简介:ROS2消息自定义与使用完整示例包,面向需要掌握消息机制并实现自定义节点通信的ROS2学习者。资料以可编译运行的工作空间为主体,完整呈现从创建msg消息包、编写.msg字段定义、生成IDL接口,到通过colcon构建安装并在发布订阅节点中使用自定义消息的全流程;消息定义支持float32、string、bool等常见字段,并附有发布端与订阅端两个可直接运行的示例节点。压缩包共645个文件,大小3.43MB,主要包含cmake/make构建配置、C++/Python源码、消息头文件、msg与IDL定义、编译生成的库文件与可执行文件,以及colcon相关环境脚本,便于在工作空间中直接复现验证。已有472人学习下载。通过对照源码、构建脚本、生成接口以及最终可执行产物,可直观理解ament包创建与编译机制、rclcpp::Publisher/Subscription的调用方式,并为后续学习QoS策略、跨语言通信和复杂机器人系统设计打下基础。整个示例包结构清晰、体积小巧,适合在Ubuntu/ROS2环境下边看边练,快速内化自定义消息的开发流程。
1. ROS2自定义消息:当默认消息类型撑不住业务的时候
ROS2 自带的 std_msgs、geometry_msgs 能覆盖大部分通用场景,但真跑到业务里,你会发现「两个节点之间传一包结构化数据」这件事,默认类型经常不够用。比如某个产线采集节点要往状态中心上报设备编号、温度、坐标和几个布尔标志位,硬塞进 String 再自己 split,能跑但那叫一个难受,调试一次就后悔一次。自定义消息就是把这类业务结构固化成接口类型,编译期帮你查字段拼写,运行期有完整工具链做验包、过滤、录制。这篇文章用一个真实场景的完整消息定义,从建接口包、写 msg、编译、Python/C++ 双端调用到常见翻车现场都过一遍。适合已经建好 ROS2 工作空间、想在节点间传结构化数据、又不想再跟字符串解析纠缠的开发者。
2. 接口包先行:为什么自定义消息要单独建包
2.1 消息包与节点包分离的工程理由
先说一个关键限制:ROS2 的消息类型不是写在一个普通包里就能自动生成代码的。.msg文件需要经过 rosidl 代码生成器,在编译期生成 Python、C++ 以及中间件层需要的代码,而rosidl_generate_interfaces这个 CMake 函数在 ROS2 里只支持 ament_cmake 构建类型。换句话说,一个包如果同时放着节点和.msg文件,并且这个包是 ament_python 类型,就行不通。最省心的做法是单独建一个「接口包」,里面只放.msg、.srv、.action文件,不写任何功能代码。
其次是复用问题。假设你写了三个节点包,pub 节点发、sub 节点收、bridge 节点转发,它们都要引用同一个消息类型。如果把消息定义放在 pub 节点包里,sub 和 bridge 就得反向依赖 pub 包,工程上非常别扭。独立接口包让所有节点包都依赖同一个纯接口层,像依赖一个公共库一样,谁也不欠谁。
还有一个容易被忽略的理由是接口演进的可见度。消息字段改了,哪些节点要重新编译,没有独立接口包的时候全靠玄学;有了独立接口包,colcon build --packages-up-to xxx能明确算出受影响范围,不至于漏编译或者多编译一堆无关包。
2.2 用 colcon 创建接口包
创建接口包的标准命令如下,我一般会把工作空间路径先切到 src 下:
cd ~/ros2_ws/src ros2 pkg create my_interfaces \ --build-type ament_cmake \ --dependencies rosidl_default_generators \ rosidl_default_runtime--build-type ament_cmake是接口包的唯一选择,只有这个构建类型支持 rosidl 代码生成。--dependencies后面跟的不是业务依赖,而是代码生成工具链:rosidl_default_generators负责编译期生成各语言代码,rosidl_default_runtime提供运行期支持,这两个在创建接口包时是标配,不要省。
创建完,包内结构长这样:
my_interfaces/ ├── CMakeLists.txt ├── package.xml ├── msg/ ├── srv/ └── action/注意msg、srv、action这三个目录在ros2 pkg create时不会自动生成,需要手动创建。如果只传自定义消息,建 msg 目录就够了:
mkdir -p my_interfaces/msg2.3 package.xml 里必须写清楚的依赖
创建接口包后,package.xml 里至少要把下面四段确认清楚。我用一个引用了 std_msgs 和 geometry_msgs 的接口包举例:
<buildtool_depend>rosidl_default_generators</buildtool_depend> <depend>rosidl_default_runtime</depend> <depend>std_msgs</depend> <depend>geometry_msgs</depend> <member_of_group>rosidl_interface_packages</member_of_group>buildtool_depend声明的是编译期工具,rosidl_default_generators不属于普通运行时库,绝不能写成普通depend。depend声明的std_msgs和geometry_msgs是消息里要引用的类型包,凡是消息里出现过的接口包,必须在 package.xml 和 CMakeLists 的DEPENDENCIES里同时出现,少一个都会在生成代码时报找不到类型。
最后那一行member_of_group是最容易被忽略的。它告诉ros2 interface工具链这个包属于 ROS2 接口包,漏掉它,包能编译、节点也能用,但ros2 interface list里永远看不到你的自定义消息,排查起来特别迷惑。
对应 CMakeLists.txt 里也要同步写好依赖查找和接口生成:
find_package(rosidl_default_generators REQUIRED) find_package(std_msgs REQUIRED) find_package(geometry_msgs REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/DeviceStatus.msg" DEPENDENCIES std_msgs geometry_msgs )DEPENDENCIES里写的是这个接口包引用了哪些其他接口包,生成代码时会自动把这些依赖的头文件和 Python 模块一起带上。到这里,包结构和依赖声明都准备好了,下一步才是真正设计消息内容,这是自定义消息的核心工作,决定了下游代码怎么写、怎么写才不容易踩坑。
3. 写清楚消息定义:msg 语法与字段设计
3.1 msg 文件能用的类型与命名约束
msg文件不是随便写个字符串就行的,它有一套严格的类型语法。支持的类型分三组,我常用这张表对照:
| 分类 | 类型 | 说明 |
|---|---|---|
| 基础类型 | bool、int8~uint64、float32、float64、string | 标量值 |
| 数组 | int32[]、float64[3]、string[] | 动态数组 / 固定长度数组 |
| 嵌套类型 | std_msgs/Header、geometry_msgs/Point | 引用其他接口包的消息 |
数组类型里有个容易被忽略的写法:int32[<=5]表示长度不超过 5 的有界数组,传输层会做长度校验。float64[3]是固定长度数组,适合坐标、姿态这类定长结构。
命名规范也要从一开始就守住:包名全小写,msg 文件名首字母大写,字段名小写下划线,常量名全大写。DeviceStatus.msg生成的头文件是device_status.hpp,类名保持驼峰DeviceStatus;Python 模块则是my_interfaces.msg.DeviceStatus。这套命名规则是在 rosidl 代码生成器里固定死的,改不了也别试图对抗,照着来就行。
3.2 从业务需求拆字段:一个设备状态消息
拿一个具体场景走一遍字段设计。假设车间里有个设备节点,要周期性上报自己的编号、当前温度、所在位置、运行标志和故障码。需求拆下来,消息定义第一版长这样:
std_msgs/Header header string device_id float64 temperature geometry_msgs/Point position bool is_running bool is_error uint8 error_code每个字段对应哪条业务,拆开看就很清楚。header是行业惯例,自带时间戳和 frame_id,比自己在消息里定义一个 time 字段再手动维护靠谱得多;device_id用字符串,因为设备编号里经常带分隔符和字母,用整型会丢信息;temperature用 float64,保留足够精度;position直接复用geometry_msgs/Point,不用自己拆三个 float 再拼回去;is_running和is_error是布尔标志;error_code用整型,不同值对应不同类型故障。
这样设计的好处是字段名自带语义,任何接手代码的人不用看注释就能猜出消息里有什么。比字段名缩写成一两个字母,再在代码顶部写一大段注释强得多。
3.3 常量、默认值与 Header:让消息更省事
msg文件支持常量和默认值,这两个特性看着不起眼,实际能省掉大量发送端代码。在刚才的 DeviceStatus 基础上补上常量和默认值:
uint8 ERROR_NONE = 0 uint8 ERROR_OVERTEMP = 1 uint8 ERROR_COMM_LOST = 2 std_msgs/Header header string device_id "unknown" float64 temperature 0.0 geometry_msgs/Point position bool is_running false bool is_error false uint8 error_code 0常量ERROR_NONE、ERROR_OVERTEMP、ERROR_COMM_LOST在发送端代码里可以直接引用,C++ 里写DeviceStatus::ERROR_OVERTEMP,Python 里写DeviceStatus.ERROR_OVERTEMP,不用各自定义一套 magic number。默认值的作用是给接收端一个兜底:如果发送端某个字段忘了赋值,接收端拿到的是 0 或 "unknown",而不是随机值,这对调试逻辑很有帮助。
Header 本身也有约定俗成的用法。发送端一般这样初始化:
msg.header = Header() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = "device_frame"时间戳用节点时钟自动生成,frame_id 填坐标系名。接收端拿到消息后,可以直接读msg.header.stamp判断数据是否过期,不用再额外传一个时间字段。
4. 编译与双端调用:消息从构建产物变成代码
4.1 编译接口包:构建顺序与产物确认
接口包建好、msg 文件写好之后,第一步是单独编译接口包,不要急着直接编译整个工作空间:
cd ~/ros2_ws colcon build --packages-select my_interfaces source install/setup.bash--packages-select只编译选中的包,单独先编接口包的原因有两个:一是消息代码生成比较慢且是纯生成过程,单独跑一遍报错信息干净;二是后面编译节点包时,消息类型必须已经存在,否则find_package(my_interfaces REQUIRED)直接失败。
编译完先别急着写代码,确认一下产物。install目录下已经能看到生成的消息源码了。头文件一般位于:
install/my_interfaces/include/my_interfaces/ ├── my_interfaces/msg/device_status.hpp └── my_interfaces/msg/detail/Python 模块则生成在:
install/my_interfaces/lib/python3.*/site-packages/my_interfaces/看到这些文件,说明 rosidl 代码生成链路是通的,可以先给接口包做个快速体检,确认消息被工具链识别。这一步我一般用ros2 interface list过滤自己的包名,下一章细讲。
4.2 Python 节点调用:import 路径与嵌套消息实例化
Python 端使用自定义消息,核心就一句话:from my_interfaces.msg import DeviceStatus。但发送端有细节要注意,嵌套消息类型不会自动实例化,需要先创建再赋值。一个完整的发布节点长这样:
import rclpy from rclpy.node import Node from std_msgs.msg import Header from geometry_msgs.msg import Point from my_interfaces.msg import DeviceStatus class DevicePublisher(Node): def __init__(self): super().__init__("device_publisher") self.pub = self.create_publisher(DeviceStatus, "device_status", 10) self.timer = self.create_timer(1.0, self.publish_status) def publish_status(self): msg = DeviceStatus() msg.header = Header() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = "device_frame" msg.device_id = "dev-001" msg.temperature = 45.2 msg.position = Point(x=1.0, y=2.0, z=0.0) msg.is_running = True msg.is_error = False msg.error_code = DeviceStatus.ERROR_NONE self.pub.publish(msg) def main(args=None): rclpy.init(args=args) node = DevicePublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown()msg.header = Header()这一行很关键,嵌套消息字段在 Python 端不会自动构造,直接写msg.header.stamp = ...会报AttributeError。同理msg.position也必须先实例化Point。字段赋值时,常量用DeviceStatus.ERROR_NONE引用,不需要再定义局部变量。create_publisher的第二个参数是话题名,第三个参数是队列深度,10 是默认值,高频发布场景建议调大到 50 以上。
4.3 C++ 节点调用:头文件与 CMake 链接
C++ 端使用自定义消息,头文件路径遵循命名转换规则:DeviceStatus.msg对应my_interfaces/msg/device_status.hpp,命名空间则是my_interfaces::msg::DeviceStatus。一个简单发布节点的骨架:
#include "rclcpp/rclcpp.hpp" #include "std_msgs/msg/header.hpp" #include "geometry_msgs/msg/point.hpp" #include "my_interfaces/msg/device_status.hpp" using my_interfaces::msg::DeviceStatus; int main(int argc, char** argv) { rclcpp::init(argc, argv); auto node = std::make_shared<rclcpp::Node>("device_publisher_cpp"); auto pub = node->create_publisher<DeviceStatus>("device_status", 10); auto timer = node->create_wall_timer( std::chrono::seconds(1), [node, pub]() { auto msg = DeviceStatus(); msg.header.stamp = node->now(); msg.device_id = "dev-002"; msg.temperature = 42.5; msg.is_running = true; msg.error_code = DeviceStatus::ERROR_NONE; pub->publish(msg); } ); rclcpp::spin(node); rclcpp::shutdown(); return 0; }C++ 端访问嵌套字段比 Python 简单,msg.header.stamp、msg.position都是内嵌对象,不需要额外实例化。但这里藏着一个陷阱:C++ 生成的成员名保持 msg 定义里的蛇形命名,device_id就是device_id,不是deviceId,跟常见 C++ 风格不一样,容易手滑写错。
CMake 编写是 C++ 端另一件必须做对的事。节点包的 CMakeLists.txt 需要:
find_package(my_interfaces REQUIRED) find_package(rclcpp REQUIRED) add_executable(device_publisher_cpp src/device_publisher_cpp.cpp) ament_target_dependencies(device_publisher_cpp rclcpp my_interfaces )ament_target_dependencies会把 my_interfaces 的 include 路径和依赖库一起传下去。如果你用了geometry_msgs、std_msgs,也要在find_package里列出来,否则编译时头文件找不到,报错信息千奇百怪。编译节点包的命令:
cd ~/ros2_ws colcon build --packages-select my_node source install/setup.bash前提是之前的接口包已经编译过并且在这个工作空间的 install 目录里。两个包都编译完,运行节点前先确认source install/setup.bash已执行,不然终端找不到my_interfaces。
5. 避坑:自定义消息最常见的五个翻车现场
5.1 C++ 编译报找不到 device_status.hpp
现象:自己节点包编译时报fatal error: my_interfaces/msg/device_status.hpp: No such file or directory。
原因:可能性有几种。最常见的是节点包的 CMakeLists.txt 里没有find_package(my_interfaces REQUIRED),或者ament_target_dependencies里漏写了 my_interfaces。另一种可能是源文件里 include 路径写成了my_interfaces/msg/DeviceStatus.hpp,忘记转成蛇形命名,rosidl 生成头文件只认device_status.hpp。
解决:先确认ament_target_dependencies里同时列了my_interfaces,再看 include 路径写法。有个土办法,find install/my_interfaces -name "*.hpp"能看到实际生成的头文件路径,照着写就不会错。
5.2 Python 运行时 ModuleNotFoundError
现象:from my_interfaces.msg import DeviceStatus报ModuleNotFoundError: No module named 'my_interfaces'。
原因:绝大多数情况是source install/setup.bash没执行,或者终端是在编译之前打开的,环境变量AMENT_PREFIX_PATH里没有当前工作空间的 install 路径。也有小概率是接口包根本没编译成功。
解决:依次执行cd ~/ros2_ws && colcon build --packages-select my_interfaces和source install/setup.bash,然后echo $AMENT_PREFIX_PATH确认路径里包含~/ros2_ws/install。如果在新终端里运行还会报错,检查~/.bashrc里有没有自动 source 工作空间的 setup.bash,没有的话手动 source。
5.3 改了 msg 文件,ros2 topic echo 还是旧字段
现象:修改了 DeviceStatus.msg,加了字段,重新编译后ros2 topic echo出来的消息还是老结构。
原因:只编译了节点包,没有重新编译接口包。或者接口包重新编译了,但运行中的节点还是旧进程,没重启。ROS2 的消息类型是编译期定死的,运行期不会热加载新字段。
解决:改完 msg 文件,一定要回到工作空间根目录执行colcon build --packages-select my_interfaces,再编译依赖它的节点包,然后重启节点。如果节点是通过ros2 run启动的,务必保证当前终端已经重新 source 过。这套顺序执行完,echo 里才能看到新字段。
5.4 两个接口包互相引用,colcon 构建死锁
现象:接口包 A 的消息引用了 B 的消息,B 的消息又引用了 A 的消息,colcon build直接报出依赖循环,构建失败。
原因:消息层互相引用形成了环,rosidl 无法确定先生成哪个包,这是接口设计层面的问题,不是配置问题。
解决:拆掉循环引用。把两个消息包里共用的字段抽到第三个更基础的接口包里,让 A 和 B 只依赖 C,不直接互相依赖。设计消息结构时,先画一下引用关系图,出现环就停手重新拆,不然编译过不去还得回头改。
5.5 ros2 interface list 里看不到自定义消息
现象:编译成功,节点也能用,消息字段赋值都正常,但ros2 interface list | grep my_interfaces没输出。
原因:package.xml 里缺了<member_of_group>rosidl_interface_packages</member_of_group>这一行。ros2 interface 工具只索引声明了成员组的接口包,漏声明就查不到。
解决:在 package.xml 的依赖区加上该行,重新colcon build --packages-select my_interfaces并 source。注意ros2 interface list是按环境变量索引的,新终端也要先 source 再用。补上之后ros2 interface package my_interfaces也能正常输出该包下所有接口。
6. 进阶:用命令行工具给自定义消息做一套完整验证
6.1 先从构建产物下手
改完消息,别急着写节点,先确认生成的文件对得上。看头文件是否生成、路径是否符合预期,这一步可以拦截大部分低级问题:
find install/my_interfaces -name "*device_status*"6.2 用 ros2 interface 三连确认接口
ros2 interface package my_interfaces ros2 interface show my_interfaces/msg/DeviceStatus ros2 interface list | grep my_interfaces三条命令分别确认消息列表、字段完整性和工具链可见性。字段里多出个没注意的默认值都能看出来。
6.3 用 topic echo 做传输验证
ros2 topic echo /device_status --full-length ros2 topic echo /device_status --field temperature--field是排查利器,日志刷得厉害的时候只挑一个字段看。
这套验证流程看着笨,但每次改完消息都走一遍,能拦住大多数翻车。从那以后我每次改消息定义,都强制自己先跑完三连验证再写业务代码。希望帮到你。
本文还有配套的精品资源,点击获取