ROS2 实战:话题、服务与动作

ROS2 的三种通信原语各有明确边界:话题用于流式数据、服务用于短同步调用、动作用于长耗时可取消任务。本文给出三者的工程写法、自定义消息设计原则、launch 与参数注入、调试工具链、性能分析与实时调优,以及单元测试与 CI 的落地方式,全部配可运行代码。

引言

知道 ROS2 有话题、服务、动作三种通信方式只是起点,真正的工程能力是判断「这个功能该用哪种」,以及写出的代码在负载下不退化。三种原语的边界很清楚:话题是单向流式、无应答;服务是双向、同步、短耗时;动作是双向、异步、可取消、带进度反馈。用错原语会带来连锁问题,比如用服务做路径规划,规划耗时 3 秒时调用方线程被阻塞,整条链路的心跳全部超时。

话题的工程难点在于「回调里能做什么」。默认执行器会把回调串行化,一个回调里的 sleep 或阻塞 IO 会拖垮整个节点。即使换了多线程执行器,回调里做动态内存分配也会引入不确定延迟。因此实时相关的话题回调必须遵守「无锁、无分配、无 IO」三原则。

动作的难点在于状态机的正确实现。一个动作服务器要处理目标接受/拒绝、执行中的周期反馈、取消请求、以及取消后如何安全回到可接受状态。很多实现只覆盖了 happy path,取消逻辑草草了事,现场遇到「取消后机械臂停在半空」就成了事故。

本文按「包结构 → 话题 → 服务 → 动作 → 接口设计 → launch → 调试 → 性能 → 测试」的顺序展开,每个小节都给出可直接编译运行的代码。示例基于 ROS2 Humble,代码在 Jazzy 上同样适用。

目录

  1. 工作空间与包结构
  2. 话题:发布订阅的正确写法
  3. 服务:同步调用的边界
  4. 动作:长耗时任务的标准模式
  5. 自定义消息与接口设计
  6. launch 文件与参数注入
  7. 调试工具链与常用命令
  8. 性能分析与实时调优
  9. 测试与 CI 落地

1. 工作空间与包结构

现代 ROS2 项目应使用 colcon 加 rosdep 管理依赖,包用 ament_cmake(C++)或 ament_python(Python)构建。

mkdir -p ~/ws_robot/src && cd ~/ws_robot
ros2 pkg create --build-type ament_cmake robot_control \
  --dependencies rclcpp std_msgs sensor_msgs \
  --node-name arm_controller
rosdep install --from-paths src --ignore-src -r -y
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=Release
source install/setup.bash

--symlink-install 让 Python 文件与 launch 文件以符号链接安装,改动无需重新构建,显著加快迭代。但 C++ 仍需重新编译,因此把参数与 launch 独立成包能减少编译等待。

包划分建议按「变化频率」而非「功能领域」:硬件驱动、算法、行为、工具四类分包,每类内部再按模块细分。硬件驱动包变更频繁(换传感器就要改),行为包变更也频繁,算法包相对稳定,工具包几乎不变。按变化频率分包能让 CI 只重建受影响的部分。

2. 话题:发布订阅的正确写法

一个生产级的话题订阅节点必须处理三件事:QoS 选择、回调内不做重活、以及时间戳校验。

#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/laser_scan.hpp>

class ScanFilter : public rclcpp::Node {
 public:
  ScanFilter() : Node("scan_filter") {
    // 传感器数据用 SensorDataQoS(BEST_EFFORT, KEEP_LAST 5)
    auto qos = rclcpp::SensorDataQoS();
    sub_ = create_subscription<sensor_msgs::msg::LaserScan>(
        "/scan", qos,
        [this](sensor_msgs::msg::LaserScan::ConstSharedPtr msg) {
          // 回调内只做拷贝与入队,不做滤波计算
          // 时间戳校验:丢弃过旧或来自未来的帧
          const auto now = this->now();
          const double age = (now - msg->header.stamp).seconds();
          if (age < 0.0 || age > 0.5) { ++stale_count_; return; }
          if (!ring_.try_push(msg)) { ++drop_count_; }
        });
    // 滤波在独立线程按自己的节奏消费
    worker_ = std::thread([this] { processLoop(); });
  }

 private:
  void processLoop() {
    while (rclcpp::ok()) {
      sensor_msgs::msg::LaserScan::ConstSharedPtr m;
      if (!ring_.try_pop(m)) { std::this_thread::sleep_for(1ms); continue; }
      doFilter(*m);
    }
  }
  // ... ring_ 为无锁 SPSC 队列,容量 16
};

发布端的关键是使用 std::make_unique 与进程内通信配合,避免不必要的拷贝:

