跳转至

第 2 章 通信机制与计算图

ROS 2 的通信体系建立在 DDS 之上,由四层结构承载:图模型定义节点间的逻辑关系,四原语(topic/service/action/parameter)提供数据流语义,QoS 策略控制传输行为,executor 决定回调调度方式。四层各自独立又彼此耦合——QoS 不兼容导致消息静默丢弃、executor 配置不当引发死锁、命名空间冲突使话题重名——是工程实践中最高频的通信故障来源。本章逐层展开,在关键处用最小示例说明机制。

1. 计算图模型与命名空间

ROS 2 的计算图(computation graph) 是一组节点(node)通过话题(topic)、服务(service)、动作(action)和参数(parameter)互联而成的有向图。图模型的核心要素:

  • 节点(node):一个进程内的最小功能单元,持有发布者、订阅者、服务与参数(本章「节点」展开)。一个进程可承载多个节点,典型场景一对一。
  • 话题(topic):节点间的数据通道,发布端(publisher)与订阅端(subscriber)通过话题名绑定,彼此不直接引用。
  • 服务(service):请求-响应模式,客户端(client)向服务端(service server)发起一次性调用。
  • 动作(action):长时间运行任务的目标-反馈-结果模式,适用于导航、抓取等可中断的过程。
  • 参数(parameter):节点持有的可读写配置项,通过参数服务远程访问。

图上的边只有这四类,每条边的两端都是节点,名字(话题名、服务名、动作名、参数名)就是边的标签——名字展开一致就连上,对不上就静默不连。一个典型系统的形状:

flowchart LR
    cam["节点 /robot_1/camera_driver"] -->|"topic /robot_1/image_raw"| det["节点 /robot_1/detector"]
    det -->|"topic /robot_1/detections"| nav["节点 /robot_1/navigator"]
    app["调用方节点"] -->|"action /robot_1/navigate_to_pose"| nav
    app -->|"service ~/reset_pose"| nav
    cli["ros2 CLI"] -.->|"parameter max_vel"| nav

1.1 命名空间与话题名

话题名、服务名、动作名与参数名共用同一套名字规则:名字由若干 token 用 / 连接,最后一个 token 是基名(basename),前面全部是命名空间(namespace)。/world/robot_1/sensor/camera/image_raw 的基名是 image_raw,命名空间是 /world/robot_1/sensor/camera。节点名是单个 token(不含 /),它占据命名空间的最后一层。

/world/robot_1/sensor/camera/image_raw
└─────── 命名空间 ───────┘└─ 基名 ─┘

三种写法决定展开结果。同一段代码在节点 my_node、命名空间 /my_ns 下运行时:

写法 类型 展开为 规则
camera/image_raw 相对名(relative name) /my_ns/camera/image_raw 拼上节点命名空间
/camera/image_raw 完全限定名(fully qualified name) /camera/image_raw 忽略节点命名空间,全图唯一
~/image_raw 私有名(private name) /my_ns/my_node/image_raw 拼上「命名空间 + 节点名」

私有名以 ~ 开头,展开结果已是绝对名,节点命名空间不会再叠加一次;~ 必须与后续 token 用 / 隔开(~/foo 合法,~foo 不合法)。

能写哪些字符由 DDS 侧倒推。ROS 2 名字最终映射成 DDS 主题名(话题加 rt/ 前缀;服务在 DDS 侧由请求/响应两个主题表示,前缀 rq/、rr/),因此展开后的 token 只允许字母、数字与下划线,且不能以数字开头;//、结尾的 /、连续下划线(foo__bar)都非法。相对名里可以用 {} 替换语法,展开后仍是这些字符。DDS 主题名上限 256 字符,命名空间层级越深,留给基名的余量越小。token 以 _ 开头(如 /foo/_debug)是隐藏名(hidden name),CLI 默认不列出,ros2 topic list --include-hidden-topics 才显示——查不到某个话题时,先确认它不是落在隐藏命名空间里。

命名机制解决的是「谁和谁连」。发布者与订阅者之间没有直接引用,只按展开后的完全限定名匹配,由此得到三个性质:

  • 解耦:换实现、换语言、加一个观察者,都不必改对端;名字对上就连,对不上就静默不连,不报错。
  • 复用:同一份节点代码用不同命名空间起多份,就是多台同型号机器人、多个相机、多条臂——代码里只写相对名,前缀在部署时给。
  • 分组:一个 launch 文件用 PushRosNamespace 给一组节点统一加前缀,比逐个改参数可靠(第 3 章「工程化工具链」)。

设置命名空间有三条途径:

## 代码内:创建节点时给 namespace,节点内所有相对名都跟随
node = rclpy.create_node('camera_driver', namespace='/robot_1')
## 命令行:__ns 是命名空间替换规则,对本进程创建的节点生效
ros2 run my_pkg camera_driver --ros-args -r __ns:=/robot_1
## launch:一组节点共用一个前缀
GroupAction([PushRosNamespace('robot_1'),
             Node(package='my_pkg', executable='camera_driver')])

私有名与命名空间同样作用于服务、动作与参数:节点内的相对名全部跟着前缀走,参数服务的名字绑定节点全名,ros2 param list /robot_1/camera_driver 才是它的参数。不跟随的是 TF frame——frame 名由 URDF 的 link 名与 robot_state_publisher 决定,命名空间不改写它;多机器人共用一棵 TF 树时,前缀要在模型侧加(frame_prefix),否则两台机器的 base_link 会撞名(第 5 章「TF2 与机器人建模」)。

