STM32 GPS定位实战:NMEA协议解析、串口DMA接收与避坑指南

STM32 GPS定位实战:NMEA协议解析、串口DMA接收与避坑指南 简介STM32GPS定位系统是一份基于ARM Cortex-M内核微控制器与NMEA-0183协议的嵌入式定位方案资源包面向物联网、车载导航、户外运动等领域的嵌入式开发者。资料覆盖STM32固件工程、GPS模块数据解析及OLED显示控制帮助用户完成从UART串口接收、NMEA语句解析到经纬度、速度、时间等定位信息展示的完整流程。包内共152个文件以工程源码为主37个.h头文件与34个.c源文件构成核心固件体系辅以.s启动文件、.hex烧录文件、.uvprojx工程文件及编译中间产物便于用户直接打开工程参考编译与链接过程整包压缩后约2.09MB。目前已有7470人学习浏览作者将GPS解析后的串口输出与OLED显示分离处理读者既可对照源码理解NMEA数据帧解析细节也可参考固件初始化、波特率配置与显示刷新逻辑快速迁移到自有硬件平台。1. 项目概述这几个月一直在折腾STM32的GPS定位项目从最开始买模块、看协议到后面把数据解析出来、在屏幕上画出轨迹中间踩了不少坑。现在把整个项目从方案选型到代码实现到问题排查完整梳理一遍希望能给正在做类似项目的人一些参考。这个项目说到底就是让STM32通过串口读取GPS模块输出的NMEA协议数据解析出经纬度、时间、速度、卫星数等关键信息然后根据需求做显示、存储或者无线上报。整个方案适合做毕业设计的学生、准备做车辆定位或便携设备的开发者也适合想深入了解GPS数据解析的嵌入式爱好者。先说结论这个项目难度不大但细节很多尤其是协议解析和硬件抗干扰这两个环节处理不好会浪费大量时间。市面上主流GPS模块的接口几乎都是串口TTL电平这就让STM32接GPS变成了一个标准的串口数据接收任务。无论你是用NEO-6M、ATGM336H还是其他模块核心工作就是接收一段ASCII字符串然后按照NMEA协议去截取字段。真要做起来硬件连接十分钟搞定真正的复杂度在解析逻辑和异常处理上。而且GPS本身有个特性——它输出的数据不是连续的而是以固定频率通常1Hz周期性发送一整条语句这对串口缓冲区的设计提出了要求。我的方案是用串口空闲中断加DMA接收这个后面会详细讲如果你只是想快速跑通用普通中断接收也能实现只是稳定性会差一些。2. 整体设计与方案选型2.1 硬件选型为什么用STM32F103C8T6和ATGM336H项目第一步是选型。STM32这边我选了最常见的F103C8T6蓝色Pill板子理由很简单便宜、资料多、串口资源足够一个串口接GPS还能留一个串口做调试输出。如果你用F407或者H743性能溢出不说调试反而更麻烦——高主频带来的电源噪声对GPS这种弱信号接收设备并不友好。GPS模块选择上我对比过市面上常见的几款这里直接列个表供参考模块型号价格区间特点适合场景NEO-6M15-25元u-blox经典款文档全但搜星较慢学习入门ATGM336H10-20元国产中科微功耗低支持BDSGPS双模大多数项目首选NEO-M8N40-60元u-blox高端款搜星快抗干扰好对性能有要求A2235H8-15元超便宜体积小性能一般预算敏感项目我最终选了ATGM336H一个重要原因是它支持BDSGPS双模在中国环境下搜星数量和定位速度都明显优于单GPS模块。实测下来冷启动搜星在开阔地带大概30到40秒能定位热启动基本在1到2秒内完成。模块上面一般会带一个陶瓷天线如果设备是放在室内的建议换一个有源天线否则信号强度根本不够。2.2 串口参数与协议基础GPS数据长什么样GPS模块通过串口输出的是一串以美元符号开头的ASCII文本协议叫NMEA 0183。上电以后模块会周期性输出多种语句常用的有$GNGGA定位信息、$GNRMC推荐最小定位信息、$GNGSA卫星精度和$GNVTG地面速度。注意不同模块前缀有区别老的GPS-only模块输出的是$GPGGA、$GPRMC双模模块会输出$GNGGA、$GNRMC。串口参数一般固定为波特率9600、8位数据位、1位停止位、无校验。这里有个小坑——不少国产模块默认确实是9600但你买到手可能被商家改过建议上电后用串口助手先看一帧数据确认波特率再说。我曾经遇到过一块默认38400的模块直接用9600去接出来的全是乱码排查了半天才发现问题。2.3 为什么选串口中断加缓冲而不是轮询GPS数据是持续不断流的每秒会有好几条语句连续发过来总长度大概几百字节。如果用轮询方式在main循环里getchar一是浪费CPU二是容易丢数据。我采用的方案是串口空闲中断加DMA接收简单说就是DMA把串口收到的数据持续搬运到缓冲区当一帧数据发完总线空闲触发空闲中断这时候在中断里置一个标志位主循环发现标志后就解析缓冲区。这个方案的好处是CPU几乎零负担也能保证数据完整性。不过STM32F103的DMA加空闲中断有个老毛病——第一次接收会有芯片勘误表里提到的问题所以我在初始化时故意把串口寄存器里的接收标志位清掉触发一次假空闲中断来规避。细节后面代码里会体现。3. NMEA协议解析从字符串到经纬度3.1 核心语句$GNGGA字段详解$GNGGA语句包含了最核心的定位数据一条完整的语句长这样字段间用逗号分隔$GNGGA,034523.000,3101.28434,N,12120.67890,E,1,08,0.9,8.5,M,-2.3,M,,*4F按顺序解读各个字段UTC时间03时45分23.000秒、纬度3101.28434度分格式即31度01.28434分、北纬N、经度12120.67890即121度20.67890分、东经E、定位质量指示0无效1GPS定位2差分定位、使用卫星数08颗、水平精度因子0.9、海拔高度8.5米、大地水准面差距、差分时间、差分站ID、校验和*4F。解析时最关键的坑是纬度和经度是度分格式不是常见的十进制格式。以北纬3101.28434为例实际十进制度数是31 01.28434 / 60 31.0214057。如果你算出来经纬度是3101度那肯定不对了得除以100取整再对余数除以60。不少人在这一步翻车拿到数据直接在百度地图里搜出来的位置完全不对。3.2 二选一解析GGA还是RMCGGA和RMC都包含了经纬度信息区别在于RMC还多了日期、速度和航向角但没有海拔。如果你的项目需要展示速度或记录日期用RMC更方便如果更关心海拔和卫星数就解析GGA。我的做法是同时解析这两条语句反正放到结构体里也不多占内存。需要提醒的是这两条语句里的经纬度字段格式完全一致解析函数可以复用。3.3 完整解析代码框架我用的HAL库加标准C语言解析部分核心代码如下typedef struct { uint8_t hour; uint8_t minute; uint8_t second; double longitude; // 十进制表示 double latitude; // 十进制表示 uint8_t positionStatus; // 0无效 1有效 uint8_t satelliteNum; float altitude; // 海拔 米 } GPS_Info_t; GPS_Info_t gpsInfo; static double convertNMEAtoDecimal(const char* src, char direction) { if (src[0] \0 || direction \0) return 0.0; double raw atof(src); 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; } void parseGNGGA(char* buffer) { char* p buffer; int fieldIndex 0; char* fieldPtr NULL; while ((fieldPtr strsep(p, ,)) ! NULL) { switch (fieldIndex) { case 1: // UTC时间 if (strlen(fieldPtr) 6) { gpsInfo.hour (fieldPtr[0] - 0) * 10 (fieldPtr[1] - 0); gpsInfo.minute (fieldPtr[2] - 0) * 10 (fieldPtr[3] - 0); gpsInfo.second (fieldPtr[4] - 0) * 10 (fieldPtr[5] - 0); gpsInfo.hour 8; // 北京时间 东八区 if (gpsInfo.hour 24) gpsInfo.hour - 24; } break; case 2: // 纬度 gpsInfo.latitude convertNMEAtoDecimal(fieldPtr, *(p 1)); break; case 4: // 经度 gpsInfo.longitude convertNMEAtoDecimal(fieldPtr, *(p 1)); break; case 6: // 定位状态 gpsInfo.positionStatus atoi(fieldPtr); break; case 7: // 卫星数 gpsInfo.satelliteNum atoi(fieldPtr); break; case 9: // 海拔 gpsInfo.altitude atof(fieldPtr); break; } fieldIndex; } }注意我这里用了strsep来分割字段这个函数在分割时会直接修改原字符串所以传入的buffer必须是可以写的。如果你用的是只读字符串数组会直接触发硬错误。另外经纬度解析部分没有做完整的边界检查实际项目里建议对上位机传来的数组长度先做个校验防止非法数据导致atof读到越界内容。3.4 校验和验证要不要做NMEA语句末尾的号后面跟两位十六进制校验和是对$和之间所有字符的异或结果。有人觉得GPS模块出来的数据不会出错省掉这一步。我的建议是调试阶段可以做正式项目最好也做因为电磁干扰可能在传输过程中改变某个字符尤其是导线较长或者设备附近有电机、继电器这类干扰源时。实现起来也不复杂十几行代码的事。但要注意我第一次写校验函数时直接把*后面的两个字符按ASCII码相加了结果怎么都不对花了两小时才想起要把它们先转成十六进制数字再算异或。4. 实操过程与核心环节实现4.1 硬件连接与先期验证硬件接线其实很简单ATGM336H模块六个引脚实际只用四个VCC接3.3V、GND接GND、TXD接STM32的PA10USART1_RX、RXD接PA9USART1_TX。模块还有一个PPS引脚是秒脉冲输出可以用来做时间同步不是必须接。注意模块TXD和STM32的RX本来就该交叉连接有人在这里接错过导致收不到任何数据。在把模块接到STM32之前强烈建议先做一个独立验证模块用USB转TTL接电脑打开串口助手看模块是否能正常输出数据。这一步能直接确认模块本身没问题避免后面STM32调不通时还要怀疑模块坏了。我遇到过模块一到室外就能定位在室内靠窗能搜到星但定不了位这种其实都算正常——GPS信号穿墙能力很差室内定位需要把天线贴近玻璃才行。连接完成后可以先跑一个最简单的回环测试STM32把收到的原始串口数据原样转发到调试串口在电脑串口助手上看有没有$GNGGA这样的语句输出。这一步过了说明硬件链路是通的再往下做解析才有意义。4.2 串口DMA空闲中断接收实现STM32F103的USART1挂载在APB2总线上时钟频率72MHz。我们配置串口波特率9600、8位数据位、1位停止位、无校验并使能空闲中断和DMA接收void MX_USART1_UART_Init(void) { huart1.Instance USART1; huart1.Init.BaudRate 9600; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); HAL_UART_Receive_DMA(huart1, uartDmaBuffer, DMA_BUFFER_SIZE); } void USART1_IRQHandler(void) { if (RESET ! __HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); uint16_t len DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1); if (len 0 len DMA_BUFFER_SIZE) { processGPSData(uartDmaBuffer, len); // 这里会把数据复制到一个可写的缓冲区再做解析 } HAL_UART_Receive_DMA(huart1, uartDmaBuffer, DMA_BUFFER_SIZE); } }这个方案的效率非常高整个接收过程中CPU几乎不参与DMA模块直接把串口数据搬运到内存里。每收到完整的一帧数据后空闲中断触发一次我们做一次解析。注意DMA缓冲区大小要设置得足够大GPS语句最长的一条可能超过100字节缓冲区太小会导致一帧数据放不下。4.3 从原始数据流中提取完整NMEA语句这里有个关键点GPS模块输出的不是单条语句而是多条语句连续输出。如果直接处理DMA缓冲区一个缓冲区里可能包含半条、一条半、甚至多条语句。我的处理方式是逐字节扫描找到$作为语句起始然后继续扫描到换行符\r\n作为语句结束把中间的内容提取到一个临时数组里再交给解析函数。伪代码如下static void processGPSData(uint8_t* data, uint16_t len) { static uint8_t lineBuffer[128]; static uint8_t lineIndex 0; static uint8_t inLine 0; for (uint16_t i 0; i len; i) { uint8_t ch data[i]; if (ch $) { inLine 1; lineIndex 0; } if (inLine) { lineBuffer[lineIndex] ch; if (lineIndex sizeof(lineBuffer) - 1) { inLine 0; // 防止缓冲区溢出 } } if (ch \n inLine) { lineBuffer[lineIndex] \0; if (strstr((char*)lineBuffer, $GNGGA) ! NULL) { parseGNGGA((char*)lineBuffer); } inLine 0; } } }这样处理以后无论一帧DMA数据里包含几条完整还是不完整的语句我们都能正确地提取出以美元符起始、以换行符结尾的完整NMEA语句。这个逻辑虽然不复杂但理清楚以后就再也不会出现一条语句被切碎导致解析失败的情况了。4.4 定位算法的理解与三边测量既然项目是GPS定位很多做毕设的朋友会对定位原理感兴趣。GPS接收机定位原理其实就是一个三维空间的三边测量——每颗卫星知道自己精确的位置也能算出自己和接收机之间的距离然后以卫星为球心、距离为半径画球面。我们如果同时收到4颗以上的卫星信号就能解出接收机位置的三个坐标经度、纬度、高度加一个时间误差一共四个未知数。在STM32上我们可以做个演示版的三边测量算法比如在已知三个参考点坐标和距离的情况下计算目标位置给定三圆交点对两圆联立方程求解得到两个交点再根据第三圆的误差选出一个真实位置实际GPS是伪距定位每颗卫星的距离都包含接收机时钟误差所以需要至少4颗卫星迭代求解如果项目里想用WIFI定位或蓝牙信标做定位这类距离传感器报出来的距离不准直接用三边测量误差很大需要配合最小二乘法做多点拟合我建议在博文里把我们做的三边测量演示也总结一下因为很多人在做室内定位的毕设时用到了类似算法。简单说一下实现思路已知三个基站的坐标(A(x1,y1)、B(x2,y2)、C(x3,y3))以及目标到这三个基站的距离r1、r2、r3可以用最小二乘法把超定方程组写出来再通过矩阵运算解出目标坐标。在STM32上我把这个写成了解矩阵的代码因为要处理浮点运算所以用了C标准库的数学函数注意使能硬件浮点或者保证编译链接了浮点支持。代码大约三十行核心就是用两个式子分别算出各自可能的点再通过误差比较选取更优的那个。4.5 KEIL5与开发环境里遇到的一个小坑这个项目开发环境用的是KEIL5配合STM32F103的芯片包。有一次我在KEIL5里编译完烧录后调试器突然提示找不到目标设备关键是一开始还能连上烧过一次程序之后就再也连不上了。排查了很久最后发现是ST-LINK的连接线松了加上开发板供电不稳导致芯片锁死了。解锁办法是按住复位键、点击下载瞬间再松开成功几率很大。另外有一点如果你同时装了C51和STM32的KEIL注意安装顺序有讲究最好先装C51再装MDK否则两个版本的Pack管理可能互相干扰提示找不到设备。这个问题虽然和GPS本身无关但做这个项目的过程中我确实被折腾了一次顺带记录下来。5. 常见问题与排查技巧实录5.1 搜不到星 / 一直无定位数据这个是最常见的问题。模块上电后如果LED灯在闪但始终没有定位最常见的原因是天线没面向天空。GPS信号强度很弱穿过玻璃、墙壁都会有明显衰减所以模块需要放在窗边或室外。另外一个原因是模块刚从仓库发出处于冷启动状态第一次搜星可能需要1到2分钟耐心等一下。还有一种可能是模块的锂电备份电池没有电导致每次上电都是冷启动——这种情况可以通过外接一个3V的纽扣电池解决。排查顺序建议先看模块有无数据输出用串口助手直接接模块再看语句里的定位状态字段是不是1再看模块附近有没有遮挡物最后再检查天线焊接和馈线。注意不要在电脑屏幕旁边测试定位——显示器的电磁辐射和金属外壳会遮蔽GPS信号。5.2 串口收到乱码 / 字符串不完整乱码的先查波特率GPS模块默认9600但有的改装板默认是38400或115200。再查电平——STM32的串口是3.3V电平如果GPS模块是5V供电但信号引脚是5V的直接接STM32会损伤引脚。用示波器或者万用表量一下TXD引脚的空闲电平是不是3.3V左右。如果模块输出的是5V电平中间需要加电平转换芯片或者用分压电阻。字符串不完整的情况大概率是DMA缓冲区太小把长语句截断了。检查DMA缓冲区设置建议至少256字节。5.3 坐标偏移到了海里解析出来的经纬度如果看起来完全不对比如本来是31度变成3101度那就是没做度分转十进制。另外还有一个可能是字段位置取错了——GGA语句和RMC语句的字段索引不一样我见过有人拿解析GGA的索引去解析RMC结果把高度当成经度用了。另一个偏移问题是坐标系不对。GPS输出的是WGS-84坐标系而国内很多地图用的是GCJ-02加密坐标直接投到百度地图上会偏移几百米。如果你要做地图显示必须做坐标转换这个网上有现成算法搜“WGS84转GCJ02”就行。5.4 STM32目标找不到 / 下载失败做这个项目时如果用的ST-LINK偶尔会遇到“Error: No STM32 target found! If your product embeds debug authentication”之类的报错。这个提示在新版固件里比较常见重点检查SWDIO和SWCLK两根线是不是接对了以及目标板有没有独立供电。如果你用了ST-LINK Utility去读Flash注意它和KEIL5不能同时占用调试口否则会互相冲突。有些新出的STM32芯片带调试认证保护正常从ST官网下载的固件不会锁死但如果你用J-Flash之类工具去强行读写反而可能触发保护导致连不上。稳妥的办法是先用标准库写个点灯程序确认下载链路完全正常后再搞GPS。5.5 测试时如何模拟GPS信号如果你在室内做开发又想快速验证解析逻辑可以考虑用模拟GPS信号的测试设备。之前提到的Partapack H2这类信号模拟器能生成模拟的GPS卫星信号射给接收机让模块在没有真实卫星信号的环境下也能输出定位数据。这类设备以前是军方或专业厂商用的动辄上万但现在网上有便宜的简易版本几百块就能买到对产线测试和开发调试很有帮助。用模拟器测出来的数据特征是经纬度固定、时间可设置卫星数一般固定为某个值。如果你暂时买不起模拟器也可以用串口助手直接把预先录制好的NMEA语句循环发送给STM32效果类似只是没法测试模块的射频性能。6. 进阶扩展方向项目跑通以后加功能的方向很多。最实用的是通过无线模块把GPS数据远程上报比如配上ESP8266或者SIM模块把解析好的经纬度通过HTTP或者MQTT发到服务器在网页上看位置轨迹。这也解释了为什么热词里有“STM32 8266 宿舍控制灯开发实战”——本质是STM32加无线模块的组合玩法GPS项目同样适用。另一个方向是数据存储。可以加个SD卡模块把定位数据按CSV格式记录到文件里形成轨迹回放。用FATFS文件系统在STM32上做SD卡读写配合GPS数据做日志记录很成熟。我当时做了个简化版——串口把数据发给电脑电脑上写了个简单的上位机实时显示经纬度并在地图上标点。如果有余力还能做电子围栏或者超速提醒。电子围栏就是设定一个中心点和半径判断当前经纬度是否在圆内——用两点的球面距离公式就行。超速提醒更简单RMC语句里有速度字段直接用公里每小时为单位输出超过阈值就驱动蜂鸣器。这次做的是把解析结果显示在OLED屏上在户外实际走了一圈验证定位轨迹跑起来效果挺好。我在实际使用中发现GPS项目最磨人的往往不是协议本身而是对异常数据的处理。实际环境里模块输出的数据远没有教程里那么干净可能夹杂着无效帧、校验错误甚至整条乱码解析代码的鲁棒性决定了一个GPS项目能不能真正落地。写代码的时候按照“宁缺毋滥”的原则——解析失败就丢弃不拿脏数据去计算这样才能保证后续显示的经纬度始终可用。最后分享一个小技巧模块的PPS秒脉冲引脚在户外能看到一秒一次的精准翻转你可以用示波器看这个引脚来确认模块确实在工作比傻等屏幕上的定位标志更快更准。本文还有配套的精品资源点击获取