← 返回文章库

从 UDP 到 DDS:新老两代机器人 SDK 之间发生了什么

最后更新 2026-08-23
⏱ 约 15 分钟 🟡 涉接线/强电
你将学到
  • 看懂裸 UDP 通信层在机器人系统里会卡在哪四个地方
  • 分清「参数化支持多型号」和「模块化支持多型号」两条路线各自的适用边界
  • 拿到一个可复用的判断信号:什么时候你的项目该动通信架构了

你搜「宇树 SDK」的时候大概率会撞见两个仓库:unitree_legged_sdkunitree_sdk2。名字不一样,接口不一样,示例代码长得也完全不一样。第一反应通常是问「我该用哪个」,但这个问题的答案取决于你手里是哪台机器,翻一下各自 README 就有了。

更值钱的问题是另一个:为什么会有第二个?

一套已经在跑、已经有人在用的 SDK,作者愿意重写一遍,说明旧的那套在某个地方拦住了他们。而重写之后新增了什么、砍掉了什么,等于把「作者后来觉得旧设计哪里不够用」这件事直接写在了目录结构里。这是免费的架构教材,而且比任何设计模式书都具体——因为它有约束、有代价、有真实的取舍痕迹。

先把这篇的边界说清楚:下面所有结论都来自两个公开仓库的 README 和文件树,我没有真机,也没有跑过其中任何一行代码。 我读的是结构,不是运行结果。涉及具体行为的地方,我会写明「仓库文档里是这么说的」。

先看体量:十来个头文件,和三百个路径

最直观的差别不需要读任何代码,include/ 目录列一下就够了。

老 SDK 的 include/unitree_legged_sdk/ 下面是这么一堆:

comm.h            —— 通信数据结构
udp.h             —— UDP 收发
safety.h          —— 安全
loop.h            —— 循环
quadruped.h       —— 四足
joystick.h        —— 手柄
a1_const.h
aliengo_const.h
b1_const.h
go1_const.h
unitree_legged_sdk.h   —— 总入口

十来个头文件,一层,平铺。你可以在一个下午把这些文件名全部读一遍,并且大致猜出每个是干什么的。这是一种优点——上手成本极低

新 SDK 的 include/ 下面是三百个路径,分成几大块:common/(基础设施)、robot/(通信抽象层 + 各型号能力)、idl/(消息类型定义)、dds_wrapper/(更贴近底层的一层收发封装)。加上 thirdparty/ 里近六百个文件的 CycloneDDS 头,整个仓库上千个路径。(这些数字来自我抓取的仓库快照,仓库更新后会变,以官方仓库当前状态为准。)

结构规模差了一个量级。但规模本身不说明谁好谁坏,它只说明两件事:新 SDK 承担了老 SDK 不承担的责任,以及新 SDK 的上手曲线一定更陡。这两条都是代价,值不值要看它换回了什么。

换回来的东西,全在通信层。

老 SDK 的世界:一个 udp.h 打天下

老 SDK 的 README 第一句话就把定位说死了——它主要用于 PC 与控制板之间的通信,也可以用在其他通过 UDP 连过来的 PC 上。

udp.h 是这套 SDK 的心脏,comm.h 是配套的数据结构定义。一个负责怎么发,一个负责发什么。示例目录里的 example_position.cppexample_torque.cppexample_velocity.cppexample_walk.cppexample_joystick.cpp,本质上都是同一个骨架:构造一个 UDP 对象,填一个结构体,周期性地发出去,同时收回来一个状态结构体。

这个设计在它的适用范围内非常干净。一台机器人、一个控制程序、一根网线——这个场景下,UDP 加一个约定好的结构体是最短的路径,没有中间件、没有序列化开销、没有额外的进程。你甚至能用 Wireshark 直接看包。

问题出在这个场景开始变复杂的时候。

裸 UDP 会在哪四个地方卡住你

第一,包格式是你自己的私事。 comm.h 里定义的结构体,本质上是靠双方编译器对内存布局的一致理解在通信。这意味着字段顺序、对齐方式、字节序全都是隐含契约。加一个字段,两端必须同时更新,否则解出来就是垃圾数据——而且它不会报错,只会给你一个看起来合理的错值。这类 bug 调起来非常痛苦。

第二,谁在哪儿要写死。 UDP 通信必须先知道对方的 IP 和端口。开发阶段这没什么,写在配置文件里就行。但当你的系统里出现第二台设备、或者机器人换了网段、或者你想在同一台机器上跑两份程序做对比时,这套写死的地址就开始成为负担。

第三,加一个数据消费者就得改代码。 这是最要命的一条。假设你已经有一个控制程序在收机器人状态,现在想再起一个进程专门记录数据做离线分析。UDP 是点对点的,状态包只发到了一个地址。你要么改控制程序让它转发一份,要么在控制程序里塞进记录逻辑。每加一个消费者,你就得改一次通信层。

第四,可靠性策略是全局的。 裸 UDP 就是不保证送达,所有数据一视同仁。但机器人系统里不同数据的需求是矛盾的:关节控制指令要低延迟,丢了就丢了、绝不能重发一条过期的(重发一个过期的力矩指令是危险的);而配置类的数据你希望它一定送到;机器人状态则希望新连上来的程序立刻能拿到最新一帧。这三种需求在同一根 UDP socket 上没法分开表达。

新 SDK 换掉的正是这一层

新 SDK 的 thirdparty/ 里躺着的是 Eclipse Cyclone DDS——dds/ddsc/dds/ddsi/dds/ddsrt/ddscxx/ 这几个目录,是一整套完整的 DDS 实现。licenses/ 目录也印证了这一点,里面明确列着 eclipse-cyclonedds 与 eclipse-iceoryx 的许可证。

这里有个细节值得停一秒:DDS 并没有抛弃 UDP。 CycloneDDS 的头文件里就有 ddsi_udp.h,它底下跑的仍然是 UDP(也有 TCP 与共享内存的路径)。变的不是最底层的传输,而是传输之上那一层原本要你自己写的东西,现在有人替你写好了

对照上面四条,DDS 给的答案分别是:

  • 自动发现(Discovery):同一个域里的参与者互相发现对方,不需要你配 IP 和端口。这条直接消掉了「谁在哪儿」的问题。
  • 类型化消息:新 SDK 的 include/unitree/idl/ 下面是一个个独立的消息定义文件——LowCmd_.hppLowState_.hppMotorCmd_.hppMotorState_.hppIMUState_.hppSportModeState_.hpp 等等。对比老 SDK 把所有结构塞进一个 comm.h,这里是一个消息一个文件、带类型信息、序列化由框架负责。字段变更不再是隐含契约。
  • 天然多订阅者:发布订阅模型里,发布方不知道也不关心有几个订阅方。你再起十个进程来订阅同一个状态话题,发布端一行代码都不用改。
  • 每话题独立 QoS:新 SDK 的 common/dds/ 下有四个 QoS 相关的头文件——dds_qos.hppdds_qos_policy.hppdds_qos_parameter.hppdds_qos_realize.hpp。可靠性、历史深度、生命周期这些策略是按话题配的,不是全局一刀切。

这四条摊开看,就是这一卷里讲 DDS 通信机制那篇要展开的全部内容。而通信层一换,上面能长出来的东西就完全不同了——unitree_sdk2 的三层架构里那套 Channel(数据流)与 Client/Server(带租约的 RPC)并存的设计,在裸 UDP 上是搭不起来的,至少搭起来的成本会高到没人愿意做。

代价也是实打实的:仓库体积大了一个量级,多了一个重量级第三方依赖,编译时间更长,出问题时你要排查的层数更多。这不是白送的。

型号怎么支持:参数化 vs 模块化

第二处硬差异藏在型号处理上,它同样是通信机制变化的连锁反应。

老 SDK 是每型号一个常量头a1_const.haliengo_const.hb1_const.hgo1_const.h。加上一个通用的 quadruped.h。这套路线的潜台词很清楚——这些机器人在结构上是同一类东西,差别可以用一组常量表达出来。同一套控制代码,换一组常量就能跑另一台机器。

