简介:这份资源面向正在学习 ROS2 节点通信机制、需要掌握自定义消息完整流程的开发者,尤其适合已具备一定 C++ 与 CMake 基础、希望从零跑通消息定义到发布订阅的中级学习者。内容围绕创建消息包、编写 .msg 定义、生成 C++/Python 接口、安装消息包以及编写发布者与订阅者节点展开,覆盖从工程搭建到实际通信的完整链路。压缩包共 645 个文件,约 3.43MB,以 cmake、make、h、hpp、cpp 等构建与源码文件为主,辅以 msg 消息定义、py 脚本、sh/bash 环境脚本及 log、o、so 等编译产物,完整保留了可复现的工作空间目录结构。已有 472 人学习,读者可据此对照消息生成结果、节点导入方式与话题通信配置,理解发布订阅的代码组织与常见编译排错思路,并在此基础上扩展到 QoS、并发等进阶话题。
1. 从一次“话题对不上”的联调说起:ROS2 消息自定义到底解决什么
去年帮一个做移动机器人底盘的团队排查联调问题,现象很典型:导航节点发出来的速度指令,底盘节点收得到,但字段全是零。两边都是 ROS2,话题名也对得上,最后发现是底盘侧订阅的是自定义消息类型,而导航侧发的是标准geometry_msgs/Twist,类型不匹配,DDS 直接把消息丢了,日志里连个像样的报错都没有。这类“话题对得上、数据对不上”的问题,在 ROS2 项目里十有八九跟消息定义有关。ROS2 的通信模型是强类型的,发布者和订阅者的消息类型必须完全一致,包括包名、消息名和字段布局,否则底层 DDS 会静默丢弃。标准消息能覆盖的场景其实很有限,一旦你要传底盘状态、传感器融合结果、自定义控制指令,就必须自己定义.msg文件,走一遍rosidl的代码生成流程。这篇笔记拆的就是这条链路:从建包、写.msg、配CMakeLists.txt和package.xml,到编译、验证、在 C++ 和 Python 节点里真正用起来,最后把几个我踩过的坑摊开讲。适合已经能跑通 ROS2 基础 talker/listener、准备往实际项目里塞自定义数据结构的开发者,新手照着步骤能走通,熟手可以重点看参数和排错那几节。
2. 消息定义与接口包搭建:从 .msg 到可编译的接口
ROS2 里自定义消息不是随便找个包扔个.msg就完事,它需要一个独立的接口包(interface package),把消息、服务、动作的定义集中管理,再让业务包依赖它。这样做的好处是接口和实现解耦,多个节点共享同一套消息定义时不会出现“各建各的、类型对不上”的翻车现场。常见做法是单独建一个xxx_interfaces包,只放.msg/.srv/.action和构建配置,不写业务逻辑。
2.1 接口包的目录结构与最小文件集
一个能编译的自定义消息接口包,最小文件集是这样的:
my_robot_interfaces/ ├── CMakeLists.txt ├── package.xml └── msg/ └── MotorState.msgmsg/目录名是约定,rosidl的构建宏默认从这里找消息定义。MotorState.msg的内容按字段逐行写,格式是“类型 字段名”,支持注释:
# 电机状态反馈 std_msgs/Header header # 时间戳和坐标系 int32 motor_id # 电机编号 float64 position # 当前位置,单位 rad float64 velocity # 当前速度,单位 rad/s float64 current # 当前电流,单位 A bool is_enabled # 使能状态 string[<=16] fault_code # 故障码,变长字符串上限16这里有几个细节值得说清楚。第一,字段类型可以是 ROS2 内置基础类型(bool、int8~int64、float32/64、string),也可以是其他包的消息类型,比如上面引用的std_msgs/Header,引用时要在package.xml里声明依赖。第二,数组分定长和变长,float64[8]是定长,float64[]是变长,string[<=16]是带上限的变长字符串,实际项目里给字符串加上限能避免消息体过大。第三,字段顺序就是序列化顺序,一旦发布出去被其他节点依赖,后续改动要谨慎,加字段尽量往末尾加。
2.2 CMakeLists.txt 与 package.xml 的关键配置
接口包的CMakeLists.txt和普通功能包不一样,核心是调rosidl_generate_interfaces。一个可用的版本:
cmake_minimum_required(VERSION 3.8) project(my_robot_interfaces) if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif() find_package(ament_cmake REQUIRED) find_package(rosidl_default_generators REQUIRED) find_package(std_msgs REQUIRED) # 声明要生成的消息文件,路径相对包根目录 rosidl_generate_interfaces(${PROJECT_NAME} "msg/MotorState.msg" DEPENDENCIES std_msgs ) ament_export_dependencies(rosidl_default_runtime) ament_package()rosidl_generate_interfaces是核心宏,第一个参数是包名,后面跟所有.msg/.srv/.action文件路径。DEPENDENCIES里要列出消息定义中引用到的其他包,这里因为用了std_msgs/Header,所以必须写std_msgs,同时上面find_package(std_msgs REQUIRED)也不能少。ament_export_dependencies(rosidl_default_runtime)保证下游包能拿到运行时依赖。
package.xml对应要声明生成依赖和运行时依赖:
<?xml version="1.0"?> <?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?> <package format="3"> <name>my_robot_interfaces</name> <version>0.1.0</version> <description>自定义消息接口包</description> <maintainer email="dev@example.com">dev</maintainer> <license>Apache-2.0</license> <buildtool_depend>ament_cmake</buildtool_depend> <buildtool_depend>rosidl_default_generators</buildtool_depend> <depend>std_msgs</depend> <exec_depend>rosidl_default_runtime</exec_depend> <member_of_group>rosidl_interface_packages</member_of_group> <export> <build_type>ament_cmake</build_type> </export> </package><member_of_group>rosidl_interface_packages</member_of_group>这一行容易被漏掉,它的作用是让这个包被识别为接口包,某些工具链和依赖解析会用到。<buildtool_depend>rosidl_default_generators</buildtool_depend>和<exec_depend>rosidl_default_runtime</exec_depend>是一对,前者负责代码生成,后者负责运行时支持,缺一个编译或运行就会出问题。
2.3 编译与接口验证
配置写完后,在工作空间根目录编译:
# 只编译接口包,先确认消息定义本身没问题 colcon build --packages-select my_robot_interfaces # 编译完成后 source 环境 source install/setup.bash # 验证消息是否注册成功 ros2 interface show my_robot_interfaces/msg/MotorStateros2 interface show能打印出消息的完整字段定义,如果这一步报“找不到接口”,说明编译没通过或者环境没 source。编译阶段常见的失败是.msg里引用了未在DEPENDENCIES声明的包,报错信息通常指向rosidl生成阶段找不到类型。另一个验证手段是ros2 interface list | grep my_robot,确认接口包出现在列表里。到这一步,消息定义这层就算立住了,接下来才是怎么在节点里用。
3. C++ 与 Python 节点里的消息使用:发布、订阅与字段访问
接口包编译通过只是第一步,真正容易出问题的是业务节点里的引用方式、字段访问和 Qos 匹配。C++ 和 Python 的用法差异不小,分开说。
3.1 C++ 节点的发布与订阅写法
C++ 节点使用自定义消息,需要在功能包的package.xml和CMakeLists.txt里依赖接口包。先看发布者:
#include <chrono> #include <memory> #include "rclcpp/rclcpp.hpp" #include "my_robot_interfaces/msg/motor_state.hpp" using namespace std::chrono_literals; class MotorPublisher : public rclcpp::Node { public: MotorPublisher() : Node("motor_publisher") { // 发布者:话题名 /motor_state,队列深度 10 pub_ = this->create_publisher<my_robot_interfaces::msg::MotorState>( "/motor_state", 10); // 100ms 周期定时器 timer_ = this->create_wall_timer(100ms, [this]() { publish(); }); } private: void publish() { auto msg = my_robot_interfaces::msg::MotorState(); msg.header.stamp = this->now(); // 填时间戳 msg.header.frame_id = "base_link"; // 填坐标系 msg.motor_id = 1; msg.position = 0.5; msg.velocity = 1.2; msg.current = 0.8; msg.is_enabled = true; msg.fault_code = "NONE"; pub_->publish(msg); } rclcpp::Publisher<my_robot_interfaces::msg::MotorState>::SharedPtr pub_; rclcpp::TimerBase::SharedPtr timer_; }; int main(int argc, char **argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<MotorPublisher>()); rclcpp::shutdown(); return 0; }头文件路径是包名/msg/消息名.hpp,消息名在 C++ 里转成小写下划线风格,MotorState对应motor_state.hpp,类型全名是my_robot_interfaces::msg::MotorState。字段访问直接点出来,嵌套消息(如header)继续点。订阅侧对称:
#include "rclcpp/rclcpp.hpp" #include "my_robot_interfaces/msg/motor_state.hpp" class MotorSubscriber : public rclcpp::Node { public: MotorSubscriber() : Node("motor_subscriber") { sub_ = this->create_subscription<my_robot_interfaces::msg::MotorState>( "/motor_state", 10, [this](const my_robot_interfaces::msg::MotorState::SharedPtr msg) { RCLCPP_INFO(this->get_logger(), "motor %d pos=%.3f vel=%.3f fault=%s", msg->motor_id, msg->position, msg->velocity, msg->fault_code.c_str()); }); } private: rclcpp::Subscription<my_robot_interfaces::msg::MotorState>::SharedPtr sub_; };回调参数用SharedPtr是零拷贝友好的写法,避免不必要的复制。fault_code是std::string,打印时记得.c_str()。
功能包的CMakeLists.txt里要加:
find_package(my_robot_interfaces REQUIRED) # 可执行文件链接接口包 ament_target_dependencies(motor_node rclcpp my_robot_interfaces)package.xml里加<depend>my_robot_interfaces</depend>。漏了这一步,编译时报“找不到头文件”,是新手最常见的翻车点之一。
3.2 Python 节点的导入与字段赋值
Python 侧更轻量,导入路径是包名.msg,类名保持驼峰:
import rclpy from rclpy.node import Node from my_robot_interfaces.msg import MotorState class MotorPublisher(Node): def __init__(self): super().__init__('motor_publisher_py') self.pub = self.create_publisher(MotorState, '/motor_state', 10) self.timer = self.create_timer(0.1, self.publish) def publish(self): msg = MotorState() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'base_link' msg.motor_id = 1 msg.position = 0.5 msg.velocity = 1.2 msg.current = 0.8 msg.is_enabled = True msg.fault_code = 'NONE' self.pub.publish(msg) def main(): rclpy.init() node = MotorPublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown()注意msg.header.stamp要赋to_msg()转换后的时间对象,直接赋 Python 的time.time()会类型不匹配。Python 包的package.xml同样要声明<depend>my_robot_interfaces</depend>,setup.py里data_files配置好入口点即可,不需要额外链接步骤。
3.3 用命令行工具做端到端验证
节点写完后,别急着上整机,先用命令行工具验证链路。开一个终端跑发布者,另一个终端:
# 查看话题上的消息类型 ros2 topic info /motor_state --verbose # 以自定义消息类型回显内容 ros2 topic echo /motor_state my_robot_interfaces/msg/MotorState # 手动发一条测试消息 ros2 topic pub --once /motor_state my_robot_interfaces/msg/MotorState \ "{header: {frame_id: 'base_link'}, motor_id: 2, position: 1.0, velocity: 0.0, current: 0.1, is_enabled: true, fault_code: 'TEST'}"ros2 topic echo不带类型参数时有时推断不出自定义类型,显式指定更稳。ros2 topic pub的 YAML 里嵌套字段用花括号,字符串要引号。如果 echo 有输出但订阅节点收不到,优先查 Qos,下一节展开。
4. 避坑与排查:自定义消息链路上的五类高频问题
这一节是我自己和小团队踩过的坑,按“现象 → 原因 → 解决”整理,基本都是编译能过、运行才炸的类型。
4.1 编译通过但运行时报“类型不支持”
现象:节点启动后订阅回调一次都不触发,ros2 topic info显示发布和订阅都在,但echo也没数据。原因:发布者和订阅者用的消息类型虽然名字一样,但来自不同的接口包(比如两个团队各建了一个MotorState),DDS 按类型全名匹配,包名不同就是两个类型。解决:统一接口包,所有节点依赖同一个xxx_interfaces,在 CI 里加一步ros2 interface show校验,避免各自为政。
4.2 字段顺序改动后老节点读到错位数据
现象:给.msg中间插了一个字段,重新编译后新节点正常,但没重编的老节点读出来的velocity变成了current的值。原因:消息序列化按字段顺序布局,中间插入字段导致后续字段偏移全部变化,而老节点用的是旧布局。解决:字段只往末尾追加,不改不删;确实要调整就升版本号并同步重编所有依赖节点。这条血泪经验值得写进团队规范。
4.3 Python 节点导入报 ModuleNotFoundError
现象:from my_robot_interfaces.msg import MotorState报找不到模块。原因:接口包没编译,或者当前终端没 source 工作空间的install/setup.bash,Python 的sys.path里没有生成的消息模块。解决:先colcon build --packages-select my_robot_interfaces,再source install/setup.bash;如果用了虚拟环境,确认 source 顺序在激活环境之后。
4.4 Qos 不匹配导致消息静默丢弃
现象:ros2 topic echo能看到数据,但自己的订阅节点收不到,日志无报错。原因:发布者用默认RELIABLE,订阅者设了BEST_EFFORT,或者反过来,Qos 不兼容时 DDS 不投递也不报错。解决:用ros2 topic info /motor_state --verbose看两端的 Qos 配置,订阅侧显式设置与发布侧兼容的 Qos,传感器类高频数据常用BEST_EFFORT,控制指令用RELIABLE。
4.5 变长字符串未设上限导致消息体膨胀
现象:高频发布时网络带宽和延迟明显上升,抓包发现单条消息体积远超预期。原因:.msg里用了无上限的string,实际填入了长文本(如完整故障日志),消息体不可控。解决:给字符串加[<=N]上限,长文本走单独的话题或服务传输,状态消息里只放故障码这类短字段。
5. 进阶技巧:用接口版本化与自动化校验守住消息契约
自定义消息一旦被多个节点依赖,它就不再是某个人的私有文件,而是一份契约。我现在的习惯是给接口包加一层轻量版本管理:在package.xml的<version>里维护语义化版本,消息字段的增删改都对应版本号变化,同时在接口包根目录放一个CHANGELOG.md记录每次字段变更和影响范围。这样下游节点升级时能一眼看出要不要重编、要不要改字段访问代码。
更进一步,可以在 CI 里加一个接口快照校验脚本,把当前所有.msg的字段布局导出成文本,和仓库里提交的基线做 diff,字段顺序或类型有变动就报警:
# 导出当前接口定义作为基线快照 ros2 interface show my_robot_interfaces/msg/MotorState > interfaces_baseline/MotorState.txt # CI 中对比,有差异则失败,强制走评审 diff -u interfaces_baseline/MotorState.txt <(ros2 interface show my_robot_interfaces/msg/MotorState)这个脚本本身很简单,价值在于把“改消息”从随手操作变成需要评审的动作。另一个实用技巧是给消息字段写注释时带上单位和取值范围,比如float64 position # 单位 rad,范围 [-3.14, 3.14],ros2 interface show会把注释一起打印出来,联调时对方不用翻文档就知道字段含义。参数上,发布队列深度别一味调大,控制类话题 10 足够,传感器高频话题可以到 50~100,但队列过大会掩盖消费端处理慢的问题,延迟反而上升。
还有一个容易被忽略的点:自定义消息的调试不要只依赖ros2 topic echo,高频消息下 echo 本身会拖慢链路,用ros2 topic hz /motor_state看频率、ros2 topic bw /motor_state看带宽,两个指标结合能快速判断是发布端问题还是传输问题。从那以后我每次新建接口包,都强制先跑一遍ros2 interface show加一次端到端 echo 验证,再让业务节点接进来,省下来的联调时间远比这几分钟多。希望帮到你。
本文还有配套的精品资源,点击获取