做带定位功能的小板子,绕不开STM32 微控制器 + GNSS 模块这套组合。它便宜、资料多、串口一接就能吐坐标,看起来门槛极低;但真把它做成一台能用的GPS 导航系统,从硬件布板到报文解析、从坐标换算到航线偏航判断,中间的坑一个都不少。我前后折腾过几版,从一开始"屏幕上有数字就算成功",到后来能稳定记录轨迹、能给出方位角和偏航提示、能扛住断电重启,中间踩的雷基本都写在下面了。
这篇内容面向三类人:刚学完 STM32 外设、想找个综合性项目练手的同学;需要给设备加定位能力、但不清楚该从哪一步下手的工程师;以及手上已经有模块、卡在"坐标对不上""定位老漂""串口收不全"这类问题上的朋友。下面按硬件选型、报文解析、导航算法、实操落地、问题排查的顺序铺开,代码和参数都给到能直接照着改的程度。
1. 项目整体设计与思路拆解
1.1 先把一句需求翻译成可测量的系统指标
"做一个 GPS 导航系统"这句话拆开来看,其实藏着四层完全不同的需求。第一层是定位获取:模块能不能在合理时间内给出可信坐标,冷启动多久、热启动多久、在城市楼宇间丢星怎么办。第二层是数据解析:坐标是度分格式的字符串,速度是节,时间是 UTC,这些都得在 MCU 里转成可计算的数值。第三层是导航计算:当前位置到目标点的直线距离、方位角、与当前航向的夹角,这些才是"导航"两个字的实质。第四层是人机呈现与存储:屏幕刷新、按键交互、轨迹落盘。
我在动手前会强制自己把这些写成一张表,标上可验收的数字,否则调起来会无限期拖延。
| 指标项 | 典型目标值 | 说明 |
|---|---|---|
| 冷启动首次定位时间 | 30 秒以内(开阔天空) | 取决于模块、天线和天空视野 |
| 热启动首次定位 | 5 秒以内 | 依赖模块内部 RTC 与星历保存 |
| 定位更新率 | 1 Hz 起步,可配到 5 Hz | 骑行/步行场景 1 Hz 足够 |
| 水平定位精度 | 2.5 米级(开阔天空 CEP) | 遮挡环境下会显著劣化 |
| 速度精度 | 0.1 m/s 量级 | 低速时噪声明显 |
| 航向有效性 | 速度超过约 0.5 m/s 才可信 | 静止时航向无意义 |
| 主循环刷新 | 屏幕 8~10 Hz 刷新 | 太慢有拖影感,太快浪费 CPU |
| 轨迹记录间隔 | 1~5 秒可配 | 决定存储容量与轨迹平滑度 |
把这张表定下来,后面每一个技术选择都有了评判依据。比如有人问"要不要上 RTK",答案就在精度那一行——步行导航场景,2.5 米级完全够用,多花的成本和功耗换来的厘米级精度是浪费。再比如"要不要 10 Hz 更新率",1 Hz 已经能在 5 m/s 速度下每 5 米取一个点,配合插值足够画轨迹,10 Hz 除了增加串口带宽和解析压力,对体验的提升非常有限。
注意:上表里"精度"和"首次定位时间"都带前提条件。开阔天空和城市峡谷是完全不同的两个世界,验收指标一定要写清楚测试环境,否则永远扯不清是模块问题还是环境问题。
1.2 主控为什么选 STM32,而不是更便宜或更强的方案
选 STM32 做这类项目的理由,不是"性能强",而是外设组合刚好对上需求。GNSS 模块走 UART 输出 NMEA 明文,天然需要一路串口;屏幕通常走 I2C 或 SPI;轨迹要落 Flash 或 SD 卡,需要 SPI;再加按键、蜂鸣器、电池电压检测,几路 GPIO 和一路 ADC 就够了。这个外设清单,STM32F1 系列的入门型号就能满足,而且它的 DMA 控制器通道多、串口空闲中断配合 DMA 非常好用,这是解析变长报文的关键。
对比下来,几类替代方案各有短板。纯 8 位 MCU 跑得动解析,但双精度浮点和大量字符串处理会很吃力,算一次 Haversine 要几百微秒,加上屏幕刷新就容易卡顿。带 Wi-Fi 的 SoC 模块算力足,但功耗和启动时间不适合做常开设备,而且实时性受操作系统调度影响。更高端的 Cortex-M4/M7 带 FPU,浮点运算快一个数量级,但成本上去后,很多场景其实感知不到差别——1 Hz 更新率下,即使软件浮点要花 2 毫秒,占空比也只有 0.2%。
我个人的判断标准是这样的:如果你的解析频率不超过 5 Hz、屏幕分辨率不超过 320×240、不做复杂地图渲染,F1 级别的芯片完全够;一旦要做矢量地图绘制或者多点路径规划,直接跳到带 FPU 的型号,省下来的调试时间比芯片差价贵得多。
1.3 模块划分与数据流向,决定了后期的可维护性
这套系统我习惯切成五个层次,从下往上依次是:驱动层(UART 收发、I2C/SPI 屏幕、Flash 读写)、协议层(NMEA 帧组装与校验、字段解析)、数据层(定位状态结构体、轨迹环形缓冲、航点表)、算法层(距离/方位计算、偏航判断、平滑滤波)、应用层(界面状态机、按键处理、记录策略)。
层与层之间只通过结构体传递,不互相直接调用外设。这个约束听起来刻板,但收益很直接:我后来把 0.96 寸 OLED 换成 1.8 寸 TFT 彩屏,只改了驱动层和界面层,协议层和算法层一行没动;再后来把 GNSS 模块从一种型号换成另一种,只改了协议层里少数几个字段的位置映射,上层依然无感。
数据流是这样的:串口 DMA 把字节流灌进环形缓冲,主循环里取出一帧完整报文,校验通过后解析成gnss_fix_t结构体,压入一个深度为 1 的"最新定位"槽位(覆盖写),算法层每次从槽位读最新值做计算,应用层按自己的节奏取算法结果刷新界面。用"最新值覆盖"而不是队列,是因为导航只关心当前位置,处理不过来时丢掉旧数据比排队积压更合理。
2. 硬件选型与电路搭建的关键细节
2.1 主控与 GNSS 模块的匹配关系
GNSS 模块选型看四个参数:支持的星座、输出接口、默认波特率、是否有备份电源引脚。星座方面,只支持单一星座的老模块在开阔地够用,但进入半遮挡环境后可见卫星数会掉得很快;支持多星座的模块能用的卫星更多,首次定位和抗遮挡表现明显更好,成本差距已经很小,没有理由再用单星座。
接口方面,绝大多数模块是 UART 输出 NMEA 明文,也有 I2C 版本但资料少、生态差,不建议新手碰。默认波特率常见 9600,也有 38400 或 115200 的,买回来第一件事就是查手册确认,别默认 9600。备份电源引脚(通常叫 VBAT 或 V_BCKP)非常关键,接一颗纽扣电池或超级电容,模块断电后能保留星历和 RTC,下次热启动几秒就能定位;不接的话每次上电都是冷启动,几十秒的等待很折磨人。
主控侧要确认两点:选用的串口是否支持 DMA,以及 DMA 通道有没有和其他外设冲突。STM32 里不同串口对应的 DMA 通道是固定的,画板子之前把手册的 DMA 请求映射表翻出来对一遍,我见过有人在 PCB 打样之后才发现串口 3 的接收 DMA 通道被 SPI 占了,只能改走中断方式,接收高波特率数据时丢字节。
| 参数 | 常见取值 | 选型建议 |
|---|---|---|
| 星座支持 | 单星座 / 多星座 | 优先多星座 |
| 输出接口 | UART(TTL 电平) | 直连 MCU 串口 |
| 默认波特率 | 9600 / 38400 / 115200 | 以手册为准,首次上电先确认 |
| 更新率 | 1 Hz / 5 Hz / 10 Hz | 1 Hz 够用,高动态场景再提 |
| 备份供电 | 有 / 无 | 必须接,热启动体验差别巨大 |
| 天线形式 | 内置陶瓷 / 外置有源 | 手持内置,车载/骑行外置 |
2.2 电源、天线和布板,这里是八成的坑所在
GNSS 模块的供电电流不是恒定的,捕获阶段峰值能到几十毫安,稳定跟踪后降下来。如果和 MCU 共用一颗小容量 LDO,捕获瞬间的压降可能把 MCU 拉复位,表现就是"一上电就重启,重启完又重启"。解决办法是给 GNSS 单独一路 LDO,或者在模块电源脚旁边放足够容量的电容做本地储能,我一般放 10 µF 陶瓷加 100 µF 钽电容的组合,实测能压住这个冲击。
天线是另一半的坑。陶瓷贴片天线有明确的接地要求,底下那层地平面尺寸要够,太小会导致谐振点偏移、增益下降。天线周围要净空,别在上面盖金属壳、别贴着电池放,也别让屏幕排线从天线正下方走过。有源天线要注意馈电,模块一般提供天线偏置电压,直接接无源天线不会有问题,但有源天线接错可能烧掉偏置电路。
布板上,GNSS 模块尽量靠近板边、靠近天线,射频走线短且做 50 欧姆阻抗控制,下方完整地平面,不要跨越分割。模块的数字部分(UART)和射频部分保持距离,晶振、DC-DC 电感、屏幕背光升压这些干扰源统统离远点。这些要求听起来苛刻,但实测下来布局差和布局好的两块板,同一个位置静止不动,卫星数能差三四颗,水平漂移能差一倍。
提示:调试阶段如果定位慢、卫星数上不去,先别怀疑代码。把板子拿到室外空旷处,天线面朝上平放,静置三分钟看能不能定住。室内靠窗测试只能验证"模块活着",不能用来评估定位性能。
2.3 显示、存储与交互部件的取舍
显示屏的选择直接影响体验和开发工作量。0.96 寸 I2C OLED 是最省事的选择,两根线搞定,无需背光,阳光下虽然对比度一般但室内清楚,适合显示经纬度、速度、卫星数、方位角这四行信息。缺点是刷新率受 I2C 速率限制,128×64 全屏刷新在 400 kHz 下要几十毫秒,做不了流畅的动态箭头旋转。
想做好一点的界面就上 SPI 的 TFT 彩屏,128×160 或 240×320 都行,SPI 时钟拉到 20 MHz 以上,配合局部刷新能做出比较跟手的转向箭头和简易航线示意。代价是多占引脚、多花显存、代码量翻倍。我的建议是先做 OLED 版本跑通全链路,再升级屏幕,不要一上来就两头作战。
存储方面,如果只是记录最近几小时的轨迹,用片内 Flash 的某个扇区做环形日志就够,注意 Flash 擦写寿命和擦除粒度,按页对齐写、按块擦除,别一个点擦一次。要长时间记录上百小时,老老实实上 SD 卡加文件系统,注意卡座选弹片式还是自锁式、掉电时文件系统的一致性(要么定期f_sync,要么接受最后一段数据丢失)。按键最少两个:模式切换和标记航点,如果能加一个旋转编码器做菜单翻页会更舒服,但会占掉两个外部中断。
3. GNSS 报文解析与定位数据处理
3.1 NMEA-0183 语句逐字段拆解
模块默认会连续吐好几种语句,每种一行,以$开头、\r\n结尾,感叹号前的部分用逗号分隔。真正需要关心的主要是三条:$GNRMC或$GPRMC给时间、状态、经纬度、速度、航向;$GNGGA给定位质量、卫星数、海拔、HDOP;$GPGSV给可见卫星的方位仰角,用来画星空图。前缀里的GP是单 GPS 星座,GN是多星座组合,BD是北斗,GL是其他系统,解析时不要硬编码前缀,比较&line[3]开始的三个字母更稳妥。
$GPRMC的字段顺序是:时间(hhmmss.sss UTC)、状态(A 有效 / V 无效)、纬度(ddmm.mmmm)、南北半球、经度(dddmm.mmmm)、东西半球、地面速度(节)、地面航向(度)、日期、磁偏角、磁偏角方向、模式指示、校验和。这里有三个容易出错的地方:一是状态位,很多新手直接拿经纬度去用,V 状态下那串数字是无意义的旧值或空值,必须判 A 才更新;二是单位,速度是节不是 m/s,1 节约等于 0.514444 m/s;三是空字段,没定位时经纬度可能是空字符串,atof会返回 0,如果直接拿去算距离,结果就是"你在非洲西海岸外海"。
$GNGGA的第二字段是定位质量:0 表示无效,1 表示单点定位,2 表示差分定位,4 表示 RTK 固定解,5 表示 RTK 浮点解。这个值配合 HDOP(水平精度因子)一起用,就能做基本的质量过滤。HDOP 小于 1 是极好,1 到 2 良好,2 到 5 一般,超过 5 基本可以认为这个点不可靠。
3.2 串口接收:DMA + 空闲中断 + 环形缓冲
NMEA 报文长度不固定,从几十字节到上百字节都有,用固定长度中断接收很容易在帧中间被切断。最稳的架构是DMA 循环接收 + 串口空闲中断:DMA 一直往缓冲区里填字节,总线空闲时触发 IDLE 中断,此时读出 DMA 已传输计数,算出这一批收到多少字节,整批塞进环形缓冲区。
#define GNSS_BUF_SIZE 256 static uint8_t dma_buf[GNSS_BUF_SIZE]; static ringbuf_t gnss_rb; void gnss_uart_init(void) { /* 省略 GPIO / USART 时钟与引脚配置 */ DMA_InitTypeDef dma; DMA_DeInit(DMA1_Channel6); dma.DMA_PeripheralBaseAddr = (uint32_t)&USART2->DR; dma.DMA_MemoryBaseAddr = (uint32_t)dma_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = GNSS_BUF_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Circular; /* 循环模式,永不停止 */ dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel6, &dma); DMA_Cmd(DMA1_Channel6, ENABLE); USART_ITConfig(USART2, USART_IT_IDLE, ENABLE); USART_DMACmd(USART2, USART_DMAReq_Rx, ENABLE); NVIC_SetPriority(USART2_IRQn, 6); NVIC_EnableIRQ(USART2_IRQn); } /* 上一次已处理到的 DMA 写指针 */ static uint16_t last_pos = 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_IDLE) != RESET) { volatile uint32_t tmp; tmp = USART2->SR; /* 先读 SR */ tmp = USART2->DR; /* 再读 DR,清 IDLE 标志 */ (void)tmp; uint16_t cur = GNSS_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel6); if (cur != last_pos) { if (cur > last_pos) { ringbuf_push(&gnss_rb, &dma_buf[last_pos], cur - last_pos); } else { ringbuf_push(&gnss_rb, &dma_buf[last_pos], GNSS_BUF_SIZE - last_pos); ringbuf_push(&gnss_rb, &dma_buf[0], cur); } last_pos = cur; } } }这段代码有两个细节值得说明。第一,清 IDLE 标志必须"读 SR 再读 DR",只读其中一个在某些系列上清不掉,会反复进中断,现象是系统卡死在中断里。第二,环形缓冲区的大小要至少能装下两到三帧最长的报文,否则主循环处理慢的时候会被覆盖。我用 1024 字节,够存十来帧。
解析侧的处理顺序是:从环形缓冲区里找$,找到后找\r或\n作为结束,两者之间就是一条完整报文。找不到结束符就说明帧还没收全,等下一批数据。这里加一个长度上限保护,超过 128 字节还没结束就丢弃并重新找$,防止因为丢掉一个字节导致后面全部错位。
static int read_frame(char *out, int cap) { int idx = 0, started = 0; uint8_t ch; while (ringbuf_pop(&gnss_rb, &ch)) { if (!started) { if (ch == '$') { started = 1; out[idx++] = (char)ch; } continue; } if (ch == '\r' || ch == '\n') { if (idx > 6) { out[idx] = '\0'; return idx; } started = 0; idx = 0; continue; } out[idx++] = (char)ch; if (idx >= cap - 1) { started = 0; idx = 0; } } return 0; }3.3 校验、度分换算与坐标基准统一
校验和的算法是:$与*之间所有字符(不含这两个)逐字节异或,结果以两位十六进制表示。这一步千万别省,串口在干扰环境下偶尔会翻位,不校验就可能把A变成V、把数字变成别的数字,算出来的坐标能偏出几公里。
static int checksum_ok(const char *line) { if (line[0] != '$') return 0; const char *star = strchr(line, '*'); if (!star || strlen(star) < 3) return 0; uint8_t cs = 0; for (const char *p = line + 1; p < star; ++p) cs ^= (uint8_t)(*p); char hex[3] = { star[1], star[2], '\0' }; return cs == (uint8_t)strtol(hex, NULL, 16); }度分转换是另一个高频错误点。NMEA 给的纬度格式是ddmm.mmmm,经度是dddmm.mmmm,都是"度分"混合,不是十进制度。换算方法是:整数部分除以 100 取整得到度,余下的小数乘以 100 得到分,再用"度 + 分/60"合成十进制度,最后按半球符加符号。
static double dm_to_deg(const char *dm, char hemi) { if (!dm || dm[0] == '\0') return 0.0; double raw = atof(dm); if (raw <= 0.0) return 0.0; int deg = (int)(raw / 100.0); double min = raw - deg * 100.0; double v = deg + min / 60.0; if (hemi == 'S' || hemi == 'W') v = -v; return v; }坐标基准这件事需要单独说一句。GNSS 模块输出的坐标属于一种全球通用的地心坐标系,属于原始观测值;而不同来源的地图底图可能使用不同的坐标基准,两者叠加显示时会出现偏移。做纯记录、纯计算(距离、方位、轨迹回放)时不需要任何转换;只有当你要把定位点画到某份来自第三方的底图上时,才需要先确认那份底图使用的是什么基准,再做统一换算。我的建议是:项目初期全部保留原始坐标,换算只在渲染那一层做,并且做成可开关的模块,这样后面换底图不会推翻已有数据。
3.4 定位质量过滤与轨迹平滑
原始定位数据一定带有跳变。静止不动的时候,坐标会在几米范围内来回跳;刚上电的前几十秒,偶尔会吐出一个离你几百公里的荒谬点。直接把这些点画成轨迹,屏幕上就是一团乱麻。
我用的过滤策略分三级。第一级是状态门控:RMC 状态非 A、GGA 质量为 0、卫星数小于 4、HDOP 大于 5,这四种情况任一成立就丢弃该点,不进入后续流程。第二级是速度合理性检查:用相邻两点算距离除以时间差得到隐含速度,如果超过合理上限(比如骑行场景设 30 m/s),说明至少有一个点错了,此时保留旧点、丢弃新点。第三级是平滑:对通过前两级过滤的点做加权滑动平均,取最近三个点,权重 0.5 / 0.3 / 0.2,能明显压掉抖动又不至于引入太大滞后。
typedef struct { double lat, lon; uint32_t tick; } pt_t; static pt_t hist[3]; static int hist_cnt = 0; int fix_accept(const gnss_fix_t *f, double max_speed_mps) { if (!f->valid || f->satellites < 4 || f->hdop > 5.0f) return 0; if (hist_cnt > 0) { double d = distance_m(hist[0].lat, hist[0].lon, f->lat, f->lon); uint32_t dt = f->utc_ms - hist[0].tick; if (dt > 0 && d / ((double)dt / 1000.0) > max_speed_mps) return 0; } hist[2] = hist[1]; hist[1] = hist[0]; hist[0].lat = f->lat; hist[0].lon = f->lon; hist[0].tick = f->utc_ms; if (hist_cnt < 3) hist_cnt++; return 1; }注意:平滑会引入相位滞后,速度越快滞后造成的误差越大。如果做的是高速移动场景(比如车载),把滑动窗口缩到两个点,或者干脆只在静止判断成立时才平滑。另外,平滑后的坐标只用于显示和画轨迹,航点标记、里程累计这类操作建议用原始点,避免误差累积。
4. 导航算法与界面呈现
4.1 距离、方位角与偏航角的计算方法
两点之间的距离,小范围可以用平面近似,但工程上直接用球面公式更省心,误差也小。Haversine 公式在嵌入式上计算量可接受,标准数学库的sin、cos、atan2都有实现。
#define EARTH_R 6371000.0 #define DEG2RAD 0.017453292519943295 #define RAD2DEG 57.29577951308232 double distance_m(double lat1, double lon1, double lat2, double lon2) { double p1 = lat1 * DEG2RAD, p2 = lat2 * DEG2RAD; double dp = (lat2 - lat1) * DEG2RAD; double dl = (lon2 - lon1) * DEG2RAD; double a = sin(dp / 2) * sin(dp / 2) + cos(p1) * cos(p2) * sin(dl / 2) * sin(dl / 2); if (a > 1.0) a = 1.0; return 2 * EARTH_R * atan2(sqrt(a), sqrt(1.0 - a)); } double bearing_deg(double lat1, double lon1, double lat2, double lon2) { double p1 = lat1 * DEG2RAD, p2 = lat2 * DEG2RAD; double dl = (lon2 - lon1) * DEG2RAD; double y = sin(dl) * cos(p2); double x = cos(p1) * sin(p2) - sin(p1) * cos(p2) * cos(dl); double b = atan2(y, x) * RAD2DEG; return (b < 0.0) ? b + 360.0 : b; }方位角算出来是 0 到 360 度,0 为正北、90 为正东,这个角度是从当前位置指向目标点的绝对方位。而模块给出的航向角是载体的行进方向。两者相减并归一化到 -180 到 180 之间,才是真正的"偏航角":正数表示目标在右手边,应该右转;负数表示在左手边。归一化的写法要小心,先做一次模运算再加 180 再取模,避免负数取模的结果不符合预期。
double wrap180(double a) { a = fmod(a + 180.0, 360.0); if (a < 0) a += 360.0; return a - 180.0; } double delta = wrap180(bearing_deg(cur.lat, cur.lon, wp.lat, wp.lon) - cur.course);这里有个必须提醒的细节:静止时航向角无效。模块在低速时输出的航向可能是上一次的残留值或者随机值,此时算出来的偏航角毫无意义。我的做法是当速度低于 0.5 m/s 时,界面不显示转向箭头,只显示"到目标距离",等动起来再显示方向。另外近距离(比如 5 米以内)时,方位角对坐标噪声极其敏感,一两米的抖动能让箭头转上百度,所以距离小于阈值就直接显示"已到达"。
4.2 屏幕刷新策略与界面信息分层
界面信息要分层,把"每秒都在变"和"偶尔才变"的东西分开处理。经纬度、速度、卫星数、偏航角属于高频刷新,一秒一次即可;航点名、总里程、记录状态属于低频,几百毫秒到几秒更新一次都行;菜单、设置页属于事件驱动,按键触发才重绘。
如果用的是 OLED,全屏清屏加重绘的方式简单粗暴,128×64 也就是 1 KB 显存,刷新一次几十毫秒,勉强能用,但会出现可见的闪烁。更好的做法是只重绘变化的区域:把屏幕按行切成几个独立区块,记录每个区块的上一帧内容,值变了才重绘那一块。这一招能把平均刷新时间压到十毫秒以内,视觉上非常干净。
如果用的是 SPI 彩屏,一定要配一块显存(MCU 内存或者屏幕自带 GRAM),在内存里完成所有绘制操作,最后一次性推送或用局部开窗方式推送。千万不要每画一个字符就发一次坐标和颜色命令,SPI 上的命令开销会让帧率崩掉。
界面布局上我一般这样分配:顶部一行是状态栏,显示卫星数、定位质量、电池图标;中间大面积区域显示核心信息,导航模式下是一个大号箭头加目标距离,记录模式下是轨迹缩略图;底部一行显示经纬度,用较小字号。信息密度不用追求满,能一眼看清关键数字就够了。
4.3 航点管理、轨迹落盘与掉电保护
航点我用一个定长数组加计数管理,最多存 32 个,每个点存经纬度、名称(8 字节)、类型。数组满了就覆盖最早的那个,或者提示用户先清理。存片内 Flash 时要按页对齐,写入前先擦除整页,所以我采用"整表重写"的策略:任何一次增删改,都把 32 个点的完整数组写到另一页,用两个页轮流写以延长寿命并保证任意时刻至少有一份完整数据。
轨迹记录的策略有两种。一种按时间采样,固定一秒一个点,简单但静止时会产生大量重复点;另一种按距离采样,移动超过 5 米才记一个点,静止时不写,适合长时间记录。我倾向距离采样加一个"最小时间间隔"约束,比如距离超过 5 米且距上一点超过 2 秒,既能过滤抖动又能控制数据量。
数据格式上,每个点我用 8 字节定长记录:相对起点的经纬度各存一个 32 位有符号整数,单位是百万分之一度(精度约 0.11 米,够用),再加一个 16 位的相对时间戳。这样一小时 1 Hz 记录也就 28 KB,一片几兆的 Flash 能存几百小时。
提示:写 Flash 或 SD 卡的时候要留意主循环阻塞。一页 Flash 擦写可能耗时几十毫秒,这段时间里串口数据还在源源不断进来,如果环形缓冲不够大就会丢帧。解决办法是把写操作分片,或者用双缓冲、在写之前先确认缓冲区剩余空间足够。
5. 动手实操:从裸机工程到能跑的样机
5.1 工程骨架与外设初始化顺序
初始化顺序我踩过坑,总结下来是这样:先时钟和电源(含 Flash 等待周期配置),再调试串口(方便打印日志),然后系统滴答定时器、GPIO、业务串口加 DMA、I2C 或 SPI 屏、Flash 或 SD 卡,最后才初始化 GNSS 模块相关的状态机。把调试串口放在前面是为了后面每一步都能打日志,出问题时能定位到具体哪一步卡住。
系统时钟配置要特别注意 Flash 等待周期,主频拉到 72 MHz 时等待周期必须配置正确,否则跑起来会莫名其妙死机,而且是概率性的,很难查。我一般把 SysTick 配成 1 ms 中断,用一个全局的volatile uint32_t g_tick累加,作为所有超时和调度的基准。
volatile uint32_t g_tick = 0; void SysTick_Handler(void) { ++g_tick; } /* 非阻塞延时:让出 CPU 给主循环 */ void delay_ms_nb(uint32_t ms) { uint32_t start = g_tick; while ((g_tick - start) < ms) { /* 可以在这里做别的事,或者直接空转 */ } }主循环的骨架我做成一个简单的轮询调度,每个任务检查自己的时间间隔是否到期,到期才执行。这样没有任何阻塞,串口和按键的响应都是及时的。
int main(void) { clock_init(); dbg_uart_init(); systick_init(); gnss_uart_init(); display_init(); storage_init(); ui_init(); uint32_t t_parse = 0, t_disp = 0, t_log = 0; while (1) { if (g_tick - t_parse >= 20) { t_parse = g_tick; gnss_poll(); } if (g_tick - t_disp >= 100) { t_disp = g_tick; ui_refresh(); } if (g_tick - t_log >= 1000) { t_log = g_tick; track_maybe_log(); } key_scan(); } }gnss_poll()里做的事情就是:从环形缓冲读帧、校验、解析、过滤、更新最新定位槽位。20 ms 轮询一次,1 Hz 的数据量绰绰有余,而且留了余量应对 5 Hz 的高更新率。
5.2 解析核心代码逐段说明
字段切分我不用strtok,因为它会修改原字符串、不可重入,而且连续逗号(空字段)的处理行为跟预期不一致。自己写一个next_field更可控。
static char *next_field(char **p) { char *start = *p; if (!start) return NULL; char *comma = strchr(start, ','); if (comma) { *comma = '\0'; *p = comma + 1; } else { *p = NULL; } return start; }RMC 解析的主体:
static int parse_rmc(char *line, gnss_fix_t *out) { /* $GPRMC,hhmmss.sss,A,ddmm.mmmm,N,dddmm.mmmm,E,spd,cog,ddmmyy,,,mode*CS */ char *p = line; char *f = next_field(&p); /* $GPRMC */ if (!f || (strncmp(f + 3, "RMC", 3) != 0)) return -1; char *utc = next_field(&p); char *stat = next_field(&p); char *lat = next_field(&p); char *ns = next_field(&p); char *lon = next_field(&p); char *ew = next_field(&p); char *spd = next_field(&p); char *cog = next_field(&p); if (!stat || stat[0] != 'A') { out->valid = 0; return -1; } out->lat = dm_to_deg(lat, ns ? ns[0] : 'N'); out->lon = dm_to_deg(lon, ew ? ew[0] : 'E'); out->speed_kmh = spd ? (float)(atof(spd) * 1.852) : 0.0f; /* 节 -> km/h */ out->course = cog ? (float)atof(cog) : 0.0f; out->utc_ms = utc_to_ms(utc); out->valid = 1; return 0; }几点说明。strncmp(f + 3, "RMC", 3)这个写法是为了同时兼容$GPRMC、$GNRMC、$BDRMC等前缀,只认语句类型不认星座。速度换算那里,1 节等于 1.852 km/h,我把内部单位统一成 km/h,显示时再决定是 km/h 还是 m/s,避免到处换算。utc_to_ms是我自己写的把hhmmss.sss转成当天毫秒数的函数,用途是判断时间差,不用管日期进位。
GGA 解析单独一个函数,主要取卫星数、HDOP、定位质量和海拔。注意 GGA 里的定位质量字段是第 6 个字段,别和 RMC 的状态位搞混。
5.3 联调顺序与验证方法
联调最忌一上来就全系统跑。我推荐的顺序是:先验证串口通路,再验证解析正确性,再验证算法,最后才是界面。
验证串口通路:把串口收到的原始字节直接转发到调试串口,用电脑上的串口助手看,能看到连续的$GPRMC,...文本就说明硬件通了。这一步还能顺便确认波特率对不对,波特率错的时候会看到一堆乱码。
验证解析:把解析出来的经纬度、速度、卫星数打印到调试串口,和模块输出的原始报文人工比对。我一般选三条报文逐一核对,一条有效定位、一条无定位、一条字段为空的,三条都对上,解析就基本没问题了。
验证算法:在电脑上先用脚本算一遍两个已知坐标的距离和方位角,把结果记下来,再让 MCU 算同样的输入,对比差值。只要在厘米和零点几度以内就算通过。这一步能抓出单位错误和参数顺序错误,这两种错误在界面上看不出来,只会表现为"距离明显不对"。
验证界面:最后接屏幕,先用固定的假数据刷界面,确认布局、字体、箭头方向都正确,再切到真实数据。用假数据这一步很关键,因为真实数据一直在跳,你没法判断是界面画错了还是数据本身在跳。
6. 常见问题与排查技巧实录
6.1 定位类问题:从"完全没星"到"漂得离谱"
定位问题的排查有明确的顺序,从物理层往上走,不要一上来就翻代码。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全收不到任何语句 | 波特率不对、TX/RX 接反、模块未供电 | 用示波器看模块 TX 脚有无波形;换波特率逐个试 |
| 收到语句但一直是 V 状态 | 天线未接、室内无信号、模块未真正启动 | 移到室外空旷处静置;检查天线馈电 |
| 卫星数极少(1~3 颗) | 天线接地不良、附近有干扰源 | 换位置对比;断开屏幕背光再测 |
| 定位能定但漂移大 | HDOP 高、多径反射、天线朝向不对 | 看 HDOP 值;天线面朝上、远离金属 |
| 冷启动时间特别长 | 备份电池没电、星历丢失 | 测 VBAT 电压;确认电池是否装反 |
| 定位后突然丢失 | 供电跌落导致模块复位 | 示波器抓模块电源脚纹波 |
我遇到过最诡异的一次是"白天正常、晚上漂移大",最后查出来是屏幕背光亮度自动调节,晚上背光调高后升压电路干扰变大,把天线附近的噪声底抬上去了。解决办法是把背光升压电感和天线拉开距离,并在模块电源上加一级 LC 滤波。这类问题没有捷径,只能靠对比实验逐项排除。
另外一个高频误区是拿室内靠窗的测试结果去判断模块好坏。窗户附近能看到一小片天空,可能凑巧定上位,但卫星几何分布极差,HDOP 动辄十几,坐标能漂出几十米。这种数据不能用来评估性能,也不能用来调算法,只会把你带偏。
6.2 数据与通信类问题:丢帧、乱码和解析失败
串口收不全是最常见的问题,成因通常有三个。一是缓冲区太小,主循环在做 Flash 写入或者屏幕全屏刷新时被阻塞几十毫秒,这期间来的数据把环形缓冲冲掉了。二是 DMA 计数读取时机不对,在非空闲中断里读DMA_GetCurrDataCounter得到的是瞬时值,可能读到一半的状态。三是波特率误差,内部 RC 振荡器做时钟源时误差可能超过 3%,高波特率下会持续误码。
解决办法分别是:加大环形缓冲到能装下两秒的数据量;只在 IDLE 中断里读取并处理;外部晶振做时钟源,别用内部 RC。这三条做完,丢帧基本能绝迹。
乱码通常就是波特率不匹配或者电平不匹配。GNSS 模块输出的是 TTL 电平(0 到 3.3 V 或 0 到 5 V),直接接 MCU 的串口脚没问题,但如果是 RS-232 电平就必须加转换芯片,直连会烧引脚。还有一种情况是模块默认波特率被改过(有些模块支持自适应或者出厂配置不同),这时逐个波特率试一遍,能出正确文本的那个就是。
解析失败除了校验和错误,还有两个隐蔽原因。一是行尾处理,有些模块用\r\n,有些只用\r,代码里两种都认最保险。二是缓冲区边界,一帧报文刚好跨在环形缓冲的两段上,如果拼接逻辑写错就会漏字符。我的做法是把帧读取做成"状态机推进",不依赖任何假设,一个字节一个字节地喂进去,$开始、换行结束,中间不管怎么分段都不会错。
/* 逐字节状态机的极简写法 */ static char frame[128]; static int fidx = 0; static int in_frame = 0; void gnss_feed(uint8_t ch) { if (ch == '$') { in_frame = 1; fidx = 0; frame[fidx++] = '$'; return; } if (!in_frame) return; if (ch == '\r' || ch == '\n') { if (fidx > 6) { frame[fidx] = '\0'; gnss_on_frame(frame); } in_frame = 0; fidx = 0; return; } if (fidx < (int)sizeof(frame) - 1) frame[fidx++] = (char)ch; else { in_frame = 0; fidx = 0; } /* 超长,丢弃重来 */ }6.3 几个只有真正上手才会遇到的细节
第一个是浮点打印的坑。在资源受限的 MCU 上,printf打印%f默认是关闭的,需要编译选项打开,而且打开后代码体积会增加好几 KB,还可能引入重入问题。调试阶段我通常不用printf打浮点,而是把数值乘以 1e6 转成整数再打,或者自己写一个定点格式化函数,既省空间又不会在中断里出问题。
第二个是时间的处理。RMC 给的是 UTC 时间,本地显示需要加偏移。我建议内部一律存 UTC 毫秒数,只在显示层做偏移,并且把偏移量做成可配置项。另外跨天的时候hhmmss会回绕,做时间差计算必须处理这个回绕,否则在午夜前后会出现一个巨大的负时间差,把所有速度检查都判成异常。
第三个是低功耗的真实收益。很多人以为关掉背光、降低主频就能省电,实际上 GNSS 模块本身才是耗电大头,通常在二十到三十毫安量级。真要省电,正确的做法是让模块进入周期唤醒模式(如果支持),或者干脆用运动传感器做触发,静止超过一定时间就彻底断掉模块电源。主控这边把主频降到几兆、关掉不用的外设时钟,能省下的只有几毫安。
第四个是航点方向箭头的显示逻辑。屏幕上的箭头有两种画法:一种是箭头固定朝上、旋转整个屏幕内容,另一种是箭头按绝对方位旋转、地图固定。前者适合步行导航,视觉上更自然;后者适合有固定地图的场景。我一开始做的是固定箭头,结果用户反馈"箭头不动,不知道往哪走",后来改成箭头随偏航角旋转,同时上方显示目标方位,体验好很多。旋转绘制需要用到三角函数算像素坐标,注意提前算好 sin/cos 表,别在刷新时现场算。
第五个是里程统计的累计误差。用相邻点距离累加算总里程,采样越密越准,但噪声点也会被算进去。静止时那几米的来回抖动,一小时能虚增几百米。我的处理是给单次位移设一个下限(比如小于 1.5 米不计入),并且要求该点通过质量过滤后才参与累计,实测下来静止一小时的虚增从几百米降到了个位数。
注意:这些阈值都是场景相关的。步行、骑行、车载三种场景的最小平移、平滑窗口、速度上限完全不同,别指望一套参数通吃。我的习惯是把这些参数集中放在一个头文件里,换个场景改一行重编译,比在代码里到处埋魔数强得多。
最后分享一个我自己一直在用的调试技巧:让设备同时输出两路数据,一路是解析后的结构化数值(精简的 CSV 格式),另一路是原始报文,两路都通过调试串口打出来。出问题时把日志存下来,在电脑上用脚本一跑,能瞬间看出是"数据源错了"还是"我算错了"。这个习惯帮我省下的时间,远比写这几行日志的成本高。