1. 先想清楚:第一个节点为什么值得你用 C++ 写
很多人学 ROS 2,上手第一件事就是照抄一个 Python 的 talker 节点,跑通了,屏幕上刷几行字符串,然后合上电脑觉得"就这?"。等你真正去翻别人的工程包,尤其是导航、控制、感知这类吃性能的模块,会发现清一色全是 C++。那一刻你才意识到,入门时图省事的那个选择,后面要用成倍的时间补回来。ROS 2 机器人开发从入门到实践这条路上,第一个节点用 C++ 写,不是自找麻烦,而是提前把编译体系、依赖管理、构建产物这些后面绕不开的东西一次性摸清楚。
先把预期讲明白。这篇内容解决的是 ROS 2 Humble 环境下,从零构建一个 C++ 功能包,写一个能定时输出日志的节点,编译、运行、验证全套流程。它适合两类人:一类是完全没碰过 ROS 2、想用一个最小例子把工具链跑通的初学者;另一类是写过 Python 节点、想切到 C++ 但被 CMakeLists.txt 和 package.xml 卡住的人。全文围绕ROS 2、C++、节点、机器人开发这几个核心点展开,不会跑到别的地方去。
我这里说的"节点",在 ROS 2 里就是一个独立运行的进程,它通过中间件和其他进程通信。你可以把它想象成一家公司里的一个员工:每个人只管自己那摊事,但都通过公司的邮件系统(话题、服务、动作)收发消息。你的第一个节点,其实就是一个"只会定时发工作日报"的员工,功能单一,但它验证了一件事——你这家公司的邮件系统是通的。
必须强调一个容易被忽略的现实:ROS 2 的 C++ 节点首次编译,是整个学习过程里最容易卡住的地方。不是因为代码难,代码一共几十行,而是因为环境变量、工作区 sourcing、CMake 版本、编译器支持这些"周边"问题会一起冒出来。我见过太多人在这一步放弃,误以为是 ROS 2 太难,实际上只是source少敲了一行。所以这篇的重点,会放在"为什么这么配"和"报错了往哪看"上,代码本身反而简单。
还有一点值得提前说:第一个节点不要贪多。别一上来就搞发布订阅、参数、生命周期全塞进去。先用一个定时器把日志打出来,确认编译链路和运行环境都对,再去叠加通信功能。这种"最小可验证单元"的思路,是我做了多年机器人软件后最推崇的调试习惯,后面每一步都会用到。
2. 开工之前的包骨架与环境,决定你后面踩不踩坑
2.1 工作区与功能包的正确创建姿势
ROS 2 的所有代码都活在"工作区"(workspace)里。工作区就是一个普通文件夹,里面按约定放src、build、install、log四个子目录。src放源码,后面三个是编译时自动生成的,你永远不要去手动改它们——改了轻则编译混乱,重则删不掉。我一般会在家目录下建一个ros2_ws,这个名字随意,但要养成固定习惯,别每个教程建一个工作区,最后自己都记不清哪个是哪个。
创建功能包的命令是:
mkdir -p ~/ros2_ws/src cd ~/ros2_ws/src ros2 pkg create --build-type ament_cmake --node-name first_node first_node_pkg这一行命令里,--build-type ament_cmake是关键。ROS 2 有两种主流构建类型:ament_cmake给 C++,ament_python给 Python。你用 C++ 就必须指定前者,否则生成的模板里根本没有CMakeLists.txt。--node-name first_node会顺便帮你生成一个源文件和可执行目标,省得你手写。包名first_node_pkg建议只用小写字母加下划线,不要用大写和短横线,CMake 对包名很挑剔。
执行完之后你会看到一个标准结构。我这里把关键文件的职责列一下,方便你建立整体认知:
| 文件/目录 | 作用 | 是否需要手改 |
|---|---|---|
package.xml | 声明包的身份、依赖、构建类型 | 需要 |
CMakeLists.txt | 描述怎么编译、装到哪 | 需要 |
src/ | 存放 C++ 源码 | 需要 |
include/ | 存放对外暴露的头文件 | 按需 |
launch/ | 启动文件 | 后面再加 |
为什么要把结构讲这么细?因为我踩过一个坑:早期我把头文件随便塞进src,本地编译没问题,结果等到别的包要引用我的头文件时,install阶段找不到,报一堆路径错误。include目录存在的意义,就是把"这个包对外提供什么接口"和"内部实现细节"分开,你越早遵守这个约定,后面的坑越少。
2.2 package.xml 里真正需要你操心的字段
package.xml是包的"身份证明",构建工具靠它解析依赖。ros2 pkg create生成的模板里有一堆占位符,你不改也能编译,但一旦别人 clone 你的代码,问题就来了。需要重点关注的是三块:身份信息、构建工具依赖、运行依赖。
身份信息里,<description>、<maintainer>、<license>都要填真实内容。别觉得这是形式主义,rosdep和发布工具会读这些字段,留空或者瞎写在正经工程里是要被退回的。许可证尤其重要,很多公司对开源协议有硬性要求,你用 Apache-2.0 还是 MIT,得提前确认。
依赖声明是核心。这里最容易混淆的是<build_depend>、<exec_depend>和<depend>。<depend>是前两者的合并简写,绝大多数情况下用<depend>就够了。对我们这个节点来说,唯一必须声明的运行依赖就是rclcpp——它是 ROS 2 的 C++ 客户端库,你的Node类、日志宏、定时器全来自它。
<buildtool_depend>ament_cmake</buildtool_depend> <depend>rclcpp</depend>buildtool_depend声明的是构建工具本身,ament_cmake是 C++ 包的标准构建工具,这个不要动。我经常看到新手把rclcpp写成buildtool_depend,结果编译能过但运行时找不到库,报libament相关的错。记住一个原则:包类型(buildtool)和功能库(depend)是两回事。
还有一个隐藏细节:package.xml里声明的依赖,和CMakeLists.txt里find_package的依赖要保持一致。有人图省事只改一处,短期能跑,但rosdep install时就会漏装依赖,换台机器直接编译失败。这种"在我电脑上好好的"问题,根源往往就在这里。
2.3 源码落位与命名约定
把源文件放到src/first_node.cpp。文件名和节点名可以不同,但为了后面维护方便,我建议一致。ROS 2 社区对 C++ 源文件没有强制命名规范,但大家默认用下划线分隔(snake_case),类名用大驼峰(PascalCase),这和 ROS 2 自身的代码风格一致。
如果你用 VSCode 开发,这一步值得顺手把 C/C++ 插件和 ROS 插件装好,配置一下c_cpp_properties.json里的includePath,让它指向/opt/ros/humble/include/**。不配的话,编辑器会满屏红波浪线,虽然不影响编译,但看着闹心,还容易误导你以为代码错了。这是纯编辑器体验问题,不影响实际构建,但省下的心智负担很值。
3. CMakeLists.txt 逐行拆开讲,每一句都有它的道理
3.1 从 cmake_minimum_required 到 project
CMake 文件的第一句通常是:
cmake_minimum_required(VERSION 3.8) project(first_node_pkg)cmake_minimum_required声明构建所需的最低 CMake 版本。ROS 2 Humble 官方推荐 3.8 以上,但系统自带的往往更高。为什么不写一个很高的大版本?因为要兼容别人机器上的旧环境。写太高会让别人的构建直接失败,写太低又用不了新特性,3.8 是社区验证过的平衡点。
project()定义项目名,通常和包名一致。这里有个细节:CMake 的project名和 ROS 2 的包名理论上可以不同,但强烈建议保持一致,否则ament解析时容易出岔子。我见过有人复制粘贴别人的 CMake 忘了改project名,编译产物装到了一个不存在的包里,运行时ros2 run死活找不到,排查半天。
接下来那段编译器警告选项:
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif()这三行是给你加严格警告的。-Wall开常用警告,-Wextra加更多,-Wpedantic严格遵循标准。为什么要开这么严?因为 C++ 里很多"能编译但行为诡异"的问题,编译器其实早就想提醒你了,比如未初始化变量、有符号无符号比较。入门阶段就该养成看警告的习惯,别等到线上出问题才回头。当然,警告不是错误,不会中断编译,但相信我,把警告当错误来处理,长期收益巨大。
3.2 find_package 的两步走和依赖机制
find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED)这两句要分开理解。ament_cmake是 ROS 2 给 CMake 套的一层扩展,它提供了后面用到的ament_target_dependencies、ament_package这些宏。rclcpp才是我们节点的功能库。REQUIRED表示找不到就报错停止,这个必须加,否则会静默继续,最后在链接阶段报一堆莫名其妙的"未定义符号"。
这里涉及一个很多新手困惑的问题:为什么我#include "rclcpp/rclcpp.hpp"就能用,不需要手动指定头文件路径?答案就在find_package里。rclcpp的包在安装时,会附带一份 CMake 配置,里面记录了头文件目录、库文件路径、依赖链。find_package把这些信息加载进来,形成一个"目标"(target),后面链接时直接引用目标名即可,路径全自动。这就是现代 CMake 相比老式include_directories的进步之处——依赖是传递的、精确的,不用你手工拼路径。
依赖冲突是另一个高频问题。假设你的节点同时依赖 A 和 B,而 A、B 又依赖了不同版本的同一个库,find_package会按顺序解析,先找到的版本生效。这种情况下报错信息往往很隐晦。我个人的经验是:尽量保持依赖链干净,不引入不必要的包,出了问题优先怀疑版本冲突而不是自己的代码。
3.3 add_executable、链接与 install 规则
核心的编译目标定义:
add_executable(first_node src/first_node.cpp) ament_target_dependencies(first_node rclcpp)add_executable把源文件编译成一个可执行文件,第一个参数是目标名,也就是你后面ros2 run用的名字。ament_target_dependencies是关键,它把rclcpp的头文件路径、库、编译选项一次性绑定到这个目标上。这是 ament 封装好的写法,等价于手动调用target_include_directories加target_link_libraries,但更省事、更不容易错。
然后是最容易被新手漏掉的 install:
install(TARGETS first_node DESTINATION lib/${PROJECT_NAME} )为什么要"安装"?ROS 2 采用"构建目录"和"安装目录"分离的机制。你colcon build编译产物在build,但运行时ros2 run找的是install目录。如果不写 install,编译能过,但ros2 run first_node_pkg first_node会告诉你找不到可执行文件。这个坑我当年踩了整整一个下午——明明编译成功,就是跑不起来,最后发现罪魁祸首就是漏了 install。
DESTINATION lib/${PROJECT_NAME}这个约定也有讲究。ROS 2 规定 C++ 可执行文件统一装到lib/<包名>/下,这样ros2 run能按规律定位。你如果装到别处,ros2 run就找不到。最后别忘了收尾:
ament_package()这一句必须在文件末尾,它生成包的元数据,告诉 ament 这个包构建完成了。漏掉它,构建会报"ament_package was not called"之类的错。
4. 那段 C++ 代码逐段读,把每行的意图说清楚
4.1 头文件、类定义与节点初始化
完整的代码大概长这样:
#include "rclcpp/rclcpp.hpp" class FirstNode : public rclcpp::Node { public: FirstNode() : Node("first_node") { timer_ = this->create_wall_timer( std::chrono::milliseconds(500), std::bind(&FirstNode::timer_callback, this)); } private: void timer_callback() { RCLCPP_INFO(this->get_logger(), "Hello ROS 2, tick %zu", count_++); } rclcpp::TimerBase::SharedPtr timer_; size_t count_ = 0; }; int main(int argc, char * argv[]) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<FirstNode>()); rclcpp::shutdown(); return 0; }先看继承。FirstNode继承自rclcpp::Node,构造时把节点名"first_node"传给基类。节点名是它在 ROS 2 图里的唯一标识,命名要遵守规则:只能用小写字母、数字、下划线,且不能以数字开头。你如果写"FirstNode"带大写,运行时会直接报非法命名。
这里必须提一下智能指针。std::make_shared<FirstNode>()和成员里的rclcpp::TimerBase::SharedPtr都是智能指针。ROS 2 的 C++ 接口大量使用shared_ptr,原因是节点的生命周期、回调的引用关系需要精细管理,用裸指针极易造成悬空引用或者内存泄漏。很多人学 C++ 时被指针绕晕,这里可以简化理解:shared_ptr就是会自动帮你在没人用的时候释放的指针,你不用手动delete。std::make_shared<T>()是创建shared_ptr的推荐方式,比new再包装效率更高,也更安全。
4.2 定时器、回调与 spin 的角色
create_wall_timer创建了一个挂了墙上时钟(wall timer)的定时器,每 500 毫秒触发一次回调。为什么用墙上时钟而不是 ROS 时间?因为第一个节点不需要仿真时间,墙上时钟就是真实世界的时间,简单直接。等以后你要做仿真,才会切到create_timer配合/clock话题使用。这个区别先记着,别混。
std::bind(&FirstNode::timer_callback, this)把成员函数绑定成一个可调用对象。this不能省,因为成员函数需要一个实例对象才能调用。这是 C++ 里回调注册的经典写法,理解起来别扭但很常见。
然后是rclcpp::spin(),这句是很多初学者的认知盲区。你可能会想:定时器都创建了,为什么还要 spin?答案是:ROS 2 的回调不会自己触发,必须有一个执行器(executor)在不停地检查有没有事情要做,然后调度回调。spin就是启动一个单线程执行器,阻塞在那里,反复检查定时器到期没有、有没有收到消息。没有spin,你的定时器任务永远不会被执行,程序会瞬间跑完main然后退出。
最后rclcpp::shutdown()在退出前做清理,释放中间件资源。虽然程序结束时系统也会回收,但显式调用是良好习惯,尤其在多节点场景下能避免资源残留。
4.3 日志宏与 get_logger 的配合
RCLCPP_INFO(this->get_logger(), "Hello ROS 2, tick %zu", count_++)这一行,日志宏的第一个参数永远是 logger 对象,通过get_logger()从节点拿到。ROS 2 的日志分五个级别:DEBUG、INFO、WARN、ERROR、FATAL。INFO 是一般信息,适合确认节点在正常工作。
为什么日志要带 logger 而不是直接printf?因为 ROS 2 的日志系统会把消息带上时间戳、节点名、级别,统一输出,还能被rqt_console之类的工具采集和过滤。这是分布式系统里排查问题的命脉。你用printf打印的信息,在多节点环境下就是一锅粥,没法定位来源。养成用日志宏的习惯,从第一个节点开始。
有个小坑:%zu是格式化size_t的正确占位符。你要是习惯性写%d,在某些平台上会输出乱码或者警告。C++ 里字符串格式化容易出这种问题,写的时候多留意格式符和参数类型匹配。
5. 编译、运行、验证:把链路彻底跑通
5.1 colcon build 的完整流程与常见报错
构建流程固定三步:
cd ~/ros2_ws colcon build --packages-select first_node_pkg source install/setup.bash--packages-select只编译指定包,能省时间。如果你工作区里有几十个包,不加这个参数会全编译,慢得让人怀疑人生。编译完成后必须source install/setup.bash,这一步的意义是把install目录下的环境变量加载到当前终端。每开一个新终端,都要重新 source 一次,这是 ROS 2 新手最容易忘的操作。
编译报错里,按经验排序,最常见的三类是:
| 报错类型 | 典型信息 | 根因 | 解决 |
|---|---|---|---|
| 找不到头文件 | rclcpp/rclcpp.hpp: No such file | source 没做,或依赖没声明 | source 环境,检查 find_package |
| 未定义符号 | undefined reference to ... | 依赖库没链接 | 检查 ament_target_dependencies |
| 找不到可执行 | No executable found | install 规则缺失 | 补 install(TARGETS ...) |
还有一类环境问题:如果你同时装了多个 ROS 2 版本,source的顺序错了,会加载到另一个版本的头文件,报一堆版本不匹配的错。解决办法是检查echo $ROS_DISTRO,确认是humble。
5.2 ros2 run 运行节点与预期输出
运行:
ros2 run first_node_pkg first_node你应该能看到大概每半秒一行日志,tick 数字递增。如果日志刷出来了,恭喜你,第一个 ROS 2 C++ 节点正式跑通。这时候再开一个终端,source 之后执行ros2 node list,能看到/first_node这个节点;执行ros2 node info /first_node,能看到它的详细信息,虽然现在只有日志没有话题,但节点结构已经立起来了。
为什么要验证 node list?因为这确认了一件事:你的节点不只在本进程里自嗨,它真的注册到了 ROS 2 的计算图里,被中间件识别了。这一步没通过,说明rclcpp::init之后的注册环节有问题,往往和节点命名、DDS 配置有关。
5.3 用调试工具确认节点活着
除了node list,还有几个顺手的工具。ros2 run rqt_console rqt_console能图形化看日志,按级别过滤。ros2 topic list现在是空的,因为节点还没发话题,这很正常。等你下一步加了发布者,这里就会冒出新话题。
如果日志没出来,但有没报错,最可能的原因是spin没调,或者定时器创建后没保存shared_ptr——注意timer_是成员变量,如果写成局部变量,函数退出时智能指针释放,定时器就没了。这个坑非常隐蔽,因为编译完全正常,就是没输出。我第一次写的时候就中了,排查了好久才发现是生命周期问题。
6. 从"能跑"到"敢用":几个值得深挖的改造点
6.1 单文件类 vs 组合式节点
当项目变大,你会发现把main和节点类塞在同一个文件里很难维护。更好的做法是把节点类放进头文件,暴露给其他包复用。ROS 2 推荐"组合式"(composition)设计:节点不自己管main,而是作为组件被加载进一个容器进程。这样多个节点能共用一个进程,减少通信开销。
要做到这一点,需要把rclcpp::Node换成rclcpp::Node的组件形式,用RCLCPP_COMPONENTS_REGISTER_NODE宏注册,CMake 里改用rclcpp_components相关配置。第一个节点不用这么复杂,但心里要有个数:现在这个写法是"入门版",后面演进方向就是组件化。
6.2 多线程执行器与回调组
默认的spin是单线程的,所有回调排队执行。如果某个回调很耗时,会拖住整个节点。解决方案是引入多线程执行器:
rclcpp::executors::MultiThreadedExecutor executor; executor.add_node(std::make_shared<FirstNode>()); executor.spin();但多线程会带来并发问题:多个回调可能同时访问同一份数据,需要加锁或者用回调组(callback group)来隔离。这是 ROS 2 并发编程的核心话题,也是最容易出 bug 的地方。我的建议是:入门阶段先用单线程,理解清楚事件循环机制,等确实遇到性能瓶颈再上多线程,别为了炫技过早引入复杂度。
6.3 参数、命名空间与可配置化
真正上生产,节点不该把 500 毫秒这种值写死在代码里。ROS 2 提供了参数(parameter)机制:
this->declare_parameter("period_ms", 500); int period = this->get_parameter("period_ms").as_int();这样运行时可以用ros2 run ... --ros-args -p period_ms:=1000动态调整,不用重新编译。命名空间则是多机器人场景的必需品,通过__ns参数给节点分组,避免命名冲突。这些都属于"第一个节点跑通之后"的自然延伸,按需学习即可。
回顾整个流程,我个人踩得最深的坑集中在两处:一是source忘记做,二是install规则漏写。前者导致编译时找不到头文件,后者导致编译成功却运行不了。这两个问题都不是代码逻辑问题,而是构建体系的认知问题。所以我一直认为,学 ROS 2 的第一课,不是写代码,是把"源码 → 编译 → 安装 → 运行"这条链路每个环节的职责搞清楚。搞清楚了,后面加话题、加服务、加动作,都只是在同一个骨架上挂东西而已。
最后一个实用建议:把你的第一次成功流程完整记下来,包括每一条命令、每一次 source、每一个报错和解决方式。ROS 2 的环境问题非常依赖系统状态,隔几天重装一次,你不记笔记就会重复踩同样的坑。这份笔记的价值,远超任何教程。