揭秘 MicroDuck:一只双足机器鸭的 50Hz神经控制闭环
揭秘 MicroDuck:一只双足机器鸭的 50Hz 神经控制闭环
原文同步发布于掘金。
https://i-blog.csdnimg.cn/direct/c306f982fd144d9185fb890cea209033.png#pic_center
导读:在嵌入式与机器人领域,如何在一块算力受限的单板电脑上,让强化学习(RL)训练出的神经网络以 50Hz(每 20 毫秒一跳)的高精度、零抖动驱动 15 个舵机与传感器?
本文以开源双足机器人 MicroDuck 为例,结合 Codemap 画布中的 23 条核心调用链路,深度拆解其从手柄意图输入、总线单事务读写、死人开关与安全守门,到 ONNX 策略推理与动作下发的全链路工程实现。
一、MicroDuck 项目背景与系统全景
1. 它是什么?
MicroDuck 是由 Pollen Robotics 开源的一款极具创新性的微型双足机器人:
- 身材参数:高约 25cm,体重仅约 800g。
- 运动能力:不仅能稳定双足行走、坐立、起伏,还能换上轮子滑行、点头扭头、张嘴说话、向前翻滚(Roulade)、甚至用脚踢球、低头用嘴叼起地面物体。
- 控制核心:并非传统的运动学逆解(IK),而是通过 MuJoCo 仿真强化学习训练、经 Sim2Real 迁移导出的 ONNX 神经网络策略。
┌────────────────────────────────┐
│ 强化学习策略 (ONNX) │
│ Walk / Stand / Sit / Kick │
└───────────────┬────────────────┘
│ 50Hz 周期推理
▼
┌────────────────────────────────┐
│ Rockchip RK3566 主控板 │
│ Rust 实时守护进程 │
└───────────────┬────────────────┘
│ 1Mbps Dynamixel 串口总线
┌──────────────┴──────────────┐
▼ ▼
15 个智能总线舵机 自制 IMU 板 (ID 200)
(腿部 10 / 颈头嘴 5) (姿态 / 陀螺仪 / 重力)
2. 软件微服务拓扑:极简与健壮
在单板主控上,系统被切分为几个职责分明的守护进程(Daemon),通过 Unix Domain Socket 采用 JSON-RPC 2.0(NDJSON)进行本地 IPC:
| 进程 / 组件 | 角色定位 | 核心职责 |
|---|---|---|
robotd | 核心大脑 | 唯一直接操作串口总线与电机的守护进程,运行 50Hz 控制闭环 |
padd | 纯传输代理 | 监听蓝牙手柄按键/摇杆,向 robotd 高频下发控制意图 |
duck-control | 无依赖算法库 | 封装总线通信、观测构建、ONNX 策略、安全门禁(无 Socket/无 Tokio) |
duck-ipc-proto | 协议合约 | 纯 Serde 数据契约,跨进程统一消息定义 |
mediad / btd | 外部通道 | 提供 WebRTC 视频流与手机 BLE 通信 |
💡 架构铁律:
robotd是系统中唯一被允许打开串口操控硬件的进程。任何外部控制(手柄、手机 App、命令行脚本)都必须转化为**“意图(Intents)”**发送给robotd,由内部安全层裁决是否执行。
二、50Hz 闭环核心概念拆解
在 20ms(50Hz)的一个周期内,系统需要完成传感器采样、安全监测、意图消化、神经网络前向推理、安全限幅、电机指令下发。这要求在设计上做到极致的硬实时与解耦。
1. 独占线程与私有运行时(Thread Isolation)
- 痛点:串口总线的读写是阻塞式 I/O(每次事务耗时 3~8ms)。如果在普通的 Tokio 异步 Worker 线程中跑,会占用线程池,导致外部 IPC 处理或定时调度产生几十毫秒的严重抖动。
- 解法:
robotd为控制循环单独开辟了一个操作系统级原生线程,并在该线程上构建专用的单线程 Tokio Runtime,使控制周期完全免受外部网络与 IPC 调度的干扰。
2. 严格的定时节拍器:为什么用 Skip?
在控制循环的节拍发生器中,代码明确配置了定时器行为:
ticker.set_missed_tick_behavior(
tokio::time::MissedTickBehavior::Skip
);
- ❌ 不能用 Burst:若某次阻塞超时,Burst 会背靠背连刷积压的消息,导致电机连续剧烈变动。
- ❌ 不能用 Delay:Delay 会在每次执行完后等待固定周期,调度唤醒延迟会永久累加,实测会导致 50Hz 衰减到 43Hz。
- ✅ 必须用 Skip:严格锚定时间基准,丢弃错过的滴答,确保绝对没有累积滞后。
图 1(示意):错过就跳过,继续对齐后续节拍——MissedTickBehavior::Skip 的调度规则。
3. 一网打尽:IMU 与舵机“单事务读写”
MicroDuck 硬件设计了一块 imu_to_dxl 板(分配设备 ID 200),与 15 个 Dynamixel 舵机挂在同一个 1Mbps UART 串口上。
在每个 tick 中,通过一次 sync_read 批量把 ID 200 及 15 个舵机的数据整块读回!
- 一次通信事务,消除多次往返总线延迟。
- ID 200 放在首位,确保先响应姿态数据,后响应电机状态。
4. 类型系统保障的“安全第一”所有权
在 Rust 代码中,RobotIo(总线读写接口)的所有权被完全转移并封闭在 Safety 结构体内部。
整个系统中,没有任何模块可以绕过 Safety 碰电机。
- NaN 拒止:如果神经网络推理输出出现
NaN(浮点异常),直接拒执,保持原姿态,绝不输出到硬件。 - 硬限幅:所有关节角度限制在物理安全范围
[ACTUATOR_MIN, ACTUATOR_MAX]。 - 死人开关(Deadman Switch):手柄意图超时未刷新(如超过 200ms),线速度立刻归零。
三、Codemap 23 条核心调用全链路穿透
根据 Codemap 画布「50Hz control loop」,整个闭环包含了 23 条关键函数连线。我们将这 23 条调用划分为四大阶段:
【阶段 A:进程启动与拓扑装配】
(1) spawn_control_thread ──► (2) control_loop
(3) serve ──► (4) handle
【阶段 B:手柄意图高频注入】
(5) padd: RobotMove ──► (6) notify
(7) robotd: as_call ──► (8/9/10) apply_intent / dispatch
(11) intents.set_twist (无锁原子更新)
【阶段 C:50Hz 核心跳动与策略推理】
(12) Safety::new (硬件独占)
(13) safety.read ──► (14) bus.read (单事务抓取 16 设备)
(15) safety.observe (跌倒检测去抖)
(16) intents.snapshot ──► (17) safety.gate (死人开关)
(18) controller.step
├─► (19) obs.build (观测组装)
├─► (20) policy.infer (ONNX 推理)
└─► (21) obs.scatter_action (动作映射)
【阶段 D:安全落地与总线刷写】
(22) safety.apply (NaN 拦截 / 物理限幅 / 增益控制)
(23) bus.write (sync_write 目标位置下发)
下面我们深入每一段源码,逐条拆解调用细节。
图 0:用 Codemap 把 padd → robotd → duck-control → duck-ipc-proto 的 50Hz 控制闭环铺在同一张无限画布上(跨 crate 连线一目了然)。
想自己复现这种「Agent 分析 + 画布连线」:在 Cursor / VS Code 安装 Codemap Extension,对 Agent 说一句 「通过 codemap 可视化」 /
visualize with codemap即可(内置 MCP,免手改mcp.json)。官网:codemap.info
阶段 A:系统初始化与线程拓扑(连线 1 ~ 4)
连线 1 & 2:创建独立控制线程与循环
spawn_control_thread(robotd/src/main.rs:702→robotd/src/main.rs:791)control_loop(robotd/src/main.rs:838→robotd/src/main.rs:1126)
robotd 主函数先拉起控制线程,然后再启动 IPC 监听。
// robotd/src/main.rs:791
fn spawn_control_thread(...)
-> std::io::Result<JoinHandle<()>>
{
// 独立系统线程,命名为 "control"
std::thread::Builder::new()
.name("control".into())
.spawn(move || {
// 专用的单线程 Tokio 运行时
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
runtime.block_on(async move {
if let Some(io) = open_bus_waiting(&port, &state).await {
// 进入 50Hz 主循环
control_loop(io, state, intents, params, period, poweroff).await;
}
});
})
}
连线 3 & 4:IPC 监听与连接托管
serve(robotd/src/main.rs:716→robotd/src/main.rs:2418)handle(robotd/src/main.rs:2455→robotd/src/main.rs:2462)
主线程在 Unix Socket 上异步运行 serve,每一个客户端连接(如 padd、robotctl)进入后,都独立 spawn 一个 handle 协程处理。
阶段 B:手柄意图输入与无锁缓存(连线 5 ~ 11)
手柄守护进程 padd 以手柄采样频率向 robotd 下发摇杆计算后的期望速度。
连线 5 & 6:手柄构造 RobotMove 并以 Notification 发送
RobotMove(padd/src/main.rs:550→duck-ipc-proto/src/lib.rs:610)notify(padd/src/main.rs:606→padd/src/main.rs:618)
// padd/src/main.rs:550
let call = proto::Call::RobotMove(proto::MoveParams {
vx: left_y * max_linear,
vy: -left_x * max_linear,
vyaw: -right_x * max_angular,
});
// padd/src/main.rs:618
// 注意:使用 notify 发送,不带 ID,无需应答
fn notify(stream: &mut UnixStream, call: &proto::Call)
-> std::io::Result<()>
{
let mut line = serde_json::to_vec(
&proto::Request::notify(call)
)?;
line.push(b'\n');
stream.write_all(&line)?;
stream.flush()
}
🎯 精妙设计:为什么不用请求-应答(Request-Response)模式?
在 50Hz 连续运动控制中,每 20ms 旧速度就会被新速度覆盖。如果每包都要接收端回复 ACK,只会徒增 IPC 延迟与 CPU 开销。
连线 7 ~ 10:解析与意图下发
as_call(robotd/src/main.rs:2587→duck-ipc-proto/src/lib.rs:1344)apply_intent(robotd/src/main.rs:2594→robotd/src/main.rs:2631)dispatch(robotd/src/main.rs:2617→robotd/src/main.rs:2675)apply_intent(robotd/src/main.rs:2686→robotd/src/main.rs:2631)
robotd 的 handle 在收到 JSON 行后,通过 as_call() 解析为类型安全的强类型枚举。无论客户端发来的是无 ID 的 Notification 还是带 ID 的 Request,最终都会汇聚到 apply_intent()。
连线 11:无锁原子更新 set_twist
set_twist(robotd/src/main.rs:2634→robotd/src/intents.rs:242)
// robotd/src/intents.rs:242
pub fn set_twist(&self, twist: [f64; 3]) {
self.twist.store(Arc::new(Stamped {
value: twist,
at_us: self.now_us(), // 附带微秒级时间戳
}));
}
这里使用了基于 ArcSwap 的原子指针存储。外部 IPC 写入意图与控制线程读取意图完全无锁竞争,延迟恒定。
阶段 C:50Hz 核心跳动与策略推理(连线 12 ~ 21)
控制循环在每一个 tick 唤醒,执行最关键的控制决策流。
连线 12:创建 Safety 实例
new(robotd/src/main.rs:1137→duck-control/src/safety.rs:117)
在 control_loop 启动时,Safety::new(io, ...) 把底层 RobotIo 封装入安全壳内。
连线 13 & 14:硬件单事务同步读取
read(robotd/src/main.rs:1343→duck-control/src/safety.rs:127)read(duck-control/src/safety.rs:128→duck-control/src/bus.rs:233)
// duck-control/src/bus.rs:233
fn read(&mut self) -> Result<Sensors> {
// 关键调用:一次 sync_read 读取 IMU(Slot 0) 和 15 个电机
let blocks = self.controller.sync_read_raw_data(
&self.ids, READ_ADDR, READ_LEN
)?;
// 解码 Slot 0:IMU 四元数、重力向量、角速度
sensors.imu = self.imu.decode(&raw);
// 解码 Slot 1..16:15 个舵机的编码器角度、转速、电流
for (joint, block) in blocks[1..].iter().enumerate() {
let pos = i32::from_le_bytes([
block[8], block[9], block[10], block[11]
]);
sensors.positions[joint] =
(2.0 * PI * pos as f64 / 4096.0) - PI;
}
Ok(sensors)
}
连线 15:跌倒检测与去抖
observe(robotd/src/main.rs:1368→duck-control/src/safety.rs:181)
机器人在踩地冲击瞬间,重力分量会出现短暂剧烈波动。
// duck-control/src/safety.rs:181
pub fn observe(&mut self, sensors: &Sensors, dt: Duration) {
if !self.io.imu_ready() { return; } // 等待滤波收敛
// 检测重力向量 z 分量是否倒置
let down = sensors.imu.gravity[2] > self.config.fall_gravity_z;
if down {
self.falling_for = self.falling_for.saturating_add(dt);
// 持续超过 debounce 时间才判定为摔倒
if self.falling_for >= self.config.fall_debounce {
self.fallen = true;
}
} else {
self.falling_for = Duration::ZERO;
self.fallen = false;
}
}
连线 16 & 17:快照采集与死人开关门禁
snapshot(robotd/src/main.rs:1379→robotd/src/intents.rs:467)gate(robotd/src/main.rs:1380→duck-control/src/safety.rs:218)
控制循环原子获取当前手柄指令快照 snapshot,并测量指令从发货到当下的持续时间 intent_age:
// duck-control/src/safety.rs:218
pub fn gate(
&self,
command: Command,
intent_age: Duration
) -> (Command, Option<Limit>) {
if intent_age <= self.config.deadman {
return (command, None);
}
// 超时则将速度归零,但保留头部目标(偏头不影响安全)
let mut stopped = command;
stopped.twist = [0.0; 3];
(stopped, Some(Limit::Deadman))
}
连线 18:控制器调度推进
step(robotd/src/main.rs:1971→robotd/src/control.rs:303)
根据当前动作状态机优先级(翻滚 > 踢球 > 叼物 > 起坐 > 站立 > 行走),确定选用哪个神经网络 Net,并调整目标指令。
连线 19:观测向量标准装配
build(robotd/src/control.rs:386→duck-control/src/obs.rs:158)
强化学习模型对输入特征有严格的布局定义。Observation::build 零拷贝式组装张量数据:
// duck-control/src/obs.rs:168
let (gyro, rest) = data.split_first_chunk_mut::<3>()?;
let (gravity, rest) = rest.split_first_chunk_mut::<3>()?;
let (positions, rest) = rest.split_first_chunk_mut::<14>()?;
let (velocities, rest) = rest.split_first_chunk_mut::<14>()?;
let (prev_action, rest) = rest.split_first_chunk_mut::<14>()?;
let (command_blk, _) = rest.split_first_chunk_mut::<13>()?;
fill(gyro, imu.gyro);
fill(gravity, imu.gravity);
// 关节位置以 home_pose 为原点计算相对差值
fill(positions, joint_positions - home_pose);
fill(velocities, joint_velocities);
*prev_action = *last_action; // 上一拍动作反馈输入
fill(command_blk, command.to_array());
连线 20:ONNX Runtime 极速前向推理
infer(robotd/src/control.rs:395→duck-control/src/policy.rs:310)
调用 ort 绑定库,执行 ONNX 神经网络计算,生成针对 14 个关节的目标动作残差(Action)。
连线 21:动作散射回 15 关节实体
scatter_action(robotd/src/control.rs:432→duck-control/src/obs.rs:221)
⚠️ 关键细节:MicroDuck 有 15 个舵机,但头部和腿部行走的神经网络只控制 14 个关节(嘴巴舵机不参与策略)。
scatter_action按照映射表,把 14 个动作精准散列到 15 关节数组中,完美避开嘴部索引,再叠加DEFAULT_POSITION和动作缩放因子生成绝对角度目标。
图 2(示意):读状态、算动作、写目标,下一拍再读取反馈——Sense → Think → Act 闭环。
阶段 D:安全校验与总线落地(连线 22 ~ 23)
连线 22:安全防护网 safety.apply
apply(robotd/src/main.rs:2180→duck-control/src/safety.rs:234)
这是指令到达硬件前最后的护栏:
- 更新电机刚度:根据状态更新舵机内部 PID P增益(站立较软、行走适中、摔倒后降为 limp 保护齿轮)。
- NaN 检查:
if targets.iter().any(|v| !v.is_finite()) { applied.limits.push(Limit::NotFinite); // 拒绝执行!保持现有姿态 self.io.write(&JointTargets::new(hold))?; return Ok(applied); } - 限幅:每一个关节角度必须 clamped 在限位内。
图 3(示意):动作落到硬件前,先经过 Safety 的异常检查与物理限幅。
连线 23:单次 sync_write 广播刷入硬件
write(duck-control/src/safety.rs:268→duck-control/src/bus.rs:293)
// duck-control/src/bus.rs:293
fn write(&mut self, targets: &JointTargets) -> Result<()> {
self.controller
.sync_write_goal_position(&JOINT_IDS, &targets.positions)
.map_err(|e| IoError::Bus(e.to_string()))
}
经过 sync_write_goal_position,15 个舵机在同一时刻收到各自的目标位置帧,完成这 20ms 的最后一次跳动!
四、写给工程师的系统设计亮点
回顾 MicroDuck 的 50Hz 控制闭环,其架构有三点非常值得嵌入式、机器人与 Rust 开发者借鉴:
1. 50Hz 循环内零动态内存分配(Zero Allocations)
在每秒跳动 50 次的硬实时线程中,严禁频繁向堆申请内存。
- 观测张量与动作数组采用固定大小数组(如
[f32; OBS_LEN])。 - 状态广播推流(
state_tx)前,先检查state_tx.receiver_count() > 0,没有客户端监听时绝不组装调试包,杜绝无效开销。
2. 状态机明确命中语义,拒绝逻辑过度压缩
在控制流状态机中,代码始终遵循直观的命中了哪种状态,如:
Sit::Sitting显式设置编码并在 Twist 中传递姿势位;- 避免为了省几行代码而将多种条件糅合成晦涩的布尔算式。可读性与可维护性第一。
3. 故障容忍与降级策略(Coast 机制)
串口总线偶尔丢一帧数据是工业现实。
- 系统设计了
coast缓冲机制:偶发单次读失败,允许沿用上一帧有效状态短暂平滑过渡; - 但如果连续丢失,立刻报警并切入安全 Hold 姿态。
- 对 IMU 冻结设计了滑动窗口检测与日志限频报警,防止串口错误日志刷爆磁盘。
五、结语
在 MicroDuck 身上,我们看到了现代机器人技术栈的典范组合:
- 上层:以 MuJoCo + PPO 强化学习训练策略,摆脱了手工编写繁琐运动学公式的束缚;
- 下层:以 Rust 的高性能、类型安全与无锁并发,搭建了坚如磐石的 50Hz 实时驱动骨架。
从手柄拨动摇杆的微弱电流,到 20 毫秒后 15 个舵机整齐划一的扭矩爆发,每一行代码都在向工程的确定性致敬。
这,就是硬核机器人的魅力所在。
(本文所有技术分析基于 MicroDuck 开源仓库对应版本及 Codemap 画布「50Hz control loop」链路整理。)
附录:怎么用 Codemap 复现本文的调用图
- 安装扩展:Codemap Extension(Marketplace)
- 打开 MicroDuck 相关仓库(确保 Rust Analyzer 等 Language Server 已就绪)
- 在 Cursor / VS Code Agent 对话中说:
分析 robotd 的 50Hz control loop,从 padd 意图注入到 bus.write,通过 codemap 可视化
- Agent 会调用 Codema

p 的show_code_structure等工具,把相关文件平铺到画布并画出调用连线
如果你也在啃跨 crate / 跨进程的实时控制系统,欢迎把你的 Codemap 画布截图丢到评论区交流。
扩展:Codemap Extension · 官网:codemap.info
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)