auto msg = std::make_unique<geometry_msgs::msg::Twist>();
msg->linear.x = cmd.v;
pub_->publish(std::move(msg));   // 配合 use_intra_process_comms 可零拷贝

3. 服务:同步调用的边界

服务的语义是「一问一答」,调用方阻塞等待。因此它只适合毫秒级、无副作用争议的操作:查询状态、设置参数、触发标定。

// 服务端
auto srv = create_service<std_srvs::srv::SetBool>(
    "/arm/enable",
    [this](const std::shared_ptr<std_srvs::srv::SetBool::Request> req,
           std::shared_ptr<std_srvs::srv::SetBool::Response> res) {
      if (req->data && !safety_ok()) {
        res->success = false;
        res->message = "safety interlock active";
        return;
      }
      enabled_ = req->data;
      res->success = true;
      res->message = enabled_ ? "enabled" : "disabled";
    });
# 客户端:必须设置超时,否则对端挂掉会永久阻塞
import rclpy
from rclpy.node import Node
from std_srvs.srv import SetBool

class ArmClient(Node):
    def __init__(self):
        super().__init__('arm_client')
        self.cli = self.create_client(SetBool, '/arm/enable')
        self.cli.wait_for_service(timeout_sec=2.0)

    def enable(self):
        req = SetBool.Request()
        req.data = True
        fut = self.cli.call_async(req)
        # 关键:加超时,避免服务端异常时无限等待
        rclpy.spin_until_future_complete(self, fut, timeout_sec=1.0)
        if not fut.done():
            self.get_logger().error('enable service timeout')
            return False
        return fut.result().success

三条纪律:客户端必须设超时;服务端回调不能耗时(超过 100 ms 就该用动作);不要在服务回调里再调用另一个服务,链式同步调用极易死锁。

4. 动作:长耗时任务的标准模式

动作是 ROS2 里最复杂也最容易被实现错的机制。它的三层结构是:目标(goal)、反馈(feedback)、结果(result),并且支持取消。

#include <rclcpp_action/rclcpp_action.hpp>
#include <robot_interfaces/action/move_arm.hpp>

class MoveArmServer : public rclcpp::Node {
 public:
  using MoveArm = robot_interfaces::action::MoveArm;
  using GoalHandle = rclcpp_action::ServerGoalHandle<MoveArm>;

  MoveArmServer() : Node("move_arm_server") {
    using namespace std::placeholders;
    server_ = rclcpp_action::create_server<MoveArm>(
        this, "move_arm",
        std::bind(&MoveArmServer::onGoal, this, _1, _2),
        std::bind(&MoveArmServer::onCancel, this, _1),
        std::bind(&MoveArmServer::onAccepted, this, _1));
  }

 private:
  rclcpp_action::GoalResponse onGoal(
      const rclcpp_action::GoalUUID &,
      std::shared_ptr<const MoveArm::Goal> goal) {
    if (goal->target_joints.size() != 6) {
      return rclcpp_action::GoalResponse::REJECT;   // 参数非法,直接拒绝
    }
    if (busy_) {
      return rclcpp_action::GoalResponse::REJECT;   // 忙时拒绝而非排队
    }
    return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
  }

  rclcpp_action::CancelResponse onCancel(const std::shared_ptr<GoalHandle>) {
    cancel_requested_ = true;
    return rclcpp_action::CancelResponse::ACCEPT;   // 接受取消,执行线程负责减速停机
  }

  void onAccepted(const std::shared_ptr<GoalHandle> gh) {
    // 必须放到独立线程,否则阻塞执行器
    std::thread([this, gh] { execute(gh); }).detach();
  }

  void execute(const std::shared_ptr<GoalHandle> gh) {
    auto fb = std::make_shared<MoveArm::Feedback>();
    rclcpp::Rate r(20.0);   // 20 Hz 反馈
    while (rclcpp::ok()) {
      if (cancel_requested_) {
        auto res = std::make_shared<MoveArm::Result>();
        res->success = false; res->message = "cancelled";
        gh->canceled(res);    // 取消后必须调用 canceled,不能调 succeed
        return;
      }
      if (reachedTarget()) {
        auto res = std::make_shared<MoveArm::Result>();
        res->success = true; res->message = "done";
        gh->succeed(res);
        return;
      }
      fb->progress = computeProgress();
      gh->publish_feedback(fb);
      r.sleep();
    }
  }
};

三个易错点:onAccepted 里必须起线程(否则执行器被阻塞);取消后必须调 canceled 而不是 succeed;取消语义是「请求」而非「强制」,执行线程负责让机器人在安全的前提下停下。

5. 自定义消息与接口设计

