A8:任务骨架与调度(5 个任务、两把锁)

需求 §10.3 落地。四件事:

1. 五任务表(tasks.c):周期与优先级写在一张 const 表里,开机自检查的是
   相对顺序(safety 唯一最高、log 唯一最低、thermal 与 actuator 同级),
   外加「巡检周期必须长于 INA226 一帧转换 8.8 ms」。不过就 abort。

2. 节拍器(sched.c):记下一次该醒的绝对时刻,回绕安全(int32_t 差判先后)。
   过载时跳过错过的整周期、保住相位,不补跑。

3. 两把锁(hal_lock):I²C 与 74HC595,禁止嵌套持有 —— 这块板上两者没有交集,
   「拿着 A 去拿 B」永远是写错了。ESP 侧用 mutex 取优先级继承。

4. t_safety 的一个周期:拿锁 → 6 片 INA226 的 V+I + PCA9555 两端口 → 放锁。
   不死等锁(5 ms 超时就跳过这圈),一片读失败不中断整圈、保留上一圈的值。

I²C 占用由 HAL 按线上真实字节数记账(两个目标同一份代码),静态预算与实测
必须相等:一次寄存器读 48 位,一圈 13 次事务 = 1560 µs / 20 ms = 7.8 %,
与 §10.3 手算的「约 1.5 ms / 约 8 %」相符(本节的 46 位没数 STOP 和重复 START)。

sim 侧配套两样:假 I²C 事务真的把虚拟时钟花掉(否则时序测试是空话),
以及可强制的加锁纪律检查(配一条反向测试,防止检查本身失灵)。

