第六章 · ROS 2 体验与系统架构
上一章写的两个 Hello World 还没碰 ROS 2。这一章正式进门:先跑三个自带的小例子,亲眼看两个节点怎么自动找到对方、怎么收发消息;然后把 ROS 2 从应用层到操作系统层一层层拆开,搞清楚 rcl、RMW、DDS 各自负责什么,为什么节点之间要用它来通信。
6.1 你说我听:最简的两个节点
这个“游戏”只做一件事:启动两个节点,一个负责发消息(说),一个负责收消息(听)。
打开一个终端,快捷键
Ctrl+Alt+T。在里面启动倾听者——它是收到消息的一方:
bashros2 run demo_nodes_py listener再打开一个新终端,同样按
Ctrl+Alt+T。在新终端里启动说话者——它是发消息的一方:
bashros2 run demo_nodes_cpp talker
然后观察两个终端的输出:talker 节点每数一个数,listener 节点就收到一个,一个不落。一个 Python 节点和一个 C++ 节点,没有配置过彼此的地址,却自动连上了。终端里的输出大致是这样:
[talker]: Publishing: 'Hello World: 1'
[listener]: I heard: [Hello World: 1]
图:你说我听
先记住这个现象
两个节点互不认识,也没告诉对方自己在哪里,却能自动匹配。这说明 ROS 2 在背后有一层机制替它们做了“牵线”——这就是下面要拆的架构。
6.2 涂鸦乌龟
打开终端(Ctrl+Alt+T),输入下面的指令启动海龟画图:
ros2 run turtlesim turtlesim_node运行后会弹出一个画着海龟的窗口:

图:海龟模拟器
点一下原来的终端,按 Ctrl+Shift+T 在同一个窗口里开一个新的标签页,输入:
ros2 run turtlesim turtle_teleop_key这个节点负责接收键盘按键。按方向键,海龟就会在窗口里移动并留下轨迹,这就是“涂鸦乌龟”。
Ctrl+Shift+T 和 Ctrl+Alt+T 的区别
Ctrl+Alt+T 是新开一个终端窗口,Ctrl+Shift+T 是在当前终端窗口里新开一个标签页。第二个节点刻意开在标签页里,就是为了方便和第一个节点的输出对照着看。
6.3 rqt 可视化
保持前面两个游戏都在运行,再打开一个终端,输入:
rqt打开之后选择插件 Introspection / Node Graph,刷新一下,就能看见节点之间的通信关系:

