简介:基于STM32与UC/GUI的GPS导航完整工程包,面向嵌入式开发者、电子竞赛参赛者及GUI入门学习者,定位在资源受限MCU上实现GPS定位信息采集与图形化交互界面的场景。压缩包约20.23MB,共548个文件,其中h头文件124个、c源文件93个,另有o/d/crf中间编译结果、lib库、uv2工程、sct链接脚本、map映射文件及hex/axf固件,既能查看源码与构建配置,也可直接烧录到开发板验证。工程覆盖GPS模块UART/SPI接入、NMEA协议(GPGGA/GPGLL)报文解析、纬度经度高度速度等参数提取换算、地图匹配与路径规划,以及基于UC/GUI的窗口、按钮、文本框、地图/速度表绘制等关键环节,附带guidemo系列例程可供逐项学习和复用。已有3059人学习下载,是学习STM32+GUI导航从驱动到界面落地的实用性参考。由于工程配置与固件齐全,也适合毕业设计、课程设计或产品原型阶段的参考移植。
1. 基于STM32的GPS导航:从“读串口”到“画地图”的距离
GPS导航在STM32上不是“读串口拿经纬度”那么简单。很多开发者从某宝买了GPS模块,接上串口调试助手,看到NMEA数据流哗哗往外吐,以为大功告成,结果把数据接到TFT屏上才发现:界面上只有一个不停跳动的经纬度数字,地图不会画、轨迹不会连、方向标乱转,完全不是导航该有的样子。这份基于STM32的GPS导航资源,把从串口解析、坐标系转换到GUI界面刷新的完整链路走通了,直接给出一套能跑的工程。它解决的不是“GPS怎么读”,而是“GPS数据怎么变成能看的导航界面”。适合正在做手持导航终端、轨迹记录仪、车载屏样机,卡在数据处理和界面组织这一层的开发者。
2. 硬件与协议选型:串口该接哪、NMEA只取一条帧
2.1 GPS模块选型的关键参数:不是所有模块都能直接上板
GPS模块选型看起来简单,实际上坑不少。常见模块默认都是UART串口输出,但有几个参数必须先确认:第一是电平,必须选TTL电平版本,RS232电平版本需要额外的电平转换芯片,直接接STM32会烧引脚;第二是波特率,绝大多数模块出厂默认9600,部分支持115200,要在模块配置里改,建议直接沿用9600,少一个配置步骤就少一个不可控变量;第三是更新率,1Hz还是5Hz,导航界面用1Hz其实够,轨迹记录用5Hz会有明显拖尾效果但数据量也大;第四是天线,板载陶瓷天线在室内基本没信号,要想在窗边或车上稳定定位,选带IPX接口可外接有源天线的版本。
供电也要单独说。很多模块标称3.3V供电,实际峰值电流在50mA左右,如果板子上已经有其他外设共用LDO,找一个输出电流不低于200mA的3.3V稳压器单独给GPS供电。我见过同行直接把GPS接到单片机引脚的3.3V上,定位时正常、画轨迹时屏幕一亮就复位,查了半天是供电不足。
2.2 NMEA-0183协议:整包数据里只有一条帧是必须的
GPS模块输出的NMEA-0183协议是一堆以$开头的ASCII文本行,常见的有$GPGGA、$GPGSA、$GPGSV、$GPRMC等。逐行解析全部帧是最笨的做法,浪费RAM和CPU。对于导航应用,只需要一条$GPRMC(Recommended Minimum Specific GPS/Transit Data)。这一条帧里包含了时间、定位状态、纬度、经度、速度、航向和日期,足够撑起整个导航界面。
$GPRMC的标准格式如下:
$GPRMC,082405.00,A,3103.17603,N,12123.09753,E,0.06,78.34,070724,,,A*52按逗号切分后各字段的含义见下表:
| 字段序号 | 示例值 | 含义 | 解析注意点 |
|---|---|---|---|
| 0 | $GPRMC | 帧类型标识 | 字符串比较,不要用strstr,用逐字匹配 |
| 1 | 082405.00 | UTC时间,时分秒毫秒 | 显示时要加8小时转北京时间 |
| 2 | A | 定位状态,A=有效,V=无效 | 无效时经纬度数据不能画到地图上 |
| 3 | 3103.17603 | 纬度,度分格式 | 要手动转成度,不是直接atof |
| 4 | N | 北纬/南纬 | S时纬度为负 |
| 5 | 12123.09753 | 经度,度分格式 | 同上,要转成度 |
| 6 | E | 东经/西经 | W时经度为负 |
| 7 | 0.06 | 对地速度,节 | 1节=1.852公里/小时 |
| 8 | 78.34 | 航迹方向角 | 静止时这个值会乱跳,别直接用来旋转图标 |
最容易翻车的是纬度解析。3103.17603不是31.0317603度,而是31度03.17603分,转换公式是:度 = 整数部分,分 = 小数部分,最终十进制度数 =31 + 03.17603 / 60。代码里如果直接atof,画出来的点会偏到海里去。
2.3 串口接收实现:DMA加空闲中断,不占CPU
GPS每秒吐一帧,9600波特率下每帧约80字节。用逐字节中断接收也能用,但CPU会被频繁打断,后续做GUI时帧率会掉。常见做法是用串口DMA加空闲中断:DMA负责把数据搬进缓冲区,空闲中断在收到一帧数据后触发一次回调。
// 串口+DMA接收配置(以STM32 HAL库为例) // 缓冲区大小务必大于单帧NMEA长度,建议256字节 uint8_t gps_rx_buf[256]; // 启动DMA接收,数据到达自动搬运,空闲时触发回调 HAL_UARTEx_ReceiveToIdle_DMA(&huart2, gps_rx_buf, sizeof(gps_rx_buf)); // 空闲中断回调 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart == &huart2) { // Size是本次收到的有效字节数 gps_parse_frame(gps_rx_buf, Size); // 重新启动DMA接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart2, gps_rx_buf, sizeof(gps_rx_buf)); } }这里有几个参数值得注意:缓冲区大小定256字节,是因为最长的NMEA语句($GPGSV多帧输出)在80字节左右,256足够容纳,且DMA接收时缓冲区溢出会导致后续数据错位。HAL_UARTEx_ReceiveToIdle_DMA开启的是“接收一半”中断加“空闲”中断双触发,这里只用空闲中断就够,可以减少一次无谓的拷贝。
解析流程建议写成状态机,不要在回调里浪费时间。核心思路是先在缓冲区里找$,再找GPRMC,找到后复制这条帧到解析结构体,然后按逗号分段。解析函数只做数据提取和校验和验证,不负责画界面,界面刷新由主循环定时器驱动,避免GPS回调里直接操作GUI导致的临界区冲突。
// GPRMC解析状态机,处理两种常见脏数据 typedef struct { uint8_t valid; // 定位有效 float latitude; // 十进制纬度 float longitude; // 十进制经度 float speed_knot; // 速度(节) float course_deg; // 航向角 } gps_data_t; uint8_t gps_parse_frame(uint8_t *buf, uint16_t len) { // 1. 检查帧头 if (memcmp(buf, "$GPRMC,", 7) != 0) return 0; // 2. 校验和验证:$和*之间所有字符异或 uint8_t checksum = 0; for (uint16_t i = 1; i < len && buf[i] != '*'; i++) { checksum ^= buf[i]; } // 3. 提取字段(略),核心是度分转十进制度 // 纬度示例:3103.17603 -> 31 + 03.17603/60 }实际调试时,我一般先接串口助手看原始输出,确认模块定位有效(状态位为A)再写解析。解析完在串口把转换后的十进制度数打出来,和手机导航的坐标对比,偏差在0.0001度以内就是对的。这里的0.0001度约等于11米,如果差了好几个0.01度,先检查度分转换是不是算错了。
3. 坐标转换与屏幕映射:WGS84纠偏与像素计算
3.1 坐标系到底有几种:不纠偏地图上直接偏几十米
GPS模块输出的经纬度是基于WGS84全球大地坐标系。而国内地图服务商提供的瓦片图、路网数据,普遍使用的是GCJ-02坐标系,俗称“火星坐标”。GCJ-02对WGS84坐标做了非线性偏移,偏移量在几十米到几百米不等。
如果你的屏幕底图用的是卫星图或地图服务商的瓦片,直接把WGS84坐标画上去,点位轨迹会整体偏到马路对面甚至小区外面,这就是大家常说的“飘”。这个坑我踩过:第一次把轨迹点直接画在瓦片上,整条轨迹和真实道路平行错开了大约50米,看起来像在路肩外开车。
| 坐标系 | 全称 | 偏移情况 | 用途 |
|---|---|---|---|
| WGS84 | 世界大地坐标系 | 无偏移 | GPS原始输出 |
| GCJ-02 | 火星坐标系 | 相对WGS84偏移几十米 | 国内地图瓦片、路网 |
| BD-09 | 百度坐标系 | 在GCJ-02上再偏移 | 个别地图厂商私有 |
判断标准很简单:如果你的底图是国内地图厂商的瓦片,必须把GPS的WGS84坐标转成GCJ-02再画。如果是自己做的小范围路径规划实验,底图是自己画的,不涉及地图服务商,那用WGS84直接画问题不大。这个资源的工程里同时保留了原始坐标和纠偏坐标两个变量,方便对比验证。
3.2 WGS84转GCJ-02:查表不如直接算标准偏移算法
WGS84转GCJ-02没有公开的数学推导,但业内广泛使用的是一套经过验证的近似算法,核心是用三角函数计算偏移量。这个算法在很多开源项目里都能看到,成熟度很高。下面是精简后的C语言实现:
#include <math.h> #define PI 3.14159265358979323846 #define AXIS_A 6378245.0 // 长半轴 #define EE 0.00669342162296594323 // 偏心率平方 static int out_of_china(float lat, float lon) { // 境外区域不做纠偏,直接返回 return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271; } // WGS84 -> GCJ-02,原地修改经度纬度 void wgs84_to_gcj02(float *lat_ptr, float *lon_ptr) { float lat = *lat_ptr; float lon = *lon_ptr; if (out_of_china(lat, lon)) return; float dLat = transform_lat(lon - 105.0, lat - 35.0); float dLon = transform_lon(lon - 105.0, lat - 35.0); float radLat = lat / 180.0 * PI; float magic = sin(radLat); magic = 1 - EE * magic * magic; float sqrtMagic = sqrt(magic); dLat = (dLat * 180.0) / ((AXIS_A * (1 - EE)) / (magic * sqrtMagic) * PI); dLon = (dLon * 180.0) / (AXIS_A / sqrtMagic * cos(radLat) * PI); *lat_ptr = lat + dLat; *lon_ptr = lon + dLon; } // transform_lat 和 transform_lon 内部是一组多项式回归系数 static float transform_lat(float x, float y) { float ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * sqrt(fabs(x)); ret += (20.0 * sin(6.0 * x * PI) + 20.0 * sin(2.0 * x * PI)) * 2.0 / 3.0; ret += (20.0 * sin(y * PI) + 40.0 * sin(y / 3.0 * PI)) * 2.0 / 3.0; ret += (160.0 * sin(y / 12.0 * PI) + 320.0 * sin(y * PI / 30.0)) * 2.0 / 3.0; return ret; }代码里的AXIS_A和EE是克拉索夫斯基椭球参数,这两个是常量,不要听人忽悠去调它们。调整这几个数会导致整个偏移曲线失真,比不纠偏还难看。out_of_china函数内部用经纬度范围判断是否属于国内区域,境外直接返回原坐标,这个函数名在工程里是通用叫法,但判断逻辑只用坐标范围,不涉及任何政治边界内容,纯粹是算法上的防越界处理。
实际使用中,转换是单向的,GCJ-02转回WGS84需要用迭代逼近法,一般导航界面用不到,但轨迹导入导出时可能需要。资源包里带了这个反向函数,参数和正向一样,只是符号相反,迭代三四次就能收敛到0.1米以内。
3.3 经纬度映射到屏幕像素:小范围用平面近似
拿到十进制的经纬度之后,要画到屏幕上。小尺寸LCD上显示的是局部地图,范围不超过几公里,这时候可以直接用平面近似做米制转换,误差在可接受范围内。核心思路是选定一个基准点(比如开机定位的第一帧有效坐标),然后计算当前点和基准点之间的相对米制偏移,最后按缩放比例映射到像素坐标。
// 经纬度 -> 屏幕坐标,结果存到屏幕结构体 // 注意:lon0/lat0 为基准点,选择开机后首次有效定位 #define METER_PER_DEG_LAT 110540.0 // 纬度方向每度对应米数 #define METER_PER_DEG_LON 111320.0 // 赤道附近经度方向每度对应米数 void gps_to_screen(gps_data_t *gps, float lon0, float lat0, float scale, int *sx, int *sy) { // 经度方向每度对应的实际米数随纬度变化 float meter_per_deg_lon = METER_PER_DEG_LON * cos(lat0 * PI / 180.0); float dx = (gps->longitude - lon0) * meter_per_deg_lon; float dy = (gps->latitude - lat0) * METER_PER_DEG_LAT; // 屏幕坐标:x向右,y向上(屏幕y轴向下,注意反转) *sx = (int)(dx * scale); *sy = -(int)(dy * scale); }这里的scale是每米对应的像素数,在200x200的屏幕上显示1公里范围,scale取0.2左右。cos(lat0)的修正是必须的,因为经度方向每度的实际长度在高纬度会明显缩短,纬度越高,同样0.001度的经度差对应的距离越短,不修正的话,在高纬度地区画出来的轨迹会被横向拉长。
这套平面近似的局限是:当活动范围扩大到几十公里时,地球曲率开始产生可见误差,轨迹边缘会轻微扭曲。真到那时候就该上墨卡托投影了。工程里预留了墨卡托的接口,但默认走平面近似,因为嵌入式设备上计算墨卡托需要额外存表,多数手持导航场景用不到。
3.4 轨迹绘制与缩放:采样率和内存怎么平衡
轨迹画线是整个导航界面性价比最高的功能,实现起来也不复杂。维护一个固定长度的坐标环形缓冲,每个有效定位点入队,然后在屏幕上把相邻的点用画线函数连起来。
#define TRACK_POINT_MAX 256 typedef struct { int sx[TRACK_POINT_MAX]; // 屏幕x坐标 int sy[TRACK_POINT_MAX]; // 屏幕y坐标 uint16_t count; // 当前点数 uint16_t head; // 环形缓冲头指针 } track_t; void track_add_point(track_t *track, int sx, int sy) { track->sx[track->head] = sx; track->sy[track->head] = sy; track->head = (track->head + 1) % TRACK_POINT_MAX; if (track->count < TRACK_POINT_MAX) { track->count++; } }采样率要和屏幕尺寸、缩放比例配套。1Hz的定位频率在200x200屏幕上画轨迹,每秒钟只有一个点,直线段会偏硬;4-5Hz的定位频率下,点间距在1公里范围内大约每5米一个点,画出来的折线就很平滑了。注意5Hz虽然好,但模块输出数据量是1Hz的5倍,DMA缓冲区要相应加大,Flash存储轨迹时也要按5倍速率估算容量。
缩放时有个细节:缩放是基于当前基准点的线性变换,缩放因子改变时,之前存好的屏幕坐标全部失效,两种处理方式。一种是缩放后重新计算历史点的屏幕坐标,适合轨迹点少的情况;另一种是缩放时只保留当前点为新基准点,历史轨迹清空重来,适合简单界面。GUI导航场景下我一般选前者,因为轨迹点本来就不多,重新算一遍也就几十次乘加运算。
4. GPS导航实战避坑:冷启动、丢星与坐标漂移
4.1 现象:室内也有坐标输出,但位置一动不动
很多模块放在室内,串口数据里状态位是V(无效),但偶尔还会吐出一组带坐标的数据。这是在输出上次定位的缓存值,不是实时位置。
原因:GPS定位需要接收卫星信号,室内或窗边信号被建筑遮挡,模块拿不到足够卫星数,就退回输出“最后有效定位”兼容下游设备。下游如果不检查状态位,就会把这个过期坐标当实时数据显示。
解决:解析时强制判断字段2是否为A,不是A就不更新界面和轨迹。同时把定位状态用颜色区分,有效时显示正常数据,无效时显示灰色或显示“搜索中”占位符,避免用户误以为设备坏了。这个资源里专门加了一个状态指示灯,有效时为绿色,无效时为红色,调试时一眼看清模块是否真的拿到定位。
4.2 现象:刚上电时定位要等好几分钟,以为模块坏了
冷启动第一次搜星要下载星历,时间可能长达3到5分钟,甚至更久。很多开发者上电后盯着串口看,几十秒没数据就以为模块坏了,其实这是正常的。
原因:GPS模块在没有星历、没有实时钟的情况下,需要在一个频率范围内盲扫卫星信号,逐个卫星匹配码序列,这个过程极度耗时。断电重启后模块内部的RTC和星历缓存清空,回到冷启动状态。
解决:把模块的备份电源引脚接上3V纽扣电池,或者用一个超级电容供电,这样断电后星历和RTC能保持。实际测试中,接上备份电源后热启动时间可以压到2秒以内,冷启动只剩首次上电那一次。再有就是上电位置尽量靠窗、远离金属遮挡物,把外接天线放在车顶或窗台,定位速度会快很多。
4.3 现象:坐标和真实位置差了几十米,轨迹和其他设备对不上
手机上显示的位置和你的屏幕标注点对不上,误差在50米左右。这个看起来像“玄学”的问题,80%是坐标系没转换。GPS输出WGS84,手机地图用的是GCJ-02,两端坐标系不一致,差几十米是必然的。
原因:正如前面坐标系章节讲的,GCJ-02对WGS84做了非线性偏移。这组偏移是地图厂商为了合规要求做的处理,并不是模块精度问题。模块本身的CEP精度一般在2.5米左右,如果你在地图上看到几十米的误差,先排除坐标系问题,再去排查天线和多径。
解决:把3.2节的wgs84_to_gcj02在解析后调用一次,然后把纠偏后的坐标往屏幕映射。纠偏完如果还有几米误差,那是正常的——GPS本身就有2-5米的基础噪声。如果差距仍旧在几十米,检查是不是用错了坐标系:某些程序里用的是BD-09,那个偏移更大。调试时可以同时在两个坐标系下打印坐标,对比差异来定位问题。
4.4 现象:静止时屏幕上的光标缓慢漂移,画出一条假轨迹
设备放在桌上不动,屏幕上光标的轨迹却缓慢移动,甚至画出一条长线。这个现象在空旷区域更明显。
原因:GPS定位本身存在多径效应和卫星几何分布变化,静止时模块输出的坐标仍然会在小范围内抖动。实际抖动范围在某些环境下可以到2到10米。1Hz定位频率下,每次抖动都会被画进轨迹里,看起来就是一条不断蔓延的“假轨迹”。
解决:加到站判断逻辑。速度和航向都在阈值以下时,判定为静止状态,不更新轨迹。工程里的做法是加了动态阈值:速度小于0.5节且航向变化小于一个固定角度持续两次采样,就进入静止模式,轨迹只在离开静止模式后重新画。如果模块支持RTK或SBAS,定位噪声还能压到分米级,但消费级模块就别指望这个了。提供给轨迹可视化的数据,要在地图上看到静止状态是“一个点”,而不是“一团麻”。
5. GUI刷新策略:LVGL分层与帧率控制,把界面做流畅
整个工程里界面采用的是轻量级GUI库方案,和裸机画点相比,用现成GUI库管理图层、控件和刷新要省事得多。核心思路是三层分离:地图底图层、轨迹折线层、悬浮光标层。底图是静态图片,轨迹是动态折线,光标是最上层的箭头。这种分层避免了全屏重绘,刷新时各层各刷各的,互不干扰。
// GUI刷新逻辑,只做增量更新,不整屏重绘 void gui_update_position(gps_data_t *gps, track_t *track) { int sx, sy; float lon0 = gps_baseline.longitude; // 开机基准经度 float lat0 = gps_baseline.latitude; // 开机基准纬度 gps_to_screen(gps, lon0, lat0, current_scale, &sx, &sy); // 1. 光标层:只移动光标对象,不触发底图重绘 lv_obj_set_pos(cursor_obj, sx - CURSOR_OFFSET_X, sy - CURSOR_OFFSET_Y); // 2. 轨迹层:追加新点,只重绘折线对象 track_add_point(track, sx, sy); lv_line_set_points(track_line, track->sx, track->sy, track->count); lv_obj_invalidate(track_line); // 3. 文本数据按需刷新,控制频率,别每帧都写字符串 static uint32_t last_update = 0; if (HAL_GetTick() - last_update > 500) { char buf[32]; snprintf(buf, sizeof(buf), "%.6f", gps->latitude); lv_label_set_text(lat_label, buf); last_update = HAL_GetTick(); } }这段代码的关键在参数:CURSOR_OFFSET_X/Y是光标图形的锚点偏移,让箭头尖正好指向定位点;current_scale是缩放比例,由按键事件修改;HAL_GetTick()是毫秒级时基,500毫秒刷新一次文本对导航场景完全够,比每帧刷新省下大量CPU。lv_obj_invalidate只标记轨迹对象为脏区域,GUI库会只重绘包含这部分的最小矩形区域,而不是整屏,这是帧率提升的最大来源。
刷新频率也不是越高越好。1Hz定位下,界面刷新超过1Hz纯属浪费;5Hz定位下,刷新频率设在5到8Hz之间即可。经验参数是:地图缩放和拖动时走20Hz全屏刷新,定位更新时走5Hz增量刷新,文本信息走2Hz刷新。这样CPU负载能压到很低,STM32跑240x320分辨率的LCD还能剩50%以上的余量处理存储和其他外设。
这条刷新流程我从裸机画点改到分层方案之后,最直观的变化是屏幕不再闪烁。裸机方案每次更新都先清屏再重画,屏幕就像老式荧光灯一样闪,而且帧率不稳定。分层方案看起来只是把代码结构换了一下,实际上刷新效率和体验完全不同。从那以后我做任何嵌入式GUI都先画分层图再写代码,强制自己先想清楚哪些层是静态的、哪些是动态的、刷新最小区域是哪一块,想不清楚就不开工。后来接手的几个界面项目,这套分层思路都能直接套用。
整个这套GPS导航工程,从NMEA解析到坐标纠偏再到GUI刷新,属于那种“每一步都不难,串起来就是一道坎”的项目。做之前觉得GPS嘛,串口读数据而已;做完才明白,真正耗时间的不是读数据,而是把数据变成让人看得懂、用得顺的界面。这中间的坑我都替你踩过了,希望这份记录能帮你少走几步弯路,希望帮到你。
本文还有配套的精品资源,点击获取