戰(zhàn):從msg設(shè)計(jì)到話題觀測)
這周調(diào)四輪差速底盤時(shí)我又被同一個(gè)問題絆了一下輪速、電流、溫度、PWM這些狀態(tài)量到底用哪種ROS2話題消息發(fā)下游節(jié)點(diǎn)才看得最省心用Float32MultiArray當(dāng)然能發(fā)但下標(biāo)一多收的一方根本不知道第3個(gè)float是電流還是占空比。于是我把這套協(xié)議寫成了自定義msg順手把ROS2自定義消息設(shè)計(jì)、功能包配置、發(fā)布訂閱實(shí)現(xiàn)和話題觀測的完整流程走了一遍。這篇記錄就是這次實(shí)戰(zhàn)的復(fù)盤適合剛?cè)腴TROS2、被自定義消息繞暈的新手也適合想規(guī)范自己消息接口的中級開發(fā)者??赐昴隳塥?dú)立完成一個(gè)msg功能包從設(shè)計(jì)字段到編譯驗(yàn)證再通過命令行和可視化工具把話題數(shù)據(jù)看得明明白白。1. 自定義消息的適用邊界什么時(shí)候該自己寫msg什么時(shí)候直接用標(biāo)準(zhǔn)類型1.1 從底盤狀態(tài)發(fā)布需求說起一個(gè)下標(biāo)引發(fā)的協(xié)議混亂先還原一下場景。我要把四個(gè)輪子的目標(biāo)轉(zhuǎn)速、實(shí)際轉(zhuǎn)速、電機(jī)溫度、母線電流、PWM占空比發(fā)出來每50ms一幀總共需要十幾二十個(gè)字段。第一反應(yīng)是用std_msgs/Float32MultiArray定義一個(gè)20維的數(shù)組。發(fā)布端攥著下標(biāo)一個(gè)values[3]xxx地賦值訂閱端也要靠下標(biāo)去猜一旦兩個(gè)版本對不上輕則讀錯(cuò)數(shù)據(jù)重則把溫度當(dāng)成了轉(zhuǎn)速去跑安全判斷。這已經(jīng)不是代碼風(fēng)格的問題是數(shù)據(jù)安全隱患。用自定義msg之后完全不同msg里字段有名字、有類型、有注釋motor_id就是motor_idtemperature就是temperature。發(fā)布端寫起來是msg.temperature45.2訂閱端一眼就知道這個(gè)數(shù)代表什么代碼可讀性和可維護(hù)性直接上了一個(gè)臺(tái)階。1.2 標(biāo)準(zhǔn)消息庫能覆蓋大多數(shù)場景但個(gè)性化協(xié)議還得自己來不是說所有消息都要自定義ROS2發(fā)展到現(xiàn)在標(biāo)準(zhǔn)接口庫已經(jīng)非常全。我最常用的幾個(gè)標(biāo)準(zhǔn)消息類型適用場景std_msgs/String、Bool、Int32簡單狀態(tài)量、開關(guān)、日志字符串std_msgs/Header帶時(shí)間戳和frame_id的消息頭geometry_msgs/Pose、Twist、Transform位姿、速度、坐標(biāo)變換相關(guān)sensor_msgs/Image、LaserScan、Imu相機(jī)、激光雷達(dá)、IMU傳感器數(shù)據(jù)nav_msgs/Odometry里程計(jì)判斷是否要自定義消息我一般看三條這個(gè)數(shù)據(jù)結(jié)構(gòu)會(huì)不會(huì)在多個(gè)節(jié)點(diǎn)之間復(fù)用字段是否相對固定下游是否依賴字段名字做邏輯判斷如果三個(gè)答案都是“是”就值得寫自定義msg。如果只是一次性臨時(shí)傳參沒人在乎字段名那用MultiArray之類的通用結(jié)構(gòu)也能湊合但別讓這種代碼活過一星期。還有一個(gè)重要權(quán)衡標(biāo)準(zhǔn)消息類型往往自帶工具鏈支持。sensor_msgs/Imu可以在rviz2里直接可視化nav_msgs/Odometry可以被導(dǎo)航棧直接消費(fèi)。自定義消息想讓外部工具看懂就得自己寫插件或者靠Foxglove這類通用工具兜底。所以我的原則是能復(fù)用標(biāo)準(zhǔn)消息就別自創(chuàng)只有標(biāo)準(zhǔn)類型無法準(zhǔn)確表達(dá)業(yè)務(wù)語義時(shí)才自寫。2. 從零創(chuàng)建一個(gè)獨(dú)立msg功能包依賴、文件配置與編譯鏈路2.1 為什么我建議把msg單獨(dú)放在一個(gè)功能包里項(xiàng)目一多你就知道消息接口和業(yè)務(wù)邏輯混裝在一個(gè)包里麻煩會(huì)接踵而至。A包要引用B包里的msgB包又要依賴A包的消息很容易出現(xiàn)循環(huán)依賴。更常見的是業(yè)務(wù)包更新頻繁消息包也跟著一遍遍重新編譯所有依賴它的節(jié)點(diǎn)全要連帶重編開發(fā)效率被拖得很低。所以我現(xiàn)在只要項(xiàng)目里有自定義消息都會(huì)給它們單獨(dú)建一個(gè)功能包比如叫my_msgs或者robot_interfaces。命名上建議用“項(xiàng)目名interfaces”或者“項(xiàng)目名msgs”既清楚又符合社區(qū)習(xí)慣。功能包構(gòu)建類型用ament_cmake雖然消息也能在ament_python包里生成但CMake版對跨語言支持最省事Python和C節(jié)點(diǎn)都能直接消費(fèi)不用額外處理安裝路徑。2.2 創(chuàng)建功能包與編寫MotorStatus.msg創(chuàng)建命令很簡單cd ~/ros2_ws/src ros2 pkg create my_msgs --build-type ament_cmake mkdir my_msgs/msg然后在msg目錄下新建MotorStatus.msg內(nèi)容可以這樣設(shè)計(jì)# 電機(jī)狀態(tài)消息 int32 motor_id # 電機(jī)編號 float32 speed # 當(dāng)前轉(zhuǎn)速 rad/s float32 target_speed # 目標(biāo)轉(zhuǎn)速 rad/s float32 current # 母線電流 A float32 temperature # 電機(jī)溫度 ℃ builtin_interfaces/Time stamp # 時(shí)間戳每一行就是一個(gè)字段格式是“類型 名字 # 注釋”。注釋會(huì)在ros2 interface show里顯示出來等于把設(shè)計(jì)文檔直接帶進(jìn)了工具鏈。2.3 CMakeLists.txt與package.xml里缺一不可的配置寫好msg文件后最關(guān)鍵的配置在CMakeLists.txt和package.xml里。CMakeLists.txt中至少在ament_package()之前加find_package(rosidl_default_generators REQUIRED) find_package(builtin_interfaces REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} msg/MotorStatus.msg )package.xml里需要補(bǔ)齊這幾行buildtool_dependrosidl_default_generators/buildtool_depend exec_dependrosidl_default_runtime/exec_depend dependbuiltin_interfaces/depend member_of_grouprosidl_interface_packages/member_of_group這個(gè)member_of_group是最容易被忽略的一行。漏掉之后消息雖然能編譯出來但其他功能包可能沒法正常識別這個(gè)包提供的接口現(xiàn)象就是找不到類型、build依賴報(bào)錯(cuò)或者ros2 interface list里搜不到。2.4 編譯、source與接口驗(yàn)證缺一步都會(huì)讓你懷疑人生配置完成后回到工作空間根目錄編譯cd ~/ros2_ws colcon build --packages-select my_msgs source install/setup.bash然后驗(yàn)證接口是否被正確識別ros2 interface show my_msgs/msg/MotorStatus看到剛才寫的字段說明消息包已經(jīng)注冊到系統(tǒng)里了。這一步一定要做。如果不做就急著去寫發(fā)布訂閱節(jié)點(diǎn)遇到import報(bào)錯(cuò)再回頭排查浪費(fèi)的時(shí)間遠(yuǎn)超你想象。3. msg字段設(shè)計(jì)原則類型選型、命名習(xí)慣與版本演進(jìn)3.1 字段類型怎么選從基本類型到時(shí)間和嵌套消息ROS2 msg的基本類型和大多數(shù)語言差不多bool、int8/16/32/64、uint8/16/32/64、float32、float64、string。需要注意兩點(diǎn)一是float32對應(yīng)C的floatPython則統(tǒng)一用float二是ROS2里時(shí)間戳字段推薦顯式寫builtin_interfaces/TimeDuration同理用builtin_interfaces/Duration不要再用舊版本那種裸time/duration寫法跨包依賴時(shí)容易出問題。如果涉及坐標(biāo)、位姿等數(shù)據(jù)可以直接嵌套其他包的消息std_msgs/Header header geometry_msgs/Pose pose sensor_msgs/PointCloud2 cloud嵌套能極大復(fù)用已有生態(tài)下游拿到Pose后可以直接配合TF、rviz等工具使用。數(shù)組也很常用float32[] ranges表示變長數(shù)組float32[4] wheel_speeds表示定長數(shù)組。設(shè)計(jì)時(shí)想清楚業(yè)務(wù)需要的是定長還是變長定長數(shù)組在內(nèi)存布局上更規(guī)整變長數(shù)組更適合點(diǎn)云這類動(dòng)態(tài)數(shù)據(jù)。3.2 常量定義與注釋規(guī)范msg里允許定義常量用法和枚舉很像uint8 MODE_IDLE0 uint8 MODE_RUN1 uint8 MODE_FAULT2 uint8 mode下游判斷模式時(shí)不用寫裸數(shù)字直接用MotorStatus.MODE_RUN代碼可讀性提高一大截。注釋用#建議把單位、范圍、異常值都寫在注釋里ros2 interface show都能看到等于給協(xié)議做了可查詢的活文檔。我見過很多項(xiàng)目里msg文件干干凈凈沒有任何注釋過兩個(gè)月連自己都得猜字段含義這習(xí)慣真的得改。3.3 版本迭代時(shí)盡量向后兼容消息協(xié)議一旦在多個(gè)節(jié)點(diǎn)間流傳改動(dòng)就要格外謹(jǐn)慎。加一個(gè)字段所有發(fā)布端和訂閱端都更新基本沒問題但刪字段、改類型哪怕只是int32改成float32都可能讓舊節(jié)點(diǎn)按錯(cuò)誤的字節(jié)序解析數(shù)據(jù)出現(xiàn)完全不正常的值。我的經(jīng)驗(yàn)是能加字段就加字段不要?jiǎng)h改舊字段如果實(shí)在要改語義就新增加一個(gè)字段舊字段保留并標(biāo)記為deprecated。另外msg功能包升級后依賴它的業(yè)務(wù)功能包必須重新編譯并source否則節(jié)點(diǎn)還在跑舊類型定義容易出現(xiàn)字段對不上、內(nèi)存解析錯(cuò)位的詭異問題。4. 發(fā)布與訂閱自定義消息Python與C的最小可運(yùn)行實(shí)現(xiàn)4.1 Python發(fā)布端與訂閱端從import到publish只需要幾行Python端流程很清晰發(fā)布節(jié)點(diǎn)import rclpy from rclpy.node import Node from my_msgs.msg import MotorStatus class MotorPublisher(Node): def __init__(self): super().__init__(motor_status_publisher) self.publisher_ self.create_publisher(MotorStatus, motor_status, 10) self.timer self.create_timer(0.1, self.timer_callback) def timer_callback(self): msg MotorStatus() msg.motor_id 1 msg.speed 120.5 msg.target_speed 120.0 msg.current 3.2 msg.temperature 45.2 self.publisher_.publish(msg) def main(argsNone): rclpy.init(argsargs) node MotorPublisher() rclpy.spin(node) node.destroy_node() rclpy.shutdown() if __name__ __main__: main()訂閱節(jié)點(diǎn)唯一需要注意的是回調(diào)函數(shù)只接收一個(gè)msg參數(shù)class MotorSubscriber(Node): def __init__(self): super().__init__(motor_status_subscriber) self.subscription self.create_subscription( MotorStatus, motor_status, self.listener_callback, 10) def listener_callback(self, msg): self.get_logger().info( fmotor_id{msg.motor_id} temp{msg.temperature:.2f})我自己項(xiàng)目里最容易犯的錯(cuò)是msg字段拼寫和定義不一致比如定義的是target_speed寫代碼時(shí)寫成targetSpeedPython會(huì)在賦值時(shí)才報(bào)AttributeError一報(bào)錯(cuò)就先慌半天。其實(shí)翻一眼ros2 interface show就能對上排查成本極低。4.2 C實(shí)現(xiàn)要點(diǎn)蛇形頭文件與CMake鏈接C工程里消息頭文件是蛇形命名的my_msgs/msg/motor_status.hpp。寫一個(gè)最小發(fā)布端#include rclcpp/rclcpp.hpp #include my_msgs/msg/motor_status.hpp using my_msgs::msg::MotorStatus; class MotorPublisher : public rclcpp::Node { public: MotorPublisher() : Node(motor_status_publisher) { publisher_ this-create_publisherMotorStatus(motor_status, 10); timer_ this-create_wall_timer(std::chrono::milliseconds(100), [this]() { auto msg std::make_sharedMotorStatus(); msg-motor_id 1; msg-speed 120.5; msg-target_speed 120.0; msg-current 3.2; msg-temperature 45.2; publisher_-publish(*msg); }); } private: rclcpp::PublisherMotorStatus::SharedPtr publisher_; rclcpp::TimerBase::SharedPtr timer_; };CMakeLists里要鏈接my_msgsfind_package(my_msgs REQUIRED) ament_target_dependencies(motor_pub rclcpp my_msgs)漏掉find_package(my_msgs REQUIRED)時(shí)編譯報(bào)錯(cuò)通常是找不到頭文件提示并不直觀容易讓人誤以為是路徑問題。如果發(fā)現(xiàn)編譯一報(bào)錯(cuò)就是fatal error: my_msgs/msg/motor_status.hpp: No such file or directory九成是這句沒寫。4.3 QoS匹配是topic echo沒數(shù)據(jù)最常見的元兇發(fā)布訂閱都寫對了ros2 topic echo還是沒數(shù)據(jù)十有八九是QoS策略不兼容。簡單說QoS就是收發(fā)雙方對消息可靠性、時(shí)效性、歷史深度的約定。depth是隊(duì)列深度reliability分reliable和best_effort前者保證不丟包但可能延遲后者犧牲可靠性換低延遲。如果發(fā)布端是sensor_databest_effort訂閱端卻是默認(rèn)的reliable兩邊匹配不上數(shù)據(jù)就傳不過去。排查方法很直接ros2 topic info /motor_status -v這個(gè)命令會(huì)顯示發(fā)布者和訂閱者各自的QoS配置。如果reliability一側(cè)是Reliable另一側(cè)是Best Effort就需要在create_subscription里顯式指定QoS或者把發(fā)布端的QoS改成兼容版本。我用攝像頭數(shù)據(jù)時(shí)經(jīng)常遇到這個(gè)問題而自己寫的控制消息默認(rèn)reliable通常不太會(huì)踩坑但一旦踩了排查起來很隱蔽。5. 話題觀測的完整工具箱CLI、可視化工具與日志兜底5.1 ros2 topic系列命令list、echo、info、hz、bw一次說清命令行是觀測話題的第一道門我基本每天都會(huì)用到這一組命令ros2 topic list # 列出所有話題 ros2 topic list -t # 列出話題及其消息類型 ros2 topic echo /motor_status # 持續(xù)打印話題數(shù)據(jù) ros2 topic echo /motor_status --once # 只打印一幀 ros2 topic info /motor_status -v # 查看發(fā)布訂閱數(shù)量和QoS ros2 topic hz /motor_status # 統(tǒng)計(jì)真實(shí)發(fā)布頻率 ros2 topic bw /motor_status # 統(tǒng)計(jì)話題帶寬占用 ros2 interface show my_msgs/msg/MotorStatus # 查看字段定義其中hz最實(shí)用。比如你設(shè)置了10Hz定時(shí)器但回調(diào)耗時(shí)太長hz顯示出來只有2Hz不用猜就能定位是發(fā)布側(cè)卡了還是調(diào)度出了問題。bw則在排查大消息點(diǎn)云、圖像占用帶寬時(shí)很有用。5.2 rqt_graph與rviz2可視化觀測能做到什么程度rqt_graph能把節(jié)點(diǎn)和話題關(guān)系畫成一張圖是理解系統(tǒng)拓?fù)渥钪庇^的工具rqt_graph看到節(jié)點(diǎn)間的箭頭就能快速判斷話題名和節(jié)點(diǎn)連接是否符合預(yù)期。如果某個(gè)話題的箭頭沒畫出來或者多了一個(gè)意料之外的節(jié)點(diǎn)連接通常說明命名或邏輯有問題。我在調(diào)多節(jié)點(diǎn)系統(tǒng)時(shí)幾乎每次都會(huì)先開rqt_graph再?zèng)Q定下一步看哪里。rviz2對自定義消息的支持就有限了。像Pose、LaserScan、Imu這種標(biāo)準(zhǔn)消息rviz2有專門的Display插件可以直接可視化但MotorStatus這種純數(shù)值消息rviz2沒有默認(rèn)Display能顯示。我一般不在rviz2里看這種消息而是用Foxglove Studio。Foxglove對自定義消息支持得更好能直接以表格和曲線形式看任意字段非常適合電機(jī)狀態(tài)這類遙測數(shù)據(jù)。5.3 自寫觀測節(jié)點(diǎn)和ros2 bag不依賴圖形界面的兜底方案如果目標(biāo)環(huán)境沒有圖形界面比如工控機(jī)或者Docker容器里命令行和自寫節(jié)點(diǎn)就很重要。我個(gè)人的習(xí)慣是在每個(gè)業(yè)務(wù)包里留一個(gè)debug_sub節(jié)點(diǎn)用最簡單的方式打印關(guān)鍵字段必要時(shí)還可以直接pdb.set_trace()進(jìn)入調(diào)試單幀數(shù)據(jù)就能停在那慢慢看。另一個(gè)被低估的工具是ros2 bagros2 bag record /motor_status ros2 bag play rosbag2_xxx現(xiàn)場測完一包數(shù)據(jù)回到工位回放配合topic echo和Foxglove慢慢分析。尤其是偶發(fā)異??咳搜鄱onsole根本盯不住錄包回放是唯一高效的辦法。這個(gè)習(xí)慣幫我解決過好幾次“現(xiàn)場復(fù)現(xiàn)不了”的疑難雜癥。6. 踩坑實(shí)錄自定義msg使用中最容易翻車的五個(gè)細(xì)節(jié)6.1 source順序和構(gòu)建緩存導(dǎo)致的消息類型“消失”最常見也最氣人的問題明明已經(jīng)colcon build了ros2 interface show卻提示找不到類型。原因通常是當(dāng)前終端沒有source新生成的install/setup.bash或者之前source的是另一個(gè)工作空間的setup.bash。解決辦法是新開終端cd ~/ros2_ws source install/setup.bash確保路徑里同時(shí)包含基礎(chǔ)ROS2環(huán)境和當(dāng)前工作空間。如果source沒問題但還是找不到就得懷疑build/install目錄里的舊緩存了。這種時(shí)候我一般直接刪掉build和install目錄再重新build雖然慢一點(diǎn)但能把各種莫名其妙的舊文件問題一次性清干凈。6.2 修改msg后調(diào)用方還在用舊字段我試過在MotorStatus里把current字段改名為bus_current所有被依賴的包都重新編譯了但跑起來的一個(gè)Python節(jié)點(diǎn)一直報(bào)AttributeError。原因是想當(dāng)然以為source了新環(huán)境就萬事大吉實(shí)際上某些ros2 run是從舊終端啟動(dòng)的進(jìn)程還加載著舊環(huán)境變量或者install目錄里舊包沒被覆蓋。解決方法是全關(guān)終端重新source必要時(shí)把install目錄下對應(yīng)包的緩存刪掉再build。經(jīng)驗(yàn)就是當(dāng)你肉眼看到字段沒生效先殺終端進(jìn)程和舊節(jié)點(diǎn)而不是只編譯一次。6.3 消息包被多個(gè)業(yè)務(wù)包依賴時(shí)的構(gòu)建順序問題當(dāng)my_msgs被底盤控制、導(dǎo)航、狀態(tài)機(jī)三個(gè)包同時(shí)依賴時(shí)colcon build的默認(rèn)順序一般沒問題它會(huì)自己解析依賴。但如果只用了--packages-select去編譯某個(gè)業(yè)務(wù)包而my_msgs還沒編譯或沒source那結(jié)果一定是找不到頭文件或import報(bào)錯(cuò)。正確做法是用--packages-up-tocolcon build --packages-up-to motor_controller這個(gè)命令會(huì)把依賴鏈上游一起編譯而--packages-select只編譯指定包本身。這兩個(gè)參數(shù)的差別踩過坑的自然懂。6.4 echo有話題但數(shù)據(jù)為空或出現(xiàn)nan這個(gè)坑我踩得莫名其妙。發(fā)布端打印出來的msg里明明是正常數(shù)值但訂閱端收到后卻是nan或者0。后來發(fā)現(xiàn)是C代碼里創(chuàng)建了消息對象但沒有給字段賦值就發(fā)布某些字段的值取決于內(nèi)存初始狀態(tài)不一定是0。另一個(gè)常見情況是把float32字段定義成數(shù)組了或者數(shù)據(jù)結(jié)構(gòu)改了但解析代碼沒改。建議發(fā)布前先斷言一遍關(guān)鍵字段if (!std::isfinite(msg-speed)) return;這種防御式寫法能把臟數(shù)據(jù)擋在源頭省得下游節(jié)點(diǎn)拿到異常值后像無頭蒼蠅一樣亂轉(zhuǎn)。6.5 話題名和消息類型對不上排查思路固定化還有一類錯(cuò)誤跟msg設(shè)計(jì)無關(guān)但特別容易在項(xiàng)目里反復(fù)出現(xiàn)發(fā)布的是/motor_status訂閱端監(jiān)聽的是/motor_status/statusrqt_graph一看全是孤立節(jié)點(diǎn)。這種問題排查思路可以固定下來先用ros2 topic list -t確認(rèn)當(dāng)前話題名單和類型再用ros2 topic info /topic -v確認(rèn)收發(fā)雙方都在最后再用rqt_graph看拓?fù)?。按這個(gè)順序走一般十分鐘內(nèi)能定位。文末不放什么宏大結(jié)論只講兩個(gè)個(gè)人體會(huì)。第一個(gè)自定義msg并不是越早設(shè)計(jì)越好先在小范圍場景里跑通發(fā)布訂閱等字段趨于穩(wěn)定后再抽出獨(dú)立消息包會(huì)少改很多協(xié)議。第二個(gè)我在my_msgs里維護(hù)了一個(gè)protocol_design.md把每個(gè)字段的含義、單位、有效范圍都寫清楚這個(gè)文件比代碼注釋有用十倍每次改協(xié)議先改文檔再改msg下游同事都能少問很多問題。如果你也在折騰自定義消息建議從最簡單的MotorStatus這類狀態(tài)消息開始先看完topic echo輸出再談其他。