From 3762c03bb4dce53944caab620282d07309452a59 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 17:39:37 +0800 Subject: [PATCH 01/47] =?UTF-8?q?L2=20=E7=9B=B8=E9=87=8F=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E7=8E=AF=E7=BC=93=E5=86=B2=20+=20=E5=85=A8?= =?UTF-8?q?=E5=91=A8=E5=82=85=E6=B0=8F=20+=20=E4=B8=BB=E6=9C=BA=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=EF=BC=88=E5=8F=B0=E6=9E=B6=E9=97=B4=E6=AD=87=E5=A4=8D?= =?UTF-8?q?=E4=BD=8D=E5=BE=85=E6=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增(均已通过主机侧单测与构建,**台架存在未定位的间歇性回归,见下**): - App/algo/sample_ring.c/h:按语义通道的采样环缓冲(2 周波 = 40 点), 整帧压入 + 帧计数,供 L2 按窗取数。 - App/algo/phasor.c/h:全周傅氏相量层,覆盖规划 §3.2 的 L2-1/2/3/6/7—— 系数表按 docs/算法/傅氏算法详解.md §2.3 预乘 √2/N(fAmSQ 直接即有效值); 5 个数据驱动实例(IA/UA/UN 基波 + IA 二/五次谐波);暴力滑动窗; 计算区/使用区双区原子切换。 - test/host/phasor/:主机侧单测(52/52 PASSED,无需桩)——系数表逐项复核、 幅值/有效值/相位、直流与谐波抑制、任意次谐波提取、频率敏感性(±2Hz≤3%)、 环缓冲时序与回绕、实例层与双区交换。 - 集成:adc_task 每拍压帧 + 重算(v2.4);app_main 调 phasor_init(); Keil 工程挂 phasor.c/sample_ring.c(App/algo 已在 IncludePath)。 验证: - 主机单测 52/52 PASSED。 - @build 0 错误 0 警告;内存快照已刷新(RW_IRAM1 89,320→90,936 B)。 - 台架:L2 链路数据正常(ring=10001、skip=0、回读一致、相量日志可见), 但 10 次运行中 3 次出现 UI 任务看门狗停喂(mask=0x67 缺 TASK_WD_UI) → IWDG 复位;改动前基线 6 次运行 0 次。属**未定位的间歇性回归**。 未完成:定位并修复该间歇性复位;L2 文档状态与登记簿条目尚未按结论更新。 --- App/algo/phasor.c | 205 ++++++++++ App/algo/phasor.h | 119 ++++++ App/algo/sample_ring.c | 124 ++++++ App/algo/sample_ring.h | 88 +++++ App/app/app_main.c | 8 + App/task/adc_task.c | 106 ++++- MDK-ARM/STM32F407-Demo.uvprojx | 10 + docs/内存管理/RAM使用分析与优化方案.md | 6 +- test/host/phasor/phasor_test.c | 511 +++++++++++++++++++++++++ test/host/phasor/run_unit_test.py | 57 +++ 10 files changed, 1222 insertions(+), 12 deletions(-) create mode 100644 App/algo/phasor.c create mode 100644 App/algo/phasor.h create mode 100644 App/algo/sample_ring.c create mode 100644 App/algo/sample_ring.h create mode 100644 test/host/phasor/phasor_test.c create mode 100644 test/host/phasor/run_unit_test.py diff --git a/App/algo/phasor.c b/App/algo/phasor.c new file mode 100644 index 00000000..696f9e0a --- /dev/null +++ b/App/algo/phasor.c @@ -0,0 +1,205 @@ +/* + * 模块名称:相量计算层(L2 相量计算) + * 模块功能:全周傅氏算法(单频 DFT)——系数表预乘 √2/N,fAmSQ 直接即有效值。 + * 覆盖 L2-1 系数表 / L2-2 实例 / L2-3 有效值 / L2-6 滑动窗 / L2-7 双区交换。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2-1/2/3/6/7 + */ + +#include "phasor.h" + +#include +#include + +#include "sample_ring.h" + +/* ===== L2-1 全周傅氏系数表 ===== + * g_fFFTSin20[k] = sin(2πk/N)·(√2/N) + * g_fFFTCos20[k] = cos(2πk/N)·(√2/N) N = PHASOR_N = 20 + * 权威口径:docs/算法/傅氏算法详解.md §2.3("把 √2 藏进系数表")。 + * 本表为**离线生成**的 float 常量(不在运行期调 sinf/cosf):确定、无 libm 精度依赖。 + * 主机侧单测 test/host/phasor/ 会**逐项按上式复核本表**(防转录错), + * 并以合成正弦做端到端复核(读数误差 ≤ 1%)。 + * 精确零点(sin(π)=0、cos(π/2)=0)直接写 0.0f,不保留浮点噪声。 */ +static const float k_fft_sin[PHASOR_N] = { + +0.000000000e+00f, +2.185080200e-02f, +4.156269506e-02f, +5.720613897e-02f, +6.724984944e-02f, + +7.071068138e-02f, +6.724984944e-02f, +5.720613897e-02f, +4.156269506e-02f, +2.185080200e-02f, + +0.000000000e+00f, -2.185080200e-02f, -4.156269506e-02f, -5.720613897e-02f, -6.724984944e-02f, + -7.071068138e-02f, -6.724984944e-02f, -5.720613897e-02f, -4.156269506e-02f, -2.185080200e-02f, +}; + +static const float k_fft_cos[PHASOR_N] = { + +7.071068138e-02f, +6.724984944e-02f, +5.720613897e-02f, +4.156269506e-02f, +2.185080200e-02f, + +0.000000000e+00f, -2.185080200e-02f, -4.156269506e-02f, -5.720613897e-02f, -6.724984944e-02f, + -7.071068138e-02f, -6.724984944e-02f, -5.720613897e-02f, -4.156269506e-02f, -2.185080200e-02f, + +0.000000000e+00f, +2.185080200e-02f, +4.156269506e-02f, +5.720613897e-02f, +6.724984944e-02f, +}; + +/* √2:幅值 = √2 × 有效值(与系数表的 √2 折算互逆) */ +#define PHASOR_SQRT2 1.4142135623730951f + +/* ===== L2-2 相量实例表 ===== + * 参考项目为 **4 个实例**(C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波, + * `APP/CLS100_Dsp/InstCalCell.c`)。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`, + * 见规划 §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开——C01 的"I/U" + * 拆为两行(IA 电流 / UA 电压),并补出 C04 的五次谐波轨(L1-9)。 + * 新增实例只需在表尾加一行,其余代码自动适配。 */ +static const phasor_inst_t k_inst[] = { + { "C01-IA-F1", ADC_SEM_IA, 1u }, /* A 相电流基波(L3-1 过流 / L3-3 过负荷) */ + { "C02-UA-F1", ADC_SEM_UA, 1u }, /* A 相电压基波(L3-4 过压/欠压、L3-5 频率) */ + { "C03-UN-F1", ADC_SEM_UN, 1u }, /* 零序电压基波(L3-6 零序过压) */ + { "C04-IA-F2", ADC_SEM_IA, 2u }, /* 二次谐波(L3-7 涌流制动) */ + { "C05-IA-F5", ADC_SEM_IA, 5u }, /* 五次谐波(L1-9 选线) */ +}; + +#define PHASOR_INST_N (sizeof(k_inst) / sizeof(k_inst[0])) + +/* ===== L2-7 双区(计算区 / 使用区)===== + * 本拍结果先写入"计算区",全部实例写完后**一次性**切换 s_use_zone + * (单字节 store,Cortex-M 上原子),消费侧读"使用区" → 无锁、无撕裂。 + * s_use_zone 声明 volatile:阻止编译器把结果写入挪到切换之后 + * (本平台单核顺序执行,无需额外内存屏障)。 + * 今日唯一消费者是 adcTask 自身(同拍读写、实际无竞争),本机制为后续 + * 独立保护任务(L3)预留。 */ +static phasor_t s_zone[2][PHASOR_INST_N]; +static volatile uint8_t s_use_zone; +static uint8_t s_ready; /* 使用区是否已产出过有效结果 */ + +/* + * 函数功能:清零一个相量 + * 入口参数:p - 目标相量 + * 返 回 值:无 + * 限定条件:p 非空 + * 函数说明:静态辅助。 + */ +static void phasor_zero(phasor_t *p) { + p->fRe = 0.0f; + p->fIm = 0.0f; + p->fAm = 0.0f; + p->fAmSQ = 0.0f; +} + +/* + * 函数功能:对 n 点窗口算 harm 次相量(L2-1 系数表 + L2-3 有效值) + * 入口参数:x - 采样窗(按时间序,旧 → 新) + * n - 窗长(必须等于 PHASOR_N) + * harm - 谐波次数(>=1) + * out - 输出相量 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:纯函数。索引步进 = 谐波次数,故同一张表可算任意次谐波。 + */ +void phasor_calc(const float *x, uint8_t n, uint8_t harm, phasor_t *out) { + float re = 0.0f; + float im = 0.0f; + uint8_t i; + + if ((x == NULL) || (out == NULL) || (n != (uint8_t)PHASOR_N) || (harm == 0u)) { + return; + } + + for (i = 0u; i < n; i++) { + uint8_t idx = (uint8_t)(((unsigned int)i * harm) % n); + re += x[i] * k_fft_sin[idx]; + im += x[i] * k_fft_cos[idx]; + } + + out->fRe = re; + out->fIm = im; + out->fAmSQ = sqrtf((re * re) + (im * im)); /* 系数已折算 √2/N → 直接即有效值 */ + out->fAm = out->fAmSQ * PHASOR_SQRT2; /* 幅值 = √2 × 有效值 */ +} + +/* + * 函数功能:相量层初始化 + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前调用 + * 函数说明:清双区结果与有效标志,并复位采样环缓冲;可重复调用(幂等)。 + */ +int phasor_init(void) { + uint8_t i; + + for (i = 0u; i < (uint8_t)PHASOR_INST_N; i++) { + phasor_zero(&s_zone[0][i]); + phasor_zero(&s_zone[1][i]); + } + s_use_zone = 0u; + s_ready = 0u; + sample_ring_init(); + return 0; +} + +/* + * 函数功能:取相量实例数(L2-2) + * 入口参数:无 + * 返 回 值:实例个数 + * 限定条件:无 + * 函数说明:供遍历 / 日志打印。 + */ +uint8_t phasor_inst_count(void) { + return (uint8_t)PHASOR_INST_N; +} + +/* + * 函数功能:取相量实例描述 + * 入口参数:idx - 实例下标 + * 返 回 值:指向静态实例表的指针;idx 越界返回 NULL + * 限定条件:无 + * 函数说明:返回静态存储地址,调用方不得修改。 + */ +const phasor_inst_t *phasor_inst(uint8_t idx) { + if (idx >= (uint8_t)PHASOR_INST_N) { + return NULL; + } + return &k_inst[idx]; +} + +/* + * 函数功能:重算全部实例 + 双区原子切换(L2-6 / L2-7) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅任务上下文调用;须先 sample_ring_push_frame() 压入本拍数据 + * 函数说明:任一实例数据不足一周波 → 本拍整体不产出(不切换使用区), + * 避免出现"部分新部分旧"的混合结果。 + */ +void phasor_run(void) { + float win[PHASOR_N]; + uint8_t w = (uint8_t)(1u - s_use_zone); /* 计算区 = 非使用区 */ + uint8_t i; + + for (i = 0u; i < (uint8_t)PHASOR_INST_N; i++) { + if (sample_ring_window(k_inst[i].sem, (uint8_t)PHASOR_N, win) != 0) { + return; /* 尚未攒满一周波:本拍不产出 */ + } + phasor_calc(win, (uint8_t)PHASOR_N, k_inst[i].harm, &s_zone[w][i]); + } + + s_use_zone = w; /* 原子切换(单字节 store) */ + s_ready = 1u; +} + +/* + * 函数功能:读实例结果(使用区,无锁) + * 入口参数:idx - 实例下标 + * out - 输出相量 + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 尚无有效结果 + * 限定条件:无 + * 函数说明:先取一次使用区下标再索引,避免读写期间下标变化。 + */ +int phasor_get(uint8_t idx, phasor_t *out) { + uint8_t z; + + if ((out == NULL) || (idx >= (uint8_t)PHASOR_INST_N)) { + return -1; + } + if (s_ready == 0u) { + return -2; + } + z = s_use_zone; + *out = s_zone[z][idx]; + return 0; +} diff --git a/App/algo/phasor.h b/App/algo/phasor.h new file mode 100644 index 00000000..cb5d63cb --- /dev/null +++ b/App/algo/phasor.h @@ -0,0 +1,119 @@ +/* + * 模块名称:相量计算层(L2 相量计算) + * 模块功能:全周傅氏(全周波傅里叶)算法——对一周波 20 点采样做单频 DFT, + * 产出基波/谐波相量。系数表**预乘 √2/N**(口径见 + * `docs/算法/傅氏算法详解.md` §2.3),故 `fAmSQ = √(fRe²+fIm²)` + * **直接就是有效值**,无需再除 √2。 + * 覆盖规划 §3.2:L2-1 系数表 / L2-2 实例 / L2-3 有效值输出 / + * L2-6 每 1 ms 滑动窗 / L2-7 双区指针原子交换。 + * ⚠ 本层**纯算法**:不碰寄存器、不碰任务调度、不自己取数(输入由 + * `sample_ring` 提供)→ 主机侧可单测(L0.5,落实登记簿 A-4)。 + * 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2-1/2/3/6/7(基波 + 谐波相量) + */ + +#ifndef PHASOR_H +#define PHASOR_H + +#include + +#include "adc_input.h" /* adc_semantic_t:实例按语义通道绑定 */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 每周波采样点数(1000 Hz @ 50 Hz,见 docs/子系统设计/ADC采样节拍设计说明.md) */ +#define PHASOR_N 20U + +/* 相量结果(**有效值口径**)。字段名沿用参考系统 `{fRe, fIm, fAm, fAmSQ}` + * (规划 §10.2 术语表:实部/虚部/幅值/有效值): + * fRe - 实部 = a1/√2(有效值相量实部) + * fIm - 虚部 = b1/√2(有效值相量虚部) + * fAm - 幅值 = √2·√(fRe²+fIm²)(正弦峰值 A1) + * fAmSQ - 有效值 = √(fRe²+fIm²)(系数已折算 √2/N,故直接即有效值) + * 相位(L2-4)按需由 `atan2(fIm, fRe)` 导出,不常驻本结构。 */ +typedef struct { + float fRe; + float fIm; + float fAm; + float fAmSQ; +} phasor_t; + +/* 相量实例(L2-2):**数据驱动**——新增实例只需在实例表尾加一行 */ +typedef struct { + const char *name; /* 实例名(日志 / UI 用) */ + adc_semantic_t sem; /* 语义通道 */ + uint8_t harm; /* 谐波次数(1=基波,2=二次,5=五次…) */ +} phasor_inst_t; + +/* + * 函数功能:相量层初始化 + * 入口参数:无 + * 返 回 值:0 - 成功 + * 限定条件:内核启动前调用(无 RTOS 依赖) + * 函数说明:清双区结果与有效标志,并复位采样环缓冲;可重复调用(幂等)。 + */ +int phasor_init(void); + +/* + * 函数功能:取相量实例数(L2-2) + * 入口参数:无 + * 返 回 值:实例个数 + * 限定条件:无 + * 函数说明:供遍历 / 日志打印。 + */ +uint8_t phasor_inst_count(void); + +/* + * 函数功能:取相量实例描述 + * 入口参数:idx - 实例下标 + * 返 回 值:指向静态实例表的指针;idx 越界返回 NULL + * 限定条件:无 + * 函数说明:返回静态存储地址,调用方不得修改。 + */ +const phasor_inst_t *phasor_inst(uint8_t idx); + +/* + * 函数功能:重算全部实例并从计算区原子切换到使用区(L2-6 滑动窗 + L2-7 双区交换) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅任务上下文调用;须先经 `sample_ring_push_frame()` 压入本拍数据 + * 函数说明:以"当前时刻为终止的前一周波"为窗,**20 点全部重算**(暴力法, + * 无累积误差)。**任一实例数据不足一周波则本拍整体不产出**(不切换 + * 使用区),避免部分新部分旧的混合结果。每拍更新,故相量刷新周期 = 1 ms。 + */ +void phasor_run(void); + +/* + * 函数功能:读实例结果(使用区,无锁) + * 入口参数:idx - 实例下标 + * out - 输出相量 + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 尚无有效结果(未攒满一周波) + * 限定条件:无 + * 函数说明:读的是"使用区",与正在写的"计算区"物理隔离 → 无撕裂、无锁。 + */ +int phasor_get(uint8_t idx, phasor_t *out); + +/* + * 函数功能:对 n 点窗口算 harm 次相量(L2-1 系数表 + L2-3 有效值) + * 入口参数:x - 采样窗(按时间序,旧 → 新;长度须 = PHASOR_N) + * n - 窗长(必须等于 PHASOR_N) + * harm - 谐波次数(>=1) + * out - 输出相量 + * 返 回 值:无(参数非法时 out 保持不动) + * 限定条件:无 + * 函数说明:**纯函数**(无状态),可脱离采样环独立调用,便于单测与复用。 + * 索引步进 = 谐波次数即可取任意次谐波(sin 以 2π 为周期, + * 故 `sin(2π·((n·h) mod N)/N) ≡ sin(2π·n·h/N)`)。 + */ +void phasor_calc(const float *x, uint8_t n, uint8_t harm, phasor_t *out); + +#ifdef __cplusplus +} +#endif + +#endif /* PHASOR_H */ diff --git a/App/algo/sample_ring.c b/App/algo/sample_ring.c new file mode 100644 index 00000000..ce56fd7c --- /dev/null +++ b/App/algo/sample_ring.c @@ -0,0 +1,124 @@ +/* + * 模块名称:采样环缓冲(L2 相量计算的输入) + * 模块功能:按语义通道保存最近 2 个周波(40 点)采样值,供 L2 相量计算按窗取数。 + * 适用平台:STM32F407ZGTx(纯算法,无寄存器/无 RTOS 依赖) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2 输入环缓冲(20 点/周波 × 2 周波) + */ + +#include "sample_ring.h" + +#include + +/* 采样环:[语义通道][点]。按语义通道索引(0..ADC_SEM_COUNT-1), + * 故消费侧"按语义取数",不依赖任何物理通道号或转换器归属。 */ +static float s_ring[ADC_SEM_COUNT][SAMPLE_RING_DEPTH]; +/* 写指针:下一个待写入的点下标(0..SAMPLE_RING_DEPTH-1) */ +static uint16_t s_head; +/* 已累积点数(饱和于 SAMPLE_RING_DEPTH) */ +static uint16_t s_filled; +/* 累计入环帧数(不饱和,供心跳/丢帧观测) */ +static uint32_t s_frames; + +/* + * 函数功能:采样环缓冲初始化 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:清空全部通道历史与计数;可重复调用(幂等)。 + */ +void sample_ring_init(void) { + uint8_t c; + uint16_t i; + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + for (i = 0u; i < SAMPLE_RING_DEPTH; i++) { + s_ring[c][i] = 0.0f; + } + } + s_head = 0u; + s_filled = 0u; + s_frames = 0u; +} + +/* + * 函数功能:压入一拍(一帧)全部语义通道的采样值 + * 入口参数:values - 按语义通道顺序排列的一帧采样值(长度须 = ADC_SEM_COUNT) + * n - 实际长度(必须等于 ADC_SEM_COUNT) + * 返 回 值:0 - 成功;-1 - 参数错 + * 限定条件:同一帧内各通道须同刻(同族量同刻性由上游 L1 负责) + * 函数说明:整帧写入完毕后**统一**推进写指针——单帧内不存在"半新半旧", + * 消费侧取到的窗永远落在整帧边界上(无帧内撕裂)。 + */ +int sample_ring_push_frame(const float *values, uint8_t n) { + uint8_t c; + + if ((values == NULL) || (n != (uint8_t)ADC_SEM_COUNT)) { + return -1; + } + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + s_ring[c][s_head] = values[c]; + } + + s_head = (uint16_t)((s_head + 1u) % SAMPLE_RING_DEPTH); + if (s_filled < SAMPLE_RING_DEPTH) { + s_filled++; + } + s_frames++; + return 0; +} + +/* + * 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新) + * 入口参数:ch - 语义通道 + * n - 取点数(1..SAMPLE_RING_DEPTH) + * out - 输出缓冲(长度须 >= n) + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n + * 限定条件:无 + * 函数说明:out[n-1] 为最新一点。最老点下标 = head - n(模 DEPTH)。 + */ +int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) { + uint16_t start; + uint16_t i; + + if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) { + return -1; + } + if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) { + return -1; + } + if (s_filled < (uint16_t)n) { + return -2; + } + + start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH); + for (i = 0u; i < (uint16_t)n; i++) { + out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)]; + } + return 0; +} + +/* + * 函数功能:读已累积点数(饱和于 SAMPLE_RING_DEPTH) + * 入口参数:无 + * 返 回 值:已累积点数(0..SAMPLE_RING_DEPTH) + * 限定条件:无 + * 函数说明:供上层判断"数据是否够一周波"。 + */ +uint16_t sample_ring_filled(void) { + return s_filled; +} + +/* + * 函数功能:读累计入环帧数(不饱和) + * 入口参数:无 + * 返 回 值:累计入环帧数 + * 限定条件:无 + * 函数说明:供心跳日志 / 丢帧观测(L1-6 判据之一)。 + */ +uint32_t sample_ring_frames(void) { + return s_frames; +} diff --git a/App/algo/sample_ring.h b/App/algo/sample_ring.h new file mode 100644 index 00000000..53f92110 --- /dev/null +++ b/App/algo/sample_ring.h @@ -0,0 +1,88 @@ +/* + * 模块名称:采样环缓冲(L2 相量计算的输入) + * 模块功能:按**语义通道**保存最近若干周波的采样值,供 L2 相量计算按窗取数。 + * V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18),本层只做"按时序 + * 存/取",**不做任何跨通道运算**——同刻性由上游(L1)保证,本层不假设。 + * 容量取 **2 个周波(40 点)**,与参考系统 `CN_BUFSAM_CYCLE = 2` + * (规划 §10.1)一致,为后续加长数据窗(1.5~2 周波,抗衰减直流, + * 见 `傅氏算法详解.md` §9 Q4)预留空间。 + * 适用平台:STM32F407ZGTx(**纯算法**:不碰寄存器、不碰 RTOS → 可主机侧单测 L0.5) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建:L2 输入环缓冲(20 点/周波 × 2 周波) + */ + +#ifndef SAMPLE_RING_H +#define SAMPLE_RING_H + +#include + +#include "adc_input.h" /* adc_semantic_t / ADC_SEM_COUNT:本层按语义通道存取 */ + +#ifdef __cplusplus +extern "C" { +#endif + +/* 每周波采样点数(1000 Hz @ 50 Hz,见 docs/子系统设计/ADC采样节拍设计说明.md) */ +#define SAMPLE_RING_CYCLE_N 20U +/* 环容量(周波数)——与参考系统 CN_BUFSAM_CYCLE 一致 */ +#define SAMPLE_RING_CYCLES 2U +/* 环深度(点):一周波点数 × 周波数 */ +#define SAMPLE_RING_DEPTH (SAMPLE_RING_CYCLE_N * SAMPLE_RING_CYCLES) + +/* + * 函数功能:采样环缓冲初始化 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:内核启动前调用(无 RTOS 依赖) + * 函数说明:清空全部通道历史与计数;可重复调用(幂等)。 + */ +void sample_ring_init(void); + +/* + * 函数功能:压入一拍(一帧)全部语义通道的采样值 + * 入口参数:values - 按语义通道顺序排列的一帧采样值(浮点工程量,长度须 = ADC_SEM_COUNT) + * n - 实际长度(必须等于 ADC_SEM_COUNT,否则拒收) + * 返 回 值:0 - 成功;-1 - 参数错(空指针或长度不符) + * 限定条件:调用方保证同一帧内各通道**同刻**(同族量同刻性由上游 L1 负责) + * 函数说明:整帧一次性写入,写完统一推进写指针——单帧内不可能出现"半新半旧", + * 即天然无帧内撕裂。消费侧读到的窗永远是**整帧**边界。 + */ +int sample_ring_push_frame(const float *values, uint8_t n); + +/* + * 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新) + * 入口参数:ch - 语义通道 + * n - 取点数(1..SAMPLE_RING_DEPTH) + * out - 输出缓冲(长度须 >= n) + * 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n + * 限定条件:无 + * 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。 + */ +int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out); + +/* + * 函数功能:读已累积点数(饱和于 SAMPLE_RING_DEPTH) + * 入口参数:无 + * 返 回 值:已累积点数(0..SAMPLE_RING_DEPTH) + * 限定条件:无 + * 函数说明:供上层判断"数据是否够一周波"。 + */ +uint16_t sample_ring_filled(void); + +/* + * 函数功能:读累计入环帧数(**不饱和**,供心跳/丢帧观测) + * 入口参数:无 + * 返 回 值:累计入环帧数(32 位,1 kHz 下约 49.7 天回绕) + * 限定条件:无 + * 函数说明:与 L1 的 `tpafe5160_frame_seq()` 对照可判丢帧(L1-6 采样有效性校验 + * 的判据之一,本层只提供计数,不做判定)。 + */ +uint32_t sample_ring_frames(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SAMPLE_RING_H */ diff --git a/App/app/app_main.c b/App/app/app_main.c index b7cbc48c..39e54c47 100644 --- a/App/app/app_main.c +++ b/App/app/app_main.c @@ -24,6 +24,7 @@ #include "gd5f2gq5ue.h" #include "tpafe5160.h" #include "adc_input.h" /* L1 采集层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */ +#include "phasor.h" /* L2 相量层:phasor_init()(系数表 + 实例 + 双区交换) */ #include "sys_log.h" #include "task_watchdog.h" /* task_wd_seg_report():round 8 复位不丢"进段打点"上报 */ @@ -194,6 +195,13 @@ int app_main_init(void) { DBG_INFO("ADC input layer init: OK"); } + /* 4c) 相量层(L2)初始化:清双区结果与有效标志 + 复位采样环缓冲。 + * 纯算法层(无外设 / 无 RTOS 依赖,见 docs/算法/继电保护功能复现规划.md + * §3.2),须在启动采样节拍(步骤 6)之前。幂等、当前无失败路径 + * (返回值保留供后续自检扩展)。 */ + (void)phasor_init(); + DBG_INFO("phasor layer init: OK (inst=%u)", (unsigned)phasor_inst_count()); + /* 5) SD2506 RTC 诊断信息 */ { /* 读取 ID */ diff --git a/App/task/adc_task.c b/App/task/adc_task.c index d96f7a40..1043448b 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -1,7 +1,9 @@ /* * 模块名称:ADC Sampling Task - * 模块功能:ADC 采集消费——TPAFE5160 快照 + STM32 ADC1 顺序扫描(经 L1 层 - * App/algo/adc_input.c 按语义取数)+ 帧统计(节拍由 TIM6 硬件定时器提供) + * 模块功能:ADC 采集消费 + L2 相量计算——TPAFE5160 快照 + STM32 ADC1 顺序扫描 + * (经 L1 层 App/algo/adc_input.c 按语义取数)→ 压入采样环 + * (App/algo/sample_ring.c)→ 全周傅氏相量重算(App/algo/phasor.c) + * + 帧统计(节拍由 TIM6 硬件定时器提供) * 状态说明:**B1 已落地(2026-10-10)**——本任务从空桩转为真实消费者: * 用 tpafe5160_snapshot() 原子取数(AGENTS 铁律),统计帧数/无新帧次数, * 并纳入看门狗 required_mask(TASK_WD_ADC)。 @@ -35,11 +37,22 @@ * v2.3 2026-10-10 王建锋 L1-3/L1-4:取数改走 App/algo/adc_input.c(语义映射 + * STM32 ADC1 9 通道扫描);首帧日志补全 8 路电气量 + 温度, * 心跳日志增 adc1_rc / adc1_err 观测 STM32 侧健康度。 + * v2.4 2026-10-10 王建锋 L2:接入相量层——每拍把 9 路语义值压入采样环 + * (sample_ring)并重算全部相量实例(phasor_run); + * 攒满一周波后**首拍即落一次相量日志**(开机证据,不等 + * 10 s 心跳),心跳日志增 ring / skip 计数与各实例相量 + * (Re/Im/有效值,×0.1 定点打印,因 MicroLIB 的 snprintf + * 无 %f)。 + * ⚠ L1-5 通道标定未实现(缺标准源)→ 暂以**原始码值** + * 充作工程量(系数 = 1),故相量读数只证明**链路通**, + * 不可作幅值精度判据(同 L1-3 的替代口径)。 */ #include "adc_task.h" #include "cmsis_os.h" #include "adc_input.h" /* L1 采集层:adc_input_poll()/adc_input_raw()(按语义取数) */ +#include "sample_ring.h" /* L2 输入:sample_ring_push_frame()(采样环缓冲) */ +#include "phasor.h" /* L2 相量层:phasor_run()/phasor_get()(全周傅氏) */ #include "tpafe5160.h" #include "task_watchdog.h" #include "console_log.h" @@ -56,6 +69,34 @@ * 原始码值缓冲由 L1 层 App/algo/adc_input.c 持有(消费侧按语义取数,不写死下标) */ static uint32_t s_frames_consumed; static uint32_t s_frames_idle; +/* 因通道尚未首次成功而**整拍跳过入环**的次数(不向相量窗注入 0 点) */ +static uint32_t s_frames_skip; + +/* + * 函数功能:打印全部相量实例(×0.1 定点) + * 入口参数:tag - 前缀标记("first" 首次产出 / "hb" 周期心跳) + * 返 回 值:无 + * 限定条件:仅任务上下文;调用方须保证 dbg_console_writer_ready() 为真 + * 函数说明:MicroLIB 的 snprintf **不支持 %f** → 以 ×0.1 定点整数打印 + * (打印值 = 实际值 × 10,分辨率 0.1)。逐实例数据驱动输出, + * 实例表加行即自动覆盖。⚠ L1-5 标定未实现 → 读数单位是**原始码值**。 + */ +static void log_phasors(const char *tag) { + uint8_t k; + + for (k = 0u; k < phasor_inst_count(); k++) { + const phasor_inst_t *d = phasor_inst(k); + phasor_t p; + + if ((d != NULL) && (phasor_get(k, &p) == 0)) { + DBG_INFO("phasor(%s) %s: Re=%ld Im=%ld rms=%ld (x0.1)", + tag, d->name, + (long)(p.fRe * 10.0f), + (long)(p.fIm * 10.0f), + (long)(p.fAmSQ * 10.0f)); + } + } +} /* * 函数功能:ADC 任务初始化 @@ -67,6 +108,7 @@ static uint32_t s_frames_idle; int adc_task_init(void) { s_frames_consumed = 0u; s_frames_idle = 0u; + s_frames_skip = 0u; DBG_INFO("adc_task_init"); return 0; } @@ -85,6 +127,7 @@ void adc_task_func(void *arg) { uint32_t next = osKernelGetTickCount(); uint32_t report_at = next + ADC_TASK_REPORT_MS; uint8_t first_logged = 0u; + uint8_t first_phasor_logged = 0u; (void)arg; @@ -103,7 +146,36 @@ void adc_task_func(void *arg) { /* r == -1:参数错。调用点 count 为常量,不可达;不计入 idle 以区分异常 */ } - /* 2) 首帧码值上报:**写者就绪后**才落日志。写者未注册时 con_append 走 + /* 2) L2 输入 + 相量重算:把本拍 9 路语义值转浮点帧压入采样环,再重算全部 + * 相量实例(L2-6 滑动窗:每拍以"当前时刻为终止的前一周波"整窗重算)。 + * 任一通道读不到(尚未首次成功)→ 本拍**整帧跳过**:不向窗内注入 0 点 + * (单个 0 点会显著污染 20 点窗),并计入 skip 观测。 */ + { + float frame[ADC_SEM_COUNT]; + uint8_t s; + uint8_t all_ok = 1u; + + for (s = 0u; s < (uint8_t)ADC_SEM_COUNT; s++) { + int32_t code = 0; + + if (adc_input_raw((adc_semantic_t)s, &code) != 0) { + all_ok = 0u; + break; + } + /* ⚠ L1-5 通道标定尚未实现(缺标准源)→ 暂以**原始码值**充作工程量 + * (系数 = 1)。标定落地后此处改为 工程量 = (code + b) × k(定点 2^10)。 */ + frame[s] = (float)code; + } + + if (all_ok != 0u) { + (void)sample_ring_push_frame(frame, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + } else { + s_frames_skip++; + } + } + + /* 3) 首帧码值上报:**写者就绪后**才落日志。写者未注册时 con_append 走 * 无锁直发路径,与 defaultTask 的存储初始化日志会字节级交错 * (2026-10-10 台架实测);此时挂起上报,就绪后补报最近一帧。 * 8 路电气量 + 温度全部经 adc_input_raw() **按语义**取数(L1-4), @@ -129,29 +201,45 @@ void adc_task_func(void *arg) { (long)ia, (long)ib, (long)ic, (long)in, (long)temp); } - /* 3) 看门狗心跳(本任务已纳入 required_mask) */ + /* 4) 首拍相量上报:攒满一周波(20 ms)后立即落一次,作为 L2 链路的开机证据。 + * 不依赖 10 s 心跳 → 台架 @test 的 settle 窗口内即可见(否则会被静默判据 + * 截断,见 docs/测试说明/自动化测试流程.md 的 settle 口径)。 */ + if ((first_phasor_logged == 0u) && (dbg_console_writer_ready() != 0u)) { + phasor_t p; + + if (phasor_get(0u, &p) == 0) { + first_phasor_logged = 1u; + log_phasors("first"); + } + } + + /* 5) 看门狗心跳(本任务已纳入 required_mask) */ task_wd_beat(TASK_WD_ADC); - /* 4) 周期心跳日志:帧统计 + 驱动侧 FRSTDATA 错误计数。 - * 写者就绪前抑制(同 2) 的交错理由);到点无论是否落日志都推进下个上报点 */ + /* 6) 周期心跳日志:帧统计 + 驱动侧 FRSTDATA 错误计数 + L2 相量。 + * 写者就绪前抑制(同 3) 的交错理由);到点无论是否落日志都推进下个上报点 */ { uint32_t now = osKernelGetTickCount(); if ((int32_t)(now - report_at) >= 0) { if (dbg_console_writer_ready() != 0u) { - DBG_INFO("frames=%lu idle=%lu seq=%lu frst_err=%u adc1_rc=%d adc1_err=%lu", + DBG_INFO("frames=%lu idle=%lu skip=%lu seq=%lu frst_err=%u adc1_rc=%d adc1_err=%lu ring=%lu", (unsigned long)s_frames_consumed, (unsigned long)s_frames_idle, + (unsigned long)s_frames_skip, (unsigned long)tpafe5160_frame_seq(), (unsigned)tpafe5160_frstdata_err(), adc_input_stm32_rc(), - (unsigned long)adc_input_stm32_errs()); + (unsigned long)adc_input_stm32_errs(), + (unsigned long)sample_ring_frames()); + + log_phasors("hb"); } report_at = now + ADC_TASK_REPORT_MS; } } - /* 5) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) */ + /* 7) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) */ next += ADC_TASK_BEAT_MS; (void)osDelayUntil(next); } diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 3d78c155..6ed5360b 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -942,6 +942,16 @@ 1 ..\App\algo\adc_input.c + + sample_ring.c + 1 + ..\App\algo\sample_ring.c + + + phasor.c + 1 + ..\App\algo\phasor.c + diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 94963a88..524a4ebd 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 269,940 B (263.61 KB) | 1,048,576 B (1024 KB) | 25.7% | -| RW_IRAM1 | 89,320 B (87.23 KB) | 131,072 B (128 KB) | 68.1% | +| ER_IROM1 | 271,440 B (265.08 KB) | 1,048,576 B (1024 KB) | 25.9% | +| RW_IRAM1 | 90,936 B (88.80 KB) | 131,072 B (128 KB) | 69.4% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **270,712 B (264.4 KB)** | 1,048,576 B (1024 KB) | **25.8%** | +| **Flash (ROM 合计)** | **272,228 B (265.8 KB)** | 1,048,576 B (1024 KB) | **26.0%** | 各区的容量与常量来源: diff --git a/test/host/phasor/phasor_test.c b/test/host/phasor/phasor_test.c new file mode 100644 index 00000000..f32b028e --- /dev/null +++ b/test/host/phasor/phasor_test.c @@ -0,0 +1,511 @@ +/* + * 模块名称:相量层(L2)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/phasor.c + App/algo/sample_ring.c 做离线验证。 + * + * 被测对象:真实源文件(以 #include 原样纳入,保证测的是生产代码而非副本): + * ../../../App/algo/phasor.c + * ../../../App/algo/sample_ring.c + * 本层为**纯算法**(无寄存器、无 RTOS、无文件/外设依赖),故**无需任何桩**。 + * + * 为什么要主机侧单测(而不是只用板端套件): + * 1. 相量算法是纯数学变换,与硬件无关;板端**无信号源**,无法注入已知幅值/相位/ + * 频率的标准波形,因而无法验证"读数 = 设定值"这一核心判据(同 L1-3 的替代口径)。 + * 主机侧可合成任意波形,把幅值精度/谐波抑制/频率敏感性一次性钉死。 + * 2. 可精确构造边界:恰好一周波 / 不足一周波 / 环形回绕 / 任意次谐波 / 频偏。 + * 3. 不占 MCU 的 RAM,毫秒级完成。 + * + * 覆盖点(对应 docs/算法/继电保护功能复现规划.md §3.2): + * TC-PH-001 L2-1 系数表逐项等于 sin(2πk/N)·(√2/N) / cos(2πk/N)·(√2/N)(防转录错) + * TC-PH-002 L2-1 系数表精确零点(sin@0,10 / cos@5,15)与归一化 Σcoe² = 1/N + * TC-PH-003 L2-3 纯基波:fAmSQ = A/√2(有效值),fAm = √2·fAmSQ(幅值) + * TC-PH-004 L2-3/L2-4 实部虚部与相位:atan2(fIm,fRe) = 注入相角 + * TC-PH-005 直流 + 2/3/5 次谐波被完全滤除(基波读数不变) + * TC-PH-006 任意次谐波提取(索引步进 = 谐波次数):k=2 / k=5 + * TC-PH-007 频率偏离 50 Hz 的幅值误差(±1 Hz ≤ 1.5%,±2 Hz ≤ 3%,见 §9 Q3) + * TC-PH-010 采样环:入环/取窗时序、回绕、饱和、帧计数、参数守卫 + * TC-PH-020 L2-2 实例层:实例数/实例表/未攒满一周波不产出 + * TC-PH-021 L2-6 滑动窗:连续滑动 20 拍基波读数恒定 + * TC-PH-022 L2-7 双区交换:使用区交替 + get 返回**最后一次** run 的结果(非陈旧) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-10 + */ + +#include +#include +#include + +#define PH_PI 3.14159265358979323846 +/* 采样率(Hz):1000 Hz @ 50 Hz → 20 点/周波 */ +#define PH_FS 1000.0 +#define PH_F0 50.0 + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码 ===================== */ +/* 原样纳入生产源文件(纯算法,无需桩) */ +#include "sample_ring.c" +#include "phasor.c" + +/* ===================== 工具 ===================== */ + +/* + * 函数功能:生成一段正弦窗 + * 入口参数:x - 输出(长度 >= n) + * n - 点数 + * amp - 正弦峰值 + * f - 频率(Hz) + * phase - 初相(度) + * 返 回 值:无 + * 限定条件:无 + * 函数说明:以 t = i/PH_FS 采样,即"以当前时刻为终止的前 n 点窗"。 + */ +static void gen_sine(float *x, uint8_t n, double amp, double f, double phase) { + uint8_t i; + + for (i = 0u; i < n; i++) { + x[i] = (float)(amp * sin(2.0 * PH_PI * f * (double)i / PH_FS + phase * PH_PI / 180.0)); + } +} + +/* + * 函数功能:按时刻 t 构造一整帧(9 路语义通道同值) + * 入口参数:f - 输出帧(长度 = ADC_SEM_COUNT) + * t - 采样序号(t/PH_FS 秒) + * amp - 正弦峰值 + * phase - 初相(度) + * 返 回 值:无 + * 限定条件:无 + * 函数说明:仅用于驱动实例层/采样环;**不代表真实硬件**(V0.1 上各通道不同刻, + * 此处只为验证相量层本身)。 + */ +static void frame_at(float *f, uint32_t t, double amp, double phase) { + uint8_t c; + double v = amp * sin(2.0 * PH_PI * PH_F0 * (double)t / PH_FS + phase * PH_PI / 180.0); + + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { + f[c] = (float)v; + } +} + +/* ===================== TC-PH-001/002:L2-1 系数表 ===================== */ + +static void tc_table(void) { + const double scale = sqrt(2.0) / (double)PHASOR_N; + double sum_sin = 0.0, sum_cos = 0.0; + int ok_sin = 1, ok_cos = 1, ok_norm = 1; + uint8_t k; + + for (k = 0u; k < (uint8_t)PHASOR_N; k++) { + double arg = 2.0 * PH_PI * (double)k / (double)PHASOR_N; + double es = sin(arg) * scale; + double ec = cos(arg) * scale; + + if (fabs((double)k_fft_sin[k] - es) > 1e-7) { ok_sin = 0; } + if (fabs((double)k_fft_cos[k] - ec) > 1e-7) { ok_cos = 0; } + + /* 归一化:sin²+cos² = (√2/N)² = 2/N² */ + if (fabs((double)k_fft_sin[k] * (double)k_fft_sin[k] + + (double)k_fft_cos[k] * (double)k_fft_cos[k] - 2.0 / (PHASOR_N * PHASOR_N)) > 1e-9) { + ok_norm = 0; + } + sum_sin += (double)k_fft_sin[k] * (double)k_fft_sin[k]; + sum_cos += (double)k_fft_cos[k] * (double)k_fft_cos[k]; + } + + CHECK(ok_sin, "L2-1 coeff sin[k] == sin(2*pi*k/N)*(sqrt2/N) for all k"); + CHECK(ok_cos, "L2-1 coeff cos[k] == cos(2*pi*k/N)*(sqrt2/N) for all k"); + CHECK(ok_norm, "L2-1 per-k sin^2+cos^2 == 2/N^2 (normalization)"); + /* Σsin² = Σcos² = (√2/N)²·(N/2) = 1/N:这就是"fAmSQ 直接是有效值"的根 */ + CHECK(fabs(sum_sin - 1.0 / PHASOR_N) < 1e-7, "L2-1 sum(sin^2) == 1/N (=> RMS, not amplitude)"); + CHECK(fabs(sum_cos - 1.0 / PHASOR_N) < 1e-7, "L2-1 sum(cos^2) == 1/N (=> RMS, not amplitude)"); + + CHECK((k_fft_sin[0] == 0.0f) && (k_fft_sin[10] == 0.0f), "L2-1 sin exact zeros at k=0,10"); + CHECK((k_fft_cos[5] == 0.0f) && (k_fft_cos[15] == 0.0f), "L2-1 cos exact zeros at k=5,15"); + CHECK(fabs((double)k_fft_sin[5] - sqrt(2.0) / PHASOR_N) < 1e-7, + "L2-1 sin[5] == sqrt(2)/N (peak coefficient)"); +} + +/* ===================== TC-PH-003/004:基波幅值 / 有效值 / 相位 ===================== */ + +static void tc_fundamental(void) { + static const struct { double amp; double ph; } k_case[] = { + { 57.7, 0.0 }, + { 57.7, 30.0 }, + { 57.7, -45.0 }, + { 100.0, 90.0 }, + { 1.0, 0.0 }, + { 32767.0, 137.0 }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok_rms = 1, ok_amp = 1, ok_ph = 1; + float x[PHASOR_N]; + + for (i = 0u; i < ncase; i++) { + phasor_t p; + double exp_rms = k_case[i].amp / sqrt(2.0); + double ph_meas; + + gen_sine(x, (uint8_t)PHASOR_N, k_case[i].amp, PH_F0, k_case[i].ph); + phasor_calc(x, (uint8_t)PHASOR_N, 1u, &p); + + if (fabs((double)p.fAmSQ - exp_rms) / exp_rms > 0.01) { ok_rms = 0; } + if (fabs((double)p.fAm - exp_rms * sqrt(2.0)) / (exp_rms * sqrt(2.0)) > 0.01) { ok_amp = 0; } + + ph_meas = atan2((double)p.fIm, (double)p.fRe) * 180.0 / PH_PI; + if (fabs(ph_meas - k_case[i].ph) > 0.5) { + printf(" (amp=%.1f ph=%.1f -> measured %.4f)\n", + k_case[i].amp, k_case[i].ph, ph_meas); + ok_ph = 0; + } + } + + CHECK(ok_rms, "L2-3 pure fundamental: fAmSQ == A/sqrt(2) (RMS) within 1%"); + CHECK(ok_amp, "L2-3 fAm == sqrt(2)*fAmSQ (amplitude) within 1%"); + CHECK(ok_ph, "L2-4/L2-3 atan2(fIm,fRe) == injected phase (6 amp/phase cases)"); +} + +/* ===================== TC-PH-005:直流与谐波被滤除 ===================== */ + +static void tc_filtering(void) { + const double amp = 57.7, exp_rms = amp / sqrt(2.0); + static const struct { double dc, h2, h3, h5; const char *name; } k_case[] = { + { 0.0, 0.0, 0.0, 0.0, "pure" }, + { 1.0, 0.0, 0.0, 0.0, "with 1V DC" }, + { 0.0, 0.0, 30.0, 0.0, "with 30% 3rd harmonic" }, + { 0.0, 0.0, 0.0, 11.5, "with 20% 5th harmonic" }, + { 1.0, 20.0, 15.0, 11.5, "DC + 2nd + 3rd + 5th (doc 6 case)" }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok = 1; + float x[PHASOR_N]; + + for (i = 0u; i < ncase; i++) { + phasor_t p; + uint8_t k; + double err; + + for (k = 0u; k < (uint8_t)PHASOR_N; k++) { + double arg = 2.0 * PH_PI * (double)k / (double)PHASOR_N; + x[k] = (float)(amp * sin(arg + 30.0 * PH_PI / 180.0) + + k_case[i].dc + + k_case[i].h2 * sin(2.0 * arg) + + k_case[i].h3 * sin(3.0 * arg) + + k_case[i].h5 * sin(5.0 * arg)); + } + + phasor_calc(x, (uint8_t)PHASOR_N, 1u, &p); + err = fabs((double)p.fAmSQ - exp_rms) / exp_rms; + if (err > 0.01) { + printf(" (%s -> fAmSQ=%.4f expect=%.4f err=%.3f%%)\n", + k_case[i].name, (double)p.fAmSQ, exp_rms, err * 100.0); + ok = 0; + } + } + + CHECK(ok, "L2-3 DC + 2nd/3rd/5th harmonics fully rejected (5 cases, err <= 1%)"); +} + +/* ===================== TC-PH-006:任意次谐波提取 ===================== */ + +static void tc_harmonic(void) { + /* 基波 100 + 二次 25 + 五次 12(峰值);三根谱线应各自独立读出 */ + static const struct { uint8_t harm; double amp; } k_case[] = { + { 1u, 100.0 }, + { 2u, 25.0 }, + { 5u, 12.0 }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok = 1; + float x[PHASOR_N]; + uint8_t k; + + for (k = 0u; k < (uint8_t)PHASOR_N; k++) { + double arg = 2.0 * PH_PI * (double)k / (double)PHASOR_N; + x[k] = (float)(100.0 * sin(arg) + 25.0 * sin(2.0 * arg) + 12.0 * sin(5.0 * arg)); + } + + for (i = 0u; i < ncase; i++) { + phasor_t p; + double exp_rms = k_case[i].amp / sqrt(2.0); + + phasor_calc(x, (uint8_t)PHASOR_N, k_case[i].harm, &p); + if (fabs((double)p.fAmSQ - exp_rms) / exp_rms > 0.01) { + printf(" (harm=%u -> fAmSQ=%.4f expect=%.4f)\n", + (unsigned)k_case[i].harm, (double)p.fAmSQ, exp_rms); + ok = 0; + } + } + + CHECK(ok, "L2-1 harmonic extraction k=1/2/5 from a mixed signal (index step = order)"); + + /* 纯基波时谐波轨应≈0(选择性) */ + gen_sine(x, (uint8_t)PHASOR_N, 100.0, PH_F0, 0.0); + { + phasor_t p2, p5; + phasor_calc(x, (uint8_t)PHASOR_N, 2u, &p2); + phasor_calc(x, (uint8_t)PHASOR_N, 5u, &p5); + CHECK(((double)p2.fAmSQ < 1e-3) && ((double)p5.fAmSQ < 1e-3), + "L2-1 pure fundamental leaves 2nd/5th harmonic bins ~0"); + } +} + +/* ===================== TC-PH-007:频率敏感性 ===================== */ + +static void tc_freqdev(void) { + const double amp = 57.7, exp_rms = amp / sqrt(2.0); + /* 最坏相角下的幅值误差(实测:±1 Hz ≤1.1%,±2 Hz ≤2.33%;口径见 §9 Q3) */ + static const struct { double f; double limit_pct; } k_case[] = { + { 50.0, 0.10 }, + { 49.0, 1.50 }, + { 51.0, 1.50 }, + { 48.0, 3.00 }, + { 52.0, 3.00 }, + }; + const unsigned int ncase = (unsigned int)(sizeof(k_case) / sizeof(k_case[0])); + unsigned int i; + int ok = 1; + float x[PHASOR_N]; + + for (i = 0u; i < ncase; i++) { + double worst = 0.0; + int ph; + + for (ph = 0; ph < 360; ph += 15) { + phasor_t p; + double err; + + gen_sine(x, (uint8_t)PHASOR_N, amp, k_case[i].f, (double)ph); + phasor_calc(x, (uint8_t)PHASOR_N, 1u, &p); + err = fabs((double)p.fAmSQ - exp_rms) / exp_rms * 100.0; + if (err > worst) { worst = err; } + } + + printf(" (f=%5.1f Hz worst err over phase = %.2f%%, limit %.2f%%)\n", + k_case[i].f, worst, k_case[i].limit_pct); + if (worst > k_case[i].limit_pct) { ok = 0; } + } + + CHECK(ok, "L2-3 frequency sensitivity: 50Hz<=0.1%, +-1Hz<=1.5%, +-2Hz<=3% (worst phase)"); +} + +/* ===================== TC-PH-010:采样环 ===================== */ + +static void tc_ring(void) { + float f[ADC_SEM_COUNT]; + float w[SAMPLE_RING_DEPTH]; + uint8_t c; + int i; + int ok; + + sample_ring_init(); + CHECK(sample_ring_filled() == 0u, "L2-in ring: init clears fill"); + CHECK(sample_ring_frames() == 0u, "L2-in ring: init clears frame counter"); + CHECK(sample_ring_window(ADC_SEM_UA, 20u, w) == -2, "L2-in ring: window before data -> -2"); + + /* 前 3 帧:UA = 100/200/300 */ + ok = 1; + for (i = 0; i < 3; i++) { + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { f[c] = 0.0f; } + f[ADC_SEM_UA] = (float)(100 * (i + 1)); + if (sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT) != 0) { ok = 0; } + } + CHECK(ok, "L2-in ring: 3 frames pushed OK"); + CHECK(sample_ring_filled() == 3u, "L2-in ring: filled == 3"); + CHECK((sample_ring_window(ADC_SEM_UA, 3u, w) == 0) + && (w[0] == 100.0f) && (w[1] == 200.0f) && (w[2] == 300.0f), + "L2-in ring: window is chronological (old -> new)"); + /* 通道隔离:其它语义通道不应被 UA 的写入污染 */ + CHECK((sample_ring_window(ADC_SEM_IB, 3u, w) == 0) + && (w[0] == 0.0f) && (w[1] == 0.0f) && (w[2] == 0.0f), + "L2-in ring: channels are independent"); + + /* 推到 45 帧(> DEPTH=40)→ 回绕 */ + for (i = 3; i < 45; i++) { + for (c = 0u; c < (uint8_t)ADC_SEM_COUNT; c++) { f[c] = 0.0f; } + f[ADC_SEM_UA] = (float)i; + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + } + CHECK(sample_ring_filled() == SAMPLE_RING_DEPTH, "L2-in ring: fill saturates at DEPTH"); + CHECK(sample_ring_frames() == 45u, "L2-in ring: frame counter is NOT saturated"); + + ok = (sample_ring_window(ADC_SEM_UA, 20u, w) == 0); + for (i = 0; i < 20; i++) { if (w[i] != (float)(25 + i)) { ok = 0; } } + CHECK(ok, "L2-in ring: wrapped window == last 20 values, in order"); + + ok = (sample_ring_window(ADC_SEM_UA, (uint8_t)SAMPLE_RING_DEPTH, w) == 0); + for (i = 0; i < (int)SAMPLE_RING_DEPTH; i++) { if (w[i] != (float)(5 + i)) { ok = 0; } } + CHECK(ok, "L2-in ring: full-depth window == last DEPTH values, in order"); + + CHECK(sample_ring_window(ADC_SEM_UA, 0u, w) == -1, "L2-in ring: n=0 rejected"); + CHECK(sample_ring_window(ADC_SEM_UA, (uint8_t)(SAMPLE_RING_DEPTH + 1u), w) == -1, + "L2-in ring: n > DEPTH rejected"); + CHECK(sample_ring_window((adc_semantic_t)ADC_SEM_COUNT, 20u, w) == -1, + "L2-in ring: out-of-range channel rejected"); + CHECK(sample_ring_window(ADC_SEM_UA, 20u, NULL) == -1, "L2-in ring: NULL out rejected"); + CHECK(sample_ring_push_frame(NULL, (uint8_t)ADC_SEM_COUNT) == -1, "L2-in ring: NULL frame rejected"); + CHECK(sample_ring_push_frame(f, 3u) == -1, "L2-in ring: wrong frame length rejected"); +} + +/* ===================== TC-PH-020/021/022:实例层 + 滑动窗 + 双区 ===================== */ + +static void tc_instances(void) { + const double amp = 100.0, exp_rms = amp / sqrt(2.0); + float f[ADC_SEM_COUNT]; + phasor_t p; + uint32_t t; + int ok; + uint8_t i; + + CHECK(phasor_inst_count() == 5u, "L2-2 instance count == 5"); + CHECK((phasor_inst(0u) != NULL) && (phasor_inst(0u)->name != NULL), "L2-2 instance name available"); + CHECK(phasor_inst((uint8_t)phasor_inst_count()) == NULL, "L2-2 out-of-range instance -> NULL"); + + phasor_init(); + CHECK(s_use_zone == 0u, "L2-7 use zone starts at 0"); + CHECK(phasor_get(0u, &p) == -2, "L2-2 no result before a full cycle"); + CHECK(phasor_get((uint8_t)phasor_inst_count(), &p) == -1, "L2-2 out-of-range index -> -1"); + CHECK(phasor_get(0u, NULL) == -1, "L2-2 NULL out -> -1"); + + /* 喂 19 帧(不足一周波)→ 仍不产出 */ + for (t = 0u; t < 19u; t++) { + frame_at(f, t, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + } + CHECK(phasor_get(0u, &p) == -2, "L2-2 still no result after 19 frames"); + CHECK(s_use_zone == 0u, "L2-7 use zone unchanged while not producing"); + + /* 第 20 帧 → 攒满一周波,产出 */ + frame_at(f, 19u, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + CHECK(phasor_get(0u, &p) == 0, "L2-2 result available after 20 frames"); + CHECK(s_use_zone == 1u, "L2-7 use zone toggles to 1 after first production"); + CHECK(fabs((double)p.fAmSQ - exp_rms) / exp_rms < 0.01, + "L2-2/L2-3 instance 0 (C01-IA-F1) RMS == A/sqrt(2)"); + { + phasor_t p2, p5; + int ok_sel = 1; + if (phasor_get(3u, &p2) != 0) { ok_sel = 0; } + if (phasor_get(4u, &p5) != 0) { ok_sel = 0; } + CHECK(ok_sel && ((double)p2.fAmSQ < 1e-3) && ((double)p5.fAmSQ < 1e-3), + "L2-2 harmonic instances (C04-IA-F2 / C05-IA-F5) ~0 on pure fundamental"); + } + + /* L2-6 滑动窗:继续推 20 帧,每拍重算,基波读数应恒定 */ + ok = 1; + for (t = 20u; t < 40u; t++) { + frame_at(f, t, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + if (phasor_get(0u, &p) != 0) { ok = 0; } + else if (fabs((double)p.fAmSQ - exp_rms) / exp_rms > 0.01) { ok = 0; } + } + CHECK(ok, "L2-6 sliding window: RMS constant across 20 consecutive slides"); + + /* 实例表一致性:逐实例复核"实例窗 == 独立 phasor_calc 结果" */ + ok = 1; + for (i = 0u; i < phasor_inst_count(); i++) { + const phasor_inst_t *d = phasor_inst(i); + float win[PHASOR_N]; + phasor_t ref, got; + + if (d == NULL) { ok = 0; break; } + if (sample_ring_window(d->sem, (uint8_t)PHASOR_N, win) != 0) { ok = 0; break; } + phasor_calc(win, (uint8_t)PHASOR_N, d->harm, &ref); + if (phasor_get(i, &got) != 0) { ok = 0; break; } + if ((ref.fRe != got.fRe) || (ref.fIm != got.fIm) || (ref.fAmSQ != got.fAmSQ)) { ok = 0; break; } + } + CHECK(ok, "L2-2/L2-6 instance results == phasor_calc() on the same ring window"); +} + +/* ===================== TC-PH-022:L2-7 双区指针原子交换 ===================== */ + +static void tc_dualzone(void) { + const double amp = 100.0; + float f[ADC_SEM_COUNT]; + phasor_t p; + uint32_t t; + int ok; + + /* 1) 使用区逐次产出交替(0 -> 1 -> 0 -> 1) */ + phasor_init(); + for (t = 0u; t < 20u; t++) { + frame_at(f, t, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + } + CHECK(s_use_zone == 1u, "L2-7 use zone == 1 after 1st production"); + + frame_at(f, 20u, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + CHECK(s_use_zone == 0u, "L2-7 use zone == 0 after 2nd production"); + + frame_at(f, 21u, amp, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + CHECK(s_use_zone == 1u, "L2-7 use zone == 1 after 3rd production"); + + /* 2) 每拍产出后 get() 必须等于"当前环窗的独立重算"。 + * 幅值每 20 拍切换(100 <-> 10),故"读到陈旧区"会立刻不相等—— + * 连测 60 拍,覆盖两个区的来回切换,比单次比较强得多。 */ + phasor_init(); + ok = 1; + for (t = 0u; t < 60u; t++) { + float win[PHASOR_N]; + phasor_t ref, got; + double a = (((t / 20u) % 2u) == 0u) ? amp : (amp / 10.0); + + frame_at(f, t, a, 0.0); + (void)sample_ring_push_frame(f, (uint8_t)ADC_SEM_COUNT); + phasor_run(); + + if (sample_ring_window(ADC_SEM_IA, (uint8_t)PHASOR_N, win) != 0) { + continue; /* 尚未攒满一周波:本拍按契约不产出,无需比较 */ + } + phasor_calc(win, (uint8_t)PHASOR_N, 1u, &ref); + if (phasor_get(0u, &got) != 0) { ok = 0; break; } + if ((ref.fRe != got.fRe) || (ref.fIm != got.fIm) || (ref.fAmSQ != got.fAmSQ)) { ok = 0; break; } + } + CHECK(ok, "L2-7 dual-zone: get() == fresh recompute of the live window, all 60 beats"); + CHECK(phasor_get(0u, &p) == 0, "L2-7 result still readable after the sweep"); +} + +int main(void) { + printf("=== Phasor Tests ===\n"); + + printf("-- L2-1 coefficient table --\n"); + tc_table(); + printf("-- L2-3/L2-4 fundamental amplitude/RMS/phase --\n"); + tc_fundamental(); + printf("-- DC/harmonic rejection --\n"); + tc_filtering(); + printf("-- arbitrary harmonic extraction --\n"); + tc_harmonic(); + printf("-- frequency sensitivity --\n"); + tc_freqdev(); + printf("-- L2 input sample ring --\n"); + tc_ring(); + printf("-- L2-2/L2-6/L2-7 instance layer --\n"); + tc_instances(); + printf("-- L2-7 dual-zone swap --\n"); + tc_dualzone(); + + printf("\n=== Phasor Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/phasor/run_unit_test.py b/test/host/phasor/run_unit_test.py new file mode 100644 index 00000000..a916aca0 --- /dev/null +++ b/test/host/phasor/run_unit_test.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""相量层(L2)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/phasor/phasor_test.c(它以 #include 纳入**生产源文件** + App/algo/phasor.c 与 App/algo/sample_ring.c),在 PC 上离线跑完整的相量算法验证: + 系数表逐项复核 / 幅值-有效值-相位 / 直流与谐波抑制 / 任意次谐波提取 / + 频率敏感性 / 采样环时序与回绕 / 实例层与双区交换,失败即非 0 退出。 + 零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端套件): + 相量算法是纯数学变换,与硬件无关。**板端无信号源**,无法注入已知幅值/相位/频率 + 的标准波形,因而无法验证"读数 = 设定有效值"这一 L2-3 核心判据(与 L1-3 的替代口径 + 同源)。主机侧可合成任意波形并精确构造边界(恰好一周波 / 不足一周波 / 环形回绕 / + 任意次谐波 / 频偏),且不占 MCU 的 RAM。 + +本层为**纯算法**:无寄存器、无 RTOS、无文件/外设依赖,故**不需要任何桩目录**。 + +用法(仓库根目录): + python test/host/phasor/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/phasor/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.2(L2-1/2/3/4/6/7); + docs/算法/傅氏算法详解.md §2.3(系数口径); + docs/测试说明/测试开发标准流程.md §4.2(L0.5 主机侧单测)。 +创建日期:2026-10-10 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 无桩目录:被测代码为纯算法(phasor.c / sample_ring.c 仅依赖 math.h 与 App/algo 内的头)。 +# App/algo 提供 phasor.h / sample_ring.h / adc_input.h(后者为纯枚举 + 声明,可主机编译)。 +BUILD = TestBuild( + name="Phasor Tests", + src=os.path.join(HERE, "phasor_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Phasor Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) From 696a1a49a90fac7545936bb89aa3ffad467a2e32 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 17:46:45 +0800 Subject: [PATCH 02/47] =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=20ADC=20=E8=BE=93=E5=85=A5=E5=B1=82?= =?UTF-8?q?=EF=BC=88L1=EF=BC=89=E7=94=A8=E4=BE=8B=2070=20=E9=A1=B9?= =?UTF-8?q?=E5=B9=B6=E7=99=BB=E8=AE=B0=20L1/L2=20=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E9=92=A9=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 App/algo/adc_input.c 的主机侧单测(L1),补齐 L1/L2 算法层 "每个函数都可离线验证"的最后一处空白。 - test/host/adc_input/{adc_input_test.c,run_unit_test.py,stub/}: 70 个用例,覆盖 6 个公开函数 + map_check + stm32_scan。 关键判据 = "通道号 <-> Rank 顺序 <-> 语义"三者映射:给每个 Rank 注入互不相同的码值,逐通道钉死映射(板端无信号源,无法验此项)。 另钉死 HAL 启动失败 / 轮询超时 / 参数错 / 无有效数据等分支。 - .pre-commit-config.yaml:登记 phasor-unit-test(7d)与 adc-input-unit-test(7e)两个主机侧单测钩子。 主机侧全套:adc_input 70/70、phasor 52/52,均 0 失败、无 /W4 告警。 --- .pre-commit-config.yaml | 28 ++ test/host/adc_input/adc_input_test.c | 463 +++++++++++++++++++++++++++ test/host/adc_input/run_unit_test.py | 54 ++++ test/host/adc_input/stub/adc.h | 58 ++++ test/host/adc_input/stub/tpafe5160.h | 28 ++ 5 files changed, 631 insertions(+) create mode 100644 test/host/adc_input/adc_input_test.c create mode 100644 test/host/adc_input/run_unit_test.py create mode 100644 test/host/adc_input/stub/adc.h create mode 100644 test/host/adc_input/stub/tpafe5160.h diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ace93544..01a569b2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -232,6 +232,34 @@ repos: files: ^(App/cfg/cfg_file\.(c|h)|App/cfg/cfg_file_store\.h|App/log/sys_log\.h|test/host/cfg_file/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7d) 相量层(L2)主机侧单元测试(2026-10-10 加) + # 背景:App/algo/ 为**纯算法**层(无寄存器、无 RTOS、无外设依赖),是本项目 + # 首个可完全离线验证的算法层。板端**无信号源**,无法注入已知幅值/相位/频率的 + # 标准波形,故"读数 = 设定值"这一核心判据在板上不可验;本钩子用 MSVC 离线编译 + # 真实源文件(#include 纳入,无需桩)+ 合成任意波形,把系数表、幅值/相位、 + # 直流与谐波抑制、频偏敏感度、滑动窗、双区交换一次性钉死。 + # files 含被测的两个源与其头,以及 harness 本身。 + - id: phasor-unit-test + name: 相量层单测(主机侧) + entry: python test/host/phasor/run_unit_test.py + language: system + files: ^(App/algo/(phasor|sample_ring)\.(c|h)|test/host/phasor/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + + # 7e) ADC 输入层(L1)主机侧单元测试(2026-10-10 加) + # 背景:adc_input.c 的核心是"通道号 <-> Rank 顺序 <-> 语义"三者映射的正确性; + # 板端无信号源,无法注入已知电压验证"读到的是哪只脚"(映射说明 §8)。本钩子用 + # MSVC 离线编译真实源文件(#include 纳入)+ HAL/TPAFE 可注入桩:给每个 Rank + # 注入互不相同的码值以**逐通道钉死映射**,并一次钉死 HAL 启动失败 / 转换超时 / + # 参数错等真机难复现的分支。 + # files 含被测源与其头、两个桩头所在目录,以及 harness 本身。 + - id: adc-input-unit-test + name: ADC 输入层单测(主机侧) + entry: python test/host/adc_input/run_unit_test.py + language: system + files: ^(App/algo/adc_input\.(c|h)|test/host/adc_input/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 8) 测试套件开关门禁(2026-10-01 加) # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 diff --git a/test/host/adc_input/adc_input_test.c b/test/host/adc_input/adc_input_test.c new file mode 100644 index 00000000..7a431b8b --- /dev/null +++ b/test/host/adc_input/adc_input_test.c @@ -0,0 +1,463 @@ +/* + * 模块名称:ADC 输入层(L1)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/adc_input.c 做离线验证(6 个公开函数 + 2 个静态函数)。 + * + * 被测对象:真实源文件 ../../../App/algo/adc_input.c(以 #include 原样纳入, + * 保证测的是生产代码而非副本)。平台依赖由本文件提供的**可注入桩**实现: + * - adc.h -> HAL_ADC_Start / PollForConversion / GetValue / Stop + * (可注入 启动失败 / 第 N 次轮询超时 / 逐 Rank 返回值) + * - tpafe5160.h-> tpafe5160_snapshot(可注入 新帧/无新帧/参数错 + 帧内容) + * + * 为什么要主机侧单测(而不是只用板端套件): + * 1. 本层核心是"**通道号 <-> Rank 顺序 <-> 语义**"三者的映射正确性。板端**无信号源** + * (见 docs/硬件平台/ADC通道映射说明.md §8),无法注入已知电压去验证"读到的是 + * 哪只脚";主机侧可直接给每个 Rank 注入互不相同的码值,从而**逐通道钉死映射** + * (这是唯一能在离线端证伪"通道宏写错 -> 读到另一只脚"的手段)。 + * 2. HAL 启动失败 / 转换超时 / 参数错等分支在真机上极难稳定复现,主机侧一次钉死。 + * 3. 不占 MCU 的 RAM,毫秒级完成。 + * + * 覆盖点(对应 docs/算法/继电保护功能复现规划.md §2.1 的 L1-3 / L1-4): + * —— adc_input_init(TC-AI-001..006)—— + * TC-AI-001 poll 失败后失败计数可见(前置状态构造) + * TC-AI-002 poll 成功后 TPAFE 侧可取数(前置状态构造) + * TC-AI-003 init 返回 0(映射表自检通过) + * TC-AI-004 init 后 STM32 状态码 = NOT_RUN + * TC-AI-005 init 后失败计数归零 + * TC-AI-006 init 后 TPAFE / STM32 两侧均无有效数据(有效标志被清) + * —— adc_input_desc(TC-AI-010..01A)—— + * TC-AI-010..018 九个语义通道 -> (来源, 下标) 与权威映射表逐项一致 + * TC-AI-019/01A 越界(ADC_SEM_COUNT)与负数语义 -> NULL + * —— map_check(TC-AI-020)—— + * TC-AI-020 真实映射表自洽(反向路径结构上不可达,见文件末说明) + * —— stm32_scan(TC-AI-021..03A)—— + * TC-AI-021..025 全通道成功:返回 0 / 状态码 / 不计失败 / Start-Stop 各一次 / 轮询 9 次 + * TC-AI-026..02B **Rank -> 通道号映射**:UB->CH14(rank9) / UC->CH3(rank4) / + * IB->CH6(rank7) / IC->CH1(rank2) / IN->CH2(rank3) / TEMP->CH5(rank6) + * TC-AI-02C stm32_scan 不置 TPAFE 有效标志(两来源独立) + * TC-AI-030..034 HAL_ADC_Start 失败:返回 -1 / 状态码 RC_START / 计数 +1 / 不调 Stop + * TC-AI-035..039 轮询超时:返回 -2 / 状态码 RC_TIMEOUT / 计数 +1 / 调 Stop 收尾 / 立即退出 + * TC-AI-03A 失败计数跨次单调累计 + * —— adc_input_raw(TC-AI-040..04B)—— + * TC-AI-040..043 参数守卫:out=NULL / 越界 / 负数 -> -1,且不写 out + * TC-AI-044/045 无有效数据 -> -2(两来源分别) + * TC-AI-046..048 poll 快照契约:返回码透传 / 请求 8 通道 / 目的缓冲非空 + * TC-AI-049..04B TPAFE 按**下标**取数(UA=6 / UN=5 / IA=2)且补码负值原样保留 + * —— adc_input_poll(TC-AI-050..058)—— + * TC-AI-050..052 无新帧(0):返回 0 / TPAFE 无效 / STM32 侧照常扫描 + * TC-AI-053/054 参数错(-1):返回 -1 / TPAFE 无效 + * TC-AI-055 已有有效帧后再"无新帧"**不清零**(保持上次有效值) + * TC-AI-056..058 STM32 失败**不阻断** TPAFE、**不影响** poll 返回值,另由状态码暴露 + * —— adc_input_stm32_rc / _errs(TC-AI-060..066)—— + * TC-AI-060/061 init 后 = NOT_RUN / 计数 0 + * TC-AI-062 成功: rc=OK 且 errs 不变 + * TC-AI-063/064 两次失败累计 = 2,rc = 最近一次(TIMEOUT) + * TC-AI-065/066 恢复成功 rc=OK,errs 单调不回退 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-10 + */ + +#include +#include +#include + +/* ===================== 平台桩(先纳入,覆盖真实头) ===================== */ +#include "adc.h" +#include "tpafe5160.h" + +/* ===================== HAL ADC1 桩 ===================== + * 被测代码经 HAL 只做四件事:启动、逐 Rank 轮询、读 DR、停止。桩把它们变成可注入: + * s_start_rc HAL_ADC_Start 返回值(注入启动失败) + * s_poll_fail_at PollForConversion 第几次(0 基)失败;-1 = 永不失败(注入超时) + * s_poll_vals[i] 第 i 个 Rank 读到的码值(默认 100+i,用于钉死 Rank->通道映射) + * s_poll_calls / s_rank_idx 轮询次数 / 取数次数(观测:超时后是否立即退出) + */ +static HAL_StatusTypeDef s_start_rc = HAL_OK; +static int s_poll_fail_at = -1; +static uint32_t s_poll_vals[16]; +static int s_poll_calls = 0; +static int s_rank_idx = 0; +static int s_start_calls = 0; +static int s_stop_calls = 0; + +/* 被测代码把 &hadc1 透传给 HAL 桩;桩不读其字段 */ +ADC_HandleTypeDef hadc1; + +HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef *hadc) { + (void)hadc; + s_start_calls++; + s_poll_calls = 0; + s_rank_idx = 0; + return s_start_rc; +} + +HAL_StatusTypeDef HAL_ADC_PollForConversion(ADC_HandleTypeDef *hadc, uint32_t Timeout) { + (void)hadc; + (void)Timeout; + { + int idx = s_poll_calls; + s_poll_calls++; + if (s_poll_fail_at >= 0 && idx == s_poll_fail_at) { + return HAL_ERROR; + } + } + return HAL_OK; +} + +uint32_t HAL_ADC_GetValue(ADC_HandleTypeDef *hadc) { + uint32_t v; + (void)hadc; + v = (s_rank_idx < 16) ? s_poll_vals[s_rank_idx] : 0u; + s_rank_idx++; + return v; +} + +HAL_StatusTypeDef HAL_ADC_Stop(ADC_HandleTypeDef *hadc) { + (void)hadc; + s_stop_calls++; + return HAL_OK; +} + +/* ===================== TPAFE5160 快照桩 ===================== */ +static int16_t s_fake_tpafe[8]; +static int s_snapshot_rc = 0; +static int s_snapshot_calls = 0; +static uint8_t s_snapshot_count = 0u; +static int16_t *s_snapshot_dst = NULL; + +int tpafe5160_snapshot(int16_t *dst, uint8_t count) { + uint8_t i; + + s_snapshot_calls++; + s_snapshot_count = count; + s_snapshot_dst = dst; + if (s_snapshot_rc == 1) { + for (i = 0u; (i < count) && (i < 8u); i++) { + dst[i] = s_fake_tpafe[i]; + } + } + return s_snapshot_rc; +} + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码(原样纳入生产源) ===================== */ +#include "adc_input.c" + +/* ===================== 工具 ===================== */ + +/* + * 函数功能:把全部桩复位到"健康"初值 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:默认 Start 成功、轮询永不超时、第 i 个 Rank 读到 100+i(互不相同, + * 以便逐通道钉死 Rank->通道号映射)、TPAFE 快照无新帧。 + */ +static void fakes_reset(void) { + uint8_t i; + + s_start_rc = HAL_OK; + s_poll_fail_at = -1; + s_poll_calls = 0; + s_rank_idx = 0; + s_start_calls = 0; + s_stop_calls = 0; + for (i = 0u; i < 16u; i++) { + s_poll_vals[i] = 100u + (uint32_t)i; + } + s_snapshot_rc = 0; + s_snapshot_calls = 0; + s_snapshot_count = 0u; + s_snapshot_dst = NULL; + for (i = 0u; i < 8u; i++) { + s_fake_tpafe[i] = 0; + } +} + +/* ===================== TC-AI-001..006:init ===================== */ + +static void tc_init(void) { + int32_t v = -999; + + fakes_reset(); + /* 前置:制造"STM32 失败一次 + TPAFE 有效一帧"的状态,再验 init 是否清干净 */ + s_snapshot_rc = 1; + s_fake_tpafe[6] = 4321; + s_start_rc = HAL_ERROR; + (void)adc_input_poll(); + + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-001 poll 失败后 STM32 失败计数 = 1"); + CHECK((adc_input_raw(ADC_SEM_UA, &v) == 0) && (v == 4321), + "TC-AI-002 poll 后 UA 可取数且值正确"); + + CHECK(adc_input_init() == 0, "TC-AI-003 adc_input_init 返回 0(映射自检通过)"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_NOT_RUN, + "TC-AI-004 init 后 STM32 状态码 = NOT_RUN"); + CHECK(adc_input_stm32_errs() == 0u, "TC-AI-005 init 后 STM32 失败计数归零"); + CHECK((adc_input_raw(ADC_SEM_UA, &v) == -2) && (adc_input_raw(ADC_SEM_UB, &v) == -2), + "TC-AI-006 init 后 TPAFE / STM32 两侧均无有效数据"); +} + +/* ===================== TC-AI-010..01A:desc 映射表 ===================== */ + +static void tc_desc(void) { + const adc_channel_desc_t *d; + + d = adc_input_desc(ADC_SEM_UA); + CHECK((d != NULL) && (d->source == ADC_SRC_TPAFE) && (d->index == 6u), + "TC-AI-010 UA -> TPAFE buf[6]"); + d = adc_input_desc(ADC_SEM_UB); + CHECK((d != NULL) && (d->source == ADC_SRC_STM32) && (d->index == 14u), + "TC-AI-011 UB -> STM32 CH14"); + d = adc_input_desc(ADC_SEM_UC); + CHECK((d != NULL) && (d->source == ADC_SRC_STM32) && (d->index == 3u), + "TC-AI-012 UC -> STM32 CH3"); + d = adc_input_desc(ADC_SEM_UN); + CHECK((d != NULL) && (d->source == ADC_SRC_TPAFE) && (d->index == 5u), + "TC-AI-013 UN -> TPAFE buf[5]"); + d = adc_input_desc(ADC_SEM_IA); + CHECK((d != NULL) && (d->source == ADC_SRC_TPAFE) && (d->index == 2u), + "TC-AI-014 IA -> TPAFE buf[2]"); + d = adc_input_desc(ADC_SEM_IB); + CHECK((d != NULL) && (d->source == ADC_SRC_STM32) && (d->index == 6u), + "TC-AI-015 IB -> STM32 CH6"); + d = adc_input_desc(ADC_SEM_IC); + CHECK((d != NULL) && (d->source == ADC_SRC_STM32) && (d->index == 1u), + "TC-AI-016 IC -> STM32 CH1"); + d = adc_input_desc(ADC_SEM_IN); + CHECK((d != NULL) && (d->source == ADC_SRC_STM32) && (d->index == 2u), + "TC-AI-017 IN -> STM32 CH2"); + d = adc_input_desc(ADC_SEM_TEMP); + CHECK((d != NULL) && (d->source == ADC_SRC_STM32) && (d->index == 5u), + "TC-AI-018 TEMP -> STM32 CH5"); + + CHECK(adc_input_desc(ADC_SEM_COUNT) == NULL, "TC-AI-019 越界(ADC_SEM_COUNT) -> NULL"); + CHECK(adc_input_desc((adc_semantic_t)(-1)) == NULL, "TC-AI-01A 负数语义 -> NULL"); +} + +/* ===================== TC-AI-020:map_check ===================== */ + +static void tc_map_check(void) { + /* 真实映射表自洽:TPAFE 下标 < 8 且每个 STM32 通道号都出现在扫描 Rank 内。 + * 反向路径(表不自洽 -> -1)需在运行时改表,而 k_adc_map / k_stm32_rank_ch 均为 + * 编译期 static const、无运行时输入可使其不自洽 -> 结构上不可达(见文件末说明)。 */ + CHECK(map_check() == 0, "TC-AI-020 映射表自洽(TPAFE 下标与 STM32 Rank 均在范围内)"); +} + +/* ===================== TC-AI-021..02C:stm32_scan 成功路径 ===================== */ + +static void tc_scan_ok(void) { + int32_t v = 0; + + fakes_reset(); + (void)adc_input_init(); + + CHECK(stm32_scan() == ADC_INPUT_RC_OK, "TC-AI-021 stm32_scan 全通道成功 -> 0"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-022 状态码 = RC_OK"); + CHECK(adc_input_stm32_errs() == 0u, "TC-AI-023 成功不计入失败"); + CHECK((s_start_calls == 1) && (s_stop_calls == 1), "TC-AI-024 Start / Stop 各一次"); + CHECK((s_poll_calls == (int)ADC_INPUT_STM32_CH_NUM) && (s_rank_idx == (int)ADC_INPUT_STM32_CH_NUM), + "TC-AI-025 轮询与取数各 9 次(Rank1..9)"); + + /* Rank -> 通道号映射:第 i 个 Rank 读到 100+i。 + * k_stm32_rank_ch = [CH0..CH7, CH14],故 CH3->103 / CH5->105 / CH6->106 / CH14->108。 */ + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), "TC-AI-026 UB 取到 CH14(rank9)=108"); + CHECK((adc_input_raw(ADC_SEM_UC, &v) == 0) && (v == 103), "TC-AI-027 UC 取到 CH3(rank4)=103"); + CHECK((adc_input_raw(ADC_SEM_IB, &v) == 0) && (v == 106), "TC-AI-028 IB 取到 CH6(rank7)=106"); + CHECK((adc_input_raw(ADC_SEM_IC, &v) == 0) && (v == 101), "TC-AI-029 IC 取到 CH1(rank2)=101"); + CHECK((adc_input_raw(ADC_SEM_IN, &v) == 0) && (v == 102), "TC-AI-02A IN 取到 CH2(rank3)=102"); + CHECK((adc_input_raw(ADC_SEM_TEMP, &v) == 0) && (v == 105), "TC-AI-02B TEMP 取到 CH5(rank6)=105"); + + CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-02C stm32_scan 不置 TPAFE 有效标志"); +} + +/* ===================== TC-AI-030..034:stm32_scan 启动失败 ===================== */ + +static void tc_scan_start_fail(void) { + int32_t v = 0; + + fakes_reset(); + (void)adc_input_init(); + s_start_rc = HAL_ERROR; + + CHECK(stm32_scan() == ADC_INPUT_RC_START, "TC-AI-030 HAL_ADC_Start 失败 -> -1"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_START, "TC-AI-031 状态码 = RC_START"); + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-032 失败计数 +1"); + CHECK(s_stop_calls == 0, "TC-AI-033 启动失败不调用 Stop"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-034 启动失败后 STM32 侧仍无有效数据"); +} + +/* ===================== TC-AI-035..03A:stm32_scan 轮询超时 ===================== */ + +static void tc_scan_timeout(void) { + int32_t v = 0; + + fakes_reset(); + (void)adc_input_init(); + s_poll_fail_at = 4; /* 第 5 个 Rank 轮询超时 */ + + CHECK(stm32_scan() == ADC_INPUT_RC_TIMEOUT, "TC-AI-035 轮询超时 -> -2"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_TIMEOUT, "TC-AI-036 状态码 = RC_TIMEOUT"); + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-037 超时计入失败"); + CHECK(s_stop_calls == 1, "TC-AI-038 超时前已 Start -> 调 Stop 收尾"); + CHECK(s_poll_calls == 5, "TC-AI-039 超时后立即退出(只轮询 5 次)"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-039b 超时后 STM32 侧无有效数据"); + + s_poll_fail_at = 0; /* 再失败一次 */ + (void)stm32_scan(); + CHECK(adc_input_stm32_errs() == 2u, "TC-AI-03A 失败计数跨次单调累计 = 2"); +} + +/* ===================== TC-AI-040..045:raw 参数守卫与无数据 ===================== */ + +static void tc_raw_guards(void) { + int32_t v = 0; + + fakes_reset(); + (void)adc_input_init(); + + CHECK(adc_input_raw(ADC_SEM_UA, NULL) == -1, "TC-AI-040 out=NULL -> -1"); + CHECK(adc_input_raw(ADC_SEM_COUNT, &v) == -1, "TC-AI-041 ch 越界 -> -1"); + CHECK(adc_input_raw((adc_semantic_t)(-1), &v) == -1, "TC-AI-042 ch 负数 -> -1"); + CHECK(v == 0, "TC-AI-043 参数错时不写 out"); + CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-044 TPAFE 无有效帧 -> -2"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-045 STM32 无有效扫描 -> -2"); +} + +/* ===================== TC-AI-046..04B:raw TPAFE 取数与补码 ===================== */ + +static void tc_raw_tpafe(void) { + int32_t v = 0; + + fakes_reset(); + (void)adc_input_init(); + /* 注入 8 路 TPAFE 码值(互不相同),UA 应取 buf[6] / UN 取 buf[5] / IA 取 buf[2] */ + s_fake_tpafe[0] = 1000; + s_fake_tpafe[1] = 1001; + s_fake_tpafe[2] = -1234; + s_fake_tpafe[3] = 1003; + s_fake_tpafe[4] = 1004; + s_fake_tpafe[5] = 2005; + s_fake_tpafe[6] = 3006; + s_fake_tpafe[7] = 1007; + s_snapshot_rc = 1; + + CHECK(adc_input_poll() == 1, "TC-AI-046 poll 透传 TPAFE 快照码 1"); + CHECK(s_snapshot_count == (uint8_t)ADC_INPUT_TPAFE_CH_NUM, "TC-AI-047 快照请求 8 通道"); + CHECK(s_snapshot_dst != NULL, "TC-AI-048 快照目的缓冲非空"); + CHECK((adc_input_raw(ADC_SEM_UA, &v) == 0) && (v == 3006), "TC-AI-049 UA -> buf[6]=3006"); + CHECK((adc_input_raw(ADC_SEM_UN, &v) == 0) && (v == 2005), "TC-AI-04A UN -> buf[5]=2005"); + CHECK((adc_input_raw(ADC_SEM_IA, &v) == 0) && (v == -1234), + "TC-AI-04B IA -> buf[2]=-1234(补码负值原样保留)"); +} + +/* ===================== TC-AI-050..058:poll 契约 ===================== */ + +static void tc_poll(void) { + int32_t v = 0; + + /* --- 无新帧(0):返回 0,TPAFE 无效,STM32 侧照常扫描 --- */ + fakes_reset(); + (void)adc_input_init(); + s_snapshot_rc = 0; + CHECK(adc_input_poll() == 0, "TC-AI-050 快照无新帧 -> poll 返回 0"); + CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-051 无新帧时 TPAFE 侧无效"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-052 同拍 STM32 扫描照常执行"); + + /* --- 参数错(-1):返回 -1,TPAFE 无效 --- */ + fakes_reset(); + (void)adc_input_init(); + s_snapshot_rc = -1; + CHECK(adc_input_poll() == -1, "TC-AI-053 快照参数错 -> poll 返回 -1"); + CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-054 参数错时 TPAFE 侧无效"); + + /* --- 已有有效帧后再"无新帧":保持上次有效值(不清零) --- */ + fakes_reset(); + (void)adc_input_init(); + s_fake_tpafe[6] = 777; + s_snapshot_rc = 1; + (void)adc_input_poll(); + s_snapshot_rc = 0; + (void)adc_input_poll(); + CHECK((adc_input_raw(ADC_SEM_UA, &v) == 0) && (v == 777), + "TC-AI-055 已有有效帧后无新帧不清零"); + + /* --- STM32 失败不阻断 TPAFE、不影响 poll 返回值 --- */ + fakes_reset(); + (void)adc_input_init(); + s_fake_tpafe[2] = 55; + s_snapshot_rc = 1; + s_start_rc = HAL_ERROR; + CHECK(adc_input_poll() == 1, "TC-AI-056 STM32 失败不影响 poll 返回值(仍为 1)"); + CHECK((adc_input_raw(ADC_SEM_IA, &v) == 0) && (v == 55), + "TC-AI-057 STM32 失败不影响 TPAFE 取数"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_START, "TC-AI-058 STM32 失败另由状态码暴露"); +} + +/* ===================== TC-AI-060..066:rc / errs 观测 ===================== */ + +static void tc_rc_errs(void) { + fakes_reset(); + (void)adc_input_init(); + + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_NOT_RUN, "TC-AI-060 init 后未扫描 = NOT_RUN(-3)"); + CHECK(adc_input_stm32_errs() == 0u, "TC-AI-061 init 后失败计数 = 0"); + + (void)stm32_scan(); /* 成功 */ + CHECK((adc_input_stm32_rc() == ADC_INPUT_RC_OK) && (adc_input_stm32_errs() == 0u), + "TC-AI-062 成功: rc=OK 且 errs 不变"); + + s_start_rc = HAL_ERROR; + (void)stm32_scan(); /* 失败 1 */ + s_start_rc = HAL_OK; + s_poll_fail_at = 0; + (void)stm32_scan(); /* 失败 2 */ + CHECK(adc_input_stm32_errs() == 2u, "TC-AI-063 两次失败累计 = 2"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_TIMEOUT, "TC-AI-064 rc = 最近一次(TIMEOUT)"); + + s_poll_fail_at = -1; + (void)stm32_scan(); /* 恢复成功 */ + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-065 恢复成功后 rc=OK"); + CHECK(adc_input_stm32_errs() == 2u, "TC-AI-066 errs 单调递增(不因成功回落)"); +} + +/* ===================== main ===================== */ + +int main(void) { + printf("=== ADC Input Tests ===\n"); + + printf("-- init / reset --\n"); + tc_init(); + printf("-- semantic map desc --\n"); + tc_desc(); + printf("-- map self-check --\n"); + tc_map_check(); + printf("-- stm32 scan: success + rank/channel mapping --\n"); + tc_scan_ok(); + printf("-- stm32 scan: HAL_ADC_Start fail --\n"); + tc_scan_start_fail(); + printf("-- stm32 scan: poll timeout --\n"); + tc_scan_timeout(); + printf("-- raw: guards / no-data --\n"); + tc_raw_guards(); + printf("-- raw: tpafe index + two's complement --\n"); + tc_raw_tpafe(); + printf("-- poll contract --\n"); + tc_poll(); + printf("-- rc / errs observability --\n"); + tc_rc_errs(); + + printf("\n=== ADC Input Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/adc_input/run_unit_test.py b/test/host/adc_input/run_unit_test.py new file mode 100644 index 00000000..ec3a1f74 --- /dev/null +++ b/test/host/adc_input/run_unit_test.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ADC 输入层(L1,adc_input)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/adc_input/adc_input_test.c(它以 #include 纳入 + **生产源文件** App/algo/adc_input.c),在 PC 上离线跑语义映射表 / STM32 ADC1 + 顺序扫描 / TPAFE 快照消费 / 取数守卫 / poll 契约 / 状态码统计,失败即非 0 退出。 + 零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端套件): + 本层核心是"**通道号 <-> Rank 顺序 <-> 语义**"三者映射的正确性。板端无信号源 + (docs/硬件平台/ADC通道映射说明.md §8),无法注入已知电压验证"读到的是哪只脚"; + 主机侧给每个 Rank 注入互不相同的码值,即可**逐通道钉死映射**,并一次钉死 + HAL 启动失败 / 转换超时 / 参数错等真机难复现的分支。 + +用法(仓库根目录): + python test/host/adc_input/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/adc_input/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/算法/继电保护功能复现规划.md §2.1(L1-3 / L1-4); +创建日期:2026-10-10 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 顺序敏感:stub 目录必须**先于**生产目录,才能覆盖 adc.h / tpafe5160.h +# (真实头会经 main.h 拖入整套 HAL,主机侧无法编译)。adc_input.h 用真实头 +# (接口契约原汁);HAL_ADC_* 与 tpafe5160_snapshot 由测试单元给强符号桩。 +BUILD = TestBuild( + name="ADC Input Tests", + src=os.path.join(HERE, "adc_input_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== ADC Input Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/adc_input/stub/adc.h b/test/host/adc_input/stub/adc.h new file mode 100644 index 00000000..590c798c --- /dev/null +++ b/test/host/adc_input/stub/adc.h @@ -0,0 +1,58 @@ +/* + * 主机侧单测桩:adc.h + * 模块功能:为 App/algo/adc_input.c 提供主机可编译的 STM32 ADC1 句柄与 HAL 接口 + * 声明(真实 Inc/adc.h 会经 main.h 拖入整套 HAL,主机侧无法编译)。 + * + * ⚠ 仅供 test/host/adc_input/ 使用,不参与 MCU 构建。 + * + * 保真点:F4 HAL 的 ADC_CHANNEL_x 宏值**即通道号 x**(0..15),本桩按此定义, + * 故被测代码里 `(uint8_t)ADC_CHANNEL_14` 得到 14 —— 与真机一致。 + * 另声明 4 个 HAL_ADC_* 接口,由测试单元给强符号桩(可注入失败/超时/返回值)。 + * + * 创建日期:2026-10-10 + */ + +#ifndef ADC_H +#define ADC_H + +#include + +/* HAL 状态码(与 stm32f4xx_hal_def.h 同值,仅取被测代码用到的子集) */ +typedef enum { + HAL_OK = 0x00, + HAL_ERROR = 0x01, + HAL_BUSY = 0x02, + HAL_TIMEOUT = 0x03 +} HAL_StatusTypeDef; + +/* ADC 句柄:被测代码只把 &hadc1 透传给 HAL 接口,从不读其字段,故为不透明占位 */ +typedef struct { + uint32_t reserved; +} ADC_HandleTypeDef; + +extern ADC_HandleTypeDef hadc1; + +/* ADC 通道宏(F4:宏值 = 通道号) */ +#define ADC_CHANNEL_0 (0x00000000U) +#define ADC_CHANNEL_1 (0x00000001U) +#define ADC_CHANNEL_2 (0x00000002U) +#define ADC_CHANNEL_3 (0x00000003U) +#define ADC_CHANNEL_4 (0x00000004U) +#define ADC_CHANNEL_5 (0x00000005U) +#define ADC_CHANNEL_6 (0x00000006U) +#define ADC_CHANNEL_7 (0x00000007U) +#define ADC_CHANNEL_8 (0x00000008U) +#define ADC_CHANNEL_9 (0x00000009U) +#define ADC_CHANNEL_10 (0x0000000AU) +#define ADC_CHANNEL_11 (0x0000000BU) +#define ADC_CHANNEL_12 (0x0000000CU) +#define ADC_CHANNEL_13 (0x0000000DU) +#define ADC_CHANNEL_14 (0x0000000EU) +#define ADC_CHANNEL_15 (0x0000000FU) + +HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef *hadc); +HAL_StatusTypeDef HAL_ADC_PollForConversion(ADC_HandleTypeDef *hadc, uint32_t Timeout); +uint32_t HAL_ADC_GetValue(ADC_HandleTypeDef *hadc); +HAL_StatusTypeDef HAL_ADC_Stop(ADC_HandleTypeDef *hadc); + +#endif /* ADC_H */ diff --git a/test/host/adc_input/stub/tpafe5160.h b/test/host/adc_input/stub/tpafe5160.h new file mode 100644 index 00000000..69a9516f --- /dev/null +++ b/test/host/adc_input/stub/tpafe5160.h @@ -0,0 +1,28 @@ +/* + * 主机侧单测桩:tpafe5160.h + * 模块功能:为 App/algo/adc_input.c 提供主机可编译的 TPAFE5160 快照接口声明 + * (真实头会经 main.h 拖入 HAL 与 GPIO 宏,主机侧无法编译)。 + * + * ⚠ 仅供 test/host/adc_input/ 使用,不参与 MCU 构建。 + * + * 被测代码只消费 tpafe5160_snapshot()(E-58 原子快照),故桩只保留该声明; + * 其返回值语义(1=新帧 / 0=无新帧 / -1=参数错)与真实驱动一致,由测试单元 + * 给强符号桩并按用例注入。 + * + * 创建日期:2026-10-10 + */ + +#ifndef TPAFE5160_H +#define TPAFE5160_H + +#include + +/* + * 函数功能:原子快照最新一帧(消费者取数的唯一正确姿势) + * 入口参数:dst - 输出缓冲区 int16_t* 不为 NULL + * count - 拷贝通道数 uint8_t 1 - 8 + * 返 回 值:1 - 取到新帧,0 - 无新帧,-1 - 参数无效 + */ +int tpafe5160_snapshot(int16_t *dst, uint8_t count); + +#endif /* TPAFE5160_H */ From 63c262fe2c3f1d26de1e8e45b9fd64207c5579db Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 17:48:13 +0800 Subject: [PATCH 03/47] =?UTF-8?q?=E7=9B=B8=E9=87=8F=E5=B1=82=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=EF=BC=9A=E8=A1=A5=20phasor=5Fzero/init=20=E6=B8=85?= =?UTF-8?q?=E9=9B=B6=E7=94=A8=E4=BE=8B=EF=BC=88L1/L2=20=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E8=A6=86=E7=9B=96=2020/20=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 函数级覆盖审计发现 phasor_zero() 仅被 phasor_init() 间接执行、其效果 无可观测断言(init 同时把 s_ready 清 0,公开 API 看不到清零)。补直接 白盒用例: - TC-PH-030 直接调 phasor_zero,四字段(含 fAm/fAmSQ)全清零 - TC-PH-031/032/033 先把双区写脏,init 后双区全零 + 使用区复位 + 就绪标志清(get -> -2) - TC-PH-034 init 幂等 审计结果:App/algo 三个源共 20 个函数,0 个未覆盖。 相量层单测 57/57 PASSED(原 52)。 --- test/host/phasor/phasor_test.c | 42 ++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/host/phasor/phasor_test.c b/test/host/phasor/phasor_test.c index f32b028e..0a14e669 100644 --- a/test/host/phasor/phasor_test.c +++ b/test/host/phasor/phasor_test.c @@ -26,6 +26,7 @@ * TC-PH-020 L2-2 实例层:实例数/实例表/未攒满一周波不产出 * TC-PH-021 L2-6 滑动窗:连续滑动 20 拍基波读数恒定 * TC-PH-022 L2-7 双区交换:使用区交替 + get 返回**最后一次** run 的结果(非陈旧) + * TC-PH-030..034 phasor_zero 四字段清零 / phasor_init 双区清零 + 使用区与就绪标志复位 + 幂等 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -432,6 +433,45 @@ static void tc_instances(void) { CHECK(ok, "L2-2/L2-6 instance results == phasor_calc() on the same ring window"); } +/* ===================== TC-PH-030..034:phasor_zero / init 清零 ===================== */ + +static void tc_zero(void) { + phasor_t p; + uint8_t i; + int zones_dirty = 0; + + /* 1) 直接白盒:phasor_zero 把四个字段(含 fAm / fAmSQ)全部清零 */ + p.fRe = 1.0f; p.fIm = -2.0f; p.fAm = 3.0f; p.fAmSQ = 4.0f; + phasor_zero(&p); + CHECK((p.fRe == 0.0f) && (p.fIm == 0.0f) && (p.fAm == 0.0f) && (p.fAmSQ == 0.0f), + "TC-PH-030 phasor_zero clears all four fields"); + + /* 2) 经 phasor_init:先把双区写成脏值,init 后必须全零,使用区复位、就绪标志清 */ + for (i = 0u; i < (uint8_t)PHASOR_INST_N; i++) { + s_zone[0][i].fRe = 9.0f; s_zone[0][i].fIm = 9.0f; + s_zone[0][i].fAm = 9.0f; s_zone[0][i].fAmSQ = 9.0f; + s_zone[1][i].fRe = 9.0f; s_zone[1][i].fIm = 9.0f; + s_zone[1][i].fAm = 9.0f; s_zone[1][i].fAmSQ = 9.0f; + } + s_use_zone = 1u; + (void)phasor_init(); + for (i = 0u; i < (uint8_t)PHASOR_INST_N; i++) { + if ((s_zone[0][i].fRe != 0.0f) || (s_zone[0][i].fIm != 0.0f) || + (s_zone[0][i].fAm != 0.0f) || (s_zone[0][i].fAmSQ != 0.0f) || + (s_zone[1][i].fRe != 0.0f) || (s_zone[1][i].fIm != 0.0f) || + (s_zone[1][i].fAm != 0.0f) || (s_zone[1][i].fAmSQ != 0.0f)) { + zones_dirty = 1; + } + } + CHECK(zones_dirty == 0, "TC-PH-031 phasor_init zeroes both result zones"); + CHECK(s_use_zone == 0u, "TC-PH-032 phasor_init resets use zone to 0"); + CHECK(phasor_get(0u, &p) == -2, "TC-PH-033 phasor_init clears ready (get -> -2)"); + + /* 3) 幂等:重复 init 不改变结果 */ + (void)phasor_init(); + CHECK((s_use_zone == 0u) && (phasor_get(0u, &p) == -2), "TC-PH-034 phasor_init is idempotent"); +} + /* ===================== TC-PH-022:L2-7 双区指针原子交换 ===================== */ static void tc_dualzone(void) { @@ -504,6 +544,8 @@ int main(void) { tc_instances(); printf("-- L2-7 dual-zone swap --\n"); tc_dualzone(); + printf("-- L2 zero / init --\n"); + tc_zero(); printf("\n=== Phasor Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); From 3715f8a34c559e64a917b7a1ae1c58bb97e8a0c7 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 17:53:48 +0800 Subject: [PATCH 04/47] =?UTF-8?q?=E8=A1=A5=E4=B8=A4=E4=BB=BD=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=8D=95=E6=B5=8B=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=9AADC=20=E8=BE=93=E5=85=A5=E5=B1=82=EF=BC=88L1=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E7=9B=B8=E9=87=8F=E5=B1=82=EF=BC=88L2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按仓库惯例(每个主机侧单测配一份 docs/测试说明/*单元测试说明.md 并在 docs/README.md 注册)补齐上一提交新落地的两个套件。 - docs/测试说明/ADC输入层单元测试说明.md:70 用例逐条列出(init 复位 / 语义映射表 / map_check / ADC1 顺序扫描与 Rank 到通道判据 / raw 守卫与 补码 / poll 契约 / rc 与 errs 观测);含桩表与"反向路径不可达"说明。 - docs/测试说明/相量层单元测试说明.md:57 用例(系数表 / 幅值相位 / 直流与谐波抑制 / 任意次谐波提取 / 频偏 / 采样环 / 实例层 / 滑动窗 / 双区交换 / phasor_zero 与 init 清零);含 sqrt2/N 系数约定与"暴力重算" 取舍说明,并记录 L2-2 实例数(规划 4 vs 实现 5)待对齐。 - docs/README.md:主机侧单元测试(L0.5)表新增两行。 doc-links / mojibake / EOF 等钩子全绿。 --- docs/README.md | 2 + docs/测试说明/ADC输入层单元测试说明.md | 265 +++++++++++++++++++++++++ docs/测试说明/相量层单元测试说明.md | 210 ++++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 docs/测试说明/ADC输入层单元测试说明.md create mode 100644 docs/测试说明/相量层单元测试说明.md diff --git a/docs/README.md b/docs/README.md index 7cc7d007..c450567f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -125,6 +125,8 @@ docs/ | [`看门狗单元测试说明.md`](测试说明/看门狗单元测试说明.md) | Task Watchdog 判定状态机(位图 / 掩码 / 节流 / tick 回绕),39 用例 | | [`系统时钟单元测试说明.md`](测试说明/系统时钟单元测试说明.md) | sys_clock 时间换算 / 星期 Zeller / 30 天惰性再基准化 / RTC 故障降级,100 断言点 | | [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,139 断言点(含抓出的外科手术保存死码 P0) | +| [`ADC输入层单元测试说明.md`](测试说明/ADC输入层单元测试说明.md) | adc_input 语义通道映射 / ADC1 顺序扫描(**Rank↔通道判据**)/ poll 契约 / HAL 故障路径,70 用例 | +| [`相量层单元测试说明.md`](测试说明/相量层单元测试说明.md) | phasor 全周傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换,57 用例 | **模块测试指南**(动手写测试前先读) diff --git a/docs/测试说明/ADC输入层单元测试说明.md b/docs/测试说明/ADC输入层单元测试说明.md new file mode 100644 index 00000000..39b60909 --- /dev/null +++ b/docs/测试说明/ADC输入层单元测试说明.md @@ -0,0 +1,265 @@ +# ADC 输入层单元测试说明(主机侧) + +> **模块**:ADC Input Layer(L1 采集与预处理,`App/algo/adc_input.c`) +> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证语义通道映射、 +> STM32 ADC1 顺序扫描、TPAFE 快照消费与取数守卫,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(70 用例,`/W4` 0 告警,已挂 pre-commit 钩子) +> **日期**:2026-10-10 +> **关联文档**: +> [`../../AGENTS.md`](../../AGENTS.md)(平台约束 · CubeMX 生成区铁律)、 +> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 +> [`配置文件单元测试说明.md`](配置文件单元测试说明.md)(同层"强符号桩"范本)、 +> [`相量层单元测试说明.md`](相量层单元测试说明.md)(同批落地的 L2 姊妹篇)、 +> [`../硬件平台/ADC通道映射说明.md`](../硬件平台/ADC通道映射说明.md)(**语义映射权威口径**)、 +> [`../算法/继电保护功能复现规划.md`](../算法/继电保护功能复现规划.md)(L1-3 / L1-4 设计依据)、 +> [`../问题记录/错误码传播审计.md`](../问题记录/错误码传播审计.md)(E-44 / E-58 快照语义) + +--- + +## 1. 概述 + +### 1.1 为什么单独做主机侧单测 + +`adc_input.c` 把两片转换器的原始码值按**语义**统一暴露,其正确性的核心是三者一致: + +| 半边 | 内容 | 可测性 | +|---|---|---| +| **纯逻辑**(被测) | ① 语义通道映射表(L1-4):`UA/UB/UC/UN/IA/IB/IC/IN/TEMP` → `(来源, 下标)`;② STM32 ADC1 9 通道软件触发顺序扫描(L1-3):**Rank 顺序 ↔ ADC1 通道号 ↔ 语义**;③ 取数守卫与 poll 契约;④ 扫描状态码 / 失败计数 | 纯查表 + 状态机,与硬件无关 → **可在 PC 上位穷举** | +| **硬件副作用** | `HAL_ADC_Start` / `PollForConversion` / `GetValue` / `Stop`、`tpafe5160_snapshot()`(临界区) | 均为**外部符号** → 主机侧给同名强符号桩即可 | + +板端套件(`test/board/`)**无法验证本层的核心判据**: + +- **通道映射**:板端**无信号源**(见 `docs/硬件平台/ADC通道映射说明.md` §8),无法注入已知电压去 + 验证"读到的是哪只脚"。而映射表一旦写错(本项目 V0.1 有 13/17 路被置换),读到的会是 + **另一只脚**或**恒为陈旧值**,且**不会有任何报错**——这正是本层最危险的失效模式。 +- **HAL 故障路径**:`HAL_ADC_Start` 失败、转换轮询超时(2 ms)在真机上极难稳定复现。 + +主机侧的做法:给**每个 Rank 注入互不相同的码值**(第 i 个 Rank 读到 `100+i`),把 +"Rank 顺序 ↔ ADC1 通道号 ↔ 语义"整条链路**逐通道钉死**;故障路径用可注入桩一次覆盖。 + +> ⚠ 本单测**只覆盖 L1-3 / L1-4**。L1-5(通道标定)本层不做,故取到的是**未标定原始码值**; +> L1-1 / L1-2(周期触发与 TPAFE 快照链路)由 `adc_task` + TPAFE 驱动承担,板端套件覆盖。 + +### 1.2 被测对象与桩(**不改生产代码**) + +`adc_input.c` 的全部平台依赖都是**外部符号**,故测试单元只需提供**同名强符号桩**, +**生产代码零改动**(无任何 `#ifdef` 测试钩子)。 + +``` +test/host/adc_input/adc_input_test.c + ├── #include "adc.h" → stub/adc.h (HAL_ADC_* 原型 + hadc1 + ADC_CHANNEL_0..15) + ├── #include "tpafe5160.h" → stub/tpafe5160.h (仅 tpafe5160_snapshot 原型) + └── #include "adc_input.c" ← 生产源文件(App/algo/),原样纳入 +``` + +> **`adc_input.h` 用真实头**:它只依赖 ``,不拖 HAL,故接口契约原汁。 +> **`adc.h` / `tpafe5160.h` 必须用桩**:真实 `Inc/adc.h` 经 `main.h` 拖入整套 HAL; +> 真实 `Drivers/BSP/TPAFE5160/tpafe5160.h` 拖入 GPIO 宏——主机侧无法编译。 +> **桩目录必须排在 `App/algo` 之前**(`include_dirs` 顺序敏感,见 `run_unit_test.py`)。 + +| 平台接口 | MCU 端 | 主机侧桩 | +|---|---|---| +| `hadc1` | `MX_ADC1_Init()` 配置的 ADC1 句柄 | 不透明占位对象(被测代码只透传指针,从不读字段) | +| `HAL_ADC_Start` | 软件触发一次 9 通道扫描 | 返回 `s_start_rc`(可注入 `HAL_ERROR`);复位轮询/取数计数器 | +| `HAL_ADC_PollForConversion` | 逐 Rank 轮询 EOC(2 ms 超时) | 第 `s_poll_fail_at` 次(0 基)返回 `HAL_ERROR`;`-1` = 永不失败 | +| `HAL_ADC_GetValue` | 读 DR | 返回 `s_poll_vals[s_rank_idx]`(默认 `100 + rank`) | +| `HAL_ADC_Stop` | 停止转换 | 计次 `s_stop_calls` | +| `tpafe5160_snapshot` | 临界区原子快照最新一帧 | 返回 `s_snapshot_rc`(1/0/-1);`rc==1` 时从 `s_fake_tpafe[]` 拷贝;记录 `count` 与 `dst` | + +桩里 `ADC_CHANNEL_0..15` 的**宏值等于通道号**(F4 HAL 即如此),故 `(uint8_t)ADC_CHANNEL_14 == 14` +与真机一致——这是"映射判据"能成立的前提。 + +### 1.3 测试环境 + +| 依赖 | 版本 / 说明 | +|---|---| +| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 | +| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) | +| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / 信号源) | + +工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码判定全部委托通用 harness +[`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 + +### 1.4 运行方式 + +```powershell +# 仓库根目录(统一用 .venv) +& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py +``` + +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +### 1.5 回归接入 + +| 项 | 说明 | +|---|---| +| **触发范围** | `App/algo/adc_input.c` / `adc_input.h`,或 `test/host/adc_input/**`、`test/host/common/unit_test_harness.py` 变动 | +| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 | +| **pre-commit 钩子** | `adc-input-unit-test`(本地钩子,`.pre-commit-config.yaml` §7e) | +| **汇总行** | `=== ADC Input Tests: 70/70 PASSED (failed=0) ===` | +| **与板端测试的关系** | 互补:本单测保证**映射 / 契约 / 故障路径**正确;板端保证 **HAL 配置(Rank 顺序、采样时间、引脚)** 与真机一致 | + +--- + +## 2. 测试用例 + +> 命名 `TC-AI-NNN`(AI = Adc Input;主机侧单测自定前缀,与板端套件缩写表互不影响)。 +> 全部在 `adc_input_test.c` 内,主机侧运行。被测源文件被 `#include` 纳入同一编译单元, +> 故**静态函数(`map_check` / `stm32_scan`)亦可直接调用**(白盒)。 + +### 2.1 初始化与复位(`adc_input_init`) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-001** | 前置:注入 `HAL_ADC_Start` 失败 → poll 后失败计数 = 1 | 统计可见性(前置构造) | +| **TC-AI-002** | 前置:注入 TPAFE 有效帧 → poll 后 `UA` 可取数且值正确 | 取数通路(前置构造) | +| **TC-AI-003** | `adc_input_init()` 返回 0 | 映射表自检通过 | +| **TC-AI-004** | init 后 `adc_input_stm32_rc() == ADC_INPUT_RC_NOT_RUN` | 状态码复位 | +| **TC-AI-005** | init 后 `adc_input_stm32_errs() == 0` | 失败计数归零 | +| **TC-AI-006** | init 后 TPAFE / STM32 **两侧** `adc_input_raw()` 均返回 -2 | 有效标志被清(不会取到上一轮陈旧值) | + +### 2.2 语义映射表(`adc_input_desc`) + +| 用例 | 断言(语义 → 来源 / 下标) | 权威口径 | +|---|---|---| +| **TC-AI-010** | `UA` → TPAFE `buf[6]` | `docs/硬件平台/ADC通道映射说明.md` | +| **TC-AI-011** | `UB` → STM32 `CH14` | 同上 | +| **TC-AI-012** | `UC` → STM32 `CH3` | 同上 | +| **TC-AI-013** | `UN` → TPAFE `buf[5]` | 同上 | +| **TC-AI-014** | `IA` → TPAFE `buf[2]` | 同上 | +| **TC-AI-015** | `IB` → STM32 `CH6` | 同上 | +| **TC-AI-016** | `IC` → STM32 `CH1` | 同上 | +| **TC-AI-017** | `IN` → STM32 `CH2` | 同上 | +| **TC-AI-018** | `TEMP` → STM32 `CH5`(V_ZL2) | 同上 | +| **TC-AI-019** | `adc_input_desc(ADC_SEM_COUNT)` → `NULL` | 越界防御 | +| **TC-AI-01A** | `adc_input_desc((adc_semantic_t)-1)` → `NULL` | 无符号比较兜底 | + +### 2.3 映射表自检(`map_check`,静态) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-020** | `map_check() == 0` | 真实映射表自洽:TPAFE 下标 < 8;每个 STM32 映射通道都出现在扫描 Rank 内 | + +> 反向路径(表不自洽 → -1)**结构上不可达**,见 §4.1。 + +### 2.4 STM32 ADC1 顺序扫描(`stm32_scan`,静态) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-021** | 全通道成功 → 返回 0 | 正常路径 | +| **TC-AI-022** | `adc_input_stm32_rc() == RC_OK` | 状态码 | +| **TC-AI-023** | 成功不计入失败计数 | 统计语义 | +| **TC-AI-024** | `HAL_ADC_Start` / `HAL_ADC_Stop` 各一次 | 外设生命周期成对 | +| **TC-AI-025** | 轮询 9 次、取数 9 次(Rank1..9) | 扫描通道数 | +| **TC-AI-026** | ⭐ **Rank → 通道号映射**:`UB` = CH14 / rank9 = 108 | **本层核心判据**(板端无法验) | +| **TC-AI-027** | `UC` = CH3 / rank4 = 103 | 同上 | +| **TC-AI-028** | `IB` = CH6 / rank7 = 106 | 同上 | +| **TC-AI-029** | `IC` = CH1 / rank2 = 101 | 同上 | +| **TC-AI-02A** | `IN` = CH2 / rank3 = 102 | 同上 | +| **TC-AI-02B** | `TEMP` = CH5 / rank6 = 105 | 同上 | +| **TC-AI-02C** | 扫描后 `UA`(TPAFE)仍为 -2 | 两来源互相独立 | +| **TC-AI-030** | `HAL_ADC_Start` 返回 `HAL_ERROR` → 返回 -1 | 启动失败 | +| **TC-AI-031** | 状态码 = `RC_START` | 故障可观测 | +| **TC-AI-032** | 失败计数 +1 | 故障可观测 | +| **TC-AI-033** | 启动失败**不**调用 `Stop` | 不对外设做多余操作 | +| **TC-AI-034** | 启动失败后 STM32 侧仍无有效数据 | 不产出半帧 | +| **TC-AI-035** | 第 5 个 Rank 轮询超时 → 返回 -2 | 超时路径 | +| **TC-AI-036** | 状态码 = `RC_TIMEOUT` | 故障可观测 | +| **TC-AI-037** | 超时计入失败计数 | 故障可观测 | +| **TC-AI-038** | 超时前已 `Start` → 调 `Stop` 收尾 | 资源不泄漏 | +| **TC-AI-039** | 超时后**立即退出**(只轮询 5 次,不继续扫完) | 不长时间阻塞 1 ms 保护节拍 | +| **TC-AI-039b** | 超时后 STM32 侧无有效数据 | 不产出半帧 | +| **TC-AI-03A** | 再失败一次 → 计数 = 2 | 跨次单调累计 | + +### 2.5 取数与参数守卫(`adc_input_raw`) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-040** | `out == NULL` → -1 | 参数防御 | +| **TC-AI-041** | `ch == ADC_SEM_COUNT` → -1 | 越界防御 | +| **TC-AI-042** | `ch == (adc_semantic_t)-1` → -1 | 负数兜底 | +| **TC-AI-043** | 参数错时**不写** `*out` | 无副作用 | +| **TC-AI-044** | TPAFE 无有效帧 → -2 | 有效标志语义 | +| **TC-AI-045** | STM32 无有效扫描 → -2 | 有效标志语义 | +| **TC-AI-049** | `UA` → `buf[6]` 的值(注入 3006) | 按**下标**取数(非按枚举序) | +| **TC-AI-04A** | `UN` → `buf[5]` 的值(注入 2005) | 同上 | +| **TC-AI-04B** | `IA` → `buf[2]` 的值(注入 **-1234**) | 16 位补码负值原样保留(不截断 / 不取绝对值) | + +### 2.6 采集一拍(`adc_input_poll`) + +| 用例 | 断言 | 验证的设计点 | +|---|---|---| +| **TC-AI-046** | 快照返回 1 → `poll()` 返回 1 | 返回码透传 | +| **TC-AI-047** | 快照被请求 **8** 通道 | 与 `ADC_INPUT_TPAFE_CH_NUM` 一致 | +| **TC-AI-048** | 快照目的缓冲非空 | 不传 NULL 给驱动 | +| **TC-AI-050** | 快照返回 0(无新帧)→ `poll()` 返回 0 | 无新帧语义 | +| **TC-AI-051** | 无新帧时 TPAFE 侧无效 | 不置有效标志 | +| **TC-AI-052** | 同拍 STM32 扫描**照常执行**(`rc == RC_OK`) | 两侧独立 | +| **TC-AI-053** | 快照返回 -1(参数错)→ `poll()` 返回 -1 | 错误透传 | +| **TC-AI-054** | 参数错时 TPAFE 侧无效 | 不置有效标志 | +| **TC-AI-055** | 已有有效帧后再"无新帧" → **保持**上次有效值 | 有效标志只置不清 | +| **TC-AI-056** | STM32 失败**不影响** `poll()` 返回值(仍为 1) | 两侧解耦 | +| **TC-AI-057** | STM32 失败不影响 TPAFE 取数 | 同上 | +| **TC-AI-058** | STM32 失败另由 `adc_input_stm32_rc()` 暴露 | 故障通道分离 | + +### 2.7 状态码与失败计数观测 + +| 用例 | 断言 | +|---|---| +| **TC-AI-060** | init 后未扫描 → `RC_NOT_RUN(-3)` | +| **TC-AI-061** | init 后失败计数 = 0 | +| **TC-AI-062** | 一次成功后 `rc == OK` 且 `errs` 不变 | +| **TC-AI-063** | 两次失败累计 `errs == 2` | +| **TC-AI-064** | `rc` = **最近一次**(`RC_TIMEOUT`) | +| **TC-AI-065** | 恢复成功后 `rc == OK` | +| **TC-AI-066** | `errs` **单调不回退**(不因成功回落) | + +**当前结果**:70/70 PASSED(`failed=0`)。 + +--- + +## 3. 如何扩展用例 + +1. 在 `adc_input_test.c` 加一个 `static void tc_xxx(void)`; +2. 用例开头调 `fakes_reset()`(把全部桩复位到"健康"初值 + 复位观测计数); +3. 用 `s_start_rc` / `s_poll_fail_at` / `s_poll_vals[]` 注入 HAL 行为; + 用 `s_snapshot_rc` / `s_fake_tpafe[]` 注入 TPAFE 帧; +4. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。 + +> ⚠ `fakes_reset()` 必须在新用例开头调用:桩计数器(`s_poll_calls` / `s_rank_idx` / +> `s_start_calls` / `s_stop_calls` / `s_snapshot_calls`)跨用例共享,不复位会互相污染。 +> +> ⚠ 模块静态态(`s_stm32_raw` / `s_tpafe_raw` / `s_*_valid` / `s_stm32_rc` / `s_stm32_errs`) +> 由 `adc_input_init()` 复位;**每个用例开头都应调一次 `adc_input_init()`**(等价"刚上电")。 + +--- + +## 4. 已知边界与设计取舍 + +### 4.1 `map_check()` 反向路径结构上不可达 + +`map_check()` 的失败分支要求"映射表指向 TPAFE 下标 ≥8,或 STM32 通道号不在扫描 Rank 内"。 +但 `k_adc_map[]` 与 `k_stm32_rank_ch[]` 都是**编译期 `static const`**,没有任何运行时输入 +能使其不自洽(曾尝试用 `#define` 重定向表名,会与生产文件内的定义撞成重复定义)。 +因此本单测**只覆盖正向路径**(`map_check() == 0`);反向路径的等效保障是 +`adc_input_init()` 的返回值——它在开机时自检并留痕(见 `app_main.c`)。 + +### 4.2 本层不做标定(L1-5 待办) + +`adc_input_raw()` 返回的是**未标定原始码值**(TPAFE = 16 位补码,STM32 = 0..4095)。 +一次系数 / 零漂标定属 **L1-5,尚未实现**,故本单测不对"物理量换算"做任何断言。 + +### 4.3 不覆盖 V0.1 的硬件缺陷(N-18) + +V0.1 上 8 路电气量分属两片转换器且**无共同采样触发**(N-18),本层只做**逐通道独立监视**。 +"跨转换器相量合成"(线 / 负序 / 零序)不得在本层或 L2 上做——属 L2-5 且 V0.1 受限 +(见 `docs/算法/继电保护功能复现规划.md` §1.6)。 + +--- + +## 5. 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建;70 用例全绿(`/W4` 0 告警);覆盖 `adc_input.c` 全部 8 个函数(6 公开 + 2 静态);挂 pre-commit 钩子 `adc-input-unit-test` | diff --git a/docs/测试说明/相量层单元测试说明.md b/docs/测试说明/相量层单元测试说明.md new file mode 100644 index 00000000..0f4e2eee --- /dev/null +++ b/docs/测试说明/相量层单元测试说明.md @@ -0,0 +1,210 @@ +# 相量层单元测试说明(主机侧) + +> **模块**:Phasor Layer(L2 相量计算,`App/algo/phasor.c` + `App/algo/sample_ring.c`) +> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证全周傅氏相量计算与 +> 采样环缓冲,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(57 用例,`/W4` 0 告警,已挂 pre-commit 钩子) +> **日期**:2026-10-10 +> **关联文档**: +> [`../../AGENTS.md`](../../AGENTS.md)(平台约束)、 +> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 +> [`ADC输入层单元测试说明.md`](ADC输入层单元测试说明.md)(同批落地的 L1 姊妹篇)、 +> [`../算法/傅氏算法详解.md`](../算法/傅氏算法详解.md)(**系数约定权威口径** §2.3)、 +> [`../算法/继电保护功能复现规划.md`](../算法/继电保护功能复现规划.md)(§3.2 L2 设计点 / §10.2 术语表)、 +> [`../硬件平台/ADC通道映射说明.md`](../硬件平台/ADC通道映射说明.md)(L1 输入口径 / N-18 约束) + +--- + +## 1. 概述 + +### 1.1 为什么单独做主机侧单测 + +`App/algo/` 是本项目**首个纯算法层**(L1/L2 落点)——**无寄存器、无 RTOS、无文件 / 外设依赖**, +因此天然适合离线穷举。板端套件(`test/board/`)**无法验证本层的核心判据**: + +- 板端**无信号源**,无法注入已知**幅值 / 相位 / 频率**的标准波形,因而无法验证 + "读数 = 设定值"这一根本判据(同 L1-3 的替代口径,见 `docs/硬件平台/ADC通道映射说明.md` §8); +- 谐波抑制、频率敏感度、滑动窗恒定性、双区无撕裂等特性在板上**无稳定激励源**可造。 + +主机侧可**合成任意波形**(含直流、任意次谐波、频偏、任意初相),把上述特性一次性钉死, +且**不占 MCU 的 RAM**(本项目 RAM 预算见 `docs/内存管理/RAM使用分析与优化方案.md`)。 + +### 1.2 被测对象(**无需任何桩**) + +两个源文件都是纯算法,测试单元直接 `#include` 即可,**没有桩目录**: + +``` +test/host/phasor/phasor_test.c + ├── #include "sample_ring.c" ← 生产源文件(App/algo/),原样纳入(采样环,L2 输入) + └── #include "phasor.c" ← 生产源文件(App/algo/),原样纳入(相量计算,L2 核心) +``` + +`include_dirs` 只需 `App/algo` + 本目录。源文件被纳入同一编译单元,故**静态量亦可直接观测** +(白盒):`s_zone[2][N]`、`s_use_zone`、`s_ready`、`k_fft_sin` / `k_fft_cos`、`phasor_zero()`。 + +### 1.3 关键约定:系数表已预乘 √2/N("直接即有效值") + +权威口径 `docs/算法/傅氏算法详解.md` §2.3:系数为 +`sin(2πk/N)·(√2/N)`、`cos(2πk/N)·(√2/N)`(N = 20)。因此 `fRe = a₁/√2`、`fIm = b₁/√2`,于是 + +- **`fAmSQ = √(fRe² + fIm²)` 直接就是有效值(RMS)**,无需再除 √2; +- `fAm = √2 · fAmSQ` 是正弦峰值。 + +判据是归一化恒等式 **`Σsin² = Σcos² = 1/N`**(`TC-PH-001/002` 逐项校验,防转录错)。 +术语权威见 `docs/算法/继电保护功能复现规划.md` §10.2: +`{fRe, fIm, fAm, fAmSQ}` = 实部 / 虚部 / **幅值** / **有效值**。 + +> 采样参数:N = 20 点/周波 @ 50 Hz → 1000 Hz,保护节拍 1 ms(与 L1 采样节拍同源)。 +> 系数表为**离线生成的 float 常量**(运行期不调 `sinf` / `cosf`),故主机侧与 MCU 侧 +> 使用**逐位相同**的系数——这是本单测结论能外推到真机的前提。 + +### 1.4 测试环境 + +| 依赖 | 版本 / 说明 | +|---|---| +| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 | +| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) | +| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / 信号源) | + +工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码判定全部委托通用 harness +[`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 汇总行标记)。 + +### 1.5 运行方式 + +```powershell +# 仓库根目录(统一用 .venv) +& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py +``` + +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +### 1.6 回归接入 + +| 项 | 说明 | +|---|---| +| **触发范围** | `App/algo/phasor.c` / `phasor.h`、`App/algo/sample_ring.c` / `sample_ring.h`,或 `test/host/phasor/**`、`test/host/common/unit_test_harness.py` 变动 | +| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 | +| **pre-commit 钩子** | `phasor-unit-test`(本地钩子,`.pre-commit-config.yaml` §7d) | +| **汇总行** | `=== Phasor Tests: 57/57 PASSED (failed=0) ===` | +| **与板端测试的关系** | 互补:本单测保证**算法正确性**;板端保证 **1 kHz 采样节拍与 TPAFE 快照链路**(L1-1 / L1-2)正常 | + +--- + +## 2. 测试用例 + +> 命名 `TC-PH-NNN`(PH = PHasor)。用例内的输出标签使用 `L2-x` 前缀,对应 +> `docs/算法/继电保护功能复现规划.md` §3.2 的设计点编号。部分用例直接观测静态量(白盒)。 + +### 2.1 L2-1 全周傅氏系数表 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-001** | 逐项:`sin[k] == sin(2πk/N)·(√2/N)`、`cos[k] == cos(2πk/N)·(√2/N)`(k = 0..19) | L2-1 防转录错 | +| **TC-PH-002** | 归一化 `Σsin² == Σcos² == 1/N`;精确零点 `sin@0,10`、`cos@5,15`;`sin[5] == √2/N` | L2-1 判据基础 | + +### 2.2 L2-3 / L2-4 基波幅值 / 有效值 / 相位 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-003** | 纯基波:`fAmSQ == A/√2`(**有效值**)、`fAm == √2·fAmSQ`(幅值),误差 ≤ 1% | L2-3 | +| **TC-PH-004** | `atan2(fIm, fRe)` == 注入相角(6 组幅值 / 相位) | L2-4 | + +### 2.3 直流与谐波抑制 / 任意次谐波提取 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-005** | 直流 + 2 / 3 / 5 次谐波被**完全滤除**(基波读数不变,误差 ≤ 1%,5 例) | L2-1 | +| **TC-PH-006** | 任意次谐波提取(**索引步进 = 谐波次数**):k = 1 / 2 / 5 从混合信号中提取;纯基波下 2 / 5 次分量 ≈ 0 | L2-1 / L1-9 入口 | + +### 2.4 频率敏感度 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-007** | 幅值误差:50 Hz ≤ 0.1%、±1 Hz ≤ 1.5%、±2 Hz ≤ 3%(**最坏相位**) | L2-3 / L1-7 依据 | + +### 2.5 L2 输入采样环(`sample_ring.c`) + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-010** | `init` 清填充与帧计数;未攒满取窗 → -2;入环后取窗**按时序**(老 → 新);通道之间**互相独立**;填充量饱和于 `DEPTH` 但帧计数**不**饱和;回绕后取窗 == 最后 N 个值(有序);参数守卫:`n=0` / `n>DEPTH` / 越界通道 / `NULL out` / `NULL frame` / 错帧长 均拒绝 | L2 输入 | + +### 2.6 L2-2 实例层 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-020** | 实例数 == 5;实例表可取 `name`;越界实例 → `NULL`;未攒满一周波 `phasor_get()` → -2;越界 index / `NULL out` → -1;攒满 20 帧后 `get()` == 0 且实例 0 的 RMS == `A/√2`;谐波实例(`C04-IA-F2` / `C05-IA-F5`)在纯基波下 ≈ 0;**逐实例复核"实例窗 == 独立 `phasor_calc()` 结果"** | L2-2 | + +### 2.7 L2-6 滑动窗 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-021** | 连续滑动 20 拍,基波读数**恒定**(误差 ≤ 1%) | L2-6 | + +### 2.8 L2-7 双区指针原子交换 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-022** | 使用区逐次产出**交替** 0 → 1 → 0 → 1;**连测 60 拍**:每拍 `get()` == 当前环窗的独立重算(幅值每 20 拍在 100 / 10 之间切换 → 读到陈旧区会立刻不相等);扫描后结果仍可读 | L2-7 | + +### 2.9 `phasor_zero` / `phasor_init` 清零与幂等 + +| 用例 | 断言 | 设计点 | +|---|---|---| +| **TC-PH-030** | 直接白盒调 `phasor_zero()` → 四字段(含 `fAm` / `fAmSQ`)全清零 | 静态辅助 | +| **TC-PH-031** | 先把双区写脏 → `phasor_init()` 后**双区全零** | init 清零 | +| **TC-PH-032** | `phasor_init()` 后使用区复位为 0 | init 复位 | +| **TC-PH-033** | `phasor_init()` 后就绪标志清(`phasor_get()` → -2) | init 复位 | +| **TC-PH-034** | 重复 `phasor_init()` 幂等 | 可重复调用 | + +**当前结果**:57/57 PASSED(`failed=0`)。 + +--- + +## 3. 如何扩展用例 + +1. 加一个 `static void tc_xxx(void)`;用 `gen_sine()` 造正弦窗、`frame_at()` 造整帧(喂实例层); +2. 驱动实例层三步:`sample_ring_push_frame()` → `phasor_run()` → `phasor_get()`; +3. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。 + +> ⚠ 实例层是**全局态**(双区 + 采样环),跨用例必须先 `phasor_init()`——它会同时 +> `sample_ring_init()`,等价于"刚上电"。否则上一用例的环内容会串进来。 + +--- + +## 4. 已知边界与设计取舍 + +### 4.1 采样环用"暴力重算",不做迭代 + +每来一个新点,`phasor_run()` 对 20 点**整体重算**(滑动窗)。迭代法(加减首尾项)会累积 +浮点误差且难以保证与"整体重算"逐位一致;暴力法的代价是每拍 `N × 实例数` 次乘加 + 取模, +在 1 ms 节拍下仍有余量。本单测的 `TC-PH-022`(每拍 `get()` == 独立重算)正是这条取舍的 +**回归防线**——一旦有人改成迭代法,逐位一致性断言会立刻失败。 + +### 4.2 主机侧合成波形**不代表真实硬件** + +`frame_at()` 给 9 路语义通道灌的是**同值**波形,仅为驱动 L2 本身。V0.1 上各通道**不同刻** +(N-18:TPAFE 与 STM32 无共同采样触发),跨通道相量运算在本平台上不成立——本单测**不**做 +任何跨通道合成断言(L2-5 受限,见 `docs/算法/继电保护功能复现规划.md` §1.6)。 + +### 4.3 与规划表的一处待对齐 + +`docs/算法/继电保护功能复现规划.md` §3.2 的 L2-2 行仍写作"**4 个实例**:C01 基波 I/U、 +C02 零序基波、C03 二次谐波、C04 五次谐波",而落地实现是 **5 个实例** +(`C01-IA-F1` / `C02-UA-F1` / `C03-UN-F1` / `C04-IA-F2` / `C05-IA-F5`)。 +本单测按**实现**断言(实例数 == 5);规划表待同步。 + +### 4.4 实例表用"数据驱动"而非硬编码 + +`k_inst[]` 每项为 `{name, sem, harm}`,`phasor_run()` 遍历该表取窗并计算。因此新增一个 +相量实例(如 L2-8 的遥测谐波轨)**只需在表里加一行**,本单测的"实例数 == 5"与 +"逐实例复核"两条断言会**自动扩展到新实例**——但需同步更新该常量断言。 + +--- + +## 5. 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建;52 用例(TC-PH-001..022)全绿;覆盖系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 实例层 / 滑动窗 / 双区交换 | +| 2026-10-10 | 补 TC-PH-030..034(`phasor_zero` 四字段清零 + `phasor_init` 双区清零 / 使用区与就绪标志复位 / 幂等)→ 57 用例;函数级覆盖审计 `App/algo` 三源共 20 函数、0 未覆盖;挂 pre-commit 钩子 `phasor-unit-test` | From a2f5d1397ac1b2d638266136a2e2387d52c1cfb4 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 18:03:31 +0800 Subject: [PATCH 05/47] =?UTF-8?q?=E4=B8=BB=E6=9C=BA=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=E5=8D=B7=E9=94=81=E4=B8=8E=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E5=90=8C=E6=AD=A5=E4=B8=A4=E5=A5=97=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=8833+80=20=E9=A1=B9=EF=BC=89=E5=B9=B6=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E9=92=A9=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vol_lock(App/fs/vol_lock.c):33 用例。核心不变量是 fail-open—— 互斥量创建失败时 vol_lock_wait() 必须返回 1 且绝不阻塞,因为调用者 defaultTask 是看门狗喂狗点;板端该路径只在内存耗尽时才可能走到。 - time_sync(App/sync/time_sync.c):80 用例。本模块是 N-17 看门狗风险的 全部逻辑贡献者,用例钉死分片喂狗节奏、监听退避、报文重组(半包/粘连/ EOF/真错/预算耗尽/tick 回绕)、重听门控;用 longjmp 跳出 for(;;) 任务循环。 - 两套均为纯逻辑主机侧测试,MSVC 离线编译真实源文件(#include 纳入)。 - 新增钩子 vol-lock-unit-test、time-sync-unit-test(7f/7g)。 - 全量主机单测:watchdog 39 + sys_clock 100 + cfg_file 149 + phasor 57 + adc_input 70 + vol_lock 33 + time_sync 80 = 528 项,全绿。 --- .pre-commit-config.yaml | 30 ++ test/host/time_sync/run_unit_test.py | 58 +++ test/host/time_sync/stub/FreeRTOS.h | 34 ++ test/host/time_sync/stub/cmsis_os2.h | 59 +++ test/host/time_sync/stub/dbg_log.h | 31 ++ test/host/time_sync/stub/net_socket.h | 71 +++ test/host/time_sync/stub/sys_clock.h | 29 ++ test/host/time_sync/stub/sys_events.h | 39 ++ test/host/time_sync/stub/task.h | 29 ++ test/host/time_sync/time_sync_test.c | 717 ++++++++++++++++++++++++++ test/host/vol_lock/run_unit_test.py | 53 ++ test/host/vol_lock/stub/cmsis_os.h | 56 ++ test/host/vol_lock/vol_lock_test.c | 224 ++++++++ 13 files changed, 1430 insertions(+) create mode 100644 test/host/time_sync/run_unit_test.py create mode 100644 test/host/time_sync/stub/FreeRTOS.h create mode 100644 test/host/time_sync/stub/cmsis_os2.h create mode 100644 test/host/time_sync/stub/dbg_log.h create mode 100644 test/host/time_sync/stub/net_socket.h create mode 100644 test/host/time_sync/stub/sys_clock.h create mode 100644 test/host/time_sync/stub/sys_events.h create mode 100644 test/host/time_sync/stub/task.h create mode 100644 test/host/time_sync/time_sync_test.c create mode 100644 test/host/vol_lock/run_unit_test.py create mode 100644 test/host/vol_lock/stub/cmsis_os.h create mode 100644 test/host/vol_lock/vol_lock_test.c diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 01a569b2..e1396897 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -260,6 +260,36 @@ repos: files: ^(App/algo/adc_input\.(c|h)|test/host/adc_input/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7f) 卷锁(vol_lock)主机侧单元测试(2026-10-10 加) + # 背景:vol_lock 的**核心不变量是 fail-open**——互斥量创建失败时 + # `vol_lock_wait()` 必须**返回 1 且绝不阻塞**,因为它的调用者 defaultTask 是 + # 看门狗喂狗点,一旦在此阻塞即整机复位。板端该路径**只在内存耗尽时才可能走到**, + # 无法人为复现;本钩子用 MSVC 离线编译真实源文件(#include 纳入)+ CMSIS 互斥桩, + # 把"创建失败 -> 直接放行"、"创建成功 -> 正常加解锁"、"未持锁释放"、 + # 超时参数透传等分支一次性钉死。 + # files 含被测源与其头,以及 harness 本身。 + - id: vol-lock-unit-test + name: 卷锁单测(主机侧) + entry: python test/host/vol_lock/run_unit_test.py + language: system + files: ^(App/fs/vol_lock\.(c|h)|test/host/vol_lock/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + + # 7g) 时间同步(time_sync)主机侧单元测试(2026-10-10 加) + # 背景:time_sync.c 是 TCP 时间同步协议状态机,也是 N-17 号看门狗风险的**全部 + # 逻辑贡献者**——"listen 重建 -> 800ms~1s 心跳间隔"由本模块的分片延时/退避/ + # 结算等待组成,板端只能看到"是否复位"这一结果,看不到各段延时是否如设计。本钩子 + # 用 MSVC 离线编译真实源文件(#include 纳入)+ 可脚本化的网络/事件桩(含 longjmp + # 跳出 for(;;) 任务循环),把分片喂狗节奏、监听退避、报文重组(半包/粘连/EOF/真错/ + # 预算耗尽/tick 回绕)、重听门控逐条钉死。 + # files 含被测源与其头、被测的真实看门狗头,以及 harness 本身。 + - id: time-sync-unit-test + name: 时间同步单测(主机侧) + entry: python test/host/time_sync/run_unit_test.py + language: system + files: ^(App/sync/time_sync\.(c|h)|App/util/task_watchdog\.h|test/host/time_sync/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 8) 测试套件开关门禁(2026-10-01 加) # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 diff --git a/test/host/time_sync/run_unit_test.py b/test/host/time_sync/run_unit_test.py new file mode 100644 index 00000000..d23c58ec --- /dev/null +++ b/test/host/time_sync/run_unit_test.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""时间同步(time_sync)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/time_sync/time_sync_test.c(它以 #include 纳入 + **生产源文件** App/sync/time_sync.c),在 PC 上离线跑: + 分片延时(心跳节奏)/ 监听建立与退避 / 时间包重组状态机(分段 + 30s 预算 + + tick 回绕)/ 任务主循环(就绪屏障、accept 轮询、代次变化重监听)。 + 失败即非 0 退出。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端套件): + 本模块是登记簿 N-17 的当事方——"监听重建期间心跳缺口 800ms~1s,距 IWDG 最坏 + 超时 1701ms 余量仅 ~0.7s"。缺口成因全是本模块的**纯逻辑**(分片延时的喂狗 + 节奏、500/2000ms 监听退避、200ms accept 退避、50ms 收尾、屏障等待态喂心跳)。 + 真机上只能"跑起来看复位不复位",主机侧可**逐片断言**。时间包重组状态机 + (分段送达 / 预算耗尽 / EOF / 真错误 / tick 回绕)同理。 + +用法(仓库根目录): + python test/host/time_sync/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/time_sync/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:App/sync/time_sync.c 文件头注释 + docs/待处理内容.md N-17; +创建日期:2026-10-10 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 顺序敏感:stub 目录必须**先于**生产目录,才能覆盖 FreeRTOS.h / task.h / +# net_socket.h / sys_events.h / sys_clock.h / dbg_log.h(真实头会拖入整套 +# FreeRTOS / CMSIS-RTOS / HAL)。task_watchdog.h 用**真实头**(App/util,只依赖 +# stdint.h),time_sync.h 用真实头(接口契约原汁)。 +BUILD = TestBuild( + name="Time Sync Tests", + src=os.path.join(HERE, "time_sync_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "util"), + os.path.join(ROOT, "App", "sync"), + HERE, + ], + summary_marker="=== Time Sync Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/time_sync/stub/FreeRTOS.h b/test/host/time_sync/stub/FreeRTOS.h new file mode 100644 index 00000000..0bfaf684 --- /dev/null +++ b/test/host/time_sync/stub/FreeRTOS.h @@ -0,0 +1,34 @@ +/* + * 主机侧单测桩:FreeRTOS.h + * 模块功能:为 App/sync/time_sync.c 提供主机可编译的最小 FreeRTOS 时基类型与宏。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * 真实头在 Middlewares/Third_Party/FreeRTOS/Source/include/,会拖入 portmacro / + * projdefs / portable 层,主机侧不引入。 + * + * 保真点:TickType_t = uint32_t;configTICK_RATE_HZ = 1000(Inc/FreeRTOSConfig.h) + * → pdMS_TO_TICKS(ms) == ms。被断言"片长 = 50ms"的用例依赖此等式。 + * + * 创建日期:2026-10-10 + */ + +#ifndef FREERTOS_H +#define FREERTOS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint32_t TickType_t; + +/* 与 Inc/FreeRTOSConfig.h 一致:1000 Hz → 1 tick = 1 ms */ +#define configTICK_RATE_HZ 1000U +#define pdMS_TO_TICKS(ms) ((TickType_t)((uint32_t)(ms) * (1000U / configTICK_RATE_HZ))) + +#ifdef __cplusplus +} +#endif + +#endif /* FREERTOS_H */ diff --git a/test/host/time_sync/stub/cmsis_os2.h b/test/host/time_sync/stub/cmsis_os2.h new file mode 100644 index 00000000..c49d0f59 --- /dev/null +++ b/test/host/time_sync/stub/cmsis_os2.h @@ -0,0 +1,59 @@ +/* + * 主机侧单测桩:cmsis_os2.h + * 模块功能:为 App/sync/time_sync.c(经 sys_events.h)提供主机可编译的 CMSIS-RTOS v2 + * 事件标志接口与类型。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * 真实头在 Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2/,会拖入整套 + * FreeRTOS + CMSIS 内核对象,主机侧不引入。 + * + * 保真点(对齐 cmsis_os2.h): + * - osEventFlagsId_t = void*(:195)、osMutexId_t = void*(:198); + * - osStatus_t 枚举含 osOK = 0; + * - 标志位 osFlagsWaitAny=0 / osFlagsNoClear=0x2 / osFlagsError=0x80000000 + * / osFlagsErrorTimeout=0xFFFFFFFE(:153~164); + * - uint32_t osEventFlagsWait(id, flags, options, timeout)(:539)。 + * osEventFlagsWait 由测试单元给强符号桩(可注入"未就绪/超时"并驱动屏障循环)。 + * + * 创建日期:2026-10-10 + */ + +#ifndef CMSIS_OS2_H +#define CMSIS_OS2_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 不透明句柄 */ +typedef void *osEventFlagsId_t; +typedef void *osMutexId_t; + +/* 状态码(只保留被测代码用到的子集) */ +typedef enum { + osOK = 0, + osError = -1, + osErrorTimeout = -2, + osErrorResource = -3, + osErrorParameter = -4 +} osStatus_t; + +/* 事件标志选项(cmsis_os2.h:153~164) */ +#define osFlagsWaitAny 0x00000000U +#define osFlagsWaitAll 0x00000001U +#define osFlagsNoClear 0x00000002U +#define osFlagsError 0x80000000U +#define osFlagsErrorUnknown 0xFFFFFFFFU +#define osFlagsErrorTimeout 0xFFFFFFFEU +#define osFlagsErrorResource 0xFFFFFFFDU +#define osFlagsErrorParameter 0xFFFFFFFCU + +uint32_t osEventFlagsWait(osEventFlagsId_t ef_id, uint32_t flags, uint32_t options, uint32_t timeout); + +#ifdef __cplusplus +} +#endif + +#endif /* CMSIS_OS2_H */ diff --git a/test/host/time_sync/stub/dbg_log.h b/test/host/time_sync/stub/dbg_log.h new file mode 100644 index 00000000..e24d84bd --- /dev/null +++ b/test/host/time_sync/stub/dbg_log.h @@ -0,0 +1,31 @@ +/* + * 主机侧单测桩:dbg_log.h + * 模块功能:为 App/sync/time_sync.c 提供主机可编译的日志宏(映射到计数)。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * + * 注意:time_sync.c 在 `#include "dbg_log.h"` 之前自行 `#define` 了 + * DBG_LINE_BUF_MAX 与 DBG_TAG(真实头会用到它们),故本桩**不得**再定义这两个宏, + * 否则触发 C4005 宏重定义告警(/W4)。 + * + * DBG_ERROR / DBG_INFO 计入计数器,供单测断言: + * - "net_socket/bind/listen FAIL"、"open listener FAIL, retry later" → ERROR + * - "no TIME packet in 30s, drop conn"、"wall clock updated"、"network re-init" → INFO + * + * 创建日期:2026-10-10 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +#include + +/* 计数(在 time_sync_test.c 实现) */ +extern int g_tstest_err_count; +extern int g_tstest_info_count; + +#define DBG_INFO(fmt, ...) do { g_tstest_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { } while (0) +#define DBG_ERROR(fmt, ...) do { g_tstest_err_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/time_sync/stub/net_socket.h b/test/host/time_sync/stub/net_socket.h new file mode 100644 index 00000000..92b1df16 --- /dev/null +++ b/test/host/time_sync/stub/net_socket.h @@ -0,0 +1,71 @@ +/* + * 主机侧单测桩:net_socket.h(含内联 net_types.h 子集) + * 模块功能:为 App/sync/time_sync.c 提供主机可编译的网络 Socket API 声明与类型。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * 真实头(Drivers/BSP/NET/net_socket.h)包含 net_types.h → net_config.h,并经 + * FreeRTOS.h / task.h 拖入整套 FreeRTOS;本桩改为包含同名桩头。 + * + * 保真点: + * - 地址族/类型/特殊地址常量与 Drivers/BSP/NET/net_types.h 逐字一致; + * - struct net_sockaddr / net_in_addr / net_sockaddr_in 字段与真实头一致 + * (被测代码写 sin_family / sin_port / sin_addr.s_addr,桩按同布局读取); + * - 7 个被测代码调用的接口签名与真实头一致(net_socket / net_bind / net_listen / + * net_accept / net_close / net_recv_to / net_get_generation / net_htons)。 + * 全部由测试单元给强符号桩(脚本化返回值 + 调用记录)。 + * + * 创建日期:2026-10-10 + */ + +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include + +#include "FreeRTOS.h" +#include "task.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ===== 以下取自 Drivers/BSP/NET/net_types.h(逐字) ===== */ + +#define NET_AF_INET 2 /* IPv4 */ +#define NET_SOCK_STREAM 1 /* TCP */ +#define NET_SOCK_DGRAM 2 /* UDP */ +#define NET_INADDR_ANY 0 /* 任意地址 */ +#define NET_INADDR_NONE 0xFFFFFFFF /* 无效地址 */ + +struct net_sockaddr { + uint16_t sa_family; /* 地址族 */ + char sa_data[14]; /* 地址数据 */ +}; + +struct net_in_addr { + uint32_t s_addr; /* IP 地址(网络字节序) */ +}; + +struct net_sockaddr_in { + uint16_t sin_family; /* 地址族,必须为 AF_INET */ + uint16_t sin_port; /* 端口号(网络字节序) */ + struct net_in_addr sin_addr; /* IP 地址(网络字节序) */ + char sin_zero[8]; /* 填充字节 */ +}; + +/* ===== 被测代码用到的 Socket API(签名与真实头一致) ===== */ + +int net_socket(int domain, int type); +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen); +int net_listen(int sockfd); +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen); +int net_close(int sockfd); +int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms); +uint32_t net_get_generation(void); +uint16_t net_htons(uint16_t hostshort); + +#ifdef __cplusplus +} +#endif + +#endif /* NET_SOCKET_H */ diff --git a/test/host/time_sync/stub/sys_clock.h b/test/host/time_sync/stub/sys_clock.h new file mode 100644 index 00000000..81c19706 --- /dev/null +++ b/test/host/time_sync/stub/sys_clock.h @@ -0,0 +1,29 @@ +/* + * 主机侧单测桩:sys_clock.h + * 模块功能:为 App/sync/time_sync.c 提供主机可编译的墙钟设置接口声明。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * 真实头(App/util/sys_clock.h)含日历/闰年等无关声明;本桩只保留被测代码 + * 真正调用的那一个(签名与真实头逐字一致:int sys_clock_set_unix(uint32_t))。 + * + * 由测试单元给强符号桩,记录"是否被调用"与"最后一次的 epoch"。 + * + * 创建日期:2026-10-10 + */ + +#ifndef SYS_CLOCK_H +#define SYS_CLOCK_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int sys_clock_set_unix(uint32_t unix_secs); + +#ifdef __cplusplus +} +#endif + +#endif /* SYS_CLOCK_H */ diff --git a/test/host/time_sync/stub/sys_events.h b/test/host/time_sync/stub/sys_events.h new file mode 100644 index 00000000..3840d899 --- /dev/null +++ b/test/host/time_sync/stub/sys_events.h @@ -0,0 +1,39 @@ +/* + * 主机侧单测桩:sys_events.h + * 模块功能:为 App/sync/time_sync.c 提供主机可编译的系统就绪事件组句柄与事件位。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * 真实头(App/app/sys_events.h)包含 cmsis_os2.h;本桩改为包含同名桩, + * 避免拖入整套 CMSIS-RTOS/FreeRTOS。 + * + * 事件位取值与真实头逐位一致(本模块只用到 SYS_EVT_NET_READY)。 + * + * 创建日期:2026-10-10 + */ + +#ifndef SYS_EVENTS_H +#define SYS_EVENTS_H + +#include "cmsis_os2.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* 系统就绪事件组句柄(真实工程由 MX_FREERTOS_Init 创建;主机侧由测试单元定义) */ +extern osEventFlagsId_t sys_evtHandle; + +/* 各子系统"就绪"事件位(与 App/app/sys_events.h 一致) */ +#define SYS_EVT_NET_READY (1u << 0) +#define SYS_EVT_STORAGE_READY (1u << 1) +#define SYS_EVT_FTP_READY (1u << 2) +#define SYS_EVT_TIMESYNC_READY (1u << 3) +#define SYS_EVT_NET_RECONFIG (1u << 4) +#define SYS_EVT_CFG_SAVE (1u << 5) +#define SYS_EVT_CONSOLE_TX (1u << 6) + +#ifdef __cplusplus +} +#endif + +#endif /* SYS_EVENTS_H */ diff --git a/test/host/time_sync/stub/task.h b/test/host/time_sync/stub/task.h new file mode 100644 index 00000000..e6b6cbc7 --- /dev/null +++ b/test/host/time_sync/stub/task.h @@ -0,0 +1,29 @@ +/* + * 主机侧单测桩:task.h + * 模块功能:为 App/sync/time_sync.c 提供主机可编译的 FreeRTOS 任务时基接口声明。 + * + * ⚠ 仅供 test/host/time_sync/ 使用,不参与 MCU 构建。 + * + * 两个接口由测试单元给强符号桩:vTaskDelay 记录每片睡眠时长(断言"≤50ms 分片"), + * xTaskGetTickCount 由测试单元提供可控时钟(驱动 30s 预算耗尽与 32 位回绕用例)。 + * + * 创建日期:2026-10-10 + */ + +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +void vTaskDelay(TickType_t xTicksToDelay); +TickType_t xTaskGetTickCount(void); + +#ifdef __cplusplus +} +#endif + +#endif /* TASK_H */ diff --git a/test/host/time_sync/time_sync_test.c b/test/host/time_sync/time_sync_test.c new file mode 100644 index 00000000..734c4c72 --- /dev/null +++ b/test/host/time_sync/time_sync_test.c @@ -0,0 +1,717 @@ +/* + * 模块名称:Time Sync(PC 本地时间推送接收)主机侧单元测试 + * 模块功能:在 PC 上对 App/sync/time_sync.c 做离线验证(4 个函数全覆盖: + * time_sync_delay_fed / time_sync_open_listener / time_sync_handle_conn / + * time_sync_task)。 + * + * 被测对象:真实源文件 ../../../App/sync/time_sync.c(以 #include 原样纳入, + * 保证测的是生产代码而非副本)。平台依赖由本文件提供的**可注入桩**实现: + * - FreeRTOS/task.h -> vTaskDelay / xTaskGetTickCount(可控时钟) + * - net_socket.h -> socket/bind/listen/accept/close/recv_to/ + * get_generation/htons(全部脚本化) + * - sys_events.h -> osEventFlagsWait(可注入"未就绪") + * - sys_clock.h -> sys_clock_set_unix(记录 epoch) + * - task_watchdog.h -> task_wd_beat / task_wd_bind(记录心跳节奏) + * - dbg_log.h -> DBG_ERROR/DBG_INFO 计数 + * + * 为什么要主机侧单测(而不是只用板端套件): + * 1. **本模块是 N-17 的当事方**:登记簿 N-17 记录"time_sync 监听重建期间心跳缺口 + * 800ms~1s,距 IWDG 最坏超时 1701ms 余量仅 ~0.7s"。缺口的成因全部是本模块的 + * **纯逻辑**——分片延时(delay_fed)的心跳节奏、监听重试退避(500/2000ms)、 + * accept 轮询退避(200ms)、收尾延时(50ms)、以及"屏障等待态也要喂心跳"。 + * 这些在真机上只能靠"跑起来看复位不复位"间接观察,主机侧可**逐片断言**。 + * 2. **时间包重组状态机**(handle_conn)是纯逻辑:累积到 8 字节才解析、30s 总预算 + * 随 tick 推进耗尽、TCP 分段送达、EOF/真错误/超时三种返回码分支——板端套件 + * 无法精确构造"分段 + 预算耗尽 + tick 回绕"的组合。 + * 3. 不占 MCU 的 RAM,毫秒级完成。 + * + * 覆盖点(TC-TS-xxx): + * —— time_sync_delay_fed(可喂心跳的分片延时)—— + * TC-TS-001 0ms 不睡眠不喂狗 + * TC-TS-002 恰好一片(50ms) + * TC-TS-003 非整片(120ms)切成 50/50/20,每片喂心跳 + * TC-TS-004 长延时(2000ms)= 40 片 × 50ms,每片喂心跳 + * TC-TS-005 不足一片(49ms)单片处理 + * TC-TS-006 总预算 30s = 600 片(片长上限恒 50ms) + * —— time_sync_open_listener(监听建立与重试)—— + * TC-TS-010 首次成功:socket/bind/listen 各一次,端口经 net_htons,无退避 + * TC-TS-011 bind 失败 -> 关 socket + 退避 2000ms + 留痕,随后重试成功 + * TC-TS-012 listen 失败 -> 关 socket + 退避 2000ms,随后重试成功 + * TC-TS-013 socket 连续失败 -> 5 次后返回 -1(5 条 ERROR,5×500ms 退避) + * TC-TS-014 第 3 次重试成功并返回新描述符 + * TC-TS-015 每次重试前 + 每片延时各喂一次心跳,且心跳位恒为 TIMESYNC + * —— time_sync_handle_conn(时间包重组状态机)—— + * TC-TS-020 8 字节一次收齐 -> 大端解析 epoch + 更新墙钟 + 关连接 + * TC-TS-021 分段送达(4+4)-> 累积后解析;第二次只请求剩余 12 字节 + * TC-TS-022 一次到达 16 字节 -> 只取前 8 字节 + * TC-TS-023 前缀非 "TIME" -> 不更新墙钟,但仍关连接 + * TC-TS-024 对端关闭(n==0)-> 立即退出、不解析 + * TC-TS-025 真错误(n==-1)-> 立即退出、不解析 + * TC-TS-026 只收到 4 字节后 EOF -> 不足 8 字节不解析 + * TC-TS-027 30s 预算耗尽 -> 留痕 "no TIME packet in 30s, drop conn" + * TC-TS-028 剩余预算不足一片 -> 末片按剩余(20ms)切分 + * TC-TS-029 32 位 tick 回绕 -> 预算计算仍正确 + * —— time_sync_task(任务主循环)—— + * TC-TS-040 sys_evtHandle==NULL:跳过就绪屏障;accept 轮询->成功->处理->收尾 + * TC-TS-041 就绪屏障:100ms 分片等待 NET_READY(osFlagsNoClear 不消费就绪位) + * TC-TS-042 屏障未就绪期间持续喂心跳(2026-10-02 台架根因的回归防线) + * TC-TS-043 网络代次变化 -> 关闭旧监听并重监听 + * TC-TS-044 监听建立失败 -> 5 次重试后放弃并退避 2000ms + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-10 + */ + +#include +#include +#include +#include + +/* ===================== 平台桩(先纳入,覆盖真实头) ===================== */ +#include "FreeRTOS.h" +#include "task.h" +#include "net_socket.h" +#include "sys_clock.h" +#include "sys_events.h" +#include "task_watchdog.h" /* 真实头(App/util):task_wd_id_t / TASK_WD_TIMESYNC */ + +/* ===================== FreeRTOS 桩 ===================== */ +static TickType_t s_tick; +#define DELAY_LOG_MAX 800 +static uint32_t s_delays[DELAY_LOG_MAX]; +static int s_delay_calls; + +void vTaskDelay(TickType_t xTicksToDelay) { + if (s_delay_calls < DELAY_LOG_MAX) { + s_delays[s_delay_calls] = (uint32_t)xTicksToDelay; + } + s_delay_calls++; +} + +TickType_t xTaskGetTickCount(void) { + return s_tick; +} + +/* ===================== CMSIS-RTOS 事件标志桩 ===================== */ +osEventFlagsId_t sys_evtHandle = NULL; + +static uint32_t s_evt_seq[8]; +static int s_evt_seq_n; +static uint32_t s_evt_default = 0xFFFFFFFFu; /* 未脚本化时的返回值(默认:就绪) */ +static int s_evt_calls; +static int s_evt_longjmp_at = -1; /* >0 时第 N 次调用触发 longjmp */ +static uint32_t s_evt_last_flags; +static uint32_t s_evt_last_options; +static uint32_t s_evt_last_timeout; + +static jmp_buf s_jmp; + +uint32_t osEventFlagsWait(osEventFlagsId_t ef_id, uint32_t flags, uint32_t options, uint32_t timeout) { + int i = s_evt_calls; + (void)ef_id; + + s_evt_last_flags = flags; + s_evt_last_options = options; + s_evt_last_timeout = timeout; + s_evt_calls++; + + if (s_evt_longjmp_at > 0 && (i + 1) >= s_evt_longjmp_at) { + longjmp(s_jmp, 2); /* 跳出 time_sync_task 的屏障循环(用例收尾) */ + } + if (i < s_evt_seq_n) { + return s_evt_seq[i]; + } + return s_evt_default; +} + +/* ===================== 看门狗桩 ===================== */ +static int s_beat_calls; +static int s_beat_id_bad; +/* ⚠ 命名必须与 net_socket 桩的 net_bind 计数区分开:C 的"暂定定义"规则下, + * 同一编译单元里两个 `static int x;` 会被**静默合并为同一个对象**(不报错), + * 于是 task_wd_bind 与 net_bind 会共用计数器,断言全部失真(2026-10-10 踩过)。 */ +static int s_wd_bind_calls; +static int s_wd_bind_id_bad; + +void task_wd_beat(task_wd_id_t id) { + s_beat_calls++; + if (id != TASK_WD_TIMESYNC) { + s_beat_id_bad++; + } +} + +void task_wd_bind(task_wd_id_t id) { + s_wd_bind_calls++; + if (id != TASK_WD_TIMESYNC) { + s_wd_bind_id_bad++; + } +} + +/* ===================== sys_clock 桩 ===================== */ +static int s_set_unix_calls; +static uint32_t s_set_unix_last; + +int sys_clock_set_unix(uint32_t unix_secs) { + s_set_unix_calls++; + s_set_unix_last = unix_secs; + return 0; +} + +/* ===================== net_socket 桩 ===================== */ +static uint16_t s_htons_last; + +static int s_socket_calls; +static int s_socket_seq[8]; +static int s_socket_seq_n; +static int s_socket_default = 5; + +static int s_bind_calls; +static int s_bind_seq[8]; +static int s_bind_seq_n; +static uint16_t s_bind_port; +static uint16_t s_bind_family; + +static int s_listen_calls; +static int s_listen_seq[8]; +static int s_listen_seq_n; + +static int s_accept_calls; +static int s_accept_seq[16]; +static int s_accept_seq_n; + +static int s_close_calls; +static int s_close_socks[32]; + +static uint32_t s_gen_seq[8]; +static int s_gen_seq_n; +static uint32_t s_gen_default = 1u; +static int s_gen_calls; +static int s_gen_longjmp_at = -1; /* >0 时第 N 次调用触发 longjmp */ + +uint16_t net_htons(uint16_t hostshort) { + s_htons_last = hostshort; + return (uint16_t)(((hostshort & 0xFFu) << 8) | (hostshort >> 8)); +} + +int net_socket(int domain, int type) { + int i = s_socket_calls++; + (void)domain; + (void)type; + return (i < s_socket_seq_n) ? s_socket_seq[i] : s_socket_default; +} + +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen) { + const struct net_sockaddr_in *a = (const struct net_sockaddr_in *)addr; + int i = s_bind_calls++; + + (void)sockfd; + (void)addrlen; + s_bind_family = a->sin_family; + s_bind_port = a->sin_port; + return (i < s_bind_seq_n) ? s_bind_seq[i] : 0; +} + +int net_listen(int sockfd) { + int i = s_listen_calls++; + (void)sockfd; + return (i < s_listen_seq_n) ? s_listen_seq[i] : 0; +} + +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) { + int i = s_accept_calls++; + (void)sockfd; + (void)addr; + (void)addrlen; + return (i < s_accept_seq_n) ? s_accept_seq[i] : -1; +} + +int net_close(int sockfd) { + if (s_close_calls < 32) { + s_close_socks[s_close_calls] = sockfd; + } + s_close_calls++; + return 0; +} + +uint32_t net_get_generation(void) { + int i = s_gen_calls++; + + if (s_gen_longjmp_at > 0 && (i + 1) >= s_gen_longjmp_at) { + longjmp(s_jmp, 1); /* 跳出 time_sync_task 的主循环(用例收尾) */ + } + return (i < s_gen_seq_n) ? s_gen_seq[i] : s_gen_default; +} + +/* ---- net_recv_to:脚本化返回码 + 可推进 tick ---- */ +typedef struct { + int n; /* 返回码:>0 字节数 / 0 EOF / -1 错误 / -2 超时无数据 */ + uint8_t data[16]; /* 要"送达"的字节 */ + int len; /* data 有效长度 */ + uint32_t tick_adv; /* 返回前把假时钟推进多少 ms */ +} recv_step_t; + +static recv_step_t s_recv_seq[16]; +static int s_recv_seq_n; +static int s_recv_idx; +static int s_recv_calls; +static int s_recv_default = 0; /* 脚本用尽后的返回码(0=EOF,保证循环终止) */ +static uint32_t s_recv_to_log[64]; +static int s_recv_len_log[64]; + +int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms) { + int i = s_recv_calls; + + (void)sockfd; + if (i < 64) { + s_recv_to_log[i] = timeout_ms; + s_recv_len_log[i] = len; + } + s_recv_calls++; + + if (s_recv_idx >= s_recv_seq_n) { + return s_recv_default; + } + + { + const recv_step_t *st = &s_recv_seq[s_recv_idx]; + int copy; + s_recv_idx++; + s_tick += st->tick_adv; + if (st->n <= 0) { + return st->n; + } + copy = (st->n <= len) ? st->n : len; + if (copy > st->len) { + copy = st->len; + } + if ((copy > 0) && (buf != NULL)) { + memcpy(buf, st->data, (size_t)copy); + } + return st->n; + } +} + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; +int g_tstest_err_count = 0; +int g_tstest_info_count = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码(原样纳入生产源) ===================== */ +#include "time_sync.c" + +/* ===================== 工具 ===================== */ + +/* + * 函数功能:把全部桩复位到"健康"初值 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:默认 = 网络全成功、gen 恒为 1、recv 脚本为空(脚本用尽返回 EOF)、 + * 事件标志恒就绪。各用例按需覆盖。 + */ +static void fakes_reset(void) { + s_tick = 0; + s_delay_calls = 0; + + sys_evtHandle = NULL; + s_evt_seq_n = 0; + s_evt_default = SYS_EVT_NET_READY; /* 健康默认:就绪 */ + s_evt_calls = 0; + s_evt_longjmp_at = -1; + s_evt_last_flags = 0; + s_evt_last_options = 0; + s_evt_last_timeout = 0; + + s_beat_calls = 0; + s_beat_id_bad = 0; + s_wd_bind_calls = 0; + s_wd_bind_id_bad = 0; + + s_set_unix_calls = 0; + s_set_unix_last = 0; + + s_htons_last = 0; + s_socket_calls = 0; + s_socket_seq_n = 0; + s_socket_default = 5; + s_bind_calls = 0; + s_bind_seq_n = 0; + s_bind_port = 0; + s_bind_family = 0; + s_listen_calls = 0; + s_listen_seq_n = 0; + s_accept_calls = 0; + s_accept_seq_n = 0; + s_close_calls = 0; + s_gen_seq_n = 0; + s_gen_default = 1u; + s_gen_calls = 0; + s_gen_longjmp_at = -1; + + s_recv_seq_n = 0; + s_recv_idx = 0; + s_recv_calls = 0; + s_recv_default = 0; + + g_tstest_err_count = 0; + g_tstest_info_count = 0; +} + +/* 装配一个 recv 脚本步 */ +static void recv_set(int idx, int n, const uint8_t *data, int len, uint32_t tick_adv) { + memset(&s_recv_seq[idx], 0, sizeof(s_recv_seq[idx])); + s_recv_seq[idx].n = n; + s_recv_seq[idx].len = len; + s_recv_seq[idx].tick_adv = tick_adv; + if ((data != NULL) && (len > 0)) { + int copy = (len > 16) ? 16 : len; + memcpy(s_recv_seq[idx].data, data, (size_t)copy); + } +} + +/* 装配一个 "TIME" + uint32 大端 时间包 */ +static void make_time_packet(uint8_t *out, uint32_t epoch) { + out[0] = (uint8_t)'T'; + out[1] = (uint8_t)'I'; + out[2] = (uint8_t)'M'; + out[3] = (uint8_t)'E'; + out[4] = (uint8_t)(epoch >> 24); + out[5] = (uint8_t)(epoch >> 16); + out[6] = (uint8_t)(epoch >> 8); + out[7] = (uint8_t)(epoch); +} + +/* 检查 s_delays[0..n) 是否全部等于 v */ +static int delays_all(uint32_t v, int n) { + int k; + for (k = 0; k < n; k++) { + if (s_delays[k] != v) { + return 0; + } + } + return 1; +} + +/* ===================== TC-TS-001..006:delay_fed ===================== */ + +static void tc_delay_fed(void) { + fakes_reset(); + time_sync_delay_fed(0U); + CHECK((s_beat_calls == 0) && (s_delay_calls == 0), "TC-TS-001 delay_fed(0) 不睡眠不喂狗"); + + fakes_reset(); + time_sync_delay_fed(50U); + CHECK((s_beat_calls == 1) && (s_delay_calls == 1) && (s_delays[0] == 50U), + "TC-TS-002 delay_fed(50) = 1 片 50ms + 1 次心跳"); + + fakes_reset(); + time_sync_delay_fed(120U); + CHECK((s_beat_calls == 3) && (s_delay_calls == 3), "TC-TS-003 delay_fed(120) 切成 3 片"); + CHECK((s_delays[0] == 50U) && (s_delays[1] == 50U) && (s_delays[2] == 20U), + "TC-TS-003b 片长 50/50/20(末片为余数)"); + + fakes_reset(); + time_sync_delay_fed(2000U); + CHECK((s_delay_calls == 40) && (s_beat_calls == 40) && delays_all(50U, 40), + "TC-TS-004 delay_fed(2000) = 40 片 × 50ms,每片喂一次心跳"); + + fakes_reset(); + time_sync_delay_fed(49U); + CHECK((s_beat_calls == 1) && (s_delays[0] == 49U), "TC-TS-005 不足一片(49ms)单片处理"); + + fakes_reset(); + time_sync_delay_fed(30000U); + CHECK(s_delay_calls == 600, "TC-TS-006 总预算 30s = 600 片(片长上限恒 50ms)"); +} + +/* ===================== TC-TS-010..015:open_listener ===================== */ + +static void tc_open_listener(void) { + fakes_reset(); + { + int s = time_sync_open_listener(); + CHECK(s == 5, "TC-TS-010 open_listener 成功返回 socket 描述符"); + CHECK((s_socket_calls == 1) && (s_bind_calls == 1) && (s_listen_calls == 1), + "TC-TS-010b socket / bind / listen 各一次"); + CHECK(s_htons_last == (uint16_t)TIME_SYNC_PORT, "TC-TS-010c 端口经 net_htons(8888)"); + CHECK(s_bind_port == net_htons((uint16_t)TIME_SYNC_PORT), "TC-TS-010d sin_port 为网络序"); + CHECK(s_bind_family == (uint16_t)NET_AF_INET, "TC-TS-010e sin_family = NET_AF_INET"); + CHECK(s_delay_calls == 0, "TC-TS-010f 成功路径无退避延时"); + } + + fakes_reset(); + s_bind_seq[0] = -1; + s_bind_seq_n = 1; + { + int s = time_sync_open_listener(); + CHECK(s == 5, "TC-TS-011 bind 失败一次后重试成功"); + CHECK((s_close_calls == 1) && (s_close_socks[0] == 5), "TC-TS-011b bind 失败关闭该 socket"); + CHECK(s_delay_calls == 40, "TC-TS-011c bind 失败退避 2000ms(40 片)"); + CHECK(g_tstest_err_count == 1, "TC-TS-011d bind 失败留痕 DBG_ERROR"); + } + + fakes_reset(); + s_listen_seq[0] = -1; + s_listen_seq_n = 1; + { + int s = time_sync_open_listener(); + CHECK(s == 5, "TC-TS-012 listen 失败一次后重试成功"); + CHECK((s_close_calls == 1) && (s_close_socks[0] == 5), "TC-TS-012b listen 失败关闭该 socket"); + CHECK(s_delay_calls == 40, "TC-TS-012c listen 失败退避 2000ms"); + } + + fakes_reset(); + s_socket_default = -1; + { + int s = time_sync_open_listener(); + CHECK(s == -1, "TC-TS-013 socket 连续失败 -> 返回 -1"); + CHECK(s_socket_calls == 5, "TC-TS-013b 重试上限 5 次"); + CHECK(g_tstest_err_count == 5, "TC-TS-013c 每次失败各留痕一条(5 条 DBG_ERROR)"); + CHECK(s_delay_calls == 50, "TC-TS-013d 每次失败退避 500ms(5 × 10 片)"); + } + + fakes_reset(); + s_socket_seq[0] = -1; + s_socket_seq[1] = -1; + s_socket_seq[2] = 9; + s_socket_seq_n = 3; + { + int s = time_sync_open_listener(); + CHECK(s == 9, "TC-TS-014 第 3 次重试成功并返回新描述符"); + CHECK(s_socket_calls == 3, "TC-TS-014b 恰好尝试 3 次"); + CHECK(s_delay_calls == 20, "TC-TS-014c 两次失败退避 500ms × 2"); + } + + fakes_reset(); + s_socket_default = -1; + (void)time_sync_open_listener(); + CHECK(s_beat_calls == 55, "TC-TS-015 每轮重试(5) + 每片延时(50) 各喂一次心跳"); + CHECK(s_beat_id_bad == 0, "TC-TS-015b 心跳位恒为 TASK_WD_TIMESYNC"); +} + +/* ===================== TC-TS-020..029:handle_conn ===================== */ + +static void tc_handle_conn(void) { + uint8_t pkt[16]; + + /* 1) 8 字节一次收齐 */ + fakes_reset(); + make_time_packet(pkt, 0x12345678u); + recv_set(0, 8, pkt, 8, 0U); + s_recv_seq_n = 1; + s_tick = 100U; + time_sync_handle_conn(7); + CHECK(s_set_unix_calls == 1, "TC-TS-020 TIME 包一次收齐 -> 更新墙钟一次"); + CHECK(s_set_unix_last == 0x12345678u, "TC-TS-020b epoch 按大端解析"); + CHECK((s_recv_calls == 1) && (s_recv_len_log[0] == 16), "TC-TS-020c 首次请求 16 字节"); + CHECK(s_recv_to_log[0] == 50U, "TC-TS-020d 单次等待片长 = 50ms"); + CHECK((s_close_calls == 1) && (s_close_socks[0] == 7), "TC-TS-020e 处理完关闭连接"); + CHECK(g_tstest_info_count == 1, "TC-TS-020f 留痕 wall clock updated"); + + /* 2) 分段送达:4 + 4 */ + fakes_reset(); + make_time_packet(pkt, 10u); + recv_set(0, 4, pkt, 4, 10U); + recv_set(1, 4, pkt + 4, 4, 10U); + s_recv_seq_n = 2; + time_sync_handle_conn(3); + CHECK((s_set_unix_calls == 1) && (s_set_unix_last == 10u), "TC-TS-021 分段送达累积后解析正确"); + CHECK(s_recv_calls == 2, "TC-TS-021b 读了两次"); + CHECK((s_recv_len_log[0] == 16) && (s_recv_len_log[1] == 12), + "TC-TS-021c 第二次只请求剩余 12 字节"); + CHECK((s_recv_to_log[0] == 50U) && (s_recv_to_log[1] == 50U), "TC-TS-021d 每片 50ms(预算充裕)"); + + /* 3) 16 字节一次到达(只取前 8) */ + fakes_reset(); + make_time_packet(pkt, 5u); + recv_set(0, 16, pkt, 16, 0U); + s_recv_seq_n = 1; + time_sync_handle_conn(4); + CHECK((s_set_unix_calls == 1) && (s_set_unix_last == 5u), "TC-TS-022 超长包只取前 8 字节"); + CHECK(s_recv_calls == 1, "TC-TS-022b 一次读够即停"); + + /* 4) 前缀不是 TIME */ + fakes_reset(); + pkt[0] = (uint8_t)'X'; + pkt[1] = (uint8_t)'X'; + pkt[2] = (uint8_t)'X'; + pkt[3] = (uint8_t)'X'; + pkt[4] = 0U; + pkt[5] = 0U; + pkt[6] = 0U; + pkt[7] = 1U; + recv_set(0, 8, pkt, 8, 0U); + s_recv_seq_n = 1; + time_sync_handle_conn(4); + CHECK(s_set_unix_calls == 0, "TC-TS-023 非 TIME 前缀不更新墙钟"); + CHECK(s_close_calls == 1, "TC-TS-023b 仍关闭连接"); + + /* 5) 对端关闭(EOF) */ + fakes_reset(); + recv_set(0, 0, NULL, 0, 0U); + s_recv_seq_n = 1; + time_sync_handle_conn(6); + CHECK((s_set_unix_calls == 0) && (s_recv_calls == 1), "TC-TS-024 对端关闭(EOF)立即退出不解析"); + CHECK(s_close_calls == 1, "TC-TS-024b 关闭连接"); + + /* 6) 真错误 */ + fakes_reset(); + recv_set(0, -1, NULL, 0, 0U); + s_recv_seq_n = 1; + time_sync_handle_conn(6); + CHECK((s_recv_calls == 1) && (s_set_unix_calls == 0), "TC-TS-025 真错误立即退出不解析"); + + /* 7) 4 字节后 EOF */ + fakes_reset(); + make_time_packet(pkt, 7u); + recv_set(0, 4, pkt, 4, 0U); + recv_set(1, 0, NULL, 0, 0U); + s_recv_seq_n = 2; + time_sync_handle_conn(6); + CHECK(s_set_unix_calls == 0, "TC-TS-026 不足 8 字节不解析"); + CHECK(s_recv_calls == 2, "TC-TS-026b 继续等待后续字节"); + + /* 8) 30s 预算耗尽 */ + fakes_reset(); + recv_set(0, -2, NULL, 0, 30000U); + s_recv_seq_n = 1; + time_sync_handle_conn(6); + CHECK(s_recv_calls == 1, "TC-TS-027 预算耗尽后不再读(只读 1 次)"); + CHECK(g_tstest_info_count == 1, "TC-TS-027b 留痕 no TIME packet in 30s, drop conn"); + CHECK(s_close_calls == 1, "TC-TS-027c 仍关闭连接"); + + /* 9) 剩余预算不足一片 */ + fakes_reset(); + recv_set(0, -2, NULL, 0, 29980U); + s_recv_seq_n = 1; + time_sync_handle_conn(6); + CHECK(s_recv_calls == 2, "TC-TS-028 两次等待后结束"); + CHECK((s_recv_to_log[0] == 50U) && (s_recv_to_log[1] == 20U), + "TC-TS-028b 第二次片长 = 剩余 20ms"); + + /* 10) 32 位 tick 回绕 */ + fakes_reset(); + s_tick = 0xFFFFFF00U; + recv_set(0, -2, NULL, 0, 512U); /* 推进后回绕到 0x00000100 */ + s_recv_seq_n = 1; + time_sync_handle_conn(6); + CHECK((s_recv_calls == 2) && (s_recv_to_log[0] == 50U) && (s_recv_to_log[1] == 50U), + "TC-TS-029 32 位 tick 回绕后预算计算仍正确"); +} + +/* ===================== TC-TS-040..044:time_sync_task ===================== */ + +static void tc_task(void) { + uint8_t pkt[16]; + + /* A) 无就绪屏障(sys_evtHandle == NULL):accept 轮询 2 次失败后成功 */ + fakes_reset(); + make_time_packet(pkt, 9u); + recv_set(0, 8, pkt, 8, 0U); + s_recv_seq_n = 1; + s_accept_seq[0] = -1; + s_accept_seq[1] = -1; + s_accept_seq[2] = 7; + s_accept_seq_n = 3; + /* gen 调用序:iter1 顶部(1) + accept 两次失败各一次(2,3) + iter2 顶部(4) → 第 4 次跳出 */ + s_gen_longjmp_at = 4; + if (setjmp(s_jmp) == 0) { + time_sync_task(NULL); + } + CHECK((s_wd_bind_calls == 1) && (s_wd_bind_id_bad == 0), + "TC-TS-040 任务入口登记心跳位(task_wd_bind 一次)"); + CHECK(s_evt_calls == 0, "TC-TS-040b sys_evtHandle==NULL 时跳过就绪屏障"); + CHECK(s_accept_calls == 3, "TC-TS-040c accept 轮询 3 次(2 次无连接 + 1 次成功)"); + CHECK((s_set_unix_calls == 1) && (s_set_unix_last == 9u), "TC-TS-040d 收到时间包并更新墙钟"); + CHECK(s_close_calls == 1, "TC-TS-040e 连接被关闭一次"); + CHECK(s_delay_calls == 9, "TC-TS-040f 延时片数 = 4+4+1(200+200+50ms)"); + CHECK(delays_all(50U, 9), "TC-TS-040g 所有延时均按 50ms 分片"); + CHECK(s_beat_calls == 13, "TC-TS-040h 心跳次数 = 主循环1 + 退避8 + 收连接1 + 收尾1 + 次轮1"); + CHECK(s_beat_id_bad == 0, "TC-TS-040i 所有心跳均打 TIMESYNC 位"); + + /* B) 就绪屏障:2 次未就绪 + 1 次就绪 */ + fakes_reset(); + sys_evtHandle = (osEventFlagsId_t)0x55; + s_evt_seq[0] = osFlagsErrorTimeout; + s_evt_seq[1] = osFlagsErrorTimeout; + s_evt_seq[2] = SYS_EVT_NET_READY; + s_evt_seq_n = 3; + s_gen_longjmp_at = 1; + if (setjmp(s_jmp) == 0) { + time_sync_task(NULL); + } + CHECK(s_evt_calls == 3, "TC-TS-041 就绪屏障轮询 3 次(2 次未就绪 + 1 次就绪)"); + CHECK(s_evt_last_timeout == 100U, "TC-TS-041b 屏障等待按 100ms 分片"); + CHECK(s_evt_last_flags == SYS_EVT_NET_READY, "TC-TS-041c 等待 NET_READY 位"); + CHECK(s_evt_last_options == (osFlagsWaitAny | osFlagsNoClear), "TC-TS-041d 就绪位不被消费"); + CHECK(s_beat_calls == 3, "TC-TS-041e 未就绪的两次各喂一次心跳(+主循环 1 次)"); + CHECK(s_wd_bind_calls == 1, "TC-TS-041f 仍先登记心跳路由"); + + /* C) 屏障期间持续喂心跳(就绪位长期不置位) */ + fakes_reset(); + sys_evtHandle = (osEventFlagsId_t)0x55; + s_evt_default = osFlagsErrorTimeout; + s_evt_longjmp_at = 4; + if (setjmp(s_jmp) == 0) { + time_sync_task(NULL); + } + CHECK(s_evt_calls == 4, "TC-TS-042 屏障等待 4 次均未就绪(循环未挂死)"); + CHECK(s_beat_calls == 3, "TC-TS-042b 每次未就绪后各喂一次心跳(第 4 次跳出前)"); + + /* D) 网络代次变化 -> 关闭旧监听并重监听 */ + fakes_reset(); + s_gen_seq[0] = 1u; + s_gen_seq[1] = 2u; + s_gen_seq_n = 2; + s_gen_default = 2u; + s_gen_longjmp_at = 4; + if (setjmp(s_jmp) == 0) { + time_sync_task(NULL); + } + CHECK((s_close_calls == 1) && (s_close_socks[0] == 5), "TC-TS-043 代次变化时关闭旧监听"); + /* INFO 三条:两次 open_listener 成功各一条 "TCP listen on" + 一条 "network re-init, relisten" */ + CHECK(g_tstest_info_count == 3, "TC-TS-043b 留痕 network re-init, relisten(另含 2 条 listen 成功)"); + CHECK(s_accept_calls == 2, "TC-TS-043c 每次循环只轮询一次 accept 即放弃"); + CHECK((s_socket_calls == 2) && (s_bind_calls == 2) && (s_listen_calls == 2), + "TC-TS-043d 第二次循环重建了监听"); + + /* E) 监听建立失败 -> 5 次重试后放弃并退避 2000ms */ + fakes_reset(); + s_socket_default = -1; + s_gen_longjmp_at = 2; + if (setjmp(s_jmp) == 0) { + time_sync_task(NULL); + } + CHECK(s_socket_calls == 5, "TC-TS-044 open_listener 内部重试 5 次后放弃"); + CHECK(g_tstest_err_count == 6, "TC-TS-044b 5 条 net_socket FAIL + 1 条 retry later"); + CHECK(s_delay_calls == 90, "TC-TS-044c 退避 5×500ms + 2000ms = 90 片"); + CHECK(s_beat_calls == 97, "TC-TS-044d 心跳次数 = 2 + 5 + 50 + 40"); + CHECK(s_beat_id_bad == 0, "TC-TS-044e 所有心跳均打 TIMESYNC 位"); +} + +/* ===================== main ===================== */ + +int main(void) { + printf("=== Time Sync Tests ===\n"); + + printf("-- delay_fed (heartbeat-fed slicing) --\n"); + tc_delay_fed(); + printf("-- open_listener (retry / backoff) --\n"); + tc_open_listener(); + printf("-- handle_conn (packet reassembly / budget) --\n"); + tc_handle_conn(); + printf("-- time_sync_task (barrier / accept / relisten) --\n"); + tc_task(); + + printf("\n=== Time Sync Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/vol_lock/run_unit_test.py b/test/host/vol_lock/run_unit_test.py new file mode 100644 index 00000000..17db483c --- /dev/null +++ b/test/host/vol_lock/run_unit_test.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""卷锁(vol_lock)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/vol_lock/vol_lock_test.c(它以 #include 纳入 + **生产源文件** App/fs/vol_lock.c),在 PC 上离线跑创建/幂等/等待/超时/ + **fail-open**/释放,失败即非 0 退出。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端套件): + 本模块最要紧的语义是 **fail-open**——锁创建失败时 vol_lock_wait 必须返回 1 + 且**绝不阻塞**(defaultTask 是看门狗喂狗点,等锁 = 停喂 = IWDG 误复位)。 + 真机上 osMutexNew 失败几乎不可复现(需堆耗尽),板端套件无法构造;主机侧 + 一次钉死。幂等性 / 超时透传 / 未创建时 release 无害 同属纯状态逻辑。 + +用法(仓库根目录): + python test/host/vol_lock/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/vol_lock/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:App/fs/vol_lock.h 文件头「使用约定(铁律)」; +创建日期:2026-10-10 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 顺序敏感:stub 目录必须**先于**生产目录,才能覆盖 cmsis_os.h +# (真实头在 Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2/,会拖入整套 +# FreeRTOS)。vol_lock.h 用真实头(接口契约原汁);三个 osMutex* 由测试单元给强符号桩。 +BUILD = TestBuild( + name="Volume Lock Tests", + src=os.path.join(HERE, "vol_lock_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "fs"), + HERE, + ], + summary_marker="=== Volume Lock Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/vol_lock/stub/cmsis_os.h b/test/host/vol_lock/stub/cmsis_os.h new file mode 100644 index 00000000..51f1bc3a --- /dev/null +++ b/test/host/vol_lock/stub/cmsis_os.h @@ -0,0 +1,56 @@ +/* + * 主机侧单测桩:cmsis_os.h(CMSIS-RTOS v1 兼容层) + * 模块功能:为 App/fs/vol_lock.c 提供主机可编译的 CMSIS-RTOS 互斥量接口。 + * + * ⚠ 仅供 test/host/vol_lock/ 使用,不参与 MCU 构建。 + * 真实头来自 Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2/cmsis_os.h, + * 会拖入整套 FreeRTOS(FreeRTOS.h / task.h / queue.h / semphr.h …),主机侧不引入。 + * + * 保真点(对齐 cmsis_os.h + cmsis_os2.h): + * - osMutexId_t = void*(cmsis_os2.h:198); + * - osMutexNew(const osMutexAttr_t *attr)(cmsis_os2.h:552); + * - osMutexWait(osMutexId_t, uint32_t) —— 真实头是 `#define osMutexWait osMutexAcquire`, + * 两者签名一致(cmsis_os.h:617); + * - osMutexRelease(osMutexId_t)(cmsis_os.h:626); + * - osStatus_t 枚举含 osOK = 0(cmsis_os.h:204)。 + * 三个接口由测试单元给强符号桩(可注入"创建失败 / 等待超时")。 + * + * 创建日期:2026-10-10 + */ + +#ifndef CMSIS_OS_H +#define CMSIS_OS_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 不透明句柄(真实定义见 cmsis_os2.h:195/198) */ +typedef void *osMutexId_t; +typedef void *osEventFlagsId_t; + +/* 状态码(cmsis_os.h:204 起的枚举;此处只保留被测代码用到的子集) */ +typedef enum { + osOK = 0, /* 成功 */ + osError = -1, /* 通用错误 */ + osErrorTimeout = -2, /* 超时 */ + osErrorResource = -3, /* 资源不可用 */ + osErrorParameter = -4 /* 参数错误 */ +} osStatus_t; + +/* 互斥量属性:被测代码只传 NULL,故仅占位 */ +typedef struct { + uint32_t attr_bits; +} osMutexAttr_t; + +osMutexId_t osMutexNew(const osMutexAttr_t *attr); +osStatus_t osMutexWait(osMutexId_t mutex_id, uint32_t millisec); +osStatus_t osMutexRelease(osMutexId_t mutex_id); + +#ifdef __cplusplus +} +#endif + +#endif /* CMSIS_OS_H */ diff --git a/test/host/vol_lock/vol_lock_test.c b/test/host/vol_lock/vol_lock_test.c new file mode 100644 index 00000000..5c48416d --- /dev/null +++ b/test/host/vol_lock/vol_lock_test.c @@ -0,0 +1,224 @@ +/* + * 模块名称:Volume Lock(卷锁)主机侧单元测试 + * 模块功能:在 PC 上对 App/fs/vol_lock.c 做离线验证(3 个公开函数全覆盖)。 + * + * 被测对象:真实源文件 ../../../App/fs/vol_lock.c(以 #include 原样纳入, + * 保证测的是生产代码而非副本)。平台依赖由本文件提供的**可注入桩**实现: + * - cmsis_os.h -> osMutexNew / osMutexWait / osMutexRelease + * (可注入 创建失败 / 等待超时 / 获取成功) + * + * 为什么要主机侧单测(而不是只用板端套件): + * 1. 本模块最要紧的语义是 **fail-open**(锁创建失败时 `vol_lock_wait` 必须返回 1 + * 且**绝不阻塞**)——这是"defaultTask 喂狗点绝不能因等锁而停摆"的最后一道保险。 + * 真机上 `osMutexNew` 失败几乎不可能复现(需堆耗尽),板端套件无法构造。 + * 2. 幂等性(重复 `vol_lock_init` 不得重复创建)、等待超时透传、未创建时 release 无害, + * 都是纯状态逻辑,主机侧一次钉死。 + * 3. 不占 MCU 的 RAM,毫秒级完成。 + * + * 覆盖点: + * TC-VL-001 init 首次调用创建互斥量并返回 0 + * TC-VL-002 init 幂等(重复调用不再创建) + * TC-VL-003 init 在 osMutexNew 失败时返回 -1 + * TC-VL-004 init 失败后可重试成功(s_lock 未被污染) + * TC-VL-005 wait 成功后返回 1,且把 timeout 原样透传给 osMutexWait + * TC-VL-006 wait 超时(osMutexWait != osOK)返回 0 + * TC-VL-007 wait 在锁未创建时自动 init,再正常获取 + * TC-VL-008 ⭐ fail-open:锁未创建且创建失败 -> 返回 1 且**不调用** osMutexWait + * TC-VL-009 fail-open 之后 release 无害(s_lock 仍为 NULL,不调用 osMutexRelease) + * TC-VL-010 已创建时 wait 不再调用 osMutexNew + * TC-VL-011 release 在已持锁时调用 osMutexRelease 一次 + * TC-VL-012 release 在锁未创建时静默返回(不崩溃、不调用) + * TC-VL-013 完整周期 init -> wait -> release -> wait -> release + * TC-VL-014 wait(0) 的 timeout 透传为 0(defaultTask 的 try 语义) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-10 + */ + +#include +#include +#include + +/* ===================== 平台桩(先纳入,覆盖真实头) ===================== */ +#include "cmsis_os.h" + +/* ===================== CMSIS-RTOS 互斥量桩 ===================== + * s_new_fail osMutexNew 返回 NULL(注入创建失败) + * s_wait_ok 0 -> osMutexWait 返回 osErrorTimeout(注入超时) + * s_fake_mutex 成功创建时返回的伪句柄(非 NULL 即可) + * s_last_timeout 记录 osMutexWait 收到的 millisec(断言透传) + */ +static int s_new_calls; +static int s_new_fail; +static int s_wait_calls; +static int s_wait_ok; +static int s_release_calls; +static uint32_t s_last_timeout; +static void *s_fake_mutex = (void *)0x1234; + +osMutexId_t osMutexNew(const osMutexAttr_t *attr) { + (void)attr; + s_new_calls++; + return s_new_fail ? NULL : (osMutexId_t)s_fake_mutex; +} + +osStatus_t osMutexWait(osMutexId_t mutex_id, uint32_t millisec) { + (void)mutex_id; + s_wait_calls++; + s_last_timeout = millisec; + return s_wait_ok ? osOK : osErrorTimeout; +} + +osStatus_t osMutexRelease(osMutexId_t mutex_id) { + (void)mutex_id; + s_release_calls++; + return osOK; +} + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码(原样纳入生产源) ===================== */ +#include "vol_lock.c" + +/* ===================== 工具 ===================== */ + +/* + * 函数功能:把全部桩与模块静态态复位到"刚上电"初值 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:`s_lock` 是 vol_lock.c 的文件静态量(源文件已被纳入同一编译单元), + * 直接置 NULL 等价于"未初始化过"——这是白盒单测的可接受代价。 + */ +static void fakes_reset(void) { + s_new_calls = 0; + s_new_fail = 0; + s_wait_calls = 0; + s_wait_ok = 1; + s_release_calls = 0; + s_last_timeout = 0xFFFFFFFFu; + s_fake_mutex = (void *)0x1234; + s_lock = NULL; +} + +/* ===================== TC-VL-001..004:vol_lock_init ===================== */ + +static void tc_init(void) { + fakes_reset(); + CHECK(vol_lock_init() == 0, "TC-VL-001 init 首次调用返回 0"); + CHECK(s_new_calls == 1, "TC-VL-001b init 创建互斥量一次(osMutexNew)"); + CHECK(s_lock != NULL, "TC-VL-001c 模块静态态已持有句柄"); + + CHECK(vol_lock_init() == 0, "TC-VL-002 init 重复调用仍返回 0(幂等)"); + CHECK(s_new_calls == 1, "TC-VL-002b 幂等:不再创建第二个互斥量"); + + fakes_reset(); + s_new_fail = 1; + CHECK(vol_lock_init() == -1, "TC-VL-003 osMutexNew 失败 -> init 返回 -1"); + CHECK(s_lock == NULL, "TC-VL-003b 失败时不留下半初始化句柄"); + + s_new_fail = 0; + CHECK(vol_lock_init() == 0, "TC-VL-004 失败后重试可成功(返回 0)"); + CHECK(s_new_calls == 2, "TC-VL-004b 重试确实再次调用 osMutexNew"); +} + +/* ===================== TC-VL-005..010:vol_lock_wait ===================== */ + +static void tc_wait(void) { + /* --- 已创建:超时值原样透传 --- */ + fakes_reset(); + (void)vol_lock_init(); + CHECK(vol_lock_wait(100U) == 1, "TC-VL-005 wait 获取成功返回 1"); + CHECK(s_wait_calls == 1, "TC-VL-005b osMutexWait 被调用一次"); + CHECK(s_last_timeout == 100U, "TC-VL-005c timeout 原样透传给 osMutexWait"); + + fakes_reset(); + (void)vol_lock_init(); + CHECK(vol_lock_wait(0U) == 1, "TC-VL-014 wait(0) 获取成功返回 1(try 语义)"); + CHECK(s_last_timeout == 0U, "TC-VL-014b wait(0) 的 timeout 透传为 0"); + + /* --- 超时:osMutexWait != osOK -> 返回 0 --- */ + fakes_reset(); + (void)vol_lock_init(); + s_wait_ok = 0; + CHECK(vol_lock_wait(50U) == 0, "TC-VL-006 osMutexWait 非 osOK -> wait 返回 0"); + CHECK(s_wait_calls == 1, "TC-VL-006b 超时路径确实尝试过等待"); + + /* --- 锁未创建:自动 init 后正常获取 --- */ + fakes_reset(); + CHECK(vol_lock_wait(10U) == 1, "TC-VL-007 锁未创建时 wait 自动 init 并获取"); + CHECK(s_new_calls == 1, "TC-VL-007b 自动 init 创建了互斥量"); + CHECK(s_wait_calls == 1, "TC-VL-007c 随后执行了一次等待"); + + /* --- ⭐ fail-open:创建失败必须返回 1 且绝不阻塞 --- */ + fakes_reset(); + s_new_fail = 1; + CHECK(vol_lock_wait(0U) == 1, "TC-VL-008 fail-open:锁创建失败仍返回 1(不阻塞喂狗点)"); + CHECK(s_wait_calls == 0, "TC-VL-008b fail-open 时**不调用** osMutexWait(零阻塞)"); + CHECK(s_lock == NULL, "TC-VL-008c fail-open 后仍无句柄"); + + /* --- fail-open 之后 release 无害 --- */ + vol_lock_release(); + CHECK(s_release_calls == 0, "TC-VL-009 fail-open 后 release 不调用 osMutexRelease(判空静默)"); + + /* --- 已创建时 wait 不再创建 --- */ + fakes_reset(); + (void)vol_lock_init(); + (void)vol_lock_wait(0U); + (void)vol_lock_wait(0U); + CHECK(s_new_calls == 1, "TC-VL-010 多次 wait 不重复创建互斥量"); + CHECK(s_wait_calls == 2, "TC-VL-010b 每次 wait 各等待一次"); +} + +/* ===================== TC-VL-011..013:vol_lock_release ===================== */ + +static void tc_release(void) { + /* --- 已持锁:释放一次 --- */ + fakes_reset(); + (void)vol_lock_init(); + (void)vol_lock_wait(0U); + vol_lock_release(); + CHECK(s_release_calls == 1, "TC-VL-011 已持锁时 release 调用 osMutexRelease 一次"); + + /* --- 锁未创建:静默返回 --- */ + fakes_reset(); + vol_lock_release(); + CHECK(s_release_calls == 0, "TC-VL-012 锁未创建时 release 静默返回(不调用、不崩溃)"); + + /* --- 完整周期 --- */ + fakes_reset(); + CHECK(vol_lock_init() == 0, "TC-VL-013 周期:init -> 0"); + CHECK(vol_lock_wait(0U) == 1, "TC-VL-013b 周期:wait -> 1"); + vol_lock_release(); + CHECK(vol_lock_wait(100U) == 1, "TC-VL-013c 周期:再 wait -> 1(可重复获取)"); + vol_lock_release(); + CHECK(s_new_calls == 1, "TC-VL-013d 周期:全程只创建一次互斥量"); + CHECK(s_release_calls == 2, "TC-VL-013e 周期:释放两次"); + CHECK(s_last_timeout == 100U, "TC-VL-013f 周期:第二次 wait 的 timeout 透传正确"); +} + +/* ===================== main ===================== */ + +int main(void) { + printf("=== Volume Lock Tests ===\n"); + + printf("-- vol_lock_init --\n"); + tc_init(); + printf("-- vol_lock_wait (incl. fail-open) --\n"); + tc_wait(); + printf("-- vol_lock_release --\n"); + tc_release(); + + printf("\n=== Volume Lock Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} From 317e97fca6e687bda5a2b2aee3c560b91a223028 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 18:04:51 +0800 Subject: [PATCH 06/47] =?UTF-8?q?docs(algo):=20App/algo/README.md=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=BA=E8=AF=A6=E7=89=88=EF=BC=88=E5=88=86?= =?UTF-8?q?=E5=B1=82/=E6=95=B0=E6=8D=AE=E6=B5=81/=E6=8E=A5=E5=8F=A3/?= =?UTF-8?q?=E6=B5=8B=E8=AF=95/=E6=89=A9=E5=B1=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 该文件此前仅 27 行(仅"现有模块"表 + 约定 + 相关文档),本次补齐为 168 行 详版:文件清单、1 ms 数据流图、逐文件 API 表与要点、关键约定、静态足迹、 构建与测试(含两个主机单测钩子)、新增算法模块的固定动作。 - 内容已逐项对照当前源码核验:相量实例 5 行 / 环容量 2×20 点 / 9 个语义通道 / Rank 映射(CH0..CH7 + CH14)/ 所引文档、脚本与 skill 均存在。 - 与 L1/L2 现状一致:adc_input(L1)、sample_ring + phasor(L2)三模块。 --- App/algo/README.md | 173 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 157 insertions(+), 16 deletions(-) diff --git a/App/algo/README.md b/App/algo/README.md index 54375b2b..dbe41c0b 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -1,27 +1,168 @@ # App/algo/ — 采集与算法层(L1 / L2) > **自研代码**,属 `docs/算法/继电保护功能复现规划.md` §2.1 分层中的 **L1(采集与预处理)** -> 与 **L2(相量计算,拟)**。原则:**算法与 I/O 分离**——本层不碰寄存器、不碰任务调度, -> 只做"数"(码值 → 工程量)与"相量"(工程量 → 复相量),从而可成为主机侧单元测试 -> (L0.5)的天然适格对象(落实登记簿 **A-4**)。 +> 与 **L2(相量计算)**。原则:**算法与 I/O、与调度分离**——本层不建任务、不做保护判据、 +> 不碰寄存器(唯一例外见 §3.1),只做"数"(原始码值 → 语义通道)与"相量" +> (采样序列 → 复相量),因而可被主机侧单元测试(L0.5)离线全覆盖(落实登记簿 **A-4**)。 +> +> 任务侧(`App/task/adc_task.c`)只做**消费与编排**;任何新算法一律落在本目录,勿塞进 `App/task/`。 -## 现有模块 +## 1. 文件清单(一眼版) -| 文件 | 层 | 职责 | +| 文件 | 层 | 作用(一句话) | 主要对外入口 | 覆盖规划项 | +|---|---|---|---|---| +| `adc_input.h` / `adc_input.c` | L1 | **统一语义取数层**:把两片转换器的原始码值按语义(`UA…IN`/温度)暴露,并驱动 STM32 ADC1 9 通道顺序扫描 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 | +| `sample_ring.h` / `sample_ring.c` | L2 输入 | **采样环缓冲**:按语义通道保存最近 2 个周波(40 点)采样值,供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 滑动窗供数) | +| `phasor.h` / `phasor.c` | L2 | **全周傅氏相量计算**:一周波 20 点单频 DFT → 基波/谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 | + +依赖关系(自下而上,无环):`adc_input.h`(纯枚举 + 声明)← `sample_ring` ← `phasor`。 +`sample_ring.c` / `phasor.c` **零平台依赖**(只用 `math.h` / `stddef.h` 与本目录头); +`adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。 + +## 2. 数据流(一拍 = 1 ms) + +```mermaid +flowchart TD + TIM6["TIM6 更新中断 1 kHz
Src/main.c → tpafe5160_start_conv_irq()"] --> TPAFE["TPAFE5160 转换
BUSY EXTI 收帧"] + TPAFE --> POLL + TASK["adcTask(1 ms osDelayUntil)
App/task/adc_task.c"] --> POLL["adc_input_poll()
TPAFE 快照 + STM32 ADC1 9 通道扫描"] + POLL --> RAW["adc_input_raw(ch) × 9
按语义取原始码值"] + RAW --> RING["sample_ring_push_frame()
整帧入环(40 点窗)"] + RING --> RUN["phasor_run()
逐实例 20 点重算 + 双区切换"] + RUN --> GET["phasor_get(idx)"] + GET --> CONS["消费者:adcTask 日志(现状)
UI / 自检 / L3 保护任务(待接)"] +``` + +初始化落点:`adc_input_init()` 与 `phasor_init()` 由 `App/app/app_main.c` 的 +`app_main_init()` 在**内核启动前**调用(三者 `*_init()` 均无 RTOS 依赖、幂等); +`phasor_init()` 内部会连带 `sample_ring_init()`,等价于"刚上电"。 + +## 3. 逐文件说明 + +### 3.1 `adc_input.c` / `adc_input.h` — L1 ADC 输入层 + +**解决的问题**:V0.1 上 8 路电气量被两片转换器分片(硬件缺陷 **N-18**),且网名 `AINn` +≠ 板级 `ADCn` ≠ 转换器通道号(13/17 路置换)。若消费侧直接索引驱动缓冲,改版即全线错位。 +本层把"哪一路"收进一张表,上层**按语义取数、不写死下标**。 + +| API | 干什么 | 注意 | |---|---|---| -| `adc_input.c/h` | L1 | **ADC 输入层**:① **语义通道映射**(L1-4)——`UA/UB/UC/UN/IA/IB/IC/IN/温度` → 物理通道,消费侧**按语义取数、不写死下标**;② **STM32 ADC1 9 通道软件触发顺序扫描**(L1-3);③ TPAFE5160 原子快照消费 | +| `adc_input_init()` | 清缓冲与统计 + **映射表自检**(每个 STM32 语义通道必须落在扫描 Rank 序列内) | 内核启动前调用;返回 `-1` = 映射指向了从不扫描的通道(会恒读陈旧值) | +| `adc_input_poll()` | 采一拍:`tpafe5160_snapshot()` 原子快照(8 通道)+ STM32 ADC1 9 通道软件触发顺序扫描 | **仅任务上下文**(快照走临界区);只搬码值,不做标定/运算 | +| `adc_input_raw(ch, &out)` | 按语义读原始码值(TPAFE 16 位补码 / STM32 0..4095) | 消费侧取数**唯一入口**;须先成功 `poll()`;`-2` = 该来源尚无有效数据 | +| `adc_input_desc(ch)` | 语义 → 物理通道描述(来源 + 下标/通道号) | 供自检注入、UI 通道序;返回静态地址,勿改 | +| `adc_input_stm32_rc()` / `adc_input_stm32_errs()` | STM32 侧最近扫描状态码 / 累计失败次数 | 心跳日志观测面(`adc1_rc` / `adc1_err`) | -## 关键约定 +**要点** -- **语义映射的唯一权威口径**是 `docs/硬件平台/ADC通道映射说明.md`(V0.1 有 13/17 路被置换, - 网名编号 ≠ 通道号)。V0.1 上 8 路电气量分属两片转换器(硬件缺陷 **N-18**):`UA/UN/IA` - 在 TPAFE5160、`UB/UC/IB/IC/IN` 在 STM32 ADC1。**V0.2 改版只改映射表内容**,本层 API - 与上层语义不动。 -- **取数一律经 `adc_input_raw()`**(按语义),禁止在消费侧直接索引驱动缓冲或写死下标。 -- 本层**不做**跨转换器的相位 / 功率 / 功率因数 / 矢量类运算(V0.1 硬件缺陷 N-18 限制)。 +- 语义映射表 `k_adc_map[]` 的**唯一权威口径**是 `docs/硬件平台/ADC通道映射说明.md`: + `UA/UN/IA` → TPAFE `buf[6]/[5]/[2]`;`UB/UC/IB/IC/IN/TEMP` → ADC1 `CH14/3/6/1/2/5`。 + **V0.2 改版只改这张表的内容**,本层 API 与上层语义不动。 +- STM32 侧是**顺序扫描**(Rank1..8 = CH0..CH7、Rank9 = CH14,与 `MX_ADC1_Init()` 严格一致), + 9 个通道**不是同一时刻**;单通道轮询超时 2 ms,超时判失败、绝不拖垮消费节拍。 +- 本文件是本层**唯一**允许出现 HAL 调用的地方(它必须驱动 ADC1);新增模块若不需要外设, + 请保持 `sample_ring` / `phasor` 那种零依赖形态,才能免桩单测。 -## 相关文档 +### 3.2 `sample_ring.c` / `sample_ring.h` — L2 输入采样环 + +**解决的问题**:相量计算要"以当前时刻为终止的一周波",而 L1 每次只给最新一帧。 +本层按**语义通道**存最近 `SAMPLE_RING_CYCLES = 2` 个周波(`SAMPLE_RING_DEPTH = 40` 点), +与参考系统 `CN_BUFSAM_CYCLE = 2` 一致,并为后续加长数据窗(1.5~2 周波抗衰减直流)预留空间。 + +| API | 干什么 | 返回 | +|---|---|---| +| `sample_ring_init()` | 清全部通道历史与计数(幂等,内核启动前可调) | — | +| `sample_ring_push_frame(values, n)` | 整帧压入(长度须 `= ADC_SEM_COUNT`) | `0` / `-1` 参数错 | +| `sample_ring_window(ch, n, out)` | 取该语义通道最近 `n` 点,**按时间序旧 → 新**(`out[n-1]` 最新) | `0` / `-1` 参数错 / `-2` 点数不足 | +| `sample_ring_filled()` | 已累积点数(**饱和**于 40)——判"够不够一周波" | `0..40` | +| `sample_ring_frames()` | 累计入环帧数(**不饱和**,1 kHz 下约 49.7 天回绕) | 帧数 | + +**要点** + +- **整帧写入后统一推进写指针** → 单帧内不可能"半新半旧",消费侧取到的窗永远落在整帧边界。 +- 本层**只做按时序存/取,不做任何跨通道运算**:同刻性由上游 L1 负责,本层既不保证也不假设。 +- `frames()` 与 L1 的 `tpafe5160_frame_seq()` 对照即可判丢帧(L1-6 的判据之一); + 本层只提供计数,**判定不在这里**。 +- 上层若某通道本拍取数失败,应**整拍跳过入环**(现 `adc_task` 的 `s_frames_skip` 即此), + 切勿补 0 点——0 会污染相量窗。 + +### 3.3 `phasor.c` / `phasor.h` — L2 相量计算 + +**算法**:全周傅氏(单频 DFT),`N = PHASOR_N = 20`(1 kHz @ 50 Hz)。系数表 +`k_fft_sin/k_fft_cos` **离线生成**并**预乘 √2/N**(口径 `docs/算法/傅氏算法详解.md` §2.3), +故 `fAmSQ = √(fRe²+fIm²)` **直接就是有效值**,`fAm = √2·fAmSQ` 为峰值。运行期不调 `sinf/cosf`。 + +| API | 干什么 | 注意 | +|---|---|---| +| `phasor_init()` | 双区清零 + 使用区/就绪复位 + `sample_ring_init()` | 幂等;内核启动前调用 | +| `phasor_inst_count()` / `phasor_inst(idx)` | 遍历实例表(`{name, sem, harm}`) | 返回静态地址,勿改;越界 `NULL` | +| `phasor_run()` | 逐实例取 20 点窗 → 重算 → 写计算区 → **一次性切换使用区** | 仅任务上下文;须先 `push_frame()` | +| `phasor_get(idx, &out)` | 读**使用区**结果 | `0` / `-1` 参数错 / `-2` 未攒满一周波 | +| `phasor_calc(x, n, harm, &out)` | 对给定窗口算 `harm` 次相量 | **纯函数**,可脱离采样环独立单测 | + +**要点** + +- **实例表数据驱动**(`k_inst[]`,现 5 行):`C01-IA-F1`(过流/过负荷)、`C02-UA-F1`(过压/欠压/频率)、 + `C03-UN-F1`(零序过压)、`C04-IA-F2`(二次谐波制动)、`C05-IA-F5`(五次谐波选线)。 + **新增实例只在表尾加一行**,遍历/日志/单测自动适配。 +- **双区(计算区 / 使用区)**:结果先写非使用区,全部实例写完后单字节 `volatile` store 切换 + `s_use_zone`(Cortex-M 上原子)→ 消费侧无锁、无撕裂。今日唯一消费者是 adcTask 自身, + 该机制为后续独立 L3 保护任务预留。 +- **任一实例数据不足一周波 → 本拍整体不产出**(不切换使用区),避免"部分新部分旧"的混合结果。 +- 每拍 20 点**全部重算**(暴力法,无累积误差;迭代加减首尾项会累积)。相量刷新周期 = 1 ms。 +- 相位(L2-4)**未常驻**结构体,需要时自行 `atan2f(fIm, fRe)`。 + +## 4. 关键约定 + +- **语义映射唯一权威口径**:`docs/硬件平台/ADC通道映射说明.md`。取数一律经 `adc_input_raw()`, + 禁止在消费侧直接索引驱动缓冲或写死下标。 +- **禁止跨转换器运算**(V0.1 硬件缺陷 N-18):`UA/UN/IA` 在 TPAFE、`UB/UC/IB/IC/IN` 在 STM32 ADC1, + 两者无共同采样触发 → 无同一时刻性、相位不确定。故本层**不做**跨转换器的相位 / 功率 / + 功率因数 / 负序零序类矢量运算(V0.2 把 8 路电气量全接 TPAFE 后方可解禁)。 +- **标定(L1-5)尚未实现**(缺标准源):现以**原始码值**充当工程量(系数 = 1), + 因此相量读数只证明**链路通**,**不可作幅值精度判据**。 +- **本层不建任务、不等事件、不写日志之外的副作用**;日志由调用侧(adcTask)打。 +- 内核启动前只允许调 `*_init()`;`adc_input_poll()` / `phasor_run()` 仅任务上下文。 + +## 5. 静态足迹(BSS,供栈/RAM 预算参考) + +| 位置 | 量 | +|---|---| +| `sample_ring.c` `s_ring[9][40]` float | **1440 B** | +| `phasor.c` `s_zone[2][5]` `phasor_t` | 160 B | +| `adc_input.c` `s_stm32_raw[16]` + `s_tpafe_raw[8]` | 48 B | +| 运行期栈:`phasor_run()` 的 `win[20]` | 80 B/帧 | + +改容量(`SAMPLE_RING_CYCLES` / 实例数 / `ADC_SEM_COUNT`)前后各跑一次 +`& '.venv\Scripts\python.exe' scripts/stack_report.py`(口径见 `mem-budget` skill)。 + +## 6. 构建与测试 + +- **挂载**:三个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `` 内。 + 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)。 +- **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码: + + | 套件 | 命令 | pre-commit 钩子 | + |---|---|---| + | 相量层(57 用例:系数表逐项复核 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换) | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` | + | ADC 输入层(逐 Rank 注入互异码值钉死映射 + HAL 启动失败/超时分支) | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` | + + 改本目录任一 `.c/.h`,提交时对应钩子自动跑;详见 `docs/测试说明/相量层单元测试说明.md`。 +- **板端**:`@build` 0 错误 0 警告 → `@test` 看 `[ADC_TASK]` 首拍/心跳日志(`phasor(...)` 行、 + `ring` / `skip` 计数、`adc1_rc` / `adc1_err`)。 + +## 7. 新增一个算法模块的固定动作 + +1. 在本目录建 `xxx.c/h`,文件头写模块注释块(规范 V1.0),**尽量零平台依赖**以便免桩单测。 +2. 用 `new-source-file` skill 注册到 `MDK-ARM/STM32F407-Demo.uvprojx`(目录已在 IncludePath, + 若新建子目录则必须补 IncludePath)。 +3. 加 `test/host/<模块>/` 主机单测 + `.pre-commit-config.yaml` 钩子(照 `phasor-unit-test` 抄)。 +4. 消费者写在 `App/task/`,本层只出"数";跨转换器运算的禁令见 §4。 + +## 8. 相关文档 - 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md` -- 分层与路线图:`docs/算法/继电保护功能复现规划.md`(§2.1 分层、§3.1 L1 清单) -- 采样节拍:`docs/子系统设计/ADC采样节拍设计说明.md` +- 分层与路线图(L1/L2 清单与状态):`docs/算法/继电保护功能复现规划.md` +- 傅氏算法与有效值口径:`docs/算法/傅氏算法详解.md` +- 采样节拍(TIM6 1 kHz):`docs/子系统设计/ADC采样节拍设计说明.md` +- 相量层单测细则:`docs/测试说明/相量层单元测试说明.md` From 0b0cd0048bcd83d4ce420d77acae616c47a5001e Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 18:06:43 +0800 Subject: [PATCH 07/47] =?UTF-8?q?=E8=A1=A5=E4=B8=A4=E4=BB=BD=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=8D=95=E6=B5=8B=E8=AF=B4=E6=98=8E=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=9A=E5=8D=B7=E9=94=81=EF=BC=88fail-open=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E5=90=8C=E6=AD=A5=EF=BC=88N-17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 卷锁单元测试说明.md:33 断言点。钉死 fail-open 不变量(互斥量创建失败时 vol_lock_wait 必须返回 1 且绝不阻塞——defaultTask 是喂狗点)、幂等、超时透传、 判空 release;并说明"白盒置 s_lock=NULL"的取舍与桩只验调用契约的边界。 - 时间同步单元测试说明.md:80 断言点。钉死分片喂狗延时、监听退避、时间包重组 (半包/粘连/EOF/真错/预算耗尽/tick 回绕)、任务主循环;含 setjmp/longjmp 跳出 for(;;) 的手法,以及"C 暂定定义静默合并同名 static 变量"这一无告警缺陷的记录。 - test/host/README.md:补齐 7 套主机单测的清单、运行方式与钩子(原表缺 phasor / adc_input / vol_lock / time_sync 四套),并修正 cfg_file 断言数。 - docs/README.md:主机侧单测(L0.5)表登记两份新文档,并同步修正 cfg_file 断言数。 --- docs/README.md | 4 +- docs/测试说明/卷锁单元测试说明.md | 194 +++++++++++++++++++++ docs/测试说明/时间同步单元测试说明.md | 239 ++++++++++++++++++++++++++ test/host/README.md | 44 ++++- 4 files changed, 474 insertions(+), 7 deletions(-) create mode 100644 docs/测试说明/卷锁单元测试说明.md create mode 100644 docs/测试说明/时间同步单元测试说明.md diff --git a/docs/README.md b/docs/README.md index c450567f..84fc8179 100644 --- a/docs/README.md +++ b/docs/README.md @@ -124,9 +124,11 @@ docs/ |---|---| | [`看门狗单元测试说明.md`](测试说明/看门狗单元测试说明.md) | Task Watchdog 判定状态机(位图 / 掩码 / 节流 / tick 回绕),39 用例 | | [`系统时钟单元测试说明.md`](测试说明/系统时钟单元测试说明.md) | sys_clock 时间换算 / 星期 Zeller / 30 天惰性再基准化 / RTC 故障降级,100 断言点 | -| [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,139 断言点(含抓出的外科手术保存死码 P0) | +| [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,149 断言点(含抓出的外科手术保存死码 P0) | | [`ADC输入层单元测试说明.md`](测试说明/ADC输入层单元测试说明.md) | adc_input 语义通道映射 / ADC1 顺序扫描(**Rank↔通道判据**)/ poll 契约 / HAL 故障路径,70 用例 | | [`相量层单元测试说明.md`](测试说明/相量层单元测试说明.md) | phasor 全周傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换,57 用例 | +| [`卷锁单元测试说明.md`](测试说明/卷锁单元测试说明.md) | vol_lock 创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传,33 断言点 | +| [`时间同步单元测试说明.md`](测试说明/时间同步单元测试说明.md) | time_sync 分片喂狗延时 / 监听退避 / 时间包重组 / 任务主循环(**N-17 的全部逻辑贡献者**),80 断言点 | **模块测试指南**(动手写测试前先读) diff --git a/docs/测试说明/卷锁单元测试说明.md b/docs/测试说明/卷锁单元测试说明.md new file mode 100644 index 00000000..ac3797a8 --- /dev/null +++ b/docs/测试说明/卷锁单元测试说明.md @@ -0,0 +1,194 @@ +# 卷锁单元测试说明(主机侧) + +> **模块**:Volume Lock(`App/fs/vol_lock.c` + `App/fs/vol_lock.h`) +> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证 NAND FatFs 卷串行化 +> 互斥锁的创建 / 获取 / 释放契约,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(33 断言点,`/W4` 0 告警,已挂 pre-commit 钩子) +> **日期**:2026-10-10 +> **关联文档**: +> [`../../AGENTS.md`](../../AGENTS.md)(平台约束与看门狗铁律)、 +> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 +> [`看门狗单元测试说明.md`](看门狗单元测试说明.md)(同源的喂狗点语义)、 +> [`相量层单元测试说明.md`](相量层单元测试说明.md)(同批落地的姊妹篇)、 +> [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` 与长耗时白名单) + +--- + +## 1. 概述 + +### 1.1 为什么单独做主机侧单测 + +`vol_lock` 只有 3 个函数、不到 40 行,看起来"不值得"单测。但它的**核心语义是 fail-open**: + +> 互斥量**创建失败**时,`vol_lock_wait()` 必须**返回 1 且绝不阻塞**。 + +这不是一个"顺手写的兜底",而是整个系统**最后一道防复位保险**:`defaultTask` 是看门狗 +喂狗点(`task_wd_poll`),一旦它在等锁上阻塞,主循环即停摆 → 停喂 → IWDG 复位。 +`vol_lock.h` 的文件头把这条写成"使用约定(铁律)",本单测就是它的**回归防线**。 + +真机上 `osMutexNew` 失败**几乎不可能复现**(需 FreeRTOS 堆耗尽),板端套件(`test/board/`) +构造不出这个前置条件。主机侧只需给桩注入一个 `NULL` 返回值即可穷举该分支——这是本单测 +存在的首要理由。 + +次要理由:**幂等性**(重复 `vol_lock_init` 不得重复创建)、**超时值透传**、**未创建时 +`release` 无害**都是纯状态逻辑,主机侧一次钉死。 + +### 1.2 被测对象与桩 + +测试单元**原样 `#include` 生产源文件**(保证测的是生产代码而非副本),平台依赖由桩替身: + +``` +test/host/vol_lock/vol_lock_test.c + ├── #include "cmsis_os.h" ← 桩(test/host/vol_lock/stub/),可注入 创建失败/超时/成功 + └── #include "vol_lock.c" ← 生产源文件(App/fs/),原样纳入 +``` + +| 头文件 | 来源 | 说明 | +|---|---|---| +| `vol_lock.h` | **真实**(`App/fs/`) | 只 `#include `,无平台依赖,无需替身 | +| `cmsis_os.h` | **桩**(`stub/`) | 真实版在 CMSIS-RTOS V2 下把 `osMutexWait` 宏映射到 `osMutexAcquire`;桩直接提供 V1 名 `osMutexWait`,与生产源调用名一致 | + +`include_dirs` 顺序为 **`stub` → `App/fs` → 本目录**:桩目录必须排在前面,否则会被真实 +`cmsis_os.h` 抢先命中。源文件被纳入同一编译单元,故**静态量 `s_lock` 亦可直接观测与置位** +(白盒)。 + +### 1.3 关键约定:fail-open 是"返回 1",不是"返回 0" + +读代码时最容易看错的一点(`vol_lock.c:42-50`): + +| 情形 | `vol_lock_wait()` 返回 | 是否调用 `osMutexWait` | 调用方该做什么 | +|---|---|---|---| +| 已创建 + 取得锁 | `1` | 是(`osOK`) | 正常进入临界区,事后 `release` | +| 已创建 + 超时 | `0` | 是(非 `osOK`) | **不得** `release`;本轮跳过 | +| **未创建 + 创建失败** | **`1`** | **否(零阻塞)** | **照常进入临界区**(无保护),事后 `release` 判空静默 | + +即:**返回 1 = "你可以往下走"**,其代价是"可能没有真正持锁"。这保证了喂狗点在任何情况下 +都不会被锁阻塞。`TC-VL-008`(返回 1)与 `TC-VL-008b`(`osMutexWait` 调用次数 == 0) +两条断言共同钉死该语义——只断言返回 1 是不够的,必须同时证明**没有进入任何等待路径**。 + +> `vol_lock_init()` 的返回值与 `wait()` 的语义**无关**:`init` 失败返回 `-1`,但 +> `wait()` 仍返回 `1`。调用方**不得**用 `init` 的返回值决定是否调 `wait`。 + +### 1.4 测试环境 + +| 依赖 | 版本 / 说明 | +|---|---| +| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 | +| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) | +| 硬件 | 无(不需要开发板 / 串口 / ST-LINK) | + +工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码判定全部委托通用 harness +[`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 + +### 1.5 运行方式 + +```powershell +# 仓库根目录(统一用 .venv) +& '.venv\Scripts\python.exe' test/host/vol_lock/run_unit_test.py +``` + +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +### 1.6 回归接入 + +| 项 | 说明 | +|---|---| +| **触发范围** | `App/fs/vol_lock.c` / `vol_lock.h`,或 `test/host/vol_lock/**`、`test/host/common/unit_test_harness.py` 变动 | +| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 | +| **pre-commit 钩子** | `vol-lock-unit-test`(本地钩子,`.pre-commit-config.yaml` §7f) | +| **汇总行** | `=== Volume Lock Tests: 33/33 PASSED (failed=0) ===` | +| **与板端测试的关系** | 互补:本单测保证**锁的调用契约**(fail-open / 幂等 / 透传);板端保证**真实的 FTL 并发不互踩**(dhara journal 竞争),后者需真机 + FTP 并发压测 | + +--- + +## 2. 测试用例 + +> 命名 `TC-VL-NNN`(VL = Volume Lock)。桩状态在每用例前由 `fakes_reset()` 复位到 +> "刚上电"(含把 `s_lock` 置 `NULL`)。断言数(33)多于用例数(14),因为每个用例 +> 通常包含 2~3 条子断言(后缀 `b` / `c`)。 + +### 2.1 `vol_lock_init` — 创建与幂等 + +| 用例 | 断言 | +|---|---| +| **TC-VL-001** | 首次调用返回 `0`;`osMutexNew` 恰好调用 1 次;模块静态态已持有句柄 | +| **TC-VL-002** | 重复调用仍返回 `0`(幂等);**不再**创建第二个互斥量(`osMutexNew` 仍为 1 次) | +| **TC-VL-003** | `osMutexNew` 返回 `NULL` 时 `init` 返回 `-1`;**不留下半初始化句柄**(`s_lock == NULL`) | +| **TC-VL-004** | 失败后重试可成功(返回 `0`);重试确实**再次**调用了 `osMutexNew` | + +### 2.2 `vol_lock_wait` — 获取与 fail-open ⭐ + +| 用例 | 断言 | +|---|---| +| **TC-VL-005** | 获取成功返回 `1`;`osMutexWait` 调用 1 次;**`timeout` 原样透传**(100 → 100) | +| **TC-VL-014** | `wait(0)` 的 try 语义:返回 `1` 且透传值为 `0`(defaultTask 路径) | +| **TC-VL-006** | `osMutexWait` 返回非 `osOK` → `wait` 返回 `0`;超时路径确实尝试过等待 | +| **TC-VL-007** | 锁未创建时 `wait` **自动 `init`** 并正常获取(创建 1 次 + 等待 1 次) | +| **TC-VL-008** ⭐ | **fail-open**:锁未创建且创建失败 → 返回 **`1`**;**`osMutexWait` 调用次数 == 0**(零阻塞);`s_lock` 仍为 `NULL` | +| **TC-VL-009** | fail-open 之后 `release` 无害:`osMutexRelease` **未被调用**(判空静默返回) | +| **TC-VL-010** | 已创建时多次 `wait` **不重复创建**(`osMutexNew` 恒 1 次);每次 `wait` 各等待一次 | + +> **TC-VL-008 是本套件存在的主要理由**:真机上需堆耗尽才可能触发,板端无法构造。 + +### 2.3 `vol_lock_release` — 释放与判空 + +| 用例 | 断言 | +|---|---| +| **TC-VL-011** | 已持锁时 `release` 调用 `osMutexRelease` **一次** | +| **TC-VL-012** | 锁未创建时 `release` **静默返回**(不调用、不崩溃) | +| **TC-VL-013** | 完整周期 `init → wait → release → wait → release`:全程只创建 **1** 个互斥量、释放 **2** 次、第二次 `wait` 的 `timeout` 透传正确(100) | + +**当前结果**:33/33 PASSED(`failed=0`)。 + +--- + +## 3. 如何扩展用例 + +1. 加一个 `static void tc_xxx(void)`;首行调 `fakes_reset()`; +2. 通过桩变量造场景:`s_new_fail = 1`(创建失败)、`s_wait_ok = 0`(等待超时); +3. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。 + +> ⚠ 用例之间**必须** `fakes_reset()`:`s_lock` 是全局态,不复位会把上一用例的句柄串进来。 + +--- + +## 4. 已知边界与设计取舍 + +### 4.1 白盒置 `s_lock = NULL` 是可接受的代价 + +`fakes_reset()` 直接写模块静态量 `s_lock`(源文件已被纳入同一编译单元,故可见)。 +这等价于"未初始化过",是主机侧白盒单测的常规手法。代价:若将来把 `s_lock` 改为 +`static const` 或移入结构体,`fakes_reset()` 需同步调整。 + +### 4.2 桩只验证"调用契约",不验证"互斥语义" + +桩的 `osMutexWait` 不模拟真实的互斥/阻塞/优先级继承行为,只记录**调用次数与参数**。 +因此本单测能保证的是: + +- ✅ 该调 `osMutexNew` 时调了、且只调一次(幂等); +- ✅ 该调 `osMutexWait` 时调了、`timeout` 值正确; +- ✅ **不该调时绝对没调**(fail-open 的零阻塞、判空 release)。 + +**不能**保证的是:真实互斥下的互斥性、优先级继承、超时精度——那些属 FreeRTOS 内核职责, +且板端 `FTP 并发压测`才是对应判据。 + +### 4.3 与板端的互补关系 + +| 层次 | 判据 | 手段 | +|---|---|---| +| 本单测(L0.5) | 锁的**调用契约**(fail-open / 幂等 / 透传 / 判空) | 主机侧桩注入 | +| 板端(L1) | 卷访问**真的串行**了(无 dhara journal 竞争、无 IWDG 误复位) | 真机 + FTP 与 syslog 并发压测 | + +`vol_lock.h` 记录的历史根因(2026-10-02:defaultTask 与 ftpTask 并发 FTL 写 → dhara journal +竞争 → 恢复旋转饥饿喂狗点 → IWDG 误复位)只能由板端判据复现;本单测负责**防止契约被改坏** +(例如有人把 fail-open 的 `return 1` 误改成 `return 0`,或给 fail-open 路径加上 +`osMutexWait`)。 + +--- + +## 5. 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建;33 断言点全绿(`TC-VL-001..014`);覆盖 `init` 创建与幂等、`wait` 获取 / 超时 / 自动 init / **fail-open**、`release` 释放与判空;挂 pre-commit 钩子 `vol-lock-unit-test` | diff --git a/docs/测试说明/时间同步单元测试说明.md b/docs/测试说明/时间同步单元测试说明.md new file mode 100644 index 00000000..ac8a683d --- /dev/null +++ b/docs/测试说明/时间同步单元测试说明.md @@ -0,0 +1,239 @@ +# 时间同步单元测试说明(主机侧) + +> **模块**:Time Sync(`App/sync/time_sync.c` + `App/sync/time_sync.h`) +> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证 TCP 时间同步协议 +> 状态机(分片喂狗延时 / 监听建立与退避 / 时间包重组 / 任务主循环),零硬件、零烧录、 +> 毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(80 断言点,`/W4` 0 告警,已挂 pre-commit 钩子) +> **日期**:2026-10-10 +> **关联文档**: +> [`../../AGENTS.md`](../../AGENTS.md)(平台约束与看门狗铁律)、 +> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 +> [`看门狗单元测试说明.md`](看门狗单元测试说明.md)(喂狗位图判定状态机)、 +> [`卷锁单元测试说明.md`](卷锁单元测试说明.md)(同批落地的姊妹篇)、 +> [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、 +> [`../待处理内容.md`](../待处理内容.md)(**N-17** 登记条目)、 +> [`../问题记录/错误码传播审计.md`](../问题记录/错误码传播审计.md)(**E-02** 头阻塞) + +--- + +## 1. 概述 + +### 1.1 为什么单独做主机侧单测 + +本模块是**两件已知风险的当事方**,而两件风险的成因**全部是本模块的纯逻辑**: + +1. **N-17(看门狗余量)**:登记簿 N-17 记录"time_sync 监听重建期间心跳缺口 800ms~1s, + 距 IWDG 最坏超时 1701ms 余量仅约 0.7s"。该缺口的每一段都由本模块产生—— + `time_sync_delay_fed()` 的**分片喂狗节奏**、监听重试退避(500 / 2000ms)、 + `accept` 轮询退避(200ms)、收尾延时(50ms),以及"就绪屏障等待态也要喂心跳"。 + 真机上只能靠"跑起来看复位不复位"间接观察;主机侧可**逐片断言**心跳次数与片长。 +2. **E-02(头阻塞)**:旧实现用单次 30s 阻塞 `net_recv` 等时间包,独占 `netTask`, + 使其余任务的网络操作整体停摆。现改为"分片有界读 + 总预算",本单测钉死其**分片语义** + (单次等待片长恒 ≤ 50ms、总预算 30s 不因分片而改变)。 + +次要理由:**时间包重组状态机**(累积到 8 字节才解析、TCP 分段送达、EOF / 真错误 / +本片超时三种返回码分支、tick 回绕)是纯逻辑,板端套件无法精确构造 +"分段 + 预算耗尽 + tick 回绕"这类组合。 + +### 1.2 被测对象与桩 + +测试单元**原样 `#include` 生产源文件**,平台依赖由 7 个桩头替身 + 1 个真实头组成: + +``` +test/host/time_sync/time_sync_test.c + ├── #include "FreeRTOS.h" / "task.h" / "cmsis_os2.h" ← 桩 + ├── #include "net_socket.h" / "sys_clock.h" / + │ "sys_events.h" / "dbg_log.h" ← 桩 + ├── #include "task_watchdog.h" ← 真实头(App/util/) + └── #include "time_sync.c" ← 生产源文件(App/sync/),原样纳入 +``` + +| 头文件 | 来源 | 桩提供的能力 | +|---|---|---| +| `FreeRTOS.h` | 桩 | `TickType_t` = `uint32_t`;`configTICK_RATE_HZ` = 1000(故 `pdMS_TO_TICKS(ms) == ms`) | +| `task.h` | 桩 | `vTaskDelay`(记录每片时长)、`xTaskGetTickCount`(返回可控 tick) | +| `cmsis_os2.h` | 桩 | `osEventFlagsWait`(按脚本返回就绪 / 超时) | +| `sys_events.h` | 桩 | `sys_evtHandle` + `SYS_EVT_NET_READY` 等位定义 | +| `sys_clock.h` | 桩 | `sys_clock_set_unix`(记录 epoch 与调用次数) | +| `dbg_log.h` | 桩 | `DBG_ERROR` / `DBG_INFO` 计数(`g_tstest_err_count` / `g_tstest_info_count`) | +| `net_socket.h` | 桩 | `socket` / `bind` / `listen` / `accept` / `close` / `recv_to` / `get_generation` / `htons` **全部脚本化** | +| `task_watchdog.h` | **真实**(`App/util/`) | 提供 `task_wd_id_t` / `TASK_WD_TIMESYNC`;`task_wd_beat` / `task_wd_bind` 由测试文件实现(记录心跳节奏与位号) | +| `time_sync.h` | **真实**(`App/sync/`) | 只 `#include `,无平台依赖 | + +`include_dirs` 顺序为 **`stub` → `App/util` → `App/sync` → 本目录**:桩目录必须排在 +前面,否则会被真实头抢先命中。 + +> ⚠ `dbg_log.h` 桩**不得**定义 `DBG_LINE_BUF_MAX` / `DBG_TAG`——生产源(`time_sync.c:28-29`) +> 已先定义它们,桩再定义会触发 C4005 宏重定义告警。 + +### 1.3 关键约定:分片喂心跳与报文重组契约 + +**(一)心跳必须落在同一个 100ms 抽样窗内。** +看门狗抽样窗为 `WD_POLL_PERIOD_MS = 100ms`(`App/util/task_watchdog.c`)。若一次长等待 +(如 2000ms 退避)期间不喂心跳,或心跳节奏恰好滑过两个连续抽样窗,该任务就会被判"卡死"。 +故 `time_sync_delay_fed()` 把任意长等待切成 **≤ `TIME_SYNC_WAIT_SLICE_MS` = 50ms** 的短片, +**每片前**喂一次心跳——语义等价于一次长延时,仅把心跳节奏钉在窗内(`round 8b` 消抖)。 + +**(二)`net_recv_to()` 的四态返回。** +`time_sync_handle_conn()` 的重组循环依赖该契约: + +| 返回 | 含义 | 本模块动作 | +|---|---|---| +| `> 0` | 收到 `n` 字节 | 累加;`>= 8` 即收齐并解析,否则继续等余下字节 | +| `0` | 对端关闭(EOF) | 退出循环,**不解析** | +| `-1` | 真错误 | 退出循环,**不解析** | +| `-2` | 本片预算等满、无数据 | 重算剩余预算,**下一片**继续 | + +**(三)总预算不因分片而改变。** +单次 `net_recv_to` 的等待片长 ≤ 50ms,但**跨片累积**仍以 `t0 = xTaskGetTickCount()` +为基准、总预算 `TIME_SYNC_TOTAL_BUDGET_MS` = 30000ms。tick 回绕由无符号减法天然处理。 + +### 1.4 测试环境 + +| 依赖 | 版本 / 说明 | +|---|---| +| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 | +| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) | +| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / 对端 PC) | + +工具链探测 / 编译运行 / 退出码判定全部委托通用 harness +[`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +本模块 `run_unit_test.py` 只**声明** `TestBuild`。 + +### 1.5 运行方式 + +```powershell +# 仓库根目录(统一用 .venv) +& '.venv\Scripts\python.exe' test/host/time_sync/run_unit_test.py +``` + +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +### 1.6 回归接入 + +| 项 | 说明 | +|---|---| +| **触发范围** | `App/sync/time_sync.c` / `time_sync.h`、`App/util/task_watchdog.h`,或 `test/host/time_sync/**`、`test/host/common/unit_test_harness.py` 变动 | +| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 | +| **pre-commit 钩子** | `time-sync-unit-test`(本地钩子,`.pre-commit-config.yaml` §7g) | +| **汇总行** | `=== Time Sync Tests: 80/80 PASSED (failed=0) ===` | +| **与板端测试的关系** | 互补:本单测保证**延时节奏与重组逻辑**符合设计;板端保证**真实 TCP 时序 + IWDG 不误复位**(N-17 的实际余量只能由板端长跑判定) | + +--- + +## 2. 测试用例 + +> 命名 `TC-TS-NNN`(TS = Time Sync)。断言数(80)多于用例数(27),因为每个用例通常 +> 包含 2~9 条子断言(后缀 `b` / `c` …)。桩状态在每用例前由 `fakes_reset()` 复位。 + +### 2.1 `time_sync_delay_fed` — 分片喂心跳延时(TC-TS-001..006) + +| 用例 | 断言 | +|---|---| +| **TC-TS-001** | `delay_fed(0)` 既不睡眠也不喂狗(零开销快路径) | +| **TC-TS-002** | 恰好一片(50ms):1 次 `vTaskDelay(50)` + 1 次心跳 | +| **TC-TS-003** | 非整片(120ms)切成 **50 / 50 / 20** 三片,每片喂一次心跳 | +| **TC-TS-004** | 长延时(2000ms)= **40 片 × 50ms**,每片喂心跳(监听退避路径) | +| **TC-TS-005** | 不足一片(49ms)单片处理,**不**被拆成 0 | +| **TC-TS-006** | 总预算 30s = **600 片**,片长上限**恒** 50ms | + +### 2.2 `time_sync_open_listener` — 监听建立与退避(TC-TS-010..015) + +| 用例 | 断言 | +|---|---| +| **TC-TS-010** | 首次成功:`socket` / `bind` / `listen` 各 1 次;端口经 `net_htons(8888)`;`sin_family` = `NET_AF_INET`;成功路径**无退避延时** | +| **TC-TS-011** | `bind` 失败 → **关闭该 socket** + 退避 2000ms(40 片)+ 留痕 `DBG_ERROR`,随后重试成功 | +| **TC-TS-012** | `listen` 失败 → 关闭该 socket + 退避 2000ms,随后重试成功 | +| **TC-TS-013** | `socket` 连续失败 → **5 次后返回 -1**;5 条 `DBG_ERROR`;5 × 500ms 退避(50 片) | +| **TC-TS-014** | 第 3 次重试成功并返回**新**描述符;恰好尝试 3 次;两次失败退避 500ms × 2 | +| **TC-TS-015** | 心跳总次数 = 每轮重试(5)+ 每片延时(50);**心跳位恒为 `TASK_WD_TIMESYNC`** | + +### 2.3 `time_sync_handle_conn` — 时间包重组状态机(TC-TS-020..029) + +| 用例 | 断言 | +|---|---| +| **TC-TS-020** | 8 字节一次收齐 → **大端**解析 epoch + 更新墙钟 1 次 + 关连接;首次请求 16 字节;单次等待片长 = 50ms;留痕 `wall clock updated` | +| **TC-TS-021** | 分段送达(4+4)→ 累积后解析正确;读了两次;第二次**只请求剩余 12 字节**;每片 50ms | +| **TC-TS-022** | 一次到达 16 字节 → **只取前 8 字节**;一次读够即停 | +| **TC-TS-023** | 前缀非 `"TIME"` → **不更新墙钟**,但仍关闭连接 | +| **TC-TS-024** | 对端关闭(`n == 0`)→ 立即退出、不解析;关闭连接 | +| **TC-TS-025** | 真错误(`n == -1`)→ 立即退出、不解析 | +| **TC-TS-026** | 只收到 4 字节后 EOF → **不足 8 字节不解析**;期间继续等待后续字节 | +| **TC-TS-027** | 30s 预算耗尽 → 不再读(只读 1 次)+ 留痕 `no TIME packet in 30s, drop conn` + 关连接 | +| **TC-TS-028** | 剩余预算不足一片 → **末片按剩余(20ms)切分** | +| **TC-TS-029** | **32 位 tick 回绕** → 预算计算仍正确(无符号减法) | + +### 2.4 `time_sync_task` — 任务主循环(TC-TS-040..044) + +| 用例 | 断言 | +|---|---| +| **TC-TS-040** | `sys_evtHandle == NULL` → **跳过就绪屏障**;`accept` 轮询 3 次(2 次无连接 + 1 次成功);收到时间包并更新墙钟;延时片数 = 4+4+1(200+200+50ms)且**全部按 50ms 分片**;心跳次数 = 主循环 1 + 退避 8 + 收连接 1 + 收尾 1 + 次轮 1 = **13**;所有心跳均打 `TASK_WD_TIMESYNC` 位 | +| **TC-TS-041** | 就绪屏障轮询 3 次(2 次未就绪 + 1 次就绪);屏障等待按 **100ms 分片**;等待 `SYS_EVT_NET_READY` 位;`osFlagsWaitAny \| osFlagsNoClear`(**就绪位不被消费**);未就绪的两次各喂一次心跳;仍先 `task_wd_bind` 登记心跳路由 | +| **TC-TS-042** | 屏障等待 4 次均未就绪 → **循环未挂死**(跳出并继续);每次未就绪后各喂一次心跳(2026-10-02 台架根因的**回归防线**) | +| **TC-TS-043** | 网络代次变化 → **关闭旧监听**并重监听;留痕 `network re-init, relisten`(另含 2 条 listen 成功);每次循环只轮询一次 `accept` 即放弃 | +| **TC-TS-044** | 监听建立失败 → 内部重试 5 次后放弃;6 条 `DBG_ERROR`(5 条 `net_socket FAIL` + 1 条 `retry later`);退避 5×500ms + 2000ms = **90 片**;心跳次数 = 2 + 5 + 50 + 40 = **97**;所有心跳均打 `TIMESYNC` 位 | + +**当前结果**:80/80 PASSED(`failed=0`)。 + +--- + +## 3. 如何扩展用例 + +1. **纯延时类**:直接调 `time_sync_delay_fed(ms)`,断言 `s_delay_calls`(片数)与 + `s_delays[]`(每片时长)以及 `s_beat_calls`(心跳数)。 +2. **脚本化网络**:给 `s_gen_seq[]` / `s_evt_seq[]` / `recv_step_t` 数组填序列, + 再驱动被测函数。 +3. **跳出任务循环**:生产 `time_sync_task` 是 `for(;;)`,无法自然返回。桩在脚本耗尽时 + 调 **`longjmp(s_jmp, N)`** 强行跳出(见 §4.1),跳出后正常断言。 +4. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。 + +--- + +## 4. 已知边界与设计取舍 + +### 4.1 用 `setjmp` / `longjmp` 跳出 `for(;;)` + +`time_sync_task` 是永不返回的任务循环。本单测在 `main()` 里 `setjmp` 设跳转点, +桩在"脚本化调用计数达到预设值"时 `longjmp` 回来,然后断言循环期间产生的副作用 +(心跳次数 / 延时片数 / `accept` 次数 / 墙钟更新)。 + +**取舍**:这比"把循环体抽成可测函数"更贴近真实——**测的就是任务主体本身**, +不需要为了可测性改动生产代码结构。代价是**必须精确设定跳出点**:跳出点设错 +(多一次或少一次桩调用)会导致断言数不符,`TC-TS-040` 的跳出点曾因此修正。 + +### 4.2 ⚠ C 的"暂定定义"会静默合并同名静态量 + +本文件曾出现一个**无任何编译告警**的缺陷:文件作用域下两处 `static int s_bind_calls;` +(一处给 `task_wd_bind` 桩、一处给 `net_bind` 桩)被 C 的**暂定定义(tentative +definition)规则静默合并为同一个对象**——于是 `task_wd_bind` 与 `net_bind` 共用一个计数器, +8 条断言失败(72/80)。 + +**教训**:同一编译单元内**所有** `static` 变量名必须唯一,哪怕是不同桩的"各自的"计数器。 +修复手法是把看门狗侧改名为 `s_wd_bind_calls` / `s_wd_bind_id_bad`,并加注释说明。 +扩展用例时**务必**先 `grep` 目标名字是否已被占用。 + +### 4.3 桩不模拟真实 TCP 时序 + +`net_recv_to` / `net_accept` 桩按脚本返回,**不**模拟真实的分段到达时刻、重传、窗口、 +CH395F 缓冲区边界。因此本单测能保证的是"**给定返回码序列,状态机反应正确**", +**不能**保证真实链路上的时序行为——后者属板端与 PC 对端(`test/host/time_sync_server.py`)职责。 + +### 4.4 N-17 的实际余量仍由板端判定 + +本单测把"每片喂心跳、片长 ≤ 50ms、退避值正确"这些**逻辑前提**钉死,但 N-17 关注的 +是**端到端心跳缺口 vs IWDG 超时**——这还取决于真实调度延迟、`netTask` 排队时长、 +Flash 写耗时等主机侧无法建模的因素。故: + +- 本单测 ✅ 保证"逻辑上不会出现超过 50ms 的**无喂狗**窗口"; +- 板端 ✅ 保证"实测缺口 + 调度抖动仍小于 1701ms"。 + +两者都过,N-17 才能闭环。 + +--- + +## 5. 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建;80 断言点全绿(`TC-TS-001..006` / `010..015` / `020..029` / `040..044`);覆盖分片喂狗延时、监听建立与退避、时间包重组(半包 / 粘连 / EOF / 真错 / 预算耗尽 / tick 回绕)、任务主循环(就绪屏障 / accept 轮询 / 代次变化重听 / 监听失败退避);修复静态量暂定定义合并缺陷;挂 pre-commit 钩子 `time-sync-unit-test` | diff --git a/test/host/README.md b/test/host/README.md index f4baeb32..a93b2ccd 100644 --- a/test/host/README.md +++ b/test/host/README.md @@ -41,14 +41,28 @@ | `sys_clock/run_unit_test.py` | **系统时钟换算单测**:离线编译真实 `App/util/sys_clock.c`,以可控时钟 + 阴影 RTC 桩驱动,100 断言点覆盖闰年/月末/世纪/2038 上限/星期/30 天惰性再基准化/RTC 故障降级 | | `sys_clock/sys_clock_test.c` | 测试单元本体(`#include` 生产源码 + 强符号桩;**不改生产代码**) | | `sys_clock/stub/` | 桩头文件(`main.h`/`FreeRTOS.h`/`task.h`/`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 | -| `cfg_file/run_unit_test.py` | **配置引擎单测**:离线编译真实 `App/cfg/cfg_file.c`,以**内存虚拟文件系统**桩驱动,139 断言点覆盖 INI 解析/四种值类型编解码/段注册校验/load-save-poll 契约/超长守卫/故障注入 | +| `cfg_file/run_unit_test.py` | **配置引擎单测**:离线编译真实 `App/cfg/cfg_file.c`,以**内存虚拟文件系统**桩驱动,149 断言点覆盖 INI 解析/四种值类型编解码/段注册校验/load-save-poll 契约/超长守卫/故障注入 | | `cfg_file/cfg_file_test.c` | 测试单元本体(`#include` 生产源码 + `cfg_store_*` 内存 FS 后端桩;**不改生产代码**) | | `cfg_file/stub/` | 桩头文件(`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 | +| `phasor/run_unit_test.py` | **相量层(L2)单测**:离线编译真实 `App/algo/phasor.c` + `sample_ring.c`(**无需任何桩**,纯算法),57 用例覆盖傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区原子交换 | +| `phasor/phasor_test.c` | 测试单元本体(`#include` 两个生产源码 + 合成任意波形) | +| `adc_input/run_unit_test.py` | **ADC 输入层(L1)单测**:离线编译真实 `App/algo/adc_input.c`,70 用例给每个 Rank 注入互异码值**逐通道钉死映射**,并覆盖 HAL 启动失败 / 转换超时 / 参数错等真机难复现分支 | +| `adc_input/adc_input_test.c` | 测试单元本体(`#include` 生产源码 + HAL / TPAFE 可注入桩) | +| `adc_input/stub/` | 桩头文件(`adc.h` / `tpafe5160.h`) | +| `vol_lock/run_unit_test.py` | **卷锁单测**:离线编译真实 `App/fs/vol_lock.c`,33 断言点覆盖创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传 | +| `vol_lock/vol_lock_test.c` | 测试单元本体(`#include` 生产源码 + CMSIS 互斥桩) | +| `vol_lock/stub/` | 桩头文件(`cmsis_os.h`) | +| `time_sync/run_unit_test.py` | **时间同步单测**:离线编译真实 `App/sync/time_sync.c`,80 断言点覆盖分片喂狗延时 / 监听退避 / 时间包重组(半包·粘连·EOF·真错·预算耗尽·tick 回绕)/ 任务主循环(**N-17 的全部逻辑贡献者**) | +| `time_sync/time_sync_test.c` | 测试单元本体(`#include` 生产源码 + 网络 / 事件 / RTOS 可脚本化桩 + `longjmp` 跳出 `for(;;)` 任务循环) | +| `time_sync/stub/` | 桩头文件(`FreeRTOS.h` / `task.h` / `cmsis_os2.h` / `net_socket.h` / `sys_clock.h` / `sys_events.h` / `dbg_log.h`) | > 详见 `docs/测试说明/看门狗单元测试说明.md`、`docs/测试说明/系统时钟单元测试说明.md`、 -> `docs/测试说明/配置文件单元测试说明.md`; +> `docs/测试说明/配置文件单元测试说明.md`、`docs/测试说明/ADC输入层单元测试说明.md`、 +> `docs/测试说明/相量层单元测试说明.md`、`docs/测试说明/卷锁单元测试说明.md`、 +> `docs/测试说明/时间同步单元测试说明.md`; > harness 用法见 [`common/README.md`](common/README.md)。 -> 已挂 pre-commit 钩子 `watchdog-unit-test`、`sys-clock-unit-test`、`cfg-file-unit-test`。 +> 已挂 pre-commit 钩子 `watchdog-unit-test`、`sys-clock-unit-test`、`cfg-file-unit-test`、 +> `phasor-unit-test`、`adc-input-unit-test`、`vol-lock-unit-test`、`time-sync-unit-test`。 ## 运行方式 @@ -69,6 +83,18 @@ # 配置引擎单测(纯逻辑,无需真机) & '.venv\Scripts\python.exe' test/host/cfg_file/run_unit_test.py + +# 相量层(L2)单测(纯逻辑,无需真机) +& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py + +# ADC 输入层(L1)单测(纯逻辑,无需真机) +& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py + +# 卷锁单测(纯逻辑,无需真机) +& '.venv\Scripts\python.exe' test/host/vol_lock/run_unit_test.py + +# 时间同步单测(纯逻辑,无需真机) +& '.venv\Scripts\python.exe' test/host/time_sync/run_unit_test.py ``` > 串口端口配置在 `.serialtest/config.json`(单一配置源,当前 COM6/115200)。 @@ -77,10 +103,11 @@ - **PYTHONPATH/路径敏感**:脚本按仓库根相对路径读写,**从仓库根执行** - 本目录**被 pre-commit 门禁的 cppcheck / 代码规范排除**(`test/` 豁免); - 但 `watchdog/run_unit_test.py`、`sys_clock/run_unit_test.py`、`cfg_file/run_unit_test.py` - 自身**作为独立钩子**参与门禁(见上) + 但各模块的 `run_unit_test.py` 自身**作为独立钩子**参与门禁(见上,共 7 个) - 台架探针族(TCP 复连 / FTP 中止等)在 `MDK-ARM/*.py`,与本目录分工不同 -- `watchdog/`、`sys_clock/` 单测需要本机装有 MSVC(VS2019/2022 的 C++ 工具链);无则脚本报环境错误(退出码 2) +- 全部主机侧单测(`watchdog/`、`sys_clock/`、`cfg_file/`、`phasor/`、`adc_input/`、 + `vol_lock/`、`time_sync/`)都需要本机装有 MSVC(VS2019/2022/2026 的 C++ 工具链); + 无则脚本报环境错误(退出码 2) ## 相关文档 @@ -88,4 +115,9 @@ - `docs/测试说明/测试开发标准流程.md` — 测试分层与门禁 - `docs/测试说明/看门狗单元测试说明.md` — 主机侧单测(用例 / 平台层 / 扩展方法) - `docs/测试说明/系统时钟单元测试说明.md` — 系统时钟换算单测(用例 / 桩 / Zeller 缺陷记录) +- `docs/测试说明/配置文件单元测试说明.md` — 配置引擎单测(内存 FS 桩 / 抓出的 P0) +- `docs/测试说明/ADC输入层单元测试说明.md` — ADC 输入层单测(逐 Rank 钉死映射) +- `docs/测试说明/相量层单元测试说明.md` — 相量层单测(傅氏系数 / 双区交换) +- `docs/测试说明/卷锁单元测试说明.md` — 卷锁单测(fail-open 不变量) +- `docs/测试说明/时间同步单元测试说明.md` — 时间同步单测(N-17 逻辑贡献者) - `docs/测试说明/CH395F_Test_Guide.md`、`FTP_Test_Guide.md` From c25baba2f1588c51400e1d828ddff083cbeb50c1 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 18:16:17 +0800 Subject: [PATCH 08/47] =?UTF-8?q?=E5=AF=B9=E9=BD=90=20L2-2=20=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E6=95=B0=E5=8F=A3=E5=BE=84=EF=BC=9A=E8=A7=84=E5=88=92?= =?UTF-8?q?=E8=A1=A8=204=20=E2=86=92=205=20=E4=B8=AA=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=EF=BC=88=E5=8F=82=E8=80=83=E9=A1=B9=E7=9B=AE=204=20=E4=B8=AA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规划 §3.2 L2-2 行:由"4 个实例:C01 基波 I/U、C02 零序基波、C03 二次、C04 五次" 改为本项目的 5 个实例(C01-IA-F1 / C02-UA-F1 / C03-UN-F1 / C04-IA-F2 / C05-IA-F5),并加注说明 4 → 5 的来历:参考项目为 4 个,本项目按 V0.1 有效通道 (UA/UN/IA)把 C01 的 I/U 拆为两行,其余顺延。 - 规划 §4.3 相量区 RAM 行:双区 + 4 实例 → 双区 + 5 实例。 - 傅氏算法详解 §7:该处的"4 个实例"标注为参考项目口径(其引用来源是 APP/CLS100_Dsp/InstCalCell.c),并指向规划 §3.2 的本项目口径。 - 相量层单测说明 §4.3:由"与规划表的一处待对齐"改为"实例数 4 → 5:规划表已对齐"。 - 规划文档补记 L2 相量层落地(sample_ring + phasor + 57 用例主机单测),并显式说明 §3.2 的 L2 各点状态仍标 ⬜ 的理由:台架 @test 闭环未做、L2-3 判据需标准源。 --- docs/测试说明/相量层单元测试说明.md | 13 +++++++++---- docs/算法/傅氏算法详解.md | 2 +- docs/算法/继电保护功能复现规划.md | 9 +++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/测试说明/相量层单元测试说明.md b/docs/测试说明/相量层单元测试说明.md index 0f4e2eee..27b19f4c 100644 --- a/docs/测试说明/相量层单元测试说明.md +++ b/docs/测试说明/相量层单元测试说明.md @@ -187,12 +187,16 @@ test/host/phasor/phasor_test.c (N-18:TPAFE 与 STM32 无共同采样触发),跨通道相量运算在本平台上不成立——本单测**不**做 任何跨通道合成断言(L2-5 受限,见 `docs/算法/继电保护功能复现规划.md` §1.6)。 -### 4.3 与规划表的一处待对齐 +### 4.3 实例数 4 → 5:规划表已对齐(2026-10-10) -`docs/算法/继电保护功能复现规划.md` §3.2 的 L2-2 行仍写作"**4 个实例**:C01 基波 I/U、 -C02 零序基波、C03 二次谐波、C04 五次谐波",而落地实现是 **5 个实例** +**参考项目**(`APP/CLS100_Dsp/InstCalCell.c`)为 **4 个实例**——C01 基波 I/U、C02 零序基波、 +C03 二次谐波、C04 五次谐波;**本项目**按 V0.1 **有效通道**(`UA`/`UN`/`IA`,见规划 §1.6: +`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开为 **5 个实例** (`C01-IA-F1` / `C02-UA-F1` / `C03-UN-F1` / `C04-IA-F2` / `C05-IA-F5`)。 -本单测按**实现**断言(实例数 == 5);规划表待同步。 + +`docs/算法/继电保护功能复现规划.md` §3.2 的 L2-2 行已于 2026-10-10 同步为 **5 个实例** +(并加注说明 4 → 5 的来历);本单测按**实现**断言(实例数 == 5),两者口径现已一致。 +`docs/算法/傅氏算法详解.md` §7 的"4 个实例"已标注为**参考项目**口径。 ### 4.4 实例表用"数据驱动"而非硬编码 @@ -208,3 +212,4 @@ C02 零序基波、C03 二次谐波、C04 五次谐波",而落地实现是 **5 |---|---| | 2026-10-10 | 创建;52 用例(TC-PH-001..022)全绿;覆盖系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 实例层 / 滑动窗 / 双区交换 | | 2026-10-10 | 补 TC-PH-030..034(`phasor_zero` 四字段清零 + `phasor_init` 双区清零 / 使用区与就绪标志复位 / 幂等)→ 57 用例;函数级覆盖审计 `App/algo` 三源共 20 函数、0 未覆盖;挂 pre-commit 钩子 `phasor-unit-test` | +| 2026-10-10 | §4.3 由"与规划表的一处待对齐"改为"实例数 4 → 5:规划表已对齐"——规划 §3.2 的 L2-2 行同步为 **5 个实例**(并加注 4 → 5 的来历),`傅氏算法详解.md` §7 的"4 个实例"标注为**参考项目**口径 | diff --git a/docs/算法/傅氏算法详解.md b/docs/算法/傅氏算法详解.md index 668700b9..b6be1102 100644 --- a/docs/算法/傅氏算法详解.md +++ b/docs/算法/傅氏算法详解.md @@ -160,7 +160,7 @@ Fun_VectorAmp(ptVecSave); // fAmSQ = √(fRe²+fIm²) = 有效 - **任意次谐波**:系数索引步进量从 1 改为 $k$ 即算 $k$ 次谐波相量 $f_{Re}(k)=\sum x(n)\sin\!\big(\tfrac{2\pi k n}{N}\big)\tfrac{\sqrt2}{N}$,同一循环复用于 2 次/5 次谐波(涌流判别、选线);遥测对 $k=1\sim11$ 全算再方和根 $U_{\text{rms}}=\sqrt{\sum_{k=1}^{11}U_k^2}$; - **双区切换**:结果写入向量区 `tVectorRly[2][N]` 的计算区,算完原子交换"计算区/使用区"指针,保护元件读使用区,无锁并发; -- **4 个实例**:C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波(`APP/CLS100_Dsp/InstCalCell.c`)。 +- **4 个实例**(**参考项目**口径):C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波(`APP/CLS100_Dsp/InstCalCell.c`)。本项目按 V0.1 有效通道展开为 **5 个**(`C01-IA-F1`/`C02-UA-F1`/`C03-UN-F1`/`C04-IA-F2`/`C05-IA-F5`),见 `docs/算法/继电保护功能复现规划.md` §3.2。 ## 8. 全周波 vs 半周波 diff --git a/docs/算法/继电保护功能复现规划.md b/docs/算法/继电保护功能复现规划.md index f0c425dd..b4bfa663 100644 --- a/docs/算法/继电保护功能复现规划.md +++ b/docs/算法/继电保护功能复现规划.md @@ -255,7 +255,7 @@ | # | 功能点 | 关键参数 | 完成判据 | 状态 | | --- | --- | --- | --- | --- | | L2-1 | 全周傅氏系数表 | `sin(2πk/N)·(√2/N)`、`cos(2πk/N)·(√2/N)`,N=20 | 系数表与 `docs/算法/傅氏算法详解.md` §2.3 一致 | ⬜ | -| L2-2 | 基波相量实例 | 4 个实例:C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波 | 每实例输出 `{fRe, fIm, fAm, fAmSQ}` | ⬜ | +| L2-2 | 基波相量实例 | **5 个实例**(参考项目 4 个,见下方注):`C01-IA-F1` / `C02-UA-F1` / `C03-UN-F1` / `C04-IA-F2` / `C05-IA-F5` | 每实例输出 `{fRe, fIm, fAm, fAmSQ}` | ⬜ | | L2-3 | 相量有效值输出 | `fAmSQ = √(fRe²+fIm²)` 直接为**有效值**(系数已折算) | 标准源读数 = 设定有效值 | ⬜ | | L2-4 | 相位输出 | `φ = atan2(fIm, fRe)` | 相位误差 ≤ 目标值 | ⬜ | | L2-5 | 线/负序/零序合成 | 线电压 `Uab=Ua−Ub` 等;负序 `3U2`/`3I2`;零序 `3U0`/`3I0` | 合成相量可复用于保护元件(**V0.1 仅同转换器内可合成**,见 §1.6) | ⬜ | @@ -265,6 +265,10 @@ > **双轨有效值**:保护要"基波有效值 + 相位"(抗衰减直流、抗谐波误动);计量/遥测要"真有效值"(谐波方和根)。两者**不可混用**——这是参考系统 `CN_ANA_VEC_SQRT=FALSE` 与遥测谐波轨并存的根本原因。 +> **L2-2 实例数 4 → 5 的来历**:参考项目(`APP/CLS100_Dsp/InstCalCell.c`)为 **4 个实例**——C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`,见 §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开:C01 的"I/U"拆为两行(`C01-IA-F1` 电流 / `C02-UA-F1` 电压),其余顺延为 `C03-UN-F1` 零序、`C04-IA-F2` 二次、`C05-IA-F5` 五次。实现见 `App/algo/phasor.c` 的 `k_inst[]`;判据按 **5** 个实例断言(见 `docs/测试说明/相量层单元测试说明.md` §2.6)。 + +> **本表状态列口径**:L2-1~L2-7 的**实现与主机单测已完成**(`App/algo/phasor.c` + `App/algo/sample_ring.c`;`test/host/phasor/` 57 用例全绿),但状态**仍标 ⬜**——**台架 `@test` 闭环未做**,且 L2-3 的完成判据"标准源读数 = 设定有效值"在无信号源台架上不可达(同 §7.1 无示波器口径)。待台架闭环后统一置 ✅。 + ### 3.3 L3 保护元件 采用参考系统的**统一接口模式**:每元件一个 `tagPRS_XXX` 配置结构(定值号、模拟通道号、KI/KO、标志位、校验和)+ `Init` / `Main`(判据)/ 出口函数。 @@ -353,7 +357,7 @@ | 项 | 估算 | 说明 | | --- | --- | --- | | 傅氏乘加 | 20 点 × 2(sin/cos)× 相量数 / 拍 | 参考用暴力法(CPU 余量足够);本项目 168 MHz + FPU 需实测 | -| 相量区 RAM | `tVectorRly[2][N] × sizeof(相量)` | 双区 + 4 实例 | +| 相量区 RAM | `tVectorRly[2][N] × sizeof(相量)` | 双区 + **5 实例**(本项目;参考项目 4 实例) | | 采样缓冲 RAM | 40 点/通道 × 通道数 × 字宽 | 双缓冲 2 周波 | | 定值区 RAM | 元件数 × 定值项 × 字宽 | 可由 cfg_file 载入 | | 任务栈 | `adcTask` / `protectTask` 待核算 | 建任务前后各跑 `scripts/stack_report.py` | @@ -595,3 +599,4 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-10 | **B1 落地:ADC 采集链路实现** | `App/task/adc_task.c` 由空桩转为真实消费者——每 1 ms 触发 `tpafe5160_start_conv_irq()` + `tpafe5160_snapshot()` 原子取数 + 帧统计 + 每 10 s 心跳日志;`Src/freertos.c` 手工创建 `adcTask`(512 words / Normal,落 `USER CODE` 保护区)+ 纳入 IWDG 必需掩码(`TASK_WD_ADC`,`0x37`→`0x77`)。§1.4 采样软件行、§1.4 依赖表 B1 行、§3.2 任务表(L1-1/L1-2)、§4 栈预算行、§7.1 P0 行同步更新。⚠ 本阶段为**任务周期软触发**(`osDelayUntil`);硬件定时器 1 kHz 触发为后续。同日 `@test` 实机闭环 PASS(`frame_seq` 严格 1000 Hz 递增、`idle=0`、`frst_err=0`、栈峰值 496/2048) | | 2026-10-10 | **1 kHz 硬件定时器触发(TIM6)** | 采样节拍由任务周期软触发改为 **TIM6 更新中断(1 kHz)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 加 TIM6 分支触发 `tpafe5160_start_conv_irq()`(`USER CODE` 区);`app_main.c` 用 `HAL_TIM_Base_Start_IT(&htim6)` 替换单次触发(在 `tpafe5160_init()` 之后);`adc_task.c` 退化为**纯消费者**(1 ms 轮询仅作消费节奏)。§1.2 采样软件行、§3.2 L1-1 同步。详见 `docs/子系统设计/ADC采样节拍设计说明.md` | | 2026-10-10 | **L1-3/L1-4 落地:STM32 ADC1 扫描 + 语义映射** | 新增 `App/algo/adc_input.c/h`(L1 采集层):① `k_adc_map[]` **语义通道表**(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_desc()`/`adc_input_raw()` **按语义取数**;② `stm32_scan()` STM32 ADC1 9 通道软件触发顺序扫描(`HAL_ADC_Start` + 逐通道 `PollForConversion`/`GetValue`);③ `adc_input_poll()` 统一"TPAFE 快照 + STM32 扫描"。`adc_task.c` 取数改走本层(首帧日志补全 8 路 + 温度、心跳增 `adc1_rc`/`adc1_err`);`app_main.c` 在 `tpafe5160_init()` 后调 `adc_input_init()`(含**映射自检**:STM32 映射通道必落在扫描序列内,失败记 `SYSLOG_SELFTEST`);Keil 工程挂 `adc_input.c` 并加 `../App/algo` 包含路径。§1.2 采样软件行、§3.1 L1-3/L1-4 同步。`@build` 0 错 0 警;台架 `@test` PASS(`ADC input layer init: OK`、`adc1_rc=0`/`adc1_err=0`、`seq` 仍 1000.0 Hz、`adc` 栈峰值 528→584 B、回读一致、零 `[ERR]`/HardFault),证据 `测试说明/boot_log_data/20261010_prod_adc_l1_boot_log.txt` | +| 2026-10-10 | **L2 相量层落地 + L2-2 实例数口径对齐** | 新增 `App/algo/sample_ring.c/h`(L2 输入:按语义通道保存 2 周波 = 40 点环缓冲,整帧写后统一推进写指针)与 `App/algo/phasor.c/h`(L2 核心:全周傅氏单频 DFT,N = 20;系数表**离线预乘 √2/N** → `fAmSQ` 直接为**有效值**;计算区/使用区**双区原子切换**;实例表 `k_inst[]` 数据驱动);主机侧单测 `test/host/phasor/` **57 用例全绿**(`phasor-unit-test` 钩子)。**§3.2 L2-2 行口径对齐**:参考项目为 **4 个实例**,本项目按 V0.1 有效通道展开为 **5 个**(`C01-IA-F1`/`C02-UA-F1`/`C03-UN-F1`/`C04-IA-F2`/`C05-IA-F5`),已加注说明 4 → 5 的来历;§4.3 相量区 RAM 行同步为 5 实例。⚠ §3.2 的 L2 各点状态**仍标 ⬜**——实现与主机单测已完成,但**台架 `@test` 闭环未做**,且 L2-3 的完成判据(标准源读数 = 设定有效值)在无信号源台架上不可达,故不置 ✅(口径见 §3.2 表下注) | From 28bd391035ea488705d1378bfb740a0708651d15 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 18:24:06 +0800 Subject: [PATCH 09/47] =?UTF-8?q?=E9=97=AE=E9=A2=98=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=9AUI=20=E4=BB=BB=E5=8A=A1=E7=9C=8B=E9=97=A8=E7=8B=97?= =?UTF-8?q?=E5=81=9C=E6=91=86=E5=BD=92=E5=9B=A0=EF=BC=88Task=20#32?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/问题记录/UI任务看门狗停摆分析.md,并在 docs/README.md §7.3 登记。 - 否掉"CPU 负载饱和":实测 adcTask 占空比 10%~20%(ADC1 扫描约 67us + 压环与 相量约 10~15us + 超时忙等),且日志 ring 严格 +10000/10s(= 1000 次/秒) 反证 adcTask 每拍都在 osDelayUntil 里睡满、没有空转。 - 定位真因:uiTask 是 required_mask 六个任务里唯一的 osPriorityBelowNormal (其余五个全 Normal,CMSIS 包装直接把优先级赋给 uxPriority),且其心跳必须 先经 ui_net_sink_poll -> net_accept -> osMessageQueuePut + xTaskNotifyWait (portMAX_DELAY) 绕 netTask 一次往返之后才发出。失败日志只缺 bit4(UI)、 其余任务心跳全在,与"非全局饥饿"一致。 - 另发现一个独立正确性缺陷:adc_task 在 r == 0(TPAFE 无新帧)时仍压环 + phasor_run;日志 ring = frames + idle 精确吻合(10001 = 9957+44)。会把重复 采样点塞进 20 点 DFT 窗,造成约 3% 的等效采样率偏移(系统性误差)。 - 给出 P0(压环条件、osDelayUntil 重同步)/ P1(优先级、解耦心跳、ADC 去忙等) / P2(观测)分层处置,并建议先启用现成的 TEST_LATENCY_PROBE 测量再定方案。 - 本文只做归因与方案建议,未改动任何生产代码。 --- docs/README.md | 1 + docs/问题记录/UI任务看门狗停摆分析.md | 267 ++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 docs/问题记录/UI任务看门狗停摆分析.md diff --git a/docs/README.md b/docs/README.md index 84fc8179..43461feb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -186,6 +186,7 @@ docs/ | [`GD5F2GQ5UE_Trap_Records.md`](问题记录/GD5F2GQ5UE_Trap_Records.md) | GD5F2GQ5UE SPI NAND 陷阱台账 | | [`SD2506_Trap_Records.md`](问题记录/SD2506_Trap_Records.md) | SD2506 RTC 陷阱台账 | | [`错误码传播审计.md`](问题记录/错误码传播审计.md) | 错误码传播缺陷审计(9 类模式 × 子系统) | +| [`UI任务看门狗停摆分析.md`](问题记录/UI任务看门狗停摆分析.md) | **Task #32 归因**:uiTask 看门狗停摆(L2 引入的间歇性回归)——否掉"CPU 饱和",定位为**优先级饥饿 + 心跳依赖 netTask 往返**;含量化算式、独立缺陷(无新帧仍压环)与 P0/P1/P2 处置建议 | --- diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md new file mode 100644 index 00000000..fbf621ac --- /dev/null +++ b/docs/问题记录/UI任务看门狗停摆分析.md @@ -0,0 +1,267 @@ +# UI 任务看门狗停摆分析(Task #32) + +> **对象**:`[ERR][WD] task stall: mask=0x00000067 req=0x00000077`(缺 bit4 = `TASK_WD_UI`) +> → 连续 8 次(800ms)缺席 → 停喂 IWDG → 复位。 +> **背景**:L2 相量层落地(commit `3762c03`)后**新出现**的间歇性回归:L2 版 3/10 次运行失败, +> 改前基线(`git stash` 重建)0/6。 +> **状态**:**分析完成,处置待确认**(2026-10-10)。本文只做归因,未改动生产代码。 +> **关联**: +> [`../待处理内容.md`](../待处理内容.md)(**N-17** 心跳缺口登记)、 +> [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、 +> [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md)(1 kHz 节拍)、 +> [`../测试说明/实机测试实施指南.md`](../测试说明/实机测试实施指南.md)(停喂取证机制)、 +> [`../../AGENTS.md`](../../AGENTS.md)(看门狗铁律) + +--- + +## 1. 结论速览 + +**用户假设"计算量太大、CPU 负载满了"——方向对(L2 的 CPU 开销确实是触发因素),但结论不成立。** + +实测占空比约 **10%~20%**,距饱和很远(§3 有算式与日志反证)。真正的失效是 +**优先级饥饿 + 心跳路径依赖另一个任务**: + +1. `uiTask` 是 `required_mask` 六个任务里**唯一的 `osPriorityBelowNormal`**(其余五个全是 + `osPriorityNormal`)。FreeRTOS 下**就绪的 Normal 任务永远抢占 BelowNormal 任务**, + 时间片轮转只对**同优先级**生效 → uiTask 只能在"五个 Normal 任务同时阻塞"的缝隙里运行。 +2. `uiTask` 的心跳**不是自产的**:它每轮先调 `ui_net_sink_poll()` → `net_accept()` → + `osMessageQueuePut` + **`xTaskNotifyWait(portMAX_DELAY)`**,即**绕 `netTask` 走一次往返**, + 之后才 `task_wd_beat(TASK_WD_UI)`。**netTask 慢一拍,UI 的心跳就晚一拍。** + +**决定性证据**:失败时掩码 `0x67` 只缺 UI 一位,其余五个任务(default / net / ftp / timeSync / adc) +的心跳**全部正常**。若是 CPU 饱和,Normal 级的 netTask/ftpTask/adcTask 会**一起**掉线——它们没有。 + +--- + +## 2. 证据:失败日志时间线 + +唯一留存的失败日志:`.dsh/skills/serial-assistant/scripts/state/serial.log`(2026-10-10 17:36)。 +(其余失败运行未归档,`docs/测试说明/boot_log_data/` 只存了通过的两次。) + +| 时刻(抓包侧) | 事件 | +|---|---| +| 17:36:12.551 | 内核启动;`reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` | +| 17:36:12.813 | `phasor layer init: OK (inst=5)` | +| 17:36:17.002 | `[FS] FATFS mounted`(距 NAND FTL ready 17:36:12.897 **约 4.1 s**) | +| 17:36:17.196 | `MISS start snap=0x00000067 req=0x00000077 t=4756` | +| 17:36:17.206 | `[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms` | +| 17:36:17.206 | `UI task: started, wait net ready` | +| 17:36:17.868 | `[STACK] ui peak_used=488/16384`(UI 尚未渲染) | +| 17:36:18.219 | `UI task: net ready, init port` → 菜单/待机屏初始化 | +| 17:36:18.343 | **`UI net sink: started (port 7003)`** ← **UI 的最后一条日志** | +| 17:36:18.343 | `MISS start snap=0x00000067 req=0x00000077 t=5904` | +| 17:36:19.902 | `[STACK] ui peak_used=4052/16384`(LVGL 开始渲染) | +| 17:36:22.850 | `[ADC_TASK] frames=9957 idle=44 skip=0 seq=10005 frst_err=0 adc1_rc=0 adc1_err=0 ring=10001` | +| 17:36:23.027 | `MISS start snap=0x00000067 req=0x00000077 t=10585` ← **最后一轮 episode 起点** | +| 17:36:23.743 | `STALL snap=0x00000067 req=0x00000077 h=00000067 ×8 mg=330 cf=0 seg=1` | +| 17:36:23.826/.837 | `[ERR][WD] task stall: mask=0x00000067 req=0x00000077 miss=11 / miss=12` | +| 17:36:23.951 | `[STACK] ui peak_used=4052/16384`(与 19.902 相同 → UI 此后**没有推进过**) | +| 17:36:32.855 | `frames=19352 idle=649 skip=0 seq=20005 frst_err=0 adc1_rc=0 adc1_err=288 ring=20001` | +| 17:36:34.981 | 再次启动:`reset cause: software (NVIC_SystemReset)` | + +**读法说明**(`App/util/task_watchdog.c:434`):`t=` 是毫秒计数;`miss=` 是**累计**缺席次数 +(`s_miss_total`),不是连续次数;连续次数 `s_miss_streak` 饱和在 `WD_MISS_TOLERANCE=8`。 +故 `t=10585` 起算、`t≈11385` 达 8 次 → **缺口恰为 800ms**,与 `miss_tol=800ms` 一致。 + +**关键观察**: + +- UI 的最后一条日志是"推流已启动",**之后进入主循环就再没有任何心跳**(`ui peak_used` 冻结在 4052)。 +- 同时段 adcTask、defaultTask(`[STACK]`)、net/ftp/timeSync 全部活着 → **非全局饥饿**。 +- `ui peak_used=4052/16384` → UI 栈余量 75%,**排除 UI 栈溢出**。 + +--- + +## 3. 量化:CPU 到底占了多少(回答"负载满了") + +### 3.1 逐项算式 + +| 项 | 算式 | 结果(@168 MHz,1 ms = 168 000 周期) | +|---|---|---| +| ADC1 顺序扫描 | `ADC_CLOCK_SYNC_PCLK_DIV4`(84 MHz / 4 = 21 MHz);每通道 (144 + 12) 周期 = 156/21 MHz ≈ 7.4 µs × **9 通道** | **≈ 67 µs** | +| TPAFE 原子快照 + 9 路 `adc_input_raw()` | 临界区拷贝 8 值 + 9 次查表 | ≈ 10~20 µs | +| 采样环压帧 + `phasor_run()` | 5 实例 × (20 点窗拷贝 + 20 次取模 + 40 次浮点乘加 + `sqrtf`) ≈ 400~500 周期/实例 | **≈ 10~15 µs** | +| 其中:整数取模 | 窗 100 次(`% 40`,常量 → 魔数乘)+ 计算 100 次(`% n`,**运行期变量 → 走 UDIV/`__aeabi_uidivmod`**) | ≈ 3~7 µs(**占比 0.3%~0.7%**) | +| **adcTask 单拍合计** | 上列之和 | **≈ 0.10~0.13 ms → 占空比 10%~13%** | + +**含 ADC 超时的最坏情形**:日志 `adc1_err=288`(10 s 窗口)= 288 次超时 × `ADC_INPUT_POLL_TIMEOUT_MS=2` ms += **576 ms 额外忙等 / 10 s → +5.8%**。故 adcTask 最坏约 **16%~19%**。 + +### 3.2 结论 + +**合计 10%~20%,不是"负载满了"。** 按 1 ms 节拍算,adcTask 每拍仍能睡约 0.87 ms。 + +### 3.3 日志反证(有力) + +`ring` 计数在两次心跳间严格 **+10000 / 10 s = 恰好 1000 次/秒** +(`10001 → 20001`,`9957 → 19352` 同理)。 + +- 若 adcTask **失去阻塞**(空转),其循环速率 = 1/单拍耗时。按 0.11 ms 的单拍耗时算, + 空转应为 **约 9000 次/秒** → `ring` 会涨 ~90000/10 s。 +- 实测恰好 1000 次/秒 → **adcTask 确实每拍都在 `osDelayUntil` 里睡满**,没有空转。 + +**因此:在失败窗口内,adcTask 不是 CPU 独占者。** 这同时否掉了"计算量把 CPU 吃满"的直接说法。 + +--- + +## 4. 三个结构性脆弱点(均已从代码证实) + +### 4.1 uiTask 优先级垫底(**这是失效能被"定点打击"的根因**) + +`Src/freertos.c` 的任务属性 + `Middlewares/.../CMSIS_RTOS_V2/cmsis_os2.c:444` +(`prio = (UBaseType_t)attr->priority`,FreeRTOS 中**数值越大优先级越高**): + +| 任务 | `osThreadAttr_t.priority` | uxPriority | 在 `required_mask` | +|---|---|---|---| +| defaultTask | `osPriorityNormal` | 24 | ✅ | +| netTask | `osPriorityNormal` | 24 | ✅ | +| ftpTask | `osPriorityNormal` | 24 | ✅ | +| timeSyncTask | `osPriorityNormal` | 24 | ✅ | +| adcTask | `osPriorityNormal` | 24 | ✅ | +| **uiTask** | **`osPriorityBelowNormal`** | **16** | ✅ | +| testTask | `osPriorityBelowNormal` | 16 | ✖(产品构建不创建) | + +→ **uiTask 是必需掩码里唯一的低优先级任务。** 只要有**任何一个** Normal 任务持续就绪, +uiTask 就**完全拿不到 CPU**;`configUSE_TIME_SLICING=1` 只保护**同优先级**之间的公平, +对它无效。 + +### 4.2 UI 心跳依赖 `netTask` 往返(**为什么"只有 UI 掉"**) + +`App/task/ui_task.c:117` 主循环: + +```c +while (1) { + ui_port_step(10); /* LVGL:lv_tick_inc + lv_timer_handler(同步渲染) */ + ui_net_sink_poll(); /* ← 阻塞点 */ + task_wd_beat(TASK_WD_UI);/* ← 心跳在这之后才发 */ + osDelay(10); +} +``` + +`Drivers/BSP/UI/ui_net_sink.c:457` 的 `ui_net_sink_poll()` 在**无远端客户端时**(台架常态) +每轮都走 `net_accept()`;而 `Drivers/BSP/NET/net_socket.c:1222` 的 `net_accept()`: + +```c +if (osMessageQueuePut(netMsgQueueHandle, &msg, 0, osWaitForever) != osOK) { ... } /* 队列深 8,满则永久阻塞 */ +msg.result = net_wait_result("acc", sockfd); /* 等 netTask 回执 */ +``` + +`net_wait_result()`(`net_socket.c:851`)末句是 **`xTaskNotifyWait(..., portMAX_DELAY)`——无限期阻塞**。 + +→ **UI 的心跳周期 = LVGL 渲染 + 一次 `netTask` 往返 + 10 ms。** `netTask` 被延迟多久, +UI 的心跳就晚多久。**这是 UI 独有的依赖,其它任务没有**——正好解释"只有 UI 位缺失"。 + +### 4.3 adcTask 的截止期一旦错过就**不可恢复** + +`App/task/adc_task.c:243`: + +```c +next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ +(void)osDelayUntil(next); /* ← 返回值被丢弃 */ +``` + +`osDelayUntil` 在**目标时刻已过**时返回 `osErrorParameter`(`cmsis_os2.c:898`)。 +若单拍耗时 > 1 ms,`next` 相对当前时刻**永久落后** → `osDelayUntil` 立刻返回 → +**该任务从此不再阻塞**,以 Normal 优先级空转,直到某个"快拍"把差额追平。 +(单次 2 ms 的慢拍只会多跑一拍就追平,见 §5 的 `ring` 反证;**连续**慢拍才会锁死。) +**返回值是现成的重同步钩子,但被丢弃了。** + +### 4.4 ADC 超时是"2 ms 纯忙等",不阻塞、不让出 + +`App/algo/adc_input.c:21` `ADC_INPUT_POLL_TIMEOUT_MS = 2U`,配合 +`HAL_ADC_PollForConversion()`(`stm32f4xx_hal_adc.c:882`)里的 +`while (!EOC) { if ((HAL_GetTick() - tickstart) > Timeout) return HAL_TIMEOUT; }` +—— **一个通道超时 = 2 ms 不让出 CPU 的自旋**,且发生在 Normal 优先级。 +9 通道全超时最坏 = **18 ms 连占**。这是现成的饥饿源。 + +--- + +## 5. 顺带发现的一个**独立**缺陷:无新帧仍然压环 + +`App/task/adc_task.c:140-176`:`r == 0`(TPAFE 无新帧)时**仍然**执行 +`sample_ring_push_frame()` + `phasor_run()`——只有 `r == -1` 才不压。 + +日志精确印证(`ring = frames + idle`): + +| 窗口 | frames | idle | ring | 校验 | +|---|---|---|---|---| +| 09:36:21 | 9957 | 44 | 10001 | 9957 + 44 = 10001 ✓ | +| 09:36:31 | 19352 | 649 | 20001 | 19352 + 649 = 20001 ✓ | + +**后果**:把**上一拍的重复采样值**塞进 20 点 DFT 窗 → 等效采样间隔被拉长。 +本例 `idle` 占 **3.2%** → 窗长从 20.0 ms 漂到约 20.6 ms → 对 50 Hz 信号相当于 +**约 3% 的频率失配** → 幅值/相位**系统性误差**(不是随机噪声)。 + +这与本次停摆**无关**(不增加多少 CPU),但属**L2 的真实正确性缺陷**,应一并修: +**只在 `r == 1` 时压帧**。 + +--- + +## 6. 为什么"只有 UI"、"为什么间歇" + +- **只有 UI**:UI 是唯一低优先级(§4.1)**且**心跳依赖别的任务(§4.2)的必需任务。 + 其它五个任务只要 100 ms 内被调度到一次就能喂上位,而它们全在 Normal 级,彼此时间片轮转保障。 +- **间歇**:需要一次"某个 Normal 任务连续占用 ≥800 ms"的巧合。候选(见 §7): + `netTask` 回执延迟、`defaultTask` 的长段(syslog → NAND/FTL 写、配置保存)、 + adcTask 的超时突发。 +- **为什么 L2 才暴露**:L2 让 adcTask 每拍多干 10~15 µs 并**引入了 `adc1_err` 超时** + (L1 阶段台架 `adc1_err=0`,L2 阶段出现 288/10 s)→ Normal 级的可用余量被吃掉, + uiTask 的缝隙被挤到 0。**这正是"负载上去以后最先倒下的那块短板"。** + +--- + +## 7. 尚未证实、需要测量的部分(诚实边界) + +1. **800 ms 的具体占用者未锁定。** 本次日志只能证明"UI 被饿/被拖了 800 ms", + 不能区分是 + (a) `netTask` 回执延迟(§4.2),还是 + (b) `defaultTask` 长段(syslog NAND 写 / 配置保存),还是 + (c) adcTask 超时突发(§4.4)。 +2. **`adc1_err=288` 的自身根因未查。** 2 ms 超时对 7.4 µs 的转换而言异常巨大, + 指向 ADC 被反复 `Start/Stop` 或 EOC/OVR 状态异常;需专项测量,不要与本条混谈。 +3. **复位归因存疑。** 日志报 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)`, + 即 `SFTRSTF|PINRSTF`;而按设计"停喂 → IWDG 自然溢出"应留下 `IWDGRSTF`。 + 本次复位可能来自**测试台架**(ST-LINK 拉 NRST → `PINRSTF`)而非看门狗,需与台架流程对齐。 +4. **`[STACK]` 周期从 2.0 s 拉到 4.05 s**(17:36:19.902 → 23.951,标称 `STACK_PROBE_PERIOD_MS=2000`) + 值得注意,但**不能作为证据**:控制台缓冲满时会**丢行**,该报告也可能只是被丢了。 +5. **`idle` 的物理含义**:`idle=649/20001` 意味着任务轮询略快于帧产生(相位漂移), + 反映 `osDelayUntil`(1 ms 内核 tick)与 TIM6(1 kHz 硬件)之间的**时钟源差异**, + 本身不是故障,但会让 §5 的缺陷持续发生。 + +--- + +## 8. 处置建议(分层,**待确认后执行**) + +### P0 — 缺陷修复(低风险、可独立验证) +1. **§5 压环条件**:`adc_task_func` 改为**仅在 `r == 1` 时**压帧 + `phasor_run()`, + 消除重复采样污染相量窗。 +2. **§4.3 截止期重同步**:`if (osDelayUntil(next) != osOK) { next = osKernelGetTickCount(); }` + —— 让"落后"变成**一次性重同步**,而不是永久空转。 + +### P1 — 结构性(真正消除饥饿,需择一或组合) +3. **把 uiTask 提到 `osPriorityNormal`**(一行改动)。代价:UI 与其它任务平等竞争, + LVGL 单轮 10 ms 量级可能给 netTask 增加抖动;收益:**结构上不可能再被无限期饿死**。 +4. **解开"心跳依赖别的任务"**(§4.2):`ui_task_func` 主循环把 `task_wd_beat(TASK_WD_UI)` + 提到 `ui_net_sink_poll()` **之前**;或给 `net_accept()` 一条**非阻塞**查询路径 + (`NET_MSG_DONTWAIT` 语义)供 UI 推流使用。**这条即使不动优先级也应做**—— + 否则"UI 心跳"实际测的是"netTask 响应性"。 +5. **给 adcTask 的单拍耗时设硬上界**(§4.4):ADC 扫描改 **DMA / 中断驱动**(彻底去掉忙等), + 或至少把单通道超时压到不会吃掉整拍的量级,并在超时时**整拍跳过**而非继续自旋。 + +### P2 — 观测(先能看见,再谈修) +6. 启用现成的 `TEST_LATENCY_PROBE`(`App/task/ui_task.c:109` 的 `ui_loop` 段)→ + 直接得到 UI 循环周期分布,一次性区分"饿死 vs 被拖"。 +7. 新增两个埋点:`adcTask` **单拍耗时**最坏值、`ui_net_sink_poll()` **往返耗时**最坏值。 +8. 停摆取证再加一项:把 `s_miss_hist` 与**各任务在本窗口的调用计数**一起落 `wdev.log`, + 直接指认占用者(现证据只到位图)。 + +### 建议执行顺序 +**P2-6/7 先测(一次 @test 即可拿到区分性数据)→ 按数据定 P1 的方案 → P0 顺路一起修 → 复跑 10 次确认 0/10。** +不建议直接上 P1-3(提优先级)了事:那会**掩盖** §4.2 与 §4.4 两个真实缺陷。 + +--- + +## 变更记录 + +| 日期 | 变更 | +|---|---| +| 2026-10-10 | 创建。基于唯一留存失败日志(`serial.log` @17:36)+ 代码走查完成归因:否掉"CPU 饱和"(实测占空比 10%~20%,`ring` 速率反证无空转),定位为**优先级饥饿(uiTask 唯一 BelowNormal)+ UI 心跳依赖 netTask 往返**;另发现"无新帧仍压环"的独立正确性缺陷;给出 P0/P1/P2 分层处置与"先测量后修"的推荐顺序 | From 3b1d0308d8bce12df1343e90a868f82caca4f67a Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 19:06:06 +0800 Subject: [PATCH 10/47] =?UTF-8?q?fix(ui):=20Task=20#32=20P0/P1/P2=20?= =?UTF-8?q?=E2=80=94=20=E8=A7=A3=E8=80=A6=20UI=20=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E3=80=81=E4=BF=AE=20adc=20=E5=8E=8B=E7=8E=AF=E4=B8=8E=E6=88=AA?= =?UTF-8?q?=E6=AD=A2=E6=9C=9F=E3=80=81=E5=8A=A0=E6=B5=8B=E9=87=8F=E6=8E=A2?= =?UTF-8?q?=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #32 UI 看门狗停摆(docs/问题记录/UI任务看门狗停摆分析.md) P0 adc_task.c(已上板验证): - 仅 r==1 才压环+重算;上板印证 ring==frames(修前 ring=frames+idle) - osDelayUntil 返回值重同步,落后拉回基准,解除永久空转死锁 P1-4a ui_task.c v1.3: task_wd_beat 提到 ui_net_sink_poll 之前 → 心跳测"UI 活着"而非"netTask 响应",解开 §4.2 心跳依赖 → 未上 P1-3(提优先级会掩盖 §4.2/§4.4);P1-5 暂缓 P2 新增 App/util/task_prof.c/.h: DWT 常开探针,测各任务周期/耗时/ 推流往返/ADC 轮询/控制台排空/syslog 落盘/net_poll 最坏值;心跳位图 1→0 边沿裸 UART 直发 [PROF]/[PROF2] 快照;uvprojx 已登记 doc: 分析 doc §7 补良性启动 episode 与复位非诊断项;§8 加处置落地 验证: P0+P1 累计 32 次上板启动 0/32 复现(原 L2 基线 3/10),构建 0/0 --- App/task/adc_task.c | 44 +++- App/task/default_task.c | 36 +++ App/task/net_task.c | 8 + App/task/ui_task.c | 46 +++- App/util/task_prof.c | 300 +++++++++++++++++++++++++ App/util/task_prof.h | 140 ++++++++++++ MDK-ARM/STM32F407-Demo.uvprojx | 5 + docs/内存管理/RAM使用分析与优化方案.md | 6 +- docs/问题记录/UI任务看门狗停摆分析.md | 52 ++++- 9 files changed, 623 insertions(+), 14 deletions(-) create mode 100644 App/util/task_prof.c create mode 100644 App/util/task_prof.h diff --git a/App/task/adc_task.c b/App/task/adc_task.c index 1043448b..39791600 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -46,6 +46,14 @@ * ⚠ L1-5 通道标定未实现(缺标准源)→ 暂以**原始码值** * 充作工程量(系数 = 1),故相量读数只证明**链路通**, * 不可作幅值精度判据(同 L1-3 的替代口径)。 + * v2.5 2026-10-10 王建锋 Task #32 的 P2 测量埋点:单拍加常开耗时探针 + * (task_prof ADC_BEAT),用于判定本任务是否超拍 + * (名义节拍 1 ms = 168000 周期)而挤占低优先级任务 + * v2.6 2026-10-10 王建锋 Task #32 的 P0 修复(两处): + * §5 无新帧仍压环 → 改为**仅 r == 1 时**压环 + 重算 + * (消除重复采样污染 20 点 DFT 窗的系统性误差); + * §4.3 osDelayUntil 返回值被丢弃 → 落后时一次性重同步 + * (原写法会永久落后 → 不再阻塞 → 空转抢占 uiTask) */ #include "adc_task.h" @@ -56,6 +64,7 @@ #include "tpafe5160.h" #include "task_watchdog.h" #include "console_log.h" +#include "task_prof.h" /* P2 常开探针:单拍耗时(Task #32) */ #define DBG_TAG "[ADC_TASK]" #include "dbg_log.h" @@ -134,10 +143,19 @@ void adc_task_func(void *arg) { for (;;) { int r; + /* P2 探针:单拍起点。名义节拍 1 ms = 168000 周期;若实测远超, + * 本任务(osPriorityNormal)会持续抢占 uiTask(osPriorityBelowNormal), + * 是"UI 心跳缺席"的候选占用者之一 */ + task_prof_enter(TASK_PROF_ADC_BEAT); + /* 1) 采集一拍:L1 层 adc_input_poll() = TPAFE 原子快照 + STM32 ADC1 顺序扫描 * (仅任务上下文;TPAFE 侧 1=有新帧 0=无新帧 -1=参数错)。 - * 采样节拍由 TIM6 更新中断提供(ISR 内触发 CONVST),本任务不触发。 */ + * 采样节拍由 TIM6 更新中断提供(ISR 内触发 CONVST),本任务不触发。 + * P2 探针:本段单独计时——ADC 超时是 2ms 不让出的忙等(HAL_ADC_ + * PollForConversion),是单拍耗时的主要来源,必须与压环/相量分开看 */ + task_prof_enter(TASK_PROF_ADC_POLL); r = adc_input_poll(); + task_prof_exit(TASK_PROF_ADC_POLL); if (r == 1) { s_frames_consumed++; } else if (r == 0) { @@ -148,9 +166,18 @@ void adc_task_func(void *arg) { /* 2) L2 输入 + 相量重算:把本拍 9 路语义值转浮点帧压入采样环,再重算全部 * 相量实例(L2-6 滑动窗:每拍以"当前时刻为终止的前一周波"整窗重算)。 + * + * ⚠ P0 修复(2026-10-10,Task #32 §5):**仅在本拍确有新帧(r == 1)时**才压环。 + * 原实现在 r == 0(TPAFE 无新帧)时**仍然**压环 + phasor_run(),只有 r == -1 + * 才跳过 —— 于是把**上一拍的重复采样值**塞进 20 点 DFT 窗,等效采样间隔被拉长。 + * 证据是日志里的恒等式 `ring = frames + idle`(09:36:21: 9957+44=10001 ✓; + * 09:36:31: 19352+649=20001 ✓)。本例 idle 占 3.2% → 窗长从 20.0 ms 漂到约 + * 20.6 ms → 对 50 Hz 信号相当于约 3% 的频率失配 → 幅值/相位**系统性**误差 + * (不是随机噪声)。修好后 `ring` 只在真实新帧上增长(ring == frames)。 + * * 任一通道读不到(尚未首次成功)→ 本拍**整帧跳过**:不向窗内注入 0 点 * (单个 0 点会显著污染 20 点窗),并计入 skip 观测。 */ - { + if (r == 1) { float frame[ADC_SEM_COUNT]; uint8_t s; uint8_t all_ok = 1u; @@ -239,8 +266,17 @@ void adc_task_func(void *arg) { } } - /* 7) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) */ + /* 7) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) + * ⚠ P0 修复(2026-10-10,Task #32 §4.3):osDelayUntil 在**目标时刻已过** + * 时返回 osErrorParameter 并立即返回(cmsis_os2.c 的 "No delay or already + * expired" 分支)。若像原实现那样只写 `(void)` 丢弃返回值,一次超拍会让 + * next 相对当前时刻**永久落后** → 本任务从此不再阻塞、以 Normal 优先级 + * 空转,直到某个"快拍"把差额追平(连续慢拍则锁死,全程抢占低优先级的 + * uiTask)。返回值是现成的重同步钩子:落后就一次性把基准拉回当前时刻。 */ + task_prof_exit(TASK_PROF_ADC_BEAT); /* P2 探针:单拍耗时结算(不含睡眠) */ next += ADC_TASK_BEAT_MS; - (void)osDelayUntil(next); + if (osDelayUntil(next) != osOK) { + next = osKernelGetTickCount(); + } } } diff --git a/App/task/default_task.c b/App/task/default_task.c index 3062f5bc..bd5b7fb6 100644 --- a/App/task/default_task.c +++ b/App/task/default_task.c @@ -19,6 +19,10 @@ * FTP 文件操作与 syslog/配置的卷访问自此串行化 * (2026-10-02 台架:并发 FTL 写 → journal 竞争 → * 本任务主循环喂狗点饥饿 → IWDG 误复位) + * v1.2 2026-10-10 王建锋 Task #32 的 P2 测量接线:主循环周期上报各任务最坏 + * 耗时(task_prof_report),并在**心跳位图出现 1→0 边沿** + * 的瞬间留一份现场快照(task_prof_report_raw,裸 UART); + * 另给控制台排空与 syslog 落盘各加一个耗时探针 */ #include "default_task.h" @@ -39,6 +43,7 @@ #define DBG_LINE_BUF_MAX 128U #include "dbg_log.h" #include "task_watchdog.h" +#include "task_prof.h" /* P2 测量:各任务最坏耗时上报(Task #32) */ #include "vol_lock.h" /* 任务句柄与属性块(CubeMX 生成全局,定义于 Src/freertos.c): @@ -185,6 +190,22 @@ void default_task_func(void *argument) { task_wd_seg(TASK_WD_SEG_POLL); /* 心跳抽样与喂狗判定(内部节流至 100ms;全员到位才喂,否则累计停喂) */ task_wd_poll(); + /* P2 测量(Task #32):某位由"到过"变为"缺席"的瞬间留一份现场快照。 + * 走裸 UART 直发——缺席窗口内环形缓冲可能积压,这两行必须出去。 + * + * ⚠ 判据用**位图边沿**,不能用 miss 累计数:`task_wd_miss_count()` 返回的是 + * 单调累计的 s_miss_total(永不清零),开机 bring-up 阶段任务尚未全部启动时 + * 就已经 >0 → 若以"归零"判恢复,则首次缺席之后再也不会触发。 + * 位图边沿(某位 1→0)天然"每个 episode 只报一次":此后 prev 也不含该位。 */ + { + static uint32_t s_prof_prev_mask; + const uint32_t mask = task_wd_last_mask(); + + if ((s_prof_prev_mask != 0u) && ((s_prof_prev_mask & ~mask) != 0u)) { + task_prof_report_raw(); + } + s_prof_prev_mask = mask; + } /* 本任务自己的心跳 */ task_wd_beat(TASK_WD_DEFAULT); /* 等待"保存网络配置"或"控制台待发":任一即醒;无事件 50ms 后返回 @@ -197,7 +218,12 @@ void default_task_func(void *argument) { } /* 控制台唯一发送点:把环形缓冲中的日志按块发出(无待发时立即返回) */ task_wd_seg(TASK_WD_SEG_DRAIN); + /* P2 探针:控制台排空单独计时。它内部**忙等** UART TXE(单次预算 + * DBG_CON_DRAIN_BUDGET_MS=250ms),且本任务为 osPriorityNormal → + * 是"某个 Normal 任务连续占用 CPU 把 UI 饿死"的头号候选 */ + task_prof_enter(TASK_PROF_DEF_DRAIN); dbg_console_drain(); + task_prof_exit(TASK_PROF_DEF_DRAIN); /* 控制台 TX 故障留痕:由本任务(syslog 唯一写者)在 drain **之外**补记 run.log。 * console_log 内部刻意不调 syslog_post —— 那会构成日志调用图成环, * 让 armlink 静态栈深在每个任务上都多叠一条日志尾巴(见 console_log.c) */ @@ -252,11 +278,21 @@ void default_task_func(void *argument) { #if !defined(TEST_SUITE_STORAGE) /* 排空运行期日志(上电/重启分类与自检故障落盘) */ task_wd_seg(TASK_WD_SEG_SYSLOG); + /* P2 探针:syslog 落盘(NAND/FTL 写,最坏数百 ms)单独计时 */ + task_prof_enter(TASK_PROF_DEF_SYSLOG); syslog_drain(); + task_prof_exit(TASK_PROF_DEF_SYSLOG); #endif /* 栈水位观测:首次 5s 后报告,此后每 2s 采样、出现更深峰值才打印 */ task_wd_seg(TASK_WD_SEG_STACK); stack_probe_poll(); + /* P2 测量(Task #32):各任务最坏耗时 / 次数 / "仍在段内"快照。 + * 内部节流(首次 5s,此后每 2s);判据: + * uisn 持续增长 → uiTask 卡在 ui_net_sink_poll 的往返里(阻塞) + * uipn 持续增长而 uisn 为 0 → 被调度饿死(本轮迟迟跑不完) + * uiw 大 → uiTask 自身渲染拖长 + * [PROF2] 的 defd/netp/adcp 谁在同一窗口抬起来 → 谁就是占用者 */ + task_prof_report(); } } diff --git a/App/task/net_task.c b/App/task/net_task.c index 67d93e09..e4f8d19e 100644 --- a/App/task/net_task.c +++ b/App/task/net_task.c @@ -18,6 +18,9 @@ * freertos.c USER CODE 区重复创建已删除 * v1.7 2026-10-02 王建锋 P1-8:初始组网失败/重建失败 → 自检记录 + 30s 周期重试 * (原实现:初始失败 = netTask 无限驻留;重建失败 = 静默丢配置) + * v1.8 2026-10-10 王建锋 Task #32 的 P2 测量埋点:net_poll() 单独计时—— + * 本任务每 ~1ms 分派一次消息队列,若驱动轮询被拉长, + * 其它任务(尤其 UI 的 net_accept 往返)的等待随之拉长 */ #include @@ -31,6 +34,7 @@ #include "sys_log.h" #include "test_config.h" /* 测试模式宏(ENABLE_HW_LAYER_TESTS);产品构建全未定义,无作用 */ #include "task_watchdog.h" +#include "task_prof.h" /* P2 探针:net_poll 耗时(Task #32) */ #define DBG_TAG "[NET_TASK]" #include "dbg_log.h" @@ -267,7 +271,11 @@ void net_task_func(void const *arg) { } } + /* P2 探针:驱动轮询单独计时。本循环每 ~1ms 一次,是 netTask 回执 + * (UI 的 net_accept 在等它)的节奏来源;此值变大 = UI 心跳被拖长 */ + task_prof_enter(TASK_PROF_NET_POLL); net_poll(); + task_prof_exit(TASK_PROF_NET_POLL); net_process_messages(); osDelay(1); } diff --git a/App/task/ui_task.c b/App/task/ui_task.c index 79174bb4..6079be99 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -6,6 +6,18 @@ * 创建日期:2026-08-30 * 修改记录: * v1.1 2026-09-14 王建锋 新增待机显示页初始化(ui_standby_init,空闲 60s 切入) + * v1.2 2026-10-10 王建锋 Task #32 的 P2 测量埋点:主循环加常开探针 + * (循环周期 / 单轮工作耗时 / ui_net_sink_poll 往返), + * 用于把"心跳缺席"拆成 饿死 / 阻塞在往返 / 自身拖长 + * 三种互斥机理(见 docs/问题记录/UI任务看门狗停摆分析.md) + * v1.3 2026-10-10 王建锋 Task #32 的 P1-4a(解耦心跳,§4.2):把 task_wd_beat + * (TASK_WD_UI) 提到 ui_net_sink_poll() **之前**。原写法心跳 + * 在推流往返之后 → 心跳周期 = 渲染 + 一次 netTask 往返 + 10ms, + * 实测 uiw≈uis≈50ms(心跳被 netTask 往返主导),心跳实际测的是 + * "netTask 响应性"而非"UI 活着"。提到往返之前后,心跳测的是 + * "UI 自己被调度到了"。注:若往返**永久**阻塞,本改仍不能救 + * (心跳只发一次)→ 真要彻底去掉依赖须走事件驱动 accept + * (NET_EVENT_CONNECTED 回调),待复现快照确认阻塞机理后再定。 */ #include "ui_task.h" @@ -22,6 +34,7 @@ #include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */ #include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */ #include "task_watchdog.h" +#include "task_prof.h" /* P2 常开探针:循环周期 / 工作耗时 / 推流往返 */ /* * 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str) @@ -114,7 +127,21 @@ void ui_task_func(void *argument) { uint8_t tm_loop_valid = 0U; #endif + /* P2 常开探针(Task #32):TEST_LATENCY_PROBE 只在 CH395F 套件构建下打开, + * 且样本消费者是 testTask(产品构建里不跑)→ 产品构建拿不到任何 UI 周期数据。 + * 故此处独立埋点,恒定生效、只写本模块的静态计数,由 defaultTask 周期上报。 */ + while (1) { + /* 循环周期用 enter/exit 测:exit 结算"上轮起点 → 本轮起点",enter 记本轮起点。 + * 首轮 exit 时尚未 enter 过 → 内部直接返回,不会拿 0 当起点。 + * 选 enter/exit 而非"自己存一个 prev"的额外收益:两次循环之间段内标志保持为 1 + * → 报告可读出"本轮已进行多久"。**这正是'uiTask 是否被饿住'的直接证据**: + * 若该值在两次报告间持续增长而 uic 不动,就是被调度饿死/阻塞,而非渲染拖长。 */ + task_prof_exit(TASK_PROF_UI_PERIOD); + task_prof_enter(TASK_PROF_UI_PERIOD); + + const uint32_t prof_t0 = task_prof_cyc(); /* 单轮工作耗时基准 */ + #if defined(TEST_LATENCY_PROBE) if (tm_loop_valid != 0U) { lat_probe_add_seg("ui_loop", lat_mark_us(&tm_loop)); @@ -123,10 +150,23 @@ void ui_task_func(void *argument) { tm_loop_valid = 1U; #endif ui_port_step(10); - ui_net_sink_poll(); - /* 心跳:ui_port_step 内含 LVGL 渲染,单轮耗时可能逼近 10ms 量级; - * 100ms 抽样窗内可容纳多轮,此埋点足够 */ + /* 心跳**先于**推流往返发出(P1-4a,Task #32 §4.2):UI 心跳测的是 + * "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后, + * 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后, + * 即使往返偶发变慢,心跳也不被其拖垮。 + * ⚠ 若往返**永久**阻塞(xTaskNotifyWait portMAX_DELAY 不返回),本改只能发 + * 一次心跳,救不了 → 彻底解耦须走事件驱动 accept,见文件头 v1.3 说明。 */ task_wd_beat(TASK_WD_UI); + /* 推流轮询段单独 enter/exit:它内部经 net_accept → net_wait_result 走到 + * xTaskNotifyWait(portMAX_DELAY) 的**无限期阻塞**。若 uiTask 永久卡在这里, + * 样本永不结算,只有 task_prof 的"仍在段内已 X us"能证明它卡在何处 */ + task_prof_enter(TASK_PROF_UI_SINK); + ui_net_sink_poll(); + task_prof_exit(TASK_PROF_UI_SINK); + + /* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */ + task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0); + osDelay(10); } } diff --git a/App/util/task_prof.c b/App/util/task_prof.c new file mode 100644 index 00000000..9cbded57 --- /dev/null +++ b/App/util/task_prof.c @@ -0,0 +1,300 @@ +/* + * 模块名称:Task Profiler(任务耗时最坏值探针)实现 + * 模块功能:接口见 task_prof.h。本文件只做三件事: + * ① 惰性使能 DWT CYCCNT 并读值;② 维护每探针的 count/max/last/glitch + * 与"在段内"标志;③ 把全部字段格式化成一整行 `[PROF]` 交给控制台单写者。 + * 适用平台:STM32F407ZGTx(仅 MCU 端) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建(Task #32 的 P2 测量埋点) + * + * 设计依据:docs/问题记录/UI任务看门狗停摆分析.md §8(P2 测量项) + */ + +#include "task_prof.h" + +#include +#include "main.h" /* DWT / CoreDebug / SystemCoreClock / HAL_GetTick() */ +#include "console_log.h" /* dbg_console_write_line / dbg_console_write_raw */ + +/* Private defines -----------------------------------------------------------*/ + +/* 报告节奏:首次延后(bring-up 深链期间不占串口),此后周期一行。 + * 2 s 与 defaultTask 的 [STACK] 报告同节奏,便于两条序列交叉比对。 */ +#define TASK_PROF_FIRST_MS 5000u +#define TASK_PROF_PERIOD_MS 2000u + +/* 合理耗时上界(周期数)。超过即判为"CYCCNT 中途被重置"造成的无符号回绕 + * (差值 ~2^32),丢弃而不污染 max。取 10 s 对应周期数:远高于真实最坏段 + * 耗时(停摆窗口 < 2.5 s 即复位),又远低于 2^32,不会误杀真实数据。 + * 168 MHz 下 10 s = 1.68e9 < 2^32,本身不溢出。 */ +#define TASK_PROF_GLITCH_CYC (SystemCoreClock * 10u) + +/* Private types -------------------------------------------------------------*/ + +typedef struct { + volatile uint32_t count; /* 已结算样本数 */ + volatile uint32_t max; /* 最坏耗时(周期数) */ + volatile uint32_t last; /* 最近一次耗时(周期数) */ + volatile uint32_t glitch; /* 丢弃的样本数(DWT 不可用 / 计数器被重置) */ + volatile uint32_t t_start; /* 进段时刻(周期数;仅 enter/exit 类探针使用) */ + volatile uint32_t in_seg; /* 1 = 此刻仍在段内(读侧据此算"已停留多久") */ +} task_prof_slot_t; + +/* Private variables ---------------------------------------------------------*/ + +static task_prof_slot_t s_prof[TASK_PROF_ID_COUNT]; +static uint8_t s_dwt_ok; /* 1 = DWT CYCCNT 已使能 */ +static uint8_t s_prof_reported; /* 1 = 已首次报告(节流状态) */ +static uint32_t s_prof_last; /* 上次报告时刻(ms) */ +static char s_prof_line[DBG_CON_LINE_MAX]; /* 报告行缓冲(仅 defaultTask 用) */ + +/* Private function prototypes -----------------------------------------------*/ + +static uint32_t prof_cyc_to_us(uint32_t cyc); +static void prof_send(int n, uint32_t raw); +static void prof_emit(uint32_t raw); + +/* + * 函数功能:周期数 → 微秒 + * 入口参数:cyc - 周期数 + * 返回值:微秒数(SystemCoreClock 低于 1 MHz 时返回 0) + * 限定条件:任意上下文可调(纯算术) + * 函数说明:MicroLIB 的 snprintf 不支持 %f → 一律以整数微秒上报。 + */ +static uint32_t prof_cyc_to_us(uint32_t cyc) { + uint32_t per_us = SystemCoreClock / 1000000u; + + if (per_us == 0u) { + return 0u; + } + return cyc / per_us; +} + +/* + * 函数功能:把已格式化在静态行缓冲里的行按指定通道发出 + * 入口参数:n - snprintf 返回的长度(<= 0 = 格式化失败,丢弃) + * raw - 0 = 入环形缓冲;非 0 = 裸 UART 直发 + * 返回值:无 + * 限定条件:仅 defaultTask 调用(唯一串口写者) + * 函数说明:长度按静态缓冲容量夹取,防 snprintf 截断后越界。 + */ +static void prof_send(int n, uint32_t raw) { + const unsigned cap = (unsigned)sizeof(s_prof_line); + unsigned len; + + if (n <= 0) { + return; + } + len = ((unsigned)n >= cap) ? (cap - 1u) : (unsigned)n; + + if (raw != 0u) { + dbg_console_write_raw(s_prof_line, len); + } else { + (void)dbg_console_write_line(s_prof_line, len); + } +} + +/* + * 函数功能:格式化并输出 `[PROF]` + `[PROF2]` 两行(静态行缓冲,零栈开销) + * 入口参数:raw - 0 = 入环形缓冲(周期报告);非 0 = 裸 UART 直发(停摆现场) + * 返回值:无 + * 限定条件:仅 defaultTask 调用(唯一串口写者,无并发;故可复用静态缓冲) + * 函数说明:字段含义(括号内为打印单位): + * [PROF] t HAL tick(ms) + * uic/uip/uipn/uil uiTask 循环次数 / 周期最坏 / 本轮已进行 / 周期最近(us) + * uiw/uis/uisn uiTask 单轮工作最坏 / 推流往返最坏 / 仍在推流段内(us) + * adc/adcm/adcn adcTask 单拍次数 / 单拍最坏 / 仍在拍内(us) + * [PROF2] defd/defs defaultTask 控制台排空最坏 / syslog 落盘最坏(us) + * netp/netn netTask 驱动轮询最坏 / 仍在轮询内(us) + * adcp/adcq adcTask ADC 单次最坏 / 仍在 ADC 内(us) + * 两行的 gl 丢弃样本数(DWT 不可用或计数器被重置) + * "仍在段内"为 0 表示不在该段内——某任务若永久阻塞在某段,该段的 + * count 不再增长,只有这个字段能证明它卡在哪里。 + */ +static void prof_emit(uint32_t raw) { + const uint32_t t = HAL_GetTick(); + const uint32_t now = task_prof_cyc(); + uint32_t uipn = 0u; + uint32_t uisn = 0u; + uint32_t adcn = 0u; + uint32_t netn = 0u; + uint32_t adcq = 0u; + uint32_t gl = 0u; + uint32_t i; + int n; + + if (s_prof[TASK_PROF_UI_PERIOD].in_seg != 0u) { + uipn = now - s_prof[TASK_PROF_UI_PERIOD].t_start; + } + if (s_prof[TASK_PROF_UI_SINK].in_seg != 0u) { + uisn = now - s_prof[TASK_PROF_UI_SINK].t_start; + } + if (s_prof[TASK_PROF_ADC_BEAT].in_seg != 0u) { + adcn = now - s_prof[TASK_PROF_ADC_BEAT].t_start; + } + if (s_prof[TASK_PROF_NET_POLL].in_seg != 0u) { + netn = now - s_prof[TASK_PROF_NET_POLL].t_start; + } + if (s_prof[TASK_PROF_ADC_POLL].in_seg != 0u) { + adcq = now - s_prof[TASK_PROF_ADC_POLL].t_start; + } + for (i = 0u; i < (uint32_t)TASK_PROF_ID_COUNT; i++) { + gl += s_prof[i].glitch; + } + + /* 行 1:任务级(uiTask / adcTask) */ + n = snprintf(s_prof_line, sizeof(s_prof_line), + "[PROF] t=%lu uic=%lu uip=%lu uipn=%lu uil=%lu uiw=%lu uis=%lu " + "uisn=%lu adc=%lu adcm=%lu adcn=%lu gl=%lu\r\n", + (unsigned long)t, + (unsigned long)s_prof[TASK_PROF_UI_PERIOD].count, + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_UI_PERIOD].max), + (unsigned long)prof_cyc_to_us(uipn), + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_UI_PERIOD].last), + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_UI_WORK].max), + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_UI_SINK].max), + (unsigned long)prof_cyc_to_us(uisn), + (unsigned long)s_prof[TASK_PROF_ADC_BEAT].count, + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_ADC_BEAT].max), + (unsigned long)prof_cyc_to_us(adcn), + (unsigned long)gl); + prof_send(n, raw); + + /* 行 2:段级(占用者指认:defaultTask 排空/落盘、netTask 驱动、adcTask ADC) */ + n = snprintf(s_prof_line, sizeof(s_prof_line), + "[PROF2] t=%lu defd=%lu defs=%lu netp=%lu netn=%lu adcp=%lu " + "adcq=%lu gl=%lu\r\n", + (unsigned long)t, + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_DEF_DRAIN].max), + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_DEF_SYSLOG].max), + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_NET_POLL].max), + (unsigned long)prof_cyc_to_us(netn), + (unsigned long)prof_cyc_to_us(s_prof[TASK_PROF_ADC_POLL].max), + (unsigned long)prof_cyc_to_us(adcq), + (unsigned long)gl); + prof_send(n, raw); +} + +/* + * 函数功能:读 DWT 周期计数器(懒使能;见 task_prof.h 的契约说明) + * 入口参数:无 + * 返回值:当前 CYCCNT 值(周期数) + * 限定条件:任意上下文可调(幂等;纯寄存器读写) + * 函数说明:只置使能位、**不清零** CYCCNT——本模块只用差值,清零无意义, + * 且会与 console_log.c 的同名惰性使能互相干扰(该模块对 CYCCNT + * 清零后,在途的差值会变成一次无符号回绕,由 glitch 守卫丢弃)。 + */ +uint32_t task_prof_cyc(void) { + if (s_dwt_ok == 0u) { + CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; + DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; + if ((DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0u) { + s_dwt_ok = 1u; + } + } + return DWT->CYCCNT; +} + +/* + * 函数功能:记录一次耗时样本(周期数) + * 入口参数:id - 探针 ID;cyc - 耗时(周期数) + * 返回值:无 + * 限定条件:单写者(每个 ID 只由一个任务调用) + * 函数说明:不可用样本(cyc 为 0 或超出合理上界)只累加 glitch,不更新 + * max/last/count——否则一次计数器重置就会把最坏值抬到 ~2^32 周期。 + */ +void task_prof_sample(task_prof_id_t id, uint32_t cyc) { + task_prof_slot_t *p; + + if ((uint32_t)id >= (uint32_t)TASK_PROF_ID_COUNT) { + return; + } + p = &s_prof[id]; + + if ((cyc == 0u) || (cyc >= TASK_PROF_GLITCH_CYC)) { + p->glitch++; + return; + } + p->count++; + p->last = cyc; + if (cyc > p->max) { + p->max = cyc; + } +} + +/* + * 函数功能:标记"进入某段"(观测"此刻仍在段内已多久") + * 入口参数:id - 探针 ID + * 返回值:无 + * 限定条件:单写者;须与 task_prof_exit 成对 + * 函数说明:写序为先 t_start 后 in_seg——读侧见 in_seg 为 1 时 t_start 必已有效。 + */ +void task_prof_enter(task_prof_id_t id) { + task_prof_slot_t *p; + + if ((uint32_t)id >= (uint32_t)TASK_PROF_ID_COUNT) { + return; + } + p = &s_prof[id]; + p->t_start = task_prof_cyc(); + p->in_seg = 1u; +} + +/* + * 函数功能:结算一段耗时(与 task_prof_enter 成对) + * 入口参数:id - 探针 ID + * 返回值:无 + * 限定条件:单写者;须与 task_prof_enter 成对 + * 函数说明:先清"在段内"标志再结算,避免读侧把已结算的段误报为"仍在段内"。 + * **未 enter 过则直接返回**:让"先 exit 再 enter"的周期测量写法 + * 在首轮不把 0 当作起点(否则会记下一次虚假的超大样本)。 + */ +void task_prof_exit(task_prof_id_t id) { + uint32_t t0; + + if ((uint32_t)id >= (uint32_t)TASK_PROF_ID_COUNT) { + return; + } + if (s_prof[id].in_seg == 0u) { + return; + } + t0 = s_prof[id].t_start; + s_prof[id].in_seg = 0u; + task_prof_sample(id, task_prof_cyc() - t0); +} + +/* + * 函数功能:周期性打印 [PROF] 行(内部节流) + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask 主循环调用 + * 函数说明:首次 5 s 后报告(bring-up 深链已跑过),此后每 2 s 一行。 + */ +void task_prof_report(void) { + const uint32_t now = HAL_GetTick(); + + if (s_prof_reported == 0u) { + if (now < TASK_PROF_FIRST_MS) { + return; + } + s_prof_reported = 1u; + } else if ((uint32_t)(now - s_prof_last) < TASK_PROF_PERIOD_MS) { + return; + } + s_prof_last = now; + prof_emit(0u); +} + +/* + * 函数功能:停摆现场快照(同一 [PROF] 行,走裸 UART 直发) + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask 调用(此时它就是唯一串口写者,无并发) + * 函数说明:用于"首次心跳缺席"瞬间;缺席窗口内环形缓冲可能积压, + * 裸直发保证这一行能出去。 + */ +void task_prof_report_raw(void) { + prof_emit(1u); +} diff --git a/App/util/task_prof.h b/App/util/task_prof.h new file mode 100644 index 00000000..bfbac61d --- /dev/null +++ b/App/util/task_prof.h @@ -0,0 +1,140 @@ +/* + * 模块名称:Task Profiler(任务耗时最坏值探针) + * 模块功能:用内核 DWT 周期计数器(CYCCNT,168 MHz 主频下 168 周期 = 1 us)为 + * 各任务主循环的**最坏耗时 / 执行次数 / "此刻是否仍在某段内"** 提供 + * 常开、低开销的观测面,经控制台单写者输出 `[PROF]` 行。 + * 用途:把"某任务心跳缺席"这一结果拆成三种互斥机理,供归因定位。 + * 适用平台:STM32F407ZGTx(仅 MCU 端:依赖 DWT 与 HAL 时基;模拟器/主机单测 + * 不编译本模块) + * 作者:王建锋 + * 创建日期:2026-10-10 + * 修改记录: + * v1.0 2026-10-10 王建锋 创建(Task #32 的 P2 测量埋点) + * + * 设计背景(docs/问题记录/UI任务看门狗停摆分析.md §8 P2): + * uiTask 偶发心跳缺席导致 IWDG 复位,但"占用者"无法从既有日志判定。 + * 同一现象至少对应三种互斥机理,观测上必须能区分: + * ① 被调度饿死 → 循环周期长,而段内耗时短; + * ② 阻塞在网络往返 → `ui_net_sink_poll()` 段内耗时长;若永久阻塞, + * 该段样本**永不结算**,只有"此刻仍在段内已 X us"能证明; + * ③ 自身渲染拖长 → 单轮工作耗时长。 + * 故每个探针记录 max / count / last,并对可疑段记录 enter/exit。 + * + * ⚠ 开销:一次采样 = 1 次 DWT 读 + 数次比较/存储(< 20 周期);对 1 ms 节拍 + * (168000 周期)可忽略。无动态分配、无内核对象、无临界区、无阻塞。 + * ⚠ 单写者假设:同一探针 ID 只由**一个任务**写入(见下表),报告由 + * defaultTask 读取。32 位对齐读写在 Cortex-M4 上原子,读侧不会撕裂。 + * + * 探针 ID 写入者 含义(enter/exit = 可读出"此刻仍在段内已多久") + * UI_PERIOD uiTask 上轮循环起点 → 本轮循环起点(enter/exit) + * UI_WORK uiTask 本轮起点 → 心跳前(含 LVGL 渲染 + 推流轮询) + * UI_SINK uiTask ui_net_sink_poll() 单次往返(enter/exit) + * ADC_BEAT adcTask 单拍起点 → 心跳后(含取数 + 压环 + 相量 + 心跳日志) + * ADC_POLL adcTask adc_input_poll() 单次(enter/exit)——隔离 ADC 超时成本 + * DEF_DRAIN defaultTask dbg_console_drain()(enter/exit)——控制台忙等发送 + * DEF_SYSLOG defaultTask syslog_drain()(enter/exit)——NAND/FTL 写 + * NET_POLL netTask net_poll()(enter/exit)——CH395F 驱动轮询 + * + * 后四个探针用于**指认占用者**:UI 心跳缺席时,谁的最坏耗时在同一窗口抬起来, + * 谁就是挤占 CPU 的 Normal 任务(见 docs/问题记录/UI任务看门狗停摆分析.md §7.1)。 + */ + +#ifndef TASK_PROF_H +#define TASK_PROF_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 探针 ID(顺序即内部槽位下标,新增只能追加) */ +typedef enum { + TASK_PROF_UI_PERIOD = 0, /* uiTask 循环周期 */ + TASK_PROF_UI_WORK = 1, /* uiTask 单轮工作耗时(不含 osDelay) */ + TASK_PROF_UI_SINK = 2, /* ui_net_sink_poll() 往返耗时 */ + TASK_PROF_ADC_BEAT = 3, /* adcTask 单拍耗时 */ + TASK_PROF_ADC_POLL = 4, /* adc_input_poll() 耗时(隔离 ADC 超时成本) */ + TASK_PROF_DEF_DRAIN = 5, /* defaultTask 控制台排空耗时 */ + TASK_PROF_DEF_SYSLOG = 6, /* defaultTask syslog 落盘耗时 */ + TASK_PROF_NET_POLL = 7, /* netTask net_poll() 耗时(CH395F 驱动) */ + TASK_PROF_ID_COUNT +} task_prof_id_t; + +/* + * 函数功能:读 DWT 周期计数器(懒使能;基准为"任意",仅供取差值) + * 入口参数:无 + * 返回值:当前 CYCCNT 值(周期数) + * 限定条件:任意上下文可调(幂等;纯寄存器读,不创建任何内核对象) + * 函数说明:首次调用置 CoreDebug->DEMCR.TRCENA 与 DWT->CTRL.CYCCNTENA。 + * 刻意**不清零** CYCCNT:本模块只用差值,清零无意义,且会与 + * console_log.c 的同名惰性使能互相干扰。DWT 使能失败时恒返回 0, + * 采样侧把 0 判为"不可用"(见 task_prof_sample)。 + */ +uint32_t task_prof_cyc(void); + +/* + * 函数功能:记录一次耗时样本(周期数) + * 入口参数:id - 探针 ID + * cyc - 耗时(周期数;0 或超出合理上界视为不可用/计数器被重置) + * 返回值:无 + * 限定条件:单写者(每个 ID 只由一个任务调用) + * 函数说明:更新 count / last / max。`cyc == 0`(DWT 未使能)或 + * `cyc >= TASK_PROF_GLITCH_CYC`(计数器中途被重置造成的无符号回绕) + * 只累加 glitch 计数、不污染 max——否则一次计数器重置就会把 + * 最坏值抬到 ~2^32 周期,掩盖真实数据。 + */ +void task_prof_sample(task_prof_id_t id, uint32_t cyc); + +/* + * 函数功能:标记"进入某段"(用于观测"此刻仍在段内已多久") + * 入口参数:id - 探针 ID + * 返回值:无 + * 限定条件:单写者;须与 task_prof_exit 成对 + * 函数说明:记录进入时刻并置"在段内"标志。若该任务此后永久阻塞在该段, + * 样本永不结算,只有报告侧的"仍在段内"字段能证明它卡在哪里。 + */ +void task_prof_enter(task_prof_id_t id); + +/* + * 函数功能:结算一段耗时(与 task_prof_enter 成对) + * 入口参数:id - 探针 ID + * 返回值:无 + * 限定条件:单写者;须与 task_prof_enter 成对 + * 函数说明:清"在段内"标志 → 以 enter 时刻为起点记一次样本。 + * **未 enter 过(in_seg == 0)时直接返回**——这使"先 exit 再 enter"的 + * 周期测量写法(如 uiTask 的循环周期)在首轮不会拿 0 当起点, + * 从而不产生一次虚假的超大样本。 + */ +void task_prof_exit(task_prof_id_t id); + +/* + * 函数功能:周期性打印 `[PROF]` / `[PROF2]` 行(内部节流:首次 5 s 后,此后每 2 s) + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask 主循环调用(唯一串口写者;静态行缓冲复用) + * 函数说明:整行一次 `dbg_console_write_line` 入环形缓冲(行级原子)。 + * `[PROF]` = uiTask/adcTask 任务级(周期 / 工作 / 往返 / 单拍); + * `[PROF2]` = 占用者段级(defaultTask 排空与落盘、netTask 驱动轮询、 + * adcTask ADC 单次),用于指认"谁把 CPU 占住了"。 + * 用**静态**行缓冲而非栈缓冲:本函数挂在 defaultTask 的最坏调用链上, + * 栈上再放一个 192 B 行缓冲会挤占该任务的栈预算。 + */ +void task_prof_report(void); + +/* + * 函数功能:停摆现场快照——同 `[PROF]` + `[PROF2]` 两行,走**裸 UART 直发** + * 入口参数:无 + * 返回值:无 + * 限定条件:仅 defaultTask 调用(此时它就是唯一串口写者,无并发) + * 函数说明:用于"某任务心跳刚由'到过'变为'缺席'"的瞬间。缺席窗口内环形缓冲 + * 可能积压,裸直发保证这两行能出去(与 task_wd_poll 的 `MISS start` + * 标记同款理由)。 + */ +void task_prof_report_raw(void); + +#ifdef __cplusplus +} +#endif + +#endif /* TASK_PROF_H */ diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 6ed5360b..4bef565e 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -907,6 +907,11 @@ 1 ..\App\util\task_watchdog.c + + task_prof.c + 1 + ..\App\util\task_prof.c + vol_lock.c 1 diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 524a4ebd..51e46d18 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 271,440 B (265.08 KB) | 1,048,576 B (1024 KB) | 25.9% | -| RW_IRAM1 | 90,936 B (88.80 KB) | 131,072 B (128 KB) | 69.4% | +| ER_IROM1 | 272,436 B (266.05 KB) | 1,048,576 B (1024 KB) | 26.0% | +| RW_IRAM1 | 91,328 B (89.19 KB) | 131,072 B (128 KB) | 69.7% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **272,228 B (265.8 KB)** | 1,048,576 B (1024 KB) | **26.0%** | +| **Flash (ROM 合计)** | **273,236 B (266.8 KB)** | 1,048,576 B (1024 KB) | **26.1%** | 各区的容量与常量来源: diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md index fbf621ac..cefde9b8 100644 --- a/docs/问题记录/UI任务看门狗停摆分析.md +++ b/docs/问题记录/UI任务看门狗停摆分析.md @@ -4,7 +4,9 @@ > → 连续 8 次(800ms)缺席 → 停喂 IWDG → 复位。 > **背景**:L2 相量层落地(commit `3762c03`)后**新出现**的间歇性回归:L2 版 3/10 次运行失败, > 改前基线(`git stash` 重建)0/6。 -> **状态**:**分析完成,处置待确认**(2026-10-10)。本文只做归因,未改动生产代码。 +> **状态**:**分析完成,P0/P1/P2 处置已落地并上板验证**(2026-10-10)。P0(adc 压环条件 + +> 截止期重同步)修复后累计 22 次启动 0/22 复现;P1-4a 已解耦 UI 心跳与 netTask 往返;P2 常开 +> 探针已上线。详见 §8「处置落地」。 > **关联**: > [`../待处理内容.md`](../待处理内容.md)(**N-17** 心跳缺口登记)、 > [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、 @@ -216,16 +218,29 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ (a) `netTask` 回执延迟(§4.2),还是 (b) `defaultTask` 长段(syslog NAND 写 / 配置保存),还是 (c) adcTask 超时突发(§4.4)。 + **补充(2026-10-10,P2 测量后):22 次上板启动(P0 修复后,含本分析前的 12 次 + + 复跑 10 次)**全部 0 复现停滞**——健康运行里最大的单一非让出占用是 + `defaultTask` 控制台排空 `defd≈48 ms`、ADC 轮询 `adcp≈4 ms`,**没有任何任务 + 接近 800 ms**。故 800 ms 是异常长尾,靠健康剖面无法指认,须靠复现时的 `[PROF]/[PROF2]` + 现场快照;但 P0 修复已把回归压到 0/22(原 L2 基线 3/10),暂不再阻塞推进。 2. **`adc1_err=288` 的自身根因未查。** 2 ms 超时对 7.4 µs 的转换而言异常巨大, 指向 ADC 被反复 `Start/Stop` 或 EOC/OVR 状态异常;需专项测量,不要与本条混谈。 -3. **复位归因存疑。** 日志报 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)`, - 即 `SFTRSTF|PINRSTF`;而按设计"停喂 → IWDG 自然溢出"应留下 `IWDGRSTF`。 - 本次复位可能来自**测试台架**(ST-LINK 拉 NRST → `PINRSTF`)而非看门狗,需与台架流程对齐。 +3. **复位归因已澄清为非诊断项。** 失败日志报 `reset cause: software (NVIC_SystemReset) + (CSR=0x14000000)`(即 `SFTRSTF|PINRSTF`);但 2026-10-10 的健康启动**同样**报 + `software (NVIC_SystemReset)` 且判定 PASS——说明本台架上电复位本就走软件复位路径 + (或烧录工具经软件复位),**不能据此区分"IWDG 溢出"还是"台架拉 NRST"**。停摆的权威 + 证据仍是 `[ERR][WD] task stall` 行本身,而非复位原因寄存器。 4. **`[STACK]` 周期从 2.0 s 拉到 4.05 s**(17:36:19.902 → 23.951,标称 `STACK_PROBE_PERIOD_MS=2000`) 值得注意,但**不能作为证据**:控制台缓冲满时会**丢行**,该报告也可能只是被丢了。 5. **`idle` 的物理含义**:`idle=649/20001` 意味着任务轮询略快于帧产生(相位漂移), 反映 `osDelayUntil`(1 ms 内核 tick)与 TIM6(1 kHz 硬件)之间的**时钟源差异**, 本身不是故障,但会让 §5 的缺陷持续发生。 +6. **启动阶段 `MISS start snap=0x67` 是良性的(2026-10-10 新增)。** `[WD] armed` 在 + bring-up 完成后、UI 首跳之前武装,而 `required_mask` 静态含 UI 位 → 武装后到 UI 首跳 + 之间必现一次 `MISS start 0x67`。健康运行(`FATFS mounted≈0.43s`)在 `UI net sink: + started` 处再出现一次后**自行恢复**;失败运行也是同一良性 episode 起头,真正的锁死是 + 运行中段(t=10585)的第二次 episode。**故该 episode 本身不是故障判据**,只作"UI 尚未 + 起跳"的旁证。 --- @@ -258,6 +273,35 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ **P2-6/7 先测(一次 @test 即可拿到区分性数据)→ 按数据定 P1 的方案 → P0 顺路一起修 → 复跑 10 次确认 0/10。** 不建议直接上 P1-3(提优先级)了事:那会**掩盖** §4.2 与 §4.4 两个真实缺陷。 +### 处置落地(2026-10-10,按"先测量后修"顺序执行完毕) + +- **P2(测量)= 已落地。** 新增常开探针模块 `App/util/task_prof.c/.h`:测各任务循环周期 / + 单轮耗时 / 推流往返 / ADC 轮询 / 控制台排空 / syslog 落盘 / net_poll 的最坏值,并在**心跳 + 位图 1→0 边沿**裸 UART 直发一份现场快照(`[PROF]`/`[PROF2]` 两行)。接线覆盖 ui / adc / + default / net 四个任务,已在 uvprojx 登记。 +- **P2 数据结论(决定 P1 的依据):** + - 健康运行 `uiw≈uis≈50 ms` → **UI 单轮耗时几乎等于推流往返耗时**,证实 §4.2:心跳被 + `netTask` 往返主导,实际测的是"netTask 响应性"而非"UI 活着"。 + - 健康运行里 `uisn=0`(推流段无"仍在段内"积压)→ UI **未被卡在往返**,阻塞机理排除。 + - 最大单一非让出占用 `defd≈48ms`(控制台排空)/ `adcp≈4ms`(ADC 轮询),无任务接近 800ms。 +- **P1(结构性)= 已落地 P1-4a。** `App/task/ui_task.c`:把 `task_wd_beat(TASK_WD_UI)` + **提到 `ui_net_sink_poll()` 之前**(v1.3)。心跳从此测"UI 自己被调度到",不再被 netTask + 往返拖垮。**未上 P1-3(提优先级)**:doc 明确警告它会掩盖 §4.2/§4.4。**P1-5(ADC 超时整拍 + 跳过)暂缓**:数据表明 `adcp≈4ms` 已受限,且 P0-2 的 `osDelayUntil` 重同步已解除自旋死锁, + 暂无必要改动采样时序(避免引入丢帧风险)。**若日后复现且快照指向"永久阻塞在往返"**, + 再升级到事件驱动 accept(`NET_EVENT_CONNECTED` 回调,无客户端时不走阻塞 `net_accept`)。 +- **P0(缺陷修复)= 已落地并验证。** `App/task/adc_task.c`: + - P0-1(§5):仅 `r==1` 时压环 + 重算。上板直接印证:修复后 `frames=29924 idle=63 + ring=29924` → **`ring == frames`**(修前 `ring = frames + idle` 污染相量窗); + `adc1_err` 从失败日志的 **288** 降到 **1**。 + - P0-2(§4.3):`osDelayUntil` 返回值重同步,落后即一次性拉回基准,解除"永久不阻塞→ + Normal 级空转抢占 uiTask"的死锁路径。 +- **验证:** P0 修复后累计 22 次上板启动 **0/22** 复现停滞(原 L2 基线 3/10); + P0+P1 后再复跑 10 次确认(见 `prof_batch4`)。构建 0 错误 0 警告 + (`Code=256276 RO=16160 RW=800 ZI=139760`)。 +- **遗留(不在本次处置范围):** §7.2 `adc1_err` 自身根因、§7.4 `[STACK]` 周期漂移、§4.4 的 + ADC 忙等未彻底改 DMA(仅以 P0-2 + 暂缓 P1-5 管控)。如复现重现,优先回头查 §7.2。 + --- ## 变更记录 From 35b5b78209cb8a2911f99c27e3322f3344a8f012 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 19:59:34 +0800 Subject: [PATCH 11/47] =?UTF-8?q?docs(adc):=20Task=20#32=20P1-5=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E9=A9=B1=E5=8A=A8=E6=B6=88=E8=B4=B9=E6=96=B9?= =?UTF-8?q?=E6=A1=88=20a=20=E8=A7=84=E5=88=92=E5=85=A5=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户决策"ISR 只发唤醒 + 计算留任务 + 提优先级"(方案 a),拒绝 ISR 内计算; 附下一版硬件 DMA 双缓冲建议(方案 b)。代码待实施。 - 新增 §10:决策依据、拟改造清单(main.c/freertos.c/adc_task.c/adc_task.h)、 预期收益、残留风险(P1-5 硬化)、DMA 硬件建议(§10.5) - 状态头与 §9 变更记录同步"规划/待实施"口径 - 未改代码;消费端当前仍为 1ms 轮询 --- docs/子系统设计/ADC采样节拍设计说明.md | 76 ++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md index cf802f90..4be21fe8 100644 --- a/docs/子系统设计/ADC采样节拍设计说明.md +++ b/docs/子系统设计/ADC采样节拍设计说明.md @@ -4,6 +4,9 @@ > (`Src/main.c` 回调 + `app_main.c` 启动 + `adc_task.c` 退化为纯消费者),`@build` 0 错 0 警; > 台架 `@test` **PASS**(`frame_seq` 每 10 s 精确 +10000 = 1000.0 Hz、`frst_err=0`、回读一致; > 实测见 §7.1)。 +> **事件驱动消费改造方案 a 已规划入档(Task #32 P1-5,2026-10-10,待实施)**:用户决策 +> "ISR 只发唤醒 + 计算留任务 + 提优先级"(方案 a),拒绝"ISR 内计算";并附下一版硬件 +> DMA 双缓冲建议(§10.5)。详见 §10;消费端当前仍为 1 ms 轮询,待该方案排期落地。 > **上位需求**:`docs/算法/继电保护功能复现规划.md` §3.2 L1-1(周期 CONVST 重触发 1000 Hz, > 与保护节拍同源)。**驱动契约**:`Drivers/BSP/TPAFE5160/README.md`。 > **背景**:B1(2026-10-10)已把 `adc_task` 由空桩改为真实消费者,但采样节拍是 @@ -224,3 +227,76 @@ t=1000 µs 下一个 TIM6 更新事件 | 2026-10-10 | 建立本文档 | B1(ADC 采集链路)遗留项"精确 1 kHz 硬件定时器触发"的规划:方案选型(A/B)、CubeMX 配置规格、代码改造清单、时序与优先级分析、风险与验收判据、等周波调频预留 | | 2026-10-10 | **实现(方案 A)** | CubeMX 加 TIM6(PSC=83 / ARR=999 / ARR 预装载 / NVIC 抢占优先级 6);`Src/main.c` 回调加 TIM6 分支触发 CONVST;`app_main.c` 用 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍;`adc_task.c` 退化为纯消费者。`@build` 0 错 0 警;台架 `@test` 验收待做 | | 2026-10-10 | **台架 `@test` 验收 PASS** | 真机闭环:`seq` 10005→20005→30005(每 10 s +10000 = 1000.0 Hz)、`frst_err=0`、`idle` 每 10 s +5(0.05%,轮询抖动)、回读 `No difference found`、零 `[ERR]`/HardFault、`reset cause` 仅 1 次。证据 `boot_log_data/20261010_prod_adc_tim6_boot_log.txt` | +| 2026-10-10 | **事件驱动消费方案 a 规划入档(Task #32 P1-5)** | 记录用户决策与改造清单(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 `adcTask` + `adcTask` 提至 `osPriorityAboveNormal`),并附下一版硬件 DMA 双缓冲建议(§10.5)。代码待实施,消费端仍为 1 ms 轮询 | + +--- + +## 10. 事件驱动消费改造(Task #32 P1-5,2026-10-10 规划:用户决策方案 a,待实施) + +> 上位背景:Task #32 暴露"UI 任务看门狗停摆"候选占用者之一是 `adcTask` 的 1 ms 轮询—— +> 原 `osDelayUntil(1ms)` 把消费唤醒**钉在 FreeRTOS tick(1 ms)边界**,带来 0~1 ms 随机 +> 相位抖动 + 累积漂移风险,且 `adcTask` 与 `uiTask` 同为可被长时抢占的对象。用户决策: +> **方案 a(ISR 只发唤醒 + 计算仍在任务 + 提优先级)**,并拒绝"ISR 内计算"方案。 +> 本方案**尚未实施**:消费端当前仍为 1 ms 轮询,改造清单见 §10.2,待排期落地。 + +### 10.1 决策:为什么选 a,不选"ISR 内计算" + +- **拒绝"ISR 内计算"**:把相量/压环搬进 TIM6 ISR 是反模式—— + ① `adc_input_poll()` 内含 `HAL_ADC_PollForConversion` 的 **2 ms 忙等**(`ADC_INPUT_POLL_TIMEOUT_MS=2U`), + 绝不可入 ISR(会冻结系统,最坏 9 通道 ×2 ms = 18 ms 全系统中断关闭); + ② 相量/采样环是任务级共享数据,ISR 内改写需额外临界区,破坏 `tpafe5160_snapshot()` 的 + "仅任务上下文"铁律;③ `HAL_TIM_PeriodElapsedCallback` 是 CubeMX 生成区回调,承载 L2 计算 + 违反"生成区不改"约定,重生成即丢。 +- **选定方案 a**:TIM6 ISR **只置位唤醒标志**(`osThreadFlagsSet`),所有计算留在 `adcTask`; + 并把 `adcTask` 优先级由 `osPriorityNormal` 提到 `osPriorityAboveNormal`,使唤醒后**立即抢占** + 其余 Normal 任务,固定 µs 级延迟。计算量、数据共享、生成区约定全部不变。 + +### 10.2 拟改造内容(待实施) + +| 文件 | 拟改动 | +|---|---| +| `Src/main.c` | `HAL_TIM_PeriodElapsedCallback` 的 TIM6 分支:在 `tpafe5160_start_conv_irq()` 后加 `(void)osThreadFlagsSet(adcTaskHandle, ADC_WAKE_FLAG);` | +| `App/task/adc_task.c` | 主循环尾 `osDelayUntil(1ms)` 改为 `(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever);`;删除 `ADC_TASK_BEAT_MS` 与 `next` 变量 | +| `Src/freertos.c` | `adcTask_attributes.priority`:`osPriorityNormal` → `osPriorityAboveNormal`(=32,高于其余 Normal=24;`configMAX_PRIORITIES=56` 有裕量) | +| `App/task/adc_task.h` | 新增 `#define ADC_WAKE_FLAG 0x0001U` 与 `extern osThreadId_t adcTaskHandle;` | + +- **ISR 安全性(设计校验)**:TIM6 抢占优先级 = 6 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`(=5) → + `osThreadFlagsSet` 在 ISR 内走 `xTaskNotifyFromISR` + `portYIELD_FROM_ISR`(CMSIS-RTOS V2 的 + `osThreadFlagsSet` 实现),合法且会立即触发上下文切换。`configUSE_OS2_THREAD_FLAGS=1` 已开,符号已编入。 +- **看门狗不变**:`TASK_WD_ADC` 心跳仍在循环内每拍调用,`required_mask` 与心跳位集合一致, + 优先级调整不影响掩码。 +- **验证前置**:实施后须 `@build` 0 错 0 警 + 台架 `@test` 复核 `idle` 是否降至 ≈0(§7.1)。 + +### 10.3 预期收益与语义 + +1. **唤醒延迟固定 µs 级**:原 `osDelayUntil(1ms)` 把唤醒量化到 tick 边界 → 0~1 ms 随机相位抖动 + + 一旦超拍即"永久落后/空转"风险(即 P0-2 的成因);事件驱动走 TIM6 硬件节拍,唤醒 = 生产时刻 + ISR/调度延迟(亚 µs~数 µs),**无 tick 量化、无累积漂移**。 +2. **idle 趋近 0**:原轮询与生产的 ±1 帧相位抖动(§7.1 每 10 s +5)消失——唤醒与生产同源锁定。 + 待台架复核(§7.1)。 +3. **电平型标志的健壮性**:若单拍偶超 1 ms,TIM6 多发脉冲合并为一次唤醒,`adc_input_poll()` 返回 + `r==0`(无新帧)记 `idle`,**不重复处理同帧**,与 P0 修复(仅 `r==1` 压环)口径一致,无 DFT 窗污染。 + +### 10.4 残留风险与后续硬化(P1-5,规划中) + +- **§4.4 的 2 ms 忙等仍在 L1 层**:`adc_input_poll()` 内的 `HAL_ADC_PollForConversion` + (`ADC_INPUT_POLL_TIMEOUT_MS=2U`)是任务级忙等。改造后 `adcTask` 若提至 AboveNormal,若 STM32 ADC + 偶发超时,该 2 ms 忙等会在**高优先级**下阻塞其余 Normal 任务(比现状更敏感)。**硬化方向(待做)**: + 超时即**整拍跳过**(不轮询 9 通道),或在 `adc_input.c` 改用非阻塞查询 + 标志,彻底消除忙等占用。 +- **启动 fail-safe(设计)**:TIM6 未启动前 `adcTask` 永久阻塞于 `osThreadFlagsWait` → 无心跳 → 看门狗 + `TASK_WD_ADC` 触发复位。属安全 fail-safe,非缺陷(确保"节拍源缺失"不被静默放过)。 + +### 10.5 下一版硬件建议:DMA + 双缓冲(方案 b,用户要求记录) + +当前硅片(STM32F407)**STM32 ADC1 已支持 TIM6-TRGO → ADC1 + DMA**;瓶颈在 **TPAFE5160 接口**, +须先以逻辑分析仪确认其接口类型再定型: + +- **若 TPAFE 为 SPI + DRDY**(推荐先验): + - 单 1 kHz 主时钟:TIM6 `TRGO` 同时驱动 ADC1 扫描(`ADC_ExternalTrigConv`=TIM6_TRGO)与 TPAFE `CONVST`; + - TPAFE 数据由 **SPI-RX-DMA** 搬运;`DRDY` 下降沿 → DMA **HT/TC 中断** 实现**乒乓双缓冲**; + - 任务只在"半满"时做后处理(压环 + 相量),CPU 完全脱离逐点搬运。 +- **若 TPAFE 为并行接口**:用 **FMC** 把 TPAFE 数据总线映射为异步 SRAM,DMA 从 FMC 读 → 内存; + 须确认 FSMC/FMC 地址/时序兼容 TPAFE 读时序(开销大于 SPI 方案)。 +- **统一收益**:CPU 脱离轮询/搬运 → 采样率可上探(如 4 k / 8 k 点/周波)、相量窗更密、腾出 + AboveNormal 算力给保护算法;与 §8 等周波调频天然契合(ARR 改频 + DMA 自动续传)。 +- **验收前置**:先以逻辑分析仪确认 TPAFE 接口类型与 `DRDY` 时序,**再**定型下一版硬件 DMA 方案, + 避免返工。 From 1d1a9d4656d91d3b8eaef213dcbd7f32a9efea98 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 20:47:59 +0800 Subject: [PATCH 12/47] =?UTF-8?q?net/ui:=20UI=20=E6=8E=A8=E6=B5=81?= =?UTF-8?q?=E5=8A=A0=E5=B0=B1=E7=BB=AA=E9=97=A8=E6=8E=A7=EF=BC=8C=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E8=84=B1=E7=A6=BB=20netTask=20=E5=BE=80=E8=BF=94?= =?UTF-8?q?=EF=BC=88=E9=94=80=E9=A1=B9=20N-10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 net_can_accept() / net_can_read():纯 RAM 读 Socket 控制块,不入队、 不等 netTask 回执,供实时性敏感任务在发 RPC 前判断"有没有活"。 ui_net_sink_poll() 改为先门控再 RPC;无客户端时 RPC 从 100 msg/s 降到 0。 两条兜底(门控引入的新卡死路径,设计阶段未预见): - I-2 每 32 轮无条件 recv:readable 仅由 RECV_OK 中断置位,Trap 22 三类 对账不含"RX 非空但 readable=0",纯门控会把已到达的数据永久藏住; - I-3 门控返 0 时查监听控制块,消失即 g_listen=-1 重建(改前靠每轮 accept 撞 BADF 暴露,门控会连这条暴露路径一起消掉)。 net_can_read() 对非 ESTABLISHED / 非法 fd 故意返 1,让上层必得 EOF/BADF 走既有错误分支——返 0 会把死连接永久藏住。 模拟器后端恒返 1(Winsock 无 RPC 成本,行为逐位不变)。 实测:uis 50ms→1µs、uiw 50ms→11.4ms(只剩 LVGL);@build 0 错 0 警、 @test 10/10 PASS(uis≤1µs、uisn≡0、0 task stall、0 [ERR])、 check_remo_sink.py e2e 0/25600 位差异、内容页文本断言 50/50。 uip 最坏 61ms 是 LVGL 帧周期地板、与网络无关,判据已按实测修订。 方案 B(把推流搬出 uiTask + 帧 seqlock)已评估并决定不做:搬任务不会 让刷新变快,收益只剩极端并发下的观感隔离,成本含必改 .ioc 的 CubeMX 重生成。残留暴露与观测手段(uis/uisn)如实记录在 §9.4。 N-19 / C7 不入登记簿(当日结案),结论沉淀进问题记录 §9。 --- Drivers/BSP/NET/net_socket.c | 48 ++++++ Drivers/BSP/NET/net_socket.h | 41 +++++ Drivers/BSP/UI/ui_net_sink.c | 55 ++++++- Drivers/BSP/UI/ui_net_sink.h | 15 +- docs/UI设计/RemoDispBus协议与接收设计说明.md | 14 ++ docs/子系统设计/看门狗设计说明.md | 1 + docs/网络/BSD_Socket_API_使用指南.md | 20 ++- docs/问题记录/UI任务看门狗停摆分析.md | 162 +++++++++++++++++++ docs/问题记录/项目审查与优化建议报告.md | 4 +- simulator/sim/net_socket_sim.c | 33 ++++ 10 files changed, 382 insertions(+), 11 deletions(-) diff --git a/Drivers/BSP/NET/net_socket.c b/Drivers/BSP/NET/net_socket.c index 3eb63e9b..ed09e87f 100644 --- a/Drivers/BSP/NET/net_socket.c +++ b/Drivers/BSP/NET/net_socket.c @@ -1271,6 +1271,54 @@ int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) { return msg.result; } +/* + * 函数功能:查询监听 Socket 是否有可接受的连接(纯 RAM 读,不发起 RPC) + * 入口参数:sockfd - 监听 Socket 描述符 int + * 出口参数:无 + * 返回值:1 - 有连接可接受;0 - 无(含描述符无效) int + * 限定条件:任意任务上下文;net_init() 已调用 + * 函数说明:1. 判据与 net_accept_locked() 一致(单连接模型:监听 Socket 自身在 + * CONNECT 后转 ESTABLISHED),故"门控放行"与"accept 会成功"严格等价 + * 2. 只读控制块,不入队、不等通知 → 调用方(uiTask 等)不被 netTask 拖住 + * 3. 丢 CONNECT 中断由 net_reconcile_tick() 对账自愈,本函数不做自愈 + */ +int net_can_accept(int sockfd) { + /* Socket 控制块(纯 RAM 读,可能为 NULL = 描述符越界或未分配) */ + const net_sock_t *p_sock = net_get_sock(sockfd); + + if (p_sock == NULL) { + return 0; + } + return (p_sock->state == NET_SOCK_STATE_ESTABLISHED) ? 1 : 0; +} + +/* + * 函数功能:查询 Socket 是否"值得发一次 recv"(纯 RAM 读,不发起 RPC) + * 入口参数:sockfd - Socket 描述符 int + * 出口参数:无 + * 返回值:1 - 应发 recv;0 - 当前无数据可读,可跳过本轮 int + * 限定条件:任意任务上下文;net_init() 已调用 + * 函数说明:1. ESTABLISHED → 看 readable(net_poll 在 RECV_OK 置位、读空清零) + * 2. 非 ESTABLISHED / 描述符失效一律返回 1:必须让上层真的 recv 一次 + * 拿到 EOF/错误并复位自身状态,门掉它会把"连接已不存在"永久藏住 + * 3. ⚠ readable 仅由 RECV_OK 中断置位,Trap 22 对账不含"RX 非空但 + * readable=0" → 中断丢失时本函数恒返 0,调用方须保留低频兜底 recv + */ +int net_can_read(int sockfd) { + /* Socket 控制块(纯 RAM 读,可能为 NULL = 描述符越界或未分配) */ + const net_sock_t *p_sock = net_get_sock(sockfd); + + /* 描述符已失效:让 recv 去报 BADF,走调用方既有错误分支重建,勿静默卡死 */ + if (p_sock == NULL) { + return 1; + } + /* 连接已非 ESTABLISHED(CLOSED/LISTENING/…):需要一次 recv 了结 EOF/错误 */ + if (p_sock->state != NET_SOCK_STATE_ESTABLISHED) { + return 1; + } + return (p_sock->readable != 0U) ? 1 : 0; +} + /* * 函数功能:TCP Client 发起与服务器的连接(线程安全版本,通过消息队列委托给 netTask) * 入口参数:sockfd - Socket 描述符 (0~7),必须为已创建的 TCP Socket int diff --git a/Drivers/BSP/NET/net_socket.h b/Drivers/BSP/NET/net_socket.h index 766e48e5..b9ca27a2 100644 --- a/Drivers/BSP/NET/net_socket.h +++ b/Drivers/BSP/NET/net_socket.h @@ -150,6 +150,47 @@ int net_listen(int sockfd); */ int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen); +/* + * 函数功能:查询监听 Socket 当前是否有可接受的连接(**纯 RAM 读,不发起 RPC**) + * 入口参数:sockfd - 监听 Socket 描述符 int + * 出口参数:无 + * 返回值:1 - 有连接可接受;0 - 无(含描述符无效) int + * 限定条件:net_init() 已调用;任意任务上下文(含 uiTask 等实时性敏感任务) + * 函数说明:1. 只读 Socket 控制块状态,**不经消息队列、不等 netTask 回执** → + * 调用方不会被 netTask 的忙碌程度拖住(对比 net_accept: + * osMessageQueuePut(osWaitForever) + xTaskNotifyWait(portMAX_DELAY), + * 两者均**无上界**) + * 2. 判据与 net_accept_locked() 完全一致:单连接模型下监听 Socket 自身 + * 在 CONNECT 事件后转 ESTABLISHED,即"有连接可接受" + * 3. 用途 = **门控**:无连接时直接跳过 net_accept(),把"每轮一次无谓 + * RPC"降为 0(UI 推流 10 ms 轮询 = 100 msg/s 队列压力的根因) + * 4. 跨任务读单字:netTask 是唯一写者,最坏两种结果都无害—— + * 晚一轮发现(netTask 每 1 ms 就 net_poll 一次),或扑空 + * (net_accept 返回 -1 + NET_ERR_WOULDBLOCK) + * 5. 丢 CONNECT 中断由 net_reconcile_tick()(200 ms 对账 + 去抖)自愈, + * 本函数**不承担**自愈责任 + */ +int net_can_accept(int sockfd); + +/* + * 函数功能:查询 Socket 是否"值得发一次 recv"(**纯 RAM 读,不发起 RPC**) + * 入口参数:sockfd - Socket 描述符 int + * 出口参数:无 + * 返回值:1 - 应发 recv(有数据可读,或连接已非 ESTABLISHED 需了结);0 - 不必发 int + * 限定条件:net_init() 已调用;任意任务上下文 + * 函数说明:1. ESTABLISHED → 看 readable(net_poll 在 RECV_OK 置位、读空清零) + * 2. **非 ESTABLISHED 一律返回 1**:连接已消失时必须让上层真的 recv 一次 + * 拿到 EOF/错误并自行复位;若把它门掉,"连接已不存在"会被永久藏住 + * (推流侧 g_client 永不复位 → 再也不接受新客户端) + * 3. 描述符失效(net_get_sock 返回 NULL)同样返回 1 → 让 recv 报 BADF, + * 走调用方既有的错误分支重建,而不是静默卡死 + * 4. ⚠ **不可作为唯一判据**:readable 只由 RECV_OK 中断置位,而 + * net_reconcile_tick 的三类对账**不含**"芯片 RX 非空但 readable=0" + * → 中断丢失时本函数会恒返 0。调用方须保留**低频兜底 recv** + * (ui_net_sink 每 32 轮无条件收一次,最坏延迟 ≈320 ms) + */ +int net_can_read(int sockfd); + /* * 函数功能:TCP Client 发起连接 * 入口参数:sockfd - socket 描述符 diff --git a/Drivers/BSP/UI/ui_net_sink.c b/Drivers/BSP/UI/ui_net_sink.c index b9c56b5b..cc3aa4ef 100644 --- a/Drivers/BSP/UI/ui_net_sink.c +++ b/Drivers/BSP/UI/ui_net_sink.c @@ -15,6 +15,14 @@ * 2026-10-02 王建锋 E-04:remo_send 校验 net_send 返回值(原丢弃)——短写/失败 * 告警,连续 3 次失败阻断推送,新连接/网络重建解除阻断 * (MCU 单连接模型不得 net_close 数据 fd,会连监听一起毁掉) + * 2026-10-10 王建锋 I-1 门控 + I-2 兜底(Task #32 §9.3,销项 N-10):accept/recv + * 前先用 net_can_accept()/net_can_read()(纯 RAM 读控制块)判 + * "有没有活",无活直接返回——台架常态(无客户端)从每 10 ms + * 一次 RPC(100 msg/s,与 FTP 抢深度 8 的队列)降到 0 次。 + * 兜底:readable 仅由 RECV_OK 中断置位且 Trap 22 对账不含 + * "RX 非空但 readable=0",故每 32 轮无条件 recv 一次(≈320 ms); + * 门控放行时若监听控制块已消失则置 g_listen=-1 触发重建, + * 避免"门控把端口永久不再接受新客户端"这一新卡死路径 */ #include "ui_net_sink.h" @@ -57,6 +65,18 @@ static uint32_t g_gen; /* 建立监听时的网络代次号(变化 static uint8_t s_send_fail_run; /* 连续发送失败计数(E-04:0 = 正常) */ static uint8_t s_sink_blocked; /* 推送阻断标志(1 = 停发帧,等客户端重连解除) */ +/* + * 兜底收帧节拍(门控失效保险,2026-10-10 I-2): + * net_can_read() 的 readable 只由 RECV_OK 中断置位,而 Trap 22 的 + * net_reconcile_tick() 三类对账**不含**"芯片 RX 非空但 readable=0" → + * 该中断一旦丢失,纯门控会把已到达的数据永久藏住(改前靠每轮 recv 内的 + * ch395f_get_recv_len() 顺带发现)。故每 RM_LIVENESS_DIV 轮**无条件**收一次: + * 10 ms 节拍 → 最坏额外延迟 ≈320 ms,而 RPC 频率仍从 100/s 降到 ≈3/s。 + * 取值必须是 2 的幂(用 & 取模,免除法)。 + */ +#define RM_LIVENESS_DIV 32U +static uint8_t s_liveness_ctr; /* 兜底计数器(单写者 = 推流宿主任务) */ + /* * 函数功能:计算 RemoDispBus 帧 payload 的 CRC 校验值 * 入口参数:d - 待校验数据指针(len 为 0 时不访问) @@ -449,10 +469,16 @@ int ui_net_sink_start(uint16_t port) { * 限定条件:ui_net_sink_start() 已成功调用 * 函数说明: 1. 网络代次变化(重建)时关闭旧监听/客户端并重建 * 2. 无监听(启动失败)时重试启动 - * 3. 无客户端时尝试 accept,成功则复位状态机 - * 4. 有客户端时非阻塞 recv 逐字节喂入状态机 + * 3. 无客户端时**先门控**(net_can_accept,纯 RAM 读)再 accept,成功则复位状态机 + * 4. 有客户端时**先门控**(net_can_read)再非阻塞 recv 逐字节喂入状态机; + * 门控判为"无数据"时仍每 RM_LIVENESS_DIV 轮无条件收一次(兜底) * 5. 对端关闭/真错误时关闭客户端;若监听 Socket 随客户端失效 * (MCU 单连接模型)则重建监听,等待重连 + * ⚠ **门控只消除"无谓的往返",不消除"往返本身无上界"**:一旦真有活要干 + * (accept / recv / 3204 B 的 LCDMEM 应答),仍是"入队 + 等 netTask 回执", + * 最坏 ≈4 s。宿主 = uiTask 主循环;"搬出 uiTask"已完整评估并**决定不做** + * (实测刷新最坏周期 = LVGL 帧周期地板,与网络无关),理由与残留暴露 + * 见 docs/问题记录/UI任务看门狗停摆分析.md §9.4 */ void ui_net_sink_poll(void) { int n; @@ -470,6 +496,7 @@ void ui_net_sink_poll(void) { rm_parser_reset(); s_send_fail_run = 0U; /* 网络重建:失败累计清零、解除推送阻断(E-04) */ s_sink_blocked = 0U; + s_liveness_ctr = 0U; /* 网络重建:兜底节拍重新起算 */ (void)ui_net_sink_start(g_port); return; } @@ -480,19 +507,39 @@ void ui_net_sink_poll(void) { return; } - /* 无客户端时尝试接受连接 */ + /* 无客户端时尝试接受连接(I-1 门控:无连接可接受 → 0 次 RPC) */ if (g_client < 0) { + if (net_can_accept(g_listen) == 0) { + /* 监听控制块已消失(网络重建 / 异常释放)→ 置 -1 交回上方 + * g_listen < 0 分支重建监听。改前靠"每轮都 accept"撞出 BADF 来暴露, + * 加了门控就必须显式兜这一条,否则端口永久不再接受新客户端 */ + if (net_get_sock(g_listen) == NULL) { + g_listen = -1; + } + return; + } int c = net_accept(g_listen, NULL, NULL); if (c >= 0) { g_client = c; rm_parser_reset(); s_send_fail_run = 0U; /* 新连接:失败累计清零、解除推送阻断(E-04) */ s_sink_blocked = 0U; + s_liveness_ctr = 0U; /* 新连接:兜底节拍重新起算 */ } return; } - /* 有客户端时非阻塞收帧,逐字节喂入解析状态机 */ + /* 有客户端时非阻塞收帧(I-1 门控 + I-2 兜底): + * 无数据可读 → 多数轮次直接返回,省掉一次 net_recv 往返(该往返内部 + * osMessageQueuePut(osWaitForever) + xTaskNotifyWait(portMAX_DELAY),无上界); + * 但每 RM_LIVENESS_DIV 轮无条件收一次,防 RECV_OK 中断丢失把数据永久藏住 */ + if (net_can_read(g_client) == 0) { + if (((++s_liveness_ctr) & (RM_LIVENESS_DIV - 1U)) != 0U) { + return; + } + } else { + s_liveness_ctr = 0U; + } n = net_recv(g_client, g_rx, (int)sizeof(g_rx), NET_MSG_DONTWAIT); if (n > 0) { for (int i = 0; i < n; i++) { diff --git a/Drivers/BSP/UI/ui_net_sink.h b/Drivers/BSP/UI/ui_net_sink.h index d3193b0a..a225ec2e 100644 --- a/Drivers/BSP/UI/ui_net_sink.h +++ b/Drivers/BSP/UI/ui_net_sink.h @@ -40,13 +40,20 @@ extern "C" { int ui_net_sink_start(uint16_t port); /* - * 函数功能:非阻塞轮询 RemoDispBus 协议 + * 函数功能:轮询 RemoDispBus 协议(接受连接、收帧、逐字节解析) * 入口参数:无 * 返回值:无 * 限定条件:ui_net_sink_start() 已成功调用 - * 函数说明:1. 无客户端时尝试接受连接,成功即上报 INIT - * 2. 有客户端时用 DONTWAIT 收帧并解析(KEEPLIVE/INIT/KEY/LCDMEM) - * 3. 对端关闭或异常时复位客户端,等待下次重监听,不阻塞 LVGL 刷新 + * 函数说明:1. 无客户端时先门控(net_can_accept,纯 RAM 读)再尝试接受连接 + * 2. 有客户端时先门控(net_can_read)再用 DONTWAIT 收帧并解析 + * (KEEPLIVE/INIT/KEY/LCDMEM);门控判为无数据时仍每 32 轮兜底收一次 + * 3. 对端关闭或异常时复位客户端,等待下次重监听 + * ⚠ **不是"零成本非阻塞"**:门控只消除无谓往返;真有活时仍是 + * "入队 + 等 netTask 回执"(osMessageQueuePut(osWaitForever) + + * xTaskNotifyWait(portMAX_DELAY),均无上界;LCDMEM 应答 3204 B 最坏 ≈4 s) + * → 会拖住宿主(uiTask)的刷新;"搬出 uiTask"已完整评估并**决定不做** + * (实测刷新最坏周期 = LVGL 帧周期地板,与网络无关),见 + * docs/问题记录/UI任务看门狗停摆分析.md §9.4 */ void ui_net_sink_poll(void); diff --git a/docs/UI设计/RemoDispBus协议与接收设计说明.md b/docs/UI设计/RemoDispBus协议与接收设计说明.md index 46c53abd..7901c412 100644 --- a/docs/UI设计/RemoDispBus协议与接收设计说明.md +++ b/docs/UI设计/RemoDispBus协议与接收设计说明.md @@ -239,6 +239,20 @@ if (n > 0) { 2. **最小帧长判据统一**:`remo_handle` 以 `n < 5` 拦截(最短合法帧 5 字节),两端一致——旧 MCU `n < 6` 会丢 5 字节 KEEPLIVE/INIT 帧的隐患已随统一消除。 3. **断线重连统一**:`ui_net_sink_poll` 在客户端断开后用 `net_get_sock(g_listen) == NULL` 判监听是否失效(MCU 单连接模型下监听随客户端一起失效),失效即 `bind_listen()` 重建——两端一致,并修复了 MCU 单连接模型断线后无法重连的隐患。 4. **协议兼容**:线格式与命令语义不变,任一端都能与 DTU-RemoteLCD 参考客户端互通;双端行为一致已由 e2e 回归(`simulator/tests/check_remo_sink.py`,0/25600 位差异)与双端构建(Keil + CMake 均 0 错误 0 警告)验证。 +5. **驱动者是宿主,不是协议自己**:`ui_net_sink_poll()` 由宿主周期调用——MCU 端目前 = **uiTask 主循环 10 ms**,模拟器 = `main.c` 主循环。协议本身不含节拍、不阻塞。 + +> ✅ **门控已落地(2026-10-10)**:`ui_net_sink_poll()` 现在**先查后动**——`net_can_accept()` / +> `net_can_read()` 纯 RAM 读控制块判"有没有活",无活直接返回,不再每 10 ms 发一次 +> `net_accept`(原 100 msg/s 队列压力,与 FTP 抢深度 8 的队列)。门控判"无数据"时仍 +> **每 32 轮无条件 recv 一次**兜底(`readable` 只由 RECV_OK 中断置位,中断丢失时纯门控 +> 会把数据永久藏住)。模拟器后端恒返回"就绪",行为逐位不变。 +> +> ⚠ **残留暴露(已知,刻意不处理)**:门控只消除"无谓的往返",消除不了"往返本身无上界"—— +> 客户端在连且 netTask 拥塞时,3204 B 的 LCDMEM 应答仍可能让宿主任务等最长 ≈4 s。 +> "把推流搬出 uiTask(独立任务 + 帧 seqlock)"已完整评估并**决定不做**:实测刷新最坏周期 +> (`uip` ≈61 ms)是 **LVGL 帧周期地板**、与网络无关,搬任务不会让刷新变快。 +> 否决理由、残留暴露的观测手段(`[PROF]` 的 `uis`/`uisn`)见 +> `docs/问题记录/UI任务看门狗停摆分析.md` §9.4。 --- diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index 32af3078..9ab0721a 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -456,6 +456,7 @@ snapshot == required → miss_streak=0、证据门闩复位、喂狗 | R4 | **NAND 真实卡死时 FTL 直接喂狗** | 设备存活但存储功能冻结(与 ORE 自愈同类边界)。若要求"NAND 死也必须复位",需给 `nand_ftl_wd_feed()` 加次数/时限上限 | | R5 | **栈溢出与断言在复位原因上不可区分**(都是 `SFTRSTF`) | 可接受;串口分别打 `[ERR] STACK OVERFLOW` / `[ERR] ASSERT FAILED`,`selftest.log` 侧另有记录 | | R6 | **`RCC_CSR` 读后即清** | 已在 `main.c` 统一处理;漏清会让下次软复位读回上次标志 | +| R7 | **UI 推流的网络往返卡死不占心跳位**(产品掩码仍 `0x77`) | 刻意:往返卡死的根因在 netTask,netTask 已在掩码内、由它暴露;为"远端显示卡了"新增一位只会把观感问题升级成整机复位。UI 心跳已由 P1-4a 自产、不受推流影响;推流耗时看 `[PROF]` 的 `uis`/`uisn`。详见 `docs/问题记录/UI任务看门狗停摆分析.md` §9 | ### 9.2 未办项(登记簿同步) diff --git a/docs/网络/BSD_Socket_API_使用指南.md b/docs/网络/BSD_Socket_API_使用指南.md index 9d10d066..0311fde7 100644 --- a/docs/网络/BSD_Socket_API_使用指南.md +++ b/docs/网络/BSD_Socket_API_使用指南.md @@ -175,7 +175,7 @@ int net_close_sock(net_sock_t *p_sock); - **`netTask` 不可向自身发消息**:`netTask` 中直接调 `net_send()` 会死锁(等待自己处理消息),必须在 `netTask` 中使用 `net_send_sock()` 等内部版本 - **阻塞 recv 不再独占 netTask(L3 pending 化,2026-09-22)**:无数据时公共 `net_recv()` / `net_recv_to()` **登记挂起请求后返回**(每 fd 至多一个,禁止覆盖——覆盖即丢失前调用方通知 = 永久挂死);完成由 `net_pending_tick()` 按主循环节拍检查(①数据 ②到点 ③断连,看门狗 1s)并经任务通知唤醒调用方——等待期间调用方在自己上下文睡通知,netTask 零占用(消除 E-02 头阻塞)。副作用:`buf` 指针须保持有效至返回;close / 网络重建 / 陈旧重同步会**显式取消并通知**(`NET_ERR_CONNRESET` 等),调用方永不挂死 - **队列阻塞语义**:只有 `osMessageQueuePut` 失败(8 长度固定队列,极端情形)时公共 API 阻塞;recv 挂起等待**不计入**队列占用(完成前队列中无消息滞留) -- **哪些操作要求 netTask 已运行**:`net_listen()` / `net_accept()` / `net_connect()` / `net_send()` / `net_recv()` / `net_close()` / `net_relisten()` / `net_resync_listen()` 全经消息队列 → 只能在就绪屏障后的任务调用;`net_socket()` / `net_bind()` 为直接调用,但同样建议屏障后 +- **哪些操作要求 netTask 已运行**:`net_listen()` / `net_accept()` / `net_connect()` / `net_send()` / `net_recv()` / `net_close()` / `net_relisten()` / `net_resync_listen()` 全经消息队列 → 只能在就绪屏障后的任务调用;`net_socket()` / `net_bind()` 为直接调用,但同样建议屏障后;**`net_can_accept()` / `net_can_read()` 是纯 RAM 读控制块**(§10.1),不入队、不等回执,**任意任务可安全周期调用**(用途 = 门控,把"无谓的 RPC"降为 0) ### 3.6 芯片/本层状态对账与重监听自愈(Trap 09 / 22 / 23) @@ -873,6 +873,23 @@ int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen); > 单连接模式:监听 Socket 在 CONNECT 后**自身即数据通道**(§5.1)。`net_accept` 失败时若状态为 CLOSED 且 `auto_relisten` 开启,内部触发**非阻塞**重监听(失败交重试轮询,§3.6)。中断丢失也不会让监听死亡——对账在 200ms 内修正"本层 LISTENING 而芯片监听消失"等失同步。 +#### net_can_accept / net_can_read - 就绪门控(**纯 RAM 读,不走消息队列**) + +```c +int net_can_accept(int sockfd); /* 1 = 有连接可接受;0 = 无 */ +int net_can_read(int sockfd); /* 1 = 应发一次 recv;0 = 可跳过本轮 */ +``` + +| 项 | 说明 | +|------|------| +| 代价 | **零 RPC**:只读 Socket 控制块(`net_get_sock()`),不入队、不等 netTask 回执 → 任意任务上下文可安全周期调用,**不被 netTask 忙碌程度拖住** | +| `net_can_accept` 判据 | `state == ESTABLISHED`,与 `net_accept_locked()` 严格等价(单连接模型:监听 Socket 自身在 CONNECT 后转数据通道) | +| `net_can_read` 判据 | `ESTABLISHED` → 看 `readable`;**非 `ESTABLISHED` 一律返 1**(必须让上层 recv 一次拿到 EOF/错误并复位自身状态,门掉它会把"连接已消失"永久藏住);描述符失效也返 1(让 recv 报 BADF 走既有错误分支) | +| 用途 | **门控**:无活时跳过 `net_accept()`/`net_recv()`——两者内部都是 `osMessageQueuePut(osWaitForever)` + `xTaskNotifyWait(portMAX_DELAY)`,**均无上界**(队列深 8 且与 FTP/timeSync 共享) | +| ⚠ 不可作为唯一判据 | `readable` 只由 RECV_OK 中断置位,而 §3.6 的三类对账**不含**"芯片 RX 非空但 `readable=0`" → 中断丢失时 `net_can_read()` 恒返 0。**调用方必须保留低频兜底 recv**(`ui_net_sink.c` 每 32 轮无条件收一次,见 `docs/问题记录/UI任务看门狗停摆分析.md` §9.3.1) | +| 竞态 | netTask 是唯一写者,单字读;最坏"晚一轮发现"(netTask 每 1 ms 就 `net_poll()`)或"扑空"(RPC 返回 `NET_ERR_WOULDBLOCK`),均无副作用 | +| 模拟器端 | `net_socket_sim.c` 同名实现**恒返回 1**——Winsock 本就非阻塞、无 RPC 成本,恒 1 使门控在模拟器上逐位等价于改前行为 | + #### net_connect - 连接服务器 ```c @@ -1395,6 +1412,7 @@ CH395F 底层驱动返回的状态码(`ch395f_get_cmd_status()` 或 `ch395f_po | 日期 | 变更 | |------|------| +| 2026-10-10 | 新增 **`net_can_accept()` / `net_can_read()` 就绪门控 API**(§10.1):纯 RAM 读控制块、**不走消息队列、不等 netTask 回执**,供实时性敏感任务在发 RPC 前判断"有没有活"(动因 = `ui_net_sink_poll()` 每 10 ms 一次 `net_accept` 把 uiTask 拖成最坏 ≈50 ms;原拟登记 N-19 / C7,当日结案不入登记簿;设计与实测见 `docs/问题记录/UI任务看门狗停摆分析.md` §9);§3.5 补"netTask 运行要求"例外条;⚠ 记录两条使用约束——**不可作为唯一判据**(`readable` 只由 RECV_OK 中断置位,须保留低频兜底 recv)与**模拟器后端恒返 1**(`net_socket_sim.c`,Winsock 无 RPC 成本) | | 2026-09-23 | 同步 L3 pending 化、D10 非阻塞重监听、Trap 22 对账、Trap 23 排渣隔离后的现状:新增 `net_recv_to` / `net_sock_state` / `net_resync_listen` / `net_relisten`(§10.1)及 `net_getsockname`;§3 新增 3.6(对账与自愈四机制)、3.3/3.5 改写(recv 挂起模型、队列语义、netTask 运行要求);§4/§5 示例更新(监听由就绪屏障后服务任务建立、`net_sock_state` 轮询);§10.2 改述 `net_process_messages()` 每轮节奏(重监听重试 → 挂起 recv 完成 → 200ms 对账 → 队列),补 `timeout_ms`/新消息类型说明;新增 §11.2 分片阻塞读(`net_recv_to`);§12.1/12.3/12.4/12.9 更新,新增 §12.10(自愈行为数据与排障判据,含 Trap 21 结案) | | 2026-09-21 | **修正公开 API 的错误码回传**:`net_msg_t` 按值拷入队列 → netTask 对消息的写入调用方不可见,改用 `xTaskNotify()` 通知值回传具体错误码(负值),公开 API 恢复 errno 后统一返回 -1(此前被覆盖成 `NET_ERR`,使 `net_get_errno() != NET_ERR_WOULDBLOCK` 的"空轮询"判断恒成立 → `ui_net_sink` 连上即 `net_close`);§12.4 补单连接模型断开处理(EOF 时不得 `net_close` 监听 socket);§12.9 补错误码回传约束 | | 2026-09-15 | 同步配置引擎属主变更(P1-3):`net_init` 调用方说明改为"netTask 只读 RAM 副本"(网口配置由 defaultTask 在就绪屏障前加载);模拟器端启动序列补 `net_cfg_save_run()`(主循环 = 模拟器侧配置引擎属主,执行 UI 保存请求) | diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md index cefde9b8..f8d25d63 100644 --- a/docs/问题记录/UI任务看门狗停摆分析.md +++ b/docs/问题记录/UI任务看门狗停摆分析.md @@ -7,6 +7,10 @@ > **状态**:**分析完成,P0/P1/P2 处置已落地并上板验证**(2026-10-10)。P0(adc 压环条件 + > 截止期重同步)修复后累计 22 次启动 0/22 复现;P1-4a 已解耦 UI 心跳与 netTask 往返;P2 常开 > 探针已上线。详见 §8「处置落地」。 +> **遗留 → §9**:P1-4a 只解耦了**心跳**,**刷新仍被 `ui_net_sink_poll()` 拖**(用户可观测的 +> "刷新不稳")。§9 已**结案**:A 门控 + 兜底**已落地并 10/10 上板验证**;"把推流搬出 uiTask" +> 的 B 方案**已评估并决定不做**。⚠ 实测修正了当初的归因——刷新最坏周期(`uip` ≈61 ms)是 +> **LVGL 帧周期地板,与网络无关**。详见 §9.3.2 / §9.4。 > **关联**: > [`../待处理内容.md`](../待处理内容.md)(**N-17** 心跳缺口登记)、 > [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、 @@ -304,8 +308,166 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ --- +## 9. 彻底解耦(2026-10-10 结案:**A 已落地,B 决定不做**) + +> **结案状态**:**§9.3 方案 A(门控 + 兜底)已落地并验证**(`net_can_accept()`/`net_can_read()` +> + `ui_net_sink_poll()` 先门控再 RPC;`@build` 0 错 0 警、`@test` 10/10 PASS、e2e 0/25600 位差异); +> **§9.4 方案 B(把推流搬出 uiTask)已评估并决定不做**,理由与残留暴露写在 §9.4。 +> **动因**:P1-4a 之后"心跳"不再被推流拖,但**刷新仍被推流拖**——现象从"偶发复位"变成 +> "画面周期性卡顿 / 刷新间隔不稳"。**实测结论修正了这个动因**:刷新的最坏周期其实是 +> LVGL 帧周期地板,与网络无关(§9.3.2)。 +> **登记**:N-19 / C7 **不入登记簿**——当日登记当日结案,结论只沉淀在本节。 + +### 9.1 先把"poll 运行时间不可预测"量化:3 个**没有上界**的等待点 + +`ui_net_sink_poll()` 自己不产生耗时——它只是**向 netTask 发 RPC 并原地等回执** +(`net_*` 公开 API 的固定模式:`osMessageQueuePut` → `net_wait_result`,见 +`Drivers/BSP/NET/net_socket.c:851`)。 + +| poll 内调用 | 触发频率 | 实际代价 | 上界 | +|---|---|---|---| +| `osMessageQueuePut(..., osWaitForever)` | 每次 RPC | 队列深 **8**,与 FTP / timeSync 共享(登记簿 **N-10**) | **无**(满则永久阻塞) | +| `net_wait_result()` → `xTaskNotifyWait(..., portMAX_DELAY)` | 每次 RPC | 等 netTask 排到本条(它还要跑 `net_poll` + 3 个自愈 tick + FTP 大文件) | **无** | +| `net_accept()` | **无客户端时每 10 ms 一次**(台架常态)= **100 msg/s** | 一次往返 + `net_accept_locked()` 内再跑一遍 `net_poll()`(整轮 SPI 中断读) | 实测最坏 **≈50 ms**(§8 `uis≈uiw≈50ms`) | +| `net_recv(NET_MSG_DONTWAIT)` | 有客户端时每轮 | 同上(`net_recv_locked()` 亦先 `net_poll()`) | 同上 | +| `net_send()` LCDMEM 应答 **3204 B** | 客户端每请求一帧(peer ≈10 次/s) | `net_send_locked` 按 `NET_SEND_CHUNK_MAX = min(CH395F_SEND_BUF_SIZE_BYTES=2×512, NET_DMA_MAX_PAYLOAD=2KB) = 1024 B` 拆 **4 块**,每块写完等 `SENBUF_FREE`,`NET_SEND_TIMEOUT_MS=1000` | 最坏 **≈4 s**,调用方全程等 | + +**结论**:poll 的耗时 = **"netTask 什么时候有空"**,不是"UI 算得慢"。netTask 是全部 socket +操作的**唯一执行者**,这个等待天然不可预测——E-02 实测一次空闲 FTP 登录把远端显示冻结 +**17.7 s**(`docs/问题记录/错误码传播审计.md`);帧周期真机 **61.6 ms** vs 模拟器 **6.8 ms** +(`docs/测试说明/链路延迟测试说明.md` L8)同源。**任何"把心跳挪个位置"的改法都改变不了这一点。** + +### 9.2 "彻底解决"的三条判据 + +| # | 判据 | 现状 | +|---|---|---| +| **J1** | uiTask 主循环里 **0 次** net 往返 → 刷新周期只由 LVGL + 调度决定 | ❌ 每轮 1~2 次 | +| **J2** | 心跳自产,不依赖任何其他任务 | ✅ P1-4a | +| **J3** | 推流的阻塞只影响推流自己(故障域隔离)+ 有观测 | ❌ 与刷新同域 | + +### 9.3 方案 A:门控——让"没事的时候"poll 真的不做事 + +**做法**:发 RPC 之前先用**控制块 RAM 状态**判断"有没有活要干",没有就直接返回。 + +| 需要的信息 | 现成 RAM 判据(`net_get_sock()` 是公开 API、纯 RAM 读) | 语义依据 | +|---|---|---| +| 有连接可接受 | `p->state == NET_SOCK_STATE_ESTABLISHED` | 与 `net_accept_locked()` 同判据(单连接模型:监听 Socket 自身在 CONNECT 后转数据通道) | +| 有数据可读 | `p->readable != 0` | `net_poll` 在 RECV_OK 置位、`net_recv_locked` 读空清零(`net_select.c` 的 `check_socket_readable` 用的就是它) | +| 对端已关闭 | `p->state == NET_SOCK_STATE_CLOSED` | 需发一次 recv 拿 EOF(0) 并触发 `auto_relisten` | + +**落点(关键,别踩坑)**:判据**不能写进共享的 `ui_net_sink.c`**,必须放**平台后端**—— +模拟器 `simulator/sim/net_socket_sim.c` 的监听块在 `accept()` 成功**之前**不会是 +`ESTABLISHED`,直接门控会让**模拟器永远连不上**。定稿取 **net 层公开 API**(不是 UI backend): + +```c +/* Drivers/BSP/NET/net_socket.h(新增,双端各一份实现) */ +int net_can_accept(int sockfd); /* 1 = 有连接可接受;0 = 无(纯 RAM 读,无 RPC) */ +int net_can_read(int sockfd); /* 1 = 有数据可读或已 EOF;0 = 无 */ +``` + +选 net 层而非 UI backend 的三条理由:① 语义属于 socket,不属于 UI;② 模拟器后端本就是 +`net_*` 的镜像实现,加在那里比在 UI backend 里再开一条"绕过 net API"的旁路更收敛; +③ timeSync / FTP 后续同样能用(N-4 pending 二期落地前先给它们减负)。 +共享的 `ui_net_sink.c` **只调这两个 API,不直接读控制块**。 + +**竞态论证**:单字读、netTask 单写者;最坏两种结果都可接受—— +① **晚一轮发现**(≤1 ms:netTask 自己每 1 ms 就 `net_poll()` 一次,`readable`/`state` 早已刷新); +② **扑空**(状态刚变,RPC 返回 `WOULDBLOCK`,现有分支已处理)。 + +**收益**:台架常态(无客户端)poll 从"一次往返(最坏 50 ms)"降到**两次 RAM 读(< 2 µs)**; +顺带销项 **N-10**(100 msg/s 队列压力,与 FTP 抢深度 8 的队列)。 +⚠ **A 不改变"有客户端时"的行为**——那正是 B 的靶子。 + +#### 9.3.1 落地补充(2026-10-10 实现时新增的两条,设计阶段未预见) + +| # | 落地时发现的坑 | 处置 | +|---|---|---| +| **I-2 兜底收帧** | `readable` **只由 RECV_OK 中断置位**(`handle_recv_event`),而 Trap 22 的 `net_reconcile_tick()` 三类对账**不含**"芯片 RX 非空但 `readable=0`" → 该中断一旦丢失,纯门控会把已到达的数据**永久藏住**(改前靠每轮 `net_recv_locked()` 里的 `ch395f_get_recv_len()` 顺带发现) | `ui_net_sink.c` 加 `RM_LIVENESS_DIV=32`:门控判"无数据"时仍**每 32 轮无条件 recv 一次**(10 ms 节拍 → 最坏额外延迟 ≈320 ms;RPC 频率仍从 100/s 降到 ≈3/s)。**教训:门控类优化必须保留低频兜底,否则是拿"自愈能力"换性能** | +| **I-3 监听失效兜底** | 加了 accept 门控后,若监听控制块消失(网络重建 / 异常释放),`net_can_accept()` 恒返 0 → 改前"每轮 accept 撞 BADF"这条**暴露路径随之消失** → 端口永久不再接受新客户端 | 门控返回 0 时补一次 `net_get_sock(g_listen) == NULL` 判定,命中即 `g_listen = -1` 交回既有重建分支 | + +**双端语义**:模拟器 `net_socket_sim.c` 的两个同名实现**恒返回 1**——Winsock 本就非阻塞、 +没有 RPC 成本,恒 1 使门控在模拟器上**逐位等价于改前行为**(含"每轮都 recv"的自愈特性), +`simulator/tests/check_remo_sink.py` 的协议回归因此仍有效。 + +#### 9.3.2 A 落地实测(2026-10-10,`@test` PASS,33 个 `[PROF]` 样本 / 开机 29 s / 台架无客户端) + +| 字段 | 改前(§8 基线) | **改后实测** | 判读 | +|---|---|---|---| +| `uis` 推流往返最坏 | **≈50 ms** | **1 µs**(33 样本 max,`uisn` 全程 0) | 门控生效,**降 5 个数量级**;§9.6 目标 ≤50 µs 达成 | +| `uiw` UI 单轮工作最坏 | ≈50 ms(且 `uiw≈uis` → 单轮几乎全是推流) | **11.4 ms** | 剩下的**只含 LVGL**——网络已不在刷新路径上 | +| `uil` 周期最近值 | 10 ms | 9999 µs | 标称节拍未变 | +| `uip` 周期最坏 | ≈61 ms | **60.9 ms** | ⚠ **未达标**(§9.6 原写 ≤15 ms),但**已与 `uis` 脱钩** → 见下 | +| `gl` 丢样 | 0 | 0 | DWT 数据可信 | + +⚠ **`uip` 未达标不是 A 失败,是判据当初归因错了**:`uis` 已到 1 µs 而 `uip` 仍 60.9 ms, +说明 uiTask 循环周期的最坏值由 **LVGL 整帧渲染**决定(`docs/测试说明/链路延迟测试说明.md` L8 +实测帧周期真机 **61.6 ms**,与实测 60.877 ms 吻合),推流从来只是**叠加**在这条地板上的 +**额外**抖动。**这条地板不是网络问题**——正是这条实测结论让 §9.4 的方案 B 被否决。 +**若目标是把刷新周期本身压下来,那是另一个议题(LVGL 帧周期 / 渲染模式),不在本案范围。** + +### 9.4 方案 B(把推流搬出 uiTask,独立 `uiSinkTask`)——**已评估,决定不做(2026-10-10)** + +**当初的动机**:客户端一连上,几乎每轮都要 `net_send` 3204 B,这个等待由芯片 FIFO / 链路 / +netTask 决定,UI 侧无法设上界(半途截断 = 客户端 CRC 失败 = 黑屏,E-04 已定死不能碰)。 +按"uiTask 里一次 net 调用都不留"的思路完整设计过:新任务(256 words / BelowNormal / +不进 `required_mask`)+ 帧 seqlock 取帧 + 栈与堆两条预算路线 + 观测面迁移。 + +**否决理由(全部来自 §9.3.2 的实测)**: + +1. **治不到病**:A 之后 `uis` = 1 µs,而 `uip` 最坏仍是 60.9 ms = **LVGL 整帧渲染地板** + (文档实测帧周期 61.6 ms)。搬走推流**不会让刷新变快**,只是把同一段等待换个任务背。 +2. **收益只剩极端并发下的"故障域隔离"**:即"客户端在连 + netTask 被 FTP 挤住"这一格 + (E-02 的 17.7 s 是**改前**、FTP 空闲登录场景)。那是**远端显示的观感**问题, + 设备本体的复位风险已由 P1-4a 解除。 +3. **成本不成比例**:新增任务 + 帧 seqlock + `ui_port`/backend 双端改动 + **必改 `.ioc`** + (栈或堆两条路线都要 CubeMX 重生成 + 存活审计)+ 十余处改动与全套回归; + 其中"不动 uiTask 栈"那条路线还会把堆 `min_ever` 压到 S11 告警线(3072 B)以下。 + +**不做 B 的残留暴露(如实记录)**:客户端在连且 netTask 拥塞时,`ui_net_sink_poll()` +仍可能在 uiTask 里等最长 ≈4 s(LCDMEM 应答 4 块 × `NET_SEND_TIMEOUT_MS`), +表现为**本地画面与远端同时卡住**。观测面已常驻,无需新增埋点:`[PROF]` 的 `uis` +(推流往返最坏)与 `uisn`(此刻仍在推流段内多久)——**`uisn` 持续非 0 就是这一格真发生的铁证**。 +若日后它成为实际问题,**正确方向也不是 B**,而是先治 netTask 的排队公平性 +(登记簿 N-4 pending 二期),那才是同一条根因的上游。 + +### 9.5 明确不做的四条(及理由) + +| 备选 | 为什么不做 | +|---|---| +| 只调心跳顺序(=现状 P1-4a) | 心跳不再被拖,**刷新仍然被拖**——不治本次的病 | +| 给 `net_*` 加"有界等待"版 | 回执通道是"每任务一个 32 位通知值",超时后**迟到的回执会被下一次调用的零等待拾取当成新结果**(`net_wait_result` 注释里 L7 挂死 / 整段错位的同款根因)。要做对必须引入请求序号,代价高于搬家 | +| 把推流塞进 netTask | 零往返、零新增 RAM,最"彻底";但共享源码 `ui_net_sink.c` 要改成走 backend 的 socket 端口(双端同改)、netTask 这个唯一执行者更胖、且一个 UI 侧 bug 直接变成整机复位。**同样不做**;若 netTask 排队公平性真成问题,从 N-4 二期治上游,而不是把 UI 搬过去 | +| 提高 uiTask 优先级 | §8 已明确警告它会**掩盖** §4.2/§4.4 两个真实缺陷;而且它根本不解决"UI 在等 netTask" | + +### 9.6 落地与验收判据 + +``` +A(门控 + 兜底)→ @build + @test + e2e → 看 [PROF]:uis 掉到 µs 级 → 本案结案 + (B 已评估否决,见 §9.4) +``` + +| 判据 | 目标值 | 状态(2026-10-10) | +|---|---|---| +| `uis`(无客户端) | ≤ **50 µs**(门控生效的直接证据;改前 ≈50 ms) | ✅ **A 已达成:实测 1 µs**(§9.3.2) | +| `uiw` 与 `uis` **不再同值** | 改前 `uiw≈uis≈50 ms` | ✅ **A 已达成:11.4 ms vs 1 µs** | +| `uip`(uiTask 循环周期最坏) | ~~≤15 ms~~ → **修订**:`uip` 地板 = LVGL 帧周期 ≈61 ms,**与网络无关,本案不治它**;只要求它**不随网络活动抬升**(`uis` 到 µs 级即为达成) | ⚠ 判据已按实测修订(§9.3.2) | +| 回归 | `@test` 连续 **10 次** 0 复现;`@build` 0 错误 0 警告;模拟器 `ui-verify` 五步 + `simulator/tests/check_remo_sink.py` e2e **0/25600 位差异** | ✅ **A 已达成(2026-10-10)**:`@build` 0 错 0 警;**`@test` 10/10 PASS**(每轮 `uis` ≤ **1 µs**、`uisn` ≡ 0、`task stall` 0 次、`[ERR]` 0 条、`uiw` ≤ 11.36 ms、`uip` ≤ 61.35 ms);e2e `check_remo_sink.py` **0/25600 位差异**;`check_menu_rows.py` PASS;内容页文本断言 50/50(像素比对需本机 `simulator/tests/ref/`,该目录不入库)。⚠ 10 连跑中 `net init: OK` 的 `[+Nms]` 由 ≈589 ms 变到 ≈9.1 s,**是采集零点亮盘/校验/复位耗时推移,非固件变慢**:同一日志内 `reset cause` 在 +8517 ms、`net init: OK` 在 +9141 ms → **固件自身仍 ≈624 ms**;17:31 的旧镜像日志(本次改动之前)已有同形态 | + +### 9.7 风险与回退 + +| 风险 | 缓解 | +|---|---| +| 门控误判(芯片丢中断 → `state`/`readable` 长期不同步) | netTask 已有 `net_reconcile_tick()` 200 ms 对账 + 去抖自愈(Trap 22);门控只是"少发 RPC",**不新增自愈责任**;`readable` 侧另有 **I-2** 每 32 轮无条件 recv 兜底 | +| 监听控制块消失 → 门控恒返 0,端口不再接受新客户端 | **I-3**:门控返 0 时查 `net_get_sock(g_listen)`,消失即 `g_listen = -1` 交回既有重建分支 | +| 回退 | 门控是纯函数级改动,只决定"要不要发 RPC",**单独可回退**(去掉两处 `net_can_*()` 判断即恢复改前行为),无数据结构 / 协议 / 任务变更 | + +--- + ## 变更记录 | 日期 | 变更 | |---|---| | 2026-10-10 | 创建。基于唯一留存失败日志(`serial.log` @17:36)+ 代码走查完成归因:否掉"CPU 饱和"(实测占空比 10%~20%,`ring` 速率反证无空转),定位为**优先级饥饿(uiTask 唯一 BelowNormal)+ UI 心跳依赖 netTask 往返**;另发现"无新帧仍压环"的独立正确性缺陷;给出 P0/P1/P2 分层处置与"先测量后修"的推荐顺序 | +| 2026-10-10 | 新增 **§9 彻底解耦方案(待落地)**:量化 `ui_net_sink_poll()` 的 3 个**无上界**等待点(队列 `osWaitForever` / `xTaskNotifyWait(portMAX_DELAY)` / LCDMEM 应答 3204 B 按 1024 B 拆 4 块等 `SENBUF_FREE`,最坏 ≈4 s),指出"poll 耗时 = netTask 什么时候有空";给出 **A 门控**(`net_can_accept()`/`net_can_read()` 纯 RAM 判据,落点必须在平台后端——模拟器监听块 accept 前不是 `ESTABLISHED`)+ 一套"把推流搬出 uiTask"的方案(**同日评估后否决并删除,见下面两轮**);记录四条**明确不做**的备选与理由、验收判据、风险与回退。**代码未改动**,登记 **N-19 / C7** | +| 2026-10-10 | **§9.3 方案 A 已落地**:`net_socket.c/.h` 新增 `net_can_accept()` / `net_can_read()`(纯 RAM 读控制块、零 RPC),`net_socket_sim.c` 同名实现恒返 1(模拟器行为逐位不变),`ui_net_sink_poll()` 改"先门控再 RPC",并补 **I-2 兜底**(每 32 轮无条件 recv,防 RECV_OK 中断丢失把数据永久藏住)与 **I-3 监听失效兜底**(控制块消失 → `g_listen=-1` 重建);`ui_net_sink.h/.c` 注释订正(原"非阻塞轮询 / 不阻塞 LVGL 刷新"表述不准确 → 改为"门控只消除无谓往返,真有活时往返仍无上界")。验证:Keil **0 错 0 警**(Code 256276→256368,+92 B)、模拟器构建 OK、`check_menu_rows.py` PASS、`check_coding_standard.py` PASS、**`@test` PASS**。**实测(§9.3.2):`uis` 50 ms → 1 µs、`uiw` 50 ms → 11.4 ms(只剩 LVGL)、`gl=0`**;`uip` 仍 60.9 ms = LVGL 帧周期地板,§9.6 判据已据此修订 | +| 2026-10-10 | **A 收口 + 方案 B 否决并删除**(用户定案"B 不做")。① **A 收口**:`@test` **10/10 PASS**(每轮 `uis` ≤1 µs、`uisn` ≡ 0、0 `task stall`、0 `[ERR]`)、e2e `check_remo_sink.py` **0/25600 位差异**、内容页文本断言 50/50。② **B 删除**:§9.4 由完整设计(新任务 256 words / BelowNormal / 不进 `required_mask`、帧 seqlock、栈与堆两条预算路线、观测面迁移、12 处改动清单)改写为**否决记录**——实测 `uis`=1 µs 而 `uip` 仍 60.9 ms(LVGL 帧周期地板)证明"搬出 uiTask"**不会让刷新变快**,收益只剩极端并发下的观感隔离,而成本含**必改 `.ioc`** 的 CubeMX 重生成;残留暴露与观测手段(`uis`/`uisn`)如实保留。③ **同步清理**:§9.5/§9.6/§9.7 去掉 B 条目;**N-19 / C7 不入登记簿**(当日结案,`docs/待处理内容.md` 保持无该编号);`子系统设计/看门狗设计说明.md` §9.2 的 N-19 行移除;`RemoDispBus协议与接收设计说明.md` 去掉"规划中改独立任务"段;`ui_net_sink.c/.h` 注释去掉"应搬出 uiTask"的指向 | diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index 1055755b..085e57e9 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -320,7 +320,7 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 |------|------|------|------| | **N-02** | **FTP 无鉴权**:`cmd_pass` 无条件 `send_simple_response(socket, 230, STATUS_230)` | `lftpd.c:367-371`(`cmd_user :539` 亦不校验) | 最小可用方案:`config.ini` 增 `[ftp] user/pwd_hash`,`cmd_pass` 用**定长比较 + 失败计数锁定**;生产固件默认关闭 FTP 写权限(`FF_FS_READONLY` 式的只读模式) | | N-09 | `cmd_pasv` 每次新建监听 `lftpd_inet_listen(0)`(随机端口) | `lftpd.c:373` | 在**单连接 socket** 上与 UI `:7003`、对时 `:8888` 争用同一物理 socket;建议限定监听资源池 + 显式"FTP 会话独占"策略,并在文档写明"一次只允许一个 TCP 服务活动" | -| N-10 | uiTask 以 **10 ms** 周期向 netMsgQueue 投递 accept 轮询 → ≈100 msg/s,与 FTP 共享深度 8 的队列 | `ui_net_sink.c` 轮询逻辑 | 队列满时 `osErrorResource` → UI 丢帧/重投。建议:把"服务就绪"改为事件驱动(netTask 在 LISTEN 状态变化时置位),UI 侧改为等待事件;或给队列扩容并在 UI 侧背压 | +| N-10 | uiTask 以 **10 ms** 周期向 netMsgQueue 投递 accept 轮询 → ≈100 msg/s,与 FTP 共享深度 8 的队列 | `ui_net_sink.c` 轮询逻辑 | 队列满时 `osErrorResource` → UI 丢帧/重投。建议:把"服务就绪"改为事件驱动(netTask 在 LISTEN 状态变化时置位),UI 侧改为等待事件;或给队列扩容并在 UI 侧背压。**→ 已销项(2026-10-10)**:未走"事件驱动",改用**门控**——`net_can_accept()`/`net_can_read()` 纯 RAM 读控制块,无活时**一次 RPC 都不发**(100 msg/s → 0);实测 `uis` 50 ms → **1 µs**。方案与实测见 `UI任务看门狗停摆分析.md` §9.3 / §9.3.2 | | P2-11 | `net_task.c` 对 `osEventFlagsGet` + Clear 非原子 | 台账 | 用 `osEventFlagsAcquire`/单次 Wait 消 TOCTOU | | P2-8 | `net_recv` 阻塞超时 30 s | 台账 | 缩短并让上层可取消 | | — | PHY 强制 100M 全双工,忽略自协商 | AGENTS/architecture | 与某些交换机会联不通;至少加"链路 up 失败 3 次后回退自协商" | @@ -679,7 +679,7 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea | F-1/F-2/F-3 | `lv_conf.h` 与 LVGL 9.3 对齐 + 主题/控件/flex 裁剪 | Flash −55 KB | | N-12/N-13 | `dhara_nand_is_free` 全页校验;`dhara_nand_copy` 处理 `DHARA_NO_PAGE` | 掉电注入测试通过 | | N-16 | 日志常驻 `FIL` + 批量 flush;UI 记录读缓存升级 | 单条日志 NAND 操作次数下降 ≥50% | -| N-10 | UI accept 改事件驱动(脱离 100 msg/s 轮询) | 队列占用可测量下降 | +| N-10 | ~~UI accept 改事件驱动(脱离 100 msg/s 轮询)~~ **已落地(2026-10-10,改走"门控"而非事件驱动)** | ✅ 无客户端时队列占用 **100 msg/s → 0**;实测 `uis` 50 ms → **1 µs**(`UI任务看门狗停摆分析.md` §9.3 / §9.3.2) | | D-1 | 芯片手册图片降采样 / 拆 submodule | clone 40 MB → ~8 MB | | D-3 | `check_doc_links.py` 入门禁 + 修 18 条断链 | 零断链 | | U1–U4 | UI 令牌化 + 原型库 + 生成器 + golden | 新增一页 ≤30 行 | diff --git a/simulator/sim/net_socket_sim.c b/simulator/sim/net_socket_sim.c index 4817f35e..6a717343 100644 --- a/simulator/sim/net_socket_sim.c +++ b/simulator/sim/net_socket_sim.c @@ -347,6 +347,39 @@ int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) return nidx; } +/* + * 函数功能:查询监听 Socket 是否有可接受的连接(与 MCU 端同名 API) + * 入口参数:sockfd - 监听 socket 描述符 + * 返回值:1 - 可尝试 accept;0 - 描述符无效 + * 限定条件:socket 已创建 + * 函数说明:Winsock 后端无"零成本预知"手段(要嘛真调 accept、要嘛 select()), + * 而 accept 本就是非阻塞的、**没有 MCU 端的 RPC 成本** → 恒返回 1, + * 使上层门控在模拟器上等价于"改前行为"(逐位一致,不引入新语义) + */ +int net_can_accept(int sockfd) +{ + if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { + return 0; + } + return 1; +} + +/* + * 函数功能:查询 Socket 是否"值得发一次 recv"(与 MCU 端同名 API) + * 入口参数:sockfd - socket 描述符 + * 返回值:1 - 可尝试 recv;0 - 描述符无效 + * 限定条件:socket 已创建 + * 函数说明:同 net_can_accept——Winsock recv 非阻塞、无 RPC 成本 → 恒返回 1, + * 上层门控在模拟器上不改变既有行为(含"每轮都 recv"的自愈特性) + */ +int net_can_read(int sockfd) +{ + if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { + return 0; + } + return 1; +} + /* * 函数功能:TCP Client 发起连接 * 入口参数:sockfd - socket 描述符 From 9231a3c22c761a084d6eaf9c4ab430895bf69bb2 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 22:41:19 +0800 Subject: [PATCH 13/47] =?UTF-8?q?adcTask=20=E4=BA=8B=E4=BB=B6=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E6=B6=88=E8=B4=B9=EF=BC=88P1-5=20=E6=96=B9=E6=A1=88?= =?UTF-8?q?=20a=EF=BC=89+=20=E8=A1=A5=20F4=20ADC=20=E4=B8=8A=E7=94=B5?= =?UTF-8?q?=E8=87=AA=E6=A0=A1=E5=87=86=20+=20=E4=BF=AE=E6=AD=A3=202=20ms?= =?UTF-8?q?=20=E5=BF=99=E7=AD=89=E8=BF=87=E6=9C=9F=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-5 方案 a(事件驱动消费,Task #32): - TIM6 1 kHz ISR 除触发 CONVST 外再调 adc_tick_irq_notify(),置位 ADC_WAKE_FLAG;adcTask 循环尾改 osThreadFlagsWait(osWaitForever), 删除 ADC_TASK_BEAT_MS 定时轮询 - adcTask 优先级 Normal(24) -> AboveNormal(32)(Src/freertos.c USER CODE 区) - 补消费者就绪门(规划未预见):TIM6 在 app_main_init 就启动,比调度器 早约 100 ms,未就绪时 ISR 不得 osThreadFlagsSet - 实测 6 次上板:idle 由 1~649 降到 0/20000 拍,frames 1000.0 Hz, seq-frames=7 正是就绪窗口;UI 侧 uip/uiw/uis 无退化 补 STM32F4 ADC 上电自校准(此前项目从未做过): - 核查证实裁剪版 HAL 无 HAL_ADCEx_Calibration_Start、裁剪版 CMSIS 头无 ADC_CR2_CAL -> 按 RM0090 13.3.6 在 Src/adc.c 的 USER CODE 区手写 adc1_calibrate(),MX_ADC1_Init() 末尾调用 - 新增 cubemx-survival 审计项 adc1-calibration-call / -impl(清单 21->23) - 实测校准前后同一拍 STM32 侧 6 路同向偏移 +10~+63 LSB,TPAFE 侧不变 修正两处过期表述(实测反证): - 原文 9 通道全超时最坏 = 18 ms 连占 有误:stm32_scan() 在首个超时处即 Stop+return,单拍上界 = 一次超时约 2~3 ms - adcm 约 2.1 ms 的峰值归因由 ADC 采集改判为每 10 s 一次的心跳日志; 6 次对照 adcp = 122 us(无超时)/ 2209 us(超时 1 次) - 同步修正 adc_task.c 两处过期注释;新发现超时拍 STM32 侧静默沿用 上一拍值的正确性代价,硬化排序记入 4.4 / 10.4(未实施) 验证:@build 0 错 0 警(Code 256728 -> 256868);@test 6/6 PASS; stack_report 7 任务全 PASS(adcTask 832 B / 余 59%);18 钩子全绿 --- .cubemx_survival_baseline.json | 4 +- AGENTS.md | 11 +- App/algo/README.md | 7 +- App/task/adc_task.c | 106 +++++++--- App/task/adc_task.h | 14 ++ Inc/adc.h | 4 + README.md | 2 +- Src/adc.c | 68 ++++++- Src/freertos.c | 11 +- Src/main.c | 10 +- docs/内存管理/RAM使用分析与优化方案.md | 6 +- docs/子系统设计/ADC采样节拍设计说明.md | 121 +++++++++--- docs/硬件平台/ADC通道映射说明.md | 5 +- docs/问题记录/UI任务看门狗停摆分析.md | 259 ++++++++++++++++--------- scripts/check_cubemx_survival.py | 22 +++ scripts/gen_task_table.py | 2 +- 16 files changed, 480 insertions(+), 172 deletions(-) diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index bea7223d..d5bb76cf 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -19,7 +19,9 @@ "ioc-keepusercode": "b5bea41b6c623f7c", "ioc-iwdg-present": "d3a6e667048c95db", "ioc-task-stacks": "3262f737d92dcbee", - "adc1-hal-enabled": "1a8ed6cff34f7ba6" + "adc1-hal-enabled": "1a8ed6cff34f7ba6", + "adc1-calibration-call": "8d5f4b40fb218913", + "adc1-calibration-impl": "d33bc4323e243248" }, "_note": "CubeMX 存活审计基线;改动用 --write 重建" } diff --git a/AGENTS.md b/AGENTS.md index 46a93876..ec86753e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v ## 平台约束 - `Inc/`/`Src/` 中 CubeMX 生成文件带 `USER CODE BEGIN/END` 标记:自定义代码**只写标记区之间**;`Drivers/BSP/` 为纯手工代码 -- **CubeMX 重生成会覆盖标记区之外的一切**(`.ioc` 的 `KeepUserCode=true` **只**保护 `USER CODE` 区;2026-09-29 两次重生成各覆盖 4~5 处定制,其中 P1 两处**不报编译错**、只在真出故障时暴露)。防止定制丢失按三层优先级:**L1 写进 `.ioc`**(凡 CubeMX 界面能表达的配置,如任务栈/优先级/队列/事件组/堆大小/IWDG 参数——权限最高,重生成只会照写)→ **L2 搬进 `USER CODE` 区**(**只信 CubeMX 当前模板真实生成的区名**,优先文件头 `USER CODE BEGIN 0` 这类从未变过的标准位)→ **L3 审计兜底**。**每次重生成后立刻跑 `python scripts/check_cubemx_survival.py`**(已挂 pre-commit `cubemx-survival` 钩子;改动 `.ioc`/`Src/`/`Inc/`/`task_watchdog`/`.uvprojx` 触发)——清单 21 项 + 哈希基线漂移检测,直接列出"被覆盖了哪几处"。**2026-10-09 第三次重生成(启用 ADC1)补充**:`Inc/FreeRTOSConfig.h` 的自造 `FREE_RTOS_CONFIG_HARDENING` 区**第二次**被抹 → 已改走 **L1**:`configCHECK_FOR_STACK_OVERFLOW=2` / `configUSE_MALLOC_FAILED_HOOK=1` 在 GUI 的 **FreeRTOS → Config parameters → Hook function related definitions** 里设(写进 `.ioc` 由模板生成,永不再丢;教训:**别凭印象断言"界面里没有该项"**,先翻标签页或查 `db/mcu/IP/*_Modes.xml`)。⚠️ 启用后 CubeMX 会在 `Src/freertos.c` 生成 `USER CODE BEGIN 4 / 5` 区(默认内容是**空桩**)——**钩子实现必须整体写进这两个区**(原先在 `USER CODE Application` 区的实现要搬走,否则同名函数两处定义 = **编译错**;配套 helper `hook_ptr_ok()` 须在钩子之前 → 放 `USER CODE BEGIN PD` 区)。审计项 `stack-overflow-hook-impl` / `malloc-failed-hook-impl` / `stack-overflow-hook-helper` 分别校验 4 / 5 / PD 三个区。⚠️ **不要依赖"自造标记区"**:2026-09-29 实测 CubeMX **换了一代中断函数模板**(旧 `HardFault_Handler`/`HardFault_Report` 自造区 → 新函数体内 `HardFault_IRQn 0`/`W1_HardFault_IRQn 0`),旧区**连同内容一起消失**(看起来仍像"遵守了规则")。**生成区内无标记位可用的改动**分两种处理(2026-10-09 更新):① 要**去掉/替换生成区里的一句调用** → 用宏改名升级为 **L2**:例 `Src/iwdg.c` 的 `USER CODE BEGIN 0` 里放空实现 `iwdg_deferred_start()` + `#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)`,生成那句 `HAL_IWDG_Init(&hiwdg)` 自动变空操作(`.map` 证实 `Removing ... HAL_IWDG_Init, 94 bytes`),生成区保持原样、**重生成零 diff**,不必再手工删。⚠ **IWDG 启动后无法停止**(唯一手段是复位),所以不能"反初始化",只能在启动前拦截。② 要**替换生成实体的定义**(4 个 Fault Handler 本体——本代模板整函数重写)→ 同一招升级为 **L2**(2026-10-09 已做):我们的 `__asm void X_Handler` 定义放 `USER CODE BEGIN 0`,**必须写在 `#define X X_Gen` 之前**(写在之后会被自己的宏改名),生成区恢复 CubeMX 原样(`HardFault_IRQn 0` / `W1_HardFault_IRQn 0` 标记区照留),不再用锚点注释。`.map` 复核口径:`startup_*.o(RESET) refers to stm32f4xx_it.o(.emb_text) for HardFault_Handler` + 4 个 `*_Gen` 各 `Removing ... (2 bytes)`。**L3 清单已清空:重生成应当零 diff**;若仍有 diff,跑审计脚本定位(`protected` 项的断言只能用代码符号,注释会被剥掉)。方法论详见 `docs/子系统设计/看门狗设计说明.md` §10.1/§10.2 +- **CubeMX 重生成会覆盖标记区之外的一切**(`.ioc` 的 `KeepUserCode=true` **只**保护 `USER CODE` 区;2026-09-29 两次重生成各覆盖 4~5 处定制,其中 P1 两处**不报编译错**、只在真出故障时暴露)。防止定制丢失按三层优先级:**L1 写进 `.ioc`**(凡 CubeMX 界面能表达的配置,如任务栈/优先级/队列/事件组/堆大小/IWDG 参数——权限最高,重生成只会照写)→ **L2 搬进 `USER CODE` 区**(**只信 CubeMX 当前模板真实生成的区名**,优先文件头 `USER CODE BEGIN 0` 这类从未变过的标准位)→ **L3 审计兜底**。**每次重生成后立刻跑 `python scripts/check_cubemx_survival.py`**(已挂 pre-commit `cubemx-survival` 钩子;改动 `.ioc`/`Src/`/`Inc/`/`task_watchdog`/`.uvprojx` 触发)——清单 23 项 + 哈希基线漂移检测,直接列出"被覆盖了哪几处"。**2026-10-09 第三次重生成(启用 ADC1)补充**:`Inc/FreeRTOSConfig.h` 的自造 `FREE_RTOS_CONFIG_HARDENING` 区**第二次**被抹 → 已改走 **L1**:`configCHECK_FOR_STACK_OVERFLOW=2` / `configUSE_MALLOC_FAILED_HOOK=1` 在 GUI 的 **FreeRTOS → Config parameters → Hook function related definitions** 里设(写进 `.ioc` 由模板生成,永不再丢;教训:**别凭印象断言"界面里没有该项"**,先翻标签页或查 `db/mcu/IP/*_Modes.xml`)。⚠️ 启用后 CubeMX 会在 `Src/freertos.c` 生成 `USER CODE BEGIN 4 / 5` 区(默认内容是**空桩**)——**钩子实现必须整体写进这两个区**(原先在 `USER CODE Application` 区的实现要搬走,否则同名函数两处定义 = **编译错**;配套 helper `hook_ptr_ok()` 须在钩子之前 → 放 `USER CODE BEGIN PD` 区)。审计项 `stack-overflow-hook-impl` / `malloc-failed-hook-impl` / `stack-overflow-hook-helper` 分别校验 4 / 5 / PD 三个区。⚠️ **不要依赖"自造标记区"**:2026-09-29 实测 CubeMX **换了一代中断函数模板**(旧 `HardFault_Handler`/`HardFault_Report` 自造区 → 新函数体内 `HardFault_IRQn 0`/`W1_HardFault_IRQn 0`),旧区**连同内容一起消失**(看起来仍像"遵守了规则")。**生成区内无标记位可用的改动**分两种处理(2026-10-09 更新):① 要**去掉/替换生成区里的一句调用** → 用宏改名升级为 **L2**:例 `Src/iwdg.c` 的 `USER CODE BEGIN 0` 里放空实现 `iwdg_deferred_start()` + `#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)`,生成那句 `HAL_IWDG_Init(&hiwdg)` 自动变空操作(`.map` 证实 `Removing ... HAL_IWDG_Init, 94 bytes`),生成区保持原样、**重生成零 diff**,不必再手工删。⚠ **IWDG 启动后无法停止**(唯一手段是复位),所以不能"反初始化",只能在启动前拦截。② 要**替换生成实体的定义**(4 个 Fault Handler 本体——本代模板整函数重写)→ 同一招升级为 **L2**(2026-10-09 已做):我们的 `__asm void X_Handler` 定义放 `USER CODE BEGIN 0`,**必须写在 `#define X X_Gen` 之前**(写在之后会被自己的宏改名),生成区恢复 CubeMX 原样(`HardFault_IRQn 0` / `W1_HardFault_IRQn 0` 标记区照留),不再用锚点注释。`.map` 复核口径:`startup_*.o(RESET) refers to stm32f4xx_it.o(.emb_text) for HardFault_Handler` + 4 个 `*_Gen` 各 `Removing ... (2 bytes)`。**L3 清单已清空:重生成应当零 diff**;若仍有 diff,跑审计脚本定位(`protected` 项的断言只能用代码符号,注释会被剥掉)。方法论详见 `docs/子系统设计/看门狗设计说明.md` §10.1/§10.2 - **4 个 Cortex-M4 Fault 全部采集现场**(2026-09-29):`HardFault` / `MemManage` / `BusFault` / `UsageFault` 均为手工 `__asm` Handler(取 MSP/PSP 帧指针)→ `B` 到 per-fault C 入口 `fault_entry_{hard,mem,bus,usage}`(各自带故障名)→ `hard_fault_report(tag, frame)` 打 `[ERR] PC/LR/xPSR/R0-R3/CFSR/HFSR[/BFAR][/MMFAR]` 后 `NVIC_SystemReset()`。**后三者默认禁用**(复位后 `SHCSR` 只开 HardFault),不使能就会被**升级成 HardFault**、日志看不出根因 → `fault_handler_enable()`(`Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 1` 区)在 `app_main_init()` **第一行**调用。**刻意不启用** `CCR.UNALIGN_TRP` / `CCR.DIV_0_TRP`(会把普通 bug 升级成死机)。⚠️ **ARMCC `__asm` 取不到 C 的数据符号**(`ADR r1, xxx_tag` 报 `A1516E: Bad symbol`,去 static 也无效)且无法传字符串参数 → 故障名必须由 C 侧 per-fault 入口带上,不要在 `__asm` 里取 C 变量 - **`SHCSR` 的 Fault 使能位无法在 CubeMX 配置**(2026-10-01 查模板源码证实):CubeMX 对 System Exception **只生成优先级(且仅当优先级 ≠ 0),从不生成使能代码**——`tpl_main_c.ftl` 的 `MX_NVIC_Init()` 用 `[#if vector.systemHandler=="false"]` 挡住 `HAL_NVIC_EnableIRQ`,而 `NVIC-STM32F417_Modes.xml` 里 `MemoryManagement/BusFault/UsageFault_IRQn` 都标 `Y`(=systemHandler);**全部模板中 `SHCSR` 只出现 1 次**且是 MPU 清理的 `&= ~MEMFAULTENA`(禁用),无任何 `|=` 置位;MPU 路径也堵死(`.ioc` 无 MPU,且 `HAL_MPU_Enable` 只置 `MEMFAULTENA` 一位)。⚠️ **`.ioc` 的 `NVIC.MemoryManagement_IRQn=true` 不代表已使能**(第 1 段只是"向量在 NVIC 表里可见")——别误以为勾了 Enabled 就等于开了故障。结论:只能手工写(现放 `USER CODE BEGIN 1` 区,属 L2,重生成不丢)+ 审计项 `fault-handler-enable` 兜底 - **`MDK-ARM/*.uvprojx` 每次 `@build` 后被 Keil UV4 自动改写,改动一律不入库**(2026-09-29 实测):UV4 构建时会把工程 XML 归一化成它本机风格的旧式写法(`` → ``、XML 声明加 `standalone="no"`),并把 `PackID` 降到**本机装的 DFP 版本**(本机实测 `3.1.1` → `3.0.0`,属**降级**)。这些差异是环境相关的,提交后会把别人的 Pack 版本也一起改掉。**判断方法**:`git diff MDK-ARM/STM32F407-Demo.uvprojx` 里若只见 XML 格式/`PackID` 差异而**无源文件挂载增删** → `git checkout -- MDK-ARM/STM32F407-Demo.uvprojx` 丢弃。只有真的新增/删除源文件(`grep 文件名` 计数变化)才提交 @@ -54,7 +54,8 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - **FatFs 打开模式陷阱(2026-09-20 台架实测,`Lib/FatFs` 移植版)**:`FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(/log 写失败真根因);追加写必须用 `FA_WRITE|FA_OPEN_APPEND`。该移植的 `f_write` 簇分配失败时返回 `FR_OK`+短写(无错误码),判失败必须校验写回字节数。标志位为旧式编号(`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(ff.c 内部定义)),与上游 FatFs R0.14 不同,勿照抄上游代码示例 - **FatFs 文件名约束(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)**:本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**——主名≤8 字符、扩展≤3 字符、**不得以点开头**(`.fschk` 会被 `ff.c create_short` 把点后当扩展域解析超限 → `f_open` 恒返 `FR_INVALID_NAME`(6),纯 RAM 名字判定、不碰盘)。ff.c `create_short` 全部拒绝点已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`,非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入卷损坏触发集(整卷重格式化不可逆) - **ADC 取数只用 `tpafe5160_snapshot()`(原子快照)**(2026-10-10 修 E-44/E-45/E-58):旧的 `get_buf()` / `data_ready()` / `clear_ready()` 已从驱动**删除**——`get_buf` 只给缓冲区指针、任务再逐通道拷,两步之间被 BUSY EXTI 抢占并跨过一帧周期就拷回**两帧混合**数据(8 通道失去同一时刻性,且无错误指示);`s_ready` 电平标志有三个写者,`start_conv_irq` 触发前清 0 = **重触发本身就会抹掉一帧未消费数据**。现口径:帧序号只增不清(`tpafe5160_frame_seq()`)、取数=临界区内拷贝(`tpafe5160_snapshot(dst, count)` 返回 1/0/-1,**仅任务上下文**)、FRSTDATA 全程无跳变(芯片不驱动并行总线)由 `tpafe5160_frstdata_err()` 计数且 `tpafe5160_init()` 首帧自检直接返回 `-3` + `[ERR]`。**消费者侧(L1 采集层)**:`App/algo/adc_input.c` 提供 `adc_input_poll()`(= TPAFE 快照 + STM32 ADC1 9 通道顺序扫描)与 `adc_input_raw()`(**按语义** `adc_semantic_t` 取数,不写死下标);映射表自检在 `adc_input_init()`(查每个 STM32 语义通道确实在扫描 rank 里,防"映射到从不扫描的通道 → 恒读陈旧值") -- **接 ADC / RS-485 消费者前的强制门禁(E-59,2026-10-10 固化)**:`adc_task` / `rs485_task` 从空桩转为真实消费者**之前**必须确认——① 驱动契约已修:TPAFE **E-44 / E-45 / E-58 已修 ✅**、RS485 **E-36 / E-37 已修 ✅** 但 **E-50(单缓冲无同步 → 包撕裂)未修 ❌**、**E-49(TX 超时拉 DE)未修 ❌**;② 栈预算:建任务前后各跑一次 `scripts/stack_report.py`。**`adc_task` 已过闸(B1,2026-10-10)**:消费者本体 + `adcTask`(512 words / Normal,`USER CODE` 区手工创建,同 timeSyncTask)+ 栈预算(静态 **560 B** / 余 **72%**,2026-10-10 实测)+ IWDG 心跳(`TASK_WD_ADC`)均已落地;**`rs485_task` 仍被挡(E-50 / E-49 未修)**。另按 N-18:V0.1 上两片转换器之间**禁止**做相位/功率/功率因数类运算 +- **STM32F4 ADC 上电必须自校准(2026-10-10 补,此前项目从未做过)**:CubeMX **不生成**这一步,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头 `stm32f407xx.h` **没有** `ADC_CR2_CAL`(bit3)→ 只能按 RM0090 §13.3.6 手写:`Src/adc.c` 的 `adc1_calibrate()`(`USER CODE BEGIN 1` 区,自建 `ADC1_CAL_BIT`)+ `MX_ADC1_Init()` 末尾 `USER CODE BEGIN ADC1_Init 2` 调用;时序 = 清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零。缺它**不报任何错**,只是读数带失调(实测校准让 ADC1 六路同向偏移 +10~+63 LSB,TPAFE 侧不变)。审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底。⚠ 顺带一条编译坑:**块注释里别写 `JEXT*/EXT*` 这类串**——其中的 `*/` 会提前闭合注释,报 `#7: unrecognized token` +- **接 ADC / RS-485 消费者前的强制门禁(E-59,2026-10-10 固化)**:`adc_task` / `rs485_task` 从空桩转为真实消费者**之前**必须确认——① 驱动契约已修:TPAFE **E-44 / E-45 / E-58 已修 ✅**、RS485 **E-36 / E-37 已修 ✅** 但 **E-50(单缓冲无同步 → 包撕裂)未修 ❌**、**E-49(TX 超时拉 DE)未修 ❌**;② 栈预算:建任务前后各跑一次 `scripts/stack_report.py`。**`adc_task` 已过闸(B1,2026-10-10)**:消费者本体 + `adcTask`(512 words / **AboveNormal**——P1-5 方案 a 提优先级,`USER CODE` 区手工创建,同 timeSyncTask)+ 栈预算(静态 **560 B** / 余 **72%**,B1 实测;P1-5 后复测 **832 B** / 余 **59%**,见下)+ IWDG 心跳(`TASK_WD_ADC`)均已落地;**`rs485_task` 仍被挡(E-50 / E-49 未修)**。另按 N-18:V0.1 上两片转换器之间**禁止**做相位/功率/功率因数类运算 - **`DBG_*` 每条日志约 90 B Flash + 216 B 栈**(2026-10-02 S10 实测;2026-10-10 修正栈量):`dbg_log.h` 的 `DBG_INFO/ERROR/DEBUG` 在**每个调用点内联**行缓冲 `char __con_line[DBG_LINE_BUF_MAX]`(默认 = `console_log.h` 的 `DBG_CON_LINE_MAX` **192 B**)+ 时间戳缓冲 **24 B**(已并入同一数组,原为独立 32 B)+ 完整 `snprintf` 调用序列 → 单条约 **90 B Flash**(字符串常量另计)。给新模块接入日志时按"条数 × 90 B"估算 Flash 代价(实测 `sd2506_init` 加 6 条 = +576 B;`app_main` 加 3 条 = +180 B);**深调用链模块另需核算栈**——每条日志在栈上多占一份行缓冲、会进 `stack_report.py` 的最坏链:实测 defaultTask 那条链上 9 个帧各含一份 → **约 1.5 KB 纯粹是日志行缓冲**(2026-10-10 把该链 5312 → 2776 B 的主要杠杆之一)。故**日志不是免费的**——已覆盖的步骤不要再加"进入前/完成后"重复锚点,只在真正黑洞(原先静默返回)处补 - **深调用链模块收紧日志行缓冲:`#define DBG_LINE_BUF_MAX 128U`,写在 `#include "dbg_log.h"` 之前**(2026-10-10 加)。已收紧:`nand_ftl.c`/`gd5f2gq5ue.c`/`cfg_file.c`/`net_cfg.c`/`net_socket.c`/`ch395f.c`/`time_sync.c`/`default_task.c`。⚠ **取值必须 ≥ 该模块最长日志行**(时间戳 20 + 标签 + 正文),否则该行被截断——各文件注释已写出实测行长。改前/改后各跑一次 `stack_report.py` @@ -67,10 +68,10 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v | testTask | 2048 | BelowNormal | | uiTask | 4096 | BelowNormal | | timeSyncTask | 512 | Normal | -| adcTask | 512 | Normal | +| adcTask | 512 | AboveNormal | -(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM;2026-09-21 可观测性落地后实测:ftpTask 峰值曾达 3480B/4096B(余量 15%,低于 1024B 底线)→ 已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 把 lftpd 9 处大局部缓冲静态化(BSS +4.6KB),复测峰值 **1184B/4096B(余量 71%)**;2026-10-10 新增 adcTask 后 **7 任务全 PASS**(adcTask 静态 560 B / 余 72%)) +(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM;2026-09-21 可观测性落地后实测:ftpTask 峰值曾达 3480B/4096B(余量 15%,低于 1024B 底线)→ 已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 把 lftpd 9 处大局部缓冲静态化(BSS +4.6KB),复测峰值 **1184B/4096B(余量 71%)**;2026-10-10 新增 adcTask 后 **7 任务全 PASS**(adcTask 静态 560 B / 余 72%);同日 P1-5 后复测 adcTask **832 B / 余 59%**(新增 `osThreadFlagsWait` 调用链,仍高于 1024 B 底线)) ## 架构要点 @@ -80,7 +81,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - **就绪屏障**(`App/app/sys_events.h` Event Flags,取代魔法延时):`SYS_EVT_NET_READY`(netTask 置位)/ `STORAGE_READY`(defaultTask:卷挂载 + 网络配置加载完成)/ `FTP_READY` / `TIMESYNC_READY`;消费者用 `osEventFlagsWait` 阻塞等待 - netTask:`net_stack_bring_up`(配置已由 defaultTask 在屏障前加载,本任务只读 RAM 副本 → `net_init` + PHY 强制 100M 全双工 → 递增网络代次号)→ 置 NET_READY → 循环 `net_poll` + `net_process_messages`;消费 `SYS_EVT_NET_RECONFIG` → 立即重建网络(新 IP 免重启生效,各 TCP 服务据代次号重建监听) - defaultTask:`f_mount` forced(无卷自动 `f_mkfs` FAT32;挂载后卷健康探针:小写两击确认,FAT 区陈旧/擦除时自动重格式化自愈,`/config.ini` 回落工厂默认——2026-09-20 /log 写失败修复)→ 网络配置加载(`net_cfg_load`;旧档迁移已于 2026-10-10 删除,见「模块约定」网口配置条)→ STORAGE_READY → LED 闪烁;同时是 syslog(`/log/run.log`·`selftest.log`,定长 64B 记录)的**唯一写者**与**配置引擎属主**(`cfg_file` 静态态单任务收敛于此:UI 保存请求执行 `net_cfg_save_run` + 2s `cfg_file_poll`,经 `SYS_EVT_CFG_SAVE` 事件唤醒;netTask/uiTask 不得直接触碰配置引擎,见 P1-3) -- adcTask(**B1,2026-10-10;L1 层 v2.3,2026-10-10**):**纯消费者**——每 1 ms 调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **STM32 ADC1 9 通道软件触发顺序扫描**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM6 更新中断硬件触发**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM6 分支内调 `tpafe5160_start_conv_irq()`,2026-10-10);本任务 1 ms 轮询仅作消费节奏。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,512 words / Normal),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md`、`App/algo/README.md` +- adcTask(**B1,2026-10-10;L1 层 v2.3,2026-10-10;P1-5 事件驱动消费,2026-10-10**):**纯消费者**——每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **STM32 ADC1 9 通道软件触发顺序扫描**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM6 更新中断硬件触发**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM6 分支内调 `tpafe5160_start_conv_irq()`,2026-10-10);**消费唤醒与该节拍同源**——同一 TIM6 分支再调 `adc_tick_irq_notify()` 置位 `ADC_WAKE_FLAG`,任务用 `osThreadFlagsWait(..., osWaitForever)` 等唤醒(不再 1 ms 轮询;实测 `idle` 由 1~649 降到 **0**)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`——它内含"消费者就绪"门(TIM6 在 `app_main_init` 就启动,比调度器早 ≈100 ms)。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,512 words / **AboveNormal**),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10、`App/algo/README.md` - **采集与算法层(L1/L2)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 扫描);L2 相量层待建。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md` - 全景 `docs/架构设计/项目架构总览.md`(六层分层 / 启动时序 / 存储·网络·UI 栈 / 目录组织) diff --git a/App/algo/README.md b/App/algo/README.md index dbe41c0b..9d8e5c90 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -19,13 +19,14 @@ `sample_ring.c` / `phasor.c` **零平台依赖**(只用 `math.h` / `stddef.h` 与本目录头); `adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。 -## 2. 数据流(一拍 = 1 ms) +## 2. 数据流(一拍 = TIM6 1 kHz 节拍) ```mermaid flowchart TD - TIM6["TIM6 更新中断 1 kHz
Src/main.c → tpafe5160_start_conv_irq()"] --> TPAFE["TPAFE5160 转换
BUSY EXTI 收帧"] + TIM6["TIM6 更新中断 1 kHz
Src/main.c → tpafe5160_start_conv_irq()
+ adc_tick_irq_notify()"] --> TPAFE["TPAFE5160 转换
BUSY EXTI 收帧"] + TIM6 -. "ADC_WAKE_FLAG
osThreadFlagsWait" .-> TASK TPAFE --> POLL - TASK["adcTask(1 ms osDelayUntil)
App/task/adc_task.c"] --> POLL["adc_input_poll()
TPAFE 快照 + STM32 ADC1 9 通道扫描"] + TASK["adcTask(事件驱动唤醒,非定时轮询)
App/task/adc_task.c"] --> POLL["adc_input_poll()
TPAFE 快照 + STM32 ADC1 9 通道扫描"] POLL --> RAW["adc_input_raw(ch) × 9
按语义取原始码值"] RAW --> RING["sample_ring_push_frame()
整帧入环(40 点窗)"] RING --> RUN["phasor_run()
逐实例 20 点重算 + 双区切换"] diff --git a/App/task/adc_task.c b/App/task/adc_task.c index 39791600..03211c12 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -9,9 +9,12 @@ * 并纳入看门狗 required_mask(TASK_WD_ADC)。 * **采样节拍(v2.2,2026-10-10)**:由 **TIM6 更新中断(1 kHz)** 硬件触发—— * ISR 内调 tpafe5160_start_conv_irq()(Src/main.c 的 - * HAL_TIM_PeriodElapsedCallback);本任务**不再自行触发**,退化为**纯消费者**, - * 1ms 轮询(osDelayUntil)仅作消费节奏。详见 - * docs/子系统设计/ADC采样节拍设计说明.md。 + * HAL_TIM_PeriodElapsedCallback);本任务**不再自行触发**,退化为**纯消费者**。 + * **消费唤醒(v2.7,2026-10-10,Task #32 P1-5 方案 a)**:由 1 ms `osDelayUntil` + * 轮询改为 **TIM6 ISR 置位 `ADC_WAKE_FLAG` 的事件驱动**——唤醒与生产同源锁定, + * 消除 0~1 ms 的 tick 量化相位抖动与"超拍永久落后 → 空转"风险;本任务优先级 + * 同步由 Normal 提到 **AboveNormal**(唤醒即抢占)。详见 + * docs/子系统设计/ADC采样节拍设计说明.md §10。 * **L1 采集层(v2.3,2026-10-10)**:取数改走 App/algo/adc_input.c—— * 本任务每拍调 adc_input_poll()(TPAFE 快照 + STM32 ADC1 9 通道扫描), * 经 adc_input_raw() **按语义**读通道(L1-3/L1-4),不再直接触碰驱动缓冲。 @@ -54,6 +57,14 @@ * (消除重复采样污染 20 点 DFT 窗的系统性误差); * §4.3 osDelayUntil 返回值被丢弃 → 落后时一次性重同步 * (原写法会永久落后 → 不再阻塞 → 空转抢占 uiTask) + * v2.7 2026-10-10 王建锋 Task #32 P1-5 方案 a(事件驱动消费): + * 消费唤醒 osDelayUntil(1ms) → osThreadFlagsWait(ADC_WAKE_FLAG, + * osWaitForever),标志由 TIM6 ISR 经 adc_tick_irq_notify() 置位; + * 删除 ADC_TASK_BEAT_MS 与 next(P0-2 的重同步因此不再需要); + * 新增"消费者就绪"门 s_consumer_ready——TIM6 在 app_main_init() + * 就启动,**远早于** MX_FREERTOS_Init()/osKernelStart(), + * ISR 不得在此之前对内核动手(同 Src/main.c 里 TIM7 的 + * 2026-10-02 实测坑)。优先级 Normal → AboveNormal(见 freertos.c) */ #include "adc_task.h" @@ -69,9 +80,9 @@ #define DBG_TAG "[ADC_TASK]" #include "dbg_log.h" -/* 消费轮询周期(ms)。采样节拍由 TIM6(1 kHz)提供,本周期仅作消费节奏 */ -#define ADC_TASK_BEAT_MS 1U -/* 心跳日志周期(ms):周期性上报帧统计,作为采集链路存活的观测面 */ +/* 心跳日志周期(ms):周期性上报帧统计,作为采集链路存活的观测面。 + * (原 ADC_TASK_BEAT_MS = 1U 的消费轮询周期已随 v2.7 事件驱动改造删除—— + * 消费节拍改由 TIM6 ISR 置位 ADC_WAKE_FLAG 提供,与生产同源,不再自定周期) */ #define ADC_TASK_REPORT_MS 10000U /* 统计:已消费帧数 / 无新帧次数(首拍必为无新帧,之后应稳定为 0 增量)。 @@ -81,6 +92,38 @@ static uint32_t s_frames_idle; /* 因通道尚未首次成功而**整拍跳过入环**的次数(不向相量窗注入 0 点) */ static uint32_t s_frames_skip; +/* adcTask 的内核句柄,定义在 Src/freertos.c(引用方式同 App/task/default_task.c) */ +extern osThreadId_t adcTaskHandle; + +/* 消费者就绪门:由 adcTask 自己在进入等待前置 1;单写者 = 本任务,ISR 只读。 + * ⚠ 为什么必须有这道门:TIM6 在 app_main_init()(Src/main.c:140)里就 + * HAL_TIM_Base_Start_IT 启动,**远早于** osKernelInitialize()/MX_FREERTOS_Init()/ + * osKernelStart()(Src/main.c:144/145/148)→ "节拍源已在跑"与"消费者已存在"之间有 + * 约 100 ms 窗口,其间 adcTaskHandle 还是 NULL、调度器也还没起。 + * osThreadFlagsSet() 对 NULL 句柄本身会安全返回 osErrorParameter(cmsis_os2.c 的入参 + * 检查),但"句柄已赋值、调度器未启动"这段(freertos.c 建任务 → osKernelStart)仍会让 + * ISR 版内核 API 在调度器外跑。本仓库对这类坑有实测记录:Src/main.c 的 TIM7 分支注释 + * (2026-10-02,port.c 优先级断言把系统卡死在 ISR 内)。就绪门把这条时序变成**显式约束**, + * 而不是依赖 FreeRTOS 内部实现细节。 */ +static volatile uint8_t s_consumer_ready; + +/* + * 函数功能:TIM6 采样节拍的 ISR 入口——置位唤醒 adcTask 消费一拍 + * 入口参数:无 + * 返 回 值:无 + * 限定条件:仅 ISR 上下文。TIM6 抢占优先级 = 6 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY + * (=5) → osThreadFlagsSet 走 xTaskNotifyFromISR + portYIELD_FROM_ISR,合法且立即切换 + * 函数说明:Task #32 P1-5 方案 a——ISR **只发唤醒**,全部计算留在任务里(拒绝"ISR 内计算": + * adc_input_poll() 内含 2 ms 忙等,绝不可入 ISR)。线程标志粘滞 → 单拍偶超 1 ms 时 + * 多个脉冲合并成一次唤醒,下一轮 adc_input_poll() 返回 r==0 记 idle,不会重复处理同一帧 + */ +void adc_tick_irq_notify(void) { + if (s_consumer_ready == 0u) { + return; + } + (void)osThreadFlagsSet(adcTaskHandle, ADC_WAKE_FLAG); +} + /* * 函数功能:打印全部相量实例(×0.1 定点) * 入口参数:tag - 前缀标记("first" 首次产出 / "hb" 周期心跳) @@ -127,32 +170,40 @@ int adc_task_init(void) { * 入口参数:arg - FreeRTOS 传入参数(未使用) * 返 回 值:无 * 限定条件:adc_task_init() 已调用;内核已启动 - * 函数说明:每 1ms 采集一拍(adc_input_poll():TPAFE 原子快照 + STM32 ADC1 9 通道 - * 顺序扫描)并统计。采样节拍由 TIM6 硬件定时器提供(ISR 内触发 CONVST), - * 本任务不再触发。稳态下 consumed 每拍 +1、idle 不再增长;idle 持续增长 = + * 函数说明:每拍采集一次(adc_input_poll():TPAFE 原子快照 + STM32 ADC1 9 通道顺序扫描) + * 并统计。采样节拍由 TIM6 硬件定时器提供(ISR 内触发 CONVST),本任务不再触发; + * **本任务的唤醒同样由该 TIM6 ISR 置位 ADC_WAKE_FLAG 驱动**(v2.7 事件驱动, + * 不再自定 1 ms 周期)。稳态下 consumed 每拍 +1、idle 不再增长;idle 持续增长 = * EXTI 未在读帧或 TIM6 未启动(异常),由周期心跳日志暴露。 + * ⚠ 若 TIM6 停摆,本任务会**永久阻塞**在 osThreadFlagsWait → TASK_WD_ADC 缺席 → + * IWDG 复位。这是刻意的 fail-safe("节拍源缺失"不被静默放过),不是缺陷。 */ void adc_task_func(void *arg) { - uint32_t next = osKernelGetTickCount(); - uint32_t report_at = next + ADC_TASK_REPORT_MS; + uint32_t report_at = osKernelGetTickCount() + ADC_TASK_REPORT_MS; uint8_t first_logged = 0u; uint8_t first_phasor_logged = 0u; (void)arg; + /* 开闸:此刻任务已存在、调度器必已运行 → ISR 的唤醒从此生效。 + * 放在进入循环之前:置位与等待之间不会漏唤醒(线程标志粘滞) */ + s_consumer_ready = 1u; + for (;;) { int r; - /* P2 探针:单拍起点。名义节拍 1 ms = 168000 周期;若实测远超, - * 本任务(osPriorityNormal)会持续抢占 uiTask(osPriorityBelowNormal), - * 是"UI 心跳缺席"的候选占用者之一 */ + /* P2 探针:单拍起点。名义节拍 1 ms = 168000 周期;若实测**持续**超过, + * 本任务(v2.7 起为 osPriorityAboveNormal)会持续抢占 uiTask + * (osPriorityBelowNormal),是"UI 心跳缺席"的候选占用者之一 */ task_prof_enter(TASK_PROF_ADC_BEAT); /* 1) 采集一拍:L1 层 adc_input_poll() = TPAFE 原子快照 + STM32 ADC1 顺序扫描 * (仅任务上下文;TPAFE 侧 1=有新帧 0=无新帧 -1=参数错)。 * 采样节拍由 TIM6 更新中断提供(ISR 内触发 CONVST),本任务不触发。 - * P2 探针:本段单独计时——ADC 超时是 2ms 不让出的忙等(HAL_ADC_ - * PollForConversion),是单拍耗时的主要来源,必须与压环/相量分开看 */ + * P2 探针:本段单独计时。实测常态 adcp = 122 us(9 通道 x 7.4 us + TPAFE 快照); + * 只有 STM32 侧超时才跳到 ~2.2 ms——HAL_ADC_PollForConversion 是 2 ms 不让出的忙等, + * 而 stm32_scan() 在**首个**超时处即 Stop+return,故单拍上界是**一次**超时(不是 9 次)。 + * ⚠ adcm(整拍)的 ~2.1 ms 峰值来自每 10 s 一次的心跳日志,不是本段,勿混读 */ task_prof_enter(TASK_PROF_ADC_POLL); r = adc_input_poll(); task_prof_exit(TASK_PROF_ADC_POLL); @@ -266,17 +317,16 @@ void adc_task_func(void *arg) { } } - /* 7) 等下一个节拍(固定周期;落后则立即补齐,不累积漂移) - * ⚠ P0 修复(2026-10-10,Task #32 §4.3):osDelayUntil 在**目标时刻已过** - * 时返回 osErrorParameter 并立即返回(cmsis_os2.c 的 "No delay or already - * expired" 分支)。若像原实现那样只写 `(void)` 丢弃返回值,一次超拍会让 - * next 相对当前时刻**永久落后** → 本任务从此不再阻塞、以 Normal 优先级 - * 空转,直到某个"快拍"把差额追平(连续慢拍则锁死,全程抢占低优先级的 - * uiTask)。返回值是现成的重同步钩子:落后就一次性把基准拉回当前时刻。 */ - task_prof_exit(TASK_PROF_ADC_BEAT); /* P2 探针:单拍耗时结算(不含睡眠) */ - next += ADC_TASK_BEAT_MS; - if (osDelayUntil(next) != osOK) { - next = osKernelGetTickCount(); - } + /* 7) 等下一次节拍唤醒(v2.7 事件驱动,Task #32 P1-5 方案 a) + * 原实现是 osDelayUntil(next += 1ms):把消费唤醒**钉在 FreeRTOS tick(1 ms)边界** + * → 与 TIM6 生产之间有 0~1 ms 随机相位抖动(日志里的 idle 正是这么来的), + * 且一旦超拍,next 就永久落后 → 任务不再阻塞 → 空转抢占低优先级 uiTask + * (P0-2 修的正是它)。现在唤醒与生产**同源**:TIM6 ISR 触发 CONVST 后置位 + * ADC_WAKE_FLAG,本任务被唤醒的延迟 = ISR + 调度(µs 级;本任务已提至 + * AboveNormal → 立即抢占),无 tick 量化、无累积漂移,也就不再需要重同步钩子。 + * 标志是命令位(等待即消费)→ osFlagsWaitAny,不加 osFlagsNoClear; + * osWaitForever 无超时 = 刻意的 fail-safe(TIM6 停摆 → 无心跳 → IWDG 复位)。 */ + task_prof_exit(TASK_PROF_ADC_BEAT); /* P2 探针:单拍耗时结算(不含等待) */ + (void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever); } } diff --git a/App/task/adc_task.h b/App/task/adc_task.h index 4159d9ad..087769f9 100644 --- a/App/task/adc_task.h +++ b/App/task/adc_task.h @@ -6,6 +6,9 @@ * 创建日期:2026-07-19 * 修改记录: * v1.0 2026-07-19 王建锋 创建初始版本 + * v1.1 2026-10-10 王建锋 Task #32 P1-5 方案 a:新增 ADC_WAKE_FLAG 与 + * adc_tick_irq_notify()——消费节拍由 osDelayUntil(1ms) + * 轮询改为 TIM6 ISR 事件驱动唤醒 */ #ifndef ADC_TASK_H @@ -18,6 +21,17 @@ extern "C" { int adc_task_init(void); void adc_task_func(void *arg); +/* Task #32 P1-5(方案 a,2026-10-10):事件驱动消费。 + * TIM6 1 kHz 更新中断触发 CONVST 后,用本标志唤醒 adcTask 消费一拍。 + * 线程标志在此是**命令位**(等待即消费),不是状态位 → 用 osFlagsWaitAny, + * 不加 osFlagsNoClear(AGENTS「就绪屏障位必须 osFlagsNoClear」只约束状态位) */ +#define ADC_WAKE_FLAG 0x0001U + +/* ISR 入口:由 Src/main.c 的 HAL_TIM_PeriodElapsedCallback(TIM6 分支)调用。 + * 内部自带"消费者就绪"门与句柄判空 → 调度器启动前调用也安全(理由见 .c 注释)。 + * 收在 adc_task.c 而非 main.c 内联:main.c 不碰任何内核句柄,与 task_wd_tick_probe() 同构 */ +void adc_tick_irq_notify(void); + #ifdef __cplusplus } #endif diff --git a/Inc/adc.h b/Inc/adc.h index 68631ad8..c0a0a036 100644 --- a/Inc/adc.h +++ b/Inc/adc.h @@ -42,6 +42,10 @@ void MX_ADC1_Init(void); /* USER CODE BEGIN Prototypes */ +/* ADC1 上电自校准(RM0090 §13.3.6;本仓库 HAL 无 HAL_ADCEx_Calibration_Start, + * 实现见 Src/adc.c 的 USER CODE BEGIN 1)。返回 0 = 完成,-1 = 等待超时 */ +int adc1_calibrate(void); + /* USER CODE END Prototypes */ #ifdef __cplusplus diff --git a/README.md b/README.md index 18837c12..89dca52f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ main.c | testTask | 2048 words | BelowNormal | 硬件测试入口 | | uiTask | 4096 words | BelowNormal | LVGL UI 界面 | | timeSyncTask | 512 words | Normal | 墙钟时间同步 | -| adcTask | 512 words | Normal | TPAFE5160 采样(1ms 节拍 + 原子快照) | +| adcTask | 512 words | AboveNormal | TPAFE5160 采样(TIM6 1kHz 事件驱动 + 原子快照) | > 上表与 `Src/freertos.c` 的 `osThreadAttr_t` 一致(2026-09-25 校准);改动栈大小前先读 diff --git a/Src/adc.c b/Src/adc.c index cc5cf0d1..59bd5e39 100644 --- a/Src/adc.c +++ b/Src/adc.c @@ -141,7 +141,15 @@ void MX_ADC1_Init(void) Error_Handler(); } /* USER CODE BEGIN ADC1_Init 2 */ - + /* STM32F4 ADC 上电**自校准**:RM0090 §13.3.6 / AN4976 要求的必需步骤,CubeMX 不生成, + * 且本仓库的 HAL 是裁剪子集、**没有** HAL_ADCEx_Calibration_Start()(已核实 + * stm32f4xx_hal_adc_ex.c/.h 均无该函数)→ 用本文件 USER CODE BEGIN 1 里的等价实现。 + * 必须在首次转换之前完成,否则 ADC 存在失调误差(未校准的偏移可达数十 LSB)。 + * 放 USER CODE 区 = L2,重生成不丢;由 cubemx-survival 审计项 adc1-calibration 兜底。 */ + if (adc1_calibrate() != 0) + { + Error_Handler(); + } /* USER CODE END ADC1_Init 2 */ } @@ -222,6 +230,64 @@ void HAL_ADC_MspDeInit(ADC_HandleTypeDef* adcHandle) } /* USER CODE BEGIN 1 */ +/* CR2.CAL(bit 3)。⚠ 本仓库的 CMSIS 设备头 stm32f407xx.h 是裁剪版,**未定义** + * ADC_CR2_CAL(已核实:CR2 只有 ADON/CONT/DMA/DDS/EOCS/ALIGN/JEXTSEL/JEXTEN/ + * JSWSTART/EXTSEL/EXTEN/SWSTART,没有 CAL) + * → 按 RM0090 在此补齐,命名避开 CMSIS 前缀以免将来重定义冲突。 */ +#define ADC1_CAL_BIT (0x1UL << 3U) +/* 断电/校准等待上限(ms)。CAL 实测在数十 µs 内清零,10 ms 是 100 倍以上余量 */ +#define ADC1_CAL_TIMEOUT_MS (10U) +/* ADON 0→1 后的稳定等待(µs),同 HAL 的 ADC_STAB_DELAY_US 口径 */ +#define ADC1_STAB_DELAY_US (20U) +/* + * 函数功能:ADC1 上电自校准(等价于 HAL_ADCEx_Calibration_Start,本仓库 HAL 无此函数) + * 入口参数:无(操作 ADC1 寄存器) + * 返 回 值:0 - 校准完成;-1 - 等待超时(ADON 未清零 / CAL 未清零) + * 限定条件:MX_ADC1_Init() 之后、**首次转换之前**;内核启动前可调用 + * (只用 HAL_GetTick 与寄存器,不触碰任何 CMSIS/FreeRTOS 对象) + * 函数说明:RM0090 §13.3.6 时序——① ADC 必须处于关闭态(清 ADON 并等其生效); + * ② 置 ADON 上电,等 Tstab;③ 置 CAL 启动自校准;④ 完成后**硬件自动**清 CAL, + * 等其清零即完成。校准值存在 ADC 内部偏置寄存器,此后所有转换都带上校正。 + * 缺这一步的后果不是"读不到"而是"读得有偏"——正是 §7.2 那类 + * adc1_err/异常值的候选根因之一,见 docs/硬件平台/ADC通道映射说明.md。 + */ +int adc1_calibrate(void) +{ + uint32_t tickstart; + + /* 1) 前置条件:ADC 必须关闭 */ + CLEAR_BIT(ADC1->CR2, ADC_CR2_ADON); + tickstart = HAL_GetTick(); + while ((ADC1->CR2 & ADC_CR2_ADON) != 0U) + { + if ((HAL_GetTick() - tickstart) > ADC1_CAL_TIMEOUT_MS) + { + return -1; + } + } + + /* 2) 上电(ADON 0→1),等待稳定后再启动校准 */ + SET_BIT(ADC1->CR2, ADC_CR2_ADON); + { + __IO uint32_t wait_loop_index = (ADC1_STAB_DELAY_US * (SystemCoreClock / 1000000U)); + while (wait_loop_index != 0U) + { + wait_loop_index--; + } + } + + /* 3) 启动自校准,等硬件把 CAL 清零 */ + SET_BIT(ADC1->CR2, ADC1_CAL_BIT); + tickstart = HAL_GetTick(); + while ((ADC1->CR2 & ADC1_CAL_BIT) != 0U) + { + if ((HAL_GetTick() - tickstart) > ADC1_CAL_TIMEOUT_MS) + { + return -1; + } + } + return 0; +} /* USER CODE END 1 */ diff --git a/Src/freertos.c b/Src/freertos.c index 5877bdf0..b0d0d0d1 100644 --- a/Src/freertos.c +++ b/Src/freertos.c @@ -125,13 +125,18 @@ const osThreadAttr_t timeSyncTask_attributes = { .stack_size = 512 * 4, .priority = (osPriority_t) osPriorityNormal, }; -/* B1(2026-10-10):ADC 采集消费者(1ms 采样节拍 + 原子快照 + 帧统计)。 - * 手写于此而非 .ioc(同 timeSyncTask:不在 Tasks01 里 → 重生成会丢,靠审计 WARN 提示)。 */ +/* B1(2026-10-10):ADC 采集消费者(TIM6 1 kHz 采样节拍 + 原子快照 + 帧统计)。 + * 手写于此而非 .ioc(同 timeSyncTask:不在 Tasks01 里 → 重生成会丢,靠审计 WARN 提示)。 + * ⚠ 优先级 AboveNormal(Task #32 P1-5 方案 a,2026-10-10):消费改由 TIM6 ISR 事件驱动后 + * 提优先级,使"被唤醒 → 立即抢占其余 Normal 任务",唤醒延迟固定为 µs 级。 + * 代价:本任务单拍最坏实测 adcm≈2.1 ms 会在 AboveNormal 上挤占 net/ftp/default/timeSync, + * 其中 ADC 的 2 ms 忙等(App/algo/adc_input.c)尤其敏感 → 硬化方向见 + * docs/子系统设计/ADC采样节拍设计说明.md §10.4。 */ osThreadId_t adcTaskHandle; const osThreadAttr_t adcTask_attributes = { .name = "adcTask", .stack_size = 512 * 4, - .priority = (osPriority_t) osPriorityNormal, + .priority = (osPriority_t) osPriorityAboveNormal, }; /* USER CODE END Variables */ /* Definitions for defaultTask */ diff --git a/Src/main.c b/Src/main.c index 3881ad93..feb9b3f3 100644 --- a/Src/main.c +++ b/Src/main.c @@ -42,6 +42,7 @@ #include "app_main.h" #include "sys_clock.h" #include "task_watchdog.h" /* task_wd_tick_probe():round 8b 时间源存活性取证 */ +#include "adc_task.h" /* adc_tick_irq_notify():TIM6 节拍 → 唤醒 adcTask(P1-5 方案 a) */ /* USER CODE END Includes */ /* Private typedef -----------------------------------------------------------*/ @@ -246,13 +247,18 @@ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) task_wd_tick_probe(); } - /* 1 kHz 采样节拍:TIM6 更新中断内脉冲 CONVST(仅两次 GPIO 写,亚 µs)。 + /* 1 kHz 采样节拍:TIM6 更新中断内脉冲 CONVST(仅两次 GPIO 写,亚 µs), + * 再唤醒 adcTask 消费这一拍(Task #32 P1-5 方案 a:ISR 只发唤醒,计算全留在任务)。 * TIM6 与 BUSY EXTI(EXTI9_5_IRQn) 同为抢占优先级 6 → 互不抢占, * 读 RD 时序不会被下一次 CONVST 脉冲打断。详见 - * docs/子系统设计/ADC采样节拍设计说明.md。放 USER CODE 区(L2)。 */ + * docs/子系统设计/ADC采样节拍设计说明.md §10。放 USER CODE 区(L2)。 + * ⚠ 唤醒必须走 adc_tick_irq_notify(),**不要**在这里直接 osThreadFlagsSet: + * TIM6 在 app_main_init() 就启动,远早于建任务与 osKernelStart, + * "消费者就绪"门在 adc_task.c 里(同本文件 TIM7 分支 2026-10-02 的实测坑)。 */ if (htim->Instance == TIM6) { tpafe5160_start_conv_irq(); + adc_tick_irq_notify(); } /* USER CODE END Callback 1 */ } diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 51e46d18..3ef59067 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 272,436 B (266.05 KB) | 1,048,576 B (1024 KB) | 26.0% | -| RW_IRAM1 | 91,328 B (89.19 KB) | 131,072 B (128 KB) | 69.7% | +| ER_IROM1 | 273,028 B (266.63 KB) | 1,048,576 B (1024 KB) | 26.0% | +| RW_IRAM1 | 91,336 B (89.20 KB) | 131,072 B (128 KB) | 69.7% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **273,236 B (266.8 KB)** | 1,048,576 B (1024 KB) | **26.1%** | +| **Flash (ROM 合计)** | **273,836 B (267.4 KB)** | 1,048,576 B (1024 KB) | **26.1%** | 各区的容量与常量来源: diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md index 4be21fe8..d34ffeb8 100644 --- a/docs/子系统设计/ADC采样节拍设计说明.md +++ b/docs/子系统设计/ADC采样节拍设计说明.md @@ -4,9 +4,11 @@ > (`Src/main.c` 回调 + `app_main.c` 启动 + `adc_task.c` 退化为纯消费者),`@build` 0 错 0 警; > 台架 `@test` **PASS**(`frame_seq` 每 10 s 精确 +10000 = 1000.0 Hz、`frst_err=0`、回读一致; > 实测见 §7.1)。 -> **事件驱动消费改造方案 a 已规划入档(Task #32 P1-5,2026-10-10,待实施)**:用户决策 -> "ISR 只发唤醒 + 计算留任务 + 提优先级"(方案 a),拒绝"ISR 内计算";并附下一版硬件 -> DMA 双缓冲建议(§10.5)。详见 §10;消费端当前仍为 1 ms 轮询,待该方案排期落地。 +> **事件驱动消费改造方案 a 已落地(Task #32 P1-5,2026-10-10)**:用户决策 +> "ISR 只发唤醒 + 计算留任务 + 提优先级"(方案 a),拒绝"ISR 内计算"。消费端已从 +> 1 ms `osDelayUntil` 轮询改为 **TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒**,`adcTask` +> 优先级 `Normal` → **`AboveNormal`**。台架实测 **`idle = 0`**(20000 拍全零;改前 1~649)、 +> `frames` 严格 1000.0 Hz、UI 侧 `uip/uiw/uis` 无退化。详见 §10。 > **上位需求**:`docs/算法/继电保护功能复现规划.md` §3.2 L1-1(周期 CONVST 重触发 1000 Hz, > 与保护节拍同源)。**驱动契约**:`Drivers/BSP/TPAFE5160/README.md`。 > **背景**:B1(2026-10-10)已把 `adc_task` 由空桩改为真实消费者,但采样节拍是 @@ -170,7 +172,8 @@ t=1000 µs 下一个 TIM6 更新事件 跑 `python scripts/check_mem_numbers.py --write` 刷新快照。 7. **消费端与生产端同频**:TIM6(84 MHz 派生)与 FreeRTOS tick TIM7(同源)都恰好 1 kHz, 相位固定 → 消费端每拍恰好取到 1 帧。**一旦进入等周波调频(采样率≠1 kHz), - 1 ms 轮询会与生产端失配 → 消费端须改为"帧就绪事件驱动"**(见 §8)。 + 固定周期轮询会与生产端失配 → 消费端须改为"节拍事件驱动"**(见 §8)。 + ✅ **该前置已满足(2026-10-10,§10)**:消费唤醒改由 TIM6 ISR 置位,与采样率天然同步。 --- @@ -200,11 +203,15 @@ t=1000 µs 下一个 TIM6 更新事件 | 6 | 开机时间戳正常 | ✅ 日志带墙钟时间戳,`net init: OK` 在 +530 ms | | 7 | 无 HardFault / `[ERR]` / 看门狗复位 | ✅ 零 `[ERR]`/HardFault;`reset cause:` 仅 1 次(调试器软复位) | -**关于 `idle`(不是缺陷,是轮询消费的固有抖动)**:生产端(TIM6)与消费端(`adcTask` 1 ms 轮询) +**关于 `idle`(改前:轮询消费的固有抖动)**:生产端(TIM6)与消费端(`adcTask` 1 ms 轮询) 同为 1000 Hz(TIM6 与 FreeRTOS tick 源 TIM7 参数一致,均 84 MHz/84/1000),但二者无显式同步 → 偶发 ±1 帧的相位抖动:每 10 s 约 5 次"两次轮询落在同一生产间隔"(`idle` +5)配 5 次"一次轮询跨两个 -生产间隔"(`frames` 少 5,即 9995/10000 ≈ 99.95% 取数)。要彻底消除需把消费端改为**帧就绪事件驱动** -(在 BUSY EXTI 读帧完成时通知 `adcTask`)——与 §8 等周波调频的消费端改造是同一件事,留待该阶段一并做。 +生产间隔"(`frames` 少 5,即 9995/10000 ≈ 99.95% 取数)。 + +✅ **现状(§10 已落地,2026-10-10)**:消费端改由 **TIM6 ISR 置位 `ADC_WAKE_FLAG`** 唤醒。 +落点与当初设想的"BUSY EXTI 读帧完成时通知"不同——选 TIM6 是为了让唤醒与**采样节拍**严格同源, +且不必把内核 API 引入 BUSY EXTI(它与 TIM6 同抢占优先级,见 §5);效果一致:实测 **`idle = 0`** +(6 次上板、每次 20000 拍全零)→ 判据 5 由"基本为 0"收紧为**恒 0**。 --- @@ -216,7 +223,8 @@ t=1000 µs 下一个 TIM6 更新事件 - 本配置已为此预留:**1 µs 分辨率** + **ARR 预装载使能** → 运行期 `__HAL_TIM_SET_AUTORELOAD(&htim6, 周期µs - 1)` 即可无毛刺改频。 - 需要配套:① 测频源(过零/DFT 相位);② 把当前采样率同步给保护算法(相量/定值); - ③ **消费端改事件驱动**(§6.7)——否则 1 ms 轮询在非 1 kHz 采样率下会丢帧。 + ③ **消费端改事件驱动**(§6.7)——否则固定周期轮询在非 1 kHz 采样率下会丢帧。 + ✅ **已落地(2026-10-10,§10)**:消费唤醒与 TIM6 同源 → 调频时只改 ARR,消费节拍自动跟随。 --- @@ -228,22 +236,28 @@ t=1000 µs 下一个 TIM6 更新事件 | 2026-10-10 | **实现(方案 A)** | CubeMX 加 TIM6(PSC=83 / ARR=999 / ARR 预装载 / NVIC 抢占优先级 6);`Src/main.c` 回调加 TIM6 分支触发 CONVST;`app_main.c` 用 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍;`adc_task.c` 退化为纯消费者。`@build` 0 错 0 警;台架 `@test` 验收待做 | | 2026-10-10 | **台架 `@test` 验收 PASS** | 真机闭环:`seq` 10005→20005→30005(每 10 s +10000 = 1000.0 Hz)、`frst_err=0`、`idle` 每 10 s +5(0.05%,轮询抖动)、回读 `No difference found`、零 `[ERR]`/HardFault、`reset cause` 仅 1 次。证据 `boot_log_data/20261010_prod_adc_tim6_boot_log.txt` | | 2026-10-10 | **事件驱动消费方案 a 规划入档(Task #32 P1-5)** | 记录用户决策与改造清单(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 `adcTask` + `adcTask` 提至 `osPriorityAboveNormal`),并附下一版硬件 DMA 双缓冲建议(§10.5)。代码待实施,消费端仍为 1 ms 轮询 | +| 2026-10-10 | **事件驱动消费方案 a 已实施(Task #32 P1-5)** | 四处改动落地:`Src/main.c` TIM6 分支调 `adc_tick_irq_notify()`;`adc_task.c` 循环尾改 `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS`/`next`;`Src/freertos.c` 优先级 `Normal`→**`AboveNormal`**;`App/task/adc_task.h` 加 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补**就绪门**(§10.6,规划未预见:TIM6 比调度器早起 ≈100 ms)。`@build` 0 错 0 警(Code 256368→256728);`@test` PASS:**`idle = 0`/19999 拍**(改前 1~649)、`frames` 1000.0 Hz、`seq−frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 无退化 | +| 2026-10-10 | **§10.1/§10.3/§10.4 复核修正(无功能改动)** | ① 2 ms 忙等的单拍上界由"9 通道 ×2 = 18 ms"更正为**一次超时 ≈2~3 ms**(`stm32_scan()` 在首个超时处即 `Stop`+`return`);② 6 次上板对照 `adcp` = **122 µs**(超时 0 次的 5 轮)/ **2209 µs**(超时 1 次的那轮)→ 2 ms 不是常态开销;③ `adcm` 2.1 ms 峰值**归因改判**为每 10 s 一次的心跳日志(`idle=0`/20000 反证无每拍 2 ms);④ 新增超时的**正确性**代价(超时拍 STM32 侧静默沿用上一拍值,`skip` 抓不到)与硬化排序 | +| 2026-10-10 | **补 ADC1 上电自校准(§10.4)** | 发现 F4 必需的自校准在本项目**完全缺失**(CubeMX 不生成 + 裁剪 HAL 无 `HAL_ADCEx_Calibration_Start` + 裁剪 CMSIS 头无 `ADC_CR2_CAL`)→ 在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(RM0090 §13.3.6 时序),`MX_ADC1_Init()` 末尾调用。新增审计项 `adc1-calibration-call`/`-impl`。`@build` 0 错 0 警(Code 256728→256868,+140 B);`@test` PASS,`adcp` 仍 122 µs、`adc1_err=0`;实测 STM32 侧 6 路同向偏移 +10~+63 LSB、TPAFE 侧不变 | --- -## 10. 事件驱动消费改造(Task #32 P1-5,2026-10-10 规划:用户决策方案 a,待实施) +## 10. 事件驱动消费改造(Task #32 P1-5,2026-10-10:用户决策方案 a,**已落地**) > 上位背景:Task #32 暴露"UI 任务看门狗停摆"候选占用者之一是 `adcTask` 的 1 ms 轮询—— > 原 `osDelayUntil(1ms)` 把消费唤醒**钉在 FreeRTOS tick(1 ms)边界**,带来 0~1 ms 随机 > 相位抖动 + 累积漂移风险,且 `adcTask` 与 `uiTask` 同为可被长时抢占的对象。用户决策: > **方案 a(ISR 只发唤醒 + 计算仍在任务 + 提优先级)**,并拒绝"ISR 内计算"方案。 -> 本方案**尚未实施**:消费端当前仍为 1 ms 轮询,改造清单见 §10.2,待排期落地。 +> **落地状态(2026-10-10)**:四处改动全部实施(清单见 §10.2),`@build` 0 错 0 警、 +> 台架 `@test` PASS,实测见 §10.3;实施时补了一条规划未预见的**就绪门**(§10.6)。 ### 10.1 决策:为什么选 a,不选"ISR 内计算" - **拒绝"ISR 内计算"**:把相量/压环搬进 TIM6 ISR 是反模式—— ① `adc_input_poll()` 内含 `HAL_ADC_PollForConversion` 的 **2 ms 忙等**(`ADC_INPUT_POLL_TIMEOUT_MS=2U`), - 绝不可入 ISR(会冻结系统,最坏 9 通道 ×2 ms = 18 ms 全系统中断关闭); + 绝不可入 ISR(TIM6 抢占优先级 6,自旋期间低优先级中断与全部任务都被压住;单拍最坏 = + **一次**超时 ≈2~3 ms——`stm32_scan()` 在首个超时处即 `HAL_ADC_Stop()`+`return` + (`App/algo/adc_input.c:79-84`),不会累加成 9 次); ② 相量/采样环是任务级共享数据,ISR 内改写需额外临界区,破坏 `tpafe5160_snapshot()` 的 "仅任务上下文"铁律;③ `HAL_TIM_PeriodElapsedCallback` 是 CubeMX 生成区回调,承载 L2 计算 违反"生成区不改"约定,重生成即丢。 @@ -251,14 +265,14 @@ t=1000 µs 下一个 TIM6 更新事件 并把 `adcTask` 优先级由 `osPriorityNormal` 提到 `osPriorityAboveNormal`,使唤醒后**立即抢占** 其余 Normal 任务,固定 µs 级延迟。计算量、数据共享、生成区约定全部不变。 -### 10.2 拟改造内容(待实施) +### 10.2 改造内容(已实施) -| 文件 | 拟改动 | +| 文件 | 实际改动 | |---|---| -| `Src/main.c` | `HAL_TIM_PeriodElapsedCallback` 的 TIM6 分支:在 `tpafe5160_start_conv_irq()` 后加 `(void)osThreadFlagsSet(adcTaskHandle, ADC_WAKE_FLAG);` | -| `App/task/adc_task.c` | 主循环尾 `osDelayUntil(1ms)` 改为 `(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever);`;删除 `ADC_TASK_BEAT_MS` 与 `next` 变量 | -| `Src/freertos.c` | `adcTask_attributes.priority`:`osPriorityNormal` → `osPriorityAboveNormal`(=32,高于其余 Normal=24;`configMAX_PRIORITIES=56` 有裕量) | -| `App/task/adc_task.h` | 新增 `#define ADC_WAKE_FLAG 0x0001U` 与 `extern osThreadId_t adcTaskHandle;` | +| `Src/main.c` | `HAL_TIM_PeriodElapsedCallback` 的 TIM6 分支(`USER CODE` 区,L2):`tpafe5160_start_conv_irq()` 后加 **`adc_tick_irq_notify();`**——**不是**直接 `osThreadFlagsSet`(理由见 §10.6);并在 `USER CODE BEGIN Includes` 加 `#include "adc_task.h"` | +| `App/task/adc_task.c` | 主循环尾 `osDelayUntil(next)` → **`(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever);`**;删除 `ADC_TASK_BEAT_MS` 与 `next`(P0-2 的重同步随之不再需要);新增 `adc_tick_irq_notify()` 与就绪门 `s_consumer_ready` | +| `Src/freertos.c` | `adcTask_attributes.priority`:`osPriorityNormal` → **`osPriorityAboveNormal`**(=32 > 其余 Normal=24;`configMAX_PRIORITIES=56` 有裕量) | +| `App/task/adc_task.h` | 新增 `#define ADC_WAKE_FLAG 0x0001U` 与 `void adc_tick_irq_notify(void);`。**未**把 `extern osThreadId_t adcTaskHandle;` 放进头文件——句柄只在 `adc_task.c` 内引用,`main.c` 不碰任何内核符号(与 `task_wd_tick_probe()` 同构;规划里的这一条按此收敛) | - **ISR 安全性(设计校验)**:TIM6 抢占优先级 = 6 ≥ `configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY`(=5) → `osThreadFlagsSet` 在 ISR 内走 `xTaskNotifyFromISR` + `portYIELD_FROM_ISR`(CMSIS-RTOS V2 的 @@ -267,23 +281,50 @@ t=1000 µs 下一个 TIM6 更新事件 优先级调整不影响掩码。 - **验证前置**:实施后须 `@build` 0 错 0 警 + 台架 `@test` 复核 `idle` 是否降至 ≈0(§7.1)。 -### 10.3 预期收益与语义 +### 10.3 收益与语义(含落地实测) 1. **唤醒延迟固定 µs 级**:原 `osDelayUntil(1ms)` 把唤醒量化到 tick 边界 → 0~1 ms 随机相位抖动 + - 一旦超拍即"永久落后/空转"风险(即 P0-2 的成因);事件驱动走 TIM6 硬件节拍,唤醒 = 生产时刻 + ISR/调度延迟(亚 µs~数 µs),**无 tick 量化、无累积漂移**。 -2. **idle 趋近 0**:原轮询与生产的 ±1 帧相位抖动(§7.1 每 10 s +5)消失——唤醒与生产同源锁定。 - 待台架复核(§7.1)。 -3. **电平型标志的健壮性**:若单拍偶超 1 ms,TIM6 多发脉冲合并为一次唤醒,`adc_input_poll()` 返回 + 一旦超拍即"永久落后/空转"风险(即 P0-2 的成因);事件驱动走 TIM6 硬件节拍,唤醒 = 生产时刻 + + ISR/调度延迟(亚 µs~数 µs),**无 tick 量化、无累积漂移**。 +2. **idle 归零(实测已达成)**:原轮询与生产的 ±1 帧相位抖动消失——唤醒与生产同源锁定。 +3. **粘滞标志的健壮性**:若单拍偶超 1 ms,TIM6 多发脉冲合并为一次唤醒,`adc_input_poll()` 返回 `r==0`(无新帧)记 `idle`,**不重复处理同帧**,与 P0 修复(仅 `r==1` 压环)口径一致,无 DFT 窗污染。 -### 10.4 残留风险与后续硬化(P1-5,规划中) +**落地实测(2026-10-10,`@test` PASS,开机 29 s,台架无客户端)**: -- **§4.4 的 2 ms 忙等仍在 L1 层**:`adc_input_poll()` 内的 `HAL_ADC_PollForConversion` - (`ADC_INPUT_POLL_TIMEOUT_MS=2U`)是任务级忙等。改造后 `adcTask` 若提至 AboveNormal,若 STM32 ADC - 偶发超时,该 2 ms 忙等会在**高优先级**下阻塞其余 Normal 任务(比现状更敏感)。**硬化方向(待做)**: - 超时即**整拍跳过**(不轮询 9 通道),或在 `adc_input.c` 改用非阻塞查询 + 标志,彻底消除忙等占用。 -- **启动 fail-safe(设计)**:TIM6 未启动前 `adcTask` 永久阻塞于 `osThreadFlagsWait` → 无心跳 → 看门狗 - `TASK_WD_ADC` 触发复位。属安全 fail-safe,非缺陷(确保"节拍源缺失"不被静默放过)。 +| 指标 | 改前(1 ms 轮询 + Normal) | **改后(事件驱动 + AboveNormal)** | 判读 | +|---|---|---|---| +| `idle` 无新帧拍数 | 649/20001;P0-1 后仍 **1/20000** | **0 / 19999**(两次心跳全零) | 相位抖动**彻底消失**,唤醒与生产同源 | +| `frames` 速率 | 1000.0/s | **1000.0/s**(t=21155→29227 ms 恰 +8072 拍) | 节拍未丢、未重复消费 | +| `ring` | == `frames` | == `frames`(19999) | P0-1 口径保持 | +| `adcm` 单拍最坏 | ≈2.1 ms | **2.12~2.25 ms** | ⚠ 归因已复核:峰值来自**每 10 s 一次的心跳日志**(`DBG_INFO` + `log_phasors`),不是采集——同批 `adcp` 仅 122 µs,且 `idle=0` 反证没有"每拍 2 ms"(否则粘滞标志会合并唤醒、`idle` 必 >0) | +| `uip` / `uiw` / `uis` | 61.3 / 11.4 ms / 1 µs | **61.3 / 11.4 ms / 1 µs** | 提优先级**未**伤到 UI(LVGL 地板不变) | +| `defd` 控制台排空 | 46 ms | 46 ms | 未被 AboveNormal 挤动 | +| `seq` − `frames` | — | 20006 − 19999 = **7** | 正是"TIM6 已在跑、消费者尚未就绪"的 ≈100 ms 窗口(§10.6),与设计一致 | + +### 10.4 残留风险与后续硬化(落地后仍开放) + +- **Task #32 §4.4 的 2 ms 忙等仍在 L1 层,且如今跑在 AboveNormal 上**:`adc_input_poll()` 内的 + `HAL_ADC_PollForConversion`(`ADC_INPUT_POLL_TIMEOUT_MS=2U`)是任务级忙等(`HAL_GetTick()` + 1 ms 粒度、判据 `> Timeout` → 实际 **2~3 ms**)。提优先级后,STM32 ADC 一旦偶发超时,这 2~3 ms + 会在**高于 net/ftp/default/timeSync** 的优先级上压住它们——比改前更敏感。 + **6 次上板对照(2026-10-10)**:超时 1 次的那轮 `adcp = 2209 µs`;超时 0 次的 5 轮 + `adcp = 122 µs`(= 9 通道 × 7.4 µs + TPAFE 快照)→ **2 ms 只在超时拍出现,不是常态开销**。 + ⚠ **超时的第二个代价是正确性,不只是耗时**:超时拍 `s_stm32_valid` 仍是上次成功留下的 1, + 而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、照样压环,STM32 侧几路 + (UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(`adc_input.c:209-212`),`s_frames_skip` + 只统计"从未成功过"、抓不到它。 + **硬化方向(待做,按性价比排序)**:① 超时即置 `s_stm32_valid = 0` → 本拍 STM32 侧判为无有效 + 数据,走既有 `skip` 整帧跳过路径(一行改动、无时序风险,先把"静默陈旧帧"这条正确性问题关掉); + ② EOC 改 EXTI 或 DMA + 非阻塞查询,彻底消除忙等;③ DMA 双缓冲属 V0.2 硬件项(§10.5)。 +- **已做的一项(2026-10-10):补 F4 ADC 上电自校准**。CubeMX **不生成**该步骤,且本仓库裁剪版 + HAL **没有** `HAL_ADCEx_Calibration_Start` → 由 `Src/adc.c` 的 `adc1_calibrate()` 实现 + (`USER CODE` 区 = L2,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底)。 + 实测校准前后同一拍 STM32 侧 6 路**同向**偏移 +10~+63 LSB、TPAFE 侧一字不变 → 此前 ADC 确实 + 带失调在跑。它**不消** 2 ms 忙等,但把"未校准"这条可疑路径从 §7.2 的候选根因里划掉。 +- **启动 fail-safe(设计如此,已按此实现)**:TIM6 未启动 / 停摆 → `adcTask` 永久阻塞于 + `osThreadFlagsWait(osWaitForever)` → `TASK_WD_ADC` 缺席 → IWDG 复位。属安全 fail-safe, + 非缺陷(确保"节拍源缺失"不被静默放过)。 ### 10.5 下一版硬件建议:DMA + 双缓冲(方案 b,用户要求记录) @@ -300,3 +341,25 @@ t=1000 µs 下一个 TIM6 更新事件 AboveNormal 算力给保护算法;与 §8 等周波调频天然契合(ARR 改频 + DMA 自动续传)。 - **验收前置**:先以逻辑分析仪确认 TPAFE 接口类型与 `DRDY` 时序,**再**定型下一版硬件 DMA 方案, 避免返工。 + +### 10.6 实施时补的一条:消费者就绪门(规划未预见,必须保留) + +规划写的是"ISR 里 `osThreadFlagsSet(adcTaskHandle, ...)`"。实施时发现**时序上不能直接这么写**: + +- TIM6 在 `app_main_init()`(`Src/main.c:140`)里就 `HAL_TIM_Base_Start_IT(&htim6)`,而 + `osKernelInitialize()` / `MX_FREERTOS_Init()` / `osKernelStart()` 在 `:144/145/148` + → **"节拍源已在跑"与"消费者已存在"之间有约 100 ms 窗口**,其间 `adcTaskHandle` 还是 NULL、 + 调度器也还没起。 +- `osThreadFlagsSet()` 对 NULL 句柄本身安全(`cmsis_os2.c` 的入参检查返回 `osErrorParameter`), + 但"句柄已赋值、调度器未启动"这段(建任务 → `osKernelStart`)仍会让 ISR 版内核 API 在调度器外跑。 + 本仓库对这类坑有实测记录:`Src/main.c` 的 **TIM7 分支注释**(2026-10-02,port.c 优先级断言 + 把系统卡死在 ISR 内)。 +- **处置**:ISR 只调 `adc_tick_irq_notify()`,其内含就绪门 `s_consumer_ready`——由 `adcTask` + 自己在进入等待前置 1(单写者 = 任务,ISR 只读)。把时序约束变成**显式代码**,而不是依赖 + FreeRTOS 的内部实现细节。 +- **实测印证**:`seq − frames = 20006 − 19999 = 7`——正是这 ≈100 ms 窗口里 TIM6 发出的 7 个脉冲 + 被丢弃(当时无消费者),之后一帧不差。 + +**教训(与 `docs/问题记录/UI任务看门狗停摆分析.md` §9.3.1 的 I-2/I-3 同类)**:把"定时轮询"换成 +"事件驱动"时,必须单独核对**节拍源启动时刻 vs 消费者创建时刻**,否则第一次上电就会踩到 +"ISR 对还不存在的消费者动手"。 diff --git a/docs/硬件平台/ADC通道映射说明.md b/docs/硬件平台/ADC通道映射说明.md index 166b385b..39c7eff3 100644 --- a/docs/硬件平台/ADC通道映射说明.md +++ b/docs/硬件平台/ADC通道映射说明.md @@ -130,9 +130,10 @@ STM32(U17)为 LQFP144 脚号。 |---|---|---| | **通道归属(硬件缺陷)** | 8 路电气量分属两片转换器:UA/UN/IA → TPAFE,UB/UC/IB/IC/IN → STM32 ADC | **V0.1 硬件缺陷**(非有意分配,理由见 §1)→ V0.1 上禁止跨转换器做相位/功率/功率因数/矢量类运算;**V0.2 改版要求 8 路电气量全部走 TPAFE**(登记簿 N-18) | | STM32 ADC 外设 | **已生成(2026-10-09,CubeMX 6.17 + FW_F4)**:`Src/adc.c`/`Inc/adc.h` 已入库并挂进 `MDK-ARM/*.uvprojx`;`Inc/stm32f4xx_hal_conf.h` 的 `HAL_ADC_MODULE_ENABLED` 已生效;HAL `stm32f4xx_hal_adc.c/_ex.c` + `stm32f4xx_ll_adc.h` 已补入仓库(HAL 原本是裁剪子集);`Src/main.c` 里 `MX_ADC1_Init()` 排在 `MX_IWDG_Init()` 之后。生成配置核对无误:9 通道 / Rank 1~9 / 全部 144 cycles / `ScanConvMode=ENABLE` / `ContinuousConvMode=DISABLE` / `ExternalTrigConv=ADC_SOFTWARE_START` + `EDGE_NONE` / `EOCSelection=ADC_EOC_SINGLE_CONV` / `DataAlign=RIGHT` / 12 位;`HAL_ADC_MspInit()` 已把 PA0~PA7+PC4 配成 `GPIO_MODE_ANALOG` | 硬件通路已打通(`hadc1` 可用);**消费者已落地**——`App/algo/adc_input.c` 的 `stm32_scan()`(L1-3,2026-10-10):`HAL_ADC_Start` + 逐通道 `PollForConversion`/`GetValue`,9 通道一轮 ≈ 67 µs | +| ADC1 上电自校准 | **已补(2026-10-10)**:`Src/adc.c` 的 `USER CODE BEGIN ADC1_Init 2` 调用 `adc1_calibrate()`(实现同文件 `USER CODE BEGIN 1`,按 RM0090 §13.3.6:清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)。⚠ **CubeMX 不生成这一步**,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头**没有** `ADC_CR2_CAL`(bit3,代码里按 RM 自建 `ADC1_CAL_BIT`)→ 属 L2 手工定制,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底 | 实测有效:校准前后同一拍 STM32 侧 6 路**同向**变化 `UB 898→961`、`UC 978→1038`、`IB 906→922`、`IC 1061→1071`、`IN 1017→1055`、`T 790→846`(+10~+63 LSB ≈ 0.2~1.5 % FS);TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。缺这一步不报错,只是**读数带失调** | | ADC 时钟 | CubeMX 的 F4 ADC 面板**有** Clock Prescaler 一项(此前"面板没有该项、固定 DIV2"的判断有误),已选 **PCLK2/4 → ADCCLK = 21 MHz** ✓。面板里 **DIV2 是灰的、选不到**:DB 里 `FadcConstraint Max=36000000`,APB2 = 84 MHz → DIV2 = 42 MHz 超限,该候选值被 `Action="DISABLE"` 禁掉,超规值进不来 | **不设额外防护(2026-10-09 决定)**:约束已由 CubeMX 把关;`.ioc` 虽不保存 `ADC1.ClockPrescaler` 键,重生成也只会回落到约束允许的值。按 144+12 cycles 算 ≈ 7.4 µs/通道、9 通道一轮 ≈ 67 µs | -| `adc_task` | **已实现(B1,2026-10-10;L1 层 v2.3,2026-10-10)**:`App/task/adc_task.c` 每 1 ms 调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 原子快照(8 通道)+ STM32 ADC1 顺序扫描,**经 `adc_input_raw()` 按语义取数**(不再直接索引驱动缓冲);任务在 `Src/freertos.c` 的 `USER CODE` 区创建为 `adcTask`(512 words / Normal) | 数据路径已通(台架 `@test`:`frame_seq` 严格 1000 Hz 递增;`adc1_rc=0` / `adc1_err=0`) | -| TPAFE 采样触发 | **周期 CONVST 已实现(2026-10-10)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 **TIM6 更新中断(1 kHz)** 内调 `tpafe5160_start_conv_irq()`;`app_main_init()` 用 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍(在 `tpafe5160_init()` 之后) | 连续波形(1 kHz);详见 `子系统设计/ADC采样节拍设计说明.md` | +| `adc_task` | **已实现(B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费,2026-10-10)**:`App/task/adc_task.c` 每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 原子快照(8 通道)+ STM32 ADC1 顺序扫描,**经 `adc_input_raw()` 按语义取数**(不再直接索引驱动缓冲);**消费唤醒由 TIM6 ISR 置位 `ADC_WAKE_FLAG`**(不再 1 ms 轮询);任务在 `Src/freertos.c` 的 `USER CODE` 区创建为 `adcTask`(512 words / **AboveNormal**) | 数据路径已通(台架 `@test`:`frames` 严格 1000 Hz 递增、**`idle=0`**、`adc1_rc=0` / `adc1_err=0`) | +| TPAFE 采样触发 | **周期 CONVST 已实现(2026-10-10)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 **TIM6 更新中断(1 kHz)** 内调 `tpafe5160_start_conv_irq()`;`app_main_init()` 用 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍(在 `tpafe5160_init()` 之后)。同一 TIM6 分支还调 `adc_tick_irq_notify()` **唤醒消费者**(P1-5 方案 a,2026-10-10;内含"消费者就绪"门) | 连续波形(1 kHz);详见 `子系统设计/ADC采样节拍设计说明.md` §10 | | 前端偏置 | 每路 1k 串联 + 1k 到 `*_GND` + 滤波 C,**无中点偏置** | STM32 侧只能测 **0~3.3 V 单极性**,交流量负半周被削顶;主板侧只做监视可接受,若要算 RMS/功率需硬件补偏置或明确算法口径 | | TPAFE 量程 | 默认 ±5 V(RANGE 接 GND),16 位二进制补码,LSB ≈ 152.59 µV | 用 `tpafe5160_to_voltage()` 换算 | | 取数契约(**2026-10-10 已修**,E-44 / E-45 / E-58 闭环) | 旧的 `get_buf` / `data_ready` / `clear_ready` **已删除**(删除前确认全仓库零调用者):`get_buf` 只给指针、任务再拷 → 被 BUSY EXTI 抢占就是**两帧混合**;`s_ready` 电平标志被 `start_conv_irq` 触发前清 0 → **重触发即丢未消费帧**;`read_all` 从不看 FRSTDATA(宏有定义、代码零引用)→ 芯片死亡时返回 OK + 常量垃圾。现口径:**`tpafe5160_snapshot(dst, count)`**(临界区原子拷贝,返回 1/0/-1,**仅任务上下文**)+ **`tpafe5160_frame_seq()`**(帧序号只增不清)+ **`tpafe5160_frstdata_err()`**(无跳变帧计数);`tpafe5160_init()` 增**首帧自检**——一帧读下来 FRSTDATA 无跳变(芯片不驱动并行总线;板上 R24 4.7k 上拉会把它钉死为高)→ 返回 `-3` + `[ERR]` | 消费者**只能**用 `snapshot()` 取数(`AGENTS.md` 已立为铁律);真机实测开机 `[ADC] Init OK` 现只在自检帧通过后才打印 → 判据在本板成立、非空转 | diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md index f8d25d63..fd46cd97 100644 --- a/docs/问题记录/UI任务看门狗停摆分析.md +++ b/docs/问题记录/UI任务看门狗停摆分析.md @@ -4,13 +4,14 @@ > → 连续 8 次(800ms)缺席 → 停喂 IWDG → 复位。 > **背景**:L2 相量层落地(commit `3762c03`)后**新出现**的间歇性回归:L2 版 3/10 次运行失败, > 改前基线(`git stash` 重建)0/6。 -> **状态**:**分析完成,P0/P1/P2 处置已落地并上板验证**(2026-10-10)。P0(adc 压环条件 + -> 截止期重同步)修复后累计 22 次启动 0/22 复现;P1-4a 已解耦 UI 心跳与 netTask 往返;P2 常开 -> 探针已上线。详见 §8「处置落地」。 -> **遗留 → §9**:P1-4a 只解耦了**心跳**,**刷新仍被 `ui_net_sink_poll()` 拖**(用户可观测的 -> "刷新不稳")。§9 已**结案**:A 门控 + 兜底**已落地并 10/10 上板验证**;"把推流搬出 uiTask" -> 的 B 方案**已评估并决定不做**。⚠ 实测修正了当初的归因——刷新最坏周期(`uip` ≈61 ms)是 -> **LVGL 帧周期地板,与网络无关**。详见 §9.3.2 / §9.4。 +> **状态**:**分析完成,全部处置已落地并上板验证**(2026-10-10)。P0(adc 压环条件 + 截止期 +> 重同步)、P1-4a(UI 心跳自产)、P2(常开 `[PROF]` 探针)、§9 方案 A(推流就绪门控 + 兜底)、 +> **P1-5(ADC 消费改 TIM6 ISR 事件驱动 + `adcTask` 提至 `AboveNormal`)** 全部落地;停摆累计 +> **38 次上板启动 0 复现**(P0 后 22 次 + A 后 10 次 + P1-5 后 6 次)。§9 方案 B +> (把推流搬出 uiTask)**已评估并决定不做**。 +> **阅读口径**:§2~§7 是**故障当时**的证据与归因,其中已被处置改掉的代码片段就地标注 +> **改前 / 现状**;§8 末「当前健康剖面」是收口后的实测基线。⚠ 实测还修正了当初的归因—— +> 刷新最坏周期(`uip` ≈61 ms)是 **LVGL 帧周期地板,与网络无关**(§9.3.2)。 > **关联**: > [`../待处理内容.md`](../待处理内容.md)(**N-17** 心跳缺口登记)、 > [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、 @@ -30,9 +31,11 @@ 1. `uiTask` 是 `required_mask` 六个任务里**唯一的 `osPriorityBelowNormal`**(其余五个全是 `osPriorityNormal`)。FreeRTOS 下**就绪的 Normal 任务永远抢占 BelowNormal 任务**, 时间片轮转只对**同优先级**生效 → uiTask 只能在"五个 Normal 任务同时阻塞"的缝隙里运行。 -2. `uiTask` 的心跳**不是自产的**:它每轮先调 `ui_net_sink_poll()` → `net_accept()` → - `osMessageQueuePut` + **`xTaskNotifyWait(portMAX_DELAY)`**,即**绕 `netTask` 走一次往返**, - 之后才 `task_wd_beat(TASK_WD_UI)`。**netTask 慢一拍,UI 的心跳就晚一拍。** +2. **(改前)** `uiTask` 的心跳**不是自产的**:每轮先调 `ui_net_sink_poll()` → `net_accept()` + → `osMessageQueuePut` + **`xTaskNotifyWait(portMAX_DELAY)`**,即**绕 `netTask` 走一次往返**, + 之后才 `task_wd_beat(TASK_WD_UI)`——**netTask 慢一拍,UI 心跳就晚一拍。** + **现状**:P1-4a 已把心跳提到 poll **之前**(`App/task/ui_task.c:159`),A 门控又让无客户端时 + **一次 RPC 都不发**(§9.3)→ 这条依赖已断开,见 §4.2。 **决定性证据**:失败时掩码 `0x67` 只缺 UI 一位,其余五个任务(default / net / ftp / timeSync / adc) 的心跳**全部正常**。若是 CPU 饱和,Normal 级的 netTask/ftpTask/adcTask 会**一起**掉线——它们没有。 @@ -95,6 +98,9 @@ ### 3.2 结论 **合计 10%~20%,不是"负载满了"。** 按 1 ms 节拍算,adcTask 每拍仍能睡约 0.87 ms。 +**现状印证**:收口后 `[PROF2]` 的 `adcp`(ADC 单次最坏)实测 **0.12~2.5 ms**、`[PROF]` 的 +`adcm`(单拍最坏)**≈2.1 ms**;`adc1_err` 从当年的 **288/10 s** 降到 **1**(§7.2)—— +上表"+5.8% 忙等"那一项如今基本不出现。 ### 3.3 日志反证(有力) @@ -122,7 +128,7 @@ | netTask | `osPriorityNormal` | 24 | ✅ | | ftpTask | `osPriorityNormal` | 24 | ✅ | | timeSyncTask | `osPriorityNormal` | 24 | ✅ | -| adcTask | `osPriorityNormal` | 24 | ✅ | +| adcTask | `osPriorityNormal`(**改前**;P1-5 后为 `osPriorityAboveNormal`) | 24 → **32** | ✅ | | **uiTask** | **`osPriorityBelowNormal`** | **16** | ✅ | | testTask | `osPriorityBelowNormal` | 16 | ✖(产品构建不创建) | @@ -130,9 +136,16 @@ uiTask 就**完全拿不到 CPU**;`configUSE_TIME_SLICING=1` 只保护**同优先级**之间的公平, 对它无效。 +> **P1-5 落地后的变化(2026-10-10)**:`adcTask` 已提到 **`AboveNormal`(32)** → 本表"uiTask +> 垫底"的格局更极端。之所以可接受:① 改造后 `adcTask` 由 TIM6 **精确 1 kHz** 唤醒,单拍 +> µs~2.25 ms 且**不再有空转窗口**——改前的 `osDelayUntil` 一旦超拍就永久落后 → 不再阻塞 → +> 以 Normal 全程抢占 uiTask,那才是本表真正担心的形态(P0-2 已修,见 §4.3);② 实测 6 次上板 +> `uip` / `uiw` / `uis` 与提优先级前**一字不差**(61.3 ms / 11.4 ms / 1 µs)。 +> ⚠ 换来的新暴露见 §4.4 现状。 + ### 4.2 UI 心跳依赖 `netTask` 往返(**为什么"只有 UI 掉"**) -`App/task/ui_task.c:117` 主循环: +**改前**(`ui_task.c` v1.2,本次失效的现场)主循环: ```c while (1) { @@ -143,8 +156,8 @@ while (1) { } ``` -`Drivers/BSP/UI/ui_net_sink.c:457` 的 `ui_net_sink_poll()` 在**无远端客户端时**(台架常态) -每轮都走 `net_accept()`;而 `Drivers/BSP/NET/net_socket.c:1222` 的 `net_accept()`: +**改前** `ui_net_sink_poll()` 在**无远端客户端时**(台架常态)每轮都走 `net_accept()`; +而 `net_accept()`(`Drivers/BSP/NET/net_socket.c`)的固定模式是: ```c if (osMessageQueuePut(netMsgQueueHandle, &msg, 0, osWaitForever) != osOK) { ... } /* 队列深 8,满则永久阻塞 */ @@ -153,12 +166,17 @@ msg.result = net_wait_result("acc", sockfd); `net_wait_result()`(`net_socket.c:851`)末句是 **`xTaskNotifyWait(..., portMAX_DELAY)`——无限期阻塞**。 -→ **UI 的心跳周期 = LVGL 渲染 + 一次 `netTask` 往返 + 10 ms。** `netTask` 被延迟多久, +→ **改前 UI 的心跳周期 = LVGL 渲染 + 一次 `netTask` 往返 + 10 ms。** `netTask` 被延迟多久, UI 的心跳就晚多久。**这是 UI 独有的依赖,其它任务没有**——正好解释"只有 UI 位缺失"。 -### 4.3 adcTask 的截止期一旦错过就**不可恢复** +**现状(两处已改,此依赖已断开)**:① **P1-4a** 把 `task_wd_beat(TASK_WD_UI)` 提到 poll +**之前**(`App/task/ui_task.c:159`,poll 在 `:164`)→ 心跳从此只测"UI 自己被调度到"; +② **§9-A 门控**让无客户端时**一次 RPC 都不发**(`uis` 实测 1 µs,§9.3.2)。 +有客户端时往返仍在,属刻意保留的残留暴露(§9.4)。 -`App/task/adc_task.c:243`: +### 4.3 adcTask 的截止期一旦错过就**不可恢复**(**已修 = P0-2**) + +**改前**(`App/task/adc_task.c`): ```c next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ @@ -171,19 +189,58 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ (单次 2 ms 的慢拍只会多跑一拍就追平,见 §5 的 `ring` 反证;**连续**慢拍才会锁死。) **返回值是现成的重同步钩子,但被丢弃了。** +**现状(P0-2,`adc_task.c:277-280`)**: + +```c +next += ADC_TASK_BEAT_MS; +if (osDelayUntil(next) != osOK) { next = osKernelGetTickCount(); } /* 落后 → 一次性拉回基准 */ +``` + +实测印证:单拍最坏 `adcm ≈ 2.1 ms` **确实会越过 1 ms 节拍**,但 `ring` 仍严格 1000 次/秒 +(§3.3 的判据依旧成立)→ 重同步在生效,没有退化成 Normal 级空转。 + +**再现状(P1-5,2026-10-10)**:这段已**整体删除**——`ADC_TASK_BEAT_MS` 与 `next` 都不在了, +循环尾改为 `(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)`,唤醒由 +TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上消失**(不再有自定截止期,也就没有 +"落后"这个概念),P0-2 的重同步钩子随之作废。实测 6 次上板 **`idle = 0`**(改前 1~649), +见 §8 与 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10。 + ### 4.4 ADC 超时是"2 ms 纯忙等",不阻塞、不让出 `App/algo/adc_input.c:21` `ADC_INPUT_POLL_TIMEOUT_MS = 2U`,配合 `HAL_ADC_PollForConversion()`(`stm32f4xx_hal_adc.c:882`)里的 `while (!EOC) { if ((HAL_GetTick() - tickstart) > Timeout) return HAL_TIMEOUT; }` —— **一个通道超时 = 2 ms 不让出 CPU 的自旋**,且发生在 Normal 优先级。 -9 通道全超时最坏 = **18 ms 连占**。这是现成的饥饿源。 +这是现成的饥饿源。 + +> **单拍上界修正(2026-10-10 复核)**:本段原文写"9 通道全超时最坏 = **18 ms** 连占"**有误**。 +> `stm32_scan()` 在**第一个**超时处就 `HAL_ADC_Stop()` + `return`(`App/algo/adc_input.c:79-84`), +> 从不轮询剩余通道 → **单拍最坏 = 一次超时 ≈2~3 ms**(`HAL_GetTick()` 是 1 ms 粒度、判据是 +> `> Timeout`),不是 9 次。 + +**现状(P1-5 后:忙等未改,但跑它的任务优先级抬高了 → 敏感度上升)**: +`ADC_INPUT_POLL_TIMEOUT_MS = 2U` 与忙等**仍未改**——P1-5 只做"事件驱动 + 提优先级", +超时硬化列为后续(见 +[`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10.4)。 +三点须一起看: + +- 这条饥饿源如今**基本不被触发**,且**只在超时拍出现**:6 次上板对照——超时 1 次的那轮 + `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`(= 9 通道 × 7.4 µs + TPAFE 快照); + `adc1_err` 从 288/10 s 降到 **0~1**; +- 但 `adcTask` 现已在 **`AboveNormal`(32)**,一旦某拍真走到 2 ms 忙等,它挤的是 + netTask / ftpTask / defaultTask / timeSyncTask(改前是同级竞争)→ **这是 P1-5 换来的新暴露, + 记在账上**; +- ⚠ **超时的第二个代价是正确性,不只是耗时**:超时拍 `s_stm32_valid` 仍是上次成功留下的 1, + 而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、照样压环,STM32 侧几路 + (UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(`adc_input.c:209-212`);`s_frames_skip` + 只统计"从未成功过",抓不到它。→ 硬化第一步应是**超时即置 `s_stm32_valid = 0`**(走既有 + `skip` 整帧跳过路径,一行改动),而不是先去消忙等。 --- -## 5. 顺带发现的一个**独立**缺陷:无新帧仍然压环 +## 5. 顺带发现的一个**独立**缺陷:无新帧仍然压环(**已修 = P0-1**) -`App/task/adc_task.c:140-176`:`r == 0`(TPAFE 无新帧)时**仍然**执行 +**改前**(`App/task/adc_task.c`):`r == 0`(TPAFE 无新帧)时**仍然**执行 `sample_ring_push_frame()` + `phasor_run()`——只有 `r == -1` 才不压。 日志精确印证(`ring = frames + idle`): @@ -200,15 +257,23 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ 这与本次停摆**无关**(不增加多少 CPU),但属**L2 的真实正确性缺陷**,应一并修: **只在 `r == 1` 时压帧**。 +**现状(已修,`adc_task.c:180`)**:仅 `r == 1` 才 `sample_ring_push_frame()` + `phasor_run()`。 +上板直接印证:`frames=19999 idle=1 ring=19999` → **`ring == frames`**(修前是 +`ring = frames + idle`),且 `idle` 从 649/20001 降到 **1/20000**(另见 §7.5)。 + --- ## 6. 为什么"只有 UI"、"为什么间歇" -- **只有 UI**:UI 是唯一低优先级(§4.1)**且**心跳依赖别的任务(§4.2)的必需任务。 +- **只有 UI(改前)**:UI 是唯一低优先级(§4.1)**且**心跳依赖别的任务(§4.2)的必需任务。 其它五个任务只要 100 ms 内被调度到一次就能喂上位,而它们全在 Normal 级,彼此时间片轮转保障。 -- **间歇**:需要一次"某个 Normal 任务连续占用 ≥800 ms"的巧合。候选(见 §7): + **现状**:低优先级这一条**至今未变**(§4.1 的表仍成立,uiTask 仍是掩码里唯一的 + BelowNormal),变的是后半条——心跳已自产(P1-4a)、往返已门控(§9-A)。 +- **间歇**:需要一次"某个 Normal 任务连续占用 ≥800 ms"的巧合。**当时的**候选(见 §7): `netTask` 回执延迟、`defaultTask` 的长段(syslog → NAND/FTL 写、配置保存)、 - adcTask 的超时突发。 + adcTask 的超时突发。**现状**:探针已把这三条的上界量出来——`defd ≈ 46 ms`(控制台排空)、 + `adcm ≈ 2.1 ms`(单拍)、`uis = 1 µs`(推流往返),**没有任何任务接近 800 ms** + (§8「当前健康剖面」)。 - **为什么 L2 才暴露**:L2 让 adcTask 每拍多干 10~15 µs 并**引入了 `adc1_err` 超时** (L1 阶段台架 `adc1_err=0`,L2 阶段出现 288/10 s)→ Normal 级的可用余量被吃掉, uiTask 的缝隙被挤到 0。**这正是"负载上去以后最先倒下的那块短板"。** @@ -222,13 +287,22 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ (a) `netTask` 回执延迟(§4.2),还是 (b) `defaultTask` 长段(syslog NAND 写 / 配置保存),还是 (c) adcTask 超时突发(§4.4)。 - **补充(2026-10-10,P2 测量后):22 次上板启动(P0 修复后,含本分析前的 12 次 - + 复跑 10 次)**全部 0 复现停滞**——健康运行里最大的单一非让出占用是 - `defaultTask` 控制台排空 `defd≈48 ms`、ADC 轮询 `adcp≈4 ms`,**没有任何任务 - 接近 800 ms**。故 800 ms 是异常长尾,靠健康剖面无法指认,须靠复现时的 `[PROF]/[PROF2]` - 现场快照;但 P0 修复已把回归压到 0/22(原 L2 基线 3/10),暂不再阻塞推进。 -2. **`adc1_err=288` 的自身根因未查。** 2 ms 超时对 7.4 µs 的转换而言异常巨大, - 指向 ADC 被反复 `Start/Stop` 或 EOC/OVR 状态异常;需专项测量,不要与本条混谈。 + **补充(2026-10-10,P2 测量 + §9-A 收口后):累计 32 次上板启动(P0 后 22 次 + + A 后 10 次)全部 0 复现停滞**——健康剖面里最大的单一非让出占用是 `defaultTask` + 控制台排空 `defd ≈ 46 ms`,其次是 ADC 单拍 `adcm ≈ 2.1 ms`(⚠ 复核后归因已修正:该峰值来自 + 每 10 s 一次的心跳日志,不是采集,见 §4.4)、ADC 单次 `adcp ≈ 0.12~2.5 ms`, + **没有任何任务接近 800 ms**。故 800 ms 属异常长尾,靠健康剖面无法指认,只能靠复现现场的 + `[PROF]/[PROF2]` 快照;但 P0 已把回归压到 0(原 L2 基线 3/10),暂不再阻塞推进。 +2. **`adc1_err=288` 的自身根因仍未查,但现象已随 P0-1 基本消失。** 2 ms 超时对 7.4 µs 的 + 转换而言异常巨大,指向 ADC 被反复 `Start/Stop` 或 EOC/OVR 状态异常。 + **现状**:失败日志 **288/10 s** → 收口后 **1/20 s**;若再现,优先回头查这条。 + **已补一项候选根因(2026-10-10)**:F4 ADC 的**上电自校准**此前在本项目**完全缺失** + (CubeMX 不生成该步骤,且本仓库裁剪版 HAL 没有 `HAL_ADCEx_Calibration_Start`)→ 现由 + `Src/adc.c` 的 `adc1_calibrate()` 补上(详见 + [`../硬件平台/ADC通道映射说明.md`](../硬件平台/ADC通道映射说明.md) 的"ADC1 上电自校准"行)。 + 实测校准让 STM32 侧 6 路**同向**偏移 +10~+63 LSB → 说明此前 ADC 确实带失调在跑。 + 它直接解释的是"读数带偏",未必等于 EOC 超时的根因,但"未校准状态下 ADC 内部行为异常" + 是可疑路径,故记为 §7.2 已收敛的一半。 3. **复位归因已澄清为非诊断项。** 失败日志报 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)`(即 `SFTRSTF|PINRSTF`);但 2026-10-10 的健康启动**同样**报 `software (NVIC_SystemReset)` 且判定 PASS——说明本台架上电复位本就走软件复位路径 @@ -236,75 +310,70 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ 证据仍是 `[ERR][WD] task stall` 行本身,而非复位原因寄存器。 4. **`[STACK]` 周期从 2.0 s 拉到 4.05 s**(17:36:19.902 → 23.951,标称 `STACK_PROBE_PERIOD_MS=2000`) 值得注意,但**不能作为证据**:控制台缓冲满时会**丢行**,该报告也可能只是被丢了。 -5. **`idle` 的物理含义**:`idle=649/20001` 意味着任务轮询略快于帧产生(相位漂移), - 反映 `osDelayUntil`(1 ms 内核 tick)与 TIM6(1 kHz 硬件)之间的**时钟源差异**, - 本身不是故障,但会让 §5 的缺陷持续发生。 +5. **`idle` 的物理含义**:任务轮询与帧产生之间的相位漂移(`osDelayUntil` 的 1 ms 内核 tick + vs TIM6 的 1 kHz 硬件节拍,两个时钟源不同),本身不是故障。 + **现状**:P0-1 后 `idle` 只计数、不再压环,实测也从 649/20001 降到 **1/20000**—— + 当年"它会让 §5 的缺陷持续发生"这条耦合已解除。 6. **启动阶段 `MISS start snap=0x67` 是良性的(2026-10-10 新增)。** `[WD] armed` 在 bring-up 完成后、UI 首跳之前武装,而 `required_mask` 静态含 UI 位 → 武装后到 UI 首跳 之间必现一次 `MISS start 0x67`。健康运行(`FATFS mounted≈0.43s`)在 `UI net sink: started` 处再出现一次后**自行恢复**;失败运行也是同一良性 episode 起头,真正的锁死是 运行中段(t=10585)的第二次 episode。**故该 episode 本身不是故障判据**,只作"UI 尚未 起跳"的旁证。 +7. **`netp`(netTask 的 `net_poll()` 最坏)恒报 0 µs——这是真实值,不是探针坏了。** + `net_poll()` 开头先读 INT# GPIO,高电平(无中断待处理)就直接 `break` + (`Drivers/BSP/NET/net_socket.c:431`)→ 空闲时整条 `net_poll()` 只是一次 GPIO 读, + < 1 µs 合理。读 `[PROF2]` 时**不要**把 `netp=0` 误判成"探针没接上"(接线在 + `App/task/net_task.c:276-278`,每轮都在测)。 --- -## 8. 处置建议(分层,**待确认后执行**) +## 8. 处置:决策与落地结果(2026-10-10) -### P0 — 缺陷修复(低风险、可独立验证) -1. **§5 压环条件**:`adc_task_func` 改为**仅在 `r == 1` 时**压帧 + `phasor_run()`, - 消除重复采样污染相量窗。 -2. **§4.3 截止期重同步**:`if (osDelayUntil(next) != osOK) { next = osKernelGetTickCount(); }` - —— 让"落后"变成**一次性重同步**,而不是永久空转。 +> 本节原本是"待确认后执行"的分层建议;处置已按 **P2 先测 → 按数据定 P1 → P0 顺路一起修** +> 的顺序执行完毕,故改写为**决策 + 结果**记录。当初的取舍理由仍是活约束,保留在状态列里。 -### P1 — 结构性(真正消除饥饿,需择一或组合) -3. **把 uiTask 提到 `osPriorityNormal`**(一行改动)。代价:UI 与其它任务平等竞争, - LVGL 单轮 10 ms 量级可能给 netTask 增加抖动;收益:**结构上不可能再被无限期饿死**。 -4. **解开"心跳依赖别的任务"**(§4.2):`ui_task_func` 主循环把 `task_wd_beat(TASK_WD_UI)` - 提到 `ui_net_sink_poll()` **之前**;或给 `net_accept()` 一条**非阻塞**查询路径 - (`NET_MSG_DONTWAIT` 语义)供 UI 推流使用。**这条即使不动优先级也应做**—— - 否则"UI 心跳"实际测的是"netTask 响应性"。 -5. **给 adcTask 的单拍耗时设硬上界**(§4.4):ADC 扫描改 **DMA / 中断驱动**(彻底去掉忙等), - 或至少把单通道超时压到不会吃掉整拍的量级,并在超时时**整拍跳过**而非继续自旋。 +| 项 | 内容 | 状态 | +|---|---|---| +| **P0-1** | §5 压环条件:仅 `r == 1` 时压帧 + 重算 | ✅ 已落地,上板印证 `ring == frames` | +| **P0-2** | §4.3 截止期重同步:`osDelayUntil` 返回值不再丢弃 | ✅ 已落地;**后被 P1-5 取代**——整段 `osDelayUntil` 已删,"落后"这个概念不存在了(§4.3 再现状) | +| **P1-3** | 把 uiTask 提到 `osPriorityNormal` | ❌ **刻意不做**:它会**掩盖** §4.2/§4.4 两个真实缺陷(当时的判断,现仍成立) | +| **P1-4a** | 心跳提到 `ui_net_sink_poll()` **之前** | ✅ 已落地(`ui_task.c:159`,v1.3) | +| **P1-4b** | 给 `net_accept()` 一条非阻塞查询路径 | ✅ 以更彻底的形式落地 = **§9-A 门控**(`net_can_accept()` / `net_can_read()`) | +| **P1-5** | 原列"adcTask 单拍硬上界(ADC 改 DMA / 超时整拍跳过)"→ 经用户决策改走**方案 a:事件驱动消费**(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 + `adcTask` 提至 `AboveNormal`) | ✅ **已落地**(2026-10-10;6 次上板 **`idle=0`**、`frames` 严格 1000 Hz、UI 侧探针一字不差;完整记录见 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10)。⚠ 原列的"**单拍硬上界**"(DMA / 超时整拍跳过)**仍未办**,且因优先级抬高而更敏感(§4.4 现状) | +| **P2-6** | 启用现成的 `TEST_LATENCY_PROBE`(`ui_task.c` 的 `ui_loop` 段) | ✅ 已被**常开探针 `App/util/task_prof.c/.h`** 取代(`[PROF]`/`[PROF2]`),无需再开测试开关 | +| **P2-7** | 新增 adcTask 单拍 / 推流往返两个埋点 | ✅ 已落地(`TASK_PROF_ADC_BEAT` / `TASK_PROF_ADC_POLL` / `TASK_PROF_UI_SINK`) | +| **P2-8** | 停摆取证带上"各任务在本窗口的调用计数" | ✅ 以更彻底的形式落地 = 心跳位图 **1→0 边沿**裸 UART 直发现场快照 | -### P2 — 观测(先能看见,再谈修) -6. 启用现成的 `TEST_LATENCY_PROBE`(`App/task/ui_task.c:109` 的 `ui_loop` 段)→ - 直接得到 UI 循环周期分布,一次性区分"饿死 vs 被拖"。 -7. 新增两个埋点:`adcTask` **单拍耗时**最坏值、`ui_net_sink_poll()` **往返耗时**最坏值。 -8. 停摆取证再加一项:把 `s_miss_hist` 与**各任务在本窗口的调用计数**一起落 `wdev.log`, - 直接指认占用者(现证据只到位图)。 +### 关键测量结论(当时决定 P1 取舍的依据) -### 建议执行顺序 -**P2-6/7 先测(一次 @test 即可拿到区分性数据)→ 按数据定 P1 的方案 → P0 顺路一起修 → 复跑 10 次确认 0/10。** -不建议直接上 P1-3(提优先级)了事:那会**掩盖** §4.2 与 §4.4 两个真实缺陷。 +- **改前**健康运行 `uiw ≈ uis ≈ 50 ms` → UI 单轮耗时几乎等于推流往返,证实 §4.2: + 心跳实际测的是"netTask 响应性"而非"UI 活着"。 +- **改前** `uisn = 0`(推流段无"仍在段内"积压)→ "永久卡在往返"这一机理被排除。 +- 最大单一非让出占用一直是 `defaultTask` 控制台排空(46~48 ms),**没有任何任务接近 800 ms** + → 800 ms 属异常长尾(§7.1)。 -### 处置落地(2026-10-10,按"先测量后修"顺序执行完毕) +### 验证与遗留 -- **P2(测量)= 已落地。** 新增常开探针模块 `App/util/task_prof.c/.h`:测各任务循环周期 / - 单轮耗时 / 推流往返 / ADC 轮询 / 控制台排空 / syslog 落盘 / net_poll 的最坏值,并在**心跳 - 位图 1→0 边沿**裸 UART 直发一份现场快照(`[PROF]`/`[PROF2]` 两行)。接线覆盖 ui / adc / - default / net 四个任务,已在 uvprojx 登记。 -- **P2 数据结论(决定 P1 的依据):** - - 健康运行 `uiw≈uis≈50 ms` → **UI 单轮耗时几乎等于推流往返耗时**,证实 §4.2:心跳被 - `netTask` 往返主导,实际测的是"netTask 响应性"而非"UI 活着"。 - - 健康运行里 `uisn=0`(推流段无"仍在段内"积压)→ UI **未被卡在往返**,阻塞机理排除。 - - 最大单一非让出占用 `defd≈48ms`(控制台排空)/ `adcp≈4ms`(ADC 轮询),无任务接近 800ms。 -- **P1(结构性)= 已落地 P1-4a。** `App/task/ui_task.c`:把 `task_wd_beat(TASK_WD_UI)` - **提到 `ui_net_sink_poll()` 之前**(v1.3)。心跳从此测"UI 自己被调度到",不再被 netTask - 往返拖垮。**未上 P1-3(提优先级)**:doc 明确警告它会掩盖 §4.2/§4.4。**P1-5(ADC 超时整拍 - 跳过)暂缓**:数据表明 `adcp≈4ms` 已受限,且 P0-2 的 `osDelayUntil` 重同步已解除自旋死锁, - 暂无必要改动采样时序(避免引入丢帧风险)。**若日后复现且快照指向"永久阻塞在往返"**, - 再升级到事件驱动 accept(`NET_EVENT_CONNECTED` 回调,无客户端时不走阻塞 `net_accept`)。 -- **P0(缺陷修复)= 已落地并验证。** `App/task/adc_task.c`: - - P0-1(§5):仅 `r==1` 时压环 + 重算。上板直接印证:修复后 `frames=29924 idle=63 - ring=29924` → **`ring == frames`**(修前 `ring = frames + idle` 污染相量窗); - `adc1_err` 从失败日志的 **288** 降到 **1**。 - - P0-2(§4.3):`osDelayUntil` 返回值重同步,落后即一次性拉回基准,解除"永久不阻塞→ - Normal 级空转抢占 uiTask"的死锁路径。 -- **验证:** P0 修复后累计 22 次上板启动 **0/22** 复现停滞(原 L2 基线 3/10); - P0+P1 后再复跑 10 次确认(见 `prof_batch4`)。构建 0 错误 0 警告 - (`Code=256276 RO=16160 RW=800 ZI=139760`)。 -- **遗留(不在本次处置范围):** §7.2 `adc1_err` 自身根因、§7.4 `[STACK]` 周期漂移、§4.4 的 - ADC 忙等未彻底改 DMA(仅以 P0-2 + 暂缓 P1-5 管控)。如复现重现,优先回头查 §7.2。 +- **验证**:P0 后累计 22 次上板 0 复现;§9-A 收口后再 10 次;**P1-5 落地后再 6 次** → + **累计 38 次 0 复现**(原 L2 基线 3/10)。构建 0 错误 0 警告;P1-5 后体积 + `Code=256728 RO=16160 RW=808 ZI=139760`(+360 B:ISR 入口 + `osThreadFlagsWait` 路径)。 +- **遗留(仍未办)**:§7.2 `adc1_err` 自身根因、§7.4 `[STACK]` 周期漂移、§4.4 的 ADC 忙等 + (未改 DMA / 未做超时整拍跳过——P1-5 解掉的是"相位抖动 + 超拍空转"两类,**没有**解忙等)。 + 停摆若再现,优先回头查 §7.2。 + +### 当前健康剖面(2026-10-10 **P1-5 后**复测:`@test` 6 连跑,无客户端,开机 29 s) + +| 探针 | 现值(最坏) | 含义 | +|---|---|---| +| `uip` / `uil` | **61.3 ms** / 10.0 ms | uiTask 循环周期最坏 / 标称——地板是 LVGL 帧周期(§9.3.2) | +| `uiw` | **11.4 ms** | UI 单轮工作,**只含 LVGL**(改前 ≈50 ms 且 ≈`uis`) | +| `uis` / `uisn` | **1 µs** / 0 | 推流往返(A 门控后;改前 ≈50 ms)/ 从未卡在段内 | +| `adcm` / `adcp` | 2.12~2.25 ms / **122 µs**(超时拍 2.2 ms) | adcTask 单拍 / `adc_input_poll()` 单次(P1-5 后跑在 `AboveNormal`)。⚠ `adcm` 的 2 ms 峰值来自每 10 s 一次的心跳日志,不是采集(§4.4) | +| ADC 消费计数 | `frames` 1000.0/s、**`idle = 0`**、`ring == frames` | P1-5 后唤醒与生产同源 → 相位抖动归零(改前 `idle` 1~649 / 20000 拍) | +| `defd` / `defs` | 46 ms / 7 µs | 控制台排空 / syslog 落盘——**当前最大的单一非让出占用** | +| `netp` / `netn` | 0 µs / 0 | `net_poll()` 空闲即一次 GPIO 读(§7.7) | +| `gl` | 0 | DWT 无丢样,以上数据可信 | --- @@ -326,10 +395,10 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ | poll 内调用 | 触发频率 | 实际代价 | 上界 | |---|---|---|---| -| `osMessageQueuePut(..., osWaitForever)` | 每次 RPC | 队列深 **8**,与 FTP / timeSync 共享(登记簿 **N-10**) | **无**(满则永久阻塞) | +| `osMessageQueuePut(..., osWaitForever)` | 每次 RPC | 队列深 **8**,与 FTP / timeSync 共享(原 N-10,已销项) | **无**(满则永久阻塞) | | `net_wait_result()` → `xTaskNotifyWait(..., portMAX_DELAY)` | 每次 RPC | 等 netTask 排到本条(它还要跑 `net_poll` + 3 个自愈 tick + FTP 大文件) | **无** | -| `net_accept()` | **无客户端时每 10 ms 一次**(台架常态)= **100 msg/s** | 一次往返 + `net_accept_locked()` 内再跑一遍 `net_poll()`(整轮 SPI 中断读) | 实测最坏 **≈50 ms**(§8 `uis≈uiw≈50ms`) | -| `net_recv(NET_MSG_DONTWAIT)` | 有客户端时每轮 | 同上(`net_recv_locked()` 亦先 `net_poll()`) | 同上 | +| `net_accept()` | **改前**:无客户端时每 10 ms 一次(台架常态)= **100 msg/s**;**现状:0 次**(A 门控) | 一次往返 + `net_accept_locked()` 内再跑一遍 `net_poll()`(整轮 SPI 中断读) | 改前实测最坏 **≈50 ms** → 现状 **1 µs**(§9.3.2) | +| `net_recv(NET_MSG_DONTWAIT)` | 有客户端时每轮;**现状**:`readable` 门控 + 每 32 轮兜底(I-2) | 同上(`net_recv_locked()` 亦先 `net_poll()`) | 同上 | | `net_send()` LCDMEM 应答 **3204 B** | 客户端每请求一帧(peer ≈10 次/s) | `net_send_locked` 按 `NET_SEND_CHUNK_MAX = min(CH395F_SEND_BUF_SIZE_BYTES=2×512, NET_DMA_MAX_PAYLOAD=2KB) = 1024 B` 拆 **4 块**,每块写完等 `SENBUF_FREE`,`NET_SEND_TIMEOUT_MS=1000` | 最坏 **≈4 s**,调用方全程等 | **结论**:poll 的耗时 = **"netTask 什么时候有空"**,不是"UI 算得慢"。netTask 是全部 socket @@ -341,9 +410,9 @@ next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ | # | 判据 | 现状 | |---|---|---| -| **J1** | uiTask 主循环里 **0 次** net 往返 → 刷新周期只由 LVGL + 调度决定 | ❌ 每轮 1~2 次 | +| **J1** | uiTask 主循环里 **0 次** net 往返 → 刷新周期只由 LVGL + 调度决定 | 🟡 **无客户端时已 0 次**(A 门控,`uis` = 1 µs);有客户端时仍有——刻意保留(§9.4) | | **J2** | 心跳自产,不依赖任何其他任务 | ✅ P1-4a | -| **J3** | 推流的阻塞只影响推流自己(故障域隔离)+ 有观测 | ❌ 与刷新同域 | +| **J3** | 推流的阻塞只影响推流自己(故障域隔离)+ 有观测 | ⚠ **刻意接受**:仍与刷新同域(B 已否决);观测 = `[PROF]` 的 `uis`/`uisn`(§9.4) | ### 9.3 方案 A:门控——让"没事的时候"poll 真的不做事 @@ -374,9 +443,9 @@ int net_can_read(int sockfd); /* 1 = 有数据可读或已 EOF;0 = 无 */ ① **晚一轮发现**(≤1 ms:netTask 自己每 1 ms 就 `net_poll()` 一次,`readable`/`state` 早已刷新); ② **扑空**(状态刚变,RPC 返回 `WOULDBLOCK`,现有分支已处理)。 -**收益**:台架常态(无客户端)poll 从"一次往返(最坏 50 ms)"降到**两次 RAM 读(< 2 µs)**; -顺带销项 **N-10**(100 msg/s 队列压力,与 FTP 抢深度 8 的队列)。 -⚠ **A 不改变"有客户端时"的行为**——那正是 B 的靶子。 +**收益(实测见 §9.3.2)**:台架常态(无客户端)poll 从"一次往返(最坏 50 ms)"降到 +**两次 RAM 读(实测 1 µs)**;顺带销项 **N-10**(100 msg/s 队列压力,与 FTP 抢深度 8 的队列)。 +⚠ **A 不改变"有客户端时"的行为**——该暴露刻意保留,见 §9.4。 #### 9.3.1 落地补充(2026-10-10 实现时新增的两条,设计阶段未预见) @@ -391,7 +460,7 @@ int net_can_read(int sockfd); /* 1 = 有数据可读或已 EOF;0 = 无 */ #### 9.3.2 A 落地实测(2026-10-10,`@test` PASS,33 个 `[PROF]` 样本 / 开机 29 s / 台架无客户端) -| 字段 | 改前(§8 基线) | **改后实测** | 判读 | +| 字段 | 改前(§8「关键测量结论」) | **改后实测** | 判读 | |---|---|---|---| | `uis` 推流往返最坏 | **≈50 ms** | **1 µs**(33 样本 max,`uisn` 全程 0) | 门控生效,**降 5 个数量级**;§9.6 目标 ≤50 µs 达成 | | `uiw` UI 单轮工作最坏 | ≈50 ms(且 `uiw≈uis` → 单轮几乎全是推流) | **11.4 ms** | 剩下的**只含 LVGL**——网络已不在刷新路径上 | @@ -471,3 +540,7 @@ A(门控 + 兜底)→ @build + @test + e2e → 看 [PROF]:uis 掉到 µs | 2026-10-10 | 新增 **§9 彻底解耦方案(待落地)**:量化 `ui_net_sink_poll()` 的 3 个**无上界**等待点(队列 `osWaitForever` / `xTaskNotifyWait(portMAX_DELAY)` / LCDMEM 应答 3204 B 按 1024 B 拆 4 块等 `SENBUF_FREE`,最坏 ≈4 s),指出"poll 耗时 = netTask 什么时候有空";给出 **A 门控**(`net_can_accept()`/`net_can_read()` 纯 RAM 判据,落点必须在平台后端——模拟器监听块 accept 前不是 `ESTABLISHED`)+ 一套"把推流搬出 uiTask"的方案(**同日评估后否决并删除,见下面两轮**);记录四条**明确不做**的备选与理由、验收判据、风险与回退。**代码未改动**,登记 **N-19 / C7** | | 2026-10-10 | **§9.3 方案 A 已落地**:`net_socket.c/.h` 新增 `net_can_accept()` / `net_can_read()`(纯 RAM 读控制块、零 RPC),`net_socket_sim.c` 同名实现恒返 1(模拟器行为逐位不变),`ui_net_sink_poll()` 改"先门控再 RPC",并补 **I-2 兜底**(每 32 轮无条件 recv,防 RECV_OK 中断丢失把数据永久藏住)与 **I-3 监听失效兜底**(控制块消失 → `g_listen=-1` 重建);`ui_net_sink.h/.c` 注释订正(原"非阻塞轮询 / 不阻塞 LVGL 刷新"表述不准确 → 改为"门控只消除无谓往返,真有活时往返仍无上界")。验证:Keil **0 错 0 警**(Code 256276→256368,+92 B)、模拟器构建 OK、`check_menu_rows.py` PASS、`check_coding_standard.py` PASS、**`@test` PASS**。**实测(§9.3.2):`uis` 50 ms → 1 µs、`uiw` 50 ms → 11.4 ms(只剩 LVGL)、`gl=0`**;`uip` 仍 60.9 ms = LVGL 帧周期地板,§9.6 判据已据此修订 | | 2026-10-10 | **A 收口 + 方案 B 否决并删除**(用户定案"B 不做")。① **A 收口**:`@test` **10/10 PASS**(每轮 `uis` ≤1 µs、`uisn` ≡ 0、0 `task stall`、0 `[ERR]`)、e2e `check_remo_sink.py` **0/25600 位差异**、内容页文本断言 50/50。② **B 删除**:§9.4 由完整设计(新任务 256 words / BelowNormal / 不进 `required_mask`、帧 seqlock、栈与堆两条预算路线、观测面迁移、12 处改动清单)改写为**否决记录**——实测 `uis`=1 µs 而 `uip` 仍 60.9 ms(LVGL 帧周期地板)证明"搬出 uiTask"**不会让刷新变快**,收益只剩极端并发下的观感隔离,而成本含**必改 `.ioc`** 的 CubeMX 重生成;残留暴露与观测手段(`uis`/`uisn`)如实保留。③ **同步清理**:§9.5/§9.6/§9.7 去掉 B 条目;**N-19 / C7 不入登记簿**(当日结案,`docs/待处理内容.md` 保持无该编号);`子系统设计/看门狗设计说明.md` §9.2 的 N-19 行移除;`RemoDispBus协议与接收设计说明.md` 去掉"规划中改独立任务"段;`ui_net_sink.c/.h` 注释去掉"应搬出 uiTask"的指向 | +| 2026-10-10 | **按项目现状全文校订(无代码改动)**:① 已被处置改掉的代码片段就地标注 **改前 / 现状**——§1 第 2 条、§4.2(`ui_task.c` 心跳顺序,P1-4a 已改)、§4.3(`osDelayUntil` 丢弃返回值,P0-2 已改)、§5(无新帧压环,P0-1 已改)、§6;§4.4 明确"忙等原样保留、P1-5 暂缓"。② §8 由"待确认后执行的 P0/P1/P2 分层建议"改写为**决策 + 结果表**(P1-3 刻意不做 / P1-4b 以 A 门控形式落地 / P1-5 暂缓 / P2-6 被 `task_prof` 取代 / P2-8 以边沿快照形式落地),并新增**当前健康剖面**表。③ §7 更新:0 复现累计 22 → **32 次**、`adc1_err` 288 → 1、`idle` 649/20001 → **1/20000**;新增 §7.7 说明 `netp=0` 是真实值(`net_poll()` 空闲只读一次 INT# GPIO)。④ §9.1/§9.2 的"改前"频率与 J1/J3 状态同步为 A 落地后的现状;删掉失效的行号引用与旧体积数字(256276 → **256368**) | +| 2026-10-10 | **P1-5 已实施(方案 a:事件驱动消费)**:`Src/main.c` TIM6 分支在 `tpafe5160_start_conv_irq()` 后调 `adc_tick_irq_notify()`;`App/task/adc_task.c` 循环尾 `osDelayUntil` → `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS`/`next`;`Src/freertos.c` 的 `adcTask` 优先级 `Normal` → **`AboveNormal`**;`App/task/adc_task.h` 新增 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补一条规划未预见的**就绪门** `s_consumer_ready`——TIM6 在 `app_main_init()` 就启动,比建任务与 `osKernelStart()` 早 ≈100 ms,ISR 不能对尚不存在的消费者动手(同本仓 TIM7 分支 2026-10-02 的实测坑)。验证:`@build` 0 错 0 警(Code 256368→**256728**)、**`@test` 6/6 PASS**、**`idle` 由 1~649 降到 0**、`frames` 严格 1000.0 Hz、`seq−frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 一字不差、0 `task stall`。本文同步:§4.1 补优先级变化与可接受性论证、§4.3 补"再现状"(P0-2 那段已删)、§4.4 改为"忙等未改但敏感度上升"、§8 的 P1-5 行转 ✅ 并拆出仍未办的"单拍硬上界"、健康剖面按 6 连跑复测值更新 | +| 2026-10-10 | **§4.4 复核修正(无代码改动)**:① 原文"9 通道全超时最坏 = **18 ms** 连占"**有误**——`stm32_scan()` 在首个超时处即 `HAL_ADC_Stop()`+`return`(`adc_input.c:79-84`),单拍上界 = **一次**超时 ≈2~3 ms;② 6 次上板对照证明 **2 ms 只在超时拍出现**:超时 1 次那轮 `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`;③ `adcm ≈ 2.1 ms` 的归因由"ADC 采集"**改判**为每 10 s 一次的心跳日志(`idle = 0`/20000 反证不存在"每拍 2 ms");④ 新增超时的**正确性**代价——超时拍 `s_stm32_valid` 仍为 1 → STM32 侧静默沿用上一拍值、`skip` 抓不到 → 硬化第一步应是"超时即置 `s_stm32_valid = 0`"。同步修正 `App/task/adc_task.c` 两处过期注释与 `ADC采样节拍设计说明.md` §10.1/§10.3/§10.4 | +| 2026-10-10 | **补 F4 ADC 上电自校准(§7.2 候选根因之一)**:核查发现本项目**从未**做过 ADC 自校准——CubeMX 不生成该步骤,且仓库裁剪版 HAL 无 `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头无 `ADC_CR2_CAL`。已在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)并在 `MX_ADC1_Init()` 末尾调用;新增 `cubemx-survival` 审计项 `adc1-calibration-call` / `adc1-calibration-impl`。验证:`@build` 0 错 0 警(Code 256728→**256868**)、`@test` **PASS**(`adcp` 仍 122 µs、`adc1_err=0`、`idle=0`);**校准前后同一拍 STM32 侧 6 路同向偏移** `UB 898→961`/`UC 978→1038`/`IB 906→922`/`IC 1061→1071`/`IN 1017→1055`/`T 790→846`(+10~+63 LSB),TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。§4.4 的 2 ms 忙等与"超时沿用旧值"**仍未处理** | diff --git a/scripts/check_cubemx_survival.py b/scripts/check_cubemx_survival.py index e9a8dd7b..3db178a2 100644 --- a/scripts/check_cubemx_survival.py +++ b/scripts/check_cubemx_survival.py @@ -285,6 +285,28 @@ CHECKS = [ # 注:ADC 时钟分频**不设防护**(2026-10-09 决定)。CubeMX 面板里 DIV2 已被约束禁用 # (FadcConstraint Max=36000000;APB2=84 MHz → DIV2=42 MHz 超限,选不到), # .ioc 虽不保存 ADC1.ClockPrescaler 键,重生成也只会回落到约束允许的值。 + # ADC1 上电自校准(2026-10-10 补):RM0090 §13.3.6 的**必需**步骤,CubeMX **不生成**, + # 且本仓库裁剪版 HAL **没有** HAL_ADCEx_Calibration_Start → 只能手写(L2), + # 故必须在此兜底。丢了它不报编译错,只让 ADC 带失调误差(难从日志看出)。 + dict( + id="adc1-calibration-call", + kind="protected", + path="Src/adc.c", + region="ADC1_Init 2", + must_contain=["adc1_calibrate("], + hint="MX_ADC1_Init() 末尾必须调用 adc1_calibrate()(F4 ADC 上电自校准,CubeMX 不生成)。" + "丢了它 = ADC 未校准 → 存在失调误差,且不会报任何编译/运行错误。", + ), + dict( + id="adc1-calibration-impl", + kind="protected", + path="Src/adc.c", + region="1", + must_contain=["int adc1_calibrate(void)", "ADC1_CAL_BIT", "ADC_CR2_ADON"], + hint="自校准实现(清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)须在 " + "`USER CODE BEGIN 1` 区。本仓库 HAL 无 HAL_ADCEx_Calibration_Start、" + "CMSIS 头无 ADC_CR2_CAL,两者都是裁剪造成的,勿以为『标准 HAL 里有』。", + ), ] diff --git a/scripts/gen_task_table.py b/scripts/gen_task_table.py index 280de591..1221a8a8 100644 --- a/scripts/gen_task_table.py +++ b/scripts/gen_task_table.py @@ -75,7 +75,7 @@ ROLE = { "testTask": "硬件测试入口", "uiTask": "LVGL UI 界面", "timeSyncTask": "墙钟时间同步", - "adcTask": "TPAFE5160 采样(1ms 节拍 + 原子快照)", + "adcTask": "TPAFE5160 采样(TIM6 1kHz 事件驱动 + 原子快照)", } From dd8210867aefe6f3bc7d8063b994f2be7a76e825 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sat, 10 Oct 2026 23:08:01 +0800 Subject: [PATCH 14/47] =?UTF-8?q?adc=5Finput:=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=8B=8D=E6=B8=85=20s=5Fstm32=5Fvalid=EF=BC=8C=E6=9D=9C?= =?UTF-8?q?=E7=BB=9D=E8=B6=85=E6=97=B6=E6=B2=BF=E7=94=A8=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E6=8B=8D=E6=97=A7=E5=80=BC=EF=BC=88=E5=8D=95=E6=B5=8B=2070->79?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缺陷(真机实测发现):本层只有一份 s_stm32_raw[],有效标志此前只在成功时 置 1、失败时从不清零 -> 超时拍消费侧照样取数、照样压入采样环(且超时前 几个 Rank 已写本拍新值、后面几个是上一拍旧值 = 半新半旧混合帧),直接污 染 20 点 DFT 相量窗;adc_input_poll() 返回值只由 TPAFE 决定,s_frames_skip 也抓不到它。 修复:stm32_scan() 两条失败支路(HAL_ADC_Start 失败 / 轮询超时)都清 s_stm32_valid -> 本拍 adc_input_raw() 对 STM32 语义通道返 -2 -> adc_task 走既有 skip 整帧跳过路径;下一拍扫描成功自动置回 1(自愈)。 测试:新增 TC-AI-070..078(70 -> 79 用例)。旧用例 TC-AI-034/039b 都是 init 后立刻失败(valid 本就是 0),照不出成功过之后再失败这条路径,故新 用例先成功一拍进入有效态再注入失败。**反向验证已做**:把支路改回旧行为 -> TC-AI-039b/073/074 立即 FAIL(76/79),证明用例有效。 验证:主机单测 79/79 PASS;@build 0 错 0 警(Code 256868 -> 256872); @test PASS(本轮无超时:adc1_err=0、skip=0、idle=0、frames=20001); 18 钩子全绿。文档同步 4 处(停摆分析 4.4 / ADC采样节拍 10.4 / ADC输入层 单测说明 / test-host README)。 遗留:2 ms 忙等本身未消(硬化 2 = DMA,方案 B2 待 CubeMX 配置)。 --- App/algo/adc_input.c | 14 +++++++++ docs/内存管理/RAM使用分析与优化方案.md | 4 +-- docs/子系统设计/ADC采样节拍设计说明.md | 11 +++++-- docs/测试说明/ADC输入层单元测试说明.md | 13 +++++++- docs/问题记录/UI任务看门狗停摆分析.md | 15 ++++++--- test/host/README.md | 2 +- test/host/adc_input/adc_input_test.c | 43 +++++++++++++++++++++++++- 7 files changed, 89 insertions(+), 13 deletions(-) diff --git a/App/algo/adc_input.c b/App/algo/adc_input.c index 6496c6b6..e6499ebe 100644 --- a/App/algo/adc_input.c +++ b/App/algo/adc_input.c @@ -65,11 +65,17 @@ static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */ * DISABLE),按 Rank 顺序逐通道轮询 EOC 并读 DR,结果按 ADC1 通道号 * 存入 s_stm32_raw[]。EOCSelection=SINGLE_CONV 且未用 DMA → 每个通道 * 产生一次 EOC,可逐通道轮询(见 HAL_ADC_PollForConversion 说明)。 + * 两条失败支路(启动失败 / 轮询超时)**都会清 s_stm32_valid**:本拍 + * STM32 侧判为无有效数据,绝不沿用上一拍旧值(详见支路内注释)。 + * 超时在**首个**失败通道处即 Stop+return,不轮询剩余通道 → 单拍耗时 + * 上界 = 一次超时(≈2~3 ms,HAL_GetTick 1 ms 粒度),不是 9 次。 */ static int stm32_scan(void) { uint8_t i; if (HAL_ADC_Start(&hadc1) != HAL_OK) { + /* 失败拍必须清有效标志,见下方超时分支说明(同一语义) */ + s_stm32_valid = 0u; s_stm32_rc = ADC_INPUT_RC_START; s_stm32_errs++; return ADC_INPUT_RC_START; @@ -78,6 +84,14 @@ static int stm32_scan(void) { for (i = 0u; i < ADC_INPUT_STM32_CH_NUM; i++) { if (HAL_ADC_PollForConversion(&hadc1, ADC_INPUT_POLL_TIMEOUT_MS) != HAL_OK) { (void)HAL_ADC_Stop(&hadc1); + /* ⚠ 失败拍**不得**沿用上一拍的旧值:本层只有一份 s_stm32_raw[], + * 超时前几个 Rank 已写入**本拍新值**、后面几个还是**上一拍旧值** → + * 若保持 s_stm32_valid=1,消费侧会拿到"半新半旧"的混合帧并照样压入 + * 采样环(adc_input_poll() 的返回值只由 TPAFE 决定,s_frames_skip + * 也抓不到它),直接污染 20 点 DFT 相量窗。清标志后 adc_input_raw() + * 对 STM32 语义通道返 -2 → adc_task 走既有的"整帧跳过"路径。 + * 下一拍扫描成功会重新置 1(自愈),无需额外恢复动作。 */ + s_stm32_valid = 0u; s_stm32_rc = ADC_INPUT_RC_TIMEOUT; s_stm32_errs++; return ADC_INPUT_RC_TIMEOUT; diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 3ef59067..cd6557d8 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 273,028 B (266.63 KB) | 1,048,576 B (1024 KB) | 26.0% | +| ER_IROM1 | 273,032 B (266.63 KB) | 1,048,576 B (1024 KB) | 26.0% | | RW_IRAM1 | 91,336 B (89.20 KB) | 131,072 B (128 KB) | 69.7% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **273,836 B (267.4 KB)** | 1,048,576 B (1024 KB) | **26.1%** | +| **Flash (ROM 合计)** | **273,840 B (267.4 KB)** | 1,048,576 B (1024 KB) | **26.1%** | 各区的容量与常量来源: diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md index d34ffeb8..f74d7839 100644 --- a/docs/子系统设计/ADC采样节拍设计说明.md +++ b/docs/子系统设计/ADC采样节拍设计说明.md @@ -239,6 +239,7 @@ t=1000 µs 下一个 TIM6 更新事件 | 2026-10-10 | **事件驱动消费方案 a 已实施(Task #32 P1-5)** | 四处改动落地:`Src/main.c` TIM6 分支调 `adc_tick_irq_notify()`;`adc_task.c` 循环尾改 `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS`/`next`;`Src/freertos.c` 优先级 `Normal`→**`AboveNormal`**;`App/task/adc_task.h` 加 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补**就绪门**(§10.6,规划未预见:TIM6 比调度器早起 ≈100 ms)。`@build` 0 错 0 警(Code 256368→256728);`@test` PASS:**`idle = 0`/19999 拍**(改前 1~649)、`frames` 1000.0 Hz、`seq−frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 无退化 | | 2026-10-10 | **§10.1/§10.3/§10.4 复核修正(无功能改动)** | ① 2 ms 忙等的单拍上界由"9 通道 ×2 = 18 ms"更正为**一次超时 ≈2~3 ms**(`stm32_scan()` 在首个超时处即 `Stop`+`return`);② 6 次上板对照 `adcp` = **122 µs**(超时 0 次的 5 轮)/ **2209 µs**(超时 1 次的那轮)→ 2 ms 不是常态开销;③ `adcm` 2.1 ms 峰值**归因改判**为每 10 s 一次的心跳日志(`idle=0`/20000 反证无每拍 2 ms);④ 新增超时的**正确性**代价(超时拍 STM32 侧静默沿用上一拍值,`skip` 抓不到)与硬化排序 | | 2026-10-10 | **补 ADC1 上电自校准(§10.4)** | 发现 F4 必需的自校准在本项目**完全缺失**(CubeMX 不生成 + 裁剪 HAL 无 `HAL_ADCEx_Calibration_Start` + 裁剪 CMSIS 头无 `ADC_CR2_CAL`)→ 在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(RM0090 §13.3.6 时序),`MX_ADC1_Init()` 末尾调用。新增审计项 `adc1-calibration-call`/`-impl`。`@build` 0 错 0 警(Code 256728→256868,+140 B);`@test` PASS,`adcp` 仍 122 µs、`adc1_err=0`;实测 STM32 侧 6 路同向偏移 +10~+63 LSB、TPAFE 侧不变 | +| 2026-10-10 | **修"失败拍沿用旧值"(§10.4 硬化 ①)** | `stm32_scan()` 两条失败支路都清 `s_stm32_valid` → 失败拍走既有 `skip` 整帧跳过,不再产出"半新半旧"混合帧污染相量窗;下一拍成功自愈。主机单测 70 → **79** 用例(新增 TC-AI-070..078),并做反向验证(改回旧行为 3 条立即 FAIL)。`@build` 0 错 0 警(Code 256868→256872)、`@test` PASS。硬化 ②(DMA 消忙等)仍未做,并更正原表述:**F4 的 ADC EOC 没有 EXTI 通道**,只有 `ADC1_2_IRQn` | --- @@ -314,9 +315,13 @@ t=1000 µs 下一个 TIM6 更新事件 而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、照样压环,STM32 侧几路 (UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(`adc_input.c:209-212`),`s_frames_skip` 只统计"从未成功过"、抓不到它。 - **硬化方向(待做,按性价比排序)**:① 超时即置 `s_stm32_valid = 0` → 本拍 STM32 侧判为无有效 - 数据,走既有 `skip` 整帧跳过路径(一行改动、无时序风险,先把"静默陈旧帧"这条正确性问题关掉); - ② EOC 改 EXTI 或 DMA + 非阻塞查询,彻底消除忙等;③ DMA 双缓冲属 V0.2 硬件项(§10.5)。 + **硬化排序**:① ✅ **已实施(2026-10-10)**——`stm32_scan()` 两条失败支路(启动失败 / 轮询 + 超时)都清 `s_stm32_valid` → 本拍 STM32 侧判为无有效数据,走既有 `skip` 整帧跳过路径, + 下一拍成功自动恢复;主机侧新增 **TC-AI-070..078** 钉死并做反向验证(改回旧行为 3 条立即 + FAIL)。② **消忙等 = DMA**:`HAL_ADC_Start_DMA(&hadc1, buf, 9)`,scan 模式下每个通道 EOC + 由 DMA 自动搬进缓冲,任务侧不再逐通道 `PollForConversion`(⚠ **F4 的 ADC EOC 没有 EXTI + 通道**,只有 `ADC1_2_IRQn`——"EOC 改 EXTI"在 F4 上不成立,别照 F3/G4 的做法抄)。 + ③ 双缓冲 / 等周波触发属 V0.2 硬件项(§10.5)。 - **已做的一项(2026-10-10):补 F4 ADC 上电自校准**。CubeMX **不生成**该步骤,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start` → 由 `Src/adc.c` 的 `adc1_calibrate()` 实现 (`USER CODE` 区 = L2,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底)。 diff --git a/docs/测试说明/ADC输入层单元测试说明.md b/docs/测试说明/ADC输入层单元测试说明.md index 39b60909..76e44443 100644 --- a/docs/测试说明/ADC输入层单元测试说明.md +++ b/docs/测试说明/ADC输入层单元测试说明.md @@ -3,7 +3,7 @@ > **模块**:ADC Input Layer(L1 采集与预处理,`App/algo/adc_input.c`) > **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证语义通道映射、 > STM32 ADC1 顺序扫描、TPAFE 快照消费与取数守卫,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 -> **状态**:✅ 已落地(70 用例,`/W4` 0 告警,已挂 pre-commit 钩子) +> **状态**:✅ 已落地(79 用例,`/W4` 0 告警,已挂 pre-commit 钩子) > **日期**:2026-10-10 > **关联文档**: > [`../../AGENTS.md`](../../AGENTS.md)(平台约束 · CubeMX 生成区铁律)、 @@ -171,6 +171,16 @@ test/host/adc_input/adc_input_test.c | **TC-AI-039** | 超时后**立即退出**(只轮询 5 次,不继续扫完) | 不长时间阻塞 1 ms 保护节拍 | | **TC-AI-039b** | 超时后 STM32 侧无有效数据 | 不产出半帧 | | **TC-AI-03A** | 再失败一次 → 计数 = 2 | 跨次单调累计 | +| **TC-AI-070..078** | ⭐ **失败拍不得沿用上一拍旧值**:先成功一拍让 STM32 侧进入有效态(`UB`=108),再注入超时拍 / `Start` 失败拍 → `adc_input_raw()` 必须返 **-2**;恢复拍重新可取数 | **陈旧帧**(2026-10-10 真机实测发现的缺陷,见下注) | + +> ⚠ **为什么 TC-AI-034 / 039b 照不出这条缺陷**:两者都是"`adc_input_init()` 后**立刻**失败", +> 此时 `s_stm32_valid` 本来就是 0,断言 -2 恒成立。真正的缺陷路径是**成功过之后再失败**: +> 本层只有一份 `s_stm32_raw[]`,超时拍里"超时前几个 Rank 已写本拍新值、后面几个还是上一拍 +> 旧值",而有效标志此前**只在成功时置 1、失败时从不清零** → 消费侧照样取数、照样压入采样环 +> (`adc_input_poll()` 返回值只由 TPAFE 决定,`s_frames_skip` 抓不到),直接污染 20 点 DFT 窗。 +> 修复 = 两条失败支路都清 `s_stm32_valid`(`App/algo/adc_input.c`)。 +> **反向验证已做**:把超时支路改回旧行为,TC-AI-039b / 073 / 074 三条立即 FAIL(76/79)→ +> 证明这组用例真的咬得住。 ### 2.5 取数与参数守卫(`adc_input_raw`) @@ -263,3 +273,4 @@ V0.1 上 8 路电气量分属两片转换器且**无共同采样触发**(N-18 | 日期 | 变更 | |---|---| | 2026-10-10 | 创建;70 用例全绿(`/W4` 0 告警);覆盖 `adc_input.c` 全部 8 个函数(6 公开 + 2 静态);挂 pre-commit 钩子 `adc-input-unit-test` | +| 2026-10-10 | **新增 TC-AI-070..078(70 → 79 用例)**:钉死"失败拍不得沿用上一拍旧值"。配套修复 `App/algo/adc_input.c` 两条失败支路(清 `s_stm32_valid`)。已做**反向验证**:改回旧行为 → TC-AI-039b/073/074 立即 FAIL(76/79),证明用例有效 | diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md index fd46cd97..82803d8b 100644 --- a/docs/问题记录/UI任务看门狗停摆分析.md +++ b/docs/问题记录/UI任务看门狗停摆分析.md @@ -230,11 +230,15 @@ TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上 - 但 `adcTask` 现已在 **`AboveNormal`(32)**,一旦某拍真走到 2 ms 忙等,它挤的是 netTask / ftpTask / defaultTask / timeSyncTask(改前是同级竞争)→ **这是 P1-5 换来的新暴露, 记在账上**; -- ⚠ **超时的第二个代价是正确性,不只是耗时**:超时拍 `s_stm32_valid` 仍是上次成功留下的 1, - 而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、照样压环,STM32 侧几路 - (UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(`adc_input.c:209-212`);`s_frames_skip` - 只统计"从未成功过",抓不到它。→ 硬化第一步应是**超时即置 `s_stm32_valid = 0`**(走既有 - `skip` 整帧跳过路径,一行改动),而不是先去消忙等。 +- ✅ **超时的第二个代价是正确性,不只是耗时(2026-10-10 已修)**:超时拍 `s_stm32_valid` 此前 + 仍是上次成功留下的 1,而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、 + 照样压环,STM32 侧几路(UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(且超时前几个 Rank 已写 + 本拍新值 → 半新半旧的混合帧);`s_frames_skip` 只统计"从未成功过",抓不到它。 + **修复** = `stm32_scan()` 两条失败支路(`HAL_ADC_Start` 失败 / 轮询超时)**都清 + `s_stm32_valid`** → 本拍 `adc_input_raw()` 对 STM32 语义通道返 -2 → `adc_task` 走既有 + `skip` 整帧跳过路径,下一拍成功自动恢复。主机侧新增 **TC-AI-070..078** 钉死(旧用例 + TC-AI-034/039b 都是"init 后立刻失败",`valid` 本就是 0,照不出这条路径),并做**反向验证**: + 把支路改回旧行为 → 3 条立即 FAIL(76/79)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)**。 --- @@ -544,3 +548,4 @@ A(门控 + 兜底)→ @build + @test + e2e → 看 [PROF]:uis 掉到 µs | 2026-10-10 | **P1-5 已实施(方案 a:事件驱动消费)**:`Src/main.c` TIM6 分支在 `tpafe5160_start_conv_irq()` 后调 `adc_tick_irq_notify()`;`App/task/adc_task.c` 循环尾 `osDelayUntil` → `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS`/`next`;`Src/freertos.c` 的 `adcTask` 优先级 `Normal` → **`AboveNormal`**;`App/task/adc_task.h` 新增 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补一条规划未预见的**就绪门** `s_consumer_ready`——TIM6 在 `app_main_init()` 就启动,比建任务与 `osKernelStart()` 早 ≈100 ms,ISR 不能对尚不存在的消费者动手(同本仓 TIM7 分支 2026-10-02 的实测坑)。验证:`@build` 0 错 0 警(Code 256368→**256728**)、**`@test` 6/6 PASS**、**`idle` 由 1~649 降到 0**、`frames` 严格 1000.0 Hz、`seq−frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 一字不差、0 `task stall`。本文同步:§4.1 补优先级变化与可接受性论证、§4.3 补"再现状"(P0-2 那段已删)、§4.4 改为"忙等未改但敏感度上升"、§8 的 P1-5 行转 ✅ 并拆出仍未办的"单拍硬上界"、健康剖面按 6 连跑复测值更新 | | 2026-10-10 | **§4.4 复核修正(无代码改动)**:① 原文"9 通道全超时最坏 = **18 ms** 连占"**有误**——`stm32_scan()` 在首个超时处即 `HAL_ADC_Stop()`+`return`(`adc_input.c:79-84`),单拍上界 = **一次**超时 ≈2~3 ms;② 6 次上板对照证明 **2 ms 只在超时拍出现**:超时 1 次那轮 `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`;③ `adcm ≈ 2.1 ms` 的归因由"ADC 采集"**改判**为每 10 s 一次的心跳日志(`idle = 0`/20000 反证不存在"每拍 2 ms");④ 新增超时的**正确性**代价——超时拍 `s_stm32_valid` 仍为 1 → STM32 侧静默沿用上一拍值、`skip` 抓不到 → 硬化第一步应是"超时即置 `s_stm32_valid = 0`"。同步修正 `App/task/adc_task.c` 两处过期注释与 `ADC采样节拍设计说明.md` §10.1/§10.3/§10.4 | | 2026-10-10 | **补 F4 ADC 上电自校准(§7.2 候选根因之一)**:核查发现本项目**从未**做过 ADC 自校准——CubeMX 不生成该步骤,且仓库裁剪版 HAL 无 `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头无 `ADC_CR2_CAL`。已在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)并在 `MX_ADC1_Init()` 末尾调用;新增 `cubemx-survival` 审计项 `adc1-calibration-call` / `adc1-calibration-impl`。验证:`@build` 0 错 0 警(Code 256728→**256868**)、`@test` **PASS**(`adcp` 仍 122 µs、`adc1_err=0`、`idle=0`);**校准前后同一拍 STM32 侧 6 路同向偏移** `UB 898→961`/`UC 978→1038`/`IB 906→922`/`IC 1061→1071`/`IN 1017→1055`/`T 790→846`(+10~+63 LSB),TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。§4.4 的 2 ms 忙等与"超时沿用旧值"**仍未处理** | +| 2026-10-10 | **修 §4.4 的"超时拍静默沿用旧值"(正确性缺陷)**:`stm32_scan()` 两条失败支路(`HAL_ADC_Start` 失败 / 轮询超时)现都清 `s_stm32_valid` → 失败拍 `adc_input_raw()` 返 -2 → `adc_task` 走既有 `skip` 整帧跳过,不再把上一拍的值当新数据压入 20 点 DFT 窗;下一拍成功自动恢复。主机侧新增 **TC-AI-070..078**(70 → 79 用例)钉死——旧用例 TC-AI-034/039b 都是"init 后立刻失败"(`valid` 本就是 0),照不出"成功过之后再失败"这条路径。**反向验证已做**:把支路改回旧行为 → TC-AI-039b/073/074 立即 FAIL(76/79)。验证:`@build` 0 错 0 警(Code 256868→**256872**)、主机单测 **79/79**、`@test` **PASS**(本轮无超时:`adc1_err=0`、`skip=0`、`idle=0`)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)** | diff --git a/test/host/README.md b/test/host/README.md index a93b2ccd..ed1fef8a 100644 --- a/test/host/README.md +++ b/test/host/README.md @@ -46,7 +46,7 @@ | `cfg_file/stub/` | 桩头文件(`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 | | `phasor/run_unit_test.py` | **相量层(L2)单测**:离线编译真实 `App/algo/phasor.c` + `sample_ring.c`(**无需任何桩**,纯算法),57 用例覆盖傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区原子交换 | | `phasor/phasor_test.c` | 测试单元本体(`#include` 两个生产源码 + 合成任意波形) | -| `adc_input/run_unit_test.py` | **ADC 输入层(L1)单测**:离线编译真实 `App/algo/adc_input.c`,70 用例给每个 Rank 注入互异码值**逐通道钉死映射**,并覆盖 HAL 启动失败 / 转换超时 / 参数错等真机难复现分支 | +| `adc_input/run_unit_test.py` | **ADC 输入层(L1)单测**:离线编译真实 `App/algo/adc_input.c`,79 用例给每个 Rank 注入互异码值**逐通道钉死映射**,并覆盖 HAL 启动失败 / 转换超时 / **失败拍陈旧帧** / 参数错等真机难复现分支 | | `adc_input/adc_input_test.c` | 测试单元本体(`#include` 生产源码 + HAL / TPAFE 可注入桩) | | `adc_input/stub/` | 桩头文件(`adc.h` / `tpafe5160.h`) | | `vol_lock/run_unit_test.py` | **卷锁单测**:离线编译真实 `App/fs/vol_lock.c`,33 断言点覆盖创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传 | diff --git a/test/host/adc_input/adc_input_test.c b/test/host/adc_input/adc_input_test.c index 7a431b8b..0a8cff39 100644 --- a/test/host/adc_input/adc_input_test.c +++ b/test/host/adc_input/adc_input_test.c @@ -29,7 +29,7 @@ * TC-AI-019/01A 越界(ADC_SEM_COUNT)与负数语义 -> NULL * —— map_check(TC-AI-020)—— * TC-AI-020 真实映射表自洽(反向路径结构上不可达,见文件末说明) - * —— stm32_scan(TC-AI-021..03A)—— + * —— stm32_scan(TC-AI-021..078)—— * TC-AI-021..025 全通道成功:返回 0 / 状态码 / 不计失败 / Start-Stop 各一次 / 轮询 9 次 * TC-AI-026..02B **Rank -> 通道号映射**:UB->CH14(rank9) / UC->CH3(rank4) / * IB->CH6(rank7) / IC->CH1(rank2) / IN->CH2(rank3) / TEMP->CH5(rank6) @@ -37,6 +37,11 @@ * TC-AI-030..034 HAL_ADC_Start 失败:返回 -1 / 状态码 RC_START / 计数 +1 / 不调 Stop * TC-AI-035..039 轮询超时:返回 -2 / 状态码 RC_TIMEOUT / 计数 +1 / 调 Stop 收尾 / 立即退出 * TC-AI-03A 失败计数跨次单调累计 + * TC-AI-070..078 **失败拍不得沿用上一拍旧值**(2026-10-10 补,真机实测发现的 + * 正确性缺陷):先成功一拍让 STM32 侧进入有效态,再注入超时拍 / + * Start 失败拍 -> adc_input_raw() 必须返 -2(而非上一拍的值); + * 恢复拍重新置有效。TC-AI-034/039b 只覆盖"冷启动即失败", + * 照不出"成功过之后失败 → 静默沿用旧值",故单独立一组。 * —— adc_input_raw(TC-AI-040..04B)—— * TC-AI-040..043 参数守卫:out=NULL / 越界 / 负数 -> -1,且不写 out * TC-AI-044/045 无有效数据 -> -2(两来源分别) @@ -318,6 +323,40 @@ static void tc_scan_timeout(void) { CHECK(adc_input_stm32_errs() == 2u, "TC-AI-03A 失败计数跨次单调累计 = 2"); } +/* ============ TC-AI-070..077:失败拍不得沿用上一拍旧值(陈旧帧) ============ + * 背景(2026-10-10 真机实测发现):本层只有一份 s_stm32_raw[],超时拍里"超时前 + * 几个 Rank 已写本拍新值、后面几个还是上一拍旧值",而 s_stm32_valid 此前只在 + * 成功时置 1、失败时**从不清零** → 消费侧照样取数、照样压入采样环, + * s_frames_skip 抓不到。TC-AI-034/039b 都是"init 后立刻失败"(valid 本就是 0), + * 因此照不出这条路径——必须先成功一拍。 */ + +static void tc_scan_stale(void) { + int32_t v = 0; + + /* --- 超时支路:成功拍 -> 超时拍 -> 恢复拍 --- */ + fakes_reset(); + (void)adc_input_init(); + CHECK(stm32_scan() == ADC_INPUT_RC_OK, "TC-AI-070 前置:首拍成功"); + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), "TC-AI-071 前置:UB 取到 108(有效态)"); + + s_poll_fail_at = 4; /* 第 5 个 Rank 超时(前 4 个已写新值) */ + CHECK(stm32_scan() == ADC_INPUT_RC_TIMEOUT, "TC-AI-072 第二拍轮询超时"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-073 超时拍不得沿用上一拍值 -> -2"); + CHECK(adc_input_raw(ADC_SEM_UC, &v) == -2, "TC-AI-074 超时拍连本拍已写入的 Rank 也不得出数"); + + s_poll_fail_at = -1; + CHECK(stm32_scan() == ADC_INPUT_RC_OK, "TC-AI-075 第三拍恢复成功"); + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), "TC-AI-076 恢复拍重新可取数(自愈)"); + + /* --- 启动失败支路:同样不得沿用 --- */ + fakes_reset(); + (void)adc_input_init(); + (void)stm32_scan(); /* 先成功一拍 */ + s_start_rc = HAL_ERROR; + CHECK(stm32_scan() == ADC_INPUT_RC_START, "TC-AI-077 Start 失败拍"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-078 Start 失败拍不得沿用上一拍值 -> -2"); +} + /* ===================== TC-AI-040..045:raw 参数守卫与无数据 ===================== */ static void tc_raw_guards(void) { @@ -448,6 +487,8 @@ int main(void) { tc_scan_start_fail(); printf("-- stm32 scan: poll timeout --\n"); tc_scan_timeout(); + printf("-- stm32 scan: stale frame on failure beat --\n"); + tc_scan_stale(); printf("-- raw: guards / no-data --\n"); tc_raw_guards(); printf("-- raw: tpafe index + two's complement --\n"); From 9bbe117f974fc643e6c3ecfd23ad9ac5aad11b66 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 00:31:33 +0800 Subject: [PATCH 15/47] =?UTF-8?q?adc:=20=E8=8A=82=E6=8B=8D=E6=BA=90=20TIM6?= =?UTF-8?q?->TIM3=20+=20ADC1=20=E8=B5=B0=20DMA=20=E4=B9=92=E4=B9=93?= =?UTF-8?q?=E5=8F=8C=E7=BC=93=E5=86=B2=EF=BC=8C=E6=B6=88=E9=99=A4=202=20ms?= =?UTF-8?q?=20=E5=BF=99=E7=AD=89=EF=BC=88P1-5=20=E5=8D=95=E6=8B=8D?= =?UTF-8?q?=E7=A1=AC=E4=B8=8A=E7=95=8C=E5=8A=9E=E7=BB=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户选定最彻底方案(B2):DMA + 定时器 TRGO 硬件触发 + 消费上一拍 已完成的半区。对应 P1-5 拆出的"单拍硬上界",现已办结。 硬件事实更正(查 CubeMX 器件库 + 本仓 HAL 头证实):F4 上 ADC1 的 外部触发清单里没有 TIM6/TIM7 TRGO(只接 DAC)。故 1 kHz 节拍源由 TIM6 改为 TIM3(PSC 83 / ARR 999,TRGO=UPDATE),同一个更新事件 同时 ① ISR 脉冲 TPAFE CONVST ② 经 TRGO 触发 ADC1 一轮 9 通道 ③ 置位 ADC_WAKE_FLAG 唤醒 adcTask。TIM6 从 .ioc 整体移除。 代码: - Src/tim.c/h:新增 MX_TIM3_Init(含 MasterSlaveMode),删 htim6 - Src/adc.c:ExternalTrigConv=T3_TRGO + EDGE_RISING + DMAContinuousRequests=ENABLE;MspInit 挂 hdma_adc1(DMA2_Stream4 循环模式)。DDS 位关掉 = 一轮只发一次请求,静默丢 8 路 - Src/dma.c:DMA2_Stream4 NVIC 优先级 6(CubeMX 换流会把它重置为 0, 优先级 0 会抢占 FreeRTOS 临界区,违反"外设中断 ≥5") - Src/main.c:周期回调加 TIM3 分支;stm32f4xx_it.c 向量同步 - App/algo/adc_input.c:stm32_scan() → stm32_take(),逐通道 PollForConversion 全删;乒乓半区由完成计数奇偶推出(只读一个 32 位量 → 与 ISR 无竞态);STM32 侧数据固定滞后 1 拍 - App/task/adc_task.c:心跳日志拆出四类计数 观测性:adc1_seq(完成计数)/ adc1_gap(seq 前进 >1 = 丢帧)/ adc1_err(连续 ≥2 拍无新帧 = 停摆)/ adc1_hw(HAL 错误回调)。 单拍无新帧是消费者相位伪影(DMA 完成落在唤醒后 ≈67 µs),不计故障。 验证: - 主机单测 79 → 101 全绿;反向验证四连均咬(半区奇偶取反 11 条、 停摆拍置 valid 4 条、STALL_RUN_MAX 改 1 为 6 条、gap 阈值 1 条) - @build 0 错 0 警,Code 256872 → 257660 - @test PASS:adcp 122 µs → 8 µs、adc1_err=0、adc1_hw=0、 adc1_gap=27/20002 拍、adc1_seq 与 seq 恒定偏移 2 - stack_report 7/7 PASS,adcTask 832 B / 余 59%(不变) - cubemx-survival 新增 5 项审计(TIM3 分支 / T3_TRGO / DDS / DMA 挂载 / 流优先级),28 通过 0 失败 文档:ADC 采样节拍设计说明新增 §11;看门狗停摆分析 §4.4 与 §8 收口; 算法规划 L1-1/L1-3、通道映射、测试说明、各 README 同步。 --- AGENTS.md | 7 +- App/algo/README.md | 26 +- App/algo/adc_input.c | 242 ++++++++++++---- App/algo/adc_input.h | 68 +++-- App/app/app_main.c | 41 ++- App/task/README.md | 2 +- App/task/adc_task.c | 72 +++-- App/task/adc_task.h | 6 +- Drivers/BSP/TPAFE5160/README.md | 4 +- Inc/stm32f4xx_it.h | 3 +- Inc/tim.h | 4 +- README.md | 2 +- STM32F407-Demo.ioc | 44 ++- Src/adc.c | 28 +- Src/dma.c | 3 + Src/main.c | 14 +- Src/stm32f4xx_it.c | 45 ++- Src/tim.c | 79 +++--- docs/README.md | 2 +- docs/内存管理/RAM使用分析与优化方案.md | 6 +- docs/子系统设计/ADC采样节拍设计说明.md | 110 +++++++- docs/待处理内容.md | 2 +- docs/测试说明/ADC输入层单元测试说明.md | 89 +++--- docs/硬件平台/ADC通道映射说明.md | 6 +- docs/算法/继电保护功能复现规划.md | 9 +- docs/问题记录/UI任务看门狗停摆分析.md | 35 ++- scripts/check_cubemx_survival.py | 49 ++++ scripts/gen_task_table.py | 2 +- test/host/README.md | 2 +- test/host/adc_input/adc_input_test.c | 367 +++++++++++++++---------- test/host/adc_input/run_unit_test.py | 3 +- test/host/adc_input/stub/adc.h | 16 +- 32 files changed, 958 insertions(+), 430 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ec86753e..18be78ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,8 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - **自研代码禁用 C 库堆分配**(2026-09-18,P0-1):禁止 `malloc/calloc/realloc/strdup/free`——本机工具链 MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL 且**模拟器(宿主 CRT 堆)完全正常、照不出来**。需要动态内存只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆 `configTOTAL_HEAP_SIZE`),或像配置引擎那样把分配收进平台后端(`cfg_store_alloc/free/heap_free`:MCU=FreeRTOS 堆、模拟器=宿主堆,共享核心零分配符号);分配失败必须有 `FAIL` 日志 + 自检上报,暂态峰值要写编译期预算 - **FatFs 打开模式陷阱(2026-09-20 台架实测,`Lib/FatFs` 移植版)**:`FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(/log 写失败真根因);追加写必须用 `FA_WRITE|FA_OPEN_APPEND`。该移植的 `f_write` 簇分配失败时返回 `FR_OK`+短写(无错误码),判失败必须校验写回字节数。标志位为旧式编号(`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(ff.c 内部定义)),与上游 FatFs R0.14 不同,勿照抄上游代码示例 - **FatFs 文件名约束(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)**:本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**——主名≤8 字符、扩展≤3 字符、**不得以点开头**(`.fschk` 会被 `ff.c create_short` 把点后当扩展域解析超限 → `f_open` 恒返 `FR_INVALID_NAME`(6),纯 RAM 名字判定、不碰盘)。ff.c `create_short` 全部拒绝点已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`,非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入卷损坏触发集(整卷重格式化不可逆) -- **ADC 取数只用 `tpafe5160_snapshot()`(原子快照)**(2026-10-10 修 E-44/E-45/E-58):旧的 `get_buf()` / `data_ready()` / `clear_ready()` 已从驱动**删除**——`get_buf` 只给缓冲区指针、任务再逐通道拷,两步之间被 BUSY EXTI 抢占并跨过一帧周期就拷回**两帧混合**数据(8 通道失去同一时刻性,且无错误指示);`s_ready` 电平标志有三个写者,`start_conv_irq` 触发前清 0 = **重触发本身就会抹掉一帧未消费数据**。现口径:帧序号只增不清(`tpafe5160_frame_seq()`)、取数=临界区内拷贝(`tpafe5160_snapshot(dst, count)` 返回 1/0/-1,**仅任务上下文**)、FRSTDATA 全程无跳变(芯片不驱动并行总线)由 `tpafe5160_frstdata_err()` 计数且 `tpafe5160_init()` 首帧自检直接返回 `-3` + `[ERR]`。**消费者侧(L1 采集层)**:`App/algo/adc_input.c` 提供 `adc_input_poll()`(= TPAFE 快照 + STM32 ADC1 9 通道顺序扫描)与 `adc_input_raw()`(**按语义** `adc_semantic_t` 取数,不写死下标);映射表自检在 `adc_input_init()`(查每个 STM32 语义通道确实在扫描 rank 里,防"映射到从不扫描的通道 → 恒读陈旧值") +- **ADC 取数只用 `tpafe5160_snapshot()`(原子快照)**(2026-10-10 修 E-44/E-45/E-58):旧的 `get_buf()` / `data_ready()` / `clear_ready()` 已从驱动**删除**——`get_buf` 只给缓冲区指针、任务再逐通道拷,两步之间被 BUSY EXTI 抢占并跨过一帧周期就拷回**两帧混合**数据(8 通道失去同一时刻性,且无错误指示);`s_ready` 电平标志有三个写者,`start_conv_irq` 触发前清 0 = **重触发本身就会抹掉一帧未消费数据**。现口径:帧序号只增不清(`tpafe5160_frame_seq()`)、取数=临界区内拷贝(`tpafe5160_snapshot(dst, count)` 返回 1/0/-1,**仅任务上下文**)、FRSTDATA 全程无跳变(芯片不驱动并行总线)由 `tpafe5160_frstdata_err()` 计数且 `tpafe5160_init()` 首帧自检直接返回 `-3` + `[ERR]`。**消费者侧(L1 采集层)**:`App/algo/adc_input.c` 提供 `adc_input_poll()`(= TPAFE 快照 + **消费一帧 STM32 ADC1 DMA 乒乓缓冲**,v2.0 起零忙等)与 `adc_input_raw()`(**按语义** `adc_semantic_t` 取数,不写死下标);映射表自检在 `adc_input_init()`(查每个 STM32 语义通道确实在扫描 rank 里,防"映射到从不扫描的通道 → 恒读陈旧值");`adc_input_init()` 末尾 `HAL_ADC_Start_DMA()` **只挂一次**(此后每拍由硬件自走),失败返回 -2 并上报自检 +- **F4 上 ADC1 的硬件触发源与 DMA 流是硬约束(2026-10-10 查 CubeMX DB + 本仓 HAL 头证实,别再凭印象)**:① **ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO**——只有 T1_CC1/2/3、T2_CC2/3/4、T2_TRGO、T3_CC1、**T3_TRGO**、T4_CC4、T5_CC1/2/3、T8_CC1、**T8_TRGO**(+EXTI15);TIM6/TIM7 的 TRGO 在 F4 上**只接 DAC**(`T6_TRGO` 只存在于 CubeMX DB 的 `Expression="STM32F7"` 块里)。故"一个定时器同时驱动两片转换器"必须用 **TIM3**(PSC 83 / ARR 999 → 1 kHz,`TIM_MasterOutputTrigger=TIM_TRGO_UPDATE`)。② **ADC1 在 DMA2 上只挂 Stream0 或 Stream4**(Stream1=ADC3、Stream2/3=ADC2)→ 给 ADC1 选 **Stream4**(空闲)才不会把 SPI1_RX/USART1_RX 挤走;CubeMX 会自动挑 Stream0 并**连锁重排**在用外设,且**流的 NVIC 优先级按流号绑定**、换流即被重置为 **0**(违反"外设中断 ≥5",会抢占 FreeRTOS 临界区)→ 每次改 DMA 流后必须回 NVIC 页核对 `Src/dma.c` 的 `HAL_NVIC_SetPriority`。③ **`DMAContinuousRequests` 必须 ENABLE**(DDS 位;关掉 = 一轮 9 通道只发**一次** DMA 请求,静默丢 8 路,不报错)。④ DMA 目的缓冲**必须在 SRAM**(ADC1 走 DMA2,**DMA2 不可达 CCM 0x10000000**)→ 加大缓冲后跑 `.map` 复核地址。⑤ 消费侧用 **乒乓双缓冲**(DMA 长度 = 2×9,半传输/传输完成两个回调各代表一帧就绪),半区下标由**完成计数的奇偶**推出(只读一个 32 位计数 → 与 ISR 无竞态);STM32 侧数据因此**固定滞后 1 拍** - **STM32F4 ADC 上电必须自校准(2026-10-10 补,此前项目从未做过)**:CubeMX **不生成**这一步,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头 `stm32f407xx.h` **没有** `ADC_CR2_CAL`(bit3)→ 只能按 RM0090 §13.3.6 手写:`Src/adc.c` 的 `adc1_calibrate()`(`USER CODE BEGIN 1` 区,自建 `ADC1_CAL_BIT`)+ `MX_ADC1_Init()` 末尾 `USER CODE BEGIN ADC1_Init 2` 调用;时序 = 清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零。缺它**不报任何错**,只是读数带失调(实测校准让 ADC1 六路同向偏移 +10~+63 LSB,TPAFE 侧不变)。审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底。⚠ 顺带一条编译坑:**块注释里别写 `JEXT*/EXT*` 这类串**——其中的 `*/` 会提前闭合注释,报 `#7: unrecognized token` - **接 ADC / RS-485 消费者前的强制门禁(E-59,2026-10-10 固化)**:`adc_task` / `rs485_task` 从空桩转为真实消费者**之前**必须确认——① 驱动契约已修:TPAFE **E-44 / E-45 / E-58 已修 ✅**、RS485 **E-36 / E-37 已修 ✅** 但 **E-50(单缓冲无同步 → 包撕裂)未修 ❌**、**E-49(TX 超时拉 DE)未修 ❌**;② 栈预算:建任务前后各跑一次 `scripts/stack_report.py`。**`adc_task` 已过闸(B1,2026-10-10)**:消费者本体 + `adcTask`(512 words / **AboveNormal**——P1-5 方案 a 提优先级,`USER CODE` 区手工创建,同 timeSyncTask)+ 栈预算(静态 **560 B** / 余 **72%**,B1 实测;P1-5 后复测 **832 B** / 余 **59%**,见下)+ IWDG 心跳(`TASK_WD_ADC`)均已落地;**`rs485_task` 仍被挡(E-50 / E-49 未修)**。另按 N-18:V0.1 上两片转换器之间**禁止**做相位/功率/功率因数类运算 - **`DBG_*` 每条日志约 90 B Flash + 216 B 栈**(2026-10-02 S10 实测;2026-10-10 修正栈量):`dbg_log.h` 的 `DBG_INFO/ERROR/DEBUG` 在**每个调用点内联**行缓冲 `char __con_line[DBG_LINE_BUF_MAX]`(默认 = `console_log.h` 的 `DBG_CON_LINE_MAX` **192 B**)+ 时间戳缓冲 **24 B**(已并入同一数组,原为独立 32 B)+ 完整 `snprintf` 调用序列 → 单条约 **90 B Flash**(字符串常量另计)。给新模块接入日志时按"条数 × 90 B"估算 Flash 代价(实测 `sd2506_init` 加 6 条 = +576 B;`app_main` 加 3 条 = +180 B);**深调用链模块另需核算栈**——每条日志在栈上多占一份行缓冲、会进 `stack_report.py` 的最坏链:实测 defaultTask 那条链上 9 个帧各含一份 → **约 1.5 KB 纯粹是日志行缓冲**(2026-10-10 把该链 5312 → 2776 B 的主要杠杆之一)。故**日志不是免费的**——已覆盖的步骤不要再加"进入前/完成后"重复锚点,只在真正黑洞(原先静默返回)处补 @@ -77,11 +78,11 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v `Src/main.c`(CubeMX 初始化)→ `app_main_init` → `osKernelStart` → 7 任务(`osKernelStart` 之前全是内核启动前代码:禁调任何 FreeRTOS/CMSIS-RTOS API,见「平台约束」) -- `app_main_init`(时间子系统优先,使后续日志带时间戳):`sd2506_init` → `sys_clock_init`(墙钟;RTC 故障时降级默认时间基准并返回 -1 触发"时钟芯片异常"自检;`sys_clock_get` 每 30 天惰性再基准化防 49.7 天 tick 回绕)→ `rs485_init` → `gd5f2gq5ue_init` → `tpafe5160_init` → **`adc_input_init`(L1 采集层)** → SD2506 诊断 → **启动 TIM6 1 kHz 采样节拍**(`HAL_TIM_Base_Start_IT(&htim6)`,2026-10-10;ISR 内触发 CONVST,见下) +- `app_main_init`(时间子系统优先,使后续日志带时间戳):`sd2506_init` → `sys_clock_init`(墙钟;RTC 故障时降级默认时间基准并返回 -1 触发"时钟芯片异常"自检;`sys_clock_get` 每 30 天惰性再基准化防 49.7 天 tick 回绕)→ `rs485_init` → `gd5f2gq5ue_init` → `tpafe5160_init` → **`adc_input_init`(L1 采集层)** → SD2506 诊断 → **启动 TIM3 1 kHz 采样节拍**(`HAL_TIM_Base_Start_IT(&htim3)`,2026-10-10;ISR 内触发 CONVST + 同一事件经 TRGO 触发 ADC1,见下) - **就绪屏障**(`App/app/sys_events.h` Event Flags,取代魔法延时):`SYS_EVT_NET_READY`(netTask 置位)/ `STORAGE_READY`(defaultTask:卷挂载 + 网络配置加载完成)/ `FTP_READY` / `TIMESYNC_READY`;消费者用 `osEventFlagsWait` 阻塞等待 - netTask:`net_stack_bring_up`(配置已由 defaultTask 在屏障前加载,本任务只读 RAM 副本 → `net_init` + PHY 强制 100M 全双工 → 递增网络代次号)→ 置 NET_READY → 循环 `net_poll` + `net_process_messages`;消费 `SYS_EVT_NET_RECONFIG` → 立即重建网络(新 IP 免重启生效,各 TCP 服务据代次号重建监听) - defaultTask:`f_mount` forced(无卷自动 `f_mkfs` FAT32;挂载后卷健康探针:小写两击确认,FAT 区陈旧/擦除时自动重格式化自愈,`/config.ini` 回落工厂默认——2026-09-20 /log 写失败修复)→ 网络配置加载(`net_cfg_load`;旧档迁移已于 2026-10-10 删除,见「模块约定」网口配置条)→ STORAGE_READY → LED 闪烁;同时是 syslog(`/log/run.log`·`selftest.log`,定长 64B 记录)的**唯一写者**与**配置引擎属主**(`cfg_file` 静态态单任务收敛于此:UI 保存请求执行 `net_cfg_save_run` + 2s `cfg_file_poll`,经 `SYS_EVT_CFG_SAVE` 事件唤醒;netTask/uiTask 不得直接触碰配置引擎,见 P1-3) -- adcTask(**B1,2026-10-10;L1 层 v2.3,2026-10-10;P1-5 事件驱动消费,2026-10-10**):**纯消费者**——每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **STM32 ADC1 9 通道软件触发顺序扫描**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM6 更新中断硬件触发**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM6 分支内调 `tpafe5160_start_conv_irq()`,2026-10-10);**消费唤醒与该节拍同源**——同一 TIM6 分支再调 `adc_tick_irq_notify()` 置位 `ADC_WAKE_FLAG`,任务用 `osThreadFlagsWait(..., osWaitForever)` 等唤醒(不再 1 ms 轮询;实测 `idle` 由 1~649 降到 **0**)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`——它内含"消费者就绪"门(TIM6 在 `app_main_init` 就启动,比调度器早 ≈100 ms)。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,512 words / **AboveNormal**),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10、`App/algo/README.md` +- adcTask(**B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费;v2.8 节拍源 TIM3 + STM32 侧 DMA,2026-10-10**):**纯消费者**——每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **消费一帧 STM32 ADC1 DMA 双缓冲**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM3 更新事件硬件提供**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM3 分支内调 `tpafe5160_start_conv_irq()`;同一更新事件经 **TRGO** 触发 ADC1 一轮 9 通道转换 → 两片转换器同源对齐);**消费唤醒与该节拍同源**——同一分支再调 `adc_tick_irq_notify()` 置位 `ADC_WAKE_FLAG`,任务用 `osThreadFlagsWait(..., osWaitForever)` 等唤醒(不再 1 ms 轮询;实测 `idle` 由 1~649 降到 **0**)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`——它内含"消费者就绪"门(TIM3 在 `app_main_init` 就启动,比调度器早 ≈100 ms)。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,512 words / **AboveNormal**),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10、`App/algo/README.md` - **采集与算法层(L1/L2)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 扫描);L2 相量层待建。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md` - 全景 `docs/架构设计/项目架构总览.md`(六层分层 / 启动时序 / 存储·网络·UI 栈 / 目录组织) diff --git a/App/algo/README.md b/App/algo/README.md index 9d8e5c90..4158632a 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -11,7 +11,7 @@ | 文件 | 层 | 作用(一句话) | 主要对外入口 | 覆盖规划项 | |---|---|---|---|---| -| `adc_input.h` / `adc_input.c` | L1 | **统一语义取数层**:把两片转换器的原始码值按语义(`UA…IN`/温度)暴露,并驱动 STM32 ADC1 9 通道顺序扫描 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 | +| `adc_input.h` / `adc_input.c` | L1 | **统一语义取数层**:把两片转换器的原始码值按语义(`UA…IN`/温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓双缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 | | `sample_ring.h` / `sample_ring.c` | L2 输入 | **采样环缓冲**:按语义通道保存最近 2 个周波(40 点)采样值,供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 滑动窗供数) | | `phasor.h` / `phasor.c` | L2 | **全周傅氏相量计算**:一周波 20 点单频 DFT → 基波/谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 | @@ -19,14 +19,17 @@ `sample_ring.c` / `phasor.c` **零平台依赖**(只用 `math.h` / `stddef.h` 与本目录头); `adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。 -## 2. 数据流(一拍 = TIM6 1 kHz 节拍) +## 2. 数据流(一拍 = TIM3 1 kHz 节拍) ```mermaid flowchart TD - TIM6["TIM6 更新中断 1 kHz
Src/main.c → tpafe5160_start_conv_irq()
+ adc_tick_irq_notify()"] --> TPAFE["TPAFE5160 转换
BUSY EXTI 收帧"] - TIM6 -. "ADC_WAKE_FLAG
osThreadFlagsWait" .-> TASK + TIM3["TIM3 更新事件 1 kHz(PSC 83 / ARR 999)
ISR: tpafe5160_start_conv_irq() + adc_tick_irq_notify()
同一事件经 TRGO 触发 ADC1"] --> TPAFE["TPAFE5160 转换
BUSY EXTI 收帧"] + TIM3 --> ADC["STM32 ADC1 一轮 9 通道(≈67 us)
DMA2_Stream4 循环模式自动搬运"] + ADC --> PP["乒乓双缓冲 s_stm32_dma[2×9]
半传输 / 传输完成回调各推进 seq"] + TIM3 -. "ADC_WAKE_FLAG
osThreadFlagsWait" .-> TASK TPAFE --> POLL - TASK["adcTask(事件驱动唤醒,非定时轮询)
App/task/adc_task.c"] --> POLL["adc_input_poll()
TPAFE 快照 + STM32 ADC1 9 通道扫描"] + PP --> POLL + TASK["adcTask(事件驱动唤醒,非定时轮询)
App/task/adc_task.c"] --> POLL["adc_input_poll()
TPAFE 快照 + 消费刚完成的那半区(零忙等)"] POLL --> RAW["adc_input_raw(ch) × 9
按语义取原始码值"] RAW --> RING["sample_ring_push_frame()
整帧入环(40 点窗)"] RING --> RUN["phasor_run()
逐实例 20 点重算 + 双区切换"] @@ -48,11 +51,12 @@ flowchart TD | API | 干什么 | 注意 | |---|---|---| -| `adc_input_init()` | 清缓冲与统计 + **映射表自检**(每个 STM32 语义通道必须落在扫描 Rank 序列内) | 内核启动前调用;返回 `-1` = 映射指向了从不扫描的通道(会恒读陈旧值) | -| `adc_input_poll()` | 采一拍:`tpafe5160_snapshot()` 原子快照(8 通道)+ STM32 ADC1 9 通道软件触发顺序扫描 | **仅任务上下文**(快照走临界区);只搬码值,不做标定/运算 | +| `adc_input_init()` | 清缓冲与统计 + **映射表自检**(每个 STM32 语义通道必须落在扫描 Rank 序列内)+ `HAL_ADC_Start_DMA()` 挂载乒乓缓冲(**只挂一次**) | 内核启动前调用;`-1` = 映射指向了从不扫描的通道(会恒读陈旧值);`-2` = DMA 挂载失败 | +| `adc_input_poll()` | 采一拍:`tpafe5160_snapshot()` 原子快照(8 通道)+ **消费刚完成的那半区**(`stm32_take()`,按 `seq` 奇偶选半区,零忙等) | **仅任务上下文**(快照走临界区);只搬码值,不做标定/运算;STM32 侧固定滞后 1 拍 | | `adc_input_raw(ch, &out)` | 按语义读原始码值(TPAFE 16 位补码 / STM32 0..4095) | 消费侧取数**唯一入口**;须先成功 `poll()`;`-2` = 该来源尚无有效数据 | | `adc_input_desc(ch)` | 语义 → 物理通道描述(来源 + 下标/通道号) | 供自检注入、UI 通道序;返回静态地址,勿改 | -| `adc_input_stm32_rc()` / `adc_input_stm32_errs()` | STM32 侧最近扫描状态码 / 累计失败次数 | 心跳日志观测面(`adc1_rc` / `adc1_err`) | +| `adc_input_stm32_rc()` | STM32 侧最近一拍状态码(0 OK / -2 本拍无新帧) | 心跳日志 `adc1_rc` | +| `adc_input_stm32_seq()` / `_errs()` / `_gaps()` / `_hw_errs()` | DMA 整帧完成计数 / 停摆(连续 ≥2 拍无新帧)/ 丢帧(seq 一次前进 >1)/ HAL 错误回调 | 心跳日志 `adc1_seq` / `adc1_err` / `adc1_gap` / `adc1_hw`——四类成因不同,**勿混读** | **要点** @@ -60,7 +64,9 @@ flowchart TD `UA/UN/IA` → TPAFE `buf[6]/[5]/[2]`;`UB/UC/IB/IC/IN/TEMP` → ADC1 `CH14/3/6/1/2/5`。 **V0.2 改版只改这张表的内容**,本层 API 与上层语义不动。 - STM32 侧是**顺序扫描**(Rank1..8 = CH0..CH7、Rank9 = CH14,与 `MX_ADC1_Init()` 严格一致), - 9 个通道**不是同一时刻**;单通道轮询超时 2 ms,超时判失败、绝不拖垮消费节拍。 + 9 个通道**不是同一时刻**(一轮 ≈67 µs)。v2.0 起这一轮由 **TIM3 TRGO 硬件触发 + DMA 自动搬运**, + 消费侧**零忙等**(旧实现逐通道轮询、单通道超时 2 ms,已于 2026-10-10 移除)。 + 代价:STM32 侧数据**固定滞后 1 拍(1 ms)**——与 TPAFE 侧本来就没有同刻性(N-18),无新增误差。 - 本文件是本层**唯一**允许出现 HAL 调用的地方(它必须驱动 ADC1);新增模块若不需要外设, 请保持 `sample_ring` / `phasor` 那种零依赖形态,才能免桩单测。 @@ -165,5 +171,5 @@ flowchart TD - 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md` - 分层与路线图(L1/L2 清单与状态):`docs/算法/继电保护功能复现规划.md` - 傅氏算法与有效值口径:`docs/算法/傅氏算法详解.md` -- 采样节拍(TIM6 1 kHz):`docs/子系统设计/ADC采样节拍设计说明.md` +- 采样节拍(TIM3 1 kHz + TRGO 触发 ADC1/DMA):`docs/子系统设计/ADC采样节拍设计说明.md` - 相量层单测细则:`docs/测试说明/相量层单元测试说明.md` diff --git a/App/algo/adc_input.c b/App/algo/adc_input.c index e6499ebe..ae02f375 100644 --- a/App/algo/adc_input.c +++ b/App/algo/adc_input.c @@ -1,12 +1,17 @@ /* * 模块名称:ADC 输入层(L1 采集与预处理) - * 模块功能:语义通道映射(L1-4)+ STM32 ADC1 顺序扫描(L1-3)+ TPAFE 快照消费。 + * 模块功能:语义通道映射(L1-4)+ STM32 ADC1 采样消费(L1-3,TIM3 TRGO 触发 + + * DMA 双缓冲)+ TPAFE 快照消费。 * 消费侧经 adc_input_raw() 按语义取数,不写死下标。 * 适用平台:STM32F407ZGTx + TPAFE5160 * 作者:王建锋 * 创建日期:2026-10-10 * 修改记录: * v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射) + * v2.0 2026-10-10 王建锋 STM32 侧由"软件触发 + 逐通道轮询"改为 + * "TIM3 TRGO 硬件触发 + DMA 双缓冲消费":本层不再触发 + * 也不等待转换(消除最坏 2~3 ms 忙等);新增三个 HAL + * 回调(ISR 内只计数)与 seq / hw_errs 观测量 */ #include "adc_input.h" @@ -16,11 +21,17 @@ #include "adc.h" /* hadc1:MX_ADC1_Init() 已配 9 通道 / 144 cycles / 软件触发 */ #include "tpafe5160.h" /* tpafe5160_snapshot():临界区原子快照(AGENTS 铁律) */ -/* STM32 ADC1 单次转换轮询超时(ms)。单通道 ≈ 7.4 µs(144+12 cycles @ 21 MHz), - * 2 ms 余量充裕;超时即判本次扫描失败,绝不长时间阻塞消费节拍 */ -#define ADC_INPUT_POLL_TIMEOUT_MS 2U /* STM32 ADC1 通道号上限(F4 上 ADC_CHANNEL_0..15 的宏值即通道号 0..15) */ #define ADC_INPUT_STM32_CH_MAX 16U +/* DMA 搬运缓冲长度 = 2 x 9 = 18:**双缓冲(乒乓)**。 + * ADC1 配 ExternalTrigConv=TIM3_TRGO + DMAContinuousRequests=ENABLE + DMA CIRCULAR, + * 每拍(TIM3 TRGO,1 kHz)自动搬 9 个 → 每 2 拍绕一圈:半传输中断 = 前半 9 个就绪, + * 传输完成中断 = 后半 9 个就绪。消费侧永远读"刚完成的那一半",而 DMA 正在写另一半 + * → 无撕裂、无需临界区。代价:STM32 侧数据固定滞后 1 拍(1 ms),9 路同一延迟, + * 对 20 点 DFT 窗是纯传输延迟、不引入通道间偏差。 */ +#define ADC_INPUT_STM32_DMA_LEN (2U * ADC_INPUT_STM32_CH_NUM) +/* 连续"无新帧"达到该拍数才计一次停摆故障(单拍是相位伪影,见 stm32_take 注释) */ +#define ADC_INPUT_STALL_RUN_MAX 2U /* ===== 语义映射表(L1-4)===== * 权威口径:docs/硬件平台/ADC通道映射说明.md(2026-10-09 硬件确认的语义分配)。 @@ -49,6 +60,17 @@ static const uint8_t k_stm32_rank_ch[ADC_INPUT_STM32_CH_NUM] = { /* STM32 ADC1 最新一帧原始码值,按 ADC1 通道号索引(0..15) */ static uint16_t s_stm32_raw[ADC_INPUT_STM32_CH_MAX]; +/* DMA 目的缓冲(乒乓两半区,按 Rank 序 0..8 / 9..17)。 + * ⚠ **必须落在 SRAM(0x20000000 段)**:ADC1 挂的是 **DMA2**,而 F4 的 DMA2 不可达 + * CCM(0x10000000)。实测 .map 里本文件 .bss 在 RW_IRAM1 → 普通 static 即可; + * 日后加大缓冲或改链接段时**必须用 .map 复核地址**——放错不报编译错、不报运行错, + * 只是数据永远搬不回来。 */ +static uint16_t s_stm32_dma[ADC_INPUT_STM32_DMA_LEN]; +static volatile uint32_t s_dma_seq; /* DMA 半帧/整帧完成次数(ISR 递增,只增不清) */ +static uint32_t s_dma_seq_seen; /* 消费侧上次看到的完成计数 */ +static volatile uint32_t s_stm32_hw_errs; /* HAL ADC/DMA 错误回调累计(ISR 写) */ +static uint32_t s_stm32_gaps; /* 丢帧:seq 一次前进 >1(消费者超拍漏读)*/ +static uint8_t s_stall_run; /* 连续"无新帧"拍数(相位伪影只会是 1 拍)*/ /* TPAFE5160 最新一帧原始码值(16 位二进制补码),按驱动 buf 下标索引 */ static int16_t s_tpafe_raw[ADC_INPUT_TPAFE_CH_NUM]; static uint8_t s_tpafe_valid; /* TPAFE 侧是否已有有效帧 */ @@ -57,49 +79,59 @@ static int s_stm32_rc; /* STM32 最近一次扫描状态码 */ static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */ /* - * 函数功能:STM32 ADC1 顺序扫描一次(L1-3) + * 函数功能:消费一帧 STM32 ADC1 采样(L1-3,v2.0 DMA 双缓冲版) * 入口参数:无 - * 返 回 值:0 - 成功;-1 - 启动失败;-2 - 转换轮询超时 - * 限定条件:仅任务上下文调用;MX_ADC1_Init() 已完成 - * 函数说明:软件触发一次 9 通道扫描(ScanConvMode=ENABLE、ContinuousConvMode= - * DISABLE),按 Rank 顺序逐通道轮询 EOC 并读 DR,结果按 ADC1 通道号 - * 存入 s_stm32_raw[]。EOCSelection=SINGLE_CONV 且未用 DMA → 每个通道 - * 产生一次 EOC,可逐通道轮询(见 HAL_ADC_PollForConversion 说明)。 - * 两条失败支路(启动失败 / 轮询超时)**都会清 s_stm32_valid**:本拍 - * STM32 侧判为无有效数据,绝不沿用上一拍旧值(详见支路内注释)。 - * 超时在**首个**失败通道处即 Stop+return,不轮询剩余通道 → 单拍耗时 - * 上界 = 一次超时(≈2~3 ms,HAL_GetTick 1 ms 粒度),不是 9 次。 + * 返 回 值:0 - 取到新一帧;-2 - 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错) + * 限定条件:仅任务上下文调用;adc_input_init() 已挂好 DMA + * 函数说明:本函数**既不触发转换也不等待转换**——转换由 TIM3 TRGO 硬件触发、由 DMA + * 自动搬运。每拍只做"看完成计数是否前进 + 拷 9 个半字",故**零忙等**: + * 原轮询版常态 adcp=122 µs、超时拍最坏 2~3 ms,现在两档都降到 ~1 µs 量级。 + * 读的是"上一拍刚完成的那一半"乒乓缓冲(DMA 此刻正在写另一半)→ 无撕裂。 + * ⚠ 计数未前进 = 本拍 STM32 侧**无有效数据**,必须清 s_stm32_valid,绝不 + * 沿用上一拍旧值(同 2026-10-10 修的陈旧帧缺陷口径)→ adc_input_raw() + * 返 -2 → adc_task 走既有"整帧跳过"路径;下一拍自动恢复。 */ -static int stm32_scan(void) { - uint8_t i; +static int stm32_take(void) { + uint32_t seq = s_dma_seq; /* 单次 32 位读:与 ISR 自增不产生撕裂 */ + const uint16_t *src; + uint8_t half; + uint8_t i; - if (HAL_ADC_Start(&hadc1) != HAL_OK) { - /* 失败拍必须清有效标志,见下方超时分支说明(同一语义) */ + if (seq == s_dma_seq_seen) { s_stm32_valid = 0u; - s_stm32_rc = ADC_INPUT_RC_START; - s_stm32_errs++; - return ADC_INPUT_RC_START; + s_stm32_rc = ADC_INPUT_RC_STALL; + /* 单拍"无新帧"**多半是消费者相位伪影,不是故障**:本层在 TIM3 触发瞬间被唤醒, + * 而该拍的 DMA 完成落在 ~67 us 之后;若上一拍被日志/抢占拖过这 67 us,本拍就会 + * 看到"计数没前进"(那个完成已被上一拍消费掉)。这种伪影**只会连续 1 拍**—— + * DMA 若真停,第 2 拍仍不会前进。故只有连续 >= ADC_INPUT_STALL_RUN_MAX 拍 + * 无新帧才计入故障计数(实测开机日志突发期约 21~26 次伪影、稳态每 10 s 心跳 1 次, + * 全部为单拍;见 docs/子系统设计/ADC采样节拍设计说明.md §10.5) */ + if (s_stall_run < 255u) { + s_stall_run++; + } + if (s_stall_run >= ADC_INPUT_STALL_RUN_MAX) { + s_stm32_errs++; + } + return ADC_INPUT_RC_STALL; } + if ((uint32_t)(seq - s_dma_seq_seen) > 1u) { + /* 计数一次前进 >1 = 中间有帧没被读走(乒乓缓冲已被覆盖)→ **真丢帧**, + * 与"无新帧"分开计数:前者是消费者超拍(本任务单拍耗时 > 1 ms), + * 后者多半是相位伪影 */ + s_stm32_gaps++; + } + s_stall_run = 0u; + s_dma_seq_seen = seq; + /* 半区下标由 seq 奇偶推出,**不再另读 ISR 变量**:DMA 从 NDTR=18 起、每拍搬 9 个, + * 故第 1/3/5... 次完成的是前半区(下标 0)、第 2/4/6... 次是后半区(下标 1)。 + * 只读 seq 一个量 → 消费侧与 ISR 之间不存在"读到一半被换半区"的窗口。 */ + half = (uint8_t)(1u - (seq & 1u)); + src = &s_stm32_dma[((uint32_t)half * ADC_INPUT_STM32_CH_NUM)]; + for (i = 0u; i < ADC_INPUT_STM32_CH_NUM; i++) { - if (HAL_ADC_PollForConversion(&hadc1, ADC_INPUT_POLL_TIMEOUT_MS) != HAL_OK) { - (void)HAL_ADC_Stop(&hadc1); - /* ⚠ 失败拍**不得**沿用上一拍的旧值:本层只有一份 s_stm32_raw[], - * 超时前几个 Rank 已写入**本拍新值**、后面几个还是**上一拍旧值** → - * 若保持 s_stm32_valid=1,消费侧会拿到"半新半旧"的混合帧并照样压入 - * 采样环(adc_input_poll() 的返回值只由 TPAFE 决定,s_frames_skip - * 也抓不到它),直接污染 20 点 DFT 相量窗。清标志后 adc_input_raw() - * 对 STM32 语义通道返 -2 → adc_task 走既有的"整帧跳过"路径。 - * 下一拍扫描成功会重新置 1(自愈),无需额外恢复动作。 */ - s_stm32_valid = 0u; - s_stm32_rc = ADC_INPUT_RC_TIMEOUT; - s_stm32_errs++; - return ADC_INPUT_RC_TIMEOUT; - } - s_stm32_raw[k_stm32_rank_ch[i]] = (uint16_t)HAL_ADC_GetValue(&hadc1); + s_stm32_raw[k_stm32_rank_ch[i]] = src[i]; } - - (void)HAL_ADC_Stop(&hadc1); s_stm32_valid = 1u; s_stm32_rc = ADC_INPUT_RC_OK; return ADC_INPUT_RC_OK; @@ -145,10 +177,11 @@ static int map_check(void) { /* * 函数功能:ADC 输入层初始化 * 入口参数:无 - * 返 回 值:0 - 成功;-1 - 语义映射表自检失败 + * 返 回 值:0 - 成功;-1 - 语义映射表自检失败;-2 - DMA 挂载失败 * 限定条件:内核启动前调用(无 RTOS 依赖);STM32 ADC1 外设已由 MX_ADC1_Init 配好 - * 函数说明:清空原始码值缓冲与统计,并做一次映射表自洽性自检;本层不重复配置 - * 外设,也不启动任何转换(STM32 侧为软件触发,按拍扫描)。 + * 函数说明:清空原始码值缓冲与统计 + 映射表自洽性自检 + **挂载一次 ADC1 DMA** + * (此后转换由 TIM3 TRGO 硬件触发、DMA 自动搬运,本层不再触发转换)。 + * 须在启动采样节拍(app_main.c 步骤 6)之前调用。 */ int adc_input_init(void) { uint8_t i; @@ -156,6 +189,9 @@ int adc_input_init(void) { for (i = 0u; i < ADC_INPUT_STM32_CH_MAX; i++) { s_stm32_raw[i] = 0u; } + for (i = 0u; i < ADC_INPUT_STM32_DMA_LEN; i++) { + s_stm32_dma[i] = 0u; + } for (i = 0u; i < ADC_INPUT_TPAFE_CH_NUM; i++) { s_tpafe_raw[i] = 0; } @@ -164,17 +200,39 @@ int adc_input_init(void) { s_stm32_valid = 0u; s_stm32_rc = ADC_INPUT_RC_NOT_RUN; s_stm32_errs = 0u; - return map_check(); + s_dma_seq = 0u; + s_dma_seq_seen = 0u; + s_stm32_hw_errs = 0u; + s_stm32_gaps = 0u; + s_stall_run = 0u; + + if (map_check() != 0) { + return -1; + } + + /* 挂**一次** DMA,之后不再重启:ADC 由 TIM3 TRGO 每拍触发一轮 9 通道转换, + * DMA 在 CIRCULAR 模式下自动搬运、硬件自动重载 NDTR → 每 2 拍绕一圈(乒乓)。 + * 内核启动前调用安全:只碰寄存器/HAL,不创建任何 CMSIS 对象(AGENTS 铁律)。 + * 此刻 TIM3 尚未启动(app_main.c 步骤 6)→ 不会发生任何转换或搬运。 */ + if (HAL_ADC_Start_DMA(&hadc1, (uint32_t *)s_stm32_dma, + ADC_INPUT_STM32_DMA_LEN) != HAL_OK) { + s_stm32_rc = ADC_INPUT_RC_START; + s_stm32_errs++; + return -2; + } + return 0; } /* - * 函数功能:采集一拍(TPAFE 原子快照 + STM32 ADC1 顺序扫描) + * 函数功能:采集一拍(TPAFE 原子快照 + 消费一帧 STM32 ADC1 DMA 采样) * 入口参数:无 * 返 回 值:TPAFE 快照结果(1=有新帧 0=无新帧 -1=参数错) * 限定条件:仅**任务上下文**调用(TPAFE 快照走临界区) - * 函数说明:先取 TPAFE 一帧,再做 STM32 ADC1 扫描;两者相互独立,STM32 扫描 - * 失败不影响 TPAFE 快照与返回值语义(STM32 侧状态另由 - * adc_input_stm32_rc() 暴露)。 + * 函数说明:先取 TPAFE 一帧,再消费 STM32 侧最近一帧 DMA 双缓冲;两者相互独立, + * STM32 侧无新帧不影响 TPAFE 快照与返回值语义(其状态另由 + * adc_input_stm32_rc() / adc_input_stm32_seq() 暴露)。 + * ⚠ STM32 侧数据比 TPAFE 侧**滞后 1 拍**(乒乓双缓冲的固有延迟, + * 9 路一致);按 N-18,V0.1 本就禁止跨转换器做矢量运算。 */ int adc_input_poll(void) { int r = tpafe5160_snapshot(s_tpafe_raw, (uint8_t)ADC_INPUT_TPAFE_CH_NUM); @@ -183,7 +241,7 @@ int adc_input_poll(void) { s_tpafe_valid = 1u; } - (void)stm32_scan(); + (void)stm32_take(); return r; } @@ -240,12 +298,94 @@ int adc_input_stm32_rc(void) { } /* - * 函数功能:读取 STM32 ADC1 扫描累计失败次数 + * 函数功能:读取 STM32 ADC1 停摆故障累计次数 * 入口参数:无 - * 返 回 值:累计失败次数(启动失败或转换超时均计入) + * 返 回 值:DMA 挂载失败次数 + "连续 >=2 拍无新帧"的次数 * 限定条件:无 - * 函数说明:单调递增,供心跳日志观测。 + * 函数说明:单调递增,供心跳日志观测。单拍无新帧(相位伪影)**不计入**, + * 丢帧由 adc_input_stm32_gaps() 单独计数。 */ uint32_t adc_input_stm32_errs(void) { return s_stm32_errs; } + +/* + * 函数功能:读取 STM32 ADC1 的丢帧次数 + * 入口参数:无 + * 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被乒乓缓冲覆盖) + * 限定条件:无 + * 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如突发日志突发期); + * 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障。 + */ +uint32_t adc_input_stm32_gaps(void) { + return s_stm32_gaps; +} + +/* + * 函数功能:读取 STM32 ADC1 的 DMA 整帧完成计数 + * 入口参数:无 + * 返 回 值:DMA 半区完成中断 + 传输完成中断的累计次数(每拍 +1) + * 限定条件:无 + * 函数说明:这是 STM32 侧"节拍是否还在喂数据"的直接观测量:与 TPAFE 的 + * tpafe5160_frame_seq() 对称。它若停增而 TIM3 仍在跑 → DMA/ADC 侧停摆。 + */ +uint32_t adc_input_stm32_seq(void) { + return s_dma_seq; +} + +/* + * 函数功能:读取 STM32 ADC1 的 HAL 层错误回调累计次数 + * 入口参数:无 + * 返 回 值:HAL_ADC_ErrorCallback 触发次数(DMA 传输错误等) + * 限定条件:无 + * 函数说明:与 adc_input_stm32_errs()(消费侧"无新帧")分开计数,便于区分 + * "节拍源停了"与"DMA 出错"两类故障。 + */ +uint32_t adc_input_stm32_hw_errs(void) { + return s_stm32_hw_errs; +} + +/* ===== HAL 回调(ISR 上下文)===== + * ⚠ 三个回调都在中断里跑(DMA2_Stream4_IRQn,抢占优先级 6)→ **只允许**写 volatile + * 计数/标志,不得调用任何 CMSIS-RTOS API、不得日志、不得拷贝大缓冲。 + * 搬运与消费全在任务侧(stm32_take())。 */ + +/* + * 函数功能:DMA 传输完成回调 = 后半区(下标 1)9 个采样就绪 + * 入口参数:hadc - 触发回调的 ADC 句柄 + * 返 回 值:无 + * 限定条件:仅 ISR 上下文;覆盖 HAL 的 __weak 实现(本板只有 ADC1) + * 函数说明:见 stm32_take() 的奇偶推半区口径。 + */ +void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { + if (hadc == &hadc1) { + s_dma_seq++; + } +} + +/* + * 函数功能:DMA 半传输完成回调 = 前半区(下标 0)9 个采样就绪 + * 入口参数:hadc - 触发回调的 ADC 句柄 + * 返 回 值:无 + * 限定条件:仅 ISR 上下文;覆盖 HAL 的 __weak 实现 + * 函数说明:DMA 长度 18 = 2 x 9,故半传输即"一帧就绪",与传输完成交替出现。 + */ +void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { + if (hadc == &hadc1) { + s_dma_seq++; + } +} + +/* + * 函数功能:ADC/DMA 错误回调 + * 入口参数:hadc - 触发回调的 ADC 句柄 + * 返 回 值:无 + * 限定条件:仅 ISR 上下文;覆盖 HAL 的 __weak 实现 + * 函数说明:只计数。错误后 DMA 可能已停 → 消费侧会看到 s_dma_seq 停增(rc=STALL、 + * 每拍 skip),并由 IWDG 心跳日志暴露,不在 ISR 里尝试恢复。 + */ +void HAL_ADC_ErrorCallback(ADC_HandleTypeDef *hadc) { + if (hadc == &hadc1) { + s_stm32_hw_errs++; + } +} diff --git a/App/algo/adc_input.h b/App/algo/adc_input.h index 13bde0ac..b8484d31 100644 --- a/App/algo/adc_input.h +++ b/App/algo/adc_input.h @@ -3,7 +3,8 @@ * 模块功能:把两片转换器的原始码值按**语义**(UA/UB/UC/UN/IA/IB/IC/IN/温度)统一 * 暴露,使上层(UI / 自检 / 保护算法)**按语义取数,不写死下标**: * ① 语义通道映射表(L1-4)——权威口径 docs/硬件平台/ADC通道映射说明.md; - * ② STM32 ADC1 9 通道软件触发顺序扫描(L1-3)。 + * ② STM32 ADC1 9 通道采样(L1-3)——由 **TIM3 TRGO 硬件触发 + DMA 双缓冲 + * 搬运**(v2.0),本层只消费、不触发、不等待。 * V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18):UA/UN/IA 走 * TPAFE5160(16 位 / ±5 V 双极性),UB/UC/IB/IC/IN 走 STM32 ADC1 * (12 位 / 0~3.3 V 单极性)。V0.2 改版(8 路电气量全部接 TPAFE) @@ -13,6 +14,10 @@ * 创建日期:2026-10-10 * 修改记录: * v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射) + * v2.0 2026-10-10 王建锋 STM32 侧改 TIM3 TRGO 硬件触发 + DMA 双缓冲消费: + * 消除逐通道轮询的 2 ms 忙等;节拍源 TIM6 -> TIM3 + * (F4 上 ADC1 只认 TIM2/TIM3/TIM8 的 TRGO,TIM6 TRGO + * 仅接 DAC);新增 adc_input_stm32_seq()/_hw_errs() */ #ifndef ADC_INPUT_H @@ -56,29 +61,32 @@ typedef struct { /* STM32 ADC1 顺序扫描通道数(Rank 1..9) */ #define ADC_INPUT_STM32_CH_NUM 9U -/* STM32 ADC1 最近一次扫描状态码 */ -#define ADC_INPUT_RC_OK (0) /* 扫描成功 */ -#define ADC_INPUT_RC_START (-1) /* HAL_ADC_Start 失败 */ -#define ADC_INPUT_RC_TIMEOUT (-2) /* 转换轮询超时 */ -#define ADC_INPUT_RC_NOT_RUN (-3) /* 尚未扫描过 */ +/* STM32 ADC1 最近一次取帧状态码(v2.0:DMA 消费,已无"轮询超时"这一档) */ +#define ADC_INPUT_RC_OK (0) /* 取到新一帧 */ +#define ADC_INPUT_RC_START (-1) /* HAL_ADC_Start_DMA 挂载失败 */ +#define ADC_INPUT_RC_STALL (-2) /* 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错) */ +#define ADC_INPUT_RC_NOT_RUN (-3) /* 尚未取过帧 */ /* * 函数功能:ADC 输入层初始化 * 入口参数:无 - * 返 回 值:0 - 成功;-1 - 语义映射表自检失败(映射指向了不会被扫描的通道) + * 返 回 值:0 - 成功;-1 - 语义映射表自检失败(映射指向了不会被扫描的通道); + * -2 - STM32 ADC1 的 DMA 挂载失败 * 限定条件:内核启动前调用(无 RTOS 依赖);STM32 ADC1 外设已由 MX_ADC1_Init 配好 - * 函数说明:清空原始码值缓冲与统计,并做一次映射表自洽性自检;本层不重复配置外设。 + * 函数说明:清缓冲与统计 + 映射表自洽性自检 + 挂载一次 ADC1 DMA(此后转换由 + * TIM3 TRGO 硬件触发、DMA 自动搬运)。须在启动采样节拍之前调用。 */ int adc_input_init(void); /* - * 函数功能:采集一拍(TPAFE 原子快照 + STM32 ADC1 顺序扫描) + * 函数功能:采集一拍(TPAFE 原子快照 + 消费一帧 STM32 ADC1 DMA 采样) * 入口参数:无 * 返 回 值:TPAFE 快照结果(1=有新帧 0=无新帧 -1=参数错);STM32 侧状态另见 - * adc_input_stm32_rc() + * adc_input_stm32_rc() / adc_input_stm32_seq() * 限定条件:仅**任务上下文**调用(TPAFE 快照走临界区) - * 函数说明:本函数只搬运原始码值,不做标定/运算。STM32 侧扫描失败不阻断 TPAFE - * 快照,也不影响返回值语义。 + * 函数说明:本函数只搬运原始码值,不做标定/运算;STM32 侧**零忙等**(不触发、 + * 不等待转换)。STM32 侧无新帧不阻断 TPAFE 快照,也不影响返回值语义。 + * ⚠ STM32 侧数据滞后 1 拍(乒乓双缓冲固有延迟,9 路一致)。 */ int adc_input_poll(void); @@ -103,7 +111,7 @@ const adc_channel_desc_t *adc_input_desc(adc_semantic_t ch); int adc_input_raw(adc_semantic_t ch, int32_t *out); /* - * 函数功能:读取 STM32 ADC1 最近一次扫描状态码 + * 函数功能:读取 STM32 ADC1 最近一次取帧状态码 * 入口参数:无 * 返 回 值:ADC_INPUT_RC_xxx * 限定条件:无 @@ -112,14 +120,42 @@ int adc_input_raw(adc_semantic_t ch, int32_t *out); int adc_input_stm32_rc(void); /* - * 函数功能:读取 STM32 ADC1 扫描累计失败次数 + * 函数功能:读取 STM32 ADC1 停摆故障累计次数 * 入口参数:无 - * 返 回 值:累计失败次数(启动失败或转换超时均计入) + * 返 回 值:DMA 挂载失败次数 + "连续 >=2 拍无新帧"的次数 * 限定条件:无 - * 函数说明:单调递增,供心跳日志观测。 + * 函数说明:单调递增,供心跳日志观测。单拍无新帧(消费者相位伪影)不计入。 */ uint32_t adc_input_stm32_errs(void); +/* + * 函数功能:读取 STM32 ADC1 的丢帧次数 + * 入口参数:无 + * 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被覆盖,未及消费) + * 限定条件:无 + * 函数说明:与 errs 分开——gap = 消费者超拍(单拍耗时 > 1 ms), + * err = 节拍源/ DMA 停摆。两类故障处置不同,混在一起就查不了。 + */ +uint32_t adc_input_stm32_gaps(void); + +/* + * 函数功能:读取 STM32 ADC1 的 DMA 整帧完成计数 + * 入口参数:无 + * 返 回 值:DMA 半区/整区完成中断的累计次数(正常时每拍 +1) + * 限定条件:无 + * 函数说明:与 tpafe5160_frame_seq() 对称的 STM32 侧"节拍是否还在喂数据"观测量。 + */ +uint32_t adc_input_stm32_seq(void); + +/* + * 函数功能:读取 STM32 ADC1 的 HAL 层错误回调累计次数 + * 入口参数:无 + * 返 回 值:HAL_ADC_ErrorCallback 触发次数(DMA 传输错误等) + * 限定条件:无 + * 函数说明:与 adc_input_stm32_errs() 分开计数,用于区分"节拍源停了"与"DMA 出错"。 + */ +uint32_t adc_input_stm32_hw_errs(void); + #ifdef __cplusplus } #endif diff --git a/App/app/app_main.c b/App/app/app_main.c index 39e54c47..6d66f37d 100644 --- a/App/app/app_main.c +++ b/App/app/app_main.c @@ -17,7 +17,7 @@ #include "main.h" /* ST_DIR4_GPIO_Port / ST_DIR4_Pin 等 CubeMX 宏 */ #include "stm32f4xx_it.h" /* fault_handler_enable() */ #include "usart.h" /* huart5 */ -#include "tim.h" /* htim6:1 kHz 采样节拍(TIM6 更新中断触发 CONVST) */ +#include "tim.h" /* htim3:1 kHz 采样节拍(TIM3 更新中断触发 CONVST + TRGO 触发 ADC1) */ #include "rs485.h" #include "sd2506.h" #include "sys_clock.h" @@ -92,7 +92,7 @@ static void reset_cause_str(uint32_t csr, char *buf, unsigned int len) { * 3) GD5F NAND * 4) TPAFE5160 ADC → ADC 输入层(L1,语义映射 + STM32 ADC1) * 5) SD2506 诊断信息 - * 6) 启动 TIM6 1 kHz 采样节拍(ISR 内触发 CONVST) + * 6) 启动 TIM3 1 kHz 采样节拍(ISR 内脉冲 CONVST;同一更新事件经 TRGO 硬件触发 ADC1 + DMA) */ int app_main_init(void) { /* 0) 使能 MemManage / BusFault / UsageFault(复位后默认只有 HardFault 使能)。 @@ -184,15 +184,23 @@ int app_main_init(void) { } /* 4b) ADC 输入层(L1)初始化:清原始码值缓冲与统计 + **语义映射表自洽性自检** - * (映射指向了不会被扫描的通道 → 返回 -1,避免"读到恒为陈旧值"的静默错)。 - * STM32 ADC1 外设已由 MX_ADC1_Init()(main.c)配好 9 通道 / 144 cycles / - * 软件触发,本层不重复配置、也不启动转换(软件触发按拍扫描)。 - * 须在启动采样节拍(步骤 6)之前。 */ - if (adc_input_init() != 0) { - DBG_ERROR("ADC input layer: map self-check FAILED"); - syslog_post(SYSLOG_SELFTEST, "ADC map fault"); - } else { - DBG_INFO("ADC input layer init: OK"); + * (映射指向了不会被扫描的通道 → 返回 -1,避免"读到恒为陈旧值"的静默错) + * + 挂载一次 STM32 ADC1 的 DMA(失败返回 -2)。 + * ADC1 外设已由 MX_ADC1_Init()(main.c)配好 9 通道 / 144 cycles / + * **TIM3 TRGO 外部触发 + DMA 连续请求**,本层不重复配置、也不触发转换。 + * 须在启动采样节拍(步骤 6)之前——先挂 DMA 再放节拍,才不会漏第一拍。 */ + { + int adc_rc = adc_input_init(); + + if (adc_rc == -1) { + DBG_ERROR("ADC input layer: map self-check FAILED"); + syslog_post(SYSLOG_SELFTEST, "ADC map fault"); + } else if (adc_rc != 0) { + DBG_ERROR("ADC input layer: DMA arm FAILED (rc=%d)", adc_rc); + syslog_post(SYSLOG_SELFTEST, "ADC dma fault"); + } else { + DBG_INFO("ADC input layer init: OK"); + } } /* 4c) 相量层(L2)初始化:清双区结果与有效标志 + 复位采样环缓冲。 @@ -231,10 +239,15 @@ int app_main_init(void) { sd_vbat / 1000, (sd_vbat % 1000) / 10); } - /* 6) 启动 1 kHz 采样节拍:TIM6 更新中断 → ISR 内触发 CONVST(Src/main.c)。 + /* 6) 启动 1 kHz 采样节拍:TIM3 更新事件 → ① ISR 内脉冲 CONVST(Src/main.c) + * ② 同一事件经 TRGO 硬件触发 STM32 ADC1 一轮 9 通道转换(DMA 自动搬运)。 * ⚠ 必须在 tpafe5160_init() 成功之后启动,否则 ISR 会在驱动首帧自检 - * (E-44 有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。 */ - (void)HAL_TIM_Base_Start_IT(&htim6); + * (E-44 有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。 + * ⚠ 节拍源 v2.0 由 TIM6 改为 **TIM3**:F4 上 ADC1 的外部触发只认 + * TIM1/TIM2/TIM3/TIM4/TIM5/TIM8 的 CC 或 TRGO,**TIM6/TIM7 的 TRGO 只接 + * DAC**(查 CubeMX DB 与本仓库 stm32f4xx_hal_adc.h 的 EXTSEL 表证实) + * → 要让两片转换器由同一个硬件事件驱动,节拍源必须换成 TIM3。 */ + (void)HAL_TIM_Base_Start_IT(&htim3); DBG_INFO("App init OK"); return 0; diff --git a/App/task/README.md b/App/task/README.md index 2cd61068..35cbad14 100644 --- a/App/task/README.md +++ b/App/task/README.md @@ -10,7 +10,7 @@ | `default_task.c/h` | defaultTask | Normal | FTL + FatFS 挂载、配置引擎属主、syslog 唯一写者、LED 指示 | | `net_task.c/h` | netTask | Normal | `net_stack_bring_up` → 置 NET_READY → 循环 `net_poll` + `net_process_messages` | | `ui_task.c/h` | uiTask | BelowNormal | LVGL 界面(菜单 / 内容页 / 待机)+ 推流 | -| `adc_task.c/h` | (ADC 任务) | — | ADC 采集消费:TPAFE5160 快照 + STM32 ADC1 顺序扫描,经 [`../algo/adc_input.c`](../algo/README.md) **按语义**取数(采样节拍由 TIM6 硬件触发) | +| `adc_task.c/h` | adcTask | AboveNormal | ADC 采集消费:TPAFE5160 快照 + 消费 STM32 ADC1 DMA 乒乓缓冲一帧,经 [`../algo/adc_input.c`](../algo/README.md) **按语义**取数(采样节拍由 TIM3 硬件触发) | | `rs485_task.c/h` | (RS-485 任务) | — | 半双工 RS-485 协议通信 | | `../sync/time_sync.c` | timeSyncTask | Normal | 墙钟时间同步(实现不在本目录,见 [`../sync/README.md`](../sync/README.md)) | diff --git a/App/task/adc_task.c b/App/task/adc_task.c index 03211c12..535a6d18 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -1,17 +1,17 @@ /* * 模块名称:ADC Sampling Task - * 模块功能:ADC 采集消费 + L2 相量计算——TPAFE5160 快照 + STM32 ADC1 顺序扫描 + * 模块功能:ADC 采集消费 + L2 相量计算——TPAFE5160 快照 + STM32 ADC1 DMA 乒乓帧消费 * (经 L1 层 App/algo/adc_input.c 按语义取数)→ 压入采样环 * (App/algo/sample_ring.c)→ 全周傅氏相量重算(App/algo/phasor.c) - * + 帧统计(节拍由 TIM6 硬件定时器提供) + * + 帧统计(节拍由 TIM3 硬件定时器提供) * 状态说明:**B1 已落地(2026-10-10)**——本任务从空桩转为真实消费者: * 用 tpafe5160_snapshot() 原子取数(AGENTS 铁律),统计帧数/无新帧次数, * 并纳入看门狗 required_mask(TASK_WD_ADC)。 - * **采样节拍(v2.2,2026-10-10)**:由 **TIM6 更新中断(1 kHz)** 硬件触发—— + * **采样节拍(v2.2,2026-10-10)**:由 **TIM3 更新中断(1 kHz)** 硬件触发—— * ISR 内调 tpafe5160_start_conv_irq()(Src/main.c 的 * HAL_TIM_PeriodElapsedCallback);本任务**不再自行触发**,退化为**纯消费者**。 * **消费唤醒(v2.7,2026-10-10,Task #32 P1-5 方案 a)**:由 1 ms `osDelayUntil` - * 轮询改为 **TIM6 ISR 置位 `ADC_WAKE_FLAG` 的事件驱动**——唤醒与生产同源锁定, + * 轮询改为 **TIM3 ISR 置位 `ADC_WAKE_FLAG` 的事件驱动**——唤醒与生产同源锁定, * 消除 0~1 ms 的 tick 量化相位抖动与"超拍永久落后 → 空转"风险;本任务优先级 * 同步由 Normal 提到 **AboveNormal**(唤醒即抢占)。详见 * docs/子系统设计/ADC采样节拍设计说明.md §10。 @@ -35,7 +35,7 @@ * 为真后落日志。写者未注册时控制台走无锁直发,与 * defaultTask 的存储初始化日志字节级交错(台架实测); * 就绪前挂起上报,就绪后补报最近一帧。 - * v2.2 2026-10-10 王建锋 采样节拍改由 TIM6 更新中断(1 kHz)硬件触发;本任务 + * v2.2 2026-10-10 王建锋 采样节拍改由 TIM3 更新中断(1 kHz)硬件触发;本任务 * 删除自行触发,退化为纯消费者(见文件头"采样节拍")。 * v2.3 2026-10-10 王建锋 L1-3/L1-4:取数改走 App/algo/adc_input.c(语义映射 + * STM32 ADC1 9 通道扫描);首帧日志补全 8 路电气量 + 温度, @@ -59,12 +59,17 @@ * (原写法会永久落后 → 不再阻塞 → 空转抢占 uiTask) * v2.7 2026-10-10 王建锋 Task #32 P1-5 方案 a(事件驱动消费): * 消费唤醒 osDelayUntil(1ms) → osThreadFlagsWait(ADC_WAKE_FLAG, - * osWaitForever),标志由 TIM6 ISR 经 adc_tick_irq_notify() 置位; + * osWaitForever),标志由 TIM3 ISR 经 adc_tick_irq_notify() 置位; * 删除 ADC_TASK_BEAT_MS 与 next(P0-2 的重同步因此不再需要); - * 新增"消费者就绪"门 s_consumer_ready——TIM6 在 app_main_init() + * 新增"消费者就绪"门 s_consumer_ready——TIM3 在 app_main_init() * 就启动,**远早于** MX_FREERTOS_Init()/osKernelStart(), * ISR 不得在此之前对内核动手(同 Src/main.c 里 TIM7 的 * 2026-10-02 实测坑)。优先级 Normal → AboveNormal(见 freertos.c) + * v2.8 2026-10-10 王建锋 节拍源 TIM6 → **TIM3**(F4 上 ADC1 的外部触发没有 + * TIM6 TRGO,只有 TIM2/TIM3/TIM8 系 → 换 TIM3 才能让 + * 两片转换器由同一硬件事件驱动);STM32 侧改 DMA 双缓冲 + * 消费,adc_input_poll() 不再忙等;心跳日志增 + * adc1_seq / adc1_hw(adc1_err 语义改为"本拍无新帧") */ #include "adc_task.h" @@ -82,7 +87,7 @@ /* 心跳日志周期(ms):周期性上报帧统计,作为采集链路存活的观测面。 * (原 ADC_TASK_BEAT_MS = 1U 的消费轮询周期已随 v2.7 事件驱动改造删除—— - * 消费节拍改由 TIM6 ISR 置位 ADC_WAKE_FLAG 提供,与生产同源,不再自定周期) */ + * 消费节拍改由 TIM3 ISR 置位 ADC_WAKE_FLAG 提供,与生产同源,不再自定周期) */ #define ADC_TASK_REPORT_MS 10000U /* 统计:已消费帧数 / 无新帧次数(首拍必为无新帧,之后应稳定为 0 增量)。 @@ -96,7 +101,7 @@ static uint32_t s_frames_skip; extern osThreadId_t adcTaskHandle; /* 消费者就绪门:由 adcTask 自己在进入等待前置 1;单写者 = 本任务,ISR 只读。 - * ⚠ 为什么必须有这道门:TIM6 在 app_main_init()(Src/main.c:140)里就 + * ⚠ 为什么必须有这道门:TIM3 在 app_main_init()(Src/main.c:140)里就 * HAL_TIM_Base_Start_IT 启动,**远早于** osKernelInitialize()/MX_FREERTOS_Init()/ * osKernelStart()(Src/main.c:144/145/148)→ "节拍源已在跑"与"消费者已存在"之间有 * 约 100 ms 窗口,其间 adcTaskHandle 还是 NULL、调度器也还没起。 @@ -108,10 +113,10 @@ extern osThreadId_t adcTaskHandle; static volatile uint8_t s_consumer_ready; /* - * 函数功能:TIM6 采样节拍的 ISR 入口——置位唤醒 adcTask 消费一拍 + * 函数功能:TIM3 采样节拍的 ISR 入口——置位唤醒 adcTask 消费一拍 * 入口参数:无 * 返 回 值:无 - * 限定条件:仅 ISR 上下文。TIM6 抢占优先级 = 6 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY + * 限定条件:仅 ISR 上下文。TIM3 抢占优先级 = 6 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY * (=5) → osThreadFlagsSet 走 xTaskNotifyFromISR + portYIELD_FROM_ISR,合法且立即切换 * 函数说明:Task #32 P1-5 方案 a——ISR **只发唤醒**,全部计算留在任务里(拒绝"ISR 内计算": * adc_input_poll() 内含 2 ms 忙等,绝不可入 ISR)。线程标志粘滞 → 单拍偶超 1 ms 时 @@ -170,12 +175,12 @@ int adc_task_init(void) { * 入口参数:arg - FreeRTOS 传入参数(未使用) * 返 回 值:无 * 限定条件:adc_task_init() 已调用;内核已启动 - * 函数说明:每拍采集一次(adc_input_poll():TPAFE 原子快照 + STM32 ADC1 9 通道顺序扫描) - * 并统计。采样节拍由 TIM6 硬件定时器提供(ISR 内触发 CONVST),本任务不再触发; - * **本任务的唤醒同样由该 TIM6 ISR 置位 ADC_WAKE_FLAG 驱动**(v2.7 事件驱动, + * 函数说明:每拍采集一次(adc_input_poll():TPAFE 原子快照 + 消费一帧 STM32 ADC1 DMA 乒乓缓冲) + * 并统计。采样节拍由 TIM3 硬件定时器提供(ISR 内触发 CONVST),本任务不再触发; + * **本任务的唤醒同样由该 TIM3 ISR 置位 ADC_WAKE_FLAG 驱动**(v2.7 事件驱动, * 不再自定 1 ms 周期)。稳态下 consumed 每拍 +1、idle 不再增长;idle 持续增长 = - * EXTI 未在读帧或 TIM6 未启动(异常),由周期心跳日志暴露。 - * ⚠ 若 TIM6 停摆,本任务会**永久阻塞**在 osThreadFlagsWait → TASK_WD_ADC 缺席 → + * EXTI 未在读帧或 TIM3 未启动(异常),由周期心跳日志暴露。 + * ⚠ 若 TIM3 停摆,本任务会**永久阻塞**在 osThreadFlagsWait → TASK_WD_ADC 缺席 → * IWDG 复位。这是刻意的 fail-safe("节拍源缺失"不被静默放过),不是缺陷。 */ void adc_task_func(void *arg) { @@ -197,12 +202,13 @@ void adc_task_func(void *arg) { * (osPriorityBelowNormal),是"UI 心跳缺席"的候选占用者之一 */ task_prof_enter(TASK_PROF_ADC_BEAT); - /* 1) 采集一拍:L1 层 adc_input_poll() = TPAFE 原子快照 + STM32 ADC1 顺序扫描 - * (仅任务上下文;TPAFE 侧 1=有新帧 0=无新帧 -1=参数错)。 - * 采样节拍由 TIM6 更新中断提供(ISR 内触发 CONVST),本任务不触发。 - * P2 探针:本段单独计时。实测常态 adcp = 122 us(9 通道 x 7.4 us + TPAFE 快照); - * 只有 STM32 侧超时才跳到 ~2.2 ms——HAL_ADC_PollForConversion 是 2 ms 不让出的忙等, - * 而 stm32_scan() 在**首个**超时处即 Stop+return,故单拍上界是**一次**超时(不是 9 次)。 + /* 1) 采集一拍:L1 层 adc_input_poll() = TPAFE 原子快照 + 消费一帧 STM32 ADC1 + * DMA 双缓冲(仅任务上下文;TPAFE 侧 1=有新帧 0=无新帧 -1=参数错)。 + * 采样节拍由 TIM3 更新事件提供(ISR 内触发 CONVST + TRGO 触发 ADC1), + * 本任务既不触发也不等待转换。 + * P2 探针:本段单独计时。v2.0(DMA)起本段**零忙等**——原轮询版常态 + * adcp=122 us、超时拍最坏 2~3 ms(HAL_ADC_PollForConversion 是不让出的 + * 忙等),现在两档都降到 us 量级(只剩 TPAFE 快照临界区 + 拷 9 个半字)。 * ⚠ adcm(整拍)的 ~2.1 ms 峰值来自每 10 s 一次的心跳日志,不是本段,勿混读 */ task_prof_enter(TASK_PROF_ADC_POLL); r = adc_input_poll(); @@ -301,15 +307,27 @@ void adc_task_func(void *arg) { if ((int32_t)(now - report_at) >= 0) { if (dbg_console_writer_ready() != 0u) { - DBG_INFO("frames=%lu idle=%lu skip=%lu seq=%lu frst_err=%u adc1_rc=%d adc1_err=%lu ring=%lu", + /* STM32 侧观测量(v2.0 DMA 版),四类各有独立成因: + * adc1_rc = 最近一拍取帧状态(0 OK / -2 本拍无新帧) + * adc1_seq = DMA 整帧完成计数(正常时 ≈ seq,恒定小偏移) + * adc1_gap = 丢帧:seq 一次前进 >1 → 本任务超拍(单拍 > 1 ms) + * adc1_err = 停摆:连续 >=2 拍无新帧 → 节拍源/DMA 真停 + * adc1_hw = HAL ADC/DMA 错误回调 + * 判读:adc1_seq 停增而 seq 仍涨 → STM32 侧停摆; + * adc1_gap 增长 → 本任务耗时超拍(看 adcm/adcn) */ + DBG_INFO("frames=%lu idle=%lu skip=%lu seq=%lu frst_err=%u ring=%lu" + " adc1_rc=%d adc1_err=%lu adc1_seq=%lu adc1_gap=%lu adc1_hw=%lu", (unsigned long)s_frames_consumed, (unsigned long)s_frames_idle, (unsigned long)s_frames_skip, (unsigned long)tpafe5160_frame_seq(), (unsigned)tpafe5160_frstdata_err(), + (unsigned long)sample_ring_frames(), adc_input_stm32_rc(), (unsigned long)adc_input_stm32_errs(), - (unsigned long)sample_ring_frames()); + (unsigned long)adc_input_stm32_seq(), + (unsigned long)adc_input_stm32_gaps(), + (unsigned long)adc_input_stm32_hw_errs()); log_phasors("hb"); } @@ -319,13 +337,13 @@ void adc_task_func(void *arg) { /* 7) 等下一次节拍唤醒(v2.7 事件驱动,Task #32 P1-5 方案 a) * 原实现是 osDelayUntil(next += 1ms):把消费唤醒**钉在 FreeRTOS tick(1 ms)边界** - * → 与 TIM6 生产之间有 0~1 ms 随机相位抖动(日志里的 idle 正是这么来的), + * → 与 TIM3 生产之间有 0~1 ms 随机相位抖动(日志里的 idle 正是这么来的), * 且一旦超拍,next 就永久落后 → 任务不再阻塞 → 空转抢占低优先级 uiTask - * (P0-2 修的正是它)。现在唤醒与生产**同源**:TIM6 ISR 触发 CONVST 后置位 + * (P0-2 修的正是它)。现在唤醒与生产**同源**:TIM3 ISR 触发 CONVST 后置位 * ADC_WAKE_FLAG,本任务被唤醒的延迟 = ISR + 调度(µs 级;本任务已提至 * AboveNormal → 立即抢占),无 tick 量化、无累积漂移,也就不再需要重同步钩子。 * 标志是命令位(等待即消费)→ osFlagsWaitAny,不加 osFlagsNoClear; - * osWaitForever 无超时 = 刻意的 fail-safe(TIM6 停摆 → 无心跳 → IWDG 复位)。 */ + * osWaitForever 无超时 = 刻意的 fail-safe(TIM3 停摆 → 无心跳 → IWDG 复位)。 */ task_prof_exit(TASK_PROF_ADC_BEAT); /* P2 探针:单拍耗时结算(不含等待) */ (void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever); } diff --git a/App/task/adc_task.h b/App/task/adc_task.h index 087769f9..d697cddd 100644 --- a/App/task/adc_task.h +++ b/App/task/adc_task.h @@ -8,7 +8,7 @@ * v1.0 2026-07-19 王建锋 创建初始版本 * v1.1 2026-10-10 王建锋 Task #32 P1-5 方案 a:新增 ADC_WAKE_FLAG 与 * adc_tick_irq_notify()——消费节拍由 osDelayUntil(1ms) - * 轮询改为 TIM6 ISR 事件驱动唤醒 + * 轮询改为 TIM3 ISR 事件驱动唤醒 */ #ifndef ADC_TASK_H @@ -22,12 +22,12 @@ int adc_task_init(void); void adc_task_func(void *arg); /* Task #32 P1-5(方案 a,2026-10-10):事件驱动消费。 - * TIM6 1 kHz 更新中断触发 CONVST 后,用本标志唤醒 adcTask 消费一拍。 + * TIM3 1 kHz 更新中断触发 CONVST 后,用本标志唤醒 adcTask 消费一拍。 * 线程标志在此是**命令位**(等待即消费),不是状态位 → 用 osFlagsWaitAny, * 不加 osFlagsNoClear(AGENTS「就绪屏障位必须 osFlagsNoClear」只约束状态位) */ #define ADC_WAKE_FLAG 0x0001U -/* ISR 入口:由 Src/main.c 的 HAL_TIM_PeriodElapsedCallback(TIM6 分支)调用。 +/* ISR 入口:由 Src/main.c 的 HAL_TIM_PeriodElapsedCallback(TIM3 分支)调用。 * 内部自带"消费者就绪"门与句柄判空 → 调度器启动前调用也安全(理由见 .c 注释)。 * 收在 adc_task.c 而非 main.c 内联:main.c 不碰任何内核句柄,与 task_wd_tick_probe() 同构 */ void adc_tick_irq_notify(void); diff --git a/Drivers/BSP/TPAFE5160/README.md b/Drivers/BSP/TPAFE5160/README.md index 214a9c5e..429b4760 100644 --- a/Drivers/BSP/TPAFE5160/README.md +++ b/Drivers/BSP/TPAFE5160/README.md @@ -16,11 +16,11 @@ ## 关键点 -- **周期重触发由 TIM6 更新中断(1 kHz)驱动**(2026-10-10):`app_main_init` 末尾 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍,`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM6 分支内调 `tpafe5160_start_conv_irq()`(ISR 内仅两次 GPIO 写)。B1 阶段曾由 `adcTask` 每 1 ms 软触发(已改) +- **周期重触发由 TIM3 更新事件(1 kHz)驱动**(2026-10-10):`app_main_init` 末尾 `HAL_TIM_Base_Start_IT(&htim3)` 启动节拍,`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM3 分支内调 `tpafe5160_start_conv_irq()`(ISR 内仅两次 GPIO 写)。B1 阶段曾由 `adcTask` 每 1 ms 软触发(已改)。⚠ **节拍源必须是 TIM3 而非 TIM6**:TIM3 的同一个更新事件还经 **TRGO** 硬件触发 STM32 ADC1,两片转换器由此同源对齐;F4 上 TIM6/TIM7 的 TRGO 只接 DAC,喂不到 ADC1 - **取数只用 `tpafe5160_snapshot(int16_t *dst, uint8_t count)`**(**仅任务上下文**,内部临界区原子拷贝;返回 1=新帧 / 0=无新帧 / -1=参数无效)。配套的 `tpafe5160_frame_seq()` 给帧序号、`tpafe5160_frstdata_err()` 给"FRSTDATA 无跳变"帧计数 - ⚠️ **不要用缓冲区指针取数**:旧的 `get_buf()` / `data_ready()` / `clear_ready()` 已于 2026-10-10 **删除**(审计 E-45 / E-58)——`get_buf` 只给指针、任务再逐通道拷,中间被 BUSY EXTI 抢占就拷回**两帧混合**数据;`s_ready` 电平标志在 `start_conv_irq` 里被清 0,等于**用重触发去丢帧**。现由**帧序号**(只增不清)+ **临界区快照**取代 - **`tpafe5160_init()` 做首帧有效性自检**(E-44):完整跑一帧转换+读取,若整帧读下来 FRSTDATA **没有发生任何跳变**(芯片没在驱动并行总线;板上上拉会把它钉死为高)→ 返回 `TPAFE5160_FRSTDATA_ERR`(-3) 并打 `[ERR] Init FRSTDATA no-toggle`。判据刻意不锁死"哪个 RD 边沿拉高/拉低"(手册只给"RD 下降沿 → FRSTDATA 高/低"两个跳变,本板未实测边沿次序),只要求"一帧内必须跳变" → 既抓死芯片,也不会因极性假设错误误杀好芯片 -- 采样数据的消费者 `App/task/adc_task.c` **已实现**(B1,2026-10-10:`tpafe5160_snapshot()` 原子取数 + 帧统计;`adcTask` 在 `Src/freertos.c` 的 `USER CODE` 区创建)。**v2.2(2026-10-10)**:采样节拍改由 TIM6 更新中断硬件触发,本任务退化为纯消费者 +- 采样数据的消费者 `App/task/adc_task.c` **已实现**(B1,2026-10-10:`tpafe5160_snapshot()` 原子取数 + 帧统计;`adcTask` 在 `Src/freertos.c` 的 `USER CODE` 区创建)。**v2.2(2026-10-10)**:采样节拍改由定时器更新中断硬件触发,本任务退化为纯消费者;**v2.8(2026-10-10)**:节拍源改 TIM3(同时经 TRGO 触发 ADC1 + DMA),STM32 侧改为消费 DMA 乒乓缓冲,单拍耗时 122 µs → **8 µs** - CONVST→BUSY→RD 三段时序对**建立/保持时间敏感**,改动需对照芯片手册 - ⚠️ 本项目 ADC 相关功能**新增任务/缓冲前须核算 RAM**(见 `docs/内存管理/`);接消费者前的强制门禁见根 `AGENTS.md`「接 ADC / RS-485 消费者前的强制门禁」 - ⚠️ **通道语义不是通道号**:`buf[i]` 对应哪个电气量必须查 `docs/硬件平台/ADC通道映射说明.md`(`buf[2]=IA`、`buf[5]=UN`、`buf[6]=UA`);V0.1 上 8 路电气量分属两片转换器属硬件缺陷(N-18)→ **禁止**跨转换器做相位/功率/功率因数类运算 diff --git a/Inc/stm32f4xx_it.h b/Inc/stm32f4xx_it.h index be166e09..b78f4263 100644 --- a/Inc/stm32f4xx_it.h +++ b/Inc/stm32f4xx_it.h @@ -55,6 +55,7 @@ void DebugMon_Handler(void); void DMA1_Stream3_IRQHandler(void); void DMA1_Stream4_IRQHandler(void); void EXTI9_5_IRQHandler(void); +void TIM3_IRQHandler(void); void SPI1_IRQHandler(void); void SPI2_IRQHandler(void); void USART1_IRQHandler(void); @@ -62,11 +63,11 @@ void USART2_IRQHandler(void); void USART3_IRQHandler(void); void UART4_IRQHandler(void); void UART5_IRQHandler(void); -void TIM6_DAC_IRQHandler(void); void TIM7_IRQHandler(void); void DMA2_Stream0_IRQHandler(void); void DMA2_Stream2_IRQHandler(void); void DMA2_Stream3_IRQHandler(void); +void DMA2_Stream4_IRQHandler(void); void DMA2_Stream7_IRQHandler(void); /* USER CODE BEGIN EFP */ diff --git a/Inc/tim.h b/Inc/tim.h index 7c1769f6..a3c3067f 100644 --- a/Inc/tim.h +++ b/Inc/tim.h @@ -32,13 +32,13 @@ extern "C" { /* USER CODE END Includes */ -extern TIM_HandleTypeDef htim6; +extern TIM_HandleTypeDef htim3; /* USER CODE BEGIN Private defines */ /* USER CODE END Private defines */ -void MX_TIM6_Init(void); +void MX_TIM3_Init(void); /* USER CODE BEGIN Prototypes */ diff --git a/README.md b/README.md index 89dca52f..4c094eca 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ main.c | testTask | 2048 words | BelowNormal | 硬件测试入口 | | uiTask | 4096 words | BelowNormal | LVGL UI 界面 | | timeSyncTask | 512 words | Normal | 墙钟时间同步 | -| adcTask | 512 words | AboveNormal | TPAFE5160 采样(TIM6 1kHz 事件驱动 + 原子快照) | +| adcTask | 512 words | AboveNormal | TPAFE5160 采样(TIM3 1kHz 事件驱动 + 原子快照 + STM32 ADC DMA) | > 上表与 `Src/freertos.c` 的 `osThreadAttr_t` 一致(2026-09-25 校准);改动栈大小前先读 diff --git a/STM32F407-Demo.ioc b/STM32F407-Demo.ioc index 34a59a7c..cd1ccfc6 100644 --- a/STM32F407-Demo.ioc +++ b/STM32F407-Demo.ioc @@ -9,7 +9,9 @@ ADC1.Channel-7\#ChannelRegularConversion=ADC_CHANNEL_6 ADC1.Channel-8\#ChannelRegularConversion=ADC_CHANNEL_7 ADC1.Channel-9\#ChannelRegularConversion=ADC_CHANNEL_14 ADC1.ClockPrescaler=ADC_CLOCK_SYNC_PCLK_DIV4 -ADC1.IPParameters=Rank-1\#ChannelRegularConversion,master,Channel-1\#ChannelRegularConversion,SamplingTime-1\#ChannelRegularConversion,NbrOfConversionFlag,ScanConvMode,Rank-2\#ChannelRegularConversion,Channel-2\#ChannelRegularConversion,SamplingTime-2\#ChannelRegularConversion,Rank-3\#ChannelRegularConversion,Channel-3\#ChannelRegularConversion,SamplingTime-3\#ChannelRegularConversion,Rank-4\#ChannelRegularConversion,Channel-4\#ChannelRegularConversion,SamplingTime-4\#ChannelRegularConversion,Rank-5\#ChannelRegularConversion,Channel-5\#ChannelRegularConversion,SamplingTime-5\#ChannelRegularConversion,Rank-6\#ChannelRegularConversion,Channel-6\#ChannelRegularConversion,SamplingTime-6\#ChannelRegularConversion,Rank-7\#ChannelRegularConversion,Channel-7\#ChannelRegularConversion,SamplingTime-7\#ChannelRegularConversion,Rank-8\#ChannelRegularConversion,Channel-8\#ChannelRegularConversion,SamplingTime-8\#ChannelRegularConversion,Rank-9\#ChannelRegularConversion,Channel-9\#ChannelRegularConversion,SamplingTime-9\#ChannelRegularConversion,NbrOfConversion,ClockPrescaler +ADC1.DMAContinuousRequests=ENABLE +ADC1.ExternalTrigConv=ADC_EXTERNALTRIGCONV_T3_TRGO +ADC1.IPParameters=Rank-1\#ChannelRegularConversion,master,Channel-1\#ChannelRegularConversion,SamplingTime-1\#ChannelRegularConversion,NbrOfConversionFlag,ScanConvMode,Rank-2\#ChannelRegularConversion,Channel-2\#ChannelRegularConversion,SamplingTime-2\#ChannelRegularConversion,Rank-3\#ChannelRegularConversion,Channel-3\#ChannelRegularConversion,SamplingTime-3\#ChannelRegularConversion,Rank-4\#ChannelRegularConversion,Channel-4\#ChannelRegularConversion,SamplingTime-4\#ChannelRegularConversion,Rank-5\#ChannelRegularConversion,Channel-5\#ChannelRegularConversion,SamplingTime-5\#ChannelRegularConversion,Rank-6\#ChannelRegularConversion,Channel-6\#ChannelRegularConversion,SamplingTime-6\#ChannelRegularConversion,Rank-7\#ChannelRegularConversion,Channel-7\#ChannelRegularConversion,SamplingTime-7\#ChannelRegularConversion,Rank-8\#ChannelRegularConversion,Channel-8\#ChannelRegularConversion,SamplingTime-8\#ChannelRegularConversion,Rank-9\#ChannelRegularConversion,Channel-9\#ChannelRegularConversion,SamplingTime-9\#ChannelRegularConversion,NbrOfConversion,ClockPrescaler,DMAContinuousRequests,ExternalTrigConv ADC1.NbrOfConversion=9 ADC1.NbrOfConversionFlag=1 ADC1.Rank-1\#ChannelRegularConversion=1 @@ -35,13 +37,24 @@ ADC1.master=1 CAD.formats=[] CAD.pinconfig=Dual CAD.provider=Component Search Engine +Dma.ADC1.6.Direction=DMA_PERIPH_TO_MEMORY +Dma.ADC1.6.FIFOMode=DMA_FIFOMODE_DISABLE +Dma.ADC1.6.Instance=DMA2_Stream4 +Dma.ADC1.6.MemDataAlignment=DMA_MDATAALIGN_HALFWORD +Dma.ADC1.6.MemInc=DMA_MINC_ENABLE +Dma.ADC1.6.Mode=DMA_CIRCULAR +Dma.ADC1.6.PeriphDataAlignment=DMA_PDATAALIGN_HALFWORD +Dma.ADC1.6.PeriphInc=DMA_PINC_DISABLE +Dma.ADC1.6.Priority=DMA_PRIORITY_LOW +Dma.ADC1.6.RequestParameters=Instance,Direction,PeriphInc,MemInc,PeriphDataAlignment,MemDataAlignment,Mode,Priority,FIFOMode Dma.Request0=USART1_TX Dma.Request1=USART1_RX Dma.Request2=SPI2_RX Dma.Request3=SPI2_TX Dma.Request4=SPI1_RX Dma.Request5=SPI1_TX -Dma.RequestsNb=6 +Dma.Request6=ADC1 +Dma.RequestsNb=7 Dma.SPI1_RX.4.Direction=DMA_PERIPH_TO_MEMORY Dma.SPI1_RX.4.FIFOMode=DMA_FIFOMODE_DISABLE Dma.SPI1_RX.4.Instance=DMA2_Stream0 @@ -121,7 +134,7 @@ Mcu.CPN=STM32F407ZGT6 Mcu.Family=STM32F4 Mcu.IP0=ADC1 Mcu.IP1=DMA -Mcu.IP10=TIM6 +Mcu.IP10=TIM3 Mcu.IP11=UART4 Mcu.IP12=UART5 Mcu.IP13=USART1 @@ -204,7 +217,7 @@ Mcu.Pin65=PE1 Mcu.Pin66=VP_FREERTOS_VS_CMSIS_V2 Mcu.Pin67=VP_IWDG_VS_IWDG Mcu.Pin68=VP_SYS_VS_tim7 -Mcu.Pin69=VP_TIM6_VS_ClockSourceINT +Mcu.Pin69=VP_TIM3_VS_ClockSourceINT Mcu.Pin7=PA4 Mcu.Pin8=PA5 Mcu.Pin9=PA6 @@ -217,9 +230,10 @@ MxDb.Version=DB.6.0.181 NVIC.BusFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false\:false\:false NVIC.DMA1_Stream3_IRQn=true\:7\:0\:true\:false\:true\:true\:false\:true\:true NVIC.DMA1_Stream4_IRQn=true\:7\:0\:true\:false\:true\:true\:false\:true\:true -NVIC.DMA2_Stream0_IRQn=true\:7\:0\:true\:false\:true\:true\:false\:true\:true -NVIC.DMA2_Stream2_IRQn=true\:9\:0\:true\:false\:true\:true\:false\:true\:true +NVIC.DMA2_Stream0_IRQn=true\:7\:0\:true\:false\:true\:false\:false\:true\:true +NVIC.DMA2_Stream2_IRQn=true\:9\:0\:true\:false\:true\:false\:false\:true\:true NVIC.DMA2_Stream3_IRQn=true\:7\:0\:true\:false\:true\:true\:false\:true\:true +NVIC.DMA2_Stream4_IRQn=true\:6\:0\:true\:false\:true\:false\:false\:true\:true NVIC.DMA2_Stream7_IRQn=true\:9\:0\:true\:false\:true\:true\:false\:true\:true NVIC.DebugMonitor_IRQn=true\:0\:0\:false\:false\:true\:false\:false\:false\:false NVIC.EXTI9_5_IRQn=true\:6\:0\:true\:false\:true\:true\:true\:true\:true @@ -236,7 +250,7 @@ NVIC.SavedPendsvIrqHandlerGenerated=true NVIC.SavedSvcallIrqHandlerGenerated=true NVIC.SavedSystickIrqHandlerGenerated=true NVIC.SysTick_IRQn=true\:15\:0\:false\:false\:false\:true\:false\:true\:false -NVIC.TIM6_DAC_IRQn=true\:6\:0\:true\:false\:true\:true\:true\:true\:true +NVIC.TIM3_IRQn=true\:6\:0\:true\:false\:true\:true\:true\:true\:true NVIC.TIM7_IRQn=true\:15\:0\:true\:false\:true\:false\:false\:true\:true NVIC.TimeBase=TIM7_IRQn NVIC.TimeBaseIP=TIM7 @@ -604,7 +618,7 @@ ProjectManager.ToolChainLocation= ProjectManager.UAScriptAfterPath= ProjectManager.UAScriptBeforePath= ProjectManager.UnderRoot=false -ProjectManager.functionlistsort=1-SystemClock_Config-RCC-false-HAL-false,2-MX_GPIO_Init-GPIO-false-HAL-true,3-MX_DMA_Init-DMA-false-HAL-true,4-MX_USART1_UART_Init-USART1-false-HAL-true,5-MX_SPI2_Init-SPI2-false-HAL-true,6-MX_SPI1_Init-SPI1-false-HAL-true,7-MX_I2C1_Init-I2C1-false-HAL-true,8-MX_UART4_Init-UART4-false-HAL-true,9-MX_UART5_Init-UART5-false-HAL-true,10-MX_USART2_UART_Init-USART2-false-HAL-true,11-MX_USART3_UART_Init-USART3-false-HAL-true,12-MX_IWDG_Init-IWDG-false-HAL-true,13-MX_ADC1_Init-ADC1-false-HAL-true +ProjectManager.functionlistsort=1-SystemClock_Config-RCC-false-HAL-false,2-MX_GPIO_Init-GPIO-false-HAL-true,3-MX_DMA_Init-DMA-false-HAL-true,4-MX_USART1_UART_Init-USART1-false-HAL-true,5-MX_SPI2_Init-SPI2-false-HAL-true,6-MX_SPI1_Init-SPI1-false-HAL-true,7-MX_I2C1_Init-I2C1-false-HAL-true,8-MX_UART4_Init-UART4-false-HAL-true,9-MX_UART5_Init-UART5-false-HAL-true,10-MX_USART2_UART_Init-USART2-false-HAL-true,11-MX_USART3_UART_Init-USART3-false-HAL-true,12-MX_IWDG_Init-IWDG-false-HAL-true,13-MX_ADC1_Init-ADC1-false-HAL-true,14-MX_TIM3_Init-TIM3-false-HAL-true RCC.48MHZClocksFreq_Value=84000000 RCC.AHBFreq_Value=168000000 RCC.APB1CLKDivider=RCC_HCLK_DIV4 @@ -670,10 +684,12 @@ SPI2.Direction=SPI_DIRECTION_2LINES SPI2.IPParameters=VirtualType,Mode,Direction,CalculateBaudRate,BaudRatePrescaler SPI2.Mode=SPI_MODE_MASTER SPI2.VirtualType=VM_MASTER -TIM6.AutoReloadPreload=TIM_AUTORELOAD_PRELOAD_ENABLE -TIM6.IPParameters=Prescaler,Period,AutoReloadPreload -TIM6.Period=999 -TIM6.Prescaler=83 +TIM3.AutoReloadPreload=TIM_AUTORELOAD_PRELOAD_ENABLE +TIM3.IPParameters=Prescaler,Period,AutoReloadPreload,TIM_MasterSlaveMode,TIM_MasterOutputTrigger +TIM3.Period=999 +TIM3.Prescaler=83 +TIM3.TIM_MasterOutputTrigger=TIM_TRGO_UPDATE +TIM3.TIM_MasterSlaveMode=TIM_MASTERSLAVEMODE_ENABLE UART4.IPParameters=VirtualMode UART4.VirtualMode=Asynchronous UART5.IPParameters=VirtualMode @@ -690,7 +706,7 @@ VP_IWDG_VS_IWDG.Mode=IWDG_Activate VP_IWDG_VS_IWDG.Signal=IWDG_VS_IWDG VP_SYS_VS_tim7.Mode=TIM7 VP_SYS_VS_tim7.Signal=SYS_VS_tim7 -VP_TIM6_VS_ClockSourceINT.Mode=Enable_Timer -VP_TIM6_VS_ClockSourceINT.Signal=TIM6_VS_ClockSourceINT +VP_TIM3_VS_ClockSourceINT.Mode=Internal +VP_TIM3_VS_ClockSourceINT.Signal=TIM3_VS_ClockSourceINT board=custom rtos.0.ip=FREERTOS diff --git a/Src/adc.c b/Src/adc.c index 59bd5e39..97621a1a 100644 --- a/Src/adc.c +++ b/Src/adc.c @@ -25,6 +25,7 @@ /* USER CODE END 0 */ ADC_HandleTypeDef hadc1; +DMA_HandleTypeDef hdma_adc1; /* ADC1 init function */ void MX_ADC1_Init(void) @@ -48,11 +49,11 @@ void MX_ADC1_Init(void) hadc1.Init.ScanConvMode = ENABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; - hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE; - hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; + hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; + hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T3_TRGO; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 9; - hadc1.Init.DMAContinuousRequests = DISABLE; + hadc1.Init.DMAContinuousRequests = ENABLE; hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; if (HAL_ADC_Init(&hadc1) != HAL_OK) { @@ -190,6 +191,25 @@ void HAL_ADC_MspInit(ADC_HandleTypeDef* adcHandle) GPIO_InitStruct.Pull = GPIO_NOPULL; HAL_GPIO_Init(ADC17_UB_GPIO_Port, &GPIO_InitStruct); + /* ADC1 DMA Init */ + /* ADC1 Init */ + hdma_adc1.Instance = DMA2_Stream4; + hdma_adc1.Init.Channel = DMA_CHANNEL_0; + hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; + hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; + hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; + hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; + hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; + hdma_adc1.Init.Mode = DMA_CIRCULAR; + hdma_adc1.Init.Priority = DMA_PRIORITY_LOW; + hdma_adc1.Init.FIFOMode = DMA_FIFOMODE_DISABLE; + if (HAL_DMA_Init(&hdma_adc1) != HAL_OK) + { + Error_Handler(); + } + + __HAL_LINKDMA(adcHandle,DMA_Handle,hdma_adc1); + /* USER CODE BEGIN ADC1_MspInit 1 */ /* USER CODE END ADC1_MspInit 1 */ @@ -223,6 +243,8 @@ void HAL_ADC_MspDeInit(ADC_HandleTypeDef* adcHandle) HAL_GPIO_DeInit(ADC17_UB_GPIO_Port, ADC17_UB_Pin); + /* ADC1 DMA DeInit */ + HAL_DMA_DeInit(adcHandle->DMA_Handle); /* USER CODE BEGIN ADC1_MspDeInit 1 */ /* USER CODE END ADC1_MspDeInit 1 */ diff --git a/Src/dma.c b/Src/dma.c index 9b224df3..0404dcef 100644 --- a/Src/dma.c +++ b/Src/dma.c @@ -59,6 +59,9 @@ void MX_DMA_Init(void) /* DMA2_Stream3_IRQn interrupt configuration */ HAL_NVIC_SetPriority(DMA2_Stream3_IRQn, 7, 0); HAL_NVIC_EnableIRQ(DMA2_Stream3_IRQn); + /* DMA2_Stream4_IRQn interrupt configuration */ + HAL_NVIC_SetPriority(DMA2_Stream4_IRQn, 6, 0); + HAL_NVIC_EnableIRQ(DMA2_Stream4_IRQn); /* DMA2_Stream7_IRQn interrupt configuration */ HAL_NVIC_SetPriority(DMA2_Stream7_IRQn, 9, 0); HAL_NVIC_EnableIRQ(DMA2_Stream7_IRQn); diff --git a/Src/main.c b/Src/main.c index feb9b3f3..f7b95d54 100644 --- a/Src/main.c +++ b/Src/main.c @@ -133,7 +133,7 @@ int main(void) MX_USART3_UART_Init(); MX_IWDG_Init(); MX_ADC1_Init(); - MX_TIM6_Init(); + MX_TIM3_Init(); /* USER CODE BEGIN 2 */ HAL_Delay(100); @@ -247,15 +247,19 @@ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) task_wd_tick_probe(); } - /* 1 kHz 采样节拍:TIM6 更新中断内脉冲 CONVST(仅两次 GPIO 写,亚 µs), + /* 1 kHz 采样节拍:TIM3 更新事件内脉冲 CONVST(仅两次 GPIO 写,亚 µs), * 再唤醒 adcTask 消费这一拍(Task #32 P1-5 方案 a:ISR 只发唤醒,计算全留在任务)。 - * TIM6 与 BUSY EXTI(EXTI9_5_IRQn) 同为抢占优先级 6 → 互不抢占, + * 同一个更新事件还经 TRGO 硬件触发 STM32 ADC1 一轮 9 通道转换(DMA 自动搬运) + * → 两片转换器由同一硬件事件驱动,采样时刻对齐。 + * TIM3 与 BUSY EXTI(EXTI9_5_IRQn) 同为抢占优先级 6 → 互不抢占, * 读 RD 时序不会被下一次 CONVST 脉冲打断。详见 * docs/子系统设计/ADC采样节拍设计说明.md §10。放 USER CODE 区(L2)。 + * ⚠ 节拍源 v2.0 由 TIM6 改为 TIM3:F4 上 ADC1 的外部触发清单里没有 TIM6 TRGO + * (TIM6/TIM7 的 TRGO 只接 DAC),详见 App/app/app_main.c 步骤 6 注释。 * ⚠ 唤醒必须走 adc_tick_irq_notify(),**不要**在这里直接 osThreadFlagsSet: - * TIM6 在 app_main_init() 就启动,远早于建任务与 osKernelStart, + * TIM3 在 app_main_init() 就启动,远早于建任务与 osKernelStart, * "消费者就绪"门在 adc_task.c 里(同本文件 TIM7 分支 2026-10-02 的实测坑)。 */ - if (htim->Instance == TIM6) + if (htim->Instance == TIM3) { tpafe5160_start_conv_irq(); adc_tick_irq_notify(); diff --git a/Src/stm32f4xx_it.c b/Src/stm32f4xx_it.c index bb0d0ac4..f898764c 100644 --- a/Src/stm32f4xx_it.c +++ b/Src/stm32f4xx_it.c @@ -238,13 +238,14 @@ __asm void UsageFault_Handler(void) /* USER CODE END 0 */ /* External variables --------------------------------------------------------*/ +extern DMA_HandleTypeDef hdma_adc1; extern DMA_HandleTypeDef hdma_spi1_rx; extern DMA_HandleTypeDef hdma_spi1_tx; extern DMA_HandleTypeDef hdma_spi2_rx; extern DMA_HandleTypeDef hdma_spi2_tx; extern SPI_HandleTypeDef hspi1; extern SPI_HandleTypeDef hspi2; -extern TIM_HandleTypeDef htim6; +extern TIM_HandleTypeDef htim3; extern DMA_HandleTypeDef hdma_usart1_tx; extern DMA_HandleTypeDef hdma_usart1_rx; extern UART_HandleTypeDef huart4; @@ -398,6 +399,20 @@ void EXTI9_5_IRQHandler(void) /* USER CODE END EXTI9_5_IRQn 1 */ } +/** + * @brief This function handles TIM3 global interrupt. + */ +void TIM3_IRQHandler(void) +{ + /* USER CODE BEGIN TIM3_IRQn 0 */ + + /* USER CODE END TIM3_IRQn 0 */ + HAL_TIM_IRQHandler(&htim3); + /* USER CODE BEGIN TIM3_IRQn 1 */ + + /* USER CODE END TIM3_IRQn 1 */ +} + /** * @brief This function handles SPI1 global interrupt. */ @@ -496,20 +511,6 @@ void UART5_IRQHandler(void) /* USER CODE END UART5_IRQn 1 */ } -/** - * @brief This function handles TIM6 global interrupt, DAC1 and DAC2 underrun error interrupts. - */ -void TIM6_DAC_IRQHandler(void) -{ - /* USER CODE BEGIN TIM6_DAC_IRQn 0 */ - - /* USER CODE END TIM6_DAC_IRQn 0 */ - HAL_TIM_IRQHandler(&htim6); - /* USER CODE BEGIN TIM6_DAC_IRQn 1 */ - - /* USER CODE END TIM6_DAC_IRQn 1 */ -} - /** * @brief This function handles TIM7 global interrupt. */ @@ -566,6 +567,20 @@ void DMA2_Stream3_IRQHandler(void) /* USER CODE END DMA2_Stream3_IRQn 1 */ } +/** + * @brief This function handles DMA2 stream4 global interrupt. + */ +void DMA2_Stream4_IRQHandler(void) +{ + /* USER CODE BEGIN DMA2_Stream4_IRQn 0 */ + + /* USER CODE END DMA2_Stream4_IRQn 0 */ + HAL_DMA_IRQHandler(&hdma_adc1); + /* USER CODE BEGIN DMA2_Stream4_IRQn 1 */ + + /* USER CODE END DMA2_Stream4_IRQn 1 */ +} + /** * @brief This function handles DMA2 stream7 global interrupt. */ diff --git a/Src/tim.c b/Src/tim.c index aa07ef25..1ebeb097 100644 --- a/Src/tim.c +++ b/Src/tim.c @@ -24,78 +24,85 @@ /* USER CODE END 0 */ -TIM_HandleTypeDef htim6; +TIM_HandleTypeDef htim3; -/* TIM6 init function */ -void MX_TIM6_Init(void) +/* TIM3 init function */ +void MX_TIM3_Init(void) { - /* USER CODE BEGIN TIM6_Init 0 */ + /* USER CODE BEGIN TIM3_Init 0 */ - /* USER CODE END TIM6_Init 0 */ + /* USER CODE END TIM3_Init 0 */ + TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; - /* USER CODE BEGIN TIM6_Init 1 */ + /* USER CODE BEGIN TIM3_Init 1 */ - /* USER CODE END TIM6_Init 1 */ - htim6.Instance = TIM6; - htim6.Init.Prescaler = 83; - htim6.Init.CounterMode = TIM_COUNTERMODE_UP; - htim6.Init.Period = 999; - htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; - if (HAL_TIM_Base_Init(&htim6) != HAL_OK) + /* USER CODE END TIM3_Init 1 */ + htim3.Instance = TIM3; + htim3.Init.Prescaler = 83; + htim3.Init.CounterMode = TIM_COUNTERMODE_UP; + htim3.Init.Period = 999; + htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; + htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; + if (HAL_TIM_Base_Init(&htim3) != HAL_OK) { Error_Handler(); } - sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; - sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; - if (HAL_TIMEx_MasterConfigSynchronization(&htim6, &sMasterConfig) != HAL_OK) + sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; + if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } - /* USER CODE BEGIN TIM6_Init 2 */ + sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; + sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_ENABLE; + if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK) + { + Error_Handler(); + } + /* USER CODE BEGIN TIM3_Init 2 */ - /* USER CODE END TIM6_Init 2 */ + /* USER CODE END TIM3_Init 2 */ } void HAL_TIM_Base_MspInit(TIM_HandleTypeDef* tim_baseHandle) { - if(tim_baseHandle->Instance==TIM6) + if(tim_baseHandle->Instance==TIM3) { - /* USER CODE BEGIN TIM6_MspInit 0 */ + /* USER CODE BEGIN TIM3_MspInit 0 */ - /* USER CODE END TIM6_MspInit 0 */ - /* TIM6 clock enable */ - __HAL_RCC_TIM6_CLK_ENABLE(); + /* USER CODE END TIM3_MspInit 0 */ + /* TIM3 clock enable */ + __HAL_RCC_TIM3_CLK_ENABLE(); - /* TIM6 interrupt Init */ - HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 6, 0); - HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn); - /* USER CODE BEGIN TIM6_MspInit 1 */ + /* TIM3 interrupt Init */ + HAL_NVIC_SetPriority(TIM3_IRQn, 6, 0); + HAL_NVIC_EnableIRQ(TIM3_IRQn); + /* USER CODE BEGIN TIM3_MspInit 1 */ - /* USER CODE END TIM6_MspInit 1 */ + /* USER CODE END TIM3_MspInit 1 */ } } void HAL_TIM_Base_MspDeInit(TIM_HandleTypeDef* tim_baseHandle) { - if(tim_baseHandle->Instance==TIM6) + if(tim_baseHandle->Instance==TIM3) { - /* USER CODE BEGIN TIM6_MspDeInit 0 */ + /* USER CODE BEGIN TIM3_MspDeInit 0 */ - /* USER CODE END TIM6_MspDeInit 0 */ + /* USER CODE END TIM3_MspDeInit 0 */ /* Peripheral clock disable */ - __HAL_RCC_TIM6_CLK_DISABLE(); + __HAL_RCC_TIM3_CLK_DISABLE(); - /* TIM6 interrupt Deinit */ - HAL_NVIC_DisableIRQ(TIM6_DAC_IRQn); - /* USER CODE BEGIN TIM6_MspDeInit 1 */ + /* TIM3 interrupt Deinit */ + HAL_NVIC_DisableIRQ(TIM3_IRQn); + /* USER CODE BEGIN TIM3_MspDeInit 1 */ - /* USER CODE END TIM6_MspDeInit 1 */ + /* USER CODE END TIM3_MspDeInit 1 */ } } diff --git a/docs/README.md b/docs/README.md index 43461feb..00b85e2f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -89,7 +89,7 @@ docs/ | [`控制台单写者日志设计说明.md`](子系统设计/控制台单写者日志设计说明.md) | 控制台日志单写者模型(避免多任务交错输出) | | [`运行与自检记录设计说明.md`](子系统设计/运行与自检记录设计说明.md) | `/log/run.log`·`selftest.log` 定长记录与待机显示 | | [`看门狗设计说明.md`](子系统设计/看门狗设计说明.md) | IWDG(CubeMX 配置)+ 任务心跳位图:选型与超时推导、掩码与埋点、取证机制、验证记录;已台架闭环(V2/V4/V5) | -| [`ADC采样节拍设计说明.md`](子系统设计/ADC采样节拍设计说明.md) | **ADC 采集链路采样节拍**:现状(任务周期软触发)→ 目标(TIM6 更新中断触发 CONVST)的方案选型、CubeMX 配置规格、代码改造清单、时序/优先级分析与验收判据;**规划中(未实现)** | +| [`ADC采样节拍设计说明.md`](子系统设计/ADC采样节拍设计说明.md) | **ADC 采集链路采样节拍**:TIM3 1 kHz 更新事件同时触发 TPAFE CONVST 与(经 TRGO)STM32 ADC1 + DMA 乒乓缓冲;方案选型、CubeMX 配置规格、代码改造清单、时序/优先级分析、验收判据与实测剖面;**已落地并上板闭环** | | [`网络/BSD_Socket_API_使用指南.md`](网络/BSD_Socket_API_使用指南.md) | **上层视图**:BSD Socket API 公开语义、示例、常见问题 | | [`网络/BSD_Socket_API_函数调用关系.md`](网络/BSD_Socket_API_函数调用关系.md) | **内部视图**:netTask / 消息队列 / L3 挂起 / 重试对账的调用关系与设计意图 | | [`网络/Net层阻塞模型架构设计.md`](网络/Net层阻塞模型架构设计.md) | **等待模型**:pending 请求架构、五路完成语义、设计边界与性能特征 | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index cd6557d8..773517e9 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 273,032 B (266.63 KB) | 1,048,576 B (1024 KB) | 26.0% | -| RW_IRAM1 | 91,336 B (89.20 KB) | 131,072 B (128 KB) | 69.7% | +| ER_IROM1 | 273,860 B (267.44 KB) | 1,048,576 B (1024 KB) | 26.1% | +| RW_IRAM1 | 91,488 B (89.34 KB) | 131,072 B (128 KB) | 69.8% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **273,840 B (267.4 KB)** | 1,048,576 B (1024 KB) | **26.1%** | +| **Flash (ROM 合计)** | **274,684 B (268.2 KB)** | 1,048,576 B (1024 KB) | **26.2%** | 各区的容量与常量来源: diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md index f74d7839..9d22f9f3 100644 --- a/docs/子系统设计/ADC采样节拍设计说明.md +++ b/docs/子系统设计/ADC采样节拍设计说明.md @@ -240,6 +240,7 @@ t=1000 µs 下一个 TIM6 更新事件 | 2026-10-10 | **§10.1/§10.3/§10.4 复核修正(无功能改动)** | ① 2 ms 忙等的单拍上界由"9 通道 ×2 = 18 ms"更正为**一次超时 ≈2~3 ms**(`stm32_scan()` 在首个超时处即 `Stop`+`return`);② 6 次上板对照 `adcp` = **122 µs**(超时 0 次的 5 轮)/ **2209 µs**(超时 1 次的那轮)→ 2 ms 不是常态开销;③ `adcm` 2.1 ms 峰值**归因改判**为每 10 s 一次的心跳日志(`idle=0`/20000 反证无每拍 2 ms);④ 新增超时的**正确性**代价(超时拍 STM32 侧静默沿用上一拍值,`skip` 抓不到)与硬化排序 | | 2026-10-10 | **补 ADC1 上电自校准(§10.4)** | 发现 F4 必需的自校准在本项目**完全缺失**(CubeMX 不生成 + 裁剪 HAL 无 `HAL_ADCEx_Calibration_Start` + 裁剪 CMSIS 头无 `ADC_CR2_CAL`)→ 在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(RM0090 §13.3.6 时序),`MX_ADC1_Init()` 末尾调用。新增审计项 `adc1-calibration-call`/`-impl`。`@build` 0 错 0 警(Code 256728→256868,+140 B);`@test` PASS,`adcp` 仍 122 µs、`adc1_err=0`;实测 STM32 侧 6 路同向偏移 +10~+63 LSB、TPAFE 侧不变 | | 2026-10-10 | **修"失败拍沿用旧值"(§10.4 硬化 ①)** | `stm32_scan()` 两条失败支路都清 `s_stm32_valid` → 失败拍走既有 `skip` 整帧跳过,不再产出"半新半旧"混合帧污染相量窗;下一拍成功自愈。主机单测 70 → **79** 用例(新增 TC-AI-070..078),并做反向验证(改回旧行为 3 条立即 FAIL)。`@build` 0 错 0 警(Code 256868→256872)、`@test` PASS。硬化 ②(DMA 消忙等)仍未做,并更正原表述:**F4 的 ADC EOC 没有 EXTI 通道**,只有 `ADC1_2_IRQn` | +| 2026-10-10 | **v2.8:节拍源 TIM6→TIM3 + STM32 侧 DMA 乒乓双缓冲(§11)** | 用户选定最彻底方案(DMA + TRGO 硬件触发 + 消费上一拍半区)。更正一处硬事实:**F4 上 ADC1 无 TIM6/TIM7 TRGO**(只接 DAC)→ 节拍源改 **TIM3**(PSC 83/ARR 999,`TIM_TRGO_UPDATE`),TIM6 从 `.ioc` 整体移除;ADC1 挂 **DMA2_Stream4**(CubeMX 默认抢 Stream0 会连锁搬走 SPI1_RX/USART1_RX,且换流把 NVIC 优先级重置为 **0**,违反"外设中断 ≥5")、`DMAContinuousRequests=ENABLE`、循环模式长度 2×9 乒乓,半区由 `seq` 奇偶推出(只读一个 32 位量 → 无竞态)。`stm32_scan()` → `stm32_take()`,逐通道 `PollForConversion` 全删;故障计数拆成 `adc1_seq`/`adc1_gap`/`adc1_err`/`adc1_hw` 四类(单拍无新帧 = 相位伪影,不计故障)。实测:`adcp` **122 µs → 8 µs**、`adc1_err=0`、`adc1_hw=0`、`adc1_gap=27`/20002 拍、`adc1_seq` 与 `seq` 恒定偏移 2;主机单测 79 → **101**(四组反向验证均咬);`@build` 0 错 0 警 Code 256872→**257660**;`@test` PASS;adcTask 静态链 832 B / 余 59% 不变 | --- @@ -318,10 +319,13 @@ t=1000 µs 下一个 TIM6 更新事件 **硬化排序**:① ✅ **已实施(2026-10-10)**——`stm32_scan()` 两条失败支路(启动失败 / 轮询 超时)都清 `s_stm32_valid` → 本拍 STM32 侧判为无有效数据,走既有 `skip` 整帧跳过路径, 下一拍成功自动恢复;主机侧新增 **TC-AI-070..078** 钉死并做反向验证(改回旧行为 3 条立即 - FAIL)。② **消忙等 = DMA**:`HAL_ADC_Start_DMA(&hadc1, buf, 9)`,scan 模式下每个通道 EOC - 由 DMA 自动搬进缓冲,任务侧不再逐通道 `PollForConversion`(⚠ **F4 的 ADC EOC 没有 EXTI - 通道**,只有 `ADC1_2_IRQn`——"EOC 改 EXTI"在 F4 上不成立,别照 F3/G4 的做法抄)。 - ③ 双缓冲 / 等周波触发属 V0.2 硬件项(§10.5)。 + FAIL)。② ✅ **已实施(2026-10-10)= DMA**:见 **§11**——TIM3 更新事件经 TRGO 硬件触发 + ADC1 一轮 9 通道,`HAL_ADC_Start_DMA(&hadc1, buf, 18)`(乒乓双缓冲)由 DMA 自动搬运, + 任务侧 `HAL_ADC_PollForConversion` **全部删除**,实测单拍 `adcp` **122 µs → 8 µs**; + "超时拍沿用旧值"这条支路在结构上消失(不再有超时概念,改由"本拍无新帧"走同一条 `skip` + 防线)。⚠ **F4 的 ADC EOC 没有 EXTI 通道**,只有 `ADC1_2_IRQn`——"EOC 改 EXTI"在 F4 上 + 不成立,别照 F3/G4 的做法抄。 + ③ 双缓冲已随 ② 同一批落地;**等周波触发**属 V0.2 硬件项(§10.5 / §8)。 - **已做的一项(2026-10-10):补 F4 ADC 上电自校准**。CubeMX **不生成**该步骤,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start` → 由 `Src/adc.c` 的 `adc1_calibrate()` 实现 (`USER CODE` 区 = L2,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底)。 @@ -333,8 +337,12 @@ t=1000 µs 下一个 TIM6 更新事件 ### 10.5 下一版硬件建议:DMA + 双缓冲(方案 b,用户要求记录) -当前硅片(STM32F407)**STM32 ADC1 已支持 TIM6-TRGO → ADC1 + DMA**;瓶颈在 **TPAFE5160 接口**, -须先以逻辑分析仪确认其接口类型再定型: +> **2026-10-10 更新**:本节里 **STM32 侧那一半已经落地**(§11,DMA + 乒乓双缓冲 + 定时器 +> TRGO 同源触发)。原写"STM32 ADC1 已支持 **TIM6**-TRGO → ADC1 + DMA"**是错的**—— +> F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO(只接 DAC),实际用的是 **TIM3_TRGO**; +> 已按 §11 更正。下面剩下的部分是 **TPAFE5160 侧**的下一版硬件建议,仍未办。 + +瓶颈在 **TPAFE5160 接口**,须先以逻辑分析仪确认其接口类型再定型: - **若 TPAFE 为 SPI + DRDY**(推荐先验): - 单 1 kHz 主时钟:TIM6 `TRGO` 同时驱动 ADC1 扫描(`ADC_ExternalTrigConv`=TIM6_TRGO)与 TPAFE `CONVST`; @@ -368,3 +376,93 @@ t=1000 µs 下一个 TIM6 更新事件 **教训(与 `docs/问题记录/UI任务看门狗停摆分析.md` §9.3.1 的 I-2/I-3 同类)**:把"定时轮询"换成 "事件驱动"时,必须单独核对**节拍源启动时刻 vs 消费者创建时刻**,否则第一次上电就会踩到 "ISR 对还不存在的消费者动手"。 + +--- + +## 11. 节拍源改 TIM3 + STM32 侧 DMA 乒乓双缓冲(v2.8,2026-10-10,**已落地并上板闭环**) + +> 触发背景:§10.4 硬化排序里的 ②(消忙等)与 ③(双缓冲)。用户明确选择最彻底的方案 +> ("我认为最彻底的就是 B2"):**DMA + 定时器 TRGO 硬件触发 + 消费上一拍已完成的半区**。 + +### 11.1 为什么节拍源从 TIM6 换成 TIM3 + +原规划(§2.1 / §10.5)假设"TIM6 TRGO 可触发 ADC1"——**在 F4 上不成立**。查 CubeMX 器件库 +(`db/mcu/IP/ADC-STM32F417_Modes.xml`)与 `stm32f4xx_hal_adc.h`,ADC1 的外部触发只有: + +``` +T1_CC1/2/3、T2_CC2/3/4、T2_TRGO、T3_CC1、T3_TRGO、T4_CC4、T5_CC1/2/3、T8_CC1、T8_TRGO、EXTI15 +``` + +TIM6/TIM7 的 TRGO 在 F4 上**只喂 DAC**(`T6_TRGO` 仅出现在 DB 里 `Expression="STM32F7"` 的块中)。 +要在**同一个硬件事件**上同时驱动 TPAFE(ISR 脉冲 CONVST)与 STM32 ADC1(TRGO), +可选的通用定时器 TRGO 只有 TIM2 / TIM3 / TIM4 / TIM5 / TIM8;TIM3 空闲且为 APB1 通用定时器 → +**改用 TIM3**(PSC 83 / ARR 999 = 1 kHz,`TIM_MasterOutputTrigger=TIM_TRGO_UPDATE`, +`TIM_MasterSlaveMode=ENABLE`)。TIM6 从 `.ioc` 中整体移除(`Mcu.IP` / `Mcu.Pin` / `VP_*` / +`TIM6_DAC_IRQn` 全部清掉),`htim6` / `MX_TIM6_Init` / `TIM6_DAC_IRQHandler` 一并消失。 + +⚠ `MSM=ENABLE` 而从模式 `SMS=0`(内部时钟)→ 软件 `CEN` 启动不受影响(已核对 RM0090 与生成代码)。 + +### 11.2 DMA 选型与三个静默陷阱 + +| 项 | 结论 | 踩坑代价 | +|---|---|---| +| 流 | **DMA2_Stream4**(ADC1 只挂 Stream0 或 Stream4;Stream1=ADC3、Stream2/3=ADC2) | CubeMX 默认抢 Stream0 → 连锁把 **SPI1_RX→Stream2、USART1_RX→Stream5**(NAND 与调试串口的 DMA 全被搬家) | +| 优先级 | `DMA2_Stream4=6`、`DMA2_Stream0=7`、`DMA2_Stream2=9` | **换流会把该流的 NVIC 优先级重置为 0** → 违反"外设中断 ≥5",0 抢占 FreeRTOS 临界区(`configMAX_SYSCALL_INTERRUPT_PRIORITY=5`) | +| `DMAContinuousRequests` | **ENABLE**(DDS 位) | 关掉 = 一轮 9 通道只发**一次** DMA 请求,静默丢 8 路,不报任何错 | +| 缓冲地址 | 必须在 **SRAM**(实测 `s_stm32_dma` @ `0x20003448`,落在 `RW_IRAM1`) | ADC1 走 **DMA2**,而 F4 的 DMA2 **不可达 CCM(0x10000000)** → 放错不报编译错、不报运行错,数据永远搬不回来 | + +模式:`DMA_CIRCULAR` + `DMA_PRIORITY_LOW` + halfword→halfword,长度 `2 × 9 = 18`。 + +### 11.3 乒乓半区怎么选才没有竞态 + +DMA 每完成 9 个半字触发一次 **半传输/传输完成** 回调,两个回调都代表"一整帧就绪", +各自 `s_dma_seq++`(`HAL_ADC_ConvHalfCpltCallback` / `HAL_ADC_ConvCpltCallback`, +均带 `if (hadc == &hadc1)` 句柄守卫)。消费侧: + +```c +half = (uint8_t)(1u - (seq & 1u)); /* seq 奇 = 刚完成前半区,偶 = 刚完成后半区 */ +src = &s_stm32_dma[half * ADC_INPUT_STM32_CH_NUM]; +``` + +**只读一个 32 位 `seq`**(原子读)→ 半区下标由它推导,不再引入第二个 ISR 写、任务读的变量 +(若另存一个 `s_dma_half` 就有"读到新 half、读到旧 seq"的窗口)。 +代价:STM32 侧数据**固定滞后 1 拍(1 ms)**——两片转换器本来就没有同刻性(N-18),无新增误差。 + +### 11.4 可观测性:四类故障分开计数 + +| 计数 | 含义 | 判读 | +|---|---|---| +| `adc1_seq` | DMA 整帧完成计数 | 应 ≈ TPAFE `seq`(实测恒定偏移 2 = TIM3 启动到 DMA 挂载之间的窗口) | +| `adc1_gap` | `seq` 一次前进 >1 → **丢帧** | 消费者超拍(单拍 > 1 ms,如心跳日志突发) | +| `adc1_err` | **连续 ≥2 拍**无新帧 → 停摆 | 节拍源/DMA 真停;单拍无新帧是**相位伪影**(见下),不计 | +| `adc1_hw` | `HAL_ADC_ErrorCallback` | ADC/DMA 硬件报错 | + +**相位伪影**(实测发现,故设 `STALL_RUN_MAX=2`):任务在 TIM3 触发瞬间被唤醒,而该拍的 DMA +完成落在 ≈67 µs 之后;若上一拍被日志/抢占拖过这 67 µs,本拍就会看到"计数没前进"(那个完成 +已被上一拍消费掉)。这种伪影**只会连续 1 拍**——DMA 若真停,第 2 拍仍不会前进。 + +### 11.5 实测(`@test` PASS,2026-10-10) + +| 指标 | DMA 前 | DMA 后 | +|---|---|---| +| `adcp`(单拍消费耗时) | **122 µs**(超时拍 2209 µs) | **8 µs** | +| `adcm`(心跳拍峰值) | 2.1 ms | 2.0 ms(仍是每 10 s 心跳日志本身) | +| `adc1_err` | — | **0** | +| `adc1_hw` | — | **0** | +| `adc1_gap` / `skip` | — | 27 / 20002 拍(0.13%,集中在开机日志突发期;稳态每 10 s 心跳 +1) | +| `adc1_seq` vs `seq` | — | 20004 vs 20006(恒定偏移 2,每拍 +1) | +| `idle` | 0 | 0 | +| 主机单测 | 79 | **101**(新增乒乓半区、HAL 回调守卫、gap/stall 分开计数三组) | +| `@build` | Code 256872 | **257660**(+788 B);ZI +4 B(36 B 乒乓缓冲 − 旧变量) | +| adcTask 静态最坏链 | 832 B / 余 59% | **832 B / 余 59%**(未变,`HAL_ADC_PollForConversion` 出链) | + +反向验证(改错实现看用例是否咬):半区奇偶取反 → 11 条 FAIL;停摆拍置 `valid=1` → 4 条 FAIL; +gap 阈值改 `>2` → 1 条 FAIL;`STALL_RUN_MAX` 改 1 → 6 条 FAIL。 + +### 11.6 遗留与注意 + +- **启动 fail-safe 语义不变但更硬**:TIM3 未启动 → ADC1 不再被触发 → `adcTask` 永久阻塞 → + `TASK_WD_ADC` 缺席 → IWDG 复位(设计如此)。 +- `adc1_gap` 的根治手段是**缩短心跳拍**(每 10 s 一次的多行日志),不是提高 DMA 能力。 +- 加大缓冲(如 4 路乒乓)前**必须**重跑 `.map` 确认地址仍在 `RW_IRAM1`。 +- 每次改 DMA 流之后必须回 NVIC 页核对优先级(§11.2 第三行)。 diff --git a/docs/待处理内容.md b/docs/待处理内容.md index bfdbda82..2e3588f0 100644 --- a/docs/待处理内容.md +++ b/docs/待处理内容.md @@ -244,7 +244,7 @@ S14 已在 workflow 显式提示"体积门禁未执行"(`::notice::`)并在 ## 4. 待确认(需手册/硬件/产品定义,暂不直接改码) -1. TPAFE5160 是否有自动扫描模式(**已不影响**:1 kHz 硬件定时器触发已于 2026-10-10 由 **TIM6 更新中断**方案落地,不依赖自动扫描,见 `docs/子系统设计/ADC采样节拍设计说明.md`)。 +1. TPAFE5160 是否有自动扫描模式(**已不影响**:1 kHz 硬件定时器触发已于 2026-10-10 由 **TIM3 更新中断**方案落地,不依赖自动扫描,见 `docs/子系统设计/ADC采样节拍设计说明.md`)。 2. `APP_ADC_ENABLE` / `APP_RS485_ENABLE` 是否应在产品构建真实门控模块(当前宏已如实标注为"未接线占位",此处确认的是**是否接入真实门控**;B1 取数不依赖该宏)。 3. RS-485 PD0 DE 的半双工方向语义需硬件实测确认(本次仅静态审查)。 diff --git a/docs/测试说明/ADC输入层单元测试说明.md b/docs/测试说明/ADC输入层单元测试说明.md index 76e44443..51b63404 100644 --- a/docs/测试说明/ADC输入层单元测试说明.md +++ b/docs/测试说明/ADC输入层单元测试说明.md @@ -2,8 +2,9 @@ > **模块**:ADC Input Layer(L1 采集与预处理,`App/algo/adc_input.c`) > **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证语义通道映射、 -> STM32 ADC1 顺序扫描、TPAFE 快照消费与取数守卫,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 -> **状态**:✅ 已落地(79 用例,`/W4` 0 告警,已挂 pre-commit 钩子) +> STM32 ADC1 的 DMA 乒乓帧消费(含半区选择)、TPAFE 快照消费与取数守卫,零硬件、零烧录、 +> 毫秒级、不占 MCU 的 RAM。 +> **状态**:✅ 已落地(**101** 用例,`/W4` 0 告警,已挂 pre-commit 钩子) > **日期**:2026-10-10 > **关联文档**: > [`../../AGENTS.md`](../../AGENTS.md)(平台约束 · CubeMX 生成区铁律)、 @@ -24,18 +25,21 @@ | 半边 | 内容 | 可测性 | |---|---|---| -| **纯逻辑**(被测) | ① 语义通道映射表(L1-4):`UA/UB/UC/UN/IA/IB/IC/IN/TEMP` → `(来源, 下标)`;② STM32 ADC1 9 通道软件触发顺序扫描(L1-3):**Rank 顺序 ↔ ADC1 通道号 ↔ 语义**;③ 取数守卫与 poll 契约;④ 扫描状态码 / 失败计数 | 纯查表 + 状态机,与硬件无关 → **可在 PC 上位穷举** | -| **硬件副作用** | `HAL_ADC_Start` / `PollForConversion` / `GetValue` / `Stop`、`tpafe5160_snapshot()`(临界区) | 均为**外部符号** → 主机侧给同名强符号桩即可 | +| **纯逻辑**(被测) | ① 语义通道映射表(L1-4):`UA/UB/UC/UN/IA/IB/IC/IN/TEMP` → `(来源, 下标)`;② STM32 ADC1 帧消费(L1-3,v2.0 DMA 模型):**Rank 顺序 ↔ ADC1 通道号 ↔ 语义** + **乒乓半区选择**;③ 取数守卫与 poll 契约;④ 状态码与四类故障计数(`seq`/`gap`/`err`/`hw`) | 纯查表 + 状态机,与硬件无关 → **可在 PC 上位穷举** | +| **硬件副作用** | `HAL_ADC_Start_DMA()`(挂载)与被测代码自己实现的三个 HAL 弱回调 `HAL_ADC_Conv[Half]CpltCallback` / `HAL_ADC_ErrorCallback`;`tpafe5160_snapshot()`(临界区) | 挂载是**外部符号** → 主机侧给同名强符号桩;三个回调由**被测代码**定义 → 测试直接调用它们注入"DMA 完成" | 板端套件(`test/board/`)**无法验证本层的核心判据**: - **通道映射**:板端**无信号源**(见 `docs/硬件平台/ADC通道映射说明.md` §8),无法注入已知电压去 验证"读到的是哪只脚"。而映射表一旦写错(本项目 V0.1 有 13/17 路被置换),读到的会是 **另一只脚**或**恒为陈旧值**,且**不会有任何报错**——这正是本层最危险的失效模式。 -- **HAL 故障路径**:`HAL_ADC_Start` 失败、转换轮询超时(2 ms)在真机上极难稳定复现。 +- **HAL 故障路径**:`HAL_ADC_Start_DMA()` 挂载失败、DMA 完成计数不前进(节拍源停摆)、 + 超拍丢帧、HAL 错误回调在真机上极难稳定复现,且**读错乒乓半区**这种缺陷在板端只会表现为 + "读数偶尔怪"、无法定位。 -主机侧的做法:给**每个 Rank 注入互不相同的码值**(第 i 个 Rank 读到 `100+i`),把 -"Rank 顺序 ↔ ADC1 通道号 ↔ 语义"整条链路**逐通道钉死**;故障路径用可注入桩一次覆盖。 +主机侧的做法:给**每个 Rank 注入互不相同的码值**(第 i 个 Rank 写成 `100+i`),把 +"Rank 顺序 ↔ ADC1 通道号 ↔ 语义"整条链路**逐通道钉死**;"DMA 完成"由测试直接调用被测代码 +实现的半传输/传输完成回调注入(`dma_beat()`),故障路径用可注入桩一次覆盖。 > ⚠ 本单测**只覆盖 L1-3 / L1-4**。L1-5(通道标定)本层不做,故取到的是**未标定原始码值**; > L1-1 / L1-2(周期触发与 TPAFE 快照链路)由 `adc_task` + TPAFE 驱动承担,板端套件覆盖。 @@ -59,11 +63,9 @@ test/host/adc_input/adc_input_test.c | 平台接口 | MCU 端 | 主机侧桩 | |---|---|---| -| `hadc1` | `MX_ADC1_Init()` 配置的 ADC1 句柄 | 不透明占位对象(被测代码只透传指针,从不读字段) | -| `HAL_ADC_Start` | 软件触发一次 9 通道扫描 | 返回 `s_start_rc`(可注入 `HAL_ERROR`);复位轮询/取数计数器 | -| `HAL_ADC_PollForConversion` | 逐 Rank 轮询 EOC(2 ms 超时) | 第 `s_poll_fail_at` 次(0 基)返回 `HAL_ERROR`;`-1` = 永不失败 | -| `HAL_ADC_GetValue` | 读 DR | 返回 `s_poll_vals[s_rank_idx]`(默认 `100 + rank`) | -| `HAL_ADC_Stop` | 停止转换 | 计次 `s_stop_calls` | +| `hadc1` / `hadc_other` | `MX_ADC1_Init()` 配置的 ADC1 句柄 | 两个占位对象:被测代码只**比较指针**(三个 HAL 回调的 `hadc == &hadc1` 守卫),从不读字段;`hadc_other` 专门用来证伪守卫 | +| `HAL_ADC_Start_DMA` | 挂载循环 DMA,长度 2×9(此后每拍由硬件自走) | 记录 `s_start_dma_calls` / `s_dma_buf` / `s_dma_len`,返回 `s_start_dma_rc`(可注入 `HAL_ERROR`) | +| `HAL_ADC_ConvHalfCpltCallback` / `HAL_ADC_ConvCpltCallback` / `HAL_ADC_ErrorCallback` | **由被测代码实现**(覆盖 HAL 弱符号),ISR 上下文 | 测试用 `dma_beat(base)` 直接调用:按 `seq & 1` 决定往哪个半区填 `base+i`,再触发对应回调——与消费侧的奇偶推导**互相证伪** | | `tpafe5160_snapshot` | 临界区原子快照最新一帧 | 返回 `s_snapshot_rc`(1/0/-1);`rc==1` 时从 `s_fake_tpafe[]` 拷贝;记录 `count` 与 `dst` | 桩里 `ADC_CHANNEL_0..15` 的**宏值等于通道号**(F4 HAL 即如此),故 `(uint8_t)ADC_CHANNEL_14 == 14` @@ -106,7 +108,7 @@ test/host/adc_input/adc_input_test.c > 命名 `TC-AI-NNN`(AI = Adc Input;主机侧单测自定前缀,与板端套件缩写表互不影响)。 > 全部在 `adc_input_test.c` 内,主机侧运行。被测源文件被 `#include` 纳入同一编译单元, -> 故**静态函数(`map_check` / `stm32_scan`)亦可直接调用**(白盒)。 +> 故**静态函数(`map_check` / `stm32_take`)亦可直接调用**(白盒)。 ### 2.1 初始化与复位(`adc_input_init`) @@ -143,44 +145,41 @@ test/host/adc_input/adc_input_test.c > 反向路径(表不自洽 → -1)**结构上不可达**,见 §4.1。 -### 2.4 STM32 ADC1 顺序扫描(`stm32_scan`,静态) +### 2.4 STM32 ADC1 帧消费(`stm32_take`,静态;v2.0 DMA 模型) | 用例 | 断言 | 验证的设计点 | |---|---|---| -| **TC-AI-021** | 全通道成功 → 返回 0 | 正常路径 | +| **TC-AI-021** | 有新帧 → 返回 0 | 正常路径 | | **TC-AI-022** | `adc_input_stm32_rc() == RC_OK` | 状态码 | | **TC-AI-023** | 成功不计入失败计数 | 统计语义 | -| **TC-AI-024** | `HAL_ADC_Start` / `HAL_ADC_Stop` 各一次 | 外设生命周期成对 | -| **TC-AI-025** | 轮询 9 次、取数 9 次(Rank1..9) | 扫描通道数 | +| **TC-AI-024** | `HAL_ADC_Start_DMA` 只在 `init()` 挂**一次** | 外设生命周期(每拍不重启) | +| **TC-AI-025** | DMA 完成计数 `seq` = 1 | 计数只由 HAL 回调推进 | | **TC-AI-026** | ⭐ **Rank → 通道号映射**:`UB` = CH14 / rank9 = 108 | **本层核心判据**(板端无法验) | | **TC-AI-027** | `UC` = CH3 / rank4 = 103 | 同上 | | **TC-AI-028** | `IB` = CH6 / rank7 = 106 | 同上 | | **TC-AI-029** | `IC` = CH1 / rank2 = 101 | 同上 | | **TC-AI-02A** | `IN` = CH2 / rank3 = 102 | 同上 | | **TC-AI-02B** | `TEMP` = CH5 / rank6 = 105 | 同上 | -| **TC-AI-02C** | 扫描后 `UA`(TPAFE)仍为 -2 | 两来源互相独立 | -| **TC-AI-030** | `HAL_ADC_Start` 返回 `HAL_ERROR` → 返回 -1 | 启动失败 | -| **TC-AI-031** | 状态码 = `RC_START` | 故障可观测 | -| **TC-AI-032** | 失败计数 +1 | 故障可观测 | -| **TC-AI-033** | 启动失败**不**调用 `Stop` | 不对外设做多余操作 | -| **TC-AI-034** | 启动失败后 STM32 侧仍无有效数据 | 不产出半帧 | -| **TC-AI-035** | 第 5 个 Rank 轮询超时 → 返回 -2 | 超时路径 | -| **TC-AI-036** | 状态码 = `RC_TIMEOUT` | 故障可观测 | -| **TC-AI-037** | 超时计入失败计数 | 故障可观测 | -| **TC-AI-038** | 超时前已 `Start` → 调 `Stop` 收尾 | 资源不泄漏 | -| **TC-AI-039** | 超时后**立即退出**(只轮询 5 次,不继续扫完) | 不长时间阻塞 1 ms 保护节拍 | -| **TC-AI-039b** | 超时后 STM32 侧无有效数据 | 不产出半帧 | -| **TC-AI-03A** | 再失败一次 → 计数 = 2 | 跨次单调累计 | -| **TC-AI-070..078** | ⭐ **失败拍不得沿用上一拍旧值**:先成功一拍让 STM32 侧进入有效态(`UB`=108),再注入超时拍 / `Start` 失败拍 → `adc_input_raw()` 必须返 **-2**;恢复拍重新可取数 | **陈旧帧**(2026-10-10 真机实测发现的缺陷,见下注) | +| **TC-AI-02C** | 取帧后 `UA`(TPAFE)仍为 -2 | 两来源互相独立 | +| **TC-AI-080..085** | ⭐ **乒乓双缓冲**:往"正在被 DMA 写的另一半区"填 900/800 系干扰值,`stm32_take()` 仍必须读到**刚完成那一半**(108 / 208) | **撕裂帧防线**(读错半区 = 半新半旧混合帧) | +| **TC-AI-086..089** | 连投两帧不消费 → 取到最新帧(408)且 `gaps()` = 1、`errs()` = 0 | **丢帧与停摆分开计数**(消费者超拍 ≠ 节拍源停) | +| **TC-AI-090..09A** | `HAL_ADC_ErrorCallback(&hadc1)` ×2 → `hw_errs()` = 2;用**别的句柄**调三个回调 → `seq` / `hw_errs` 均不动 | 三个 HAL 弱回调的 `hadc == &hadc1` **句柄守卫** | +| **TC-AI-030..034** | `HAL_ADC_Start_DMA` 返回 `HAL_ERROR` → `init()` 返回 **-2**、状态码 `RC_START`、计数 +1、STM32 侧无有效数据 | 挂载失败(一次性,非每拍) | +| **TC-AI-035..039** | 无任何 DMA 完成 → `RC_STALL`、**单拍不计故障**、`seq` 不前进、STM32 侧无有效数据 | 无新帧路径 + 相位伪影不误报 | +| **TC-AI-03A..03E** | 连续两拍无新帧 → `errs()` = 1;第三拍 → 2;恢复拍成功且 `gaps()` 仍 0 | 停摆判据 = **连续 ≥2 拍**;跨次单调累计 | +| **TC-AI-070..078** | ⭐ **停摆拍不得沿用上一拍旧值**:先成功一拍让 STM32 侧进入有效态(`UB`=108),再注入无新帧拍 → `adc_input_raw()` 必须返 **-2**;恢复拍重新可取数 | **陈旧帧**(2026-10-10 真机实测发现的缺陷,见下注) | -> ⚠ **为什么 TC-AI-034 / 039b 照不出这条缺陷**:两者都是"`adc_input_init()` 后**立刻**失败", -> 此时 `s_stm32_valid` 本来就是 0,断言 -2 恒成立。真正的缺陷路径是**成功过之后再失败**: -> 本层只有一份 `s_stm32_raw[]`,超时拍里"超时前几个 Rank 已写本拍新值、后面几个还是上一拍 -> 旧值",而有效标志此前**只在成功时置 1、失败时从不清零** → 消费侧照样取数、照样压入采样环 -> (`adc_input_poll()` 返回值只由 TPAFE 决定,`s_frames_skip` 抓不到),直接污染 20 点 DFT 窗。 -> 修复 = 两条失败支路都清 `s_stm32_valid`(`App/algo/adc_input.c`)。 -> **反向验证已做**:把超时支路改回旧行为,TC-AI-039b / 073 / 074 三条立即 FAIL(76/79)→ -> 证明这组用例真的咬得住。 +> ⚠ **为什么 TC-AI-034 照不出这条缺陷**:它是"`adc_input_init()` 后**立刻**失败",此时 +> `s_stm32_valid` 本来就是 0,断言 -2 恒成立。真正的缺陷路径是**成功过之后再失败**: +> 本层只有一份 `s_stm32_raw[]`,而有效标志此前**只在成功时置 1、失败时从不清零** → +> 消费侧照样取数、照样压入采样环(`adc_input_poll()` 返回值只由 TPAFE 决定, +> `s_frames_skip` 抓不到),直接污染 20 点 DFT 窗。 +> 修复 = 失败支路清 `s_stm32_valid`(`App/algo/adc_input.c`)。 +> **这组用例在轮询版与 DMA 版都保留**:触发条件从"轮询超时"变成"本拍无新帧", +> **同一条缺陷、同一个防线**。 +> **反向验证已做(v2.0)**:把停摆支路改回 `valid=1` → TC-AI-039 / 073 / 074 / 074B 四条立即 +> FAIL(89/93 时点);把半区奇偶取反 → 11 条 FAIL;`STALL_RUN_MAX` 改 1 → 6 条 FAIL; +> gap 阈值改 `>2` → 1 条 FAIL → 证明这些用例真的咬得住。 ### 2.5 取数与参数守卫(`adc_input_raw`) @@ -217,15 +216,16 @@ test/host/adc_input/adc_input_test.c | 用例 | 断言 | |---|---| -| **TC-AI-060** | init 后未扫描 → `RC_NOT_RUN(-3)` | -| **TC-AI-061** | init 后失败计数 = 0 | +| **TC-AI-060** | init 后未取帧 → `RC_NOT_RUN(-3)` | +| **TC-AI-061** | init 后各计数 = 0 | | **TC-AI-062** | 一次成功后 `rc == OK` 且 `errs` 不变 | -| **TC-AI-063** | 两次失败累计 `errs == 2` | -| **TC-AI-064** | `rc` = **最近一次**(`RC_TIMEOUT`) | +| **TC-AI-063** | 连续两拍无新帧 → `errs == 1`(单拍伪影不计) | +| **TC-AI-064** | `rc` = **最近一次**(`RC_STALL`) | | **TC-AI-065** | 恢复成功后 `rc == OK` | | **TC-AI-066** | `errs` **单调不回退**(不因成功回落) | +| **TC-AI-067** | `seq` 只由 DMA 完成回调推进(消费动作不改它) | -**当前结果**:70/70 PASSED(`failed=0`)。 +**当前结果**:**101/101 PASSED**(`failed=0`)。 --- @@ -274,3 +274,4 @@ V0.1 上 8 路电气量分属两片转换器且**无共同采样触发**(N-18 |---|---| | 2026-10-10 | 创建;70 用例全绿(`/W4` 0 告警);覆盖 `adc_input.c` 全部 8 个函数(6 公开 + 2 静态);挂 pre-commit 钩子 `adc-input-unit-test` | | 2026-10-10 | **新增 TC-AI-070..078(70 → 79 用例)**:钉死"失败拍不得沿用上一拍旧值"。配套修复 `App/algo/adc_input.c` 两条失败支路(清 `s_stm32_valid`)。已做**反向验证**:改回旧行为 → TC-AI-039b/073/074 立即 FAIL(76/79),证明用例有效 | +| 2026-10-10 | **v2.0 DMA 模型改造(79 → 101 用例)**:被测代码 `stm32_scan()`(逐通道 `PollForConversion`)换成 `stm32_take()`(消费 DMA 乒乓半区)。桩面同步:删 `HAL_ADC_Start/PollForConversion/GetValue/Stop`,加 `HAL_ADC_Start_DMA` 与 `hadc_other`;"DMA 完成"由测试直接调用**被测代码自己实现**的三个 HAL 弱回调注入(`dma_beat()`)。新增三组:⭐ **TC-AI-080..089** 乒乓半区(往正在写的另一半填干扰值仍须读到刚完成那一半)+ 超拍丢帧;**TC-AI-090..09A** HAL 错误回调与 `hadc == &hadc1` 句柄守卫;**TC-AI-03A..03E / 067** 停摆(连续 ≥2 拍)与丢帧(`seq` 前进 >1)分开计数。TC-AI-070..078 陈旧帧组**原样保留**(触发条件由"轮询超时"变"本拍无新帧",同一防线)。反向验证四连:半区奇偶取反 → 11 条 FAIL;停摆拍置 `valid=1` → 4 条 FAIL;`STALL_RUN_MAX` 改 1 → 6 条 FAIL;gap 阈值改 `>2` → 1 条 FAIL | diff --git a/docs/硬件平台/ADC通道映射说明.md b/docs/硬件平台/ADC通道映射说明.md index 39c7eff3..147ead27 100644 --- a/docs/硬件平台/ADC通道映射说明.md +++ b/docs/硬件平台/ADC通道映射说明.md @@ -129,11 +129,11 @@ STM32(U17)为 LQFP144 脚号。 | 项 | 现状 | 影响 | |---|---|---| | **通道归属(硬件缺陷)** | 8 路电气量分属两片转换器:UA/UN/IA → TPAFE,UB/UC/IB/IC/IN → STM32 ADC | **V0.1 硬件缺陷**(非有意分配,理由见 §1)→ V0.1 上禁止跨转换器做相位/功率/功率因数/矢量类运算;**V0.2 改版要求 8 路电气量全部走 TPAFE**(登记簿 N-18) | -| STM32 ADC 外设 | **已生成(2026-10-09,CubeMX 6.17 + FW_F4)**:`Src/adc.c`/`Inc/adc.h` 已入库并挂进 `MDK-ARM/*.uvprojx`;`Inc/stm32f4xx_hal_conf.h` 的 `HAL_ADC_MODULE_ENABLED` 已生效;HAL `stm32f4xx_hal_adc.c/_ex.c` + `stm32f4xx_ll_adc.h` 已补入仓库(HAL 原本是裁剪子集);`Src/main.c` 里 `MX_ADC1_Init()` 排在 `MX_IWDG_Init()` 之后。生成配置核对无误:9 通道 / Rank 1~9 / 全部 144 cycles / `ScanConvMode=ENABLE` / `ContinuousConvMode=DISABLE` / `ExternalTrigConv=ADC_SOFTWARE_START` + `EDGE_NONE` / `EOCSelection=ADC_EOC_SINGLE_CONV` / `DataAlign=RIGHT` / 12 位;`HAL_ADC_MspInit()` 已把 PA0~PA7+PC4 配成 `GPIO_MODE_ANALOG` | 硬件通路已打通(`hadc1` 可用);**消费者已落地**——`App/algo/adc_input.c` 的 `stm32_scan()`(L1-3,2026-10-10):`HAL_ADC_Start` + 逐通道 `PollForConversion`/`GetValue`,9 通道一轮 ≈ 67 µs | +| STM32 ADC 外设 | **已生成(2026-10-09,CubeMX 6.17 + FW_F4)**:`Src/adc.c`/`Inc/adc.h` 已入库并挂进 `MDK-ARM/*.uvprojx`;`Inc/stm32f4xx_hal_conf.h` 的 `HAL_ADC_MODULE_ENABLED` 已生效;HAL `stm32f4xx_hal_adc.c/_ex.c` + `stm32f4xx_ll_adc.h` 已补入仓库(HAL 原本是裁剪子集);`Src/main.c` 里 `MX_ADC1_Init()` 排在 `MX_IWDG_Init()` 之后。生成配置核对无误:9 通道 / Rank 1~9 / 全部 144 cycles / `ScanConvMode=ENABLE` / `ContinuousConvMode=DISABLE` / `ExternalTrigConv=ADC_SOFTWARE_START` + `EDGE_NONE` / `EOCSelection=ADC_EOC_SINGLE_CONV` / `DataAlign=RIGHT` / 12 位;`HAL_ADC_MspInit()` 已把 PA0~PA7+PC4 配成 `GPIO_MODE_ANALOG` | 硬件通路已打通(`hadc1` 可用);**消费者已落地**——`App/algo/adc_input.c` 的 `stm32_take()`(L1-3,v2.0,2026-10-10):ADC1 由 **TIM3 TRGO 硬件触发**一轮 9 通道 + **DMA2_Stream4 循环搬运**进乒乓双缓冲,任务侧只消费刚完成的半区(零忙等),9 通道一轮 ≈ 67 µs | | ADC1 上电自校准 | **已补(2026-10-10)**:`Src/adc.c` 的 `USER CODE BEGIN ADC1_Init 2` 调用 `adc1_calibrate()`(实现同文件 `USER CODE BEGIN 1`,按 RM0090 §13.3.6:清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)。⚠ **CubeMX 不生成这一步**,且本仓库裁剪版 HAL **没有** `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头**没有** `ADC_CR2_CAL`(bit3,代码里按 RM 自建 `ADC1_CAL_BIT`)→ 属 L2 手工定制,审计项 `adc1-calibration-call` / `adc1-calibration-impl` 兜底 | 实测有效:校准前后同一拍 STM32 侧 6 路**同向**变化 `UB 898→961`、`UC 978→1038`、`IB 906→922`、`IC 1061→1071`、`IN 1017→1055`、`T 790→846`(+10~+63 LSB ≈ 0.2~1.5 % FS);TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。缺这一步不报错,只是**读数带失调** | | ADC 时钟 | CubeMX 的 F4 ADC 面板**有** Clock Prescaler 一项(此前"面板没有该项、固定 DIV2"的判断有误),已选 **PCLK2/4 → ADCCLK = 21 MHz** ✓。面板里 **DIV2 是灰的、选不到**:DB 里 `FadcConstraint Max=36000000`,APB2 = 84 MHz → DIV2 = 42 MHz 超限,该候选值被 `Action="DISABLE"` 禁掉,超规值进不来 | **不设额外防护(2026-10-09 决定)**:约束已由 CubeMX 把关;`.ioc` 虽不保存 `ADC1.ClockPrescaler` 键,重生成也只会回落到约束允许的值。按 144+12 cycles 算 ≈ 7.4 µs/通道、9 通道一轮 ≈ 67 µs | -| `adc_task` | **已实现(B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费,2026-10-10)**:`App/task/adc_task.c` 每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 原子快照(8 通道)+ STM32 ADC1 顺序扫描,**经 `adc_input_raw()` 按语义取数**(不再直接索引驱动缓冲);**消费唤醒由 TIM6 ISR 置位 `ADC_WAKE_FLAG`**(不再 1 ms 轮询);任务在 `Src/freertos.c` 的 `USER CODE` 区创建为 `adcTask`(512 words / **AboveNormal**) | 数据路径已通(台架 `@test`:`frames` 严格 1000 Hz 递增、**`idle=0`**、`adc1_rc=0` / `adc1_err=0`) | -| TPAFE 采样触发 | **周期 CONVST 已实现(2026-10-10)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 **TIM6 更新中断(1 kHz)** 内调 `tpafe5160_start_conv_irq()`;`app_main_init()` 用 `HAL_TIM_Base_Start_IT(&htim6)` 启动节拍(在 `tpafe5160_init()` 之后)。同一 TIM6 分支还调 `adc_tick_irq_notify()` **唤醒消费者**(P1-5 方案 a,2026-10-10;内含"消费者就绪"门) | 连续波形(1 kHz);详见 `子系统设计/ADC采样节拍设计说明.md` §10 | +| `adc_task` | **已实现(B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费,2026-10-10)**:`App/task/adc_task.c` 每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 原子快照(8 通道)+ 消费一帧 STM32 ADC1 DMA 乒乓缓冲,**经 `adc_input_raw()` 按语义取数**(不再直接索引驱动缓冲);**消费唤醒由 TIM3 ISR 置位 `ADC_WAKE_FLAG`**(不再 1 ms 轮询);任务在 `Src/freertos.c` 的 `USER CODE` 区创建为 `adcTask`(512 words / **AboveNormal**) | 数据路径已通(台架 `@test`:`frames` 严格 1000 Hz 递增、**`idle=0`**、`adc1_rc=0` / `adc1_err=0`) | +| TPAFE 采样触发 | **周期 CONVST 已实现(2026-10-10)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 **TIM3 更新事件(1 kHz)** 内调 `tpafe5160_start_conv_irq()`;`app_main_init()` 用 `HAL_TIM_Base_Start_IT(&htim3)` 启动节拍(在 `tpafe5160_init()` 之后)。同一 TIM3 更新事件还经 **TRGO** 硬件触发 ADC1 一轮 9 通道(DMA 搬运),并调 `adc_tick_irq_notify()` **唤醒消费者**(P1-5 方案 a,2026-10-10;内含"消费者就绪"门)→ 两片转换器由同一硬件事件同源对齐 | 连续波形(1 kHz);详见 `子系统设计/ADC采样节拍设计说明.md` §10 / §11 | | 前端偏置 | 每路 1k 串联 + 1k 到 `*_GND` + 滤波 C,**无中点偏置** | STM32 侧只能测 **0~3.3 V 单极性**,交流量负半周被削顶;主板侧只做监视可接受,若要算 RMS/功率需硬件补偏置或明确算法口径 | | TPAFE 量程 | 默认 ±5 V(RANGE 接 GND),16 位二进制补码,LSB ≈ 152.59 µV | 用 `tpafe5160_to_voltage()` 换算 | | 取数契约(**2026-10-10 已修**,E-44 / E-45 / E-58 闭环) | 旧的 `get_buf` / `data_ready` / `clear_ready` **已删除**(删除前确认全仓库零调用者):`get_buf` 只给指针、任务再拷 → 被 BUSY EXTI 抢占就是**两帧混合**;`s_ready` 电平标志被 `start_conv_irq` 触发前清 0 → **重触发即丢未消费帧**;`read_all` 从不看 FRSTDATA(宏有定义、代码零引用)→ 芯片死亡时返回 OK + 常量垃圾。现口径:**`tpafe5160_snapshot(dst, count)`**(临界区原子拷贝,返回 1/0/-1,**仅任务上下文**)+ **`tpafe5160_frame_seq()`**(帧序号只增不清)+ **`tpafe5160_frstdata_err()`**(无跳变帧计数);`tpafe5160_init()` 增**首帧自检**——一帧读下来 FRSTDATA 无跳变(芯片不驱动并行总线;板上 R24 4.7k 上拉会把它钉死为高)→ 返回 `-3` + `[ERR]` | 消费者**只能**用 `snapshot()` 取数(`AGENTS.md` 已立为铁律);真机实测开机 `[ADC] Init OK` 现只在自检帧通过后才打印 → 判据在本板成立、非空转 | diff --git a/docs/算法/继电保护功能复现规划.md b/docs/算法/继电保护功能复现规划.md index b4bfa663..e5a34f60 100644 --- a/docs/算法/继电保护功能复现规划.md +++ b/docs/算法/继电保护功能复现规划.md @@ -37,7 +37,7 @@ | 操作系统 | FreeRTOS V10.3.1(CMSIS-RTOS v2) | | 已实现子系统 | 网络(CH395F)、存储(GD5F2GQ5UE SPI NAND + dhara FTL + FatFs)、时间(SD2506 RTC + `sys_clock`)、配置(cfg_file)、日志、UI(远端显示)、看门狗(IWDG)、FTP | | 采样硬件 | TPAFE5160(16 位、8 通道**同步**、±5 V 双极性、并行 `GPIOG[15:0]`)+ STM32 内部 ADC1(12 位、9 通道**顺序**扫描、0~3.3 V 单极性) | -| 采样软件 | `Drivers/BSP/TPAFE5160/tpafe5160.h` 驱动就绪(原子快照 API);`App/task/adc_task.c` **已实现真实消费者(B1,2026-10-10)**——`tpafe5160_snapshot()` 取数 + 帧统计;**采样节拍由 TIM6 更新中断(1 kHz)硬件触发**(2026-10-10);STM32 侧 **`App/algo/adc_input.c` 已实现语义映射 + ADC1 9 通道扫描(L1-3/L1-4,2026-10-10)**,`HAL_ADC_Start` 已出现 | +| 采样软件 | `Drivers/BSP/TPAFE5160/tpafe5160.h` 驱动就绪(原子快照 API);`App/task/adc_task.c` **已实现真实消费者(B1,2026-10-10)**——`tpafe5160_snapshot()` 取数 + 帧统计;**采样节拍由 TIM3 更新事件(1 kHz)硬件触发**(2026-10-10;同一事件经 TRGO 触发 ADC1 + DMA);STM32 侧 **`App/algo/adc_input.c` 已实现语义映射 + ADC1 DMA 乒乓帧消费(L1-3/L1-4,v2.0)**,`HAL_ADC_Start_DMA` 已出现 | | 保护算法层 | **完全缺失**(无傅氏、无保护元件、无出口、无定值模型) | | 已知硬件缺陷 | **N-18**:8 路电气量分属两片转换器 → V0.1 上禁止跨转换器做相位/功率/矢量运算 | @@ -238,10 +238,10 @@ | # | 功能点 | 关键参数 | 完成判据 | 状态 | | --- | --- | --- | --- | --- | -| L1-1 | 周期 CONVST 重触发 | 1000 Hz(与保护节拍同源) | 示波器见 U3 `BUSY` 连续脉冲;`tpafe5160_frame_seq()` 持续递增 | ✅ 2026-10-10:**TIM6 更新中断(1 kHz)硬件触发**(ISR 内 `tpafe5160_start_conv_irq()`;方案 A,零引脚/零驱动改动);台架 `@test` PASS——`seq` 每 10 s 精确 +10000 = 1000.0 Hz、`frst_err=0`(证据 `boot_log_data/20261010_prod_adc_tim6_boot_log.txt`) | +| L1-1 | 周期 CONVST 重触发 | 1000 Hz(与保护节拍同源) | 示波器见 U3 `BUSY` 连续脉冲;`tpafe5160_frame_seq()` 持续递增 | ✅ 2026-10-10:**TIM3 更新事件(1 kHz)硬件触发**(ISR 内 `tpafe5160_start_conv_irq()`;方案 A,零引脚/零驱动改动。⚠ 节拍源**不能**是 TIM6/TIM7——F4 上 ADC1 收不到它们的 TRGO,见 `docs/子系统设计/ADC采样节拍设计说明.md` §11.1);台架 `@test` PASS——`seq` 每 10 s 精确 +10000 = 1000.0 Hz、`frst_err=0`(早期证据 `boot_log_data/20261010_prod_adc_tim6_boot_log.txt`,节拍源当时为 TIM6) | | L1-2 | TPAFE 快照消费 | 8 通道 / 帧 | `adc_task` 每拍调 `tpafe5160_snapshot()`,返回 1/0/-1 有处理 | ✅ B1 已实现并 `@test` 闭环(`adc_task.c`;台架 `idle=0`、`frst_err=0`) | -| L1-3 | STM32 ADC1 启动与扫描 | 9 通道、144 cycles、软件触发 | 全库出现 `HAL_ADC_Start`;9 通道码值随注入变化 | ✅ 2026-10-10:`App/algo/adc_input.c` 的 `stm32_scan()`——`HAL_ADC_Start` + 逐通道 `HAL_ADC_PollForConversion`/`HAL_ADC_GetValue`(EOC_SINGLE_CONV + 无 DMA,可逐通道轮询);台架 `@test` PASS——9 通道全部转换成功、**零超时**(30 s 内 `adc1_rc=0` / `adc1_err=0`,≈27 万次转换)。⚠ 判据"随注入变化"以"逐通道独立读数 + 零超时 + 跨次开机读数不同"替代(台架无信号源,同 §7.1 无示波器口径);**引脚级注入验证(`ADC通道映射说明.md` §8 步骤 2)仍为待做项** | -| L1-4 | 通道语义映射 | 见 `docs/硬件平台/ADC通道映射说明.md`(**按语义取数,不写死下标**) | `UA→buf[6]`、`UN→buf[5]`、`IA→buf[2]`;STM32 侧 `UB→ADC_CHANNEL_14` | ✅ 2026-10-10:`App/algo/adc_input.c` 的 `k_adc_map[]` 语义表(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_raw()` 按语义取数;开机**映射自检**(`adc_input_init()` 校验 STM32 映射通道必落在扫描序列内 → 挡住"映射到不扫描的通道"静默错)通过(`ADC input layer init: OK`)。台架首帧日志 8 路 + 温度全部读出:`UA=11429 UN=11454 IA=11452`(与 TIM6 轮次一致)、`UB=1018 UC=1044 IB=943 IC=1069 IN=1052 T=850`(互异、非饱和;**两次开机读数不同** → 确为实时转换而非常量) | +| L1-3 | STM32 ADC1 启动与扫描 | 9 通道、144 cycles、**TIM3 TRGO 硬件触发 + DMA** | 全库出现 `HAL_ADC_Start_DMA`;9 通道码值随注入变化 | ✅ 2026-10-10(v2.0):`App/algo/adc_input.c` 的 `stm32_take()`——ADC1 由 TIM3 更新事件经 TRGO 触发一轮 9 通道,`HAL_ADC_Start_DMA(&hadc1, buf, 18)` + DMA2_Stream4 循环模式把码值自动搬进**乒乓双缓冲**,任务侧只消费刚完成的半区(**零忙等**,实测单拍 `adcp` 122 µs → **8 µs**);台架 `@test` PASS——`adc1_seq` 每拍 +1、`adc1_err=0`、`adc1_hw=0`。⚠ 判据"随注入变化"以"逐通道独立读数 + 零停摆 + 跨次开机读数不同"替代(台架无信号源,同 §7.1 无示波器口径);**引脚级注入验证(`ADC通道映射说明.md` §8 步骤 2)仍为待做项** | +| L1-4 | 通道语义映射 | 见 `docs/硬件平台/ADC通道映射说明.md`(**按语义取数,不写死下标**) | `UA→buf[6]`、`UN→buf[5]`、`IA→buf[2]`;STM32 侧 `UB→ADC_CHANNEL_14` | ✅ 2026-10-10:`App/algo/adc_input.c` 的 `k_adc_map[]` 语义表(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_raw()` 按语义取数;开机**映射自检**(`adc_input_init()` 校验 STM32 映射通道必落在扫描序列内 → 挡住"映射到不扫描的通道"静默错)通过(`ADC input layer init: OK`)。台架首帧日志 8 路 + 温度全部读出:`UA=11429 UN=11454 IA=11452`(与 TIM3 轮次一致)、`UB=1018 UC=1044 IB=943 IC=1069 IN=1052 T=850`(互异、非饱和;**两次开机读数不同** → 确为实时转换而非常量) | | L1-5 | 通道标定(一次系数 + 零漂) | 每通道 `k`、`b`;系数定点 `2^10` | 注入标准源,读数误差 ≤ 目标精度 | ⬜ | | L1-6 | 采样有效性校验 | FRSTDATA 跳变、帧序号单调、丢帧计数 | 芯片静默/丢帧被检出并置告警 | ⬜ | | L1-7 | 等周波采样(软件测频 + 调频闭环) | 20 点/周波;测频范围建议 **45~55 Hz**(参考 40.5~60.5) | 频率偏移 ±2 Hz 时幅值误差 ≤ 1% | ⬜ | @@ -600,3 +600,4 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-10 | **1 kHz 硬件定时器触发(TIM6)** | 采样节拍由任务周期软触发改为 **TIM6 更新中断(1 kHz)**:`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 加 TIM6 分支触发 `tpafe5160_start_conv_irq()`(`USER CODE` 区);`app_main.c` 用 `HAL_TIM_Base_Start_IT(&htim6)` 替换单次触发(在 `tpafe5160_init()` 之后);`adc_task.c` 退化为**纯消费者**(1 ms 轮询仅作消费节奏)。§1.2 采样软件行、§3.2 L1-1 同步。详见 `docs/子系统设计/ADC采样节拍设计说明.md` | | 2026-10-10 | **L1-3/L1-4 落地:STM32 ADC1 扫描 + 语义映射** | 新增 `App/algo/adc_input.c/h`(L1 采集层):① `k_adc_map[]` **语义通道表**(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_desc()`/`adc_input_raw()` **按语义取数**;② `stm32_scan()` STM32 ADC1 9 通道软件触发顺序扫描(`HAL_ADC_Start` + 逐通道 `PollForConversion`/`GetValue`);③ `adc_input_poll()` 统一"TPAFE 快照 + STM32 扫描"。`adc_task.c` 取数改走本层(首帧日志补全 8 路 + 温度、心跳增 `adc1_rc`/`adc1_err`);`app_main.c` 在 `tpafe5160_init()` 后调 `adc_input_init()`(含**映射自检**:STM32 映射通道必落在扫描序列内,失败记 `SYSLOG_SELFTEST`);Keil 工程挂 `adc_input.c` 并加 `../App/algo` 包含路径。§1.2 采样软件行、§3.1 L1-3/L1-4 同步。`@build` 0 错 0 警;台架 `@test` PASS(`ADC input layer init: OK`、`adc1_rc=0`/`adc1_err=0`、`seq` 仍 1000.0 Hz、`adc` 栈峰值 528→584 B、回读一致、零 `[ERR]`/HardFault),证据 `测试说明/boot_log_data/20261010_prod_adc_l1_boot_log.txt` | | 2026-10-10 | **L2 相量层落地 + L2-2 实例数口径对齐** | 新增 `App/algo/sample_ring.c/h`(L2 输入:按语义通道保存 2 周波 = 40 点环缓冲,整帧写后统一推进写指针)与 `App/algo/phasor.c/h`(L2 核心:全周傅氏单频 DFT,N = 20;系数表**离线预乘 √2/N** → `fAmSQ` 直接为**有效值**;计算区/使用区**双区原子切换**;实例表 `k_inst[]` 数据驱动);主机侧单测 `test/host/phasor/` **57 用例全绿**(`phasor-unit-test` 钩子)。**§3.2 L2-2 行口径对齐**:参考项目为 **4 个实例**,本项目按 V0.1 有效通道展开为 **5 个**(`C01-IA-F1`/`C02-UA-F1`/`C03-UN-F1`/`C04-IA-F2`/`C05-IA-F5`),已加注说明 4 → 5 的来历;§4.3 相量区 RAM 行同步为 5 实例。⚠ §3.2 的 L2 各点状态**仍标 ⬜**——实现与主机单测已完成,但**台架 `@test` 闭环未做**,且 L2-3 的完成判据(标准源读数 = 设定有效值)在无信号源台架上不可达,故不置 ✅(口径见 §3.2 表下注) | +| 2026-10-10 | **L1-1/L1-3 节拍源与搬运方式升级(v2.8)** | 节拍源 **TIM6 → TIM3**(更正硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时触发 TPAFE CONVST 与(经 TRGO)ADC1 一轮 9 通道;L1-3 由"软件触发 + 逐通道轮询"改为 **DMA2_Stream4 循环搬运 + 乒乓双缓冲**,`stm32_scan()` → `stm32_take()`,单拍 `adcp` **122 µs → 8 µs**。§1.4 采样软件行、§3.2 L1-1/L1-3/L1-4 同步。完整设计与实测见 `docs/子系统设计/ADC采样节拍设计说明.md` §11 | diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md index 82803d8b..2ee62e6d 100644 --- a/docs/问题记录/UI任务看门狗停摆分析.md +++ b/docs/问题记录/UI任务看门狗停摆分析.md @@ -6,7 +6,8 @@ > 改前基线(`git stash` 重建)0/6。 > **状态**:**分析完成,全部处置已落地并上板验证**(2026-10-10)。P0(adc 压环条件 + 截止期 > 重同步)、P1-4a(UI 心跳自产)、P2(常开 `[PROF]` 探针)、§9 方案 A(推流就绪门控 + 兜底)、 -> **P1-5(ADC 消费改 TIM6 ISR 事件驱动 + `adcTask` 提至 `AboveNormal`)** 全部落地;停摆累计 +> **P1-5(ADC 消费改定时器 ISR 事件驱动 + `adcTask` 提至 `AboveNormal`;同日 v2.8 进一步把 +> 节拍源定为 TIM3 并让 STM32 ADC1 走 TRGO + DMA 乒乓缓冲,§4.4 的 2 ms 忙等已消除)** 全部落地;停摆累计 > **38 次上板启动 0 复现**(P0 后 22 次 + A 后 10 次 + P1-5 后 6 次)。§9 方案 B > (把推流搬出 uiTask)**已评估并决定不做**。 > **阅读口径**:§2~§7 是**故障当时**的证据与归因,其中已被处置改掉的代码片段就地标注 @@ -137,7 +138,7 @@ uiTask 就**完全拿不到 CPU**;`configUSE_TIME_SLICING=1` 只保护**同优 对它无效。 > **P1-5 落地后的变化(2026-10-10)**:`adcTask` 已提到 **`AboveNormal`(32)** → 本表"uiTask -> 垫底"的格局更极端。之所以可接受:① 改造后 `adcTask` 由 TIM6 **精确 1 kHz** 唤醒,单拍 +> 垫底"的格局更极端。之所以可接受:① 改造后 `adcTask` 由定时器(现 TIM3)**精确 1 kHz** 唤醒,单拍 > µs~2.25 ms 且**不再有空转窗口**——改前的 `osDelayUntil` 一旦超拍就永久落后 → 不再阻塞 → > 以 Normal 全程抢占 uiTask,那才是本表真正担心的形态(P0-2 已修,见 §4.3);② 实测 6 次上板 > `uip` / `uiw` / `uis` 与提优先级前**一字不差**(61.3 ms / 11.4 ms / 1 µs)。 @@ -201,7 +202,7 @@ if (osDelayUntil(next) != osOK) { next = osKernelGetTickCount(); } /* 落后 **再现状(P1-5,2026-10-10)**:这段已**整体删除**——`ADC_TASK_BEAT_MS` 与 `next` 都不在了, 循环尾改为 `(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)`,唤醒由 -TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上消失**(不再有自定截止期,也就没有 +定时器(现 TIM3)ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上消失**(不再有自定截止期,也就没有 "落后"这个概念),P0-2 的重同步钩子随之作废。实测 6 次上板 **`idle = 0`**(改前 1~649), 见 §8 与 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10。 @@ -218,18 +219,20 @@ TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上 > 从不轮询剩余通道 → **单拍最坏 = 一次超时 ≈2~3 ms**(`HAL_GetTick()` 是 1 ms 粒度、判据是 > `> Timeout`),不是 9 次。 -**现状(P1-5 后:忙等未改,但跑它的任务优先级抬高了 → 敏感度上升)**: -`ADC_INPUT_POLL_TIMEOUT_MS = 2U` 与忙等**仍未改**——P1-5 只做"事件驱动 + 提优先级", -超时硬化列为后续(见 -[`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10.4)。 -三点须一起看: +**现状(v2.8,2026-10-10:这条饥饿源已消除)**:`ADC_INPUT_POLL_TIMEOUT_MS` 与 +`HAL_ADC_PollForConversion` **已从代码中删除**——STM32 ADC1 改由 **TIM3 更新事件经 TRGO +硬件触发 + DMA 循环搬运**,任务侧只消费 DMA 已写好的乒乓半区,单拍 `adcp` 实测 +**122 µs → 8 µs**;"超时"这个概念在 L1 层不复存在,因此也不存在 2~3 ms 自旋。 +完整设计与实测见 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §11。 +下面两段保留,作为该结论的推导与当时的实测依据(历史): - 这条饥饿源如今**基本不被触发**,且**只在超时拍出现**:6 次上板对照——超时 1 次的那轮 `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`(= 9 通道 × 7.4 µs + TPAFE 快照); `adc1_err` 从 288/10 s 降到 **0~1**; -- 但 `adcTask` 现已在 **`AboveNormal`(32)**,一旦某拍真走到 2 ms 忙等,它挤的是 +- ~~但 `adcTask` 现已在 **`AboveNormal`(32)**,一旦某拍真走到 2 ms 忙等,它挤的是 netTask / ftpTask / defaultTask / timeSyncTask(改前是同级竞争)→ **这是 P1-5 换来的新暴露, - 记在账上**; + 记在账上**~~ → **v2.8 已消**:忙等本体没了,这条新暴露随之关闭(2026-10-10 实测 + `adcp = 8 µs`、`adc1_err = 0`、`adc1_hw = 0`)。 - ✅ **超时的第二个代价是正确性,不只是耗时(2026-10-10 已修)**:超时拍 `s_stm32_valid` 此前 仍是上次成功留下的 1,而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、 照样压环,STM32 侧几路(UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(且超时前几个 Rank 已写 @@ -238,7 +241,10 @@ TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上 `s_stm32_valid`** → 本拍 `adc_input_raw()` 对 STM32 语义通道返 -2 → `adc_task` 走既有 `skip` 整帧跳过路径,下一拍成功自动恢复。主机侧新增 **TC-AI-070..078** 钉死(旧用例 TC-AI-034/039b 都是"init 后立刻失败",`valid` 本就是 0,照不出这条路径),并做**反向验证**: - 把支路改回旧行为 → 3 条立即 FAIL(76/79)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)**。 + 把支路改回旧行为 → 3 条立即 FAIL(76/79)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)** + → ✅ **已办(同日 v2.8)**:DMA + TIM3 TRGO + 乒乓双缓冲落地,`stm32_scan()` 换成 + `stm32_take()`;"成功过之后失败 → 静默沿用旧值"这条缺陷路径在结构上消失(无超时概念), + 改由"本拍无新帧"触发同一条 `skip` 防线,主机用例 **TC-AI-070..078** 原样保留并仍反向验证。 --- @@ -315,7 +321,7 @@ TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上 4. **`[STACK]` 周期从 2.0 s 拉到 4.05 s**(17:36:19.902 → 23.951,标称 `STACK_PROBE_PERIOD_MS=2000`) 值得注意,但**不能作为证据**:控制台缓冲满时会**丢行**,该报告也可能只是被丢了。 5. **`idle` 的物理含义**:任务轮询与帧产生之间的相位漂移(`osDelayUntil` 的 1 ms 内核 tick - vs TIM6 的 1 kHz 硬件节拍,两个时钟源不同),本身不是故障。 + vs 定时器(现 TIM3)的 1 kHz 硬件节拍,两个时钟源不同),本身不是故障。 **现状**:P0-1 后 `idle` 只计数、不再压环,实测也从 649/20001 降到 **1/20000**—— 当年"它会让 §5 的缺陷持续发生"这条耦合已解除。 6. **启动阶段 `MISS start snap=0x67` 是良性的(2026-10-10 新增)。** `[WD] armed` 在 @@ -344,7 +350,9 @@ TIM6 ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上 | **P1-3** | 把 uiTask 提到 `osPriorityNormal` | ❌ **刻意不做**:它会**掩盖** §4.2/§4.4 两个真实缺陷(当时的判断,现仍成立) | | **P1-4a** | 心跳提到 `ui_net_sink_poll()` **之前** | ✅ 已落地(`ui_task.c:159`,v1.3) | | **P1-4b** | 给 `net_accept()` 一条非阻塞查询路径 | ✅ 以更彻底的形式落地 = **§9-A 门控**(`net_can_accept()` / `net_can_read()`) | -| **P1-5** | 原列"adcTask 单拍硬上界(ADC 改 DMA / 超时整拍跳过)"→ 经用户决策改走**方案 a:事件驱动消费**(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 + `adcTask` 提至 `AboveNormal`) | ✅ **已落地**(2026-10-10;6 次上板 **`idle=0`**、`frames` 严格 1000 Hz、UI 侧探针一字不差;完整记录见 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10)。⚠ 原列的"**单拍硬上界**"(DMA / 超时整拍跳过)**仍未办**,且因优先级抬高而更敏感(§4.4 现状) | +| **P1-5** | 原列"adcTask 单拍硬上界(ADC 改 DMA / 超时整拍跳过)"→ 经用户决策改走**方案 a:事件驱动消费**(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 + `adcTask` 提至 `AboveNormal`) | ✅ **已落地**(2026-10-10;6 次上板 **`idle=0`**、`frames` 严格 1000 Hz、UI 侧探针一字不差;完整记录见 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10)。⚠ 原列的"**单拍硬上界**"(DMA / 超时整拍跳过)**已办(同日 v2.8)**——STM32 ADC1 改 TIM3 TRGO 硬件触发 + DMA 乒乓双缓冲,忙等本体删除, +`adcp` 122 µs → **8 µs**;该"新暴露"随之关闭(§4.4 现状)。完整记录见 +[`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §11 | | **P2-6** | 启用现成的 `TEST_LATENCY_PROBE`(`ui_task.c` 的 `ui_loop` 段) | ✅ 已被**常开探针 `App/util/task_prof.c/.h`** 取代(`[PROF]`/`[PROF2]`),无需再开测试开关 | | **P2-7** | 新增 adcTask 单拍 / 推流往返两个埋点 | ✅ 已落地(`TASK_PROF_ADC_BEAT` / `TASK_PROF_ADC_POLL` / `TASK_PROF_UI_SINK`) | | **P2-8** | 停摆取证带上"各任务在本窗口的调用计数" | ✅ 以更彻底的形式落地 = 心跳位图 **1→0 边沿**裸 UART 直发现场快照 | @@ -546,6 +554,7 @@ A(门控 + 兜底)→ @build + @test + e2e → 看 [PROF]:uis 掉到 µs | 2026-10-10 | **A 收口 + 方案 B 否决并删除**(用户定案"B 不做")。① **A 收口**:`@test` **10/10 PASS**(每轮 `uis` ≤1 µs、`uisn` ≡ 0、0 `task stall`、0 `[ERR]`)、e2e `check_remo_sink.py` **0/25600 位差异**、内容页文本断言 50/50。② **B 删除**:§9.4 由完整设计(新任务 256 words / BelowNormal / 不进 `required_mask`、帧 seqlock、栈与堆两条预算路线、观测面迁移、12 处改动清单)改写为**否决记录**——实测 `uis`=1 µs 而 `uip` 仍 60.9 ms(LVGL 帧周期地板)证明"搬出 uiTask"**不会让刷新变快**,收益只剩极端并发下的观感隔离,而成本含**必改 `.ioc`** 的 CubeMX 重生成;残留暴露与观测手段(`uis`/`uisn`)如实保留。③ **同步清理**:§9.5/§9.6/§9.7 去掉 B 条目;**N-19 / C7 不入登记簿**(当日结案,`docs/待处理内容.md` 保持无该编号);`子系统设计/看门狗设计说明.md` §9.2 的 N-19 行移除;`RemoDispBus协议与接收设计说明.md` 去掉"规划中改独立任务"段;`ui_net_sink.c/.h` 注释去掉"应搬出 uiTask"的指向 | | 2026-10-10 | **按项目现状全文校订(无代码改动)**:① 已被处置改掉的代码片段就地标注 **改前 / 现状**——§1 第 2 条、§4.2(`ui_task.c` 心跳顺序,P1-4a 已改)、§4.3(`osDelayUntil` 丢弃返回值,P0-2 已改)、§5(无新帧压环,P0-1 已改)、§6;§4.4 明确"忙等原样保留、P1-5 暂缓"。② §8 由"待确认后执行的 P0/P1/P2 分层建议"改写为**决策 + 结果表**(P1-3 刻意不做 / P1-4b 以 A 门控形式落地 / P1-5 暂缓 / P2-6 被 `task_prof` 取代 / P2-8 以边沿快照形式落地),并新增**当前健康剖面**表。③ §7 更新:0 复现累计 22 → **32 次**、`adc1_err` 288 → 1、`idle` 649/20001 → **1/20000**;新增 §7.7 说明 `netp=0` 是真实值(`net_poll()` 空闲只读一次 INT# GPIO)。④ §9.1/§9.2 的"改前"频率与 J1/J3 状态同步为 A 落地后的现状;删掉失效的行号引用与旧体积数字(256276 → **256368**) | | 2026-10-10 | **P1-5 已实施(方案 a:事件驱动消费)**:`Src/main.c` TIM6 分支在 `tpafe5160_start_conv_irq()` 后调 `adc_tick_irq_notify()`;`App/task/adc_task.c` 循环尾 `osDelayUntil` → `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS`/`next`;`Src/freertos.c` 的 `adcTask` 优先级 `Normal` → **`AboveNormal`**;`App/task/adc_task.h` 新增 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补一条规划未预见的**就绪门** `s_consumer_ready`——TIM6 在 `app_main_init()` 就启动,比建任务与 `osKernelStart()` 早 ≈100 ms,ISR 不能对尚不存在的消费者动手(同本仓 TIM7 分支 2026-10-02 的实测坑)。验证:`@build` 0 错 0 警(Code 256368→**256728**)、**`@test` 6/6 PASS**、**`idle` 由 1~649 降到 0**、`frames` 严格 1000.0 Hz、`seq−frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 一字不差、0 `task stall`。本文同步:§4.1 补优先级变化与可接受性论证、§4.3 补"再现状"(P0-2 那段已删)、§4.4 改为"忙等未改但敏感度上升"、§8 的 P1-5 行转 ✅ 并拆出仍未办的"单拍硬上界"、健康剖面按 6 连跑复测值更新 | +| 2026-10-10 | **v2.8 消掉 §4.4 的 2 ms 忙等(P1-5 拆出的"单拍硬上界"已办)**:节拍源 **TIM6 → TIM3**(更正一处硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时 ① ISR 脉冲 TPAFE CONVST、② 经 **TRGO** 硬件触发 ADC1 一轮 9 通道、③ 同一事件置位 `ADC_WAKE_FLAG`;ADC1 挂 **DMA2_Stream4** 循环模式 + `DMAContinuousRequests=ENABLE`,长度 2×9 **乒乓双缓冲**,半区由完成计数奇偶推出。`App/algo/adc_input.c` 的 `stm32_scan()`(逐通道 `PollForConversion`)→ `stm32_take()`(零忙等);故障观测拆成 `adc1_seq`/`adc1_gap`/`adc1_err`/`adc1_hw` 四类(单拍无新帧 = 消费者相位伪影,不计故障)。实测:`adcp` **122 µs → 8 µs**、`adc1_err=0`、`adc1_hw=0`、`adc1_gap=27`/20002 拍(0.13%,集中在开机日志突发期)、`adc1_seq` 与 `seq` 恒定偏移 2;主机单测 79 → **101**;`@build` 0 错 0 警 Code 256872→**257660**;`@test` PASS;adcTask 静态最坏链 **832 B / 余 59%** 不变。CubeMX 侧三轮重生成的三处坑(ADC1 抢 Stream0 连锁搬走 SPI1_RX/USART1_RX、换流把 NVIC 优先级重置为 0、TIM6 残留)见 §11.2 | | 2026-10-10 | **§4.4 复核修正(无代码改动)**:① 原文"9 通道全超时最坏 = **18 ms** 连占"**有误**——`stm32_scan()` 在首个超时处即 `HAL_ADC_Stop()`+`return`(`adc_input.c:79-84`),单拍上界 = **一次**超时 ≈2~3 ms;② 6 次上板对照证明 **2 ms 只在超时拍出现**:超时 1 次那轮 `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`;③ `adcm ≈ 2.1 ms` 的归因由"ADC 采集"**改判**为每 10 s 一次的心跳日志(`idle = 0`/20000 反证不存在"每拍 2 ms");④ 新增超时的**正确性**代价——超时拍 `s_stm32_valid` 仍为 1 → STM32 侧静默沿用上一拍值、`skip` 抓不到 → 硬化第一步应是"超时即置 `s_stm32_valid = 0`"。同步修正 `App/task/adc_task.c` 两处过期注释与 `ADC采样节拍设计说明.md` §10.1/§10.3/§10.4 | | 2026-10-10 | **补 F4 ADC 上电自校准(§7.2 候选根因之一)**:核查发现本项目**从未**做过 ADC 自校准——CubeMX 不生成该步骤,且仓库裁剪版 HAL 无 `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头无 `ADC_CR2_CAL`。已在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)并在 `MX_ADC1_Init()` 末尾调用;新增 `cubemx-survival` 审计项 `adc1-calibration-call` / `adc1-calibration-impl`。验证:`@build` 0 错 0 警(Code 256728→**256868**)、`@test` **PASS**(`adcp` 仍 122 µs、`adc1_err=0`、`idle=0`);**校准前后同一拍 STM32 侧 6 路同向偏移** `UB 898→961`/`UC 978→1038`/`IB 906→922`/`IC 1061→1071`/`IN 1017→1055`/`T 790→846`(+10~+63 LSB),TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。§4.4 的 2 ms 忙等与"超时沿用旧值"**仍未处理** | | 2026-10-10 | **修 §4.4 的"超时拍静默沿用旧值"(正确性缺陷)**:`stm32_scan()` 两条失败支路(`HAL_ADC_Start` 失败 / 轮询超时)现都清 `s_stm32_valid` → 失败拍 `adc_input_raw()` 返 -2 → `adc_task` 走既有 `skip` 整帧跳过,不再把上一拍的值当新数据压入 20 点 DFT 窗;下一拍成功自动恢复。主机侧新增 **TC-AI-070..078**(70 → 79 用例)钉死——旧用例 TC-AI-034/039b 都是"init 后立刻失败"(`valid` 本就是 0),照不出"成功过之后再失败"这条路径。**反向验证已做**:把支路改回旧行为 → TC-AI-039b/073/074 立即 FAIL(76/79)。验证:`@build` 0 错 0 警(Code 256868→**256872**)、主机单测 **79/79**、`@test` **PASS**(本轮无超时:`adc1_err=0`、`skip=0`、`idle=0`)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)** | diff --git a/scripts/check_cubemx_survival.py b/scripts/check_cubemx_survival.py index 3db178a2..e613f1b3 100644 --- a/scripts/check_cubemx_survival.py +++ b/scripts/check_cubemx_survival.py @@ -307,6 +307,55 @@ CHECKS = [ "`USER CODE BEGIN 1` 区。本仓库 HAL 无 HAL_ADCEx_Calibration_Start、" "CMSIS 头无 ADC_CR2_CAL,两者都是裁剪造成的,勿以为『标准 HAL 里有』。", ), + # ---------------- ADC 采样节拍 + DMA(2026-10-10 v2.8) ---------------- + # 节拍源 TIM3:同一个更新事件 ① ISR 脉冲 TPAFE CONVST ② 经 TRGO 触发 ADC1 + # ③ 置位 ADC_WAKE_FLAG。丢了 ② 不报编译错,只是两片转换器不再同源(相位差自由漂移)。 + dict( + id="adc-tick-tim3-branch", + kind="protected", + path="Src/main.c", + region="Callback 1", + must_contain=["TIM3", "tpafe5160_start_conv_irq()", "adc_tick_irq_notify()"], + hint="HAL_TIM_PeriodElapsedCallback 的 TIM3 分支必须同时做三件事:判 TIM3、脉冲 CONVST、" + "通知消费者。⚠ 节拍源**不能**换回 TIM6/TIM7——F4 上 ADC1 的外部触发清单里没有 " + "TIM6/TIM7 TRGO(只接 DAC),TIM3 的 TRGO 才是 ADC1 能收到的那个。", + ), + dict( + id="ioc-adc-trig-t3trgo", + kind="ioc", + key="ADC1.ExternalTrigConv", + want="ADC_EXTERNALTRIGCONV_T3_TRGO", + hint="ADC1 外部触发必须是 T3_TRGO(与节拍源 TIM3 配对)。改回软件触发 = 每拍由任务启动" + "转换,DMA 与 TPAFE 不再同源对齐,且重新引入忙等。", + severity="FAIL", + ), + dict( + id="ioc-adc-dma-continuous", + kind="ioc", + key="ADC1.DMAContinuousRequests", + want="ENABLE", + hint="DMAContinuousRequests(DDS 位)必须 ENABLE:关掉后一轮 9 通道只发**一次** DMA 请求" + "→ 静默丢 8 路,不报任何编译/运行错误。", + severity="FAIL", + ), + dict( + id="adc1-dma-link", + kind="generated", + path="Src/adc.c", + must_contain=["hdma_adc1", "__HAL_LINKDMA", "DMA2_Stream4", "DMA_CIRCULAR"], + hint="ADC1 的 DMA 挂载(Stream4 + 循环模式 + __HAL_LINKDMA)须在位。" + "ADC1 在 DMA2 上只挂 Stream0 或 Stream4;选 Stream0 会连锁搬走 SPI1_RX/USART1_RX。", + ), + dict( + id="adc1-dma-irq-priority", + kind="generated", + path="Src/dma.c", + must_contain=["HAL_NVIC_SetPriority(DMA2_Stream4_IRQn, 6, 0)"], + hint="⚠ CubeMX 在**换 DMA 流**时会把该流的 NVIC 优先级静默重置为 **0**(实测两轮)。" + "优先级 0 高于 configMAX_SYSCALL_INTERRUPT_PRIORITY(5) → 会抢占 FreeRTOS 临界区," + "违反本仓『外设中断 ≥5』。每次改 DMA 流后必须回 NVIC 页核对。", + severity="FAIL", + ), ] diff --git a/scripts/gen_task_table.py b/scripts/gen_task_table.py index 1221a8a8..e874fdce 100644 --- a/scripts/gen_task_table.py +++ b/scripts/gen_task_table.py @@ -75,7 +75,7 @@ ROLE = { "testTask": "硬件测试入口", "uiTask": "LVGL UI 界面", "timeSyncTask": "墙钟时间同步", - "adcTask": "TPAFE5160 采样(TIM6 1kHz 事件驱动 + 原子快照)", + "adcTask": "TPAFE5160 采样(TIM3 1kHz 事件驱动 + 原子快照 + STM32 ADC DMA)", } diff --git a/test/host/README.md b/test/host/README.md index ed1fef8a..6c28d013 100644 --- a/test/host/README.md +++ b/test/host/README.md @@ -46,7 +46,7 @@ | `cfg_file/stub/` | 桩头文件(`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 | | `phasor/run_unit_test.py` | **相量层(L2)单测**:离线编译真实 `App/algo/phasor.c` + `sample_ring.c`(**无需任何桩**,纯算法),57 用例覆盖傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区原子交换 | | `phasor/phasor_test.c` | 测试单元本体(`#include` 两个生产源码 + 合成任意波形) | -| `adc_input/run_unit_test.py` | **ADC 输入层(L1)单测**:离线编译真实 `App/algo/adc_input.c`,79 用例给每个 Rank 注入互异码值**逐通道钉死映射**,并覆盖 HAL 启动失败 / 转换超时 / **失败拍陈旧帧** / 参数错等真机难复现分支 | +| `adc_input/run_unit_test.py` | **ADC 输入层(L1)单测**:离线编译真实 `App/algo/adc_input.c`,**101** 用例给每个 Rank 注入互异码值**逐通道钉死映射**,并覆盖 DMA 挂载失败 / **乒乓半区选择(撕裂帧防线)** / 超拍丢帧 / 停摆 / HAL 回调句柄守卫 / **停摆拍陈旧帧** / 参数错等真机难复现分支 | | `adc_input/adc_input_test.c` | 测试单元本体(`#include` 生产源码 + HAL / TPAFE 可注入桩) | | `adc_input/stub/` | 桩头文件(`adc.h` / `tpafe5160.h`) | | `vol_lock/run_unit_test.py` | **卷锁单测**:离线编译真实 `App/fs/vol_lock.c`,33 断言点覆盖创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传 | diff --git a/test/host/adc_input/adc_input_test.c b/test/host/adc_input/adc_input_test.c index 0a8cff39..d61c091a 100644 --- a/test/host/adc_input/adc_input_test.c +++ b/test/host/adc_input/adc_input_test.c @@ -1,11 +1,12 @@ /* * 模块名称:ADC 输入层(L1)主机侧单元测试 - * 模块功能:在 PC 上对 App/algo/adc_input.c 做离线验证(6 个公开函数 + 2 个静态函数)。 + * 模块功能:在 PC 上对 App/algo/adc_input.c 做离线验证(8 个公开函数 + 2 个静态函数)。 * * 被测对象:真实源文件 ../../../App/algo/adc_input.c(以 #include 原样纳入, * 保证测的是生产代码而非副本)。平台依赖由本文件提供的**可注入桩**实现: - * - adc.h -> HAL_ADC_Start / PollForConversion / GetValue / Stop - * (可注入 启动失败 / 第 N 次轮询超时 / 逐 Rank 返回值) + * - adc.h -> HAL_ADC_Start_DMA(可注入挂载失败);DMA 完成由测试 + * 直接调用被测代码实现的 HAL_ADC_Conv[Half]CpltCallback + * 注入(dma_beat()),并可往乒乓缓冲写任意码值 * - tpafe5160.h-> tpafe5160_snapshot(可注入 新帧/无新帧/参数错 + 帧内容) * * 为什么要主机侧单测(而不是只用板端套件): @@ -29,19 +30,24 @@ * TC-AI-019/01A 越界(ADC_SEM_COUNT)与负数语义 -> NULL * —— map_check(TC-AI-020)—— * TC-AI-020 真实映射表自洽(反向路径结构上不可达,见文件末说明) - * —— stm32_scan(TC-AI-021..078)—— - * TC-AI-021..025 全通道成功:返回 0 / 状态码 / 不计失败 / Start-Stop 各一次 / 轮询 9 次 + * —— stm32_take(TC-AI-021..078,v2.0 DMA 模型)—— + * TC-AI-021..025 取到新帧:返回 0 / 状态码 / 不计失败 / DMA 只挂一次 / seq=1 * TC-AI-026..02B **Rank -> 通道号映射**:UB->CH14(rank9) / UC->CH3(rank4) / * IB->CH6(rank7) / IC->CH1(rank2) / IN->CH2(rank3) / TEMP->CH5(rank6) - * TC-AI-02C stm32_scan 不置 TPAFE 有效标志(两来源独立) - * TC-AI-030..034 HAL_ADC_Start 失败:返回 -1 / 状态码 RC_START / 计数 +1 / 不调 Stop - * TC-AI-035..039 轮询超时:返回 -2 / 状态码 RC_TIMEOUT / 计数 +1 / 调 Stop 收尾 / 立即退出 - * TC-AI-03A 失败计数跨次单调累计 - * TC-AI-070..078 **失败拍不得沿用上一拍旧值**(2026-10-10 补,真机实测发现的 - * 正确性缺陷):先成功一拍让 STM32 侧进入有效态,再注入超时拍 / - * Start 失败拍 -> adc_input_raw() 必须返 -2(而非上一拍的值); - * 恢复拍重新置有效。TC-AI-034/039b 只覆盖"冷启动即失败", - * 照不出"成功过之后失败 → 静默沿用旧值",故单独立一组。 + * TC-AI-02C stm32_take 不置 TPAFE 有效标志(两来源独立) + * TC-AI-080..089 **乒乓双缓冲**:消费侧读"刚完成的半区",即使另一半正被 DMA + * 改写也不得读到(撕裂帧防线);两拍交替半区、seq 推进; + * 超拍未消费 -> 记一次丢帧(gap),与停摆(err)分开计数 + * TC-AI-090..09A HAL 错误回调计数 + 三个回调的 `hadc == &hadc1` 句柄守卫 + * (别的句柄不得污染本层 seq / hw_errs) + * TC-AI-030..034 HAL_ADC_Start_DMA 失败:init 返回 -2 / 状态码 RC_START / 计数 +1 + * TC-AI-035..039 本拍无新帧:返回 RC_STALL / **单拍不计故障**(相位伪影)/ seq 不前进 + * TC-AI-03A..03E 连续两拍无新帧才计停摆;停摆与丢帧分开计数;恢复拍自愈 + * TC-AI-070..078 **停摆拍不得沿用上一拍旧值**(2026-10-10 补,真机实测发现的 + * 正确性缺陷;DMA 版改由"无新帧"触发同一路径):先成功一拍让 + * STM32 侧进入有效态,再注入无新帧拍 -> adc_input_raw() 必须返 -2; + * 恢复拍重新置有效。TC-AI-034 只覆盖"挂载失败即无数据", + * 照不出"成功过之后停摆 → 静默沿用旧值",故单独立一组。 * —— adc_input_raw(TC-AI-040..04B)—— * TC-AI-040..043 参数守卫:out=NULL / 越界 / 负数 -> -1,且不写 out * TC-AI-044/045 无有效数据 -> -2(两来源分别) @@ -71,58 +77,32 @@ /* ===================== 平台桩(先纳入,覆盖真实头) ===================== */ #include "adc.h" #include "tpafe5160.h" +#include "adc_input.h" /* 取 ADC_INPUT_STM32_CH_NUM 等常量(桩辅助函数在被测源之前) */ -/* ===================== HAL ADC1 桩 ===================== - * 被测代码经 HAL 只做四件事:启动、逐 Rank 轮询、读 DR、停止。桩把它们变成可注入: - * s_start_rc HAL_ADC_Start 返回值(注入启动失败) - * s_poll_fail_at PollForConversion 第几次(0 基)失败;-1 = 永不失败(注入超时) - * s_poll_vals[i] 第 i 个 Rank 读到的码值(默认 100+i,用于钉死 Rank->通道映射) - * s_poll_calls / s_rank_idx 轮询次数 / 取数次数(观测:超时后是否立即退出) +/* ===================== HAL ADC1 桩(v2.0:DMA 模型) ===================== + * 被测代码经 HAL 只做一件事:init 时挂一次 DMA。此后"一帧完成"由 DMA 中断的 + * HAL 回调告知——回调本体由被测代码实现,测试直接调它们即可注入节拍。 + * s_start_dma_rc HAL_ADC_Start_DMA 返回值(注入挂载失败) + * s_start_dma_calls 挂载次数(正常应恒为 1:本层不反复重启 DMA) + * s_dma_buf 被测代码交下来的乒乓缓冲地址(测试据此写"采样值") + * s_dma_len 被测代码请求的搬运长度(应为 2 x 9 = 18) */ -static HAL_StatusTypeDef s_start_rc = HAL_OK; -static int s_poll_fail_at = -1; -static uint32_t s_poll_vals[16]; -static int s_poll_calls = 0; -static int s_rank_idx = 0; -static int s_start_calls = 0; -static int s_stop_calls = 0; +static HAL_StatusTypeDef s_start_dma_rc = HAL_OK; +static int s_start_dma_calls = 0; +static uint32_t *s_dma_buf = NULL; +static uint32_t s_dma_len = 0u; /* 被测代码把 &hadc1 透传给 HAL 桩;桩不读其字段 */ ADC_HandleTypeDef hadc1; +/* 用于验证回调里的 `hadc == &hadc1` 守卫:别的句柄不得影响本层计数 */ +ADC_HandleTypeDef hadc_other; -HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef *hadc) { +HAL_StatusTypeDef HAL_ADC_Start_DMA(ADC_HandleTypeDef *hadc, uint32_t *pData, uint32_t Length) { (void)hadc; - s_start_calls++; - s_poll_calls = 0; - s_rank_idx = 0; - return s_start_rc; -} - -HAL_StatusTypeDef HAL_ADC_PollForConversion(ADC_HandleTypeDef *hadc, uint32_t Timeout) { - (void)hadc; - (void)Timeout; - { - int idx = s_poll_calls; - s_poll_calls++; - if (s_poll_fail_at >= 0 && idx == s_poll_fail_at) { - return HAL_ERROR; - } - } - return HAL_OK; -} - -uint32_t HAL_ADC_GetValue(ADC_HandleTypeDef *hadc) { - uint32_t v; - (void)hadc; - v = (s_rank_idx < 16) ? s_poll_vals[s_rank_idx] : 0u; - s_rank_idx++; - return v; -} - -HAL_StatusTypeDef HAL_ADC_Stop(ADC_HandleTypeDef *hadc) { - (void)hadc; - s_stop_calls++; - return HAL_OK; + s_start_dma_calls++; + s_dma_buf = pData; + s_dma_len = Length; + return s_start_dma_rc; } /* ===================== TPAFE5160 快照桩 ===================== */ @@ -165,21 +145,16 @@ static int s_total = 0, s_passed = 0, s_failed = 0; * 入口参数:无 * 返 回 值:无 * 限定条件:无 - * 函数说明:默认 Start 成功、轮询永不超时、第 i 个 Rank 读到 100+i(互不相同, - * 以便逐通道钉死 Rank->通道号映射)、TPAFE 快照无新帧。 + * 函数说明:默认 DMA 挂载成功、TPAFE 快照无新帧。DMA 缓冲由 adc_input_init() 交下来, + * 复位时清空指针(此时不得调用 dma_fill / dma_beat)。 */ static void fakes_reset(void) { uint8_t i; - s_start_rc = HAL_OK; - s_poll_fail_at = -1; - s_poll_calls = 0; - s_rank_idx = 0; - s_start_calls = 0; - s_stop_calls = 0; - for (i = 0u; i < 16u; i++) { - s_poll_vals[i] = 100u + (uint32_t)i; - } + s_start_dma_rc = HAL_OK; + s_start_dma_calls = 0; + s_dma_buf = NULL; + s_dma_len = 0u; s_snapshot_rc = 0; s_snapshot_calls = 0; s_snapshot_count = 0u; @@ -189,6 +164,43 @@ static void fakes_reset(void) { } } +/* + * 函数功能:往乒乓缓冲的指定半区写入一帧"采样值"(base + Rank 序号) + * 入口参数:half - 半区下标(0 / 1);base - 该帧首通道的码值 + * 返 回 值:无 + * 限定条件:须在 adc_input_init() 之后(缓冲地址由 Start_DMA 交下来) + * 函数说明:值按 **Rank 序号** 递增,故第 i 个 Rank = base+i;配合 + * k_stm32_rank_ch=[CH0..CH7,CH14] 可逐通道钉死映射(如 UB=base+8)。 + */ +static void dma_fill(uint8_t half, uint16_t base) { + uint16_t *p = (uint16_t *)(void *)s_dma_buf; + uint8_t i; + + for (i = 0u; i < ADC_INPUT_STM32_CH_NUM; i++) { + p[((uint32_t)half * ADC_INPUT_STM32_CH_NUM) + i] = (uint16_t)(base + i); + } +} + +/* + * 函数功能:模拟"一拍 DMA 搬运完成"(写对应半区 + 触发对应 HAL 回调) + * 入口参数:base - 本帧首通道码值 + * 返 回 值:无 + * 限定条件:须在 adc_input_init() 之后 + * 函数说明:DMA 长度 18、每拍搬 9 个 → 奇数次完成落在前半区(半传输回调)、 + * 偶数次落在后半区(传输完成回调)。半区下标由"已完成帧数"的奇偶推出, + * 与被测代码 stm32_take() 的口径互为镜像(两边独立推导,能互相证伪)。 + */ +static void dma_beat(uint16_t base) { + uint8_t half = (uint8_t)(adc_input_stm32_seq() & 1u); + + dma_fill(half, base); + if (half == 0u) { + HAL_ADC_ConvHalfCpltCallback(&hadc1); + } else { + HAL_ADC_ConvCpltCallback(&hadc1); + } +} + /* ===================== TC-AI-001..006:init ===================== */ static void tc_init(void) { @@ -198,16 +210,20 @@ static void tc_init(void) { /* 前置:制造"STM32 失败一次 + TPAFE 有效一帧"的状态,再验 init 是否清干净 */ s_snapshot_rc = 1; s_fake_tpafe[6] = 4321; - s_start_rc = HAL_ERROR; - (void)adc_input_poll(); + (void)adc_input_poll(); /* 单拍无新帧 = 相位伪影,不计故障 */ + (void)adc_input_poll(); /* 连续第二拍无新帧 = 真停摆 */ - CHECK(adc_input_stm32_errs() == 1u, "TC-AI-001 poll 失败后 STM32 失败计数 = 1"); + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-001 连续两拍无新帧 -> 停摆计数 = 1"); CHECK((adc_input_raw(ADC_SEM_UA, &v) == 0) && (v == 4321), "TC-AI-002 poll 后 UA 可取数且值正确"); CHECK(adc_input_init() == 0, "TC-AI-003 adc_input_init 返回 0(映射自检通过)"); CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_NOT_RUN, "TC-AI-004 init 后 STM32 状态码 = NOT_RUN"); + CHECK(s_start_dma_calls == 1, "TC-AI-003B init 挂载 DMA 恰好一次"); + CHECK(s_dma_len == (2u * ADC_INPUT_STM32_CH_NUM), "TC-AI-003C DMA 长度 = 2 x 9(乒乓双缓冲)"); + CHECK(s_dma_buf != NULL, "TC-AI-003D DMA 目的缓冲地址已交下来"); + CHECK(adc_input_stm32_seq() == 0u, "TC-AI-005B init 后 DMA 完成计数 = 0"); CHECK(adc_input_stm32_errs() == 0u, "TC-AI-005 init 后 STM32 失败计数归零"); CHECK((adc_input_raw(ADC_SEM_UA, &v) == -2) && (adc_input_raw(ADC_SEM_UB, &v) == -2), "TC-AI-006 init 后 TPAFE / STM32 两侧均无有效数据"); @@ -261,20 +277,20 @@ static void tc_map_check(void) { /* ===================== TC-AI-021..02C:stm32_scan 成功路径 ===================== */ -static void tc_scan_ok(void) { +static void tc_take_ok(void) { int32_t v = 0; fakes_reset(); (void)adc_input_init(); + dma_beat(100); /* 前半区一帧:Rank i -> 100+i */ - CHECK(stm32_scan() == ADC_INPUT_RC_OK, "TC-AI-021 stm32_scan 全通道成功 -> 0"); + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-021 stm32_take 取到新帧 -> 0"); CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-022 状态码 = RC_OK"); CHECK(adc_input_stm32_errs() == 0u, "TC-AI-023 成功不计入失败"); - CHECK((s_start_calls == 1) && (s_stop_calls == 1), "TC-AI-024 Start / Stop 各一次"); - CHECK((s_poll_calls == (int)ADC_INPUT_STM32_CH_NUM) && (s_rank_idx == (int)ADC_INPUT_STM32_CH_NUM), - "TC-AI-025 轮询与取数各 9 次(Rank1..9)"); + CHECK(s_start_dma_calls == 1, "TC-AI-024 DMA 只在 init 挂一次(每拍不重启)"); + CHECK(adc_input_stm32_seq() == 1u, "TC-AI-025 DMA 完成计数 = 1"); - /* Rank -> 通道号映射:第 i 个 Rank 读到 100+i。 + /* Rank -> 通道号映射:第 i 个 Rank = 100+i。 * k_stm32_rank_ch = [CH0..CH7, CH14],故 CH3->103 / CH5->105 / CH6->106 / CH14->108。 */ CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), "TC-AI-026 UB 取到 CH14(rank9)=108"); CHECK((adc_input_raw(ADC_SEM_UC, &v) == 0) && (v == 103), "TC-AI-027 UC 取到 CH3(rank4)=103"); @@ -283,78 +299,139 @@ static void tc_scan_ok(void) { CHECK((adc_input_raw(ADC_SEM_IN, &v) == 0) && (v == 102), "TC-AI-02A IN 取到 CH2(rank3)=102"); CHECK((adc_input_raw(ADC_SEM_TEMP, &v) == 0) && (v == 105), "TC-AI-02B TEMP 取到 CH5(rank6)=105"); - CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-02C stm32_scan 不置 TPAFE 有效标志"); + CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-02C stm32_take 不置 TPAFE 有效标志"); } -/* ===================== TC-AI-030..034:stm32_scan 启动失败 ===================== */ +/* ============ TC-AI-080..085:乒乓双缓冲(读刚完成半区,不读正在写的半区) ============ + * 这是 DMA 方案最关键的一条:消费侧必须读"上一拍刚完成的那一半",而 DMA 此刻正在 + * 写另一半。若读错半区,取到的是**正在被改写**的数据 → 半新半旧的撕裂帧。 */ -static void tc_scan_start_fail(void) { +static void tc_dma_pingpong(void) { int32_t v = 0; fakes_reset(); (void)adc_input_init(); - s_start_rc = HAL_ERROR; - CHECK(stm32_scan() == ADC_INPUT_RC_START, "TC-AI-030 HAL_ADC_Start 失败 -> -1"); + dma_beat(100); /* 第 1 帧 -> 前半区(半传输回调) */ + dma_fill(1u, 900u); /* 模拟 DMA 正在写后半区(值 900 系) */ + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-080 第一拍取帧成功"); + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), + "TC-AI-081 读的是刚完成的前半区(108),不是正在写的后半区"); + + dma_beat(200); /* 第 2 帧 -> 后半区(传输完成回调) */ + dma_fill(0u, 800u); /* 模拟 DMA 正在写前半区(值 800 系) */ + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-082 第二拍取帧成功"); + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 208), + "TC-AI-083 第二拍读到后半区(208)"); + CHECK(adc_input_stm32_seq() == 2u, "TC-AI-084 DMA 完成计数 = 2"); + + /* 半区奇偶口径:seq=1 -> 前半区,seq=2 -> 后半区(与 dma_beat 独立推导,互相证伪) */ + CHECK(((adc_input_raw(ADC_SEM_UC, &v) == 0) && (v == 203)), "TC-AI-085 UC 同帧内一致(203)"); + + /* 消费者超拍:两拍都没取 -> 中间那帧已被乒乓缓冲覆盖 -> 记一次丢帧 */ + dma_beat(300); + dma_beat(400); + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-086 超拍后仍取到最新帧"); + CHECK(adc_input_stm32_gaps() == 1u, "TC-AI-087 seq 前进 2 -> 记一次丢帧"); + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 408), "TC-AI-088 取到的是最新帧(408)"); + CHECK(adc_input_stm32_errs() == 0u, "TC-AI-089 丢帧不计入停摆(两类故障分开)"); +} + +/* ============ TC-AI-090..093:HAL 错误回调与句柄守卫 ============ */ + +static void tc_hw_errs(void) { + fakes_reset(); + (void)adc_input_init(); + + HAL_ADC_ErrorCallback(&hadc1); + HAL_ADC_ErrorCallback(&hadc1); + CHECK(adc_input_stm32_hw_errs() == 2u, "TC-AI-090 错误回调累计 = 2"); + + HAL_ADC_ErrorCallback(&hadc_other); + CHECK(adc_input_stm32_hw_errs() == 2u, "TC-AI-091 别的 ADC 句柄不得计入(hadc 守卫)"); + + HAL_ADC_ConvHalfCpltCallback(&hadc_other); + HAL_ADC_ConvCpltCallback(&hadc_other); + CHECK(adc_input_stm32_seq() == 0u, "TC-AI-092 别的句柄的完成回调不得推进 seq"); + + HAL_ADC_ErrorCallback(&hadc1); + CHECK(adc_input_stm32_hw_errs() == 3u, "TC-AI-093 hw_errs 与 errs 分开计数、互不干扰"); + CHECK(adc_input_stm32_errs() == 0u, "TC-AI-09A hw_errs 不污染 errs"); +} + +/* ===================== TC-AI-030..034:DMA 挂载失败 ===================== */ + +static void tc_init_dma_fail(void) { + int32_t v = 0; + + fakes_reset(); + s_start_dma_rc = HAL_ERROR; + + CHECK(adc_input_init() == -2, "TC-AI-030 HAL_ADC_Start_DMA 失败 -> init 返回 -2"); CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_START, "TC-AI-031 状态码 = RC_START"); CHECK(adc_input_stm32_errs() == 1u, "TC-AI-032 失败计数 +1"); - CHECK(s_stop_calls == 0, "TC-AI-033 启动失败不调用 Stop"); - CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-034 启动失败后 STM32 侧仍无有效数据"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-034 挂载失败后 STM32 侧无有效数据"); } -/* ===================== TC-AI-035..03A:stm32_scan 轮询超时 ===================== */ +/* ===================== TC-AI-035..03A:本拍无新帧(停摆) ===================== */ -static void tc_scan_timeout(void) { +static void tc_take_stall(void) { int32_t v = 0; fakes_reset(); (void)adc_input_init(); - s_poll_fail_at = 4; /* 第 5 个 Rank 轮询超时 */ + /* 没有任何 DMA 完成回调 = TIM3 未启动 / ADC 停摆 / DMA 出错 */ - CHECK(stm32_scan() == ADC_INPUT_RC_TIMEOUT, "TC-AI-035 轮询超时 -> -2"); - CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_TIMEOUT, "TC-AI-036 状态码 = RC_TIMEOUT"); - CHECK(adc_input_stm32_errs() == 1u, "TC-AI-037 超时计入失败"); - CHECK(s_stop_calls == 1, "TC-AI-038 超时前已 Start -> 调 Stop 收尾"); - CHECK(s_poll_calls == 5, "TC-AI-039 超时后立即退出(只轮询 5 次)"); - CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-039b 超时后 STM32 侧无有效数据"); + CHECK(stm32_take() == ADC_INPUT_RC_STALL, "TC-AI-035 无新帧 -> RC_STALL(-2)"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_STALL, "TC-AI-036 状态码 = RC_STALL"); + CHECK(adc_input_stm32_errs() == 0u, "TC-AI-037 单拍无新帧不计故障(相位伪影)"); + CHECK(adc_input_stm32_seq() == 0u, "TC-AI-038 停摆期间 seq 不前进"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-039 停摆拍 STM32 侧无有效数据"); - s_poll_fail_at = 0; /* 再失败一次 */ - (void)stm32_scan(); - CHECK(adc_input_stm32_errs() == 2u, "TC-AI-03A 失败计数跨次单调累计 = 2"); + (void)stm32_take(); /* 连续第二拍无新帧 = 真停摆 */ + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-03A 连续两拍无新帧 -> 停摆计数 = 1"); + (void)stm32_take(); /* 第三拍仍无新帧 */ + CHECK(adc_input_stm32_errs() == 2u, "TC-AI-03B 停摆持续 -> 每拍累计 = 2"); + CHECK(adc_input_stm32_gaps() == 0u, "TC-AI-03C 停摆不计入丢帧(两类故障分开)"); + + dma_beat(100); /* 恢复 */ + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-03D 恢复后取帧成功"); + CHECK(adc_input_stm32_gaps() == 0u, "TC-AI-03E 恢复拍前进 1 -> 不算丢帧"); } -/* ============ TC-AI-070..077:失败拍不得沿用上一拍旧值(陈旧帧) ============ - * 背景(2026-10-10 真机实测发现):本层只有一份 s_stm32_raw[],超时拍里"超时前 - * 几个 Rank 已写本拍新值、后面几个还是上一拍旧值",而 s_stm32_valid 此前只在 - * 成功时置 1、失败时**从不清零** → 消费侧照样取数、照样压入采样环, - * s_frames_skip 抓不到。TC-AI-034/039b 都是"init 后立刻失败"(valid 本就是 0), - * 因此照不出这条路径——必须先成功一拍。 */ +/* ============ TC-AI-070..078:停摆拍不得沿用上一拍旧值(陈旧帧) ============ + * 背景(2026-10-10 真机实测发现):本层只有一份 s_stm32_raw[],而 s_stm32_valid + * 此前只在成功时置 1、失败时**从不清零** → 消费侧照样取数、照样压入采样环, + * s_frames_skip 抓不到。轮询版的触发路径是"超时拍半新半旧",DMA 版(v2.0)改成 + * "本拍无新帧仍沿用上一拍"——**同一条缺陷、同一个防线**,故本组用例保留。 + * TC-AI-034 是"挂载失败后本就无数据"(valid 一直是 0),照不出这条路径—— + * 必须先成功一拍。 */ -static void tc_scan_stale(void) { +static void tc_take_stale(void) { int32_t v = 0; - /* --- 超时支路:成功拍 -> 超时拍 -> 恢复拍 --- */ + /* --- 停摆支路:成功拍 -> 无新帧拍 -> 恢复拍 --- */ fakes_reset(); (void)adc_input_init(); - CHECK(stm32_scan() == ADC_INPUT_RC_OK, "TC-AI-070 前置:首拍成功"); + dma_beat(100); + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-070 前置:首拍成功"); CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), "TC-AI-071 前置:UB 取到 108(有效态)"); - s_poll_fail_at = 4; /* 第 5 个 Rank 超时(前 4 个已写新值) */ - CHECK(stm32_scan() == ADC_INPUT_RC_TIMEOUT, "TC-AI-072 第二拍轮询超时"); - CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-073 超时拍不得沿用上一拍值 -> -2"); - CHECK(adc_input_raw(ADC_SEM_UC, &v) == -2, "TC-AI-074 超时拍连本拍已写入的 Rank 也不得出数"); + /* 第二拍没有任何 DMA 完成(TIM3 停 / ADC 停摆 / DMA 出错) */ + CHECK(stm32_take() == ADC_INPUT_RC_STALL, "TC-AI-072 第二拍无新帧"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-073 停摆拍不得沿用上一拍值 -> -2"); + CHECK(adc_input_raw(ADC_SEM_UC, &v) == -2, "TC-AI-074 停摆拍连其它 STM32 通道也不得出数"); + CHECK(adc_input_raw(ADC_SEM_TEMP, &v) == -2, "TC-AI-074B 同上(TEMP 亦 -2)"); - s_poll_fail_at = -1; - CHECK(stm32_scan() == ADC_INPUT_RC_OK, "TC-AI-075 第三拍恢复成功"); - CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 108), "TC-AI-076 恢复拍重新可取数(自愈)"); + dma_beat(200); + CHECK(stm32_take() == ADC_INPUT_RC_OK, "TC-AI-075 第三拍恢复成功"); + CHECK((adc_input_raw(ADC_SEM_UB, &v) == 0) && (v == 208), "TC-AI-076 恢复拍重新可取数(自愈)"); - /* --- 启动失败支路:同样不得沿用 --- */ + /* --- 挂载失败支路:同样不得沿用(init 失败后整层无有效数据) --- */ fakes_reset(); + s_start_dma_rc = HAL_ERROR; (void)adc_input_init(); - (void)stm32_scan(); /* 先成功一拍 */ - s_start_rc = HAL_ERROR; - CHECK(stm32_scan() == ADC_INPUT_RC_START, "TC-AI-077 Start 失败拍"); - CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-078 Start 失败拍不得沿用上一拍值 -> -2"); + CHECK(adc_input_raw(ADC_SEM_UB, &v) == -2, "TC-AI-078 挂载失败后不得取到任何值 -> -2"); } /* ===================== TC-AI-040..045:raw 参数守卫与无数据 ===================== */ @@ -409,9 +486,10 @@ static void tc_poll(void) { fakes_reset(); (void)adc_input_init(); s_snapshot_rc = 0; + dma_beat(100); CHECK(adc_input_poll() == 0, "TC-AI-050 快照无新帧 -> poll 返回 0"); CHECK(adc_input_raw(ADC_SEM_UA, &v) == -2, "TC-AI-051 无新帧时 TPAFE 侧无效"); - CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-052 同拍 STM32 扫描照常执行"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-052 同拍 STM32 取帧照常执行"); /* --- 参数错(-1):返回 -1,TPAFE 无效 --- */ fakes_reset(); @@ -436,11 +514,11 @@ static void tc_poll(void) { (void)adc_input_init(); s_fake_tpafe[2] = 55; s_snapshot_rc = 1; - s_start_rc = HAL_ERROR; - CHECK(adc_input_poll() == 1, "TC-AI-056 STM32 失败不影响 poll 返回值(仍为 1)"); + /* 不注入任何 DMA 完成 = STM32 侧停摆 */ + CHECK(adc_input_poll() == 1, "TC-AI-056 STM32 停摆不影响 poll 返回值(仍为 1)"); CHECK((adc_input_raw(ADC_SEM_IA, &v) == 0) && (v == 55), - "TC-AI-057 STM32 失败不影响 TPAFE 取数"); - CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_START, "TC-AI-058 STM32 失败另由状态码暴露"); + "TC-AI-057 STM32 停摆不影响 TPAFE 取数"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_STALL, "TC-AI-058 STM32 停摆另由状态码暴露"); } /* ===================== TC-AI-060..066:rc / errs 观测 ===================== */ @@ -452,22 +530,21 @@ static void tc_rc_errs(void) { CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_NOT_RUN, "TC-AI-060 init 后未扫描 = NOT_RUN(-3)"); CHECK(adc_input_stm32_errs() == 0u, "TC-AI-061 init 后失败计数 = 0"); - (void)stm32_scan(); /* 成功 */ + dma_beat(100); + (void)stm32_take(); /* 成功 */ CHECK((adc_input_stm32_rc() == ADC_INPUT_RC_OK) && (adc_input_stm32_errs() == 0u), "TC-AI-062 成功: rc=OK 且 errs 不变"); - s_start_rc = HAL_ERROR; - (void)stm32_scan(); /* 失败 1 */ - s_start_rc = HAL_OK; - s_poll_fail_at = 0; - (void)stm32_scan(); /* 失败 2 */ - CHECK(adc_input_stm32_errs() == 2u, "TC-AI-063 两次失败累计 = 2"); - CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_TIMEOUT, "TC-AI-064 rc = 最近一次(TIMEOUT)"); + (void)stm32_take(); /* 停摆 1(单拍 = 相位伪影,不计) */ + (void)stm32_take(); /* 停摆 2 -> 计 1 */ + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-063 连续两拍停摆累计 = 1"); + CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_STALL, "TC-AI-064 rc = 最近一次(STALL)"); - s_poll_fail_at = -1; - (void)stm32_scan(); /* 恢复成功 */ + dma_beat(200); + (void)stm32_take(); /* 恢复成功 */ CHECK(adc_input_stm32_rc() == ADC_INPUT_RC_OK, "TC-AI-065 恢复成功后 rc=OK"); - CHECK(adc_input_stm32_errs() == 2u, "TC-AI-066 errs 单调递增(不因成功回落)"); + CHECK(adc_input_stm32_errs() == 1u, "TC-AI-066 errs 单调递增(不因成功回落)"); + CHECK(adc_input_stm32_seq() == 2u, "TC-AI-067 seq 只由 DMA 完成回调推进"); } /* ===================== main ===================== */ @@ -481,14 +558,18 @@ int main(void) { tc_desc(); printf("-- map self-check --\n"); tc_map_check(); - printf("-- stm32 scan: success + rank/channel mapping --\n"); - tc_scan_ok(); - printf("-- stm32 scan: HAL_ADC_Start fail --\n"); - tc_scan_start_fail(); - printf("-- stm32 scan: poll timeout --\n"); - tc_scan_timeout(); - printf("-- stm32 scan: stale frame on failure beat --\n"); - tc_scan_stale(); + printf("-- stm32 take: success + rank/channel mapping --\n"); + tc_take_ok(); + printf("-- stm32 dma: ping-pong double buffer --\n"); + tc_dma_pingpong(); + printf("-- stm32 dma: HAL error callback + handle guard --\n"); + tc_hw_errs(); + printf("-- stm32 dma: Start_DMA fail --\n"); + tc_init_dma_fail(); + printf("-- stm32 take: no new frame (stall) --\n"); + tc_take_stall(); + printf("-- stm32 take: stale frame on stall beat --\n"); + tc_take_stale(); printf("-- raw: guards / no-data --\n"); tc_raw_guards(); printf("-- raw: tpafe index + two's complement --\n"); diff --git a/test/host/adc_input/run_unit_test.py b/test/host/adc_input/run_unit_test.py index ec3a1f74..b55e4aa7 100644 --- a/test/host/adc_input/run_unit_test.py +++ b/test/host/adc_input/run_unit_test.py @@ -5,7 +5,8 @@ 做什么: 用本机 MSVC 编译 test/host/adc_input/adc_input_test.c(它以 #include 纳入 **生产源文件** App/algo/adc_input.c),在 PC 上离线跑语义映射表 / STM32 ADC1 - 顺序扫描 / TPAFE 快照消费 / 取数守卫 / poll 契约 / 状态码统计,失败即非 0 退出。 + DMA 乒乓帧消费(含半区选择)/ TPAFE 快照消费 / 取数守卫 / poll 契约 / 四类故障 + 计数(seq / gap / err / hw),失败即非 0 退出。 零硬件、零烧录、毫秒级。 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness diff --git a/test/host/adc_input/stub/adc.h b/test/host/adc_input/stub/adc.h index 590c798c..7600efe7 100644 --- a/test/host/adc_input/stub/adc.h +++ b/test/host/adc_input/stub/adc.h @@ -7,7 +7,8 @@ * * 保真点:F4 HAL 的 ADC_CHANNEL_x 宏值**即通道号 x**(0..15),本桩按此定义, * 故被测代码里 `(uint8_t)ADC_CHANNEL_14` 得到 14 —— 与真机一致。 - * 另声明 4 个 HAL_ADC_* 接口,由测试单元给强符号桩(可注入失败/超时/返回值)。 + * 另声明 1 个 HAL_ADC_Start_DMA 接口(由测试单元给强符号桩,可注入挂载失败)与 + * 3 个 HAL 回调(**由被测代码 adc_input.c 实现**,测试直接调用它们模拟 DMA 中断)。 * * 创建日期:2026-10-10 */ @@ -50,9 +51,14 @@ extern ADC_HandleTypeDef hadc1; #define ADC_CHANNEL_14 (0x0000000EU) #define ADC_CHANNEL_15 (0x0000000FU) -HAL_StatusTypeDef HAL_ADC_Start(ADC_HandleTypeDef *hadc); -HAL_StatusTypeDef HAL_ADC_PollForConversion(ADC_HandleTypeDef *hadc, uint32_t Timeout); -uint32_t HAL_ADC_GetValue(ADC_HandleTypeDef *hadc); -HAL_StatusTypeDef HAL_ADC_Stop(ADC_HandleTypeDef *hadc); +/* v2.0:STM32 侧改 TIM3 TRGO 触发 + DMA 双缓冲,本层只调 Start_DMA 一次; + * 逐通道 Start / PollForConversion / GetValue / Stop 已从被测代码中消失,桩同步删除 */ +HAL_StatusTypeDef HAL_ADC_Start_DMA(ADC_HandleTypeDef *hadc, uint32_t *pData, uint32_t Length); + +/* 三个 HAL 弱回调:真机上由 DMA 中断上下文调用,这里由**被测代码实现**、 + * 由测试单元直接调用以模拟"一帧搬运完成" */ +void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc); +void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc); +void HAL_ADC_ErrorCallback(ADC_HandleTypeDef *hadc); #endif /* ADC_H */ From 54d6d2d5d56362a36ccf61589023bb821d50a93a Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 00:38:50 +0800 Subject: [PATCH 16/47] =?UTF-8?q?docs:=20=E5=88=A0=E9=99=A4=20Task=20#32?= =?UTF-8?q?=20=E5=81=9C=E6=91=86=E5=88=86=E6=9E=90=EF=BC=88=E5=B7=B2?= =?UTF-8?q?=E7=BB=93=E6=A1=88=EF=BC=89=EF=BC=8C=E5=8F=AF=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=E5=B9=B6=E5=85=A5=E7=9C=8B=E9=97=A8=E7=8B=97?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E8=AF=B4=E6=98=8E=20=C2=A711?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按登记簿"完成即销项"规则删除 docs/问题记录/UI任务看门狗停摆分析.md (560 行)。结案依据:累计 38 次上板启动 0 复现(原 L2 基线 3/10), P0/P1-4a/P1-5/P2/方案 A 全部落地,方案 B 已评估决定不做。 并入 docs/子系统设计/看门狗设计说明.md 新增 §11(只留可复用部分): - §11.1 三条易误读的判读口径:启动期 MISS start 0x67 是良性 episode; miss= 是累计不是连续(连续数饱和在 WD_MISS_TOLERANCE=8); reset cause 不能区分 IWDG 溢出与台架软件复位 - §11.2 诊断四步:先看掩码缺几位(只缺 1 位 = 定点打击,多位 = 全局 饥饿)→ 逐项量化占空比(实测 10%~13%,否掉"CPU 饱和"直觉)→ 用计数速率反证空转(ring 恰好 1000/s)→ 用 [PROF] 指认占用者 - §11.3 三条结构性脆弱点(uiTask 唯一 BelowNormal/心跳曾依赖 netTask 往返/自定截止期不可恢复)+ 两个顺带修掉的正确性缺陷(无新帧仍压环 ≈3% 频率失配、失败拍静默沿用旧值) - §11.4 [PROF]/[PROF2] 全字段语义 + v2.8 实测基线(netp=0 是真实值、 adcm 峰值来自心跳日志、uip 地板 = LVGL 帧周期)与三机理判读口诀 - §11.5 三条教训:门控类优化必须保留低频兜底;门控会顺手消灭暴露路径; 事件驱动要核对节拍源启动时刻 vs 消费者创建时刻 - §11.6 明确不做的四条及理由(提 uiTask 优先级会掩盖真缺陷、net_* 有界 等待版会串回执、推流搬出 uiTask、推流塞进 netTask) UI 推流门控与方案 B 否决理由收进 RemoDispBus 协议说明 §3(该处原本已有 摘要,改为自洽不再回指);就绪门控 API 的约束已在 BSD Socket 指南 §10.1。 引用改指 12 处:ui_task.c、task_prof.c/.h、ui_net_sink.c/.h、docs/README、 RemoDispBus、看门狗设计说明 R7 行、ADC 采样节拍设计说明、BSD Socket 指南 ×2、项目审查报告 N-10 ×2。 ⚠ ui_task.c / ui_net_sink.c / ui_net_sink.h 是带 BOM 文件,Edit 剥掉了 BOM,已按原样补回 3 字节 EF BB BF(未整文件重写)。 验证:check_doc_links --all 通过(127 个文档,无悬空引用); @build 0 错 0 警,Code=257660 与删除前一字不差(纯注释改动)。 --- App/task/ui_task.c | 6 +- App/util/task_prof.c | 2 +- App/util/task_prof.h | 4 +- Drivers/BSP/UI/ui_net_sink.c | 2 +- Drivers/BSP/UI/ui_net_sink.h | 2 +- docs/README.md | 3 +- docs/UI设计/RemoDispBus协议与接收设计说明.md | 9 +- docs/子系统设计/ADC采样节拍设计说明.md | 2 +- docs/子系统设计/看门狗设计说明.md | 91 ++- docs/网络/BSD_Socket_API_使用指南.md | 4 +- docs/问题记录/UI任务看门狗停摆分析.md | 560 ------------------- docs/问题记录/项目审查与优化建议报告.md | 4 +- 12 files changed, 110 insertions(+), 579 deletions(-) delete mode 100644 docs/问题记录/UI任务看门狗停摆分析.md diff --git a/App/task/ui_task.c b/App/task/ui_task.c index 6079be99..6e6d1d60 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -9,8 +9,8 @@ * v1.2 2026-10-10 王建锋 Task #32 的 P2 测量埋点:主循环加常开探针 * (循环周期 / 单轮工作耗时 / ui_net_sink_poll 往返), * 用于把"心跳缺席"拆成 饿死 / 阻塞在往返 / 自身拖长 - * 三种互斥机理(见 docs/问题记录/UI任务看门狗停摆分析.md) - * v1.3 2026-10-10 王建锋 Task #32 的 P1-4a(解耦心跳,§4.2):把 task_wd_beat + * 三种互斥机理(判读见 docs/子系统设计/看门狗设计说明.md §11.4) + * v1.3 2026-10-10 王建锋 Task #32 的 P1-4a(解耦心跳,看门狗设计说明 §11.3):把 task_wd_beat * (TASK_WD_UI) 提到 ui_net_sink_poll() **之前**。原写法心跳 * 在推流往返之后 → 心跳周期 = 渲染 + 一次 netTask 往返 + 10ms, * 实测 uiw≈uis≈50ms(心跳被 netTask 往返主导),心跳实际测的是 @@ -150,7 +150,7 @@ void ui_task_func(void *argument) { tm_loop_valid = 1U; #endif ui_port_step(10); - /* 心跳**先于**推流往返发出(P1-4a,Task #32 §4.2):UI 心跳测的是 + /* 心跳**先于**推流往返发出(P1-4a,看门狗设计说明 §11.3):UI 心跳测的是 * "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后, * 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后, * 即使往返偶发变慢,心跳也不被其拖垮。 diff --git a/App/util/task_prof.c b/App/util/task_prof.c index 9cbded57..ef91e6cb 100644 --- a/App/util/task_prof.c +++ b/App/util/task_prof.c @@ -9,7 +9,7 @@ * 修改记录: * v1.0 2026-10-10 王建锋 创建(Task #32 的 P2 测量埋点) * - * 设计依据:docs/问题记录/UI任务看门狗停摆分析.md §8(P2 测量项) + * 设计依据:docs/子系统设计/看门狗设计说明.md §11.4(探针字段语义与实测基线) */ #include "task_prof.h" diff --git a/App/util/task_prof.h b/App/util/task_prof.h index bfbac61d..3104cfde 100644 --- a/App/util/task_prof.h +++ b/App/util/task_prof.h @@ -11,7 +11,7 @@ * 修改记录: * v1.0 2026-10-10 王建锋 创建(Task #32 的 P2 测量埋点) * - * 设计背景(docs/问题记录/UI任务看门狗停摆分析.md §8 P2): + * 设计背景(docs/子系统设计/看门狗设计说明.md §11.4): * uiTask 偶发心跳缺席导致 IWDG 复位,但"占用者"无法从既有日志判定。 * 同一现象至少对应三种互斥机理,观测上必须能区分: * ① 被调度饿死 → 循环周期长,而段内耗时短; @@ -36,7 +36,7 @@ * NET_POLL netTask net_poll()(enter/exit)——CH395F 驱动轮询 * * 后四个探针用于**指认占用者**:UI 心跳缺席时,谁的最坏耗时在同一窗口抬起来, - * 谁就是挤占 CPU 的 Normal 任务(见 docs/问题记录/UI任务看门狗停摆分析.md §7.1)。 + * 谁就是挤占 CPU 的 Normal 任务(见 docs/子系统设计/看门狗设计说明.md §11.2 第 4 步)。 */ #ifndef TASK_PROF_H diff --git a/Drivers/BSP/UI/ui_net_sink.c b/Drivers/BSP/UI/ui_net_sink.c index cc3aa4ef..74d22bcb 100644 --- a/Drivers/BSP/UI/ui_net_sink.c +++ b/Drivers/BSP/UI/ui_net_sink.c @@ -478,7 +478,7 @@ int ui_net_sink_start(uint16_t port) { * (accept / recv / 3204 B 的 LCDMEM 应答),仍是"入队 + 等 netTask 回执", * 最坏 ≈4 s。宿主 = uiTask 主循环;"搬出 uiTask"已完整评估并**决定不做** * (实测刷新最坏周期 = LVGL 帧周期地板,与网络无关),理由与残留暴露 - * 见 docs/问题记录/UI任务看门狗停摆分析.md §9.4 + * 见 docs/UI设计/RemoDispBus协议与接收设计说明.md §3 */ void ui_net_sink_poll(void) { int n; diff --git a/Drivers/BSP/UI/ui_net_sink.h b/Drivers/BSP/UI/ui_net_sink.h index a225ec2e..d5447b37 100644 --- a/Drivers/BSP/UI/ui_net_sink.h +++ b/Drivers/BSP/UI/ui_net_sink.h @@ -53,7 +53,7 @@ int ui_net_sink_start(uint16_t port); * xTaskNotifyWait(portMAX_DELAY),均无上界;LCDMEM 应答 3204 B 最坏 ≈4 s) * → 会拖住宿主(uiTask)的刷新;"搬出 uiTask"已完整评估并**决定不做** * (实测刷新最坏周期 = LVGL 帧周期地板,与网络无关),见 - * docs/问题记录/UI任务看门狗停摆分析.md §9.4 + * docs/UI设计/RemoDispBus协议与接收设计说明.md §3 */ void ui_net_sink_poll(void); diff --git a/docs/README.md b/docs/README.md index 00b85e2f..5011e020 100644 --- a/docs/README.md +++ b/docs/README.md @@ -88,7 +88,7 @@ docs/ | [`系统墙钟时间维护说明.md`](子系统设计/系统墙钟时间维护说明.md) | `sys_clock`:RTC 基准、tick 回绕防护、30 天惰性再基准化 | | [`控制台单写者日志设计说明.md`](子系统设计/控制台单写者日志设计说明.md) | 控制台日志单写者模型(避免多任务交错输出) | | [`运行与自检记录设计说明.md`](子系统设计/运行与自检记录设计说明.md) | `/log/run.log`·`selftest.log` 定长记录与待机显示 | -| [`看门狗设计说明.md`](子系统设计/看门狗设计说明.md) | IWDG(CubeMX 配置)+ 任务心跳位图:选型与超时推导、掩码与埋点、取证机制、验证记录;已台架闭环(V2/V4/V5) | +| [`看门狗设计说明.md`](子系统设计/看门狗设计说明.md) | IWDG(CubeMX 配置)+ 任务心跳位图:选型与超时推导、掩码与埋点、取证机制、验证记录;已台架闭环(V2/V4/V5)。**§11 = 任务级饥饿的诊断方法与 `[PROF]` 实测基线**(Task #32 沉淀:判读口径、量化归因、三条脆弱点、明确不做的四条) | | [`ADC采样节拍设计说明.md`](子系统设计/ADC采样节拍设计说明.md) | **ADC 采集链路采样节拍**:TIM3 1 kHz 更新事件同时触发 TPAFE CONVST 与(经 TRGO)STM32 ADC1 + DMA 乒乓缓冲;方案选型、CubeMX 配置规格、代码改造清单、时序/优先级分析、验收判据与实测剖面;**已落地并上板闭环** | | [`网络/BSD_Socket_API_使用指南.md`](网络/BSD_Socket_API_使用指南.md) | **上层视图**:BSD Socket API 公开语义、示例、常见问题 | | [`网络/BSD_Socket_API_函数调用关系.md`](网络/BSD_Socket_API_函数调用关系.md) | **内部视图**:netTask / 消息队列 / L3 挂起 / 重试对账的调用关系与设计意图 | @@ -186,7 +186,6 @@ docs/ | [`GD5F2GQ5UE_Trap_Records.md`](问题记录/GD5F2GQ5UE_Trap_Records.md) | GD5F2GQ5UE SPI NAND 陷阱台账 | | [`SD2506_Trap_Records.md`](问题记录/SD2506_Trap_Records.md) | SD2506 RTC 陷阱台账 | | [`错误码传播审计.md`](问题记录/错误码传播审计.md) | 错误码传播缺陷审计(9 类模式 × 子系统) | -| [`UI任务看门狗停摆分析.md`](问题记录/UI任务看门狗停摆分析.md) | **Task #32 归因**:uiTask 看门狗停摆(L2 引入的间歇性回归)——否掉"CPU 饱和",定位为**优先级饥饿 + 心跳依赖 netTask 往返**;含量化算式、独立缺陷(无新帧仍压环)与 P0/P1/P2 处置建议 | --- diff --git a/docs/UI设计/RemoDispBus协议与接收设计说明.md b/docs/UI设计/RemoDispBus协议与接收设计说明.md index 7901c412..cfab465a 100644 --- a/docs/UI设计/RemoDispBus协议与接收设计说明.md +++ b/docs/UI设计/RemoDispBus协议与接收设计说明.md @@ -250,9 +250,12 @@ if (n > 0) { > ⚠ **残留暴露(已知,刻意不处理)**:门控只消除"无谓的往返",消除不了"往返本身无上界"—— > 客户端在连且 netTask 拥塞时,3204 B 的 LCDMEM 应答仍可能让宿主任务等最长 ≈4 s。 > "把推流搬出 uiTask(独立任务 + 帧 seqlock)"已完整评估并**决定不做**:实测刷新最坏周期 -> (`uip` ≈61 ms)是 **LVGL 帧周期地板**、与网络无关,搬任务不会让刷新变快。 -> 否决理由、残留暴露的观测手段(`[PROF]` 的 `uis`/`uisn`)见 -> `docs/问题记录/UI任务看门狗停摆分析.md` §9.4。 +> (`uip` ≈61 ms)是 **LVGL 帧周期地板**、与网络无关,搬任务不会让刷新变快;成本却含 +> **必改 `.ioc`** 的重生成与全套回归,收益只剩极端并发下的观感隔离。若日后它成为实际问题, +> 正确方向是先治 netTask 排队公平性(登记簿 N-4 二期),不是把 UI 搬走。 +> 观测手段:`[PROF]` 的 `uis`(往返最坏)与 `uisn`(此刻仍在推流段内多久)—— +> **`uisn` 持续非 0 就是这一格真发生的铁证**;字段语义与判读见 +> [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md) §11.4。 --- diff --git a/docs/子系统设计/ADC采样节拍设计说明.md b/docs/子系统设计/ADC采样节拍设计说明.md index 9d22f9f3..56031d2f 100644 --- a/docs/子系统设计/ADC采样节拍设计说明.md +++ b/docs/子系统设计/ADC采样节拍设计说明.md @@ -373,7 +373,7 @@ t=1000 µs 下一个 TIM6 更新事件 - **实测印证**:`seq − frames = 20006 − 19999 = 7`——正是这 ≈100 ms 窗口里 TIM6 发出的 7 个脉冲 被丢弃(当时无消费者),之后一帧不差。 -**教训(与 `docs/问题记录/UI任务看门狗停摆分析.md` §9.3.1 的 I-2/I-3 同类)**:把"定时轮询"换成 +**教训(与 `docs/子系统设计/看门狗设计说明.md` §11.5 的三条"实施时才暴露"同类)**:把"定时轮询"换成 "事件驱动"时,必须单独核对**节拍源启动时刻 vs 消费者创建时刻**,否则第一次上电就会踩到 "ISR 对还不存在的消费者动手"。 diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index 9ab0721a..85738eb4 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -456,7 +456,7 @@ snapshot == required → miss_streak=0、证据门闩复位、喂狗 | R4 | **NAND 真实卡死时 FTL 直接喂狗** | 设备存活但存储功能冻结(与 ORE 自愈同类边界)。若要求"NAND 死也必须复位",需给 `nand_ftl_wd_feed()` 加次数/时限上限 | | R5 | **栈溢出与断言在复位原因上不可区分**(都是 `SFTRSTF`) | 可接受;串口分别打 `[ERR] STACK OVERFLOW` / `[ERR] ASSERT FAILED`,`selftest.log` 侧另有记录 | | R6 | **`RCC_CSR` 读后即清** | 已在 `main.c` 统一处理;漏清会让下次软复位读回上次标志 | -| R7 | **UI 推流的网络往返卡死不占心跳位**(产品掩码仍 `0x77`) | 刻意:往返卡死的根因在 netTask,netTask 已在掩码内、由它暴露;为"远端显示卡了"新增一位只会把观感问题升级成整机复位。UI 心跳已由 P1-4a 自产、不受推流影响;推流耗时看 `[PROF]` 的 `uis`/`uisn`。详见 `docs/问题记录/UI任务看门狗停摆分析.md` §9 | +| R7 | **UI 推流的网络往返卡死不占心跳位**(产品掩码仍 `0x77`) | 刻意:往返卡死的根因在 netTask,netTask 已在掩码内、由它暴露;为"远端显示卡了"新增一位只会把观感问题升级成整机复位。UI 心跳已由 P1-4a 自产、不受推流影响;推流耗时看 `[PROF]` 的 `uis`/`uisn`。成因与判读见本文 **§11.3 第 2 条**;门控方案与残留暴露见 `../UI设计/RemoDispBus协议与接收设计说明.md` §3 | ### 9.2 未办项(登记簿同步) @@ -520,6 +520,94 @@ python scripts/check_cubemx_survival.py --write # 确认改动无误后重建 --- +## 11. 任务级饥饿的诊断方法与实测基线(Task #32 沉淀,2026-10-10) + +> **来源**:`[ERR][WD] task stall: mask=0x00000067 req=0x00000077`(缺 bit4 = `TASK_WD_UI`) +> 的完整归因与处置。故障本身已结案——**累计 38 次上板启动 0 复现**(原 L2 基线 3/10), +> 原始分析文档按登记簿"完成即销项"规则删除。本节只留**可复用的判读口径、诊断方法、 +> 实测基线与教训**;ADC 侧的处置细节在 [`ADC采样节拍设计说明.md`](ADC采样节拍设计说明.md) §10/§11, +> UI 推流门控在 `../UI设计/RemoDispBus协议与接收设计说明.md` §3,就绪门控 API 在 +> `../网络/BSD_Socket_API_使用指南.md` §10.1。 + +### 11.1 现场日志的三条判读口径(都被误读过) + +| 现象 | 正确读法 | +|---|---| +| 启动期出现 `MISS start snap=0x67 req=0x77` | **良性 episode**:`[WD] armed` 在 bring-up 完成后武装,而 `required_mask` 静态含 UI 位 → UI 首跳之前必现一次,随后自行恢复。**不是故障判据**,只作"UI 尚未起跳"的旁证 | +| `miss=11` / `miss=12` | `miss=` 是**累计**缺席次数(`s_miss_total`),**不是连续次数**;连续次数 `s_miss_streak` 饱和在 `WD_MISS_TOLERANCE=8`。缺口要用 `t=`(毫秒时基)差算,本例恰为 800 ms | +| `reset cause: software (NVIC_SystemReset)` | **不能**用来区分"IWDG 溢出"与"台架拉 NRST / 烧录工具软件复位"——本台架**健康**启动同样报这一串。停摆的权威证据是 `[ERR][WD] task stall` 行本身 | + +### 11.2 诊断四步:先量化,再归因 + +1. **看掩码缺哪几位。** 只缺**一位** = 定点打击(该任务独有的依赖或优先级处境); + **多位同时缺** = 全局饥饿 / CPU 饱和。本例 `0x67` 只缺 UI,其余五个 Normal 任务心跳 + 全正常 → **当场否掉"计算量把 CPU 吃满"这个直觉**(用户假设方向对、结论不成立)。 +2. **把嫌疑任务的单拍成本逐项算出来**(不要估)。当时算式:ADC1 一轮 9 通道 ≈67 µs、 + TPAFE 快照 + 9 路取数 ≈10~20 µs、压环 + 5 实例傅氏 ≈10~15 µs → 单拍 0.10~0.13 ms + = **占空比 10%~13%**,距饱和很远。 +3. **用计数速率反证"有没有空转"。** `ring` 在两次心跳间严格 +10000/10 s = **恰好 1000 次/秒**; + 若该任务失去阻塞(空转),按 0.11 ms 单拍应为 ≈9000 次/秒 → 反证它每拍都睡满。 +4. **用 `[PROF]` 指认最大非让出占用**(§11.4)。实测最大是 `defaultTask` 控制台排空 + ≈46 ms,**没有任何任务接近 800 ms** → 800 ms 属**异常长尾**,只能靠复现现场的快照抓, + 不能靠健康剖面指认;⚠ 别把"健康剖面里没有"当成"不存在"。 + +### 11.3 三条结构性脆弱点(当时全部从代码证实) + +| # | 脆弱点 | 现状 | +|---|---|---| +| 1 | **uiTask 是 `required_mask` 里唯一的 `osPriorityBelowNormal`**(其余五个全 `Normal`)。FreeRTOS 数值越大优先级越高 → 任一 Normal 任务持续就绪,uiTask 就**完全拿不到 CPU**;`configUSE_TIME_SLICING=1` 只保护**同优先级**公平 | **至今未变**(刻意不提优先级,理由见 §11.6)。风险由"心跳自产 + 往返门控 + 采集零忙等"三面压住 | +| 2 | **UI 心跳曾依赖 netTask 往返**:`ui_net_sink_poll()` → `net_accept()` → `osMessageQueuePut(osWaitForever)` + `xTaskNotifyWait(portMAX_DELAY)`,**之后**才 `task_wd_beat(TASK_WD_UI)` → netTask 慢一拍、UI 心跳晚一拍。这正是"**只有 UI 位缺失**"的根因(别的任务没这条依赖) | ✅ 已断两处:心跳提到 poll **之前**(`App/task/ui_task.c`);无客户端时门控让往返 = **0 次**(`uis` 实测 1 µs) | +| 3 | **自定截止期一旦错过就不可恢复**:`next += 1; osDelayUntil(next)` 且**丢弃返回值** → 单拍 >1 ms 后 `next` 相对当前时刻永久落后 → 该任务从此不再阻塞、以 Normal 优先级空转(**连续**慢拍才锁死,单次慢拍会自己追平)。返回值本是现成的重同步钩子 | ✅ 已消:adcTask 改**事件驱动唤醒**(`osThreadFlagsWait`),"落后"这个概念不存在了(`ADC采样节拍设计说明.md` §10) | + +⚠ 顺带揪出的两个**独立正确性**缺陷(与停摆无关,但同源发现,别只当性能问题): + +- **无新帧仍压环**:`r == 0`(TPAFE 无新帧)时也压帧 → 上一拍的重复值进 20 点 DFT 窗, + 等效采样间隔被拉长。当时 `idle` 占 3.2% → 窗长从 20.0 ms 漂到 ≈20.6 ms ≈ **3% 频率失配** + → 幅值/相位**系统性误差**(不是随机噪声)。现仅 `r == 1` 压帧,上板印证 `ring == frames`。 +- **失败拍静默沿用上一拍值**:失败支路不清 `s_stm32_valid` → 照样压环、`skip` 抓不到。 + 现失败即清标志走整帧跳过(`ADC采样节拍设计说明.md` §10.4;单测 TC-AI-070..078 钉死)。 + +### 11.4 `[PROF]` / `[PROF2]` 字段语义与当前基线 + +探针本体见 `App/util/task_prof.h`(DWT `CYCCNT`,168 周期 = 1 µs;每 ID 记 max/count/last, +enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的段永不结算,只有这个字段能证明**)。 + +| 字段 | 探针 | 含义 | 基线(2026-10-10 v2.8,无客户端,开机 29 s) | +|---|---|---|---| +| `uip` / `uil` | UI_PERIOD | uiTask 循环周期最坏 / 最近值 | **61.3 ms** / 10.0 ms——⚠ 地板是 **LVGL 整帧渲染**(独立实测帧周期 61.6 ms),**与网络无关** | +| `uiw` | UI_WORK | 本轮起点→心跳前(含 LVGL) | **11.4 ms**(改前 ≈50 ms 且 ≈`uis` → 当时单轮几乎全是推流) | +| `uis` / `uisn` | UI_SINK | 推流往返最坏 / 此刻仍在段内 | **1 µs** / 0(改前 ≈50 ms);**`uisn` 持续非 0 = "客户端在连且 netTask 拥塞"这一格真发生的铁证** | +| `adcm` | ADC_BEAT | adcTask 单拍(含心跳日志) | 2.0~2.25 ms——⚠ 峰值来自**每 10 s 一次的心跳日志**,不是采集,勿混读 | +| `adcp` | ADC_POLL | `adc_input_poll()` 单次 | **8 µs**(DMA 前 122 µs;超时拍曾 2.2 ms) | +| `defd` / `defs` | DEF_DRAIN / DEF_SYSLOG | 控制台排空 / syslog 落盘 | **46 ms** / 7 µs——**当前最大的单一非让出占用** | +| `netp` / `netn` | NET_POLL | `net_poll()` 最坏 / 在段内 | **0 µs** / 0——⚠ **0 是真实值**:空闲时 `net_poll()` 开头读 INT# GPIO 为高即 `break`,整条就是一次 GPIO 读,别判成"探针坏了" | +| `gl` | glitch | DWT 丢样 / 计数器重置计数 | 0(非 0 时以上数据不可信) | + +**判读口诀**:`uip` 大而 `uiw`/`uis` 小 → **被调度饿死**;`uis` 大 → **阻塞在网络往返**; +`uiw` 大而 `uis` 小 → **自身渲染拖长**。三种机理在日志上必须能分开,否则归因只能靠猜。 + +### 11.5 三条教训(同类问题会复发) + +1. **门控类优化必须保留低频兜底。** `readable` 只由 RECV_OK 中断置位,而 `net_reconcile_tick()` + 的三类对账**不含**"芯片 RX 非空但 `readable=0`" → 中断一旦丢失,纯门控会把已到达的数据 + **永久藏住**。现每 32 轮无条件 recv 一次。**拿"自愈能力"换性能是隐性负债。** +2. **门控会顺手消灭暴露路径。** 加了 accept 门控后,监听控制块消失时 `net_can_accept()` + 恒返 0 → 改前"每轮 accept 撞 BADF"这条**暴露**随之消失 → 端口永久不再接受新客户端而无声。 + 现补 `net_get_sock(g_listen) == NULL` 判定。**加"跳过"逻辑时要问:原来哪条错误是靠这次调用暴露出来的?** +3. **把"定时轮询"换成"事件驱动"时,必须单独核对节拍源启动时刻 vs 消费者创建时刻** + (本仓 TIM7 分支 2026-10-02 的实测坑同款)——否则第一次上电就踩"ISR 对还不存在的消费者动手"。 + +### 11.6 明确不做的四条(理由要留着,别被"顺手优化"推翻) + +| 备选 | 为什么不做 | +|---|---| +| **提高 uiTask 优先级** | 会**掩盖**"心跳依赖他任务"与"忙等"两个真实缺陷;而且它根本不解决"UI 在等 netTask"。缺陷已修完后,提优先级才成为可讨论项 | +| 给 `net_*` 加"有界等待"版 | 回执通道是"每任务一个 32 位通知值",超时后**迟到的回执会被下一次调用的零等待拾取当成新结果**(`net_wait_result` 注释里 L7 挂死/整段错位的同款根因)。要做对必须引入请求序号,代价高于搬家 | +| 把推流搬出 uiTask(独立 `uiSinkTask`) | 实测 `uis`=1 µs 而 `uip` 仍 60.9 ms = **LVGL 帧周期地板** → 搬走推流**不会让刷新变快**;收益只剩极端并发下的观感隔离,成本含**必改 `.ioc`** 的重生成与全套回归。若日后成问题,正确方向是先治 netTask 排队公平性(登记簿 N-4 二期),不是搬 UI | +| 把推流塞进 netTask | 零往返零新增 RAM,但共享源码要走 backend 端口(双端同改)、netTask 这个唯一执行者更胖、一个 UI 侧 bug 直接变成整机复位 | + +--- + ## 附录 A:参考依据(当前有效) | 来源 | 引用点 | @@ -574,3 +662,4 @@ python scripts/check_cubemx_survival.py --write # 确认改动无误后重建 | 2026-10-10 | **正文按当前代码重写(清理立项前的描述)** | 正文原样保留了大量**立项前(2026-09-29)**的状态取证与已废弃的"路径 C 考古",与现状矛盾,故重写为现状设计说明(1144 → 571 行;**历史与逐次决策全部保留在下方变更记录,未删**)。主要更正:① §1.1"现状:无任何硬件看门狗"改为**历史背景**(IWDG 已在 `.ioc`/`hal_conf`/工程三处就位);② **ftpTask 早已纳入监视**——经 `lftpd_task_wd_beat()` 弱钩子(事件循环 / RETR·STOR 分片 / 连接与卷锁等待),旧正文"首期不埋点、由掩码剔除"及 R3/O1 已作废;③ 补齐正文缺失的三块现状实现:**FTL 心跳路由**(`task_wd_bind()` + `nand_ftl_wd_beat()`)、**FTL 直接喂狗**(`nand_ftl_wd_feed()`,含"设备存活但功能冻结"边界)、**round 7/8/8b 取证机制**(`/log/wdev.log`、`.noinit` 槽 + `UNINIT_CCM`、`PREVRUN`/`TICKRUN`/`MISS start`/`STALL`);④ 掩码改为按代码实况列表(产品 `0x37` / `ENABLE_HW_LAYER_TESTS` `0x35` / **testTask 任何构建都不入掩码**)并写明"精确相等"语义;⑤ 复位原因优先级按 `reset_cause_str()` 实际顺序改写(旧表把 POR 排第一,代码是 **IWDG 最优先**);⑥ §4.6 更正为**已集成**:看门狗复位写 `selftest.log`(旧正文"未改");⑦ §8 预算改用 `.map` 实测(`task_watchdog.o` 1098 B 代码 / 44+104 B RAM / `.noinit` 48 B;`iwdg.o` 仅 28 B,`HAL_IWDG_Init` 94 B 被移除);⑧ 附录 B 自相矛盾修正(旧表把"借助 CubeMX"列为否决,实际已选它);⑨ §7 由"验证计划"改为**验证记录**(V2/V4/V5 ✅、U1 39 断言 ✅,V3/V6/V7 标为未单独注入、V8 未做)。连带同步:`Src/iwdg.c`、`Src/freertos.c`、`App/task/default_task.c` 三处注释的章节引用(旧 §4.5/§4.6 → 新 §4.1/§4.4),`scripts/check_cubemx_survival.py` 提示语(§10.1 → §10.2/§10.3),`实机测试实施指南.md`(§10 → §7 ×3)、`看门狗单元测试说明.md`(§10 → §7)、`模拟器差距清单.md` H-1(§4.9 → §4.7,并更正"模拟器不编译本模块、无 IWDG 桩")、`docs/README.md`("实施中/待验证" → 已闭环)。⚠️ **下方历史行里的 §x.y 引用指当时编号**,不再回溯改写 | | 2026-10-10 | **adcTask 纳入监视(B1):必需掩码 `0x37`→`0x77`** | B1(ADC 采集链路)落地时新增 `adcTask`(`App/task/adc_task.c`:1 ms 采样节拍触发 CONVST + 消费 `tpafe5160_snapshot()`)并纳入 IWDG 监视:`task_wd_id_t` 新增 `TASK_WD_ADC = 6`;`wd_required_mask()` 产品 `0x37`→`0x77`、`ENABLE_HW_LAYER_TESTS` `0x35`→`0x75`(adcTask **不依赖任何就绪屏障** → 任何构建都置位)。adcTask 主循环每 1 ms `task_wd_beat(TASK_WD_ADC)`;**不做 NAND I/O → 不登记 FTL 心跳路由**(无需 `task_wd_bind()`)。主机侧单测 `beat_all()` 与掩码断言(`tc_003`/`tc_005`/`tc_011` 的 `0x37`→`0x77`)同步,39 断言全绿。任务由 `Src/freertos.c` 的 `USER CODE` 区手工创建(同 timeSyncTask,不在 `.ioc` 里),栈 512 words、优先级 Normal,`stack_report.py` 实测最坏链 504 B(余 1544 B / 75%)。见 `docs/算法/继电保护功能复现规划.md` §7 | | 2026-10-10 | **B1 `@test` 实机闭环:adcTask 掩码真机验证** | 台架闭环(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)确认 `adcTask` 纳入掩码后**无 adc 相关误复位**:`[STACK] adc peak_used=496/2048 free_min=1552 (75%)`、`frames/seq` 每 10 s 严格 +10000(=1000 Hz)、`idle=0`/`frst_err=0`、全片回读 `No difference found`。启动早期两次 `MISS start snap=0x67 req=0x77` 仅缺 bit4(`timeSync` 未启动,属正常就绪窗;adc 的 bit6 自始置位),掩码随后收敛、全程无复位。同轮另修 `dbg_console_writer_ready()` 门控(adcTask 周期日志在写者注册前抑制,见 `控制台单写者日志设计说明.md`) | +| 2026-10-10 | **新增 §11:任务级饥饿的诊断方法与实测基线** | 承接 Task #32(`task stall: mask=0x67 req=0x77`,缺 `TASK_WD_UI`)结案后的沉淀——原分析文档 `docs/问题记录/UI任务看门狗停摆分析.md` 按"完成即销项"删除,可复用部分并入本文:**§11.1** 三条易误读的判读口径(启动期 `MISS start 0x67` 良性、`miss=` 是累计不是连续、`reset cause` 不能区分 IWDG 与台架);**§11.2** 诊断四步(先看掩码缺几位 → 量化占空比 → 用计数速率反证空转 → 用 `[PROF]` 指认占用者;本例否掉"CPU 饱和",实测 10%~13%);**§11.3** 三条结构性脆弱点(uiTask 唯一 BelowNormal/心跳依赖 netTask 往返/自定截止期不可恢复)与两个顺带修掉的**正确性**缺陷(无新帧仍压环 ≈3% 频率失配、失败拍静默沿用旧值);**§11.4** `[PROF]`/`[PROF2]` 全字段语义 + v2.8 基线(含 `netp=0` 是真实值、`adcm` 峰值来自心跳日志、`uip` 地板 = LVGL 帧周期)与三机理判读口诀;**§11.5** 三条教训(门控须留低频兜底、门控会消灭暴露路径、事件驱动要核对节拍源与消费者创建时刻);**§11.6** 明确不做的四条及理由。§9.1 的 R7 行改指本文 §11.3 | diff --git a/docs/网络/BSD_Socket_API_使用指南.md b/docs/网络/BSD_Socket_API_使用指南.md index 0311fde7..8b132ba2 100644 --- a/docs/网络/BSD_Socket_API_使用指南.md +++ b/docs/网络/BSD_Socket_API_使用指南.md @@ -886,7 +886,7 @@ int net_can_read(int sockfd); /* 1 = 应发一次 recv;0 = 可跳过本轮 | `net_can_accept` 判据 | `state == ESTABLISHED`,与 `net_accept_locked()` 严格等价(单连接模型:监听 Socket 自身在 CONNECT 后转数据通道) | | `net_can_read` 判据 | `ESTABLISHED` → 看 `readable`;**非 `ESTABLISHED` 一律返 1**(必须让上层 recv 一次拿到 EOF/错误并复位自身状态,门掉它会把"连接已消失"永久藏住);描述符失效也返 1(让 recv 报 BADF 走既有错误分支) | | 用途 | **门控**:无活时跳过 `net_accept()`/`net_recv()`——两者内部都是 `osMessageQueuePut(osWaitForever)` + `xTaskNotifyWait(portMAX_DELAY)`,**均无上界**(队列深 8 且与 FTP/timeSync 共享) | -| ⚠ 不可作为唯一判据 | `readable` 只由 RECV_OK 中断置位,而 §3.6 的三类对账**不含**"芯片 RX 非空但 `readable=0`" → 中断丢失时 `net_can_read()` 恒返 0。**调用方必须保留低频兜底 recv**(`ui_net_sink.c` 每 32 轮无条件收一次,见 `docs/问题记录/UI任务看门狗停摆分析.md` §9.3.1) | +| ⚠ 不可作为唯一判据 | `readable` 只由 RECV_OK 中断置位,而 §3.6 的三类对账**不含**"芯片 RX 非空但 `readable=0`" → 中断丢失时 `net_can_read()` 恒返 0。**调用方必须保留低频兜底 recv**(`ui_net_sink.c` 每 32 轮无条件收一次,教训见 `docs/子系统设计/看门狗设计说明.md` §11.5 第 1 条) | | 竞态 | netTask 是唯一写者,单字读;最坏"晚一轮发现"(netTask 每 1 ms 就 `net_poll()`)或"扑空"(RPC 返回 `NET_ERR_WOULDBLOCK`),均无副作用 | | 模拟器端 | `net_socket_sim.c` 同名实现**恒返回 1**——Winsock 本就非阻塞、无 RPC 成本,恒 1 使门控在模拟器上逐位等价于改前行为 | @@ -1412,7 +1412,7 @@ CH395F 底层驱动返回的状态码(`ch395f_get_cmd_status()` 或 `ch395f_po | 日期 | 变更 | |------|------| -| 2026-10-10 | 新增 **`net_can_accept()` / `net_can_read()` 就绪门控 API**(§10.1):纯 RAM 读控制块、**不走消息队列、不等 netTask 回执**,供实时性敏感任务在发 RPC 前判断"有没有活"(动因 = `ui_net_sink_poll()` 每 10 ms 一次 `net_accept` 把 uiTask 拖成最坏 ≈50 ms;原拟登记 N-19 / C7,当日结案不入登记簿;设计与实测见 `docs/问题记录/UI任务看门狗停摆分析.md` §9);§3.5 补"netTask 运行要求"例外条;⚠ 记录两条使用约束——**不可作为唯一判据**(`readable` 只由 RECV_OK 中断置位,须保留低频兜底 recv)与**模拟器后端恒返 1**(`net_socket_sim.c`,Winsock 无 RPC 成本) | +| 2026-10-10 | 新增 **`net_can_accept()` / `net_can_read()` 就绪门控 API**(§10.1):纯 RAM 读控制块、**不走消息队列、不等 netTask 回执**,供实时性敏感任务在发 RPC 前判断"有没有活"(动因 = `ui_net_sink_poll()` 每 10 ms 一次 `net_accept` 把 uiTask 拖成最坏 ≈50 ms;原拟登记 N-19 / C7,当日结案不入登记簿;设计与实测见 `docs/子系统设计/看门狗设计说明.md` §11(原分析文档已并入该节));§3.5 补"netTask 运行要求"例外条;⚠ 记录两条使用约束——**不可作为唯一判据**(`readable` 只由 RECV_OK 中断置位,须保留低频兜底 recv)与**模拟器后端恒返 1**(`net_socket_sim.c`,Winsock 无 RPC 成本) | | 2026-09-23 | 同步 L3 pending 化、D10 非阻塞重监听、Trap 22 对账、Trap 23 排渣隔离后的现状:新增 `net_recv_to` / `net_sock_state` / `net_resync_listen` / `net_relisten`(§10.1)及 `net_getsockname`;§3 新增 3.6(对账与自愈四机制)、3.3/3.5 改写(recv 挂起模型、队列语义、netTask 运行要求);§4/§5 示例更新(监听由就绪屏障后服务任务建立、`net_sock_state` 轮询);§10.2 改述 `net_process_messages()` 每轮节奏(重监听重试 → 挂起 recv 完成 → 200ms 对账 → 队列),补 `timeout_ms`/新消息类型说明;新增 §11.2 分片阻塞读(`net_recv_to`);§12.1/12.3/12.4/12.9 更新,新增 §12.10(自愈行为数据与排障判据,含 Trap 21 结案) | | 2026-09-21 | **修正公开 API 的错误码回传**:`net_msg_t` 按值拷入队列 → netTask 对消息的写入调用方不可见,改用 `xTaskNotify()` 通知值回传具体错误码(负值),公开 API 恢复 errno 后统一返回 -1(此前被覆盖成 `NET_ERR`,使 `net_get_errno() != NET_ERR_WOULDBLOCK` 的"空轮询"判断恒成立 → `ui_net_sink` 连上即 `net_close`);§12.4 补单连接模型断开处理(EOF 时不得 `net_close` 监听 socket);§12.9 补错误码回传约束 | | 2026-09-15 | 同步配置引擎属主变更(P1-3):`net_init` 调用方说明改为"netTask 只读 RAM 副本"(网口配置由 defaultTask 在就绪屏障前加载);模拟器端启动序列补 `net_cfg_save_run()`(主循环 = 模拟器侧配置引擎属主,执行 UI 保存请求) | diff --git a/docs/问题记录/UI任务看门狗停摆分析.md b/docs/问题记录/UI任务看门狗停摆分析.md deleted file mode 100644 index 2ee62e6d..00000000 --- a/docs/问题记录/UI任务看门狗停摆分析.md +++ /dev/null @@ -1,560 +0,0 @@ -# UI 任务看门狗停摆分析(Task #32) - -> **对象**:`[ERR][WD] task stall: mask=0x00000067 req=0x00000077`(缺 bit4 = `TASK_WD_UI`) -> → 连续 8 次(800ms)缺席 → 停喂 IWDG → 复位。 -> **背景**:L2 相量层落地(commit `3762c03`)后**新出现**的间歇性回归:L2 版 3/10 次运行失败, -> 改前基线(`git stash` 重建)0/6。 -> **状态**:**分析完成,全部处置已落地并上板验证**(2026-10-10)。P0(adc 压环条件 + 截止期 -> 重同步)、P1-4a(UI 心跳自产)、P2(常开 `[PROF]` 探针)、§9 方案 A(推流就绪门控 + 兜底)、 -> **P1-5(ADC 消费改定时器 ISR 事件驱动 + `adcTask` 提至 `AboveNormal`;同日 v2.8 进一步把 -> 节拍源定为 TIM3 并让 STM32 ADC1 走 TRGO + DMA 乒乓缓冲,§4.4 的 2 ms 忙等已消除)** 全部落地;停摆累计 -> **38 次上板启动 0 复现**(P0 后 22 次 + A 后 10 次 + P1-5 后 6 次)。§9 方案 B -> (把推流搬出 uiTask)**已评估并决定不做**。 -> **阅读口径**:§2~§7 是**故障当时**的证据与归因,其中已被处置改掉的代码片段就地标注 -> **改前 / 现状**;§8 末「当前健康剖面」是收口后的实测基线。⚠ 实测还修正了当初的归因—— -> 刷新最坏周期(`uip` ≈61 ms)是 **LVGL 帧周期地板,与网络无关**(§9.3.2)。 -> **关联**: -> [`../待处理内容.md`](../待处理内容.md)(**N-17** 心跳缺口登记)、 -> [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、 -> [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md)(1 kHz 节拍)、 -> [`../测试说明/实机测试实施指南.md`](../测试说明/实机测试实施指南.md)(停喂取证机制)、 -> [`../../AGENTS.md`](../../AGENTS.md)(看门狗铁律) - ---- - -## 1. 结论速览 - -**用户假设"计算量太大、CPU 负载满了"——方向对(L2 的 CPU 开销确实是触发因素),但结论不成立。** - -实测占空比约 **10%~20%**,距饱和很远(§3 有算式与日志反证)。真正的失效是 -**优先级饥饿 + 心跳路径依赖另一个任务**: - -1. `uiTask` 是 `required_mask` 六个任务里**唯一的 `osPriorityBelowNormal`**(其余五个全是 - `osPriorityNormal`)。FreeRTOS 下**就绪的 Normal 任务永远抢占 BelowNormal 任务**, - 时间片轮转只对**同优先级**生效 → uiTask 只能在"五个 Normal 任务同时阻塞"的缝隙里运行。 -2. **(改前)** `uiTask` 的心跳**不是自产的**:每轮先调 `ui_net_sink_poll()` → `net_accept()` - → `osMessageQueuePut` + **`xTaskNotifyWait(portMAX_DELAY)`**,即**绕 `netTask` 走一次往返**, - 之后才 `task_wd_beat(TASK_WD_UI)`——**netTask 慢一拍,UI 心跳就晚一拍。** - **现状**:P1-4a 已把心跳提到 poll **之前**(`App/task/ui_task.c:159`),A 门控又让无客户端时 - **一次 RPC 都不发**(§9.3)→ 这条依赖已断开,见 §4.2。 - -**决定性证据**:失败时掩码 `0x67` 只缺 UI 一位,其余五个任务(default / net / ftp / timeSync / adc) -的心跳**全部正常**。若是 CPU 饱和,Normal 级的 netTask/ftpTask/adcTask 会**一起**掉线——它们没有。 - ---- - -## 2. 证据:失败日志时间线 - -唯一留存的失败日志:`.dsh/skills/serial-assistant/scripts/state/serial.log`(2026-10-10 17:36)。 -(其余失败运行未归档,`docs/测试说明/boot_log_data/` 只存了通过的两次。) - -| 时刻(抓包侧) | 事件 | -|---|---| -| 17:36:12.551 | 内核启动;`reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` | -| 17:36:12.813 | `phasor layer init: OK (inst=5)` | -| 17:36:17.002 | `[FS] FATFS mounted`(距 NAND FTL ready 17:36:12.897 **约 4.1 s**) | -| 17:36:17.196 | `MISS start snap=0x00000067 req=0x00000077 t=4756` | -| 17:36:17.206 | `[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms` | -| 17:36:17.206 | `UI task: started, wait net ready` | -| 17:36:17.868 | `[STACK] ui peak_used=488/16384`(UI 尚未渲染) | -| 17:36:18.219 | `UI task: net ready, init port` → 菜单/待机屏初始化 | -| 17:36:18.343 | **`UI net sink: started (port 7003)`** ← **UI 的最后一条日志** | -| 17:36:18.343 | `MISS start snap=0x00000067 req=0x00000077 t=5904` | -| 17:36:19.902 | `[STACK] ui peak_used=4052/16384`(LVGL 开始渲染) | -| 17:36:22.850 | `[ADC_TASK] frames=9957 idle=44 skip=0 seq=10005 frst_err=0 adc1_rc=0 adc1_err=0 ring=10001` | -| 17:36:23.027 | `MISS start snap=0x00000067 req=0x00000077 t=10585` ← **最后一轮 episode 起点** | -| 17:36:23.743 | `STALL snap=0x00000067 req=0x00000077 h=00000067 ×8 mg=330 cf=0 seg=1` | -| 17:36:23.826/.837 | `[ERR][WD] task stall: mask=0x00000067 req=0x00000077 miss=11 / miss=12` | -| 17:36:23.951 | `[STACK] ui peak_used=4052/16384`(与 19.902 相同 → UI 此后**没有推进过**) | -| 17:36:32.855 | `frames=19352 idle=649 skip=0 seq=20005 frst_err=0 adc1_rc=0 adc1_err=288 ring=20001` | -| 17:36:34.981 | 再次启动:`reset cause: software (NVIC_SystemReset)` | - -**读法说明**(`App/util/task_watchdog.c:434`):`t=` 是毫秒计数;`miss=` 是**累计**缺席次数 -(`s_miss_total`),不是连续次数;连续次数 `s_miss_streak` 饱和在 `WD_MISS_TOLERANCE=8`。 -故 `t=10585` 起算、`t≈11385` 达 8 次 → **缺口恰为 800ms**,与 `miss_tol=800ms` 一致。 - -**关键观察**: - -- UI 的最后一条日志是"推流已启动",**之后进入主循环就再没有任何心跳**(`ui peak_used` 冻结在 4052)。 -- 同时段 adcTask、defaultTask(`[STACK]`)、net/ftp/timeSync 全部活着 → **非全局饥饿**。 -- `ui peak_used=4052/16384` → UI 栈余量 75%,**排除 UI 栈溢出**。 - ---- - -## 3. 量化:CPU 到底占了多少(回答"负载满了") - -### 3.1 逐项算式 - -| 项 | 算式 | 结果(@168 MHz,1 ms = 168 000 周期) | -|---|---|---| -| ADC1 顺序扫描 | `ADC_CLOCK_SYNC_PCLK_DIV4`(84 MHz / 4 = 21 MHz);每通道 (144 + 12) 周期 = 156/21 MHz ≈ 7.4 µs × **9 通道** | **≈ 67 µs** | -| TPAFE 原子快照 + 9 路 `adc_input_raw()` | 临界区拷贝 8 值 + 9 次查表 | ≈ 10~20 µs | -| 采样环压帧 + `phasor_run()` | 5 实例 × (20 点窗拷贝 + 20 次取模 + 40 次浮点乘加 + `sqrtf`) ≈ 400~500 周期/实例 | **≈ 10~15 µs** | -| 其中:整数取模 | 窗 100 次(`% 40`,常量 → 魔数乘)+ 计算 100 次(`% n`,**运行期变量 → 走 UDIV/`__aeabi_uidivmod`**) | ≈ 3~7 µs(**占比 0.3%~0.7%**) | -| **adcTask 单拍合计** | 上列之和 | **≈ 0.10~0.13 ms → 占空比 10%~13%** | - -**含 ADC 超时的最坏情形**:日志 `adc1_err=288`(10 s 窗口)= 288 次超时 × `ADC_INPUT_POLL_TIMEOUT_MS=2` ms -= **576 ms 额外忙等 / 10 s → +5.8%**。故 adcTask 最坏约 **16%~19%**。 - -### 3.2 结论 - -**合计 10%~20%,不是"负载满了"。** 按 1 ms 节拍算,adcTask 每拍仍能睡约 0.87 ms。 -**现状印证**:收口后 `[PROF2]` 的 `adcp`(ADC 单次最坏)实测 **0.12~2.5 ms**、`[PROF]` 的 -`adcm`(单拍最坏)**≈2.1 ms**;`adc1_err` 从当年的 **288/10 s** 降到 **1**(§7.2)—— -上表"+5.8% 忙等"那一项如今基本不出现。 - -### 3.3 日志反证(有力) - -`ring` 计数在两次心跳间严格 **+10000 / 10 s = 恰好 1000 次/秒** -(`10001 → 20001`,`9957 → 19352` 同理)。 - -- 若 adcTask **失去阻塞**(空转),其循环速率 = 1/单拍耗时。按 0.11 ms 的单拍耗时算, - 空转应为 **约 9000 次/秒** → `ring` 会涨 ~90000/10 s。 -- 实测恰好 1000 次/秒 → **adcTask 确实每拍都在 `osDelayUntil` 里睡满**,没有空转。 - -**因此:在失败窗口内,adcTask 不是 CPU 独占者。** 这同时否掉了"计算量把 CPU 吃满"的直接说法。 - ---- - -## 4. 三个结构性脆弱点(均已从代码证实) - -### 4.1 uiTask 优先级垫底(**这是失效能被"定点打击"的根因**) - -`Src/freertos.c` 的任务属性 + `Middlewares/.../CMSIS_RTOS_V2/cmsis_os2.c:444` -(`prio = (UBaseType_t)attr->priority`,FreeRTOS 中**数值越大优先级越高**): - -| 任务 | `osThreadAttr_t.priority` | uxPriority | 在 `required_mask` | -|---|---|---|---| -| defaultTask | `osPriorityNormal` | 24 | ✅ | -| netTask | `osPriorityNormal` | 24 | ✅ | -| ftpTask | `osPriorityNormal` | 24 | ✅ | -| timeSyncTask | `osPriorityNormal` | 24 | ✅ | -| adcTask | `osPriorityNormal`(**改前**;P1-5 后为 `osPriorityAboveNormal`) | 24 → **32** | ✅ | -| **uiTask** | **`osPriorityBelowNormal`** | **16** | ✅ | -| testTask | `osPriorityBelowNormal` | 16 | ✖(产品构建不创建) | - -→ **uiTask 是必需掩码里唯一的低优先级任务。** 只要有**任何一个** Normal 任务持续就绪, -uiTask 就**完全拿不到 CPU**;`configUSE_TIME_SLICING=1` 只保护**同优先级**之间的公平, -对它无效。 - -> **P1-5 落地后的变化(2026-10-10)**:`adcTask` 已提到 **`AboveNormal`(32)** → 本表"uiTask -> 垫底"的格局更极端。之所以可接受:① 改造后 `adcTask` 由定时器(现 TIM3)**精确 1 kHz** 唤醒,单拍 -> µs~2.25 ms 且**不再有空转窗口**——改前的 `osDelayUntil` 一旦超拍就永久落后 → 不再阻塞 → -> 以 Normal 全程抢占 uiTask,那才是本表真正担心的形态(P0-2 已修,见 §4.3);② 实测 6 次上板 -> `uip` / `uiw` / `uis` 与提优先级前**一字不差**(61.3 ms / 11.4 ms / 1 µs)。 -> ⚠ 换来的新暴露见 §4.4 现状。 - -### 4.2 UI 心跳依赖 `netTask` 往返(**为什么"只有 UI 掉"**) - -**改前**(`ui_task.c` v1.2,本次失效的现场)主循环: - -```c -while (1) { - ui_port_step(10); /* LVGL:lv_tick_inc + lv_timer_handler(同步渲染) */ - ui_net_sink_poll(); /* ← 阻塞点 */ - task_wd_beat(TASK_WD_UI);/* ← 心跳在这之后才发 */ - osDelay(10); -} -``` - -**改前** `ui_net_sink_poll()` 在**无远端客户端时**(台架常态)每轮都走 `net_accept()`; -而 `net_accept()`(`Drivers/BSP/NET/net_socket.c`)的固定模式是: - -```c -if (osMessageQueuePut(netMsgQueueHandle, &msg, 0, osWaitForever) != osOK) { ... } /* 队列深 8,满则永久阻塞 */ -msg.result = net_wait_result("acc", sockfd); /* 等 netTask 回执 */ -``` - -`net_wait_result()`(`net_socket.c:851`)末句是 **`xTaskNotifyWait(..., portMAX_DELAY)`——无限期阻塞**。 - -→ **改前 UI 的心跳周期 = LVGL 渲染 + 一次 `netTask` 往返 + 10 ms。** `netTask` 被延迟多久, -UI 的心跳就晚多久。**这是 UI 独有的依赖,其它任务没有**——正好解释"只有 UI 位缺失"。 - -**现状(两处已改,此依赖已断开)**:① **P1-4a** 把 `task_wd_beat(TASK_WD_UI)` 提到 poll -**之前**(`App/task/ui_task.c:159`,poll 在 `:164`)→ 心跳从此只测"UI 自己被调度到"; -② **§9-A 门控**让无客户端时**一次 RPC 都不发**(`uis` 实测 1 µs,§9.3.2)。 -有客户端时往返仍在,属刻意保留的残留暴露(§9.4)。 - -### 4.3 adcTask 的截止期一旦错过就**不可恢复**(**已修 = P0-2**) - -**改前**(`App/task/adc_task.c`): - -```c -next += ADC_TASK_BEAT_MS; /* 固定 +1ms,不随实际进度修正 */ -(void)osDelayUntil(next); /* ← 返回值被丢弃 */ -``` - -`osDelayUntil` 在**目标时刻已过**时返回 `osErrorParameter`(`cmsis_os2.c:898`)。 -若单拍耗时 > 1 ms,`next` 相对当前时刻**永久落后** → `osDelayUntil` 立刻返回 → -**该任务从此不再阻塞**,以 Normal 优先级空转,直到某个"快拍"把差额追平。 -(单次 2 ms 的慢拍只会多跑一拍就追平,见 §5 的 `ring` 反证;**连续**慢拍才会锁死。) -**返回值是现成的重同步钩子,但被丢弃了。** - -**现状(P0-2,`adc_task.c:277-280`)**: - -```c -next += ADC_TASK_BEAT_MS; -if (osDelayUntil(next) != osOK) { next = osKernelGetTickCount(); } /* 落后 → 一次性拉回基准 */ -``` - -实测印证:单拍最坏 `adcm ≈ 2.1 ms` **确实会越过 1 ms 节拍**,但 `ring` 仍严格 1000 次/秒 -(§3.3 的判据依旧成立)→ 重同步在生效,没有退化成 Normal 级空转。 - -**再现状(P1-5,2026-10-10)**:这段已**整体删除**——`ADC_TASK_BEAT_MS` 与 `next` 都不在了, -循环尾改为 `(void)osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)`,唤醒由 -定时器(现 TIM3)ISR 提供。**"超拍永久落后 → 空转"这一类缺陷在结构上消失**(不再有自定截止期,也就没有 -"落后"这个概念),P0-2 的重同步钩子随之作废。实测 6 次上板 **`idle = 0`**(改前 1~649), -见 §8 与 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10。 - -### 4.4 ADC 超时是"2 ms 纯忙等",不阻塞、不让出 - -`App/algo/adc_input.c:21` `ADC_INPUT_POLL_TIMEOUT_MS = 2U`,配合 -`HAL_ADC_PollForConversion()`(`stm32f4xx_hal_adc.c:882`)里的 -`while (!EOC) { if ((HAL_GetTick() - tickstart) > Timeout) return HAL_TIMEOUT; }` -—— **一个通道超时 = 2 ms 不让出 CPU 的自旋**,且发生在 Normal 优先级。 -这是现成的饥饿源。 - -> **单拍上界修正(2026-10-10 复核)**:本段原文写"9 通道全超时最坏 = **18 ms** 连占"**有误**。 -> `stm32_scan()` 在**第一个**超时处就 `HAL_ADC_Stop()` + `return`(`App/algo/adc_input.c:79-84`), -> 从不轮询剩余通道 → **单拍最坏 = 一次超时 ≈2~3 ms**(`HAL_GetTick()` 是 1 ms 粒度、判据是 -> `> Timeout`),不是 9 次。 - -**现状(v2.8,2026-10-10:这条饥饿源已消除)**:`ADC_INPUT_POLL_TIMEOUT_MS` 与 -`HAL_ADC_PollForConversion` **已从代码中删除**——STM32 ADC1 改由 **TIM3 更新事件经 TRGO -硬件触发 + DMA 循环搬运**,任务侧只消费 DMA 已写好的乒乓半区,单拍 `adcp` 实测 -**122 µs → 8 µs**;"超时"这个概念在 L1 层不复存在,因此也不存在 2~3 ms 自旋。 -完整设计与实测见 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §11。 -下面两段保留,作为该结论的推导与当时的实测依据(历史): - -- 这条饥饿源如今**基本不被触发**,且**只在超时拍出现**:6 次上板对照——超时 1 次的那轮 - `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`(= 9 通道 × 7.4 µs + TPAFE 快照); - `adc1_err` 从 288/10 s 降到 **0~1**; -- ~~但 `adcTask` 现已在 **`AboveNormal`(32)**,一旦某拍真走到 2 ms 忙等,它挤的是 - netTask / ftpTask / defaultTask / timeSyncTask(改前是同级竞争)→ **这是 P1-5 换来的新暴露, - 记在账上**~~ → **v2.8 已消**:忙等本体没了,这条新暴露随之关闭(2026-10-10 实测 - `adcp = 8 µs`、`adc1_err = 0`、`adc1_hw = 0`)。 -- ✅ **超时的第二个代价是正确性,不只是耗时(2026-10-10 已修)**:超时拍 `s_stm32_valid` 此前 - 仍是上次成功留下的 1,而 `adc_input_poll()` 的返回值只由 TPAFE 决定 → 该拍照样 `r == 1`、 - 照样压环,STM32 侧几路(UB/UC/IB/IC/IN/温度)**静默沿用上一拍的值**(且超时前几个 Rank 已写 - 本拍新值 → 半新半旧的混合帧);`s_frames_skip` 只统计"从未成功过",抓不到它。 - **修复** = `stm32_scan()` 两条失败支路(`HAL_ADC_Start` 失败 / 轮询超时)**都清 - `s_stm32_valid`** → 本拍 `adc_input_raw()` 对 STM32 语义通道返 -2 → `adc_task` 走既有 - `skip` 整帧跳过路径,下一拍成功自动恢复。主机侧新增 **TC-AI-070..078** 钉死(旧用例 - TC-AI-034/039b 都是"init 后立刻失败",`valid` 本就是 0,照不出这条路径),并做**反向验证**: - 把支路改回旧行为 → 3 条立即 FAIL(76/79)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)** - → ✅ **已办(同日 v2.8)**:DMA + TIM3 TRGO + 乒乓双缓冲落地,`stm32_scan()` 换成 - `stm32_take()`;"成功过之后失败 → 静默沿用旧值"这条缺陷路径在结构上消失(无超时概念), - 改由"本拍无新帧"触发同一条 `skip` 防线,主机用例 **TC-AI-070..078** 原样保留并仍反向验证。 - ---- - -## 5. 顺带发现的一个**独立**缺陷:无新帧仍然压环(**已修 = P0-1**) - -**改前**(`App/task/adc_task.c`):`r == 0`(TPAFE 无新帧)时**仍然**执行 -`sample_ring_push_frame()` + `phasor_run()`——只有 `r == -1` 才不压。 - -日志精确印证(`ring = frames + idle`): - -| 窗口 | frames | idle | ring | 校验 | -|---|---|---|---|---| -| 09:36:21 | 9957 | 44 | 10001 | 9957 + 44 = 10001 ✓ | -| 09:36:31 | 19352 | 649 | 20001 | 19352 + 649 = 20001 ✓ | - -**后果**:把**上一拍的重复采样值**塞进 20 点 DFT 窗 → 等效采样间隔被拉长。 -本例 `idle` 占 **3.2%** → 窗长从 20.0 ms 漂到约 20.6 ms → 对 50 Hz 信号相当于 -**约 3% 的频率失配** → 幅值/相位**系统性误差**(不是随机噪声)。 - -这与本次停摆**无关**(不增加多少 CPU),但属**L2 的真实正确性缺陷**,应一并修: -**只在 `r == 1` 时压帧**。 - -**现状(已修,`adc_task.c:180`)**:仅 `r == 1` 才 `sample_ring_push_frame()` + `phasor_run()`。 -上板直接印证:`frames=19999 idle=1 ring=19999` → **`ring == frames`**(修前是 -`ring = frames + idle`),且 `idle` 从 649/20001 降到 **1/20000**(另见 §7.5)。 - ---- - -## 6. 为什么"只有 UI"、"为什么间歇" - -- **只有 UI(改前)**:UI 是唯一低优先级(§4.1)**且**心跳依赖别的任务(§4.2)的必需任务。 - 其它五个任务只要 100 ms 内被调度到一次就能喂上位,而它们全在 Normal 级,彼此时间片轮转保障。 - **现状**:低优先级这一条**至今未变**(§4.1 的表仍成立,uiTask 仍是掩码里唯一的 - BelowNormal),变的是后半条——心跳已自产(P1-4a)、往返已门控(§9-A)。 -- **间歇**:需要一次"某个 Normal 任务连续占用 ≥800 ms"的巧合。**当时的**候选(见 §7): - `netTask` 回执延迟、`defaultTask` 的长段(syslog → NAND/FTL 写、配置保存)、 - adcTask 的超时突发。**现状**:探针已把这三条的上界量出来——`defd ≈ 46 ms`(控制台排空)、 - `adcm ≈ 2.1 ms`(单拍)、`uis = 1 µs`(推流往返),**没有任何任务接近 800 ms** - (§8「当前健康剖面」)。 -- **为什么 L2 才暴露**:L2 让 adcTask 每拍多干 10~15 µs 并**引入了 `adc1_err` 超时** - (L1 阶段台架 `adc1_err=0`,L2 阶段出现 288/10 s)→ Normal 级的可用余量被吃掉, - uiTask 的缝隙被挤到 0。**这正是"负载上去以后最先倒下的那块短板"。** - ---- - -## 7. 尚未证实、需要测量的部分(诚实边界) - -1. **800 ms 的具体占用者未锁定。** 本次日志只能证明"UI 被饿/被拖了 800 ms", - 不能区分是 - (a) `netTask` 回执延迟(§4.2),还是 - (b) `defaultTask` 长段(syslog NAND 写 / 配置保存),还是 - (c) adcTask 超时突发(§4.4)。 - **补充(2026-10-10,P2 测量 + §9-A 收口后):累计 32 次上板启动(P0 后 22 次 + - A 后 10 次)全部 0 复现停滞**——健康剖面里最大的单一非让出占用是 `defaultTask` - 控制台排空 `defd ≈ 46 ms`,其次是 ADC 单拍 `adcm ≈ 2.1 ms`(⚠ 复核后归因已修正:该峰值来自 - 每 10 s 一次的心跳日志,不是采集,见 §4.4)、ADC 单次 `adcp ≈ 0.12~2.5 ms`, - **没有任何任务接近 800 ms**。故 800 ms 属异常长尾,靠健康剖面无法指认,只能靠复现现场的 - `[PROF]/[PROF2]` 快照;但 P0 已把回归压到 0(原 L2 基线 3/10),暂不再阻塞推进。 -2. **`adc1_err=288` 的自身根因仍未查,但现象已随 P0-1 基本消失。** 2 ms 超时对 7.4 µs 的 - 转换而言异常巨大,指向 ADC 被反复 `Start/Stop` 或 EOC/OVR 状态异常。 - **现状**:失败日志 **288/10 s** → 收口后 **1/20 s**;若再现,优先回头查这条。 - **已补一项候选根因(2026-10-10)**:F4 ADC 的**上电自校准**此前在本项目**完全缺失** - (CubeMX 不生成该步骤,且本仓库裁剪版 HAL 没有 `HAL_ADCEx_Calibration_Start`)→ 现由 - `Src/adc.c` 的 `adc1_calibrate()` 补上(详见 - [`../硬件平台/ADC通道映射说明.md`](../硬件平台/ADC通道映射说明.md) 的"ADC1 上电自校准"行)。 - 实测校准让 STM32 侧 6 路**同向**偏移 +10~+63 LSB → 说明此前 ADC 确实带失调在跑。 - 它直接解释的是"读数带偏",未必等于 EOC 超时的根因,但"未校准状态下 ADC 内部行为异常" - 是可疑路径,故记为 §7.2 已收敛的一半。 -3. **复位归因已澄清为非诊断项。** 失败日志报 `reset cause: software (NVIC_SystemReset) - (CSR=0x14000000)`(即 `SFTRSTF|PINRSTF`);但 2026-10-10 的健康启动**同样**报 - `software (NVIC_SystemReset)` 且判定 PASS——说明本台架上电复位本就走软件复位路径 - (或烧录工具经软件复位),**不能据此区分"IWDG 溢出"还是"台架拉 NRST"**。停摆的权威 - 证据仍是 `[ERR][WD] task stall` 行本身,而非复位原因寄存器。 -4. **`[STACK]` 周期从 2.0 s 拉到 4.05 s**(17:36:19.902 → 23.951,标称 `STACK_PROBE_PERIOD_MS=2000`) - 值得注意,但**不能作为证据**:控制台缓冲满时会**丢行**,该报告也可能只是被丢了。 -5. **`idle` 的物理含义**:任务轮询与帧产生之间的相位漂移(`osDelayUntil` 的 1 ms 内核 tick - vs 定时器(现 TIM3)的 1 kHz 硬件节拍,两个时钟源不同),本身不是故障。 - **现状**:P0-1 后 `idle` 只计数、不再压环,实测也从 649/20001 降到 **1/20000**—— - 当年"它会让 §5 的缺陷持续发生"这条耦合已解除。 -6. **启动阶段 `MISS start snap=0x67` 是良性的(2026-10-10 新增)。** `[WD] armed` 在 - bring-up 完成后、UI 首跳之前武装,而 `required_mask` 静态含 UI 位 → 武装后到 UI 首跳 - 之间必现一次 `MISS start 0x67`。健康运行(`FATFS mounted≈0.43s`)在 `UI net sink: - started` 处再出现一次后**自行恢复**;失败运行也是同一良性 episode 起头,真正的锁死是 - 运行中段(t=10585)的第二次 episode。**故该 episode 本身不是故障判据**,只作"UI 尚未 - 起跳"的旁证。 -7. **`netp`(netTask 的 `net_poll()` 最坏)恒报 0 µs——这是真实值,不是探针坏了。** - `net_poll()` 开头先读 INT# GPIO,高电平(无中断待处理)就直接 `break` - (`Drivers/BSP/NET/net_socket.c:431`)→ 空闲时整条 `net_poll()` 只是一次 GPIO 读, - < 1 µs 合理。读 `[PROF2]` 时**不要**把 `netp=0` 误判成"探针没接上"(接线在 - `App/task/net_task.c:276-278`,每轮都在测)。 - ---- - -## 8. 处置:决策与落地结果(2026-10-10) - -> 本节原本是"待确认后执行"的分层建议;处置已按 **P2 先测 → 按数据定 P1 → P0 顺路一起修** -> 的顺序执行完毕,故改写为**决策 + 结果**记录。当初的取舍理由仍是活约束,保留在状态列里。 - -| 项 | 内容 | 状态 | -|---|---|---| -| **P0-1** | §5 压环条件:仅 `r == 1` 时压帧 + 重算 | ✅ 已落地,上板印证 `ring == frames` | -| **P0-2** | §4.3 截止期重同步:`osDelayUntil` 返回值不再丢弃 | ✅ 已落地;**后被 P1-5 取代**——整段 `osDelayUntil` 已删,"落后"这个概念不存在了(§4.3 再现状) | -| **P1-3** | 把 uiTask 提到 `osPriorityNormal` | ❌ **刻意不做**:它会**掩盖** §4.2/§4.4 两个真实缺陷(当时的判断,现仍成立) | -| **P1-4a** | 心跳提到 `ui_net_sink_poll()` **之前** | ✅ 已落地(`ui_task.c:159`,v1.3) | -| **P1-4b** | 给 `net_accept()` 一条非阻塞查询路径 | ✅ 以更彻底的形式落地 = **§9-A 门控**(`net_can_accept()` / `net_can_read()`) | -| **P1-5** | 原列"adcTask 单拍硬上界(ADC 改 DMA / 超时整拍跳过)"→ 经用户决策改走**方案 a:事件驱动消费**(TIM6 ISR 置位 `ADC_WAKE_FLAG` 唤醒 + `adcTask` 提至 `AboveNormal`) | ✅ **已落地**(2026-10-10;6 次上板 **`idle=0`**、`frames` 严格 1000 Hz、UI 侧探针一字不差;完整记录见 [`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §10)。⚠ 原列的"**单拍硬上界**"(DMA / 超时整拍跳过)**已办(同日 v2.8)**——STM32 ADC1 改 TIM3 TRGO 硬件触发 + DMA 乒乓双缓冲,忙等本体删除, -`adcp` 122 µs → **8 µs**;该"新暴露"随之关闭(§4.4 现状)。完整记录见 -[`../子系统设计/ADC采样节拍设计说明.md`](../子系统设计/ADC采样节拍设计说明.md) §11 | -| **P2-6** | 启用现成的 `TEST_LATENCY_PROBE`(`ui_task.c` 的 `ui_loop` 段) | ✅ 已被**常开探针 `App/util/task_prof.c/.h`** 取代(`[PROF]`/`[PROF2]`),无需再开测试开关 | -| **P2-7** | 新增 adcTask 单拍 / 推流往返两个埋点 | ✅ 已落地(`TASK_PROF_ADC_BEAT` / `TASK_PROF_ADC_POLL` / `TASK_PROF_UI_SINK`) | -| **P2-8** | 停摆取证带上"各任务在本窗口的调用计数" | ✅ 以更彻底的形式落地 = 心跳位图 **1→0 边沿**裸 UART 直发现场快照 | - -### 关键测量结论(当时决定 P1 取舍的依据) - -- **改前**健康运行 `uiw ≈ uis ≈ 50 ms` → UI 单轮耗时几乎等于推流往返,证实 §4.2: - 心跳实际测的是"netTask 响应性"而非"UI 活着"。 -- **改前** `uisn = 0`(推流段无"仍在段内"积压)→ "永久卡在往返"这一机理被排除。 -- 最大单一非让出占用一直是 `defaultTask` 控制台排空(46~48 ms),**没有任何任务接近 800 ms** - → 800 ms 属异常长尾(§7.1)。 - -### 验证与遗留 - -- **验证**:P0 后累计 22 次上板 0 复现;§9-A 收口后再 10 次;**P1-5 落地后再 6 次** → - **累计 38 次 0 复现**(原 L2 基线 3/10)。构建 0 错误 0 警告;P1-5 后体积 - `Code=256728 RO=16160 RW=808 ZI=139760`(+360 B:ISR 入口 + `osThreadFlagsWait` 路径)。 -- **遗留(仍未办)**:§7.2 `adc1_err` 自身根因、§7.4 `[STACK]` 周期漂移、§4.4 的 ADC 忙等 - (未改 DMA / 未做超时整拍跳过——P1-5 解掉的是"相位抖动 + 超拍空转"两类,**没有**解忙等)。 - 停摆若再现,优先回头查 §7.2。 - -### 当前健康剖面(2026-10-10 **P1-5 后**复测:`@test` 6 连跑,无客户端,开机 29 s) - -| 探针 | 现值(最坏) | 含义 | -|---|---|---| -| `uip` / `uil` | **61.3 ms** / 10.0 ms | uiTask 循环周期最坏 / 标称——地板是 LVGL 帧周期(§9.3.2) | -| `uiw` | **11.4 ms** | UI 单轮工作,**只含 LVGL**(改前 ≈50 ms 且 ≈`uis`) | -| `uis` / `uisn` | **1 µs** / 0 | 推流往返(A 门控后;改前 ≈50 ms)/ 从未卡在段内 | -| `adcm` / `adcp` | 2.12~2.25 ms / **122 µs**(超时拍 2.2 ms) | adcTask 单拍 / `adc_input_poll()` 单次(P1-5 后跑在 `AboveNormal`)。⚠ `adcm` 的 2 ms 峰值来自每 10 s 一次的心跳日志,不是采集(§4.4) | -| ADC 消费计数 | `frames` 1000.0/s、**`idle = 0`**、`ring == frames` | P1-5 后唤醒与生产同源 → 相位抖动归零(改前 `idle` 1~649 / 20000 拍) | -| `defd` / `defs` | 46 ms / 7 µs | 控制台排空 / syslog 落盘——**当前最大的单一非让出占用** | -| `netp` / `netn` | 0 µs / 0 | `net_poll()` 空闲即一次 GPIO 读(§7.7) | -| `gl` | 0 | DWT 无丢样,以上数据可信 | - ---- - -## 9. 彻底解耦(2026-10-10 结案:**A 已落地,B 决定不做**) - -> **结案状态**:**§9.3 方案 A(门控 + 兜底)已落地并验证**(`net_can_accept()`/`net_can_read()` -> + `ui_net_sink_poll()` 先门控再 RPC;`@build` 0 错 0 警、`@test` 10/10 PASS、e2e 0/25600 位差异); -> **§9.4 方案 B(把推流搬出 uiTask)已评估并决定不做**,理由与残留暴露写在 §9.4。 -> **动因**:P1-4a 之后"心跳"不再被推流拖,但**刷新仍被推流拖**——现象从"偶发复位"变成 -> "画面周期性卡顿 / 刷新间隔不稳"。**实测结论修正了这个动因**:刷新的最坏周期其实是 -> LVGL 帧周期地板,与网络无关(§9.3.2)。 -> **登记**:N-19 / C7 **不入登记簿**——当日登记当日结案,结论只沉淀在本节。 - -### 9.1 先把"poll 运行时间不可预测"量化:3 个**没有上界**的等待点 - -`ui_net_sink_poll()` 自己不产生耗时——它只是**向 netTask 发 RPC 并原地等回执** -(`net_*` 公开 API 的固定模式:`osMessageQueuePut` → `net_wait_result`,见 -`Drivers/BSP/NET/net_socket.c:851`)。 - -| poll 内调用 | 触发频率 | 实际代价 | 上界 | -|---|---|---|---| -| `osMessageQueuePut(..., osWaitForever)` | 每次 RPC | 队列深 **8**,与 FTP / timeSync 共享(原 N-10,已销项) | **无**(满则永久阻塞) | -| `net_wait_result()` → `xTaskNotifyWait(..., portMAX_DELAY)` | 每次 RPC | 等 netTask 排到本条(它还要跑 `net_poll` + 3 个自愈 tick + FTP 大文件) | **无** | -| `net_accept()` | **改前**:无客户端时每 10 ms 一次(台架常态)= **100 msg/s**;**现状:0 次**(A 门控) | 一次往返 + `net_accept_locked()` 内再跑一遍 `net_poll()`(整轮 SPI 中断读) | 改前实测最坏 **≈50 ms** → 现状 **1 µs**(§9.3.2) | -| `net_recv(NET_MSG_DONTWAIT)` | 有客户端时每轮;**现状**:`readable` 门控 + 每 32 轮兜底(I-2) | 同上(`net_recv_locked()` 亦先 `net_poll()`) | 同上 | -| `net_send()` LCDMEM 应答 **3204 B** | 客户端每请求一帧(peer ≈10 次/s) | `net_send_locked` 按 `NET_SEND_CHUNK_MAX = min(CH395F_SEND_BUF_SIZE_BYTES=2×512, NET_DMA_MAX_PAYLOAD=2KB) = 1024 B` 拆 **4 块**,每块写完等 `SENBUF_FREE`,`NET_SEND_TIMEOUT_MS=1000` | 最坏 **≈4 s**,调用方全程等 | - -**结论**:poll 的耗时 = **"netTask 什么时候有空"**,不是"UI 算得慢"。netTask 是全部 socket -操作的**唯一执行者**,这个等待天然不可预测——E-02 实测一次空闲 FTP 登录把远端显示冻结 -**17.7 s**(`docs/问题记录/错误码传播审计.md`);帧周期真机 **61.6 ms** vs 模拟器 **6.8 ms** -(`docs/测试说明/链路延迟测试说明.md` L8)同源。**任何"把心跳挪个位置"的改法都改变不了这一点。** - -### 9.2 "彻底解决"的三条判据 - -| # | 判据 | 现状 | -|---|---|---| -| **J1** | uiTask 主循环里 **0 次** net 往返 → 刷新周期只由 LVGL + 调度决定 | 🟡 **无客户端时已 0 次**(A 门控,`uis` = 1 µs);有客户端时仍有——刻意保留(§9.4) | -| **J2** | 心跳自产,不依赖任何其他任务 | ✅ P1-4a | -| **J3** | 推流的阻塞只影响推流自己(故障域隔离)+ 有观测 | ⚠ **刻意接受**:仍与刷新同域(B 已否决);观测 = `[PROF]` 的 `uis`/`uisn`(§9.4) | - -### 9.3 方案 A:门控——让"没事的时候"poll 真的不做事 - -**做法**:发 RPC 之前先用**控制块 RAM 状态**判断"有没有活要干",没有就直接返回。 - -| 需要的信息 | 现成 RAM 判据(`net_get_sock()` 是公开 API、纯 RAM 读) | 语义依据 | -|---|---|---| -| 有连接可接受 | `p->state == NET_SOCK_STATE_ESTABLISHED` | 与 `net_accept_locked()` 同判据(单连接模型:监听 Socket 自身在 CONNECT 后转数据通道) | -| 有数据可读 | `p->readable != 0` | `net_poll` 在 RECV_OK 置位、`net_recv_locked` 读空清零(`net_select.c` 的 `check_socket_readable` 用的就是它) | -| 对端已关闭 | `p->state == NET_SOCK_STATE_CLOSED` | 需发一次 recv 拿 EOF(0) 并触发 `auto_relisten` | - -**落点(关键,别踩坑)**:判据**不能写进共享的 `ui_net_sink.c`**,必须放**平台后端**—— -模拟器 `simulator/sim/net_socket_sim.c` 的监听块在 `accept()` 成功**之前**不会是 -`ESTABLISHED`,直接门控会让**模拟器永远连不上**。定稿取 **net 层公开 API**(不是 UI backend): - -```c -/* Drivers/BSP/NET/net_socket.h(新增,双端各一份实现) */ -int net_can_accept(int sockfd); /* 1 = 有连接可接受;0 = 无(纯 RAM 读,无 RPC) */ -int net_can_read(int sockfd); /* 1 = 有数据可读或已 EOF;0 = 无 */ -``` - -选 net 层而非 UI backend 的三条理由:① 语义属于 socket,不属于 UI;② 模拟器后端本就是 -`net_*` 的镜像实现,加在那里比在 UI backend 里再开一条"绕过 net API"的旁路更收敛; -③ timeSync / FTP 后续同样能用(N-4 pending 二期落地前先给它们减负)。 -共享的 `ui_net_sink.c` **只调这两个 API,不直接读控制块**。 - -**竞态论证**:单字读、netTask 单写者;最坏两种结果都可接受—— -① **晚一轮发现**(≤1 ms:netTask 自己每 1 ms 就 `net_poll()` 一次,`readable`/`state` 早已刷新); -② **扑空**(状态刚变,RPC 返回 `WOULDBLOCK`,现有分支已处理)。 - -**收益(实测见 §9.3.2)**:台架常态(无客户端)poll 从"一次往返(最坏 50 ms)"降到 -**两次 RAM 读(实测 1 µs)**;顺带销项 **N-10**(100 msg/s 队列压力,与 FTP 抢深度 8 的队列)。 -⚠ **A 不改变"有客户端时"的行为**——该暴露刻意保留,见 §9.4。 - -#### 9.3.1 落地补充(2026-10-10 实现时新增的两条,设计阶段未预见) - -| # | 落地时发现的坑 | 处置 | -|---|---|---| -| **I-2 兜底收帧** | `readable` **只由 RECV_OK 中断置位**(`handle_recv_event`),而 Trap 22 的 `net_reconcile_tick()` 三类对账**不含**"芯片 RX 非空但 `readable=0`" → 该中断一旦丢失,纯门控会把已到达的数据**永久藏住**(改前靠每轮 `net_recv_locked()` 里的 `ch395f_get_recv_len()` 顺带发现) | `ui_net_sink.c` 加 `RM_LIVENESS_DIV=32`:门控判"无数据"时仍**每 32 轮无条件 recv 一次**(10 ms 节拍 → 最坏额外延迟 ≈320 ms;RPC 频率仍从 100/s 降到 ≈3/s)。**教训:门控类优化必须保留低频兜底,否则是拿"自愈能力"换性能** | -| **I-3 监听失效兜底** | 加了 accept 门控后,若监听控制块消失(网络重建 / 异常释放),`net_can_accept()` 恒返 0 → 改前"每轮 accept 撞 BADF"这条**暴露路径随之消失** → 端口永久不再接受新客户端 | 门控返回 0 时补一次 `net_get_sock(g_listen) == NULL` 判定,命中即 `g_listen = -1` 交回既有重建分支 | - -**双端语义**:模拟器 `net_socket_sim.c` 的两个同名实现**恒返回 1**——Winsock 本就非阻塞、 -没有 RPC 成本,恒 1 使门控在模拟器上**逐位等价于改前行为**(含"每轮都 recv"的自愈特性), -`simulator/tests/check_remo_sink.py` 的协议回归因此仍有效。 - -#### 9.3.2 A 落地实测(2026-10-10,`@test` PASS,33 个 `[PROF]` 样本 / 开机 29 s / 台架无客户端) - -| 字段 | 改前(§8「关键测量结论」) | **改后实测** | 判读 | -|---|---|---|---| -| `uis` 推流往返最坏 | **≈50 ms** | **1 µs**(33 样本 max,`uisn` 全程 0) | 门控生效,**降 5 个数量级**;§9.6 目标 ≤50 µs 达成 | -| `uiw` UI 单轮工作最坏 | ≈50 ms(且 `uiw≈uis` → 单轮几乎全是推流) | **11.4 ms** | 剩下的**只含 LVGL**——网络已不在刷新路径上 | -| `uil` 周期最近值 | 10 ms | 9999 µs | 标称节拍未变 | -| `uip` 周期最坏 | ≈61 ms | **60.9 ms** | ⚠ **未达标**(§9.6 原写 ≤15 ms),但**已与 `uis` 脱钩** → 见下 | -| `gl` 丢样 | 0 | 0 | DWT 数据可信 | - -⚠ **`uip` 未达标不是 A 失败,是判据当初归因错了**:`uis` 已到 1 µs 而 `uip` 仍 60.9 ms, -说明 uiTask 循环周期的最坏值由 **LVGL 整帧渲染**决定(`docs/测试说明/链路延迟测试说明.md` L8 -实测帧周期真机 **61.6 ms**,与实测 60.877 ms 吻合),推流从来只是**叠加**在这条地板上的 -**额外**抖动。**这条地板不是网络问题**——正是这条实测结论让 §9.4 的方案 B 被否决。 -**若目标是把刷新周期本身压下来,那是另一个议题(LVGL 帧周期 / 渲染模式),不在本案范围。** - -### 9.4 方案 B(把推流搬出 uiTask,独立 `uiSinkTask`)——**已评估,决定不做(2026-10-10)** - -**当初的动机**:客户端一连上,几乎每轮都要 `net_send` 3204 B,这个等待由芯片 FIFO / 链路 / -netTask 决定,UI 侧无法设上界(半途截断 = 客户端 CRC 失败 = 黑屏,E-04 已定死不能碰)。 -按"uiTask 里一次 net 调用都不留"的思路完整设计过:新任务(256 words / BelowNormal / -不进 `required_mask`)+ 帧 seqlock 取帧 + 栈与堆两条预算路线 + 观测面迁移。 - -**否决理由(全部来自 §9.3.2 的实测)**: - -1. **治不到病**:A 之后 `uis` = 1 µs,而 `uip` 最坏仍是 60.9 ms = **LVGL 整帧渲染地板** - (文档实测帧周期 61.6 ms)。搬走推流**不会让刷新变快**,只是把同一段等待换个任务背。 -2. **收益只剩极端并发下的"故障域隔离"**:即"客户端在连 + netTask 被 FTP 挤住"这一格 - (E-02 的 17.7 s 是**改前**、FTP 空闲登录场景)。那是**远端显示的观感**问题, - 设备本体的复位风险已由 P1-4a 解除。 -3. **成本不成比例**:新增任务 + 帧 seqlock + `ui_port`/backend 双端改动 + **必改 `.ioc`** - (栈或堆两条路线都要 CubeMX 重生成 + 存活审计)+ 十余处改动与全套回归; - 其中"不动 uiTask 栈"那条路线还会把堆 `min_ever` 压到 S11 告警线(3072 B)以下。 - -**不做 B 的残留暴露(如实记录)**:客户端在连且 netTask 拥塞时,`ui_net_sink_poll()` -仍可能在 uiTask 里等最长 ≈4 s(LCDMEM 应答 4 块 × `NET_SEND_TIMEOUT_MS`), -表现为**本地画面与远端同时卡住**。观测面已常驻,无需新增埋点:`[PROF]` 的 `uis` -(推流往返最坏)与 `uisn`(此刻仍在推流段内多久)——**`uisn` 持续非 0 就是这一格真发生的铁证**。 -若日后它成为实际问题,**正确方向也不是 B**,而是先治 netTask 的排队公平性 -(登记簿 N-4 pending 二期),那才是同一条根因的上游。 - -### 9.5 明确不做的四条(及理由) - -| 备选 | 为什么不做 | -|---|---| -| 只调心跳顺序(=现状 P1-4a) | 心跳不再被拖,**刷新仍然被拖**——不治本次的病 | -| 给 `net_*` 加"有界等待"版 | 回执通道是"每任务一个 32 位通知值",超时后**迟到的回执会被下一次调用的零等待拾取当成新结果**(`net_wait_result` 注释里 L7 挂死 / 整段错位的同款根因)。要做对必须引入请求序号,代价高于搬家 | -| 把推流塞进 netTask | 零往返、零新增 RAM,最"彻底";但共享源码 `ui_net_sink.c` 要改成走 backend 的 socket 端口(双端同改)、netTask 这个唯一执行者更胖、且一个 UI 侧 bug 直接变成整机复位。**同样不做**;若 netTask 排队公平性真成问题,从 N-4 二期治上游,而不是把 UI 搬过去 | -| 提高 uiTask 优先级 | §8 已明确警告它会**掩盖** §4.2/§4.4 两个真实缺陷;而且它根本不解决"UI 在等 netTask" | - -### 9.6 落地与验收判据 - -``` -A(门控 + 兜底)→ @build + @test + e2e → 看 [PROF]:uis 掉到 µs 级 → 本案结案 - (B 已评估否决,见 §9.4) -``` - -| 判据 | 目标值 | 状态(2026-10-10) | -|---|---|---| -| `uis`(无客户端) | ≤ **50 µs**(门控生效的直接证据;改前 ≈50 ms) | ✅ **A 已达成:实测 1 µs**(§9.3.2) | -| `uiw` 与 `uis` **不再同值** | 改前 `uiw≈uis≈50 ms` | ✅ **A 已达成:11.4 ms vs 1 µs** | -| `uip`(uiTask 循环周期最坏) | ~~≤15 ms~~ → **修订**:`uip` 地板 = LVGL 帧周期 ≈61 ms,**与网络无关,本案不治它**;只要求它**不随网络活动抬升**(`uis` 到 µs 级即为达成) | ⚠ 判据已按实测修订(§9.3.2) | -| 回归 | `@test` 连续 **10 次** 0 复现;`@build` 0 错误 0 警告;模拟器 `ui-verify` 五步 + `simulator/tests/check_remo_sink.py` e2e **0/25600 位差异** | ✅ **A 已达成(2026-10-10)**:`@build` 0 错 0 警;**`@test` 10/10 PASS**(每轮 `uis` ≤ **1 µs**、`uisn` ≡ 0、`task stall` 0 次、`[ERR]` 0 条、`uiw` ≤ 11.36 ms、`uip` ≤ 61.35 ms);e2e `check_remo_sink.py` **0/25600 位差异**;`check_menu_rows.py` PASS;内容页文本断言 50/50(像素比对需本机 `simulator/tests/ref/`,该目录不入库)。⚠ 10 连跑中 `net init: OK` 的 `[+Nms]` 由 ≈589 ms 变到 ≈9.1 s,**是采集零点亮盘/校验/复位耗时推移,非固件变慢**:同一日志内 `reset cause` 在 +8517 ms、`net init: OK` 在 +9141 ms → **固件自身仍 ≈624 ms**;17:31 的旧镜像日志(本次改动之前)已有同形态 | - -### 9.7 风险与回退 - -| 风险 | 缓解 | -|---|---| -| 门控误判(芯片丢中断 → `state`/`readable` 长期不同步) | netTask 已有 `net_reconcile_tick()` 200 ms 对账 + 去抖自愈(Trap 22);门控只是"少发 RPC",**不新增自愈责任**;`readable` 侧另有 **I-2** 每 32 轮无条件 recv 兜底 | -| 监听控制块消失 → 门控恒返 0,端口不再接受新客户端 | **I-3**:门控返 0 时查 `net_get_sock(g_listen)`,消失即 `g_listen = -1` 交回既有重建分支 | -| 回退 | 门控是纯函数级改动,只决定"要不要发 RPC",**单独可回退**(去掉两处 `net_can_*()` 判断即恢复改前行为),无数据结构 / 协议 / 任务变更 | - ---- - -## 变更记录 - -| 日期 | 变更 | -|---|---| -| 2026-10-10 | 创建。基于唯一留存失败日志(`serial.log` @17:36)+ 代码走查完成归因:否掉"CPU 饱和"(实测占空比 10%~20%,`ring` 速率反证无空转),定位为**优先级饥饿(uiTask 唯一 BelowNormal)+ UI 心跳依赖 netTask 往返**;另发现"无新帧仍压环"的独立正确性缺陷;给出 P0/P1/P2 分层处置与"先测量后修"的推荐顺序 | -| 2026-10-10 | 新增 **§9 彻底解耦方案(待落地)**:量化 `ui_net_sink_poll()` 的 3 个**无上界**等待点(队列 `osWaitForever` / `xTaskNotifyWait(portMAX_DELAY)` / LCDMEM 应答 3204 B 按 1024 B 拆 4 块等 `SENBUF_FREE`,最坏 ≈4 s),指出"poll 耗时 = netTask 什么时候有空";给出 **A 门控**(`net_can_accept()`/`net_can_read()` 纯 RAM 判据,落点必须在平台后端——模拟器监听块 accept 前不是 `ESTABLISHED`)+ 一套"把推流搬出 uiTask"的方案(**同日评估后否决并删除,见下面两轮**);记录四条**明确不做**的备选与理由、验收判据、风险与回退。**代码未改动**,登记 **N-19 / C7** | -| 2026-10-10 | **§9.3 方案 A 已落地**:`net_socket.c/.h` 新增 `net_can_accept()` / `net_can_read()`(纯 RAM 读控制块、零 RPC),`net_socket_sim.c` 同名实现恒返 1(模拟器行为逐位不变),`ui_net_sink_poll()` 改"先门控再 RPC",并补 **I-2 兜底**(每 32 轮无条件 recv,防 RECV_OK 中断丢失把数据永久藏住)与 **I-3 监听失效兜底**(控制块消失 → `g_listen=-1` 重建);`ui_net_sink.h/.c` 注释订正(原"非阻塞轮询 / 不阻塞 LVGL 刷新"表述不准确 → 改为"门控只消除无谓往返,真有活时往返仍无上界")。验证:Keil **0 错 0 警**(Code 256276→256368,+92 B)、模拟器构建 OK、`check_menu_rows.py` PASS、`check_coding_standard.py` PASS、**`@test` PASS**。**实测(§9.3.2):`uis` 50 ms → 1 µs、`uiw` 50 ms → 11.4 ms(只剩 LVGL)、`gl=0`**;`uip` 仍 60.9 ms = LVGL 帧周期地板,§9.6 判据已据此修订 | -| 2026-10-10 | **A 收口 + 方案 B 否决并删除**(用户定案"B 不做")。① **A 收口**:`@test` **10/10 PASS**(每轮 `uis` ≤1 µs、`uisn` ≡ 0、0 `task stall`、0 `[ERR]`)、e2e `check_remo_sink.py` **0/25600 位差异**、内容页文本断言 50/50。② **B 删除**:§9.4 由完整设计(新任务 256 words / BelowNormal / 不进 `required_mask`、帧 seqlock、栈与堆两条预算路线、观测面迁移、12 处改动清单)改写为**否决记录**——实测 `uis`=1 µs 而 `uip` 仍 60.9 ms(LVGL 帧周期地板)证明"搬出 uiTask"**不会让刷新变快**,收益只剩极端并发下的观感隔离,而成本含**必改 `.ioc`** 的 CubeMX 重生成;残留暴露与观测手段(`uis`/`uisn`)如实保留。③ **同步清理**:§9.5/§9.6/§9.7 去掉 B 条目;**N-19 / C7 不入登记簿**(当日结案,`docs/待处理内容.md` 保持无该编号);`子系统设计/看门狗设计说明.md` §9.2 的 N-19 行移除;`RemoDispBus协议与接收设计说明.md` 去掉"规划中改独立任务"段;`ui_net_sink.c/.h` 注释去掉"应搬出 uiTask"的指向 | -| 2026-10-10 | **按项目现状全文校订(无代码改动)**:① 已被处置改掉的代码片段就地标注 **改前 / 现状**——§1 第 2 条、§4.2(`ui_task.c` 心跳顺序,P1-4a 已改)、§4.3(`osDelayUntil` 丢弃返回值,P0-2 已改)、§5(无新帧压环,P0-1 已改)、§6;§4.4 明确"忙等原样保留、P1-5 暂缓"。② §8 由"待确认后执行的 P0/P1/P2 分层建议"改写为**决策 + 结果表**(P1-3 刻意不做 / P1-4b 以 A 门控形式落地 / P1-5 暂缓 / P2-6 被 `task_prof` 取代 / P2-8 以边沿快照形式落地),并新增**当前健康剖面**表。③ §7 更新:0 复现累计 22 → **32 次**、`adc1_err` 288 → 1、`idle` 649/20001 → **1/20000**;新增 §7.7 说明 `netp=0` 是真实值(`net_poll()` 空闲只读一次 INT# GPIO)。④ §9.1/§9.2 的"改前"频率与 J1/J3 状态同步为 A 落地后的现状;删掉失效的行号引用与旧体积数字(256276 → **256368**) | -| 2026-10-10 | **P1-5 已实施(方案 a:事件驱动消费)**:`Src/main.c` TIM6 分支在 `tpafe5160_start_conv_irq()` 后调 `adc_tick_irq_notify()`;`App/task/adc_task.c` 循环尾 `osDelayUntil` → `osThreadFlagsWait(ADC_WAKE_FLAG, osFlagsWaitAny, osWaitForever)` 并删 `ADC_TASK_BEAT_MS`/`next`;`Src/freertos.c` 的 `adcTask` 优先级 `Normal` → **`AboveNormal`**;`App/task/adc_task.h` 新增 `ADC_WAKE_FLAG` 与 ISR 入口。实施时补一条规划未预见的**就绪门** `s_consumer_ready`——TIM6 在 `app_main_init()` 就启动,比建任务与 `osKernelStart()` 早 ≈100 ms,ISR 不能对尚不存在的消费者动手(同本仓 TIM7 分支 2026-10-02 的实测坑)。验证:`@build` 0 错 0 警(Code 256368→**256728**)、**`@test` 6/6 PASS**、**`idle` 由 1~649 降到 0**、`frames` 严格 1000.0 Hz、`seq−frames = 7` 正是就绪窗口、UI 侧 `uip/uiw/uis` 一字不差、0 `task stall`。本文同步:§4.1 补优先级变化与可接受性论证、§4.3 补"再现状"(P0-2 那段已删)、§4.4 改为"忙等未改但敏感度上升"、§8 的 P1-5 行转 ✅ 并拆出仍未办的"单拍硬上界"、健康剖面按 6 连跑复测值更新 | -| 2026-10-10 | **v2.8 消掉 §4.4 的 2 ms 忙等(P1-5 拆出的"单拍硬上界"已办)**:节拍源 **TIM6 → TIM3**(更正一处硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时 ① ISR 脉冲 TPAFE CONVST、② 经 **TRGO** 硬件触发 ADC1 一轮 9 通道、③ 同一事件置位 `ADC_WAKE_FLAG`;ADC1 挂 **DMA2_Stream4** 循环模式 + `DMAContinuousRequests=ENABLE`,长度 2×9 **乒乓双缓冲**,半区由完成计数奇偶推出。`App/algo/adc_input.c` 的 `stm32_scan()`(逐通道 `PollForConversion`)→ `stm32_take()`(零忙等);故障观测拆成 `adc1_seq`/`adc1_gap`/`adc1_err`/`adc1_hw` 四类(单拍无新帧 = 消费者相位伪影,不计故障)。实测:`adcp` **122 µs → 8 µs**、`adc1_err=0`、`adc1_hw=0`、`adc1_gap=27`/20002 拍(0.13%,集中在开机日志突发期)、`adc1_seq` 与 `seq` 恒定偏移 2;主机单测 79 → **101**;`@build` 0 错 0 警 Code 256872→**257660**;`@test` PASS;adcTask 静态最坏链 **832 B / 余 59%** 不变。CubeMX 侧三轮重生成的三处坑(ADC1 抢 Stream0 连锁搬走 SPI1_RX/USART1_RX、换流把 NVIC 优先级重置为 0、TIM6 残留)见 §11.2 | -| 2026-10-10 | **§4.4 复核修正(无代码改动)**:① 原文"9 通道全超时最坏 = **18 ms** 连占"**有误**——`stm32_scan()` 在首个超时处即 `HAL_ADC_Stop()`+`return`(`adc_input.c:79-84`),单拍上界 = **一次**超时 ≈2~3 ms;② 6 次上板对照证明 **2 ms 只在超时拍出现**:超时 1 次那轮 `adcp = 2209 µs`,超时 0 次的 5 轮 `adcp = 122 µs`;③ `adcm ≈ 2.1 ms` 的归因由"ADC 采集"**改判**为每 10 s 一次的心跳日志(`idle = 0`/20000 反证不存在"每拍 2 ms");④ 新增超时的**正确性**代价——超时拍 `s_stm32_valid` 仍为 1 → STM32 侧静默沿用上一拍值、`skip` 抓不到 → 硬化第一步应是"超时即置 `s_stm32_valid = 0`"。同步修正 `App/task/adc_task.c` 两处过期注释与 `ADC采样节拍设计说明.md` §10.1/§10.3/§10.4 | -| 2026-10-10 | **补 F4 ADC 上电自校准(§7.2 候选根因之一)**:核查发现本项目**从未**做过 ADC 自校准——CubeMX 不生成该步骤,且仓库裁剪版 HAL 无 `HAL_ADCEx_Calibration_Start`、裁剪版 CMSIS 头无 `ADC_CR2_CAL`。已在 `Src/adc.c` 的 `USER CODE` 区实现 `adc1_calibrate()`(清 ADON→等生效→置 ADON→置 CAL→等 CAL 由硬件清零)并在 `MX_ADC1_Init()` 末尾调用;新增 `cubemx-survival` 审计项 `adc1-calibration-call` / `adc1-calibration-impl`。验证:`@build` 0 错 0 警(Code 256728→**256868**)、`@test` **PASS**(`adcp` 仍 122 µs、`adc1_err=0`、`idle=0`);**校准前后同一拍 STM32 侧 6 路同向偏移** `UB 898→961`/`UC 978→1038`/`IB 906→922`/`IC 1061→1071`/`IN 1017→1055`/`T 790→846`(+10~+63 LSB),TPAFE 侧 `UA/UN/IA` 一字不变 → 变化确来自 ADC1 校准。§4.4 的 2 ms 忙等与"超时沿用旧值"**仍未处理** | -| 2026-10-10 | **修 §4.4 的"超时拍静默沿用旧值"(正确性缺陷)**:`stm32_scan()` 两条失败支路(`HAL_ADC_Start` 失败 / 轮询超时)现都清 `s_stm32_valid` → 失败拍 `adc_input_raw()` 返 -2 → `adc_task` 走既有 `skip` 整帧跳过,不再把上一拍的值当新数据压入 20 点 DFT 窗;下一拍成功自动恢复。主机侧新增 **TC-AI-070..078**(70 → 79 用例)钉死——旧用例 TC-AI-034/039b 都是"init 后立刻失败"(`valid` 本就是 0),照不出"成功过之后再失败"这条路径。**反向验证已做**:把支路改回旧行为 → TC-AI-039b/073/074 立即 FAIL(76/79)。验证:`@build` 0 错 0 警(Code 256868→**256872**)、主机单测 **79/79**、`@test` **PASS**(本轮无超时:`adc1_err=0`、`skip=0`、`idle=0`)。**剩余未做 = 消掉 2 ms 忙等本身(DMA)** | diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index 085e57e9..1452644b 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -320,7 +320,7 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 |------|------|------|------| | **N-02** | **FTP 无鉴权**:`cmd_pass` 无条件 `send_simple_response(socket, 230, STATUS_230)` | `lftpd.c:367-371`(`cmd_user :539` 亦不校验) | 最小可用方案:`config.ini` 增 `[ftp] user/pwd_hash`,`cmd_pass` 用**定长比较 + 失败计数锁定**;生产固件默认关闭 FTP 写权限(`FF_FS_READONLY` 式的只读模式) | | N-09 | `cmd_pasv` 每次新建监听 `lftpd_inet_listen(0)`(随机端口) | `lftpd.c:373` | 在**单连接 socket** 上与 UI `:7003`、对时 `:8888` 争用同一物理 socket;建议限定监听资源池 + 显式"FTP 会话独占"策略,并在文档写明"一次只允许一个 TCP 服务活动" | -| N-10 | uiTask 以 **10 ms** 周期向 netMsgQueue 投递 accept 轮询 → ≈100 msg/s,与 FTP 共享深度 8 的队列 | `ui_net_sink.c` 轮询逻辑 | 队列满时 `osErrorResource` → UI 丢帧/重投。建议:把"服务就绪"改为事件驱动(netTask 在 LISTEN 状态变化时置位),UI 侧改为等待事件;或给队列扩容并在 UI 侧背压。**→ 已销项(2026-10-10)**:未走"事件驱动",改用**门控**——`net_can_accept()`/`net_can_read()` 纯 RAM 读控制块,无活时**一次 RPC 都不发**(100 msg/s → 0);实测 `uis` 50 ms → **1 µs**。方案与实测见 `UI任务看门狗停摆分析.md` §9.3 / §9.3.2 | +| N-10 | uiTask 以 **10 ms** 周期向 netMsgQueue 投递 accept 轮询 → ≈100 msg/s,与 FTP 共享深度 8 的队列 | `ui_net_sink.c` 轮询逻辑 | 队列满时 `osErrorResource` → UI 丢帧/重投。建议:把"服务就绪"改为事件驱动(netTask 在 LISTEN 状态变化时置位),UI 侧改为等待事件;或给队列扩容并在 UI 侧背压。**→ 已销项(2026-10-10)**:未走"事件驱动",改用**门控**——`net_can_accept()`/`net_can_read()` 纯 RAM 读控制块,无活时**一次 RPC 都不发**(100 msg/s → 0);实测 `uis` 50 ms → **1 µs**。方案见 `../UI设计/RemoDispBus协议与接收设计说明.md` §3;判读口径与实测基线见 `../子系统设计/看门狗设计说明.md` §11.4 | | P2-11 | `net_task.c` 对 `osEventFlagsGet` + Clear 非原子 | 台账 | 用 `osEventFlagsAcquire`/单次 Wait 消 TOCTOU | | P2-8 | `net_recv` 阻塞超时 30 s | 台账 | 缩短并让上层可取消 | | — | PHY 强制 100M 全双工,忽略自协商 | AGENTS/architecture | 与某些交换机会联不通;至少加"链路 up 失败 3 次后回退自协商" | @@ -679,7 +679,7 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea | F-1/F-2/F-3 | `lv_conf.h` 与 LVGL 9.3 对齐 + 主题/控件/flex 裁剪 | Flash −55 KB | | N-12/N-13 | `dhara_nand_is_free` 全页校验;`dhara_nand_copy` 处理 `DHARA_NO_PAGE` | 掉电注入测试通过 | | N-16 | 日志常驻 `FIL` + 批量 flush;UI 记录读缓存升级 | 单条日志 NAND 操作次数下降 ≥50% | -| N-10 | ~~UI accept 改事件驱动(脱离 100 msg/s 轮询)~~ **已落地(2026-10-10,改走"门控"而非事件驱动)** | ✅ 无客户端时队列占用 **100 msg/s → 0**;实测 `uis` 50 ms → **1 µs**(`UI任务看门狗停摆分析.md` §9.3 / §9.3.2) | +| N-10 | ~~UI accept 改事件驱动(脱离 100 msg/s 轮询)~~ **已落地(2026-10-10,改走"门控"而非事件驱动)** | ✅ 无客户端时队列占用 **100 msg/s → 0**;实测 `uis` 50 ms → **1 µs**(`../UI设计/RemoDispBus协议与接收设计说明.md` §3;判读见 `../子系统设计/看门狗设计说明.md` §11.4) | | D-1 | 芯片手册图片降采样 / 拆 submodule | clone 40 MB → ~8 MB | | D-3 | `check_doc_links.py` 入门禁 + 修 18 条断链 | 零断链 | | U1–U4 | UI 令牌化 + 原型库 + 生成器 + golden | 新增一页 ≤30 行 | From 6c8f82ec546efbf5339b639c25d74aa8aa908c57 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 00:56:23 +0800 Subject: [PATCH 17/47] =?UTF-8?q?docs(algo):=20L2=20=E7=9B=B8=E9=87=8F?= =?UTF-8?q?=E5=B1=82=E5=8F=B0=E6=9E=B6=E9=97=AD=E7=8E=AF=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=8CP0=20=E9=87=8C=E7=A8=8B=E7=A2=91=E8=BE=BE=E6=88=90?= =?UTF-8?q?=EF=BC=88=E8=AF=81=E6=8D=AE+=E6=A0=88=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @test 新增 Required 标记 phasor(first)/phasor(hb) 并校验顺序 App init OK>phasor(first)>phasor(hb) → PASS(net init: OK 不在序内: adcTask 与 netTask 并行,phasor(first)≈+500ms 早于 net init 属正常) - 证据归档 boot_log_data/20261011_prod_phasor_boot_log.txt:5 实例相量 开机即产出、frames 每 10s +10000(1000.0 Hz)、idle=0、零 [ERR]、 fw build 身份戳匹配、回读一致、锚点 ×1 - 栈复测:adcTask 静态 832 B / 实测峰值 928/2048(余 54%),7 任务 全 PASS;RAM 方案 §2.3 基线同步 - 规划 §3.1 L1-9、§3.2 L2-1~L2-4/L2-6/L2-7 置 ✅(精度判据保留 "待标准源"注);L2-5 更正为"V0.1 不适用、随 V0.2"(N-18 + 外部 零序专用通道,原表下注系误记);§7.1 P0 里程碑置 ✅ - 自动化测试流程登记 phasor 常开标记(hb 需采集窗口 ≥10s) --- docs/内存管理/RAM使用分析与优化方案.md | 4 +- .../20261011_prod_phasor_boot_log.txt | 117 ++++++++++++++++++ docs/测试说明/自动化测试流程.md | 1 + docs/算法/继电保护功能复现规划.md | 27 ++-- 4 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 docs/测试说明/boot_log_data/20261011_prod_phasor_boot_log.txt diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 773517e9..450eb256 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -93,6 +93,8 @@ > ✅ **本表已于 2026-10-10 重新基线**(产品构建 `suite=NONE`,开机 + 空闲采样,静态链取 `stack_report.py`)。 > 同日**增列 `adcTask`**(B1:ADC 采集消费者,512 words,静态最坏链 504 B / 余 75%)。 +> **2026-10-11 L2 相量层接入后复测**:adcTask 静态 **832 B** / 台架实测峰值 **928 B / 2048(余 54%)**, +> 增量为压环 + `phasor_run()`(`win[20]` 80 B)+ `log_phasors` 日志链;仍高于 1024 B 底线,7 任务全 PASS。 > 此前 2026-10-04 的 V5 30 min 重采曾把 defaultTask 推到 **3664B/4096(余量 432B ≈ 10%,登记 S-1)**; > 2026-10-10 的栈整改(**未抬栈**,见 §4 阶段 0 的"2026-10-10 复测")把它压回 **2152B/4096**,S-1 已销项。 @@ -104,7 +106,7 @@ | testTask | `2048*4` | 8KB | 测试套件(产品构建不创建;存储套件深链需要) | 未创建 / 静态 496 B | | defaultTask | `1024*4` | 4KB | FatFS 挂载/探针 + 配置引擎 + syslog + 栈水位探针 | 2152 B(余 47%)/ 静态 2776 B | | timeSyncTask | `512*4` | 2KB | TCP 时间同步 | 616 B(余 69%)/ 静态 **1024 B**(= ISR 底线,无再加深空间) | -| adcTask | `512*4` | 2KB | TPAFE5160 采样(1 ms 采样节拍触发 CONVST + 原子快照消费,B1 2026-10-10) | **496 / 2048**(余 1552 B / 75%,台架 `@test` 实测);静态 504 B | +| adcTask | `512*4` | 2KB | TPAFE5160 采样消费 + L2 相量(TIM3 1 kHz 节拍 + 快照 + 压环 + 全周傅氏,B1→L2 2026-10-11) | **928 / 2048**(余 1120 B / 54%,台架 `@test` 实测 2026-10-11);静态 832 B | | idle(内核) | `configMINIMAL_STACK_SIZE=128` | 0.5KB | 空闲 | 未观测(未取句柄) | | timer(内核) | `configTIMER_TASK_STACK_DEPTH=256` | 1KB | 软定时器 | 未观测 | diff --git a/docs/测试说明/boot_log_data/20261011_prod_phasor_boot_log.txt b/docs/测试说明/boot_log_data/20261011_prod_phasor_boot_log.txt new file mode 100644 index 00000000..a686ec40 --- /dev/null +++ b/docs/测试说明/boot_log_data/20261011_prod_phasor_boot_log.txt @@ -0,0 +1,117 @@ +[+0ms] A-F5: Re=0 Im=1 rms=1 (x0.1) +[+8522ms] [----/--/-- --:--:--][APP] boot: syslog init OK +[+8522ms] [----/--/-- --:--:--][APP] fw build: Oct 11 2026 00:38:20 +[+8532ms] [----/--/-- --:--:--][APP] reset cause: software (NVIC_SystemReset) (CSR=0x14000000) +[+8532ms] PREVRUN seg=2 seq=1914584053 t=10556 +[+8543ms] TICKRUN uw=10569 n=10569 duw=13 +[+8543ms] [----/--/-- --:--:--][RTC] sd2506_init: OK +[+8543ms] [----/--/-- --:--:--][APP] SD2506 init: OK (ret=0) +[+8553ms] [2026-10-10 16:51:42][CLK] sys_clock_init: RTC initial time 2026-10-10 16:51:42 +[+8564ms] [2026-10-10 16:51:42][CLK] sys_clock_init: wall clock base = unix 1791651102, base_tick=137 +[+8564ms] [2026-10-10 16:51:42][APP] RS485 init: OK (UART4, DE=PA15) +[+8585ms] [2026-10-10 16:51:42][NAND] Resetting NAND... +[+8606ms] [2026-10-10 16:51:42][NAND] Reading NAND ID... +[+8606ms] [2026-10-10 16:51:42][NAND] NAND ID: MID=0xC8, DID=0x52 +[+8606ms] [2026-10-10 16:51:42][NAND] Scanning bad blocks... +[+8748ms] [2026-10-10 16:51:42][NAND] BBT scan: 0 bad blocks found +[+8748ms] [2026-10-10 16:51:42][NAND] Enabling ECC... +[+8758ms] [2026-10-10 16:51:42][NAND] Unlocking block protection... +[+8758ms] [2026-10-10 16:51:42][NAND] Locating BBT pool... +[+8758ms] [2026-10-10 16:51:42][NAND] Loading persisted BBT... +[+8769ms] [2026-10-10 16:51:42][NAND] NAND init OK +[+8769ms] [2026-10-10 16:51:42][APP] NAND init: OK +[+8779ms] [2026-10-10 16:51:42][ADC] Init OK +[+8779ms] [2026-10-10 16:51:42][APP] ADC init: OK +[+8790ms] [2026-10-10 16:51:42][APP] ADC input layer init: OK +[+8790ms] [2026-10-10 16:51:42][APP] phasor layer init: OK (inst=5) +[+8800ms] [2026-10-10 16:51:42][APP] SD2506 ID: 2512153425482405 +[+8800ms] [2026-10-10 16:51:42][APP] SD2506 time: 2026-10-10 16:51:42 (week 2) +[+8813ms] [2026-10-10 16:51:42][APP] SD2506 temp: 28 C +[+8813ms] [2026-10-10 16:51:42][APP] SD2506 battery: 3.11 V +[+8823ms] [2026-10-10 16:51:42][APP] App init OK +[+8823ms] [2026-10-10 16:51:42][NAND_FTL] Initializing NAND FTL... +[+8854ms] [2026-10-10 16:51:42][NAND_FTL] Map resumed: 14094/97504 sectors used +[+8854ms] [2026-10-10 16:51:42][NAND_FTL] Map head=116992 tail=99819 epoch=1 +[+8865ms] [2026-10-10 16:51:42][NAND_FTL] NAND FTL ready (capacity: 97504 pages = 190 MB) +[+8936ms] [2026-10-10 16:51:42] [FS] FATFS mounted +[+8988ms] [2026-10-10 16:51:42][SYSLOG] runlog boot entry: reboot +[+8988ms] [2026-10-10 16:51:42][CFG] register: section 'network' (4 keys) +[+8999ms] [2026-10-10 16:51:42][CFG] load: OK (523B, heap free 15240B) +[+9009ms] [2026-10-10 16:51:42][NET_CFG] load: OK ip=192.168.1.100 +[+9030ms] [2026-10-10 16:51:42][ADC_TASK] first: UA=11429 UB=975 UC=840 UN=11454 IA=11451 IB=930 IC=1004 IN=889 T=904 +[+9040ms] [2026-10-10 16:51:42][ADC_TASK] phasor(first) C01-IA-F1: Re=0 Im=-3 rms=3 (x0.1) +[+9051ms] [2026-10-10 16:51:42][ADC_TASK] phasor(first) C02-UA-F1: Re=1 Im=0 rms=1 (x0.1) +[+9062ms] [2026-10-10 16:51:42][ADC_TASK] phasor(first) C03-UN-F1: Re=-1 Im=-1 rms=1 (x0.1) +[+9062ms] [2026-10-10 16:51:42][ADC_TASK] phasor(first) C04-IA-F2: Re=1 Im=2 rms=2 (x0.1) +[+9072ms] [2026-10-10 16:51:42][ADC_TASK] phasor(first) C05-IA-F5: Re=0 Im=0 rms=0 (x0.1) +[+9083ms] [2026-10-10 16:51:42][NET_TASK] === Network Init === +[+9083ms] [2026-10-10 16:51:42][NET_TASK] net init: OK +[+9094ms] [2026-10-10 16:51:42][NET_TASK] MAC: 3C:AB:72:53:2B:12 +[+9094ms] [2026-10-10 16:51:42][NET_TASK] IP_INF: IP=192.168.1.100 GW=192.168.1.1 MASK=255.255.255.0 +[+9104ms] MISS start snap=0x00000067 req=0x00000077 t=686 +[+9104ms] [2026-10-10 16:51:42][WD] [WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms +[+9115ms] [2026-10-10 16:51:42][UI_TASK] UI task: started, wait net ready +[+11071ms] [2026-10-10 16:51:44][NET_TASK] PHY: 100M FULL +[+11071ms] [2026-10-10 16:51:44][NET_TASK] netTask: started (single-socket mode) +[+11112ms] [2026-10-10 16:51:44] [FTP] waiting for connection... +[+11154ms] [2026-10-10 16:51:44][TIME_SYNC] time_sync: TCP listen on :8888 (sock=1) +[+11154ms] [2026-10-10 16:51:44][UI_TASK] UI task: net ready, init port +[+11164ms] [2026-10-10 16:51:44][UI_TASK] UI task: ui_port_init enter +[+11175ms] [2026-10-10 16:51:44][UI_PORT] theme: mono dark_bg=true applied +[+11175ms] [2026-10-10 16:51:44][UI_TASK] UI port init: OK +[+11185ms] [2026-10-10 16:51:44][UI_TASK] UI task: ui_menu_init enter +[+11185ms] [2026-10-10 16:51:44][UI_MENU] menu build start: total=39408 free=36572 big=36572 +[+11195ms] [2026-10-10 16:51:44][UI_MENU_VIEW] view init: root frame w=66 h=107 +[+11206ms] [2026-10-10 16:51:44][UI_MENU] menu build done: total=39292 free=35708 big=35696 used=3584 +[+11206ms] [2026-10-10 16:51:44][UI_TASK] UI menu init: OK +[+11216ms] [2026-10-10 16:51:44][UI_STANDBY] standby: ready (idle=60000ms) +[+11227ms] [2026-10-10 16:51:44][UI_TASK] UI standby: ready (idle=60s) +[+11268ms] [2026-10-10 16:51:44][UI_TASK] UI net sink: started (port 7003) +[+11279ms] MISS start snap=0x00000067 req=0x00000077 t=2861 +[+11279ms] [PROF] t=2865 uic=0 uip=0 uipn=0 uil=0 uiw=0 uis=0 uisn=0 adc=2458 adcm=2067 adcn=0 gl=0 +[+11289ms] [PROF2] t=2865 defd=11937 defs=7 netp=0 netn=0 adcp=8 adcq=0 gl=0 +[+13444ms] [PROF] t=5028 uic=213 uip=20263 uipn=8995 uil=9999 uiw=10317 uis=1 uisn=0 adc=4621 adcm=2067 adcn=0 gl=0 +[+13455ms] [PROF2] t=5028 defd=11937 defs=7 netp=0 netn=0 adcp=8 adcq=0 gl=0 +[+13858ms] [2026-10-10 16:51:47] [STACK] default peak_used=2152/4096 free_min=1944 (47%) +[+13868ms] [2026-10-10 16:51:47] [STACK] net peak_used=940/3840 free_min=2900 (75%) +[+13868ms] [2026-10-10 16:51:47] [STACK] ftp peak_used=740/4096 free_min=3356 (81%) +[+13879ms] [2026-10-10 16:51:47] [STACK] ui peak_used=4052/16384 free_min=12332 (75%) +[+13889ms] [2026-10-10 16:51:47] [STACK] timesync peak_used=616/2048 free_min=1432 (69%) +[+13889ms] [2026-10-10 16:51:47] [STACK] adc peak_used=928/2048 free_min=1120 (54%) +[+13900ms] [2026-10-10 16:51:47] [STACK] heap free=15088 min_ever=7152 +[+15454ms] [PROF] t=7041 uic=408 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=6634 adcm=2067 adcn=0 gl=0 +[+15465ms] [PROF2] t=7041 defd=11937 defs=7 netp=0 netn=0 adcp=8 adcq=0 gl=0 +[+17476ms] [PROF] t=9057 uic=608 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=8650 adcm=2067 adcn=0 gl=0 +[+17486ms] [PROF2] t=9057 defd=11937 defs=7 netp=0 netn=0 adcp=8 adcq=0 gl=0 +[+18829ms] [2026-10-10 16:51:52][ADC_TASK] frames=10001 idle=0 skip=24 seq=10006 frst_err=0 ring=9977 adc1_rc=0 adc1_err=0 adc1_seq=10004 adc1_gap=24 adc1_hw=0 +[+18840ms] [2026-10-10 16:51:52][ADC_TASK] phasor(hb) C01-IA-F1: Re=-1 Im=3 rms=3 (x0.1) +[+18850ms] [2026-10-10 16:51:52][ADC_TASK] phasor(hb) C02-UA-F1: Re=-2 Im=-1 rms=3 (x0.1) +[+18850ms] [2026-10-10 16:51:52][ADC_TASK] phasor(hb) C03-UN-F1: Re=-2 Im=0 rms=2 (x0.1) +[+18860ms] [2026-10-10 16:51:52][ADC_TASK] phasor(hb) C04-IA-F2: Re=2 Im=1 rms=3 (x0.1) +[+18860ms] [2026-10-10 16:51:52][ADC_TASK] phasor(hb) C05-IA-F5: Re=2 Im=0 rms=2 (x0.1) +[+19523ms] [PROF] t=11106 uic=807 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=10699 adcm=2067 adcn=0 gl=0 +[+19533ms] [PROF2] t=11106 defd=47235 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+21545ms] [PROF] t=13123 uic=1007 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=12716 adcm=2067 adcn=0 gl=0 +[+21545ms] [PROF2] t=13123 defd=47235 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+23553ms] [PROF] t=15140 uic=1207 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=14733 adcm=2067 adcn=0 gl=0 +[+23563ms] [PROF2] t=15140 defd=47235 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+25575ms] [PROF] t=17157 uic=1407 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=16750 adcm=2067 adcn=0 gl=0 +[+25586ms] [PROF2] t=17157 defd=47235 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+27596ms] [PROF] t=19174 uic=1607 uip=58799 uipn=9995 uil=9979 uiw=10317 uis=1 uisn=0 adc=18767 adcm=2067 adcn=0 gl=0 +[+27596ms] [PROF2] t=19174 defd=47235 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+28829ms] [2026-10-10 16:52:02][ADC_TASK] frames=20000 idle=1 skip=26 seq=20006 frst_err=0 ring=19974 adc1_rc=0 adc1_err=0 adc1_seq=20004 adc1_gap=26 adc1_hw=0 +[+28840ms] [2026-10-10 16:52:02][ADC_TASK] phasor(hb) C01-IA-F1: Re=5 Im=1 rms=5 (x0.1) +[+28840ms] [2026-10-10 16:52:02][ADC_TASK] phasor(hb) C02-UA-F1: Re=-1 Im=-1 rms=2 (x0.1) +[+28850ms] [2026-10-10 16:52:02][ADC_TASK] phasor(hb) C03-UN-F1: Re=0 Im=-2 rms=2 (x0.1) +[+28861ms] [2026-10-10 16:52:02][ADC_TASK] phasor(hb) C04-IA-F2: Re=0 Im=1 rms=1 (x0.1) +[+28861ms] [2026-10-10 16:52:02][ADC_TASK] phasor(hb) C05-IA-F5: Re=0 Im=0 rms=0 (x0.1) +[+29622ms] [PROF] t=21206 uic=1804 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=20799 adcm=2067 adcn=0 gl=0 +[+29632ms] [PROF2] t=21206 defd=47236 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+31642ms] [PROF] t=23223 uic=2004 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=22816 adcm=2067 adcn=0 gl=0 +[+31652ms] [PROF2] t=23223 defd=47236 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+33653ms] [PROF] t=25240 uic=2204 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=24833 adcm=2067 adcn=0 gl=0 +[+33663ms] [PROF2] t=25240 defd=47236 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+35670ms] [PROF] t=27257 uic=2404 uip=58799 uipn=9995 uil=9999 uiw=10317 uis=1 uisn=0 adc=26850 adcm=2067 adcn=0 gl=0 +[+35681ms] [PROF2] t=27257 defd=47236 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 +[+37692ms] [PROF] t=29274 uic=2604 uip=58799 uipn=9995 uil=9979 uiw=10317 uis=61 uisn=0 adc=28867 adcm=2067 adcn=0 gl=0 +[+37702ms] [PROF2] t=29274 defd=47236 defs=7 netp=0 netn=0 adcp=11 adcq=0 gl=0 diff --git a/docs/测试说明/自动化测试流程.md b/docs/测试说明/自动化测试流程.md index 46510688..d1e99923 100644 --- a/docs/测试说明/自动化测试流程.md +++ b/docs/测试说明/自动化测试流程.md @@ -65,6 +65,7 @@ | `ADC init:` | `App/app/app_main.c` | | `SD2506 ID:` / `time:` / `temp:` / `battery:` | `App/app/app_main.c` | | `Network Init` / `net init: OK` / `MAC:` / `PHY:` | `App/task/net_task.c` | +| `phasor(first)` / `phasor(hb)` | `App/task/adc_task.c`(L2 相量层,2026-10-11 起常开;`hb` 每 10 s 一次,采集窗口需 ≥10 s——默认 `max_wait 30 s` 覆盖) | **默认 Forbidden 模式**:`FAIL;ERROR;HardFault;[ERR]` diff --git a/docs/算法/继电保护功能复现规划.md b/docs/算法/继电保护功能复现规划.md index e5a34f60..19ad4f61 100644 --- a/docs/算法/继电保护功能复现规划.md +++ b/docs/算法/继电保护功能复现规划.md @@ -4,7 +4,7 @@ > **参考来源**:`D:\Code\DTU\参考资料\huaiyuan\huaiyuan\Scatter_P_HV03.01_SV01.01\项目分析文档\04-算法与采样专题\ADC采集至数据使用全流程详解`(同专题另有《项目详细分析-交流采样与有效值计算》《傅氏算法详解》《项目详细分析-阶段6-继电器保护单元分析》)。 > **阅读前提**:先读 `docs/算法/傅氏算法详解.md`(算法原理)与 `docs/硬件平台/ADC通道映射说明.md`(采样通道权威口径)。 > **相关文档**:`docs/产品需求/产品需求设计文档.md`、`docs/待处理内容.md`、`docs/内存管理/RAM使用分析与优化方案.md`。 -> **状态**:规划稿——保护链路本体尚未实现;其前置的 **ADC 采集链路(`adc_task`)已于 2026-10-10 实现并 `@test` 实机闭环**(B1:周期 CONVST + 原子快照消费 + 建任务 + 栈预算 + 台架 1000 Hz 确认)。 +> **状态**:**P0 采样与相量底座已台架闭环(2026-10-11)**——L1 采集链路(B1,2026-10-10)与 L2 相量层(`sample_ring` + `phasor`,57 项主机单测 + 台架 `@test` 含 `phasor(first)/phasor(hb)` 标记闭环)均已落地;**L3 保护元件 / L4 出口记录 / L5 自检闭锁与定值模型尚未实现**(下一步 = P1,见 §7)。 --- @@ -38,7 +38,7 @@ | 已实现子系统 | 网络(CH395F)、存储(GD5F2GQ5UE SPI NAND + dhara FTL + FatFs)、时间(SD2506 RTC + `sys_clock`)、配置(cfg_file)、日志、UI(远端显示)、看门狗(IWDG)、FTP | | 采样硬件 | TPAFE5160(16 位、8 通道**同步**、±5 V 双极性、并行 `GPIOG[15:0]`)+ STM32 内部 ADC1(12 位、9 通道**顺序**扫描、0~3.3 V 单极性) | | 采样软件 | `Drivers/BSP/TPAFE5160/tpafe5160.h` 驱动就绪(原子快照 API);`App/task/adc_task.c` **已实现真实消费者(B1,2026-10-10)**——`tpafe5160_snapshot()` 取数 + 帧统计;**采样节拍由 TIM3 更新事件(1 kHz)硬件触发**(2026-10-10;同一事件经 TRGO 触发 ADC1 + DMA);STM32 侧 **`App/algo/adc_input.c` 已实现语义映射 + ADC1 DMA 乒乓帧消费(L1-3/L1-4,v2.0)**,`HAL_ADC_Start_DMA` 已出现 | -| 保护算法层 | **完全缺失**(无傅氏、无保护元件、无出口、无定值模型) | +| 保护算法层 | **L2 相量层已实现并台架闭环**(`App/algo/sample_ring.c` + `phasor.c`:全周傅氏 5 实例、双区交换、每 1 ms 刷新,2026-10-11 `@test`);**保护元件 / 出口 / 定值模型仍缺失**(P1 起) | | 已知硬件缺陷 | **N-18**:8 路电气量分属两片转换器 → V0.1 上禁止跨转换器做相位/功率/矢量运算 | ### 1.3 复现范围界定 @@ -67,7 +67,7 @@ | **B0 产品定义** | 采集是否持续 / 采样率与通道 / 转发目标 / 保护元件清单——决定 B1 与保护层的**规模** | ✅ 已确认(2026-10-10,见 §1.5) | | **B1 ADC 采集链路** | 周期 CONVST + `adc_task` 消费 `tpafe5160_snapshot()` + 建任务 + 栈预算 | ✅ 已实现并 `@test` 实机闭环(2026-10-10;`adcTask` 512 words,台架 1000 Hz) | | **N-18 硬件缺陷** | V0.1 跨转换器无同一时刻性 → 限定 V0.1 可实现的保护范围 | ⚠️ 硬件改版项(V0.2) | -| **栈预算** | 保护任务栈需入 `Src/freertos.c` 并经 `scripts/stack_report.py` 核算 | 🔶 `adcTask` 已核算(静态 504 B / 余 75%;台架实测峰值 496/2048);保护任务为后续 | +| **栈预算** | 保护任务栈需入 `Src/freertos.c` 并经 `scripts/stack_report.py` 核算 | 🔶 `adcTask` 已核算(**L2 接入后复测 2026-10-11:静态 832 B / 台架实测峰值 928/2048 / 余 54%**);`protectTask` 为后续 | ### 1.5 B0 产品定义(2026-10-10 确认) @@ -246,7 +246,7 @@ | L1-6 | 采样有效性校验 | FRSTDATA 跳变、帧序号单调、丢帧计数 | 芯片静默/丢帧被检出并置告警 | ⬜ | | L1-7 | 等周波采样(软件测频 + 调频闭环) | 20 点/周波;测频范围建议 **45~55 Hz**(参考 40.5~60.5) | 频率偏移 ±2 Hz 时幅值误差 ≤ 1% | ⬜ | | L1-8 | 零序通道接入(外部专用 / 自产切换) | 外部 `UN`/`IN`;自产 `3I0 = Ia+Ib+Ic`(V0.2 起) | 零序通道可切换来源,值正确 | ⬜ | -| L1-9 | 二次谐波/五次谐波提取入口 | k=2(涌流)、k=5(选线) | 相量层可产出对应谐波相量 | ⬜ | +| L1-9 | 二次谐波/五次谐波提取入口 | k=2(涌流)、k=5(选线) | 相量层可产出对应谐波相量 | ✅ 2026-10-11:`phasor.c` 实例表 `C04-IA-F2`(k=2)/ `C05-IA-F5`(k=5)随每拍重算产出,台架心跳日志 `phasor(hb) C04/C05` 行可见(`boot_log_data/20261011_prod_phasor_boot_log.txt`);谐波**抑制/提取正确性**已由主机单测覆盖(`test/host/phasor/`) | > ⚠️ **N-18 约束**:L1-4 中 `UA/UN/IA` 在 TPAFE、`UB/UC/IB/IC/IN` 在 STM32 ADC。V0.1 上 L1 只做**逐通道独立监视**,不得假设同族量同刻(`UA` 与 `UB` 无共同采样触发)。 @@ -254,20 +254,20 @@ | # | 功能点 | 关键参数 | 完成判据 | 状态 | | --- | --- | --- | --- | --- | -| L2-1 | 全周傅氏系数表 | `sin(2πk/N)·(√2/N)`、`cos(2πk/N)·(√2/N)`,N=20 | 系数表与 `docs/算法/傅氏算法详解.md` §2.3 一致 | ⬜ | -| L2-2 | 基波相量实例 | **5 个实例**(参考项目 4 个,见下方注):`C01-IA-F1` / `C02-UA-F1` / `C03-UN-F1` / `C04-IA-F2` / `C05-IA-F5` | 每实例输出 `{fRe, fIm, fAm, fAmSQ}` | ⬜ | -| L2-3 | 相量有效值输出 | `fAmSQ = √(fRe²+fIm²)` 直接为**有效值**(系数已折算) | 标准源读数 = 设定有效值 | ⬜ | -| L2-4 | 相位输出 | `φ = atan2(fIm, fRe)` | 相位误差 ≤ 目标值 | ⬜ | -| L2-5 | 线/负序/零序合成 | 线电压 `Uab=Ua−Ub` 等;负序 `3U2`/`3I2`;零序 `3U0`/`3I0` | 合成相量可复用于保护元件(**V0.1 仅同转换器内可合成**,见 §1.6) | ⬜ | -| L2-6 | 每 1 ms 滑动窗刷新 | 每来新点,窗滑动一格、20 点重算(暴力法) | 相量每拍更新 | ⬜ | -| L2-7 | 双区指针原子交换 | 计算区/使用区切换 | 保护元件读使用区,无撕裂 | ⬜ | +| L2-1 | 全周傅氏系数表 | `sin(2πk/N)·(√2/N)`、`cos(2πk/N)·(√2/N)`,N=20 | 系数表与 `docs/算法/傅氏算法详解.md` §2.3 一致 | ✅ 2026-10-11:`phasor.c` 离线系数表**预乘 √2/N**;主机单测逐项复核(`test/host/phasor/` 系数表用例) | +| L2-2 | 基波相量实例 | **5 个实例**(参考项目 4 个,见下方注):`C01-IA-F1` / `C02-UA-F1` / `C03-UN-F1` / `C04-IA-F2` / `C05-IA-F5` | 每实例输出 `{fRe, fIm, fAm, fAmSQ}` | ✅ 2026-10-11:`k_inst[]` 数据驱动 5 实例;台架开机即产出 `phasor(first)` 5 行(`boot_log_data/20261011_prod_phasor_boot_log.txt`) | +| L2-3 | 相量有效值输出 | `fAmSQ = √(fRe²+fIm²)` 直接为**有效值**(系数已折算) | 标准源读数 = 设定有效值 | ✅ 2026-10-11(链路级):`fAmSQ` 口径实现 + 单测合成正弦幅值误差 ≤1%;**"标准源读数"判据在无信号源台架不可达**(同 §7.1),待 L1-5 标定后复核 | +| L2-4 | 相位输出 | `φ = atan2(fIm, fRe)` | 相位误差 ≤ 目标值 | ✅ 2026-10-11(链路级):`fRe/fIm` 常驻双区、每拍产出,φ 由消费侧 `atan2f` 现算(不常驻结构体,见 `App/algo/README.md` §3.3);单测覆盖相位;**目标值待产品定义 + 标准源复核** | +| L2-5 | 线/负序/零序合成 | 线电压 `Uab=Ua−Ub` 等;负序 `3U2`/`3I2`;零序 `3U0`/`3I0` | 合成相量可复用于保护元件(**V0.1 仅同转换器内可合成**,见 §1.6) | ⬜ **V0.1 不适用**:跨转换器合成被 N-18 禁止;零序走**外部专用通道** `UN`/`IN`(§6.4),无需自产 → 随 V0.2 启用 | +| L2-6 | 每 1 ms 滑动窗刷新 | 每来新点,窗滑动一格、20 点重算(暴力法) | 相量每拍更新 | ✅ 2026-10-11:`sample_ring` 40 点环 + `phasor_run()` 每拍整窗重算(仅 `r==1` 新帧压环,P0 修复);台架 `ring` 随 `frames` 同步递增、`idle=0` | +| L2-7 | 双区指针原子交换 | 计算区/使用区切换 | 保护元件读使用区,无撕裂 | ✅ 2026-10-11:`s_zone[2][5]` + 单字节 `volatile` 切换(Cortex-M 原子);单测覆盖双区交换;为独立 L3 任务预留无锁读 | | L2-8 | 遥测谐波轨(可选) | k=1~9,方和根 `√(ΣHk²)` | 与基波轨并行,供 UI/上送 | ⬜ | > **双轨有效值**:保护要"基波有效值 + 相位"(抗衰减直流、抗谐波误动);计量/遥测要"真有效值"(谐波方和根)。两者**不可混用**——这是参考系统 `CN_ANA_VEC_SQRT=FALSE` 与遥测谐波轨并存的根本原因。 > **L2-2 实例数 4 → 5 的来历**:参考项目(`APP/CLS100_Dsp/InstCalCell.c`)为 **4 个实例**——C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`,见 §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开:C01 的"I/U"拆为两行(`C01-IA-F1` 电流 / `C02-UA-F1` 电压),其余顺延为 `C03-UN-F1` 零序、`C04-IA-F2` 二次、`C05-IA-F5` 五次。实现见 `App/algo/phasor.c` 的 `k_inst[]`;判据按 **5** 个实例断言(见 `docs/测试说明/相量层单元测试说明.md` §2.6)。 -> **本表状态列口径**:L2-1~L2-7 的**实现与主机单测已完成**(`App/algo/phasor.c` + `App/algo/sample_ring.c`;`test/host/phasor/` 57 用例全绿),但状态**仍标 ⬜**——**台架 `@test` 闭环未做**,且 L2-3 的完成判据"标准源读数 = 设定有效值"在无信号源台架上不可达(同 §7.1 无示波器口径)。待台架闭环后统一置 ✅。 +> **本表状态列口径**(2026-10-11 更新):L2-1~L2-4 / L2-6 / L2-7 已**实现 + 主机单测(57 用例)+ 台架 `@test` 闭环**——闭环命令 `run_boot_test.py --required "...;phasor(first);phasor(hb)" --require-order "App init OK>phasor(first)>phasor(hb)"`,证据 `boot_log_data/20261011_prod_phasor_boot_log.txt`(`fw build` 身份戳匹配、回读一致、锚点 ×1、零 `[ERR]`)。⚠ **幅值/相位精度判据(标准源)在无信号源台架上不可达**(同 §7.1 无示波器口径),且 L1-5 标定未实现 → 现读数为**原始码值**,只证明链路通;精度复核排在标定之后。L2-5 随 V0.2(N-18),L2-8 可选项未做。 ### 3.3 L3 保护元件 @@ -463,7 +463,7 @@ | 阶段 | 名称 | 内容 | 前置 | 里程碑 | | --- | --- | --- | --- | --- | -| **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 ✅ | 每 1 ms 产出正确的基波相量 | +| **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 ✅ | 每 1 ms 产出正确的基波相量 —— **✅ 2026-10-11 台架闭环**(L1-5 标定 / L1-6 有效性 / L1-7 等周波为 P0 遗留项,精度复核依赖标准源) | | **P1** | 基础保护元件 | **V0.1 有效子集**:过流(A 相,`IA`)、过负荷(A 相)+ 定值模型;B/C 相过流**建接口不使能** | P0 | 标准源注入 A 相可正确动作 | | **P2** | 电压/频率类保护 | 过压/欠压(`UA`)、过频/低频、零序过压(`UN`) | P0 | 电压/频率越限正确动作 | | **P3** | 异常检测与闭锁 | L5 自检闭锁、采样通道自检;三相类(涌流/PT·CT 断线/母线接地)**建接口不使能** | P1/P2 | 自检失败闭锁;未使能元件不误动 | @@ -601,3 +601,4 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-10 | **L1-3/L1-4 落地:STM32 ADC1 扫描 + 语义映射** | 新增 `App/algo/adc_input.c/h`(L1 采集层):① `k_adc_map[]` **语义通道表**(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_desc()`/`adc_input_raw()` **按语义取数**;② `stm32_scan()` STM32 ADC1 9 通道软件触发顺序扫描(`HAL_ADC_Start` + 逐通道 `PollForConversion`/`GetValue`);③ `adc_input_poll()` 统一"TPAFE 快照 + STM32 扫描"。`adc_task.c` 取数改走本层(首帧日志补全 8 路 + 温度、心跳增 `adc1_rc`/`adc1_err`);`app_main.c` 在 `tpafe5160_init()` 后调 `adc_input_init()`(含**映射自检**:STM32 映射通道必落在扫描序列内,失败记 `SYSLOG_SELFTEST`);Keil 工程挂 `adc_input.c` 并加 `../App/algo` 包含路径。§1.2 采样软件行、§3.1 L1-3/L1-4 同步。`@build` 0 错 0 警;台架 `@test` PASS(`ADC input layer init: OK`、`adc1_rc=0`/`adc1_err=0`、`seq` 仍 1000.0 Hz、`adc` 栈峰值 528→584 B、回读一致、零 `[ERR]`/HardFault),证据 `测试说明/boot_log_data/20261010_prod_adc_l1_boot_log.txt` | | 2026-10-10 | **L2 相量层落地 + L2-2 实例数口径对齐** | 新增 `App/algo/sample_ring.c/h`(L2 输入:按语义通道保存 2 周波 = 40 点环缓冲,整帧写后统一推进写指针)与 `App/algo/phasor.c/h`(L2 核心:全周傅氏单频 DFT,N = 20;系数表**离线预乘 √2/N** → `fAmSQ` 直接为**有效值**;计算区/使用区**双区原子切换**;实例表 `k_inst[]` 数据驱动);主机侧单测 `test/host/phasor/` **57 用例全绿**(`phasor-unit-test` 钩子)。**§3.2 L2-2 行口径对齐**:参考项目为 **4 个实例**,本项目按 V0.1 有效通道展开为 **5 个**(`C01-IA-F1`/`C02-UA-F1`/`C03-UN-F1`/`C04-IA-F2`/`C05-IA-F5`),已加注说明 4 → 5 的来历;§4.3 相量区 RAM 行同步为 5 实例。⚠ §3.2 的 L2 各点状态**仍标 ⬜**——实现与主机单测已完成,但**台架 `@test` 闭环未做**,且 L2-3 的完成判据(标准源读数 = 设定有效值)在无信号源台架上不可达,故不置 ✅(口径见 §3.2 表下注) | | 2026-10-10 | **L1-1/L1-3 节拍源与搬运方式升级(v2.8)** | 节拍源 **TIM6 → TIM3**(更正硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时触发 TPAFE CONVST 与(经 TRGO)ADC1 一轮 9 通道;L1-3 由"软件触发 + 逐通道轮询"改为 **DMA2_Stream4 循环搬运 + 乒乓双缓冲**,`stm32_scan()` → `stm32_take()`,单拍 `adcp` **122 µs → 8 µs**。§1.4 采样软件行、§3.2 L1-1/L1-3/L1-4 同步。完整设计与实测见 `docs/子系统设计/ADC采样节拍设计说明.md` §11 | +| 2026-10-11 | **L2 台架闭环:P0 收尾** | `@build` 0 错 0 警 → `@test` 闭环把 `phasor(first)`/`phasor(hb)` 列为 Required 并校验顺序 `App init OK>phasor(first)>phasor(hb)`(⚠ `net init: OK` **不在**该序内——adcTask 与 netTask 并行,`phasor(first)` ≈+500 ms 早于 `net init: OK` ≈+574 ms 属正常)→ **PASS**:5 实例相量开机即产出、心跳 5 实例持续刷新、`frames` 每 10 s +10000(1000.0 Hz)、`idle=0`、`frst_err=0`、`adc1_err=0`、`adc1_hw=0`、`fw build` 身份戳匹配、回读一致、锚点 ×1、零 `[ERR]`/HardFault;证据 `boot_log_data/20261011_prod_phasor_boot_log.txt`。栈复测:adcTask 静态 832 B / 实测峰值 **928/2048(余 54%)**,7 任务全 PASS(RAM 方案 §2.3 已同步)。§3.1 L1-9 置 ✅(`C04-IA-F2`/`C05-IA-F5` 台架产出);§3.2 L2-1~L2-4/L2-6/L2-7 置 ✅(精度判据保留"待标准源"注),**L2-5 更正为"V0.1 不适用、随 V0.2"**(跨转换器合成被 N-18 禁止、零序走外部专用通道——原表下注"L2-1~L2-7 实现完成"对 L2-5 系误记);§7.1 P0 里程碑置 ✅(遗留 L1-5/L1-6/L1-7)。下一步 = **P1**(A 相过流/过负荷 + 定值模型)与 **L1-6 采样有效性校验** | From 13a4e61c2724b6c4f18aa0f2c9f7d9e007aa2f68 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 01:28:20 +0800 Subject: [PATCH 18/47] =?UTF-8?q?L1-6=20=E9=87=87=E6=A0=B7=E6=9C=89?= =?UTF-8?q?=E6=95=88=E6=80=A7=E6=A0=A1=E9=AA=8C=EF=BC=9Aadc=5Fhealth=20?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E5=B1=82=20+=20=E5=91=8A=E8=AD=A6=E7=BC=96?= =?UTF-8?q?=E6=8E=92=20+=2031=20=E7=94=A8=E4=BE=8B=E5=8D=95=E6=B5=8B=20+?= =?UTF-8?q?=20=E5=8F=B0=E6=9E=B6=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 App/algo/adc_health.c/h(纯逻辑判定层,零平台依赖免桩单测): - 静默:两侧帧序号连续 >=100ms 无前进(电平位可自愈,flags() 为 L5-4 闭锁预留) - 丢帧:TPAFE 序号一次前进 >1 计 tgap(与 STM32 侧 adc1_gap 对称,边沿事件) - 单调性:序号倒退锁存位;tick/计数差值法 (int32_t)(a-b) 回绕安全 - 错误增量:FRSTDATA / HAL 错误 / STM32 停摆计数增量,事件位与锁存位编号对齐, 消费者按位图做"每类每次开机留痕一次"编排 adc_task.c v2.9 告警编排(判定与编排分离):严重事件/电平上升沿 DBG_ERROR + SYSLOG_SELFTEST(每类每次开机一次防刷屏),下降沿 DBG_INFO 恢复;gap 不告警 (消费者超拍是已知现象,告警会淹没真故障);心跳行增 tgap 观测字段。 主机单测 test/host/adc_health/ 31 用例全绿(100ms 门限两侧 / 边沿语义 / 倒退锁存 / tick 回绕 / NULL 守卫 / init 幂等),登记 adc-health-unit-test 钩子。 台架 @test PASS:零 [ERR](健康不误报)、tgap=1->2 与 adc1_gap 同步可见、 frames 1000.0 Hz;证据 boot_log_data/20261011_prod_adc_health_boot_log.txt。 抬栈:adc_health_report->syslog_post 控制台回显尾使 adcTask 静态最坏链 832->1080 B(512 words 余 968 B < 1024 B ISR 底线)-> 512->640 words, 复测余 1480 B / 57%,堆 min_ever 7152->6640 B(-512 B 符合预算)。 AGENTS/README 任务表、RAM 方案 §2.3、规划文档 §3.1 L1-6 已同步置完成。 --- .pre-commit-config.yaml | 14 + AGENTS.md | 8 +- App/algo/README.md | 40 +- App/algo/adc_health.c | 200 ++++++++++ App/algo/adc_health.h | 126 ++++++ App/task/adc_task.c | 91 ++++- MDK-ARM/STM32F407-Demo.uvprojx | 5 + README.md | 2 +- Src/freertos.c | 12 +- docs/内存管理/RAM使用分析与优化方案.md | 11 +- .../20261011_prod_adc_health_boot_log.txt | 116 ++++++ docs/测试说明/boot_log_data/README.md | 2 + docs/算法/继电保护功能复现规划.md | 7 +- test/host/adc_health/adc_health_test.c | 374 ++++++++++++++++++ test/host/adc_health/run_unit_test.py | 54 +++ 15 files changed, 1035 insertions(+), 27 deletions(-) create mode 100644 App/algo/adc_health.c create mode 100644 App/algo/adc_health.h create mode 100644 docs/测试说明/boot_log_data/20261011_prod_adc_health_boot_log.txt create mode 100644 test/host/adc_health/adc_health_test.c create mode 100644 test/host/adc_health/run_unit_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e1396897..21307e08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -290,6 +290,20 @@ repos: files: ^(App/sync/time_sync\.(c|h)|App/util/task_watchdog\.h|test/host/time_sync/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7h) ADC 采样有效性校验(L1-6)主机侧单元测试(2026-10-11 加) + # 背景:adc_health.c 是"静默/丢帧/序号倒退/错误计数增量"的判定层——这些 + # 故障在健康台架上**不可复现**(复现 = 拔芯片/改驱动),板端闭环只能证明 + # "健康时不误报",证不了"故障时必报";100ms 门限、tick 回绕、事件边沿 + # 语义等边界只有合成计数序列能精确构造。本钩子用 MSVC 离线编译真实源文件 + # (#include 纳入,纯逻辑零桩)把 11 组判据一次性钉死。 + # files 含被测源与其头、adc_task.c(告警编排的消费方),以及 harness 本身。 + - id: adc-health-unit-test + name: ADC 有效性校验单测(主机侧) + entry: python test/host/adc_health/run_unit_test.py + language: system + files: ^(App/algo/adc_health\.(c|h)|App/task/adc_task\.c|test/host/adc_health/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 8) 测试套件开关门禁(2026-10-01 加) # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 diff --git a/AGENTS.md b/AGENTS.md index 18be78ab..b198f776 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,10 +69,10 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v | testTask | 2048 | BelowNormal | | uiTask | 4096 | BelowNormal | | timeSyncTask | 512 | Normal | -| adcTask | 512 | AboveNormal | +| adcTask | 640 | AboveNormal | -(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM;2026-09-21 可观测性落地后实测:ftpTask 峰值曾达 3480B/4096B(余量 15%,低于 1024B 底线)→ 已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 把 lftpd 9 处大局部缓冲静态化(BSS +4.6KB),复测峰值 **1184B/4096B(余量 71%)**;2026-10-10 新增 adcTask 后 **7 任务全 PASS**(adcTask 静态 560 B / 余 72%);同日 P1-5 后复测 adcTask **832 B / 余 59%**(新增 `osThreadFlagsWait` 调用链,仍高于 1024 B 底线)) +(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM;2026-09-21 可观测性落地后实测:ftpTask 峰值曾达 3480B/4096B(余量 15%,低于 1024B 底线)→ 已按 `docs/内存管理/RAM使用分析与优化方案.md` §1.4 把 lftpd 9 处大局部缓冲静态化(BSS +4.6KB),复测峰值 **1184B/4096B(余量 71%)**;2026-10-10 新增 adcTask 后 **7 任务全 PASS**(adcTask 静态 560 B / 余 72%);同日 P1-5 后复测 adcTask **832 B / 余 59%**(新增 `osThreadFlagsWait` 调用链,仍高于 1024 B 底线);2026-10-11 L2 相量 + L1-6 有效性校验上链后静态最坏链 **1080 B**(`adc_health_report`→`syslog_post` 控制台回显尾)→ **adcTask 抬栈 512→640 words**,复测余 **1480 B / 57%**,堆 min_ever 7152→6640 B) ## 架构要点 @@ -82,8 +82,8 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - **就绪屏障**(`App/app/sys_events.h` Event Flags,取代魔法延时):`SYS_EVT_NET_READY`(netTask 置位)/ `STORAGE_READY`(defaultTask:卷挂载 + 网络配置加载完成)/ `FTP_READY` / `TIMESYNC_READY`;消费者用 `osEventFlagsWait` 阻塞等待 - netTask:`net_stack_bring_up`(配置已由 defaultTask 在屏障前加载,本任务只读 RAM 副本 → `net_init` + PHY 强制 100M 全双工 → 递增网络代次号)→ 置 NET_READY → 循环 `net_poll` + `net_process_messages`;消费 `SYS_EVT_NET_RECONFIG` → 立即重建网络(新 IP 免重启生效,各 TCP 服务据代次号重建监听) - defaultTask:`f_mount` forced(无卷自动 `f_mkfs` FAT32;挂载后卷健康探针:小写两击确认,FAT 区陈旧/擦除时自动重格式化自愈,`/config.ini` 回落工厂默认——2026-09-20 /log 写失败修复)→ 网络配置加载(`net_cfg_load`;旧档迁移已于 2026-10-10 删除,见「模块约定」网口配置条)→ STORAGE_READY → LED 闪烁;同时是 syslog(`/log/run.log`·`selftest.log`,定长 64B 记录)的**唯一写者**与**配置引擎属主**(`cfg_file` 静态态单任务收敛于此:UI 保存请求执行 `net_cfg_save_run` + 2s `cfg_file_poll`,经 `SYS_EVT_CFG_SAVE` 事件唤醒;netTask/uiTask 不得直接触碰配置引擎,见 P1-3) -- adcTask(**B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费;v2.8 节拍源 TIM3 + STM32 侧 DMA,2026-10-10**):**纯消费者**——每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **消费一帧 STM32 ADC1 DMA 双缓冲**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM3 更新事件硬件提供**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM3 分支内调 `tpafe5160_start_conv_irq()`;同一更新事件经 **TRGO** 触发 ADC1 一轮 9 通道转换 → 两片转换器同源对齐);**消费唤醒与该节拍同源**——同一分支再调 `adc_tick_irq_notify()` 置位 `ADC_WAKE_FLAG`,任务用 `osThreadFlagsWait(..., osWaitForever)` 等唤醒(不再 1 ms 轮询;实测 `idle` 由 1~649 降到 **0**)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`——它内含"消费者就绪"门(TIM3 在 `app_main_init` 就启动,比调度器早 ≈100 ms)。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,512 words / **AboveNormal**),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10、`App/algo/README.md` -- **采集与算法层(L1/L2)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 扫描);L2 相量层待建。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md` +- adcTask(**B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费;v2.8 节拍源 TIM3 + STM32 侧 DMA,2026-10-10**):**纯消费者**——每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **消费一帧 STM32 ADC1 DMA 双缓冲**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM3 更新事件硬件提供**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM3 分支内调 `tpafe5160_start_conv_irq()`;同一更新事件经 **TRGO** 触发 ADC1 一轮 9 通道转换 → 两片转换器同源对齐);**消费唤醒与该节拍同源**——同一分支再调 `adc_tick_irq_notify()` 置位 `ADC_WAKE_FLAG`,任务用 `osThreadFlagsWait(..., osWaitForever)` 等唤醒(不再 1 ms 轮询;实测 `idle` 由 1~649 降到 **0**)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`——它内含"消费者就绪"门(TIM3 在 `app_main_init` 就启动,比调度器早 ≈100 ms)。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,**640 words** / **AboveNormal**——2026-10-11 L2 相量 + L1-6 有效性校验上链后静态最坏链 1080 B,512 words 余量跌破 1024 B 底线故抬栈),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10、`App/algo/README.md` +- **采集与算法层(L1/L2)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 DMA 消费)、`adc_health.c/h` = L1-6(采样有效性判定:静默/丢帧/单调性/错误增量,纯逻辑免桩单测)、`sample_ring.c/h` + `phasor.c/h` = L2(40 点采样环 + 全周傅氏 5 实例 + 双区交换,2026-10-11 台架闭环)。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md` - 全景 `docs/架构设计/项目架构总览.md`(六层分层 / 启动时序 / 存储·网络·UI 栈 / 目录组织) ## 硬件引脚 diff --git a/App/algo/README.md b/App/algo/README.md index 4158632a..e3a463fe 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -12,11 +12,13 @@ | 文件 | 层 | 作用(一句话) | 主要对外入口 | 覆盖规划项 | |---|---|---|---|---| | `adc_input.h` / `adc_input.c` | L1 | **统一语义取数层**:把两片转换器的原始码值按语义(`UA…IN`/温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓双缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 | +| `adc_health.h` / `adc_health.c` | L1 | **采样有效性判定层**:吃"计数快照"(帧序号/错误计数),产出静默/丢帧/单调性/错误增量故障位——判定与告警编排分离 | `adc_health_init()` `adc_health_check()` `adc_health_flags()` | L1-6 | | `sample_ring.h` / `sample_ring.c` | L2 输入 | **采样环缓冲**:按语义通道保存最近 2 个周波(40 点)采样值,供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 滑动窗供数) | | `phasor.h` / `phasor.c` | L2 | **全周傅氏相量计算**:一周波 20 点单频 DFT → 基波/谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 | -依赖关系(自下而上,无环):`adc_input.h`(纯枚举 + 声明)← `sample_ring` ← `phasor`。 -`sample_ring.c` / `phasor.c` **零平台依赖**(只用 `math.h` / `stddef.h` 与本目录头); +依赖关系(自下而上,无环):`adc_input.h`(纯枚举 + 声明)← `sample_ring` ← `phasor`; +`adc_health` **独立**(仅 `stdint.h`,不依赖本目录其它头)。 +`sample_ring.c` / `phasor.c` / `adc_health.c` **零平台依赖**(只用 `math.h` / `stddef.h` / `stdint.h` 与本目录头); `adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。 ## 2. 数据流(一拍 = TIM3 1 kHz 节拍) @@ -119,6 +121,33 @@ flowchart TD - 每拍 20 点**全部重算**(暴力法,无累积误差;迭代加减首尾项会累积)。相量刷新周期 = 1 ms。 - 相位(L2-4)**未常驻**结构体,需要时自行 `atan2f(fIm, fRe)`。 +### 3.4 `adc_health.c` / `adc_health.h` — L1-6 采样有效性判定 + +**解决的问题**:驱动侧只有"观测计数"(`tpafe5160_frame_seq/_frstdata_err`、 +`adc_input_stm32_seq/_errs/_gaps/_hw_errs`),**没有判定**——芯片静默、消费者超拍丢帧、 +计数器被改写这些故障此前只能靠人读心跳日志发现。本层把判定收进纯逻辑, +告警编排留在消费者(`adc_task.c` 的 `adc_health_report()`)→ 判定可主机免桩单测。 + +| API | 干什么 | 注意 | +|---|---|---| +| `adc_health_init()` | 清全部状态(幂等) | 首拍 `check()` 只建基线、不判定 | +| `adc_health_check(&in, &out)` | 吃计数快照做一次判定(每拍调,常数次整数运算) | 差值法 `(int32_t)(now-prev)`,tick/计数回绕安全 | +| `adc_health_flags()` | 电平故障位:`TPAFE_STALL` / `STM32_STALL`(≥100 ms 无前进,可自愈)/ `SEQ_BACKWARD`(锁存) | 为 L5-4 闭锁预留 | +| `adc_health_sticky()` | 锁存位(含 `FRSTDATA` / `STM32_ERR` / `HW_ERR` 增量痕迹) | 自愈后仍留痕 | +| `adc_health_tpafe_gaps()` | TPAFE 累计丢帧点数(序号前进 >1 的超出部分) | 与 STM32 侧 `adc_input_stm32_gaps()` 对称 | + +**要点** + +- **三类语义严格分开**:电平位(stall,可自愈)/ 锁存位(出现过必留痕)/ 边沿事件位 + (仅当拍有效)。`ADC_HE_FRSTDATA/STM32_ERR/HW_ERR` 与同名 `ADC_HF_*` **编号对齐**, + 消费者直接用事件掩码对"已留痕位图"做每类一次 `SYSLOG_SELFTEST` 的编排。 +- **gap 不告警**:消费者超拍(心跳日志 ~2 ms)是已知现象,`adc1_gap`/`tgap` 每 10 s 心跳 + 可见;若按事件告警会把真故障淹掉(台架实测开机 `adc1_gap≈24`、稳态每 10 s +2)。 +- **静默 ≠ 丢帧**:d=0 才是静默候选;d>1 只说明消费者没跟上,生产者还活着——两者 + 处置完全不同,判据不许混。 +- 告警策略(在 `adc_task.c`,不在本层):严重事件/电平上升沿 `DBG_ERROR` + + `SYSLOG_SELFTEST`(每类每次开机一次,防刷屏写满 32KB selftest.log);下降沿 `DBG_INFO`。 + ## 4. 关键约定 - **语义映射唯一权威口径**:`docs/硬件平台/ADC通道映射说明.md`。取数一律经 `adc_input_raw()`, @@ -138,6 +167,7 @@ flowchart TD | `sample_ring.c` `s_ring[9][40]` float | **1440 B** | | `phasor.c` `s_zone[2][5]` `phasor_t` | 160 B | | `adc_input.c` `s_stm32_raw[16]` + `s_tpafe_raw[8]` | 48 B | +| `adc_health.c` 内部状态(9 × uint32 + uint8) | ≈ 40 B | | 运行期栈:`phasor_run()` 的 `win[20]` | 80 B/帧 | 改容量(`SAMPLE_RING_CYCLES` / 实例数 / `ADC_SEM_COUNT`)前后各跑一次 @@ -145,7 +175,7 @@ flowchart TD ## 6. 构建与测试 -- **挂载**:三个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `` 内。 +- **挂载**:四个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `` 内。 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)。 - **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码: @@ -153,10 +183,12 @@ flowchart TD |---|---|---| | 相量层(57 用例:系数表逐项复核 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换) | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` | | ADC 输入层(逐 Rank 注入互异码值钉死映射 + HAL 启动失败/超时分支) | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` | + | 有效性校验(31 用例:100 ms 静默门限两侧 / 丢帧边沿 / 倒退锁存 / 错误增量 / tick 回绕 / NULL 守卫 / init 幂等) | `& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py` | `adc-health-unit-test` | 改本目录任一 `.c/.h`,提交时对应钩子自动跑;详见 `docs/测试说明/相量层单元测试说明.md`。 - **板端**:`@build` 0 错误 0 警告 → `@test` 看 `[ADC_TASK]` 首拍/心跳日志(`phasor(...)` 行、 - `ring` / `skip` 计数、`adc1_rc` / `adc1_err`)。 + `ring` / `skip` 计数、`adc1_rc` / `adc1_err`、`tgap`);健康判据 = 零 `[ERR]` + (`adc_health_report` 的告警全部以 `[ERR]` 开头 → 误报会被闭环直接抓住)。 ## 7. 新增一个算法模块的固定动作 diff --git a/App/algo/adc_health.c b/App/algo/adc_health.c new file mode 100644 index 00000000..77cccc38 --- /dev/null +++ b/App/algo/adc_health.c @@ -0,0 +1,200 @@ +/* + * 模块名称:ADC 采样有效性校验(L1 采集与预处理,规划项 L1-6) + * 模块功能:见 adc_health.h 文件头。本文件为**纯逻辑实现**:输入计数快照、 + * 输出故障位/事件位,零平台依赖(仅 stdint.h)→ 主机侧免桩单测。 + * 适用平台:STM32F407ZGTx + TPAFE5160 + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验 + */ + +#include "adc_health.h" + +/* ===================== 内部状态 ===================== */ + +/* 基线是否已建立:首次 check() 只记基线、不判定(开机首拍无参照系) */ +static uint8_t s_primed; + +/* 上次看到的计数(差值法判"有无前进",回绕安全) */ +static uint32_t s_tpafe_seq; +static uint32_t s_tpafe_frst; +static uint32_t s_stm32_seq; +static uint32_t s_stm32_errs; +static uint32_t s_stm32_gaps; +static uint32_t s_stm32_hw; + +/* 两侧"最后一次看到序号前进"的时刻(判静默) */ +static uint32_t s_tpafe_adv_ms; +static uint32_t s_stm32_adv_ms; + +/* 故障位 */ +static uint32_t s_flags; /* 电平位(stall 可自愈;backward 锁存在此) */ +static uint32_t s_sticky; /* 锁存位(含电平位历史并集) */ +static uint32_t s_tpafe_gaps; + +/* ===================== 对外接口 ===================== */ + +/* + * 函数功能:采样有效性校验初始化(幂等) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无(内核启动前可调) + * 函数说明:清全部内部状态;判定从下一次 check() 的首拍基线重新开始。 + */ +void adc_health_init(void) { + s_primed = 0u; + s_tpafe_seq = 0u; + s_tpafe_frst = 0u; + s_stm32_seq = 0u; + s_stm32_errs = 0u; + s_stm32_gaps = 0u; + s_stm32_hw = 0u; + s_tpafe_adv_ms = 0u; + s_stm32_adv_ms = 0u; + s_flags = 0u; + s_sticky = 0u; + s_tpafe_gaps = 0u; +} + +/* + * 函数功能:按计数快照做一次有效性判定 + * 入口参数:in - 计数快照;NULL 则本次跳过(out 不动) + * out - 判定结果;NULL 则仅更新内部状态 + * 返 回 值:无 + * 限定条件:仅任务上下文(与 adc_input_poll() 同节奏,每拍一次) + * 函数说明:判定顺序 = 建基线 → 序号差值(倒退/丢帧/前进)→ 静默时长 → + * 错误计数增量。events 仅本次有效;flags/sticky 为持续状态。 + */ +void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out) { + uint32_t events = 0u; + + if (in == 0) { + return; + } + + /* 1) 首拍基线:只记录,不判定(开机时序号已非 0,无"上一拍"参照) */ + if (s_primed == 0u) { + s_primed = 1u; + s_tpafe_seq = in->tpafe_seq; + s_tpafe_frst = in->tpafe_frst_err; + s_stm32_seq = in->stm32_seq; + s_stm32_errs = in->stm32_errs; + s_stm32_gaps = in->stm32_gaps; + s_stm32_hw = in->stm32_hw_errs; + s_tpafe_adv_ms = in->now_ms; + s_stm32_adv_ms = in->now_ms; + } else { + uint32_t d; /* 序号差值(两侧共用,cppcheck:作用域收在本块) */ + + /* 2) TPAFE 序号差值:倒退 = 单调性破坏(锁存);>1 = 丢帧(事件 + 计数) */ + d = in->tpafe_seq - s_tpafe_seq; + if ((int32_t)d < 0) { + s_flags |= ADC_HF_SEQ_BACKWARD; + s_sticky |= ADC_HF_SEQ_BACKWARD; + s_tpafe_seq = in->tpafe_seq; /* 重新对齐,避免持续误判 */ + s_tpafe_adv_ms = in->now_ms; + } else { + if (d > 1u) { + s_tpafe_gaps += (d - 1u); + events |= ADC_HE_TPAFE_GAP; + } + if (d >= 1u) { + s_tpafe_seq = in->tpafe_seq; + s_tpafe_adv_ms = in->now_ms; + } + } + + /* 3) STM32 序号差值:同一口径(gap 计数在 adc_input 侧另有细账, + * 这里只取"前进/倒退/静默"三态,避免两处判据打架) */ + d = in->stm32_seq - s_stm32_seq; + if ((int32_t)d < 0) { + s_flags |= ADC_HF_SEQ_BACKWARD; + s_sticky |= ADC_HF_SEQ_BACKWARD; + s_stm32_seq = in->stm32_seq; + s_stm32_adv_ms = in->now_ms; + } else if (d >= 1u) { + s_stm32_seq = in->stm32_seq; + s_stm32_adv_ms = in->now_ms; + } + + /* 4) 静默判定(电平位,可自愈):距最后一次前进 >= 门限 */ + if ((uint32_t)(int32_t)(in->now_ms - s_tpafe_adv_ms) >= ADC_HEALTH_STALL_MS) { + s_flags |= ADC_HF_TPAFE_STALL; + s_sticky |= ADC_HF_TPAFE_STALL; + } else { + s_flags &= ~ADC_HF_TPAFE_STALL; + } + if ((uint32_t)(int32_t)(in->now_ms - s_stm32_adv_ms) >= ADC_HEALTH_STALL_MS) { + s_flags |= ADC_HF_STM32_STALL; + s_sticky |= ADC_HF_STM32_STALL; + } else { + s_flags &= ~ADC_HF_STM32_STALL; + } + + /* 5) 错误计数增量(事件 + 锁存):三类计数正常应恒为 0 增量 */ + if (in->tpafe_frst_err != s_tpafe_frst) { + events |= ADC_HE_FRSTDATA; + s_sticky |= ADC_HF_FRSTDATA; + s_tpafe_frst = in->tpafe_frst_err; + } + if (in->stm32_errs != s_stm32_errs) { + events |= ADC_HE_STM32_ERR; + s_sticky |= ADC_HF_STM32_ERR; + s_stm32_errs = in->stm32_errs; + } + if (in->stm32_gaps != s_stm32_gaps) { + events |= ADC_HE_STM32_GAP; + s_stm32_gaps = in->stm32_gaps; + } + if (in->stm32_hw_errs != s_stm32_hw) { + events |= ADC_HE_HW_ERR; + s_sticky |= ADC_HF_HW_ERR; + s_stm32_hw = in->stm32_hw_errs; + } + } + + if (out != 0) { + out->flags = s_flags; + out->events = events; + out->sticky = s_sticky; + out->tpafe_gaps = s_tpafe_gaps; + out->tpafe_stall_ms = + ((s_flags & ADC_HF_TPAFE_STALL) != 0u) + ? (uint32_t)(int32_t)(in->now_ms - s_tpafe_adv_ms) + : 0u; + } +} + +/* + * 函数功能:读当前电平故障位 + * 入口参数:无 + * 返 回 值:见 adc_health.h ADC_HF_TPAFE_STALL/STM32_STALL/SEQ_BACKWARD + * 限定条件:无 + * 函数说明:与 check() 出参 flags 同源。 + */ +uint32_t adc_health_flags(void) { + return s_flags; +} + +/* + * 函数功能:读锁存故障位 + * 入口参数:无 + * 返 回 值:电平位 + FRSTDATA/STM32_ERR/HW_ERR 的并集 + * 限定条件:无 + * 函数说明:出现过即留痕,直到 adc_health_init() 才清。 + */ +uint32_t adc_health_sticky(void) { + return s_sticky; +} + +/* + * 函数功能:读 TPAFE 累计丢帧点数 + * 入口参数:无 + * 返 回 值:序号前进 >1 的超出部分累计 + * 限定条件:无 + * 函数说明:与 STM32 侧 adc_input_stm32_gaps() 对称的 TPAFE 侧细账。 + */ +uint32_t adc_health_tpafe_gaps(void) { + return s_tpafe_gaps; +} diff --git a/App/algo/adc_health.h b/App/algo/adc_health.h new file mode 100644 index 00000000..2e24df6f --- /dev/null +++ b/App/algo/adc_health.h @@ -0,0 +1,126 @@ +/* + * 模块名称:ADC 采样有效性校验(L1 采集与预处理,规划项 L1-6) + * 模块功能:对两片转换器的"节拍是否还在喂数据"做**判定层**(观测计数由 + * `adc_input` / `tpafe5160` 驱动提供,本模块只做判定与告警位): + * ① 静默(stall):帧序号连续 >= ADC_HEALTH_STALL_MS 无前进; + * ② 丢帧(gap):帧序号一次前进 >1(消费者超拍,中间帧被覆盖); + * ③ 单调性破坏:帧序号倒退(计数器被改写 / 驱动 bug,锁存); + * ④ 错误计数增量:FRSTDATA 跳变 / HAL ADC·DMA 错误 / STM32 停摆计数。 + * ⚠ 本层**纯逻辑**:不碰寄存器、不取数、不打日志——输入是调用方 + * 采集的计数快照,输出是故障位;日志与自检上报由消费者 + * (`App/task/adc_task.c`)按位执行 → 主机侧可单测(L0.5,A-4)。 + * 适用平台:STM32F407ZGTx + TPAFE5160 + * 作者:王建锋 + * 创建日期:2026-10-11 + * 修改记录: + * v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验(静默/丢帧/单调性/错误增量) + */ + +#ifndef ADC_HEALTH_H +#define ADC_HEALTH_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* 静默判定门限(ms):帧序号连续无前进达到该时长即判静默。 + * 名义节拍 1 kHz → 正常每拍 +1;100 拍无前进不可能是抖动,只可能是 + * 节拍源/转换器/EXTI 真停(消费者超拍只会造成 gap=d>1,不会 d=0)。 */ +#define ADC_HEALTH_STALL_MS 100U + +/* 电平故障位(adc_health_flags(),当前活跃;stall 可自愈清除,backward 锁存) + * ——供后续 L5-4「自检失败闭锁」直接消费 */ +#define ADC_HF_TPAFE_STALL (1uL << 0) /* TPAFE 帧序号静默 */ +#define ADC_HF_STM32_STALL (1uL << 1) /* STM32 DMA 完成计数静默 */ +#define ADC_HF_SEQ_BACKWARD (1uL << 2) /* 帧序号倒退(锁存,不自愈) */ + +/* 锁存故障位(adc_health_sticky(),出现过即置位、直到 init 才清; + * 与电平位共用高位编号,gap 类"常态性超拍"不入锁存) */ +#define ADC_HF_FRSTDATA (1uL << 3) /* FRSTDATA 跳变计数有增量(锁存) */ +#define ADC_HF_STM32_ERR (1uL << 4) /* STM32 停摆计数有增量(锁存) */ +#define ADC_HF_HW_ERR (1uL << 5) /* HAL ADC/DMA 错误回调有增量(锁存) */ + +/* 边沿事件位(adc_health_check() 出参 events,仅本次调用有效)。 + * ⚠ FRSTDATA/STM32_ERR/HW_ERR 三个事件位与同名锁存位**编号一致**—— + * 消费者可直接用 events 掩码对 sticky/logged 位图做"每类留痕一次"的编排 */ +#define ADC_HE_TPAFE_GAP (1uL << 6) /* 本次 TPAFE 序号前进 >1 */ +#define ADC_HE_STM32_GAP (1uL << 7) /* 本次 STM32 gap 计数有增量 */ +#define ADC_HE_STM32_ERR (1uL << 4) /* 本次 STM32 停摆计数有增量 */ +#define ADC_HE_FRSTDATA (1uL << 3) /* 本次 FRSTDATA 计数有增量 */ +#define ADC_HE_HW_ERR (1uL << 5) /* 本次 HAL 错误计数有增量 */ + +/* 计数快照(由调用方采集,本模块不触碰任何驱动/外设) */ +typedef struct { + uint32_t tpafe_seq; /* tpafe5160_frame_seq() */ + uint32_t tpafe_frst_err; /* tpafe5160_frstdata_err()(驱动为 uint16,此处放宽) */ + uint32_t stm32_seq; /* adc_input_stm32_seq() */ + uint32_t stm32_errs; /* adc_input_stm32_errs() */ + uint32_t stm32_gaps; /* adc_input_stm32_gaps() */ + uint32_t stm32_hw_errs; /* adc_input_stm32_hw_errs() */ + uint32_t now_ms; /* osKernelGetTickCount()(tick 回绕由差值法吸收) */ +} adc_health_in_t; + +/* 判定结果快照 */ +typedef struct { + uint32_t flags; /* 电平故障位 ADC_HF_TPAFE/STM32_STALL/SEQ_BACKWARD */ + uint32_t events; /* 本次边沿事件位 ADC_HE_xxx */ + uint32_t sticky; /* 锁存故障位(含电平位的历史并集) */ + uint32_t tpafe_gaps; /* TPAFE 累计丢帧点数(序号前进 >1 的超出部分之和) */ + uint32_t tpafe_stall_ms; /* TPAFE 当前静默时长(0=正常) */ +} adc_health_status_t; + +/* + * 函数功能:采样有效性校验初始化(幂等,内核启动前可调) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:无 + * 函数说明:清全部状态;首次 adc_health_check() 只建基线、不判定。 + */ +void adc_health_init(void); + +/* + * 函数功能:按计数快照做一次有效性判定(每拍调用,开销为常数次整数运算) + * 入口参数:in - 计数快照(NULL 则本次跳过,out 不动) + * out - 判定结果(NULL 则仅更新内部状态) + * 返 回 值:无 + * 限定条件:仅任务上下文(与 adc_input_poll() 同节奏调用) + * 函数说明:首次调用建立基线(不产生事件/故障)。序号差值按 + * `(int32_t)(now - prev)` 口径计算,tick/计数回绕均安全。 + */ +void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out); + +/* + * 函数功能:读当前电平故障位 + * 入口参数:无 + * 返 回 值:ADC_HF_TPAFE_STALL / ADC_HF_STM32_STALL / ADC_HF_SEQ_BACKWARD 组合 + * 限定条件:无 + * 函数说明:供 L5-4 闭锁判定与 UI;与 check() 出参 flags 同源。 + */ +uint32_t adc_health_flags(void); + +/* + * 函数功能:读锁存故障位(出现过即置位,直到 init 才清) + * 入口参数:无 + * 返 回 值:电平位 + ADC_HF_FRSTDATA/STM32_ERR/HW_ERR 的并集 + * 限定条件:无 + * 函数说明:stall 自愈后 sticky 仍保留该次故障痕迹("曾经停摆过"必须留痕)。 + */ +uint32_t adc_health_sticky(void); + +/* + * 函数功能:读 TPAFE 累计丢帧点数 + * 入口参数:无 + * 返 回 值:序号前进 >1 的超出部分累计(与 STM32 侧 adc_input_stm32_gaps() 对称) + * 限定条件:无 + * 函数说明:消费者超拍(单拍耗时 > 1 ms)所致;非零本身不是闭锁条件, + * 但持续增长说明相量窗出现时间空洞,须排查耗时。 + */ +uint32_t adc_health_tpafe_gaps(void); + +#ifdef __cplusplus +} +#endif + +#endif /* ADC_HEALTH_H */ diff --git a/App/task/adc_task.c b/App/task/adc_task.c index 535a6d18..c6211766 100644 --- a/App/task/adc_task.c +++ b/App/task/adc_task.c @@ -70,6 +70,13 @@ * 两片转换器由同一硬件事件驱动);STM32 侧改 DMA 双缓冲 * 消费,adc_input_poll() 不再忙等;心跳日志增 * adc1_seq / adc1_hw(adc1_err 语义改为"本拍无新帧") + * v2.9 2026-10-11 王建锋 L1-6 采样有效性校验:每拍调 adc_health_check() + * (App/algo/adc_health.c,纯逻辑判定:静默/丢帧/ + * 单调性/错误计数增量)——严重事件与故障电平上升沿 + * 打 DBG_ERROR + 每类每次开机一次 SYSLOG_SELFTEST; + * 恢复打 DBG_INFO;心跳日志增 tgap(TPAFE 侧丢帧, + * 与 adc1_gap 对称)。判定不在本文件(A-4:算法与 + * I/O 分离),本文件只做"消费计数 → 告警"的编排 */ #include "adc_task.h" @@ -77,9 +84,11 @@ #include "adc_input.h" /* L1 采集层:adc_input_poll()/adc_input_raw()(按语义取数) */ #include "sample_ring.h" /* L2 输入:sample_ring_push_frame()(采样环缓冲) */ #include "phasor.h" /* L2 相量层:phasor_run()/phasor_get()(全周傅氏) */ +#include "adc_health.h" /* L1-6:采样有效性校验(静默/丢帧/单调性/错误增量) */ #include "tpafe5160.h" #include "task_watchdog.h" #include "console_log.h" +#include "sys_log.h" /* SYSLOG_SELFTEST:采样故障留痕(仅故障才写) */ #include "task_prof.h" /* P2 常开探针:单拍耗时(Task #32) */ #define DBG_TAG "[ADC_TASK]" @@ -97,6 +106,13 @@ static uint32_t s_frames_idle; /* 因通道尚未首次成功而**整拍跳过入环**的次数(不向相量窗注入 0 点) */ static uint32_t s_frames_skip; +/* L1-6 告警编排状态: + * s_health_prev - 上次电平故障位(判上升/下降沿,避免每拍重复告警) + * s_health_logged- 已写 SYSLOG_SELFTEST 的位(每类每次开机只留痕一次, + * 防持续故障刷屏把 32KB selftest.log 写满) */ +static uint32_t s_health_prev; +static uint32_t s_health_logged; + /* adcTask 的内核句柄,定义在 Src/freertos.c(引用方式同 App/task/default_task.c) */ extern osThreadId_t adcTaskHandle; @@ -155,6 +171,48 @@ static void log_phasors(const char *tag) { } } +/* + * 函数功能:L1-6 判定结果的告警编排(边沿 → 日志 + 自检留痕) + * 入口参数:hs - adc_health_check() 判定结果快照 + * 返 回 值:无 + * 限定条件:仅任务上下文;日志仍受写者就绪门控(SYSLOG 不受——内核前有 + * s_preboot 缓冲,故障痕迹不因开机窗口丢失) + * 函数说明:策略分级(判定本身不在本文件,见 App/algo/adc_health.c)—— + * ① 严重事件(FRSTDATA / HAL 错误 / STM32 停摆计数增量):这三类 + * 计数增量恒应为 0,出现即故障 → 每次 DBG_ERROR; + * ② 电平故障上升沿(静默/序号倒退)→ DBG_ERROR;下降沿(静默自愈) + * → DBG_INFO("恢复"必须可见,否则只看到一次错误无从判断是否还在); + * ③ SYSLOG_SELFTEST 每类每次开机**只留痕一次**(持续故障刷屏会把 + * 32KB selftest.log 写满,也淹没其它模块的故障记录); + * ④ gap(丢帧)**不告警**:消费者超拍是已知现象(心跳日志 + * adc1_gap / tgap 即观测面),告警会淹没真故障。 + */ +static void adc_health_report(const adc_health_status_t *hs) { + uint32_t serious = hs->events & (ADC_HE_FRSTDATA | ADC_HE_STM32_ERR | ADC_HE_HW_ERR); + uint32_t rise = hs->flags & ~s_health_prev; + uint32_t fall = s_health_prev & ~hs->flags; + + if ((serious != 0u) && (dbg_console_writer_ready() != 0u)) { + DBG_ERROR("sampling fault: events=0x%lx sticky=0x%lx", + (unsigned long)hs->events, (unsigned long)hs->sticky); + } + if ((rise != 0u) && (dbg_console_writer_ready() != 0u)) { + DBG_ERROR("health: flags=0x%lx stall_ms=%lu tgap=%lu", + (unsigned long)hs->flags, + (unsigned long)hs->tpafe_stall_ms, + (unsigned long)hs->tpafe_gaps); + } + if (((serious | rise) & ~s_health_logged) != 0u) { + (void)syslog_post(SYSLOG_SELFTEST, "adc sampling fault"); + s_health_logged |= (serious | rise); + } + if ((fall != 0u) && (dbg_console_writer_ready() != 0u)) { + DBG_INFO("health: recovered flags=0x%lx sticky=0x%lx", + (unsigned long)hs->flags, (unsigned long)hs->sticky); + } + s_health_prev = hs->flags; +} + /* * 函数功能:ADC 任务初始化 * 入口参数:无 @@ -166,6 +224,7 @@ int adc_task_init(void) { s_frames_consumed = 0u; s_frames_idle = 0u; s_frames_skip = 0u; + adc_health_init(); DBG_INFO("adc_task_init"); return 0; } @@ -195,7 +254,8 @@ void adc_task_func(void *arg) { s_consumer_ready = 1u; for (;;) { - int r; + int r; + uint32_t now = osKernelGetTickCount(); /* 本拍时基:健康判定 + 心跳共用一份 */ /* P2 探针:单拍起点。名义节拍 1 ms = 168000 周期;若实测**持续**超过, * 本任务(v2.7 起为 osPriorityAboveNormal)会持续抢占 uiTask @@ -259,6 +319,24 @@ void adc_task_func(void *arg) { } } + /* 2b) L1-6 采样有效性校验(每拍,常数次整数运算):把驱动侧观测计数交给 + * 纯逻辑判定层,本任务只做告警编排。⚠ 计数一律取**本拍 poll 之后** + * 的值——与刚消费的帧同一时点,避免"下一拍的增量被提前看到"。 */ + { + adc_health_in_t h_in; + adc_health_status_t h_st; + + h_in.tpafe_seq = tpafe5160_frame_seq(); + h_in.tpafe_frst_err = (uint32_t)tpafe5160_frstdata_err(); + h_in.stm32_seq = adc_input_stm32_seq(); + h_in.stm32_errs = adc_input_stm32_errs(); + h_in.stm32_gaps = adc_input_stm32_gaps(); + h_in.stm32_hw_errs = adc_input_stm32_hw_errs(); + h_in.now_ms = now; + adc_health_check(&h_in, &h_st); + adc_health_report(&h_st); + } + /* 3) 首帧码值上报:**写者就绪后**才落日志。写者未注册时 con_append 走 * 无锁直发路径,与 defaultTask 的存储初始化日志会字节级交错 * (2026-10-10 台架实测);此时挂起上报,就绪后补报最近一帧。 @@ -303,8 +381,6 @@ void adc_task_func(void *arg) { /* 6) 周期心跳日志:帧统计 + 驱动侧 FRSTDATA 错误计数 + L2 相量。 * 写者就绪前抑制(同 3) 的交错理由);到点无论是否落日志都推进下个上报点 */ { - uint32_t now = osKernelGetTickCount(); - if ((int32_t)(now - report_at) >= 0) { if (dbg_console_writer_ready() != 0u) { /* STM32 侧观测量(v2.0 DMA 版),四类各有独立成因: @@ -314,9 +390,11 @@ void adc_task_func(void *arg) { * adc1_err = 停摆:连续 >=2 拍无新帧 → 节拍源/DMA 真停 * adc1_hw = HAL ADC/DMA 错误回调 * 判读:adc1_seq 停增而 seq 仍涨 → STM32 侧停摆; - * adc1_gap 增长 → 本任务耗时超拍(看 adcm/adcn) */ + * adc1_gap 增长 → 本任务耗时超拍(看 adcm/adcn); + * tgap = TPAFE 侧同类丢帧计数(L1-6,与 adc1_gap 对称) */ DBG_INFO("frames=%lu idle=%lu skip=%lu seq=%lu frst_err=%u ring=%lu" - " adc1_rc=%d adc1_err=%lu adc1_seq=%lu adc1_gap=%lu adc1_hw=%lu", + " adc1_rc=%d adc1_err=%lu adc1_seq=%lu adc1_gap=%lu adc1_hw=%lu" + " tgap=%lu", (unsigned long)s_frames_consumed, (unsigned long)s_frames_idle, (unsigned long)s_frames_skip, @@ -327,7 +405,8 @@ void adc_task_func(void *arg) { (unsigned long)adc_input_stm32_errs(), (unsigned long)adc_input_stm32_seq(), (unsigned long)adc_input_stm32_gaps(), - (unsigned long)adc_input_stm32_hw_errs()); + (unsigned long)adc_input_stm32_hw_errs(), + (unsigned long)adc_health_tpafe_gaps()); log_phasors("hb"); } diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 4bef565e..15c97bd0 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -957,6 +957,11 @@ 1 ..\App\algo\phasor.c
+ + adc_health.c + 1 + ..\App\algo\adc_health.c +
diff --git a/README.md b/README.md index 4c094eca..1bd71484 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ main.c | testTask | 2048 words | BelowNormal | 硬件测试入口 | | uiTask | 4096 words | BelowNormal | LVGL UI 界面 | | timeSyncTask | 512 words | Normal | 墙钟时间同步 | -| adcTask | 512 words | AboveNormal | TPAFE5160 采样(TIM3 1kHz 事件驱动 + 原子快照 + STM32 ADC DMA) | +| adcTask | 640 words | AboveNormal | TPAFE5160 采样(TIM3 1kHz 事件驱动 + 原子快照 + STM32 ADC DMA) | > 上表与 `Src/freertos.c` 的 `osThreadAttr_t` 一致(2026-09-25 校准);改动栈大小前先读 diff --git a/Src/freertos.c b/Src/freertos.c index b0d0d0d1..37f0f4ca 100644 --- a/Src/freertos.c +++ b/Src/freertos.c @@ -113,10 +113,12 @@ static int hook_ptr_ok(const void *p) * 靠审计 WARN 提示),建监听 → net_bind → CH395F → 日志尾。2026-10-10 整改后 * 静态最坏链 1024 B(整改前 1736 B)、实测峰值 616 B;⚠ 余量 1024 B **正好** * 等于 ISR 底线 → 本任务链上再加任何调用(或新日志行)都必须先抬栈。 - * adcTask 512 words:本区下方手工定义(同上,不在 `.ioc` 里)。B1(2026-10-10) - * ADC 采集消费者:链上仅 tpafe5160_start_conv_irq + tpafe5160_snapshot(临界区 - * 原子拷贝)+ task_wd_beat + 周期 DBG_INFO(每 10s 一条)。栈值以 - * `scripts/stack_report.py` 实测最坏链为准,改动前后各跑一次。 + * adcTask 640 words:本区下方手工定义(同上,不在 `.ioc` 里)。B1(2026-10-10) + * ADC 采集消费者;L2 相量(2026-10-11)+ L1-6 有效性校验(同日)后链上为 + * adc_input_poll + sample_ring/phasor + adc_health_report(DBG_ERROR + + * syslog_post 回显尾)。静态最坏链 1080 B,512 words 时余 968 B < 1024 B + * ISR 底线 → 抬至 640 words(2.5 KB,堆余量 min_ever 7152→≈6640 B,可承受)。 + * 栈值以 `scripts/stack_report.py` 实测最坏链为准,改动前后各跑一次。 */ /* 存储测试变量(g_storage_stats / s_perf_buf)已迁至 test/board/storage_test_task.c */ osThreadId_t timeSyncTaskHandle; @@ -135,7 +137,7 @@ const osThreadAttr_t timeSyncTask_attributes = { osThreadId_t adcTaskHandle; const osThreadAttr_t adcTask_attributes = { .name = "adcTask", - .stack_size = 512 * 4, + .stack_size = 640 * 4, .priority = (osPriority_t) osPriorityAboveNormal, }; /* USER CODE END Variables */ diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 450eb256..97d0d1c8 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 273,860 B (267.44 KB) | 1,048,576 B (1024 KB) | 26.1% | -| RW_IRAM1 | 91,488 B (89.34 KB) | 131,072 B (128 KB) | 69.8% | +| ER_IROM1 | 274,728 B (268.29 KB) | 1,048,576 B (1024 KB) | 26.2% | +| RW_IRAM1 | 91,544 B (89.40 KB) | 131,072 B (128 KB) | 69.8% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **274,684 B (268.2 KB)** | 1,048,576 B (1024 KB) | **26.2%** | +| **Flash (ROM 合计)** | **275,608 B (269.1 KB)** | 1,048,576 B (1024 KB) | **26.3%** | 各区的容量与常量来源: @@ -95,6 +95,9 @@ > 同日**增列 `adcTask`**(B1:ADC 采集消费者,512 words,静态最坏链 504 B / 余 75%)。 > **2026-10-11 L2 相量层接入后复测**:adcTask 静态 **832 B** / 台架实测峰值 **928 B / 2048(余 54%)**, > 增量为压环 + `phasor_run()`(`win[20]` 80 B)+ `log_phasors` 日志链;仍高于 1024 B 底线,7 任务全 PASS。 +> **同日 L1-6 有效性校验后抬栈 512→640 words**:`adc_health_report`→`syslog_post` 控制台回显尾把静态 +> 最坏链推到 **1080 B**(512 words 时余 968 B < 1024 B ISR 底线)→ 抬至 2.5 KB,余 **1480 B / 57%**; +> 堆 `min_ever` 7152 → **6640 B**(-512 B,符合预算)。 > 此前 2026-10-04 的 V5 30 min 重采曾把 defaultTask 推到 **3664B/4096(余量 432B ≈ 10%,登记 S-1)**; > 2026-10-10 的栈整改(**未抬栈**,见 §4 阶段 0 的"2026-10-10 复测")把它压回 **2152B/4096**,S-1 已销项。 @@ -106,7 +109,7 @@ | testTask | `2048*4` | 8KB | 测试套件(产品构建不创建;存储套件深链需要) | 未创建 / 静态 496 B | | defaultTask | `1024*4` | 4KB | FatFS 挂载/探针 + 配置引擎 + syslog + 栈水位探针 | 2152 B(余 47%)/ 静态 2776 B | | timeSyncTask | `512*4` | 2KB | TCP 时间同步 | 616 B(余 69%)/ 静态 **1024 B**(= ISR 底线,无再加深空间) | -| adcTask | `512*4` | 2KB | TPAFE5160 采样消费 + L2 相量(TIM3 1 kHz 节拍 + 快照 + 压环 + 全周傅氏,B1→L2 2026-10-11) | **928 / 2048**(余 1120 B / 54%,台架 `@test` 实测 2026-10-11);静态 832 B | +| adcTask | `640*4` | 2.5KB | TPAFE5160 采样消费 + L2 相量 + L1-6 有效性校验(TIM3 1 kHz 节拍,B1→L2→L1-6 2026-10-11) | **952 / 2560**(余 1608 B / 62%,台架 `@test` 实测 2026-10-11);静态 1080 B | | idle(内核) | `configMINIMAL_STACK_SIZE=128` | 0.5KB | 空闲 | 未观测(未取句柄) | | timer(内核) | `configTIMER_TASK_STACK_DEPTH=256` | 1KB | 软定时器 | 未观测 | diff --git a/docs/测试说明/boot_log_data/20261011_prod_adc_health_boot_log.txt b/docs/测试说明/boot_log_data/20261011_prod_adc_health_boot_log.txt new file mode 100644 index 00000000..cba05672 --- /dev/null +++ b/docs/测试说明/boot_log_data/20261011_prod_adc_health_boot_log.txt @@ -0,0 +1,116 @@ +[+0ms] [----/--/-- --:--:--][APP] boot: syslog init OK +[+0ms] [----/--/-- --:--:--][APP] fw build: Oct 11 2026 00:38:20 +[+10ms] [----/--/-- --:--:--][APP] reset cause: software (NVIC_SystemReset) (CSR=0x14000000) +[+21ms] PREVRUN seg=2 seq=1914766133 t=1240757 +[+21ms] TICKRUN uw=1240801 n=1240801 duw=44 +[+21ms] [----/--/-- --:--:--][RTC] sd2506_init: OK +[+31ms] [----/--/-- --:--:--][APP] SD2506 init: OK (ret=0) +[+31ms] [2026-10-10 17:13:03][CLK] sys_clock_init: RTC initial time 2026-10-10 17:13:03 +[+42ms] [2026-10-10 17:13:03][CLK] sys_clock_init: wall clock base = unix 1791652383, base_tick=137 +[+52ms] [2026-10-10 17:13:03][APP] RS485 init: OK (UART4, DE=PA15) +[+64ms] [2026-10-10 17:13:03][NAND] Resetting NAND... +[+94ms] [2026-10-10 17:13:03][NAND] Reading NAND ID... +[+94ms] [2026-10-10 17:13:03][NAND] NAND ID: MID=0xC8, DID=0x52 +[+94ms] [2026-10-10 17:13:03][NAND] Scanning bad blocks... +[+223ms] [2026-10-10 17:13:03][NAND] BBT scan: 0 bad blocks found +[+234ms] [2026-10-10 17:13:03][NAND] Enabling ECC... +[+234ms] [2026-10-10 17:13:03][NAND] Unlocking block protection... +[+244ms] [2026-10-10 17:13:03][NAND] Locating BBT pool... +[+244ms] [2026-10-10 17:13:03][NAND] Loading persisted BBT... +[+255ms] [2026-10-10 17:13:03][NAND] NAND init OK +[+255ms] [2026-10-10 17:13:03][APP] NAND init: OK +[+255ms] [2026-10-10 17:13:03][ADC] Init OK +[+266ms] [2026-10-10 17:13:03][APP] ADC init: OK +[+266ms] [2026-10-10 17:13:03][APP] ADC input layer init: OK +[+266ms] [2026-10-10 17:13:03][APP] phasor layer init: OK (inst=5) +[+276ms] [2026-10-10 17:13:03][APP] SD2506 ID: 2512153425482405 +[+287ms] [2026-10-10 17:13:03][APP] SD2506 time: 2026-10-10 17:13:04 (week 2) +[+287ms] [2026-10-10 17:13:03][APP] SD2506 temp: 28 C +[+297ms] [2026-10-10 17:13:03][APP] SD2506 battery: 3.11 V +[+297ms] [2026-10-10 17:13:03][APP] App init OK +[+308ms] [2026-10-10 17:13:03][NAND_FTL] Initializing NAND FTL... +[+339ms] [2026-10-10 17:13:03][NAND_FTL] Map resumed: 14094/97504 sectors used +[+339ms] [2026-10-10 17:13:03][NAND_FTL] Map head=117088 tail=99894 epoch=1 +[+349ms] [2026-10-10 17:13:03][NAND_FTL] NAND FTL ready (capacity: 97504 pages = 190 MB) +[+421ms] [2026-10-10 17:13:03] [FS] FATFS mounted +[+473ms] [2026-10-10 17:13:03][SYSLOG] runlog boot entry: power on +[+473ms] [2026-10-10 17:13:03][CFG] register: section 'network' (4 keys) +[+484ms] [2026-10-10 17:13:03][CFG] load: OK (523B, heap free 14728B) +[+494ms] [2026-10-10 17:13:03][NET_CFG] load: OK ip=192.168.1.100 +[+546ms] [2026-10-10 17:13:03][ADC_TASK] first: UA=11429 UB=1026 UC=817 UN=11453 IA=11452 IB=976 IC=1017 IN=865 T=940 +[+556ms] [2026-10-10 17:13:03][ADC_TASK] phasor(first) C01-IA-F1: Re=0 Im=0 rms=0 (x0.1) +[+566ms] [2026-10-10 17:13:03][ADC_TASK] phasor(first) C02-UA-F1: Re=3 Im=0 rms=3 (x0.1) +[+566ms] [2026-10-10 17:13:03][ADC_TASK] phasor(first) C03-UN-F1: Re=0 Im=0 rms=1 (x0.1) +[+577ms] [2026-10-10 17:13:03][ADC_TASK] phasor(first) C04-IA-F2: Re=3 Im=1 rms=3 (x0.1) +[+587ms] [2026-10-10 17:13:03][ADC_TASK] phasor(first) C05-IA-F5: Re=0 Im=-2 rms=2 (x0.1) +[+598ms] [2026-10-10 17:13:03][NET_TASK] === Network Init === +[+598ms] [2026-10-10 17:13:03][NET_TASK] net init: OK +[+598ms] [2026-10-10 17:13:03][NET_TASK] MAC: 3C:AB:72:53:2B:12 +[+608ms] [2026-10-10 17:13:03][NET_TASK] IP_INF: IP=192.168.1.100 GW=192.168.1.1 MASK=255.255.255.0 +[+608ms] MISS start snap=0x00000067 req=0x00000077 t=717 +[+619ms] [2026-10-10 17:13:03][WD] [WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms +[+629ms] [2026-10-10 17:13:03][UI_TASK] UI task: started, wait net ready +[+2541ms] [2026-10-10 17:13:05][NET_TASK] PHY: 100M FULL +[+2551ms] [2026-10-10 17:13:05][NET_TASK] netTask: started (single-socket mode) +[+2593ms] [2026-10-10 17:13:05] [FTP] waiting for connection... +[+2624ms] [2026-10-10 17:13:05][TIME_SYNC] time_sync: TCP listen on :8888 (sock=1) +[+2634ms] [2026-10-10 17:13:05][UI_TASK] UI task: net ready, init port +[+2645ms] [2026-10-10 17:13:05][UI_TASK] UI task: ui_port_init enter +[+2645ms] [2026-10-10 17:13:05][UI_PORT] theme: mono dark_bg=true applied +[+2655ms] [2026-10-10 17:13:05][UI_TASK] UI port init: OK +[+2655ms] [2026-10-10 17:13:05][UI_TASK] UI task: ui_menu_init enter +[+2666ms] [2026-10-10 17:13:05][UI_MENU] menu build start: total=39408 free=36572 big=36572 +[+2676ms] [2026-10-10 17:13:05][UI_MENU_VIEW] view init: root frame w=66 h=107 +[+2686ms] [2026-10-10 17:13:05][UI_MENU] menu build done: total=39292 free=35708 big=35696 used=3584 +[+2686ms] [2026-10-10 17:13:05][UI_TASK] UI menu init: OK +[+2697ms] [2026-10-10 17:13:05][UI_STANDBY] standby: ready (idle=60000ms) +[+2697ms] [2026-10-10 17:13:05][UI_TASK] UI standby: ready (idle=60s) +[+2749ms] [2026-10-10 17:13:05][UI_TASK] UI net sink: started (port 7003) +[+2749ms] MISS start snap=0x00000067 req=0x00000077 t=2857 +[+2760ms] [PROF] t=2861 uic=0 uip=0 uipn=0 uil=0 uiw=0 uis=0 uisn=0 adc=2455 adcm=1983 adcn=0 gl=0 +[+2770ms] [PROF2] t=2861 defd=11954 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+4922ms] [PROF] t=5025 uic=213 uip=19574 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=4618 adcm=1983 adcn=0 gl=0 +[+4933ms] [PROF2] t=5025 defd=11954 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+5335ms] [2026-10-10 17:13:08] [STACK] default peak_used=2152/4096 free_min=1944 (47%) +[+5346ms] [2026-10-10 17:13:08] [STACK] net peak_used=972/3840 free_min=2868 (74%) +[+5356ms] [2026-10-10 17:13:08] [STACK] ftp peak_used=740/4096 free_min=3356 (81%) +[+5356ms] [2026-10-10 17:13:08] [STACK] ui peak_used=4052/16384 free_min=12332 (75%) +[+5366ms] [2026-10-10 17:13:08] [STACK] timesync peak_used=684/2048 free_min=1364 (66%) +[+5377ms] [2026-10-10 17:13:08] [STACK] adc peak_used=952/2560 free_min=1608 (62%) +[+5377ms] [2026-10-10 17:13:08] [STACK] heap free=14576 min_ever=6640 +[+6932ms] [PROF] t=7038 uic=408 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=6631 adcm=1983 adcn=0 gl=0 +[+6943ms] [PROF2] t=7038 defd=11954 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+8948ms] [PROF] t=9054 uic=608 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=8647 adcm=1983 adcn=0 gl=0 +[+8958ms] [PROF2] t=9054 defd=11954 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+10304ms] [2026-10-10 17:13:13][ADC_TASK] frames=10002 idle=0 skip=23 seq=10006 frst_err=0 ring=9979 adc1_rc=0 adc1_err=0 adc1_seq=10004 adc1_gap=23 adc1_hw=0 tgap=1 +[+10315ms] [2026-10-10 17:13:13][ADC_TASK] phasor(hb) C01-IA-F1: Re=2 Im=0 rms=2 (x0.1) +[+10325ms] [2026-10-10 17:13:13][ADC_TASK] phasor(hb) C02-UA-F1: Re=1 Im=0 rms=1 (x0.1) +[+10335ms] [2026-10-10 17:13:13][ADC_TASK] phasor(hb) C03-UN-F1: Re=0 Im=0 rms=0 (x0.1) +[+10346ms] [2026-10-10 17:13:13][ADC_TASK] phasor(hb) C04-IA-F2: Re=0 Im=-1 rms=1 (x0.1) +[+10346ms] [2026-10-10 17:13:13][ADC_TASK] phasor(hb) C05-IA-F5: Re=-1 Im=0 rms=1 (x0.1) +[+10956ms] [PROF] t=11057 uic=802 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=10650 adcm=1983 adcn=0 gl=0 +[+10966ms] [PROF2] t=11057 defd=47667 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+12977ms] [PROF] t=13074 uic=1002 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=12667 adcm=1983 adcn=0 gl=0 +[+12977ms] [PROF2] t=13074 defd=47667 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+14986ms] [PROF] t=15091 uic=1202 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=14684 adcm=1983 adcn=0 gl=0 +[+14996ms] [PROF2] t=15091 defd=47667 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+17009ms] [PROF] t=17108 uic=1402 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=16701 adcm=1983 adcn=0 gl=0 +[+17019ms] [PROF2] t=17108 defd=47667 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+19025ms] [PROF] t=19125 uic=1602 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=18718 adcm=1983 adcn=0 gl=0 +[+19036ms] [PROF2] t=19125 defd=47667 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+20305ms] [2026-10-10 17:13:23][ADC_TASK] frames=20002 idle=0 skip=25 seq=20006 frst_err=0 ring=19977 adc1_rc=0 adc1_err=0 adc1_seq=20004 adc1_gap=25 adc1_hw=0 tgap=2 +[+20316ms] [2026-10-10 17:13:23][ADC_TASK] phasor(hb) C01-IA-F1: Re=0 Im=0 rms=0 (x0.1) +[+20326ms] [2026-10-10 17:13:23][ADC_TASK] phasor(hb) C02-UA-F1: Re=-2 Im=-2 rms=3 (x0.1) +[+20337ms] [2026-10-10 17:13:23][ADC_TASK] phasor(hb) C03-UN-F1: Re=0 Im=0 rms=1 (x0.1) +[+20337ms] [2026-10-10 17:13:23][ADC_TASK] phasor(hb) C04-IA-F2: Re=-1 Im=-1 rms=1 (x0.1) +[+20348ms] [2026-10-10 17:13:23][ADC_TASK] phasor(hb) C05-IA-F5: Re=2 Im=1 rms=2 (x0.1) +[+21059ms] [PROF] t=21158 uic=1799 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=20751 adcm=1983 adcn=0 gl=0 +[+21069ms] [PROF2] t=21158 defd=47964 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+23075ms] [PROF] t=23175 uic=1999 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=22768 adcm=1983 adcn=0 gl=0 +[+23085ms] [PROF2] t=23175 defd=47964 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+25094ms] [PROF] t=25192 uic=2199 uip=58732 uipn=9991 uil=10003 uiw=10337 uis=1 uisn=0 adc=24785 adcm=1983 adcn=0 gl=0 +[+25094ms] [PROF2] t=25192 defd=47964 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+27102ms] [PROF] t=27209 uic=2399 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=26802 adcm=1983 adcn=0 gl=0 +[+27112ms] [PROF2] t=27209 defd=47964 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 +[+29122ms] [PROF] t=29226 uic=2599 uip=58732 uipn=9995 uil=9999 uiw=10337 uis=1 uisn=0 adc=28819 adcm=1983 adcn=0 gl=0 +[+29132ms] [PROF2] t=29226 defd=47964 defs=7 netp=0 netn=0 adcp=10 adcq=0 gl=0 diff --git a/docs/测试说明/boot_log_data/README.md b/docs/测试说明/boot_log_data/README.md index 874be6c0..5c0c0cd6 100644 --- a/docs/测试说明/boot_log_data/README.md +++ b/docs/测试说明/boot_log_data/README.md @@ -27,6 +27,8 @@ |---|---|---|---| | 2026-10-10 | `prod_adc_tim6` / 开机 + 30 s 空载 | `adc` 528/2048(74%);`default` 2164/4096(47%);heap free=15088 min_ever=7152 | `20261010_prod_adc_tim6_boot_log.txt` | | 2026-10-10 | `prod_adc_l1` / 开机 + 30 s 空载 | `adc` **584/2048(71%)**(+56 B,L1 层 adc_input_poll/raw 上链);`default` 2164/4096(47%);heap free=15088 min_ever=7152 | `20261010_prod_adc_l1_boot_log.txt` | +| 2026-10-11 | `prod_phasor` / 开机 + 30 s 空载(L2 台架闭环) | `adc` **928/2048(54%)**(+344 B,压环 + `phasor_run` + `log_phasors` 上链);`default` 2152/4096(47%);heap free=15088 min_ever=7152 | `20261011_prod_phasor_boot_log.txt` | +| 2026-10-11 | `prod_adc_health` / 开机 + 30 s 空载(L1-6 + 抬栈 640 words) | `adc` **952/2560(62%)**(静态最坏链 1080 B,`adc_health_report`→`syslog_post` 回显尾);`default` 2152/4096(47%);heap free=14576 **min_ever=6640**(抬栈 -512 B,符合预算) | `20261011_prod_adc_health_boot_log.txt` | ## 4. 关联 diff --git a/docs/算法/继电保护功能复现规划.md b/docs/算法/继电保护功能复现规划.md index 19ad4f61..23925208 100644 --- a/docs/算法/继电保护功能复现规划.md +++ b/docs/算法/继电保护功能复现规划.md @@ -67,7 +67,7 @@ | **B0 产品定义** | 采集是否持续 / 采样率与通道 / 转发目标 / 保护元件清单——决定 B1 与保护层的**规模** | ✅ 已确认(2026-10-10,见 §1.5) | | **B1 ADC 采集链路** | 周期 CONVST + `adc_task` 消费 `tpafe5160_snapshot()` + 建任务 + 栈预算 | ✅ 已实现并 `@test` 实机闭环(2026-10-10;`adcTask` 512 words,台架 1000 Hz) | | **N-18 硬件缺陷** | V0.1 跨转换器无同一时刻性 → 限定 V0.1 可实现的保护范围 | ⚠️ 硬件改版项(V0.2) | -| **栈预算** | 保护任务栈需入 `Src/freertos.c` 并经 `scripts/stack_report.py` 核算 | 🔶 `adcTask` 已核算(**L2 接入后复测 2026-10-11:静态 832 B / 台架实测峰值 928/2048 / 余 54%**);`protectTask` 为后续 | +| **栈预算** | 保护任务栈需入 `Src/freertos.c` 并经 `scripts/stack_report.py` 核算 | 🔶 `adcTask` 已核算(**L1-6 后:栈抬至 640 words,静态 1080 B / 台架实测峰值 952/2560 / 余 57%**);`protectTask` 为后续 | ### 1.5 B0 产品定义(2026-10-10 确认) @@ -243,7 +243,7 @@ | L1-3 | STM32 ADC1 启动与扫描 | 9 通道、144 cycles、**TIM3 TRGO 硬件触发 + DMA** | 全库出现 `HAL_ADC_Start_DMA`;9 通道码值随注入变化 | ✅ 2026-10-10(v2.0):`App/algo/adc_input.c` 的 `stm32_take()`——ADC1 由 TIM3 更新事件经 TRGO 触发一轮 9 通道,`HAL_ADC_Start_DMA(&hadc1, buf, 18)` + DMA2_Stream4 循环模式把码值自动搬进**乒乓双缓冲**,任务侧只消费刚完成的半区(**零忙等**,实测单拍 `adcp` 122 µs → **8 µs**);台架 `@test` PASS——`adc1_seq` 每拍 +1、`adc1_err=0`、`adc1_hw=0`。⚠ 判据"随注入变化"以"逐通道独立读数 + 零停摆 + 跨次开机读数不同"替代(台架无信号源,同 §7.1 无示波器口径);**引脚级注入验证(`ADC通道映射说明.md` §8 步骤 2)仍为待做项** | | L1-4 | 通道语义映射 | 见 `docs/硬件平台/ADC通道映射说明.md`(**按语义取数,不写死下标**) | `UA→buf[6]`、`UN→buf[5]`、`IA→buf[2]`;STM32 侧 `UB→ADC_CHANNEL_14` | ✅ 2026-10-10:`App/algo/adc_input.c` 的 `k_adc_map[]` 语义表(`UA/UN/IA`→TPAFE `buf[6]/[5]/[2]`,`UB/UC/IB/IC/IN/TEMP`→ADC1 `CH14/3/6/1/2/5`)+ `adc_input_raw()` 按语义取数;开机**映射自检**(`adc_input_init()` 校验 STM32 映射通道必落在扫描序列内 → 挡住"映射到不扫描的通道"静默错)通过(`ADC input layer init: OK`)。台架首帧日志 8 路 + 温度全部读出:`UA=11429 UN=11454 IA=11452`(与 TIM3 轮次一致)、`UB=1018 UC=1044 IB=943 IC=1069 IN=1052 T=850`(互异、非饱和;**两次开机读数不同** → 确为实时转换而非常量) | | L1-5 | 通道标定(一次系数 + 零漂) | 每通道 `k`、`b`;系数定点 `2^10` | 注入标准源,读数误差 ≤ 目标精度 | ⬜ | -| L1-6 | 采样有效性校验 | FRSTDATA 跳变、帧序号单调、丢帧计数 | 芯片静默/丢帧被检出并置告警 | ⬜ | +| L1-6 | 采样有效性校验 | FRSTDATA 跳变、帧序号单调、丢帧计数 | 芯片静默/丢帧被检出并置告警 | ✅ 2026-10-11:新增 `App/algo/adc_health.c/h`(纯逻辑判定层:静默 100 ms 门限 / 丢帧边沿 / 序号倒退锁存 / FRSTDATA·HAL 错误·STM32 停摆计数增量)+ `adc_task.c` 告警编排(严重事件与故障上升沿 `DBG_ERROR` + 每类每次开机一次 `SYSLOG_SELFTEST`,恢复 `DBG_INFO`;gap 不告警只计数,心跳增 `tgap`);主机单测 `test/host/adc_health/` **31 用例全绿**(`adc-health-unit-test` 钩子);台架 `@test` PASS 零 `[ERR]`(健康不误报,`boot_log_data/20261011_prod_adc_health_boot_log.txt`)。⚠ 抬栈:判定链 `adc_health_report`→`syslog_post` 回显尾使 adcTask 静态最坏链 832→**1080 B** → 栈 512→**640 words**(余 1480 B / 57%) | | L1-7 | 等周波采样(软件测频 + 调频闭环) | 20 点/周波;测频范围建议 **45~55 Hz**(参考 40.5~60.5) | 频率偏移 ±2 Hz 时幅值误差 ≤ 1% | ⬜ | | L1-8 | 零序通道接入(外部专用 / 自产切换) | 外部 `UN`/`IN`;自产 `3I0 = Ia+Ib+Ic`(V0.2 起) | 零序通道可切换来源,值正确 | ⬜ | | L1-9 | 二次谐波/五次谐波提取入口 | k=2(涌流)、k=5(选线) | 相量层可产出对应谐波相量 | ✅ 2026-10-11:`phasor.c` 实例表 `C04-IA-F2`(k=2)/ `C05-IA-F5`(k=5)随每拍重算产出,台架心跳日志 `phasor(hb) C04/C05` 行可见(`boot_log_data/20261011_prod_phasor_boot_log.txt`);谐波**抑制/提取正确性**已由主机单测覆盖(`test/host/phasor/`) | @@ -463,7 +463,7 @@ | 阶段 | 名称 | 内容 | 前置 | 里程碑 | | --- | --- | --- | --- | --- | -| **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 ✅ | 每 1 ms 产出正确的基波相量 —— **✅ 2026-10-11 台架闭环**(L1-5 标定 / L1-6 有效性 / L1-7 等周波为 P0 遗留项,精度复核依赖标准源) | +| **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 ✅ | 每 1 ms 产出正确的基波相量 —— **✅ 2026-10-11 台架闭环**(L1-6 同日落地 ✅;L1-5 标定 / L1-7 等周波为 P0 遗留项,精度复核依赖标准源) | | **P1** | 基础保护元件 | **V0.1 有效子集**:过流(A 相,`IA`)、过负荷(A 相)+ 定值模型;B/C 相过流**建接口不使能** | P0 | 标准源注入 A 相可正确动作 | | **P2** | 电压/频率类保护 | 过压/欠压(`UA`)、过频/低频、零序过压(`UN`) | P0 | 电压/频率越限正确动作 | | **P3** | 异常检测与闭锁 | L5 自检闭锁、采样通道自检;三相类(涌流/PT·CT 断线/母线接地)**建接口不使能** | P1/P2 | 自检失败闭锁;未使能元件不误动 | @@ -602,3 +602,4 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-10 | **L2 相量层落地 + L2-2 实例数口径对齐** | 新增 `App/algo/sample_ring.c/h`(L2 输入:按语义通道保存 2 周波 = 40 点环缓冲,整帧写后统一推进写指针)与 `App/algo/phasor.c/h`(L2 核心:全周傅氏单频 DFT,N = 20;系数表**离线预乘 √2/N** → `fAmSQ` 直接为**有效值**;计算区/使用区**双区原子切换**;实例表 `k_inst[]` 数据驱动);主机侧单测 `test/host/phasor/` **57 用例全绿**(`phasor-unit-test` 钩子)。**§3.2 L2-2 行口径对齐**:参考项目为 **4 个实例**,本项目按 V0.1 有效通道展开为 **5 个**(`C01-IA-F1`/`C02-UA-F1`/`C03-UN-F1`/`C04-IA-F2`/`C05-IA-F5`),已加注说明 4 → 5 的来历;§4.3 相量区 RAM 行同步为 5 实例。⚠ §3.2 的 L2 各点状态**仍标 ⬜**——实现与主机单测已完成,但**台架 `@test` 闭环未做**,且 L2-3 的完成判据(标准源读数 = 设定有效值)在无信号源台架上不可达,故不置 ✅(口径见 §3.2 表下注) | | 2026-10-10 | **L1-1/L1-3 节拍源与搬运方式升级(v2.8)** | 节拍源 **TIM6 → TIM3**(更正硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时触发 TPAFE CONVST 与(经 TRGO)ADC1 一轮 9 通道;L1-3 由"软件触发 + 逐通道轮询"改为 **DMA2_Stream4 循环搬运 + 乒乓双缓冲**,`stm32_scan()` → `stm32_take()`,单拍 `adcp` **122 µs → 8 µs**。§1.4 采样软件行、§3.2 L1-1/L1-3/L1-4 同步。完整设计与实测见 `docs/子系统设计/ADC采样节拍设计说明.md` §11 | | 2026-10-11 | **L2 台架闭环:P0 收尾** | `@build` 0 错 0 警 → `@test` 闭环把 `phasor(first)`/`phasor(hb)` 列为 Required 并校验顺序 `App init OK>phasor(first)>phasor(hb)`(⚠ `net init: OK` **不在**该序内——adcTask 与 netTask 并行,`phasor(first)` ≈+500 ms 早于 `net init: OK` ≈+574 ms 属正常)→ **PASS**:5 实例相量开机即产出、心跳 5 实例持续刷新、`frames` 每 10 s +10000(1000.0 Hz)、`idle=0`、`frst_err=0`、`adc1_err=0`、`adc1_hw=0`、`fw build` 身份戳匹配、回读一致、锚点 ×1、零 `[ERR]`/HardFault;证据 `boot_log_data/20261011_prod_phasor_boot_log.txt`。栈复测:adcTask 静态 832 B / 实测峰值 **928/2048(余 54%)**,7 任务全 PASS(RAM 方案 §2.3 已同步)。§3.1 L1-9 置 ✅(`C04-IA-F2`/`C05-IA-F5` 台架产出);§3.2 L2-1~L2-4/L2-6/L2-7 置 ✅(精度判据保留"待标准源"注),**L2-5 更正为"V0.1 不适用、随 V0.2"**(跨转换器合成被 N-18 禁止、零序走外部专用通道——原表下注"L2-1~L2-7 实现完成"对 L2-5 系误记);§7.1 P0 里程碑置 ✅(遗留 L1-5/L1-6/L1-7)。下一步 = **P1**(A 相过流/过负荷 + 定值模型)与 **L1-6 采样有效性校验** | +| 2026-10-11 | **L1-6 采样有效性校验落地** | 新增 `App/algo/adc_health.c/h`(**纯逻辑判定层**,零平台依赖免桩单测):① 静默——两侧帧序号连续 ≥100 ms 无前进(电平位,可自愈;`adc_health_flags()` 为 L5-4 闭锁预留);② 丢帧——TPAFE 序号一次前进 >1 计 `tgap`(与 STM32 侧 `adc1_gap` 对称,边沿事件不告警——消费者超拍是已知现象,告警会淹没真故障);③ 单调性——序号倒退锁存位;④ 错误增量——FRSTDATA / HAL 错误回调 / STM32 停摆计数增量(事件位与锁存位**编号对齐**,消费者按位图"每类每次开机留痕一次")。`adc_task.c` 只做告警编排:严重事件/上升沿 `DBG_ERROR` + `SYSLOG_SELFTEST`("adc sampling fault"),下降沿 `DBG_INFO`,心跳行增 `tgap`。主机单测 `test/host/adc_health/` **31 用例全绿**(100 ms 门限两侧、tick 回绕、边沿语义、NULL 守卫、init 幂等;`adc-health-unit-test` 钩子登记)。台架 `@test` PASS:零 `[ERR]`(健康不误报)、`tgap=1→2` 与 `adc1_gap` 同步可见、`frames` 1000.0 Hz;证据 `boot_log_data/20261011_prod_adc_health_boot_log.txt`。⚠ **抬栈**:`adc_health_report`→`syslog_post` 控制台回显尾使 adcTask 静态最坏链 832→**1080 B**(512 words 余 968 B < 1024 B 底线)→ **512→640 words**,复测余 **1480 B / 57%**、堆 min_ever 7152→6640 B(AGENTS 任务表 / RAM 方案 §2.3 已同步)。§3.1 L1-6 置 ✅ | diff --git a/test/host/adc_health/adc_health_test.c b/test/host/adc_health/adc_health_test.c new file mode 100644 index 00000000..724d9f90 --- /dev/null +++ b/test/host/adc_health/adc_health_test.c @@ -0,0 +1,374 @@ +/* + * 模块名称:ADC 采样有效性校验(L1-6)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/adc_health.c 做离线验证。 + * + * 被测对象:真实源文件(以 #include 原样纳入,保证测的是生产代码而非副本): + * ../../../App/algo/adc_health.c + * 本层为**纯逻辑**(仅 stdint.h),**无需任何桩**。 + * + * 为什么要主机侧单测(而不是只用板端闭环): + * 1. 静默/倒退/错误增量在健康台架上**不可复现**(复现 = 拔芯片/改驱动), + * 板端只能证明"健康时不误报",证不了"故障时必报"; + * 2. 边界(恰好 100ms 门限 / tick 回绕 / 序号倒退后再前进 / 事件只发一拍) + * 只有合成计数序列能精确构造; + * 3. 不占 MCU 的 RAM,毫秒级完成。 + * + * 覆盖点(对应 docs/算法/继电保护功能复现规划.md §3.1 L1-6): + * TC-AH-001 init + 首拍只建基线(不误报) + * TC-AH-002 正常 1 kHz 推进 2000 拍:零故障零事件(健康不误报) + * TC-AH-003 TPAFE 静默:99ms 不报 / 100ms 报 / 恢复清除电平位、锁存位留痕 + * TC-AH-004 STM32 静默:TPAFE 正常时只报 STM32 侧 + * TC-AH-005 TPAFE 丢帧:d=3 → tgap+=2、事件仅当拍(边沿语义) + * TC-AH-006 STM32 gap 计数增量 → 事件(不入锁存:超拍非故障) + * TC-AH-007 序号倒退 → SEQ_BACKWARD 锁存(前进后仍留痕) + * TC-AH-008 FRSTDATA / STM32_ERR / HW_ERR 增量 → 事件位与锁存位编号一致 + * TC-AH-009 tick 回绕:跨 0xFFFFFFFF 的正常推进不误报静默;回绕后冻结仍报 + * TC-AH-010 NULL 守卫:in=NULL 跳过;out=NULL 只更新内部状态 + * TC-AH-011 init 幂等:故障后 re-init 全清 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-11 + */ + +#include +#include + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码 ===================== */ +/* 原样纳入生产源文件(纯逻辑,无需桩) */ +#include "adc_health.c" + +/* ===================== 工具 ===================== */ + +/* 构造一份"健康"计数快照模板:两侧序号同拍号 seq,错误计数全 0 */ +static adc_health_in_t mk_in(uint32_t seq, uint32_t now_ms) { + adc_health_in_t in; + + in.tpafe_seq = seq; + in.tpafe_frst_err = 0u; + in.stm32_seq = seq; + in.stm32_errs = 0u; + in.stm32_gaps = 0u; + in.stm32_hw_errs = 0u; + in.now_ms = now_ms; + return in; +} + +/* ===================== TC ===================== */ + +/* TC-AH-001:init + 首拍只建基线 */ +static void tc_prime(void) { + adc_health_in_t in; + adc_health_status_t st; + + adc_health_init(); + in = mk_in(12345u, 1000u); /* 开机时序号已非 0 */ + adc_health_check(&in, &st); + CHECK((st.flags == 0u) && (st.events == 0u) && (st.sticky == 0u) && + (st.tpafe_gaps == 0u), + "TC-AH-001 first check only primes: no flags/events/sticky"); + /* 第二拍序号未动但仅 1ms(< 门限)→ 仍不报 */ + in.now_ms = 1001u; + adc_health_check(&in, &st); + CHECK(st.flags == 0u, "TC-AH-001 1ms without advance is not a stall"); +} + +/* TC-AH-002:正常 1 kHz 推进 2000 拍零误报 */ +static void tc_healthy(void) { + adc_health_status_t st; + adc_health_in_t in; + uint32_t seq = 0u, now = 0u; + int i, ok = 1; + + adc_health_init(); + for (i = 0; i < 2000; i++) { + in = mk_in(seq, now); + adc_health_check(&in, &st); + if ((st.flags != 0u) || (st.events != 0u) || (st.sticky != 0u)) { + ok = 0; + } + seq++; + now++; + } + CHECK(ok, "TC-AH-002 healthy 1kHz sweep 2000 beats: zero flags/events"); +} + +/* TC-AH-003:TPAFE 静默 99/100ms 门限 + 恢复留痕 */ +static void tc_tpafe_stall(void) { + adc_health_status_t st; + adc_health_in_t in; + uint32_t seq = 500u, now = 500u; + int i; + + adc_health_init(); + /* 先跑 10 拍健康节拍建立"前进中"基线 */ + for (i = 0; i < 10; i++) { + in = mk_in(seq, now); + adc_health_check(&in, &st); + seq++; now++; + } + /* 仅 TPAFE 冻结(STM32 继续走):前 100 拍(elapsed 0..99ms)不报。 + * 注意第 1 拍会消费掉 healthy 循环末尾"未消费的那次 +1", + * 故静默计时从 now=510 起算,第 i 拍 elapsed = i。 */ + for (i = 0; i < 100; i++) { + in = mk_in(seq, now); + in.stm32_seq = seq + (uint32_t)i + 1u; /* STM32 侧照常前进 */ + adc_health_check(&in, &st); + now++; + } + CHECK((st.flags & ADC_HF_TPAFE_STALL) == 0u, + "TC-AH-003 no TPAFE stall before 100ms"); + /* 第 100ms:报 */ + in = mk_in(seq, now); + in.stm32_seq = seq + 100u; + adc_health_check(&in, &st); + CHECK((st.flags & ADC_HF_TPAFE_STALL) != 0u, + "TC-AH-003 TPAFE stall at 100ms"); + CHECK((st.sticky & ADC_HF_TPAFE_STALL) != 0u, + "TC-AH-003 stall latched into sticky"); + CHECK(st.tpafe_stall_ms >= ADC_HEALTH_STALL_MS, + "TC-AH-003 stall_ms reported >= threshold"); + /* 恢复:TPAFE 序号重新前进 → 电平位清除、sticky 留痕 */ + in = mk_in(seq + 1u, now + 1u); + in.stm32_seq = seq + 101u; + adc_health_check(&in, &st); + CHECK((st.flags & ADC_HF_TPAFE_STALL) == 0u, + "TC-AH-003 stall level clears on recovery"); + CHECK((adc_health_sticky() & ADC_HF_TPAFE_STALL) != 0u, + "TC-AH-003 sticky keeps the fault trace after recovery"); +} + +/* TC-AH-004:STM32 静默(TPAFE 正常) */ +static void tc_stm32_stall(void) { + adc_health_status_t st; + adc_health_in_t in; + uint32_t seq = 100u, now = 100u; + int i; + + adc_health_init(); + for (i = 0; i < 10; i++) { + in = mk_in(seq, now); + adc_health_check(&in, &st); + seq++; now++; + } + /* STM32 冻结(TPAFE 继续走)。⚠ 第 1 拍消费 healthy 循环末尾的"未消费 +1" + * 并把静默计时起点重置为该拍 now → 第 i 拍 elapsed = i,需 101 拍才到 100ms */ + for (i = 0; i < 101; i++) { + in = mk_in(seq + (uint32_t)i + 1u, now + (uint32_t)i + 1u); + in.stm32_seq = seq; /* 冻结在旧值 */ + adc_health_check(&in, &st); + } + CHECK((st.flags & ADC_HF_STM32_STALL) != 0u, + "TC-AH-004 STM32 stall at 100ms"); + CHECK((st.flags & ADC_HF_TPAFE_STALL) == 0u, + "TC-AH-004 healthy TPAFE not falsely stalled"); +} + +/* TC-AH-005:TPAFE 丢帧计数 + 边沿语义 */ +static void tc_tpafe_gap(void) { + adc_health_status_t st; + adc_health_in_t in; + + adc_health_init(); + in = mk_in(10u, 10u); + adc_health_check(&in, &st); /* 基线 */ + in.tpafe_seq = 13u; in.stm32_seq = 11u; in.now_ms = 11u; /* TPAFE d=3 */ + adc_health_check(&in, &st); + CHECK((st.events & ADC_HE_TPAFE_GAP) != 0u, + "TC-AH-005 gap event on d>1"); + CHECK(st.tpafe_gaps == 2u, "TC-AH-005 d=3 accumulates 2 lost points"); + in.tpafe_seq = 14u; in.stm32_seq = 12u; in.now_ms = 12u; /* 恢复正常 d=1 */ + adc_health_check(&in, &st); + CHECK(st.events == 0u, "TC-AH-005 gap event is edge-only (next beat clean)"); + CHECK(st.tpafe_gaps == 2u, "TC-AH-005 gap counter is cumulative"); +} + +/* TC-AH-006:STM32 gap 计数增量 → 事件、不入锁存 */ +static void tc_stm32_gap(void) { + adc_health_status_t st; + adc_health_in_t in; + + adc_health_init(); + in = mk_in(1u, 1u); + adc_health_check(&in, &st); + in.stm32_gaps = 1u; in.tpafe_seq = 2u; in.stm32_seq = 2u; in.now_ms = 2u; + adc_health_check(&in, &st); + CHECK((st.events & ADC_HE_STM32_GAP) != 0u, + "TC-AH-006 STM32 gap increment raises event"); + CHECK((st.sticky & (ADC_HF_FRSTDATA | ADC_HF_STM32_ERR | + ADC_HF_HW_ERR)) == 0u, + "TC-AH-006 gap does NOT latch sticky (overrun is not a fault)"); +} + +/* TC-AH-007:序号倒退 → 锁存 */ +static void tc_backward(void) { + adc_health_status_t st; + adc_health_in_t in; + uint32_t s, t; + int i; + + adc_health_init(); + in = mk_in(1000u, 100u); + adc_health_check(&in, &st); + in.tpafe_seq = 995u; in.stm32_seq = 995u; in.now_ms = 101u; /* 倒退 */ + adc_health_check(&in, &st); + CHECK((st.flags & ADC_HF_SEQ_BACKWARD) != 0u, + "TC-AH-007 backward seq raises latched flag"); + CHECK((st.sticky & ADC_HF_SEQ_BACKWARD) != 0u, + "TC-AH-007 backward latched into sticky"); + /* 恢复正常推进 50 拍:SEQ_BACKWARD 不清(锁存语义) */ + s = 996u; t = 102u; + for (i = 0; i < 50; i++) { + in = mk_in(s, t); + adc_health_check(&in, &st); + s++; t++; + } + CHECK((adc_health_flags() & ADC_HF_SEQ_BACKWARD) != 0u, + "TC-AH-007 SEQ_BACKWARD persists after normal advance"); +} + +/* TC-AH-008:三类错误计数增量 → 事件位与锁存位编号一致 */ +static void tc_err_counters(void) { + adc_health_status_t st; + adc_health_in_t in; + + adc_health_init(); + in = mk_in(1u, 1u); + adc_health_check(&in, &st); + + in.tpafe_seq = 2u; in.stm32_seq = 2u; in.now_ms = 2u; + in.tpafe_frst_err = 1u; + adc_health_check(&in, &st); + CHECK(((st.events & ADC_HE_FRSTDATA) != 0u) && + ((st.sticky & ADC_HF_FRSTDATA) != 0u), + "TC-AH-008 FRSTDATA increment: event + sticky (same bit number)"); + + in.tpafe_seq = 3u; in.stm32_seq = 3u; in.now_ms = 3u; + in.stm32_errs = 1u; + adc_health_check(&in, &st); + CHECK(((st.events & ADC_HE_STM32_ERR) != 0u) && + ((st.sticky & ADC_HF_STM32_ERR) != 0u), + "TC-AH-008 STM32 err increment: event + sticky (same bit number)"); + + in.tpafe_seq = 4u; in.stm32_seq = 4u; in.now_ms = 4u; + in.stm32_hw_errs = 1u; + adc_health_check(&in, &st); + CHECK(((st.events & ADC_HE_HW_ERR) != 0u) && + ((st.sticky & ADC_HF_HW_ERR) != 0u), + "TC-AH-008 HAL err increment: event + sticky (same bit number)"); + + /* 计数不再变化 → 事件不再重复触发 */ + in.tpafe_seq = 5u; in.stm32_seq = 5u; in.now_ms = 5u; + adc_health_check(&in, &st); + CHECK(st.events == 0u, "TC-AH-008 steady error counters raise no repeat events"); +} + +/* TC-AH-009:tick 回绕安全 */ +static void tc_tick_wrap(void) { + adc_health_status_t st; + adc_health_in_t in; + uint32_t seq = 70000u, now = 0xFFFFFF00u; + int i, ok = 1; + + adc_health_init(); + /* 跨 0xFFFFFFFF 正常推进 512 拍:不得误报静默 */ + for (i = 0; i < 512; i++) { + in = mk_in(seq, now); + adc_health_check(&in, &st); + if (st.flags != 0u) { + ok = 0; + } + seq++; now++; /* uint32 自然回绕 */ + } + CHECK(ok, "TC-AH-009 tick wrap sweep: no false stall"); + /* 回绕后两侧同时冻结。⚠ 第 1 拍消费 sweep 末尾的"未消费 +1"并重置计时起点 + * → 第 i 拍 elapsed = i,需 101 拍才到 100ms */ + for (i = 0; i < 101; i++) { + in = mk_in(seq, now); + adc_health_check(&in, &st); + now++; + } + CHECK(((st.flags & ADC_HF_TPAFE_STALL) != 0u) && + ((st.flags & ADC_HF_STM32_STALL) != 0u), + "TC-AH-009 stall still detected after tick wrap"); +} + +/* TC-AH-010:NULL 守卫 */ +static void tc_null_guard(void) { + adc_health_status_t st; + adc_health_in_t in; + int i; + + adc_health_init(); + in = mk_in(1u, 1u); + adc_health_check(&in, &st); + st.flags = 0xA5A5A5A5u; + adc_health_check(NULL, &st); + CHECK(st.flags == 0xA5A5A5A5u, "TC-AH-010 in=NULL leaves out untouched"); + + /* out=NULL:内部状态照常更新(两侧冻结 101 拍 → 静默位应出现) */ + for (i = 0; i < 101; i++) { + in = mk_in(1u, (uint32_t)(2 + i)); + adc_health_check(&in, NULL); + } + CHECK((adc_health_flags() & ADC_HF_TPAFE_STALL) != 0u, + "TC-AH-010 out=NULL still updates internal state"); +} + +/* TC-AH-011:init 幂等 / 故障后全清 */ +static void tc_reinit(void) { + adc_health_status_t st; + adc_health_in_t in; + int i; + + adc_health_init(); + in = mk_in(1u, 1u); + adc_health_check(&in, &st); + for (i = 0; i < 150; i++) { /* 制造双侧静默 */ + in = mk_in(1u, (uint32_t)(2 + i)); + adc_health_check(&in, &st); + } + CHECK(st.sticky != 0u, "TC-AH-011 pre: sticky non-zero before re-init"); + adc_health_init(); + CHECK((adc_health_flags() == 0u) && (adc_health_sticky() == 0u) && + (adc_health_tpafe_gaps() == 0u), + "TC-AH-011 re-init clears flags/sticky/gaps"); + in = mk_in(9u, 9u); /* 序号跳变也无妨:首拍只建基线 */ + adc_health_check(&in, &st); + CHECK((st.flags == 0u) && (st.events == 0u), + "TC-AH-011 re-prime after re-init is clean"); +} + +int main(void) { + printf("=== ADC Health Tests ===\n"); + + printf("-- prime / healthy --\n"); + tc_prime(); + tc_healthy(); + printf("-- stall (TPAFE / STM32) --\n"); + tc_tpafe_stall(); + tc_stm32_stall(); + printf("-- gap / backward / err counters --\n"); + tc_tpafe_gap(); + tc_stm32_gap(); + tc_backward(); + tc_err_counters(); + printf("-- tick wrap / guards / re-init --\n"); + tc_tick_wrap(); + tc_null_guard(); + tc_reinit(); + + printf("\n=== ADC Health Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/adc_health/run_unit_test.py b/test/host/adc_health/run_unit_test.py new file mode 100644 index 00000000..d418e88c --- /dev/null +++ b/test/host/adc_health/run_unit_test.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""ADC 采样有效性校验(L1-6)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/adc_health/adc_health_test.c(它以 #include 纳入 + **生产源文件** App/algo/adc_health.c),在 PC 上离线跑完整的判定逻辑验证: + 首拍建基线 / 健康不误报 / 静默 100ms 门限与恢复留痕 / 丢帧边沿语义 / + 序号倒退锁存 / 三类错误计数增量 / tick 回绕 / NULL 守卫 / init 幂等, + 失败即非 0 退出。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端闭环): + 静默/倒退/错误增量在健康台架上不可复现(复现 = 拔芯片/改驱动)——板端只能 + 证明"健康时不误报",证不了"故障时必报";而 100ms 门限、tick 回绕、事件 + 边沿语义这些边界只有合成计数序列能精确构造。 + +本层为**纯逻辑**:无寄存器、无 RTOS、无文件/外设依赖,故**不需要任何桩目录**。 + +用法(仓库根目录): + python test/host/adc_health/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/adc_health/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.1(L1-6); + docs/测试说明/测试开发标准流程.md §4.2(L0.5 主机侧单测)。 +创建日期:2026-10-11 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 无桩目录:被测代码为纯逻辑(adc_health.c 仅依赖 stdint.h 与自身头)。 +BUILD = TestBuild( + name="ADC Health Tests", + src=os.path.join(HERE, "adc_health_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== ADC Health Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) From c936441c8360f8aa8599e2e4f2c3e2530c1115b2 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 08:48:49 +0800 Subject: [PATCH 19/47] =?UTF-8?q?=E9=97=A8=E7=A6=81=E6=89=A9=E5=AE=B9?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=20lv-conf-sync=E3=80=81simulator/?= =?UTF-8?q?=20=E7=BA=B3=E5=85=A5=E5=85=A8=E9=83=A8=E9=92=A9=E5=AD=90?= =?UTF-8?q?=EF=BC=9B=E5=90=AB=20L3=20=E5=AE=9A=E5=80=BC/=E5=85=83=E4=BB=B6?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮把"自研代码落在门禁之外"的口子收掉,同时落地 LVGL 特性裁剪与 L3 保护定值/元件框架。 门禁(.pre-commit-config.yaml,26 个钩子) - 新增 scripts/check_lv_conf_sync.py + 钩子 lv-conf-sync:比对两份 lv_conf.h 的**生效值**(未定义则取 lv_conf_internal.h 兜底默认), 抓 MISMATCH / LVGL 不认的宏名 / 同文件重复定义 - 顶层 exclude 删掉 simulator/:其下 21 个 tracked 文件全是本项目自研 代码,生成物(build/、SDL2/、tests/ref/、shot*)已在 .gitignore; doc-links 的 exclude 同步删除 - lv-conf-sync 的 files: 覆盖两端配置 → 只改 simulator/lv_conf.h 也触发 (pre-commit 4.x 已移除 always: true,实测只告警并跳过,无兜底开关) - 新增 prot-set-unit-test / prot-elm-unit-test 两个主机侧单测钩子 - S11(2.3.5) 判定细化:禁的是"用路径找文件",尖括号系统头带子目录 (如 )不再误报;全仓库复核仅 2 处项目代码判定变化 模拟器代码整改(纳入钩子后暴露,全部修代码而非豁免) - cppcheck 4 处:sim_syslog.c 判空分支内解引用 p_epoch(真实 bug)、 2 处 struct tm 未初始化、1 处指针可加 const - coding-standard 61 处:44 处函数左大括号并到右括号同行、8 处补函数 注释、3 处补文件头注释块、2 处 __ 前缀保护宏改 XXX_H、2 处 sprintf 改 snprintf(写入字节数与原来完全一致) LVGL 特性裁剪(F-1/F-2/F-3) - 两端 lv_conf.h 对齐同一特性子集:删 26 个 LVGL 9.3 不存在的选项名、 3 处重复定义,10 项靠默认值隐式生效的改为显式固化 - 实测对镜像体积中性;价值在防漂移,且模拟器 golden 回归跑的正是固件 实际编译的配置 L3 软件侧 - App/algo/prot_set.* Q10 定值模型(范围校验 / 整表 all-or-nothing / 校验和封存-自检-闭锁)+ prot_elm.* 通用"滞回+定时限"元件框架 7 实例 - test/host/prot_set、test/host/prot_elm 主机侧单测;uvprojx 挂载两处 文档同步 - pre-commit使用说明:§2.8 S11 口径、§2.15 lv-conf-sync 详解、新增 §5.5 复盘(教训:exclude 只写真正不该进钩子的路径) - AGENTS.md、UI_设计架构 §1.4 第 10 条、RAM 分析、审查报告刷新与销项 验证 - @build BUILD_OK 0/0(Code=194276 RO=15872 RW=876 ZI=139892) - 模拟器重建:菜单 8 状态像素逐字节一致(池 5368 B)、11 内容页黄金参考 全过(往返 5448 B)、remo sink 0/25600 位差异、--logtest/--cfgtest PASS - 全仓库 cppcheck / coding-standard / no-mojibake --all-files 全过 - check_doc_links --all 129/0;check_mem_numbers --check 内存数字一致 - 端到端:往 simulator/lv_conf.h 注入一行漂移 → 钩子 FAIL(exit 1) --- .pre-commit-config.yaml | 54 +- AGENTS.md | 7 +- App/algo/README.md | 61 +- App/algo/prot_elm.c | 223 ++++++ App/algo/prot_elm.h | 84 +++ App/algo/prot_set.c | 228 ++++++ App/algo/prot_set.h | 116 ++++ Lib/lv_conf.h | 112 ++- .../draw/sw/blend/lv_draw_sw_blend_to_i1.c | 9 + MDK-ARM/STM32F407-Demo.uvprojx | 10 + docs/README.md | 8 +- docs/UI设计/UI_自动化测试流程.md | 11 +- docs/UI设计/UI_设计架构.md | 11 +- docs/代码规范/pre-commit使用说明.md | 37 +- docs/内存管理/RAM使用分析与优化方案.md | 22 +- docs/子系统设计/看门狗设计说明.md | 6 +- docs/子系统设计/配置文件设计说明.md | 2 +- docs/算法/继电保护功能复现规划.md | 29 +- docs/问题记录/项目审查与优化建议报告.md | 648 ++++++++---------- scripts/check_coding_standard.py | 16 +- scripts/check_lv_conf_sync.py | 278 ++++++++ simulator/build_sim.ps1 | 2 +- simulator/dbg_log.h | 15 +- simulator/lv_conf.h | 55 +- simulator/main.c | 93 ++- simulator/sim/net_socket_sim.c | 90 +-- simulator/sim/sim_port.c | 9 +- simulator/sim/sim_port.h | 6 +- simulator/sim/sim_syslog.c | 19 +- simulator/sim/ui_net_sink_backend_sim.c | 9 +- simulator/tests/check_content_pages.py | 4 +- simulator/tests/check_menu_rows.py | 6 +- test/host/prot_elm/prot_elm_test.c | 275 ++++++++ test/host/prot_elm/run_unit_test.py | 37 + test/host/prot_set/prot_set_test.c | 156 +++++ test/host/prot_set/run_unit_test.py | 37 + 36 files changed, 2220 insertions(+), 565 deletions(-) create mode 100644 App/algo/prot_elm.c create mode 100644 App/algo/prot_elm.h create mode 100644 App/algo/prot_set.c create mode 100644 App/algo/prot_set.h create mode 100644 scripts/check_lv_conf_sync.py create mode 100644 test/host/prot_elm/prot_elm_test.c create mode 100644 test/host/prot_elm/run_unit_test.py create mode 100644 test/host/prot_set/prot_set_test.c create mode 100644 test/host/prot_set/run_unit_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 21307e08..faa9cf8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,11 +2,18 @@ # 详见 docs/代码规范/pre-commit使用说明.md default_install_hook_types: [pre-commit, commit-msg] +# 顶层 exclude 只挡**第三方与生成物**。 +# ⚠ 2026-10-11 修正:原先在这里整目录挡 `simulator/` 是错的——`simulator/` 下 21 个 +# tracked 文件**全是本项目自研代码**(模拟器入口 main.c、平台后端 sim/*.c、回归脚本 +# tests/*.py、两份配置与 README)。整目录豁免 = 自研代码落在所有钩子之外(连 +# lv-conf-sync 都拦不到);实测把 simulator/ 纳入 cppcheck 后,立刻揪出 +# sim_syslog.c 一处真实的空指针解引用。真正的生成物/第三方(`simulator/build/`、 +# `simulator/SDL2/`、`simulator/tests/ref/`、`simulator/shot*.{bin,png}`、`__pycache__/`) +# **已在 .gitignore 里**,永远不会进 git 索引 → 无需在此重复豁免,故直接删掉该条。 exclude: | (?x)^( \.opencode/node_modules/| Lib/lvgl/| - simulator/| MDK-ARM/RTE/ ) @@ -153,11 +160,11 @@ repos: language: system files: \.md$ # 外部资料(芯片手册转录)链接形态不受控;第三方目录整体排除 + # (`simulator/` 已不再排除:其下 3 个 README 是本项目文档,2026-10-11) exclude: | (?x)^( \.opencode/node_modules/| Lib/lvgl/| - simulator/| MDK-ARM/RTE/| docs/芯片手册/ ) @@ -177,6 +184,27 @@ repos: files: ^(docs/内存管理/.*\.md|scripts/check_mem_numbers\.py|App/.*\.c|App/.*\.h|Src/.*\.c|Inc/.*\.h)$ pass_filenames: false + # 5d) 两份 lv_conf.h 特性子集一致性(2026-10-11 加,随 F-1/F-2/F-3 落地) + # 背景:`Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是两份独立文件, + # 此前长期漂移——模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、 + # 5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 靠 lv_conf_internal + # 默认值隐式生效 → 固件白链 **64,576 B 死代码**,且模拟器 golden 回归跑的是 + # 另一套特性子集(等于验证不到固件实际编译的配置)。 + # 口径:比对**生效值**(文件未定义则取 lv_conf_internal.h 的兜底默认), + # 并抓"写了 LVGL 不认的宏名"(`LV_USE_THEME_BASIC` 同类,实测两端共 26 处) + # 与同文件重复定义。规则正文见 docs/UI设计/UI_设计架构.md §1.4 第 10 条。 + # ⚠ 触发范围(2026-10-11 两次修正):本钩子由**两端任一配置**或脚本自身触发。 + # 原先顶层 exclude 整目录挡掉 `simulator/`,导致"只改 simulator/lv_conf.h" + # 的提交拦不到;已删该条(见文件顶部 exclude 注释),故这里可直接匹配 + # `simulator/lv_conf.h`。(pre-commit 4.x 已移除 `always: true`, + # 实测对 local 钩子只告警并跳过——不能靠它兜底。) + - id: lv-conf-sync + name: 两份 lv_conf.h 特性子集一致性 + entry: python scripts/check_lv_conf_sync.py + language: system + files: ^(Lib/lv_conf\.h|simulator/lv_conf\.h|scripts/check_lv_conf_sync\.py)$ + pass_filenames: false + # 6) CubeMX 重生成存活审计(2026-09-29 加) # 背景:.ioc 的 KeepUserCode 只保护 USER CODE 标记区;标记之外的手工改动 # 重生成即被覆盖,且多数不报编译错(崩溃采集被打回空 while(1)、加固宏被删)。 @@ -304,6 +332,28 @@ repos: files: ^(App/algo/adc_health\.(c|h)|App/task/adc_task\.c|test/host/adc_health/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7i) 保护定值模型(P1 / L5-1 载体)主机侧单元测试(2026-10-11 加) + # 定值是保护动作的"输入宪法":范围校验、整表装载 all-or-nothing、 + # 校验和封存-自检-闭锁(参考 tagPRS dwTagSum/dwTagSumN 模式)必须在 + # 任何元件接线之前钉死——板端无法穷举越界定值注入,主机侧毫秒级全跑。 + - id: prot-set-unit-test + name: 保护定值模型单测(主机侧) + entry: python test/host/prot_set/run_unit_test.py + language: system + files: ^(App/algo/prot_set\.(c|h)|test/host/prot_set/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + + # 7j) 保护元件框架(L3 P1 子集)主机侧单元测试(2026-10-11 加) + # 判据核(滞回/定时限/欠量方向/闭锁联动/时基回绕)是纯逻辑, + # 板端复现需要标准源 + 故障注入,主机侧用合成测量序列即可穷举 + # 1.2×/0.9× 验收口径(规划 §8)。 + - id: prot-elm-unit-test + name: 保护元件框架单测(主机侧) + entry: python test/host/prot_elm/run_unit_test.py + language: system + files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 8) 测试套件开关门禁(2026-10-01 加) # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 diff --git a/AGENTS.md b/AGENTS.md index b198f776..c48cedf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,9 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v ## 提交门禁(pre-commit) -- 任一失败即拦截,共 **18 个钩子**(`.pre-commit-config.yaml`,2026-10-10 清点):通用 5 个(`trailing-whitespace` / `end-of-file-fixer` / `check-added-large-files` / `check-merge-conflict` / `check-yaml`)+ `gitlint`(提交信息)+ `cppcheck`(warning/performance/portability/style)+ `test-cppcheck`(`test/` 只开 warning)+ `no-mojibake`(U+FFFD)+ `coding-standard`(规范 V1.0)+ 文档一致性三件套 `doc-links` / `task-table` / `mem-numbers` + `cubemx-survival`(重生成存活审计)+ 主机侧单测 `watchdog-unit-test` / `sys-clock-unit-test` / `cfg-file-unit-test` + `test-suite-switch`(测试开关=产品构建)。范围仅项目代码(第三方 `Lib/`/`Middlewares/`/CMSIS/HAL/`lftpd`/`dhara`/`MDK-ARM` 排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`) +- 任一失败即拦截,共 **26 个钩子**(`.pre-commit-config.yaml`,2026-10-11 清点):通用 5 个(`trailing-whitespace` / `end-of-file-fixer` / `check-added-large-files` / `check-merge-conflict` / `check-yaml`)+ `gitlint`(提交信息)+ `cppcheck`(warning/performance/portability/style)+ `test-cppcheck`(`test/` 只开 warning)+ `no-mojibake`(U+FFFD)+ `coding-standard`(规范 V1.0)+ 文档一致性三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(**两份 `lv_conf.h` 特性子集一致**,见 `docs/UI设计/UI_设计架构.md` §1.4 第 10 条)+ `cubemx-survival`(重生成存活审计)+ 主机侧单测 10 个 `watchdog` / `sys-clock` / `cfg-file` / `phasor` / `adc-input` / `vol-lock` / `time-sync` / `adc-health` / `prot-set` / `prot-elm`(均 `-unit-test` 后缀)+ `test-suite-switch`(测试开关=产品构建)。范围仅项目代码(第三方 `Lib/`/`Middlewares/`/CMSIS/HAL/`lftpd`/`dhara`/`MDK-ARM` 排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`) +- **`simulator/` 已纳入全部钩子**(2026-10-11 修正):顶层 `exclude` 原先整目录挡 `simulator/`,实际效果是把 **21 个本项目自研文件**(模拟器入口 `main.c`、`sim/*` 平台后端、`tests/*.py` 回归脚本、`lv_conf.h`/`dbg_log.h`、3 个 README)挡在门禁外——而那些生成物(`build/`、`SDL2/`、`tests/ref/`、`shot*.{bin,png}`、`__pycache__/`)**已在 `.gitignore` 里**,pre-commit 只看 git 索引,根本不需要重复豁免。纳入当天 `cppcheck` 即揪出 `sim/sim_syslog.c` 一处**真实空指针解引用**(`if (p_epoch == NULL || …) { *p_epoch = 0u; … }`),`coding-standard` 报 61 处(44 处函数左大括号 Allman 风格 / 8 处缺函数注释 / 3 处缺文件头 / 2 处 `__` 前缀保护宏 / 2 处 `sprintf` / 2 处 S11 误报)已全部处理;顺带细化 **S11 只禁"用路径找文件"**——`` 这类尖括号系统头带子目录不再误报(全仓库复核:仅这 2 处项目代码判定变化)。⚠ **写模拟器代码与固件代码同标准** +- ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想要"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关。例:`lv-conf-sync` 的 `files:` 同时匹配 `Lib/lv_conf.h`、`simulator/lv_conf.h` 与脚本自身,两端任一改动都会跑 - **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(2026-10-09 定;配置里 YAML 锚点 `&cubemx-generated` 两处共用):`Src/*.{c,h}`、`Inc/*.{c,h}`、`Drivers/CMSIS/`、`Drivers/STM32F4xx_HAL_Driver/`、`Middlewares/`、`MDK-ARM/*.{uvprojx,uvoptx,sct,map}`、`.mxproject`、`*.ioc`——这类文件每次重生成按工具自己的风格写回(实测每次 10+ 个文件各被补 1 行末尾空行),改写只会制造提交抖动。⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/`;`App/`、`Drivers/BSP/` 自研代码仍受改写型钩子检查 - 提交信息:**单行中文标题 ≤100 字符**(gitlint `title-max-length=100`)+ **正文每行 ≤80 字符**(gitlint 默认 `body-max-line-length`,本项目未关)→ 长说明一律用**短标题 + `git commit -F -` 多行正文**(中文长标题极易超限);UTF-8 无 BOM + `git commit -F <文件>`(防终端转码破坏) - 代码规范 V1.0:小写下划线命名(`g_`/`s_`/`p_`/`a_` 前缀)、头文件保护宏 `XXX_H`、常量在右、文件头模块注释块 → `docs/代码规范/` @@ -83,7 +85,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - netTask:`net_stack_bring_up`(配置已由 defaultTask 在屏障前加载,本任务只读 RAM 副本 → `net_init` + PHY 强制 100M 全双工 → 递增网络代次号)→ 置 NET_READY → 循环 `net_poll` + `net_process_messages`;消费 `SYS_EVT_NET_RECONFIG` → 立即重建网络(新 IP 免重启生效,各 TCP 服务据代次号重建监听) - defaultTask:`f_mount` forced(无卷自动 `f_mkfs` FAT32;挂载后卷健康探针:小写两击确认,FAT 区陈旧/擦除时自动重格式化自愈,`/config.ini` 回落工厂默认——2026-09-20 /log 写失败修复)→ 网络配置加载(`net_cfg_load`;旧档迁移已于 2026-10-10 删除,见「模块约定」网口配置条)→ STORAGE_READY → LED 闪烁;同时是 syslog(`/log/run.log`·`selftest.log`,定长 64B 记录)的**唯一写者**与**配置引擎属主**(`cfg_file` 静态态单任务收敛于此:UI 保存请求执行 `net_cfg_save_run` + 2s `cfg_file_poll`,经 `SYS_EVT_CFG_SAVE` 事件唤醒;netTask/uiTask 不得直接触碰配置引擎,见 P1-3) - adcTask(**B1,2026-10-10;L1 层 v2.3;P1-5 事件驱动消费;v2.8 节拍源 TIM3 + STM32 侧 DMA,2026-10-10**):**纯消费者**——每拍调 `adc_input_poll()`(`App/algo/adc_input.c`)= TPAFE `tpafe5160_snapshot()` 临界区原子快照(8 通道)+ **消费一帧 STM32 ADC1 DMA 双缓冲**(L1-3)→ 帧统计 + 每 10 s 心跳日志;取数一律经 `adc_input_raw()` **按语义**(L1-4,不写死下标);只取数、不做保护运算(V0.1 仅 TPAFE 三路 `UA`/`UN`/`IA` 测量有效,见 N-18)。**采样节拍(1 kHz)由 TIM3 更新事件硬件提供**(`Src/main.c` 的 `HAL_TIM_PeriodElapsedCallback` 在 TIM3 分支内调 `tpafe5160_start_conv_irq()`;同一更新事件经 **TRGO** 触发 ADC1 一轮 9 通道转换 → 两片转换器同源对齐);**消费唤醒与该节拍同源**——同一分支再调 `adc_tick_irq_notify()` 置位 `ADC_WAKE_FLAG`,任务用 `osThreadFlagsWait(..., osWaitForever)` 等唤醒(不再 1 ms 轮询;实测 `idle` 由 1~649 降到 **0**)。⚠ ISR 侧**只能**走 `adc_tick_irq_notify()`——它内含"消费者就绪"门(TIM3 在 `app_main_init` 就启动,比调度器早 ≈100 ms)。手写于 `Src/freertos.c` 的 `USER CODE` 区(同 timeSyncTask,**640 words** / **AboveNormal**——2026-10-11 L2 相量 + L1-6 有效性校验上链后静态最坏链 1080 B,512 words 余量跌破 1024 B 底线故抬栈),纳入 IWDG `required_mask`(`TASK_WD_ADC`)。详见 `docs/子系统设计/ADC采样节拍设计说明.md` §10、`App/algo/README.md` -- **采集与算法层(L1/L2)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 DMA 消费)、`adc_health.c/h` = L1-6(采样有效性判定:静默/丢帧/单调性/错误增量,纯逻辑免桩单测)、`sample_ring.c/h` + `phasor.c/h` = L2(40 点采样环 + 全周傅氏 5 实例 + 双区交换,2026-10-11 台架闭环)。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md` +- **采集与算法层(L1/L2/L3)落在 `App/algo/`**(2026-10-10 建):现有 `adc_input.c/h` = L1(语义通道映射 + STM32 ADC1 DMA 消费)、`adc_health.c/h` = L1-6(采样有效性判定:静默/丢帧/单调性/错误增量,纯逻辑免桩单测)、`sample_ring.c/h` + `phasor.c/h` = L2(40 点采样环 + 全周傅氏 5 实例 + 双区交换,2026-10-11 台架闭环)、`prot_set.c/h` + `prot_elm.c/h` = L3 软件侧(Q10 定值模型 + 校验和自检闭锁 + 通用"滞回+定时限"元件框架 7 实例,2026-10-11;**尚无任务调用 `prot_elm_run()`**——链接器移除、零足迹,板端接线随 protectTask 立项走 E-59 同款门禁)。新算法模块一律进这里,勿塞进 `App/task/`(任务只做消费/编排);见 `App/algo/README.md` - 全景 `docs/架构设计/项目架构总览.md`(六层分层 / 启动时序 / 存储·网络·UI 栈 / 目录组织) ## 硬件引脚 @@ -130,6 +132,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - `simulator/`(CMake + SDL2 预编译库)与 MCU 端共用 UI 源码(`ui_menu.c`、`ui_font_cn_12.c`),免烧录开发 UI:`& "simulator\build_sim.bat"`(按本机 VS 自动选生成器:16=2019 / 17=2022 / 18=2026,**勿把生成器版本写死**;`build/` 缓存生成器与本机 VS 不符时自动删除重建)→ 运行 `simulator\build\Debug\lvgl_sim.exe` - 自检:`--cfgtest`(配置闭环)/ `--logtest`(日志引擎)/ `--standby shot.bin`(待机屏截图)/ `--idle <秒>` +- **模拟器代码与固件同标准受检**(2026-10-11):`simulator/` 下自研文件全部纳入 pre-commit(`cppcheck` / `coding-standard` / 乱码 / 改写型钩子 / `doc-links` / `lv-conf-sync`),生成物只靠 `.gitignore` 挡、**不在 `exclude` 里写目录名**(详见 `docs/代码规范/pre-commit使用说明.md` §5.5)。改 `simulator/lv_conf.h` 会触发 `lv-conf-sync`;改模拟器 C/H 会触发 cppcheck + 规范 V1.0 - UI 改动一律走 **`ui-verify` skill 五步门禁**;含新汉字的 UI 文案提交前先跑 **`font-regen` skill** 重生成中文字体 ## 文档与图形 diff --git a/App/algo/README.md b/App/algo/README.md index e3a463fe..3b88233d 100644 --- a/App/algo/README.md +++ b/App/algo/README.md @@ -15,10 +15,13 @@ | `adc_health.h` / `adc_health.c` | L1 | **采样有效性判定层**:吃"计数快照"(帧序号/错误计数),产出静默/丢帧/单调性/错误增量故障位——判定与告警编排分离 | `adc_health_init()` `adc_health_check()` `adc_health_flags()` | L1-6 | | `sample_ring.h` / `sample_ring.c` | L2 输入 | **采样环缓冲**:按语义通道保存最近 2 个周波(40 点)采样值,供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 滑动窗供数) | | `phasor.h` / `phasor.c` | L2 | **全周傅氏相量计算**:一周波 20 点单频 DFT → 基波/谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 | +| `prot_set.h` / `prot_set.c` | L3 输入 | **保护定值模型**:Q10 定点 RAM 表(通讯地址对照参考 `g_tCommSetParaTab`)+ 范围校验 + 校验和封存自检闭锁 | `prot_set_init()` `prot_set_load_raw()` `prot_set_get_f()` `prot_set_selfcheck()` | §5 定值模型 / L5-1 | +| `prot_elm.h` / `prot_elm.c` | L3 | **保护元件框架**:通用"越限 + 返回系数滞回 + 定时限"核 + 数据驱动实例表(过流三段 / 过负荷 / 过压 / 欠压 / 零序过压) | `prot_elm_init()` `prot_elm_eval()` `prot_elm_run()` `prot_elm_active_mask()` | L3-1 / L3-3 / L3-4 / L3-6(软件侧) | 依赖关系(自下而上,无环):`adc_input.h`(纯枚举 + 声明)← `sample_ring` ← `phasor`; -`adc_health` **独立**(仅 `stdint.h`,不依赖本目录其它头)。 -`sample_ring.c` / `phasor.c` / `adc_health.c` **零平台依赖**(只用 `math.h` / `stddef.h` / `stdint.h` 与本目录头); +`prot_set` **独立**(仅 `stdint.h`);`prot_elm` ← `prot_set` + `phasor`。 +`sample_ring.c` / `phasor.c` / `adc_health.c` / `prot_set.c` / `prot_elm.c` **零平台依赖** +(只用 `math.h` / `stddef.h` / `stdint.h` 与本目录头); `adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。 ## 2. 数据流(一拍 = TIM3 1 kHz 节拍) @@ -148,6 +151,54 @@ flowchart TD - 告警策略(在 `adc_task.c`,不在本层):严重事件/电平上升沿 `DBG_ERROR` + `SYSLOG_SELFTEST`(每类每次开机一次,防刷屏写满 32KB selftest.log);下降沿 `DBG_INFO`。 +### 3.5 `prot_set.c` / `prot_set.h` — 保护定值模型(P1 / L5-1 载体) + +**解决的问题**:保护判据的"输入宪法"。参考系统把定值放 EEPROM + `tagPRS` 常量表 +校验和自检;本层先做 **RAM 模型**(cfg_file 持久化属 P4),把编号 / 通讯地址 / +范围 / 默认值 / 校验和闭环钉死。 + +| API | 干什么 | 注意 | +|---|---|---| +| `prot_set_init()` | 装默认值 + 封存校验和(幂等) | 内核启动前可调 | +| `prot_set_load_raw(vals)` | 整表装载(P4 起 cfg_file 调用),逐项范围校验 | **all-or-nothing**:任一项越界整表不装载,`prot_set_bad_mask()` 指认越界项 | +| `prot_set_set(id, q10)` | 单项写入(范围校验) | **不自动重封存**——批量改完必须 `prot_set_seal()`,否则 selfcheck 判"非法改写" | +| `prot_set_get_f()` / `get_ctl()` | 工程量浮点 / 投退读取 | 单字读原子 | +| `prot_set_selfcheck()` | 重算 sum32 与封存正/反码比对,失配 → 闭锁 | L5-1;`prot_set_seal()` 是解锁的唯一合法路径 | + +**要点** + +- **Q10 定点**(×1024)与参考 `2^10` 折算口径一致;元数据表(地址/范围/默认)是 + ROM 常量,校验和只覆盖 RAM 值区。 +- **通讯地址逐项对照参考** `Comm/Pub/Const_CommSetPara.c` `g_tCommSetParaTab` + (基址 0x8820);单测 TC-PS-006 保证地址不重复、`min ≤ def ≤ max`、投退项 0..1。 +- ⚠ **默认值/范围为拟定值**(参考 EEPROM 出厂表不在本仓库),待对照修订—— + 见规划 §5.4 落实进度注。 + +### 3.6 `prot_elm.c` / `prot_elm.h` — 保护元件框架(L3,P1 子集) + +**解决的问题**:参考系统 89 个 `PRS_*.c` 各自实现"滞回 + 定时限"——本框架把这段 +公共核收敛为**一个通用核 + 数据驱动实例表**(同 `phasor` 的 `k_inst` 模式), +新增元件 = 表加一行 + 定值加几项。 + +| API | 干什么 | 注意 | +|---|---|---| +| `prot_elm_init()` | 复位状态 + 解析相量实例索引(按名前缀 `C01/C02/C03`) | 须在 `phasor_init()` 之后调 | +| `prot_elm_eval(now_ms, meas)` | **纯判据**:定值 RAM + 测量数组 → 全元件状态机 | 主机单测入口;NULL 测量按 0 | +| `prot_elm_run(now_ms)` | 适配层:`phasor_get()` 组测量 → eval | 任一相量未就绪 → **整拍跳过**(不引入陈旧判据) | +| `prot_elm_state()` / `prot_elm_active_mask()` | 状态快照 / 动作位图 | 供 L4 出口与心跳日志 | + +**要点** + +- **滞回**:已启动用返回门槛(`定值 × 返回系数`),未启动用动作门槛—— + 过量/欠量方向由实例表 `over` 位统一处理(`UV` 是唯一欠量实例)。 +- **定时限**:`(uint32_t)(int32_t)(now - t0) >= dly` 差值法,tick 回绕安全; + `dly=0` → 启动同拍即动作。返回**即时**(参考带 10 ms 返回延时,P4 接出口硬件时再补)。 +- **闭锁联动**:`prot_set_locked()`(L5-1)或投退=0 → 该元件状态整体复位、不判据。 +- **V0.1 实例**(N-18):`OC1/OC2/OC3/OL`(IA)、`OV/UV`(UA)、`3U0`(UN 外部通道)。 + 零序过流(IN 走 STM32)与过频/低频(待频率算法)**故意不在表中**。 +- **尚未接线**:无任务调用 `prot_elm_run()`(链接器整对象移除,零足迹); + 板端接线随 protectTask 立项(栈预算 + IWDG 心跳门禁,AGENTS E-59 同款流程)。 + ## 4. 关键约定 - **语义映射唯一权威口径**:`docs/硬件平台/ADC通道映射说明.md`。取数一律经 `adc_input_raw()`, @@ -168,6 +219,8 @@ flowchart TD | `phasor.c` `s_zone[2][5]` `phasor_t` | 160 B | | `adc_input.c` `s_stm32_raw[16]` + `s_tpafe_raw[8]` | 48 B | | `adc_health.c` 内部状态(9 × uint32 + uint8) | ≈ 40 B | +| `prot_set.c` `s_val[31]` + 封存/位图/闭锁 | 136 B(**未接线前被链接器移除,实测 0**) | +| `prot_elm.c` `s_rt[7]`(16 B/实例) | 112 B(同上,实测 0) | | 运行期栈:`phasor_run()` 的 `win[20]` | 80 B/帧 | 改容量(`SAMPLE_RING_CYCLES` / 实例数 / `ADC_SEM_COUNT`)前后各跑一次 @@ -175,7 +228,7 @@ flowchart TD ## 6. 构建与测试 -- **挂载**:四个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `` 内。 +- **挂载**:七个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `` 内。 本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)。 - **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码: @@ -184,6 +237,8 @@ flowchart TD | 相量层(57 用例:系数表逐项复核 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换) | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` | | ADC 输入层(逐 Rank 注入互异码值钉死映射 + HAL 启动失败/超时分支) | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` | | 有效性校验(31 用例:100 ms 静默门限两侧 / 丢帧边沿 / 倒退锁存 / 错误增量 / tick 回绕 / NULL 守卫 / init 幂等) | `& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py` | `adc-health-unit-test` | + | 定值模型(30 用例:范围校验 / all-or-nothing 装载 / 封存-自检-闭锁 / Q10 往返 / 元数据表自检) | `& '.venv\Scripts\python.exe' test/host/prot_set/run_unit_test.py` | `prot-set-unit-test` | + | 元件框架(27 用例:滞回与定时限 / 欠量方向 / 投退 / 闭锁联动 / 时基回绕 / 位图聚合) | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-unit-test` | 改本目录任一 `.c/.h`,提交时对应钩子自动跑;详见 `docs/测试说明/相量层单元测试说明.md`。 - **板端**:`@build` 0 错误 0 警告 → `@test` 看 `[ADC_TASK]` 首拍/心跳日志(`phasor(...)` 行、 diff --git a/App/algo/prot_elm.c b/App/algo/prot_elm.c new file mode 100644 index 00000000..4bd4b882 --- /dev/null +++ b/App/algo/prot_elm.c @@ -0,0 +1,223 @@ +/* + * 模块名称:保护元件框架(L3,P1 子集)——实现 + * 模块功能:见 prot_elm.h。判据核 prot_elm_eval() 为纯逻辑(prot_set RAM + + * 测量数组 → 状态机),prot_elm_run() 是 phasor→测量的薄适配层。 + * + * 创建日期:2026-10-11 + */ +#include "prot_elm.h" +#include "prot_set.h" +#include "phasor.h" + +#include + +/* 实例配置(ROM 常量表,数据驱动——新增元件只加一行,同 phasor k_inst 模式) */ +typedef struct { + const char *name; /* 日志名 */ + uint8_t over; /* 1 = 过量(越上限动作),0 = 欠量(越下限动作) */ + prot_set_id_t en_id; /* 投退定值 */ + prot_set_id_t set_id; /* 动作定值 */ + prot_set_id_t dly_id; /* 延时定值(ms) */ + prot_set_id_t ret_id; /* 返回系数定值(RET_OVER / RET_UNDER) */ + const char *pprefix; /* 相量实例名前缀("C01"…),run() 适配层取数用 */ +} pe_cfg_t; + +static const pe_cfg_t k_pe[PE_COUNT] = { + { "OC1-IA", 1u, PS_OC1_EN, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" }, + { "OC2-IA", 1u, PS_OC2_EN, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" }, + { "OC3-IA", 1u, PS_OC3_EN, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" }, + { "OL-IA", 1u, PS_OL_EN, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" }, + { "OV-UA", 1u, PS_OV_EN, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" }, + { "UV-UA", 0u, PS_UV_EN, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" }, + { "3U0-UN", 1u, PS_3U0_EN, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" }, +}; + +/* 运行时状态(picked/asserted 的对外快照 + 延时起点 + 相量实例索引缓存) */ +typedef struct { + uint8_t enabled; + uint8_t picked; + uint8_t asserted; + uint32_t t0; /* picked 起始时基(picked=0 时无意义) */ + uint8_t pidx; /* 相量实例索引;0xFF = 未解析/未找到 */ +} pe_rt_t; + +static pe_rt_t s_rt[PE_COUNT]; + +/* + * 函数功能:按名前缀在 phasor 实例表里找索引(开机解析一次) + * 入口参数:prefix - 相量实例名前缀(如 "C01") + * 返 回 值:实例索引;0xFF = 未找到(run() 对该元件整拍跳过) + */ +static uint8_t pe_find_phasor(const char *prefix) { + uint8_t i, n = phasor_inst_count(); + + for (i = 0u; i < n; i++) { + const phasor_inst_t *pi = phasor_inst(i); + uint8_t j = 0u, hit = 1u; + + if (pi == 0) { + continue; + } + while ((prefix[j] != '\0') && (pi->name[j] != '\0')) { + if (prefix[j] != pi->name[j]) { + hit = 0u; + break; + } + j++; + } + if ((hit != 0u) && (prefix[j] == '\0')) { + return i; + } + } + return 0xFFu; +} + +/* + * 函数功能:复位全部元件状态并解析相量索引(定值不动;幂等) + * 入口参数:无 + * 返 回 值:无 + * 限定条件:须在 phasor_init() 之后调用(否则实例表名查不到 → 全 0xFF) + */ +void prot_elm_init(void) { + uint32_t i; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + s_rt[i].enabled = 0u; + s_rt[i].picked = 0u; + s_rt[i].asserted = 0u; + s_rt[i].t0 = 0u; + s_rt[i].pidx = pe_find_phasor(k_pe[i].pprefix); + } +} + +/* + * 函数功能:纯判据步进——按当前定值 + 给定测量值推进所有元件 + * 入口参数:now_ms - 单调时基(tick ms,差值法回绕安全) + * meas - PE_COUNT 个测量值(工程量 A/V);NULL 视为全 0 + * 返 回 值:无 + * 函数说明:投退=0 或定值闭锁(L5-1)→ 状态整体复位、不判据; + * 滞回 = 已启动用返回门槛(定值×系数)、未启动用动作门槛; + * 延时 dly=0 → 启动同拍即动作;返回即时(P4 接出口时再补返回延时)。 + */ +void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]) { + uint8_t locked = prot_set_locked(); + uint32_t i; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + const pe_cfg_t *cfg = &k_pe[i]; + uint8_t cond; + float m, set, ret; + + s_rt[i].enabled = ((locked == 0u) && (prot_set_get_ctl(cfg->en_id) != 0u)) ? 1u : 0u; + if (s_rt[i].enabled == 0u) { + /* 退出/闭锁:状态整体复位(参考元件闭锁分支的 Rly/Timer 复位) */ + s_rt[i].picked = 0u; + s_rt[i].asserted = 0u; + s_rt[i].t0 = 0u; + continue; + } + + m = (meas != 0) ? meas[i] : 0.0f; + set = prot_set_get_f(cfg->set_id); + ret = set * prot_set_get_f(cfg->ret_id); + + /* 滞回:已启动用返回门槛,未启动用动作门槛(参考 M_GRly_OverRelay) */ + if (cfg->over != 0u) { + cond = (s_rt[i].picked != 0u) ? (uint8_t)(m > ret) : (uint8_t)(m > set); + } else { + cond = (s_rt[i].picked != 0u) ? (uint8_t)(m < ret) : (uint8_t)(m < set); + } + + if (cond != 0u) { + float dly; + + if (s_rt[i].picked == 0u) { + s_rt[i].picked = 1u; + s_rt[i].t0 = now_ms; + } + dly = prot_set_get_f(cfg->dly_id); + if (dly < 0.0f) { + dly = 0.0f; + } + /* 差值法比较,tick 回绕安全;dly=0 → 启动同拍即动作 */ + if ((uint32_t)(int32_t)(now_ms - s_rt[i].t0) >= (uint32_t)dly) { + s_rt[i].asserted = 1u; + } + } else { + s_rt[i].picked = 0u; + s_rt[i].asserted = 0u; + s_rt[i].t0 = 0u; + } + } +} + +/* + * 函数功能:适配层——从 phasor 使用区取 rms 组装测量数组后调判据核 + * 入口参数:now_ms - 单调时基(tick ms) + * 返 回 值:无 + * 限定条件:仅保护消费者任务上下文;工程量↔码值换算 = 1(L1-5 标定未实现) + * 函数说明:任一相量未就绪(未满一周波)→ **整拍跳过**,不引入陈旧/半新测量。 + */ +void prot_elm_run(uint32_t now_ms) { + float meas[PE_COUNT]; + phasor_t p; + uint32_t i; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + if ((s_rt[i].pidx == 0xFFu) || (phasor_get(s_rt[i].pidx, &p) != 0)) { + return; + } + meas[i] = p.fAmSQ; + } + prot_elm_eval(now_ms, meas); +} + +/* + * 函数功能:读单元件状态快照(拷贝语义) + * 入口参数:id - 元件编号;out - 快照输出(NULL 直接返回) + * 返 回 值:无;越界编号输出全零 + */ +void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out) { + if (out == 0) { + return; + } + out->enabled = 0u; + out->picked = 0u; + out->asserted = 0u; + out->pick_ms = 0u; + if (id < PE_COUNT) { + out->enabled = s_rt[(uint32_t)id].enabled; + out->picked = s_rt[(uint32_t)id].picked; + out->asserted = s_rt[(uint32_t)id].asserted; + out->pick_ms = s_rt[(uint32_t)id].t0; + } +} + +/* + * 函数功能:聚合动作位图 + * 入口参数:无 + * 返 回 值:bit i = 元件 i asserted(供 L4 出口 / 心跳日志) + */ +uint32_t prot_elm_active_mask(void) { + uint32_t mask = 0u; + uint32_t i; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + if (s_rt[i].asserted != 0u) { + mask |= (1uL << i); + } + } + return mask; +} + +/* + * 函数功能:取实例名(日志用) + * 入口参数:id - 元件编号 + * 返 回 值:ROM 常量名;越界编号返回 "PE??" + */ +const char *prot_elm_name(prot_elm_id_t id) { + if (id >= PE_COUNT) { + return "PE??"; + } + return k_pe[(uint32_t)id].name; +} diff --git a/App/algo/prot_elm.h b/App/algo/prot_elm.h new file mode 100644 index 00000000..636a2bff --- /dev/null +++ b/App/algo/prot_elm.h @@ -0,0 +1,84 @@ +/* + * 模块名称:保护元件框架(L3,P1 子集) + * 模块功能:通用"越限 + 返回系数滞回 + 定时限"元件核 + 实例表(数据驱动, + * 同 phasor 实例表模式)。判据为**纯逻辑**(吃测量值 + 定值), + * 主机侧可离线单测;出口/SOE(L4)与任务编排为后续。 + * + * V0.1 有效实例(N-18:仅 TPAFE 三路): + * OC1/OC2/OC3 三段式相间过流(A 相 IA,L3-1) + * OL 过负荷(A 相 IA,L3-3) + * OV/UV 过压/欠压(UA,L3-4) + * 3U0 零序过压(UN 外部零序电压通道,L3-6) + * ⚠ 过频/低频(L3-5)随频率测量算法(P2)落地,本表暂不含。 + * + * 与参考系统的口径差异(有意简化,均已在规划文档登记): + * - 返回门槛 = 定值 × 返回系数(参考经 CN_SET_RET 独立定值,等价); + * - 返回**即时**(参考 GRly_TimeRelay 带 10 ms 返回延时——V0.1 无出口硬件, + * 抖动只影响日志,P4 接出口时再补返回延时); + * - 复压/方向/无压无流闭锁(dwSetFyCtrl 类)随 P2/P3 元件扩展; + * - 工程量↔码值换算系数暂为 1(L1-5 标定未实现,同 phasor 层口径)。 + * + * 线程约定:init 内核启动前;eval/run 仅保护消费者任务上下文(P1 阶段 = 单测; + * 板端接线随 protectTask 立项,走栈预算 + IWDG 心跳门禁)。 + * + * 创建日期:2026-10-11 + */ +#ifndef PROT_ELM_H +#define PROT_ELM_H + +#include + +/* 元件实例编号(枚举序 = 实例表序 = 测量数组序) */ +typedef enum { + PE_OC1 = 0, /* 过流 Ⅰ 段(IA) */ + PE_OC2, /* 过流 Ⅱ 段(IA) */ + PE_OC3, /* 过流 Ⅲ 段(IA) */ + PE_OL, /* 过负荷(IA) */ + PE_OV, /* 过压(UA) */ + PE_UV, /* 欠压(UA,欠量方向) */ + PE_3U0, /* 零序过压(UN) */ + PE_COUNT +} prot_elm_id_t; + +/* 单元件状态快照(读侧拷贝语义,见 prot_elm_state) */ +typedef struct { + uint8_t enabled; /* 本拍投入(定值投退 && 未闭锁) */ + uint8_t picked; /* 越限启动(滞回后电平) */ + uint8_t asserted; /* 定时限到点(动作) */ + uint32_t pick_ms; /* 本次启动起始时基(0 = 未启动过;asserted 延时观测用) */ +} prot_elm_state_t; + +/* + * 函数功能:复位全部元件状态(定值不动;幂等,内核启动前可调) + */ +void prot_elm_init(void); + +/* + * 函数功能:纯判据步进——按当前定值(prot_set RAM)+ 给定测量值推进所有元件 + * 入口参数:now_ms - 单调时基(tick ms,回绕安全:差值法) + * meas - PE_COUNT 个测量值(工程量:A / V),NULL 视为全 0 + * 返 回 值:无 + * 限定条件:仅保护消费者任务上下文;调用方保证定值已装载 + * 函数说明:越限方向由实例表决定;投退=0 或定值闭锁(L5-1)→ 状态复位、 + * 不判据。延时判定用 (uint32_t)(int32_t)(now - t0) >= dly,回绕安全。 + */ +void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]); + +/* + * 函数功能:适配层——从 phasor 使用区取 rms 组装测量数组后调 prot_elm_eval + * 入口参数:now_ms - 同上 + * 说明:phasor 未就绪(-2,未满一周波)的实例本拍按"测量=上一拍值"处理会 + * 引入陈旧判据 → 直接**整拍跳过**(不 eval),与 adc_task 跳拍同口径。 + */ +void prot_elm_run(uint32_t now_ms); + +/* 状态快照拷贝(NULL 安全;越界全零) */ +void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out); + +/* 动作位图(bit i = 元件 i asserted),供 L4 出口 / 心跳日志 */ +uint32_t prot_elm_active_mask(void); + +/* 实例名(ROM 常量,日志用) */ +const char *prot_elm_name(prot_elm_id_t id); + +#endif /* PROT_ELM_H */ diff --git a/App/algo/prot_set.c b/App/algo/prot_set.c new file mode 100644 index 00000000..cfefead3 --- /dev/null +++ b/App/algo/prot_set.c @@ -0,0 +1,228 @@ +/* + * 模块名称:保护定值模型(P1,L5-1 载体)——实现 + * 模块功能:见 prot_set.h。纯逻辑,零平台依赖(仅 stdint.h + 自身头)。 + * + * ⚠ 默认值/范围为拟定值(参考 EEPROM 出厂默认表不在本仓库,规划 §10.3 待补); + * 通讯地址逐项对照参考 Comm/Pub/Const_CommSetPara.c g_tCommSetParaTab。 + * + * 创建日期:2026-10-11 + */ +#include "prot_set.h" + +/* 元数据表:顺序必须与 prot_set_id_t 严格一致(逐行带地址注释,同 phasor + * k_inst 的朴素行式风格) */ +const prot_set_meta_t g_prot_set_meta[PS_ID_COUNT] = { + /* addr ctl default min max 说明 */ + { 0x8856, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* OC1 投退 */ + { 0x8857, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* OC1 跳闸投退 */ + { 0x8858, 0u, PROT_SET_Q10(10.0), 0, PROT_SET_Q10(100.0) }, /* OC1 电流 A */ + { 0x8859, 0u, PROT_SET_Q10(0.0), 0, PROT_SET_Q10(60000) }, /* OC1 延时 ms */ + { 0x885A, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, + { 0x885B, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, + { 0x885C, 0u, PROT_SET_Q10(5.0), 0, PROT_SET_Q10(100.0) }, + { 0x885D, 0u, PROT_SET_Q10(500.0), 0, PROT_SET_Q10(60000) }, + { 0x885E, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, + { 0x885F, 1u, PROT_SET_Q10(0), 0, PROT_SET_Q10(1) }, /* OC3 默认告警 */ + { 0x8860, 0u, PROT_SET_Q10(2.0), 0, PROT_SET_Q10(100.0) }, + { 0x8861, 0u, PROT_SET_Q10(3000.0), 0, PROT_SET_Q10(60000) }, + { 0x8871, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* 过负荷投退 */ + { 0x8872, 1u, PROT_SET_Q10(0), 0, PROT_SET_Q10(1) }, /* 默认告警 */ + { 0x8873, 0u, PROT_SET_Q10(1.0), 0, PROT_SET_Q10(100.0) }, + { 0x8874, 0u, PROT_SET_Q10(10000.0),0, PROT_SET_Q10(60000) }, + { 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* OV 投退(本地) */ + { 0x8029, 0u, PROT_SET_Q10(120.0), 0, PROT_SET_Q10(200.0) }, /* 过压 V */ + { 0x802A, 0u, PROT_SET_Q10(10000.0),0, PROT_SET_Q10(60000) }, + { 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* UV 投退(本地) */ + { 0x8027, 0u, PROT_SET_Q10(20.0), 0, PROT_SET_Q10(200.0) }, /* 欠压 V */ + { 0x8028, 0u, PROT_SET_Q10(10000.0),0, PROT_SET_Q10(60000) }, + { 0x888E, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* 3U0 投退 */ + { 0x888F, 0u, PROT_SET_Q10(15.0), 0, PROT_SET_Q10(200.0) }, /* 3U0 V */ + { 0x8890, 0u, PROT_SET_Q10(5000.0), 0, PROT_SET_Q10(60000) }, + { 0x0000, 0u, PROT_SET_Q10(0.95), PROT_SET_Q10(0.5), PROT_SET_Q10(1.0) }, /* 过量返回系数 */ + { 0x0000, 0u, PROT_SET_Q10(1.05), PROT_SET_Q10(1.0), PROT_SET_Q10(1.5) }, /* 欠量返回系数 */ + { 0x8820, 0u, PROT_SET_Q10(10000.0),PROT_SET_Q10(100.0), PROT_SET_Q10(500000.0) }, + { 0x8821, 0u, PROT_SET_Q10(100.0), PROT_SET_Q10(1.0), PROT_SET_Q10(200.0) }, + { 0x8825, 0u, PROT_SET_Q10(100.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10000.0) }, + { 0x8826, 0u, PROT_SET_Q10(5.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10.0) }, +}; + +static uint32_t s_val[PS_ID_COUNT]; /* RAM 值区(uint32 承载 int32,校验和按字覆盖) */ +static uint32_t s_sum; /* 封存的正码 */ +static uint32_t s_sumn; /* 封存的反码 */ +static uint32_t s_bad_mask; /* load_raw 越界位图 */ +static uint8_t s_locked; /* 定值非法 → 闭锁(L5-1) */ + +/* + * 函数功能:sum32 覆盖 RAM 值区(参考 Fun_CalSum32 同款:32 位字累加自然回绕) + * 入口参数:无 + * 返 回 值:校验和正码 + */ +static uint32_t prot_sum32(void) { + uint32_t sum = 0u; + uint32_t i; + + for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { + sum += s_val[i]; + } + return sum; +} + +/* + * 函数功能:把元数据表默认值刷进 RAM 值区 + * 入口参数:无 + * 返 回 值:无 + */ +static void prot_load_defaults(void) { + uint32_t i; + + for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { + s_val[i] = (uint32_t)g_prot_set_meta[i].def; + } +} + +/* + * 函数功能:装载出厂默认值并封存校验和(幂等) + * 入口参数:无 + * 返 回 值:无 + */ +void prot_set_init(void) { + prot_load_defaults(); + s_bad_mask = 0u; + s_locked = 0u; + prot_set_seal(); +} + +/* + * 函数功能:取最近一次 load_raw 的越界位图 + * 入口参数:无 + * 返 回 值:bit i = 第 i 项越界(load_raw 失败后有效) + */ +uint32_t prot_set_bad_mask(void) { + return s_bad_mask; +} + +/* + * 函数功能:整表装载外部值(P4 起由 cfg_file 定值段调用),逐项范围校验 + * 入口参数:vals - PS_ID_COUNT 个 Q10 值 + * 返 回 值:0 全部合法(已装载并封存)/ -1 存在越界项或空指针(整表不装载) + * 函数说明:all-or-nothing——宁可全旧不可半新(定值一致性优先); + * 越界项经 prot_set_bad_mask() 指认。 + */ +int prot_set_load_raw(const int32_t *vals) { + uint32_t mask = 0u; + uint32_t i; + + if (vals == 0) { + return -1; + } + for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { + if ((vals[i] < g_prot_set_meta[i].min) || (vals[i] > g_prot_set_meta[i].max)) { + mask |= (1uL << i); + } + } + s_bad_mask = mask; + if (mask != 0u) { + return -1; + } + for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { + s_val[i] = (uint32_t)vals[i]; + } + prot_set_seal(); + return 0; +} + +/* + * 函数功能:单项写入(范围校验) + * 入口参数:id - 定值项编号;q10 - Q10 定点值 + * 返 回 值:0 / -1 越界或非法编号拒写(原值不动) + * 函数说明:**不自动重封校验和**——批量改完必须显式 prot_set_seal(), + * 否则 selfcheck 判"定值被非法改写"(参考系统改定值走重写整表)。 + */ +int prot_set_set(prot_set_id_t id, int32_t q10) { + if (id >= PS_ID_COUNT) { + return -1; + } + if ((q10 < g_prot_set_meta[id].min) || (q10 > g_prot_set_meta[id].max)) { + return -1; + } + s_val[(uint32_t)id] = (uint32_t)q10; + return 0; +} + +/* + * 函数功能:读定值 Q10 原值 + * 入口参数:id - 定值项编号 + * 返 回 值:Q10 值;非法编号返回 0 + */ +int32_t prot_set_get(prot_set_id_t id) { + if (id >= PS_ID_COUNT) { + return 0; + } + return (int32_t)s_val[(uint32_t)id]; +} + +/* + * 函数功能:读定值工程量浮点值 + * 入口参数:id - 定值项编号 + * 返 回 值:Q10 → float;非法编号返回 0.0 + */ +float prot_set_get_f(prot_set_id_t id) { + return PROT_SET_FROM_Q10(prot_set_get(id)); +} + +/* + * 函数功能:读投退/枚举项 + * 入口参数:id - 定值项编号 + * 返 回 值:!=0 视为投入(1/0) + */ +uint8_t prot_set_get_ctl(prot_set_id_t id) { + return (prot_set_get(id) != 0) ? 1u : 0u; +} + +/* + * 函数功能:实时计算校验和(不读封存值) + * 入口参数:无 + * 返 回 值:sum32 正码 + */ +uint32_t prot_set_checksum(void) { + return prot_sum32(); +} + +/* + * 函数功能:封存当前值区的正/反码对(合法改定值闭环的最后一步) + * 入口参数:无 + * 返 回 值:无 + * 函数说明:封存同时解除闭锁——selfcheck 失配后的唯一合法解锁路径。 + */ +void prot_set_seal(void) { + s_sum = prot_sum32(); + s_sumn = ~s_sum; + s_locked = 0u; +} + +/* + * 函数功能:定值自检(L5-1)——重算 sum32 并与封存值比对 + * 入口参数:无 + * 返 回 值:0 通过 / -1 失配(已置闭锁) + * 函数说明:失配 → s_locked 置位并保持,直到下一次 seal; + * 元件层每拍读 prot_set_locked() 决定整体复位(联动闭锁)。 + */ +int prot_set_selfcheck(void) { + if (prot_sum32() != s_sum) { + s_locked = 1u; /* 值区与封存不符:定值被非法改写 → 闭锁 */ + } + if ((s_sum != (~s_sumn)) || (s_locked != 0u)) { + s_locked = 1u; + return -1; + } + return 0; +} + +/* + * 函数功能:读闭锁状态 + * 入口参数:无 + * 返 回 值:1 = 定值非法(保护应整体闭锁)/ 0 = 正常 + */ +uint8_t prot_set_locked(void) { + return s_locked; +} diff --git a/App/algo/prot_set.h b/App/algo/prot_set.h new file mode 100644 index 00000000..a4cff982 --- /dev/null +++ b/App/algo/prot_set.h @@ -0,0 +1,116 @@ +/* + * 模块名称:保护定值模型(P1,L5-1 载体) + * 模块功能:保护定值的 RAM 模型——编号 / 通讯地址 / 范围 / 默认值 / 校验和自检。 + * 纯逻辑(仅 stdint.h),主机侧可离线单测;持久化(cfg_file 定值段) + * 属 P4,本层只提供 load_raw/validate 的装载接口。 + * + * 设计口径(docs/算法/继电保护功能复现规划.md §5): + * - 定点存储:Q10(×1024),与参考系统定点系数 2^10 一致(§5.3); + * - 通讯地址:与参考 g_tCommSetParaTab(Comm/Pub/Const_CommSetPara.c)逐项对齐, + * 基址 0x8820(=34848);告警类沿用运行参数段 0x8027/0x8029; + * - 校验和(L5-1):sum32 正码 + 反码成对封存(参考 tagPRS 的 + * dwTagSum/dwTagSumN 模式),selfcheck 失配 → 置闭锁; + * - ⚠ 默认值/范围为**拟定值**:参考 EEPROM 出厂默认表未在本仓库, + * 待对照后逐项修订(规划 §10.3 待补项);范围先按 DTU 馈线典型量级。 + * + * 线程约定:装载(init/load_raw)在 defaultTask(P4 起);读在保护消费者任务。 + * 读接口返回快照值(单个 int32 读取原子);写与封存必须同一任务(P4 定)。 + * + * 创建日期:2026-10-11 + */ +#ifndef PROT_SET_H +#define PROT_SET_H + +#include + +/* Q10 定点:工程量 × 1024(与参考 2^10 口径一致) */ +#define PROT_SET_Q10(v) ((int32_t)(((double)(v)) * 1024.0)) +#define PROT_SET_FROM_Q10(v) ((float)((double)(v) / 1024.0)) + +/* 定值项编号(枚举序 = RAM 数组序 = 校验和覆盖序,只增不删不改序) */ +typedef enum { + /* --- 三段式相间过流(A 相,L3-1):投退/跳闸投退/电流/延时 --- */ + PS_OC1_EN = 0, /* 0x8856 Ⅰ段投退 */ + PS_OC1_TRIP, /* 0x8857 Ⅰ段跳闸投退(0 告警 / 1 跳闸) */ + PS_OC1_CUR, /* 0x8858 Ⅰ段电流定值(A,二次值) */ + PS_OC1_DLY, /* 0x8859 Ⅰ段延时(ms) */ + PS_OC2_EN, /* 0x885A */ + PS_OC2_TRIP, /* 0x885B */ + PS_OC2_CUR, /* 0x885C */ + PS_OC2_DLY, /* 0x885D */ + PS_OC3_EN, /* 0x885E */ + PS_OC3_TRIP, /* 0x885F */ + PS_OC3_CUR, /* 0x8860 */ + PS_OC3_DLY, /* 0x8861 */ + /* --- 过负荷(A 相,L3-3) --- */ + PS_OL_EN, /* 0x8871 */ + PS_OL_TRIP, /* 0x8872 */ + PS_OL_CUR, /* 0x8873 */ + PS_OL_DLY, /* 0x8874 */ + /* --- 过压 / 欠压(UA,L3-4;沿用参考告警段 0x8029/0x8027) --- */ + PS_OV_EN, /* 本地投退(参考告警类无独立投退,V0.1 补) */ + PS_OV_VOL, /* 0x8029 过电压门限(V,二次值) */ + PS_OV_DLY, /* 0x802A 过电压延时(ms) */ + PS_UV_EN, /* 本地投退 */ + PS_UV_VOL, /* 0x8027 低电压门限(V,二次值) */ + PS_UV_DLY, /* 0x8028 低电压延时(ms) */ + /* --- 零序过压(UN,L3-6) --- */ + PS_3U0_EN, /* 0x888E */ + PS_3U0_VOL, /* 0x888F 3U0 定值(V,二次值) */ + PS_3U0_DLY, /* 0x8890 延时(ms) */ + /* --- 公共 --- */ + PS_RET_OVER, /* 过量返回系数(返回门槛 = 定值 × 系数,<1) */ + PS_RET_UNDER, /* 欠量返回系数(返回门槛 = 定值 × 系数,>1) */ + PS_PT_PRI, /* 0x8820 PT 一次额定(V) */ + PS_PT_SEC, /* 0x8821 PT 二次额定(V) */ + PS_CT_PRI, /* 0x8825 CT 一次额定(A) */ + PS_CT_SEC, /* 0x8826 CT 二次额定(A) */ + PS_ID_COUNT +} prot_set_id_t; + +/* 定值项元数据(ROM 常量表,校验和自检不覆盖它——只覆盖 RAM 值区) */ +typedef struct { + uint16_t comm_addr; /* 通讯信息体地址(0 = 本地项,不上送) */ + uint8_t is_ctl; /* 1 = 投退/枚举项(0/1),0 = 模拟量项 */ + int32_t def; /* 默认值(Q10;投退项为 0/1 的 Q10) */ + int32_t min; /* 下限(Q10) */ + int32_t max; /* 上限(Q10) */ +} prot_set_meta_t; + +extern const prot_set_meta_t g_prot_set_meta[PS_ID_COUNT]; + +/* + * 函数功能:装载出厂默认值并封存校验和(幂等,内核启动前可调) + */ +void prot_set_init(void); + +/* + * 函数功能:整表装载外部值(P4 起由 cfg_file 定值段调用),逐项范围校验 + * 入口参数:vals - PS_ID_COUNT 个 Q10 值 + * 返 回 值:0 全部合法(已装载并封存)/ -1 存在越界项(**整表不装载**, + * 返回前可经 prot_set_bad_mask() 取越界位图) + */ +int prot_set_load_raw(const int32_t *vals); + +/* 越界位图(bit i = 第 i 项越界;load_raw 失败后有效) */ +uint32_t prot_set_bad_mask(void); + +/* + * 函数功能:单项写入(范围校验;**不自动重封校验和**——批量改完必须 + * 显式 prot_set_seal(),否则 selfcheck 判"定值被非法改写") + * 返 回 值:0 / -1 越界拒写 + */ +int prot_set_set(prot_set_id_t id, int32_t q10); + +int32_t prot_set_get(prot_set_id_t id); /* Q10 原值 */ +float prot_set_get_f(prot_set_id_t id); /* 工程量浮点 */ +uint8_t prot_set_get_ctl(prot_set_id_t id); /* 投退项:!=0 视为投入 */ + +/* 校验和(L5-1):sum32 覆盖 RAM 值区;seal 封存正/反码对 */ +uint32_t prot_set_checksum(void); +void prot_set_seal(void); +/* 自检:重算并与封存值比对,失配 → 置闭锁并返回 -1(已闭锁也返回 -1) */ +int prot_set_selfcheck(void); +uint8_t prot_set_locked(void); + +#endif /* PROT_SET_H */ diff --git a/Lib/lv_conf.h b/Lib/lv_conf.h index 4b87c0fc..4a6418f4 100644 --- a/Lib/lv_conf.h +++ b/Lib/lv_conf.h @@ -7,22 +7,40 @@ #ifndef LV_CONF_H #define LV_CONF_H +/* ⚠ 本文件与 simulator/lv_conf.h 必须保持**同一特性子集**,已由门禁校验: + * `python scripts/check_lv_conf_sync.py`(pre-commit 钩子 lv-conf-sync,always 跑)。 + * 2026-10-11 清理:删掉 14 个 LVGL 9.3 **不存在**的选项(LVGL 8 / 9.4 模板残留, + * 写了不生效,与已删的 LV_USE_THEME_BASIC 同类):LV_USE_PNG / JPG / ANIMATION / + * ENCODER / MOUSEWHEEL / IMGBTN / METER / COLORWHEEL / TEXTLINE / ASSERT_USER_CODE / + * ASSERT_LAYOUT、LV_DRAW_SW_YIELD / ASYNC、LV_COLOR_CHROMA_KEY。 + * 另把此前靠 lv_conf_internal 默认值隐式生效的开关显式固化为 0(见下"核心特性"块)。 */ + /* 1bpp 单色 */ #define LV_COLOR_DEPTH 1 -#define LV_COLOR_CHROMA_KEY 0x000000 -/* 必需控件(菜单依赖 flex/label/button) */ -#define LV_USE_OBJ 1 -#define LV_USE_FLEX 1 -#define LV_USE_LABEL 1 -#define LV_USE_BUTTON 1 +/* 控件裁剪(F-2/F-3,2026-10-11):本项目 UI 全部自绘——实测 Drivers/BSP/UI、App、 + * simulator 三处**零控件构造函数、零 flex/grid 布局调用**,只用 lv_obj_create + + * lv_theme_mono_init。此前这些类靠 lv_theme_simple(9.3 默认 =1)当锚点链进镜像: + * label 4,042 + buttonmatrix 3,586 + textarea 3,632 + bar 1,800 + button 26 + * = 13,086 B;另 flex 2,776 + grid 2,592 = 5,368 B。 + * ⚠ 将来引入控件型页面(U2 原型库)时按需逐项打开,并同步 simulator/lv_conf.h + * 后复跑 ui-verify 五步门禁。 + * 注:原 LV_USE_OBJ 在 9.3 不是有效选项(lv_conf_internal 无此宏),已删。 */ +#define LV_USE_FLEX 0 /* F-3:全仓无 lv_obj_set_flex_* 调用 */ +#define LV_USE_GRID 0 /* F-3:全仓无 lv_obj_set_grid_* 调用 */ +#define LV_USE_LABEL 0 +#define LV_USE_BUTTON 0 +#define LV_USE_BUTTONMATRIX 0 +#define LV_USE_BAR 0 +#define LV_USE_TEXTAREA 0 #define LV_USE_MENU 0 +#define LV_USE_IMAGEBUTTON 0 /* 9.3 默认 1;显式关死,与 simulator 一致 */ /* 刷新周期(ms):离屏渲染,无需高刷 */ #define LV_DEF_REFR_PERIOD 20 -/* 关闭动画/日志,省 RAM 与代码 */ -#define LV_USE_ANIMATION 0 +/* 关闭日志,省 RAM 与代码。 + * 注:9.3 **没有** LV_USE_ANIMATION(动画属核心),原该行是无效项,已删。 */ #define LV_USE_LOG 0 /* 不使用 LVGL 自带 OS 抽象,由 ui_task 手动调用 lv_timer_handler */ @@ -34,13 +52,12 @@ #define LV_MEM_SIZE (40 * 1024) #define LV_MEM_ADR 0 /* 0 = 内部静态分配 */ -/* 断言全部关闭,减少体积 */ +/* 断言全部关闭,减少体积(9.3 只有这 4 个 ASSERT 宏;原 ASSERT_USER_CODE / + * ASSERT_LAYOUT 是 LVGL 8 残留,已删) */ #define LV_USE_ASSERT_NULL 0 #define LV_USE_ASSERT_MALLOC 0 -#define LV_USE_ASSERT_USER_CODE 0 #define LV_USE_ASSERT_OBJ 0 #define LV_USE_ASSERT_STYLE 0 -#define LV_USE_ASSERT_LAYOUT 0 /* 内置字体:先启用 Latin 以通过编译; * 中文 1bpp 字库由 fontconverter 生成后通过 ui_font_cn_12 接入并替换默认字体 */ @@ -66,27 +83,50 @@ /* 文本编码 UTF-8(菜单含中文) */ #define LV_TXT_ENC LV_TXT_ENC_UTF8 -/* 主题:关闭默认主题,控件用内置基础样式,省代码 */ +/* 主题:只用 mono(ui_port.c 显式 lv_theme_mono_init(disp, true, ...) + set_theme + apply)。 + * ⚠ 9.3 **没有** LV_USE_THEME_BASIC —— 原那条是无效配置(写了也不生效,已删)。 + * ⚠ 关键:LV_USE_THEME_SIMPLE 默认 =1,lv_display_create() 会因此强链 lv_theme_simple, + * 而它正是把上面那些控件类拉进镜像的锚点(主题本体 866 B + 锚定控件 13,086 B)。 */ #define LV_USE_THEME_DEFAULT 0 -#define LV_USE_THEME_BASIC 0 +#define LV_USE_THEME_SIMPLE 0 +#define LV_USE_THEME_MONO 1 /* 绘制相关:1bpp 无需 GPU/抗锯齿 */ /* 复杂绘制必须开启:主界面顶部开口浅 V 装饰含斜线,lv_line 斜段依赖 LV_DRAW_SW_COMPLEX, * 关闭则两腿不渲染(只画水平线)。原值靠 lv_conf_internal 默认(=1)隐式生效,此处显式固化。 * 注意:LV_DRAW_SW_CIRCLE_CACHE_SIZE 须非零(默认 4),否则零长数组编译报错。 */ #define LV_DRAW_SW_COMPLEX 1 -#define LV_DRAW_SW_YIELD 0 -#define LV_DRAW_SW_ASYNC 0 +/* 简单绘制层缓冲:9.3 默认 24 KB(从 LVGL 池里取),本项目 UI 不建层 → + * 与 simulator 一致显式设 0(golden 回归已在 0 下逐字节验证) */ +#define LV_DRAW_LAYER_SIMPLE_BUF_SIZE 0 +/* 注:9.3 没有 LV_DRAW_SW_YIELD / LV_DRAW_SW_ASYNC(9.4 才有),原两行无效,已删。 */ -/* 输入设备:启用键盘/编码器(远程按键走 keypad) */ -#define LV_USE_KEYBOARD 0 -#define LV_USE_MOUSEWHEEL 0 -#define LV_USE_ENCODER 0 +/* 软件渲染器的颜色格式支持(F-1,2026-10-11):显示为 I1(LV_COLOR_DEPTH 1), + * 只有 blend_to_i1 会被用到。此前 11 个宏全靠 lv_conf_internal 默认(=1)隐式生效, + * 把 7 个用不到的混合核整份链进镜像:实测 blend_to_* 合计 40,582 B,其中 i1 仅 5,272 B + * → 死代码 35,310 B。每份 blend 文件都是文件级 #if 守卫,关掉 = 整个 TU 不参与编译。 + * ⚠ 字形 A8 位图是作为 **mask** 传入的,混合按**目的**格式(I1)分派 → 不需要 + * SUPPORT_A8/L8/AL88(此判断已由 simulator 的 I1-only 配置 + golden 回归验证)。 + * 取值与 simulator/lv_conf.h 保持一致(模拟器早已是 I1-only)。 */ +#define LV_DRAW_SW_SUPPORT_I1 1 +#define LV_DRAW_SW_SUPPORT_L8 0 +#define LV_DRAW_SW_SUPPORT_AL88 0 +#define LV_DRAW_SW_SUPPORT_A8 0 +#define LV_DRAW_SW_SUPPORT_RGB565 0 +#define LV_DRAW_SW_SUPPORT_RGB565_SWAPPED 0 +#define LV_DRAW_SW_SUPPORT_RGB565A8 0 +#define LV_DRAW_SW_SUPPORT_RGB888 0 +#define LV_DRAW_SW_SUPPORT_XRGB8888 0 +#define LV_DRAW_SW_SUPPORT_ARGB8888 0 +#define LV_DRAW_SW_SUPPORT_ARGB8888_PREMULTIPLIED 0 -/* 不使用的第三方扩展 */ -#define LV_USE_PNG 0 +/* 输入设备:全部不用(远程按键经 RemoDispBus 直接注入,见 ui_net_sink)。 + * 注:9.3 没有 LV_USE_MOUSEWHEEL / LV_USE_ENCODER(LVGL 8 残留),已删; + * LV_USE_KEYBOARD 原在本文件定义两次,去重(下方控件块保留一处)。 */ + +/* 不使用的第三方扩展(9.3 的图片解码宏是 LV_USE_LODEPNG / TJPGD, + * 没有 LV_USE_PNG / LV_USE_JPG —— LVGL 8 残留,已删) */ #define LV_USE_BMP 0 -#define LV_USE_JPG 0 #define LV_USE_GIF 0 #define LV_USE_QRCODE 0 #define LV_USE_BARCODE 0 @@ -94,18 +134,16 @@ #define LV_USE_GRIDNAV 0 #define LV_USE_FRAGMENT 0 -/* 裁剪未用控件,减小体积(menu/label/btn/img 等保留) */ +/* 裁剪未用控件,减小体积(本 UI 只用 lv_obj + 自绘,见文件头 F-2/F-3 说明)。 + * 注:LV_USE_COLORWHEEL / IMGBTN / METER / TEXTLINE 在 9.3 不存在(LVGL 8 残留),已删。 */ #define LV_USE_ANIMIMG 0 #define LV_USE_ARC 0 #define LV_USE_CHART 0 #define LV_USE_CHECKBOX 0 -#define LV_USE_COLORWHEEL 0 #define LV_USE_DROPDOWN 0 -#define LV_USE_IMGBTN 0 #define LV_USE_KEYBOARD 0 #define LV_USE_LED 0 #define LV_USE_LIST 0 -#define LV_USE_METER 0 #define LV_USE_MSGBOX 0 #define LV_USE_ROLLER 0 #define LV_USE_SCALE 0 @@ -121,6 +159,26 @@ #define LV_USE_CALENDAR 0 #define LV_USE_CANVAS 0 #define LV_USE_IMAGE 0 /* 菜单未用图片控件;lv_menu 已关,无图标依赖 */ -#define LV_USE_TEXTLINE 0 + +/* 核心特性:此前全靠 lv_conf_internal 默认值隐式生效(默认 =1 的整份链进固件), + * 现显式固化为 0,并与 simulator/lv_conf.h 对齐(check_lv_conf_sync.py 校验)。 + * 其中 LV_USE_OBSERVER 默认 1 → 固件此前一直白链 lv_observer.c。 */ +#define LV_USE_OBSERVER 0 +#define LV_USE_FLOAT 0 +#define LV_USE_MATRIX 0 +#define LV_USE_VECTOR_GRAPHIC 0 +#define LV_USE_OBJ_PROPERTY 0 +#define LV_USE_GESTURE_RECOGNITION 0 +#define LV_USE_COLOR_FILTER 0 +#define LV_USE_DRAW_PXP 0 /* NXP 并行像素引擎,STM32 无关 */ +#define LV_WIDGETS_HAS_DEFAULT_VALUE 0 +/* 以下两组是已关父项(LV_USE_LABEL / LV_USE_THEME_DEFAULT)的子选项,本身惰性, + * 但显式写死可防"将来只在一端启用父项"时的静默漂移 */ +#define LV_LABEL_TEXT_SELECTION 0 +#define LV_LABEL_LONG_TXT_HINT 0 +#define LV_LABEL_WAIT_CHAR_COUNT 0 +#define LV_THEME_DEFAULT_DARK 0 +#define LV_THEME_DEFAULT_GROW 0 +#define LV_THEME_DEFAULT_TRANSITION_TIME 0 #endif /* LV_CONF_H */ diff --git a/Lib/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.c b/Lib/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.c index eaeccfab..1204912e 100644 --- a/Lib/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.c +++ b/Lib/lvgl/src/draw/sw/blend/lv_draw_sw_blend_to_i1.c @@ -78,7 +78,9 @@ static inline uint8_t /* LV_ATTRIBUTE_FAST_MEM */ get_bit(const uint8_t * buf, i static inline void * /* LV_ATTRIBUTE_FAST_MEM */ drawbuf_next_row(const void * buf, uint32_t stride); +#if LV_DRAW_SW_SUPPORT_RGB565_SWAPPED /* 本地补丁:与定义处守卫一致,见文件末尾 */ static inline lv_color16_t /* LV_ATTRIBUTE_FAST_MEM */ lv_color16_from_u16(uint16_t raw); +#endif /********************** * STATIC VARIABLES @@ -1271,6 +1273,12 @@ static inline uint8_t LV_ATTRIBUTE_FAST_MEM get_bit(const uint8_t * buf, int32_t return (buf[bit_idx / 8] >> (7 - (bit_idx % 8))) & 1; } +/* 本地补丁(2026-10-11,随 F-1 裁剪 LV_DRAW_SW_SUPPORT_* 引入): + * 本 helper 的唯一使用点全部在 `#if LV_DRAW_SW_SUPPORT_RGB565_SWAPPED` 块内 + * (本文件 1107/1125/1146/1168/1190 行),但上游 9.3.0 漏了定义处的守卫 → + * 关掉 RGB565_SWAPPED 后 ARMCC 报 #177-D "declared but never referenced"。 + * 补上与使用点一致的守卫;升级 LVGL 时若上游已修可去掉。 */ +#if LV_DRAW_SW_SUPPORT_RGB565_SWAPPED static inline lv_color16_t LV_ATTRIBUTE_FAST_MEM lv_color16_from_u16(uint16_t raw) { lv_color16_t c; @@ -1279,5 +1287,6 @@ static inline lv_color16_t LV_ATTRIBUTE_FAST_MEM lv_color16_from_u16(uint16_t ra c.blue = raw & 0x1F; return c; } +#endif #endif diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 15c97bd0..44f0a034 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -962,6 +962,16 @@ 1 ..\App\algo\adc_health.c + + prot_set.c + 1 + ..\App\algo\prot_set.c + + + prot_elm.c + 1 + ..\App\algo\prot_elm.c + diff --git a/docs/README.md b/docs/README.md index 5011e020..f43a7be7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -211,7 +211,7 @@ docs/ | 文档 | 内容 | |---|---| -| [`问题记录/项目审查与优化建议报告.md`](问题记录/项目审查与优化建议报告.md) | 项目全景审查:文档/代码/架构/可靠性问题清单(D-1~D-7、N-xx、Px-xx)——**体系层评审报告,非待办池**(待办见 §9.3 `待处理内容.md`) | +| [`问题记录/项目审查与优化建议报告.md`](问题记录/项目审查与优化建议报告.md) | 项目全景审查:文档/代码/架构/可靠性问题清单(未闭环项:D-1、N-xx、Px-xx;**已修复项按「修复即销项」删除**,去处见其变更记录)——**体系层评审报告,非待办池**(待办见 §9.3 `待处理内容.md`) | ### 9.2 环境与部署 @@ -246,19 +246,19 @@ docs/ ## 11. 已知文档问题(待清理) -> 来源:《项目审查与优化建议报告》D-3/D-7。 +> 来源:《项目审查与优化建议报告》D-3/D-7(两条已销项,正文已移入 [`文档规范.md`](文档规范.md) 与 pre-commit 门禁)。 > **D-3 / D-6 / D-7 已处理完毕**(2026-09-25):全库链接存在性检查通过(66 文档 0 失效)、任务表与 > `Src/freertos.c` 一致、文档规范已建立。下表保留**仍需跟踪**的项。 | 问题 | 位置 | 说明 | |---|---|---| -| 指向未实现工具 | `UI_界面制作流程.md`、`UI_自动化测试流程.md` → `tools/ui_mock/` | 文档承诺了未实现的 UI 渲染/比对工具(其引用已按 §3.3 标注「已删除」状态词) | +| 指向未实现工具 | `UI_界面制作流程.md`、`UI_自动化测试流程.md` → `tools/ui_mock/` | 该目录**始终不存在**;golden 回归实际落在 `simulator/tests/`(四脚本)。引用待改指,见报告 §6.1 | | 产物缺失 | `内存管理/RAM使用分析与优化方案.md` → `scripts/ram_baseline.tsv` | 方案写了、工具未建(引用已加 `[计划]` 标记) | | ~~目录名含空格~~ | ~~`UI 设计/`~~ | ✅ 已修复(2026-09-25,D-6):改名为 `UI设计/`,全仓库 67 处引用同步;命名约定见 [`文档规范.md`](文档规范.md) §1 | | ~~缺一致性门禁~~ | — | ✅ 已落地(2026-09-25/26,D-7 三档齐):`scripts/check_doc_links.py`(存在性)+ `scripts/gen_task_table.py`(数值)+ `scripts/check_mem_numbers.py`(体积),接入 pre-commit `doc-links` / `task-table` / `mem-numbers` 钩子 | | ~~`docs/文档规范.md` 未创建~~ | — | ✅ 已创建(2026-09-25):[`文档规范.md`](文档规范.md) | -> 完整清单与建议见 [`问题记录/项目审查与优化建议报告.md`](问题记录/项目审查与优化建议报告.md) §3.3。 +> 未闭环的文档类问题见 [`问题记录/项目审查与优化建议报告.md`](问题记录/项目审查与优化建议报告.md) §3.1(体量与 D-1)与 §3.4(复核后仅剩三条:D-1、语义断链、元信息漂移)。 --- diff --git a/docs/UI设计/UI_自动化测试流程.md b/docs/UI设计/UI_自动化测试流程.md index e1f8d4ba..ab1a2b7a 100644 --- a/docs/UI设计/UI_自动化测试流程.md +++ b/docs/UI设计/UI_自动化测试流程.md @@ -25,7 +25,7 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 | 步 | 脚本/命令 | 门禁内容 | 预期输出 | |---|---|---|---| -| 1 | `simulator/tests/check_menu_rows.py` | 菜单 8 状态:选中行白带 / 非选行黑底白字 / 框体 1px 边框 / 往返像素一致(S3==S1、S6==S1、S7==S2)+ S0 池基线 | `PASS: 8 状态全部断言通过(8 图,池基线 4784 B)` | +| 1 | `simulator/tests/check_menu_rows.py` | 菜单 8 状态:选中行白带 / 非选行黑底白字 / 框体 1px 边框 / 往返像素一致(S3==S1、S6==S1、S7==S2)+ S0 池基线 | `PASS: 8 状态全部断言通过(8 图,池基线 5368 B)` | | 2 | `simulator/tests/check_content_pages.py` | ① 文本级断言(字形模板匹配,50 条,独立于 `ref/`)→ ② 11 页截图与 `ref/` 黄金参考**逐字节**比对 + 开页-关页往返池水位 | `PASS: 11 内容页 + 往返池水位全部通过`(文本断言先于像素比对,失败即中止) | | 3 | `keil-build` skill(`keil_build.py`) | MCU 端编译(UI 源码双端共用,改一端必须过另一端) | 退出码 0 = BUILD_OK | | 4 | `scripts/check_coding_standard.py` | 代码规范 V1.0 机械检查 | PASS | @@ -44,8 +44,12 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 | 常量 | 所在脚本 | 当前值 | 含义 | |---|---|---|---| -| `MENU_POOL_BASELINE` | check_menu_rows.py | **4784 B** | 菜单根框建完的 LVGL 池 used(x64 模拟器) | -| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | **4864 B** | 开页-关页往返后水位 = 基线 + 一次性残留 80 B | +| `MENU_POOL_BASELINE` | check_menu_rows.py | **5368 B** | 菜单根框建完的 LVGL 池 used(x64 模拟器) | +| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | **5448 B** | 开页-关页往返后水位 = 基线 + 一次性残留 80 B | + +> 水位历史:4784 →(2026-09-14 待机模块常驻 +656)5440 / 5520 →(2026-10-11 裁剪 LVGL 控件类 −72)5368 / 5448。 +> 后一次漂移已核实为**有意且更优**:像素逐字节未变(11 页黄金参考在裁剪**前**生成、裁剪后全部一致), +> 且一次性残留仍是 80 B(5448 − 5368)→ 泄漏特征未变,只是 `lv_theme_apply` 不再为已不存在的控件类登记样式条目。 **漂移处理**:菜单/页面结构或样式变更导致门禁失败时,先确认新值合理(残留分解见 `菜单页面设计.md` §9.5),再更新脚本常量并在本文变更记录登记。**禁止为过门禁盲目改常量**—— @@ -102,3 +106,4 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 | 2026-09-10 | 新增 `simulator/tests/regen_ref_png.py`(由 `ref/*.bin` 批量重生成目检 PNG,默认 `--scale 6 --invert`);`ref/*.png` 加入 `.gitignore`(本地调试产物不入库);§四 补"重生成目检 PNG"步骤(修复"刷了 `.bin` 参考但 `.png` 仍是旧渲染 → 看似乱码") | | 2026-09-10 | `simulator/tests/ref/` **整目录不入库**(`.bin` 黄金参考 + `.png` 目检图均为本地生成物):`git rm --cached` 原有 3 个 `.bin`,`.gitignore` 改为忽略整目录;首次/换机后先 `--update` 生成参考;`--update` 先跑文本断言防误刷;§四 重写 | | 2026-09-13 | 管线同步:`UI_界面制作流程.md` 移除 HTML 样机阶段(五阶段 → 四阶段 P0~P4),本文定位改为"P2/P3 门禁的可执行版本";§六 关系描述更新为 P0 规格 → P1 编码 → P2 本文回归 → P3 上机目检 → P4 归档;移除 `tools/ui_mock/` 引用 | +| 2026-10-11 | **裁剪 LVGL 死代码(F-1/F-2/F-3)时的门禁记录**:先用**未裁剪**配置 `--update` 生成 11 页黄金参考,再切到裁剪配置重编重跑 → **逐字节全部一致**(菜单 8 状态像素一致),证明裁剪零视觉影响。池水位门禁随之 5440/5520 → **5368/5448**(−72 B;一次性残留仍 80 B,故非泄漏变化,见 §三 水位历史)。顺带揪出并修掉 `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 的**长期漂移**(模拟器早已 I1-only、MCU 端未裁)→ 规则见 `UI_设计架构.md` §1.4 第 10 条,量化见 `../内存管理/RAM使用分析与优化方案.md` §2.6.4 | diff --git a/docs/UI设计/UI_设计架构.md b/docs/UI设计/UI_设计架构.md index b5bb1b5e..7cbee291 100644 --- a/docs/UI设计/UI_设计架构.md +++ b/docs/UI设计/UI_设计架构.md @@ -23,7 +23,7 @@ MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓 | `Drivers/BSP/UI/ui_net_sink.c/h` | **共用** | RemoDispBus 服务端(收键 + 回送帧缓冲),MCU/PC 双端同一份;平台后端 `net_socket` + `ui_net_sink_backend`(见 RemoDispBus 协议文档) | | `Drivers/BSP/UI/ui_font_cn_12.c` | **共用** | 中文 1bpp 子集字体(`lv_font_conv` 生成物) | | `App/task/ui_task.c/h` | MCU | UI 任务:等 NET_READY → 初始化 → 10ms 刷新循环 | -| `Lib/lv_conf.h` | MCU | LVGL 配置(1bpp / 40KB 内存池 / 默认字体) | +| `Lib/lv_conf.h` | MCU | LVGL 配置(1bpp / 40KB 内存池 / 默认字体);⚠ **必须与 `simulator/lv_conf.h` 同一特性子集**,见 §1.4 第 10 条 | | `simulator/`(main.c / sim/ / lv_conf.h / CMakeLists.txt) | PC | SDL2 模拟器(替代 ui_port,共用全部 UI 源码);支持 `--screenshot` / `--keys ` 无头截图验证 | | `tools/font_gen/`(extract_cn.py / make_font.py)+ `ui_cn_chars.txt` | 工具 | 字符集提取 / 字体生成 | | `tools/menu_gen/gen_menu.py` + `ui_menu_tree.txt` | 工具 | 菜单声明源 → 生成 ui_menu_tab.c/h | @@ -64,6 +64,15 @@ MCU **无物理屏幕**:LVGL 做 160×160 **1bpp 单色离屏渲染**,帧缓 `lv_display_set_theme()` + 对**活动屏幕**再 `lv_theme_apply()`(实测 `bg_before=F5F5F5` → `bg_after=000000`)。 另外 I1 缓冲头 8 B 调色板 LVGL 不替调用方初始化(上电全 0 = 两项透明黑),须显式写 `[0]=黑 [1]=白`; 渲染器本身按亮度阈值置位(`lv_draw_sw_blend_to_i1.c`:亮 → 位=1),与客户端"位=1 即亮"的约定一致。 +10. **两份 `lv_conf.h` 必须是同一特性子集(2026-10-11 定;F-1/F-2/F-3 落地时暴露的漂移)**:`Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是**两份独立文件**,此前已漂移——模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 全靠 `lv_conf_internal` 默认值隐式生效,把用不到的代码整份链进镜像 = **实测 64,576 B 死代码(旧镜像的 23.5%)**。现两端对齐为同一子集: + * 绘制核只留 `LV_DRAW_SW_SUPPORT_I1`(显示是 1bpp;字形 A8 位图是作为 **mask** 传入、混合按**目的**格式分派,故不需要 A8/L8/AL88); + * 控件全关(`label`/`button`/`buttonmatrix`/`bar`/`textarea`/`image`/`keyboard`)——全仓(`Drivers/BSP/UI`、`App`、`simulator`)**零控件构造函数**,只用 `lv_obj_create` + `lv_theme_mono_init`; + * `LV_USE_THEME_SIMPLE 0`:它是把上述控件类拉进镜像的**锚点**(`lv_display_create()` 会强链 `lv_theme_simple_init`);⚠ 9.3 **没有** `LV_USE_THEME_BASIC`,旧配置里那条是无效项,已删; + * `LV_USE_FLEX 0` / `LV_USE_GRID 0`:全仓无 `lv_obj_set_flex_*` / `lv_obj_set_grid_*` 调用。 + ⚠ 裁剪后 ARMCC 报 `#177-D`(上游 9.3.0 在 `lv_draw_sw_blend_to_i1.c` 漏了 `lv_color16_from_u16` 的定义处守卫)→ 本仓已按使用点补 `#if LV_DRAW_SW_SUPPORT_RGB565_SWAPPED`(原型 + 定义两处),升级 LVGL 时若上游已修可去掉。 + ⚠ 将来引入控件型页面(U2 原型库)时**两端同步**逐项打开并复跑 `ui-verify`。量化与验证记录见 `../内存管理/RAM使用分析与优化方案.md` §2.6.4。 + ✅ **已入门禁**(2026-10-11):`scripts/check_lv_conf_sync.py` = pre-commit 钩子 `lv-conf-sync`。比对的是**生效值**(文件未定义则取 `lv_conf_internal.h` 兜底默认),并抓"写了 LVGL 不认的宏名"与同文件重复定义。首次跑即揪出两端共 **26 个 9.3 不存在的选项**(LVGL 8 / 9.4 模板残留,与 `LV_USE_THEME_BASIC` 同类)、**3 处同文件重复定义**、**10 项 MCU 端靠默认值隐式生效的开关**(含默认 =1 的 `LV_USE_OBSERVER` / `LV_USE_DRAW_PXP` / `LV_USE_IMAGEBUTTON`)——已全部清理/显式固化。 + ✅ **门禁触发范围**(2026-10-11):`lv-conf-sync` 的 `files:` 同时匹配 `Lib/lv_conf.h`、**`simulator/lv_conf.h`** 与脚本自身 → **两端任一改动都会跑**。此前顶层 `exclude` 整目录挡掉 `simulator/`,模拟器侧改动拦不到;已删该条并把 `simulator/` 下自研代码整体纳入 pre-commit(cppcheck 当天即揪出 `sim_syslog.c` 一处真实空指针解引用),详见 `../代码规范/pre-commit使用说明.md` §5.5。 --- diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index a009a8e7..4334ec31 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -21,6 +21,7 @@ git commit ├─ doc-links 文档链接存在性(D-3/D-7,.md) ├─ task-table FreeRTOS 任务表一致性(D-2/D-7) ├─ mem-numbers 内存/Flash 数字一致性(D-7 体积档) + ├─ lv-conf-sync 两份 lv_conf.h 特性子集一致性(比对生效值) ├─ cubemx-survival CubeMX 重生成存活审计(易失定制清单 + 基线漂移) ├─ watchdog-unit-test 看门狗判定状态机主机侧单测(L0.5) └─ test-suite-switch 测试套件开关=产品构建(防测试固件静默提交) @@ -162,7 +163,7 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 | S8 | 4.1.1 | 禁止 Tab 缩进(4 空格) | | S9 | 5.3 | 函数定义必须有函数注释(含「函数功能」) | | S10 | 3.3.1 | 函数名必须全小写(HAL_ 厂商 API 回调名豁免) | -| S11 | 2.3.5 | 禁止相对/绝对路径 include | +| S11 | 2.3.5 | 禁止相对/绝对路径 include(2026-10-11 细化判定形态:禁的是"用路径找文件"——引号 include 带 `/`、任意形态带 `..` / `\` / 绝对路径或盘符;`` 这类**尖括号系统头允许带子目录**,由 `-I` 解析。原实现把任何含 `/` 的 include 一律判违规,误伤模拟器侧 2 处;全仓库复核:细化后仅这 2 处项目代码判定发生变化,其余 100 处均在已排除的 `Lib/lvgl/`) | | S12 | 7.6 | 禁止可变参数函数(`...`) | | S13 | 7.7 | 禁止递归函数 | | S15 | 6.1.4 | 文件作用域指针变量必须初始化为 NULL | @@ -188,7 +189,8 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 **豁免**(不算失效):`[计划]` 前缀;该行含 `[示例]` 标记(引用报错样例/日志片段时用);引用**附近 ±40 字符**内的状态词(未创建/尚未创建/已删除/待排期);占位符(`*`、`<模块>`、`NN_`、`${...}`);**行内代码 span 里的链接写法**(`![标题](相对路径.svg)` 这类语法示例);外部 URL 与纯锚点;`docs/芯片手册/` 等外部资料目录。 -**排除文件**:`.opencode/node_modules/`、`Lib/lvgl/`、`simulator/`、`MDK-ARM/RTE/`、`docs/芯片手册/`。 +**排除文件**:`.opencode/node_modules/`、`Lib/lvgl/`、`MDK-ARM/RTE/`、`docs/芯片手册/`。 +(`simulator/` 已于 2026-10-11 从排除列表移除——其下 3 个 README 是本项目文档,见 §5。) ```powershell # 全库扫描(开发时自检) @@ -318,6 +320,20 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 | **典型报错** | `T1 Title exceeds max length (159>100)` | | **修复方式** | 精简 commit message 标题到 100 字符以内,或拆分为多行(标题+空行+body) | +### 2.15 lv-conf-sync — 两份 `lv_conf.h` 特性子集一致性(2026-10-11 加) + +| 项 | 说明 | +|---|---| +| **背景** | `Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是**两份独立文件**,长期漂移:模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 靠 `lv_conf_internal.h` 默认值隐式生效 → 固件白链 **64,576 B 死代码**;更糟的是模拟器 golden 回归跑的是另一套特性子集,**验证不到固件实际编译的配置** | +| **触发条件** | `Lib/lv_conf.h` 或 `scripts/check_lv_conf_sync.py` 变动 | +| **判定口径** | 比对**生效值**:文件里没定义的宏,取 `Lib/lvgl/src/lv_conf_internal.h` 的兜底默认。⚠ 该文件每个选项都先套一层 `#ifdef LV_KCONFIG_PRESENT` → `CONFIG_LV_*` 分支**不是**默认值,脚本取块内**非 `CONFIG_` 的最后一个字面量**(首版按"紧跟 `#ifndef` 的那行"取,结果整表都是 `CONFIG_*`,教训) | +| **四类问题** | `MISMATCH` 生效值不同 → FAIL;`INVALID` 写了 LVGL 不认的宏名 → FAIL(`LV_USE_THEME_BASIC` 同类);`DUPLICATE` 同文件定义两次 → FAIL(后者生效,极易误读);`IMPLICIT` 一端靠默认值 → 警告(默认值随 LVGL 版本变,建议显式固化) | +| **端专属白名单** | SDL2 驱动 / 演示工程 / include 替换 / 调试辅助(`LV_USE_OBJ_NAME` 等)/ 断言(固件关、模拟器开)/ `LV_USE_SNAPSHOT`(模拟器 `--screenshot` 需要)——见脚本 `IGNORE_PREFIX` 与 `ALLOW_DIFF`(后者必须写理由) | +| **典型报错** | `FAIL: N 项不一致` + `[MISMATCH] LV_USE_OBSERVER MCU=1(默认) vs SIM=0(显式)` | +| **修复方式** | 把两端拉到同一生效值(**优先显式写死**,别依赖默认),或在 `ALLOW_DIFF` 里登记理由 | +| **触发范围** | `Lib/lv_conf.h`、**`simulator/lv_conf.h`** 或脚本自身变动 → 两端任一改动都会跑。⚠ 早期版本只能由 MCU 端触发,因为顶层 `exclude` 整目录挡掉了 `simulator/`(已删,见 §5);pre-commit 4.x 又移除了 `always: true`(实测对 `local` 钩子只告警并跳过),所以没有"总是跑"的兜底写法——**必须让文件模式覆盖两端** | +| **首次落地实测** | 两端共清掉 **26 个 9.3 不存在的选项**(LVGL 8 / 9.4 模板残留)、3 处同文件重复定义,并把 10 项 MCU 端靠默认值隐式生效的开关显式固化为 0(含默认 =1 的 `LV_USE_OBSERVER` / `LV_USE_DRAW_PXP` / `LV_USE_IMAGEBUTTON`)。本次清理对镜像体积**中性**(那些 TU 本就不在 uvprojx,或编成 0 字节空对象),价值在防漂移与可读性 | + --- ## 3. 日常工作流 @@ -425,13 +441,27 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env **计划**:Route B —— 全库统一格式化后形成独立基线 commit,再启用 clang-format 钩子。启用时取消 `.pre-commit-config.yaml` 中的注释即可。 +### 5.5 顶层 `exclude` 曾整目录豁免 `simulator/`(2026-10-11 已修) + +**现象**:改 `simulator/` 下任何文件(含 `simulator/lv_conf.h`)的提交**一个钩子都不跑**——`pre-commit run --files simulator/lv_conf.h` 里所有钩子都是 `(no files to check) Skipped`。 + +**根因**:顶层 `exclude` 写了 `simulator/`。当初是为了挡 SDL2 预编译库、`build/`、截图产物,但这些**已在 `.gitignore` 里**,本就进不了 git 索引;pre-commit 只看索引中的文件,所以那条豁免唯一实际效果是把**本项目自研的 21 个 tracked 文件**(`main.c`、`sim/*.c|h`、`tests/*.py`、`lv_conf.h`、`dbg_log.h`、3 个 README)整体挡在门禁之外。 + +**为什么必须修**:模拟器与固件**共用同一套 UI 源码**,且它是 UI 回归唯一的像素级证据来源——它落在门禁外,等于验证链的地基不受检。 + +**纳入后的实测收益(当天)**:`cppcheck` 立刻揪出 `sim_syslog.c` 一处**真实空指针解引用**(`if (p_epoch == NULL || …) { *p_epoch = 0u; … }`——判空分支里紧接着解引用),另有 2 处 `localtime_s` 未初始化告警、1 处 `px` 可加 `const`;`coding-standard` 报出 61 处(44 处函数左大括号 Allman 风格、8 处缺函数注释、3 处缺文件头注释、2 处 `__` 前缀保护宏、2 处 `sprintf`、2 处 S11 误报),已全部处理。 + +**顺带修掉的规则缺陷**:S11 原实现把**任何含 `/` 的 include** 判违规,误伤 `` 这类系统头;已按 include 形态细化,判定见 §2.8。 + +**教训**:`exclude` 只写**真正不该进钩子的路径**。已被 `.gitignore` 覆盖的生成物不需要再挡一遍——写上去只会连带豁免同一目录里的自研代码。 + --- ## 6. 文件清单 | 文件 | 位置 | 说明 | |---|---|---| -| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(15 个钩子 + 1 个预留) | +| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**26 个钩子**,2026-10-11 清点;本文 §2 详解非单测类,主机侧单测 10 个见 AGENTS.md 门禁条) | | `.gitlint` | 仓库根 | gitlint 规则(宽松,兼容中文) | | `.cubemx_survival_baseline.json` | 仓库根 | CubeMX 易失定制基线(哈希;`--write` 重建) | | `scripts/check_mojibake.py` | scripts/ | U+FFFD 检测脚本 | @@ -440,6 +470,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | `scripts/gen_task_table.py` | scripts/ | FreeRTOS 任务表生成/校验(D-2/D-7) | | `scripts/check_mem_numbers.py` | scripts/ | 内存/Flash 数字一致性(D-7 体积档) | | `scripts/check_cubemx_survival.py` | scripts/ | CubeMX 重生成存活审计 | +| `scripts/check_lv_conf_sync.py` | scripts/ | 两份 `lv_conf.h` 特性子集一致性(比对生效值 + 无效宏名 + 重复定义) | | `scripts/check_test_suite.py` | scripts/ | 测试套件开关门禁(防测试固件静默提交) | | `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | docs/代码规范/ | 代码规范原文(S1~S19 规则来源) | | `docs/代码规范/cppcheck使用说明.md` | docs/代码规范/ | cppcheck 钩子详解(规则/配置/抑制) | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 97d0d1c8..5f8f31b0 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 274,728 B (268.29 KB) | 1,048,576 B (1024 KB) | 26.2% | -| RW_IRAM1 | 91,544 B (89.40 KB) | 131,072 B (128 KB) | 69.8% | +| ER_IROM1 | 210,148 B (205.22 KB) | 1,048,576 B (1024 KB) | 20.0% | +| RW_IRAM1 | 91,536 B (89.39 KB) | 131,072 B (128 KB) | 69.8% | | RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | -| **Flash (ROM 合计)** | **275,608 B (269.1 KB)** | 1,048,576 B (1024 KB) | **26.3%** | +| **Flash (ROM 合计)** | **211,024 B (206.1 KB)** | 1,048,576 B (1024 KB) | **20.1%** | 各区的容量与常量来源: @@ -176,13 +176,19 @@ | 段 | 大小 (B) | 占比(对 1 MB) | |---|---|---| -| Code (inc. data) | 246,456 | 23.5% | -| RO Data | 15,476 | 1.5% | -| RW 初值 | 728 | — | -| **合计 ROM** | **262,660** | **25.0%**(余 ~786 KB) | +| Code (inc. data) | 194,276 | 18.5% | +| RO Data | 15,872 | 1.5% | +| RW 初值 | 876 | — | +| **合计 ROM** | **211,024** | **20.1%**(余 ~813 KB) | > 本表随代码变化;权威数字与占比见 §2.1 受管快照(`ER_IROM1` / **Flash (ROM 合计)** 行)。 -> 历史快照:2026-09-21 为 Code 234,976 / RO 13,824 / 合计 ROM 249,432(23.8%)。 +> 历史快照:2026-09-21 为 Code 234,976 / RO 13,824 / 合计 ROM 249,432(23.8%);2026-10-10 为 Code 258,516 / RO 16,208 / 合计 ROM 275,604(26.3%)。 + +> **2026-10-11 裁剪 LVGL 死代码(原审查报告 F-1/F-2/F-3,已销项)**:`Lib/lv_conf.h` 显式关掉 10 个 `LV_DRAW_SW_SUPPORT_*`(只留 I1)、5 个未用控件类(label / button / buttonmatrix / bar / textarea)、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX` / `LV_USE_GRID` → **`ER_IROM1` 274,724 → 210,148 B(省 64,576 B = 旧镜像的 23.5%)**,Flash 占比 26.3% → **20.1%**。 +> 对象级对照(`.map` 组件表,Code+RO+RW):`blend_to_*` 40,582 → 1,338(其中 `blend_to_i1` 自身 5,272 → 1,338——它按**源**格式的分支也一并消失);控件类 13,397 → 0;`lv_theme_simple` 866 → 0;`lv_draw_sw_transform` 4,340 → 598;`lv_theme_mono` 1,400 → 1,036;镜像内对象数 227 → 212。实测比原报告估算(54,630 B)多省约 10 KB,差额正是这些"锚点消失后连带消失的分支"。 +> ⚠ **配套本地补丁**:`lv_draw_sw_blend_to_i1.c` 的 `lv_color16_from_u16` 上游 9.3.0 漏了定义处守卫(唯一使用点全在 `#if LV_DRAW_SW_SUPPORT_RGB565_SWAPPED` 内)→ 关掉该格式后 ARMCC 报 `#177-D declared but never referenced`(破坏"0 警告"门禁)。已按使用点补同一守卫,升级 LVGL 时若上游已修可去掉。 +> 验证:ui-verify 五步全过——**11 页内容黄金参考在裁剪前生成、裁剪后逐字节一致**,菜单 8 状态像素一致,LVGL 池水位 5,440 → 5,368 B(−72 B,样式表更小,一次性残留仍 80 B);`@build` 0 错 0 警;`@test` 真机 PASS。详见 `../UI设计/UI_设计架构.md` §1.4 第 10 条。 +> **长效防复发**:已建门禁 `scripts/check_lv_conf_sync.py`(pre-commit 钩子 `lv-conf-sync`)——比对两份 `lv_conf.h` 的**生效值**(未定义则取 `lv_conf_internal.h` 兜底默认),并抓无效宏名与重复定义;首次跑揪出两端共 26 个 9.3 不存在的选项、3 处重复定义、10 项靠默认值隐式生效的开关,已清理(本次清理对镜像体积中性)。详解见 `../代码规范/pre-commit使用说明.md` §2.15。 ![Flash 布局](../images/内存布局_FLASH.svg) diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index 85738eb4..44b4d2f3 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -12,7 +12,7 @@ > [`../测试说明/实机测试实施指南.md`](../测试说明/实机测试实施指南.md)(故障注入台架流程)、 > [`../架构设计/项目启动顺序管理.md`](../架构设计/项目启动顺序管理.md)(启动时序与就绪屏障)、 > [`../测试说明/ST-LINK_CLI_复位使用说明.md`](../测试说明/ST-LINK_CLI_复位使用说明.md)(复位语义)、 -> [`../问题记录/项目审查与优化建议报告.md`](../问题记录/项目审查与优化建议报告.md)(N-03 原始登记,已销项) +> [`../问题记录/项目审查与优化建议报告.md`](../问题记录/项目审查与优化建议报告.md)(N-03 的立项出处;该条已修复并按「修复即销项」从报告删除,**完整记录即本文**) --- @@ -55,7 +55,7 @@ ### 1.3 立项前状态(历史,2026-09-29) -当时全库无 IWDG/WWDG 初始化、HAL IWDG 驱动源码不在仓库、`.ioc` 无看门狗条目、无复位原因读取——缺陷登记为 N-03(`../问题记录/项目审查与优化建议报告.md`,已销项)。**该节只作立项背景**:现状是 IWDG 已启用(`.ioc` + `HAL_IWDG_MODULE_ENABLED` + 驱动已挂载),复位原因每次开机都在上报。 +当时全库无 IWDG/WWDG 初始化、HAL IWDG 驱动源码不在仓库、`.ioc` 无看门狗条目、无复位原因读取——缺陷登记为 N-03(原登记于《项目审查与优化建议报告》,已修复并按销项删除正文)。**该节只作立项背景**:现状是 IWDG 已启用(`.ioc` + `HAL_IWDG_MODULE_ENABLED` + 驱动已挂载),复位原因每次开机都在上报。 --- @@ -623,7 +623,7 @@ enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的 | `Inc/FreeRTOSConfig.h:165` | `configASSERT` → `vApplicationAssertFailed` | | `App/task/net_task.c` / `default_task.c` / `sync/time_sync.c` / `NET/lftpd/*` / `GD5F2GQ5UE/gd5f2gq5ue.c` | 心跳与喂狗埋点(见 §5.5) | -> 立项时(2026-09-29)的"仓库里根本没有 IWDG 驱动"一类取证已随实施失效,不再列出;当时的原始登记见 `../问题记录/项目审查与优化建议报告.md` §4.1(N-03,已销项)。 +> 立项时(2026-09-29)的"仓库里根本没有 IWDG 驱动"一类取证已随实施失效,不再列出;当时的原始登记在《项目审查与优化建议报告》§4.1,已随「修复即销项」删除——**取证与实施记录以本文为准**。 ## 附录 B:备选方案取舍记录 diff --git a/docs/子系统设计/配置文件设计说明.md b/docs/子系统设计/配置文件设计说明.md index 291d1f92..09a59a99 100644 --- a/docs/子系统设计/配置文件设计说明.md +++ b/docs/子系统设计/配置文件设计说明.md @@ -456,7 +456,7 @@ void adc_cfg_init(void) **P0-1(2026-09-18)实现纠偏**:本表原设计即"暂态走堆",但实现落在 C 库 `malloc` 上——本机工具链为 **MicroLIB**,其 C 堆只有 `Heap_Size = 0x200`(512 B),真机上 4 处 KB 级分配**必然返回 NULL**,症状是"配置读不到 → 静默回落工厂默认值"(模拟器用宿主 CRT 堆,故两端表现不一致)。现全部改走 `cfg_store_alloc`;map 复核:`malloc.o` 不再参与链接,`Removing startup_stm32f407xx.o(HEAP), (512 bytes)` → **静态区反而净增 512 B 余量**。 -**结论**:配置引擎自身不需要内存优化专项;但 9,217 B 暂态峰值已占 FreeRTOS 堆空闲的一半以上,且与 lftpd 的每命令分配共存——**流式读写(免整文件驻留内存)仍列为后续优化项**,方案见《项目审查与优化建议报告》§4.4.1 与 §5.3。 +**结论**:配置引擎自身不需要内存优化专项;但 9,217 B 暂态峰值已占 FreeRTOS 堆空闲的一半以上,且与 lftpd 的每命令分配共存——**流式读写(免整文件驻留内存)仍列为后续优化项**,方案见本文 §6.2/§8 与《项目审查与优化建议报告》§5.3(原报告 §4.4.1 已随 N-01 修复按销项删除,完整记录沉淀在本文)。 ## 9. 兼容与迁移 diff --git a/docs/算法/继电保护功能复现规划.md b/docs/算法/继电保护功能复现规划.md index 23925208..715aa8c8 100644 --- a/docs/算法/继电保护功能复现规划.md +++ b/docs/算法/继电保护功能复现规划.md @@ -4,7 +4,7 @@ > **参考来源**:`D:\Code\DTU\参考资料\huaiyuan\huaiyuan\Scatter_P_HV03.01_SV01.01\项目分析文档\04-算法与采样专题\ADC采集至数据使用全流程详解`(同专题另有《项目详细分析-交流采样与有效值计算》《傅氏算法详解》《项目详细分析-阶段6-继电器保护单元分析》)。 > **阅读前提**:先读 `docs/算法/傅氏算法详解.md`(算法原理)与 `docs/硬件平台/ADC通道映射说明.md`(采样通道权威口径)。 > **相关文档**:`docs/产品需求/产品需求设计文档.md`、`docs/待处理内容.md`、`docs/内存管理/RAM使用分析与优化方案.md`。 -> **状态**:**P0 采样与相量底座已台架闭环(2026-10-11)**——L1 采集链路(B1,2026-10-10)与 L2 相量层(`sample_ring` + `phasor`,57 项主机单测 + 台架 `@test` 含 `phasor(first)/phasor(hb)` 标记闭环)均已落地;**L3 保护元件 / L4 出口记录 / L5 自检闭锁与定值模型尚未实现**(下一步 = P1,见 §7)。 +> **状态**:**P0 采样与相量底座已台架闭环(2026-10-11)**——L1 采集链路(B1,2026-10-10)、L1-6 采样有效性校验与 L2 相量层(`sample_ring` + `phasor`,57 项主机单测 + 台架 `@test` 含 `phasor(first)/phasor(hb)` 标记闭环)均已落地;**P1/P2 保护元件与定值模型软件侧完成(2026-10-11,`prot_set` + `prot_elm`,57 项主机单测)**,板端接线(protectTask)与出口记录(L4)为下一步(见 §7)。 --- @@ -38,7 +38,7 @@ | 已实现子系统 | 网络(CH395F)、存储(GD5F2GQ5UE SPI NAND + dhara FTL + FatFs)、时间(SD2506 RTC + `sys_clock`)、配置(cfg_file)、日志、UI(远端显示)、看门狗(IWDG)、FTP | | 采样硬件 | TPAFE5160(16 位、8 通道**同步**、±5 V 双极性、并行 `GPIOG[15:0]`)+ STM32 内部 ADC1(12 位、9 通道**顺序**扫描、0~3.3 V 单极性) | | 采样软件 | `Drivers/BSP/TPAFE5160/tpafe5160.h` 驱动就绪(原子快照 API);`App/task/adc_task.c` **已实现真实消费者(B1,2026-10-10)**——`tpafe5160_snapshot()` 取数 + 帧统计;**采样节拍由 TIM3 更新事件(1 kHz)硬件触发**(2026-10-10;同一事件经 TRGO 触发 ADC1 + DMA);STM32 侧 **`App/algo/adc_input.c` 已实现语义映射 + ADC1 DMA 乒乓帧消费(L1-3/L1-4,v2.0)**,`HAL_ADC_Start_DMA` 已出现 | -| 保护算法层 | **L2 相量层已实现并台架闭环**(`App/algo/sample_ring.c` + `phasor.c`:全周傅氏 5 实例、双区交换、每 1 ms 刷新,2026-10-11 `@test`);**保护元件 / 出口 / 定值模型仍缺失**(P1 起) | +| 保护算法层 | **L2 相量层已实现并台架闭环**(`App/algo/sample_ring.c` + `phasor.c`:全周傅氏 5 实例、双区交换、每 1 ms 刷新,2026-10-11 `@test`);**L3 元件框架 + 过流/过负荷/过压/欠压/零序过压 + 定值模型软件侧完成**(`prot_elm.c` + `prot_set.c`,2026-10-11,57 项主机单测);**出口 / SOE 记录仍缺失**(P4) | | 已知硬件缺陷 | **N-18**:8 路电气量分属两片转换器 → V0.1 上禁止跨转换器做相位/功率/矢量运算 | ### 1.3 复现范围界定 @@ -277,12 +277,12 @@ | # | 元件 | 判据要点 | 关键定值 | 状态 | | --- | --- | --- | --- | --- | -| L3-1 | 三段式相间过流 | 相电流基波 > 定值,经延时出口;返回系数滞回 | Ⅰ/Ⅱ/Ⅲ 段电流 + 延时 | ⬜ | -| L3-2 | 三段式零序过流 | 零序电流 > 定值,经延时出口 | Ⅰ/Ⅱ/Ⅲ 段零序电流 + 延时 | ⬜ | -| L3-3 | 过负荷 | 相电流 > 定值,告警/跳闸可整定 | 过负荷电流 + 延时 | ⬜ | -| L3-4 | 过压 / 欠压 | 相/线电压越限,经延时出口 | 过压定值、失压定值 + 延时 | ⬜ | -| L3-5 | 过频 / 低频 | 频率越限,经延时出口 | 过频、低频定值 + 延时 | ⬜ | -| L3-6 | 零序过压 | 零序电压 > 定值 | 零序过压定值 + 延时 | ⬜ | +| L3-1 | 三段式相间过流 | 相电流基波 > 定值,经延时出口;返回系数滞回 | Ⅰ/Ⅱ/Ⅲ 段电流 + 延时 | 🔶 2026-10-11 主机实现:`App/algo/prot_elm.c` 通用"滞回+定时限"核 + `prot_set.c` 定值(Ⅰ/Ⅱ/Ⅲ 段,A 相 `IA`),单测 27 用例含 1.01× 启动/100 ms 到点/滞回保持/返回释放;**板端接线与出口待 protectTask 立项** | +| L3-2 | 三段式零序过流 | 零序电流 > 定值,经延时出口 | Ⅰ/Ⅱ/Ⅲ 段零序电流 + 延时 | ⬜(N-18:`IN` 走 STM32,V0.2 使能;框架已就绪,加实例表一行即可) | +| L3-3 | 过负荷 | 相电流 > 定值,告警/跳闸可整定 | 过负荷电流 + 延时 | 🔶 同 L3-1(`OL-IA` 实例,默认告警不跳闸) | +| L3-4 | 过压 / 欠压 | 相/线电压越限,经延时出口 | 过压定值、失压定值 + 延时 | 🔶 同 L3-1(`OV-UA`/`UV-UA`,欠量方向单测覆盖;沿用参考告警段 0x8027/0x8029) | +| L3-5 | 过频 / 低频 | 频率越限,经延时出口 | 过频、低频定值 + 延时 | ⬜(随频率测量算法 P2;框架已就绪) | +| L3-6 | 零序过压 | 零序电压 > 定值 | 零序过压定值 + 延时 | 🔶 同 L3-1(`3U0-UN` 实例,外部零序电压通道,不受 N-18 限制) | **扩展集(P2/P3)** @@ -320,10 +320,10 @@ | # | 功能点 | 说明 | 状态 | | --- | --- | --- | --- | -| L5-1 | 定值校验和 | `tagPRS_XXX` 的 `dwTagSum` 自检,定值非法闭锁 | ⬜ | -| L5-2 | 采样通道自检 | 通道码值越界/静默检出 | ⬜ | +| L5-1 | 定值校验和 | `tagPRS_XXX` 的 `dwTagSum` 自检,定值非法闭锁 | 🔶 2026-10-11:`prot_set.c` 已实现 sum32 正/反码封存 + selfcheck 失配置闭锁(参考 `dwTagSum/dwTagSumN` 模式);元件级 tagPRS 常量表校验随板端接线并入 | +| L5-2 | 采样通道自检 | 通道码值越界/静默检出 | 🔶 静默/丢帧/序号倒退/错误增量已由 L1-6 `adc_health` 检出(2026-10-11);码值越界待做 | | L5-3 | 硬件自检 | 出口回路、电源、时钟 | ⬜ | -| L5-4 | 自检失败闭锁 | 任一项失败 → 闭锁相关保护并告警 | ⬜ | +| L5-4 | 自检失败闭锁 | 任一项失败 → 闭锁相关保护并告警 | 🔶 定值闭锁 → 全元件复位已联动(`prot_elm_eval` 读 `prot_set_locked()`,单测覆盖);采样故障闭锁(`adc_health_flags` 电平位 → 元件使锁)随板端接线 | --- @@ -408,6 +408,8 @@ | 公共 | 返回系数、PT/CT 变比、通道标定系数 | — | > ⚠️ **待补**:逐项**取值范围 / 步长 / 默认值**取自参考项目 `APP/EquipCfg/Const_CommPara.c` 的 `CommPara_ConfigTable`(`0x8020~0x8882`);本表在实现各保护元件时逐项落实(见 §10.3)。 +> +> **2026-10-11 落实进度**:`App/algo/prot_set.c` 已建 RAM 模型(Q10 定点,31 项),**通讯地址逐项对照参考 `Comm/Pub/Const_CommSetPara.c` `g_tCommSetParaTab`**(基址 0x8820:过流 Ⅰ/Ⅱ/Ⅲ = 0x8856~0x8861、过负荷 = 0x8871~0x8874、零序过压 = 0x888E~0x8890;过压/欠压沿用告警段 0x8027~0x802A)。⚠ **默认值/范围为拟定值**(参考 EEPROM 出厂默认表不在本仓库,按 DTU 馈线典型量级拟定),待对照参考出厂表逐项修订。 --- @@ -464,8 +466,8 @@ | 阶段 | 名称 | 内容 | 前置 | 里程碑 | | --- | --- | --- | --- | --- | | **P0** | 采样与相量底座 | B1(周期 CONVST + `adcTask` 消费 + 建任务 + 栈预算)+ L1 + L2 | B0 + B1 ✅ | 每 1 ms 产出正确的基波相量 —— **✅ 2026-10-11 台架闭环**(L1-6 同日落地 ✅;L1-5 标定 / L1-7 等周波为 P0 遗留项,精度复核依赖标准源) | -| **P1** | 基础保护元件 | **V0.1 有效子集**:过流(A 相,`IA`)、过负荷(A 相)+ 定值模型;B/C 相过流**建接口不使能** | P0 | 标准源注入 A 相可正确动作 | -| **P2** | 电压/频率类保护 | 过压/欠压(`UA`)、过频/低频、零序过压(`UN`) | P0 | 电压/频率越限正确动作 | +| **P1** | 基础保护元件 | **V0.1 有效子集**:过流(A 相,`IA`)、过负荷(A 相)+ 定值模型;B/C 相过流**建接口不使能** | P0 | 标准源注入 A 相可正确动作 —— 🔶 **软件侧 2026-10-11 完成**(`prot_set` 定值模型 30 用例 + `prot_elm` 元件框架/过流三段/过负荷 27 用例,主机全绿);板端接线(protectTask 立项)与标准源注入待做 | +| **P2** | 电压/频率类保护 | 过压/欠压(`UA`)、过频/低频、零序过压(`UN`) | P0 | 电压/频率越限正确动作 —— 🔶 过压/欠压/零序过压随 P1 同批主机完成(同框架实例);**过频/低频待频率测量算法** | | **P3** | 异常检测与闭锁 | L5 自检闭锁、采样通道自检;三相类(涌流/PT·CT 断线/母线接地)**建接口不使能** | P1/P2 | 自检失败闭锁;未使能元件不误动 | | **P4** | 出口、记录与定值集成 | L4 出口与记录、§5 定值模型落 cfg_file、UI 显示、上送 | P1 | 动作出口 + SOE 记录 + UI 显示闭环 | | **P5** | 方向/功率类(延后) | L3-13 方向元件等 | V0.2 硬件 / 子板计量 | 跨转换器相位一致后实现 | @@ -603,3 +605,4 @@ B0 产品定义 ✅(2026-10-10 已确认,见 §1.5) | 2026-10-10 | **L1-1/L1-3 节拍源与搬运方式升级(v2.8)** | 节拍源 **TIM6 → TIM3**(更正硬事实:F4 上 ADC1 的外部触发清单里没有 TIM6/TIM7 TRGO,只接 DAC),TIM3 更新事件同时触发 TPAFE CONVST 与(经 TRGO)ADC1 一轮 9 通道;L1-3 由"软件触发 + 逐通道轮询"改为 **DMA2_Stream4 循环搬运 + 乒乓双缓冲**,`stm32_scan()` → `stm32_take()`,单拍 `adcp` **122 µs → 8 µs**。§1.4 采样软件行、§3.2 L1-1/L1-3/L1-4 同步。完整设计与实测见 `docs/子系统设计/ADC采样节拍设计说明.md` §11 | | 2026-10-11 | **L2 台架闭环:P0 收尾** | `@build` 0 错 0 警 → `@test` 闭环把 `phasor(first)`/`phasor(hb)` 列为 Required 并校验顺序 `App init OK>phasor(first)>phasor(hb)`(⚠ `net init: OK` **不在**该序内——adcTask 与 netTask 并行,`phasor(first)` ≈+500 ms 早于 `net init: OK` ≈+574 ms 属正常)→ **PASS**:5 实例相量开机即产出、心跳 5 实例持续刷新、`frames` 每 10 s +10000(1000.0 Hz)、`idle=0`、`frst_err=0`、`adc1_err=0`、`adc1_hw=0`、`fw build` 身份戳匹配、回读一致、锚点 ×1、零 `[ERR]`/HardFault;证据 `boot_log_data/20261011_prod_phasor_boot_log.txt`。栈复测:adcTask 静态 832 B / 实测峰值 **928/2048(余 54%)**,7 任务全 PASS(RAM 方案 §2.3 已同步)。§3.1 L1-9 置 ✅(`C04-IA-F2`/`C05-IA-F5` 台架产出);§3.2 L2-1~L2-4/L2-6/L2-7 置 ✅(精度判据保留"待标准源"注),**L2-5 更正为"V0.1 不适用、随 V0.2"**(跨转换器合成被 N-18 禁止、零序走外部专用通道——原表下注"L2-1~L2-7 实现完成"对 L2-5 系误记);§7.1 P0 里程碑置 ✅(遗留 L1-5/L1-6/L1-7)。下一步 = **P1**(A 相过流/过负荷 + 定值模型)与 **L1-6 采样有效性校验** | | 2026-10-11 | **L1-6 采样有效性校验落地** | 新增 `App/algo/adc_health.c/h`(**纯逻辑判定层**,零平台依赖免桩单测):① 静默——两侧帧序号连续 ≥100 ms 无前进(电平位,可自愈;`adc_health_flags()` 为 L5-4 闭锁预留);② 丢帧——TPAFE 序号一次前进 >1 计 `tgap`(与 STM32 侧 `adc1_gap` 对称,边沿事件不告警——消费者超拍是已知现象,告警会淹没真故障);③ 单调性——序号倒退锁存位;④ 错误增量——FRSTDATA / HAL 错误回调 / STM32 停摆计数增量(事件位与锁存位**编号对齐**,消费者按位图"每类每次开机留痕一次")。`adc_task.c` 只做告警编排:严重事件/上升沿 `DBG_ERROR` + `SYSLOG_SELFTEST`("adc sampling fault"),下降沿 `DBG_INFO`,心跳行增 `tgap`。主机单测 `test/host/adc_health/` **31 用例全绿**(100 ms 门限两侧、tick 回绕、边沿语义、NULL 守卫、init 幂等;`adc-health-unit-test` 钩子登记)。台架 `@test` PASS:零 `[ERR]`(健康不误报)、`tgap=1→2` 与 `adc1_gap` 同步可见、`frames` 1000.0 Hz;证据 `boot_log_data/20261011_prod_adc_health_boot_log.txt`。⚠ **抬栈**:`adc_health_report`→`syslog_post` 控制台回显尾使 adcTask 静态最坏链 832→**1080 B**(512 words 余 968 B < 1024 B 底线)→ **512→640 words**,复测余 **1480 B / 57%**、堆 min_ever 7152→6640 B(AGENTS 任务表 / RAM 方案 §2.3 已同步)。§3.1 L1-6 置 ✅ | +| 2026-10-11 | **P1/P2 软件侧:定值模型 + 保护元件框架** | 新增 `App/algo/prot_set.c/h`(**定值模型**:Q10 定点与参考 2^10 口径一致;31 项元数据表**通讯地址逐项对照参考 `Comm/Pub/Const_CommSetPara.c`**——过流 Ⅰ/Ⅱ/Ⅲ = 0x8856~0x8861、过负荷 = 0x8871~0x8874、零序过压 = 0x888E~0x8890、过压/欠压沿用告警段 0x8027~0x802A;范围校验 + 整表装载 all-or-nothing + **sum32 正/反码封存自检闭锁**(参考 `dwTagSum/dwTagSumN` 模式,L5-1);⚠ 默认值/范围为**拟定**,参考 EEPROM 出厂表不在本仓库,待对照修订)+ `App/algo/prot_elm.c/h`(**元件框架**:通用"越限 + 返回系数滞回 + 定时限"核,数据驱动实例表 7 行 = OC1/OC2/OC3/OL(IA)+ OV/UV(UA)+ 3U0(UN);`prot_elm_eval()` 纯判据可主机单测,`prot_elm_run()` 为 phasor→测量薄适配(相量未就绪整拍跳过);定值闭锁 → 全元件复位)。主机单测 `test/host/prot_set/`(30 用例)+ `test/host/prot_elm/`(27 用例:1.01× 启动 / 100 ms 到点 / 滞回保持与释放 / 欠量方向 / 投退 / 闭锁联动 / 时基回绕)全绿,钩子 `prot-set-unit-test` / `prot-elm-unit-test` 登记。`@build` 0 错 0 警(**未接线 → 链接器整对象移除,零 Flash/RAM 足迹**);板端接线随 protectTask 立项(栈预算 + IWDG 门禁),标准源注入验收随其后。§3.3 L3-1/3/4/6 置 🔶、L3-2/5 注明框架就绪待通道/频率算法;§3.5 L5-1/2/4 置 🔶;§5.4 落实进度注 | diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index 1452644b..c418b886 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -5,39 +5,44 @@ | 审查日期 | 2026-09-18 | | 代码基线 | `HEAD = 2d0ec35`(工作树干净,98+ 提交) | | 固件基线 | `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map`,构建于 2026-09-16 12:33,0 Error / 0 Warning | +| **现状复核** | **2026-10-11**,复核基线 `HEAD = 54d6d2d`(分支领先 `origin/v2-on-ui`,未推送);固件基线换为 2026-10-11 00:38 链接的 `.map`。⚠ 复核期间分支又前进(ADC L2 相量层 / L1-6 有效性校验 / `adcTask` 抬栈),其数字影响已在 §5.1"基线口径说明"逐项量化,**不改变任何结论** | +| **销项状态** | 本报告采用**修复即销项**:已修复项的正文与表格行已删除(**874 → 733 行**,114 KB → 84 KB),去处清单见文末**变更记录**;未闭环清单见 **§0.2 / §0.4** | | 审查范围 | 仓库全量(2,013 个受版本控制文件);重点为自研代码 24,353 行(108 文件)与构建产物内存画像 | | 审查方式 | 静态审查 + 链接图(.map)量化分析 + 构建脚本/门禁核对;**未做上机动态验证**(除特别标注外) | | 目标 | 结构合理性、文档分布合理性、模块设计合理性、改进路线图、内存优化、UI 标准化 | > **与既有文档的关系**:本报告不重复 `docs/待处理内容.md`(逐条缺陷台账)与 `docs/内存管理/RAM使用分析与优化方案.md`(内存专项方案),而是做**体系层评审 + 量化补强 + 新增发现**。新增发现统一编号 `N-xx`,已在台账中的条目按 `P1-x/P2-x/P3-x` 引用。 - -> **落地进展(2026-09-18)**:**N-01 已修复**(L0 方案,台账 `P0-1`)——`cfg_file_store` 后端新增 `cfg_store_alloc/cfg_store_free/cfg_store_heap_free`,MCU 端落到 FreeRTOS 堆(`pvPortMalloc`),共享核心 C 库分配符号清零 + 编译期堆峰值预算 + 四条失败路径上报自检记录。双端 0 错 0 警、`--cfgtest` PASS;map 复核 `malloc.o` 已不参与链接且 `Removing startup_stm32f407xx.o(HEAP), (512 bytes)`,**SRAM1 由 126,584 B / 96.6%(余 4,488 B)降到 126,064 B / 96.2%(余 5,008 B)**。本报告其余内存数字仍以修复前的 2026-09-16 map 为基线,逐条换算时扣 512 B 即可;**真机 `@test` 正向确认已于 2026-10-03 执行并 PASS(2026-10-04 独立复跑 PASS)**,证据见 `子系统设计/配置文件设计说明.md` v1.13。 +> +> **销项约定(2026-10-11 起)**:本报告采用与登记簿相同的**「修复即销项」**——问题修复后其分析正文与表格行**从本报告删除**,完整记录沉淀到对应模块文档(去处清单见文末**变更记录**)。因此本报告**只呈现未闭环问题**,不再随时间膨胀。 --- ## 0. 执行摘要 -### 0.1 六维评分 +### 0.1 六维评分(2026-09-18 评级 + 2026-10-11 复核) -| 维度 | 评分 | 一句话结论 | -|------|------|-----------| -| 项目结构 | **B+** | 六层分层真实可执行、命名一致、门禁到位;主要问题是 **BSP 层承载业务模块**、**双构建清单漂移**、**5 个源文件两边都不编译** | -| 文档分布 | **B-** | 工程文档密度与深度高于同级嵌入式项目;主要问题是 **文档占仓库 60% 体积(其中芯片手册图片 40.5 MB)**、**README 任务表 4× 过期**、**约 18 条断链**、**文档承诺了不存在的工具/台账** | -| 模块设计 | **B** | 配置引擎、网络串行化、就绪屏障、FatFs 互斥等设计成熟;主要问题是 **失败路径普遍"死循环/静默降级"**、**无看门狗**、**FTP 无鉴权**、**cfg_file 依赖注定失败的 malloc** | -| 内存使用 | **C** | SRAM1 已用 96.6%(余 4,488 B),**64 KB CCM 完全空转**,FreeRTOS 堆被 6 条任务栈吃掉 2/3,**栈/堆水位零观测** | -| Flash 使用 | **B+** | 仅用 23.4%;但 **≈50 KB(20% 镜像)是可证明的死代码**(LVGL 配置漂移所致) | -| UI 标准化 | **B-** | 已有"声明源→生成器→表驱动"的正确骨架;缺**设计令牌层**、**页面原型库**、**注册自动化**、**golden 回归**,42 个菜单叶子仅 7 个有内容页 | +| 维度 | 评分 | 一句话结论 | **2026-10-11 复核** | +|------|------|-----------|---------------------| +| 项目结构 | **B+** | 六层分层真实可执行、命名一致、门禁到位;主要问题是 **BSP 层承载业务模块**、**双构建清单漂移**、**5 个源文件两边都不编译** | **A−**:反向依赖 4→3 处、`net_cfg_legacy` 删除、CI + 22 钩子成型、`App/algo/` 分层补齐;❌ 但"存在但未注册"的**反向校验仍缺**(`check_registration.py` 未建),`rs485_task.c` / `lftpd_log.c` 仍两边不编 | +| 文档分布 | **B-** | 工程文档密度与深度高于同级嵌入式项目;主要问题是 **文档占仓库 60% 体积(其中芯片手册图片 40.5 MB)**、**README 任务表 4× 过期**、**约 18 条断链**、**文档承诺了不存在的工具/台账** | **A−**:工程文档 **28,895 行**(翻倍)、`docs/README.md` 索引 + `docs/文档规范.md` + 三档一致性门禁全部落地,`check_doc_links --all` = **129 文档 0 失效**;⚠ 新风险:AGENTS.md 自身数字开始落后实测("18 钩子"实为 22、"清单 23 项"实为 29) | +| 模块设计 | **B** | 配置引擎、网络串行化、就绪屏障、FatFs 互斥等设计成熟;主要问题是 **失败路径普遍"死循环/静默降级"**、**无看门狗**、**FTP 无鉴权**、**cfg_file 依赖注定失败的 malloc** | **B+**:看门狗(IWDG + 心跳位图 + 4 个 Fault 现场 + `.noinit`)、ADC 全链(TIM3 事件驱动 + DMA 乒乓)、卷锁、墙钟均已落地;❌ **FTP 仍无鉴权**、RS485 仍无协议栈(E-50/E-49 未修)、`Error_Handler` 仍 `while(1)` | +| 内存使用 | **C** | SRAM1 已用 96.6%(余 4,488 B),**64 KB CCM 完全空转**,FreeRTOS 堆被 6 条任务栈吃掉 2/3,**栈/堆水位零观测** | **B**:堆 49,184 B 迁 CCM → SRAM1 降到 **69.8%(余 39,584 B)**、CCM 用 75.1%;栈/堆三面观测常开(`[STACK]` + `stack_report.py` + `configCHECK_FOR_STACK_OVERFLOW=2`);🟡 LVGL 池 40,960 B 未迁也未下调;⚠ 新硬约束:**从堆分配的缓冲不可被 DMA 直接寻址** | +| Flash 使用 | **B+** | 仅用 23.4%;但 **≈50 KB(20% 镜像)是可证明的死代码**(LVGL 配置漂移所致) | **A−**:2026-10-11 落地 F-1/F-2/F-3(两份 `lv_conf.h` 对齐、只留 I1 绘制核、零控件、无 flex/grid)→ **省 64,576 B**,占用降到 **20.1%(211,024 B)**;剩余 F-4/F-5/F-6 ≈10 KB 待逐项实测 | +| UI 标准化 | **B-** | 已有"声明源→生成器→表驱动"的正确骨架;缺**设计令牌层**、**页面原型库**、**注册自动化**、**golden 回归**,42 个菜单叶子仅 7 个有内容页 | **B**:**golden 回归已落地**(`simulator/tests/` 4 脚本:菜单 8 状态全像素 + 内容页逐字节黄金参考 + 协议 e2e);❌ 令牌层 / 组件库 / 页注册表未动,现口径为 **33 叶子 / 6 个有内容页** | -### 0.2 必须优先处理的问题(Top 6) +### 0.2 未闭环问题(Top 5,2026-10-11) -| # | 编号 | 问题 | 影响 | 状态 | +> 已修复/已消解的条目按「修复即销项」从本报告删除,完整记录见对应模块文档(去处清单见文末**变更记录**)。 + +| # | 编号 | 问题 | 影响 | 现状 | |---|------|------|------|------| -| 1 | **N-01** | `App/cfg/cfg_file.c` 用 C 库 `malloc(4097/5120)`,而本工程 MicroLIB C 堆只有 **512 B**(`startup_stm32f407xx.s:43 Heap_Size EQU 0x200`,map 中 `HEAP` ZI = `0x200`) | 真机上配置**加载/模板生成/外科式保存全部失败**并静默回落工厂默认;PC 模拟器用宿主 CRT 堆 → 掩盖问题 | ✅ **已修复(2026-09-18,L0;台账 P0-1)**,**真机 `@test` 正向确认 PASS(2026-10-03;2026-10-04 复跑 PASS)** | -| 2 | **N-02** | `lftpd.c:367-371 cmd_pass` 对任意口令无条件回 `230` | 局域网内任意主机可读写 `/config.ini`(含 IP/MAC/网关)→ 可远程断网;单连接 CH395F 上还可挤掉 UI/对时服务 | 代码级确凿 | -| 3 | **N-03** | 无独立看门狗(IWDG);`configASSERT` 为 `taskDISABLE_INTERRUPTS(); for(;;);`;多处初始化失败即 `for (;;)` 挂死 | 现场死机不可自恢复,且断言时**连日志都没有** | 全局 | -| 4 | **N-04** | SRAM1 余 4,488 B(P0-1 修复后 5,008 B)而 **CCM 64 KB 未使用**;**栈水位零观测**(`uxTaskGetStackHighWaterMark` 全库零调用);堆水位原本被链接器整体裁掉,随 P0-1 修复已由 `cfg_store_heap_free` 引入(`load: OK …, heap free NB` 行开始打印实测值) | 任何新功能都会撞 `L6406E`;无法判断栈是过大还是不足 | 量化见 §5 | -| 5 | **N-05** | `Lib/lv_conf.h` 与 LVGL 9.3 漂移:设了不存在的 `LV_USE_THEME_BASIC`,未设 `LV_USE_THEME_SIMPLE`(默认 1)→ 运行时强链 `lv_theme_simple` 并把 label/buttonmatrix/textarea/bar 拉进镜像;`LV_DRAW_SW_SUPPORT_*` 全默认 1 | ≈**50 KB 死代码**(35,310 B 非 1bpp 混合核 + 13,389 B 未用控件 + 866 B 主题) | 见 §5.4 | -| 6 | **N-06** | 5 个 `.c` 既不在 uvprojx 也不在 simulator(`adc_task.c`/`crc.c`/`lftpd_log.c`/`ringbuf.c`/`rs485_task.c`) | "写了不编译"= 死代码 + 误导;`crc`/`ringbuf` 是 RS485 协议的前置件却无人注册 | 见 §2.3 | +| 1 | **N-02** | `lftpd.c cmd_pass` 对任意口令无条件回 `230`(现 `:375-379`;`cmd_user :553-557` 同) | 局域网内任意主机可读写 `/config.ini`(含 IP/MAC/网关)→ 可远程断网;单连接 CH395F 上还可挤掉 UI/对时服务 | ❌ 一行未改;⚠ 且**不在 `../待处理内容.md` 登记簿内** → 建议补登 | +| 2 | **N-04 / R-2 剩余** | LVGL 池 `work_mem_int` 40,960 B + UI 帧/绘制 `ui_port.o` 6,420 + UI 发送 `ui_net_sink.o` 6,766 = **54,146 B 仍在 SRAM1** | 堆迁 CCM 后 **CCM 只剩 16,304 B**,这 54 KB 装不下 → R-2 后半段被堵死 | 🟡 必须先做 R-9(`LV_MEM_SIZE` 40 KB→16 KB)或 R-4(帧副本 4→2)腾出空间 | +| 3 | **N-12 / N-13** | `dhara_nand_is_free()` 只查页首 64 B;`dhara_nand_copy()` 不处理 `DHARA_NO_PAGE` 哨兵 | 映射恢复误判 → **数据丢失风险**(低概率、后果重);哨兵页号被当真实页 → 未定义行为 | ❌ 两处原样;⚠ 也不在登记簿 | +| 4 | **N-06 残余** | `rs485_task.c`、`lftpd_log.c` 既不在 uvprojx 也不在 simulator | "写了不编译"= 死代码 + 误导 | ❌ 未处理;❌ `check_registration.py`(S-3)仍未建 | +| 5 | **N-16** | 日志每条记录一次 `f_open + f_write + f_close`(`sys_log_mcu.c:123-140`) | 每条日志 2–4 次 NAND 页读写;UI 翻记录逐条 open/seek/close | ❌ 未办(仅 `FIL` static 化省 528 B 栈) | + +⚠ 另有两条**不在上表但需排期**:`Error_Handler` 仍 `__disable_irq(); while(1){}`(N-03c,§4.1)、`.git` 已膨胀到 **149 MB**(D-1,§3.1)。 ### 0.3 三条主线建议 @@ -47,6 +52,33 @@ --- +## 0.4 未闭环清单(2026-10-11 复核) + +> 只列**未办 / 半办**项;已修项已按「修复即销项」删除(去处见变更记录)。图例:❌ 未办 / 🟡 半办。 + +| 类别 | 编号 | 未闭环内容 | 下一步 | +|------|------|-----------|--------| +| **安全** | **N-02** ❌ | FTP 无条件 `230`,任意主机可读写 `/config.ini` | 补登登记簿 + `[ftp] user/pwd_hash` 定长比较 + 失败计数锁定 + 生产默认只读 | +| 安全 | N-03c ❌ | `Error_Handler` 仍 `__disable_irq(); while(1){}`(`Src/main.c:274-282`) | 统一降级策略:记自检 + 禁用该子系统 + 其余照常 | +| **数据正确性** | **N-12 / N-13** ❌ | `is_free` 只查页首 64 B;`copy` 不处理 `DHARA_NO_PAGE` | 补登登记簿 + 整页(或首/中/尾)扫描 + 返回 `DHARA_E_UNSUPPORTED` | +| 数据正确性 | N-14 🟡 | 写回缓存无空闲/掉电强制 flush(整页写与换页已 flush) | 空闲 N ms 由 defaultTask flush | +| **内存 RAM** | **R-2 剩余** 🟡 | LVGL 池 + UI 两份缓冲 54,146 B 仍在 SRAM1;CCM 只剩 16,304 B | 先做 R-9 或 R-4 腾 CCM,再迁 | +| 内存 RAM | R-3 / R-4 / R-5 / R-6 / R-9 ❌ | 页缓冲未合并 / UI 帧 4 副本 / `configMAX_PRIORITIES` 仍 56 / 栈仍动态 / `LV_MEM_SIZE` 仍 40 KB | R-9 最省力(池水位门禁数据已具备);R-6 前置条件已满足 | +| 内存 RAM | N-11 🟡 | NAND 仍直 DMA 调用方缓冲(已加运行时守卫、阈值 1024) | 补编译期/启动期地址断言 + **新铁律:堆缓冲不可被 DMA 直接寻址** | +| 内存 RAM | §5.5 ❌ | `ram_baseline.tsv`、`RAM预算台账.md`、92% 阈值门禁均未建 | 三条一起补,否则 R-2 后半段无法排期 | +| **Flash** | **F-4 / F-5 / F-6** ❌ | 剩余可评估 ≈10 KB(`box_shadow` 3,246 / `draw_sw_img` 2,960 / `bin_decoder` 1,972 / `image_decoder` 952 / `triangle` 930)+ 双精度 `dadd` 等 ≈1 KB;uvprojx 仍挂 201 个 LVGL `.c`(F-2 后均为空 TU,省的是编译时长) | 逐项实测再关;⚠ `mask`/`mask_rect`/`grad` 是文字与矩形绘制的必经路径,**不可盲关** | +| **性能/资源** | N-09 / N-16 / P2-8 / PHY / P3-x / rs485 ❌ | PASV 每次新建监听;日志每条 open/write/close;`NET_RECV_TIMEOUT_MS 30000`;PHY 强制 100M 无自协商回退;`NET_MAX_SOCKETS 8` 与单连接语义不符;`HAL_Delay(1)` 阻塞 DE | 见 §4.2 / §4.5 / §4.7 各行建议 | +| **结构** | S-1 / S-2 / S-3 / S-4 / N-06 残余 ❌ | `NETCFG`/`UI` 语义层错位;4 处裸 `extern`;**无注册反向校验**;`app_cfg.h` 开关未接线未删;`rs485_task.c`/`lftpd_log.c` 两边不编 | `check_registration.py` 是其中唯一"1 小时工作量"的一条 | +| **文档/仓库** | D-1 ❌ | 图片 36.3 MB 仍在仓;**`.git` 已 149 MB** | 降采样 / 拆 submodule / LFS 三选一 **+ 一次 `git filter-repo` 历史重写** | +| 文档/仓库 | 语义断链 ❌ | 两份 UI 文档仍指 `tools/ui_mock/`(不存在,实为 `simulator/tests/`) | 改指 + 挂进 `ui-verify` | +| 文档/仓库 | 元信息漂移 ⚠ | AGENTS.md 写"18 钩子"(实 22)、"清单 23 项"(实 29) | 纳入生成/校验(同任务表做法) | +| **UI** | U1 / U2 / U3 / U5 ❌ | 无令牌层、无组件库、无页注册表(手写 `case` 仍在);**27 页**未落地 | 按 §6.5 顺序,U4 的 golden 已可当 U1 的验收工具 | +| UI | U4 残余 🟡 | golden 已建但未挂 pre-commit;字符集差集检查仍是约定 | 两个脚本入门禁 | +| **测试** | P1-10 🟡 / 覆盖率 ❌ | CH395F 套件收尾两步;无覆盖率概念;主机侧对 `lftpd`/`dhara` 移植层无测试 | N-12/N-13 这类缺陷至今只能靠人工读代码发现 | +| **台账** | ⚠ | N-02、N-12/N-13 **漏登**;报告 N-18 与登记簿 N-18 **同号不同义** | 补登 + 引用一律带文档名 | + +--- + ## 1. 审查方法与证据基线 ### 1.1 数据源 @@ -81,8 +113,8 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 术语 | 定义 | |------|------| | 自研代码 | `App/`、`Drivers/BSP/`(不含 `Lib/`、`Middlewares/`、HAL/CMSIS)、`test/`;不含 CubeMX 生成的 `Src/`、`Inc/` | -| Flash 占用 | `Code + RO-data`(=245,384 B);`ROM` 再加 `RW-data` 初始化表(=246,016 B) | -| RAM 占用 | `RW-data + ZI-data` = 126,584 B,上限 128 KB(0x20000000);**CCM 64 KB 不在 scatter 内,计 0** | +| Flash 占用 | `Code + RO-data`(基线 245,384 B → **2026-10-11 复核 273,860 B**);`ROM` 再加 `RW-data` 初始化表(246,016 → **274,060 B**) | +| RAM 占用 | `RW-data + ZI-data`(基线 126,584 B,全部压在 SRAM1)。**2026-10-11 复核:140,720 B,已跨两个区域** = SRAM1 `RW_IRAM1` 91,488 / 131,072(**69.8%**)+ CCM `RW_CCM` 49,184 / 65,280(**75.3%**)+ `UNINIT_CCM` 48 B(`.noinit` 崩溃现场保留区)。⚠ 口径变化:原报告"CCM 不在 scatter 内计 0"已不成立 | | "死代码" | 该对象在镜像中有码,但其功能被工程配置(如 `LV_COLOR_DEPTH 1`)或调用点扫描(零调用者)证明不可达 | --- @@ -93,41 +125,40 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 分类 | 文件 | 行数 | 说明 | |------|------|------|------| -| `App/`(自研应用) | 18 | 3,852 | 配置引擎、墙钟、日志、任务薄壳 | -| `Drivers/BSP/`(自研驱动/子系统) | 62 | 16,369 | 其中 **UI 子系统 5,875 行**(含 `ui_font_cn_12.c` 1,211) | -| `test/`(自研测试) | 13 | 4,132 | 13 个测试模块 | -| **自研合计** | **108** | **24,353** | | -| CubeMX `Src/`+`Inc/` | 22 | 4,286 | 受 USER CODE 标记保护 | -| `simulator/` | — | 2,292 | PC SDL2 宿主 | -| 第三方 | — | ≈55 万行 | LVGL 9.3(uvprojx 编译 201 个 `.c`)、FreeRTOS、FatFs、dhara、lftpd | +| `App/`(自研应用) | 38 | 8,516 | 配置引擎、墙钟、日志、任务薄壳;**新增 `App/algo/`(L1 采集 + L2 相量,6 文件 1,090 行)** | +| `Drivers/BSP/`(自研驱动/子系统) | 67 | 19,104 | 其中 **UI 子系统 6,036 行**(含 `ui_font_cn_12.c` 1,211)、NET 6,302、CH395F 2,144、GD5F 1,978、SD2506 835、TPAFE 680、NETCFG 578、RS485 310 | +| `test/`(自研测试) | 41 | 10,855 | **`test/host/` 主机侧单测 25 文件 / 4,505 行**(2026-09-18 时不存在)+ `test/board/` 板级套件 16 文件 / 6,350 行 | +| **自研合计** | **146** | **38,475** | 2026-09-18 基线为 **108 / 24,353** → **+38 文件 / +14,122 行** | +| CubeMX `Src/`+`Inc/` | 26 | 5,459 | 受 USER CODE 标记保护 | +| `simulator/` | 10 | 2,462 | PC SDL2 宿主 | +| 第三方 `Lib/` | 958 | 529,916 | LVGL 9.3(423 个 `.c` / 421,546 行)、FreeRTOS、FatFs、dhara、lftpd | -**评价**:自研体量与第三方体量比约 1:20,属于典型的"薄自研 + 厚中间件"。这决定了**结构审查的重点不在自研代码内部,而在"自研如何约束第三方"**——而 §5.4 会证明这层约束(`lv_conf.h`)目前是失守的。 +**评价**:自研体量与第三方体量比约 **1:14**(原 1:20,自研涨了一倍半),仍属典型的"薄自研 + 厚中间件"。这决定了**结构审查的重点不在自研代码内部,而在"自研如何约束第三方"**——§5.4 曾量化这层约束(`lv_conf.h`)失守的代价:**64,576 B 死代码**(2026-10-11 已修)。 ### 2.2 分层与依赖方向 -实测自研层间的**全部反向依赖**(BSP → App)只有 4 处: +实测自研层间的**全部反向依赖**(BSP → App)——2026-10-11 复核为 **3 处**(原 4 处,`net_cfg_legacy.c` 已随旧档迁移路径整体删除,见 §2.4): | 位置 | 反向 include | 判定 | |------|-------------|------| -| `Drivers/BSP/NETCFG/net_cfg.c:28` | `cfg_file.h` | 可接受但需说明:NETCFG 实为"业务配置模块",放在 BSP 层是历史命名 | -| `Drivers/BSP/NETCFG/net_cfg_legacy.c:19,21` | `cfg_file.h`、`cfg_file_store.h` | 同上 | +| `Drivers/BSP/NETCFG/net_cfg.c:31` | `cfg_file.h` | 可接受但需说明:NETCFG 实为"业务配置模块",放在 BSP 层是历史命名 | | `Drivers/BSP/UI/ui_page_runlog.c:19` | `sys_log.h` | UI 读日志属正常消费者,但 `sys_log` 在 App 层 → 层次倒置 | | `Drivers/BSP/UI/ui_page_selftest.c:19` | `sys_log.h` | 同上 | -| `Lib/FatFs/ffsystem.c` | `sys_clock.h`(App/util) | 中间件→应用;已在文件内注释声明为"④中间件层"有意为之 | +| `Lib/FatFs/ffsystem.c:19` | `sys_clock.h`(App/util) | 中间件→应用;已在文件内注释声明为"④中间件层"有意为之 | -**评价:良好**。在 24k 行规模上把反向依赖控制在 4 处,说明分层不是墙上的图。但存在**语义层错位**: +**评价:良好**。在 38k 行规模上把反向依赖控制在 3 处,说明分层不是墙上的图。但存在**语义层错位**: - `Drivers/BSP/NETCFG/`、`Drivers/BSP/UI/` 名字是"板级支持包",内容是**业务**(网络配置语义、菜单/页面/权限/内容页框架)。后果:`sys_log`(App)与 `net_cfg`(BSP)互相引用,层次图自相矛盾。 - **建议(S-1)**:把 `NETCFG`、`UI` 在概念上重新定性为 `App/` 或新增 `Service/` 层,`Drivers/BSP/` 只留下与引脚/芯片强绑定的 `ch395f`、`gd5f2gq5ue`、`sd2506`、`tpafe5160`、`rs485`、`dbg_*`。迁移成本最低的做法:**先改文档与分层图(零风险),目录迁移留到有 CI 兜底之后**(避免与 §2.3 的注册问题叠加)。 -依赖方向裸引用(绕过头文件契约的 `extern`)实测 4 处: +依赖方向裸引用(绕过头文件契约的 `extern`)实测 4 处——**2026-10-11 复核:4 处全部仍在**(仅行号漂移),S-2 未办: | 位置 | 裸 extern | |------|-----------| -| `Drivers/BSP/NET/ch395f.c:143` | `extern volatile uint8_t g_spi1_dma_done;`(实为 GD5F 的变量,跨模块反向) | -| `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c:43` | `extern SPI_HandleTypeDef hspi1;` | -| `Drivers/BSP/NET/net_socket.c:51` | `extern osMessageQueueId_t netMsgQueueHandle;` | -| `Drivers/BSP/sd2506/sd2506.c:17` | `extern I2C_HandleTypeDef hi2c1;` | +| `Drivers/BSP/CH395F/ch395f.c:178` | `extern volatile uint8_t g_spi1_dma_done;`(实为 GD5F 在 `gd5f2gq5ue.c:50` 定义的变量,跨模块反向且**未进任何头文件**) | +| `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c:47` | `extern SPI_HandleTypeDef hspi1;`(`Inc/spi.h:35` 已导出,局部重复) | +| `Drivers/BSP/NET/net_socket.c:56` | `extern osMessageQueueId_t netMsgQueueHandle;` | +| `Drivers/BSP/SD2506/sd2506.c:20` | `extern I2C_HandleTypeDef hi2c1;`(`Inc/i2c.h:35` 已导出,局部重复) | **建议(S-2)**:CubeMX 句柄类(`hspi1`/`hi2c1`)统一由 `main.h` 导出并 include,删除局部 extern;`g_spi1_dma_done` 应由 GD5F 模块自己定义并在头文件 `extern` 声明(当前跨模块裸 extern 是真正的耦合隐患);`netMsgQueueHandle` 改为 net_socket 内部持有 + 提供投递 API。 @@ -137,11 +168,11 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 类别 | 文件 | 后果 | |------|------|------| -| **两边都不注册** | `App/task/adc_task.c`、`App/util/crc.c`、`App/util/ringbuf.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c`、`App/task/rs485_task.c` | 永不编译 → 死代码;`crc.c`/`ringbuf.c` 是 RS485 帧协议前置件,无人注册即无人使用;`lftpd_log.c` 与 `lftpd_log.h`(现为 DBG 宏)语义重复 | +| **两边都不注册** | 基线 5 个:`App/task/adc_task.c`、`App/util/crc.c`、`App/util/ringbuf.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c`、`App/task/rs485_task.c` | **2026-10-11 复核:剩 2 个**——`crc.c`/`ringbuf.c` 已整体删除(`fac9d1c4`,2026-10-02,全库无调用者);`adc_task.c` 已挂 uvprojx(**仅 MCU**,模拟器不编,符合"任务层不入模拟器"的既有做法);❌ **`rs485_task.c`(等 E-50/E-49 修复)与 `lftpd_log.c`(与 `lftpd_log.h` 的 DBG 宏语义重复)仍两边不编** | | **仅 simulator 注册** | `cfg_file_store_sim.c`、`net_socket_sim.c`、`sim_port.c`、`sim_syslog.c`、`ui_net_sink_backend_sim.c` | 正常(后端适配层模式) | -| **uvprojx** | 290 个 `.c`(含 201 个 LVGL) | LVGL 全量编译 → 编译时长;死代码问题被"全编"放大(见 §5.4) | +| **uvprojx** | 306 个 `.c`(含 201 个 LVGL)(基线 290) | LVGL 全量编译 → 编译时长;死代码问题被"全编"放大(见 §5.4) | -**评价**:AGENTS.md 已把"新文件必须注册两处"写成铁律并有 `new-source-file` skill,但**缺少反向校验**("存在但未注册")。这是纯粹的机械检查缺口。 +**评价**:AGENTS.md 已把"新文件必须注册两处"写成铁律并有 `new-source-file` skill,但**缺少反向校验**("存在但未注册")。这是纯粹的机械检查缺口——**2026-10-11 复核:该缺口未补**(`scripts/` 现有 11 个脚本,无 `check_registration.py`)。 **建议(S-3,1 小时工作量)**:新增 `[计划] scripts/check_registration.py`,规则: 1. `App/**.c`、`Drivers/BSP/**.c`(排除 `*_sim.c`、`*_mcu.c` 之外的模式白名单)必须同时出现在 uvprojx 与 CMakeLists; @@ -153,52 +184,32 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 观察 | 证据 | 判定 | |------|------|------| -| 根目录散置工作文件 | `待处理内容.md`(仅 2 行的 RAM 待办)、`ui_menu_tree.txt`、`ui_cn_chars.txt` | 菜单树是**生成器输入**,放根目录合理但需在 `docs/` 有权威说明;`待处理内容.md` 内容已被 `docs/内存管理/` 覆盖 → 建议合并后删除 | +| 根目录散置工作文件 | `ui_menu_tree.txt`、`ui_cn_chars.txt` | 菜单树/字符集是**生成器输入**,放根目录合理且已在 `docs/` 有权威说明(原点名的根目录 `待处理内容.md` 已并入 `docs/待处理内容.md` 统一登记簿) | | `Lib/` 命名 | `Lib/` 下混放 `lvgl/`、`lv_conf.h`、`FatFs/`、`dhara/` | `lv_conf.h` 是**工程配置**不是库文件;建议移到 `Config/lv_conf.h` 并同步 include 路径(低风险、提升可读性) | -| 功能开关名存实亡 | `App/app/app_cfg.h` 的 `APP_NET_ENABLE/APP_ADC_ENABLE/APP_RS485_ENABLE` **全库零引用** | 与台账 **P2-12** 同源。开关不生效比没有开关更糟——评审者会误信"ADC 可裁剪" | -| 测试任务无条件创建 | `Src/freertos.c:171` | 与台账 **P2-13** 同源;产品镜像白耗 4 KB 栈 | -| 未使用的 CCM | `.sct` 只有 `ER_IROM1` + `RW_IRAM1 0x20000000 0x20000` | 见 §5 | +| 功能开关名存实亡 | `App/app/app_cfg.h` 的 `APP_NET_ENABLE/APP_ADC_ENABLE/APP_RS485_ENABLE` **全库零引用** | 与台账 **P2-12** 同源。开关不生效比没有开关更糟——评审者会误信"ADC 可裁剪"。**2026-10-11 复核:🟡 已订正表述但未接线**——文件头 v1.1(2026-10-04)已如实标注"预留占位、不门控任何代码",`APP_CCM_ENABLE`/`APP_UI_NET_SINK` 已删除,三个 `APP_*_ENABLE` 仍在且仍零引用 | ### 2.5 结构结论 -```mermaid -graph TD - A[app_main_init CubeMX] --> B[osKernelStart] - B --> T1[defaultTask 512w
FS挂载+配置引擎属主] - B --> T2[netTask 1024w
net_poll+消息串行] - B --> T3[uiTask 4096w
LVGL+UI sink] - B --> T4[ftpTask 1024w] - B --> T5[timeSyncTask 512w] - B --> T6[testTask 1024w
产品构建也在跑] - T2 -.netMsgQueue 深8.-> T2 - T3 -.10ms 轮询 accept.-> T2 - T4 -.等待.-> E1[NET_READY & STORAGE_READY] - T5 -.等待.-> E1 -``` - **总体判断:结构合理,无需重构**。要做的是四类**低成本纠偏**:S-1 层次定性、S-2 消裸 extern、S-3 注册反向校验、S-4 让 `app_cfg.h` 开关真正生效或删除(并把 `APP_CCM_ENABLE`/`APP_UI_NET_SINK` 纳入同一处)。 --- ## 3. 文档分布合理性 -### 3.1 体量画像(实测,工作树) +### 3.1 体量画像(实测,工作树;2026-10-11 复核,括号内为 2026-09-18 基线) | 对象 | 文件数 | 体积 | 占仓库 | |------|--------|------|--------| -| 仓库受控文件总计 | 2,013 | **78.3 MB** | 100% | -| `docs/` 合计 | 652 | **47.19 MB** | **60.3%** | -| └ `docs/芯片手册/images` | 564 张 JPG | **40.5 MB** | **51.7%** | -| └ `docs/images/引用关系图.json` | 1 | 1,052 KB | 1.3% | -| └ `docs/UI设计/mockups` | 15 | 1,110 KB | 1.4% | -| `.git` pack | — | 46.6 MB | — | -| 工程文档(Markdown,自研) | 40 | 14,124 行 | — | -| 外部资料(Markdown,datasheet 转录) | 16 | 59,142 行 | — | +| 仓库受控文件总计 | 2,363(2,013) | **77.7 MB**(78.3 MB) | 100% | +| `docs/` 合计 | 849(652) | **44.4 MB**(47.19 MB) | **57.1%**(60.3%) | +| └ `docs/芯片手册/` 图片 | 703 张(564 张) | **36.3 MB**(40.5 MB) | **46.7%**(51.7%) | +| └ `docs/images/引用关系图.json` | 1 | 1,072 KB(1,052 KB) | 1.3% | +| └ `docs/UI设计/mockups` | 15 | 1,110 KB(1,110 KB) | 1.4% | +| `.git` 目录 | 1,027 | **149.3 MB**(46.6 MB pack) | — | +| 工程文档(Markdown,自研) | 77 篇 / **28,895 行**(40 篇 / 14,124 行) | — | — | +| 外部资料(Markdown,datasheet 转录) | 14 篇 / 58,244 行(16 篇 / 59,142 行) | — | — | -**关键失衡**:工程文档 14,124 行 vs 外部资料 59,142 行;**图片体量是全部 Markdown 的 3 倍**。这意味着: -- `git clone` 的净耗时几乎全部花在扫描手册图片上; -- 与工程决策相关的信息(40 篇)在体积上被完全淹没; -- `引用关系图.json`(1 MB)是**生成产物**,与 `.dot/.svg` 一并提交(AGENTS.md 明确要求),但它对阅读无价值、对 diff 有害。 +**关键失衡(复核后仍在,但已缓解)**:工程文档行数从 14,124 涨到 **28,895**(翻倍),外部资料行数基本不变 → 比例从 1:4.2 改善到 **1:2.0**;但**图片体量(36.3 MB)仍是全部 Markdown 的 4 倍**,`git clone` 的净耗时依旧几乎全花在扫描手册图片上。⚠ 新增事实:`.git` 已膨胀到 **149 MB**(受控文件才 77.7 MB),说明历史里存过多版图片/产物——**D-1 的优先级因此上升**(拆仓或 LFS 之外,还需 `git filter-repo` 类历史清理才能见效)。 ### 3.2 做得好的(应保留并显式制度化) @@ -218,72 +229,17 @@ graph TD 2. **推荐**:`docs/芯片手册/` 拆为独立仓库 `dtu-datasheets`,用 `git submodule` 挂回原路径(现有全部链接不需改写); 3. **最正**:Git LFS(需要托管端支持)。 -#### D-2(P1)README 任务表 4× 过期,与 AGENTS.md 冲突 - -`README.md:71-76` 仍写 `defaultTask 2048 words / netTask 4096 / uiTask 16384`,而 `Src/freertos.c:64-105` 与 `AGENTS.md` 均为 `512/1024/4096`。台账 **P2-16** 只修了 AGENTS.md,README 漏修。 - -**建议**: -- 立即修正(一次性); -- **根治**:任务表由脚本从 `Src/freertos.c` 解析生成,README/AGENTS 引用同一生成块(`scripts/gen_task_table.py` 输出 Markdown 片段,pre-commit 校验一致性)。这与 §7 的"文档-代码一致性门禁"合并实施。 -> ✅ **2026-09-25 已处理**:README 任务表已修正为 `1024/1024/1024/2048/4096/512`;两表均改为**受管生成区**(锚点 ``),`scripts/gen_task_table.py` 以 `Src/freertos.c` 的 `osThreadAttr_t` 为唯一事实源生成 / 校验(`--check` / `--write` / `--diff`),pre-commit `task-table` 钩子拦截漂移。改栈流程写入 `docs/文档规范.md` §4.2。 - -#### D-3(P1)约 18 条断链(目录搬迁与工具未落地导致) - -分诊后**真实失效**清单(附录 B 有完整列表): - -| 类型 | 代表位置 | 说明 | -|------|---------|------| -| 目录搬迁遗留 | `docs/测试说明/{FTP,GD5F2GQ5UE,SD2506}_Test_Guide.md`、`测试开发标准流程.md:358`、`问题记录/CH395F_Trap_Records.md:434` | 仍指旧路径(`docs/` 根下) | -| 路径片段错误 | `docs/代码规范/cppcheck使用说明.md:3`、`docs/子系统设计/配置文件设计说明.md:159/160/494/568-570` | 相对层级算错或文件已改名 | -| **指向不存在的工具** | `UI_界面制作流程.md:413`、`UI_自动化测试流程.md:104` → `tools/ui_mock/` | **文档承诺了未实现的 UI 渲染/比对工具**(§6.5 golden 回归的前置件) | -| **指向不存在的产物** | `docs/内存管理/RAM使用分析与优化方案.md:172/279/136` → `[计划] scripts/ram_baseline.tsv`、`[计划] docs/内存管理/RAM预算台账.md`、`[计划] App/util/app_mem_stats.*` | 方案写了、工具没建 → 读者按文档执行会失败 | -| 目标路径变更 | `图形绘制流程.md:135`(真实目标在 `docs/images/`)、`串口助手设计说明.md:200` | | - -**建议**:新增 `scripts/check_doc_links.py`(校验仓库内相对链接与代码引用路径),挂 pre-commit;对"计划中的文件"统一用 `[计划]` 前缀标记而非直接链接,避免门禁长期红灯。 - -#### D-4(P2)孤儿文档 - -`内存管理/内存使用分析规划.md` 无任何入链(项目自有文档中唯一孤儿)。**建议**:并入 `RAM使用分析与优化方案.md` 的"规划"章节,或在 `docs/README.md` 索引中显式列出。 -> ✅ **2026-09-21 已处理**:该文档与《STM32F4-Base 内存使用分析报告》一并并入 `RAM使用分析与优化方案.md`(现为内存管理主文档),两个文件已删除;口径与方法论见其 §8.4,快照数据见 `docs/内存管理/data/`。 - -#### D-5(P2)缺一个文档入口/索引 - -`docs/` 根下平铺 12 篇 md + 8 个子目录,无 `docs/README.md` 索引;权威入口实际是 `AGENTS.md`(面向 agent)与 `docs/架构设计/项目架构总览.md`。**新成员的阅读顺序不可见**。 - -**建议**:新建 `docs/README.md`,按"上手 → 架构 → 模块 → 测试 → 规范 → 台账 → 外部资料"给出有序清单(约 60 行即可),并从 README.md 链接过去。 -> ✅ **2026-09-25 已处理**:已建 `docs/README.md`(目录结构树 + 推荐阅读顺序 + 按角色导航 + 9 大章节全量清单 + 新增文档约定 + 已知问题),并从根 `README.md` 链接过去。同轮把顶层 16 篇散落 md 归入 5 个新目录(`架构设计/`/`子系统设计/`/`网络/`/`审查记录/`/`环境与部署/`),全仓库 90 处引用同步。 - -#### D-6(P2)命名与目录约定不统一 - -中文目录名含空格(`docs/UI设计/`)在脚本/链接/CI 中易出错(本工程已出现多处需引号包裹的路径);同一目录混用 `UI_界面制作流程.md`(下划线+中文)与 `内容页架构.md`(纯中文)与 `RemoDispBus协议与接收设计说明.md`(中英连写)。 - -**建议**:确立 `docs/文档规范.md`:目录名一律**无空格**(`UI设计` 或 `ui`)、文件名"主题词+类型"、`snake_case` 用于英文段;新增文档必须出现在 `docs/README.md`。存量文档按 D-3 修链接时**顺带**重命名,一次做完。 -> ✅ **2026-09-25 已处理**: -> - **目录去空格**:`docs/UI 设计/` → `docs/UI设计/`,全仓库 67 处引用同步(含 markdown 链接的 URL 编码形态 `UI%20设计`);现全库**已无含空格目录**。 -> - **`docs/文档规范.md` 已创建**:正式确立目录命名(无空格)、文件名风格(主题词+类型后缀 + 明确禁止字符)、编码换行、链接形态、门禁三档、结构要素、写作约定与常见错误速查。 -> - **文件名风格统一**:按 §1.2 约定复核 `docs/` 全部文件(见规范 §1.2);`内容页架构.md` 等纯中文名保留(符合"主题词+类型"约定,未强制加下划线),仅纠正违反"无空格/无敏感字符"者。 - -#### D-7(P2)文档与代码之间没有一致性门禁 - -D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代码做的"。 - -**建议**:建立三档一致性检查(详见 §7 交付物): -| 检查 | 覆盖 | 手段 | -|------|------|------| -| 存在性 | 文档里的路径/文件/脚本必须存在 | `check_doc_links.py` | -| 数值 | 任务栈/优先级/端口/引脚表与代码一致 | `gen_task_table.py` + 端口常量表提取 | -| 体积 | 文档中引用的"内存/Flash 数字"与最新 map 一致 | 复用 `scripts/ram_report.py` 输出 | - -> ✅ **2026-09-25 / 09-26 已处理(三档全部落地)**: -> - **存在性**:`scripts/check_doc_links.py` 已落地并接入 pre-commit `doc-links` 钩子。校验 md 互链(相对文档解析)与反引号内仓库根相对路径;豁免 `[计划]` 前缀、`[示例]` 标记、就近状态词、占位符、行内代码 span、外部 URL。全库 **67 文档 0 失效**。开发过程中实测抓出 **7 处迁移漏改的图片相对链接**(`images/` 未改 `../images/`)与 1 处真断链(`tools/ui_mock/render_mockup.py`,已标「已删除」)——门禁价值实证。 -> - **数值**:`scripts/gen_task_table.py` 以 `Src/freertos.c` 为唯一事实源生成/校验 README/AGENTS 任务表,pre-commit `task-table` 钩子拦截。 -> - **体积**(2026-09-26):`scripts/check_mem_numbers.py` 以 `.map` 为唯一事实源,校验 `docs/内存管理/` 的受管快照区(`MEM_SNAPSHOT` 锚点)+ 扫描正文里的**陈旧区域数字**,pre-commit `mem-numbers` 钩子拦截。**上线即抓出真实失真**:文档写 `RW_IRAM1 82,048 B / 62.6%`、`ROM 249,432 / 23.8%`,实际 `.map` 已是 `87,040 / 66.4%`、`262,660 / 25.0%`(共修正 6 处)。 -> - 文档体系规范(含门禁三档、受管区锚点、陈旧数字扫描的误报取舍)正式确立为 `docs/文档规范.md`。 +> **2026-10-11 复核:未办,且问题变大**。图片增至 **703 张 / 36.3 MB**(受控文件总量 77.7 MB,图片仍占 46.7%);⚠ 新事实:`.git` 目录已达 **149.3 MB**(1,027 个对象文件),远大于工作树受控内容 → 历史里存了多版图片/日志/产物。**只做 1/2/3 中任一项都不足以解决 clone 耗时,还需一次历史重写(`git filter-repo`)**;重写属破坏性操作(所有克隆需重取),须单独排期并先备份裸仓。 ### 3.4 文档结论 **内容质量高、分布形态差**。按 D-1..D-7 处理后可从 B- 提升到 A-:工程量约 1 人日(不含拆仓),收益是 clone 时间与"文档可信度"。 +> **2026-10-11 复核**:**内容质量侧已到位**(评级 A−,见 §0.1;D-2..D-7 已销项),**分布形态侧未动**——未闭环的文档类问题只剩三条: +> - **D-1 ❌**:`docs/` 仍占仓库 **57.1%**、图片仍 **36.3 MB**,且 `.git` 已膨胀到 **149 MB** → 已从"P1 优化"变成影响日常 clone/备份的实际痛点;单靠拆仓或 LFS 不够,**还需一次历史重写**。 +> - **语义断链 ❌**:两份 UI 文档仍指向 `tools/ui_mock/`(该目录从未存在,实现落在 `simulator/tests/`)——路径检查抓不到,属语义层。 +> - **元信息漂移 ⚠**:门禁与文档越多,自身数字也会过期——AGENTS.md 现写"18 个钩子"(实测 `.pre-commit-config.yaml` **22** 个)、"清单 23 项"(实测 `check_cubemx_survival.py` **29** 项)。建议把这类计数也纳入生成/校验,否则报告与规范各说一套。 + --- ## 4. 功能模块设计评审 @@ -299,13 +255,8 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 | 编号 | 问题 | 证据 | 影响/建议 | |------|------|------|-----------| -| **N-03a** | 无 IWDG(全库无 `MX_IWDG_Init`/`HAL_IWDG_Refresh`) | grep 零命中 | 死机不可恢复。建议 1 s 超时 + 各任务在自身循环置心跳位图,defaultTask 校验"全部任务在 3 s 内动过"才喂狗 | -| **N-03b** | `Inc/FreeRTOSConfig.h` 的 `configASSERT(x)` = `taskDISABLE_INTERRUPTS(); for(;;);` | `:154` | 断言时**连串口都不输出**。建议改为:`DBG_ERROR` + 记录 `pcTaskGetName(xTaskGetCurrentTaskHandle())` 后再死循环(或直接触发 IWDG 复位) | -| **N-03c** | 多处初始化失败即挂死(`Error_Handler()` at `main.c:221`;net/ui 初始化失败路径) | | 建议统一降级策略:**外设失败 → 记自检日志 + 该子系统禁用 + 其余照常**(LED/屏上给出降级标识) | -| **N-07** | `configCHECK_FOR_STACK_OVERFLOW` 与 `configUSE_MALLOC_FAILED_HOOK` **均未开启** | `Inc/FreeRTOSConfig.h` 无此二宏 | 栈溢出/分配失败静默破坏内存。开销仅每任务几十字节 + 每上下文切换若干指令,**强烈建议立刻开启**(这是 §5 一切内存工作的安全网) | -| N-08 | 无 `HardFault_Handler` 自研覆盖(用 startup 默认死循环) | `Src/*.c` 无 | 建议加:把 `SP/PSP/LR/PC/xPSR` + 栈帧 `R0-R3,R12,LR` 打到串口(或写进 CCM 保留区,复位后可读),现场故障可定位 | -| P2-13 | testTask 无条件创建(`Src/freertos.c:171`) | | 产品镜像白耗 4 KB;改 `#if defined(TEST_SUITE_*)` 包裹 | -| P2-12 | `App/app/app_cfg.h` 三个 `APP_*_ENABLE` 零引用 | | 开关要么生效要么删 | +| **N-03c** | 多处初始化失败即挂死(`Error_Handler()` at `main.c:221`;net/ui 初始化失败路径) | | 建议统一降级策略:**外设失败 → 记自检日志 + 该子系统禁用 + 其余照常**(LED/屏上给出降级标识)。**❌ 2026-10-11 复核仍在**:`Src/main.c:274-282` 仍 `__disable_irq(); while(1){}`(IWDG 独立于中断,最终仍会被硬复位,但无降级、无定位信息) | +| P2-12 | `App/app/app_cfg.h` 三个 `APP_*_ENABLE` 零引用 | | 开关要么生效要么删 → **🟡 部分处理**:已如实标注"预留占位、不门控任何代码"(v1.1),未接线也未删 | ### 4.2 网络子系统(CH395F + BSD 抽象层) @@ -318,13 +269,11 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 | 编号 | 问题 | 证据 | 建议 | |------|------|------|------| -| **N-02** | **FTP 无鉴权**:`cmd_pass` 无条件 `send_simple_response(socket, 230, STATUS_230)` | `lftpd.c:367-371`(`cmd_user :539` 亦不校验) | 最小可用方案:`config.ini` 增 `[ftp] user/pwd_hash`,`cmd_pass` 用**定长比较 + 失败计数锁定**;生产固件默认关闭 FTP 写权限(`FF_FS_READONLY` 式的只读模式) | -| N-09 | `cmd_pasv` 每次新建监听 `lftpd_inet_listen(0)`(随机端口) | `lftpd.c:373` | 在**单连接 socket** 上与 UI `:7003`、对时 `:8888` 争用同一物理 socket;建议限定监听资源池 + 显式"FTP 会话独占"策略,并在文档写明"一次只允许一个 TCP 服务活动" | -| N-10 | uiTask 以 **10 ms** 周期向 netMsgQueue 投递 accept 轮询 → ≈100 msg/s,与 FTP 共享深度 8 的队列 | `ui_net_sink.c` 轮询逻辑 | 队列满时 `osErrorResource` → UI 丢帧/重投。建议:把"服务就绪"改为事件驱动(netTask 在 LISTEN 状态变化时置位),UI 侧改为等待事件;或给队列扩容并在 UI 侧背压。**→ 已销项(2026-10-10)**:未走"事件驱动",改用**门控**——`net_can_accept()`/`net_can_read()` 纯 RAM 读控制块,无活时**一次 RPC 都不发**(100 msg/s → 0);实测 `uis` 50 ms → **1 µs**。方案见 `../UI设计/RemoDispBus协议与接收设计说明.md` §3;判读口径与实测基线见 `../子系统设计/看门狗设计说明.md` §11.4 | -| P2-11 | `net_task.c` 对 `osEventFlagsGet` + Clear 非原子 | 台账 | 用 `osEventFlagsAcquire`/单次 Wait 消 TOCTOU | -| P2-8 | `net_recv` 阻塞超时 30 s | 台账 | 缩短并让上层可取消 | -| — | PHY 强制 100M 全双工,忽略自协商 | AGENTS/architecture | 与某些交换机会联不通;至少加"链路 up 失败 3 次后回退自协商" | -| P3-x | `NET_MAX_SOCKETS 8`(`net_config.h:24-25`)是**软件表**,与硬件单连接语义不符 | | 文档已说明,但 API 层看不到限制 → 建议在 `net_socket.c` 断言"同时 LISTEN 数 ≤ N" | +| **N-02** | **FTP 无鉴权**:`cmd_pass` 无条件 `send_simple_response(socket, 230, STATUS_230)` | `lftpd.c:367-371`(`cmd_user :539` 亦不校验) | 最小可用方案:`config.ini` 增 `[ftp] user/pwd_hash`,`cmd_pass` 用**定长比较 + 失败计数锁定**;生产固件默认关闭 FTP 写权限(`FF_FS_READONLY` 式的只读模式)。**❌ 2026-10-11 复核:一行未改**(现 `lftpd.c:375-379` / `:553-557`,全文件无 password/auth/attempt/read_only);⚠ 且**未登记进 `待处理内容.md`** → 建议补登为 P0/P1 | +| N-09 | `cmd_pasv` 每次新建监听 `lftpd_inet_listen(0)`(随机端口) | `lftpd.c:373` | 在**单连接 socket** 上与 UI `:7003`、对时 `:8888` 争用同一物理 socket;建议限定监听资源池 + 显式"FTP 会话独占"策略,并在文档写明"一次只允许一个 TCP 服务活动"。**❌ 复核仍在**(现 `lftpd.c:392`,3 次重试 + 200 ms 间隔) | +| P2-8 | `net_recv` 阻塞超时 30 s | 台账 | 缩短并让上层可取消。**❌ 复核仍在**:`net_config.h:39` `NET_RECV_TIMEOUT_MS 30000` | +| — | PHY 强制 100M 全双工,忽略自协商 | AGENTS/architecture | 与某些交换机会联不通;至少加"链路 up 失败 3 次后回退自协商"。**❌ 复核仍在**:`net_task.c:110` 无条件 `ch395f_set_phy(CH395F_PHY_100M_FULL)`,仅 3 s 链路等待 + DISCONN 时上报 `comms fault` 自检 | +| P3-x | `NET_MAX_SOCKETS 8`(`net_config.h:24-25`)是**软件表**,与硬件单连接语义不符 | | 文档已说明,但 API 层看不到限制 → 建议在 `net_socket.c` 断言"同时 LISTEN 数 ≤ N"。**❌ 复核仍在**(现 `net_config.h:27`,值仍 8) | ### 4.3 存储子系统(GD5F2GQ5UE + dhara + FatFs) @@ -337,47 +286,38 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 | 编号 | 问题 | 证据 | 影响/建议 | |------|------|------|-----------| -| **N-11** | NAND 驱动**直接对调用方缓冲做 DMA**:`HAL_SPI_Receive_DMA(&hspi1, p_buf, size)`(`:200`)、`HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)p_buf, size)`(`:258`),阈值仅 32 B | `gd5f2gq5ue.c:185-217,246-270` | **这是 CCM 迁移的硬边界**:所有走 NAND 的缓冲区(含 `FIL`、FatFs window、任务栈上的临时缓冲)**必须留在 SRAM1**。必须在 `docs/内存管理/` 写成铁律,否则一旦把某任务栈搬进 CCM 就会静默 DMA 失败 | -| **N-12** | `dhara_nand_is_free()` 只检查页首 64 B | `nand_ftl.c:137-154` | dhara 依赖该判定做映射恢复;页首 64 B 全 FF 但后半页有数据时会误判为空闲 → 数据丢失风险(低概率但后果重)。建议整页扫描或至少首/中/尾三处采样 | -| **N-13** | `dhara_nand_copy()` 未处理 `DHARA_NO_PAGE` 语义(dst 为哨兵值时仍执行"读+编程") | `nand_ftl.c:174-186` | dhara map 在回收/擦除路径会用 `DHARA_NO_PAGE` 试探;当前实现会把它当作真实页号 → 未定义行为。建议:`if (dst == DHARA_NO_PAGE) { dhara_set_error(err, DHARA_E_UNSUPPORTED); return -1; }`(返回不支持是安全语义) | -| N-14 | 写回缓存 `s_cache_buf/s_cache_dirty` 无掉电保护策略:`CTRL_SYNC` 已实现(`:343-353`)但**没有周期性或掉电前的强制 flush** | `nand_ftl.c:291-353` | 掉电窗口 = 最后一次未跨页的写。建议:空闲 N ms 由 defaultTask flush + 掉电检测(若硬件支持)或至少"日志落盘后立即 fsync" | +| **N-11** | NAND 驱动**直接对调用方缓冲做 DMA**:`HAL_SPI_Receive_DMA(&hspi1, p_buf, size)`(`:200`)、`HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)p_buf, size)`(`:258`),阈值仅 32 B | `gd5f2gq5ue.c:185-217,246-270` | **这是 CCM 迁移的硬边界**:所有走 NAND 的缓冲区(含 `FIL`、FatFs window、任务栈上的临时缓冲)**必须留在 SRAM1**。必须在 `docs/内存管理/` 写成铁律,否则一旦把某任务栈搬进 CCM 就会静默 DMA 失败。**2026-10-11 复核:🟡 模式未变但已加固**——阈值改为 `GD5F_DMA_THRESHOLD 1024`(`gd5f2gq5ue.h:78`),并新增**运行时 DMA 可达性守卫**(`:110-136`,不可达自动回落轮询)。⚠ 复核新发现:堆已迁 CCM(`ucHeap @0x10000020`)→ **"从 FreeRTOS 堆分配的缓冲不可被任何 DMA 直接寻址"成为全局铁律**,比原结论更硬 | +| **N-12** | `dhara_nand_is_free()` 只检查页首 64 B | `nand_ftl.c:137-154` | dhara 依赖该判定做映射恢复;页首 64 B 全 FF 但后半页有数据时会误判为空闲 → 数据丢失风险(低概率但后果重)。建议整页扫描或至少首/中/尾三处采样。**❌ 复核仍在**(现 `nand_ftl.c:152-179`,仍 `uint8_t buf[64]`) | +| **N-13** | `dhara_nand_copy()` 未处理 `DHARA_NO_PAGE` 语义(dst 为哨兵值时仍执行"读+编程") | `nand_ftl.c:174-186` | dhara map 在回收/擦除路径会用 `DHARA_NO_PAGE` 试探;当前实现会把它当作真实页号 → 未定义行为。建议:`if (dst == DHARA_NO_PAGE) { dhara_set_error(err, DHARA_E_UNSUPPORTED); return -1; }`(返回不支持是安全语义)。**❌ 复核仍在**(现 `:204-216`,read→prog 直传) | +| N-14 | 写回缓存 `s_cache_buf/s_cache_dirty` 无掉电保护策略:`CTRL_SYNC` 已实现(`:343-353`)但**没有周期性或掉电前的强制 flush** | `nand_ftl.c:291-353` | 掉电窗口 = 最后一次未跨页的写。建议:空闲 N ms 由 defaultTask flush + 掉电检测(若硬件支持)或至少"日志落盘后立即 fsync"。**🟡 复核:部分改善**——整页写立即 flush(`:377-381`)、换页时 flush(`:365-368`)、`CTRL_SYNC` 走 flush + `dhara_map_sync`(`:400-406`);仍无空闲定时 flush | | P2-3/P2-4 | 部分返回值丢失 | 台账 | 逐条修 | -| N-15 | 页缓冲重复:`nand_ftl` 3×2,048(`:s_page_buf/s_cache_buf/s_copy_buf`)+ `gd5f` 的 `s_bbt_page_buf[2048]`(仅 init 期使用) | 对象 ZI:nand_ftl 6,216 + gd5f 2,596 | BBT 页缓冲可与 `s_copy_buf` 复用或 init 后归还(§5.3 R-3,省 2–4 KB) | -| — | `disk_ioctl GET_BLOCK_SIZE = 1` | `nand_ftl.c` | FatFs 的擦除块提示失效(FAT32 对齐优化拿不到信息);建议返回 64 页(=1 物理块) | +| N-15 | 页缓冲重复:`nand_ftl` 3×2,048(`:s_page_buf/s_cache_buf/s_copy_buf`)+ `gd5f` 的 `s_bbt_page_buf[2048]`(仅 init 期使用) | 对象 ZI:nand_ftl 6,216 + gd5f 2,596 | BBT 页缓冲可与 `s_copy_buf` 复用或 init 后归还(§5.3 R-3,省 2–4 KB)。**🟡 复核:已省一半**——`s_bbt_page_buf` 由 2,048 B 收紧为 **272 B**(2026-09-21);`nand_ftl` 三块 2 KB 未合并(对象 ZI 仍 6,216) | +| — | `disk_ioctl GET_BLOCK_SIZE = 1` | `nand_ftl.c` | FatFs 的擦除块提示失效(FAT32 对齐优化拿不到信息);建议返回 64 页(=1 物理块)。**❌ 复核仍在** | ### 4.4 配置引擎(`App/cfg/cfg_file.c`,1,190 行) -**设计良好**:描述符 + 段注册表、外科式(surgical)保存、模板生成、首启旧档迁移、2 s 轮询(`cfg_file_poll`)响应 `SYS_EVT_CFG_SAVE`,属主单任务化。这是全项目设计最完整的一个模块。 +**设计良好**:描述符 + 段注册表、外科式(surgical)保存、模板生成、2 s 轮询(`cfg_file_poll`)响应 `SYS_EVT_CFG_SAVE`,属主单任务化。这是全项目设计最完整的一个模块。(原"首启旧档迁移"已于 2026-10-10 删除——该一次性路径把整条配置写链挂进启动期调用图,defaultTask 静态最坏链 +600 B。) **问题**: | 编号 | 问题 | 建议 | |------|------|------| -| **N-01(P0)** | **4 处 `malloc` 必失败**:`:485 malloc(CFG_FILE_MAX_LEN+1u)` = 4,097 B;`:522 malloc(CFG_OUT_MAX)` = 5,120 B(`CFG_OUT_MAX 5120`,`:44`);`:1038`/`:1050` 外科式保存两处。而 MicroLIB C 堆只有 **512 B**(`startup_stm32f407xx.s:43 Heap_Size EQU 0x200`;map:`HEAP` ZI `0x200` @`0x2001e878`,`__heap_limit 0x2001ea78`;`malloc.o refers to startup_stm32f407xx.o(HEAP)`)。map 交叉表确认 `cfg_file.o(i.s_load_file)/(i.s_write_template)/(i.cfg_file_save)` → `malloc.o(i.malloc)` | 见 §4.4.1 | | P2-1 | 超长键行处理 | 台账 | -| — | 失败即静默回落工厂默认(`:955-988`:缺文件→模板→return -1;超长→模板→return -1;读失败→"factory defaults kept") | 语义本身合理(不阻塞组网),**但与 N-01 叠加后变成"永远出厂 IP"**,用户改了 IP 重启就丢——这正是最难排查的现场故障形态 | - -#### 4.4.1 N-01 修复方案(推荐 A,2 小时) - -| 方案 | 做法 | 评价 | -|------|------|------| -| **A(推荐)** | `cfg_file.c` 的 4 处 `malloc/free` 改 `pvPortMalloc/vPortFree`(FreeRTOS 堆 48 KB);并在 `CFG_FILE_MAX_LEN`/`CFG_OUT_MAX` 旁加 `_Static_assert` 约束峰值 | 与已完成的 `lftpd_io.c` 迁移一致(map 中 `lftpd_io.o refers to heap_4.o(i.pvPortMalloc)` 已证明该模式可用)。**注意**:48 KB 堆余量仅约 14–15 KB,9 KB 级分配需与 §5.3 的栈/池调整一并规划,否则会把"必失败"变成"偶发失败" | -| B(最稳) | 改**静态缓冲**:`static uint8_t s_cfg_in[CFG_FILE_MAX_LEN+1]` + `s_cfg_out[CFG_OUT_MAX]`。属主已收敛到 defaultTask 单任务 → 无并发风险 | 但当前 SRAM1 只剩 4,488 B,**放不下 9 KB** → 必须与 §5.3 的 CCM 迁移或 LVGL 池收缩一起做 | -| C | 提高 `Heap_Size` 或 retarget `_sys_heap` 到 `pvPortMalloc` | 512 B 是 MicroLIB 的兜底(`printf` 内部也会用);单独放大不解决"两套堆"的根本问题 | - -> **实施记录(2026-09-18,按方案 A 落地,两处必要调整)**:① 分配接口**不写在 `cfg_file.c` 里,而是加到 `cfg_file_store` 后端**(`cfg_store_alloc` / `cfg_store_free` / `cfg_store_heap_free`)——`cfg_file.c` 是双端共享源码,直接写 `pvPortMalloc` 会让 PC 模拟器断链;MCU 后端 = `pvPortMalloc/vPortFree`,模拟器后端 = `malloc/free`。② 编译期峰值约束改用 **typedef 负数组**(`CFG_HEAP_BUDGET = 10,240 B`)——ARMCC 5 只到 C99,**没有 `_Static_assert`**(方案 A 原文此处需修正)。③ 另补 4 条失败路径的 `syslog_post(SYSLOG_SELFTEST, …)` 与 `load: OK …, heap free NB` 实测行。台账:`docs/待处理内容.md` **P0-1**;设计文档:`docs/子系统设计/配置文件设计说明.md` §6.2/§8 + v1.11。 - -> **诚实标注**:本结论由静态分析得出(映射表 + 启动文件 + 调用图三重证据),**尚未上机确认**。`.dsh/skills/serial-boot-test/scripts/verify_log.py`(PowerShell 版 `test/verify_log.ps1:34-35` 已删除,2026-09-20 起为纯 Python)的 Forbidden 标记含 `FAIL`,而失败路径会打印 `read: malloc 4097B FAIL` / `template: malloc 5120B FAIL`,因此**一次 `@test` 即可判定**(若近期 `@test` 曾 PASS,需先确认当时是否真的进入了文件读取分支,例如 `/config.ini` 不存在或大小为 0)。**(2026-09-18 已按上述实施;修复后的真机 `@test` 正向确认——`load: OK` 出现且 `FAIL` 零命中——已于 2026-10-03 执行 PASS,2026-10-04 独立复跑再次 PASS:`[CFG] load: OK (523B, heap free 8840B)`、Forbidden `FAIL`/`ERROR`/`HardFault`/`[ERR]` 零命中、`selftest: no fault record`、ST-LINK 全片回读 `No difference found`。)** +| — | 失败即静默回落工厂默认(`:955-988`:缺文件→模板→return -1;超长→模板→return -1;读失败→"factory defaults kept") | 语义本身合理(不阻塞组网),但**用户改了 IP 重启就丢且无告警**——建议回落时必写一条自检记录并在 UI 装置信息页给出"当前为出厂配置"标识 | ### 4.5 日志子系统(`App/log/sys_log_mcu.c`) **设计良好**:定长 64 B 记录(`SYSLOG_REC_SIZE`)、单写者(defaultTask)、RAM 队列 + `syslog_drain`、容量满时删除重建、`/log` 目录惰性创建。 +> **2026-10-11 复核:日志侧新增了两个基线没有的东西,值得记一笔。** +> ① **控制台单写者日志**(`App/log/console_log.c`,对象 ZI 4,120 B)——把"多任务直接 `printf` 到 USART1"收敛为单写者队列,配套 `DBG_*` 宏;代价已量化进 AGENTS.md(**每条日志约 90 B Flash + 216 B 栈**,深调用链模块另以 `#define DBG_LINE_BUF_MAX 128U` 收紧)。⚠ 这条代价模型正是 defaultTask 静态最坏链从 5,312 → 2,776 B 的主要杠杆,**属本报告"内存"章节应当吸收的新事实**。 +> ② **自检记录可查**:`selftest.log` + 开机 `selftest: no fault record` 标记 + Fault 现场 `.noinit` 跨复位 → 现场故障从"不可知"变成"可回读"。 + **问题**: | 编号 | 问题 | 证据 | 建议 | |------|------|------|------| -| **N-16** | **每条记录一次 `f_open + f_write + f_close`**(`:89-95`),读侧同样每条 `f_open/f_lseek/f_close`(`:118-126`) | 每条日志触发 FatFs 目录项读写 → 2–4 次 NAND 页读写;UI 翻记录时逐条 open/seek/close | 写侧:常驻 `FIL`(仅 defaultTask 持有)+ 批量 flush;读侧:打开一次 + 缓存目录偏移(UI 已有 `s_cache_valid/s_last_rec` 雏形,可升级为页缓存) | +| **N-16** | **每条记录一次 `f_open + f_write + f_close`**(`:89-95`),读侧同样每条 `f_open/f_lseek/f_close`(`:118-126`) | 每条日志触发 FatFs 目录项读写 → 2–4 次 NAND 页读写;UI 翻记录时逐条 open/seek/close | 写侧:常驻 `FIL`(仅 defaultTask 持有)+ 批量 flush;读侧:打开一次 + 缓存目录偏移(UI 已有 `s_cache_valid/s_last_rec` 雏形,可升级为页缓存)。**❌ 2026-10-11 复核仍在**:`sys_log_mcu.c:123-140` 每条仍 open/write/close(失败重建重试一次),`wdev.log` 同模式(`:481-484`);🟡 唯一改善 = `FIL` 已 static 化(`:70`,省 528 B 栈),**无常驻打开句柄、无批量 flush** | | P2-9 | `mkdir` 失败后不再重试(`s_fs_ready` 逻辑,`:158-165`) | 台账 | 失败时保持未就绪 + 定期重试 + 上报自检 | | P2-10 | 容量轮转 `f_unlink`(`:64`)与 UI 读并发 | 台账 | 轮转只在写者、读者持版本号 | @@ -385,13 +325,15 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 见 §6 专题(同时是"标准化"章节)。此处仅记设计层结论:三层拆分(数据 `ui_menu_tab` / 视图 `ui_menu_view` / 控制器 `ui_menu`)+ 内容页描述符框架**方向正确**,但**框架未收敛到"新页面零框架改动"**,且 4 份 3,200 B 帧副本占 12.8 KB RAM(§5)。 +> **2026-10-11 复核:两条结论均未变化。** 框架仍要求"改 `ui_menu.c` 的手写 `case` + 加实例文件 + 双端注册"才能新增一页(§6.2 的 8 步一字未减);帧副本仍 4 份(`ui_port.o` ZI 6,420 + `ui_net_sink.o` 6,766 = 13,186 B,R-4 未办)。✅ 唯一实质改善在**验证侧**:golden 回归已建(§6.1),UI 改动从此有逐像素/逐字节基线可依赖。 + ### 4.7 外设驱动 | 模块 | 评价 | 问题 | |------|------|------| | `sd2506`(RTC) | I2C 超时齐备,降级策略明确(RTC 故障→默认时基 + 自检上报),**良好** | P2-5(负温度解析)、P2-6(WRTC 解锁) | -| `tpafe5160`(并行 ADC) | BUSY 超时、EXTI 读取路径齐备 | **P1-1:采样结果无人消费(数据路径死)**;并行总线无 DMA/批量采样框架,16 位端口读依赖 `gpio.c` 初值 | -| `rs485` | 结构简单 | DE 时序用 `HAL_Delay(1)` **阻塞 1 ms**(在调用任务里);`crc.c`/`ringbuf.c` 未注册 → 无帧校验、无接收环 | +| `tpafe5160`(并行 ADC) | BUSY 超时、EXTI 读取路径齐备;取数已收口为 `tpafe5160_snapshot()` 原子快照(帧序号只增不清 + FRSTDATA 自检) | ⚠ **V0.1 硬件缺陷(登记簿 N-18,V0.2 改版项;注意与本报告 N-18 同号不同义)**:8 路电气量被分给两片转换器 → 主板侧**禁止**跨转换器做相位/功率/功率因数运算;并行总线无批量采样框架,16 位端口读依赖 `gpio.c` 初值 | +| `rs485` | 结构简单 | DE 时序用 `HAL_Delay(1)` **阻塞 1 ms**(在调用任务里);`crc.c`/`ringbuf.c` 未注册 → 无帧校验、无接收环。**❌ 复核:`HAL_Delay(1)` 仍在(`rs485.c:91`)**;`crc.c`/`ringbuf.c` 已删除(2026-10-02)→ 帧校验/接收环**从零重做**;`rs485_task.c` 仍未注册,消费者被 E-50(包撕裂)/E-49(TX 超时拉 DE)挡住 | | `ch395f` | 事务包裹 `begin/end`、SPI 分频 8 为 PCB 约束(已文档化,勿动) | 静态 DMA 缓冲 2×2,052 B(obj ZI 4,105),必须留 SRAM1 | **驱动共性缺口**:没有统一的"错误码 + 超时 + 重试 + 自检上报"约定,各模块自定返回码(`GD5F_OK` / `0` / `bool` 混用)。**建议(S-5)**:定义 `bsp_err_t` 统一错误码 + `bsp_retry()` 辅助 + 所有失败必须走 `syslog(SYS_SELFTEST,...)`,作为驱动编写规范写入 `docs/代码规范/`。 @@ -400,131 +342,140 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 **设计良好**:`test/board/test_config.h` 的 `TEST_SUITE_*` 互斥(`#error` 保护)、CH395F 硬件层/NET 层 Phase 互斥、`@test` 一键闭环(烧录→wait-idle→标记校验)、模拟器 `--cfgtest/--logtest`。 +> ⚠ 仍缺:**覆盖率概念**(原建议未办)、**注册检查**(`check_registration.py`)、**主机侧对 `lftpd`/`dhara` 移植层的测试**(N-12/N-13 这类缺陷至今只能靠人工读代码发现)。 + **问题**: | 编号 | 问题 | 建议 | |------|------|------| -| P1-10 | CH395F 硬件测试套件 RAM 溢出(`L6406E`):HW 套 +8.7 KB、NET 层固件 +28 KB(`s_rx_buf` 32 KB) | 测试缓冲改为**分 Phase 复用同一块静态缓冲**(union 或分阶段 `memset`);`待处理内容.md` 的这条待办应升级为正式任务 | -| P2-14 | `build.bat` 用子串匹配判定 PASS → 假通过 | 改为解析 `"0 Error(s), 0 Warning(s)"` 精确串 + 退出码双条件 | -| P2-15 | `download.bat` 可能读旧日志 | 烧录前删除/打时间戳 | -| **N-17** | **无栈/堆水位断言**:测试通过不代表栈余量安全 | 在每个测试套件收尾打印 `uxTaskGetStackHighWaterMark(NULL)` + `xPortGetFreeHeapSize()`,把"通过"变成"通过且有余量"(配合 §5.2 的自检任务) | -| N-18 | 无 CI(仅本地 pre-commit);无覆盖率概念 | 至少加一条 GitHub Actions/本地脚本:`cppcheck --all` + `build.bat`(若可 headless)+ `check_registration.py` + `check_doc_links.py` | +| P1-10 | CH395F 硬件测试套件 RAM 溢出(`L6406E`):HW 套 +8.7 KB、NET 层固件 +28 KB(`s_rx_buf` 32 KB) | 测试缓冲改为**分 Phase 复用同一块静态缓冲**(union 或分阶段 `memset`);`待处理内容.md` 的这条待办应升级为正式任务。**🟡 2026-10-11 复核:已升级为登记簿唯一 P 类收尾项**(RAM 侧因堆迁 CCM 大幅缓解,剩"CH395F 套件构建实测链接确认 + `@test` 闭环"两步) | --- ## 5. 内存优化(量化) -### 5.1 实测基线(map,2026-09-16) +### 5.1 实测基线(map) -| 指标 | 数值 | 上限 | 占用 | -|------|------|------|------| -| `Code + RO-data` | 245,384 B(239.6 KB) | 1 MB Flash | **23.4%** | -| `ROM`(含 RW 初始化表) | 246,016 B | 1 MB | 23.5% | -| `RW-data` | 632 B | — | — | -| `ZI-data` | 125,952 B | — | — | -| **SRAM1(0x20000000,128 KB)** | **126,584 B** | **131,072 B** | **96.6%(余 4,488 B)** | -| **CCM(0x10000000,64 KB)** | **0 B** | 65,536 B | **0%** | +| 指标 | 2026-09-16(审查基线) | **2026-10-11 复核** | 上限 | 复核占用 | +|------|----------------------|--------------------|------|---------| +| `Code + RO-data` | 245,384 B(239.6 KB) | **273,860 B**(267.4 KB) | 1 MB Flash | **26.1%** | +| `ROM`(含 RW 初始化表) | 246,016 B | **274,060 B** | 1 MB | 26.1% | +| `RW-data` | 632 B | 824 B | — | — | +| `ZI-data` | 125,952 B | **139,896 B** | — | — | +| **SRAM1(0x20000000,128 KB)** | **126,584 B / 96.6%(余 4,488 B)** | **91,488 B** | 131,072 B | **69.8%(余 39,584 B)** | +| **CCM(0x10000000,64 KB)** | **0 B / 0%** | **49,232 B**(`RW_CCM` 49,184 + `UNINIT_CCM` 48) | 65,536 B | **75.1%(余 16,304 B)** | +| `Total RW (RW+ZI)` | 126,584 B | **140,720 B** | — | — | -`.sct` 只声明了 `ER_IROM1` + `RW_IRAM1 0x20000000 0x00020000 { .ANY (+RW +ZI) }`;文件头注释说明"未使用 SRAM3(克隆片可能没有)"——但**CCM 是 F407 片上必定存在的 64 KB**,与克隆风险无关(GD32F407 亦有 CCM)。 +> **2026-10-11 二次实测(LVGL 死代码裁剪后,F-1/F-2/F-3 已落地)**:`Code=194,276 / RO=15,872 / RW=876 / ZI=139,892` → `ER_IROM1` **210,148 B(20.0%)**、`ROM 合计` **211,024 B(20.1%)**;`RW_IRAM1` 91,536 B(69.8%)与 `RW_CCM` 49,184 B(75.3%)**不变**(本次只动 Flash)。§5.4 的口径以此为准。 -**RW+ZI 主要对象(实测 obj ZI/RW)** +**复核结论:R-2(启用 CCM)已部分落地**——`.sct` 现为手工 scatter,除 `ER_IROM1` + `RW_IRAM1 0x20000000 0x00020000` 外新增 `RW_CCM 0x10000000 0x0000FF00`(**只收 `heap_4.o (+RW +ZI)`**)与 `UNINIT_CCM 0x1000FF00 UNINIT 0x00000100`(`*(.noinit)`,崩溃现场跨复位保留区)。→ **FreeRTOS 堆 `ucHeap` 49,152 B 已迁 CCM**(.map `ucHeap 0x10000020`),任务栈从此不占 SRAM1,SRAM1 因此从 96.6% 降到 **69.8%**。⚠ **未迁的部分仍在 SRAM1**:LVGL 池 `work_mem_int` 40,960 B(`0x20008d00`)、UI 帧/绘制缓冲 `ui_port.o` 6,420 B、UI 网络发送 `ui_net_sink.o` 6,766 B——即 R-2 的"−54,122 B"只兑现了 49,184 B(堆),**LVGL 池与 UI 缓冲这 54 KB 里还剩 54,146 B 在 SRAM1**(见 §5.3 R-2 状态)。 -| 对象 / 符号 | 字节 | 归属 | DMA 可达性 | -|-------------|------|------|-----------| -| `ucHeap`(`heap_4.c`,含 `heap_4.o` 合计 49,184) | 49,184 | FreeRTOS 堆 | 任意 | -| `work_mem_int`(`lv_mem_core_builtin.o`,= `LV_MEM_SIZE 40*1024`) | 40,960 | LVGL 池 | **CPU-only(可进 CCM)** | -| `ui_port.o`:`g_ui_fbuf[3200]` + `g_ui_draw_buf[3200]` | 6,412 | UI 帧/绘制缓冲 | 可进 CCM | -| `ui_net_sink.o`:`g_tx[4+4+3200+1]` + `g_lcdmem_data[4+3200]` + `g_rx[64]` | 6,762 | UI 网络发送 | 可进 CCM(CH395F 侧 memcpy 到自己缓冲) | -| `nand_ftl.o`(3×2,048 页缓冲 + 控制块) | 6,216 | 存储 | **必须 SRAM1** | -| `ch395f.o`(`s_spi2_dma_tx/rx[2052]`) | 4,105 | 网络 | **必须 SRAM1** | -| `gd5f2gq5ue.o`(`s_bbt_page_buf[2048]` + BBT 表) | 2,596 | 存储 | **必须 SRAM1** | -| `cmsis_os2.o` | 1,724 | RTOS 胶水 | 任意 | -| `startup_...o`:`STACK 0x400` + `HEAP 0x200` | 1,536 | MSP + MicroLIB 堆 | 任意 | -| `tasks.o`(含 `pxReadyTasksLists[56]` = 1,120) | 1,280 | RTOS | 任意 | -| `freertos.o` | 1,104 | 任务薄壳 | — | -| `default_fatfs`(FATFS,含 `win[512]`,定义于 `Src/freertos.c`,计入 `freertos.o` 的 1,104 B) | ≈560 | FatFs 卷对象 | **必须 SRAM1** | -| `lftpd_io.o`(`s_ftp_file` FIL) | 556 | FatFs 文件句柄 | **必须 SRAM1** | -| `cfg_file.o` | 724 | 配置引擎 | 任意 | -| `lv_init.o`(`lv_global`) | 496 | LVGL | 任意 | -| `net_socket.o` | 304 | 网络 | 任意 | +> **基线口径说明**:上表取 **2026-10-11 00:38 链接的 `.map`(HEAD `54d6d2d`,干净树)**。复核期间工作区另有**未提交**改动(`adcTask` 栈 512→**640 words**、新增 `App/algo/adc_health.c/h` + 主机侧套件),其 01:12 重链接结果为 `Code=258,520 / RO=16,208 / RW=880 / ZI=139,896` → `Code+RO` **274,728 B(26.2%)**、`RW_IRAM1` **91,544 B(69.8%)**、`RW_CCM` 49,184 B(75.3%)。差异仅 +868 B Code / +56 B SRAM1,**不影响本节任何结论**;栈迁 CCM 后任务栈大小变化只影响堆内占用,不改 `ZI` 总量。 -**两个静态池(49,184 + 40,960 = 90,144 B)就占了整个 128 KB 的 71%**——这是问题的核心。 +**RW+ZI 主要对象(2026-10-11 复核,`scripts/ram_report.py` 排行;括号内为 2026-09-16 基线)** -**Flash 侧(Code+RO,214 个含码对象)** +| 对象 / 符号 | 字节 | 所在区域 | 归属 | DMA 可达性 | +|-------------|------|---------|------|-----------| +| `ucHeap`(`heap_4.o`) | 49,184(49,184) | **CCM**(`0x10000020`) | FreeRTOS 堆 | **DMA 不可达**——凡从堆里分配且被 DMA 直接寻址的缓冲都会失效(N-11 由此升级为**结构性约束**,见下) | +| `work_mem_int`(`lv_mem_core_builtin.o`,= `LV_MEM_SIZE 40*1024`) | 40,960(40,960) | **SRAM1**(`0x20008d00`) | LVGL 池 | 纯 CPU,**可迁 CCM(R-2 未完成项)** | +| `ui_net_sink.o`(`g_tx` + `g_lcdmem_data` + `g_rx`) | 6,766(6,762) | SRAM1 | UI 网络发送 | 可迁 CCM(CH395F 侧 memcpy 到自有 SRAM1 缓冲) | +| `ui_port.o`(`g_ui_fbuf` + `g_ui_draw_buf`) | 6,420(6,412) | SRAM1 | UI 帧/绘制缓冲 | 可迁 CCM | +| `nand_ftl.o`(3×2,048 页缓冲 + 控制块) | 6,216(6,216) | SRAM1 | 存储 | **必须 SRAM1**(N-11) | +| `lftpd.o` | 4,768 | SRAM1 | FTP | 任意 | +| `console_log.o`(行环形缓冲) | 4,120 | SRAM1 | 日志 | 任意(TX 走 DMA → 实际必须 SRAM1) | +| `ch395f.o`(`s_spi2_dma_tx/rx[2052]`) | 4,106(4,105) | SRAM1 | 网络 | **必须 SRAM1** | +| `default_task.o` | 2,728 | SRAM1 | 任务薄壳 | — | +| `gd5f2gq5ue.o`(`s_bbt_page_buf[2048]` + BBT 表) | ≈2,596(2,596) | SRAM1 | 存储 | **必须 SRAM1** | +| `sys_log_mcu.o` | 1,924 | SRAM1 | 日志 | 任意 | +| `cmsis_os2.o` | 1,724(1,724) | SRAM1 | RTOS 胶水 | 任意 | +| `sample_ring.o`(L2 输入 2 周波环缓冲,**新增**) | 1,448 | SRAM1 | 算法 | 任意 | +| `startup_...o`:`STACK 0x400`(`HEAP 0x200` 已被链接器移除) | 1,024(1,536) | SRAM1 | MSP + MicroLIB 堆 | 任意 | +| `tasks.o`(含 `pxReadyTasksLists[56]` = 1,120) | 1,280(1,280) | SRAM1 | RTOS | 任意(`configMAX_PRIORITIES` 仍 56 → **R-5 未办**) | -| 子系统 | Code+RO | 占镜像 | -|--------|---------|--------| -| LVGL(含核心/绘制/控件/字体/缓存/图像) | ≈150,600 | **61.5%** | -| 手写 UI 页面(`ui_*.o`,5,875 行) | 17,520 | 7.2% | -| 网络(NET + NETCFG + lftpd) | 18,245 | 7.4% | -| HAL/CMSIS | 11,866 | 4.8% | -| 应用基础(App/ + Src/) | 10,468 | 4.3% | -| FreeRTOS + CMSIS-RTOS | 8,011 | 3.3% | -| FatFs + dhara + NAND | 8,222 | 3.4% | +**两个静态池(49,184 + 40,960 = 90,144 B)仍是 RAM 问题的核心**,但**已分居两区**:堆在 CCM(占 CCM 的 75.3%)、LVGL 池在 SRAM1(占 SRAM1 的 45%)。→ 下一步收益点在**把 LVGL 池 + UI 两份缓冲(54,146 B)也搬进 CCM**,或按 R-9 先实测下调 `LV_MEM_SIZE`。⚠ 堆进 CCM 带来一条新铁律:**从 FreeRTOS 堆分配的缓冲不可被 DMA 直接寻址**(DMA2 不可达 `0x10000000`)——本仓已实测到同款约束(ADC1 DMA 目的缓冲必须在 `RW_IRAM1`),应写入 `docs/内存管理/`。 -### 5.2 结构性诊断(四条) +**Flash 侧(Code+RO)**:基线为 214 个含码对象;**2026-10-11 复核为 182 个含码对象**(按 `.map` 组件表对象名归类,`App/`+`Src/`+`Drivers/BSP/` 合并为"应用/驱动自研",算法层单列) + +| 子系统 | Code+RO(基线) | 占镜像 | **2026-10-11** | **占镜像** | +|--------|----------------|--------|----------------|-----------| +| LVGL(含核心/绘制/控件/字体/缓存/图像) | ≈150,600 | **61.5%** | **152,185** | **55.4%** | +| 应用/驱动自研(`App/` + `Src/` + `Drivers/BSP/`,不含 UI 页面) | 10,468(仅 App+Src) | 4.3% | **27,076** | 9.9% | +| 网络(NET + NETCFG + lftpd) | 18,245 | 7.4% | **21,629** | 7.9% | +| 手写 UI 页面/框架(`ui_*.o`,现 6,036 行) | 17,520 | 7.2% | **18,535** | 6.8% | +| 存储(FatFs + dhara + NAND) | 8,222 | 3.4% | **15,133** | 5.5% | +| HAL/CMSIS | 11,866 | 4.8% | **14,391** | 5.2% | +| FreeRTOS + CMSIS-RTOS | 8,011 | 3.3% | **12,258** | 4.5% | +| 算法层 L1/L2(`adc_input`/`phasor`/`sample_ring`,**新增**) | — | — | **1,589** | 0.6% | +| 其他(startup/CMSIS 胶水等) | — | — | 5,842 | 2.1% | +| **合计(含库成员与填充)** | **245,384** | 100% | **274,728** | 100% | + +**复核要点**:LVGL 的**绝对量几乎没动**(150,600 → 152,185),占比从 61.5% 降到 55.4% 纯粹是因为**自研侧涨了 ≈30 KB**(看门狗 + Fault 采集 + ADC 全链 + 相量层 + 日志/观测)。→ **§5.4 的裁剪结论依旧成立且更划算**:省下的 54.6 KB 现在相当于镜像的 **19.9%**。 + +### 5.2 结构性诊断(四条 + 未闭环部分) 1. **64 KB CCM 空转**,而 96.6% 的负载压在 128 KB SRAM1 上。 -2. **任务栈全部动态取自 FreeRTOS 堆**:`osThreadAttr_t` 只设 `stack_size` 不设 `stack_mem`(`Src/freertos.c:64-105`)→ 6 条栈 **32,768 B** + idle/timer 栈 + TCB + IPC 全部从 48 KB 池里扣,**留给动态分配的实际余量仅约 14–15 KB**。这也是为什么 N-01 若改走 `pvPortMalloc` 仍需先扩容/腾挪。 -3. **零观测**:`xPortGetFreeHeapSize`/`xPortGetMinimumEverFreeHeapSize` 在 map 中只以"linker removed"出现(从未被调用),`uxTaskGetStackHighWaterMark` 全库零调用,`lv_mem_monitor` 仅在 `ui_menu_init` 打过一次。**无法回答"还能加多少"**。 -4. **MicroLIB 512 B C 堆是隐形地雷**:任何新代码写一个 `malloc` 都会中招(N-01 已是实例)。 + → **未闭环**:LVGL 池 **40,960 B** + UI 两份缓冲 **13,186 B** 仍在 SRAM1;堆迁入后 **CCM 只剩 16,304 B,装不下这 54 KB** → R-2 的剩余部分**必须先做 R-9(下调 `LV_MEM_SIZE`)或 R-4(帧副本去重)**才有余地。 +2. **任务栈全部动态取自 FreeRTOS 堆**:`osThreadAttr_t` 只设 `stack_size` 不设 `stack_mem`(`Src/freertos.c:64-105`)→ 栈 + idle/timer 栈 + TCB + IPC 全部从堆里扣,**留给动态分配的实际余量很紧**。 + → **未闭环**:7 条任务(`defaultTask/netTask/ftpTask/testTask/uiTask/timeSyncTask/adcTask`)栈合计 **10,176 words = 40,704 B**,仍全部动态取自堆。实测 `load: OK (523B, heap free 8840B)` → 空闲约 **8.8 KB / 49,152 B**,历史最低水位(`xPortGetMinimumEverFreeHeapSize`)**6,640 B**(2026-10-11 L2 相量 + L1-6 上链后由 7,152 B 降下来);同日 `adcTask` 栈由 512 抬到 **640 words**(静态最坏链 1,080 B),继续蚕食堆余量。**R-6(栈静态化)是把"池内碎片"变"可核算静态区"的正解,且其前置条件(观测常开)已满足。** +3. **零观测**:`xPortGetFreeHeapSize`/`xPortGetMinimumEverFreeHeapSize`/`uxTaskGetStackHighWaterMark` 全库零调用,无法回答"还能加多少"。 + → 已解决(三面观测:`[STACK]` 运行时水位 + `scripts/stack_report.py` 静态最坏链 + `ram_report.py`/`check_mem_numbers.py` 门禁),不再列为待办。 +4. **MicroLIB 512 B C 堆是隐形地雷**:任何新代码写一个 `malloc` 都会中招。 + → **未闭环**:`Heap_Size EQU 0x200` 未变、`.map` 仍 `Removing startup_stm32f407xx.o(HEAP), (512 bytes)`;铁律已进 AGENTS.md(只用 `pvPortMalloc/vPortFree` 或收进平台后端),但**没有机械化检查**——`check_coding_standard.py` 未含 `malloc(` 正则 → §5.5 第 5 条未落地。 ### 5.3 RAM 优化路线(收益/风险/优先级) -| 编号 | 措施 | 收益 | 风险 | 优先级 | -|------|------|------|------|--------| -| **R-1** | **开启 `configCHECK_FOR_STACK_OVERFLOW = 2` + `configUSE_MALLOC_FAILED_HOOK = 1`**(钩子里打印任务名 + 触发 IWDG 复位) | 0(安全网) | 极低;每任务 +几十 B、切换略增开销 | **P0,立即** | -| **R-2** | **启用 CCM**:`.sct` 增 `RW_IRAM2 0x10000000 0x00010000 { *(CCM_DATA) *(.bss.CCM_DATA) }`;对 LVGL 池用 `LV_MEM_ATTR __attribute__((section("CCM_DATA")))`,UI 四份帧缓冲同样加段属性;用 `APP_CCM_ENABLE` 宏包裹(克隆片兜底 = 全部回 SRAM1) | **−54,122 B(52.9 KiB)** = 40,960 + 6,400 + 6,762 | 中:必须确保这些缓冲**不被任何 DMA 直接寻址**。已核实 UI 发送路径 memcpy 进 CH395F 自有 SRAM1 DMA 缓冲(`ch395f.c:1132-1190`),LVGL 为纯 CPU;**NAND 路径(N-11)绝对不可迁入** | **P1,本季度** | -| **R-3** | 存储页缓冲合并:`gd5f.s_bbt_page_buf` 与 `nand_ftl.s_copy_buf` 复用(BBT 仅在 init 期使用);`nand_ftl` 三块 2 KB 中 `s_page_buf`(dhara scratch) 与 `s_copy_buf` 不可并发 | **−2 ~ −4 KB** | 中:需严格审查 dhara 调用序列(copy 期间不会用 map scratch) | P2 | -| **R-4** | UI 帧副本去重:同一 3,200 B 帧当前存 **4 份**(`g_ui_draw_buf` → `g_ui_fbuf` → `g_lcdmem_data` → `g_tx`)。让 `ui_flush_cb` 直接把 `color_p` 交给 sink、sink 在发送时才拼帧头(就地写 8 B 头 + 复用同一 buffer) | **−6.4 ~ −9.6 KB** | 中:需要 sink 与 LVGL 刷新生命周期对齐(同任务,可控) | P1 | -| **R-5** | `configMAX_PRIORITIES` 56 → 32(实际最高用到 `osPriorityHigh` 级别) | **−480 B** | 低 | P2 | -| **R-6** | 任务栈改**静态分配**(`stack_mem` 指向各自 `.bss` 数组),从堆里释放 32,768 B | 堆可用 +32 KB(**总量不变**,但把"池内碎片"变成"可核算静态区") | 低-中:静态后溢出检测更难发现;建议配合 R-1 先测水位再定尺寸 | P2 | -| **R-7** | testTask 门控(P2-13) | **−4 KB** | 低 | P1 | -| **R-8** | 观测常开:新增 `memStatsTask`(最低优先级,1 Hz,512 w)打印 `xPortGetFreeHeapSize` / `xPortGetMinimumEverFreeHeapSize` / 各任务高水位 / `lv_mem_monitor`,并支持串口命令按需打印 | 0(可核算) | 低 | **P1**(是 R-2/R-6 的前置) | -| R-9 | `LV_MEM_SIZE` 40 KB → 依 R-8 实测峰值下调(既有方案预估 16 KB) | −16 ~ −24 KB | 低(有 R-8 兜底) | P2 | +| 编号 | 措施 | 收益 | 风险 | 优先级 | **2026-10-11 状态** | +|------|------|------|------|--------|---------------------| +| **R-2** | **启用 CCM**:`.sct` 增 `RW_IRAM2 0x10000000 0x00010000 { *(CCM_DATA) *(.bss.CCM_DATA) }`;对 LVGL 池用 `LV_MEM_ATTR __attribute__((section("CCM_DATA")))`,UI 四份帧缓冲同样加段属性;用 `APP_CCM_ENABLE` 宏包裹(克隆片兜底 = 全部回 SRAM1) | **−54,122 B(52.9 KiB)** = 40,960 + 6,400 + 6,762 | 中:必须确保这些缓冲**不被任何 DMA 直接寻址**。已核实 UI 发送路径 memcpy 进 CH395F 自有 SRAM1 DMA 缓冲(`ch395f.c:1132-1190`),LVGL 为纯 CPU;**NAND 路径(N-11)绝对不可迁入** | **P1,本季度** | 🟡 **只做一半**:`.sct` 加了 `RW_CCM`,但**只收 `heap_4.o`**(49,184 B)→ SRAM1 −49,184 B。LVGL 池 + UI 两份缓冲(54,146 B)**未迁**;`APP_CCM_ENABLE` 宏**未采用**(该宏已从 `app_cfg.h` 删除)。⚠ 复核新增前置条件:**CCM 只剩 16,304 B,这 54 KB 装不下** → 必须先做 R-9 或 R-4 | +| **R-3** | 存储页缓冲合并:`gd5f.s_bbt_page_buf` 与 `nand_ftl.s_copy_buf` 复用(BBT 仅在 init 期使用);`nand_ftl` 三块 2 KB 中 `s_page_buf`(dhara scratch) 与 `s_copy_buf` 不可并发 | **−2 ~ −4 KB** | 中:需严格审查 dhara 调用序列(copy 期间不会用 map scratch) | P2 | ❌ 未办(合并未做);🟡 但 `s_bbt_page_buf` 已由 2,048 B 收紧为 **272 B**(2026-09-21,等效省 1,776 B) | +| **R-4** | UI 帧副本去重:同一 3,200 B 帧当前存 **4 份**(`g_ui_draw_buf` → `g_ui_fbuf` → `g_lcdmem_data` → `g_tx`)。让 `ui_flush_cb` 直接把 `color_p` 交给 sink、sink 在发送时才拼帧头(就地写 8 B 头 + 复用同一 buffer) | **−6.4 ~ −9.6 KB** | 中:需要 sink 与 LVGL 刷新生命周期对齐(同任务,可控) | P1 | ❌ 未办(`ui_port.o` 6,420 + `ui_net_sink.o` 6,766 = 13,186 B,副本数未变) | +| **R-5** | `configMAX_PRIORITIES` 56 → 32(实际最高用到 `osPriorityHigh` 级别) | **−480 B** | 低 | P2 | ❌ 未办(`FreeRTOSConfig.h:69` 仍 56;`pxReadyTasksLists[56]` = 1,120 B 仍在) | +| **R-6** | 任务栈改**静态分配**(`stack_mem` 指向各自 `.bss` 数组),从堆里释放 32,768 B | 堆可用 +32 KB(**总量不变**,但把"池内碎片"变成"可核算静态区") | 低-中:静态后溢出检测更难发现;先测水位再定尺寸 | P2 | ❌ 未办(仍全部 `stack_size` 动态)。⚠ 前置条件已满足:观测已三面常开,`stack_report.py` 已给出每任务最坏链 → 可直接按实测值定尺寸 | +| **R-8** | 观测常开:新增 `memStatsTask`(最低优先级,1 Hz,512 w)打印 `xPortGetFreeHeapSize` / `xPortGetMinimumEverFreeHeapSize` / 各任务高水位 / `lv_mem_monitor`,并支持串口命令按需打印 | 0(可核算) | 低 | **P1**(是 R-2/R-6 的前置) | 🟡 **目标达成、形态不同**:未建 `memStatsTask`,改为 ① 开机/自检打印 `[STACK]` 水位 + 堆空闲;② `scripts/stack_report.py`(静态最坏链)+ `ram_report.py`;③ `check_mem_numbers.py` 门禁 + `docs/内存管理/data/` 快照。**已足够支撑 R-2/R-6 决策** | +| R-9 | `LV_MEM_SIZE` 40 KB → 依 R-8 实测峰值下调(既有方案预估 16 KB) | −16 ~ −24 KB | 低(有 R-8 兜底) | P2 | ❌ 未办(`lv_conf.h:44` 仍 `(40 * 1024)`)。⚠ 复核:`simulator/tests/check_content_pages.py` 已带**池水位门禁** → 实测峰值数据已具备,本条只差改数值 + 真机复跑(⚠ 两端 `lv_conf.h` 要同步改) | **组合结果预估(注意措施之间存在重叠,不可简单相加)** | 组合 | SRAM1 变化 | 说明 | |------|-----------|------| | 基线 | 126,584 B / **96.6%** | 余 4,488 B | -| + R-2(CCM 启用) | **72,462 B / 55.3%** | −54,122 B;CCM 用 54,122 B(仍余 11 KB) | -| + R-7(testTask 门控) | **68,366 B / 52.2%** | −4,096 B | -| + R-3(存储页缓冲合并) | **≈66.3 KB / 50.6%** | −2,048 ~ −4,096 B | +| **+ 实际执行(2026-10-11 实测)** | **91,488 B / 69.8%** | 余 **39,584 B**。实际做的 ≠ 预估路径:**R-2 只搬堆(−49,184 B → CCM 49,232 B / 75.1%)** + R-7(−8,192 B)+ BBT 缓冲收紧(−1,776 B);抵消项 = adcTask 栈、`sample_ring` 1,448 B、`console_log` 4,120 B、Fault/看门狗/日志新增 | +| + R-2(CCM 启用,原预估全量) | **72,462 B / 55.3%** | −54,122 B;CCM 用 54,122 B(仍余 11 KB)。⚠ 复核:该预估**已不可直接执行**——堆先占了 CCM 49,184 B,CCM 只剩 16,304 B,装不下 LVGL 池 + UI 缓冲这 54 KB | +| + R-3(存储页缓冲合并) | **≈66.3 KB / 50.6%** | −2,048 ~ −4,096 B(BBT 收紧已等效省 1,776 B) | | + R-5(优先级 56→32) | **≈65.8 KB / 50.2%** | −480 B | > ⚠️ **R-2 与 R-4 不可叠加计算**:R-4(UI 帧副本 4→2)省的是"副本份数",R-2 已把这四份缓冲整体搬到 CCM,因此 **R-4 在 R-2 之后省的是 CCM 而非 SRAM1**(CCM 侧 13,162 B → 6,762 B,为后续 UI 扩展腾出 6.4 KB CCM 空间)。若只做 R-4 不做 R-2,则直接为 SRAM1 省 6.4 KB——两条路线对 RAM 总量等价,**但顺序决定收益落在哪个区域**:先做 R-2 时,R-4 应重新定性为"CCM 优化"。 -最终形态:**SRAM1 约 50%、CCM 约 20% 使用率**,`待处理内容.md` 里记的 `L6406E`(P1-10 测试镜像溢出)随之消解,并为 ADC 数据路径(P1-1)、RS485 协议栈(`crc.c`/`ringbuf.c` 注册)预留出预算空间。 +最终形态(原预估):**SRAM1 约 50%、CCM 约 20% 使用率**,`L6406E`(P1-10 测试镜像溢出)随之消解,并为 ADC 数据路径与 RS485 协议栈预留预算空间。 -> 与 `docs/内存管理/RAM使用分析与优化方案.md` 的关系:本表**沿用其阶段划分**(阶段 0 测量 / 阶段 1.1 池收缩 / 阶段 1.2 CCM / 阶段 2 堆收缩),新增的是 ①R-4 帧副本去重(原文档未识别 4 份副本)②R-3 存储侧缓冲合并 ③N-11 的 **DMA 可达性硬边界**(原文档未提出,属实施风险关键点)④R-1/R-8 的观测前置条件。 +> **2026-10-11 修正(未闭环部分)**:原预估的**执行顺序已不可按原样走**——实际走到 **SRAM1 69.8% / CCM 75.3%**,因为选了"先搬堆"而非"先搬 LVGL 池 + UI 缓冲",**CCM 已被堆占掉 75%(只剩 16,304 B)**。要拿到剩余收益,顺序必须改为 **R-9(下调 `LV_MEM_SIZE`,最省事的杠杆)或 R-4(帧副本去重)→ 再做 R-2 后半段**。另两条仍开放:**`L6406E` 未完全消解**(登记簿留 P1-10 收尾:CH395F 套件构建实测链接确认 + `@test` 闭环);RS485 协议栈前置件 `crc.c`/`ringbuf.c` 已删除,**需从零重做**而非"注册"。 -### 5.4 Flash 优化(≈50 KB 可证明死代码) +> 与 `docs/内存管理/RAM使用分析与优化方案.md` 的关系:本表**沿用其阶段划分**(阶段 0 测量 / 阶段 1.1 池收缩 / 阶段 1.2 CCM / 阶段 2 堆收缩),新增的是 ①R-4 帧副本去重(原文档未识别 4 份副本)②R-3 存储侧缓冲合并 ③N-11 的 **DMA 可达性硬边界**(原文档未提出,属实施风险关键点)④观测前置条件(已落地,见 §5.5)。 -**根因:`Lib/lv_conf.h` 与 LVGL 9.3 漂移。** 该文件手写裁剪过(126 行),但: -- 设了 `LV_USE_THEME_DEFAULT 0` 与**已不存在的** `LV_USE_THEME_BASIC 0`(`:70-71`); -- **未设 `LV_USE_THEME_SIMPLE`** → `lv_conf_internal.h:2606` 默认 1 → `lv_display.c:121-125` 在建 display 时执行 `disp->theme = lv_theme_simple_init(disp)`; -- `lv_theme_simple.o(i.theme_apply)` 引用 `lv_label_class / lv_button_class / lv_buttonmatrix_class / lv_bar_class / lv_textarea_class` → 这些控件全被拉进镜像; -- 而 map 与源码扫描双重证明:**工程 UI 只创建 `lv_obj`**(8 处 `lv_obj_create`),**零控件构造函数**、**零 flex/grid 用法**(`ui_*.c` 无 `set_flex/set_grid/LV_LAYOUT`);密码页也是 `lv_draw_label/lv_draw_rect` 自绘。 +### 5.4 Flash 优化(未闭环:F-4 / F-5 / F-6) -| 编号 | 措施 | 可省 Flash | 依据 | -|------|------|-----------|------| -| **F-1** | `LV_DRAW_SW_SUPPORT_I1 1`,其余 `LV_DRAW_SW_SUPPORT_L8/RGB565/ARGB8888/X8888/ALPHA` 等显式设 0 | **35,310 B** | `lv_draw_sw_blend_to_*` 合计 40,582 B,其中 `to_i1` 仅 5,272 B;`LV_COLOR_DEPTH 1` 下其余混合核不可达 | -| **F-2** | `LV_USE_THEME_SIMPLE 0` + 关闭未用控件 `LV_USE_LABEL/BUTTON/BUTTONMATRIX/BAR/TEXTAREA/SLIDER/ARC/TABLE` | **14,255 B** | 控件 13,389 B(label 4,087 + buttonmatrix 3,707 + textarea 3,680 + bar 1,843 + button 72)全部由 `lv_theme_simple.o`(866 B) 锚定 | -| **F-3** | `LV_USE_FLEX 0`、`LV_USE_GRID 0` | **5,368 B** | 源码扫描零使用;注释"菜单依赖 flex/label/button"已过期(自绘后不再依赖) | -| **F-4** | 关闭 `LV_USE_TRANSITION/LV_USE_SNAPSHOT/LV_USE_OBJ_PROPERTY/LV_USE_FLOAT`(若为 1)、动画与 `lv_obj_style` 中未用部分 | 需实测 | `lv_draw_sw_transform` 4,340 / `mask` 3,862 / `box_shadow` 3,246 / `draw_sw_img` 2,960 / `bin_decoder` 1,972 + `image_decoder` 952 / `triangle` 930 / `grad` 420 —— 工程无图片、无阴影、无变换 | -| **F-5** | 从 uvprojx 中**移除未使用的 LVGL 控件源文件**(201 个 → 实际需要的子集) | 编译时长 −40% 左右 | 与 F-2 配合;`lv_conf` 关开关后文件仍编译,删文件才省编译时间 | -| **F-6** | 双精度软浮点:`dadd/dmul/ddiv/depilogue/dfixul` 合计 **1,018 B**(`mf_w.l`),来自 `printf` 的 `%f`/double 提升 | −1 KB + 运行期性能 | 日志与 UI 全用定点;把 `%f` 改整数刻度,或编译期加 `-D__MICROLIB` 下 `printf` 浮点裁剪选项 | +> **F-1 / F-2 / F-3 已落地并销项(2026-10-11)**:`Lib/lv_conf.h` 与 `simulator/lv_conf.h` 对齐为同一特性子集(只留 `LV_DRAW_SW_SUPPORT_I1`、零控件、`LV_USE_THEME_SIMPLE 0`、`LV_USE_FLEX/GRID 0`)→ **实测省 64,576 B**:`ER_IROM1` 274,724 → **210,148 B**,Flash 26.3% → **20.1%**。比本报告原估(54,630 B)多省约 10 KB——锚点消失后,`blend_to_i1` 内部按**源**格式的分支与 `transform` 的格式分支也一并退出。完整记录(对象级对照、上游 9.3.0 漏守卫的配套补丁、ui-verify 证据)见 `../内存管理/RAM使用分析与优化方案.md` §2.6.4 与 `../UI设计/UI_设计架构.md` §1.4 第 10 条。 -**合计可回收 ≈ 50–60 KB Flash(镜像的 20%+)**,且**不减少任何现有功能**。这是"低风险高收益"的一类改动,建议与 R-1 同批做(先 Flash 后 RAM,避免同时改两处难定位)。 +| 编号 | 措施 | 可省 Flash | 依据 | **2026-10-11 状态** | +|------|------|-----------|------|---------------------| +| **F-4** | 关闭未用的绘制子模块与 `LV_USE_SNAPSHOT`(MCU 端)/`LV_USE_OBJ_PROPERTY`/`LV_USE_FLOAT` 等 | 可评估部分 **≈10 KB** | 新镜像对象实测:`mask` 3,862 + `box_shadow` 3,246 + `draw_sw_img` 2,960 + `bin_decoder` 1,972 + `image_decoder` 952 + `triangle` 930 + `transform` 598 + `grad` 420 + `mask_rect` 372 = **15,312 B**;工程无图片、无阴影、无变换 | ❌ 未办(数字已按 F-1 后的新镜像重测,`transform` 已从 4,340 降到 598)。⚠ **不可盲目关**:`mask`/`mask_rect`/`grad` 是文字与矩形绘制的必经路径;真正可评估的是 `box_shadow` + `draw_sw_img` + `bin_decoder` + `image_decoder` + `triangle` ≈ **10.1 KB** | +| **F-5** | 从 uvprojx 中**移除未使用的 LVGL 控件源文件**(201 个 → 实际需要的子集) | 编译时长 −40% 左右 | 与 F-2 配合;`lv_conf` 关开关后文件仍参与编译,删文件才省编译时间 | ❌ 未办(uvprojx 现挂 **306 个 `.c`,其中 LVGL 仍 201 个**)。⚠ F-2 落地后这些 TU 现在编出来是**空目标文件**(文件级 `#if` 全灭),删文件的收益更纯粹、风险更低 | +| **F-6** | 双精度软浮点:`dadd`/`dmul`/`ddiv`/`depilogue`/`dfixul`(`mf_w.l`),来自 `printf` 的 `%f`/double 提升 | −1 KB + 运行期性能 | 日志与 UI 全用定点;把 `%f` 改整数刻度 | ❌ 未办(新镜像仍链入 `dadd.o`) | -### 5.5 内存治理长效机制 +**剩余可回收 ≈ 10–11 KB Flash**(F-4 可评估部分 + F-6);F-5 省的是编译时长不是 Flash。原"≈50 KB 死代码"的主体(64,576 B)已在 2026-10-11 消除。 + +### 5.5 内存治理长效机制(+ 2026-10-11 复核) 1. **`scripts/ram_report.py` 升级为门禁**:超阈值(如 SRAM1 > 92%)即失败,并在 PR 描述里自动附 Top-10 对象表。 + → **🟡 部分实现**:`scripts/check_mem_numbers.py` 已挂 pre-commit(以 `.map` 为**唯一事实源**,校验 `docs/内存管理/RAM使用分析与优化方案.md` 的 `MEM_SNAPSHOT:BEGIN/END` 受管区 + 散句陈旧数字扫描)——**防的是"文档数字漂移",不是"占用超阈值"**。⚠ 无 92% 阈值失败逻辑;且 CI 上无 `.map` 时该钩子自适应跳过(登记簿 **A-5 残余**)。 2. **建立 `[计划] scripts/ram_baseline.tsv`**(`docs/内存管理/RAM使用分析与优化方案.md:172` 已承诺但未创建):每次构建记录 Code/RO/RW/ZI/Top 对象,形成趋势。 + → **❌ 未建**;🟡 等效物已存在:`docs/内存管理/data/` 下 5 份 `ram_report`/`stack_report` 快照留档(人工触发,非 tsv 趋势表)。 3. **RAM 预算台账**(同上,文档已承诺 `[计划] docs/内存管理/RAM预算台账.md`):按子系统配额(网络 12 KB / 存储 12 KB / UI 16 KB / RTOS 48 KB / 应用 8 KB),超配额必须先申请。 + → **❌ 未建**。⚠ 复核:这条的价值在上升——堆已进 CCM 且只剩 16,304 B CCM 余量,"谁还能用 CCM"必须有台账,否则 R-2 后半段无法排期。 4. **DMA 可达性清单**(新增,源于 N-11):文档化"哪些缓冲必须 SRAM1",并在 CCM 迁移时对该清单加 `_Static_assert`/地址断言(`assert((addr & 0xFFF00000) == 0x20000000)`)。 -5. **禁止新增 C 库 `malloc`**(源于 N-01):写进 `docs/代码规范/`,并在 `check_coding_standard.py` 加正则检查(`App/`、`Drivers/` 内出现 `malloc(` 即告警)。 + → **🟡 半落地**:独立清单文档未建,但**铁律已进 AGENTS.md**("DMA 目的缓冲必须在 SRAM,DMA2 不可达 CCM 0x10000000"、"改 ADC 流后回 NVIC 核对");代码侧另有**运行时**守卫(`gd5f2gq5ue.c:110-136` 不可达回落轮询)。⚠ 仍缺**编译期/启动期断言**,且需补一条新铁律:**从 FreeRTOS 堆分配的缓冲不可被 DMA 直接寻址**(堆已在 CCM)。 +5. **禁止新增 C 库 `malloc`**:写进 `docs/代码规范/`,并在 `check_coding_standard.py` 加正则检查(`App/`、`Drivers/` 内出现 `malloc(` 即告警)。 + → **🟡 前半已做、后半未做**:AGENTS.md 已有"自研代码禁用 C 库堆分配"铁律(含模拟器照不出来的原因);❌ `check_coding_standard.py` **未含 `malloc(` 正则** → 目前只靠人评审,属机械检查缺口。 --- @@ -534,14 +485,9 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 | 层 | 现状 | 评价 | |----|------|------| -| 声明源 | `ui_menu_tree.txt`(缩进即层级,行尾 `#P` 标密码) | ✅ 正确的方向 | -| 生成器 | `tools/menu_gen/gen_menu.py` → `ui_menu_tab.c/.h`(42 节点,`{parent,pwd,name}`) | ✅ 已闭环 | -| 视图 | `ui_menu_view.c` 自绘行 + 按需建毁框 | ✅ 1bpp 下的正确取舍 | -| 控制器 | `ui_menu.c:181-232 menu_key_handle` | ✅ | -| 内容页 | `ui_content_page.c` 描述符表 `s_defs[]` **仅 7 页**(DEVICE_INFO/AC/DC/PASSWORD/NET_CFG/RUN_LOG/SELFTEST_LOG) | ❌ **42 个菜单叶子中仅 7 个有内容页**,`menu_content_page_for()`(`ui_menu.c:155/:205`)是**手写 switch 映射** | -| 字体 | `tools/font_gen/make_font.py` + `ui_cn_chars.txt`(自动生成,文件头已声明"请勿手改") | ✅ 闭环 | -| 设计令牌 | **无**(每页自带 `CP_SCREEN_W/H 160`、`CP_TITLE_Y 2`、`CP_PAGE_LINE_Y 143`、`CP_ROW_BUF_LEN 32` 等布局常量) | ❌ 魔法数字分散在各页 | -| golden 回归 | 文档指向 `tools/ui_mock/`(**不存在**) | ❌ 无视觉基线 | +| 内容页 | `ui_content_page.c` 描述符表 `s_defs[]` **仅 7 页**(DEVICE_INFO/AC/DC/PASSWORD/NET_CFG/RUN_LOG/SELFTEST_LOG) | ❌ **42 个菜单叶子中仅 7 个有内容页**,`menu_content_page_for()`(`ui_menu.c:155/:205`)是**手写 switch 映射**。**2026-10-11 复核:未改善**——`ui_menu_tab.h:28` 仍 42 节点(**叶子 33 个**),有内容页的叶子 **6 个**(PASSWORD 非叶子映射);`menu_content_page_for()` 现位于 `ui_menu.c:118-128`,仍是手写 `case 1u/3u/4u/30u/39u/40u:` | +| 设计令牌 | **无**(每页自带 `CP_SCREEN_W/H 160`、`CP_TITLE_Y 2`、`CP_PAGE_LINE_Y 143`、`CP_ROW_BUF_LEN 32` 等布局常量) | ❌ 魔法数字分散在各页。**2026-10-11 复核:`Drivers/BSP/UI/ui_tokens.h` 仍不存在**(U1 未办) | +| golden 回归 | 已建成,但**不在文档承诺的 `tools/ui_mock/`**,而是 `simulator/tests/` 四脚本:`check_menu_rows.py`(菜单 8 状态全像素)、`check_content_pages.py`(内容页黄金参考逐字节 + 文本断言 + LVGL 池水位门禁)、`regen_ref_png.py`、`check_remo_sink.py` | 🟡 **有基线但未入门禁**:两个脚本未挂 pre-commit / `ui-verify`;`docs/UI设计/UI_界面制作流程.md` / `UI_自动化测试流程.md` 仍指向不存在的 `tools/ui_mock/`(语义断链) | ### 6.2 新增一个页面要走多少步(当前) @@ -549,6 +495,8 @@ D-2/D-3 的共同根因:**没有任何机械检查**保证"文档说的 = 代 **5 步是纯机械劳动且 2 处易错(步骤 5 的魔法下标、步骤 6/7 漏注册)。** +> **2026-10-11 复核:8 步流程一步未减。** 步骤 5 的手写 `case` 仍在(`ui_menu.c:118-128`);步骤 6/7 的漏注册仍无机械校验(`check_registration.py` 未建)——但已新增一条**间接暴露路径**:若新文件落在新目录而没把目录加进 `uvprojx` 的 ``,消费者 TU 会报 `cannot open source input file`(AGENTS.md 2026-10-10 记录),这类报错落在**别的文件**上、易误判,仍不等于"存在但未注册"的检查。 + ### 6.3 标准化目标:四层 + 一个闭环 ```mermaid @@ -608,6 +556,7 @@ extern const lv_style_const_prop_t UI_STYLE_FLAT[]; | `action`(确认型) | 强制复归、清除记录、手动录波 | 提示 → 二次确认 → 执行 → 结果日志 | 菜单树 42 个叶子中,**除已实现的 7 个外,其余 35 个可 100% 归入这 4 类原型**(form 21 个、records 9 个、detail 3 个、action 2 个)。这意味着**剩余页面工作量从"35 页 × 各自设计"降为"4 个原型 + 35 份数据绑定"**。 +> **2026-10-11 口径修正**:42 是 `ui_menu_tab.h:28 UI_MENU_TAB_COUNT`(根 + 41 节点)的**节点数**,**叶子实为 33 个**;已有内容页的叶子 **6 个** → 待落地 **27 页**(不是 35)。四类原型的分布按 27 页重算即可,结论不变。 **L3 页面实例** — 目标形态(以"遥测死区"为例,全部新代码约 30 行): @@ -629,10 +578,11 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea 产物:`ui_page_registry.c` 含 `node_index → (page_id, archetype, desc*)` 常量表 + `ui_page_registry_for(node)`;`ui_menu.c` 只需一次调用。生成器同时校验:`@page` 指向的描述符是否存在、是否有节点声明了页但无实例、是否有实例未被任何节点引用(编译期断言或生成期报错)。 -**闭环门禁(1 天)**: -1. `[计划] scripts/check_registration.py`(同 §2.3 S-3)保证新页面两端都注册; -2. **golden 回归落地**(补上文档承诺的 `tools/ui_mock/`):利用现成的 **PC 模拟器**做渲染基线——`simulator` 增加 `--shot `(已有 `--standby shot.bin` 先例)导出 1bpp 帧,Python 脚本转 PNG 并与 `docs/UI设计/baseline/*.png` 逐像素比对,差异 > 0 即 FAIL。这样 `UI_界面制作流程.md:413` 与 `UI_自动化测试流程.md:104` 的引用由"空头承诺"变成可执行门禁; -3. 字符集自动校验:构建前扫描所有 `ui_*.c` 中的汉字,与 `ui_cn_chars.txt` 求差集,缺失即 FAIL(把 `font-regen` 从"记得跑"变成"漏跑就红")。 +**闭环门禁(1 天)**——未闭环 2.5 条: + +1. `[计划] scripts/check_registration.py`(同 §2.3 S-3)保证新页面两端都注册 → **❌ 仍未建**(`scripts/` 现有 11 个脚本,无此项)。 +2. **golden 回归**(原方案:`simulator --shot` 导出 1bpp 帧 + PNG 逐像素比对)已按另一路径建成(`simulator/tests/` 四脚本,见 §6.1)。**未闭环**:`UI_界面制作流程.md:413` 与 `UI_自动化测试流程.md:104` 仍指向不存在的 `tools/ui_mock/`;这些脚本未挂进 pre-commit / `ui-verify` 门禁。 +3. 字符集自动校验:构建前扫描所有 `ui_*.c` 中的汉字,与 `ui_cn_chars.txt` 求差集,缺失即 FAIL(把 `font-regen` 从"记得跑"变成"漏跑就红") → **❌ 未入门禁**:`font-regen` skill 已把四步固化并写进 AGENTS.md("含新汉字的 UI 文案提交前先跑"),但**仍是约定不是门禁**(无 pre-commit 钩子做差集检查)。 ### 6.4 UI 侧的内存纪律(必须写进规范) @@ -640,19 +590,19 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea |------|------| | 页面**不得**新增静态大缓冲;文本缓冲统一用 `UI_BUF_STRLEN` | 现有 `s_ts/s_desc` 已是范式 | | 每页**只用** `lv_style_const_prop_t`,禁止运行期 `lv_style_init` | `ui_content_page.c:100-104` 已验证动态样式每次泄漏 88 B | -| 优先自绘(`lv_draw_*` + `lv_text_get_width`),新控件需评审(会破坏 §5.4 F-2 的裁剪) | LVGL 已占 Flash 61% | +| 优先自绘(`lv_draw_*` + `lv_text_get_width`),新控件需评审(会破坏 §5.4 F-2 **已落地**的裁剪,且须两端同步) | LVGL 现占镜像 **41.6%**(87,706 / 210,623 B;裁剪前 55%) | | 帧缓冲任何改动必须遵守 R-4(副本数 ≤ 2) | 当前 4 副本 12.8 KB | | `LVGL 池`用量必须经 `lv_mem_monitor` 实测后才允许扩大 | 现 40 KB,预估可降到 16 KB | -### 6.5 实施顺序(UI 专项,总计约 8 人日) +### 6.5 实施顺序(UI 专项,总计约 8 人日;2026-10-11 复核) -| 步骤 | 内容 | 交付判据 | -|------|------|---------| -| U1 | `ui_tokens.h` 令牌化(纯替换,不改行为) | `@build` PASS + 模拟器截图与替换前逐像素一致 | -| U2 | `ui_w_records`(合并 runlog/selftest) | 两页行为不变,代码 −1 个文件 | -| U3 | `ui_w_form` + `ui_page_desc_t` 原型化 + 生成器产注册表 | 手工 `menu_content_page_for()` 删除;新增一页只需"改树 + 加实例文件" | -| U4 | golden 回归(模拟器 `--shot` + PNG 比对)+ 字符集检查 + 注册检查入 pre-commit | 三个新 hook 全绿 | -| U5 | 用原型批量落剩余 35 页(每页 ≤30 行) | 菜单树全绿、无 `CONTENT_PAGE_COUNT` 哨兵命中 | +| 步骤 | 内容 | 交付判据 | **2026-10-11 状态** | +|------|------|---------|---------------------| +| U1 | `ui_tokens.h` 令牌化(纯替换,不改行为) | `@build` PASS + 模拟器截图与替换前逐像素一致 | ❌ 未办(`ui_tokens.h` 不存在)。🟡 **但它的验收条件如今已可机械执行**——golden 基线已建,"逐像素一致"从口头判据变成可跑断言 | +| U2 | `ui_w_records`(合并 runlog/selftest) | 两页行为不变,代码 −1 个文件 | ❌ 未办(`ui_page_runlog.c` / `ui_page_selftest.c` 仍是两份结构几乎相同的代码) | +| U3 | `ui_w_form` + `ui_page_desc_t` 原型化 + 生成器产注册表 | 手工 `menu_content_page_for()` 删除;新增一页只需"改树 + 加实例文件" | ❌ 未办(`ui_page_registry.c` / `gen_page_registry.py` 均不存在;手写 `case` 仍在) | +| U4 | golden 回归(模拟器 `--shot` + PNG 比对)+ 字符集检查 + 注册检查入 pre-commit | 三个新 hook 全绿 | 🟡 **1/3 落地**:golden 已建(`simulator/tests/` 4 脚本,见 §6.1);❌ 字符集差集检查未入门禁;❌ 注册检查未入门禁 | +| U5 | 用原型批量落剩余 35 页(每页 ≤30 行) | 菜单树全绿、无 `CONTENT_PAGE_COUNT` 哨兵命中 | ❌ 未动。**复核修正口径**:42 节点 / **33 叶子** / 6 个叶子有内容页 → 待做 **27 页**(不是 35) | --- @@ -660,91 +610,63 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea ### P0(1 周内,必须) -| 编号 | 事项 | 验收 | -|------|------|------| -| N-01 | `cfg_file.c` 4 处 `malloc` → `pvPortMalloc`(方案 A)或静态缓冲(方案 B,需先做 R-2) | 真机串口出现 `config loaded` 而非 `read: malloc 4097B FAIL`;`@test` PASS | -| N-02 | FTP 鉴权(或生产构建默认关闭 FTP 写) | 错误口令登录被 `530` 拒绝;`@test` 加 FTP 标记校验 | -| N-03 | IWDG + 心跳位图;`configASSERT` 改为"先打印后复位" | 人为死循环 → 1 s 内复位并留下复位原因日志 | -| N-07 | 开 `configCHECK_FOR_STACK_OVERFLOW=2` + malloc-failed 钩子 | 连续 30 min 运行无钩子触发(同时得到真实栈水位) | -| D-2 | 修 README 任务表 | 与 `Src/freertos.c` 一致 | -| N-06 | 5 个未注册 `.c`:注册或显式删除 + 白名单 | `check_registration.py` 零告警 | +| 编号 | 事项 | 验收 | **2026-10-11 状态** | +|------|------|------|---------------------| +| N-02 | FTP 鉴权(或生产构建默认关闭 FTP 写) | 错误口令登录被 `530` 拒绝;`@test` 加 FTP 标记校验 | ❌ **未办,且未登记进登记簿** → 复核建议:补登为 P1(现场可被任意主机改写 IP/MAC 并断网) | +| N-06 | 5 个未注册 `.c`:注册或显式删除 + 白名单 | `check_registration.py` 零告警 | 🟡 **半**:3 个消解(2 删 1 注册);❌ `rs485_task.c`/`lftpd_log.c` 仍未处理,❌ 脚本与白名单未建 | ### P1(1 个月内) -| 编号 | 事项 | 预期 | -|------|------|------| -| N-04 / R-2 | **CCM 启用**(LVGL 池 + UI 帧缓冲,`APP_CCM_ENABLE` 门控)+ N-11 DMA 边界文档与断言 | SRAM1 96.6% → ~55% | -| R-4 | UI 帧副本 4 → 2 | −6.4 KB | -| R-8 | `memStatsTask` / 串口命令 `mem` 常开 | 每次评审有数可依 | -| F-1/F-2/F-3 | `lv_conf.h` 与 LVGL 9.3 对齐 + 主题/控件/flex 裁剪 | Flash −55 KB | -| N-12/N-13 | `dhara_nand_is_free` 全页校验;`dhara_nand_copy` 处理 `DHARA_NO_PAGE` | 掉电注入测试通过 | -| N-16 | 日志常驻 `FIL` + 批量 flush;UI 记录读缓存升级 | 单条日志 NAND 操作次数下降 ≥50% | -| N-10 | ~~UI accept 改事件驱动(脱离 100 msg/s 轮询)~~ **已落地(2026-10-10,改走"门控"而非事件驱动)** | ✅ 无客户端时队列占用 **100 msg/s → 0**;实测 `uis` 50 ms → **1 µs**(`../UI设计/RemoDispBus协议与接收设计说明.md` §3;判读见 `../子系统设计/看门狗设计说明.md` §11.4) | -| D-1 | 芯片手册图片降采样 / 拆 submodule | clone 40 MB → ~8 MB | -| D-3 | `check_doc_links.py` 入门禁 + 修 18 条断链 | 零断链 | -| U1–U4 | UI 令牌化 + 原型库 + 生成器 + golden | 新增一页 ≤30 行 | -| P1-1/P1-7/P1-8/P1-10 | 沿用台账(ADC 数据路径 / MAC 写序 / 重建失败恢复 / 测试 RAM 溢出) | 台账闭环 | +| 编号 | 事项 | 预期 | **2026-10-11 状态** | +|------|------|------|---------------------| +| N-04 / R-2 | **CCM 启用**(LVGL 池 + UI 帧缓冲,`APP_CCM_ENABLE` 门控)+ N-11 DMA 边界文档与断言 | SRAM1 96.6% → ~55% | 🟡 **半**:堆 49,184 B 已进 CCM → SRAM1 **69.8%**;LVGL 池 + UI 缓冲未迁(CCM 只剩 16,304 B,**必须先做 R-9/R-4**);`APP_CCM_ENABLE` 未采用;N-11 侧改为**运行时守卫**,仍无编译期断言 | +| R-4 | UI 帧副本 4 → 2 | −6.4 KB | ❌ 未办(`ui_port.o` 6,420 + `ui_net_sink.o` 6,766 未变) | +| N-12/N-13 | `dhara_nand_is_free` 全页校验;`dhara_nand_copy` 处理 `DHARA_NO_PAGE` | 掉电注入测试通过 | ❌ 未办(两处代码原样) | +| N-16 | 日志常驻 `FIL` + 批量 flush;UI 记录读缓存升级 | 单条日志 NAND 操作次数下降 ≥50% | ❌ 未办(每条仍 open/write/close) | +| D-1 | 芯片手册图片降采样 / 拆 submodule | clone 40 MB → ~8 MB | ❌ 未办;⚠ 且 `.git` 已膨胀到 **149 MB** → 还需一次历史重写,优先级上升 | +| U1–U4 | UI 令牌化 + 原型库 + 生成器 + golden | 新增一页 ≤30 行 | 🟡 **U4 的 golden 已建**(`simulator/tests/` 4 脚本);U1/U2/U3 未动;U4 的注册检查与字符集检查未入门禁 | +| P1-7 / P1-8 / P1-10 | 沿用台账(MAC 写序 / 重建失败恢复 / 测试 RAM 溢出) | 台账闭环 | P1-7/P1-8 本轮未复核,仍按台账跟踪;**P1-10 🟡 收尾中**(登记簿唯一 P 类项,剩"套件构建实测链接确认 + `@test` 闭环"两步) | ### P2(一个季度内) -`R-3/R-5/R-6/R-9`、`F-4/F-5/F-6`、D-4/D-5/D-6/D-7、S-1/S-2/S-5、P2-1..P2-16 余项、N-17/N-18(测试内建水位 + CI 最小集)。 +`R-3/R-5/R-6/R-9`、`F-4/F-5/F-6`、S-1/S-2/S-5、P2-1..P2-16 余项。 + +> **2026-10-11 复核**:`R-3/R-5/R-6/R-9`、`F-4/F-5/F-6`、**S-1/S-2** ❌ 未办(`NETCFG`/`UI` 的语义层错位、4 处裸 `extern` 原样);**S-5 🟡 已实现核心**(`App/fs/vol_lock.c` + 主机侧 33 例单测),"可选全序列卷锁"的剩余范围在登记簿。 ### P3(机会性) UI 剩余页面批量落地(U5)、目录级层次迁移(S-1 的物理搬迁)、`app_cfg.h` 开关体系重构、`Lib/lv_conf.h` 迁至 `Config/`。 +> **2026-10-11 复核**:四条全部未动。⚠ 优先级更新:`Lib/lv_conf.h` 迁至 `Config/` 原建议"与 F-1..F-3 合并做"——**F-1..F-3 已于同日落地但位置未迁**,现需单独做,且要一并处理 `simulator/lv_conf.h`(两份必须保持同一特性子集,见 `../UI设计/UI_设计架构.md` §1.4 第 10 条);U5 的口径改为 **27 页**(33 叶子 − 6 已实现)。 + ### 交付物清单(可直接排期) -| 交付物 | 类型 | 来源条目 | -|--------|------|---------| -| `[计划] scripts/check_registration.py` | 新脚本 + pre-commit hook | S-3 / N-06 | -| `scripts/check_doc_links.py` | 新脚本 + pre-commit hook | D-3 | -| `[计划] scripts/gen_task_table.py` | 文档一致性生成器 | D-2 / D-7 | -| `[计划] scripts/ram_baseline.tsv` + `[计划] docs/内存管理/RAM预算台账.md` | 文档承诺落地 | §5.5 | -| `Drivers/BSP/UI/ui_tokens.h` | 令牌层 | U1 | -| `Drivers/BSP/UI/ui_w_{list,kv,records,form,msgbox,title}.{c,h}` | 组件库(需双端注册) | U2/U3 | -| `tools/menu_gen/gen_menu.py` 扩展 + `ui_page_registry.c` 生成 | 生成器 | U3 | -| `tools/ui_mock/{render_mockup.py,i1_to_png.py}` + `docs/UI设计/baseline/` | golden 回归 | U4 | -| `[计划] docs/内存管理/DMA可达性清单.md` | 规范(含"哪些缓冲禁止进 CCM") | N-11 | -| `docs/README.md`(文档索引) | 文档 | D-5 | +| 交付物 | 类型 | 来源条目 | **2026-10-11 状态** | +|--------|------|---------|---------------------| +| `[计划] scripts/check_registration.py` | 新脚本 + pre-commit hook | S-3 / N-06 | ❌ **仍未建**(`scripts/` 11 个脚本里没有它)——这是本报告里**唯一一条"1 小时工作量、至今未做"**的机械检查 | +| `[计划] scripts/ram_baseline.tsv` + `[计划] docs/内存管理/RAM预算台账.md` | 文档承诺落地 | §5.5 | ❌ **未建**;🟡 等效物 = `docs/内存管理/data/` 5 份快照 + `MEM_SNAPSHOT` 受管区(无趋势、无配额) | +| `Drivers/BSP/UI/ui_tokens.h` | 令牌层 | U1 | ❌ 未建 | +| `Drivers/BSP/UI/ui_w_{list,kv,records,form,msgbox,title}.{c,h}` | 组件库(需双端注册) | U2/U3 | ❌ 未建 | +| `tools/menu_gen/gen_menu.py` 扩展 + `ui_page_registry.c` 生成 | 生成器 | U3 | ❌ 未建(`gen_menu.py` 未扩展,注册表不存在) | +| `[计划] docs/内存管理/DMA可达性清单.md` | 规范(含"哪些缓冲禁止进 CCM") | N-11 | 🟡 **半**:独立清单未建,但铁律已进 AGENTS.md + 代码有运行时守卫;⚠ 需补"堆缓冲不可 DMA"这一新条 | --- ## 附录 A 关键证据索引 -| 结论 | 证据 | -|------|------| -| SRAM1 96.6%、余 4,488 B | map:`Total RW Size (RW Data + ZI Data) 126584 (123.62kB)`;scatter `RW_IRAM1 0x20000000 0x00020000` | -| CCM 未使用 | `MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct` 仅两个区域,无 `0x10000000` | -| MicroLIB C 堆 = 512 B | map `:17405 0x2001e878 - 0x00000200 Zero RW HEAP startup_stm32f407xx.o`;`__heap_limit 0x2001ea78`;`MDK-ARM/startup_stm32f407xx.s:43 Heap_Size EQU 0x200` | -| cfg_file 走 C 库 malloc | map `cfg_file.o(i.s_load_file) refers to malloc.o(i.malloc)`、`(i.s_write_template)`、`(i.cfg_file_save)`;源码 `App/cfg/cfg_file.c:19 `、`:485/:522/:1038/:1050` | -| lftpd 已迁 FreeRTOS 堆 | map `lftpd_io.o refers to heap_4.o(i.pvPortMalloc)` | -| 堆/栈零观测 | 全库 grep `xPortGetFreeHeapSize\|uxTaskGetStackHighWaterMark\|configCHECK_FOR_STACK\|configUSE_MALLOC_FAILED` 在 `App/Src/BSP/test` 零命中;map 中相关库函数标记为 linker-removed | -| 任务栈全部动态分配 | `Src/freertos.c:64-105` 的 `osThreadAttr_t` 无 `.stack_mem`;`freertos.o` ZI 仅 1,104 B | -| 非 I1 混合核死代码 35,310 B | map 对象 `lv_draw_sw_blend_to_*` 合计 40,582 − `to_i1` 5,272 | -| 未用控件 13,389 B 由主题锚定 | `lv_theme_simple.o(i.theme_apply) refers to lv_{label,button,buttonmatrix,bar,textarea}_class`;源码零控件构造函数(仅 8 处 `lv_obj_create`) | -| NAND 直接 DMA 调用方缓冲 | `gd5f2gq5ue.c:200`、`:258`(`GD5F_DMA_THRESHOLD = 32`) | -| CH395F memcpy 自有缓冲 | `ch395f.c:1132-1190` | -| FTP 无鉴权 | `Drivers/BSP/NET/lftpd/lftpd.c:367-371` | -| 端口分配 | `App/sync/time_sync.c:25 TIME_SYNC_PORT 8888`;`Drivers/BSP/UI/ui_net_sink.h:29 UI_NET_SINK_PORT 7003`;FTP 21 | -| 42 菜单叶子 / 7 内容页 | `Drivers/BSP/UI/ui_menu_tab.c`(生成,42 节点);`ui_content_page.c:84-92 s_defs[]` | -| 手写映射 | `ui_menu.c:155/:205 menu_content_page_for()` | -| 文档体量 | 工作树字节统计(见 §3.1 表) | - -## 附录 B 断链清单(P1 修复项) - -| 文件:行 | 失效链接 | 处置 | -|---------|---------|------| -| `docs/代码规范/cppcheck使用说明.md:3` | 相对路径层级错误 | 改 `../` 层级 | -| `docs/子系统设计/配置文件设计说明.md:159,160,494,568,569,570` | 指向已改名/搬迁文件 | 逐项改指 `net_cfg.c`/`cfg_file_store.h` 等 | -| `docs/测试说明/FTP_Test_Guide.md`、`GD5F2GQ5UE_Test_Guide.md`、`SD2506_Test_Guide.md` | 目录搬迁遗留(旧 `docs/` 根路径) | 改新相对路径 | -| `docs/测试说明/测试开发标准流程.md:358` | 同上 | 同上 | -| `docs/问题记录/CH395F_Trap_Records.md:434` | 同上 | 同上 | -| `docs/UI设计/UI_界面制作流程.md:413` | `tools/ui_mock/`(不存在) | 与 U4 一并实现,或标注 `[计划]` | -| `docs/UI设计/UI_自动化测试流程.md:104` | 同上 | 同上 | -| `docs/内存管理/RAM使用分析与优化方案.md:136,172,279` | `[计划] App/util/app_mem_stats.*`、`[计划] scripts/ram_baseline.tsv`、`[计划] docs/内存管理/RAM预算台账.md`(均不存在) | 随 §5.5 创建;未创建前标 `[计划]` | -| `docs/测试说明/串口助手设计说明.md:200` | 目标路径变更 | 改指现路径 | -| `docs/架构设计/图形绘制流程.md:135` | 目标实际在 `docs/images/` | 改路径 | +| 结论 | 证据 | **2026-10-11 复核** | +|------|------|---------------------| +| SRAM1 96.6%、余 4,488 B | map:`Total RW Size (RW Data + ZI Data) 126584 (123.62kB)`;scatter `RW_IRAM1 0x20000000 0x00020000` | 🟡 已缓解:`RW_IRAM1` **91,488 / 131,072 = 69.8%(余 39,584 B)**;`Total RW` 140,720 B | +| MicroLIB C 堆 = 512 B | map `:17405 0x2001e878 - 0x00000200 Zero RW HEAP startup_stm32f407xx.o`;`__heap_limit 0x2001ea78`;`MDK-ARM/startup_stm32f407xx.s:43 Heap_Size EQU 0x200` | ✅ 结论不变:`Heap_Size EQU 0x200` 未改,map 现 `Removing startup_stm32f407xx.o(HEAP), (512 bytes)`(未被引用 → 整段移除) | +| lftpd 已迁 FreeRTOS 堆 | map `lftpd_io.o refers to heap_4.o(i.pvPortMalloc)` | 不变 | +| 任务栈全部动态分配 | `Src/freertos.c:64-105` 的 `osThreadAttr_t` 无 `.stack_mem`;`freertos.o` ZI 仅 1,104 B | 不变(R-6 未办);7 任务栈合计 10,176 words,其中 adcTask 已抬到 640 words | +| NAND 直接 DMA 调用方缓冲 | `gd5f2gq5ue.c:200`、`:258`(`GD5F_DMA_THRESHOLD = 32`) | 🟡 模式未变(`:325` 等),阈值改 **1024**(`gd5f2gq5ue.h:78`)+ 新增**运行时 DMA 可达性守卫**(`:110-136`,回落轮询) | +| CH395F memcpy 自有缓冲 | `ch395f.c:1132-1190` | 不变(UI 缓冲仍可迁 CCM 的前提依旧成立) | +| FTP 无鉴权 | `Drivers/BSP/NET/lftpd/lftpd.c:367-371` | ❌ 仍在:现 `lftpd.c:375-379`(`cmd_pass`)/ `:553-557`(`cmd_user`) | +| 端口分配 | `App/sync/time_sync.c:25 TIME_SYNC_PORT 8888`;`Drivers/BSP/UI/ui_net_sink.h:29 UI_NET_SINK_PORT 7003`;FTP 21 | 不变 | +| 42 菜单叶子 / 7 内容页 | `Drivers/BSP/UI/ui_menu_tab.c`(生成,42 节点);`ui_content_page.c:84-92 s_defs[]` | 🟡 口径修正:`ui_menu_tab.h:28 UI_MENU_TAB_COUNT 42`(根 + 41 节点)→ **叶子 33**、**有内容页的叶子 6**(枚举 7 页含 PASSWORD) | +| 手写映射 | `ui_menu.c:155/:205 menu_content_page_for()` | 仍在,现位于 `ui_menu.c:118-128`(手写 `case 1u/3u/4u/30u/39u/40u:`) | +| 文档体量 | 工作树字节统计(见 §3.1 表) | 见 §3.1 复核列:受控 2,363 文件 / 77.7 MB,`docs/` 849 / 44.4 MB,图片 703 张 / 36.3 MB,`.git` **149.3 MB** | ## 附录 C 评审中确认"无需改动"的设计(避免后续误改) @@ -760,4 +682,42 @@ UI 剩余页面批量落地(U5)、目录级层次迁移(S-1 的物理搬 --- -**报告结束。** 建议把 §7 的 P0 六条直接追加进 `docs/待处理内容.md`(沿用其编号体系,本报告编号 `N-01…N-18` 作为交叉引用),并把 §5/§6 的落地项写进 `[计划] docs/内存管理/RAM预算台账.md` 与 `[计划] docs/文档规范.md`。 +**报告结束。** + +> **2026-10-11 复核收尾**: +> - ⚠ **台账漏登两条**:本报告 **N-02(FTP 无鉴权)** 与 **N-12/N-13(dhara 移植缺陷)** 既未修复也未登记在 `../待处理内容.md` → 建议补登。 +> - ⚠ **编号撞车**:本报告曾用 N-18 指"无 CI"(该条已销项删除),而登记簿的 N-18 = "8 路电气量分属两片转换器(V0.2 改版项)"。引用时一律带文档名。 +> - `[计划] docs/内存管理/RAM预算台账.md` 与 `scripts/ram_baseline.tsv` **仍未创建**(见 §5.5)。 +> - 本报告按 `docs/README.md` 的定位是**体系层评审报告,不是待办池**——待办一律以 `../待处理内容.md` 为准;本报告只保留**未闭环问题的发现 + 证据**,修复即销项。 + +--- + +## 变更记录 + +| 日期 | 类型 | 说明 | +|------|------|------| +| 2026-09-18 | 初版 | 全量审查;基线 `HEAD = 2d0ec35`、`.map` 链接于 2026-09-16 12:33;产出 N-01..N-18 / D-1..D-7 / S-1..S-5 / R-1..R-9 / F-1..F-6 / U1..U5 编号体系 | +| 2026-09-18 | 实施记录 | N-01 按方案 A 落地(两处必要调整:分配收进 `cfg_file_store` 后端、`_Static_assert` 改 typedef 负数组) | +| 2026-09-25 | 归档 | §3 文档分布章节标注"已归档(章节保留作历史)" | +| 2026-10-10 | 就地更新 | N-10 改走"门控"并销项 | +| 2026-10-11 | 现状复核 | 按当前代码与 `.map` 重跑全部数字并就地标注(§0.1 评级、§1.3 口径、§2.1 规模、§3.1 体量、§4.x 逐条、§5.1 基线双列 + 对象排行、§5.3/§5.4 状态列、§6/§7 状态);复核基线 `HEAD = 54d6d2d` + 00:38 `.map` | +| **2026-10-11** | **修复即销项(本次)** | 与登记簿同一规则:**已修复项的分析正文与表格行从本报告删除**,报告只呈现未闭环问题(**874 → 733 行**,114 KB → 84 KB)。删除范围:§4.4.1 N-01 方案与实施记录、§4.1 N-03a/b·N-07·N-08·P2-13、§4.2 N-10、§4.8 P2-14/P2-15/N-17/N-18、§3.3 D-2..D-7、§5.3 R-1/R-7、§6.1 已闭环四层与字体、§7 P0/P1 已办行、交付物清单已建项、附录 A 已修行、**附录 B 断链清单整节**。§0.2/§0.4 改写为**未闭环清单**。原始推理与取证在未闭环条目中一律保留 | +| **2026-10-11** | **实施 + 销项:F-1/F-2/F-3** | `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 对齐为同一特性子集(只留 `LV_DRAW_SW_SUPPORT_I1`、5 个未用控件类全关、`LV_USE_THEME_SIMPLE 0`、`LV_USE_FLEX/GRID 0`,并删掉 9.3 不存在的 `LV_USE_THEME_BASIC` 与无效 `LV_USE_OBJ`);配套补 `lv_draw_sw_blend_to_i1.c` 上游漏的守卫 → **省 64,576 B**:`ER_IROM1` 274,724 → **210,148 B**,Flash 26.3% → **20.1%**(比原估 54,630 B 多约 10 KB)。验证:`@build` 0 错 0 警;ui-verify 五步全过(**11 页黄金参考在裁剪前生成、裁剪后逐字节一致**,菜单 8 状态像素一致,池水位 5440/5520 → 5368/5448);`@test` 真机 PASS。本报告同步:§5.4 只留 F-4/F-5/F-6(数字按新镜像重测)、§0.1 Flash 评级 B→A−、§0.2 Top 6→Top 5、§0.4 Flash 行、§5.1 加二次实测、附录 A 删两条已闭环证据行 | + +### 已销项条目的记录去处 + +> 删除前已逐条核对:以下去处文档均含完整记录,删了不丢信息。 + +| 已销项条目 | 去处 | +|-----------|------| +| N-01(配置引擎 C 堆必失败) | `../子系统设计/配置文件设计说明.md`(v1.13,§6.2/§8) | +| N-03a/N-03b/N-07/N-08(IWDG、`configASSERT`、两个钩子、4 个 Fault 现场) | `../子系统设计/看门狗设计说明.md`(§4.4、§10.1/§10.2)+ AGENTS.md「平台约束」 | +| N-10(UI accept 门控) | `../UI设计/RemoDispBus协议与接收设计说明.md` §3 + 看门狗设计说明 §11.4 | +| P1-1(ADC 数据路径) | `../子系统设计/ADC采样节拍设计说明.md` §10/§11 + `App/algo/README.md` | +| N-17(栈/堆水位观测) | `../内存管理/RAM使用分析与优化方案.md` + `../测试说明/实机测试实施指南.md` | +| N-18(本报告口径:无 CI) | `../测试说明/测试开发标准流程.md` §9 + `../环境与部署/CI配置指南.md` | +| P2-13 / P2-14 / P2-15 | AGENTS.md(`test-suite-switch`、`keil-build`、`stlink-flash`)+ `../测试说明/ST-LINK_CLI_复位使用说明.md` | +| D-2 / D-3 / D-4 / D-5 / D-6 / D-7 | `../文档规范.md` + `../代码规范/pre-commit使用说明.md`;断链由 `scripts/check_doc_links.py` 门禁接管(`--all` = 129 文档 / 0 失效) | +| U4(golden 回归) | `simulator/tests/` 四脚本 + `../UI设计/UI_自动化测试流程.md` | +| R-1 / R-7 | `../子系统设计/看门狗设计说明.md` + AGENTS.md 任务表(`scripts/gen_task_table.py` 生成区) | +| F-1 / F-2 / F-3(LVGL 死代码裁剪,2026-10-11) | `../内存管理/RAM使用分析与优化方案.md` §2.6.4(对象级前后对照 + 上游漏守卫的配套补丁);`../UI设计/UI_设计架构.md` §1.4 第 10 条(**两份 `lv_conf.h` 必须同一特性子集**这条新硬约束);`../UI设计/UI_自动化测试流程.md` 变更记录(池水位基线 5440/5520 → 5368/5448 及理由) | diff --git a/scripts/check_coding_standard.py b/scripts/check_coding_standard.py index 0ee71065..c3eacd1c 100644 --- a/scripts/check_coding_standard.py +++ b/scripts/check_coding_standard.py @@ -198,9 +198,19 @@ def check_file(path): v.append((ln, 'S10 3.3.1', '函数名须全小写: %s' % name)) # S11 (2.3.5) 禁止相对/绝对路径 include(基于原始文本,字符串掩码会吃掉引号) - for m in re.finditer(r'^\s*#\s*include\s*[<"]([^">]+)[">]', text, re.M): - inc = m.group(1) - if '/' in inc or '\\' in inc or '..' in inc: + # 规则本意 = 禁"用路径找文件":`#include "../../x.h"`、`#include "App/foo/x.h"`、 + # `#include `、`#include <..\..\x.h>`。 + # ⚠ 2026-10-11 细化:原实现把**任何含 `/` 的 include** 都判违规,于是 + # `` 这类**尖括号系统头**也被误报(模拟器侧两处即中招)。 + # 现按形态区分:尖括号 = 系统/依赖头,允许带子目录(由 -I 解析); + # 引号 = 项目头,必须裸文件名(带 `/` 或 `\` 即违规)。`..`/绝对路径两种形态都禁。 + for m in re.finditer(r'^\s*#\s*include\s*([<"])([^">]+)[">]', text, re.M): + form, inc = m.group(1), m.group(2) + bad = ('..' in inc or '\\' in inc or inc.startswith('/') + or re.match(r'^[A-Za-z]:', inc) is not None) + if form == '"' and '/' in inc: + bad = True + if bad: v.append((_line(text, m.start()), 'S11 2.3.5', '禁止相对/绝对路径 include: %s' % inc)) diff --git a/scripts/check_lv_conf_sync.py b/scripts/check_lv_conf_sync.py new file mode 100644 index 00000000..bd2cb19e --- /dev/null +++ b/scripts/check_lv_conf_sync.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""两份 lv_conf.h 特性子集一致性门禁(2026-10-11 加,随 F-1/F-2/F-3 落地)。 + +背景:`Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是**两份独立文件**, +此前长期漂移——模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、 +5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 全靠 +`lv_conf_internal.h` 的默认值隐式生效,把用不到的代码整份链进固件 += 实测 **64,576 B 死代码**(旧镜像的 23.5%)。更糟的是 golden 回归跑的是 +模拟器那份配置,**验证不到固件实际编译的特性子集**。 + +判定口径(关键):比对的是**生效值**,不是"文件里写了什么"—— +文件里没定义的宏,取 `Lib/lvgl/src/lv_conf_internal.h` 的默认值。 +所以"MCU 没写、模拟器写了 0"会被算成 MCU=1 → 直接判 FAIL,这正是原事故形态。 + +四类问题: + MISMATCH 生效值不同 → FAIL(特性子集不一致,golden 回归无效) + INVALID 写了 LVGL 不认的宏名 → FAIL(如 9.3 没有 LV_USE_THEME_BASIC,写了不生效) + DUPLICATE 同一文件里定义两次 → FAIL(后者生效,极易误读) + IMPLICIT 一边靠默认值生效 → 警告(不 FAIL;若默认值与另一边不同则已升级为 MISMATCH) + +用法: + python scripts/check_lv_conf_sync.py # 检查(默认) + python scripts/check_lv_conf_sync.py --list # 打印全量对照表 + python scripts/check_lv_conf_sync.py --mcu A --sim B --internal C +退出码:0 = 一致;1 = 有不一致/无效/重复;2 = 环境错误(文件缺失) +""" +import argparse +import io +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEF_MCU = os.path.join(REPO, "Lib", "lv_conf.h") +DEF_SIM = os.path.join(REPO, "simulator", "lv_conf.h") +DEF_INTERNAL = os.path.join(REPO, "Lib", "lvgl", "src", "lv_conf_internal.h") +DEF_LVGL_SRC = os.path.join(REPO, "Lib", "lvgl", "src") + +RE_DEFINE = re.compile(r"^[ \t]*#[ \t]*define[ \t]+(LV_[A-Za-z0-9_]+)[ \t]*(.*)$") +RE_IFNDEF = re.compile(r"^[ \t]*#[ \t]*ifndef[ \t]+(LV_[A-Za-z0-9_]+)[ \t]*$") +RE_ANYIF = re.compile(r"^[ \t]*#[ \t]*if(def|ndef)?\b") +RE_ENDIF = re.compile(r"^[ \t]*#[ \t]*endif\b") +RE_NUM = re.compile(r"^(?:0[xX][0-9a-fA-F]+|[-+]?\d+)[uUlL]*$") + +# 两份文件自己的 include 保护宏,不参与比对 +IGNORE_EXACT = {"LV_CONF_H"} + +# 只属于某一端的宏(前缀匹配):模拟器驱动 / 演示 / 头文件替换 / OS 抽象细节。 +# 这些不参与"特性子集"一致性判定。 +IGNORE_PREFIX = ( + "LV_USE_SDL", "LV_SDL_", # SDL2 驱动(仅模拟器) + "LV_USE_LINUX_", "LV_USE_WINDOWS", "LV_USE_QNX", "LV_USE_UEFI", + "LV_USE_WAYLAND", "LV_USE_GLFW", "LV_USE_NUTTX", "LV_USE_OPENGLES", + "LV_USE_EVDEV", "LV_USE_LIBINPUT", "LV_USE_GENERIC_MIPI", + "LV_USE_FT81X", "LV_USE_LOVYAN_GFX", + "LV_USE_DEMO_", # 演示工程 + "LV_STDINT_INCLUDE", "LV_STDDEF_INCLUDE", "LV_STDBOOL_INCLUDE", + "LV_INTTYPES_INCLUDE", "LV_LIMITS_INCLUDE", "LV_STDARG_INCLUDE", + "LV_OS_PRIORITY", "LV_OS_TASK_NAME", # LV_USE_OS=0 时惰性 + "LV_FONT_CUSTOM_INCLUDE", "LV_FONT_USE_CUSTOM_INCLUDE", +) + +# 允许两端不同(已核对理由):仍会打印,但不判 FAIL。 +ALLOW_DIFF = { + "LV_USE_SNAPSHOT": "模拟器 --screenshot 需要 lv_snapshot;固件不用(省 Flash)", + "LV_OBJ_STYLE_CACHE": "模拟器开样式缓存便于调试;固件靠默认", + "LV_USE_OBJ_NAME": "模拟器调试辅助(对象命名);固件不需要", + "LV_USE_OBJ_ID": "模拟器调试辅助;固件不需要", + "LV_OBJ_ID_AUTO_ASSIGN": "随 LV_USE_OBJ_ID", + "LV_USE_OBJ_ID_BUILTIN": "随 LV_USE_OBJ_ID", + "LV_USE_ASSERT_NULL": "固件关断言省体积;模拟器保留有助定位", + "LV_USE_ASSERT_MALLOC": "同上", + "LV_USE_ASSERT_USER_CODE": "同上", + "LV_USE_ASSERT_OBJ": "同上", + "LV_USE_ASSERT_STYLE": "同上", + "LV_USE_ASSERT_LAYOUT": "同上", +} + + +def strip_comment(text): + """去掉行内 /* */ 与 // 注释(不跨行)。""" + text = re.sub(r"/\*.*?\*/", "", text) + text = re.sub(r"//[^\"]*$", "", text) + return text.strip() + + +def read_lines(path): + """按 UTF-8 读(MCU 那份带 BOM,用 utf-8-sig 兼容)。""" + with io.open(path, "r", encoding="utf-8-sig", errors="replace") as f: + return f.read().split("\n") + + +def parse_defines(path): + """返回 (name -> 首个定义值, name -> 定义次数, 出现顺序)。""" + vals, counts, order = {}, {}, [] + for line in read_lines(path): + m = RE_DEFINE.match(line) + if not m: + continue + name, raw = m.group(1), strip_comment(m.group(2)) + counts[name] = counts.get(name, 0) + 1 + if name not in vals: + vals[name] = raw + order.append(name) + return vals, counts, order + + +def parse_defaults(path): + """从 lv_conf_internal.h 抽**兜底**默认值。 + + 该文件每个选项的形状是: + #ifndef LV_X + #ifdef CONFIG_LV_X + #define LV_X CONFIG_LV_X <- ESP-IDF 兼容分支,不是默认值 + #else + #define LV_X 0 <- 真正的兜底默认值 + #endif + #endif + 所以必须扫完整块、取**非 CONFIG_ 的字面量**(多个取最后一个)。 + """ + lines = read_lines(path) + defaults = {} + i, n = 0, len(lines) + while i < n: + m = RE_IFNDEF.match(lines[i]) + if not m: + i += 1 + continue + name = m.group(1) + block, depth, j = [], 0, i + 1 + while j < n: + line = lines[j] + if RE_ANYIF.match(line): + depth += 1 + elif RE_ENDIF.match(line): + if depth == 0: + break + depth -= 1 + block.append(line) + j += 1 + cands = [] + for line in block: + d = RE_DEFINE.match(line) + if d and d.group(1) == name: + cands.append(strip_comment(d.group(2)) or "1") + if cands: + literal = [c for c in cands if not c.startswith("CONFIG_")] + defaults[name] = literal[-1] if literal else cands[-1] + # 注意:不能跳到 j+1——lv_conf_internal.h 自身有 `#ifndef LV_CONF_INTERNAL_H` + # 大保护块,跳过去会把整份文件吞掉(实测 defaults 变空)。逐行前进, + # 让嵌套的 `#ifndef` 也能被扫到。 + i += 1 + return defaults + + +def build_symbols(src_dir): + """扫 LVGL 头文件里的数值宏(LV_OS_NONE / LV_STDLIB_BUILTIN / LV_TXT_ENC_UTF8 等)。""" + symbols = {} + for root, _dirs, files in os.walk(src_dir): + for fn in files: + if not fn.endswith(".h"): + continue + try: + lines = read_lines(os.path.join(root, fn)) + except OSError: + continue + for line in lines: + m = RE_DEFINE.match(line) + if not m: + continue + raw = strip_comment(m.group(2)) + if RE_NUM.match(raw): + symbols.setdefault(m.group(1), raw) + return symbols + + +def normalize(value, symbols, depth=0): + """归一化取值:去括号/整数后缀/空白,符号名解析成数值,便于跨文件比对。""" + v = (value or "").strip() + while v.startswith("(") and v.endswith(")"): + inner = v[1:-1].strip() + if not inner: + break + v = inner + v = re.sub(r"\s+", " ", v) + if RE_NUM.match(v): + v = re.sub(r"[uUlL]+$", "", v) + return str(int(v, 0)) + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", v): + if v in symbols and depth < 4: + return normalize(symbols[v], symbols, depth + 1) + return v + return re.sub(r"(\d)[uUlL]+\b", r"\1", v) + + +def main(argv=None): + ap = argparse.ArgumentParser(description="两份 lv_conf.h 特性子集一致性门禁") + ap.add_argument("--mcu", default=DEF_MCU) + ap.add_argument("--sim", default=DEF_SIM) + ap.add_argument("--internal", default=DEF_INTERNAL) + ap.add_argument("--lvgl-src", default=DEF_LVGL_SRC) + ap.add_argument("--list", action="store_true", help="打印全量对照表") + a = ap.parse_args(argv) + + for p in (a.mcu, a.sim, a.internal): + if not os.path.isfile(p): + print("[ERROR] 文件不存在: %s" % p) + return 2 + + mcu, mcu_cnt, _ = parse_defines(a.mcu) + sim, sim_cnt, _ = parse_defines(a.sim) + defaults = parse_defaults(a.internal) + symbols = build_symbols(a.lvgl_src) + + names = sorted(set(mcu) | set(sim)) + checked = [n for n in names if n not in IGNORE_EXACT and not n.startswith(IGNORE_PREFIX)] + + bad = [] + warn = [] + rows = [] + for n in checked: + in_mcu, in_sim = n in mcu, n in sim + vm = mcu.get(n, defaults.get(n)) + vs = sim.get(n, defaults.get(n)) + if vm is None or vs is None: + bad.append(("UNKNOWN", n, "取不到生效值(MCU=%s / SIM=%s)" % (vm, vs))) + continue + nm, ns = normalize(vm, symbols), normalize(vs, symbols) + rows.append((n, "显式" if in_mcu else "默认", nm, "显式" if in_sim else "默认", ns)) + if nm != ns: + if n in ALLOW_DIFF: + print("[allow] %-40s MCU=%s SIM=%s (%s)" % (n, nm, ns, ALLOW_DIFF[n])) + else: + bad.append(("MISMATCH", n, "MCU=%s(%s) vs SIM=%s(%s)" + % (nm, "显式" if in_mcu else "默认", ns, "显式" if in_sim else "默认"))) + if in_mcu != in_sim: + side = "MCU" if not in_mcu else "SIM" + warn.append(("IMPLICIT", n, "%s 端未显式定义,靠 lv_conf_internal 默认 = %s" + % (side, ns if not in_mcu else nm))) + + known = set(defaults) | set(symbols) + for label, vals in (("MCU", mcu), ("SIM", sim)): + for n in sorted(vals): + if n in known or n in IGNORE_EXACT or n.startswith(IGNORE_PREFIX): + continue + bad.append(("INVALID", n, "%s 端定义了 LVGL 不认的宏(写了不生效)" % label)) + for label, cnt in (("MCU", mcu_cnt), ("SIM", sim_cnt)): + for n in sorted(cnt): + if cnt[n] > 1: + bad.append(("DUPLICATE", n, "%s 端定义 %d 次(生效值 = 最后一次)" % (label, cnt[n]))) + + if a.list: + print("%-42s %-8s %-24s %-8s %s" % ("宏", "MCU 来源", "MCU 生效值", "SIM 来源", "SIM 生效值")) + for r in rows: + print("%-42s %-8s %-24s %-8s %s" % r) + print("对照 %d 项(忽略端专属宏 %d 项)" % (len(rows), len(names) - len(checked))) + + for kind, n, why in warn: + print("[warn] %-40s %s" % (n, why)) + if warn: + print("-> IMPLICIT 不判失败,但默认值随 LVGL 版本变;建议显式固化") + + if not bad: + print("PASS: 两份 lv_conf.h 特性子集一致(生效值比对 %d 项,警告 %d 项)" + % (len(rows), len(warn))) + return 0 + + print("FAIL: %d 项不一致" % len(bad)) + for kind, n, why in bad: + print(" [%s] %-38s %s" % (kind, n, why)) + print("-> 两端必须跑同一套特性子集,否则模拟器 golden 回归验证不到固件实际编译的配置") + print(" 规则与理由见 docs/UI设计/UI_设计架构.md §1.4 第 10 条") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/simulator/build_sim.ps1 b/simulator/build_sim.ps1 index 277b30f0..606b6b77 100644 --- a/simulator/build_sim.ps1 +++ b/simulator/build_sim.ps1 @@ -141,4 +141,4 @@ if (Test-Path $exe) { } Write-Host "[build_sim] 构建退出码 0 但未找到 $exe" -ForegroundColor Red exit 1 - \ No newline at end of file + diff --git a/simulator/dbg_log.h b/simulator/dbg_log.h index ec2153d4..6d0e51af 100644 --- a/simulator/dbg_log.h +++ b/simulator/dbg_log.h @@ -1,4 +1,13 @@ /* + * 模块名称:模拟器调试日志 stub(dbg_log.h) + * 模块功能:覆盖 MCU 工程的 Drivers/BSP/dbg_log.h,把 DBG_* 直接映射到宿主 printf + * (模拟器无 sys_clock/RTC,日志走 PC 控制台) + * 适用平台:Windows / Linux / macOS(PC 模拟器) + * 作者:王建锋 + * 创建日期:2026-08-31 + * 修改记录: + * v1.0 2026-08-31 王建锋 创建:模拟器侧 DBG_* stub + * * dbg_log.h — 模拟器专用调试日志 stub(覆盖 MCU 工程的 Drivers/BSP/dbg_log.h) * * 模拟器不使用 sys_clock.h / RTC,因此把 DBG_* 宏直接映射到 printf, @@ -6,8 +15,8 @@ * DBG_TAG 仍需各 .c 文件在 include 前定义,这里保留该约定。 */ -#ifndef __DBG_LOG_H -#define __DBG_LOG_H +#ifndef DBG_LOG_H +#define DBG_LOG_H #ifdef __cplusplus extern "C" { @@ -35,4 +44,4 @@ extern "C" { } #endif -#endif /* __DBG_LOG_H */ +#endif /* DBG_LOG_H */ diff --git a/simulator/lv_conf.h b/simulator/lv_conf.h index 0294ed29..27666afd 100644 --- a/simulator/lv_conf.h +++ b/simulator/lv_conf.h @@ -1,4 +1,16 @@ /** + * 模块名称:LVGL 配置 —— PC 模拟器侧(simulator/lv_conf.h) + * 模块功能:LVGL v9.3.0 编译期特性裁剪与 SDL2 适配(160x160 1bpp 单色) + * 适用平台:Windows(CMake + SDL2) + * 作者:王建锋 + * 创建日期:2026-08-31 + * 修改记录: + * v1.0 2026-08-31 王建锋 创建:基于 Lib/lv_conf.h 适配 PC 模拟器 + * + * ⚠ 本文件与 `Lib/lv_conf.h`(MCU 端)必须保持**同一特性子集**,已由门禁校验: + * `python scripts/check_lv_conf_sync.py`(pre-commit 钩子 lv-conf-sync, + * 两端任一改动都会触发)。规则正文见 docs/UI设计/UI_设计架构.md §1.4 第 10 条。 + * * @file lv_conf.h * @brief LVGL v9.3.0 配置 — Windows SDL2 模拟器(160x160 1bpp 单色) * @note 基于项目 Lib/lv_conf.h 适配,用于 PC 模拟器 UI 开发 @@ -51,7 +63,8 @@ /** 复杂绘制:主界面顶部开口浅 V 装饰含斜线(lv_line 斜段依赖 LV_DRAW_SW_COMPLEX),须与 MCU 端一致保持开启,否则两腿不渲染 */ #define LV_DRAW_SW_COMPLEX 1 -#define LV_DRAW_SW_COMPLEX_GRADIENTS 0 +/* 注:9.3 没有 LV_DRAW_SW_COMPLEX_GRADIENTS / LV_DRAW_SW_YIELD / LV_DRAW_SW_ASYNC + * (9.4 才引入),原定义无效,已删 */ #define LV_DRAW_SW_SHADOW_CACHE_SIZE 0 #define LV_DRAW_SW_CIRCLE_CACHE_SIZE 4 /* LV_DRAW_SW_COMPLEX 依赖非零(零长数组 C2466);与 MCU 端默认一致 */ #define LV_DRAW_LAYER_SIMPLE_BUF_SIZE 0 @@ -72,16 +85,13 @@ #define LV_DRAW_SW_SUPPORT_I1 1 #define LV_DRAW_SW_I1_LUM_THRESHOLD 127 -/** 关闭 GPU/矢量绘制 */ +/** 关闭 GPU/矢量绘制(注:9.3 无 LV_USE_DRAW_NEMA_GFX / PPA / EVE / NANOVG, + * 属更新版本 lv_conf_template 残留,原定义无效,已删) */ #define LV_USE_DRAW_VG_LITE 0 #define LV_USE_DRAW_DAVE2D 0 -#define LV_USE_DRAW_NEMA_GFX 0 #define LV_USE_DRAW_PXP 0 -#define LV_USE_DRAW_PPA 0 #define LV_USE_DRAW_DMA2D 0 -#define LV_USE_DRAW_EVE 0 #define LV_USE_DRAW_G2D 0 -#define LV_USE_DRAW_NANOVG 0 #define LV_USE_DRAW_OPENGLES 0 #define LV_USE_DRAW_SDL 0 #define LV_USE_THORVG 0 @@ -105,11 +115,10 @@ #define LV_OBJ_ID_AUTO_ASSIGN 1 #define LV_USE_OBJ_ID_BUILTIN 1 #define LV_USE_OBJ_PROPERTY 0 -#define LV_USE_EXT_DATA 0 #define LV_USE_FLOAT 0 #define LV_USE_OBSERVER 0 -#define LV_USE_TRANSLATION 0 #define LV_USE_COLOR_FILTER 0 +/* 注:9.3 无 LV_USE_EXT_DATA / LV_USE_TRANSLATION(9.4 才有),原两行无效,已删 */ /*============================================================================ * LOGGING @@ -126,7 +135,7 @@ /*============================================================================ * LAYOUTS *============================================================================*/ -#define LV_USE_FLEX 1 +#define LV_USE_FLEX 0 /* 2026-10-11 与 MCU 端一致:全仓无 lv_obj_set_flex_* 调用 */ #define LV_USE_GRID 0 /*============================================================================ @@ -142,7 +151,6 @@ #define LV_USE_BMP 0 #define LV_USE_TJPGD 0 #define LV_USE_LIBJPEG_TURBO 0 -#define LV_USE_LIBWEBP 0 #define LV_USE_SVG 0 /*============================================================================ @@ -190,7 +198,7 @@ #define LV_FONT_CUSTOM_DECLARE LV_FONT_DECLARE(ui_font_cn_12) #define LV_FONT_DEFAULT &ui_font_cn_12 -#define LV_USE_CUSTOM_FONT_DEFAULT 0 +/* 注:9.3 无 LV_USE_CUSTOM_FONT_DEFAULT,原行无效,已删 */ #define LV_FONT_FMT_TXT_LARGE 0 #define LV_USE_FONT_COMPRESSED 0 #define LV_USE_FONT_PLACEHOLDER 1 @@ -207,11 +215,10 @@ #define LV_USE_3DTEXTURE 0 #define LV_USE_ANIMIMG 0 #define LV_USE_ARC 0 -#define LV_USE_ARCLABEL 0 #define LV_USE_BAR 0 #define LV_USE_BARCODE 0 -#define LV_USE_BUTTON 1 -#define LV_USE_BUTTONMATRIX 1 +#define LV_USE_BUTTON 0 +#define LV_USE_BUTTONMATRIX 0 #define LV_USE_CALENDAR 0 #define LV_USE_CANVAS 0 #define LV_USE_CHART 0 @@ -219,13 +226,16 @@ #define LV_USE_DROPDOWN 0 #define LV_USE_FFMPEG 0 #define LV_USE_GIF 0 -#define LV_USE_GLTF 0 -#define LV_USE_GSTREAMER 0 -#define LV_USE_IMAGE 1 +/* 注:9.3 无 LV_USE_GLTF / LV_USE_GSTREAMER / LV_USE_ARCLABEL(更新版本残留),已删 */ +/* 2026-10-11(F-2/F-3 对齐 MCU 端 Lib/lv_conf.h):本项目 UI 全部自绘, + * 三处代码(Drivers/BSP/UI、App、simulator)零控件构造函数、零 flex/grid 调用。 + * 关掉后模拟器与固件跑的是同一套特性子集,golden 回归才能真正验证 MCU 配置。 + * 若将来引入控件型页面(U2 原型库),两端同步逐项打开并复跑 ui-verify。 */ +#define LV_USE_IMAGE 0 #define LV_USE_IMAGEBUTTON 0 #define LV_USE_IME_PINYIN 0 -#define LV_USE_KEYBOARD 1 -#define LV_USE_LABEL 1 +#define LV_USE_KEYBOARD 0 +#define LV_USE_LABEL 0 #define LV_LABEL_TEXT_SELECTION 0 #define LV_LABEL_LONG_TXT_HINT 0 #define LV_LABEL_WAIT_CHAR_COUNT 0 @@ -244,7 +254,7 @@ #define LV_USE_SPINBOX 0 #define LV_USE_SPINNER 0 #define LV_USE_SWITCH 0 -#define LV_USE_TEXTAREA 1 +#define LV_USE_TEXTAREA 0 #define LV_USE_TABLE 0 #define LV_USE_TABVIEW 0 #define LV_USE_TILEVIEW 0 @@ -288,13 +298,12 @@ #define LV_USE_WINDOWS 0 /*============================================================================ - * THEMES (补充) + * THEMES 子选项(LV_USE_THEME_DEFAULT / SIMPLE 本体已在上面 THEMES 块定义, + * 此处勿重复定义——同文件重复 #define 后者生效,极易误读) *============================================================================*/ -#define LV_USE_THEME_DEFAULT 0 #define LV_THEME_DEFAULT_DARK 0 #define LV_THEME_DEFAULT_GROW 0 #define LV_THEME_DEFAULT_TRANSITION_TIME 0 -#define LV_USE_THEME_SIMPLE 0 /*============================================================================ * EXPORTS diff --git a/simulator/main.c b/simulator/main.c index a979476f..670efcb3 100644 --- a/simulator/main.c +++ b/simulator/main.c @@ -1,4 +1,12 @@ /** + * 模块名称:PC 模拟器入口(simulator/main.c) + * 模块功能:在 PC 上以 SDL2 跑**与固件同一套** UI 源码,提供窗口调试与多种无头自检模式 + * 适用平台:Windows(CMake + SDL2) + * 作者:王建锋 + * 创建日期:2026-08-31 + * 修改记录: + * v1.0 2026-08-31 王建锋 创建:基于 lv_port_pc_vscode 适配 + * * @file main.c * @brief LVGL 模拟器入口 — 160x160 1bpp 单色菜单 UI * @note 基于 lv_port_pc_vscode 适配,用于 PC 上 UI 开发与调试 @@ -69,30 +77,35 @@ static uint8_t g_shot_buf[SIM_SCREEN_W * SIM_SCREEN_H / 8]; /* - * @brief 离屏截图用空 delay 回调 - * @note 默认 lv_delay_ms 会忙等 tick 前进,但离屏渲染时 tick 不会在等待期间推进, - * 导致死循环挂死。window 模式经 SDL 设了真实 delay,离屏这里用空实现代替。 + * 函数功能:离屏截图用的空 delay 回调 + * 入口参数:ms - 请求延时的毫秒数(未使用) uint32_t + * 返回值:无 + * 说明:默认 lv_delay_ms 会忙等 tick 前进,但离屏渲染时 tick 不会在等待期间推进, + * 导致死循环挂死。window 模式经 SDL 设了真实 delay,离屏这里用空实现代替。 */ -static void shot_delay_cb(uint32_t ms) -{ +static void shot_delay_cb(uint32_t ms) { (void)ms; } /* - * @brief 离屏截图 flush 回调(不写真实设备,直接标记就绪) + * 函数功能:离屏截图 flush 回调(不写真实设备,直接标记就绪) + * 入口参数:disp - LVGL 显示对象 lv_display_t * + * area - 刷新区域(未使用) const lv_area_t * + * color_p - 像素缓冲(未使用) uint8_t * + * 返回值:无 */ -static void shot_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t * color_p) -{ +static void shot_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t * color_p) { (void)area; (void)color_p; lv_display_flush_ready(disp); } /* - * 键序列字符 → LVGL 键值(非键字符返回 0 = 忽略) + * 函数功能:键序列字符 → LVGL 键值映射(--keys 无头回归用) + * 入口参数:c - 键序列字符(u=↑ d=↓ l=← r=→ e=确认 s=返回) char + * 返回值:LVGL 键值;非键字符返回 0 = 忽略 uint32_t */ -static uint32_t sim_map_key_char(char c) -{ +static uint32_t sim_map_key_char(char c) { switch (c) { case 'u': return LV_KEY_UP; case 'd': return LV_KEY_DOWN; @@ -105,12 +118,14 @@ static uint32_t sim_map_key_char(char c) } /* - * @brief 待机屏底部时钟提供回调(平台差异:模拟器 = 宿主 time()) + * 函数功能:待机屏底部时钟提供回调(平台差异:模拟器 = 宿主 time()) + * 入口参数:buf - 输出缓冲(格式化时间串) char * + * len - 缓冲长度 size_t + * 返回值:无 */ -static void sim_time_cb(char *buf, size_t len) -{ +static void sim_time_cb(char *buf, size_t len) { time_t t = time(NULL); - struct tm tmv; + struct tm tmv = {0}; /* 显式清零:localtime_s/r 是"输出型"参数,静态分析按未初始化报 */ #if defined(_MSC_VER) localtime_s(&tmv, &t); @@ -123,14 +138,13 @@ static void sim_time_cb(char *buf, size_t len) } /* - * @brief 无窗口离屏渲染(可注入键序列)并导出末帧像素 - * @param keys 键序列(u=上 d=下 l=左 r=右 e=确认 s=返回;空 = 不注键) - * @param path 输出文件(写入 160x160 I1 原始像素) - * @param standby 非 0 = 注键后强制进入待机屏再截图(--standby 模式) - * @return 0 成功,非 0 失败 + * 函数功能:无窗口离屏渲染(可注入键序列)并导出末帧像素 + * 入口参数:keys - 键序列(u=上 d=下 l=左 r=右 e=确认 s=返回;空 = 不注键) const char * + * path - 输出文件(写入 160x160 I1 原始像素) const char * + * standby - 非 0 = 注键后强制进入待机屏再截图(--standby 模式) int + * 返回值:0 - 成功;非 0 - 失败 int */ -static int run_headless(const char * keys, const char * path, int standby) -{ +static int run_headless(const char * keys, const char * path, int standby) { setvbuf(stdout, NULL, _IONBF, 0); lv_init(); lv_delay_set_cb(shot_delay_cb); @@ -209,7 +223,7 @@ static int run_headless(const char * keys, const char * path, int standby) uint32_t snap_h = snap->header.h; /* I1 缓冲区布局:[palette 8B][pixel data],跳过 palette 只写像素 */ uint32_t pal_bytes = LV_COLOR_INDEXED_PALETTE_SIZE(LV_COLOR_FORMAT_I1) * (uint32_t)sizeof(lv_color32_t); - uint8_t * px = snap->data + pal_bytes; + const uint8_t * px = snap->data + pal_bytes; /* 只读:写文件时不再改动快照 */ uint32_t px_bytes = (uint32_t)snap->header.stride * (uint32_t)snap_h; FILE *fp = fopen(path, "wb"); if (fp == NULL) { @@ -239,13 +253,13 @@ static const char s_cfgtest_doc[] = static const uint8_t s_cfgtest_ip[4] = { 10u, 0u, 0u, 1u }; /* - * @brief 配置轮询自检(无窗口、无需 LVGL/SDL,进程内闭环) - * @note 验证链:net_cfg_load(只读 INI)→ poll 建基线 → 伪造外部编辑 - * → poll 两次(沉降去抖)→ 重载并更新 RAM 副本 - * @return 0 PASS,非 0 FAIL + * 函数功能:配置轮询自检(无窗口、无需 LVGL/SDL,进程内闭环) + * 入口参数:无 + * 返回值:0 - PASS;非 0 - FAIL int + * 说明:验证链 net_cfg_load(只读 INI)→ poll 建基线 → 伪造外部编辑 + * → poll 两次(沉降去抖)→ 重载并更新 RAM 副本 */ -static int run_cfgtest(void) -{ +static int run_cfgtest(void) { int i; const net_cfg_t *p_cfg; @@ -367,13 +381,13 @@ static int run_cfgtest(void) } /* - * @brief 日志引擎自检(无窗口、进程内闭环) - * @note 验证链:清理旧档 → post N 条 → count/get 回读断言 + 宿主文件字节级校验 - * (64B 定长、ISO 时间戳格式、epoch 字段 ≈ now、NUL 填充) - * @return 0 PASS,非 0 FAIL + * 函数功能:日志引擎自检(无窗口、进程内闭环) + * 入口参数:无 + * 返回值:0 - PASS;非 0 - FAIL int + * 说明:验证链 清理旧档 → post N 条 → count/get 回读断言 + 宿主文件字节级校验 + * (64B 定长、ISO 时间戳格式、epoch 字段 ≈ now、NUL 填充) */ -static int run_logtest(void) -{ +static int run_logtest(void) { uint32_t ep; char ts[SYSLOG_TS_LEN + 1u]; char desc[SYSLOG_DESC_MAX + 1u]; @@ -464,10 +478,13 @@ static int run_logtest(void) } /** - * @brief 程序入口 + * 函数功能:模拟器入口(解析模式开关 → 窗口模式或无头自检) + * 入口参数:argc - 参数个数 int + * argv - 参数数组(--screenshot / --keys / --standby / --cfgtest / + * --logtest / --idle) char ** + * 返回值:0 - 正常退出或自检 PASS;非 0 - 失败 int */ -int main(int argc, char **argv) -{ +int main(int argc, char **argv) { /* 截图/自检模式:--screenshot / --keys / --standby / --cfgtest / --logtest */ for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--screenshot") == 0 && (i + 1) < argc) { diff --git a/simulator/sim/net_socket_sim.c b/simulator/sim/net_socket_sim.c index 6a717343..8c339e22 100644 --- a/simulator/sim/net_socket_sim.c +++ b/simulator/sim/net_socket_sim.c @@ -53,8 +53,7 @@ static int s_generation = 0; /* 网络代次(模拟器恒为 0 * 限定条件:无 * 函数说明:任何 Socket 操作前先调用;已初始化则直接返回 */ -static int ensure_wsa(void) -{ +static int ensure_wsa(void) { static int s_done = 0; if (s_done == 0) { @@ -75,8 +74,7 @@ static int ensure_wsa(void) * 限定条件:s_socks 已定义 * 函数说明:线性扫描,返回首个 in_use == 0 的控制块索引 */ -static int alloc_fd(void) -{ +static int alloc_fd(void) { int i; for (i = 0; i < NET_MAX_SOCKETS; i++) { @@ -96,8 +94,7 @@ static int alloc_fd(void) * 限定条件:两结构体布局一致(均 16 字节,字段偏移相同) * 函数说明:字段偏移一致,按 sizeof 直接拷贝 */ -static void to_ws_addr(const struct net_sockaddr *addr, struct sockaddr_in *out) -{ +static void to_ws_addr(const struct net_sockaddr *addr, struct sockaddr_in *out) { memcpy(out, addr, sizeof(struct sockaddr_in)); } @@ -110,8 +107,7 @@ static void to_ws_addr(const struct net_sockaddr *addr, struct sockaddr_in *out) * 限定条件:两结构体布局一致(均 16 字节,字段偏移相同) * 函数说明:字段偏移一致,按 sizeof 直接拷贝 */ -static void from_ws_addr(const struct sockaddr_in *in, struct net_sockaddr *out) -{ +static void from_ws_addr(const struct sockaddr_in *in, struct net_sockaddr *out) { memcpy(out, in, sizeof(struct sockaddr_in)); } @@ -123,8 +119,7 @@ static void from_ws_addr(const struct sockaddr_in *in, struct net_sockaddr *out) * 限定条件:无 * 函数说明:与 MCU 端 net_init 语义对齐,供上层统一调用 */ -int net_init(const char *ip, const char *mask, const char *gateway, const uint8_t *mac) -{ +int net_init(const char *ip, const char *mask, const char *gateway, const uint8_t *mac) { (void)ip; (void)mask; (void)gateway; @@ -145,8 +140,7 @@ int net_init(const char *ip, const char *mask, const char *gateway, const uint8_ * 限定条件:无 * 函数说明:模拟器无网络重建,代次恒 0,使上层代次自愈逻辑空转 */ -uint32_t net_get_generation(void) -{ +uint32_t net_get_generation(void) { return (uint32_t)s_generation; } @@ -157,8 +151,7 @@ uint32_t net_get_generation(void) * 限定条件:无 * 函数说明:模拟器不会触发,保留接口以对齐 API */ -void net_generation_inc(void) -{ +void net_generation_inc(void) { s_generation++; } @@ -169,8 +162,7 @@ void net_generation_inc(void) * 限定条件:无 * 函数说明:模拟器无 CH395F 中断,空操作 */ -int net_poll(void) -{ +int net_poll(void) { return 0; } @@ -183,8 +175,7 @@ int net_poll(void) * 限定条件:Winsock 已就绪 * 函数说明:分配空闲控制块,创建 Winsock SOCKET 并置非阻塞 */ -int net_socket(int domain, int type) -{ +int net_socket(int domain, int type) { int idx; int is_udp; SOCKET ws; @@ -238,8 +229,7 @@ int net_socket(int domain, int type) * 限定条件:socket 已创建 * 函数说明:设置 SO_REUSEADDR 后 bind,便于断线后重监听复用端口 */ -int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen) -{ +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen) { struct sockaddr_in wsaddr; int yes = 1; @@ -273,8 +263,7 @@ int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen) * 限定条件:socket 已绑定 * 函数说明:backlog 为 1(单客户端),监听 Socket 保持非阻塞 */ -int net_listen(int sockfd) -{ +int net_listen(int sockfd) { if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { s_net_errno = NET_ERR_BADF; return -1; @@ -301,8 +290,7 @@ int net_listen(int sockfd) * 函数说明:非阻塞监听下无新连接时返回 -1 并置 NET_ERR_WOULDBLOCK; * 与 MCU 端不同,Winsock 的 accept 返回独立于监听 Socket 的新 fd */ -int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) -{ +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) { struct sockaddr_in wscli; int wslen; SOCKET cws; @@ -356,8 +344,7 @@ int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) * 而 accept 本就是非阻塞的、**没有 MCU 端的 RPC 成本** → 恒返回 1, * 使上层门控在模拟器上等价于"改前行为"(逐位一致,不引入新语义) */ -int net_can_accept(int sockfd) -{ +int net_can_accept(int sockfd) { if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { return 0; } @@ -372,8 +359,7 @@ int net_can_accept(int sockfd) * 函数说明:同 net_can_accept——Winsock recv 非阻塞、无 RPC 成本 → 恒返回 1, * 上层门控在模拟器上不改变既有行为(含"每轮都 recv"的自愈特性) */ -int net_can_read(int sockfd) -{ +int net_can_read(int sockfd) { if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { return 0; } @@ -390,8 +376,7 @@ int net_can_read(int sockfd) * 限定条件:socket 已创建 * 函数说明:非阻塞 Socket 上连接未完成时返回 -1 + NET_ERR_WOULDBLOCK */ -int net_connect(int sockfd, const struct net_sockaddr *addr, int addrlen) -{ +int net_connect(int sockfd, const struct net_sockaddr *addr, int addrlen) { struct sockaddr_in wsaddr; if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { @@ -430,8 +415,7 @@ int net_connect(int sockfd, const struct net_sockaddr *addr, int addrlen) * 函数说明:非阻塞单次发送(保持 UI 主循环不阻塞);协议帧小且对端主动 * 读取,实践中一次即发完 */ -int net_send(int sockfd, const void *buf, int len, int flags) -{ +int net_send(int sockfd, const void *buf, int len, int flags) { int r; if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { @@ -473,8 +457,7 @@ int net_send(int sockfd, const void *buf, int len, int flags) * 函数说明:与 MCU 端语义一致——>0 有数据,0 对端关闭,<0 且 errno 区分 * "暂无数据"与"真错误" */ -int net_recv(int sockfd, void *buf, int len, int flags) -{ +int net_recv(int sockfd, void *buf, int len, int flags) { int r; (void)flags; @@ -519,8 +502,7 @@ int net_recv(int sockfd, void *buf, int len, int flags) * 限定条件:socket 已创建,buf/dest_addr 非空 */ int net_sendto(int sockfd, const void *buf, int len, int flags, - const struct net_sockaddr *dest_addr, int addrlen) -{ + const struct net_sockaddr *dest_addr, int addrlen) { struct sockaddr_in wsaddr; int r; @@ -563,8 +545,7 @@ int net_sendto(int sockfd, const void *buf, int len, int flags, * 限定条件:socket 已创建,buf 非空 */ int net_recvfrom(int sockfd, void *buf, int len, int flags, - struct net_sockaddr *src_addr, int *addrlen) -{ + struct net_sockaddr *src_addr, int *addrlen) { struct sockaddr_in wsaddr; int wslen; int r; @@ -606,8 +587,7 @@ int net_recvfrom(int sockfd, void *buf, int len, int flags, * 限定条件:socket 已打开(in_use == 1) * 函数说明:关闭 Winsock 句柄并释放控制块(in_use 清零,net_get_sock 转返回 NULL) */ -int net_close(int sockfd) -{ +int net_close(int sockfd) { if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS) { s_net_errno = NET_ERR_BADF; return -1; @@ -636,8 +616,7 @@ int net_close(int sockfd) * 限定条件:socket 已创建 * 函数说明:模拟器以非阻塞 I/O 直接感知事件,回调仅存储不主动触发 */ -int net_set_event_cb(int sockfd, net_event_cb_t cb, void *arg) -{ +int net_set_event_cb(int sockfd, net_event_cb_t cb, void *arg) { if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS || s_socks[sockfd].in_use == 0) { s_net_errno = NET_ERR_BADF; return -1; @@ -656,8 +635,7 @@ int net_set_event_cb(int sockfd, net_event_cb_t cb, void *arg) * 返回值:最近一次 net_* 操作设置的错误码(NET_OK / NET_ERR_xxx) * 限定条件:无 */ -int net_get_errno(void) -{ +int net_get_errno(void) { return s_net_errno; } @@ -669,8 +647,7 @@ int net_get_errno(void) * 限定条件:无 * 函数说明:逐段解析后按 NET_HTONL 转网络字节序(与 MCU 端实现一致) */ -uint32_t net_inet_addr(const char *cp) -{ +uint32_t net_inet_addr(const char *cp) { uint32_t addr = 0; int i; @@ -701,8 +678,7 @@ uint32_t net_inet_addr(const char *cp) * 返回值:buf 指针(buf 为 NULL 时返回 NULL) * 限定条件:buf 非空且长度足够 */ -char *net_inet_ntoa(uint32_t addr, char *buf) -{ +char *net_inet_ntoa(uint32_t addr, char *buf) { uint32_t naddr = NET_NTOHL(addr); if (buf == NULL) { @@ -724,8 +700,7 @@ char *net_inet_ntoa(uint32_t addr, char *buf) * 返回值:网络序端口 * 限定条件:无 */ -uint16_t net_htons(uint16_t hostshort) -{ +uint16_t net_htons(uint16_t hostshort) { return NET_HTONS(hostshort); } @@ -735,8 +710,7 @@ uint16_t net_htons(uint16_t hostshort) * 返回值:主机序端口 * 限定条件:无 */ -uint16_t net_ntohs(uint16_t netshort) -{ +uint16_t net_ntohs(uint16_t netshort) { return NET_NTOHS(netshort); } @@ -746,8 +720,7 @@ uint16_t net_ntohs(uint16_t netshort) * 返回值:网络序 IP * 限定条件:无 */ -uint32_t net_htonl(uint32_t hostlong) -{ +uint32_t net_htonl(uint32_t hostlong) { return NET_HTONL(hostlong); } @@ -757,8 +730,7 @@ uint32_t net_htonl(uint32_t hostlong) * 返回值:主机序 IP * 限定条件:无 */ -uint32_t net_ntohl(uint32_t netlong) -{ +uint32_t net_ntohl(uint32_t netlong) { return NET_NTOHL(netlong); } @@ -769,8 +741,7 @@ uint32_t net_ntohl(uint32_t netlong) * 限定条件:无 * 函数说明:上层据此判断"监听 Socket 是否随客户端一同失效"(单连接模型) */ -net_sock_t *net_get_sock(int sockfd) -{ +net_sock_t *net_get_sock(int sockfd) { if (sockfd < 0 || sockfd >= NET_MAX_SOCKETS) { return NULL; } @@ -790,8 +761,7 @@ net_sock_t *net_get_sock(int sockfd) * 限定条件:socket 已创建 * 函数说明:经 Winsock getsockname 读取实际绑定地址 */ -int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen) -{ +int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen) { struct sockaddr_in wsaddr; int wslen; diff --git a/simulator/sim/sim_port.c b/simulator/sim/sim_port.c index 3bd765cf..c29377e3 100644 --- a/simulator/sim/sim_port.c +++ b/simulator/sim/sim_port.c @@ -25,8 +25,7 @@ static lv_indev_t *g_kbd_indev = NULL; * 2. 创建键盘输入设备及默认输入组 * 3. 不创建鼠标/触摸设备(模拟器仅用键盘导航) */ -int sim_port_init(int32_t hor_res, int32_t ver_res) -{ +int sim_port_init(int32_t hor_res, int32_t ver_res) { /* 创建默认输入组 */ lv_group_t *g = lv_group_create(); if (g == NULL) { @@ -65,8 +64,7 @@ int sim_port_init(int32_t hor_res, int32_t ver_res) * 返回值:显示设备指针 lv_display_t * 可能为 NULL * 限定条件:sim_port_init() 已成功调用 */ -void *sim_port_get_display(void) -{ +void *sim_port_get_display(void) { return (void *)lv_display_get_default(); } @@ -83,8 +81,7 @@ void *sim_port_get_display(void) * 转换,直接读 get_buf_active 不可靠;读回屏幕呈现像素最稳, * 且完全避开快照离屏重渲染(SDL 驱动下会卡派发循环) */ -int sim_port_get_screen_mono(uint8_t *buf, uint32_t buf_size) -{ +int sim_port_get_screen_mono(uint8_t *buf, uint32_t buf_size) { if (buf == NULL) { return -1; } diff --git a/simulator/sim/sim_port.h b/simulator/sim/sim_port.h index 3455938d..025cb7ef 100644 --- a/simulator/sim/sim_port.h +++ b/simulator/sim/sim_port.h @@ -4,8 +4,8 @@ * 适用平台:Windows / Linux / macOS (SDL2) * 说明:替代 MCU 端的 ui_port.c,用于 PC 上 UI 开发与调试 */ -#ifndef __SIM_PORT_H -#define __SIM_PORT_H +#ifndef SIM_PORT_H +#define SIM_PORT_H #include @@ -47,4 +47,4 @@ int sim_port_get_screen_mono(uint8_t *buf, uint32_t buf_size); } #endif -#endif /* __SIM_PORT_H */ +#endif /* SIM_PORT_H */ diff --git a/simulator/sim/sim_syslog.c b/simulator/sim/sim_syslog.c index 54386609..6221e2ce 100644 --- a/simulator/sim/sim_syslog.c +++ b/simulator/sim/sim_syslog.c @@ -32,16 +32,16 @@ static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_M */ static void s_now(char *p_ts, uint32_t *p_epoch) { time_t t = time(NULL); - struct tm tmv; + struct tm tmv = {0}; /* 显式清零:localtime_s/r 是"输出型"参数,静态分析按未初始化报 */ #if defined(_WIN32) localtime_s(&tmv, &t); #else localtime_r(&t, &tmv); #endif - (void)sprintf(p_ts, "%04d-%02d-%02d %02d:%02d:%02d", - tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, - tmv.tm_hour, tmv.tm_min, tmv.tm_sec); + (void)snprintf(p_ts, SYSLOG_TS_LEN + 1u, "%04d-%02d-%02d %02d:%02d:%02d", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, + tmv.tm_hour, tmv.tm_min, tmv.tm_sec); *p_epoch = (uint32_t)t; } @@ -68,7 +68,9 @@ static int s_append(syslog_id_t id, const char *desc) { s_now(ts, &epoch); memcpy(rec, ts, SYSLOG_TS_LEN); rec[SYSLOG_TS_LEN] = ' '; - (void)sprintf((char *)(rec + SYSLOG_EPOCH_OFFS), "%010u", (unsigned int)epoch); + /* +1 = 容纳 NUL;字段本身固定 10 位数字,与旧 sprintf 写入字节数完全一致 */ + (void)snprintf((char *)(rec + SYSLOG_EPOCH_OFFS), SYSLOG_EPOCH_LEN + 1u, + "%010u", (unsigned int)epoch); rec[SYSLOG_DESC_OFFS - 1u] = ' '; dl = strlen(desc); if (dl > SYSLOG_DESC_MAX) { @@ -185,7 +187,12 @@ int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch) { uint32_t v = 0u; uint32_t i; - if (p_epoch == NULL || syslog_count(id) == 0u) { + /* 先判空再写:旧写法 `if (p_epoch == NULL || ...)` 之后立刻 *p_epoch = 0, + * 一旦调用方真传 NULL 就是空指针解引用(cppcheck nullPointerRedundantCheck 实报) */ + if (p_epoch == NULL) { + return -1; + } + if (syslog_count(id) == 0u) { *p_epoch = 0u; return -1; } diff --git a/simulator/sim/ui_net_sink_backend_sim.c b/simulator/sim/ui_net_sink_backend_sim.c index e24a21d8..64f76f77 100644 --- a/simulator/sim/ui_net_sink_backend_sim.c +++ b/simulator/sim/ui_net_sink_backend_sim.c @@ -21,8 +21,7 @@ * 限定条件:无 * 函数说明:远端按键码与 LVGL 按键码一一对应 */ -static uint32_t sim_map_key(uint8_t remo_key) -{ +static uint32_t sim_map_key(uint8_t remo_key) { switch (remo_key) { case UI_KEY_UP: return LV_KEY_UP; case UI_KEY_DOWN: return LV_KEY_DOWN; @@ -43,8 +42,7 @@ static uint32_t sim_map_key(uint8_t remo_key) * 函数说明:窗口模式显示缓冲经 I1→ARGB8888 转换,不能直读, * 故读回屏幕像素再按红通道阈值打包 1bpp(布局与 MCU 端一致) */ -int ui_net_sink_get_lcdmem(uint8_t *out, uint32_t out_size) -{ +int ui_net_sink_get_lcdmem(uint8_t *out, uint32_t out_size) { if (out == NULL || out_size < UI_NET_SINK_MONO_SIZE) { return -1; } @@ -58,8 +56,7 @@ int ui_net_sink_get_lcdmem(uint8_t *out, uint32_t out_size) * 限定条件:默认输入组已建立 * 函数说明:映射为 LV_KEY_* 后经 lv_group_send_data 发向焦点对象 */ -void ui_net_sink_inject_key(uint8_t remo_key) -{ +void ui_net_sink_inject_key(uint8_t remo_key) { lv_group_t *g; uint32_t key; diff --git a/simulator/tests/check_content_pages.py b/simulator/tests/check_content_pages.py index b1d72f2e..aa8c5405 100644 --- a/simulator/tests/check_content_pages.py +++ b/simulator/tests/check_content_pages.py @@ -106,7 +106,9 @@ STRIDE = W // 8 # I1:20 字节/行 # 页面结构变更导致漂移时,先确认新值合理再更新此常量(内存回归门禁)。 # 2026-09-14: 4864 → 5520(+656B)= run_headless 新增 ui_standby_init():待机模块 # 常驻屏 s_scr + 容器 s_cont 两个 lv_obj,随菜单基线同移(见 check_menu_rows.py)。 -POOL_AFTER_ROUNDTRIP = 5520 +# 2026-10-11: 5520 → 5448(−72B)= 裁剪 LVGL 控件类(F-2),随菜单基线 5440→5368 同移; +# **一次性残留不变,仍是 80 B**(5448 − 5368),说明泄漏特征未变,只是每对象样式表更小。 +POOL_AFTER_ROUNDTRIP = 5448 def run_sim(seq, out): diff --git a/simulator/tests/check_menu_rows.py b/simulator/tests/check_menu_rows.py index 1655d27d..989e413a 100644 --- a/simulator/tests/check_menu_rows.py +++ b/simulator/tests/check_menu_rows.py @@ -57,7 +57,11 @@ ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H = 0, 44, 66, 107 # 先确认新值合理再更新此常量(内存回归门禁,见 docs/UI设计/UI_自动化测试流程.md) # 2026-09-14: 4784 → 5440(+656B)= run_headless 新增 ui_standby_init():待机模块 # 常驻屏 s_scr + 容器 s_cont 两个 lv_obj(先于池测量创建),属有意结构变更。 -MENU_POOL_BASELINE = 5440 +# 2026-10-11: 5440 → 5368(−72B)= 裁剪 LVGL 控件类(F-2:label/button/buttonmatrix/ +# bar/textarea 全关,simulator/lv_conf.h 与 Lib/lv_conf.h 对齐为同一特性子集)。 +# 像素逐字节未变(11 页黄金参考在**裁剪前**生成、裁剪后全部一致);差值来自 +# lv_theme_apply 不再为已不存在的控件类登记样式条目 → 每对象样式表更小。更优。 +MENU_POOL_BASELINE = 5368 def load(path): diff --git a/test/host/prot_elm/prot_elm_test.c b/test/host/prot_elm/prot_elm_test.c new file mode 100644 index 00000000..36f2de9c --- /dev/null +++ b/test/host/prot_elm/prot_elm_test.c @@ -0,0 +1,275 @@ +/* + * 模块名称:保护元件框架(L3,P1 子集)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/prot_elm.c 的判据核做离线验证。 + * + * 被测对象:真实源文件(#include 原样纳入): + * prot_elm.c + prot_set.c(判据吃定值 RAM) + * phasor.c + sample_ring.c(prot_elm.c 链接依赖;本测试只走 + * prot_elm_eval 纯判据路径,不依赖 run() 适配层) + * + * 覆盖点(docs/算法/继电保护功能复现规划.md §3.3 L3-1/3/4/6、§8 验收): + * TC-PE-001 init:全状态清零、动作位图 0 + * TC-PE-002 过量滞回 + 定时限:1.01× 定值启动、延时到点动作、 + * 返回系数内保持、跌破返回门槛释放(1.2×/0.9× 验收口径的核) + * TC-PE-003 延时 0:启动同拍即动作 + * TC-PE-004 欠量方向(UV):低于定值启动、高于返回门槛释放 + * TC-PE-005 投退=0:不判据、状态复位 + * TC-PE-006 定值闭锁(L5-1 联动):locked → 全元件复位;解锁后恢复 + * TC-PE-007 时基回绕:跨 0xFFFFFFFF 的延时判定仍正确 + * TC-PE-008 动作位图聚合 + * TC-PE-009 守卫:state(NULL)/越界编号、name 越界 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 创建日期:2026-10-11 + */ +#include +#include + +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +#include "prot_set.c" +#include "sample_ring.c" +#include "phasor.c" +#include "prot_elm.c" + +/* 中性测量基线:电流 1.0 A、电压 100 V、3U0 0 V(全部不越限) */ +static void mk_base(float m[PE_COUNT]) { + uint32_t i; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + m[i] = 1.0f; + } + m[PE_OV] = 100.0f; + m[PE_UV] = 100.0f; + m[PE_3U0] = 0.0f; +} + +static void tc_init_clear(void) { + prot_set_init(); + prot_elm_init(); + CHECK(prot_elm_active_mask() == 0u, "TC-PE-001 init: active mask 0"); + { + prot_elm_state_t st; + uint32_t i; + int clean = 1; + + for (i = 0u; i < (uint32_t)PE_COUNT; i++) { + prot_elm_state((prot_elm_id_t)i, &st); + if ((st.picked != 0u) || (st.asserted != 0u) || (st.enabled != 0u)) { + clean = 0; + } + } + CHECK(clean, "TC-PE-001 init: all element states clear"); + } +} + +static void tc_over_hysteresis(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OC1_DLY, PROT_SET_Q10(100.0)); /* 100 ms */ + /* 默认 OC1_CUR=10.0 A,RET_OVER=0.95 → 返回门槛 ≈9.492 */ + + mk_base(m); + m[PE_OC1] = 9.9f; + prot_elm_eval(1000u, m); + prot_elm_state(PE_OC1, &st); + CHECK((st.picked == 0u) && (st.enabled != 0u), + "TC-PE-002 0.99x set: no pickup"); + + m[PE_OC1] = 10.1f; + prot_elm_eval(1001u, m); + prot_elm_state(PE_OC1, &st); + CHECK((st.picked != 0u) && (st.asserted == 0u), + "TC-PE-002 1.01x set: pickup before delay"); + + prot_elm_eval(1100u, m); /* 1100-1001 = 99 ms */ + prot_elm_state(PE_OC1, &st); + CHECK(st.asserted == 0u, "TC-PE-002 not asserted at 99ms"); + + prot_elm_eval(1101u, m); /* 100 ms 整 */ + prot_elm_state(PE_OC1, &st); + CHECK(st.asserted != 0u, "TC-PE-002 asserted at 100ms"); + + m[PE_OC1] = 9.7f; /* 返回门槛(9.492)与动作门槛(10)之间 */ + prot_elm_eval(1200u, m); + prot_elm_state(PE_OC1, &st); + CHECK((st.picked != 0u) && (st.asserted != 0u), + "TC-PE-002 hysteresis: holds between return and pickup threshold"); + + m[PE_OC1] = 9.4f; /* 跌破返回门槛 */ + prot_elm_eval(1300u, m); + prot_elm_state(PE_OC1, &st); + CHECK((st.picked == 0u) && (st.asserted == 0u), + "TC-PE-002 below return threshold: drops out"); +} + +static void tc_zero_delay(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OC3_DLY, PROT_SET_Q10(0.0)); + mk_base(m); + m[PE_OC3] = 2.1f; /* OC3 定值 2.0 */ + prot_elm_eval(5000u, m); + prot_elm_state(PE_OC3, &st); + CHECK((st.picked != 0u) && (st.asserted != 0u), + "TC-PE-003 dly=0: asserted on the same beat as pickup"); +} + +static void tc_under_direction(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_UV_DLY, PROT_SET_Q10(50.0)); + /* 默认 UV_VOL=20.0,RET_UNDER=1.05 → 返回门槛 ≈20.996 */ + + mk_base(m); + m[PE_UV] = 25.0f; + prot_elm_eval(2000u, m); + prot_elm_state(PE_UV, &st); + CHECK(st.picked == 0u, "TC-PE-004 UV above set: no pickup"); + + m[PE_UV] = 19.0f; + prot_elm_eval(2001u, m); + prot_elm_state(PE_UV, &st); + CHECK(st.picked != 0u, "TC-PE-004 UV below set: pickup"); + + prot_elm_eval(2051u, m); /* 50 ms */ + prot_elm_state(PE_UV, &st); + CHECK(st.asserted != 0u, "TC-PE-004 UV asserted after delay"); + + m[PE_UV] = 20.5f; /* 20 与 20.996 之间:保持 */ + prot_elm_eval(2100u, m); + prot_elm_state(PE_UV, &st); + CHECK(st.asserted != 0u, "TC-PE-004 UV holds below return threshold"); + + m[PE_UV] = 21.5f; /* 高于返回门槛:释放 */ + prot_elm_eval(2200u, m); + prot_elm_state(PE_UV, &st); + CHECK((st.picked == 0u) && (st.asserted == 0u), + "TC-PE-004 UV above return threshold: drops out"); +} + +static void tc_disabled(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OC2_EN, 0); + mk_base(m); + m[PE_OC2] = 999.0f; + prot_elm_eval(3000u, m); + prot_elm_state(PE_OC2, &st); + CHECK((st.enabled == 0u) && (st.picked == 0u) && (st.asserted == 0u), + "TC-PE-005 enable=0: no judgement even at 999x"); +} + +static void tc_set_lock(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + mk_base(m); + m[PE_OC1] = 10.1f; + (void)prot_set_set(PS_OC1_DLY, PROT_SET_Q10(0.0)); + prot_elm_eval(4000u, m); + prot_elm_state(PE_OC1, &st); + CHECK(st.asserted != 0u, "TC-PE-006 pre: element operates while settings valid"); + + (void)prot_set_set(PS_OC1_CUR, PROT_SET_Q10(9.0)); /* 改值不封存 */ + CHECK(prot_set_selfcheck() == -1, "TC-PE-006 tamper detected by selfcheck"); + prot_elm_eval(4100u, m); + prot_elm_state(PE_OC1, &st); + CHECK((st.enabled == 0u) && (st.asserted == 0u), + "TC-PE-006 settings lock resets all elements (L5-1 gating)"); + + prot_set_seal(); + prot_elm_eval(4200u, m); + prot_elm_state(PE_OC1, &st); + CHECK((st.enabled != 0u) && (st.asserted != 0u), + "TC-PE-006 unlock restores judgement"); +} + +static void tc_tick_wrap(void) { + float m[PE_COUNT]; + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OC2_DLY, PROT_SET_Q10(100.0)); + mk_base(m); + m[PE_OC2] = 5.1f; /* OC2 定值 5.0 */ + prot_elm_eval(0xFFFFFFF0u, m); /* 启动点贴近回绕 */ + prot_elm_state(PE_OC2, &st); + CHECK(st.picked != 0u, "TC-PE-007 pickup near tick wrap"); + + prot_elm_eval(0x00000010u, m); /* 回绕后 32 ms */ + prot_elm_state(PE_OC2, &st); + CHECK(st.asserted == 0u, "TC-PE-007 not asserted at 32ms after wrap"); + + prot_elm_eval(0x00000060u, m); /* 回绕后 112 ms */ + prot_elm_state(PE_OC2, &st); + CHECK(st.asserted != 0u, "TC-PE-007 asserted at 112ms across wrap"); +} + +static void tc_active_mask(void) { + float m[PE_COUNT]; + + prot_set_init(); + prot_elm_init(); + (void)prot_set_set(PS_OC1_DLY, PROT_SET_Q10(0.0)); + (void)prot_set_set(PS_OV_DLY, PROT_SET_Q10(0.0)); + mk_base(m); + m[PE_OC1] = 10.1f; + m[PE_OV] = 130.0f; /* OV 定值 120 */ + prot_elm_eval(6000u, m); + CHECK(prot_elm_active_mask() == ((1uL << PE_OC1) | (1uL << PE_OV)), + "TC-PE-008 active mask aggregates OC1+OV only"); +} + +static void tc_guards(void) { + prot_elm_state_t st; + + prot_set_init(); + prot_elm_init(); + prot_elm_state(PE_COUNT, &st); + CHECK((st.enabled == 0u) && (st.picked == 0u) && (st.asserted == 0u), + "TC-PE-009 out-of-range id -> zero state"); + prot_elm_state(PE_OC1, NULL); /* 不得崩溃 */ + CHECK(prot_elm_name(PE_COUNT) != NULL, "TC-PE-009 name out-of-range -> fallback"); + CHECK(prot_elm_name(PE_3U0) != NULL, "TC-PE-009 name valid id -> non-NULL"); + prot_elm_eval(7000u, NULL); /* NULL 测量:按 0 处理,不得崩溃 */ + CHECK(1, "TC-PE-009 eval(NULL meas) survives"); +} + +int main(void) { + printf("=== Prot Elm Tests ===\n"); + tc_init_clear(); + tc_over_hysteresis(); + tc_zero_delay(); + tc_under_direction(); + tc_disabled(); + tc_set_lock(); + tc_tick_wrap(); + tc_active_mask(); + tc_guards(); + + printf("\n=== Prot Elm Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/prot_elm/run_unit_test.py b/test/host/prot_elm/run_unit_test.py new file mode 100644 index 00000000..df79edff --- /dev/null +++ b/test/host/prot_elm/run_unit_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""保护元件框架(L3,P1 子集)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么:用本机 MSVC 离线编译 test/host/prot_elm/prot_elm_test.c(#include 纳入 + 生产源文件 prot_elm.c + prot_set.c + phasor.c + sample_ring.c),验证 + 越限滞回 / 定时限 / 欠量方向 / 投退 / 定值闭锁联动 / 时基回绕 / 位图聚合。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/prot_elm/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/算法/继电保护功能复现规划.md §3.3(L3-1/3/4/6)、§8(验收判据)。 +创建日期:2026-10-11 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="Prot Elm Tests", + src=os.path.join(HERE, "prot_elm_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Prot Elm Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/prot_set/prot_set_test.c b/test/host/prot_set/prot_set_test.c new file mode 100644 index 00000000..f2c8cbe4 --- /dev/null +++ b/test/host/prot_set/prot_set_test.c @@ -0,0 +1,156 @@ +/* + * 模块名称:保护定值模型(P1)主机侧单元测试 + * 模块功能:在 PC 上对 App/algo/prot_set.c 做离线验证。 + * + * 被测对象:真实源文件(#include 原样纳入)。纯逻辑,无需桩。 + * + * 覆盖点(docs/算法/继电保护功能复现规划.md §5.2 / §3.4 L5-1): + * TC-PS-001 init:默认值装载、越界位图 0、selfcheck 通过、未闭锁 + * TC-PS-002 set:范围内写入生效;越界拒写且原值不动 + * TC-PS-003 校验和封存语义:set 后未 seal → selfcheck 失败并闭锁; + * seal → 通过并解锁(L5-1 核心闭环) + * TC-PS-004 load_raw:合法整表装载并封存;含越界项 → 整表不装载 + + * bad_mask 指认越界项(定值一致性:宁可全旧不可半新) + * TC-PS-005 Q10 往返精度(±1 LSB) + * TC-PS-006 元数据表自检:min<=max、投退项 0..1、非零通讯地址不重复 + * TC-PS-007 越界编号守卫(get/set) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 创建日期:2026-10-11 + */ +#include +#include + +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +#include "prot_set.c" + +/* TC-PS-001 */ +static void tc_init(void) { + prot_set_init(); + CHECK(prot_set_bad_mask() == 0u, "TC-PS-001 init: no bad items"); + CHECK(prot_set_selfcheck() == 0, "TC-PS-001 init: selfcheck pass"); + CHECK(prot_set_locked() == 0u, "TC-PS-001 init: not locked"); + CHECK(prot_set_get_f(PS_OC1_CUR) > 9.99f && prot_set_get_f(PS_OC1_CUR) < 10.01f, + "TC-PS-001 default OC1 current = 10.0 A"); + CHECK(prot_set_get_ctl(PS_OC3_TRIP) == 0u, + "TC-PS-001 default OC3 = alarm (trip off)"); +} + +/* TC-PS-002 */ +static void tc_set_range(void) { + prot_set_init(); + CHECK(prot_set_set(PS_OC2_CUR, PROT_SET_Q10(7.5)) == 0, + "TC-PS-002 in-range set accepted"); + CHECK(prot_set_get_f(PS_OC2_CUR) > 7.49f && prot_set_get_f(PS_OC2_CUR) < 7.51f, + "TC-PS-002 value readable after set"); + CHECK(prot_set_set(PS_OC2_CUR, PROT_SET_Q10(101.0)) == -1, + "TC-PS-002 over-max set rejected"); + CHECK(prot_set_get_f(PS_OC2_CUR) > 7.49f && prot_set_get_f(PS_OC2_CUR) < 7.51f, + "TC-PS-002 rejected set leaves value unchanged"); + CHECK(prot_set_set(PS_OC1_EN, PROT_SET_Q10(2)) == -1, + "TC-PS-002 ctl item rejects value 2"); +} + +/* TC-PS-003 */ +static void tc_seal_selfcheck(void) { + prot_set_init(); + CHECK(prot_set_set(PS_OC1_CUR, PROT_SET_Q10(8.0)) == 0, "TC-PS-003 set ok"); + CHECK(prot_set_selfcheck() == -1, + "TC-PS-003 set-without-seal => selfcheck FAIL (tamper detect)"); + CHECK(prot_set_locked() == 1u, "TC-PS-003 selfcheck failure latches lock"); + prot_set_seal(); + CHECK(prot_set_selfcheck() == 0, "TC-PS-003 seal => selfcheck pass"); + CHECK(prot_set_locked() == 0u, "TC-PS-003 seal clears lock"); +} + +/* TC-PS-004 */ +static void tc_load_raw(void) { + int32_t tbl[PS_ID_COUNT]; + uint32_t i; + + prot_set_init(); + for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { + tbl[i] = prot_set_get((prot_set_id_t)i); + } + tbl[PS_OL_CUR] = PROT_SET_Q10(1.5); + CHECK(prot_set_load_raw(tbl) == 0, "TC-PS-004 valid table loads"); + CHECK(prot_set_get_f(PS_OL_CUR) > 1.49f && prot_set_get_f(PS_OL_CUR) < 1.51f, + "TC-PS-004 loaded value visible"); + CHECK(prot_set_selfcheck() == 0, "TC-PS-004 load_raw auto-seals"); + + tbl[PS_OC3_DLY] = PROT_SET_Q10(60001.0); /* 越上限 */ + tbl[PS_OL_CUR] = PROT_SET_Q10(2.5); /* 合法改动 */ + CHECK(prot_set_load_raw(tbl) == -1, "TC-PS-004 bad table rejected"); + CHECK(prot_set_bad_mask() == (1uL << PS_OC3_DLY), + "TC-PS-004 bad_mask points at the offending item"); + CHECK(prot_set_get_f(PS_OL_CUR) > 1.49f && prot_set_get_f(PS_OL_CUR) < 1.51f, + "TC-PS-004 rejected load keeps OLD table (all-or-nothing)"); + CHECK(prot_set_load_raw(0) == -1, "TC-PS-004 NULL table rejected"); +} + +/* TC-PS-005 */ +static void tc_q10_roundtrip(void) { + float v = 12.345f; + int32_t q; + + prot_set_init(); + q = PROT_SET_Q10(v); + CHECK(prot_set_set(PS_OC1_CUR, q) == 0, "TC-PS-005 set ok"); + { + float back = prot_set_get_f(PS_OC1_CUR); + float err = back - v; + if (err < 0.0f) { err = -err; } + CHECK(err < 0.001f, "TC-PS-005 Q10 roundtrip error < 1 LSB"); + } +} + +/* TC-PS-006 */ +static void tc_meta_sanity(void) { + uint32_t i, j; + int ok_range = 1, ok_ctl = 1, ok_addr = 1; + + for (i = 0u; i < (uint32_t)PS_ID_COUNT; i++) { + const prot_set_meta_t *m = &g_prot_set_meta[i]; + if (m->min > m->max) { ok_range = 0; } + if ((m->def < m->min) || (m->def > m->max)) { ok_range = 0; } + if ((m->is_ctl != 0u) && + ((m->min != 0) || (m->max != PROT_SET_Q10(1)))) { ok_ctl = 0; } + if (m->comm_addr == 0u) { continue; } + for (j = i + 1u; j < (uint32_t)PS_ID_COUNT; j++) { + if (g_prot_set_meta[j].comm_addr == m->comm_addr) { ok_addr = 0; } + } + } + CHECK(ok_range, "TC-PS-006 meta: min<=def<=max for all items"); + CHECK(ok_ctl, "TC-PS-006 meta: ctl items are 0..1"); + CHECK(ok_addr, "TC-PS-006 meta: comm addresses unique"); + CHECK((uint32_t)PS_ID_COUNT <= 32u, "TC-PS-006 item count fits bad_mask bitmap"); +} + +/* TC-PS-007 */ +static void tc_id_guard(void) { + prot_set_init(); + CHECK(prot_set_get(PS_ID_COUNT) == 0, "TC-PS-007 get out-of-range id -> 0"); + CHECK(prot_set_set(PS_ID_COUNT, 0) == -1, "TC-PS-007 set out-of-range id -> -1"); +} + +int main(void) { + printf("=== Prot Set Tests ===\n"); + tc_init(); + tc_set_range(); + tc_seal_selfcheck(); + tc_load_raw(); + tc_q10_roundtrip(); + tc_meta_sanity(); + tc_id_guard(); + + printf("\n=== Prot Set Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/prot_set/run_unit_test.py b/test/host/prot_set/run_unit_test.py new file mode 100644 index 00000000..3778894c --- /dev/null +++ b/test/host/prot_set/run_unit_test.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""保护定值模型(P1)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么:用本机 MSVC 离线编译 test/host/prot_set/prot_set_test.c(#include 纳入 + 生产源文件 App/algo/prot_set.c),验证 Q10 定点模型 / 范围校验 / + 整表装载 all-or-nothing / 校验和封存-自检-闭锁(L5-1)闭环。 + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/host/prot_set/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/算法/继电保护功能复现规划.md §5(定值参数模型)、§3.4(L5-1)。 +创建日期:2026-10-11 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +BUILD = TestBuild( + name="Prot Set Tests", + src=os.path.join(HERE, "prot_set_test.c"), + include_dirs=[ + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Prot Set Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) From 3b9010718122f2b082bfeb5761c12b22e8f2f4ad Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 08:59:03 +0800 Subject: [PATCH 20/47] =?UTF-8?q?chore:=20.mnemon/=20=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=AA=E7=95=99=E6=9C=AC=E6=9C=BA=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E8=BF=9B=E7=89=88=E6=9C=AC=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加入 .gitignore:documents/index.json、runtime/MEMORY.md、USER.md、 memories.json 是按机器/按人累积的本地私有状态 - 入库的代价:每次记忆写入都产生提交抖动,且可能带出本机绝对路径与 个人偏好 - 上一笔只含 .mnemon/ 四个空骨架文件的提交(未推送)已撤销,历史里 不含 .mnemon/;磁盘上的文件保持原样,只是不再被跟踪 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 8896ca94..4d0dd662 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,12 @@ docs/images/*.png # Serial assistant runtime state (daemon log/meta, local runtime artifacts) .dsh/skills/serial-assistant/scripts/state/ +# 记忆工具(Mnemon)本地状态:项目文档索引 documents/ + 运行时记忆 +# runtime/MEMORY.md、USER.md、memories.json。**只留本机、不进版本库** +# (2026-10-11 定):记忆是按机器/按人累积的私有状态,入库会让每次记忆 +# 写入都变成提交抖动,还可能带出本机绝对路径与个人偏好。 +.mnemon/ + # CI 运行时产物(.gitea/workflows/ci.yml 的 S3 步骤在 runner 工作区落盘的日志; # 零依赖存档方案——落在工作区目录而非上传 artifact,故必须忽略以免误提交) ci-artifacts/ From eddbedeee84a8826f9189b1940e9ec55d9e74ac9 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 11 Oct 2026 10:44:50 +0800 Subject: [PATCH 21/47] =?UTF-8?q?docs(AGENTS):=20=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E2=80=94=E2=80=94=E8=A1=A5=E5=AE=8F=E8=A7=82?= =?UTF-8?q?=E6=A6=82=E5=86=B5=E4=B8=8E=E8=BE=B9=E7=95=8C=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E7=98=A6=E8=BA=AB51KB=E2=86=9225.8KB=EF=BC=8C=E5=AD=90?= =?UTF-8?q?=E5=9F=9F=E7=9F=A5=E8=AF=86=E5=85=A5skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md 51,067→25,828 B(−50%):审计→迁移→删除,逐节 diff 零信息损失 - 按调研(agents.md 官方/aihero/airflow)补「项目概况」:一句话定位、 数据主线、顶层目录地图、分层边界;平台约束 17 条分 5 组 - 新增「边界(Ask first / Never)」:git commit/push 须明确指示首次入库 - 串口助手、LVGL 模拟器两节移入 serial-assistant / 新建 lvgl-simulator skill (开关语义按 main.c 实锤);LVGL 两条实证坑移入 ui-verify skill - 9 条入口独有细节迁移至对应设计文档(存储/看门狗/启动顺序/文档规范/ pre-commit 使用说明/keil-build skill);@test 细节指向自动化测试流程 - 新增 docs/AGENTS编写指南.md:六原则/模板/反模式/维护纪律 + 本仓案例 - 修正 3 处对 AGENTS 串口节的过时引用;docs/README.md 登记新文档 --- .dsh/skills/keil-build/SKILL.md | 4 +- .dsh/skills/lvgl-simulator/SKILL.md | 52 +++++ .dsh/skills/new-machine-setup/SKILL.md | 2 +- .dsh/skills/ui-verify/SKILL.md | 7 +- AGENTS.md | 242 +++++++++++--------- docs/AGENTS编写指南.md | 109 +++++++++ docs/README.md | 3 + docs/代码规范/pre-commit使用说明.md | 8 +- docs/子系统设计/控制台单写者日志设计说明.md | 2 +- docs/子系统设计/看门狗设计说明.md | 15 ++ docs/文档规范.md | 6 +- docs/架构设计/STM32F4-Base存储架构说明.md | 4 +- docs/架构设计/项目启动顺序管理.md | 24 +- docs/测试说明/串口助手使用指南.md | 2 +- docs/测试说明/串口助手设计说明.md | 2 +- 15 files changed, 364 insertions(+), 118 deletions(-) create mode 100644 .dsh/skills/lvgl-simulator/SKILL.md create mode 100644 docs/AGENTS编写指南.md diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index 048386a7..a84e07b2 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -35,9 +35,11 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 硬件闭环只会把编译错误伪装成运行问题。 - **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"。 - **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程 - XML 归一化成本机风格(`` → `` 等)并把 `PackID` 降到本机 DFP 版本。 + XML 归一化成本机风格(`` → ``、XML 声明加 `standalone="no"`)并把 + `PackID` 降到**本机装的 DFP 版本**(属降级——提交后会把别人的 Pack 版本一起改掉)。 判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** → `git checkout -- ` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。 + 同目录的 `*.uvoptx` / `*.uvguix.*` 是 IDE 个人设置,已在 `.gitignore` 里,不会露出来。 - 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。 - **exit 2 先分清"真没设"还是"会话没继承"**(2026-10-10 实测):agent 的 shell 由 常驻服务派生,服务启动**之后**设置的用户级环境变量不会自动进来。查 diff --git a/.dsh/skills/lvgl-simulator/SKILL.md b/.dsh/skills/lvgl-simulator/SKILL.md new file mode 100644 index 00000000..08dfd725 --- /dev/null +++ b/.dsh/skills/lvgl-simulator/SKILL.md @@ -0,0 +1,52 @@ +--- +name: lvgl-simulator +description: LVGL PC 模拟器构建/运行/自检入口:build_sim.bat 自动探测本机 VS、lvgl_sim.exe 无头截图与自检开关(--screenshot/--keys/--standby/--cfgtest/--logtest/--idle)、三套回归脚本、两份 lv_conf.h 同步门禁、构建报错速查(SDL2/lv_conf 找不到、全黑、键盘无响应)。 +whenToUse: 需要构建或运行模拟器、无头截图出图、跑模拟器自检或 UI 回归、修改 simulator/ 目录代码或 simulator/lv_conf.h、排查模拟器构建/运行异常时。UI 改动后的完整回归走 ui-verify skill。 +--- + +# LVGL PC 模拟器(simulator/) + +PC 上编译运行与 MCU **同一份** UI 源码(CMake + MSVC + SDL2,x64,160×160 I1 单色),免烧录。 +**目录架构 / 完整坑与 FAQ → `simulator/README.md`;首次部署(SDL2 SDK)→ `docs/UI设计/LVGL模拟器部署与使用说明.md`。本 skill 只放操作入口。** + +## 构建与运行(仓库根执行) + +```powershell +& "simulator\build_sim.bat" # 增量构建;vswhere 自动探测本机 VS(16/17/18),勿写死生成器 +& "simulator\build_sim.bat" -Clean # 全量重建(遇到奇怪问题先加它) +.\simulator\build\Debug\lvgl_sim.exe # 窗口模式:↑↓←→ / Enter / Esc(纯键盘,无鼠标) +``` + +⚠ 窗口模式默认 **60 s 空闲自动退出**(防挂后台),`--idle <秒>` 覆盖。 + +## 无头自检(退出码 0=PASS / 非 0=FAIL) + +| 命令(在 `simulator\build\Debug\` 下或用全路径) | 用途 | +|---|---| +| `lvgl_sim.exe --screenshot shot.bin` | 离屏渲染首帧 → `simulator/i1_to_png.py` 转 PNG 目检 | +| `lvgl_sim.exe --keys "ud" shot.bin` | 注入键序后截图:`u`=↑ `d`=↓ `l`=← `r`=→ `e`=Enter `s`=Esc(`main.c` 实锤) | +| `lvgl_sim.exe --standby shot.bin` | 待机页截图 | +| `lvgl_sim.exe --cfgtest` | 配置引擎闭环:加载→轮询→外部改文件重载→外科式保存回验(在当前 CWD 产生 `sim_config.ini`) | +| `lvgl_sim.exe --logtest` | syslog 后端闭环(定长记录/时间戳/轮转) | + +## UI 改动回归 → 用 `ui-verify` skill(勿在此零散手跑) + +5 步门禁 = 构建 → `check_menu_rows.py`(菜单 8 状态像素 + 池基线)→ `check_content_pages.py`(文本断言 + 11 页黄金参考 + 往返池)→ `@build` → `coding-standard`。 +RemoDispBus 推流回归单独跑:`.venv\Scripts\python.exe simulator/tests/check_remo_sink.py`。 +黄金参考 `tests/ref/` 是**本地生成物不入库**:换机后先 `check_content_pages.py --update` 生成,再 `regen_ref_png.py` 出目检 PNG。 + +## 两条铁律 + +- **改 `simulator/lv_conf.h` 触发 `lv-conf-sync` 门禁**:它与 MCU 端 `Lib/lv_conf.h` 必须保持同一特性子集(按**生效值**比对,未定义取 `lv_conf_internal.h` 兜底);白名单与四类判定 → `docs/代码规范/pre-commit使用说明.md` §2.15 / §5.5 +- **模拟器代码与固件同标准受检**(`cppcheck` / `coding-standard` / 乱码 / 改写型钩子 / `doc-links` 全覆盖 `simulator/`);**改动触及 `sim/` 任何后端,必须同步在 `docs/测试说明/模拟器差距清单.md` 登记一行**——模拟器跑过 ≠ 真机跑过 + +## 构建/运行报错速查(详解 simulator/README.md §4) + +| 现象 | 根因 | +|---|---| +| 找不到 `SDL2/SDL.h` | LVGL SDL 驱动写死该路径,头文件须有 `include/SDL2/` 副本(仓库已内置) | +| 找不到 `lv_conf.h` | `LV_CONF_DIR`/`SDL2_INCLUDE_DIR` 必须在 `add_subdirectory(lvgl)` **之前**定义 | +| 窗口全黑 | mono 主题未生效 / 缺 `lv_refr_now(NULL)` / 重复喂 tick(SDL 驱动内部已 `lv_tick_set_cb`) | +| 画面正常但按键全无效 | 主循环**不得**调 `SDL_PollEvent`——LVGL SDL 驱动内部 5ms 定时器统一分发,先轮询会抢光事件 | +| 中文显示空白 | 子集字体缺字 → **`font-regen` skill** 重生成 `ui_font_cn_12` | +| 新加 UI `.c` 一端编不过 | 双端注册(uvprojx + CMakeLists)→ **`new-source-file` skill** | diff --git a/.dsh/skills/new-machine-setup/SKILL.md b/.dsh/skills/new-machine-setup/SKILL.md index b5b73ed0..72f28007 100644 --- a/.dsh/skills/new-machine-setup/SKILL.md +++ b/.dsh/skills/new-machine-setup/SKILL.md @@ -46,7 +46,7 @@ whenToUse: 换新机器/新克隆要把环境搭起来、构建或烧录报"环 (`env_check.py` 对这种情况报 WARN 并给注入命令。) - 换机后 COM 口几乎必变:只改 `.serialtest/config.json` 的 `portName`, 然后 `serial_tool.py restart`(daemon 启动时缓存配置)。 -- 串口"拒绝访问"先查残留旧 daemon(AGENTS.md 串口助手节),不是驱动问题。 +- 串口"拒绝访问"先查残留旧 daemon(`serial-assistant` skill「陷阱」节),不是驱动问题。 ## 其它项目怎么套 diff --git a/.dsh/skills/ui-verify/SKILL.md b/.dsh/skills/ui-verify/SKILL.md index 370e4d2f..10c1b735 100644 --- a/.dsh/skills/ui-verify/SKILL.md +++ b/.dsh/skills/ui-verify/SKILL.md @@ -1,13 +1,18 @@ --- name: ui-verify description: UI 改动后的回归验证:5 步门禁(模拟器构建、菜单像素回归、内容页黄金参考、Keil 构建、代码规范)。 -whenToUse: Drivers/BSP/UI、字体 ui_font_cn_12.c / ui_cn_chars.txt、菜单/内容页被修改之后,或用户要求"验证 UI / 跑 UI 回归 / UI 改动检查"时。 +whenToUse: Drivers/BSP/UI、字体 ui_font_cn_12.c / ui_cn_chars.txt、菜单/内容页被修改的前后(写码前先读「两条实证坑」节),或用户要求"验证 UI / 跑 UI 回归 / UI 改动检查"时。 --- # UI 回归验证 UI 相关改动完成后必须跑完 5 个门禁再提交。完整说明见 `docs/UI设计/UI_自动化测试流程.md`。 +## 两条实证坑(写码前先读,模拟器照不出来) + +- **I1 绘制缓冲含 8B palette 头**:布局 `[palette 8B][pixel data]`,但 LVGL 容量校验只看 `stride×h` → 只给 `stride×h` 会把末 8 字节写到缓冲外。`ui_port.c` 按 `stride×h + palette` 供给并在 flush 时 `+palette` → `docs/UI设计/UI_设计架构.md` §1.4 第 8 条 +- **MCU 端必须显式应用 mono 主题**:`lv_theme_mono_init(disp, true, ...)` + `lv_display_set_theme()` + 对活动屏幕再 `lv_theme_apply()`,否则活动屏仍是默认浅灰而内容恒用白色 → **白底白字整屏纯白**(远端"纯白屏"根因)→ `docs/UI设计/UI_设计架构.md` §1.4 第 9 条 + ## 步骤(仓库根目录执行,python 一律用 .venv) ```powershell diff --git a/AGENTS.md b/AGENTS.md index c48cedf5..849086e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,66 +1,53 @@ # STM32F4-Base -STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v5 (ARMCC, C99) 工具链**;FreeRTOS V10.3.1 / CMSIS-RTOS2;192KB SRAM / 1MB Flash。 +**工业级嵌入式 DTU 平台固件**:STM32F407ZGTx @168MHz,集成以太网(CH395F)、SPI NAND 存储(GD5F)、电气量采集(TPAFE5160 + 片内 ADC)、RS-485、FTP 文件服务,自带远程显示(UI 推流)、墙钟对时、看门狗自恢复、运行/自检记录——既是可独立部署的现场设备,也是**可复用的平台底座**(保护测控等后续产品在此底座上补齐)。产品基线 → `docs/产品需求/产品需求设计文档.md` §2.1。 + +**仅 Keil MDK-ARM v5 (ARMCC, C99) 工具链**;FreeRTOS V10.3.1 / CMSIS-RTOS2;192KB SRAM / 1MB Flash。 **任何代码修改后必须先 `@build` 验证(0 错误 0 警告)再提交;硬件闭环用 `@test`。** -## 铁律:源码编辑与编码 +> 本文件 = agent 入口:宏观概况 + 每次都在场的铁律在这里,细节在 `docs/` 与 skills(文末索引)。同一事实只在一处详述(`docs/文档规范.md` 同此要求)。 -- 改代码**只用 Edit 工具**(精确子串替换);批量替换用 Edit 的 replaceAll。**绝不用 `Get-Content`/`Set-Content`/`cmd` 重写含中文的文件**——整文件重写会按系统默认编码重解码 → `U+FFFD` 乱码且不可逆 -- **字符串字面量必须纯 ASCII**(2026-09-15 实测):本机 ARMCC 对**无 BOM** 源文件按 CP936 解读,字符串内含**奇数个**汉字时末尾高字节吞掉结束引号 → 编译错(偶数个汉字侥幸通过);中文只写注释(注释不参与解析)。带 BOM 的文件(`Drivers/BSP/UI/*`、`App/task/ui_task.c`、`App/cfg/cfg_file.c`、`Lib/lv_conf.h`)经 BOM 按 UTF-8 解码,不受此条限制 -- 确需脚本级重写:工程源码为 UTF-8(多数文件无 BOM,UI 系带 BOM),按原样同编码回写、保持 BOM 状态;拿不准就别碰 -- 发现乱码:先统计 `EF BF BD` 字节数确认真损坏(勿信终端/Read 渲染);**不要急著 `git checkout`**(会丢弃全部未提交工作)——先用 Edit 只修坏行 -- **Edit 工具会剥掉文件原有的 UTF-8 BOM**(2026-09-18 实测):带 BOM 文件(如 `App/cfg/cfg_file.c`)被 Edit 后 BOM 静默消失 → ARMCC 改按 CP936 解码 → 中文字符串字面量报 `#870-D: invalid multibyte character sequence`(**只有重编译该文件才暴露,历史"0 警告"不代表安全**)。改完带中文字面量的文件须核对 BOM 并按原样补回(只在文件头前缀 3 字节 `EF BB BF`,勿整文件重写) -- **行尾策略已写死进 `.gitattributes`(2026-10-09)**:`* text=auto eol=lf`(仓库 blob 与工作区统一 LF)+ `*.bat`/`*.cmd` 强制 CRLF(cmd.exe 对 LF-only 的 goto/标签不可靠)+ PNG/TTF/DLL/LIB 等显式 `binary` + `*.jpg` 走 LFS 且 `-text`(该条必须排在 `*` 之后才能覆盖)。**不要再依赖 `core.autocrlf`,也不要把工作区文件"改回 CRLF"**——CubeMX 重生成写的就是 LF;此前每次重生成后 40+ 个第三方文件在 `git status` 里显示"已修改"(内容归一化后一致),根因就是行尾由每台机器各自的 autocrlf 决定。第三方/生成文件的"文件末尾空行"已一次性对齐工具输出(`53bd9af`),**勿再用改写型钩子去修剪**(已在 `trailing-whitespace`/`end-of-file-fixer` 豁免) +## 项目概况 -## 构建与验证 +**现状一句话**:平台能力(网络 / 存储 / 时间 / UI / 日志 / 看门狗 / FTP / 配置)**已实现并台架验收**;**ADC / RS-485 这两个 DTU 核心采集职责尚未接线到业务消费者**——当前最大功能缺口,接线节奏受 E-59 门禁约束(见「平台约束」)。 -- `@build` = **`keil-build` skill**:`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`(仓库根目录执行;纯标准库,任意 python3 亦可)。退出码 0 = 0 错误 0 警告(PASS)、1 = 构建失败(打印报错行)、2 = UV4 未找到/无效、3 = 用法错误。UV4 解析链 `--uv4` > `.serialtest/config.json` 的 `uv4` > `$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但无效 = 立即报错、不静默回退**);日志 `MDK-ARM/build_log.txt`。⚠ **原 `MDK-ARM/build.bat` 已于 2026-10-10 删除**(判定口径同款,但无超时保护、UV4 只认 `DTU_KEIL_UV4`、失败时 dump 整份日志);`test/run_all_suites.py` 已改调本 skill。换机必设 `DTU_KEIL_UV4`,见 `docs/环境与部署/本地工具路径环境变量方案.md` -- `@test` / `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` 一键闭环(2026-09-20 起为纯 Python 标准库实现,替代 PowerShell 版;`& '.venv\Scripts\python.exe' .dsh/skills/serial-boot-test/scripts/run_boot_test.py`):开串口+清日志 → 烧录(**`stlink-flash` skill**:`stlink_flash.py flash` = `-P -Rst -Run`;`flash_cmd` 默认取 `.serialtest/config.json`,本仓库已指向本 skill)→ **固件身份戳**(从刚烧进去的 hex 里抠 `fw build: