简介:这是一份面向单片机嵌入式开发者的GPS NMEA协议解析实战资源包,以STM32F103ZE为主控,演示如何通过USART串口接收GPS模块数据,并从中提取经纬度、UTC时间等关键信息,最终在TFT屏上显示。资源基于RealView MDK开发环境,包含完整的工程文件、硬件驱动代码以及NMEA字符串解析实现,适合正在学习串口通信、定位模块接入或物联网定位应用开发的读者。资源包共141个文件,以C源码与配套头文件为主(含32个c文件、34个h文件),另有编译链接生成的o/hex/axf/d等中间文件及Keil工程配置文件,压缩包大小3.22MB,结构清晰便于直接打开工程对照学习。已有396人学习下载。通过该工程可掌握从NMEA字符串分割、校验和检查、度分秒转十进制,到TFT屏幕显示坐标与时间的完整流程,并了解串口数据丢包处理、电源管理等工程优化思路。
1. 从串口乱码到坐标上屏:GPS 模块接入单片机的第一道坎
手里这块 GPS 模块上电后,TFT 屏幕只有一行时间在跳,经纬度全显示 0.000000。用示波器看 RX 引脚有波形,用串口助手接 PC 也能看到以$开头的一行行文本,但就是进不了单片机。问题几乎总出在同一个地方:单片机不知道这一串字符什么时候算一帧,也不知道从哪里截取纬度值。GPS 模块输出的 NMEA 0183 协议本质上是 ASCII 文本流,和 Modbus 那种定长或带长度域的帧完全不一样,它是靠$起始、*加校验和结尾的可变长文本帧。这篇文章记录的是 STM32F103ZE 上完整跑通的实现路径,素材来自一个 Keil MDK 工程,包含 stm32f10x_tim.c、stm32f10x_rcc.c 等标准外设库文件,而我的复现版本基于 CubeMX + HAL 库,对想抄作业的人更友好。适合手里有串口 GPS 模块、想在单片机上做定位显示或轨迹记录的开发者。
2. NMEA 0183 帧结构与 $GPGGA 字段:校验和怎么算
2.1 一条完整 NMEA 语句的最小识别规则
NMEA 0183 是美国国家海洋电子协会定义的串口通信协议,GPS 模块输出的标准语句以 ASCII 字符组成,典型波特率是 9600,8 个数据位、1 个停止位、无校验。每条语句以$开头,以回车换行(\r\n)结束,例如:
$GPGGA,082356.000,3101.2345,N,12123.4567,E,1,08,1.2,12.5,M,0.0,M,,*6F这条数据里我最关心的是$GPGGA语句——GPS 固定数据输出,它包含了 UTC 时间、纬度、经度、定位质量、卫星数、海拔高度。它的字段排列是固定的:帧头之后依次是 UTC 时间(hhmmss.sss)、纬度(ddmm.mmmm)、纬度方向(N/S)、经度(dddmm.mmmm)、经度方向(E/W)、定位质量指示符(0 无定位、1 非差分定位、2 差分定位)、卫星数量、水平精度因子、海拔高度及单位、大地水准面高度及单位、差分数据龄期、差分参考站 ID。
| 字段位置 | 示例值 | 含义 |
|---|---|---|
| 1 | 082356.000 | UTC 时间 08:23:56.000 |
| 2 | 3101.2345 | 纬度 31 度 01.2345 分 |
| 3 | N | 北纬 |
| 4 | 12123.4567 | 经度 121 度 23.4567 分 |
| 5 | E | 东经 |
| 6 | 1 | 定位质量:1 表示非差分定位有效 |
纬度字段3101.2345不是十进制度数,而是度分格式:前两位31是度,后面的01.2345是分。转换时要用度数 + 分数 / 60的公式,这个细节是新手最容易算错的地方,直接把字符串转 float 得到的值会比真实坐标大 60 倍,TFT 上显示出来的点会漂到几百公里外。
2.2 校验和算法:判断一帧数据是否可信的底线
NMEA 语句中*后面跟着两位十六进制校验和,计算范围是$和*之间的所有字符,包括逗号,按字节做异或(XOR)。以$GPGGA,082356.000,3101.2345,N为例,就是把这些 ASCII 码逐个异或,最后的结果是大写十六进制。下面是工程里常用的校验函数:
uint8_t nmea_calc_checksum(const char *buf, uint16_t len) { uint8_t cs = 0; for (uint16_t i = 0; i < len; i++) { cs ^= (uint8_t)buf[i]; // 逐字节异或 } return cs; } // 调用示例:buf 指向 '$' 之后第一个字符,semicolon_pos 是 '*' 的位置 // 实际使用时 len = asterisk_pos - 1 - 1,跳过 '$' 本身逻辑说明:函数入参buf指向$之后的首个字符(即GPGGA的G),len是$与*之间的字符个数。计算得到的cs与帧里*后面的两位十六进制数比较,一致才认为这一帧没有在串口传输过程中被干扰。我在工程里习惯单独写一个nmea_verify()函数,先找$和*的位置,再调用上面的异或逻辑,校验失败就把整帧丢弃,不做任何字段提取。因为 GPS 模块发出的数据是连续流,偶尔丢一帧不影响整体定位,但错误帧如果被误解析,显示出来的坐标会跳变,排查起来反而更麻烦。
3. USART 中断接收与环形缓冲区:把字节流变成完整帧
3.1 CubeMX 下 USART1 的参数配置与引脚分配
STM32F103ZE 的 USART1 挂载在 APB2 总线上,时钟频率最高 72MHz,连接 GPS 模块时我选择 USART1 作为接收口,PA9 是 TX、PA10 是 RX,GPS 模块的 TX 接单片机 PA10,GPS 模块的 RX 接 PA9。在 CubeMX 中把 USART1 模式设为 Asynchronous,波特率填 9600,数据位 8,停止位 1,无校验,开启全局中断。注意 GPS 模块上电后第一帧数据可能在几百毫秒内就到达,所以串口初始化和中断使能必须早于主循环开始执行。
| 参数项 | 配置值 | 说明 |
|---|---|---|
| Mode | Asynchronous | 异步串口模式 |
| Baud Rate | 9600 | 与 GPS 模块默认波特率一致 |
| Word Length | 8 Bits | 标准 NMEA 数据位 |
| Parity | None | 无校验位 |
| Stop Bits | 1 | 1 位停止位 |
| USART1 Interrupt | Enabled | 使能接收中断 |
3.2 HAL 库中断接收与环形缓冲区实现
直接在主循环里调用HAL_UART_Receive()做阻塞式接收会浪费 CPU,而且 GPS 数据是不定长连续到达的,阻塞等待一帧完成期间无法处理其他任务。我采用的做法是:中断接收单字节写入环形缓冲区,主循环周期性从缓冲区批量取走数据做帧解析。环形缓冲区用数组加读写索引实现,读写索引都做模运算,溢出时写指针追平读指针则丢弃最旧数据。
#define RING_BUF_SIZE 256 static uint8_t ring_buf[RING_BUF_SIZE]; static volatile uint16_t ring_head = 0; static volatile uint16_t ring_tail = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { uint16_t next = (ring_head + 1) % RING_BUF_SIZE; if (next != ring_tail) { // 缓冲区未满 ring_buf[ring_head] = rx_byte; ring_head = next; } HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 重新使能单字节接收中断 } } uint16_t ring_read(uint8_t *dst, uint16_t max_len) { uint16_t cnt = 0; while (ring_tail != ring_head && cnt < max_len) { dst[cnt++] = ring_buf[ring_tail]; ring_tail = (ring_tail + 1) % RING_BUF_SIZE; } return cnt; }逻辑说明:HAL_UART_RxCpltCallback是 HAL 库的中断回调函数,每次收到一个字节后把数据写入环形缓冲区尾部,并立即再次调用HAL_UART_Receive_IT使能下一次单字节接收。ring_read供主循环调用,从缓冲区头部取出数据填入dst数组。参数max_len限制单次读取长度,避免调用方缓冲区越界。
这样一个字节一个字节地收,虽然中断频率在 9600 波特率下约 1ms 一次,但对 Cortex-M3 来说负载很低。真正需要控制的是缓冲区大小——如果主循环被 TFT 刷屏等耗时操作阻塞太久,缓冲区满了之后新数据会被直接丢弃,这是丢帧的主要原因。工程里把缓冲区开到了 256 字节,而一条完整$GPGGA语句最长不超过 100 字节,正常情况下足够容纳两三帧数据。
4. 状态机解析 NMEA:经纬度与 UTC 时间提取的实现
4.1 解析器状态机:从单字节流到字段分割
拿到连续的字节流后,需要在主循环里完成帧识别和字段提取。不建议用strtok()或sscanf()处理——它们要么修改原缓冲区,要么在解析失败时代码路径复杂。我习惯用手写状态机,把每一类解析动作拆成独立状态,逐字节推进。解析器的状态流转可以分成四个阶段:等待$符号、接收帧类型与数据内容、遇到*后接收校验和、校验结束后输出结果。
typedef enum { RX_STATE_WAIT_HEADER, // 等待 '$' RX_STATE_RECEIVE_DATA, // 接收 $ 到 * 之间的内容 RX_STATE_RECEIVE_CS, // 接收 * 后两位校验和 RX_STATE_DONE // 一帧解析完成 } rx_state_t; uint8_t parse_buffer[128]; uint16_t parse_len = 0; rx_state_t rx_state = RX_STATE_WAIT_HEADER; void nmea_parse_byte(uint8_t byte) { switch (rx_state) { case RX_STATE_WAIT_HEADER: if (byte == '$') { parse_len = 0; rx_state = RX_STATE_RECEIVE_DATA; } break; case RX_STATE_RECEIVE_DATA: if (byte == '*') { rx_state = RX_STATE_RECEIVE_CS; } else if (byte == '\r' || byte == '\n') { rx_state = RX_STATE_WAIT_HEADER; // 异常帧,丢弃 } else if (parse_len < sizeof(parse_buffer) - 1) { parse_buffer[parse_len++] = byte; } break; case RX_STATE_RECEIVE_CS: // 收两个十六进制字符后进入 DONE,然后回 WAIT_HEADER // 略:实际代码用计数器累计两位字符 break; default: rx_state = RX_STATE_WAIT_HEADER; break; } }逻辑说明:状态从WAIT_HEADER开始,只有收到$才进入数据接收状态,这能屏蔽 GPS 模块上电时的乱码。在RECEIVE_DATA状态中遇到*表示数据载荷结束,转入校验和接收;若在数据中途遇到回车换行,说明帧不完整,直接丢弃回到等待状态。parse_buffer里存的是$与*之间的原始文本,后续按逗号分割字段,就是干净的GPGGA,082356.000,3101.2345,N,...。
4.2 经纬度 DDMM.MMMM 转十进制度数
帧解析完成后,$GPGGA语句中纬度和经度字段还是度分格式字符串。转十进制度数的标准做法是:把字段按小数点拆成整数部分和小数部分,整数部分的前两位是度,后两位是分;小数部分直接作为分的分数。转换逻辑代码如下:
double nmea_to_decimal(const char *ddmm_str, char direction) { double raw = atof(ddmm_str); // 先把字符串转浮点 int degrees = (int)(raw / 100); // 前两位是度 double minutes = raw - degrees * 100; // 剩余是分 double decimal = degrees + minutes / 60.0; if (direction == 'S' || direction == 'W') { decimal = -decimal; // 南纬西经取负 } return decimal; } // 调用示例:nmea_to_decimal("3101.2345", 'N') 返回 31.020575参数说明:ddmm_str是 NMEA 帧里的原始字段字符串,direction是对应的方向字符。atof把3101.2345转成浮点数,除以 100 取整得 31 度,3101.2345 - 3100得 1.2345 分,除以 60 后与 31 相加即得到十进制度数。南纬和西经方向必须取负值,否则绘制轨迹时坐标会对称地反转到另一个半球。嵌入式环境下如果不想引入atof的浮点开销,可以改为纯整数运算:把度分字符串拆开,用整数分除以 600000 再累加,精度完全够用。
4.3 UTC 时间提取与 TFT 显示的格式化
$GPGGA的第一个字段是 UTC 时间,格式是hhmmss.sss,其中小时范围 00 到 23。GPS 模块输出的是 UTC 时间,不是本地时间,直接显示会让用户困惑。国内使用时要在小时上加 8 小时,并处理跨日问题:加完后如果大于等于 24,减去 24 且日期加一。工程里我把时间字段解析为整型时分秒,再单独维护一个日期计数器,这样在 TFT 上可以显示成北京时间 16:23:56这种格式。
TFT 显示部分用的是裸机驱动,先把经纬度和时间拼成字符串放入显存,再调用刷屏函数。注意不要在刷屏期间停掉串口接收中断,否则 GPS 数据会持续积压到环形缓冲区,一旦缓冲区满,所有新帧都会丢失。我一般把显示刷新率控制在每秒 2 次,而不是每帧 NMEA 数据都刷一次,这样既能看到实时位置变化,也给串口解析留出足够的 CPU 时间。
5. 串口抓帧与校验和排错:验证 GPS 解析结果的实用技巧
5.1 用串口调试助手确认原始输出
拿到 GPS 模块后不要急着写单片机代码,先接一个 USB 转 TTL 模块,打开串口调试助手看原始输出。很多模块默认波特率是 9600,但也有个别是 115200,如果看到乱码,优先尝试切换波特率。另外,无源陶瓷天线和有源天线在室内定位能力上差异极大,无源天线靠近窗户都未必搜到星,这会让串口只有$GPGGA而没有有效定位字段。串口调试助手能看到完整帧但定位质量字段一直是 0,先别怀疑程序,把天线挪到窗边再试。
5.2 ch340 驱动与串口烧写失败的排查顺序
在用 USB 转 TTL 调试时,PC 端识别不到串口通常和 CH340 驱动有关,换一根线或换一个 COM 口就能定位问题。单片机侧的串口烧写失败则大概率不是驱动问题,而是 BOOT0 引脚电平状态不对——STM32F103ZE 需要 BOOT0 拉高才能进入 ISP 下载模式,下载完成后拉低复位才运行用户程序。工程里附带了一个keilkilll.bat批处理,用来清理 Keil 编译产生的中间文件,如果你在换电脑后编译报错出现乱码文件,先跑一次这个脚本再重新编译。调试过程中还有个常用技巧:把解析后的经纬度与时间通过另一个串口回发到 PC,在串口助手里肉眼比对原始帧和解析结果。下面是回发调试代码:
char dbg_buf[64]; snprintf(dbg_buf, sizeof(dbg_buf), "LAT:%.6f LON:%.6f UTC:%02d:%02d:%02d\r\n", lat_decimal, lon_decimal, hour, minute, second); HAL_UART_Transmit(&huart2, (uint8_t *)dbg_buf, strlen(dbg_buf), 100);逻辑说明:snprintf把固件解析出的十进制度数和 UTC 时分秒格式化成可读字符串,通过 USART2 发给 PC 串口助手。参数100是超时时间,单位为毫秒。这样对比原始 NMEA 帧和解析结果,能够快速确认是协议解析错误、校验和算法错误,还是浮点转换精度问题。
5.3 一个实用的验证技巧:用定位质量字段过滤无定位数据
$GPGGA的第六个字段是定位质量指示符,室内或者刚上电时它的值是 0。解析时前检查这个字段,只有大于 0 才把经纬度更新到全局变量,否则保持上次有效定位不变。这样 TFT 上显示的坐标不会在搜星过程中跳成 0.000000。另外把校验和失败的帧单独计数,如果失败率持续超过 5%,优先检查接线质量和串口波特率偏差——GPS 模块的晶振偏差大时,长时间运行会出现偶发丢字节,表现为校验和频繁失败。
本文还有配套的精品资源,点击获取