diff --git a/App/algo/run_par.c b/App/algo/run_par.c index de64e3dd..3f4e26ab 100644 --- a/App/algo/run_par.c +++ b/App/algo/run_par.c @@ -77,10 +77,21 @@ static uint8_t s_dirty; /* 内部函数实现区 */ +/* + * 函数功能:毫单位定点 → 工程量浮点(**本模块唯一换算点**) + * 入口参数:milli - 毫单位 内 int32_t + * 返 回 值:工程量 内 float + * 限定条件:仅本文件内使用(消费侧一律走 run_par_deadband()/run_par_db_range(), + * 不许各自再写一次 /1000——那才是"第二真源"最常见的样子) + */ +static float s_milli_to_eng(int32_t milli) { + return (float)milli / 1000.0f; +} + /* * 函数功能:死区点量程上限(毫单位) * 入口参数:idx - 0..8 uint8_t - * 返 回 值:上限;idx 越界返 0 长(一切值皆拒) 内 int32_t + * 返 回 值:上限;idx 越界返 0 内 int32_t */ static int32_t s_db_cap_milli(uint8_t idx) { if (idx < 4u) { @@ -156,7 +167,29 @@ float run_par_deadband(uint8_t idx) { if (idx >= RUN_PAR_DB_N) { return 0.0f; } - return ((float)s_db.db[idx]) / 1000.0f; /* 毫→工程唯一换算点 */ + return s_milli_to_eng(s_db.db[idx]); /* 毫→工程:唯一换算点 = s_milli_to_eng */ +} + +/* + * 函数功能:取第 idx 路死区的合法域(工程量)——**合法域的唯一出口** + * 入口参数:idx - 0..8 uint8_t;lo/hi - 输出下限/上限(工程量 V/A/Hz) float * + * 返回值:0 - 已写出;-1 - idx 越界或指针为空(调用方不得回退成自造默认域) int + * 限定条件:任意上下文(纯常量读,无锁、无 I/O) + * 函数说明:为什么公开这个口:UI 整定页要给数字编辑器设 min/max,而这些上限的 + * 真源是本模块的 RUN_PAR_DB_MAX_*(电压 50V / 电流 1A / 频率 5Hz, + * "额定 × 二档裕量")。页面里抄一份就是第二真源——判据「不新增第二份 + * 真源」最典型的复发形态是"顺手把常量写进界面代码"。因此本函数的 + * 消费者是装配层 App/app/ui_data_feed.c 的注入表实现,不是页面本身。 + * ⚠ 给的是**粗守域**(挡误写),不是工况域;真提交仍由 s_db_check + * 同源守卫裁(104 选择段与 UI 编辑器共用同一批常量,不会漂移)。 + */ +int run_par_db_range(uint8_t idx, float *lo, float *hi) { + if ((idx >= RUN_PAR_DB_N) || (lo == NULL) || (hi == NULL)) { + return -1; + } + *lo = 0.0f; + *hi = s_milli_to_eng(s_db_cap_milli(idx)); + return 0; } /* @@ -198,19 +231,74 @@ int run_par_104_read(uint32_t addr, float *out) { } /* - * 函数功能:104 运行参数地址写(两段式;k/b 直通 adc_cal_set——**run_par 不存 + * 函数功能:写某路系数 k(µV/码)**并在同一函数内置脏**——整定的语义写入口 + * 入口参数:sem - 电气语义通道 adc_semantic_t;k_uv - 系数(µV/码,>0) float + * 返回值:0 - RAM 副本已写 + 已置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int + * 限定条件:整定路径上下文(iec104Task 的 104 执行段;UI 经 ui_data_yt_ops_t 由 + * 装配层 App/app/ui_data_feed.c 转进来)。勿在 adcTask 调用 + * 函数说明:**为什么"写值 + 置脏"必须长在一个函数里**(lead 裁②,2026-10-27): + * 置脏是持久化承诺,写值是 RAM 事实,两者拆开就会出现"屏上已改、盘上 + * 永不落、且没有任何错误返回"这种无人报错的状态——本仓今天已实测到它的 + * 既有形态(绕过本模块直调 adc_cal_set() 的写者不置脏,值永远不落盘, + * 另立 task-43 处置)。所以这里不提供单独的 mark_dirty 公开口:它一旦 + * 存在就一定有人只调它(同"零消费者的状态查询口"那条教训)。 + * 零点 b 走 RMW 取**当前生效值**(经合法带哨兵,天然满足再写校验)。 + * 粗守(0 < k ≤ RUN_PAR_K_MAX_UV)只为挡误写,真带在 adc_cal 执行段裁: + * 两层各说各的,页面上"合法域内仍被拒(ERR)"就是执行段拒的,不伪装。 + */ +int run_par_cal_k_set(adc_semantic_t sem, float k_uv) { + int32_t k_q16; + int32_t b_codes; + + /* 通道号不在这里判:adc_cal_set 自带合法性与合法带裁决(越界一律 -1), + * 本层再判一次就是第二真源。粗守只挡误写:0 < k ≤ RUN_PAR_K_MAX_UV */ + if (!isfinite(k_uv) || !(k_uv > 0.0f) || (k_uv > RUN_PAR_K_MAX_UV)) { + return -1; + } + k_q16 = (int32_t)lrintf(k_uv * 65536.0f); + b_codes = (int32_t)lrintf(adc_cal_b_codes(sem)); + if (adc_cal_set(sem, k_q16, b_codes) != 0) { + return -1; /* 执行段拒:副本未动,脏位也不置 = 不承诺任何持久化 */ + } + s_dirty = 1u; + return 0; +} + +/* + * 函数功能:写某路零点偏移 b(码值)并置脏(与 k 同款语义,粗守 ±1e6 码) + * 入口参数:sem - 电气语义通道 adc_semantic_t;b_codes - 零点(码值) int32_t + * 返回值:0 - 已写并置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int + * 限定条件:同 run_par_cal_k_set() + * 函数说明:k 侧 RMW 用当前生效值;±8192 真带由 adc_cal 执行段裁(粗守目的是 + * 挡误写不是限工况)。本函数的消费者 = 104 的 0x8048~ 分支(改造后), + * 不是"为将来预留"的口 + */ +int run_par_cal_b_set(adc_semantic_t sem, int32_t b_codes) { + int32_t k_q16; + + if (((float)b_codes < -1.0e6f) || ((float)b_codes > 1.0e6f)) { + return -1; + } + k_q16 = (int32_t)lrintf(adc_cal_k_uv(sem) * 65536.0f); + if (adc_cal_set(sem, k_q16, b_codes) != 0) { + return -1; + } + s_dirty = 1u; + return 0; +} + +/* + * 函数功能:104 运行参数地址写(两段式;k/b 经本模块语义 setter——**run_par 不存 * 系数**,系数唯一真源 = [adc_cal]/[adc_cal_ofs],lead 裁②a) * 返 回 值:0 接受 / -1 拒绝 / -2 非本段(调用方落 prot_set) int * 函数说明:选择段 = 校验不提交(死区走与 set 同源守卫;k/b 的 adc_cal 合法带 * 由执行段裁决——IEC104 允许执行段 PN,两段结果可不一致,注释在此 - * 明说防"选择过了怎么还 PN"的现场困惑);k/b 单侧写 RMW 用**当前 - * 生效值**做另一侧(生效值经合法带哨兵,天然满足再写校验)。 + * 明说防"选择过了怎么还 PN"的现场困惑)。k/b 两支自 2026-10-27 起一律 + * 走 run_par_cal_k_set() / run_par_cal_b_set():写值与置脏在同一个函数 + * 里发生,本函数不再自己碰 s_dirty(lead 裁②:消除两份置脏逻辑)。 */ int run_par_104_write(uint32_t addr, float v, uint8_t select) { uint8_t slot; /* 每个命中分支先赋值再使用(无未init读路径) */ - int32_t k_q16; - int32_t b_codes; - int rc; if ((addr >= RUN_PAR_IOA_DB0) && (addr < RUN_PAR_IOA_DB0 + RUN_PAR_DB_N)) { int32_t milli; @@ -234,29 +322,20 @@ int run_par_104_write(uint32_t addr, float v, uint8_t select) { if (select != 0u) { return 0; /* 粗守过了 ≠ 执行必过:合法带裁决在执行段(见函数头) */ } - k_q16 = (int32_t)lrintf(v * 65536.0f); - b_codes = (int32_t)lrintf(adc_cal_b_codes((adc_semantic_t)slot)); - rc = adc_cal_set((adc_semantic_t)slot, k_q16, b_codes); - if (rc == 0) { - s_dirty = 1u; - } - return rc; + /* 写值 + 置脏收进语义 setter(粗守 0= RUN_PAR_IOA_B0) && (addr < RUN_PAR_IOA_B0 + RUN_PAR_CH_N)) { slot = (uint8_t)(addr - RUN_PAR_IOA_B0); - if (!isfinite(v) || (v < -1000000.0f) || (v > 1000000.0f)) { - return -1; /* 粗守 ±1e6 码;±8192 真带由 adc_cal 执行段裁 */ + if (!isfinite(v)) { + return -1; /* NaN/Inf 不得进 lrintf(该转换是 UB) */ } if (select != 0u) { return 0; } - b_codes = (int32_t)lrintf(v); - k_q16 = (int32_t)lrintf(adc_cal_k_uv((adc_semantic_t)slot) * 65536.0f); - rc = adc_cal_set((adc_semantic_t)slot, k_q16, b_codes); - if (rc == 0) { - s_dirty = 1u; - } - return rc; + /* 同款语义 setter:粗守 ±1e6 码在 setter 内,±8192 真带由 adc_cal 执行段裁 */ + return run_par_cal_b_set((adc_semantic_t)slot, (int32_t)lrintf(v)); } return -2; } diff --git a/App/algo/run_par.h b/App/algo/run_par.h index 68921261..5e539617 100644 --- a/App/algo/run_par.h +++ b/App/algo/run_par.h @@ -33,6 +33,14 @@ * 修改记录: * v1.0 2026-10-25 DTU 项目组 创建:[run_par] 段 + 104 地址映射 + 直通 * adc_cal + B7 式脏标志落盘请求 + * v1.1 2026-10-27 DTU 项目组 道F task-7(遥测整定两页)配套:新增语义 + * setter run_par_cal_k_set / run_par_cal_b_set + * (写值 + 置脏成对;104 的 k/b 两支同批改调它, + * 消除两份置脏逻辑)+ 合法域唯一出口 + * run_par_db_range() + 系数粗守上限 + * RUN_PAR_K_MAX_UV;毫→工程换算收进模块内单点 + * s_milli_to_eng()(顺带把 task-43 第①项所需的 + * "写值必置脏"这一半满足掉) */ #ifndef RUN_PAR_H @@ -40,6 +48,7 @@ #include +#include "adc_cal.h" /* adc_semantic_t:公开语义 setter 的形参类型(同层 algo→algo,方向不逆)*/ #ifdef __cplusplus extern "C" { #endif @@ -48,6 +57,11 @@ extern "C" { #define RUN_PAR_DB_N 9u #define RUN_PAR_CH_N 8u +/* 系数 k 的**粗守**上限(µV/码):只为挡误写(如把 mV 当 µV 填),不是工况域; + * 真合法带在 adc_cal 执行段裁(µV/码 ×[0.5,2.0]),两层各说各的、互不掩盖。 + * 公开它是因为 UI 整定页要给编辑器设上限——上限在这里,就不许页面再抄一份。*/ +#define RUN_PAR_K_MAX_UV 1.0e9f + /* * 函数功能:运行参数初始化——副本置工厂默认 + 注册 [run_par] 段进 cfg 引擎 * 入口参数:无 @@ -64,7 +78,9 @@ void run_par_init(void); * 入口参数:idx - 0..7 = 语义通道序(UA..IN),8 = 频率 uint8_t * 返 回 值:死区 V / A / Hz;idx 越界返 0.0(调用方按"无死区"保守处理) float * 限定条件:任意上下文(int32 单字读原子;104 COV 每拍调用) - * 函数说明:毫单位 → 浮点的换算点(除以 1000 唯一在此,勿在消费侧另写)。 + * 函数说明:毫单位 → 浮点的换算在本模块内也只有一处(run_par.c 的 + * s_milli_to_eng(),run_par_deadband 与 run_par_db_range 共用); + * 消费侧一律取本模块给的工程量,不要再各自除 1000。 */ float run_par_deadband(uint8_t idx); @@ -105,6 +121,40 @@ int run_par_104_read(uint32_t addr, float *out); */ int run_par_104_write(uint32_t addr, float v, uint8_t select); +/* + * 函数功能:写某路系数 k(µV/码)**并在同一函数内置脏**——整定的语义写入口 + * 入口参数:sem - 电气语义通道(adc_semantic_t 枚举序) adc_semantic_t; + * k_uv - 系数(µV/码,须 >0 且 <= RUN_PAR_K_MAX_UV) float + * 返回值:0 - RAM 副本已写且已置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int + * 限定条件:整定路径上下文(iec104Task 的 C_SE 执行段;UI 侧由装配层 + * App/app/ui_data_feed.c 经 ui_data_yt_ops_t 转进来)。勿在 adcTask 调用 + * 函数说明:**写值与置脏成对,不许拆开**(lead 裁②):拆开就等于"屏上已改、 + * 盘上永不落、且没有任何错误返回"。因此本模块**不提供**公开的 + * mark_dirty——单独调它等于空承诺;零点 b 由本函数 RMW 取当前生效值。 + */ +int run_par_cal_k_set(adc_semantic_t sem, float k_uv); + +/* + * 函数功能:写某路零点偏移 b(码值)并置脏(与 k 同款语义,粗守 ±1e6 码) + * 入口参数:sem - 电气语义通道 adc_semantic_t;b_codes - 零点(码值) int32_t + * 返回值:0 - 已写并置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int + * 限定条件:同 run_par_cal_k_set() + * 函数说明:k 侧 RMW 取当前生效值;±8192 真带由 adc_cal 执行段裁。消费者 = + * 104 的 0x8048~ 分支(改造后走这里),不是为将来预留的口 + */ +int run_par_cal_b_set(adc_semantic_t sem, int32_t b_codes); + +/* + * 函数功能:取第 idx 路死区的合法域(工程量)——**合法域的唯一出口** + * 入口参数:idx - 0..8 uint8_t;lo/hi - 输出下限/上限(V/A/Hz) float * + * 返回值:0 - 已写出;-1 - idx 越界或指针空(调用方不得回退成自造默认域) int + * 限定条件:任意上下文(纯常量读) + * 函数说明:存在的唯一理由:UI 整定页要给编辑器设 min/max,而这些上限的真源是 + * 本模块的 RUN_PAR_DB_MAX_*;页面抄一份就是第二真源。给的是**粗守域**, + * 真提交仍由与 104 选择段同源的守卫裁,两条路不会漂移到两套数字。 + */ +int run_par_db_range(uint8_t idx, float *lo, float *hi); + /* * 函数功能:落盘脏标志探查(非消费) * 入口参数:无 diff --git a/App/log/console_log.c b/App/log/console_log.c index a5bd1cc6..5d8571bf 100644 --- a/App/log/console_log.c +++ b/App/log/console_log.c @@ -168,7 +168,7 @@ static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护 * 窗口可能被更高优先级上下文再次进入,共享量会被它改写、退出时就恢复错了。 * ⚠ 只允许包住"几条指令"的量级,**绝不允许**包住串口发送本身:一行 90 B @115200 * 约 7.8 ms,把它关进中断等于给启动窗口塞 7.8 ms 中断延迟与最坏喂狗抖动 - * (红线与机器判据见 con_tx_bounded 函数头与主机单测 TC-CONLOG-015b/177)。 + * (红线与机器判据见 con_tx_bounded 函数头与主机单测 TC-CONLOG-015b 与 TC-CONLOG-177)。 */ #define CON_CRIT_ENTER(pm_var) do { (pm_var) = __get_PRIMASK(); __disable_irq(); } while (0) #define CON_CRIT_EXIT(pm_var) do { __set_PRIMASK(pm_var); } while (0) diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index 38dd1e3f..f9642896 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -7,6 +7,15 @@ * 作者:王建锋 * 创建日期:2026-09-14 * 修改记录: + * v1.8 2026-10-27 DTU 项目组 道G task-40:syslog_drain() 尾部按**边沿**补投一条 + * 卷锁 fail-open(零互斥那一态)自检记录。锁层只自增计数不产日志 + * (唯一能写的通道自己也要这把**非递归**卷锁),留痕必须由 + * 唯一写者做;一个 episode(= 一次上电)只投一条,不打 + * DBG 错级、不打 [ERR]([ERR] 是"设备出故障"的唯一信号 + * 通道,塞进可观测性事件就变成第二个信号源——与 task-23 + * "日志让路不计 [ERR]"同一根判据)。 + * 成本:.bss +48 B(3 枚计数/标志 + 静态快照 + 静态行缓冲), + * **零栈增量**(缓冲全静态;前后两表见 task-40 交付)。 * v1.7 2026-10-26 王建锋 「记录 5 页」的数据面(task-10):syslog_id_t 尾部追加 * SYSLOG_ACCIDENT / ALARM / START / OPER / YK 五枚,本文件 * s_paths[]/s_caps[] 同步到七项(**必须与枚举同序**,越界 @@ -162,6 +171,33 @@ static volatile uint8_t s_post_pboot_full_reported;/* 启动前数组满丢弃 static volatile uint32_t s_quiet_drop; /* quiet 通道累计丢弃条数 */ static uint32_t s_quiet_reported; /* drain 侧已上报水位(唯一写者私有) */ +/* 卷锁 fail-open(零互斥那一态)的边沿留痕(道G task-40,2026-10-27) + * ------------------------------------------------------------------ + * 锁层只**自增计数**、绝不产日志:唯一能把事写下来的通道(/log/wdev.log 等)自己也要 + * 取这把卷锁,而卷锁**非递归**——被卷忙挡住时既写不出东西,也不许在失败分支里再去取 + * 同一把锁(App/fs/vol_lock.h「可观测性」段 + task-17 查出的事实)。所以留痕由唯一写者 + * (defaultTask)在这里按**边沿**补投,投的是自检记录。 + * ⚠ episode = 一次上电,只投一条。fail-open 的成因是"互斥体从未创建成功"(vol_lock.c + * 的那一支),一旦成立,后面每一次取锁都零互斥;若"计数每前进一次报一条" + * 就等于每轮 drain 一条记录 + 一次 NAND 写——诊断自己变成负载,正是上面 s_quiet_drop + * 那条纪律要避免的事(同款先例另有 App/fs/vol_idle_sync.c 的 s_vol_sync_fail_latch)。 + * ⚠ **不打 DBG 错级、不打 [ERR]**:[ERR] 在本仓是"设备出故障"的唯一信号通道(@test 的 + * Forbidden 标记也依赖它,四个 Fault 的崩溃日志都以 [ERR] 开头)。把一个可观测性事件 + * 塞进 [ERR],它就同时不再是故障、也不再是禁令,两边一起失去意义——与 task-23 里 + * "日志让路不计 [ERR]"是同一根判据。机器判据 = TC-SYSLOG-137 里的 g_sys_err_count == 0。 + * 时刻:本模块在这里**不读 tick**(读一次 tick 就给 defaultTask 的 drain 最坏链多挂一个 + * callee,而这条链的余量我们一直在省着花)。记录自带的 ts 字段就是"**被观测到的时刻**" + * ——不是发生时刻,粒度 = 一轮 drain;这正是 vol_lock.h 第②条告诫要求消费方自己说清的 + * 那件事,所以这里不另造 obs_first_ms/obs_last_ms 字段(那只会造出第二个"看起来像发生 + * 时刻"的东西)。要"何时开始忙"的精确窗口,请读 vol_lock_get_stats() 的现值。 + * 成本:3 枚计数/标志 + 一份静态快照 + 一行静态文本 ≈ 48 B .bss,**零栈增量** + * (缓冲全静态,照 App/util/task_prof.c 的 s_prof_line 先例;前后 stack_report 两表 + * 见 task-40 交付)。 */ +static volatile uint32_t s_vl_failopen_seen; /* drain 侧上次读到的 fail-open 计数 */ +static uint8_t s_vl_reported; /* 本 boot 已留痕(1 = 本 episode 不再投) */ +static vol_lock_stats_t s_vl_now; /* 静态快照,不走调用方栈 */ +static char s_vl_note[32]; /* 记录文本(落盘按 SYSLOG_DESC_MAX=28 截断) */ + /* 日志读写放大收口(2026-10-15,读侧):8 条滑动读窗口 + 写者代数。 * 窗口 512 B 走静态(不吃任务栈;defaultTask/uiTask 最坏链见 stack_report.py, * 预算余量见 docs/内存管理/RAM预算台账.md)。s_rd_gen 由写者自增、读者比对, @@ -843,6 +879,22 @@ void syslog_drain(void) { s_quiet_reported = s_quiet_drop; DBG_ERROR("quiet drop total=%lu", (unsigned long)s_quiet_drop); } + /* 卷锁 fail-open(零互斥那一态)边沿补投一条自检记录(约束与理由全在上面那组静态量旁边)。 + * vol_lock_get_stats() 不取锁、不关中断、不阻塞:卷被长期占住时正是最需要读它的 + * 时刻,观测口若再去抢同一把锁,就把观测做成了故障的一部分。 + * 必须排在 s_clear_service() 之前吗?不必——两者无先后关系(清除的是记录文件, + * 本条是**新增**一条自检记录);但必须排在下面 soe_store_drain() 之前,因为本函数 + * 这条支路不持卷锁,而 soe_store_drain() 自己 try 锁(沿用本函数既有纪律)。 */ + vol_lock_get_stats(&s_vl_now); + if (s_vl_now.failopen != s_vl_failopen_seen) { + s_vl_failopen_seen = s_vl_now.failopen; /* 水位每轮都追:别让下一轮误判"新增" */ + if (s_vl_reported == 0U) { + s_vl_reported = 1U; /* 一个 episode 一条(= 一次上电) */ + (void)snprintf(s_vl_note, sizeof(s_vl_note), "vollock failopen=%lu", + (unsigned long)s_vl_failopen_seen); + syslog_post(SYSLOG_SELFTEST, s_vl_note); + } + } /* 「清除记录」的落地半边——必须在上面的条目排空**之后**(顺序是判据的一部分: * 先删后写会把本轮刚排空的旧记录又落回去,count 不归零 = 没实际效果的按钮), * 也在 soe_store_drain() 之前:本函数自带卷锁 try,绝不在此持锁状态下调用。 diff --git a/Src/stm32f4xx_it.c b/Src/stm32f4xx_it.c index 35baf5f5..c7e3682d 100644 --- a/Src/stm32f4xx_it.c +++ b/Src/stm32f4xx_it.c @@ -153,7 +153,7 @@ static void hard_fault_puthex(uint32_t v) * 残余风险(照实登记,别当成已消除): * - **极早期崩溃仍然静默**:若在 MX_USART1_UART_Init() 之前进 Fault,本口的 * `huart1.Instance == NULL` 短路会直接返回、一个字节都不发(换来的是"绝不解引用 - * 地址 0",见 hard_fault_write ③;该短路有机器判据 TC-CONLOG-100/102)。旧写法在 + * 地址 0",见 hard_fault_write ③;该短路有机器判据 TC-CONLOG-100 与 TC-CONLOG-102)。旧写法在 * 同样场景下也不发(gState==RESET 不等于 READY ⇒ HAL_BUSY),**并没有因为本次改动 * 变差**,但也别写成"早期崩溃现在能看到"。 * - **ORE/NE/FE 脏标志本身没被消除**:本笔只是不再经由 HAL 状态机,从而消除了 diff --git a/docs/子系统设计/控制台单写者日志设计说明.md b/docs/子系统设计/控制台单写者日志设计说明.md index 194f7624..4381530b 100644 --- a/docs/子系统设计/控制台单写者日志设计说明.md +++ b/docs/子系统设计/控制台单写者日志设计说明.md @@ -242,7 +242,7 @@ sequenceDiagram **验证设计(崩溃路径,两腿分工,2026-10-26 台架更正后定稿)**:这条路径不能只靠静态论证(本仓最贵的教训就是「0 警告不代表安全」),但**两腿各证各的,别互相顶包**。 **腿 A — 主机侧套件承担全部关于 IPSR 与异常态整行性的主张**(已落地、可重复、确定性证据): -- 假 USART + `con_ipsr` + 「每个上线字节的临界区深度水位」+ 一次性注入把手(在第 N 个字节上线后跑第二个生产者),把「Fault 上下文照发、不等、不丢、不越权归还」做成必然失败的断言:`TC-CONLOG-175` / `TC-CONLOG-175b`(IPSR 直发且不动别人的凭证)、`TC-CONLOG-174` / `TC-CONLOG-174b`(一次独占内多段连发不自我丢弃、`tx_end` 后别人能接手)、`TC-CONLOG-015` / `TC-CONLOG-015b` / `TC-CONLOG-177`(发送全程临界区深度恒 0,即"绝不把整行包进关中断区"这条硬红线)、`TC-CONLOG-170..172`/`178`(两个生产者并发时行内不得出现别人的字节;`TC-CONLOG-178` 直接复演台架那行原文)。 +- 假 USART + `con_ipsr` + 「每个上线字节的临界区深度水位」+ 一次性注入把手(在第 N 个字节上线后跑第二个生产者),把「Fault 上下文照发、不等、不丢、不越权归还」做成必然失败的断言:`TC-CONLOG-175` / `TC-CONLOG-175b`(IPSR 直发且不动别人的凭证)、`TC-CONLOG-174` / `TC-CONLOG-174b`(一次独占内多段连发不自我丢弃、`tx_end` 后别人能接手)、`TC-CONLOG-015` / `TC-CONLOG-015b` / `TC-CONLOG-177`(发送全程临界区深度恒 0,即"绝不把整行包进关中断区"这条硬红线)、`TC-CONLOG-170 至 TC-CONLOG-172`/`178`(两个生产者并发时行内不得出现别人的字节;`TC-CONLOG-178` 直接复演台架那行原文)。 - ⚠ **「硬件上多跑几次启动照不出 IPSR」**——产品构建正常启动根本不进异常路径,跑 100 次也只是重复证明"没崩溃"。任何人想推翻或确认本节关于异常态的说法,请改主机侧套件,不要去台架上碰运气。 **腿 B — 实机腿:本腿含故意复位(不是常规回归)** @@ -306,7 +306,7 @@ sequenceDiagram |------|------|---------|---------| | 缓冲满(日志突发 / 写者卡滞) | 生产者等 200ms 后丢行 | `s_con_drop` 累计 → 写者刷 `[ERR] CONSOLE dropped N lines`(@test FAIL,永不静默) | — | | 写者未注册(内核前) | 单线程直发 | — | 无(无并发) | -| 未注册期其他任务直发 | **已消除**(2026-10-26):整行独占,抢不到就整行入环延后发(§4.4.1) | 让路不计数;只有"抢不到且环也满"才进 `s_con_drop` → `[ERR] CONSOLE dropped N lines` | 残余 = 让路行在流里位置后移(内容与直发逐字节相同);主机单测 `TC-CONLOG-170..179` 钉死 | +| 未注册期其他任务直发 | **已消除**(2026-10-26):整行独占,抢不到就整行入环延后发(§4.4.1) | 让路不计数;只有"抢不到且环也满"才进 `s_con_drop` → `[ERR] CONSOLE dropped N lines` | 残余 = 让路行在流里位置后移(内容与直发逐字节相同);主机单测 `TC-CONLOG-170 至 TC-CONLOG-179` 钉死 | | 崩溃上报(四个 Fault)原走独立 HAL 发送口 | **已归一**(2026-10-26 task-26):`hard_fault_report` 的 14 段裸发改走 `dbg_console_write_raw`(整行独占**不**用在这里——异常态空转,见 §4.4.1);ORE 快速失败(= 证据整条蒸发)与 `HAL_GetTick`/`HAL_Delay` 的 tick 依赖随之消失 | 崩溃行 `[ERR] PC=… CFSR=…` 现在是单写者输出;实机验证设计见 §4.4.1 末(待台架窗口) | 极早期(`MX_USART1_UART_Init()` 之前)崩溃仍完全静默(NULL 短路);ORE 脏标志本身未消除(§12) | | ISR 直发撞写者发送中 | 与写者发送竞争 → 交错/丢失,不挂死(`IPSR != 0` 一律放行,§4.4.1 规则①);异常态没有第二写者(Fault 优先级 -1 + 任务全冻结),残留 ≤1 个移位寄存器字节且落在行**前** | 未计数 | ISR 日志频率极低,best-effort 语义 | | 内核状态损坏(栈溢出 / 堆耗尽) | 钩子裸发固定文本 → 复位(栈溢出)/ 仅告警(堆) | `[ERR] STACK OVERFLOW task=…` / `[ERR] MALLOC FAILED …`;任务名范围校验 + 本地拷贝,TCB 指针不入 UART | — | @@ -360,8 +360,8 @@ sequenceDiagram | 2026-10-02 | **round 8b 加固**:发送超时判据改用 DWT `CYCCNT`(时间源无关)。原 `HAL_UART_Transmit` 的 `HAL_GetTick() - Tickstart` 依赖 TIM7 的 `uwTick`,TIM7 一旦停摆超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 → IWDG 复位。裸发送与缓冲满等待两处均换成 DWT 周期计数硬上界(DWT 使能失败时退回旧 HAL 行为);另加 `huart1.Instance == NULL` 短路,防早期/断言路径解引用 NULL | | 2026-10-10 | **新增 `dbg_console_writer_ready()` + 未注册期纪律固化**:裸发为寄存器级轮询(无 `gState`)→ 未注册期多任务并发**逐字节交错**(台架 `adcTask` 首帧日志与 `[NAND_FTL]` 行交错实证)。新增 `dbg_console_writer_ready()` 供周期任务门控;`adcTask` 首帧/心跳日志据此门控(§3.4 / §4.4) | | 2026-10-25 | **N-2 × S8 合并评估收口(评估任务,零代码)**:S8 生产者侧判定已达成、TX IT/DMA 维持否决并绑定 S-3 复议条件、N-2 收敛到 V5 复跑双零判据——全文见文末附录 | -| 2026-10-26 | **崩溃上报归一 + 多段裸发收口(道G task-26,接 task-23 第二段)**:`Src/stm32f4xx_it.c hard_fault_report`(四个 Fault 共用,14 段裸发)原**自行调用 `HAL_UART_Transmit`** ⇒ 绕过本模块行级独占的**第三个写者**,并带两条已实证失效:HAL 超时依赖 `HAL_GetTick()`(TIM7 停摆永不成立)与僵尸通道下恒快速失败(**关键 `[ERR]` 证据整条蒸发**)。现改 `dbg_console_write_raw`(**不包** `tx_begin()/tx_end()`,理由见 §4.4.1);`hard_fault_report` 与栈溢出钩子输出后的 `HAL_Delay(200)` 一并删除(本口已等 TC;少一条 tick 依赖,避免卡在复位之前)。同类收口只落在任务态:`App/util/task_watchdog.c` STALL 行 2 段包进一次独占;`Src/freertos.c` 栈溢出钩子(3 段)与断言钩子(5 段)以及 `hard_fault_report`(14 段)**不包**(异常态空转 + 代价 ×9 条任务链,见 §4.4.1)。**语义**:异常上下文 `tx_begin()` 放行不持有,故障行整行性靠 Fault 优先级与任务冻结,残留 ≤1 个移位寄存器字节(落在行前,不影响 `[ERR]` grep)。**未消除**:ORE/NE/FE 脏标志、极早期(串口未初始化)崩溃静默。主机侧复用既有判据 `TC-CONLOG-100/102`(NULL 短路)与 `TC-CONLOG-174`(一次独占内多段连发不自我丢弃),未新增用例;实机验证设计见 §4.4.1 末。**行序口径**:让路行入环后位置后移,台架 5/5 观察到 `[NAND_FTL]` 稳定排在 `[PROT]` 前一行(旧缺陷形态相反)⇒ 行序与时间戳先后**都不再是判据**,只能按整行逐字节锚定(§4.4.1) | -| 2026-10-26 | **O1 第二个实例 → 未注册期裸发加「行级单一所有者」(道G task-23)**:台架同镜像两次"仅复位"启动,`[PROT]` 未标定闭锁边沿行内分别多出 `[20`(3 字节)与 `[`(1 字节),而紧随其后的 `[NAND_FTL] Initializing NAND FTL...` 行恰好缺同样那几个字节的**行首**(字节账与三次启动命中两次的频率见 §4.4.1)→ 判据"framing 错不会让行变长"锁定为**两个写者并发裸发**,即 O1 的第二个实例(第一个是 2026-10-12 B6)。修法 = 非阻塞行所有权(`con_tx_try_claim`/`con_tx_release`,只在几条指令的临界区里 test-and-set;发送全程**不关中断**)+ 让路整行原文入环延后发(不丢、不补前缀、不重打时间戳)+ drain 每轮先取凭证(堵掉"attach 撞上别人半行")+ `[ERR]` 只在"抢不到且环也满"时红。新增 `dbg_console_tx_begin/tx_end`(多段裸发收口,调用点改造 → task-26)。主机单测 127 → **152** 条(M 族 `TC-CONLOG-170..179`,含逐字节相同判据),四处生产码注入反证各自转红(取消独占 11 红 / 让路改静默丢 5 红 / 非属主可归还 1 红 / 把发送包进临界区 3 红)。§3.4 / §4.4 / §7 / §13-O1 同步 | +| 2026-10-26 | **崩溃上报归一 + 多段裸发收口(道G task-26,接 task-23 第二段)**:`Src/stm32f4xx_it.c hard_fault_report`(四个 Fault 共用,14 段裸发)原**自行调用 `HAL_UART_Transmit`** ⇒ 绕过本模块行级独占的**第三个写者**,并带两条已实证失效:HAL 超时依赖 `HAL_GetTick()`(TIM7 停摆永不成立)与僵尸通道下恒快速失败(**关键 `[ERR]` 证据整条蒸发**)。现改 `dbg_console_write_raw`(**不包** `tx_begin()/tx_end()`,理由见 §4.4.1);`hard_fault_report` 与栈溢出钩子输出后的 `HAL_Delay(200)` 一并删除(本口已等 TC;少一条 tick 依赖,避免卡在复位之前)。同类收口只落在任务态:`App/util/task_watchdog.c` STALL 行 2 段包进一次独占;`Src/freertos.c` 栈溢出钩子(3 段)与断言钩子(5 段)以及 `hard_fault_report`(14 段)**不包**(异常态空转 + 代价 ×9 条任务链,见 §4.4.1)。**语义**:异常上下文 `tx_begin()` 放行不持有,故障行整行性靠 Fault 优先级与任务冻结,残留 ≤1 个移位寄存器字节(落在行前,不影响 `[ERR]` grep)。**未消除**:ORE/NE/FE 脏标志、极早期(串口未初始化)崩溃静默。主机侧复用既有判据 `TC-CONLOG-100 与 TC-CONLOG-102`(NULL 短路)与 `TC-CONLOG-174`(一次独占内多段连发不自我丢弃),未新增用例;实机验证设计见 §4.4.1 末。**行序口径**:让路行入环后位置后移,台架 5/5 观察到 `[NAND_FTL]` 稳定排在 `[PROT]` 前一行(旧缺陷形态相反)⇒ 行序与时间戳先后**都不再是判据**,只能按整行逐字节锚定(§4.4.1) | +| 2026-10-26 | **O1 第二个实例 → 未注册期裸发加「行级单一所有者」(道G task-23)**:台架同镜像两次"仅复位"启动,`[PROT]` 未标定闭锁边沿行内分别多出 `[20`(3 字节)与 `[`(1 字节),而紧随其后的 `[NAND_FTL] Initializing NAND FTL...` 行恰好缺同样那几个字节的**行首**(字节账与三次启动命中两次的频率见 §4.4.1)→ 判据"framing 错不会让行变长"锁定为**两个写者并发裸发**,即 O1 的第二个实例(第一个是 2026-10-12 B6)。修法 = 非阻塞行所有权(`con_tx_try_claim`/`con_tx_release`,只在几条指令的临界区里 test-and-set;发送全程**不关中断**)+ 让路整行原文入环延后发(不丢、不补前缀、不重打时间戳)+ drain 每轮先取凭证(堵掉"attach 撞上别人半行")+ `[ERR]` 只在"抢不到且环也满"时红。新增 `dbg_console_tx_begin/tx_end`(多段裸发收口,调用点改造 → task-26)。主机单测 127 → **152** 条(M 族 `TC-CONLOG-170 至 TC-CONLOG-179`,含逐字节相同判据),四处生产码注入反证各自转红(取消独占 11 红 / 让路改静默丢 5 红 / 非属主可归还 1 红 / 把发送包进临界区 3 红)。§3.4 / §4.4 / §7 / §13-O1 同步 | | 2026-10-12 | **纪律盲区台架实证(B6)**:`protectTask`(B5 接线,台架腿当时未跑)启动锚点行在写者注册前裸发 → 与 defaultTask 启动日志**逐字节交错**(`st[art: cycle=220ms elm=70`,两次开机同形复现 = 启动调度确定性的竞态,非 USB 噪声)→ `@test` 锚点 MISS。修复 = 启动行改 `dbg_console_writer_ready()` 首拍补打(adcTask 首帧同款),`@build` 0/0 + `@test` PASS 复验(锚点完好 +577 ms)。§3.4 纪律示例更新 | ## 12. ORE 僵尸通道(2026-10-02 台架实证) diff --git a/docs/子系统设计/运行与自检记录设计说明.md b/docs/子系统设计/运行与自检记录设计说明.md index ffbb5fb8..d6cffbd9 100644 --- a/docs/子系统设计/运行与自检记录设计说明.md +++ b/docs/子系统设计/运行与自检记录设计说明.md @@ -1,6 +1,6 @@ # 运行/自检记录与待机显示设计说明 -> **版本**:v1.2(§4.3 已随七类记录更新;v1.1 的原始设计口径不变) +> **版本**:v1.3(§4.4 新增"卷锁 fail-open 边沿补投自检记录"一条,道G task-40;v1.2 为 §4.3 随七类记录更新,v1.1 的原始设计口径不变) > **日期**:2026-09-14(v1.2 更新 2026-10-26) > **状态**:步骤 1–9 全部完成——双端构建 0/0、`--logtest` PASS、ui-verify 五门禁全绿;真机 @test 待上电回归(§8.2) > **关联文档**:`项目架构总览.md` · `STM32F4-Base存储架构说明.md` · `系统墙钟时间维护说明.md` · `UI设计/内容页架构.md`(如存在)· `docs/UI设计/mockups/`(待机显示页面.jpg / 运行记录页.jpg / 自检记录页.jpg / 密码页面实际机器的示例图片.jpg) @@ -112,7 +112,7 @@ | 文件 | 记录类型(= `syslog_id_t`) | 上限 | 满时策略 | |---|------|------|---------| | `/log/run.log` | `SYSLOG_RUN` 上电/重启与运行事件 | **1024 条(64KB)** | 删除重建(整卷清空,串口告警);DTU 上电频率低,容量充裕 | -| `/log/selftest.log` | `SYSLOG_SELFTEST` 自检故障(有问题才记) | **512 条(32KB)** | 同上 | +| `/log/selftest.log` | `SYSLOG_SELFTEST` 自检故障(有问题才记;来源含**卷锁 fail-open 边沿留痕**,见 §4.4 最后一条) | **512 条(32KB)** | 同上 | | `/log/accid.log` | `SYSLOG_ACCIDENT` 事故(跳闸型动作) | **512 条(32KB)** | 同上 | | `/log/alarm.log` | `SYSLOG_ALARM` 保护告警 | **1024 条(64KB)** | 同上 | | `/log/start.log` | `SYSLOG_START` 保护启动(越限电平) | **1024 条(64KB)** | 同上 | @@ -177,6 +177,21 @@ defaultTask(唯一写者,挂载成功后): - **容量轮转仍只在按条慢路径做**:批量路径发现剩余容量装不下一批时返回 -3,退回逐条 `s_append()`("满了 unlink 重建"只保留一处实现,避免一批跨多轮重建)。 - 自愈重试(L1 丢弃重建 + 重试一次)与错误去重(L2 只报第一次)判据与单条路径一致。 - **自愈触发集分类(2026-10-24,代码侧唯一入口 `s_is_selfheal_code()`)**:只有"删了这个文件真能修好"的介质/结构类码才触发 unlink 重建——`FR_DISK_ERR` / `FR_INT_ERR` / `FR_NO_FILESYSTEM` / `FR_DENIED`;**名字/参数类严禁归入**(`FR_INVALID_NAME` / `FR_NO_PATH` / `FR_NO_FILE` / `FR_INVALID_OBJECT`,另显式排除 `FR_NOT_READY` / `FR_NOT_ENABLED` / `FR_TIMEOUT`)。同名同因下 unlink 必然同样失败,旧集合的真实后果是**每轮排空删一次自己的历史日志且永远修不好**,违反 `docs/架构设计/STM32F4-Base存储架构说明.md` §10.1 第 10 条。`f_write` 之后的"失败/短写 → unlink 重试一次"**不接**本判定(能走到那里说明已打开成功;而簇分配失败是 `FR_OK` + 短写、根本没有码可判,必须无条件重试)。钉住:TC-SYSLOG-101 / 101b / 102 / 103;`App/log/soe_store.c` 侧同源但取**更保守子集**(TC-SOESTORE-022 / 022b)。 + - **卷锁 fail-open(零互斥那一态)边沿补投一条自检记录(2026-10-27 道G task-40)**:锁层只自增计数、 + 绝不产日志——唯一能落盘的通道(`/log/wdev.log` 等)自己也要取这把**非递归**卷锁,被卷忙 + 挡住时既写不出、也不许在失败分支里再取同一把锁(机理与四态语义的唯一出处是 + `App/fs/vol_lock.h` 的「可观测性」段,本文不复述)。因此留痕由唯一写者在 `syslog_drain()` + 尾部按**边沿**补投:判据是"计数比上一轮读到的大了",不是"计数大于零"(后者是电平,每轮一条 + = 每轮一次 NAND 写,诊断自己变成负载);**一个 episode(= 一次上电)只投一条**,重新上电 + 复位 latch 后故障复发仍会再投(否则第二次开机起永久静默)。文本形如 + `vollock failopen=3`(累计值就是证据,不带态号),落 `selftest.log`。 + ⚠ 三条约束(与 task-23「日志让路不计 `[ERR]`」同一根判据):① 不在失败调用点就地打日志; + ② 不打 DBG 错级、不打 `[ERR]`——`[ERR]` 是"设备出故障"的唯一信号通道(`@test` 的 Forbidden + 也依赖它),塞进可观测性事件就同时不再是故障、不再是禁令;③ 记录自带的时间戳是 + **被观测到的时刻**(粒度 = 一轮 `syslog_drain()`),不是发生时刻,所以本模块刻意不再另造 + `obs_first_ms`/`obs_last_ms` 字段去"看起来更精确"。主机判据:`TC-SYSLOG-137` 至 + `TC-SYSLOG-137d`(首投一条并带累计值 / 同 boot 不再投 / 计数不变不投 / 重新上电可再投), + 其中 `g_sys_err_count == 0` 就是"不打错级"的机器证据。 - **留痕节流分两层、各自独立抑制位**:drain 侧"整批回投"只在正常→故障那一次报(`write fail, requeue %lu entries`,落盘成功一轮复位;旧文案写作 "drop N entries" 与实际行为相反——整批其实一条没丢);post 侧 `queue full, drop` / `preboot full, drop` 各一位(**丢弃语义不变,满则丢照旧**,只压留痕次数)。节流之后**没有丢弃计数**——要计数属新增对外可见信息,另列后续项。 - 读路径与写路径并发安全:UI 页在 uiTask 经 `syslog_get_record()` 读记录(内部走 `s_read_raw()`,见 §4.5);读写两侧都取卷锁 try 语义,FatFS 系统锁保护单次调用。 - **存储未就绪/挂载失败**:待发条目滞留 RAM(下次上电丢失),串口打印告警——存储故障时本就无法持久化,属预期行为。 diff --git a/test/host/console_log/console_log_test.c b/test/host/console_log/console_log_test.c index 01a992b0..34975d52 100644 --- a/test/host/console_log/console_log_test.c +++ b/test/host/console_log/console_log_test.c @@ -3,7 +3,7 @@ * 模块功能:在 PC 上离线验证 App/log/console_log.c 的单写者契约(G1~G5 / K1~K5,见 * docs/子系统设计/控制台单写者日志设计说明.md):环形缓冲入环与分块发送、 * 行级原子、192B 截断、写者未注册/ISR 上下文的直发(未注册期自 2026-10-26 - * 起是**整行独占**,不再是"无锁":M 族 TC-CONLOG-170..179 钉"别的写者插不进 + * 起是**整行独占**,不再是"无锁":M 族 TC-CONLOG-170 至 TC-CONLOG-179 钉"别的写者插不进 * 一行、让路行逐字节等同直发、让位不丢、真溢出才 [ERR]")、缓冲满有界等待与 * 丢弃可观测、TX 僵尸通道软复位自愈 + 300ms 冷却、DWT 与指令计数两种硬上界、 * huart1.Instance 未初始化的 NULL 短路、fputc 重定向、drain 250ms 预算、 @@ -1303,7 +1303,7 @@ static void tc_ownership(void) char fill[DBG_CON_LINE_MAX]; uint32_t i; - printf("-- TC-CONLOG-170..179 whole-line ownership (O1 instance 2, bench byte account) --\n"); + printf("-- TC-CONLOG-170 to TC-CONLOG-179 whole-line ownership (O1 instance 2, bench byte account) --\n"); /* 170 A 行发到第 4 个字节时被 B 抢占:A 逐字节完整,B 整行让路入环 */ reset_all(); diff --git a/test/host/console_log/run_unit_test.py b/test/host/console_log/run_unit_test.py index 20bdd352..e4c07ed1 100644 --- a/test/host/console_log/run_unit_test.py +++ b/test/host/console_log/run_unit_test.py @@ -6,7 +6,7 @@ 用本机 MSVC 编译 test/host/console_log/console_log_test.c(以 #include 纳入生产源 App/log/console_log.c),在 PC 上离线验证单写者环形缓冲的全套契约:192B 行截断、 256B 分块、跨环尾两段拷贝、写者未注册/ISR 上下文的直发(未注册期自 2026-10-26 起 - 为**整行独占**:抢不到独占的整行原文入环延后发,M 族 TC-CONLOG-170..179 负责钉)、 + 为**整行独占**:抢不到独占的整行原文入环延后发,M 族 TC-CONLOG-170 至 TC-CONLOG-179 负责钉)、 缓冲满两条上界 (DWT 周期 / 200 tick)与丢弃计数、[ERR] 告警行不经环(不递归)、TX 僵尸通道软复位 自愈 + 故障即止 + 300ms 冷却(含无符号回绕)、DWT 使能失败退回 20000 次指令计数、 diff --git a/test/host/console_log/stub/stm32f4_fake.h b/test/host/console_log/stub/stm32f4_fake.h index 9a26a272..ed7db951 100644 --- a/test/host/console_log/stub/stm32f4_fake.h +++ b/test/host/console_log/stub/stm32f4_fake.h @@ -127,7 +127,7 @@ void con_uart_reset(void); * ① con_uart_crit_at_tx_max:本字节上线时的临界区深度水位。整行所有权只能靠一次 * 几条指令的 test-and-set 取得,**绝不允许把 7.8 ms 的发送包进关中断区**(那等于 * 把中断延迟与最坏喂狗抖动塞进启动窗口,比交错本身更糟)——本水位恒 0 就是这条 - * 硬红线的机器证据(判据见 console_log_test.c 的 M 族 TC-CONLOG-170..179)。 + * 硬红线的机器证据(判据见 console_log_test.c 的 M 族 TC-CONLOG-170 至 TC-CONLOG-179)。 * ② con_uart_tx_hook/_at:在"第 N 个字节上线之后"跑一遍测试装填的函数,用来精确 * 模拟**发送任务在两个字节之间被抢占**。行级所有权是"拿不到就当场让路"的非阻塞 * 设计,所以既有 con_delay_hook(vTaskDelay 时才跑)在这条路径上永远不会触发, diff --git a/test/host/sys_log_mcu/sys_log_mcu_test.c b/test/host/sys_log_mcu/sys_log_mcu_test.c index 2aa76f01..b973bce5 100644 --- a/test/host/sys_log_mcu/sys_log_mcu_test.c +++ b/test/host/sys_log_mcu/sys_log_mcu_test.c @@ -179,6 +179,13 @@ * TC-SYSLOG-134 quiet 的入参守卫:坏 id / NULL 既不投也不计丢弃(参数错 != 容量故障) * TC-SYSLOG-135 启动前数组满同样计数、同样静默(另一条腿,与 F 项的两个通道分区一致) * TC-SYSLOG-136 同内容在七类之间**逐字节相同**:记录体不编码类型 = "文件即类型"的机器表达 + * TC-SYSLOG-137 卷锁 fail-open(零互斥那一态)第一次前进:补投**一条**自检记录、字段带累计值、 + * 且 g_sys_err_count 恒 0(不打 DBG 错级、不打 [ERR]——道G task-40) + * TC-SYSLOG-137b 同一 episode(一次上电)内该计数继续前进:不再投第二条(诊断不许变成 + * 负载:每轮一条 = 每轮一次 NAND 写,与 s_quiet_drop 那条纪律同源) + * TC-SYSLOG-137c 计数没前进:多轮 drain 一条都不投(边沿判据,不是电平判据) + * TC-SYSLOG-137d 重新上电(reset_all)后计数再前进:又投一条——证明 latch 是"每 boot + * 一条"而不是"永远只有一条",否则故障复发就永久静默 * * 覆盖率台账(`scripts/check_test_coverage.py --module sys_log_mcu` 本机实跑): * App/log/sys_log_mcu.c **403/406 行 = 99.3%、函数 22/22**。 @@ -421,8 +428,24 @@ static void locks_clear(void) s_last_wait_to = 0xFFFFFFFFu; /* 哨兵:从未调用过就不等于 timeout=0 */ } +/* 卷锁观测计数假件(道G task-40):生产侧 syslog_drain() 每轮读一次,判"边沿"。 + * failopen 由测试自己抬(模拟 osMutexNew 失败那一支),其余三态给固定值——本卡 + * 只消费 fail-open 那一态,其余三态各有属主(App/fs/vol_lock.h 的四态语义不得合并)。 */ +static uint32_t s_vl_failopen_fake; + int vol_lock_init(void) { return 0; } +void vol_lock_get_stats(vol_lock_stats_t *p_out) +{ + if (p_out == NULL) { + return; + } + p_out->acquired = 11u; + p_out->try_timeout = 2u; + p_out->blk_timeout = 0u; + p_out->failopen = s_vl_failopen_fake; +} + int vol_lock_wait(uint32_t timeout_ms) { int r; @@ -599,6 +622,11 @@ static void reset_all(void) * 否则上一个场景的丢弃会让本场景第一条留痕被当成"已经报过" */ s_quiet_drop = 0u; s_quiet_reported = 0u; + /* 卷锁 fail-open 留痕(道G task-40):latch 与水位、假件计数都得归零, + * 否则上一个场景"已经报过"会让本场景的边沿静默(同上面那条教训) */ + s_vl_failopen_seen = 0u; + s_vl_reported = 0u; + s_vl_failopen_fake = 0u; (void)memset(s_rd_win, 0, sizeof(s_rd_win)); s_rd_win_id = -1; s_rd_win_base = 0u; @@ -2408,6 +2436,68 @@ static void tc_kinds(void) CHECK(ok, "TC-SYSLOG-136 a record body does not encode its kind: same content is byte-identical across all seven kinds"); } +/* ============ TC-SYSLOG-137 卷锁 fail-open 边沿留痕(道G task-40) ============ */ +static void tc_vollock_note(void) +{ + uint8_t rec[SYSLOG_REC_SIZE]; + uint32_t c0; + uint32_t c1; + int ok; + + printf("-- TC-SYSLOG-137 volume-lock fail-open: one self-test record per boot, never [ERR] --\n"); + + /* 137 边沿第一次前进:补投一条,字段带累计值,且全程不打错级/[ERR]。 + * 注意投递与落盘分在两轮 drain(投递发生在 drain 尾部,本轮批次已取走), + * 所以这里显式排两轮,别把"本轮没落盘"误读成"没投"。 */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); /* 建队列;此刻计数为 0,不该有任何留痕 */ + c0 = syslog_count(SYSLOG_SELFTEST); + g_sys_err_count = 0; + s_vl_failopen_fake = 1u; + syslog_drain(); /* 读到 1 != 上次 0 -> 投递 */ + ok = (syslog_count(SYSLOG_SELFTEST) == c0); + syslog_drain(); /* 落盘 */ + c1 = syslog_count(SYSLOG_SELFTEST); + (void)memset(rec, 0, sizeof(rec)); + (void)raw_at(P_SELFTEST, c0, rec); + ok = ok && (c1 == c0 + 1u) + && (memcmp(rec + SYSLOG_DESC_OFFS, "vollock failopen=1", 18u) == 0) + && (rec[SYSLOG_DESC_OFFS + 18u] == 0u) + && (g_sys_err_count == 0); + CHECK(ok, "TC-SYSLOG-137 the first fail-open edge posts exactly one self-test record carrying the cumulative count, with zero error-level traces"); + + /* 137b 同一 episode 内继续前进:不得再投(每轮一条 = 每轮一次 NAND 写) */ + s_vl_failopen_fake = 5u; + syslog_drain(); + syslog_drain(); + CHECK((syslog_count(SYSLOG_SELFTEST) == c1) && (g_sys_err_count == 0), + "TC-SYSLOG-137b a growing fail-open count inside the same boot posts no second record (the diagnostic must not become load)"); + + /* 137c 电平不变:多轮 drain 一条都不投——边沿判据而非电平判据 */ + syslog_drain(); + syslog_drain(); + CHECK((syslog_count(SYSLOG_SELFTEST) == c1) && (g_sys_err_count == 0), + "TC-SYSLOG-137c an unchanged count posts nothing across further drains (edge-triggered, not level-triggered)"); + + /* 137d 重新上电:latch 复位,故障复发必须还能出声(否则"每 boot 一条"会退化成 + * "永远一条",第二次开机就永久静默) */ + reset_all(); + ready_only(); + s_kernel_state = osKernelRunning; + syslog_drain(); + c0 = syslog_count(SYSLOG_SELFTEST); + s_vl_failopen_fake = 2u; + syslog_drain(); + syslog_drain(); + ok = (syslog_count(SYSLOG_SELFTEST) == c0 + 1u); + (void)memset(rec, 0, sizeof(rec)); + (void)raw_at(P_SELFTEST, c0, rec); + ok = ok && (memcmp(rec + SYSLOG_DESC_OFFS, "vollock failopen=2", 18u) == 0); + CHECK(ok, "TC-SYSLOG-137d after a reboot the latch re-arms, so a repeat fail-open is audible again (one record per boot, not one per lifetime)"); +} + /* ===================== 入口 ===================== */ int main(void) @@ -2427,6 +2517,7 @@ int main(void) tc_post_throttle(); tc_clear(); tc_kinds(); + tc_vollock_note(); printf("\n=== Sys Log Mcu Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed);