← 返回文章库

宇树 Z1 机械臂:从 SDK 和 controller 两个仓库看一条机械臂怎么被控制

最后更新 2026-08-23
⏱ 约 26 分钟 🟡 涉接线/强电
你将学到
  • 看懂机械臂控制系统的三层分工:实时控制器、用户 SDK、ROS 生态对接层
  • 从 z1_controller 的有限状态机与轨迹目录,读出一台机械臂运行时都要管哪些事
  • 分清关节空间与笛卡尔空间、正运动学与逆运动学,理解为什么必须做轨迹规划

机械臂在外行眼里是个很朴素的东西:几个电机串起来,一节带一节,末端挂个夹爪。听上去比机器狗简单多了——狗要考虑平衡、要考虑腾空相,机械臂底座是拧在桌子上的,不会摔。

但真动手你就会发现,「让它动」和「让它准确地动到你要的那个位置」,是两个量级的问题。

前者你给电机通个电就能看见胳膊晃起来。后者你要回答:从当前姿态到目标姿态,中间每一个毫秒各个关节应该在哪个角度?速度怎么起、怎么收?途中要不要过某个中间点?夹爪什么时候闭合?如果中途通信断了、某个关节报错了,整条臂应该怎么停?——这些问题一个都绕不过去,而它们加起来就是一整套控制系统。

宇树把 Z1 这条机械臂的软件拆成了四个独立仓库:z1_controllerz1_sdkz1_rosz1_joystick。这个拆法本身就是教材,因为它把机械臂控制系统的分层,直接摊在了仓库列表上

先把这篇的边界说清楚:下面所有结论都来自这几个公开仓库的 README 和完整文件树,我手上没有 Z1 实机,没有跑过任何一行代码,也不会去谈任何硬件参数。我们只读结构,读设计意图。运行效果一律以官方文档为准。

为什么一条机械臂要四个仓库

如果一套软件只服务一个使用场景,它没有理由拆成四份。拆开,说明这四份东西的运行位置、更新节奏、依赖关系都不一样。

粗看一眼各自的定位:

z1_controller  —— 控制器本体,跑在贴近硬件的那一侧
z1_sdk         —— 给用户程序调用的接口层
z1_ros         —— ROS 生态对接(仿真、可视化、MoveIt)
z1_joystick    —— 手柄控制

z1_ros 的 README 里有一句很关键的提醒:它自带的 z1_controller和独立的 z1_controller 仓库不是同一个东西,而且独立的 z1_sdk 仓库与这个 ROS 包不兼容

这句话初看很劝退——同一个名字两套代码,还互不兼容。但它其实透露了一个重要事实:走 ROS 那条路和走原生 SDK 那条路,是两条并行的技术栈,不是同一条路上的两级台阶。你得先选路,再动手,中途换车的成本不低。这种事写在 README 顶部而不是藏在 FAQ 里,是负责任的做法。

下面逐个拆。

z1_controller:一台机械臂的运行时长什么样

这个仓库的 README 只有两行文档链接,正文几乎为零。但它的文件树有八十多个路径,信息密度极高。把 include/ 下的目录列出来:

include/FSM/           —— 有限状态机,十几个状态
include/trajectory/    —— 轨迹生成
include/control/       —— 控制组件与指令来源
include/interface/     —— IO 接口抽象
include/message/       —— 消息定义
include/model/         —— 机械臂模型与夹爪
include/common/        —— 数学、滤波、计时、CSV
include/thirdparty/    —— 第三方库

七个目录,基本就是一台机械臂运行时的全部职责清单。挨个看。

FSM:状态机才是控制器的主干

include/FSM/ 下面除了三个基类文件(BaseState.hFSMState.hFiniteStateMachine.h),是一串以 State_ 开头的头文件:

State_Passive.h        State_Calibration.h
State_BackToStart.h    State_LowCmd.h
State_JointSpace.h     State_Cartesian.h
State_MoveJ.h          State_MoveL.h        State_MoveC.h
State_Trajectory.h     State_ToState.h
State_Teach.h          State_TeachRepeat.h  State_SaveState.h

这份清单值得停下来看一分钟,因为它几乎把工业机械臂的操作语汇列全了。

