4. 自定义ROS消息与服务:msg文件定义、srv文件定义、编译与使用、最佳实践

好,咱们进入第4章。前面几章我们把ROS通信的骨架搭起来了,但光用现成的标准消息类型,就像盖房子只用标准砖——能盖,但总有些特殊形状的墙角填不上。这时候就需要自己烧制“异形砖”了。

说白了,自定义消息(msg)和服务(srv) 就是让你能定义自己的数据结构。我个人的习惯是,只要一个话题要传递超过两个基础类型字段,我就直接定义一个新msg,而不是用 std_msgs/String 拼个JSON字符串。为什么?因为类型安全,编译期就能发现错误,而不是等到运行时解析JSON报错。

核心原则: 消息是数据的载体,服务是请求-响应的契约。把数据结构和业务逻辑解耦,是架构师的基本功。

4.1 msg文件定义:从零开始造一个“数据包”

msg文件,其实就是个文本文件,里面一行一个字段。格式很简单:类型 名称。我刚开始学的时候,觉得这玩意儿跟C语言的结构体定义差不多,确实,本质就是序列化协议。

咱们拿一个实际项目举例。我在做机器人底盘驱动时,需要把电机的状态、电流、温度打包成一个消息。标准库里没有现成的,那就自己造。

在功能包目录下创建 msg/ 文件夹,然后新建 MotorStatus.msg:

# MotorStatus.msg
# 电机状态消息
# 时间戳
std_msgs/Header header

# 电机编号
int8 motor_id

# 当前转速 (rpm)
float32 rpm

# 当前电流 (A)
float32 current

# 电机温度 (摄氏度)
float32 temperature

# 是否过载
bool overload

注意几点:

  • 首行惯例:我习惯把 std_msgs/Header 放在第一个字段。这样消息自带时间戳和坐标系信息,很多工具(比如 rqt_bag)能自动识别。
  • 类型支持:ROS支持int8/16/32/64、float32/64、string、bool、time、duration,以及嵌套的其他msg类型。
  • 注释用#:跟Python一样,# 后面是注释。写清楚每个字段的含义和单位,这是好习惯。我曾经接手过一个项目,里面全是 float32 a、float32 b 这种字段,看得我血压飙升。
命名规范: 文件名首字母大写,驼峰命名。比如 MotorStatus.msg,而不是 motor_status.msg。ROS社区约定俗成的规矩,跟着走就行。

4.2 srv文件定义:请求-响应的“契约”

服务(Service)是同步的远程过程调用。srv文件比msg文件多了一个 --- 分隔线,上面是请求(Request),下面是响应(Response)。

举个例子,机器人需要执行一个“设置目标位置”的服务。客户端发一个坐标,服务端返回是否成功。

在功能包下创建 srv/ 文件夹,新建 SetTarget.srv:

# SetTarget.srv
# 请求:目标位置
float32 x
float32 y
float32 theta
---
# 响应:执行结果
bool success
string message

你看,结构一目了然。请求字段和响应字段可以完全不同类型。我见过有人把响应写成 --- 下面空着,只返回一个空消息——虽然语法上允许,但实践中强烈不推荐。服务调用方总得知道调用结果吧?

避坑指南: 我曾经在srv文件里把请求和响应的字段名写重了,比如请求里有个 int32 id,响应里也有个 int32 id。编译能过,但运行时会混淆。建议请求字段加 req_ 前缀,响应字段加 res_ 前缀,或者干脆用不同的名字。

4.3 编译与使用:让自定义类型“活”起来

定义好文件只是第一步。要让ROS知道这些新类型,需要修改 CMakeLists.txt 和 package.xml。这一步很多人容易漏,编译报错半天找不到原因。

第一步:修改 package.xml

添加编译依赖和运行依赖:

<build_depend>message_generation</build_depend>
<exec_depend>message_runtime</exec_depend>

第二步:修改 CMakeLists.txt

这里有几个关键点,我按顺序列出来:

  1. find_package 里加上 message_generation:
find_package(catkin REQUIRED COMPONENTS
  roscpp
  rospy
  std_msgs
  message_generation
)
  1. add_message_files 声明你的msg文件:
add_message_files(
  FILES
  MotorStatus.msg
)
  1. add_service_files 声明你的srv文件:
add_service_files(
  FILES
  SetTarget.srv
)
  1. generate_messages 生成代码:
generate_messages(
  DEPENDENCIES
  std_msgs
)
  1. catkin_package 里导出 message_runtime:
catkin_package(
  CATKIN_DEPENDS message_runtime
)

嗯,步骤有点多,但别怕。我整理了一个对照表,方便你检查:

文件 必须添加的内容 常见遗漏
package.xml build_depend: message_generation
exec_depend: message_runtime
只加了build,忘了exec
CMakeLists.txt find_package + message_generation
add_message_files / add_service_files
generate_messages
catkin_package + message_runtime
漏了generate_messages

配置好之后,catkin_make 编译。如果一切顺利,你会看到类似 Generating messages for package xxx 的日志。然后就可以在代码里 #include 或者 import 了。

C++ 示例:

#include <ros/ros.h>
#include <your_package/MotorStatus.h>

void callback(const your_package::MotorStatus::ConstPtr& msg)
{
  ROS_INFO("Motor %d: rpm = %.2f", msg->motor_id, msg->rpm);
}

Python 示例:

import rospy
from your_package.msg import MotorStatus

def callback(msg):
    rospy.loginfo(f"Motor {msg.motor_id}: rpm = {msg.rpm}")

rospy.init_node('listener')
sub = rospy.Subscriber('motor_status', MotorStatus, callback)

4.4 最佳实践:架构师的“内功心法”

定义消息类型看似简单,但用好了能极大提升系统可维护性。我总结了几个原则:

  • 单一职责:一个消息只描述一个实体。别把电机状态和电池电量塞到同一个msg里。拆开,各自独立发布。
  • 避免嵌套过深:msg里可以嵌套其他msg,但别超过两层。比如 A.msg 包含 B.msg,B.msg 又包含 C.msg。调试的时候你会疯的。
  • 字段带单位:在注释里写清楚单位。是米还是毫米?是弧度还是度?我吃过一次亏,两个模块一个用度一个用弧度,机器人转圈转得跟抽风一样。
  • 版本兼容:消息定义一旦发布,尽量别删字段。可以新增,但别改类型或删旧字段。否则旧bag文件回放时会崩溃。
架构建议: 把常用的消息定义放在一个独立的 common_msgs 包中,其他功能包都依赖它。这样消息定义统一管理,避免每个包都定义一套“轮子”。

最后,咱们用一张图来总结本章的知识体系:

自定义ROS消息与服务知识体系 msg文件定义 srv文件定义 编译配置(CMakeLists + package.xml) C++ / Python 代码调用 命令行工具(rossrv / rosmsg) 最佳实践:单一职责 / 版本兼容 / 带单位 核心流程:定义文件 → 编译配置 → 生成代码 → 业务调用

这张图把整个流程串起来了。从定义msg/srv开始,到配置编译,再到代码调用和命令行验证,最后用最佳实践收尾。你想想看,是不是每一步都有章可循?

验证小技巧: 编译成功后,用 rosmsg show your_package/MotorStatus 和 rossrv show your_package/SetTarget 检查定义是否正确。如果命令行能打印出字段,说明编译和依赖都对了。