对四足来说这个假设基本成立。所以你会看到,老 SDK 的头文件里有一个叫 quadruped.h 的文件——「四足」这个词直接写进了类型名里。它把整个 SDK 的适用范围钉死在了一类机器人上。

新 SDK 是每型号一个目录、目录下按能力分模块

robot/go2/sport/     robot/go2/video/    robot/go2/vui/
robot/go2/config/    robot/go2/robot_state/
robot/b2/sport/      robot/b2/front_video/   robot/b2/back_video/
robot/b2/motion_switcher/
robot/g1/loco/       robot/g1/arm/       robot/g1/audio/
robot/h1/loco/
robot/h2/loco/       robot/h2/arm/
robot/r1/loco/       robot/r1/audio/

每个能力目录下面严格是 xxx_api.hpp / xxx_client.hpp / xxx_error.hpp 三件套。顶层目录也不叫 quadruped 了,叫 robot

为什么必须变?因为当四足和人形要放进同一套 SDK 时,「差别可以用常量表达」这个假设塌了。四足的运动模块叫 sport,人形的叫 loco——命名上的分歧,往往说明底层控制模型真的不一样。人形的手臂、平衡、步态切换,没法靠改几个常量从四足代码里长出来。

这两条路线没有优劣,只有边界:参数化在「同类设备的不同规格」上效率最高,模块化在「异类设备共享基础设施」上才成立。你自己做项目时选哪条,取决于你未来要支持的东西是同类还是异类。这个判断做错的代价,恰恰就是重写一遍 SDK。

另外提醒一句:某型号目录下没有某个模块,只能说明这套 SDK 没有暴露那项能力,不能反推那台机器做不到。仓库里没写的事,我不写。

三个名字的变迁

除了通信和型号,还有几个小地方能看出重心的移动。

loop.hcommon/thread/recurrent_thread.hpp 老 SDK 里「周期性循环」是一个独立头文件,位置和 udp.hsafety.h 平级,属于顶层概念。新 SDK 里它变成了线程基础设施的一部分,旁边站着 thread_pool.hppthread_task.hppfuture.hpp。同一件事,从「SDK 的一个功能」降级成了「底座里的一个组件」——因为上面要跑的东西多了,周期循环不再是主角。

joystick.h → 到处都是的 gamepad.hpp 老 SDK 里手柄是一个头文件加一个 example_joystick.cpp。新 SDK 里 gamepad.hppexample/state_machine/example/wireless_controller/example/g1/low_level/example/h2/low_level/example/r1/low_level/ 反复出现,还有一个 advanced_gamepad.hpp。手柄没有变得更重要,是**「人拿手柄兜底 + 程序自动控制」这个并行模式被承认成了常态**,所以每个场景都得配一份。

safety.h 这个模块本身。 老 SDK 里它单独成一个顶层头文件,和通信平起平坐。新 SDK 的 include/ 里没有同名的全局 safety 头,能找到的相近物是 robot/g1/common/terminations.hpprobot/h2/common/terminations.hpp,示例里也有对应的 terminations.cpp这只说明安全相关能力的暴露方式变了,不说明谁更安全——真正的安全逻辑在哪一层、由谁兜底,仓库文件树回答不了,这一点我不猜。

Python:从附带绑定到独立项目

老 SDK 的 Python 支持是内建的。README 里写着,构建时把 cmake 那行换成带 -DPYTHON_BUILD=TRUE 的版本就能编出 Python 绑定;仓库里有 python_wrapper/python_interface.cpp,以及 lib/python/amd64/lib/python/arm64/ 下预编译好的 robot_interface 扩展模块。示例目录 example_py/ 里是 C++ 示例的对应版本。

有意思的是仓库体积构成。老 SDK 总共不到三百个路径,其中绝大多数属于 python_wrapper/third-party/pybind11/——pybind11 的源码、文档、测试被整个 vendored 进了仓库。真正属于这套 SDK 自己的文件,可能三十个都不到。README 里甚至专门写了两条应急指引:找不到 pybind11 头文件时往 python_wrapper/CMakeLists.txt 里加一行 include_directories,找不到 msgpack.hpp 时用 apt 装一下。这两条注意事项本身就在告诉你,这套构建链是有点脆的。