图:RQT Node Graph 可视化
rqt 把“节点之间谁发给谁”画成了图:椭圆里是节点名,箭头旁边标着话题名,箭头指向就是消息的流向。刚才“你说我听”里两个节点自动匹配的秘密,到这里已经能看见一半了——图里 /talker 指向 /listener 的那根箭头,旁边写的就是它们通信用的话题。
6.4 分层架构总览
前面两个小游戏跑通了,rqt 里也能看到连线,但这些都还停留在“会用”的层面。这一节把 ROS 2 拆开,从整体框架上看它由哪几层构成,以及为什么要这样分层。
ROS 2 的整体结构可以描述成一张“千层饼”式的图,从上到下依次是:应用层 → ROS 2 客户端库 → 客户端库核心 rcl → 中间件抽象层 RMW → DDS 实现层 → 操作系统层。相邻两层之间是双向调用关系:上层向下层提出请求,下层把结果和事件回传给上层。
| 层次(自上而下) | 这一层有什么 | 这一层的作用 |
|---|---|---|
| 应用层 | 用户节点、rqt、命令行工具 | 我们写的所有节点代码都住在这里 |
| ROS 2 客户端库 | rclpy、rclcpp、rcljava、rclnodejs…… | 提供给各语言的 API(跨语言绑定) |
| 客户端库核心 rcl | C 语言实现的统一核心 | ROS 2 自身的实现,各语言库共用 |
| 中间件抽象层 RMW | ROS Middleware 抽象接口 | 给上层统一接口,屏蔽各家差异 |
| DDS 实现层 | Fast DDS、Cyclone DDS、Connext…… | 第三方中间件,可自由替换 |
| 操作系统层 | Linux、Windows、macOS、驱动与网络协议栈 | 提供硬件、进程、网络等基础能力 |
各层的归属并不相同:从应用层到客户端库核心 rcl,这三层是 ROS 2 官方自己实现的;中间的 RMW 是一层统一接口;最下面的 DDS 实现层则来自第三方。接下来自下而上,一层一层看。
6.5 操作系统层
这一层最好理解。ROS 2 自己不是一个操作系统,它只是跑在 Linux、Windows 或 macOS 之上的一个中间件框架。真正去驱动硬件、管理进程、收发底层网络数据包这些脏活累活,全部交给操作系统完成。
也正因为如此,ROS 2 才能跨这么多平台:只要那个平台提供的系统调用和网络协议栈足够完整,上面的各层就可以原封不动地搬过去。
顺带说一句,前面折腾 WSL 里的 Ubuntu,其实就是在给上面几层准备一块合适的“地基”。
6.6 DDS 实现层
想弄懂这一层,得先回答两个问题:DDS 到底是什么?为什么 ROS 2 里会同时存在好几套 DDS 实现?
6.6.1 DDS 是什么
DDS 全称 Data Distribution Service,中文叫“数据分发服务”,是由对象管理组 OMG(Object Management Group)在 2003 年发布、2007 年修订的一套分布式系统通信标准。
它的通信方式和 ROS 里的话题发布订阅几乎一模一样:一方把数据“发布”到某个主题上,关心这个主题的另一方“订阅”后就能收到,双方不需要事先知道对方是谁。除此之外,DDS 还提供了相当丰富的服务质量(QoS)策略,用来规定通信的可靠性、持久性、传输方式等要求。
正因为设计理念如此契合,ROS 2 干脆不再自己另造一套通信协议,而是直接把 DDS 拿来当作底层的通信支柱。
6.6.2 为什么会有多套 DDS 实现
DDS 只是一份标准文档,标准本身不能运行,得有人照着它写出真正的程序,这些程序就叫“DDS 实现”。目前常见的实现有:
| DDS 实现 | 出品方 |
|---|---|
| Fast DDS | eProsima |
| Cyclone DDS | Eclipse |
| Connext DDS | RTI |
它们遵守同一份标准,但侧重点各不相同:有的偏重性能,有的体积小巧,有的面向商用场景提供技术支持。既然标准是统一的,多准备几套实现就不会给上层添麻烦,想换随时换。至于这些差异是怎么被“抹平”的,就要看下面这一层。
6.7 中间件抽象层 RMW
RMW 是 ROS Middleware 的缩写,也就是中间件抽象层。它的作用可以用一句话概括:给上面一个统一的接口,把下面各家的差异藏起来。
为什么不直接把 DDS 暴露给上层?因为直接用 DDS 其实挺麻烦:创建一次通信要设置分区、主题名称、发现模式、消息类型等一大堆参数,还要记得在不用的时候逐项清理。这些琐碎而重复的配置如果让每个写节点的同学自己处理,代码会变得又臭又长。
于是 ROS 2 在中间加了一层 RMW:它定义好一套固定的接口,无论底下换成 Fast DDS 还是 Cyclone DDS,上层看到的 API 都完全一样;那些繁琐的配置也就顺理成章地在 RMW 和 RCL 里统一完成了。
# 查看当前使用的 DDS 实现
ros2 doctor --report | grep middleware
# 通过环境变量切换(需要系统里已安装对应的 DDS)
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp6.8 客户端库 RCL 与语言绑定
RCL 是 ROS Client Library 的缩写,也就是 ROS 2 客户端库。它建立在 RMW 之上,把 ROS 里的节点、话题、服务、参数、Action 这些概念包装成一套好用的 API。
这里有个细节值得留意:RCL 的底层实现是用 C 语言写的。因为 C 是众多高级语言的“共同祖先”,基于它再去封装其它语言会轻松很多。所以官方的 rclpy(Python)和 rclcpp(C++)都直接建立在这套 C 实现之上——这也是后面用两种语言写节点时,代码结构会如此相似的原因。
社区还用同样的方式把 ROS 2 搬到了更多语言和平台上,比如在 Android 应用或网页里也能和 ROS 2 通信:
| 语言 | 客户端库 | 项目地址 |
|---|---|---|
| Python | rclpy | https://github.com/ros2/rclpy |
| C++ | rclcpp | https://github.com/ros2/rclcpp |
| Java | rcljava | https://github.com/esteve/ros2_java |
| Rust | rclrust | https://github.com/ros2-rust/ros2_rust |
| Node.js | rclnodejs | https://github.com/RobotWebTools/rclnodejs |
| Go | rclgo | https://github.com/juaruipav/rclgo |
| Lua | rcllua | https://github.com/jbbjarnason/rcllua |
| Kotlin | rclkin | https://github.com/ros2java-alfred/ros2_kotlin |
| Swift | rclswift | https://github.com/atyshka/ros2_swift |
| C# | rclcs | https://github.com/RobotecAI/ros2cs |
表:ROS 2 的常见多语言客户端库
6.9 应用层
最上面就是我们自己写代码,以及 rqt、命令行工具这些开发工具所在的层。后面写的所有节点代码,其实都住在这里。
它们不需要关心底下的通信是怎么实现的,只要调用客户端库提供的 API 就好——这也正是分层带来的好处。
6.10 中间件与 DDS 架构
6.10.1 什么是中间件
中间件是一种独立的系统软件或服务程序,分布式应用软件借助它在不同技术之间共享资源。中间件位于客户机/服务器的操作系统之上,管理计算机资源和网络通讯,是连接两个独立应用程序或独立系统的软件。相连的系统即使接口不同,通过中间件也能交换信息;执行中间件的一个关键途径是信息传递。借助中间件,应用程序可以工作在多平台或不同 OS 环境。

