Soma Zero Tutorials
🔍 搜索功能尚未开启,敬请期待。

附录2 信号链路:MoveIt 收哪里的信号、怎么发信号(Gazebo / 真机)

很多人第一次把 MoveIt、Gazebo、真机接到一起会懵:MoveIt 到底从哪里知道"机器人现在长什么样"?又把"要去哪"发给谁?为什么同一套 MoveIt 配置,既能驱动仿真、又能驱动真机?这篇把整条信号链路一次讲透,并给一张总图。

配合附录1(Gazebo 物理仿真)看:附录1 讲"怎么把臂接进物理",本篇讲"信号怎么在 RViz / MoveIt / 控制器 / Gazebo / 真机 之间流动"。

一、一张图看懂整条链路

MoveIt / Gazebo / 真机 信号链路图
图 附2.1 从 RViz 到 Gazebo / 真机 的完整信号链路(实线=命令下行,虚线=状态回流)

一句话:RViz → move_group(MoveIt)→ ros2_control 控制器 →(仿真 gz_ros2_control / 真机 episode_controller);而机器人的当前状态(/joint_states)再从最底层一路回流给 MoveIt。上半段(RViz→MoveIt→控制器)仿真和真机完全一样,只有最底层那一格不同。

二、MoveIt 收什么信号(输入)

收什么类型作用
/joint_statestopic(sensor_msgs/JointState)机器人当前关节角。MoveIt 靠它知道"现在长什么样"——RViz 里那个橙色实体就是它。仿真里由 joint_state_broadcaster 从 Gazebo 物理关节读出发布;真机里由 episode_controller 从编码器读出发布。话题名一样,所以 MoveIt 无感
/planning_scene/collision_object/attached_collision_objecttopic环境障碍物 / 手里抓的物体,供碰撞检查。
/move_actionaction(moveit_msgs/MoveGroup)RViz 的交互球或你的代码,把目标位姿/关节角发给 move_group 触发"规划+执行"。
robot_description / robot_description_semanticparamURDF(模型)/ SRDF(规划组、免碰撞矩阵)。启动时加载。

三、MoveIt 发什么信号(输出)

发什么类型作用
/episode_arm_controller/follow_joint_trajectoryaction(FollowJointTrajectory)规划成功后,move_group 把整条轨迹发给控制器去执行。这是 MoveIt → 硬件的唯一执行通道
/display_planned_pathtopic规划预览(RViz 里那条动画 / 灰色"重影"目标态)。
/monitored_planning_scenetopic维护中的规划场景。

关键:MoveIt 不直接驱动关节。它只负责"算出一条轨迹"并交给 ros2_control 的控制器;真正逐点跟踪关节的是控制器。所以 MoveIt 和"用什么硬件"是解耦的。

四、中间层:ros2_control(把 MoveIt 和硬件解耦的关键)

这一层是"同一套 MoveIt 能同时支持仿真和真机"的核心:

  • controller_manager:加载、配置、激活各控制器的总管。
  • episode_arm_controller(JointTrajectoryController,JTC):接收 MoveIt 发来的轨迹,按时间插值,每个控制周期把 position 命令写给"硬件接口"。
  • gripper_controller(JTC):同理,管夹爪两指。
  • joint_state_broadcaster:反向——从"硬件接口"读 position/velocity,打包成 /joint_states 发布出去(供 MoveIt/RViz)。
  • 硬件接口(hardware interface):ros2_control 的抽象层,只定义两件事——"读状态(read)"和"写命令(write)"。具体怎么读怎么写,由插件实现。换插件 = 换后端,上面的控制器一行都不用改。

五、两个后端:同一接口,两种实现

维度【仿真】【真机】
硬件接口插件gz_ros2_controlGazeboSimSystemepisode_controllerinterface 节点
"写命令"落到哪Gazebo 物理关节(施力/置位)CAN 总线 → 真实电机(PCAN-USB)
"读状态"从哪来Gazebo 物理引擎真实编码器
控制器名episode_arm_controller(特意同名)episode_arm_controller
MoveIt 配置共用(规划组 episode_arm、IKFast)共用
时间源use_sim_time=true,/clock 来自 gzuse_sim_time=false,墙上时钟
启动sim.launch.py真机 launch(回零→关 gui_server→interface init_mode:=1)

六、怎么区分 MoveIt 现在连的是仿真还是真机

没有"自动切换"——靠你启动了哪套 launch 决定。运行时一眼判断:

# 有 gz_ros_control / gazebo 节点 → 仿真;只有 episode_controller 的 interface → 真机 ros2 node list | grep -E "gz_ros_control|gazebo|episode_controller" # true=仿真, false=真机 ros2 param get /move_group use_sim_time # 看 /joint_states 的发布者是谁 ros2 topic info /joint_states --verbose

七、关键话题 / 动作 / 服务 速查

名称类型方向说明
/move_actionaction你 → MoveIt发目标、触发 Plan&Execute
/episode_arm_controller/follow_joint_trajectoryactionMoveIt → 控制器下发臂轨迹
/gripper_controller/joint_trajectorytopic你/MoveIt → 控制器夹爪开合
/joint_statestopic控制器 → MoveIt/RViz当前关节状态(回流)
/check_state_validityservice你 → MoveIt查某状态是否自碰撞
/clocktopicgz → 全体仿真时钟(use_sim_time)

八、为什么这么设计

整条链路只有最底层一格(硬件接口插件)区分仿真/真机,上面全部共用。好处:

  • 数字孪生:仿真里调好的规划、写好的上层状态机/抓放逻辑,搬到真机几乎不用改——只换底层 launch。
  • 先仿真后真机:在不损坏价值百万的真机前提下,反复在 Gazebo 里验证轨迹、碰撞、夹取。
  • 同一套调试经验:/joint_statesfollow_joint_trajectory 这些话题/动作在两边都一样,排查方法通用。

记住这张图,以后无论"臂不动""规划失败""真机和仿真对不上",都能顺着这条链路一段段定位:是 MoveIt 没规划出来?还是轨迹没发到控制器?还是控制器没驱动到硬件?还是 /joint_states 没回流?

九、附:/joint_states 发布频率 = CAN 询问频率(真机的"滋滋"电流声)

第二节说 /joint_states 是状态回流的入口。在真机这一格,它还藏着一个新手坑:发布这个话题的频率,和去 CAN 总线上"问电机角度"的频率是绑死的。理解这一点,能解释一个常见现象——一连上 ROS(还没让 MoveIt 动),机械臂就发出"滋滋"的电流声,把 ROS 进程关掉就没了;而用 gui_server(上位机)时没有这个声音

9.1 为什么这两个频率是"同一个数"

真机后端 episode_controllerinterface 节点,用一个定时器周期性发布 /joint_states;而每一次发布前,都现去 CAN 读一次 6 个电机的角度,中间没有缓存:

hz = 120.0
self._timer = self.create_timer(1.0 / hz, self.publish_joint_states)

def publish_joint_states(self):
    angles_deg = self.motor_control.read_motor_angles()   # ← 每跳都现读一次 CAN(6 个电机)
    ...
    self._joint_states_publisher.publish(msg)             # ← 紧接着把读到的值发出去

所以 1 跳 = 1 次 CAN 往返(6 电机)= 1 次发布,三个频率被代码结构强行变成同一个数。这不是物理定律,而是这段代码图省事的写法——把"测量"和"发布"焊在了同一次回调里。

第一性原理:"我多久测量一次世界"(读 CAN)和"我多久告诉别人一次"(发 topic)本是两个独立的自由度。只因为把测量写进了发布回调,它们才相等。想解耦,见 9.5。

9.2 为什么默认 120Hz?仿真里免费,真机上昂贵

这个 120Hz 是为了模仿标准 ros2_control 的状态广播频率——正常的 joint_state_broadcastercontroller_managerupdate_rate(本教程配的是 100)发布,MoveIt / RViz 期待 /joint_states 是一条 ~100Hz 的连续高频流(画面顺滑、执行监控及时)。问题在于"读一次状态"的代价在两个后端天差地别:

后端读一次关节状态 = 什么120Hz 的代价
【仿真】gz_ros2_control从物理引擎读内存变量几乎为零,随便拉多高
【真机】episode_controllerUSB2CAN → 6 块 ZDT 驱动板的阻塞式 CAN 事务把整条 CAN 总线打满

官方把仿真用的 120Hz 原样搬到真机,没考虑真实 CAN 的代价——这才是声音的根源。注意:这个频率只服务于"反馈/可视化",不影响运动执行(执行走的是 follow_joint_trajectory 那条独立通道),所以降低它对 Plan&Execute 毫无功能损失。

9.3 "滋滋"声物理上是怎么产生的

  1. 步进电机靠灌电流保持位置:关节停在某角度顶住重力,驱动板必须往线圈维持受控的直流电流。
  2. 这电流是 PWM 斩波给的:为效率,H 桥把全电压(24/36V)高速通断,靠线圈电感平均成目标电流。
  3. 每次电流脉冲都让电机微微振动:磁场让铁芯伸缩(磁致伸缩)、让通电线圈受力(洛伦兹力),铁芯和线圈以斩波/调制频率机械振动,推动空气=声音。
  4. 落在 20Hz~20kHz 就听得见:纯超声斩波是静音的;一旦被调制进可听频段就成了"滋滋"。

对比两个后端:gui_server 空闲时几乎不读 CAN,驱动板处于稳定保持态、斩波多在超声、近乎静音;ROS 的 120Hz 则让每 1/120 秒驱动板就要响应一次 CAN 读中断,这个周期性负载把电流调节环/斩波调制出 120Hz 及其谐波的包络,正好砸在人耳最敏感的频段 →"滋滋"。这声音是通信负载的声学症状,不等于电机在多受力或多发热。

9.4 危险吗?——"读取"不命令运动

"读角度"是一条询问指令,不改变电机的电流/力矩设定值,不命令任何运动,所以高频轮询本身不会往线圈多灌功率。电机的热主要来自保持电流(使能顶重力),而保持电流和你 5s 读一次还是 120Hz 读一次无关,gui_server 下也一样有。结论:读取对绕组基本无害;真正衡量损伤的判据是温度——摸电机外壳,温热=正常,烫手=停。别让它带电"滋滋"响着长时间无人值守即可。

9.5 怎么办:降频,或彻底解耦

  • 最简单(够用):把 hz 降到 20~30Hz(或改成可命令行调的参数),CAN 安静、电机更稳,RViz/MoveIt 无感。
  • 正解(要高频发布又要 CAN 安静时):解耦——开一个低频线程读 CAN 写进缓存,发布定时器只发缓存值;读和发各跑各的频率。

顺带:VLA(LeRobot)那条路不会遇到这个声音——它不走这个直连 CAN 的 ROS 节点,而是经 TCP robot.port=12345 接 gui_server,控制频率 fps=30(可调到 100),驱动板还设 Response=None(不回 ACK)。详见第 2 章 VLA 实战。