STM32实战:串口接收GPS模块NMEA数据并解析经纬度
2026/9/15 6:10:11 网站建设 项目流程

简介:这是一份面向单片机嵌入式开发者的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。

字段位置示例值含义
1082356.000UTC 时间 08:23:56.000
23101.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指向$之后的首个字符(即GPGGAG),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 模块上电后第一帧数据可能在几百毫秒内就到达,所以串口初始化和中断使能必须早于主循环开始执行。

参数项配置值说明
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是对应的方向字符。atof3101.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 模块的晶振偏差大时,长时间运行会出现偶发丢字节,表现为校验和频繁失败。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询