多机时,命名空间与 domain 是两件事:

目标 手段 效果
区分同一网络内的两台同型号机器人 命名空间(/robot_1、/robot_2) 名字不冲突;发现与带宽仍共享
完全隔离两组节点(开发与演示、多租户) ROS_DOMAIN_ID 不同 domain 互不可见,不用改任何名字

命名空间不是网络隔离。同一 domain 内的机器仍通过 DDS 组播互相发现,ros2 topic list 会同时列出对方的话题,大流量话题照旧共享带宽;命名空间只保证名字不撞,不阻止彼此看见。反过来,ros2 topic list 看不到对端时,要查的是 domain、网卡选择与组播可达性(「ROS_DOMAIN_ID 与多机隔离」、第 3 章「工程化工具链」的排查表),加命名空间不解决这个问题。

同 domain 内还有一个容易忽略的点:节点名不保证唯一。两台机器都起 camera_driver 不会报错,ros2 node list 会列出两个同名项,按名字执行的 ros2 param、ros2 node info 因此存在歧义。多机部署时节点名也应落在各自的命名空间下,而不是只给话题加前缀。

1.2 remap:不改代码换话题名

remap 在节点启动时将一个名字映射为另一个,无需修改源码:

## 命令行 remap:把节点订阅的 /chatter 重定向到 /my_chatter
ros2 run demo_nodes_cpp listener --ros-args -r /chatter:=/my_chatter
## 代码内 remap(rclpy)——create_node 用 cli_args 接收 --ros-args 系列参数
node = rclpy.create_node('my_node', namespace='/robot_1',
                         cli_args=['--ros-args', '-r', 'scan:=/lidar_front/scan'])

remap 的匹配与替换都先展开成完全限定名再比较:节点在 /robot_1 下写 -r scan:=/lidar_front/scan,左侧实际展开为 /robot_1/scan,跨命名空间重定向要写全 /robot_1/scan:=/lidar_front/scan。优先级:命令行 > launch 文件 > 代码默认值。命名空间冲突是话题重复的常见成因——两个节点在同一命名空间下发布了同名话题,下游订阅者收到的将是两者的混合。

2. 节点

节点(node)是计算图的最小可调度单元:它持有自己的一组发布者、订阅者、服务端/客户端、定时器与参数,并挂在某个 executor 上被调度。图上的其他实体都从属于某个节点——话题归属发布它的节点,服务名挂在服务端节点下,参数服务的名字就是节点全名。

节点与进程不是一一对应。一个进程可以起一个节点(最常见),也可以在一个 executor 里装多个节点,或把多个 C++ 组件装进同一个容器进程(第 3 章「工程化工具链」)。后两种共享进程内的 DDS participant 与传输资源,省掉序列化开销;代价是崩溃隔离变弱——一个组件异常可能拖垮整个容器。

2.1 节点的职责与边界

节点承担四件事:

  • 持有图实体:创建 publisher / subscriber / service / client / timer,并在销毁时一并注销。
  • 提供名字:节点全名 = 命名空间 + 节点名,私有名 ~/ 与参数服务都挂在它下面(「命名空间与话题名」)。
  • 提供上下文:日志(get_logger())、时钟(get_clock(),受 use_sim_time 影响)、参数接口。
  • 提供调度入口:回调注册后由 executor 调用;节点本身不跑线程循环,rclpy.spin 是 executor 的入口。

节点不负责的事也要划清:它不管进程生命周期(进程退出由信号与 shutdown 决定)、不管 QoS 匹配(DDS 侧判定)、也不管跨机发现(域与网络配置决定)。

2.2 Node 的构造面

rclpy 的构造参数覆盖了命名、上下文与参数注入三件事:

import rclpy

rclpy.init()
node = rclpy.create_node(
    'camera_driver',              # 节点名:单个 token,不含 /
    namespace='/robot_1',         # 与节点名拼成 /robot_1/camera_driver
    parameter_overrides=[rclpy.parameter.Parameter('fps', value=30)],
    start_parameter_services=True,   # 关掉则不起远程参数服务,代码内读写仍可用
)
参数 作用 何时用
node_name 节点名(token) 必填;决定私有名与参数服务名
namespace 命名空间 多机器人、多实例分组
parameter_overrides 启动时覆盖参数默认值(优先于代码默认值) 部署时改阈值不改代码
start_parameter_services 是否对外提供参数服务 组件化或纯内部节点可关
enable_rosout 是否把日志发到 /rosout 容器内节点可关,减日志流量
context 运行时上下文 一进程多上下文(少见)
cli_args 传给节点的参数数组 测试时替代全局命令行

C++ 侧对应的是 rclcpp::Node + NodeOptions,同一组设置换成方法调用与一个多出来的进程内通信开关:

#include <memory>
#include "rclcpp/rclcpp.hpp"

int main(int argc, char ** argv)
{
  rclcpp::init(argc, argv);

  rclcpp::NodeOptions options;
  options.arguments({"--ros-args", "-r", "__ns:=/robot_1"});        // 命名空间与 remap
  options.parameter_overrides({rclcpp::Parameter("fps", 30)});      // 启动时覆盖参数默认值
  options.use_intra_process_comms(true);                            // 同进程零拷贝(C++ 专属)

  auto node = std::make_shared<rclcpp::Node>("camera_driver", options);
  rclcpp::spin(node);
  node.reset();          // 注销图实体(shared_ptr 析构)
  rclcpp::shutdown();
  return 0;
}

