4、自定义话题消息:msg文件定义、字段类型、数组与嵌套、编译与使用、消息版本管理
聊完了话题通信的基本套路,咱们得面对一个现实:ROS2 自带的那些标准消息类型(比如 std_msgs::String、geometry_msgs::Pose),在真实项目里往往不够用。
拿机械臂来说,你想发布一个「关节角度 + 当前电流 + 温度」的复合数据。用标准消息?要么拆成三个话题,要么塞进一个 String 里自己解析。这两种方式我都踩过坑——拆话题导致同步困难,塞字符串又让代码变得又臭又硬。
所以,自定义消息是绕不开的一步。今天咱们就把这事聊透。
4.1 为什么需要自定义消息?
说白了,自定义消息就是让你定义自己的数据结构。ROS2 的通信本质是序列化与反序列化,你定义好 .msg 文件,编译系统会自动生成对应的 C++/Python 代码。
我个人的习惯是:只要数据字段超过 3 个,或者有嵌套需求,就立刻定义自定义消息。别偷懒,后面维护的时候你会感谢自己的。
4.2 msg 文件定义与字段类型
先看一个最简单的例子。假设我们要定义一个机械臂单关节的状态消息:
# JointState.msg
int16 joint_id
float64 position
float64 velocity
float64 effort
string name
文件放在 my_robot_interfaces/msg/JointState.msg。注意目录结构:msg 文件夹必须和 CMakeLists.txt 同级。
ROS2 支持的字段类型很丰富,我整理了一张表:
| 类型 | C++ 对应 | 说明 |
|---|---|---|
bool |
bool |
布尔值 |
int8/uint8 |
int8_t/uint8_t |
字节型,注意 uint8 在 Python 里会被当作 bytes |
int16/uint16 |
int16_t/uint16_t |
短整型 |
int32/uint32 |
int32_t/uint32_t |
标准整型 |
int64/uint64 |
int64_t/uint64_t |
长整型 |
float32 |
float |
单精度浮点 |
float64 |
double |
双精度浮点,机械臂控制里最常用 |
string |
std::string |
字符串 |
time |
builtin_interfaces::Time |
时间戳 |
duration |
builtin_interfaces::Duration |
时间间隔 |
float64,电流和温度用 float32 就够了。精度够用就行,别盲目上高精度,序列化开销也是成本。
4.3 数组与嵌套
单关节状态太简单了。真实机械臂有 6 个、7 个甚至更多关节。你总不能定义 6 个独立的话题吧?
这时候数组就派上用场了:
# MultiJointState.msg
int16[] joint_ids
float64[] position
float64[] velocity
float64[] effort
string[] name
数组用 [] 表示,长度不固定。ROS2 内部会帮你处理好动态数组的序列化。
但这样写有个问题:每个数组独立存在,你没法保证 joint_ids[2] 和 position[2] 对应的是同一个关节。万一有人插入了数据没同步更新,排查起来很痛苦。
更好的做法是用嵌套消息:
# JointState.msg(复用上面的单关节定义)
int16 joint_id
float64 position
float64 velocity
float64 effort
string name
# MultiJointState.msg
JointState[] joints
嵌套消息用 其他消息类型[] 的格式。编译时 ROS2 会自动递归处理所有依赖。
4.4 编译与使用
定义好 .msg 文件,接下来就是编译。这里分两步走。
4.4.1 配置 CMakeLists.txt
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/JointState.msg"
"msg/MultiJointState.msg"
DEPENDENCIES builtin_interfaces
)
关键点:
- 必须调用
rosidl_generate_interfaces,而不是add_executable - 依赖的包(比如
builtin_interfaces)要在DEPENDENCIES里声明 - 如果嵌套了其他自定义消息,那个消息所在的包也要在
find_package里找到
4.4.2 配置 package.xml
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
这三行缺一不可。我曾经漏了 member_of_group,结果编译出来的消息在其他包里死活找不到,折腾了半天。
4.4.3 在代码中使用
C++ 端:
#include "my_robot_interfaces/msg/joint_state.hpp"
auto msg = my_robot_interfaces::msg::JointState();
msg.joint_id = 1;
msg.position = 0.5;
Python 端:
from my_robot_interfaces.msg import JointState
msg = JointState()
msg.joint_id = 1
msg.position = 0.5
注意 C++ 的头文件路径是 my_robot_interfaces/msg/joint_state.hpp,全小写+下划线。Python 则是 my_robot_interfaces.msg,驼峰命名。这个差异我一开始也经常搞混。
4.5 消息版本管理
项目做大了,消息定义难免要改。今天加个字段,明天改个类型。怎么保证改了之后老代码还能跑?
ROS2 没有官方的版本管理机制,但我在实践中总结了几条原则:
- 只增不减: 新字段只往后加,不要删除或修改已有字段的类型和含义。老代码读到新消息,不认识新增字段就忽略;新代码读到老消息,缺省字段用默认值。
- 用默认值: 在
.msg文件里给新字段设置默认值。比如float64 temperature 25.0,这样老代码即使没发这个字段,新代码也能拿到一个合理值。 - 包名带版本: 如果改动实在太大(比如重构了整个数据结构),我建议新建一个接口包,比如
my_robot_interfaces_v2。新旧并存,逐步迁移。 - 记录变更日志: 在接口包的根目录放一个
CHANGELOG.md,每次改.msg文件都注明改了啥、为什么改。别信自己的记忆力,三个月后你肯定忘。
.msg 文件里的字段,结果所有订阅者崩溃。从那以后,我改消息定义一定先通知团队,再走 CI 测试,最后才合并。
4.6 知识体系总览
下面这张图把自定义消息的完整流程串起来了:
从 .msg 文件出发,字段类型、数组嵌套、编译使用三条线并行,最终汇聚到版本管理。这就是自定义消息的完整知识闭环。
.msg 文件练手,编译通过后再加数组和嵌套。别一上来就搞复杂结构,容易把自己绕晕。