ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于STM32的GPS导航:从NMEA解析到控制量完整实现

基于STM32的GPS导航:从NMEA解析到控制量完整实现

简介:这是一份基于STM32微控制器与UC/GUI图形库的GPS导航系统相关工程源码,面向嵌入式开发者以及正在学习STM32、GPS应用和GUI编程的学生。资源以STM32为主控芯片,通过UART或SPI接口接收GPS模块的NMEA协议数据,完成经纬度解析、定位计算、地图显示与路径规划,并利用UC/GUI设计窗口、按钮、速度表等图形界面,覆盖从数据采集到人机交互的完整开发流程。工程附带guidemo系列界面演示,包括控件、位图、颜色列表等示例,有助于快速理解图形库的函数调用与界面组织方式。压缩包共包含548个文件,以头文件、C源文件、目标文件为主体,另有Keil工程文件、烧录用的HEX和AXF文件及内存映射文件,整体大小约20.23MB,目录清晰,可直接打开工程进行编译与调试。资源已有3059人学习下载,适合作为嵌入式GUI与GPS导航项目起步和二次开发的有力参考。

1. 基于STM32的GPS导航:把经纬度变成控制量的完整链路

串口助手满屏的 GNGGA、GPRMC 句子不断往外蹦,看着像有数据,可你的小车还是不知道该往哪走——大多数基于STM32的GPS导航项目就卡在这一步。数据能读到、位置却算不出来,不是模块有问题,而是链路没打通:从 GPS 接收机吐出的 NMEA 文本,到经纬度数值,再到能控制电机转向的控制量,中间隔着协议解析、坐标计算和方向判断三道坎。这篇笔记把这三道坎逐个拆开,硬件、代码、参数和坑位都按实际动手做过的路线来讲。适合刚把定位模块接到单片机、想让设备真正“会认路”的开发者,也适合做巡检车、物流小车、手持定位终端时被坐标转换反复折磨的工程师。

2. 系统框架与定位原理:选型先说清,协议先看透

2.1 数据链路:卫星到控制量之间发生了什么

基于STM32的GPS导航,整个系统可以拆成三段:卫星与接收机、单片机与协议、算法与控制。卫星信号到达接收机后,模块内部完成射频、基带和定位解算,通过串口把经纬度、速度、航向角等结果以文本帧形式输出。对 STM32 来说,卫星解算是黑匣子,模块已经封装好了;单片机要做的是把文本帧按协议拆开,提取有效字段,再换算成工程可用的量。

很多新手误以为 GPS 导航要自己处理卫星信号,其实消费级串口模块就是一台“会说话的定位仪”。MCU 的工作重心不在射频,而在数据处理和控制决策:解析帧 → 算坐标 → 算距离方位 → 生成转向指令。理解这个划分,后面所有代码和调试都有了落点。

2.2 为什么是 STM32 + 串口 GPS 模块这个组合

常见方案是 STM32 配串口输出的 GPS/北斗双模模块,而不是直接用树莓派或安卓设备。原因主要是这三条:一是 STM32 的 UART + DMA 吃下 9600bps 的数据流几乎不占 CPU,还能留出资源跑控制逻辑;二是模块功耗低,电池供电、车身供电都稳得住;三是模块化程度高,不必碰射频电路,调试界面就是串口文本,门槛低、可预期。

选模块时我看五个点:协议是否兼容 NMEA-0183;是否支持多星座(单 GPS 在楼宇间容易丢星,北斗+GPS 双模更稳);串口电平是否 3.3V TTL;天线类型是有源还是无源——底盘金属环境中建议有源;输出频率能不能配置,默认 1Hz 够做追踪,做运动控制最好能调到 5Hz 或 10Hz。

还有一条容易被忽略:有些模块冷启动后不会自动输出,需要用配置指令开启,甚至要先通过上位机软件把配置保存到模块内部 Flash。拿到模块后第一件事不是接单片机,而是先接 USB 转串口,上电看它到底吐不吐数据、吐什么格式。

2.3 协议基础:NMEA-0183、GPRMC、GNGGA 与 UTC

NMEA-0183 是纯文本协议,帧以 $ 开头,逗号分隔字段,以回车换行结束。做导航最常用两类帧:GPRMC 是“推荐最小定位信息”,包含状态位、经纬度、速度、航向角、UTC 日期时间;GNGGA 是“固定数据”,包含经纬度、定位质量、卫星数和椭球高度。

