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 --queryproperty查看设备的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模块主要组成部分有三个串口读取线程持续从串口读数据按行切分并过滤NMEA帧帧解析器负责校验和、提取字段、转换坐标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, timeout1) 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_size10) self.thread threading.Thread(targetself.read_serial_loop, daemonTrue) 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, errorsignore) 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, timeout1) while True: line port.readline() if line: print(line.decode(ascii, errorsignore).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 namegps_driver_node pkggps_driver typegps_node.py outputscreen param nameport value/dev/ttyUSB0/ param namebaudrate value9600/ param nameframe_id valuegps/ param nametopic_name valuefix/ param namerate value5.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查看话题频率正常情况下应该接近你设置的发布频率。比如设置rate5.0rostopic 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内容大概是SUBSYSTEMtty, ATTRS{idVendor}1a86, ATTRS{idProduct}7523, SYMLINKgps_module然后重新加载udev规则sudo udevadm control --reload-rules sudo udevadm trigger这样以后无论插拔多少次设备都稳定地出现在/dev/gps_moduleROS节点里的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。当你能够只看原始帧就判断模块处于什么状态、解析逻辑哪里对不上整个定位系统在你的掌控下就会变得透明且可靠。把第一个版本跑通后再慢慢扩展速度发布、状态诊断、协方差输出这套底盘就算是立住了。
阅读完成 · 觉得有帮助?