Files
2026-08-05 18:17:25 +08:00

18 KiB

20 · Topic 深度:pub/sub(完全指南)

目标:吃透 ROS2 pub/sub,涵盖消息定义、QoS、跨语言互通、常见坑,学完直接写工业级代码。


目录


1. 通信模型

1.1 一句话

Topic 是 异步、多对多、单向的发布订阅通道。

Publisher ──publish()──> Topic (chatter) ──callback(msg)──> Subscriber
        ────────────────────────────────────────────────────
        异步,非阻塞     多对多,单向       事件循环触发

1.2 三种通信对比

维度 Topic Service Action
同步 异步 同步(阻塞) 异步(long-running)
方向 单向 pub→sub 双向 req/resp 双向 goal/fb/result
一对多 一对一 一对一
取消 N/A
进度反馈 N/A
适合 传感器流、状态 短查询 长任务

1.3 何时用 Topic

适合:

  • 周期性传感器数据(相机、IMU、激光雷达、关节状态)
  • 状态发布(机器人位置、电池电量)
  • 持续监控数据(诊断、log)

不适合:

  • 一次性 req/resp(用 Service)
  • 长任务(用 Action)
  • 需要反馈进度(用 Action)

2. 消息类型

2.1 标准消息(ROS2 自带)

常用消息
std_msgs String, Bool, Int32, Float64, Header
geometry_msgs Point, Quaternion, Pose, Twist, Transform, Vector3
sensor_msgs Image, JointState, Imu, PointCloud2, CameraInfo, LaserScan
nav_msgs Odometry, Path, OccupancyGrid
trajectory_msgs JointTrajectory, JointTrajectoryPoint

2.2 消息结构示例

# sensor_msgs/msg/Image
msg = sensor_msgs.msg.Image()
msg.header.stamp = node.get_clock().now().to_msg()
msg.header.frame_id = "camera_optical_frame"
msg.height = 480
msg.width = 640
msg.encoding = "bgr8"             # OpenCV 默认
msg.is_bigendian = 0
msg.step = 640 * 3                # width * bytes_per_pixel
msg.data = bgr_array.tobytes()     # numpy → bytes

2.3 自定义消息(本仓库不用)

如果需要自定义消息:

  1. 在包内 msg/MyMsg.msg 定义字段
  2. package.xml<build_depend>rosidl_default_generators</build_depend> + <exec_depend>rosidl_default_runtime</exec_depend>
  3. CMakeLists.txtrosidl_generate_interfaces(...)
  4. colcon build 后 Python/C++ 自动生成类

本仓库只使用标准消息,简化学习曲线。


3. Publisher API(Python / C++)

⚠️ 本节用教学简化命名(Talker/Listener/talker_py/listener_py),只是为了讲解 API。 仓库真实节点是 chatter_publisher / chatter_subscriber,launch 重命名为 chatter_publisher_py / chatter_publisher_cpp 等。 看真实代码:src/py_pubsub/py_pubsub/publisher_node.pyChatterPublisher

3.1 Python

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

class Talker(Node):
    def __init__(self):
        super().__init__('talker_py')

        # 1) 声明参数
        self.declare_parameter('period_ms', 500)
        self.declare_parameter('topic', 'chatter')

        # 2) 取参数
        period = self.get_parameter('period_ms').value
        topic  = self.get_parameter('topic').value

        # 3) 创建 Publisher
        #    create_publisher(msg_type, topic, qos_depth)
        self.publisher_ = self.create_publisher(String, topic, 10)

        # 4) 创建定时器,周期性 publish
        self.timer_ = self.create_timer(period / 1000.0, self.timer_callback)

    def timer_callback(self):
        msg = String()
        msg.data = f'Hello, count={self.count}'
        self.publisher_.publish(msg)            # 异步,不等
        self.count += 1

def main():
    rclpy.init()
    node = Talker()
    try:
        rclpy.spin(node)                       # 进入事件循环
    except KeyboardInterrupt:
        pass
    node.destroy_node()
    rclpy.shutdown()

3.2 C++

#include <chrono>
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

using namespace std::chrono_literals;

