简介:面向嵌入式开发与单片机学习者的完整NMEA-0183协议解析工程,以STM32F407ZG为验证平台,实现北斗/GPS多模模块的报文解析,覆盖GNGGA、GPGSA、BDGSA、GPGSV、BDGSV、GNRMC、GNVTG等常见语句。压缩包共95个文件,以h/c源码头文件、o/crf等编译中间文件、uvprojx工程配置、sct链接脚本及hex烧录文件为主,整体仅2.89MB,便于直接打开、编译与烧录验证。相比零散代码,其目录结构清晰,外设配置(USART、定时器)与解析逻辑分离,读者可快速定位GNSS数据提取、校验和计算、字段拆分等关键环节,也能参考其工程组织方式,方便迁移到其他STM32型号。目前已有3711人浏览学习,适合刚接触STM32或GNSS解析的开发者作为入门参考,也适合需要快速集成定位功能的项目复用。 北斗和GPS的报文解析,在嵌入式里是个老生常谈但又绕不开的话题。只要你的板子要定位、要授时、要记录轨迹,基本就绕不过NMEA-0183这套协议。最近我基于STM32F407把一套完整的解析工程整理了出来,从串口数据接收到经纬度、时间、速度的字段提取全部跑通,这篇文章就把整个思路和代码逻辑摊开来讲,包括我踩过的几个坑。
这套工程的核心并不复杂:北斗GPS模块上电后,会以固定的频率通过串口向外输出一行一行的NMEA语句,我们只需要在STM32F407上用一个USART把数据收进来,然后按行、按逗号切出关键字段,再转换成我们需要的浮点数、整型或者是时间结构体。真正有点讲究的地方在于:数据接收的可靠性、帧提取的边界处理、以及经纬度这种特殊格式的换算。
如果你手里有STM32F407的开发板(或者是探索者V2/V3,这个不确定的话直接看板子上的丝印和主芯片旁边是否有V2/V3标识就行),又恰好想给自己的项目加上定位功能,那这篇文章适合你从头到尾看一遍。哪怕你用的是其他型号的STM32,串口部分的逻辑也是通用的,只需要改一下时钟配置和引脚映射。
1. 项目总体思路与协议选型分析
1.1 为什么选择STM32F407做主控
STM32F407这颗芯片在定位类项目里其实有点“杀鸡用牛刀”的意思,但恰恰因为它的资源足够丰富,才让整个工程的调试变得省心。
首先是串口资源多,F407有6个USART/UART。GPS模块占用一个串口用于收数据,我们还可以留出另一个串口做调试打印,两个互不干扰。这在开发阶段特别重要——你可以一边在串口助手里查看原始NMEA语句,一边在另一个串口看解析后的结构化结果,方便对照排查。如果换一颗串口资源少的芯片,比如很多小封装型号只有两三个串口,调试起来就会束手束脚。
其次是主频和浮点运算能力,F407主频168MHz,带FPU(硬件浮点单元)。虽然解析NMEA报文本身用不到太多浮点运算,但在做经纬度格式转换时会有小数的乘除运算。比如把ddmm.mmmm这种度分格式转成十进制度数,公式是“度 + 分/60”,涉及浮点除法和乘法。有了硬件FPU,这类运算都是几个周期完成,毫无压力。
再有就是F407的生态和资料非常全,不管是寄存器版还是HAL库版,网上能查到的参考资料非常多。探索者开发板V2和V3的区别主要在于板载外设和走线布局,核心芯片逻辑是一样的,本文的工程代码在V2/V3上都可以直接使用。如果用的不是探索者板子,只要你的F407最小系统板引出了USART1和USART2,同样可以跑。
1.2 NMEA-0183协议里究竟都有什么
NMEA-0183是美国国家海洋电子协会制定的串行通信协议标准,最早用于海洋电子设备之间的通信,后来被广泛应用在GPS、北斗、GLONASS等卫星导航接收机的数据输出上。它的格式很简单:每一条语句以$开头,以\r\n结尾,中间用逗号分隔各个字段。
北斗GPS双模模块输出的语句一般包括GGA、RMC、GSV、GSA、VTG等类型。其中对我们普通用户最有用的两条是:
$GNRMC:推荐最小定位信息,包含时间、定位状态、经纬度、速度、航向、日期。一条语句几乎覆盖了所有核心数据。$GNGGA:全球定位系统固定数据,包含时间、经纬度、定位质量、卫星数量、海拔高度。适合用来获取定位质量指标和海拔。
其他语句比如$GNGSV是可见卫星信息,$GNGSA是精度因子和活跃卫星编号,$GNVTG是地面速度矢量。这些语句在工程里可以解析,但优先级相对低,一般日志类应用才会用到完整解析。
需要特别注意的是,不同模块输出的语句前缀可能不一样。有的模块输出$GPRMC(纯GPS)或$BDRMC(纯北斗),有的输出$GNRMC(双模)。北斗GPS双模模块通常在双模工作模式下输出$GN开头。所以代码里做帧头匹配时,最好不要只匹配固定前缀,可以做一个通用匹配:凡是类型字段为RMC或GGA的都接收。
2. 硬件连接与开发环境准备
2.1 北斗GPS模块的选型建议
市面上常见的北斗GPS模块有ATGM336H、NEO-M8N、中科微的GNS3308等。我个人用得比较多的是ATGM336H,原因很简单:便宜、双模、串口直接输出NMEA语句、功耗低。它和NEO-M8N的引脚基本兼容,都是串口TTL电平输出,可以直接接STM32F407的USART引脚。
选型时要确认三件事:
- 模块输出的电平是TTL还是RS232。绝大多数北斗GPS模块是TTL电平,可以直接连单片机串口。如果是RS232电平的老模块,需要额外加MAX232做电平转换。
- 模块的默认波特率。大部分默认9600,也有部分默认115200。这个参数必须和STM32串口初始化配置一致,否则收到的全是乱码。
- 模块的上电时间。冷启动状态下,模块可能需要几十秒才能完成首次定位,所以工程里要设计一个“等待定位成功”的处理流程,不能一上电就期望立刻有有效数据。
ATGM336H的电路很简单,VCC接3.3V,GND接地,TXD接STM32的RX引脚,RXD接STM32的TX引脚(如果需要向模块发送配置命令的话)。如果板上没有走线把模块的TXD和STM32的USART1_RX连在一起,就需要自己飞线。注意模块的TXD接单片机的RX,交叉连接,这个方向接反是新手最容易犯的错误。
2.2 串口引脚映射与时钟配置要点
我用的引脚分配是:
- USART1:TX = PA9,RX = PA10,用于连接北斗GPS模块。
- USART2:TX = PA2,RX = PA3,用于调试打印解析结果。
用USART1接GPS是因为它挂在APB2总线上,时钟最高可以到84MHz,波特率配置误差更小。USART2挂在APB1上,时钟42MHz,调试打印完全够用。
时钟树配置时需要特别留意APB1和APB2的时钟频率,这直接决定了串口波特率寄存器BRR的写入值。STM32F407的时钟树不算复杂:外部晶振8MHz通过PLL倍频到168MHz系统主频,AHB预分频为1,则HCLK为168MHz;APB1预分频为4,则APB1外设时钟为42MHz;APB2预分频为2,则APB2外设时钟为84MHz。
串口波特率计算公式是:
BRR = 外设时钟 / 波特率如果外设时钟配置错了,波特率就是错的。比如USART1若错误地按42MHz计算9600波特率,实际BRR写入4375,但真实时钟是84MHz,实际波特率会变成19200,自然收不到正常数据。所以遇到串口乱码、收不到数据时,优先检查外设时钟配置是否正确。我用STM32CubeMX生成初始化代码时会直接确认这两路外设时钟的具体数值,心里有底了再往下写。
3. 报文接收与解析核心实现
3.1 串口数据接收:为什么必须用中断+缓冲区
很多人刚开始做GPS解析时,习惯用阻塞式接收,也就是在while循环里不断调用HAL_UART_Receive。这种做法在简单验证时没问题,但一旦你的系统里还有别的任务要跑——比如刷新OLED屏幕、处理按键、控制LED闪烁——就会很痛苦。模块每秒输出好几条语句,每条语句几十到上百字节,如果主循环一直在等串口数据,其他任务就全卡死了。
正确做法是:串口接收中断 + 环形缓冲区。中断每收到一个字节,就把数据放进缓冲区里;主循环负责从缓冲区取数据做解析。这样串口接收不占用主循环时间,解析也不会丢数据。
环形缓冲区的实现不复杂,就是一个数组加读写索引,读索引和写索引相等时缓冲区为空。比较关键的点是:缓冲区大小必须足够容纳模块一帧最多长度。NMEA语句最长的一般是GSV语句,可能超过80字节,RMC和GGA也有70字节左右。为了保险,缓冲区建议开256字节甚至512字节。
串口中断接收的HAL库写法是:
// 使能USART1接收中断 HAL_UART_Receive_IT(&huart1, &rx_data, 1);然后在中断回调里把数据写入环形缓冲区:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint8_t byte = rx_data; // 写入环形缓冲区的空闲位置 uint16_t next = (rx_buf.tail + 1) % RX_BUF_SIZE; if (next != rx_buf.head) { rx_buf.data[rx_buf.tail] = byte; rx_buf.tail = next; } // 重新使能接收中断 HAL_UART_Receive_IT(&huart1, &rx_data, 1); } }这里有一个细节:在回调函数里再次调用HAL_UART_Receive_IT,是因为HAL库接收完指定字节数后会自动关闭中断,必须重新使能才能继续接收。如果忘了这一步,串口只会收到第一个字节就再也不进了。这是我见过最多人踩的坑。
3.2 帧提取:从字节流中切出完整的一句
环形缓冲区里的数据是连续的字节流,我们需要从中找到一个个完整的NMEA语句。NMEA语句以$开头,以\r\n结尾。所以帧提取逻辑可以设计成状态机:
- 初始状态:等待
$字符。 - 找到
$后,开始把后续字符暂存到行缓冲区。 - 每收一个字符都检查是否为
\n(或\r\n组合),如果是,则一行结束,把这一行交给解析函数。 - 重复上述过程。
这个状态机的处理位于主循环里,每轮循环检查缓冲区是否有新数据,有则按字节处理。关键在于行缓冲区的长度要足够长(比如100字节),并且要防止溢出——如果收到了超过100字节还没见到结束符,说明这一帧有问题,直接丢弃重置,避免脏数据污染后续解析。
帧提取的伪代码逻辑如下:
// 从环形缓冲区取一个字节,送入状态机 uint8_t byte; while (ring_buffer_read(&rx_buf, &byte)) { if (state == STATE_WAIT_START) { if (byte == '$') { line_len = 0; line_buf[line_len++] = byte; state = STATE_RECEIVING; } } else if (state == STATE_RECEIVING) { if (byte == '\n' && line_len > 0) { line_buf[line_len] = '\0'; parse_nmea_line(line_buf); // 解析这一行 state = STATE_WAIT_START; } else if (line_len < LINE_BUF_SIZE - 1) { line_buf[line_len++] = byte; } else { // 行长度溢出,丢弃并重置 state = STATE_WAIT_START; } } }3.3 字段解析:完整实现RMC和GGA的提取
拿到一行完整的NMEA语句后,首先要判断语句类型。简单的方法是用strstr在字符串里查找"RMC"或"GGA",但更严谨的做法是检查第4和第5个字符。比如$GNRMC的字符序列是$GN后跟RMC,当然也有$GPRMC或$BDRMC。
为了兼顾不同模块输出的前缀差异,我的做法是:先找到第一个逗号的位置,然后看逗号前面的内容是否以"RMC"结尾。RMC语句的结构是:
$GNRMC,hhmmss.sss,A,ddmm.mmmm,N,dddmm.mmmm,E,速度,航向,日期,磁偏角,磁偏角方向,A*校验和具体字段含义:
- 字段0:UTC时间,格式为hhmmss.sss。
- 字段1:定位状态,A表示有效定位,V表示无效。
- 字段2:纬度,格式为ddmm.mmmm(度分格式)。
- 字段3:纬度方向,N或S。
- 字段4:经度,格式为dddmm.mmmm。
- 字段5:经度方向,E或W。
- 字段6:地面速度,单位为节。
- 字段7:地面航向,单位为度。
- 字段8:UTC日期,格式为ddmmyy。
解析时最方便的是用strtok按逗号分割字段,但要注意strtok会修改原字符串,所以要先拷贝一份行数据再操作。我封装了一个简单的获取字段函数,每次定位到第n个逗号和下一个逗号之间,拷贝出字段字符串。这样代码更可控,也不用担心strtok在中断里被调用(虽然我这里解析放在主循环)。
经纬度的度分格式转换是重点。比如RMC字段里的纬度是3109.5623,表示31度09.5623分。转成十进制度数的公式是:
十进制纬度 = 31 + 09.5623 / 60 = 31.1593716667注意分的小数部分是60进制,不是100进制。这一步做错的话,定位结果会偏出去好几公里。方向字符N/S决定纬度正负号,北纬为正,南纬为负;E/W决定经度正负号,东经为正,西经为负。
时间字段的处理也要细心。RMC里的时间是UTC时间,北京时间比UTC快8小时,需要加8小时换算。但注意不能简单地把“时分秒”数值加8,因为小时可能溢出到24小时以上。正确的处理是转成秒再转换:
uint32_t total_seconds = hour * 3600 + minute * 60 + second; total_seconds += 8 * 3600; // UTC转北京时间 total_seconds %= 86400; // 超过24小时则取模 uint8_t bj_hour = total_seconds / 3600; uint8_t bj_min = (total_seconds % 3600) / 60; uint8_t bj_sec = total_seconds % 60;日期字段的格式是ddmmyy,分别提取日、月、年,注意年份只有两位,通常加上2000转换成完整年份。
下面是RMC语句解析的核心实现:
int parse_rmc(char *line, gps_info_t *gps) { // 定位状态 char *p = line; // 跳过 "$GNRMC" 前导部分 // 逐个字段提取 char *field[13]; int field_cnt = 0; char *token = p; while (field_cnt < 13 && token != NULL) { if (field_cnt == 0) { token = strchr(token, ','); if (token) { token++; field[field_cnt++] = token; } } else { field[field_cnt++] = token; token = strchr(token, ','); if (token) { *token = '\0'; // 把逗号替换为字符串结束符 token++; } } } if (field_cnt < 10) return -1; // 定位状态 if (field[1][0] != 'A') { gps->valid = 0; return -1; } // 解析时间 hhmmss.sss uint32_t hh = (field[0][0]-'0')*10 + (field[0][1]-'0'); uint32_t mm = (field[0][2]-'0')*10 + (field[0][3]-'0'); uint32_t ss = (field[0][4]-'0')*10 + (field[0][5]-'0'); // 解析纬度 ddmm.mmmm double lat_deg = (field[2][0]-'0')*10 + (field[2][1]-'0'); double lat_min = atof(field[2] + 2); double latitude = lat_deg + lat_min / 60.0; if (field[3][0] == 'S') latitude = -latitude; // 解析经度 dddmm.mmmm double lon_deg = (field[4][0]-'0')*100 + (field[4][1]-'0')*10 + (field[4][2]-'0'); double lon_min = atof(field[4] + 3); double longitude = lon_deg + lon_min / 60.0; if (field[5][0] == 'W') longitude = -longitude; // 解析速度(节转公里每小时) double speed_knot = atof(field[6]); gps->speed_kmh = speed_knot * 1.852; // 解析日期 ddmmyy gps->day = (field[8][0]-'0')*10 + (field[8][1]-'0'); gps->month = (field[8][2]-'0')*10 + (field[8][3]-'0'); gps->year = 2000 + (field[8][4]-'0')*10 + (field[8][5]-'0'); gps->valid = 1; return 0; }3.4 避免浮点开销的整型解析方案
上面的代码用了atof把字符串转成浮点数,在F407的FPU帮助下其实也没问题。但如果你用的芯片不带FPU,或者你觉得atof在库函数调用上有开销,可以用整型来解析经纬度。
思路是:把ddmm.mmmm的整数部分拆成“度”和“分”两个整型,小数部分按毫分(1/1000分)来处理。比如纬度3109.5623,度是31,分是09,分的毫分部分是5623。存储时用int32_t存一个“毫度”值:
毫度 = 度 * 1000 + 分 * 1000 / 60这样处理后,整个工程就不涉及浮点运算了,在低端MCU上也能跑。但代价是代码可读性下降,我个人在F407上还是倾向直接用浮点,毕竟硬件FPU不是白给的。不过了解整型方案有个好处:如果将来要移植到8位MCU或者不带FPU的Cortex-M0上,可以直接切换过去。
4. 常见问题与排查技巧实录
4.1 串口收到的全是乱码
这个问题90%是波特率不匹配。先确认模块的默认波特率,再确认程序里USART1的初始化波特率。两者一致才会正常。我遇到过一种特殊情况:模块默认9600,但因为在调试别的功能时写过配置命令让模块改成了115200,重新上电后模块又恢复成默认配置。这时不要盲目怀疑程序,先把模块的TXD直接用USB转TTL工具接到电脑串口助手,看原始输出是什么波特率,再回头改程序。
另一个原因是时钟树配置错误。USART1和USART2挂在不同的APB总线上,若总线上外设时钟算错,BRR就会算错。检查CubeMX生成的SystemClock_Config,确认APB1和APB2的外设时钟频率。注意USART1在APB2上,USART2/3/4/5/6在APB1上,这点经常被忽略。
4.2 能收到数据,但解析出来是空的
这种情况一般是帧匹配出了问题。检查两点:
- 行缓冲区的指针传递是否正确,是否在回调函数里意外修改了共享变量。环形缓冲区的读写指针建议定义为文件级静态变量,并且只在主循环和中断里分别访问各自的索引。如果中断里写指针,主循环里读指针,两个索引的修改要保证原子性——在Cortex-M4上,单字节写入是原子的,但多字节操作可能会有中断竞争,所以处理时可以先关中断取指针,取完再恢复。
- 检查模块输出的是
\r\n还是只有\n。大多数模块是\r\n,但有些模块如果配置了“只输出LF”模式,你的状态机专门等\n也没问题,注意不要在处理时把\r当成有效数据带进去,否则后续字段解析会多一个不可见字符,导致数字转换失败。
4.3 位置信息时有时无,信号强度不高
这通常是环境问题而不是代码问题。GPS/北斗信号在室内几乎不可用,在窗户旁边也要看朝向。拿到模块后先在室外空旷处验证,确认能定位后再搬到室内调试代码。不要一开始就在室内怀疑程序写错了。
如果室外能定位,但数据断断续续,可以检查模块的供电是否稳定。北斗GPS模块的峰值电流在信号搜索时会较高,如果用开发板的3.3V供电且板上有其他大功率外设,可能出现瞬时压降导致模块重启。解决方法是模块单独用LDO供电,或者在模块VCC处并联一个100uF电解电容和0.1uF瓷片电容。
4.4 经纬度输出在某个方向上偏得离谱
先检查方向字符解析有没有错误。北纬是N,南纬是S;东经是E,西经是W。如果方向字符判断反了,经纬度的符号就错了。尤其在国内测试时,北纬(N)和东经(E)应该都保持正值。如果解析出来纬度是负数或者经度是负数,基本就是方向字符处理的问题。
再看度分转换是否正确。我先给个小测试用例验证你的解析代码:已知RMC里的纬度为2238.87521(上海附近),手动计算:22 + 38.87521/60 = 22.64792017。如果代码输出结果偏差在1以上,多半是度分处理时把小数点位置搞错了。
4.5 接收数据时偶发丢失
排查思路首先要看环形缓冲区是否有覆盖。缓冲区的大小决定了能缓冲多少字节。NMEA模块一般每秒输出多条语句,如果主循环处理不过来导致缓冲区写满,新数据就会被丢掉。可以把环形缓冲区大小设到512字节,同时打印缓冲区溢出的计数,如果溢出计数持续增长,说明主循环解析速度跟不上,需要优化解析代码或提高主循环执行频率。
另外注意,如果开了多个串口中断,要看中断优先级是否合理。USART1的接收中断优先级应该设为不低于其他外设中断,否则数据可能在中断嵌套期间被延迟处理。F407的NVIC配置很简单,把USART1中断优先级设为抢占优先级2即可。
4.6 校验和要不要认真做
NMEA-0183规范里,每条语句在末尾有*xx格式的校验和,是$和*之间所有字符的逐字节异或值。有些模块这个校验值是正确的,有些模块则可能省略或算错。
我的建议是:在正式工程里最好做校验和验证,因为卫星信号弱时可能出现误码,误码会导致语句格式正确但字段值错误。如果解析时完全不检查校验和,错误数据就会混进来。对于做精准定位、地图记录、自动驾驶这类应用,校验是必须的。对于普通demo和教学演示,可以先不关注校验和,等系统稳定后再补上。
校验和计算的代码很简单:
uint8_t calc_nmea_checksum(const char *line) { uint8_t xor = 0; // 跳过起始的 '$' const char *p = line + 1; // 逐字符异或,直到 '*' 或字符串结束 while (*p && *p != '*') { xor ^= (uint8_t)*p++; } return xor; }然后把语句中*后面的两个十六进制字符转换成数字,与计算结果比较。不一致就丢弃该帧。
5. 工程扩展与实用建议
5.1 从“能解析”到“能用”的优化路径
解析出经纬度只是第一步。实际工程里,很多人会在解析完成后立刻用printf打印。这在调试期没什么问题,但如果你的系统需要长时间运行并记录轨迹,printf本身的开销和时间抖动会影响整体节奏。
建议把解析结果存到结构体里,由应用层决定怎么使用。比如在结构体里加一个update_flag,每次成功解析RMC后置1,主循环检测到标志位后统一处理显示、存储等操作,处理完清0。这样可以避免GPS数据更新和应用层处理之间互相干扰。
此外,GGA语句里的定位质量指示(字段6)很有用:0表示无效,1表示单点定位,2表示差分定位。可以根据这个字段判断当前定位的可靠性。如果值长期为0,说明模块还没有锁定足够的卫星。在车载或户外项目中,这个字段还可以用来切换工作模式——差分定位时记录精度更高,单点定位时可以提醒用户位置可能有偏差。
5.2 深入做产品时值得关注的几个方向
如果你想把这套工程做成真正可交付的产品,可以考虑以下几点:
一是低功耗设计。北斗GPS模块常供电会持续消耗电流。在电池供电的场景中,可以让模块进入备份模式,只有需要定位时才唤醒。STM32F407自身也有多种低功耗模式,但要注意唤醒后的串口重新配置问题。
二是多系统融合。目前很多双模模块还支持GLONASS或Galileo。NMEA-0183会为每个系统输出独立的GSV语句,也可以通过配置让模块输出合并后的语句。融合多个星座能明显提升定位速度和室外的抗遮挡能力。
三是数据存储。加一张SD卡(用SPI或SDIO接口),把解析后的位置数据记录成CSV或GPX格式,就能做一个完整的轨迹记录仪。F407的SDIO接口速度足以支撑普通的数据记录需求。
5.3 个人实操中的几点总结
这套工程跑通之后,我有几点比较深的体会。
第一,GPS解析项目的难点从来不在“解析”本身,而在于数据链路每一个环节的可靠性。从天线信号、模块供电、串口电平、波特率配置、中断处理、缓冲区管理,任何一个环节出问题,都会以“解析不到数据”的形式呈现。排查时不要只盯着代码看,先从物理链路开始逐级检查,往往效率更高。
第二,NMEA-0183协议虽然老,但就是因为它简单稳定,才在几十年后的今天依然是卫星导航模块的标准输出格式。理解它的行结构、字段含义、度分转换规则,对阅读各种模块的手册非常有帮助。不同模块输出的小差异,比如字段偶尔为空、小数点位数不同、前缀不同,都是很正常的,代码要有容忍这些变化的能力。
第三,在实际项目中,建议把所有NMEA原始数据先存日志,再在日志基础上调试解析算法。这样即使复现不了现场环境,也能靠日志完整模拟。我以前做定位设备时,就是把原始NMEA通过调试串口传到PC上存成文本,然后用Python脚本离线验证解析逻辑,验证稳定后才把逻辑搬回C代码里,大大节约了开发时间。
这个项目本身不复杂,但把协议解析、串口处理、数据格式化这些基础功夫练好了,后面不管是做无人小车、定位追踪器还是便携导航设备,思路都是一样的。希望这篇文章能帮你少走一些弯路。
本文还有配套的精品资源,点击获取