解读 01 · bus.rs:一条 1 Mbps 总线上的 15 个对话
解读对象:
duck-control/src/bus.rs(507 行)
需要的前置:《什么鸭公开课》第 10 课(舵机从 SG90 到总线)
15 个舵机加一块 IMU 板挂在同一条半双工串行总线上(BAUD_RATE = 1_000_000,见 model.rs:80),控制循环跑 50 Hz。每个 tick 只有一天 20 ms,这一篇看代码怎么把总线时间花在刀刃上。
1. 一个 tick 一次对话:IMU 排第一
read() 的核心是一句 sync_read_raw_data(&self.ids, READ_ADDR, READ_LEN)(bus.rs:248):用 Dynamixel 的 sync_read 一条指令问遍所有设备。READ_ADDR = 124、READ_LEN = 12(bus.rs:27-28),12 字节正好装下 present_pwm、present_current、present_velocity、present_position 四个寄存器。IMU 板的 ID(IMU_DXL_ID = 200,model.rs:78)被排在 ids 的第 0 位(bus.rs:114-116),所以它的应答先回来,blocks[0] 就是 IMU。
为什么合并成一次读?半双工总线上每笔事务都要付寻址和等待的固定开销,16 次单读的握手时间远大于 1 次批量读。这也是 io.rs 里 RobotIo trait 把"读"设计成一次拿全 Sensors 的原因。
2. 快慢分离:电压和温度单独一笔账
电池电压在寄存器 144、温度在 146,中间隔着 velocity_trajectory/position_trajectory 共 12 字节没人要的数据(bus.rs:37-40)。硬把它们并进 tick 的读块,等于每次 tick 让每个舵机多答 8 字节,耗的总线时间反而超过单独读。于是有了 SLOW_READ_ADDR = 144、SLOW_READ_LEN = 3(bus.rs:41-42):一笔 3 字节的 sync_read 同时拿回两个量,约每秒一次而非每 tick。slow_sensors()(bus.rs:351)里电压取 15 个舵机的平均——同一块电池;温度不平均,因为"一个发热的关节"才是要看的信号,均值会把它藏掉。
3. 开机体检:把"这台车为什么慢"消灭在启动时
check_registers()(bus.rs:132)逐舵机核对 EXPECTED_REGISTERS 里的 EEPROM 寄存器(return_delay_time、baud_rate、pwm_slope、shutdown),不符就写回并计数。理由写在注释里:被恢复出厂的舵机 return_delay_time = 250,仅此一项就吃掉全总线约 40% 的 tick 预算(bus.rs:129-131)。启动时每寄存器一次读,换掉一整类"这台机器人莫名慢"的排查。
同类心思还出现在 set_gain()(bus.rs:316):写 kP 时顺手把 ki、kd 固定为 0(const KI/KD: u16 = 0)。这些是 RAM 寄存器,每次上电回到出厂值,而出厂 D 增益非零——不归零,同样 kP 下机器人会比原型"明显偏软"(bus.rs:317-322)。
4. StaleImuTracker:给"沉默的谎言"计数
总线读成功不代表数据是新的:IMU 板可能一直回同一个采样(StaleImuTracker,bus.rs:67)。observe()(bus.rs:78)比较当前块与上一块,相同则累计,返回连续重复长度。连续 STALE_RUN_WARN = 25 次——50 Hz 下的半秒(bus.rs:59)——才告警一次,之后每 500 次才再提醒(bus.rs:270)。注释说得很直白:第一次重复就报,只会教会所有人忽略这条消息。另外 set_torque()(bus.rs:202)故意"写完全部再报错":一个丢应答曾让关机流程半路死掉,腿还锁着就断电了。
你带走的收获
- 半双工总线上,"一条批量指令问所有设备"省下的主要是每笔事务的握手开销,不是字节本身。
- 控制环里只读必需字节,慢变量(电压/温度)合并成低频的独立事务——数据离得远时,"捎带"反而更贵。
- EEPROM 寄存器在启动时校验并改正,比运行期靠症状排查便宜一个数量级。
- 要分清"读失败"和"读到旧值":后者只有靠显式计数器才能被看见。
- 告警必须设阈值并限流,否则高频重复日志会毁掉整个日志系统的信用。