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。 + --- ## 变更记录