图:第一次读上面这段定义时的真实感受(笑)
6.10.2 DDS 接口层与抽象层
ROS 2 为每家 DDS 供应商都开发了对应的 DDS_Interface(即 DDS 接口层),然后通过 DDS Abstract 抽象层来统一 DDS 的 API:

图:ROS2 DDS 架构
6.11 DDS 通信模型:DCPS
DDS 的核心模型是 DCPS——以数据为中心的发布订阅(Data-Centric Publish-Subscribe)。
DCPS 模型通过主题实现发布者与订阅者的解耦,核心构成包括:
- 域(Domain):逻辑隔离的通信平面。只有相同域 ID 下的节点才能互相发现和通信,用来解决多系统同网络下的隔离问题。
- 主题(Topic):连接发布者与订阅者的逻辑纽带,对数据做分类标记,是发布和订阅匹配的唯一依据。发布者只需要发布对应主题的数据,所有订阅该主题的节点都会自动收到。
- 发布者/数据写入器(Publisher/DataWriter):由发布者创建 DataWriter,实际负责数据的发送。
- 订阅者/数据读取器(Subscriber/DataReader):由订阅者创建 DataReader,实际负责接收对应主题的数据。
这种模式下,发布者和订阅者不需要知道对方的地址信息,仅通过主题完成匹配,能够轻松构建复杂的多对多通信关系,实现系统的高度解耦。6.1 节里 talker 和 listener 能自动接上,靠的就是这套模型。
6.12 ROS 2 的节点是怎么编译出来的
节点说到底是 C++ 程序,而 C/C++ 程序从源码到能执行,一般要经过预处理、编译、汇编、链接几个步骤:
源文件(.h、.cpp 等)
│ 预处理
▼
│ 编译
▼
│ 汇编
▼
│ 链接 ←── 静态库 / 动态库
▼
可执行文件实际项目里,有些公共代码需要反复使用,就会先编译成“库”文件,在链接阶段由链接器从库文件里取出所需代码:
- 静态(链接)库:可执行文件中包含库代码的一份完整拷贝,缺点是被多次使用就会有多份冗余拷贝。
- 动态(链接)库:程序开始运行后调用库函数时才被载入。这种库独立于现有的程序,本身不可执行,但包含着程序需要调用的一些函数。
两种库在不同平台上的后缀也不一样:
| 平台 | 静态链接库 | 动态链接库 |
|---|---|---|
| Windows | .lib | .dll |
| Linux | .a | .so |
静态库、动态库的区别来自链接阶段如何处理库;链接成可执行程序的方式,分别称为静态链接和动态链接。
那么,一个 ROS 2 节点该怎么正确编译?直接用 g++ 单文件编译是走不通的——正确做法是构建一个 ament 包,这正是下一章的内容。