ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

NMEA-0183协议与GPS模块经纬度解析实战

NMEA-0183协议与GPS模块经纬度解析实战 拿到一块GPS模块上电接串口终端里立刻吐出一串以$GPRMC、$GPGGA开头的字符——这就是NMEA-0183协议在跟你说话了。很多新手卡在“把GPS模块接到开发板”这一步往往不是硬件连错而是看不懂这一串文本里经纬度到底藏在哪个位置。这篇文章就把NMEA-0183协议从头到尾捋一遍它是什么、为什么GPS模块都用它、怎么从一串字符里把经纬度抠出来变成真正的坐标以及我在实际调试中踩过的各种坑。适合刚接触GPS模块的嵌入式开发者、做物联网定位项目的同学以及想自己写解析代码但不想看冗长协议文档的人。1. 协议本身并不复杂先搞懂它在解决什么问题1.1 为什么GPS模块输出的字符串会长这样GPS模块本质上是一个接收机它从卫星信号里算出自身位置后需要把位置信息告诉外面的单片机或电脑。问题是全世界有那么多家GPS芯片厂商如果每家都用自己的格式输出数据那下游开发者就得为每个型号写一套解析代码日子就没法过了。NMEA-0183就是在这种情况下被广泛采用的一种标准化输出格式。NMEA-0183由美国国家海洋电子协会制定最早是给船舶电子设备之间通信用的后来GPS接收机几乎都把这种格式当成了默认输出。它的特点很朴素纯文本、按行输出、以$开头、以回车换行结束。这个设计在当年是为了方便用串口直接看在今天看来反而成了最大的优点——你不需要任何专业工具用一根USB转串口线接到电脑打开串口助手就能看到GPS模块在“说话”。值得一提的是NMEA-0183并不是GPS专用协议。航海领域里的测深仪、罗经、风速风向仪也用它语句类型靠句子开头的标识符区分。GPS模块常用的只是其中一小部分比如GGA定位数据、RMC推荐最小定位信息、GSV可见卫星、GSA精度因子和卫星编号。明白这一点后再看那些以$GP开头的句子就不会觉得它们是加密信息了。1.2 最常见的五种语句分别是什么为了快速建立印象我把GPS模块最常输出的几种语句整理成了表格并标注了它们各自的核心用途。实际开发中大部分场景只需要GGA或RMC中的任意一种就能拿到经纬度但了解全部类型有助于排查问题。语句标识全称含义核心内容我的使用场景$GPGGAGlobal Positioning System Fix Data经纬度、定位质量、卫星数、海拔需要海拔和定位状态时首选$GPRMCRecommended Minimum Specific GNSS Data经纬度、速度、航向、日期、时间做导航、记录轨迹时最常用$GPGSVGNSS Satellites in View可见卫星编号、仰角、方位角、信噪比判断天线接收环境是否良好$GPGSAGNSS DOP and Active Satellites定位模式、使用的卫星编号、DOP值配合GGA分析定位精度$GPVTGCourse Over Ground and Ground Speed对地航向、对地速度做高速运动载体时参考我早期调试时犯过一个低级错误——只盯着GGA看忽略RMC。后来发现某些模块在弱信号环境下GGA语句里的高度字段可能为空但RMC的经纬度还是能正常输出。所以现在我的习惯是解析代码同时处理这两类语句谁有效用谁。这也是很多商业GPS解析库的常见策略因为它能显著提高数据可用率。2. 经纬度解析的关键门槛度分格式到底怎么算2.1 为什么GPS不用更直观的小数度新手第一次看到4807.038,N这种纬度数据时大概率会懵这到底是48.07038度还是4807.038分答案都不是。这是NMEA协议里的经典格式——度分格式写作ddmm.mmmm意思是“度 分”其中分部分保留了4位小数。度分格式在航海和航空领域非常常见因为1度等于60分用度分表示可以把精度做到大约1.85米1分对应1海里。GPS协议设计于上世纪80年代自然延续了航海界的习惯。对我们开发者来说只需要记住一句话小数点前面的数字既是度也是分前两位经度前三位是度剩下的部分除以60才是度的小数部分。这个格式还容易引出另一个坑如果直接用float(4807.038)当成小数度去画地图坐标会偏到几千里外而且定位结果看起来还“挺稳定”。我见过不止一个新手项目因为这个原因在自家院子里测出了“跨越大西洋”的轨迹。2.2 从ddmm.mmmm到小数度的换算公式换算其实一句话就能说清楚度 整数部分的前两位纬度/ 前三位经度分 整数部分剩下的数 小数部分最终度 度 分 / 60。拿4807.038,N举例前两位48是度剩下的07.038是分换算成小数度48 07.038 / 60 48 0.1173 48.1173再拿经度01131.000,E举例前三位011是度剩下的31.000是分换算成小数度11 31.000 / 60 11 0.51667 11.51667代码实现也很简单但要注意先处理字符串而不是浮点数否则很容易丢失前导零。比如01131.000如果用float转会变成1131.0再按前三位切分就全乱了。正确做法是先把字符串按.拆开整数部分用字符串切片小数部分再转浮点数。def dm_to_dd(dm_str, deg_digits): # deg_digits: 纬度传2经度传3 parts dm_str.split(.) int_part parts[0] deg int(int_part[:deg_digits]) minutes float(int_part[deg_digits:] . parts[1]) return deg minutes / 60.02.3 N/S、E/W和正负号的关系度分换算只是第一步更阴险的是正负号问题。NMEA语句里的纬度和经度是分开两个字段输出的纬度和N或S一起出现经度和E或W一起出现。比如4807.038,N表示北纬48.1173度如果是4807.038,S那就得取负值即-48.1173度。这个设计本身没毛病坏就坏在很多解析代码只做了转换忘了处理方向字符。常见的表现是在北半球和东经区域一切正常一旦设备被带到南半球或西经区域坐标就突然跳到错误位置。更隐蔽的是如果项目里有人“好心”把字符串里的N和E写死成默认值那在测试阶段根本发现不了等设备真的出国了才出问题。我给自己的代码定了三条规矩供参考解析时永远把方向和数值一起读出来不要默认N、E纬度S取负经度W取负在返回结果里统一用带符号小数度表示输出到地图或日志前用断言检查范围纬度-90到90经度-180到180。这套组合拳帮我少踩了很多坑。2.4 GGA和RMC里还有哪些值得提取的信息除了经纬度这两条语句还藏着一些免费的好东西。以GGA为例完整语句是$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47按逗号切分后字段第7个从0开始数是下标6是定位质量标识0代表无效、1代表GPS定位、2代表差分定位、4和5代表RTK固定解和浮点解。如果这个值是0说明模块当前根本没有可用定位就算你解析出了经纬度也是废数据。所以我在判断“定位是否有效”时从不只看有没有数据而是强制检查这个质量标识。再说RMC的完整语句$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A第2个字段下标2是状态标识A代表有效V代表接收机警告通常意味着定位不可靠。第7个字段下标7是地速单位是节乘以1.852就是公里每小时第8个字段是对地航向单位是度。我之前做车辆轨迹记录时就是直接从RMC里拿速度省掉了单独的测速模块。但要注意那条语句的日期字段是ddmmyy格式只有两位年份跨百年后需要自己判断世纪。3. 从串口到坐标完整解析流程实操3.1 硬件连接与串口参数设置在写解析代码之前先把硬件链路打通。GPS模块和开发板之间的连接大多数情况下就是4根线VCC、GND、TX、RX。需要注意的是很多GPS模块的串口电平是3.3V如果你用的是5V单片机最好确认一下模块是否支持5V容忍多数支持但不是全部。我踩过最惨的一次是直接把5V接到了某个老模块的TX引脚结果模块烧了教训深刻。模块接好后串口参数基本是固定的波特率96008位数据位无校验1位停止位简称8N1。少数高性能模块默认波特率可能更高比如NEO-M8N默认是9600但有些国产模块出厂默认38400保险起见先看模块手册。用Python的话通过pyserial库打开串口就够import serial ser serial.Serial( port/dev/ttyUSB0, baudrate9600, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout1 )我习惯在写完打开串口后先不做任何解析而是让数据纯输出30秒肉眼确认一下每一条语句是否完整、是否稳定。这样做不是为了仪式感而是能快速发现两个问题一是波特率不对会直接产生乱码二是GPS天线没接好时屏幕上可能刷屏$GPGGA,,,,,,0,,,,,,,,*66之类的空数据。先看原始数据再写解析能省去折腾代码的时间。3.2 按行、按逗号两步切分NMEA语句的结构是“行”每行独立成一条句子。串口源源不断地吐数据时最常见的方式是每次读取一行然后再对这一行做逗号切分。这看起来很简单但有两个细节值得注意。第一个细节行尾可能有\r\n也可能只有\n。不同GPS模块固件、不同串口调试工具甚至同一模块在不同波特率下都可能表现出差异。我处理时统一用.strip()清掉首尾空白然后再判断是否以$开头。第二个细节如果某条语句中间有字段为空行内会出现连续两个逗号比如545.4,M,,*47数据库中那个“差分站ID”字段是空的。用.split(,)切分时这个空字段会保留为空字符串字段数量不会改变这对按下标取数据非常友好。相反的如果你图省事用正则表达式或固定长度去解遇到空字段很容易错位。所以我的建议是解析NMEA只用逗号切分然后用下标取字段不要用固定位置。def parse_line(line): line line.strip() if not line.startswith($): return None parts line.split(,) if parts[0] $GPGGA: return parse_gga(parts) elif parts[0] $GPRMC: return parse_rmc(parts) return None3.3 校验和验证网上大半代码都漏掉的一步NMEA-0183协议规定每条语句在*后面跟一个两位十六进制校验和。校验方法从$后面第一个字符开始到*之前所有字符的ASCII码依次异或结果转成两位大写十六进制。例如提取$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,中$和*之间的内容逐字符取ASCII码异或得到的值应该等于*47后面那两位47。很多开源代码在解析时根本不做校验这对“玩具项目”可能没问题但在实际设备上会暴露出隐患。我遇到过一种很诡异的现场GPS模块的TX线和另一根信号线靠得太近串口数据偶尔被干扰某一行的$GPRMC语句的日期字段多了一位解析出来的坐标在几个位置之间乱跳。如果不做校验这种脏数据就会无声无息地混进数据库。校验代码很简单def checksum_valid(line): if * not in line: return False body, _, expected line.partition(*) calc 0 for ch in body[1:]: # 跳过$号 calc ^ ord(ch) return calc int(expected, 16)我在项目里的做法是解析前先调checksum_valid不过校验的直接丢弃。就算偶尔丢几帧也没关系GPS数据是连续的丢了这一秒还有下一秒但脏数据一旦入库排查成本要高得多。3.4 Python完整解析代码可直接抄下面给出一段可以直接运行的完整示例。它的逻辑是读串口一行先过校验和再按语句类型分别解析GGA和RMC最后把经纬度统一成带符号的小数度输出。import serial def dm_to_dd(dm_str, deg_digits): parts dm_str.split(.) int_part parts[0] deg int(int_part[:deg_digits]) minutes float(int_part[deg_digits:] . parts[1]) return deg minutes / 60.0 def parse_gga(parts): if len(parts) 7: return None quality parts[6] if quality 0 or not parts[2] or not parts[4]: return None lat dm_to_dd(parts[2], 2) if parts[3] S: lat -lat lon dm_to_dd(parts[4], 3) if parts[5] W: lon -lon alt float(parts[9]) if parts[9] else None return {lat: lat, lon: lon, alt: alt, quality: int(quality)} def parse_rmc(parts): if len(parts) 7: return None if parts[2] ! A or not parts[3] or not parts[5]: return None lat dm_to_dd(parts[3], 2) if parts[4] S: lat -lat lon dm_to_dd(parts[5], 3) if parts[6] W: lon -lon speed float(parts[7]) * 1.852 if parts[7] else None return {lat: lat, lon: lon, speed_kph: speed} def checksum_valid(line): if * not in line: return False body, _, expected line.partition(*) calc 0 for ch in body[1:]: calc ^ ord(ch) return calc int(expected, 16) ser serial.Serial(/dev/ttyUSB0, 9600, timeout1) while True: line ser.readline().decode(ascii, errorsignore).strip() if not line.startswith($): continue if not checksum_valid(line): print(checksum error:, line) continue parts line.split(,) if parts[0] $GPGGA: data parse_gga(parts) if data: print(fGGA: lat{data[lat]:.6f}, lon{data[lon]:.6f}, alt{data[alt]}) elif parts[0] $GPRMC: data parse_rmc(parts) if data: print(fRMC: lat{data[lat]:.6f}, lon{data[lon]:.6f}, speed{data[speed_kph]:.1f}km/h)这段代码可以直接跑但前提是硬件已经正确连接。你可以把它当成第一版解析器后续再根据项目需求扩展出定位质量等级、卫星数量、航向角等字段。对这个阶段来说能拿到稳定准确的经纬度已经完成了80%的工作。3.5 拿到坐标后还要注意坐标系问题解析出小数度之后另一个容易踩的坑是坐标系。GPS模块输出的是WGS-84坐标系下的经纬度也就是GPS全球定位系统本身使用的坐标基准。这个坐标本身没有错但如果你打算把它直接叠加到国内的在线地图比如高德、百度上会发现位置有几十到几百米的偏移。原因在于国内常用的电子地图使用GCJ-02俗称“火星坐标系”或百度BD-09坐标系它们和WGS-84之间有一个非线性的偏移转换。我早期做共享单车定位时就因为没有做坐标转换直接把WGS-84坐标上传到地图服务结果所有车辆图标都“漂”到了马路对面甚至一两百米外的街区。后来加了一层坐标转换库比如coord_convert问题立刻消失。需要注意的是如果你做的不是地图展示而是纯粹的轨迹记录或距离计算那么继续使用WGS-84没有任何问题。坐标转换只有在需要和国内地图服务商交互时才需要做。这一点我先说清楚免得有人稀里糊涂地以为所有GPS数据都得转换一遍。4. 实际踩过的坑数据无效、乱码、时间慢8小时4.1 定位状态一直是V或者GGA质量标识为0这是GPS模块调试中最常见的问题。现象是串口有数据在刷但RMC语句第3个字段是VGGA第7个字段是0。新手第一反应通常是折腾代码但实际上问题几乎都在硬件或环境侧。我在室内调试时几乎不会去分析新代码的逻辑因为GPS信号穿透力差室内定位成功率极低。正确做法是把天线放到窗边或室外最好能仰望天空等待30秒到几分钟。还有一个容易忽略的点有些模块需要接上外部天线才有信号板上那个陶瓷天线只在空旷环境下才勉强可用。如果你在室内用板载陶瓷天线测试看到V是正常现象不是模块坏了。判断天线是否在工作可以观察GSV语句里卫星的信噪比SNR字段通常单位是dBHz。信噪比低于30说明信号很弱高于40说明接收质量良好。如果一台设备在室外晴天依然能看到大量低信噪比卫星那就要检查天线馈线是不是松动或者受损了。4.2 输出乱码或者偶尔丢行串口输出乱码十有八九是波特率不匹配。GPS模块的默认波特率并不统一有些是9600有些是38400还有少数模块出厂是115200。我处理过的模块里u-blox系列的常见默认是9600国内一些厂家封装的模块则可能默认38400。遇到乱码时先别急着改解析代码把波特率轮流试一遍往往就能解决。另一个隐蔽问题是丢行表现为数据看起来正常但每秒更新的频率忽高忽低。这通常是串口缓冲区溢出或者读取线程处理不及时。Python的serial库默认超时机制配合单线程解析在低波特率下够用但如果你的程序里同时有网络请求、数据库写入等耗时操作建议把串口读取放到独立线程并把解析结果放到队列里否则很容易丢数据。我在实际项目里还遇到过一种奇怪的情况程序在Windows上一切正常部署到Linux后却经常丢行。排查后发现是Linux下串口设备的timeout行为和Windows不同导致readline()偶尔读到半行。解决办法是读取时不依赖readline而是按照\r\n或\n自行做缓冲和切行。4.3 时间怎么差了8小时NMEA语句里的UTC时间本身没有错但它是一种世界协调时间国内用户看到它时往往已经习惯性地换算成北京时间。换算规则很简单北京时间等于UTC加8小时。只不过这个加8小时的操作容易在日期边界上出问题。比如UTC时间是2024-06-01 20:30:00加上8小时是北京时间2024-06-02 04:30:00。如果用简单的字符串拼接或时间戳加法日期没有跟着变就会产生“昨天”的错误记录。我处理轨迹数据时吃过这个亏后来统一改用带时区的datetime对象做计算代码瞬间清爽了。另一个小坑是RMC语句的日期字段本身只有两位年份比如240601代表2024年6月1日。如果程序不做世纪推断到2100年又会出错。这个大概率等不到那天但代码里加一个简单的规则小于70按20xx大于等于70按19xx也不亏。4.4 常见问题速查表现象可能原因排查建议串口无数据TX/RX接反、未共地、波特率错误检查接线用串口助手轮换波特率输出乱码波特率不匹配尝试9600/38400/115200RMC状态为V室内信号弱、天线没接放窗边、外接天线、检查GSV信噪比GGA质量标识为0卫星数不足、未完成定位等待冷启动完成查看可见卫星数坐标偏移很大度分格式按小数度解析用ddmm.mmmm换算公式重新解析坐标在国内地图上偏移WGS-84和GCJ-02/BD-09不一致按地图服务商要求做坐标转换时间慢8小时UTC未转北京时间UTC8注意日期进位偶尔丢行串口缓冲溢出、读取阻塞独立线程读串口用队列传递数据室内能收到星但定位失败多路径效应、信号反射挪动天线位置远离金属反射面这个速查表是我在多个项目里沉淀出来的基本覆盖了GPS模块调试的前期问题。如果你遇到表格以外的现象建议先保存一份原始串口日志然后用“回放日志”的方式排查比直接盯屏幕抓数据要高效得多。最后分享一个我自己的调试习惯写解析代码时把每一行原始NMEA语句原样存进日志文件哪怕是调试通过之后也不删。这个习惯帮我解决过不少“客户现场数据异常”的问题——只要把原始日志拿回来回放就能在电脑上完全复现现场环境。你也可以把回放脚本当成一个小工具输入日志文件输出解析结果不用每次都用真机测试。这算是GPS模块开发里容易被忽略、但非常值得长期投入的一个做法。
返回列表