文件
phonerail/firmware/test/test_t_safety.c
T
zhou yang和Claude Opus 5.5 b8ad69681f 固件: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]>
2026-09-27 10:26:24 +08:00

624 行
32 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* A10-R2 之一:`t_safety` **一圈的形状与账**(需求 §10.3、计划 §5.5)。
判据本身在 `test_t_safety_rules.c` 里,这里只管「这一圈发了什么、发给了谁」。
---- 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
/* 主干上那两片 DAC(`rail.h` 的 U6 / U7)。巡检一次也不该碰它们 ——
设定值是影子里的(`rail_setpoint_mv()`),去读 DAC 就是每圈多五次事务。 */
#define DAC_ADDR_A 0x4A
#define DAC_ADDR_B 0x4B
/* ⭐ 五路预置成**互不相同**的电压: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 = 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++) {
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_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)
{
sim_hal_reset();
board_up();
}
/* 每个用例收尾都查一次「有没有同时开两路通道」。真板上那是两片同地址的 INA226
一起应答、读回来是线与 —— 数值正常、还很稳,没有任何一层会报错。
所以这个数**必须恒为 0**,放在 tearDown 里等于给每一条用例都加上这条断言。 */
void tearDown(void)
{
TEST_ASSERT_EQUAL_UINT32(0, sim_i2c_ambiguous());
}
/* ---- 一圈的事务账(计划 §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)
{
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(&g_snap));
TEST_ASSERT_EQUAL_UINT64(t_safety_budget_us(), hal_time_now_us() - t0);
sched_periodic_wait(&tick);
/* ⚠️ 相位起点是**第一次醒来那一刻**,不是 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(&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;
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, g_snap.cycles);
TEST_ASSERT_EQUAL_UINT32(0, g_snap.skipped);
TEST_ASSERT_EQUAL_UINT32(0, g_snap.events);
}
/* 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)
{
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()); /* 任务表自检里钉着同一条 */
}
/* ---- 加锁纪律 ------------------------------------------------------------ */
/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。
R2 里这一圈要拿六次锁(五路 + 主干),每一次都得规规矩矩。 */
static void test_every_transaction_happens_under_the_i2c_lock(void)
{
sim_lock_require(HAL_LOCK_I2C, true);
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);
}
/* 上一条不是句空话:不拿锁直接读一片 INA226,违规计数必须 +1。
检查本身失灵的话,上一条会永远绿。 */
static void test_the_lock_check_itself_can_fail(void)
{
sim_lock_require(HAL_LOCK_I2C, true);
ina226_vi_t vi;
(void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi);
TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */
}
/* 拿不到锁:**跳过,不死等**(t_safety.h 规矩 1)。死等的现象是巡检整个停摆,
而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。
⚠️ 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)
{
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);
const uint32_t t0 = hal_time_now_ms();
TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&g_snap));
/* 这一圈**什么也没做**:一次事务没发、一根引脚没动、快照原封不动。 */
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:失联 ≠ 断电) ------------------------------ */
/* 一片刀片的 INA226 掉线,**剩下四路的读照常**。中断这一圈的现象是
「一片 INA226 掉线,五路判据全瞎」—— 而那四台手机什么毛病都没有。 */
static void test_one_dead_ina226_does_not_stop_the_other_four(void)
{
sim_i2c_set_present_ch(ch_of(3), rail_cfg(3)->ina_addr, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&g_snap));
TEST_ASSERT_FALSE(g_snap.rail_ok[2]);
for (int rail = 1; rail <= N; rail++) {
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]);
}
/* 坏掉那一路的 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 —— 不清零。
清零的话,判据会看到一个 0 V 的「正常数」;而 `ok` 正是让它先看一眼
「这数是不是新的」。拿一个没更新的电压去判过压是最难查的那种错。 */
static void test_a_failed_read_keeps_the_stale_value_and_flags_it(void)
{
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_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);
}
/* 同一路上的两颗器件互不牵连:PCA9555 读不到,不影响这一路 INA226 已经读到的值。
`cycle_rail()` 里「器件读失败不提前返回」那一条就是这个意思 ——
提前返回的现象是「一片 I/O 扩展器失联,这一路的过压判据也跟着停了」。 */
static void test_a_dead_pca9555_does_not_spoil_the_same_rails_ina226(void)
{
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);
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(&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(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);
}
}
/* 第一圈的占用里不该混进启动期间的事务(游标要在 `t_safety_init()` 时对齐到「现在」)。
混进去的现象是「开机那一下占用率 40 %」,然后再也不复现。 */
static void test_startup_traffic_is_not_charged_to_the_first_cycle(void)
{
/* 假装启动时又扫了一遍总线。 */
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);
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_one_cycle_is_exactly_the_budgeted_transaction_count);
RUN_TEST(test_measured_bus_time_equals_the_static_budget);
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_ina226_does_not_stop_the_other_four);
RUN_TEST(test_a_failed_read_keeps_the_stale_value_and_flags_it);
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();
}