← 返回文章库

unitree_sdk2 架构拆解:一个工业级足式机器人 SDK 长什么样

最后更新 2026-08-23
⏱ 约 14 分钟 🟡 涉接线/强电
你将学到
  • 看懂 unitree_sdk2 的三层结构:DDS 底座、通信抽象层、型号能力客户端
  • 分清「话题订阅」和「请求响应」这两条并存的通信路径,知道什么时候用哪个
  • 掌握从目录结构反推一套陌生 SDK 设计意图的方法,以后读任何 SDK 都能用

你第一次打开一个陌生的机器人 SDK,通常会经历这么一段:README 翻完了,示例也跑起来了,能让机器狗站起来、走两步。然后你想做点自己的东西——比如让它走到某个坐标就停下、同时把摄像头画面存下来——就卡住了。因为你不知道那几行示例代码背后连着什么,也不知道 SDK 还给了你哪些没在 README 里出现的能力。

这时候最有效的办法不是继续搜教程,而是读目录结构

一套认真写的 SDK,它的目录结构就是设计文档。谁依赖谁、哪里是抽象层、哪里是具体实现、作者心里的边界画在哪儿——这些东西藏不住,全在文件夹的层级里写着。这篇我们就拿宇树官方的 unitree_sdk2 做样本,把它的结构完整拆一遍。

先说清楚这篇文章的边界:我们读的是代码结构,不是产品说明书。所有结论来自这个开源仓库里的头文件路径、示例目录和 README,不涉及任何硬件参数、价格、性能指标——那些东西会变,而架构设计的思路不会。

先看最顶层:五个目录说明了一切

把仓库根目录列出来,忽略掉 CMake 配置和 LICENSE 这类杂项,剩下五个实质性的目录:

include/       —— 头文件,SDK 的全部接口都在这
lib/           —— 预编译好的静态库
thirdparty/    —— 第三方依赖
example/       —— 示例代码
cmake/         —— 构建脚本

第一个值得注意的信号在 lib/ 里。它下面只有两个子目录:aarch64x86_64,各躺着一个 libunitree_sdk2.a

这说明两件事。第一,SDK 的核心实现是闭源的——你拿到的是编译好的静态库加头文件,不是完整源码。第二,官方只预编译了这两种 CPU 架构。x86_64 是你的开发机,aarch64 是机器人身上那块 ARM 计算板。这个组合本身就描绘了典型的开发场景:你在 PC 上写代码、交叉编译,或者直接登录到机器人的板子上编译。

README 里对环境的要求也印证了这一点:Ubuntu 20.04 LTS、gcc 9.4.0、CMake 3.10 以上,以及 yaml-cpp、Eigen、Boost、fmt 这几个依赖。这是一套很典型的 Linux 机器人开发环境组合。(版本要求可能随仓库更新变化,以官方仓库当前文档为准。)

第二个信号在 thirdparty/ 里。这个目录有近六百个文件,绝大部分是同一样东西的头文件:dds/ddsc/dds/ddsi/dds/dds.h——这是 Eclipse Cyclone DDS

一个第三方依赖占了整个仓库最大的体积,这就是在告诉你:DDS 是这套 SDK 的地基。不理解 DDS,后面所有东西都是浮的。

第一层:DDS 是这套 SDK 的地基

DDS 全称 Data Distribution Service,数据分发服务。你可以先粗暴地把它理解成一套「工业级的发布-订阅系统」——有点像 MQTT,但不需要中间那个 Broker。

我们在机器人操作系统 ROS 那篇里聊过 ROS 2 的通信机制,ROS 2 的底层用的也是 DDS。所以你会发现一个有意思的现象:宇树的 SDK 和 ROS 2 其实站在同一块地基上,这也是后面它能顺滑地接进 ROS 生态的原因。

SDK 没有让你直接调用 CycloneDDS 的 C 接口。在 include/unitree/common/dds/ 下面,它包了整整一层 C++ 封装:

