ESP32 实战教程:接入 GPS,解析 NMEA 并做位置记录器

本文由 AI 协助整理。GPS 首次定位时间、精度和可用卫星数受天空遮挡、天线、天气与模块状态影响,文中不虚构固定精度。位置数据属于敏感信息,发布日志或上传服务器前请先处理隐私。

ESP32 GPS NMEA 数据流

GPS 模块究竟在输出什么

GPS 模块并不会直接把“我现在在某某地方”塞给 ESP32。它通常通过 UART 连续输出 NMEA 语句,例如 $GNRMC、$GNGGA。这些语句包含定位状态、时间、经纬度、速度、航向和卫星信息。

ESP32 的工作分为三层:UART 接收字节,NMEA 库解析语句,业务代码只使用有效字段。不要在业务代码里用 readStringUntil(',') 到处拆逗号,那会把校验、跨语句和半包问题全塞进一个循环。

硬件与接线

选择一个带 UART 输出的 GPS/GNSS 模块,例如常见的 u-blox 系列或兼容模块。接线:

GPS ESP32
TX GPIO16 / UART2 RX
RX GPIO17 / UART2 TX,可选
GND GND
VCC 按模块规格供电

GPS 模块冷启动时可能需要较长时间获得定位。把天线放到室外开阔处,先用 USB 转串口或 ESP32 串口打印原始语句,确认模块确实在输出,再开始研究经纬度。室内窗边拿不到有效定位,不一定是代码错,卫星也不太愿意穿墙参加会议。

安装解析库

Arduino IDE 可以安装 TinyGPSPlus。它负责从字节流中识别 NMEA 语句,并提供 location.isValid()、speed.kmph()、satellites.value() 等字段。

#include <TinyGPSPlus.h>

TinyGPSPlus gps;
HardwareSerial gpsSerial(2);

constexpr int GPS_RX = 16;
constexpr int GPS_TX = 17;

初始化 UART 与读取字节

void setup() {
  Serial.begin(115200);
  gpsSerial.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);
  Serial.println("GPS logger ready");
}

void loop() {
  while (gpsSerial.available() > 0) {
    char c = static_cast<char>(gpsSerial.read());
    if (gps.encode(c)) {
      handleSentence();
    }
  }
}

如果模块波特率不是 9600,应以配置为准。有些模块默认输出 9600,有些项目被改过 38400 或更高。TX/RX 接线要交叉:模块 TX 进入 ESP32 RX,模块 RX 才接 ESP32 TX。只想读取时可以暂时不接模块 RX。

只使用有效定位

void handleSentence() {
  if (gps.location.isValid()) {
    Serial.printf("lat=%.6f lon=%.6f age=%lu ms\n",
                  gps.location.lat(),
                  gps.location.lng(),
                  static_cast<unsigned long>(gps.location.age()));
  } else {
    Serial.println("Location not valid");
  }

  if (gps.time.isValid()) {
    Serial.printf("UTC %02d:%02d:%02d\n",
                  gps.time.hour(), gps.time.minute(), gps.time.second());
  }
}

isValid() 说明解析器收到了可用字段,age() 反映这条数据距离最近一次更新多久。设备从开阔处移到室内后,最后一个坐标可能仍然存在,但不代表当前位置还在不断更新。把时间和定位状态一起保存,不能只看坐标有没有数字。

记录速度、航向和卫星数

struct PositionSample {
  uint32_t sequence;
  bool valid;
  double latitude;
  double longitude;
  double speedKmph;
  double course;
  uint32_t satellites;
  uint32_t hdop100;
};

uint32_t positionSequence = 0;

PositionSample readPosition() {
  PositionSample sample{};
  sample.sequence = positionSequence++;
  sample.valid = gps.location.isValid();

  if (!sample.valid) return sample;

  sample.latitude = gps.location.lat();
  sample.longitude = gps.location.lng();
  sample.speedKmph = gps.speed.kmph();
  sample.course = gps.course.deg();
  sample.satellites = gps.satellites.value();
  sample.hdop100 = static_cast<uint32_t>(gps.hdop.hdop() * 100.0);
  return sample;
}

实际项目中不要在每条 NMEA 语句到达时写 SD 或上报网络。GPS 可能每秒输出多条语句,业务层应该按固定周期读取“当前最新状态”。否则记录器会变成语句计数器,而不是位置采样器。

校验与异常数据

位置值需要业务范围检查:纬度应在 -90 到 90,经度在 -180 到 180,卫星数量不应是离谱的大数。速度、HDOP 和定位年龄也要与场景结合判断。