多星座模块输出的帧头值得注意。只收 GPS 时是 GPGGA、GPRMC;收北斗+GPS 时常见 GNGGA、GNRMC。字母 G 代表 GNSS 而不是单指 GPS。解析逻辑不能用“以 $GPRMC 开头”做唯一判断,要把 $GNRMC 也涵盖进来,否则换一个模块代码就失效。

经纬度格式是度分制:ddmm.mmmm。比如 3127.1234 表示 31 度 27.1234 分,转十进制的算法是“度 + 分/60”,这是换算出距离的基础,也是高频翻车点。UTC 时间比北京时间慢 8 小时,室内显示时要加 8 小时并处理跨天回绕;模块输出的高度是椭球高,和海拔之间有差异,需要区域高程异常值修正,这在平地项目里可以先不处理。

3. 硬件连接与串口数据采集:先把 GPS 原始数据拿回来

3.1 接线与电平匹配:3.3V/5V 的坑

接线本身不复杂,常见接法如下表。建议用 USART1 做 GPS 数据接收,因为 DMA 通道多、调试时逻辑分析仪也好接。

GPS 模块引脚STM32 引脚说明
VCC3.3V 或 5V按模块手册,留意工作电流
GNDGND必须共地
TXUSART1_RX,如 PA10模块发送,MCU 接收
RXUSART1_TX,如 PA9只读位置可不接
有源天线馈电3.3V有源天线需要单独供电

电平匹配是第一个容易翻车的地方。有些模块标 5V 供电,串口逻辑却是 3.3V,可以直接连 STM32;也有模块的串口电平跟随 VCC,5V 供电时 TX 输出高电平接近 5V,长期直连会损伤 MCU 引脚。稳妥做法是先查模块手册确认逻辑电平,或者用万用表量空闲状态下 TX 对 GND 的电压,高于 3.6V 就要加电平转换或串电阻分压。

提示:确认模块是否支持 3.3V 供电。很多模块标称 3.3V 到 5V 宽压,但实际 5V 供电时发热更明显,长期运行建议降到 3.3V。有源天线必须供电,否则信号强度直接掉一大截,容易被误判成模块损坏。

3.2 用 UART + DMA + 空闲中断收完整帧

GPS 模块是持续吐数据的,一帧 80 字节左右,9600bps 下单字节约 1ms。在主循环里一个个字符读会占用大量 CPU,也不容易判断帧边界。稳定做法是 DMA 接收加串口空闲中断:DMA 负责把数据连续搬进缓冲区,空闲中断表示“这一帧结束了”,这时从缓冲区取整帧解析。

#define FRAME_MAX 256 static uint8_t dma_buf[FRAME_MAX]; static volatile uint16_t frame_len = 0; void uart_start_dma_receive(void) { __HAL_UART_ENABLE_IT(&huart, UART_IT_IDLE); HAL_UART_Receive_DMA(&huart, dma_buf, FRAME_MAX); } /* 串口空闲中断回调,在中断内调用 */ void on_uart_idle(void) { uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); frame_len = FRAME_MAX - remain; __HAL_UART_DISABLE_DMA(&huart); HAL_UART_Receive_DMA(&huart, dma_buf, FRAME_MAX); parse_nmea(dma_buf, frame_len); }

逻辑说明:DMA 一直在往 dma_buf 写入数据,不清空也不会停。空闲中断触发时,通过 DMA 计数器反推本次收了多少字节,就得到了完整一帧的长度。处理完这一帧后要重新启动 DMA 接收,否则后续数据不会再写入。注意要先关 DMA 再开,避免重复启动导致数据错位。

参数说明:FRAME_MAX 要大于单帧最大长度。NMEA 最长帧一般不到 120 字节,留 256 余量足够;如果设成 80 并开启循环模式,帧超长就会覆盖前半段,解析出来是乱码。模块输出频率调高到 5Hz 甚至 10Hz 时,帧间隔变短,空闲中断依然有效,但解析函数耗时不能超过帧间隔——9600bps 下一帧约 80ms,5Hz 时 200ms 发一帧,留给解析和控制的余量仍然充足。

3.3 用串口助手验证原始输出

