第4章:话题通信实战:编写发布者与订阅者节点

好,咱们今天来点真家伙。

前面几章我们把ROS2的底层概念、环境搭建都捋了一遍。说实话,光看理论容易犯困。我当年学ROS2的时候,最烦的就是看一堆文档,然后不知道代码怎么写。所以这一章,咱们直接上手写代码。

目标很明确:用C++和Python各写一对发布者(Publisher)和订阅者(Subscriber)节点。先传字符串,再传自定义消息。嗯,这是ROS2通信的基石,也是你后续所有机器人开发的基础。

4.1 先聊聊话题通信的本质

话题通信,说白了就是发布-订阅模式。一个节点发消息,一个或多个节点收消息。发的人不管谁在收,收的人也不管谁在发。这种解耦设计,在嵌入式系统里特别实用。

我做过一个多传感器融合的项目,激光雷达、摄像头、IMU各跑各的节点。如果都用服务通信,那代码耦合度会高到让人崩溃。话题通信让每个传感器节点只管往外扔数据,融合节点自己挑着收就行。

为什么会这样设计?因为机器人系统里,数据流往往是单向的、持续的。比如里程计数据,每秒几十次,你不可能每次都去请求一下。话题通信的异步特性,正好解决这个问题。

核心要点:

  • 发布者:只管发布,不关心谁在订阅
  • 订阅者:只管接收,不关心谁在发布
  • 话题名:双方通过同一个字符串名称匹配
  • 消息类型:双方必须使用完全相同的消息定义

4.2 准备工作:创建功能包

动手之前,先把工作空间搭好。我个人习惯用colcon构建,干净利落。

# 创建工作空间
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src

# 创建功能包,同时支持C++和Python
ros2 pkg create --build-type ament_cmake chapter4_talker_listener \
    --dependencies rclcpp std_msgs

# 再创建一个纯Python包,方便对比
ros2 pkg create --build-type ament_python chapter4_py_talker_listener \
    --dependencies rclpy std_msgs

注意这里我加了--dependencies参数。很多新手会忘记加,然后编译报错才回头补。我建议你一开始就把依赖写清楚,省得后面折腾。

4.3 C++实现:字符串发布者

先来C++版本。为什么先写C++?因为ROS2底层就是C++写的,性能更好。在嵌入式场景下,我通常用C++写核心节点。

chapter4_talker_listener/src目录下创建talker.cpp

#include <chrono>
#include <memory>
#include <string>

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

using namespace std::chrono_literals;

class TalkerNode : public rclcpp::Node
{
public:
    TalkerNode() : Node("talker_node")
    {
        publisher_ = this->create_publisher<std_msgs::msg::String>("chatter", 10);
        timer_ = this->create_wall_timer(
            500ms, std::bind(&TalkerNode::timer_callback, this));
        RCLCPP_INFO(this->get_logger(), "发布者节点已启动");
    }

private:
    void timer_callback()
    {
        auto message = std_msgs::msg::String();
        message.data = "Hello, ROS2! 第 " + std::to_string(count_++) + " 条消息";
        publisher_->publish(message);
        RCLCPP_INFO(this->get_logger(), "发布: '%s'", message.data.c_str());
    }

    rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
    rclcpp::TimerBase::SharedPtr timer_;
    size_t count_ = 0;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<TalkerNode>());
    rclcpp::shutdown();
    return 0;
}

这段代码里,我用了create_wall_timer定时触发。为什么不用死循环加sleep?因为ROS2的spin机制会阻塞主线程,用定时器更优雅。我曾经见过有人用while(1)sleep,结果节点无法正常退出,Ctrl+C都杀不死。嗯,定时器才是正道。

4.4 C++实现:字符串订阅者

同样在src目录下创建listener.cpp

#include <memory>

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

class ListenerNode : public rclcpp::Node
{
public:
    ListenerNode() : Node("listener_node")
    {
        subscription_ = this->create_subscription<std_msgs::msg::String>(
            "chatter", 10, std::bind(&ListenerNode::topic_callback, this, std::placeholders::_1));
        RCLCPP_INFO(this->get_logger(), "订阅者节点已启动");
    }

private:
    void topic_callback(const std_msgs::msg::String &msg) const
    {
        RCLCPP_INFO(this->get_logger(), "收到: '%s'", msg.data.c_str());
    }

    rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};

int main(int argc, char *argv[])
{
    rclcpp::init(argc, argv);
    rclcpp::spin(std::make_shared<ListenerNode>());
    rclcpp::shutdown();
    return 0;
}

注意看回调函数的参数类型:const std_msgs::msg::String &msg。这里用引用传递,避免拷贝。在嵌入式设备上,消息体可能很大(比如图像),用引用能省不少内存。

小技巧:回调函数里不要做耗时操作。如果你需要处理数据,请把数据放到队列里,另起线程处理。否则会阻塞消息接收,导致丢包。我踩过这个坑,当时IMU数据一直丢,查了两天才发现是回调里做了FFT计算。

4.5 配置CMakeLists.txt

别忘了把这两个源文件加到编译列表里。打开CMakeLists.txt,在末尾添加:

