URDF(Unified Robot Description Format,统一机器人描述格式)是 ROS 描述机器人几何、运动学、动力学的 XML 文件。人形机器人的每一个关节模组(髋、膝、踝、肩、肘、腕)在 URDF 里最终都落成两样东西:link(连杆,刚体)和 joint(关节,连接两个连杆的约束)。
一个 <link> 通常包含三块内容,它们的几何可以各不相同,这是初学者最容易混的点:
| 子标签 | 作用 | 由谁使用 | 典型差异 |
|---|---|---|---|
<visual> | 可视化几何:屏幕上长什么样 | RViz2、Gazebo 渲染 | 可以用高精度 mesh(.stl/.dae),越精细越像真机 |
<collision> | 碰撞几何:碰撞检测用什么 | MoveIt2 自碰撞、Gazebo 物理、规划器 | 尽量用凸体简化(box/cylinder/sphere 或简化 mesh),越简单算得越快 |
<inertial> | 惯性:质量与转动惯量 | Gazebo 物理、动力学、逆动力学 | 必须给 mass(必须 > 0)和 3×3 惯量张量 inertia |
三者的 <origin> 都相对该 link 自身坐标系描述;而 link 之间的相对位姿则由 <joint> 里的 <origin> 定义。
joint 通过 type 指定约束类型,并通过 parent / child 确定父子关系(child 相对 parent 运动,这是 TF 树的根)。常见六种:
| type | 自由度 | 典型用途 |
|---|---|---|
revolute | 1(绕 axis 旋转,有上下限) | 人形机器人几乎全部旋转关节模组(髋/膝/肘) |
continuous | 1(无限旋转) | 轮子、转台 |
prismatic | 1(沿 axis 平移) | 滑轨、直线执行器 |
fixed | 0(焊死) | 把相机、IMU 固定到某个 link 上 |
floating | 6 | 整机浮动基座(如四足/人形整机的根,较复杂) |
planar | 3(平面内移动+旋转) | 平面移动小车(较少用) |
一个 revolute 关节的关键字段:<origin>(关节坐标系相对父连杆的位姿)、<axis>(旋转轴单位向量,如 0 0 1 表示绕 Z 轴)、<limit>(lower/upper 限位、effort 最大力矩、velocity 最大速度)。
x y z(米)和 rpy(弧度,roll/pitch/yaw)表示;欧拉角是"先绕固定轴 roll→pitch→yaw"的 RPY 顺序。颜色用 rgba,四个值都是 0~1。
下面是一个 2 自由度的平面机械臂:基座 + 两节绕 Z 轴旋转的连杆。把下面内容存成 two_link_arm.urdf 即可用于 RViz2 可视化:
<?xml version="1.0"?>
<robot name="two_link_arm">
<!-- ===== 基座:静止连杆 ===== -->
<link name="base_link">
<visual>
<geometry>
<cylinder radius="0.06" length="0.12"/>
</geometry>
<origin xyz="0 0 0.06" rpy="0 0 0"/>
<material name="grey">
<color rgba="0.4 0.4 0.4 1.0"/>
</material>
</visual>
<collision>
<geometry>
<cylinder radius="0.06" length="0.12"/>
</geometry>
<origin xyz="0 0 0.06" rpy="0 0 0"/>
</collision>
<inertial>
<origin xyz="0 0 0.06" rpy="0 0 0"/>
<mass value="1.0"/>
<inertia ixx="0.01" ixy="0.0" ixz="0.0" iyy="0.01" iyz="0.0" izz="0.01"/>
</inertial>
</link>
<!-- ===== 关节 1:base_link --> link1,绕 Z 轴 -->
<joint name="joint1" type="revolute">
<parent link="base_link"/>
<child link="link1"/>
<origin xyz="0 0 0.12" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.14" upper="3.14" effort="10" velocity="3.14"/>
</joint>
<link name="link1">
<visual>
<geometry>
<box size="0.08 0.08 0.3"/>
</geometry>
<origin xyz="0 0 0.15" rpy="0 0 0"/>
<material name="blue">
<color rgba="0.2 0.5 1.0 1.0"/>
</material>
</visual>
<collision>
<geometry>
<box size="0.08 0.08 0.3"/>
</geometry>
<origin xyz="0 0 0.15" rpy="0 0 0"/>
</collision>
<inertial>
<origin xyz="0 0 0.15" rpy="0 0 0"/>
<mass value="1.0"/>
<inertia ixx="0.01" ixy="0.0" ixz="0.0" iyy="0.01" iyz="0.0" izz="0.01"/>
</inertial>
</link>
<!-- ===== 关节 2:link1 --> link2 -->
<joint name="joint2" type="revolute">
<parent link="link1"/>
<child link="link2"/>
<origin xyz="0 0 0.3" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.14" upper="3.14" effort="10" velocity="3.14"/>
</joint>
<link name="link2">
<visual>
<geometry>
<box size="0.06 0.06 0.25"/>
</geometry>
<origin xyz="0 0 0.125" rpy="0 0 0"/>
<material name="red">
<color rgba="1.0 0.3 0.3 1.0"/>
</material>
</visual>
<collision>
<geometry>
<box size="0.06 0.06 0.25"/>
</geometry>
<origin xyz="0 0 0.125" rpy="0 0 0"/>
</collision>
<inertial>
<origin xyz="0 0 0.125" rpy="0 0 0"/>
<mass value="0.6"/>
<inertia ixx="0.005" ixy="0.0" ixz="0.0" iyy="0.005" iyz="0.0" izz="0.005"/>
</inertial>
</link>
</robot>
<inertial>,否则加载直接报错。
URDF 写长了会又臭又长:xacro(XML 宏)给它加上了常量、变量、数学运算、宏函数、文件包含。核心用法:
<xacro:property name=".." value=".."/> —— 定义常量;${表达式} —— 引用常量或做运算(如 ${link1_len / 2});<xacro:macro name=".." params="..">...</xacro:macro> —— 定义宏(函数);<xacro:include filename=".."/> —— 包含其它 xacro 文件;<xacro:宏名 参数="值"/>。把上面的 2 连杆臂改写成 xacro(节选核心部分):
<?xml version="1.0"?>
<robot name="two_link_arm" xmlns:xacro="http://www.ros.org/wiki/xacro">
<!-- 常量:连杆长度,改这里即可整体改变臂长 -->
<xacro:property name="link1_len" value="0.3"/>
<xacro:property name="link2_len" value="0.25"/>
<xacro:property name="radius" value="0.04"/>
<!-- 宏:生成一节连杆(参数化) -->
<xacro:macro name="arm_link" params="name length color">
<link name="${name}">
<visual>
<geometry>
<box size="${2*radius} ${2*radius} ${length}"/>
</geometry>
<origin xyz="0 0 ${length/2}" rpy="0 0 0"/>
<material name="m_${name}">
<color rgba="${color}"/>
</material>
</visual>
<collision>
<geometry>
<box size="${2*radius} ${2*radius} ${length}"/>
</geometry>
<origin xyz="0 0 ${length/2}" rpy="0 0 0"/>
</collision>
<inertial>
<origin xyz="0 0 ${length/2}" rpy="0 0 0"/>
<mass value="${length}"/>
<inertia ixx="0.001" ixy="0.0" ixz="0.0" iyy="0.001" iyz="0.0" izz="0.001"/>
</inertial>
</link>
</xacro:macro>
<!-- 宏:生成一个 revolute 关节 -->
<xacro:macro name="arm_joint" params="name parent child z">
<joint name="${name}" type="revolute">
<parent link="${parent}"/>
<child link="${child}"/>
<origin xyz="0 0 ${z}" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="-3.14" upper="3.14" effort="10" velocity="3.14"/>
</joint>
</xacro:macro>
<!-- 调用宏:三行搞定三节 + 两个关节 -->
<arm_link name="link1" length="${link1_len}" color="0.2 0.5 1.0 1.0"/>
<arm_link name="link2" length="${link2_len}" color="1.0 0.3 0.3 1.0"/>
<arm_joint name="joint1" parent="base_link" child="link1" z="${0.12}"/>
<arm_joint name="joint2" parent="link1" child="link2" z="${link1_len}"/>
</robot>
xacro 文件要用 xacro 工具转成 URDF 才能被下游读取:
xacro two_link_arm.urdf.xacro > two_link_arm.urdf
# 也可以直接转成可视化 PDF 检查(部分发行版需装 graphviz/urdfdom)
check_urdf two_link_arm.urdf # 校验结构是否合法
urdf_to_graphiz two_link_arm.urdf # 生成 tf 结构图(可选)
RViz2 只负责"显示",它自己不解析 URDF —— 解析和发布 TF 靠 robot_state_publisher,关节角度靠 joint_state_publisher。完整流程四步:
# ① 转 URDF(可选,便于 check_urdf 校验)
xacro two_link_arm.urdf.xacro > two_link_arm.urdf
# ② 启动 robot_state_publisher,发布模型与 TF
ros2 run robot_state_publisher robot_state_publisher --ros-args \
-p robot_description:="$(xacro /path/to/two_link_arm.urdf.xacro)"
# ③ 启动 joint_state_publisher_gui,可拖动滑杆改关节角
ros2 run joint_state_publisher_gui joint_state_publisher_gui
# ④ 新开终端,启动 RViz2
rviz2
RViz2 打开后按下面顺序配置(每一步都有坑):
base_link(注意大小写、不能有前导 /)。设错会整屏报红或模型消失。/robot_description 话题;若模型不出现,检查话题名是否一致。base_link → link1 → link2 的树。display.rviz,下次用 rviz2 -d display.rviz 直接加载。<origin> 写错或 Fixed Frame 没设对。
TF2 维护一棵有向树上所有坐标系之间的相对位姿,并缓存一段时间让异步消息也能对齐时间戳。人形机器人典型的 tf 树:
map ──> odom ──> base_link ──> torso ──> l_arm ──> l_hand ──> l_gripper
└────> r_arm ──> r_hand ──> r_gripper
| 坐标系 | 含义 | 典型发布者 |
|---|---|---|
base_link | 机器人本体基准系(通常位于底盘/躯干) | robot_state_publisher(由 URDF 决定) |
odom | 里程计系:随运动漂移的"世界系"近似 | 轮式/腿式里程计节点、IMU 融合 |
map | 地图系:全局固定、不漂移的参考系 | SLAM/定位节点(如 AMCL) |
为什么要分三层?因为里程计会累积误差(odom → base_link 随运动漂移),而地图需要稳定;定位节点定期发布 map → odom 来"纠正"漂移,从而隔离两种误差源。这是导航栈的标准设计。
两坐标系若刚性固定(如给机器人装一个固定位置的激光雷达),用 static_transform_publisher 一次发布即可:
# 形式:xyz + 欧拉角(rpy),再跟 父系 子系
ros2 run tf2_ros static_transform_publisher 0.1 0 0.2 0 0 0 base_link laser
# 或使用命名参数(更不易写错顺序)
ros2 run tf2_ros static_transform_publisher --x 0.1 --y 0 --z 0.2 \
--roll 0 --pitch 0 --yaw 0 --frame-id base_link --child-frame-id laser
程序里查变换的核心是 Buffer + TransformListener(负责接收),再调 lookup_transform:
import rclpy
from rclpy.node import Node
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener
class TfLookup(Node):
def __init__(self):
super().__init__('tf_lookup')
self.buffer = Buffer()
self.listener = TransformListener(self.buffer, self)
self.timer = self.create_timer(1.0, self.lookup)
def lookup(self):
try:
# 查 base_link 到 link2 的变换,time 用 Time() 表示"取最新"
t = self.buffer.lookup_transform('base_link', 'link2', rclpy.time.Time())
pos = t.transform.translation
self.get_logger().info('link2 相对 base_link: x=%.3f y=%.3f z=%.3f'
% (pos.x, pos.y, pos.z))
except Exception as e:
self.get_logger().warn('查询失败(通常系还没发布): %s' % e)
def main(args=None):
rclpy.init(args=args)
node = TfLookup()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
调试命令:ros2 run tf2_ros tf2_echo base_link link2 持续打印变换;view_frames(生成 frames.pdf)可视化整棵树。
真实机器人启动时少则几个、多则几十个节点,不可能手动敲。ROS2 推荐 Python 版 launch 文件(*.launch.py),它其实是普通 Python 脚本,入口函数返回一个 LaunchDescription。核心构件:
| 构件 | 导入来源 | 作用 |
|---|---|---|
LaunchDescription | launch | 描述要启动什么(所有 Action 的列表) |
Node | launch_ros.actions | 启动一个 ROS2 节点(package/executable/name/parameters) |
DeclareLaunchArgument | launch.actions | 声明可覆盖参数,如 use_sim_time |
LaunchConfiguration | launch.substitutions | 读取参数当前值 |
IncludeLaunchDescription | launch.actions | 嵌套包含另一个 launch 文件 |
GroupAction | launch.actions | 分组,可统一加命名空间/条件 |
FindPackageShare | launch_ros.substitutions | 定位包的 share 目录(找 URDF/配置) |
一个把 URDF 显示起来的完整多节点示例(display.launch.py):
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import Command, LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
# 参数:运行时可用 use_sim_time:=true 覆盖
use_sim_time = LaunchConfiguration('use_sim_time', default='false')
robot_description = Command([
'xacro ',
PathJoinSubstitution([
FindPackageShare('my_arm'),
'urdf', 'two_link_arm.urdf.xacro',
]),
])
return LaunchDescription([
DeclareLaunchArgument('use_sim_time', default_value='false',
description='是否使用 /clock 仿真时间'),
# 节点 1:发布模型与 TF
Node(
package='robot_state_publisher',
executable='robot_state_publisher',
name='robot_state_publisher',
parameters=[{'robot_description': robot_description,
'use_sim_time': use_sim_time}],
output='screen',
),
# 节点 2:关节角度(带 GUI 滑杆)
Node(
package='joint_state_publisher_gui',
executable='joint_state_publisher_gui',
name='joint_state_publisher_gui',
),
# 节点 3:可视化
Node(
package='rviz2',
executable='rviz2',
name='rviz2',
arguments=['-d', PathJoinSubstitution([
FindPackageShare('my_arm'), 'rviz', 'display.rviz'])],
),
])
运行:ros2 launch my_arm display.launch.py use_sim_time:=true。若要"分组",把一组 Node 包进 GroupAction([...]) 并可加 namespace;若要"包含",用 IncludeLaunchDescription(PythonLaunchDescriptionSource([...])) 复用别的 launch 文件。
URDF 只描述"长什么样",真正给每个关节模组下发力矩/位置/速度、并读回编码器反馈的是 ros2_control。它的架构把"控制逻辑"和"硬件驱动"解耦:
SystemInterface 派生类实现 export_state_interfaces() 与 export_command_interfaces()。┌───────────── controller_manager ─────────────┐
│ joint_state_broadcaster joint_trajectory_controller │
└───────────────┬───────────────────────────────┘
│ 通过 state/cmd 接口
┌───────────────▼───────────────────────────────┐
│ hardware_interface (SystemInterface) │
│ 真机驱动(CAN/EtherCAT) 或 仿真插件(ign_ros2_control) │
└───────────────────────────────────────────────┘
它是最基础的控制器:把硬件读到的关节角度/速度/力矩广播到 /joint_states,供 TF 和 MoveIt2 使用。配置文件(controllers.yaml):
controller_manager:
ros__parameters:
update_rate: 50 # 控制循环 Hz
joint_state_broadcaster:
ros__parameters:
type: joint_state_broadcaster/JointStateBroadcaster
publish_rate: 50 # 发布 /joint_states 的频率
extra_joints: [] # 可额外并入非受控关节
启动与激活(两种方式):
# 方式 A:spawner(推荐,自动处理 lifecycle)
ros2 run controller_manager spawner joint_state_broadcaster
# 方式 B:ros2 control 命令行手动管理
ros2 control load_controller joint_state_broadcaster
ros2 control set_controller_state joint_state_broadcaster active
ros2 control list_controllers # 查看所有控制器状态
inactive,必须 activate 才会真正发布/控制。URDF 里的关节名、控制器配置里的关节名、MoveIt2 里的关节名三者必须完全一致,否则 controller_manager 直接报"no matching joint"。
MoveIt2 是 ROS2 生态里最主流的操作与运动规划框架:给定起始位姿和目标位姿,它在避开障碍和自碰撞的前提下算出一条轨迹,再交给控制器执行。核心对象是 MoveGroup(一组关节/规划组的封装)。
一个交互式 GUI 工具,把 URDF 变成 MoveIt2 可用的配置包。流程:
# 生成后即可在 RViz 里做交互式规划(带 MotionPlanning 插件)
ros2 launch my_arm_moveit_config demo.launch.py
程序化规划的最小示例(以规划组名 arm 为例):
#include <rclcpp/rclcpp.hpp>
#include <moveit/move_group_interface/move_group_interface.h>
#include <moveit/planning_scene_interface/planning_scene_interface.h>
int main(int argc, char** argv)
{
rclcpp::init(argc, argv);
auto node = rclcpp::Node::make_shared("move_group_demo");
// 用默认规划组 "arm" 构造 MoveGroup
moveit::planning_interface::MoveGroupInterface move_group(node, "arm");
// 目标 1:关节空间目标(单位弧度)
std::vector<double> joint_goal = {0.0, -0.785, 1.57};
move_group.setJointValueTarget(joint_goal);
// 规划
moveit::planning_interface::MoveGroupInterface::Plan plan;
bool success = (move_group.plan(plan) == moveit::core::MoveItErrorCode::SUCCESS);
// 成功才执行
if (success) {
RCLCPP_INFO(node->get_logger(), "plan ok, executing...");
move_group.execute(plan);
} else {
RCLCPP_WARN(node->get_logger(), "plan failed");
}
rclcpp::shutdown();
return 0;
}
常用接口速查:setPoseTarget()(笛卡尔位姿目标)、setJointValueTarget()(关节角目标)、setMaxVelocityScalingFactor()(限速,真机必设)、plan()(只规划)、move()(规划并执行一步到位)、execute(plan)(执行已有轨迹)。
在 demo.launch.py 打开的 RViz 里:
arm;setMaxVelocityScalingFactor(0.1) 之类)并站在急停开关旁。本页示例都是仿真,切勿直接把未经减速的轨迹下发到关节模组。
URDF 描述模型、MoveIt2 负责规划,而"物理世界"由仿真器提供:重力、接触、传感器、电机。仿真器与 ROS2 之间靠"桥"交换消息(关节状态、力矩命令、TF、时钟等)。两大类桥接:
| 仿真器 | 桥接方案 | 说明 |
|---|---|---|
| Gazebo Classic(gazebo11) | gazebo_ros(gazebo_ros_pkgs) |
插件式接入;可用 gazebo_ros2_control 把 ros2_control 的硬件接口接到仿真关节上,模型进仿真器即可受控。 |
| Gazebo 新版(Harmonic/Ionic,旧名 Ignition) | ros_gz_bridge |
独立桥接节点,按需把 Gazebo 话题与 ROS2 话题双向映射(如 /joint_states、/cmd_vel)。 |
| NVIDIA Isaac Sim | Isaac Sim ROS2 Bridge(Omniverse 扩展) | 启用后自动提供 ROS2 时钟/TF/话题;配合 Isaac Lab 可做大规模并行强化学习(详见本站 04-04)。 |
典型 Gazebo Classic 启动(URDF 经 gazebo_ros 插件带进仿真):
# 启动 Gazebo + 加载 URDF 模型(spawn 到仿真)
ros2 launch gazebo_ros gazebo.launch.py
ros2 run gazebo_ros spawn_entity.py -topic robot_description -entity my_arm
# 若用 ros2_control 驱动仿真关节(需在 URDF 里配置 gazebo_ros2_control 插件)
ros2 run controller_manager spawner joint_state_broadcaster
ros2 run controller_manager spawner joint_trajectory_controller
/、区分大小写(常见 base_link 与 base_footprint)。source ~/ws/install/setup.bash,否则 ros2 launch/run 找不到你的包(详见 04-09 的八步流程)。<inertial> 或 mass=0:MoveIt2 与 Gazebo 加载直接失败;可视化可以没有,物理/规划必须有惯性。robot_description 参数若直接指向 .xacro 而没经过 xacro 工具或 launch 的 Command(['xacro',...]) 处理,会解析失败。activate 就没有 /joint_states,TF 树也不更新。0 1 0(绕 Y 轴),在 RViz2 里会有什么不同?这对"人形机器人肩关节俯仰"意味着什么?static_transform_publisher 只适合固定关系,而 robot_state_publisher 才能处理会转动的关节?两者发布 TF 的机制有何区别?prismatic 关节,做成"旋转+伸缩"的 2 自由度机构,并跑通 MoveIt2 规划。