Passive 是被动状态,也就是不主动出力。任何一台会动的机器,第一个要有的状态就是「什么都不做」——这是所有安全逻辑的落点。Calibration 是标定。BackToStart 是回到起始位姿,这是一个高频操作:每次跑完一段动作,你都想让它回到一个已知的、干净的姿态。

中间三个 MoveJ / MoveL / MoveC 是工业机械臂里最经典的三种运动指令:按关节插补移动、走直线、走圆弧。它们不是三种「实现方式」,而是三种你想要的末端轨迹形状。同样是从 A 点到 B 点,MoveJ 只保证两端对得上,中间末端走的是一条谁也说不清的曲线;MoveL 保证末端在空间里走直线;MoveC 走圆弧。喷涂、涂胶、切割这类任务对轨迹形状有硬要求,MoveJ 不够用。

JointSpaceCartesian 这一对,对应的是两种坐标语言——下一节会展开。

最后三个是我最喜欢的一组:TeachTeachRepeatSaveState。示教、示教复现、保存状态。再看仓库根目录下有个 config/savedArmStates.csvcommon/utilities/ 里有个 CSVTool.h

三个线索拼起来,一条完整的工作流就浮出来了:你用手把机械臂拖到你想要的姿态,控制器把关节角记进 CSV,之后它能照着复现一遍。这是机械臂领域最实用的一个功能——很多现场根本没人会写代码,示教就是他们的编程方式。把它做进状态机,而不是做成一个外挂脚本,说明这是被当作一等公民对待的能力。

State_LowCmd 单独存在,意味着状态机里专门留了一个「让外部直接下发底层指令」的口子。这个口子后面会和 SDK 接上。

trajectory:为什么给个目标点不够

include/trajectory/ 有八个文件:

Trajectory.h           TrajectoryManager.h
JointSpaceTraj.h       EndLineTraj.h
EndCircleTraj.h        EndHomoTraj.h
SCurve.h               StopForTime.h

EndLineTraj / EndCircleTraj 显然对应 MoveL / MoveCJointSpaceTraj 对应 MoveJ。真正值得讲的是另外两个。

SCurve.h——S 曲线。这是运动控制里的一个核心概念:从静止到运动,速度不能是阶跃的。如果你直接把目标速度甩给电机,加速度理论上是无穷大,实际表现就是猛地一顿、整条臂抖一下、末端过冲。S 曲线让加速度本身也平滑变化,代价是同样距离花的时间稍长,换来的是不抖、不冲、机械结构受力小。

一个仓库里有没有 S 曲线,基本能判断它是在「做玩具」还是在「做设备」。

StopForTime.h 是另一个信号——「停一段时间」也被建模成了一条轨迹。这说明作者把整个运动序列统一成了「一段接一段的轨迹」,等待也是轨迹的一种。这样 TrajectoryManager 就能用一套逻辑串起任意复杂的动作序列,而不需要在外面再包一层调度器。这种把特例收编进通用抽象的做法,通常是代码写到第二版之后才会出现的。

interface 与 message:硬件在哪一侧被隔开

include/interface/ 只有两个文件:IOInterface.hIOUDP.h。一个抽象基类,一个 UDP 实现。

而仓库根目录下还有个 sim/ 目录:

sim/IOROS.h    sim/IOROS.cpp
sim/sim_ctrl.cpp
sim/CMakeLists.txt    sim/package.xml

IOROS 显然是 IOInterface 的另一个实现,走的是 ROS 那侧。合起来看,这个设计的意图非常清楚:控制器的全部上层逻辑——状态机、轨迹、模型——对「指令最终发给谁」是无感的。接真机就换成 IOUDP,接仿真就换成 IOROS

这是分层最直接的一次兑现。它带来的好处是:你可以在没有硬件的情况下,把状态机和轨迹逻辑全部调通。

include/message/ 里的六个文件也很有讲究:

LowlevelCmd.h    LowlevelState.h
MotorCmd.h       MotorState.h
arm_common.h     udp.h

注意这里有两层指令MotorCmd / MotorState 是单个电机层面的,LowlevelCmd / LowlevelState 是整条臂层面的。控制器要同时管这两层,把「整条臂的期望状态」翻译成「每个电机的指令」。

