From ec318914295f07b03a9d46d38e6eeecdeb66e19b Mon Sep 17 00:00:00 2001 From: zhou yang Date: Mon, 7 Sep 2026 07:35:39 +0800 Subject: [PATCH] =?UTF-8?q?A8=EF=BC=9A=E4=BB=BB=E5=8A=A1=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E4=B8=8E=E8=B0=83=E5=BA=A6=EF=BC=885=20=E4=B8=AA=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E3=80=81=E4=B8=A4=E6=8A=8A=E9=94=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求 §10.3 落地。四件事: 1. 五任务表(tasks.c):周期与优先级写在一张 const 表里,开机自检查的是 相对顺序(safety 唯一最高、log 唯一最低、thermal 与 actuator 同级), 外加「巡检周期必须长于 INA226 一帧转换 8.8 ms」。不过就 abort。 2. 节拍器(sched.c):记下一次该醒的绝对时刻,回绕安全(int32_t 差判先后)。 过载时跳过错过的整周期、保住相位,不补跑。 3. 两把锁(hal_lock):I²C 与 74HC595,禁止嵌套持有 —— 这块板上两者没有交集, 「拿着 A 去拿 B」永远是写错了。ESP 侧用 mutex 取优先级继承。 4. t_safety 的一个周期:拿锁 → 6 片 INA226 的 V+I + PCA9555 两端口 → 放锁。 不死等锁(5 ms 超时就跳过这圈),一片读失败不中断整圈、保留上一圈的值。 I²C 占用由 HAL 按线上真实字节数记账(两个目标同一份代码),静态预算与实测 必须相等:一次寄存器读 48 位,一圈 13 次事务 = 1560 µs / 20 ms = 7.8 %, 与 §10.3 手算的「约 1.5 ms / 约 8 %」相符(本节的 46 位没数 STOP 和重复 START)。 sim 侧配套两样:假 I²C 事务真的把虚拟时钟花掉(否则时序测试是空话), 以及可强制的加锁纪律检查(配一条反向测试,防止检查本身失灵)。 11 条 ctest(161 例)全绿,idf.py build 通过;16 个注错实验全部变红。 Co-Authored-By: Claude Opus 5 --- firmware/HANDOFF.md | 147 +- firmware/components/drv/include/ina226.h | 211 ++- .../components/phonerail_hal/CMakeLists.txt | 26 +- .../phonerail_hal/esp/hal_i2c_esp.c | 234 +-- .../phonerail_hal/esp/hal_lock_esp.c | 65 + .../components/phonerail_hal/hal_i2c_acct.c | 66 + .../phonerail_hal/include/hal_i2c_acct.h | 58 + .../phonerail_hal/include/hal_lock.h | 61 + .../phonerail_hal/sim/hal_i2c_sim.c | 367 ++-- .../phonerail_hal/sim/hal_lock_sim.c | 107 ++ .../components/phonerail_hal/sim/sim_hal.c | 44 +- .../components/phonerail_hal/sim/sim_hal.h | 342 ++-- .../components/phonerail_hal/sim/sim_sr595.c | 203 +- firmware/host/CMakeLists.txt | 155 +- firmware/main/CMakeLists.txt | 23 +- firmware/main/app_main.c | 224 ++- firmware/main/phonerail.h | 46 +- firmware/main/sched.c | 53 + firmware/main/sched.h | 49 + firmware/main/t_safety.c | 118 ++ firmware/main/t_safety.h | 72 + firmware/main/tasks.c | 77 + firmware/main/tasks.h | 58 + firmware/test/test_sched.c | 272 +++ firmware/test/test_t_safety.c | 320 ++++ 需求文档.md | 1675 +++++++++-------- 26 files changed, 3340 insertions(+), 1733 deletions(-) create mode 100644 firmware/components/phonerail_hal/esp/hal_lock_esp.c create mode 100644 firmware/components/phonerail_hal/hal_i2c_acct.c create mode 100644 firmware/components/phonerail_hal/include/hal_i2c_acct.h create mode 100644 firmware/components/phonerail_hal/include/hal_lock.h create mode 100644 firmware/components/phonerail_hal/sim/hal_lock_sim.c create mode 100644 firmware/main/sched.c create mode 100644 firmware/main/sched.h create mode 100644 firmware/main/t_safety.c create mode 100644 firmware/main/t_safety.h create mode 100644 firmware/main/tasks.c create mode 100644 firmware/main/tasks.h create mode 100644 firmware/test/test_sched.c create mode 100644 firmware/test/test_t_safety.c diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 1add3d3..d2a8276 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,93 +1,92 @@ # 交接 -更新:2026-09-07 完成任务包 **A7**(通道配置表 + 表驱动遍历 + 启动自检) +更新:2026-09-07 完成任务包 **A8**(任务骨架与调度:5 个任务、两把锁) ## 一句话现状 -五路的六组编号(INA 地址 / DAC 片通道 / 三个按键位 / EDL 位 / 两根 EN 的 GPIO) -拼成了**一张 const 表** `rail_cfg(1..5)`,开机自检 `rail_selftest()` 把它和四个驱动 -那四张表**逐格对账**,再查一遍全板引脚有没有重复 / 越界,不过就 `abort()`。 -A3~A6 留下的四个自检谓词至此全部有了调用点。9 条 ctest(131 个用例)全绿, -`idf.py build` 通过。 +§10.3 的五个任务成了一张 const 表(周期 + 优先级,开机自检不过就 abort), +周期任务走**绝对时刻**的节拍器(不漂、回绕安全、过载跳过不补跑), +两把互斥锁(I²C / 595)**禁止嵌套持有**,`t_safety` 的一圈已经能跑: +拿锁 → 6 片 INA226 的 V+I + PCA9555 两个输入端口 → 放锁 → 记账。 +I²C 占用由 HAL 按线上真实字节数算出来:**1560 µs / 20 ms = 7.8 %**, +与 §10.3 手算的「约 1.5 ms / 约 8 %」相符。11 条 ctest(161 个用例)全绿,`idf.py build` 通过。 ## 下一步 -**A8**:任务骨架与调度(5 个任务、两把锁,需求 §10.3 全节)。 -验收是「sim 时钟下 `t_safety` 周期 20 ms ±1 ms;I²C 占用打印出来与 §10.3 的 8 % 对得上」。 -A7 给它备好的东西:`rail_cfg()` 表驱动遍历、`board_pins.h` 的 SDA/SCL 引脚、 -`sim_gpio_write_seq()`(断言顺序用,A9 的 17 ms 上电时序会更需要它)。 +**A9**:三个执行器状态机 + 互斥(需求 §10.6 全节)。 +验收是「硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。 +A8 给它备好的:`HAL_LOCK_SR595`(595 那把锁,sim 里能强制检查)、 +虚拟时钟下的 `sched_*`、`sim_gpio_write_seq()`(A7 留的顺序断言)。 +⚠️ A9 的状态机同样要写成「一个函数一步」,不要写进 `while(1)` —— 死循环在宿主机上跑不到。 ## 本次新增/修改的文件 -- `main/rail_cfg.h` / `main/rail_cfg.c` — 那张表、启动自检、两根 EN 线(≈250 行) -- `main/board_pins.h` — **全板引脚表**(§8.1 里「全板独一份」的那些脚:I²C、风扇、 - NTC、USB、UP_VBUS_DET,加上 strapping / 不存在 / R8 版 PSRAM 三张禁用清单)。 - 五路自己的 `EFUSE_ENn` / `VBUS_ENn` **不在这里**,它们是「每路一个」,在五路表里 -- `main/app_main.c` — 开机跑 `rail_selftest()`,不过就 abort;然后 `rail_en_init()` -- `test/test_rail_cfg.c`(28 个用例) -- `components/phonerail_hal/sim/sim_hal.h` + `sim/hal_gpio_sim.c` — 新增 - `sim_gpio_write_seq()`(全局写序号,断言「先断 VBUS 再关 eFuse」这类顺序用); - **`SIM_GPIO_PINS` 48 → 49** -- `components/phonerail_hal/esp/hal_gpio_esp.c` — **`PIN_MAX` 48 → 49** -- 两处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a7`) -- `../需求文档.md` §10.4 — 记 A7 落地 + 五条实现约定 + 那张六列表 +- `main/tasks.h` / `tasks.c` — 五任务表(名字/周期/优先级/栈)+ `task_table_ok()` +- `main/sched.h` / `sched.c` — 绝对时刻节拍器(≈60 行),过载计数与最迟到时长 +- `main/t_safety.h` / `t_safety.c` — 巡检的**一个周期** + 静态预算 + 一行人话报告 +- `components/phonerail_hal/include/hal_lock.h` + `esp/hal_lock_esp.c` + `sim/hal_lock_sim.c` + — 两把锁(HAL 的第五样东西,理由写在 hal_lock.h 开头) +- `components/phonerail_hal/include/hal_i2c_acct.h` + `hal_i2c_acct.c` + — I²C 占用核算,**两个目标编同一份**,所以宿主机跑出来的数就是上板会打印的数 +- `sim/hal_i2c_sim.c` — 每次事务记账 + **把虚拟时钟真的花掉** + 查加锁纪律 +- `sim/sim_sr595.c` / `sim/sim_hal.c` / `sim/sim_hal.h` — 595 那四根线也查加锁纪律; + 新增 `sim_lock_*()`(扮演第二个任务、强制加锁纪律、违规计数)与 `sim_i2c_set_bus_time()` +- `esp/hal_i2c_esp.c` — 四个事务入口各加一行记账 +- `components/drv/include/ina226.h` — 新增 `INA226_CONV_US`(8.8 ms,给任务表自检对账) +- `main/app_main.c` — 建五个任务;`selftest_or_die()` 多查一项任务表 +- `test/test_sched.c`(17 个用例)/ `test/test_t_safety.c`(13 个用例) +- 三处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a8`) +- `../需求文档.md` §10.3 — 记 A8 落地 + 五条实现约定 ## 验收命令与结果 ``` $ bash host/test.sh -100% tests passed, 0 tests failed out of 9 # +test_rail_cfg(28),总计 131 个用例 +100% tests passed, 0 tests failed out of 11 # +test_sched(17) +test_t_safety(13),共 161 例 + +$ ./host/build/test_t_safety.exe +t_safety: cycles=1 skipped=0 i2c_err=0 bus=1560 us/20 ms load=7.8%(§10.3 手算:约 1.5 ms / 约 8 %) $ .\build_idf.ps1 # PowerShell -phonerail.bin binary size 0x34250 bytes. ... 0xcbdb0 bytes (80%) free. -Project build complete. # rail_cfg.c.obj 在(已单独确认) +phonerail.bin binary size 0x3cc40 bytes. ... 0xc33c0 bytes (76%) free. +Project build complete. # 16 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库): -# INA 地址改回 0x40+ch / CH3 的 DAC 挪回 U6 / CH4↔CH5 按键位对调 -# EDL 位改回 16+ch / PG 位跟着 FLT 加偏移 -# EFUSE_EN2 撞上 UP_VBUS_DET / EFUSE_EN1 落到 strapping / CH1 两根 EN 写反 -# EN 极性写反 / all_off 先关 eFuse / all_off 第一个错就 return -# en_init 不写那句明文的 0 / 表与驱动对账被删 / 引脚查重被删 -# GPIO 上限退回 48 -# → 以上 15 条全部变红。 -# -# ⚠️ 唯一没能变红的一条:**把 `rail_selftest()` 整个改成 `return 0;`**。 -# 判据本身(`rail_selftest_table()`)已经被测得很死,但「rail_selftest 有没有 -# 真的去调它」这一行接线,在一切正常的前提下测试**原理上分不出来** -# —— 真表是 const 的,没有任何输入能让它该报错。这是已知残留,不是漏做。 +# 节拍:next 从「现在」起算(漂移)/ 用裸比较判先后(回绕)/ 过载后补跑 +# 任务表:t_log 抬到最高 / 巡检周期缩到 5 ms / thermal 与 actuator 不同级 +# 巡检:拿锁改成死等 / 一片读失败就中断整圈 / 读失败把值清零 / 忘了放锁 / +# 地址改成 0x40+ch / 多读一次 PCA9555 / 占用游标不对齐到「现在」 +# 记账:漏掉 STOP 位 +# sim:两把锁允许嵌套 / 假总线上的事务不花时间 +# → 16 条全部变红。 ``` ## 踩到的坑(只写会再犯的) -- ⚠️ **HAL 两侧的 GPIO 边界原来都是 `pin < 48`,而 `EFUSE_EN2` 在 IO48 上。** - ESP32-S3 的编号是 **0~48(含 48)**,写成「个数 48」正好把它关在门外, - 现象是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()` 直接 HAL_ERR_ARG)。 - 已改成 49,`test_io48_is_a_usable_pin` 是回归拦网。 - 顺带记住:IO22~IO25 在 S3 上**根本不存在**、IO26~IO32 是模组内部 flash/PSRAM, - 写进配置表 `gpio_config()` 既不报错也不干活 —— 自检把这些号也当越界。 -- ⚠️ **「不是顺排」要看整列,逐格撞算式会撞空。** 第一版测试是「拿几个常见错算式 - 逐路比,断言错了几路」。写完逐格核对才发现:CH4 的按键位正好就是 9/10/11、 - CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好落在猜的算式上 —— 三处「错了 5 路」 - 实际都只有 4 路。**猜错算式的测试是句空话**,而且这种空话看上去和真断言一样。 - 改成断言整列**不是等差数列**(`is_arithmetic()`),任何 `起点+步长×ch` 一次拦掉。 -- **注错实验里把源文件 mtime 推到未来**(`os.utime(path, now+5)`):注错与恢复 - 挤在同一秒里,make 有可能认为 `.o` 还是新的、跳过重编,于是跑的是**上一轮**的 - 二进制 —— 那样「注了错还是绿」是假绿,看起来却像「这条测试没覆盖到」。 - ⚠️ 本轮**没有观察到它真的发生**(三条绿是真绿,原因见上面那段), - 这条是预防性的;但假绿这种错一旦发生就会被当成结论写进文档,代价不对称。 - (A6 那两条纪律仍然有效:try/finally 恢复原文;编译期红的一条要重写成能编过的。) -- **判据要能脱离真表被调用**。真表是 const 的,测试改不动,所以「查得出问题吗」 - 这件事本来是测不了的 —— 把判据拎成 `rail_selftest_table(const rail_cfg_t *t)` - 之后,测试复制一份表出来逐样改坏即可。这条比多写十个断言管用。 +- ⚠️ **sim 里「干活不花时间」会让时序测试变成一句空话。** 巡检在 sim 中耗时为零时, + 「周期 20 ms」测的只是「delay 参数传对了没」,任何实现都能踩准。 + 所以假 I²C 现在**真的把虚拟时钟花掉**(按位周期折算), + 而且测试第一句就断言「这一圈确实花掉了 `t_safety_budget_us()`」—— + 这条断言本身就是防着有人把 `sim_i2c_set_bus_time` 关掉。 +- ⚠️ **注错实验里 `old in src` 会命中你没想到的那一行。** 这次 + `" s_bus_time = true;"` 先匹配到了声明行(`static bool s_bus_time = true;` + 里正好含这段),改的是初值、而它每次 reset 又被覆盖回来 —— 于是那条注错「绿了」, + 看上去像「测试没覆盖到」。**注错的锚点要连着上一行一起写**, + 跑完还要确认它真的改到了那一处。 +- **周期任务的相位要用绝对时刻,而且比较必须走 `int32_t` 差。** + 裸写 `now >= next` 在 49.7 天回绕那一刻会把「还早」判成「迟到了 49 天」, + 然后一口气空转两百万个周期。农场是长稳跑的,这一定会发生。 +- **加锁纪律要能被强制检查,否则「都加了锁」是靠人读代码保证的。** + sim 侧 `sim_lock_require()` 打开后,动 I²C / 595 却没持锁就记一次违规; + 配套必须有一条**反向测试**(不拿锁直接读一片,违规必须 +1), + 不然检查失灵时那些「违规为 0」的断言会永远绿。 +- **两把锁不定加锁顺序,直接禁止嵌套。** 这块板上 595 是纯 GPIO、I²C 是纯总线, + 没有任何一处需要同时持有;把「不可能」编码进 `hal_lock_take()`, + 比定一套顺序然后指望后面每个人都记得可靠。 -## 这一层的几条约定(写 A8/A9/A10 之前看一眼) -- **`rail_cfg()` 是唯一入口。** 任何地方都不许再出现 `0x40 + rail`、`3 * (rail-1)` - 这类由通道号算出来的编号。六列里没有一列是顺排的,而写错了**板子照样正常工作**, - 只是「重启 CH1」重启了 CH4。 -- ⚠️ **两列 GPIO 没有第二张表能对账**(INA/DAC/595/PCA 那四列都有驱动那边兜着)。 - 所以自检**查不出**「`EFUSE_EN1` 和 `VBUS_EN1` 写反」—— 两个都是合法空闲脚、 - 不重复、不越界。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的十个值 - (出处 `.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 -- **`rail_all_off()` 是复位 / 收尾路径,不是故障快路径。** A10 过压要断的是出事 - 那一路,直接调 `rail_efuse_enable(rail, false)`,别等着遍历五路。 -- **开机顺序**:`rail_selftest()` → `rail_en_init()`(十个脚配成输出并拉低)→ - 才轮到 `rail_volt_init()` 等器件。DAC 一上电输出就有电压,EN 这时必须已经确定是低的。 -- **高有效只出现一次**(`en_write()`)。和 `sr595_set_hub_reset()` 的反相、 - `rail_mv_to_code()` 的减法同一条规矩。 -- **自检不过就 abort,不降级。** 它查的全是编译进固件的常量表,不过 = 固件写错了, - §10.10 的降级矩阵管的是硬件坏了。带着错表跑起来的样子是「命令发给了另一路」, - 五台手机全在跑、一个错误码都不报。 +## 这一层的几条约定(写 A9/A10 之前看一眼) +- **任务体里不许有判据和时序。** 写进 `while(1)` 的东西宿主机一行也跑不到 —— + `app_main.c` 里的五个任务体各自只有三行(拿节拍 / 调一个函数 / 睡到下一拍), + 真正的活在 `main/` 的其它 .c 里。A9 的状态机照这个形状写。 +- **A10 的判据写在 `t_safety_step()` 里,而且要在放锁之后。** + §10.3 原话:越界直接操作 `EFUSE_EN` / `VBUS_EN`,**不经任何队列或抽象层**。 + 发消息给 t_actuator 是错的;拿着总线锁去写引脚也是错的(那两根线和 I²C 无关)。 +- **判据先看 `ok` 再看值。** 读失败时 `snap.rail[]` 里留的是上一圈的值。 +- **动了巡检里读的东西,就要同步改 `t_safety_budget_us()` 的那一行** + (测试会拦:静态预算与实测必须相等)。 +- **器件初始化还没做**(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值), + 那是 A15。在那之前上板跑起来电流恒读 0,是预期的,不是故障。 diff --git a/firmware/components/drv/include/ina226.h b/firmware/components/drv/include/ina226.h index e1bc069..4b9f3b3 100644 --- a/firmware/components/drv/include/ina226.h +++ b/firmware/components/drv/include/ina226.h @@ -1,103 +1,108 @@ -/* INA226 —— 板上 6 片(CH1~CH5 输出各一片 + 输入侧一片),分流电阻全部 5 mΩ / 1%。 - 地址表见设计文档 §8.2,**不是顺排的**,详见下面 ina226_rail_addr() 的警告。 - - 本驱动只通过 hal_i2c_* 访问总线,宿主机上用 sim 的假器件即可全部测完。 */ -#ifndef INA226_H -#define INA226_H - -#include -#include - -#include "hal_err.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* ---- 寄存器(全部 16 位、大端) ---------------------------------------- */ - -#define INA226_REG_CONFIG 0x00 -#define INA226_REG_SHUNT_V 0x01 -#define INA226_REG_BUS_V 0x02 -#define INA226_REG_POWER 0x03 -#define INA226_REG_CURRENT 0x04 -#define INA226_REG_CALIB 0x05 -#define INA226_REG_MASK_EN 0x06 -#define INA226_REG_ALERT_L 0x07 -#define INA226_REG_MFG_ID 0xFE -#define INA226_REG_DIE_ID 0xFF - -#define INA226_MFG_ID 0x5449 /* 'TI' */ -#define INA226_DIE_ID 0x2260 - -/* ---- 量程与标定(五路与输入侧同一颗分流电阻,所以是常量不是参数) ------ */ - -#define INA226_SHUNT_UOHM 5000 /* 5 mΩ,设计文档 §8.2 */ -#define INA226_CURRENT_LSB_UA 500 /* 0.5 mA —— 分流 LSB 2.5 µV ÷ 5 mΩ */ - -/* CAL = 0.00512 / (Current_LSB × R_shunt) = 0.00512 / (500 µA × 5 mΩ) = 2048 */ -#define INA226_CALIB 2048 - -/* Config:保留位 100 + AVG=4 + VBUSCT=1.1 ms + VSHCT=1.1 ms + 连续测量分流与母线。 - 转换周期 4 × (1.1 + 1.1) = 8.8 ms < t_safety 的 20 ms,所以每次巡检都拿到新的一帧。 */ -#define INA226_CONFIG 0x4327 - -/* ---- 地址表(设计文档 §8.2) ------------------------------------------- */ - -#define INA226_RAIL_COUNT 5 -#define INA226_COUNT 6 -#define INA226_ADDR_INPUT 0x43 /* U13,输入侧 V+I,整板功率预算用 */ - -/* CH1~CH5 → U8~U12 的地址。rail 是 1~5,越界直接 abort。 - ⚠️ **`0x40 + ch` 是错的**,CH3/CH4/CH5 三路全错(0x44 / 0x45 / 0x42)。 - 地址按布线难度重排过(§8.2 那张 A1/A0 表),永远查表,不要算。 */ -uint8_t ina226_rail_addr(int rail); - -extern const uint8_t ina226_all_addr[INA226_COUNT]; /* CH1..CH5 + 输入侧,扫描用 */ - -/* 六个地址互不重复、且不与 DAC63204(0x4A/0x4B) / PCA9555(0x20) / LM75(0x48) 撞车。 - 启动自检调一次;返回 false 说明表被改坏了。 */ -bool ina226_addr_table_ok(void); - -/* ---- 采样 --------------------------------------------------------------- */ - -typedef struct { - int32_t bus_mv; /* 母线电压,1.25 mV/LSB */ - int32_t current_ua; /* **有符号** —— 反灌时为负(SW-8 靠这个判据) */ -} ina226_vi_t; - -typedef struct { - int32_t bus_mv; - int32_t shunt_uv; /* 2.5 µV/LSB,四舍五入到 µV(±1.25 µV ≈ ±0.25 mA) */ - int32_t current_ua; - int32_t power_mw; /* 12.5 mW/LSB,向零截断 */ -} ina226_sample_t; - -/* 读 Manufacturer ID / Die ID 认件。 - 器件不应答 → HAL_ERR_NAK;**应答了但 ID 不对 → HAL_ERR_BUS** - (这个地址上有东西,但不是 INA226:多半地址跳线焊错或贴错件)。 */ -hal_err_t ina226_probe(uint8_t addr); - -/* 写 Config 与 Calibration 并**逐个回读校验**。回读不一致 → HAL_ERR_BUS。 */ -hal_err_t ina226_init(uint8_t addr); - -/* 只校验 Calibration 还在。掉了的现象是 **Current/Power 恒读 0**(不是报错), - 看上去就像「这台手机不耗电」,所以巡检里要定期查一次。 */ -hal_err_t ina226_check(uint8_t addr); - -/* 巡检主路径:母线电压 + 电流,两次事务。 */ -hal_err_t ina226_read_vi(uint8_t addr, ina226_vi_t *out); - -/* 诊断/事件记录用:四个量一次读齐,四次事务。 */ -hal_err_t ina226_read_all(uint8_t addr, ina226_sample_t *out); - -hal_err_t ina226_read_bus_mv(uint8_t addr, int32_t *mv); -hal_err_t ina226_read_shunt_uv(uint8_t addr, int32_t *uv); -hal_err_t ina226_read_current_ua(uint8_t addr, int32_t *ua); -hal_err_t ina226_read_power_mw(uint8_t addr, int32_t *mw); - -#ifdef __cplusplus -} -#endif - -#endif /* INA226_H */ +/* INA226 —— 板上 6 片(CH1~CH5 输出各一片 + 输入侧一片),分流电阻全部 5 mΩ / 1%。 + 地址表见设计文档 §8.2,**不是顺排的**,详见下面 ina226_rail_addr() 的警告。 + + 本驱动只通过 hal_i2c_* 访问总线,宿主机上用 sim 的假器件即可全部测完。 */ +#ifndef INA226_H +#define INA226_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 寄存器(全部 16 位、大端) ---------------------------------------- */ + +#define INA226_REG_CONFIG 0x00 +#define INA226_REG_SHUNT_V 0x01 +#define INA226_REG_BUS_V 0x02 +#define INA226_REG_POWER 0x03 +#define INA226_REG_CURRENT 0x04 +#define INA226_REG_CALIB 0x05 +#define INA226_REG_MASK_EN 0x06 +#define INA226_REG_ALERT_L 0x07 +#define INA226_REG_MFG_ID 0xFE +#define INA226_REG_DIE_ID 0xFF + +#define INA226_MFG_ID 0x5449 /* 'TI' */ +#define INA226_DIE_ID 0x2260 + +/* ---- 量程与标定(五路与输入侧同一颗分流电阻,所以是常量不是参数) ------ */ + +#define INA226_SHUNT_UOHM 5000 /* 5 mΩ,设计文档 §8.2 */ +#define INA226_CURRENT_LSB_UA 500 /* 0.5 mA —— 分流 LSB 2.5 µV ÷ 5 mΩ */ + +/* CAL = 0.00512 / (Current_LSB × R_shunt) = 0.00512 / (500 µA × 5 mΩ) = 2048 */ +#define INA226_CALIB 2048 + +/* Config:保留位 100 + AVG=4 + VBUSCT=1.1 ms + VSHCT=1.1 ms + 连续测量分流与母线。 + 转换周期 4 × (1.1 + 1.1) = 8.8 ms < t_safety 的 20 ms,所以每次巡检都拿到新的一帧。 */ +#define INA226_CONFIG 0x4327 + +/* 上面那个 Config 折算出来的**一帧转换时间**:AVG=4 × (1.1 ms + 1.1 ms) = 8.8 ms。 + `t_safety` 的周期必须长于它,否则同一帧会被读两次 —— 现象是「电流特别稳」, + 不是任何一种错误。tasks.c 的表自检拿它和 20 ms 对一次。 */ +#define INA226_CONV_US 8800 + +/* ---- 地址表(设计文档 §8.2) ------------------------------------------- */ + +#define INA226_RAIL_COUNT 5 +#define INA226_COUNT 6 +#define INA226_ADDR_INPUT 0x43 /* U13,输入侧 V+I,整板功率预算用 */ + +/* CH1~CH5 → U8~U12 的地址。rail 是 1~5,越界直接 abort。 + ⚠️ **`0x40 + ch` 是错的**,CH3/CH4/CH5 三路全错(0x44 / 0x45 / 0x42)。 + 地址按布线难度重排过(§8.2 那张 A1/A0 表),永远查表,不要算。 */ +uint8_t ina226_rail_addr(int rail); + +extern const uint8_t ina226_all_addr[INA226_COUNT]; /* CH1..CH5 + 输入侧,扫描用 */ + +/* 六个地址互不重复、且不与 DAC63204(0x4A/0x4B) / PCA9555(0x20) / LM75(0x48) 撞车。 + 启动自检调一次;返回 false 说明表被改坏了。 */ +bool ina226_addr_table_ok(void); + +/* ---- 采样 --------------------------------------------------------------- */ + +typedef struct { + int32_t bus_mv; /* 母线电压,1.25 mV/LSB */ + int32_t current_ua; /* **有符号** —— 反灌时为负(SW-8 靠这个判据) */ +} ina226_vi_t; + +typedef struct { + int32_t bus_mv; + int32_t shunt_uv; /* 2.5 µV/LSB,四舍五入到 µV(±1.25 µV ≈ ±0.25 mA) */ + int32_t current_ua; + int32_t power_mw; /* 12.5 mW/LSB,向零截断 */ +} ina226_sample_t; + +/* 读 Manufacturer ID / Die ID 认件。 + 器件不应答 → HAL_ERR_NAK;**应答了但 ID 不对 → HAL_ERR_BUS** + (这个地址上有东西,但不是 INA226:多半地址跳线焊错或贴错件)。 */ +hal_err_t ina226_probe(uint8_t addr); + +/* 写 Config 与 Calibration 并**逐个回读校验**。回读不一致 → HAL_ERR_BUS。 */ +hal_err_t ina226_init(uint8_t addr); + +/* 只校验 Calibration 还在。掉了的现象是 **Current/Power 恒读 0**(不是报错), + 看上去就像「这台手机不耗电」,所以巡检里要定期查一次。 */ +hal_err_t ina226_check(uint8_t addr); + +/* 巡检主路径:母线电压 + 电流,两次事务。 */ +hal_err_t ina226_read_vi(uint8_t addr, ina226_vi_t *out); + +/* 诊断/事件记录用:四个量一次读齐,四次事务。 */ +hal_err_t ina226_read_all(uint8_t addr, ina226_sample_t *out); + +hal_err_t ina226_read_bus_mv(uint8_t addr, int32_t *mv); +hal_err_t ina226_read_shunt_uv(uint8_t addr, int32_t *uv); +hal_err_t ina226_read_current_ua(uint8_t addr, int32_t *ua); +hal_err_t ina226_read_power_mw(uint8_t addr, int32_t *mw); + +#ifdef __cplusplus +} +#endif + +#endif /* INA226_H */ diff --git a/firmware/components/phonerail_hal/CMakeLists.txt b/firmware/components/phonerail_hal/CMakeLists.txt index bc5d92c..1aa499e 100644 --- a/firmware/components/phonerail_hal/CMakeLists.txt +++ b/firmware/components/phonerail_hal/CMakeLists.txt @@ -1,12 +1,14 @@ -# PhoneRail 的硬件抽象层(ESP-IDF 侧)。宿主机侧编的是 sim/,由 host/CMakeLists.txt 收。 -# -# ⚠️ 组件名不能叫 `hal` —— ESP-IDF 自己有一个 components/hal(driver 依赖它), -# 重名会把 IDF 的那个盖掉。所以目录叫 phonerail_hal。 -idf_component_register( - SRCS "hal_err.c" - "esp/hal_i2c_esp.c" - "esp/hal_gpio_esp.c" - "esp/hal_pwm_esp.c" - "esp/hal_time_esp.c" - INCLUDE_DIRS "include" - REQUIRES esp_driver_i2c esp_driver_gpio esp_driver_ledc esp_timer freertos) +# PhoneRail 的硬件抽象层(ESP-IDF 侧)。宿主机侧编的是 sim/,由 host/CMakeLists.txt 收。 +# +# ⚠️ 组件名不能叫 `hal` —— ESP-IDF 自己有一个 components/hal(driver 依赖它), +# 重名会把 IDF 的那个盖掉。所以目录叫 phonerail_hal。 +idf_component_register( + SRCS "hal_err.c" + "hal_i2c_acct.c" + "esp/hal_lock_esp.c" + "esp/hal_i2c_esp.c" + "esp/hal_gpio_esp.c" + "esp/hal_pwm_esp.c" + "esp/hal_time_esp.c" + INCLUDE_DIRS "include" + REQUIRES esp_driver_i2c esp_driver_gpio esp_driver_ledc esp_timer freertos) diff --git a/firmware/components/phonerail_hal/esp/hal_i2c_esp.c b/firmware/components/phonerail_hal/esp/hal_i2c_esp.c index c76878f..1ff214c 100644 --- a/firmware/components/phonerail_hal/esp/hal_i2c_esp.c +++ b/firmware/components/phonerail_hal/esp/hal_i2c_esp.c @@ -1,114 +1,120 @@ -/* I²C 的 ESP-IDF v5.4 实现(新版 i2c_master 驱动,不是已废弃的 driver/i2c.h)。 */ -#include - -#include "driver/i2c_master.h" -#include "hal_i2c.h" - -#define I2C_XFER_TIMEOUT_MS 50 -#define I2C_DEFAULT_FREQ_HZ 400000 - -static i2c_master_bus_handle_t s_bus; -static i2c_master_dev_handle_t s_devs[128]; /* 按 7 位地址索引,用到才建 */ -static uint32_t s_freq_hz; - -static hal_err_t map_err(esp_err_t e) -{ - switch (e) { - case ESP_OK: return HAL_OK; - case ESP_ERR_INVALID_ARG: return HAL_ERR_ARG; - case ESP_ERR_INVALID_STATE: return HAL_ERR_STATE; - case ESP_ERR_TIMEOUT: return HAL_ERR_TIMEOUT; - case ESP_ERR_NOT_FOUND: return HAL_ERR_NAK; - case ESP_FAIL: return HAL_ERR_NAK; /* 从机没 ACK 走这条 */ - default: return HAL_ERR_BUS; - } -} - -hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg) -{ - if (cfg == NULL || cfg->sda_gpio < 0 || cfg->scl_gpio < 0) { - return HAL_ERR_ARG; - } - if (s_bus != NULL) { - return HAL_OK; /* 幂等 */ - } - i2c_master_bus_config_t bc = { - .i2c_port = -1, /* 自动选口 */ - .sda_io_num = (gpio_num_t)cfg->sda_gpio, - .scl_io_num = (gpio_num_t)cfg->scl_gpio, - .clk_source = I2C_CLK_SRC_DEFAULT, - .glitch_ignore_cnt = 7, - /* 板上已有 4.7 kΩ 外部上拉(设计文档 §8.2),内部上拉 45 kΩ 太弱, - 开着只会掩盖「上拉电阻漏焊」这类问题。 */ - .flags.enable_internal_pullup = false, - }; - esp_err_t e = i2c_new_master_bus(&bc, &s_bus); - if (e != ESP_OK) { - return map_err(e); - } - s_freq_hz = cfg->freq_hz != 0 ? cfg->freq_hz : I2C_DEFAULT_FREQ_HZ; - return HAL_OK; -} - -static hal_err_t dev_of(uint8_t addr, i2c_master_dev_handle_t *out) -{ - if (s_bus == NULL) { - return HAL_ERR_STATE; - } - if (addr > 0x7f) { - return HAL_ERR_ARG; - } - if (s_devs[addr] == NULL) { - i2c_device_config_t dc = { - .dev_addr_length = I2C_ADDR_BIT_LEN_7, - .device_address = addr, - .scl_speed_hz = s_freq_hz, - }; - esp_err_t e = i2c_master_bus_add_device(s_bus, &dc, &s_devs[addr]); - if (e != ESP_OK) { - return map_err(e); - } - } - *out = s_devs[addr]; - return HAL_OK; -} - -hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len) -{ - i2c_master_dev_handle_t dev; - hal_err_t err = dev_of(addr, &dev); - if (err != HAL_OK) { - return err; - } - return map_err(i2c_master_transmit(dev, data, len, I2C_XFER_TIMEOUT_MS)); -} - -hal_err_t hal_i2c_read(uint8_t addr, uint8_t *data, size_t len) -{ - i2c_master_dev_handle_t dev; - hal_err_t err = dev_of(addr, &dev); - if (err != HAL_OK) { - return err; - } - return map_err(i2c_master_receive(dev, data, len, I2C_XFER_TIMEOUT_MS)); -} - -hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len, - uint8_t *rd, size_t rd_len) -{ - i2c_master_dev_handle_t dev; - hal_err_t err = dev_of(addr, &dev); - if (err != HAL_OK) { - return err; - } - return map_err(i2c_master_transmit_receive(dev, wr, wr_len, rd, rd_len, - I2C_XFER_TIMEOUT_MS)); -} - -hal_err_t hal_i2c_probe(uint8_t addr) -{ - if (s_bus == NULL) { - return HAL_ERR_STATE; - } - return map_err(i2c_master_probe(s_bus, addr, I2C_XFER_TIMEOUT_MS)); -} +/* I²C 的 ESP-IDF v5.4 实现(新版 i2c_master 驱动,不是已废弃的 driver/i2c.h)。 */ +#include + +#include "driver/i2c_master.h" +#include "hal_i2c.h" +#include "hal_i2c_acct.h" + +#define I2C_XFER_TIMEOUT_MS 50 +#define I2C_DEFAULT_FREQ_HZ 400000 + +static i2c_master_bus_handle_t s_bus; +static i2c_master_dev_handle_t s_devs[128]; /* 按 7 位地址索引,用到才建 */ +static uint32_t s_freq_hz; + +static hal_err_t map_err(esp_err_t e) +{ + switch (e) { + case ESP_OK: return HAL_OK; + case ESP_ERR_INVALID_ARG: return HAL_ERR_ARG; + case ESP_ERR_INVALID_STATE: return HAL_ERR_STATE; + case ESP_ERR_TIMEOUT: return HAL_ERR_TIMEOUT; + case ESP_ERR_NOT_FOUND: return HAL_ERR_NAK; + case ESP_FAIL: return HAL_ERR_NAK; /* 从机没 ACK 走这条 */ + default: return HAL_ERR_BUS; + } +} + +hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg) +{ + if (cfg == NULL || cfg->sda_gpio < 0 || cfg->scl_gpio < 0) { + return HAL_ERR_ARG; + } + if (s_bus != NULL) { + return HAL_OK; /* 幂等 */ + } + i2c_master_bus_config_t bc = { + .i2c_port = -1, /* 自动选口 */ + .sda_io_num = (gpio_num_t)cfg->sda_gpio, + .scl_io_num = (gpio_num_t)cfg->scl_gpio, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + /* 板上已有 4.7 kΩ 外部上拉(设计文档 §8.2),内部上拉 45 kΩ 太弱, + 开着只会掩盖「上拉电阻漏焊」这类问题。 */ + .flags.enable_internal_pullup = false, + }; + esp_err_t e = i2c_new_master_bus(&bc, &s_bus); + if (e != ESP_OK) { + return map_err(e); + } + s_freq_hz = cfg->freq_hz != 0 ? cfg->freq_hz : I2C_DEFAULT_FREQ_HZ; + hal_i2c_acct_set_freq(s_freq_hz); /* 占用核算按真实速率折算,见 hal_i2c_acct.h */ + return HAL_OK; +} + +static hal_err_t dev_of(uint8_t addr, i2c_master_dev_handle_t *out) +{ + if (s_bus == NULL) { + return HAL_ERR_STATE; + } + if (addr > 0x7f) { + return HAL_ERR_ARG; + } + if (s_devs[addr] == NULL) { + i2c_device_config_t dc = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = addr, + .scl_speed_hz = s_freq_hz, + }; + esp_err_t e = i2c_master_bus_add_device(s_bus, &dc, &s_devs[addr]); + if (e != ESP_OK) { + return map_err(e); + } + } + *out = s_devs[addr]; + return HAL_OK; +} + +hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len) +{ + i2c_master_dev_handle_t dev; + hal_err_t err = dev_of(addr, &dev); + if (err != HAL_OK) { + return err; + } + hal_i2c_acct_add(len, 0); + return map_err(i2c_master_transmit(dev, data, len, I2C_XFER_TIMEOUT_MS)); +} + +hal_err_t hal_i2c_read(uint8_t addr, uint8_t *data, size_t len) +{ + i2c_master_dev_handle_t dev; + hal_err_t err = dev_of(addr, &dev); + if (err != HAL_OK) { + return err; + } + hal_i2c_acct_add(0, len); + return map_err(i2c_master_receive(dev, data, len, I2C_XFER_TIMEOUT_MS)); +} + +hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len, + uint8_t *rd, size_t rd_len) +{ + i2c_master_dev_handle_t dev; + hal_err_t err = dev_of(addr, &dev); + if (err != HAL_OK) { + return err; + } + hal_i2c_acct_add(wr_len, rd_len); + return map_err(i2c_master_transmit_receive(dev, wr, wr_len, rd, rd_len, + I2C_XFER_TIMEOUT_MS)); +} + +hal_err_t hal_i2c_probe(uint8_t addr) +{ + if (s_bus == NULL) { + return HAL_ERR_STATE; + } + hal_i2c_acct_add(0, 0); + return map_err(i2c_master_probe(s_bus, addr, I2C_XFER_TIMEOUT_MS)); +} diff --git a/firmware/components/phonerail_hal/esp/hal_lock_esp.c b/firmware/components/phonerail_hal/esp/hal_lock_esp.c new file mode 100644 index 0000000..b539e49 --- /dev/null +++ b/firmware/components/phonerail_hal/esp/hal_lock_esp.c @@ -0,0 +1,65 @@ +/* 两把互斥锁的 FreeRTOS 实现。静态分配(`xSemaphoreCreateMutexStatic`)—— + 两把锁的数量是编译期定死的,没有理由去堆上要内存,也就没有「创建失败」这条路径。 + + ⚠️ 用的是 mutex 不是 binary semaphore:mutex 有**优先级继承**。 + t_safety 是最高优先级且每 20 ms 就要总线,低优先级的 t_link 拿着 I²C 锁去读遥测时 + 如果没有继承,t_safety 会被一个中优先级任务无限期挡在门外(经典优先级反转)。 */ +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "hal_lock.h" + +static StaticSemaphore_t s_buf[HAL_LOCK_COUNT]; +static SemaphoreHandle_t s_mtx[HAL_LOCK_COUNT]; +static uint32_t s_timeouts[HAL_LOCK_COUNT]; + +hal_err_t hal_lock_init(void) +{ + for (int i = 0; i < HAL_LOCK_COUNT; i++) { + if (s_mtx[i] == NULL) { + s_mtx[i] = xSemaphoreCreateMutexStatic(&s_buf[i]); + s_timeouts[i] = 0; + } + } + return HAL_OK; +} + +bool hal_lock_held(hal_lock_id_t id) +{ + if (id < 0 || id >= HAL_LOCK_COUNT || s_mtx[id] == NULL) { + return false; + } + return xSemaphoreGetMutexHolder(s_mtx[id]) == xTaskGetCurrentTaskHandle(); +} + +hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms) +{ + if (id < 0 || id >= HAL_LOCK_COUNT || s_mtx[id] == NULL) { + return HAL_ERR_STATE; + } + /* 已经持有 = 重复加锁;持有另一把 = 嵌套。两者都是写错了,见 hal_lock.h。 */ + for (int i = 0; i < HAL_LOCK_COUNT; i++) { + if (hal_lock_held((hal_lock_id_t)i)) { + return HAL_ERR_STATE; + } + } + const TickType_t ticks = + (timeout_ms == HAL_LOCK_FOREVER) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); + if (xSemaphoreTake(s_mtx[id], ticks) != pdTRUE) { + s_timeouts[id]++; + return HAL_ERR_TIMEOUT; + } + return HAL_OK; +} + +void hal_lock_give(hal_lock_id_t id) +{ + configASSERT(id >= 0 && id < HAL_LOCK_COUNT && s_mtx[id] != NULL); + configASSERT(hal_lock_held(id)); /* 不是自己拿的就别放 */ + (void)xSemaphoreGive(s_mtx[id]); +} + +uint32_t hal_lock_timeouts(hal_lock_id_t id) +{ + return (id >= 0 && id < HAL_LOCK_COUNT) ? s_timeouts[id] : 0; +} diff --git a/firmware/components/phonerail_hal/hal_i2c_acct.c b/firmware/components/phonerail_hal/hal_i2c_acct.c new file mode 100644 index 0000000..cbaf0f6 --- /dev/null +++ b/firmware/components/phonerail_hal/hal_i2c_acct.c @@ -0,0 +1,66 @@ +/* I²C 占用核算的实现。**两个目标编的是同一份**(和 hal_err.c 一样放在组件根下), + 所以宿主机上算出来的占用率就是上板会打印的那个数。 */ +#include "hal_i2c_acct.h" + +static uint64_t s_bits; +static uint32_t s_txns; +static uint32_t s_freq_hz = HAL_I2C_FREQ_HZ_DEFAULT; + +uint32_t hal_i2c_txn_bits(size_t wr_len, size_t rd_len) +{ + if (wr_len == 0 && rd_len == 0) { + return 1u + 9u + 1u; /* 探测:START + 地址字节 + STOP */ + } + + uint32_t bits = 1u; /* START */ + if (wr_len > 0) { + bits += 9u * (uint32_t)(1u + wr_len); /* 地址 + 载荷 */ + } + if (rd_len > 0) { + if (wr_len > 0) { + bits += 1u; /* 重复 START */ + } + bits += 9u * (uint32_t)(1u + rd_len); + } + bits += 1u; /* STOP */ + return bits; +} + +void hal_i2c_acct_add(size_t wr_len, size_t rd_len) +{ + s_bits += hal_i2c_txn_bits(wr_len, rd_len); + s_txns++; +} + +uint64_t hal_i2c_acct_bits(void) { return s_bits; } +uint32_t hal_i2c_acct_txns(void) { return s_txns; } + +void hal_i2c_acct_set_freq(uint32_t hz) +{ + s_freq_hz = (hz == 0) ? HAL_I2C_FREQ_HZ_DEFAULT : hz; +} + +uint32_t hal_i2c_acct_freq(void) { return s_freq_hz; } + +uint64_t hal_i2c_bits_to_us(uint64_t bits) +{ + /* 先乘后除,别先算「每位多少 µs」—— 400 kHz 下那是 2.5 µs,整数除法会抹平。 */ + return (bits * 1000000ull) / (uint64_t)s_freq_hz; +} + +uint32_t hal_i2c_acct_window_us(uint64_t *cursor_bits) +{ + const uint64_t now = s_bits; + const uint64_t was = (cursor_bits != NULL) ? *cursor_bits : 0; + if (cursor_bits != NULL) { + *cursor_bits = now; + } + return (uint32_t)hal_i2c_bits_to_us(now - was); +} + +void hal_i2c_acct_reset(void) +{ + s_bits = 0; + s_txns = 0; + s_freq_hz = HAL_I2C_FREQ_HZ_DEFAULT; +} diff --git a/firmware/components/phonerail_hal/include/hal_i2c_acct.h b/firmware/components/phonerail_hal/include/hal_i2c_acct.h new file mode 100644 index 0000000..1aa8c80 --- /dev/null +++ b/firmware/components/phonerail_hal/include/hal_i2c_acct.h @@ -0,0 +1,58 @@ +/* I²C 总线占用核算 —— 需求 §10.3 那 8 % 的可执行版本。 + + §10.3 手算了一遍「t_safety 每 20 ms 要占总线多久」,结论是约 1.5 ms、约 8 %, + 并在末尾要求「首板上电时用逻辑分析仪实测复核一次」。这里做的是中间那一档: + 让固件**自己按线上真实发生的事务**把这个数算出来,两个目标(sim 与 ESP) + 用的是同一份计数代码,所以宿主机上跑出来的数就是上板会打印出来的数。 + + 为什么放在 HAL 里而不是应用层:只有 HAL 知道每次事务真正过了多少字节。 + 放在应用层就变成「按计划中的事务数手算」,那是把 §10.3 的算式抄第二遍 + —— 少读一次寄存器、多一次重试,手算的那份都不会变。 + + ⚠️ 这是**理想时序**的下限:不含时钟延展、不含 NAK 重试、不含事务之间 + 固件自己的开销。它能证明「预算没被写崩」,不能替代 §10.3 要求的实测。 */ +#ifndef HAL_I2C_ACCT_H +#define HAL_I2C_ACCT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define HAL_I2C_FREQ_HZ_DEFAULT 400000u + +/* 一次事务在总线上占的**位周期数**。 + 写: START + (1+wr_len) 个字节 × 9 位 + STOP + 写后读: START + (1+wr_len) 字节 + 重复 START + (1+rd_len) 字节 + STOP + 每字节 9 位 = 8 数据 + 1 应答;START / 重复 START / STOP 各按 1 个位周期计。 + rd_len == 0 就是纯写,wr_len == 0 就是纯读(探测是 wr_len = rd_len = 0)。 + + 对一次 INA226 寄存器读(wr 1 字节、rd 2 字节)它给出 48 位 = 120 µs @400 kHz。 + §10.3 手算的是「≈46 个位周期 ≈115 µs」—— 差的两位是 STOP 和一个 START, + §10.3 没数进去。整周期的结论不变(1.56 ms / 7.8 %,见 §10.3 的「约 1.5 ms / 约 8 %」)。 */ +uint32_t hal_i2c_txn_bits(size_t wr_len, size_t rd_len); + +/* 累计位周期数。两个 HAL 实现都在事务成功**与失败**之后调它 —— + NAK 掉的事务也占了总线时间(地址字节已经发出去了)。 */ +void hal_i2c_acct_add(size_t wr_len, size_t rd_len); +uint64_t hal_i2c_acct_bits(void); +uint32_t hal_i2c_acct_txns(void); + +/* 位周期 → µs,按当前总线频率。hal_i2c_init() 会把频率填进来(0 视为默认 400 kHz)。 */ +void hal_i2c_acct_set_freq(uint32_t hz); +uint32_t hal_i2c_acct_freq(void); +uint64_t hal_i2c_bits_to_us(uint64_t bits); + +/* 取「从上一次取样到现在」占了多少 µs,并把取样点推到现在。 + t_safety 每个周期调一次,拿到的就是这一周期的总线占用。 */ +uint32_t hal_i2c_acct_window_us(uint64_t *cursor_bits); + +void hal_i2c_acct_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif /* HAL_I2C_ACCT_H */ diff --git a/firmware/components/phonerail_hal/include/hal_lock.h b/firmware/components/phonerail_hal/include/hal_lock.h new file mode 100644 index 0000000..035147b --- /dev/null +++ b/firmware/components/phonerail_hal/include/hal_lock.h @@ -0,0 +1,61 @@ +/* 互斥锁 —— HAL 的第五样东西,而且**只有两把**(需求 §10.3)。 + + 计划 §4.1 写着「HAL 只抽象四样:I²C / GPIO / PWM-定时 / 单调时钟,不要抽象得更多」。 + 这里多出来的第五样是有理由的:§10.3 的五个任务共享的资源正好是两个 + —— I²C 总线(10 个器件在同一条上)和 74HC595 的 24 位链 —— 而「谁在用总线」 + 这件事没法用前四样表达。 + + 为了不让它长成一个通用的锁库,这里**没有创建接口**:两把锁是枚举出来的, + 编译期就这么多,也不许再加第三把。要加第三把之前先回头看一眼 §10.3 + ——「共享资源只有两个」是架构结论,不是当时懒得数。 + + ⚠️ **两把锁不许嵌套持有。** 595 那条链是纯 GPIO(SER/SRCLK/RCLK), + 和 I²C 没有任何交集,所以「拿着 A 去拿 B」这件事在这块板子上永远是写错了, + 而不是一个需要小心处理的情况。`hal_lock_take()` 直接把它判成 HAL_ERR_STATE + —— 与其定一套加锁顺序然后指望后面每个人都记得,不如让它根本不可能发生。 */ +#ifndef HAL_LOCK_H +#define HAL_LOCK_H + +#include +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + HAL_LOCK_I2C = 0, /* I²C 总线:6×INA226 + 2×DAC63204 + PCA9555(+ 载板 LM75) */ + HAL_LOCK_SR595, /* 74HC595 的 24 位链,全板共享(§10.6 的影子寄存器) */ + HAL_LOCK_COUNT, +} hal_lock_id_t; + +/* 幂等。任何任务起来之前调一次;没 init 就 take 会返回 HAL_ERR_STATE + (FreeRTOS 上那是个空句柄,不拦就是随机崩)。 */ +hal_err_t hal_lock_init(void); + +/* 拿锁。timeout_ms = HAL_LOCK_FOREVER 表示一直等。 + HAL_ERR_TIMEOUT —— 等超时了,**调用方必须自己处理**:巡检任务应当跳过这一周期 + 并计一次数,不要死等(死等的现象是 t_safety 整个停摆)。 + HAL_ERR_STATE —— 没 init,或者违反了「不许嵌套持有两把锁」。 + 同一个任务重复拿同一把锁也是 HAL_ERR_STATE:这两把都不是递归锁。 */ +#define HAL_LOCK_FOREVER 0xFFFFFFFFu +hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms); + +/* 放锁。不是自己拿的、或者根本没人拿,都会 abort —— + 那是代码写错了,悄悄放掉会让下一个持有者莫名其妙地丢掉互斥。 */ +void hal_lock_give(hal_lock_id_t id); + +/* **当前任务**是不是正持着这把锁。给断言和 sim 的强制检查用, + 不要拿它做流程控制(「没拿就拿一下」正是双重加锁的写法)。 */ +bool hal_lock_held(hal_lock_id_t id); + +/* 累计等锁失败次数(超时)。遥测里报出去,用来看总线是不是排队了。 */ +uint32_t hal_lock_timeouts(hal_lock_id_t id); + +#ifdef __cplusplus +} +#endif + +#endif /* HAL_LOCK_H */ diff --git a/firmware/components/phonerail_hal/sim/hal_i2c_sim.c b/firmware/components/phonerail_hal/sim/hal_i2c_sim.c index ff4a33a..c9dfdbe 100644 --- a/firmware/components/phonerail_hal/sim/hal_i2c_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_i2c_sim.c @@ -1,174 +1,193 @@ -/* I²C 的假实现:一张寄存器表 + 可注入的 NAK。宿主机专用。 */ -#include -#include - -#include "sim_hal.h" - -static sim_i2c_dev_t s_dev[SIM_I2C_MAX_DEV]; -static int s_n; -static bool s_inited; - -void sim_i2c_reset_internal(void); /* 只给 sim_hal_reset() 用 */ - -void sim_i2c_reset_internal(void) -{ - memset(s_dev, 0, sizeof(s_dev)); - s_n = 0; - s_inited = false; -} - -sim_i2c_dev_t *sim_i2c_dev(uint8_t addr) -{ - for (int i = 0; i < s_n; i++) { - if (s_dev[i].addr == addr) { - return &s_dev[i]; - } - } - return NULL; -} - -sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width) -{ - assert(addr <= 0x7f); - assert(reg_width == 1 || reg_width == 2); - assert(sim_i2c_dev(addr) == NULL); /* 地址撞车:测试写错了 */ - assert(s_n < SIM_I2C_MAX_DEV); - - sim_i2c_dev_t *d = &s_dev[s_n++]; - memset(d, 0, sizeof(*d)); - d->addr = addr; - d->reg_width = reg_width; - d->present = true; - return d; -} - -static sim_i2c_dev_t *must(uint8_t addr) -{ - sim_i2c_dev_t *d = sim_i2c_dev(addr); - assert(d != NULL); /* 先 sim_i2c_add() 再预置寄存器 */ - return d; -} - -void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val) { must(addr)->regs[reg] = val; } -uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg) { return must(addr)->regs[reg]; } -void sim_i2c_inject_nak(uint8_t addr, int count) { must(addr)->nak_left = count; } -void sim_i2c_set_present(uint8_t addr, bool present) { must(addr)->present = present; } - -/* ---- HAL 实现 ---------------------------------------------------------- */ - -hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg) -{ - (void)cfg; /* sim 不关心引脚与速率 */ - s_inited = true; - return HAL_OK; -} - -/* 取器件并把「这次事务要不要 NAK」判掉。 */ -static hal_err_t take(uint8_t addr, sim_i2c_dev_t **out) -{ - if (!s_inited) { - return HAL_ERR_STATE; - } - if (addr > 0x7f) { - return HAL_ERR_ARG; - } - sim_i2c_dev_t *d = sim_i2c_dev(addr); - if (d == NULL || !d->present) { - return HAL_ERR_NAK; - } - if (d->nak_left != 0) { - if (d->nak_left > 0) { - d->nak_left--; - } - return HAL_ERR_NAK; - } - *out = d; - return HAL_OK; -} - -/* data[0] 是寄存器指针,其后按 reg_width 一格一格写,指针自增(uint8_t 自然回绕)。 */ -static hal_err_t do_write(sim_i2c_dev_t *d, const uint8_t *data, size_t len) -{ - if (len != 0 && data == NULL) { - return HAL_ERR_ARG; - } - if (len > 1 && ((len - 1) % d->reg_width) != 0) { - return HAL_ERR_ARG; /* 半个寄存器:真器件上是未定义行为,这里直接报错 */ - } - d->n_write++; - d->last_write_len = (uint8_t)(len < SIM_I2C_LOG_BYTES ? len : SIM_I2C_LOG_BYTES); - if (d->last_write_len != 0) { - memcpy(d->last_write, data, d->last_write_len); - } - if (len == 0) { - return HAL_OK; /* 只发地址 = 探测 */ - } - d->ptr = data[0]; - for (size_t i = 1; i < len; i += d->reg_width) { - uint16_t v = data[i]; - if (d->reg_width == 2) { - v = (uint16_t)((v << 8) | data[i + 1]); - } - d->regs[d->ptr++] = v; - } - if (d->hook != NULL) { - d->hook(d, SIM_I2C_EV_POST_WRITE); - } - return HAL_OK; -} - -static hal_err_t do_read(sim_i2c_dev_t *d, uint8_t *data, size_t len) -{ - if (len != 0 && data == NULL) { - return HAL_ERR_ARG; - } - if ((len % d->reg_width) != 0) { - return HAL_ERR_ARG; - } - d->n_read++; - if (d->hook != NULL) { - d->hook(d, SIM_I2C_EV_PRE_READ); /* 按 datasheet 建模的器件在这里合成输入寄存器 */ - } - for (size_t i = 0; i < len; i += d->reg_width) { - uint16_t v = d->regs[d->ptr++]; - if (d->reg_width == 2) { - data[i] = (uint8_t)(v >> 8); - data[i + 1] = (uint8_t)(v & 0xff); - } else { - data[i] = (uint8_t)v; - } - } - return HAL_OK; -} - -hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len) -{ - sim_i2c_dev_t *d = NULL; - hal_err_t err = take(addr, &d); - return err != HAL_OK ? err : do_write(d, data, len); -} - -hal_err_t hal_i2c_read(uint8_t addr, uint8_t *data, size_t len) -{ - sim_i2c_dev_t *d = NULL; - hal_err_t err = take(addr, &d); - return err != HAL_OK ? err : do_read(d, data, len); -} - -hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len, - uint8_t *rd, size_t rd_len) -{ - sim_i2c_dev_t *d = NULL; - hal_err_t err = take(addr, &d); - if (err != HAL_OK) { - return err; - } - err = do_write(d, wr, wr_len); - return err != HAL_OK ? err : do_read(d, rd, rd_len); -} - -hal_err_t hal_i2c_probe(uint8_t addr) -{ - sim_i2c_dev_t *d = NULL; - return take(addr, &d); -} +/* I²C 的假实现:一张寄存器表 + 可注入的 NAK。宿主机专用。 */ +#include +#include + +#include "sim_hal.h" + +static sim_i2c_dev_t s_dev[SIM_I2C_MAX_DEV]; +static int s_n; +static bool s_inited; +static bool s_bus_time = true; + +void sim_i2c_reset_internal(void); /* 只给 sim_hal_reset() 用 */ + +void sim_i2c_reset_internal(void) +{ + memset(s_dev, 0, sizeof(s_dev)); + s_n = 0; + s_inited = false; + s_bus_time = true; +} + +sim_i2c_dev_t *sim_i2c_dev(uint8_t addr) +{ + for (int i = 0; i < s_n; i++) { + if (s_dev[i].addr == addr) { + return &s_dev[i]; + } + } + return NULL; +} + +sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width) +{ + assert(addr <= 0x7f); + assert(reg_width == 1 || reg_width == 2); + assert(sim_i2c_dev(addr) == NULL); /* 地址撞车:测试写错了 */ + assert(s_n < SIM_I2C_MAX_DEV); + + sim_i2c_dev_t *d = &s_dev[s_n++]; + memset(d, 0, sizeof(*d)); + d->addr = addr; + d->reg_width = reg_width; + d->present = true; + return d; +} + +static sim_i2c_dev_t *must(uint8_t addr) +{ + sim_i2c_dev_t *d = sim_i2c_dev(addr); + assert(d != NULL); /* 先 sim_i2c_add() 再预置寄存器 */ + return d; +} + +void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val) { must(addr)->regs[reg] = val; } +uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg) { return must(addr)->regs[reg]; } +void sim_i2c_inject_nak(uint8_t addr, int count) { must(addr)->nak_left = count; } +void sim_i2c_set_present(uint8_t addr, bool present) { must(addr)->present = present; } +void sim_i2c_set_bus_time(bool on) { s_bus_time = on; } + +/* 每次事务(成功或 NAK)都记账并把虚拟时钟花掉。NAK 也占总线:地址字节已经发出去了。 + 同时查一次加锁纪律 —— I²C 是 §10.3 两个共享资源之一。 */ +static void spend(size_t wr_len, size_t rd_len) +{ + sim_lock_check(HAL_LOCK_I2C); + hal_i2c_acct_add(wr_len, rd_len); + if (s_bus_time) { + sim_time_advance_us(hal_i2c_bits_to_us(hal_i2c_txn_bits(wr_len, rd_len))); + } +} + +/* ---- HAL 实现 ---------------------------------------------------------- */ + +hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg) +{ + (void)cfg; /* sim 不关心引脚 —— 但速率要告诉占用核算,它按频率折算 µs */ + hal_i2c_acct_set_freq(cfg != NULL ? cfg->freq_hz : 0); + s_inited = true; + return HAL_OK; +} + +/* 取器件并把「这次事务要不要 NAK」判掉。 */ +static hal_err_t take(uint8_t addr, sim_i2c_dev_t **out) +{ + if (!s_inited) { + return HAL_ERR_STATE; + } + if (addr > 0x7f) { + return HAL_ERR_ARG; + } + sim_i2c_dev_t *d = sim_i2c_dev(addr); + if (d == NULL || !d->present) { + return HAL_ERR_NAK; + } + if (d->nak_left != 0) { + if (d->nak_left > 0) { + d->nak_left--; + } + return HAL_ERR_NAK; + } + *out = d; + return HAL_OK; +} + +/* data[0] 是寄存器指针,其后按 reg_width 一格一格写,指针自增(uint8_t 自然回绕)。 */ +static hal_err_t do_write(sim_i2c_dev_t *d, const uint8_t *data, size_t len) +{ + if (len != 0 && data == NULL) { + return HAL_ERR_ARG; + } + if (len > 1 && ((len - 1) % d->reg_width) != 0) { + return HAL_ERR_ARG; /* 半个寄存器:真器件上是未定义行为,这里直接报错 */ + } + d->n_write++; + d->last_write_len = (uint8_t)(len < SIM_I2C_LOG_BYTES ? len : SIM_I2C_LOG_BYTES); + if (d->last_write_len != 0) { + memcpy(d->last_write, data, d->last_write_len); + } + if (len == 0) { + return HAL_OK; /* 只发地址 = 探测 */ + } + d->ptr = data[0]; + for (size_t i = 1; i < len; i += d->reg_width) { + uint16_t v = data[i]; + if (d->reg_width == 2) { + v = (uint16_t)((v << 8) | data[i + 1]); + } + d->regs[d->ptr++] = v; + } + if (d->hook != NULL) { + d->hook(d, SIM_I2C_EV_POST_WRITE); + } + return HAL_OK; +} + +static hal_err_t do_read(sim_i2c_dev_t *d, uint8_t *data, size_t len) +{ + if (len != 0 && data == NULL) { + return HAL_ERR_ARG; + } + if ((len % d->reg_width) != 0) { + return HAL_ERR_ARG; + } + d->n_read++; + if (d->hook != NULL) { + d->hook(d, SIM_I2C_EV_PRE_READ); /* 按 datasheet 建模的器件在这里合成输入寄存器 */ + } + for (size_t i = 0; i < len; i += d->reg_width) { + uint16_t v = d->regs[d->ptr++]; + if (d->reg_width == 2) { + data[i] = (uint8_t)(v >> 8); + data[i + 1] = (uint8_t)(v & 0xff); + } else { + data[i] = (uint8_t)v; + } + } + return HAL_OK; +} + +hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len) +{ + sim_i2c_dev_t *d = NULL; + spend(len, 0); + hal_err_t err = take(addr, &d); + return err != HAL_OK ? err : do_write(d, data, len); +} + +hal_err_t hal_i2c_read(uint8_t addr, uint8_t *data, size_t len) +{ + sim_i2c_dev_t *d = NULL; + spend(0, len); + hal_err_t err = take(addr, &d); + return err != HAL_OK ? err : do_read(d, data, len); +} + +hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len, + uint8_t *rd, size_t rd_len) +{ + sim_i2c_dev_t *d = NULL; + spend(wr_len, rd_len); + hal_err_t err = take(addr, &d); + if (err != HAL_OK) { + return err; + } + err = do_write(d, wr, wr_len); + return err != HAL_OK ? err : do_read(d, rd, rd_len); +} + +hal_err_t hal_i2c_probe(uint8_t addr) +{ + sim_i2c_dev_t *d = NULL; + spend(0, 0); + return take(addr, &d); +} diff --git a/firmware/components/phonerail_hal/sim/hal_lock_sim.c b/firmware/components/phonerail_hal/sim/hal_lock_sim.c new file mode 100644 index 0000000..4fefdff --- /dev/null +++ b/firmware/components/phonerail_hal/sim/hal_lock_sim.c @@ -0,0 +1,107 @@ +/* 两把互斥锁的假实现。宿主机测试是**单线程**的,所以这里模拟的不是并发本身, + 而是「加锁纪律」—— 谁拿了、放没放、有没有嵌套、该拿的时候拿了没有。 + + ⚠️ 单线程 sim 里「等一把别人持有的锁」永远等不到,所以: + · 有限超时 → 把虚拟时钟往前拨满 timeout_ms,计一次超时,返回 HAL_ERR_TIMEOUT + (这正是真板上等不到锁时该发生的事,调用方的处理路径因此测得到); + · HAL_LOCK_FOREVER 且被别人持有 → 直接 abort。那在真板上是死锁, + 在这里悄悄返回一个错误码只会把它藏起来。 + + 「谁」是一个假任务号(`sim_lock_set_task()`),默认 0。测试用它扮演第二个任务。 */ +#include +#include + +#include "hal_lock.h" +#include "sim_hal.h" + +#define NO_OWNER (-1) + +static int s_owner[HAL_LOCK_COUNT]; +static uint32_t s_takes[HAL_LOCK_COUNT]; +static uint32_t s_timeouts[HAL_LOCK_COUNT]; +static uint32_t s_violations[HAL_LOCK_COUNT]; +static bool s_require[HAL_LOCK_COUNT]; +static int s_task; +static bool s_inited; + +void sim_lock_reset_internal(void); /* 只给 sim_hal_reset() 用 */ + +void sim_lock_reset_internal(void) +{ + for (int i = 0; i < HAL_LOCK_COUNT; i++) { + s_owner[i] = NO_OWNER; + } + memset(s_takes, 0, sizeof(s_takes)); + memset(s_timeouts, 0, sizeof(s_timeouts)); + memset(s_violations, 0, sizeof(s_violations)); + memset(s_require, 0, sizeof(s_require)); + s_task = 0; + s_inited = false; +} + +static bool valid(hal_lock_id_t id) { return id >= 0 && id < HAL_LOCK_COUNT; } + +hal_err_t hal_lock_init(void) +{ + s_inited = true; + return HAL_OK; +} + +bool hal_lock_held(hal_lock_id_t id) +{ + return valid(id) && s_owner[id] == s_task; +} + +hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms) +{ + if (!valid(id) || !s_inited) { + return HAL_ERR_STATE; + } + /* 已经持有 = 重复加锁(这两把都不是递归锁);持有另一把 = 嵌套,见 hal_lock.h。 */ + for (int i = 0; i < HAL_LOCK_COUNT; i++) { + if (s_owner[i] == s_task) { + return HAL_ERR_STATE; + } + } + if (s_owner[id] != NO_OWNER) { + assert(timeout_ms != HAL_LOCK_FOREVER); /* 单线程里死等 = 死锁 */ + s_timeouts[id]++; + hal_time_delay_ms(timeout_ms); /* 真板上这段时间是等掉的,虚拟时钟也要花掉 */ + return HAL_ERR_TIMEOUT; + } + s_owner[id] = s_task; + s_takes[id]++; + return HAL_OK; +} + +void hal_lock_give(hal_lock_id_t id) +{ + assert(valid(id)); + assert(s_owner[id] == s_task); /* 不是自己拿的 / 根本没人拿 */ + s_owner[id] = NO_OWNER; +} + +uint32_t hal_lock_timeouts(hal_lock_id_t id) { return valid(id) ? s_timeouts[id] : 0; } + +/* ---- 假板子这一侧的控制面 ------------------------------------------------ */ + +void sim_lock_set_task(int task) { s_task = task; } +int sim_lock_task(void) { return s_task; } +int sim_lock_owner(hal_lock_id_t id) { return valid(id) ? s_owner[id] : NO_OWNER; } +uint32_t sim_lock_takes(hal_lock_id_t id) { return valid(id) ? s_takes[id] : 0; } + +void sim_lock_require(hal_lock_id_t id, bool on) +{ + if (valid(id)) { + s_require[id] = on; + } +} + +uint32_t sim_lock_violations(hal_lock_id_t id) { return valid(id) ? s_violations[id] : 0; } + +void sim_lock_check(hal_lock_id_t id) +{ + if (valid(id) && s_require[id] && !hal_lock_held(id)) { + s_violations[id]++; + } +} diff --git a/firmware/components/phonerail_hal/sim/sim_hal.c b/firmware/components/phonerail_hal/sim/sim_hal.c index b2159d5..2a9edc0 100644 --- a/firmware/components/phonerail_hal/sim/sim_hal.c +++ b/firmware/components/phonerail_hal/sim/sim_hal.c @@ -1,20 +1,24 @@ -/* sim 的总闸:一次把四路都恢复出厂。每个测试的 setUp() 调它。 - 各路的清理函数是内部符号,不进 sim_hal.h —— 测试没有理由单独重置某一路。 */ -#include "sim_hal.h" - -void sim_i2c_reset_internal(void); -void sim_gpio_reset_internal(void); -void sim_sr595_reset_internal(void); -void sim_pca9555_reset_internal(void); -void sim_pwm_reset_internal(void); -void sim_time_reset_internal(void); - -void sim_hal_reset(void) -{ - sim_i2c_reset_internal(); - sim_gpio_reset_internal(); - sim_sr595_reset_internal(); - sim_pca9555_reset_internal(); - sim_pwm_reset_internal(); - sim_time_reset_internal(); -} +/* sim 的总闸:一次把四路都恢复出厂。每个测试的 setUp() 调它。 + 各路的清理函数是内部符号,不进 sim_hal.h —— 测试没有理由单独重置某一路。 */ +#include "hal_i2c_acct.h" +#include "sim_hal.h" + +void sim_i2c_reset_internal(void); +void sim_gpio_reset_internal(void); +void sim_sr595_reset_internal(void); +void sim_pca9555_reset_internal(void); +void sim_pwm_reset_internal(void); +void sim_time_reset_internal(void); +void sim_lock_reset_internal(void); + +void sim_hal_reset(void) +{ + sim_i2c_reset_internal(); + sim_gpio_reset_internal(); + sim_sr595_reset_internal(); + sim_pca9555_reset_internal(); + sim_pwm_reset_internal(); + sim_time_reset_internal(); + sim_lock_reset_internal(); + hal_i2c_acct_reset(); +} diff --git a/firmware/components/phonerail_hal/sim/sim_hal.h b/firmware/components/phonerail_hal/sim/sim_hal.h index 57f5421..e0f4bcf 100644 --- a/firmware/components/phonerail_hal/sim/sim_hal.h +++ b/firmware/components/phonerail_hal/sim/sim_hal.h @@ -1,156 +1,186 @@ -/* 假板子的控制面 —— 只有宿主机测试与 sim/ 自己会包含它,应用代码一律不许包含。 - (包含了就编不过:ESP 目标根本不编译 sim/。) - - ⚠️ 模拟器是用来证明**代码逻辑**对的,不是用来证明**设计**对的。 - 电压、余量、时序裕量的结论全部来自设计文档的计算与首板实测。见计划 §4.2。 */ -#ifndef SIM_HAL_H -#define SIM_HAL_H - -#include -#include - -#include "hal_gpio.h" -#include "hal_i2c.h" -#include "hal_pwm.h" -#include "hal_time.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* 四路 sim 全部恢复出厂:器件表清空、引脚未配置、PWM 未配置、时钟归零、 - init 标志清掉。每个测试的 setUp() 里调它。 */ -void sim_hal_reset(void); - -/* ---------------- I²C ---------------- */ - -#define SIM_I2C_MAX_DEV 16 -#define SIM_I2C_REGS 256 -#define SIM_I2C_LOG_BYTES 8 - -/* 假器件的窃听事件。通用寄存器表模拟不了「配成输出就对地短路」这种事, - 所以按 datasheet 建模的假器件(sim_pca9555)挂一个钩子进来。 */ -typedef enum { - SIM_I2C_EV_POST_WRITE = 0, /* 一次写事务刚落进寄存器表 */ - SIM_I2C_EV_PRE_READ, /* 一次读事务即将取寄存器表 —— 在这里合成输入 */ -} sim_i2c_ev_t; - -struct sim_i2c_dev; -typedef void (*sim_i2c_hook_t)(struct sim_i2c_dev *d, sim_i2c_ev_t ev); - -typedef struct sim_i2c_dev { - uint8_t addr; - uint8_t reg_width; /* 1 或 2 字节。2 字节按**大端**——INA226 / DAC63204 / LM75 都是 */ - bool present; /* false = 器件失联(A15 降级矩阵用),事务一律 NAK */ - int nak_left; /* >0:接下来 n 次事务 NAK 后自动恢复;<0:一直 NAK;0:正常 */ - uint16_t regs[SIM_I2C_REGS]; - uint8_t ptr; /* 器件内部的寄存器指针,跨事务保持(真器件就是这个行为) */ - uint32_t n_write; /* 事务计数,可用来断言「驱动真的访问过它 / 没多访问」 */ - uint32_t n_read; - uint8_t last_write[SIM_I2C_LOG_BYTES]; /* 最后一次写进去的原始字节 */ - uint8_t last_write_len; - sim_i2c_hook_t hook; /* NULL = 就是一张寄存器表 */ -} sim_i2c_dev_t; - -/* 往假总线上挂一个器件。地址重复或超出 16 个会 abort(测试写错了,不该悄悄过)。 */ -sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width); -sim_i2c_dev_t *sim_i2c_dev(uint8_t addr); /* 没挂就返回 NULL */ - -/* 预置 / 回读寄存器。地址没挂过会 abort。 */ -void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val); -uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg); - -/* 故障注入。count>0:接下来 count 次事务 NAK;count<0:一直 NAK;0:清除。 */ -void sim_i2c_inject_nak(uint8_t addr, int count); -void sim_i2c_set_present(uint8_t addr, bool present); - -/* ---------------- GPIO ---------------- */ - -/* ESP32-S3 的 GPIO 编号 **0~48**,所以数组要 49 个 —— `EFUSE_EN2` 就在 IO48 上。 - A7 之前这里是 48,于是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()` - 直接 HAL_ERR_ARG)。边界写成「个数」而不是「上限」,就是这么错的。 */ -#define SIM_GPIO_PINS 49 - -/* 外部世界把某个**输入**脚拉成 level。对输出脚调用会 abort(那是总线打架)。 */ -void sim_gpio_drive(int pin, int level); - -/* 该脚当前电平:输出脚 = 固件驱动的值,输入脚 = 外部驱动的值。没配过返回 -1。 */ -int sim_gpio_level(int pin); - -/* 被 hal_gpio_write() 写成功过几次。断言「改 CH1 没动 CH2~5」用得上(A5)。 */ -uint32_t sim_gpio_writes(int pin); - -/* 全局递增的写序号:每成功写一个脚 +1,本函数返回**该脚最后一次**被写时的序号 - (从没被写过返回 0)。用来断言顺序 —— 「先断 VBUS 再关 eFuse」(A7)、 - 「17 ms 上电时序」(A9)这类事,光看每个脚的终值和次数是看不出来的。 */ -uint32_t sim_gpio_write_seq(int pin); - -/* ---------------- 74HC595 ×3 的 24 位链 ---------------- */ - -/* 按 datasheet 建模的假移位链,**只看线上真实发生的边**(SRCLK / RCLK 上升沿)。 - 对着驱动自己的影子变量断言等于自己证明自己 —— 字节顺序发反、每帧多打一个时钟、 - RCLK 打早了,影子变量一律看不出来。位号与 sr595.h 一致:0 = U21.QA,23 = U33.QH。 - - attach 之后移位寄存器与输出锁存器被塞成 0xA5A5A5:真板子上电时它们的内容 - **不确定**(`MR#` 接死在 +3V3,没有复位线),驱动必须先锁存安全值再使能 OE#。 */ -void sim_sr595_attach(int ser_pin, int srclk_pin, int rclk_pin, int oe_pin); - -uint32_t sim_sr595_outputs(void); /* 输出锁存器(不管 OE# 是高是低) */ -uint32_t sim_sr595_shift(void); /* 移位寄存器当前内容(还没锁存的那一份) */ -uint32_t sim_sr595_clocks(void); /* SRCLK 上升沿总数:一帧应当正好 +24 */ -uint32_t sim_sr595_latches(void); /* RCLK 上升沿总数:一帧应当正好 +1 */ -int sim_sr595_oe(void); /* OE# 当前电平。初值 1 —— 板上 R609 拉着 */ - -/* OE# 第一次被拉低那一刻的现场。断言「输出使能之前已经锁存过安全值」用: - 驱动要是把顺序换了,enable_latches 会是 0、enable_outputs 会是那坨 0xA5A5A5。 */ -bool sim_sr595_enabled(void); -uint32_t sim_sr595_enable_latches(void); -uint32_t sim_sr595_enable_outputs(void); - -/* 某一位在栅极上的实际电平;OE# 为高时返回 -1(高阻,不是 0)。 */ -int sim_sr595_pin(int bit); - -/* ---------------- PCA9555(按 datasheet 建模的假器件) ---------------- */ - -/* 通用寄存器表证明不了 A6 的核心判据。这个模型只多做三件真器件会做的事: - ① **Output Port 复位值 0xFF** —— 所以把一个脚配成输出,它下一刻就输出高; - ② 输入寄存器按位合成:配成输入的位读**外部电平**,配成输出的位读**自己的输出**; - ③ 四个备用脚 P0_7 / P1_5~7 **焊死在地上** —— 读永远是 0,被驱动成高就是短路。 - ③ 是这块板子上「配错方向」唯一会留下痕迹的地方:真板子上它既不报错也不 NAK。 */ -void sim_pca9555_attach(uint8_t addr); - -/* 外部世界把某个端口的 8 根线驱动成 level(接地脚不受影响,恒 0)。 - FLT#/PGOOD 都是开漏上拉,所以「什么都没发生」的静息值是 0xFF。 */ -void sim_pca9555_drive(int port, uint8_t level); - -uint8_t sim_pca9555_config(int port); /* 方向寄存器现值,1 = 输入 */ -uint8_t sim_pca9555_output(int port); /* 输出锁存器现值(复位 0xFF) */ -uint8_t sim_pca9555_polinv(int port); - -/* 接地脚被配成输出过几次(不管输出高低,配了就算 —— 那已经是在对地灌电流)。 */ -uint32_t sim_pca9555_gnd_driven(void); -/* 其中真的输出了高电平、也就是**直接对地短路**过几次。 */ -uint32_t sim_pca9555_shorted(void); - -/* INT# 现在是不是被拉低(低有效)。输入变化拉低,**读走输入端口才释放**。 */ -bool sim_pca9555_int_asserted(void); -uint32_t sim_pca9555_int_asserts(void); /* 累计拉低次数 */ - -/* ---------------- PWM ---------------- */ - -int sim_pwm_pin(int ch); /* 未配置返回 -1 */ -uint32_t sim_pwm_freq(int ch); /* 未配置返回 0 */ -int sim_pwm_permille(int ch); /* 未配置返回 -1 */ - -/* ---------------- 时钟 ---------------- */ - -void sim_time_set_us(uint64_t us); -void sim_time_advance_us(uint64_t us); -void sim_time_advance_ms(uint32_t ms); -uint32_t sim_time_delay_calls(void); /* hal_time_delay_ms() 被调了几次 */ - -#ifdef __cplusplus -} -#endif - -#endif /* SIM_HAL_H */ +/* 假板子的控制面 —— 只有宿主机测试与 sim/ 自己会包含它,应用代码一律不许包含。 + (包含了就编不过:ESP 目标根本不编译 sim/。) + + ⚠️ 模拟器是用来证明**代码逻辑**对的,不是用来证明**设计**对的。 + 电压、余量、时序裕量的结论全部来自设计文档的计算与首板实测。见计划 §4.2。 */ +#ifndef SIM_HAL_H +#define SIM_HAL_H + +#include +#include + +#include "hal_gpio.h" +#include "hal_i2c.h" +#include "hal_i2c_acct.h" +#include "hal_lock.h" +#include "hal_pwm.h" +#include "hal_time.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 四路 sim 全部恢复出厂:器件表清空、引脚未配置、PWM 未配置、时钟归零、 + init 标志清掉。每个测试的 setUp() 里调它。 */ +void sim_hal_reset(void); + +/* ---------------- I²C ---------------- */ + +#define SIM_I2C_MAX_DEV 16 +#define SIM_I2C_REGS 256 +#define SIM_I2C_LOG_BYTES 8 + +/* 假器件的窃听事件。通用寄存器表模拟不了「配成输出就对地短路」这种事, + 所以按 datasheet 建模的假器件(sim_pca9555)挂一个钩子进来。 */ +typedef enum { + SIM_I2C_EV_POST_WRITE = 0, /* 一次写事务刚落进寄存器表 */ + SIM_I2C_EV_PRE_READ, /* 一次读事务即将取寄存器表 —— 在这里合成输入 */ +} sim_i2c_ev_t; + +struct sim_i2c_dev; +typedef void (*sim_i2c_hook_t)(struct sim_i2c_dev *d, sim_i2c_ev_t ev); + +typedef struct sim_i2c_dev { + uint8_t addr; + uint8_t reg_width; /* 1 或 2 字节。2 字节按**大端**——INA226 / DAC63204 / LM75 都是 */ + bool present; /* false = 器件失联(A15 降级矩阵用),事务一律 NAK */ + int nak_left; /* >0:接下来 n 次事务 NAK 后自动恢复;<0:一直 NAK;0:正常 */ + uint16_t regs[SIM_I2C_REGS]; + uint8_t ptr; /* 器件内部的寄存器指针,跨事务保持(真器件就是这个行为) */ + uint32_t n_write; /* 事务计数,可用来断言「驱动真的访问过它 / 没多访问」 */ + uint32_t n_read; + uint8_t last_write[SIM_I2C_LOG_BYTES]; /* 最后一次写进去的原始字节 */ + uint8_t last_write_len; + sim_i2c_hook_t hook; /* NULL = 就是一张寄存器表 */ +} sim_i2c_dev_t; + +/* 往假总线上挂一个器件。地址重复或超出 16 个会 abort(测试写错了,不该悄悄过)。 */ +sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width); +sim_i2c_dev_t *sim_i2c_dev(uint8_t addr); /* 没挂就返回 NULL */ + +/* 预置 / 回读寄存器。地址没挂过会 abort。 */ +void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val); +uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg); + +/* 故障注入。count>0:接下来 count 次事务 NAK;count<0:一直 NAK;0:清除。 */ +void sim_i2c_inject_nak(uint8_t addr, int count); +void sim_i2c_set_present(uint8_t addr, bool present); + +/* **假总线上的事务是要花时间的**(默认开):每次事务把虚拟时钟往前拨 + `hal_i2c_txn_bits() / 频率` 那么多微秒。没有这一条,t_safety 的一个周期在 sim 里 + 耗时为零,「20 ms 周期」的测试就只是在测「delay 传了 20」—— + 而真正会出事的是「巡检自己就占掉了 1.5 ms、周期还准不准」。 + 关掉它只有一个正当理由:某个测试要拿虚拟时钟当纯计数器用。 */ +void sim_i2c_set_bus_time(bool on); + +/* ---------------- GPIO ---------------- */ + +/* ESP32-S3 的 GPIO 编号 **0~48**,所以数组要 49 个 —— `EFUSE_EN2` 就在 IO48 上。 + A7 之前这里是 48,于是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()` + 直接 HAL_ERR_ARG)。边界写成「个数」而不是「上限」,就是这么错的。 */ +#define SIM_GPIO_PINS 49 + +/* 外部世界把某个**输入**脚拉成 level。对输出脚调用会 abort(那是总线打架)。 */ +void sim_gpio_drive(int pin, int level); + +/* 该脚当前电平:输出脚 = 固件驱动的值,输入脚 = 外部驱动的值。没配过返回 -1。 */ +int sim_gpio_level(int pin); + +/* 被 hal_gpio_write() 写成功过几次。断言「改 CH1 没动 CH2~5」用得上(A5)。 */ +uint32_t sim_gpio_writes(int pin); + +/* 全局递增的写序号:每成功写一个脚 +1,本函数返回**该脚最后一次**被写时的序号 + (从没被写过返回 0)。用来断言顺序 —— 「先断 VBUS 再关 eFuse」(A7)、 + 「17 ms 上电时序」(A9)这类事,光看每个脚的终值和次数是看不出来的。 */ +uint32_t sim_gpio_write_seq(int pin); + +/* ---------------- 74HC595 ×3 的 24 位链 ---------------- */ + +/* 按 datasheet 建模的假移位链,**只看线上真实发生的边**(SRCLK / RCLK 上升沿)。 + 对着驱动自己的影子变量断言等于自己证明自己 —— 字节顺序发反、每帧多打一个时钟、 + RCLK 打早了,影子变量一律看不出来。位号与 sr595.h 一致:0 = U21.QA,23 = U33.QH。 + + attach 之后移位寄存器与输出锁存器被塞成 0xA5A5A5:真板子上电时它们的内容 + **不确定**(`MR#` 接死在 +3V3,没有复位线),驱动必须先锁存安全值再使能 OE#。 */ +void sim_sr595_attach(int ser_pin, int srclk_pin, int rclk_pin, int oe_pin); + +uint32_t sim_sr595_outputs(void); /* 输出锁存器(不管 OE# 是高是低) */ +uint32_t sim_sr595_shift(void); /* 移位寄存器当前内容(还没锁存的那一份) */ +uint32_t sim_sr595_clocks(void); /* SRCLK 上升沿总数:一帧应当正好 +24 */ +uint32_t sim_sr595_latches(void); /* RCLK 上升沿总数:一帧应当正好 +1 */ +int sim_sr595_oe(void); /* OE# 当前电平。初值 1 —— 板上 R609 拉着 */ + +/* OE# 第一次被拉低那一刻的现场。断言「输出使能之前已经锁存过安全值」用: + 驱动要是把顺序换了,enable_latches 会是 0、enable_outputs 会是那坨 0xA5A5A5。 */ +bool sim_sr595_enabled(void); +uint32_t sim_sr595_enable_latches(void); +uint32_t sim_sr595_enable_outputs(void); + +/* 某一位在栅极上的实际电平;OE# 为高时返回 -1(高阻,不是 0)。 */ +int sim_sr595_pin(int bit); + +/* ---------------- PCA9555(按 datasheet 建模的假器件) ---------------- */ + +/* 通用寄存器表证明不了 A6 的核心判据。这个模型只多做三件真器件会做的事: + ① **Output Port 复位值 0xFF** —— 所以把一个脚配成输出,它下一刻就输出高; + ② 输入寄存器按位合成:配成输入的位读**外部电平**,配成输出的位读**自己的输出**; + ③ 四个备用脚 P0_7 / P1_5~7 **焊死在地上** —— 读永远是 0,被驱动成高就是短路。 + ③ 是这块板子上「配错方向」唯一会留下痕迹的地方:真板子上它既不报错也不 NAK。 */ +void sim_pca9555_attach(uint8_t addr); + +/* 外部世界把某个端口的 8 根线驱动成 level(接地脚不受影响,恒 0)。 + FLT#/PGOOD 都是开漏上拉,所以「什么都没发生」的静息值是 0xFF。 */ +void sim_pca9555_drive(int port, uint8_t level); + +uint8_t sim_pca9555_config(int port); /* 方向寄存器现值,1 = 输入 */ +uint8_t sim_pca9555_output(int port); /* 输出锁存器现值(复位 0xFF) */ +uint8_t sim_pca9555_polinv(int port); + +/* 接地脚被配成输出过几次(不管输出高低,配了就算 —— 那已经是在对地灌电流)。 */ +uint32_t sim_pca9555_gnd_driven(void); +/* 其中真的输出了高电平、也就是**直接对地短路**过几次。 */ +uint32_t sim_pca9555_shorted(void); + +/* INT# 现在是不是被拉低(低有效)。输入变化拉低,**读走输入端口才释放**。 */ +bool sim_pca9555_int_asserted(void); +uint32_t sim_pca9555_int_asserts(void); /* 累计拉低次数 */ + +/* ---------------- PWM ---------------- */ + +int sim_pwm_pin(int ch); /* 未配置返回 -1 */ +uint32_t sim_pwm_freq(int ch); /* 未配置返回 0 */ +int sim_pwm_permille(int ch); /* 未配置返回 -1 */ + +/* ---------------- 两把互斥锁 ---------------- */ + +/* 扮演第几号任务(默认 0)。单线程 sim 里「另一个任务持着锁」就靠它模拟。 */ +void sim_lock_set_task(int task); +int sim_lock_task(void); + +/* 现在被几号任务持着;没人持着返回 -1。 */ +int sim_lock_owner(hal_lock_id_t id); +uint32_t sim_lock_takes(hal_lock_id_t id); + +/* **强制加锁纪律**:打开之后,动这个共享资源(I²C 的任何事务 / 595 那四根线) + 却没持着对应的锁,就记一次违规。默认关 —— A3~A6 的驱动测试是直接调驱动的, + 它们不该被要求先拿锁。 + + ⚠️ 违规只计数、不 abort:这条检查的用处是**证明上层的加锁路径是通的** + (t_safety 跑一圈违规为 0),以及证明这条检查本身不是句空话 + (不拿锁直接读一片 INA226,违规必须 +1)。 */ +void sim_lock_require(hal_lock_id_t id, bool on); +uint32_t sim_lock_violations(hal_lock_id_t id); +void sim_lock_check(hal_lock_id_t id); /* 给 sim 各路自己调,测试一般用不到 */ + +/* ---------------- 时钟 ---------------- */ + +void sim_time_set_us(uint64_t us); +void sim_time_advance_us(uint64_t us); +void sim_time_advance_ms(uint32_t ms); +uint32_t sim_time_delay_calls(void); /* hal_time_delay_ms() 被调了几次 */ + +#ifdef __cplusplus +} +#endif + +#endif /* SIM_HAL_H */ diff --git a/firmware/components/phonerail_hal/sim/sim_sr595.c b/firmware/components/phonerail_hal/sim/sim_sr595.c index 2b66e8d..fc9db0f 100644 --- a/firmware/components/phonerail_hal/sim/sim_sr595.c +++ b/firmware/components/phonerail_hal/sim/sim_sr595.c @@ -1,98 +1,105 @@ -/* 假的 74HC595 ×3 移位链 —— 挂在 GPIO sim 上,只看**线上真实发生的边**。 - - 为什么要这么一个东西:A5 的两条验收断言(改一路不动别路、字节顺序 U33→U22→U21) - 如果只对着驱动自己的影子变量断言,那是**自己证明自己** —— 字节顺序发反了、 - 每帧多打一个时钟、RCLK 在数据发完之前就打了,影子变量一律看不出来。 - 所以这里按 datasheet 建模:SRCLK 上升沿移一格,RCLK 上升沿把移位寄存器抄进输出锁存器。 - - 24 级的编号与 sr595.h 一致:0 = U21.QA(DS 进来的第一级),23 = U33.QH(链尾)。 */ -#include -#include - -#include "sim_hal.h" - -typedef struct { - bool attached; - int ser_pin, srclk_pin, rclk_pin, oe_pin; - int ser, srclk, rclk, oe; /* 当前电平;oe 初值 1 = 板上 R609 拉着 */ - uint32_t shift; /* 移位寄存器(未锁存) */ - uint32_t out; /* 输出锁存器 */ - uint32_t clocks; /* SRCLK 上升沿总数 */ - uint32_t latches; /* RCLK 上升沿总数 */ - bool enabled_once; - uint32_t enable_latches; /* OE# 第一次被拉低时,已经锁存过几次 */ - uint32_t enable_out; /* OE# 第一次被拉低那一刻,输出锁存器里是什么 */ -} chain_t; - -static chain_t s_ch; - -void sim_sr595_reset_internal(void); /* 只给 sim_hal_reset() 用 */ -void sim_sr595_gpio_hook(int pin, int level); - -void sim_sr595_reset_internal(void) { memset(&s_ch, 0, sizeof(s_ch)); } - -void sim_sr595_attach(int ser_pin, int srclk_pin, int rclk_pin, int oe_pin) -{ - memset(&s_ch, 0, sizeof(s_ch)); - s_ch.attached = true; - s_ch.ser_pin = ser_pin; - s_ch.srclk_pin = srclk_pin; - s_ch.rclk_pin = rclk_pin; - s_ch.oe_pin = oe_pin; - /* `OE#` 上电就被 R609 拉高 —— 高阻,不是 0。 */ - s_ch.oe = 1; - /* 上电后移位寄存器与输出锁存器的内容**不确定**(`MR#` 接死在 +3V3,没有复位线)。 - 这里塞一个显眼的非零值:驱动要是先使能 OE# 再锁存安全值, - `sim_sr595_enable_outputs()` 就会把这坨脏东西原样报出来。 */ - s_ch.shift = 0x00A5A5A5u & 0x00FFFFFFu; - s_ch.out = s_ch.shift; -} - -void sim_sr595_gpio_hook(int pin, int level) -{ - if (!s_ch.attached) { - return; - } - level = level ? 1 : 0; - - if (pin == s_ch.ser_pin) { - s_ch.ser = level; - } else if (pin == s_ch.srclk_pin) { - if (level == 1 && s_ch.srclk == 0) { /* 上升沿:整条链往编号大的方向挪一格 */ - s_ch.shift = ((s_ch.shift << 1) | (uint32_t)s_ch.ser) & 0x00FFFFFFu; - s_ch.clocks++; - } - s_ch.srclk = level; - } else if (pin == s_ch.rclk_pin) { - if (level == 1 && s_ch.rclk == 0) { - s_ch.out = s_ch.shift; - s_ch.latches++; - } - s_ch.rclk = level; - } else if (pin == s_ch.oe_pin) { - if (level == 0 && s_ch.oe != 0 && !s_ch.enabled_once) { - s_ch.enabled_once = true; - s_ch.enable_latches = s_ch.latches; - s_ch.enable_out = s_ch.out; - } - s_ch.oe = level; - } -} - -uint32_t sim_sr595_outputs(void) { return s_ch.out; } -uint32_t sim_sr595_shift(void) { return s_ch.shift; } -uint32_t sim_sr595_clocks(void) { return s_ch.clocks; } -uint32_t sim_sr595_latches(void) { return s_ch.latches; } -int sim_sr595_oe(void) { return s_ch.oe; } -bool sim_sr595_enabled(void) { return s_ch.enabled_once; } -uint32_t sim_sr595_enable_latches(void) { return s_ch.enable_latches; } -uint32_t sim_sr595_enable_outputs(void) { return s_ch.enable_out; } - -int sim_sr595_pin(int bit) -{ - assert(bit >= 0 && bit < 24); - if (s_ch.oe) { - return -1; /* 高阻。栅极侧靠自己的 100 kΩ 下拉,HUB_RST 侧靠 R716 上拉 */ - } - return (int)((s_ch.out >> bit) & 1u); -} +/* 假的 74HC595 ×3 移位链 —— 挂在 GPIO sim 上,只看**线上真实发生的边**。 + + 为什么要这么一个东西:A5 的两条验收断言(改一路不动别路、字节顺序 U33→U22→U21) + 如果只对着驱动自己的影子变量断言,那是**自己证明自己** —— 字节顺序发反了、 + 每帧多打一个时钟、RCLK 在数据发完之前就打了,影子变量一律看不出来。 + 所以这里按 datasheet 建模:SRCLK 上升沿移一格,RCLK 上升沿把移位寄存器抄进输出锁存器。 + + 24 级的编号与 sr595.h 一致:0 = U21.QA(DS 进来的第一级),23 = U33.QH(链尾)。 */ +#include +#include + +#include "sim_hal.h" + +typedef struct { + bool attached; + int ser_pin, srclk_pin, rclk_pin, oe_pin; + int ser, srclk, rclk, oe; /* 当前电平;oe 初值 1 = 板上 R609 拉着 */ + uint32_t shift; /* 移位寄存器(未锁存) */ + uint32_t out; /* 输出锁存器 */ + uint32_t clocks; /* SRCLK 上升沿总数 */ + uint32_t latches; /* RCLK 上升沿总数 */ + bool enabled_once; + uint32_t enable_latches; /* OE# 第一次被拉低时,已经锁存过几次 */ + uint32_t enable_out; /* OE# 第一次被拉低那一刻,输出锁存器里是什么 */ +} chain_t; + +static chain_t s_ch; + +void sim_sr595_reset_internal(void); /* 只给 sim_hal_reset() 用 */ +void sim_sr595_gpio_hook(int pin, int level); + +void sim_sr595_reset_internal(void) { memset(&s_ch, 0, sizeof(s_ch)); } + +void sim_sr595_attach(int ser_pin, int srclk_pin, int rclk_pin, int oe_pin) +{ + memset(&s_ch, 0, sizeof(s_ch)); + s_ch.attached = true; + s_ch.ser_pin = ser_pin; + s_ch.srclk_pin = srclk_pin; + s_ch.rclk_pin = rclk_pin; + s_ch.oe_pin = oe_pin; + /* `OE#` 上电就被 R609 拉高 —— 高阻,不是 0。 */ + s_ch.oe = 1; + /* 上电后移位寄存器与输出锁存器的内容**不确定**(`MR#` 接死在 +3V3,没有复位线)。 + 这里塞一个显眼的非零值:驱动要是先使能 OE# 再锁存安全值, + `sim_sr595_enable_outputs()` 就会把这坨脏东西原样报出来。 */ + s_ch.shift = 0x00A5A5A5u & 0x00FFFFFFu; + s_ch.out = s_ch.shift; +} + +void sim_sr595_gpio_hook(int pin, int level) +{ + if (!s_ch.attached) { + return; + } + level = level ? 1 : 0; + + /* 动这条链的四根线之一 = 在用那个共享资源,此刻必须持着 595 那把锁 + (只在 sim_lock_require(HAL_LOCK_SR595, true) 之后才计数,见 hal_lock_sim.c)。 */ + if (pin == s_ch.ser_pin || pin == s_ch.srclk_pin || pin == s_ch.rclk_pin || + pin == s_ch.oe_pin) { + sim_lock_check(HAL_LOCK_SR595); + } + + if (pin == s_ch.ser_pin) { + s_ch.ser = level; + } else if (pin == s_ch.srclk_pin) { + if (level == 1 && s_ch.srclk == 0) { /* 上升沿:整条链往编号大的方向挪一格 */ + s_ch.shift = ((s_ch.shift << 1) | (uint32_t)s_ch.ser) & 0x00FFFFFFu; + s_ch.clocks++; + } + s_ch.srclk = level; + } else if (pin == s_ch.rclk_pin) { + if (level == 1 && s_ch.rclk == 0) { + s_ch.out = s_ch.shift; + s_ch.latches++; + } + s_ch.rclk = level; + } else if (pin == s_ch.oe_pin) { + if (level == 0 && s_ch.oe != 0 && !s_ch.enabled_once) { + s_ch.enabled_once = true; + s_ch.enable_latches = s_ch.latches; + s_ch.enable_out = s_ch.out; + } + s_ch.oe = level; + } +} + +uint32_t sim_sr595_outputs(void) { return s_ch.out; } +uint32_t sim_sr595_shift(void) { return s_ch.shift; } +uint32_t sim_sr595_clocks(void) { return s_ch.clocks; } +uint32_t sim_sr595_latches(void) { return s_ch.latches; } +int sim_sr595_oe(void) { return s_ch.oe; } +bool sim_sr595_enabled(void) { return s_ch.enabled_once; } +uint32_t sim_sr595_enable_latches(void) { return s_ch.enable_latches; } +uint32_t sim_sr595_enable_outputs(void) { return s_ch.enable_out; } + +int sim_sr595_pin(int bit) +{ + assert(bit >= 0 && bit < 24); + if (s_ch.oe) { + return -1; /* 高阻。栅极侧靠自己的 100 kΩ 下拉,HUB_RST 侧靠 R716 上拉 */ + } + return (int)((s_ch.out >> bit) & 1u); +} diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index e40fa84..015a371 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -1,75 +1,80 @@ -# PhoneRail 宿主机构建 —— 不依赖 ESP-IDF,用来跑单元测试(CTest)。 -# -# bash host/test.sh 配置 + 编译 + ctest -# -# 与 IDF 目标共享的源码只有 ../main 里的与硬件无关部分。 -# 新增一个既要上板又要被测的 .c,必须同时加进: -# ../main/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_CORE_SRCS -# HAL 里新增一路实现则要同时加进: -# ../components/phonerail_hal/CMakeLists.txt 的 SRCS(esp 那份) -# 和下面的 PHONERAIL_HAL_SIM_SRCS(sim 那份) -# 新增一个器件驱动则是: -# ../components/drv/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_DRV_SRCS -cmake_minimum_required(VERSION 3.16) -project(phonerail_host C) - -set(CMAKE_C_STANDARD 11) -set(CMAKE_C_STANDARD_REQUIRED ON) -add_compile_options(-Wall -Wextra -Werror) - -set(FW_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) - -# ---- 被测代码 ------------------------------------------------------------- -set(PHONERAIL_CORE_SRCS - ${FW_ROOT}/main/version.c - ${FW_ROOT}/main/rail.c - ${FW_ROOT}/main/rail_cfg.c -) - -# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。 -set(PHONERAIL_HAL_DIR ${FW_ROOT}/components/phonerail_hal) -set(PHONERAIL_HAL_SIM_SRCS - ${PHONERAIL_HAL_DIR}/hal_err.c - ${PHONERAIL_HAL_DIR}/sim/sim_hal.c - ${PHONERAIL_HAL_DIR}/sim/hal_i2c_sim.c - ${PHONERAIL_HAL_DIR}/sim/hal_gpio_sim.c - ${PHONERAIL_HAL_DIR}/sim/sim_sr595.c - ${PHONERAIL_HAL_DIR}/sim/sim_pca9555.c - ${PHONERAIL_HAL_DIR}/sim/hal_pwm_sim.c - ${PHONERAIL_HAL_DIR}/sim/hal_time_sim.c -) - -# 器件驱动:与平台无关(只经 hal_i2c_*),两个目标编的是同一份源码。 -set(PHONERAIL_DRV_DIR ${FW_ROOT}/components/drv) -set(PHONERAIL_DRV_SRCS - ${PHONERAIL_DRV_DIR}/ina226.c - ${PHONERAIL_DRV_DIR}/dac63204.c - ${PHONERAIL_DRV_DIR}/sr595.c - ${PHONERAIL_DRV_DIR}/pca9555.c -) - -add_library(phonerail_core STATIC - ${PHONERAIL_CORE_SRCS} ${PHONERAIL_HAL_SIM_SRCS} ${PHONERAIL_DRV_SRCS}) -target_include_directories(phonerail_core PUBLIC - ${FW_ROOT}/main - ${PHONERAIL_HAL_DIR}/include - ${PHONERAIL_HAL_DIR}/sim - ${PHONERAIL_DRV_DIR}/include) - -# ---- 测试框架 Unity(从 ESP-IDF v5.4 原样复制到 third_party/,与上板测试同一套) -add_library(unity STATIC ${FW_ROOT}/third_party/unity/unity.c) -target_include_directories(unity PUBLIC ${FW_ROOT}/third_party/unity) -target_compile_definitions(unity PUBLIC UNITY_OUTPUT_COLOR) -# 第三方源码不吃本工程的 -Werror -target_compile_options(unity PRIVATE -w) - -# ---- 测试用例:test/test_*.c 一个文件一个可执行 + 一条 ctest --------------- -enable_testing() - -file(GLOB PHONERAIL_TESTS ${FW_ROOT}/test/test_*.c) -foreach(test_src ${PHONERAIL_TESTS}) - get_filename_component(test_name ${test_src} NAME_WE) - add_executable(${test_name} ${test_src}) - target_link_libraries(${test_name} PRIVATE phonerail_core unity) - add_test(NAME ${test_name} COMMAND ${test_name}) -endforeach() +# PhoneRail 宿主机构建 —— 不依赖 ESP-IDF,用来跑单元测试(CTest)。 +# +# bash host/test.sh 配置 + 编译 + ctest +# +# 与 IDF 目标共享的源码只有 ../main 里的与硬件无关部分。 +# 新增一个既要上板又要被测的 .c,必须同时加进: +# ../main/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_CORE_SRCS +# HAL 里新增一路实现则要同时加进: +# ../components/phonerail_hal/CMakeLists.txt 的 SRCS(esp 那份) +# 和下面的 PHONERAIL_HAL_SIM_SRCS(sim 那份) +# 新增一个器件驱动则是: +# ../components/drv/CMakeLists.txt 的 SRCS 和 下面的 PHONERAIL_DRV_SRCS +cmake_minimum_required(VERSION 3.16) +project(phonerail_host C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +add_compile_options(-Wall -Wextra -Werror) + +set(FW_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) + +# ---- 被测代码 ------------------------------------------------------------- +set(PHONERAIL_CORE_SRCS + ${FW_ROOT}/main/version.c + ${FW_ROOT}/main/rail.c + ${FW_ROOT}/main/rail_cfg.c + ${FW_ROOT}/main/sched.c + ${FW_ROOT}/main/tasks.c + ${FW_ROOT}/main/t_safety.c +) + +# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。 +set(PHONERAIL_HAL_DIR ${FW_ROOT}/components/phonerail_hal) +set(PHONERAIL_HAL_SIM_SRCS + ${PHONERAIL_HAL_DIR}/hal_err.c + ${PHONERAIL_HAL_DIR}/hal_i2c_acct.c + ${PHONERAIL_HAL_DIR}/sim/hal_lock_sim.c + ${PHONERAIL_HAL_DIR}/sim/sim_hal.c + ${PHONERAIL_HAL_DIR}/sim/hal_i2c_sim.c + ${PHONERAIL_HAL_DIR}/sim/hal_gpio_sim.c + ${PHONERAIL_HAL_DIR}/sim/sim_sr595.c + ${PHONERAIL_HAL_DIR}/sim/sim_pca9555.c + ${PHONERAIL_HAL_DIR}/sim/hal_pwm_sim.c + ${PHONERAIL_HAL_DIR}/sim/hal_time_sim.c +) + +# 器件驱动:与平台无关(只经 hal_i2c_*),两个目标编的是同一份源码。 +set(PHONERAIL_DRV_DIR ${FW_ROOT}/components/drv) +set(PHONERAIL_DRV_SRCS + ${PHONERAIL_DRV_DIR}/ina226.c + ${PHONERAIL_DRV_DIR}/dac63204.c + ${PHONERAIL_DRV_DIR}/sr595.c + ${PHONERAIL_DRV_DIR}/pca9555.c +) + +add_library(phonerail_core STATIC + ${PHONERAIL_CORE_SRCS} ${PHONERAIL_HAL_SIM_SRCS} ${PHONERAIL_DRV_SRCS}) +target_include_directories(phonerail_core PUBLIC + ${FW_ROOT}/main + ${PHONERAIL_HAL_DIR}/include + ${PHONERAIL_HAL_DIR}/sim + ${PHONERAIL_DRV_DIR}/include) + +# ---- 测试框架 Unity(从 ESP-IDF v5.4 原样复制到 third_party/,与上板测试同一套) +add_library(unity STATIC ${FW_ROOT}/third_party/unity/unity.c) +target_include_directories(unity PUBLIC ${FW_ROOT}/third_party/unity) +target_compile_definitions(unity PUBLIC UNITY_OUTPUT_COLOR) +# 第三方源码不吃本工程的 -Werror +target_compile_options(unity PRIVATE -w) + +# ---- 测试用例:test/test_*.c 一个文件一个可执行 + 一条 ctest --------------- +enable_testing() + +file(GLOB PHONERAIL_TESTS ${FW_ROOT}/test/test_*.c) +foreach(test_src ${PHONERAIL_TESTS}) + get_filename_component(test_name ${test_src} NAME_WE) + add_executable(${test_name} ${test_src}) + target_link_libraries(${test_name} PRIVATE phonerail_core unity) + add_test(NAME ${test_name} COMMAND ${test_name}) +endforeach() diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index a22c26b..9a69712 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,10 +1,13 @@ -# ESP-IDF 侧的 main 组件。 -# app_main.c 是 ESP 专用入口;其余 .c 必须同时在宿主机上编得过 -# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 -idf_component_register( - SRCS "app_main.c" - "version.c" - "rail.c" - "rail_cfg.c" - INCLUDE_DIRS "." - REQUIRES phonerail_hal drv) +# ESP-IDF 侧的 main 组件。 +# app_main.c 是 ESP 专用入口;其余 .c 必须同时在宿主机上编得过 +# (新增文件时两处都要加:这里 + host/CMakeLists.txt 的 PHONERAIL_CORE_SRCS)。 +idf_component_register( + SRCS "app_main.c" + "version.c" + "rail.c" + "rail_cfg.c" + "sched.c" + "tasks.c" + "t_safety.c" + INCLUDE_DIRS "." + REQUIRES phonerail_hal drv) diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index e8c8527..9dc9e92 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -1,50 +1,174 @@ -/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */ -#include - -#include "esp_log.h" -#include "hal_time.h" -#include "phonerail.h" -#include "rail_cfg.h" - -static const char *TAG = "phonerail"; - -/* 启动自检(需求 §10.4):五路的地址 / 位号 / 引脚无重复、无越界, - 而且那张五路表和四个驱动自己的表没有分家。 - - ⚠️ **不过就 abort,不降级。** 这里查的全是编译进固件的常量表, - 查不过 = 固件本身写错了,不是硬件坏了 —— §10.10 的降级矩阵管的是后者。 - 带着一张错表跑起来的样子是「命令发给了另一路」,五台手机全在跑、 - 一个错误码都不报,谁也不会去怀疑它。 */ -static void selftest_or_die(void) -{ - const uint32_t bad = rail_selftest(); - if (bad == 0) { - ESP_LOGI(TAG, "五路配置表自检通过"); - return; - } - for (uint32_t bit = 1; bit != 0; bit <<= 1) { - if ((bad & bit) != 0) { - ESP_LOGE(TAG, "自检失败:%s (0x%02x)", rail_selftest_str(bit), (unsigned)bit); - } - } - abort(); -} - -void app_main(void) -{ - ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(), - PHONERAIL_RAIL_COUNT, (unsigned)hal_time_now_ms()); - - selftest_or_die(); - - /* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有 - 输出,这两根线这时必须已经确定是低的。 */ - const hal_err_t err = rail_en_init(); - if (err != HAL_OK) { - ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err)); - abort(); - } - ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭,等待 A8 的任务骨架"); - - /* 器件初始化与五个任务留到 A8/A15。 */ -} +/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */ +#include + +#include "board_pins.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "hal_i2c.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "phonerail.h" +#include "rail_cfg.h" +#include "sched.h" +#include "t_safety.h" +#include "tasks.h" + +static const char *TAG = "phonerail"; + +/* 启动自检(需求 §10.4):五路的地址 / 位号 / 引脚无重复、无越界, + 而且那张五路表和四个驱动自己的表没有分家。 + + ⚠️ **不过就 abort,不降级。** 这里查的全是编译进固件的常量表, + 查不过 = 固件本身写错了,不是硬件坏了 —— §10.10 的降级矩阵管的是后者。 + 带着一张错表跑起来的样子是「命令发给了另一路」,五台手机全在跑、 + 一个错误码都不报,谁也不会去怀疑它。 */ +static void selftest_or_die(void) +{ + /* 五个任务那张表(§10.3):周期与优先级的相对顺序。同样是编译进去的常量, + 同样不过就 abort —— 优先级写反了板子照跑,只是巡检会在总线忙时被挤后。 */ + if (!task_table_ok()) { + ESP_LOGE(TAG, "任务表自检失败:周期或优先级与 §10.3 对不上"); + abort(); + } + + const uint32_t bad = rail_selftest(); + if (bad == 0) { + ESP_LOGI(TAG, "五路配置表自检通过"); + return; + } + for (uint32_t bit = 1; bit != 0; bit <<= 1) { + if ((bad & bit) != 0) { + ESP_LOGE(TAG, "自检失败:%s (0x%02x)", rail_selftest_str(bit), (unsigned)bit); + } + } + abort(); +} + +/* ---- 五个任务(需求 §10.3) --------------------------------------------- + 任务体只在这个文件里,而且都短得能一眼看完 —— 真正的活在 main/ 的其它 .c 里, + 那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的 + 东西,宿主机一行也跑不到。 */ + +static void task_safety(void *arg) +{ + (void)arg; + static safety_snap_t snap; /* static:不占任务栈,也方便挂调试器看 */ + t_safety_init(&snap); + + sched_periodic_t tick; + sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); + + char line[128]; + for (;;) { + (void)t_safety_step(&snap); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */ + + /* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」, + 首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */ + if ((snap.cycles % 250u) == 0u) { + t_safety_format(&snap, line, sizeof(line)); + ESP_LOGI(TAG, "%s (预算 %u us, 过载 %u, 最迟 %u ms)", line, + (unsigned)t_safety_budget_us(), (unsigned)tick.overruns, + (unsigned)tick.worst_late_ms); + } + sched_periodic_wait(&tick); + } +} + +/* 下面四个是骨架:周期与优先级已经按 §10.3 定好,任务体等各自的包。 + 现在就把它们建起来,是为了让 A9~A16 只需要往里填,不用再动启动流程。 */ + +static void task_thermal(void *arg) +{ + (void)arg; + sched_periodic_t tick; + sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms); + for (;;) { + /* A16:两路 NTC + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */ + sched_periodic_wait(&tick); + } +} + +static void task_actuator(void *arg) +{ + (void)arg; + for (;;) { + /* A9:三个状态机 + 互斥 + 超时兜底。队列在那一包里建。 */ + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +static void task_link(void *arg) +{ + (void)arg; + for (;;) { + /* A12~A14:命令处理器 + 两条传输链路。 */ + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +static void task_log(void *arg) +{ + (void)arg; + for (;;) { + /* A11:事件环形缓冲落盘。 */ + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} + +static void spawn(task_id_t id, TaskFunction_t fn) +{ + const task_desc_t *d = task_desc(id); + /* ⚠️ ESP-IDF 的 xTaskCreate 收的 stack_depth 是**字节**(原版 FreeRTOS 是字)。 */ + if (xTaskCreate(fn, d->name, d->stack_bytes, NULL, d->prio, NULL) != pdPASS) { + ESP_LOGE(TAG, "建任务失败:%s", d->name); + abort(); + } +} + +void app_main(void) +{ + ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(), + PHONERAIL_RAIL_COUNT, (unsigned)hal_time_now_ms()); + + selftest_or_die(); + + /* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有 + 输出,这两根线这时必须已经确定是低的。 */ + hal_err_t err = rail_en_init(); + if (err != HAL_OK) { + ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err)); + abort(); + } + ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭"); + + /* 两把锁必须早于任何一个用总线的任务。 */ + (void)hal_lock_init(); + + const hal_i2c_cfg_t i2c = { + .sda_gpio = BOARD_PIN_I2C_SDA, + .scl_gpio = BOARD_PIN_I2C_SCL, + .freq_hz = 400000, + }; + err = hal_i2c_init(&i2c); + if (err != HAL_OK) { + ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err)); + abort(); + } + + /* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值) + 与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0 + (Calibration 还没写),这是预期的,不是故障。 */ + + spawn(TASK_SAFETY, task_safety); + spawn(TASK_THERMAL, task_thermal); + spawn(TASK_ACTUATOR, task_actuator); + spawn(TASK_LINK, task_link); + spawn(TASK_LOG, task_log); + + ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms)," + "I²C 预算 %u us 每周期", + (unsigned)task_desc(TASK_SAFETY)->period_ms, + (unsigned)task_desc(TASK_THERMAL)->period_ms, + (unsigned)t_safety_budget_us()); +} diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 9fafa97..4d672c1 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -1,23 +1,23 @@ -/* PhoneRail 固件公共头 —— 与硬件无关,宿主机与 ESP32-S3 上都编得过。 */ -#ifndef PHONERAIL_H -#define PHONERAIL_H - -/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.0-a7" - -/* 通道数。五路直供,见设计文档 §2。 */ -#define PHONERAIL_RAIL_COUNT 5 - -#ifdef __cplusplus -extern "C" { -#endif - -/* 返回 PHONERAIL_FW_VERSION。存在的意义是让宿主机测试有一个真的会被链接进去的 - 符号 —— 骨架期用来证明「同一份 main/ 源码两个目标都编得过」。 */ -const char *phonerail_version(void); - -#ifdef __cplusplus -} -#endif - -#endif /* PHONERAIL_H */ +/* PhoneRail 固件公共头 —— 与硬件无关,宿主机与 ESP32-S3 上都编得过。 */ +#ifndef PHONERAIL_H +#define PHONERAIL_H + +/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ +#define PHONERAIL_FW_VERSION "0.1.0-a8" + +/* 通道数。五路直供,见设计文档 §2。 */ +#define PHONERAIL_RAIL_COUNT 5 + +#ifdef __cplusplus +extern "C" { +#endif + +/* 返回 PHONERAIL_FW_VERSION。存在的意义是让宿主机测试有一个真的会被链接进去的 + 符号 —— 骨架期用来证明「同一份 main/ 源码两个目标都编得过」。 */ +const char *phonerail_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* PHONERAIL_H */ diff --git a/firmware/main/sched.c b/firmware/main/sched.c new file mode 100644 index 0000000..672f2d9 --- /dev/null +++ b/firmware/main/sched.c @@ -0,0 +1,53 @@ +#include "sched.h" + +#include +#include + +#include "hal_time.h" + +void sched_periodic_init(sched_periodic_t *p, uint32_t period_ms) +{ + assert(p != NULL); + assert(period_ms > 0); /* 事件驱动的任务不该走这里 */ + p->period_ms = period_ms; + p->next_ms = hal_time_now_ms() + period_ms; + p->cycles = 0; + p->overruns = 0; + p->worst_late_ms = 0; +} + +uint32_t sched_periodic_delay_from(sched_periodic_t *p, uint32_t now_ms) +{ + assert(p != NULL && p->period_ms > 0); + p->cycles++; + + /* ⚠️ 一律用有符号差判先后,不要写 `now >= next`。 + 32 位 ms 在 49.7 天回绕,农场是长稳跑的,回绕一定会发生 —— + 裸比较会在回绕那一刻把「还早着呢」判成「已经迟到了」, + 现象是巡检突然连着空转 49.7 天份的周期。 */ + const int32_t late = (int32_t)(now_ms - p->next_ms); + + if (late < 0) { + const uint32_t delay = (uint32_t)(-late); + p->next_ms += p->period_ms; + return delay; + } + + /* 到点了活还没干完。跳过错过的整周期、保住相位,见 sched.h。 */ + p->overruns++; + if ((uint32_t)late > p->worst_late_ms) { + p->worst_late_ms = (uint32_t)late; + } + const uint32_t missed = (uint32_t)late / p->period_ms + 1u; + p->next_ms += missed * p->period_ms; + return 0; +} + +uint32_t sched_periodic_wait(sched_periodic_t *p) +{ + const uint32_t delay = sched_periodic_delay_from(p, hal_time_now_ms()); + if (delay > 0) { + hal_time_delay_ms(delay); + } + return delay; +} diff --git a/firmware/main/sched.h b/firmware/main/sched.h new file mode 100644 index 0000000..188c4c8 --- /dev/null +++ b/firmware/main/sched.h @@ -0,0 +1,49 @@ +/* 周期任务的节拍器(需求 §10.3)。 + + 两个周期任务 —— `t_safety` 20 ms、`t_thermal` 1 s —— 都不能用 + 「干完活睡 20 ms」这种写法:那样周期会变成 `20 ms + 干活时间`, + 而干活时间本身是变的(I²C 重试、NAK 超时),于是巡检的实际间隔会**漂**, + 还会跟着总线忙闲一起抖。§10.3 的 20 ms 是**间隔**,不是延时。 + + 所以这里记的是**下一次该醒的绝对时刻**,每次把它 += 周期, + 睡「到那个时刻还差多久」。FreeRTOS 的 `vTaskDelayUntil()` 是同一个东西, + 这里自己写一份的唯一理由是:它得在宿主机上用虚拟时钟跑得起来。 + + ---- 过载了怎么办 -------------------------------------------------------- + 一个周期没干完就到点了(`sched_periodic_wait()` 算出来该睡 0 ms), + **跳过错过的整周期、保住相位**,并计一次 `overruns`。 + 不补跑 —— 补跑的现象是「卡了一下之后连着跑五圈巡检」,把总线瞬间打满、 + 把已经落后的任务推得更后面。巡检读的是**当前**电压电流,补读三个周期前的没有意义。 */ +#ifndef SCHED_H +#define SCHED_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint32_t period_ms; + uint32_t next_ms; /* 下一次该醒的绝对时刻(32 位 ms,回绕安全) */ + uint32_t cycles; /* 已经等过几个周期 */ + uint32_t overruns; /* 其中「到点时活还没干完」的有几个 */ + uint32_t worst_late_ms; /* 最严重的一次迟到了多久 —— 遥测里报出去 */ +} sched_periodic_t; + +/* 以「现在」为相位起点。period_ms 为 0 会 abort(那是事件驱动任务,不该用这个)。 */ +void sched_periodic_init(sched_periodic_t *p, uint32_t period_ms); + +/* 纯计算:now_ms 这一刻还该睡多久,并把下一次的时刻推进。 + 与 `sched_periodic_wait()` 分开是为了能不依赖时钟单独测它 —— + 回绕、过载、连续过载这三种情况用真时钟很难摆出来。 */ +uint32_t sched_periodic_delay_from(sched_periodic_t *p, uint32_t now_ms); + +/* 睡到下一个节拍。返回实际睡了多久(0 = 这一圈超时了)。 */ +uint32_t sched_periodic_wait(sched_periodic_t *p); + +#ifdef __cplusplus +} +#endif + +#endif /* SCHED_H */ diff --git a/firmware/main/t_safety.c b/firmware/main/t_safety.c new file mode 100644 index 0000000..27b63ce --- /dev/null +++ b/firmware/main/t_safety.c @@ -0,0 +1,118 @@ +#include "t_safety.h" + +#include +#include +#include + +#include "hal_i2c_acct.h" +#include "hal_lock.h" +#include "rail_cfg.h" +#include "tasks.h" + +void t_safety_init(safety_snap_t *s) +{ + assert(s != NULL); + memset(s, 0, sizeof(*s)); + /* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */ + s->bus_cursor = hal_i2c_acct_bits(); +} + +hal_err_t t_safety_step(safety_snap_t *s) +{ + assert(s != NULL); + s->cycles++; + + hal_err_t err = hal_lock_take(HAL_LOCK_I2C, T_SAFETY_LOCK_TIMEOUT_MS); + if (err != HAL_OK) { + /* 规矩 1:不死等。这一圈作废,20 ms 后重来。 */ + s->skipped++; + s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor); + return err; + } + + hal_err_t first = HAL_OK; + + /* 五路 —— 地址查表,**不许算**(rail_cfg.h 那条禁令)。 */ + for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) { + ina226_vi_t vi; + const hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi); + s->rail_ok[rail - 1] = (e == HAL_OK); + if (e == HAL_OK) { + s->rail[rail - 1] = vi; /* 规矩 2:失败就留着上一圈的值,别清零 */ + } else { + s->i2c_errors++; + if (first == HAL_OK) { + first = e; + } + } + } + + /* 第六片:输入侧。 */ + { + ina226_vi_t vi; + const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi); + s->input_ok = (e == HAL_OK); + if (e == HAL_OK) { + s->input = vi; + } else { + s->i2c_errors++; + if (first == HAL_OK) { + first = e; + } + } + } + + /* PCA9555 的两个输入端口一次事务读完(自增指针)。 */ + { + pca9555_in_t in; + const hal_err_t e = pca9555_read_inputs(&in); + s->io_ok = (e == HAL_OK); + if (e == HAL_OK) { + s->io = in; + } else { + s->i2c_errors++; + if (first == HAL_OK) { + first = e; + } + } + } + + hal_lock_give(HAL_LOCK_I2C); + + /* A10 的判据接在这里:越界**直接**写 EFUSE_EN / VBUS_EN(规矩 3), + 而且要在放锁之后 —— 那两根线是 GPIO 直连,和 I²C 没关系, + 拿着总线锁去操作引脚只会把别的任务多挡一会儿。 */ + + s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor); + return first; +} + +uint32_t t_safety_budget_us(void) +{ + /* 上面那一圈真正发出去的事务,逐条数: + 6 片 INA226 × 2 次寄存器读(母线电压 + 电流) + 1 次 PCA9555 读(一次事务读回两个输入端口) + 每次都是「写 1 字节寄存器号 + 读 2 字节」的形状。 */ + const uint32_t txns = INA226_COUNT * 2u + 1u; + return (uint32_t)hal_i2c_bits_to_us((uint64_t)txns * hal_i2c_txn_bits(1, 2)); +} + +uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms) +{ + if (period_ms == 0) { + return 0; + } + return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull)); +} + +int t_safety_format(const safety_snap_t *s, char *buf, size_t len) +{ + assert(s != NULL && buf != NULL && len > 0); + const uint32_t period = task_desc(TASK_SAFETY)->period_ms; + const uint32_t pm = t_safety_load_permille(s->bus_us, period); + return snprintf(buf, len, + "t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%%", + (unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors, + (unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u), + (unsigned)(pm % 10u)); +} diff --git a/firmware/main/t_safety.h b/firmware/main/t_safety.h new file mode 100644 index 0000000..d0edd0c --- /dev/null +++ b/firmware/main/t_safety.h @@ -0,0 +1,72 @@ +/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。 + + 本包(A8)只做骨架:拿 I²C 锁 → 读 6 片 INA226 的 V+I → 读 PCA9555 两个输入端口 + → 放锁 → 记账。**判据(SW-2 / SW-8 / 过压 / 反灌)是 A10 的事**,这里一条都没有。 + + 分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来: + 宿主机没有 FreeRTOS,任务体是死循环就一行也测不了。ESP 侧的 `while(1)` + 在 app_main.c 里,只有三行(拿节拍 / 调这个 / 睡到下一拍)。 + + ---- 三条不能改的规矩 ---------------------------------------------------- + 1. **不许死等锁。** 拿不到就跳过这一周期并计数(`skipped`)。死等的现象是 + 巡检整个停摆,而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。 + 2. **一个器件读失败不能中断这一圈。** 六片里坏一片,剩下五路的判据照常要出。 + 失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。 + 3. **越界要断电时直接写 `EFUSE_EN` / `VBUS_EN` 引脚**(§10.3 原话:不经任何队列 + 或抽象层)。所以 A10 的判据要写在这个函数里,不是发个消息给 t_actuator。 */ +#ifndef T_SAFETY_H +#define T_SAFETY_H + +#include +#include +#include + +#include "hal_err.h" +#include "ina226.h" +#include "pca9555.h" +#include "phonerail.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 等 I²C 锁最多等这么久。取周期的 1/4:等过头这一周期就废了, + 而**废一个周期**(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */ +#define T_SAFETY_LOCK_TIMEOUT_MS 5 + +typedef struct { + /* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 —— + 判据(A10)必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */ + ina226_vi_t rail[PHONERAIL_RAIL_COUNT]; + bool rail_ok[PHONERAIL_RAIL_COUNT]; + ina226_vi_t input; /* 输入侧 U13(0x43),整板功率预算 */ + bool input_ok; + pca9555_in_t io; /* 五路 FLT# / PGOOD + Buck PG 或 + INA ALERT 或 */ + bool io_ok; + + uint32_t cycles; /* 跑过几圈(含被跳过的) */ + uint32_t skipped; /* 其中因为拿不到 I²C 锁而整圈跳过的 */ + uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */ + uint32_t bus_us; /* **上一圈**在 I²C 上真正花掉的时间 */ + uint64_t bus_cursor; /* 内部:位数游标,别在外面改 */ +} safety_snap_t; + +void t_safety_init(safety_snap_t *s); +hal_err_t t_safety_step(safety_snap_t *s); + +/* 按 §10.3 的算式静态算出来的「一圈该占总线多久」,单位 µs。 + 它和 `s->bus_us` 应当**一模一样** —— 不一样只有两种可能:巡检里多读/少读了东西, + 或者有别的任务在同一圈里插了事务进来。测试拿这两个数对账。 */ +uint32_t t_safety_budget_us(void); + +/* 占用率(千分之)。20 ms 周期、1560 µs → 78‰。§10.3 手算的是「约 8 %」。 */ +uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms); + +/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */ +int t_safety_format(const safety_snap_t *s, char *buf, size_t len); + +#ifdef __cplusplus +} +#endif + +#endif /* T_SAFETY_H */ diff --git a/firmware/main/tasks.c b/firmware/main/tasks.c new file mode 100644 index 0000000..92bf51b --- /dev/null +++ b/firmware/main/tasks.c @@ -0,0 +1,77 @@ +#include "tasks.h" + +#include +#include +#include + +#include "ina226.h" + +/* 优先级的绝对值本身不重要,**相对顺序**才是 §10.3 定的东西。 + 留出空档(6/4/4/3/1)是为了以后插一个任务时不用整体重排 —— 重排的那次 + 一定会有人只改一半。 */ +#define PRIO_SAFETY 6 +#define PRIO_THERMAL 4 +#define PRIO_ACTUATOR 4 +#define PRIO_LINK 3 +#define PRIO_LOG 1 + +static const task_desc_t s_tasks[TASK_COUNT] = { + [TASK_SAFETY] = { "t_safety", 20, PRIO_SAFETY, 4096 }, + [TASK_THERMAL] = { "t_thermal", 1000, PRIO_THERMAL, 3072 }, + /* 执行器要跑三个状态机 + 超时兜底,栈给足;link 要收发帧与 JSON,最费。 */ + [TASK_ACTUATOR] = { "t_actuator", 0, PRIO_ACTUATOR, 4096 }, + [TASK_LINK] = { "t_link", 0, PRIO_LINK, 6144 }, + [TASK_LOG] = { "t_log", 0, PRIO_LOG, 3072 }, +}; + +const task_desc_t *task_desc(task_id_t id) +{ + assert(id >= 0 && id < TASK_COUNT); + return &s_tasks[id]; +} + +bool task_table_ok(void) +{ + for (int i = 0; i < TASK_COUNT; i++) { + const task_desc_t *a = &s_tasks[i]; + if (a->name == NULL || a->name[0] == '\0' || a->stack_bytes < 2048) { + return false; + } + for (int j = i + 1; j < TASK_COUNT; j++) { + if (strcmp(a->name, s_tasks[j].name) == 0) { + return false; + } + } + } + + /* 周期:§10.3 那一列,逐格钉死。 */ + if (s_tasks[TASK_SAFETY].period_ms != 20 || s_tasks[TASK_THERMAL].period_ms != 1000) { + return false; + } + if (s_tasks[TASK_ACTUATOR].period_ms != 0 || s_tasks[TASK_LINK].period_ms != 0 || + s_tasks[TASK_LOG].period_ms != 0) { + return false; + } + + /* 优先级:t_safety 唯一最高、t_log 唯一最低。 */ + for (int i = 0; i < TASK_COUNT; i++) { + if (i != TASK_SAFETY && s_tasks[i].prio >= s_tasks[TASK_SAFETY].prio) { + return false; + } + if (i != TASK_LOG && s_tasks[i].prio <= s_tasks[TASK_LOG].prio) { + return false; + } + } + if (s_tasks[TASK_THERMAL].prio != s_tasks[TASK_ACTUATOR].prio) { + return false; /* §10.3 里两个都是「中」 */ + } + if (s_tasks[TASK_LINK].prio >= s_tasks[TASK_THERMAL].prio) { + return false; + } + + /* 巡检周期要长于 INA226 的一帧转换时间(见 tasks.h 的最后一条)。 */ + if (s_tasks[TASK_SAFETY].period_ms * 1000u <= (uint32_t)INA226_CONV_US) { + return false; + } + return true; +} diff --git a/firmware/main/tasks.h b/firmware/main/tasks.h new file mode 100644 index 0000000..0adf0a0 --- /dev/null +++ b/firmware/main/tasks.h @@ -0,0 +1,58 @@ +/* 五个任务的那张表(需求 §10.3)。 + + §10.3 定了五个任务、各自的周期与优先级,还有一句最容易被写丢的话: + **`t_safety` 越界时直接操作 `EFUSE_EN` / `VBUS_EN`,不经任何队列或抽象层。** + 这张表在这里的意义不是「方便建任务」,而是让那五行**有一个能被自检查的形状** —— + 优先级顺序写反了不会报错,只会让巡检在总线忙的时候被挤到几十毫秒之后。 + + ---- 为什么周期与优先级要合在一张表里 ------------------------------------ + 因为它们是同一个判断的两半:`t_safety` 之所以是最高优先级,正是因为它有一个 + 20 ms 的硬节拍;`t_link` 之所以最低,正是因为它没有节拍(§10.2:失联不断电, + 上位机慢一点没有任何后果)。分成两处写,改了一半的那天没人看得出来。 + + ⚠️ 事件驱动的三个任务(actuator / link / log)在本表里 `period_ms == 0`。 + 它们的队列与消息定义在 A9(执行器)/ A12(命令)/ A11(日志)里,本包只搭骨架。 */ +#ifndef TASKS_H +#define TASKS_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + TASK_SAFETY = 0, /* 20 ms,最高:SW-2 / SW-8 判据 + 直接操作 EN 引脚 */ + TASK_THERMAL, /* 1 s,中:两路 NTC + 载板 LM75 → 风扇 PWM 闭环 */ + TASK_ACTUATOR, /* 事件驱动,中:断电重启 / 按键序列 / EDL 序列 */ + TASK_LINK, /* 事件驱动,低:上位机命令与遥测 */ + TASK_LOG, /* 空闲,最低:事件环形缓冲落盘 */ + TASK_COUNT, +} task_id_t; + +typedef struct { + const char *name; + uint32_t period_ms; /* 0 = 事件驱动 */ + int prio; /* FreeRTOS 优先级,越大越高 */ + uint32_t stack_bytes; /* ESP-IDF 的 xTaskCreate 收的是**字节**,不是字 */ +} task_desc_t; + +/* id 越界直接 abort。 */ +const task_desc_t *task_desc(task_id_t id); + +/* 启动自检的一部分(和 `rail_selftest()` 一样:不过就 abort,不降级)。 + 查的是**表本身的形状**,不碰硬件: + · 名字非空且不重复; + · `t_safety` 是**唯一**的最高优先级,`t_log` 是**唯一**的最低; + · thermal 与 actuator 同级(§10.3 都是「中」),link 严格低于它们、高于 log; + · 周期与 §10.3 逐格相符(20 ms / 1 s / 三个 0); + · `t_safety` 的周期必须长于 INA226 的一帧转换时间,否则每次读到的是同一帧 —— + 现象是「电流看起来非常稳」,而不是任何一种错误。 */ +bool task_table_ok(void); + +#ifdef __cplusplus +} +#endif + +#endif /* TASKS_H */ diff --git a/firmware/test/test_sched.c b/firmware/test/test_sched.c new file mode 100644 index 0000000..fcb1652 --- /dev/null +++ b/firmware/test/test_sched.c @@ -0,0 +1,272 @@ +/* A8 之一:节拍器(sched)、五任务表(tasks)、两把锁(hal_lock)。 + + 这三样都是「写错了也照跑」的东西:周期漂了、优先级反了、锁忘了放, + 板子上的现象都是「偶尔慢一点」。所以这里的断言全部对着**判据**, + 不对着「跑起来没崩」。 */ +#include + +#include "hal_lock.h" +#include "hal_time.h" +#include "ina226.h" +#include "sched.h" +#include "sim_hal.h" +#include "tasks.h" +#include "unity.h" + +void setUp(void) { sim_hal_reset(); } +void tearDown(void) {} + +/* ---- 节拍器:周期是「间隔」,不是「延时」 -------------------------------- */ + +/* 干活花掉 3 ms,节拍仍然是整 20 ms —— 这是 §10.3 的 20 ms 的定义。 + 写成「干完睡 20」的话这里会变成 23 ms。 */ +static void test_period_is_the_interval_not_the_delay(void) +{ + sched_periodic_t p; + sched_periodic_init(&p, 20); + + uint32_t prev = hal_time_now_ms(); + for (int i = 0; i < 100; i++) { + sim_time_advance_ms(3); /* 干活 */ + sched_periodic_wait(&p); + const uint32_t now = hal_time_now_ms(); + TEST_ASSERT_EQUAL_UINT32(20, now - prev); + prev = now; + } + TEST_ASSERT_EQUAL_UINT32(0, p.overruns); +} + +/* 干活时间**每圈都不一样**(1~19 ms),周期照样必须是整 20 ms。 + 固定耗时测不出漂移:漂移是「上一圈的耗时被加进下一圈」,等长时看不出来。 */ +static void test_jittery_work_does_not_move_the_phase(void) +{ + sched_periodic_t p; + sched_periodic_init(&p, 20); + + uint32_t work = 1; + for (int i = 0; i < 200; i++) { + sim_time_advance_ms(work); + work = (work * 7u) % 19u + 1u; /* 1~19 ms,绕着周期打转 */ + sched_periodic_wait(&p); + /* 每一次醒来都落在 20 ms 的格子上 */ + TEST_ASSERT_EQUAL_UINT32(0, hal_time_now_ms() % 20u); + } + TEST_ASSERT_EQUAL_UINT32(0, p.overruns); +} + +/* 一圈超时(干了 25 ms):计一次过载、不睡,而且**相位不丢** —— + 下一次仍然落在 20 ms 的格子上,不是从 25 ms 重新起算。 */ +static void test_overrun_keeps_the_phase_and_is_counted(void) +{ + sched_periodic_t p; + sched_periodic_init(&p, 20); + + sim_time_advance_ms(25); + TEST_ASSERT_EQUAL_UINT32(0, sched_periodic_wait(&p)); /* 不睡 */ + TEST_ASSERT_EQUAL_UINT32(1, p.overruns); + TEST_ASSERT_EQUAL_UINT32(5, p.worst_late_ms); + + TEST_ASSERT_EQUAL_UINT32(15, sched_periodic_wait(&p)); /* 睡到 t=40 */ + TEST_ASSERT_EQUAL_UINT32(40, hal_time_now_ms()); +} + +/* 卡了 105 ms(错过 5 个周期)之后**不补跑** —— 只跳过去,计一次过载。 + 补跑的现象是「卡一下之后连着打满总线」,见 sched.h。 */ +static void test_a_long_stall_skips_instead_of_catching_up(void) +{ + sched_periodic_t p; + sched_periodic_init(&p, 20); + + sim_time_advance_ms(105); + TEST_ASSERT_EQUAL_UINT32(0, sched_periodic_wait(&p)); + TEST_ASSERT_EQUAL_UINT32(1, p.overruns); /* 一次,不是五次 */ + + /* 下一拍是 t=120,也就是 15 ms 之后 —— 仍在 20 ms 的格子上。 */ + TEST_ASSERT_EQUAL_UINT32(15, sched_periodic_wait(&p)); + TEST_ASSERT_EQUAL_UINT32(120, hal_time_now_ms()); +} + +/* 32 位毫秒在 49.7 天回绕。裸写 `now >= next` 的话,回绕那一刻会被判成 + 「迟到了 49 天」,然后一口气跳掉两百万个周期。农场是长稳跑的,这一定会发生。 */ +static void test_millisecond_wraparound_is_just_another_tick(void) +{ + sim_time_set_us(0xFFFFFFF6ull * 1000ull); /* 距回绕 10 ms */ + sched_periodic_t p; + sched_periodic_init(&p, 20); + + for (int i = 0; i < 5; i++) { + const uint32_t slept = sched_periodic_wait(&p); + TEST_ASSERT_EQUAL_UINT32(20, slept); /* 跨过回绕点也还是 20 */ + TEST_ASSERT_EQUAL_UINT32(0, p.overruns); + } +} + +/* 纯计算入口要能不依赖时钟单独用(回绕、连续过载用真时钟很难摆出来)。 */ +static void test_delay_from_is_pure(void) +{ + sched_periodic_t p; + sched_periodic_init(&p, 20); /* now=0 → next=20 */ + TEST_ASSERT_EQUAL_UINT32(20, sched_periodic_delay_from(&p, 0)); + TEST_ASSERT_EQUAL_UINT32(1, sched_periodic_delay_from(&p, 39)); + TEST_ASSERT_EQUAL_UINT32(0, hal_time_now_ms()); /* 一点时间也没花 */ + TEST_ASSERT_EQUAL_UINT32(2, p.cycles); +} + +/* ---- 五个任务那张表(§10.3) --------------------------------------------- */ + +static void test_task_table_passes_selftest(void) +{ + TEST_ASSERT_TRUE(task_table_ok()); +} + +static void test_periods_match_section_10_3(void) +{ + TEST_ASSERT_EQUAL_UINT32(20, task_desc(TASK_SAFETY)->period_ms); + TEST_ASSERT_EQUAL_UINT32(1000, task_desc(TASK_THERMAL)->period_ms); + /* 三个事件驱动的没有周期 —— 给它们安一个周期就是把 §10.3 读错了。 */ + TEST_ASSERT_EQUAL_UINT32(0, task_desc(TASK_ACTUATOR)->period_ms); + TEST_ASSERT_EQUAL_UINT32(0, task_desc(TASK_LINK)->period_ms); + TEST_ASSERT_EQUAL_UINT32(0, task_desc(TASK_LOG)->period_ms); +} + +/* §10.3 的优先级列:最高 / 中 / 中 / 低 / 最低。 + 反了的现象是「总线一忙巡检就被挤后」,没有任何错误码。 */ +static void test_priority_order_is_safety_highest_log_lowest(void) +{ + const int safety = task_desc(TASK_SAFETY)->prio; + const int thermal = task_desc(TASK_THERMAL)->prio; + const int actuator = task_desc(TASK_ACTUATOR)->prio; + const int link = task_desc(TASK_LINK)->prio; + const int log = task_desc(TASK_LOG)->prio; + + TEST_ASSERT_GREATER_THAN_INT(thermal, safety); + TEST_ASSERT_GREATER_THAN_INT(actuator, safety); + TEST_ASSERT_EQUAL_INT(thermal, actuator); /* 两个都是「中」 */ + TEST_ASSERT_GREATER_THAN_INT(link, thermal); + TEST_ASSERT_GREATER_THAN_INT(log, link); +} + +/* 巡检周期必须长于 INA226 的一帧转换时间(8.8 ms),否则同一帧会被读两次。 + 现象是「电流特别稳」,不是任何一种错误。 */ +static void test_safety_period_outlasts_one_ina226_conversion(void) +{ + TEST_ASSERT_GREATER_THAN_UINT32(INA226_CONV_US, + task_desc(TASK_SAFETY)->period_ms * 1000u); +} + +static void test_every_task_has_a_name_and_a_real_stack(void) +{ + for (int i = 0; i < TASK_COUNT; i++) { + const task_desc_t *d = task_desc((task_id_t)i); + TEST_ASSERT_NOT_NULL(d->name); + TEST_ASSERT_TRUE(d->name[0] != 0); + TEST_ASSERT_GREATER_OR_EQUAL_UINT32(2048, d->stack_bytes); + for (int j = i + 1; j < TASK_COUNT; j++) { + TEST_ASSERT_NOT_EQUAL(0, strcmp(d->name, task_desc((task_id_t)j)->name)); + } + } +} + +/* ---- 两把锁 --------------------------------------------------------------- */ + +static void test_take_and_give_round_trip(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + TEST_ASSERT_TRUE(hal_lock_held(HAL_LOCK_I2C)); + hal_lock_give(HAL_LOCK_I2C); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); +} + +/* 没 init 就拿 —— 真板上那是个空句柄,不拦就是随机崩。 */ +static void test_take_before_init_is_refused(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_I2C, 10)); +} + +/* 同一把锁拿两次:这两把都不是递归锁,第二次必须被拒。 + 悄悄通过的话,第一次 give 就把互斥放掉了,而代码看上去还在临界区里。 */ +static void test_double_take_is_refused(void) +{ + (void)hal_lock_init(); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_I2C, 10)); + hal_lock_give(HAL_LOCK_I2C); +} + +/* 拿着一把去拿另一把:这块板上永远是写错了(595 是纯 GPIO,和 I²C 没交集), + 所以不定加锁顺序,直接判非法。见 hal_lock.h。 */ +static void test_nesting_the_two_locks_is_refused_both_ways(void) +{ + (void)hal_lock_init(); + + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_SR595, 10)); + hal_lock_give(HAL_LOCK_I2C); + + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_I2C, 10)); + hal_lock_give(HAL_LOCK_SR595); +} + +/* 别的任务持着的时候:等满超时、计一次数、返回 TIMEOUT, + 而且**把那段时间真的花掉** —— 调用方跳过这一周期的路径因此测得到。 */ +static void test_contention_times_out_and_costs_real_time(void) +{ + (void)hal_lock_init(); + sim_lock_set_task(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + + sim_lock_set_task(0); + const uint32_t t0 = hal_time_now_ms(); + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, hal_lock_take(HAL_LOCK_I2C, 5)); + TEST_ASSERT_EQUAL_UINT32(5, hal_time_elapsed_ms(t0)); + TEST_ASSERT_EQUAL_UINT32(1, hal_lock_timeouts(HAL_LOCK_I2C)); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); + + /* 1 号放掉之后 0 号才拿得到。 */ + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); + sim_lock_set_task(0); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 5)); + hal_lock_give(HAL_LOCK_I2C); +} + +/* 两把锁互不相干:拿着 I²C 不影响另一个任务去拿 595。 */ +static void test_the_two_locks_are_independent(void) +{ + (void)hal_lock_init(); + sim_lock_set_task(1); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(2); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10)); + TEST_ASSERT_EQUAL_INT(1, sim_lock_owner(HAL_LOCK_I2C)); + TEST_ASSERT_EQUAL_INT(2, sim_lock_owner(HAL_LOCK_SR595)); + hal_lock_give(HAL_LOCK_SR595); + sim_lock_set_task(1); + hal_lock_give(HAL_LOCK_I2C); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_period_is_the_interval_not_the_delay); + RUN_TEST(test_jittery_work_does_not_move_the_phase); + RUN_TEST(test_overrun_keeps_the_phase_and_is_counted); + RUN_TEST(test_a_long_stall_skips_instead_of_catching_up); + RUN_TEST(test_millisecond_wraparound_is_just_another_tick); + RUN_TEST(test_delay_from_is_pure); + RUN_TEST(test_task_table_passes_selftest); + RUN_TEST(test_periods_match_section_10_3); + RUN_TEST(test_priority_order_is_safety_highest_log_lowest); + RUN_TEST(test_safety_period_outlasts_one_ina226_conversion); + RUN_TEST(test_every_task_has_a_name_and_a_real_stack); + RUN_TEST(test_take_and_give_round_trip); + RUN_TEST(test_take_before_init_is_refused); + RUN_TEST(test_double_take_is_refused); + RUN_TEST(test_nesting_the_two_locks_is_refused_both_ways); + RUN_TEST(test_contention_times_out_and_costs_real_time); + RUN_TEST(test_the_two_locks_are_independent); + return UNITY_END(); +} diff --git a/firmware/test/test_t_safety.c b/firmware/test/test_t_safety.c new file mode 100644 index 0000000..362322c --- /dev/null +++ b/firmware/test/test_t_safety.c @@ -0,0 +1,320 @@ +/* A8 之二:`t_safety` 的一个周期,以及需求 §10.3 的两条验收判据 —— + ① sim 时钟下周期是 20 ms ±1 ms; + ② I²C 占用打印出来,与 §10.3 手算的「约 1.5 ms / 约 8 %」对得上。 + + ⚠️ 这两条能成立,靠的是 sim 的假 I²C **真的把虚拟时钟花掉** + (`sim_i2c_set_bus_time`,默认开)。没有那一条,巡检在 sim 里耗时为零, + 「20 ms 周期」就退化成「测 delay 参数传对了没」—— 那是句空话: + 真正会出事的是「巡检自己占掉了 1.5 ms,节拍还准不准」。 */ +#include +#include + +#include "hal_i2c.h" +#include "hal_i2c_acct.h" +#include "hal_lock.h" +#include "hal_time.h" +#include "ina226.h" +#include "pca9555.h" +#include "rail_cfg.h" +#include "sched.h" +#include "sim_hal.h" +#include "t_safety.h" +#include "tasks.h" +#include "unity.h" + +#define N PHONERAIL_RAIL_COUNT + +/* 一圈发出去的事务数:6 片 INA226 各 2 次寄存器读 + PCA9555 一次。 */ +#define TXNS_PER_CYCLE (INA226_COUNT * 2 + 1) + +/* 把假板子搭起来:6 片 INA226 + 1 片 PCA9555,地址全部**查表**取。 */ +static void board_up(void) +{ + const hal_i2c_cfg_t cfg = { .sda_gpio = 14, .scl_gpio = 15, .freq_hz = 400000 }; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init()); + + for (int rail = 1; rail <= N; rail++) { + sim_i2c_dev_t *d = sim_i2c_add(rail_cfg(rail)->ina_addr, 2); + d->regs[INA226_REG_BUS_V] = 3120; /* 3.9 V ÷ 1.25 mV */ + d->regs[INA226_REG_CURRENT] = (uint16_t)(rail * 1000); /* 每路不同,好认 */ + } + sim_i2c_add(INA226_ADDR_INPUT, 2); + sim_pca9555_attach(PCA9555_ADDR); +} + +void setUp(void) +{ + sim_hal_reset(); + board_up(); +} +void tearDown(void) {} + +/* ---- 验收主项一:20 ms 的节拍 -------------------------------------------- */ + +/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻 + (µs 级)对理想时刻的偏差始终 < 1 ms —— 这就是「20 ms ±1 ms」。 + 会漂的实现(干完睡 20)在这里第一圈就出局:那样周期是 21.56 ms, + 500 圈之后差出 780 ms。 */ +static void test_safety_holds_a_20ms_beat_for_500_cycles(void) +{ + safety_snap_t snap; + t_safety_init(&snap); + + sched_periodic_t tick; + sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); + + /* 先确认「干活」在 sim 里真的花掉了时间。不然下面 500 圈测的只是 + 「delay 参数传对了没」—— 巡检耗时为零的话,什么实现都能踩准 20 ms。 */ + const uint64_t t0 = hal_time_now_us(); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT64(t_safety_budget_us(), hal_time_now_us() - t0); + sched_periodic_wait(&tick); + + uint32_t prev_ms = hal_time_now_ms(); + for (int i = 1; i < 500; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + sched_periodic_wait(&tick); + + const uint32_t now_ms = hal_time_now_ms(); + TEST_ASSERT_EQUAL_UINT32(20, now_ms - prev_ms); + prev_ms = now_ms; + + /* 理想时刻是 (i+1)×20 ms;实际唤醒的 µs 与它相差不到一个毫秒格。 */ + const int64_t ideal_us = (int64_t)(i + 1) * 20000; + const int64_t err_us = (int64_t)hal_time_now_us() - ideal_us; + TEST_ASSERT_TRUE(err_us > -1000 && err_us < 1000); + } + TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); + TEST_ASSERT_EQUAL_UINT32(500, snap.cycles); + TEST_ASSERT_EQUAL_UINT32(0, snap.skipped); +} + +/* ---- 验收主项二:I²C 占用 ------------------------------------------------ */ + +/* §10.3 手算:一次寄存器读 ≈46 个位周期,12 次 INA + 1 次 PCA ≈1.5 ms / 20 ms ≈8 %。 + 这里按线上真实发生的字节数算,得 48 位/次 × 13 = 624 位 = 1560 µs = 7.8 %。 + 差的两位是 STOP 和一个重复 START,§10.3 没数进去(hal_i2c_acct.h 里记了这件事)。 */ +static void test_i2c_load_per_cycle_matches_section_10_3(void) +{ + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + + TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2)); + TEST_ASSERT_EQUAL_UINT32(1560, snap.bus_us); + + const uint32_t pm = t_safety_load_permille(snap.bus_us, + task_desc(TASK_SAFETY)->period_ms); + TEST_ASSERT_EQUAL_UINT32(78, pm); + /* §10.3 的「约 8 %」:7.0 %~8.5 % 都算对得上,超出就是巡检里多读/少读了东西。 */ + TEST_ASSERT_TRUE(pm >= 70 && pm <= 85); + + char line[128]; + const int n = t_safety_format(&snap, line, sizeof(line)); + TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line)); + TEST_ASSERT_NOT_NULL(strstr(line, "bus=1560 us/20 ms")); + TEST_ASSERT_NOT_NULL(strstr(line, "load=7.8%")); + printf(" %s(§10.3 手算:约 1.5 ms / 约 8 %%)\n", line); +} + +/* 静态预算与实测必须一模一样。不一样只有两种可能:巡检里多读/少读了东西, + 或者别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。 + (这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */ +static void test_measured_bus_time_equals_the_static_budget(void) +{ + safety_snap_t snap; + t_safety_init(&snap); + for (int i = 0; i < 10; i++) { + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), snap.bus_us); + } +} + +/* 一圈正好 13 次事务,而且每片器件被读的次数是钉死的 —— + 多读一次寄存器(比如顺手 check 一下 Calibration)会当场变红, + 然后写的人才会想起来去把预算那一行也改了。 */ +static void test_one_cycle_is_exactly_thirteen_transactions(void) +{ + safety_snap_t snap; + t_safety_init(&snap); + const uint32_t before = hal_i2c_acct_txns(); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT32(TXNS_PER_CYCLE, hal_i2c_acct_txns() - before); + + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(rail_cfg(rail)->ina_addr)->n_read); + } + TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read); + TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_dev(PCA9555_ADDR)->n_read); +} + +/* 五路读的是**表里的**地址。挂一片在「0x40+ch 算出来」的地址上, + 它一次也不该被碰到 —— 这是 A7 那条禁令在巡检路径上的回归拦网。 */ +static void test_rails_are_read_by_table_not_by_formula(void) +{ + sim_i2c_dev_t *wrong = sim_i2c_add(0x46, 2); /* 谁都不是的地址 */ + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT32(0, wrong->n_read); + TEST_ASSERT_EQUAL_UINT32(0, wrong->n_write); + + /* 顺带确认值确实按路对上了(每路电流不同)。 */ + for (int rail = 1; rail <= N; rail++) { + TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]); + TEST_ASSERT_EQUAL_INT32(rail * 1000 * INA226_CURRENT_LSB_UA, + snap.rail[rail - 1].current_ua); + } +} + +/* ---- 加锁纪律 ------------------------------------------------------------- */ + +/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。 */ +static void test_every_transaction_happens_under_the_i2c_lock(void) +{ + sim_lock_require(HAL_LOCK_I2C, true); + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C)); + TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */ + TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C)); +} + +/* 上一条不是句空话:不拿锁直接读一片,违规计数必须 +1。 + (检查本身失灵的话,上一条会永远绿。) */ +static void test_the_lock_check_itself_can_fail(void) +{ + sim_lock_require(HAL_LOCK_I2C, true); + ina226_vi_t vi; + (void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi); + TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */ +} + +/* 拿不到锁:**跳过这一圈**,不死等(t_safety.h 规矩 1)。 + 等掉的是 5 ms,还在 20 ms 之内,所以节拍不丢。 */ +static void test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever(void) +{ + sim_lock_set_task(1); /* 假装 t_link 正在用总线 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10)); + sim_lock_set_task(0); + + safety_snap_t snap; + t_safety_init(&snap); + const uint32_t t0 = hal_time_now_ms(); + const uint32_t txns = hal_i2c_acct_txns(); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT32(1, snap.skipped); + TEST_ASSERT_EQUAL_UINT32(1, snap.cycles); + TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns); /* 一次事务也没发 */ + TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_TIMEOUT_MS, hal_time_elapsed_ms(t0)); + TEST_ASSERT_TRUE(T_SAFETY_LOCK_TIMEOUT_MS < task_desc(TASK_SAFETY)->period_ms); +} + +/* ---- 器件出问题的时候(§10.2:失联 ≠ 断电) ------------------------------ */ + +/* 六片里坏一片,剩下五片照读、PCA 照读。中断这一圈的话, + 现象是「一片 INA226 掉线,五路判据全瞎」。 */ +static void test_one_dead_device_does_not_stop_the_round(void) +{ + sim_i2c_set_present(rail_cfg(3)->ina_addr, false); + + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&snap)); + + TEST_ASSERT_FALSE(snap.rail_ok[2]); + for (int rail = 1; rail <= N; rail++) { + if (rail != 3) { + TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]); + } + } + TEST_ASSERT_TRUE(snap.input_ok); + TEST_ASSERT_TRUE(snap.io_ok); + TEST_ASSERT_EQUAL_UINT32(1, snap.i2c_errors); +} + +/* 读失败时保留**上一圈的值**并把 ok 标成 false —— 不清零。 + 清零的话,A10 的过压判据会看到一个 0 V 的「正常」电压, + 而 ok 标志正是让它先看一眼「这数是不是新的」。 */ +static void test_a_failed_read_keeps_the_stale_value_and_flags_it(void) +{ + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + const int32_t good_mv = snap.rail[0].bus_mv; + TEST_ASSERT_EQUAL_INT32(3900, good_mv); + + sim_i2c_set_present(rail_cfg(1)->ina_addr, false); + TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&snap)); + TEST_ASSERT_FALSE(snap.rail_ok[0]); + TEST_ASSERT_EQUAL_INT32(good_mv, snap.rail[0].bus_mv); /* 还是上一圈那个 */ +} + +/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,但占得更少)。 + ⚠️ 这条测的是「巡检不会因为总线出错就乱了节奏」。 */ +static void test_the_beat_survives_a_dead_device(void) +{ + sim_i2c_set_present(rail_cfg(2)->ina_addr, false); + + safety_snap_t snap; + t_safety_init(&snap); + sched_periodic_t tick; + sched_periodic_init(&tick, 20); + + uint32_t prev = hal_time_now_ms(); + for (int i = 0; i < 50; i++) { + (void)t_safety_step(&snap); + sched_periodic_wait(&tick); + TEST_ASSERT_EQUAL_UINT32(20, hal_time_now_ms() - prev); + prev = hal_time_now_ms(); + } + TEST_ASSERT_EQUAL_UINT32(0, tick.overruns); + TEST_ASSERT_EQUAL_UINT32(50, snap.i2c_errors); +} + +/* PCA9555 的输入被读进来了,而且语义判读能用(A10 要拿它做判据)。 */ +static void test_pca9555_inputs_land_in_the_snapshot(void) +{ + sim_pca9555_drive(0, 0xFF & ~(1u << pca9555_flt_bit(4))); /* CH4 的 FLT# 拉低 */ + + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + + TEST_ASSERT_TRUE(snap.io_ok); + TEST_ASSERT_TRUE(pca9555_rail_fault(&snap.io, 4)); + TEST_ASSERT_FALSE(pca9555_rail_fault(&snap.io, 1)); +} + +/* 第一圈的占用里不该混进启动期间的事务(游标要在 init 时对齐到「现在」)。 */ +static void test_startup_traffic_is_not_charged_to_the_first_cycle(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(PCA9555_ADDR)); /* 假装启动扫了一下 */ + + safety_snap_t snap; + t_safety_init(&snap); + TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap)); + TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), snap.bus_us); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles); + RUN_TEST(test_i2c_load_per_cycle_matches_section_10_3); + RUN_TEST(test_measured_bus_time_equals_the_static_budget); + RUN_TEST(test_one_cycle_is_exactly_thirteen_transactions); + RUN_TEST(test_rails_are_read_by_table_not_by_formula); + RUN_TEST(test_every_transaction_happens_under_the_i2c_lock); + RUN_TEST(test_the_lock_check_itself_can_fail); + RUN_TEST(test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever); + RUN_TEST(test_one_dead_device_does_not_stop_the_round); + RUN_TEST(test_a_failed_read_keeps_the_stale_value_and_flags_it); + RUN_TEST(test_the_beat_survives_a_dead_device); + RUN_TEST(test_pca9555_inputs_land_in_the_snapshot); + RUN_TEST(test_startup_traffic_is_not_charged_to_the_first_cycle); + return UNITY_END(); +} diff --git a/需求文档.md b/需求文档.md index 7ac8eb2..933f03d 100644 --- a/需求文档.md +++ b/需求文档.md @@ -1,824 +1,851 @@ -# 五路手机直供电源板 —— 项目需求文档 - -| 项 | 值 | -|---|---| -| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收
v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 | -| 项目代号 | PhoneRail | -| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) | -| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) | - -> **本文档与设计文档的分工** -> 本文只回答 **「要做成什么样、凭什么算做到了」**; -> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。 -> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的), -> **需求的取舍与验收标准以本文为准**。 -> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。 -> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点, -> 暂放本文,理由写在该节开头。 - ---- - -## 1. 背景与目标 - -### 1.1 要解决的问题 - -手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题: - -1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患; -2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键; -3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。 - -### 1.2 项目目标 - -做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、 -稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、 -USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。 - -**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启, -任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。 - -### 1.3 目标用户与使用场景 - -| 用户 | 场景 | -|---|---| -| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 | -| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 | -| 本项目自身 | 首板调试、长稳测试、失效注入验证 | - ---- - -## 2. 范围 - -### 2.1 交付范围(In Scope) - -| # | 交付物 | -|---|---| -| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 | -| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 | -| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) | -| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) | -| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) | -| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。上位机侧的调用方由农场既有系统实现(N7),本项目交付板侧协议与实现,见 §10.8 | -| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 | - -### 2.2 明确不做(Out of Scope) - -| # | 不做 | 理由 | -|---|---|---| -| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 | -| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) | -| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 | -| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V | -| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) | -| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 | -| N7 | 上位机管理软件 / Web 界面 | 本期只交付固件与命令接口,上层由农场既有系统对接 | - ---- - -## 3. 系统组成(需求视角) - -```diagram=system -12V/10A 适配器 - │ - ┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐ - │ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │ - │ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │ - │ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │ - └────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘ - ┌────┴─────────────── 上板(载板)────────────────────────────┐ - │ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │ - │ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │ - │ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │ - └──────────────────────────────────────────────────────────────┘ -``` - -两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。 - ---- - -## 4. 功能需求(FR) - -| ID | 需求 | 优先级 | 设计出处 | -|---|---|---|---| -| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 | -| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 | -| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 | -| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 | -| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 | -| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 | -| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | -| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | -| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | -| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | -| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | -| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | -| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | -| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 | -| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 | - -### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值) - -固件必须实现**两级重启**,且**先软后硬**: - -| 级 | 动作 | 触发条件 | 要求 | -|---|---|---|---| -| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 | -| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 | - -> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全 -> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。 - ---- - -## 5. 电气与性能需求(PR) - -| ID | 参数 | 要求 | 出处 | -|---|---|---|---| -| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 | -| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 | -| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 | -| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 | -| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 | -| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 | -| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 | -| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 | -| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 | -| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 | -| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 | -| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 | -| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 | -| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 | -| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 | - -### 5.1 输出上限为什么定在 4.01 V(这条需求的来源) - -去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。 -4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** —— -若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠, -坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。 - -**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。** - ---- - -## 6. 安全需求(SR)—— 本项目最高优先级 - -> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是 -> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。 -> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。 - -| ID | 需求 | 出处 | -|---|---|---| -| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 | -| **SR-2** | **第一层 · 拓扑限幅**:分压网络与 DAC 量程在物理上把输出限死在 3.42 ~ 4.12 V(含最坏容差),全部落在正常锂电电压内。防固件 bug / I²C 误码 / MCU 跑飞 | §4 | -| **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 | -| **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 | -| **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | -| **SR-6** | **复位安全**:ESP32 复位 / 上电期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件 | §8.1 / §22.4 | -| **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | -| **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | -| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | -| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | -| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | -| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | -| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | -| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 | -| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 | - -> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。** -> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。 -> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。 - -> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。** -> 按名字当地用会直接短路电池。 - ---- - -## 7. 接口需求(IF) - -> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 —— -> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识, -> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。 - -### 7.1 手机侧接口(每台一套) - -| ID | 接口 | 要求 | 出处 | -|---|---|---|---| -| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 | -| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 | -| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 | -| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 | -| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 | -| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 | - -> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那 -> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。 - -> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。 - -### 7.2 板对板接口 - -| ID | 需求 | 出处 | -|---|---|---| -| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 | -| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 | -| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 | -| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 | - -### 7.3 对外接口 - -| ID | 接口 | 要求 | -|---|---|---| -| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 | -| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | -| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | -| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | -| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | - -> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, -> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 - -### 7.4 内部数字接口 - -| ID | 需求 | -|---|---| -| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 | -| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 | -| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** | -| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 | - ---- - -## 8. 机械与热需求(MR / TR) - -| ID | 需求 | 出处 | -|---|---|---| -| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 | -| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 | -| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 | -| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 | -| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 | -| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 | -| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 | -| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 | -| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 | -| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 | -| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 | - ---- - -## 9. 固件需求(SW) - -> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。** - -### 9.1 必做 - -| ID | 需求 | -|---|---| -| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | -| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >±100 mV 持续 20 ms 即关路告警(SR-5) | -| **SW-3** | 过流 / 故障事件记录:路号、时刻、电流值 —— 排查排线接触不良的唯一线索 | -| **SW-4** | 单路电压微调接口(跑不稳的手机提到 4.0 V,或反向降压降温),**上限受 SR-13 约束** | -| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 2 s 开机 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery | -| **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | -| **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | -| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | -| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | -| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | -| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 | -| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 | - -### 9.2 明确禁止 - -| ID | 禁止事项 | 理由 | -|---|---|---| -| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 | -| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 | -| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 | -| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 | - ---- - -## 10. 软件架构设计 - -> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有 -> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的 -> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。 -> 硬件的「怎么做」仍然一律在设计文档。 - -### 10.1 分层与职责边界 - -```diagram=layers -┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐ -│ adb 探活 / 业务调度 / 崩溃判定 │ -└───────┬────────────────────────────────┬──────────────────────────┘ - │ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定) - ▼ ▼ - 5 台小米8 主板 ┌─────────────────────┐ - ▲ │ PhoneRail 控制器 │ - │ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │ - └──────────────────────────┤ 遥测 · 执行 · 记录 │ - └──────────┬──────────┘ - │ 只能建议,不能否决 - ┌───────────────▼───────────────┐ - │ 硬实时层:eFuse OVP / 限流 │ - │ 放电 MOS 反相联锁 / 上下拉 │ - │ —— 不含任何软件(SR-3、SR-7) │ - └───────────────────────────────┘ -``` - -**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置 -(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径 -是「不跑到就会烧手机」的。因此: - -- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; -- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; -- 固件可以随时被重启、被降级运行,硬件安全性不变。 - -### 10.2 架构的第一个判断:失联 ≠ 断电 - -| 状态 | 输出该怎么办 | 为什么 | -|---|---|---| -| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | -| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | -| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | - -⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。 -失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。 - -**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、 -按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。 - -### 10.3 任务划分(FreeRTOS / ESP-IDF) - -| 任务 | 周期 | 优先级 | 职责 | 失败后果 | -|---|---|---|---|---| -| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) | -| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 | -| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 | -| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 | -| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 | - -**共享资源只有两个**,各配一把互斥锁: - -1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。 -2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。 - -**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户): - -``` -一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs -6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms -PCA9555 读两个输入端口 ≈ 0.12 ms - 合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用 -``` - -余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次** -——这个数是按理想时序算的,没算时钟延展和重试。 - -### 10.4 五路一张表:禁止用通道号算任何东西 - -这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: - -- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2); -- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4); -- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。 - -所以固件里**只允许有一张静态配置表**,全部五路循环都索引它: - -```c -typedef struct { - uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ - uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */ - uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */ - gpio_num_t efuse_en; /* GPIO 直连 */ - gpio_num_t vbus_en; /* GPIO 直连 */ - uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */ - uint8_t edl_bit; /* U33 上的位号 */ - uint8_t flt_bit; /* PCA9555 IO0_0..4 */ - uint8_t pg_bit; /* PCA9555 IO1_0..4 */ -} rail_cfg_t; -``` - -> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。** -> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。 - -> **A3 已落地**:表在 `firmware/components/drv/include/ina226.h`,取值只走 -> `ina226_rail_addr(rail)`(rail 是 1~5,越界 abort), -> `test_channel_index_is_not_the_address_low_bits` 专门拦「改回算式」这一手。 -> 同处还记了两条 INA226 的实现约定:**寄存器指针不自增**(每次读前重写指针, -> sim 会自增、真件不会,靠自增在宿主机上测不出来);**Calibration 丢了不报错、 -> 只让 Current/Power 恒读 0**,所以巡检要定期 `ina226_check()`。 - -同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上 -直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。 - -> **A6 已落地**:驱动在 `firmware/components/drv/include/pca9555.h`, -> **根本不提供「把某个脚配成输出」的接口**(和 sr595.h 不提供「抬 OE#」同一条规矩)。 -> 方向字一律过 `pca9555_dir_safe()`,不安全直接 abort。 -> 同处还记了四条这一片的实现约定: -> ① ⚠️ **Output Port 寄存器的复位值是 0xFF** —— 所以把接地脚配成输出的那一刻它就 -> 输出高电平,中间没有「先写个 0 再说」的余地,这比「配错方向」本身更急; -> ② **极性反转寄存器必须无条件清零**,不能赌复位值:看门狗只复位 ESP32, -> PCA9555 不掉电,上一条命的残留值原样留着,而写错的现象是「五路同时报故障」 -> 或者更糟的「五路故障全都看不见」,I²C 一切正常; -> ③ **三种极性**(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在 -> `pca9555_rail_fault()` 等四个函数里出现一次,上层不许自己去 & 位; -> ④ **INT# 是加速路径不是主路径**:它要读走输入端口才释放,漏读一次就再也等不到 -> 下一个沿,所以 `t_safety` 必须无条件轮询(§10.3 的 20 ms 就是按这个排的)。 -> -> ⚠️ **FLT 与 PG 两张位表这次确实是顺排的**(都是 `bit = rail - 1`), -> 和 INA226 地址、595 位号那两张表不一样。所以这里最容易写出来的错不是「算式」, -> 而是**把 Port0 和 Port1 搞反** —— 两个端口的 bit0~4 长得一模一样,搞反之后 -> 「有故障」会被读成「电源良好」,方向正好相反,而且**静息态两个端口读回来都是 -> 全 1,单看一帧完全看不出来**。`test_one_rail_at_a_time` 专门拦这一手。 - -> **A7 已落地**(2026-09-07):那张 `rail_cfg_t` 表在 `firmware/main/rail_cfg.h` / -> `rail_cfg.c`,取值只走 `rail_cfg(rail)`(1~5,越界 abort)。六列的样子: -> -> | CH | INA226 | DAC | 按键位 PWR/VUP/VDN | EDL 位 | EFUSE_EN | VBUS_EN | -> |---|---|---|---|---|---|---| -> | 1 | 0x40 | U6.OUT2 | 1 / 2 / 3 | 16 | IO39 | IO40 | -> | 2 | 0x41 | U6.OUT0 | 19 / 17 / 18 | 7 | **IO48** | IO10 | -> | 3 | 0x44 | **U7**.OUT0 | 22 / 21 / 20 | 6 | IO6 | IO11 | -> | 4 | 0x45 | U6.OUT1 | 9 / 10 / 11 | 0 | IO41 | IO42 | -> | 5 | 0x42 | U7.OUT1 | 15 / 14 / 13 | 5 | IO8 | IO17 | -> -> 落地时定下来的五条: -> ① **本表与四个驱动那四张表是「两处写同一个数」,所以要对账而不是现算。** -> 开机自检 `rail_selftest()` 逐格拿本表和 `ina226_rail_addr()` 等取值函数比一遍, -> 对不上就报 `RAIL_SELFTEST_TABLE`。做成「开机从驱动现算」的话按定义就查不出分家, -> 而且表不再是 const 的、用之前得先记着初始化。 -> ② **判据本身也要被测**:真表是 const 的,测试改不动它,所以判据单拎成 -> `rail_selftest_table(t)`,测试喂几张故意写坏的表进去看它认不认 -> (分家 / 引脚重复 / 撞 SDA / strapping / 不存在的号 / 越界,六种都验过)。 -> ③ **两列 GPIO 是全固件唯一一处出处**,没有第二张表能对账 —— 所以 -> 「`EFUSE_EN1` 和 `VBUS_EN1` 写反」这种错自检**查不出来**(两个都是合法空闲脚、 -> 不重复、不越界)。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的 -> 十个值(出处:`.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 -> ④ **「不是顺排」要看整列,不能逐格撞算式**:CH4 的按键位正好是 9/10/11、 -> CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好等于「IO4 起 / IO9 起」那个算式 —— -> 单看任何一路都「像顺排」。测试改成断言整列**不是等差数列**。 -> ⑤ ⚠️ **HAL 两侧的 GPIO 上限原来是 `pin < 48`,把 IO48 关在了门外**, -> 而 `EFUSE_EN2` 就在 IO48 上 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 -> 已改成 0~48(含)。ESP32-S3 的编号是连续的但芯片不是:IO22~IO25 不存在、 -> IO26~IO32 是模组内部 flash/PSRAM,写进配置表 `gpio_config()` 不报错也不干活, -> 所以自检把这些号也当成越界。 -> -> 同处还落地了**全板引脚表** `firmware/main/board_pins.h`(§8.1 那张表里 -> 「全板独一份」的那些脚)与两根 EN 线的表驱动遍历 -> (`rail_en_init()` / `rail_efuse_enable()` / `rail_vbus_enable()` / `rail_all_off()`)。 -> `rail_all_off()` **先断五路 VBUS 再关五路 eFuse**,中间不留「手机只靠 USB -> 供着电」的窗口;十个脚全部试一遍,不因为某一个失败就提前返回。 -> 高有效这件事只在 `en_write()` 里出现一次。 - -### 10.5 电压通道:DAC 只做微调,而且是反向的 - -| 事实 | 含义 | -|---|---| -| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** | -| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 | -| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) | - -> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是 -> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电, -> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口, -> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。 - -三条固件规矩: - -1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。 - 注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。 -2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。 -3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出, - 做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。 - -> **A4 已落地**:换算与设定在 `firmware/main/rail.c`,唯一入口 `rail_set_mv(rail, mv)` -> (rail 是 **1~5**)。三个验收点各有一条测试:4020 mV 被拒、码大电压低、3900 mV ↔ Vd 0.548 V。 -> 标定(本节规矩 3)**还没做**,现在走的是理论公式;`RAIL_VOUT0_UV` / `RAIL_DAC_SPAN_UV` -> 两个常量就是将来要被标定值顶替的地方。 -> -> 同处记下三条 DAC63204 的实现约定(出处 SLASEX3A): -> - **12 位码在 `DAC-X-DATA`(19h~1Ch) 里是左对齐的**(bit15-4)。忘了 `<< 4` 不报错, -> 只让输出变成 1/16 —— 而在本板上那表现为「五路都是 4.010 V 满电」,看不出异常。 -> - **`EN-INT-REF` 要先于增益设定写**,且**上电安全码要写在通道上电之前**: -> 复位码是 0,而 Vd = 0 就是输出上限 4.010 V。`dac63204_init()` 因此把 -> `COMMON-CONFIG` 写两次(先按住输出,写完码再上电)。 -> - **通道分配不是 `rail−1`**(设计文档 §26.18 重排过,五路里四路对不上): -> CH1→U6.OUT2 / CH2→U6.OUT0 / CH3→**U7**.OUT0 / CH4→U6.OUT1 / CH5→U7.OUT1。 -> 四通道电气对等 ⇒ 接错了照样工作,只是调错路,功能测试测不出来。 - -### 10.6 执行器:三个状态机 + 一个影子寄存器 - -**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个): - -| 序列 | 时序 | 关键约束 | -|---|---|---| -| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS | -| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 | -| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB | - -**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位 -移位链**上,写任何一位都要把整条链重发一遍。 - -``` -ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33) -写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效 -``` - -规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键 -或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手 -清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。 - -> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#` -> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉, -> 是复位路径,不是正常控制路径。 - -> **2026-09-06 A5 落地**:`components/drv/sr595.{h,c}`。影子是一个 24 位整数 -> (bit0 = U21.QA 链首 … bit23 = U33.QH 链尾),唯一写入点是 `sr595_flush()`; -> 别处只准 `sr595_modify()` 改影子,组合键走 `sr595_write(mask, value)` 一帧发完。 -> 位表见设计文档 §17.2(§26.18 重排后的那张,**不是 09-04 那张**)。 -> -> 同处记下三条这一层的实现约定: -> - **上电顺序是「先锁存安全值,再使能 `OE#`」,不能换。** 三片的 `MR#` 接死在 +3V3, -> 没有复位线 ⇒ 上电时移位寄存器内容**不确定**。先拉低 `OE#` 就是把那坨随机值 -> 放到 15 个栅极上 —— 五台手机的电源键可能一起被按下去。所以 `sr595_init()` 里 -> `KEY_OE#` 在安全值锁存完之前**根本不配成输出**,靠板上 R609 上拉着。 -> (ESP-IDF 的 `gpio_config()` 一配成输出就开始驱动,输出寄存器复位值是 0。) -> - **`HUB_RST` 低有效**,所以上电安全影子是 `1 << 23` 而不是 0。写成 0 的现象是 -> 「Hub 插上枚举不出来」,而五路电压电流全都正常,看不出跟固件有关。 -> - **发送失败要留着脏**(`sr595_flush()` 失败不清 dirty,下次重发整帧)。 -> 丢了脏标志 = 链上和影子永久对不上,而后面每一次 flush 都会「成功」。 - -### 10.7 事件记录与存储 - -事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。 - -| 项 | 设计 | -|---|---| -| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 | -| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 | -| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) | -| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 | -| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) | - -必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 -每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 - -### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) - -**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」, -而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。 -这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。 - -**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。** - -选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值, -而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」 -共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。 - -留档备查的两个单选方案: - -| 方案 | 优点 | 为什么不单独用 | -|---|---|---| -| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 | -| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 | - -#### 两条链路怎么共存 - -```diagram=links - WiFi (MQTT/HTTP) USB CDC (J22) - │ │ - ┌───────▼────────┐ ┌────────▼───────┐ - │ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧 - └───────┬────────┘ └────────┬───────┘ - └──────────┬─────────────┘ - ┌──────▼───────┐ - │ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里 - └──────┬───────┘ - ▼ - 执行器 / 遥测 -``` - -四条规矩: - -1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束 - (SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 —— - 否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。 -2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。 - 做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。 -3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号, - 板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时, - 第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。 -4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是 - CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。 - 代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行 - JSON,日志走另一个前缀),否则日志一刷就把响应冲散。 - -> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— -> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 - -命令语义与链路无关,两条链路完全一致: - -| 命令 | 参数 | 说明 | -|---|---|---| -| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 | -| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) | -| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) | -| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 | -| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 | -| `vbus` | 路号, on/off | 默认断开 | -| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 | -| `events` | 起始序号 | 增量拉取事件日志 | - -三条接口硬约束: - -- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令, - 它压根不该存在于发布固件里。 -- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。 -- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。 - -### 10.9 启动顺序 - -``` -1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次) -2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动 -3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对 -4 载入 NVS 里的标定值与上次的每路使能状态 -5 逐路上电,**路与路之间错开 100 ms** -6 等各路 PG,超时则记事件并保持该路关闭 -7 起风扇闭环,起链路任务 -``` - -> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A -> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后 -> 各路的斜坡(11.5 ms)完全不重叠。 - -### 10.10 降级矩阵 - -| 失效 | 检测方式 | 行为 | -|---|---|---| -| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 | -| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 | -| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 | -| PCA9555 无应答 | I²C NAK | 失去 FAULT / PG 可见性;改用 INA226 的电压判据兜底 | -| 载板 LM75BD 无应答 | I²C NAK | 多半是排针接触不良(它在排针另一侧)→ **风扇转到 100 %** 并告警,不要按「读不到温度=不热」处理 | -| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** | -| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 | - -**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令, -持续告警。宁可五台手机不开机,也不要在盲态下带电跑。 - -### 10.11 关于 FR-12「模拟电量曲线」的一个说明 - -这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从 -电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」 -= **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。 - -两个后果: - -- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器; -- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 —— - 否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。 - -FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。 - -### 10.12 架构层面的待定项 - -| 项 | 状态 | -|---|---| -| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 | -| **OTA 升级** | 既然定了 WiFi,**建议做**:双分区 + 失败回滚。没有 OTA 的话一次坏固件要把板子从机架上拆下来插 USB,而 USB 兜底链路本身也能烧 —— 所以这是「省事」不是「救命」,可以排在首版之后 | -| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 | -| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 | -| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) | -| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 | - ---- - -## 11. 约束与假设 - -### 11.1 硬约束 - -| ID | 约束 | -|---|---| -| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 | -| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 | -| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 | -| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 | -| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 | -| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 | -| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** | - -### 11.2 假设(若不成立需重新评估) - -| ID | 假设 | -|---|---| -| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) | -| **A-2** | 手机关机待机电流在 ≈50 µA 量级 | -| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) | -| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 | - ---- - -## 12. 验收准则 - -### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**) - -完整步骤与通过标准见设计文档 **§11**。其中: - -- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)** - 是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。 -- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。 -- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。 - -### 12.2 需求 → 验收方法对照 - -| 需求 | 验收方法 | 通过标准 | -|---|---|---| -| PR-4 / PR-5 | 扫 DAC 全量程 | 输出扫过 3.52 ~ 4.01 V,与公式误差 <1%;**任何码值都到不了 4.2 V** | -| SR-2 | 固件写越界值 | 输出被拓扑限死,不越过上限 | -| SR-3 / SR-4 | 人为断开反馈下电阻 | eFuse 断开,输出侧尖峰不超过 4.7 V | -| SR-5 | 注入 ±150 mV 偏差 | 20 ms 内关路并产生告警记录 | -| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | -| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | -| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 | -| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS | -| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) | -| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** | -| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K | -| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V | -| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 | -| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 | -| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 | -| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 | -| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) | -| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 | -| **总体目标** | 五台长稳 | **24 h 无重启、无告警** | - -### 12.3 装配前必做的实物验证(阻塞项) - -| 项 | 内容 | 出处 | -|---|---|---| -| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 | -| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 | -| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 | -| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 | -| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 | -| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 | -| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 | -| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 | - -> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。 - ---- - -## 13. 已知风险与已接受的取舍 - -| # | 风险 | 处置 | -|---|---|---| -| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 | -| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 | -| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 | -| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 | -| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 | -| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 | -| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) | -| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** | - ---- - -## 14. 需求 → 设计追溯表(速查) - -| 需求域 | 设计文档章节 | -|---|---| -| 规格 / 输入输出 | §1、§3 | -| 电压范围与本质安全 | §4(含 §4.4 失效态验算) | -| 三层保护链 | §5 | -| 瞬态阻抗与阻尼(C-3) | §6 | -| 断电重启与放电 | §7 | -| MCU 接口 / GPIO / I²C | §8、§26.18 | -| 选型与 BOM | §9 | -| PCB 与叠层 | §10、§25、§26 | -| 验收与调试 | §11、§14 | -| 手机侧接口 | §13 | -| USB 子系统 | §15、§18、§21 | -| 两板架构与机械 | §22、§25 | -| 散热 | §22.6 | -| EDL | §22.4 | -| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** | - ---- - -## 15. 术语 - -| 术语 | 含义 | -|---|---| -| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 | -| **dipper** | 小米8 的代号 | -| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) | -| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) | -| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) | -| **EDL / 9008** | 高通紧急下载模式 | -| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 | -| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 | +# 五路手机直供电源板 —— 项目需求文档 + +| 项 | 值 | +|---|---| +| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收
v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 | +| 项目代号 | PhoneRail | +| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) | +| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) | + +> **本文档与设计文档的分工** +> 本文只回答 **「要做成什么样、凭什么算做到了」**; +> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。 +> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的), +> **需求的取舍与验收标准以本文为准**。 +> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。 +> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点, +> 暂放本文,理由写在该节开头。 + +--- + +## 1. 背景与目标 + +### 1.1 要解决的问题 + +手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题: + +1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患; +2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键; +3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。 + +### 1.2 项目目标 + +做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、 +稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、 +USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。 + +**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启, +任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。 + +### 1.3 目标用户与使用场景 + +| 用户 | 场景 | +|---|---| +| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 | +| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 | +| 本项目自身 | 首板调试、长稳测试、失效注入验证 | + +--- + +## 2. 范围 + +### 2.1 交付范围(In Scope) + +| # | 交付物 | +|---|---| +| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 | +| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 | +| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) | +| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) | +| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) | +| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。上位机侧的调用方由农场既有系统实现(N7),本项目交付板侧协议与实现,见 §10.8 | +| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 | + +### 2.2 明确不做(Out of Scope) + +| # | 不做 | 理由 | +|---|---|---| +| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 | +| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) | +| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 | +| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V | +| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) | +| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 | +| N7 | 上位机管理软件 / Web 界面 | 本期只交付固件与命令接口,上层由农场既有系统对接 | + +--- + +## 3. 系统组成(需求视角) + +```diagram=system +12V/10A 适配器 + │ + ┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐ + │ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │ + │ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │ + │ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │ + └────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘ + ┌────┴─────────────── 上板(载板)────────────────────────────┐ + │ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │ + │ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │ + │ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │ + └──────────────────────────────────────────────────────────────┘ +``` + +两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。 + +--- + +## 4. 功能需求(FR) + +| ID | 需求 | 优先级 | 设计出处 | +|---|---|---|---| +| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 | +| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 | +| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 | +| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 | +| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 | +| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 | +| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 | +| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 | +| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 | +| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 | +| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 | +| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 | +| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 | +| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 | +| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 | + +### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值) + +固件必须实现**两级重启**,且**先软后硬**: + +| 级 | 动作 | 触发条件 | 要求 | +|---|---|---|---| +| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 | +| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 | + +> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全 +> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。 + +--- + +## 5. 电气与性能需求(PR) + +| ID | 参数 | 要求 | 出处 | +|---|---|---|---| +| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 | +| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 | +| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 | +| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 | +| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 | +| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 | +| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 | +| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 | +| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 | +| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 | +| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 | +| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 | +| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 | +| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 | +| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 | + +### 5.1 输出上限为什么定在 4.01 V(这条需求的来源) + +去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。 +4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** —— +若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠, +坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。 + +**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。** + +--- + +## 6. 安全需求(SR)—— 本项目最高优先级 + +> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是 +> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。 +> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。 + +| ID | 需求 | 出处 | +|---|---|---| +| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 | +| **SR-2** | **第一层 · 拓扑限幅**:分压网络与 DAC 量程在物理上把输出限死在 3.42 ~ 4.12 V(含最坏容差),全部落在正常锂电电压内。防固件 bug / I²C 误码 / MCU 跑飞 | §4 | +| **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 | +| **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 | +| **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 | +| **SR-6** | **复位安全**:ESP32 复位 / 上电期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件 | §8.1 / §22.4 | +| **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 | +| **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 | +| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 | +| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 | +| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 | +| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 | +| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 | +| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 | +| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 | + +> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。** +> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。 +> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。 + +> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。** +> 按名字当地用会直接短路电池。 + +--- + +## 7. 接口需求(IF) + +> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 —— +> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识, +> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。 + +### 7.1 手机侧接口(每台一套) + +| ID | 接口 | 要求 | 出处 | +|---|---|---|---| +| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 | +| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 | +| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 | +| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 | +| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 | +| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 | + +> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那 +> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。 + +> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。 + +### 7.2 板对板接口 + +| ID | 需求 | 出处 | +|---|---|---| +| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 | +| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 | +| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 | +| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 | + +### 7.3 对外接口 + +| ID | 接口 | 要求 | +|---|---|---| +| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 | +| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 | +| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 | +| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) | +| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 | + +> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**, +> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。 + +### 7.4 内部数字接口 + +| ID | 需求 | +|---|---| +| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 | +| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 | +| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** | +| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 | + +--- + +## 8. 机械与热需求(MR / TR) + +| ID | 需求 | 出处 | +|---|---|---| +| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 | +| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 | +| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 | +| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 | +| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 | +| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 | +| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 | +| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 | +| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 | +| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 | +| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 | + +--- + +## 9. 固件需求(SW) + +> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。** + +### 9.1 必做 + +| ID | 需求 | +|---|---| +| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** | +| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >±100 mV 持续 20 ms 即关路告警(SR-5) | +| **SW-3** | 过流 / 故障事件记录:路号、时刻、电流值 —— 排查排线接触不良的唯一线索 | +| **SW-4** | 单路电压微调接口(跑不稳的手机提到 4.0 V,或反向降压降温),**上限受 SR-13 约束** | +| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 2 s 开机 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery | +| **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) | +| **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 | +| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS | +| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 | +| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 | +| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 | +| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 | + +### 9.2 明确禁止 + +| ID | 禁止事项 | 理由 | +|---|---|---| +| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 | +| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 | +| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 | +| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 | + +--- + +## 10. 软件架构设计 + +> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有 +> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的 +> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。 +> 硬件的「怎么做」仍然一律在设计文档。 + +### 10.1 分层与职责边界 + +```diagram=layers +┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐ +│ adb 探活 / 业务调度 / 崩溃判定 │ +└───────┬────────────────────────────────┬──────────────────────────┘ + │ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定) + ▼ ▼ + 5 台小米8 主板 ┌─────────────────────┐ + ▲ │ PhoneRail 控制器 │ + │ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │ + └──────────────────────────┤ 遥测 · 执行 · 记录 │ + └──────────┬──────────┘ + │ 只能建议,不能否决 + ┌───────────────▼───────────────┐ + │ 硬实时层:eFuse OVP / 限流 │ + │ 放电 MOS 反相联锁 / 上下拉 │ + │ —— 不含任何软件(SR-3、SR-7) │ + └───────────────────────────────┘ +``` + +**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置 +(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径 +是「不跑到就会烧手机」的。因此: + +- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死; +- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗; +- 固件可以随时被重启、被降级运行,硬件安全性不变。 + +### 10.2 架构的第一个判断:失联 ≠ 断电 + +| 状态 | 输出该怎么办 | 为什么 | +|---|---|---| +| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 | +| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 | +| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 | + +⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。 +失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。 + +**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、 +按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。 + +### 10.3 任务划分(FreeRTOS / ESP-IDF) + +| 任务 | 周期 | 优先级 | 职责 | 失败后果 | +|---|---|---|---|---| +| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) | +| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 | +| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 | +| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 | +| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 | + +**共享资源只有两个**,各配一把互斥锁: + +1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。 +2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。 + +**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户): + +``` +一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs +6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms +PCA9555 读两个输入端口 ≈ 0.12 ms + 合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用 +``` + +余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次** +——这个数是按理想时序算的,没算时钟延展和重试。 + +> **A8 已落地**(2026-09-07,`firmware/main/{tasks,sched,t_safety}.c` + +> `components/phonerail_hal/{include/hal_lock.h,hal_i2c_acct.c}`)。五条实现约定: +> +> 1. **五个任务是一张 const 表**(`tasks.c`),周期与优先级写在一起,开机 `task_table_ok()` +> 自检,不过就 abort。查的是**相对顺序**:safety 唯一最高、log 唯一最低、 +> thermal 与 actuator 同级、link 夹在中间;外加「巡检周期必须长于 INA226 的 +> 一帧转换 8.8 ms」,否则同一帧被读两次,现象是「电流特别稳」。 +> 2. **周期是间隔,不是延时。** `sched_periodic_*()` 记的是下一次该醒的**绝对时刻** +> (回绕安全,一律用 `int32_t` 差判先后)。写成「干完睡 20 ms」的话, +> 实际周期是 21.6 ms 且随总线忙闲抖动。过载时**跳过错过的整周期、保住相位**, +> 不补跑 —— 补跑的现象是「卡一下之后连着打满总线」。 +> 3. **两把锁不许嵌套持有。** 595 是纯 GPIO、I²C 是纯总线,两者没有交集, +> 所以「拿着 A 去拿 B」永远是写错了,`hal_lock_take()` 直接判 `HAL_ERR_STATE` +> ——与其定一套加锁顺序再指望人人记得,不如让它不可能发生。ESP 侧用的是 +> **mutex 不是 binary semaphore**(要优先级继承:t_link 拿着总线时 +> t_safety 不能被中优先级任务无限期挡住)。 +> 4. **巡检不死等锁**:等 5 ms(周期的 1/4)拿不到就跳过这一圈并计数。 +> 死等的现象是最高优先级的任务整个停摆,而且没有任何人会发现。 +> 同样地,**一个器件读失败不中断这一圈**,失败那一路保留上一圈的值并把 `ok` 标 false +> ——清零的话 A10 的过压判据会看到一个「正常」的 0 V。 +> 5. **那 8 % 是算出来的,不是抄的。** `hal_i2c_acct` 在 HAL 里按线上真实字节数记账 +> (两个目标同一份代码),`t_safety_budget_us()` 按事务形状静态预算,两者必须相等。 +> 实测:一次寄存器读 **48** 个位周期(本节手算的 46 没数 STOP 和一个重复 START), +> 一圈 13 次事务 = **1560 µs / 20 ms = 7.8 %**,与本节的「约 1.5 ms / 约 8 %」相符。 +> 多读一次寄存器就会让静态预算与实测对不上,测试当场变红。 +> 首板仍要按本节末尾那句用逻辑分析仪复核一次(这个数不含时钟延展与重试)。 + +### 10.4 五路一张表:禁止用通道号算任何东西 + +这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: + +- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2); +- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4); +- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。 + +所以固件里**只允许有一张静态配置表**,全部五路循环都索引它: + +```c +typedef struct { + uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */ + uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */ + uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */ + gpio_num_t efuse_en; /* GPIO 直连 */ + gpio_num_t vbus_en; /* GPIO 直连 */ + uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */ + uint8_t edl_bit; /* U33 上的位号 */ + uint8_t flt_bit; /* PCA9555 IO0_0..4 */ + uint8_t pg_bit; /* PCA9555 IO1_0..4 */ +} rail_cfg_t; +``` + +> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。** +> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。 + +> **A3 已落地**:表在 `firmware/components/drv/include/ina226.h`,取值只走 +> `ina226_rail_addr(rail)`(rail 是 1~5,越界 abort), +> `test_channel_index_is_not_the_address_low_bits` 专门拦「改回算式」这一手。 +> 同处还记了两条 INA226 的实现约定:**寄存器指针不自增**(每次读前重写指针, +> sim 会自增、真件不会,靠自增在宿主机上测不出来);**Calibration 丢了不报错、 +> 只让 Current/Power 恒读 0**,所以巡检要定期 `ina226_check()`。 + +同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上 +直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。 + +> **A6 已落地**:驱动在 `firmware/components/drv/include/pca9555.h`, +> **根本不提供「把某个脚配成输出」的接口**(和 sr595.h 不提供「抬 OE#」同一条规矩)。 +> 方向字一律过 `pca9555_dir_safe()`,不安全直接 abort。 +> 同处还记了四条这一片的实现约定: +> ① ⚠️ **Output Port 寄存器的复位值是 0xFF** —— 所以把接地脚配成输出的那一刻它就 +> 输出高电平,中间没有「先写个 0 再说」的余地,这比「配错方向」本身更急; +> ② **极性反转寄存器必须无条件清零**,不能赌复位值:看门狗只复位 ESP32, +> PCA9555 不掉电,上一条命的残留值原样留着,而写错的现象是「五路同时报故障」 +> 或者更糟的「五路故障全都看不见」,I²C 一切正常; +> ③ **三种极性**(FLT# 低有效 / PGOOD 高有效 / INA_ALERT 低有效)各自只在 +> `pca9555_rail_fault()` 等四个函数里出现一次,上层不许自己去 & 位; +> ④ **INT# 是加速路径不是主路径**:它要读走输入端口才释放,漏读一次就再也等不到 +> 下一个沿,所以 `t_safety` 必须无条件轮询(§10.3 的 20 ms 就是按这个排的)。 +> +> ⚠️ **FLT 与 PG 两张位表这次确实是顺排的**(都是 `bit = rail - 1`), +> 和 INA226 地址、595 位号那两张表不一样。所以这里最容易写出来的错不是「算式」, +> 而是**把 Port0 和 Port1 搞反** —— 两个端口的 bit0~4 长得一模一样,搞反之后 +> 「有故障」会被读成「电源良好」,方向正好相反,而且**静息态两个端口读回来都是 +> 全 1,单看一帧完全看不出来**。`test_one_rail_at_a_time` 专门拦这一手。 + +> **A7 已落地**(2026-09-07):那张 `rail_cfg_t` 表在 `firmware/main/rail_cfg.h` / +> `rail_cfg.c`,取值只走 `rail_cfg(rail)`(1~5,越界 abort)。六列的样子: +> +> | CH | INA226 | DAC | 按键位 PWR/VUP/VDN | EDL 位 | EFUSE_EN | VBUS_EN | +> |---|---|---|---|---|---|---| +> | 1 | 0x40 | U6.OUT2 | 1 / 2 / 3 | 16 | IO39 | IO40 | +> | 2 | 0x41 | U6.OUT0 | 19 / 17 / 18 | 7 | **IO48** | IO10 | +> | 3 | 0x44 | **U7**.OUT0 | 22 / 21 / 20 | 6 | IO6 | IO11 | +> | 4 | 0x45 | U6.OUT1 | 9 / 10 / 11 | 0 | IO41 | IO42 | +> | 5 | 0x42 | U7.OUT1 | 15 / 14 / 13 | 5 | IO8 | IO17 | +> +> 落地时定下来的五条: +> ① **本表与四个驱动那四张表是「两处写同一个数」,所以要对账而不是现算。** +> 开机自检 `rail_selftest()` 逐格拿本表和 `ina226_rail_addr()` 等取值函数比一遍, +> 对不上就报 `RAIL_SELFTEST_TABLE`。做成「开机从驱动现算」的话按定义就查不出分家, +> 而且表不再是 const 的、用之前得先记着初始化。 +> ② **判据本身也要被测**:真表是 const 的,测试改不动它,所以判据单拎成 +> `rail_selftest_table(t)`,测试喂几张故意写坏的表进去看它认不认 +> (分家 / 引脚重复 / 撞 SDA / strapping / 不存在的号 / 越界,六种都验过)。 +> ③ **两列 GPIO 是全固件唯一一处出处**,没有第二张表能对账 —— 所以 +> 「`EFUSE_EN1` 和 `VBUS_EN1` 写反」这种错自检**查不出来**(两个都是合法空闲脚、 +> 不重复、不越界)。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的 +> 十个值(出处:`.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。 +> ④ **「不是顺排」要看整列,不能逐格撞算式**:CH4 的按键位正好是 9/10/11、 +> CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好等于「IO4 起 / IO9 起」那个算式 —— +> 单看任何一路都「像顺排」。测试改成断言整列**不是等差数列**。 +> ⑤ ⚠️ **HAL 两侧的 GPIO 上限原来是 `pin < 48`,把 IO48 关在了门外**, +> 而 `EFUSE_EN2` 就在 IO48 上 —— 现象是 CH2 的 eFuse 既开不了也**关不掉**。 +> 已改成 0~48(含)。ESP32-S3 的编号是连续的但芯片不是:IO22~IO25 不存在、 +> IO26~IO32 是模组内部 flash/PSRAM,写进配置表 `gpio_config()` 不报错也不干活, +> 所以自检把这些号也当成越界。 +> +> 同处还落地了**全板引脚表** `firmware/main/board_pins.h`(§8.1 那张表里 +> 「全板独一份」的那些脚)与两根 EN 线的表驱动遍历 +> (`rail_en_init()` / `rail_efuse_enable()` / `rail_vbus_enable()` / `rail_all_off()`)。 +> `rail_all_off()` **先断五路 VBUS 再关五路 eFuse**,中间不留「手机只靠 USB +> 供着电」的窗口;十个脚全部试一遍,不因为某一个失败就提前返回。 +> 高有效这件事只在 `en_write()` 里出现一次。 + +### 10.5 电压通道:DAC 只做微调,而且是反向的 + +| 事实 | 含义 | +|---|---| +| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** | +| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 | +| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) | + +> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是 +> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电, +> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口, +> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。 + +三条固件规矩: + +1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。 + 注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。 +2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。 +3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出, + 做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。 + +> **A4 已落地**:换算与设定在 `firmware/main/rail.c`,唯一入口 `rail_set_mv(rail, mv)` +> (rail 是 **1~5**)。三个验收点各有一条测试:4020 mV 被拒、码大电压低、3900 mV ↔ Vd 0.548 V。 +> 标定(本节规矩 3)**还没做**,现在走的是理论公式;`RAIL_VOUT0_UV` / `RAIL_DAC_SPAN_UV` +> 两个常量就是将来要被标定值顶替的地方。 +> +> 同处记下三条 DAC63204 的实现约定(出处 SLASEX3A): +> - **12 位码在 `DAC-X-DATA`(19h~1Ch) 里是左对齐的**(bit15-4)。忘了 `<< 4` 不报错, +> 只让输出变成 1/16 —— 而在本板上那表现为「五路都是 4.010 V 满电」,看不出异常。 +> - **`EN-INT-REF` 要先于增益设定写**,且**上电安全码要写在通道上电之前**: +> 复位码是 0,而 Vd = 0 就是输出上限 4.010 V。`dac63204_init()` 因此把 +> `COMMON-CONFIG` 写两次(先按住输出,写完码再上电)。 +> - **通道分配不是 `rail−1`**(设计文档 §26.18 重排过,五路里四路对不上): +> CH1→U6.OUT2 / CH2→U6.OUT0 / CH3→**U7**.OUT0 / CH4→U6.OUT1 / CH5→U7.OUT1。 +> 四通道电气对等 ⇒ 接错了照样工作,只是调错路,功能测试测不出来。 + +### 10.6 执行器:三个状态机 + 一个影子寄存器 + +**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个): + +| 序列 | 时序 | 关键约束 | +|---|---|---| +| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS | +| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 | +| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB | + +**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位 +移位链**上,写任何一位都要把整条链重发一遍。 + +``` +ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33) +写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效 +``` + +规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键 +或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手 +清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。 + +> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#` +> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉, +> 是复位路径,不是正常控制路径。 + +> **2026-09-06 A5 落地**:`components/drv/sr595.{h,c}`。影子是一个 24 位整数 +> (bit0 = U21.QA 链首 … bit23 = U33.QH 链尾),唯一写入点是 `sr595_flush()`; +> 别处只准 `sr595_modify()` 改影子,组合键走 `sr595_write(mask, value)` 一帧发完。 +> 位表见设计文档 §17.2(§26.18 重排后的那张,**不是 09-04 那张**)。 +> +> 同处记下三条这一层的实现约定: +> - **上电顺序是「先锁存安全值,再使能 `OE#`」,不能换。** 三片的 `MR#` 接死在 +3V3, +> 没有复位线 ⇒ 上电时移位寄存器内容**不确定**。先拉低 `OE#` 就是把那坨随机值 +> 放到 15 个栅极上 —— 五台手机的电源键可能一起被按下去。所以 `sr595_init()` 里 +> `KEY_OE#` 在安全值锁存完之前**根本不配成输出**,靠板上 R609 上拉着。 +> (ESP-IDF 的 `gpio_config()` 一配成输出就开始驱动,输出寄存器复位值是 0。) +> - **`HUB_RST` 低有效**,所以上电安全影子是 `1 << 23` 而不是 0。写成 0 的现象是 +> 「Hub 插上枚举不出来」,而五路电压电流全都正常,看不出跟固件有关。 +> - **发送失败要留着脏**(`sr595_flush()` 失败不清 dirty,下次重发整帧)。 +> 丢了脏标志 = 链上和影子永久对不上,而后面每一次 flush 都会「成功」。 + +### 10.7 事件记录与存储 + +事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。 + +| 项 | 设计 | +|---|---| +| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 | +| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 | +| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) | +| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 | +| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) | + +必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 +每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 + +### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) + +**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」, +而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。 +这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。 + +**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。** + +选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值, +而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」 +共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。 + +留档备查的两个单选方案: + +| 方案 | 优点 | 为什么不单独用 | +|---|---|---| +| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 | +| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 | + +#### 两条链路怎么共存 + +```diagram=links + WiFi (MQTT/HTTP) USB CDC (J22) + │ │ + ┌───────▼────────┐ ┌────────▼───────┐ + │ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧 + └───────┬────────┘ └────────┬───────┘ + └──────────┬─────────────┘ + ┌──────▼───────┐ + │ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里 + └──────┬───────┘ + ▼ + 执行器 / 遥测 +``` + +四条规矩: + +1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束 + (SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 —— + 否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。 +2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。 + 做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。 +3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号, + 板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时, + 第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。 +4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是 + CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。 + 代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行 + JSON,日志走另一个前缀),否则日志一刷就把响应冲散。 + +> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 —— +> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。 + +命令语义与链路无关,两条链路完全一致: + +| 命令 | 参数 | 说明 | +|---|---|---| +| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 | +| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) | +| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) | +| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 | +| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 | +| `vbus` | 路号, on/off | 默认断开 | +| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 | +| `events` | 起始序号 | 增量拉取事件日志 | + +三条接口硬约束: + +- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令, + 它压根不该存在于发布固件里。 +- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。 +- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。 + +### 10.9 启动顺序 + +``` +1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次) +2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动 +3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对 +4 载入 NVS 里的标定值与上次的每路使能状态 +5 逐路上电,**路与路之间错开 100 ms** +6 等各路 PG,超时则记事件并保持该路关闭 +7 起风扇闭环,起链路任务 +``` + +> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A +> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后 +> 各路的斜坡(11.5 ms)完全不重叠。 + +### 10.10 降级矩阵 + +| 失效 | 检测方式 | 行为 | +|---|---|---| +| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 | +| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 | +| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 | +| PCA9555 无应答 | I²C NAK | 失去 FAULT / PG 可见性;改用 INA226 的电压判据兜底 | +| 载板 LM75BD 无应答 | I²C NAK | 多半是排针接触不良(它在排针另一侧)→ **风扇转到 100 %** 并告警,不要按「读不到温度=不热」处理 | +| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** | +| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 | + +**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令, +持续告警。宁可五台手机不开机,也不要在盲态下带电跑。 + +### 10.11 关于 FR-12「模拟电量曲线」的一个说明 + +这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从 +电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」 += **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。 + +两个后果: + +- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器; +- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 —— + 否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。 + +FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。 + +### 10.12 架构层面的待定项 + +| 项 | 状态 | +|---|---| +| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 | +| **OTA 升级** | 既然定了 WiFi,**建议做**:双分区 + 失败回滚。没有 OTA 的话一次坏固件要把板子从机架上拆下来插 USB,而 USB 兜底链路本身也能烧 —— 所以这是「省事」不是「救命」,可以排在首版之后 | +| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 | +| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 | +| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) | +| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 | + +--- + +## 11. 约束与假设 + +### 11.1 硬约束 + +| ID | 约束 | +|---|---| +| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 | +| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 | +| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 | +| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 | +| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 | +| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 | +| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** | + +### 11.2 假设(若不成立需重新评估) + +| ID | 假设 | +|---|---| +| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) | +| **A-2** | 手机关机待机电流在 ≈50 µA 量级 | +| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) | +| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 | + +--- + +## 12. 验收准则 + +### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**) + +完整步骤与通过标准见设计文档 **§11**。其中: + +- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)** + 是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。 +- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。 +- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。 + +### 12.2 需求 → 验收方法对照 + +| 需求 | 验收方法 | 通过标准 | +|---|---|---| +| PR-4 / PR-5 | 扫 DAC 全量程 | 输出扫过 3.52 ~ 4.01 V,与公式误差 <1%;**任何码值都到不了 4.2 V** | +| SR-2 | 固件写越界值 | 输出被拓扑限死,不越过上限 | +| SR-3 / SR-4 | 人为断开反馈下电阻 | eFuse 断开,输出侧尖峰不超过 4.7 V | +| SR-5 | 注入 ±150 mV 偏差 | 20 ms 内关路并产生告警记录 | +| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 | +| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 | +| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 | +| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS | +| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) | +| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** | +| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K | +| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V | +| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 | +| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 | +| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 | +| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 | +| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) | +| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 | +| **总体目标** | 五台长稳 | **24 h 无重启、无告警** | + +### 12.3 装配前必做的实物验证(阻塞项) + +| 项 | 内容 | 出处 | +|---|---|---| +| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 | +| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 | +| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 | +| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 | +| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 | +| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 | +| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 | +| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 | + +> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。 + +--- + +## 13. 已知风险与已接受的取舍 + +| # | 风险 | 处置 | +|---|---|---| +| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 | +| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 | +| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 | +| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 | +| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 | +| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 | +| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) | +| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** | + +--- + +## 14. 需求 → 设计追溯表(速查) + +| 需求域 | 设计文档章节 | +|---|---| +| 规格 / 输入输出 | §1、§3 | +| 电压范围与本质安全 | §4(含 §4.4 失效态验算) | +| 三层保护链 | §5 | +| 瞬态阻抗与阻尼(C-3) | §6 | +| 断电重启与放电 | §7 | +| MCU 接口 / GPIO / I²C | §8、§26.18 | +| 选型与 BOM | §9 | +| PCB 与叠层 | §10、§25、§26 | +| 验收与调试 | §11、§14 | +| 手机侧接口 | §13 | +| USB 子系统 | §15、§18、§21 | +| 两板架构与机械 | §22、§25 | +| 散热 | §22.6 | +| EDL | §22.4 | +| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** | + +--- + +## 15. 术语 + +| 术语 | 含义 | +|---|---| +| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 | +| **dipper** | 小米8 的代号 | +| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) | +| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) | +| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) | +| **EDL / 9008** | 高通紧急下载模式 | +| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 | +| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 |