dds_entity.hpp          —— DDS 实体的封装
dds_topic_channel.hpp   —— 话题通道
dds_qos.hpp             —— 服务质量策略
dds_qos_policy.hpp
dds_qos_realize.hpp
dds_callback.hpp        —— 回调机制
dds_easy_model.hpp      —— 简化模型
dds_factory_model.hpp   —— 工厂模型
dds_traits.hpp
dds_native.hpp          —— 贴近原生的一层
dds_exception.hpp

注意这里出现了两个「模型」:dds_easy_modeldds_factory_model。同一件事提供两套抽象,通常意味着作者在照顾两类使用者——一类只想快速收发消息,另一类需要精细控制实体的创建过程。这种「简单入口 + 完整入口」并存的设计,在成熟的库里很常见。

再注意 QoS 相关的头文件占了四个。QoS(服务质量)是 DDS 区别于普通消息队列的核心特性:消息要不要保证送达、能缓存几条、超时多久算失效、新加入的订阅者能不能收到历史消息——这些都是 QoS 策略。对机器人来说这事很关键:关节控制指令必须低延迟、丢了就丢了不要重发(重发一条过期的指令是危险的),而机器人状态这类数据则可能希望新订阅者一连上就能拿到最新值。同一套通信系统里,不同数据需要完全不同的可靠性策略——这就是它要用 DDS 而不是随便一个消息库的原因。

common/ 目录下除了 dds,还有一批标准的基础设施:

common/thread/     —— thread_pool、recurrent_thread、future、thread_task
common/log/        —— 一整套日志系统,八个文件
common/json/       —— json、jsonize
common/lock/       —— 锁
common/filesystem/ —— 文件与目录
common/service/    —— 服务框架基类

recurrent_thread(周期性线程)这个名字值得停一秒。机器人控制天然是周期性的——每隔固定时间读一次传感器、算一次控制量、发一次指令。SDK 直接把「周期性执行」封装成了一等公民,这是控制类程序的典型特征。

第二层:两条并存的通信路径

看懂了地基,往上走一层。include/unitree/robot/ 下面有几个不带型号名的目录,这些是通用抽象层:

robot/channel/    —— 频道:发布与订阅
robot/client/     —— 客户端:发起请求
robot/server/     —— 服务端:响应请求
robot/internal/   —— 内部消息定义
robot/future/     —— 异步结果
robot/serialize/  —— 序列化

这里藏着这套 SDK 最重要的一个设计决策:它同时提供了两条通信路径。

路径一:Channel,面向数据流

robot/channel/ 下有五个文件:

channel_factory.hpp     —— 工厂,负责初始化和创建
channel_publisher.hpp   —— 发布者
channel_subscriber.hpp  —— 订阅者
channel_namer.hpp       —— 话题命名
channel_labor.hpp

这是标准的发布-订阅模型。channel_namer 的存在说明话题名不是随手写死的字符串,而是有一套统一的命名规则在管。

这条路径适合什么?高频、单向、持续流动的数据。机器人每一毫秒都在往外吐自己的状态——各关节的角度、速度、力矩,IMU 的姿态,足端受力。你订阅一次,然后源源不断地收。反过来,你要做底层控制时,也是不停地往里发关节指令。这类通信没有「请求」和「回复」的概念,只有流。

路径二:Client/Server,面向指令

另一条路径完全不同。看 robot/client/

client.hpp
client_base.hpp
client_stub.hpp     —— 存根
lease_client.hpp    —— 租约客户端

robot/server/ 对称:

server.hpp
server_base.hpp
server_stub.hpp
lease_server.hpp

出现 stub(存根)这个词,基本可以确定这是一套 RPC(远程过程调用) 机制。再看 robot/internal/internal_idl_decl/ 里定义的消息类型:

Request_.hpp          ResponseHeader_.hpp
RequestHeader_.hpp    ResponseStatus_.hpp
RequestIdentity_.hpp  Response_.hpp
RequestLease_.hpp
RequestPolicy_.hpp

