简介:NMEA-0183协议是卫星定位模块最常用的数据输出格式之一,在实际车载导航、手持设备和无人机应用中经常需要解析。这套基于STM32F407ZG芯片的完整工程,专门用于解析北斗GPS多模定位模块的报文,面向嵌入式开发者和单片机学习者,可直接参考或移植到其他芯片平台。工程支持解析GNGGA、GPGSA、BDGSA、GPGSV、BDGSV、GNRMC、GNVTG等常用语句,覆盖经纬度、海拔、时间、卫星编号、信号强度、速度航向等关键定位信息,并对语句类型识别、异或校验、字段提取与数据缓存均有完整实现。压缩包共95个文件,大小约2.89MB,以C源文件、头文件、Keil工程文件、十六进制烧录文件为主,并包含编译生成的中间目标文件、依赖文件、映射文件以及启动汇编代码,方便查看完整构建过程。代码基于标准外设库编写,对串口数据接收、定时器超时判断和延时函数均有明确实现。资源已有3711人学习,拿到后可在Keil中直接打开工程,按模块阅读各报文解析函数,也能参考其状态机调度思路,快速适配不同型号的STM32或国产兼容芯片。 做嵌入式几年,凡是涉及定位设备的项目,基本绕不开NMEA-0183这套协议。最近正好在STM32F407平台上把北斗GPS模块的报文解析完整跑通了,从串口初始化、帧同步、校验和计算,到GGA/RMC字段提取、度分坐标转换,整个过程踩了不少坑,也沉淀了一套可以直接复用的解析模板。这篇就把整个工程的实现思路、代码细节和排查经验整理出来,给正在做定位项目的同学一个参考。
这套东西能解决什么问题?简单说,就是让MCU读懂北斗/GPS模块输出的串口数据,从中拿到经纬度、速度、UTC时间、卫星数这些关键信息。适合刚接触STM32与定位模块的开发新手,也适合项目上需要快速接入定位功能、又不想用一堆重量级库的老手。F407的主频和资源跑这种文本解析绰绰有余,哪怕后续要叠加显示、存储、无线上报,性能也不会成为瓶颈。
1. 项目背景与整体设计思路
1.1 为什么选STM32F407和NMEA-0183
市面上定位模块输出协议五花八门,但绝大多数消费级、工业级模块(中科微ATGM336H、u-blox NEO-M8N、正点原子ATK-S1216等)默认都支持NMEA-0183输出。这套协议是纯ASCII明文格式,每个语句以$开头、以回车换行结束,特别容易调试。有人可能会问,直接调用现成解析库不香吗?真到自己上手做,你会发现——库在特定模块上能用,换个模块、换条语句、甚至换个波特率,就要改一堆配置。自己解析的优势是透明可控,能把每一字段的含义彻底搞清楚,而且代码量很小(核心解析部分也就几百行C),对资源紧张的单片机方案更友好。
选STM32F407而不是F103,核心原因有三点:
- 主频168MHz,多个串口同时工作毫无压力,后续接4G模块、LCD屏也够用
- 自带FPU,虽然NMEA解析用不到浮点加速,但坐标换算后如果要做轨迹平滑、卡尔曼滤波,浮点运算能力就是实打实的优势
- 3.3V电平与现代GPS/北斗模块电平完全匹配,UART直连,不需要电平转换芯片
1.2 北斗GPS双模与语句格式的对应关系
现在的模块基本都是“GPS+北斗”双模,所以输出语句中用$GNRMC、$GNGGA(GN代表全球导航卫星系统)代替了传统的$GPRMC、$GPGGA(GP代表GPS单模)。解析时需要注意适配这两种前缀,否则定位模块升级切换模式后代码就失效。
以我用的ATGM336H模块为例,默认波特率9600,1Hz输出。上电后会持续输出一堆语句:$GNGGA(定位信息)、$GNRMC(推荐最小定位信息)、$GNGSV/$GPGSV(可见卫星)等等。实际工程里不需要全部解析,一般只保留GGA和RMC两个句子就够了——GGA提供经纬度、定位质量、卫星数、海拔,RMC提供UTC时间、速度、航向。
2. 硬件连接与串口初始化
2.1 引脚分配与硬件接线注意事项
F407开发板上我用的是USART1(PA9-TX、PA10-RX)来对接定位模块,因为USART1挂在APB2总线上,时钟频率84MHz,波特率配置更灵活;波特率9600这种低速场景下,APB1还是APB2影响不大,但养成用USART1做调试口、USART2做模块口的分工习惯,以后扩展更清爽。
接线尤其要注意“交叉互连”:
| 开发板F407 | 北斗GPS模块 |
|---|---|
| PA9(USART1_TX) | RXD |
| PA10(USART1_RX) | TXD |
| GND | GND |
| 5V或3.3V | VCC(看模块规格) |
模块供电要单独看手册。ATGM336H虽然标称3.3V,但很多带天线馈电的模块需要5V给有源天线供电,只给3.3V会导致搜星困难。另外,开发板和模块必须共地,否则串口数据会出现偶发乱码——这是我调试时第一个踩的坑,供电看起来没问题,但如果不共地,信号参考点不一致,UART通信就会变得不稳定。
注意:有源天线接口的模块,上电瞬间电流可以到几十毫安甚至更高,如果用开发板的3.3V LDO供电,可能会造成电压跌落导致模块复位。建议外接独立稳压供电,或者选择5V供电的模块版本。
2.2 串口初始化关键点(含时钟树设置)
用STM32CubeMX配置工程时,时钟树这里有个容易混淆的细节:USART1的波特率时钟来自APB2,而USART2/UART4等来自APB1。如果用的库函数是HAL_UART_Init(),内部自动根据PCLK计算USARTDIV,理论上不会出错;但如果你直接操作寄存器或者用了旧版标准库,就必须手动确认串口挂载的时钟频率。
以168MHz主频为例,APB2分频器如果设置成1分频,USART1时钟就是84MHz;APB1分频器设置成2分频,USART2时钟是42MHz。这两者对应的波特率寄存器初值不同,混用就会出现波特率误差。建议调试初期在CubeMX中把时钟树设为HSE 8MHz外部晶振,PLL倍频到168MHz,APB1=42MHz,APB2=84MHz,这是F407最典型的配置。
串口初始化代码用HAL库写比较简洁:
UART_HandleTypeDef huart1; void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 9600; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); }还有一个小细节:开机后串口还没配置完,模块就已经在往外吐数据了,丢掉的初始帧无关紧要,反正1秒一帧,解析系统具备快速重同步能力就好。
3. NMEA-0183报文格式拆解
3.1 语句结构与常见字段含义
NMEA-0183每条语句以$开始,后面跟5个字符的地址域(前两位是系统ID,后三位是语句ID),然后是逗号分隔的数据域,最后是*和两位十六进制校验和,以<CR><LF>结束。
拿一个实际抓到的$GNGGA语句举例:
$GNGGA,121935.000,3037.5967,N,10404.1009,E,1,6,1.45,497.8,M,0.0,M,,*64字段依次是:
121935.000:UTC时间(时、分、秒、毫秒),注意这是UTC,北京时间要加8小时3037.5967,N:纬度,度分格式,前两位30是度,后面37.5967是分,N代表北纬10404.1009,E:经度,度分格式,前三位104是度,后面04.1009是分,E代表东经1:定位质量,0=未定位,1=单点定位,2=差分定位6:参与定位的卫星数量1.45:水平精度因子HDOP497.8,M:海拔高度(米)- 空字段:差分站ID等,用双逗号表示
再看$GNRMC的关键字段:
$GNRMC,121935.000,A,3037.5967,N,10404.1009,E,0.08,136.19,150325,,,D,V*0CA:定位状态,A=有效,V=无效,这个字段比GGA的定位质量更直观0.08:地面速度(节,1节=0.5144m/s)136.19:地面航向角(度)150325:UTC日期(日/月/年)
3.2 校验和计算原理
校验和覆盖范围是从$之后到*之前的所有字符,把每个字符做异或运算,结果再转成两位大写十六进制。比如GNGGA,121935.000,...这些字符逐个异或,得到的值就是*后面的64。MCU端解析时如果校验失败,直接丢弃该帧,不要尝试做部分解析——数据帧中间只要错一位,坐标可能就会偏出几公里。
校验和的C语言实现很简单:
uint8_t nmea_calc_checksum(const char *buf, int len) { uint8_t cs = 0; for (int i = 0; i < len; i++) { cs ^= (uint8_t)buf[i]; } return cs; }这个函数是整套解析流程的地基,调用时传入的字符串要从$后面的第一个字符开始。
4. 报文解析核心实现与工程实践
4.1 串口接收与帧同步机制
解析NMEA最容易犯的错误是“按字节流直接处理”,一会儿找$,一会儿算长度,逻辑搞得特别乱。我的做法是拆成三个阶段:接收、找帧、解析。
接收阶段:用串口中断逐字节把数据放进环形缓冲区。F407的RAM完全够用,开一个512字节的环形缓冲就足够了,NMEA单条语句最长一般不超过100字节,512字节可以缓存好几条。
#define RX_BUF_SIZE 512 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_buf[rx_head] = rx_byte; rx_head = (rx_head + 1) % RX_BUF_SIZE; HAL_UART_Receive_IT(huart, &rx_byte, 1); } }注意中断回调里要重新调用HAL_UART_Receive_IT()开启下一次接收,不然只收一字节就停了。另外rx_head声明为volatile,因为主循环和中断都在访问它。
帧同步阶段:在主循环中持续检查缓冲区,按字节寻找$,找到后再继续找*,两个位置之间的数据就是有效载荷。找到*后,取后面两个字符解析成校验值,再与本地计算结果比对。这一步能用状态机实现,代码更健壮,不会因为半个帧卡死。
4.2 GGA/RMC字段提取与坐标转换
帧校验通过后,把整条语句复制到一个临时字符串里,接下来就是字段拆分。C语言里最直接的办法是手写一个按逗号切分的函数,比strtok更安全(strtok会修改原字符串,在嵌入式里容易引出隐蔽bug)。
const char* get_field(const char *str, int field_index) { int cur = 0; const char *p = str; while (cur < field_index) { p = strchr(p, ','); if (!p) return NULL; p++; cur++; } return p; // 指向目标字段首字符 }拿到一个字段的起始指针后,再用strtod/atof/strtol做数值转换。这里有个推荐做法:把目标字段复制到局部小数组,用空字符结尾,避免strtod读到下一个逗号。
坐标转换是整个解析中最重要的数值处理环节。NMEA输出的纬度是ddmm.mmmm格式,度在前两位(经度是前三位),后面的数值是分的整数部分和小数部分。转十进制度的公式是:
十进制度 = 度 + 分 / 60例如纬度3037.5967:度 = 30,分 = 37.5967,十进制 = 30 + 37.5967/60 = 30.6266117。经度同理。北纬还是南纬(N/S)、东经还是西经(E/W)由后面的字母决定,南纬和西经要取负。
double nmea_to_decimal(const char *nmea_str, int deg_digits) { double val = atof(nmea_str); int deg = (int)(val / 100.0); double min = val - deg * 100.0; return deg + min / 60.0; }deg_digits这个参数其实可以省略,因为val/100取整已经天然处理了度是两位还是三位的情况——经度也可能是10404.1009,除以100取整就是104。这个函数是我后来重构时优化的,一开始我写了两个分别处理纬度和经度的函数,后来发现一个函数足矣。
4.3 时间处理与UTC转北京时间
RMC里的时间是UTC,国内项目一般要转成北京时间(UTC+8)。转的时候要考虑跨日期问题:如果UTC时区是23点,加8小时后已经是第二天凌晨7点,日期要加1。最简单的做法是换算成秒再处理:
int utc_hh = (int)atof(rmc_time) / 10000; int utc_mm = ((int)atof(rmc_time) / 100) % 100; int utc_ss = (int)atof(rmc_time) % 100;然后统一转成“从当天0点开始的秒数”,加8*3600秒,如果超过86400就减去86400并且日期+1。这个小逻辑平时看起来不起眼,真正跑日志的时候能帮你少废半天排查时间。
完整解析流程的伪代码结构如下:
while (get_frame_from_buffer(frame)) { if (checksum_ok(frame)) { if (strstr(frame, "GNGGA") || strstr(frame, "GPGGA")) { parse_gga(frame); } else if (strstr(frame, "GNRMC") || strstr(frame, "GPRMC")) { parse_rmc(frame); } } }这样三个环节解耦后,后续要增加$GNGSA、$GNVTG等语句解析,只需要在判断逻辑里加一个分支,不影响已有代码。
5. 实测现象、常见问题与排查技巧
5.1 实测记录与定位效果验证
整个工程在开发板上跑通后,我特意用USB转串口同时接了模块原始输出和MCU解析后的结果做对照。把开发板放在窗边(金属窗框会挡信号,放缝纫机台面上实测效果明显更好),冷启动大约40秒后$GNGGA里的定位质量字段从0变成1,$GNRMC的状态位从V变成A,串口助手上能看到解析出的经纬度与手机地图对照基本一致。
这里有一个很有意思的观察:模块输出是9600波特率,但MCU解析整条语句加上坐标换算,耗时不到2ms。也就是说,即便把模块输出频率调到5Hz甚至10Hz,F407也完全跟得上,主循环里有大量时间做其他业务逻辑。
室内测试时定位状态大概率是V(无效),这不算故障。GPS和北斗信号衰减严重,钢筋水泥墙体基本能把信号吃掉。实测中要验证解析正确性,可以先把模块放窗边拿到定位,再移到室内观察数据是否持续有效;或者用一个固定位置的已知坐标来比对。
5.2 高频问题排查速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 完全收不到数据 | TX/RX接反、GND未共地、模块未供电 | 先用USB转TTL单独接模块,确认模块本身有输出 |
| 收到数据全乱码 | 波特率不匹配、电平不匹配 | 确认9600,模块是否TTL电平而非RS232 |
能收到$但解析不出坐标 | 校验和算法错误、字段索引偏移 | 用串口助手的十六进制显示,核对校验字节 |
| 模块输出帧率很低 | 天线位置差、有源天线供电不足 | 观察GSV语句数量,天线置于开阔处 |
| 数据偶发跳变 | 供电纹波过大、天线接触不良 | 加100uF电解电容,重新插拔射频天线 |
| 定位时间很长 | 冷启动特性,星历丢失 | 首次定位几十秒正常,后续热启动应<5秒 |
| UTC时间比北京晚8小时 | 未做时区转换 | 把换算逻辑放到显示层而不是解析层 |
排查时最有效的步骤是“先隔离后端”。模块输出是否正常,用USB转TTL直接看;MCU解析是否正常,用一个串口发送器模拟模块输出固定语句来测试。两端分别验证通过后,再合在一起联调,问题立刻就能定位到是哪一端。
另外提一个嵌入式常栽的坑:解析用的临时缓冲区要设成局部数组还是全局数组?我的建议是用全局数组,但解析完成立刻把结果拷贝到结构体里。这样既能避免栈溢出风险,又能在中断和主循环之间安全传递数据。定位结果建议定义成这样的结构体:
typedef struct { uint8_t valid; // 定位是否有效 double latitude; // 十进制纬度,北正南负 double longitude; // 十进制经度,东正西负 float speed_kn; // 速度(节) float course_deg; // 航向(度) uint8_t utc_hour, utc_min, utc_sec; uint8_t sat_num; uint16_t date; // 格式 YYYYMMDD 或直接用 RTC 处理 } gps_data_t;6. 工程扩展:从解析到应用
解析本身不是目的,数据被业务用起来才算闭环。我在这套工程基础上做了两个小扩展,一个是在解析到有效坐标后驱动OLED显示经纬度和速度,另一个是通过另一个串口把坐标打包成自定义协议发给上位机。如果项目里需要把定位数据存到SD卡,建议用FATFS时把写入操作放到任务队列,避免在解析主循环里直接做耗时写卡。
还有一点值得提醒:模块输出的坐标基准是WGS-84坐标系,在国内地图(GCJ-02火星坐标系)上直接标点会有几百米的偏移。这个问题在纯MCU工程里容易被忽视,如果上位机用地图API做轨迹显示,得在服务端或MCU里做坐标纠偏。这个项目先不展开,但做定位项目时一定要提前问清楚数据最终用在哪个平台上。
调试的时候我养成了一个习惯:把原始NMEA语句原封不动地存到调试日志里,而不是只打印解析结果。这样做有个好处,出现定位异常时,可以直接拿原始语句放到PC端工具里做交叉验证,判断是模块输出问题还是MCU解析问题,省去很多猜测时间。这个习惯救过我很多次,尤其在模块固件版本升级导致输出格式微调的时候。
最后再分享一个小技巧:模块输出的语句里,$GNGGA和$GNRMC并非同时有效,有时候GGA已经定位成功但RMC还是V。判断定位是否可用,建议以RMC的状态位A为准,因为RMC里的经纬度与速度航向是同一个时间断面的数据,逻辑上更一致。项目做到后期,这些细小的判断标准往往决定了数据的可靠性。
本文还有配套的精品资源,点击获取