4 · 编写第一个订阅者节点
上一章我们写了个发布者,像个广播电台。这一章我们来写接收端——订阅者节点。说白了,就是让机器人“听到”话题上的消息,然后干点实事。
我个人习惯先把订阅者的骨架搭出来,再填回调逻辑。你想想看,ROS2里订阅者其实就三件事:创建订阅、注册回调、等待消息。嗯,咱们一个一个拆开聊。
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_ptr | Python 对象 |
| 队列深度 | int (QoS 的一部分) | int (QoS 的一部分) |
| 多线程支持 | MultiThreadedExecutor | MultiThreadedExecutor |
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 本身不保证回调线程安全,自己多留个心眼。
嗯,到这里你应该能写出一个能收消息的订阅者了。