写解析代码之前,务必先用串口助手把模块原始输出看清楚。我把验证步骤固定成三板斧:

第一,上电后等 30 秒看有没有输出。完全没有输出先量供电、查 TX/RX 是否接反,再确认天线是否接好。第二,确认波特率。模块默认大多是 9600,也有出厂设成 115200 的,逐个试 9600、19200、38400、115200。第三,看帧种类。如果只有 GNGGA 没有 GPRMC,说明模块配置或协议栈不同,代码要按实际存在的帧类型写。

这一步是整个系统里最简单却最省时的一步。我见过好几次情况:代码按照 GPRMC 解析,实际模块只输出 GNGGA,结果解析函数永远进不去,位置一直是零。先用工具确认“数据长什么样”,再动手写代码,能节省半天无用功。

4. NMEA0183 协议解析:从 GPRMC/GNGGA 到经纬度的代码实现

4.1 帧字段拆解:GPRMC 与 GNGGA 对照

以一段示例 RMC 帧为例(仅为展示格式,坐标无实际对应):

$GPRMC,083559.00,A,2456.7890,N,12100.1234,E,0.024,77.52,260624,,,A*6A
字段位置示例值含义
0$GPRMC帧头
1083559.00UTC 时间 hhmmss.ss
2A定位状态,A 有效,V 无效
32456.7890纬度 ddmm.mmmm
4N北纬或南纬
512100.1234经度 dddmm.mmmm
6E东经或西经
70.024速度,单位节
877.52航向角,相对真北
9260624UTC 日期 ddmmyy

GNGGA 的结构完全不同:第 4 字段是纬度、第 5 是 N/S,第 6 是经度、第 7 是 E/W,第 8 是定位质量指示,第 14 是椭球高度。所以在代码里不要用“第几字段”这一套同时处理两种帧,应该按帧头分别走不同分支,避免字段错位。

4.2 解析核心代码:字段拆分与经纬度换算

下面这段 C 代码是项目里可以直接按需改用的核心解析片段,去掉了平台相关的东西,可以放进任何 STM32 工程。

#include <string.h> #include <stdlib.h> typedef struct { int valid; /* 1 有效,0 无效 */ double latitude; /* 十进制纬度,负为南纬 */ double longitude; /* 十进制经度,负为西经 */ double speed_knot; /* 速度(节) */ double heading_deg; /* 航向角(度) */ int hour, minute, second; } rmc_t; static int get_field(const char *buf, int idx, char *out, int out_len) { const char *p = buf; int cur = 0; while (*p && cur < idx) { if (*p == ',') cur++; p++; } if (!*p) return -1; int len = 0; while (*p && *p != ',' && *p != '*' && len < out_len - 1) { out[len++] = *p++; } out[len] = '\0'; return len; } static double convert_degmin(const char *field, char hemi) { double v = atof(field); int deg = (int)(v / 100.0); double min = v - deg * 100.0; double dec = deg + min / 60.0; if (hemi == 'S' || hemi == 'W') return -dec; return dec; } int parse_rmc(const char *buf, rmc_t *out) { if (strncmp(buf, "$GPRMC,", 7) != 0 && strncmp(buf, "$GNRMC,", 7) != 0) { return -1; } char f[10][32]; for (int i = 0; i < 10; i++) { if (get_field(buf, i, f[i], sizeof(f[i])) < 0) return -1; } if (f[2][0] != 'A') return -2; /* 定位无效直接丢弃 */ int t = atoi(f[1]); out->hour = t / 10000; out->minute = (t / 100) % 100; out->second = t % 100; out->latitude = convert_degmin(f[3], f[4][0]); out->longitude = convert_degmin(f[5], f[6][0]); out->speed_knot = atof(f[7]); out->heading_deg = atof(f[8]); out->valid = 1; return 0; }

逻辑说明:get_field 手工扫描逗号位置来切字段,而不是用 strtok,是因为 strtok 会修改原始缓冲区,而 DMA 缓冲区的帧数据后面可能还要做校验和比对。convert_degmin 先取整得到度,再把剩余小数当作分除以 60,这是度分制转十进制的标准算法;南纬西经要加负号,否则距离和方位全会算反。

