简介STM32F103ZET6结合STM32CubeMX的GPS例程包面向需要快速入门CubeMX配置与串口GPS模块开发的嵌入式学习者尤其适合使用正点原子ATK MO1218等模块的场景。压缩包共188个文件、8.58MB包含63个.h头文件与26个.c源码文件以及.ioc工程配置、Keil MDK工程.uvprojx/.uvoptx、hex/axf等编译输出并附GPS模块的NMEA与二进制消息解析代码。例程覆盖系统时钟72MHz设置、UART 9600波特率通信、接收中断处理、GPIO初始化以及NMEA协议数据解析等关键知识点并附CubeMX生成的多目录工程结构便于对照学习底层初始化逻辑。已有220人浏览学习对理解STM32外部接口交互、提升CubeMX/HAL库实战能力有明确帮助。1. STM32F103ZET6 结合 CUBE 使用 GPS 例程串口通只是起点能解析才是落地拿到一块 STM32F103ZET6 最小系统和一块串口输出的 GPS 模块大多数情况下接好线打开串口助手能看到不断滚动的 NMEA 原始报文大家就认为 GPS 例程跑通了。但真实的需求往往不是看报文而是让 MCU 自己完成接收、解析、输出定位结果和时间。这个标题里的 STM32F103ZET6 结合 CUBE 使用 GPS 例程本质上就是用 STM32CubeMX 串起 UART、DMA、解析三段链路让 GPS 模块变成可以随时调用的数据源。适合刚入手 Cube 工具链、想在真实定位场景里少走弯路的开发者也适合有硬件基础但被协议解析和中断逻辑卡住的朋友。下面先把协议层讲清再落一套可以直接跑的代码最后把最常见的坑挨个拆开。2. 先把 GPS 模块的 NMEA 协议和接线选型搞清楚再动 CubeMX2.1 GPRMC 字段速查真正要解析的只有一条语句GPS 模块上电后自行找星通过串口往外部吐 NMEA 0183 格式的文本。它输出很多条以$开头的语句比如$GPGGA、$GPGSA、$GPGSV、$GPRMC每一行都用逗号分隔字段。新手最容易犯的错是试图解析所有语句其实绝大多数定位应用只需要$GPRMC。$GPRMC是推荐最小定位信息它同时包含 UTC 时间、定位有效标志、纬度、经度、速度、航向和日期。一条典型的语句长这样$GPRMC,082735.00,A,0930.1234,S,05210.5678,E,0.8,45.6,230424,5.2,W*5A按逗号拆开后的关键字段如下表字段序号示例值含义是否常用0$GPRMC语句头是1082735.00UTC 时间格式 hhmmss.sss是2A定位状态A有效V无效是30930.1234纬度格式 ddmm.mmmm是4S南纬/北纬标记是505210.5678经度格式 dddmm.mmmm是6E东经/西经标记是70.8对地速度单位节否845.6航向角单位度否9230424UTC 日期格式 DDMMYY是105.2磁偏角否11W磁偏方向否需要注意*5A不是逗号分隔出来的独立字段它跟在字段 11 后面*前没有逗号所以按逗号分割后它挂在 W 后面。校验和的计算方法是$与*之间的所有字符逐字节异或结果转成两个十六进制字符。很多教程不校验也能用但如果你要把定位结果用于后装设备或者做数据质量统计建议保留校验逻辑它能挡住大量乱帧。2.2 GPGGA 里的定位质量标志什么时候该信这颗星除了$GPRMC$GPGGA也值得关注它包含定位质量字段。GGA 的第 6 个字段表示定位状态0 表示无效1 表示 GPS 定位2 表示差分定位。当模块刚上电处于冷启动状态时GPRMC 里的状态字段是V而 GGA 的定位质量是 0。这两条语句可以互相印证。我一般会把 GPRMC 的A/V作为主判断因为A出现时定位质量一定是可用的。但要注意一个细节某些模块在信号弱的环境下会输出A状态但坐标漂移明显这时需要配合 HDOP 水平精度因子来判断HDOP 在$GPGSA语句里。对 STM32F103ZET6 这种主频 72MHz 的 MCU 来说解析 NMEA 的 CPU 开销很小。真正影响系统稳定性的不是解析算法而是串口接收方式。下一章会专门讲用 DMA 接收的原因这里先把模块接口和接线定下来。2.3 模块接线与电压适配先看 TTL、3.3V 还是 5VGPS 模块按接口大体分两类USB 接口和串口 TTL 接口。做嵌入式例程必须选串口 TTL 的模块。STM32F103ZET6 的 UART 引脚是 3.3V 电平如果你手里的模块是 5V TTL电平兼容性会出问题。常见做法是选 3.3V 供电的模块或者用带电平转换的模块。接线逻辑并不复杂但容易被忽略的是共地。GPS 模块的 GND 必须和 STM32F103ZET6 的 GND 接在一起否则串口信号没有参考电平数据会乱码。模块的 TX 接 MCU 的 RX模块的 RX 接 MCU 的 TX这是交叉连接。以 USART2 为例推荐使用 PA2 作为 USART2_TXPA3 作为 USART2_RX。GPS 模块引脚STM32F103ZET6 引脚说明VCC3.3V 或 5V 电源看模块规格优先 3.3VGNDGND必须共地TXPA3 / USART2_RXGPS 发送MCU 接收RXPA2 / USART2_TX可选接配置指令用PPS任意 GPIO秒脉冲进阶校准用选串口的原则优先用 USART2 或 USART3不要抢 USART1。很多 STM32F103ZET6 最小系统板的 USART1 默认接了板载调试串口你要是把 GPS 也挂到 USART1调试时数据会互相打架。这个问题我会在避坑清单里专门展开。3. CubeMX 配置 STM32F103ZET6时钟、USART2 与 DMA 一套走完3.1 新建工程与时钟树72MHz 主频怎么调打开 CubeMX 新建工程时直接搜 STM32F103ZET6选中后进入 Pinout 配置界面。首先要做的是 RCC 配置在 System Core 菜单里选 RCC把 HSE 设成 Crystal/Ceramic Resonator。如果你用的板子外部晶振是 8MHz这一步会直接影响后面的时钟树。进入 Clock Configuration 页面把 PLL Source 选为 HSE然后手动把 SYSCLK 调到 72MHz。CubeMX 会自动算出 HCLK、PCLK1、PCLK2 的数值。SYSCLK 72MHz 时HCLK 也是 72MHzPCLK1 是 36MHzPCLK2 是 72MHz。这里要特别注意 APB1 定时器时钟是 72MHzAPB1 外设总线是 36MHzGPIO 翻转速度够用。不要图省事直接用默认的 8MHz 内部时钟GPS 模块串口波特率不高但你后面做 PPS 时间校准时系统时钟精度直接影响时间戳误差。时钟配置完成后在 Pinout 界面找到 USART2把 Mode 选为 Asynchronous。这一步会自动把 PA2 和 PA3 分配为 USART2 的 TX 和 RX。如果你的工程里这两个引脚被其他外设抢占CubeMX 会标红这时选择其他可用 UART 或者手动解除冲突。3.2 USART2 参数与 DMA 配置波特率、循环模式和中断优先级USART2 的参数配置里波特率默认 115200但很多 GPS 模块出厂默认 9600。先确认模块手册建议直接用 9600距离短、抗干扰强。参数配置按 8 位数据、无校验、1 位停止位也就是常说的 8N1。不要为了赶时髦设成 9 位GPS 模块标准就是 8N1。DMA 部分是最关键的一步。在 USART2 的 DMA Settings 里添加 DMA Request 为 USART2_RXMode 选 Circular。为什么一定要用 DMA因为 GPS 模块每秒输出 1 到 10 帧 NMEA 数据每帧最长 80 字节左右如果用 HAL_UART_Receive 死等CPU 会一直被打断而且一帧数据可能在接收过程中断成两段解析时容易丢。DMA 循环模式让数据在后台自动写入内存缓冲区CPU 只在缓冲区拿到完整一帧时做一次解析。中断优先级也要配置好。NVIC 里把 USART2 全局中断和 DMA 中断都打开优先级建议给到中等偏低比如抢占优先级 2、子优先级 2。如果把 UART 中断优先级设成 0它会在中断里和 SysTick 抢占资源极端情况下系统会卡死。这是我调过多次的坑优先级不是越高越好。3.3 生成代码后的三个手动补丁启动 DMA、串口重定向、清溢出标志CubeMX 生成工程后默认的 main.c 里会生成一段MX_USART2_UART_Init并且在 USER CODE BEGIN 2 区域通常只初始化一些外设。你需要在 USER CODE BEGIN 2 里手动启动 DMA 接收。注意默认生成的HAL_UART_Receive_DMA只能接收一次数据接收满指定长度后会停止而 GPS 数据是持续的改用HAL_UARTEx_ReceiveToIdle_DMA更合适。这个函数把 DMA 循环接收和空闲中断组合在一起串口空闲时触发一次回调回调里能拿到本次接收到的数据长度。#define GPS_RX_BUF_LEN 256 #define GPS_LINE_MAX 128 static uint8_t gps_rx_buf[GPS_RX_BUF_LEN]; static uint8_t gps_line[GPS_LINE_MAX]; static uint16_t gps_line_len 0; void gps_uart_start(void) { __HAL_UART_CLEAR_FLAG(huart2, UART_FLAG_ORE); if (HAL_UARTEx_ReceiveToIdle_DMA(huart2, gps_rx_buf, GPS_RX_BUF_LEN) ! HAL_OK) { Error_Handler(); } }这段代码里GPS_RX_BUF_LEN是 DMA 循环缓冲区的总长度必须大于任何一条 NMEA 语句的长度。NMEA 行最长约 82 字节256 字节足够。GPS_LINE_MAX是行缓冲区的长度用于把 DMA 缓冲区里可能被截断的半行数据拼成完整一行。__HAL_UART_CLEAR_FLAG(huart2, UART_FLAG_ORE)是清除上溢标志如果之前串口数据来得太快HAL 库会置溢出标志导致后续接收停止启动前清一次可以避免这个问题。启动函数要在进入主循环前调用之后不用再管。串口重定向也必须做。如果你希望用 printf 直接打印解析结果需要在usart.c里添加重定向函数把 printf 的输出送到调试串口。这里有一个容易踩坑的地方printf 重定向到 USART1 时不要和 GPS 的 USART2 搞混两个串口各干各的。最后在生成的stm32f1xx_it.c里USART2 的中断服务函数已经存在但默认只调用 HAL_UART_IRQHandler。如果你用的是HAL_UARTEx_ReceiveToIdle_DMA引脚和中断回调的处理方式与普通 DMA 接收略有不同下一章会写清楚回调函数怎么写。4. 写 GPS 解析代码DMA 入缓冲GPRMC 出经纬度4.1 用 DMA 加空闲中断接收回调函数里拼完整行HAL_UARTEx_ReceiveToIdle_DMA的工作方式是DMA 不断把串口收到的字节写入gps_rx_buf当总线空闲时HAL 库会调用HAL_UARTEx_RxEventCallback。这个回调的第二个参数Size表示从本次启动接收开始到现在一共收到了多少字节。因为 DMA 是循环模式缓冲区头部可能已经被覆盖所以本次数据的起始位置不是缓冲区首地址而是GPS_RX_BUF_LEN - Size。回调函数的实现重点是数据拼装。NMEA 数据可能一帧正好落在缓冲区中间也可能被拆成两段所以不能直接把整块缓冲区丢给解析函数。我在工程里维护一个独立行缓冲gps_line把每次回调收到的字节逐字节追加进去遇到\n就认为这是一个完整行交给解析函数然后清空行缓冲。void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance ! USART2) { return; } uint16_t start (GPS_RX_BUF_LEN - Size) % GPS_RX_BUF_LEN; for (uint16_t i 0; i Size; i) { uint8_t c gps_rx_buf[(start i) % GPS_RX_BUF_LEN]; if (c \n) { while (gps_line_len 0 (gps_line[gps_line_len - 1] \n || gps_line[gps_line_len - 1] \r)) { gps_line[--gps_line_len] \0; } gps_line[gps_line_len] \0; gps_parse_line((char *)gps_line); gps_line_len 0; } else if (gps_line_len GPS_LINE_MAX - 1) { gps_line[gps_line_len] c; } else { gps_line_len 0; } } if (HAL_UARTEx_ReceiveToIdle_DMA(huart2, gps_rx_buf, GPS_RX_BUF_LEN) ! HAL_OK) { Error_Handler(); } }这里有几个参数值得细看。start的计算用的是(GPS_RX_BUF_LEN - Size) % GPS_RX_BUF_LEN当 Size 等于缓冲区长度时取模后是 0等价于从缓冲区头开始读。逐字节拼接时如果行缓冲满了还没等到\n说明收到了异常数据直接清空重来避免满行后内存越界。回调函数末尾必须重新调用一次接收函数否则 DMA 只跑一轮就停了这是新手最容易漏掉的一步。4.2 GPRMC 解析逗号定位优于 sscanf解析 NMEA 行时新手习惯用 sscanf但 GPRMC 的字段数量和尾随字段在不同模块上不是完全一致有的模块会多发几个空字段有的模块在语句末尾的磁偏字段缺失。sscanf 遇到格式不匹配时容易解析出垃圾值。我一般用逗号分割法先把整行按逗号拆成字段数组再按下标取值这样代码更可控。typedef struct { uint8_t valid; double lat; double lon; uint8_t hour; uint8_t min; uint8_t sec; uint8_t day; uint8_t month; uint8_t year; } gps_result_t; volatile gps_result_t gps_result; static int gps_split_nmea(char *line, char *field[], int max_field) { int n 0; field[n] line; while (*line n max_field) { if (*line ,) { *line \0; field[n] line 1; } line; } return n; } void gps_parse_line(char *line) { char *f[16]; int n gps_split_nmea(line, f, 16); if (n 9) { return; } if (strncmp(f[0], $GPRMC, 6) ! 0) { return; } if (f[2][0] ! A) { return; } double lat_raw atof(f[3]); double lon_raw atof(f[5]); gps_result.lat (int)(lat_raw / 100) (lat_raw - (int)(lat_raw / 100) * 100) / 60.0; gps_result.lon (int)(lon_raw / 100) (lon_raw - (int)(lon_raw / 100) * 100) / 60.0; if (f[4][0] S) { gps_result.lat -gps_result.lat; } if (f[6][0] W) { gps_result.lon -gps_result.lon; } gps_result.hour (f[1][0] - 0) * 10 (f[1][1] - 0); gps_result.min (f[1][2] - 0) * 10 (f[1][3] - 0); gps_result.sec (f[1][4] - 0) * 10 (f[1][5] - 0); gps_result.day (f[9][0] - 0) * 10 (f[9][1] - 0); gps_result.month (f[9][2] - 0) * 10 (f[9][3] - 0); gps_result.year (f[9][4] - 0) * 10 (f[9][5] - 0); gps_result.valid 1; }这段代码里gps_split_nmea把一行按逗号拆成最多 16 个字符串每个字段以\0结尾。f[2][0] ! A是定位有效判断只要状态是 V 就直接丢。纬度转换时原始值是 ddmm.mmmm 格式前两位是度后面是分所以先除以 100 拿度再取余拿分最后把分除以 60 变成度。经度同理只是原始格式是 dddmm.mmmm。f[4][0]和f[6][0]是南北纬和东西经标记转换成负值表示南纬和西经。时间解析用的是字符偏移法因为f[1]的格式固定是hhmmss.sss前两位是小时第三四位是分钟第五六位是秒。这样比 atof 再取整更省事也避免浮点误差。日期字段同理。4.3 时区转换与打印UTC 时间要加 8 小时GPRMC 里的时间是 UTC 时间不是本地时间。国内应用需要转成北京时间也就是 UTC 8。转换并不复杂但要考虑跨日问题UTC 时间 18 点时北京已经是第二天凌晨 2 点。如果只把小时加 8 再取模日期就会错一天。uint8_t utc_hour gps_result.hour; utc_hour 8; if (utc_hour 24) { utc_hour - 24; gps_result.day; if (gps_result.day 31) { gps_result.day 1; gps_result.month; if (gps_result.month 12) { gps_result.month 1; gps_result.year; } } } gps_result.hour utc_hour;这段代码是简化的日期进位处理。gps_result.year存的是两位数年份比如 24 代表 2024 年所以跨年后变成 25 是合理的。实际的月份天数差别很大2 月有 28 天大月有 31 天如果你只需要在控制台看个时间上面的简化够用如果要做正式的日志系统建议引入日期库或者自己写闰年判断。打印解析结果可以直接用 printf 重定向到 USART1。调试串口接电脑GPS 串口接模块这样互不干扰。printf(UTC8: %02d:%02d:%02d, %.6f, %.6f\n, gps_result.hour, gps_result.min, gps_result.sec, gps_result.lat, gps_result.lon);%.6f可以打印出小数点后 6 位的经纬度精确到米级。注意 MCU 上用 printf 默认只支持整数和字符串浮点数格式化需要开启 C 库的浮点支持。在 CubeMX 生成的工程里可以在 Keil 的 Options 里把 C Library 选为 Use MicroLIB这样才能正常打印%.6f。这个问题在很多新手上手时会出现编译器报一堆警告但程序能编译过运行后发现输出全是 0。5. GPS 例程避坑清单5 个让新手最容易翻车的点5.1 现象串口助手有数据程序里读不到这个问题在接手别人的板子时经常遇到。串口助手上能看到完整的 NMEA 语句说明 GPS 模块工作正常但单片机里gps_result.valid始终是 0。第一反应不要怪代码先检查接线。原因多半是GPS 模块的 TX 接到了单片机同一个引脚的 TX 上或者 GND 没有和单片机共地。还有可能是 GPS 模块输出电平是 5V而 STM32F103ZET6 的 GPIO 容忍 5V 输入但串口外设的 RX 引脚状态不确定接收会乱码。解决办法是按第 2 章的接线表重新核对重点看 PA3 是不是 USART2_RX模块 TX 是否确实进了 PA3。如果电平不匹配加一个电平转换模块。另一个隐蔽的原因是 DMA 配置里选了 Normal 模式而不是 Circular 模式Normal 模式接收满一次就停下来后续数据全部丢失。把 Mode 改成 Circular并且在回调函数末尾重新调用接收启动函数。5.2 现象静止时经纬度跳变漂移几十米定位后坐标不稳定甚至静止时每隔几秒跳几十米这是 GPS 应用里最典型的“血泪经验”。如果程序里已经用f[2][0] ! A过滤了无效状态那问题大概率出在坐标转换上。很多模块输出的纬度是ddmm.mmmm格式。例如字段值是0930.1234正确的度数是 9 度后面 30.1234 是分转换后是 9.50539 度。如果直接把字符串0930.1234用 atof 转成 930.1234再除以 100 得到 9.301234差了约 0.2 度对应地面距离约 20 公里以上。这不算漂移是单位换算错误。正确的做法是拆出整数度和小数分分除以 60。另一个容易忽略的原因是天线位置。GPS 模块的天线或者外置有源天线必须朝上放在金属外壳里或者被 LCD 排线遮住信号会出现断断续续的跳变。这种情况不是代码能解决的应该先观察串口助手里 GGA 语句的定位质量字段是否为 1HDOP 是否小于 2。如果定位质量本身不稳定代码再怎么过滤都没用。5.3 现象USART1 复用冲突代码卡死在串口回调很多 STM32F103ZET6 最小系统板出厂时把 USART1 接在板载调试串口芯片上用于和电脑通信。如果你把 GPS 模块也接到 USART1 的 PA9 和 PA10就会出问题GPS 的 TX 信号和板载调试串口的 TX 信号在同一根线上竞争两边同时驱动时电平互相拉扯串口数据完全乱掉严重时程序跑进串口错误中断出不来。解决办法很简单给 GPS 换 USART2 或 USART3。如果板子设计上只有 USART1 引出了排针那么调试时不要同时打开串口助手或者把板载调试串口的跳线帽拔掉。这个坑的价值在于提醒你复用不是只查 CubeMX 的引脚分配表还要看硬件原理图上的外围器件占用了哪些引脚。我在排查时还会在初始化后打印一行启动日志确认 USART1 调试串口工作正常再继续调 GPS。如果日志都打印不出来说明板卡硬件或者时钟配置有问题这时候去查 GPS 是没有意义的。5.4 现象解析出的时间比本地时间晚了 8 小时这是 UTC 和北京时间的关系没处理好。GPRMC 里的时间是 UTC 时间北京时间的时区是 UTC8。如果你直接把这个时间打印出来会发现凌晨 0 点对应北京时间早上 8 点正好差 8 小时。处理时区时除了小时加 8还要考虑日期进位。UTC 时间 16:00 之后北京时间已经是第二天。很多人的做法是简单取模gps_result.hour (gps_result.hour 8) % 24;这样做小时数对但日期不变第二天凌晨会顶着一张旧日期报数据。我在第 4 章给出的进位代码能处理大部分场景。要注意月末的判断我的简单版只在天数大于 31 时进位遇到 4 月 31 日这种情况会出错。实际工程里最好用固定的天数表按月份判断当月最大天数。这个坑不深但数据处理上容易埋雷。5.5 现象上电后要等很久才有定位数据程序像死机GPS 模块冷启动时需要下载星历才能定位时间是几十秒到几分钟。上电后如果程序一直等gps_result.valid变为 1在这段时间里什么都干不了用户会以为死机了。实际上模块在找星串口上已经持续输出 NMEA 语句只是状态字段是V。解决办法是不要让主循环阻塞等待定位。正确做法是解析代码不断更新gps_result无论状态是 A 还是 V 都实时刷新到屏幕上或者串口。主循环只消费结果显示当前定位状态。如果 3 分钟还没定位再提示用户检查天线。还有一个和硬件相关的坑有些 GPS 模块需要备份电池保持星历如果模块没有电池每次上电都是冷启动。必要时可以在模块的 V_BCKP 引脚接一个纽扣电池或超级电容它不负责供电只维持内部 RTC 和星历 RAM启动时间能从几十秒缩短到几秒。这属于硬件优化软件上只能做状态提示。6. 进阶验证PPS 时间校准、自动波特率与链路自检6.1 用 PPS 引脚校准本地时间GPS 模块通常有一个 PPS 引脚每秒输出一个高电平脉冲上升沿对应的时刻就是整秒。把这个引脚接到 STM32F103ZET6 的任意 GPIO并配置成外部中断上升沿触发可以做一个秒级时间校准。void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPS_PPS_GPIO_Pin) { uint32_t tick HAL_GetTick(); /* 用 tick 校准本地 RTC 或系统时间戳 */ } }PPS 的精度比串口解析时间高得多因为串口帧到达时间和实际整秒之间有几毫秒到几十毫秒的随机延迟。用 PPS 校准后本地时间和 GPS 时间可以做到毫秒级对齐。这个技巧在数据记录、时间同步类应用里很有价值。6.2 自动波特率探测换模块不慌不同 GPS 模块的默认波特率可能是 9600、38400 甚至 115200。如果程序里写死 9600换一个模块就得改代码重烧。我习惯在初始化时做一次自动探测把候选波特率列表逐个尝试每个波特率下等一两秒看是否能解析出$GPRMC且状态字段有意义。static const uint32_t baud_list[] {4800, 9600, 19200, 38400, 115200};探测到有效帧后固定当前波特率后续不再切换。这个功能的代价是启动时多花几秒但换来的是硬件更换时的便利。注意探测期间要重新初始化 UART因为修改波特率后 HAL 的句柄参数不会自动生效。6.3 给例程加自检统计最后说一个我自己长期保留的习惯在代码里加一个简单的统计结构记录接收到的帧数、解析成功的帧数、校验失败帧数。GPS 这类射频链路最容易出软故障数据时好时坏没有统计就只能靠感觉排查。static struct { uint16_t received; uint16_t fixed; uint16_t checksum_err; } gps_stats;如果checksum_err / received的比例超过 20%优先怀疑接线、电平或干扰而不是去调解析逻辑。这个自检代码不到 20 行却能省下大量排查时间。我一直保留这个习惯先把统计和 PPS 校准做进去再跑业务流程。换模块、换环境时通过这些数据能立刻判断是上游条件不行还是下游逻辑不对少走很多冤枉路。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?