1. 为什么需要把RTK模块接入ROS Noetic
做移动机器人、无人车或者高精度测绘的朋友,应该都有过这样的经历:手里拿着一块RTK模块,串口一插,终端里哗哗往外蹦$GPGGA,、$GPRMC这种字符串,一眼看去全是字符,但真正要用的时候,却不知道该拿它怎么办——尤其是在ROS Noetic环境下。
RTK模块的定位结果是厘米级的,但ROS里的机器人导航、建图、路径规划,消费的是navsatfix、sensor_msgs/NavSatFix这样的结构化话题,而不是一串裸文本。所以你要解决的问题其实很明确:把串口上不断输出的NMEA协议数据,解析成ROS能识别、能使用的话题消息,并保证数据可用、时间戳合理、固定解状态可判断。
这篇文章就是围绕这件事展开的。我假设你已经装好了Ubuntu和ROS Noetic,手里有一块支持NMEA输出的RTK模块(不管它是内置板卡还是外接接收机,底层逻辑都一样)。我会从串口怎么认、NMEA每句话讲了什么,讲到自写发布节点的完整代码,再到现成驱动怎么选、怎么避坑。适合正在做移动机器人高精度定位、准备把RTK接入建图导航系统,或者单纯想把模块数据读进ROS的开发者参考。
先说一句总结性的话:这件事本身不算难,但坑特别多,主要集中在串口权限、波特率、NMEA语句解析、固定解状态判断和时间戳处理这几个点上。下面我把每个环节拆开讲清楚。
2. RTK模块和NMEA协议的基础认知
2.1 RTK定位原理对数据解析的影响
RTK(Real-Time Kinematic)定位的原理,简单说就是用基准站的改正数,去消除卫星信号传播过程中的电离层、对流层误差,让流动站拿到厘米级定位结果。但你要注意,RTK模块输出的NMEA语句里,不是每一句都包含高精度坐标。
比如最常用的GGA语句,它有quality indicator这个字段(第6个字段),用来标记当前定位状态。数值是4或5时,表示RTK固定解或浮点解,这时候经纬度才是可用的厘米/分米级结果;数值是1时只是单点定位,只能用单GPS精度;如果是0,说明当前根本没有定位。所以解析数据时,不能拿到经纬度就发出去,必须先判断定位质量。
我做过一个项目,把RTK模块的GGA数据直接当成高精度坐标灌给建图算法,结果发现地图一米多误差,排查了一天,最后才发现是模块在楼宇间经常掉回单点解,但代码里根本没做质量判断,把单点解也当成好数据用。这个教训后面会详细展开。
2.2 NMEA语句的常见类型
NMEA 0183协议是串口通信中的明文协议,目前绝大多数RTK模块都支持输出以下几条常用语句:
| 语句 | 全称 | 包含的核心信息 |
|---|---|---|
| GGA | Global Positioning System Fix Data | 定位时间、经纬度、定位质量、卫星数、海拔高度 |
| RMC | Recommended Minimum Specific GNSS Data | 推荐最小定位信息,含日期、时间、经纬度、速度 |
| GSA | GNSS DOP and Active Satellites | 参与定位的卫星编号、PDOP/HDOP/VDOP |
| GSV | Satellites in View | 可见卫星详细信息 |
| GST | GNSS Pseudorange Errors | 伪距误差标准差,RTK精度评估常用 |
| ZDA | Time and Date | UTC时间和日期,常用于时间同步 |
我实际用得最多的就是GGA和RMC。GGA负责拿经纬度和海拔高度,RMC负责拿地面速度和对地航向角。如果你要做时间同步,ZDA也很重要,因为它直接给UTC日期时间,可以和系统时间做比对。
2.3 串口参数怎么定
RTK模块的串口输出参数,一般通过上位机软件或AT指令配置,常见默认波特率有9600、115200、230400、460800等。不同的模块默认值不一样,所以不要想当然地按模块说明书上的值去配,而是先用串口助手或者命令行扫一遍。
NMEA 0183的串口约定通常是:8位数据位、无校验、1位停止位,即8N1。这个几乎没有例外。但波特率是最大的坑。有的模块默认115200,有的模块默认9600,你配错波特率时,串口上会看到一堆乱码,或者完全没数据,而不是报错——这个特征很像硬件没接好,所以特别容易误导人。
3. 准备工作:Ubuntu下串口识别与权限处理
3.1 查看RTK模块对应的串口设备
把RTK模块通过USB转串口线(或者直接是USB接口模块)接到Ubuntu主机上以后,先确认系统识别到了哪个设备节点。
ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null dmesg | tail -30如果模块走的是USB转TTL芯片,通常会出现/dev/ttyUSB0;如果模块本身就带USB接口,则可能是/dev/ttyACM0。dmesg可以看到类似USB Serial Converter detected这样的信息,同时会显示设备对应的USB口描述,比如cp210x或者ch341-uart,这些可以帮你确认是哪块硬件。
如果你同时接了多个串口设备,可能会看到ttyUSB0、ttyUSB1两个设备。这时候不要靠猜,可以用udevadm info查询设备信息:
udevadm info -a -n /dev/ttyUSB0 | grep -E "serial|manufacturer"通过序列号或者制造商信息,就能区分哪个节点对应你的RTK模块。我习惯把这一步做扎实,因为后面如果接多个传感器(激光雷达、惯导、电台),设备节点错乱是家常便饭。
3.2 串口权限:最常见的第一个坑
Ubuntu下读串口,默认需要dialout组权限。如果当前用户不在这个组里,open()串口时会报Permission denied,ROS节点直接启动失败,但有时候你以为代码写错了,其实只是权限问题。
一次性解法是把当前用户加入dialout组:
sudo usermod -a -G dialout $USER改完以后必须重新登录或者重启,组权限才会生效。如果你不想重启,也可以临时用sudo跑节点,但我不推荐,ROS节点经常要配合launch文件启动,用sudo会带来一堆环境变量问题,比如找不到ROS_MASTER_URI,或者rosrun都起不来。
更推荐的方案是用一条udev规则,把串口设备固定命名并放开权限。比如在/etc/udev/rules.d/99-rtk-usb.rules里写:
KERNEL=="ttyUSB*", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0666", SYMLINK+="ttyRTK"这里的idVendor和idProduct,用lsusb查你的模块实际值替换。写完执行sudo udevadm control --reload && sudo udevadm trigger,以后插上模块就会固定出现/dev/ttyRTK这个稳定路径,权限也直接放开。这个做法在机器人上特别实用,因为每次插拔串口设备后节点启动顺序不同,设备号会漂移,固定名能彻底解决这个问题。
4. 先不急着写代码:命令行验证NMEA数据
4.1 用screen或cat直接看串口
写ROS节点之前,我强烈建议先在终端里确认串口能出数据,数据格式符合预期。这一步能帮你筛掉一半的问题。
sudo screen /dev/ttyUSB0 115200或者如果你已经给了权限,直接用cat:
cat /dev/ttyUSB0屏幕上有数据翻滚,说明链路通了。如果看到的是乱码,大概率是波特率不对;如果完全没反应,需要检查RTK模块供电、天线状态,以及串口线是否接对。有的模块TX/RX线序和USB转串口板不一致,也可能导致完全没数据。
看到正常NMEA输出后,Ctrl+A退出screen。如果用的是cat,按Ctrl+C即可。
4.2 命令行阶段就该注意的信息
我习惯在命令行阶段就观察几个关键点:
- 语句类型是否包含GGA、RMC。只输出GGA也可以用,但不包含RMC时,想要地面速度就得另想办法。
- GGA第6字段的数值。稳定输出4或5,说明RTK固定解没问题;如果长期是1,说明RTK并未固定,可能是基准站信号没接好,或者差分数据没进来。
- 更新率。RTK模块通常支持1Hz、5Hz、10Hz、20Hz配置,命令行下可以看到数据翻页速度。后期在ROS里,话题频率也要和这个保持一致。
这里我提一个很容易忽略的点:NMEA数据使用UTC时间,不携带时区信息。GGA里的hhmmss.sss是UTC时间,和你系统的本地时间默认差8小时(东八区)。如果你把NMEA里的时间拿来打时间戳,后面做数据同步时会发现ROS里的时间戳和现实时间差一大截。这个我放在第7节详细说。
5. 自写一个ROS Noetic话题发布节点
5.1 节点设计思路
命令行确认模块能出数据后,就可以考虑把它接进ROS了。如果你用的是现成的RTK模块配套驱动,或者网络上有现成的NMEA驱动,那可以直接跳到第6节。但我还是建议自写一个——因为自写节点的过程,能让你彻底搞懂NMEA里每个字段的语义,后面遇到奇怪的定位数据时,你才知道问题出在哪一环。
节点设计其实很简单:一个线程循环读串口,按行分割NMEA语句,解析出经纬度、海拔、定位质量、卫星数、速度、航向等信息,然后以std_msgs/String或者raw字节的原始数据话题发出来,再另起解析模块转成sensor_msgs/NavSatFix。
我个人的经验是先把原始NMEA字符串作为一个话题原样发出来,好处有两个:一是方便调试,任何时候都能用rostopic echo看模块到底输出了什么;二是后续无论改解析逻辑,还是换协议版本,原始数据都在,不需要重新从串口拉线。
5.2 代码实现:串口读取与发布
下面是我常用的一个简化实现,基于pyserial和rospy。它先把串口数据按行读出来,发布到/nmea_sentence话题,同时调用解析函数发布NavSatFix。
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import rospy import serial import serial.tools.list_ports from sensor_msgs.msg import NavSatFix, NavSatStatus from std_msgs.msg import String def parse_gga(parts): """解析 $GPGGA 语句,返回 (lat, lon, alt, quality, num_satellites)""" try: lat_raw = float(parts[2]) lat_deg = int(lat_raw / 100) lat_min = lat_raw - lat_deg * 100 lat = lat_deg + lat_min / 60.0 if parts[3] == 'S': lat = -lat lon_raw = float(parts[4]) lon_deg = int(lon_raw / 100) lon_min = lon_raw - lon_deg * 100 lon = lon_deg + lon_min / 60.0 if parts[5] == 'W': lon = -lon quality = int(parts[6]) num_satellites = int(parts[7]) alt = float(parts[9]) if parts[9] else 0.0 return lat, lon, alt, quality, num_satellites except (ValueError, IndexError): return None def handle_line(line, pub_fix, pub_raw): line = line.strip() if not line.startswith('$'): return pub_raw.publish(String(data=line)) if line.startswith('$GPGGA') or line.startswith('$GNGGA'): fields = line.split(',') result = parse_gga(fields) if result is None: return lat, lon, alt, quality, num_satellites = result if quality == 0: return fix = NavSatFix() fix.header.stamp = rospy.Time.now() fix.header.frame_id = "gps_link" fix.status.status = NavSatStatus.STATUS_FIX fix.status.service = NavSatStatus.SERVICE_GPS | NavSatStatus.SERVICE_RTK fix.latitude = lat fix.longitude = lon fix.altitude = alt fix.position_covariance_type = NavSatFix.COVARIANCE_TYPE_UNKNOWN pub_fix.publish(fix) def main(): rospy.init_node("rtk_nmea_driver", anonymous=False) port = rospy.get_param("~port", "/dev/ttyUSB0") baudrate = rospy.get_param("~baudrate", 115200) pub_fix = rospy.Publisher("fix", NavSatFix, queue_size=1) pub_raw = rospy.Publisher("nmea_sentence", String, queue_size=100) try: ser = serial.Serial(port, baudrate, timeout=0.1) except serial.SerialException as e: rospy.logerr("Failed to open serial port %s: %s", port, e) return rate = rospy.Rate(50) line_buffer = b"" while not rospy.is_shutdown(): data = ser.read(ser.in_waiting or 1) if data: line_buffer += data while b"\n" in line_buffer: raw_line, line_buffer = line_buffer.split(b"\n", 1) try: line = raw_line.decode("ascii") handle_line(line, pub_fix, pub_raw) except UnicodeDecodeError: pass rate.sleep() if __name__ == "__main__": main()这里有一个很关键的细节:ser.read(ser.in_waiting or 1)。in_waiting返回当前串口缓冲区里有多少个字节可读,如果为0,则read(1)会阻塞最多0.1秒(由timeout决定)。这样既能保证CPU占用低,又能及时拿到新数据。如果你直接写ser.read(1),在高波特率下读取效率很低,容易丢数据;如果你写ser.read(1000),则会一直等缓冲区凑满1000个字节,导致数据延迟。
5.3 坐标转换:NMEA和ROS的经纬度格式差异
NMEA里的经纬度格式和ROS里NavSatFix用的十进制经纬度格式不同,这个非常容易搞错。
NMEA的GGA语句里,纬度是一个数字,比如3104.68201,它的含义是:31度04.68201分。要转成十进制度数,需要先取出整数部分的前两位作为度数(31),剩下部分(4.68201)除以60得到小数度数(约0.078),然后加回31度,最终纬度为31.078度左右。
同理,经度12123.4567表示121度23.4567分,转出来是121.3909...度。
如果你不管这个格式,直接把3104.68201当作31.0468度来用,那么误差会大到离谱——大约每差0.1度就会偏出10公里以上。我第一次写这个解析函数时就犯过错,用Google地图核对半天发现位置飘到海里去了。所以这一步建议单独写成一个纯函数,并写单元测试验证。
5.4 运行节点并用rostopic验证
把代码保存为rtk_nmea_driver.py,放到功能包的scripts/目录下,然后给可执行权限:
chmod +x rtk_nmea_driver.py在CMakeLists.txt里加上:
catkin_install_python(PROGRAMS scripts/rtk_nmea_driver.py DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION})然后运行:
rosrun your_package rtk_nmea_driver.py _port:=/dev/ttyUSB0 _baudrate:=115200另外开一个终端:
rostopic echo /fix rostopic hz /fix rostopic echo /nmea_sentence如果/fix以你配置的频率持续输出经纬度、海拔,并且/nmea_sentence能看到原始语句,节点就基本通了。
6. 现成驱动选用:nmea_navsat_driver能做什么
6.1 安装与配置
如果你不想自己维护节点,ROS社区里现成的方案是nmea_navsat_driver包。它帮你把串口读数据、解析NMEA、发布NavSatFix、发布Imu(部分语句支持航向角)这些工作全部做了。在Noetic下可以直接装:
sudo apt install ros-noetic-nmea-navsat-driver然后用launch启动:
<launch> <node name="nmea_navsat_driver" pkg="nmea_navsat_driver" type="nmea_serial_driver" output="screen"> <param name="port" value="/dev/ttyUSB0" /> <param name="baud" value="115200" /> <param name="frame_id" value="gps_link" /> <rosparam param="time_ref_source">gps</rosparam> </node> </launch>6.2 驱动发布的话题
驱动起来之后,你会看到它发布:
/fix:类型sensor_msgs/NavSatFix,包含经纬度和海拔。/fix_velocity:包含东北天坐标系下的速度分量。/time_reference:用于时间同步的参考时间戳。/imu:部分NMEA语句(如GPRMC里的航向角)可以转成IMU数据。
这个包的核心优势是它把NMEA解析做得很完整,包括GGA、RMC、GST、ZDA等语句都支持,而且已经在大量项目里验证过,稳定性没问题。
6.3 局限性与扩展思路
但现成驱动也有几个问题,我用下来最头疼的是:
第一,它默认不判断RTK固定解状态。至少到目前这个版本,它把GGA里的定位质量字段没有严格区分,比如质量2(差分定位)和质量4(RTK固定解)都直接发布成STATUS_FIX。如果你的应用对精度要求高,就得吞掉它输出的/fix,自己读/nmea_sentence或者在驱动源码里加过滤器。
第二,时间戳处理方式不一定符合你的需求。驱动默认会用接收到的NMEA时间或者系统当前时间给/fix打时间戳。在我的项目里,因为RTK和激光雷达需要做时间同步,我更希望话题时间戳是接收到串口数据那一刻的系统时间,这样才能和ROS的其他传感器时间站在同一个基准面上。此时我会把time_ref_source参数做相应处理,或者干脆在输出的回调里重新定义header.stamp。
第三,它对单点解和浮点解的置信度没有输出。如果你要拿RTK数据做车道级定位或者扫描建图,不仅需要知道经纬度,还需要知道当前解的稀释度、伪距误差标准差(GST语句里的数据)。现成驱动不会把这些原始字段透传出来,你需要自己扩展。
所以我的建议是:如果你只是想把RTK数据拿来看一眼,或者精度要求不高,直接用nmea_navsat_driver最省事;如果你需要做高精度建图、车道级导航,或者需要严格区分固定解状态,那最好自写节点,或者至少在现成节点外面再加一层质量过滤。
7. 实际使用中的几个坑
7.1 串口权限导致的启动失败
这个话题在文档里讲得不多,但实际遇到概率极高。特别是你用systemd服务或者roslaunch开机自启动RTK节点时,环境里没有交互式终端,usermod加的组权限可能不会在会话里生效。表现为:手动rosrun没问题,一加上launch文件启动就报Permission denied。
解决方式,一是用前面提到的udev规则把MODE设置为0666;二是在launch文件里给节点显式指定用户,或者用一个udev规则配合SYMLINK固定设备名。我实际项目里就是用固定名加0666权限,彻底绕开了权限问题,系统重启后节点自动连接,不用人工干预。
7.2 波特率配置不一致
有个场景我印象很深:模块说明书上写着默认波特率115200,但是这块模块之前被测试人员用上位机改成过460800,我直接用115200去读,串口完全不出数据。排查老半天,最后用上位机软件才发现波特率被改了。
所以如果你发现模块上电后命令行完全没输出,先不要怀疑硬件坏了,试着用几个常见波特率轮询一遍:
for baud in 9600 115200 230400 460800 921600; do echo "=== $baud ===" timeout 1 cat /dev/ttyUSB0 & sleep 0.5 done另一个场景是两个模块串口对接时,一个模块的波特率配置和另一个不一致,也会导致数据收发异常。注意,RTK模块的串口波特率往往和差分数据链路(电台、网络)不是一回事,需要单独确认。
7.3 时间戳与UTC时区
NMEA里的时间字段全是UTC时间,不带时区。如果你在做传感器融合,ROS中的时间戳最好统一用rospy.Time.now()(即系统时间),因为激光雷达、相机的时间戳都是以系统时间为基准的。千万别直接用NMEA里解析出来的UTC时间戳覆盖header.stamp,否则在时区不是UTC的机器上,你的RTK数据和其他传感器数据会整体偏8小时(或对应时差),融合时要么轨迹飘,要么地图错位。
我之前做过一个室外建图项目,RTK时间戳没处理,导致每帧点云和GPS坐标差了8小时,程序直接以为传感器静止了,地图糊成一团。后来把header.stamp改成系统接收时间,问题立刻消失。
7.4 固定解状态判断一定要做
这个坑我在2.1节提过,这里给一个可落地的判断逻辑。GGA语句的第6个字段:
| 数值 | 状态 | 建议处理 |
|---|---|---|
| 0 | 未定位 | 丢弃 |
| 1 | 单点定位 | 只能当普通GPS用 |
| 2 | 差分定位 | 有SBAS或局域差分,精度亚米级 |
| 4 | RTK固定解 | 厘米级,放心用 |
| 5 | RTK浮点解 | 分米级,可继续观测 |
在自写节点里,可以直接在handle_line里判断quality,只有等于4或5时才往/fix里发数据。如果你用现成驱动,可以加一层订阅者节点,订阅/nmea_sentence,解析出质量字段做过滤,或者直接修改驱动的源码。
我个人的经验是:固定解状态比经纬度数值更容易暴露问题。有一次在树荫下测试,RTK模块半天不固定,但是单点解的数据依然在正常发布,从/fix话题看起来定位没问题,但实际上精度已经差了好几米。所以在系统里最好有可视化手段显示当前解类型,比如在RVIZ里用不同颜色的图标标识固定解、浮点解、单点解,这样室外测试时一眼就能发现问题。
7.5 串口数据丢失与缓冲区溢出
如果你发现rostopic hz /fix的频率不稳定,或者/nmea_sentence有丢行,大概率不是模块的问题,而是读取逻辑不够及时。
NMEA语句在115200波特率下,每秒大约11520字节,但模块的更新率可能是10Hz,每条语句长度80个字节左右,每秒输出800字节,其实压力不算大。真正的瓶颈在于:串口数据是流式的,如果你在回调里做耗时操作(比如写文件、网络发送),下一个字节到了却没读,内核缓冲区满后就会丢数据。
解决办法是在串口读取线程里只做轻量解析和发布,绝对不做阻塞操作。如果需要保存数据,最好在另一个线程里订阅/nmea_sentence然后写文件,避开串口读取路径上的瓶颈。
7.6 天线的限制与固定率
最后说一个和代码无关、但直接影响数据质量的点:RTK模块能不能固定解,很多时候取决于天线视野。在树荫、高楼旁、隧道口附近,卫星信号被遮挡或反射,固定解率会大幅下降。这时候不管你代码写得再好,数据质量也无法保证。所以测试时尽量选开阔场地,我一般会先在一个稳定的空地上确认模块能固定解,再去做长时间跑测,避免把环境问题误判成软件问题。
8. 从串口字节到高精度话题:整个流程的复盘
到这里,完整链路已经走通了:Ubuntu识别串口设备,处理权限和稳定命名;命令行验证NMEA原始数据;自写节点完成读取、解析、发布NavSatFix和原始NMEA话题;或者直接用nmea_navsat_driver快速接进ROS;最后根据实际场景处理固定解状态、时间戳、波特率等细节。
在实际项目中,我通常会把自写节点和现成驱动结合使用:自写节点负责读取串口和解析关键字段,同时把原始NMEA数据完整发出来;然后单独起一个质量过滤节点,订阅原始NMEA,实时统计固定解率,并决定往后续算法里喂多少数据。这样既保证链路简单可控,又保留了原始数据可追溯性。
如果你刚入门,可以直接照着第5节的代码跑通第一个版本,然后再逐步加功能。这个流程我帮好几个同事梳理过,他们普遍反馈卡住的地方不是ROS本身,而是串口通信和NMEA格式这两个老派的“基本功”。所以如果你在调的时候遇到奇怪的问题,先回到命令行检查串口数据是不是正常的,再回到ROS层面找问题,比在代码里反复调试要快得多。
分享一下我现在的固定习惯:每次接一块新的RTK模块,第一步永远是cat /dev/ttyUSB0看看原始数据,第二步看GGA里的quality字段,第三步才写代码或者接驱动。这套流程帮我省下来的调试时间,远比最开始那个半小时多得多。