参数说明:f[2][0] 是状态位,不是 'A' 直接返回,避免拿着无效位置算导航。speed_knot 是节,转米每秒要乘 0.514444。heading_deg 是运动方向角,静止时可能保持旧值或退化为 0,不能当作模块朝向用。时间字段是 UTC,显示成北京时间要加 8,跨天时做循环回绕处理。

4.3 数据有效性判断:校验和、时间新鲜度与运动一致性

状态位是 A 只是最基本的要求,还要加三道防线,否则导航数据里会混进脏数。

第一道是 NMEA 校验和。帧里 * 后面的两个十六进制字符就是校验值,算法是 $ 和 * 之间所有字符的异或和。通信干扰、线路接触不良都会造成帧损坏,校验不过直接丢。

static int nmea_checksum(const char *buf) { const char *star = strchr(buf, '*'); if (!star) return -1; unsigned char calc = 0; for (const char *p = buf + 1; p < star; p++) { calc ^= (unsigned char)*p; } unsigned char recv = (unsigned char)strtol(star + 1, NULL, 16); return (calc == recv) ? 0 : -1; }

第二道是时间新鲜度。模块冷启动后可能输出缓存里的旧位置,帧时间明显早于系统当前时间时,这一帧要丢弃。做法是解析出 UTC 时间后和 RTC 对比,误差超过阈值就丢掉。

第三道是运动一致性。前后两帧位置算出的位移,除以时间差得到估计速度,超过平台物理上限就认为是跳变。这个过滤对高楼、隧道口场景下的多径反射特别有效,比一上来就上卡尔曼滤波更简单实用。

5. 基于STM32的GPS导航高频踩坑与排查思路:定位不准、乱码、跳变

5.1 串口助手能看到数据,程序里就是收不到

现象:同一个模块接 USB 转串口有数据,接 STM32 后串口调试完全空白。

原因多是三个方向:引脚复用没配置成串口功能;DMA 或中断使能顺序不对;接收缓冲区太小导致帧被截断。最隐蔽的是 DMA 的数据宽度设错了,NMEA 是字节流,DMA 必须设 Byte 模式,设成 HalfWord 后收到的高低字节错位,出来全是乱码。

解决:先做最小验证——关掉 DMA,只用轮询读一个字节直接回显。能通再往上加 DMA。逐项查:GPIO 复用模式、USART 时钟、DMA 时钟、中断优先级。用调试器单步停在中断回调里,看缓冲区内容是否在增长,能很快定位到是哪一层断了。

5.2 定位数据时有时无,室内和窗边尤其严重

现象:valid 字段经常是 V,或者干脆没有帧,上电一段时间后才慢慢恢复。

原因:GPS 信号穿透力很弱,室内基本靠反射信号定位,状态会反复在 A 和 V 之间跳动;车窗贴膜、金属外壳、模块摆放位置靠近大电流线,都会造成信号恶化;冷启动搜星需要几十秒到几分钟,期间一直输出无效状态。

解决:先到开阔地平测试;使用有源天线并远离开关电源;程序里加“连续 N 秒有效才进入导航流程”的门槛,防止刚上星、位置还没收敛时就拿来算方向。把天线尽量放高、陶瓷面朝上,是最便宜的改善方式。

5.3 经纬度偶尔跳变几百米,小车跟着突然转向

现象:正常行驶时某个点突然飞出去几公里,下一帧又恢复正常,控制逻辑跟着抽风。

原因:多径效应产生位置尖刺;也可能是解析字段错位产生偶发坏值。两种源头都需要在解析层先拦截,再靠应用层兜底。

解决: 第一层在解析层,状态位不是 A、校验失败、时间不对的帧全部丢弃。 第二层在应用层,把当前帧和上一帧的距离除以时间差得到估计速度,超过平台物理极限就丢弃。对小车来说上限可以取 20 米/秒,这个数值要按平台调整,不能抄死。

double gap_m = distance_haversine(last_lat, last_lon, cur_lat, cur_lon); double dt = current_sec - last_sec; if (dt > 0 && gap_m / dt > 20.0) { discard_frame(); /* 视为跳变帧 */ }

参数说明:20 米/秒对普通小车是安全上限。如果做无人机或高速平台,这个值一定要放大,否则真实高速运动会被误杀,导航系统会认为位置一直不可信。

5.4 距离算出来与实际差两倍甚至乱套

现象:用经纬度算两点距离,结果比实际远一倍,或者轨迹方向完全对不上。

