附录2 信号链路:MoveIt 收哪里的信号、怎么发信号(Gazebo / 真机)
很多人第一次把 MoveIt、Gazebo、真机接到一起会懵:MoveIt 到底从哪里知道"机器人现在长什么样"?又把"要去哪"发给谁?为什么同一套 MoveIt 配置,既能驱动仿真、又能驱动真机?这篇把整条信号链路一次讲透,并给一张总图。
配合附录1(Gazebo 物理仿真)看:附录1 讲"怎么把臂接进物理",本篇讲"信号怎么在 RViz / MoveIt / 控制器 / Gazebo / 真机 之间流动"。
一、一张图看懂整条链路
一句话:RViz → move_group(MoveIt)→ ros2_control 控制器 →(仿真 gz_ros2_control / 真机 episode_controller);而机器人的当前状态(/joint_states)再从最底层一路回流给 MoveIt。上半段(RViz→MoveIt→控制器)仿真和真机完全一样,只有最底层那一格不同。
二、MoveIt 收什么信号(输入)
| 收什么 | 类型 | 作用 |
|---|---|---|
/joint_states | topic(sensor_msgs/JointState) | 机器人当前关节角。MoveIt 靠它知道"现在长什么样"——RViz 里那个橙色实体就是它。仿真里由 joint_state_broadcaster 从 Gazebo 物理关节读出发布;真机里由 episode_controller 从编码器读出发布。话题名一样,所以 MoveIt 无感。 |
/planning_scene、/collision_object、/attached_collision_object | topic | 环境障碍物 / 手里抓的物体,供碰撞检查。 |
/move_action | action(moveit_msgs/MoveGroup) | RViz 的交互球或你的代码,把目标位姿/关节角发给 move_group 触发"规划+执行"。 |
robot_description / robot_description_semantic | param | URDF(模型)/ SRDF(规划组、免碰撞矩阵)。启动时加载。 |
三、MoveIt 发什么信号(输出)
| 发什么 | 类型 | 作用 |
|---|---|---|
/episode_arm_controller/follow_joint_trajectory | action(FollowJointTrajectory) | 规划成功后,move_group 把整条轨迹发给控制器去执行。这是 MoveIt → 硬件的唯一执行通道。 |
/display_planned_path | topic | 规划预览(RViz 里那条动画 / 灰色"重影"目标态)。 |
/monitored_planning_scene | topic | 维护中的规划场景。 |
关键: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_control 的 GazeboSimSystem | episode_controller 的 interface 节点 |
| "写命令"落到哪 | Gazebo 物理关节(施力/置位) | CAN 总线 → 真实电机(PCAN-USB) |
| "读状态"从哪来 | Gazebo 物理引擎 | 真实编码器 |
| 控制器名 | episode_arm_controller(特意同名) | episode_arm_controller |
| MoveIt 配置 | 共用(规划组 episode_arm、IKFast) | 共用 |
| 时间源 | use_sim_time=true,/clock 来自 gz | use_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_action | action | 你 → MoveIt | 发目标、触发 Plan&Execute |
/episode_arm_controller/follow_joint_trajectory | action | MoveIt → 控制器 | 下发臂轨迹 |
/gripper_controller/joint_trajectory | topic | 你/MoveIt → 控制器 | 夹爪开合 |
/joint_states | topic | 控制器 → MoveIt/RViz | 当前关节状态(回流) |
/check_state_validity | service | 你 → MoveIt | 查某状态是否自碰撞 |
/clock | topic | gz → 全体 | 仿真时钟(use_sim_time) |
八、为什么这么设计
整条链路只有最底层一格(硬件接口插件)区分仿真/真机,上面全部共用。好处:
- 数字孪生:仿真里调好的规划、写好的上层状态机/抓放逻辑,搬到真机几乎不用改——只换底层 launch。
- 先仿真后真机:在不损坏价值百万的真机前提下,反复在 Gazebo 里验证轨迹、碰撞、夹取。
- 同一套调试经验:
/joint_states、follow_joint_trajectory这些话题/动作在两边都一样,排查方法通用。
记住这张图,以后无论"臂不动""规划失败""真机和仿真对不上",都能顺着这条链路一段段定位:是 MoveIt 没规划出来?还是轨迹没发到控制器?还是控制器没驱动到硬件?还是 /joint_states 没回流?
九、附:/joint_states 发布频率 = CAN 询问频率(真机的"滋滋"电流声)
第二节说 /joint_states 是状态回流的入口。在真机这一格,它还藏着一个新手坑:发布这个话题的频率,和去 CAN 总线上"问电机角度"的频率是绑死的。理解这一点,能解释一个常见现象——一连上 ROS(还没让 MoveIt 动),机械臂就发出"滋滋"的电流声,把 ROS 进程关掉就没了;而用 gui_server(上位机)时没有这个声音。
9.1 为什么这两个频率是"同一个数"
真机后端 episode_controller 的 interface 节点,用一个定时器周期性发布 /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_broadcaster 按 controller_manager 的 update_rate(本教程配的是 100)发布,MoveIt / RViz 期待 /joint_states 是一条 ~100Hz 的连续高频流(画面顺滑、执行监控及时)。问题在于"读一次状态"的代价在两个后端天差地别:
| 后端 | 读一次关节状态 = 什么 | 120Hz 的代价 |
|---|---|---|
【仿真】gz_ros2_control | 从物理引擎读内存变量 | 几乎为零,随便拉多高 |
【真机】episode_controller | USB2CAN → 6 块 ZDT 驱动板的阻塞式 CAN 事务 | 把整条 CAN 总线打满 |
官方把仿真用的 120Hz 原样搬到真机,没考虑真实 CAN 的代价——这才是声音的根源。注意:这个频率只服务于"反馈/可视化",不影响运动执行(执行走的是 follow_joint_trajectory 那条独立通道),所以降低它对 Plan&Execute 毫无功能损失。
9.3 "滋滋"声物理上是怎么产生的
- 步进电机靠灌电流保持位置:关节停在某角度顶住重力,驱动板必须往线圈维持受控的直流电流。
- 这电流是 PWM 斩波给的:为效率,H 桥把全电压(24/36V)高速通断,靠线圈电感平均成目标电流。
- 每次电流脉冲都让电机微微振动:磁场让铁芯伸缩(磁致伸缩)、让通电线圈受力(洛伦兹力),铁芯和线圈以斩波/调制频率机械振动,推动空气=声音。
- 落在 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 实战。