
简介这是一份面向单片机嵌入式开发者的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 字符组成典型波特率是 96008 个数据位、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。字段位置示例值含义1082356.000UTC 时间 08:23:56.00023101.2345纬度 31 度 01.2345 分3N北纬412123.4567经度 121 度 23.4567 分5E东经61定位质量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的Glen是$与*之间的字符个数。计算得到的cs与帧里*后面的两位十六进制数比较一致才认为这一帧没有在串口传输过程中被干扰。我在工程里习惯单独写一个nmea_verify()函数先找$和*的位置再调用上面的异或逻辑校验失败就把整帧丢弃不做任何字段提取。因为 GPS 模块发出的数据是连续流偶尔丢一帧不影响整体定位但错误帧如果被误解析显示出来的坐标会跳变排查起来反而更麻烦。3. USART 中断接收与环形缓冲区把字节流变成完整帧3.1 CubeMX 下 USART1 的参数配置与引脚分配STM32F103ZE 的 USART1 挂载在 APB2 总线上时钟频率最高 72MHz连接 GPS 模块时我选择 USART1 作为接收口PA9 是 TX、PA10 是 RXGPS 模块的 TX 接单片机 PA10GPS 模块的 RX 接 PA9。在 CubeMX 中把 USART1 模式设为 Asynchronous波特率填 9600数据位 8停止位 1无校验开启全局中断。注意 GPS 模块上电后第一帧数据可能在几百毫秒内就到达所以串口初始化和中断使能必须早于主循环开始执行。参数项配置值说明ModeAsynchronous异步串口模式Baud Rate9600与 GPS 模块默认波特率一致Word Length8 Bits标准 NMEA 数据位ParityNone无校验位Stop Bits11 位停止位USART1 InterruptEnabled使能接收中断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 模块的晶振偏差大时长时间运行会出现偶发丢字节表现为校验和频繁失败。本文还有配套的精品资源点击获取