class Talker : public rclcpp::Node {
public:
  Talker() : rclcpp::Node("talker_cpp"), count_(0) {
    // 1) 声明 + 读参数
    this->declare_parameter<int>("period_ms", 500);
    this->declare_parameter<std::string>("topic", "chatter");
    int period = this->get_parameter("period_ms").as_int();
    std::string topic = this->get_parameter("topic").as_string();

    // 2) 创建 Publisher
    publisher_ = this->create_publisher<std_msgs::msg::String>(topic, 10);

    // 3) 定时器
    timer_ = this->create_wall_timer(
      std::chrono::milliseconds(period),
      std::bind(&Talker::timer_callback, this));
  }

private:
  void timer_callback() {
    auto msg = std_msgs::msg::String();
    msg.data = "Hello from C++, seq=" + std::to_string(count_++);
    publisher_->publish(msg);
  }

  rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
  rclcpp::TimerBase::SharedPtr timer_;
  size_t count_;
};

int main(int argc, char * argv[]) {
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<Talker>());
  rclcpp::shutdown();
  return 0;
}

3.3 关键 API 速查

Python C++ 用途
create_publisher(MsgType, name, depth) create_publisher<MsgType>(name, depth) 创建 publisher
publisher.publish(msg) publisher->publish(msg) 异步发消息
publisher.get_subscription_count() 看有几个订阅者
destroy_publisher() 销毁

4. Subscriber API(Python / C++)

同 §3,本节用教学简化命名。真实代码:src/py_pubsub/py_pubsub/subscriber_node.pyChatterSubscriber

4.1 Python

class Listener(Node):
    def __init__(self):
        super().__init__('listener_py')
        self.declare_parameter('topic', 'chatter')
        topic = self.get_parameter('topic').value

        # create_subscription(msg_type, topic, callback, qos_depth)
        # callback 签名: callback(msg)
        self.subscription = self.create_subscription(
            String, topic, self.listener_callback, 10)

    def listener_callback(self, msg):
        self.get_logger().info(f'recv: "{msg.data}"')
        # 这里做处理:解析、入队、下发指令、可视化等

4.2 C++

class Listener : public rclcpp::Node {
public:
  Listener() : rclcpp::Node("listener_cpp") {
    this->declare_parameter<std::string>("topic", "chatter");
    std::string topic = this->get_parameter("topic").as_string();

    // create_subscription<T>(topic, depth, callback)
    // callback 签名: [](const T::SharedPtr msg) { ... }
    subscription_ = this->create_subscription<std_msgs::msg::String>(
      topic, 10,
      [this](const std_msgs::msg::String::SharedPtr msg) {
        RCLCPP_INFO(this->get_logger(), "recv: \"%s\"", msg->data.c_str());
      });
  }
private:
  rclcpp::Subscription<std_msgs::msg::String>::SharedPtr subscription_;
};

4.3 关键 API 速查

Python C++ 用途
create_subscription(MsgType, name, cb, depth) create_subscription<T>(name, depth, cb) 创建订阅者
callback 签名: cb(msg) [](const T::SharedPtr msg) { ... } 消息回调

4.4 重要原则

  • 回调里别阻塞:回调在主线程里跑,长时间阻塞会让 timer / 其他 callback 卡死
  • 回调里别抛异常:rclcpp 会捕获但仍可能崩
  • 复杂处理入队:把消息放到 queue,另起线程消费

5. QoS 详解(必备)

5.1 五个维度

维度 取值 默认 含义
Reliability RELIABLE / BEST_EFFORT RELIABLE 必须投递 / 丢一帧无所谓
History KEEP_LAST(N) / KEEP_ALL KEEP_LAST(10) 队列策略
Durability VOLATILE / TRANSIENT_LOCAL VOLATILE 晚订阅者是否收旧数据
Deadline Duration 最长多久发一次
Lifespan Duration 多旧的消息失效

5.2 常用组合

from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy

# 视频流:丢一帧没关系,要最新
sensor_qos = QoSProfile(
    reliability=ReliabilityPolicy.BEST_EFFORT,
    history=HistoryPolicy.KEEP_LAST,
    depth=1
)

# 控制指令:必须到达
control_qos = QoSProfile(
    reliability=ReliabilityPolicy.RELIABLE,
    history=HistoryPolicy.KEEP_LAST,
    depth=10
)

5.3 兼容规则(关键!)

Publisher Subscriber 结果
RELIABLE RELIABLE
BEST_EFFORT BEST_EFFORT
RELIABLE BEST_EFFORT (sub 不发 ACK,pub 报 QoS incompatible)
BEST_EFFORT RELIABLE (sub 容忍丢)

报错样例:

[WARN] ... New subscription discovered on this topic with incompatible QoS ...

怎么查: ros2 topic info /topic -v 看 pub/sub 各自 QoS。

5.4 本仓库 QoS 策略