记住这四个文件名,等下看 SDK 的时候会有对照。

model、config 与那些不起眼的角落

include/model/ 下是 ArmModel.hunitree_gripper.h。机械臂模型和夹爪被分成两个文件——夹爪是可换的末端执行器,和臂体本身不是一回事,分开是对的。

include/common/math/ 里除了 mathToolsmathTypesrobotics.h,还有一个 Filter.h。滤波器出现在控制器这一侧而不是 SDK 那一侧,说明信号处理是贴着硬件做的——传感器读数带噪声,得在最靠近它的地方先滤干净,再往上层送。

include/thirdparty/ 里两个库:quadProgpp 是二次规划求解器,tinyxml 是 XML 解析。后者对应 config/config.xml——配置走 XML 文件,不是硬编码。前者的用途我不去猜作者的具体考虑,但二次规划在机械臂里最常见的场合是带约束的求解:在满足关节限位、速度上限这些约束的前提下,找一个最优解。

还有几个细节:common/utilities/loop.htimer.h——周期性循环和计时器,控制程序的骨架。control/cmdPanel.hkeyboard.h——指令来源被抽象成了「面板」,键盘是其中一种实现。control/armSDK.h——这就是控制器这一侧对接 SDK 的入口。

最后是 deploy/ 目录:

deploy/aarch64/z1_udp_service
deploy/x86_64/z1_udp_service

两个架构,各一个预编译好的可执行文件,名字叫 z1_udp_servicex86_64 是你的开发机,aarch64 是嵌入式计算板。和 unitree_sdk2 的架构拆解里看到的双架构预编译是同一个套路,反映的是同一种开发场景。

z1_sdk:用户程序那一侧,口子开得很窄

翻到 z1_sdk,第一感受是它比 controller 小得多。四十来个路径,README 也只有两行文档链接。

看它的 include/unitree_arm_sdk/

control/ctrlComponents.h    control/unitreeArm.h
math/mathTools.h            math/mathTypes.h       math/typeTrans.h
message/LowlevelCmd.h       message/LowlevelState.h
message/arm_common.h        message/udp.h
model/ArmModel.h
utilities/loop.h
thirdparty/robotics.h       thirdparty/quadProgpp/

把它和 controller 的 message/ 对照着看,一个差异立刻跳出来:SDK 这边没有 MotorCmd.hMotorState.h

这不是遗漏,这是边界。SDK 暴露给你的最底层是 LowlevelCmd——整条臂的关节级指令;再往下的单电机通信,留在控制器里,你碰不到,也不需要碰。上层库故意不透出的东西,往往比它透出的东西更能说明设计者的想法:能在下面解决的问题,不要让上面的人操心

同时注意,ArmModel.hquadProgpprobotics.hmathTools 在两边都有。运动学模型和数学工具两侧都需要——你在自己的程序里算目标位姿,控制器在自己那边算实际执行,两边用的必须是同一套模型,否则算出来的结果对不上。

examples/ 目录的命名把 SDK 的使用方式说得很直白:

examples/highcmd_basic.cpp
examples/highcmd_development.cpp
examples/lowcmd_development.cpp
examples/lowcmd_multirobots.cpp

highcmd 和 lowcmd 两条线。高层指令让你说「移到这个位姿」,控制器负责规划和执行;低层指令让你直接下发关节级的目标,规划由你自己来。前者省事,后者自由。lowcmd_multirobots 这个名字还说明了一件事——同一个程序里控制多条臂是被考虑过的场景。

examples_py/ 更有意思:

examples_py/arm_python_interface.cpp
examples_py/example_highcmd.py
examples_py/example_lowcmd.py
examples_py/example_model.py
examples_py/example_http_client.py
examples_py/example_http_service.py
examples_py/unitree_arm_interface.pyi

arm_python_interface.cpp 是 C++ 写的绑定层,.pyi 是类型存根文件——有了它,你的编辑器才能对 Python 接口做补全和类型提示。这是个很体贴的细节,很多绑定库都懒得提供。

lib/ 里除了两个架构的动态库,还躺着一个编译好的 Python 扩展 .so,文件名里带着具体的 Python 版本和平台标记。这意味着这个预编译的绑定是和特定 Python 版本绑定的,换个版本可能要自己重新编译(具体以官方仓库当前文档为准)。踩过这个坑的人不少,提前知道能省半天。

最后是那对 example_http_client.py / example_http_service.py。HTTP 服务出现在示例里,说明官方设想过「把机械臂包成一个网络服务,别的程序通过 HTTP 调它」这种用法。这在多机协同、或者把机械臂接进一套已有的业务系统时很实用。

z1_ros:接进 ROS 之后,控制器反而变瘦了

z1_ros 是三个仓库里文件最多的,一百七十多个路径。README 把它的包结构写得很清楚:

z1_bringup        —— 启动 MoveIt 驱动所需组件
z1_controller     —— 直接控制机械臂、并与 SDK 连接
z1_hw             —— 机械臂与 MoveIt 之间的硬件接口
z1_moveit_config  —— MoveIt 配置示例
z1_rviz           —— RViz 启动与配置
z1_examples       —— MoveIt 使用示例
z1_sdk            —— UDP 通信示例

z1_bringup/launch/ 下四个 launch 文件的命名很整齐:real_arm.launchreal_ctrl.launchsim_arm.launchsim_ctrl.launch。真机与仿真两套,各自再分「臂」和「控制」,组合关系一目了然。

按 README 的说法,z1_hw 会为机械臂创建一个 joint_trajectory_controller,并为夹爪创建一个 action server。这是标准的 ROS 控制接口范式:关节轨迹用控制器,夹爪开合用动作服务——因为开合夹爪是一个有起点有终点、需要知道成没成功的动作,而不是一条持续的数据流。

z1_moveit_config/config/ 这一堆配置文件把 MoveIt 的能力面摊开了:

kinematics.yaml            —— 运动学求解器配置
joint_limits.yaml          —— 关节限位
cartesian_limits.yaml      —— 笛卡尔空间限速
ompl_planning.yaml
chomp_planning.yaml
stomp_planning.yaml
z1_description.srdf
sensors_3d.yaml

launch/ 里还有对应的 ompl_planning_pipeline.launch.xmlchomp_planning_pipeline.launch.xmlstomp_planning_pipeline.launch.xml,以及一个 pilz_industrial_motion_planner_planning_pipeline.launch.xml

同一条机械臂配了多套规划器,这本身就说明「路径规划」不是一个有标准答案的问题。不同的规划器擅长的场景不一样:有的偏向快速找到可行解,有的偏向优化轨迹的平滑度与代价。MoveIt 把它们做成可切换的 pipeline,你按任务选。

sensors_3d.yaml 的存在说明 MoveIt 的三维感知避障是留了接口的——但仓库里我只看到配置文件,具体接什么传感器不在本文讨论范围。

现在回到开头那个「两个 z1_controller 不是同一个东西」的提醒。把 ROS 版的 FSM 列出来对照:

State_Passive.h      State_Init.h        State_Calibration.h
State_JointCtrl.h    State_LowCmd.h
State_Teach.h        State_TeachRepeat.h
State_ClearError.h

差异非常明显:MoveJ / MoveL / MoveC / Cartesian / Trajectory 这些状态在 ROS 版里都不见了

这就顺理成章了——在 ROS 那条路上,路径规划的活由 MoveIt 干,控制器只需要老老实实执行 MoveIt 规划出来的关节轨迹。所以它退化成了一个更薄的执行层,只保留状态管理、标定、示教和底层指令这些必须贴着硬件做的事。

反过来,ROS 版多出了一个 State_ClearError.h——清除错误。这个状态在独立版的 FSM 里没有对应项。它只说明 ROS 版把「错误清除」显式建模成了一个状态,不代表另一边处理不了错误。ROS 版还有个 error/errorClass.herror/Color.h,后者大概率是终端彩色输出,属于调试体验的小优化。

顺带一提,ROS 版的目录组织方式也和独立版不同,它把数学、循环、文件、IO 端口全收进了 UnitreeArmModule/ 这个大目录下。同一批人写的两套代码,组织习惯都能有这么大差别,这在实际工程里太常见了——目录结构反映的是「写的时候在想什么」,不是某种永恒真理

z1_joystick:人工兜底是常态,不是退路

z1_joystick 这个仓库的描述只有一句:用宇树手柄控制 Z1。

单独为手柄开一个仓库,加上我们在 unitree_sdk2 架构拆解里看到的——gamepad.hpp 这个文件在那套 SDK 的示例目录里反复出现,几乎每个型号的示例都带一份。

两处放在一起看,结论就清楚了:在真机开发里,「人拿着手柄随时能接管」不是给新手的玩具功能,而是工程标配

原因很实际。自动控制程序一定会有跑飞的时候——参数没调好、目标位姿算错了、传感器读数异常。这种时候你需要一个不依赖上层逻辑的通道,能立刻把机器接管过来或者让它停住。手柄就是这个通道。它的优先级往往比程序更高,链路也更短。

调试期用手柄兜底,然后逐步把手柄能做的事交给程序——这是真机开发的常规节奏。跳过这一步直接上自动,代价通常是硬件。

补一节原理:关节空间、笛卡尔空间和轨迹规划

上面反复出现 JointSpaceCartesian 这一对词。如果你没接触过机械臂,这里需要补一段基础。这部分是通用原理,不特指任何型号。

关节空间用「每个关节各转到多少度」来描述机械臂的状态。这是机器自己的语言——电机能直接理解。

笛卡尔空间用「末端在空间中的位置和姿态」来描述。这是人的语言——你想让夹爪去桌上那个杯子的位置,你脑子里想的是 xyz 和朝向,不是六个角度值。

两种语言之间的翻译,就是运动学:

  • 正运动学:已知各关节角度,求末端在哪。这个方向永远有唯一解,一路矩阵乘下去就行。
  • 逆运动学:已知末端要到哪,求各关节角度。这个方向要麻烦得多——可能有多组解(同一个末端位置,胳膊「肘朝上」和「肘朝下」都能达到),可能一个解都没有(目标点在工作空间之外),还可能在某些位形附近解会剧烈跳变。

这就是为什么 ArmModel.h 要在 controller 和 SDK 两边都存在,也是为什么 MoveIt 要单独有个 kinematics.yaml 来配求解器。逆运动学不是一个「调库就完事」的问题。想把这块彻底搞明白,我们在机械臂运动学基础那篇里有更系统的推导。

理解了这两个空间,就能回答最后一个问题:为什么不能直接把目标点丢给机械臂,让它自己过去?

三个原因,缺一不可。

第一,中间过程也是要负责的。起点和终点之间有无穷多条路。有的路会撞到工作台,有的路会让某个关节转到限位,有的路会让整条臂经过奇异位形——在那附近,末端移动一点点,关节就要疯狂旋转。轨迹规划就是在这无穷多条路里挑一条安全的。

第二,速度不能突变。前面讲 SCurve 时说过:没有加减速规划,机械结构会被自己的惯性反复敲打。轨迹规划要把整段运动的速度和加速度曲线都排好。

第三,执行需要有节奏。控制器是按固定周期跑的,每个周期它需要知道「此刻各关节应该在哪」。轨迹规划的产物不是一个终点,而是一条按时间采样的序列。有了这条序列,底层的位置环、速度环才有东西可跟——这一层的原理和PID 控制是相通的,只是被控对象从一个电机变成了一组耦合的关节。

所以从上到下,一条完整的链是这样的:

你的意图(去抓那个杯子)
    ↓  笛卡尔空间的目标位姿
逆运动学 + 路径规划
    ↓  关节空间的路径点序列
轨迹生成(加减速、S 曲线)
    ↓  按周期采样的关节目标
关节控制环
    ↓  电机指令
硬件

Z1 这几个仓库的分层,基本就是照着这条链切的。z1_sdk 和 MoveIt 在链条上半段,z1_controller 在下半段。

对照 UniArmL1:同样是机械臂,出发点完全不同

同一个组织下还有另一条机械臂线:UniArmL1。把它和 Z1 放在一起看,能看出「机械臂」这个词底下藏着多不一样的目标。

