Microduck 的安全层拆解:一只 800g 鸭子摔倒前的那 10 毫秒
做过双足机器人的人都知道,摔倒不是意外,是默认结局。重心偏一点、脚底打滑一下、舵机响应慢几毫秒——任何一个都能让一台机器人从站立变成趴下。Microduck 作为一个 25 厘米、800 克的小型双足机器人,没有传统大型仿人机器人的 ZMP 在线规划或倒立摆模型求解器。它的防摔靠的是一个嵌在 50Hz 控制循环里的安全层,用 Rust 的类型系统做硬保证,用运行时检查做软兜底。今天拆开看这层代码。
安全层在哪:borrow checker 就是安全边界
Microduck 的 robotd 架构里有一个核心设计原则:safety 模块持有唯一的 RobotIo。这不是约定,是编译器强制的。
RobotIo 是封装了 Dynamixel 总线读写能力的结构体。谁持有它,谁就能给舵机发指令。在 Microduck 的代码里,Safety::new(io) 在启动时拿走了这个 io,之后策略、控制器、客户端——没有任何一个能直接写舵机。它们能做的只是”提议”:计算出目标关节角度,交给 safety.apply() 去审核。
这个设计的妙处在于,它不是运行时的权限检查,是编译时的所有权检查。你写不出发出舵机指令但不经过 safety 的代码——编译器会拒绝。在一个跑在嵌入式 Linux 上的机器人系统里,这种保证比任何运行时 ACL 都可靠。
一个 tick 里安全层做了什么
50Hz 循环每个 tick 的数据流是这样的:总线读取 → 构建观测向量 → 策略推理 → 得到 14 维动作 → safety.apply() → 写回总线。
安全层拿到的输入是策略输出的 14 个目标关节角度(嘴部舵机排除,所以是 14 不是 15),加上当前 hold pose(保持姿态)和 gain(增益)。它做三件事:
拒绝非有限值。 策略输出的是 float32。ONNX 推理在边缘场景下可能输出 NaN 或 Inf——尤其是观测向量里有异常值时。safety 第一件事就是检查 14 个目标值是否全部是有限数。有一个不是,整个 tick 的策略输出就被丢弃,回退到 hold pose。鸭子保持上一个安全姿态不动,等下一个 tick 重新尝试。
钳位到执行器范围。 策略输出的 14 维动作是归一化的,乘以 scale 后加到 home pose 上得到目标关节角度。但策略可能输出超出舵机物理范围的角度——尤其在不熟悉的观测空间里。safety 把每个目标角度钳位到该舵机的合法范围内。这不是建议,是硬性截断。
跌倒检测不是安全门。 这一点容易误解。safety 里有跌倒检测逻辑,但它不阻止跌倒——它只报告跌倒状态。跌倒 verdict 发布到 robot.state 里,客户端可以读到,但它不 gate 任何东西。为什么?因为跌倒检测有延迟(debounced),如果用它做门控,鸭子会在已经开始摔但还没被判定为摔的几毫秒里继续接收策略指令,反而更危险。正确的做法是让策略自己处理这些场景——训练时就见过摔倒恢复的数据。
hold pose:不移动就是最安全的移动
Microduck 启动时有一个硬规则:永远不要在进程启动时移动。robotd 启动后的第一个 tick 读总线拿到当前姿态,把这个姿态设为 hold。策略没加载时,每个 tick 输出的就是 hold——鸭子保持它启动时的姿态不动。
这个设计解决了一个具体的故障场景:更新系统重启 robotd 后,如果策略立刻输出动作,一个刚启动还没稳定的服务可能导致鸭子突然动一下。尤其是更新后策略版本可能变了,第一个 tick 的输出不可预测。hold 机制确保更新重启 = 鸭子原地不动。
停止驱动时也一样。padd 断开连接、策略报错、sensors 读不到——任何一个条件触发”停止驱动”,safety 立刻捕获当前姿态作为 hold,后续 tick 全部输出 hold。注意 hold 不是每 tick 重新读取的——它在停止瞬间捕获一次。如果每 tick 都重新读取,舵机会在重力下缓慢下垂,hold 就不再是 hold 了。
limp-fall:摔了之后的降级模式
真正摔倒时,safety 切换到一种叫 limp-fall 的模式。这个模式不试图阻止摔倒——鸭子已经在摔了。它的目标是让摔倒过程尽可能柔和。
limp-fall 模式下,目标关节角度来自一个预定义的序列而不是策略输出。这个序列的设计目标是降低冲击:让腿收起来、让头部不要硬着地。gain 也切换到序列自带的值,不是策略的 gain。
关键设计决策:limp-fall 期间策略被显式跳过。不是”策略输出被 safety 拒绝”,是策略根本不被调用。这避免了策略在摔倒状态下输出不可预测的动作——一个训练时没见过仰面朝天姿态的策略,你不知道它会输出什么。
driving 条件的四个检查
安全层决定是否让策略驱动机器人,看四个条件。全部为 true 才驱动,任何一个为 false 就回退到 hold:
- enabled:客户端发了 robot.enable。这是显式的使能命令,不是默认状态。
- policy loaded:策略成功加载。策略文件缺失、ONNX 解析失败、版本不兼容——任何一个都导致 policy not loaded,safety 知道不能让空策略驱动。
- sensors this tick:这个 tick 的总线读取成功了。如果读取失败,sensors 是 None,没有观测向量可构建。策略不能在”不知道当前姿态”的情况下输出动作。
- 非 limp-fall:当前不在 limp-fall 序列中。如果在 limp-fall,目标来自序列而非策略。
第四个条件值得多说一句。它不是”拒绝”策略——limp-fall 序列拥有机器人期间,策略是”故意不驱动”。目标来自序列本身。这跟”策略输出了但被 safety 拒绝”是不同的代码路径。
策略 reset:从 hold 到 drive 的瞬间
从 hold 切换到 driving 的瞬间,safety 做一个容易忽略但关键的操作:controller.reset()。
为什么需要 reset?策略是一个有状态的神经网络——具体来说,它内部的观测归一化器维护着运行均值和方差。如果在 hold 期间策略没被调用,归一化器的状态停留在上一次驱动时的值。现在突然恢复驱动,第一个 tick 的观测可能跟归一化器记忆的分布差得很远,策略输出会有一个跳变。
reset 清空归一化器的累积状态,让策略从一个干净的起点开始。这不会让策略”忘记”训练时学到的东西——那些在模型权重里,不在归一化器的运行统计里。reset 的是运行时统计,不是训练参数。
反方向也一样。从 driving 切换到 hold 时,hold 姿态被捕获一次。如果不捕获、每 tick 重新读取当前姿态作为 hold,问题在于舵机在重力下会缓慢移动——每 tick 的”当前姿态”都在变,hold 就变成了一个缓慢下蹲的过程。捕获一次之后,后续 tick 不管舵机实际在哪里,目标都是那个捕获的值。舵机会尽力维持。
安全层不做的事
理解安全层不做什么,跟理解它做什么一样重要。
不做 ZMP 计算。 传统双足机器人用 ZMP(零力矩点)来判断稳定性——计算当前姿态下 ZMP 是否在支撑多边形内。Microduck 不做这个。原因很简单:ZMP 需要足底力分布信息,而 XL330 舵机没有力反馈。Microduck 的脚底没有六维力传感器。跌倒检测靠的是 IMU 四元数和关节角度的阈值判断,不是 ZMP。
不做在线步态规划。 没有轨迹生成器在实时计算下一步该迈多远。步态是策略在训练时学到的,部署时直接执行。安全层只做审核和兜底,不做规划。
不做主动平衡控制。 没有倒立摆控制器在检测到倾斜时主动调整踝关节扭矩来保持平衡。策略自己在训练时学会了应对扰动——域随机化训练出来的策略对小幅倾斜有鲁棒性。安全层只在策略明显失效时(跌倒、传感器丢失)才介入。
这意味着 Microduck 的安全性高度依赖策略质量。安全层是最后一道防线,不是主要平衡机制。如果策略本身不好,安全层只能让鸭子摔得软一点,不能阻止它摔。
对复刻者的启示
如果你在搭自己的小型双足机器人,Microduck 的安全层设计有几个值得借鉴的思路。
把安全检查放在编译时而不是运行时。用类型系统保证只有安全模块能写执行器,比运行时 ACL 更可靠——没有绕过的可能。Rust 的所有权模型特别适合这个,但 C++ 用 unique_ptr 也能做到类似的效果。
分离”审核”和”执行”。策略算目标,安全层审核并写入总线。两个职责分开,策略的 bug 不会直接变成舵机的疯狂动作。
跌倒检测报告不门控。这听起来反直觉,但 Microduck 的设计是对的:跌倒检测有延迟,用它做门控反而增加风险。让策略自己处理边缘场景,安全层只在确定性的失败模式下介入。
hold 姿态捕获一次不重读。这个细节不注意,你的机器人会缓慢下蹲。每 tick 重新读取当前姿态作为 hold,等于把重力下垂变成了主动行为。
策略切换时 reset 运行时状态。从 hold 到 drive 的跳变是最容易出问题的瞬间——归一化器状态过时会导致输出跳变。一个 reset 调用成本几乎为零,收益是消除了一个常见的启动抖动。
不做的也要想清楚。没有 ZMP、没有在线规划、没有主动平衡——这些”不做”是有意的。Microduck 把平衡的复杂性交给训练好的策略,安全层只兜底。但前提是你的策略足够好。如果你的策略不够鲁棒,安全层帮不了太多。这个设计选择的代价是:策略质量是硬门槛,没有控制器层面的补丁可以打。
数据来源:pollen-robotics/microduck GitHub 仓库 docs/design/robotd-design.md(安全层设计、RobotIo 所有权、tick 数据流、driving 条件四检查、hold pose 机制、limp-fall 序列、controller.reset 逻辑、Safety::apply 审核流程),docs/design/architecture.md(七守护进程架构与 safety 的职责边界、safety 模块在 crate 层级中的位置),README.md(硬件规格、XL330 舵机配置、50Hz 控制循环描述)。