原因:最高频的坑是度分格式没换算。模块输出 2456.7890 是 24 度 56.7890 分,如果把整个数当成十进制度,误差就有 30% 以上。其次是把经纬度写反,或者 N/S、E/W 符号没加负号。距离差异如果超过 100%,基本都是坐标系和单位问题,不是公式精度问题。

解决:先用两个已知坐标点做闭环测试。用 Haversine 公式计算,和一个可信的在线坐标距离计算器比对,误差在两三米内就说明换算正确。确认公式无误后,再对接下来的平面近似代码做同样验证。

5.5 速度字段读出来一直是零

现象:已经显示定位成功,移动时 speed 字段也为 0;偶尔有数值但明显偏小或偏大。

原因有两种:解析字段位置不对,把纬度的下一个字段当成了速度,或者用了 GNGGA 却指望它输出速度——GGA 帧里根本没有速度字段;另一种是模块静止时速度本来就是 0,低速运动时低于测速门限也可能显示 0,1Hz 输出频率下读数尤其粗糙。

解决:先打印原始帧,核对 GPRMC 第 7 个字段才是速度。如果必须用 GNGGA 的速度,要把解析帧类型配成 RMC 或同时解析两种帧。低速场景想得到连续速度值,把模块输出频率提高到 5Hz,在运动控制里也会明显平滑。

6. 从经纬度到导航控制量:距离、方位角与整链验证

6.1 东北天平面坐标与两点距离、方位角

拿到稳定经纬度后,下一步是把“当前位置”和“目标点”的差换算成可控制量。常见做法是用目标点作为局部原点,做东北天坐标近似。纬度差乘地球半径得到北向位移,经度差乘纬度余弦得到东向位移,这对几公里到几十公里的导航完全够用,比球面距离公式简单、直观。

#define EARTH_RADIUS 6371000.0 void latlon_to_local(double lat1, double lon1, double lat2, double lon2, double *east_m, double *north_m) { double dy = (lat2 - lat1) * 3.141592653589793 / 180.0; double dx = (lon2 - lon1) * 3.141592653589793 / 180.0; double mid = (lat1 + lat2) * 3.141592653589793 / 360.0; *north_m = dy * EARTH_RADIUS; *east_m = dx * EARTH_RADIUS * cos(mid); }

逻辑说明:东向分量要乘纬度余弦,因为经圈在远离赤道时会收缩,不修正就会在高纬度地区产生明显偏差。距离就是 east 和 north 的平方和开根号,方位角是 atan2(east, north),返回值范围是 -180 到 180 度,单位弧度,后面要转成度。

6.2 转向控制量的生成

控制核心是让目标方位角和当前运动方向的夹角收敛。最简单的比例控制在小车上就很实用,关键是先把角度差规范化到 [-180, 180],否则 350 度和 -10 度原本只差 20 度,直接相减会算出 360 度的假偏差,导致转向逻辑反向。

double bearing = atan2(east_m, north_m) * 180 / 3.141592653589793; double diff = bearing - current_heading; while (diff > 180) diff -= 360; while (diff < -180) diff += 360; int16_t steer_pwm = (int16_t)(diff * 2); /* 比例系数 2,按底盘调整 */

比例系数是调试时手感最明显的参数:系数太大,小车蛇形振荡;太小,靠近目标时转向无力。我习惯先给小系数,稳定后逐步加大,每次只改一个参数。

6.3 整链验证:五个关键量的调试法

我调试基于STM32的GPS导航时,习惯在调试口定时打印五个量:UTC 时间、十进制纬度、十进制经度、速度(米/秒)、到目标点距离(米)。打印频率 1Hz,数据量小不卡逻辑,然后带着终端从起点走 50 米,肉眼看距离是否从 50 米左右平稳降到 0,方位角是否基本指着一个方向不变。这套验证能同时暴露解析错误、坐标换算错误和控制符号问题。

我吃过亏的地方是第一次联调只打印经纬度,没打印速度,结果小车在墙角原地画圈,我还以为是模块坏了。后来发现航向角在静止时根本没更新,程序一直拿旧航向在纠偏。以后凡是做导航,第一件事就是把原始帧和关键换算量同时打印出来,比任何理论分析都快。希望这篇笔记能帮你在做基于STM32的GPS导航时少走几条弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进