☰
ROS下GPS模块接入实战:NMEA解析与NavSatFix发布
2026/10/24 7:08:41 网站建设 项目流程

给移动机器人加室外全局定位,最省事的方案其实就是GPS。但很多朋友在跑通激光雷达、IMU之后,反而被一块小小的GPS模块卡住了——模块插上USB,串口里哗哗往外吐一堆以$GP开头、逗号隔开的字符串,看着像乱码,订阅话题又不知道从哪下手。这篇文章就基于实际项目里的一台串口GPS模块,讲讲怎么用ROS读取它的数据、解析NMEA 0183语句,并发布成标准的sensor_msgs/NavSatFix话题,让后续的轨迹记录、坐标转换、多传感器融合都能直接消费这些消息。内容不依赖某个厂商的私有SDK,适合正准备给小车做室外定位的开发者参考。

1. 先搞懂GPS模块吐出来的数据:NMEA语句拆解与换算

1.1 串口输出长什么样:GGA和RMC语句的结构

GPS模块上电后,会以固定的频率向外输出NMEA 0183协议文本,常见的有$GPGGA、$GPRMC、$GPGSA、$GPGSV等。其中$GPGGA包含了定位时间、经纬度、定位质量、卫星数、海拔高度这些关键信息,$GPRMC则额外带着速度、航向和日期。ROS里标准的GPS消息需要经纬度和海拔,所以大多数驱动都以解析GGA语句为主,RMC通常用来补充速度和航向信息。

一条典型的GGA语句长这样:

$GPGGA,063109.000,3131.1625,N,12004.5702,E,1,08,1.2,24.5,M,-6.5,M,,0000*63

这是一串逗号分隔的文本,看不懂很正常,关键把字段位置记牢就行。我整理了一张表,对应这条语句从左到右的字段:

字段序号内容示例值含义
1UTC时间063109.00006时31分09秒
2纬度3131.1625度分格式,见下文
3纬度方向NN北纬/S南纬
4经度12004.5702度分格式,见下文
5经度方向EE东经/W西经
6定位质量10未定位 1单点定位 2差分定位 4RTK固定解
7卫星数08当前参与定位的卫星数量
8水平精度因子1.2越小定位越准
9海拔高度24.5单位米
10高度单位M米
11大地水准面高度-6.5单位米,部分场景不用
12差分基站ID空无差分时为空
13校验和63前文所有字节的异或结果

看到这里你应该明白了,GPS模块本来就在做协议解析,我们只要读懂它的输出格式,写一个解析器把经纬度、高度等字段提取出来即可。真正容易出错的是经纬度的度分格式,这一步错,后面所有坐标都会偏到离谱。

1.2 度分格式与十进制转换:新手必错的换算

NMEA里的纬度不是常见的十进制度数,而是“度分”格式。比如纬度3131.1625,它表示31度31.1625分,而不是31.311625度。经度12004.5702表示120度04.5702分。转换公式很简单:

十进制度数 = 度 + 分 / 60

以刚才的纬度和经度为例:

纬度 = 31 + 31.1625 / 60 = 31.519375 经度 = 120 + 4.5702 / 60 = 120.07617

换算本身的数学很简单,但坑在三处:第一,纬度必须取前2位作为“度”,经度必须取前3位作为“度”,因为经度范围是0到180,latitude只有0到90;第二,部分模块输出的分钟部分不足四位,比如3131.162,如果直接按固定长度截取会少算;第三,方向字段S和W要把结果取负。我曾经见过有人在转换时用float(parts[2]) / 100.0,把度分当成百分制来算,结果出来的坐标差了十几公里。

一个稳妥的解析函数可以这样写:

def parse_lat_lon(lat_str, lon_str, lat_dir, lon_dir): lat_deg = float(lat_str[:2]) lat_min = float(lat_str[2:]) lat = lat_deg + lat_min / 60.0 if lat_dir == 'S': lat = -lat lon_deg = float(lon_str[:3]) lon_min = float(lon_str[3:]) lon = lon_deg + lon_min / 60.0 if lon_dir == 'W': lon = -lon return lat, lon

在实际工程中,我还会加一个合法性检查:纬度必须在-90到90之间,经度必须在-180到180之间。如果解析结果超出这个范围,这一帧数据直接丢弃。别嫌这一步多余——串口偶尔出现半个包,或者校验没通过时,解析出来的数据就可能是天文数字,直接把它当成定位结果发出去,会让下游的滤波节点崩溃好一阵子。

2. 硬件连接和串口访问:把一串字符从设备节点里读出来

2.1 接线与电平匹配

市面上的串口GPS模块大致分两类:一类是纯UART引脚,需要外接USB转串口芯片;另一类是板载了USB转串口芯片,插上USB线就直接出现在/dev/ttyUSBx。无论哪种,底层都是串口协议,只需要关注三根信号线:TX、RX、GND,再加上电源VCC。

接线时最关键的一点是交叉连接:模块的TX接串口设备的RX,模块的RX接串口设备的TX。很多人第一次接反,结果串口什么都读不到,还以为是模块坏了。如果模块支持3.3V电平,而你的USB转串口芯片是5V电平,最好使用支持电平转换的板卡,避免长期运行出现烧毁风险。我用过的大部分模块都是3.3V TTL电平,搭配常见的USB转TTL串口芯片是没问题的。

另外提醒一句:GPS模块的天线一定不要放在金属屏蔽罩附近,天线正上方最好不要有遮挡。模块上电后首次定位可能需要一两分钟,这段时间串口可能没有任何有效数据,这是正常的,不代表硬件坏了。

2.2 串口权限、波特率与设备号的确认

把模块插到电脑上以后,终端执行:

lsusb ls /dev/ttyUSB*

第一次插上时通常会出现/dev/ttyUSB0。如果没有,检查是不是USB线只是充电线,没有数据线功能。设备节点有了,下一步面临的就是权限问题——Linux默认不允许普通用户直接读写串口设备,如果你直接运行脚本,大概率会碰到Permission denied。

常规做法是把当前用户加入dialout组:

sudo usermod -a -G dialout $USER

退出当前登录重新进入终端,组权限才会生效。如果不想折腾权限,也可以先用sudo chmod 666 /dev/ttyUSB0临时解决,但这不是长久之计,每次插拔设备后权限会重置。

波特率也是个容易踩坑的点。模块默认波特率一般是9600或115200,老模块很多默认9600。判断方法很简单,先用串口工具读一下:

cat /dev/ttyUSB0

终端里出现可读的NMEA文本,说明波特率匹配;如果全是乱码字节,多半是波特率不对;如果什么都没有,可能是接线或电源问题。需要注意的是,如果用cat能正常读到数据,但你的ROS脚本读不到,先检查Python serial库是否被ModemManager之类的系统服务抢先占用了设备,我遇到过这个情况,详见后面排错章节。

2.3 用指令修改输出频率和语句类型

很多GPS模块支持配置指令,常见的PMTK指令集可以控制输出频率、语句类型、NMEA版本等。比如把定位数据输出频率设为1Hz,可以发送:

$PMTK220,1000*2F

设为5Hz则发:

$PMTK220,200*2C

控制哪些语句输出,用$PMTK314指令。比如只输出GGA是:

$PMTK314,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0*29

这些指令可以直接用串口调试助手发送,模块一般会把配置保存在内部FLASH里,断电后依然生效。我的实际做法是先把模块接到电脑上,用串口助手把频率设为5Hz、只保留GGA和RMC语句,然后再接回机器人主控,省得每次开机都去发配置指令。这样做的原因很简单:10Hz的原始输出看起来数据很多,但ROS下游做融合滤波时,GPS多数时候只需要1到5Hz,高频纯属浪费串口带宽和CPU。

3. 核心节点实现:串口解析、校验和与NavSatFix发布

3.1 为什么消息类型选NavSatFix

ROS里表示GPS定位结果的标准消息是sensor_msgs/NavSatFix。它包含经纬度、海拔、定位状态、协方差矩阵,几乎所有和GPS相关的下游工具都能直接接收。如果用自定义消息,后面接robot_localization、记录rosbag、做可视化都会变得别扭。

重点讲一下消息里的几个关键字段:

std_msgs/Header header sensor_msgs/NavSatStatus status int8 status uint16 service float64 latitude float64 longitude float64 altitude float64[9] position_covariance uint8 position_covariance_type

header.stamp是时间戳,header.frame_id是坐标系名称,这个要和后续的TF变换保持一致。status.status表示定位状态,-1表示未定位,0表示普通定位,1表示SBAS增强定位,2表示差分定位。status.service表示使用的卫星系统,GPS是1,GLONASS是2,北斗是4,Galileo是8。如果模块没有输出GSA语句,service可以直接填NavSatStatus.SERVICE_GPS。

