1. 为什么实时遥操作值得单独拿出来讲
很多人第一次接触UR机械臂和ROS2的组合,注意力都放在运动规划上——怎么让机械臂从A点走到B点,怎么绕开障碍物,怎么把轨迹规划得平滑。这些确实是MoveIt2的强项,但真正到了需要"人机协同"的场景,比如拖动示教、远程操控、力反馈主从跟随,你会发现规划器那套"先算再走"的逻辑根本跟不上节奏。规划一次动辄几百毫秒甚至上秒级,而遥操作要求的是几十毫秒内响应,操作者手一动,机械臂就得跟着动,中间不能有肉眼可见的延迟。
MoveIt2 Servo就是为这个场景设计的。它绕开了完整的运动规划管线,直接把笛卡尔空间或关节空间的速度指令以高频流式发送给控制器,让机械臂进入一种"持续跟随"的状态。我第一次在UR5e上跑通Servo的时候,最大的感受是:这才是机械臂该有的手感。之前用规划的方式做拖动,机械臂一顿一顿的,像在走台阶;换成Servo之后,整个运动是连续的、跟手的,操作者能真正建立起"我在控制它"的直觉。
这篇内容适合已经装好ROS2和MoveIt2、手上有UR机械臂(或准备用仿真替代)、想搞明白Servo到底怎么配怎么调的人。我会从Servo的工作机制讲起,把配置文件的每个关键字段拆开说清楚,然后给出UR机械臂的完整实操流程,最后重点讲那些文档里不会写、但实际调试中一定会遇到的坑。关键词里提到的MoveIt2、Servo、UR机械臂、ROS2、遥操作,都会在具体操作中一一落地。
需要提前说明的是,Servo不是万能的。它适合的是"人在回路"的连续控制,不适合需要严格避障的自主任务。理解它的边界,比学会怎么启动它更重要。
2. Servo的工作机制:它到底绕过了什么
2.1 从规划管线到流式控制的本质区别
常规的MoveIt2运动规划,走的是这样一条链路:你给一个目标位姿,规划器在关节空间里搜索一条无碰撞轨迹,然后对轨迹做时间参数化,最后把带时间戳的轨迹点序列发给控制器执行。这条链路里最耗时的是碰撞检测和轨迹搜索,尤其是环境复杂的时候,规划时间可能到秒级。对于"点到点"的任务这没问题,但遥操作要的是"我现在往右推,机械臂立刻往右走",规划器根本来不及。
Servo的做法完全不同。它维护一个当前状态,接收来自输入设备(手柄、键盘、空间鼠标、或者另一个机械臂的关节状态)的速度指令,然后在一个固定的高频周期内(默认通常是几十赫兹到上百赫兹),把速度指令转换成关节速度,直接下发给控制器。整个过程没有轨迹搜索,没有碰撞检测(或者说碰撞检测是可选的、轻量的),只有"当前状态加增量"的迭代。这就是它能做到低延迟的根本原因。
用一个生活化的类比:规划就像你出门前查地图规划一条完整路线,Servo就像你开车时看着前方微调方向盘。前者适合长途陌生路段,后者适合近距离灵活操控。
2.2 Servo节点内部的几个关键环节
Servo在MoveIt2里是以一个独立节点的形式存在的,它订阅几个话题,发布一个关节轨迹话题。理解这几个环节,后面配置的时候才知道每个参数在管什么。
第一个环节是输入接收。Servo支持多种输入类型,最常见的是Twist指令(笛卡尔空间的速度)和JointJog指令(关节空间的速度)。Twist指令来自/servo_node/delta_twist_cmds话题,JointJog来自/servo_node/delta_joint_cmds。你用手柄或者键盘发布这些消息,Servo就收到了。
第二个环节是奇异点处理和速度缩放。笛卡尔速度要转成关节速度,需要用到雅可比矩阵的伪逆。当机械臂接近奇异位形时,雅可比矩阵条件数变差,求逆会得到巨大的关节速度,机械臂会突然猛冲。Servo内置了奇异点规避逻辑,会在接近奇异时自动降低笛卡尔速度,这个缩放行为由一组参数控制。
第三个环节是碰撞检测与自碰撞检查。Servo可以选择性地做碰撞检查,如果检测到即将碰撞,它会拒绝这次速度指令或者降低速度。这个功能在遥操作里很有用,但也会增加延迟,需要权衡。
第四个环节是指令平滑与限幅。Servo会对关节速度做限幅,确保不超过UR控制器允许的最大速度,同时可能做一些平滑处理避免抖动。
第五个环节是发布关节轨迹。最终Servo把计算出的关节位置(或速度)以JointTrajectory消息的形式发布到/servo_node/...对应的控制器话题上,UR的scaled_joint_trajectory_controller接收并执行。
2.3 为什么UR机械臂特别适合配Servo
UR系列机械臂(UR3、UR5、UR5e、UR10、UR10e等)在ROS2生态里的支持相当成熟,ur_robot_driver提供了完整的ROS2驱动,scaled_joint_trajectory_controller这个控制器天然支持高频的关节轨迹流式输入。这意味着Servo算出来的关节指令可以很顺畅地喂给UR控制器,中间不需要额外的适配层。
另外UR机械臂本身的速度限制比较友好,关节速度上限通常在180度每秒左右,Servo的限幅逻辑有足够的空间做平滑。相比之下,一些高速工业臂的速度上限很高,Servo如果不做严格限幅,很容易出现危险动作。UR的力控特性也让它在拖动示教场景下表现更好,配合Servo能做出很自然的手感。
3. 环境准备:从ROS2到UR驱动的完整链路
3.1 ROS2版本选择与MoveIt2安装
Servo是MoveIt2的一个组件,所以第一步是把ROS2和MoveIt2装好。ROS2版本的选择上,如果你是新装环境,建议直接用Humble或者Jazzy。Humble是LTS版本,生态最全,UR驱动和MoveIt2的兼容性经过大量验证;Jazzy是较新的LTS,Ubuntu 24.04上的默认选择,MoveIt2的新特性支持更好。我个人的建议是:如果你手上是Ubuntu 22.04,用Humble;如果是Ubuntu 24.04,用Jazzy。不要混搭,否则编译UR驱动的时候会遇到一堆依赖问题。
安装MoveIt2的命令很直接:
sudo apt install ros-humble-moveit如果你用的是Jazzy,把humble换成jazzy即可。装完之后用ros2 pkg list | grep moveit确认一下,应该能看到moveit_servo这个包。如果看不到,说明你的MoveIt2版本里没有包含Servo,需要从源码编译,这种情况在早期版本里出现过,现在的主流版本都已经包含了。
3.2 UR驱动安装与网络配置
UR机械臂的ROS2驱动是ur_robot_driver,安装命令:
sudo apt install ros-humble-ur-robot-driver如果你要用仿真替代真机,还需要装ur_simulation_gazebo或者用ros2_control的mock组件。真机的话,接下来是网络配置,这一步是新手最容易卡住的地方。
UR机械臂的控制柜有一个网口,默认IP通常是192.168.1.1或者你之前设置过的地址。你的电脑需要和它在同一个网段。我一般会把电脑的有线网口设成静态IP,比如192.168.1.100,子网掩码255.255.255.0。设置完之后先ping一下192.168.1.1,通了再往下走。
在UR的示教器上,需要做几件事:第一,确认Polyscope版本,e系列需要5.x以上;第二,在设置里找到"网络",确认IP;第三,在"程序"里安装external_control的URCap,这个URCap是ROS2驱动和UR控制器通信的桥梁,没有它驱动连不上。URCap的安装文件在ur_robot_driver的安装目录里能找到,具体路径可以用ros2 pkg prefix ur_robot_driver查。
3.3 工作空间与URDF准备
如果你只是用UR官方提供的描述文件,可以直接用ur_description包里的URDF。但实际项目里你往往在机械臂末端装了夹爪或者其他工具,需要自己组装URDF。我的做法是创建一个自己的工作空间,把ur_description和夹爪的描述文件通过xacro组合起来。
mkdir -p ~/ur_servo_ws/src cd ~/ur_servo_ws/src git clone https://github.com/UniversalRobots/Universal_Robots_ROS2_Description.git然后写一个顶层xacro文件,把UR的macro和夹爪的macro拼在一起。这里有个细节:Servo需要知道末端执行器的link名字,用来计算笛卡尔速度对应的参考坐标系。这个link名字在配置Servo的时候要用到,所以组装URDF的时候要记清楚末端link叫什么。
编译工作空间:
cd ~/ur_servo_ws colcon build --symlink-install source install/setup.bash--symlink-install这个参数建议加上,改xacro文件的时候不用重新编译,省很多时间。
4. Servo配置文件的逐字段拆解
4.1 servo.yaml的整体结构
Servo的配置通常放在一个yaml文件里,通过launch文件加载。这个文件的结构大致分几块:moveit_servo命名空间下的基础参数、robot_link_command_frame等坐标系参数、command_in_type输入类型、以及各种缩放和限幅参数。我见过很多人直接抄别人的配置文件,结果机械臂要么不动,要么乱动,根本原因是没搞懂每个字段在管什么。
先看一个最小可用的配置骨架:
moveit_servo: robot_link_command_frame: "base_link" command_in_type: "speed_units" scale: linear: 0.4 rotational: 0.8 joint: 0.5 smoothing_filter_plugin_name: "online_signal_smoothing::ButterworthFilterPlugin" low_pass_filter_coeff: 2.0 publish_period: 0.01 incoming_command_timeout: 0.1 num_outgoing_halt_msgs_to_publish: 4 cartesian_command_in_topic: "~/delta_twist_cmds" joint_command_in_topic: "~/delta_joint_cmds" joint_topic: "/joint_states" status_topic: "~/status" command_out_topic: "/scaled_joint_trajectory_controller/joint_trajectory" command_out_type: "trajectory_msgs/JointTrajectory" publish_joint_positions: true publish_joint_velocities: false publish_joint_accelerations: false check_collisions: true collision_check_rate: 10.0 self_collision_proximity_threshold: 0.01 scene_collision_proximity_threshold: 0.02 move_group_name: "ur_manipulator" planning_frame: "base_link" ee_frame_name: "tool0" is_primary_planning_scene_monitor: true下面逐块说。
4.2 输入类型与缩放系数
command_in_type有两个可选值:speed_units和unitless。speed_units表示输入的速度指令带物理单位,线速度是米每秒,角速度是弧度每秒。unitless表示输入是归一化的,Servo内部再乘以缩放系数。我建议用speed_units,因为这样你对速度有直观的控制,调试的时候心里有数。
scale下面的linear、rotational、joint分别控制线速度、角速度、关节速度的缩放。这三个值不是随便填的。linear设成0.4意味着你发一个1.0米每秒的Twist,实际执行的是0.4米每秒。为什么要缩放?因为输入设备的满量程往往对应一个很大的速度,直接映射会太快。我一般从0.3到0.5开始调,手感太慢就往上加,太冲就往下减。
rotational通常比linear大一些,因为角速度的感知阈值和线速度不一样。0.8到1.0是常见范围。joint用在JointJog模式下,0.5是个稳妥的起点。
4.3 平滑滤波与发布周期
smoothing_filter_plugin_name指定平滑滤波器,默认的Butterworth滤波器对速度指令做低通滤波,去掉输入设备的抖动。low_pass_filter_coeff控制滤波强度,值越大滤波越强但延迟也越大。2.0是个平衡点,如果你发现机械臂对手柄的快速动作响应迟钝,可以降到1.0;如果机械臂抖动明显,可以升到3.0。
publish_period是Servo发布关节指令的周期,0.01秒对应100赫兹。这个值要和UR控制器的接收能力匹配。UR的scaled_joint_trajectory_controller通常能接受100赫兹以上的指令流,但如果你设得太高,比如0.005秒,可能会因为网络或计算负载导致丢包。100赫兹对遥操作来说足够了,人手动作的频率远低于这个。
incoming_command_timeout是输入超时时间,0.1秒意味着如果100毫秒内没收到新的速度指令,Servo会认为输入设备断开了,开始发布停止指令。这个值不能太大,否则输入设备断开后机械臂还会继续动;也不能太小,否则网络抖动会导致误判。0.1秒是经验值。
4.4 碰撞检测的取舍
check_collisions打开后,Servo会在每次迭代时检查碰撞。collision_check_rate是检查频率,10赫兹意味着每100毫秒检查一次,不是每个发布周期都检查。这是为了控制计算负载。self_collision_proximity_threshold和scene_collision_proximity_threshold是距离阈值,单位是米,当机械臂和障碍物的距离小于这个值时,Servo会开始减速或停止。
这里有个重要的取舍:碰撞检测能提高安全性,但会增加延迟。在遥操作场景下,如果碰撞检测频率太低,操作者可能已经撞上去了才检测到;如果频率太高,计算负载上去了,发布周期可能不稳定。我的建议是:初期调试阶段打开碰撞检测,确认安全边界;熟练之后如果追求极致手感,可以关掉,但前提是你对工作空间有充分的把握。
4.5 坐标系与话题映射
robot_link_command_frame是速度指令的参考坐标系,通常设成base_link。这意味着你发的Twist是在基座坐标系下的。如果你希望指令在工具坐标系下,可以改成tool0,但这样操作逻辑会反过来,需要适应。
ee_frame_name是末端执行器的link名字,Servo用它来计算雅可比矩阵。这个必须和你的URDF里末端link的名字一致,否则Servo启动时会报错或者计算出错误的关节速度。
command_out_topic是Servo发布关节轨迹的话题,必须和UR控制器订阅的话题一致。用ros2 topic list确认一下/scaled_joint_trajectory_controller/joint_trajectory是否存在。如果UR驱动启动后这个话题名字不一样,Servo发的指令就没人接。
5. UR机械臂上的完整实操流程
5.1 启动UR驱动与MoveIt2
真机的话,先在示教器上运行external_control程序,让UR进入远程控制模式。然后在电脑上启动驱动:
ros2 launch ur_robot_driver ur_control.launch.py ur_type:=ur5e robot_ip:=192.168.1.1把ur_type换成你的型号,robot_ip换成实际IP。启动成功后,用ros2 control list_controllers应该能看到scaled_joint_trajectory_controller处于active状态。
接着启动MoveIt2:
ros2 launch ur_moveit_config ur_moveit.launch.py ur_type:=ur5e launch_servo:=true注意launch_servo:=true这个参数,它会同时启动Servo节点。如果你的ur_moveit_config版本不支持这个参数,需要手动启动Servo节点,或者在自己的launch文件里include servo的launch。
5.2 用键盘做第一次遥操作测试
Servo自带一个键盘控制的示例节点,叫servo_keyboard_input。启动它:
ros2 run moveit_servo servo_keyboard_input然后在终端里按方向键,机械臂应该会跟着动。第一次测试的时候,把速度缩放调小一点,比如linear: 0.1,确认方向正确、没有异常抖动之后再往上加。
键盘控制的按键映射是这样的:上下左右控制X和Y方向的线速度,W和S控制Z方向,A和D控制绕Z轴的角速度,等等。具体映射可以在servo_keyboard_input的源码里看到。第一次用的时候建议把机械臂的末端抬到工作空间中间,周围留出足够的安全距离。
5.3 用游戏手柄做更自然的操控
键盘控制是离散的,按一下动一下,手感不好。真正做遥操作,游戏手柄或者空间鼠标更合适。我用的是一个普通的Xbox手柄,通过joy包发布Joy消息,然后写一个转换节点把Joy消息转成Twist发到/servo_node/delta_twist_cmds。
转换节点的逻辑很简单:读手柄的摇杆值,左摇杆控制XY平面移动,右摇杆控制旋转,扳机控制Z轴。摇杆值范围是-1到1,乘以一个最大速度系数,就得到了Twist。这里要注意死区处理,摇杆回中时可能有微小偏移,不加死区的话机械臂会缓慢漂移。
import rclpy from rclpy.node import Node from sensor_msgs.msg import Joy from geometry_msgs.msg import TwistStamped class JoyToTwist(Node): def __init__(self): super().__init__('joy_to_twist') self.sub = self.create_subscription(Joy, '/joy', self.joy_cb, 10) self.pub = self.create_publisher(TwistStamped, '/servo_node/delta_twist_cmds', 10) self.max_linear = 0.3 self.max_angular = 0.8 self.deadzone = 0.1 def apply_deadzone(self, val): if abs(val) < self.deadzone: return 0.0 return val def joy_cb(self, msg): twist = TwistStamped() twist.header.stamp = self.get_clock().now().to_msg() twist.header.frame_id = 'base_link' twist.twist.linear.x = self.apply_deadzone(msg.axes[1]) * self.max_linear twist.twist.linear.y = self.apply_deadzone(msg.axes[0]) * self.max_linear twist.twist.linear.z = self.apply_deadzone(msg.axes[4]) * self.max_linear twist.twist.angular.z = self.apply_deadzone(msg.axes[3]) * self.max_angular self.pub.publish(twist) def main(): rclpy.init() node = JoyToTwist() rclpy.spin(node) rclpy.shutdown()这个节点跑起来之后,手柄一动,机械臂就跟着动。手感比键盘好太多,尤其是做精细的接近操作时,手柄的模拟量输入能让你控制得很细腻。
5.4 验证Servo状态与调试输出
Servo会发布一个status话题,类型是moveit_msgs/ServoStatus。用ros2 topic echo /servo_node/status可以看到当前状态,包括是否处于奇异点附近、是否检测到碰撞、当前的速度缩放系数等。调试的时候盯着这个话题,能快速定位问题。
如果机械臂不动,先检查command_out_topic是否正确,用ros2 topic hz看Servo有没有在发布指令。如果Servo在发但机械臂不动,检查UR控制器是否active,以及话题名字是否匹配。如果机械臂动了但方向不对,检查robot_link_command_frame和Twist的frame_id是否一致。
6. 调试中一定会遇到的几个坑
6.1 奇异点附近的突然猛冲
这是Servo调试中最危险的问题。当机械臂接近奇异位形(比如手腕关节对齐、或者手臂完全伸直),雅可比矩阵求逆会得到极大的关节速度,机械臂会突然朝某个方向猛冲。Servo虽然有奇异点规避,但默认参数不一定够用。
我的处理办法是:第一,把scale里的linear和rotational都调小,给规避逻辑留出反应时间;第二,在Servo配置里找到奇异点相关的参数,通常有lower_singularity_threshold和hard_stop_singularity_threshold,前者是开始减速的阈值,后者是强制停止的阈值,把这两个值调得更保守一些;第三,在操作时尽量让机械臂远离奇异位形,比如手腕不要完全伸直。
如果你发现机械臂在某个特定姿态附近总是抖或者冲,用ros2 topic echo /servo_node/status看一下singularity字段,确认是不是奇异点问题。
6.2 输入超时导致的间歇性停止
incoming_command_timeout设得太小,网络稍微抖一下,Servo就认为输入断开了,开始发停止指令,机械臂一顿一顿的。设得太大,输入设备真断开了,机械臂还在动,很危险。
我的经验是:如果用手柄通过USB接收器连接,延迟很稳定,0.1秒够用;如果是无线连接或者网络传输,可能要放宽到0.2秒。但放宽的同时,要在输入节点里加一个"心跳"机制,即使摇杆没动也定期发零速度指令,避免超时误判。
6.3 碰撞检测的误报与漏报
Servo的碰撞检测用的是MoveIt2的PlanningScene,如果你的URDF里包含了夹爪,但PlanningScene里没有更新夹爪的碰撞体,Servo可能会误报碰撞。反过来,如果场景里的障碍物没有及时更新,Servo可能漏报。
调试的时候,先用ros2 topic echo /servo_node/status确认碰撞检测的状态。如果频繁误报,检查PlanningScene里的碰撞体是否和实际一致。如果用的是MoveIt2的move_group节点,它默认会维护PlanningScene,但如果你自己写了场景更新逻辑,要确保更新频率足够。
6.4 UR控制器的速度限制与Servo限幅的冲突
UR控制器有自己的关节速度限制,Servo也有自己的限幅。如果Servo算出的关节速度超过了UR的限制,UR控制器会拒绝执行或者报错。表现是机械臂突然停住,ros2 control的日志里能看到速度超限的警告。
解决办法是在Servo配置里把关节速度限幅设得比UR的限制更保守。UR5e的关节速度上限大约是180度每秒,换算成弧度大约是3.14弧度每秒。Servo的限幅参数通常叫max_joint_velocity或者类似的,把它设成2.5弧度每秒左右,留出余量。
6.5 手柄死区与漂移
手柄摇杆回中不精确是常见问题,不加死区的话,机械臂会缓慢漂移。死区设成0.1到0.15比较合适,太小了漂移,太大了操作不灵敏。另外,不同手柄的摇杆特性不一样,Xbox手柄和PS手柄的死区范围就不同,需要实际测试调整。
还有一个细节:手柄的扳机键在某些驱动里默认范围是-1到1,而不是0到1,如果不做映射,松开扳机时机械臂会朝反方向动。这个坑我在第一次用手柄的时候踩过,排查了半天才发现是扳机范围的问题。
7. 从遥操作到动态响应的进阶思路
7.1 用JointJog做关节空间的精细操控
Twist指令是在笛卡尔空间操作的,适合大范围移动。但有些场景需要单独控制某个关节,比如调整手腕姿态去对准一个孔位。这时候用JointJog更直接。Servo订阅/servo_node/delta_joint_cmds话题,消息类型是control_msgs/JointJog,里面指定关节名字和速度。
JointJog的好处是绕开了雅可比矩阵求逆,不会遇到奇异点问题,而且每个关节独立控制,精度更高。缺点是操作者需要理解关节的运动方向,不如笛卡尔空间直观。我的做法是:粗调用Twist,精调用JointJog,两者结合。
7.2 结合力反馈做双向遥操作
如果主端和从端都是UR机械臂,可以做双向遥操作:操作者拖动主端机械臂,从端跟随,同时从端受到的力反馈回主端。这需要用到UR的力控接口和Servo的实时性。
实现思路是:主端的关节状态通过Servo的JointJog模式发给从端,从端的关节电流或力矩通过UR的RTDE接口读回来,经过缩放后作为主端的力矩指令。这个链路对实时性要求很高,Servo的100赫兹发布周期基本够用,但力反馈的更新频率可能需要更高,要考虑用UR的RTDE直接通信,绕过ROS2的话题机制。
7.3 用Servo做视觉伺服
Servo的笛卡尔速度指令可以来自视觉系统。比如相机检测到目标物体的位姿偏差,把这个偏差转换成速度指令发给Servo,机械臂就会朝着减小偏差的方向移动。这就是视觉伺服的基本逻辑。
视觉伺服的难点在于延迟和稳定性。相机的帧率通常只有30赫兹,比Servo的100赫兹低,如果直接把相机输出映射成速度,会有明显的滞后和振荡。我的做法是在中间加一个滤波器,对视觉输出的速度指令做平滑,同时降低Servo的响应速度,让整个环路稳定下来。
7.4 性能调优的几个观察点
调Servo性能的时候,我一般关注这几个指标:第一,从输入设备动作到机械臂开始动的延迟,用高速相机或者示波器测,理想情况在50毫秒以内;第二,机械臂运动的平滑度,有没有抖动或者顿挫;第三,奇异点附近的稳定性,会不会突然减速或者猛冲;第四,长时间运行的稳定性,有没有内存泄漏或者话题堵塞。
这些指标里,延迟是最难优化的,因为它涉及输入设备、ROS2通信、Servo计算、UR控制器执行多个环节。我的经验是:把Servo节点和UR驱动放在同一台电脑上,用本地回环通信,能省掉网络延迟;输入设备用有线连接,避免无线延迟;Servo的publish_period不要设得太小,100赫兹是个平衡点。
8. 一些实际项目中的经验体会
我在几个不同的项目里用过Servo,有实验室的UR5e,也有集成到产线上的UR10e。最大的体会是:Servo的配置没有一套放之四海而皆准的参数,必须根据具体的机械臂型号、末端工具、操作任务来调。同一个配置文件,在UR5e上跑得很顺,换到UR10e上可能就因为惯量不同而抖动。
另一个体会是:安全边界一定要提前想清楚。Servo让机械臂变得很"活",操作者很容易在兴奋之下做出大幅度的动作。我一般会在工作空间周围设置虚拟墙,用MoveIt2的PlanningScene加碰撞体,Servo的碰撞检测会阻止机械臂越过虚拟墙。这个措施在调试初期特别重要,能避免很多意外。
还有就是日志和回放。Servo的调试过程中,很多问题是偶发的,比如某个特定姿态下的抖动、某个速度下的超调。我习惯用ros2 bag把/joint_states、/servo_node/status、/servo_node/delta_twist_cmds这几个话题录下来,出问题的时候回放分析。这个习惯帮我定位过好几次难以复现的问题。
最后说一个细节:UR机械臂的scaled_joint_trajectory_controller在接收高频指令时,如果指令的时间戳不连续或者跳跃,控制器可能会报错。Servo发布的时间戳是基于ROS时钟的,如果系统时钟不稳定(比如用了NTP同步导致时钟跳变),会出现时间戳回退。解决办法是用use_sim_time或者确保系统时钟稳定。这个坑比较隐蔽,但一旦遇到,表现是机械臂随机停止,日志里能看到时间戳相关的错误。