简介:面向ROS2初学者与机器人开发者的自定义消息实操资源,围绕创建消息包、定义msg结构、生成接口代码、搭建发布订阅节点,梳理了节点间通信的关键环节。压缩包共645个文件、约3.43MB,以CMake/make构建配置、C++/Python源码、msg与idl消息定义、bash环境脚本等类型为主,适合对照工程文件学习构建与编译流程。目前已有471人学习下载,是入门ROS2通信机制时可参考的紧凑示例包。内含完整的消息定义、发布订阅节点示例及colcon构建产物,从my_msgs包创建、MyMessage.msg编写,到rclcpp::Publisher与Subscription调用均有体现;同时具备逐步操作说明,可帮助读者快速掌握导入、构建、运行流程,并延伸到服务质量QoS等进阶话题。
1. 为什么自定义消息总是第一步:从一次接口不够用说起
做 ROS2 开发的人,几乎都会在第一个需要跨节点传数据的例子里撞上同一堵墙:手头只有std_msgs/String、geometry_msgs/Point这些内置消息,而你想发的是一整包“传感器编号 + 位置 + 速度 + 置信度 + 有效位”,拆成好几个话题发又得在接收端同步对齐,非常别扭。自定义消息就是用来解决这个问题的:定义一个SensorData.msg,把多个字段打包成一个接口类型,发布方一条publish(msg)发出去,订阅方一个回调拿齐全。这篇笔记围绕 ROS2 自定义消息的完整落地流程来写,适合刚入门想自己做接口包的人,也可以给写过一两次但老在编译、导入、类型匹配上翻车的人当参考。我把从包创建、msg 编写、colcon 构建,到 Python 和 C++ 双端使用的步骤都拆开讲,并把最容易踩的几个坑单独列出来。
2. 自定义消息机制:字段类型、IDL 映射与包结构
2.1 内置消息的边界:什么情况才需要自己定义
先明确一件事:不是所有场景都需要自定义消息。内置的std_msgs里有String、Int32、Float64MultiArray这些基础类型,geometry_msgs里也有Point、Pose、Twist这些常用结构体。如果你的数据刚好能表达成“一个点”或者“一个姿态”,直接用内置类型更省事,还能省去接口包维护成本。
但实际项目中很少有这种好事。比如你要从一个组合导航设备发数据,每个周期包含设备 ID、经纬度、航向角、速度、时间戳和有效性状态,这六个字段业务上是一个整体。拆成Float64MultiArray发也不是不行,但接收方得靠文档里的数组下标去读数据,读错一位就是隐蔽故障,可维护性很差。自定义消息的本质,是把“多个字段 + 各自类型 + 常量枚举”封装成一个独立类型,让发送和接收两端都面对一个具名结构体。
还有一层是序列化和代码生成。ROS2 的接口定义文件(.msg)不只是给开发者看的,构建时会经过 rosidl 代码生成器,为 Python、C++、TypeScript 等生成对应的类。也就是说你写一行float64 x,C++ 侧就会多一个成员变量x,Python 侧也会多一个x属性,类型映射全部由构建系统完成,不用你手写字节流解析。这正是自定义消息比“数组 + 约定下标”更稳的原因:类型信息是编译产物的一部分,两端对不上会直接报错,不会在运行时静默错位。
2.2 msg 语法与常用字段:从一行定义看透序列化
.msg文件格式非常直白,每一行是一个字段定义,格式是“类型 字段名”。下面是一份实际会用到的传感器消息:
# SensorData.msg int32 id float64 x float64 y float64 vx float64 vy float64 confidence string frame_id bool valid uint8 status这份定义里,x和y是平面坐标,vx、vy是速度分量,confidence是置信度,frame_id表示坐标系名称,status表示设备状态。如果你写过 C 结构体,会觉得很亲切;如果你用过 protobuf,会发现它就是去掉显式编号的 message。
支持的基础类型包括:bool、int8、uint8、int16、uint16、int32、uint32、int64、uint64、float32、float64、string。需要发一组数据时,用数组类型,比如float64[] positions表示变长数组,float64[3] position表示固定长度数组。注意固定长度数组的语法是类型[长度] 字段名,方括号紧跟在类型后面,这个格式写错会导致编译失败。
.msg还支持常量定义,语法是类型 常量名=值:
uint8 STATUS_OK=0 uint8 STATUS_FAIL=1常量定义放在文件靠前位置。生成代码后,在 C++ 里可以通过SensorData::STATUS_OK访问,在 Python 里可以通过SensorData.STATUS_OK访问。常量的价值在于把魔法数字变成有语义的符号。比如状态字段传0和传STATUS_OK,读代码的人理解成本完全不同。
还有一个常用能力是嵌套其它消息类型。例如需要把平面坐标改成 3D 坐标,可以直接写geometry_msgs/msg/Point center。嵌套消息要求你把这个依赖声明在功能包的 CMakeLists 和 package.xml 里,否则构建时找不到类型。
2.3 包结构:放哪、依赖谁、构建顺序
自定义消息不能单独存在,它必须属于某个 ROS2 功能包。行业里常见的做法是把消息、服务、动作集中在独立的接口包中,节点代码放另一个应用包。这样做的好处是依赖方向清晰:接口包只依赖基础消息类型,应用包依赖接口包,改动节点代码不会波及消息定义。
一个典型接口包目录长这样:
tutorial_interfaces/ ├── CMakeLists.txt ├── package.xml ├── msg/ │ └── SensorData.msg └── srv/ └── GetSensorData.srv文件位置有硬性要求:消息定义必须放在包根目录下的msg/子目录。构建时 rosidl 会扫描这个目录下的所有.msg文件,并生成对应代码。如果你建了msg目录但忘记在 CMakeLists 里把文件名写进rosidl_generate_interfaces,构建过程不会自动收集,这个问题后文会详细讲。
构建顺序上,接口包应该最先构建,因为应用包编译时要通过find_package(tutorial_interfaces)找到它的生成产物。如果两个包放在同一个工作空间,colcon build的默认行为会尝试按依赖拓扑排序,但更稳妥的做法是显式指定顺序:
colcon build --packages-select tutorial_interfaces colcon build --packages-select app_py app_cpp先构建接口包,再构建应用包。这样做的另一个原因是排查问题方便:如果应用包编译失败,你可以确认接口包本身是否正常,而不是被其它包的错误干扰。
3. 从零写一个自定义消息:包创建、msg 编写与 colcon 构建
3.1 创建功能包并初始化依赖
我用一条命令创建接口包。这里选择ament_cmake构建类型,而不是ament_python,因为接口包必须经过 CMake 才能调用代码生成器编译产物:
ros2 pkg create tutorial_interfaces --build-type ament_cmake这条命令会在当前目录生成tutorial_interfaces包,包含默认的CMakeLists.txt和package.xml。默认模板离可编译的自定义消息包还差几步,需要手动补依赖和接口声明。
如果希望把依赖提前写进package.xml,也可以在创建时指定:
ros2 pkg create tutorial_interfaces --build-type ament_cmake --dependencies std_msgs geometry_msgs这里把std_msgs和geometry_msgs列为依赖,是因为我们大概率会用到Header或者Point这类嵌套类型。即使当前版本没用到,预先把常用依赖放进package.xml也方便后续扩展。注意--dependencies不会自动修改CMakeLists.txt的find_package段,仍要手动检查。
3.2 编写 msg 文件:字段顺序有讲究
进入包目录,创建msg目录,写入SensorData.msg:
cd tutorial_interfaces mkdir msg文件内容这样写:
# SensorData.msg int32 id float64 x float64 y float64 vx float64 vy float64 confidence string frame_id bool valid uint8 status uint8 STATUS_OK=0 uint8 STATUS_FAIL=1字段顺序我会把主键放前面,比如id在最前,便于抓包时快速辨认消息主体;状态位放最后,因为它的变化频率低。字段顺序从代码层面不会影响收发正确性,但会影响可读性和默认构造顺序,建议把固定属性和变化属性分开排列。
命名上注意:字段名必须符合 C 语言标识符规范,且 ROS2 官方建议使用小写加下划线的 snake_case。我在实际项目中见过pointX这类字段名,编译能过,但生成代码风格和整个项目不一致,C++ 里看着很突兀。写point_x会顺眼得多。注释用#开头,和 Python 注释一样,构建时会忽略。
3.3 CMakeLists 与 package.xml 配置:容易漏的两处
默认生成的CMakeLists.txt不会自动帮你注册.msg文件,这是新手第一次构建时最容易卡住的地方。需要在ament_package()之前加上一段接口生成声明:
cmake_minimum_required(VERSION 3.8) project(tutorial_interfaces) find_package(ament_cmake REQUIRED) find_package(rosidl_default_generators REQUIRED) find_package(std_msgs REQUIRED) find_package(geometry_msgs REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/SensorData.msg" DEPENDENCIES std_msgs geometry_msgs ) ament_export_dependencies(rosidl_default_runtime) ament_package()rosidl_generate_interfaces是核心入口。第一个参数是包名,后面列出所有.msg文件,DEPENDENCIES声明消息中嵌套了哪些外部接口包。如果SensorData.msg中用到了geometry_msgs/msg/Point,而这里没写DEPENDENCIES geometry_msgs,生成的代码会找不到类型定义。
package.xml同样要补三处以内容:
<buildtool_depend>ament_cmake</buildtool_depend> <buildtool_depend>rosidl_default_generators</buildtool_depend> <depend>std_msgs</depend> <depend>geometry_msgs</depend> <exec_depend>rosidl_default_runtime</exec_depend> <member_of_group>rosidl_interface_packages</member_of_group>rosidl_default_generators声明了构建时需要的代码生成器,rosidl_default_runtime是运行时依赖。member_of_group这一行容易被忽略,它把包登记为 ROS2 接口包,这样ros2 interface list才能枚举到这个包的接口。
3.4 构建与验证:用 ros2 interface 检查产物
配置完成后,回到工作空间根目录构建:
colcon build --packages-select tutorial_interfaces source install/setup.bash构建成功后再验证消息是否真的进入了环境。我一般会跑三条命令:
ros2 interface list | grep tutorial_interfaces ros2 interface show tutorial_interfaces/msg/SensorData ros2 pkg prefix tutorial_interfacesros2 interface list输出环境中所有已注册的接口,过滤后如果能看到tutorial_interfaces/msg/SensorData,说明包的接口已被识别。ros2 interface show会打印出消息的字段结构,这是排查“消息没更新”问题时最重要的命令。ros2 pkg prefix打印包安装路径,用来确认当前终端 source 的是哪个工作空间的安装目录。
构建产物已经落在install/目录中,C++ 头文件在install/tutorial_interfaces/include/,Python 模块在install/tutorial_interfaces/lib/python3.*/site-packages/下。这些路径你暂时不用记,但要理解:ros2 interface show能看到的新字段,就是编译产物里的字段;如果它显示的内容和.msg不一致,说明构建没生效,后面第 5 章专门讲这个坑。
4. 在 Python 与 C++ 里使用自定义消息:发布订阅与包可见性
4.1 Python:rclpy 导入与发布订阅写法
接口包构建好之后,实际使用中还要有一个应用包。Python 侧我用ament_python创建应用包:
ros2 pkg create app_py --build-type ament_python --dependencies rclpy tutorial_interfaces创建完成后,在app_py/app_py/目录下新建sensor_publisher.py,内容是一个最简单的发布节点:
import rclpy from rclpy.node import Node from tutorial_interfaces.msg import SensorData class SensorPublisher(Node): def __init__(self): super().__init__('sensor_publisher') self.pub = self.create_publisher(SensorData, '/sensor_data', 10) self.timer = self.create_timer(0.1, self.on_timer) self.seq = 0 def on_timer(self): msg = SensorData() msg.id = self.seq msg.x = float(self.seq * 0.1) msg.y = float(self.seq * -0.1) msg.vx = 0.5 msg.vy = 0.2 msg.confidence = 0.98 msg.frame_id = 'base_link' msg.valid = True msg.status = SensorData.STATUS_OK self.pub.publish(msg) self.seq += 1 def main(args=None): rclpy.init(args=args) node = SensorPublisher() rclpy.spin(node) node.shutdown() rclpy.shutdown()代码里的导入行from tutorial_interfaces.msg import SensorData是关键。这里tutorial_interfaces是包名,msg是消息子模块,SensorData是消息类名。类名必须和.msg文件名完全一致,SensorData.msg对应SensorData,大小写敏感。赋值时,消息字段就是 Python 对象的属性,直接点号访问;SensorData.STATUS_OK这种常量也像类属性一样可用。
订阅端代码结构差不多,只是把create_publisher换成create_subscription:
import rclpy from rclpy.node import Node from tutorial_interfaces.msg import SensorData class SensorSubscriber(Node): def __init__(self): super().__init__('sensor_subscriber') self.sub = self.create_subscription( SensorData, '/sensor_data', self.callback, 10) def callback(self, msg): self.get_logger().info( f'id={msg.id}, x={msg.x:.2f}, y={msg.y:.2f}, ' f'confidence={msg.confidence}, valid={msg.valid}' ) def main(args=None): rclpy.init(args=args) node = SensorSubscriber() rclpy.spin(node) node.shutdown() rclpy.shutdown()回调函数里接收到的msg对象和发布端构造的对象是同一类型,字段读取方式一致。注意create_subscription最后一个参数10是队列深度,不是滤波频率,很多人刚接触 ROS2 时会把这里理解成“缓存多少条消息多久处理一次”,实际上它只决定 QoS history 的深度,单位是条数。
4.2 C++:rclcpp 头文件与类型支持
C++ 应用包我另建一个目录:
ros2 pkg create app_cpp --build-type ament_cmake --dependencies rclcpp tutorial_interfacesC++ 侧的消息类型映射规则和 Python 不一样。.msg文件SensorData.msg生成的头文件是tutorial_interfaces/msg/sensor_data.hpp,命名空间是tutorial_interfaces::msg,类名是SensorData。文件名的 snake_case 和类名的 PascalCase 要区分清楚,这个对应关系很隐蔽。
下面是一个订阅节点的完整实现:
#include "rclcpp/rclcpp.hpp" #include "tutorial_interfaces/msg/sensor_data.hpp" using std::placeholders::_1; class SensorSubscriber : public rclcpp::Node { public: SensorSubscriber() : Node("sensor_cpp_subscriber") { sub_ = this->create_subscription<tutorial_interfaces::msg::SensorData>( "/sensor_data", 10, std::bind(&SensorSubscriber::callback, this, _1)); } private: void callback(const tutorial_interfaces::msg::SensorData::SharedPtr msg) { RCLCPP_INFO(this->get_logger(), "id=%d, x=%.2f, y=%.2f, valid=%s", msg->id, msg->x, msg->y, msg->valid ? "true" : "false"); } rclcpp::Subscription<tutorial_interfaces::msg::SensorData>::SharedPtr sub_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<SensorSubscriber>()); rclcpp::shutdown(); return 0; }重点在模板类型tutorial_interfaces::msg::SensorData。C++ 中所有消息都用SharedPtr传递,回调参数取msg->id指针访问字段。头文件路径里的sensor_data.hpp是小写加下划线,和文件名SensorData.msg完全两种写法,我第一次从 Python 转 C++ 时在这里走过弯路。
别忘了在应用包的CMakeLists.txt里链接接口包:
find_package(rclcpp REQUIRED) find_package(tutorial_interfaces REQUIRED) add_executable(sensor_subscriber src/sensor_subscriber.cpp) ament_target_dependencies(sensor_subscriber rclcpp tutorial_interfaces) install(TARGETS sensor_subscriber DESTINATION lib/${PROJECT_NAME})find_package(tutorial_interfaces)必须在add_executable之前,ament_target_dependencies把库和头文件搜索路径都传给目标。漏掉tutorial_interfaces这一行,编译时就会报找不到<pkg>/msg/sensor_data.hpp>。
4.3 多节点联调:运行时发生的事
两边都写完后,编译并启动:
colcon build --packages-select tutorial_interfaces app_py app_cpp source install/setup.bash ros2 run app_py sensor_publisher ros2 run app_cpp sensor_subscriber如果一切正常,C++ 终端会持续打印收到的消息。用ros2 topic echo /sensor_data可以实时看到话题上的原始数据;用ros2 topic info /sensor_data -v能看到发布端和订阅端的数量、类型名以及 QoS 参数。
这里有个重要认知:ROS2 通信不是靠话题名字符串匹配的,而是靠接口类型匹配。如果发布端发的是tutorial_interfaces/msg/SensorData,订阅端也声明同一个类型,两端才能建立连接。假如两端因为构建时序不同导入了同一个包名的不同版本,会出现“话题存在但收不到数据”的诡异现象,这类问题放在下一章讲。
5. 自定义消息排查:三个高频报错与两个隐蔽坑
5.1 Package not found:环境没 source 或 source 错了工作区
现象:新开终端执行ros2 run app_py sensor_publisher,报错提示Package 'app_py' not found或者Package 'tutorial_interfaces' not found,但明明刚才在工作区里构建成功过。
原因:每个终端默认只加载了基础 ROS2 环境,工作空间的install/setup.bash没有被执行。命令行工具找不到包,并不是包没构建成功,而是当前终端的环境变量里没有这个包的路径。
解决:回到工作空间根目录,执行source install/setup.bash后再运行。如果想省去每次开终端都手动 source 的麻烦,把这一行追加到~/.bashrc,但要注意如果同时 source 多个工作区,后 source 的会覆盖先 source 的。排查时用echo $AMENT_PREFIX_PATH看当前环境中包含哪些工作区安装路径,确认顺序是否符合预期。
5.2 Python 导入失败:消息没生成或名称对不上
现象:启动 Python 发布节点时报ModuleNotFoundError: No module named 'tutorial_interfaces.msg',或者ImportError提示找不到SensorData。
原因:最常见的是接口包没有成功构建,或者构建完没有重新 source;另一种是.msg文件名和导入名不一致,比如文件叫sensor_data.msg,代码里却写from tutorial_interfaces.msg import SensorData,生成类名是SensorData而实际需要与sensor_data.msg对应的SensorData类,这个坑在文件名首字母小写时容易触发。
解决:按顺序做三步检查。第一步,ls install/tutorial_interfaces/lib/python*/site-packages/tutorial_interfaces/msg/看是否存在生成的_sensor_data.py模块文件。第二步,ros2 interface show tutorial_interfaces/msg/SensorData确认接口被环境识别。第三步,确认代码里的导入名称严格等于.msg文件名去掉扩展名后的驼峰形式。如果生成文件存在但仍导入失败,多半是终端 source 到了旧的安装前缀。
5.3 改了 msg 却不生效:增量构建的旧产物残留
现象:修改了SensorData.msg,增加了一个int32 level字段,重新colcon build --packages-select tutorial_interfaces后,节点代码里访问msg.level依然报错,ros2 interface show看到的还是旧结构。
原因:构建命令只重新编译了接口包,但没有重新编译依赖这个接口的应用包。应用包链接的是旧版本接口包生成的类型定义,即使接口包 install 目录已经更新,应用包里的生成代码和可执行文件还停留在上一次编译的状态。另一个常见情况是用了--symlink-install构建,.msg文件被符号链接但生成代码没有重新执行,导致界面元数据过期。
解决:修改.msg后,把接口包和所有依赖它的应用包一起重新构建:
colcon build --packages-select tutorial_interfaces app_py app_cpp构建完先不要急着跑节点,用ros2 interface show确认新字段已经出现在命令行输出中,再启动应用。如果两次构建之间改动较大,干脆删掉整个build/、install/、log/目录做一次全量重建:
rm -rf build install log colcon build --packages-select tutorial_interfaces app_py app_cpp这个做法看起来粗暴,但能排除绝大部分由缓存导致的“改了不生效”问题。
5.4 C++ 编译失败:头文件路径与类型依赖缺失
现象:C++ 应用包编译时报fatal error: tutorial_interfaces/msg/sensor_data.hpp: No such file or directory,或者静态检查提示tutorial_interfaces::msg::SensorData不是一个类型名。
原因:CMakeLists 里缺少find_package(tutorial_interfaces REQUIRED),或者ament_target_dependencies没有把接口包写进目标依赖,导致编译器的头文件搜索路径和工作区里没有该包。也有一种情况是把头文件路径写成了tutorial_interfaces/msg/SensorData.hpp,用大写文件名找消息头文件,这也不对,ROS2 生成头文件是 snake_case 命名。
解决:先确认CMakeLists.txt中同时存在find_package(tutorial_interfaces REQUIRED)和对应目标的ament_target_dependencies。然后检查 include 语句,按tutorial_interfaces/msg/sensor_data.hpp写。如果消息定义里嵌套了其它包类型,比如geometry_msgs/msg/Point,还需要在接口包的rosidl_generate_interfaces的DEPENDENCIES中声明该依赖,否则生成 C++ 代码内部找不到嵌套类型头文件。
5.5 编译能过但收不到消息:QoS 与类型哈希不一致
现象:发布端和订阅端都能正常启动,ros2 topic list里也能看到/sensor_data,但订阅端回调从未触发,且不打印任何错误。
原因:这类“静默断流”最常见有两个来源。一是 QoS 策略不匹配,发布端设置了best_effort可靠性,订阅端却是reliable默认策略,两端协商失败;二是接口包版本不一致,发布端和订阅端各自使用了不同构建时间戳的tutorial_interfaces,DDS 根据消息类型哈希判断两边的定义不一致,连接建立失败。
解决:先在任意终端执行ros2 topic info /sensor_data -v,查看发布方和订阅方的 QoS 类型以及 type name。确认两边的类型名都是tutorial_interfaces/msg/SensorData,然后把发布端和订阅端的reliability、history策略对齐。接口包版本问题则要检查两端的运行环境,确保都 source 了同一个工作空间的install/setup.bash,而不是各自加载了不同目录里的同名包。
6. 进阶:给消息加时间戳,把接口包做成可复用资产
自定义消息真正好用的形态,是在设计初期就把时间戳和坐标系字段加进去。给SensorData.msg增加一个标准时间戳字段:
# SensorData.msg builtin_interfaces/msg/Time stamp int32 id float64 x float64 y float64 vx float64 vy float64 confidence string frame_id bool valid uint8 status同时把builtin_interfaces加进接口包的依赖声明中。这样改有两个实际好处 | 说明
stamp字段作为消息内置时间 | 接收端可以计算延迟,判断数据是否过期ros2 topic delay /sensor_data能直接基于该字段输出延迟统计 | 不用自己写测量代码 后续如果消息要用于录制回放 | 时间戳是 ROS2 bag 处理时间轴的基础
Python 发布端给时间戳赋值时用self.get_clock().now().to_msg(),C++ 发布端用this->get_clock()->now().to_msg()。这两行代码看似简单,实际上规避了把time.time()或std::chrono手搓成秒的格式转换问题,ROS2 的Time类型包含秒和纳秒两个字段,手搓极易出错。
在这个基础上,发布频率为 10Hz 的消息,可以用ros2 topic hz /sensor_data验证实际发布频率,用ros2 topic delay /sensor_data查看端到端延迟。这两个命令是自定义消息上线前最快速的体检工具,比写日志更直观。如果你把消息放进一个只包含接口定义的独立包,应用包只需要声明依赖就能编译运行,以后其它项目也能直接复用这套接口。
从那以后,我每次改完.msg都强制走一遍固定流程:改文件 → 重新编译接口包 → source →ros2 interface show确认字段 → 再动应用代码。这串动作看起来多,实际上能省掉大量“为什么改了没生效”的排查时间。自定义消息的设计属于接口层的工作,接口层稳定,上层节点才能稳定。希望这篇笔记能帮你在 ROS2 自定义消息这条路上少走几步弯路,把精力留在真正需要调试的功能逻辑上。
本文还有配套的精品资源,点击获取