复刻 Microduck 的人大多有个尴尬阶段:策略训好了,代码改完了,但真机不在桌上。你没法跑 robotctl health,没法试 chorale,没法验证热切换——除非你有一只装好的鸭子随时待命。
官方的解法不是给你一个模拟器。是给你一只一模一样的鸭子,只不过它的身体在 MuJoCo 里。
scripts/duck-sim 一条命令就能跑起来。窗口打开,鸭子站起来,然后你用的所有命令跟真机完全一样:robotctl health、robotctl configure、robotctl chorale、journalctl -u robotd -f。连 duckctl open 都能用。这不是一个模拟环境,是一个孪生体(twin)。
核心设计:不是 mock,是 twin
大多数机器人项目都有个 “sim mode”——在代码里加一堆 if-else,仿真走这条路径、真机走那条。Microduck 明确拒绝了这种做法。
它的设计目标写在文档第一行:一个你像对待真机一样开发的鸭子,同样的二进制、同样的单位、同样的 robotctl、同样的 duckctl open——只是身体在 MuJoCo 里而不是桌上。
实现这个目标靠的是一条干净的缝合线:duck_control::io::RobotIo trait。六个方法,是仿真唯一被允许存在的地方:
read()— 读关节和 IMU,一次事务write()— 写目标位置set_gain()— 设置 PD 增益set_torque()— 开关力矩slow_sensors()— 读电压和温度
这条线以上,什么都没变。50Hz 控制循环、ONNX 策略推理、安全层、跌倒检测、里程计、运动学、所有 IPC 调用、完整的 robotctl 和 duckctl——全跑同一份代码。这条线以下,真机是 DynamixelIo(跟真舵机通信),仿真是 FakeIo(跟 MuJoCo 通信)。
关键洞察是:FakeIo 本来就是 RobotIo 的完整实现,这也是为什么 cargo test 不需要硬件。仿真只是给了它一个真正的物理后端,而不是返回固定值。
身体协议:为什么是 TCP + JSON
仿真那半边(MuJoCo)和守护进程这半边(Rust)分属两个仓库,需要一套通信协议。Microduck 选了 TCP 上跑换行分隔的 JSON,每个请求一个回答。
这个选择背后全是被坑出来的经验:
TCP 而非 Unix socket,因为 Unix 路径长度被限制在 108 字节以内(SUN_LEN),而仿真器可能跑在容器里、跑在 macOS 的 Linux VM 里——路径根本控不住。TCP 没有这个限制。
JSON 而非二进制结构体,因为一个 tick 的数据量大概 1KB,50Hz 就是 50KB/s——带宽根本不是问题。但用 JSON 意味着你可以用 nc 手动读一帧,可以用二十行 Python 写个测试端。文档里原话是:”一个跨两个仓库两种语言的打包结构体,就是这个项目以前丢了几天的地方。”
TCP_NODELAY 不是微优化,而是刚需。Nagle 算法会把小包延迟最多 40ms——正好是两个 tick 的周期。如果你不开 NODELAY,仿真器看起来就是”慢”,而实际上只是网络栈在攒包。
请求格式是 {"op":"hello"|"read"|"write"|"gain"|"torque"|"slow", ...}。握手时双方检查 protocol 版本号——因为两边住在不同仓库里,”你的仿真器太旧”和”你的守护进程太旧”表现出来的症状一模一样,不检查就是猜。
还有一个细节特别重要:仿真器用机器人自己的单位报告数据——弧度、rad/s、毫安,IMU 已经解算到躯干坐标系。MuJoCo 知道自己模型的关节顺序、缩放和坐标系;如果在这边再加一层翻译,就是第二个可能漂移的地方。
无线电必须模拟成坏的
多鸭子合唱(chorale)是 Microduck 一个特色功能——多只鸭子通过无线电互相发现,协调唱一首歌。仿真里当然也要能测这个。
但官方发现了一个反直觉的问题:完美的无线电仿真比没有仿真更糟。
四只鸭子在完美仿真里,每次都能在几秒内收敛到同一首歌——因为每只鸭子都能即时、无损地看到其他所有鸭子。但现实中的无线电不是这样:有距离衰减、有丢包、有不对称链路。那个导致现场出 bug 的属性——”有时候什么都没发生,有时候两首不同的歌”——恰恰是完美仿真无法复现的。
所以 duck-ether 工具故意把无线电模拟得很烂:
--discovery 90:每对鸭子之间有 90 秒的发现延迟(不是全局延迟,是 per-pair 的,因为不对称才是拆散鸭群的原因)--loss 0.3:30% 的投递丢包率--seed 3:固定随机种子,因为”一个时好时坏的无线电只在能复现的时候才有用”
用 --discovery 90 --loss 0.3 --seed 3 跑出来的结果跟现场报告一模一样:鸭子 a 没在唱、b 唱低音、c 唱中音、d 也在唱低音——名册不一致,声部重复。这就是仿真该做的事:不是证明它能工作,是复现它什么时候不工作。
qemu-user 为什么跑不了 systemd
一个很有诱惑力的想法:这个项目所有构建产物都是 aarch64,如果在 x86 笔记本上用 qemu-user 模拟,就能跑完全相同的二进制——完美的一致性。
实测了两次,结果矛盾但都有道理:
单独跑守护进程没问题。 CI 的真实 aarch64 产物在 x86 笔记本上用 qemu 模拟:robotctl health 报 50.0 of 50.0 Hz · 3804 ticks · 0 missed,CPU 占用 4.7%,策略推理 0.029ms(对比 20ms 的预算)。吞吐根本不是瓶颈。
但在 systemd 容器里不行。 用 systemd-nspawn 启动 aarch64 系统,systemd 257 能在 8.7 秒内起来,但之后什么都启动不了:robotd.service 退出码 226/NAMESPACE,systemd-journald 退出码 243/CREDENTIALS,logind、tmpfiles、console-getty 全挂。
原因很具体:qemu-user 8.2 不翻译新的 mount API(fsopen、move_mount、open_tree),而 systemd 257 用这些 API 做每单元的命名空间隔离和凭证管理。per-unit 加固是 boot 容器而不是直接跑七个进程的全部意义——丢了它就丢了出发点。
所以孪生体的最终方案是跑宿主机的原生架构:x86 笔记本上跑 amd64 容器和原生构建;Apple Silicon 上跑 arm64 容器,直接用机器人自己的签名产物。除了 CI 的精确字节不同,其他一切都一样——真实的 systemd 单元、真实的加固、真实的 journal。
四条设计规矩
duck-sim 的文档里写了四条自我约束的规矩,值得任何开发工具借鉴:
一,不需要记住的配置步骤。 第一次跑 duck-sim up 会自动构建缺的东西、拉取缺的依赖,然后告诉你。需要你手动做的,会打印出确切的命令让你粘贴。
二,默认值就是最常见的情况。 不指定数量就是一只鸭子,不指定场景就是公寓场景,不加 --cameras 就没有摄像头——因为大多数调试不需要摄像头,而它们最耗资源。
三,所有东西都能停。 每只鸭子跑成一个 systemd 单元,duck-sim down 就是 systemctl stop——不是什么快捷键。这条规矩是用一次事故换来的:一个容器只能从第二个终端 kill,因为 Ctrl-] 在法文键盘上是 AltGr + )。
四,鸭子有名字。 duck-a、duck-b,每只有自己的 machine-id。所以 robotctl quack 在每只鸭子上听起来不一样——因为鸭子的声音是从序列号生成的。四只鸭子合唱不应该是四个相同的声音。
复刻者能拿走什么
如果你也在复刻小机器人,duck-sim 的设计有三条特别值得抄:
第一,缝合线放在 IO 层,不是业务层。一个 trait 六个方法,以上全不变、以下随便换。这比在代码里撒 if-sim-else-real 干净一万倍,也更不容易出 bug。
第二,通信协议选简单不选高效。TCP+JSON 在 50KB/s 的带宽下毫无压力,但它让你能用 nc 调试、用 Python 快速写测试端。跨仓库的打包结构体是时间黑洞。
第三,仿真要故意模拟故障。完美环境只能证明 happy path,而机器人最贵的 bug 全在 edge case——不对称的链路、丢包、发现延迟。把无线电模拟得很烂,才能在笔记本上复现现场的诡异问题。
一只不在桌上的鸭子,跟在桌上的一样有用——前提是你尊重这条缝合线。
—
参考来源:
- Pollen Robotics. microduck 仓库
docs/design/simulation.md:RobotIo trait 设计、身体协议(TCP/JSON/TCP_NODELAY)、ether 故障模拟、qemu-user 实测数据、四条设计规矩。GitHub. - Pollen Robotics. microduck 仓库
README.md:scripts/duck-sim命令、七守护进程概述、RK3566 平台。GitHub. - Pollen Robotics. microduck 仓库
docs/design/app-path-design.md:btd/configd 设计、BLE 配网路径、NetworkManager 迁移决策。GitHub.