请求、请求头、身份、租约、策略;响应、响应头、状态。这是一套完整的请求-响应协议,而且它是架在 DDS 之上的——用发布订阅的底座,实现了一套 RPC。

这条路径适合什么?一次性的、需要确认结果的指令。「站起来」「趴下」「切换到运动模式」——你发出去之后需要知道成没成功,失败了是什么原因。所以每个能力模块都配了 error 头文件,里面是错误码。

lease:一个容易被忽略但很关键的设计

Request 相关的消息里有个 RequestLease_,客户端和服务端也各有一个 lease_client / lease_server

Lease,租约。这个概念在分布式系统里的作用是独占与超时释放:某个客户端申请到租约,在租约有效期内它独占某项资源;如果它崩溃或者断线,租约到期自动释放,别人才能接管。

放到机器人身上,这个设计解决的是一个很现实的安全问题:同一时刻绝不能有两个程序同时控制同一台机器人。你想象一下,一个程序让它往前走,另一个让它蹲下,指令交替到达执行层——结果不会好看。租约机制保证了控制权的排他性,而且在你的程序意外挂掉时,控制权能自动收回,而不是永久锁死。

这类设计不会写在快速上手教程里,但它是「工业级」和「玩具级」之间的分水岭之一。

第三层:型号能力客户端,一个高度规整的模式

再往上就是具体型号了。include/unitree/robot/ 下的型号目录有:a2as2b2g1go2h1h2r1。示例目录 example/ 里还多出 b2wgo2w 两个。

每个型号目录下面按能力再分子目录,而每个能力子目录,都严格遵守同一个三件套:

xxx_api.hpp      —— 接口定义
xxx_client.hpp   —— 客户端类
xxx_error.hpp    —— 错误码

比如 go2/sport/ 下就是 sport_api.hppsport_client.hppsport_error.hppg1/loco/ 下就是 g1_loco_api.hppg1_loco_client.hppg1_loco_error.hpp

这种一致性有个很实在的好处:你学会一个模块,就学会了所有模块。搞懂 sport_client 怎么用,再去用 audio_clientvideo_client,几乎不用重新学。对 SDK 使用者来说,可预测性比花哨的接口设计值钱得多。

把各型号的能力目录横向摊开看,能读出不少信息:

  • go2sport(运动)、videovuiconfigrobot_stateobstacles_avoid(避障)、utrack
  • b2sportfront_videoback_video(前后两路视频)、configrobot_statemotion_switcher(运动模式切换)
  • g1locoarmaudioagvcommon/terminations
  • h1loco
  • h2locoarmcommon/terminations
  • a2sportaudio
  • r1locoaudio

这里有个特别值得注意的细节:四足型号的运动模块叫 sport,人形型号的叫 loco

loco 是 locomotion(移动/运动)的缩写。为什么不统一命名?我不去猜宇树内部的考虑,但从接口设计的角度看,这个区分本身是合理的:四足和人形的运动指令,在语义上差别很大。四足你可能是「以某个速度朝某个方向走」「原地转身」「趴下」;人形则涉及步态切换、上半身与下半身的协调、手臂是否参与平衡。硬塞进同一套接口里,只会让两边都别扭。接口命名的分歧,往往反映的是底层控制模型的真实分歧。

同理,b2motion_switchergo2 没有、go2obstacles_avoid 而别的没有——这些差异都只说明「这个型号的 SDK 暴露了这项能力」,不能反推「别的型号做不到」。仓库里没写的事情,我们就不写。

example 目录:另一份没人读的说明书

example/ 除了按型号分的目录,还有四个通用示例,每一个都很有信息量:

helloworld/ —— 里面是 publisher.cppsubscriber.cpp 和一个自定义的 HelloWorldData。这是纯粹的 DDS 收发示例,跟机器人没有任何关系。它的存在等于官方在说:先搞懂发布订阅,再谈控制机器人。如果你是第一次接触这套 SDK,这里才是真正的起点。

