Microduck 硬件入门:给软件人的鸭子硬件课

本文是 《Microduck 架构图》 的配套前传。

那篇讲软件:7 个守护进程怎么分工。本文讲硬件:这只鸭子里到底有什么零件,它们怎么和软件对话。

我默认你只会写代码,对电子、机械一窍不通。所以最底层的概念都会从头讲;所有用来举例的数字,都是这只鸭子的真实数字。

写法和架构图一致:鸭子相关的事实全部来自仓库,并在每章末尾标注出处。仓库没写到的规格会明确说「没查到」,不编。
生成日期:2026-09-03


第0层 电:先别管代码,想想一根电线里有什么

软件人习惯说「这个函数返回 3.5 米」。但电线里跑的从来不是「3.5」。电线里跑的是电压。理解鸭子,第一步就是把「软件看到的数据」和「电线里的物理事实」对上号。

0.1 电压、电流、功率

把电池想象成一个水塔,把设备想象成接在水塔上的水龙头。

鸭子肚子里是一块 NP-F550 电池,2 节锂电池串联——行话叫「2S」。它的工作电压范围只有 6.6 V 到 8.2 V。这个数是什么概念?比一节 9V 方块电池还低一点。但它胜在能输出很大的电流:15 台小电机一起使劲时,电流才是吃资源的大头。

这里有一个关键现象:电压会被电流拉下来。15 台电机一起发力,电池电压会瞬间往下掉;它们停下来,电压又慢慢弹回去。这叫做「电压下垂」或 sag。鸭子判断「还剩多少电」必须理解这个现象,否则它会在你让它快走两步的时候,以为自己没电了,当场坐下关机。后面第 3.6 节会讲软件怎么对付它。

0.2 高电平和低电平:计算机的 0 和 1 是两段电压

芯片的一根引脚上,电压不是精确某个数,而是分成两段约定好的区间:

中间那段电压是禁区,设计好的电路不会停在那里。所以一根线上传的「一串字节」,物理上就是时间上依次出现的高低电平。后面讲的 UART、I²C,本质上只是不同的约定:多高算 1,每拍多长,谁先说话。

0.3 数字信号和模拟信号

一句话:模拟是形状,数字是转述。转述会损失精度,但不会被电线噪声轻易歪曲。

0.4 半双工:对讲机式的总线

鸭子的舵机总线就是半双工的。15 台小电机加 1 块 IMU 板,一共 16 台设备,共用一根数据线。robotd 喊一句「所有人报位置」,然后排队听 16 台设备挨个回答。

舵机总线(半双工,一根线)robotd(主机,唯一发起请求的一方)舵机总线(半双工,一根线)robotd(主机,唯一发起请求的一方)同一时刻只能有一方说话每台出厂默认还要先沉默 500 微秒16 台排队 ≈ 8 毫秒整个控制环只有 20 毫秒,光读数就吃掉了 40%sync_read:所有人都报一下位置id 200(IMU 板)先答id 10-14(右腿 5 台)id 20-24(左腿 5 台)id 30-34(颈/头/嘴 5 台)sync_write:把新的目标位置写回 15 台

这张图里的「排队」不是软件选择,是硬件结构决定的。所以后面软件层的很多设计——比如为什么一个 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-88robotd-design.md §2.1。


第1层 芯片、板卡和引脚:软件打开一个文件,就是在用电

1.1 一颗芯片就是一套电脑

芯片是一块硅片上刻出来的电路,外面封着黑色塑料壳,边缘伸出金属引脚。引脚是它对外说话的全部渠道。

鸭子的大脑是一颗 SoC,全称 System on Chip,意思是「一颗芯片里塞了一整套电脑」。具体型号是 Rockchip RK3566,四核 Cortex-A55,arm64 架构。

SoC 里面不止有 CPU。对鸭子来说比较重要的几块:

一个热知识:满负荷跑物体检测时,SoC 会热到 95°C,然后自动把 CPU 降到 408 MHz 运行。这个硬件层面的「热到降速」会直接改写软件性能——所以鸭子把检测算法降到每秒只跑 2 次。

1.2 板卡:Radxa Zero 3W

芯片焊在板卡上才成为能用的电脑。鸭子用的是 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。

1.3 设备树:为什么软件能打开 /dev/ttyS2

板卡边缘那排 40 根金属针,是 SoC 引脚的引出。麻烦的是:同一根物理针脚内部可以被接到几种不同的电路上,这叫引脚复用(pinmux)。同一根针,今天可以是串口,明天可以是 GPIO。

谁来决定今天是什么?设备树(Device Tree)。这是一块随内核一起加载的数据表,逐根声明「这根针今天干什么」。内核照表把每条启用的电路注册成一个 /dev 下的文件。所以:

robotd 打开 /dev/ttyS2 这个文件,物理上就是在操作板子上某根特定的针脚;那根针脚通过板上的走线,最终连到了舵机总线。

这就是软件世界和硬件世界的接口。

udev 规则起别名

robotd 独占

/boot/armbianEnv.txt
overlay_prefix=rk3568

