3、ROS2通信机制实战(一):使用C++编写发布者与订阅者节点、自定义消息类型(msg)、编译与运行、使用rqt_graph可视化节点通信
各位同学,欢迎来到ROS2通信机制实战的第一部分。说实话,通信机制是ROS2的魂。你想想看,机器人身上那么多传感器、执行器、算法模块,它们之间怎么协同工作?靠的就是这套发布-订阅模型。今天我们就从最基础的开始,手把手写一个发布者和订阅者节点,再聊聊自定义消息类型,最后用rqt_graph看看节点之间到底是怎么“聊天”的。
3.1 理解发布者与订阅者模型
发布者-订阅者模型,说白了就是一个“广播”模式。发布者只管往外发消息,它不关心谁在收;订阅者只管收消息,它不关心谁在发。这种解耦设计,在机器人系统里特别实用。
我个人习惯把发布者想象成电台,订阅者就是收音机。电台播它的,收音机收它的,互不干扰。你可以在同一个话题(Topic)上挂多个发布者,也可以挂多个订阅者,ROS2的中间件(DDS)会帮你处理好一切。
核心概念:
- 话题(Topic):数据通道的名称,比如 /cmd_vel、/odom
- 消息(Message):数据的结构定义,比如 Twist、Odometry
- 发布者(Publisher):向话题发送消息的节点
- 订阅者(Subscriber):从话题接收消息的节点
下面这张图展示了我们今天要搭建的通信结构:
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建图,底层都是这套机制在支撑。
最后留个思考题:如果我想让一个节点同时发布多个话题,或者同时订阅多个话题,代码该怎么改?下一章我们会深入探讨这个话题。