揭秘 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 把 paddrobotdduck-controlduck-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:702robotd/src/main.rs:791)
  • control_loop (robotd/src/main.rs:838robotd/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:716robotd/src/main.rs:2418)
  • handle (robotd/src/main.rs:2455robotd/src/main.rs:2462)

主线程在 Unix Socket 上异步运行 serve,每一个客户端连接(如 paddrobotctl)进入后,都独立 spawn 一个 handle 协程处理。


阶段 B:手柄意图输入与无锁缓存(连线 5 ~ 11)

手柄守护进程 padd 以手柄采样频率向 robotd 下发摇杆计算后的期望速度。

连线 5 & 6:手柄构造 RobotMove 并以 Notification 发送
  • RobotMove (padd/src/main.rs:550duck-ipc-proto/src/lib.rs:610)
  • notify (padd/src/main.rs:606padd/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:2587duck-ipc-proto/src/lib.rs:1344)
  • apply_intent (robotd/src/main.rs:2594robotd/src/main.rs:2631)
  • dispatch (robotd/src/main.rs:2617robotd/src/main.rs:2675)
  • apply_intent (robotd/src/main.rs:2686robotd/src/main.rs:2631)

robotdhandle 在收到 JSON 行后,通过 as_call() 解析为类型安全的强类型枚举。无论客户端发来的是无 ID 的 Notification 还是带 ID 的 Request,最终都会汇聚到 apply_intent()

连线 11:无锁原子更新 set_twist
  • set_twist (robotd/src/main.rs:2634robotd/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:1137duck-control/src/safety.rs:117)

control_loop 启动时,Safety::new(io, ...) 把底层 RobotIo 封装入安全壳内。

连线 13 & 14:硬件单事务同步读取
  • read (robotd/src/main.rs:1343duck-control/src/safety.rs:127)
  • read (duck-control/src/safety.rs:128duck-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:1368duck-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:1379robotd/src/intents.rs:467)
  • gate (robotd/src/main.rs:1380duck-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:1971robotd/src/control.rs:303)

根据当前动作状态机优先级(翻滚 > 踢球 > 叼物 > 起坐 > 站立 > 行走),确定选用哪个神经网络 Net,并调整目标指令。

连线 19:观测向量标准装配
  • build (robotd/src/control.rs:386duck-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:395duck-control/src/policy.rs:310)

调用 ort 绑定库,执行 ONNX 神经网络计算,生成针对 14 个关节的目标动作残差(Action)。

连线 21:动作散射回 15 关节实体
  • scatter_action (robotd/src/control.rs:432duck-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:2180duck-control/src/safety.rs:234)

这是指令到达硬件前最后的护栏:

  1. 更新电机刚度:根据状态更新舵机内部 PID P增益(站立较软、行走适中、摔倒后降为 limp 保护齿轮)。
  2. NaN 检查
    if targets.iter().any(|v| !v.is_finite()) {
        applied.limits.push(Limit::NotFinite);
        // 拒绝执行!保持现有姿态
        self.io.write(&JointTargets::new(hold))?;
        return Ok(applied);
    }
    
  3. 限幅:每一个关节角度必须 clamped 在限位内。

图 3(示意):动作落到硬件前,先经过 Safety 的异常检查与物理限幅。

连线 23:单次 sync_write 广播刷入硬件
  • write (duck-control/src/safety.rs:268duck-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 复现本文的调用图

  1. 安装扩展:Codemap Extension(Marketplace)
  2. 打开 MicroDuck 相关仓库(确保 Rust Analyzer 等 Language Server 已就绪)
  3. 在 Cursor / VS Code Agent 对话中说:

    分析 robotd 的 50Hz control loop,从 padd 意图注入到 bus.write,通过 codemap 可视化

  4. Agent 会调用 Codema在这里插入图片描述
    p 的 show_code_structure 等工具,把相关文件平铺到画布并画出调用连线

如果你也在啃跨 crate / 跨进程的实时控制系统,欢迎把你的 Codemap 画布截图丢到评论区交流。


扩展:Codemap Extension · 官网:codemap.info

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