ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ROS读取GPS模块数据并发布到Topic的完整指南

ROS读取GPS模块数据并发布到Topic的完整指南

1. 项目概述与思路拆解

做机器人或者自动驾驶相关项目时,定位模块基本是绕不开的。GPS虽然室内不可用,但在室外开阔环境下是最便宜、最直接的全局定位来源。这个项目标题“ros读取GPS模块数据,并发布到topic”,想做的事情其实很明确:把GPS模块通过串口连到工控机,然后写一个ROS节点,把模块输出的NMEA语句解析成标准的ROS消息,再发布到指定topic,让导航、路径规划、地图模块能直接订阅使用。

这个需求看似简单,但实际动手时会发现里面藏着不少细节。比如串口设备名可能飘忽不定,比如GPS输出的NMEA格式需要自己解析还是直接调库,比如解析出来的是度分格式还是纯角度格式,再比如很多模块默认出来的经纬度不是标准WGS84下的经纬度,这些都会直接影响下游模块的定位效果。如果你只是想在roslaunch里看到一串字符串topic,那很简单;但如果后续要接导航栈或者可视化成轨迹,就必须把数据做成标准的消息类型。

我这里选用sensor_msgs/NavSatFix作为发布的消息类型,它是ROS标准消息中的一个成员,专门用来表示带有协方差的全球导航卫星系统定位信息,包含经纬度、高度、位置协方差等字段。整个系统的架构大致是:串口读取 -> NMEA帧校验与解析 -> 坐标与状态映射 -> 发布NavSatFix话题。这个思路基本适用于市面上绝大多数串口输出的GPS/GNSS模块,不管是国产的还是国外的,只要输出的是标准NMEA协议,都能用同一套代码逻辑跑通。

2. 硬件连接与配置准备

2.1 接线与电平匹配

大部分GPS模块输出的是TTL电平的串口信号,而工控机或者普通电脑的串口有可能是RS232电平,也可能是USB转TTL接口。直接连,轻则收不到数据,重则烧坏模块。正确做法是:如果模块板上自带USB转串口芯片,直接用USB线连;如果是裸板留出TTL引脚,那需要接一个USB-TTL转换器,GND、TX、RX三根线对应接好,最好再确认一下模块是3.3V还是5V供电,别一上电就冒烟。

我自己实操时比较喜欢在模块和主机之间先加一块串口调试工具验证数据,确认模块本身工作正常后再去写ROS节点。这样做的好处是能快速把“硬件问题”和“软件问题”隔离开。很多新手上来就写代码,最后发现topic里一直没数据,折腾半天才发现是模块供电不足,根本没有输出。先接线、上电、打开串口助手看几秒钟,如果能看到一帧一帧的$GNRMC、$GNGGA之类的字符,那硬件链路就已经通了。

2.2 串口设备与权限确认

Linux下,USB转串口设备一般会映射成/dev/ttyUSB0、/dev/ttyACM0这类名字。如果没有显示,先插拔一次,再用dmesg | tail查看内核日志,一般能看到类似“usb 1-1: new full-speed USB device”和“ch341-uart converter now attached to ttyUSB0”的信息。如果设备名一会儿是ttyUSB0一会儿是ttyUSB1,建议用udevadm info --name=/dev/ttyUSB0 --query=property查看设备的ID_SERIAL,然后写一个udev规则固定成/dev/gps_module,这样后面启动节点就不会因为插入顺序不同而找不到设备。

串口权限是另一个高频坑。默认情况下只有dialout用户组能读写串口。如果当前用户不在这个组里,打开设备时报错[Errno 13] Permission denied。解决办法是把用户加入dialout组,然后重新登录,或者偷懒一点直接chmod 666 /dev/ttyUSB0,不过重启后会失效,建议还是用udev规则顺便把权限也改了。

2.3 波特率与数据帧格式确认

不同GPS模块的默认波特率不一样,常见的有9600、115200,也有模块默认38400或者230400。这个参数不是猜出来的,建议查一下模块手册,或者用串口助手从9600往上逐个试。波特率一旦不对,串口工具里看到的全是乱码或一堆“嚼不烂”的字符。确认能收到正确的NMEA语句后,记住这个波特率,后面写ROS节点时要用。

NMEA语句里最常见的几类帧有:

  • $GNGGA:时间、经纬度、定位质量、卫星数、海拔
  • $GNRMC:推荐的最小定位信息,包含经纬度、速度、航向
  • $GNZDA:日期时间,对同步时间戳很有用