设备树 dtb + overlay
逐根声明:这些针当串口,那些针当 I²C

Linux 内核

/dev/ttyS2
串口 → 舵机总线

/dev/i2c-3
I²C → HAT 扩展板

/dev/video0..9
摄像头

/dev/i2c-pihat
稳定名字

robotd

tofd

mediad

鸭子真实的踩坑记录都和这条链有关:

排查谁占着串口:fuser -v /dev/ttyS2

1.4 HAT:扣在主板上的「帽子」

HAT 是 Hardware Attached on Top 的缩写,一块带 40 针插座的扩展板,直接扣在主机板上。鸭子的 HAT 是 Pollen Robotics RPI Robot HAT。

它上面住着:

1.5 整机硬件总拓扑

把上面这些零件连起来,就是下面这张图。建议你把它和《架构图》文档里的软件拓扑对照看:软件进程图里「谁连谁」,对应到这里就是「谁抓着哪根总线」。

HAT 扩展板(扣在 40 针上)

Radxa Zero 3W(SoC:RK3566)

经 HAT 分发,不走 USB-C

400 kHz 共享总线

音频数据流

🔋 NP-F550 电池包
2S 锂离子 · 6.6–8.2 V

RK3566
CPU · NPU(出厂禁用) · VPU · ISP

UART2 引脚

i2c3 · 引脚 3/5

MIPI-CSI

I²S

🎵 TLV320AIC3104
音频 codec · I²C 地址 0x18

Stemma J5 接口

Dynamixel 总线
半双工 UART · 1 Mbps · 协议 v2

🦾 15 × XL330 舵机
id 10–14 / 20–24 / 30–34

IMU 板 imu_to_dxl v2
LSM6DSV16X · id 200

👁 ToF 测距 VL53L5/8CX
8×8 深度矩阵 · I²C

📷 IMX219 相机
(Pi Cam v2)

🎤 麦克风(板载)

🔊 喇叭

本层事实出处:RK3566 见 scripts/board-test.sh:4;Radxa Zero 3W 见 scripts/setup-board.sh:20deploy/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-200scripts/setup-board.sh:221-240;串口 getty 占用见 scripts/setup-board.sh:377-410;HAT 描述见 deploy/audio/aic3104-i2c3.dts:5i2c3-pihat.dts:10-34


第2层 总线和协议:多设备怎么共用一根线

2.1 总线是什么

一根线接多台设备能省引脚、省布线,代价是必须立规矩:谁在什么时候说话、说的话给谁。这套规矩就是总线协议

鸭子身上有两条重要总线,脾气完全不同:

Dynamixel 总线(UART) I²C(i2c3,HAT 上)
线数 一根数据线(半双工) 两根:数据 SDA + 时钟 SCL
谁发起对话 只有主机 robotd 只有主机
怎么点名 帧里带设备 id(0–252) 帧里带 7 位地址
速率 1 Mbps 400 kHz
鸭子上的设备 15 台舵机 + 1 块 IMU 板 音频 codec + ToF

2.2 UART:没有时钟线,靠双方事先约好速度

UART 是最简单的串口:两根线(一根发、一根收),没有时钟线。双方必须事先约好:一拍的时长是多少、一帧怎么拼。这个约定叫波特率。鸭子的舵机总线跑在 1 Mbps。

约错了波特率,听到的就是乱码——像两个人各说各的,一个说快板、一个数拍子,节奏对不上。

Dynamixel 协议 v2 盖在 UART 上。一帧里包含:谁说话、给谁、指令是什么、正文、校验。robotd 最常用的读法叫 sync_read:一帧问出去,「id 10 到 34 还有 200,把寄存器 124 到 136 的内容都给我」,然后 16 台设备排队回答,一次事务拿回全部传感器数据。

舵机总线robotd舵机总线robotd只要有一台不答,整批读数作废这是半双工排队必然的代价软件策略:保留上一帧好数据控制环继续跑;陈旧计数 +1连续 25 次(0.5 秒)没新数据 → 写 journal 报警sync_read:124–136(12 字节 × 16 台)id 200、10–14、20–24、30–34 排队应答

注意这里的软件策略不是惩罚失败,而是容忍失败:半双工总线上偶尔丢一次应答很正常,控制环不能因此停。所以 robotd 永远保留上一份好数据;同时它还要持续检测 IMU 数据是不是「变陈旧」——也就是读成功了,但 16 台设备交回的字节和上次一模一样。这说明 IMU 板内部卡住了,连续 25 次一样就报警。这套机制完全是从「半双工、排队应答」这个硬件事实里长出来的。

2.3 I²C:带时钟线,按地址点名

I²C 比 UART 多一根时钟线 SCL。主机每敲一下时钟,数据线上才允许换一个位。这根时钟线消灭了「双方约波特率」的问题,还让多台从机共线变得自然:每台从机有一个 7 位地址,主机喊地址,只有地址匹配的那台才会应答。

0x18

0x29 或 0x52

0x19 / 0x68 休眠不参与

0x22 已禁用

i2c3 · 400 kHz
两根线(SDA/SCL) + HAT 上的 10k 上拉

