ROS 怎么接足式机器人?unitree_ros 的 Gazebo 仿真与 URDF 模型
- 拿到任何一个陌生的 ROS 机器人仓库,能在五分钟内定位 description / gazebo / controller 三类包
- 讲清 URDF 描述了什么、碰撞体与视觉体为什么要分开、以及为什么要用 xacro 而不是裸写 URDF
- 理解「模型定义一次、到处使用」这条 ROS 生态主线,以及仿真到真机之间那座桥为什么必须单独建
你装完 ROS,跑通了 turtlesim,那只小乌龟在窗口里画了几个圈。教程到这儿就结束了。
然后你想接一台真机器人——不是仿真里的乌龟,是有腿、有关节、会摔倒的那种。你 clone 下来一个官方仓库,解压,ls。屏幕上刷出十几个文件夹,每个下面还有 meshes、xacro、launch、config,加起来上千个文件。你盯着看了两分钟,不知道该从哪个文件打开。
这一步卡住的人非常多。卡住的原因不是 ROS 难,而是没人告诉过你一个 ROS 机器人项目应该长什么样。它其实有固定的骨架,只是这套骨架从来没被单独讲过一遍。
这篇我们就拿宇树的 unitree_ros 当样本,把这套骨架完整拆一遍。看完你再打开任何一个 ROS 机器人仓库——不管是谁家的、什么形态的——都能在五分钟内知道东西都藏在哪。
先说清边界:本文基于公开仓库的 README 与文件树结构写成,没有真机验证,也没有在本地跑过这套仿真。 所有关于「怎么跑」的说法都来自仓库文档本身,所有关于「怎么组织」的判断来自文件与目录的排列方式。涉及运行效果的地方,我会明确写成「README 里写的是」。
三件套:先把包分成三堆
unitree_ros 的 README 开门见山地把包分了组,这个分组本身就是答案:
Robot description(机器人描述)
a1 a2 aliengo aliengoZ1 b1 b2 b2w dexterous_hand
g1 go1 go2 go2w h1 h1_2 h2 laikago r1 r1_air z1
Robot and joints controller(机器人与关节控制器)
unitree_controller
z1_controller
Simulation related(仿真相关)
unitree_gazebo
unitree_legged_control
这就是 ROS 机器人项目的标准三件套:描述包说明「这台机器是什么」,仿真包说明「让它在哪个世界里动」,控制包说明「怎么驱动它的关节」。
三者的依赖方向是单向的。描述包最底层,它不知道自己会被谁用;仿真包和控制包都往下依赖描述包。这个方向很重要——它意味着你换一台机器人只需要换描述包,仿真和控制那两层理论上不用动。README 里那条启动命令直接印证了这一点:
roslaunch unitree_gazebo normal.launch rname:=a1 wname:=stairs
rname 是机器人名,wname 是世界名。一个 launch 文件,靠两个参数切换机型和场景。能做到这件事的前提,就是所有描述包遵守同一套结构约定。
拿到一个陌生仓库时,你要做的第一个动作就是这个分堆。名字里带 description 的归一堆,带 gazebo / sim / simulation 的归一堆,带 control / controller 的归一堆。剩下没归进去的,往往是消息定义、工具脚本或者示例。
description 包里到底装了什么
挑 a1_description 来看,因为它的结构最完整、最典型:
robots/a1_description/
├── CMakeLists.txt
├── package.xml
├── config/
│ └── robot_control.yaml
├── launch/
│ ├── a1_rviz.launch
│ └── check_joint.rviz
├── meshes/
│ ├── trunk.dae
│ ├── hip.dae
│ ├── thigh.dae
│ ├── thigh_mirror.dae
│ ├── calf.dae
│ └── trunk_A1.png
├── urdf/
│ └── a1.urdf
└── xacro/
├── const.xacro
├── materials.xacro
├── leg.xacro
├── transmission.xacro
├── gazebo.xacro
├── robot.xacro
└── stairs.xacro
package.xml 和 CMakeLists.txt 是 ROS 包的身份证,任何 ROS 包都有,跳过不谈。剩下五个目录,每个都对应一件正经事。
URDF:机器人的身体定义
urdf/a1.urdf 是这个包的核心。URDF 全称 Unified Robot Description Format,统一机器人描述格式,本质是一个 XML 文件。
它描述的东西可以归成两类:连杆(link)和关节(joint)。
连杆是刚体,是机器人身上那些不会自己变形的块——躯干、大腿、小腿、脚。一个 link 通常写三样东西:
- visual:视觉体。长什么样,给人看的。通常指向一个网格文件,也可以是简单的立方体或圆柱。
- collision:碰撞体。物理引擎拿它算碰撞。
- inertial:惯性参数。质量、质心位置、惯量张量。这三样决定了它在物理世界里的行为——同样形状的两根杆,质量分布不同,甩起来完全是两回事。
关节是连接两个连杆的东西,它规定了父连杆和子连杆之间允许怎么相对运动。URDF 的关节有几种类型,最常用的是这几个:
revolute:转动关节,有角度上下限。膝盖就是这种,它转不过头。continuous:也是转动,但没有限位,可以无限转。轮子是典型。prismatic:滑动关节,沿一条轴平移。fixed:固定,不动。用来把传感器、装饰件死死焊在某个连杆上。
revolute 关节还带一组 limit 参数:角度上下限、速度上限、力矩上限。这几个数字非常关键——它们不只是给仿真器看的,运动规划器也靠它们判断一个姿态可不可行。写错了,规划器会给你算出物理上根本达不到的轨迹。
仓库里有个例子把关节结构说得很清楚。h1_2_description 和 h2_description 的网格文件里,踝部出现了这样一组命名:
left_ankle_A_link.STL
left_ankle_A_rod_link.STL
left_ankle_B_link.STL
left_ankle_B_rod_link.STL
left_ankle_pitch_link.STL
left_ankle_roll_link.STL
A、B 两套 link 加 rod(连杆/推杆),这是连杆机构的典型命名。而 h2_description 目录下同时躺着 H2.urdf 和 H2_loop.urdf 两个文件。loop 一般指闭链(闭合运动链)。这里有个 URDF 的固有限制值得知道:URDF 描述的是一棵树,每个连杆只能有一个父节点,它天然表达不了闭环结构。 我不去猜作者具体是怎么处理的,但从「同一台机器出两份 URDF、其中一份文件名里标着 loop」这个安排看,闭链确实是被单独对待的一件事。
视觉体和碰撞体为什么要分开
这是 URDF 里最容易被新手忽略、又最影响仿真质量的一个设计。
视觉体追求好看,碰撞体追求算得快。物理引擎每一帧都要做大量碰撞检测,如果你直接把几万个三角面的精细模型丢给它当碰撞体,仿真会慢到没法用,而且容易出数值问题——两个复杂网格在接触点上算出的接触力可能剧烈跳变。
所以标准做法是:视觉体用精细网格,碰撞体用简化几何(长方体、圆柱、胶囊,或者面数极少的凸包)。
这个仓库里有两处直接的证据。第一处在 g1_with_brainco_hand/meshes/ 下:
left_knee_link.STL
left_knee_link_simple_collision.STL
right_knee_link.STL
right_knee_link_simple_collision.STL
文件名把话说完了——膝关节有一份正常网格,还有一份专门给碰撞用的简化网格。
第二处更有意思,在 go2_description/urdf/ 目录里,除了 URDF 本身还放了两张图片:
go2_description/urdf/Normal_collision_model.png
go2_description/urdf/Amended_collision_model.png
「常规碰撞模型」和「修正后的碰撞模型」。有人专门截了两张图放进仓库做对比,说明碰撞模型是被单独调过的,而且调完之后觉得这个差异值得让使用者看见。
碰撞体调不好会怎样?腿在仿真里互相穿模、脚底和地面接触时抖个不停、机器人莫名其妙自己弹起来——这些坑基本都出在碰撞几何上。
meshes:三种格式各有各的活
meshes/ 里的文件格式在这个仓库里不统一,而这种不统一本身有信息量。
.dae(COLLADA):带材质和贴图信息。a1_description用的就是 dae,而且meshes/里躺着一张trunk_A1.png——那是躯干的贴图。aliengo_description同理,配的是trunk_uv_base_final.png,文件名里的uv是 UV 展开的意思。这类模型在 RViz 和 Gazebo 里看起来有颜色、有 logo。.STL:纯几何,没有材质。a2_description、as2_description、g1_description这些较新的目录基本全是 STL。.obj:b2_description_mujoco/meshes/下用的是 obj。
h1_description 把这件事演到了极致——同一个连杆两份文件并排放着:
left_knee_link.STL
left_knee_link.dae
torso_link.STL
torso_link.dae
为什么要存两份?因为下游工具吃的格式不一样。这是一个很现实的工程妥协:与其让每个用户自己转格式,不如仓库里直接备齐。
b2_description_mujoco/meshes/ 里还藏着另一类东西:
fake_head_Link.STL
fake_imu_link.STL
fake_tail_link.STL
unitree_ladar.obj
带 fake_ 前缀的连杆,通常是为了挂一个坐标系而存在的虚拟连杆。IMU 装在机身某个位置上,你需要知道它相对于机身原点的精确位姿,才能把它测出来的加速度转换到机身坐标系。做法就是在 URDF 里定义一个几乎没有质量的连杆,用 fixed 关节焊在躯干上,位置就是传感器的实际安装位置。它不参与物理,只提供坐标系。
这个技巧在你自己写 URDF 时会反复用到。
xacro:为什么不直接写 URDF
看回 a1_description/xacro/ 那七个文件,答案就在文件名里:
const.xacro 常量:尺寸、质量、限位数值
materials.xacro 材质:颜色定义
leg.xacro 腿:一整条腿的宏
transmission.xacro 传动:关节与执行器的映射
gazebo.xacro 仿真:Gazebo 专用标签与插件
robot.xacro 总装:把上面这些拼起来
stairs.xacro 楼梯:场景物件
四足机器人有四条腿,结构完全一样,区别只是安装位置和左右镜像。如果直接写 URDF,你要把同样的 link 和 joint 定义抄四遍,每条腿三到四个关节,每个关节十几行 XML。抄完之后改一个尺寸,你得在四个地方同步改,改漏一个就是一台歪的机器人。
xacro 是 XML Macro 的缩写,它给 XML 加了三样东西:宏、参数、简单计算。于是 leg.xacro 里定义一次腿的宏,robot.xacro 里调用四次、传不同的位置和镜像参数,就完事了。const.xacro 把所有数值常量集中在一处,改一个数字全身生效。
xacro 不是运行时的东西。它在 launch 阶段被展开成标准 URDF,再交给下游。所以你会发现很多描述包里 xacro/ 和 urdf/ 是并存的:xacro 是源码,urdf 是编译产物,给那些不认识 xacro 的工具用。
这个仓库里的组织方式并不统一,反而更真实。a1、aliengo、b2、b2w、go1、go2 都是 xacro/ 加 urdf/ 双目录;b1_description 却把 URDF 直接扔进了 xacro 目录里(xacro/b1.urdf);a2_description 和 as2_description 干脆没有 xacro,只有 urdf/a2.urdf 加 meshes——这两个包看起来是纯模型包,不参与 Gazebo 仿真。
还有一个包值得单独说:aliengoZ1_description。它的内容是这样的:
robots/aliengoZ1_description/
├── CMakeLists.txt
├── package.xml
├── config/robot_control.yaml
├── launch/aliengoZ1_gazebo.launch
├── launch/aliengoZ1_rviz.launch
├── worlds/earth.world
└── xacro/
├── const.xacro
├── gazebo.xacro
├── robot.xacro
└── stairs.xacro
注意它没有 meshes 目录,也没有 leg.xacro。这是一个四足底盘加机械臂的组合包,几何资源从被组合的那两个包里来。这就是 xacro 的另一个价值:描述包之间可以互相引用,组合出新构型时不用复制任何网格文件。
同样的组合思路在 g1_description 里也能看到。那里有一组 URDF 文件,用后缀区分不同的关节配置和不同的末端装配——g1_23dof.urdf、g1_29dof.urdf、g1_29dof_lock_waist.urdf、g1_29dof_with_hand.urdf、g1_dual_arm.urdf 等等,还有一串 mode_11 到 mode_18 的变体。目录里另外放着一个 inspire_hand/ 子目录(里面是几份手部 URDF 加一个 config.yaml),以及一个 Jupyter notebook:
robots/g1_description/merge_g1_29dof_and_inspire_hand.ipynb
用脚本把机身 URDF 和手的 URDF 合并成新文件。手本身在 dexterous_hand_description/ 下有独立的一族描述(dex1_1、dex2_5、dex3_1、dex5_1),甚至还有 Left_Hand_G1_5010_Wrist.urdf 这种已经带上特定手腕的组合件。
URDF 是可组合的——这句话听起来平平无奇,但它意味着模型资产可以像库一样复用,而不是每出一个新构型就手工画一份新模型。
launch 和 config
launch/a1_rviz.launch 加 launch/check_joint.rviz,这两个是一对:前者是启动文件,后者是 RViz 的界面配置(存了摄像机视角、显示了哪些图层、TF 树怎么画)。
check_joint 这个名字值得停一秒。它是一个专门用来逐个检查关节的视图配置。写完 URDF 之后你必须验证一遍:每个关节的旋转轴对不对、正方向是不是符合预期、限位卡在哪儿。这活儿在 RViz 里配合关节滑块做最方便。仓库里把这个视图配置存下来共享出去,说明这是一个高频动作。
README 也给了对应命令:
roslaunch laikago_description laikago_rviz.launch
先在 RViz 里把模型看明白,再谈仿真。 这个顺序不能反——模型本身就错的话,你在 Gazebo 里看到的所有诡异现象都会把你引向错误的方向。
顺带一提,launch 文件的命名在这个仓库里也有代际差异:老机型用 <name>_rviz.launch,b2、b2w、h1 这些较新的用 display.launch 和 gazebo.launch。同一个仓库里两套命名习惯并存,这是长期演进的项目的正常样貌。
config/robot_control.yaml 留到下一节讲,它是喂给 ros_control 的。
Gazebo 要凑齐哪几样才能跑
仿真器不是把模型丢进去就能动的。它需要四样东西同时到位:几何、物理、驱动、世界。
几何和物理来自 URDF——就是上面说的 visual、collision、inertial。这部分描述包已经给全了。
驱动是关键的一环,也是最容易被忽略的。Gazebo 默认不知道你的关节该由谁来控制。URDF 里的 transmission 标签负责说明「这个关节由哪个执行器驱动、用什么接口」,然后 gazebo_ros_control 这个插件把 Gazebo 的关节和 ros_control 的控制器接起来。这就是为什么每个参与仿真的描述包里都有 transmission.xacro 和 gazebo.xacro——前者定义传动,后者塞 Gazebo 专用标签和插件声明。
世界来自 .world 文件。README 里说 wname 可以是 earth、space 或 stairs,默认是 earth。space 这个名字挺有意思,一个改重力常数的世界文件就能让你看到低重力下的步态是什么样——这是仿真独有的乐趣,真机上做不到。
关于世界文件,README 里还写了一个非常真实的坑。unitree_gazebo/worlds/stairs.world 文件末尾有这么一段:
<include>
<uri>model:///home/unitree/catkin_ws/src/unitree_ros/unitree_gazebo/worlds/building_editor_models/stairs</uri>
</include>
一个写死的绝对路径,而且是仓库作者机器上的路径。README 明确要求你改成自己机器上的实际路径。这类硬编码路径是 Gazebo 项目里的经典绊脚石,很多人第一次跑楼梯场景时看到的是一片空地,原因就在这儿。
传感器也是插件挂上去的
go1_description 里有一组文件把这件事说得很清楚:
xacro/depthCamera.xacro xacro/ultraSound.xacro
meshes/depthCamera.dae meshes/ultraSound.dae
深度相机和超声,各自一个 xacro 宏加一个网格。宏里做两件事:定义传感器的物理外形和安装位置(一个连杆加一个 fixed 关节),以及声明对应的 Gazebo 传感器插件——插件负责在仿真里生成假的点云或测距数据,并发布到 ROS 话题上。
b2_description_mujoco/meshes/unitree_ladar.obj 也是同类东西的痕迹。
传感器作为可插拔的 xacro 宏存在,这个设计的好处是:同一台机器人,你可以出一个带相机的版本和一个不带的版本,只需要在 robot.xacro 里多调用或少调用一次宏。仿真里跑视觉算法、跑激光 SLAM,靠的就是这套机制。想深入这条线的话,可以顺着传感器库那边的内容对照着看。
一个诚实的细节
README 里有句话我觉得特别值得引出来:启动 Gazebo 之后,机器人是趴在地上的,关节没有激活。
这不是 bug。仿真器把模型加载进去了,物理引擎开始算了,但没有任何控制器在往关节里送指令,所以它就是一堆被重力拉着的刚体,只能瘫着。你得再开一个终端,把控制器跑起来:
rosrun unitree_controller unitree_servo
它才会站起来。
加载模型和驱动模型是两件独立的事——这个认知很重要。很多人第一次跑仿真看到机器人瘫在地上就以为自己装错了。
README 还提到另一件事,措辞很明确:Gazebo 仿真里能做的是低层控制(力矩、位置、角速度),做不了高层控制,也就是走路。走路那套步态和平衡控制不在这个仿真包的能力范围内。这个边界要记清楚,仓库文档写了什么就是什么,没写的不要脑补。
unitree_controller 里的三个可执行
README 列了三个节点,各有各的意思:
rosrun unitree_controller unitree_servo # 站立控制
rosrun unitree_controller unitree_external_force # 施加外部扰动
rosrun unitree_controller unitree_move_kinetic # 位置与姿态发布
unitree_external_force 这个值得多说一句。README 描述它是「add external disturbances, like a push or a kick」——加一个推力或者踢一脚。
这正是仿真相对真机最大的优势之一:扰动可以精确定量,而且可以完全复现。 你在真机上踹机器狗一脚,力多大、方向多准、作用了多久,全凭手感,而且第二脚不可能和第一脚一样。仿真里这些都是参数,改一个数字重跑,实验条件严格一致。调平衡控制器的时候,这种可复现性是刚需。
unitree_move_kinetic 对应的源文件是 unitree_controller/src/move_publisher.cpp。README 说它演示的是「不用控制器直接控制机器人的位置和姿态」,让机器人绕原点转圈,并且源码里有个 def_frame 变量,改成 coord::ROBOT 再重新 catkin_make,运动就变成在机器人自身坐标系下进行。
这个节点的用途 README 也直说了:对 SLAM 或视觉开发有用。想想就明白——你要调一个建图算法,根本不关心腿怎么迈的,你只想让机身按一条已知轨迹在场景里移动,然后看传感器数据和建图结果。这时候把腿的动力学整个绕开,直接瞬移机身,是最高效的做法。
仿真不一定要仿全套。按需求砍掉不关心的那部分,是很正当的工程决策。
ros_control:controller 和硬件之间的插座
unitree_legged_control 这个包,README 的描述是:包含用于 Gazebo 仿真的关节控制器,允许用户以位置、速度、力矩三种方式控制关节,示例代码在 unitree_controller/src/servo.cpp。
它是一个 ros_control 的控制器插件。要理解它在什么位置,得先搞清 ros_control 这套东西的角色划分。
README 的依赖安装命令里,把角色名字全列出来了:
sudo apt-get install \
ros-melodic-controller-interface \
ros-melodic-gazebo-ros-control \
ros-melodic-joint-state-controller \
ros-melodic-effort-controllers \
ros-melodic-joint-trajectory-controller
Kinetic 那条命令更长,还多出 controller-manager、ros-control、ros-controllers、velocity-controllers、position-controllers、robot-state-publisher。(仓库文档写的运行环境是 ROS Melodic 或 Kinetic 加 Gazebo8,这是 ROS1 时代的组合,具体版本要求以官方仓库当前文档为准。)
把这些名字翻译成角色,大致是这么几层:
hardware_interface —— 插座。 这是整套设计的枢纽。它定义了一组标准接口:读关节位置、读速度、读力矩,写位置指令、写速度指令、写力矩指令。控制器只认这个接口,不认接口后面是什么。
仿真时,gazebo_ros_control 实现这个接口,读写的是 Gazebo 里的虚拟关节。真机时,厂商的驱动实现这个接口,读写的是真实电机。
同一个控制器,插在不同的插座上,就分别在仿真和真机上跑起来了。 这是 ros_control 存在的全部理由,也是整套 ROS 控制生态最值钱的一个抽象。
controller —— 插头。 上面那串包名里的几种:
joint_state_controller:最特殊的一个,它不控制任何东西,只读。把所有关节的当前位置、速度、力矩打包成sensor_msgs/JointState消息发到/joint_states话题上。它是纯粹的状态出口。effort_controllers:力/力矩控制。足式机器人上用得多,因为腿的柔顺性、落地缓冲这些事必须在力这一层做。position_controllers/velocity_controllers:位置控制和速度控制。joint_trajectory_controller:轨迹控制。你给它一串带时间戳的关节路点,它负责插值执行。机械臂上最常用。
unitree_legged_control 就是宇树自己实现的一个控制器插件,README 说它同时支持位置、速度、力矩三种模式。这类底层关节控制的实现思路,跟机器人 PID 控制那篇讲的是同一件事,只是搬到了 ROS 的插件框架里。
controller_manager —— 配电盘。 负责加载、启动、停止、切换控制器。它读的配置就是每个描述包里那个 config/robot_control.yaml——哪些控制器要加载、每个挂在哪个关节上、增益参数是多少,全在这个 YAML 里。
这就解释了一个之前留下的疑问:为什么控制器的参数文件会放在 description 包里,而不是控制包里?因为参数是跟具体机型走的。A1 的膝关节增益和 B2 的不可能一样,但用的是同一个控制器实现。控制器实现归控制包,控制器参数归描述包,这个划分是对的。
robot_state_publisher —— 翻译官。 它订阅 /joint_states,同时读 URDF,把两者一合,算出每个连杆此刻在空间里的位姿,发布成 TF 树。
这个节点是下一节的主角。
一套 URDF,喂给所有人
到这里,这篇文章真正想说的那件事可以摆出来了。
回头看:URDF 描述了连杆、关节、几何、惯量。这份描述被谁用了?
Gazebo 用它。 加载模型、算物理、算碰撞。
RViz 用它。 但 RViz 不直接读 URDF——它读的是 robot_state_publisher 算出来的 TF 树加上 URDF 里的视觉体。所以 a1_rviz.launch 背后的链条是:URDF 进 robot_state_publisher,关节角度从 /joint_states 来,TF 树出去,RViz 按 TF 摆放各个连杆的网格。你在屏幕上看到的那台机器人,是 URDF 加实时关节角度渲染出来的。
运动学求解器用它。 URDF 里的连杆长度和关节轴向,本身就是一棵完整的运动学树。KDL 这类库可以直接从 URDF 构建运动学模型,正解逆解都不用你手写 DH 参数。如果你手推过机械臂的正逆运动学,就知道这件事省掉了多少力气——机械臂运动学那篇讲的那套推导,在 ROS 里是可以从模型文件自动生成的。
碰撞检测和运动规划用它。 collision 几何直接就是规划器做自碰撞检查的输入。
其他仿真器也用它。 这是最有力的一条证据。
看这个仓库里 MuJoCo 相关的文件:
robots/b2_description_mujoco/xml/b2.xml
robots/b2_description_mujoco/xml/b2_description.urdf
robots/b2_description_mujoco/xml/scene.xml
robots/h1_description/mjcf/h1.xml
robots/h1_description/mjcf/h1_with_hand.xml
robots/h1_description/mjcf/scene.xml
robots/g1_description/g1_29dof.xml
robots/h2_description/H2_loop.xml
MJCF 是 MuJoCo 的模型格式。注意 b2_description_mujoco/xml/ 下 b2.xml 和 b2_description.urdf 是并排放着的——URDF 在旁边,MJCF 从它来。scene.xml 则是 MuJoCo 的场景文件,相当于 Gazebo 那边的 .world:机器人模型加地面、光照、摄像机,组装成一个可运行的场景。
再看另一个仓库 unitree_model。它整个仓库只装一样东西——USD 格式的模型文件:
G1/29dof/usd/g1_29dof_rev_1_0/
├── g1_29dof_rev_1_0.usd
└── configuration/
├── g1_29dof_rev_1_0_base.usd
├── g1_29dof_rev_1_0_physics.usd
└── g1_29dof_rev_1_0_sensor.usd
USD 是 Isaac Sim 那条线用的格式。而这个仓库的 README 里有一节标题就叫 「Generate from urdf」(从 URDF 生成),下面给的是 URDF 导入的操作要点:连杆选 Movable Base、关节配置选 Stiffness、驱动类型选 Force、允许自碰撞。
白纸黑字:这些 USD 是从 URDF 转出来的。
而且 USD 那边的目录拆分方式也很值得看一眼——_base / _physics / _sensor 三个文件分层。几何是几何,物理是物理,传感器是传感器。跟 URDF 里 visual / collision+inertial / sensor 的分工是完全同一个思路,只是换了个生态、换了种文件组织方式。
(顺带记一笔:unitree_model 的 README 顶部标注该仓库已废弃,后续更新迁到了 Hugging Face 的数据集。这是仓库自己写的,用之前先看一眼当前状态。)
所以整张图是这样的:
URDF / xacro
(唯一的事实来源)
│
┌───────────┬───────┴───────┬────────────┬──────────┐
▼ ▼ ▼ ▼ ▼
Gazebo RViz 运动学求解 MuJoCo Isaac Sim
物理仿真 可视化 KDL / 规划 MJCF USD
模型定义一次,到处使用。 这是 ROS 生态最大的价值之一,也是为什么哪怕你最后不打算用 ROS 跑生产环境,也值得把 URDF 写规范——它是你的模型资产,能带着你换仿真器、换算法框架。
反过来说,这也解释了为什么 description 包值得花时间做好。质量差的 URDF——惯量瞎填、碰撞体没简化、关节限位不准——污染的不是一个工具,是下游所有工具。
如果你还没建立起对机器人本体结构的直觉,建议先补一下机器人由哪些部分组成,再回来看 URDF 里那些 link 和 joint,会具体很多。
那座桥:unitree_ros_to_real
仿真跑通了,接下来要上真机。这时候你会发现需要一个新的仓库:unitree_ros_to_real。
为什么不能直接用? 因为仿真那边关节是被 Gazebo 插件驱动的,而真机上的关节握在机器人自己的实时控制器手里,ROS 根本碰不到。中间必须有一层翻译。
这个包的结构非常紧凑,一共两个 ROS 包:
unitree_legged_msgs/ 消息定义
└── msg/
├── HighCmd.msg HighState.msg
├── LowCmd.msg LowState.msg
├── MotorCmd.msg MotorState.msg
├── IMU.msg Cartesian.msg
├── BmsCmd.msg BmsState.msg
└── LED.msg
unitree_legged_real/ 接口实现
├── include/convert.h
├── launch/real.launch
├── launch/keyboard_control.launch
└── src/exe/
├── ros_udp.cpp
├── example_walk.cpp
├── example_position.cpp
├── state_sub.cpp
├── twist_sub.cpp
└── control_via_keyboard.cpp
消息定义为什么单独成包
先注意一个容易滑过去的事实:unitree_ros 的依赖列表里明确写着,它需要 unitree_legged_msgs,而这个包在 unitree_ros_to_real 仓库里。
仿真仓库依赖真机仓库的消息定义。
这个方向乍看有点别扭,想通了很合理:消息定义是公共词汇表。仿真侧和真机侧必须说同一种话,否则你在仿真里调好的控制器代码,搬到真机上要重写一遍数据结构。把词汇表抽出来单独成包、两边共用,是 sim2real 能成立的第一步——也是最基础、最容易被跳过的一步。
msg 清单本身也说明了不少事。HighCmd/HighState 和 LowCmd/LowState 成对出现,这跟 SDK 那边 high_level / low_level 的分野是一致的——unitree_sdk2 架构那篇里也见过同样的划分。MotorCmd/MotorState 是单个电机的颗粒度,是低层控制的基本单位。IMU 是姿态传感,Cartesian 是笛卡尔坐标(足端位置这类),BmsCmd/BmsState 对应电池管理,LED 对应灯效。
一个通信接口暴露了哪些消息类型,基本就框定了它能做什么。
convert.h:桥的核心就一个头文件
unitree_legged_real/include/convert.h。名字直白得可爱——转换。
ROS 消息和 SDK 的 C++ 结构体是两套内存布局,桥接层要做的核心工作就是在这两套之间来回映射。这类代码写起来枯燥,但它是整座桥的承重结构。
src/exe/ros_udp.cpp 则是桥的另一半:跑 UDP 收发的那个节点。README 里说明这套连接是网线直连——把网线插在 PC 和机器人之间,ifconfig 找到端口名,改 ipconfig.sh 里的端口名并执行,让这张网卡拿到静态 IP,进到机器人所在的网段。想让它开机自动生效,就去改 /etc/network/interfaces。
这一步是真机开发里卡住新手最多的地方,而且它跟 ROS 一点关系都没有——纯粹是 Linux 网络配置。
一个 launch,两种控制层级
roslaunch unitree_legged_real real.launch ctrl_level:=highlevel
# 或
roslaunch unitree_legged_real real.launch ctrl_level:=lowlevel
ctrl_level 是个参数。你在启动时就要决定这次是走高层还是低层,两者不能混。
对应的示例节点也分两套:高层跑 ros_example_walk(控制行走方向和速度),低层跑 ros_example_postion(控制所有关节)。另外还有 state_sub 用来订阅机器人反馈的状态,以及一个 keyboard_control.launch——README 说它让你能像玩 turtlesim 一样用键盘控制机器人。
src/exe/twist_sub.cpp 这个文件特别值得注意。Twist 是 geometry_msgs/Twist,ROS 里最通用的速度指令消息——线速度加角速度。订阅 Twist 意味着任何会发 Twist 的上游都能直接驱动这台机器人:键盘遥控节点、手柄节点、move_base 之类的导航栈、甚至你自己写的任何一个规划器。
这是接口对齐的价值。桥接层只要把 Twist 翻译成厂商的高层指令,整个 ROS 生态里成千上万个发 Twist 的东西就全接上了。这也是为什么机器人开发要用 ROS 那篇里反复强调的一点:生态的价值在于接口的统一,不在于某个具体功能包写得多好。
README 里的安全提示,别跳过
这段我原样转述,因为它是安全红线:
在做低层控制之前,README 要求先按 L2+A 让机器人坐下,再按 L1+L2+start 进入可做关节级控制的模式,并且——务必先把机器人吊起来。
为什么?因为低层控制是你直接往每个关节写指令。你的代码里一个符号写反、一个增益给大了,机器人就会以最大力矩把自己甩出去。吊起来意味着即使控制器完全失控,它也只是在空中乱蹬,而不是砸向地面或者砸向你。
在仿真里翻车只是重启一下,在真机上翻车是几万块钱和一次事故。 从仿真跨到真机的那一刻,成本函数完全变了。
版本耦合是桥接层的固有代价
README 里有一件事说得很明确:这个包的版本和 unitree_legged_sdk 的某个 release 绑定,而不同的 SDK release 支持的机型不一样,配置时要下载对应版本、放到工作空间的 source 目录下,而且文件夹必须叫 unitree_legged_sdk,不能带版本后缀(因为 CMake 里是按这个名字找的)。文件树里那个空的 unitree_legged_sdk 条目和根目录的 .gitmodules,说明 SDK 是以 git 子模块的形式挂进来的。
具体哪个版本对哪个机型,以官方仓库当前文档为准——这类对应关系会变,记死了反而害人。
要理解的是背后的原理:桥接层必然强耦合它桥接的两端。 一端是 ROS 的消息与 launch 体系,一端是厂商 SDK 的私有协议。任何一端改了,桥就得跟着改。这是这类包的固有代价,不是设计缺陷。
把这个仓库当教材读
如果你想系统学 ROS 机器人开发,这个仓库是个不错的样本。给一条读法:
第一步,只读一个 description 包。 挑 a1_description,把 xacro/ 下七个文件全打开看一遍。const.xacro 看常量怎么组织,leg.xacro 看宏怎么写和怎么处理镜像,robot.xacro 看总装怎么调用宏,transmission.xacro 看关节和执行器怎么绑定。这一步看懂了,你就能自己写 URDF 了。
第二步,在 RViz 里把模型看明白。 用 check_joint.rviz 那个视图,拖每个关节的滑块,确认旋转轴和限位符合你的预期。这一步是所有后续工作的地基。
第三步,再进 Gazebo。 记住 README 说的:起来时机器人是趴着的,得单独跑控制器才会站。跑 unitree_external_force 推它一把,看看控制器的抗扰能力——这是仿真最好玩也最有用的部分。
第四步,读 ros_control 的配置。 打开 config/robot_control.yaml,对照上面讲的角色划分,看清楚哪个控制器挂在哪个关节上、参数怎么给的。
第五步,最后才是真机。 读 unitree_ros_to_real,从 convert.h 和 ros_udp.cpp 入手。上电之前把 README 的安全提示读三遍。
有几件事需要提前知道:这个仓库是 ROS1 血统——catkin_make、Melodic/Kinetic、Gazebo8,都是 ROS1 时代的东西。如果你的目标是 ROS 2,接法不一样,那条线我们在这一卷的其他文章里单独讲。另外 README 有一句实在话也一并转述:如果 catkin_make 遇到依赖问题,再跑一次通常就好了——包之间的编译顺序在首次构建时可能没排对。
最后,回到开头那个问题:拿到任何一个陌生的 ROS 机器人仓库,怎么快速定位?
- 先按名字分三堆:
*_description是模型,带gazebo/sim的是仿真,带control/controller的是驱动。 - 进 description 包,先找 URDF 或 xacro,那是这台机器的身体定义,也是所有下游工具的共同输入。
- 看
meshes/里有没有简化碰撞网格——有,说明作者认真对待过物理仿真;没有,你可能要自己补。 - 看
config/下的 YAML,控制器参数在那儿,机型差异也在那儿。 - 看有没有单独的 msgs 包,有的话它就是这套系统的公共词汇表,值得第一个读。
- 找桥接包,名字里带
real、hardware、driver、bridge的,那是从仿真通往真机的路。
想动手做点什么的话,不必上来就啃足式机器人。机器人与具身智能卷里有几个用 ESP32 就能做的项目,差速驱动小车是个很好的起点——把小车的 URDF 自己写一遍、在 RViz 里看它转起来,你对本文讲的这套东西的理解会牢固得多。等这些基础打通了,再回头读工业级仓库,很多当时看不懂的目录安排会突然变得理所当然。