11 条 ctest(161 例)全绿,idf.py build 通过;16 个注错实验全部变红。

Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-07 07:35:39 +08:00
共同撰写人 Claude Opus 5
父节点 45de84ed3c
当前提交 ec31891429
共修改 26 个文件,包含 3340 行新增和 1733 行删除
+320
查看文件
@@ -0,0 +1,320 @@
/* A8 之二:`t_safety` 的一个周期,以及需求 §10.3 的两条验收判据 ——
① sim 时钟下周期是 20 ms ±1 ms;
② I²C 占用打印出来,与 §10.3 手算的「约 1.5 ms / 约 8 %」对得上。
⚠️ 这两条能成立,靠的是 sim 的假 I²C **真的把虚拟时钟花掉**
(`sim_i2c_set_bus_time`,默认开)。没有那一条,巡检在 sim 里耗时为零,
「20 ms 周期」就退化成「测 delay 参数传对了没」—— 那是句空话:
真正会出事的是「巡检自己占掉了 1.5 ms,节拍还准不准」。 */
#include <stdio.h>
#include <string.h>
#include "hal_i2c.h"
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "ina226.h"
#include "pca9555.h"
#include "rail_cfg.h"
#include "sched.h"
#include "sim_hal.h"
#include "t_safety.h"
#include "tasks.h"
#include "unity.h"
#define N PHONERAIL_RAIL_COUNT
/* 一圈发出去的事务数:6 片 INA226 各 2 次寄存器读 + PCA9555 一次。 */
#define TXNS_PER_CYCLE (INA226_COUNT * 2 + 1)
/* 把假板子搭起来:6 片 INA226 + 1 片 PCA9555,地址全部**查表**取。 */
static void board_up(void)
{
const hal_i2c_cfg_t cfg = { .sda_gpio = 14, .scl_gpio = 15, .freq_hz = 400000 };
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_init(&cfg));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
for (int rail = 1; rail <= N; rail++) {
sim_i2c_dev_t *d = sim_i2c_add(rail_cfg(rail)->ina_addr, 2);
d->regs[INA226_REG_BUS_V] = 3120; /* 3.9 V ÷ 1.25 mV */
d->regs[INA226_REG_CURRENT] = (uint16_t)(rail * 1000); /* 每路不同,好认 */
}
sim_i2c_add(INA226_ADDR_INPUT, 2);
sim_pca9555_attach(PCA9555_ADDR);
}
void setUp(void)
{
sim_hal_reset();
board_up();
}
void tearDown(void) {}
/* ---- 验收主项一:20 ms 的节拍 -------------------------------------------- */
/* 500 圈。每一圈都要**正好**落在 20 ms 的格子上,而且真实唤醒时刻
(µs 级)对理想时刻的偏差始终 < 1 ms —— 这就是「20 ms ±1 ms」。
会漂的实现(干完睡 20)在这里第一圈就出局:那样周期是 21.56 ms,
500 圈之后差出 780 ms。 */
static void test_safety_holds_a_20ms_beat_for_500_cycles(void)
{
safety_snap_t snap;
t_safety_init(&snap);
sched_periodic_t tick;
sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms);
/* 先确认「干活」在 sim 里真的花掉了时间。不然下面 500 圈测的只是
「delay 参数传对了没」—— 巡检耗时为零的话,什么实现都能踩准 20 ms。 */
const uint64_t t0 = hal_time_now_us();
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT64(t_safety_budget_us(), hal_time_now_us() - t0);
sched_periodic_wait(&tick);
uint32_t prev_ms = hal_time_now_ms();
for (int i = 1; i < 500; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
sched_periodic_wait(&tick);
const uint32_t now_ms = hal_time_now_ms();
TEST_ASSERT_EQUAL_UINT32(20, now_ms - prev_ms);
prev_ms = now_ms;
/* 理想时刻是 (i+1)×20 ms;实际唤醒的 µs 与它相差不到一个毫秒格。 */
const int64_t ideal_us = (int64_t)(i + 1) * 20000;
const int64_t err_us = (int64_t)hal_time_now_us() - ideal_us;
TEST_ASSERT_TRUE(err_us > -1000 && err_us < 1000);
}
TEST_ASSERT_EQUAL_UINT32(0, tick.overruns);
TEST_ASSERT_EQUAL_UINT32(500, snap.cycles);
TEST_ASSERT_EQUAL_UINT32(0, snap.skipped);
}
/* ---- 验收主项二:I²C 占用 ------------------------------------------------ */
/* §10.3 手算:一次寄存器读 ≈46 个位周期,12 次 INA + 1 次 PCA ≈1.5 ms / 20 ms ≈8 %。
这里按线上真实发生的字节数算,得 48 位/次 × 13 = 624 位 = 1560 µs = 7.8 %。
差的两位是 STOP 和一个重复 START,§10.3 没数进去(hal_i2c_acct.h 里记了这件事)。 */
static void test_i2c_load_per_cycle_matches_section_10_3(void)
{
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(48, hal_i2c_txn_bits(1, 2));
TEST_ASSERT_EQUAL_UINT32(1560, snap.bus_us);
const uint32_t pm = t_safety_load_permille(snap.bus_us,
task_desc(TASK_SAFETY)->period_ms);
TEST_ASSERT_EQUAL_UINT32(78, pm);
/* §10.3 的「约 8 %」:7.0 %~8.5 % 都算对得上,超出就是巡检里多读/少读了东西。 */
TEST_ASSERT_TRUE(pm >= 70 && pm <= 85);
char line[128];
const int n = t_safety_format(&snap, line, sizeof(line));
TEST_ASSERT_TRUE(n > 0 && n < (int)sizeof(line));
TEST_ASSERT_NOT_NULL(strstr(line, "bus=1560 us/20 ms"));
TEST_ASSERT_NOT_NULL(strstr(line, "load=7.8%"));
printf(" %s(§10.3 手算:约 1.5 ms / 约 8 %%)\n", line);
}
/* 静态预算与实测必须一模一样。不一样只有两种可能:巡检里多读/少读了东西,
或者别的任务在同一圈里插了事务进来 —— 两种都该被这条拦住。
(这也是为什么预算函数不是一个写死的常数:它按同一个算式从事务形状来。) */
static void test_measured_bus_time_equals_the_static_budget(void)
{
safety_snap_t snap;
t_safety_init(&snap);
for (int i = 0; i < 10; i++) {
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), snap.bus_us);
}
}
/* 一圈正好 13 次事务,而且每片器件被读的次数是钉死的 ——
多读一次寄存器(比如顺手 check 一下 Calibration)会当场变红,
然后写的人才会想起来去把预算那一行也改了。 */
static void test_one_cycle_is_exactly_thirteen_transactions(void)
{
safety_snap_t snap;
t_safety_init(&snap);
const uint32_t before = hal_i2c_acct_txns();
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(TXNS_PER_CYCLE, hal_i2c_acct_txns() - before);
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(rail_cfg(rail)->ina_addr)->n_read);
}
TEST_ASSERT_EQUAL_UINT32(2, sim_i2c_dev(INA226_ADDR_INPUT)->n_read);
TEST_ASSERT_EQUAL_UINT32(1, sim_i2c_dev(PCA9555_ADDR)->n_read);
}
/* 五路读的是**表里的**地址。挂一片在「0x40+ch 算出来」的地址上,
它一次也不该被碰到 —— 这是 A7 那条禁令在巡检路径上的回归拦网。 */
static void test_rails_are_read_by_table_not_by_formula(void)
{
sim_i2c_dev_t *wrong = sim_i2c_add(0x46, 2); /* 谁都不是的地址 */
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(0, wrong->n_read);
TEST_ASSERT_EQUAL_UINT32(0, wrong->n_write);
/* 顺带确认值确实按路对上了(每路电流不同)。 */
for (int rail = 1; rail <= N; rail++) {
TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]);
TEST_ASSERT_EQUAL_INT32(rail * 1000 * INA226_CURRENT_LSB_UA,
snap.rail[rail - 1].current_ua);
}
}
/* ---- 加锁纪律 ------------------------------------------------------------- */
/* 一整圈里的每一次事务都持着 I²C 锁;出了这个函数锁必须已经放掉。 */
static void test_every_transaction_happens_under_the_i2c_lock(void)
{
sim_lock_require(HAL_LOCK_I2C, true);
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(0, sim_lock_violations(HAL_LOCK_I2C));
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C)); /* 出门放锁 */
TEST_ASSERT_EQUAL_INT(-1, sim_lock_owner(HAL_LOCK_I2C));
}
/* 上一条不是句空话:不拿锁直接读一片,违规计数必须 +1。
(检查本身失灵的话,上一条会永远绿。) */
static void test_the_lock_check_itself_can_fail(void)
{
sim_lock_require(HAL_LOCK_I2C, true);
ina226_vi_t vi;
(void)ina226_read_vi(rail_cfg(1)->ina_addr, &vi);
TEST_ASSERT_EQUAL_UINT32(2, sim_lock_violations(HAL_LOCK_I2C)); /* 两次事务 */
}
/* 拿不到锁:**跳过这一圈**,不死等(t_safety.h 规矩 1)。
等掉的是 5 ms,还在 20 ms 之内,所以节拍不丢。 */
static void test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever(void)
{
sim_lock_set_task(1); /* 假装 t_link 正在用总线 */
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
sim_lock_set_task(0);
safety_snap_t snap;
t_safety_init(&snap);
const uint32_t t0 = hal_time_now_ms();
const uint32_t txns = hal_i2c_acct_txns();
TEST_ASSERT_EQUAL_INT(HAL_ERR_TIMEOUT, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(1, snap.skipped);
TEST_ASSERT_EQUAL_UINT32(1, snap.cycles);
TEST_ASSERT_EQUAL_UINT32(0, hal_i2c_acct_txns() - txns); /* 一次事务也没发 */
TEST_ASSERT_EQUAL_UINT32(T_SAFETY_LOCK_TIMEOUT_MS, hal_time_elapsed_ms(t0));
TEST_ASSERT_TRUE(T_SAFETY_LOCK_TIMEOUT_MS < task_desc(TASK_SAFETY)->period_ms);
}
/* ---- 器件出问题的时候(§10.2:失联 ≠ 断电) ------------------------------ */
/* 六片里坏一片,剩下五片照读、PCA 照读。中断这一圈的话,
现象是「一片 INA226 掉线,五路判据全瞎」。 */
static void test_one_dead_device_does_not_stop_the_round(void)
{
sim_i2c_set_present(rail_cfg(3)->ina_addr, false);
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&snap));
TEST_ASSERT_FALSE(snap.rail_ok[2]);
for (int rail = 1; rail <= N; rail++) {
if (rail != 3) {
TEST_ASSERT_TRUE(snap.rail_ok[rail - 1]);
}
}
TEST_ASSERT_TRUE(snap.input_ok);
TEST_ASSERT_TRUE(snap.io_ok);
TEST_ASSERT_EQUAL_UINT32(1, snap.i2c_errors);
}
/* 读失败时保留**上一圈的值**并把 ok 标成 false —— 不清零。
清零的话,A10 的过压判据会看到一个 0 V 的「正常」电压,
而 ok 标志正是让它先看一眼「这数是不是新的」。 */
static void test_a_failed_read_keeps_the_stale_value_and_flags_it(void)
{
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
const int32_t good_mv = snap.rail[0].bus_mv;
TEST_ASSERT_EQUAL_INT32(3900, good_mv);
sim_i2c_set_present(rail_cfg(1)->ina_addr, false);
TEST_ASSERT_EQUAL_INT(HAL_ERR_NAK, t_safety_step(&snap));
TEST_ASSERT_FALSE(snap.rail_ok[0]);
TEST_ASSERT_EQUAL_INT32(good_mv, snap.rail[0].bus_mv); /* 还是上一圈那个 */
}
/* 器件掉线之后节拍照样是 20 ms(NAK 的事务也占总线时间,但占得更少)。
⚠️ 这条测的是「巡检不会因为总线出错就乱了节奏」。 */
static void test_the_beat_survives_a_dead_device(void)
{
sim_i2c_set_present(rail_cfg(2)->ina_addr, false);
safety_snap_t snap;
t_safety_init(&snap);
sched_periodic_t tick;
sched_periodic_init(&tick, 20);
uint32_t prev = hal_time_now_ms();
for (int i = 0; i < 50; i++) {
(void)t_safety_step(&snap);
sched_periodic_wait(&tick);
TEST_ASSERT_EQUAL_UINT32(20, hal_time_now_ms() - prev);
prev = hal_time_now_ms();
}
TEST_ASSERT_EQUAL_UINT32(0, tick.overruns);
TEST_ASSERT_EQUAL_UINT32(50, snap.i2c_errors);
}
/* PCA9555 的输入被读进来了,而且语义判读能用(A10 要拿它做判据)。 */
static void test_pca9555_inputs_land_in_the_snapshot(void)
{
sim_pca9555_drive(0, 0xFF & ~(1u << pca9555_flt_bit(4))); /* CH4 的 FLT# 拉低 */
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_TRUE(snap.io_ok);
TEST_ASSERT_TRUE(pca9555_rail_fault(&snap.io, 4));
TEST_ASSERT_FALSE(pca9555_rail_fault(&snap.io, 1));
}
/* 第一圈的占用里不该混进启动期间的事务(游标要在 init 时对齐到「现在」)。 */
static void test_startup_traffic_is_not_charged_to_the_first_cycle(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_i2c_probe(PCA9555_ADDR)); /* 假装启动扫了一下 */
safety_snap_t snap;
t_safety_init(&snap);
TEST_ASSERT_EQUAL_INT(HAL_OK, t_safety_step(&snap));
TEST_ASSERT_EQUAL_UINT32(t_safety_budget_us(), snap.bus_us);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_safety_holds_a_20ms_beat_for_500_cycles);
RUN_TEST(test_i2c_load_per_cycle_matches_section_10_3);
RUN_TEST(test_measured_bus_time_equals_the_static_budget);
RUN_TEST(test_one_cycle_is_exactly_thirteen_transactions);
RUN_TEST(test_rails_are_read_by_table_not_by_formula);
RUN_TEST(test_every_transaction_happens_under_the_i2c_lock);
RUN_TEST(test_the_lock_check_itself_can_fail);
RUN_TEST(test_a_busy_bus_skips_the_cycle_instead_of_waiting_forever);
RUN_TEST(test_one_dead_device_does_not_stop_the_round);
RUN_TEST(test_a_failed_read_keeps_the_stale_value_and_flags_it);
RUN_TEST(test_the_beat_survives_a_dead_device);
RUN_TEST(test_pca9555_inputs_land_in_the_snapshot);
RUN_TEST(test_startup_traffic_is_not_charged_to_the_first_cycle);
return UNITY_END();
}