固件:R2 迁移 R-0 ~ R-6(底板 + 5 片同构刀片)

2026-09-16 完成、当时未提交,这里补提交(原样,不含 09-27 上板的修改)。

- 器件层 / 拓扑层整体从 R1 换到 R2:TCA9548A 分通道、五路同址 INA226 0x40 + 输入侧 0x43、
  刀片 PCA9555(VBUS_EN 挪到 P0.4)、CH224A 只读驱动、风扇闭环 t_thermal
- 新增 i2cbus(mux 原子事务)、slot(刀片 I/O 门面)、host/qtest.sh
- 74HC595 链退役(代码与测试保留)
- 判据阈值与时间常量未动;19 个 ctest 全绿,idf.py build 通过

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 10:26:24 +08:00
共同撰写人 Claude Opus 5.5
父节点 5a80d8a872
当前提交 b8ad69681f
共修改 51 个文件,包含 10882 行新增和 4068 行删除
+612
查看文件
@@ -0,0 +1,612 @@
/* R-0:`i2cbus` —— 带 mux 的 I²C 原子事务层(计划 §4.3)。
⭐ 这一份是 R2 最重要的一个测试文件,因为它守的是**唯一一类不会报错的错**:
R2 之后,一次「读 CH3 的电压」在总线上是两件事 —— 先写 TCA9548A 选通道,
再读 0x40 的寄存器。五片刀片完全同构,五片上的 INA226 都是 0x40。
所以通道选错时:地址对、应答正常、寄存器号对、数值在量程内、
还会跟着负载变化 —— **读到的是另一台手机的数据,而每一层都返回 HAL_OK。**
这个文件里的每一条用例都是在证明「这一类错发生时有人会红」。
凡是只能靠 `i2cbus` 自己的影子变量看出来的事,一律再补一条**数据层面**的断言:
影子变量说自己对不算数,要让假器件吐出另一片的读数才算数。
假板子的布置(与真板一致):
· 0x70 mux,`MUX_RST#` 接 BOARD_PIN_MUX_RST_N;
· 0x43 主干上的输入侧 INA226(全关时唯一还看得见的 INA226);
· 0x40 通道 0~4 各一片 —— 五片同址,正是 D8 的设计。
五片的母线电压预置成**五个都很正常的电池电压**(3.75 / 4.20 / 3.80 / 4.00 / 4.10 V):
选错通道时读回来的值不会超量程、不会是 0、也不会是 0xFFFF,
所以「加个范围检查就能发现」这条退路在这块板上是不存在的。 */
#include "i2cbus.h"
#include "board_pins.h"
#include "hal_gpio.h"
#include "hal_i2c.h"
#include "hal_lock.h"
#include "ina226.h"
#include "sim_hal.h"
#include "tca9548a.h"
#include "unity.h"
#define TRUNK_INA 0x43
#define BLADE_INA 0x40
#define N_BLADE 5
/* 通道 n 上那片刀片 INA226 的 BUS_V 原始值。bus_mv = raw × 1.25 mV。 */
static const uint16_t k_raw[N_BLADE] = { 3000, 3360, 3040, 3200, 3280 };
static const int32_t k_mv[N_BLADE] = { 3750, 4200, 3800, 4000, 4100 };
#define TRUNK_RAW 9600 /* 12.0 V —— 主干那片量的是 12 V 母线 */
#define TRUNK_MV 12000
/* ---- 脚手架 -------------------------------------------------------------- */
typedef struct {
uint8_t addr;
uint32_t calls;
int32_t mv;
hal_err_t inner; /* 嵌套那条用例用 */
} ctx_t;
/* 事务正文:读一次母线电压。**只调 hal_i2c_\***,通道是上层选好的。 */
static hal_err_t cb_read_mv(void *arg)
{
ctx_t *c = (ctx_t *)arg;
c->calls++;
return ina226_read_bus_mv(c->addr, &c->mv);
}
/* 在事务里调 `i2cbus_recover()` —— 它自己要拿锁、还要睡 1 ms。 */
static hal_err_t cb_recover_inside(void *arg)
{
ctx_t *c = (ctx_t *)arg;
c->calls++;
c->inner = i2cbus_recover();
return HAL_OK;
}
/* 在事务里再调一次 `i2cbus_xact()` —— 头文件末尾那条警告。 */
static hal_err_t cb_nested(void *arg)
{
ctx_t *c = (ctx_t *)arg;
c->calls++;
static ctx_t inner_ctx;
inner_ctx.addr = BLADE_INA;
c->inner = i2cbus_xact(1, 10, cb_read_mv, &inner_ctx);
return HAL_OK;
}
static void mount(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N);
sim_i2c_add(TRUNK_INA, 2);
sim_i2c_set_reg(TRUNK_INA, INA226_REG_BUS_V, TRUNK_RAW);
for (int ch = 0; ch < N_BLADE; ch++) {
sim_i2c_add_ch(ch, BLADE_INA, 2);
sim_i2c_set_reg_ch(ch, BLADE_INA, INA226_REG_BUS_V, k_raw[ch]);
}
}
static void mount_ready(void)
{
mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
}
/* 读一路,返回码必须是 HAL_OK。 */
static int32_t read_blade(int ch)
{
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(ch, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(1, c.calls);
return c.mv;
}
void setUp(void) { sim_hal_reset(); }
/* ⭐ 全文件恒为 0:同一地址同时看得见两片 = 通道开重了。
真板上那是两片 INA226 一起应答、读回来是线与 —— 数值正常、还很稳,
除了这个计数没有任何东西会告诉你。 */
void tearDown(void) { TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); }
/* ---- init ---------------------------------------------------------------- */
/* init 之后:`MUX_RST#` 是高(不复位)、通道全关、影子 = 主干。
⚠️ `sim_i2c_mux_resets()` 这时是 **1**,不是 0:`hal_gpio_config()` 配成输出的
那一刻脚上是 0(ESP-IDF 也是这个行为),紧接着才被写成 1。
这个窗口只有几条指令、此刻总线上还没有人,是 i2cbus.c 里那段注释认下的代价。
后面所有涉及复位次数的断言都从这个基线上算增量。 */
static void test_init_leaves_the_mux_closed_and_out_of_reset(void)
{
mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
TEST_ASSERT_EQUAL_INT(I2CBUS_CH_TRUNK, i2cbus_channel());
TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask());
TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_MUX_RST_N));
TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_mux_resets()); /* 见上面那段注释 */
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_selects());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_recoveries());
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 认件那一段的锁放回去了 */
}
/* mux 没认出来照样要 init 完(计划 §5.8:那是运行期降级,不是固件写错)。
⚠️ 关键在于**影子必须是 UNKNOWN 而不是主干** —— 认件都失败了,
凭什么相信器件里的通道是全关的。写成 TRUNK 的话,
插着的第一笔主干事务会跳过选通,发到一个状态不明的 mux 后面。 */
static void test_init_survives_a_missing_mux_but_trusts_nothing(void)
{
mount();
sim_i2c_set_present(TCA9548A_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_init());
TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel());
/* 仍然可用:事务会去试选通道,失败了如实往上报。 */
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(0, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(0, c.calls); /* 选通道没成,正文一次都不许跑 */
}
/* ---- ⭐ 本文件的主项:选谁读谁 ------------------------------------------- */
/* ⭐⭐ 验收主项。五片刀片同址 0x40,`i2cbus_xact(ch, ...)` 必须选谁读谁。
这条挂掉 = R2 的整条监控链在报别人的数据。 */
static void test_xact_reads_the_blade_you_asked_for(void)
{
mount_ready();
for (int ch = 0; ch < N_BLADE; ch++) {
TEST_ASSERT_EQUAL_INT32(k_mv[ch], read_blade(ch));
TEST_ASSERT_EQUAL_INT(ch, sim_i2c_mux_ch()); /* 线上真的切过去了 */
TEST_ASSERT_EQUAL_INT(ch, i2cbus_channel());
}
/* 倒着再来一遍:证明上面不是「碰巧按顺序自增」。 */
for (int ch = N_BLADE - 1; ch >= 0; ch--) {
TEST_ASSERT_EQUAL_INT32(k_mv[ch], read_blade(ch));
}
}
/* ⭐⭐ 反证:**绕过 `i2cbus` 直接读会读到另一台手机的数据,而且一切正常。**
这是 R2 固件最危险的错误形态,也是 §4.3 那条「应用层不许直接调 hal_i2c_*」
规矩的全部理由。剧本就是最自然的那一种写错:
1. 巡检刚读完 CH2(通道 1),mux 停在通道 1;
2. 有人写了 `ina226_read_bus_mv(0x40, &mv)` —— 他心里想的是 CH1,
毕竟 R1 时代这么写了一年,0x40 就是「那片 INA226」;
3. 返回 HAL_OK,拿到 4200 mV。
4200 mV 是一个**完全正常**的电池电压:在量程内、在告警阈值内、
会随负载变化、多读几次还很稳。没有任何一层会报错:
· HAL 不会 —— 器件应答了;
· 驱动不会 —— 寄存器号对、转换公式对;
· 安全判据不会 —— 3.5~4.2 V 是合法区间;
· `sim_i2c_ambiguous()` 也不会 —— 只有一片在应答,它就是"对"的那一片。
唯一能发现这件事的办法,是像下面这样把两片的值预置成不同的数去对。
⚠️ 这条用例**证明的是错误存在且沉默**,不是在验收某个正确行为 ——
所以它断言的是「读到的是 CH2 的值,不是 CH1 的值」。
哪天它变红了,说明有人给底层加了通道校验,那是好事,改这条用例即可。 */
static void test_bypassing_i2cbus_silently_reads_the_wrong_phone(void)
{
mount_ready();
/* 1. 巡检读了 CH2(通道 1)。 */
TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1));
TEST_ASSERT_EQUAL_INT(1, sim_i2c_mux_ch());
/* 2~3. 有人以为自己在读 CH1(通道 0),直接调驱动。 */
int32_t mv = -1;
const hal_err_t err = ina226_read_bus_mv(BLADE_INA, &mv);
TEST_ASSERT_EQUAL_INT(HAL_OK, err); /* ★ 不报错 */
TEST_ASSERT_EQUAL_INT32(k_mv[1], mv); /* ★ 拿到的是 CH2 的 4200 mV */
TEST_ASSERT_NOT_EQUAL(k_mv[0], mv); /* ★ 不是他以为的 CH1 */
TEST_ASSERT_TRUE(mv >= 3500 && mv <= 4200); /* ★ 而且落在完全合法的区间里 */
/* 同一件事换个方向说:通道停在哪,0x40 就是哪一片。 */
TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3));
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_bus_mv(BLADE_INA, &mv));
TEST_ASSERT_EQUAL_INT32(k_mv[3], mv);
}
/* 主干也是一个「通道」:全关的时候 0x43 看得见、0x40 一片都看不见。
写成「主干 = 不选通道」(也就是保持上一次的通道)的话,
读输入侧 0x43 会在某片刀片还挂着的支路上进行 —— 0x43 在主干上依然应答,
所以**照样不报错**,只是这时候整条总线上同时挂着一片 0x40,
离「通道开重了」只差一个人手滑。 */
static void test_trunk_is_a_channel_too(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2)); /* 先把通道支到 2 */
ctx_t c = { TRUNK_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(I2CBUS_CH_TRUNK, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_INT32(TRUNK_MV, c.mv);
TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); /* ★ 支路真的全断开了 */
TEST_ASSERT_EQUAL_INT(I2CBUS_CH_TRUNK, i2cbus_channel());
/* 这时候任何一片刀片的 0x40 都该是不应答的。 */
int32_t mv = -1;
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_read_bus_mv(BLADE_INA, &mv));
}
/* ---- 影子:省事务,但省出来的不变量要守住 -------------------------------- */
/* 同一个通道连着两次事务,第二次不重发选通字节。
§5.5 的账就靠这个:t_safety 每 20 ms 一圈五路,无条件重发一圈多 5 次事务。 */
static void test_a_second_xact_on_the_same_channel_skips_the_select(void)
{
mount_ready();
sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR);
TEST_ASSERT_NOT_NULL(mux);
TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2));
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects());
const uint32_t n_write = mux->n_write;
TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2));
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects()); /* ★ 没有 +1 */
TEST_ASSERT_EQUAL_UINT32(n_write, mux->n_write); /* ★ mux 上一个字节都没多收 */
/* 换一路就必须重发。 */
TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3));
TEST_ASSERT_EQUAL_UINT32(2, i2cbus_selects());
}
/* ⭐⭐ 选通失败必须让影子作废。
剧本:
1. 读 CH1(通道 0)成功,影子 = 0;
2. 去读 CH2(通道 1),选通字节被 NAK 掉 —— 失败的写**不改变器件状态**,
mux 还停在通道 0(这条事实在 test_tca9548a.c 里单独钉过);
3. 现实里紧接着发生什么是**不知道**的:mux 可能自己复位了、
可能那一笔写其实落进去了只是 ACK 丢了、也可能另一次毛刺把它挪到了别处。
这里用一次裸写把它挪到通道 1 来代表「不知道」的一个具体实例;
4. 再去读 CH1(通道 0)。
影子作废了:第 4 步重发选通字节 → 读回 CH1 的 3750 mV。
影子没作废(把 `s_ch = I2CBUS_CH_UNKNOWN` 那一行删掉):影子还是 0、命中、
**跳过选通** → 事务发到通道 1 上 → 读回 4200 mV,返回 HAL_OK。
那正是本文件开头那类「读到另一台手机」的错,只不过是固件自己制造的。 */
static void test_a_failed_select_invalidates_the_shadow(void)
{
mount_ready();
/* 1 */
TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0));
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects());
/* 2 */
sim_i2c_inject_nak(TCA9548A_ADDR, 1);
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(1, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(0, c.calls); /* 选通没成,正文一次都不许跑 */
TEST_ASSERT_EQUAL_UINT32(2, i2cbus_selects());
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_select_fails());
TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel());
TEST_ASSERT_EQUAL_HEX8(0x01, sim_i2c_mux_mask()); /* 器件没被动过,还在通道 0 */
/* 3:代表「失败之后通道到底在哪,谁也不知道」。 */
const uint8_t elsewhere = 0x02;
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(TCA9548A_ADDR, &elsewhere, 1));
TEST_ASSERT_EQUAL_INT(1, sim_i2c_mux_ch());
/* 4:必须重发选通,读回来的必须是 CH1 自己的值。 */
TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0));
TEST_ASSERT_EQUAL_UINT32(3, i2cbus_selects()); /* ★ 又 +1 = 真的重发了 */
TEST_ASSERT_EQUAL_INT(0, sim_i2c_mux_ch());
}
/* 选通失败时正文一次都不许跑 —— 换个角度再钉一次:
跑了的话事务会落在**上一次**那条支路上,而那条支路上的器件会照常应答。
这也是 `xact` 的返回码契约:「选通道的错误码 —— fn 一次都没被调」。 */
static void test_the_body_never_runs_on_a_channel_that_was_not_selected(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT32(k_mv[4], read_blade(4));
sim_i2c_inject_nak(TCA9548A_ADDR, 1);
ctx_t c = { BLADE_INA, 0, -12345, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(2, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(0, c.calls);
TEST_ASSERT_EQUAL_INT32(-12345, c.mv); /* 调用方的值一个字节没动 */
}
/* ---- 连续失败计数:§5.8「mux 失联」的判据读的就是它 ---------------------- */
/* ⭐ **连续**失败,成功一次就清零。
写成累计量的后果:一次总线毛刺的账会一直留着,板子跑上几天几夜,
`select_fails` 迟早爬过阈值 —— 于是某个凌晨五台手机一起断电,
而那一刻总线其实是好的。这种误停机在实验室里永远复现不出来。 */
static void test_fail_streak_is_consecutive_and_one_success_clears_it(void)
{
mount_ready();
sim_i2c_inject_nak(TCA9548A_ADDR, 3);
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
for (uint32_t i = 1; i <= 3; i++) {
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(0, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(i, i2cbus_select_fail_streak());
TEST_ASSERT_EQUAL_UINT32(i, i2cbus_select_fails());
}
/* 第 4 次总线好了。 */
TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0));
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); /* ★ 清零 */
TEST_ASSERT_EQUAL_UINT32(3, i2cbus_select_fails()); /* 累计量不清零 */
/* 再来一次失败:从 1 开始重新数,不是从 4。 */
sim_i2c_inject_nak(TCA9548A_ADDR, 1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(1, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_select_fail_streak());
TEST_ASSERT_EQUAL_UINT32(4, i2cbus_select_fails());
}
/* 影子命中的那些事务**不算**成功的选通,所以也不该去清 streak ——
但它们同样不该去累加。这条守的是「反复读同一路时 streak 冻在原地」:
写成「每次 xact 都清零」的话,mux 失联判据永远等不到阈值。 */
static void test_a_shadow_hit_neither_counts_nor_clears(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0)); /* 成功一次,streak = 0 */
sim_i2c_inject_nak(TCA9548A_ADDR, 1);
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, i2cbus_xact(1, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_select_fail_streak());
/* 失败之后影子是 UNKNOWN,所以下一次同通道**不会**命中;
这里先成功一次把影子立起来,再连读两次看 streak 与 selects 都不动。 */
TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1));
const uint32_t sel = i2cbus_selects();
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak());
TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1));
TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1));
TEST_ASSERT_EQUAL_UINT32(sel, i2cbus_selects());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak());
}
/* ---- 复位 ---------------------------------------------------------------- */
/* ⭐ `i2cbus_recover()`:`MUX_RST#` 真的被拉低过,之后影子作废、通道全关。
只断言 `i2cbus_channel() == UNKNOWN` 是不够的 —— 那只是自己说自己对。
这里同时断言假 mux 的 `resets` +1(脚真的低过)和掩码归 0(器件真的被复位了)。
不作废影子的后果:复位之后器件里的通道是**全关**的,影子却还记着上一路,
下一次事务跳过选通,落在全关的总线上 —— 现象是「五片刀片一起失联」,
而且 `i2cbus_recover()` 本来就是为了抢救失联才调的,会变成越救越死。 */
static void test_recover_pulses_mux_rst_and_forgets_the_channel(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3));
TEST_ASSERT_EQUAL_HEX8(0x08, sim_i2c_mux_mask());
const uint32_t resets = sim_i2c_mux_resets();
const uint32_t writes = sim_gpio_writes(BOARD_PIN_MUX_RST_N);
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_recover());
TEST_ASSERT_EQUAL_UINT32(resets + 1, sim_i2c_mux_resets()); /* ★ 脚真低过 */
TEST_ASSERT_EQUAL_UINT32(writes + 2, sim_gpio_writes(BOARD_PIN_MUX_RST_N)); /* 低再高 */
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(BOARD_PIN_MUX_RST_N)); /* 收尾必须放开 */
TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); /* ★ 通道全关 */
TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel()); /* ★ 影子作废 */
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_recoveries());
/* 复位之后不重新选通的话,刀片就是全部失联 —— 这就是影子必须作废的代价证明。 */
int32_t mv = -1;
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_read_bus_mv(BLADE_INA, &mv));
/* 而经 i2cbus 走就自动恢复:重发选通、读回本路自己的值。 */
const uint32_t sel = i2cbus_selects();
TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3));
TEST_ASSERT_EQUAL_UINT32(sel + 1, i2cbus_selects());
}
/* recover 自己要拿锁、而且中间要睡 1 ms,所以**不许在事务的回调里调** ——
直接判 HAL_ERR_STATE。不拦的话它会持着总线锁去 `hal_time_delay_ms(1)`,
把巡检整个钉住;而且那 1 ms 里 `MUX_RST#` 是低的,
外层事务回来之后会在一个刚被复位(通道全关)的 mux 上继续跑。 */
static void test_recover_is_rejected_inside_a_transaction(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT32(k_mv[0], read_blade(0));
const uint32_t resets = sim_i2c_mux_resets();
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(0, 10, cb_recover_inside, &c));
TEST_ASSERT_EQUAL_UINT32(1, c.calls);
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, c.inner); /* ★ 被挡下来了 */
TEST_ASSERT_EQUAL_UINT32(resets, sim_i2c_mux_resets()); /* ★ 脚一次都没低过 */
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_recoveries());
TEST_ASSERT_EQUAL_HEX8(0x01, sim_i2c_mux_mask()); /* 通道也没被关掉 */
/* 事务外面调是合法的。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_recover());
TEST_ASSERT_EQUAL_UINT32(resets + 1, sim_i2c_mux_resets());
}
/* ---- 两条「别把自己锁死」 ------------------------------------------------ */
/* ⭐ 嵌套调用返回 HAL_ERR_STATE,而且**不死锁**:外层事务照常跑完、锁照常放回去。
不拦的话会在同一个任务里第二次 take 同一把锁 —— sim 上是 HAL_ERR_STATE,
FreeRTOS 上(非递归的 mutex)就是那个任务当场停住,
现象是 `t_safety` 整个不跑了,而看门狗之外没有任何日志。 */
static void test_a_nested_xact_is_rejected_and_does_not_deadlock(void)
{
mount_ready();
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
const uint32_t sel = i2cbus_selects();
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_xact(0, 10, cb_nested, &c));
TEST_ASSERT_EQUAL_UINT32(1, c.calls);
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, c.inner); /* ★ 内层被挡在拿锁之前 */
TEST_ASSERT_EQUAL_UINT32(sel + 1, i2cbus_selects()); /* 内层一个字节都没发 */
/* 锁真的放回去了:拿得到就说明没漏。 */
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
hal_lock_give(HAL_LOCK_I2C);
/* 外层结束之后总线恢复正常,不是一次性坏掉。 */
TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2));
}
/* ⭐ 拿不到锁时返回 HAL_ERR_TIMEOUT,而且**什么也没做**。
「什么也没做」是调用方(t_safety)敢直接跳过这一周期的前提:
要是这里已经把通道切过去了、或者把影子改了,跳过这一周期之后
下一个拿到锁的人会在一个被人改过的通道上开工 —— 又是一次静默的读错片。 */
static void test_a_lock_timeout_changes_nothing_at_all(void)
{
mount_ready();
sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR);
TEST_ASSERT_NOT_NULL(mux);
TEST_ASSERT_EQUAL_INT32(k_mv[1], read_blade(1)); /* 通道停在 1 */
const uint32_t sel = i2cbus_selects();
const uint32_t nw = mux->n_write;
const uint8_t mask = sim_i2c_mux_mask();
const int shadow = i2cbus_channel();
/* 1 号任务持着 I²C 锁,0 号任务来跑一次事务。 */
sim_lock_set_task(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
sim_lock_set_task(0);
ctx_t c = { BLADE_INA, 0, -777, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, i2cbus_xact(3, 5, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(0, c.calls); /* ★ 正文没跑 */
TEST_ASSERT_EQUAL_INT32(-777, c.mv); /* ★ 调用方的值没动 */
TEST_ASSERT_EQUAL_UINT32(sel, i2cbus_selects()); /* ★ 没发选通 */
TEST_ASSERT_EQUAL_UINT32(nw, mux->n_write); /* ★ mux 一个字节没收 */
TEST_ASSERT_EQUAL_HEX8(mask, sim_i2c_mux_mask()); /* ★ 通道没被动 */
TEST_ASSERT_EQUAL_INT(shadow, i2cbus_channel()); /* ★ 影子没被动 */
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_lock_timeouts());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fail_streak()); /* 等锁失败 ≠ 选通失败 */
/* 1 号放掉之后一切照旧。 */
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_I2C);
sim_lock_set_task(0);
TEST_ASSERT_EQUAL_INT32(k_mv[3], read_blade(3));
}
/* ---- 参数与状态 ---------------------------------------------------------- */
/* 没 init 就调 → HAL_ERR_STATE,而且一个字节都不许发。
`hal_gpio_config()` 还没跑过,这时候碰总线等于在 `MUX_RST#` 悬空的情况下开工。
⚠️ **这一条必须是 main() 里的第一条**:`i2cbus` 没有 deinit 接口,
`s_inited` 是文件级 static,`sim_hal_reset()` 管不着它 ——
一旦前面有任何一条跑过 `i2cbus_init()`,「没 init」这个状态就再也造不出来了。
(真板上也一样:init 一次之后就没有回头路,所以这不是设计缺陷。) */
static void test_xact_before_init_is_a_state_error(void)
{
mount(); /* 挂好器件,但**不** i2cbus_init() */
sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR);
TEST_ASSERT_NOT_NULL(mux);
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, i2cbus_xact(0, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_UINT32(0, c.calls);
TEST_ASSERT_EQUAL_UINT32(0, mux->n_write);
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, i2cbus_recover());
}
/* 通道号越界要在拿锁之前挡掉。
⚠️ 特别是 `I2CBUS_CH_UNKNOWN`(-2):它是一个**内部状态**,不是一个能传进来的通道。
放过去的话 `1u << -2` 是未定义行为,而 x86 上多半"正常"跑出一个值来。 */
static void test_xact_rejects_bad_arguments_before_taking_the_lock(void)
{
mount_ready();
sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR);
TEST_ASSERT_NOT_NULL(mux);
const uint32_t nw = mux->n_write;
ctx_t c = { BLADE_INA, 0, -1, HAL_OK };
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, i2cbus_xact(I2CBUS_CH_UNKNOWN, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, i2cbus_xact(-3, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, i2cbus_xact(TCA9548A_CH_COUNT, 10, cb_read_mv, &c));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, i2cbus_xact(0, 10, NULL, &c));
TEST_ASSERT_EQUAL_UINT32(0, c.calls);
TEST_ASSERT_EQUAL_UINT32(nw, mux->n_write);
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C));
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_lock_timeouts());
}
/* `i2cbus_forget_channel()` 只是作废影子,**不碰器件** ——
它是给 recover 和测试用的。作废之后下一次事务必须重发选通。 */
static void test_forget_channel_only_drops_the_shadow(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2));
TEST_ASSERT_EQUAL_UINT32(1, i2cbus_selects());
i2cbus_forget_channel();
TEST_ASSERT_EQUAL_INT(I2CBUS_CH_UNKNOWN, i2cbus_channel());
TEST_ASSERT_EQUAL_HEX8(0x04, sim_i2c_mux_mask()); /* 器件没被动 */
TEST_ASSERT_EQUAL_INT32(k_mv[2], read_blade(2));
TEST_ASSERT_EQUAL_UINT32(2, i2cbus_selects()); /* 重发了 */
}
/* 巡检的形状:五路各一次 xact,来回跑 20 圈。
每一圈每一路读回来的都必须是自己那一片,而且**通道永远只开一路**。
这一条是前面所有断言的"耐久版"——单次对不代表跑久了还对
(影子写成 static 局部、计数溢出、掩码累加而不是覆盖,都要跑几圈才现形)。 */
static void test_a_full_sweep_stays_correct_for_many_rounds(void)
{
mount_ready();
for (int round = 0; round < 20; round++) {
for (int ch = 0; ch < N_BLADE; ch++) {
TEST_ASSERT_EQUAL_INT32(k_mv[ch], read_blade(ch));
TEST_ASSERT_EQUAL_INT(ch, sim_i2c_mux_ch()); /* 恰好一路,不是 -2 */
}
}
/* 一圈五次选通,20 圈 = 100 次;没有多也没有少。 */
TEST_ASSERT_EQUAL_UINT32(100, i2cbus_selects());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails());
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_lock_timeouts());
}
int main(void)
{
UNITY_BEGIN();
/* ⚠️ 这一条必须排在最前面,理由见它自己的注释(i2cbus 没有 deinit)。 */
RUN_TEST(test_xact_before_init_is_a_state_error);
RUN_TEST(test_init_leaves_the_mux_closed_and_out_of_reset);
RUN_TEST(test_init_survives_a_missing_mux_but_trusts_nothing);
RUN_TEST(test_xact_reads_the_blade_you_asked_for);
RUN_TEST(test_bypassing_i2cbus_silently_reads_the_wrong_phone);
RUN_TEST(test_trunk_is_a_channel_too);
RUN_TEST(test_a_second_xact_on_the_same_channel_skips_the_select);
RUN_TEST(test_a_failed_select_invalidates_the_shadow);
RUN_TEST(test_the_body_never_runs_on_a_channel_that_was_not_selected);
RUN_TEST(test_fail_streak_is_consecutive_and_one_success_clears_it);
RUN_TEST(test_a_shadow_hit_neither_counts_nor_clears);
RUN_TEST(test_recover_pulses_mux_rst_and_forgets_the_channel);
RUN_TEST(test_recover_is_rejected_inside_a_transaction);
RUN_TEST(test_a_nested_xact_is_rejected_and_does_not_deadlock);
RUN_TEST(test_a_lock_timeout_changes_nothing_at_all);
RUN_TEST(test_xact_rejects_bad_arguments_before_taking_the_lock);
RUN_TEST(test_forget_channel_only_drops_the_shadow);
RUN_TEST(test_a_full_sweep_stays_correct_for_many_rounds);
return UNITY_END();
}