ARTICLE DETAIL

资讯详情

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

ROS自定义消息完全指南:从msg文件到发布订阅实战

ROS自定义消息完全指南:从msg文件到发布订阅实战 说实话绝大多数ROS新手第一次被Message这个词卡住都是在写完发布者和订阅者的入门示例之后。前面那几篇笔记里我们虽然已经用到了std_msgs::String但基本是复制粘贴式地传字符串并没有真正理解话题消息本身。可只要你稍微做点真实项目就会发现一个话题上要传的往往不是单个字符串而是一组结构化的数据名字、年龄、身高、性别、坐标、置信度、时间戳甚至坐标系id。把这些字段拆成几个Float64话题分别传节点一多话题列表就乱到没法看更麻烦的是还要对不同话题上的时间戳做对齐才能拼回一个完整对象这种方案基本是在给自己挖坑。正确做法就是自定义一种话题消息Message把所有字段打包成一个结构体让它在ROS的Topic上作为一个整体流动。这篇笔记就专门把消息的定义与使用讲透先理清消息在ROS通信里的定位再讲内置消息怎么查然后完整走一遍自定义消息的流程给出C和Python双端调用代码最后把我实际踩过的坑整理成排查链路。1. 为什么消息是话题通信的语言——从一次实际需求说起1.1 消息是什么话题上流动的数据契约用一句话概括消息就是ROS话题上流动的数据格式约定。它决定了发布者和订阅者双方约定好按什么结构说话。你可以把它理解成两个人之间说好的对话模板——比如双方约定了每句话都必须包含时间、地点、人物、事件四个要素那么无论谁开口另一方都能按这个结构去理解。消息的定义存放在.msg文件里文件里就是一行一行的字段声明。比如下面这行string name就表示这条消息里有一个叫name的字段类型是字符串。多个字段排在一起就组成了一条完整消息的结构。ROS在编译时会根据这个.msg文件自动生成C头文件和Python模块让你在代码里像使用普通的结构体或类一样使用这个消息。1.2 不直接传裸数据ROS消息类型体系到底解决了什么问题很多刚接触ROS的人会有一个疑问网络上传输数据直接发一串字节不就行了吗为什么要搞一套消息类型系统这个问题我在学习初期也困惑过后来实际操作了才发现这套体系至少解决了五个实际问题。第一个是跨语言通信。ROS节点可以用C写也可以用Python写双方需要能互相理解。消息系统在编译时生成了各语言对应的代码C端按C的结构体编码Python端按Python对象编码底层序列化格式完全一致两端才能顺利解析。第二个是类型校验。发布方和订阅方如果对不上数据类型轻则收不到数据重则解析错位、程序崩溃。有了一套消息类型话题在建立连接时会做类型匹配发布类型和订阅类型不一致就直接连不上把很多错误挡在了运行之前。第三个是可调试性。rosbag录制数据包、rqt_topic查看实时话题、RViz可视化点云和位姿这些工具能工作是因为它们能读取消息的类型定义知道每条数据每个字段的含义。如果只传裸字节工具根本无从下手。第四个是字段语义。消息里每个字段都有名字比如pose.position.x一看就知道是位姿中的X坐标数据可读性远超一串匿名数字。这对于调试和团队协作都至关重要。第五个是编译期检查。C端写错字段名编译阶段就会报错不用等到运行时才发现数据不对。这一点在写大规模机器人程序时体会特别深能在编译期暴露的问题都不是大问题。1.3 从msg文件到C头文件和Python模块编译器做了什么当你执行catkin_make的时候ROS的代码生成器会扫描功能包里所有的.msg文件然后分三步处理第一步解析.msg文件检查字段类型是否合法、是否有重名、引用的外部消息是否存在第二步根据解析结果生成C头文件放在devel/include/你的功能包/目录下第三步生成Python模块放在devel/lib/python3/dist-packages/你的功能包/msg/目录下所以你在代码里写#include learning_message/Person.h或者from learning_message.msg import Person时用的其实都是编译过程中自动生成的文件不需要你自己手动创建这些文件。理解这个生成流程对后面排查明明定义了消息但代码找不到的问题非常关键。2. 动手之前先摸清底细内置消息类型与三个高频命令2.1 我常用的四类标准消息ROS发行版里内置了几百种消息大部分常见需求其实已经被官方解决掉了。定义自己的消息之前先看看有没有现成的可以复用这既是效率问题也是代码风格问题。我平时最常用的是下面这四类包名常用消息类型典型用途std_msgsString, Int32, Float64, Bool, Header基础数据类型学习阶段最常用geometry_msgsPose, Twist, Point, Quaternion, Vector3机器人位姿、速度、空间点和朝向sensor_msgsImage, LaserScan, PointCloud2, JointState图像、激光雷达、点云、关节状态等传感器数据nav_msgsOdometry, Path, OccupancyGrid导航里程计、路径规划结果、栅格地图以geometry_msgs/Pose为例它内部是两个子消息Point类型的position和Quaternion类型的orientation。这种消息嵌套消息的结构很常见自定义消息时也完全可以引用这些官方消息作为字段类型。2.2 Header几乎所有复杂消息都少不了的公共头在自然语言处理类项目里一条消息往往要同时表达数据是什么、什么时候采集的、在哪个坐标系下采集的。这三个信息太常用了ROS就把它们抽成了一个公共结构体叫做Header定义在std_msgs包中。uint32 seq time stamp string frame_idseq是消息序号每次发布自动加1可以用来检测是否丢消息stamp是时间戳ROS1里表现为秒加纳秒的结构体用来标记数据采集时刻frame_id是坐标系名称比如map、base_link、camera_link表示这条数据是在哪个坐标系下观测到的。很多人在自定义消息时习惯把这三个字段也写在msg文件里这并没错。但更好的做法是直接放一个Header字段。这样下游节点拿到消息就能统一通过msg.header.stamp访问时间戳通过msg.header.frame_id拿到坐标系不会出现有人用msg.time、有人用msg.timestamp这种各写一套的混乱情况。2.3 rosmsg三条命令把消息看穿理解消息最直接的工具是rosmsg它专门用来查看消息结构。注意一点rosmsg读的是文件系统里已经生成或已经安装的定义所以没有启动roscore也能用。这一点能帮你排除不少初级困惑有些新手一跑rosmsg就以为必须先起roscore浪费时间。rosmsg show std_msgs/String查看任意消息的具体结构比如rosmsg show geometry_msgs/Poserosmsg list | grep 关键词列出系统里所有消息名用管道加grep过滤快速检索有没有满足需求的现成消息rosmsg package std_msgs查看某个包里的所有消息类型适合浏览一个包的功能范围自定义消息编译通过后同样用rosmsg show learning_message/Person验证。如果这个命令能看到你的消息结构说明编译和路径都没问题可以放心进入编写节点代码的环节。3. 自定义消息完整实操从Person.msg到编译通过3.1 什么时候值得自己定义消息官方消息虽然多但终究是通用设计。当你需要传递的业务数据带有明确业务语义时自定义消息往往是更好的选择。比如我做一个人员识别节点检测到一个人时下游需要拿到这五个信息名字、年龄、身高、性别以及这个人在空间中的位姿。这五个字段的集合就是一个人对象的完整描述没有任何官方消息能天然匹配这个结构。我自己判断该不该自定义的标准就三条字段超过两三个、字段之间有明确的组合语义、或者希望下游节点用一个变量就能拿到完整对象。如果只是传一个float64或者一个字符串直接用std_msgs就好搞自定义消息反而增加维护成本。3.2 编写Person.msg字段类型、常量与嵌套消息我以learning_message这个功能包为例走一遍完整流程。先进入功能包目录创建msg文件夹cd ~/catkin_ws/src/learning_message mkdir msg在msg目录下新建Person.msg文件内容如下Header header string name uint8 age float64 height uint8 UNKNOWN 0 uint8 MALE 1 uint8 FEMALE 2 uint8 sex geometry_msgs/Pose pose这个文件里包含了四类msg编写要素值得逐一说明。要素一是基本类型加字段名比如string name、uint8 age。ROS的msg支持int8到int64、uint8到uint64、float32、float64、string、bool、time、duration这些内置类型。int和float直接用不需要像部分语言那样区分大小写。要素二是常量定义比如uint8 MALE 1。常量定义的作用是给魔法数字起名字。sex字段是uint8类型但直接用0、1、2来赋值可读性很差定义成UNKNOWN、MALE、FEMALE之后代码里就能写msg.sex msg.MALE或者msg.sex Person.MALE一眼就能看懂含义。约定俗成的规范是常量名全大写避免和字段名混淆。要素三是数组写法。如果某个人有多项成绩可以写float64[] scores。ROS支持变长数组不需要预先指定长度发布时往里塞多少都行。要素四是嵌套其他包的消息比如geometry_msgs/Pose pose。引用时格式是包名/消息类型并且在CMakeLists.txt和package.xml里都要声明对geometry_msgs的依赖。这条常常被忽略漏掉了编译就会报找不到类型。3.3 CMakeLists.txt三处关键修改写完了msg文件接下来是很多初学者感到头疼的CMakeLists.txt配置。其实归纳起来就三处缺一不可。我用learning_message包的CMakeLists.txt来演示。第一处找到add_message_files这一段取消注释并改成add_message_files( FILES Person.msg )这里的作用是告诉编译系统本包需要编译哪些msg文件。如果以后新增了一个Score.msg就在FILES下面再加一行。第二处找到generate_messages这一段取消注释并声明依赖的消息包generate_messages( DEPENDENCIES std_msgs geometry_msgs )因为我的Person.msg里用到了std_msgs/Header和geometry_msgs/Pose所以这段必须列出这两个包。如果msg里只用了内置类型只需要依赖std_msgs甚至可以不写DEPENDENCIES。第三处找到catkin_package这一段在CATKIN_DEPENDS里添加消息相关依赖catkin_package( INCLUDE_DIRS include LIBRARIES learning_message CATKIN_DEPENDS geometry_msgs roscpp rospy std_msgs )注意这里的CATKIN_DEPENDS是给依赖本包的其他包看的。别的包include你的消息头文件时需要知道你的包依赖了哪些传递依赖。所以geometry_msgs和std_msgs必须写上否则下游编译会出现奇怪的间接依赖缺失问题。3.4 package.xml依赖声明两句不能漏掉的dependencyCMakeLists.txt负责的是编译逻辑package.xml负责的是构建工具和依赖解析两边都要配置。打开package.xml在build_depend和exec_depend这两个区域里需要保证有下面三对依赖build_dependmessage_generation/build_depend exec_dependmessage_runtime/exec_depend build_dependstd_msgs/build_depend exec_dependstd_msgs/exec_depend build_dependgeometry_msgs/build_depend exec_dependgeometry_msgs/exec_dependmessage_generation是编译阶段生成消息代码所需的工具包message_runtime是运行时解析消息所需的库。一个管编译一个管运行位置不能放反。std_msgs和geometry_msgs是消息里引用的类型所在包build和exec都要依赖。如果你是新手把这两句当固定流程先记住后面慢慢就理解了。我早期漏掉message_generation编译时报一堆看不懂的依赖错误查了半天才发现是package.xml的问题。3.5 编译验证catkin_make与rosmsg show配置全部完成后回到工作空间根目录编译cd ~/catkin_ws catkin_make source devel/setup.bash编译完成后立刻用rosmsg验证rosmsg show learning_message/Person如果能看到完整的字段结构说明消息已经成功生成工作空间路径也已生效。此时你可以在devel/include/learning_message/目录下找到生成的Person.h头文件也可以在devel/lib/python3/dist-packages/learning_message/msg/目录下找到Person.py模块。这些就是后面写代码时实际导入的东西。这里还有一个非常容易踩的坑如果编译前开了一个终端编译完没有执行source devel/setup.bash直接运行rosmsg show大概率查不到这个新消息。因为当前终端的ROS环境变量还停留在编译前的工作空间状态。新开终端或者重新source一下就能解决。4. C和Python双端调用发布方与订阅方的完整代码4.1 C发布者advertise与消息字段赋值消息定义好了接下来就是拿它写节点。我用上面定义的Person消息在learning_message包下写一发布一订阅两个C节点。先创建src/person_talker.cpp文件#include ros/ros.h #include learning_message/Person.h int main(int argc, char** argv) { ros::init(argc, argv, person_talker); ros::NodeHandle nh; // advertise消息类型(话题名, 队列长度) ros::Publisher pub nh.advertiselearning_message::Person(person_info, 10); // 控制每秒发布1条 ros::Rate rate(1.0); while (ros::ok()) { learning_message::Person msg; // Header字段赋值 msg.header.stamp ros::Time::now(); msg.header.frame_id map; // 普通字段赋值 msg.name Tom; msg.age 25; msg.height 1.78; msg.sex msg.MALE; // 嵌套消息字段赋值 msg.pose.position.x 1.0; msg.pose.position.y 2.0; msg.pose.position.z 0.0; msg.pose.orientation.w 1.0; pub.publish(msg); rate.sleep(); } return 0; }这里有几个关键点。advertise的模板参数填的就是消息类型话题名是person_info队列长度10表示最多缓存10条消息超过后旧消息会被丢弃这一参数要根据实际发布频率和数据重要性来调。Header的stamp字段最好填实际数据产生的时间而不是发布时刻泛型化应用里如果发布延迟大下游拿到的时间就会失真。msg.MALE是通过常量名访问msg文件里定义的常量C端会自动生成对应的静态成员。4.2 C订阅者回调函数的参数类型与打印验证再创建src/person_listener.cpp#include ros/ros.h #include learning_message/Person.h void personCallback(const learning_message::Person::ConstPtr msg) { ROS_INFO(name: %s, age: %u, height: %.2f, sex: %u, msg-name.c_str(), msg-age, msg-height, msg-sex); // 顺便打印坐标信息 ROS_INFO(position: (%.2f, %.2f, %.2f), msg-pose.position.x, msg-pose.position.y, msg-pose.position.z); } int main(int argc, char** argv) { ros::init(argc, argv, person_listener); ros::NodeHandle nh; ros::Subscriber sub nh.subscribe(person_info, 10, personCallback); ros::spin(); return 0; }订阅者的核心思路是回调函数。消息一旦到达ROS就会调用personCallback参数类型固定写成const learning_message::Person::ConstPtr在回调里用-访问字段。这是ROS C开发里最经典也最常用的写法。注意我用%u打印uint8用%.2f打印float64格式化字符必须和字段类型对应否则输出会出现乱码或者警告。需要提醒的是回调函数里不应该处理耗时太长的逻辑。回调线程一旦被阻塞后续消息就会积压甚至丢弃。如果需要做复杂处理常见的做法是把消息拷贝到外部队列由主循环或另一个线程处理。4.3 CMakeLists.txt中新增可执行文件的写法add_dependencies不能省C文件写好后还需要在CMakeLists.txt里添加可执行文件配置。很多人会把这一步和前面的add_message_files混为一谈其实它们是两段不同的内容。add_executable(person_talker src/person_talker.cpp) target_link_libraries(person_talker ${catkin_LIBRARIES}) add_dependencies(person_talker learning_message_generate_messages_cpp) add_executable(person_listener src/person_listener.cpp) target_link_libraries(person_listener ${catkin_LIBRARIES}) add_dependencies(person_listener learning_message_generate_messages_cpp)add_executable告诉编译系统要把哪个cpp编译成可执行文件target_link_libraries把catkin库链接进来最关键的是第三行add_dependencies。它的作用是声明这个可执行文件依赖本包生成的C消息代码。如果不加这一行在高并发的编译场景下可能出现编译person_talker.cpp时Person.h还没生成完的情况从而报出找不到头文件的错误。虽然单核顺序编译时可能碰巧不报错但不能赌这个运气。配置完重新编译catkin_make source devel/setup.bash4.4 Python端实现导入自定义消息与常量访问Python端的代码要简洁一些。先写scripts/person_talker.py#!/usr/bin/env python3 import rospy from learning_message.msg import Person def talker(): rospy.init_node(person_talker, anonymousTrue) pub rospy.Publisher(person_info, Person, queue_size10) rate rospy.Rate(1) while not rospy.is_shutdown(): msg Person() msg.header.stamp rospy.Time.now() msg.header.frame_id map msg.name Jack msg.age 30 msg.height 1.85 msg.sex Person.MALE msg.pose.position.x 3.0 msg.pose.position.y 4.0 msg.pose.position.z 0.0 msg.pose.orientation.w 1.0 pub.publish(msg) rate.sleep() if __name__ __main__: try: talker() except rospy.ROSInterruptException: pass再写scripts/person_listener.py#!/usr/bin/env python3 import rospy from learning_message.msg import Person def person_callback(msg): rospy.loginfo(name: %s, age: %d, height: %.2f, sex: %d, msg.name, msg.age, msg.height, msg.sex) rospy.loginfo(position: (%.2f, %.2f, %.2f), msg.pose.position.x, msg.pose.position.y, msg.pose.position.z) def listener(): rospy.init_node(person_listener, anonymousTrue) rospy.Subscriber(person_info, Person, person_callback) rospy.spin() if __name__ __main__: listener()Python端导入自定义消息的方式是from learning_message.msg import Person。注意这里是一个稳定的三部分路径功能包名.msg.消息名。常量访问直接用Person.MALE即可。另外Python脚本必须赋予执行权限否则用rosrun运行时会卡在Permission denied上等会儿踩坑环节我会细说这个问题。4.5 运行验证从roscore到rqt_graph全流程所有代码都准备完毕后按顺序运行# 终端1启动ROS主节点 roscore # 终端2启动发布者 source devel/setup.bash rosrun learning_message person_talker # 终端3启动订阅者 source devel/setup.bash rosrun learning_message person_listener看到订阅者终端持续打印消息说明通信已经打通。如果想进一步确认可以用rostopic命令验证# 查看某个话题的类型 rostopic type person_info # 查看某个话题的发布者和订阅者信息 rostopic info person_info # 直接监听话题内容 rostopic echo person_info运行rqt_graph看到的画面会直观很多person_talker和person_listener两个椭圆节点中间通过person_info话题连成一条单向箭头。看到这条连线就说明话题消息从定义、编译、发布、订阅的完整链路全部走通了。5. 编译报错与运行时异常我踩过的坑与排查链路5.1 找不到消息头文件最常见的第一个拦路虎现象是编译C节点时出现fatal error: learning_message/Person.h: No such file or directory这个报错几乎每个自定义消息的初学者都会遇到。我个人的排查链路是这样的按顺序检查第一步确认是否执行过catkin_make。没有编译消息头文件就根本没有生成这种报错是必然的。第二步确认CMakeLists.txt三处配置是否完整。最常见的问题是只写了add_message_files漏掉了generate_messages和catkin_package里的CATKIN_DEPENDS。第三步检查是否source过工作空间。因为你include的头文件在devel/include目录下当前终端如果没source编译时使用的库路径和头文件路径就不包含这个目录。第四步也是最隐蔽的一步检查add_dependencies是否添加。有时候头文件存在但编译系统没有先构建消息再编译你的目标文件就会出现偶发性的找不到头文件。这条链路走完绝大多数无头文件问题都能解决。5.2 rosmsg show查不到自定义消息先怀疑环境路径如果在运行rosmsg show learning_message/Person时没有输出首先检查当前终端是否执行了source devel/setup.bash。ROS通过ROS_PACKAGE_PATH环境变量来查找功能包没有source环境变量里就没有你的工作空间。echo $ROS_PACKAGE_PATH正常情况下输出里应该能看到/home/用户名/catkin_ws/src。如果没有就source一下。如果source了还是没有检查工作空间的目录结构必须是catkin_ws/src/learning_message这种标准结构不能把功能包直接放在catkin_ws根目录下。还有一种冷门情况你改了msg文件但忘了重新catkin_make。rosmsg读的是编译生成的类型定义不是直接读msg源文件。所以每次修改msg后都必须重新编译才能生效。5.3 发布订阅类型不匹配最隐晦的没数据问题有时候发布者和订阅者都正常启动rqt_graph里也能看到两个节点但订阅者就是收不到任何消息。在rqt_graph里点开话题发现发布者端显示的类型是learning_message/Person订阅者端显示的类型却是std_msgs/String或者两端明明写的是同一个名字但大小写不同。这种情况下ROS虽然允许建立连接但数据发送会被静默丢弃不是报错而是压根没数据。排查命令非常有效rostopic info /person_info这个命令会列出发布者的类型和订阅者的类型。如果类型不一致立刻就能看出来。我在项目里还遇到过一种情况两个功能包各自定义了一个同名消息比如task_a/Person和task_b/Person字段结构也一模一样但它们是两个不同的类型直接互相订阅也是收不到数据的。所以看话题类型时一定要看包名/消息名的完整组合。5.4 Python脚本没法rosrun权限、shebang与导入路径Python脚本最常见的三个问题都很好排查。第一个是文件没有执行权限运行时报Permission denied解决方式是chmod x scripts/person_talker.py第二个是第一行缺少shebang也就是#!/usr/bin/env python3这一行。没有这行rosrun不知道用哪个解释器去运行这个脚本。第三是导入路径写错自定义消息的导入格式必须是from 功能包名.msg import 消息名中间不能少.msg消息名的首字母大写也要严格匹配msg文件名。5.5 字段命名踩雷Field has same name as message type这个错误比较隐蔽我第一次遇到时盯着msg文件看了半天才反应过来。假设你的消息类型叫Person如果你在msg文件里写了一个字段名字恰好也叫person比如Person person编译时就会报Field has same name as message type原因很简单代码生成器在生成C类时会把类名和成员名同时定义在一个作用域里如果成员名和类型名完全相同就会出现同名冲突。解决方式也直接把字段名改成有语义的其他名字比如target_person。这个错误告诉我们msg文件的字段命名不只是风格问题还可能直接影响代码生成的合法性。5.6 常见问题速查表把上面这些坑汇总成一个表格方便以后遇到问题快速对照现象最可能原因排查命令/方法编译找不到xxx/Person.h未编译、CMakeLists配置缺失、未source、缺少add_dependencies检查CMakeLists重新catkin_make并sourcerosmsg show无输出未source、工作空间路径不对、修改msg后没重新编译echo $ROS_PACKAGE_PATH重新catkin_make两端无数据流动话题名或消息类型不一致rostopic info /话题名对照类型Python脚本Permission denied缺少执行权限chmod x 脚本文件Python端ImportError导入路径少了.msgfrom 功能包名.msg import 请求msg编译报同名错误字段名与消息类型重名修改字段名5.7 一个运行期的小坑rostopic echo看不到数据的错觉还有一个很常见但不算报错的坑发布者明明在发但你一执行rostopic echo person_info终端却迟迟没有输出。大多数情况下不是消息没发出来而是rostopic echo只显示它启动之后收到的数据。如果你的发布频率是1秒1条执行echo之后等一两秒自然就有了。如果发布频率特别低比如每分钟1条那等待时间就更长。遇到这种情况可以先用rostopic hz person_info查看话题的实际发布频率确认数据流是通的而不是干等。每次跑自定义消息的完整链路我最后都会习惯性地用rostopic list扫一眼当前所有话题再配合rqt_graph查看节点和话题的连接情况。这两步做完整个通信状态基本就心中有数了。最后说点个人体会。消息一旦发布出去就等于和所有订阅方签了一份接口合同以后再改字段名或者删字段所有下游节点都得跟着改。所以我在定义消息前通常先花五分钟想清楚字段设计能用标准消息就优先用标准消息比如坐标相关的直接用geometry_msgs/Pose而不是自己拆成六个float64一个消息里的字段做语义化命名别用什么data1、data2如果包是团队共同维护的我会在msg定义里用注释把每个字段的含义和单位标清楚这能省掉后面大量沟通成本。另外还有一个每次动手前都会用的小技巧先跑一条rosmsg list | grep 关键词看看系统里有没有现成的消息类型可以复用。ROS发行版里内置了几百种消息很多需求官方早就解决过了。
返回列表