4. 订阅者节点实现:编写Python订阅者节点、回调函数机制、接收并处理消息

好,咱们接着聊。上一节我们把发布者节点搭起来了,地图数据能发出去。但光发没人收,那不成单口相声了?这一节,我们就来写一个订阅者节点,让它把地图数据接住,再处理一下。

说白了,订阅者节点就是“听”的那一方。它不主动说话,但耳朵一直竖着。一旦有话题数据过来,它立刻响应。

4.1 订阅者节点的基本结构

先看一个最简的订阅者节点长什么样。我习惯先把骨架搭好,再往里填逻辑。

import rclpy
from rclpy.node import Node
from nav_msgs.msg import OccupancyGrid

class MapSubscriber(Node):
    def __init__(self):
        super().__init__('map_subscriber')
        self.subscription = self.create_subscription(
            OccupancyGrid,
            '/map',
            self.map_callback,
            10
        )
        self.subscription  # 防止被垃圾回收

    def map_callback(self, msg):
        self.get_logger().info('收到地图数据!')
        # 这里处理消息

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

if __name__ == '__main__':
    main()

嗯,代码不长,但每个点都很关键。你想想看,create_subscription 里四个参数:消息类型、话题名、回调函数、队列大小。跟发布者那边的 create_publisher 是对称的。

核心要点:订阅者不需要设置“发布频率”,它完全是被动的。数据来了就处理,没来就等着。这叫事件驱动。

4.2 回调函数机制——ROS2的“中断服务”

回调函数是订阅者的灵魂。我刚开始学ROS2时,总觉得回调函数有点“玄学”——它什么时候被调用?谁调用的?

其实很简单:当有消息到达话题时,ROS2的底层会自动调用你注册的那个函数。你不需要手动去轮询检查有没有新数据。

为什么会这样?因为 rclpy.spin(node) 这行代码在背后做了大量工作。它让节点进入一个循环,不断检查有没有新消息、新服务请求、新定时器触发。一旦有消息,它就把消息塞进回调函数里。

我的经验:回调函数里不要做耗时操作。比如写文件、发网络请求、复杂计算。我曾经在一个回调里做图像处理,结果地图数据一多,回调被阻塞,后面的消息全丢了。后来我把处理逻辑扔到另一个线程里,回调只负责“收快递”,不负责“拆快递”。

4.3 接收并处理地图消息

地图消息的类型是 OccupancyGrid。它里面有什么?我列一下常用字段:

字段 类型 说明
header std_msgs/Header 时间戳、坐标系ID
info MapMetaData 地图分辨率、宽高、原点坐标
data int8[] 栅格数据,-1未知,0空闲,100占用

收到地图后,我们一般会做这几件事:

  • 检查时间戳——地图是不是太旧了?
  • 解析info——知道地图有多大、每个格子对应现实世界多少米
  • 遍历data——把栅格数据转成我们能用的格式

看一个实际的处理示例:

def map_callback(self, msg):
    # 1. 检查地图是否有效
    if msg.info.width == 0 or msg.info.height == 0:
        self.get_logger().warn('收到空地图,跳过')
        return

    # 2. 提取地图参数
    resolution = msg.info.resolution  # 米/像素
    width = msg.info.width
    height = msg.info.height
    origin_x = msg.info.origin.position.x
    origin_y = msg.info.origin.position.y

    self.get_logger().info(
        f'地图尺寸: {width}x{height}, 分辨率: {resolution} m/px'
    )

    # 3. 处理栅格数据
    # 这里只做统计,实际项目中会做路径规划、障碍物提取等
    occupied = 0
    free = 0
    unknown = 0
    for cell in msg.data:
        if cell == 100:
            occupied += 1
        elif cell == 0:
            free += 1
        else:
            unknown += 1

    self.get_logger().info(
        f'占用: {occupied}, 空闲: {free}, 未知: {unknown}'
    )

注意:地图数据可能很大。一张 1000x1000 的地图,data 数组就有 100 万个元素。如果你在回调里做复杂循环,会严重影响节点响应速度。我建议:回调里只做轻量级处理,需要重计算的任务放到定时器或独立线程里。

4.4 订阅者节点的避坑指南

写订阅者节点时,有几个坑我踩过,分享给你:

  • 队列大小别设太小——如果处理速度跟不上接收速度,队列满了会丢消息。地图数据一般设 10 就够,但高频数据(比如激光雷达)可能需要 100 以上。
  • 别忘了 self.subscription——Python 的垃圾回收机制可能会把 subscription 对象回收掉。加一行 self.subscription 就是为了防止这个。
  • 回调函数不要抛异常——一旦回调里抛出未捕获的异常,整个节点会挂掉。我习惯在回调里加 try-except,至少保证节点不崩。

4.5 知识体系总览

下面这张图把订阅者节点的核心逻辑串起来了。你可以看到,从话题数据流入,到回调函数触发,再到消息处理,是一条清晰的链路。

订阅者节点核心流程 话题 /map OccupancyGrid 消息 create_subscription 注册回调函数 rclpy.spin() 等待消息到达 回调函数被调用 map_callback(msg) 消息处理 解析/存储/转发 日志/可视化/决策 get_logger().info() 注:整个过程由 rclpy.spin() 驱动,无需手动轮询

嗯,到这里,订阅者节点的核心内容就讲完了。你可能会问:回调函数里能不能修改全局变量?当然可以,但要注意线程安全。ROS2 的回调默认是在同一个线程里执行的,所以一般没问题。但如果你用了多线程执行器,那就得加锁了。

我个人习惯是:订阅者节点只做数据接收和初步解析,真正的业务逻辑(比如导航、建图)放到单独的模块里。这样职责清晰,调试也方便。

一句话总结:订阅者节点 = 话题监听 + 回调函数 + 消息处理。回调是核心,别让它变重。

好,这一节就到这里。代码你可以直接拿去跑,把发布者节点和订阅者节点一起启动,看看地图数据能不能顺利传过来。如果遇到问题,检查一下话题名是否匹配、消息类型是否一致——这两个是新手最容易翻车的地方。