position_covariance是3x3协方差矩阵,按行优先排列。普通消费级模块没有直接输出协方差的字段,通常根据定位质量估算。我的做法是:单点定位时把水平协方差设为10.0,差分定位设为1.0,RTK固定解设为0.04,这样下游滤波节点能正确判断GPS数据的可信度。

3.2 完整节点代码与解析逻辑

下面的代码是一个可以直接运行的Python ROS节点,它读取串口数据、解析GGA语句、发布NavSatFix消息。我特意把校验和和经纬度转换单独抽成了函数,方便你复用或修改。

#!/usr/bin/env python3 import serial import rospy from sensor_msgs.msg import NavSatFix, NavSatStatus def nmea_checksum_valid(line): """对'$'与'*'之间的字符做异或校验,返回值是否为十六进制校验和""" if '*' not in line: return False payload, checksum = line.split('*', 1) calc = 0 for ch in payload: calc ^= ord(ch) return calc == int(checksum.strip(), 16) def parse_gga(line): parts = line.split(',') if len(parts) < 15: return None lat_deg = float(parts[2][:2]) lat_min = float(parts[2][2:]) lat = lat_deg + lat_min / 60.0 if parts[3] == 'S': lat = -lat lon_deg = float(parts[4][:3]) lon_min = float(parts[4][3:]) lon = lon_deg + lon_min / 60.0 if parts[5] == 'W': lon = -lon if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0): return None altitude = float(parts[9]) if parts[9] else 0.0 quality = int(parts[6]) if parts[6] else 0 return lat, lon, altitude, quality def quality_to_status(quality): if quality == 1: return NavSatStatus.STATUS_FIX if quality == 2: return NavSatStatus.STATUS_GBAS_FIX if quality == 4: return NavSatStatus.STATUS_GBAS_FIX return NavSatStatus.STATUS_NO_FIX if __name__ == '__main__': rospy.init_node('gps_driver') port = rospy.get_param('~port', '/dev/ttyUSB0') baud = rospy.get_param('~baud', 115200) frame_id = rospy.get_param('~frame_id', 'gps') pub = rospy.Publisher('gps/fix', NavSatFix, queue_size=10) ser = serial.Serial(port, baud, timeout=1) rospy.loginfo('GPS driver started on %s at %d baud', port, baud) while not rospy.is_shutdown(): line = ser.readline().decode('ascii', errors='ignore').strip() if not line.startswith('$GPGGA'): continue if not nmea_checksum_valid(line[1:]): continue parsed = parse_gga(line) if parsed is None: continue lat, lon, altitude, quality = parsed fix = NavSatFix() fix.header.stamp = rospy.Time.now() fix.header.frame_id = frame_id fix.status.status = quality_to_status(quality) fix.status.service = NavSatStatus.SERVICE_GPS fix.latitude = lat fix.longitude = lon fix.altitude = altitude fix.position_covariance_type = NavSatFix.COVARIANCE_TYPE_APPROXIMATED pub.publish(fix)

这段代码的核心逻辑只有三件事:按行读取串口数据,过滤出GGA语句,校验通过后解析并发布。timeout=1的意思是串口读取最多阻塞1秒,防止GPS数据中断时节点卡死。

3.3 时间戳和发布频率的选择

时间戳看起来是小问题,其实很影响下游融合效果。GGA语句第一个字段是UTC时间,比如063109.000,理论上可以解析出来作为header.stamp。但我在实践里更推荐直接用rospy.Time.now(),原因是模块的日期时间靠自身RTC维持,冷启动后如果不做时间同步,误差会越来越大;而机器人主控通常已经通过网络或系统时钟校准过,时间基准更可靠。如果你的系统对时间同步要求很高,比如做激光惯性GPS紧耦合,那需要使用PPS秒脉冲或硬件时间戳方案,这是另一个话题。

发布频率不需要额外控制。模块输出1Hz,节点就自然以1Hz发布;模块输出5Hz,节点就5Hz发布。很多人一开始会手动加一个sleep(0.1),反而破坏了数据的原始节奏。串口读取本身是阻塞的,每读到一行就解析发布,这个循环天生就是按模块输出节奏运行的。

4. 实测排错:我在串口GPS节点上栽过的跟头

4.1 串口被系统服务和权限占用

有很长一段时间,我的节点一启动就报错:

serial.serialutil.SerialException: [Errno 13] Permission denied: '/dev/ttyUSB0'

