简介:面向ROS2初学者与机器人开发者的消息通信实践资料,聚焦节点间数据共享这一核心机制,帮助读者跨越从内置消息到自定义消息的门槛。内容围绕创建消息包、编写.msg定义、生成C++与Python接口、安装消息包,以及发布者与订阅者节点的编写与联调展开,覆盖自定义消息从定义到实际收发的完整链路。压缩包共645个文件,约3.43MB,以cmake、make构建脚本和h、hpp、cpp、c源码为主,辅以msg消息定义、py脚本、sh与bash环境配置及log、o、so等编译产物,完整保留了可复现的构建与运行痕迹。已有472人学习,适合对照目录结构理解工作空间布局、排查编译与导入问题,并在此基础上扩展QoS设置与并发处理等进阶话题。
1. 从一条编译不过的ros2 msg说起:自定义消息到底卡在哪
刚接触 ROS2 的人,十有八九会在自定义消息这一步翻车。现象很统一:ros2 interface show能看到消息定义,colcon build却报找不到头文件;或者 Python 节点里from xxx.msg import Yyy直接 ImportError。问题不在你手笨,而在 ROS2 把「消息定义」「代码生成」「包依赖」三件事拆得比 ROS1 更细,任何一环没接上,整条链就断。这篇笔记围绕一个最小可复现的自定义消息工程展开:从建包、写.msg、配CMakeLists.txt和package.xml,到 C++ 与 Python 双端收发,再到排查那些不报错但收不到数据的玄学问题。适合已经跑通过 talker/listener 官方例程、准备把自定义数据结构塞进节点通信的从业者,也适合带课程设计、需要一套能直接抄的工程骨架的人。RSO2 这类拼写错误在检索里很常见,实际指的就是 ROS2,下面统一按 ROS2 讲。
2. 消息定义与工程骨架:三个包怎么切、.msg怎么写
自定义消息在 ROS2 里不是一个文件的事,而是一个独立的接口包。很多人图省事,把.msg塞进业务包里,结果业务包既当接口又当节点,编译顺序和依赖立刻乱套。常见做法是拆成三个包:接口包只放.msg和生成配置,发布者包、订阅者包各自依赖接口包。这样接口包可以被任意节点复用,也不会因为业务代码改动触发全量重编。
2.1 为什么接口包必须独立,以及目录怎么摆
ROS2 的构建系统在编译期会为每个.msg生成对应语言的代码(C++ 是头文件,Python 是模块),生成动作发生在接口包自己的构建阶段。如果.msg和节点代码在同一个包里,colcon可能先编译节点、后生成消息代码,于是头文件找不到。独立接口包等于给构建系统一个明确的先后顺序:先建接口,再建依赖它的包。
一个能直接用的目录结构如下,工作空间叫ws_msg_demo:
ws_msg_demo/ └── src/ ├── demo_interfaces/ # 接口包,只放消息定义 │ ├── msg/ │ │ ├── RobotStatus.msg │ │ └── SensorReading.msg │ ├── CMakeLists.txt │ └── package.xml ├── demo_publisher/ # 发布者包 └── demo_subscriber/ # 订阅者包建包命令要带对构建类型。接口包用ament_cmake,因为消息生成依赖 CMake 宏;纯 Python 节点包可以用ament_python,但一旦它要依赖自定义消息,依赖声明照样得写全。
cd ws_msg_demo/src ros2 pkg create --build-type ament_cmake demo_interfaces ros2 pkg create --build-type ament_cmake demo_publisher --dependencies rclcpp demo_interfaces ros2 pkg create --build-type ament_python demo_subscriber --dependencies rclpy demo_interfaces这里--dependencies会把依赖自动写进package.xml,省掉手写漏项。注意接口包创建时不要加--dependencies,它自己就是被依赖方。
2.2.msg字段语法与两个示例消息
.msg语法很朴素:每行一个字段,格式是「类型 字段名」,#后面是注释。基础类型有bool、int8/16/32/64、uint8/16/32/64、float32/64、string、byte、char,复合类型可以嵌套其他.msg,数组用类型[](变长)或类型[N](定长)。定长数组在序列化时省一个长度字段,高频小消息可以用,但改长度就得改接口,慎重。
RobotStatus.msg放业务状态,演示嵌套和数组:
# 机器人运行状态 string robot_id uint8 status # 0 空闲 1 运行 2 故障 float32 battery_pct float64[3] pose_xyz # 定长数组,位置坐标 SensorReading[] sensors # 嵌套另一个消息,变长数组SensorReading.msg是被嵌套的那个:
string sensor_name float64 value uint32 timestamp_ms字段命名用小写下划线,这是 ROS2 社区惯例,生成 C++ 成员时也是这个风格,混用驼峰会让你在两种语言间来回切换时反复看错。uint8 status这种枚举语义字段,建议在注释里把取值写全,否则半年后自己都忘了 2 代表什么。
2.3CMakeLists.txt与package.xml的关键几行
接口包的CMakeLists.txt是整件事的核心,漏一行就白干。最小可用版本:
cmake_minimum_required(VERSION 3.8) project(demo_interfaces) find_package(ament_cmake REQUIRED) find_package(rosidl_default_generators REQUIRED) # 声明要生成代码的消息文件,多个文件用空格分隔 rosidl_generate_interfaces(${PROJECT_NAME} "msg/RobotStatus.msg" "msg/SensorReading.msg" ) ament_export_dependencies(rosidl_default_runtime) ament_package()rosidl_generate_interfaces是生成入口,路径必须相对包根目录写全。ament_export_dependencies(rosidl_default_runtime)保证下游包在运行时能拿到消息的动态库,漏了它,编译能过、运行时报找不到类型支持。
package.xml对应要写三处:
<buildtool_depend>ament_cmake</buildtool_depend> <buildtool_depend>rosidl_default_generators</buildtool_depend> <exec_depend>rosidl_default_runtime</exec_depend> <member_of_group>rosidl_interface_packages</member_of_group>member_of_group这一行最容易被忽略,它告诉colcon这个包属于接口包组,构建顺序会据此排序。少了它,多包并行编译时偶发「消息还没生成就被引用」,而且不是每次都复现,属于典型血泪坑。
2.4 编译与验证:先确认接口真的生成了
写完配置先单独编接口包,别急着编全部:
cd ws_msg_demo colcon build --packages-select demo_interfaces source install/setup.bash ros2 interface show demo_interfaces/msg/RobotStatus--packages-select只编指定包,出错信息干净。ros2 interface show能打印出完整字段结构,说明生成成功。如果这里就报错,问题一定在接口包内部,跟节点无关,排查范围立刻缩小。再补一条ros2 interface list | grep demo_interfaces,确认包已注册进环境。这两步过了,再往下写节点,能省掉一大半「到底是消息问题还是节点问题」的扯皮。
3. C++ 与 Python 双端收发:从生成代码到跑通话题
接口包编过只是上半场,真正容易翻车的是节点端。C++ 和 Python 的引入方式、依赖声明、QoS 设置都不一样,分开讲清楚,再给一套能对照验证的跑法。
3.1 C++ 发布者:头文件路径与依赖声明
C++ 端引入生成的头文件,路径规则是包名/msg/消息名.hpp,注意是小写.hpp:
#include <chrono> #include <memory> #include "rclcpp/rclcpp.hpp" #include "demo_interfaces/msg/robot_status.hpp" using namespace std::chrono_literals; class StatusPublisher : public rclcpp::Node { public: StatusPublisher() : Node("status_publisher") { // 队列深度 10,消息积压超过 10 条丢最旧的 pub_ = create_publisher<demo_interfaces::msg::RobotStatus>("robot_status", 10); timer_ = create_wall_timer(500ms, [this]() { publish(); }); } private: void publish() { auto msg = demo_interfaces::msg::RobotStatus(); msg.robot_id = "robot_01"; msg.status = 1; msg.battery_pct = 87.5f; msg.pose_xyz = {1.0, 2.0, 0.0}; // 定长数组可直接初始化列表赋值 demo_interfaces::msg::SensorReading s; s.sensor_name = "temp"; s.value = 36.2; s.timestamp_ms = 1000; msg.sensors.push_back(s); // 变长数组用 push_back pub_->publish(msg); } rclcpp::Publisher<demo_interfaces::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; }定长数组pose_xyz是std::array,可以直接花括号赋值;变长数组sensors是std::vector,用push_back。这两种容器行为不同,混用会编译报错,但报错信息往往指向模板深处,不好读,记住这个区别能省时间。
对应CMakeLists.txt要加可执行目标和依赖:
find_package(rclcpp REQUIRED) find_package(demo_interfaces REQUIRED) add_executable(status_publisher src/status_publisher.cpp) ament_target_dependencies(status_publisher rclcpp demo_interfaces) install(TARGETS status_publisher DESTINATION lib/${PROJECT_NAME})ament_target_dependencies里必须同时列rclcpp和demo_interfaces,只列前者会在链接阶段报未定义符号。install那行漏了,ros2 run会提示找不到可执行文件,这是新手最常见的「编过了但跑不起来」。
3.2 Python 订阅者:模块导入与回调
Python 端导入路径是包名.msg,消息类名保持原样:
import rclpy from rclpy.node import Node from demo_interfaces.msg import RobotStatus class StatusSubscriber(Node): def __init__(self): super().__init__('status_subscriber') # 队列深度 10,与发布端保持一致更稳 self.sub = self.create_subscription( RobotStatus, 'robot_status', self.callback, 10) def callback(self, msg): names = [s.sensor_name for s in msg.sensors] self.get_logger().info( f'{msg.robot_id} status={msg.status} ' f'battery={msg.battery_pct:.1f} sensors={names}') def main(args=None): rclpy.init(args=args) node = StatusSubscriber() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()create_subscription的第三个参数是回调,第四个是队列深度。回调里遍历msg.sensors得到的是生成的消息对象列表,字段访问方式和.msg定义一一对应。try/finally包住spin是为了 Ctrl+C 时也能正常销毁节点,否则偶尔会留下僵尸进程占着话题名。
package.xml里要有<exec_depend>demo_interfaces</exec_depend>,setup.py的entry_points里注册可执行入口:
entry_points={ 'console_scripts': [ 'status_subscriber = demo_subscriber.status_subscriber:main', ], },模块路径写全到包名.文件名:函数名,少一段就ros2 run找不到。
3.3 联调验证:三条命令确认链路通
编完全部包后,开三个终端,各自source install/setup.bash:
# 终端 1:看话题是否存在、类型对不对 ros2 topic list | grep robot_status ros2 topic info /robot_status # 终端 2:直接抓原始消息,不依赖自己写的订阅者 ros2 topic echo /robot_status # 终端 3:跑发布者 ros2 run demo_publisher status_publisherros2 topic info会打印消息类型和发布/订阅计数,类型显示为demo_interfaces/msg/RobotStatus才算对。ros2 topic echo是最可靠的中间验证:如果 echo 有数据而你的订阅者没反应,问题在订阅者;如果 echo 也没数据,问题在发布者或话题名。这条分界线能砍掉大量盲目改代码的时间。话题名注意前导斜杠,robot_status和/robot_status在多数场景等价,但混着写容易在 remap 时出岔子,统一风格更省心。
4. 避坑与排查:五条真实翻车记录
4.1 编译过了,运行报找不到类型支持
现象:节点启动瞬间抛异常,提示某个消息类型的 type support 未注册。原因:接口包package.xml漏了ament_export_dependencies(rosidl_default_runtime)对应的运行时导出,或者下游包只声明了 build 依赖没声明 exec 依赖。解决:接口包补ament_export_dependencies,下游包package.xml同时写<build_depend>和<exec_depend>,改完清掉build/、install/重编,增量编译有时不刷新依赖图。
4.2 Python 端 ImportError,C++ 端却正常
现象:同一个接口包,C++ 节点能编,Python 节点from demo_interfaces.msg import ...直接失败。原因:Python 包没在package.xml里声明对接口包的运行依赖,或者setup.py的packages没包含子模块。解决:补<exec_depend>demo_interfaces</exec_depend>,确认setup.py里packages=find_packages(),然后重新colcon build并重新 source。只 build 不 source 等于没生效,这个顺序错误极高频。
4.3 消息发出去了,订阅端一条收不到
现象:ros2 topic echo有数据,自己写的订阅者回调不触发。原因:QoS 不匹配。发布端用了默认的 reliable,订阅端如果设成 best_effort,或者反过来在不可靠传输上要求 reliable,两边握手失败,数据静默丢弃,不报错。解决:先用ros2 topic info /话题名 --verbose看两端 QoS,把create_subscription的 QoS 配置调成与发布端一致,联调阶段统一用默认值最稳。
4.4 改了.msg字段,行为还是旧的
现象:给消息加了一个字段,重新编译,订阅端打印出来的结构没变,甚至字段错位。原因:build/和install/里残留旧生成代码,colcon增量构建没识别到.msg变更。解决:rm -rf build install log全量重编。改接口定义属于结构性变更,别指望增量,全量重编一次几十秒,比排查半天划算。
4.5 多包并行编译偶发失败,重试又好了
现象:colcon build全量编译时随机报接口包未就绪,单独编又正常。原因:接口包package.xml缺<member_of_group>rosidl_interface_packages</member_of_group>,构建顺序没被正确约束,并行时抢跑。解决:补上这一行,或者临时用colcon build --executor sequential串行编译验证。补配置是根治,串行只是确认手段。
5. 进阶:用ros2 interface和自定义 QoS 把接口当契约管
消息定义稳定之后,真正的工程问题变成「接口变更怎么管」。我一般把.msg当契约对待,改字段前先跑一遍ros2 interface show存档,改完对比差异,确认没有删字段、没有改类型、没有动定长数组长度——这三类变更都是破坏性的,会让已部署的节点直接失联。加字段相对安全,但接收端如果用了严格的反序列化,也可能出问题,所以联调环境永远先全量重编再验证。
一个实用的自检脚本,编完接口包后自动打印所有消息结构,存成快照:
#!/bin/bash # 用法:./snapshot_interfaces.sh > interfaces_$(date +%F).txt source install/setup.bash for pkg in $(ros2 interface list | grep demo_interfaces | cut -d/ -f1 | sort -u); do for msg in $(ros2 interface list | grep "^${pkg}/msg/"); do echo "=== ${msg} ===" ros2 interface show "${msg}" done doneros2 interface list输出带包名前缀,grep过滤出目标包,再逐个show。快照进版本库,接口变更在 diff 里一目了然,比靠记忆靠谱得多。这个习惯是从一次线上事故后养成的:当时有人给消息中间插了一个字段,C++ 端因为结构体布局变化读到了错位数据,日志里全是乱码数值,查了大半天才定位到接口。从那以后我每次动.msg都强制走一遍快照对比,再跑双端联调,确认ros2 topic echo和自定义订阅者输出一致才提交。
QoS 这块也值得单独配。高频传感器数据用best_effort加小队列,丢几帧无所谓,延迟优先;控制指令用reliable加适当深度,宁可重传也不能丢。配置写在代码里而不是靠默认值,换个人接手也能看懂意图。接口定义加 QoS 策略,基本就构成了一个节点间通信的完整契约,后面加节点、换语言、做录制回放,都围绕这份契约走,返工最少。希望这套骨架能帮到你,少在编译和导入上耗时间。
本文还有配套的精品资源,点击获取