资讯动态

ROS 2机器人开发:从零构建第一个C++节点

发布时间:2026/9/17 10:43:47 来源:尧图企业网站定制
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给 Cament_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_dependament_cmake/buildtool_depend dependrclcpp/dependbuildtool_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 到 projectCMake 文件的第一句通常是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但更省事、更不容易错。然后是最容易被新手漏掉的 installinstall(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_sharedFirstNode()); rclcpp::shutdown(); return 0; }先看继承。FirstNode继承自rclcpp::Node构造时把节点名first_node传给基类。节点名是它在 ROS 2 图里的唯一标识命名要遵守规则只能用小写字母、数字、下划线且不能以数字开头。你如果写FirstNode带大写运行时会直接报非法命名。这里必须提一下智能指针。std::make_sharedFirstNode()和成员里的rclcpp::TimerBase::SharedPtr都是智能指针。ROS 2 的 C 接口大量使用shared_ptr原因是节点的生命周期、回调的引用关系需要精细管理用裸指针极易造成悬空引用或者内存泄漏。很多人学 C 时被指针绕晕这里可以简化理解shared_ptr就是会自动帮你在没人用的时候释放的指针你不用手动delete。std::make_sharedT()是创建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 filesource 没做或依赖没声明source 环境检查 find_package未定义符号undefined reference to ...依赖库没链接检查 ament_target_dependencies找不到可执行No executable foundinstall 规则缺失补 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_sharedFirstNode()); 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 的环境问题非常依赖系统状态隔几天重装一次你不记笔记就会重复踩同样的坑。这份笔记的价值远超任何教程。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价