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 会自动递归处理所有依赖。

注意: 嵌套层数不要太深。我见过有人嵌套了 5 层,编译时间暴涨,而且调试时根本看不清数据。一般来说 2-3 层就够了。

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 没有官方的版本管理机制,但我在实践中总结了几条原则:

  1. 只增不减: 新字段只往后加,不要删除或修改已有字段的类型和含义。老代码读到新消息,不认识新增字段就忽略;新代码读到老消息,缺省字段用默认值。
  2. 用默认值: 在 .msg 文件里给新字段设置默认值。比如 float64 temperature 25.0,这样老代码即使没发这个字段,新代码也能拿到一个合理值。
  3. 包名带版本: 如果改动实在太大(比如重构了整个数据结构),我建议新建一个接口包,比如 my_robot_interfaces_v2。新旧并存,逐步迁移。
  4. 记录变更日志: 在接口包的根目录放一个 CHANGELOG.md,每次改 .msg 文件都注明改了啥、为什么改。别信自己的记忆力,三个月后你肯定忘。
避坑指南: 我曾经在生产环境中直接删除了一个 .msg 文件里的字段,结果所有订阅者崩溃。从那以后,我改消息定义一定先通知团队,再走 CI 测试,最后才合并。

4.6 知识体系总览

下面这张图把自定义消息的完整流程串起来了:

自定义消息知识体系 自定义消息知识体系 msg 文件定义 字段类型 数组与嵌套 编译与使用 基本类型:int/float/string 时间类型:time/duration 动态数组:type[] 嵌套消息:OtherType CMakeLists.txt 配置 C++/Python 调用 消息版本管理 只增不减 默认值 包名带版本 变更日志

从 .msg 文件出发,字段类型、数组嵌套、编译使用三条线并行,最终汇聚到版本管理。这就是自定义消息的完整知识闭环。

我的建议: 刚开始学的时候,先从一个简单的 .msg 文件练手,编译通过后再加数组和嵌套。别一上来就搞复杂结构,容易把自己绕晕。