解析时要优先选择GGA和RMC。GGA含海拔和定位质量指示,RMC额外包含速度和航向,但两者经纬度格式相同,都是“度分”格式。

3. NMEA协议解析基础

3.1 度分格式的数学转换

NMEA里的纬度格式一般是ddmm.mmmm,经度是dddmm.mmmm,比如:

3146.50446,N,11709.11740,E

这个值不能直接当作小数角度用。正确转换方式分两步:取前两位(经度取前三位)作为度,剩下的部分除以60得到小数的分,然后加起来。以纬度为例子:

度 = 31 分 = 46.50446 小数度 = 31 + 46.50446 / 60 = 31.7750743

经度同理:

度 = 117 分 = 09.11740 小数度 = 117 + 9.11740 / 60 = 117.1519567

要注意,字符串解析时不要被中间的逗号迷惑。很多模块在经纬度字段后面跟着N/S、E/W标识,N和E是正,对应北纬和东经,S和W是负。如果忽略了这个方向标识,位置会跑到另一个半球,在RVIZ里看起来就是轨迹断裂甚至反着飞。

3.2 帧校验与完整帧识别

NMEA帧以$开头,以回车换行结尾,语句中的有效字符之间以逗号分隔。帧末尾的*后面跟两位十六进制校验值,它是对$和*之间所有字符做异或计算的结果。虽然很多ROS库里会忽略校验和,但实际项目中建议还是校验一下。因为串口传输或者模块本身出问题时,丢帧和错位都会发生,如果不做校验,解析器可能会把半截帧当作有效数据,导致下游导航短时间内跳出一个非常离谱的坐标,这在真实机器人上是很危险的。

校验算法的实现不复杂,Python里可以这样写:

def nmea_checksum(sentence): data = sentence[1:sentence.index('*')] checksum = 0 for char in data: checksum ^= ord(char) return hex(checksum)[2:].upper()

把它和帧尾给出的校验值对比,不一致就直接丢弃。对于数据率要求不高的定位场景,丢几帧无所谓的,稳定最重要。

3.3 经纬度精度与定位状态

GGA语句的第6个字段表示定位质量,常见的是0、1、2、5。0代表无定位,1代表单点定位,2代表差分定位,5代表浮点或固定解算。如果这个值是0,哪怕解析出了经纬度也没有意义。很多新手看到topic里有数据,就以为GPS正常工作了,其实模块还没有定位成功,输出的可能是全0或者上一次残留的位置。所以节点里最好把这个字段映射成NavSatFix的status.status字段,其中0表示未定位,1表示单点定位,2表示差分定位。后面使用方直接通过status.status判断数据是否可用,逻辑就清晰了。

4. ROS节点设计与实现

4.1 节点结构设计

我习惯把整个GPS驱动做成一个小型的Python模块,主要组成部分有三个:

  1. 串口读取线程:持续从串口读数据,按行切分并过滤NMEA帧
  2. 帧解析器:负责校验和、提取字段、转换坐标
  3. ROS发布器:把解析结果填充到NavSatFix消息并发布

当然也可以只写单线程循环,但串口读取时如果遇到阻塞,会直接影响后续数据的发布频率。GPS模块一般输出频率是1Hz到10Hz,不算高,单线程也能跑,但一旦串口底层缓冲因为某些原因积压,Ros话题更新频率就会变得不稳定。所以我更倾向于用单独的线程做串口读取,解析和发布放在主循环里。

4.2 关键代码实现

下面给出一份可直接运行的示例代码,基于rospy,串口读取这块使用了Python标准库的serial。假设你在自己的工作空间里建了一个名称为gps_driver的功能包,并把代码放在scripts/gps_node.py:

#!/usr/bin/env python3 # -*- coding: utf-8 -*- import serial import threading import rospy from sensor_msgs.msg import NavSatFix from nav_msgs.msg import Odometry # 可选,如果你也想发布里程计/位姿 import math class GpsDriver: def __init__(self): self.port = rospy.get_param('~port', '/dev/ttyUSB0') self.baudrate = rospy.get_param('~baudrate', 9600) self.frame_id = rospy.get_param('~frame_id', 'gps') self.topic_name = rospy.get_param('~topic_name', 'fix') self.rate = rospy.get_param('~rate', 5.0) self.lock = threading.Lock() self.latest_fix = None # NavSatFix类型 try: self.serial_port = serial.Serial(self.port, self.baudrate, timeout=1) except serial.SerialException as e: rospy.logfatal('Can not open serial port %s: %s', self.port, e) rospy.signal_shutdown('serial port open failed') return self.pub = rospy.Publisher(self.topic_name, NavSatFix, queue_size=10) self.thread = threading.Thread(target=self.read_serial_loop, daemon=True) def parse_nmea(self, line): line = line.strip() if not line.startswith('$') or '*' not in line: return # 校验和 if not self.verify_checksum(line): return parts = line.split(',') frame_type = parts[0] fix = NavSatFix() fix.header.frame_id = self.frame_id if frame_type.endswith('GGA') and len(parts) > 13: if parts[6] == '0': return fix.status.status = int(parts[6]) # 1: SPS, 2: DGPS fix.latitude = self.dm_to_deg(parts[2], parts[3]) fix.longitude = self.dm_to_deg(parts[4], parts[5]) fix.altitude = float(parts[9]) if parts[9] else 0.0 fix.position_covariance_type = NavSatFix.COVARIANCE_TYPE_DIAGONAL_KNOWN fix.position_covariance[0] = 1.0 fix.position_covariance[4] = 1.0 fix.position_covariance[8] = 2.0 self.latest_fix = fix elif frame_type.endswith('RMC') and len(parts) > 11: if parts[2] != 'A': return fix.status.status = 1 fix.latitude = self.dm_to_deg(parts[3], parts[4]) fix.longitude = self.dm_to_deg(parts[5], parts[6]) fix.altitude = 0.0 # RMC不提供海拔 self.latest_fix = fix def verify_checksum(self, line): try: content = line[1:line.index('*')] checksum = line[line.index('*') + 1:] cksum = 0 for ch in content: cksum ^= ord(ch) return '{:02X}'.format(cksum) == checksum.upper() except Exception: return False def dm_to_deg(self, value_str, direction): if not value_str: return 0.0 value = float(value_str) degrees = int(value // 100) minutes = value - degrees * 100 decimal = degrees + minutes / 60.0 if direction in ('S', 'W'): decimal = -decimal return decimal def read_serial_loop(self): buffer = b'' while not rospy.is_shutdown(): try: data = self.serial_port.readline() if data: line = data.decode('ascii', errors='ignore') with self.lock: self.parse_nmea(line) except serial.SerialException as e: rospy.logerr('Serial read error: %s', e) break except Exception as e: rospy.logwarn('Parse nmea error: %s', e) def spin(self): self.thread.start() r = rospy.Rate(self.rate) while not rospy.is_shutdown(): with self.lock: if self.latest_fix is not None: now = rospy.Time.now() self.latest_fix.header.stamp = now self.pub.publish(self.latest_fix) r.sleep() if __name__ == '__main__': rospy.init_node('gps_driver_node') driver = GpsDriver() rospy.on_shutdown(lambda: driver.serial_port.close()) driver.spin()

这段代码虽然只写了最基本的功能,但已经有几个值得注意的设计点。

第一,串口读取用了独立的线程,readline会阻塞等待完整一行,避免主循环被串口等待卡住。第二,帧解析和消息发布之间加了一个锁,因为latest_fix会被两个线程访问,不保护一下可能出现半更新状态。第三,解析GGA和RMC时都先判断定位状态字段,无效定位直接丢弃,避免把脏数据发出去。第四,NavSatFix消息的协方差字段被设置为对角线固定值,虽然在精准场景下你应该使用模块输出的实时协方差,但在大多数ROS导航栈中,固定值也比不填要好,至少不会让EKF误以为某个自由度完全无约束。

4.3 话题与前缀设计

有时一台机器上会有多个GPS模块,比如做组合导航时前天线一个、后天线一个。这种情况下就不能都用默认的/fix话题,建议通过~topic_name参数显式指定,发布成类似gps_front/fix、gps_rear/fix。同时,frame_id也要区分,比如gps_front_link、gps_rear_link,这样下游在坐标变换时能准确知道每一条定位数据对应的传感器安装位置。如果你要发布速度信息,可以参考RMC中的速度字段(单位是节,需要乘以0.514444换算成m/s),发布到/odom/gps,这个对于车辆导航特别有用。

5. 实操过程与运行调试

5.1 先做串口级验证

写ROS节点之前,我的习惯是先在终端用minicom或者Python的serial工具直接读串口。下面这段最简单的代码能帮你快速确认串口通不通:

import serial port = serial.Serial('/dev/ttyUSB0', 9600, timeout=1) while True: line = port.readline() if line: print(line.decode('ascii', errors='ignore').strip())

当终端开始稳定输出NMEA语句,说明硬件链路没有问题。如果输出的是乱码,大概率是波特率不对;如果完全没有任何输出,就需要检查供电、接线,以及模块有没有真正切换成串口输出模式。一些模块出厂时可能默认走I2C或SPI接口,需要先按模块手册把串口模式打开。

5.2 构建工作空间并运行节点

假设你已经把gps_node.py放到了功能包里,接着在CMakeLists.txt中确保有catkin_install_python或直接用chmod +x保证脚本可执行。然后编译工作空间:

cd ~/catkin_ws catkin_make source devel/setup.bash roslaunch gps_driver start.launch

启动文件start.launch可以这样写:

<launch> <node name="gps_driver_node" pkg="gps_driver" type="gps_node.py" output="screen"> <param name="port" value="/dev/ttyUSB0"/> <param name="baudrate" value="9600"/> <param name="frame_id" value="gps"/> <param name="topic_name" value="fix"/> <param name="rate" value="5.0"/> </node> </launch>

运行时如果一切正常,会看到rospy的日志输出。接着新开终端:

rostopic echo /fix

如果能看到类似这样的输出:

header: stamp: sec: 1733456767 nsec: 0 frame_id: "gps" status: status: 1 service: 0 latitude: 31.7750743333 longitude: 117.1519566667 altitude: 42.6 position_covariance_type: 2 position_covariance: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0]

那说明整个链路已经通了。这个输出里status为1表示单点定位。如果一直在室内或者窗口旁边,status可能长期是0,经纬度也可能是全0,这是正常的,需要把模块放到户外或者靠窗的地方等冷启动完成定位。

5.3 话题可视化与下游验证

rostopic echo能证明数据在流动,但没法直观感受定位连续性和稳定性。建议用rqt_graph看一眼节点和话题之间的关系,确认没有多出来莫名其妙的节点在重复接管同一个串口。也可以启动RVIZ,添加一个By topic的MarkerArray或者直接用NavSatFix显示插件(如果没有插件,也可以发布成visualization_msgs/Marker来做简单的轨迹显示)。

多设备联调时,我一般还会用rostopic hz /fix查看话题频率,正常情况下应该接近你设置的发布频率。比如设置rate=5.0,rostopic hz应该输出约5Hz。如果频率低很多,看一下是不是串口读取线程阻塞了,或者解析器的校验和逻辑处理太慢。实测下来,Python脚本面对10Hz的GPS数据流,解析压力基本可以忽略。

6. 常见问题与排查技巧实录

6.1 串口打开失败与权限问题

错误信息一般是[FATAL] Can not open serial port。先检查设备文件是否存在:

ls -l /dev/ttyUSB0

如果文件不存在,拔插一下USB,或者换个USB口。如果文件存在但提示权限拒绝,执行:

sudo usermod -a -G dialout $USER

然后注销重新登录。这种权限问题在Ubuntu系统上非常典型,特别是那些从树莓派镜像拷贝过来的系统,串口权限经常没配置好。

6.2 有数据但解析不出经纬度

如果你用串口助手能看到NMEA帧,但ROS话题里没有经纬度或者经纬度一直是0,大概率是解析逻辑里没有正确读取GGA的字段索引。比如某些模块输出的是$GNGGA,前面两个字母是GN而不是GP,如果你的解析逻辑只判断GPGGA就会错过。写代码时不要把字符串写死成GPGGA,要养成用frame_type.endswith('GGA')的习惯,这样不管前面是GP、GL还是GN都能兼容。

6.3 经纬度方向搞反导致的异常轨迹

方向标识北纬N、南纬S、东经E、西经W,转换时很容易因为某个解析字段错位导致符号取反。做过一个比较隐蔽的案例:某模块输出的经度是11709.11740,E,但另一个模块同样格式输出的却是11709.11740,W,如果代码里只判断了direction in ('S', 'W')取负,这一句逻辑本身没错,但对方位标识的字段位置没对齐,就会导致整个值变成负数。排查方法是把原始NMEA帧和转换后的经纬度打出来,肉眼对比一条,很快就能发现问题。

6.4 串口数据乱码

乱码的原因无外乎波特率不匹配,或者模块输出电平不稳定。如果你确认波特率和模块手册一致,可以尝试用示波器看看TX引脚是否有正常的脉冲。更常见的场景是模块上电时会输出几条乱码,然后恢复正常的NMEA帧。如果乱码持续不断,检查是不是USB转串口芯片的驱动没有正确安装,特别是某些廉价转换芯片,在Linux下需要手动装驱动。

6.5 时间戳和时区问题