bool positionLooksReasonable(const PositionSample& sample) {
  return sample.valid &&
         sample.latitude >= -90.0 && sample.latitude <= 90.0 &&
         sample.longitude >= -180.0 && sample.longitude <= 180.0 &&
         sample.satellites > 0 && sample.hdop100 < 5000;
}

不要把无效定位写成 (0,0),这会让地图上出现一个看似真实的海上点。CSV 中应保留空坐标和 NO_FIX 状态,后续统计才不会把无效记录算进路线。

时间处理:GPS UTC 与本地时间

GPS 时间通常是 UTC。记录文件时要明确使用 UTC 还是北京时间,不要在字符串里写一个没有时区说明的 2026-09-28 08:00:00。

可以保存原始 UTC 字段,展示层再转换到本地时区:

utc_time,latitude,longitude,speed_kmph,satellites,status
2026-09-28T01:00:00Z,31.230400,121.473700,0.0,12,VALID

如果设备还有 RTC,可以在首次有效定位后用 GPS 时间校准 RTC,之后即使暂时没有卫星,也能给传感器记录提供相对连续的时间。校时要限制频率,避免每条语句都修改系统时间。

保存成 CSV

#include <SD.h>

bool appendPosition(const PositionSample& sample) {
  File file = SD.open("/track.csv", FILE_APPEND);
  if (!file) return false;

  size_t written = 0;
  if (positionLooksReasonable(sample)) {
    written = file.printf("%lu,%.6f,%.6f,%.2f,%lu,VALID\n",
                          static_cast<unsigned long>(sample.sequence),
                          sample.latitude, sample.longitude,
                          sample.speedKmph,
                          static_cast<unsigned long>(sample.satellites));
  } else {
    written = file.printf("%lu,,,,,NO_FIX\n",
                          static_cast<unsigned long>(sample.sequence));
  }
  file.flush();
  file.close();
  return written > 0;
}

位置日志涉及隐私,文件名不要直接包含姓名、家庭地址或车牌。上传服务器时使用设备匿名 ID,并按照实际需求设置保存期限。

只在移动或距离变化时记录

如果设备静止放置,按秒记录会产生大量重复坐标。可以设置最小距离或最小时间间隔。初学阶段先用固定时间间隔,等数据格式与定位状态稳定后再加入距离过滤。

距离计算可使用 TinyGPSPlus 提供的工具,或者在服务器端处理。设备端如果需要低功耗,建议减少计算和写卡次数,把原始定位按较低频率保存。

GPS 状态机

一个可维护的程序至少区分:

  • NO_DATA:UART 没有收到有效语句。
  • PARSING:有语句但尚未定位。
  • VALID:坐标和时间有效。
  • STALE:曾经定位,但数据长时间没有更新。
  • ERROR:串口或存储发生异常。

网页、OLED、MQTT 和 CSV 都使用同一状态,不要每个输出模块自己猜“没有坐标代表什么”。状态统一以后,排错日志会比一串孤零零的经纬度更有用。

分阶段验证

  1. 只打印原始 NMEA,确认 UART 有连续输出。
  2. 加 TinyGPSPlus,观察 charsProcessed()、sentencesWithFix()。
  3. 把天线移到开阔处,等待首次定位。
  4. 打印卫星数、HDOP、坐标年龄和速度。
  5. 保存 CSV,检查 UTC、列数和无效行。
  6. 关闭 GPS 或遮挡天线,确认状态变成无定位而不是沿用旧值。
  7. 在移动中记录,检查速度和航向是否合理。

常见问题

现象 优先检查
串口完全没有字符 VCC、GND、TX/RX、波特率
有 NMEA 但一直无定位 天线、天空视野、冷启动时间
坐标跳动很大 卫星数、HDOP、多路径反射、固定天线
速度偶尔很高 无效数据未过滤,或低速航向不稳定
时间差几个小时 UTC 与本地时区混用
记录文件坐标为 0 把无效定位错误写成默认数值

总结

GPS 项目不是“串口读到一串数字”就完成了。要把 NMEA 解析成位置业务,还要处理定位状态、数据年龄、时间区域、无效值、隐私和记录频率。

先确认原始语句,再确认有效定位,最后把结构化状态写入 CSV 或上报网络。这样做出来的位置记录器,才能回答“这条坐标是什么时候、在什么状态下产生的”,而不是把一堆数字堆在文件里等未来的自己猜。

参考资料

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-28 23:43  潇洒石头  阅读(24)  评论(0)    收藏  举报

导航