A10:安全巡检的判据(SR-5 / SW-8)

判据全在 t_safety_step() 里、**放锁之后**、直接写引脚(§10.3 原话:不经任何
队列或抽象层):

  SR-5   |实测 − 该路设定值| > 100 mV **连着两圈** → 先断 VBUS 再关 eFuse
  SW-8   I < −50 mA,或 VBUS 期间电压被顶高 > +100 mV → **一圈就断 VBUS**

七条实现约定(详见需求文档 §10.2):

· 「持续 20 ms」数的是圈,不是墙钟毫秒;
· 反灌不设确认窗口,与过压那 20 ms 的不对称是有意的 —— 误断 VBUS 只丢一次
  USB 数据,漏判会把能量泵回 12V 母线连累另外四台(设计文档 §15.2);
· 判据只在「该路该有电 + 这一圈的值新鲜 + 过了 60 ms 缓启动窗口」时武装
  (对应 §10.10 的「盲跑 → 保持供电,禁用该路判据」);
· 比的是该路 DAC 设定值的影子,不是 3900 这个常数(SW-4 要单路微调);
· SR-5 关路关干净(不留「只靠 USB 供着电」的窗口),SW-8 只断 VBUS 不碰 eFuse;
· 断电之前先 t_actuator_abort(),断 VBUS 时只打断 EDL(只有它写 VBUS_EN);
· 动作在前、记录在后,事件走回调出去,A11 在那儿接环形缓冲。

判据一条 I²C 事务都不发(设定值、两根 EN、正在跑的序列全部走影子),
§10.3 那 7.8 % 的预算不变。顺带新增两处影子:rail_efuse_is_on()/rail_vbus_is_on()
与 rail_setpoint_mv(),都**只在写成功时更新**,两个方向都倒向安全的那一侧。

