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 行删除
+73 -74
查看文件
@@ -1,93 +1,92 @@
# 交接
更新:2026-09-07 完成任务包 **A7**(通道配置表 + 表驱动遍历 + 启动自检)
更新:2026-09-07 完成任务包 **A8**(任务骨架与调度:5 个任务、两把锁)
## 一句话现状
五路的六组编号(INA 地址 / DAC 片通道 / 三个按键位 / EDL 位 / 两根 EN 的 GPIO)
拼成了**一张 const 表** `rail_cfg(1..5)`,开机自检 `rail_selftest()` 把它和四个驱动
那四张表**逐格对账**,再查一遍全板引脚有没有重复 / 越界,不过就 `abort()`。
A3~A6 留下的四个自检谓词至此全部有了调用点。9 条 ctest(131 个用例)全绿,
`idf.py build` 通过。
§10.3 的五个任务成了一张 const 表(周期 + 优先级,开机自检不过就 abort),
周期任务走**绝对时刻**的节拍器(不漂、回绕安全、过载跳过不补跑),
两把互斥锁(I²C / 595)**禁止嵌套持有**,`t_safety` 的一圈已经能跑:
拿锁 → 6 片 INA226 的 V+I + PCA9555 两个输入端口 → 放锁 → 记账。
I²C 占用由 HAL 按线上真实字节数算出来:**1560 µs / 20 ms = 7.8 %**,
与 §10.3 手算的「约 1.5 ms / 约 8 %」相符。11 条 ctest(161 个用例)全绿,`idf.py build` 通过。
## 下一步
**A8**:任务骨架与调度(5 个任务、两把锁,需求 §10.3 全节)。
验收是「sim 时钟下 `t_safety` 周期 20 ms ±1 ms;I²C 占用打印出来与 §10.3 的 8 % 对得上」。
A7 给它备好的东西:`rail_cfg()` 表驱动遍历、`board_pins.h` 的 SDA/SCL 引脚、
`sim_gpio_write_seq()`(断言顺序用,A9 的 17 ms 上电时序会更需要它)。
**A9**:三个执行器状态机 + 互斥(需求 §10.6 全节)。
验收是「硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。
A8 给它备好的:`HAL_LOCK_SR595`(595 那把锁,sim 里能强制检查)、
虚拟时钟下的 `sched_*`、`sim_gpio_write_seq()`(A7 留的顺序断言)。
⚠️ A9 的状态机同样要写成「一个函数一步」,不要写进 `while(1)` —— 死循环在宿主机上跑不到。
## 本次新增/修改的文件
- `main/rail_cfg.h` / `main/rail_cfg.c` — 那张表、启动自检、两根 EN 线(≈250 行)
- `main/board_pins.h` — **全板引脚表**(§8.1 里「全板独一份」的那些脚:I²C、风扇、
NTC、USB、UP_VBUS_DET,加上 strapping / 不存在 / R8 版 PSRAM 三张禁用清单)。
五路自己的 `EFUSE_ENn` / `VBUS_ENn` **不在这里**,它们是「每路一个」,在五路表里
- `main/app_main.c` — 开机跑 `rail_selftest()`,不过就 abort;然后 `rail_en_init()`
- `test/test_rail_cfg.c`(28 个用例)
- `components/phonerail_hal/sim/sim_hal.h` + `sim/hal_gpio_sim.c` — 新增
`sim_gpio_write_seq()`(全局写序号,断言「先断 VBUS 再关 eFuse」这类顺序用);
**`SIM_GPIO_PINS` 48 → 49**
- `components/phonerail_hal/esp/hal_gpio_esp.c` — **`PIN_MAX` 48 → 49**
- 两处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a7`)
- `../需求文档.md` §10.4 — 记 A7 落地 + 五条实现约定 + 那张六列表
- `main/tasks.h` / `tasks.c` — 五任务表(名字/周期/优先级/栈)+ `task_table_ok()`
- `main/sched.h` / `sched.c` — 绝对时刻节拍器(≈60 行),过载计数与最迟到时长
- `main/t_safety.h` / `t_safety.c` — 巡检的**一个周期** + 静态预算 + 一行人话报告
- `components/phonerail_hal/include/hal_lock.h` + `esp/hal_lock_esp.c` + `sim/hal_lock_sim.c`
— 两把锁(HAL 的第五样东西,理由写在 hal_lock.h 开头)
- `components/phonerail_hal/include/hal_i2c_acct.h` + `hal_i2c_acct.c`
— I²C 占用核算,**两个目标编同一份**,所以宿主机跑出来的数就是上板会打印的数
- `sim/hal_i2c_sim.c` — 每次事务记账 + **把虚拟时钟真的花掉** + 查加锁纪律
- `sim/sim_sr595.c` / `sim/sim_hal.c` / `sim/sim_hal.h` — 595 那四根线也查加锁纪律;
新增 `sim_lock_*()`(扮演第二个任务、强制加锁纪律、违规计数)与 `sim_i2c_set_bus_time()`
- `esp/hal_i2c_esp.c` — 四个事务入口各加一行记账
- `components/drv/include/ina226.h` — 新增 `INA226_CONV_US`(8.8 ms,给任务表自检对账)
- `main/app_main.c` — 建五个任务;`selftest_or_die()` 多查一项任务表
- `test/test_sched.c`(17 个用例)/ `test/test_t_safety.c`(13 个用例)
- 三处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a8`)
- `../需求文档.md` §10.3 — 记 A8 落地 + 五条实现约定
## 验收命令与结果
```
$ bash host/test.sh
100% tests passed, 0 tests failed out of 9 # +test_rail_cfg(28),总计 131 个用例
100% tests passed, 0 tests failed out of 11 # +test_sched(17) +test_t_safety(13),共 161 例
$ ./host/build/test_t_safety.exe
t_safety: cycles=1 skipped=0 i2c_err=0 bus=1560 us/20 ms load=7.8%(§10.3 手算:约 1.5 ms / 约 8 %)
$ .\build_idf.ps1 # PowerShell
phonerail.bin binary size 0x34250 bytes. ... 0xcbdb0 bytes (80%) free.
Project build complete. # rail_cfg.c.obj 在(已单独确认)
phonerail.bin binary size 0x3cc40 bytes. ... 0xc33c0 bytes (76%) free.
Project build complete.
# 16 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
# INA 地址改回 0x40+ch / CH3 的 DAC 挪回 U6 / CH4↔CH5 按键位对调
# EDL 位改回 16+ch / PG 位跟着 FLT 加偏移
# EFUSE_EN2 撞上 UP_VBUS_DET / EFUSE_EN1 落到 strapping / CH1 两根 EN 写反
# EN 极性写反 / all_off 先关 eFuse / all_off 第一个错就 return
# en_init 不写那句明文的 0 / 表与驱动对账被删 / 引脚查重被删
# GPIO 上限退回 48
# → 以上 15 条全部变红。
#
# ⚠️ 唯一没能变红的一条:**把 `rail_selftest()` 整个改成 `return 0;`**。
# 判据本身(`rail_selftest_table()`)已经被测得很死,但「rail_selftest 有没有
# 真的去调它」这一行接线,在一切正常的前提下测试**原理上分不出来**
# —— 真表是 const 的,没有任何输入能让它该报错。这是已知残留,不是漏做。
# 节拍:next 从「现在」起算(漂移)/ 用裸比较判先后(回绕)/ 过载后补跑
# 任务表:t_log 抬到最高 / 巡检周期缩到 5 ms / thermal 与 actuator 不同级
# 巡检:拿锁改成死等 / 一片读失败就中断整圈 / 读失败把值清零 / 忘了放锁 /
# 地址改成 0x40+ch / 多读一次 PCA9555 / 占用游标不对齐到「现在」
# 记账:漏掉 STOP 位
# sim:两把锁允许嵌套 / 假总线上的事务不花时间
# → 16 条全部变红。
```
## 踩到的坑(只写会再犯的)
- ⚠️ **HAL 两侧的 GPIO 边界原来都是 `pin < 48`,而 `EFUSE_EN2` 在 IO48 上。**
ESP32-S3 的编号是 **0~48(含 48)**,写成「个数 48」正好把它关在门外,
现象是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()` 直接 HAL_ERR_ARG)。
已改成 49,`test_io48_is_a_usable_pin` 是回归拦网。
顺带记住:IO22~IO25 在 S3 上**根本不存在**、IO26~IO32 是模组内部 flash/PSRAM,
写进配置表 `gpio_config()` 既不报错也不干活 —— 自检把这些号也当越界。
- ⚠️ **「不是顺排」要看整列,逐格撞算式会撞空。** 第一版测试是「拿几个常见错算式
逐路比,断言错了几路」。写完逐格核对才发现:CH4 的按键位正好就是 9/10/11、
CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好落在猜的算式上 —— 三处「错了 5 路」
实际都只有 4 路。**猜错算式的测试是句空话**,而且这种空话看上去和真断言一样。
改成断言整列**不是等差数列**(`is_arithmetic()`),任何 `起点+步长×ch` 一次拦掉。
- **注错实验里把源文件 mtime 推到未来**(`os.utime(path, now+5)`):注错与恢复
挤在同一秒里,make 有可能认为 `.o` 还是新的、跳过重编,于是跑的是**上一轮**的
二进制 —— 那样「注了错还是绿」是假绿,看起来却像「这条测试没覆盖到」。
⚠️ 本轮**没有观察到它真的发生**(三条绿是真绿,原因见上面那段),
这条是预防性的;但假绿这种错一旦发生就会被当成结论写进文档,代价不对称。
(A6 那两条纪律仍然有效:try/finally 恢复原文;编译期红的一条要重写成能编过的。)
- **判据要能脱离真表被调用**。真表是 const 的,测试改不动,所以「查得出问题吗」
这件事本来是测不了的 —— 把判据拎成 `rail_selftest_table(const rail_cfg_t *t)`
之后,测试复制一份表出来逐样改坏即可。这条比多写十个断言管用。
- ⚠️ **sim 里「干活不花时间」会让时序测试变成一句空话。** 巡检在 sim 中耗时为零时,
「周期 20 ms」测的只是「delay 参数传对了没」,任何实现都能踩准。
所以假 I²C 现在**真的把虚拟时钟花掉**(按位周期折算),
而且测试第一句就断言「这一圈确实花掉了 `t_safety_budget_us()`」——
这条断言本身就是防着有人把 `sim_i2c_set_bus_time` 关掉。
- ⚠️ **注错实验里 `old in src` 会命中你没想到的那一行。** 这次
`" s_bus_time = true;"` 先匹配到了声明行(`static bool s_bus_time = true;`
里正好含这段),改的是初值、而它每次 reset 又被覆盖回来 —— 于是那条注错「绿了」,
看上去像「测试没覆盖到」。**注错的锚点要连着上一行一起写**,
跑完还要确认它真的改到了那一处。
- **周期任务的相位要用绝对时刻,而且比较必须走 `int32_t` 差。**
裸写 `now >= next` 在 49.7 天回绕那一刻会把「还早」判成「迟到了 49 天」,
然后一口气空转两百万个周期。农场是长稳跑的,这一定会发生。
- **加锁纪律要能被强制检查,否则「都加了锁」是靠人读代码保证的。**
sim 侧 `sim_lock_require()` 打开后,动 I²C / 595 却没持锁就记一次违规;
配套必须有一条**反向测试**(不拿锁直接读一片,违规必须 +1),
不然检查失灵时那些「违规为 0」的断言会永远绿。
- **两把锁不定加锁顺序,直接禁止嵌套。** 这块板上 595 是纯 GPIO、I²C 是纯总线,
没有任何一处需要同时持有;把「不可能」编码进 `hal_lock_take()`,
比定一套顺序然后指望后面每个人都记得可靠。
## 这一层的几条约定(写 A8/A9/A10 之前看一眼)
- **`rail_cfg()` 是唯一入口。** 任何地方都不许再出现 `0x40 + rail`、`3 * (rail-1)`
这类由通道号算出来的编号。六列里没有一列是顺排的,而写错了**板子照样正常工作**,
只是「重启 CH1」重启了 CH4。
- ⚠️ **两列 GPIO 没有第二张表能对账**(INA/DAC/595/PCA 那四列都有驱动那边兜着)。
所以自检**查不出**「`EFUSE_EN1` 和 `VBUS_EN1` 写反」—— 两个都是合法空闲脚、
不重复、不越界。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的十个值
(出处 `.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。
- **`rail_all_off()` 是复位 / 收尾路径,不是故障快路径。** A10 过压要断的是出事
那一路,直接调 `rail_efuse_enable(rail, false)`,别等着遍历五路。
- **开机顺序**:`rail_selftest()` → `rail_en_init()`(十个脚配成输出并拉低)→
才轮到 `rail_volt_init()` 等器件。DAC 一上电输出就有电压,EN 这时必须已经确定是低的。
- **高有效只出现一次**(`en_write()`)。和 `sr595_set_hub_reset()` 的反相、
`rail_mv_to_code()` 的减法同一条规矩。
- **自检不过就 abort,不降级。** 它查的全是编译进固件的常量表,不过 = 固件写错了,
§10.10 的降级矩阵管的是硬件坏了。带着错表跑起来的样子是「命令发给了另一路」,
五台手机全在跑、一个错误码都不报。
## 这一层的几条约定(写 A9/A10 之前看一眼)
- **任务体里不许有判据和时序。** 写进 `while(1)` 的东西宿主机一行也跑不到 ——
`app_main.c` 里的五个任务体各自只有三行(拿节拍 / 调一个函数 / 睡到下一拍),
真正的活在 `main/` 的其它 .c 里。A9 的状态机照这个形状写。
- **A10 的判据写在 `t_safety_step()` 里,而且要在放锁之后。**
§10.3 原话:越界直接操作 `EFUSE_EN` / `VBUS_EN`,**不经任何队列或抽象层**。
发消息给 t_actuator 是错的;拿着总线锁去写引脚也是错的(那两根线和 I²C 无关)。
- **判据先看 `ok` 再看值。** 读失败时 `snap.rail[]` 里留的是上一圈的值。
- **动了巡检里读的东西,就要同步改 `t_safety_budget_us()` 的那一行**
(测试会拦:静态预算与实测必须相等)。
- **器件初始化还没做**(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值),
那是 A15。在那之前上板跑起来电流恒读 0,是预期的,不是故障。
+5
查看文件
@@ -42,6 +42,11 @@ extern "C" {
转换周期 4 × (1.1 + 1.1) = 8.8 ms < t_safety 的 20 ms,所以每次巡检都拿到新的一帧。 */
#define INA226_CONFIG 0x4327
/* 上面那个 Config 折算出来的**一帧转换时间**:AVG=4 × (1.1 ms + 1.1 ms) = 8.8 ms。
`t_safety` 的周期必须长于它,否则同一帧会被读两次 —— 现象是「电流特别稳」,
不是任何一种错误。tasks.c 的表自检拿它和 20 ms 对一次。 */
#define INA226_CONV_US 8800
/* ---- 地址表(设计文档 §8.2) ------------------------------------------- */
#define INA226_RAIL_COUNT 5
@@ -4,6 +4,8 @@
# 重名会把 IDF 的那个盖掉。所以目录叫 phonerail_hal。
idf_component_register(
SRCS "hal_err.c"
"hal_i2c_acct.c"
"esp/hal_lock_esp.c"
"esp/hal_i2c_esp.c"
"esp/hal_gpio_esp.c"
"esp/hal_pwm_esp.c"
@@ -3,6 +3,7 @@
#include "driver/i2c_master.h"
#include "hal_i2c.h"
#include "hal_i2c_acct.h"
#define I2C_XFER_TIMEOUT_MS 50
#define I2C_DEFAULT_FREQ_HZ 400000
@@ -47,6 +48,7 @@ hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg)
return map_err(e);
}
s_freq_hz = cfg->freq_hz != 0 ? cfg->freq_hz : I2C_DEFAULT_FREQ_HZ;
hal_i2c_acct_set_freq(s_freq_hz); /* 占用核算按真实速率折算,见 hal_i2c_acct.h */
return HAL_OK;
}
@@ -80,6 +82,7 @@ hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len)
if (err != HAL_OK) {
return err;
}
hal_i2c_acct_add(len, 0);
return map_err(i2c_master_transmit(dev, data, len, I2C_XFER_TIMEOUT_MS));
}
@@ -90,6 +93,7 @@ hal_err_t hal_i2c_read(uint8_t addr, uint8_t *data, size_t len)
if (err != HAL_OK) {
return err;
}
hal_i2c_acct_add(0, len);
return map_err(i2c_master_receive(dev, data, len, I2C_XFER_TIMEOUT_MS));
}
@@ -101,6 +105,7 @@ hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len,
if (err != HAL_OK) {
return err;
}
hal_i2c_acct_add(wr_len, rd_len);
return map_err(i2c_master_transmit_receive(dev, wr, wr_len, rd, rd_len,
I2C_XFER_TIMEOUT_MS));
}
@@ -110,5 +115,6 @@ hal_err_t hal_i2c_probe(uint8_t addr)
if (s_bus == NULL) {
return HAL_ERR_STATE;
}
hal_i2c_acct_add(0, 0);
return map_err(i2c_master_probe(s_bus, addr, I2C_XFER_TIMEOUT_MS));
}
@@ -0,0 +1,65 @@
/* 两把互斥锁的 FreeRTOS 实现。静态分配(`xSemaphoreCreateMutexStatic`)——
两把锁的数量是编译期定死的,没有理由去堆上要内存,也就没有「创建失败」这条路径。
⚠️ 用的是 mutex 不是 binary semaphore:mutex 有**优先级继承**。
t_safety 是最高优先级且每 20 ms 就要总线,低优先级的 t_link 拿着 I²C 锁去读遥测时
如果没有继承,t_safety 会被一个中优先级任务无限期挡在门外(经典优先级反转)。 */
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "hal_lock.h"
static StaticSemaphore_t s_buf[HAL_LOCK_COUNT];
static SemaphoreHandle_t s_mtx[HAL_LOCK_COUNT];
static uint32_t s_timeouts[HAL_LOCK_COUNT];
hal_err_t hal_lock_init(void)
{
for (int i = 0; i < HAL_LOCK_COUNT; i++) {
if (s_mtx[i] == NULL) {
s_mtx[i] = xSemaphoreCreateMutexStatic(&s_buf[i]);
s_timeouts[i] = 0;
}
}
return HAL_OK;
}
bool hal_lock_held(hal_lock_id_t id)
{
if (id < 0 || id >= HAL_LOCK_COUNT || s_mtx[id] == NULL) {
return false;
}
return xSemaphoreGetMutexHolder(s_mtx[id]) == xTaskGetCurrentTaskHandle();
}
hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms)
{
if (id < 0 || id >= HAL_LOCK_COUNT || s_mtx[id] == NULL) {
return HAL_ERR_STATE;
}
/* 已经持有 = 重复加锁;持有另一把 = 嵌套。两者都是写错了,见 hal_lock.h。 */
for (int i = 0; i < HAL_LOCK_COUNT; i++) {
if (hal_lock_held((hal_lock_id_t)i)) {
return HAL_ERR_STATE;
}
}
const TickType_t ticks =
(timeout_ms == HAL_LOCK_FOREVER) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms);
if (xSemaphoreTake(s_mtx[id], ticks) != pdTRUE) {
s_timeouts[id]++;
return HAL_ERR_TIMEOUT;
}
return HAL_OK;
}
void hal_lock_give(hal_lock_id_t id)
{
configASSERT(id >= 0 && id < HAL_LOCK_COUNT && s_mtx[id] != NULL);
configASSERT(hal_lock_held(id)); /* 不是自己拿的就别放 */
(void)xSemaphoreGive(s_mtx[id]);
}
uint32_t hal_lock_timeouts(hal_lock_id_t id)
{
return (id >= 0 && id < HAL_LOCK_COUNT) ? s_timeouts[id] : 0;
}
@@ -0,0 +1,66 @@
/* I²C 占用核算的实现。**两个目标编的是同一份**(和 hal_err.c 一样放在组件根下),
所以宿主机上算出来的占用率就是上板会打印的那个数。 */
#include "hal_i2c_acct.h"
static uint64_t s_bits;
static uint32_t s_txns;
static uint32_t s_freq_hz = HAL_I2C_FREQ_HZ_DEFAULT;
uint32_t hal_i2c_txn_bits(size_t wr_len, size_t rd_len)
{
if (wr_len == 0 && rd_len == 0) {
return 1u + 9u + 1u; /* 探测:START + 地址字节 + STOP */
}
uint32_t bits = 1u; /* START */
if (wr_len > 0) {
bits += 9u * (uint32_t)(1u + wr_len); /* 地址 + 载荷 */
}
if (rd_len > 0) {
if (wr_len > 0) {
bits += 1u; /* 重复 START */
}
bits += 9u * (uint32_t)(1u + rd_len);
}
bits += 1u; /* STOP */
return bits;
}
void hal_i2c_acct_add(size_t wr_len, size_t rd_len)
{
s_bits += hal_i2c_txn_bits(wr_len, rd_len);
s_txns++;
}
uint64_t hal_i2c_acct_bits(void) { return s_bits; }
uint32_t hal_i2c_acct_txns(void) { return s_txns; }
void hal_i2c_acct_set_freq(uint32_t hz)
{
s_freq_hz = (hz == 0) ? HAL_I2C_FREQ_HZ_DEFAULT : hz;
}
uint32_t hal_i2c_acct_freq(void) { return s_freq_hz; }
uint64_t hal_i2c_bits_to_us(uint64_t bits)
{
/* 先乘后除,别先算「每位多少 µs」—— 400 kHz 下那是 2.5 µs,整数除法会抹平。 */
return (bits * 1000000ull) / (uint64_t)s_freq_hz;
}
uint32_t hal_i2c_acct_window_us(uint64_t *cursor_bits)
{
const uint64_t now = s_bits;
const uint64_t was = (cursor_bits != NULL) ? *cursor_bits : 0;
if (cursor_bits != NULL) {
*cursor_bits = now;
}
return (uint32_t)hal_i2c_bits_to_us(now - was);
}
void hal_i2c_acct_reset(void)
{
s_bits = 0;
s_txns = 0;
s_freq_hz = HAL_I2C_FREQ_HZ_DEFAULT;
}
@@ -0,0 +1,58 @@
/* I²C 总线占用核算 —— 需求 §10.3 那 8 % 的可执行版本。
§10.3 手算了一遍「t_safety 每 20 ms 要占总线多久」,结论是约 1.5 ms、约 8 %,
并在末尾要求「首板上电时用逻辑分析仪实测复核一次」。这里做的是中间那一档:
让固件**自己按线上真实发生的事务**把这个数算出来,两个目标(sim 与 ESP)
用的是同一份计数代码,所以宿主机上跑出来的数就是上板会打印出来的数。
为什么放在 HAL 里而不是应用层:只有 HAL 知道每次事务真正过了多少字节。
放在应用层就变成「按计划中的事务数手算」,那是把 §10.3 的算式抄第二遍
—— 少读一次寄存器、多一次重试,手算的那份都不会变。
⚠️ 这是**理想时序**的下限:不含时钟延展、不含 NAK 重试、不含事务之间
固件自己的开销。它能证明「预算没被写崩」,不能替代 §10.3 要求的实测。 */
#ifndef HAL_I2C_ACCT_H
#define HAL_I2C_ACCT_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define HAL_I2C_FREQ_HZ_DEFAULT 400000u
/* 一次事务在总线上占的**位周期数**。
写: START + (1+wr_len) 个字节 × 9 位 + STOP
写后读: START + (1+wr_len) 字节 + 重复 START + (1+rd_len) 字节 + STOP
每字节 9 位 = 8 数据 + 1 应答;START / 重复 START / STOP 各按 1 个位周期计。
rd_len == 0 就是纯写,wr_len == 0 就是纯读(探测是 wr_len = rd_len = 0)。
对一次 INA226 寄存器读(wr 1 字节、rd 2 字节)它给出 48 位 = 120 µs @400 kHz。
§10.3 手算的是「≈46 个位周期 ≈115 µs」—— 差的两位是 STOP 和一个 START,
§10.3 没数进去。整周期的结论不变(1.56 ms / 7.8 %,见 §10.3 的「约 1.5 ms / 约 8 %」)。 */
uint32_t hal_i2c_txn_bits(size_t wr_len, size_t rd_len);
/* 累计位周期数。两个 HAL 实现都在事务成功**与失败**之后调它 ——
NAK 掉的事务也占了总线时间(地址字节已经发出去了)。 */
void hal_i2c_acct_add(size_t wr_len, size_t rd_len);
uint64_t hal_i2c_acct_bits(void);
uint32_t hal_i2c_acct_txns(void);
/* 位周期 → µs,按当前总线频率。hal_i2c_init() 会把频率填进来(0 视为默认 400 kHz)。 */
void hal_i2c_acct_set_freq(uint32_t hz);
uint32_t hal_i2c_acct_freq(void);
uint64_t hal_i2c_bits_to_us(uint64_t bits);
/* 取「从上一次取样到现在」占了多少 µs,并把取样点推到现在。
t_safety 每个周期调一次,拿到的就是这一周期的总线占用。 */
uint32_t hal_i2c_acct_window_us(uint64_t *cursor_bits);
void hal_i2c_acct_reset(void);
#ifdef __cplusplus
}
#endif
#endif /* HAL_I2C_ACCT_H */
@@ -0,0 +1,61 @@
/* 互斥锁 —— HAL 的第五样东西,而且**只有两把**(需求 §10.3)。
计划 §4.1 写着「HAL 只抽象四样:I²C / GPIO / PWM-定时 / 单调时钟,不要抽象得更多」。
这里多出来的第五样是有理由的:§10.3 的五个任务共享的资源正好是两个
—— I²C 总线(10 个器件在同一条上)和 74HC595 的 24 位链 —— 而「谁在用总线」
这件事没法用前四样表达。
为了不让它长成一个通用的锁库,这里**没有创建接口**:两把锁是枚举出来的,
编译期就这么多,也不许再加第三把。要加第三把之前先回头看一眼 §10.3
——「共享资源只有两个」是架构结论,不是当时懒得数。
⚠️ **两把锁不许嵌套持有。** 595 那条链是纯 GPIO(SER/SRCLK/RCLK),
和 I²C 没有任何交集,所以「拿着 A 去拿 B」这件事在这块板子上永远是写错了,
而不是一个需要小心处理的情况。`hal_lock_take()` 直接把它判成 HAL_ERR_STATE
—— 与其定一套加锁顺序然后指望后面每个人都记得,不如让它根本不可能发生。 */
#ifndef HAL_LOCK_H
#define HAL_LOCK_H
#include <stdbool.h>
#include <stdint.h>
#include "hal_err.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
HAL_LOCK_I2C = 0, /* I²C 总线:6×INA226 + 2×DAC63204 + PCA9555(+ 载板 LM75) */
HAL_LOCK_SR595, /* 74HC595 的 24 位链,全板共享(§10.6 的影子寄存器) */
HAL_LOCK_COUNT,
} hal_lock_id_t;
/* 幂等。任何任务起来之前调一次;没 init 就 take 会返回 HAL_ERR_STATE
(FreeRTOS 上那是个空句柄,不拦就是随机崩)。 */
hal_err_t hal_lock_init(void);
/* 拿锁。timeout_ms = HAL_LOCK_FOREVER 表示一直等。
HAL_ERR_TIMEOUT —— 等超时了,**调用方必须自己处理**:巡检任务应当跳过这一周期
并计一次数,不要死等(死等的现象是 t_safety 整个停摆)。
HAL_ERR_STATE —— 没 init,或者违反了「不许嵌套持有两把锁」。
同一个任务重复拿同一把锁也是 HAL_ERR_STATE:这两把都不是递归锁。 */
#define HAL_LOCK_FOREVER 0xFFFFFFFFu
hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms);
/* 放锁。不是自己拿的、或者根本没人拿,都会 abort ——
那是代码写错了,悄悄放掉会让下一个持有者莫名其妙地丢掉互斥。 */
void hal_lock_give(hal_lock_id_t id);
/* **当前任务**是不是正持着这把锁。给断言和 sim 的强制检查用,
不要拿它做流程控制(「没拿就拿一下」正是双重加锁的写法)。 */
bool hal_lock_held(hal_lock_id_t id);
/* 累计等锁失败次数(超时)。遥测里报出去,用来看总线是不是排队了。 */
uint32_t hal_lock_timeouts(hal_lock_id_t id);
#ifdef __cplusplus
}
#endif
#endif /* HAL_LOCK_H */
@@ -7,6 +7,7 @@
static sim_i2c_dev_t s_dev[SIM_I2C_MAX_DEV];
static int s_n;
static bool s_inited;
static bool s_bus_time = true;
void sim_i2c_reset_internal(void); /* 只给 sim_hal_reset() 用 */
@@ -15,6 +16,7 @@ void sim_i2c_reset_internal(void)
memset(s_dev, 0, sizeof(s_dev));
s_n = 0;
s_inited = false;
s_bus_time = true;
}
sim_i2c_dev_t *sim_i2c_dev(uint8_t addr)
@@ -53,12 +55,25 @@ void sim_i2c_set_reg(uint8_t addr, uint8_t reg, uint16_t val) { must(addr)->regs
uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg) { return must(addr)->regs[reg]; }
void sim_i2c_inject_nak(uint8_t addr, int count) { must(addr)->nak_left = count; }
void sim_i2c_set_present(uint8_t addr, bool present) { must(addr)->present = present; }
void sim_i2c_set_bus_time(bool on) { s_bus_time = on; }
/* 每次事务(成功或 NAK)都记账并把虚拟时钟花掉。NAK 也占总线:地址字节已经发出去了。
同时查一次加锁纪律 —— I²C 是 §10.3 两个共享资源之一。 */
static void spend(size_t wr_len, size_t rd_len)
{
sim_lock_check(HAL_LOCK_I2C);
hal_i2c_acct_add(wr_len, rd_len);
if (s_bus_time) {
sim_time_advance_us(hal_i2c_bits_to_us(hal_i2c_txn_bits(wr_len, rd_len)));
}
}
/* ---- HAL 实现 ---------------------------------------------------------- */
hal_err_t hal_i2c_init(const hal_i2c_cfg_t *cfg)
{
(void)cfg; /* sim 不关心引脚与速率 */
(void)cfg; /* sim 不关心引脚 —— 但速率要告诉占用核算,它按频率折算 µs */
hal_i2c_acct_set_freq(cfg != NULL ? cfg->freq_hz : 0);
s_inited = true;
return HAL_OK;
}
@@ -144,6 +159,7 @@ static hal_err_t do_read(sim_i2c_dev_t *d, uint8_t *data, size_t len)
hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len)
{
sim_i2c_dev_t *d = NULL;
spend(len, 0);
hal_err_t err = take(addr, &d);
return err != HAL_OK ? err : do_write(d, data, len);
}
@@ -151,6 +167,7 @@ hal_err_t hal_i2c_write(uint8_t addr, const uint8_t *data, size_t len)
hal_err_t hal_i2c_read(uint8_t addr, uint8_t *data, size_t len)
{
sim_i2c_dev_t *d = NULL;
spend(0, len);
hal_err_t err = take(addr, &d);
return err != HAL_OK ? err : do_read(d, data, len);
}
@@ -159,6 +176,7 @@ hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len,
uint8_t *rd, size_t rd_len)
{
sim_i2c_dev_t *d = NULL;
spend(wr_len, rd_len);
hal_err_t err = take(addr, &d);
if (err != HAL_OK) {
return err;
@@ -170,5 +188,6 @@ hal_err_t hal_i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wr_len,
hal_err_t hal_i2c_probe(uint8_t addr)
{
sim_i2c_dev_t *d = NULL;
spend(0, 0);
return take(addr, &d);
}
@@ -0,0 +1,107 @@
/* 两把互斥锁的假实现。宿主机测试是**单线程**的,所以这里模拟的不是并发本身,
而是「加锁纪律」—— 谁拿了、放没放、有没有嵌套、该拿的时候拿了没有。
⚠️ 单线程 sim 里「等一把别人持有的锁」永远等不到,所以:
· 有限超时 → 把虚拟时钟往前拨满 timeout_ms,计一次超时,返回 HAL_ERR_TIMEOUT
(这正是真板上等不到锁时该发生的事,调用方的处理路径因此测得到);
· HAL_LOCK_FOREVER 且被别人持有 → 直接 abort。那在真板上是死锁,
在这里悄悄返回一个错误码只会把它藏起来。
「谁」是一个假任务号(`sim_lock_set_task()`),默认 0。测试用它扮演第二个任务。 */
#include <assert.h>
#include <string.h>
#include "hal_lock.h"
#include "sim_hal.h"
#define NO_OWNER (-1)
static int s_owner[HAL_LOCK_COUNT];
static uint32_t s_takes[HAL_LOCK_COUNT];
static uint32_t s_timeouts[HAL_LOCK_COUNT];
static uint32_t s_violations[HAL_LOCK_COUNT];
static bool s_require[HAL_LOCK_COUNT];
static int s_task;
static bool s_inited;
void sim_lock_reset_internal(void); /* 只给 sim_hal_reset() 用 */
void sim_lock_reset_internal(void)
{
for (int i = 0; i < HAL_LOCK_COUNT; i++) {
s_owner[i] = NO_OWNER;
}
memset(s_takes, 0, sizeof(s_takes));
memset(s_timeouts, 0, sizeof(s_timeouts));
memset(s_violations, 0, sizeof(s_violations));
memset(s_require, 0, sizeof(s_require));
s_task = 0;
s_inited = false;
}
static bool valid(hal_lock_id_t id) { return id >= 0 && id < HAL_LOCK_COUNT; }
hal_err_t hal_lock_init(void)
{
s_inited = true;
return HAL_OK;
}
bool hal_lock_held(hal_lock_id_t id)
{
return valid(id) && s_owner[id] == s_task;
}
hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms)
{
if (!valid(id) || !s_inited) {
return HAL_ERR_STATE;
}
/* 已经持有 = 重复加锁(这两把都不是递归锁);持有另一把 = 嵌套,见 hal_lock.h。 */
for (int i = 0; i < HAL_LOCK_COUNT; i++) {
if (s_owner[i] == s_task) {
return HAL_ERR_STATE;
}
}
if (s_owner[id] != NO_OWNER) {
assert(timeout_ms != HAL_LOCK_FOREVER); /* 单线程里死等 = 死锁 */
s_timeouts[id]++;
hal_time_delay_ms(timeout_ms); /* 真板上这段时间是等掉的,虚拟时钟也要花掉 */
return HAL_ERR_TIMEOUT;
}
s_owner[id] = s_task;
s_takes[id]++;
return HAL_OK;
}
void hal_lock_give(hal_lock_id_t id)
{
assert(valid(id));
assert(s_owner[id] == s_task); /* 不是自己拿的 / 根本没人拿 */
s_owner[id] = NO_OWNER;
}
uint32_t hal_lock_timeouts(hal_lock_id_t id) { return valid(id) ? s_timeouts[id] : 0; }
/* ---- 假板子这一侧的控制面 ------------------------------------------------ */
void sim_lock_set_task(int task) { s_task = task; }
int sim_lock_task(void) { return s_task; }
int sim_lock_owner(hal_lock_id_t id) { return valid(id) ? s_owner[id] : NO_OWNER; }
uint32_t sim_lock_takes(hal_lock_id_t id) { return valid(id) ? s_takes[id] : 0; }
void sim_lock_require(hal_lock_id_t id, bool on)
{
if (valid(id)) {
s_require[id] = on;
}
}
uint32_t sim_lock_violations(hal_lock_id_t id) { return valid(id) ? s_violations[id] : 0; }
void sim_lock_check(hal_lock_id_t id)
{
if (valid(id) && s_require[id] && !hal_lock_held(id)) {
s_violations[id]++;
}
}
@@ -1,5 +1,6 @@
/* sim 的总闸:一次把四路都恢复出厂。每个测试的 setUp() 调它。
各路的清理函数是内部符号,不进 sim_hal.h —— 测试没有理由单独重置某一路。 */
#include "hal_i2c_acct.h"
#include "sim_hal.h"
void sim_i2c_reset_internal(void);
@@ -8,6 +9,7 @@ void sim_sr595_reset_internal(void);
void sim_pca9555_reset_internal(void);
void sim_pwm_reset_internal(void);
void sim_time_reset_internal(void);
void sim_lock_reset_internal(void);
void sim_hal_reset(void)
{
@@ -17,4 +19,6 @@ void sim_hal_reset(void)
sim_pca9555_reset_internal();
sim_pwm_reset_internal();
sim_time_reset_internal();
sim_lock_reset_internal();
hal_i2c_acct_reset();
}
@@ -11,6 +11,8 @@
#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"
@@ -64,6 +66,13 @@ uint16_t sim_i2c_get_reg(uint8_t addr, uint8_t reg);
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 上。
@@ -142,6 +151,27 @@ 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);
@@ -55,6 +55,13 @@ void sim_sr595_gpio_hook(int pin, int level)
}
level = level ? 1 : 0;
/* 动这条链的四根线之一 = 在用那个共享资源,此刻必须持着 595 那把锁
(只在 sim_lock_require(HAL_LOCK_SR595, true) 之后才计数,见 hal_lock_sim.c)。 */
if (pin == s_ch.ser_pin || pin == s_ch.srclk_pin || pin == s_ch.rclk_pin ||
pin == s_ch.oe_pin) {
sim_lock_check(HAL_LOCK_SR595);
}
if (pin == s_ch.ser_pin) {
s_ch.ser = level;
} else if (pin == s_ch.srclk_pin) {
+5
查看文件
@@ -24,12 +24,17 @@ set(PHONERAIL_CORE_SRCS
${FW_ROOT}/main/version.c
${FW_ROOT}/main/rail.c
${FW_ROOT}/main/rail_cfg.c
${FW_ROOT}/main/sched.c
${FW_ROOT}/main/tasks.c
${FW_ROOT}/main/t_safety.c
)
# HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。
set(PHONERAIL_HAL_DIR ${FW_ROOT}/components/phonerail_hal)
set(PHONERAIL_HAL_SIM_SRCS
${PHONERAIL_HAL_DIR}/hal_err.c
${PHONERAIL_HAL_DIR}/hal_i2c_acct.c
${PHONERAIL_HAL_DIR}/sim/hal_lock_sim.c
${PHONERAIL_HAL_DIR}/sim/sim_hal.c
${PHONERAIL_HAL_DIR}/sim/hal_i2c_sim.c
${PHONERAIL_HAL_DIR}/sim/hal_gpio_sim.c
+3
查看文件
@@ -6,5 +6,8 @@ idf_component_register(
"version.c"
"rail.c"
"rail_cfg.c"
"sched.c"
"tasks.c"
"t_safety.c"
INCLUDE_DIRS "."
REQUIRES phonerail_hal drv)
+127 -3
查看文件
@@ -1,10 +1,18 @@
/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */
#include <stdlib.h>
#include "board_pins.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "hal_i2c.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "phonerail.h"
#include "rail_cfg.h"
#include "sched.h"
#include "t_safety.h"
#include "tasks.h"
static const char *TAG = "phonerail";
@@ -17,6 +25,13 @@ static const char *TAG = "phonerail";
一个错误码都不报,谁也不会去怀疑它。 */
static void selftest_or_die(void)
{
/* 五个任务那张表(§10.3):周期与优先级的相对顺序。同样是编译进去的常量,
同样不过就 abort —— 优先级写反了板子照跑,只是巡检会在总线忙时被挤后。 */
if (!task_table_ok()) {
ESP_LOGE(TAG, "任务表自检失败:周期或优先级与 §10.3 对不上");
abort();
}
const uint32_t bad = rail_selftest();
if (bad == 0) {
ESP_LOGI(TAG, "五路配置表自检通过");
@@ -30,6 +45,87 @@ static void selftest_or_die(void)
abort();
}
/* ---- 五个任务(需求 §10.3) ---------------------------------------------
任务体只在这个文件里,而且都短得能一眼看完 —— 真正的活在 main/ 的其它 .c 里,
那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的
东西,宿主机一行也跑不到。 */
static void task_safety(void *arg)
{
(void)arg;
static safety_snap_t snap; /* static:不占任务栈,也方便挂调试器看 */
t_safety_init(&snap);
sched_periodic_t tick;
sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms);
char line[128];
for (;;) {
(void)t_safety_step(&snap); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */
/* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」,
首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */
if ((snap.cycles % 250u) == 0u) {
t_safety_format(&snap, line, sizeof(line));
ESP_LOGI(TAG, "%s (预算 %u us, 过载 %u, 最迟 %u ms)", line,
(unsigned)t_safety_budget_us(), (unsigned)tick.overruns,
(unsigned)tick.worst_late_ms);
}
sched_periodic_wait(&tick);
}
}
/* 下面四个是骨架:周期与优先级已经按 §10.3 定好,任务体等各自的包。
现在就把它们建起来,是为了让 A9~A16 只需要往里填,不用再动启动流程。 */
static void task_thermal(void *arg)
{
(void)arg;
sched_periodic_t tick;
sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms);
for (;;) {
/* A16:两路 NTC + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */
sched_periodic_wait(&tick);
}
}
static void task_actuator(void *arg)
{
(void)arg;
for (;;) {
/* A9:三个状态机 + 互斥 + 超时兜底。队列在那一包里建。 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
static void task_link(void *arg)
{
(void)arg;
for (;;) {
/* A12~A14:命令处理器 + 两条传输链路。 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
static void task_log(void *arg)
{
(void)arg;
for (;;) {
/* A11:事件环形缓冲落盘。 */
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
static void spawn(task_id_t id, TaskFunction_t fn)
{
const task_desc_t *d = task_desc(id);
/* ⚠️ ESP-IDF 的 xTaskCreate 收的 stack_depth 是**字节**(原版 FreeRTOS 是字)。 */
if (xTaskCreate(fn, d->name, d->stack_bytes, NULL, d->prio, NULL) != pdPASS) {
ESP_LOGE(TAG, "建任务失败:%s", d->name);
abort();
}
}
void app_main(void)
{
ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(),
@@ -39,12 +135,40 @@ void app_main(void)
/* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有
输出,这两根线这时必须已经确定是低的。 */
const hal_err_t err = rail_en_init();
hal_err_t err = rail_en_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err));
abort();
}
ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭,等待 A8 的任务骨架");
ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭");
/* 器件初始化与五个任务留到 A8/A15。 */
/* 两把锁必须早于任何一个用总线的任务。 */
(void)hal_lock_init();
const hal_i2c_cfg_t i2c = {
.sda_gpio = BOARD_PIN_I2C_SDA,
.scl_gpio = BOARD_PIN_I2C_SCL,
.freq_hz = 400000,
};
err = hal_i2c_init(&i2c);
if (err != HAL_OK) {
ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err));
abort();
}
/* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值)
与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0
(Calibration 还没写),这是预期的,不是故障。 */
spawn(TASK_SAFETY, task_safety);
spawn(TASK_THERMAL, task_thermal);
spawn(TASK_ACTUATOR, task_actuator);
spawn(TASK_LINK, task_link);
spawn(TASK_LOG, task_log);
ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms),"
"I²C 预算 %u us 每周期",
(unsigned)task_desc(TASK_SAFETY)->period_ms,
(unsigned)task_desc(TASK_THERMAL)->period_ms,
(unsigned)t_safety_budget_us());
}
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.0-a7"
#define PHONERAIL_FW_VERSION "0.1.0-a8"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+53
查看文件
@@ -0,0 +1,53 @@
#include "sched.h"
#include <assert.h>
#include <stddef.h>
#include "hal_time.h"
void sched_periodic_init(sched_periodic_t *p, uint32_t period_ms)
{
assert(p != NULL);
assert(period_ms > 0); /* 事件驱动的任务不该走这里 */
p->period_ms = period_ms;
p->next_ms = hal_time_now_ms() + period_ms;
p->cycles = 0;
p->overruns = 0;
p->worst_late_ms = 0;
}
uint32_t sched_periodic_delay_from(sched_periodic_t *p, uint32_t now_ms)
{
assert(p != NULL && p->period_ms > 0);
p->cycles++;
/* ⚠️ 一律用有符号差判先后,不要写 `now >= next`。
32 位 ms 在 49.7 天回绕,农场是长稳跑的,回绕一定会发生 ——
裸比较会在回绕那一刻把「还早着呢」判成「已经迟到了」,
现象是巡检突然连着空转 49.7 天份的周期。 */
const int32_t late = (int32_t)(now_ms - p->next_ms);
if (late < 0) {
const uint32_t delay = (uint32_t)(-late);
p->next_ms += p->period_ms;
return delay;
}
/* 到点了活还没干完。跳过错过的整周期、保住相位,见 sched.h。 */
p->overruns++;
if ((uint32_t)late > p->worst_late_ms) {
p->worst_late_ms = (uint32_t)late;
}
const uint32_t missed = (uint32_t)late / p->period_ms + 1u;
p->next_ms += missed * p->period_ms;
return 0;
}
uint32_t sched_periodic_wait(sched_periodic_t *p)
{
const uint32_t delay = sched_periodic_delay_from(p, hal_time_now_ms());
if (delay > 0) {
hal_time_delay_ms(delay);
}
return delay;
}
+49
查看文件
@@ -0,0 +1,49 @@
/* 周期任务的节拍器(需求 §10.3)。
两个周期任务 —— `t_safety` 20 ms、`t_thermal` 1 s —— 都不能用
「干完活睡 20 ms」这种写法:那样周期会变成 `20 ms + 干活时间`,
而干活时间本身是变的(I²C 重试、NAK 超时),于是巡检的实际间隔会**漂**,
还会跟着总线忙闲一起抖。§10.3 的 20 ms 是**间隔**,不是延时。
所以这里记的是**下一次该醒的绝对时刻**,每次把它 += 周期,
睡「到那个时刻还差多久」。FreeRTOS 的 `vTaskDelayUntil()` 是同一个东西,
这里自己写一份的唯一理由是:它得在宿主机上用虚拟时钟跑得起来。
---- 过载了怎么办 --------------------------------------------------------
一个周期没干完就到点了(`sched_periodic_wait()` 算出来该睡 0 ms),
**跳过错过的整周期、保住相位**,并计一次 `overruns`。
不补跑 —— 补跑的现象是「卡了一下之后连着跑五圈巡检」,把总线瞬间打满、
把已经落后的任务推得更后面。巡检读的是**当前**电压电流,补读三个周期前的没有意义。 */
#ifndef SCHED_H
#define SCHED_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
uint32_t period_ms;
uint32_t next_ms; /* 下一次该醒的绝对时刻(32 位 ms,回绕安全) */
uint32_t cycles; /* 已经等过几个周期 */
uint32_t overruns; /* 其中「到点时活还没干完」的有几个 */
uint32_t worst_late_ms; /* 最严重的一次迟到了多久 —— 遥测里报出去 */
} sched_periodic_t;
/* 以「现在」为相位起点。period_ms 为 0 会 abort(那是事件驱动任务,不该用这个)。 */
void sched_periodic_init(sched_periodic_t *p, uint32_t period_ms);
/* 纯计算:now_ms 这一刻还该睡多久,并把下一次的时刻推进。
与 `sched_periodic_wait()` 分开是为了能不依赖时钟单独测它 ——
回绕、过载、连续过载这三种情况用真时钟很难摆出来。 */
uint32_t sched_periodic_delay_from(sched_periodic_t *p, uint32_t now_ms);
/* 睡到下一个节拍。返回实际睡了多久(0 = 这一圈超时了)。 */
uint32_t sched_periodic_wait(sched_periodic_t *p);
#ifdef __cplusplus
}
#endif
#endif /* SCHED_H */
+118
查看文件
@@ -0,0 +1,118 @@
#include "t_safety.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "rail_cfg.h"
#include "tasks.h"
void t_safety_init(safety_snap_t *s)
{
assert(s != NULL);
memset(s, 0, sizeof(*s));
/* 游标对齐到「现在」,否则第一圈会把启动期间(器件初始化)的事务算进来。 */
s->bus_cursor = hal_i2c_acct_bits();
}
hal_err_t t_safety_step(safety_snap_t *s)
{
assert(s != NULL);
s->cycles++;
hal_err_t err = hal_lock_take(HAL_LOCK_I2C, T_SAFETY_LOCK_TIMEOUT_MS);
if (err != HAL_OK) {
/* 规矩 1:不死等。这一圈作废,20 ms 后重来。 */
s->skipped++;
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
return err;
}
hal_err_t first = HAL_OK;
/* 五路 —— 地址查表,**不许算**(rail_cfg.h 那条禁令)。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
ina226_vi_t vi;
const hal_err_t e = ina226_read_vi(rail_cfg(rail)->ina_addr, &vi);
s->rail_ok[rail - 1] = (e == HAL_OK);
if (e == HAL_OK) {
s->rail[rail - 1] = vi; /* 规矩 2:失败就留着上一圈的值,别清零 */
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
}
/* 第六片:输入侧。 */
{
ina226_vi_t vi;
const hal_err_t e = ina226_read_vi(INA226_ADDR_INPUT, &vi);
s->input_ok = (e == HAL_OK);
if (e == HAL_OK) {
s->input = vi;
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
}
/* PCA9555 的两个输入端口一次事务读完(自增指针)。 */
{
pca9555_in_t in;
const hal_err_t e = pca9555_read_inputs(&in);
s->io_ok = (e == HAL_OK);
if (e == HAL_OK) {
s->io = in;
} else {
s->i2c_errors++;
if (first == HAL_OK) {
first = e;
}
}
}
hal_lock_give(HAL_LOCK_I2C);
/* A10 的判据接在这里:越界**直接**写 EFUSE_EN / VBUS_EN(规矩 3),
而且要在放锁之后 —— 那两根线是 GPIO 直连,和 I²C 没关系,
拿着总线锁去操作引脚只会把别的任务多挡一会儿。 */
s->bus_us = hal_i2c_acct_window_us(&s->bus_cursor);
return first;
}
uint32_t t_safety_budget_us(void)
{
/* 上面那一圈真正发出去的事务,逐条数:
6 片 INA226 × 2 次寄存器读(母线电压 + 电流)
1 次 PCA9555 读(一次事务读回两个输入端口)
每次都是「写 1 字节寄存器号 + 读 2 字节」的形状。 */
const uint32_t txns = INA226_COUNT * 2u + 1u;
return (uint32_t)hal_i2c_bits_to_us((uint64_t)txns * hal_i2c_txn_bits(1, 2));
}
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms)
{
if (period_ms == 0) {
return 0;
}
return (uint32_t)(((uint64_t)bus_us * 1000ull) / ((uint64_t)period_ms * 1000ull));
}
int t_safety_format(const safety_snap_t *s, char *buf, size_t len)
{
assert(s != NULL && buf != NULL && len > 0);
const uint32_t period = task_desc(TASK_SAFETY)->period_ms;
const uint32_t pm = t_safety_load_permille(s->bus_us, period);
return snprintf(buf, len,
"t_safety: cycles=%u skipped=%u i2c_err=%u bus=%u us/%u ms load=%u.%u%%",
(unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors,
(unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u),
(unsigned)(pm % 10u));
}
+72
查看文件
@@ -0,0 +1,72 @@
/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。
本包(A8)只做骨架:拿 I²C 锁 → 读 6 片 INA226 的 V+I → 读 PCA9555 两个输入端口
→ 放锁 → 记账。**判据(SW-2 / SW-8 / 过压 / 反灌)是 A10 的事**,这里一条都没有。
分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来:
宿主机没有 FreeRTOS,任务体是死循环就一行也测不了。ESP 侧的 `while(1)`
在 app_main.c 里,只有三行(拿节拍 / 调这个 / 睡到下一拍)。
---- 三条不能改的规矩 ----------------------------------------------------
1. **不许死等锁。** 拿不到就跳过这一周期并计数(`skipped`)。死等的现象是
巡检整个停摆,而它是优先级最高的那个任务 —— 停摆了没有任何人会发现。
2. **一个器件读失败不能中断这一圈。** 六片里坏一片,剩下五路的判据照常要出。
失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。
3. **越界要断电时直接写 `EFUSE_EN` / `VBUS_EN` 引脚**(§10.3 原话:不经任何队列
或抽象层)。所以 A10 的判据要写在这个函数里,不是发个消息给 t_actuator。 */
#ifndef T_SAFETY_H
#define T_SAFETY_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "hal_err.h"
#include "ina226.h"
#include "pca9555.h"
#include "phonerail.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 等 I²C 锁最多等这么久。取周期的 1/4:等过头这一周期就废了,
而**废一个周期**(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
typedef struct {
/* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 ——
判据(A10)必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
ina226_vi_t rail[PHONERAIL_RAIL_COUNT];
bool rail_ok[PHONERAIL_RAIL_COUNT];
ina226_vi_t input; /* 输入侧 U13(0x43),整板功率预算 */
bool input_ok;
pca9555_in_t io; /* 五路 FLT# / PGOOD + Buck PG 或 + INA ALERT 或 */
bool io_ok;
uint32_t cycles; /* 跑过几圈(含被跳过的) */
uint32_t skipped; /* 其中因为拿不到 I²C 锁而整圈跳过的 */
uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */
uint32_t bus_us; /* **上一圈**在 I²C 上真正花掉的时间 */
uint64_t bus_cursor; /* 内部:位数游标,别在外面改 */
} safety_snap_t;
void t_safety_init(safety_snap_t *s);
hal_err_t t_safety_step(safety_snap_t *s);
/* 按 §10.3 的算式静态算出来的「一圈该占总线多久」,单位 µs。
它和 `s->bus_us` 应当**一模一样** —— 不一样只有两种可能:巡检里多读/少读了东西,
或者有别的任务在同一圈里插了事务进来。测试拿这两个数对账。 */
uint32_t t_safety_budget_us(void);
/* 占用率(千分之)。20 ms 周期、1560 µs → 78‰。§10.3 手算的是「约 8 %」。 */
uint32_t t_safety_load_permille(uint32_t bus_us, uint32_t period_ms);
/* 一行人话,给启动日志和遥测用。返回写进去的字符数(不含结尾 0)。 */
int t_safety_format(const safety_snap_t *s, char *buf, size_t len);
#ifdef __cplusplus
}
#endif
#endif /* T_SAFETY_H */
+77
查看文件
@@ -0,0 +1,77 @@
#include "tasks.h"
#include <assert.h>
#include <stddef.h>
#include <string.h>
#include "ina226.h"
/* 优先级的绝对值本身不重要,**相对顺序**才是 §10.3 定的东西。
留出空档(6/4/4/3/1)是为了以后插一个任务时不用整体重排 —— 重排的那次
一定会有人只改一半。 */
#define PRIO_SAFETY 6
#define PRIO_THERMAL 4
#define PRIO_ACTUATOR 4
#define PRIO_LINK 3
#define PRIO_LOG 1
static const task_desc_t s_tasks[TASK_COUNT] = {
[TASK_SAFETY] = { "t_safety", 20, PRIO_SAFETY, 4096 },
[TASK_THERMAL] = { "t_thermal", 1000, PRIO_THERMAL, 3072 },
/* 执行器要跑三个状态机 + 超时兜底,栈给足;link 要收发帧与 JSON,最费。 */
[TASK_ACTUATOR] = { "t_actuator", 0, PRIO_ACTUATOR, 4096 },
[TASK_LINK] = { "t_link", 0, PRIO_LINK, 6144 },
[TASK_LOG] = { "t_log", 0, PRIO_LOG, 3072 },
};
const task_desc_t *task_desc(task_id_t id)
{
assert(id >= 0 && id < TASK_COUNT);
return &s_tasks[id];
}
bool task_table_ok(void)
{
for (int i = 0; i < TASK_COUNT; i++) {
const task_desc_t *a = &s_tasks[i];
if (a->name == NULL || a->name[0] == '\0' || a->stack_bytes < 2048) {
return false;
}
for (int j = i + 1; j < TASK_COUNT; j++) {
if (strcmp(a->name, s_tasks[j].name) == 0) {
return false;
}
}
}
/* 周期:§10.3 那一列,逐格钉死。 */
if (s_tasks[TASK_SAFETY].period_ms != 20 || s_tasks[TASK_THERMAL].period_ms != 1000) {
return false;
}
if (s_tasks[TASK_ACTUATOR].period_ms != 0 || s_tasks[TASK_LINK].period_ms != 0 ||
s_tasks[TASK_LOG].period_ms != 0) {
return false;
}
/* 优先级:t_safety 唯一最高、t_log 唯一最低。 */
for (int i = 0; i < TASK_COUNT; i++) {
if (i != TASK_SAFETY && s_tasks[i].prio >= s_tasks[TASK_SAFETY].prio) {
return false;
}
if (i != TASK_LOG && s_tasks[i].prio <= s_tasks[TASK_LOG].prio) {
return false;
}
}
if (s_tasks[TASK_THERMAL].prio != s_tasks[TASK_ACTUATOR].prio) {
return false; /* §10.3 里两个都是「中」 */
}
if (s_tasks[TASK_LINK].prio >= s_tasks[TASK_THERMAL].prio) {
return false;
}
/* 巡检周期要长于 INA226 的一帧转换时间(见 tasks.h 的最后一条)。 */
if (s_tasks[TASK_SAFETY].period_ms * 1000u <= (uint32_t)INA226_CONV_US) {
return false;
}
return true;
}
+58
查看文件
@@ -0,0 +1,58 @@
/* 五个任务的那张表(需求 §10.3)。
§10.3 定了五个任务、各自的周期与优先级,还有一句最容易被写丢的话:
**`t_safety` 越界时直接操作 `EFUSE_EN` / `VBUS_EN`,不经任何队列或抽象层。**
这张表在这里的意义不是「方便建任务」,而是让那五行**有一个能被自检查的形状** ——
优先级顺序写反了不会报错,只会让巡检在总线忙的时候被挤到几十毫秒之后。
---- 为什么周期与优先级要合在一张表里 ------------------------------------
因为它们是同一个判断的两半:`t_safety` 之所以是最高优先级,正是因为它有一个
20 ms 的硬节拍;`t_link` 之所以最低,正是因为它没有节拍(§10.2:失联不断电,
上位机慢一点没有任何后果)。分成两处写,改了一半的那天没人看得出来。
⚠️ 事件驱动的三个任务(actuator / link / log)在本表里 `period_ms == 0`。
它们的队列与消息定义在 A9(执行器)/ A12(命令)/ A11(日志)里,本包只搭骨架。 */
#ifndef TASKS_H
#define TASKS_H
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
TASK_SAFETY = 0, /* 20 ms,最高:SW-2 / SW-8 判据 + 直接操作 EN 引脚 */
TASK_THERMAL, /* 1 s,中:两路 NTC + 载板 LM75 → 风扇 PWM 闭环 */
TASK_ACTUATOR, /* 事件驱动,中:断电重启 / 按键序列 / EDL 序列 */
TASK_LINK, /* 事件驱动,低:上位机命令与遥测 */
TASK_LOG, /* 空闲,最低:事件环形缓冲落盘 */
TASK_COUNT,
} task_id_t;
typedef struct {
const char *name;
uint32_t period_ms; /* 0 = 事件驱动 */
int prio; /* FreeRTOS 优先级,越大越高 */
uint32_t stack_bytes; /* ESP-IDF 的 xTaskCreate 收的是**字节**,不是字 */
} task_desc_t;
/* id 越界直接 abort。 */
const task_desc_t *task_desc(task_id_t id);
/* 启动自检的一部分(和 `rail_selftest()` 一样:不过就 abort,不降级)。
查的是**表本身的形状**,不碰硬件:
· 名字非空且不重复;
· `t_safety` 是**唯一**的最高优先级,`t_log` 是**唯一**的最低;
· thermal 与 actuator 同级(§10.3 都是「中」),link 严格低于它们、高于 log;
· 周期与 §10.3 逐格相符(20 ms / 1 s / 三个 0);
· `t_safety` 的周期必须长于 INA226 的一帧转换时间,否则每次读到的是同一帧 ——
现象是「电流看起来非常稳」,而不是任何一种错误。 */
bool task_table_ok(void);
#ifdef __cplusplus
}
#endif
#endif /* TASKS_H */
+272
查看文件
@@ -0,0 +1,272 @@
/* A8 之一:节拍器(sched)、五任务表(tasks)、两把锁(hal_lock)。
这三样都是「写错了也照跑」的东西:周期漂了、优先级反了、锁忘了放,
板子上的现象都是「偶尔慢一点」。所以这里的断言全部对着**判据**,
不对着「跑起来没崩」。 */
#include <string.h>
#include "hal_lock.h"
#include "hal_time.h"
#include "ina226.h"
#include "sched.h"
#include "sim_hal.h"
#include "tasks.h"
#include "unity.h"
void setUp(void) { sim_hal_reset(); }
void tearDown(void) {}
/* ---- 节拍器:周期是「间隔」,不是「延时」 -------------------------------- */
/* 干活花掉 3 ms,节拍仍然是整 20 ms —— 这是 §10.3 的 20 ms 的定义。
写成「干完睡 20」的话这里会变成 23 ms。 */
static void test_period_is_the_interval_not_the_delay(void)
{
sched_periodic_t p;
sched_periodic_init(&p, 20);
uint32_t prev = hal_time_now_ms();
for (int i = 0; i < 100; i++) {
sim_time_advance_ms(3); /* 干活 */
sched_periodic_wait(&p);
const uint32_t now = hal_time_now_ms();
TEST_ASSERT_EQUAL_UINT32(20, now - prev);
prev = now;
}
TEST_ASSERT_EQUAL_UINT32(0, p.overruns);
}
/* 干活时间**每圈都不一样**(1~19 ms),周期照样必须是整 20 ms。
固定耗时测不出漂移:漂移是「上一圈的耗时被加进下一圈」,等长时看不出来。 */
static void test_jittery_work_does_not_move_the_phase(void)
{
sched_periodic_t p;
sched_periodic_init(&p, 20);
uint32_t work = 1;
for (int i = 0; i < 200; i++) {
sim_time_advance_ms(work);
work = (work * 7u) % 19u + 1u; /* 1~19 ms,绕着周期打转 */
sched_periodic_wait(&p);
/* 每一次醒来都落在 20 ms 的格子上 */
TEST_ASSERT_EQUAL_UINT32(0, hal_time_now_ms() % 20u);
}
TEST_ASSERT_EQUAL_UINT32(0, p.overruns);
}
/* 一圈超时(干了 25 ms):计一次过载、不睡,而且**相位不丢** ——
下一次仍然落在 20 ms 的格子上,不是从 25 ms 重新起算。 */
static void test_overrun_keeps_the_phase_and_is_counted(void)
{
sched_periodic_t p;
sched_periodic_init(&p, 20);
sim_time_advance_ms(25);
TEST_ASSERT_EQUAL_UINT32(0, sched_periodic_wait(&p)); /* 不睡 */
TEST_ASSERT_EQUAL_UINT32(1, p.overruns);
TEST_ASSERT_EQUAL_UINT32(5, p.worst_late_ms);
TEST_ASSERT_EQUAL_UINT32(15, sched_periodic_wait(&p)); /* 睡到 t=40 */
TEST_ASSERT_EQUAL_UINT32(40, hal_time_now_ms());
}
/* 卡了 105 ms(错过 5 个周期)之后**不补跑** —— 只跳过去,计一次过载。
补跑的现象是「卡一下之后连着打满总线」,见 sched.h。 */
static void test_a_long_stall_skips_instead_of_catching_up(void)
{
sched_periodic_t p;
sched_periodic_init(&p, 20);
sim_time_advance_ms(105);
TEST_ASSERT_EQUAL_UINT32(0, sched_periodic_wait(&p));
TEST_ASSERT_EQUAL_UINT32(1, p.overruns); /* 一次,不是五次 */
/* 下一拍是 t=120,也就是 15 ms 之后 —— 仍在 20 ms 的格子上。 */
TEST_ASSERT_EQUAL_UINT32(15, sched_periodic_wait(&p));
TEST_ASSERT_EQUAL_UINT32(120, hal_time_now_ms());
}
/* 32 位毫秒在 49.7 天回绕。裸写 `now >= next` 的话,回绕那一刻会被判成
「迟到了 49 天」,然后一口气跳掉两百万个周期。农场是长稳跑的,这一定会发生。 */
static void test_millisecond_wraparound_is_just_another_tick(void)
{
sim_time_set_us(0xFFFFFFF6ull * 1000ull); /* 距回绕 10 ms */
sched_periodic_t p;
sched_periodic_init(&p, 20);
for (int i = 0; i < 5; i++) {
const uint32_t slept = sched_periodic_wait(&p);
TEST_ASSERT_EQUAL_UINT32(20, slept); /* 跨过回绕点也还是 20 */
TEST_ASSERT_EQUAL_UINT32(0, p.overruns);
}
}
/* 纯计算入口要能不依赖时钟单独用(回绕、连续过载用真时钟很难摆出来)。 */
static void test_delay_from_is_pure(void)
{
sched_periodic_t p;
sched_periodic_init(&p, 20); /* now=0 → next=20 */
TEST_ASSERT_EQUAL_UINT32(20, sched_periodic_delay_from(&p, 0));
TEST_ASSERT_EQUAL_UINT32(1, sched_periodic_delay_from(&p, 39));
TEST_ASSERT_EQUAL_UINT32(0, hal_time_now_ms()); /* 一点时间也没花 */
TEST_ASSERT_EQUAL_UINT32(2, p.cycles);
}
/* ---- 五个任务那张表(§10.3) --------------------------------------------- */
static void test_task_table_passes_selftest(void)
{
TEST_ASSERT_TRUE(task_table_ok());
}
static void test_periods_match_section_10_3(void)
{
TEST_ASSERT_EQUAL_UINT32(20, task_desc(TASK_SAFETY)->period_ms);
TEST_ASSERT_EQUAL_UINT32(1000, task_desc(TASK_THERMAL)->period_ms);
/* 三个事件驱动的没有周期 —— 给它们安一个周期就是把 §10.3 读错了。 */
TEST_ASSERT_EQUAL_UINT32(0, task_desc(TASK_ACTUATOR)->period_ms);
TEST_ASSERT_EQUAL_UINT32(0, task_desc(TASK_LINK)->period_ms);
TEST_ASSERT_EQUAL_UINT32(0, task_desc(TASK_LOG)->period_ms);
}
/* §10.3 的优先级列:最高 / 中 / 中 / 低 / 最低。
反了的现象是「总线一忙巡检就被挤后」,没有任何错误码。 */
static void test_priority_order_is_safety_highest_log_lowest(void)
{
const int safety = task_desc(TASK_SAFETY)->prio;
const int thermal = task_desc(TASK_THERMAL)->prio;
const int actuator = task_desc(TASK_ACTUATOR)->prio;
const int link = task_desc(TASK_LINK)->prio;
const int log = task_desc(TASK_LOG)->prio;
TEST_ASSERT_GREATER_THAN_INT(thermal, safety);
TEST_ASSERT_GREATER_THAN_INT(actuator, safety);
TEST_ASSERT_EQUAL_INT(thermal, actuator); /* 两个都是「中」 */
TEST_ASSERT_GREATER_THAN_INT(link, thermal);
TEST_ASSERT_GREATER_THAN_INT(log, link);
}
/* 巡检周期必须长于 INA226 的一帧转换时间(8.8 ms),否则同一帧会被读两次。
现象是「电流特别稳」,不是任何一种错误。 */
static void test_safety_period_outlasts_one_ina226_conversion(void)
{
TEST_ASSERT_GREATER_THAN_UINT32(INA226_CONV_US,
task_desc(TASK_SAFETY)->period_ms * 1000u);
}
static void test_every_task_has_a_name_and_a_real_stack(void)
{
for (int i = 0; i < TASK_COUNT; i++) {
const task_desc_t *d = task_desc((task_id_t)i);
TEST_ASSERT_NOT_NULL(d->name);
TEST_ASSERT_TRUE(d->name[0] != 0);
TEST_ASSERT_GREATER_OR_EQUAL_UINT32(2048, d->stack_bytes);
for (int j = i + 1; j < TASK_COUNT; j++) {
TEST_ASSERT_NOT_EQUAL(0, strcmp(d->name, task_desc((task_id_t)j)->name));
}
}
}
/* ---- 两把锁 --------------------------------------------------------------- */
static void test_take_and_give_round_trip(void)
{
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_init());
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C));
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
TEST_ASSERT_TRUE(hal_lock_held(HAL_LOCK_I2C));
hal_lock_give(HAL_LOCK_I2C);
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C));
}
/* 没 init 就拿 —— 真板上那是个空句柄,不拦就是随机崩。 */
static void test_take_before_init_is_refused(void)
{
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_I2C, 10));
}
/* 同一把锁拿两次:这两把都不是递归锁,第二次必须被拒。
悄悄通过的话,第一次 give 就把互斥放掉了,而代码看上去还在临界区里。 */
static void test_double_take_is_refused(void)
{
(void)hal_lock_init();
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_I2C, 10));
hal_lock_give(HAL_LOCK_I2C);
}
/* 拿着一把去拿另一把:这块板上永远是写错了(595 是纯 GPIO,和 I²C 没交集),
所以不定加锁顺序,直接判非法。见 hal_lock.h。 */
static void test_nesting_the_two_locks_is_refused_both_ways(void)
{
(void)hal_lock_init();
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_SR595, 10));
hal_lock_give(HAL_LOCK_I2C);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10));
TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_lock_take(HAL_LOCK_I2C, 10));
hal_lock_give(HAL_LOCK_SR595);
}
/* 别的任务持着的时候:等满超时、计一次数、返回 TIMEOUT,
而且**把那段时间真的花掉** —— 调用方跳过这一周期的路径因此测得到。 */
static void test_contention_times_out_and_costs_real_time(void)
{
(void)hal_lock_init();
sim_lock_set_task(1);
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, hal_lock_take(HAL_LOCK_I2C, 5));
TEST_ASSERT_EQUAL_UINT32(5, hal_time_elapsed_ms(t0));
TEST_ASSERT_EQUAL_UINT32(1, hal_lock_timeouts(HAL_LOCK_I2C));
TEST_ASSERT_FALSE(hal_lock_held(HAL_LOCK_I2C));
/* 1 号放掉之后 0 号才拿得到。 */
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_I2C);
sim_lock_set_task(0);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 5));
hal_lock_give(HAL_LOCK_I2C);
}
/* 两把锁互不相干:拿着 I²C 不影响另一个任务去拿 595。 */
static void test_the_two_locks_are_independent(void)
{
(void)hal_lock_init();
sim_lock_set_task(1);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_I2C, 10));
sim_lock_set_task(2);
TEST_ASSERT_EQUAL_INT(HAL_OK, hal_lock_take(HAL_LOCK_SR595, 10));
TEST_ASSERT_EQUAL_INT(1, sim_lock_owner(HAL_LOCK_I2C));
TEST_ASSERT_EQUAL_INT(2, sim_lock_owner(HAL_LOCK_SR595));
hal_lock_give(HAL_LOCK_SR595);
sim_lock_set_task(1);
hal_lock_give(HAL_LOCK_I2C);
}
int main(void)
{
UNITY_BEGIN();
RUN_TEST(test_period_is_the_interval_not_the_delay);
RUN_TEST(test_jittery_work_does_not_move_the_phase);
RUN_TEST(test_overrun_keeps_the_phase_and_is_counted);
RUN_TEST(test_a_long_stall_skips_instead_of_catching_up);
RUN_TEST(test_millisecond_wraparound_is_just_another_tick);
RUN_TEST(test_delay_from_is_pure);
RUN_TEST(test_task_table_passes_selftest);
RUN_TEST(test_periods_match_section_10_3);
RUN_TEST(test_priority_order_is_safety_highest_log_lowest);
RUN_TEST(test_safety_period_outlasts_one_ina226_conversion);
RUN_TEST(test_every_task_has_a_name_and_a_real_stack);
RUN_TEST(test_take_and_give_round_trip);
RUN_TEST(test_take_before_init_is_refused);
RUN_TEST(test_double_take_is_refused);
RUN_TEST(test_nesting_the_two_locks_is_refused_both_ways);
RUN_TEST(test_contention_times_out_and_costs_real_time);
RUN_TEST(test_the_two_locks_are_independent);
return UNITY_END();
}
+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();
}
+27
查看文件
@@ -376,6 +376,33 @@ PCA9555 读两个输入端口 ≈ 0.12 ms
余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次**
——这个数是按理想时序算的,没算时钟延展和重试。
> **A8 已落地**(2026-09-07,`firmware/main/{tasks,sched,t_safety}.c` +
> `components/phonerail_hal/{include/hal_lock.h,hal_i2c_acct.c}`)。五条实现约定:
>
> 1. **五个任务是一张 const 表**(`tasks.c`),周期与优先级写在一起,开机 `task_table_ok()`
> 自检,不过就 abort。查的是**相对顺序**:safety 唯一最高、log 唯一最低、
> thermal 与 actuator 同级、link 夹在中间;外加「巡检周期必须长于 INA226 的
> 一帧转换 8.8 ms」,否则同一帧被读两次,现象是「电流特别稳」。
> 2. **周期是间隔,不是延时。** `sched_periodic_*()` 记的是下一次该醒的**绝对时刻**
> (回绕安全,一律用 `int32_t` 差判先后)。写成「干完睡 20 ms」的话,
> 实际周期是 21.6 ms 且随总线忙闲抖动。过载时**跳过错过的整周期、保住相位**,
> 不补跑 —— 补跑的现象是「卡一下之后连着打满总线」。
> 3. **两把锁不许嵌套持有。** 595 是纯 GPIO、I²C 是纯总线,两者没有交集,
> 所以「拿着 A 去拿 B」永远是写错了,`hal_lock_take()` 直接判 `HAL_ERR_STATE`
> ——与其定一套加锁顺序再指望人人记得,不如让它不可能发生。ESP 侧用的是
> **mutex 不是 binary semaphore**(要优先级继承:t_link 拿着总线时
> t_safety 不能被中优先级任务无限期挡住)。
> 4. **巡检不死等锁**:等 5 ms(周期的 1/4)拿不到就跳过这一圈并计数。
> 死等的现象是最高优先级的任务整个停摆,而且没有任何人会发现。
> 同样地,**一个器件读失败不中断这一圈**,失败那一路保留上一圈的值并把 `ok` 标 false
> ——清零的话 A10 的过压判据会看到一个「正常」的 0 V。
> 5. **那 8 % 是算出来的,不是抄的。** `hal_i2c_acct` 在 HAL 里按线上真实字节数记账
> (两个目标同一份代码),`t_safety_budget_us()` 按事务形状静态预算,两者必须相等。
> 实测:一次寄存器读 **48** 个位周期(本节手算的 46 没数 STOP 和一个重复 START),
> 一圈 13 次事务 = **1560 µs / 20 ms = 7.8 %**,与本节的「约 1.5 ms / 约 8 %」相符。
> 多读一次寄存器就会让静态预算与实测对不上,测试当场变红。
> 首板仍要按本节末尾那句用逻辑分析仪复核一次(这个数不含时钟延展与重试)。
### 10.4 五路一张表:禁止用通道号算任何东西
这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序: