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 行删除
+186 -156
查看文件
@@ -1,156 +1,186 @@
/* 假板子的控制面 —— 只有宿主机测试与 sim/ 自己会包含它,应用代码一律不许包含。
(包含了就编不过:ESP 目标根本不编译 sim/。)
⚠️ 模拟器是用来证明**代码逻辑**对的,不是用来证明**设计**对的。
电压、余量、时序裕量的结论全部来自设计文档的计算与首板实测。见计划 §4.2。 */
#ifndef SIM_HAL_H
#define SIM_HAL_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_gpio.h"
#include "hal_i2c.h"
#include "hal_pwm.h"
#include "hal_time.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 四路 sim 全部恢复出厂:器件表清空、引脚未配置、PWM 未配置、时钟归零、
init 标志清掉。每个测试的 setUp() 里调它。 */
void sim_hal_reset(void);
/* ---------------- I²C ---------------- */
#define SIM_I2C_MAX_DEV 16
#define SIM_I2C_REGS 256
#define SIM_I2C_LOG_BYTES 8
/* 假器件的窃听事件。通用寄存器表模拟不了「配成输出就对地短路」这种事,
所以按 datasheet 建模的假器件(sim_pca9555)挂一个钩子进来。 */
typedef enum {
SIM_I2C_EV_POST_WRITE = 0, /* 一次写事务刚落进寄存器表 */
SIM_I2C_EV_PRE_READ, /* 一次读事务即将取寄存器表 —— 在这里合成输入 */
} sim_i2c_ev_t;
struct sim_i2c_dev;
typedef void (*sim_i2c_hook_t)(struct sim_i2c_dev *d, sim_i2c_ev_t ev);
typedef struct sim_i2c_dev {
uint8_t addr;
uint8_t reg_width; /* 1 或 2 字节。2 字节按**大端**——INA226 / DAC63204 / LM75 都是 */
bool present; /* false = 器件失联(A15 降级矩阵用),事务一律 NAK */
int nak_left; /* >0:接下来 n 次事务 NAK 后自动恢复;<0:一直 NAK;0:正常 */
uint16_t regs[SIM_I2C_REGS];
uint8_t ptr; /* 器件内部的寄存器指针,跨事务保持(真器件就是这个行为) */
uint32_t n_write; /* 事务计数,可用来断言「驱动真的访问过它 / 没多访问」 */
uint32_t n_read;
uint8_t last_write[SIM_I2C_LOG_BYTES]; /* 最后一次写进去的原始字节 */
uint8_t last_write_len;
sim_i2c_hook_t hook; /* NULL = 就是一张寄存器表 */
} sim_i2c_dev_t;
/* 往假总线上挂一个器件。地址重复或超出 16 个会 abort(测试写错了,不该悄悄过)。 */
sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width);
sim_i2c_dev_t *sim_i2c_dev(uint8_t addr); /* 没挂就返回 NULL */
/* 预置 / 回读寄存器。地址没挂过会 abort。 */
void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val);
uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg);
/* 故障注入。count>0:接下来 count 次事务 NAK;count<0:一直 NAK;0:清除。 */
void sim_i2c_inject_nak(uint8_t addr, int count);
void sim_i2c_set_present(uint8_t addr, bool present);
/* ---------------- GPIO ---------------- */
/* ESP32-S3 的 GPIO 编号 **0~48**,所以数组要 49 个 —— `EFUSE_EN2` 就在 IO48 上。
A7 之前这里是 48,于是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()`
直接 HAL_ERR_ARG)。边界写成「个数」而不是「上限」,就是这么错的。 */
#define SIM_GPIO_PINS 49
/* 外部世界把某个**输入**脚拉成 level。对输出脚调用会 abort(那是总线打架)。 */
void sim_gpio_drive(int pin, int level);
/* 该脚当前电平:输出脚 = 固件驱动的值,输入脚 = 外部驱动的值。没配过返回 -1。 */
int sim_gpio_level(int pin);
/* 被 hal_gpio_write() 写成功过几次。断言「改 CH1 没动 CH2~5」用得上(A5)。 */
uint32_t sim_gpio_writes(int pin);
/* 全局递增的写序号:每成功写一个脚 +1,本函数返回**该脚最后一次**被写时的序号
(从没被写过返回 0)。用来断言顺序 —— 「先断 VBUS 再关 eFuse」(A7)、
「17 ms 上电时序」(A9)这类事,光看每个脚的终值和次数是看不出来的。 */
uint32_t sim_gpio_write_seq(int pin);
/* ---------------- 74HC595 ×3 的 24 位链 ---------------- */
/* 按 datasheet 建模的假移位链,**只看线上真实发生的边**(SRCLK / RCLK 上升沿)。
对着驱动自己的影子变量断言等于自己证明自己 —— 字节顺序发反、每帧多打一个时钟、
RCLK 打早了,影子变量一律看不出来。位号与 sr595.h 一致:0 = U21.QA,23 = U33.QH。
attach 之后移位寄存器与输出锁存器被塞成 0xA5A5A5:真板子上电时它们的内容
**不确定**(`MR#` 接死在 +3V3,没有复位线),驱动必须先锁存安全值再使能 OE#。 */
void sim_sr595_attach(int ser_pin, int srclk_pin, int rclk_pin, int oe_pin);
uint32_t sim_sr595_outputs(void); /* 输出锁存器(不管 OE# 是高是低) */
uint32_t sim_sr595_shift(void); /* 移位寄存器当前内容(还没锁存的那一份) */
uint32_t sim_sr595_clocks(void); /* SRCLK 上升沿总数:一帧应当正好 +24 */
uint32_t sim_sr595_latches(void); /* RCLK 上升沿总数:一帧应当正好 +1 */
int sim_sr595_oe(void); /* OE# 当前电平。初值 1 —— 板上 R609 拉着 */
/* OE# 第一次被拉低那一刻的现场。断言「输出使能之前已经锁存过安全值」用:
驱动要是把顺序换了,enable_latches 会是 0、enable_outputs 会是那坨 0xA5A5A5。 */
bool sim_sr595_enabled(void);
uint32_t sim_sr595_enable_latches(void);
uint32_t sim_sr595_enable_outputs(void);
/* 某一位在栅极上的实际电平;OE# 为高时返回 -1(高阻,不是 0)。 */
int sim_sr595_pin(int bit);
/* ---------------- PCA9555(按 datasheet 建模的假器件) ---------------- */
/* 通用寄存器表证明不了 A6 的核心判据。这个模型只多做三件真器件会做的事:
① **Output Port 复位值 0xFF** —— 所以把一个脚配成输出,它下一刻就输出高;
② 输入寄存器按位合成:配成输入的位读**外部电平**,配成输出的位读**自己的输出**;
③ 四个备用脚 P0_7 / P1_5~7 **焊死在地上** —— 读永远是 0,被驱动成高就是短路。
③ 是这块板子上「配错方向」唯一会留下痕迹的地方:真板子上它既不报错也不 NAK。 */
void sim_pca9555_attach(uint8_t addr);
/* 外部世界把某个端口的 8 根线驱动成 level(接地脚不受影响,恒 0)。
FLT#/PGOOD 都是开漏上拉,所以「什么都没发生」的静息值是 0xFF。 */
void sim_pca9555_drive(int port, uint8_t level);
uint8_t sim_pca9555_config(int port); /* 方向寄存器现值,1 = 输入 */
uint8_t sim_pca9555_output(int port); /* 输出锁存器现值(复位 0xFF) */
uint8_t sim_pca9555_polinv(int port);
/* 接地脚被配成输出过几次(不管输出高低,配了就算 —— 那已经是在对地灌电流)。 */
uint32_t sim_pca9555_gnd_driven(void);
/* 其中真的输出了高电平、也就是**直接对地短路**过几次。 */
uint32_t sim_pca9555_shorted(void);
/* INT# 现在是不是被拉低(低有效)。输入变化拉低,**读走输入端口才释放**。 */
bool sim_pca9555_int_asserted(void);
uint32_t sim_pca9555_int_asserts(void); /* 累计拉低次数 */
/* ---------------- PWM ---------------- */
int sim_pwm_pin(int ch); /* 未配置返回 -1 */
uint32_t sim_pwm_freq(int ch); /* 未配置返回 0 */
int sim_pwm_permille(int ch); /* 未配置返回 -1 */
/* ---------------- 时钟 ---------------- */
void sim_time_set_us(uint64_t us);
void sim_time_advance_us(uint64_t us);
void sim_time_advance_ms(uint32_t ms);
uint32_t sim_time_delay_calls(void); /* hal_time_delay_ms() 被调了几次 */
#ifdef __cplusplus
}
#endif
#endif /* SIM_HAL_H */
/* 假板子的控制面 —— 只有宿主机测试与 sim/ 自己会包含它,应用代码一律不许包含。
(包含了就编不过:ESP 目标根本不编译 sim/。)
⚠️ 模拟器是用来证明**代码逻辑**对的,不是用来证明**设计**对的。
电压、余量、时序裕量的结论全部来自设计文档的计算与首板实测。见计划 §4.2。 */
#ifndef SIM_HAL_H
#define SIM_HAL_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_gpio.h"
#include "hal_i2c.h"
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "hal_pwm.h"
#include "hal_time.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 四路 sim 全部恢复出厂:器件表清空、引脚未配置、PWM 未配置、时钟归零、
init 标志清掉。每个测试的 setUp() 里调它。 */
void sim_hal_reset(void);
/* ---------------- I²C ---------------- */
#define SIM_I2C_MAX_DEV 16
#define SIM_I2C_REGS 256
#define SIM_I2C_LOG_BYTES 8
/* 假器件的窃听事件。通用寄存器表模拟不了「配成输出就对地短路」这种事,
所以按 datasheet 建模的假器件(sim_pca9555)挂一个钩子进来。 */
typedef enum {
SIM_I2C_EV_POST_WRITE = 0, /* 一次写事务刚落进寄存器表 */
SIM_I2C_EV_PRE_READ, /* 一次读事务即将取寄存器表 —— 在这里合成输入 */
} sim_i2c_ev_t;
struct sim_i2c_dev;
typedef void (*sim_i2c_hook_t)(struct sim_i2c_dev *d, sim_i2c_ev_t ev);
typedef struct sim_i2c_dev {
uint8_t addr;
uint8_t reg_width; /* 1 或 2 字节。2 字节按**大端**——INA226 / DAC63204 / LM75 都是 */
bool present; /* false = 器件失联(A15 降级矩阵用),事务一律 NAK */
int nak_left; /* >0:接下来 n 次事务 NAK 后自动恢复;<0:一直 NAK;0:正常 */
uint16_t regs[SIM_I2C_REGS];
uint8_t ptr; /* 器件内部的寄存器指针,跨事务保持(真器件就是这个行为) */
uint32_t n_write; /* 事务计数,可用来断言「驱动真的访问过它 / 没多访问」 */
uint32_t n_read;
uint8_t last_write[SIM_I2C_LOG_BYTES]; /* 最后一次写进去的原始字节 */
uint8_t last_write_len;
sim_i2c_hook_t hook; /* NULL = 就是一张寄存器表 */
} sim_i2c_dev_t;
/* 往假总线上挂一个器件。地址重复或超出 16 个会 abort(测试写错了,不该悄悄过)。 */
sim_i2c_dev_t *sim_i2c_add(uint8_t addr, uint8_t reg_width);
sim_i2c_dev_t *sim_i2c_dev(uint8_t addr); /* 没挂就返回 NULL */
/* 预置 / 回读寄存器。地址没挂过会 abort。 */
void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val);
uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg);
/* 故障注入。count>0:接下来 count 次事务 NAK;count<0:一直 NAK;0:清除。 */
void sim_i2c_inject_nak(uint8_t addr, int count);
void sim_i2c_set_present(uint8_t addr, bool present);
/* **假总线上的事务是要花时间的**(默认开):每次事务把虚拟时钟往前拨
`hal_i2c_txn_bits() / 频率` 那么多微秒。没有这一条,t_safety 的一个周期在 sim 里
耗时为零,「20 ms 周期」的测试就只是在测「delay 传了 20」——
而真正会出事的是「巡检自己就占掉了 1.5 ms、周期还准不准」。
关掉它只有一个正当理由:某个测试要拿虚拟时钟当纯计数器用。 */
void sim_i2c_set_bus_time(bool on);
/* ---------------- GPIO ---------------- */
/* ESP32-S3 的 GPIO 编号 **0~48**,所以数组要 49 个 —— `EFUSE_EN2` 就在 IO48 上。
A7 之前这里是 48,于是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()`
直接 HAL_ERR_ARG)。边界写成「个数」而不是「上限」,就是这么错的。 */
#define SIM_GPIO_PINS 49
/* 外部世界把某个**输入**脚拉成 level。对输出脚调用会 abort(那是总线打架)。 */
void sim_gpio_drive(int pin, int level);
/* 该脚当前电平:输出脚 = 固件驱动的值,输入脚 = 外部驱动的值。没配过返回 -1。 */
int sim_gpio_level(int pin);
/* 被 hal_gpio_write() 写成功过几次。断言「改 CH1 没动 CH2~5」用得上(A5)。 */
uint32_t sim_gpio_writes(int pin);
/* 全局递增的写序号:每成功写一个脚 +1,本函数返回**该脚最后一次**被写时的序号
(从没被写过返回 0)。用来断言顺序 —— 「先断 VBUS 再关 eFuse」(A7)、
「17 ms 上电时序」(A9)这类事,光看每个脚的终值和次数是看不出来的。 */
uint32_t sim_gpio_write_seq(int pin);
/* ---------------- 74HC595 ×3 的 24 位链 ---------------- */
/* 按 datasheet 建模的假移位链,**只看线上真实发生的边**(SRCLK / RCLK 上升沿)。
对着驱动自己的影子变量断言等于自己证明自己 —— 字节顺序发反、每帧多打一个时钟、
RCLK 打早了,影子变量一律看不出来。位号与 sr595.h 一致:0 = U21.QA,23 = U33.QH。
attach 之后移位寄存器与输出锁存器被塞成 0xA5A5A5:真板子上电时它们的内容
**不确定**(`MR#` 接死在 +3V3,没有复位线),驱动必须先锁存安全值再使能 OE#。 */
void sim_sr595_attach(int ser_pin, int srclk_pin, int rclk_pin, int oe_pin);
uint32_t sim_sr595_outputs(void); /* 输出锁存器(不管 OE# 是高是低) */
uint32_t sim_sr595_shift(void); /* 移位寄存器当前内容(还没锁存的那一份) */
uint32_t sim_sr595_clocks(void); /* SRCLK 上升沿总数:一帧应当正好 +24 */
uint32_t sim_sr595_latches(void); /* RCLK 上升沿总数:一帧应当正好 +1 */
int sim_sr595_oe(void); /* OE# 当前电平。初值 1 —— 板上 R609 拉着 */
/* OE# 第一次被拉低那一刻的现场。断言「输出使能之前已经锁存过安全值」用:
驱动要是把顺序换了,enable_latches 会是 0、enable_outputs 会是那坨 0xA5A5A5。 */
bool sim_sr595_enabled(void);
uint32_t sim_sr595_enable_latches(void);
uint32_t sim_sr595_enable_outputs(void);
/* 某一位在栅极上的实际电平;OE# 为高时返回 -1(高阻,不是 0)。 */
int sim_sr595_pin(int bit);
/* ---------------- PCA9555(按 datasheet 建模的假器件) ---------------- */
/* 通用寄存器表证明不了 A6 的核心判据。这个模型只多做三件真器件会做的事:
① **Output Port 复位值 0xFF** —— 所以把一个脚配成输出,它下一刻就输出高;
② 输入寄存器按位合成:配成输入的位读**外部电平**,配成输出的位读**自己的输出**;
③ 四个备用脚 P0_7 / P1_5~7 **焊死在地上** —— 读永远是 0,被驱动成高就是短路。
③ 是这块板子上「配错方向」唯一会留下痕迹的地方:真板子上它既不报错也不 NAK。 */
void sim_pca9555_attach(uint8_t addr);
/* 外部世界把某个端口的 8 根线驱动成 level(接地脚不受影响,恒 0)。
FLT#/PGOOD 都是开漏上拉,所以「什么都没发生」的静息值是 0xFF。 */
void sim_pca9555_drive(int port, uint8_t level);
uint8_t sim_pca9555_config(int port); /* 方向寄存器现值,1 = 输入 */
uint8_t sim_pca9555_output(int port); /* 输出锁存器现值(复位 0xFF) */
uint8_t sim_pca9555_polinv(int port);
/* 接地脚被配成输出过几次(不管输出高低,配了就算 —— 那已经是在对地灌电流)。 */
uint32_t sim_pca9555_gnd_driven(void);
/* 其中真的输出了高电平、也就是**直接对地短路**过几次。 */
uint32_t sim_pca9555_shorted(void);
/* INT# 现在是不是被拉低(低有效)。输入变化拉低,**读走输入端口才释放**。 */
bool sim_pca9555_int_asserted(void);
uint32_t sim_pca9555_int_asserts(void); /* 累计拉低次数 */
/* ---------------- PWM ---------------- */
int sim_pwm_pin(int ch); /* 未配置返回 -1 */
uint32_t sim_pwm_freq(int ch); /* 未配置返回 0 */
int sim_pwm_permille(int ch); /* 未配置返回 -1 */
/* ---------------- 两把互斥锁 ---------------- */
/* 扮演第几号任务(默认 0)。单线程 sim 里「另一个任务持着锁」就靠它模拟。 */
void sim_lock_set_task(int task);
int sim_lock_task(void);
/* 现在被几号任务持着;没人持着返回 -1。 */
int sim_lock_owner(hal_lock_id_t id);
uint32_t sim_lock_takes(hal_lock_id_t id);
/* **强制加锁纪律**:打开之后,动这个共享资源(I²C 的任何事务 / 595 那四根线)
却没持着对应的锁,就记一次违规。默认关 —— A3~A6 的驱动测试是直接调驱动的,
它们不该被要求先拿锁。
⚠️ 违规只计数、不 abort:这条检查的用处是**证明上层的加锁路径是通的**
(t_safety 跑一圈违规为 0),以及证明这条检查本身不是句空话
(不拿锁直接读一片 INA226,违规必须 +1)。 */
void sim_lock_require(hal_lock_id_t id, bool on);
uint32_t sim_lock_violations(hal_lock_id_t id);
void sim_lock_check(hal_lock_id_t id); /* 给 sim 各路自己调,测试一般用不到 */
/* ---------------- 时钟 ---------------- */
void sim_time_set_us(uint64_t us);
void sim_time_advance_us(uint64_t us);
void sim_time_advance_ms(uint32_t ms);
uint32_t sim_time_delay_calls(void); /* hal_time_delay_ms() 被调了几次 */
#ifdef __cplusplus
}
#endif
#endif /* SIM_HAL_H */