简介:这份资源面向ROS2初学者与机器人开发入门者,围绕消息自定义与节点通信这一核心机制展开,帮助读者理解如何从零搭建自定义消息包并完成发布订阅的完整链路。压缩包共645个文件,约3.43MB,以cmake、make构建脚本,h/hpp头文件,cpp/c源码,以及msg消息定义、py脚本和各类setup.bash环境配置为主,完整保留了从建包、编译到安装运行后的工作空间产物,便于对照目录结构理解构建流程。已有472人学习下载,适合作为动手实践的参考样例。读者可从中获得消息包创建与.msg结构定义的实例、C++发布者与订阅者节点的代码骨架,以及colcon构建与安装后的真实产物,配合描述中的步骤可快速复现实验,并在此基础上扩展QoS、并发等进阶话题。
1. 从一次“话题接不上”的联调说起:ROS2 消息自定义到底解决什么
去年帮一个做移动机器人底盘的团队排查联调问题,现象很典型:导航节点发出来的位姿话题,控制节点订阅到了,但字段对不上,一个用x/y/theta,一个用pose.position.x,中间靠一个临时写的转换节点硬凑,延迟抖动还特别大。这类“话题能通、语义不通”的毛病,在 ROS2 项目里十有八九不是通信层的问题,而是消息类型没设计好。ROS2 简单的消息自定义以及使用,说的就是这件事:用.msg文件定义自己的数据结构,通过rosidl生成 C++/Python 绑定,再在发布者和订阅者里统一使用。它适合正在把 demo 往工程化推进的从业者——当你发现标准消息std_msgs、geometry_msgs不够表达业务语义时,就该动手了。下面按“定义→编译→收发→排错”的顺序拆,参数和坑都给到能直接抄的程度。
2. 消息定义与接口包搭建:从 .msg 文件到可编译的接口包
自定义消息的第一步不是写代码,而是建一个独立的接口包。很多人图省事把.msg塞进功能包里,短期能跑,后期一改消息全工程重编,血泪经验就是接口一定要单独成包。ROS2 的接口包本质是一个只含msg/、srv/、action/目录和CMakeLists.txt的普通 ament_cmake 包,编译产物是语言无关的中间表示,C++ 和 Python 节点都能链接。
2.1 接口包的目录结构与 package.xml 依赖
先建包。假设项目叫demo_msgs,只放消息,不放节点代码:
ros2 pkg create --build-type ament_cmake demo_msgs mkdir -p demo_msgs/msg目录长这样:
demo_msgs/ ├── CMakeLists.txt ├── package.xml └── msg/ └── RobotStatus.msgpackage.xml里必须声明rosidl_default_generators作为构建依赖,rosidl_default_runtime作为运行依赖,并在末尾加rosidl_interface_packages成员组,否则ros2 interface show找不到这个包:
<buildtool_depend>ament_cmake</buildtool_depend> <build_depend>rosidl_default_generators</build_depend> <exec_depend>rosidl_default_runtime</exec_depend> <member_of_group>rosidl_interface_packages</member_of_group>这里有个容易忽略的点:如果消息字段里引用了别的包的消息类型(比如geometry_msgs/Pose),还要在package.xml里加对应的<depend>,并在CMakeLists.txt的find_package里带上。漏了不会立刻报错,而是在生成阶段抛Could not find package,新手常在这里卡半天。
2.2 写一个带嵌套和数组的 .msg
消息语法很朴素,一行一个字段,格式是“类型 名称”,支持常量、数组、有界数组和嵌套。下面这个RobotStatus.msg覆盖了工程里最常用的几种形态:
# 机器人状态上报消息 std_msgs/Header header # 时间戳与坐标系 string robot_id # 设备唯一标识 float32 battery_voltage # 电池电压,单位 V float32[3] linear_velocity # 线速度 x/y/z,单位 m/s geometry_msgs/Pose current_pose # 当前位姿,嵌套标准消息 uint8 STATE_IDLE=0 # 常量:空闲 uint8 STATE_RUNNING=1 # 常量:运行 uint8 state # 当前状态,取值见上方常量几个语法边界要记牢:数组用类型[N]表示定长、类型[]表示变长;常量必须带初值且全大写;注释用#;字段名不能和 ROS2 保留字冲突(比如int、float这种)。定长数组在序列化时省一个长度前缀,高频小消息用它更划算,但一旦长度写死,后续扩展就得改消息定义,属于“省一时、痛一阵”的取舍。
2.3 CMakeLists.txt 里的 rosidl 生成配置
CMakeLists.txt是接口包的核心,写错一行就生成不出头文件:
cmake_minimum_required(VERSION 3.8) project(demo_msgs) 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/RobotStatus.msg" DEPENDENCIES std_msgs geometry_msgs ) ament_export_dependencies(rosidl_default_runtime) ament_package()rosidl_generate_interfaces的第一个参数必须是包名,后面跟所有.msg的相对路径;DEPENDENCIES里列的消息包要和find_package保持一致。编译后产物落在install/demo_msgs/下,C++ 头文件在include/demo_msgs/msg/robot_status.hpp,Python 模块在demo_msgs/msg/_robot_status.py,两边字段名会自动从下划线转驼峰,robot_id在 C++ 里是robot_id(),在 Python 里是.robot_id,这个差异后面写节点时会反复遇到。
3. C++ 与 Python 双端收发:发布订阅的最小可跑闭环
消息定义好只是有了“语言”,真正验证要靠一收一发。ROS2 的节点模型里,发布者和订阅者通过话题名和消息类型双重匹配,类型不一致时ros2 topic list能看到话题,但订阅端收不到数据,这是排查时第一个要看的点。下面用同一个RobotStatus分别写 C++ 和 Python 两端,方便对照字段访问方式。
3.1 C++ 发布者:构造消息与定时器驱动
C++ 节点用rclcpp,消息头文件由接口包生成。核心是构造消息、填字段、定时发布:
#include <chrono> #include <memory> #include "rclcpp/rclcpp.hpp" #include "demo_msgs/msg/robot_status.hpp" using namespace std::chrono_literals; class StatusPublisher : public rclcpp::Node { public: StatusPublisher() : Node("status_publisher") { pub_ = this->create_publisher<demo_msgs::msg::RobotStatus>("robot_status", 10); timer_ = this->create_wall_timer(100ms, [this]() { publish(); }); } private: void publish() { auto msg = demo_msgs::msg::RobotStatus(); msg.header.stamp = this->now(); // 填时间戳 msg.header.frame_id = "base_link"; // 填坐标系 msg.robot_id = "robot_01"; msg.battery_voltage = 24.5f; msg.linear_velocity = {0.2f, 0.0f, 0.0f}; // 定长数组直接初始化 msg.current_pose.position.x = 1.0; msg.state = demo_msgs::msg::RobotStatus::STATE_RUNNING; // 用常量而非魔法数字 pub_->publish(msg); } rclcpp::Publisher<demo_msgs::msg::RobotStatus>::SharedPtr pub_; rclcpp::TimerBase::SharedPtr timer_; }; int main(int argc, char** argv) { rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<StatusPublisher>()); rclcpp::shutdown(); return 0; }create_publisher的第二个参数是 QoS 队列深度,10 表示最多缓存 10 条待发消息,高频场景要调大,否则会丢帧。create_wall_timer用挂钟时间而非 ROS 时间,仿真环境里如果开了use_sim_time,要换成节点时钟,不然定时器节奏和仿真对不上。常量访问走demo_msgs::msg::RobotStatus::STATE_RUNNING,别直接写1,后面状态机扩展时改常量一处就够。
3.2 Python 订阅者:字段访问与回调
Python 端用rclpy,字段访问是属性式,嵌套消息直接点下去:
import rclpy from rclpy.node import Node from demo_msgs.msg import RobotStatus class StatusSubscriber(Node): def __init__(self): super().__init__('status_subscriber') self.sub = self.create_subscription( RobotStatus, 'robot_status', self.callback, 10) def callback(self, msg): # 定长数组在 Python 里是 list,可直接索引 vx = msg.linear_velocity[0] self.get_logger().info( f'id={msg.robot_id} state={msg.state} ' f'vx={vx:.2f} pose_x={msg.current_pose.position.x:.2f}') def main(): rclpy.init() rclpy.spin(StatusSubscriber()) rclpy.shutdown() if __name__ == '__main__': main()create_subscription的四个参数依次是消息类型、话题名、回调、队列深度,顺序写反是新手高频错误,类型对不上时 Python 会直接抛TypeError,比 C++ 的静默失败好排查。回调里不要做耗时操作,ROS2 默认单线程执行器,回调阻塞会拖慢整个节点,重活丢给独立线程或MultiThreadedExecutor。
3.3 编译、source 与验证命令
接口包和节点包分开编译,顺序不能乱:
colcon build --packages-select demo_msgs source install/setup.bash colcon build --packages-select demo_nodes source install/setup.bash先编接口包再编节点包,是因为节点包在find_package(demo_msgs)时要能找到已安装的接口。验证分三步:ros2 interface show demo_msgs/msg/RobotStatus看定义是否生成;ros2 topic list看话题是否出现;ros2 topic echo /robot_status看字段值是否符合预期。如果interface show报找不到包,八成是没 source 或者package.xml漏了成员组;如果echo有输出但订阅端没反应,先查两端消息类型是否一致,ros2 topic info /robot_status -v能看到发布订阅双方的完整类型名。
4. 避坑与排查:自定义消息最容易翻车的五个地方
自定义消息的坑集中在“编译能过、运行不对”这一类,下面五条是按出现频率排的,每条都给出定位手段。
4.1 现象:改了 .msg 但节点行为没变
原因通常是增量编译没触发接口重生成,或者节点包链接的是旧版接口。解决:先colcon build --packages-select demo_msgs --cmake-clean-cache强制重生成,再重编节点包;确认install/下头文件时间戳已更新。养成改消息必清接口包缓存的习惯,能省掉大量“玄学”排查。
4.2 现象:C++ 编译报找不到头文件
原因多是package.xml里漏了<depend>demo_msgs</depend>,或节点包CMakeLists.txt没写find_package(demo_msgs REQUIRED)和ament_target_dependencies。解决:两处都补上,target_link_libraries里也要带接口包。注意ament_target_dependencies和target_link_libraries别混用同一目标,二选一即可。
4.3 现象:Python 导入报 ModuleNotFoundError
原因是接口包没 source,或 Python 节点在错误的虚拟环境里跑。解决:确认source install/setup.bash在当前终端生效,python3 -c "import demo_msgs"能过再启动节点;用虚拟环境时要把 ROS2 的 site-packages 路径带进去,别用系统 Python 和 venv 混跑。
4.4 现象:话题能 echo 到,订阅端收不到
原因通常是 QoS 不匹配,发布端用reliable、订阅端用best_effort(或反过来),或者队列深度太小导致丢包。解决:ros2 topic info /robot_status -v看两端 QoS,统一成reliable加合适深度;高频传感器数据才考虑best_effort,控制指令一律reliable。
4.5 现象:定长数组赋值编译失败
原因是 C++ 里定长数组是std::array,不能用push_back,初始化列表长度必须严格等于声明长度。解决:用msg.linear_velocity = {0.2f, 0.0f, 0.0f}一次性赋值,长度不符编译器会直接报错;需要动态长度就改.msg为变长数组float32[],别在代码里硬绕。
5. 进阶:用接口版本化与兼容性检查守住长期维护
消息定义一旦被多个节点依赖,改动就成了“后悔药”问题。我一般会在接口包里维护一个版本约定:新增字段一律追加在末尾并给默认值,删除字段先标记废弃、跨两个版本再删,改类型视为不兼容、必须升大版本。ROS2 本身不做消息版本校验,靠的是团队纪律加一道自动检查。下面这个脚本在 CI 里跑,对比当前.msg和基线文件的字段差异,不兼容就退出非零:
import sys def parse_msg(path): fields = {} with open(path, encoding='utf-8') as f: for line in f: line = line.split('#')[0].strip() if not line or '=' in line: continue parts = line.split() if len(parts) >= 2: fields[parts[1]] = parts[0] return fields base = parse_msg('baseline/RobotStatus.msg') curr = parse_msg('demo_msgs/msg/RobotStatus.msg') for name, typ in base.items(): if name not in curr: print(f'[BREAK] 字段被删除: {name}') sys.exit(1) if curr[name] != typ: print(f'[BREAK] 字段类型变更: {name} {typ} -> {curr[name]}') sys.exit(1) for name in curr: if name not in base: print(f'[OK] 新增字段: {name} ({curr[name]})') print('兼容性检查通过')脚本逻辑很直白:基线里有的字段,当前必须有且类型一致,否则判为破坏性变更;当前新增的字段只提示不拦截,因为追加字段对旧订阅者是安全的(旧端忽略未知字段)。参数上,baseline/目录存上一发布版本的.msg快照,每次发版前更新;CI 里把它放在colcon build之前跑,能在编译前就拦住不兼容改动。这套检查配合ros2 interface show做人工复核,基本能避免“改一个字段、全组重联调”的场面。
从那以后我每次动.msg文件,都强制先跑一遍兼容性脚本再提交,宁可多花两分钟,也不让下游节点替我的改动买单。希望帮到你。
本文还有配套的精品资源,点击获取