Radxa ZERO 3W 零基础开发教程:模型部署、硬件调试与软件开发
承接《Radxa ZERO 3W 零基础部署调试教程》。
那份教程教你把软件装上,这份教程教你自己造软件:训练一个模型、把它部署上板、
调试舵机和传感器、改一行代码并在一分钟内看到它跑在鸭子上。
全部命令都来自本仓库的真实脚本和文档,
标注了来源文件,可直接跳过去看原文。
怎么读这份教程
这份教程假设你已经走完部署教程(板子能 SSH、robotctl 0.13.0 已装、7 个守护进程在跑)。
如果你的板子还没装好,先回去把部署教程 §7、§8 做完。
它分四块,可以跳着读:
| 想先搞清楚"鸭子系统到底怎么长在一起的" | §1 |
| 想训练/部署一个自己的策略或检测模型 | §2(全链路) |
| 想调舵机、IMU、ToF、摄像头 | §3 |
| 想改 Rust 代码并推到板子上跑 | §4 |
| 想把上面串起来做一个完整小功能 | §5(端到端演练) |
三块代码、三个仓库,先记在脑子里:
| microduck | e:\\optiDuck\\joyandai\\microduck | 上板软件栈:7 个 Rust daemon、robotctl/duckctl、板级脚本。你的开发主要在这里 |
| microduck_rl | e:\\optiDuck\\joyandai\\microduck_rl | 强化学习训练:MuJoCo + PPO 训走路/站立/翻跟头,导出 ONNX |
| duck_detector(外部) | 在 GitHub/HF 上 | 检测器训练:YOLO 模型训练 + ONNX→RKNN 转换(不在本地仓库里) |
还有一个"仓库"不是代码:Hugging Face Hub。策略(microduck-*)、检测器
(microduck-duck-detector)都以"文件 + 一个 manifest.json"的形式发布在 HF 上,
板子通过 updaterd 从 HF 拉取。模型和策略的更新不经过 daemon 发版——这是全篇最重要的概念之一。
🎯 0. 路线图:从"会部署"到"会开发"
先看整张图,心里有数再逐节拆。三条链路,本文各占一节:
┌────────────────────────────────────────────────┐
模型链路 (§2) │ │
┌────────────┐ ┌──────┴──────┐ ┌───────────┐ ┌───────────────────┐ │
│ 训练 │ │ 导出 ONNX │ │ 转 RKNN │ │ 发布到 HF Hub │ │
│ microduck_ │ →│ scripts/ │ →│ (检测器) │→ │ policies / │ │
│ rl (PPO) │ │ export.py │ │ to_rknn.py│ │ duck-detector │ │
└────────────┘ └─────────────┘ └───────────┘ └─────────┬─────────┘ │
│ updaterd │
硬件链路 (§3) ▼ │
┌────────────────────────┐ ┌────────────────────────────────┐ │
│ robotctl health/monitor│ │ /opt/robot/policies/current │ │
│ 舵机·IMU·ToF·摄像头 │────────│ /opt/robot/detector/current │ │
└────────────────────────┘ └──────────┬─────────────────────┘ │
│ robotd 50 Hz 循环 │
代码链路 (§4) │ │
┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ 改代码 │ →│ 交叉编译 │ →│ dev-push.sh │─│ 打包+签名+apply+健康门 │
└──────────┘ └──────────┘ └──────────────┘ └────────────────────────┘
一句话版本:
- 模型 = 被动数据(.onnx / .rknn + manifest.json),走 HF Hub → updaterd 通道,
更新不重启 daemon 之外的任何东西,随时可以回退; - 代码 = 主动逻辑(Rust 二进制 + 板级脚本),走 dev-push / release 通道,
每次替换都要过签名校验 + 健康门 + 自动回滚; - 硬件 = 你调试的对象,全部以 robotctl health / monitor 为观测入口。
💡 部署教程讲"装",这里讲"造"。 你在部署教程里见过的 policies/current → seed-v5、
detector/current → seed-duck-v1 这两个符号链接,就是模型链路的终点——读完 §2 你会知道
它们是怎么被填上的、以及如何填上你自己的。
1. 先建立全局观:这个系统是怎么长在一起的
1.1 一张图认识 7 个守护进程
部署教程只让你"装上了",没讲它们各自是什么。这里补上(来自
docs/design/architecture.md):
手柄(padd) 手机(btd) 你/SSH(robotctl) 远端(mediad) GitHub release
│ BLE │ BLE │ ssh │ WebRTC │ https
▼ ▼ ▼ ▼ ▼
┌─────┐ ┌─────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│padd │ │ btd │ │ robotctl│ │ mediad │ │ updaterd│
└──┬──┘ └──┬──┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ JSON-RPC 2.0 · 一行的 NDJSON · unix socket │
▼ ▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ robotd(50 Hz 控制循环,唯一能碰电机的人) configd(Wi-Fi/名字/配对)│
│ tofd(ToF 8×8 深度矩阵,只发不收) │
└──────────────────────────────────────────────────────────────────────┘
│ 一条 Dynamixel UART(/dev/ttyS2)
▼
15 个舵机 + IMU(id 200,你的鸭子为 HD1910)
三个必须记住的硬规则(后面调试全靠它们):
1.2 两个必须分清的词:slot 与 skill、policy 与 detector
| slot(槽位) | 机器人默认跑什么:7 个固定槽位 walk/stand/sitstand/ground_pick/kick_left/kick_right/roulade | walk 槽装走路步态 |
| skill(技能) | 机器人被要求时跑什么:一次性的动作,跑完自己回去 | 鞠躬polite-bow、前滚翻 roulade |
| policy(策略) | 从 RL 训练出的obs[1,61] → actions[1,14] 的 ONNX 模型 | walk.onnx |
| detector(检测器) | 从图像训练出的目标检测模型(YOLO),输入 320×320 帧,输出 2100 个候选框 | duck_detect.rknn(NPU)/ .onnx(CPU 兜底) |
robotctl policy list # 看 7 个槽位 + 已装的 skills
robotctl policy add polite-bow <HF repo> # 加一个技能
robotctl robot do polite-bow # 让它跑一下
💡 slot 由配置指向某个模型文件,skill 由名字被调用。 一个策略文件放对地方(槽位/技能),就是"部署"的全部。
1.3 三条更新通道:什么走哪条,为什么
| 策略 / 检测器模型 | HF Hub →updaterd(robotctl policy update / robotctl duck-detector update) | 只需manifest.json 字段合法 | 拒绝安装,现有模型不动 |
| daemon 代码(发版) | GitHub release →robotctl update apply daemon | 签名 + 健康门 | 自动回滚到上一个 release |
| 你的开发代码 | dev-push.sh(用开发密钥签名)→ robotctl update apply –from | 开发板 + dev key | 同上,自动回滚 |
为什么模型单独一条通道? 因为"换一个策略"和"换一个 daemon"是两种风险等级:
策略文件是数据,robotd 每次加载前都会校验形状(obs_len/action_len/model_api/robot.model),
错了顶多拒绝加载;而 daemon 二进制是代码,错了可能让板子失联,所以必须过签名 + 健康门 + 回滚。
记住这个分层,后面所有操作都不迷路。
1.4 你手上的三板斧(先记住,后面反复用)
robotctl version # 每个 daemon 实际跑的版本 vs 已安装版本(先跑这个,别信记忆)
robotctl health # 硬件 + 软件一份报告;不健康时退出码非 0(可接脚本)
robotctl monitor # 实时:指令 vs 实际、IMU 重力、循环频率、电量和温度
robotctl monitor 是调试主力,先记几个键:q 退出、t 打开 ToF 矩阵、c 打开摄像头、
d 关掉右侧机器人小图、p 打开手柄输入流。终端不够宽时按 d 关掉机器人图。
2. 模型部署:从训练到上板(全链路)
目标:这一节结束,你能把一个自己训出来的模型(或仓库里已有的模型)放到鸭子上跑起来,
并知道每一步卡住时去哪查。全链路五段:
① 训练 (microduck_rl) → ② 导出 ONNX → ③ 彩排 (CPU) → ④ 发布到 HF → ⑤ 上板+验证
uv run train export.py infer_policy / uv run publish robotctl policy /
policy-rehearsal duck-detector
策略和检测器是两条平行的链(训练仓不同、转换不同、上板命令不同),分开讲,最后汇总一张对照表。(uv一个包及命令管理器,可以给你的每个命令单独创造一个环境进行运行,速度快、兼容好)
2.1 训练一个走路策略(最短路径)
训练仓是 microduck_rl,基于 mjlab(MuJoCo + Warp)跑 PPO(rsl_rl)。
mjlab:一个物理机器学习模型,用来分配及观测每次PyTorch的结果。一次训练迭代流程如下
mjlab 重置并并行运行数千个仿真环境
基于 MuJoCo Warp,在 GPU 上批量步进物理世界。
策略网络(PyTorch)接收观测,输出动作
观测是 PyTorch 张量,策略网络也是 PyTorch nn.Module,两者共享 GPU 内存,零拷贝。
mjlab 把动作送入物理引擎,计算新观测和奖励
奖励、终止条件、课程学习、域随机化都由 mjlab 定义。
RSL-RL 等算法层用 PyTorch 计算损失和梯度
mjlab 收集 rollout 数据,RSL-RL 调用 PyTorch 的 autograd 和 optimizer 更新策略参数。
循环直到策略收敛
mjlab 负责“跑环境”,PyTorch 负责“学参数”。
你的第一目标是跑通冒烟测试,不是训出好策略——冒烟测试 1–2 分钟,能拦下约 95% 的配置错误
(这是 microduck_rl/AGENTS.md 的原话:Never launch a long run without one)。
cd e:\\optiDuck\\joyandai\\microduck_rl
# 0) 先同步依赖(uv 管理,装好 uv 后)
uv sync
# 1) 冒烟测试(必须先跑;64 envs × 5 iterations,1–2 分钟)
uv run train Mjlab-Velocity-Flat-MicroDuck –env.scene.num-envs 64 –agent.max_iterations 5
# 2) 正式训练(需要 CUDA GPU;8–12 GB 显存用 1024 envs,≥16 GB 用 4096)
uv run train Mjlab-Velocity-Flat-MicroDuck \\
–env.scene.num-envs 1024 \\
–agent.max_iterations 15000
# 3) 续训(从某个 checkpoint 接着跑)
uv run train Mjlab-Velocity-Flat-MicroDuck \\
–env.scene.num-envs 1024 \\
–agent.load-checkpoint logs/rsl_rl/velocity/<run>/model_15000.pt \\
–agent.resume True \\
–agent.max_iterations 30000
来源:docs/training_config_velocity_hd1910.md §10 训练命令、microduck_rl/AGENTS.md Commands。
训练产物:logs/rsl_rl/velocity/<timestamp>/model_<iter>.pt;
曲线在 wandb 项目 mjlab_microduck(本地跑是离线 run:wandb/offline-run-*)。
训练前必须懂的三件事(否则训出来的东西上不了板)
① 观测是 61 维,全系统共享,别动它。 布局固定:48 维本体感受 + 13 维命令块 [twist(3), head_pose(4), body_pose(6)],顺序固定。新任务只改命令块的含义,不能删槽位
(AGENTS.md Invariants 第一条)。全零命令 = 站立——这是部署时的空闲状态,必须显式训练过。
② 关节布局是 14 个舵机,ctrl idx = joint idx:
0–4 左腿:hip_yaw, hip_roll, hip_pitch, knee, ankle
5–8 脖子/头:neck_pitch, head_pitch, head_yaw, head_roll
9–13 右腿
MDP 是 Markov Decision Process 的缩写,中文叫 马尔可夫决策过程 。它是强化学习里描述“智能体如何在一系列状态下做决策”的数学框架。
写 MDP 时永远用 _servo_joint_ids / _servo_joint_pos 辅助函数取关节下标,
不要硬编码(换带被动关节的模型时下标会错位)。
③ 执行器是 BAM 模型(电压控制的作动器,摩擦力由执行器自己算)。电机参数按型号分开存:
microduck_rl/vendor/bam/bam/params/hd1910/*.json(你的鸭子)/ hls2909(另一款)。
训练、彩排命令都要带对应 –motor hd1910(§2.3)。dof_frictionloss 在 BAM 下是零——
给关节摩擦加 DR **(Domain Randomization,域随机化)**要缩放执行器的 friction_scale,否则是静默的无效操作。
为什么按型号分开?因为换执行器 = 重训策略:官方 XL330 的 ONNX 是给 XL330 的 BAM M6
执行器模型训的,直接换到 HD1910 上不能 bit-exact 复用(OpenMicroDuck docs/servo.md §1),
电机型号写在训练/彩排命令里(–motor hd1910),从源头就锁对执行器参数。
训练纪律(少走几个月的弯路)
- 奖励符号: microduck_mdp 里自取负的惩罚项(*_penalty / *_l1 返回 ≤ 0)要用正权重,
否则双重取负变成"奖励违规"。铁律:每个 Episode_Reward/<penalty> 在 wandb 里必须 ≤ 0。 - 预算参考: 简单一次性动作 ≈ 1000 iterations(4096 envs);走路等复杂步态 4000–6000。
- 失败先测再改: 别凭感觉调奖励,先 headless 评估 checkpoint,很多"失败"只是 checkpoint 太早。
💡 当前板子实况: 板子上 policies/current → seed-v5 是官方策略集。你训练的第一步
不是替换它,而是先在 PC 上跑通"冒烟 → 导出 → 彩排",等 §2.3 的彩排通过再谈上板。
2.2 导出 ONNX(归一化会被烤进模型)
训练完的 checkpoint 不能直接用,必须走官方导出器——它会把观测归一化
(obs_normalization=True)烘焙进 ONNX。手工转换会丢掉归一化,在仿真里玩看不出问题
(play 时环境会再归一化一次),上了真机才露馅。这是 AGENTS.md 明确警告过的坑。
# 方式 A:从 wandb run 导出
uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck –wandb-run-path <entity/project/run_id>
# 方式 B:从本地 checkpoint 导出(最常用)
uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck \\
–onnx-file walk_hd1910.onnx –checkpoint <iter>
例如需要导出以下目录模型:E:\\optiDuck\\microduck_rl\\logs\\rsl_rl\\velocity\\2026-09-15_20-44-58_prebom_resume
实际命令为:
# 1) 切到训练仓库根目录(导出器按相对路径找 logs/)
cd E:\\optiDuck\\microduck_rl
# 2) 导出 model_7750.pt → walk_hd1910.onnx(归一化自动烤进 ONNX)
uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck `
–checkpoint-file "E:\\optiDuck\\microduck_rl\\logs\\rsl_rl\\velocity\\2026-09-15_20-44-58_prebom_resume\\model_7750.pt" `
–onnx-file walk_hd1910.onnx
# 3) 确认产物(应显示 walk_hd1910.onnx,约几十 MB)
Get-ChildItem walk_hd1910.onnx | Select-Object Name, Length, LastWriteTime
3D演示这模型的命令如下:
cd E:\\optiDuck\\microduck_rl
uv run play Mjlab-Velocity-Flat-MicroDuck `
–checkpoint-file "E:\\optiDuck\\microduck_rl\\logs\\rsl_rl\\velocity\\2026-09-15_20-44-58_prebom_resume\\model_7750.pt" `
–num-envs 1
导出实现(microduck_rl/src/mjlab_microduck/export.py L251-L269)的核心动作:
runner.export_policy_to_onnx 只导出 actor(critic 不上板),把观测归一化的均值/方差写成
ONNX 常量,并把 model_api、obs_len、action_len 等写进 metadata。
产出契约(也是板子校验的契约,见 docs/recurrent-policies.md 与 docs/policy-manifest.md):
| 前馈模型 | obs [1, 61] | actions [1, 14] |
| LSTM 模型(model_api: 2) | obs [1, 61], h_in, c_in | actions [1, 14], h_out, c_out |
所有张量 float32、按名字匹配(与顺序无关)。LSTM 导出必须标 model_api: 2,
老 daemon 会拒绝安装;前馈是 API 1。
2.3 CPU 彩排:上真机前的最后一次检查
两个彩排工具,一个在训练仓(仿真),一个在上板仓(真实推理路径):(注意以下命令是linux环境)
# ① 训练仓:MuJoCo 里用真实执行器参数走一遍(默认已是 hd1910 电机模型)
uv run scripts/infer_policy.py –walking walk_hd1910.onnx –motor hd1910
# ② 上板仓:用生产级 Rust 推理路径测延迟,不碰电机总线
# (在 PC 上开发、最终在板子上跑——板子的耗时才是真预算)
export ORT_DYLIB_PATH=/path/to/libonnxruntime.so
cargo run –release -p duck-control –example policy-rehearsal — policy.onnx > rehearsal.json
policy-rehearsal 会预热模型、清空 LSTM 状态、测 1000 次推理,输出 p50/p95/p99/max 延迟、
超过 20 ms 的次数和动作序列(docs/recurrent-policies.md §Offline rehearsal)。
💡 部署侧提醒: 板子上的 ONNX Runtime 由部署脚本装(setup-board.sh 装 1.28.0)。
检测器走 NPU 时模型是 .rknn,策略走 CPU 时是 .onnx,两者链路不同,别混。
2.4 发布到 HF Hub:一个模型变成一个"仓库"
发布前置:HF 登录(一次性的)。 uv run publish 走 huggingface_hub 上传,
token 从两个地方找,没有就不让你发(报 no cached HF token. Run hf auth login.):
# 方式 A(推荐):huggingface_hub CLI 登录,token 缓存到本机
# 先在 https://huggingface.co/settings/tokens 创建 token,再粘贴进来
hf auth login
# 方式 B:临时用环境变量(不写盘,仅当前终端生效)
$env:HF_TOKEN = "hf_xxx" # PowerShell;Linux 用 export HF_TOKEN=hf_xxx
token 权限要求:publish 要在你名下发仓库(create_repo)并上传文件
(upload_folder),必须用 Write 权限的 token(fine-grained token 勾 write;
只读 token 上传会 401/403)。–repo <user>/microduck-<name> 里的 <user> 就是
登录账号的 HF 用户名,publish 会自动从 token 推断——所以先登录、用户名填自己的。
💡 如果你之后用 uv run train … –hf-jobs 在 HF 上跑训练,那是另一个
带 Jobs 权限的 fine-grained token,与本地发布不是一回事(hf_jobs.py 里
token 鉴权通过但缺 Jobs 权限会明确报 “token-scope problem, not billing”)。
策略不是"文件上传"这么简单,板子要求旁边有一个 manifest.json 描述它。
训练仓的 publish 命令帮你把两者一起发布(microduck_rl/AGENTS.md):
uv run publish –task <TASK_ID> –wandb-run-path <entity/project/run_id> \\
–checkpoint N –repo <user>/microduck-<name> \\
–kind episodic –duration-s 4.0
# 产出:policy.onnx + schema-2 的 manifest.json + README,推送到 HF Hub
# 板子侧用 `robotctl policy add` 读取它
publish 只允许发布常命令的 episodic/perpetual 策略;phase/posture 那种由 daemon 驱动的
策略(地面捡物、坐↔站)属于官方策略集,不走这条普通发布路。
manifest.json 长什么样(完整字段见 docs/policy-manifest.md,一个典型单策略仓库):
{
"schema_version": 2,
"model_api": 1,
"obs_len": 61,
"action_len": 14,
"robot": { "model": "microduck", "hw_rev": 1, "servos": "hd1910", "control_hz": 50 },
"name": "polite-bow",
"kind": "episodic",
"duration_s": 4.0,
"chain": false,
"entry_pose": "standing",
"description": "Bows from a two-foot stand and comes back up.",
"command": { "encoding": "constant", "idle": [0, 0, 0],
"twist": "unused (zeros)", "head": "unused (zeros)", "body": "unused (zeros)" },
"training": { "task_id": "Mjlab-PoliteBow-Flat-MicroDuck", "repo": "pollen-robotics/microduck_rl",
"commit": "0bf9897", "branch": "bow", "dirty": false,
"run": "pollen-robotics/mjlab_microduck/abc123", "checkpoint": 3000,
"exported": "2026-09-02T14:05:00Z" }
}
💡 robot.model(microduck)才是校验字段,写错直接拒绝安装;robot.servos
只是展示字段(官方例子写 xl330,你的鸭子写 hd1910),不校验但写对才能溯源。
官方 policy-manifest.md 里的例子、以及 robotctl policy add 的校验规则都基于 robot.model。
两个最关键的概念字段(kind 和 command.encoding,板子靠它们决定怎么用你的模型):
| episodic | 自己跑duration_s 秒后回安全姿势 | 一次性技能(robot do 触发) |
| perpetual | 一直跑直到被告知停下 | 槽位步态(policy load walk …) |
| scripted | 可被中断的一次性 | 记录用,daemon 自己驱动 |
| 缺省 /constant | 固定 twist,回程idle | 所有一次性技能 |
| phase | [cos 2πφ, sin 2πφ, 0] 随时间转 | 地面捡物(daemon 驱动,拒绝 policy add) |
| posture_flag | 一个槽位携带 sit/stand | 坐↔站(同上,拒绝 policy add) |
💡 一个常命令的 episodic 策略,加上 duration_s,就是"按名字就能调用的技能"——
这是社区发布策略最常用的形态。phase/posture 策略不能做成技能,装进槽位由 daemon 驱动。
2.5 策略上板:五条命令全场景
板子上所有策略操作都走 robotctl policy …(只读命令不需要 sudo,改状态的要 sudo):
robotctl policy list # 看 7 个槽位 + 已装技能(两栏表格)
robotctl policy check # 官方策略集:已装 vs 最新 vs 仓库还提供什么(只读)
sudo robotctl policy update # 更新到最新(–version v1 可回退到指定版本)
试自己的文件(不发布、不改配置、不重启):
sudo robotctl policy load walk /home/radxa/my_walking.onnx
走路时会先回 home 姿势、加载、再驱动;坐着时则什么都不动——换的不是正在跑的那个网络。
sudo robotctl policy reset walk 放回原样。
装别人的(社区)策略:
robotctl policy search microduck
sudo robotctl policy load walk RemiFabre/microduck-flamingo-cycle
加一个技能并触发:
sudo robotctl policy add polite-bow fffiloni/microduck-polite-bow-b1d864
robotctl robot do polite-bow
robot do 需要机器人正在驱动(手柄 Start 后)。技能长度来自 manifest 的 duration_s。
把技能绑到手柄按钮:
robotctl pad bindings # 看当前绑定
sudo robotctl pad bind x polite-bow
写入 [pad] x = "polite-bow" 一行,padd 一秒内生效,不重启。
校验规则(被拒绝的常见原因):policy add 在下载前就检查 obs_len(必须 61)、
action_len(14)、model_api(≤ daemon 的)、robot.model(microduck),
以及非 constant 编码——任何一项不符直接拒绝,不会动你现有的策略。
📖 代码侧实现:policy.fetch 由 updaterd 处理,robot.setSkill / robot.policies /
robot.do 是 duck-ipc-proto 的现成方法名——spaces/policy-shop/app.py 里的"一键安装"
就是按这个顺序调这四个调用(详见 §4.5)。
2.6 检测器模型:另一条链(YOLO → RKNN)
策略是"自己训 + 导出 + 发布",检测器链路的转换步骤不同——它要变成 NPU 能跑的 .rknn:
① 训练 (duck_detector 仓) → ② 转 RKNN (scripts/to_rknn.py) → ③ 发布 HF Hub → ④ 上板
yolo11n 320×320 INT8 量化 → duck_detector.rknn 同名仓发两个文件 robotctl
同时留 duck_detector.onnx (CPU 兜底) duck-detector
为什么两个文件? 板子优先跑 NPU 的 .rknn;NPU 不可用时回退 CPU 的 .onnx
(deploy/robotd.toml L348-358 写了这个选择逻辑)。
上板命令(跟策略同构,根不同、文件清单固定):
robotctl duck-detector check # 已装 vs 仓库最新(只读)
sudo robotctl duck-detector update # 更新(重启 mediad,画面会断一下)
# 检测器跑不跑还受配置控制:sudo robotctl configure 里 [duck_detector] enabled
首次上板机制:scripts/seed-detector.sh 由 release 的 postinstall 钩子跑,
在空板子上按 pin 安装(DETECTOR_REPO / DETECTOR_VERSION / DETECTOR_FILES,脚本 L32-47),
装到 /opt/robot/detector/releases/seed-duck-v1/ 后原子切换 current 符号链接
(先写 current.new 再 rename,脚本 L66-126)。它绝不碰不是自己装的东西。
板子目录结构(/opt/robot 下三条 current 链,是模型链路的"安装现场"):
/opt/robot/detector/current → releases/seed-duck-v1/ # 检测器(你的板子现在是这个)
/opt/robot/policies/current → releases/seed-v5/ # 官方策略集(含 manifest.json)
/opt/robot/daemon/current → releases/<version>/ # daemon 二进制(§4 的主角)
2.7 NPU 推理链路:模型在板子上到底怎么跑
检测器的运行时绑定在 duck-detect crate(docs/project/npu-bringup.md):
- librknnrt.so 是 dlopen 动态加载的,不是链接的(duck-detect/src/rknn.rs)。
因为它是 vendor blob、不在任何 Debian 套件里,链接它就没法在 CI 交叉编译; - 运行时反量化:INT8 量化模型的输出带 scale 和 zero point,
rknn_outputs_get 在请求时会转成 float——解码层拿到的直接是 float,不用自己算 scale; - 版本约束:librknnrt.so(板子上是 v2.3.2,由 scripts/setup-npu.sh 装)不能低于
转换模型用的 rknn-toolkit2 版本,否则 rknn_init 失败且无解释(Cargo.toml L34-42
有这条约束的 pin)。
验证工具 duck-bench(不在 release 里,是测量工具,走 scp;duck-detect/src/bin/duck-bench.rs):
cargo board –bins -p duck-detect # 交叉编译(§4.3 会讲 cargo board 是什么)
scp target/aarch64-unknown-linux-gnu/release/duck-bench radxa@<robot>:/var/tmp/
scp <the>.rknn radxa@<robot>:/var/tmp/duck.rknn
scp -r datasets/raw/<a-session> radxa@<robot>:/var/tmp/frames
/var/tmp/duck-bench –model /var/tmp/duck.rknn –frames /var/tmp/frames
它按顺序回答三个问题:能跑吗 → 还认鸭子吗 → 花多少钱(延迟分位数 + 本进程 CPU)。
量化模型的第一个坑——阈值: 量化模型的分数在自己的尺度上,float 模型的 0.5
不是它的 0.5。一跑检测不到东西,先试 –threshold 0.2,别急着怀疑转换坏了。
实测数字(Radxa ZERO 3 上,2 Hz 节奏,来自 npu-bringup 文档):
| driver / runtime | 0.9.8 / 2.3.2 |
| 延迟 p50 / p95 | 25.7 ms / 58.4 ms |
| 每帧 CPU(含 letterbox 缩放) | 20.7 ms |
| 满载结束时 SoC 温度 | 63 °C |
💡 letterbox(1280×720 → 320×320 缩放)在 CPU 上跑,CPU 数字不是纯 NPU 的代价。
用 NPU 的意义是不占用 robotd 的 50 Hz 循环,这个目标要在测量后确认。
2.8 两条模型链路对照表
| 训练仓 | microduck_rl(PPO/MuJoCo) | duck_detector(YOLO,外部仓) |
| 转换 | 不需要(CPU 直接跑 ONNX) | to_rknn.py INT8 量化(NPU 跑 RKNN) |
| 板载格式 | .onnx | .rknn + .onnx(CPU 兜底) |
| 发布地 | <user>/microduck-<name> 或官方策略集 | pollen-robotics/microduck-duck-detector |
| 上板命令 | robotctl policy add/load/update | robotctl duck-detector update |
| 加载校验 | obs_len/action_len/model_api/robot.model | 形状 + 阈值 |
| 谁在跑 | robotd(50 Hz 循环) | mediad(摄像头侧) |
| 更新后重启 | 不重启(槽位重新加载) | 重启mediad |
3. 硬件调试:从体检到动起来
⚠️ 本机现状(2026-09):你手上还没有舵机、摄像头、ToF HAT。 这一节按"硬件到手后的
完整流程"写,每步都标注**【现在能跑】还是【等硬件】**。等硬件到齐,直接照做即可;
现在就能跑的部分(总线体检、日志、排障习惯),建议立刻练手。
核心思想只有一个:板上的一切硬件都由 7 个 daemon 包了一层接口,你永远从
robotctl 这一侧观察和操作,不要绕过 daemon 直接摸硬件。 绕过了,下一层就是
"为什么它不理我"的无底洞。
3.0 硬件地图:这条鸭子身上有什么
Radxa ZERO 3W(你的板子)
┌──────────────────────────────────────────────────────────────┐
│ /dev/ttyS2 ←── Dynamixel UART(protocol 2.0,唯一电机总线)│
│ /dev/i2c-4 ←── HAT 上的 ToF(VL53L5/8CX,8×8 深度矩阵) │
│ CSI-2 MIPI ←── 摄像头(配 rk3568- 前缀的 overlay) │
│ I2S / codec ←── 音频(喇叭 + 麦克风) │
│ /dev/dri/renderD128 ←── NPU(§2.7 已讲) │
└──────────────────────────────────────────────────────────────┘
│ 一条总线上的两个"人"
▼
15 × Dynamixel 协议舵机 + IMU 板(id 200,走 imu_to_dxl)
左腿 20–24 · 右腿 10–14 · 头/脖子/嘴 30–34(robotd-design.md §1.1 官方布局)
· 同一条 UART、同一个 protocol 2.0、同一次 sync_read 批量读回
· 舵机具体型号由机器人配置决定:**你的鸭子是 HD1910**(官方鸭子是 XL330,同协议不同电机)
· 机器人**没有第二根电机总线**,也没用 DXL 之外的协议(docs/design/robotd-design.md §1.1)
舵机 ID 布局是官方写死的,不是随便编的号(docs/design/robotd-design.md L35-37):
| 200 | imu_to_dxl(IMU 板,伪装成"舵机") | 1 |
| 20–24 | 左腿(hip_yaw → ankle 五关节) | 5 |
| 10–14 | 右腿(同上) | 5 |
| 30–34 | 头 / 脖子 / 嘴 | 5 |
为什么 IMU 能和舵机挂同一条线?因为 Dynamixel 是地址寻址的菊花链:每帧带目标 id,
主控广播、目标应答。IMU 板把自己伪装成一个 id=200 的"舵机"(协议层)挂进来,
robotd 一次 sync_read 就把 15 个舵机 + IMU 的数据全拿回来了(IMU 排在 id 向量第一个,
先于舵机爆回应)。这条链是机器人唯一的运动感知来源,所以排查顺序永远是:总线 → robotd → 上层。
主控的接口是怎么分给鸭子的(OpenMicroDuck docs/main_controller.md §4,ZERO 3W 的 40-pin 引出):
| UART2 | 40-pin 引出 | HAT 半双工换向后 → 舵机总线 +imu_to_dxl(id 200) |
| I²C3 M0 | 40-pin 的 3 / 5 | HAT 上 codec、ToF(原厂同一控制器 M1 给了 USB-C PD;官方 overlay 改到 M0 后失去 PD 协商,5 V 充电仍可用) |
| MIPI CSI | 22-pin、0.5 mm 间距、4-lane | IMX219 一类模组,mediad 用 |
| I²S | 40-pin | HAT 上 TLV320AIC3104 音频 codec |
| Wi-Fi / BT | 板载 | configd / btd,不经过 HAT |
⚠️ UART2 有个 Armbian 默认坑:它默认在 UART2 开 serial-getty(登录终端),一个
agetty 占着端口,所有舵机就全看不见了。scripts/setup-board.sh 会 mask 这个 unit
(部署教程已做过)。以后谁再"突然全不认舵机",先 fuser -v /dev/ttyS2 看是不是 agetty
又回来了(robotd-design.md §1.1)。
总线的"运行参数"是启动时写进舵机 EEPROM 的(robotd-design.md §1.1、§2.1)——不是默认值:
| baud_rate | 3(= 1 000 000 bps) | 50 Hz 环一拍要读写 16 个设备,波特率低于 1 Mbps 拍预算就爆 |
| return_delay_time | 0 | XL330 出厂 250 = 每设备 500 µs 换向,16 个设备约 8 ms = 拍预算的 40%,必须清零 |
| pwm_slope | 255 | 位置环斜率,防电流尖峰 |
| shutdown | 52 | 错误锁存掩码:过载 + 过热 + 输入电压故障(谁触发谁锁住,必须 reboot 才清) |
💡 理解换向(turnaround):Dynamixel 是半双工总线,一个设备答完才能轮下一个。
每个设备默认回包前等 500 µs,15 个舵机 + IMU 串下来一拍就吃掉 8 ms(20 ms 拍的 40%)。
把 return_delay_time 清零,就是把这 8 ms 还回去——所以"init 慢"和"一拍 50 Hz"的账,
都在这几个寄存器里。
3.0.1 舵机选型:你的 HD1910 与官方 XL330(为什么能混着讲)
本节内容主要来自 OpenMicroDuck 仓库的 docs/servo.md(选型逻辑)、docs/bom.md(采购)
与 docs/structure.md(结构差异)。先记住一句话:
“总线说 Dynamixel 话” ≠ “电机是 ROBOTIS 原厂”。
官方 Microduck 的 BOM 是 ROBOTIS Dynamixel XL330-M288-T(18 g、288.4:1 减速比、
堵转 0.60 N·m @ 6 V、Dynamixel Protocol 2.0)。你的鸭子换成 飞特 Feetech HD-1910-C001,
OpenMicroDuck 的 3D 结构就是按这颗舵机优化的(docs/structure.md:HD1910 拆掉副舵盘后是
突起的,XL330 是凹陷的,头部/躯干/胯/腿/脚都为此改过——不要拿 XL330 的结构件硬装 HD1910)。
两者协议层兼容:HD-1910 是半双工 8N1 的"飞特数字包",总线上按 XL330 的协议/寄存器工作
(1 Mbps、baud_rate=3),所以 robotd 不用改一行代码。但电机本身不是同一颗——
换型号必须重训策略(§2 里所有训练/彩排命令带 –motor hd1910,就是这个原因)。
HD-1910-C001 关键规格(飞特规格书 A/0,2026-09-07,OpenMicroDuck/hardware_spec/servo/):
| 重量 / 尺寸 | 21 ± 2 g / 20 × 34 × 23 mm(含舵盘) | 18 g / 20 × 34 × 23 mm |
| 减速比 | 320 : 1 | 288.4 : 1 |
| 工作电压 | 4–8.4 V(6 V 为标定点) | 3.7–6.0 V(推荐 5 V) |
| 堵转扭矩 | 12 kg·cm(1.18 N·m)@ 6 V | 6.1 kg·cm(0.60 N·m)@ 6 V |
| 额定扭矩 | 3 kg·cm @ 6 V | 手册未给 |
| 空载转速 | 92 rpm @ 6 V | 123 rpm @ 6 V |
| 电流 @ 6 V | 待机 20 mA / 空载 ≤180 mA / 额定 690 mA / 堵转 1.6 A | 17 mA / — / — / 1.74 A |
| 编码器 | 12-bit 磁编码 | 12-bit 绝对式(AS5601) |
| 电机 / 齿轮 | 空心杯 / 金属齿 | 有芯 / 工程塑料 |
| 协议 | 半双工 8N1,飞特数字包(总线按 XL330 协议跑) | Dynamixel Protocol 2.0 |
| 恒力模式 | 模式 2 恒流;默认模式 4(PD 位置) | Mode 0 电流 / Mode 5 电流限制位置 |
| ID 范围 | 0–253(出厂 ID 1) | 0–252 |
| 针序(AMP2.0-3P) | 1=Signal / 2=Vcc / 3=GND | JST 3P:1=GND / 2=Vcc / 3=DATA |
| 零售价(2026-09) | 预售¥123 | $23.90–$27.49 / ¥95–¥238 |
HD-1910 在 4.8 / 6 / 7.4 V 的完整电气点(规格书 §5):空载 73 / 92 / 113 rpm;堵转 9 / 12 / 15 kg·cm;
额定 2.2 / 3 / 3.7 kg·cm;堵转电流 1.2 / 1.6 / 2 A。Kt = 7.5 kg·cm/A(力矩电流比,BAM 标定用)。
四个必须记的差异(对照表之外的坑):
(1=GND / 3=Signal)相反。做转接线/HAT 前先核对针序,插反轻则不认、重则烧舵机板。
OpenMicroDuck 桌面调试阶段专门有一根 PH2.0 转 5264 3P 转接线(docs/bom.md),
就是给"外置 URT2(5264 口)→ HD-1910(AMP2.0-3P 口)"用的。
直供在范围内;供电轨的 MOS / 连接器电流预算按 15 路额定电流(690 mA × 15 ≈ 10 A 峰值预算)算。
OpenMicroDuck 的结构/装配件(cad/)已按 HD-1910 调好,别混用 XL330 零件。
bit-exact 用到 HD-1910 上(servo.md §1 引言:换执行器之后,官方 9 个 ONNX 不能当即插即用步态)。
你的训练/彩排命令统一带 –motor hd1910(§2.1 ③、§2.3),就是让 BAM 用 HD-1910 的参数
(microduck_rl/vendor/bam/bam/params/hd1910/*.json)。
3.0.2 采购清单:硬件到手前先对一遍(OpenMicroDuck BOM)
OpenMicroDuck 把采购分成两个阶段(docs/bom.md)——别等所有零件齐了再开工:
- 桌面调试版:主控和舵机驱动板外置,用电源适配器供电、舵机驱动板直接连 PC,
先把控制和策略跑通(研发期主力,你现在正处在的阶段); - 整机集成版:Zero Robot HAT + IMU 转接板 + 电池全部进机体内,装好模型软件成整机。
共用件(两个版本都要):
| 舵机 | 飞特 HD-1910-C001 | 恒力空心杯舵机,4–8.4 V,堵转 12 kg·cm | 15 | 换型号必须重训策略 |
| 主控 | Radxa ZERO 3W | RK3566,2 GB RAM,带 WiFi,无 eMMC,带排针版 | 1 | 系统装在 MicroSD 上 |
| SD 卡 | MicroSD / TF | 64 GB,A2 | 1 | 无 eMMC 时必需 |
| 摄像头 | IMX219 | MIPI FPC 排线,500 万像素 | 1 | CSI 接口 |
| 麦克风 / 扬声器 | — | — | — | 料号未定 |
紧固五金(M2/M2.5 自攻螺丝各规格共约 131 颗、轴承 6700K × 3 与 ET2216 × 1)和 3D 打印结构件
(约 40 个零件,推荐 PLA,脚垫可试 TPU-95)详见 bom.md 原文,这里不重复。
桌面调试版额外(研发期必须):
| IMU 模组 | LSM6DSV16X 模块 | 支持 I2C 和 SPI | 1 | IMU_to_servo 自制板未就绪前,成品模组直接挂主控 I2C/SPI 读陀螺仪/加速度 |
| 舵机转接线 | PH2.0 转 5264 3P | 1 根 PH2.0 3P 接 1 根 5264 3P | 1 | 外置飞特 URT2 口是 5264/AMP-3,HD-1910 是 AMP2.0-3P,中间必须转接 |
| 舵机驱动板 | 飞特 URT2 串口总线舵机驱动板 | Type-C 接口 | 1 | 桌面调试的总线入口,直接连 PC |
| 舵机电源适配器 | 7.5 V 3A,DC 5.5×2.1 插头,3C 认证 | — | 1 | 给外置 URT2 / 舵机供电 |
| 主控电源适配器 | Type-C PD(5 V 3A 或 5 V 2A) | — | 1 | 主控外置时单独供电 |
整机集成版额外:Zero Robot HAT 转接板(40-pin 半双工舵机换向 + 电池配电 + I²C / I²S 外设)、
IMU_to_servo 转接板(板载 LSM6DSV16X,挂在舵机总线上、规划从机 ID 200,对齐官方 50 Hz 契约——
整机里 IMU 与 15 颗舵机同一趟 sync_read,不再走主控上的裸 I²C 模组)、
7.4 V 3400 mAh 2S 锂电池组(18650×2,2C 放电,XH2.54 2P 公头)。
3.1 硬件体检:【现在能跑】先养成这个习惯
robotctl health # 一次性报告:硬件 + 软件 + 网络 + 电量温度
robotctl health –json # 机器可读,可接脚本;不健康时退出码非 0
health 是排障第一站:它把"电机总线识别到没有、IMU 读数正不正常、哪个 daemon 没在跑"
一次性摆出来。退出码非 0 可以拿来写检查脚本(比如每分钟跑一次,非 0 就报警)。
robotctl monitor # 实时 TUI,看循环的"真实世界"
monitor 是调试主力,记住它的版面在说什么:
| 指令 vs 实际 | 每条腿关节的目标角 vs 传感器实测——偏差持续 >3° 就是机械/电机问题 |
| IMU 投影重力 | 机器人"以为"自己在往哪倒(跌倒判断的依据) |
| 循环频率 | robotd 是否稳定 50 Hz;掉到 30 Hz 以下先查 CPU 争抢 |
| 电机总线 | 读写耗时、失败次数、电压/温度(每秒采样一次) |
| 电量 / 温度 | 别让训练好的鸭子饿着肚子干活 |
monitor 按键:q 退出、t 打开 ToF 矩阵、c 打开摄像头窗口、d 关右侧机器人小图
(终端窄时按 d)、p 打开手柄输入流。
💡 把「先 health,再 monitor」练成本能。 这两个命令不碰任何硬件状态、不产生副作用,
任何时候都可以跑——这是硬件调试里唯一可以闭眼执行的环节。
3.2 舵机总线调试:【等硬件】Dynamixel 链
先认识三个基本操作(robotctl robot …,全部由 robotd 代理执行,因为它独占总线):
robotctl robot init # 给所有关节上电,约 2 秒位置斜坡到 home 姿势
# 会动所有关节!放架子上或扶住(main.rs L437-448)
robotctl robot relax # 切电,机器人瘫倒——拿起/收纳前先 relax
robotctl robot reboot-motors # 重启全部舵机(或 reboot-motors 10 20 只重启这两颗)
# 舵机过载/过热进入硬件错误时用它恢复,无需拔电池
# 注意:重启后 torque off,要再 robot init 或按 Start
# ID 布局见 §3.0(10–14 右腿 / 20–24 左腿 / 30–34 头)
三个操作对应三个心智模型: init = 上电并站稳(无策略也能站);enable = 把控制权交给
策略(等于手柄 Start);relax = 放倒。调试时永远先 init 再操作,结束时 relax。
总线本身的验证(robotd 没起也能测):
sudo systemctl status robotd # robotd 必须在跑,总线是它独占的
sudo journalctl -u robotd -f # 看 50 Hz 循环的实时日志:读回失败会在这里报
robotctl monitor # 看总线健康栏:读写耗时、失败计数
fuser -v /dev/ttyS2 # "谁占着总线"?出现 agetty = serial-getty 复活了
判断"总线活着"的三个信号(按可靠度排序):
三个常见总线坑:
- 只报 1 个舵机或一半舵机:菊花链断点。从头到尾找松动的接插口,robotctl robot reboot-motors
后重看 health(新插上的舵机按顺序重新上链); - 某个舵机一直过热/过载(monitor 电压温度栏飘红):机械卡死或减速箱锁死,先 reboot-motors <id>
排除固件态,还红就拆下人工转——电机阻转和固件错误的处理完全不同; - 换过舵机后第一次 init 很慢或报错:这是**"收养"机制在工作**(robotd-design.md §2.1)——
新舵机出厂是 ID 1 / 57 600 baud,跟总线(ID 布局 / 1 Mbps)都对不上。robotd 启动时
先 ping 15 个期望 ID;恰好一个没回应时,就去 1 Mbps、再降到 57 600 找 ID 1,
找到后把缺失 ID 和 1 Mbps 波特率写进去、重启它、再跑寄存器检查。所以"换舵机后第一次
init 慢"是正常的收养周期,等 journalctl -u robotd 出现稳定循环再继续;注意只换一颗
——换两颗以上同时缺位,收养逻辑找不到唯一缺失者,会当作总线故障。
3.3 IMU:【等硬件】姿势的裁判
IMU 不是独立传感器,它住在电机总线上(id 200,imu_to_dxl),和舵机一起被
robotd 每 tick 读一次(robotd-design.md L144-156:50 Hz 循环、每秒读一次电压/温度)。
- 在哪里看:robotctl monitor 的 IMU 投影重力项——三个值接近 (0,0,-1) 表示板子水平;
明显偏移且跟随你的摇晃,说明 IMU 活着且在正确工作; - 跌倒判断:robotd 用 IMU 重力投影决定"我倒了没",所以校准/安装方向错误会让机器人
误以为自己在跌倒——换过 IMU 板或拆过 HAT 后,先看 monitor 的重力方向对不对再启用控制。 - ToF HAT 上的 IMU 是另一只([head_imu],默认关闭,docs/project/tof-on-demand.md):sudo robotctl configure # [head_imu] enabled = true 打开,改完需重启 robotd
tofd –imu # 单独会话里读一次 IMU,验证线路(–imu-hz 可调采样率)
💡 两个 IMU 别搞混:总线上那个(id 200)是控制用的、必须开;ToF HAT 上那个是
可选的头部 IMU、默认关。调错对象是新手最常见的"IMU 坏了"误报来源。
3.4 ToF:【等硬件】HAT 上的 8×8 深度矩阵
ToF 由独立的 tofd 守护进程独占(跟 robotd 独占总线一个道理),它只发不收:
把 8×8 深度矩阵写到 /run/tofd/tof.sock,谁想用谁读(architecture.md L80-89)。
robotctl monitor # 按 t 打开 ToF 矩阵,实时看 8×8 深度(越近越亮)
ls -la /run/tofd/ # 确认 tof.sock 在(tofd 活着)
sudo systemctl status tofd
验证顺序:tofd 在跑 → /run/tofd/tof.sock 存在 → monitor 按 t 有数据 → 拿手在
HAT 上方 5–20 cm 晃动看矩阵响应。前三步通过但没响应,多半是 I2C 地址或接线。
ToF 很费 CPU 的教训(tof-on-demand.md 实测):depth + head IMU 的采样开销在
Radxa ZERO 3 上不可忽略。不用头部 IMU 就保持默认关闭,省一档 CPU 给 robotd。
3.5 摄像头:【等硬件】看世界的眼睛
摄像头是"硬件门槛最高"的一环,两个前置条件缺一不可(docs/project/media-bringup.md):
overlay 名(主板 MIPI 复用了 SoC 上的 CSI 口,用错前缀加载了别的型号的 overlay,
摄像头就是黑的);
每十几秒烧满一核(部署教程 §7.3 排障表有这条,你的板子已经把 mediad 停了)。接好
摄像头、配好 overlay 后第一件事就是把它开回来:sudo systemctl enable –now mediad
验证命令(接好摄像头后):
robotctl health # [media] 段显示摄像头型号和分辨率为正常
robotctl monitor # 按 c 打开摄像头窗口
robotctl frame # 存一帧到本机:机器人视角的一张快照(–quality 可调 JPEG 质量)
# 帧还有一个 HTTP 出口:http://<robot>:8080/frame(mediad 提供,遥控页面用它)
画面质量:robotctl configure 里 [media] 段的 quality/resolution 控制。
检测器吃的是流不是快照——mediad 先把帧送进 duck-detect 推理,再把带框的结果
编码推给远端(这就是 §2.7 的 NPU 链路在流水线里的位置)。
3.6 音频:【等硬件】一句话带过
音频 codec(I2S)由 mediad 管,麦克风听声音、喇叭发鸭子叫。验证就一句话:
robotctl monitor 按 q 旁边的声音栏有电平跳动 = 麦克风活着;放个声音文件喇叭响 = 输出活着。
音频和视频共用 mediad,所以"声音没了"先查 mediad 日志,再查 codec 驱动。
3.7 常见硬件故障速查表
| 电机总线一个舵机都不认 | robotctl health 总线行、journalctl -u robotd | 查/dev/ttyS2 是否存在(ls /dev/ttyS2);没它就是内核/设备树问题 |
| 突然全部舵机不认(之前好好的) | fuser -v /dev/ttyS2 | 出现agetty = serial-getty 复活占了总线;mask 掉(systemctl mask serial-getty@ttyS2) |
| 认到但read failed 刷屏 | 总线电压、接线松动 | robotctl robot reboot-motors;还不行换线 |
| 只认一半舵机 | 菊花链断点位置 | 从主控往后逐段排查插口;新插上的舵机重新上链 |
| 换上新舵机不认 | 新舵机出厂 ID 1 / 57.6 kbps | 等robotd 启动"收养"周期(§3.2),看 journalctl -u robotd |
| 某个关节指令 vs 实际偏差大 | monitor 该关节偏差 | 机械卡死/齿轮滑齿,拆下人工转确认 |
| monitor 重力方向不对 | IMU 安装方向 | 重装 IMU 板或改标定,别硬调控制参数 |
| 摄像头黑屏 | overlay 有没有rk3568- 前缀 | sudo journalctl -u mediad -n 30 看具体报错 |
| ToF 无数据但 tofd 活着 | /run/tofd/tof.sock、I2C 地址 | i2cdetect -y 4 看设备在不在总线上 |
| 负载莫名飙高 | systemctl show mediad -p NRestarts | 没接摄像头时 mediad 崩溃循环,先 disable –now,接好再 enable(你已踩过) |
4. 软件开发:改代码,推到板子上
目标:这一节结束,你能改一行代码,一分钟内在板子上看到它生效,并且知道自己推的
版本是谁、出问题怎么退回来。整节围绕一个命令:scripts/dev-push.sh——它就是"从改一行
到跑起来"的循环(docs/robot/dev-push.md 全文)。
4.1 代码结构:一个 workspace,七个 daemon
microduck 是单个 Rust workspace(README.md L92-99),顶层结构:
microduck/
├── Cargo.toml # workspace;[workspace.metadata.rknpu] pin 了 NPU runtime 版本(§2.7)
├── .cargo/config.toml # 定义 cargo board(交叉编译别名,§4.3 用)
├── robotctl/ # CLI:你调试板子的唯一入口(§3 全用它)
├── duckctl/ # CLI:找板子/SSH/SCP(蓝牙、Wi-Fi 都没网也能找到板子)
├── robotd/ # 50 Hz 控制循环,唯一碰电机的人(改它最危险也最直观)
├── mediad/ # 摄像头/音频/WebRTC 远端
├── updaterd/ # 更新引擎:签名、原子切换、健康门、回滚(§4.3 的 apply、§4.7 的错误表都靠它)
├── configd/ # Wi-Fi、名字、配对
├── padd/ # 手柄接收
├── btd/ # 手机蓝牙连接
├── tofd/ # ToF 8×8 深度矩阵(只发不收)
├── duck-detect/ # 检测器推理绑定(§2.7 讲过 dlopen rknn)
├── duck-ipc-proto/ # 共享协议:JSON-RPC 2.0 NDJSON 方法定义(全部 daemon 通信契约)
├── spaces/ # 示例项目(Python):hello / vision-demo / policy-shop(§4.5)
├── scripts/ # dev-push.sh、provision-board.sh、seed-*.sh 等板级脚本
└── deploy/ # systemd unit、robotd.toml 部署配置
要改什么先想清楚: 控制逻辑在 robotd/src/(main.rs 是主循环,control.rs 是策略驱动,
intents.rs 是意图裁决);命令契约在 duck-ipc-proto;CLI 在 robotctl/src/。
“加个命令给鸭子” = 协议加方法 + robotd 实现 + robotctl 接线,三处都改。
4.2 一次性准备(只在第一台板子做一次)
dev-push 的门槛有三件(dev-push.md §Once):
# ① 板子必须是 dev board(允许开发密钥)
# 你已经配过了:/etc/robot/updater.toml 里 allow_dev_keys=true 且 team.dev.pub 已放好。
# 验证:sudo grep -c 'DEV BOARD' /var/lib/robot/provision.log # 输出 1 为是
# ② 本机放开发签名密钥(私钥,板子只留 .pub)
mkdir -p ~/.duck-keys && cp /path/to/team.dev.key ~/.duck-keys/team.dev.key
# 密钥在别处就设:export DUCK_DEV_SECRET_KEY=/path/to/team.dev.key
# ③ 装交叉编译工具链(二选一)
cargo install cargo-zigbuild –locked # 方式 A:zigbuild + zig
# 或不用装任何东西,每次推加 –docker # 方式 B:Docker
💡 为什么必须签名? 板子的 updaterd 对任何代码一视同仁:验证签名 → 校验哈希 →
检查兼容 → 过健康门。开发密钥签的包,客户板子会拒收(跟拒收任意 –ref 一样),
反过来你的 dev 板也拒收任何没签名的东西。没有"临时绕过",这是设计不是麻烦。
4.3 dev-push 闭环:改一行 → 板子上看到它
cd e:\\optiDuck\\joyandai\\microduck
# 方式一:按名字推(板子在蓝牙范围内,会自动找到 IP 并缓存)
scripts/dev-push.sh –name duck-c51b # 你的板子名,duckctl scan 可查
# 方式二:直接给地址(推荐日常用,绕开蓝牙)
scripts/dev-push.sh radxa@192.168.31.30
# 或设一次环境变量,之后直接跑
export DUCK_BOARD=radxa@192.168.31.30
scripts/dev-push.sh
完整输出长这样(每一步都在干什么,一目了然):
==> building 0.5.1-dev.local.1763400000.g7fc1444 for the board (zigbuild) # 交叉编译
==> packaging # 打成 release 同款包
==> signing with /Users/you/.duck-keys/team.dev.key # 签名
==> copying to radxa@192.168.31.30:/home/radxa/duck-sideload # 拷上板
==> applying on radxa@192.168.31.30 # robotctl update apply –from
==> 0.5.1-dev.local.1763400000.g7fc1444 is live on radxa@192.168.31.30
==> checking every daemon is running it
current -> 0.5.1-dev.local.1763400000.g7fc1444
[ok] robotd [ok] configd [ok] padd [ok] updaterd [ok] btd [ok] mediad [ok] tofd
版本号 0.5.1-dev.local.<时间戳>.<commit>:每次推送带时间戳,两次推送同一棵 dirty 树不会撞车。
板子上确认与回退:
robotctl version # 板上每个 daemon 实际跑的版本
sudo robotctl update rollback daemon # 手动回退到上一个 release(目的性回退就这个命令)
三个常用变体:
scripts/dev-push.sh –dry-run radxa@192.168.31.30
# 干跑:构建/签名/拷贝/校验全做,只差最后"切换 current"。板子什么都不动。推前必用。
scripts/dev-push.sh –docker radxa@192.168.31.30
# 本机没有 zig 时用 Docker 构建(更慢,但零工具链要求)
scripts/dev-push.sh –bootstrap radxa@192.168.31.30
# 只用于板子版本 < 0.5.0 的第一次推(老 updaterd 没有 apply –from 的 API)。
# 会停 robotd 并放弃健康门——正常情况下绝不用。
只看代码是否能在板子上编译(不推):
cargo board –bins # = cargo zigbuild,用板子 target + glibc floor(.cargo/config.toml 定义)
4.4 第一个真实改动:加一条"被摸"日志
找一处真实代码,把 debug 级别提到 info,推上去就能在 journalctl 里看到。位置:
robotd/src/main.rs L2171-2182(抚摸检测事件处理):
pet_detect::PettingEvent::Start => {
let calm = !safety.fallen() && controller.as_ref().is_none_or(|c| !c.busy());
if calm {
tracing::info!("petting started");
voice.play("coo", false);
} else {
tracing::debug!("petting detected (ignored: busy or down)");
// 👇 你的第一行改动:改成 info,这样不用开 debug 就能在日志里看到"被摸但忙"的事件
// tracing::info!("petting detected (ignored: busy or down)");
}
}
改完推上去:
ssh radxa@192.168.31.30 'journalctl -f -u robotd' # 开一个窗口盯日志(推之前开好)
scripts/dev-push.sh radxa@192.168.31.30 # 另一台终端推
# 等板上出现 "[ok] robotd",去摸摸摄像头/ToF(pet_detect 用它们感知)
# journalctl 里出现 "petting detected (ignored: busy or down)" 即你的代码在跑了
📖 这条改动选它有三个理由:位置真实(不是我编的)、改动最小(一行)、验证直观
(摸一下就有日志)。第一次 dev-push 用它建立"我改的代码真的在板子上跑"的信心,比任何
理论都强。
4.5 示例项目:spaces/ 里有什么
spaces/ 是独立的小项目(不是 workspace 的一部分),演示"用现成接口拼功能",
全部是 Python、用 uv 跑。
① policy-shop —— 策略商店(spaces/policy-shop/app.py L13-16 定义了它依赖的四个调用):
policy.fetch {repo, file} → updaterd 下载并读 manifest (你的板子用 robotctl policy add 等价)
robot.setSkill {name, path…} → robotd 写入技能条目并重读技能
robot.policies → 重载成功了吗?change_error 字段说"不"及原因
robot.do {skill} → 跑它
本地跑:
cd spaces/policy-shop
uv venv && uv pip install -r requirements.txt
export DUCK_HOST=192.168.31.30 # 板子 IP
export HF_TOKEN=hf_xxx # 读 HF 公共仓只需要一个真 token
uv run app.py
界面上选一个策略 → 点 install → 点 run:上面四个调用按顺序各来一次。
这是"客户端怎么调鸭子"的最短教材——四个调用就是 §2.5 那些 robotctl policy … 命令
的底层方法名。
② vision-demo —— 视觉流:DUCK_RECEIVER 指向机器人摄像头帧的接收端,
演示"不用板子也能看它看到什么"。跑法同样是 uv run app.py(README.md L81-98)。
③ hello —— 最小演示:最小可跑的"连上机器人 + 说句话"样板,看目录 README 即可。
💡 spaces 和 daemon 的关系: daemon 是板子上常驻的系统软件(Rust),spaces 是
你 PC/手机上的应用(Python)。鸭子只提供 JSON-RPC 接口,怎么拼由你的应用决定。
想加"遥控页面"“自动投喂”“语音对话”,都是在 spaces 这个层次做。
4.6 调试三板斧(按顺序用)
# ① 盯 daemon 日志(推代码前先开好)
ssh radxa@192.168.31.30 'journalctl -f -u robotd -u configd -u btd -u padd'
ssh radxa@192.168.31.30 'journalctl -f -u updaterd' # 推更新时盯这个:每阶段、健康门、重启
# panic 会带完整 backtrace——发布版二进制不剥离符号,帧名都在
# ② 要 debug 级别的日志:drop-in 覆盖 RUST_LOG(默认 info)
sudo mkdir -p /etc/systemd/system/robotd.service.d
sudo tee /etc/systemd/system/robotd.service.d/log.conf > /dev/null <<'EOF'
[Service]
Environment=RUST_LOG=debug
EOF
sudo systemctl daemon-reload && sudo systemctl restart robotd
# drop-in 在 /etc 下,能挺过所有后续推送。调完删掉 + daemon-reload 即复原。
# ③ 问板子"你是谁、什么版本、健不健康"
robotctl health && robotctl version
4.7 dev-push 常见错误速查(全部来自 dev-push.md §When it does not work)
| no dev signing key at … | 板子拒收未签名产物 | 放好team.dev.key,或设 DUCK_DEV_SECRET_KEY |
| signature did not verify against any of N usable trusted key(s) | 板子不是 dev board(不是包坏了) | grep -c 'DEV BOARD' /var/lib/robot/provision.log = 0 就走 install-dev.md 补配置 |
| apply failed (exit 2) + API mismatch | 板子版本太老,没有 apply –from | 用一次–bootstrap,之后恢复正常 |
| preflight check failed: SideloadDir … not there | 包放在/tmp//var/tmp 下 | updaterd 有 PrivateTmp=yes,看不到你的拷贝;换 ~/duck-sideload 等路径 |
| could not reach <name> over Bluetooth | 名字解析不到地址 | duckctl scan 看板子在不在;直接给地址 radxa@IP 绕开蓝牙 |
| ssh 重刷后连不上 | 板子重新生成了 host key | ./scripts/provision-board.sh radxa@IP –forget-host-key |
| is live but not everything is running it | 交换成功、健康门过了,但某 daemon 还在旧版 | robotctl health 看 units 块;`journalctl -u updaterd -b |
| libudev 链接错误(重刷后) | 缓存的 libudev 拷贝过期 | rm -rf ~/.cache/duck-cross/aarch64,下次推送重新抓 |
5. 端到端演练:给鸭子加一个"被摸会回应"的行为
把三块串成一个完整功能。这个演练分两个阶段,第一阶段现在就能做(纯软件),
第二阶段等硬件。每个阶段结束都有明确的"过关"标准——不过关别进下一阶段。
目标功能:摸鸭子(ToF 近距离手势 / 摄像头识别),它发出一声"咕"并做个小动作。
阶段 0:摸底(现在,10 分钟)
robotctl health # 板子健康吗?哪个 daemon 没跑?
robotctl version # 板上 daemon 版本?
ssh radxa@192.168.31.30 'ls -l /opt/robot/policies/current /opt/robot/detector/current'
过关标准:health 全绿(mediad 无摄像头是预期状态,不算病)、版本 ≥ 0.5.0、
policies/current → seed-v5、detector/current → seed-duck-v1。
阶段 1:在 PC 上走通"训练 → 导出 → 彩排"(现在,1 小时)
cd e:\\optiDuck\\joyandai\\microduck_rl
# 1) 冒烟测试(拦配置错误)
uv run train Mjlab-Velocity-Flat-MicroDuck –env.scene.num-envs 64 –agent.max_iterations 5
# 2) 导出(用官方 checkpoint;–checkpoint <iter> 填你冒烟跑出来的实际轮数)
uv run scripts/export.py Mjlab-Velocity-Flat-MicroDuck –onnx-file my_walk.onnx –checkpoint 5
# 3) CPU 彩排:真推理路径测延迟 + 动作序列
cargo run –release -p duck-control –example policy-rehearsal — my_walk.onnx > rehearsal.json
cat rehearsal.json | head
过关标准:导出无警告、rehearsal.json 里 p50 延迟 < 20 ms、动作序列有限值。
不过关就先停在这——调出问题的是配置,不是鸭子。
阶段 2:发布 + 上板(现在能做的部分)
# 1) 发布到 HF(需要 HF 账号;repo 名必须是 <user>/microduck-<name>)
uv run publish –task Mjlab-Velocity-Flat-MicroDuck –checkpoint 5 \\
–repo <you>/microduck-my-walk –kind perpetual
# 2) 板子上装进 walk 槽位试跑(装到非当前正在跑的位置,安全)
ssh radxa@192.168.31.30
robotctl policy list # 看 7 个槽位
sudo robotctl policy load walk <you>/microduck-my-walk
sudo robotctl policy reset walk # 试完放回原样
过关标准:policy load 返回成功(manifest 校验通过)、policy list 里 walk 槽显示你的策略名。
阶段 3:等硬件——接上舵机、摄像头后的收尾
# 1) 把 mediad 开回来(§3.5:现在它因为没摄像头被停着)
sudo systemctl enable –now mediad
# 2) 让鸭子站起来
robotctl robot init # 上电回 home(放架子上!)
robotctl robot enable # 交给策略(等于手柄 Start)
robotctl monitor # 观察:能站住吗?指令 vs 实际偏差?
# 3) 触发"被摸"(阶段 2 的 pet 行为已就绪,取决于 pet_detect 配置的感知来源)
# 观察 monitor 的事件行 + journalctl 里我们的 petting 日志(§4.4)
# 4) 收工
robotctl robot relax # 切电,收纳
过关标准:init 后不抽搐、enable 后站住超过 10 秒、被摸触发回应、relax 干净放倒。
🎯 这套演练的路线图意义: 阶段 1 验证"你的工具链通"、阶段 2 验证"你的模型契约对"、
阶段 3 验证"你的鸭子真身"。每一步都能独立验收,卡在哪一步就去哪一节找答案
(阶段 1 → §2.1-2.3,阶段 2 → §2.4-2.5,阶段 3 → §3 + §4)。
6. 常见问题速查
Q1:改完代码不想等整包编译,能不能只推一个文件?
不能。daemon 是静态编译的 Rust 二进制,任何改动都要重编译 → dev-push。但增量编译约一分钟,
–dry-run 先验、再正式推,两分钟一轮。
Q2:robotctl policy load walk xxx 说拒绝,为什么?
policy add/load 在下载前就校验:obs_len 必须 61、action_len 14、model_api ≤ 当前 daemon、
robot.model 必须 microduck、命令编码必须 constant。看报错对应哪条(§2.5 校验规则)。
Q3:模型怎么才算"部署好了"?
三条 current 链(§2.6):/opt/robot/{policies,detector,daemon}/current 指向你要的版本,
且 robotctl policy list / robotctl duck-detector check 显示已加载。模型不重启 daemon,代码要重启。
Q4:板子没接摄像头,为什么 CPU 负载会飙高?
mediad 崩溃循环(部署教程 §7.3 排障表 + §3.7):找不到 /dev/media* 退出,systemd 反复拉起。
sudo systemctl disable –now mediad,接好摄像头再 enable。
Q5:dev-push 报"板子不是 dev board"怎么办?
grep -c 'DEV BOARD' /var/lib/robot/provision.log 输出 0 就是。走 docs/robot/install-dev.md:
放 team.dev.pub、改 /etc/robot/updater.toml 的 allow_dev_keys=true、重启 updaterd。
Q6:训练导出的 ONNX 在彩排里延迟没问题,上板后还是慢?
彩排在 PC 上跑(§2.3),板子 CPU 弱很多。上板后用 robotctl health 看 CPU、用 NPU 跑检测器
(§2.7),策略推理预算以板子实测为准,PC 数字只是下限参考。
Q7:怎么把鸭子接回我自己的网络?(IP 变了)
duckctl scan 蓝牙找名字 → duckctl –name <名字> wifi connect <ssid> –psk <密码>。
或者板子上 robotctl system set-name 改名后走蓝牙(§4.7 错误表)。
Q8:更新 daemon 后板子失联了,怎么办?
健康门 + 自动回滚(§4.3):起不来的 release 会被自动回退。如果连 updaterd 都坏了,
SSH 上板:sudo robotctl update rollback daemon。再不行就上板刷机(部署教程兜底)。
7. 参考资料索引(按主题)
示例项目
| hello | spaces/hello/ | 连上机器人 + 说句话 |
| vision-demo | spaces/vision-demo/ | 收摄像头帧 |
| policy-shop | spaces/policy-shop/ | 策略商店四个调用 |
资料(本教程 §3.0.1/§3.0.2 的参考来源)
| 舵机选型:XL330 vs HD1910 全对照、波特率、协议 | docs/servo.md |
| 采购清单:桌面调试版 / 整机集成版两阶段 BOM | docs/bom.md |
| 主控选型:ZERO 3W 接口分配、RK3566、NPU 现状 | docs/main_controller.md |
| 结构说明:HD1910 与 XL330 的结构差异、3D 打印优化点 | docs/structure.md |
| 硬件规格书(HD-1910 / HL 系列 / ED330) | hardware_spec/servo/ |
| HD1910 结构 CAD(Bambu Studio 直接打印) | cad/openmicroduck.3mf |
8. 更新日志
| v1.1 | 2026-09-16 | 参考 OpenMicroDuck 文档补充硬件细节:§3.0 修正官方舵机 ID 布局(左腿 20–24 / 右腿 10–14 / 头 30–34 / IMU 200),新增主控接口分配表与总线寄存器参数(1 Mbps /baud_rate=3 / return_delay_time=0);新增 §3.0.1 舵机选型(HD1910 vs XL330 对照、针序警告、换型号必须重训);新增 §3.0.2 两阶段采购清单;§3.2 补充舵机"收养"机制与 fuser -v /dev/ttyS2 排查、修正 reboot-motors 示例 ID;§3.7 故障表新增 3 行;§2.1 补"换执行器 = 重训策略";§2.4 补发布前置(hf auth login / HF_TOKEN、Write 权限 token、Jobs 权限区分);§7 增加 OpenMicroDuck 资料索引 |
| v1.0 | 2026-09 | 首次成稿:§0 路线图、§1 全局观、§2 模型部署全链路、§3 硬件调试、§4 软件开发、§5 端到端演练、§6 FAQ、§7 索引 |
本教程与《Radxa_ZERO_3W_零基础部署调试教程.md》配套使用:部署教程回答"怎么把它装起来",
本教程回答"怎么让它干我想让它干的事"。


