本文是 《Microduck 架构图》 的配套前传。
那篇讲软件:7 个守护进程怎么分工。本文讲硬件:这只鸭子里到底有什么零件,它们怎么和软件对话。
我默认你只会写代码,对电子、机械一窍不通。所以最底层的概念都会从头讲;所有用来举例的数字,都是这只鸭子的真实数字。
写法和架构图一致:鸭子相关的事实全部来自仓库,并在每章末尾标注出处。仓库没写到的规格会明确说「没查到」,不编。
生成日期:2026-09-03
软件人习惯说「这个函数返回 3.5 米」。但电线里跑的从来不是「3.5」。电线里跑的是电压。理解鸭子,第一步就是把「软件看到的数据」和「电线里的物理事实」对上号。
把电池想象成一个水塔,把设备想象成接在水塔上的水龙头。
鸭子肚子里是一块 NP-F550 电池,2 节锂电池串联——行话叫「2S」。它的工作电压范围只有 6.6 V 到 8.2 V。这个数是什么概念?比一节 9V 方块电池还低一点。但它胜在能输出很大的电流:15 台小电机一起使劲时,电流才是吃资源的大头。
这里有一个关键现象:电压会被电流拉下来。15 台电机一起发力,电池电压会瞬间往下掉;它们停下来,电压又慢慢弹回去。这叫做「电压下垂」或 sag。鸭子判断「还剩多少电」必须理解这个现象,否则它会在你让它快走两步的时候,以为自己没电了,当场坐下关机。后面第 3.6 节会讲软件怎么对付它。
芯片的一根引脚上,电压不是精确某个数,而是分成两段约定好的区间:
中间那段电压是禁区,设计好的电路不会停在那里。所以一根线上传的「一串字节」,物理上就是时间上依次出现的高低电平。后面讲的 UART、I²C,本质上只是不同的约定:多高算 1,每拍多长,谁先说话。
一句话:模拟是形状,数字是转述。转述会损失精度,但不会被电线噪声轻易歪曲。
鸭子的舵机总线就是半双工的。15 台小电机加 1 块 IMU 板,一共 16 台设备,共用一根数据线。robotd 喊一句「所有人报位置」,然后排队听 16 台设备挨个回答。
这张图里的「排队」不是软件选择,是硬件结构决定的。所以后面软件层的很多设计——比如为什么一个 sync_read 失败要保持上一次的值、为什么要检测 IMU 数据是不是「陈旧」——都是从这张图里长出来的。
本层事实出处:电池规格见 duck-control/src/model.rs:108-129;半双工总线与 16 台设备排队见 robotd-design.md §1.1、duck-control/src/bus.rs:26-28,114-116,232-237;出厂默认应答延迟 250→500 微秒见 duck-control/src/model.rs:84-88、robotd-design.md §2.1。
芯片是一块硅片上刻出来的电路,外面封着黑色塑料壳,边缘伸出金属引脚。引脚是它对外说话的全部渠道。
鸭子的大脑是一颗 SoC,全称 System on Chip,意思是「一颗芯片里塞了一整套电脑」。具体型号是 Rockchip RK3566,四核 Cortex-A55,arm64 架构。
SoC 里面不止有 CPU。对鸭子来说比较重要的几块:
一个热知识:满负荷跑物体检测时,SoC 会热到 95°C,然后自动把 CPU 降到 408 MHz 运行。这个硬件层面的「热到降速」会直接改写软件性能——所以鸭子把检测算法降到每秒只跑 2 次。
芯片焊在板卡上才成为能用的电脑。鸭子用的是 Radxa Zero 3W,信用卡大小的一台完整 Linux 电脑。系统跑的是 Armbian + Debian 13,厂商内核版本 6.1.115-vendor-rk35xx。
这里有一个和硬件打交道的日常经验:同型号的板卡也会有细微差异。比如鸭子的蓝牙控制器是 aic8800,实测大约一半的 Zero 3W 单元需要额外设置 Privacy = device 才能绑上 Xbox 手柄。不是代码写错了,是不同批次的板卡在这件事上有差异。
每颗 RK3566 出厂时还会烧制一个唯一序列号(烧进芯片、不可改写)。鸭子把它保存在 /proc/device-tree/serial-number 里。后面你会看到,这只鸭子的嗓音人格就是拿这个序列号当种子生成的——所以每只鸭子的叫声独一无二,而且终身不变。
供电上也要注意:机器人内部的供电全部来自电池,经 HAT 分发给舵机和主板。USB-C 口并不负责给机器人供电,它只在没有电池或电池没电时给板子供 5V。
板卡边缘那排 40 根金属针,是 SoC 引脚的引出。麻烦的是:同一根物理针脚内部可以被接到几种不同的电路上,这叫引脚复用(pinmux)。同一根针,今天可以是串口,明天可以是 GPIO。
谁来决定今天是什么?设备树(Device Tree)。这是一块随内核一起加载的数据表,逐根声明「这根针今天干什么」。内核照表把每条启用的电路注册成一个 /dev 下的文件。所以:
robotd 打开
/dev/ttyS2这个文件,物理上就是在操作板子上某根特定的针脚;那根针脚通过板上的走线,最终连到了舵机总线。
这就是软件世界和硬件世界的接口。
鸭子真实的踩坑记录都和这条链有关:
rk3568-*,但 Armbian 出厂配置找的是 rk35xx-* 前缀。结果是 overlay 根本没加载,板子看起来正常启动,但 /dev/ttyS2 不存在,软件找不到电机。修复方法是 setup-board 脚本直接把 /boot/armbianEnv.txt 里的前缀改成 rk3568。麻烦的是,内核升级会重新指启动文件,可能把这个修复覆盖掉——所以升级后「看不见电机」要首先怀疑这里。serial-getty@ttyS2,这个终端会和 robotd 抢线,还会吃掉舵机的应答字节。setup-board 脚本会把它 mask 掉,并把内核控制台从串口挪到 HDMI。排查谁占着串口:fuser -v /dev/ttyS2。
HAT 是 Hardware Attached on Top 的缩写,一块带 40 针插座的扩展板,直接扣在主机板上。鸭子的 HAT 是 Pollen Robotics RPI Robot HAT。
它上面住着:
把上面这些零件连起来,就是下面这张图。建议你把它和《架构图》文档里的软件拓扑对照看:软件进程图里「谁连谁」,对应到这里就是「谁抓着哪根总线」。
本层事实出处:RK3566 见 scripts/board-test.sh:4;Radxa Zero 3W 见 scripts/setup-board.sh:20、deploy/audio/i2c3-pihat.dts:46;NPU 出厂禁用见 scripts/setup-npu.sh:4,19-20;SoC 热节流见 robotd-params/src/lib.rs:265-267;SoC 唯一序列号见 architecture.md §9.7;蓝牙绑定个体差异见 scripts/setup-board.sh:51-61,428-433;供电不走 USB-C 见 deploy/audio/i2c3-pihat.dts:27;overlay 名字陷阱见 deploy/README.md:193-200、scripts/setup-board.sh:221-240;串口 getty 占用见 scripts/setup-board.sh:377-410;HAT 描述见 deploy/audio/aic3104-i2c3.dts:5、i2c3-pihat.dts:10-34。
一根线接多台设备能省引脚、省布线,代价是必须立规矩:谁在什么时候说话、说的话给谁。这套规矩就是总线协议。
鸭子身上有两条重要总线,脾气完全不同:
| Dynamixel 总线(UART) | I²C(i2c3,HAT 上) | |
|---|---|---|
| 线数 | 一根数据线(半双工) | 两根:数据 SDA + 时钟 SCL |
| 谁发起对话 | 只有主机 robotd | 只有主机 |
| 怎么点名 | 帧里带设备 id(0–252) | 帧里带 7 位地址 |
| 速率 | 1 Mbps | 400 kHz |
| 鸭子上的设备 | 15 台舵机 + 1 块 IMU 板 | 音频 codec + ToF |
UART 是最简单的串口:两根线(一根发、一根收),没有时钟线。双方必须事先约好:一拍的时长是多少、一帧怎么拼。这个约定叫波特率。鸭子的舵机总线跑在 1 Mbps。
约错了波特率,听到的就是乱码——像两个人各说各的,一个说快板、一个数拍子,节奏对不上。
Dynamixel 协议 v2 盖在 UART 上。一帧里包含:谁说话、给谁、指令是什么、正文、校验。robotd 最常用的读法叫 sync_read:一帧问出去,「id 10 到 34 还有 200,把寄存器 124 到 136 的内容都给我」,然后 16 台设备排队回答,一次事务拿回全部传感器数据。
注意这里的软件策略不是惩罚失败,而是容忍失败:半双工总线上偶尔丢一次应答很正常,控制环不能因此停。所以 robotd 永远保留上一份好数据;同时它还要持续检测 IMU 数据是不是「变陈旧」——也就是读成功了,但 16 台设备交回的字节和上次一模一样。这说明 IMU 板内部卡住了,连续 25 次一样就报警。这套机制完全是从「半双工、排队应答」这个硬件事实里长出来的。
I²C 比 UART 多一根时钟线 SCL。主机每敲一下时钟,数据线上才允许换一个位。这根时钟线消灭了「双方约波特率」的问题,还让多台从机共线变得自然:每台从机有一个 7 位地址,主机喊地址,只有地址匹配的那台才会应答。
鸭子 I²C 总线有几个真实的取舍:
手查总线上挂了什么:sudo i2cdetect -y -r 3。
每台从机芯片内部都有一排寄存器,也就是编号的小格子,一个格子存一个数。通信帧的正文,本质上就是「几号格子、读还是写、写几」。
Dynamixel 舵机的格子分两类,这个分类直接影响鸭子行为:
return_delay_time=0、baud_rate=3(=1 Mbps)、pwm_slope=255、shutdown=52(过载/过热/电压故障的保护掩码)。不对就纠正。常用寄存器地图节选:
| 格子号 | 内容 | 读法 |
|---|---|---|
| 124–136 | 当前 PWM / 电流 / 速度 / 位置(12 字节) | 每个 tick,一个 sync_read |
| 144–146 | 输入电压 + 温度 | 每秒一次,独立慢事务 |
| EEPROM 区 | return_delay / 波特率 / shutdown 掩码 | 启动时断言 |
ToF 也靠寄存器认亲:选 bank 0,读 0x0000 两个 ID 字节,revision 0x02 是 VL53L5CX,0x0C 是 VL53L8CX。猜错了刷错固件,传感器可能变砖。
本层事实出处:波特率与 sync_read 见 duck-control/src/bus.rs:26-28,114-116,232-237;保留上一帧与陈旧检测见 duck-control/src/bus.rs:334-338,52-59;I²C 总线速率与取舍见 deploy/audio/i2c3-pihat.dts:16-42;ToF 地址探测见 tof/src/main.rs:82-87;舵机寄存器分类与启动断言见 duck-control/src/model.rs:89-94、duck-control/src/bus.rs:304-325、robotd-design.md §2.1;ToF 型号寄存器判断见 tof/vendor/probe.c:3-7,56-60。
舵机 = 电机 + 减速齿轮 + 位置传感器 + 一颗内置控制芯片,封装好卖给你。你给命令「转到 30°」,它自己闭环转到 30°。
鸭子用的是 ROBOTIS Dynamixel XL330 系列,具体子型号仓库没有记录。它的位置反馈精度是 4096 格每圈,换算成弧度是 2π × 格数 / 4096 − π。
15 台的布局:
几个关键点:
MOUTH_INDEX=9 被跳过;写回 15 个电机槽时,嘴的位置强制置零。嘴是输出设备,不是感觉设备。robotctl health 上显示的是最热那台舵机的名字和温度。Limp → Homing(开扭矩、2 秒斜坡)→ Ready。IMU 是惯性测量单元,测「我自己怎么歪、怎么转」。鸭子没有眼睛看自己,全靠它。
但这颗 IMU 的接法很不寻常:芯片是 LSM6DSV16X,焊在一块叫 imu_to_dxl v2 的小板上。这块板把自己伪装成一台 Dynamixel 设备,挂在舵机总线上,id 200。于是舵机和 IMU 在同一个 sync_read 事务里被读回——这是硬件送的礼物,两者状态永远共享同一个时间戳。
芯片内置 SFLP 模块,在芯片内部把陀螺仪和加速度计融合成一个四元数姿态,还会自己估陀螺零偏。板子上不做额外融合。
数据格式很「硬件」:陀螺仪是 i16 小端,±500 dps 满量程;四元数 xyz 是 IEEE 半精度浮点,16 位 float,w 用 √(1−x²−y²−z²) 补。板子装歪了,软件用一个固定安装四元数把数据转回躯干坐标系。
策略真正吃的不是原始四元数,而是 projected_gravity:重力单位向量投到躯干系。站直时约 [0, 0, −1];跌倒判定就是看它是否越过 −0.5。
没有磁力计,所以航向是积分出来的,纯相对、会漂移。文档里直接说明这一点,不假装有绝对方向。
ToF 是 Time of Flight,飞行时间测距。它打一发不可见光脉冲,测反弹回来用多久,乘光速除二就是距离。鸭子头顶的 ST VL53L5CX / VL53L8CX 把视野切成 8×8 = 64 个区,一次返回 64 个毫米级距离。
相机是 Pi Cam v2,传感器 IMX219,走 MIPI-CSI 接口。这是一条专为摄像头设计的高速并行通道,不经 USB。
/dev/video* 一个都不出现,dmesg 也没痕迹——像根本没插相机。装对了的 dmesg 标志能看到 imx219 2-0010: Model ID 0x0219…。sounds/ 是一个合成器 crate。它拿熔在 SoC 里的唯一序列号当种子,确定性派生出这只鸭子的「人格」——音域、鼻音、颤动、quackiness。7 种情绪 tag,48 kHz 原生渲染。所以每只鸭子的嗓音独一无二,且终身不变。鸭子的电源系统简单到过分:一块 NP-F550(2S 锂电)喂全部 15 台舵机,主板也经 HAT 从它取电。
而且没有电量计芯片,没有 ADC。整个系统感知电量的唯一方式,是让舵机自报供电电压。
为什么这样设计?
voltage_adapt(默认关)会按实测电压缩放动作幅度:低电时走路主动变蔫。这是软件层对这种物理现象的诚实处理。本层事实出处:舵机型号与布局见 duck-control/src/bus.rs:18,110-112、duck-control/src/model.rs:15-31;低通 α 见 robotd-params/src/lib.rs:655-656;温度按名报最热见 duck-control/src/bus.rs:327-332;启动 homing 见 robotd-design.md §1.4、§3.3、duck-control/src/bus.rs:209-229;IMU 见 duck-control/src/imu.rs 全文、duck-control/src/model.rs:76-78、robotd-design.md §4.4;ToF 见 tof/src/lib.rs:47-51,64-100、tof/src/sensor.rs:225-229、tof/src/main.rs:64-71,104-107、kinematics/src/tof.rs;相机见 docs/project/media-bringup.md、mediad/src/pipeline.rs:314-331、robotd-params/src/lib.rs:209-211;音频 codec 见 deploy/audio/aic3104-init.sh、scripts/setup-board.sh:531-645、sounds/src/lib.rs:1-44;电池与电量见 duck-control/src/model.rs:101-129、robotd/src/main.rs:94-96,1574-1597、robotd-params/src/lib.rs:688-691;机身尺寸与站高见 README.md:25、kinematics/assets/alpha/robot_walk.xml:6,8,64、robotd-design.md §9.1。
读完上面,架构图文档里的很多设计决定就有了物理理由。下面是映射表:
| 硬件事实 | 软件里看到的设计 | 出处 |
|---|---|---|
| ToF 上电要灌 ~90 KB 固件,且与 codec 共享 I²C | 独立 tofd;robotd 不等它 |
architecture.md §1 |
| 16 台共用一条半双工 UART | 一个 sync_read 事务全拿;失败保留上帧 |
bus.rs:334-338 |
| 出厂 return delay 250 → 读数占 40% tick 预算 | 启动断言 EEPROM return_delay=0 |
model.rs:89-94 |
| 增益是 RAM 寄存器,断电丢;出厂 D≠0 | 每次上电重写 kp=200、强制 I=D=0 | bus.rs:304-325 |
| 一块电池喂 15 台,无电量计 | 电压取平均 → 线性映射电量百分比 | model.rs:101-129 |
| 负载下电压下垂 | 用约 10 秒 EMA 才允许触发 6.6 V 关机 | main.rs:94-96 |
| IMU 板伪装成 Dynamixel 设备 id 200 | 与舵机同事务读取,共享时间戳 | model.rs:76-78 |
| 膝比嘴热得多 | 温度按名字报最热,不平均 | bus.rs:327-332 |
| Armbian 在 UART2 跑登录终端 | setup-board 屏蔽 getty,console 挪 HDMI | setup-board.sh:377-410 |
| overlay 文件名前缀陷阱 | setup-board 改 armbianEnv.txt;升级后「看不见电机」先查这里 |
deploy/README.md:193-200 |
| i2c3 引脚被 USB-C PD 芯片占用 | 重新配置 mux 到 M0 + 禁 FUSB302,牺牲 PD 留 5V | i2c3-pihat.dts:16-28 |
| 嘴不参与步态 | 61 维观测 / 14 维动作,嘴槽写回时置零 | model.rs:28-31 |
| 训练时低通 α(0.5/0.7)是开着的 | 部署必须同值,否则 sim2real 变差 | robotd-params/src/lib.rs:655-656 |
| SoC 满负荷 95°C 会降频 | 检测降到 2 Hz;媒体档位最低 360p | robotd-params/src/lib.rs:265-284 |
| SoC 烧制唯一序列号 | 嗓音人格按序列号播种,一鸭一嗓 | sounds/src/lib.rs:1-44 |
| 无磁力计 | 里程计纯相对、会漂移,文档直说 | robotd-design.md §4.4 |
| 电池/温度不是固件质量问题 | 永不进更新健康判定、不触发回滚 | robotd-design.md §1.5 |
最后重复一遍核心不变量(硬件版):
这块板子上,每条总线基本上只有一个进程抓着。robotd 抓着 UART,tofd 抓着 HAT 的 I²C,mediad 抓着相机和 codec。软件文档说「只有 robotd 能碰电机」,物理上说的是「只有 robotd 打开着
/dev/ttyS2,还上了 TIOCEXCL 独占锁」。
按架构图文档的规矩,没查到就明说,不编:
[thermal] 节明确列为未来项;shutdown=52 只是舵机自身硬件保护掩码)。