ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32F103与ATGM336H-5N:NMEA协议解析和串口接收实现

STM32F103与ATGM336H-5N:NMEA协议解析和串口接收实现 简介ATGM336H-5N与STM32F103联合开发例程包面向物联网、工业自动化和智能家居等应用场景的嵌入式开发者。资源围绕无线模块与MCU的协作提供从硬件连接、软件配置到数据收发、错误处理的全套示例帮助使用者快速掌握ATGM336H-5N的初始化流程和基于STM32F103标准外设库的驱动编写。压缩包共134个文件大小仅1.87MB包含33个h头文件、32个c源码文件、Keil工程文件uvproj/uvopt、可烧录的hex、编译调试用的axf/map以及批量清理脚本bat等从源码到下载验证的链路一目了然。已有2971人学习浏览适合正在调试同类无线模块或希望借助例程快速上手STM32F103的开发者。通过阅读源码结构、对照硬件接口并直接烧录hex可显著缩短前期环境搭建与模块配置的时间工程内保留的标准外设库文件也为后续功能扩展或移植到其他STM32型号提供了现成参考。1. ATGM336H-5N 在 STM32F103 上要先解决的不是定位而是串口帧零售版 ATGM336H-5N 模块上电就开始工作不要求单片机做任何初始化也没有寄存器可读。单片机看到的只是一串每秒出现一次的文本行以$开头、回车换行结束逗号分隔字段这就是 NMEA 0183 协议。所以用 STM32F103 驱动这个模块例程的本质只有两个动作把完整的 NMEA 语句接收下来再按帧类型解析出经纬度、UTC 时间和定位质量。接收环节要处理波特率、中断缓冲、帧尾判断和异或校验解析环节要处理空字段和坐标从度分到十进制的换算。下面的例程按一块 STM32F103C8T6 最小系统来写USART2 接模块USART1 留作调试输出代码可直接抄进标准外设库 V3.5 工程。2. ATGM336H-5N 引脚与 USART2 配置9600 8N1 只是第一步2.1 先确认 ATGM336H-5N 的核心引脚常见的 ATGM336H-5N 底板把引脚排成两排插针功能上真正需要接的只有五根电源、地、串口发送、串口接收、秒脉冲。典型接线如下表。ATGM336H-5N 引脚方向说明接到 STM32F103VCC电源输入模块核心电压 3.3V典型电流约 30-40mA3.3V 电源轨GND地必须与单片机共地GNDTXD输出TTL 电平模块发送 GPS 数据PA3USART2_RXRXD输入TTL 电平用于配置模块PA2USART2_TXPPS输出秒脉冲定位成功后可观察到每秒一次跳变暂时悬空或接 PA8这里有两个容易出错的地方。第一如果买的是带底板的小模块板上通常已经有 3.3V LDO可以用 5V 供电如果是裸板供电电压不能超过 3.6V。第二串口是 TTL 电平接 USB-TTL 或者 STM32F103 都可以直连不需要 MAX232也不需要额外电平转换。RXD 只有在需要修改模块配置时才用如果你只是要读取定位结果这一根可以不接。2.2 把 GPS 放在 USART2调试输出放在 USART1用标准外设库写例程时printf 默认重定向到 USART1 是多数工程的习惯。如果 GPS 也放在 USART1调试输出和定位数据就会混在同一条串口上。常见做法是把 ATGM336H-5N 接在 USART2 上USART2 的 TX/RX 是 PA2/PA3刚好不占用 USART1 的 PA9/PA10。如果你手里的硬件已经飞线到其他串口那也不需要改逻辑只需要换 GPIO、RCC 时钟和中断处理函数名。值得注意的一点是 USART1 挂在 APB2 总线上USART2 和 USART3 挂在 APB1 总线上两条总线的时钟频率不同这会影响波特率分频。串口1和串口3的使用差异也主要集中在这里换到 USART3 时时钟使能函数要换成RCC_APB1Periph_USART3中断处理函数换成USART3_IRQHandler。2.3 标准外设库的 USART2 初始化代码以下初始化函数把 USART2 配置为 9600bps、8 数据位、1 停止位、无校验并使能接收中断。ATGM336H-5N 出厂默认波特率一般就是 9600但部分卖家会预先改成 115200遇到乱码时两个值都要试。#include stm32f10x.h void GPS_UART_Init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // PA3 作为 USART2_RX gpio.GPIO_Pin GPIO_Pin_3; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); // PA2 作为 USART2_TX gpio.GPIO_Pin GPIO_Pin_2; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); usart.USART_BaudRate 9600; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_HardwareFlowControl USART_HardwareFlowControl_None; usart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, usart); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); nvic.NVIC_IRQChannel USART2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); USART_Cmd(USART2, ENABLE); }PA3 配置成浮空输入是因为模块 TXD 空闲时是标准高电平浮空输入可以直接采样如果你所在的现场电磁干扰明显可以改用GPIO_Mode_IPU加上拉。波特率这一项是整个例程里最容易被忽略的USART2 的时钟来自 PCLK1如果外部晶振用的是 8MHz 而系统时钟配置成了 36MHz 或 72MHzBRR 分频值会不一样但 9600 这个参数本身不会变。上电后如果收到的全是乱码先打开调试串口打印 PCLK1 的频率再确认模块输出波特率。2.4 上电后模块到底输出什么把模块用 USB-TTL 直接接到电脑串口助手设置 9600 8N1可以看到类似下面的内容$GNRMC,081508.00,A,3107.12345,N,12122.54321,E,0.02,0.00,010725,,*hh $GNGGA,081508.00,3107.12345,N,12122.54321,E,1,08,0.9,10.5,M,,M,,*hh $GNGSA,A,3,07,10,12,14,19,22,28,32,,,,,1.2,0.9,0.8*hh $GNGSA,A,3,08,11,12,15,,,,,,,,,1.2,0.9,0.8*hh $GPGSV,3,1,11,03,22,133,31,05,22,220,26,07,28,048,30,08,07,308,28*64 $BDGSV,1,1,02,12,31,112,30,15,29,221,32*6D帧名前缀是GN表示 GPS 和北斗联合定位GP表示纯 GPSBD表示纯北斗。例程要处理的重点是GNRMC和GNGGA前者有经纬度、地速、航向和日期后者多一个定位质量标志位和卫星数。*后面的两位十六进制是校验和由$之后到*之前的所有字节异或得到。串口助手能弹出这些语句说明模块硬件、天线和串口参数都没问题接下来才是单片机侧的工作。3. 例程的接收层用状态机在 STM32F103 里收完一帧 NMEA 再做校验3.1 为什么用中断状态机而不是 DMA 和环形缓冲ATGM336H-5N 默认 1Hz 输出每秒最多十几条语句总数据量不超过 1KB。这个量级用 USART2 接收中断完全够CPU 占用几乎可以忽略。DMA 加 IDLE 的方式在大量突发数据的场景有意义但对 GPS 属于过度设计。真正需要避免的反模式是在中断里把每个字节都推进环形缓冲区再在主循环里慢慢找$。因为如果主循环处理不及时环形缓冲区被写满旧的帧会被覆盖最终看起来像“丢语句”。按帧做状态机的思路更直接等$存字符等\n一整帧就结束了。接收方式适用场景在 1Hz GPS 上的表现单字节中断低速周期性数据实现简单CPU 占用可忽略DMA 加 IDLE高速突发数据需要额外管理半满和全满中断代码量更大3.2 中断接收状态机代码下面这段代码放在stm32f10x_it.c或者主文件里只要保证函数名是USART2_IRQHandler即可。#define NMEA_BUF_SIZE 128 volatile uint8_t nmea_rx_state 0; // 0: 等待 $, 1: 正在收帧 volatile uint8_t nmea_buf[NMEA_BUF_SIZE]; volatile uint16_t nmea_len; volatile uint8_t frame_ok; // 收完一帧置 1 void USART2_IRQHandler(void) { uint8_t ch; if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { ch USART_ReceiveData(USART2); if (nmea_rx_state 0) { if (ch $) { nmea_rx_state 1; nmea_len 0; } } else { if (ch \n) { nmea_buf[nmea_len] \0; nmea_rx_state 0; frame_ok 1; } else if (ch \r) { // NMEA 帧以 CRLF 结束丢弃回车符 } else if (nmea_len NMEA_BUF_SIZE - 1) { nmea_buf[nmea_len] ch; } else { nmea_rx_state 0; // 超长视为坏帧丢弃 } } } }状态机只有两个状态等待$和收帧中。收到\n时帧结束此时nmea_buf里存放的是从$开始到*XX的完整内容不含\r\n。nmea_buf会以\0结尾方便后面的字符串解析函数直接使用。\r被丢弃因为有些模块输出\r\n有些只输出\n统一按\n判断帧尾兼容性最好。NMEA_BUF_SIZE是 128 字节ATGM336H-5N 最长的一条 GSV 语句也不会超过这个值。3.3 主循环里如何取走一帧frame_ok是在中断里置位的主循环检测到之后要把缓冲区拷贝出来再解析。拷贝动作最好关中断否则中断可能刚好在两行 C 代码之间写入新数据导致解析端看到半个帧。uint8_t gps_frame[NMEA_BUF_SIZE]; uint16_t gps_frame_len; while (1) { if (frame_ok) { __disable_irq(); memcpy(gps_frame, (uint8_t *)nmea_buf, nmea_len); gps_frame_len nmea_len; frame_ok 0; __enable_irq(); if (nmea_checksum_ok((char *)gps_frame, gps_frame_len)) { gps_parse_gga((char *)gps_frame, gps_data); } } }这里关中断的窗口非常短只有一次 memcpy 和两个赋值对系统实时性几乎没有影响。如果你在工程里同时用了 FreeRTOS更合适的做法是把frame_ok放进队列或者用taskENTER_CRITICAL但原理是一样的取帧操作不能和中断写缓冲并发。gps_frame是普通全局数组解析完这一帧后下一帧到来前它不会被覆盖。3.4 NMEA 异或校验的实现NMEA 校验规则是对$之后到*之前的所有字符做异或结果写成两位大写十六进制放在*后面。很多人会把$也算进去或者漏掉最后一个逗号这类错误最常见的表现是解析偶尔成功、偶尔失败。static uint8_t hex2val(char c) { if (c 0 c 9) return (uint8_t)(c - 0); if (c A c F) return (uint8_t)(c - A 10); if (c a c f) return (uint8_t)(c - a 10); return 0; } int nmea_checksum_ok(const char *frame, int len) { uint8_t cs 0; int i 1; // 跳过 $ while (i len frame[i] ! *) { cs ^ (uint8_t)frame[i]; i; } if (frame[i] ! *) return 0; if (i 2 len) return 0; return (uint8_t)((hex2val(frame[i 1]) 4) | hex2val(frame[i 2])) cs; }hex2val同时兼容大写和小写十六进制实际模块输出的是大写。校验通过后再进解析函数坏帧直接丢弃这样解析代码里就不需要再处理$GNGGA...*ZZ这种被干扰的脏数据。判断i 2 len是为了防止把校验位截断的残帧误判为合法帧。如果你在串口线上看到校验频繁失败往往不是代码问题而是模块供电不足或者天线接头接触不良这点留到最后一章调试验证时再展开。4. 解析 $GNGGA把 ATGM336H-5N 的经纬度转成十进制坐标4.1 按逗号索引取字段而不是 strtokNMEA 帧是逗号分隔的听起来很适合strtok但实际用的时候会踩坑某些字段允许为空比如 GGA 里的差分站 ID、RMC 里的模式指示连续两个逗号出现时strtok会直接跳过空字段导致后面的字段索引全部错位。因此例程里一般写一个按索引扫描逗号的工具函数返回字段起始地址和长度。下面的nmea_field就是这个作用。static int nmea_field(const char *frame, int index, const char **start, int *flen) { int cur 0; const char *p frame; while (*p *p ! * *p ! \n) { if (cur index) { *start p; while (*p *p ! , *p ! * *p ! \n) p; *flen (int)(p - *start); return 1; } if (*p ,) cur; p; } return 0; }index从 0 开始0 号字段是GNGGA或GNRMC这种帧名1 号是 UTC 时间2 号是纬度以此类推。函数不会修改原始缓冲区只返回指针所以调用时零拷贝。遇到空字段时*flen会是 0调用方要主动判断这是处理 NMEA 数据的基本修养。4.2 $GNGGA 的字段映射$GNGGA 是例程的主解析帧下表列的是实际会用到的最重要字段。字段索引示例值含义处理方式0GNGGA帧名判断帧类型1081508.00UTC 时间转时分秒23107.12345纬度度分格式转十进制度3N北纬或南纬S 时取负412122.54321经度度分格式转十进制度5E东经或西经W 时取负61定位质量0 无效1 单点2 差分708参与定位的卫星数直接显示帧名判断不要用strcmp(frame, $GNGGA)因为模块可能在纯 GPS 模式下输出$GPGGA。更稳的写法是判断frame 3开头的三个字符是不是GGA这样$GNGGA、$GPGGA都可以被识别。4.3 度分格式转十进制度3107.12345表示北纬 31 度 07.12345 分不是 31.0712345 度。转换成十进制度的公式是“度 分 / 60”。最简单的实现是把整个字段读成浮点数再拆出度和分static float nmea_deg_to_dec(const char *s, int len) { float raw; int deg; char tmp[16]; if (len 0 || len (int)sizeof(tmp)) return 0.0f; memcpy(tmp, s, len); tmp[len] \0; raw atof(tmp); // 3107.12345 deg (int)(raw / 100.0f); // 31 return (float)deg (raw - (float)deg * 100.0f) / 60.0f; }使用atof在这个场景是合理的1Hz 定位数据的解析量很小Cortex-M3 虽然没 FPU但软件浮点的耗时可以不考虑。提示float只有约 7 位有效数字转换3107.12345时会损失最后两位引起约 1 米误差。需要厘米级以上精度时建议保留字符串并在 PC 端后处理。完整的 GGA 解析函数如下typedef struct { uint8_t utc_hour, utc_min, utc_sec; float lat; float lon; uint8_t fix; uint8_t sv; } gps_data_t; static uint8_t parse_u8(const char *s, int len) { uint8_t v 0; int i; for (i 0; i len; i) { if (s[i] 0 || s[i] 9) break; v (uint8_t)(v * 10 (s[i] - 0)); } return v; } int gps_parse_gga(const char *frame, gps_data_t *gps) { const char *p; int len; if (strncmp(frame 3, GGA, 3) ! 0) return 0; if (nmea_field(frame, 1, p, len) len 6) { gps-utc_hour (uint8_t)((p[0] - 0) * 10 (p[1] - 0)); gps-utc_min (uint8_t)((p[2] - 0) * 10 (p[3] - 0)); gps-utc_sec (uint8_t)((p[4] - 0) * 10 (p[5] - 0)); } if (nmea_field(frame, 2, p, len)) { gps-lat nmea_deg_to_dec(p, len); } if (nmea_field(frame, 3, p, len) len 1) { if (p[0] S) gps-lat -gps-lat; } if (nmea_field(frame, 4, p, len)) { gps-lon nmea_deg_to_dec(p, len); } if (nmea_field(frame, 5, p, len) len 1) { if (p[0] W) gps-lon -gps-lon; } if (nmea_field(frame, 6, p, len) len 1) { gps-fix (uint8_t)(p[0] - 0); } if (nmea_field(frame, 7, p, len)) { gps-sv parse_u8(p, len); } return 1; }这里用了两次nmea_field调用拿纬度和南北标志是因为这两个字段之间隔着一个逗号分开解析比一次解析更方便判断 N/S。fix字段如果拿不到应该保持为 0不要让结构体里的数据被上一帧残留值污染。如果要在工程里长期使用建议在进入解析函数前把gps结构体整体清零。4.4 UTC 时间转北京时间与 RMC 里的附加信息$GNGGA的时间是 UTC比北京时间慢 8 小时。在北京时间的应用里需要加 8 小时并处理跨天void gps_utc_to_beijing(gps_data_t *gps) { gps-utc_hour 8; if (gps-utc_hour 24) { gps-utc_hour - 24; // 日期进位需要用 RMC 的 DDMMYY 字段单独处理 } }跨天进位不能只看时间还要改日期所以实际工程里通常同时解析$GNRMC把它的日期字段和 UTC 时间合在一起。RMC 的第 7 个字段是地速单位为节转 km/h 要乘 1.852第 8 个字段是航向角0-359.9 度。如果只需要经纬度GGA 就够了如果要做车载轨迹或航向显示RMC 不可跳过。5. 上板联调怎么确认 ATGM336H-5N 的 STM32F103 例程真的在定位5.1 先把模块拿到 USB-TTL 上验证模块接入单片机之前先用 USB-TTL 转串口接到电脑。这一步是为了把故障范围切成两半如果串口助手能看到每秒一条的 NMEA 数据说明模块供电、天线、波特率都正常问题大概率在单片机侧如果串口助手都没有数据就不要去调 STM32F103 代码先检查模块电源和天线方向。ATGM336H-5N 冷启动需要大约 30 秒上电后前几十秒属于搜星状态这时候输出里会出现fix0或者$GNRMC的定位状态是V这不算故障。5.2 转发原始帧到调试串口同时检查 PPS把接收到的原始帧通过 USART1 打印出来是排查解析问题最有效的方法。先重定向 printfint fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }然后在主循环里把gps_frame原样输出打印内容与串口助手保持一致就能确认单片机确实收到了 ATGM336H-5N 的完整帧。如果单片机侧收到的帧和你用 USB-TTL 看到的帧一致只是解析不到数据问题就在解析函数如果收到的帧断断续续先怀疑接线太长或供电压降。第二个确认手段是 PPS 引脚定位成功后它会输出 1Hz 脉冲用示波器测试没有示波器就接一个 LED 到 PA8 观察闪烁频率。5.3 用统计计数替代“看一眼有没有数据”把校验通过帧数和总帧数都统计出来每秒通过调试串口打印一次。正常状态下总帧数约 10-15 条校验失败率为 0如果失败率超过 1%大概率是地线接触不良或者模块供电线过细造成地电位漂移。这个统计方法比单纯看“有没有定位数据”更早暴露信号完整性问题也是例程里最值得长期保留的一段调试代码。本文还有配套的精品资源点击获取
返回列表