做嵌入式这行,凡是把STM32微控制器和GPS导航系统放在一起的项目,十有八九都会经历同一个心路历程:第一天觉得"不就是串口读字符串嘛",第七天开始怀疑人生,第十四天对着屏幕上乱跳的经纬度发呆。我前后做过几个类似的定位终端,有车载轨迹记录的,有手持测绘打点的,也有给移动设备做定位辅助的,踩的坑高度重合。这篇就把整套东西从头到尾拆一遍:硬件怎么搭、串口数据怎么收才不丢、NMEA报文怎么解析才不崩、WGS-84坐标怎么变成人能看懂的距离和方位,以及定位精度不稳的时候到底该往哪个方向查。
适合谁看?做过一点单片机、会用串口打印调试信息、但没正经做过定位系统的朋友最合适。如果你已经做过几版,也能在里面找到一些关于天线布局、电源纹波和滤波策略的细节,这些内容一般的教程不会写,因为写教程的人往往只跑通了"有数据输出"这一步,没做过长时间野外测试。
1. 先把系统骨架定下来:一颗MCU、一个模块、三条数据流
很多人一上来就画原理图,结果画到一半发现不知道MCU该配哪些外设。先把数据流理清楚,硬件自然就出来了。整套系统本质上只有三条流:模块往MCU送的定位流、MCU往人机界面送的显示流、MCU往存储器送的记录流。这三条流各自对资源的要求完全不同,想明白了再选型,能省掉大量返工。
1.1 定位流:为什么串口是唯一合理的入口
市面上的定位模块,对外的接口无非就是串口、I2C、SPI这几种,少量模块还带USB。从工程实践看,串口几乎是默认选项,原因不复杂:定位数据是低速、持续、不定长的字节流,典型输出频率1Hz到10Hz,单条报文最长不超过82字节。这种"慢速、长包、连续"的特征,天生不适合SPI那种要靠片选和时钟严格同步的方式,也不适合I2C那种主从仲裁容易被打断的场合。异步串口只要配置好波特率和帧格式,剩下的就是收字节,MCU的负担极低。
配置上,绝大多数模块出厂默认9600bps、8位数据位、无校验、1位停止位,也有默认38400或者115200的。这里有个细节值得说:虽然9600bps看起来慢,但算一下就够用了。1Hz输出时,一个周期内模块要吐GGA、RMC、GSA、GSV、VTG五条报文,加起来大概400到600字节,9600bps约合960字节/秒,勉强够。如果你想上5Hz或者10Hz,就必须把波特率提到38400甚至115200,否则报文会互相挤压,MCU这边收到的就是残包。我一般习惯直接开到115200,反正STM32的USART完全吃得住,还省得以后加频率时再改配置。
提示:改模块波特率通常要发一条厂商自定义的配置报文,不同模块指令不同。改完之后记得同步改MCU端的初始化参数,并且把配置写进模块的非易失存储,否则下次上电又回到默认值,这种"改了但没生效"的问题非常浪费调试时间。
1.2 显示流与记录流:分开考虑,别混在一起
显示这块,小尺寸单色OLED最省事,I2C两线搞定,分辨率128x64够显示经纬度、速度、航向、卫星数、定位状态这几项。彩屏TFT好看但占RAM,还要考虑刷屏带宽,STM32F103这类资源紧张的芯片上跑全屏刷新会明显拖慢主循环。我的做法是把刷新率压到2Hz,并且只重绘变化的区域,比如经纬度区域和状态区域分开刷新,这样即便用软件I2C也不会卡。
记录流就更有讲究了。如果只是记最近几十个航点,片内Flash或者外挂EEPROM就够;要做长时间轨迹记录,必须上SD卡,而且是SPI模式,因为SDIO对布线和时钟要求高,小板子上很容易出问题。这里的关键是写卡一定要做缓冲:SD卡的写入延迟是毫秒级且不稳定的,如果你在串口中断里直接写卡,整个系统都会被拖死。正确做法是把解析出来的航点先塞进一个独立的记录队列,主循环在空闲时批量落盘,一次写512字节对齐的块,效率能翻好几倍。
| 数据流 | 接口 | 典型速率 | 资源占用 | 设计要点 |
|---|---|---|---|---|
| 定位流 | USART + DMA | 1-10Hz | 一个DMA通道 + 1KB缓冲 | 必须用空闲中断收不定长包 |
| 显示流 | I2C 或 SPI | 2Hz刷新 | 1KB显存 | 局部刷新,避免全屏重绘 |
| 记录流 | SPI + SD卡 | 1Hz写入 | 512B块缓冲 | 主循环批量落盘,禁止中断内写 |
1.3 MCU选型:够用比强大更重要
我见过不少人一上手就挑最高配的型号,结果板子做出来功耗高、成本高,实际只用到三成外设。定位类应用对算力的要求其实很低:NMEA解析是纯字符串处理,坐标换算只是几十次浮点运算,1Hz频率下即便用Cortex-M3内核的入门型号也绰绰有余,CPU占用率通常不到5%。真正需要关注的是串口数量、DMA通道数、Flash容量和低功耗模式。
如果要做多模块融合,比如同时接一个定位模块和一个惯性测量单元做组合导航,那就得选带双串口、最好带硬件浮点单元的型号,因为卡尔曼滤波里的矩阵运算用软件浮点会明显吃力。我的经验是:单纯做定位显示和记录,选基础型号就行;要做姿态融合或者地图渲染,再往上加一档。选型时还有个容易忽略的点——后备域。有些型号带独立的VBAT引脚和备份寄存器,接一颗纽扣电池就能在主电源断开时维持RTC和少量数据,这对记录时间戳很有用。
2. 硬件层的隐性成本:天线、电源与串口电平这些地方最容易翻车
软件问题排查起来至少有串口打印可看,硬件问题往往表现为"程序没错但就是不对",最难查。这一章讲的三块内容——有源天线的馈电、电源纹波、串口电平匹配——几乎覆盖了我遇到过的八成"玄学故障"。
2.1 有源天线的馈电不是接上就行
多数定位模块支持外接有源天线,增益一般在20到30dB。有源天线内部带低噪声放大器,需要模块通过射频座提供直流偏置,典型值是3.3V,电流几十毫安。问题就出在这里:很多人在画板子时把射频座当成纯信号接口,只连了RF走线,忘了模块的偏置引脚要接到电源,或者在中间串了隔直电容把直流也隔掉了。结果就是模块能输出报文,但卫星数始终个位数,定位慢得离谱。
正确的做法是:模块的RF馈电引脚经一个电感(典型值几十纳亨)接到3.3V,电感的作用是隔离射频信号不让它窜进电源;天线座的中心针通过这个电感获得直流,同时射频信号通过同一根线回到模块。如果模块内部已经集成了偏置电路,那就只需要外部提供一个干净的3.3V,不用再加电感。判断方法很简单:万用表量天线座中心针对地的电压,有源天线正常工作时应该是3V左右。
布局上,天线座要尽量靠近模块,射频走线做50欧姆阻抗控制,走线短、直、不打孔、不跨越分割地平面。天线本体要远离MCU晶振、DC-DC电感和屏幕排线,这些干扰源会直接把噪声灌进接收前端。我做过一次对比测试,同一块板子,天线从屏幕排线旁边挪到板子边缘,冷启动首次定位时间从90多秒降到40秒左右,差别非常明显。
2.2 电源纹波:最容易被忽视的复位来源
定位模块对电源的敏感度比一般外设高。它的射频前端在捕获微弱信号,电源上几十毫伏的高频纹波就可能抬高噪声基底,表现为定位慢、坐标漂移大。更麻烦的是,如果MCU和模块共用一路LDO,而模块在启动瞬间有较大的浪涌电流,会把电源拉低导致MCU复位,现象就是"每隔几秒重启一次",新手很容易误判成看门狗问题或者堆栈溢出。
我的处理方式分三层:第一层是物理隔离,模块单独用一路LDO,或者至少在主电源和模块之间串一个磁珠再并10uF加0.1uF电容;第二层是去耦就近布置,模块的电源引脚旁边一定要有0.1uF,不要放在板子另一头;第三层是上电时序,模块的使能引脚最好由MCU控制,让MCU先完成初始化再给模块上电,避免模块启动浪涌干扰MCU的复位电路。
注意:如果你在调试时发现系统偶发复位,先别急着查软件。拿示波器在3.3V轨上抓一下,触发方式设成下降沿,电压阈值设成2.9V,跑半小时看看有没有掉下来。这个动作花不了十分钟,但能排除掉一大类疑难问题。
2.3 串口电平与共地:3.3V和5V不能想当然
模块的UART电平通常是3.3V TTL,和STM32的IO电平一致,可以直接连。但如果你的模块是5V电平版本,或者你中间接了一个电平转换芯片而方向搞反了,就会出现"能收到数据但全是乱码"或者"完全收不到"的情况。更隐蔽的是共地问题:如果模块由独立电源供电,而两个电源的地没有连在一起,串口线上就没有共同的参考电平,收到的字节会随机出错。这种故障的特点是错误率不高但持续存在,看起来像是波特率不匹配。
排查顺序建议是这样:先确认双方波特率一致,再用示波器或者逻辑分析仪抓TX线的波形,看位宽是否符合预期。9600bps时一位大约104微秒,115200bps时大约8.7微秒,量一下就能判断。如果波形正常但数据错,再查电平和共地。另外提醒一句:模块的TX接MCU的RX,模块的RX接MCU的TX,这个交叉关系虽然简单,但在改板子的时候真的很容易接反,而且接反不会烧东西,只会让你怀疑人生。
3. NMEA-0183报文解析:从串口字节流到可信坐标的完整链路
数据能收进来只是第一步,能不能稳定、不丢、不乱地把它变成结构化的坐标,才是这个项目真正的分水岭。我看过太多代码是这样写的:用HAL_UART_Receive轮询收固定长度,然后strstr找"$GPGGA",找到就sscanf。这套写法在实验室里能跑,一到实际环境就各种崩——因为报文长度会变,卫星数从个位数变成两位数长度就变了,轮询还会漏字节。
3.1 接收层:DMA加空闲中断才是正解
STM32的USART有一个非常好用的特性:空闲线检测。当总线上连续一个字节时间内没有新数据时,会触发IDLE标志,配合DMA可以实现"收多少算多少"的不定长接收。配置步骤是:先把DMA配成循环模式接收固定大小的缓冲,再使能USART的IDLE中断。每来一批数据,IDLE中断触发,你在回调里读出DMA的剩余计数,算出这次收了多少字节,拷进环形缓冲区,然后把DMA计数器重置。
#define GPS_DMA_BUF_SIZE 256 static uint8_t gps_dma_buf[GPS_DMA_BUF_SIZE]; static ringbuf_t gps_rb; void gps_uart_init(void) { HAL_UARTEx_ReceiveToIdle_DMA(&huart2, gps_dma_buf, GPS_DMA_BUF_SIZE); /* 关掉半传输中断,避免频率太高打断主循环 */ __HAL_DMA_DISABLE_IT(&hdma_usart2_rx, DMA_IT_HT); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { ringbuf_write(&gps_rb, gps_dma_buf, Size); /* 循环模式会自动续传,这里不用重开,但计数器需要处理 */ } }为什么要用环形缓冲而不是直接处理?因为NMEA报文是跨包的。一次DMA传输可能只收到半条$GNGGA,...,下一次才收到剩下的一半。环形缓冲把字节流拉平,解析器只需要不停地从里面取字节喂给状态机,不用关心物理边界。缓冲大小取256到1024都行,按你的主循环周期和波特率算:115200bps下每秒约11.5KB,如果主循环100ms跑一次,至少要1.2KB才不溢出,我一般直接给2KB,占不了多少RAM。
3.2 解析层:用状态机而不是字符串查找
字符串查找的写法有硬伤:遇到报文被截断、遇到校验和错误的包、遇到多星座系统输出的$GNGGA和$GPGGA混发,都会出问题。稳妥的做法是写一个字节驱动的状态机,只认$开头、*结尾、中间是逗号分隔字段的结构。
typedef enum { ST_IDLE, ST_BODY, ST_CSUM_H, ST_CSUM_L, ST_DONE } nmea_state_t; static nmea_state_t st = ST_IDLE; static char body[96]; static uint8_t blen; static uint8_t csum_calc, csum_recv; void nmea_feed(uint8_t c) { switch (st) { case ST_IDLE: if (c == '$') { blen = 0; csum_calc = 0; st = ST_BODY; } break; case ST_BODY: if (c == '*') { st = ST_CSUM_H; } else if (c == '\r' || blen >= sizeof(body) - 1) { st = ST_IDLE; } else { body[blen++] = c; csum_calc ^= c; } break; case ST_CSUM_H: csum_recv = hex_val(c) << 4; st = ST_CSUM_L; break; case ST_CSUM_L: csum_recv |= hex_val(c); if (csum_recv == csum_calc) { body[blen] = 0; nmea_dispatch(body, blen); } st = ST_IDLE; break; default: st = ST_IDLE; break; } }校验和是这个状态机存在的核心理由。NMEA的校验规则很简单:两个字符之间(不含$和*)所有字节做异或。别小看这一步,野外环境下串口线上有电机、开关电源、射频干扰,错误的字节是常态。不做校验的话,一个字节翻转就能让你的纬度从31度变成71度,显示出来的位置飘到几千公里外,而且这种错误极其偶发,事后根本复现不了。加了校验,错误包直接被丢弃,下一帧1秒后又来,用户完全无感。
3.3 字段分发:只解析你真正需要的三条报文
模块默认会输出一堆报文,全解析既费CPU又没必要。真正有用的就三条:
- GGA:时间、经纬度、定位质量、卫星数、水平精度因子、海拔。这是主数据源,定位状态和精度都从这来。
- RMC:时间、日期、经纬度、速度、航向。日期只有这里有,做轨迹记录必须解析。
- GSV:可见卫星的方位角、仰角、信噪比。调试阶段非常有用,可以判断天线装得好不好。
字段提取建议写一个轻量的分割函数,按逗号切分并存指针数组,不要用sscanf。原因是sscanf的浮点解析在部分精简版C库上不可用,而且速度慢、栈占用大。自己写一个原地切分的函数,十几行代码,效率高得多。
/* 原地按逗号切分,返回字段数 */ uint8_t nmea_split(char *s, char *fields[], uint8_t max) { uint8_t n = 0; fields[n++] = s; while (*s && n < max) { if (*s == ',') { *s = 0; fields[n++] = s + 1; } s++; } return n; }有个细节必须处理:空字段。NMEA里未定位时经纬度字段是空的,直接拿去atof会得到0,如果代码没判断,就会把坐标显示成(0,0),也就是几内亚湾某处。我第一次做的时候就踩了这个坑,调试时看着地图上那个点,愣了半天才反应过来。正确做法是先检查GGA的第6个字段(定位质量),值为0表示无效,直接跳过整条报文。
4. 坐标换算与导航算法:WGS-84到能用距离、航向算出来的本地坐标
原始报文里的经纬度格式是ddmm.mmmm或者dddmm.mmmm,不是十进制度。纬度31度12.3456分写成3112.3456,经度121度30.1234分写成12130.1234。直接把这个数当度用,位置能偏出几十公里。这一步转换虽然简单,但出错率极高,而且出错后的现象不明显——坐标看起来还是"像模像样"的一个数,只有跟真实位置对比才会发现不对。
4.1 度分格式与十进制度的转换
转换公式是:度 = 整数部分 + 小数部分 / 60。关键在于整数部分要按100取整,不是按10,因为分是两位。
static double nmea_deg_to_double(double raw, char hemi) { int deg = (int)(raw / 100.0); double min = raw - deg * 100.0; double d = deg + min / 60.0; if (hemi == 'S' || hemi == 'W') d = -d; return d; }半球标识符也必须处理。南纬和西经是负值,如果忽略这一点,南半球的轨迹会整个镜像到北半球去。这个错误在测试阶段很难发现,因为国内测试永远是北纬东经。
转换完之后建议在调试输出里打印两种格式,一眼就能核对:比如31.205760对应31°12.3456'。我习惯在串口日志里同时打原始值和转换值,跑通之后再关掉,后面排查坐标异常时特别省事。
4.2 两点距离:什么时候用球面公式,什么时候用平面近似
要算"当前位置距离起点多远",有两个选择。Haversine球面公式精度高,任何距离都适用,代价是几次三角函数运算;局部平面近似速度快,但只在几公里范围内准确。
#define R_EARTH 6371000.0 static double rad(double d) { return d * M_PI / 180.0; } double dist_haversine(double lat1, double lon1, double lat2, double lon2) { double dlat = rad(lat2 - lat1), dlon = rad(lon2 - lon1); double a = sin(dlat/2)*sin(dlat/2) + cos(rad(lat1))*cos(rad(lat2))*sin(dlon/2)*sin(dlon/2); return 2 * R_EARTH * asin(sqrt(a)); }我的实际做法是:距离计算用Haversine,因为1Hz频率下这点浮点运算完全无压力;但航迹绘制和相对位移用平面近似,把第一个有效定位点作为原点,后续点转成以米为单位的(x,y),这样在屏幕上画轨迹或者判断"是否偏离航线超过阈值"会方便很多。
void wgs84_to_local(double lat0, double lon0, double lat, double lon, float *x, float *y) { const double K = 111319.49; /* 每度纬度对应的米数 */ *x = (float)(rad(lon - lon0) * K * cos(rad(lat0))); *y = (float)(rad(lat - lat0) * K); }注意经度方向的系数要乘cos(纬度),因为经线在高纬度会收拢。在北纬31度附近,1度经度大约对应95公里,而1度纬度是111公里,差了16%。如果不做这个修正,东西方向的位移会系统性偏大,画出来的轨迹会被横向拉伸。这个错误在短距离测试里看不出来,跑几公里就明显了。
4.3 航向角与速度:别直接信模块给的字段
RMC报文里直接带速度和航向字段,单位分别是节和度。速度乘以1.852得到公里每小时,这个换算没什么好说的。但航向字段有个特点:低速时不可信。当速度低于大约1公里每小时,模块算出来的航向基本是噪声,会看到指针疯狂乱转。
处理办法是加一个速度门限:速度低于阈值时保持上一次的航向值不更新,或者干脆显示"--"。如果要更平滑,可以对航向做角度域的滑动平均,注意不能直接对角度求算术平均,因为359度和1度的平均值是180度,完全错了。要么转成单位向量做平均再转回角度,要么做角度差归一化后再平均。
速度本身也有坑。静止时模块输出的速度不会是精确的0,通常在0.1到0.5公里每小时之间跳动,坐标也会在几米范围内缓慢漂移。如果业务逻辑是"速度大于0就认为在移动",那车停着不动也会一直触发。稳妥的判定方式是速度门限加位置变化门限双条件:速度大于1公里每小时,并且与上一个记录点的距离大于5米,才认定有效移动。
5. 定位精度不稳时的排查与补偿手段
"昨天还好好的,今天怎么飘了"是这一行的日常。定位精度受卫星几何分布、多径反射、天线性能、电磁环境共同影响,任何一个环节变化都会导致结果波动。这一章按从外到内的顺序,把排查链路和补偿手段捋一遍。
5.1 先看卫星数和HDOP,再谈算法
拿到异常数据,第一件事不是改代码,是看GGA报文里的卫星数和水平精度因子HDOP。卫星数低于6颗,定位基本不可信;HDOP大于2,说明卫星在天空中的几何分布不好,即使卫星数多也会有大误差;HDOP在1以下才是理想状态。
我一般会在代码里加一个质量门限:GGA的定位质量为0(无效)或HDOP大于3时,把这一帧标记为低置信度,不参与航迹记录,但仍在界面上显示,并给出提示。这样既不会污染数据,也不会让用户以为设备坏了。
如果长时间卫星数上不去,排查顺序是这样:先确认是否在室内或有遮挡,定位信号在混凝土建筑内部基本无解,这不是设备问题;再看天线方向,有源天线的接收面要朝上,平放比竖放效果好很多;最后查天线馈电,量一下电压,前面说过。我遇到过一次卫星数始终只有4颗的情况,折腾了一下午,最后发现是天线座的偏置电感焊错了位置,等于天线一直没通电,靠模块自身的小陶瓷天线在勉强工作。
| 现象 | 最可能的原因 | 快速验证方法 |
|---|---|---|
| 冷启动超过2分钟无定位 | 天线未馈电、遮挡严重 | 量天线座电压,换到空旷处重试 |
| 卫星数正常但定位质量为0 | 星历未下载完、天线性能不足 | 等3-5分钟,看GSV信噪比 |
| 坐标在几米内持续漂移 | 静止漂移,正常物理现象 | 加静止判定与滤波 |
| 坐标偶尔跳到几百米外 | 校验未做、多径、HDOP突变 | 检查校验逻辑,过滤HDOP>3的帧 |
| 速度字段抖动剧烈 | 低速噪声 | 加门限与滑动平均 |
5.2 静止漂移与滑动滤波:承认它,然后处理它
必须接受一个事实:民用单点定位的精度就是2到5米,静止时坐标会在这个范围内持续游走,这不是故障。想彻底消除只有两条路:上差分服务,或者做组合导航。前者需要一个外部差分数据源,后者需要加速度计和陀螺仪。
在单模块条件下,能做的是软件滤波。最简单的是滑动窗口平均:缓存最近N个点,取平均作为输出。窗口取5到10比较合适,太大了会明显滞后,移动时轨迹会"拖尾巴"。稍微好一点的是加权平均,越新的点权重越大。
#define WIN 8 static double lat_buf[WIN], lon_buf[WIN]; static uint8_t idx, cnt; void pos_filter_push(double lat, double lon) { lat_buf[idx] = lat; lon_buf[idx] = lon; idx = (idx + 1) % WIN; if (cnt < WIN) cnt++; } void pos_filter_get(double *lat, double *lon) { double sl = 0, so = 0; uint8_t i; for (i = 0; i < cnt; i++) { sl += lat_buf[i]; so += lon_buf[i]; } *lat = sl / cnt; *lon = so / cnt; }再往上就是卡尔曼滤波,把位置和速度作为状态量,用定位数据做观测更新。这套东西效果确实好,尤其是低速和遮挡场景,但实现门槛不低,要调过程噪声和观测噪声矩阵,调不好反而比简单平均更差。我的建议是:如果你只是做轨迹显示和距离统计,滑动平均完全够用;要做车辆姿态或者高精度轨迹重建,再考虑卡尔曼,而且最好配合惯性器件一起做。
提示:滤波有一个副作用——它会掩盖真实的高动态变化。如果设备用于急转弯或者急加减速的场景,窗口别开太大,否则转弯半径会被明显"抹平"。这类场景宁可保留一些噪声,也要保证响应及时。
5.3 冷启动、温启动、热启动:时间预期要提前建立
调试时最容易产生的误判是"模块坏了",实际上只是启动方式不同。冷启动指模块断电很久、星历和位置全部丢失,首次定位时间通常需要30到60秒,天线条件差时更久;温启动是星历还在但位置信息过期,一般20到40秒;热启动是星历、位置、时间都有效,几秒内就能定位。
这就解释了为什么很多模块要接后备电池:VBAT引脚接一颗纽扣电池,主电源断电后模块内部的RTC和星历存储还能维持,下次上电就可以走热启动。如果不接,每次断电都要重新冷启动,用户体验会很差,尤其是车载场景,每次打火都要等半分钟。
代码里可以做一件事:上电后先给一个"正在搜星"的状态提示,同时统计已用时间,超过90秒仍未定位就提示"请移至开阔区域"。这个小设计能挡掉大量"设备坏了"的误反馈。
6. 显示、存储与整机联调:从能跑到好用
前面几章解决的是"数据对不对",这一章解决"用起来顺不顺"。很多项目的技术指标都达标了,但界面刷新卡顿、记录文件损坏、断电丢数据,实际体验很差。
6.1 界面刷新:把"重绘整屏"这个习惯改掉
单色OLED的显存是1KB,对应128x64个像素。如果用软件I2C,全屏刷新一次要传1024字节,每字节还要加上控制字节和应答,耗时几十毫秒,期间主循环完全被阻塞。1Hz定位数据下勉强能忍,但如果同时有按键响应,就会感觉明显卡顿。
我的做法是分区域刷新加脏标记。把屏幕划分成几个逻辑块:顶部状态栏(卫星数、定位状态、时间)、中部主数据区(经纬度)、底部辅助区(速度、航向、距起点距离)。每个区域有一个脏标记,只有数据变化时才置位,主循环检查到脏标记才刷新对应区域。经纬度显示到小数点后5位,大约对应1米精度,变化时才刷新,静止时几乎不刷新,CPU占用立刻就下来了。
还有一个细节是数字转字符串。sprintf带浮点格式化在很多精简库上是关闭的,就算开了也很占空间和时间。我一般自己写定点转换:先把浮点乘100000取整,再逐位拆成字符,速度比sprintf快一个数量级,也不依赖库配置。
6.2 轨迹记录:文件系统不是必需品
有些方案一上来就移植文件系统,代码量大、RAM占用高、掉电还容易损坏。如果你的需求只是"记录航点,后期导出",完全可以用自定义的二进制格式,每个记录固定长度,比如经纬度各4字节浮点、时间4字节、速度2字节、状态1字节,一共15字节,按512字节对齐写入SD卡。
好处是写入简单、抗掉电能力强、解析速度快。导出的时候在电脑端写个小脚本按固定偏移读取就行,不需要文件系统。坏处是不通用,别人拿到卡读不出来。折中方案是用文件系统但只做最基本的写操作,不做目录和长文件名。
无论哪种方案,掉电保护都要做。做法是双缓冲:维护一个写指针,写入新记录时先写到缓冲区,凑满一个扇区再落盘;同时在EEPROM或者备份寄存器里记录当前的写入位置。上电时读回位置,从下一个块继续写,这样即使掉电最坏也只丢最后一个扇区。
6.3 整机联调:一份可以照着走的检查清单
从焊完板子到能拿出去跑,中间有几个必须验证的节点,漏掉任何一个都会在后面付出更大代价。
第一步,不接天线,只上电,确认MCU能正常启动、串口能打印、屏幕能点亮。这一步排除电源和基础外设问题。第二步,接上天线,看原始报文,用串口助手抓模块输出,确认报文完整、校验正确、卫星数随时间上升。第三步,跑解析程序,把解析结果打印出来,和原始报文逐字段对比,确认没有错位。第四步,静止测试,放在空旷处记录半小时,看坐标漂移范围是否在预期内。第五步,移动测试,带着设备走一段已知路线,回来后用地图工具把记录的坐标画出来,看轨迹是否贴合。
第五步最有价值。我做过一次测试,轨迹整体偏移了几十米,一开始怀疑是坐标转换写错了,后来把原始记录画出来对比,发现偏移是恒定方向的,最后定位到是天线附近有一块金属支架造成的多径效应。这种系统性的偏差,只有通过实际轨迹对比才能发现,单看坐标数值是看不出来的。
注意:整机联调阶段,建议在日志里保留原始报文和解析结果两条线,并且给每帧加上接收时间戳。后面出现任何异常,这份日志就是唯一的证据链。我吃过亏——只存了解析后的坐标,出了问题根本无法判断是模块输出错了还是我的解析错了。
最后分享一个我一直在用的小技巧:把定位质量、HDOP、卫星数、接收时间这四个值合成一个"健康度"指标显示在状态栏上,用简单的分级(优/良/差)呈现。用户不需要懂HDOP是什么,但他能一眼看出当前数据可不可信。这个小设计比任何说明书都有用,也是我在做了几版之后才意识到的东西——技术指标是给自己看的,可读的状态才