ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ROS自定义消息实战:从定义、编译到发布订阅全流程解析

ROS自定义消息实战:从定义、编译到发布订阅全流程解析 1. 先从“通信”说起为什么话题消息是ROS的命根子接触ROS一段时间后你会发现几乎所有机器人功能都绕不开“节点之间怎么说话”这个问题。激光雷达扫描到的数据要发给导航模块相机图像要传给识别算法底盘状态要反馈给上层决策——这些数据在ROS里统一靠“话题Topic”传输而话题上跑的数据格式就是标题里说的“消息Message”。很多人一开始只用现成的消息类型比如std_msgs/String、sensor_msgs/LaserScan、geometry_msgs/Twist用起来确实方便。但真到了做项目的时候你很快就会撞上一堵墙现成的消息装不下你的数据。我举个例子你想把一个目标物的编号、坐标、置信度、时间戳打包发出去给别的节点用翻遍标准消息也找不到合适的类型。这时候就必须自己定义消息。所以“话题消息的定义与使用”不是一道可做可不做的练习题它是从“跑通官方例程”走向“做自己的机器人应用”的分水岭。这篇笔记我按自己从零折腾过来的顺序整理先讲为什么要自定义消息再讲怎么定义、怎么编译、怎么用最后把我踩过的坑和排查方法一并列出来。不管是玩模拟器还是真机这套流程都一样。2. 动手之前先把消息的“底层逻辑”搞清楚2.1 用“快递包裹”理解消息结构如果拿快递来打比方话题就是一条物流线路消息就是这条线上跑的包裹。包裹里面不能乱塞东西得有个统一的装箱清单——这个清单就是消息定义文件。ROS里消息定义文件的扩展名是.msg每一行写一个字段字段类型加字段名。消息类型分两类基础类型int32、float64、string、bool这些跟C/Python里的基础类型差不多。复合类型消息里头还能嵌套另一些消息比如geometry_msgs/PoseStamped里嵌套了一个PosePose里头又包含Point和Quaternion。我第一次学的时候总把“消息文件路径”和“消息类型名”搞混。这里关键就一句话消息定义文件的名字就是消息类型名文件里写的字段决定了数据长什么样。比如你创建一个MyInfo.msg编译后你的消息类型就叫my_package/MyInfo包名加文件名别人在别的节点里import或#include这个类型就能用。2.2 ROS 1和ROS 2在消息定义上的差异要提前知道网上大部分教程还在讲ROS 1比如Melodic、Noetic但现在已经有不少人直接上ROS 2Humble、Foxy。两者的消息定义思路一致但细节差别很明显对比项ROS 1ROS 2消息文件后缀.msg.msg定义字段语法字段类型 字段名相同生成代码的构建系统catkinament_cmake/ament_pythonCMakeLists修改位置find_package(catkin REQUIRED COMPONENTS ...)add_message_filesgenerate_messagesfind_package(rosidl_default_generators REQUIRED)rosidl_generate_interfacesPython中使用方式from 包名.msg import 消息名from 包名.msg import 消息名接口包独立C中使用方式#include 包名/消息名.h#include 包名/msg/消息名.hpp我这篇笔记以ROS 1为主因为大部分初学者还是从ROS 1入门的而且ROS 1的自定义消息流程更直观理解之后切到ROS 2会很快。最后我单独列一小节说ROS 2怎么改。3. 完整实操从零定义一个“目标信息”消息3.1 定义消息文件内容看起来简单坑全在细节我先说场景假设我要做一个目标检测节点检测到目标后就发布一个消息里面包含目标ID、目标名称、横向坐标、纵向坐标、置信度、检测时间。标准消息真的很难一次满足所以就自己定义一个。先在功能包里创建msg目录然后在里面新建文件。包名假设叫my_robot_msgs消息名叫TargetInfo.msg那么完整路径是my_robot_msgs/msg/TargetInfo.msg文件内容int32 target_id string target_name float64 x float64 y float64 confidence time timestamp这里特别注意几点字段名建议全小写加下划线符合ROS命名规范。time类型在ROS 1里很常用表示一个时间戳由secs和nsecs两块合成。你直接在Python里给timestamp赋成rospy.Time.now()就行C里赋值用ros::Time::now()。如果你需要发送数组写成int32[] data或string[] names方括号紧跟在类型后面中间不要留空格。不要在消息文件里随意加注释之外的内容。#开头是注释可以留着解释每个字段的含义对接手你代码的人非常友好。我习惯每个字段后面都写一句用途比如# 目标唯一ID int32 target_id这么写的好处是生成的消息代码可读性会好很多而且团队协作时减少沟通成本。3.2 修改package.xml让ROS找到你的依赖接下来打开功能包里的package.xml。重点是加上消息生成相关的依赖。打开文件后在buildtool_depend那一段附近加上buildtool_dependcatkin/buildtool_depend build_dependmessage_generation/build_depend exec_dependmessage_runtime/exec_depend如果你消息里用了std_msgs或geometry_msgs那还要把对应的依赖也加进去build_dependstd_msgs/build_depend exec_dependstd_msgs/exec_depend build_dependgeometry_msgs/build_depend exec_dependgeometry_msgs/exec_depend很多教程会漏掉message_runtime。编译时可能没问题但运行时Python脚本会报找不到模块、C程序会报找不到头文件。我遇到过一次卡了快一个小时最后发现就是少了这一行。3.3 修改CMakeLists.txt三步配置一个不能少CMakeLists.txt的修改是自定义消息的关键也是最容易出错的地方。按顺序来不用怕第一步在find_package里把需要的东西加进去find_package(catkin REQUIRED COMPONENTS roscpp rospy std_msgs message_generation )注意message_generation必须加不然下面的add_message_files和generate_messages都找不到。如果你消息引用了geometry_msgs这里也要加geometry_msgs。第二步找到add_message_files这一段把注释去掉并改成add_message_files( FILES TargetInfo.msg )一个FILES后面可以列多个.msg文件像这样add_message_files( FILES TargetInfo.msg AnotherInfo.msg )不过我还是建议一个消息放一个文件不要贪多。第三步找到generate_messages这一段改成generate_messages( DEPENDENCIES std_msgs )DEPENDENCIES里要写你消息中用到的其他消息包。如果你消息里用到了geometry_msgs/Pose那这里必须写geometry_msgs。如果你消息里只用基础类型比如int32、float64、string那就只写std_msgs甚至std_msgs也可以不写我建议还是写上因为std_msgs是最基础的依赖写上稳妥。改完这三步后回到工作空间根目录执行catkin_make或者如果你用catkin toolscatkin build编译成功后你会看到Python和C的生成代码已经自动出现了。Python的生成模块一般在devel/lib/python3/dist-packages/你的包名/msg/目录下C生成的头文件则在devel/include/你的包名/目录下。3.4 验证消息是否生成成功这一步很多人跳过我建议别跳。编译后在终端里输入rosmsg show my_robot_msgs/TargetInfo如果能看到类似这样的输出说明消息定义成功了int32 target_id string target_name float64 x float64 y float64 confidence time timestamp看到这个输出再往下走心里就有底了。以后你定义完消息先用rosmsg show确认不要直接去写一堆发布订阅代码不然出了错不知道是消息问题还是逻辑问题。4. 写个节点试试发布端和订阅端都不能少4.1 用Python写一个发布节点自定义消息最终要落实到代码里用。Python这边代码很简洁但有几个细节新手容易踩。先建一个发布节点文件talker.py#!/usr/bin/env python # -*- coding: utf-8 -*- import rospy from my_robot_msgs.msg import TargetInfo def talker(): rospy.init_node(target_info_talker, anonymousTrue) pub rospy.Publisher(target_info, TargetInfo, queue_size10) rate rospy.Rate(1) # 1 Hz target_id 0 while not rospy.is_shutdown(): msg TargetInfo() msg.target_id target_id msg.target_name person_ str(target_id) msg.x 1.0 * target_id msg.y 2.0 * target_id msg.confidence 0.9 msg.timestamp rospy.Time.now() rospy.loginfo(Publish target: id%d, name%s, x%.2f, y%.2f, conf%.2f, msg.target_id, msg.target_name, msg.x, msg.y, msg.confidence) pub.publish(msg) target_id 1 rate.sleep() if __name__ __main__: try: talker() except rospy.ROSInterruptException: pass这里重点说三个容易错的地方第一导入路径。from my_robot_msgs.msg import TargetInfo包名加.msg文件名就是消息名。如果拼错了Python会提示找不到模块。这里尤其注意如果功能包名带下划线比如my_robot_msgs导入时也要写下划线。第二msg.timestamp rospy.Time.now()。有时候你可能会想直接赋一个float时间比如time.time()这样会报类型错误。ROS里的time不是float必须用rospy.Time类型。第三queue_size。ROS 1的Python发布者里queue_size不写会有控制台警告虽然也能跑但最好养成写的习惯。4.2 用Python写一个订阅节点订阅端代码#!/usr/bin/env python # -*- coding: utf-8 -*- import rospy from my_robot_msgs.msg import TargetInfo def callback(msg): rospy.loginfo(Received target: id%d, name%s, x%.2f, y%.2f, conf%.2f, msg.target_id, msg.target_name, msg.x, msg.y, msg.confidence) def listener(): rospy.init_node(target_info_listener, anonymousTrue) rospy.Subscriber(target_info, TargetInfo, callback) rospy.spin() if __name__ __main__: listener()写完这两个文件后记得chmod x talker.py listener.py然后回到CMakeLists.txt找到catkin_install_python这一段把两个脚本加进去catkin_install_python(PROGRAMS scripts/talker.py scripts/listener.py DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION} )这一步不是必须的但建议做。加了你才能用rosrun正常启动两个节点不然可能报找不到可执行文件。如果你有setup.bash没刷新的问题很可能也会在这里卡住。启动方式很简单开三个终端# 终端1 roscore # 终端2先刷新环境 source devel/setup.bash rosrun my_robot_msgs talker.py # 终端3先刷新环境 source devel/setup.bash rosrun my_robot_msgs listener.py终端3如果能持续打印出目标信息说明整个链路已经通了。4.3 用C写一个发布与订阅示例C这边定义方式一样只是代码风格不同。发布端代码#include ros/ros.h #include my_robot_msgs/TargetInfo.h int main(int argc, char **argv) { ros::init(argc, argv, target_info_talker_cpp); ros::NodeHandle nh; ros::Publisher pub nh.advertisemy_robot_msgs::TargetInfo(target_info, 10); ros::Rate rate(1); int target_id 0; while (ros::ok()) { my_robot_msgs::TargetInfo msg; msg.target_id target_id; msg.target_name person_ std::to_string(target_id); msg.x 1.0 * target_id; msg.y 2.0 * target_id; msg.confidence 0.9; msg.timestamp ros::Time::now(); ROS_INFO(Publish target: id%d, name%s, x%.2f, y%.2f, conf%.2f, msg.target_id, msg.target_name.c_str(), msg.x, msg.y, msg.confidence); pub.publish(msg); target_id; rate.sleep(); } return 0; }C里有一个地方特别容易出错ros::Publisher pub nh.advertisemy_robot_msgs::TargetInfo(target_info, 10);。这里的模板参数必须是消息类型如果只写my_robot_msgs::TargetInfo没问题但是如果你写错成my_robot_msgs::target_info之类的编译直接报错。订阅端代码#include ros/ros.h #include my_robot_msgs/TargetInfo.h void callback(const my_robot_msgs::TargetInfo::ConstPtr msg) { ROS_INFO(Received target: id%d, name%s, x%.2f, y%.2f, conf%.2f, msg-target_id, msg-target_name.c_str(), msg-x, msg-y, msg-confidence); } int main(int argc, char **argv) { ros::init(argc, argv, target_info_listener_cpp); ros::NodeHandle nh; ros::Subscriber sub nh.subscribe(target_info, 10, callback); ros::spin(); return 0; }C编译配置需要在CMakeLists.txt里添加add_executable(talker_cpp src/talker.cpp) target_link_libraries(talker_cpp ${catkin_LIBRARIES}) add_dependencies(talker_cpp ${catkin_EXPORTED_TARGETS} ${PROJECT_NAME}_generate_messages_cpp) add_executable(listener_cpp src/listener.cpp) target_link_libraries(listener_cpp ${catkin_LIBRARIES}) add_dependencies(listener_cpp ${catkin_EXPORTED_TARGETS} ${PROJECT_NAME}_generate_messages_cpp)注意add_dependencies这行非常重要。如果你忘了加有时候编译后运行时会报找不到消息头文件。${PROJECT_NAME}_generate_messages_cpp的意思是先确保消息的C代码生成了再来编译你的可执行文件。4.4 用rostopic命令行工具来调试写完发布订阅后总会碰到“看起来没报错但数据就是没通”的情况。这时候命令行工具是你的救星。先看话题列表rostopic list正常会看到/rosout /rosout_agg /target_info如果你运行了发布节点但没看到/target_info说明发布节点没起来或者话题名拼错了。看话题类型和内容rostopic echo /target_info rostopic type /target_info rostopic hz /target_inforostopic hz能告诉你话题发布的频率。如果频率是0说明发布节点有问题如果频率有但订阅节点打印不出来那问题出在订阅端。还可以直接命令行发布一个测试消息rostopic pub /target_info my_robot_msgs/TargetInfo target_id: 1 target_name: test x: 1.0 y: 2.0 confidence: 0.8 timestamp: {secs: 0, nsecs: 0}这条命令比较长但能快速验证订阅端是否正常。注意这里的timestamp要用花括号包起来因为它是内嵌的time类型。如果格式不对会一直提示你要按自动补全来填。5. 消息嵌套让自定义消息像搭积木一样组合5.1 在自定义消息里引用另一个自定义消息单一消息定义简单但实际项目中很少只用一个消息类型。比如你得发布一个“目标列表”里面包含多个TargetInfo。这时候有两个选择用数组TargetInfo[] targets定义一个新消息类型里面嵌套TargetInfo在ROS 1中直接在.msg文件里写另一个消息类型作为字段就行。比如新建一个TargetArray.msgTargetInfo[] targets int32 count前提是TargetInfo和TargetArray在同一个包中。编译时ROS会先处理TargetInfo再处理TargetArray自动解决依赖顺序。如果引用的消息在别的包里比如你要引用geometry_msgs/Pose写法是geometry_msgs/Pose pose同时需要在CMakeLists.txt的generate_messages里加上generate_messages( DEPENDENCIES std_msgs geometry_msgs )不然编译时ROS不知道去哪找geometry_msgs的定义。5.2 什么时候该拆分消息什么时候该合并这是一个经验问题。我见过不少人图省事把所有字段堆在一个大消息里几十个字段后面维护起来特别头疼。我自己后来总结出几个原则按“语义边界”拆分。TargetInfo只管一个目标的信息TargetArray只管一组目标的集合两者各司其职。别把机器人底盘状态、传感器数据也塞进去。按“发布频率”拆分。高频发布的消息字段要尽量精简降低序列化和网络传输开销。低频的配置类消息可以带更多信息。比如目标检测结果10Hz发布你没必要把一整张原始图像都塞在同一个消息里图像走图像话题检测结果走结果话题。按“订阅者需求”拆分。每个订阅者关心什么数据就为它设计对应的话题和消息。如果三个订阅者关心的数据完全不同全部挤在一个消息里会强迫每个订阅者接收一堆无用数据。这些原则不绝对对新手来说也不用一步到位先能跑通再慢慢优化。5.3 一个常见错误消息字段用错了范围我在网上帮人看过好几次代码出现这种情况发布端给msg.confidence赋值成0~100的整数订阅端却当成0~1的浮点数用结果阈值判断老不对。这并不是代码“跑不通”而是“数据语义不一致”。自定义消息的字段名和单位定义最好在msg文件里用注释写清楚。比如# 目标置信度取值范围0.0~1.0 float64 confidence这样发布端和订阅端的开发者看到定义时都能遵守同样的约定。团队协作里.msg文件就是一份“口头协议”的文字化版本。6. 高频踩坑现场我遇到过的那些问题6.1rosmsg show能显示但Python导入失败这种情况一般是环境变量没刷新。别忘了每次编译完自定义消息后新生成的消息代码在devel目录下如果你不在工作空间根目录执行source devel/setup.bashPython导入就会失败。解决办法source devel/setup.bash如果你开了多个终端每个终端都要执行一次。我建议直接把这句话加到~/.bashrc里echo source ~/你的工作空间名/devel/setup.bash ~/.bashrc source ~/.bashrc以后打开终端就不用每次手动敲了。不过注意如果你同时有多个工作空间source顺序会影响ROS_PACKAGE_PATH建议只把最常用的写进~/.bashrc。6.2 编译报错找不到消息头文件或生成代码报错信息大概长这样fatal error: my_robot_msgs/TargetInfo.h: No such file or directory大部分原因是add_dependencies没加。在CMakeLists.txt里你的可执行文件必须加上add_dependencies(talker_cpp ${catkin_EXPORTED_TARGETS} ${PROJECT_NAME}_generate_messages_cpp)还有一种情况是消息源文件有语法错误比如字段类型写错、多个空格、末尾多了分号。.msg文件不像C那样以分号结尾千万别随手加分号。检查一下定义文件保持简单。6.3 运行rosrun提示找不到可执行文件报错rosrun my_robot_msgs talker.py [rosrun] Couldnt find executable named talker.py below /home/...原因有几种脚本没有chmod xCMakeLists.txt里没有配置catkin_install_pythonPython脚本第一行没有#!/usr/bin/env python忘了source devel/setup.bash把这些都检查一遍就好。我习惯在写完脚本后第一时间就chmod x省得后面忘。6.4 话题通了但频率很奇怪你用rostopic hz /target_info看到频率忽高忽低比如设置的是1Hz结果有时候0.5Hz有时候3Hz。常见原因是rospy.Rate(1)放在循环外没错但循环里做了耗时操作比如调用了time.sleep()或者处理了大型数据导致睡眠时间不准。另一个原因是发布缓存queue_size设得太大会占用内存设成0在ROS 1的Python里表示无限缓存有时候会造成延迟堆积。查看频率时注意rostopic hz在全车通信负载高的时候也会误报先排除自身节点问题再怀疑工具。6.5 C字符串字段的坑C订阅消息时字段是std::string还是const char*很多人第一次会搞混。消息生成代码里string字段的类型是std::string。所以在打印时需要用.c_str()转换比如ROS_INFO(%s, msg-target_name.c_str());如果不转换printf系列函数会把它当const char*处理轻则打印乱码重则程序崩溃。这个问题属于“一看就懂但没踩过就想不到”的类型。7. 进阶消息定义中还值得注意的三个细节7.1 用Header为消息加上时间戳和坐标系信息标准消息里经常看到Header header字段。std_msgs/Header包含三个子字段uint32 seq time stamp string frame_idseq是消息序号自动递增stamp是时间戳frame_id是坐标系名称。加上Header的好处是消息自带时间信息订阅端能判断数据新旧配合TF和可视化工具时frame_id能告诉系统数据在哪个坐标系下自定义消息时我建议第一行就写std_msgs/Header header然后编译再在代码里对msg.header.stamp和msg.header.frame_id赋值。这样你的消息从出生就兼容ROS的很多工具链比如rviz显示时能正确识别坐标系。7.2 用数组字段表示批量数据如果你要发一组坐标点可以这样定义float64[] x_coords float64[] y_coordsPython里赋值很简单msg.x_coords [1.0, 2.0, 3.0] msg.y_coords [4.0, 5.0, 6.0]C里则用std::vectormsg.x_coords {1.0, 2.0, 3.0}; msg.y_coords {4.0, 5.0, 6.0};数组字段非常实用。比如激光雷达点云数据本质上就是一堆float32数组拼出来的。自定义消息时不要怕用数组它比挨个字段罗列简洁得多。不过要注意消息里嵌套的数组字段在跨语言传输时会经过序列化和反序列化。ROS内部已经优化过但如果你在低性能板子上比如树莓派高频发布大数据仍然要留意会不会因为频繁序列化导致CPU占用过高。7.3 常量字段给消息加一个“协议标记”.msg文件里还可以定义常量例如int32 STATUS_OK0 int32 STATUS_ERROR1 int32 status这样订阅端就能用msg.STATUS_OK和msg.STATUS_ERROR来比较而不是硬编码魔法数字。定义常量的格式是类型 名字值没有分号。常量的作用相当于给消息带上文档。多节点协作时需求和状态码变化频繁用常量能让代码语义清晰得多。8. 切到ROS 2该怎么做如果你用的是ROS 2其实思路完全一致只是构建配置不一样。以Humble为例在功能包目录里的CMakeLists.txt查找到相关段落并做对应修改find_package(rosidl_default_generators REQUIRED) find_package(std_msgs REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} msg/TargetInfo.msg DEPENDENCIES std_msgs )package.xml里则要加build_dependrosidl_default_generators/build_depend exec_dependrosidl_default_runtime/exec_depend member_of_grouprosidl_interface_packages/member_of_group运行编译用colcon build而非catkin_makecolcon build --packages-select my_robot_msgsROS 2里Python的导入方式仍然是from my_robot_msgs.msg import TargetInfoC头文件变成了.hpp后缀#include my_robot_msgs/msg/target_info.hpp注意ROS 2里C的消息类型命名空间也变成了小写加下划线的形式比如my_robot_msgs::msg::TargetInfo。如果你是从ROS 1入门已经学会自定义消息转到ROS 2大概半小时就能适应。核心本质没变定义接口、生成代码、发布订阅。9. 能“抄作业”的一套完整操作顺序最后我把完整流程按顺序列一遍照着做基本不会出错。创建工作空间并初始化若已有可跳过mkdir -p ~/catkin_ws/src cd ~/catkin_ws/src catkin_init_workspace cd ~/catkin_ws catkin_make source devel/setup.bash创建功能包cd ~/catkin_ws/src catkin_create_pkg my_robot_msgs roscpp rospy std_msgs message_generation在包内创建msg目录和TargetInfo.msg文件填入字段定义。修改package.xml添加message_generation和message_runtime依赖。修改CMakeLists.txt的三处find_package、add_message_files、generate_messages。回到工作空间根目录编译cd ~/catkin_ws catkin_make source devel/setup.bash验证rosmsg show my_robot_msgs/TargetInfo编写并运行发布订阅节点用rostopic echo观察数据。这套流程我反复用过很多次换包名换消息名就能适配新项目。就算以后换到ROS 2也只是把编译命令从catkin_make换成colcon build微调CMakeLists.txt其余步骤几乎一样。自定义消息是ROS学习路上的一个“分水岭”。过了这一关你就不再只是个调用现成接口的人而是开始设计自己系统的人。日常杂活里还有一个特别实用的小技巧如果只是临时调试用别急着专门建消息可以用rosmsg在命令行定义一个临时消息类型或者干脆用std_msgs/String塞JSON字符串。定义正式消息是在接口真正稳定后做的事情——但一旦决定做就把字段注释、依赖关系和构建配置一次写到位后面不知道能省多少事。
返回列表