add_executable(talker src/talker.cpp)
ament_target_dependencies(talker rclcpp std_msgs)

add_executable(listener src/listener.cpp)
ament_target_dependencies(listener rclcpp std_msgs)

install(TARGETS
    talker
    listener
    DESTINATION lib/${PROJECT_NAME}
)

这里有个细节:install部分很多人会漏掉。不写的话,编译出来的可执行文件不会安装到install目录,运行时会找不到。我刚开始学的时候,每次都要手动拷贝,后来才发现是install没写。

4.6 Python实现:发布者与订阅者

Python版本更适合快速原型验证。在嵌入式开发中,我经常先用Python写个demo,确认逻辑没问题,再转成C++。

chapter4_py_talker_listener/chapter4_py_talker_listener目录下创建talker.py

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

class TalkerNode(Node):
    def __init__(self):
        super().__init__('talker_node')
        self.publisher_ = self.create_publisher(String, 'chatter', 10)
        timer_period = 0.5  # 500ms
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.count = 0
        self.get_logger().info('发布者节点已启动')

    def timer_callback(self):
        msg = String()
        msg.data = f'Hello, ROS2! 第 {self.count} 条消息'
        self.publisher_.publish(msg)
        self.get_logger().info(f'发布: "{msg.data}"')
        self.count += 1

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

if __name__ == '__main__':
    main()

再创建listener.py

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

class ListenerNode(Node):
    def __init__(self):
        super().__init__('listener_node')
        self.subscription = self.create_subscription(
            String,
            'chatter',
            self.listener_callback,
            10)
        self.subscription  # 防止被垃圾回收
        self.get_logger().info('订阅者节点已启动')

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

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

if __name__ == '__main__':
    main()

Python代码看起来更简洁。但注意那个self.subscription赋值,很多人会问为什么还要再写一遍?因为Python的垃圾回收机制,如果不用变量保存,订阅对象会被回收,导致收不到消息。我刚开始写Python ROS2时就被这个坑过,调试了半天才发现是引用丢了。

4.7 自定义消息:定义.msg文件

字符串消息太简单了,实际项目中我们更需要自定义消息。比如传一个包含温度、湿度、时间戳的传感器数据。

chapter4_talker_listener包下创建msg目录,新建SensorData.msg

# 传感器数据结构
float32 temperature    # 温度,单位:摄氏度
float32 humidity       # 湿度,单位:百分比
int64 timestamp        # 时间戳,单位:微秒
string device_id       # 设备ID

然后修改CMakeLists.txt,添加消息生成支持:

find_package(rosidl_default_generators REQUIRED)

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

ament_export_dependencies(rosidl_default_runtime)

别忘了在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>

4.8 使用自定义消息的发布者

修改talker.cpp,使用自定义消息:

#include "chapter4_talker_listener/msg/sensor_data.hpp"

// 在回调函数中
auto message = chapter4_talker_listener::msg::SensorData();
message.temperature = 25.5 + (rand() % 100) / 10.0;
message.humidity = 60.0 + (rand() % 100) / 10.0;
message.timestamp = this->now().nanoseconds() / 1000;
message.device_id = "sensor_01";
publisher_->publish(message);

注意:自定义消息的头文件路径是包名/msg/消息名.hpp。很多人会写成msg/sensor_data.hpp,编译会报找不到头文件。这是ROS2的命名空间规则,习惯了就好。

4.9 编译与运行测试

回到工作空间根目录:

cd ~/ros2_ws
colcon build --packages-select chapter4_talker_listener chapter4_py_talker_listener
source install/setup.bash

开三个终端,分别运行:

# 终端1:C++发布者
ros2 run chapter4_talker_listener talker

# 终端2:C++订阅者
ros2 run chapter4_talker_listener listener

# 终端3:Python订阅者(可以同时订阅)
ros2 run chapter4_py_talker_listener listener

你会看到C++和Python的订阅者都能收到消息。这就是ROS2跨语言通信的魅力。我当年第一次看到C++发的消息被Python节点收到时,觉得这玩意儿真神奇。其实底层用的是DDS,语言只是封装。

4.10 避坑指南与调试技巧

写到这里,我总结几个实战中容易踩的坑:

  • 话题名拼写错误:发布者和订阅者的话题名必须完全一致,大小写敏感。我曾经把chatter写成chater,找了半小时bug。
  • QoS设置不匹配:默认的QoS配置是10(队列深度)。如果发布者用10,订阅者用1,可能会丢消息。建议双方保持一致。
  • 忘记source setup.bash:每次新开终端都要source,或者加到.bashrc里。我习惯用echo "source ~/ros2_ws/install/setup.bash" >> ~/.bashrc一劳永逸。
  • 自定义消息未编译:修改了.msg文件后,必须重新编译整个包。只编译依赖包是不够的。

调试利器:ros2 topic list查看当前所有话题,用ros2 topic echo /chatter直接看消息内容。这两个命令能帮你快速定位通信问题。

好了,这一章的内容就到这里。你想想看,从字符串到自定义消息,从C++到Python,你已经掌握了ROS2话题通信的核心。下一章我们会聊服务通信,那是另一种模式——请求-响应。到时候你会发现,话题和服务各有各的用武之地。