简介:MicroNMEA是一款面向Arduino平台的轻量级NMEA语句解析库,专为需要接入GPS、GLONASS等GNSS模块的嵌入式开发者设计,可在内存受限环境中高效提取经纬度、时间、速度、航向等定位信息。资源包共24个文件,以C++头文件与实现文件为核心,附带了可直接运行的示例程序、说明文档、构建配置以及Python测试脚本,整体仅37KB,结构紧凑且按源码、示例、测试与文档分区组织,方便快速集成到Arduino IDE中。库的API简洁实用,支持GPGGA、GPRMC、GPVTG等常见NMEA语句,内置校验和验证与完整语句判定机制,能有效应对串口数据丢失或乱序问题;除GPS外也兼容GLONASS,适用于定位导航、航海航空、轨迹跟踪等场景。目前已有410人学习使用,适合入门到中级的Arduino开发者作为解析GNSS数据的可靠工具。
1. 为什么每个GPS项目都需要一个NMEA解析库
拿到GPS模块先跑通串口日志,能看到一串以$开关的字符串,第一反应可能是「这不就是文本吗,自己切一下逗号不就行了」——直到发现纬度格式是ddmm.mmm、经度是dddmm.mmmm,高度是椭球高而不是海拔,校验和要按字节异或,定位状态还分A和V两种,你才明白这些看似普通的字符里全是坑。MicroNMEA 就是一个帮你把这些脏活全包掉的 Arduino 库:只喂给它一个字节一个字节的串口数据,它就把经纬度、速度、航向、时间、卫星数、定位状态全部解析好,存在内存占用很小的结构体里。适合做 GPS 轨迹记录、车载定位终端、手持设备或者任何不想自己折腾 NMEA 协议的开发者,也适合想搞懂 NMEA 语句本身的人拿它当对照实现来读。
2. 从协议到数据模型:MicroNMEA 为什么敢说精简
2.1 NMEA 0183 语句结构:把一条 GGA 拆到不能再拆
先看一条最常见的$GPGGA语句:
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47拆开看字段:$GPGGA是句子类型,123519是 UTC 时间(12时35分19秒),4807.038,N是纬度(度分格式,北纬),01131.000,E是经度,1是定位质量(1 表示 GPS 定位),08是卫星数,0.9是水平精度因子,545.4,M是椭球高(注意不是海拔),最后*47是校验和。
微控制器要解析这个,至少要做四件事:按行分割(以$开头、以\r\n结尾)、校验和验证、字段切分、度分转十进制。再加上$GPRMC、$GPGSA、$GPGSV、$GLGSV这些句子,每种句子结构还不一样,自己解析的工作量比想象中大得多。而且 GPS 模块每秒输出多条句子,来一个处理一个,解析代码如果写得不够紧凑,会挤占本来就不宽裕的 RAM。
MicroNMEA 的定位就是只做解析,不做其他事。它不关心你的 GPS 模块是什么型号,不负责输出格式化,也不维护定位历史。你给它字符,它拆出结构化数据,就完了。正是这种克制让这个库在只有 2KB RAM 的老爷机级别的板子上也能跑。
2.2 数据存储:为什么经纬度要用 int32 微度而不是 float
很多自写的解析器会把经纬度存成float,这是最容易翻车的地方。NMEA 语句里的经纬度是度分格式,转换成十进制后,比如4807.038度分转出来是48.1173,float 在 Arduino Uno 上只有 4 字节,有效数字约 7 位,而赤道上 0.0001 度对应大约 11 米。当你要存储+/-179.999999这种 9 位有效数字时,float 的尾数已经不够了,最后一位会被截掉或者四舍五入到不可控的方向,轨迹就会出现肉眼可见的小锯齿。
MicroNMEA 的解法是:经纬度统一存在int32_t里,单位是「百万分之一度」——也就是度数乘以 1000000。北纬和东经为正,南纬和西经为负。这样48.1173度实际存的是48117300,有效数字能稳定到小数点后 6 位,大约对应 0.1 米的距离,远高于 GPS 模块本身 2~3 米的定位精度。
用 int32 还有一层好处:在 AVR 这类 8 位单片机上,float运算有额外的软件浮点模拟开销,而int32_t的加减乘除直接走原生整数指令,速度更快、功耗更低。对电池供电的便携设备来说,省下几千个时钟周期是有意义的。
2.3 与常用方案的选型对比:TinyGPS++ 和手写字符串解析
在 Arduino 生态里,MicroNMEA 的主要竞争对手是 TinyGPS++。两者定位相似,但实现思路有明显差异。
TinyGPS++ 同样支持解析 GGA、RMC、GSA、GSV,API 也是封装好的gps.encode(c)往里丢字符,这点两者几乎一致。区别在数据的落脚点:TinyGPS++ 内部用_lat、_lon存储原始度分整数值,向外暴露的location.lat()返回的是经过一个静态浮点转换后的double。在 AVR 平台上double和float一样是 4 字节,打印时再用库自带的浮点转字符串函数转换,这个过程有开发者反馈过会丢失精度,而且打印出来的字符串最长只有约 9 个字符,小数点后的有效位被压缩。也就是说,你用 TinyGPS++ 在 Uno 上打印经纬度,得到的可能是一个被截断到小数点后 5 位左右的数值,做轨迹记录时会有累积误差。
MicroNMEA 则在拿到int32_t的微度值后,推荐你自己用整数运算把它拆成度、分、秒或者直接用float(微度) / 1000000.0f转成浮点打印。因为浮点转换发生在展示阶段,原始数据一直是精确的整数,怎么打印都不会失真。
手写字符串解析则是另一个极端:很多开发者用Serial.readStringUntil('\n')整行读取再strtok切分,代码量不大,但 RAM 被字符串缓冲区吃掉了不少,同时readStringUntil是阻塞式的,遇到 GPS 断流或者干扰字符会导致整个循环卡住。MicroNMEA 的process()每次只吃一个字符,非阻塞,拿不到完整句子就什么都不发生,天然适合在loop()里穿插执行其他任务。
3. Arduino 接入实操:从串口字符到可用的定位数据
3.1 接线与串口选型:SoftwareSerial 能用,但有前提
常见的 GPS/北斗双模模块通过串口输出 NMEA 数据,默认波特率大多是 9600 或 115200。以 Uno 为例,如果模块接到数字引脚 10 和 11 上,用 SoftwareSerial 是最快的接入方式:
#include <MicroNMEA.h> #include <SoftwareSerial.h> // RX 接模块 TX,TX 接模块 RX SoftwareSerial gpsSerial(10, 11);但 SoftwareSerial 有一个根深蒂固的毛病:它依赖定时器中断模拟串口时序,当主循环里出现delay(100)或者其他耗时操作时,字节就会丢失。GPS 模块每秒输出约 200~500 字节,波特率 9600 时每字节约 1.04ms,SoftwareSerial 在 Uno 上最多能维持约 57600 波特率,但只要你延迟超过一个字节的时间,就必然丢数据。所以我的习惯是:能用硬串口就用硬串口。
Mega 板有 4 个硬串口,直接把模块接到Serial1的 RX1/TX1 上,Uno 则把模块接在Serial的 RX/TX 上,用Serial.read()喂数据。注意 Uno 的 Serial 和 USB 共用了同一组串口,接模块调试时没法同时用串口监视器,这时候可以用Serial1(Mega)或者换一块带串口交换能力的板子。
3.2 核心循环:process() 逐字符解析,不要整行读
MicroNMEA 的核心接口是process(char c)方法。每次从串口读到一个字节就喂给它,内部会按行切分、校验、提取字段,一行完整句子处理完之后自动进入下一行:
#include <MicroNMEA.h> char nmeaBuffer[100]; MicroNMEA::NMEA nmea(nmeaBuffer, sizeof(nmeaBuffer)); void setup() { Serial.begin(115200); // 调试串口 Serial1.begin(9600); // GPS 模块串口(Mega 示例) } void loop() { // 每轮循环尽量把当前串口缓冲区的字节全部处理完 while (Serial1.available() > 0) { char c = Serial1.read(); nmea.process(c); } // 只读取有效定位状态的数据 if (nmea.isValid()) { int32_t lat = nmea.getLatitude(); // 单位:微度(度数 * 1e6) int32_t lon = nmea.getLongitude(); int32_t speedMM = nmea.getSpeed(); // 单位:毫米/秒 int32_t course = nmea.getCourse(); // 单位:千分之一度 int32_t alt = nmea.getAltitude(); // 单位:毫米 } }这段代码要注意几个关键点:
nmeaBuffer是库内部用来临时拼接输入行文本的缓冲区,长度要覆盖最长可能出现的句子。GGA 和 GSV 句子都可能超过 80 字节,GPS 模块还会输出一些厂商自己的扩展句子,留到 128 字节更安全。如果缓冲区太小,长句子会被截断,解析结果是不可预知的。
process()是逐字符处理,所以不要用readString()去整行读,也不要在while (Serial1.available())循环里做其他耗时操作,否则会积累延迟。loop()里每轮把缓冲区读空是比较保险的做法。
getSpeed()返回的值是「毫米每秒」,要转换成 km/h 需要除以1000000.0f再乘以3.6f;getCourse()是千分之一度,除以1000.0f就是通常意义的航向角。这些单位在官方 README 里写得很清楚,但新手很容易直接当标准单位打印出来,看到一个几百万的「速度」值吓得以为是 bug。
3.3 完整示例:打印一行格式化位置信息
真正到了项目里,通常还要带上 UTC 时间转换、有效状态判断和显示格式化。下面这个例子直接输出一行 CSV 风格的定位数据:
#include <MicroNMEA.h> char nmeaBuffer[128]; MicroNMEA::NMEA nmea(nmeaBuffer, sizeof(nmeaBuffer)); void setup() { Serial.begin(115200); Serial1.begin(9600); Serial.println(F("lat,lon,speedKmh,courseDeg,altM,fix,sat")); } void loop() { while (Serial1.available() > 0) { nmea.process(Serial1.read()); } if (!nmea.isValid()) { return; // 未定位,直接跳过本轮输出 } // 微度转度的正确姿势:先转 float 再除,避免整数截断 float latDeg = nmea.getLatitude() / 1000000.0f; float lonDeg = nmea.getLongitude() / 1000000.0f; float speedKmh = nmea.getSpeed() / 1000000.0f * 3.6f; float courseDeg = nmea.getCourse() / 1000.0f; float altM = nmea.getAltitude() / 1000.0f; Serial.print(latDeg, 6); Serial.print(','); Serial.print(lonDeg, 6); Serial.print(','); Serial.print(speedKmh, 2); Serial.print(','); Serial.print(courseDeg, 1); Serial.print(','); Serial.print(altM, 1); Serial.print(','); Serial.print(nmea.isValid() ? 1 : 0); Serial.print(','); Serial.println(nmea.getSatellites()); }这里最容易出错的地方是浮点除法:nmea.getLatitude()返回的是int32_t,如果直接写成int32_t / 1000000,整数除法会把小数部分直接截断,结果纬度变成48而不是48.1173。必须先转换成float再除,也就是getLatitude() / 1000000.0f中的.0f不能省。这个坑我见过不止一个开发者踩过,打印出来经纬度永远是整数,还以为是模块坏了。
nmea.isValid()判断的是 RMC 语句里的有效状态A/V。GGA 虽然也有定位质量字段,但 MicroNMEA 内部把「有效性」和「定位质量」分开处理——isValid()表示接收机认为当前数据可用,「定位质量」则要看内部状态字。这一点后面专门展开讲。
4. 用好 Offset 逻辑:坐标换算、多句子协作与自定义扩展
4.1 经纬度微度换算:不只是除以 1e6
前面提到了微度存储,现在把换算细节彻底讲透。MicroNMEA 的getLatitude()和getLongitude()返回值已经经过了度分转十进制的过程,也就是 NMEA 语句里的4807.038,N会被库换算成度格式后乘以 1000000。要打印给人看,有两种做法:
// 做法一:直接输出微度整数的字符串形式(无精度损失) int32_t lat = nmea.getLatitude(); Serial.print(lat / 1000000); // 输出度和百万分之一度的整数混合 Serial.print('.'); Serial.print((lat % 1000000), 6); // 注意这里需要补零 // 做法二:转成 float 再输出(注意浮点尾数截断) float latDeg = lat / 1000000.0f; Serial.print(latDeg, 6);做法一的代码看着繁琐,但能保证你打印出来的数字和模块原始精度完全一致;做法二简单,但 float 在小数第 7 位开始可能会有误差。在 2~3 米精度的 GPS 场景下,这两种做法打印的结果几乎没有差别。但在做高精度静态测量或者 RTK 数据记录时,建议用做法一,直接把int32_t的微度值存进 EEPROM 或者 SD 卡,事后在 PC 上做浮点运算,这样原始数据完全不会被单片机的浮点格式污染。
此外要注意纬度符号:南纬返回负数,西经返回负数。很多轨迹记录程序在拼接时无条件写N和E,导致南半球的轨迹全部跑到北半球去了,这个问题在亚洲地区测试时不容易暴露,等设备真的出海或者跑到南半球才会翻车。
4.2 GGA 与 RMC 的职责分离:状态判断要多看一层
NMEA 0183 里有多条句子都携带位置信息:GGA 里有经纬度、高度、定位质量、卫星数、HDOP;RMC 里有经纬度、速度、航向、日期。MicroNMEA 的设计是:位置和速度最终从最新到达的那条有效句子中提取,句子的到达顺序取决于模块配置,通常 GGA 和 RMC 交替出现。
这里有一个常见的误读:以为isValid()返回 true 就代表定位精确定位质量没问题。实际上 RMC 句子的A/V只表示「导航接收机是否认为定位有效」,而 GGA 的定位质量字段1/2/3才区分 GPS 定位、差分定位或实时动态测量。MicroNMEA 的getFix()返回内部综合状态,但它不暴露 GGA 里的原始定位质量字段。
如果做的是物流追踪这类对定位质量有要求的产品,我的做法是在自定义句子里解析 GGA 的质量字段,或者直接改造库源码,在内部解析 GGA 时多存一个质量值。方法不复杂,找到parseGGA()函数,把第四组字段(定位质量)的字符存成内部成员变量即可。这个改法不破坏原有 API,只是多暴露一个getQuality()。
4.3 自定义 NMEA 句子:SentenceListener 回调的正确用法
MicroNMEA 允许你注册一个监听器,在每收到一条完整句子时回调,这样就能接管那些库本身不认识的句子:
#include <MicroNMEA.h> class MyListener : public MicroNMEA::SentenceListener { public: void sentenceReceived(const char* sentence) override { // 只关心厂商私有的 PMTK 请求确认句子,比如 $PMTK001,314,3*... if (sentence[0] == '$' && strncmp_P(sentence + 1, PMTK确认标记, 4) == 0) { // 自己的解析逻辑 } } }; char nmeaBuffer[128]; MicroNMEA::NMEA nmea(nmeaBuffer, sizeof(nmeaBuffer)); MyListener listener; void setup() { nmea.setSentenceListener(&listener); }注意sentenceReceived里拿到的是完整句子文本(包含$和校验和),你需要自行切分。库本身的parseSentence()内部逻辑和这个回调不冲突,库认识的句子先被提取,不认识的再走回调。这里有个性能隐患:如果你每一个句子都触发回调,每秒几十次回调,回调里如果做字符串扫描或者浮点运算,会拖累主循环速度。正确的姿势是开头只比较句子类型标识,比如判断前三位是不是"GGA"或者"PMTK",不匹配就直接返回。
4.4 GNSS 多系统支持:GLONASS 和北斗的卫星统计
现在的双模或者多模 GPS 模块会同时输出$GPGGA、$GPGSV、$GLGSV、$BDGSV等句子。MicroNMEA 对多系统支持体现在卫星数统计上:它内部会同时统计 GPS、GLONASS、北斗的可见卫星和参与定位卫星数,最终getSatellites()返回的是参与定位的卫星总数,这个数字是多个系统的累加。
GSV 句子每条最多携带 4 颗卫星的信息,一个模块可能连续输出多条 GSV 句子,库内部会有合算逻辑。如果你发现自己模块的卫星数显示只有个位数,而手机同时看到二十几颗卫星,那是正常的——GSV 里的「可见卫星」未必都被接收机选入定位解算。判断芯片能力要从 GGA 的卫星数字段看,不要拿 GSV 的总数当定位卫星数。
5. 避坑指南:MicroNMEA 使用的常见问题与排查记录
5.1 现象:经纬度一直是 0,偶尔跳成一个大数字
原因:GPS 模块刚上电时还没有完成定位,RMC 句子里的状态标志是V(无效),此时经纬度字段是空的,MicroNMEA 解析后内部经纬度保持上一次的数值,如果你上电后第一次读就是空字段,那它就保持 0。还有一种情况是模块天线没有搜到星,一直输出空数据的句子,也是同样表现。
解决:不要读零点就当作错误,而是要判断isValid()。在isValid()为false时不要更新轨迹记录,同时输出一个「未定位」标志而不是打印 0,0,0 的坐标在 GIS 系统里会被当真实位置画到地图中心附近。我的习惯是:连续isValid()超过 10 秒才认为真正定位成功,避免单帧抖动。
5.2 现象:时间一直是初始化值,日期不更新
原因:时间字段是从 RMC 句子提取的,GGA 里虽然也有时间字段,但 MicroNMEA 的getHour()、getMinute()等接口只由 RMC 更新。如果你的模块默认把 RMC 句子关闭了(有些模块为了省功耗默认只输出 GGA),那定位和经纬度都能拿到,但时间永远不变。
解决:初始化时给模块发送配置指令,打开 RMC 输出。常见做法是发一串$PMTK314,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0*2E\r\n(某方案的 GGA+RMC+GSV 配置指令),等模块返回确认后再进入主循环。注意这个指令需要在模块串口上以相同的波特率发送,而且指令里的校验和需要提前算好,用HELP()函数或者串口调试助手验证。
5.3 现象:解析出来的位置偶尔跳几百米,然后自己恢复
原因:SoftwareSerial 丢字节导致 NMEA 语句的校验和错误,MicroNMEA 会丢弃这一句不解析。但库的缓冲区机制是逐行处理,如果丢的字节恰好是下一句开头的$,库会把这半句和后半句拼成错误句子,产生一个残缺字段,进而解析出错误坐标。
解决:优先换硬串口,这是最彻底的方案。如果必须用 SoftwareSerial,一定要保证主循环里没有delay(),并且中断优先级不被其他库抢占。还有一个补救办法是开启setBadChecksumListener(),把校验和错误的句子数统计出来,如果每秒错误比例超过 5%,说明物理链路有问题,需要检查 GPS 模块供电电容和天线接触。校验和错误本身不代表库坏了,而是数据链路不可靠的信号。
5.4 现象:高度显示负数,或者和平常手机看到的海拔差了十几米
原因:GPS 模块输出的 GGA 高度字段是 WGS84 椭球高,而不是海拔。在海洋区域椭球面和大地水准面几乎重合,但在内陆山区差异可以达到几十米甚至上百米。手机显示的海拔通常经过了 EGM96 模型的修正,所以两者对不上是正常的。
解决:如果产品需要显示海拔,要去查当地的大地水准面差距,或者用气压计辅助修正。MicroNMEA 的getAltitude()返回的也是椭球高,单位是毫米,不能直接当海拔用。有人为了省事直接在代码里减去一个固定偏移,这样在固定区域能用,但设备一移动就会出错。
5.5 现象:getSatellites()返回 0,但模块配置工具里能看到卫星
原因:卫星数是跟着 GGA 或 GSA 句子走的,模块如果输出的是「自定义精简 NMEA 子集」(有些模块为了省流量可以配置只输出 GGA 和 RMC,不输出 GSA),那么getSatellites()就无法从 GSA 里拿到参与解算的卫星列表,只能间接依赖 GGA 的卫星数字段。部分固件版本在 GGA 的卫星数字段里填 0,而把真实值放在 GSA 里。
解决:先用串口监视器直接看模块原始输出,确认 GGA 第 8 个字段有没有值。如果确实没有,就用模块的配置软件把输出语句调成完整模式。另外要区分「参与定位的卫星数」和「可见卫星数」,getSatellites()返回的是前者,后者要多条 GSV 句子合成,库并没有直接提供。
6. 进阶用法:用一张离线 NMEA 日志验证整个解析链路
在项目开发阶段,抱着 GPS 模块到处跑不仅费电,而且定位结果不稳定,很难复现问题。更好的办法是录制一段真实的 NMEA 日志,然后在实验室里回放给板子。
具体做法是:找一台已经定位成功的设备,把串口输出的原始 NMEA 语句存成文本文件(只需要 1 分钟的数据量就够测试),然后写一个简单的串口发送脚本,按同样的波特率(比如 9600)逐字符把日志发给板子的串口。板子用 MicroNMEA 解析后,把结果通过 USB 串口回传,和日志文件里的原始值做对比。这样你就能在没有 GPS 信号的环境下验证代码逻辑是否正确。
我一般会在板子上加一个测试模式:上电后如果串口收到TEST指令,则进入日志回放模式,等待外部串口把整个日志文件喂进来,然后持续输出解析结果。下面是这个模式的骨架:
bool testMode = false; void loop() { while (Serial.available() > 0) { char c = Serial.read(); if (c == 'T' && !testMode) { // 收到测试指令,切换回放模式 testMode = true; continue; } if (testMode) { nmea.process(c); // 回放模式下从 USB 串口喂数据 } } if (testMode && nmea.isValid()) { // 打印解析结果,和日志文件逐行对比 Serial.print(F("OK ")); Serial.print(nmea.getLatitude()); Serial.print(' '); Serial.println(nmea.getLongitude()); } }用回放模式还顺带验证了缓冲区是否够用:如果日志里有超长句子(比如某厂商扩展句子),而且解析结果出现断裂,就能立刻发现问题。从那以后我每次拿到新 GPS 模块,都先离线喂一遍录好的 NMEA 日志,确认解析器可用再装机,省掉了大量在室外调试的时间。这个习惯帮我避开过至少两次因天线虚焊造成的「假死机」问题——回放模式下解析正常,说明库和代码没问题,问题只可能在硬件环境上。希望帮到你。
本文还有配套的精品资源,点击获取