固件: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 行删除
+321
查看文件
@@ -0,0 +1,321 @@
/* R-0:CH224A 驱动(底板 U36,USB-PD 受电芯片,挂在 mux 通道 5 上)。
板上它只干一件事:`R7` 120 k 硬件请求 20 V,固件**不参与协商**,
只读出「当前这个充电器在 20 V 档最多能给多少电流」,换算成整板功率上限
(计划 §5.9 / R1 §8.3 的降载)。
这份测试守三件事:
1. `0x50` 的单位是 **50 mA/LSB** —— 把单位记错,60 W 的充电器会被当成 6 W
(整板立刻降到只能开一路)或者 600 W(降载形同虚设,输入侧过流跳闸);
2. **读失败时不许动调用方的 `*ma`** —— 旧的上限比一个 0 可信;
3. ⛔ **驱动永远不写 `0x0A`**(`0x0A = 5` 是请求 28 V EPR,
板上 `D3` 是 SMBJ22A、VRWM 22 V,会把 TVS 打到雪崩)。
⚠️ `CH224A_ADDR_DEFAULT`(0x23)**还没有被实物证实**(计划 §11 的待定项),
所以驱动的每个接口都收 addr 参数,这份测试也从不把 0x23 当成已知事实 ——
见 test_every_entry_point_takes_the_address_because_it_is_unconfirmed。 */
#include "ch224a.h"
#include "hal_i2c.h"
#include "sim_hal.h"
#include "unity.h"
/* B1 上板扫描之前,测试里用哪个地址都行 —— 这正是要证明的事之一。
这里**故意不用** CH224A_ADDR_DEFAULT,免得哪天 0x23 被证伪时
这一整份测试跟着一起红。 */
#define TEST_ADDR 0x22
/* ---- 窃听口:这颗器件到底被写过什么 -------------------------------------- */
/* `hal_i2c_write_read()` 的「寄存器指针」那一段在假总线上也算一次 write,
所以断言 `n_write == 0` 是做不到的(只读驱动也得把指针写出去)。
退而求其次,但更严:
· `s_touched[r]` —— 寄存器指针曾经指向过 r;
· `s_data_bytes` —— 指针后面**跟过几个数据字节**(只读驱动必须恒为 0)。
「写 0x0A = 5」这个动作会同时踩中两条:s_touched[0x0A] 和 s_data_bytes。 */
static uint8_t s_touched[256];
static uint32_t s_data_bytes;
static void spy(sim_i2c_dev_t *d, sim_i2c_ev_t ev)
{
if (ev != SIM_I2C_EV_POST_WRITE) {
return;
}
if (d->last_write_len >= 1) {
s_touched[d->last_write[0]] = 1;
}
if (d->last_write_len > 1) {
s_data_bytes += (uint32_t)d->last_write_len - 1u;
}
}
void setUp(void)
{
sim_hal_reset();
for (int i = 0; i < 256; i++) {
s_touched[i] = 0;
}
s_data_bytes = 0;
}
void tearDown(void) {}
static sim_i2c_dev_t *mount(uint8_t addr)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL));
sim_i2c_dev_t *d = sim_i2c_add(addr, 1);
TEST_ASSERT_NOT_NULL(d);
d->hook = spy;
return d;
}
/* ---- 单位换算:50 mA/LSB ------------------------------------------------- */
/* ★ 三个真实档位。20 V 档下 60 W / 65 W / 100 W 分别是 3000 / 3250 / 5000 mA,
除以 50 正好是 60 / 65 / 100 —— **寄存器值的数字恰好等于瓦数**,
这个巧合很容易让人误以为「这个寄存器读出来就是瓦数」而把 ×50 省掉。
省掉的后果:60 W 充电器被当成 60 mA,整板功率上限 1.2 W,五路一个都开不起来;
反过来把单位当成 10 mA/LSB,100 W 的充电器会被算成 20 W —— 同样是静默的,
现象只是「插了 100 W 的充电器也只敢开两路」,没有任何报错。 */
static void test_current_register_is_50ma_per_lsb(void)
{
TEST_ASSERT_EQUAL_UINT32(3000, ch224a_ma_from_reg(60)); /* 60 W @20 V */
TEST_ASSERT_EQUAL_UINT32(3250, ch224a_ma_from_reg(65)); /* 65 W @20 V */
TEST_ASSERT_EQUAL_UINT32(5000, ch224a_ma_from_reg(100)); /* 100 W @20 V,要 e-marker 线 */
TEST_ASSERT_EQUAL_UINT32(0, ch224a_ma_from_reg(0));
TEST_ASSERT_EQUAL_UINT32(50, ch224a_ma_from_reg(1)); /* 1 LSB = 50 mA */
TEST_ASSERT_EQUAL_UINT32(12750, ch224a_ma_from_reg(255)); /* 满量程不溢出 */
/* 逐点线性,没有偏移:写成 (reg+1)*50 之类的实现在 reg=0 上就露馅。 */
for (unsigned r = 0; r <= 255u; r++) {
TEST_ASSERT_EQUAL_UINT32(r * 50u, ch224a_ma_from_reg((uint8_t)r));
}
}
/* ★ 功率上限按 **20 V** 档换算,不是 5 V 也不是 12 V。
板上是 `R7` 一颗电阻把档位定死在 20 V,降压 `U37` 再变 12 V ——
拿 12 V(母线电压)去乘是最自然的一个错,而且算出来的数看着也很合理
(100 W 的充电器会被算成 60 W),于是最坏工况下真的会过载跳闸。 */
static void test_power_limit_is_twenty_volts_times_the_current(void)
{
TEST_ASSERT_EQUAL_UINT32(60000, ch224a_power_limit_mw(3000));
TEST_ASSERT_EQUAL_UINT32(65000, ch224a_power_limit_mw(3250));
TEST_ASSERT_EQUAL_UINT32(100000, ch224a_power_limit_mw(5000));
TEST_ASSERT_EQUAL_UINT32(0, ch224a_power_limit_mw(0));
/* 档位是 20 000 mV,不是 12 000 / 5 000。 */
TEST_ASSERT_EQUAL_UINT32(20000, CH224A_FIXED_MV);
TEST_ASSERT_EQUAL_UINT32(20u * 1234u, ch224a_power_limit_mw(1234));
/* 满量程 12750 mA = 255 W。中间过程若用 32 位相乘
(20000 × 12750 = 255 000 000)还没溢出,但离 uint32 上限只剩一个数量级 ——
驱动里用 uint64 中转就是为这条。 */
TEST_ASSERT_EQUAL_UINT32(255000, ch224a_power_limit_mw(ch224a_ma_from_reg(255)));
/* 两个函数串起来:寄存器 → 功率,整条链一次对完。 */
TEST_ASSERT_EQUAL_UINT32(60000, ch224a_power_limit_mw(ch224a_ma_from_reg(60)));
TEST_ASSERT_EQUAL_UINT32(100000, ch224a_power_limit_mw(ch224a_ma_from_reg(100)));
}
/* ---- 总线上的行为 -------------------------------------------------------- */
/* 读到什么就换算什么,而且只发一次事务(写指针 + 读一个字节)。 */
static void test_read_max_ma_converts_what_the_chip_says(void)
{
sim_i2c_dev_t *d = mount(TEST_ADDR);
sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 65); /* 65 W 的砖 */
uint32_t ma = 0;
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma));
TEST_ASSERT_EQUAL_UINT32(3250, ma);
TEST_ASSERT_EQUAL_UINT32(1, d->n_read);
TEST_ASSERT_EQUAL_HEX8(CH224A_REG_CURRENT, d->last_write[0]); /* 读的是 0x50 */
/* 换一块 100 W 的:同一个接口,不需要重新 init。 */
sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 100);
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma));
TEST_ASSERT_EQUAL_UINT32(5000, ma);
TEST_ASSERT_EQUAL_UINT32(100000, ch224a_power_limit_mw(ma));
}
/* ★ 读失败时 `*ma` **一个字节都不许动**。
写成「先清零再读」的后果:总线上一次毛刺 → 上限变成 0 mA → 降载逻辑认为
这块砖一点电都给不了 → 五路全部降载甚至停机。而下一个周期读成功了又恢复,
现象是「偶尔全部掉一下」,日志里除了一条 NAK 什么线索都没有。
旧的上限即使过期了也比一个 0 可信 —— 充电器不会在运行期被换掉。 */
static void test_a_failed_read_leaves_the_previous_limit_untouched(void)
{
mount(TEST_ADDR);
sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 100);
uint32_t ma = 3000; /* 上一轮读到的 60 W */
sim_i2c_inject_nak(TEST_ADDR, 1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_read_max_ma(TEST_ADDR, &ma));
TEST_ASSERT_EQUAL_UINT32(3000, ma); /* ★ 没被改成 0,也没被改成 5000 */
/* 器件失联(芯片挂了 / PD 口拔了)同样如此。 */
sim_i2c_set_present(TEST_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_read_max_ma(TEST_ADDR, &ma));
TEST_ASSERT_EQUAL_UINT32(3000, ma);
/* 恢复之后才更新。 */
sim_i2c_set_present(TEST_ADDR, true);
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma));
TEST_ASSERT_EQUAL_UINT32(5000, ma);
}
/* 状态寄存器是 0x09,而且**不校验内容** —— 这颗芯片没有 ID 寄存器,
状态字的每一个值都是合法的。拿状态字当 ID 校验的后果是
「协商到某个档位时启动自检就过不去」,而那完全取决于插的是哪块砖。 */
static void test_probe_accepts_any_status_byte(void)
{
sim_i2c_dev_t *d = mount(TEST_ADDR);
for (unsigned v = 0; v <= 255u; v++) {
sim_i2c_set_reg(TEST_ADDR, CH224A_REG_STATUS, (uint16_t)v);
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_probe(TEST_ADDR));
uint8_t st = 0xAA;
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_status(TEST_ADDR, &st));
TEST_ASSERT_EQUAL_HEX8((uint8_t)v, st);
}
TEST_ASSERT_EQUAL_HEX8(CH224A_REG_STATUS, d->last_write[0]);
sim_i2c_set_present(TEST_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_probe(TEST_ADDR));
}
/* NULL 出参一律 HAL_ERR_ARG,而且**在碰总线之前**就返回。 */
static void test_null_outputs_are_rejected_before_touching_the_bus(void)
{
sim_i2c_dev_t *d = mount(TEST_ADDR);
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, ch224a_read_status(TEST_ADDR, NULL));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, ch224a_read_max_ma(TEST_ADDR, NULL));
TEST_ASSERT_EQUAL_UINT32(0, d->n_read);
TEST_ASSERT_EQUAL_UINT32(0, d->n_write);
}
/* ---- ⛔ 那条禁令 --------------------------------------------------------- */
/* ⭐⭐ **社会性用例:驱动里不存在任何写 `0x0A` 的接口。**
C 里没法断言「某个函数不存在」,所以这里从**线上**反过来证:
把驱动的每一个公开接口都跑一遍(而且是反复跑),然后断言
· 寄存器指针**从来没有指向过 0x0A**;
· 指针后面**从来没有跟过数据字节**(`s_data_bytes == 0`)—— 也就是说
这颗器件在整个测试跑完之后,一次真正的「写寄存器」都没有发生过;
· 驱动碰过的寄存器只有 `0x09` 和 `0x50` 两个,一个不多。
⚠️ 为什么不是直接断言 `n_write == 0`:`hal_i2c_write_read()` 的
「发寄存器指针」那一段在假总线上也计一次 write,只读驱动也躲不掉。
`n_write == 0` 在这颗器件上永远做不到,所以换成上面三条 —— 它们更严:
`n_write == 0` 拦不住「写 0x0A 用的是 write_read」,`s_data_bytes == 0` 能。
为什么这件事值得单列一条用例:`0x0A = 5` 是「请求 28 V EPR」。
板上 `D3` 是 SMBJ22A(VRWM 22 V),`U37` 的 EN 分压按 20 V 设计 ——
28 V 会把 TVS 打到雪崩,一次就报废。这个动作**不会报错**,
它是 CH224A 完全合法的一条命令,芯片会老老实实去协商。
所以防线不是「叮嘱别用」,是**根本不提供接口**(和 `sr595` 不提供抬 `OE#`、
`pca9555` 不提供把接地脚配成输出,是同一条规矩)。
这条用例的作用是:哪天有人「顺手加个 ch224a_request_mv()」,这里立刻红。 */
static void test_the_driver_never_writes_a_single_data_byte(void)
{
mount(TEST_ADDR);
sim_i2c_set_reg(TEST_ADDR, 0x0A, 0x5A); /* 哨兵:谁动了它谁露馅 */
sim_i2c_set_reg(TEST_ADDR, CH224A_REG_CURRENT, 60);
/* 把公开接口全跑一遍,跑够多次(万一有人写成「第 N 次才配置一下」)。 */
for (int i = 0; i < 16; i++) {
uint8_t st = 0;
uint32_t ma = 0;
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_probe(TEST_ADDR));
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_status(TEST_ADDR, &st));
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(TEST_ADDR, &ma));
TEST_ASSERT_EQUAL_UINT32(3000, ma);
TEST_ASSERT_TRUE(ch224a_addr_ok(TEST_ADDR));
(void)ch224a_ma_from_reg(60);
(void)ch224a_power_limit_mw(3000);
}
/* ★ 一个数据字节都没写出去 = 这是一颗只读器件。 */
TEST_ASSERT_EQUAL_UINT32(0, s_data_bytes);
/* ★ 指针从没指向过 0x0A。 */
TEST_ASSERT_EQUAL_UINT8(0, s_touched[0x0A]);
/* ★ 哨兵没被动过。 */
TEST_ASSERT_EQUAL_HEX16(0x5A, sim_i2c_get_reg(TEST_ADDR, 0x0A));
/* ★ 碰过的寄存器只有这两个。 */
for (int r = 0; r < 256; r++) {
const uint8_t want = (r == CH224A_REG_STATUS || r == CH224A_REG_CURRENT) ? 1u : 0u;
TEST_ASSERT_EQUAL_UINT8(want, s_touched[r]);
}
/* 这条检查本身不是句空话:手工写一次 0x0A = 5(也就是那个禁令动作),
窃听口必须立刻照出来。抓不到的话上面那四条断言全是摆设。 */
const uint8_t evil[2] = { 0x0A, 5 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(TEST_ADDR, evil, sizeof(evil))); /* 器件不报错 */
TEST_ASSERT_EQUAL_UINT32(1, s_data_bytes);
TEST_ASSERT_EQUAL_UINT8(1, s_touched[0x0A]);
TEST_ASSERT_EQUAL_HEX16(5, sim_i2c_get_reg(TEST_ADDR, 0x0A)); /* 28 V 就是这么请求的 */
}
/* ---- 地址 ---------------------------------------------------------------- */
/* ⚠️ **0x23 还没有被实物证实**(计划 §11)。这条用例钉住的是
「驱动不把地址写死」这件事,而不是 0x23 本身:
所有接口都收 addr 参数,换一个地址照样跑得通。
B1 上板扫描时把通道 5 上真正扫到的那个地址填回 `CH224A_ADDR_DEFAULT`
并同步改计划 §5.2 —— 在那之前,用 0x23 扫不到是**预期结果,不是固件 bug**。 */
static void test_every_entry_point_takes_the_address_because_it_is_unconfirmed(void)
{
/* 器件蹲在 0x22,`CH224A_ADDR_DEFAULT` 上什么也没有。 */
mount(0x22);
sim_i2c_set_reg(0x22, CH224A_REG_CURRENT, 60);
uint8_t st = 0;
uint32_t ma = 0;
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_probe(0x22));
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_status(0x22, &st));
TEST_ASSERT_EQUAL_INT(HAL_OK, ch224a_read_max_ma(0x22, &ma));
TEST_ASSERT_EQUAL_UINT32(3000, ma);
/* 默认值上扫不到 —— 这就是 B1 之前应当看到的现象。 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ch224a_probe(CH224A_ADDR_DEFAULT));
/* 顺带确认默认值还是那个待确认的 0x23(改了就该同时改计划 §5.2)。 */
TEST_ASSERT_EQUAL_HEX8(0x23, CH224A_ADDR_DEFAULT);
TEST_ASSERT_TRUE(ch224a_addr_ok(CH224A_ADDR_DEFAULT));
}
/* 地址冲突表:主干上的 4 个 + mux 自己 + 刀片支路上的 2 个。
⚠️ 与 `tca9548a_addr_ok()` 不同,这里**连 0x20 / 0x40 也要查** ——
CH224A 挂在通道 5 上,选到通道 5 的那一刻它和主干是连通的;
要是它和某片刀片同址,那更是直接把刀片的 INA226 盖住。 */
static void test_addr_ok_rejects_every_address_already_on_the_board(void)
{
static const uint8_t taken[] = { 0x20, 0x40, 0x43, 0x4A, 0x4B, 0x70 };
for (unsigned i = 0; i < sizeof(taken); i++) {
TEST_ASSERT_FALSE(ch224a_addr_ok(taken[i]));
}
TEST_ASSERT_TRUE(ch224a_addr_ok(0x22));
TEST_ASSERT_TRUE(ch224a_addr_ok(0x23));
TEST_ASSERT_TRUE(ch224a_addr_ok(0x7f));
TEST_ASSERT_FALSE(ch224a_addr_ok(0x80)); /* 7 位地址,0x80 起就不是地址了 */
TEST_ASSERT_FALSE(ch224a_addr_ok(0xFF));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_current_register_is_50ma_per_lsb);
RUN_TEST(test_power_limit_is_twenty_volts_times_the_current);
RUN_TEST(test_read_max_ma_converts_what_the_chip_says);
RUN_TEST(test_a_failed_read_leaves_the_previous_limit_untouched);
RUN_TEST(test_probe_accepts_any_status_byte);
RUN_TEST(test_null_outputs_are_rejected_before_touching_the_bus);
RUN_TEST(test_the_driver_never_writes_a_single_data_byte);
RUN_TEST(test_every_entry_point_takes_the_address_because_it_is_unconfirmed);
RUN_TEST(test_addr_ok_rejects_every_address_already_on_the_board);
return UNITY_END();
}
+129 -2
查看文件
@@ -76,19 +76,45 @@ static void test_flash_format_is_frozen(void)
TEST_ASSERT_EQUAL_INT(11, LOG_EV_FAN_STALL);
TEST_ASSERT_EQUAL_INT(12, LOG_EV_REBOOT);
TEST_ASSERT_EQUAL_INT(13, LOG_EV_CMD_REJECT);
TEST_ASSERT_EQUAL_INT(14, LOG_EV_COUNT);
/* ⭐ R2 新增的三条(计划 §5.8 / §5.9)。追加在末尾,前面 13 个一个都没动 ——
所以 R1 那块板子上跑了半年的 flash 拿到 R2 固件上仍然读得对。
⚠️ 这三行不是「补全」,它们是钉子:谁要是图省事把 MUX_LOST 插到
OVER_VOLT 前面("安全事件排一起好看"),历史记录里的 3 就变成了 mux 失联,
而读日志的工具、板子、测试全都不会报一个字。 */
TEST_ASSERT_EQUAL_INT(14, LOG_EV_MUX_LOST);
TEST_ASSERT_EQUAL_INT(15, LOG_EV_SLOT_EMPTY);
TEST_ASSERT_EQUAL_INT(16, LOG_EV_PD_LIMIT);
TEST_ASSERT_EQUAL_INT(17, LOG_EV_COUNT);
}
/* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住,
免得 A12/A15/A16 各自往中间插一个,把 flash 里的旧记录挪了位。 */
免得 A12/A15/A16 各自往中间插一个,把 flash 里的旧记录挪了位。
⭐ 这条用例的上界是 `LOG_EV_COUNT`,所以 R2 追加的 14/15/16 **自动**被它盖住:
往枚举里加一条却忘了在 `evlog_ev_str()` 里加一行,这里立刻红。
(`evlog_ev_str()` 的 switch 没有 `default`,`-Wswitch` 本来也会拦一道;
但只要有人手贱补一个 `default: return "?"`,编译器那一道就没了 —— 这条还在。) */
static void test_every_event_type_has_a_name(void)
{
const char *names[LOG_EV_COUNT];
for (int i = 0; i < LOG_EV_COUNT; i++) {
const char *s = evlog_ev_str((log_ev_t)i);
TEST_ASSERT_NOT_NULL(s);
TEST_ASSERT_TRUE_MESSAGE(s[0] != '?', "有事件类型没给名字");
names[i] = s;
}
TEST_ASSERT_EQUAL_STRING("?", evlog_ev_str((log_ev_t)LOG_EV_COUNT));
/* 名字还得**两两不同**。加新类型时最省事的写法是复制上一行的 `case` ——
复制完忘了改字符串,上面那个循环一个字都不会说。真板上的现象是
日志里两种完全不同的事件打出同一行字(比如「刀片不在位」显示成
「mux失联全断」),排查的人会照着那行字去查错的那条链。 */
for (int i = 0; i < LOG_EV_COUNT; i++) {
for (int j = i + 1; j < LOG_EV_COUNT; j++) {
TEST_ASSERT_FALSE_MESSAGE(strcmp(names[i], names[j]) == 0,
"两个事件类型共用了同一个名字");
}
}
}
/* `safety_ev_t` 与 `log_ev_t` 是两套编号,中间是一个**显式的 switch**。
@@ -108,6 +134,105 @@ static void test_the_safety_mapping_is_explicit(void)
TEST_ASSERT_NOT_EQUAL((int)SAFETY_EV_PUSHED_UP, (int)LOG_EV_PUSHED_UP);
}
/* ⭐ R2:把「显式 switch」这件事**用具体数字**钉死。
上面那条用例全用符号写(`LOG_EV_REVERSE`),它证明得了「对应关系没接错」,
证明不了「这个函数不是一个偏移」—— 因为把 switch 换成
`return (log_ev_t)(LOG_EV_OVER_VOLT + safety_ev - 1)` 之后,
R1 那四条(1→3 2→4 3→5 4→6)的符号断言**全部照样通过**。
R2 追加的两条恰好是这个写法的照妖镜:`safety_ev_t` 里它们排在 5/6,
而 `log_ev_t` 里它们是 14/15(前面还隔着 A12/A15/A16 占的七个号)。
偏移写法会把它们算成 7 和 8 —— 也就是 `LOG_EV_EFUSE_FAULT` 与 `LOG_EV_PG_CHANGE`。
**这个错在功能测试里完全看不见**:日志照写、条数不少、CRC 也对,
只有事后读日志的人会看到「五路全断」那一刻记的是「eFuse故障」。
所以下面每一对都写成裸数字:两个枚举里任何一边动了号,这条都红。 */
static void test_the_safety_mapping_is_a_table_not_an_offset(void)
{
/* 先把两边的号本身钉住 —— 不然「偏移对上了」可能是因为有人挪了 safety 那边。 */
TEST_ASSERT_EQUAL_INT(0, SAFETY_EV_NONE);
TEST_ASSERT_EQUAL_INT(1, SAFETY_EV_OVER_VOLT);
TEST_ASSERT_EQUAL_INT(2, SAFETY_EV_UNDER_VOLT);
TEST_ASSERT_EQUAL_INT(3, SAFETY_EV_REVERSE);
TEST_ASSERT_EQUAL_INT(4, SAFETY_EV_PUSHED_UP);
TEST_ASSERT_EQUAL_INT(5, SAFETY_EV_MUX_LOST);
TEST_ASSERT_EQUAL_INT(6, SAFETY_EV_SLOT_EMPTY);
/* 逐对:safety 号 → flash 号,两边都是裸数字。 */
TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(0)); /* NONE */
TEST_ASSERT_EQUAL_INT(3, (int)evlog_from_safety(1)); /* OVER_VOLT */
TEST_ASSERT_EQUAL_INT(4, (int)evlog_from_safety(2)); /* UNDER_VOLT */
TEST_ASSERT_EQUAL_INT(5, (int)evlog_from_safety(3)); /* REVERSE */
TEST_ASSERT_EQUAL_INT(6, (int)evlog_from_safety(4)); /* PUSHED_UP */
TEST_ASSERT_EQUAL_INT(14, (int)evlog_from_safety(5)); /* MUX_LOST */
TEST_ASSERT_EQUAL_INT(15, (int)evlog_from_safety(6)); /* SLOT_EMPTY */
/* 而且这张表**在数学上不可能**是一个偏移:前四对差 2,后两对差 9。
写死这一条,是为了让「改成偏移」这件事在用例名上就说得清楚,
而不是靠上面某一行恰好不满足。 */
TEST_ASSERT_EQUAL_INT(2, (int)evlog_from_safety(1) - (int)SAFETY_EV_OVER_VOLT);
TEST_ASSERT_EQUAL_INT(9, (int)evlog_from_safety(5) - (int)SAFETY_EV_MUX_LOST);
/* 越过表尾的号一律 NONE。偏移写法在这里会算出一个**越界但合法**的
`log_ev_t`(7 → 记成 eFuse 故障,200 → 记成 202 这种根本没有的号)。 */
TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(7));
TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(200));
TEST_ASSERT_EQUAL_INT(0, (int)evlog_from_safety(255));
}
/* ⭐ R2 的两条新判据要真的落得了盘、读得回来。
这里刻意走**完整的一条链**:`safety_ev_fn` 回调 → RAM 环 → flash → 重启 → 读回。
只断言 `evlog_from_safety()` 的返回值是不够的 —— `evlog_put_safety()` 里
`r.rail = ev->rail` 这一行同样能出错,而 mux 失联是**整板级事件(rail = 0)**,
正好落在「路号是 1~5」这个到处都在假设的前提之外:
谁要是在入队或读回的路上顺手做一次 `rail - 1` 当下标、或者把 0 「修正」成 1,
日志里五路全断那一刻就记成了「CH1 出事」,而板子、条数、CRC 全都正常。 */
static void test_the_r2_events_round_trip_through_flash(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
const safety_ev_fn sink = evlog_put_safety; /* 按 A10 的出口类型接 */
t_safety_set_event_sink(sink, NULL);
/* mux 失联:**整板级**,rail = 0,五路全断那一刻的输入侧读数。 */
const safety_event_t mux = {
.t_ms = 50000, .rail = 0, .type = SAFETY_EV_MUX_LOST,
.bus_mv = 12050, .current_ua = 4300000,
};
/* 刀片不在位:**单路**,rail = 5(那一路正好是 PRSNT# 不顺排的 IO7)。 */
const safety_event_t empty = {
.t_ms = 50007, .rail = 5, .type = SAFETY_EV_SLOT_EMPTY,
.bus_mv = 0, .current_ua = 0,
};
sim_time_advance_ms(88888); /* 记账在断电之后:时间戳不许换成「现在」 */
sink(NULL, &mux);
sink(NULL, &empty);
TEST_ASSERT_EQUAL_UINT32(3, evlog_flush(0)); /* BOOT + 这两条 */
/* 断电重启:这两条必须是从 **flash** 里读回来的,不是还留在 RAM 里。 */
sim_hal_reboot();
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_store_erases(), "重启把日志擦了");
const evlog_rec_t a = read_one(1);
TEST_ASSERT_EQUAL_INT_MESSAGE(14, a.type, "mux 失联没按 14 号落盘");
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, a.rail, "整板级事件被安到了某一路头上");
TEST_ASSERT_EQUAL_UINT32(50000, a.t_ms);
TEST_ASSERT_EQUAL_INT32(12050, a.bus_mv);
TEST_ASSERT_EQUAL_INT32(4300000, a.current_ua);
const evlog_rec_t b = read_one(2);
TEST_ASSERT_EQUAL_INT_MESSAGE(15, b.type, "刀片不在位没按 15 号落盘");
TEST_ASSERT_EQUAL_UINT8(5, b.rail);
TEST_ASSERT_EQUAL_UINT32(50007, b.t_ms);
/* 读日志的人看到的是名字,不是数字 —— 顺带确认这两条打得出人话来。 */
TEST_ASSERT_EQUAL_STRING("mux失联全断", evlog_ev_str((log_ev_t)a.type));
TEST_ASSERT_EQUAL_STRING("刀片不在位", evlog_ev_str((log_ev_t)b.type));
}
/* A10 的事件出口是 `safety_ev_fn`。A11 要能**原样**接上去(HANDOFF 里
「只接 sink,不动判据」那条),所以这里就按那个函数指针类型调一次。 */
static void test_the_sink_plugs_into_the_safety_layer_unchanged(void)
@@ -622,6 +747,8 @@ int main(void)
RUN_TEST(test_flash_format_is_frozen);
RUN_TEST(test_every_event_type_has_a_name);
RUN_TEST(test_the_safety_mapping_is_explicit);
RUN_TEST(test_the_safety_mapping_is_a_table_not_an_offset);
RUN_TEST(test_the_r2_events_round_trip_through_flash);
RUN_TEST(test_the_sink_plugs_into_the_safety_layer_unchanged);
RUN_TEST(test_capacity_meets_the_requirement);
RUN_TEST(test_putting_events_never_touches_flash);
+104 -3
查看文件
@@ -1,12 +1,25 @@
/* A2 验收主项:能在假总线上挂一个器件并读回预置值。
顺带把「器件失联 / 偶发 NAK」这两种后面 A15 要用的故障注入也测掉。 */
顺带把「器件失联 / 偶发 NAK」这两种后面 A15 要用的故障注入也测掉。
---- R2 新增:假总线有了 `bus` 与一颗假 mux ------------------------------
五片刀片**完全同构**(同址 0x40 / 0x20),真板上区分它们的唯一手段是底板
TCA9548A 的通道。假总线如果只按地址查表,五片就塌成一片 ——
于是「选错通道」这一类 R2 最危险的静默错误在宿主机上一次也复现不出来。
所以器件带一个 `bus`(-1 主干 / 0~7 支路),本文件最后两条用例测的就是它:
**关着的支路一律 NAK**、**同时开两路会被记账**。
原有那几条用的仍是 `sim_i2c_add()`(挂主干),语义没变。 */
#include <string.h>
#include "sim_hal.h"
#include "unity.h"
#define INA226_CH1 0x40 /* 设计文档 §8.2 的地址表;A3 才会真的写驱动 */
#define PCA9555 0x20
#define INA226_RAIL 0x40 /* R2:五片刀片上的那片,五片同址(计划 §5.2) */
#define INA226_INPUT 0x43 /* R2:底板输入侧 U13,挂**主干** */
#define PCA9555 0x20
#define MUX 0x70 /* TCA9548A(底板 U35) */
#define MUX_RST_PIN 40 /* `MUX_RST#` 接的 GPIO,本文件用不到,给个真值免得误导 */
#define INA226_CH1 INA226_RAIL /* 原有用例沿用的名字 */
void setUp(void) { sim_hal_reset(); }
void tearDown(void) {}
@@ -120,6 +133,92 @@ static void test_address_out_of_range(void)
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_i2c_probe(0x80));
}
/* ---- R2:mux 支路 ---------------------------------------------------------
控制字是**掩码不是编号**,而且写 mux 是**一个裸字节**(不带寄存器号)。
这两件事都在 tca9548a.h 的文件头里;这里直接按线上的样子发。 */
static void select_mask(uint8_t mask)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(MUX, &mask, 1));
}
/* ⭐ 通道**关着**的时候,挂在那条支路上的器件一律 NAK —— 真器件就是这个行为:
支路被断开,从机根本收不到地址。
⚠️ 假总线要是不做这一层(只按地址查表),五片刀片就塌成一片:
「忘了选通道」「选错通道」「选通道失败却照读」这三种错在宿主机上**全都是绿的**,
而它们在真板上的现象是「读到的是另一台手机的电压电流」——
量纲、范围、变化趋势全部正常,没有任何一层会报错。 */
static void test_device_behind_a_closed_channel_naks(void)
{
init_bus();
sim_i2c_add(INA226_INPUT, 2); /* 主干上那片,永远看得见 */
sim_i2c_add_ch(2, INA226_RAIL, 2); /* 槽 3 的刀片 */
sim_i2c_add_ch(3, INA226_RAIL, 2); /* 槽 4 的刀片,**同一个地址** */
sim_i2c_add_mux(MUX, MUX_RST_PIN);
sim_i2c_set_reg_ch(2, INA226_RAIL, 0x02, 0x1111);
sim_i2c_set_reg_ch(3, INA226_RAIL, 0x02, 0x2222);
/* 上电全关:两片刀片谁也不应答,而主干那片照常。 */
TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(INA226_RAIL));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_INPUT));
/* 选中槽 3:同一个地址,读回来的必须是**那一片**的寄存器。 */
select_mask(1u << 2);
TEST_ASSERT_EQUAL_INT(2, sim_i2c_mux_ch());
const uint8_t ptr = 0x02;
uint8_t rd[2] = { 0, 0 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write_read(INA226_RAIL, &ptr, 1, rd, sizeof(rd)));
TEST_ASSERT_EQUAL_HEX16(0x1111, (uint16_t)((rd[0] << 8) | rd[1]));
/* 换成槽 4:地址一个字没改,读回来的却是另一片 —— 这就是「选错通道」的全貌。 */
select_mask(1u << 3);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write_read(INA226_RAIL, &ptr, 1, rd, sizeof(rd)));
TEST_ASSERT_EQUAL_HEX16(0x2222, (uint16_t)((rd[0] << 8) | rd[1]));
/* 槽 3 那片这时**一次都没有**再被访问过。 */
TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_dev_ch(2, INA226_RAIL)->n_read);
/* 关回全断:两片都不见了,主干那片还在。 */
select_mask(0);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(INA226_RAIL));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_INPUT));
/* 全程只开过一路,不许有「两片一起应答」。 */
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* ⭐ 同一个地址同时看得见两片 = **通道开重了**(控制字是掩码,写 0x03 不会报错)。
真板上那是两片一起应答,主机读回来的是两片的**线与** ——
数值在量程里、随负载变化、还特别稳,谁也看不出这是两台手机的数据与在一起了。
所以这个计数必须恒为 0;它非 0 的那一刻,就是所有电流读数开始悄悄偏小的那一刻。 */
static void test_two_channels_open_at_once_is_counted_as_ambiguous(void)
{
init_bus();
sim_i2c_add_ch(0, INA226_RAIL, 2);
sim_i2c_add_ch(1, INA226_RAIL, 2);
sim_i2c_add_mux(MUX, MUX_RST_PIN);
select_mask(1u << 0); /* 规规矩矩只开一路 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_RAIL));
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
select_mask(0x03); /* 两路一起开:写下去**不报错**,器件也不抗议 */
TEST_ASSERT_EQUAL_INT_MESSAGE(-2, sim_i2c_mux_ch(), "多于一路不是一种用法,是 bug");
TEST_ASSERT_EQUAL_HEX8(0x03, sim_i2c_mux_mask());
/* 事务照样「成功」—— 这正是它难查的地方。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_RAIL));
TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_ambiguous());
uint8_t rd[2];
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_read(INA226_RAIL, rd, sizeof(rd)));
TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, sim_i2c_ambiguous(), "每一次事务都记一笔");
/* 收回成一路,计数就不再涨(它是「发生过几次」,不是一个状态位)。 */
select_mask(1u << 1);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(INA226_RAIL));
TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_ambiguous());
}
int main(void)
{
UNITY_BEGIN();
@@ -132,5 +231,7 @@ int main(void)
RUN_TEST(test_transient_nak_then_recovers);
RUN_TEST(test_partial_register_length_rejected);
RUN_TEST(test_address_out_of_range);
RUN_TEST(test_device_behind_a_closed_channel_naks);
RUN_TEST(test_two_channels_open_at_once_is_counted_as_ambiguous);
return UNITY_END();
}
+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();
}
+154 -42
查看文件
@@ -1,10 +1,28 @@
/* A3:INA226 驱动 + 六片地址表。
两个验收主项:①六个地址就是 0x40/41/44/45/42/43;②通道号 ≠ 地址低位。 */
/* A3:INA226 驱动 + 地址表(**R2 版**,计划 §5.2)。
两个验收主项:
① 总线上只有**两个** INA226 地址:刀片那五片全是 `0x40`,底板输入侧 `U13` 是 `0x43`;
② ⭐ **五路同址不是 bug** —— 区分是哪一路的是 **mux 通道**,不是地址。
---- R1 → R2 换掉了什么 --------------------------------------------------
R1 是「六片六个地址、一条总线」,于是那时的验收主项是「通道号 ≠ 地址低位」,
拦的是有人写出 `0x40 + rail`。R2 把五路做成五片完全同构的刀片(D8),
地址 strap 全部焊死在 0x40,**`0x40 + rail` 这行代码现在写出来会当场读不到器件**,
一上板就暴露。真正接替它的坑是通道:选错通道读到的是**另一台手机**的电压电流,
量纲、范围、变化趋势全都正常,没有任何一层会报错。
所以那条用例在这里被换成了「五路地址必须全相同」,
而「五路通道必须两两不同」那一半在 `test_rail_cfg.c` 里。 */
#include "ina226.h"
#include "sim_hal.h"
#include "unity.h"
void setUp(void) { sim_hal_reset(); }
void setUp(void)
{
sim_hal_reset();
for (uint8_t a = 0x40; a <= 0x4F; a++) { /* 对调登记是驱动里的静态表,不清会串到别的用例 */
ina226_set_reversed(a, false);
}
}
void tearDown(void) {}
/* 挂一片假 INA226 并预置 ID,返回地址。 */
@@ -19,41 +37,58 @@ static uint8_t mount(uint8_t addr)
/* ---- 地址表 -------------------------------------------------------------- */
/* ⭐ 验收主项一:设计文档 §8.2 的六个地址,逐个钉死。 */
static void test_addr_table_is_the_one_from_8_2(void)
/* ⭐ 验收主项一:总线上出现过的 INA226 地址只有两个,逐个钉死。
出处:刀片 `U3` 的 pin1/pin2(A1/A0)都接 GND ⇒ 0x40;底板 `U13` pin1=GND、
pin2=SCL ⇒ 0x43(`.claude/r2/_blade_pcb_nets.json` / `_base_pcb_nets.json`)。 */
static void test_addr_list_is_the_two_from_5_2(void)
{
TEST_ASSERT_EQUAL_HEX8(0x40, ina226_rail_addr(1));
TEST_ASSERT_EQUAL_HEX8(0x41, ina226_rail_addr(2));
TEST_ASSERT_EQUAL_HEX8(0x44, ina226_rail_addr(3));
TEST_ASSERT_EQUAL_HEX8(0x45, ina226_rail_addr(4));
TEST_ASSERT_EQUAL_HEX8(0x42, ina226_rail_addr(5));
static const uint8_t want[INA226_ADDR_COUNT] = { 0x40, 0x43 };
TEST_ASSERT_EQUAL_INT_MESSAGE(2, INA226_ADDR_COUNT, "R2 只有两个地址,不是六个");
TEST_ASSERT_EQUAL_HEX8_ARRAY(want, ina226_addr_list, INA226_ADDR_COUNT);
TEST_ASSERT_EQUAL_HEX8(0x40, INA226_ADDR_RAIL);
TEST_ASSERT_EQUAL_HEX8(0x43, INA226_ADDR_INPUT);
static const uint8_t want[INA226_COUNT] = { 0x40, 0x41, 0x44, 0x45, 0x42, 0x43 };
TEST_ASSERT_EQUAL_HEX8_ARRAY(want, ina226_all_addr, INA226_COUNT);
/* **物理片数仍然是 6**。两个数不一样,正是 R2 的全部意思 ——
把 `INA226_COUNT` 当成「扫描要扫几个地址」用,就会去扫 0x44/0x45 那种
R2 板上根本不存在的地址,然后得出「四片 INA226 失联」的结论。 */
TEST_ASSERT_EQUAL_INT(6, INA226_COUNT);
TEST_ASSERT_EQUAL_INT(5, INA226_RAIL_COUNT);
}
/* ⭐ 验收主项二:`0x40 + ch` 这行代码一定会被写出来(需求 §10.4),
这条测试就是拦它的 —— 五路里有三路对不上,把表改成算式必红。 */
static void test_channel_index_is_not_the_address_low_bits(void)
/* ⭐ 验收主项二:**五路的地址全是 0x40 —— 这不是 bug。**
五片刀片完全同构(D8),地址 strap 焊死;区分是哪一路的是
`rail_cfg(rail)->mux_ch`,光有地址是选不中任何一路的。
⚠️ 写错了会怎样:把这张表改回「每路一个地址」(R1 的记忆),
CH2~CH5 一上板就读不到器件 —— 这一半会当场暴露,不可怕。
可怕的是反过来:既然五路同址,任何「拿到地址就直接读」的代码
读的都是**上一次选中的那一路**,五路数字全部正常、还很稳,只是有几路是同一份。
这条用例只能钉住地址这一半,另一半(通道两两不同)在 test_rail_cfg.c 里。 */
static void test_all_five_rails_share_one_address(void)
{
int mismatches = 0;
for (int rail = 1; rail <= INA226_RAIL_COUNT; rail++) {
uint8_t naive = (uint8_t)(0x40 + (rail - 1));
if (ina226_rail_addr(rail) != naive) {
mismatches++;
TEST_ASSERT_EQUAL_HEX8_MESSAGE(INA226_ADDR_RAIL, ina226_rail_addr(rail),
"五路同址是设计(D8),不是笔误");
}
/* 输入侧那片**必须**不同址:它挂主干,和刀片那片同时可见。 */
TEST_ASSERT_NOT_EQUAL(INA226_ADDR_RAIL, INA226_ADDR_INPUT);
/* R1 那行 `0x40 + rail` 现在对 CH1 仍然「碰巧对」,对其余四路全错 ——
写下来是为了让「拿 CH1 试一下没问题」这条老路在这里就断掉。 */
int naive_ok = 0;
for (int rail = 1; rail <= INA226_RAIL_COUNT; rail++) {
if (ina226_rail_addr(rail) == (uint8_t)(0x40 + rail - 1)) {
naive_ok++;
}
}
TEST_ASSERT_EQUAL_INT_MESSAGE(3, mismatches, "CH3/CH4/CH5 三路必须与顺排不同");
/* 反过来也钉一遍:拿地址低位算回通道号同样会算错。 */
TEST_ASSERT_NOT_EQUAL(0x42, ina226_rail_addr(3)); /* CH3 是 0x44,不是 0x42 */
TEST_ASSERT_NOT_EQUAL(0x44, ina226_rail_addr(5)); /* CH5 是 0x42,不是 0x44 */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, naive_ok, "只有 CH1 碰巧和 0x40+ch 对得上");
}
/* 撞车检查的对象在 R2 里换了一批:主干上的 DAC63204(0x4A/0x4B) 与 TCA9548A(0x70),
加上刀片支路上的 PCA9555(0x20)。**0x48 的 LM75 不在里面** —— R2 没有载板。 */
static void test_addr_table_has_no_collisions(void)
{
TEST_ASSERT_TRUE(ina226_addr_table_ok()); /* 含与 DAC/PCA9555/LM75 的撞车检查 */
TEST_ASSERT_TRUE(ina226_addr_table_ok());
}
/* ---- 认件 ---------------------------------------------------------------- */
@@ -66,28 +101,37 @@ static void test_probe_accepts_real_ina226(void)
/* 应答了但 ID 不对 = 贴错件或地址跳线焊错,必须和「没这个器件」区分开。 */
static void test_probe_rejects_wrong_die_id(void)
{
uint8_t a = mount(0x41);
uint8_t a = mount(INA226_ADDR_RAIL);
sim_i2c_set_reg(a, INA226_REG_DIE_ID, 0x1234);
TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, ina226_probe(a));
}
static void test_probe_absent_device_naks(void)
{
mount(0x40);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x44)); /* 没挂 */
mount(INA226_ADDR_RAIL);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(INA226_ADDR_INPUT)); /* 没挂 */
sim_i2c_set_present(0x40, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x40)); /* 失联 */
}
/* 需求 §12.1 步 2:六片全应答。 */
static void test_all_six_answer_scan(void)
/* 需求 §12.1 步 2 的 R2 版:扫描只会扫到**两个**地址。
⚠️ 这里挂的两片都在主干上,因为本文件测的是驱动、不带 mux;
真板上 0x40 那片在支路后面,**只有对应通道开着的时候才应答** ——
所以「扫一圈看到几片 INA226」在 R2 上回答不了「五路都在不在」。
那件事要靠逐通道扫(i2cbus + slot),不在本文件的范围里。 */
static void test_only_two_addresses_answer_a_plain_scan(void)
{
for (int i = 0; i < INA226_COUNT; i++) {
mount(ina226_all_addr[i]);
for (int i = 0; i < INA226_ADDR_COUNT; i++) {
mount(ina226_addr_list[i]);
}
for (int i = 0; i < INA226_COUNT; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_probe(ina226_all_addr[i]));
for (int i = 0; i < INA226_ADDR_COUNT; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_probe(ina226_addr_list[i]));
}
/* R1 的那四个地址在 R2 板上根本不存在 —— 照着老表扫会得出「四片失联」。 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x41));
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x42));
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x44));
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_probe(0x45));
}
static void test_bus_not_initialised_propagates(void)
@@ -101,7 +145,7 @@ static void test_bus_not_initialised_propagates(void)
static void test_init_writes_config_and_calibration(void)
{
uint8_t a = mount(0x45);
uint8_t a = mount(INA226_ADDR_INPUT);
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_init(a));
TEST_ASSERT_EQUAL_HEX16(INA226_CONFIG, sim_i2c_get_reg(a, INA226_REG_CONFIG));
TEST_ASSERT_EQUAL_HEX16(2048, sim_i2c_get_reg(a, INA226_REG_CALIB));
@@ -130,7 +174,7 @@ static void test_check_catches_lost_calibration(void)
/* 满载一路:4.010 V / 4 A / 16 W。5 mΩ + 0.5 mA LSB 下分流码与电流码同值。 */
static void test_readings_at_full_load(void)
{
uint8_t a = mount(0x44);
uint8_t a = mount(INA226_ADDR_RAIL);
sim_i2c_set_reg(a, INA226_REG_BUS_V, 3208); /* ×1.25 mV */
sim_i2c_set_reg(a, INA226_REG_SHUNT_V, 8000); /* ×2.5 µV = 20 mV */
sim_i2c_set_reg(a, INA226_REG_CURRENT, 8000); /* ×0.5 mA */
@@ -148,7 +192,7 @@ static void test_readings_at_full_load(void)
而判据是「电流为负」,于是保护永远不触发。 */
static void test_reverse_current_is_negative(void)
{
uint8_t a = mount(0x42);
uint8_t a = mount(INA226_ADDR_RAIL);
sim_i2c_set_reg(a, INA226_REG_CURRENT, (uint16_t)(int16_t)-120); /* −60 mA */
sim_i2c_set_reg(a, INA226_REG_SHUNT_V, (uint16_t)(int16_t)-120); /* −300 µV */
@@ -177,7 +221,7 @@ static void test_bus_voltage_rounding(void)
靠自增省事的写法在这里会绿,到板上四个量全读成同一个。 */
static void test_pointer_is_rewritten_before_every_read(void)
{
uint8_t a = mount(0x41);
uint8_t a = mount(INA226_ADDR_RAIL);
ina226_sample_t s = { 0, 0, 0, 0 };
sim_i2c_dev(a)->n_write = 0;
sim_i2c_dev(a)->n_read = 0;
@@ -204,7 +248,7 @@ static void test_read_vi_uses_two_transactions(void)
/* 器件中途失联:错误必须一路传上去,不能返回一个看着正常的 0。 */
static void test_read_propagates_nak(void)
{
uint8_t a = mount(0x45);
uint8_t a = mount(INA226_ADDR_INPUT);
sim_i2c_inject_nak(a, -1);
ina226_vi_t vi = { 123, 456 };
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, ina226_read_vi(a, &vi));
@@ -218,16 +262,81 @@ static void test_null_output_rejected(void)
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, ina226_read_bus_mv(0x40, NULL));
}
/* ---- Kelvin 对调接的片子(R2 底板 U13)--------------------------------- */
/* ⭐ R2 底板 U13 的 IN+ 接下游:正常供电时芯片读出负值。登记后驱动取反,
调用方看到正值 —— 否则 SW-8 会把正常供电当成反灌。母线电压 / 功率是无符号量,不动。 */
static void test_reversed_chip_reads_positive_in_normal_flow(void)
{
uint8_t a = mount(INA226_ADDR_INPUT);
ina226_set_reversed(a, true);
TEST_ASSERT_TRUE(ina226_is_reversed(a));
sim_i2c_set_reg(a, INA226_REG_BUS_V, 9600); /* 12.000 V */
sim_i2c_set_reg(a, INA226_REG_SHUNT_V, (uint16_t)(int16_t)-8000); /* 芯片读 −20 mV */
sim_i2c_set_reg(a, INA226_REG_CURRENT, (uint16_t)(int16_t)-8000); /* 芯片读 −4 A */
sim_i2c_set_reg(a, INA226_REG_POWER, 3840); /* 48 W,恒正 */
ina226_sample_t s = { 0, 0, 0, 0 };
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_all(a, &s));
TEST_ASSERT_EQUAL_INT32(12000, s.bus_mv);
TEST_ASSERT_EQUAL_INT32(20000, s.shunt_uv);
TEST_ASSERT_EQUAL_INT32(4000000, s.current_ua);
TEST_ASSERT_EQUAL_INT32(48000, s.power_mw);
ina226_vi_t vi = { 0, 0 };
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_vi(a, &vi));
TEST_ASSERT_EQUAL_INT32(4000000, vi.current_ua);
}
/* 对调片子上真反灌:芯片读正值 → 驱动给出负值,SW-8 照样触发。
没登记的片子(CH 路)不受影响。 */
static void test_reversed_only_affects_that_chip(void)
{
uint8_t in = mount(INA226_ADDR_INPUT);
uint8_t ch = mount(0x40);
ina226_set_reversed(in, true);
sim_i2c_set_reg(in, INA226_REG_CURRENT, 120); /* 芯片读 +60 mA = 反灌 */
sim_i2c_set_reg(ch, INA226_REG_CURRENT, 120);
int32_t ua_in = 0, ua_ch = 0;
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_current_ua(in, &ua_in));
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_current_ua(ch, &ua_ch));
TEST_ASSERT_EQUAL_INT32(-60000, ua_in);
TEST_ASSERT_EQUAL_INT32(60000, ua_ch);
TEST_ASSERT_FALSE(ina226_is_reversed(ch));
}
/* 取消登记恢复原样;地址越界(不是 INA226 的 0x40~0x4F)的调用被忽略、不越界写。 */
static void test_reversed_can_be_cleared_and_ignores_bad_addr(void)
{
uint8_t a = mount(INA226_ADDR_RAIL);
ina226_set_reversed(a, true);
ina226_set_reversed(a, false);
TEST_ASSERT_FALSE(ina226_is_reversed(a));
sim_i2c_set_reg(a, INA226_REG_CURRENT, (uint16_t)(int16_t)-120);
int32_t ua = 0;
TEST_ASSERT_EQUAL_INT(HAL_OK, ina226_read_current_ua(a, &ua));
TEST_ASSERT_EQUAL_INT32(-60000, ua);
ina226_set_reversed(0x20, true); /* PCA9555 的地址 */
ina226_set_reversed(0x50, true);
TEST_ASSERT_FALSE(ina226_is_reversed(0x20));
TEST_ASSERT_FALSE(ina226_is_reversed(0x50));
for (uint8_t b = 0x40; b <= 0x4F; b++) {
TEST_ASSERT_FALSE(ina226_is_reversed(b));
}
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_addr_table_is_the_one_from_8_2);
RUN_TEST(test_channel_index_is_not_the_address_low_bits);
RUN_TEST(test_addr_list_is_the_two_from_5_2);
RUN_TEST(test_all_five_rails_share_one_address);
RUN_TEST(test_addr_table_has_no_collisions);
RUN_TEST(test_probe_accepts_real_ina226);
RUN_TEST(test_probe_rejects_wrong_die_id);
RUN_TEST(test_probe_absent_device_naks);
RUN_TEST(test_all_six_answer_scan);
RUN_TEST(test_only_two_addresses_answer_a_plain_scan);
RUN_TEST(test_bus_not_initialised_propagates);
RUN_TEST(test_init_writes_config_and_calibration);
RUN_TEST(test_check_catches_lost_calibration);
@@ -238,5 +347,8 @@ int main(void)
RUN_TEST(test_read_vi_uses_two_transactions);
RUN_TEST(test_read_propagates_nak);
RUN_TEST(test_null_output_rejected);
RUN_TEST(test_reversed_chip_reads_positive_in_normal_flow);
RUN_TEST(test_reversed_only_affects_that_chip);
RUN_TEST(test_reversed_can_be_cleared_and_ignores_bad_addr);
return UNITY_END();
}
+578 -233
查看文件
@@ -1,230 +1,578 @@
/* A6:PCA9555 驱动。
验收主项:**方向寄存器永远不会把 Port0[7] / Port1[7:5] 配成输出** ——
那四个脚在板上直接接地,配成输出等于对地短路,而真器件既不报错也不 NAK。
第二主项:FLT / PG / BUCK_PG / INA_ALERT 三种极性一个都不许写反。 */
/* A6(**R2 版**):刀片 PCA9555 驱动 —— 每片刀片一颗,五片同址 0x20,
靠底板 TCA9548A 分通道;**P0.0~P0.4 是输出**。
R1 那版测试的前提(一颗、16 脚全是输入)一条都不成立了,所以整篇重写。
本文件要证明的三件「真会出事」的事,按危险程度排:
⭐① **初始化顺序就是安全本身。** Output Port 复位值是 0xFF,Config 复位值
也是 0xFF(全输入)。只要先改方向、后写锁存器,那一刻 P0.0~P0.4 一起
输出 1 = 三个键全按下 + EDL 短接 + VBUS 上电,五台手机一起被按住电源键,
**而 I²C 从头到尾一切正常**:不 NAK、不超时、回读也对。
拦网是 `sim_pca9555_unsafe_enable()`,必须恒为 0。
⭐② **接地脚永远不许被配成输出。** P0.5~7 / P1.4~7 在刀片上直接接地、不串
限流电阻,配成输出就是对地短路 —— 真器件不报错、不 NAK,只会发烫。
Port1 更严:bit0~3 接的是 eFuse / Buck / INA 的**开漏输出**,配成输出
是两个驱动器对打,一位都不许。
⭐③ **五片的影子互不串。** 这是 R2 新增的失败形态:影子记到了另一片上,
下一次写会把那一片的键"顺手"改掉,而五路电压电流全部正常。
断言一律打在假器件的可观测面(`sim_pca9555_*`)上。对着 `pca9555_shadow()`
断言等于自己证明自己 —— 影子和线上真实发生的事分家,正是这里要抓的错。
⚠️ 本驱动**不碰 mux**(契约:通道由调用方先选好),所以测试自己拿
`tca9548a_select()` 选通道。约定 idx i ↔ 通道 i(只是本文件的约定,
路号 → 通道的真表在 `rail_cfg.c`,那是 `test_slot.c` 的事)。 */
#include "pca9555.h"
#include "board_pins.h"
#include "i2cbus.h"
#include "sim_hal.h"
#include "tca9548a.h"
#include "unity.h"
void setUp(void) { sim_hal_reset(); }
void tearDown(void) {}
/* ---- 脚手架 -------------------------------------------------------------- */
static void mount(void)
{
sim_pca9555_attach(PCA9555_ADDR);
sim_hal_reset();
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);
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sim_pca9555_attach_ch(i, i, PCA9555_ADDR);
}
/* 驱动的影子是文件级静态,跨用例不会自己清 —— 每条用例都从「刚上电」开始。 */
pca9555_shadow_reset();
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init()); /* 顺带把通道全关 */
}
/* 挂好并 init 完,外部电平停在静息值(全高 = 无故障、全部良好)。 */
static void mount_ready(void)
void setUp(void) { mount(); }
void tearDown(void) {}
/* 选到第 idx 片。驱动的契约就是「调用方先选好」,这里代的是 `slot.c` 的班。 */
static void sel(int idx) { TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, idx)); }
static void init_all(void)
{
mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init());
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(i));
}
}
/* ---- 验收主项一:接地脚永远是输入 ---------------------------------------- */
/* init 之后两个方向寄存器都必须是 0xFF —— 不是「大致全输入」,是逐位。 */
static void test_init_configures_every_pin_as_input(void)
/* 绕开驱动往当前通道上那片写一个寄存器。用来演示「假器件真的会照出问题」。 */
static hal_err_t raw_write(uint8_t reg, uint8_t val)
{
mount_ready();
TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_config(0));
TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_config(1));
const uint8_t wr[2] = { reg, val };
return hal_i2c_write(PCA9555_ADDR, wr, sizeof(wr));
}
/* ---- ⭐① 初始化顺序 ------------------------------------------------------ */
/* 五片全部 init 完,**一次都不许出现「锁存器还是 0xFF 就把 Port0 配成输出」**。
写错的现象:五台手机一起被按住电源键 + VBUS 全部上电,持续到下一次写锁存器
(几十 µs~几 ms),足够让手机认到一次长按。I²C 侧一切正常,日志里什么都没有。
把 `pca9555_init()` 里的 CONFIG0 那两句挪到 OUTPUT0 之前,这条立刻红。 */
static void test_init_never_enables_outputs_while_the_latch_is_still_ff(void)
{
init_all();
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_shorted());
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
TEST_ASSERT_EQUAL_HEX8(PCA9555_CONFIG0_INIT, sim_pca9555_config(i, 0));
TEST_ASSERT_EQUAL_HEX8(PCA9555_CONFIG1_INIT, sim_pca9555_config(i, 1));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(i, 0)); /* 复位值 0xFF 已被清掉 */
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(i, 1));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(i, 0));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(i, 1));
}
}
/* dir_safe 的判据穷举:**当且仅当**接地位全为 1(输入)才算安全。
写成「dir != 0」「dir & 0x80」这类近似判据的,会在这 512 个组合里露馅。 */
static void test_dir_safe_is_exactly_the_grounded_pins(void)
/* 上一条的加强版:init 完**每一个输出脚在栅极上的实际电平**都得是 0。
三个键松开、EDL 不导通、VBUS 断。接地脚必须是高阻(-1),不是 0 —— 这两者
在寄存器上长得一样,在栅极上差着一条对地通路。 */
static void test_after_init_every_output_pin_is_released(void)
{
init_all();
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
for (int bit = 0; bit <= PCA9555_BIT_VBUS_EN; bit++) {
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(i, 0, bit));
}
for (int bit = 5; bit < 8; bit++) {
TEST_ASSERT_EQUAL_INT(-1, sim_pca9555_pin(i, 0, bit)); /* 接地脚 = 输入 = 高阻 */
}
for (int bit = 0; bit < 8; bit++) {
TEST_ASSERT_EQUAL_INT(-1, sim_pca9555_pin(i, 1, bit)); /* Port1 一位都不输出 */
}
}
}
/* ⛔ 这条证明上面那道拦网**不是句空话**。手工按「有人把顺序调过来了」写一次
CONFIG0:此刻锁存器还是复位值 0xFF ⇒ 五个输出脚一起输出高。
注意 `gnd_driven` 仍然是 0 —— 方向字本身是对的,错的只有**时机**。
两个计数分开,就是为了让「顺序错」和「方向错」不互相掩护。 */
static void test_sim_catches_the_reversed_init_order(void)
{
sel(0);
TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(0, 0)); /* 复位值,这才是要命的地方 */
TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG0, PCA9555_CONFIG0_INIT));
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_unsafe_enable());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
/* 这一刻线上真实发生的事:三个键被按住 + EDL 短接 + VBUS 上电。 */
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_PWR));
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_EDL_EN));
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN));
}
/* 极性反转是「看门狗复位后残留」的重灾区:ESP32 重启了,刀片上的 PCA9555 没掉电,
上一条命写进去的值原样留着。init 必须**无条件清零**,不是「复位值本来就是 0」。
写成非 0 的现象是「故障全都看不见」,而 I²C 一切正常。 */
static void test_init_clears_a_stale_polarity_inversion(void)
{
sel(2);
sim_i2c_set_reg_ch(2, PCA9555_ADDR, PCA9555_REG_POLINV0, 0xFF); /* 上一条命的残留 */
sim_i2c_set_reg_ch(2, PCA9555_ADDR, PCA9555_REG_POLINV1, 0x0F);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(2));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(2, 0));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(2, 1));
}
/* 半路失败必须立刻停 —— 尤其不许「输出锁存器没写成,方向照样改」:
那正好凑成了顺序反过来的那一幕。这里让头两次事务 NAK(写 OUTPUT0 + 回读),
之后总线恢复正常,看驱动会不会接着把方向写下去。 */
static void test_init_stops_at_the_first_failure_and_leaves_the_pins_as_inputs(void)
{
sel(1);
sim_i2c_inject_nak_ch(1, PCA9555_ADDR, 1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init(1));
TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(1, 0)); /* 方向没被碰 */
TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(1, 1));
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
}
/* 回读校验:写下去了、读回来不是那个值 = 这地址上蹲着的不是我们以为的那片。
必须失败而不是接着往下配方向。用「写成功但回读时 NAK」造这条错路。 */
static void test_init_fails_when_the_readback_does_not_come_back(void)
{
sel(3);
sim_i2c_inject_nak_ch(3, PCA9555_ADDR, 2); /* 第 1 次写过、第 2 次(回读)起 NAK */
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init(3));
TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(3, 0));
}
static void test_init_rejects_a_bad_slot(void)
{
sel(0);
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_init(-1));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_init(PCA9555_SLOT_COUNT));
TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, sim_pca9555_config(0, 0)); /* 什么都没发生 */
}
/* ---- ⭐② 方向白名单 ------------------------------------------------------ */
/* Port0 的判据穷举 256 个方向字:**当且仅当 bit5~7 全是 1(输入)才安全**。
写成「dir != 0」「dir & 0x80」这类近似判据的,会在这 256 个组合里露馅。
⚠️ bit0~4 随意 —— 它们本来就是要当输出的,多配少配是功能问题不是安全问题。 */
static void test_dir_safe_port0_only_guards_the_grounded_pins(void)
{
for (int dir = 0; dir <= 0xFF; dir++) {
const bool p0_want = ((uint8_t)dir & PCA9555_P0_GND_MASK) == PCA9555_P0_GND_MASK;
const bool p1_want = ((uint8_t)dir & PCA9555_P1_GND_MASK) == PCA9555_P1_GND_MASK;
TEST_ASSERT_EQUAL_INT(p0_want, pca9555_dir_safe(0, (uint8_t)dir));
TEST_ASSERT_EQUAL_INT(p1_want, pca9555_dir_safe(1, (uint8_t)dir));
const bool want = ((uint8_t)dir & PCA9555_P0_GND_MASK) == PCA9555_P0_GND_MASK;
TEST_ASSERT_EQUAL_INT(want, pca9555_dir_safe(0, (uint8_t)dir));
}
/* 几个会被真写出来的错值 */
TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0x7F)); /* 只把 P0_7 配成输出:最像笔误的一个 */
TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x1F)); /* 「低五位是 PG,高三位不用」 */
TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x00)); /* 全输出 */
TEST_ASSERT_TRUE(pca9555_dir_safe(0, PCA9555_DIR_ALL_INPUT));
TEST_ASSERT_TRUE(pca9555_dir_safe(1, PCA9555_DIR_ALL_INPUT));
TEST_ASSERT_FALSE(pca9555_dir_safe(2, PCA9555_DIR_ALL_INPUT)); /* 端口号越界 */
TEST_ASSERT_TRUE(pca9555_dir_safe(0, PCA9555_CONFIG0_INIT)); /* 定稿值 0xE0 */
TEST_ASSERT_TRUE(pca9555_dir_safe(0, PCA9555_DIR_ALL_INPUT)); /* 全输入永远安全 */
TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0x60)); /* 顺手把 bit7 也配成输出:最像笔误的一个 */
TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0x00)); /* 「全配成输出反正用不着」 */
TEST_ASSERT_FALSE(pca9555_dir_safe(0, 0xE0u & ~0x20u)); /* 只错一位也是对地短路 */
}
/* 这条证明的是**假器件真的会照出短路**,否则上面那条「shorted == 0」是句空话。
手工绕过驱动,按「有人把 P0_7 配成输出」写一次方向寄存器:
PCA9555 的 Output Port 复位值是 0xFF,所以这一刻它就在对地灌电流。 */
/* Port1 **一位都不许输出**,所以只有 0xFF 一个合法值。
bit0~3 接的是 eFuse `FLT#` / Buck `PWRGD` / INA `ALERT` 的开漏输出:配成输出
= 我们的推挽和对方的开漏对着灌,现象是「故障永远读不到」+ 那颗芯片过热;
bit4~7 焊死在地上,配成输出直接对地短路。两种后果都不 NAK、不报错。 */
static void test_dir_safe_port1_allows_nothing_to_be_an_output(void)
{
for (int dir = 0; dir <= 0xFF; dir++) {
TEST_ASSERT_EQUAL_INT(dir == PCA9555_DIR_ALL_INPUT, pca9555_dir_safe(1, (uint8_t)dir));
}
TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0xF0)); /* 「低四位是信号,配成输出读得快」 */
TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x0F)); /* 「高四位不用,随便配」 */
TEST_ASSERT_TRUE(pca9555_dir_safe(1, PCA9555_CONFIG1_INIT));
}
/* 端口号越界一律 false(不是「当成 0 号口」)。 */
static void test_dir_safe_rejects_a_bad_port(void)
{
TEST_ASSERT_FALSE(pca9555_dir_safe(2, PCA9555_DIR_ALL_INPUT));
TEST_ASSERT_FALSE(pca9555_dir_safe(-1, PCA9555_DIR_ALL_INPUT));
}
/* 整篇跑下来一次都不许碰接地脚:init 五片 + 各种输出写 + 读输入。 */
static void test_grounded_pins_are_never_driven_on_any_blade(void)
{
init_all();
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK,
pca9555_write_out0(i, PCA9555_P0_OUT_MASK, PCA9555_P0_OUT_MASK));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(i, PCA9555_P0_OUT_MASK, 0x00));
pca9555_in_t in;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
}
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_shorted());
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 通道从没开重过 */
}
/* 证明上面那两个计数不是空话:手工把 P0.7(接地)配成输出。
锁存器还是 0xFF ⇒ 立刻对地短路(`shorted` 也 +1)。 */
static void test_sim_catches_a_grounded_pin_driven_high(void)
{
mount_ready();
TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(0)); /* 复位值就是全高,这才是要命的地方 */
const uint8_t wr[2] = { PCA9555_REG_CONFIG0, 0x7F };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(PCA9555_ADDR, wr, sizeof(wr)));
sel(4);
TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG0, 0x7F));
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_gnd_driven());
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_shorted());
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_shorted()); /* 真板子上这一刻开始冒烟 */
}
/* 同上,但先把输出锁存器写成 0 再配成输出:不冒烟,可仍然是在驱动一个接地脚。
两个计数分开就是为了让这一格不被漏掉。 */
/* 同上,但先把锁存器写成 0 再配成输出:不冒烟,可仍然是在驱动一个接地脚
(下一次写 Port0 就会把它拉高)。两个计数分开就是为了不漏掉这一格。 */
static void test_sim_counts_driving_a_grounded_pin_even_when_low(void)
{
mount_ready();
const uint8_t out0[2] = { PCA9555_REG_OUTPUT0, 0x00 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(PCA9555_ADDR, out0, sizeof(out0)));
const uint8_t cfg0[2] = { PCA9555_REG_CONFIG0, 0x7F };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(PCA9555_ADDR, cfg0, sizeof(cfg0)));
sel(4);
TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_OUTPUT0, 0x00));
TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG0, 0x7F));
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_gnd_driven());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_shorted());
}
/* 驱动全程一个字节都不许写 Output Port:没有输出脚,写它只可能是写错寄存器号。 */
static void test_init_never_touches_the_output_registers(void)
/* Port1 被配成输出:`gnd_driven` 只看 bit4~7,所以这里同时钉住
「dir_safe 把 bit0~3 也管住了」这件事 —— 它是判据,不是假器件能照出来的。 */
static void test_sim_catches_port1_configured_as_output(void)
{
mount_ready();
TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(0));
TEST_ASSERT_EQUAL_HEX8(0xFF, sim_pca9555_output(1));
sel(0);
TEST_ASSERT_FALSE(pca9555_dir_safe(1, 0x0F));
TEST_ASSERT_EQUAL_INT(HAL_OK, raw_write(PCA9555_REG_CONFIG1, 0x0F)); /* bit4~7 输出 */
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_gnd_driven());
}
/* ---- 极性反转寄存器 ------------------------------------------------------ */
/* ---- 位分配与地址 -------------------------------------------------------- */
/* 极性反转是「看门狗复位后残留」的重灾区:ESP32 重启了,PCA9555 没掉电。
init 必须无条件清零,而不是「复位值本来就是 0」。 */
static void test_init_clears_a_stale_polarity_inversion(void)
static void test_bit_map_is_self_consistent(void)
{
mount();
sim_i2c_set_reg(PCA9555_ADDR, PCA9555_REG_POLINV0, 0xFF); /* 上一条命的残留 */
sim_i2c_set_reg(PCA9555_ADDR, PCA9555_REG_POLINV1, 0x1F);
TEST_ASSERT_TRUE(pca9555_map_ok());
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init());
/* 五个输出位正好铺满允许配成输出的那五位,一位不多一位不少。 */
const uint8_t out_bits = (uint8_t)((1u << PCA9555_BIT_KEY_PWR) | (1u << PCA9555_BIT_KEY_VUP) |
(1u << PCA9555_BIT_KEY_VDN) | (1u << PCA9555_BIT_EDL_EN) |
(1u << PCA9555_BIT_VBUS_EN));
TEST_ASSERT_EQUAL_HEX8(PCA9555_P0_OUT_MASK, out_bits);
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(out_bits & PCA9555_P0_GND_MASK)); /* 没落到接地脚上 */
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(0));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(1));
const uint8_t in_bits = (uint8_t)((1u << PCA9555_BIT_EFUSE_FLT) | (1u << PCA9555_BIT_BUCK_PG) |
(1u << PCA9555_BIT_INA_ALERT) | (1u << PCA9555_BIT_EFUSE_PG));
TEST_ASSERT_EQUAL_HEX8(PCA9555_P1_IN_MASK, in_bits);
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(in_bits & PCA9555_P1_GND_MASK));
/* 方向字与位分配自洽:Port0 上「不是输入」的正好是那五个输出位。 */
TEST_ASSERT_EQUAL_HEX8(PCA9555_P0_OUT_MASK, (uint8_t)~PCA9555_CONFIG0_INIT);
TEST_ASSERT_EQUAL_HEX8(PCA9555_DIR_ALL_INPUT, PCA9555_CONFIG1_INIT);
/* 两个端口的掩码各自铺满 8 位 —— 漏一位就是「有个脚没人管」。 */
TEST_ASSERT_EQUAL_HEX8(0xFF, (uint8_t)(PCA9555_P0_OUT_MASK | PCA9555_P0_GND_MASK));
TEST_ASSERT_EQUAL_HEX8(0xFF, (uint8_t)(PCA9555_P1_IN_MASK | PCA9555_P1_GND_MASK));
}
/* ---- 验收主项二:三种极性 ------------------------------------------------ */
/* 静息态(两个端口都被上拉到 0xFF)= 五路无故障、五路电源良好、Buck 正常、无告警。
任何一处极性写反,这条立刻红。 */
static void test_idle_bus_means_everything_is_fine(void)
/* 三个键的位号互不重复、都在输出白名单里。`pca9555_key_mask()` 是
`t_actuator` 的「防御性松键」用的那个掩码,少一位就是「有个键松不掉」。 */
static void test_key_bits_and_mask(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT(PCA9555_BIT_KEY_PWR, pca9555_key_bit(PCA9555_KEY_PWR));
TEST_ASSERT_EQUAL_INT(PCA9555_BIT_KEY_VUP, pca9555_key_bit(PCA9555_KEY_VUP));
TEST_ASSERT_EQUAL_INT(PCA9555_BIT_KEY_VDN, pca9555_key_bit(PCA9555_KEY_VDN));
const uint8_t m = pca9555_key_mask();
TEST_ASSERT_EQUAL_HEX8(0x07, m);
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(m & PCA9555_P0_GND_MASK)); /* 松键不许碰接地脚 */
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(m & (1u << PCA9555_BIT_VBUS_EN))); /* 也不许碰 VBUS */
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)(m & (1u << PCA9555_BIT_EDL_EN)));
}
/* 地址撞车 = 两片一起应答,读回来是线与(数值正常、还很稳),一层都不报错。
⚠️ 刀片支路上的 INA226 是 **0x40**,与 0x20 本来就不是同一个地址,所以它同在
一条支路上没问题 —— 真正要防的是「有人把 PCA9555 的 A0 焊成 1 变 0x21」,
或者哪天往主干上加器件时选了 0x20。 */
static void test_address_does_not_collide(void)
{
TEST_ASSERT_TRUE(pca9555_addr_ok());
TEST_ASSERT_EQUAL_HEX8(0x20, PCA9555_ADDR); /* A0=A1=A2=GND(U4 的 pin21/2/3) */
TEST_ASSERT_NOT_EQUAL(TCA9548A_ADDR, PCA9555_ADDR); /* 0x70:撞了 = 选通道变成写 IO */
TEST_ASSERT_NOT_EQUAL(0x43, PCA9555_ADDR); /* 输入侧 INA226 */
TEST_ASSERT_NOT_EQUAL(0x4A, PCA9555_ADDR); /* DAC63204 ×2 */
TEST_ASSERT_NOT_EQUAL(0x4B, PCA9555_ADDR);
TEST_ASSERT_NOT_EQUAL(0x40, PCA9555_ADDR); /* 同支路的刀片 INA226 —— 也不撞 */
/* 五片同址是**设计**不是笔误:假总线允许它,靠通道区分(sim_i2c_add_ch 的 assert
只管同一条支路)。这一条把「哪天有人想给五片分配不同地址」钉死。 */
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe()); /* 五个通道上都是同一个地址 */
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* ---- ⭐③ 输出写:读改写 + 五片互不串 ------------------------------------ */
/* 读改写语义:只动 mask 里的位,其余位保持影子里的值。
组合键(VUP+PWR)必须落在**同一次写**里 —— 分两次写会先单按一个键,
小米 8 在那几毫秒里可能真的响应了单键(§10.6)。 */
static void test_write_out0_only_touches_the_masked_bits(void)
{
init_all();
sel(0);
/* 先按住 VBUS(它在后面每一步里都必须纹丝不动)。 */
const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_VBUS_EN);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, vbus, vbus));
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN));
/* 组合键:两位一次写下去。 */
const uint8_t combo = (uint8_t)((1u << PCA9555_BIT_KEY_PWR) | (1u << PCA9555_BIT_KEY_VUP));
const sim_i2c_dev_t *d = sim_i2c_dev_ch(0, PCA9555_ADDR);
const uint32_t w0 = d->n_write;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, combo, combo));
TEST_ASSERT_EQUAL_UINT32(1, d->n_write - w0); /* 一次事务,不是两次 */
TEST_ASSERT_EQUAL_HEX8((uint8_t)(combo | vbus), sim_pca9555_output(0, 0));
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(0, 0, PCA9555_BIT_VBUS_EN)); /* 没被顺手清掉 */
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_KEY_VDN));
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(0, 0, PCA9555_BIT_EDL_EN));
/* 松键:只清这两位,VBUS 还在。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, pca9555_key_mask(), 0x00));
TEST_ASSERT_EQUAL_HEX8(vbus, sim_pca9555_output(0, 0));
}
/* mask 碰到输出白名单以外的位 → `HAL_ERR_ARG`,而且**总线上什么都没发生**。
悄悄把它写下去 = 把接地脚拉高(如果方向也错了)或者白写一次;
这里返回错误码而不是 abort,是因为那多半是上层算错了掩码。 */
static void test_write_out0_rejects_a_mask_that_touches_a_grounded_pin(void)
{
init_all();
sel(2);
const sim_i2c_dev_t *d = sim_i2c_dev_ch(2, PCA9555_ADDR);
const uint32_t w0 = d->n_write;
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(2, 0x20, 0x20)); /* bit5 接地 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(2, 0xFF, 0x00)); /* 「全清一遍」 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(2, 0x80, 0x00));
TEST_ASSERT_EQUAL_UINT32(0, d->n_write - w0); /* 一个字节都没发出去 */
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(2, 0));
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
}
static void test_write_out0_rejects_a_bad_slot(void)
{
init_all();
sel(0);
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(-1, 0x01, 0x01));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_write_out0(PCA9555_SLOT_COUNT, 0x01, 0x01));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(0, 0));
}
/* ⭐ **写失败时影子不更新。** 更新了的后果不是「这一次没生效」,而是**下一次**
写会带着一个从没落到线上的位:比如这次「按 PWR」失败了却记了影子,下一次
「按 VUP」就会把 PWR 一起按下去 —— 而那一次是成功的,日志里干干净净。
所以判据打在**下一次写真正落到线上的字节**上,不是打在影子上。 */
static void test_write_out0_keeps_the_shadow_when_the_write_fails(void)
{
init_all();
sel(1);
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
const uint8_t vup = (uint8_t)(1u << PCA9555_BIT_KEY_VUP);
sim_i2c_inject_nak_ch(1, PCA9555_ADDR, 1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_write_out0(1, pwr, pwr));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(1, 0)); /* 线上确实没变 */
/* 下一次写(成功)只许出现 VUP 一位。带上 PWR = 影子被污染了。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(1, vup, vup));
TEST_ASSERT_EQUAL_HEX8(vup, sim_pca9555_output(1, 0));
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(1, 0, PCA9555_BIT_KEY_PWR));
}
/* ⭐ R2 新增的失败形态:**五片的影子串了**。
R1 只有一颗芯片,这类错根本不存在;R2 里影子是个五格数组,写成
`s_out0[0]` 或者少减一个 1,现象是「改 CH1 的键把 CH4 的键也改了」,
而五路电压电流全部正常、I²C 也没报错。 */
static void test_the_five_shadows_do_not_bleed_into_each_other(void)
{
init_all();
/* 每一片按一个不同的键,通道来回切,模拟 t_actuator 交错操作五路。 */
const uint8_t want[PCA9555_SLOT_COUNT] = {
(uint8_t)(1u << PCA9555_BIT_KEY_PWR),
(uint8_t)(1u << PCA9555_BIT_KEY_VUP),
(uint8_t)(1u << PCA9555_BIT_KEY_VDN),
(uint8_t)(1u << PCA9555_BIT_EDL_EN),
(uint8_t)(1u << PCA9555_BIT_VBUS_EN),
};
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(i, PCA9555_P0_OUT_MASK, want[i]));
}
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
TEST_ASSERT_EQUAL_HEX8(want[i], sim_pca9555_output(i, 0));
}
/* 再回到第 0 片改一位:另外四片必须一位都不动。 */
sel(0);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(0, pca9555_key_mask(), 0x00));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(0, 0));
for (int i = 1; i < PCA9555_SLOT_COUNT; i++) {
TEST_ASSERT_EQUAL_HEX8(want[i], sim_pca9555_output(i, 0));
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* `pca9555_shadow_reset()` 只动影子、**不碰总线**(A15 的降级路径靠它说
「现在生效的是上电默认值」)。碰了总线就是在一条已经失联的总线上瞎写。 */
static void test_shadow_reset_does_not_touch_the_bus(void)
{
init_all();
sel(3);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_write_out0(3, PCA9555_P0_OUT_MASK, 0x1F));
const sim_i2c_dev_t *d = sim_i2c_dev_ch(3, PCA9555_ADDR);
const uint32_t w0 = d->n_write;
pca9555_shadow_reset();
TEST_ASSERT_EQUAL_UINT32(0, d->n_write - w0);
TEST_ASSERT_EQUAL_HEX8(0x1F, sim_pca9555_output(3, 0)); /* 器件上的值没变 */
TEST_ASSERT_EQUAL_HEX8(0x00, pca9555_shadow(3));
}
/* ---- 输入与四个语义判读 -------------------------------------------------- */
/* 静息态:Port1 四根开漏线全被上拉 ⇒ 无故障、eFuse 良好、Buck 正常、无告警。
任何一处极性写反,这条立刻红。
⚠️ Port0 读回来必须是 0x00:bit0~4 是输出(锁存器 0),bit5~7 接地。 */
static void test_idle_inputs_mean_everything_is_fine(void)
{
init_all();
sel(0);
pca9555_in_t in;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_EQUAL_HEX8(0x00, in.port0);
TEST_ASSERT_EQUAL_HEX8(PCA9555_P1_IN_MASK, in.port1); /* bit4~7 接地,恒 0 */
TEST_ASSERT_EQUAL_HEX8(0x7F, in.port0); /* P0_7 接地,读回来必是 0 */
TEST_ASSERT_EQUAL_HEX8(0x1F, in.port1); /* P1_5~7 接地 */
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
TEST_ASSERT_FALSE(pca9555_rail_fault(&in, rail));
TEST_ASSERT_TRUE(pca9555_rail_pg(&in, rail));
}
TEST_ASSERT_FALSE(pca9555_efuse_fault(&in));
TEST_ASSERT_TRUE(pca9555_efuse_pg(&in));
TEST_ASSERT_TRUE(pca9555_buck_pg(&in));
TEST_ASSERT_FALSE(pca9555_ina_alert(&in));
}
/* 全 0(比如整条总线被拉低、或者上拉没焊)= 五路全故障、五路全不良、Buck 不良、告警。
注意这**不是**「安全默认值」:它和「五路真的全炸了」长得一模一样,
/* 四个信号**两种极性**,一位一位单独拉低,四个判读函数各测一次。
极性写反的现象是「有故障被读成电源良好」—— 方向正好相反,是最坏的那一种:
系统不但不保护,还会更自信。所以每一格都要:拉低这一位时它变了,
**而另外三个判读一动不动**(端口内串位的现象和极性写反长得一模一样)。 */
static void test_each_polarity_exactly_once(void)
{
init_all();
sel(2);
pca9555_in_t in;
/* ① EFUSE_FLT# 低有效 ⇒ 拉低 = 有故障 */
sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_FLT)));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_TRUE(pca9555_efuse_fault(&in));
TEST_ASSERT_TRUE(pca9555_efuse_pg(&in)); /* 另外三个不许跟着动 */
TEST_ASSERT_TRUE(pca9555_buck_pg(&in));
TEST_ASSERT_FALSE(pca9555_ina_alert(&in));
/* ② EFUSE_PG 高 = 良好 ⇒ 拉低 = 不良 */
sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_PG)));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_FALSE(pca9555_efuse_pg(&in));
TEST_ASSERT_FALSE(pca9555_efuse_fault(&in));
TEST_ASSERT_TRUE(pca9555_buck_pg(&in));
TEST_ASSERT_FALSE(pca9555_ina_alert(&in));
/* ③ BUCK_PG 高 = 正常 ⇒ 拉低 = 本路 Buck 不良(R2 里只代表本路!) */
sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_BUCK_PG)));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_FALSE(pca9555_buck_pg(&in));
TEST_ASSERT_FALSE(pca9555_efuse_fault(&in));
TEST_ASSERT_TRUE(pca9555_efuse_pg(&in));
TEST_ASSERT_FALSE(pca9555_ina_alert(&in));
/* ④ INA_ALERT 低有效 ⇒ 拉低 = 在告警 */
sim_pca9555_drive(2, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_INA_ALERT)));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_TRUE(pca9555_ina_alert(&in));
TEST_ASSERT_FALSE(pca9555_efuse_fault(&in));
TEST_ASSERT_TRUE(pca9555_efuse_pg(&in));
TEST_ASSERT_TRUE(pca9555_buck_pg(&in));
}
/* 全 0(整条支路被拉低、或者上拉没焊)= 全故障 + 全不良 + 告警。
这**不是**安全默认值:它和「这一路真的全炸了」长得一模一样,
§10.10 因此规定这片不应答时改用 INA226 的电压判据兜底,而不是信这一帧。 */
static void test_all_low_means_everything_is_bad(void)
{
mount_ready();
sim_pca9555_drive(0, 0x00);
sim_pca9555_drive(1, 0x00);
init_all();
sel(1);
sim_pca9555_drive(1, 1, 0x00);
pca9555_in_t in;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
TEST_ASSERT_TRUE(pca9555_rail_fault(&in, rail));
TEST_ASSERT_FALSE(pca9555_rail_pg(&in, rail));
}
TEST_ASSERT_TRUE(pca9555_efuse_fault(&in));
TEST_ASSERT_FALSE(pca9555_efuse_pg(&in));
TEST_ASSERT_FALSE(pca9555_buck_pg(&in));
TEST_ASSERT_TRUE(pca9555_ina_alert(&in));
}
/* 逐路点名:只让一路报故障,其余四路必须纹丝不动。
FLT 和 PG 两张表在两个端口上长得一模一样(都是 bit0~4),把端口搞反之后
「有故障」会被读成「电源良好」,所以这里一路一路地把两个端口错开来验。 */
static void test_one_rail_at_a_time(void)
/* 读的是**当前通道上那一片**。五片同址,读错通道 = 读到另一台手机的故障状态,
而量纲、范围、变化趋势全部正常。这条把「同址靠通道区分」真的跑一遍。 */
static void test_read_inputs_reads_the_blade_on_the_current_channel(void)
{
mount_ready();
for (int hurt = 1; hurt <= PCA9555_RAIL_COUNT; hurt++) {
/* FLT 只拉低 hurt 那一路;PG 只拉低**另一路**(hurt 的下一路,绕回来)。 */
const int other = (hurt % PCA9555_RAIL_COUNT) + 1;
sim_pca9555_drive(0, (uint8_t)(0xFFu & ~(1u << pca9555_flt_bit(hurt))));
sim_pca9555_drive(1, (uint8_t)(0xFFu & ~(1u << pca9555_pg_bit(other))));
init_all();
/* 只让第 3 片报 eFuse 故障。 */
sim_pca9555_drive(3, 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_FLT)));
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
pca9555_in_t in;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(rail == hurt, pca9555_rail_fault(&in, rail));
TEST_ASSERT_EQUAL_INT(rail != other, pca9555_rail_pg(&in, rail));
}
/* 两根线或信号不受任何一路 FLT 影响(它们在 P0_5 / P0_6 上)。 */
TEST_ASSERT_TRUE(pca9555_buck_pg(&in));
TEST_ASSERT_FALSE(pca9555_ina_alert(&in));
TEST_ASSERT_EQUAL_INT(i == 3, pca9555_efuse_fault(&in));
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* 两根线或信号自己点名:拉低 BUCK_PG 不许把任何一路读成故障,反之亦然。 */
static void test_wire_or_signals_do_not_alias_a_rail(void)
{
mount_ready();
pca9555_in_t in;
sim_pca9555_drive(0, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_BUCK_PG)));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_FALSE(pca9555_buck_pg(&in));
TEST_ASSERT_FALSE(pca9555_ina_alert(&in));
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
TEST_ASSERT_FALSE(pca9555_rail_fault(&in, rail));
}
sim_pca9555_drive(0, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_INA_ALERT)));
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_TRUE(pca9555_buck_pg(&in));
TEST_ASSERT_TRUE(pca9555_ina_alert(&in));
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
TEST_ASSERT_FALSE(pca9555_rail_fault(&in, rail));
}
}
/* ---- 位表与地址 ---------------------------------------------------------- */
static void test_bit_map_is_consistent(void)
{
TEST_ASSERT_TRUE(pca9555_map_ok());
TEST_ASSERT_TRUE(pca9555_addr_ok());
TEST_ASSERT_EQUAL_HEX8(0x20, PCA9555_ADDR); /* A0=A1=A2=GND,U23 的 pin21/2/3 */
for (int rail = 1; rail <= PCA9555_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(rail - 1, pca9555_flt_bit(rail));
TEST_ASSERT_EQUAL_INT(rail - 1, pca9555_pg_bit(rail));
/* 一路都不许落到接地脚或两根线或信号上 */
TEST_ASSERT_TRUE(pca9555_flt_bit(rail) < PCA9555_BIT_BUCK_PG);
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)((1u << pca9555_flt_bit(rail)) & PCA9555_P0_GND_MASK));
TEST_ASSERT_EQUAL_HEX8(0, (uint8_t)((1u << pca9555_pg_bit(rail)) & PCA9555_P1_GND_MASK));
}
TEST_ASSERT_EQUAL_INT(5, PCA9555_BIT_BUCK_PG);
TEST_ASSERT_EQUAL_INT(6, PCA9555_BIT_INA_ALERT);
}
/* ---- 总线行为 ------------------------------------------------------------ */
/* §10.3 的 0.12 ms 预算是按**一次事务**算的(写命令字节 + 重复起始读 2 字节)。
拆成两次事务照样能跑,但 t_safety 的 I²C 占用会对不上 A8 要断言的那个数。 */
/* §10.3 的总线预算是按**一次事务**算的(写命令字节 + 重复起始读 2 字节)。
拆成两次照样能跑,但 t_safety 一圈的 I²C 占用会对不上 §5.5 那张表。 */
static void test_read_inputs_uses_one_transaction(void)
{
mount_ready();
const sim_i2c_dev_t *d = sim_i2c_dev(PCA9555_ADDR);
init_all();
sel(0);
const sim_i2c_dev_t *d = sim_i2c_dev_ch(0, PCA9555_ADDR);
const uint32_t w0 = d->n_write, r0 = d->n_read;
pca9555_in_t in;
@@ -234,107 +582,104 @@ static void test_read_inputs_uses_one_transaction(void)
TEST_ASSERT_EQUAL_UINT32(1, d->n_read - r0);
}
static void test_probe_accepts_a_fresh_device(void)
/* §10.10:这片不应答只是失去可见性,驱动如实返回错误码、什么动作都不做,
**也不许改写调用方的缓冲** —— 半新半旧的一帧比明着失败危险得多。 */
static void test_read_inputs_propagates_nak_without_touching_the_buffer(void)
{
mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe());
}
/* 这个地址上蹲着别的东西(应答了,但方向寄存器不是复位值)。 */
static void test_probe_rejects_a_stranger(void)
{
mount();
sim_i2c_set_reg(PCA9555_ADDR, PCA9555_REG_CONFIG1, 0x00);
TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, pca9555_probe());
}
static void test_probe_absent_device_naks(void)
{
mount();
sim_i2c_set_present(PCA9555_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_probe());
}
/* §10.10:这片不应答只是失去可见性,驱动如实返回 NAK,什么动作都不做。 */
static void test_read_propagates_nak(void)
{
mount_ready();
sim_i2c_inject_nak(PCA9555_ADDR, -1);
init_all();
sel(4);
sim_i2c_inject_nak_ch(4, PCA9555_ADDR, -1);
pca9555_in_t in = { 0xAA, 0x55 };
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_read_inputs(&in));
TEST_ASSERT_EQUAL_HEX8(0xAA, in.port0); /* 失败时不许改写调用方的缓冲 */
TEST_ASSERT_EQUAL_HEX8(0xAA, in.port0);
TEST_ASSERT_EQUAL_HEX8(0x55, in.port1);
}
static void test_init_propagates_nak(void)
{
mount();
sim_i2c_inject_nak(PCA9555_ADDR, -1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init());
}
/* 回读对不上 = 总线上有东西但不是我们以为的那片,init 必须失败而不是接着往下跑。
这里用「写第二个方向寄存器时 NAK」来造回读失败之外的另一条错路。 */
static void test_init_stops_at_the_first_failure(void)
{
mount();
sim_i2c_inject_nak(PCA9555_ADDR, 3); /* 头三次事务 NAK,之后恢复 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_init());
/* 失败了就别把剩下的寄存器也写一遍 —— 极性反转还停在没被碰过的状态。 */
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_polinv(1));
}
static void test_null_output_rejected(void)
{
mount_ready();
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, pca9555_read_inputs(NULL));
}
/* ---- INT#(A8/A10 会用,这里只钉住它的行为) ----------------------------- */
/* ---- probe ---------------------------------------------------------------- */
/* 中断是加速路径不是主路径:**读走输入端口才释放**。
漏读一次就再也等不到下一个沿,所以 t_safety 必须无条件轮询。 */
static void test_int_is_only_cleared_by_reading_the_input_ports(void)
/* probe 的语义是「这是一片**刚上电**的 PCA9555 吗」:两个方向寄存器都得是 0xFF。 */
static void test_probe_accepts_a_fresh_blade(void)
{
mount_ready();
TEST_ASSERT_FALSE(sim_pca9555_int_asserted());
for (int i = 0; i < PCA9555_SLOT_COUNT; i++) {
sel(i);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe());
}
}
sim_pca9555_drive(0, 0xFE); /* CH1 报故障 */
TEST_ASSERT_TRUE(sim_pca9555_int_asserted());
TEST_ASSERT_EQUAL_UINT32(1, sim_pca9555_int_asserts());
/* ⚠️ 已经被 init 过的片子返回 HAL_ERR_BUS —— 这是对的,不是 bug。
所以重连之后不能拿 probe 当「它还在不在」的判据(那是 read_inputs 的活)。 */
static void test_probe_rejects_an_already_initialised_blade(void)
{
sel(0);
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_init(0));
TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, pca9555_probe());
}
/* 读别的寄存器不清中断 */
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_probe());
TEST_ASSERT_TRUE(sim_pca9555_int_asserted());
/* 这地址上蹲着别的东西(应答了,方向寄存器却不是复位值)。 */
static void test_probe_rejects_a_stranger(void)
{
sel(1);
sim_i2c_set_reg_ch(1, PCA9555_ADDR, PCA9555_REG_CONFIG1, 0x00);
TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, pca9555_probe());
}
pca9555_in_t in;
TEST_ASSERT_EQUAL_INT(HAL_OK, pca9555_read_inputs(&in));
TEST_ASSERT_FALSE(sim_pca9555_int_asserted());
/* 刀片拔了 / 那条支路没选通 → NAK。R2 特有:**通道没开**和**器件没了**在总线上
长得一模一样,两者都只能得到 NAK —— 区分它们是 `t_safety` 的事(PRSNT# + mux
失联判据),不是驱动的事。 */
static void test_probe_naks_when_the_blade_is_not_reachable(void)
{
sel(2);
sim_i2c_set_present_ch(2, PCA9555_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_probe());
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE));
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, pca9555_probe()); /* 通道全关 = 谁也够不着 */
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_init_configures_every_pin_as_input);
RUN_TEST(test_dir_safe_is_exactly_the_grounded_pins);
/* ① 初始化顺序 */
RUN_TEST(test_init_never_enables_outputs_while_the_latch_is_still_ff);
RUN_TEST(test_after_init_every_output_pin_is_released);
RUN_TEST(test_sim_catches_the_reversed_init_order);
RUN_TEST(test_init_clears_a_stale_polarity_inversion);
RUN_TEST(test_init_stops_at_the_first_failure_and_leaves_the_pins_as_inputs);
RUN_TEST(test_init_fails_when_the_readback_does_not_come_back);
RUN_TEST(test_init_rejects_a_bad_slot);
/* ② 方向白名单 */
RUN_TEST(test_dir_safe_port0_only_guards_the_grounded_pins);
RUN_TEST(test_dir_safe_port1_allows_nothing_to_be_an_output);
RUN_TEST(test_dir_safe_rejects_a_bad_port);
RUN_TEST(test_grounded_pins_are_never_driven_on_any_blade);
RUN_TEST(test_sim_catches_a_grounded_pin_driven_high);
RUN_TEST(test_sim_counts_driving_a_grounded_pin_even_when_low);
RUN_TEST(test_init_never_touches_the_output_registers);
RUN_TEST(test_init_clears_a_stale_polarity_inversion);
RUN_TEST(test_idle_bus_means_everything_is_fine);
RUN_TEST(test_sim_catches_port1_configured_as_output);
/* 位分配与地址 */
RUN_TEST(test_bit_map_is_self_consistent);
RUN_TEST(test_key_bits_and_mask);
RUN_TEST(test_address_does_not_collide);
/* ③ 输出写 */
RUN_TEST(test_write_out0_only_touches_the_masked_bits);
RUN_TEST(test_write_out0_rejects_a_mask_that_touches_a_grounded_pin);
RUN_TEST(test_write_out0_rejects_a_bad_slot);
RUN_TEST(test_write_out0_keeps_the_shadow_when_the_write_fails);
RUN_TEST(test_the_five_shadows_do_not_bleed_into_each_other);
RUN_TEST(test_shadow_reset_does_not_touch_the_bus);
/* 输入与极性 */
RUN_TEST(test_idle_inputs_mean_everything_is_fine);
RUN_TEST(test_each_polarity_exactly_once);
RUN_TEST(test_all_low_means_everything_is_bad);
RUN_TEST(test_one_rail_at_a_time);
RUN_TEST(test_wire_or_signals_do_not_alias_a_rail);
RUN_TEST(test_bit_map_is_consistent);
RUN_TEST(test_read_inputs_reads_the_blade_on_the_current_channel);
RUN_TEST(test_read_inputs_uses_one_transaction);
RUN_TEST(test_probe_accepts_a_fresh_device);
RUN_TEST(test_read_inputs_propagates_nak_without_touching_the_buffer);
/* probe */
RUN_TEST(test_probe_accepts_a_fresh_blade);
RUN_TEST(test_probe_rejects_an_already_initialised_blade);
RUN_TEST(test_probe_rejects_a_stranger);
RUN_TEST(test_probe_absent_device_naks);
RUN_TEST(test_read_propagates_nak);
RUN_TEST(test_init_propagates_nak);
RUN_TEST(test_init_stops_at_the_first_failure);
RUN_TEST(test_null_output_rejected);
RUN_TEST(test_int_is_only_cleared_by_reading_the_input_ports);
RUN_TEST(test_probe_naks_when_the_blade_is_not_reachable);
return UNITY_END();
}
文件差异内容过多而无法显示 加载差异
+464
查看文件
@@ -0,0 +1,464 @@
/* `slot` —— 一片刀片的 I/O 门面(R2 新增)。
这一层存在的全部理由是**一类不会报错的错**:
`pca9555_write_out0(slot, ...)` 的 `slot` 只用来记影子,驱动没法验证它和当前
mux 通道是不是同一路。传错的后果是「按了另一台手机的电源键」——
而五路的电压、电流、温度全部正常,I²C 一次都不 NAK,日志里什么都没有。
`slot.c` 把「选通道」和「记影子」绑在同一个函数里,让这件事**不可能**传错。
所以本文件的断言几乎都长成同一个样子:
**动了第 N 路 ⇒ 假 mux 的当前通道 == `rail_cfg(N)->mux_ch`,
而且只有第 N 片假 PCA9555 的输出锁存器变了。**
光看返回值和终态是看不出通道错的 —— 五片同址 0x20,写哪一片都成功。
另外三件会真出事的事:
· VBUS 写失败时影子往**哪一边**倒(关失败 ⇒ 留在 on,还会再关一次);
· `_locked` 版本在 `xact` 回调外被调(通道没选)会写到错的片子上;
· `PRSNT#` 读失败时返回**上一次成功的值**,而不是「不在位」。 */
#include "slot.h"
#include "board_pins.h"
#include "i2cbus.h"
#include "rail_cfg.h"
#include "sim_hal.h"
#include "tca9548a.h"
#include "unity.h"
/* ---- 脚手架 -------------------------------------------------------------- */
/* 假器件 idx 与路号的对应:本文件一律用 `blade(rail)`,不写 `rail - 1`
(`rail_cfg.h` 的规矩:由路号算出来的编号只许在那张表里出现)。 */
static int blade(int rail) { return rail - 1; }
static void mount(void)
{
sim_hal_reset();
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);
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
/* ⚠️ 通道从**表**里取。写成 `rail - 1` 的话,哪天表改了(`PRSNT#` 那一列
就是现成的先例),测试会跟着一起错,而且仍然全绿。 */
sim_pca9555_attach_ch(blade(rail), rail_cfg(rail)->mux_ch, PCA9555_ADDR);
}
pca9555_shadow_reset(); /* 驱动的影子是文件级静态,跨用例不会自己清 */
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init()); /* VBUS 受控之前 eFuse 必须先关 */
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init());
}
void setUp(void) { mount(); }
void tearDown(void) {}
static void init_all(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail));
}
}
/* 除了 `except` 那一片,其余四片的输出锁存器都必须是 `rest`。 */
static void assert_only_blade_changed(int except_rail, uint8_t want, uint8_t rest)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const uint8_t got = sim_pca9555_output(blade(rail), 0);
TEST_ASSERT_EQUAL_HEX8(rail == except_rail ? want : rest, got);
}
}
/* ---- 路号 → 通道(唯一换算点) ------------------------------------------ */
/* `slot_mux_ch()` 必须**查表**,不许算。现在表恰好是 0 1 2 3 4,所以任何
「`rail - 1`」的写法都跑得过 —— 这条用例挡不住那种写法,它挡的是
「哪天表改了而 `slot.c` 没跟着改」。`rail_cfg.h` 里 `PRSNT#` 那一列就是先例。 */
static void test_mux_ch_comes_from_the_table(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, slot_mux_ch(rail));
}
}
/* ---- ⭐ 通道选择:动了这一路 = 只动了这一片 ------------------------------ */
/* 核心用例。改 CH3 的电源键时:
① 假 mux 的当前通道必须是 `rail_cfg(3)->mux_ch`;
② **只有第 3 片**的输出锁存器变了;
③ 从头到尾没有两片一起应答(`ambiguous == 0`)。
通道传错的现象是「按了另一台手机的电源键」,五路电压电流全部正常。 */
static void test_out0_write_selects_this_rails_channel(void)
{
init_all();
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(3, pwr, pwr));
TEST_ASSERT_EQUAL_INT(rail_cfg(3)->mux_ch, sim_i2c_mux_ch());
assert_only_blade_changed(3, pwr, 0x00);
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(3), 0, PCA9555_BIT_KEY_PWR));
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* 五路各点一次名。上一条只证明了 CH3 对,这条排掉「常数 2」这种碰巧对的写法。 */
static void test_every_rail_touches_only_its_own_blade(void)
{
init_all();
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(rail, pwr, pwr));
TEST_ASSERT_EQUAL_INT(rail_cfg(rail)->mux_ch, sim_i2c_mux_ch());
assert_only_blade_changed(rail, pwr, 0x00);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(rail, pwr, 0x00)); /* 松开再走下一路 */
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* 通道来回切之后,各路的影子还得是各路自己的 —— 影子串了的现象是
「按 CH5 的音量键顺手把 CH1 的电源键松开了」,而两次写都成功。 */
static void test_shadows_survive_channel_switching(void)
{
init_all();
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
const uint8_t vup = (uint8_t)(1u << PCA9555_BIT_KEY_VUP);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, pwr, pwr));
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(5, vup, vup));
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, vup, vup)); /* CH1 再加一个键 */
TEST_ASSERT_EQUAL_HEX8((uint8_t)(pwr | vup), sim_pca9555_output(blade(1), 0));
TEST_ASSERT_EQUAL_HEX8(vup, sim_pca9555_output(blade(5), 0));
TEST_ASSERT_EQUAL_HEX8((uint8_t)(pwr | vup), slot_shadow(1));
TEST_ASSERT_EQUAL_HEX8(vup, slot_shadow(5));
}
/* `slot_init()` 也得选对通道,而且**不许把别的刀片初始化掉** ——
只 init 一路时,其余四片必须还停在上电复位态(方向全输入)。
顺带再钉一次「初始化顺序」那道拦网:经 `slot` 走也一次都不许触发。 */
static void test_slot_init_configures_only_this_blade(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(4));
TEST_ASSERT_EQUAL_INT(rail_cfg(4)->mux_ch, sim_i2c_mux_ch());
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
const uint8_t want = (rail == 4) ? PCA9555_CONFIG0_INIT : PCA9555_DIR_ALL_INPUT;
TEST_ASSERT_EQUAL_HEX8(want, sim_pca9555_config(blade(rail), 0));
}
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_unsafe_enable());
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
}
/* ---- VBUS ---------------------------------------------------------------- */
/* 极性:**高 = 通**(TPS22918 的 ON 脚 + R701 100 k 下拉)。写反的现象是
「上电就通、命令关它反而开」,而 I²C 全程正常。判据打在栅极电平上。 */
static void test_vbus_enable_drives_the_right_pin_on_the_right_blade(void)
{
init_all();
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(2, true));
TEST_ASSERT_EQUAL_INT(rail_cfg(2)->mux_ch, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(2), 0, PCA9555_BIT_VBUS_EN));
assert_only_blade_changed(2, (uint8_t)(1u << PCA9555_BIT_VBUS_EN), 0x00);
TEST_ASSERT_TRUE(slot_vbus_is_on(2));
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(2, false));
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(2), 0, PCA9555_BIT_VBUS_EN));
TEST_ASSERT_FALSE(slot_vbus_is_on(2));
}
/* 影子只有一份(在驱动里)。`slot.c` 要是自己再存一份,两处迟早分家:
现象是「`slot_vbus_is_on()` 说通着,下一次写键的读改写却把 VBUS 位清了」。
这里把「另存一份」钉死:绕过 `slot_vbus_enable()` 直接写驱动,
`slot_vbus_is_on()` 必须跟着变。 */
static void test_vbus_shadow_is_the_drivers_one(void)
{
init_all();
TEST_ASSERT_FALSE(slot_vbus_is_on(4));
/* 走 `slot_out0_write()`(键的那条路)把 VBUS 位置上 —— 同一个字节、同一个影子。 */
const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_VBUS_EN);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(4, vbus, vbus));
TEST_ASSERT_TRUE(slot_vbus_is_on(4));
TEST_ASSERT_EQUAL_HEX8(vbus, slot_shadow(4));
/* 反过来:按一个键不许把 VBUS 顺手带掉(读改写的那条语义)。 */
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(4, pwr, pwr));
TEST_ASSERT_TRUE(slot_vbus_is_on(4));
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(4), 0, PCA9555_BIT_VBUS_EN));
}
/* ⭐ **关 VBUS 失败 ⇒ 影子留在 on。**
方向是有讲究的:留在 on ⇒ 上层的反灌判据继续武装,下一圈还会再关一次。
要是写失败却把影子改成 off,判据会认为「已经关掉了」而解除武装 ——
现象是「日志里写着 VBUS 已断,实际还通着」,而这一路的电压电流看上去正常。 */
static void test_a_failed_vbus_off_leaves_the_shadow_on(void)
{
init_all();
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(5, true));
TEST_ASSERT_TRUE(slot_vbus_is_on(5));
sim_i2c_inject_nak_ch(rail_cfg(5)->mux_ch, PCA9555_ADDR, -1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, slot_vbus_enable(5, false));
TEST_ASSERT_TRUE(slot_vbus_is_on(5)); /* 还武装着 */
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(5), 0, PCA9555_BIT_VBUS_EN)); /* 线上确实还通 */
/* 总线恢复之后再关一次就该真的关掉(判据没解除武装,所以会有这一次)。 */
sim_i2c_inject_nak_ch(rail_cfg(5)->mux_ch, PCA9555_ADDR, 0);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable(5, false));
TEST_ASSERT_FALSE(slot_vbus_is_on(5));
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(5), 0, PCA9555_BIT_VBUS_EN));
}
/* ⭐ **开 VBUS 失败 ⇒ 影子留在 off。**
同一条规则(写成功才更新影子)在另一个方向上的表现,两边都倒向安全的那一侧:
这次影子要是变成 on,上层会以为 USB 已经供上了,从而不再重试,
而手机那头其实一直没电 —— 而且下一次写键的读改写会把 VBUS 位"补"上去,
变成一次没人下令的上电。 */
static void test_a_failed_vbus_on_leaves_the_shadow_off(void)
{
init_all();
sim_i2c_inject_nak_ch(rail_cfg(1)->mux_ch, PCA9555_ADDR, -1);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, slot_vbus_enable(1, true));
TEST_ASSERT_FALSE(slot_vbus_is_on(1));
/* 恢复后按一个键:VBUS 位不许被带上去。 */
sim_i2c_inject_nak_ch(rail_cfg(1)->mux_ch, PCA9555_ADDR, 0);
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, pwr, pwr));
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(1), 0, PCA9555_BIT_VBUS_EN));
}
/* ---- ⭐ `_locked` 版本:为什么必须在 `xact` 回调里 ---------------------- */
typedef struct {
int rail;
bool on;
} vbus_arg_t;
static hal_err_t cb_vbus(void *arg)
{
const vbus_arg_t *a = (const vbus_arg_t *)arg;
return slot_vbus_enable_locked(a->rail, a->on);
}
/* 正路:`t_safety` 的用法 —— 拿 `slot_mux_ch(rail)` 开 `xact`,在回调里断 VBUS。
SW-8 要求「同一圈、同一把锁」里完成(计划 §5.4)。 */
static void test_vbus_locked_inside_a_xact_hits_the_right_blade(void)
{
init_all();
vbus_arg_t a = { 3, true };
TEST_ASSERT_EQUAL_INT(HAL_OK,
i2cbus_xact(slot_mux_ch(3), SLOT_LOCK_TIMEOUT_MS, cb_vbus, &a));
TEST_ASSERT_EQUAL_INT(rail_cfg(3)->mux_ch, sim_i2c_mux_ch());
assert_only_blade_changed(3, (uint8_t)(1u << PCA9555_BIT_VBUS_EN), 0x00);
TEST_ASSERT_TRUE(slot_vbus_is_on(3));
}
/* ⭐⭐ 反路:在回调**外面**调 `_locked` 版本。通道还停在上一次事务选的那一路,
于是这次写落在了**那一片**上,而影子记在了第 3 路的格子里。
返回值是 HAL_OK,五路电压电流全部正常,日志里什么都没有 ——
**这就是 `slot.c` 存在的理由**:不带 `_locked` 的版本自己开 `xact`,
让「选通道」和「记影子」不可能分开。
⚠️ 这条用例是在演示一个**错误用法的后果**,不是在钉一个正确行为。
哪天 `_locked` 版本自己也会选通道了,它会变红 —— 那时该改的是这条注释。 */
static void test_vbus_locked_outside_a_xact_writes_to_the_wrong_blade(void)
{
init_all();
const uint8_t vbus = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
/* 上一次事务把通道留在了 CH1 上。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_out0_write(1, vbus, 0x00));
TEST_ASSERT_EQUAL_INT(rail_cfg(1)->mux_ch, sim_i2c_mux_ch());
/* 现在「关 CH3 的 VBUS」——没人给它选通道。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_vbus_enable_locked(3, true)); /* 居然成功了 */
/* 线上真实发生的事:CH1 的 VBUS 被打开了,CH3 一动没动。 */
TEST_ASSERT_EQUAL_INT(1, sim_pca9555_pin(blade(1), 0, PCA9555_BIT_VBUS_EN));
TEST_ASSERT_EQUAL_INT(0, sim_pca9555_pin(blade(3), 0, PCA9555_BIT_VBUS_EN));
/* 而固件这边一口咬定 CH3 通着、CH1 没通 —— 两份认知就此分家。 */
TEST_ASSERT_TRUE(slot_vbus_is_on(3));
TEST_ASSERT_FALSE(slot_vbus_is_on(1));
}
/* ⚠️ `i2cbus.h` 末尾那条警告:回调里再调不带 `_locked` 的版本 = 嵌套拿同一把锁。
`i2cbus` 直接判 `HAL_ERR_STATE` 而不是死锁 —— 死锁在板子上的现象是
「巡检任务不再喂狗,整机每 8 s 重启一次」,而错误码至少能进事件日志。 */
static hal_err_t cb_nested(void *arg)
{
(void)arg;
return slot_out0_write(2, (uint8_t)(1u << PCA9555_BIT_KEY_PWR), 0x00);
}
static void test_calling_the_unlocked_version_inside_a_xact_is_rejected(void)
{
init_all();
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE,
i2cbus_xact(slot_mux_ch(2), SLOT_LOCK_TIMEOUT_MS, cb_nested, NULL));
}
/* ---- 输入(只有 `_locked` 版本) ---------------------------------------- */
static pca9555_in_t s_in;
static hal_err_t cb_read(void *arg)
{
const int rail = *(const int *)arg;
return slot_read_inputs_locked(rail, &s_in);
}
/* 读的是**这一路**的刀片。只让 CH4 报 eFuse 故障,五路挨个读一遍:
通道错的现象是「CH4 的故障被记在了 CH2 头上」,于是断的是好的那一路。 */
static void test_read_inputs_locked_reads_this_rails_blade(void)
{
init_all();
sim_pca9555_drive(blade(4), 1, (uint8_t)(0xFFu & ~(1u << PCA9555_BIT_EFUSE_FLT)));
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
int r = rail;
s_in.port0 = 0xAA;
s_in.port1 = 0xAA;
TEST_ASSERT_EQUAL_INT(HAL_OK,
i2cbus_xact(slot_mux_ch(rail), SLOT_LOCK_TIMEOUT_MS, cb_read, &r));
TEST_ASSERT_EQUAL_INT(rail == 4, pca9555_efuse_fault(&s_in));
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* ---- 参数与掩码的守门 ---------------------------------------------------- */
/* 路号是 **1~5**(不是 0~4)。越界必须返回 `HAL_ERR_ARG` 而不是悄悄当成 CH1 ——
悄悄当成 CH1 的现象是「命令发给了另一台手机」。 */
static void test_a_bad_rail_is_rejected_everywhere(void)
{
init_all();
const uint8_t pwr = (uint8_t)(1u << PCA9555_BIT_KEY_PWR);
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(0, pwr, pwr));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(PHONERAIL_RAIL_COUNT + 1, pwr, pwr));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_init(0));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_vbus_enable(0, true));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_vbus_enable_locked(PHONERAIL_RAIL_COUNT + 1, true));
TEST_ASSERT_FALSE(slot_vbus_is_on(0));
TEST_ASSERT_FALSE(slot_present(0));
TEST_ASSERT_FALSE(slot_present(PHONERAIL_RAIL_COUNT + 1));
/* 一片都没被碰过。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(blade(rail), 0));
}
}
/* 掩码碰到接地脚 → 驱动的 `HAL_ERR_ARG` 必须原样透出来(不是被 `xact` 吃掉
变成 HAL_OK)。透不出来的现象是「上层以为键按下了,其实一个字节都没发」。 */
static void test_a_mask_touching_a_grounded_pin_is_rejected(void)
{
init_all();
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(3, 0x20, 0x20)); /* P0.5 接地 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_out0_write(3, 0xFF, 0x00));
TEST_ASSERT_EQUAL_UINT32(0, sim_pca9555_gnd_driven());
TEST_ASSERT_EQUAL_HEX8(0x00, sim_pca9555_output(blade(3), 0));
}
static void test_read_inputs_locked_rejects_null(void)
{
init_all();
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_read_inputs_locked(1, NULL));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, slot_read_inputs_locked(0, &s_in));
}
/* ---- `PRSNT#`:纯 GPIO,低 = 在位 ---------------------------------------- */
/* 极性:刀片把金手指 B31 短到 A1 的 GND,底板 R606~R610 上拉 ⇒ **低 = 在位**。
写反的现象是「五个槽全空 ⇒ 固件拒绝上电」(还算安全),或者
「空槽被当成在位 ⇒ 给一排裸露的金手指送 12 V」(不安全)。 */
static void test_present_is_low_true(void)
{
/* `slot_prsnt_init()` 刚跑完:内部上拉、没人拉低 = 五个槽都空着。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_FALSE(slot_present(rail));
TEST_ASSERT_EQUAL_INT(1, sim_gpio_level(rail_cfg(rail)->prsnt_n)); /* 上拉生效 */
}
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0); /* 插上一片 */
}
slot_prsnt_poll();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_TRUE(slot_present(rail));
}
}
/* 五个槽各读各的脚。⚠️ `PRSNT5#` 是 **IO7**,不接着前四路顺排 ——
写成 `15 + rail - 1` 的话第五路会去读 IO19(那上面是别的信号),
现象是「CH5 的刀片时有时无」。 */
static void test_each_slot_reads_its_own_pin(void)
{
for (int on = 1; on <= PHONERAIL_RAIL_COUNT; on++) {
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
sim_gpio_drive(rail_cfg(rail)->prsnt_n, rail == on ? 0 : 1);
}
slot_prsnt_poll();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(rail == on, slot_present(rail));
}
}
}
/* ⭐ **GPIO 读失败时返回上一次成功的值,不是「不在位」。**
让一次读失败把五台正在跑的手机判成"拔掉了",代价比漏判大得多
(上层会因此断电、记事件、甚至拒绝重新上电)。
这里用「引脚整体回到没 config 的状态」造读失败(`hal_gpio_read()` 在两个目标上
也只可能因为这个失败)。断言用**混合现状**:两路不在位、三路在位。
写成「失败就当不在位」⇒ 那三路变红;写成「失败就当在位」⇒ 那两路变红。 */
static void test_present_keeps_the_last_good_value_when_the_read_fails(void)
{
const bool want[PHONERAIL_RAIL_COUNT] = { false, true, false, true, true };
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
sim_gpio_drive(rail_cfg(rail)->prsnt_n, want[rail - 1] ? 0 : 1);
}
slot_prsnt_poll();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(want[rail - 1], slot_present(rail));
}
/* 引脚全部回到「没 config」⇒ `hal_gpio_read()` 一律 HAL_ERR_ARG。 */
sim_hal_reset();
slot_prsnt_poll();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
TEST_ASSERT_EQUAL_INT(want[rail - 1], slot_present(rail)); /* 一格没变 */
}
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_mux_ch_comes_from_the_table);
RUN_TEST(test_out0_write_selects_this_rails_channel);
RUN_TEST(test_every_rail_touches_only_its_own_blade);
RUN_TEST(test_shadows_survive_channel_switching);
RUN_TEST(test_slot_init_configures_only_this_blade);
RUN_TEST(test_vbus_enable_drives_the_right_pin_on_the_right_blade);
RUN_TEST(test_vbus_shadow_is_the_drivers_one);
RUN_TEST(test_a_failed_vbus_off_leaves_the_shadow_on);
RUN_TEST(test_a_failed_vbus_on_leaves_the_shadow_off);
RUN_TEST(test_vbus_locked_inside_a_xact_hits_the_right_blade);
RUN_TEST(test_vbus_locked_outside_a_xact_writes_to_the_wrong_blade);
RUN_TEST(test_calling_the_unlocked_version_inside_a_xact_is_rejected);
RUN_TEST(test_read_inputs_locked_reads_this_rails_blade);
RUN_TEST(test_a_bad_rail_is_rejected_everywhere);
RUN_TEST(test_a_mask_touching_a_grounded_pin_is_rejected);
RUN_TEST(test_read_inputs_locked_rejects_null);
RUN_TEST(test_present_is_low_true);
RUN_TEST(test_each_slot_reads_its_own_pin);
RUN_TEST(test_present_keeps_the_last_good_value_when_the_read_fails);
return UNITY_END();
}
文件差异内容过多而无法显示 加载差异
+487 -184
查看文件
@@ -1,46 +1,146 @@
/* A8 之二:`t_safety` 的一个周期,以及需求 §10.3 的两条验收判据 ——
① sim 时钟下周期是 20 ms ±1 ms;
② I²C 占用打印出来,与 §10.3 手算的「约 1.5 ms / 约 8 %」对得上。
/* A10-R2 之一:`t_safety` **一圈的形状与账**(需求 §10.3、计划 §5.5)。
判据本身在 `test_t_safety_rules.c` 里,这里只管「这一圈发了什么、发给了谁」。
⚠️ 这两条能成立,靠的是 sim 的假 I²C **真的把虚拟时钟花掉**
(`sim_i2c_set_bus_time`,默认开)。没有那一条,巡检在 sim 里耗时为零,
「20 ms 周期」就退化成「测 delay 参数传对了没」—— 那是句空话:
真正会出事的是「巡检自己占掉了 1.5 ms,节拍还准不准」。 */
---- R1 → R2,这个文件测的东西换了重心 ------------------------------------
R1:一条总线、六片不同地址的 INA226,一圈 13 次事务。那时唯一会出事的形态是
「地址算错」,而地址算错**会 NAK**,一眼就能看见。
R2:五片刀片完全同构(INA226 全是 0x40、PCA9555 全是 0x20),区分它们的
唯一手段是底板 TCA9548A 的通道。于是出现了一种 R1 根本没有的错误形态:
**通道选错 ⇒ 读到另一台手机的电压电流**,而量纲、范围、变化趋势全都正常,
没有任何一层会报错、会 NAK、会计数。⭐ 本文件里带星的那两条就是它的拦网。
⚠️ 两条能成立的前提:
· 假总线里有一颗**真的 mux**(`sim_i2c_add_mux`)—— 没有它,五片刀片会
塌成一片,「选错通道」在宿主机上一次也复现不出来;
· 假 I²C **真的把虚拟时钟花掉**(`sim_i2c_set_bus_time`,默认开)——
没有它,「20 ms 周期」就退化成「测 delay 参数传对了没」,而真正会出事的是
「巡检自己占掉了 2.34 ms,节拍还准不准」。 */
#include <stdio.h>
#include <string.h>
#include "board_pins.h"
#include "hal_gpio.h"
#include "hal_i2c.h"
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "i2cbus.h"
#include "ina226.h"
#include "pca9555.h"
#include "rail.h"
#include "rail_cfg.h"
#include "sched.h"
#include "sim_hal.h"
#include "slot.h"
#include "t_actuator.h"
#include "t_safety.h"
#include "tasks.h"
#include "tca9548a.h"
#include "unity.h"
#define N PHONERAIL_RAIL_COUNT
/* 一圈发出去的事务数:6 片 INA226 各 2 次寄存器读 + PCA9555 一次。 */
#define TXNS_PER_CYCLE (INA226_COUNT * 2 + 1)
/* 主干上那两片 DAC(`rail.h` 的 U6 / U7)。巡检一次也不该碰它们 ——
设定值是影子里的(`rail_setpoint_mv()`),去读 DAC 就是每圈多五次事务。 */
#define DAC_ADDR_A 0x4A
#define DAC_ADDR_B 0x4B
/* 把假板子搭起来:6 片 INA226 + 1 片 PCA9555,地址全部**查表**取。 */
/* ⭐ 五路预置成**互不相同**的电压:CH1 3600 → CH5 4000 mV。
只有值各不相同,「读到了另一台手机的数据」才留得下痕迹。 */
#define RAIL_MV(rail) (3600 + 100 * ((rail) - 1))
/* 电流也各不相同:CH1 1 A → CH5 5 A。 */
#define RAIL_UA(rail) ((rail) * 1000000)
/* 五片刀片的 Port1(四个故障/PG 信号,开漏上拉 ⇒ 静息 0x0F)各驱动成不同的图案,
每一片**恰好有一个信号在响**,而且响的不是同一个。
全驱成一样的话,「五路读的都是同一片」这条错就看不出来。 */
static const uint8_t k_port1[N] = {
(uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_FLT)), /* CH1:eFuse 报故障 */
(uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_BUCK_PG)), /* CH2:Buck PG 掉了 */
(uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_INA_ALERT)), /* CH3:INA226 在告警 */
(uint8_t)(0x0Fu & ~(1u << PCA9555_BIT_EFUSE_PG)), /* CH4:eFuse PG 掉了 */
0x0Fu, /* CH5:什么事都没有 */
};
static safety_snap_t g_snap;
/* `board_up()` 跑完那一刻的基准值。启动本身会在总线和引脚上留下痕迹,
拿绝对值去断言等于把启动的账算进第一圈(那正是 `t_safety_init()` 对齐游标要防的事)。 */
static uint32_t g_resets0; /* `MUX_RST#` 复位次数 —— `i2cbus_init()` 必定留下一次,见下 */
static int ch_of(int rail) { return (int)rail_cfg(rail)->mux_ch; }
static sim_i2c_dev_t *ina_of(int rail)
{
return sim_i2c_dev_ch(ch_of(rail), rail_cfg(rail)->ina_addr);
}
static sim_i2c_dev_t *pca_of(int rail) { return sim_i2c_dev_ch(ch_of(rail), PCA9555_ADDR); }
static sim_i2c_dev_t *mux_dev(void) { return sim_i2c_dev(TCA9548A_ADDR); }
/* 从**假器件的寄存器**注入,不去改快照 —— 改快照等于跳过驱动和 mux,
那样连「读的是哪一片」都证明不了。 */
static void put_vi(int rail, int mv, int ua)
{
const int ch = ch_of(rail);
const uint8_t addr = rail_cfg(rail)->ina_addr;
TEST_ASSERT_EQUAL_INT(0, mv % 5); /* 母线 LSB 1.25 mV:不是 5 的倍数就注不准 */
sim_i2c_set_reg_ch(ch, addr, INA226_REG_BUS_V, (uint16_t)(mv * 4 / 5));
sim_i2c_set_reg_ch(ch, addr, INA226_REG_CURRENT,
(uint16_t)(int16_t)(ua / INA226_CURRENT_LSB_UA));
}
/* 假板子:主干(mux + 输入侧 INA226 + 两片 DAC)+ 五片刀片(各 INA226 + PCA9555)。 */
static void board_up(void)
{
const hal_i2c_cfg_t cfg = { .sda_gpio = 14, .scl_gpio = 15, .freq_hz = 400000 };
const hal_i2c_cfg_t cfg = { .sda_gpio = BOARD_PIN_I2C_SDA,
.scl_gpio = BOARD_PIN_I2C_SCL,
.freq_hz = 400000 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
sim_i2c_add_mux(TCA9548A_ADDR, BOARD_PIN_MUX_RST_N);
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); /* 每路不同,好认 */
const int ch = ch_of(rail);
sim_i2c_add_ch(ch, rail_cfg(rail)->ina_addr, 2);
put_vi(rail, RAIL_MV(rail), RAIL_UA(rail));
sim_pca9555_attach_ch(rail - 1, ch, PCA9555_ADDR);
sim_pca9555_drive(rail - 1, 1, k_port1[rail - 1]);
}
sim_i2c_add(INA226_ADDR_INPUT, 2);
sim_pca9555_attach(PCA9555_ADDR);
sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_BUS_V, (uint16_t)(12000 * 4 / 5));
sim_i2c_set_reg(INA226_ADDR_INPUT, INA226_REG_CURRENT, (uint16_t)(2000000 / 500));
sim_i2c_add(DAC_ADDR_A, 2);
sim_i2c_add(DAC_ADDR_B, 2);
TEST_ASSERT_EQUAL_INT(HAL_OK, i2cbus_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, rail_en_init());
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_prsnt_init());
/* ⚠️ `PRSNT#` 配的是**上拉输入**,没人驱动就是高 = 刀片不在位。
不驱低的话每一圈都会触发「刀片不在位」判据,而现象是「所有用例都多出几条
事件、五路自己关掉」—— 看上去像判据写错了,其实是假板子插反了。 */
for (int rail = 1; rail <= N; rail++) {
sim_gpio_drive(rail_cfg(rail)->prsnt_n, 0);
}
slot_prsnt_poll();
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, slot_init(rail));
}
t_actuator_init(NULL, NULL);
rail_setpoint_reset();
t_safety_init(&g_snap);
/* ⚠️ 这里一定是 1,不是 0:`i2cbus_init()` 先把 `MUX_RST#` 配成输出(两个 HAL
都保证「配完是 0」)再写 1,中间那一瞬间对真器件就是一次复位脉冲,假 mux
如实照做。这个窗口是已知且可接受的(那时还没人用总线),但**不能当它不存在**
—— 所以后面的用例一律和这个基准比差值。 */
g_resets0 = sim_i2c_mux_resets();
TEST_ASSERT_EQUAL_UINT32(1, g_resets0);
}
void setUp(void)
@@ -48,142 +148,271 @@ void setUp(void)
sim_hal_reset();
board_up();
}
void tearDown(void) {}
/* ---- 验收主项一:20 ms 的节拍 -------------------------------------------- */
/* 每个用例收尾都查一次「有没有同时开两路通道」。真板上那是两片同地址的 INA226
一起应答、读回来是线与 —— 数值正常、还很稳,没有任何一层会报错。
所以这个数**必须恒为 0**,放在 tearDown 里等于给每一条用例都加上这条断言。 */
void tearDown(void)
{
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻
(µs 级)对理想时刻的偏差始终 < 1 ms —— 这就是「20 ms ±1 ms」。
会漂的实现(干完睡 20)在这里第一圈就出局:那样周期是 21.56 ms,
500 圈之后差出 780 ms。 */
/* ---- 一圈的事务账(计划 §5.5) ------------------------------------------- */
/* 一圈正好 `t_safety_cycle_txns()` 次事务,而且**每一片器件被读的次数是钉死的**。
顺手多读一个寄存器(比如 check 一下 Calibration)会当场变红,
然后写的人才会想起来去把预算那一行也改了。 */
static void test_one_cycle_is_exactly_the_budgeted_transaction_count(void)
{
uint32_t ina_rd[N], pca_rd[N], pca_wr[N];
for (int rail = 1; rail <= N; rail++) {
ina_rd[rail - 1] = ina_of(rail)->n_read;
pca_rd[rail - 1] = pca_of(rail)->n_read;
pca_wr[rail - 1] = pca_of(rail)->n_write;
}
const uint32_t in_rd = sim_i2c_dev(INA226_ADDR_INPUT)->n_read;
const uint32_t mux_wr = mux_dev()->n_write;
const uint32_t mux_rd = mux_dev()->n_read;
const uint32_t dac_rd = sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read;
const uint32_t before = hal_i2c_acct_txns();
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), hal_i2c_acct_txns() - before);
TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns);
/* 逐片对账:五路各 2 次 INA226 寄存器读 + 1 次 PCA9555 输入读;
主干那片 INA226 2 次;mux 被写 6 次(五路各一次 + 回主干一次)。
⚠️ 比的是**这一圈的增量**:`slot_init()` 自己就读了 PCA9555 六次(写一个回读一个),
拿绝对值比等于把启动的账算进巡检。 */
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_EQUAL_UINT32(2, ina_of(rail)->n_read - ina_rd[rail - 1]);
TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_read - pca_rd[rail - 1]);
/* 「读输入」在线上是「写一个寄存器号 + 读两字节」,所以写计数也会 +1。
真正要拦的是**写输出锁存器**:平安无事的一圈里,最后落到这一片上的写
只能是那一个字节的寄存器指针(0x00),不能是 OUTPUT0(0x02)。 */
TEST_ASSERT_EQUAL_UINT32(1, pca_of(rail)->n_write - pca_wr[rail - 1]);
TEST_ASSERT_EQUAL_UINT8(PCA9555_REG_INPUT0, pca_of(rail)->last_write[0]);
TEST_ASSERT_EQUAL_UINT8(1, pca_of(rail)->last_write_len);
TEST_ASSERT_EQUAL_UINT8(0, sim_pca9555_output(rail - 1, 0)); /* 键与 VBUS 全是 0 */
}
TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read - in_rd);
TEST_ASSERT_EQUAL_UINT32((uint32_t)N + 1u, mux_dev()->n_write - mux_wr);
TEST_ASSERT_EQUAL_UINT32(0, mux_dev()->n_read - mux_rd); /* 选完不回读,那是 probe 的事 */
/* 设定值从影子里取(`rail_setpoint_mv()`),判据一次也不该去读 DAC。
读一次就是每圈多五次事务,把 §10.3 的预算直接顶穿。 */
TEST_ASSERT_EQUAL_UINT32(
0, sim_i2c_dev(DAC_ADDR_A)->n_read + sim_i2c_dev(DAC_ADDR_B)->n_read - dac_rd);
}
/* 静态预算与实测必须**一模一样**。不一样只有两种可能:巡检里多读/少读了东西,
或者有别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。
(这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */
static void test_measured_bus_time_equals_the_static_budget(void)
{
for (int i = 0; i < 10; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us);
TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns);
}
}
/* 计划 §5.5 那张表的可执行版本:23 次事务 ≈ 2.5 ms / 12.5 %。
这里按线上真实发生的位数算,得 6×20 + 17×48 = 936 位 = 2340 µs = 11.7 %。
§5.5 手算的 2.48 ms 用的是每次事务的近似值,差的那 140 µs 是近似的账。
⚠️ 这一条同时钉住「R1 的 13 次 / 1.56 ms 已经不是这块板子的数了」——
把预算函数改回 R1 那版,这里第一行就红。 */
static void test_the_bus_account_matches_plan_5_5(void)
{
TEST_ASSERT_EQUAL_UINT32(23, t_safety_cycle_txns());
TEST_ASSERT_EQUAL_UINT32(20, hal_i2c_txn_bits(1, 0)); /* mux 选通:裸写一个字节 */
TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2)); /* 寄存器读 */
TEST_ASSERT_EQUAL_UINT32(2340, t_safety_budget_us());
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
const uint32_t period = task_desc(TASK_SAFETY)->period_ms;
const uint32_t pm = t_safety_load_permille(g_snap.bus_us, period);
TEST_ASSERT_EQUAL_UINT32(117, pm);
/* 计划 §5.5 的「≈12.4 %」:11 %~13 % 都算对得上。超出就是巡检里多读/少读了东西。 */
TEST_ASSERT_TRUE(pm >= 110 && pm <= 130);
/* 一圈占的总线时间必须远小于周期,否则巡检自己就把节拍吃掉了。 */
TEST_ASSERT_TRUE(g_snap.bus_us < period * 1000u / 4u);
char line[160];
const int n = t_safety_format(&g_snap, line, sizeof(line));
TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line));
TEST_ASSERT_NOT_NULL(strstr(line, "txns=23"));
TEST_ASSERT_NOT_NULL(strstr(line, "bus=2340 us/20 ms"));
TEST_ASSERT_NOT_NULL(strstr(line, "load=11.7%"));
printf(" %s(计划 §5.5:≈2.5 ms / 12.4 %%)\n", line);
}
/* ---- ⭐ R2 最危险的错误形态:读到了另一台手机 ---------------------------- */
/* 五个通道上的假 INA226 预置成**互不相同**的电压与电流,跑一圈,逐路对上。
通道选错的后果是「读到另一台手机的数据」—— 量纲、范围、变化趋势全都正常,
遥测曲线好看得很,五台机器的过压判据却全部盯错了对象。
R1 里这条错误不存在(六片六个地址,错了就 NAK);R2 里它不报错、不 NAK、
不计数,**只有值本身能拆穿它**。 */
static void test_each_rail_is_read_on_its_own_mux_channel(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]);
TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv);
TEST_ASSERT_EQUAL_INT32(RAIL_UA(rail), g_snap.rail[rail - 1].current_ua);
}
/* 主干那片(0x43)不在任何通道后面,它的值不许混进五路里。 */
TEST_ASSERT_TRUE(g_snap.input_ok);
TEST_ASSERT_EQUAL_INT32(12000, g_snap.input.bus_mv);
/* 换一组值再跑一圈:万一上面那组恰好被「按顺序发牌」蒙对,换一组就露馅。 */
for (int rail = 1; rail <= N; rail++) {
put_vi(rail, 4000 - 100 * (rail - 1), -(rail * 100000)); /* 顺序反过来、电流转负 */
}
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_EQUAL_INT32(4000 - 100 * (rail - 1), g_snap.rail[rail - 1].bus_mv);
TEST_ASSERT_EQUAL_INT32(-(rail * 100000), g_snap.rail[rail - 1].current_ua);
}
}
/* 同一件事,换 PCA9555 那一路:五片的四个故障信号各响一个,逐片对上。
INA226 那条对上了、这条没对上,说明「选通道」和「读哪一片」在两条路径上
各自算了一遍 —— 而 `slot.c` 存在的全部理由就是让这两件事只算一次。 */
static void test_each_blade_reports_its_own_pca9555_inputs(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]);
TEST_ASSERT_EQUAL_UINT8(k_port1[rail - 1], g_snap.io[rail - 1].port1);
}
/* 换成语义判读再验一遍(极性写反在原始字节上是看不出来的)。 */
TEST_ASSERT_TRUE(pca9555_efuse_fault(&g_snap.io[0])); /* CH1 FLT# 低 = 有故障 */
TEST_ASSERT_FALSE(pca9555_efuse_fault(&g_snap.io[1]));
TEST_ASSERT_FALSE(pca9555_buck_pg(&g_snap.io[1])); /* CH2 Buck PG 低 = 不良 */
TEST_ASSERT_TRUE(pca9555_buck_pg(&g_snap.io[0]));
TEST_ASSERT_TRUE(pca9555_ina_alert(&g_snap.io[2])); /* CH3 ALERT 低 = 在告警 */
TEST_ASSERT_FALSE(pca9555_ina_alert(&g_snap.io[3]));
TEST_ASSERT_FALSE(pca9555_efuse_pg(&g_snap.io[3])); /* CH4 eFuse PG 低 */
TEST_ASSERT_TRUE(pca9555_efuse_pg(&g_snap.io[4])); /* CH5 全好 */
}
/* 任何时刻都只许开一路通道,而且一圈走完停在**主干**上。
同时开两路在真板上是两片一起应答、读回线与(数值正常、还很稳)——
`sim_i2c_ambiguous()` 是这件事在宿主机上唯一的痕迹。 */
static void test_the_mux_never_has_two_channels_open(void)
{
for (int i = 0; i < 50; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
/* 一圈的最后一次选通是回主干(读输入侧那片),所以收尾时通道是全关的。 */
TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_UINT8(0, sim_i2c_mux_mask());
}
TEST_ASSERT_EQUAL_UINT32(0, i2cbus_select_fails());
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_mux_resets() - g_resets0); /* 一切正常时不该再复位 */
}
/* 通道 5 上是 CH224A(PD 受电芯片),通道 6 / 7 悬空。巡检一次也不许选到它们。
选到通道 5 再去读 0x40 会一个字节也读不到,而现象是「这一路电压电流突然全是 0」
—— 看上去像刀片拔了。这里在那三路上各挂一片 0x40,它们必须一次也没被碰过。 */
static void test_the_unused_channels_are_never_selected(void)
{
sim_i2c_dev_t *pd = sim_i2c_add_ch(TCA9548A_CH_PD, INA226_ADDR_RAIL, 2);
sim_i2c_dev_t *sp6 = sim_i2c_add_ch(6, INA226_ADDR_RAIL, 2);
sim_i2c_dev_t *sp7 = sim_i2c_add_ch(7, INA226_ADDR_RAIL, 2);
for (int i = 0; i < 5; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
}
TEST_ASSERT_EQUAL_UINT32(0, pd->n_read);
TEST_ASSERT_EQUAL_UINT32(0, sp6->n_read);
TEST_ASSERT_EQUAL_UINT32(0, sp7->n_read);
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 它们也没和刀片撞上过 */
}
/* ---- 节拍 ---------------------------------------------------------------- */
/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻(µs 级)
对理想时刻的偏差始终 < 1 ms —— 这就是 §10.3 的「20 ms ±1 ms」。
会漂的实现(干完睡 20)在这里第一圈就出局:R2 一圈自己要占 2.34 ms,
那样周期是 22.34 ms,500 圈之后差出 1.17 s。 */
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_INT(HAL_OK, t_safety_step(&g_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();
/* ⚠️ 相位起点是**第一次醒来那一刻**,不是 0:R2 的启动自己就要跑 mux 认件 +
五片 PCA9555 初始化,上百次事务在虚拟时钟上是真花掉了的。拿绝对 0 当起点,
测出来的是「启动花了多久」,和节拍没关系。 */
const uint64_t base_us = hal_time_now_us();
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));
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_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 ideal_us = (int64_t)base_us + (int64_t)i * 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);
TEST_ASSERT_EQUAL_UINT32(500, g_snap.cycles);
TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped);
TEST_ASSERT_EQUAL_UINT32(0, g_snap.events);
}
/* ---- 验收主项二: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)
/* 20 ms 的周期必须**长于** INA226 的一帧转换时间(AVG=4 × 2×1.1 ms = 8.8 ms)。
短了的话同一帧会被读两次,而现象是「电流特别稳」—— 不是任何一种错误,
不会 NAK、不会报警、遥测曲线还更好看。R2 一圈占 2.34 ms,离 8.8 ms 还很远,
所以每一圈拿到的都是新的一帧。 */
static void test_the_period_outlives_one_ina226_conversion(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);
const uint32_t period_us = task_desc(TASK_SAFETY)->period_ms * 1000u;
TEST_ASSERT_EQUAL_UINT32(8800, (uint32_t)INA226_CONV_US);
TEST_ASSERT_TRUE(period_us > (uint32_t)INA226_CONV_US);
/* 两倍余量:巡检自己占的总线时间加进去也不能逼近一帧。 */
TEST_ASSERT_TRUE(period_us >= 2u * (uint32_t)INA226_CONV_US);
TEST_ASSERT_TRUE(t_safety_budget_us() < (uint32_t)INA226_CONV_US);
TEST_ASSERT_TRUE(task_table_ok()); /* 任务表自检里钉着同一条 */
}
/* 静态预算与实测必须一模一样。不一样只有两种可能:巡检里多读/少读了东西,
或者别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。
(这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */
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 锁;出了这个函数锁必须已经放掉。 */
/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。
R2 里这一圈要拿六次锁(五路 + 主干),每一次都得规规矩矩。 */
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));
const uint32_t takes0 = sim_lock_takes(HAL_LOCK_I2C);
for (int i = 0; i < 5; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_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));
/* 一圈六次 `xact` = 六次拿锁(R1 是一把大锁包住整圈,只拿一次)。
这个数正是「给 t_actuator 留的缝」有几条 —— 变少了缝就没了。 */
TEST_ASSERT_EQUAL_UINT32(5u * ((uint32_t)N + 1u), sim_lock_takes(HAL_LOCK_I2C) - takes0);
}
/* 上一条不是句空话:不拿锁直接读一片,违规计数必须 +1。
(检查本身失灵的话,上一条会永远绿。) */
/* 上一条不是句空话:不拿锁直接读一片 INA226,违规计数必须 +1。
检查本身失灵的话,上一条会永远绿。 */
static void test_the_lock_check_itself_can_fail(void)
{
sim_lock_require(HAL_LOCK_I2C, true);
@@ -192,129 +421,203 @@ static void test_the_lock_check_itself_can_fail(void)
TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */
}
/* 拿不到锁:**跳过这一圈**,不死等(t_safety.h 规矩 1)。
等掉的是 5 ms,还在 20 ms 之内,所以节拍不丢。 */
/* 拿不到锁:**跳过,不死等**(t_safety.h 规矩 1)。死等的现象是巡检整个停摆,
而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。
⚠️ R2 的一笔新账:一圈要等**六次**锁,最坏 6 × 5 = 30 ms,**比 20 ms 的周期还长**
(R1 只等一次,5 ms)。这条断言把这个数钉在这里:谁改了超时常量或者 `xact`
的次数,都得先来正面回答「一圈最坏等多久」这个问题。 */
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, t_safety_step(&g_snap)); /* 先跑一圈好的,留下基准 */
const int32_t mv0 = g_snap.rail[0].bus_mv;
const uint32_t txns0 = hal_i2c_acct_txns();
const uint32_t en_writes0 = sim_gpio_writes(rail_cfg(1)->efuse_en);
sim_lock_set_task(1); /* 假装 t_actuator 正在按键,总线在它手里 */
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(&g_snap));
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);
/* 这一圈**什么也没做**:一次事务没发、一根引脚没动、快照原封不动。 */
TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns0);
TEST_ASSERT_EQUAL_UINT32(0, g_snap.txns);
TEST_ASSERT_EQUAL_UINT32(0, g_snap.bus_us);
TEST_ASSERT_EQUAL_INT32(mv0, g_snap.rail[0].bus_mv);
TEST_ASSERT_EQUAL_UINT32(en_writes0, sim_gpio_writes(rail_cfg(1)->efuse_en));
TEST_ASSERT_EQUAL_UINT32(0, g_snap.events);
/* 但这一圈**被记下来了**:不计数的跳过等于悄悄失明。 */
TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped);
TEST_ASSERT_EQUAL_UINT32(2, g_snap.cycles);
/* ⭐ **整圈的账,不是单次的账。** R2 一圈要拿六次锁,六次各等满 5 ms 就是
30 ms —— 比 20 ms 的周期还长,现象是巡检成片丢拍而每一次 `xact` 都老老实实
按时返回了。所以 `T_SAFETY_LOCK_BUDGET_MS` 是一条整圈的闸:
花到 10 ms 就不再试剩下的路。
这里断言的正是那条闸:**只试了 2 次**(5 + 5 = 10 ms 用完预算),
而不是六次全试一遍。把预算那一段删掉的话,下面两条会变成 6 和 30。 */
TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS / T_SAFETY_LOCK_TIMEOUT_MS,
i2cbus_lock_timeouts());
TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_BUDGET_MS, hal_time_elapsed_ms(t0));
/* 而且整圈花掉的时间**必须短于巡检周期**,否则节拍本身就保不住了。 */
TEST_ASSERT_TRUE(hal_time_elapsed_ms(t0) < task_desc(TASK_SAFETY)->period_ms);
/* 总线还回来,下一圈照常。 */
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_I2C);
sim_lock_set_task(0);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns);
TEST_ASSERT_EQUAL_UINT32(1, g_snap.skipped);
}
/* ---- 器件出问题的时候(§10.2:失联 ≠ 断电) ------------------------------ */
/* 六片里坏一片,剩下五片照读、PCA 照读。中断这一圈的话,
现象是「一片 INA226 掉线,五路判据全瞎」。 */
static void test_one_dead_device_does_not_stop_the_round(void)
/* 一片刀片的 INA226 掉线,**剩下四路的读照常**。中断这一圈的现象是
「一片 INA226 掉线,五路判据全瞎」—— 而那四台手机什么毛病都没有。 */
static void test_one_dead_ina226_does_not_stop_the_other_four(void)
{
sim_i2c_set_present(rail_cfg(3)->ina_addr, false);
sim_i2c_set_present_ch(ch_of(3), 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_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap));
TEST_ASSERT_FALSE(snap.rail_ok[2]);
TEST_ASSERT_FALSE(g_snap.rail_ok[2]);
for (int rail = 1; rail <= N; rail++) {
if (rail != 3) {
TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]);
if (rail == 3) {
continue;
}
TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]);
TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv);
TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]);
}
TEST_ASSERT_TRUE(snap.input_ok);
TEST_ASSERT_TRUE(snap.io_ok);
TEST_ASSERT_EQUAL_UINT32(1, snap.i2c_errors);
/* 坏掉那一路的 PCA9555 没跟着遭殃(是两颗器件,不是一颗)。 */
TEST_ASSERT_TRUE(g_snap.io_ok[2]);
TEST_ASSERT_EQUAL_UINT8(k_port1[2], g_snap.io[2].port1);
TEST_ASSERT_TRUE(g_snap.input_ok);
TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors);
/* 读 V 那一次就 NAK 了,读 I 那一次不会再发 ⇒ 这一圈**少一次事务**。
把这个数写下来,是为了让「失联之后还傻读第二次」也会变红。 */
TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns() - 1u, g_snap.txns);
TEST_ASSERT_EQUAL_UINT32(
t_safety_budget_us() - (uint32_t)hal_i2c_bits_to_us(hal_i2c_txn_bits(1, 2)),
g_snap.bus_us);
}
/* 读失败时保留**上一圈的值**并把 ok 标成 false —— 不清零。
清零的话,A10 的过压判据会看到一个 0 V 的「正常」电压,
而 ok 标志正是让它先看一眼「这数是不是新的」。 */
清零的话,判据会看到一个 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);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_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); /* 还是上一圈那个 */
sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, false);
/* 器件掉线之后把寄存器也改掉:万一实现偷偷读到了,值就会跟着变。 */
put_vi(1, 3000, 0);
for (int i = 0; i < 5; i++) {
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap));
TEST_ASSERT_FALSE(g_snap.rail_ok[0]);
TEST_ASSERT_EQUAL_INT32(RAIL_MV(1), g_snap.rail[0].bus_mv); /* 还是上一圈那个 */
TEST_ASSERT_EQUAL_INT32(RAIL_UA(1), g_snap.rail[0].current_ua);
}
/* 器件回来了就立刻用新值(`ok` 不是个一去不返的标志)。 */
sim_i2c_set_present_ch(ch_of(1), rail_cfg(1)->ina_addr, true);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_TRUE(g_snap.rail_ok[0]);
TEST_ASSERT_EQUAL_INT32(3000, g_snap.rail[0].bus_mv);
}
/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,但占得更少)。
⚠️ 这条测的是「巡检不会因为总线出错就乱了节奏」。 */
static void test_the_beat_survives_a_dead_device(void)
/* 同一路上的两颗器件互不牵连:PCA9555 读不到,不影响这一路 INA226 已经读到的值。
`cycle_rail()` 里「器件读失败不提前返回」那一条就是这个意思 ——
提前返回的现象是「一片 I/O 扩展器失联,这一路的过压判据也跟着停了」。 */
static void test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226(void)
{
sim_i2c_set_present(rail_cfg(2)->ina_addr, false);
sim_i2c_set_present_ch(ch_of(2), PCA9555_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap));
TEST_ASSERT_FALSE(g_snap.io_ok[1]);
TEST_ASSERT_TRUE(g_snap.rail_ok[1]); /* INA226 是好的,值是新的 */
TEST_ASSERT_EQUAL_INT32(RAIL_MV(2), g_snap.rail[1].bus_mv);
TEST_ASSERT_EQUAL_INT32(RAIL_UA(2), g_snap.rail[1].current_ua);
for (int rail = 1; rail <= N; rail++) {
if (rail != 2) {
TEST_ASSERT_TRUE(g_snap.io_ok[rail - 1]);
}
}
TEST_ASSERT_EQUAL_UINT32(1, g_snap.i2c_errors);
}
/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,只是占得更少)。
测的是「巡检不会因为总线出错就乱了节奏」。 */
static void test_the_beat_survives_a_dead_blade(void)
{
sim_i2c_set_present_ch(ch_of(5), rail_cfg(5)->ina_addr, false);
sim_i2c_set_present_ch(ch_of(5), PCA9555_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);
(void)t_safety_step(&g_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);
TEST_ASSERT_EQUAL_UINT32(100, g_snap.i2c_errors); /* 每圈两颗器件各一次 */
/* 别的四路一点没受影响 —— 这才是「失联 ≠ 别的路也不看了」。 */
for (int rail = 1; rail <= 4; rail++) {
TEST_ASSERT_TRUE(g_snap.rail_ok[rail - 1]);
TEST_ASSERT_EQUAL_INT32(RAIL_MV(rail), g_snap.rail[rail - 1].bus_mv);
}
}
/* 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 时对齐到「现在」)。 */
/* 第一圈的占用里不该混进启动期间的事务(游标要在 `t_safety_init()` 时对齐到「现在」)。
混进去的现象是「开机那一下占用率 40 %」,然后再也不复现。 */
static void test_startup_traffic_is_not_charged_to_the_first_cycle(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(PCA9555_ADDR)); /* 假装启动扫了一下 */
/* 假装启动时又扫了一遍总线。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
(void)hal_i2c_probe(TCA9548A_ADDR);
(void)hal_i2c_probe(INA226_ADDR_INPUT);
hal_lock_give(HAL_LOCK_I2C);
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);
t_safety_init(&g_snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&g_snap));
TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), g_snap.bus_us);
TEST_ASSERT_EQUAL_UINT32(t_safety_cycle_txns(), g_snap.txns);
}
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_one_cycle_is_exactly_the_budgeted_transaction_count);
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_the_bus_account_matches_plan_5_5);
RUN_TEST(test_each_rail_is_read_on_its_own_mux_channel);
RUN_TEST(test_each_blade_reports_its_own_pca9555_inputs);
RUN_TEST(test_the_mux_never_has_two_channels_open);
RUN_TEST(test_the_unused_channels_are_never_selected);
RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles);
RUN_TEST(test_the_period_outlives_one_ina226_conversion);
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_one_dead_ina226_does_not_stop_the_other_four);
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_a_dead_pca9555_does_not_spoil_the_same_rails_ina226);
RUN_TEST(test_the_beat_survives_a_dead_blade);
RUN_TEST(test_startup_traffic_is_not_charged_to_the_first_cycle);
return UNITY_END();
}
文件差异内容过多而无法显示 加载差异
+466
查看文件
@@ -0,0 +1,466 @@
/* A16 / R-6:风扇闭环与 TACH 采样窗口(需求 FR-10 / FR-13 / TR-6,计划 §5.10)。
这一包的判据全部长在三件**写错了不会报错**的事情上:
① `FAN_EN` 复位态是「不转」。忘了主动拉高 ⇒ 温度闭环、PWM、遥测全部正常,
只是一点风都没有。功能测试看不出来,因为没有任何一个返回值会变。
② PWM 经 Q6 反相。写反了 ⇒ **越热转得越慢**,而遥测里「占空比随温度上升」
这句话仍然是对的 —— 看仪表盘的人永远发现不了。
③ TACH 在 PWM 关断期间没有脉冲(TR-6)。按「这一圈没数到脉冲就是停转」写
⇒ 低占空比下**稳定误报**,大家很快学会无视风扇告警,等到真停转那天
告警照常发出来、照常没人看。
所以下面的断言一律打在**假风扇**这个可观测面上(转速、边沿数、引脚电平),
不去对着模块自己的影子变量断言 —— 那是自己证明自己。 */
#include <string.h>
#include "board_pins.h"
#include "evlog.h"
#include "hal_pwm.h"
#include "hal_time.h"
#include "sim_hal.h"
#include "t_thermal.h"
#include "tasks.h"
#include "unity.h"
/* 假风扇按设计文档 §22.6 的数:满速 11000 RPM、0 % PWM 仍转 3300 RPM、2 PPR。 */
#define FAN_RPM_FULL 11000u
#define FAN_RPM_MIN 3300u
/* 一圈 1 s —— 和任务表里 `t_thermal` 的周期一致(tasks.c 定死,本包不改它)。 */
static uint32_t period_ms(void) { return task_desc(TASK_THERMAL)->period_ms; }
/* ---- 喂进来的两样东西(温度与 TACH 计数器,见 t_thermal.h 的「外部喂进来」)---- */
static int32_t s_temp_mc[T_THERMAL_NTC_COUNT];
static bool s_temp_ok[T_THERMAL_NTC_COUNT];
static bool temp_src(void *ctx, int idx, int32_t *mc)
{
(void)ctx;
if (!s_temp_ok[idx]) {
return false;
}
*mc = s_temp_mc[idx];
return true;
}
/* TACH 直接取假风扇的累计边沿数 —— 板上这个数来自 PCNT。 */
static bool tach_src(void *ctx, int idx, uint32_t *edges)
{
(void)ctx;
*edges = sim_fan_edges(idx);
return true;
}
/* 下一条落盘记录会拿到的序号(和 test_evlog.c 一个写法)。 */
static uint32_t next_seq(void)
{
evlog_stats_t st;
evlog_stats(&st);
return st.next_seq;
}
static void set_temp(int32_t mc)
{
for (int i = 0; i < T_THERMAL_NTC_COUNT; i++) {
s_temp_mc[i] = mc;
s_temp_ok[i] = true;
}
}
static void fans_up(void)
{
for (int i = 0; i < T_THERMAL_FAN_COUNT; i++) {
sim_fan_attach(i, T_THERMAL_PWM_CH, BOARD_PIN_FAN_EN,
FAN_RPM_FULL, FAN_RPM_MIN, T_THERMAL_TACH_PPR);
/* **关断期不吐脉冲** —— TR-6 说的就是这种风扇,也是最坏情况。 */
sim_fan_set_gated(i, true);
}
}
void setUp(void)
{
sim_hal_reset();
fans_up();
set_temp(25000); /* 25 °C:常温开机 */
}
void tearDown(void) {}
/* 跑 n 圈,每圈把虚拟时钟往前拨一个周期。真板上是 `sched_periodic_wait()` 睡过去的。 */
static void run(thermal_snap_t *s, uint32_t n)
{
for (uint32_t i = 0; i < n; i++) {
sim_time_advance_ms(period_ms());
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_step(s));
}
}
/* 起模块 + 跑过启动宽限(`T_THERMAL_SPINUP_MS`),让停转判据开始工作。 */
static void boot(thermal_snap_t *s)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(s, temp_src, tach_src, NULL));
run(s, T_THERMAL_SPINUP_MS / 1000u + 1u);
}
/* ---- ① 上电就得把 12V 打开 ---------------------------------------------- */
/* `FAN_EN`(IO48)复位时高阻 + R749 下拉 ⇒ 12V 是断的。固件不主动拉高就一直没风。
⚠️ 光断言引脚电平还不够 —— 引脚为高但顺序写错(先使能供电、后配 PWM)在真板上
也是一段全速乱转,所以这里还断言**假风扇真的转起来了**。
写错了会怎样:删掉 `t_thermal_init()` 里最后那行 `hal_gpio_write(FAN_EN, 1)`,
整块板子的行为只有一处变化 —— 不散热。任何看返回值的测试都是绿的。 */
static void test_init_drives_fan_en_high_and_fans_actually_spin(void)
{
/* 起之前:谁也没配过这个脚,更没人驱动它。 */
TEST_ASSERT_EQUAL_INT_MESSAGE(-1, sim_gpio_level(BOARD_PIN_FAN_EN), "起之前不该有人动 FAN_EN");
TEST_ASSERT_EQUAL_UINT32(0, sim_fan_rpm(0));
thermal_snap_t s;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(&s, temp_src, tach_src, NULL));
TEST_ASSERT_EQUAL_INT_MESSAGE(1, sim_gpio_level(BOARD_PIN_FAN_EN), "FAN_EN 没被拉高 = 没风");
/* 先写 0 再写 1(§22.6 的上电顺序:先关、配好 PWM、再给电)。 */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, sim_gpio_writes(BOARD_PIN_FAN_EN), "上电顺序不是「先 0 后 1」");
TEST_ASSERT_EQUAL_INT(BOARD_PIN_FAN_PWM, sim_pwm_pin(T_THERMAL_PWM_CH));
TEST_ASSERT_EQUAL_UINT32(T_THERMAL_PWM_HZ, sim_pwm_freq(T_THERMAL_PWM_CH));
/* 两只都要转 —— 一路 PWM 喂两个座(J26/J27),漏掉一只就是半个风道。 */
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) > 0, "进风扇没转");
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(1) > 0, "排风扇没转");
}
/* ---- ② PWM 反相:越热必须越快 ------------------------------------------- */
/* Q6 是低边管,栅极高 = 把风扇的 PWM 线拉低 ⇒ `D_gpio = 1 − D_fan`。
⚠️ 断言分两层:写进 hal_pwm 的值是反的(接口层),**假风扇的转速跟着温度涨**
(物理层)。只断言第一层的话,把 `gpio_duty_of()` 和调用处一起改反仍然是绿的。
写错了会怎样:温度越高风扇越慢,而遥测里 `duty` 照常单调上升 —— 看不出来。 */
static void test_hotter_never_means_slower(void)
{
thermal_snap_t s;
boot(&s);
uint16_t prev_duty = 0;
uint32_t prev_rpm = 0;
for (int32_t t = 20000; t <= 90000; t += 5000) {
set_temp(t);
run(&s, 1);
TEST_ASSERT_TRUE_MESSAGE(s.duty >= prev_duty, "温度升了,占空比反而降");
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) >= prev_rpm, "温度升了,风扇反而慢了(PWM 反相写反了)");
/* 接口层:真写进去的是反相后的值。 */
TEST_ASSERT_EQUAL_INT_MESSAGE((int)(1000u - s.duty), sim_pwm_permille(T_THERMAL_PWM_CH),
"写进 hal_pwm 的不是 1000 − 风扇侧占空比");
prev_duty = s.duty;
prev_rpm = sim_fan_rpm(0);
}
TEST_ASSERT_EQUAL_UINT16_MESSAGE(1000, s.duty, "90 °C 还没满速");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(FAN_RPM_FULL, sim_fan_rpm(0), "满速时假风扇没到额定转速");
}
/* 纯函数那一层:曲线形状 + 两个端点。分段线性写成整数除法很容易在端点差 1,
而端点正是「安静」和「救命」两件事各自的极限。 */
static void test_duty_curve_is_monotonic_and_bounded(void)
{
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, t_thermal_duty_for(0, true));
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, t_thermal_duty_for(T_THERMAL_TEMP_LO_MC, true));
TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_duty_for(T_THERMAL_TEMP_HI_MC, true));
TEST_ASSERT_EQUAL_UINT16(1000, t_thermal_duty_for(200000, true));
uint16_t prev = 0;
for (int32_t t = -10000; t <= 120000; t += 250) {
const uint16_t d = t_thermal_duty_for(t, true);
TEST_ASSERT_TRUE_MESSAGE(d >= prev, "占空比曲线不单调");
TEST_ASSERT_TRUE(d <= 1000);
prev = d;
}
}
/* 温度读不到 ⇒ **满速**。需求 §10.10 对「温度读不到」给的处置就是这一条:
不要按「读不到温度 = 不热」处理。
写错了会怎样:NTC 开路(一根线的事)之后风扇按 0 °C 跑最低速,机器照常工作,
直到夏天某个下午集体降频 —— 而日志里温度那一栏很干净,因为它根本没有值。 */
static void test_unreadable_temperature_means_full_speed(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
s_temp_ok[0] = false;
s_temp_ok[1] = false;
run(&s, 1);
TEST_ASSERT_FALSE(s.temp_valid);
TEST_ASSERT_EQUAL_UINT16_MESSAGE(1000, s.duty, "温度读不到时没有转满速");
TEST_ASSERT_EQUAL_UINT32(1, s.temp_errors);
}
/* 两路 NTC 取**高**的那一个:一路在电感区、一路在 MOS 区,凉的那一路完全可能是
「那半边没在用」。取平均的现象是 90 °C 的热点被 30 °C 的凉点平成 60 °C —— 一个
哪儿都不存在、但看着很健康的数,而且平均值**永远不会**触发降载。 */
static void test_two_ntc_take_the_hotter_one(void)
{
thermal_snap_t s;
boot(&s);
s_temp_ok[0] = true; s_temp_mc[0] = 30000;
s_temp_ok[1] = true; s_temp_mc[1] = 90000;
run(&s, 1);
TEST_ASSERT_EQUAL_INT32_MESSAGE(90000, s.temp_max_mc, "两路 NTC 没取高的那一个");
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
}
/* ---- ③ TR-6:采样窗口 ---------------------------------------------------- */
/* **本包的核心用例。** 风扇转得好好的,只是占空比在下限(30 %),而这种风扇在
PWM 关断期间一个脉冲都没有 ⇒ 按「一个固定墙钟窗口里脉冲数不够就是停转」写的
实现会**稳定误报**。
本用例先证明这个坑是真的(低占空比下每秒脉冲数掉到满速的约 1/6),
再证明我们没掉进去(跑 60 s 一次都没判停转),最后证明我们不是靠「干脆永远不判」
混过去的(窗口照常在结算)—— 把误报换成漏报是这条判据最容易出的「修复」。 */
static void test_low_duty_windows_are_not_mistaken_for_a_stall(void)
{
thermal_snap_t s;
boot(&s);
set_temp(25000); /* 凉 ⇒ 占空比压到下限 */
run(&s, 1);
TEST_ASSERT_EQUAL_UINT16(T_THERMAL_DUTY_MIN, s.duty);
/* 坑本身:同一只转着的风扇,每秒脉冲数只有满速的一小截。
满速是 11000 RPM × 2 PPR / 60 = 366 脉冲/s;按它的一半(183)做门限的话,
下面这 60 圈**每一圈都会**被判成停转。 */
const uint32_t naive_threshold = FAN_RPM_FULL * T_THERMAL_TACH_PPR / 60u / 2u;
uint32_t worst_per_second = 0xFFFFFFFFu;
const uint32_t edges_0 = sim_fan_edges(0);
for (int i = 0; i < 60; i++) {
const uint32_t before = sim_fan_edges(0);
run(&s, 1);
const uint32_t d = sim_fan_edges(0) - before;
if (d < worst_per_second) {
worst_per_second = d;
}
TEST_ASSERT_TRUE_MESSAGE(sim_fan_rpm(0) > 0, "假风扇自己停了,这个用例就白测了");
TEST_ASSERT_FALSE_MESSAGE(s.fan[0].stalled, "低占空比被误判成停转(TR-6)");
TEST_ASSERT_FALSE_MESSAGE(s.fan[1].stalled, "低占空比被误判成停转(TR-6)");
}
TEST_ASSERT_TRUE_MESSAGE(worst_per_second < naive_threshold,
"假风扇没有重现 TR-6 的场景(关断期照样吐脉冲),这个用例失去意义");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "误报了停转");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[1].stalls, "误报了停转");
/* 没有靠「永远不判」混过去:60 s 里窗口按导通时间结算了好几个,转速也出来了。 */
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].windows >= 3, "窗口根本没结算过 —— 把误报换成了漏报");
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm_valid, "没算出转速");
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].rpm > 0, "转速算成了 0");
/* ---- 机制本身:窗口**跟着占空比自动拉长了** ----
这两条才是判据成立的理由。固定墙钟窗口(比如「每圈判一次」)在这 60 s 里会
结算约 57 个窗口、每个窗口只有 56 个脉冲 —— 离门限只剩 14 倍余量,而占空比
再低一截余量就没了,那时它才开始误报。**误报是有门槛的**:按今天这个占空比
测不出来,等将来限功率把风扇压到 5 % 才出事,那时没人会想到是这里。
按导通时间拉长窗口之后,「窗口里该有多少脉冲」与占空比无关,余量恒定。 */
const uint32_t settled = s.fan[0].windows;
const uint32_t counted = sim_fan_edges(0) - edges_0;
TEST_ASSERT_TRUE_MESSAGE(settled <= 12, "窗口没有跟着占空比拉长(还是一圈判一次)");
TEST_ASSERT_TRUE_MESSAGE(counted / settled >= 50u * T_THERMAL_TACH_MIN_EDGES,
"每个窗口的脉冲数离门限太近 —— 占空比再低一截就要误报了");
}
/* 真停转(PWM 满速 + 风扇一个脉冲也不吐)必须判得出来,而且要记一条事件。
写错了会怎样:风扇卡死/线脱落之后板子一声不吭地继续满载跑,
现象是「某台机器偶尔重启」,查到风扇上要花几天。 */
static void test_a_real_stall_is_caught_and_logged(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0); /* 把 BOOT 那条落掉,后面的序号就是本用例自己的 */
const uint32_t seq = next_seq();
thermal_snap_t s;
boot(&s);
/* 70 °C:满速(导通时间攒得最快,判据也最快),但**够不着降载门限** ——
本用例数的是日志条数,混进一条降载事件就分不清是谁记的了。 */
set_temp(70000);
run(&s, 2);
TEST_ASSERT_EQUAL_UINT16(1000, s.duty);
sim_fan_set_stalled(0, true);
sim_fan_set_stalled(1, true);
run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u);
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].stalled, "真停转没判出来");
TEST_ASSERT_TRUE_MESSAGE(s.fan[1].stalled, "真停转没判出来");
TEST_ASSERT_EQUAL_UINT32(1, s.fan[0].stalls);
/* 持续停着不会每个窗口再记一条 —— 256 条的 RAM 缓冲经不起每 3 s 一条,
真正救命的过压/反灌事件会被挤掉,而日志看上去「记录得非常完整」。 */
run(&s, 20);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.fan[0].stalls, "停转事件在刷屏");
TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0)); /* 两只风扇各一条 */
evlog_rec_t r[2];
memset(r, 0, sizeof(r));
TEST_ASSERT_EQUAL_UINT32(2, evlog_read(seq, r, 2));
TEST_ASSERT_EQUAL_UINT8(LOG_EV_FAN_STALL, r[0].type);
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, r[0].rail, "风扇是整板级事件,rail 应当是 0");
TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, r[0].arg, "第一条应当是进风扇(arg = 风扇号)");
TEST_ASSERT_EQUAL_UINT8(LOG_EV_FAN_STALL, r[1].type);
TEST_ASSERT_EQUAL_UINT8_MESSAGE(1, r[1].arg, "第二条应当是排风扇");
}
/* 两路 TACH 是**独立**的两套(Q9/Q10 各一路)。一只停了不能把另一只也报停 ——
报串了的现象是「两只一起坏」,于是维修先去查共用的 12V/PWM,而真相是某一只的
线掉了。这在真板上是最容易出现的单点故障(KF2510 插头)。 */
static void test_two_tach_channels_do_not_bleed_into_each_other(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
thermal_snap_t s;
boot(&s);
set_temp(70000); /* 满速但不降载,理由同上一个用例 */
run(&s, 2);
sim_fan_set_stalled(0, true); /* 只停进风那只 */
run(&s, T_THERMAL_TACH_ON_MS / 1000u + 2u);
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].stalled, "停掉的那只没报");
TEST_ASSERT_FALSE_MESSAGE(s.fan[1].stalled, "另一只被连坐了");
TEST_ASSERT_EQUAL_UINT32(1, s.fan[0].stalls);
TEST_ASSERT_EQUAL_UINT32(0, s.fan[1].stalls);
TEST_ASSERT_TRUE_MESSAGE(s.fan[1].rpm > 0, "还在转的那只转速被算成 0");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, evlog_flush(0), "事件条数不是 1");
}
/* 启动宽限:`FAN_EN` 刚拉高的头几秒风扇还在起转,这时候判停转就是**每次上电必报**。
报多了的代价不是噪声,是这条告警从此没人看 —— 等到真停转那天它照常发出来、
照常被划过去。
⚠️ 宽限时长必须盖得住「播种一圈 + 一个完整判据窗口」,否则第一个窗口在宽限
到期之后才结算,宽限判断成了死代码(见 `T_THERMAL_SPINUP_MS` 上的警告)。
本用例就是钉这件事的:把宽限判断整段删掉,它必须变红。 */
static void test_spinup_grace_suppresses_the_first_windows(void)
{
thermal_snap_t s;
TEST_ASSERT_EQUAL_INT(HAL_OK, t_thermal_init(&s, temp_src, tach_src, NULL));
set_temp(70000); /* 满速:窗口攒得最快,也就最容易在宽限内结算出一个假停转 */
/* 起转期间一个脉冲也没有(真风扇就是这样:12V 刚通,转子还没动)。 */
sim_fan_set_stalled(0, true);
sim_fan_set_stalled(1, true);
run(&s, T_THERMAL_SPINUP_MS / 1000u - 1u); /* 还在宽限内 */
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "启动宽限内就报了停转");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[1].stalls, "启动宽限内就报了停转");
sim_fan_set_stalled(0, false);
sim_fan_set_stalled(1, false);
run(&s, 10);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, s.fan[0].stalls, "起转完成后仍然报了停转");
TEST_ASSERT_FALSE(s.fan[0].stalled);
/* 宽限过后判据要**真的在工作**(不是被宽限永久关掉了)。 */
TEST_ASSERT_TRUE_MESSAGE(s.fan[0].windows > 0, "宽限之后判据没恢复");
}
/* ---- ④ 过温降载:记一条,并且只记一条 ----------------------------------- */
/* 温度在门限上抖是常态(±0.5 °C 的 ADC 噪声足够)。没有迟滞的现象是每秒一条
降载事件,4 分多钟就把 256 条的 RAM 环形缓冲刷满一圈,把过压/反灌那些真正
救命的事件挤掉 —— 而 `evlog` 只会在下一次落盘时留下一条 LOST,没人会注意。 */
static void test_derate_is_edge_triggered_with_hysteresis(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
const uint32_t seq = next_seq();
thermal_snap_t s;
boot(&s);
set_temp(T_THERMAL_DERATE_MC - 1000);
run(&s, 1);
TEST_ASSERT_FALSE(s.derate);
set_temp(T_THERMAL_DERATE_MC + 500);
run(&s, 1);
TEST_ASSERT_TRUE(s.derate);
TEST_ASSERT_EQUAL_UINT32(1, s.derates);
/* 在门限上抖 20 次:迟滞没做的话这里会变成 20 条事件。 */
for (int i = 0; i < 20; i++) {
set_temp(T_THERMAL_DERATE_MC + ((i % 2) ? 500 : -500));
run(&s, 1);
}
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, s.derates, "降载事件在刷屏(迟滞没做)");
/* 真凉下来才解除,再热起来才允许记第二条。 */
set_temp(T_THERMAL_DERATE_CLEAR_MC - 1000);
run(&s, 1);
TEST_ASSERT_FALSE(s.derate);
set_temp(T_THERMAL_DERATE_MC + 500);
run(&s, 1);
TEST_ASSERT_EQUAL_UINT32(2, s.derates);
TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0));
evlog_rec_t r[2];
memset(r, 0, sizeof(r));
TEST_ASSERT_EQUAL_UINT32(2, evlog_read(seq, r, 2));
TEST_ASSERT_EQUAL_UINT8(LOG_EV_THERMAL_DERATE, r[0].type);
/* `evlog.h` 对这个类型的约定:bus_mv 放温度 m°C。放错了日志里就是一个
看着完全合法、但意思全错的数(比如把它当成电压)。 */
TEST_ASSERT_EQUAL_INT32_MESSAGE(T_THERMAL_DERATE_MC + 500, r[0].bus_mv,
"降载事件里没记温度(evlog.h:bus_mv = 温度 m°C)");
}
/* `t_thermal` 一次也不许碰 flash:唯一碰 flash 的是 `t_log`(`evlog.h` 文件头)。
在 1 s 周期的任务里写 flash 的现象是**偶发的几十毫秒卡顿**,而它和高优先级的
`t_safety` 抢的是同一颗芯片的总线 —— 20 ms 的巡检节拍会跟着抖,查起来毫无线索。 */
static void test_thermal_never_touches_flash(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init());
evlog_flush(0);
const uint32_t writes = sim_store_writes();
thermal_snap_t s;
boot(&s);
set_temp(95000); /* 过温 ⇒ 记降载 */
run(&s, 2);
sim_fan_set_stalled(0, true);
run(&s, 10); /* 停转 ⇒ 再记事件 */
TEST_ASSERT_TRUE(s.derates > 0 && s.fan[0].stalls > 0);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(writes, sim_store_writes(), "t_thermal 自己落盘了");
}
/* ---- ⑤ 没 init 就 step ---------------------------------------------------- */
/* 启动顺序排错(A15 的降级矩阵)时,这个函数会在 init 失败之后照样被调到。
返回 HAL_ERR_STATE 而不是「拿一个全 0 的快照继续跑」—— 后者的现象是
风扇停在 0 %、温度报 0 °C、TACH 永远读不到,看上去一切正常。
⚠️ 这一条**必须排在第一个跑**:`s_inited` 是模块级状态,`sim_hal_reset()`
管不着它(那是假板子的开关,不是固件的),init 过一次就回不去了。 */
static void test_step_before_init_is_refused(void)
{
thermal_snap_t s;
memset(&s, 0, sizeof(s));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, t_thermal_step(&s));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, t_thermal_step(NULL));
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_step_before_init_is_refused); /* 必须第一个,见函数上的警告 */
RUN_TEST(test_init_drives_fan_en_high_and_fans_actually_spin);
RUN_TEST(test_hotter_never_means_slower);
RUN_TEST(test_duty_curve_is_monotonic_and_bounded);
RUN_TEST(test_unreadable_temperature_means_full_speed);
RUN_TEST(test_two_ntc_take_the_hotter_one);
RUN_TEST(test_low_duty_windows_are_not_mistaken_for_a_stall);
RUN_TEST(test_a_real_stall_is_caught_and_logged);
RUN_TEST(test_two_tach_channels_do_not_bleed_into_each_other);
RUN_TEST(test_spinup_grace_suppresses_the_first_windows);
RUN_TEST(test_derate_is_edge_triggered_with_hysteresis);
RUN_TEST(test_thermal_never_touches_flash);
return UNITY_END();
}
+329
查看文件
@@ -0,0 +1,329 @@
/* R-0:TCA9548A 驱动(底板 U35,0x70)。
验收主项:**选通道发出去的是一个裸字节,不是「寄存器号 + 值」。**
按后者写,通道会被选成「寄存器号」那个字节的值,而且从 HAL 到驱动到应用
一路全是 HAL_OK —— 板子上的现象是「读到的电压电流全都正常,就是不对号」。
第二主项:控制字是**掩码**不是编号(同时开两路 = 两片同址 INA226 一起应答,
读回来的是线与,数值正常还很稳),以及 `probe()` 要能把
「0x70 上蹲着别的东西」和「0x70 根本不应答」分成两种错误码。
⚠️ 本文件只测驱动,不挂 `MUX_RST#`(`rst_pin` 传 -1)——
驱动本来就不该碰 GPIO,复位那条路径是 `i2cbus` 的事,在 test_i2cbus.c 里测。 */
#include "tca9548a.h"
#include "hal_i2c.h"
#include "sim_hal.h"
#include "unity.h"
/* 主干上的输入侧 INA226(0x43)——「0x70 不与主干撞车」那条要有个真东西做对照。 */
#define TRUNK_INA 0x43
/* 刀片支路上的 INA226。五片同址,正是 D8 的设计。 */
#define BLADE_INA 0x40
void setUp(void) { sim_hal_reset(); }
/* 同一地址在当前可见范围里出现不止一次 = 通道开重了。
除了那条**故意**把通道开重的用例,全文件恒为 0。 */
void tearDown(void) {}
/* 假板子:一颗 mux + 主干一片 0x43 + 通道 0 / 通道 1 各一片 0x40。 */
static sim_i2c_dev_t *mount(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL));
sim_i2c_add_mux(TCA9548A_ADDR, -1);
sim_i2c_add(TRUNK_INA, 2);
sim_i2c_add_ch(0, BLADE_INA, 2);
sim_i2c_add_ch(1, BLADE_INA, 2);
sim_i2c_dev_t *mux = sim_i2c_dev(TCA9548A_ADDR);
TEST_ASSERT_NOT_NULL(mux);
return mux;
}
static int popcount8(unsigned v)
{
int n = 0;
while (v != 0u) {
n += (int)(v & 1u);
v >>= 1;
}
return n;
}
/* ---- 验收主项一:一个裸字节 ---------------------------------------------- */
/* ★ 这一条就是文件头第 1 条的全部内容:线上必须**只有一个字节**,
而且那个字节就是掩码本身。
写错了会怎样:见下面 test_the_reg_plus_value_spelling_is_silently_wrong。
为什么功能测试看不出来:写法错了以后照样「选到了某个通道」,
五片刀片的读数全都在量程内、也会随负载变化,只是对应错了槽位。 */
static void test_select_puts_exactly_one_byte_on_the_wire(void)
{
sim_i2c_dev_t *mux = mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 3));
TEST_ASSERT_EQUAL_UINT32(1, mux->n_write);
TEST_ASSERT_EQUAL_UINT8(1, mux->last_write_len); /* ★ 不是 2 */
TEST_ASSERT_EQUAL_HEX8(0x08, mux->last_write[0]); /* ★ 掩码本身,不是通道号 3 */
TEST_ASSERT_EQUAL_HEX8(0x08, sim_i2c_mux_mask());
TEST_ASSERT_EQUAL_INT(3, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* ★★ 反证:按「寄存器号 + 值」写会怎样。
这是最像笔误、也最容易在 code review 里被放过去的一种写法 ——
绝大多数 I²C 器件就是这么写的。
结果:器件只看第一个字节,于是「选通道 3」变成了掩码 0x03 = **通道 0 和 1 同时开**,
HAL 返回 HAL_OK,驱动返回 HAL_OK,上层拿到一个完全正常的读数。
真板上这时两片 0x40 一起应答,SDA 上是两片的线与:数值在量程内、还很稳定
—— 功能测试、甚至示波器看波形都发现不了。 */
static void test_the_reg_plus_value_spelling_is_silently_wrong(void)
{
mount();
const uint8_t wr[2] = { 0x03, 0x08 }; /* 「写寄存器 3,值 = 通道 3 的掩码」 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_write(TCA9548A_ADDR, wr, sizeof(wr))); /* ★ 不报错 */
TEST_ASSERT_EQUAL_HEX8(0x03, sim_i2c_mux_mask()); /* 想开 3,实际开了 0 和 1 */
TEST_ASSERT_EQUAL_INT(-2, sim_i2c_mux_ch()); /* 多于一路 */
/* 后果:0x40 这个地址现在同时看得见两片。 */
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA)); /* ★ 照样应答 */
TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_ambiguous());
/* 对照:驱动的写法一次只开一路,同样一次 probe 不会再增加 ambiguous。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 3));
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(BLADE_INA)); /* 通道 3 上什么都没有 */
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 1));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA));
TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_ambiguous()); /* 没有新增 */
}
/* ---- 验收主项二:掩码不是编号 -------------------------------------------- */
/* 八个通道逐个对:掩码必须是 1<<ch。
写成「发通道号」的实现在这里只有 ch=0 和 ch=1 侥幸对得上(0→0、1→1),
ch=2 开始就全错 —— 所以八个都要测,只测一个通道是测不出来的。 */
static void test_every_channel_is_one_bit_not_a_number(void)
{
sim_i2c_dev_t *mux = mount();
for (int ch = 0; ch < TCA9548A_CH_COUNT; ch++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, ch));
TEST_ASSERT_EQUAL_UINT8(1, mux->last_write_len);
TEST_ASSERT_EQUAL_HEX8((uint8_t)(1u << ch), sim_i2c_mux_mask());
TEST_ASSERT_EQUAL_INT(ch, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_INT(1, popcount8(sim_i2c_mux_mask())); /* ★ 永远只开一路 */
}
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* 全关 = 掩码 0:主干可见、所有支路断开。`i2cbus` 的「主干」就是这个状态。 */
static void test_none_closes_every_branch(void)
{
mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 0));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA));
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask());
TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch());
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(BLADE_INA)); /* 支路断了就是不应答 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TRUNK_INA)); /* 主干不受影响 */
}
/* 越界的通道号必须在**发出任何字节之前**被挡掉。
不挡的话 `1u << 8` 溢出成 0 = 悄悄全关,现象是「五路一起失联」而不是报错。 */
static void test_out_of_range_channel_sends_nothing(void)
{
sim_i2c_dev_t *mux = mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 2));
const uint32_t n = mux->n_write;
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_select(TCA9548A_ADDR, -2));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_COUNT));
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_select(TCA9548A_ADDR, 99));
TEST_ASSERT_EQUAL_UINT32(n, mux->n_write); /* 一个字节都没发 */
TEST_ASSERT_EQUAL_HEX8(0x04, sim_i2c_mux_mask()); /* 通道也没被动过 */
}
/* `mask_to_ch()` 的判据穷举:恰好一位 → 0~7;全 0 → NONE;**多于一位 → -2**。
-2 不是「另一种通道」,是「这块板上不该出现的状态」——
把它当成合法返回值去用(比如拿去做数组下标)会越界。 */
static void test_mask_to_ch_is_exhaustive(void)
{
for (int m = 0; m <= 0xFF; m++) {
const int got = tca9548a_mask_to_ch((uint8_t)m);
if (m == 0) {
TEST_ASSERT_EQUAL_INT(TCA9548A_CH_NONE, got);
} else if (popcount8((unsigned)m) > 1) {
TEST_ASSERT_EQUAL_INT(-2, got);
} else {
TEST_ASSERT_TRUE(got >= 0 && got < TCA9548A_CH_COUNT);
TEST_ASSERT_EQUAL_HEX8((uint8_t)m, (uint8_t)(1u << got));
}
}
/* 几个最容易被写错的具体值 */
TEST_ASSERT_EQUAL_INT(0, tca9548a_mask_to_ch(0x01));
TEST_ASSERT_EQUAL_INT(7, tca9548a_mask_to_ch(0x80));
TEST_ASSERT_EQUAL_INT(-2, tca9548a_mask_to_ch(0x03)); /* 就是上面那个笔误的结果 */
TEST_ASSERT_EQUAL_INT(-2, tca9548a_mask_to_ch(0xFF));
TEST_ASSERT_EQUAL_INT(TCA9548A_CH_NONE, tca9548a_mask_to_ch(0x00));
}
/* 选了就要能读回来 —— 读它同样是一个裸字节,不带寄存器指针。
这条是 `probe()` 的地基:写下去不报错但没生效(上拉太弱、支路被拉死)
只有靠读回来才认得出。 */
static void test_read_mask_reads_back_what_was_selected(void)
{
mount();
uint8_t m = 0xFF;
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 4));
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_read_mask(TCA9548A_ADDR, &m));
TEST_ASSERT_EQUAL_HEX8(0x10, m);
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE));
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_read_mask(TCA9548A_ADDR, &m));
TEST_ASSERT_EQUAL_HEX8(0x00, m);
TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, tca9548a_read_mask(TCA9548A_ADDR, NULL));
}
/* ---- 认件的三种结局 ------------------------------------------------------ */
/* 好器件:全关之后读回 0。 */
static void test_probe_accepts_a_fresh_mux(void)
{
mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_probe(TCA9548A_ADDR));
TEST_ASSERT_EQUAL_HEX8(0x00, sim_i2c_mux_mask()); /* probe 顺带把通道全关了 */
TEST_ASSERT_EQUAL_INT(-1, sim_i2c_mux_ch());
}
/* ★ 地址上应答了、但读回来不是 0 → HAL_ERR_BUS,**不是** NAK。
两者要分开:NAK 是「没插 / 焊虚 / 地址错」,BUS 是「0x70 上蹲着另一颗器件,
或者写根本没生效」。后者在真板上的现象是「所有刀片都读不到」,
而这里能在启动自检那一刻就叫出来。
⚠️ 这条用例里故意**不挂假 mux**(0x70 上换成一颗普通寄存器器件),
因为要模拟的正是「这不是一颗听话的 TCA9548A」。 */
static void test_probe_reports_bus_error_when_readback_is_not_zero(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(NULL));
sim_i2c_add(TCA9548A_ADDR, 1); /* 0x70 上的陌生人:会应答,但不听话 */
sim_i2c_set_reg(TCA9548A_ADDR, 0x00, 0x55);
TEST_ASSERT_EQUAL_INT(HAL_ERR_BUS, tca9548a_probe(TCA9548A_ADDR));
}
/* 器件不在(没焊、没插、地址错)→ NAK,而且是在**写**那一步就返回。 */
static void test_probe_naks_when_nobody_answers(void)
{
mount();
sim_i2c_set_present(TCA9548A_ADDR, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, tca9548a_probe(TCA9548A_ADDR));
}
/* 「全关」那一次写就 NAK 了,就不该再去读。
继续往下读的后果:读也 NAK,返回的错误码一样,但**总线上多了一次事务**;
更糟的是如果读侥幸成功(毛刺只打在写上),会拿一个陈旧的掩码判「认件通过」。
顺带钉住一件后面 i2cbus 要用到的事实:**失败的写不改变器件状态** ——
掩码还停在上一次选的那一路。固件这边却「以为」通道变了,
这正是 `i2cbus` 的通道影子必须在选通失败时作废的原因。 */
static void test_probe_stops_at_the_first_failure(void)
{
sim_i2c_dev_t *mux = mount();
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 2));
const uint32_t n_read = mux->n_read;
sim_i2c_inject_nak(TCA9548A_ADDR, 1); /* 只吃掉「全关」那一次写 */
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, tca9548a_probe(TCA9548A_ADDR));
TEST_ASSERT_EQUAL_UINT32(n_read, mux->n_read); /* 没有多发那次读 */
TEST_ASSERT_EQUAL_HEX8(0x04, sim_i2c_mux_mask()); /* 器件还停在通道 2 */
}
/* ---- 地址表 -------------------------------------------------------------- */
/* 0x70 不与**主干上**的 0x43 / 0x4A / 0x4B 撞。
撞上的后果:选通道那一个字节会被写进 DAC63204 的寄存器里 —— 输出电压乱跳。 */
static void test_addr_does_not_collide_with_the_trunk(void)
{
TEST_ASSERT_TRUE(tca9548a_addr_ok());
TEST_ASSERT_NOT_EQUAL(0x43, TCA9548A_ADDR); /* 输入侧 INA226 */
TEST_ASSERT_NOT_EQUAL(0x4A, TCA9548A_ADDR); /* DAC63204 #1 */
TEST_ASSERT_NOT_EQUAL(0x4B, TCA9548A_ADDR); /* DAC63204 #2 */
TEST_ASSERT_TRUE(TCA9548A_ADDR <= 0x7f);
}
/* ★ 为什么 0x40 / 0x20 **不在**那张冲突表里 —— 这不是漏了,这是 D8 的全部意义。
它们在 mux 的支路上:主干上根本看不见,所以五片刀片可以完全同构
(每片都是 0x40 + 0x20、零 strap 电阻、五片可互换)。
把它们加进冲突表的后果比漏掉更糟:`addr_ok()` 会在一块好板子上返回 false,
启动自检直接把整机拦下来。 */
static void test_branch_addresses_are_absent_from_the_table_on_purpose(void)
{
mount();
/* 全关:主干上只有 0x43 和 0x70,没有 0x40。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, TCA9548A_CH_NONE));
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, hal_i2c_probe(BLADE_INA));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TRUNK_INA));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TCA9548A_ADDR));
/* 选通道 0:0x40 出现,与主干的 0x43 / 0x70 同时可见、互不干扰。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 0));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TRUNK_INA));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(TCA9548A_ADDR));
/* 换到通道 1:还是那个 0x40,但已经是**另一片**了。 */
TEST_ASSERT_EQUAL_INT(HAL_OK, tca9548a_select(TCA9548A_ADDR, 1));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(BLADE_INA));
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous()); /* 全程没有两片一起应答 */
}
/* 通道→去哪的那张表:槽 1 在通道 0、槽 5 在通道 4、CH224A 在通道 5。
⚠️ 顺排是当下的事实不是不变量,所以业务代码要从 rail_cfg 查 ——
这里只钉住「这三个常量互不相同、且都在 0~7 内」。 */
static void test_channel_map_constants_are_sane(void)
{
TEST_ASSERT_TRUE(TCA9548A_CH_SLOT1 >= 0 && TCA9548A_CH_SLOT1 < TCA9548A_CH_COUNT);
TEST_ASSERT_TRUE(TCA9548A_CH_SLOT5 >= 0 && TCA9548A_CH_SLOT5 < TCA9548A_CH_COUNT);
TEST_ASSERT_TRUE(TCA9548A_CH_PD >= 0 && TCA9548A_CH_PD < TCA9548A_CH_COUNT);
TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_SLOT1, TCA9548A_CH_SLOT5);
TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_PD, TCA9548A_CH_SLOT1);
TEST_ASSERT_NOT_EQUAL(TCA9548A_CH_PD, TCA9548A_CH_SLOT5);
/* 五个槽正好占掉 SLOT1..SLOT5,PD 必须在这五个之外。 */
TEST_ASSERT_TRUE(TCA9548A_CH_PD < TCA9548A_CH_SLOT1 || TCA9548A_CH_PD > TCA9548A_CH_SLOT5);
TEST_ASSERT_EQUAL_INT(-1, TCA9548A_CH_NONE);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_select_puts_exactly_one_byte_on_the_wire);
RUN_TEST(test_the_reg_plus_value_spelling_is_silently_wrong);
RUN_TEST(test_every_channel_is_one_bit_not_a_number);
RUN_TEST(test_none_closes_every_branch);
RUN_TEST(test_out_of_range_channel_sends_nothing);
RUN_TEST(test_mask_to_ch_is_exhaustive);
RUN_TEST(test_read_mask_reads_back_what_was_selected);
RUN_TEST(test_probe_accepts_a_fresh_mux);
RUN_TEST(test_probe_reports_bus_error_when_readback_is_not_zero);
RUN_TEST(test_probe_naks_when_nobody_answers);
RUN_TEST(test_probe_stops_at_the_first_failure);
RUN_TEST(test_addr_does_not_collide_with_the_trunk);
RUN_TEST(test_branch_addresses_are_absent_from_the_table_on_purpose);
RUN_TEST(test_channel_map_constants_are_sane);
return UNITY_END();
}