state_machine/ —— 这个示例的文件构成很讲究:

main.cpp
state_machine.hpp        —— 状态机
robot_controller.hpp     —— 机器人控制器
robot_interface.hpp      —— 机器人接口
user_controller.hpp      —— 用户控制器
gamepad.hpp              —— 手柄
conversion.hpp
cfg.hpp
params/params.json       —— 参数配置

这不是一个演示单个 API 的片段,而是一个完整应用的骨架:状态机管模式切换,robot_interface 把 SDK 调用隔离在一层里,user_controller 放你自己的控制逻辑,参数走 JSON 配置而不是硬编码。

如果你要基于这套 SDK 做自己的项目,与其从零搭结构,不如从这个示例的分层方式抄起。把控制逻辑和 SDK 调用隔开,是能省下大量后期麻烦的一个决定——换型号、换通信方式时,你只需要动 robot_interface 那一层。

wireless_controller/ 和各型号目录下反复出现的 gamepad.hpp —— 手柄输入被反复用到,说明「人拿着手柄控制 + 程序自动控制」这两种模式在实际开发中是并行的。调试期间用手柄兜底,是很现实的做法。

jsonize/ —— 单独演示序列化,对应 common/json/jsonize.hpp。配置和参数传递大量依赖 JSON。

最后,型号目录内部的分层也统一:g1h1h2r1 下面都分了 high_level/low_level/ 两个子目录。这个分野是整套 SDK 里最需要理解清楚的概念,值得单独展开,我们放在后面的文章里讲。

把这张图收拢起来

从下往上,unitree_sdk2 是这样一层层堆起来的:

┌─────────────────────────────────────────────┐
│  型号能力客户端                              │
│  go2/sport、g1/loco、b2/motion_switcher …    │
│  统一三件套:api + client + error            │
├─────────────────────────────────────────────┤
│  通信抽象层(两条路径并存)                   │
│  Channel:发布订阅,跑数据流                  │
│  Client/Server:RPC + 租约,下指令            │
├─────────────────────────────────────────────┤
│  基础设施层 common/                          │
│  DDS 封装 · 线程池 · 周期线程 · 日志 · JSON  │
├─────────────────────────────────────────────┤
│  CycloneDDS(thirdparty/)                   │
└─────────────────────────────────────────────┘

三层,职责清楚,每层只依赖下一层。新增一个型号,只要在最上层加一个目录、按三件套写三个头文件,下面两层完全不用动。这就是分层的价值。

这套方法可以复用

这篇从头到尾我们没跑过一行代码,只是读了目录、头文件名和示例结构,就把这套 SDK 的设计意图还原了个七八成。这个方法在你面对任何陌生 SDK 时都成立,几个可以直接照搬的动作:

  1. 先看 thirdparty/ 或依赖清单。体积最大的那个依赖,通常就是整套东西的地基。
  2. 找不带业务名的目录。像 channel/client/common/ 这类,是作者提炼出来的抽象层,最能体现设计思路。
  3. 看重复出现的命名模式api/client/error 三件套这种规整,说明作者在刻意维持一致性,你可以放心地举一反三。
  4. 注意命名的分歧sportloco 的区别不是随意的,命名不一致的地方,底层往往真的不一样。
  5. example/ 里的「非典型示例」最有价值helloworld 告诉你起点在哪,state_machine 告诉你完整项目该怎么组织——这两个比十个 API 演示加起来都有用。

至于这套结构里最关键的「高层控制 vs 低层控制」到底怎么划、DDS 相比 MQTT 到底强在哪、Python 开发者怎么接进来,我们在这一卷的其他文章里接着聊。

如果你还没做过机器人,建议先从机器人与具身智能卷入手,用 ESP32 亲手做一台循迹小车或自平衡车——机器人是由哪些部分组成的电机怎么选这些基础打牢之后,再回头看这套工业级 SDK,很多设计决策你会瞬间明白它在防什么坑。

📄 来源 / 自校链接

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

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

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