
简介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.1173float 在 Arduino Uno 上只有 4 字节有效数字约 7 位而赤道上 0.0001 度对应大约 11 米。当你要存储/-179.999999这种 9 位有效数字时float 的尾数已经不够了最后一位会被截掉或者四舍五入到不可控的方向轨迹就会出现肉眼可见的小锯齿。MicroNMEA 的解法是经纬度统一存在int32_t里单位是「百万分之一度」——也就是度数乘以 1000000。北纬和东经为正南纬和西经为负。这样48.1173度实际存的是48117300有效数字能稳定到小数点后 6 位大约对应 0.1 米的距离远高于 GPS 模块本身 23 米的定位精度。用 int32 还有一层好处在 AVR 这类 8 位单片机上float运算有额外的软件浮点模拟开销而int32_t的加减乘除直接走原生整数指令速度更快、功耗更低。对电池供电的便携设备来说省下几千个时钟周期是有意义的。2.3 与常用方案的选型对比TinyGPS 和手写字符串解析在 Arduino 生态里MicroNMEA 的主要竞争对手是 TinyGPS。两者定位相似但实现思路有明显差异。TinyGPS 同样支持解析 GGA、RMC、GSA、GSVAPI 也是封装好的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 接模块 TXTX 接模块 RX SoftwareSerial gpsSerial(10, 11);但 SoftwareSerial 有一个根深蒂固的毛病它依赖定时器中断模拟串口时序当主循环里出现delay(100)或者其他耗时操作时字节就会丢失。GPS 模块每秒输出约 200500 字节波特率 9600 时每字节约 1.04msSoftwareSerial 在 Uno 上最多能维持约 57600 波特率但只要你延迟超过一个字节的时间就必然丢数据。所以我的习惯是能用硬串口就用硬串口。Mega 板有 4 个硬串口直接把模块接到Serial1的 RX1/TX1 上Uno 则把模块接在Serial的 RX/TX 上用Serial.read()喂数据。注意 Uno 的 Serial 和 USB 共用了同一组串口接模块调试时没法同时用串口监视器这时候可以用Serial1Mega或者换一块带串口交换能力的板子。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.6fgetCourse()是千分之一度除以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 位开始可能会有误差。在 23 米精度的 GPS 场景下这两种做法打印的结果几乎没有差别。但在做高精度静态测量或者 RTK 数据记录时建议用做法一直接把int32_t的微度值存进 EEPROM 或者 SD 卡事后在 PC 上做浮点运算这样原始数据完全不会被单片机的浮点格式污染。此外要注意纬度符号南纬返回负数西经返回负数。很多轨迹记录程序在拼接时无条件写N和E导致南半球的轨迹全部跑到北半球去了这个问题在亚洲地区测试时不容易暴露等设备真的出海或者跑到南半球才会翻车。4.2 GGA 与 RMC 的职责分离状态判断要多看一层NMEA 0183 里有多条句子都携带位置信息GGA 里有经纬度、高度、定位质量、卫星数、HDOPRMC 里有经纬度、速度、航向、日期。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时不要更新轨迹记录同时输出一个「未定位」标志而不是打印 00,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某方案的 GGARMCGSV 配置指令等模块返回确认后再进入主循环。注意这个指令需要在模块串口上以相同的波特率发送而且指令里的校验和需要提前算好用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 日志确认解析器可用再装机省掉了大量在室外调试的时间。这个习惯帮我避开过至少两次因天线虚焊造成的「假死机」问题——回放模式下解析正常说明库和代码没问题问题只可能在硬件环境上。希望帮到你。本文还有配套的精品资源点击获取