use_intra_process_comms 只有 C++ 有,且要发布与订阅两端都传 std::unique_ptr<Msg> 才真正零拷贝;Python 节点之间即使同进程也走 DDS 序列化。

2.3 生命周期与关闭顺序

节点的生命周期只有四步,顺序错了会踩两类错误:

import rclpy
from rclpy.node import Node

rclpy.init()                     # 1) 初始化上下文(每进程一次)
node = Node('demo')
try:
    rclpy.spin(node)             # 2) 交给 executor 调度回调
finally:
    node.destroy_node()          # 3) 先注销图实体(发布者/订阅者/服务)
    rclpy.shutdown()             # 4) 再关上下文

两个常见错误:

  • 先 shutdown 再 destroy_node:上下文已关闭,销毁时的注销调用会报错(rclpy.shutdown() has already been called 一类)。
  • 只 destroy_node 不 shutdown:进程退出前资源能回收,但同一进程再起第二个上下文会失败——rclpy.init() 对同一上下文只能调一次。

destroy_node() 的语义是撤销该节点在图上的全部实体:订阅者注销后不再收消息,发布者消失后下游会看到话题端减少。不注销直接退出,DDS 靠 participant 关闭兜底,但清理时机不可控。

2.4 一名多节点与组件化边界

同一份代码起多份、或多份代码共享一个进程,有两条路。

同一 executor 装多个节点(Python 与 C++ 都可用):

from rclpy.executors import SingleThreadedExecutor
from rclpy.node import Node

rclpy.init()
executor = SingleThreadedExecutor()
executor.add_node(Node('talker'))
executor.add_node(Node('listener'))
executor.spin()

组件化容器(仅 C++):把节点编译成 rclcpp_components 插件,由 component_container 按名字加载(第 3 章「工程化工具链」)。C++ 侧靠一个注册宏声明插件,容器才能按名字找到它:

#include "rclcpp_components/register_node_macro.hpp"
// 类继承 rclcpp::Node、构造函数接 NodeOptions 后,在文件末尾注册;
// 同一容器内的组件共享 DDS participant,消息可同进程传递
RCLCPP_COMPONENTS_REGISTER_NODE(camera_driver::CameraDriver)

Python 节点不能装进这种容器——rclpy 没有对应的插件注册与加载机制,多节点只能靠同一 executor。

两条路的取舍:需要 Python 且图规模不大用同一 executor;高频数据通路且节点是 C++ 用组件化(零拷贝);需要崩溃隔离用独立进程。节点名不保证唯一(两台机器都起 camera_driver 不报错),多实例部署时节点名也要落在各自命名空间下。

3. 四原语与选用判据

原语 通信模式 典型消息频率 选用场景
topic 一对多、异步、单向 连续流(10 Hz ~ kHz) 传感器数据、状态广播、控制指令流
service 一对一、同步请求-响应 偶发调用 配置切换、模式查询、一次性触发(不建议耗时任务)
action 一对多、异步、可取消、有反馈 有明确起止过程 导航到目标点、执行轨迹、等待不确定时间的操作
parameter 读写、可远程订阅变更 低频变更 阈值、开关、PID 增益等运行期可调配置

四原语的载荷不是任意数据,而是接口定义文件声明的类型:topic 用 msg,service 用 srv,action 用 action(内部由 goal / result / feedback 三段消息声明组成)。三类文件分别放在包的 msg/、srv/、action/ 目录,由 rosidl 按同一份描述生成 C++/Python 类型,因此跨语言互通。

msg:字段与常量。每行一个字段,格式是「类型 名字」,可选第三个元素作默认值;常量用 = 赋值且名字必须大写:

# 字段:类型 名字(可选第三个元素是默认值)
float64 x
float64 y
float64 theta
int32[] samples [-200, -100, 0, 100]   # 数组与内置类型可带默认值
int32 MAX=10                            # 常量:运行期不可改

内置类型有 bool、byte、char、float32、float64、int8–int64、uint8–uint64、string、wstring;数组分三种:定长 int32[5]、变长 int32[]、有界变长 int32[<=5],字符串可限长 string<=10。字段也可以嵌套其他消息(geometry_msgs/Point position)。字段名小写、字母开头,不能以下划线结尾或出现连续下划线。

srv:请求与响应。--- 之上是请求、之下是响应,两边各自是一份消息声明:

# example_interfaces/srv/AddTwoInts.srv
int64 a
int64 b
---
int64 sum

同包消息直接写名字(不带包名),服务里不能再嵌套服务。

action:三段声明。两个 --- 切成 goal / result / feedback,任一段可以为空:

# example_interfaces/action/Fibonacci.action
int32 order          # goal:要算多少项
---
int32[] sequence     # result:最终序列
---
int32[] sequence     # feedback:每步已算出的序列

生成与依赖。接口包本身是 ament_cmake 包(第 1 章「ament_python:纯 Python 包」讲过纯 Python 包不能生成接口):package.xml 声明生成器、运行期库与包组成员,CMakeLists.txt 列出接口文件:

<buildtool_depend>rosidl_default_generators</buildtool_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Pose2D.msg" "srv/AddTwoInts.srv" "action/Fibonacci.action")

生成后按包名导入:from my_interfaces.msg import Pose2D,srv、action 同理;使用方在 package.xml 里加 <depend>my_interfaces</depend>,编译期再用 find_package 与 ament_target_dependencies 带上它。

判据:同名话题的发布端与订阅端必须是同一消息类型,类型不一致时两端不匹配、回调不触发——与 QoS 不匹配一样是静默的,只在发现阶段可能打一行告警。接口是跨包契约:字段增删改要让生产与消费两侧一起重建,靠「只加字段」做向后兼容不可行,旧版本反序列化新消息会失败。字段尺寸也影响传输量:定长与有界类型便于中间件预分配,变长大数组每帧都要分配与分片。

3.1 发布/订阅:topic

发布端与订阅端通过「话题名 + 消息类型」配对,双方互不引用,也不需要知道对方存在。一个话题可以挂多个发布者与多个订阅者(多对多),DDS 发现完成后自动建立连接;中途加入的订阅者只收到之后的消息,除非发布端把 durability 设为 Transient Local(历史消息缓存给晚到者,见「兼容规则」)。

一条话题边的形状——收发两端都可挂多个节点,中途接入的订阅端从下一条消息开始收:

flowchart LR
    p1["发布端 A"] --> topic(("话题 /chatter"))
    p2["发布端 B"] --> topic
    topic --> s1["订阅端 X"]
    topic --> s2["订阅端 Y"]
    topic -.-> s3["订阅端 Z(中途加入,只收之后的消息)"]

最小示例把发布端与订阅端装进同一进程调度,实际部署通常是两个独立进程:

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

class Talker(Node):
    def __init__(self):
        super().__init__('talker')
        # 创建一个 publisher:消息类型 String,话题名 /chatter,队列深度 10
        self.pub = self.create_publisher(String, '/chatter', 10)
        # 100ms 周期定时器
        self.timer = self.create_timer(0.1, self.publish)

    def publish(self):
        msg = String()
        msg.data = 'hello ROS 2'
        self.pub.publish(msg)

class Listener(Node):
    def __init__(self):
        super().__init__('listener')
        # 创建 subscriber:收到消息后回调 callback
        self.sub = self.create_subscription(String, '/chatter', self.callback, 10)

    def callback(self, msg):
        self.get_logger().info(f'received: {msg.data}')

## 实际部署中 Talker 与 Listener 常为两个独立进程;这里为演示收发闭环,
## 用同一 SingleThreadedExecutor 把两个节点装进一个进程调度
rclpy.init()
executor = SingleThreadedExecutor()
executor.add_node(Talker())
executor.add_node(Listener())
executor.spin()

判据:连续数据流、状态广播、控制指令流用 topic。它没有应答语义——发布端不知道谁收到了、也不等确认;丢一帧是否可接受由 QoS 决定(可靠性、历史深度),不由接口类型决定。

3.2 请求/响应:service

service 是两节点之间的一次请求-响应:客户端发请求、服务端返回响应,一问一答。接口由 .srv 定义(请求与响应两段字段),rosidl 生成对应类型。

一次调用的时序——请求与响应在 DDS 侧各占一个主题(rq/、rr/ 前缀),客户端拿到的是 future:

sequenceDiagram
    participant C as 客户端节点
    participant D as DDS 主题 rq/ 与 rr/
    participant S as 服务端节点
    C->>C: wait_for_service 确认对端在线
    C->>D: call_async(req)
    D->>S: rq/addRequest
    S->>S: 回调返回 Response
    S->>D: rr/addReply
    D-->>C: future 完成,取 sum
    Note over C: 异步提交,不阻塞 executor 的其他回调

服务端把回调注册到服务名上,回调返回 Response 实例:

from example_interfaces.srv import AddTwoInts
from rclpy.node import Node

class AddServer(Node):
    def __init__(self):
        super().__init__('add_server')
        # 服务名 /add:绝对名;换成 add 则跟随节点命名空间(见「命名空间与话题名」)
        self.srv = self.create_service(AddTwoInts, '/add', self.handle)

    def handle(self, request, response):
        response.sum = request.a + request.b
        return response

客户端提交请求并等待结果:

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

rclpy.init()
node = Node('add_client')
client = node.create_client(AddTwoInts, '/add')
client.wait_for_service(timeout_sec=1.0)   # 服务端未起时避免直接失败

req = AddTwoInts.Request()
req.a, req.b = 2, 3
future = client.call_async(req)            # 异步提交,不阻塞 executor
rclpy.spin_until_future_complete(node, future)
print(future.result().sum)

判据:偶发、耗时在毫秒到数百毫秒的调用——配置切换、模式查询、一次性触发。耗时任务不要用 service:调用期间客户端处于等待,接口没有进度反馈,也没有取消协议,长任务会把调用方一起拖住;这三样正是 action 补上的。

3.3 长任务:action

action 把一次长任务拆成四件:目标(goal)、周期反馈(feedback)、最终结果(result)与取消(cancel)。底层仍由服务与话题组合而成——目标与结果走服务、反馈走话题——但接口把整套语义封装好,调用方不必自己拼。

一次目标的完整时序——接受后进入执行,反馈周期性到达,结果与取消各走独立通路:

sequenceDiagram
    participant C as 客户端
    participant S as 服务端 ActionServer
    C->>S: send_goal_async(goal)
    S-->>C: goal_handle(accepted / rejected)
    alt 正常完成
        loop 每算一步
            S-->>C: feedback(publish_feedback)
        end
        S-->>C: result(succeed)
    else 中途取消
        C->>S: cancel_goal_async
        S-->>C: canceled,结束
    end
    Note over C,S: 底层由目标/结果服务与反馈话题组合而成

服务端在目标被接受后执行 execute 回调,通过 goal handle 发反馈、判取消:

from rclpy.action import ActionServer
from rclpy.node import Node
from example_interfaces.action import Fibonacci

class FibServer(Node):
    def __init__(self):
        super().__init__('fib_server')
        self.server = ActionServer(self, Fibonacci, 'fibonacci', self.execute)

    def execute(self, goal_handle):
        seq = [0, 1]
        for _ in range(1, goal_handle.request.order):
            seq.append(seq[-1] + seq[-2])
            # 每算一项发一次反馈,客户端据此显示进度
            goal_handle.publish_feedback(Fibonacci.Feedback(sequence=seq))
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()          # 接受取消请求并结束
                return Fibonacci.Result()
        goal_handle.succeed()                   # 正常完成
        return Fibonacci.Result(sequence=seq)

客户端发目标、收反馈、等结果:

import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from example_interfaces.action import Fibonacci

rclpy.init()
node = Node('fib_client')
client = ActionClient(node, Fibonacci, 'fibonacci')
client.wait_for_server()

def on_feedback(msg):
    print('progress:', msg.feedback.sequence)

goal = Fibonacci.Goal(order=10)
send_future = client.send_goal_async(goal, feedback_callback=on_feedback)
rclpy.spin_until_future_complete(node, send_future)
goal_handle = send_future.result()
if not goal_handle.accepted:
    raise RuntimeError('goal rejected')

result_future = goal_handle.get_result_async()
rclpy.spin_until_future_complete(node, result_future)
print(result_future.result().result.sequence)

判据:导航到目标点、执行一条轨迹、抓取放置这类「有明确起止、可能中途取消、需要进度」的过程用 action。它比 service 多出的不是性能,而是语义:可取消、有反馈、结果异步返回。

3.4 运行期配置:parameter

parameter 是节点自己持有的键值配置,通过节点名下的参数服务读写。它不是数据通道:适合低频、运行期可调的阈值与开关,不适合传状态流。

写入的三条来路与读取的缓存:启动覆盖直接落到参数值,运行期改写经回调校验,读取只在启动时走一次参数服务。

flowchart LR
    decl["declare_parameter<br/>声明后才有值可读"] --> val[("节点参数值")]
    ovr["启动覆盖<br/>--ros-args -p / YAML"] --> val
    rt["运行期 set_parameter<br/>CLI / 远程节点 / 代码"] --> cb{"on_set_parameters<br/>校验后接受或拒绝"}
    cb -->|"接受"| val
    cb -->|"拒绝"| rej["保持旧值"]
    val --> get["get_parameter<br/>启动时读一次存成员变量"]
## 声明——必须显式声明参数才可后续读取(默认 0.5 m/s)
self.declare_parameter('max_vel', 0.5)
vel = self.get_parameter('max_vel').value

## 动态回调——参数被修改时触发,可在这里校验并拒绝非法值
self.add_on_set_parameters_callback(self.param_cb)

参数可从命令行或 YAML 文件覆盖,不改代码:

ros2 run my_pkg my_node --ros-args -p max_vel:=1.2
## params.yaml:层级是「节点名 → ros__parameters → 参数名」
my_node:
  ros__parameters:
    max_vel: 1.2

判据:阈值、开关、PID 增益这类运行期可调项用参数。读取走参数服务、有调用开销,高频访问应在启动时读一次缓存到成员变量。参数名绑定节点全名,多命名空间部署时要用 ros2 param list /robot_1/my_node 看对应实例。

生命周期节点与启动顺序。需要有序启停的系统把节点做成 lifecycle 节点:状态从隐式的「启动即可用」变为显式五态机,inactive 下可以接收参数变更但不发布数据,active 才对外提供功能:

stateDiagram-v2
    [*] --> unconfigured
    unconfigured --> inactive: configure
    inactive --> active: activate
    active --> finalizing: shutdown
    active --> unconfigured: cleanup
    finalizing --> [*]
    unconfigured --> error: 转换失败
    error --> [*]
  • unconfigured:刚创建,资源未分配。
  • inactive:已配置但未运行,可收到参数变更但不发布数据。
  • active:正常运行。
  • finalizing:正在清理。
  • error:转换失败时进入。

lifecycle manager 按依赖顺序批量转换:启动时 configure → activate 全部节点,关闭时 deactivate → cleanup。适合传感器初始化 → 控制器激活 → 导航启动这种有先后依赖的链路。

与通用 RPC 框架(gRPC)的分工。ROS 2 的 service / action 与通用 RPC 框架解决的是一类问题——跨进程的请求-响应与长任务。差别在定位与传输:gRPC(Google 提出的通用 RPC 框架,基于 HTTP/2 传输、Protocol Buffers 定义接口)面向语言中立、跨网络的微服务调用;ROS 2 原语面向机器人计算图内的节点交互,底层由 DDS 承载、自动发现、带 QoS 策略。二者不是替代关系,按边界分工:

维度 ROS 2 service / action gRPC
接口定义 .srv / .action(rosidl 生成) .proto(Protocol Buffers,grpc-tools 生成 stub)
传输 DDS(RTPS,组播自动发现) HTTP/2 over TCP(需显式 host:port 或名字服务)
服务发现 DDS 去中心自动发现 无内建发现,靠配置 / 注册中心 / DNS
长任务与反馈 action 的 goal / feedback / result / cancel 内建 客户端流 / 服务端流 / 双向流(无内建取消协议,需自定义)
QoS reliability / durability / deadline 等十余策略 无 QoS 概念,依赖 HTTP/2 流控
语言中立性 C++ / Python 经 rcl 对等 原生跨多语言(C++/Go/Java/Python 等)
典型部署语境 机器人图内节点 云 / 跨网段 / 非 ROS 组件

分工判据:

  • 机器人内部节点间(同一计算图、需 QoS 匹配实时数据、要 cancel / feedback)用 ROS 2 原语,不为图内通信引入 gRPC。
  • 与外部系统边界(云端 API、经 gRPC 暴露的模型推理服务如 Triton / TensorFlow Serving、非 ROS 的 Go / Java 微服务、跨广域网组件)用 gRPC 更合适——其语言中立与 HTTP/2 生态正好补 DDS 的短板。
  • 桥接方式:一个 ROS 2 节点在 service / action 回调里作为 gRPC 客户端调用外部服务,属应用层组合——图模型与 DDS 传输都不感知,不改变 ROS 2 侧语义。

边界定位:gRPC 不是 ROS 2 的原生 rmw 传输(rmw 只对接 DDS 类中间件),它出现在计算图与外部世界的边界,与「DDS 实现对比与替换」讨论的 rmw 实现(Fast DDS / CycloneDDS / rmw_zenoh)不在同一层,也不与它们竞争。

判据:图内用原语,跨边界才上 gRPC

节点之间要 QoS、要 cancel/feedback 就用 topic/service/action;出了计算图(云端推理、非 ROS 微服务)才由应用层组 gRPC,rmw 本身只对接 DDS 类中间件。

4. 客户端库分层

ROS 2 通信栈从用户代码到网络有明确的层级:

flowchart LR
    user["用户代码<br/>Python / C++"] --> bind["rclpy | rclcpp<br/>语言绑定层"]
    bind --> rcl["rcl(C API)<br/>语言无关核心"]
    rcl --> rmw["rmw<br/>中间件抽象接口"]
    rmw --> dds["DDS 实现<br/>Fast DDS / CycloneDDS / Zenoh"]
  • rcl 是 C 语言实现的公共核心,管理节点、话题、服务的全部逻辑。
  • rclpy 是 rcl 的 Python 绑定(通过 Cython/FFI),rclcpp 是 C++ 绑定。两者功能对等,差异在并发模型(GIL)与内存管理。
  • rmw 是一组 C 接口定义,DDS 实现作为插件注册。换 DDS 只需安装对应 rmw 包并设置环境变量,上层代码不感知。

这一分层设计意味着:Python 与 C++ 写的节点之间可自由通信(只要消息类型一致),因为它们在 rcl 层以下共享同一套 DDS 传输——语言只影响节点内部实现方式,不影响图上的交互协议。

5. DDS 实现对比与替换

5.1 候选实现与切换

维度 Fast DDS(默认) CycloneDDS RTI Connext rmw_zenoh
许可 Apache-2.0 EPL-2.0 商业(评估免费) Apache-2.0
发现机制 RTPS 组播 DDS 标准组播 DDS 标准组播 Zenoh 网络(broker / P2P)
共享内存/零拷贝 SHM transport iceoryx 集成 原生 zero-copy Zenoh shared memory
XML 配置能力 profiles XML QoS XML + domain gazette 完整 XML library 配置文件 JSON/TOML
大消息表现 分片(RTPS frag) 分片 原生 big-data 分片 + 流控
ROS_DOMAIN_ID 依赖 是 是 是 否(用 Zenoh key space)
跨 WAN 组网 困难(组播穿透) 困难 困难 原生支持(unicast routing)
Humble 包名 rmw_fastrtps_cpp rmw_cyclonedds_cpp rmw_connextdds rmw_zenoh_cpp(Humble 0.1.11,可切换)

rmw_zenoh 是对 DDS 发现机制的根本替换:不依赖组播、不读 ROS_DOMAIN_ID(改用 Zenoh key space),发现走 broker/P2P,适合大规模跨网段/跨地域部署。Humble 提供 ros-humble-rmw-zenoh-cpp(0.1.11),装上即可切换。

这里安装 CycloneDDS 并在当前终端生效:

sudo apt install -y ros-humble-rmw-cyclonedds-cpp
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
## CycloneDDS 自身配置
export CYCLONEDDS_URI='<Tracing><OutputFile>/tmp/cyclone.log</OutputFile></Tracing><Discovery><Peers><Peer address="192.168.1.100"/></Peers></Discovery>'

确认当前进程激活的 rmw(不经 CLI daemon,直接查)——切到 CycloneDDS 后应打印 rmw_cyclonedds_cpp:

