4 · 编写第一个订阅者节点

上一章我们写了个发布者,像个广播电台。这一章我们来写接收端——订阅者节点。说白了,就是让机器人“听到”话题上的消息,然后干点实事。

我个人习惯先把订阅者的骨架搭出来,再填回调逻辑。你想想看,ROS2里订阅者其实就三件事:创建订阅、注册回调、等待消息。嗯,咱们一个一个拆开聊。

订阅者节点核心逻辑 ① 创建订阅 rclcpp::Subscription rclpy.Subscription 话题名 + 消息类型 + QoS ② 回调函数 void callback(msg) def callback(msg) 处理数据、打印、控制 ③ spin / executor rclcpp::spin() rclpy.spin() 多线程Executor 绑定 触发

4.1 C++ 订阅者:代码结构

先上 C++ 版本。我早期用 ROS1 的时候,回调函数总喜欢写一大坨逻辑,后来发现拆成小函数更清爽。来看一个标准订阅者:

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

class MySubscriber : public rclcpp::Node {
public:
    MySubscriber() : Node("string_subscriber") {
        subscription_ = this->create_subscription<std_msgs::msg::String>(
            "chatter", 10,
            std::bind(&MySubscriber::topic_callback, this, std::placeholders::_1));
    }

private:
    void topic_callback(const std_msgs::msg::String::SharedPtr msg) {
        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<MySubscriber>());
    rclcpp::shutdown();
    return 0;
}

看到没?create_subscription 里传了三个东西:话题名、队列深度、回调函数。队列深度 10 表示缓冲区最多存 10 条消息,如果处理不过来,旧消息会被丢掉。我在项目里遇到过队列设太大导致内存暴涨的情况,后来改成 10 就稳了。

关键点: 回调函数用 std::bind 绑定到成员函数。C++ 里必须显式传 this 指针,否则编译直接报错。Python 就没这个烦恼,但各有各的坑。

4.2 Python 订阅者:更简洁的写法

Python 版本读起来像伪代码,我个人觉得特别适合快速验证想法。

# subscriber_node.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class MySubscriber(Node):
    def __init__(self):
        super().__init__('string_subscriber')
        self.subscription = self.create_subscription(
            String,
            'chatter',
            self.topic_callback,
            10)

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

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

if __name__ == '__main__':
    main()

Python 的回调直接传函数名就行,不用 bind。但注意——回调里千万别做耗时操作,比如写文件、发网络请求。我曾经在回调里写了个日志落盘,结果 spin 卡死,整个节点跟死了一样。后来用异步 executor 才解决。

4.3 回调函数机制:到底怎么工作的?

回调函数不是被你的代码直接调用的,而是由 ROS2 的底层触发。每当有消息到达,DDS 层把数据塞进队列,然后 executor 从队列里取出消息,调用你注册的那个函数。

说白了,这就是个事件驱动模型。你写一个函数,告诉系统“有消息来了就叫我”,然后该干嘛干嘛。我刚开始学的时候总想在回调里 return 一个值,后来才明白——回调就是消费数据,不返回值。

特性C++ (rclcpp)Python (rclpy)
回调注册std::bind + 占位符直接传函数名
消息类型SharedPtr / unique_ptrPython 对象
队列深度int (QoS 的一部分)int (QoS 的一部分)
多线程支持MultiThreadedExecutorMultiThreadedExecutor

4.4 spin() 与 executor:节点的心脏

rclcpp::spin() 或者 rclpy.spin() 一调用,节点就“活”了。它会阻塞主线程,不断检查有没有新消息,有就调回调。你想想看,如果没有 spin,节点跑完 main 就直接退出了,谁还等你消息?

但 spin 是单线程的。如果你的回调里有阻塞操作(比如等待传感器数据),整个节点就卡住了。这时候可以用 MultiThreadedExecutor:

// C++ 多线程执行器
rclcpp::executors::MultiThreadedExecutor executor;
executor.add_node(node);
executor.spin();
# Python 多线程执行器
from rclpy.executors import MultiThreadedExecutor
executor = MultiThreadedExecutor()
executor.add_node(node)
executor.spin()

我个人习惯在复杂项目里直接用 MultiThreadedExecutor,省心。但注意,回调里如果有共享变量,记得加锁,不然数据竞争够你查一宿。

我曾经踩过的坑: 在回调里调用了 spin() 本身——死锁!spin 是递归的,千万别在回调里再 spin。另外,Python 的 spin 默认是单线程,如果回调里 sleep,整个节点就睡过去了。用 executor 可以避免。

4.5 接收并处理消息:实战小技巧

光打印消息太初级了。实际中你可能会做这些事:

  • 数据转换:把字符串转成控制指令,或者把点云数据滤波。
  • 状态机触发:收到特定消息后切换机器人行为。
  • 缓存最新消息:用成员变量保存最近一帧数据,供其他函数使用。

举个例子,我做过一个订阅激光扫描的节点,回调里只做一件事:把数据塞进环形缓冲区,然后通知主循环处理。这样回调执行得飞快,不会阻塞后续消息。

小建议: 回调里尽量只做轻量操作。如果必须处理大量数据,用 std::async 或 Python 的 threading 丢到后台线程。ROS2 的 executor 本身不保证回调线程安全,自己多留个心眼。

嗯,到这里你应该能写出一个能收消息的订阅者了。