接口定义在 .msg、.srv、.action 文件里,由 rosidl 生成代码。设计原则是「向后兼容优先」。

# robot_interfaces/msg/DetectedObject.msg
std_msgs/Header header
int32 id
string label
geometry_msgs/Pose pose
geometry_msgs/Vector3 dimensions
float32 confidence        # 0.0~1.0
float32[6] covariance     # 位姿协方差对角线

向后兼容的三条规则:只追加字段、不改变已有字段的类型与语义、不依赖字段顺序(用名称访问)。ROS2 的接口演进靠「类型哈希」检测不兼容:修改已有字段会让哈希变化,新旧节点无法通信,这正是想要的保护。

接口包应独立成 *_interfaces 包,只含 .msg/.srv/.action 与构建配置,不依赖任何算法包。这样下游可以只依赖接口包,避免拉入庞大的算法依赖树。生成时间也是考虑因素:一个含 30 个消息的接口包首次编译约 30 秒,把它与算法包分离能让算法改动不触发接口重新生成。

6. launch 文件与参数注入

launch 文件是部署的入口,应支持多环境(仿真、真机、测试)而不改代码。

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node, ComposableNodeContainer
from launch_ros.descriptions import ComposableNode

def generate_launch_description():
    use_sim = LaunchConfiguration('use_sim_time')
    params = PathJoinSubstitution(['/etc/robot', 'control.yaml'])

    return LaunchDescription([
        DeclareLaunchArgument('use_sim_time', default_value='false'),
        DeclareLaunchArgument('enable_perception', default_value='true'),

        ComposableNodeContainer(
            name='robot_container', namespace='',
            package='rclcpp_components', executable='component_container_mt',
            composable_node_descriptions=[
                ComposableNode(
                    package='robot_control', plugin='robot_control::ArmController',
                    name='arm_controller',
                    parameters=[params, {'use_sim_time': use_sim}],
                    extra_arguments=[{'use_intra_process_comms': True}]),
                ComposableNode(
                    package='perception', plugin='perception::Detector',
                    name='detector',
                    condition=IfCondition(LaunchConfiguration('enable_perception')),
                    parameters=[{'use_sim_time': use_sim}]),
            ]),
    ])

参数文件用 YAML,键名必须与节点的完全限定名一致,否则静默失效:

/arm_controller:
  ros__parameters:
    control_rate_hz: 500
    max_joint_velocity: 2.0
    use_sim_time: false
    joint_limits: [2.9, 2.9, 2.9, 2.9, 2.9, 2.9]

「参数没生效」的第一排查步骤是 ros2 param list /arm_controller,看参数是否存在;再看 ros2 param get 的值是否是你期望的。YAML 里节点名写错(比如用了相对名)是头号原因。

7. 调试工具链与常用命令

ROS2 的 CLI 工具覆盖面很广,掌握这十条能解决大部分问题:

ros2 node list                       # 有哪些节点
ros2 node info /arm_controller       # 某节点的订阅/发布/服务
ros2 topic list -t                   # 话题及其类型
ros2 topic info /scan --verbose      # QoS 与端点详情
ros2 topic hz /scan --window 50      # 实际频率
ros2 topic delay /scan               # 端到端延迟(需 header.stamp)
ros2 topic bw /points                # 带宽
ros2 interface show sensor_msgs/msg/PointCloud2
ros2 param dump /arm_controller      # 导出当前参数
ros2 doctor --report                 # 环境与网络诊断

ros2 topic delay 依赖消息的 header.stamp,如果驱动写的时间戳不对,这个命令会给出误导性结果(甚至负值)。先用它做粗筛,再在代码里打点做精确测量。

离线调试用 rosbag2:录制、回放、以及用 --topics 过滤,能大幅降低回放时的 CPU 压力。

ros2 bag record -s mcap -a -o session
ros2 bag info session.mcap
ros2 bag play session.mcap --clock --rate 0.5 --topics /scan /tf

8. 性能分析与实时调优

延迟要分段测量,否则无法归因。ROS2 提供了 tracetools,基于 LTTng 做端到端追踪。

# 启用追踪(需要在编译时开启 TRACETOOLS,默认发行版通常已开)
ros2 run tracetools_trace trace --session-name mytrace \
  --events ros2:* --path /tmp/traces
# 跑一段场景后停止,用 babeltrace 或 Trace Compass 分析
babeltrace /tmp/traces/mytrace | head -50

不用追踪时的轻量做法是在消息里塞时间戳并逐段打点:

// 在消息的 header 里放采集时刻,节点逐段记录处理耗时
const double t_in = now_seconds();
process(msg);
const double t_out = now_seconds();
hist_.record((t_out - t_in) * 1e6);   // 微秒
// 每 1000 次输出一次 p50/p99
if (hist_.count() % 1000 == 0) {
  RCLCPP_INFO(get_logger(), "p50=%.0fus p99=%.0fus max=%.0fus",
              hist_.p50(), hist_.p99(), hist_.max());
}

实时调优的三条硬性做法:回调内禁止动态分配(预分配缓冲区并复用);禁止在实时线程里加锁(用无锁数据结构 传递数据);禁止在实时线程里做日志输出(写无锁环形缓冲,非实时线程落盘)。这三条做到后,1 kHz 控制回路的抖动通常能从毫秒级降到 200 µs 以内。

9. 测试与 CI 落地

ROS2 的测试分三层:单元测试(纯函数、算法)、集成测试(多节点启动 + 话题断言)、以及仿真回归(场景回放)。

# launch_testing 示例:启动节点,断言话题有数据
import unittest
import launch_testing
import rclpy
from sensor_msgs.msg import LaserScan

@launch_testing.markers.keep_alive
def test_scan_published():
    rclpy.init()
    node = rclpy.create_node('test_scan')
    received = []
    node.create_subscription(LaserScan, '/scan',
                             lambda m: received.append(m), 10)
    deadline = node.get_clock().now().nanoseconds + 5_000_000_000
    while not received and node.get_clock().now().nanoseconds < deadline:
        rclpy.spin_once(node, timeout_sec=0.1)
    assert len(received) > 0, 'no scan received within 5s'
    assert len(received[0].ranges) == 360
    node.destroy_node()
    rclpy.shutdown()
# .github/workflows/ci.yml 的关键片段
- name: Build
  run: colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo
- name: Test
  run: colcon test --packages-select robot_control && colcon test-result --verbose
- name: Lint
  run: ament_lint_auto  # 或分别跑 ament_cpplint / ament_flake8 / ament_uncrustify

CI 里必须包含「启动完整 launch 文件并等 10 秒不崩溃」的冒烟测试。这类测试能抓住参数名拼错、依赖缺失、生命周期顺序错误等编译期发现不了的问题,投入产出比最高。

权衡取舍

需求话题服务动作
流式传感器数据首选不可用不可用
查询当前状态(<10 ms)可用首选过重
设置参数/触发标定不推荐(无应答)首选过重
移动到目标点(秒级)不推荐阻塞调用方首选
需要进度反馈需自建反馈话题不支持内置
需要取消不支持不支持内置
多消费者天然支持不支持不支持

选型口诀:单向流用话题,短问答用服务,长任务用动作。拿不准时优先话题加自定义反馈话题,它的解耦性最好,代价是要自己维护状态。

常见坑清单

  1. 服务客户端不设超时,服务端挂掉后调用方永久阻塞——所有 call_async 都配 timeout_sec。
  2. 动作的 onAccepted 里直接执行长循环,阻塞执行器导致其他回调饿死——必须起独立线程。
  3. 取消动作后调用 succeed,客户端认为任务成功——取消路径必须调 canceled。
  4. 参数 YAML 里节点名写错,参数静默不生效——启动后 ros2 param list 校验。
  5. 回调里做动态分配与日志输出,控制周期抖动到毫秒级——实时路径只写预分配缓冲。
  6. 修改已有消息字段导致类型哈希变化,新旧节点无法通信——只追加字段不改旧字段。
  7. 用服务做耗时规划,规划 3 秒期间心跳全超时——超过 100 ms 一律改用动作。
  8. 订阅者 QoS 比发布者更严格,静默收不到数据——ros2 topic info --verbose 比对。
  9. 回放时不加 --clock,节点用墙钟导致时间语义错乱——回放必须配 use_sim_time。
  10. CI 只跑单元测试,参数名错误上线才发现——必须有启动冒烟测试。

小结

ROS2 实战的核心是「原语选对 + 回调内保持轻量 + 接口向后兼容」。这三件事做好,系统在负载下的行为就是可预测的。三种通信原语的选择不是风格问题而是架构问题,选错会在负载上升时集中爆发。

下一步建议阅读 ROS2 架构 ,理解 QoS、执行器与 DDS 的底层机制,那样遇到「明明代码没错但收不到数据」时就有系统性的排查路径。涉及控制回路时,机器人部署、实时性与安全 会给出线程模型、优先级与安全回路的完整配置方法。

最后提醒一句:把所有调试命令写成脚本。ros2 topic info --verbose、ros2 param dump、ros2 doctor --report 的组合输出,比任何口头描述都更能快速定位问题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「机器人」更多文章

  1. 机器人实时控制与嵌入式
  2. ROS 2 通信与 QoS
  3. 足式与人形机器人运动控制