4、Python客户端库(rclpy)实战:编写发布者与订阅者节点,理解spin与回调机制

好,咱们今天来点真家伙。

前面几章我们把ROS2的架构、通信原理、还有rclpy的基本概念都捋了一遍。说实话,光看理论容易犯困。我当年学的时候也是这样,看了一堆文档,结果一写代码就懵——"这个spin到底是个啥?回调函数怎么老不执行?"

所以这一章,咱们直接上手写代码。你跟着我敲一遍,保证比看十遍文档都管用。

4.1 一个最简单的发布者节点

先来个最基础的——发布"Hello ROS2"字符串消息。

我个人习惯,写任何节点之前,先想清楚三件事:

  • 我要发什么类型的数据?——这里用 std_msgs.msg.String
  • 话题名叫什么?——就叫 /hello_topic
  • 多久发一次?——每秒2次,也就是0.5秒间隔
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

class HelloPublisher(Node):
    def __init__(self):
        super().__init__('hello_publisher')
        self.publisher_ = self.create_publisher(String, '/hello_topic', 10)
        self.timer = self.create_timer(0.5, 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 = HelloPublisher()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

代码不长,但有几个关键点我得跟你说道说道。

核心要点:

  • create_publisher 第三个参数是队列深度,我一般设10。设太小容易丢消息,设太大占内存。10是个比较稳妥的值。
  • create_timer 第一个参数是秒数,0.5就是500ms触发一次回调。
  • rclpy.spin(node) 这行是灵魂——它让节点一直活着,等待事件发生。

4.2 订阅者节点:把消息接住

有发的就得有收的。咱们再写一个订阅者节点,专门听 /hello_topic 上的消息。

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

class HelloSubscriber(Node):
    def __init__(self):
        super().__init__('hello_subscriber')
        self.subscription = self.create_subscription(
            String,
            '/hello_topic',
            self.listener_callback,
            10
        )
        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 = HelloSubscriber()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

你看,订阅者的结构和发布者几乎是对称的。区别就在于:

  • 发布者用 create_publisher + 定时器主动发
  • 订阅者用 create_subscription + 回调函数被动收

嗯,这里要注意:回调函数的参数 msg 就是接收到的消息对象。你可以在回调里做任何处理——打印、存数据库、甚至触发另一个动作。

4.3 spin到底在干什么?

这个问题,我当年纠结了整整两天。说白了,rclpy.spin(node) 就是一个死循环,它不停地做两件事:

  1. 检查有没有新消息到达——如果有,就调用对应的回调函数
  2. 检查定时器是否到期——如果到期,就触发定时器回调

你可以把它想象成一个前台服务员:

  • 客人(消息)来了,他就喊一声(触发回调)
  • 闹钟(定时器)响了,他也喊一声(触发定时器回调)
  • 没事情做的时候,他就站在那儿等着(阻塞)

小技巧: 如果你不想让spin阻塞整个程序,可以用 rclpy.spin_once(node) 或者把spin放到单独的线程里。我在做多传感器融合项目时,经常把不同传感器的节点放在不同线程里spin。

4.4 回调机制:你写代码,ROS2帮你调用

回调(callback)是ROS2里最核心的编程模式。你想想看,你不需要写一个while循环去轮询有没有新消息——你只需要注册一个回调函数,ROS2会在合适的时候自动调用它。

这就是所谓的"事件驱动"编程。

我曾经犯过一个低级错误:在回调函数里写了一个死循环,结果整个节点卡死了。为什么?因为回调函数是在spin的线程里执行的,你把它堵住了,spin就没办法处理其他事件了。

警告: 回调函数里不要做耗时操作!如果确实需要处理大量数据,请用多线程或者异步方式。否则你的节点会变得非常"迟钝"。

4.5 运行起来看看效果

好,代码写完了,咱们跑一下。

打开两个终端:

# 终端1:运行发布者
ros2 run your_package hello_publisher

# 终端2:运行订阅者
ros2 run your_package hello_subscriber

你应该会看到:

[INFO] [hello_publisher]: 发布了: Hello ROS2!
[INFO] [hello_subscriber]: 收到了: Hello ROS2!
[INFO] [hello_publisher]: 发布了: Hello ROS2!
[INFO] [hello_subscriber]: 收到了: Hello ROS2!
...

每秒两条,整整齐齐。看到这个输出,说明你的第一个ROS2节点对已经跑通了。

4.6 避坑指南:我踩过的几个坑

常见问题 原因 解决办法
节点启动后没反应 忘记调用 rclpy.spin() 加上spin,节点才能"活"起来
回调函数不执行 话题名拼写不一致 检查发布者和订阅者的话题名是否完全一样
程序卡死 回调里做了耗时操作 把耗时操作放到单独线程或异步执行
消息丢失 队列深度太小 增大 create_publisher 的队列参数

我曾经在调试一个机器人导航项目时,发现订阅者总是收不到部分消息。查了半天,原来是发布者的队列深度设成了1,消息一多就丢了。改成10之后,问题立刻解决。这种坑,踩过一次就记住了。

4.7 小结与思考

这一章我们做了三件事:

  • 写了一个发布者节点,用定时器周期性地发消息
  • 写了一个订阅者节点,用回调函数接收消息
  • 理解了spin的本质——一个等待事件的事件循环

说实话,ROS2编程的80%工作,就是在跟发布者、订阅者、回调函数打交道。你把这套模式玩熟了,后面学服务、动作、参数,都会轻松很多。

下一章,我们会聊聊更复杂的消息类型——比如如何发布图像数据、激光雷达数据。到时候你会发现,核心逻辑跟今天讲的完全一样,只是消息结构变复杂了而已。

好,今天就到这儿。你自己动手把代码敲一遍,遇到问题随时翻翻上面的避坑指南。咱们下章见。