STM32+GNSS GPS导航系统:NMEA解析与偏航判断实践
发布时间:2026/10/29 21:29:46 锦皓数字建站

做带定位功能的小板子绕不开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 占了只能改走中断方式接收高波特率数据时丢字节。参数常见取值选型建议星座支持单星座 / 多星座优先多星座输出接口UARTTTL 电平直连 MCU 串口默认波特率9600 / 38400 / 115200以手册为准首次上电先确认更新率1 Hz / 5 Hz / 10 Hz1 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/s1 节约等于 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 格式另一路是原始报文两路都通过调试串口打出来。出问题时把日志存下来在电脑上用脚本一跑能瞬间看出是数据源错了还是我算错了。这个习惯帮我省下的时间远比写这几行日志的成本高。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。