所有 demo 都用默认 QoS(RELIABLE + KEEP_LAST(10))。

  • 优点:跨语言互通零障碍
  • 缺点:高频场景需调优

实战: 视频流改 BEST_EFFORT + depth=1;关节控制用 RELIABLE + depth=1


6. 跨语言互通(核心特性)

6.1 为什么能互通

ROS2 用 DDS 做底层,Python / C++ / 其他语言只是同一消息的不同"视图"。 消息定义在 .msg/.srv/.action 里,所有语言按这个定义自动生成代码

6.2 互通条件

  1. 消息类型一致(std_msgs/String 的 Python/C++ 字段名都是 data)
  2. Topic 名一致
  3. QoS 兼容
  4. ROS_DOMAIN_ID 一致(ROS_DOMAIN_ID 环境变量)

6.3 验证互通

# 启 4 节点(pubsub_launch.py)
ros2 launch bringup pubsub_launch.py

# 看 /chatter 的 pub/sub 列表
ros2 topic info /chatter -v

预期(实际节点名是 bringup/launch/pubsub_launch.pyname= 字段定的):

Publication count: 2
Subscription count: 2
  Node name: chatter_publisher_py     Node namespace: /
    Publisher count: 0
  Node name: chatter_subscriber_py    Node namespace: /
    Publisher count: 1
  Node name: chatter_publisher_cpp    Node namespace: /
    Publisher count: 0
  Node name: chatter_subscriber_cpp   Node namespace: /
    Publisher count: 1

看到 chatter_publisher_py + chatter_publisher_cpp 两个 publisher,chatter_subscriber_py + chatter_subscriber_cpp 两个 subscriber。

6.4 看跨语言消息流

# 终端 1
ros2 launch bringup pubsub_launch.py

# 终端 2
ros2 topic echo /chatter

# 预期:
# data: 'Hello from PY, seq=42'
# data: 'Hello from C++, seq=12'
# data: 'Hello from PY, seq=43'
# data: 'Hello from C++, seq=13'

Python talker 发 → C++ listener 收到 C++ talker 发 → Python listener 收到

端到端日志: docker/bringup_e2e.log


7. 完整实战:写一个传感器数据流

假设你做一个激光雷达节点:

7.1 Publisher(传感器端)

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
import random

class FakeLidar(Node):
    def __init__(self):
        super().__init__('fake_lidar')

        self.declare_parameter('rate_hz', 10)
        rate = self.get_parameter('rate_hz').value

        self.pub_ = self.create_publisher(
            LaserScan, '/scan', 10)
        self.timer_ = self.create_timer(1.0/rate, self.tick)

    def tick(self):
        scan = LaserScan()
        scan.header.stamp = self.get_clock().now().to_msg()
        scan.header.frame_id = 'laser_frame'
        scan.angle_min = -3.14159
        scan.angle_max = 3.14159
        scan.angle_increment = 0.01
        scan.time_increment = 0.0
        scan.range_min = 0.05
        scan.range_max = 30.0
        scan.ranges = [random.uniform(0.5, 5.0) for _ in range(629)]
        self.pub_.publish(scan)

7.2 Subscriber(消费端)

class LidarProcessor(Node):
    def __init__(self):
        super().__init__('lidar_processor')
        self.sub_ = self.create_subscription(
            LaserScan, '/scan', self.cb, 10)

    def cb(self, msg):
        # 找最近障碍
        nearest = min(msg.ranges)
        self.get_logger().info(f'nearest obstacle: {nearest:.2f}m')
        # 这里可以做:避障规划、点云处理、可视化

7.3 完整 run

# 终端 1:传感器
ros2 run my_pkg fake_lidar

# 终端 2:处理
ros2 run my_pkg lidar_processor

# 终端 3:验证
ros2 topic hz /scan         # 10 Hz
ros2 topic echo /scan       # 看数据(不要全打印,会很乱)

8. 调试命令大全

# 列所有 topic
ros2 topic list

# 看 topic 元数据(类型 + pub/sub + QoS)
ros2 topic info /chatter -v

# 实时打印消息内容
ros2 topic echo /chatter

# 测频率(平均 / 最小 / 最大 / 标准差)
ros2 topic hz /chatter

# 测带宽(每秒多少 KB)
ros2 topic bw /chatter

# 发一条测试消息
ros2 topic pub /chatter std_msgs/String "{data: 'hello'}" --once

# 录包
ros2 bag record /chatter -o my_bag

# 录包回放
ros2 bag play my_bag

