ROS教程11.5:roscore源码阅读——从启动脚本到 Master 注册表与控制面
摘要:沿 Noetic 源码解释 roscore、roslaunch、rosmaster、Master 注册表与 Python/C++ 边界,并用 bringup、READY Service 和 Docker Compose 落地整机启动。
@[toc]
阶段 06~11 已经从 roscpp、Topic、TCPROS、CallbackQueue、Service 与 Action 的客户端/服务端路径建立了 ROS1 通信模型。本章作为阶段 A 的收束篇,换到 Master 服务端视角,把前面多次出现但尚未完整拆开的 roscore、roslaunch、rosmaster 和 Master 注册表串起来。
本章不重复第 07 章已经完成的 roscpp registerPublisher() / registerSubscriber() 客户端调用,也不重复第 08 章 TCPROS socket 数据面,更不重新展开第 11 章 actionlib 状态机。关注点只有一条:
1 | 终端执行 roscore |
源码基线固定为 ROS1 Noetic 的 noetic-devel 分支。当前 ros1-docker 环境使用 ros:noetic-ros-base-focal,因此源码路径、Python 版本和运行时模型都以 Noetic 为准。
1. 先确定一个关键事实:roscore 不是 ROS Master 本体
终端执行:
1 | roscore |
容易形成下面这种直觉:
1 | roscore == ROS Master |
从源码看并不是这样。
roscore 位于 ros_comm/tools/roslaunch/scripts/roscore,它是 roslaunch package 安装出来的 Python 入口脚本。脚本解析少量 core 相关参数后,核心动作是进入:
1 | roslaunch.main(... --core ...) |
真正提供 Master XML-RPC API 的进程稍后才由 roslaunch 创建,它运行的是:
1 | rosmaster --core -p <port> -w <num_workers> |
所以从进程职责上应该先建立下面的区分:
| 对象 | 本质 | 主要职责 |
|---|---|---|
roscore |
Python 启动脚本 | 以 --core 模式进入 roslaunch |
roslaunch parent |
Python 父进程 | 加载 core 配置、启动/监控 Master 与 core Node |
rosmaster |
Python 子进程 | 提供 Master API 与 Parameter Server |
/rosout |
普通 ROS Node | 接收 /rosout 日志 Topic |
这也是为什么系统里可以同时看到 roscore/rosmaster/rosout 相关进程,而不是只有一个“万能 roscore 进程”。
2. roscore 是怎么“编译”出来的:这里和 roscpp 完全不同
第 05 章为了 F11 进入 ros::init(),需要把 roscpp 编译成带 Debug 信息的 libroscpp.so。roscore/rosmaster 不能照搬这套理解。
Noetic 中:
1 | roslaunch/CMakeLists.txt |
因此执行:
1 | catkin build rosgraph rosmaster roslaunch |
对这几个 package 而言,核心不是“把 Python 编译成一个像 libroscpp.so 那样的 ELF”,而是让 catkin 完成 package 配置、Python package/script 的 devel/install 布置以及依赖关系处理。
这会直接改变后面的调试方式:
1 | roscpp |
rosout 则是另一个独立 package,属于真正被 roslaunch 启动的 core Node,不应和 Python Master 实现混为一体。
2.1 为什么标准 Master 是 Python,而工程 Node 又经常是 C++
看到 roscore、roslaunch、rosmaster 都是 Python 后,容易继续追问:ROS1 是否还有另一套“真正的 C++ Master”?
Noetic 的标准实现不是这种结构。在 ros_comm 中,标准 rosmaster 就是 Python 实现;C++ 侧对应的是 roscpp
客户端库,而不是另一套并行维护的 C++ Master executable。两侧通过 ROS Master XML-RPC API 对接。
因此更准确的边界是:
1 | Python 控制面 |
例如一个 C++ Publisher 执行 advertise() 时,客户端侧会进入 roscpp 的 Topic/Master 调用链,最终通过 XML-RPC
调用 Python Master 的 registerPublisher()。Master 返回和推送的是发现信息;Publisher 与 Subscriber 建立 TCPROS
连接后,业务消息不会继续经过 Python Master。
这也是 Python Master 可以成立的重要原因:Master 处在控制面,而不是高频 Topic payload 的数据转发路径。roscpp 中真正值得和本章交叉阅读的文件包括:
1 | clients/roscpp/src/libros/ |
因此调试策略也应该分开:
1 | 想看 Master 为什么注册/查询成这样 |
这不是“两套 ROS”,而是同一个 ROS1 graph 中不同职责的实现语言边界。
3. 第一条源码链:scripts/roscore -> roslaunch.main()
源码入口:
1 | ros_comm/ |
roscore 先定义 -p、-w、timeout、Master logger level 等选项,然后进入 roslaunch.main(),并主动追加 --core。
用等价阅读结构表示:
1 | 解析 roscore 自己的命令行参数 |
此时还没有执行 ROSMasterHandler.registerPublisher(),甚至还没有创建 rosmaster 子进程。现在只是把“启动 ROS core”这个任务交给 roslaunch 的通用启动框架。
3.1 --core 到底改变什么
继续进入:
1 | ros_comm/tools/roslaunch/src/roslaunch/__init__.py |
roslaunch.main() 看到 --core 后,最重要的行为有三件:
- 不允许再传普通 launch 文件;
- 未指定端口时把 Master 端口设为默认的
11311; - 构造
ROSLaunchParent(..., is_core=True, ...),随后执行start()和spin()。
因此第一段调用链可以固定为:
1 | roscore |
spin() 很重要:父进程不会在 Master 启动后立即退出,而是继续承担 process monitor 的主线程工作,直到整个 core 被关闭。
4. 为什么启动 roscore 时会自动出现 /rosout
ROSLaunchParent._load_config() 会调用:
1 | roslaunch.config.load_config_default(...) |
而 load_config_default() 会先隐式加载 core 配置,再加载用户指定的 launch 文件。roscore 模式没有普通用户 launch 文件,所以这里最重要的是:
1 | get_roscore_filename() |
Noetic 自带 roscore.xml 的核心内容只有三项:
1 | /rosversion |
这里有一个容易混淆的点:
rosmaster并不是roscore.xml里的<node>。
Master 由 ROSLaunchRunner._launch_master() 单独启动;roscore.xml 中的 /rosout 才作为 core Node 进入普通 Node 启动路径。
所以启动过程实际上分成两条:
1 | Master |
5. ROSLaunchParent 先建立什么基础设施
继续进入:
1 | ros_comm/tools/roslaunch/src/roslaunch/parent.py |
ROSLaunchParent.start() 会先执行 _start_infrastructure()。这里不是直接“调用 Master API”,而是在为整个 roslaunch 生命周期搭框架:
1 | _load_config() |
对于本项目当前 Docker + host network 的本地学习场景,最需要抓住的是 ProcessMonitor:后续 rosmaster 和 /rosout 都由 roslaunch 纳入进程生命周期管理。
随后创建 ROSLaunchRunner,进入 runner 的 setup/launch 流程。
6. ROSLaunchRunner._setup():Master 为什么一定先于 /rosout
源码:
1 | ros_comm/tools/roslaunch/src/roslaunch/launch.py |
ROSLaunchRunner._setup() 的核心顺序是:
1 | _launch_master() |
对 roscore 来说,这意味着:
1 | 先把 Master 拉起来并等它可用 |
这个顺序是必须的,因为 /rosout 自己也是普通 ROS Node。它启动后同样需要向 Master 注册自己的 Publisher/Subscriber 等图状态。如果 Master 还没有可用,后面的 Node 注册发现链就无法正常建立。
6.1 已经有 Master 时会怎样
_launch_master() 会先检查配置中的 Master URI 是否已经有 Master 在线。
普通 roslaunch 可以复用已经存在的 Master;但 roscore 以 is_core=True 运行时,如果目标 Master 已经在线,会直接报错,避免再次启动第二套 core。
这解释了常见现象:
1 | 终端 A 已经运行 roscore |
这不是端口绑定失败后才偶然出现,而是 roslaunch 有显式的 core 冲突检查。
7. create_master_process():这里才真正拼出 rosmaster --core
继续进入:
1 | ros_comm/tools/roslaunch/src/roslaunch/nodeprocess.py |
create_master_process() 构造的关键命令等价于:
1 | rosmaster --core -p <port> -w <num_workers> |
其中:
- package 是
rosmaster; - 默认端口最终是
11311; -w对应 Master 用于异步通知的 worker 数量;- timeout/logger level 等选项会按 roscore 参数继续向下传递。
随后该 LocalProcess 被注册到 ProcessMonitor,再真正 start()。
因此从终端命令到 Master 子进程的完整入口已经可以写成:
1 | roscore |
到这里才真正跨越了 进程边界。
7.1 启动 roscore 期间到底向 Master 发送了什么
把“启动进程”和“发送 ROS 控制请求”分开看,roscore 启动阶段的网络交互会更清楚。ROSLaunchRunner._launch_master() 在创建 Master 前后会通过 Master XML-RPC 做一组探测和初始化。
第一步是在线探测。roslaunch.core.Master.is_running() 实际调用:
1 | Master.getPid('/roslaunch') |
启动前调用一次,用于判断目标 ROS_MASTER_URI 是否已经有 Master;启动 rosmaster 子进程后又循环调用,直到 Master 能正常响应或超时。也就是说,终端刚执行 roscore 时,最早出现的一类 Master XML-RPC 请求不是 Topic 注册,而是 getPid 健康探测。
Master 可用后,roslaunch 会初始化本次 launch session:
1 | hasParam('/run_id') |
如果 roslaunch parent 自己的 XML-RPC server URI 已建立,还会写入类似:
1 | /roslaunch/uris/<host>__<port> |
用于记录当前 roslaunch parent 的 URI。
接着 _launch_core_nodes() 会先对 core node 执行:
1 | lookupNode('/roslaunch', '/rosout') |
如果 Master 返回“没有这个 Node”,才真正启动 /rosout。因此 /rosout 是否需要创建,也是通过 Master 的控制面查询决定的。
core node 启动后,_load_parameters() 使用 XML-RPC MultiCall 把 launch config 中的参数批量送到 Parameter Server。对默认 roscore.xml,其中包括:
1 | /rosversion |
所以从 roscore 启动到 core ready,典型的控制请求可以概括为:
1 | getPid -> Master 在线探测 |
这里没有任何 Topic payload 被 roscore 转发。启动阶段这些交互仍然属于 XML-RPC 控制面。
8. 第二条源码链:scripts/rosmaster -> rosmaster_main()
Master 子进程入口:
1 | ros_comm/tools/rosmaster/scripts/rosmaster |
这个脚本本身极薄,只负责导入 rosmaster package 并进入主函数。真正逻辑位于:
1 | ros_comm/tools/rosmaster/src/rosmaster/main.py |
rosmaster_main() 依次完成:
1 | 解析 --core / -p / -w / timeout / logger level |
这里的循环不是 XML-RPC 请求处理循环。XML-RPC server 会在 Master.start() 内部另外启动线程;rosmaster_main() 主线程主要维持进程生命周期并等待 shutdown。
9. Master.start():真正把“网络入口”和“业务处理器”装配起来
源码:
1 | ros_comm/tools/rosmaster/src/rosmaster/master.py |
Master 这个类很薄。它的价值是把两个核心对象装起来:
1 | ROSMasterHandler |
等价阅读结构:
1 | handler = ROSMasterHandler(num_workers) |
这里形成一个非常重要的职责分工:
1 | XmlRpcNode |
这两个对象不能混成一个“Master 类”来理解。
10. XmlRpcNode 与线程模型:Master API 在什么线程执行
XmlRpcNode 来自:
1 | ros_comm/tools/rosgraph/src/rosgraph/xmlrpc.py |
XmlRpcNode.start() 会启动一条 Python thread 执行 server run()。初始化阶段创建的是:
1 | ThreadingXMLRPCServer |
它继承 Python socketserver.ThreadingMixIn 和 SimpleXMLRPCServer,因此允许多个 XML-RPC 请求并发进入。
这意味着 Master 不是:
1 | 一个主线程串行处理所有 registerPublisher/registerSubscriber |
而更接近:
1 | rosmaster main thread |
正因为 XML-RPC API 可能并发进入,ROSMasterHandler 在修改图状态时需要 ps_lock。RegistrationManager 源码也明确说明其自身不是线程安全容器,调用方需要负责外部加锁。
除此之外还有第二类线程:MarkedThreadPool。它不是用来接收 Master API 的,而是用来执行 Master 主动回调其他 Node 的任务,例如 publisherUpdate 和同名 Node 替换时的 shutdown。
所以本章后续遇到异步路径时,必须区分:
1 | XML-RPC request thread |
11. Master 内部类关系:先把对象所有权看清
到这里再看整体对象关系,已经不会把类名当成抽象架构名词。
关键对象可以压缩成下面这张表:
| 类/对象 | 所在 package | 持有什么 | 主要职责 |
|---|---|---|---|
ROSLaunchParent |
roslaunch |
config、runner、ProcessMonitor | 组织整个 launch/core 生命周期 |
ROSLaunchRunner |
roslaunch |
launch config、process monitor | 启动 Master、core Node、普通 Node、参数 |
Master |
rosmaster |
ROSMasterHandler、XmlRpcNode |
装配并启动/停止 Master |
XmlRpcNode |
rosgraph |
ThreadingXMLRPCServer、handler |
建立 XML-RPC 网络入口 |
ROSMasterHandler |
rosmaster |
注册管理器、参数字典、线程池、Topic type 表 | 实现 Master/Parameter XML-RPC API |
RegistrationManager |
rosmaster |
nodes + 四类 Registrations |
维护 Node 与注册关系 |
NodeRef |
rosmaster |
某 Node 的 API 与注册 key 列表 | 表示某个 caller_id 当前拥有哪些注册 |
Registrations |
rosmaster |
map、Service 时额外有 service_api_map |
建立 Topic/Service/Param key 到 Node 的索引 |
ParamDictionary |
rosmaster |
参数树 + RegistrationManager |
Parameter Server 数据存取和订阅更新 |
MarkedThreadPool |
rosmaster |
异步任务 | 向 Node 发 publisherUpdate、paramUpdate、shutdown 等回调 |
这一层对象关系解释了后面几乎所有 Master 行为。
12. Node 到底怎样“注册到 Master”:并不存在 registerNode()
这是理解 Master 注册表最关键的一点。
从 ROS 图概念看会自然认为:
1 | Node 启动 |
Noetic Master API 并没有这种独立 registerNode() 流程。
Node 是在注册下面这些资源时,作为副作用进入 RegistrationManager.nodes:
1 | registerPublisher |
它们最终都会走向类似:
1 | RegistrationManager._register(...) |
所以 Master 对一个 Node 的核心身份实际上是:
1 | caller_id |
12.1 NodeRef 记录什么
一个 NodeRef 维护:
1 | id -> caller_id |
也就是说,NodeRef 不是 Node 的进程对象,不持有 PID,不负责执行业务 callback。它只是 Master 图状态中的一条“这个 Node 当前登记了什么”的记录。
12.2 Node 什么时候从 nodes 删除
正常 unregister 时,RegistrationManager._unregister() 会从对应 Registrations 删除资源,同时从 NodeRef 移除 key。
当:
1 | param_subscriptions |
全部为空时,NodeRef.is_empty() 为真,Master 才会删除 nodes[caller_id]。
因此 Node 的 Master 侧存在性是由注册资源驱动的,不是一个独立“Node 在线状态机”。
13. 遇到同名 Node 怎么办:为什么后启动的会顶掉前一个
RegistrationManager._register_node_api() 里专门处理:
1 | 相同 caller_id |
这表示 Master 看到:
1 | 同一个 Node 名称 |
处理逻辑是:
1 | 找到旧 NodeRef |
这里有一个明确的异步边界:
1 | 当前注册请求线程 |
所以“同名 Node 后启动会让前一个退出”不是 roslaunch 特有行为。即使两个进程不是由同一个 launch 文件启动,只要它们向同一个 Master 使用相同 caller_id、但暴露不同 caller_api,Master 的注册逻辑就会触发替换。
14. Topic 到达 Master 后执行什么:Publisher 与 Subscriber 是两张注册表
ROSMasterHandler.__init__() 通过 RegistrationManager 持有:
1 | publishers |
其中 Topic 不是一张“Topic 对象表”,而是至少要同时理解:
1 | publishers.map |
14.1 Subscriber 先启动:registerSubscriber() 做什么
一个 roscpp Subscriber 在第 07 章客户端侧最终会向 Master 发:
1 | registerSubscriber(caller_id, topic, topic_type, caller_api) |
Master 侧执行顺序可以压缩为:
1 | XML-RPC request thread |
所以 Subscriber 注册的返回值不是 TCPROS 地址,而是:
1 | 当前已经发布这个 Topic 的 Node XML-RPC API URI 列表 |
Subscriber 后续再直接联系这些 Publisher Node。
14.2 Publisher 后启动:registerPublisher() 做什么
Publisher 向 Master 注册后,Master 会:
1 | 获取 ps_lock |
这里最值得继续进入的是 _notify_topic_subscribers()。
它不会在当前 XML-RPC request thread 中同步逐个连接所有 Subscriber,而是把通知任务交给 MarkedThreadPool。worker 随后对每个 Subscriber 的 caller_api 调用:
1 | publisherUpdate('/master', topic, pub_uris) |
于是形成:
1 | Publisher Node |
到 requestTopic() 以后,Master 就退出 Topic 建链主路径。TCPROS/UDPROS 的协议协商和消息字节传输属于第 08 章的数据面。
14.3 Master 在 Topic 中到底存什么、不存什么
Master 存:
1 | Topic 名称 |
Master 不存:
1 | Publisher 的业务消息队列 |
这些都属于具体 Node 进程内部或后续点对点数据连接。
15. Service 到达 Master 后执行什么:这里存的是 service_api
Service 和 Topic 在 Master 中有一个本质差异。
registerService() 参数同时包含:
1 | caller_api |
Master 会把 Node 身份写入 NodeRef,同时把 Service 名称写入 services 注册表。
Registrations 对 Service 还有一个特殊约束:
1 | 同一个 service key 只允许一个当前有效 provider |
因此 Service 注册表额外维护 service_api_map。
15.1 Client 查询 Service 时 Master 返回什么
Service Client 调用:
1 | lookupService(caller_id, service) |
Master 直接从 services.get_service_api(service) 取出并返回:
1 | rosrpc://host:port |
这和 Topic 的发现链不同:
1 | Topic |
因此 Service 请求体和响应体也不经过 Master。Master 仍然只承担控制面发现。
16. Action 到 Master 后怎么办:Master 根本没有 Action 注册表
阶段 11 已经确认 ActionClient/ActionServer 最终建立五类 Topic:
1 | <action>/goal |
从 Master 源码可以进一步得到一个更强的结论:
ROS1 Master 不认识“Action”这种独立 graph entity。
直接看 getSystemState() 的系统状态结构即可:
1 | [publishers, subscribers, services] |
没有:
1 | actions |
RegistrationManager 的注册类型也只有:
1 | TOPIC_SUBSCRIPTIONS |
同样没有 ACTION。
因此 Action 到达 Master 后的真实过程是:
| Action 端 | actionlib 创建的 ROS 接口 | Master 实际看到的行为 |
|---|---|---|
| Client | publish goal |
普通 registerPublisher() |
| Client | publish cancel |
普通 registerPublisher() |
| Client | subscribe status |
普通 registerSubscriber() |
| Client | subscribe feedback |
普通 registerSubscriber() |
| Client | subscribe result |
普通 registerSubscriber() |
| Server | subscribe goal / cancel |
普通 Subscriber 注册 |
| Server | publish status / feedback / result |
普通 Publisher 注册 |
Goal ID、状态迁移、Cancel/Preempt、Feedback/Result 关联都由 actionlib 客户端和服务端实现,不进入 rosmaster 的数据模型。
这也解释了为什么:
1 | rostopic list |
能直接看到 Action 展开的 Topic,而 Master 不需要额外提供一个 registerAction()。
17. Parameter Server 在哪里:就在同一个 rosmaster 进程里
ROSMasterHandler.__init__() 同时创建:
1 | ParamDictionary(self.reg_manager) |
所以 Noetic 的默认架构不是:
1 | rosmaster process |
而是:
1 | 同一个 rosmaster 进程 |
二者共用同一个 XML-RPC server 入口。
17.1 ParamDictionary 存什么
其核心状态是:
1 | parameters = {} |
按 ROS namespace 形成嵌套参数树,并通过自己的 RLock 保护读写。
普通:
1 | getParam |
最终都操作这个参数字典。
17.2 subscribeParam() 为什么又回到 RegistrationManager
参数订阅不能只存一个字典值,还必须记录:
1 | 哪些 Node 想在某个参数变化时收到 paramUpdate |
所以 param_subscribers 也属于 RegistrationManager。
当参数被修改或删除时,ParamDictionary 计算受影响的 subscriber,再由 Master 异步回调对应 Node 的:
1 | paramUpdate(...) |
因此 Parameter 和 Topic 在控制面上有相似结构:
1 | Node 先把“我关心什么”登记到 Master |
但 Parameter 没有 TCPROS 数据面,值本身就是通过 XML-RPC API 读写/推送。
18. ROSMasterHandler 到底是怎样的数据中心
把 ROSMasterHandler.__init__() 的核心字段放在一起,可以形成 Master 的最小心智模型:
1 | ROSMasterHandler |
看到这里后,Master API 就不再是几十个彼此独立的函数。它们基本都属于下面几类操作:
| API 类型 | 典型函数 | 实际目的 |
|---|---|---|
| 注册 | registerPublisher、registerSubscriber、registerService |
修改 RegistrationManager |
| 注销 | unregisterPublisher 等 |
删除注册关系,必要时删除空 NodeRef |
| 查询 | lookupNode、lookupService、getSystemState |
从注册表读取发现信息 |
| Topic 通知 | publisherUpdate 的发起侧逻辑 |
Publisher 集合变化后通知 Subscriber |
| Parameter | getParam、setParam、subscribeParam |
访问 ParamDictionary 和参数订阅表 |
| 生命周期 | shutdown、getPid |
Master 自身管理/诊断 |
19. lookupNode()、lookupService()、getSystemState() 分别回答什么
这三个 API 很适合用来反推 Master 保存的数据边界。
19.1 lookupNode(caller_id, node_name)
回答:
1 | 这个 Node 的 XML-RPC API 在哪里? |
数据来源:
1 | RegistrationManager.nodes[node_name].api |
返回的是:
1 | http://host:node_xmlrpc_port/ |
它不是 Topic 的 TCPROS 地址,也不是 Service 的 ROSRPC 地址。
19.2 lookupService(caller_id, service_name)
回答:
1 | 这个 Service Server 的 ROSRPC endpoint 在哪里? |
数据来源:
1 | services.service_api_map |
返回的是:
1 | rosrpc://host:service_port |
19.3 getSystemState(caller_id)
回答:
1 | 当前 ROS graph 有哪些 Publisher、Subscriber、Service provider? |
返回结构是:
1 | [ |
这是一张 控制面快照,不是运行时数据连接快照。它不能告诉某个 TCPROS socket 当前发送到第几个字节,也不能告诉某个 callback 是否正在执行。
20. 把控制面和数据面重新放到一张表里
完成 Master 源码后,阶段 06~11 中出现的几个通信机制可以统一成下面的边界:
| 机制 | Master 保存/处理什么 | Master 返回或通知什么 | 后续业务数据是否经过 Master |
|---|---|---|---|
| Node | caller_id -> NodeRef(caller_api) |
lookupNode() 返回 Node XML-RPC URI |
不适用 |
| Topic | Publisher/Subscriber 注册 + Topic type | Publisher URI 列表、publisherUpdate |
否,后续 requestTopic -> TCPROS/UDPROS 点对点 |
| Service | Service provider + service_api |
lookupService() 返回 ROSRPC URI |
否,Client 直接连 Server |
| Action | 没有 Action 表;只看到五类 Topic | 按普通 Topic 规则发现 | 否,仍是 Topic 数据面 |
| Parameter | 参数字典 + param subscriber | XML-RPC 返回值或 paramUpdate |
Parameter 本身就走 XML-RPC |
这张表可以把 Master 的角色压缩成一句工程判断:
ROS1 Master 是名称注册、发现和参数控制面的中心,不是 Topic/Service/Action 业务 payload 的中心转发器。
21. 在当前 ros1-docker 中怎样准备 Master 源码
第 05 章已经使用 ros_debug_ws 阅读 ros_comm。如果源码已经存在,不要重复 clone。
Container 中确认:
1 | ls /workspace/ros_debug_ws/src/ros_comm/tools/roslaunch |
如果还没有 ros_comm:
1 | cd /workspace/ros_debug_ws/src |
本章需要的主要源码都在这一个仓库里,不需要为了 Master 再 clone roscpp_core。
22. 怎样构建自己的 roslaunch/rosmaster overlay
进入 Debug workspace:
1 | cd /workspace/ros_debug_ws |
如果该 workspace 还没配置:
1 | catkin init |
然后构建:
1 | catkin build rosgraph rosmaster roslaunch |
这里保留 Debug 配置是为了和现有 debug workspace 一致;但要再次强调:
1 | 它不会让 rosmaster.py 获得“DWARF 调试信息” |
因为 Master 主体是 Python。
构建后:
1 | source /workspace/ros_debug_ws/devel/setup.bash |
先不要只看 which roscore,因为 shell PATH 中仍可能首先出现 /opt/ros/noetic/bin/roscore。判断 Python package 是否真的来自 overlay,更可靠的是:
1 | python3 -c 'import roslaunch; print(roslaunch.__file__)' |
期望路径优先落在:
1 | /workspace/ros_debug_ws/... |
而不是只有:
1 | /opt/ros/noetic/lib/python3/dist-packages/... |
这样才能证明当前 Python import 的确使用了 checkout 的源码 overlay。
23. 最适合这套 Python 源码的动态阅读方式
23.1 第一遍:直接运行完整 roscore
先观察完整进程关系:
1 | source /workspace/.devcontainer/ros_env.bash |
另一个终端:
1 | ps -ef | grep -E 'roscore|rosmaster|rosout' | grep -v grep |
预期能够区分:
1 | roscore / roslaunch parent |
这一步的目标不是背 PID,而是确认源码阅读里的三个运行角色确实存在。
23.2 第二遍:人为触发 Master API
保持 roscore 运行,再启动阶段 02/03 已有的 Node,例如 Publisher 和 Subscriber,然后观察:
1 | rosnode list |
这些命令看到的是 Master/Node API 汇总出来的控制面信息。与此同时可以读取本次 ROS session 的 master.log,确认 registerPublisher、registerSubscriber、lookup 等 API 调用顺序。
23.3 第三遍:单独调 rosmaster,减少 roslaunch 干扰
如果目标是逐行理解 ROSMasterHandler,可以暂时不运行完整 roscore,而是直接启动 Master:
1 | source /workspace/.devcontainer/ros_env.bash |
这样能直接在 Python debugger 中进入:
1 | rosmaster_main() |
此时没有完整 roscore 自动启动的 /rosout,但 Master 和 Parameter Server 本身已经可以用于研究注册逻辑。若实验需要 /rosout,可在另一个终端手工运行:
1 | rosrun rosout rosout |
这种“先隔离 Master,再补 core Node”的方式,比一上来同时调 roslaunch、rosmaster、rosout 三个进程更容易建立调用链。
24. 源码应该按什么顺序读:不要从 master_api.py 随机翻函数
本章最推荐的真实阅读顺序是:
1 | 1. roslaunch/scripts/roscore |
这个顺序的核心原则是:
1 | 先弄清“谁把谁启动起来” |
如果一开始就打开 800 多行的 master_api.py 从 registerPublisher 往下看,很容易知道单个函数在做什么,却不知道它为什么会在这个进程、这个线程、这个生命周期阶段被调用。
25. 四条典型事件重新走一遍
25.1 一个新 Publisher 出现
1 | roscpp TopicManager |
25.2 一个新 Subscriber 出现
1 | roscpp TopicManager |
25.3 一个 Service Server 出现
1 | ServiceManager |
25.4 一个 Action Server 出现
1 | ActionServer |
26. 从 roscore 源码回到真实工程:整套 ROS1 系统怎样启动
前 25 节解决的是“ROS core 内部怎么工作”。到这里还需要把源码认知落回工程,否则仍然会留下三个实际问题:
1 | 项目部署后,谁来启动 roscore? |
当前仓库新增一套最小实现,把这三个问题直接落到可运行文件中:
1 | compose.runtime.yaml |
这套实现不改变原来的开发模式。compose.yaml 仍然让 ros1-dev 执行 sleep infinity,方便 VS Code、GDB、
手工 roscore/roslaunch 和源码阅读;compose.runtime.yaml 才代表“设备运行态”。
26.1 实际工程通常不需要人工先敲 roscore
开发阶段为了观察每一个进程,可以手工执行:
1 | roscore |
但部署时更常见的是只保留一个顶层入口:
1 | roslaunch ros1_bringup system.launch |
普通 roslaunch 运行时会检查目标 Master。对于当前工程默认的本机 Master 配置,如果 Master 尚未运行,ROSLaunchRunner 会进入 _launch_master(),最终调用前文已经读过的 create_master_process():
1 | roslaunch ros1_bringup system.launch |
因此工程入口不需要再额外包装一条“先执行 roscore”命令。roscore 适合独立学习和手工调试;顶层roslaunch 更适合把 Master、core Node 和业务 Node 组织成一个系统启动入口。
这里的“自动启动 Master”有明确边界:普通 roslaunch 只有在目标 Master 地址属于本机时才会自动创建新的 Master。
如果 ROS_MASTER_URI 指向另一台机器,而远程 Master 又无法联系,Noetic validate_master_launch() 会直接报错,
不会在本机偷偷启动一个 Master 来替代远程 Master。当前仓库使用 Host network 和默认本机 Master,因此走的是前一种路径。
26.2 为什么新增 ros1_bringup,而不是继续把所有 launch 塞进 ros1_hello
业务 package 和系统启动 package 的职责不同:
1 | ros1_hello |
当前新增目录:
1 | ros_ws/src/ |
这种拆法的价值在于:驱动、感知、控制 package 可以各自维护自己的实现,而 bringup package 只负责系统装配。
后续阶段 B 增加 CAN/UART/Ethernet Driver Node 时,也可以继续沿这个边界扩展,而不需要把所有实现放进同一个 package。
26.3 system.launch 怎样一次启动工程 Node
当前 ros1_bringup/launch/system.launch 先复用第 03 章已有的 /chatter 示例,然后再启动 READY 依赖实验:
1 | <launch> |
这里故意把依赖方 ready_client 写在提供方 ready_server 前面。这个顺序只用于把问题暴露得更直观:
launch 文件中的书写位置不是业务 READY 契约。
即使某个进程先被 roslaunch 创建,也只能说明操作系统已经开始运行这个进程,不能推出下面这些条件已经成立:
1 | CAN 设备已经打开 |
进程“存在”和业务“可用”必须分开建模。
26.4 ready_server:初始化完成以后才发布 READY Service
ready_server.cpp 用 3 秒延时模拟真实驱动初始化。延时只是为了让实验过程可见,不是依赖管理方案。
真正的契约是:初始化完成前不 advertise READY Service;初始化完成后才注册 /demo_driver/ready。
核心代码:
1 | double startup_delay = 3.0; |
这条路径和本章前半部分的 Master 服务端源码可以直接对应:
1 | ready_server |
此时 Master 才能够通过 lookupService() 返回这个 Service 的 rosrpc://... endpoint。
26.5 ready_client:等待 READY,而不是 sleep(5) 猜时间
依赖方启动后立刻等待:
1 | const std::string ready_service_name = "/demo_driver/ready"; |
ros::service::waitForService() 还不是单纯“看 Master 注册表里有没有这个名字”。Noetic service.cpp 中,waitForService() 会循环调用 service::exists();exists() 先经 ServiceManager::lookupService() 获取 Service
host/port,再建立同步 TransportTCP 连接并发送 probe=1 的 Service header。也就是说,这个等待条件不仅要求 Master
已经知道 Service provider,还要求对应 ROSRPC endpoint 能够接受连接。
READY Service 可用后,再真正调用一次:
1 | ros::ServiceClient ready_client = |
只有这一步成功后,才进入业务路径:
1 | ros::Subscriber chatter_sub = |
所以这里建立的因果关系是:
1 | ready_client 进程已经启动 |
这比下面这种写法可靠:
1 | 启动 A |
固定延时只能假设“5 秒通常够用”,不能表达真实状态。机器负载、USB 枚举、CAN/串口初始化、网络连接和设备
固件启动时间变化后,这种假设很容易失效。
26.6 构建并运行完整 bringup
在开发 Container 中先构建新增 package:
1 | cd /workspace/ros_ws |
这里不需要单独启动 roscore。直接执行:
1 | roslaunch ros1_bringup system.launch |
默认 ready_server 会模拟 3 秒初始化,因此日志顺序应该能够观察到类似阶段:
1 | ready_client: waiting for /demo_driver/ready |
另开终端可以观察:
1 | rosnode list |
也可以调大初始化时间,让等待过程更明显:
1 | roslaunch ros1_bringup system.launch \ |
如果把 ready_timeout 设置得比初始化时间短,ready_client 会在依赖未就绪时退出,而不是假装系统已经可以工作。
当前 system.launch 把 ready_client 和 ready_server 都标记为 required="true",因此任一关键 Node 退出都会让
roslaunch 关闭当前进程组;运行在 compose.runtime.yaml 下时,外层 Docker restart policy 再决定是否重新拉起整套运行态。
26.7 运行态 Docker:Container 启动后直接执行顶层 roslaunch
开发 Compose 继续保持:
1 | # compose.yaml |
因为开发 Container 的目标是长期存在,方便 VS Code attach、GDB、多个终端和手工实验。
运行态另建 compose.runtime.yaml:
1 | services: |
这里有两个刻意保留的工程边界。
第一,运行态 Container 不在每次开机时自动重新编译 workspace。源码构建仍然属于开发/CI/部署阶段;运行阶段只消费已经存在的ros_ws/devel。这可以避免设备每次启动都把编译器、源码变化和启动时序混进运行生命周期。
第二,restart: unless-stopped 不是“Compose 文件放在磁盘上就会自己创建 Container”。第一次部署仍然需要创建运行态:
1 | docker compose -f compose.runtime.yaml up -d --build |
之后,只要 Docker daemon 随 Host 启动,并且这个 Container 没有被人工 stop/remove,Docker 的 restart policy 会在
Docker daemon 重启后恢复该 Container。roslaunch 又是 Container 内的主业务进程,因此完整恢复链是:
1 | Host 启动 |
查看运行态:
1 | docker compose -f compose.runtime.yaml ps |
停止运行态:
1 | docker compose -f compose.runtime.yaml down |
开发和运行两个 Compose 都使用 Host network,因此不要在两个 Container 中同时启动两套默认端口的 ROS Master。
开发 Container 可以继续保持 sleep infinity;需要手工运行 roscore/roslaunch 前,应先确认运行态 Master 是否已经占用
默认 11311。
26.8 Docker、roslaunch、Node READY 分别负责哪一层
实际项目中最容易混淆的是三个不同生命周期:
| 层级 | 当前工程机制 | 解决的问题 | 不解决的问题 |
|---|---|---|---|
| Host/Container | Docker restart: unless-stopped |
Host/Docker 重启后恢复运行态 Container | 不判断某个 ROS Node 的业务资源是否 READY |
| ROS 进程组 | roslaunch |
启动 Master/core Node/业务 Node,并监控 ROS 进程 | 不应把 <node> 书写顺序当成业务初始化依赖 |
| 业务状态 | READY Service / 状态机 | 表达驱动、Service、Action、设备等是否真正可用 | 不负责创建 Docker Container |
当前示例对两个 READY Node 使用 required="true":关键 Node 退出时让 roslaunch 结束整套进程组,再由 Docker
restart policy 负责外层恢复。required 仍然只是进程级失败策略,它不表达业务 READY。
如果以后改用 respawn="true",它也只属于第二层:Node 进程退出后让 roslaunch 重新创建进程;它不能证明
重启后的 Node 已经恢复业务 READY。required 与 respawn 也不能同时用于同一个 <node>。
对于更复杂的驱动系统,READY 条件可以进一步升级为明确状态机:
1 | INIT |
控制 Node 依赖的是某个可观察状态,而不是“另一个进程已经存在了几秒”。
26.9 从开机到业务 RUNNING,再把整条链走一遍
至此可以把本章的源码链和工程链合并:
1 | Host / Docker |
这条链说明工程里“自启动”和“有顺序”不是一件事:
1 | 自启动 |
只有把这两层分开,系统启动才不会退化成一串不可证明的 sleep N。
27. 阶段 A 到这里真正闭合了什么
阶段 06~11 的学习顺序现在可以闭合成一套完整 ROS1 运行模型:
1 | Node 启动与 ros::start() |
因此进入阶段 B 的“ROS 驱动 Node 软件架构”之前,控制面、数据面、进程、线程、注册发现和上层通信语义已经能够相互对应。后续再设计 CAN/UART/Ethernet Driver 时,就可以明确判断:
1 | 哪些状态属于 ROS graph |
这正是阶段 A 从“会写 Node”过渡到“能设计驱动 Node”的边界。
参考源码与官方资料
源码基线:ROS1 Noetic noetic-devel。
roslaunch/scripts/roscore:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/scripts/roscoreroslaunch/__init__.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/src/roslaunch/__init__.pyroslaunch/config.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/src/roslaunch/config.pyroslaunch/resources/roscore.xml:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/resources/roscore.xmlroslaunch/parent.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/src/roslaunch/parent.pyroslaunch/launch.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/src/roslaunch/launch.pyroslaunch/nodeprocess.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/src/roslaunch/nodeprocess.pyrosmaster/scripts/rosmaster:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/scripts/rosmasterrosmaster/main.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/src/rosmaster/main.pyrosmaster/master.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/src/rosmaster/master.pyrosmaster/master_api.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/src/rosmaster/master_api.pyrosmaster/registrations.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/src/rosmaster/registrations.pyrosmaster/paramserver.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/src/rosmaster/paramserver.pyrosgraph/xmlrpc.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosgraph/src/rosgraph/xmlrpc.pyrosmaster/CMakeLists.txt:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/CMakeLists.txtrosmaster/setup.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/rosmaster/setup.pyroslaunch/CMakeLists.txt:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/CMakeLists.txtroslaunch/setup.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/setup.pyactionlib/client/action_client.h:https://github.com/ros/actionlib/blob/noetic-devel/actionlib/include/actionlib/client/action_client.hroslaunch/launch.py:https://github.com/ros/ros_comm/blob/noetic-devel/tools/roslaunch/src/roslaunch/launch.pyroscpp/master.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/master.cpproscpp/service.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/service.cpp- Docker Compose
restart:https://docs.docker.com/reference/compose-file/services/#restart actionlib/server/action_server.h:https://github.com/ros/actionlib/blob/noetic-devel/actionlib/include/actionlib/server/action_server.h











