【ROS 2 全栈入门指南四】:TF坐标变换、URDF模型编写与TF发布实战
ROS 2 Jazzy 机器人建模全指南:TF坐标变换、URDF模型编写与TF发布实战
在机器人开发的世界里,有两个概念如同空气和水一样无处不在:TF(坐标变换) 和 URDF(统一机器人描述格式)。它们是所有机器人应用的"骨架"——没有TF,你永远不知道相机相对于机械臂末端在哪里;没有URDF,你就无法描述机器人的结构,更谈不上仿真和控制。
本文将带你从零开始,通过一步步实战,从在RViz中直观理解TF,到亲手编写一个完整的两轮差速移动底盘URDF,最后将其打包成标准ROS 2包并自动发布TF。掌握这三个技能,你就真正踏入了机器人系统开发的大门。

一、TF:机器人世界的"骨骼系统"
想象一下,你正在控制一个机械臂抓取桌子上的杯子。你需要知道:
- 机械臂底座相对于世界坐标系在哪里?
- 机械臂末端相对于底座在哪里?
- 相机相对于机械臂末端在哪里?
- 杯子相对于相机在哪里?
这些"谁相对谁在哪里"的问题,就是TF要解决的。
1.1 先动手:在RViz中直观感受TF
空谈概念不如动手实践。我们先用ROS 2自带的示例包,在RViz中直观地看到TF是什么样子。
步骤1:安装依赖
# 安装urdf_tutorial示例包(将<distro>替换为jazzy)
sudo apt install ros-jazzy-urdf-tutorial ros-jazzy-tf2-tools
步骤2:启动示例机器人
# 启动urdf_tutorial的显示程序
ros2 launch urdf_tutorial display.launch.py model:=/opt/ros/jazzy/share/urdf_tutorial/urdf/07-physics.urdf
启动后你会看到两个窗口:
- RViz主窗口:3D视图中显示一个机器人模型
- Joint State Publisher:一堆滑条,用来模拟关节角度

现在拖动滑条,你会看到机器人的各个部件跟着动起来。同时,机器人身上会出现很多彩色的坐标轴,还有连接它们的箭头——这些就是Frame和TF。
1.2 三个核心概念:Link / Frame / TF 一次讲清
很多初学者会混淆这三个概念,其实它们的关系非常简单:
- Link(连杆):机器人的一个刚体部件,比如底盘、轮子、机械臂的一节。它本身是不会动的。
- Frame(坐标系):每个Link都有一个自己的坐标系原点和朝向。在RViz中就是那组红绿蓝的坐标轴。
- TF(变换):两个Frame之间的空间关系,包括平移和旋转。在RViz中就是连接两个Frame的箭头。
通俗解释:
把机器人比作你的身体:
- Link = 你的上臂、前臂、手掌这些骨头(刚体)
- Frame = 每个骨头的关节点(肘关节、腕关节)
- TF = 肘关节和肩关节之间的角度和距离,腕关节和肘关节之间的角度和距离
当你拖动Joint State Publisher的滑条时,你改变的是关节的角度,也就是两个Frame之间的TF关系。TF一变,所有子Frame都会跟着动——这就是为什么你转动肘关节时,前臂和手掌会一起跟着动。
1.3 TF Tree:为什么它一定是一棵树?
TF不是乱连的,它被严格组织成一棵树状结构。这是ROS 2中一个非常重要的设计原则。
父子关系
每个TF都有明确的父子关系:
parent frame:父坐标系child frame:子坐标系
一个Frame可以有多个子Frame,但只能有一个父Frame。这样整个关系就是一棵树,而不是一张网。
这样做的好处是:从任意一个Frame到另一个Frame的路径是唯一的,计算它们之间的变换时不会产生歧义。
导出TF Tree
ROS 2提供了一个非常好用的工具,可以把整棵TF Tree导出成PDF文件:
# 保持RViz和机器人模型运行,新开终端执行
ros2 run tf2_tools view_frames
这个命令会监听/tf话题5秒钟,然后在当前目录生成两个文件:
frames.pdf:完整的TF Tree图frames.gv:Graphviz源文件
打开PDF,你会看到类似这样的结构:
base_link
├── base_footprint
├── right_wheel_link
├── left_wheel_link
├── caster_wheel_link
└── shoulder_link
└── elbow_link
└── wrist_link
└── gripper_link
图1 TF Tree结构示意图(出自原文Figure 1)
从图中可以清晰地看到,base_link是根节点,所有其他Frame都是它的子孙。当base_link移动时,整棵树上的所有Frame都会跟着移动。
1.4 TF的本质:一个话题而已
你在RViz里看到的所有TF,本质上都来自一个话题:/tf(以及静态变换的/tf_static)。
你可以直接在终端查看这个话题的内容:
ros2 topic echo /tf
你会看到大量的变换数据,每条变换都包含以下核心信息:
transforms:
- header:
stamp:
sec: 1719900000
nanosec: 123456789
frame_id: "base_link"
child_frame_id: "right_wheel_link"
transform:
translation:
x: -0.15
y: -0.225
z: 0.0
rotation:
x: 0.0
y: 0.0
z: 0.0
w: 1.0
stamp:时间戳,表示这个变换是在哪个时刻的frame_id:父坐标系child_frame_id:子坐标系translation:平移量(x, y, z),单位米rotation:旋转量,用四元数(x, y, z, w)表示
注意:旋转用四元数表示,虽然对人来说不直观,但在计算机里计算更稳定,不会出现"万向节死锁"问题。你不需要手动计算四元数,ROS 2的tf2库会帮你处理所有变换计算。
1.5 TF到底解决了什么问题?
总结一下,TF系统解决了机器人开发中三个最核心的问题:
- 空间关系查询:任意两个坐标系之间的相对位置和姿态
- 时间一致性:所有变换都带时间戳,确保你查询的是同一时刻的关系
- 自动链式计算:你只需要定义相邻Frame之间的变换,tf2库会自动计算任意两个Frame之间的变换
举个例子:如果你想知道机械臂末端相对于世界坐标系的位置,你不需要自己计算world -> base_link -> shoulder -> elbow -> wrist -> end_effector这一长串变换,只需要调用tf2库的一个函数:
transform = tf_buffer.lookup_transform("world", "end_effector", rclpy.time.Time())
tf2会自动帮你沿着TF Tree找到路径并计算出最终结果。
二、URDF:机器人的"说明书"
现在你已经理解了TF是什么,以及它有多重要。那问题来了:TF是从哪里来的?
答案是:从URDF来的。
URDF(Unified Robot Description Format)是ROS生态中标准的机器人描述格式。它是一个XML文件,用来描述机器人的:
- 刚体部件(Links)
- 部件之间的连接方式(Joints)
- 外观和物理属性
robot_state_publisher节点会读取URDF文件,结合关节状态,自动发布TF。
2.1 最小可用URDF:先让机器人"看得见"
我们从最简单的URDF开始,写一个只有一个底盘的机器人。
创建my_robot.urdf文件:
<?xml version="1.0"?>
<robot name="my_robot">
<!-- 定义绿色材质 -->
<material name="green">
<color rgba="0 0.6 0 1"/>
</material>
<!-- 底盘Link -->
<link name="base_link">
<visual>
<geometry>
<!-- 长方体:长0.6m,宽0.4m,高0.2m -->
<box size="0.6 0.4 0.2"/>
</geometry>
<!-- 把visual沿z轴上移0.1m,让link原点在盒子底部中心 -->
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<material name="green"/>
</visual>
</link>
</robot>
用RViz可视化这个URDF:
ros2 launch urdf_tutorial display.launch.py model:=/home/<你的用户名>/my_robot.urdf
你会在RViz中看到一个绿色的长方体。这就是你的第一个机器人模型!
关键细节:
注意<origin xyz="0 0 0.1" rpy="0 0 0"/>这一行。它的意思是:
- 这个长方体的视觉中心在link原点的正上方0.1m处
- 这样link原点就正好在长方体的底部中心,而不是中心
这一点非常重要,因为后面我们要把轮子放在底盘的底部,让机器人正好放在地面上。
2.2 URDF最难的部分:两个Link怎么正确拼起来?
很多初学者写URDF时,都会在这里卡很久:两个Link怎么用Joint正确连接?
不要靠"乱调参数试出来",那会让你越写越崩。正确的做法是遵循一个固定的5步流程,以后每加一个Link都照做。
我们以在底盘上加一个肩部关节为例,演示这个流程:
Step 1:先加新Link,所有origin先写0
<!-- 肩部Link -->
<link name="shoulder_link">
<visual>
<geometry>
<!-- 圆柱:半径0.1m,长度0.3m -->
<cylinder radius="0.1" length="0.3"/>
</geometry>
<origin xyz="0 0 0" rpy="0 0 0"/>
<material name="gray"/>
</visual>
</link>
Step 2:加Joint,建立parent/child关系,origin先写0
<!-- 底盘和肩部之间的关节 -->
<joint name="base_shoulder_joint" type="fixed">
<parent link="base_link"/>
<child link="shoulder_link"/>
<origin xyz="0 0 0" rpy="0 0 0"/>
</joint>
重要:如果没有Joint,你会遇到经典报错:
Two root links found。因为URDF只能有一个根Link。
Step 3:修改Joint的origin,把child的Frame放到正确位置
这是最关键的一步!TF是由Joint决定的,和Visual无关。所以先把坐标系放对,再管外观。
我们想把肩部放在底盘的正上方,底盘高度是0.2m,所以Joint的origin的z值应该是0.2:
<origin xyz="0 0 0.2" rpy="0 0 0"/>
Step 4:设置Joint的类型、轴和限制(如果需要运动)
常用的Joint类型:
fixed:固定关节,不能动(用于传感器、结构件)revolute:有限角度旋转关节(用于机械臂关节)continuous:无限旋转关节(用于轮子)prismatic:直线滑动关节(用于升降结构)
我们让肩部可以绕Z轴旋转±180°:
<joint name="base_shoulder_joint" type="revolute">
<parent link="base_link"/>
<child link="shoulder_link"/>
<origin xyz="0 0 0.2" rpy="0 0 0"/>
<!-- 旋转轴:Z轴 -->
<axis xyz="0 0 1"/>
<!-- 限制:角度范围±π弧度,最大速度100rad/s,最大力矩100Nm -->
<limit lower="-3.14" upper="3.14" velocity="100" effort="100"/>
</joint>
Step 5:最后修改child Link的visual origin,让外形贴合坐标系
现在坐标系已经放对了,但圆柱的中心在坐标系原点,我们想让圆柱从坐标系向上"长出去"。圆柱长度是0.3m,所以把visual沿z轴上移0.15m:
<origin xyz="0 0 0.15" rpy="0 0 0"/>
到此,肩部就完美地拼接到底盘上了!现在拖动RViz里的滑条,你会看到肩部绕Z轴旋转。
2.3 实战:写一个完整的两轮差速移动底盘
现在我们用上面的5步流程,写一个完整的两轮差速移动底盘。它包含:
base_footprint:虚拟Link,作为地面上的2D原点base_link:底盘主体right_wheel_link:右轮left_wheel_link:左轮caster_wheel_link:前方脚轮
完整URDF代码
<?xml version="1.0"?>
<robot name="my_diff_drive_robot">
<!-- 材质定义 -->
<material name="green">
<color rgba="0 0.6 0 1"/>
</material>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
<material name="gray">
<color rgba="0.5 0.5 0.5 1"/>
</material>
<!-- 1. base_footprint:虚拟Link,地面上的2D原点 -->
<link name="base_footprint"/>
<!-- 2. base_link:底盘主体 -->
<link name="base_link">
<visual>
<geometry>
<box size="0.6 0.4 0.2"/>
</geometry>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
<material name="green"/>
</visual>
</link>
<!-- base_footprint和base_link之间的固定关节 -->
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
<!-- 把base_link抬高0.1m(轮半径) -->
<origin xyz="0 0 0.1" rpy="0 0 0"/>
</joint>
<!-- 3. 右轮 -->
<link name="right_wheel_link">
<visual>
<geometry>
<cylinder radius="0.1" length="0.05"/>
</geometry>
<!-- 轮子绕X轴转90度,让它立起来 -->
<origin xyz="0 0 0" rpy="1.5708 0 0"/>
<material name="black"/>
</visual>
</link>
<joint name="base_right_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="right_wheel_link"/>
<!-- 位置:x=-0.15(靠后),y=-0.225(右侧),z=0(底部) -->
<origin xyz="-0.15 -0.225 0" rpy="0 0 0"/>
<!-- 旋转轴:Y轴 -->
<axis xyz="0 1 0"/>
</joint>
<!-- 4. 左轮(和右轮对称,y坐标为正) -->
<link name="left_wheel_link">
<visual>
<geometry>
<cylinder radius="0.1" length="0.05"/>
</geometry>
<origin xyz="0 0 0" rpy="1.5708 0 0"/>
<material name="black"/>
</visual>
</link>
<joint name="base_left_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="left_wheel_link"/>
<origin xyz="-0.15 0.225 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
<!-- 5. 前方脚轮(被动轮,固定关节) -->
<link name="caster_wheel_link">
<visual>
<geometry>
<sphere radius="0.05"/>
</geometry>
<material name="gray"/>
</visual>
</link>
<joint name="base_caster_wheel_joint" type="fixed">
<parent link="base_link"/>
<child link="caster_wheel_link"/>
<!-- 位置:x=0.2(前方),y=0(居中),z=-0.05(底部) -->
<origin xyz="0.2 0 -0.05" rpy="0 0 0"/>
</joint>
</robot>
现在用RViz打开这个URDF,你会看到一个完整的两轮差速底盘。拖动滑条,两个轮子会独立旋转。
2.4 Xacro:让URDF变得可维护
当你的URDF越来越长,你会发现硬编码的数字到处都是,重复代码越来越多。比如左右轮的代码几乎一模一样,只是y坐标符号不同。
Xacro就是为了解决这个问题而生的。它是URDF的模板语言,提供了三个核心功能:
- Properties(变量):把硬编码的数字定义成变量
- Macros(宏):把重复的代码写成模板
- Include(包含):把大文件拆分成多个小文件
把URDF改成Xacro
把文件名改成my_robot.urdf.xacro,然后修改内容:
<?xml version="1.0"?>
<robot name="my_diff_drive_robot" xmlns:xacro="http://www.ros.org/wiki/xacro">
<!-- 定义变量 -->
<xacro:property name="base_length" value="0.6"/>
<xacro:property name="base_width" value="0.4"/>
<xacro:property name="base_height" value="0.2"/>
<xacro:property name="wheel_radius" value="0.1"/>
<xacro:property name="wheel_length" value="0.05"/>
<xacro:property name="pi" value="3.141592653589793"/>
<!-- 材质定义 -->
<material name="green">
<color rgba="0 0.6 0 1"/>
</material>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
<material name="gray">
<color rgba="0.5 0.5 0.5 1"/>
</material>
<!-- 轮子宏:传入prefix参数(left/right) -->
<xacro:macro name="wheel" params="prefix">
<link name="${prefix}_wheel_link">
<visual>
<geometry>
<cylinder radius="${wheel_radius}" length="${wheel_length}"/>
</geometry>
<origin xyz="0 0 0" rpy="${pi/2} 0 0"/>
<material name="black"/>
</visual>
</link>
<joint name="base_${prefix}_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="${prefix}_wheel_link"/>
<origin
xyz="-0.15 ${prefix == 'left' ? base_width/2 + wheel_length/2 : -(base_width/2 + wheel_length/2)} 0"
rpy="0 0 0"
/>
<axis xyz="0 1 0"/>
</joint>
</xacro:macro>
<!-- base_footprint -->
<link name="base_footprint"/>
<!-- base_link -->
<link name="base_link">
<visual>
<geometry>
<box size="${base_length} ${base_width} ${base_height}"/>
</geometry>
<origin xyz="0 0 ${base_height/2}" rpy="0 0 0"/>
<material name="green"/>
</visual>
</link>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
<origin xyz="0 0 ${wheel_radius}" rpy="0 0 0"/>
</joint>
<!-- 调用宏,生成左右轮 -->
<xacro:wheel prefix="left"/>
<xacro:wheel prefix="right"/>
<!-- 脚轮 -->
<link name="caster_wheel_link">
<visual>
<geometry>
<sphere radius="${wheel_radius/2}"/>
</geometry>
<material name="gray"/>
</visual>
</link>
<joint name="base_caster_wheel_joint" type="fixed">
<parent link="base_link"/>
<child link="caster_wheel_link"/>
<origin xyz="${base_length/2 - wheel_radius/2} 0 ${-wheel_radius/2}" rpy="0 0 0"/>
</joint>
</robot>
现在代码清爽多了!所有尺寸都定义成了变量,左右轮的重复代码用一个宏解决了。而且Xacro文件仍然可以用同样的方式在RViz中打开:
ros2 launch urdf_tutorial display.launch.py model:=/home/<你的用户名>/my_robot.urdf.xacro
三、从URDF到TF:发布自己的坐标变换
现在你已经写出了自己的URDF,但我们一直依赖urdf_tutorial来帮我们启动和发布TF。在真实项目中,你需要自己发布TF,并且把所有文件打包成标准的ROS 2包。
3.1 发布TF的核心节点:robot_state_publisher
发布TF的核心节点是robot_state_publisher。它是ROS 2的官方节点,几乎所有机器人项目都会用到。
它需要两个输入:
- robot_description参数:URDF文件的内容(Xacro需要先展开)
- /joint_states话题:每个关节的当前状态(位置、速度等)
工作流程:
robot_state_publisher读取URDF,了解机器人的结构- 它订阅
/joint_states话题,获取每个关节的当前角度 - 根据URDF和关节状态,计算所有Frame之间的变换
- 把变换发布到
/tf和/tf_static话题

3.2 不用urdf_tutorial:手动在终端发布TF
在写launch文件之前,我们先在终端手动跑通整个流程,这样你会清楚每个组件的作用。
Terminal 1:启动robot_state_publisher
# 用xacro命令展开Xacro文件,然后作为参数传给robot_state_publisher
ros2 run robot_state_publisher robot_state_publisher --ros-args -p robot_description:="$(xacro /home/<你的用户名>/my_robot.urdf.xacro)"
如果正常启动,你会看到它输出识别到的所有Link。
Terminal 2:启动joint_state_publisher_gui
# 这个节点提供滑条界面,用来模拟关节状态,发布到/joint_states话题
ros2 run joint_state_publisher_gui joint_state_publisher_gui
现在拖动滑条,/joint_states话题就会有数据,robot_state_publisher就会开始发布TF。
Terminal 3:验证TF是否发布成功
# 查看/tf话题
ros2 topic echo /tf
# 导出TF Tree
ros2 run tf2_tools view_frames
Terminal 4:启动RViz并配置
rviz2
在RViz中做三个关键配置:
- Global Options → Fixed Frame:改成
base_footprint - 点击左下角Add → 选择RobotModel → 点击OK
- 点击左下角Add → 选择TF → 点击OK
现在你应该能在RViz中看到你的机器人模型和所有TF坐标轴了。拖动滑条,机器人会跟着动起来。
3.3 工程最佳实践:创建标准的_description包
在真实项目中,你不应该把URDF文件散落在home目录里。ROS 2的标准做法是:为机器人模型单独创建一个_description包。
这个包不写任何业务代码,只用来存放:
urdf/:URDF/Xacro文件meshes/:CAD导出的模型文件(STL/DAE)rviz/:RViz配置文件launch/:启动文件
步骤1:创建工作空间和包
# 创建工作空间
mkdir -p ~/my_robot_ws/src
cd ~/my_robot_ws
colcon build
# 创建description包
cd src
ros2 pkg create my_robot_description --build-type ament_cmake
# 清理不需要的目录
cd my_robot_description
rm -r include/ src/
步骤2:创建目录结构
mkdir urdf meshes rviz launch
步骤3:把文件移到对应的目录
- 把
my_robot.urdf.xacro移到urdf/目录 - 把RViz配置保存为
rviz/display.rviz
步骤4:修改Xacro中的include路径
如果你有多个Xacro文件互相include,要改成从包中查找的写法:
<!-- 错误写法 -->
<xacro:include filename="common_properties.xacro"/>
<!-- 正确写法 -->
<xacro:include filename="$(find my_robot_description)/urdf/common_properties.xacro"/>
步骤5:编写CMakeLists.txt
cmake_minimum_required(VERSION 3.8)
project(my_robot_description)
find_package(ament_cmake REQUIRED)
# 安装所有资源目录
install(
DIRECTORY urdf meshes rviz launch
DESTINATION share/${PROJECT_NAME}/
)
ament_package()
3.4 写一个launch文件:一键启动TF发布和RViz
现在我们写一个launch文件,一键启动所有需要的节点:
robot_state_publisherjoint_state_publisher_guirviz2
创建launch/display.launch.xml:
<launch>
<!-- 定义路径变量 -->
<let name="urdf_path" value="$(find-pkg-share my_robot_description)/urdf/my_robot.urdf.xacro"/>
<let name="rviz_config_path" value="$(find-pkg-share my_robot_description)/rviz/display.rviz"/>
<!-- 启动robot_state_publisher -->
<node pkg="robot_state_publisher" exec="robot_state_publisher">
<param name="robot_description" value="$(command 'xacro $(var urdf_path)')"/>
</node>
<!-- 启动joint_state_publisher_gui -->
<node pkg="joint_state_publisher_gui" exec="joint_state_publisher_gui"/>
<!-- 启动RViz,加载配置文件 -->
<node pkg="rviz2" exec="rviz2" args="-d $(var rviz_config_path)"/>
</launch>
构建并运行
cd ~/my_robot_ws
colcon build --packages-select my_robot_description
source install/setup.bash
# 一键启动
ros2 launch my_robot_description display.launch.xml
启动后,你会看到RViz自动打开,显示你的机器人模型,并且滑条可以控制关节运动。TF也会自动发布。
总结
本文完整介绍了ROS 2机器人建模的三个核心技能:
- TF坐标变换:理解Link、Frame、TF的关系,学会用RViz可视化和调试TF
- URDF机器人描述:掌握编写URDF的5步流程,用Xacro让模型更可维护
- TF发布与打包:用robot_state_publisher发布TF,创建标准的_description包
这套流程是所有ROS机器人项目的基础,无论是移动机器人、机械臂还是无人机,都遵循同样的模式。掌握了这些,你就为下一步的Gazebo仿真、导航和控制打下了坚实的基础。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)