3、话题通信实战:Publisher与Subscriber的编写与调试技巧
聊完了理论,咱们得真刀真枪干一场了。话题通信是ROS2里最常用的模式,说白了就是一个节点发消息,另一个节点收消息。我刚开始学的时候,总觉得写个发布者和订阅者很简单,结果一跑起来各种问题——节点找不到、消息对不上、频率乱跳。今天我就把实战中积累的经验掰开揉碎讲给你听。
核心要点:Publisher只管发,Subscriber只管收,两者通过话题名称和消息类型匹配。写代码不难,难的是写出健壮、可调试的代码。
3.1 编写一个标准的Publisher节点
先看一个最基础的发布者。我用的是C++,Python写法类似,但C++在性能上更稳。我个人习惯用C++写底层通信节点,Python写测试脚本。
// publisher_node.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
class SimplePublisher : public rclcpp::Node
{
public:
SimplePublisher() : Node("simple_publisher")
{
publisher_ = this->create_publisher<std_msgs::msg::String>("chatter", 10);
timer_ = this->create_wall_timer(
std::chrono::milliseconds(500),
std::bind(&SimplePublisher::timer_callback, this));
}
private:
void timer_callback()
{
auto message = std_msgs::msg::String();
message.data = "Hello, ROS2! " + std::to_string(count_++);
RCLCPP_INFO(this->get_logger(), "Publishing: '%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<SimplePublisher>());
rclcpp::shutdown();
return 0;
}
这段代码里有个细节:create_publisher的第二个参数是队列深度。我见过很多人设成1000,觉得越大越好。其实不然——队列太深会导致内存暴涨,而且消息积压后实时性反而变差。一般场景10~50就够了。
我的经验:发布频率高的节点(比如100Hz以上),队列深度设10以内。低频状态消息(比如1Hz),设10~20。别超过100,除非你明确知道自己在做什么。
3.2 编写一个标准的Subscriber节点
订阅者更简单,但坑也不少。来看代码:
// subscriber_node.cpp
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
class SimpleSubscriber : public rclcpp::Node
{
public:
SimpleSubscriber() : Node("simple_subscriber")
{
subscription_ = this->create_subscription<std_msgs::msg::String>(
"chatter", 10,
std::bind(&SimpleSubscriber::topic_callback, this, std::placeholders::_1));
}
private:
void topic_callback(const std_msgs::msg::String::SharedPtr msg)
{
RCLCPP_INFO(this->get_logger(), "I heard: '%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<SimpleSubscriber>());
rclcpp::shutdown();
return 0;
}
注意回调函数的参数类型——std_msgs::msg::String::SharedPtr。这是智能指针,你不需要手动释放内存。但有个问题:如果你在回调里把消息存起来,一定要拷贝一份,不能只存指针。因为消息对象在回调结束后可能被回收。
我曾经踩过的坑:在回调里把msg指针存到成员变量里,想着后面再用。结果程序跑着跑着就段错误了。后来才明白,ROS2的消息回调默认是零拷贝传递,指针指向的内存随时可能被覆盖。正确的做法是:stored_msg_ = *msg;,深拷贝一份。
3.3 编译与运行:CMakeLists.txt的写法
代码写好了,怎么编译?ROS2用ament_cmake,和传统CMake有点区别。我直接给你一个能用的模板:
cmake_minimum_required(VERSION 3.8)
project(my_publisher_subscriber)
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
add_executable(publisher_node src/publisher_node.cpp)
ament_target_dependencies(publisher_node rclcpp std_msgs)
add_executable(subscriber_node src/subscriber_node.cpp)
ament_target_dependencies(subscriber_node rclcpp std_msgs)
install(TARGETS
publisher_node
subscriber_node
DESTINATION lib/${PROJECT_NAME})
ament_package()
编译命令很简单:
cd ~/ros2_ws
colcon build --packages-select my_publisher_subscriber
source install/setup.bash
运行的时候开两个终端:
# 终端1
ros2 run my_publisher_subscriber publisher_node
# 终端2
ros2 run my_publisher_subscriber subscriber_node
如果一切正常,你会看到订阅者终端不断打印收到的消息。嗯,这里要注意:如果没看到输出,先检查话题名称是否一致。我遇到过太多次因为拼写错误导致节点之间“失联”的情况。
3.4 调试技巧:用命令行工具快速定位问题
代码跑不起来?别急着改代码,先用ROS2自带的命令行工具诊断一下。这些工具比GDB快多了。
| 命令 | 作用 | 使用场景 |
|---|---|---|
ros2 node list |
列出所有活跃节点 | 检查节点是否启动成功 |
ros2 topic list |
列出所有话题 | 确认话题是否被创建 |
ros2 topic echo /chatter |
实时打印话题消息 | 查看发布者是否在发数据 |
ros2 topic info /chatter |
显示话题的发布者和订阅者数量 | 检查通信是否建立 |
ros2 topic hz /chatter |
统计发布频率 | 验证发布周期是否符合预期 |
ros2 topic bw /chatter |
统计带宽占用 | 排查网络或CPU瓶颈 |
我个人的调试流程是这样的:
- 先跑
ros2 node list,确认两个节点都在。 - 再跑
ros2 topic list,看话题有没有出现。 - 然后
ros2 topic info /chatter,看发布者和订阅者数量是不是1和1。 - 最后
ros2 topic echo /chatter,看消息内容对不对。
如果第3步显示发布者或订阅者为0,说明节点没连上。最常见的原因是话题名称拼写不一致,或者节点跑在不同的DDS域里。
一个小技巧:用ros2 topic echo的时候,加上--once参数可以只打印一条消息就退出,适合快速验证。比如ros2 topic echo /chatter --once。
3.5 常见问题与避坑指南
实战中总会遇到各种奇怪的问题。我把最常见的几个列出来,你遇到了可以直接对照排查。
- 节点启动后立刻退出:检查
rclcpp::spin()有没有写。没有spin,节点跑完构造函数就退出了。 - 订阅者收不到消息:先确认话题名称完全一致(大小写敏感)。再用
ros2 topic echo验证发布者确实在发数据。 - 消息频率不对:用
ros2 topic hz看实际频率。如果低于预期,检查定时器周期设置,或者回调里有没有阻塞操作。 - 编译报错找不到头文件:检查CMakeLists.txt里有没有
find_package对应的依赖包。比如用了std_msgs就必须find_package(std_msgs REQUIRED)。 - 运行时提示“node already exists”:节点名冲突了。每个节点在同一个进程中必须唯一,换个名字或者用
rclcpp::NodeOptions加命名空间。
我曾经遇到的一个诡异问题:两个节点在同一台机器上,话题名也一致,但就是收不到消息。折腾了半天,发现一个节点用的是rmw_fastrtps_cpp,另一个用的是rmw_cyclonedds_cpp。不同DDS实现之间默认不互通。解决办法:统一设置RMW_IMPLEMENTATION环境变量。
3.6 本章知识体系图
下面这张图把Publisher和Subscriber的完整工作流程串起来了。从节点创建到消息收发,再到调试工具的使用,一目了然。
这张图里,左侧是发布者的三个关键步骤:创建发布器、创建定时器、发布消息。右侧是订阅者的三个步骤:创建订阅器、回调处理、打印日志。中间的话题是桥梁。底部是调试工具链,按顺序排查基本能解决90%的问题。
3.7 小结
Publisher和Subscriber的编写本身不难,难的是写出能稳定运行、方便调试的代码。记住几个关键点:队列深度别贪大、回调里别存指针、话题名必须一致、调试先用命令行。把这些基本功练扎实了,后面学服务、动作这些高级通信模式就会轻松很多。