新一代把 Python 拆成了独立仓库 unitree_sdk2_python。从「C++ 仓库附带一个绑定」变成「一个独立项目」,带来的变化是版本节奏解耦、依赖不再污染 C++ 仓库、Python 侧可以有自己的打包与发布方式。代价是两个仓库之间的一致性需要额外维护——这也是所有多语言 SDK 都要面对的问题。这条线我们在讲 Python SDK 的那篇里单独展开。

有一条 README 值得单独拎出来

老 SDK 的 README 里有一段很朴素但信息量很大的说明:它列出了当前分支支持的机器人型号,也列出了不支持的几款,并且给出了指引——要用那几款的话,去找早期的 release。仓库的分支名本身也带着型号名。

这传达的是一个所有做硬件配套软件的人早晚要面对的事实:SDK 是跟着硬件代际走的,老机器不会永远被新 SDK 支持

对你的影响很直接。如果你手上是一台早几代的机器,你能用的是老 SDK 的某个特定分支或 release,新 SDK 里那些能力和你没关系;反过来,如果你在为新机器写代码,就不要照着老 SDK 的教程学,两边的心智模型是断开的。这也解释了为什么网上关于宇树开发的教程会互相矛盾——它们说的可能根本不是同一代东西。

顺带说一件两代都没变的事:老 SDK 的 README 里明确写着,C++ 示例要用 sudo 运行,目的是内存锁定(memory locking)。锁定内存是为了防止关键页被换出,避免在控制循环里出现不可预测的延迟尖峰。低层控制对实时性和确定性的要求,不会因为你换了通信中间件就消失。 关于低层控制到底意味着什么、它和高层指令的边界在哪,高层控制与低层控制那篇讲得更细。

读完这两个仓库,你该带走什么

我把这次对照读下来最有用的几条摊在这里:

一,通信机制的选择决定了整个系统的能力上限。 老 SDK 的所有限制——单消费者、写死地址、全局一刀切的可靠性——都不是它写得不好,而是裸 UDP 这个选择的必然后果。你在这层上做多少工程努力,都绕不开这四个约束。新 SDK 能长出 Channel 与 RPC 并存的三层结构、能同时挂多个订阅者、能按话题配 QoS,前提是它先把地基换了。

二,「参数化支持多型号」有明确的失效边界。 常量表能覆盖同类设备的规格差异,覆盖不了异类设备的模型差异。你在设计自己的抽象时,问一句「未来要接进来的东西,和现在这个是同一类吗」,能省下一次重写。

三,仓库里被 vendored 进来的第三方代码,是构建链脆弱程度的指示器。 老 SDK 把 pybind11 整个塞进仓库并且要在 README 里写应急指引,新 SDK 把 Python 拆成独立项目——这个演进方向不是偶然。

四,给自己留一个判断信号。 这是我觉得这次对照读最能迁移的一条:

当你发现「每加一个功能都要改通信层」时,就是该换通信架构的信号。

不是等到性能扛不住,也不是等到代码难看得受不了——性能和美观都是滞后指标。真正的领先指标是修改的传播路径。如果加一个数据消费者要动发送端、加一个字段要两端同步、加一台设备要改配置分发,说明你的通信层承担了它不该承担的耦合。这时候引入一套带自动发现和类型化消息的中间件,看起来是把系统变复杂了,实际是把复杂度从「每次修改都要付」变成「一次性付清」。

新 SDK 的具体分层怎么读,我们在架构拆解那篇里已经走过一遍;想动手把这些结构落到自己的项目上,可以先从这一卷的目录挑一条线。如果你还没做过机器人,机器人与具身智能卷那边有更基础的入口——用 ESP32 亲手接一次电机、写一次控制循环之后,再回头看这两代 SDK 的取舍,很多决定你会瞬间明白它在防什么坑。

📄 来源 / 自校链接

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

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

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