# 看当前所有节点
ros2 node list

# 看节点发布的 topic(实际节点名 chatter_publisher_py / chatter_publisher_cpp)
ros2 node info /chatter_publisher_py

9. 常见坑 + 解决方案

9.1 收不到消息(最常见)

症状: ros2 topic echo 没输出,但 ros2 topic list 看得到。

排查步骤:

# 1) 看 pub/sub 列表
ros2 topic info /topic -v

# 2) 看 QoS 是否兼容
#    如果 QoS incompatible,WARN 日志会打印

# 3) 看节点是否活着
ros2 node list

常见原因:

  • 节点名重复: Node 'x' already exists → 改名或加 namespace
  • Topic 名拼写错误:大小写、/ 区分
  • QoS 不兼容:改成 default 或两边匹配
  • 消息类型不匹配:Publisher 是 std_msgs/String,Subscriber 是 std_msgs/Int32 → 静默不匹配
  • Publisher 还没起来:等 1-2s DDS discovery

9.2 Docker 内 localhost 不互通

症状: 两个容器里节点互相看不到。

解决: 用 network_mode: host(本仓库已配),或 ROS_STATIC_PEERS 单播。

9.3 DDS QoS 兼容性错

报错:

[WARN] ... Incompatible QoS ... (PolicyKind=RMW_QOS_POLICY_RELIABILITY)

解决: 双方 Reliability 一致。

from rclpy.qos import QoSProfile, ReliabilityPolicy
qos = QoSProfile(reliability=ReliabilityPolicy.BEST_EFFORT, depth=1)
self.pub_ = self.create_publisher(String, 'topic', qos)

9.4 Cyclone DDS 没装导致 build 失败

CMake 错误:

Could not find ROS middleware implementation 'rmw_cyclonedds_cpp'

解决:

  • 不要设 RMW_IMPLEMENTATION=rmw_cyclonedds_cpp(本仓库默认 fastdds)
  • 切 RMW 时务必 rm -rf build/ install/ log/

9.5 Callback 阻塞导致节点"卡死"

症状: 节点启动后什么都不做,其他 timer / callback 也不响应。

原因: callback 里跑同步阻塞代码(long I/O、time.sleep)。

解决: 用 MultiThreadedExecutor,callback 里只入队 + 另起线程处理。

from rclpy.executors import MultiThreadedExecutor
exec_ = MultiThreadedExecutor(num_threads=4)
exec_.add_node(node)
exec_.spin()

10. 在本仓库里跑

10.1 启动 4 节点跨语言 demo

docker exec ros2_dev bash -lc "cd /root/ros2_ws && source install/setup.bash && ros2 launch bringup pubsub_launch.py"

10.2 源码位置

10.3 端到端日志

docker/bringup_e2e.log — 跨语言互通验证。


11. 进阶:可靠通信 / 录制 / 跨机

11.1 Reliable 通信设置

# 默认就是 RELIABLE,显式写:
from rclpy.qos import QoSProfile, ReliabilityPolicy

qos = QoSProfile(
    reliability=ReliabilityPolicy.RELIABLE,
    history=HistoryPolicy.KEEP_LAST,
    depth=10
)

11.2 录制 + 回放(bag)

ros2 bag record -o my_bag /chatter /tf /joint_states
# 输出: my_bag_0.db3 + metadata.yaml

ros2 bag play my_bag --loop     # --loop 循环回放

ros2 bag info my_bag           # 看消息数 / 时间 / 类型

注意: 回放时,topic 真实 pub 也要在,否则 recorder 找不到对应 publisher。bag 不会保存节点,只保存消息。

11.3 跨机 DDS

默认走 UDP multicast,同网段自动发现。

跨子网: 用 unicast discovery。

PC:

export ROS_DISCOVERY_SERVER=192.168.1.20:11811

RDK X5: 启 discovery server:

ros2 run discovery_server discovery_server --address 0.0.0.0 --port 11811

跨 LAN + 防火墙: 用 ROS_STATIC_PEERS 静态发现。

详见 doc/100-embedded-deployment.md §5。


接下来读

主题 文档
Service 深度 30-services.md
Action 深度 40-actions.md
TF2 坐标变换 50-tf2.md
三机部署 100-embedded-deployment.md
具身智能路径 99-embodied-ai.md


📖 阅读路径导航

💡 这是仓库 doc/ 下所有文档的推荐阅读顺序。返回 README 总导航

本文预计阅读时间: 60 分钟 📍 当前位置: 第 11 / 24 篇