🎵 音频 codec AIC3104
(它的上限就是 400 kHz)

👁 ToF VL53L5/8CX
(本来能跑 1 MHz,被总线拖到 400)

BMI088(原型遗留)

FUSB302 USB-C PD 芯片

鸭子 I²C 总线有几个真实的取舍:

手查总线上挂了什么:sudo i2cdetect -y -r 3

2.4 寄存器:设备内部的一排编号小格子

每台从机芯片内部都有一排寄存器,也就是编号的小格子,一个格子存一个数。通信帧的正文,本质上就是「几号格子、读还是写、写几」。

Dynamixel 舵机的格子分两类,这个分类直接影响鸭子行为:

robotd 每次启动:
断言 EEPROM + 重写 RAM

断电

出厂 EEPROM
return_delay = 250
D 增益 ≠ 0(会阻尼)

运行态 RAM
return_delay = 0
kp = 200 · I = D = 0

常用寄存器地图节选:

格子号 内容 读法
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-94duck-control/src/bus.rs:304-325robotd-design.md §2.1;ToF 型号寄存器判断见 tof/vendor/probe.c:3-7,56-60


第3层 鸭子身上的零件,一件一件讲

3.1 15 台舵机:肌肉

舵机 = 电机 + 减速齿轮 + 位置传感器 + 一颗内置控制芯片,封装好卖给你。你给命令「转到 30°」,它自己闭环转到 30°。

鸭子用的是 ROBOTIS Dynamixel XL330 系列,具体子型号仓库没有记录。它的位置反馈精度是 4096 格每圈,换算成弧度是 2π × 格数 / 4096 − π

15 台的布局:

Dynamixel 总线(id 是点名用的编号)

颈/头/嘴 · id 30–34

颈俯仰 · −1.571..+1.047

头俯仰 · ±π/2

头左右 · ±2.967(≈±170°)

头侧倾 · ±0.436(≈±25°)

嘴 · 闭 −5° / 开 +30°

左腿 · id 20–24

同右腿对称

右腿 · id 10–14

髋外摆/内收 · ±0.436/0.524 rad

髋侧摆 · ±0.384

髋前后 · ±π/2

膝 · ±π/2

踝 · ±π/2

IMU 板 · id 200
(独立编号段,不跟舵机冲突)

几个关键点:

3.2 IMU:本体感觉

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。

没有磁力计,所以航向是积分出来的,纯相对、会漂移。文档里直接说明这一点,不假装有绝对方向。

3.3 ToF:头顶的 64 点深度图

ToF 是 Time of Flight,飞行时间测距。它打一发不可见光脉冲,测反弹回来用多久,乘光速除二就是距离。鸭子头顶的 ST VL53L5CX / VL53L8CX 把视野切成 8×8 = 64 个区,一次返回 64 个毫米级距离。

3.4 相机和它的硬件流水线

相机是 Pi Cam v2,传感器 IMX219,走 MIPI-CSI 接口。这是一条专为摄像头设计的高速并行通道,不经 USB。

3.5 音频:codec、喇叭,和一鸭一嗓

3.6 电池和电量:一只鸭子怎么知道自己没电了

鸭子的电源系统简单到过分:一块 NP-F550(2S 锂电)喂全部 15 台舵机,主板也经 HAT 从它取电。

而且没有电量计芯片,没有 ADC。整个系统感知电量的唯一方式,是让舵机自报供电电压

EMA 跌破 6.6 V

🔋 NP-F550 · 2S 锂电
静置满电可 >8.2 V,带载会下垂

15 台舵机(同一包)

主板(经 HAT)

每台自报电压
0.1 V/格 · 答 0 的剔除

15 台取平均

约 10 秒 EMA
每秒刷新一次

电量 %:6.6 V → 0%
8.2 V → 100%

先坐下 → 切断扭矩 → poweroff

为什么这样设计?

3.7 机身和运动学

本层事实出处:舵机型号与布局见 duck-control/src/bus.rs:18,110-112duck-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-78robotd-design.md §4.4;ToF 见 tof/src/lib.rs:47-51,64-100tof/src/sensor.rs:225-229tof/src/main.rs:64-71,104-107kinematics/src/tof.rs;相机见 docs/project/media-bringup.mdmediad/src/pipeline.rs:314-331robotd-params/src/lib.rs:209-211;音频 codec 见 deploy/audio/aic3104-init.shscripts/setup-board.sh:531-645sounds/src/lib.rs:1-44;电池与电量见 duck-control/src/model.rs:101-129robotd/src/main.rs:94-96,1574-1597robotd-params/src/lib.rs:688-691;机身尺寸与站高见 README.md:25kinematics/assets/alpha/robot_walk.xml:6,8,64robotd-design.md §9.1。


第4层 从硬件事实到软件设计

读完上面,架构图文档里的很多设计决定就有了物理理由。下面是映射表:

硬件事实 软件里看到的设计 出处
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 独占锁」。


附录:仓库未记录的空白

按架构图文档的规矩,没查到就明说,不编: