3、ROS2通信机制实战(一):使用C++编写发布者与订阅者节点、自定义消息类型(msg)、编译与运行、使用rqt_graph可视化节点通信

各位同学,欢迎来到ROS2通信机制实战的第一部分。说实话,通信机制是ROS2的魂。你想想看,机器人身上那么多传感器、执行器、算法模块,它们之间怎么协同工作?靠的就是这套发布-订阅模型。今天我们就从最基础的开始,手把手写一个发布者和订阅者节点,再聊聊自定义消息类型,最后用rqt_graph看看节点之间到底是怎么“聊天”的。

3.1 理解发布者与订阅者模型

发布者-订阅者模型,说白了就是一个“广播”模式。发布者只管往外发消息,它不关心谁在收;订阅者只管收消息,它不关心谁在发。这种解耦设计,在机器人系统里特别实用。

我个人习惯把发布者想象成电台,订阅者就是收音机。电台播它的,收音机收它的,互不干扰。你可以在同一个话题(Topic)上挂多个发布者,也可以挂多个订阅者,ROS2的中间件(DDS)会帮你处理好一切。

核心概念:

  • 话题(Topic):数据通道的名称,比如 /cmd_vel、/odom
  • 消息(Message):数据的结构定义,比如 Twist、Odometry
  • 发布者(Publisher):向话题发送消息的节点
  • 订阅者(Subscriber):从话题接收消息的节点

下面这张图展示了我们今天要搭建的通信结构:

发布者节点 talker 订阅者节点 listener /chatter 话题 发布者发布消息到 /chatter 话题 订阅者从 /chatter 话题接收消息 消息类型:std_msgs::String

3.2 创建功能包并编写发布者节点

好,我们开始动手。首先创建一个功能包,里面包含发布者和订阅者两个节点。

# 进入你的工作空间
cd ~/ros2_ws/src

# 创建功能包,依赖 rclcpp 和 std_msgs
ros2 pkg create cpp_topic_demo \
  --build-type ament_cmake \
  --dependencies rclcpp std_msgs

# 进入功能包目录
cd cpp_topic_demo

接下来,在 src/ 目录下创建发布者节点文件 talker.cpp:

// talker.cpp
#include <chrono>
#include <memory>
#include <string>

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

using namespace std::chrono_literals;

class Talker : public rclcpp::Node
{
public:
    Talker() : Node("talker")
    {
        // 创建发布者,话题名为 /chatter,队列深度为 10
        publisher_ = this->create_publisher<std_msgs::msg::String>("/chatter", 10);
        
        // 创建定时器,每 500ms 触发一次回调
        timer_ = this->create_wall_timer(
            500ms, std::bind(&Talker::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_++) + " 条消息";
        
        RCLCPP_INFO(this->get_logger(), "发布: '%s'", message.data.c_str());
        publisher_->publish(message);
    }
    
    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<Talker>());
    rclcpp::shutdown();
    return 0;
}

避坑指南:我曾经在队列深度上吃过亏。如果你把队列深度设为1,而订阅者处理速度跟不上,消息就会丢失。对于高频数据(比如激光雷达),建议队列深度设大一些,比如10或100。

3.3 编写订阅者节点

在同一个 src/ 目录下创建 listener.cpp:

// listener.cpp
#include <memory>
#include <string>

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

class Listener : public rclcpp::Node
{
public:
    Listener() : Node("listener")
    {
        // 创建订阅者,订阅 /chatter 话题
        subscription_ = this->create_subscription<std_msgs::msg::String>(
            "/chatter", 10, std::bind(&Listener::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<Listener>());
    rclcpp::shutdown();
    return 0;
}

嗯,这里要注意回调函数的参数类型。订阅者的回调函数接收的是 const & 引用,这样可以避免不必要的拷贝。在机器人系统中,如果消息体很大(比如点云数据),这个细节能省下不少性能开销。

3.4 配置 CMakeLists.txt 并编译

代码写好了,接下来要告诉CMake怎么编译。打开 CMakeLists.txt,在文件末尾添加:

# 找到依赖包
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

# 添加可执行文件
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}
)

然后编译:

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

重要提醒:每次编译后别忘了 source 工作空间!我见过太多同学编译完直接运行,结果找不到节点。你可以把 source 命令加到 ~/.bashrc 里,省得每次都手动执行。

3.5 运行节点并使用 rqt_graph 可视化

打开两个终端,分别运行发布者和订阅者:

# 终端1:运行发布者
ros2 run cpp_topic_demo talker

# 终端2:运行订阅者
ros2 run cpp_topic_demo listener

你应该能看到订阅者终端不断打印收到的消息。接下来,我们打开rqt_graph看看节点通信的“心电图”:

# 在第三个终端运行
rqt_graph

你会看到一个漂亮的节点关系图。椭圆代表节点,矩形代表话题,箭头表示数据流向。从 talker 指向 /chatter,再从 /chatter 指向 listener,一目了然。

rqt_graph 小技巧:

  • 勾选 "Hide Debug" 可以隐藏系统内部节点,只看你的应用节点
  • 点击节点可以查看它的发布者和订阅者详情
  • 如果图太乱,可以拖动节点重新布局

3.6 自定义消息类型(msg)

实际项目中,我们很少直接用 std_msgs::String。比如你要发布一个机器人状态信息,包含位置、速度、电池电量等,就需要自定义消息类型。

在功能包目录下创建 msg/ 文件夹,新建 RobotStatus.msg:

# RobotStatus.msg
string robot_name
float32 x
float32 y
float32 velocity
float32 battery_level
bool is_charging

然后修改 CMakeLists.txt,添加消息生成相关配置:

# 在 find_package 部分添加
find_package(rosidl_default_generators REQUIRED)

# 添加消息文件
set(msg_files
    "msg/RobotStatus.msg"
)

rosidl_generate_interfaces(${PROJECT_NAME}
    ${msg_files}
    DEPENDENCIES std_msgs
)

# 确保依赖
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>

重新编译后,你就可以在代码中使用自定义消息了:

#include "cpp_topic_demo/msg/robot_status.hpp"

// 创建发布者
auto publisher = this->create_publisher<cpp_topic_demo::msg::RobotStatus>("/robot_status", 10);

// 创建消息并发布
auto msg = cpp_topic_demo::msg::RobotStatus();
msg.robot_name = "TurtleBot3";
msg.x = 1.5;
msg.y = 2.3;
msg.velocity = 0.5;
msg.battery_level = 85.0;
msg.is_charging = false;
publisher->publish(msg);

个人经验:定义消息类型时,字段命名最好用下划线风格(snake_case),这是ROS社区的约定。另外,尽量把相关字段放在同一个消息里,减少话题数量。我在做一个多传感器融合项目时,一开始每个传感器一个话题,结果rqt_graph图乱得像蜘蛛网,后来合并成几个复合消息,清爽多了。

3.7 本章小结

今天我们走完了ROS2通信的完整流程:从创建功能包、编写发布者和订阅者节点,到自定义消息类型,再到编译运行和可视化。这些是ROS2开发的基本功,后面的章节会在这个基础上不断扩展。

你想想看,掌握了发布-订阅模型,就等于拿到了ROS2的钥匙。不管是控制机器人移动、处理传感器数据,还是做SLAM建图,底层都是这套机制在支撑。

最后留个思考题:如果我想让一个节点同时发布多个话题,或者同时订阅多个话题,代码该怎么改?下一章我们会深入探讨这个话题。