29 个用例;25 条注错逐个跑过,24 条变红。第一版把「持续 20 ms」按墙钟算,
测试一半红一半绿 —— 巡检的采样时刻带亚毫秒残差(一圈占 1.56 ms 总线,
而睡到下一拍按整毫秒算),相邻两次判据的时刻是 21/19 ms 交替,19 ms 那一半
要拖到第三圈 = 40 ms,超验收且间歇性。改成数圈之后一次不飘,
并留了一条跑 20 轮、两种相位都踩得到的回归拦网。

Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-07 08:41:53 +08:00
共同撰写人 Claude Opus 5
父节点 b3206005ab
当前提交 273c53347e
共修改 11 个文件,包含 1354 行新增和 206 行删除
+17 -1
查看文件
@@ -70,6 +70,17 @@ static bool act_pg_from_safety(void *ctx, int rail, bool *pg)
return true;
}
/* A10 的事件出口。**A11 会把这里换成环形缓冲的入队**(§10.7:`t_safety` 只入队、
不落盘),在那之前先打到日志上 —— 一条也不留才是最坏的结果。
⚠️ 这个回调是在断电之后、不持任何锁的情况下被调的,所以里面不许阻塞。 */
static void log_safety_event(void *ctx, const safety_event_t *ev)
{
(void)ctx;
ESP_LOGW(TAG, "安全事件 t=%u ms CH%u %s V=%d mV I=%d uA", (unsigned)ev->t_ms,
(unsigned)ev->rail, t_safety_ev_str((safety_ev_t)ev->type),
(int)ev->bus_mv, (int)ev->current_ua);
}
static void task_safety(void *arg)
{
(void)arg;
@@ -187,9 +198,14 @@ void app_main(void)
与**降级矩阵**是 A15 的启动顺序那一包。在那之前 t_safety 读回来的电流恒为 0
(Calibration 还没写),这是预期的,不是故障。 */
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。 */
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。
⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`,
而没 init 的执行器只会直接返回,那时序列还在跑,电关掉几秒又会被抬回来。 */
t_actuator_init(act_pg_from_safety, NULL);
/* 事件出口。同样要在巡检起来之前接上,否则第一批事件没人接。 */
t_safety_set_event_sink(log_safety_event, NULL);
spawn(TASK_SAFETY, task_safety);
spawn(TASK_THERMAL, task_thermal);
spawn(TASK_ACTUATOR, task_actuator);
+1 -1
查看文件
@@ -3,7 +3,7 @@
#define PHONERAIL_H
/* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */
#define PHONERAIL_FW_VERSION "0.1.0-a9"
#define PHONERAIL_FW_VERSION "0.1.0-a10"
/* 通道数。五路直供,见设计文档 §2。 */
#define PHONERAIL_RAIL_COUNT 5
+33 -2
查看文件
@@ -65,6 +65,29 @@ hal_err_t rail_volt_init(void)
return HAL_OK;
}
/* 五路设定值的影子(契约见 rail.h)。初值就是 DAC 的 NVM 出厂默认,
所以 `rail_volt_init()` 跑之前它也说的是实话。 */
static int s_set_mv[PHONERAIL_RAIL_COUNT] = {
RAIL_MV_SAFE, RAIL_MV_SAFE, RAIL_MV_SAFE, RAIL_MV_SAFE, RAIL_MV_SAFE,
};
_Static_assert(PHONERAIL_RAIL_COUNT == 5, "s_set_mv 的初值列表要跟着改");
int rail_setpoint_mv(int rail)
{
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return RAIL_MV_SAFE;
}
return s_set_mv[rail - 1];
}
void rail_setpoint_reset(void)
{
for (int i = 0; i < PHONERAIL_RAIL_COUNT; i++) {
s_set_mv[i] = RAIL_MV_SAFE;
}
}
hal_err_t rail_set_mv(int rail, int mv)
{
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
@@ -73,8 +96,16 @@ hal_err_t rail_set_mv(int rail, int mv)
if (!rail_mv_in_range(mv)) {
return HAL_ERR_ARG; /* SR-13。TODO(A11):这里要记一条事件 */
}
return dac63204_write_code(dac63204_rail_addr(rail), dac63204_rail_ch(rail),
rail_mv_to_code(mv));
const hal_err_t err = dac63204_write_code(dac63204_rail_addr(rail),
dac63204_rail_ch(rail),
rail_mv_to_code(mv));
if (err == HAL_OK) {
/* 记的是**请求的 mV**,不是 `rail_code_to_mv(码)` —— 两者差不到一个码
(118.75 µV),但 A10 的判据是拿它当「设定值」用的,回读换算会让
「设成 3900」在判据眼里变成 3899,白白吃掉 1 mV 的余量。 */
s_set_mv[rail - 1] = mv;
}
return err;
}
hal_err_t rail_get_mv(int rail, int *mv)
+17
查看文件
@@ -71,6 +71,23 @@ hal_err_t rail_set_mv(int rail, int mv);
实测要走该路的 INA226(A10 的巡检)。 */
hal_err_t rail_get_mv(int rail, int *mv);
/* 最后一次**成功**写进那一路 DAC 的设定值(mV)。不碰总线。
A10 的 SR-5 判据要的就是这个「设定值」(`|实测 − 设定| > 100 mV`)。
不用 `rail_get_mv()` 是因为它是一次 I²C 事务:巡检每 20 ms 跑一圈,
五路各多一次读就是每圈多 5 次事务,把 §10.3 那 7.8 % 的预算顶到 11 %,
而这个数是编译期就知道的。
上电默认是 `RAIL_MV_SAFE`(3900)—— 那不是猜的,是 DAC63204 **NVM 里的
出厂默认值**(rail.h 开头那段),所以在 `rail_volt_init()` 跑之前它也是真的。
写失败时不更新:那一次 DAC 里到底是什么值不知道,留着上一个成功的更可信。 */
int rail_setpoint_mv(int rail);
/* 把五路的设定值影子放回 `RAIL_MV_SAFE`,**不碰 DAC**。
给两处用:宿主机测试的 `setUp()`(`sim_hal_reset()` 够不到应用层的静态变量),
以及 A15 在「DAC 无应答」降级路径上声明「现在生效的是 NVM 默认值」。 */
void rail_setpoint_reset(void);
#ifdef __cplusplus
}
#endif
+27 -2
查看文件
@@ -218,6 +218,21 @@ static hal_err_t en_write(int pin, bool on)
return hal_gpio_write(pin, on ? 1 : 0);
}
/* 两根线的影子(契约见 rail_cfg.h)。写失败时**不更新** —— 那一次的线上电平
是未知的,而「保留上一次成功的意图」在两个方向上都倒向安全的那一侧。 */
static bool s_efuse_on[PHONERAIL_RAIL_COUNT];
static bool s_vbus_on[PHONERAIL_RAIL_COUNT];
bool rail_efuse_is_on(int rail)
{
return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_efuse_on[rail - 1];
}
bool rail_vbus_is_on(int rail)
{
return rail >= 1 && rail <= PHONERAIL_RAIL_COUNT && s_vbus_on[rail - 1];
}
hal_err_t rail_en_init(void)
{
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
@@ -236,6 +251,8 @@ hal_err_t rail_en_init(void)
return err;
}
}
s_efuse_on[rail - 1] = false;
s_vbus_on[rail - 1] = false;
}
return HAL_OK;
}
@@ -245,7 +262,11 @@ hal_err_t rail_efuse_enable(int rail, bool on)
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return HAL_ERR_ARG;
}
return en_write(k_cfg[rail - 1].efuse_en, on);
const hal_err_t err = en_write(k_cfg[rail - 1].efuse_en, on);
if (err == HAL_OK) {
s_efuse_on[rail - 1] = on;
}
return err;
}
hal_err_t rail_vbus_enable(int rail, bool on)
@@ -253,7 +274,11 @@ hal_err_t rail_vbus_enable(int rail, bool on)
if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) {
return HAL_ERR_ARG;
}
return en_write(k_cfg[rail - 1].vbus_en, on);
const hal_err_t err = en_write(k_cfg[rail - 1].vbus_en, on);
if (err == HAL_OK) {
s_vbus_on[rail - 1] = on;
}
return err;
}
hal_err_t rail_all_off(void)
+14
查看文件
@@ -106,6 +106,20 @@ hal_err_t rail_en_init(void);
hal_err_t rail_efuse_enable(int rail, bool on);
hal_err_t rail_vbus_enable(int rail, bool on);
/* 这两根线**最后一次成功写进去**的值(上电与 `rail_en_init()` 之后都是 false)。
存在的理由是 A10 的判据要知道「这一路现在该不该有电」:关着的路电压本来就是 0,
拿它去和设定值比,每一圈都会得出「偏低 3900 mV」,于是巡检会没完没了地
「再关一次已经关着的路」并刷事件。判据只在这两个影子为 true 时武装。
⚠️ **只在写成功时更新**,而且方向是有讲究的:关的那一次写失败 ⇒ 影子留在 on
⇒ 判据继续武装(还会再关一次);开的那一次写失败 ⇒ 影子留在 off
⇒ 判据不武装(不会对着一路没开成的电报偏差)。两边都倒向安全的那一侧。
—— 不去读回引脚,是因为 `hal_gpio_read()` 读输出脚在两个目标上的语义不一样,
而这里要的本来就是「固件的意图」,不是「线上的电平」。 */
bool rail_efuse_is_on(int rail);
bool rail_vbus_is_on(int rail);
/* 五路全关:**先断 VBUS 再关 eFuse**,中间不留「只靠 USB 供着电」的窗口。
十个脚全部试一遍,不因为某一个失败就提前返回 —— 关电路径上「剩下九个也别关了」
是最坏的选择。返回第一个错误码(全成功就是 HAL_OK)。
+279 -118
查看文件
@@ -1,118 +1,279 @@
#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));
}
#include "t_safety.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hal_i2c_acct.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "rail.h"
#include "rail_cfg.h"
#include "t_actuator.h"
#include "tasks.h"
/* ---- A10:两条判据 --------------------------------------------------------
§10.3 原话:「越界**直接**操作 `EFUSE_EN` / `VBUS_EN`(GPIO 直连,不经任何
队列或抽象层)」。所以下面这几个函数直接调 `rail_*_enable()`,不发消息给
t_actuator —— 发消息就等于把「关电」排在了一个中优先级任务的队列后面。
四条不能改的规矩:
1. **判据只在放锁之后跑。** 两根 EN 线是 GPIO,和 I²C 没有关系;拿着总线锁
去写引脚只会把别的任务多挡一会儿。而且 `t_actuator_abort()` 会去拿 595
那把锁,拿着 I²C 锁进去就是嵌套(`hal_lock_take()` 会直接判 HAL_ERR_STATE)。
2. **先 `t_actuator_abort()`,再断电。** 反过来有个真实的洞:硬重启的
`HR_OFF` 状态会在 4 s 后自己把 `EFUSE_EN` 抬回去,于是「关掉的路」几秒钟
之后又带电了。abort 之后那一路只会走收尾(只碰 595),再也够不到 EN 线。
代价是 abort 最坏会等 595 那把锁 20 ms(`ACT_LOCK_TIMEOUT_MS`),也就是
巡检可能丢一拍 —— 认了:SR-5 原文写着它「仅承担记录与告警,不承担实时保护」,
真正的过压保护是 eFuse 里的硬件 OVP,不靠这条路径的延迟。
3. **判据只在「这一路该有电」而且「这一圈的值是新鲜的」时武装。**
关着的路电压本来就是 0,不设这一条的话每一圈都会得出「偏低 3900 mV」;
而器件失联那一路的值是上一圈的,§10.10 明写着「保持供电,禁用该路的
SR-5 / SW-8 判据并持续告警」。
4. **一条判据也不许发 I²C 事务。** 判据要的三个量(设定值、两根 EN 的状态、
正在跑的序列)全部有影子可查。多读一次就把 §10.3 那 7.8 % 的预算顶穿,
`test_one_cycle_is_exactly_thirteen_transactions()` 会当场变红。 */
static safety_ev_fn s_sink;
static void *s_sink_ctx;
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx)
{
s_sink = fn;
s_sink_ctx = ctx;
}
const char *t_safety_ev_str(safety_ev_t ev)
{
switch (ev) {
case SAFETY_EV_NONE:
return "无";
case SAFETY_EV_OVER_VOLT:
return "过压关路(SR-5)";
case SAFETY_EV_UNDER_VOLT:
return "欠压关路(SR-5)";
case SAFETY_EV_REVERSE:
return "反灌断VBUS(SW-8)";
case SAFETY_EV_PUSHED_UP:
return "被顶高断VBUS(SW-8)";
default:
return "?";
}
}
/* 记一条。**永远在动作之后调** —— 记录慢一步没关系,断电慢一步不行。 */
static void emit(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
const ina226_vi_t *vi = &s->rail[rail - 1];
s->last.t_ms = now;
s->last.rail = (uint8_t)rail;
s->last.type = (uint8_t)ev;
s->last.bus_mv = vi->bus_mv;
s->last.current_ua = vi->current_ua;
s->events++;
if (s_sink != NULL) {
s_sink(s_sink_ctx, &s->last); /* TODO(A11):这里接环形缓冲 */
}
}
/* SR-5:关这一路。**先断 VBUS 再关 eFuse** —— 只关 eFuse 的话,插着 USB 的那几路
会停在「手机只靠 VBUS 供着电」,那既不是开也不是关(`rail_all_off()` 里同一条
理由)。两个都要试,不因为第一个失败就不关第二个。 */
static void trip_rail(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
t_actuator_abort(rail); /* 规矩 2:必须在断电之前 */
(void)rail_vbus_enable(rail, false);
(void)rail_efuse_enable(rail, false);
emit(s, rail, ev, now);
}
/* SW-8:只断这一路的 VBUS,**不碰 eFuse** —— 反灌的能量是从 USB 那一侧来的,
把直供也一起关掉等于「因为充电器有问题就把手机关机」,而 §10.2 的整节都在讲
「范围可控的时候只动出事的那一处」。 */
static void cut_vbus(safety_snap_t *s, int rail, safety_ev_t ev, uint32_t now)
{
/* 三个序列里只有 EDL 会去写 `VBUS_EN`(`EDL_EN=1 → VBUS_EN=1 → 等枚举`)。
它正跑着的话必须打断,否则下一拍它就把 VBUS 又抬回来了。
硬重启 / 按键不碰 VBUS,没有理由为一次反灌把它们打断。 */
if (t_actuator_seq(rail) == ACT_SEQ_EDL) {
t_actuator_abort(rail);
}
(void)rail_vbus_enable(rail, false);
emit(s, rail, ev, now);
}
static void check_rail(safety_snap_t *s, int rail, uint32_t now)
{
safety_rail_t *st = &s->st[rail - 1];
const bool en = rail_efuse_is_on(rail);
/* `EFUSE_EN` 的上升沿:缓启动窗口从这一刻算起。 */
if (en && !st->en_on) {
st->en_ms = now;
}
st->en_on = en;
/* 规矩 3 的三道闸。任何一道不过就把圈数清零 —— 「关着的那几秒」
不能算进「持续 20 ms」里,不然一开机就跳闸。
(缓启动窗口是真按毫秒算的:它对应的是 tON + 斜坡那段物理时间,
不像「持续 20 ms」那样是在数样本。) */
if (!en || !s->rail_ok[rail - 1] || (now - st->en_ms) < T_SAFETY_SETTLE_MS) {
st->dev_n = 0;
return;
}
const int32_t mv = s->rail[rail - 1].bus_mv;
const int32_t ua = s->rail[rail - 1].current_ua;
const int32_t dev = mv - (int32_t)rail_setpoint_mv(rail);
/* ---- SW-8(先跑):只在 VBUS 通着的时候武装 -------------------------
反灌的能量只可能从 VBUS 那一侧来,断开之后判据自然就解除武装了 ——
这也是「关掉之后不会每 20 ms 再刷一条事件」的机制。
先跑它是因为过压这一种情况两条判据都成立:该先撤掉能量来源(VBUS),
20 ms 后 SR-5 再决定要不要连这一路一起关掉。 */
if (rail_vbus_is_on(rail)) {
if (ua < T_SAFETY_REV_UA) {
cut_vbus(s, rail, SAFETY_EV_REVERSE, now);
} else if (dev > T_SAFETY_DEV_MV) {
cut_vbus(s, rail, SAFETY_EV_PUSHED_UP, now);
}
}
/* ---- SR-5:±100 mV 连着 T_SAFETY_DEV_HOLD_CYCLES 圈(= 持续 20 ms)---- */
if (dev > T_SAFETY_DEV_MV || dev < -T_SAFETY_DEV_MV) {
if (st->dev_n < 0xFF) {
st->dev_n++;
}
if (st->dev_n >= T_SAFETY_DEV_HOLD_CYCLES) {
trip_rail(s, rail, dev > 0 ? SAFETY_EV_OVER_VOLT : SAFETY_EV_UNDER_VOLT, now);
/* ⚠️ 这里**故意不**把 `dev_n` 清零。关掉之后下一圈 `en` 就是 false,
上面那道闸自己会清;而万一那次写引脚失败了(影子留在 on),
下一圈 `dev_n` 已经够数,会**立刻再关一次** —— 清零的话得再等两圈。
关电路径上「重试快一点」永远是对的那一边。 */
}
} else {
st->dev_n = 0; /* 「持续」的意思是连续,中间回到范围内就从头数 */
}
}
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);
/* 判据:放锁之后跑,直接写 EFUSE_EN / VBUS_EN(上面那四条规矩)。
`now` 只取一次 —— 五路要拿同一个时刻去算「持续了多久」。 */
{
const uint32_t now = hal_time_now_ms();
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
check_rail(s, rail, now);
}
}
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%% "
"events=%u",
(unsigned)s->cycles, (unsigned)s->skipped, (unsigned)s->i2c_errors,
(unsigned)s->bus_us, (unsigned)period, (unsigned)(pm / 10u),
(unsigned)(pm % 10u), (unsigned)s->events);
}
+90 -4
查看文件
@@ -1,7 +1,7 @@
/* `t_safety` —— 20 ms 巡检任务的**一个周期**(需求 §10.3 第一行)。
本包(A8)只做骨架:拿 I²C 锁 → 读 6 片 INA226 的 V+I → 读 PCA9555 两个输入端口
→ 放锁 → 记账。**判据(SW-2 / SW-8 / 过压 / 反灌)是 A10 的事**,这里一条都没有。
一个周期干四件事:拿 I²C 锁 → 读 6 片 INA226 的 V+I 与 PCA9555 两个输入端口
→ 放锁 → **跑判据**(A10:SR-5 / SW-8,见本文件下半段)。
分成「一个周期是个函数」而不是直接写成 `while(1)`,是为了让它在宿主机上跑得起来:
宿主机没有 FreeRTOS,任务体是死循环就一行也测不了。ESP 侧的 `while(1)`
@@ -13,7 +13,7 @@
2. **一个器件读失败不能中断这一圈。** 六片里坏一片,剩下五路的判据照常要出。
失联 ≠ 断电(§10.2),失联更不等于「别的路也不看了」。
3. **越界要断电时直接写 `EFUSE_EN` / `VBUS_EN` 引脚**(§10.3 原话:不经任何队列
或抽象层)。所以 A10 的判据要写在这个函数里,不是发个消息给 t_actuator。 */
或抽象层)。所以判据就写在这个函数里,不是发个消息给 t_actuator。 */
#ifndef T_SAFETY_H
#define T_SAFETY_H
@@ -34,9 +34,91 @@ extern "C" {
而**废一个周期**(20 ms 后重来)比**赖着不走**(把最高优先级任务钉死)好得多。 */
#define T_SAFETY_LOCK_TIMEOUT_MS 5
/* ---- A10:两条判据的阈值。**每一个都有出处,改了就是改需求** ------------
`test_thresholds_match_the_documents()` 会拦。 */
/* SR-5 / SW-2:「电压偏离设定 >±100 mV」。比的是 INA226 读回来的母线电压与
**该路 DAC 的设定值**(`rail_setpoint_mv()`),不是 3900 这个常数 ——
SW-4 允许运维单路微调,拿常数比会把一台调到 4.0 V 的机器当成过压。 */
#define T_SAFETY_DEV_MV 100
/* SR-5 的「且持续 20 ms」——「持续」在这里数的是**圈**,不是墙钟毫秒:
连着两圈新鲜样本都越界才算,而成立的那一刻就是断电的那一刻。
⚠️ **不要改回按墙钟算**(`now - 第一次看见 >= 20 ms`)。巡检的采样时刻带着
亚毫秒残差:一圈自己要占 1.56 ms 总线,而「睡到下一拍」是按整毫秒算的,
于是相邻两次判据的时刻实测是 **21 / 19 ms 交替**。拿 `>= 20 ms` 去判,
有一半的机会 19 ms 那一次不算数、要等到第三圈 —— 也就是 40 ms 才关路,
直接超了 §12.1 的验收,而且是**间歇性**的:同一个故障注两次,一次过一次不过。
数圈没有这个毛病,而「两圈」在 20 ms 的周期下就是 20 ms。
⚠️ SR-5 说「持续 20 ms 才算」,§12.1 的验收说「20 ms 内关路」——
字面上互相顶着,只有「从第一次看见算起、下一圈就断电」同时满足两条。
注意这是从**第一次看见**算的:物理事件最早可能发生在上一圈之后,
所以对着外部世界的最坏延迟是 2 个周期。这不要紧 —— SR-5 原文就写着
「仅承担记录与告警,不承担实时保护」,真正的过压保护是 eFuse 里的硬件 OVP。 */
#define T_SAFETY_DEV_HOLD_CYCLES 2
/* 上面那个圈数折算成毫秒 = (圈数 − 1) × 巡检周期。需求 SR-5 写的就是这个 20 ms,
`test_thresholds_match_the_documents()` 拿它和任务表里的周期对账 ——
哪天巡检周期改了,这两个数会当场对不上,而不是悄悄把判据变成 40 ms。 */
#define T_SAFETY_DEV_HOLD_MS 20
/* SW-8 的反灌判据:设计文档 §15.2 对策 2 原话「if I < −50 mA → 立即拉低 VBUS_EN」。
离噪声很远:INA226 电流 LSB 是 0.5 mA,分流失调 ±10 µV / 5 mΩ ≈ ±2 mA,
−50 mA 是它的 25 倍。§12.1 的验收注入 −60 mA,落在阈值外侧。 */
#define T_SAFETY_REV_UA (-50000)
/* 反灌**不做确认窗口**(一圈看见就断),这和上面 20 ms 的过压判据是有意不对称的:
· 误断 VBUS 的代价是丢一次 USB 数据,手机还由直供电着,没有任何风险;
· 漏判的代价是能量泵回 12V 母线,**连累另外四台**(设计文档 §15.2 末尾)。
一圈就断 ⇒ 最坏 40 ms(事件发生 + 一圈),SW-8 给的预算是 200 ms,5 倍余量。 */
#define T_SAFETY_REV_BUDGET_MS 200
/* `EFUSE_EN` 拉高之后多久才开始判这一路的电压。tON 5.1 ms + 缓启动斜坡 11.5 ms
≈ 17 ms(设计文档 §5 / §7.4),取 3 个巡检周期。
没有这个窗口的现象是**每次上电都自己把自己关掉**:斜坡走到一半时
实测电压离设定值差着好几百 mV,判据不认识「正在爬」和「掉下去了」的区别。 */
#define T_SAFETY_SETTLE_MS 60
/* ---- A10:事件(§10.7 的「单条」形状:时间戳 + 路号 + 类型 + 当时的 V/I)----
A11 会把这条记录塞进环形缓冲,这里只负责**产生**它。故意先把记录类型定下来,
A11 就只剩「缓冲 + 落盘」,不用回头再动判据这一层。 */
typedef enum {
SAFETY_EV_NONE = 0,
SAFETY_EV_OVER_VOLT, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_UNDER_VOLT, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */
SAFETY_EV_REVERSE, /* SW-8:电流转负(I < −50 mA)→ 断这一路 VBUS */
SAFETY_EV_PUSHED_UP, /* SW-8:VBUS 期间电压被顶高 >+100 mV → 断这一路 VBUS */
} safety_ev_t;
typedef struct {
uint32_t t_ms; /* 上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */
uint8_t rail; /* 1~5 */
uint8_t type; /* safety_ev_t */
int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */
int32_t current_ua;
} safety_event_t; /* 16 B —— §10.7「单条 16 B 量级」 */
/* 事件出口。A11 在这里接环形缓冲;不接就只有快照里的 `last` 与计数。
⚠️ 回调是在**断电之后**才调的(先保命,再记账),而且此刻两把锁都没拿着 ——
接进来的东西不许阻塞,也不许回头调 t_safety_*。 */
typedef void (*safety_ev_fn)(void *ctx, const safety_event_t *ev);
void t_safety_set_event_sink(safety_ev_fn fn, void *ctx);
const char *t_safety_ev_str(safety_ev_t ev);
/* 每路的判据状态。全在快照里,`t_safety_init()` 一并清干净。 */
typedef struct {
bool en_on; /* 上一圈看到的 `EFUSE_EN` 影子,用来认它的上升沿 */
uint32_t en_ms; /* 上升沿的时刻,缓启动窗口从这里算(这个是真按毫秒的) */
uint8_t dev_n; /* 连着几圈越界了。回到范围内 / 这一路被关掉就清零 */
} safety_rail_t;
typedef struct {
/* 这一圈读回来的原始量。`ok` 为 false 时对应的值是**上一圈的**,不是 0 ——
判据(A10)必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
判据必须先看 ok 再看值,拿一个没更新的电压去判过压是最难查的那种错。 */
ina226_vi_t rail[PHONERAIL_RAIL_COUNT];
bool rail_ok[PHONERAIL_RAIL_COUNT];
ina226_vi_t input; /* 输入侧 U13(0x43),整板功率预算 */
@@ -44,6 +126,10 @@ typedef struct {
pca9555_in_t io; /* 五路 FLT# / PGOOD + Buck PG 或 + INA ALERT 或 */
bool io_ok;
safety_rail_t st[PHONERAIL_RAIL_COUNT]; /* 每路的判据状态(内部用) */
safety_event_t last; /* 最近一条事件;`last.type == SAFETY_EV_NONE` = 还没出过 */
uint32_t events; /* 出过几条事件 */
uint32_t cycles; /* 跑过几圈(含被跳过的) */
uint32_t skipped; /* 其中因为拿不到 I²C 锁而整圈跳过的 */
uint32_t i2c_errors; /* 累计器件级失败次数(一圈里可能不止一次) */