第三讲:话题通信实战——发布者与订阅者模式、自定义消息类型、多话题同步机制

好,咱们直接进入正题。话题通信是ROS2里最基础、也是最常用的通信方式。说白了,就是一个节点把数据“喊”出来,另一个节点“听”到并处理。今天这一讲,我会带着你从最基础的发布订阅写起,再到自定义消息类型,最后聊聊多话题同步的那些坑。

3.1 发布者与订阅者模式:从零搭建一个“喊话”系统

先看一个最简单的例子。假设我们有一个传感器节点,每隔1秒发布一次“Hello ROS2”字符串。另一个节点负责接收并打印。

发布者节点(talker)

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class Talker(Node):
    def __init__(self):
        super().__init__('talker')
        self.pub = self.create_publisher(String, 'chatter', 10)
        self.timer = self.create_timer(1.0, self.timer_callback)
        self.count = 0

    def timer_callback(self):
        msg = String()
        msg.data = f'Hello ROS2: {self.count}'
        self.pub.publish(msg)
        self.get_logger().info(f'发布: {msg.data}')
        self.count += 1

def main(args=None):
    rclpy.init(args=args)
    node = Talker()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()

订阅者节点(listener)

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class Listener(Node):
    def __init__(self):
        super().__init__('listener')
        self.sub = self.create_subscription(String, 'chatter', self.callback, 10)

    def callback(self, msg):
        self.get_logger().info(f'收到: {msg.data}')

def main(args=None):
    rclpy.init(args=args)
    node = Listener()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()
我的习惯:创建发布者时,队列大小(10)别随便填。我遇到过队列太小导致丢消息,太大又占内存。一般传感器数据给10~50就够了,高频数据可以给100。

运行方式很简单:开两个终端,分别运行 ros2 run 包名 talkerros2 run 包名 listener。你会看到发布者每秒喊一次,订阅者同步收到。

3.2 自定义消息类型:让数据“说人话”

内置的String、Int32这些类型,说实话太简陋了。实际项目中,我们经常需要传递复合数据。比如一个超声波传感器,要同时传距离、温度、置信度。这时候就得自定义消息。

第一步:创建.msg文件

在包目录下新建 msg/Ultrasound.msg

float32 distance
float32 temperature
uint8 confidence
string sensor_id
builtin_interfaces/Time timestamp

第二步:修改CMakeLists.txt和package.xml

在CMakeLists.txt中添加:

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Ultrasound.msg"
)

package.xml中添加:

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

第三步:编译并使用

colcon build --packages-select 你的包名

然后在代码里这样用:

from 你的包名.msg import Ultrasound

msg = Ultrasound()
msg.distance = 1.25
msg.temperature = 23.5
msg.confidence = 200
msg.sensor_id = 'ultra_01'
msg.timestamp = self.get_clock().now().to_msg()
我曾经踩过的坑:自定义消息的字段名不要用“type”、“class”这类Python保留字。编译不会报错,但运行时会出现诡异的属性访问错误。我排查了整整一下午才发现是字段名冲突。

3.3 多话题同步机制:让数据“对齐”

实际项目中,一个节点往往要订阅多个话题。比如机器人导航,要同时接收激光雷达数据、里程计数据、IMU数据。但这些数据频率不同,到达时间也不同。怎么保证它们“对齐”呢?

方案一:时间戳对齐(最常用)

每个消息都带上时间戳。订阅者收到后,根据时间戳做缓存和匹配。ROS2的 message_filters 包提供了现成的工具:

from message_filters import Subscriber, TimeSynchronizer, ApproximateTimeSynchronizer

# 精确时间同步(要求时间戳完全一致)
sub_laser = Subscriber(self, LaserScan, 'scan')
sub_odom = Subscriber(self, Odometry, 'odom')
ts = TimeSynchronizer([sub_laser, sub_odom], 10)
ts.registerCallback(self.sync_callback)

# 近似时间同步(允许一定时间差)
ats = ApproximateTimeSynchronizer(
    [sub_laser, sub_odom], 
    queue_size=10, 
    slop=0.1  # 允许0.1秒的误差
)
ats.registerCallback(self.sync_callback)

方案二:缓存队列 + 手动匹配

如果数据频率差异很大(比如激光10Hz,IMU 100Hz),用时间同步器反而容易丢数据。我习惯自己维护一个缓存字典:

class SyncNode(Node):
    def __init__(self):
        super().__init__('sync_node')
        self.buffer = {'laser': None, 'odom': None}
        
        self.create_subscription(LaserScan, 'scan', self.laser_cb, 10)
        self.create_subscription(Odometry, 'odom', self.odom_cb, 10)
    
    def laser_cb(self, msg):
        self.buffer['laser'] = msg
        self.try_sync()
    
    def odom_cb(self, msg):
        self.buffer['odom'] = msg
        self.try_sync()
    
    def try_sync(self):
        if self.buffer['laser'] and self.buffer['odom']:
            # 检查时间差是否在允许范围内
            dt = abs(self.buffer['laser'].header.stamp - 
                     self.buffer['odom'].header.stamp)
            if dt < 0.05:  # 50ms以内认为同步
                self.process_sync(
                    self.buffer['laser'], 
                    self.buffer['odom']
                )
核心要点:多话题同步没有银弹。高频+低频用缓存手动匹配;同频或近频用message_filters;如果数据量极大,考虑用“最近邻”策略——收到一个话题时,取另一个话题最近的一帧数据。

3.4 实战避坑指南

常见问题 原因 解决方案
订阅者收不到消息 话题名拼写错误或命名空间不匹配 ros2 topic list 确认话题名
消息顺序错乱 多个发布者竞争同一话题 使用唯一话题名或加时间戳排序
自定义消息编译失败 CMakeLists.txt中漏了依赖 检查rosidl_generate_interfaces配置
同步回调不触发 时间戳误差过大或队列太小 增大slop参数或queue_size

嗯,这一讲的内容就这些。说白了,发布订阅模式是ROS2的基石,自定义消息让数据更“聪明”,多话题同步则是实战中的硬骨头。我建议你动手写一个完整的例子:一个节点发布自定义的传感器数据,另一个节点订阅并做时间同步。遇到问题别怕,多看看 ros2 topic echo 的输出,数据是不会骗人的。

一个小技巧:调试多话题同步时,我习惯先打印每个话题的时间戳,看看它们的时间差分布。用 ros2 topic hz /话题名 可以看发布频率,用 ros2 topic echo /话题名 --field header.stamp 可以只看时间戳。

下一讲我们会聊服务通信——那种“一问一答”的模式。到时候你会发现,有些场景用话题反而别扭,服务才是正解。先消化今天的内容,有问题随时翻翻ROS2官方文档,或者直接看源码。好,下课。