Microduck 的安全层设计:borrow checker 怎么管住 15 个舵机不发疯
—
给机器人跑强化学习策略,最让人紧张的不是策略训不好——训不好最多鸭子不走。最怕的是策略”发疯”:一个 NaN 输出灌进舵机,15 个关节同时甩到极限,800g 的塑料壳子在你桌上表演体操。
Microduck 的安全层不是”加一个检查”这么简单。它是一套架构级的设计,用 Rust 的类型系统把”谁能写电机”这个问题,从运行时约定提升到了编译时保证。五条铁律,每条都有具体的代码结构在执行,不是写在文档里让人自觉遵守的东西。
铁律一:只有 safety 模块能写电机,borrow checker 来保证
这是整个安全体系的基石。duck_control::io::RobotIo 是对 Dynamixel 总线的抽象——六个方法,read()、write()、set_gain()、set_torque()、slow_sensors(),谁拿到这个 trait 对象谁就能驱动电机。
Microduck 的做法是:启动时把 RobotIo move 进 safety 模块,之后再也不出来。 策略拿到的是观测值,控制器拿到的是目标提案,客户端拿到的是 intent 接口——它们都能”建议”电机去哪,但只有 safety 模块能真正调 write()。
这不是运行时检查。Rust 的 ownership 系统,在编译阶段就保证了一件事:如果 safety 持有 RobotIo 的所有权,编译器不会让任何其他代码路径拿到它的可变引用。你写不出来的代码,运行时就不会执行。这比”大家约定一下别乱调 write”靠谱太多了。
官方文档里原话是:safety holds the RobotIo, so the policy, the controller and every client can propose targets and none of them can send one. That is the borrow checker, not a convention.
铁律二:开机和更新重启不动机器人
robotd 启动时做三件事:打开总线、构造 Safety(把 RobotIo 交给它)、读一帧当前姿态设为 hold。注意第三步——它不设为 home pose,它设为”机器人现在是什么姿势就保持什么姿势”。
这条规矩防的是一种很吓人的场景:你 SSH 进去重启 robotd,鸭子突然站起来了。更新重启也一样——updaterd 调 systemctl restart robotd,鸭子应该纹丝不动。重启不是”重新初始化姿态”,是”换了个进程继续看着同一个姿势”。
启动时策略状态有三种分支:策略已加载且正常 → controller 接管;策略没加载 → controller = None,鸭子站着不动;策略加载失败 → 标记 unhealthy,controller = None。无论哪种,电机都不会因为”进程刚起来”就动。
铁律三:控制循环不等任何人
50Hz 循环里最危险的不是推理慢,是被客户端堵住。如果循环在等一个 IPC 响应,20ms 预算花完了还没写回目标位置,舵机就会保持上一个命令——这在行走中意味着一只脚可能悬空没有支撑,直接摔。
Microduck 的设计是:intent 是原子加载,telemetry 是 drop-on-lag。 客户端发来的 move 命令写入一个 atomic 变量,循环每 tick 读一次最新值——客户端不需要”确认”,循环不需要”等待”。状态发布也一样:有人订阅了就发一帧,没订阅就跳过,发送慢了就直接丢——绝不积压。
循环跑在自己的 tokio runtime 上,跟 IPC 的 runtime 隔离。这意味着即使有个客户端把 IPC 线程堵了,控制循环的 tick 不受影响。
铁律四:摔倒检测只报告不拦截
这听起来反直觉——检测到摔倒了为什么不立刻停?因为摔倒检测的作用不是”阻止摔倒”(那时候已经晚了),是在摔倒发生后接管控制权。
摔倒判定经过去抖处理(debounced),发布到状态里。一旦判定摔倒,一个叫 limp-fall 的序列接管:它不是停掉策略,而是用自己的目标序列和增益,让鸭子”安全地瘫下来”——力矩保持但目标回到当前姿态,避免关节锁死在极端角度。
关键设计是:limp-fall 序列期间,策略不驱动。 driving 的四个条件之一就是 ¬limp-fall。这不是策略自己决定的——循环检查这个标志,如果 limp-fall 在跑,目标来自序列而不是策略。
摔倒恢复也走同一条路:如果鸭子从趴着站起来,sitstand 策略接管。这一切的切换都在 50Hz 循环里实时发生,不需要重启或外部干预。
铁律五:电池和温度不触发回滚
updaterd 在更新后检查 robotd 是否 healthy,不健康就回滚。但有个关键限制:只有 release 能负责的问题才允许触发回滚。
电池没电了、温度过高了——这些是硬件状态,不是软件 bug。如果拿它们当回滚条件,你会得到一个荒谬的结果:鸭子快没电了,updaterd 把软件回滚到旧版——问题没解决,还丢了更新。
所以 health verdict 严格区分两类信息。软件状态(策略加载失败、循环卡死)是回滚输入;电池和温度是 description,只报告不决策。这条规矩的深层逻辑是:回滚是一个有代价的操作,只有”回滚能修好”的问题才值得回滚。
deadman 开关:intent 不是心跳
Microduck 的 intent 系统里有一个 deadman 开关,但它的设计跟大多数人想的不一样。
不是”客户端每隔 N 秒发一个心跳,没收到就停”。那种设计的问题是:如果心跳线程卡了(比如系统负载高),鸭子会误停——而真正危险的指令还在 intent 里没清。
Microduck 的 deadman 是 intent 的一部分。每个 move 命令自带一个 deadman 标志,gating 逻辑在观测构建阶段就处理了:如果 deadman 没按下,twist 命令归零。这意味着”松手”不是一个额外操作,是命令本身的一部分——你不按,命令就是零。
这个设计的好处是不存在”心跳丢了但命令还在”的时间窗口。每帧的观测里已经包含了 deadman 状态,策略看到的”命令”已经是 gating 后的结果。
五条铁律怎么互相配合
把五条放在一起看,你会发现它们不是独立的检查清单,是一个闭环:
- borrow checker 保证只有 safety 能写电机(铁律一)
- safety.apply 在写之前做三件事:拒绝非有限值、钳制到关节范围、发布摔倒判定(铁律四)
- 循环不等客户端,所以 safety 拿到的永远是最新 intent 而不是排队命令(铁律三)
- 重启不动机器人,所以 safety 的 hold pose 总是有效(铁律二)
- health 只报软件问题,所以回滚不会因为硬件状态误触发(铁律五)
这个架构的价值不在于”检查多”,在于每一层都不依赖下一层的配合。borrow checker 不需要运行时检查来补位;hold pose 不需要策略正确才能保持静止;deadman 不需要心跳线程活着才生效。每层各自闭环,任何一层失灵都不会突破下一层的边界。
对于复刻者来说,最值得抄的不是具体代码,是这条原则:安全的保证应该尽可能前移到编译时和架构设计里,而不是堆在运行时检查上。 运行时检查会被绕过、会被忘记、会在性能压力下被注释掉。borrow checker 不会。
—
参考来源:
- Pollen Robotics. microduck 仓库
docs/design/robotd-design.md§1.5 五条不变量、§2.4 safety 层与 RobotIo ownership、§2.4.1 limp-fall 序列、§4.1 intent 原子加载与 drop-on-lag telemetry、§3.3 启动 hold pose 与 robot.init/robot.relax、§3.4 health 发布与回滚输入隔离、§5.4 非实时系统判断。GitHub. - Pollen Robotics. microduck 仓库
docs/design/architecture.md§1 七守护进程分工与 robotd 职责边界。GitHub. - Pollen Robotics. microduck 仓库
docs/design/policy-channel-design.md:策略 sandbox 与 safety 层的关系、社区策略不验签的安全论证。GitHub.