按 README 的自述,UniArmL1 是一个轻量化的开源机械臂及遥操作框架,支持三种控制模式与标准化数据采集,采集的数据可以对接 unitree_lerobot 做模仿学习的训练与部署

它给出的完整流程是这样一条:

硬件准备 → 校准 → 配置环境 → 遥操作 & 数据采集 → 训练 → 部署

注意这条流程的终点是「部署一个学出来的策略」,不是「执行一段编好的轨迹」。这跟 Z1 那条链的方向完全不同。

三种控制模式对应仓库里 teleop/robot_control/input/ 下的三个文件——input_vr.pyinput_keyboard.pyinput_leader.py,分别是 VR 手柄、键盘、主从臂。README 里写得很清楚,主从模式下主臂处于零阻尼模式,从臂直接跟随主臂的关节角度

零阻尼这三个字是关键。它意味着你推主臂时几乎感觉不到阻力,就像在推一个没通电的骨架,而从臂一比一地复现你的动作。这和 Z1 那个 State_Teach 是同一个思路的两种实现——都是「用人的手去教机器」,只不过一个是教一条固定轨迹,一个是实时跟随。

再看它的仓库结构,和 Z1 的差别就更明显了:

hardware/STL/    —— 3D 打印文件
hardware/STEP/   —— 三维模型源文件
hardware/doc/    —— 电机使用手册
setup.sh         —— 一键装依赖
teleop/image_server/     —— 相机采集
teleop/utils/episode_writer.py     —— 回合数据写入
teleop/utils/rerun_visualizer.py   —— 可视化
teleop/robot_control/recorder.py   —— 录制

hardware/ 目录里放着 STL 和 STEP 文件、装配指南、电机手册——这条臂是让你自己打印、自己拧出来的。而 teleop/ 那一侧几乎全是围绕「采数据」建的:图像服务、回合写入器、录制器。

命令行参数也很说明问题:--record 开启录制,--task-dir 指定数据目录,--task-goal 写一句任务目标的自然语言描述,--record-hz 设置录制频率,--cameras名字:编号 的格式配多路相机。

一个「任务目标的文字描述」被做成了录制参数——这是给语言条件的模仿学习准备的。数据集里每一段轨迹都带着一句「这段是在干什么」。

所以两条线的出发点其实是这样:

Z1 这套 UniArmL1 这套
核心问题 让末端精确地按指定轨迹到位 高效地采到能训模型的数据
人的角色 编程者、示教者 演示者
关键抽象 状态机 + 轨迹 输入源 + 录制器
下游 执行任务 训练策略、再部署回来

没有哪个更先进。做精密装配,你要的是可重复、可验证的确定性轨迹;做通用抓取,你要的是大量多样化的人类演示数据。先想清楚要解决什么问题,再选技术栈——这个顺序反了,后面全是返工。

关于遥操作与数据采集这条线,这一卷的其他文章里还有更多展开。

读完这几个仓库,你该记住三件事

第一,机械臂的控制系统天然是分层的,而且层与层之间的边界能从目录结构直接读出来。 z1_sdk 里没有 MotorCmd.h,这一个缺失就把「用户能碰到哪一层」说清楚了。你以后设计自己的系统时,也应该能回答:我的用户最底能碰到什么?再往下我用什么挡住?

第二,IOInterface / IOUDP / IOROS 这个三件套是最值得抄的模式。 把「和硬件说话」抽象成一个接口,真机和仿真各出一个实现,上层逻辑一行不改就能在两个世界之间切换。做机器人不这么干,你的开发效率会被硬件的可用性死死卡住。

第三,状态机比你想的重要。State_Passive 开始,把机器的每一种运行模式显式建模成一个状态,把模式之间的切换条件写清楚——这件事看起来很笨重,但它是「跑得起来」和「敢让它在人旁边跑」之间的分界线。PassiveCalibrationBackToStartClearError 这几个状态没有一个是在实现功能,它们全是在兜底

如果你还没做过机器人,建议先去机器人与具身智能卷里用 ESP32 亲手做点会动的东西,把电机、编码器、闭环控制这些基础过一遍。等你被一条自己拧出来的小臂的抖动折磨过之后,再回头看 SCurve.hFilter.h 出现在哪一层,会有完全不同的体会。

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明