4、第一个ROS2节点:使用Python编写发布者(Publisher)、订阅者(Subscriber),理解rclpy生命周期

好,咱们开始动手写代码了。

前面几章我们把环境搭好了,概念也理清了。现在终于到了最激动人心的环节——写第一个ROS2节点。我个人习惯,学任何新框架,第一件事就是写一个发布者和订阅者。为什么?因为这是ROS2通信的基石,你搞懂了这两个,后面所有传感器驱动、控制指令、状态反馈,本质上都是这套模式的变体。

4.1 先聊聊rclpy的生命周期

写代码之前,得先理解rclpy是怎么管理节点的。说白了,一个节点从出生到销毁,经历了几个固定的阶段。

rclpy的生命周期其实不复杂,就三步:

  1. 初始化:rclpy.init() 这行代码一跑,ROS2的通信层就启动了。我刚开始学的时候,经常忘记写这行,结果节点跑起来啥反应都没有,卡了半天才发现。
  2. 创建节点:Node类实例化,给节点起个名字。这个名字在整个ROS2网络里是唯一的标识。
  3. 自旋(spin):这是最核心的一步。spin() 会让节点一直活着,不断处理回调函数、接收消息、执行定时器。一旦spin退出,节点就挂了。

核心要点:rclpy.spin() 是一个阻塞调用。它会一直运行,直到你按 Ctrl+C 或者节点被销毁。很多新手以为spin是后台运行的,其实不是,它占着主线程呢。

嗯,这里要注意:ROS2节点的生命周期和Python脚本的生命周期是绑定的。脚本结束了,节点也就没了。所以我们的发布者/订阅者程序,通常都会在最后调用 rclpy.shutdown() 来优雅地关闭。

4.2 写一个发布者(Publisher)

发布者,说白了就是“说话的人”。它定期往外扔消息,谁爱听谁听。

我记得第一次写发布者时,犯了个低级错误——忘了设置消息类型。结果编译通过了,运行时却报类型不匹配。你想想看,ROS2是强类型的,消息类型必须严格对应。

下面是一个标准的发布者代码,我习惯把它叫做 talker.py

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class TalkerNode(Node):
    def __init__(self):
        super().__init__('talker_node')
        self.publisher_ = self.create_publisher(String, 'chatter', 10)
        self.timer = self.create_timer(1.0, self.timer_callback)
        self.get_logger().info('发布者节点已启动!')

    def timer_callback(self):
        msg = String()
        msg.data = 'Hello, ROS2!'
        self.publisher_.publish(msg)
        self.get_logger().info(f'发布了消息: {msg.data}')

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

if __name__ == '__main__':
    main()

这段代码里,有几个关键点我得拎出来说说:

  • create_publisher:三个参数——消息类型、话题名称、队列大小。队列大小设为10,意思是如果订阅者处理不过来,最多缓存10条消息。我曾经在项目中把队列设成1,结果高频传感器数据疯狂丢包,教训深刻。
  • create_timer:定时器,每隔1秒触发一次回调。这个定时器是ROS2节点自带的,不是Python的time.sleep()。用定时器的好处是,它和spin是协同工作的,不会阻塞主循环。
  • get_logger:日志输出。我建议你养成习惯,关键节点都打日志,调试时能救命。

我的小技巧:发布者的话题名称最好用有意义的命名,比如 /sensor/imu/data 而不是 /data。这样在大型系统中,别人一看就知道这个话题是干嘛的。

4.3 写一个订阅者(Subscriber)

订阅者就是“听话的人”。它一直监听某个话题,一旦有消息进来,就触发回调函数处理。

我刚开始做传感器驱动时,订阅者回调里写了个耗时的图像处理算法,结果导致消息处理不过来,系统越来越卡。后来才明白,回调函数里不能做耗时操作,得用多线程或者异步处理。

来看订阅者的代码,我管它叫 listener.py

import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class ListenerNode(Node):
    def __init__(self):
        super().__init__('listener_node')
        self.subscription = self.create_subscription(
            String,
            'chatter',
            self.listener_callback,
            10
        )
        self.subscription  # 防止被垃圾回收
        self.get_logger().info('订阅者节点已启动!')

    def listener_callback(self, msg):
        self.get_logger().info(f'收到消息: {msg.data}')

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

if __name__ == '__main__':
    main()

这段代码和发布者很像,但有几个不同点:

  • create_subscription:参数顺序是——消息类型、话题名称、回调函数、队列大小。注意,这里没有定时器,因为订阅者是“被动”的,有消息来才干活。
  • self.subscription:这行赋值看起来多余,其实是为了防止Python的垃圾回收把订阅者对象给回收了。我踩过这个坑,不加这行,订阅者跑着跑着就“失聪”了。
  • 回调函数:listener_callback 接收一个消息对象,你可以直接访问它的字段。比如 String 类型就有 .data 字段。

避坑指南:我曾经在回调函数里直接修改了消息对象,结果导致后续订阅者收到的是修改后的数据。ROS2的消息对象在回调中是只读的,千万别手欠去改它。

4.4 运行起来看看效果

代码写好了,怎么跑?很简单,开两个终端:

  • 终端1:运行发布者 ros2 run your_package talker
  • 终端2:运行订阅者 ros2 run your_package listener

你会看到发布者每隔1秒打印一条“发布了消息: Hello, ROS2!”,订阅者同步打印“收到消息: Hello, ROS2!”。

如果订阅者没收到消息,别慌。先检查话题名称是否一致。我遇到过最蠢的错误——发布者用 chatter,订阅者用 chatter2,当然收不到。用 ros2 topic list 命令可以查看当前所有活跃的话题,非常实用。

4.5 理解背后的生命周期

现在回头看看,整个流程其实很清晰:

  1. rclpy.init():初始化ROS2客户端库,建立通信上下文。
  2. Node实例化:创建节点,注册到ROS2网络中。
  3. 创建发布者/订阅者:在节点内部创建通信端点。
  4. rclpy.spin():进入事件循环,处理定时器、回调、消息。
  5. 销毁节点并shutdown:释放资源,优雅退出。

你想想看,这个生命周期是不是很像嵌入式系统的main loop?初始化硬件、进入主循环、处理中断、退出清理。ROS2的设计哲学其实借鉴了很多嵌入式系统的经验。

总结一下:发布者和订阅者是ROS2通信的原子操作。搞懂了它们,你就掌握了ROS2的“呼吸”和“心跳”。后面所有的传感器驱动、控制指令、状态反馈,都是在这个基础上叠加的。

下一章,我们会把传感器数据塞进这个消息里,让发布者真正“发布”有用的数据。嗯,到时候你会看到,传感器驱动其实就是把硬件数据包装成ROS2消息,然后扔到话题上。就这么简单。