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瓶颈

我个人的调试流程是这样的:

  1. 先跑ros2 node list,确认两个节点都在。
  2. 再跑ros2 topic list,看话题有没有出现。
  3. 然后ros2 topic info /chatter,看发布者和订阅者数量是不是1和1。
  4. 最后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的完整工作流程串起来了。从节点创建到消息收发,再到调试工具的使用,一目了然。

Publisher与Subscriber通信流程 Publisher Node simple_publisher create_publisher() create_wall_timer() publish(message) /chatter std_msgs/String Subscriber Node simple_subscriber create_subscription() topic_callback() RCLCPP_INFO() 调试工具链 ros2 topic list ros2 topic echo ros2 topic info ros2 topic hz 排查顺序:list → info → echo → hz → bw

这张图里,左侧是发布者的三个关键步骤:创建发布器、创建定时器、发布消息。右侧是订阅者的三个步骤:创建订阅器、回调处理、打印日志。中间的话题是桥梁。底部是调试工具链,按顺序排查基本能解决90%的问题。

3.7 小结

Publisher和Subscriber的编写本身不难,难的是写出能稳定运行、方便调试的代码。记住几个关键点:队列深度别贪大、回调里别存指针、话题名必须一致、调试先用命令行。把这些基本功练扎实了,后面学服务、动作这些高级通信模式就会轻松很多。