ROS教程06:ROS1 Node 启动与停止流程
摘要:围绕 ROS1 roscpp 的 Node 生命周期,说明共享库与 main 前静态初始化、ros::init、NodeHandle、ros::start、shutdown 的关系,并在 Docker 中接入 rqt GUI。
@[toc]
这一章只建立一个整体模型:一个 roscpp Node 从进程启动、ROS 初始化、内部运行设施启动,到 shutdown 和资源释放,依次发生什么。
实验继续使用:
1 | ros_ws/src/ros1_hello # 业务 Node |
先看整条生命周期,后文只展开需要掌握的节点。
需要先抓住四个边界:
main()之前可能已经执行共享库中的全局/静态对象构造;ros::init()只把 roscpp 初始化到 initialized;- 第一个
NodeHandle在NodeHandle::construct()中触发ros::start(); ros::start()建立真正的运行设施,shutdown 再按相反方向释放这些资源。
1. .so 在哪里,main() 前的代码又是谁执行的
1.1 当前工程里的 .so 在哪里看
.so 是构建产物,不是在 src/ 目录里阅读的源码文件。
当前 Debug overlay 构建出的 ROS 库主要在:
1 | /workspace/ros_debug_ws/devel/lib/ |
例如:
1 | ls -lh /workspace/ros_debug_ws/devel/lib/libroscpp.so |
官方 Noetic underlay 自带的库则在:
1 | /opt/ros/noetic/lib/ |
例如:
1 | ls -lh /opt/ros/noetic/lib/libroscpp.so |
如果要知道 hello_node 实际声明依赖了哪些共享库:
1 | readelf -d /workspace/ros_ws/devel/lib/ros1_hello/hello_node \ |
如果要看动态链接器最终解析到了哪一份文件:
1 | ldd /workspace/ros_ws/devel/lib/ros1_hello/hello_node |
这两条命令的区别是:
1 | readelf -d |
因此在 Debug overlay 环境中,重点确认类似:
1 | libroscpp.so => /workspace/ros_debug_ws/devel/lib/libroscpp.so |
而不是意外回到:
1 | /opt/ros/noetic/lib/... |
程序运行起来之后,还可以直接看进程当前映射了哪些共享库:
1 | pid=$(pgrep -n hello_node) |
1.2 为什么 hello_node 会加载这些 .so
业务 package 的 CMake 中:
1 | target_link_libraries(hello_node |
find_package(catkin REQUIRED COMPONENTS roscpp std_msgs) 会把 roscpp 等依赖放进 ${catkin_LIBRARIES}。
链接 hello_node 时,链接器把需要的共享库记录到 ELF 的动态段中。Linux 创建进程后,ELF interpreter,也就是动态装载器,会处理这些 DT_NEEDED:
1 | hello_node ELF |
所以 main() 并不是 Linux 进程真正的第一条指令。对本章只需要区分:
| 层次 | 入口 | 作用 |
|---|---|---|
| ELF | _start 一类的进程入口 |
C Runtime 启动前的真正 ELF 入口 |
| C/C++ | main() |
进入用户应用代码 |
| ROS | ros::init() |
进入 roscpp 的显式初始化 |
如果想看 ELF 自己记录的入口地址:
1 | readelf -h /workspace/ros_ws/devel/lib/ros1_hello/hello_node \ |
VS Code 当前 launch.json 中的:
1 | "stopAtEntry": false |
改成:
1 | "stopAtEntry": true |
即可让 F5 启动后先停在程序入口附近。正常学习 roscpp 生命周期时,从 main() 开始阅读已经足够。
2. 为什么 rosconsole.cpp 会先执行,随后又看到 master.cpp、network.cpp、param.cpp
这里要严格区分四种“顺序”:
1 | CMake 源文件列表 |
它们不是同一个东西。
2.1 rosconsole.cpp 为什么能在 main() 前执行
rosconsole 是独立共享库。源码中存在:
1 | class StaticInit |
g_static_init 是全局 C++ 对象。
当动态装载器初始化 librosconsole.so 时,会执行这个对象的构造函数,于是进入:
1 | ROSCONSOLE_AUTOINIT |
所以完全可能看到:
1 | ros::console::initialize() |
它不是 roscore 调用了 hello_node,而是当前 hello_node 进程自己的共享库初始化。
2.2 master.cpp、network.cpp 等为什么也会在 main() 前出现
这些文件都属于 libroscpp.so。
例如它们包含全局对象:
1 | // master.cpp |
std::string、std::map、Boost mutex 等都不是简单的纯零初始化对象,它们需要 C++ 构造过程。因此 libroscpp.so 初始化时,也会执行各 translation unit 生成的静态初始化函数。
XMLRPCManager::instance() 本身采用函数内 static:
1 | static XMLRPCManagerPtr xmlrpc_manager = boost::make_shared<XMLRPCManager>(); |
这一个对象只有第一次真正调用 XMLRPCManager::instance() 时才构造;但 xmlrpc_manager.cpp 里其它具有动态初始化需求的全局/静态对象仍可能产生初始化代码。
2.3 “构建顺序”在哪里看
roscpp 的源码成员直接写在:
1 | ros_debug_ws/src/ros_comm/clients/roscpp/CMakeLists.txt |
其中:
1 | add_library(roscpp |
这说明这些 .cpp 最终一起组成 libroscpp.so。
每个 .cpp 先独立编译成 .o。并行构建时,不存在值得依赖的“master.cpp 必须先编译、network.cpp 再编译”规则。
当前构建目录下真正的链接命令可以看:
1 | cat /workspace/ros_debug_ws/build/roscpp/CMakeFiles/roscpp.dir/link.txt |
只看 src/libros 对象文件:
1 | tr ' ' '\n' \ |
每个源文件对应的对象文件则在:
1 | ros_debug_ws/build/roscpp/CMakeFiles/roscpp.dir/src/libros/ |
2.4 “main 前实际初始化顺序”在哪里看
最终顺序已经不是 CMake 源码调用链,而是链接器写进 ELF 的初始化表。
可以看:
1 | readelf -W -S /workspace/ros_debug_ws/devel/lib/libroscpp.so \ |
也可以检查各 .o 是否生成了全局初始化函数:
1 | nm -aC \ |
把 master.cpp.o 换成 network.cpp.o、param.cpp.o 等即可。
这里最重要的结论是:
不同
.cpptranslation unit 之间的全局动态初始化顺序不应作为 ROS API 契约依赖。当前二进制里可以观察它,但业务逻辑不要依赖它。
因此你在 main() 前看到的:
1 | rosconsole.cpp |
属于“共享库/translation unit 静态初始化”这一层。
它和下面 ros::init() 内真正写死的函数调用顺序是两回事。
3. ros::init():显式初始化顺序
当前应用调用:
1 | ros::init(argc, argv, "hello_node"); |
本章不展开重载形式,只看最终进入核心初始化后做什么。
源码:
1 | ros_debug_ws/src/ros_comm/clients/roscpp/src/libros/init.cpp |
核心顺序非常明确:
1 | 注册 atexit callback |
这才是本章真正需要记住的 ros::init() 调用顺序。
network::init()
主要决定当前 Node 对外使用的 host 和 TCPROS 相关网络配置。
它会考虑:
1 | __hostname |
这里只需要知道它在准备“本 Node 怎样被其它 ROS 进程访问”的网络身份。
master::init()
主要确定 Master URI:
1 | __master |
随后把 URI 拆成 host/port 保存下来。
注意:这里仍只是准备 Master 地址,不是在本节展开注册发现过程;注册发现留到 07。
this_node::init() 与 names::init()
它负责形成当前 Node 的最终名字和 namespace,并在确定 namespace 后初始化 ROS 名称 remapping。
因此 ros::init() 完成以后,this_node::getName()、namespace、全局 name remapping 等基础状态已经建立。
file_log::init()
确定当前 Node 的日志目录和日志文件路径。
param::init()
建立参数系统当前进程侧需要的初始化状态,例如参数更新回调绑定等。
所以可以把 ros::init() 压缩成一句:
1 | 解析启动参数 |
此时还没有执行 ros::start()。
4. 第一个 NodeHandle:从 initialized 进入 started
当前代码:
1 | ros::NodeHandle nh; |
实现:
1 | ros_debug_ws/src/ros_comm/clients/roscpp/src/libros/node_handle.cpp |
构造函数内部顺序是:
1 | 处理 namespace |
注意:initRemappings() 不在 construct() 内部。二者是构造函数依次调用的两个阶段。
4.1 NodeHandle::construct()
核心职责:
- 检查是否已经执行
ros::init(); - 创建
NodeHandleBackingCollection; - 解析当前 NodeHandle 的 namespace;
- 加锁保护全局
g_nh_refcount; - 若这是第一个 NodeHandle 且 Node 尚未 started,则调用
ros::start(); - 增加全局 NodeHandle 引用计数。
关键判断:
1 | if (g_nh_refcount == 0 && !ros::isStarted()) |
因此当前程序的启动边界非常清楚:
1 | ros::init() |
后面再创建 pnh("~") 时,Node 已经 started,不会重复 start。
4.2 NodeHandle::initRemappings()
这个函数只处理当前 NodeHandle 自己携带的局部 remapping。
源码逻辑是把每一对映射保存两份:
1 | 原始 from/to |
例如:
1 | ros::M_string remaps; |
之后这个 nh 在解析名称时,先检查自己的局部 remapping;没有命中才继续使用 ros::names 的全局 remapping。
当前:
1 | ros::NodeHandle nh; |
都没有显式传入 remappings,所以 initRemappings() 仍会执行,但传入 map 为空。
pnh("~") 中的 ~ 会先解析到当前 Node 的 private namespace,因此:
1 | pnh.param("publish_rate", ...); |
位于 /hello_node 的 private namespace 下。
5. ros::start():真正启动运行设施
ros::start() 仍位于:
1 | ros_comm/clients/roscpp/src/libros/init.cpp |
先建立全局运行状态:
1 | g_shutdown_requested = false |
然后建立 shutdown 入口:
1 | PollManager 注册 checkForShutdown listener |
再初始化内部 timer manager,并依次启动:
1 | TopicManager::start() |
本章对这些 Manager 只建立职责印象:
| 组件 | 这里先理解为 |
|---|---|
TopicManager |
Topic 发布/订阅状态和相关处理入口 |
ServiceManager |
Service server/client 状态 |
ConnectionManager |
TCPROS/UDPROS 连接与监听 |
PollManager |
网络 fd 事件轮询和 poll listener |
XMLRPCManager |
当前 Node 自己的 XML-RPC server/client 管理 |
之后 ros::start() 继续完成:
1 | 安装 SIGINT handler |
所以当前阶段只需要形成:
1 | ros::init() |
后续再分别深入 Master/XML-RPC、Topic/TCPROS、CallbackQueue,不需要现在把 start() 中每个 Manager 全部钻完。
6. rosconsole:三个 backend 到底用哪一个
源码树同时存在:
1 | rosconsole_log4cxx.cpp |
它们是三个构建期可选 backend,不是运行时三个一起执行。
rosconsole/CMakeLists.txt 定义:
1 | set(ROSCONSOLE_BACKEND "" CACHE STRING |
没有显式指定时,Noetic 的探测顺序是:
1 | log4cxx |
实际选择结果直接看当前 Debug overlay:
1 | grep '^ROSCONSOLE_BACKEND:' \ |
也可以看当前产物和依赖:
1 | ls -lh /workspace/ros_debug_ws/devel/lib/librosconsole*.so |
如果结果是:
1 | ROSCONSOLE_BACKEND:STRING=log4cxx |
那么本次构建真正走的是 rosconsole_log4cxx.cpp。
只有明确构建成 glog backend 时,rosconsole_glog.cpp 中的:
1 | google::LogMessage(...) |
才属于当前运行路径。
ROSCONSOLE_FORMAT 默认格式在哪里
Noetic 的 rosconsole.cpp 直接定义:
1 | const char* g_format_string = "[${severity}] [${time}]: ${message}"; |
Formatter::init() 再解析 ${...} token。
常用 token:
1 | severity |
主要资料:
- ROS Wiki rosconsole:
https://wiki.ros.org/rosconsole - Noetic rosconsole 源码:
https://github.com/ros/rosconsole/blob/noetic-devel/src/rosconsole/rosconsole.cpp - rosconsole backend 选择:
https://github.com/ros/rosconsole/blob/noetic-devel/CMakeLists.txt
7. shutdown 怎样进入,ros::shutdown() 又做什么
Ctrl+C / SIGINT
ros::start() 默认安装:
1 | SIGINT |
PollManager 已经注册 checkForShutdown() listener,随后进入:
1 | checkForShutdown() |
rosnode kill /hello_node
另一条入口是:
1 | rosnode kill /hello_node |
对应:
1 | XML-RPC shutdown request |
XML-RPC 请求怎样找到 Node 留到 07,这一章只关注它最终怎样进入 shutdown。
显式调用
应用也可以直接:
1 | ros::shutdown(); |
这条路径不需要经过 requestShutdown()。
ros::shutdown() 主干
init.cpp 中的清理顺序可以压缩为:
1 | 设置 g_shutting_down |
g_ok = false 后:
1 | while (ros::ok()) |
会在下一次检查时退出。
最后一个 NodeHandle 析构
NodeHandle 用:
1 | g_nh_refcount |
维护全局引用计数。
如果引用计数降到 0,并且这个 Node 原本是由第一个 NodeHandle 自动启动的,则 NodeHandle::destruct() 也会调用 ros::shutdown()。
因此生命周期首尾正好对应:
1 | 第一个 NodeHandle |
8. 把 rqt_graph / rqt_console 正式加入 Docker 环境
这两个工具都是 GUI:
rqt_graph:Qt GUI,用于查看 ROS computation graph;rqt_console:Qt GUI,用于显示和过滤 ROS log messages。
因此它们不能只靠一个纯字符终端完成可视化。需要有可用的图形显示服务。
当前环境是 Ubuntu 桌面 + Docker,最简单的方式是让 Container 连接 Host 的 X11/XWayland display:
1 | rqt Qt GUI in Container |
如果 Host 的 GNOME 会话使用 Wayland,也通常可以通过 XWayland 兼容层运行这类 Qt/X11 应用。本工程先使用 X11 socket 转发,不额外引入原生 Wayland socket。
8.1 Dockerfile
已经把:
1 | ros-noetic-rqt-graph |
加入原有 apt-get install:
1 | RUN apt-get update && \ |
只安装这两个具体 package 即可,它们会通过 Debian/ROS package dependency 拉取 rqt_gui、python_qt_binding 等运行依赖。
8.2 compose.yaml
给 ros1-dev 增加:
1 | environment: |
并把 Host 的 X11 Unix socket bind mount 到 Container:
1 | volumes: |
其中:
1 | DISPLAY |
DISPLAY: ${DISPLAY:-:0} 表示:
- Compose 启动环境已经有
DISPLAY:直接继承; - 没有:先默认使用常见的
:0。
如果 Ubuntu 桌面实际不是 :0,以桌面终端执行:
1 | echo "$DISPLAY" |
看到的值为准。
8.3 Host 授权 Container 连接 X server
在 Ubuntu 图形桌面里的终端执行:
1 | xhost +si:localuser:$(whoami) |
当前 Dockerfile 会让 Container 用户 UID/GID 与 Host 对齐,因此这个 local-user 授权方式适合当前开发环境。
不要使用:
1 | xhost + |
它会直接关闭 X server 的访问控制,范围过大。
如果 Host 没有 xhost:
1 | sudo apt update |
8.4 重建并启动
修改 Dockerfile 后需要重新 build 镜像:
1 | cd /home/wdfk/share/ros1-docker |
确认 Container 能看到 display:
1 | docker compose exec ros1-dev bash |
然后先启动 roscore 和实验 Node,再执行:
1 | rqt_graph |
或:
1 | rqt_console |
窗口应该出现在 Ubuntu VM 的桌面环境中,而不是嵌在 VS Code Terminal 里面。
rqt_graph 当前主要用来看:
1 | /hello_node |
以及后续 listener 加入后的发布/订阅连接关系。
rqt_console 用来看和过滤 ROS 日志,可以按 Node、Severity、Message 等维度观察。
8.5 相关资料
- ROS Wiki
rqt_graph:https://wiki.ros.org/rqt_graph - ROS Wiki
rqt_console:https://wiki.ros.org/rqt_console - ROS Wiki
rqt:https://wiki.ros.org/rqt rqt_graphNoetic 源码:https://github.com/ros-visualization/rqt_graph/tree/noetic-develrqt_consoleNoetic 源码:https://github.com/ros-visualization/rqt_console/tree/noetic-devel- X.Org
xhost:https://www.x.org/releases/X11R7.0/doc/html/xhost.1.html - Docker bind mounts:
https://docs.docker.com/engine/storage/bind-mounts/
9. 本章源码阅读主线
按下面的符号顺序阅读即可:
1 | hello_node.cpp |
这里有一个时序细节:构造函数中 construct() 先执行,而第一个 construct() 会在内部调用 ros::start();返回以后才执行 initRemappings()。因此当前源码真实顺序是:
1 | NodeHandle constructor |
本章真正需要建立的是:
1 | 进程装载与静态初始化 |
能把这条链解释清楚后,再进入 07 的 Master/XML-RPC 注册发现机制。
参考源码与文档
- roscpp
CMakeLists.txt:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/CMakeLists.txt - roscpp
init.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/init.cpp - roscpp
node_handle.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/node_handle.cpp - roscpp
node_handle.h:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/include/ros/node_handle.h - roscpp
master.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/master.cpp - roscpp
network.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/network.cpp - roscpp
param.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/param.cpp - roscpp
xmlrpc_manager.cpp:https://github.com/ros/ros_comm/blob/noetic-devel/clients/roscpp/src/libros/xmlrpc_manager.cpp - rosconsole
CMakeLists.txt:https://github.com/ros/rosconsole/blob/noetic-devel/CMakeLists.txt - rosconsole
rosconsole.cpp:https://github.com/ros/rosconsole/blob/noetic-devel/src/rosconsole/rosconsole.cpp - ROS1 Names:
https://wiki.ros.org/Names - ROS1 rosconsole:
https://wiki.ros.org/rosconsole - ROS1 rqt_graph:
https://wiki.ros.org/rqt_graph - ROS1 rqt_console:
https://wiki.ros.org/rqt_console