python3 -c "import rclpy;from rclpy.node import Node;from rclpy.utilities import get_rmw_implementation_identifier as g;rclpy.init();n=Node('p');print(g());n.destroy_node()"

换成 RMW_IMPLEMENTATION=rmw_cyclonedds_cpp 后,同一套上层 pub/sub 代码照常跨进程收发——图上的交互不感知 rmw 实现差异。

同一网络内多个 rmw 实现共存时,通信双方必须使用相同 rmw——Fast DDS 与 CycloneDDS 之间不可互操作。混合环境的排障第一步是确认所有节点的 RMW_IMPLEMENTATION 一致。

换到 rmw_zenoh 则不同:它不用 RTPS 组播发现,必须显式跑一个 Zenoh router 作为发现汇聚点(这也是它跨网段/跨 WAN 的机制):

sudo apt install -y ros-humble-rmw-zenoh-cpp
ros2 run rmw_zenoh_cpp rmw_zenohd &          # 起 router;日志输出 "Started Zenoh router with id ..."
export RMW_IMPLEMENTATION=rmw_zenoh_cpp

不起 router 时,节点初始化会告警 Unable to connect to a Zenoh router ... 且发现不到对端;起 router 后,两个独立进程即可跨进程发现并收发——上层代码与其他 rmw 完全一致,只多了“必须先起 router”这一步。ROS_DOMAIN_ID 对 zenoh 不生效(隔离靠 Zenoh key space 与 router 拓扑)。

6. QoS 策略与兼容矩阵

QoS(Quality of Service)是 DDS 的核心能力,ROS 2 通过 qos_profile 暴露十余项策略。最常触发的三条:

策略 含义 典型取值
reliability 可靠(Reliable) / 尽力而为(Best Effort) Reliable: TCP 语义;Best Effort: UDP 语义
durability 易失(Volatile) / 持久(Transient Local) Transient Local: 新订阅者收到历史缓存
history + depth 队列保留策略与深度 Keep Last(N): 保留最近 N 条

6.1 兼容规则与常用预设

publish 端与 subscribe 端的 QoS 必须满足"订阅端要求 ≤ 发布端提供"的规则,否则两端不匹配,订阅端的回调一次都不会触发——数据面静默:不抛异常、不影响节点其余功能,话题 list 里照常存在,只是收不到数据。关键约束对:

发布端 订阅端 兼容
Best Effort Reliable 不兼容(回调不触发)
Reliable Best Effort 兼容
Volatile Transient Local 不兼容(回调不触发)
Transient Local Volatile 兼容
depth=10 depth=5 兼容

需区分两个层面:对应用数据面是静默的——订阅端不抛异常、回调不触发、话题 list 里照常存在,只是收不到数据;部分 rmw 实现(如默认 Fast DDS)会在发现时打印一行 offering incompatible QoS. No messages will be received 告警。排障时应按“可靠/持久策略是否匹配”直接核对两端 QoS 配置。

该兼容矩阵决定了排障方向:话题存在、节点正常、就是没数据——检查 QoS 是否匹配是第一步。

兼容判定的方向只有一个:订阅端的要求必须被发布端满足:

flowchart LR
    pub["发布端 QoS<br/>可靠性 / 持久性 / 深度"] --> cmp{"订阅端要求 ≤ 发布端提供?"}
    cmp -->|"是"| ok["数据面正常"]
    cmp -->|"否"| bad["回调一次不触发<br/>话题仍在、不抛异常"]
    bad --> diag["排障第一步:核对两端策略"]

高频传感器的预设。相机、激光雷达、IMU 这类连续流用 qos_profile_sensor_data,其含义是一组固定取值:

from rclpy.qos import qos_profile_sensor_data
## 等价于:
## reliability = Best Effort(丢帧优于延迟)
## durability = Volatile
## history = Keep Last, depth = 5
## 使用预设创建订阅
self.sub = self.create_subscription(
    LaserScan, '/scan', self.scan_cb, qos_profile_sensor_data)

6.2 自定义 QoS

from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy

qos = QoSProfile(
    reliability=ReliabilityPolicy.RELIABLE,   # 可靠传输
    history=HistoryPolicy.KEEP_LAST,
    depth=50,                                 # 队列深度 50
)

服务端的 QoS 由框架内部管理(service 默认 Reliable + Keep Last 10),通常无需手动配置。

坑:QoS 不匹配不报错

订阅端的要求高于发布端提供(如 Best Effort 发布、Reliable 订阅)时,回调一次都不触发、话题仍然存在、不抛异常;只有部分 rmw 在发现阶段打一行告警。

7. executor 与 callback group

executor 决定节点如何接收与调度回调——这是 ROS 2 并发模型的核心。

7.1 常见 executor

executor 行为 适用
SingleThreadedExecutor 一次只执行一个回调 默认行为;无并发需求
MultiThreadedExecutor 线程池并行执行多个回调 多话题高频、允许重入的回调
EventExecutor 单线程,按事件类型轮转 避免高优先级事件饥饿
from rclpy.executors import MultiThreadedExecutor

executor = MultiThreadedExecutor(num_threads=4)
executor.add_node(talker)
executor.add_node(listener)
try:
    executor.spin()
finally:
    executor.shutdown()

7.2 callback group 与重入

