4. 话题通信编程:让节点真正“聊”起来

好,我们继续往前走。前面几章我们把 ROS2 的环境搭好了,也学会了怎么创建功能包和节点。但说实话,单个节点就像一台没联网的电脑——能干点本地活,但没啥大用。这一章,我们来聊聊 ROS2 最核心的通信方式:话题(Topic)。

说白了,话题通信就是“发布-订阅”模式。一个节点发布消息,其他节点订阅这个消息。发布者不管谁在听,订阅者也不管谁在发。这种解耦设计,我在做多传感器融合项目时深有体会——你想想看,激光雷达和摄像头各自发布数据,融合节点只管订阅,互不干扰,多清爽。

4.1 编写发布者(Publisher)节点

我们先从发布者开始。我习惯先写 C++ 版本,因为性能要求高的场景下 C++ 更靠谱。但别担心,Python 版本我也会一并给出。

4.1.1 C++ 发布者实现

假设我们要发布一个“字符串消息”,内容就是“Hello, ROS2!”。在 C++ 里,核心逻辑就这几步:

// publisher_node.cpp
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<rclcpp::Node>("talker");
  auto publisher = node->create_publisher<std_msgs::msg::String>("topic_chatter", 10);
  rclcpp::WallRate rate(1); // 1 Hz

  while (rclcpp::ok())
  {
    auto msg = std_msgs::msg::String();
    msg.data = "Hello, ROS2!";
    RCLCPP_INFO(node->get_logger(), "Publishing: '%s'", msg.data.c_str());
    publisher->publish(msg);
    rclcpp::spin_some(node);
    rate.sleep();
  }

  rclcpp::shutdown();
  return 0;
}
我的小习惯:队列大小设 10 通常够用。如果消息频率很高(比如 100Hz 以上),我会设到 100 甚至 1000,防止丢消息。

这段代码里,create_publisher 的第二个参数是队列深度。嗯,这里要注意:队列满了之后,最老的消息会被丢弃。如果你做的是控制指令,丢消息可能出大问题。

4.1.2 Python 发布者实现

Python 版本更简洁,适合快速原型验证。我在做算法验证时经常先用 Python 写一遍。

# publisher_node.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class Talker(Node):
    def __init__(self):
        super().__init__('talker')
        self.publisher_ = self.create_publisher(String, 'topic_chatter', 10)
        timer_period = 1.0  # seconds
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.i = 0

    def timer_callback(self):
        msg = String()
        msg.data = 'Hello, ROS2! %d' % self.i
        self.get_logger().info('Publishing: "%s"' % msg.data)
        self.publisher_.publish(msg)
        self.i += 1

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

if __name__ == '__main__':
    main()
我曾经踩过的坑:Python 里用 create_timer 时,回调函数里千万别做耗时操作。有一次我在回调里写了个大文件,结果定时器直接卡死,整个节点像睡着了一样。后来我改用多线程才解决。

4.2 编写订阅者(Subscriber)节点

有发就有收。订阅者节点的写法跟发布者对称,只是角色反过来。

4.2.1 C++ 订阅者实现

// subscriber_node.cpp
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/string.hpp>

void topic_callback(const std_msgs::msg::String::SharedPtr msg)
{
  RCLCPP_INFO(rclcpp::get_logger("listener"), "I heard: '%s'", msg->data.c_str());
}

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<rclcpp::Node>("listener");
  auto subscription = node->create_subscription<std_msgs::msg::String>(
    "topic_chatter", 10, topic_callback);
  rclcpp::spin(node);
  rclcpp::shutdown();
  return 0;
}

你看,订阅者不需要循环,rclcpp::spin 会一直等待消息到来。回调函数里处理数据就行。

4.2.2 Python 订阅者实现

# subscriber_node.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class Listener(Node):
    def __init__(self):
        super().__init__('listener')
        self.subscription = self.create_subscription(
            String,
            'topic_chatter',
            self.listener_callback,
            10)
        self.subscription  # prevent unused variable warning

    def listener_callback(self, msg):
        self.get_logger().info('I heard: "%s"' % msg.data)

def main(args=None):
    rclpy.init(args=args)
    listener = Listener()
    rclpy.spin(listener)
    listener.destroy_node()
    rclpy.close()
关键点:话题名称必须完全一致,包括大小写。我见过有人把 topic_chatter 写成 Topic_Chatter,结果两个节点各说各话,排查了半天。

4.3 自定义消息类型

内置的 String、Int32 这些类型,说实话,在真实项目中根本不够用。比如你要发布一个“机器人状态”消息,包含位置、速度、电池电量——这时候就需要自定义消息了。

4.3.1 定义 .msg 文件

在功能包的 msg 目录下创建 RobotStatus.msg:

# RobotStatus.msg
float32 x
float32 y
float32 theta
float32 linear_velocity
float32 angular_velocity
float32 battery_percentage
string status_description

然后修改 CMakeLists.txt 和 package.xml,把消息类型注册进去。这一步容易忘,我刚开始学的时候经常漏掉 rosidl_generate_interfaces 那行。

4.3.2 在代码中使用自定义消息

C++ 里包含头文件:

#include "your_package/msg/robot_status.hpp"

Python 里导入:

from your_package.msg import RobotStatus

用法跟内置消息完全一样。我个人建议,消息字段尽量用基本类型,别嵌套太深,否则序列化性能会下降。

4.4 话题通信的核心逻辑

为了让你更直观地理解,我画了一张图:

发布者节点 (Talker) 订阅者节点1 (Listener A) 订阅者节点2 (Listener B) 话题总线 /topic_chatter 发布 订阅 订阅 发布者将消息推送到话题总线,所有订阅者都能收到

这张图展示了话题通信的精髓:发布者只管往话题里扔数据,订阅者只管从话题里取数据。两者完全解耦,互不知道对方的存在。我在做机器人集群控制时,一个发布者可以同时被十几个订阅者监听,代码一行都不用改。

4.5 编译与运行

写完了代码,怎么跑起来?

  1. 编译:在功能包目录下执行 colcon build --packages-select your_package
  2. source 环境:source install/setup.bash
  3. 启动发布者:ros2 run your_package talker
  4. 启动订阅者:另开终端,ros2 run your_package listener
调试小技巧:用 ros2 topic list 查看所有活跃话题,用 ros2 topic echo /topic_chatter 直接看消息内容。这两个命令我几乎每天用。

4.6 常见问题与避坑

问题 原因 解决方法
订阅者收不到消息 话题名称拼写不一致 用 ros2 topic list 核对名称
编译时报找不到消息类型 忘记在 CMakeLists.txt 中添加依赖 检查 find_package 和 rosidl_generate_interfaces
Python 节点启动后立即退出 没有调用 rclpy.spin() 确保主循环里有 spin 调用
消息频率不稳定 回调函数里做了耗时操作 用多线程或异步处理
我曾经犯过的错:有一次我自定义消息里用了 uint8[] 数组,结果在 Python 里死活解析不出来。后来发现是字节序问题——ROS2 默认用 little-endian,而我的数据源是 big-endian。从那以后,我定消息类型时都会先确认字节序。

好了,这一章的内容就到这里。话题通信是 ROS2 的基石,你把它吃透了,后面服务、动作这些通信方式学起来会快很多。记住:发布者只管发,订阅者只管收,中间的话题就是你们的“邮局”。