GPS模块输出的UTC时间,和本地系统时间差了好几个小时。如果直接把NMEA里的UTC时间填到header.stamp里,导航栈里时间会出现跳变,严重影响轨迹平滑。推荐做法是用rospy.Time.now()或者time.time()给消息打系统时间戳,因为ROS里的时间本来就是希望传感器消息带上“数据被接收的时刻”,而不是GPS内部固件的时钟。如果你的系统做了PTP或者NTP同步,那用系统时间戳反而更统一。不要试图去转换UTC和本地时间,除非你明确知道自己在做什么。

6.6 地图与GPS坐标系不匹配

NavSatFix里的经纬度默认是WGS84坐标系。如果你的导航栈用的是UTM坐标系或者自己定义的地图坐标系,直接把经纬度丢进去是不行的,需要先调用robot_localization的navsat_transform_node完成经纬度到UTM以及UTM到map系的转换。这里要注意一个常用技巧:navsat_transform_node需要订阅一个里程计来源,而且主要通过RMC里的速度和航向信息推算机器人朝向,所以如果你的GPS驱动只发布NavSatFix、不发布速度信息,那么navsat_transform_node虽然能出“病态”的mapping,但效果很差。实际操作中最好同时把RMC里的速度和航向解析出来,发布成传感器消息给下游。

7. 一些实用小技巧

7.1 用udev固定设备名

多USB设备情况下,串口号会变。创建一个规则文件/etc/udev/rules.d/99-gps.rules,内容大概是:

SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="gps_module"

然后重新加载udev规则:

sudo udevadm control --reload-rules sudo udevadm trigger

这样以后无论插拔多少次,设备都稳定地出现在/dev/gps_module,ROS节点里的port参数也就不用每次改。

7.2 用bag包回放定位数据

调试导航算法时不需要每次都背着GPS模块去户外。可以先录一段距离的bag包:

rosbag record -O gps_run.bag /fix /imu/data /odom /cmd_vel

之后做算法调参时直接用:

rosbag play gps_run.bag --loop

既能复现同样一段轨迹,又不会受真实环境和信号遮挡的干扰。这个方法特别适合在办公室做导航栈参数调试,反复回放同一段数据,把问题定位到算法层而不是传感器层。

7.3 数据漂移对比工具

如果同一时刻有两个定位源需要对比,可以写一个小工具同时订阅/fix和参考定位话题,打印两者的经纬度差值。我用过比较直接的方式:把两个源的经纬度都转成UTM坐标,然后计算平面距离。差几厘米说明GPS状态很好,差几十米大概率是某个信号源在城市峡谷或者高架桥下产生了多径效应。这个对比工具不用太复杂,但能帮你判断到底该信任哪个传感器,尤其是后期做传感器融合时非常有用。

7.4 预留日志开关

在实际项目中,解析后的经纬度、状态、卫星数这些信息虽然调试时很想看,但一直打印会拖慢频率并且淹没别的日志。我习惯在节点里加一个~debug参数,默认False,只有调试时打开才会打印每个解析出来的坐标。正式运行时用rospy.logdebug级别,这样通过rqt_logger_level可以瞬时调整打印级别,不需要改代码重启节点。

7.5 善用rosservice做诊断

给GPS驱动节点加一个简单的std_srvs/Trigger服务,服务内部检查串口状态、最近一次有效解析时间、定位状态,然后返回诊断字符串。这样在机器人运行中,运维人员只需要调用一次服务就能知道GPS模块是否还活着。实测下来,这个功能对快速排查现场问题非常有帮助,尤其是室外机器人在运行途中突然定位丢失的时候,用服务拿出来的诊断信息比猜要高效得多。

8. 收尾:一些踩坑后的体会

这个项目本身不算复杂,但它让我意识到一个道理:串口类传感器接入ROS,难点往往不在ROS,而在底层数据链路和坐标语义的理解。你可能花在排查串口权限、波特率、NMEA字段错位上的时间,会比写ROS发布逻辑的时间多几倍,这是很正常的事。如果你只是想尽快看到坐标跳出来,直接用现成的驱动包当然可以;但如果你想把GPS数据真正用进导航、融合、路径规划里,那该做的校验、状态判断、坐标转换、时间戳处理一个都省不掉。

另外,我特别想建议你养成看原始NMEA帧的习惯。不要总是依赖封装好的驱动,因为封装层出问题时,你最终还是要回到串口助手去看那一行一行的$GNGGA、$GNRMC。当你能够只看原始帧就判断模块处于什么状态、解析逻辑哪里对不上,整个定位系统在你的掌控下就会变得透明且可靠。把第一个版本跑通后,再慢慢扩展速度发布、状态诊断、协方差输出,这套底盘就算是立住了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进