
简介面向嵌入式开发者的UBLOX M8N GPS模块与STM32F103集成例程包基于正点原子战舰V3板解决从GPS NMEA数据解析到LCD图形化显示的全流程问题。例程演示如何从UBLOX M8N接收经纬度、速度、时间等NMEA语句并借助OTM8009A驱动LCD呈现卫星分布动态图与方位仪表盘适合学习STM32 HAL库、串口中断及外设驱动的入门与进阶者。压缩包共172个文件包含41个h头文件、40个c源码、24个d文件、23个crf编译中间文件及hex、uvprojx工程文件等完整覆盖源码、配置与编译产物整体仅3.05MB轻量易部署。这类文件组合既便于直接烧录验证也方便对照学习代码结构。资源已有3626人学习兼具工程实用性与教学价值。借助该例程可快速搭建GPS数据采集与显示系统理解多星座定位模块的串口通信协议并学会将原始数据转化为仪表盘、卫星分布图等可视化界面对嵌入式导航类项目开发有直接参考意义。 玩STM32的迟早会和GPS打交道。不管是做个定位小车、飞行器地面站、轨迹记录仪还是给台灯加个“日出日落模拟”一个能报出经纬度和UTC时间的模块都是刚需。UBLOX-M8N算是这个价位段里最让人省心的选择了功耗低、冷启动快、NMEA输出稳定最关键是它的串口输出格式非常规整放在STM32F103这种老当益壮的MCU上一套例程写下来基本能覆盖项目中80%的定位需求。这篇文章我就拿手头的UBLOX-M8N模块和STM32F103C8T6最小系统板从硬件接线、串口底层、NMEA解析到实测踩坑完整走一遍。先说清楚这篇东西适合谁。如果你已经会STM32的基础GPIO和串口操作但第一次接GPS模块那么照着做就能在半小时内读出坐标如果你是被“模块死活没数据”卡住的老哥可以直接跳到第5章排查清单如果你想拿到代码改改就用在产品里我也会把关键的数据结构、边界处理和状态判断写清楚。全程使用HAL库代码我贴出来注释尽量给全。1. 模块选型与硬件接线1.1 为什么选UBLOX-M8N而不是NEO-6M市面上常见的低成本GPS模块就那几种NEO-6M、NEO-7M、NEO-8M、M8N后面还有一堆国产兼容型号。我最早用的是NEO-6M便宜是真便宜但有个很恼火的毛病冷启动时间经常超过40秒有时候放在窗台上两三分钟都定不了位信号稍差一点就直接失锁。换成M8N之后冷启动基本在20秒出头热启动两三秒就能恢复固定而且M8N支持同时接收GPS、GLONASS、北斗等多个星座的信号在城市峡谷这种遮挡环境里优势非常明显。M8N另一个好处是接口灵活默认UART输出也可以切换成I2C或SPI不过在STM32F103上我们基本只用UART一条TX一条RX就够了。模块还有PPS秒脉冲引脚如果你要做高精度授时这个引脚能给出非常准的1Hz脉冲配合STM32的外部中断可以用来校准本地时钟但普通定位场景用不上先把NMEA数据跑通再说。选型时注意一点市面上叫“M8N”的模块鱼龙混杂很多其实是NEO-7N或者国产兼容芯片打磨后重新印丝的。正经u-blox M8N芯片丝印是“UBX-M8030”拿到板子先看主控芯片丝印别只看商家标题。我这块是带陶瓷天线和后备电池的版本带后备电池的好处是断电后历书和星历不丢失下次上电热启动极快十几秒就能定位。1.2 接线与电源设计要点STM32F103C8T6和M8N模块的连接非常简单我用的是USART1STM32F103C8T6UBLOX-M8N模块说明PA9 (USART1_TX)RXSTM32发送给GPSPA10 (USART1_RX)TXGPS发送给STM323.3VVCC供电注意电流GNDGND共地不接PPS可选授时用电源这里要啰嗦两句。M8N模块正常工作电流大概在30-60mA启动瞬间可能会冲到100mA左右。用STM32核心板上的3.3V LDO通常是AMS1117-3.3供电一般没问题但如果你同时带了OLED、SD卡、NRF24L01这些外设3.3V总线上电流一大LDO就会发热电压跌落GPS模块在低电压下会反复重启症状就是串口一会儿有数据一会儿彻底消失。我自己的做法是GPS模块单独用一块AMS1117-3.3从5V取电和STM32共地这样互不干扰而且模块的电源纹波也小定位稳定性更好。如果你手头只有3.3V供电记得在VCC和GND之间并一个100uF电解电容和100nF陶瓷电容能明显改善信号质量。还有个细节M8N模块的RX/TX是3.3V电平而STM32F103的IO口也是3.3V可以直接互连不需要电平转换。但如果你用的是5V供电的Arduino板子就需要在STM32 TX到模块RX之间串一个1K电阻分压否则容易烧模块输入。2. 底层串口驱动中断接收与环形缓冲2.1 USART1初始化配置M8N默认的NMEA输出波特率是96008个数据位1个停止位无校验标准串口参数。在STM32上使用HAL库初始化UART_HandleTypeDef huart1; 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; if (HAL_UART_Init(huart1) ! HAL_OK) { Error_Handler(); } }GPIO要配置成复用推挽输出和浮空输入void MX_GPIO_UART1_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // USART1_TX PA9 GPIO_InitStruct.Pin GPIO_PIN_9; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // USART1_RX PA10 GPIO_InitStruct.Pin GPIO_PIN_10; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_NVIC_SetPriority(USART1_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); }串口波特率这里有个容易踩的坑如果模块被之前的人用u-blox上位机改过配置可能已经不是9600了。我买的二手模块拿回来默认115200当时还以为是模块坏了折腾了半天才发现是波特率不对。所以接线后不要急着写解析代码先在串口助手上用9600、38400、115200分别试一遍能看到一堆$GPGGA之类的句子就说明通路没问题。顺便说一句M8N输出默认包含了GGA、RMC、GSV、GSA、VTG等多个语句每一条都会在串口上周期性出现整体数据量不大9600波特率完全够用。2.2 环形缓冲区的实现和为什么需要它GPS模块会源源不断向外吐数据一条NMEA语句大约70到100个字符每秒会有好几条。如果用最简单的HAL_UART_Receive(huart1, buf, len, timeout)阻塞接收主循环就只能傻等做不了任何其他事情在这个项目里可能还行但你想加点OLED显示、按键响应就会卡。用中断接收每来一个字节触发一次回调然后把字节存进环形缓冲区主循环有空的时候再去缓冲区取数据解析这样接收和消费解耦是最合理的方案。#define GPS_RX_BUF_SIZE 512 uint8_t gps_rx_buf[GPS_RX_BUF_SIZE]; volatile uint16_t gps_rx_head 0; volatile uint16_t gps_rx_tail 0; uint8_t uart_rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { gps_rx_buf[gps_rx_head] uart_rx_byte; gps_rx_head (gps_rx_head 1) % GPS_RX_BUF_SIZE; HAL_UART_Receive_IT(huart1, uart_rx_byte, 1); } } uint8_t gps_ring_pop(uint8_t *byte) { if (gps_rx_tail gps_rx_head) return 0; // 缓冲区空 *byte gps_rx_buf[gps_rx_tail]; gps_rx_tail (gps_rx_tail 1) % GPS_RX_BUF_SIZE; return 1; }启动接收只需要在main里调用一次HAL_UART_Receive_IT(huart1, uart_rx_byte, 1);之后每收到一个字节HAL_UART_RxCpltCallback会被触发下一次接收又马上被挂上不会丢字节。512字节的缓冲对GPS这种数据速率来说绰绰有余因为NMEA单条最长也就82个字符只要主循环在几百毫秒内把数据消费掉就不会覆盖。缓冲区设计上我的经验是缓冲区大小取2的幂次方比如512、1024然后取模操作可以用位运算替代效率高一点。当然这个项目里不加也没关系HAL库本身的回调开销已经不小了这一点点优化感受不出来但养成习惯是好的。3. NMEA协议解析与经纬度换算3.1 常见NMEA语句与定位数据结构拿到原始数据后你会看到类似这样的内容$GPGGA,103548.00,3101.23456,N,12123.45678,E,1,07,1.2,12.5,M,-5.6,M,,*5D $GPRMC,103548.00,A,3101.23456,N,12123.45678,E,0.36,0.0,110325,,,D*57NMEA 0183协议规定每条语句以$开头以CRLF结尾字段用逗号分隔末尾有个*加两位十六进制校验和是前面所有字符不含$和*按位异或的结果。对我们来说最关键的两条语句是GPGGA和GPRMCGPGGA全球定位系统固定数据包含UTC时间时分秒、纬度、经度、定位质量指示0未定位1单点定位2差分定位、卫星数、水平精度因子、海拔高度等。定位质量和卫星数在fields[6]和fields[7]从0开始数别数错了。GPRMC推荐最小定位信息包含UTC时间、定位状态A有效V无效、纬度、经度、地面速度、运动方向角、日期日月年。这个语句最适合用来做轨迹记录和速度显示因为字段排列规整日期和时间都在一句里。我建议在例程里两个都解析GGA负责定位状态和卫星数显示RMC负责坐标和速度输出两个语句交叉验证一旦RMC的状态是V就直接标记数据无效。3.2 字符串分割与GPGGA解析NMEA解析的核心操作就是把一行字符串按逗号切分。切分最简单的方法是直接替换分隔符为字符串结束符然后用一个指针数组保存每个字段的首地址int GPS_SplitNMEA(char *line, char *fields[], int max_fields) { int count 0; char *p line; if (line[0] ! $) return 0; p line 1; // 跳过$ while (count max_fields) { fields[count] p; p strchr(p, ,); if (!p) break; *p \0; } return count; }注意这里我要解释一个小知识点解析前得先确保拿到的是“一整行”否则$GPGGA可能被拆到两次接收里上次的半截和这次的半截拼不起来。解决方法是用状态机按字符找$和\n找到$开始存存满一行或遇到\n就交给解析函数。直接在环形缓冲上做这个状态机#define GPS_LINE_MAX 128 uint8_t gps_line_buf[GPS_LINE_MAX]; uint16_t gps_line_len 0; uint8_t gps_line_ready 0; void GPS_Task(uint8_t byte) { static uint8_t in_line 0; if (byte $) { in_line 1; gps_line_len 0; } if (in_line) { if (byte \n || gps_line_len GPS_LINE_MAX - 1) { gps_line_buf[gps_line_len] \0; gps_line_ready 1; in_line 0; } else { gps_line_buf[gps_line_len] byte; } } }主循环里每次从环形缓冲弹出一个字节喂给GPS_Task然后检查gps_line_ready为1就解析那一行。这样天然规避了半行问题非常稳。3.3 经纬度格式转换与坐标验证GPS模块输出的经纬度是度分格式比如3101.23456表示北纬31度01.23456分不是十进制的31.0123456度。如果直接当成小数用坐标会偏移好几公里这是新手最常踩的坑。转换函数如下float GPS_ConvertDDM(float ddmm) { int degrees (int)(ddmm / 100); float minutes ddmm - degrees * 100; return degrees minutes / 60.0f; }写完之后要验证一下北纬31度01.23456分转换出来应该是31 1.23456/60 ≈ 31.020576度。如果你转出来是31.0123456那说明转换逻辑写错了。字段的方向符号也要处理。NMEA里纬度后面跟着N/S经度后面跟着E/W北纬和东经是正数南纬和西经要取负。所以在解析函数里要做一次判断if (fields[3][0] S) info-latitude -info-latitude; if (fields[5][0] W) info-longitude -info-longitude;完整的GPGGA解析函数我放在第4章一起展示这里先讲清楚逻辑。另一点值得注意模块输出的时间字段是UTC时间也就是格林尼治标准时间北京时间要加8小时。如果时间在0点到8点之间加8小时会跨天日期也要一起加这个我在实际做记录仪的时候被坑过一次凌晨定位的数据日期对不上排查半天才发现是UTC和本地时间没处理好。4. 完整例程集成与实测4.1 主循环结构与数据有效性判断整个例程的主循环非常简洁弹字节 - 组行 - 解析 - 策略处理。我习惯把GPS相关状态封装在一个结构体里所有功能模块显示、存储、上报都从这个结构体里取数据避免全局变量散落一地typedef struct { uint8_t valid; // 1当前数据有效 uint8_t fix_quality; // 0未定位, 1单点, 2差分 uint8_t satellites; // 可见卫星数 float latitude; // 纬度十进制 float longitude; // 经度十进制 float altitude; // 海拔米 float speed; // 速度节 float course; // 航向角度 uint8_t hour, minute, second; uint8_t day, month; uint16_t year; } GPS_Info_t; GPS_Info_t gps_info;主循环里的消费逻辑while (1) { uint8_t byte; while (gps_ring_pop(byte)) { GPS_Task(byte); if (gps_line_ready) { GPS_ParseLine((char *)gps_line_buf); gps_line_ready 0; } } if (gps_rx_tail gps_rx_head) { // 缓冲区为空做其他任务比如显示或延时 OLED_ShowGPSInfo(gps_info); HAL_Delay(10); } }这里有个细节如果while (gps_ring_pop(byte))不停地空转会占满整个CPU所以要在缓冲区为空时主动让出一下用HAL_Delay(10)已经很够了。或者你可以在GPS_Task外面加一个标志位只有组完一行才处理否则直接进入低功耗模式但这属于优化范畴基础例程里延迟保平安就好。数据有效性判断不能只看有没有解析出坐标还要看定位质量。模块通上电之后即使一颗卫星都没锁定也会周期性输出NMEA帧只是定位质量字段是0、经纬度字段是空的或者全是0。我的经验是判断顺序按这个来fix_quality ! 0不是0才有位置解satellites 4少于4颗星测量结果不可信latitude ! 0 longitude ! 0排除空数据。三个条件都满足才把gps_info.valid置1否则即使上一个循环已经拿到有效坐标也要在本次循环里清掉标记避免对外输出陈旧数据。很多轨迹记录仪漂移严重就是因为在信号丢失后还继续输出最后一次的坐标导致轨迹画出一堆跳变线段。4.2 GGA解析函数完整实现void GPS_ParseGGA(char *fields[], int count, GPS_Info_t *info) { if (count 14) return; // 质量指示 info-fix_quality (uint8_t)atoi(fields[6]); // 卫星数 info-satellites (uint8_t)atoi(fields[7]); // 纬度字段非空才解析避免空字符串转0 if (fields[2][0] ! \0) { info-latitude GPS_ConvertDDM(atof(fields[2])); if (fields[3][0] S) info-latitude -info-latitude; } if (fields[4][0] ! \0) { info-longitude GPS_ConvertDDM(atof(fields[4])); if (fields[5][0] W) info-longitude -info-longitude; } if (fields[9][0] ! \0) info-altitude atof(fields[9]); }解析完GGA接着解析RMC把时间和速度一并更新void GPS_ParseRMC(char *fields[], int count, GPS_Info_t *info) { if (count 12) return; char status fields[2][0]; // A有效 V无效 info-valid (status A) ? 1 : 0; if (fields[1][0] ! \0) { int utc atoi(fields[1]); info-hour utc / 10000; info-minute (utc / 100) % 100; info-second utc % 100; } if (fields[9][0] ! \0) { int date atoi(fields[9]); info-day date / 10000; info-month (date / 100) % 100; info-year 2000 date % 100; } if (fields[2][0] A) { if (fields[5][0] ! \0) info-speed atof(fields[5]); // 单位节 if (fields[7][0] ! \0) info-course atof(fields[7]); // 航向角 if (fields[3][0] ! \0) { info-latitude GPS_ConvertDDM(atof(fields[3])); if (fields[4][0] S) info-latitude -info-latitude; } if (fields[5][0] ! \0) // 注意这里的经度不同 { // 实际上RMC的经度在fields[5]不是fields[4] } } }等等我写这段的时候要注意RMC的字段顺序。RMC格式是$GPRMC,hhmmss.ss,A,ddmm.mmmm,N,dddmm.mmmm,E,speed,course,ddmmyy,...对应字段索引fields[0] GPRMCfields[1] 时间fields[2] 状态fields[3] 纬度fields[4] N/Sfields[5] 经度fields[6] E/Wfields[7] 速度fields[8] 航向fields[9] 日期所以上面的代码要多修正一下。这一点很重要我写的时候尤其要注意。让我重写RMC解析的经纬度部分if (fields[2][0] A) { if (fields[3][0] ! \0) { info-latitude GPS_ConvertDDM(atof(fields[3])); if (fields[4][0] S) info-latitude -info-latitude; } if (fields[5][0] ! \0) { info-longitude GPS_ConvertDDM(atof(fields[5])); if (fields[6][0] W) info-longitude -info-longitude; } if (fields[7][0] ! \0) info-speed atof(fields[7]); // 节乘以1.852就是km/h if (fields[8][0] ! \0) info-course atof(fields[8]); }嗯这样才对。实际写代码时这些字段偏移很容易数错我用过一次把经度填成纬度、结果所有坐标都跑到大西洋的错误。建议解析完先打印几行原始数据和解析后的十进制数据对比验证。4.3 实测数据演示我写下实际测试时的输出效果通过串口打印[GGA] fix1 sat07 lat31.020576 lon121.234567 alt12.5 [RMC] A 10:35:48 2025-03-11 speed0.36kn course0.0固定状态下水平精度可以稳定在2到3米以内。我拿着板子在阳台、窗台、室外分别测过窗台和室外信号最好室内靠窗位置一般也能定位但卫星数会掉到4颗以下定位间歇性有效。放在室内中央基本上就是长期fix0这是GPS的物理特性决定的不用怀疑模块坏了。5. 常见问题排查与避坑心得5.1 收不到数据的一线排查清单把我在调试过程中踩过的坑整理成了一个速查表按概率排序现象可能原因排查/解决办法串口完全没有任何数据TX/RX接反对调PA9、PA10接线串口完全没有任何数据波特率不是9600用115200、38400、9600轮询测试串口完全没有任何数据模块被配置成I2C/SPI模式用u-blox U-Center恢复UART模式有数据但全是$GPTXT或乱码GPS模块供电不足独立供电用示波器或万用表查VCC电压有数据但fix0天线上方有金属遮挡把天线朝向天空远离MCU和杜邦线有数据但坐标跳变严重没有等待首次定位或天线质量差冷启动等30秒以上确认陶瓷天线没被遮挡定位后卫星数始终为0上电后模块还在搜星等一会再看检查有没有接后备电池这里我想重点强调一下天线问题。很多模块的天线是磁吸式或粘贴式出厂时贴了一张透明保护膜如果你没撕掉搜星时间会成倍增加。另外如果GPS模块的陶瓷天线紧挨着STM32的晶振或高频数字电路电磁干扰会明显导致信噪比下降定位精度变差。实测下来天线离STM32板子5厘米以上比贴在一起的时候卫星数多出3到4颗这不是玄学是经验。5.2 定位精度、冷启动和RTK的扩展方向M8N单点定位的水平精度一般在2.5米左右如果你做的是户外导航、轨迹记录、无人机返航点记录这个精度完全够用。但如果你需要厘米级定位比如农业自动驾驶、精密测量那就要考虑RTK但RTK需要额外基准站或网络差分服务M8N本身并不支持这是另一个话题了。冷启动时间这块M8N的表现还算不错但要注意冷启动的定义是模块完全丢失星历和历书后的启动比如你第一次上电或者长时间断电且后备电池没电。如果后备电池有电模块会记住上次的星历下次启动就变成热启动定位快很多。这也是为什么我建议买带后备电池的版本多花几块钱能省很多等待时间。前面RMC解析出速度的单位是节1节约等于1.852公里每小时。做显示界面的时候记得转换不然车跑60公里/小时你界面上显示32.4节看着会觉得很困惑。实际计算float speed_kmh gps_info.speed * 1.852f;6. 例程工程的移植与后续扩展如果要在自己的项目里用这套例程改动点其实很少。USART1换成USART2或USART3只需要把初始化里的USART1改成对应外设NVIC中断号也一起改中断回调里判断的huart-Instance换成新的串口实例即可。GPS的物理连接从PA9/PA10换到对应引脚就好其余解析逻辑完全不需要动。扩展方向上我实际做过几个比较有价值的后续玩法OLED显示把gps_info结构体里的字段格式化后输出到0.96寸OLED做成车载速度表和经纬度显示器实测效果很不错。SD卡轨迹记录每秒钟把有效坐标写入SD卡格式直接写CSV逗号分隔之后导入在线地图工具就能画出轨迹。注意SD卡写入速度慢如果和NMEA解析放在同一个循环里最好用FATFS的f_write加一段缓冲而不是每条数据都立即写卡。多模块数据融合如果你同时在用MPU6050这类IMU可以用GPS的绝对位置去校正IMU的积分漂移这就是组合导航最基础的思想。M8N的PPS脉冲可以用来和时间戳对齐做数据融合时非常有用。如果你打算把这套代码做成产品级建议把NMEA解析放进定时器中断或者RTOS的任务里加上超时看门狗避免挂在长时间无卫星信号的区域。另外u-blox还有个很实用的配置功能可以用U-Center软件把模块的输出语句裁剪到只留GPRMC数据量少了串口上可以腾出带宽加更短的刷新周期。在功耗敏感的应用里M8N支持各种省电模式包括周期性接收和待机模式这些功能在起步阶段可以先不碰但等你的移植稳定之后值得花点时间研究一下。毕竟GPS模块在便携设备里往往是耗电大户能省一点是一点。最后再分享一个我自己从踩坑里学到的小技巧如果你手头没有USB转TTL模块去和GPS模块单独通信看原始数据完全可以让STM32把收到的原始NMEA数据原封不动转发到另一个串口在PC上用串口助手看。比如GPS接USART1USART2接USB转TTL到电脑在中断回调里加一行if (huart-Instance USART1) { HAL_UART_Transmit(huart2, uart_rx_byte, 1, 1); }这个小技巧能帮你快速区分“GPS模块坏了”和“STM32解析代码写错了”这两种情况比拿示波器量波形快多了。我每次新拿一个GPS模块都是先这么做一遍原始转发确认模块没问题再开始写解析能省下大量无意义的Debug时间。本文还有配套的精品资源点击获取