1. 为什么要在ROS2里折腾MoveIt2 Servo
如果你手头有一台UR机械臂,不管是UR3、UR5还是UR10,并且你正在用ROS2做开发,那你大概率已经踩过MoveIt2的坑了。MoveIt2负责运动规划,这个没问题,但一旦你想让机械臂跟着手柄或者外部输入实时动起来,传统MoveIt2的规划-执行流程就会让你抓狂。原因很简单:MoveIt2默认的规划器是“先算一条完整轨迹,再交给控制器执行”,这个链路里规划耗时从几十毫秒到几百毫秒不等,对于需要高频响应的遥操作场景来说,延迟感非常明显。
MoveIt2 Servo就是来解决这个问题的。它的核心思路是把“规划”这一步拆掉,直接根据当前机器人状态和输入指令,在每个控制周期内计算出一个关节速度指令,然后通过ROS2的实时控制接口发给机器人。换句话说,Servo绕过了传统规划器,走了一条“感知-伺服”的短路径。我第一次在UR5上跑通Servo的时候,用手柄推着末端走,那种跟手的感觉确实和之前用MoveIt2规划完全不一样。
这篇文章适合谁看?如果你已经装好了ROS2和MoveIt2,手里有UR机械臂或者正在用UR的仿真模型,想实现实时遥操作、手柄拖拽、或者视觉伺服这类动态响应场景,那接下来的内容可以直接抄作业。如果你还没装ROS2,建议先把基础环境搭好,否则后面每一步都会卡住。
2. 环境准备与核心依赖梳理
2.1 ROS2版本选择与UR驱动安装
UR机械臂在ROS2下的官方驱动是ur_robot_driver,这个包从ROS2 Foxy开始就有,但不同版本差异很大。我实测下来,Humble和Jazzy是目前最稳的两个选择。Humble的社区资料最多,遇到问题容易搜到答案;Jazzy比较新,但MoveIt2 Servo的接口更完善。如果你用的是Ubuntu 22.04,直接上Humble;如果是Ubuntu 24.04,那就Jazzy。
安装UR驱动的方式有两种:二进制安装和源码编译。二进制安装快,但版本可能不是最新的;源码编译慢,但可以自己改代码。我建议先用二进制把流程跑通,后面有定制需求再切源码。
# Humble下安装UR驱动 sudo apt install ros-humble-ur-robot-driver ros-humble-ur-description # Jazzy下安装 sudo apt install ros-jazzy-ur-robot-driver ros-jazzy-ur-descriptionMoveIt2 Servo的包名是moveit_servo,在MoveIt2的二进制发行版里已经包含了。如果你装的是完整版MoveIt2,这个包应该已经有了。检查一下:
ros2 pkg list | grep moveit_servo如果没有输出,说明你的MoveIt2安装不完整,需要补装:
sudo apt install ros-humble-moveit-servo2.2 UR机械臂的ROS2描述文件配置
UR机械臂的URDF在ur_description包里,但直接拿来用还不够,因为Servo需要知道每个关节的名称、限位、以及末端执行器的参考坐标系。我建议自己写一个launch文件,把URDF加载到参数服务器,同时启动robot_state_publisher。
这里有个坑:UR的URDF默认关节名称是shoulder_pan_joint、shoulder_lift_joint、elbow_joint、wrist_1_joint、wrist_2_joint、wrist_3_joint,Servo的配置文件里必须和这些名称完全一致,否则会报“joint not found”的错误。我见过有人因为把wrist_1_joint写成了wrist_1,排查了一下午。
# ur_servo_launch.py 片段 from launch import LaunchDescription from launch_ros.actions import Node from ament_index_python.packages import get_package_share_directory import os def generate_launch_description(): ur_description_path = get_package_share_directory('ur_description') urdf_file = os.path.join(ur_description_path, 'urdf', 'ur5.urdf.xacro') # 实际使用时需要用xacro解析,这里简化示意 robot_description = {'robot_description': open(urdf_file).read()} return LaunchDescription([ Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[robot_description] ), Node( package='moveit_servo', executable='servo_node', parameters=[os.path.join(get_package_share_directory('your_pkg'), 'config', 'servo_config.yaml')] ) ])2.3 Servo核心配置文件详解
Servo的行为几乎完全由YAML配置文件决定。这个文件里最关键的几个参数我列一下,每个都解释为什么这么设。
# servo_config.yaml servo: # 发布频率,单位Hz。UR机械臂的控制器一般跑在125Hz或500Hz publish_period: 0.008 # 125Hz # 关节速度缩放因子,0到1之间。太小了动得慢,太大了容易超速 scale: linear: 0.3 rotational: 0.5 joint: 0.5 # 碰撞检查相关 check_collisions: true collision_check_rate: 10.0 # Hz,太高了会拖慢主循环 # 奇异点处理 lower_singularity_threshold: 17.0 hard_stop_singularity_threshold: 30.0 # 命令类型:twist表示笛卡尔速度,joint表示关节速度 command_in_type: "speed_units" command_out_type: "trajectory_msgs/JointTrajectory" # 平滑滤波,减少抖动 smoothing_filter_plugin: "online_signal_smoothing::ButterworthFilterPlugin" butterworth_filter_coeff: 2.0publish_period这个参数我单独说一下。UR的ur_robot_driver默认的控制器更新频率是125Hz,所以Servo的发布周期设成0.008秒正好匹配。如果你设成0.004秒(250Hz),Servo会发得比控制器快,多出来的指令会被丢弃,反而增加CPU负担。我试过设成0.002秒,结果CPU直接飙到80%,机械臂还出现了轻微抖动。
scale参数是速度缩放,线性速度0.3意味着最大线速度被限制在配置的max_linear_velocity的30%。这个值需要根据你的实际场景调。遥操作手柄的话,0.2到0.5之间比较合适;如果是视觉伺服,可能需要更小,0.1左右,因为视觉反馈本身有延迟。
3. Servo的核心原理与实时性保障
3.1 Servo到底是怎么算速度的
很多人以为Servo内部有什么复杂的规划算法,其实它的核心逻辑非常直接。每个控制周期,Servo做以下几件事:
- 读取当前机器人关节位置(通过
/joint_states话题) - 读取输入指令(手柄的Twist消息或者关节速度指令)
- 计算雅可比矩阵,把笛卡尔速度映射到关节速度
- 做奇异点检测和碰撞检查
- 对关节速度做平滑滤波
- 发布
JointTrajectory消息给控制器
这里面最关键的是第3步。雅可比矩阵描述了关节速度到末端笛卡尔速度的映射关系。Servo用的是机器人的当前构型来计算雅可比,所以它是“瞬时”的,不涉及未来轨迹。这也是为什么Servo响应快——它只算当前这一帧。
但雅可比矩阵有个问题:在奇异点附近,雅可比会变得病态,求逆会得到非常大的关节速度。Servo用lower_singularity_threshold和hard_stop_singularity_threshold来处理这个问题。当奇异值低于下限阈值时,Servo会开始缩放速度;低于硬停阈值时,直接停止运动。这两个值的单位是“奇异值”,不是角度,所以不要试图用角度去理解。
3.2 实时性从哪来
Servo的实时性保障来自几个方面。首先是单线程执行,Servo的主循环在一个独立的线程里跑,不依赖ROS2的默认回调组。这意味着即使你的其他节点在忙,Servo的循环也不会被阻塞。其次是零拷贝,Servo内部尽量使用引用传递,避免大消息的拷贝开销。最后是固定周期,Servo用rclcpp::WallRate来控制循环周期,而不是用spin,这样即使没有新消息,循环也会按时执行。
但这里有个隐藏的坑:如果你的/joint_states话题发布频率不够,Servo算出来的速度就会基于过时的状态。UR驱动默认的/joint_states频率是125Hz,和Servo的周期匹配。如果你用的是仿真,比如Gazebo,/joint_states的频率可能只有50Hz甚至更低,这时候Servo的响应就会明显变差。解决办法是在Gazebo的URDF里把<update_rate>调高,或者用ros2 topic hz /joint_states确认一下实际频率。
3.3 输入指令的两种模式
Servo支持两种输入模式:twist和joint。twist模式下,你发一个geometry_msgs/Twist消息,Servo自己算雅可比;joint模式下,你直接发关节速度,Servo只做限幅和平滑。
遥操作场景一般用twist,因为手柄输出的就是笛卡尔速度。但如果你要做力控或者关节空间的示教,joint模式更直接。我两种都试过,twist模式在UR5上从手柄到运动的延迟大概在20到30毫秒,joint模式能降到10毫秒左右,因为少了一步雅可比计算。
# twist模式下发指令 ros2 topic pub /servo_node/delta_twist_cmds geometry_msgs/msg/Twist \ "{linear: {x: 0.01, y: 0.0, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}" # joint模式下发指令 ros2 topic pub /servo_node/delta_joint_cmds sensor_msgs/msg/JointState \ "{velocity: [0.1, 0.0, 0.0, 0.0, 0.0, 0.0]}"注意twist消息里的单位是米每秒和弧度每秒,不是每周期。Servo内部会根据publish_period把它转换成每周期的增量。我见过有人把0.01米每秒写成了0.01米每周期,结果机械臂动得像疯了一样。
4. 从零搭建UR5 Servo遥操作节点
4.1 创建功能包与目录结构
先建一个功能包,名字随便,我习惯叫ur_servo_teleop。依赖里要包含moveit_servo、geometry_msgs、sensor_msgs、rclcpp。
ros2 pkg create --build-type ament_cmake ur_servo_teleop \ --dependencies moveit_servo geometry_msgs sensor_msgs rclcpp目录结构大概是这样:
ur_servo_teleop/ ├── CMakeLists.txt ├── package.xml ├── config/ │ ├── servo_config.yaml │ └── ur5_servo_params.yaml ├── launch/ │ └── ur5_servo_teleop.launch.py └── src/ └── teleop_node.cppconfig目录放Servo的配置,launch目录放启动文件,src放遥操作节点。遥操作节点负责读手柄输入,转成Twist消息发给Servo。
4.2 手柄输入到Twist的转换
我用的是Xbox手柄,ROS2下用joy包读输入。joy包发布sensor_msgs/Joy消息,里面是按钮和轴的值。左摇杆控制XY平移,右摇杆控制Z平移和偏航,这个映射需要自己写。
// teleop_node.cpp 核心片段 #include <rclcpp/rclcpp.hpp> #include <sensor_msgs/msg/joy.hpp> #include <geometry_msgs/msg/twist.hpp> class TeleopNode : public rclcpp::Node { public: TeleopNode() : Node("teleop_node") { joy_sub_ = this->create_subscription<sensor_msgs::msg::Joy>( "/joy", 10, std::bind(&TeleopNode::joyCallback, this, std::placeholders::_1)); twist_pub_ = this->create_publisher<geometry_msgs::msg::Twist>( "/servo_node/delta_twist_cmds", 10); } private: void joyCallback(const sensor_msgs::msg::Joy::SharedPtr msg) { auto twist = geometry_msgs::msg::Twist(); // 左摇杆:轴0和轴1 twist.linear.x = msg->axes[1] * 0.1; // 前后 twist.linear.y = msg->axes[0] * 0.1; // 左右 // 右摇杆:轴3和轴4 twist.linear.z = msg->axes[4] * 0.1; // 上下 twist.angular.z = msg->axes[3] * 0.5; // 偏航 twist_pub_->publish(twist); } rclcpp::Subscription<sensor_msgs::msg::Joy>::SharedPtr joy_sub_; rclcpp::Publisher<geometry_msgs::msg::Twist>::SharedPtr twist_pub_; };这里0.1和0.5是缩放系数,对应最大线速度0.1米每秒和最大角速度0.5弧度每秒。实际用的时候可以根据手感调。我一开始设的0.3,结果手柄稍微一推机械臂就窜出去了,后来降到0.1才觉得跟手。
4.3 启动文件与参数加载
launch文件要把UR驱动、MoveIt2的move_group、Servo节点、遥操作节点都拉起来。顺序很重要:先起robot_state_publisher和UR驱动,等/joint_states有数据了再起Servo,否则Servo会因为拿不到初始状态而报错。
# ur5_servo_teleop.launch.py from launch import LaunchDescription from launch_ros.actions import Node from launch.actions import TimerAction from ament_index_python.packages import get_package_share_directory import os def generate_launch_description(): pkg_path = get_package_share_directory('ur_servo_teleop') servo_node = Node( package='moveit_servo', executable='servo_node', parameters=[os.path.join(pkg_path, 'config', 'servo_config.yaml')], output='screen' ) teleop_node = Node( package='ur_servo_teleop', executable='teleop_node', output='screen' ) # 延迟3秒启动Servo,等UR驱动就绪 delayed_servo = TimerAction(period=3.0, actions=[servo_node]) delayed_teleop = TimerAction(period=5.0, actions=[teleop_node]) return LaunchDescription([ delayed_servo, delayed_teleop ])TimerAction这个技巧很实用。UR驱动启动后需要几秒钟来建立和真实机械臂的通信,如果Servo起得太早,它会一直等/joint_states,虽然不会崩,但日志里会刷一堆警告。延迟3秒是个经验值,具体看你的网络和控制器响应速度。
5. 调试与常见问题排查
5.1 Servo不响应指令的排查思路
这是最常见的问题。你发了Twist消息,但机械臂纹丝不动。排查顺序如下:
第一步,确认Servo节点在运行。ros2 node list看看有没有servo_node。如果没有,检查launch文件里的包名和可执行文件名对不对。
第二步,确认Servo订阅了正确的话题。ros2 topic list看看有没有/servo_node/delta_twist_cmds。如果没有,说明Servo的配置里command_in_type设错了,或者Servo根本没起来。
第三步,确认Servo发布了输出。ros2 topic echo /servo_node/status看看状态码。Servo的状态码含义如下:
| 状态码 | 含义 | 处理方式 |
|---|---|---|
| 0 | 正常 | 继续 |
| 1 | 奇异点接近 | 调整机械臂构型 |
| 2 | 碰撞即将发生 | 检查碰撞场景 |
| 3 | 关节限位接近 | 换方向 |
| 4 | 无有效指令 | 检查输入话题 |
| 5 | 未初始化 | 等/joint_states |
第四步,确认UR控制器在接收。ros2 topic echo /joint_trajectory_controller/joint_trajectory看看有没有消息。如果没有,说明Servo的输出话题和UR控制器的输入话题不匹配。UR驱动默认的控制器话题是/scaled_joint_trajectory_controller/joint_trajectory,不是/joint_trajectory_controller。这个坑我踩过,Servo的配置里command_out_topic必须和UR控制器的话题一致。
5.2 机械臂抖动或运动不平滑
抖动一般来自三个原因:滤波参数不合适、/joint_states频率不够、或者速度缩放太大。
滤波参数butterworth_filter_coeff默认是2.0,这个值越大滤波越强,但延迟也越大。如果抖动不严重,可以降到1.0;如果抖动很明显,可以升到4.0,但响应会变慢。我一般设在2.0到3.0之间。
/joint_states频率的问题前面提过,用ros2 topic hz /joint_states确认。如果低于100Hz,考虑换用UR的实时数据接口,或者降低Servo的publish_period来匹配。
速度缩放太大的话,机械臂会在目标位置附近来回过冲。把scale.linear降到0.1试试,如果抖动消失,说明就是缩放的问题。
5.3 奇异点附近的处理策略
UR5的奇异点主要出现在手臂完全伸直或者手腕关节对齐的时候。Servo的lower_singularity_threshold默认是17.0,这个值对应的是雅可比矩阵的最小奇异值。当最小奇异值低于17时,Servo开始缩放速度;低于30时(hard_stop_singularity_threshold),直接停止。
实际使用中,17和30这两个值对UR5来说偏保守,机械臂在离奇异点还有一段距离时就开始减速了。如果你觉得减速太早,可以适当降低,比如设成10和20。但不要设得太低,否则在奇异点附近关节速度会突然变得很大,可能触发UR控制器的保护。
注意:调整奇异点阈值后,一定要在低速下测试。我见过有人把阈值设成5和10,结果机械臂在奇异点附近直接甩了一下,差点撞到桌子。
5.4 碰撞检查的性能影响
check_collisions设为true时,Servo每个周期都会调用MoveIt2的碰撞检查。这个检查本身不慢,但如果你的碰撞场景很复杂(比如有很多障碍物),它会成为瓶颈。我实测过,一个包含20个障碍物的场景,碰撞检查会让Servo的周期从8毫秒增加到15毫秒,直接导致控制频率下降。
如果你的场景不需要碰撞检查,直接关掉。如果需要,考虑降低collision_check_rate,比如从10Hz降到5Hz。这样碰撞检查不是每个周期都做,而是隔几个周期做一次,对实时性的影响小很多。
6. 进阶:视觉伺服与动态避障的扩展思路
Servo跑通之后,你可以把它和视觉结合起来做视觉伺服。基本思路是用相机检测目标位姿,算出当前位姿和目标位姿的误差,转成Twist消息发给Servo。这样机械臂就能跟着目标动。
我试过用RealSense D435i做这个,流程是:D435i发布点云,用find_object_2d或者自己写的节点检测目标,算出目标在相机坐标系下的位姿,再转换到机械臂基坐标系,最后算Twist。延迟大概在50到80毫秒,比手柄遥操作慢,但对于慢速跟踪场景够用了。
动态避障是另一个方向。Servo本身不做避障,但你可以把障碍物的位置实时更新到MoveIt2的碰撞场景里,Servo的碰撞检查会自动考虑这些障碍物。更新碰撞场景的频率不用太高,5到10Hz就够了,因为障碍物一般不会瞬间移动。
提示:视觉伺服和动态避障都会增加计算负载,建议在独立的线程或节点里做,不要塞进Servo的主循环。Servo的主循环越干净,实时性越好。
7. 我踩过的几个坑和对应解法
第一个坑是UR控制器的模式切换。UR机械臂上电后默认是Position模式,Servo需要的是Velocity模式。如果你不在UR的示教器上手动切模式,Servo发的速度指令会被忽略。解决办法是在UR驱动启动时通过ur_robot_driver的script_command接口自动切模式,或者每次上电后手动切一次。
第二个坑是**/joint_states的时间戳**。Servo会用/joint_states的时间戳来判断数据是否新鲜。如果UR驱动发布的时间戳和系统时间不同步(比如用了仿真时间),Servo会认为数据过期而拒绝执行。解决办法是在Servo的配置里把use_sim_time设成和UR驱动一致。
第三个坑是Servo的启动顺序。前面提过要延迟启动,但延迟多久合适?我的经验是:等/joint_states话题的发布频率稳定在100Hz以上再启动Servo。你可以写一个简单的脚本,用ros2 topic hz检测,稳定了再启动Servo节点。
第四个坑是手柄的死区。Xbox手柄的摇杆在中间位置会有微小漂移,如果不设死区,机械臂会慢慢往一个方向漂。解决办法是在遥操作节点里加一个死区判断,绝对值小于0.05的轴值直接置零。
double applyDeadzone(double value, double deadzone = 0.05) { if (std::abs(value) < deadzone) return 0.0; // 线性映射,把死区外的值重新缩放到0到1 return (value - std::copysign(deadzone, value)) / (1.0 - deadzone); }这个函数我用了很久,效果很好。死区大小可以根据手柄的新旧程度调,新手柄0.03就够了,旧手柄可能要0.08。
8. 性能实测与参数调优记录
我在UR5上做了一组对比测试,硬件是Intel NUC11,Ubuntu 22.04,ROS2 Humble。测试内容是让机械臂末端沿直线来回运动,记录实际轨迹和指令轨迹的偏差。
| 参数组合 | 平均延迟(ms) | 最大偏差(mm) | CPU占用(%) |
|---|---|---|---|
| scale=0.5, filter=2.0 | 25 | 3.2 | 35 |
| scale=0.3, filter=2.0 | 22 | 1.8 | 32 |
| scale=0.3, filter=3.0 | 30 | 1.2 | 33 |
| scale=0.1, filter=2.0 | 20 | 0.9 | 30 |
| scale=0.3, filter=2.0, 关碰撞 | 18 | 1.8 | 25 |
从数据看,scale=0.3是个比较平衡的选择,延迟和偏差都在可接受范围。滤波系数从2.0升到3.0,偏差减小了但延迟增加了8毫秒,对于遥操作来说这8毫秒是能感觉到的。关掉碰撞检查能省7毫秒和7%的CPU,如果你的场景不需要碰撞检查,这个优化很划算。
还有一个发现:publish_period从0.008秒降到0.004秒,延迟只减少了2毫秒,但CPU占用从32%涨到了48%。所以没必要追求过高的发布频率,匹配UR控制器的125Hz就够了。
注意:这些数据是在特定硬件和网络环境下测的,你的结果可能不同。建议自己跑一遍,用
ros2 topic delay和ros2 topic hz记录实际数据,再决定参数。
9. 从仿真到实机的迁移要点
仿真里跑通不代表实机上没问题。从Gazebo迁移到真实UR5,有几个地方必须改。
第一,URDF的关节限位。仿真里限位可以随便设,实机上必须和UR的实际限位一致。UR5的关节限位是±360度,但实际使用中一般不会用到极限位置。Servo的配置里joint_limit_margin设成0.1弧度,留一点余量。
第二,控制器的命名。仿真里控制器叫joint_trajectory_controller,实机上UR驱动默认的是scaled_joint_trajectory_controller。Servo的command_out_topic必须改成实机的话题名。
第三,网络延迟。仿真里没有网络延迟,实机上UR控制器和上位机之间的网络延迟会影响Servo的响应。如果延迟超过10毫秒,考虑用UR的实时数据接口(RTDE)来获取关节状态,比ROS2话题快。
第四,安全配置。实机上一定要设UR的安全限位,包括关节速度限位、末端力限位、以及安全平面。Servo的速度缩放再小,也不能替代安全配置。我一般把UR的安全关节速度设在30度每秒,末端力设在50牛顿,这样即使Servo出问题,机械臂也不会造成伤害。
10. 后续可以继续折腾的方向
Servo跑通之后,你可以往几个方向扩展。一个是多机械臂协同,用两个Servo节点分别控制两台UR,通过ROS2的命名空间隔离话题。另一个是力控融合,把UR的力传感器数据和Servo的速度指令结合,做柔顺控制。还有一个是学习型控制,用强化学习训练一个策略网络,输出Twist指令给Servo,实现自主操作。
我个人最感兴趣的是把Servo和ROS2的ros2_control框架深度结合,用ros2_control的实时控制器来替代Servo的输出接口。这样整个控制链路都在ros2_control里,实时性更有保障。不过这个工作量比较大,需要自己写控制器插件,适合对实时性要求极高的场景。
最后分享一个小技巧:Servo的日志级别默认是INFO,每周期都会打印状态信息,日志量很大。把日志级别调到WARN,能显著减少I/O开销,对实时性有好处。在launch文件里加一行:
parameters=[{'log_level': 'WARN'}]这个改动看起来小,但在长时间运行中能省不少CPU。我实测过,日志从INFO降到WARN,CPU占用降了3到5个百分点。