callback group 控制哪些回调可以并行执行:

  • MutuallyExclusiveCallbackGroup:同组内的回调互斥,一次只跑一个(默认)。
  • ReentrantCallbackGroup:同组内的回调可并行。

典型死锁成因:SingleThreadedExecutor + MutuallyExclusiveCallbackGroup 下,回调 A 内部调用 service client 并等待响应,而响应回调也落在同一互斥组——A 阻塞等待响应,响应回调等待 A 释放——死锁。解决方式:将 service client 的回调放入独立的 ReentrantCallbackGroup,或使用 MultiThreadedExecutor。

from rclpy.callback_groups import ReentrantCallbackGroup

## 让 timer 和 service 响应回调可以并发执行(rclpy 的 Node 同时提供 create_client / create_service)
self.cb_group = ReentrantCallbackGroup()
self.client = self.create_client(AddTwoInts, '/add', callback_group=self.cb_group)

8. 时钟、域隔离与大消息

8.1 时钟与 use_sim_time

ROS 2 节点通过 get_clock() 获取时间源。默认使用系统墙钟(ClockType.SYSTEM_TIME);设置参数 use_sim_time := true 后,节点从 /clock 话题读取仿真时间:

## 启动时指定使用仿真时间
ros2 run my_pkg my_node --ros-args --params-file sim_time.yaml
## sim_time.yaml
my_node:
  ros__parameters:
    use_sim_time: true

发布 /clock 的话题类型是 rosgraph_msgs/msg/Clock。仿真器(Gazebo、MuJoCo 桥接)负责按仿真步推进 /clock;真机环境通常不发布此话题——节点一旦设 use_sim_time := true 而无人发布 /clock,时间戳将冻结在零,导致 TF 查询超时与传感器数据全部过期丢弃。

8.2 ROS_DOMAIN_ID 与多机隔离

ROS_DOMAIN_ID(0~232)是 DDS 层面的逻辑分区。相同 domain ID 的节点互相可见,不同 ID 之间完全隔离——不需要防火墙规则,不需要修改话题名:

export ROS_DOMAIN_ID=42   # 当前终端启动的所有节点加入 domain 42

多机器人共场时每机分配唯一 domain ID 是最简单的隔离手段。domain ID 参与 DDS 发现端口的计算(默认从 7400 起、每域间隔 250),组播地址本身在 Linux 上固定为 239.255.0.1;隔离范围是整个子网,端口分配细节可查 DDS 实现的端口公式。

跨机时间同步:多台机器之间的 DDS 消息时间戳依赖各自系统时钟。NTP/chrony 同步精度(毫秒级)对传感器融合够用;控制回路若需亚毫秒对齐,应评估 PTP(IEEE 1588)或在硬件触发侧打时间戳。

8.3 大消息与零拷贝

DDS 对单条消息有分片机制(RTPS Fragment),大消息(如 64 位点云帧 > 1 MB)在传输前拆为多个 data packet。实际影响:

  • 分片传输引入序列化/反序列化开销,大消息吞吐受限于带宽。
  • loaned message(publish_loaned / steal_data)可在同机 pub-sub 之间绕过序列化——数据留在共享内存,订阅端直接引用,即所谓"零拷贝"。但零拷贝的前提是双方在同一进程或共享内存可达,跨机不生效。
  • ROS 2 默认走完整序列化;零拷贝只在显式使用 loaned message API 且 rmw 实现支持共享内存传输时发生,跨机不生效。

端到端时延测量:在消息头写入发送时间戳,接收端减去该戳得到单向延迟;或使用 /clock 同步后两端打时间戳。ros2_tracing 工具可获取从发布到回调进入的全链路 trace event,判据级分析足够定位瓶颈段。

9. 要点

  • 计算图五要素(node/topic/service/action/parameter)构成所有交互的基础;命名空间与 remap 决定最终话题全名。
  • 四原语的选用判据:连续流用 topic、短时请求用 service、可中断长任务用 action、低频配置用 parameter。
  • 客户端库(rclpy/rclcpp)→ rcl → rmw → DDS 的四层结构实现语言无关通信;换 DDS 实现只动 rmw 环境变量(Fast DDS/CycloneDDS),rmw_zenoh 额外要先起一个 router。
  • QoS 不兼容时订阅端不报错、不抛异常、回调不触发(数据面静默丢),但部分 rmw(如默认 Fast DDS)会在发现时打一行 incompatible QoS 的 WARN;话题存在但无数据时首先排查可靠性与持久性策略是否匹配。
  • executor 决定回调并行度;死锁常因互斥 callback group 内的阻塞等待。
  • 参数必须显式声明才可生效;lifecycle 五态机提供有序启停。
  • ROS_DOMAIN_ID 做逻辑隔离,use_sim_time 切换时钟源——两者配置不当分别导致"跨机看不到"和"时间戳冻结"。
  • service / action 与通用 RPC 框架(gRPC)按边界分工:图内节点交互用 ROS 2 原语(走 DDS、自动发现、带 QoS),跨语言 / 对接云端与推理服务等外部边界用 gRPC;gRPC 非 ROS 2 原生 rmw 传输,只在图与外部世界的边界上出现。

参考文献与延伸阅读

  • ROS 2. Concepts 文档(节点、话题、服务、动作). 文档
  • ROS 2. About Quality of Service Settings. 文档
  • Object Management Group. Data Distribution Service (DDS) 规范. 规范