把用户加入dialout组之后,权限问题解决了。但过了一周又遇到一个更隐蔽的问题:节点启动时正常,运行几分钟后串口突然读不到任何数据,roscore里也没报错。排查半天发现是Linux的ModemManager自动探测到了串口设备,认为它是一个拨号调制解调器,周期性向设备发送AT指令,把GPS模块的数据流打断了。

解决方法是关掉这个服务,或者把设备加入忽略列表:

sudo systemctl stop ModemManager sudo systemctl disable ModemManager

如果是笔记本这种经常插拔串口设备的系统,还可以检查一下是否有其他终端程序还在占用串口。比如之前用来调试的screen或minicom没退干净,也会导致设备被占用,现象就是新的进程打开串口失败。排查这类问题最简单的命令是:

lsof /dev/ttyUSB0

能看到是哪个进程占用了设备,一查一个准。

4.2 数据中断、半包和经纬度瞬间跳变

在室外开阔地测试时,数据流基本稳定,但偶尔会出现某一行读到一半就被截断的情况。NMEA语句以换行符结尾,readline()依赖这个换行符做分割。如果传输过程中丢了一个字节,或者串口缓冲区被冲掉,就可能出现只有半句、缺少校验和字段的行。所以在解析前一定要做两件事:第一,只处理以$GPGGA开头且以*和校验和结尾的行;第二,校验和不验证就丢弃。

另一个常见问题是经纬度瞬间跳变,比如明明停在原地,定位点突然跳出去几十米。这通常发生在卫星数量刚够、几何分布很差的时候。处理办法有两个:一是利用定位质量字段,quality == 0时直接不发布或发布STATUS_NO_FIX;二是在下游加一层简单的滑动平均,但要小心过大的窗口会把真实运动延迟很久。我用的是质量过滤加状态机:连续5帧都是有效定位才切换为“已定位”状态,一旦出现无效帧就暂停发布,这样能避免线路上画出一条长刺。

4.3 室内与高遮挡环境下的“伪定位”

GPS模块在室内的表现很容易让新手上头。我曾经在实验室靠窗位置测试,模块显示能定位,但坐标一直在附近十几米范围内漂移。原因很简单:窗户玻璃和建筑结构反射卫星信号,形成多径效应,模块解算出的位置本身就是“假位置”。

判断是否为伪定位,最直接的办法是看定位质量字段、水平精度因子(HDOP)和卫星数。HDOP小于2且卫星数大于6时,定位结果比较可信;HDOP大于4时,即使状态显示“已定位”,点也会飘得厉害。所以真正要往ROS话题里发数据,应该在解析层就过滤掉低质量的定位结果。

如果需要在大楼遮挡较多的园区里做长期测试,建议把天线放到车顶或更高的支架上。外置有源天线的增益和内置贴片天线差距非常大,这一点直接决定定位稳定性。

5. 发布后的最后一公里:坐标转换、TF与轨迹记录

5.1 从经纬度到平面坐标:UTM和navsat_transform_node

NavSatFix里的经纬度是WGS-84大地坐标系下的位置,而机器人导航里常用的odom坐标系是一个平面直角坐标系。如果直接把经纬度塞进move_base或AMCL,系统是没法用的,因为地图和路径规划都工作在平面坐标下。

常用的做法是把GPS经纬度投影到UTM坐标系。ROS里最省事的工具是robot_localization包提供的navsat_transform_node。它的作用是把gps/fix话题转换成机器人局部坐标系下的位置,同时发布utm->odom的TF。我的配置大致是这样:

navsat_transform: frequency: 10 delay: 3 magnetic_declination_radians: 0.0 yaw_offset: 0.0 zero_altitude: true broadcast_utm_transform: true use_navsat_msg: true

注意,navsat_transform_node需要订阅gps/fix、imu/data和odometry/filtered这三个话题。它会利用IMU提供的航向来判断GPS点在机器人坐标系下的方向,所以IMU的yaw必须可靠。如果IMU没校准,输出的GPS坐标就会围绕真实位置旋转,看起来像整个轨迹被拧了一圈。

如果你暂时不想引入robot_localization,也可以手动调用UTM投影公式做换算,但我不建议自己写,原因很简单:UTM投影包含椭球参数、中央经线、比例因子、偏北补偿等一堆细节,自己写很难一次调对。

5.2 frame_id和静态TF的关系

发布gps/fix时,header.frame_id建议设为gps或gps_link,不要直接写成map或odom。因为map和odom是机器人在局部环境中的坐标系,和GPS所在的全球坐标系是两回事。

要让下游知道GPS天线在机器人身上的安装位置,需要发布一个静态TF变换:

rosrun tf2_ros static_transform_publisher 0 0 0.3 0 0 0 base_link gps

上面的命令表示GPS天线在base_link坐标系下位于z轴向上0.3米处。别看这个0.3米小,天线位置对GPS航向精度是有影响的,尤其是近距离转弯和靠边停的场景,安装偏移会导致轨迹和实际路径错开。

5.3 用rosbag回放GPS轨迹

话题发布之后,最直接的验证方式是录一段rosbag,然后在RViz里看轨迹:

rosbag record -O gps_test.bag /gps/fix /tf /tf_static

回放的时候可以顺便看一下坐标是否落在合理的范围内。我习惯把NavSatFix的经纬度和UTM转换后的位置同时记录下来,因为后者能直接看出机器人的运动轨迹形状,前者则用来做全局位置校验。

如果轨迹里有明显的跳点,回放的时候逐帧检查定位质量字段就能定位问题。这一招在排查“到底是解析问题还是硬件定位问题”时特别好用,因为rosbag里保留了原始数据帧,可以事后重新解析对比。

6. 稳一点的进阶思路:模块选型与多源融合

6.1 选型时优先看接口、RTK支持和天线设计

回到项目初期的选型问题。如果你只是想在院子里测一个粗略的室外导航,普通串口GPS模块够了;但如果要做路径规划里的全局定位,宁可多花一点预算上支持RTK的模块。RTK固定解时的定位精度可以达到厘米级,单点定位只有两三米——这两者的差别在建筑边缘、窄路场景里是致命性的。

另一个容易忽略的点是天线接口。带外置有源天线接口的模块比板载贴片天线模块灵活得多,实际部署时可以把天线放在视野更好的位置。电源方面,有源天线需要模块输出3.3V或5V偏置供电,选型时看清楚模块规格,别把天线供电忘了。

模块的输出格式也值得关注。很多新模块同时输出GPS和北斗的卫星数据,NMEA语句会变成$GNGGA开头,此时解析脚本里就不能只匹配$GPGGA,需要把$GNGGA也加进去,否则节点会一直收不到数据。这是一个很低级但容易踩的坑,我在换模块后花了一下午才找到原因。

6.2 与IMU、轮式里程计融合:GPS当全局约束

单一GPS在机器人定位里的角色,更多是“长期不漂移的全局约束”,而不是高频定位源。GPS更新频率低、在有遮挡时可能完全失效,但它的误差不会像轮式里程计那样随时间累积。因此主流的做法是用robot_localization的EKF把IMU、轮式里程计、GPS放在一起做融合。

在这种框架下,IMU提供高频姿态和角速度,轮式里程计提供高频速度约束,GPS提供低频绝对位置修正。EKF的输出再喂给导航模块。这样即使GPS进入隧道或树荫下短暂失效,局部定位也不会立刻崩掉。我在测试中发现,只要GPS数据质量过滤做得好,融合后的轨迹比单独用任何一路传感器都稳定得多。

对已具备ROS基础但尚未做过传感器融合的开发者,我建议从robot_localization的三路融合开始试,先分别看每路传感器单独滤波的效果,再逐步合入。每次只改一个变量,出了问题容易定位。

6.3 一个小细节:冷启动时间不可忽视

最后补充一个工程细节。GPS模块从断电状态上电到首次输出有效定位,通常需要30秒到几分钟,这个时间叫冷启动时间。如果你在机器人程序启动时就急着等待GPS数据,程序可能会一直卡在初始化状态。

我的处理方式是:节点一启动就发布STATUS_NO_FIX的NavSatFix消息,同时打印提示信息,等收到第一个有效定位再切到正常状态。这样下游节点可以自动处理“还没有GPS”的情况,而不是整个流程被阻塞。实际测试时也别忘了把“上电后等待定位”的时间和模块放在通风、开阔的地方,这两点决定了你调试效率的下限。

从串口里的一行行NMEA文本,到规范的NavSatFix话题,中间看起来只隔了一个解析节点,实际却牵扯到串口访问、校验和、坐标系转换、时间同步、数据质量过滤这一整条链路。把这些细节逐个想清楚,才算真正让GPS走进ROS的定位体系里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询