简介:一套完整的GPS数据解析C程序源码包,面向嵌入式开发者和单片机爱好者,解决GPS模块NMEA报文解析与12864液晶屏实时显示经纬度的问题。资源共23个文件,以.C源码、.H头文件、.OBJ目标文件和.LST列表文件为主,压缩包约72KB,同时包含HEX烧录文件、UV2工程文件及备份文件,便于直接编译、烧录与二次开发。程序由郭天祥开发,代码规范实用,涵盖UART串口接收、NMEA语句(如GPGGA/GPGLL)解析、逗号分隔字段提取、坐标字符串转浮点以及12864驱动显示等关键步骤,并配有LCD和GPS模块的驱动接口。已有2196人学习下载,适合希望掌握GPS数据解析、串口通信与嵌入式显示技术的开发者参考,可快速迁移到物联网、定位终端、车载导航等实际项目中。
1. GPS数据解析到底在解析什么:NMEA 0183协议速览
先说句实话,很多人第一次拿到GPS模块,以为串口直接吐一个经纬度数字出来。接上USB转串口一看,收到的却是这么一串东西:
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47这是NMEA 0183协议,GPS行业最常用的一种数据输出格式。C程序要做的事,就是把这串文本里逗号分隔的字段拆出来,把经纬度、时间、速度、定位质量这些信息变成一个结构体,交给上层算法或者界面去用。这个项目本身不大,但涉及串口数据流、文本解析、校验算法、边界情况处理,特别适合练手C语言基本功,也适合要在嵌入式设备上接GPS模块的开发者直接抄作业。
1.1 一条NMEA语句是怎么组成的
NMEA 0183的语句都以$开头,以\r\n结尾,中间用逗号分隔。整体可以拆成四块:起始符和地址域、数据字段、校验和、结束符。
地址域前两位表示系统来源,GP是GPS,GL是GLONASS,BD是北斗;后面三位是语句类型,GGA、RMC、GSV这些都算。校验和紧跟在*后面,是两个十六进制字符,内容是$和*之间所有字符按位异或的结果。比如上面那条GGA,校验和就是$后面GPGGA,123519,...到*之前每个字符异或的结果。
为什么要设计成ASCII文本而不是二进制?因为调试方便,一个串口助手就能直接看到数据,而且模块厂商之间兼容性也好。代价是每条语句有大量冗余字符,但对串口波特率不敏感的应用来说完全不是问题,9600波特率下传几条语句绰绰有余。
1.2 实际项目里最常用的几条语句
实际开发中真正高频用到的是GGA、RMC、GSV这几条,关键字段我整理成了一张表:
| 语句 | 主要信息 | 典型输出频率 |
|---|---|---|
| GGA | 定位状态、经纬度、卫星数、HDOP、海拔 | 1Hz~5Hz |
| RMC | 推荐最小数据,含时间、经纬度、速度、航向、日期 | 1Hz |
| GSV | 可见卫星编号、仰角、方位角、信噪比 | 随卫星数量变化 |
| GSA | 参与定位卫星编号、PDOP/HDOP/VDOP、定位模式 | 1Hz |
GGA和RMC是解析器最优先支持的。GGA直接给定位质量和坐标,RMC带有速度和航向,做车载导航或者轨迹记录时基本靠这两条。
具体到字段编号,GGA里的位置大概是这样的:
$GPGGA,hhmmss.ss,ddmm.mmmm,N,dddmm.mmmm,W,定位状态,卫星数,HDOP,海拔,M,... 字段1 字段2 字段3 字段4 字段5 字段6 字段7 字段8 字段9RMC的字段更紧凑,定位状态在第2个字段,经纬度在3到6字段,速度是第7个字段,航向是第8个,日期是第9个。解析之前先把这些编号记牢,写代码的时候就不用来回翻协议文档了。
2. 解析程序的设计思路:先把流程拆成三件大事
写解析器最容易犯的错误是一上来就while(1)里等数据,然后硬啃字符串。我的建议是把整个流程拆成三个阶段:成帧、校验、字段提取。三个阶段各干各的事,出了问题也好定位。
2.1 成帧:串口读到的字节流不是天然分好的
串口驱动每次read返回的数据长度是随机的。可能一次读到半条语句,也可能一次读到好几条黏在一起。所以第一件事不是找逗号,而是先把一条完整的NMEA语句从字节流里切出来。
我习惯用一个简单的状态机,状态迁移是:找$、接收数据、等*、读两个十六进制校验字符、等\r\n。代码写出来不复杂:
typedef enum { IDLE, RECV, SUM, END } frame_state_t; void gps_rx_byte(frame_state_t *st, char c, char *buf, int *len, unsigned char *sum) { switch (*st) { case IDLE: if (c == '$') { *st = RECV; *len = 0; *sum = 0; buf[(*len)++] = c; } break; case RECV: if (c == '*') { *st = SUM; } else { *sum ^= (unsigned char)c; if (*len < MAX_FRAME_LEN - 1) { buf[(*len)++] = c; } else { *st = IDLE; } } break; case SUM: /* 开始读两个十六进制校验字符,这里需要另外保存半字节状态 */ *st = END; break; case END: if (c == '\n') { /* 一帧完整数据,可以交给解析模块 */ buf[*len] = '\0'; } *st = IDLE; break; } }状态机的好处是天然处理半包:一个字节一个字节喂,来多少收多少,跟底层串口一次给多少字节没有任何关系。这个设计是整套程序里最值得花时间的部分。
2.2 校验和必须自己算,不能省
这个环节看起来多余,实际省不得。GPS模块在电磁环境差的时候可能出现误码,尤其是串口线附近有电机驱动器在跑,干扰一上来数据就花了。
校验和算法很简单:从$后面第一个字符到*之前,逐个字符异或,得到一个字节,再跟星号后面的两个十六进制字符比对。逻辑上不复杂,但有一个细节要注意:别用strlen去计算长度。串口缓冲区里可能混入0x00这种不可见字符,虽然正常NMEA是ASCII,但一旦有脏数据,strlen会在中间截断,校验和永远对不上。按实际接收长度循环才是最稳妥的。
2.3 字段切分:尽量别用strtok
NMEA语句的字段分隔符是逗号,但有些字段为空,比如GGA里后面几个字段经常是空的。标准库的strtok会把连续分隔符当成一个处理,还会修改原字符串,在多线程环境或者还需要保留原始帧内容的地方容易出问题。
我自己的做法是写一个按分隔符索引的函数:给定帧字符串和字段编号,返回该字段起始指针和长度。拿到指针和长度后,要么复制到临时缓冲区,要么直接结合strtod、atoi解析。这样源头清晰,也好调试。如果非要用标准库函数,至少记住用strtok_r而不是strtok,而且解析前先复制一份原字符串,别把缓冲区搞得面目全非。
3. 核心代码实现:一个可直接移植的GPS解析器
到这一步,我们已经能把一条完整的NMEA语句从串口流里拎出来了。接下来就是常规的字符串解析。我习惯把解析结果先收拢到一个结构体里,这样上层逻辑和底层协议彻底解耦。
3.1 先定义好输出结构体
typedef struct { int fix_quality; /* 0=无效, 1=GPS单点, 2=差分 */ int satellites; /* 参与定位的卫星数 */ double latitude; /* 十进制度,南纬为负 */ double longitude; /* 十进制度,西经为负 */ double altitude; /* 海拔,单位米 */ double hdop; /* 水平精度因子 */ double speed_knots; /* 速度,节 */ double course; /* 对地航向,度 */ int hour, minute, second; int day, month, year; /* UTC日期 */ } gps_info_t;有人喜欢把原始度分格式存下来,等上层用的时候再换算。我的建议是解析层就把标准单位算好,上层不应该关心NMEA格式细节。不然以后换模块、换协议版本,上层代码全要跟着改,得不偿失。
3.2 GGA解析:经纬度度分转换是重点
GGA的第2、4个字段分别是纬度和经度,格式是ddmm.mmmm和dddmm.mmmm。很多新手直接把atof结果当成十进制度数用,结果坐标偏出去几十公里。转换成十进制度的公式是:整数部分除以100得到度,余下的小数部分是分,最终度数 = 度 + 分 / 60。
static double coord_to_decimal(const char *field, char dir) { double raw = strtod(field, NULL); int deg = (int)(raw / 100.0); double min = raw - deg * 100.0; double dec = deg + min / 60.0; if (dir == 'S' || dir == 'W') { dec = -dec; } return dec; }南纬和西经取负号,北纬东经保持正值。这个符号处理放在解析层,上层拿到的就是带正负号的标准十进制度坐标。
解析GGA时,字段2是纬度数值,字段3是纬度方向N/S,字段4是经度数值,字段5是经度方向E/W,字段6是定位状态。核心逻辑大致是这样:
int gps_parse_gga(const char *frame, gps_info_t *info) { const char *lat = get_field(frame, 2); const char *lat_dir = get_field(frame, 3); const char *lon = get_field(frame, 4); const char *lon_dir = get_field(frame, 5); const char *fix = get_field(frame, 6); if (!lat || !lat_dir || !lon || !lon_dir || !fix) { return -1; } info->fix_quality = atoi(fix); info->latitude = coord_to_decimal(lat, lat_dir[0]); info->longitude = coord_to_decimal(lon, lon_dir[0]); return 0; }解析的时候尽量用strtod而不是sscanf("%f"),因为sscanf遇到空字段的行为在不同平台上有差异;而且经纬度字段可能为空字符串,strtod会返回0.0,配合定位状态标志位就能识别出无效数据。
3.3 RMC解析:速度和时间
RMC语句字段少,解析起来比GGA更简单。定位状态在第2个字段,只有A和V两种取值,A代表有效定位,V代表无效。第7个字段是速度,单位节;第8个字段是航向,单位度;第9个字段是日期,格式ddmmyy。
int gps_parse_rmc(const char *frame, gps_info_t *info) { const char *status = get_field(frame, 2); const char *speed = get_field(frame, 7); const char *course = get_field(frame, 8); if (!status || !speed || !course) { return -1; } if (status[0] == 'A') { info->speed_knots = strtod(speed, NULL); info->course = strtod(course, NULL); } else { info->speed_knots = 0.0; info->course = 0.0; } return 0; }速度单位是节,想转公里每小时就乘1.852,想转米每秒就乘0.514444。时间字段是UTC,如果设备要显示本地时间,得按业务配置时区偏移,别把UTC直接当本地时间显示,否则车载屏幕上会差好几个小时。
4. 实测现场的坑:串口粘包、脏数据与定位无效
代码写完之后,真正开始联调才会碰到一堆文档外的问题。这一节的内容,基本是我在不同项目里反复踩过的坑,每一行都值得记下来。
4.1 半包和粘包同时出现
我实际调试时用USB转串口,一次read下去,缓冲区里塞了八条完整语句,最后一条还缺了一半。这种情况非常常见。状态机一个字节一个字节处理就完全不怕;如果是传统做法一次性收完再按\n找行,遇到一条被拆成两次读的情况就麻烦了。
稳妥的做法是:每次read得到的字节都喂给状态机,帧完整后立刻回调解析函数。另外要特别注意,串口助手软件里能正常显示的语句,不代表程序里就能完整收到。\r\n这个结尾是协议的一部分,很多人在找行尾的时候只找\n,结果把\r残留到了下一条数据前面,折腾半天才发现。
4.2 定位无效时,返回的不是错误而是0
GPS模块刚上电或者在天线遮挡严重的室内,输出的GGA语句定位状态字段是0,经纬度字段可能是空或者0.0。这种帧语法完全正常,校验和也正确,如果解析器不判断状态直接输出坐标,上层画轨迹就会出现一堆(0,0)或者漂移点。
我的做法是:解析成功后先看fix_quality,为0就不更新位置信息,但可以继续统计卫星数。冷启动找星需要时间,有时候要几十秒甚至几分钟,程序里最好有对应的超时提示,不然用户会以为设备坏了。
4.3 常见问题速查表
把平时最容易撞上的问题整理成了一张速查表,排查方向基本都在里面:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 校验和频繁失败 | 波特率错误、串口线干扰、数据里混入非ASCII垃圾 | 确认模块波特率,用串口助手先看原始数据 |
| 经纬度偏差几十公里级 | 将ddmm.mmmm当纯角度解析 | 按 度=整数/100,分=小数,最终=度+分/60 转换 |
| 坐标一直是0,0 | 定位状态为0,未定位 | 天线放窗边,检查fix_quality,等待冷启动完成 |
| 程序偶发崩溃 | strtok破坏原字符串、数组越界 | 改用字段索引+长度,解析前复制原字符串 |
| 本地时间差好几个小时 | UTC时间未加时区偏移 | 按业务配置偏移,或只存UTC由上层处理 |
| 速度明显不对 | 节和公里每小时没换算 | 明确内部单位,在解析层统一换算 |
特别是经纬度转换那条,我在别人代码里见过不止三次同样的错误。这属于典型的"看起来对,实际差得很远"的问题。
5. 这个解析器后续怎么扩展:从解析到定位应用
解析器只解决数据入口问题,真正要做车载导航、无人机、物流追踪,后面还有一堆活。但解析层做得够不够干净,直接决定后面费不费劲。
5.1 把解析层做成独立模块
建议把解析接口设计成输入完整帧、输出结构体:
int gps_parse_frame(const char *frame, gps_info_t *out);这样好处很明显:不用依赖具体串口实现,既能接真实硬件,也能用一个文本文件里的NMEA日志回放来测试和复现问题。没有GPS模块时,可以用GPS信号模拟器生成固定场景的卫星信号,或者直接录一段设备日志,反复喂给解析器做回归。把采集和解析分开,是工程上很推荐的写法。
我还习惯给解析器配上几组典型的测试用例:一条完整GGA、一条带空字段的GGA、一条校验和错误的语句、一条只有半截的语句。每次都跑一遍,防止改其他功能的时候把解析逻辑改坏。
5.2 对接定位算法时的几个思路
解析出干净的经纬度只是开始。如果要做轨迹平滑,可以接卡尔曼滤波;如果要根据多组距离做位置估计,常用的是三边测量算法;如果需要更高精度,可以关注差分数据或者RTK。这些算法需要的数据入口往往就是gps_info_t这种结构体。
另外,现在很多模块输出多星座数据,地址域会变成BD、GL、GA这些,语句类型和字段顺序基本一致,解析逻辑可以复用,只需要把地址域判断改成更通用的形式。这个扩展点在设计结构体的时候就应该留好。
最后分享一点个人体会:我在好几个项目里都写过GPS解析,每次重写的原因不是协议不懂,而是第一版总是把解析和业务逻辑耦合在一起,导致换个模块型号或者加个语句类型就要动一大片代码。现在我的习惯是先做纯解析库,用日志文件做自动化测试,再接到实际设备上,省心很多。如果你也在做类似的东西,建议从状态机成帧和字段索引解析这两块入手,先把这两块写稳,后面的功能基本都是在这个骨架上加肉。
本文还有配套的精品资源,点击获取