From 930215f81ad47c429468440cb43a7e37646b63bf Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 2 Oct 2026 20:48:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DIWDG=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=9C=AA=E9=85=8D=E7=BD=AE=E4=B8=8Edhara=20resume=E5=9B=9E?= =?UTF-8?q?=E6=BB=9A=EF=BC=9A=E7=9C=8B=E9=97=A8=E7=8B=97=E8=AF=AF=E5=A4=8D?= =?UTF-8?q?=E4=BD=8D=E4=B8=8E=E5=A4=8D=E4=BD=8D=E5=90=8E=E5=86=99=E5=85=A5?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E4=B8=A2=E5=A4=B1=EF=BC=8C=E5=8F=B0=E6=9E=B6?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E8=87=AA=E6=A3=80PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cubemx_survival_baseline.json | 2 +- App/app/app_main.c | 8 + App/cfg/cfg_file_store_mcu.c | 32 +- App/fs/vol_lock.c | 62 +++ App/fs/vol_lock.h | 65 +++ App/log/console_log.c | 206 ++++++++- App/log/console_log.h | 4 +- App/log/sys_log.h | 15 + App/log/sys_log_mcu.c | 103 ++++- App/sync/time_sync.c | 66 ++- App/task/default_task.c | 26 +- App/task/net_task.c | 23 +- App/task/ui_task.c | 18 +- App/util/task_watchdog.c | 462 ++++++++++++++++++++- App/util/task_watchdog.h | 123 +++++- Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c | 20 + Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h | 30 ++ Drivers/BSP/GD5F2GQ5UE/nand_ftl.c | 13 + Drivers/BSP/NET/lftpd/lftpd.c | 19 +- Drivers/BSP/NET/lftpd/lftpd.h | 10 + Drivers/BSP/NET/lftpd/lftpd_inet.c | 24 ++ Drivers/BSP/NET/lftpd/lftpd_io.c | 36 +- Drivers/BSP/NET/lftpd/private/lftpd_inet.h | 9 + Lib/dhara/journal.c | 33 +- MDK-ARM/STM32F407-Demo.uvprojx | 7 +- MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct | 5 +- Src/freertos.c | 22 +- Src/main.c | 19 +- docs/内存管理/RAM使用分析与优化方案.md | 10 +- docs/子系统设计/看门狗设计说明.md | 11 + docs/架构设计/STM32F4-Base存储架构说明.md | 28 ++ docs/测试说明/实机测试规划.md | 416 ++++++++++++++++++- test/host/run_boot_test.py | 42 +- 33 files changed, 1875 insertions(+), 94 deletions(-) create mode 100644 App/fs/vol_lock.c create mode 100644 App/fs/vol_lock.h diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index f41d1d0f..4044ad67 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -10,7 +10,7 @@ "assert-not-deadloop": "d4a00718ab2bd071", "assert-impl": "b7fd1fadb4522edf", "stack-overflow-hook-impl": "b7fd1fadb4522edf", - "watchdog-module": "e8fb07637a214d03", + "watchdog-module": "c2580a87fa1fb72c", "reset-cause-latch": "52fc71acbb0f1aed", "iwdg-hal-driver": "4b902875a84554e9", "hal-iwdg-enabled": "c8c9b916b05464b9", diff --git a/App/app/app_main.c b/App/app/app_main.c index 53f0e5e3..02233e4c 100644 --- a/App/app/app_main.c +++ b/App/app/app_main.c @@ -23,6 +23,7 @@ #include "gd5f2gq5ue.h" #include "tpafe5160.h" #include "sys_log.h" +#include "task_watchdog.h" /* task_wd_seg_report():round 8 复位不丢"进段打点"上报 */ #define DBG_TAG "[APP]" #include "dbg_log.h" @@ -120,6 +121,13 @@ int app_main_init(void) { } } + /* 0b) round 8 取证:上报上一轮 defaultTask 主循环的**最后进段**。 + * 轨迹槽在 .noinit(scatter UNINIT_CCM),IWDG/引脚复位不清 SRAM → + * 误复位后开机第一时间可见 `PREVRUN seg=N`,直接指向主循环阻塞点 + * (round 7 结论:判定分支未执行 = poller 自身停摆,见 实机测试规划.md §0.5.4)。 + * 裸 UART 直发,且一次复位只报一次(内部清 magic)。 */ + task_wd_seg_report(); + /* * 1) 时间子系统初始化(尽早完成,后续所有日志带时间戳) * sd2506_init() 从 RTC 读取初始时间 diff --git a/App/cfg/cfg_file_store_mcu.c b/App/cfg/cfg_file_store_mcu.c index 493a7c7a..ef411776 100644 --- a/App/cfg/cfg_file_store_mcu.c +++ b/App/cfg/cfg_file_store_mcu.c @@ -13,6 +13,11 @@ * v1.2 2026-09-22 王建锋 原子写修复:rename 前先 unlink 已存在目标——本 FatFs * 移植 f_rename 不覆盖既有目标(FR_EXIST),旧实现一切 * "目标存在的覆盖写"必然失败(E-01 台架验证暴露) + * v1.3 2026-10-02 王建锋 四个后端函数纳入卷锁 try 语义(vol_lock_wait(0)): + * 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 → + * dhara journal 竞争实证);busy 返回 -1 由调用方 + * 按既有失败语义处理(poll 保留 RAM/保存待重试), + * 本任务 = 看门狗喂狗点,绝不阻塞等锁 */ /* 头文件包含区 */ @@ -23,6 +28,7 @@ #include "cfg_file_store.h" #include "ff.h" +#include "vol_lock.h" /* 静态变量定义区 */ @@ -49,16 +55,22 @@ int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nre if (p_buf == NULL || p_nread == NULL) { return -1; } + if (vol_lock_wait(0u) == 0u) { + return -1; /* 卷被占用:本轮跳过(调用方按读失败语义处理) */ + } fr = f_open(&f, p_path, FA_READ); if (fr != FR_OK) { + vol_lock_release(); return -1; } fr = f_read(&f, p_buf, (UINT)cap, &br); (void)f_close(&f); if (fr != FR_OK) { + vol_lock_release(); return -1; } *p_nread = (size_t)br; + vol_lock_release(); return 0; } @@ -78,14 +90,19 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) if (p_buf == NULL) { return -1; } + if (vol_lock_wait(0u) == 0u) { + return -1; /* 卷被占用:本轮跳过(保存请求由 2s 轮询重试) */ + } fr = f_open(&f, s_path_tmp, FA_WRITE | FA_CREATE_ALWAYS); if (fr != FR_OK) { + vol_lock_release(); return -1; } fr = f_write(&f, (const void *)p_buf, (UINT)len, &bw); (void)f_close(&f); if (fr != FR_OK || bw != (UINT)len) { (void)f_unlink(s_path_tmp); + vol_lock_release(); return -1; } /* 本 FatFs 移植的 f_rename 对已存在目标返回 FR_EXIST、不覆盖(ff.c @@ -99,8 +116,10 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) fr = f_rename(s_path_tmp, p_path); if (fr != FR_OK) { (void)f_unlink(s_path_tmp); + vol_lock_release(); return -1; } + vol_lock_release(); return 0; } @@ -120,12 +139,18 @@ int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st) { if (p_st == NULL) { return -1; } + if (vol_lock_wait(0u) == 0u) { + return -1; /* 卷被占用:调用方按"文件缺失/读失败"语义处理 + (poll = 保留 RAM 配置,安全) */ + } fr = f_stat(p_path, &fno); if (fr != FR_OK) { + vol_lock_release(); return -1; } p_st->size = (uint32_t)fno.fsize; p_st->mtime = (uint32_t)(((uint32_t)fno.fdate << 16) | (uint32_t)fno.ftime); + vol_lock_release(); return 0; } @@ -136,9 +161,14 @@ int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st) { * 限定条件:无 */ int cfg_store_remove(const char *p_path) { - if (f_unlink(p_path) != FR_OK) { + if (vol_lock_wait(0u) == 0u) { return -1; } + if (f_unlink(p_path) != FR_OK) { + vol_lock_release(); + return -1; + } + vol_lock_release(); return 0; } diff --git a/App/fs/vol_lock.c b/App/fs/vol_lock.c new file mode 100644 index 00000000..4c990ae2 --- /dev/null +++ b/App/fs/vol_lock.c @@ -0,0 +1,62 @@ +/* + * 模块名称:Volume Lock(卷锁)— FreeRTOS 实现 + * 模块功能:osMutex 串行化 NAND FatFs 卷访问(设计/使用约定见 vol_lock.h) + * 适用平台:STM32F407ZGTx + FreeRTOS + * 作者:王建锋 + * 创建日期:2026-10-02 + * 修改记录: + * v1.0 2026-10-02 王建锋 创建 + */ + +/* 头文件包含区 */ +#include "vol_lock.h" + +#include "cmsis_os.h" + +/* 静态变量定义区 */ +static osMutexId_t s_lock; /* 卷串行化互斥锁(vol_lock_init 创建) */ + +/* 函数定义区 */ + +/* + * 函数功能:创建卷串行化互斥锁(幂等,重复调用安全) + * 入口参数:无 void + * 返回值:0 - 成功(已创建或已存在);-1 - osMutexNew 失败 int + * 限定条件:可在内核启动后任意时刻调用;本实现不检查是否已启动调度器 + */ +int vol_lock_init(void) { + if (s_lock != NULL) { + return 0; /* 幂等 */ + } + s_lock = osMutexNew(NULL); + return (s_lock != NULL) ? 0 : -1; +} + +/* + * 函数功能:获取卷锁(首次调用自动 vol_lock_init) + * 入口参数:timeout_ms - 等待上限(毫秒);0 = try 语义(不阻塞) uint32_t + * 返回值:1 - 已持锁;0 - 超时/未取得,或锁创建失败(fail-open) int + * 限定条件:返回 0 时调用方**不得** vol_lock_release;fail-open 是为保证 + * 看门狗喂狗点(defaultTask)绝不能因等锁而阻塞 + */ +int vol_lock_wait(uint32_t timeout_ms) { + if (s_lock == NULL) { + (void)vol_lock_init(); + if (s_lock == NULL) { + return 1; /* 锁创建失败:fail-open(不阻塞,保持启动段可用) */ + } + } + return (osMutexWait(s_lock, timeout_ms) == osOK) ? 1 : 0; +} + +/* + * 函数功能:释放卷锁 + * 入口参数:无 void + * 返回值:无 void + * 限定条件:仅在 vol_lock_wait() 返回 1 后调用;锁未创建时静默返回 + */ +void vol_lock_release(void) { + if (s_lock != NULL) { + (void)osMutexRelease(s_lock); + } +} diff --git a/App/fs/vol_lock.h b/App/fs/vol_lock.h new file mode 100644 index 00000000..c2502a61 --- /dev/null +++ b/App/fs/vol_lock.h @@ -0,0 +1,65 @@ +/* + * 模块名称:Volume Lock(卷锁) + * 模块功能:NAND FatFs 卷的跨任务串行化锁——FatFs 卷状态(FS 窗口缓冲/FAT + * 缓存)与 FTL 核心状态(单页 RAM 缓存 + dhara journal)均非线程安全; + * defaultTask(syslog 落盘/配置引擎/存储 bring-up)与 ftpTask(lftpd + * 文件操作)对卷的访问必须在本锁下串行执行 + * 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2) + * 作者:王建锋 + * 创建日期:2026-10-02 + * 修改记录: + * v1.0 2026-10-02 王建锋 创建(2026-10-02 台架实证:defaultTask 与 + * ftpTask 并发 FTL 写 → dhara journal 竞争 → + * 长耗时恢复旋转饥饿 defaultTask 主循环(喂狗点) + * → IWDG 误复位;单写者卷属主假设在多任务 FatFs + * 调用下不成立,须显式串行化) + * + * 使用约定(铁律): + * - 锁跨越"一次完整文件操作序列"(open...close / opendir...closedir / + * 单发 stat/unlink/原子写),不得只锁单个 f_read/f_write; + * - defaultTask 调用方一律用非阻塞 try(vol_lock_wait(0)):defaultTask 是 + * 看门狗喂狗点(task_wd_poll),阻塞等锁 = 主循环停摆 = 停喂 → IWDG 误复位; + * try 失败 = 本轮跳过(syslog 条目滞留队列/配置保存待下轮重试); + * - ftpTask 调用方用 100ms 分片阻塞等待 + 每片心跳(lftpd_task_wd_beat), + * 防长等待导致 FTP 心跳位缺席误复位; + * - 锁在内核启动后由 defaultTask 存储 bring-up 创建(vol_lock_init 幂等)。 + */ + +#ifndef VOL_LOCK_H +#define VOL_LOCK_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * 函数功能:创建卷锁(幂等) + * 入口参数:无 + * 返回值:0 - 成功/已创建;-1 - 创建失败 int + * 限定条件:内核启动后调用(defaultTask 存储 bring-up 首行) + */ +int vol_lock_init(void); + +/* + * 函数功能:获取卷锁 + * 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t + * 返回值:1 - 已获取;0 - 超时未获取(卷被占用) int + * 限定条件:见文件头使用约定(defaultTask 必须 timeout_ms=0) + */ +int vol_lock_wait(uint32_t timeout_ms); + +/* + * 函数功能:释放卷锁(获取方任务释放) + * 入口参数:无 + * 返回值:无 + * 限定条件:必须与 vol_lock_wait 成对;未获取时调用无害(内部判空) + */ +void vol_lock_release(void); + +#ifdef __cplusplus +} +#endif + +#endif /* VOL_LOCK_H */ diff --git a/App/log/console_log.c b/App/log/console_log.c index e22fcc97..7b3c80f6 100644 --- a/App/log/console_log.c +++ b/App/log/console_log.c @@ -8,6 +8,24 @@ * 修改记录: * 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出,实现逻辑不变, * 仅变更文件位置:HAL 库生成文件不得包含应用代码) + * 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架 gen-288 实证):round-3 的 + * "单次 drain 预算 250ms" 只限单次调用——ORE 锁存在外设软复位后 + * 毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms + * 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙 + * 超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、 + * 无 stall 日志,纯节奏性饥饿)。修复:发送故障后立即结束本轮 + * drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环), + * 保证喂狗节奏 ≤ ~300ms。根治(USART1 TX-only 灭 ORE 机制) + * 另行推进(.ioc 结构性修改 + CubeMX 重生成,见待处理内容①)。 + * 2026-10-02 王建锋 round 8b 加固:发送超时判据改用 DWT CYCCNT(时间源无关)。 + * 台架 round 8a 取证(实机测试规划.md §0.5.4.2)定位到:defaultTask + * 冻结在 console 突发行内、无 STALL、复位为 IWDGRSTF。唯一自洽机理 = + * HAL_UART_Transmit 的超时判据 `HAL_GetTick() - Tickstart` 依赖 + * TIM7 的 uwTick —— TIM7 一旦停摆,超时永不成立 → 写者死等 TXE → + * 唯一喂狗者停摆 → IWDG 复位。本改动给"裸发送"与"缓冲满等待"两处 + * 都换成 DWT 周期计数硬上界(DWT 使能失败时退回旧 HAL 行为)。 + * 另加 USART1 未初始化(huart1.Instance == NULL)短路:防止在 + * HAL_Init 早期/断言-故障上报路径上解引用 NULL 读写地址 0。 */ #include "console_log.h" @@ -18,6 +36,7 @@ #include "task.h" #include "cmsis_os.h" #include "sys_events.h" +#include "sys_log.h" /* ===== 控制台单写者日志(2026-09-24 加固,HardFault 定位结论驱动)===== * 背景:旧路径 DBG_* → printf → fputc **每字符一次** HAL_UART_Transmit。HAL 的锁只保证 @@ -35,12 +54,29 @@ #define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */ #define DBG_CON_CHUNK_MAX 256U /* 写者单次 HAL_UART_Transmit 字节上限 */ #define DBG_CON_WAIT_MS 200U /* 缓冲满时生产者最长等待(ms),仍满则丢弃并计数 */ +#define DBG_CON_TX_TIMEOUT_MS 100U /* 单次发送超时:256B 块 @115200 标称 22ms;僵尸通道限时 + 自愈 */ +#define DBG_CON_DRAIN_BUDGET_MS 250U /* 单次 drain 连续发送上限:让位喂狗轮询(缺失容忍 800ms) */ +#define DBG_CON_FAULT_COOL_MS 300U /* 故障后发送冷却:ORE 锁存毫秒级重锁,背靠连发无益 + 且拖长主循环周期 → 喂狗节奏饥饿(gen-288 实证); + 冷却期内 drain 只让位、不发送,下轮重试 */ + +/* ==== round 8b:与时间源无关的硬上界(DWT CYCCNT)==== + * HAL_UART_Transmit 的超时判据是 `HAL_GetTick() - Tickstart > Timeout`,而 HAL 时基 + * = TIM7 的 uwTick。TIM7 停摆 → 超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 → + * IWDG 误复位(round 8a 台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何 + * 中断/uwTick)做判据;DWT 使能失败时退回 HAL_UART_Transmit(旧行为,功能不退化)。 */ +#define CON_TX_TIMEOUT_CYC (SystemCoreClock / 10U) /* 发送硬上界 = 100ms 对应周期数 */ +#define CON_APPEND_WAIT_CYC (SystemCoreClock / 5U) /* 缓冲满等待硬上界 = 200ms 对应周期数 */ static uint8_t s_con_ring[DBG_CON_RING_SIZE]; static volatile uint16_t s_con_head; /* 生产者写入位置 */ static volatile uint16_t s_con_tail; /* 写者读取位置 */ static volatile uint32_t s_con_drop; /* 因缓冲满丢弃的行数 */ +static volatile uint32_t s_con_tx_fault; /* TX 失败(僵尸通道)次数,均经外设软复位自愈 */ +static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(0 = 无冷却) */ +static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保护) */ static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */ +static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */ static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */ /* @@ -52,10 +88,75 @@ static uint16_t con_used(void) { return (uint16_t)((uint16_t)(s_con_head - s_con_tail) & (DBG_CON_RING_SIZE - 1U)); } +/* + * 函数功能:惰性使能 DWT 周期计数器(时间源无关的硬上界基准) + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文可调(幂等;纯寄存器写,不创建任何内核对象) + * 函数说明:CoreDebug->DEMCR.TRCENA=1 → DWT->CYCCNT=0 → DWT->CTRL.CYCCNTENA=1; + * 使能成功置 s_con_dwt_up=1(供超时判据选择),失败保持 0(退回 HAL 超时)。 + * 在 Cortex-M4 上 DWT 恒存在;失败仅可能出现在调试单元被硬件关闭时。 + */ +static void con_dwt_ensure(void) { + if (s_con_dwt_up != 0U) { + return; + } + CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; + DWT->CYCCNT = 0U; + DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; + if ((DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0U) { + s_con_dwt_up = 1U; + } +} + +/* + * 函数功能:有界裸发送(不依赖 uwTick / HAL 超时) + * 入口参数:p_buf - 待发数据;len - 字节数(调用方已限 ≤ DBG_CON_CHUNK_MAX) + * 返回值:HAL_OK - 已全部写入发送寄存器;HAL_TIMEOUT - 有界上界内未完成 + * 限定条件:USART1 已初始化 + * 函数说明:寄存器级轮询 TXE/TC,超时判据用 DWT CYCCNT(CON_TX_TIMEOUT_CYC); + * DWT 未使能时退回 HAL_UART_Transmit(旧行为)。本函数只做 TX, + * 不触碰 RX/错误标志——僵尸通道自愈由调用方按返回值处理。 + * ⚠ USART1 未初始化(huart1.Instance == NULL,如 HAL_Init 早期或 + * 断言/故障上报路径)时立即返回 HAL_ERROR:否则会解引用 NULL 去读写 + * 地址 0(flash 别名区)→ 在故障处理里再触发故障(2026-10-02 台架实测)。 + */ +static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) { + USART_TypeDef *u; + uint32_t t0; + uint16_t i; + + con_dwt_ensure(); + if (huart1.Instance == NULL) { + return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位 */ + } + if (s_con_dwt_up == 0U) { + return HAL_UART_Transmit(&huart1, (uint8_t *)p_buf, len, DBG_CON_TX_TIMEOUT_MS); + } + + u = huart1.Instance; + t0 = DWT->CYCCNT; + for (i = 0U; i < len; i++) { + while ((u->SR & USART_SR_TXE) == 0U) { + if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) { + return HAL_TIMEOUT; /* 有界:本块放弃(调用方自愈) */ + } + } + u->DR = (uint16_t)p_buf[i]; + } + while ((u->SR & USART_SR_TC) == 0U) { + if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) { + return HAL_TIMEOUT; + } + } + return HAL_OK; +} + /* * 函数功能:控制台裸发送(异常/早期路径专用:有界、单次、不加锁) * 入口参数:buf - 文本缓冲;len - 字节数(超上限截断) * 限定条件:串口已初始化;仅用于写者未注册、中断/异常上下文、崩溃上报 + * 函数说明:走 con_tx_bounded(DWT 硬上界),tick 停摆时仍能在有界时间内返回。 */ void dbg_console_write_raw(const char *buf, unsigned len) { if ((buf == NULL) || (len == 0U)) { @@ -64,7 +165,7 @@ void dbg_console_write_raw(const char *buf, unsigned len) { if (len > DBG_CON_CHUNK_MAX) { len = DBG_CON_CHUNK_MAX; } - (void)HAL_UART_Transmit(&huart1, (uint8_t *)buf, (uint16_t)len, 0xFFFFu); + (void)con_tx_bounded((const uint8_t *)buf, (uint16_t)len); } /* @@ -76,31 +177,96 @@ void dbg_console_writer_attach(void) { s_con_writer_up = 1U; } +/* + * 函数功能:USART1 僵尸通道自愈(清错误锁存 + 整外设重初始化) + * 函数说明:RX 悬空噪声(PA10 未接线)锁存的 ORE/NE/FE/PE 使 HAL 轮询 TX 恒快速失败 + * (控制台静默、系统不复位);发送器卡死状态则使 TXE 等待耗满超时(冻结唯一 + * 写者 → 看门狗误复位,2026-10-02 台架实证)。外设软复位清锁存与卡死状态后 + * 重新初始化,故障时间被单块超时限定 + */ +static void con_uart_reinit(void) { + __NVIC_ClearPendingIRQ(USART1_IRQn); + __HAL_RCC_USART1_CLK_DISABLE(); + __HAL_RCC_USART1_CLK_ENABLE(); + huart1.gState = HAL_UART_STATE_RESET; + huart1.RxState = HAL_UART_STATE_RESET; + huart1.ErrorCode = HAL_UART_ERROR_NONE; + (void)MX_USART1_UART_Init(); +} + /* * 函数功能:排空环形缓冲(仅写者任务调用) - * 函数说明:按块一次 HAL_UART_Transmit 发出;每块完成后再推进读指针(临界区保护) + * 函数说明:按块一次 con_tx_bounded 发出(DWT 硬上界,不依赖 uwTick);每块完成后 + * 再推进读指针(临界区保护);发送失败 → 外设软复位自愈(丢当前块); + * 单次调用最多连续发 DBG_CON_DRAIN_BUDGET_MS 后让位主循环,保证看门狗轮询 + * 不被僵尸通道饿死 */ void dbg_console_drain(void) { uint16_t used; + uint32_t t0; if (s_con_writer_up == 0U) { return; } + if (s_con_draining != 0U) { + return; /* 重入保护:写者自记录不触发嵌套 drain */ + } + + s_con_draining = 1U; + t0 = (uint32_t)HAL_GetTick(); + + /* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送—— + * 本轮 drain 直接让位主循环,保住喂狗节奏(gen-288 停喂误复位的 + * 直接诱因 = 背靠连发的块超时链)。无符号差值跨 49.7 天回绕安全 */ + if (s_con_cool_until != 0U) { + if ((uint32_t)(t0 - s_con_cool_until) < DBG_CON_FAULT_COOL_MS) { + s_con_draining = 0U; + return; + } + s_con_cool_until = 0U; + } while ((used = con_used()) != 0U) { - uint16_t tail = s_con_tail; - uint16_t contig = (uint16_t)(DBG_CON_RING_SIZE - tail); /* 到环尾的连续段 */ - uint16_t n = (used < contig) ? used : contig; + uint16_t tail; + uint16_t contig; + uint16_t n; + HAL_StatusTypeDef st; + + tail = s_con_tail; + contig = (uint16_t)(DBG_CON_RING_SIZE - tail); /* 到环尾的连续段 */ + n = (used < contig) ? used : contig; if (n > DBG_CON_CHUNK_MAX) { n = DBG_CON_CHUNK_MAX; } - (void)HAL_UART_Transmit(&huart1, &s_con_ring[tail], n, 0xFFFFu); + st = con_tx_bounded(&s_con_ring[tail], n); taskENTER_CRITICAL(); s_con_tail = (uint16_t)((uint16_t)(s_con_tail + n) & (DBG_CON_RING_SIZE - 1U)); taskEXIT_CRITICAL(); + + if (st != HAL_OK) { + /* 僵尸通道:软复位自愈 + 进入发送冷却(ORE 毫秒级重锁, + * 继续背靠连发只会把主循环耗在块超时链上);本块丢弃 + * (行已出环,不可重放) */ + con_uart_reinit(); + s_con_tx_fault++; + s_con_cool_until = (uint32_t)HAL_GetTick(); + if (s_con_tx_fault == 1U) { + static const char note[] = "\r\n[WARN] CONSOLE tx fault: UART reinit (self-healed)\r\n"; + + syslog_post(SYSLOG_RUN, "console tx fault, uart reinit"); /* 仅 run.log,不入自检判定 */ + (void)dbg_console_write_raw(note, (unsigned)sizeof(note) - 1U); + } + break; /* 故障即止:本轮 drain 到此为止,让位主循环 */ + } + + if ((uint32_t)(HAL_GetTick() - t0) >= DBG_CON_DRAIN_BUDGET_MS) { + break; /* 预算用尽:让位主循环(看门狗轮询),下轮续排 */ + } } + s_con_draining = 0U; + /* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失) */ if (s_con_drop != 0U) { char note[64]; @@ -124,9 +290,13 @@ void dbg_console_drain(void) { * 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped) * 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行; * 写者自身记录日志时先排空缓冲,避免自等。 + * 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)—— + * tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(round 8b)。 */ static int con_append(const char *buf, uint16_t len) { uint32_t waited = 0U; + uint32_t d0 = 0U; + uint8_t have_dwt = 0U; if ((buf == NULL) || (len == 0U)) { return -1; @@ -140,8 +310,14 @@ static int con_append(const char *buf, uint16_t len) { len = (uint16_t)(DBG_CON_RING_SIZE / 2U); /* 超长保护(正常路径不会触发) */ } - if (xTaskGetCurrentTaskHandle() == s_con_writer_task) { - dbg_console_drain(); /* 写者自己记录:先腾空,避免自等 */ + if ((xTaskGetCurrentTaskHandle() == s_con_writer_task) && (s_con_draining == 0U)) { + dbg_console_drain(); /* 写者自己记录:先腾空,避免自等(drain 进行中则跳过,防嵌套) */ + } + + con_dwt_ensure(); + if (s_con_dwt_up != 0U) { + d0 = DWT->CYCCNT; + have_dwt = 1U; } for (;;) { @@ -151,7 +327,12 @@ static int con_append(const char *buf, uint16_t len) { if (free_space >= len) { break; } - if (waited >= DBG_CON_WAIT_MS) { + if (have_dwt != 0U) { + if ((uint32_t)(DWT->CYCCNT - d0) >= CON_APPEND_WAIT_CYC) { + s_con_drop++; + return -1; + } + } else if (waited >= DBG_CON_WAIT_MS) { s_con_drop++; return -1; } @@ -213,6 +394,13 @@ uint32_t dbg_console_dropped(void) { return s_con_drop; } +/* + * 函数功能:已发生的 TX 故障(僵尸通道自愈)次数(诊断用) + */ +uint32_t dbg_console_tx_fault(void) { + return s_con_tx_fault; +} + /* * 函数功能:MicroLIB printf 底层重定向(库内部/遗留 printf 调用) * 函数说明:同样只把字节交给单写者路径;未注册写者时直发(与旧行为一致) diff --git a/App/log/console_log.h b/App/log/console_log.h index 613c8631..e24eda62 100644 --- a/App/log/console_log.h +++ b/App/log/console_log.h @@ -34,8 +34,9 @@ extern "C" { * DBG_PRINT 宏内用库 snprintf 完成) * dbg_console_write_raw - 异常/早期路径:有界单次直发(不加锁) * dbg_console_writer_attach- 写者任务(defaultTask)注册自己为唯一发送者 - * dbg_console_drain - 写者任务排空缓冲并发送 + * dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈) * dbg_console_dropped - 因缓冲满丢弃的行数(诊断) + * dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断) */ #define DBG_CON_LINE_MAX 192U /* 单行文本上限(含结尾 '\0',超出截断) */ int dbg_console_write_line(const char *line, unsigned len); @@ -43,6 +44,7 @@ void dbg_console_write_raw(const char *buf, unsigned len); void dbg_console_writer_attach(void); void dbg_console_drain(void); uint32_t dbg_console_dropped(void); +uint32_t dbg_console_tx_fault(void); /* * 函数功能:MicroLIB printf 底层重定向(C 库 retarget 钩子,须为全局符号) diff --git a/App/log/sys_log.h b/App/log/sys_log.h index 5e4397b4..7ec36701 100644 --- a/App/log/sys_log.h +++ b/App/log/sys_log.h @@ -119,6 +119,21 @@ void syslog_mcu_mounted(void); */ void syslog_drain(void); +/* + * 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;变长行,不走 64B 定长格式) + * 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char * + * 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,**不阻断判定**) int + * 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞, + * 本上下文 = 看门狗喂狗点) + * 函数说明:round 7 取证通道(设计见 docs/测试说明/实机测试规划.md §0.5.4: + * 误复位后 RAM 态随复位消失、stall 行经僵尸串口丢失,故证据必须落盘)。 + * 内部在释放卷锁后立刻追加一条 s_append(SYSLOG_RUN, ...) 写另一个 + * 文件——FTL 单页缓存语义为"写到不同 LPN 才把前一脏页刷进 NAND", + * 没有该 flush 诱导,本行证据会在约 1.7s 后的 IWDG 复位中回滚丢失。 + * 文件 > 8KB 时先 unlink 重建,防长跑无界增长。 + */ +int sys_log_evidence(const char *p_desc); + #ifdef __cplusplus } #endif diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index 8b449556..49341e09 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -9,6 +9,13 @@ * v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4.4) * v1.1 2026-09-21 王建锋 消息队列改为内核启动后由 syslog_drain 惰性创建 * (修复 osKernelStart 前创建 FreeRTOS 内核对象导致的启动失败) + * v1.2 2026-10-02 王建锋 s_append 落盘序列纳入卷锁 try 语义(vol_lock_wait(0)): + * 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 → + * dhara journal 竞争实证);busy 返回 -2 静默顺延 + * (本任务 = 看门狗喂狗点,绝不阻塞等锁) + * v1.3 2026-10-02 王建锋 新增 sys_log_evidence():把看门狗停喂现场 + * 证据落 /log/wdev.log(掉电不丢)+ flush 诱导 + * (round 7 纯取证,见 实机测试规划.md §0.5.4) */ /* 头文件包含区 */ @@ -22,6 +29,7 @@ #include "ff.h" #include "cmsis_os.h" #include "sys_clock.h" +#include "vol_lock.h" /* 静态变量定义区 */ @@ -48,7 +56,7 @@ static volatile uint8_t s_fs_ready; /* 卷挂载 + /log 目录 * 函数功能:追加一条记录(格式化 64B + FA_OPEN_APPEND;容量满先删除重建) * 入口参数:id - 日志 ID syslog_id_t * desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char * - * 返回值:0 - 成功;-1 - 写失败 int + * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 */ static int s_append(syslog_id_t id, const char *desc) { @@ -63,10 +71,17 @@ static int s_append(syslog_id_t id, const char *desc) { if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { return -1; } + /* 卷锁 try:本任务 = 看门狗喂狗点,绝不允许阻塞等锁(阻塞 = 喂狗停摆 = + * 误复位);busy = FTP 文件操作持有卷,本轮顺延(条目滞留队列/待发 + * 数组),静默跳过、不计故障 */ + if (vol_lock_wait(0u) == 0u) { + return -2; + } if (syslog_count(id) >= s_caps[id]) { fr = f_unlink(s_paths[id]); DBG_INFO("cap full, recreate %s", s_paths[id]); if (fr != FR_OK && fr != FR_NO_FILE) { + vol_lock_release(); return -1; } } @@ -129,6 +144,7 @@ static int s_append(syslog_id_t id, const char *desc) { s_fault_reported[id] = 1u; DBG_ERROR("write %s fail (%d)", s_paths[id], (int)fr); } + vol_lock_release(); return rc; } @@ -236,9 +252,16 @@ void syslog_drain(void) { if (osMessageQueueGet(s_queue, &e, 0u, 0u) != osOK) { break; /* 队列空 */ } - if (s_append((syslog_id_t)e.id, e.desc) != 0) { - DBG_ERROR("drop entry id=%d", (int)e.id); - break; /* 落盘失败:停止排空,避免连续丢弃 */ + { + int arc = s_append((syslog_id_t)e.id, e.desc); + if (arc == -2) { + break; /* 卷被占用(FTP 文件操作进行中):本轮顺延,静默 + (条目已出队;run.log 尽力而为语义,不计故障) */ + } + if (arc != 0) { + DBG_ERROR("drop entry id=%d", (int)e.id); + break; /* 落盘失败:停止排空,避免连续丢弃 */ + } } } } @@ -357,3 +380,75 @@ int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch) { } return 0; } + +/* + * 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;round 7 纯取证) + * 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char * + * 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,不阻断判定) int + * 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞) + * 函数说明: + * 为什么必须落盘:误复位后 RAM 态随复位消失、`[WD] task stall` 行经僵尸串口 + * 可能丢失(2026-10-02 台架 round 3~6 绕圈的判据缺口),故证据必须落到 + * **掉电不丢**的盘上,复位后经 FTP RETR 回读。 + * flush 诱导(关键):写完 wdev.log 后立刻 s_append(SYSLOG_RUN, ...) 写另一个 + * 文件——FTL 单页缓存语义为"写到**不同 LPN** 才把前一脏页刷进 NAND";没有 + * 这一步,本行证据会在约 1.7s 后的 IWDG 复位中回滚丢失(409 条里被回滚的 + * 07:56 记录即同类教训)。s_append 自行取卷锁(非递归锁),故必须在 + * vol_lock_release() **之后**调用,绝不可在持锁状态下调用。 + * 容量护栏:> 8KB 先 unlink 重建,防长跑无界增长(8.3 合法名 wdev.log)。 + */ +int sys_log_evidence(const char *p_desc) { + FIL f; + FRESULT fr; + UINT bw = 0u; + UINT line_len; + char line[192]; + char ts[SYSLOG_TS_LEN + 1u]; + int n; + int rc = 0; + + if (p_desc == NULL) { + return -1; + } + /* 卷锁 try:本上下文 = 看门狗喂狗点,绝不阻塞等锁 */ + if (vol_lock_wait(0u) == 0u) { + return -1; + } + + /* 容量护栏:超过 8KB 先删除重建(防长跑无界增长) */ + { + FILINFO finfo; + if (f_stat("/log/wdev.log", &finfo) == FR_OK && finfo.fsize > 8192u) { + (void)f_unlink("/log/wdev.log"); + } + } + + if (sys_clock_get_str(ts, sizeof(ts)) == NULL) { + memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN); + } + ts[SYSLOG_TS_LEN] = '\0'; + n = snprintf(line, sizeof(line), "%s %s\r\n", ts, p_desc); + if (n <= 0) { + vol_lock_release(); + return -1; + } + line_len = (UINT)strlen(line); + + fr = f_open(&f, "/log/wdev.log", FA_WRITE | FA_OPEN_APPEND); + if (fr == FR_OK) { + fr = f_write(&f, line, line_len, &bw); + (void)f_close(&f); + if (fr != FR_OK || bw != line_len) { + rc = -1; + } + } else { + rc = -1; + } + vol_lock_release(); + + /* flush 诱导:写另一个文件(不同 LPN)把 wdev.log 的脏页刷进 NAND。 + * 必须在释放卷锁之后调用(s_append 自行取锁,非递归锁不可重入)。 */ + (void)s_append(SYSLOG_RUN, "wd stall declared"); + + return rc; +} diff --git a/App/sync/time_sync.c b/App/sync/time_sync.c index 2a9f5f82..00ec840a 100644 --- a/App/sync/time_sync.c +++ b/App/sync/time_sync.c @@ -10,6 +10,10 @@ * v1.1 2026-08-28 王建锋 改为 TCP Server,迁入独立任务 timeSyncTask, * 所有 socket 操作经 net_socket 线程安全 API 走消息队列, * netTask 仅负责 net_poll/net_process_messages,多任务并发安全 + * v1.2 2026-10-02 王建锋 round 8b 消抖:等待片长 200ms→50ms,并把各处长 + * 等待(500/2000/200ms)统一走 time_sync_delay_fed() + * 按 ≤50ms 分片、每片喂心跳——消除"心跳滑出两个连续 + * 100ms 抽样窗"的节拍抖动(round 7 台架 snap=0x17) */ #include @@ -38,9 +42,31 @@ #define TIME_SYNC_TOTAL_BUDGET_MS 30000U /* 看门狗分片等待:单次 net_recv_to 的等待片长上限。须显著小于看门狗抽样窗 - * (task_watchdog.c WD_POLL_PERIOD_MS=100ms)的容错窗口(8 窗 = 800ms), - * 200ms 留足余量;语义上仅把一次长等待拆成多次短等待,总预算不变。 */ -#define TIME_SYNC_WAIT_SLICE_MS 200U + * (task_watchdog.c WD_POLL_PERIOD_MS=100ms)——片长 ≤50ms 时每片前喂的心跳 + * 必然落在同一个 100ms 抽样窗内,消除"心跳恰好滑过两个连续窗口"的边界抖动 + * (round 7 台架稳态 snap=0x17 即抽样窗 vs 心跳节拍的良性错位,round 8b 消抖)。 + * 语义上仅把一次长等待拆成多次短等待,总预算不变。 */ +#define TIME_SYNC_WAIT_SLICE_MS 50U + +/* + * 函数功能:可喂心跳的延时(把长等待切成 ≤50ms 短片,每片喂一次心跳) + * 入口参数:ms - 总延时(ms) uint32_t + * 返回值:无 + * 限定条件:任务上下文调用 + * 函数说明:看门狗抽样窗为 100ms(task_watchdog.c WD_POLL_PERIOD_MS)。把任意长 + * 等待切成 ≤TIME_SYNC_WAIT_SLICE_MS 的短片、每片前喂心跳,使心跳必然 + * 落在单个抽样窗内——语义等价于一次长延时,仅把"心跳节奏"钉在窗内 + * (round 8b 消抖;不改变总延时预算)。 + */ +static void time_sync_delay_fed(uint32_t ms) { + while (ms > 0U) { + uint32_t slice = (ms > TIME_SYNC_WAIT_SLICE_MS) ? TIME_SYNC_WAIT_SLICE_MS : ms; + + task_wd_beat(TASK_WD_TIMESYNC); + vTaskDelay(pdMS_TO_TICKS(slice)); + ms -= slice; + } +} /* * 函数功能:创建并监听 TCP 时间同步服务端口 @@ -59,7 +85,7 @@ static int time_sync_open_listener(void) { int s = net_socket(NET_AF_INET, NET_SOCK_STREAM); if (s < 0) { DBG_ERROR("time_sync: net_socket FAIL"); - vTaskDelay(pdMS_TO_TICKS(500)); + time_sync_delay_fed(500U); continue; } @@ -72,16 +98,14 @@ static int time_sync_open_listener(void) { if (net_bind(s, (struct net_sockaddr *)&addr, sizeof(addr)) != 0) { DBG_ERROR("time_sync: bind port %d FAIL", TIME_SYNC_PORT); net_close(s); - task_wd_beat(TASK_WD_TIMESYNC); - vTaskDelay(pdMS_TO_TICKS(2000)); + time_sync_delay_fed(2000U); continue; } if (net_listen(s) != 0) { DBG_ERROR("time_sync: listen FAIL"); net_close(s); - task_wd_beat(TASK_WD_TIMESYNC); - vTaskDelay(pdMS_TO_TICKS(2000)); + time_sync_delay_fed(2000U); continue; } @@ -166,11 +190,24 @@ static void time_sync_handle_conn(int conn_sock) { void time_sync_task(void *argument) { (void)argument; + task_wd_bind(TASK_WD_TIMESYNC); /* FTL 心跳路由:登记本任务句柄(入口一次) */ + /* 等网络栈真正就绪(取代拍脑袋的 8s 魔法延时),确保 netTask 已开始处理消息; * osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒) */ if (sys_evtHandle != NULL) { - (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, - osFlagsWaitAny | osFlagsNoClear, osWaitForever); + /* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒)。 + * 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳——IWDG 必需掩码含 + * TS 位,原 osWaitForever 无心跳 → 启动初期(存储已就绪、网络未就绪) + * 掩码 800ms 不可达 → 停喂 → 复位循环(2026-10-02 台架根因之一)。 + * 100ms 分片 = 抽样窗,纯任务睡眠零队列开销。 */ + for (;;) { + uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, + osFlagsWaitAny | osFlagsNoClear, 100u); + if ((r & osFlagsError) == 0u) { + break; /* NET_READY 已置位(状态位,未被消费) */ + } + task_wd_beat(TASK_WD_TIMESYNC); /* 看门狗:分片喂心跳(屏障等待态) */ + } } for (;;) { @@ -184,8 +221,7 @@ void time_sync_task(void *argument) { int ls = time_sync_open_listener(); if (ls < 0) { DBG_ERROR("time_sync: open listener FAIL, retry later"); - task_wd_beat(TASK_WD_TIMESYNC); - vTaskDelay(pdMS_TO_TICKS(2000)); + time_sync_delay_fed(2000U); continue; } @@ -198,8 +234,7 @@ void time_sync_task(void *argument) { net_close(ls); /* 网络重建:旧监听失效,回外层重建 */ break; } - task_wd_beat(TASK_WD_TIMESYNC); - vTaskDelay(pdMS_TO_TICKS(200)); + time_sync_delay_fed(200U); } } while (conn < 0); @@ -211,7 +246,6 @@ void time_sync_task(void *argument) { time_sync_handle_conn(conn); /* conn(单连接模式下即 ls)已被 net_close 释放,下次循环重建监听 */ - task_wd_beat(TASK_WD_TIMESYNC); - vTaskDelay(pdMS_TO_TICKS(50)); + time_sync_delay_fed(50U); } } diff --git a/App/task/default_task.c b/App/task/default_task.c index 4687dafa..cf80eaaa 100644 --- a/App/task/default_task.c +++ b/App/task/default_task.c @@ -15,6 +15,10 @@ * v1.0 2026-09-25 王建锋 自 Src/freertos.c 迁出 StartDefaultTask 任务体及 * stack_probe_poll / storage_bring_up / 保存通知回调 * (CubeMX 生成文件只留任务创建接线与崩溃钩子) + * v1.1 2026-10-02 王建锋 存储 bring-up 首行创建卷锁(vol_lock_init,幂等): + * FTP 文件操作与 syslog/配置的卷访问自此串行化 + * (2026-10-02 台架:并发 FTL 写 → journal 竞争 → + * 本任务主循环喂狗点饥饿 → IWDG 误复位) */ #include "default_task.h" @@ -31,6 +35,7 @@ #include "sys_events.h" #include "dbg_log.h" #include "task_watchdog.h" +#include "vol_lock.h" /* 任务句柄与属性块(CubeMX 生成全局,定义于 Src/freertos.c): * 栈水位观测需 6 任务句柄 + 属性块 stack_size(配置栈大小的唯一定义处) */ @@ -96,6 +101,8 @@ static FRESULT storage_bring_up(FATFS *fs); void default_task_func(void *argument) { (void)argument; + task_wd_bind(TASK_WD_DEFAULT); /* FTL 心跳路由:登记本任务句柄(入口一次) */ + /* 系统启动期挂载 NAND 上的 FatFS 卷,供 FTP 及其它模块访问。 * f_mount(forced=1) 会触发 disk_initialize() → nand_ftl_init() * (dhara_map_resume)。此前默认构建从未挂载,导致 FTP 的 @@ -159,26 +166,34 @@ void default_task_func(void *argument) { task_wd_start(); for (;;) { + /* round 8 取证:进段打点——每步前记录段号到 .noinit 槽(复位不丢)。 + * 误复位后开机 `PREVRUN seg=N` 即"上一轮主循环死在哪个调用点" + * (round 7 结论 = poller 自身停摆,见 实机测试规划.md §0.5.4)。 */ + task_wd_seg(TASK_WD_SEG_POLL); /* 心跳抽样与喂狗判定(内部节流至 100ms;全员到位才喂,否则累计停喂) */ task_wd_poll(); /* 本任务自己的心跳 */ task_wd_beat(TASK_WD_DEFAULT); /* 等待"保存网络配置"或"控制台待发":任一即醒;无事件 50ms 后返回 * (日志时延 = 事件唤醒,实测无丢失;50ms 兜底仅防事件位异常) */ + task_wd_seg(TASK_WD_SEG_EVTWAIT); if (sys_evtHandle != NULL) { (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_CFG_SAVE | SYS_EVT_CONSOLE_TX, osFlagsWaitAny, 50u); } /* 控制台唯一发送点:把环形缓冲中的日志按块发出(无待发时立即返回) */ + task_wd_seg(TASK_WD_SEG_DRAIN); dbg_console_drain(); /* 执行待处理保存(配置引擎属主:写盘 + 成功后立即应用);无请求 = 空操作 */ #if !defined(TEST_SUITE_STORAGE) + task_wd_seg(TASK_WD_SEG_CFGSAVE); net_cfg_save_run(); #endif /* 配置文件变更轮询(2s 周期;沉降去抖在引擎内) */ if ((cfg_poll_cnt++ % 40u) == 0u) { #if !defined(TEST_SUITE_STORAGE) + task_wd_seg(TASK_WD_SEG_CFGPOLL); /* E-31 自愈:启动期挂载失败时,每 2s 重跑完整 bring-up—— * 瞬态毛刺(如 dhara resume 扫描的瞬时 SPI 故障)通常第二次 * 即恢复;成功后由 storage_bring_up 补跑启动时跳过的 @@ -201,6 +216,7 @@ void default_task_func(void *argument) { } /* LED 心跳:500ms 翻转一次(与旧 500ms 循环节奏等效;循环基准改 50ms 后 * 不能再每轮翻转,否则变成 10Hz 闪烁) */ + task_wd_seg(TASK_WD_SEG_LED); if ((HAL_GetTick() - led_tick) >= 500u) { led_tick = HAL_GetTick(); HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin); @@ -209,9 +225,11 @@ void default_task_func(void *argument) { } #if !defined(TEST_SUITE_STORAGE) /* 排空运行期日志(上电/重启分类与自检故障落盘) */ + task_wd_seg(TASK_WD_SEG_SYSLOG); syslog_drain(); #endif /* 栈水位观测:首次 5s 后报告,此后每 2s 采样、出现更深峰值才打印 */ + task_wd_seg(TASK_WD_SEG_STACK); stack_probe_poll(); } } @@ -322,7 +340,13 @@ static void stack_probe_poll(void) { */ #if !defined(TEST_SUITE_STORAGE) static FRESULT storage_bring_up(FATFS *fs) { - FRESULT fr = f_mount(fs, "", 1); + FRESULT fr; + + /* 卷锁创建(幂等):必须早于本卷任何跨任务使用者——ftpTask 的 lftpd 文件 + * 操作受 STORAGE_READY 屏障门控,屏障在本函数末尾才置位,时序天然安全 */ + (void)vol_lock_init(); + + fr = f_mount(fs, "", 1); if (fr == FR_NO_FILESYSTEM) { /* 卷为空(如恢复/坏块重建后清空):自动格式化建卷 diff --git a/App/task/net_task.c b/App/task/net_task.c index a401540b..0330ec0d 100644 --- a/App/task/net_task.c +++ b/App/task/net_task.c @@ -165,6 +165,8 @@ int net_task_init(void) { void net_task_func(void const *arg) { (void)arg; + task_wd_bind(TASK_WD_NET); /* FTL 心跳路由:登记本任务句柄(入口一次) */ + /* 硬件层测试模式:testTask 独占 CH395F,本任务自挂起(基础线程恒创建, * 该模式下"不运行"):不 bring_up、不 net_poll、不广播 NET_READY; * ftpTask/timeSyncTask 将永久阻塞于屏障 = 预期行为(见《项目启动顺序管理》) */ @@ -181,9 +183,24 @@ void net_task_func(void const *arg) { /* 配置在 NAND 的 FatFS 卷上:等存储就绪再读(取代原 2s 魔法延时); * 超时(存储异常)继续以工厂默认组网,不卡网络启动 */ if (sys_evtHandle != NULL) { - /* osFlagsNoClear:就绪位是状态,不能被本任务消费(ftpTask 等仍要 WaitAll 两位) */ - (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_STORAGE_READY, - osFlagsWaitAny | osFlagsNoClear, 10000u); + /* osFlagsNoClear:就绪位是状态,不能被本任务消费(ftpTask 等仍要 WaitAll 两位)。 + * 2026-10-02 台架修复:10s 超时等待改 100ms 分片 + 每片喂心跳——原实现 + * 在等待期无心跳:若存储 bring-up 失败,本等待跑满 10s 超时,而 IWDG + * 此时已启动(defaultTask 在 bring-up 后即启动),NET 位长期缺失 → + * 必需掩码不可达 → 复位循环,E-31 的 2s 自愈重试根本没有机会。 + * 分片喂位保证"存储未就绪"降级态下 NET 位持续新鲜。 */ + uint32_t rounds_left = 100u; /* 100 × 100ms = 原 10s 超时语义 */ + for (;;) { + uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_STORAGE_READY, + osFlagsWaitAny | osFlagsNoClear, 100u); + if ((r & osFlagsError) == 0u) { + break; /* 存储就绪 */ + } + task_wd_beat(TASK_WD_NET); /* 看门狗:分片喂心跳(屏障等待态) */ + if ((--rounds_left) == 0u) { + break; /* 10s 超时:工厂默认组网,不阻塞网络启动 */ + } + } } if (net_stack_bring_up() != 0) { diff --git a/App/task/ui_task.c b/App/task/ui_task.c index ca8a4e9c..79174bb4 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -47,9 +47,19 @@ static void ui_task_time_cb(char *buf, size_t len) { */ static void ui_task_wait_net_ready(void) { if (sys_evtHandle != NULL) { - /* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒) */ - osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, - osFlagsWaitAny | osFlagsNoClear, osWaitForever); + /* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒)。 + * 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳(与 timeSync/netTask/ + * ftpTask 入口同模式)——IWDG 必需掩码含 UI 位,原 osWaitForever 无心跳: + * IWDG 启动(存储就绪)时网络未必就绪 → 掩码 800ms 不可达 → 停喂 → 复位循环。 + * 100ms 分片 = 抽样窗,纯任务睡眠零队列开销。 */ + for (;;) { + uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, + osFlagsWaitAny | osFlagsNoClear, 100u); + if ((r & osFlagsError) == 0u) { + break; /* NET_READY 已置位(状态位,未被消费) */ + } + task_wd_beat(TASK_WD_UI); /* 看门狗:分片喂心跳(屏障等待态) */ + } } } @@ -64,6 +74,8 @@ static void ui_task_wait_net_ready(void) { void ui_task_func(void *argument) { (void)argument; + task_wd_bind(TASK_WD_UI); /* FTL 心跳路由:登记本任务句柄(入口一次) */ + /* 等待网络就绪期间不可能喂狗:本任务此时尚未进入主循环,而就绪位由 netTask 置位。 * 该段若长期不成立,看门狗会复位——这是期望行为(网络 bring-up 卡死须暴露)。 * 注意本任务在 required_mask 内,主循环跑起来后须保证每 100ms 窗口内至少喂一次。 */ diff --git a/App/util/task_watchdog.c b/App/util/task_watchdog.c index ff55d50e..0602452a 100644 --- a/App/util/task_watchdog.c +++ b/App/util/task_watchdog.c @@ -8,6 +8,27 @@ * 创建日期:2026-09-29 * 修改记录: * v1.0 2026-09-29 王建锋 创建:心跳位图 + 抽样判定 + IWDG 延后启动 + * v1.1 2026-10-02 王建锋 round 7 纯取证埋点:近 8 次抽样位图环 + 最大喂狗 + * 间隔统计 + 停喂现场落 /log/wdev.log(掉电不丢); + * 全部在 TASK_WATCHDOG_UNIT_TEST 守卫内,判定状态机 + * 与主机侧单测逐字不变(见 实机测试规划.md §0.5.4) + * v1.2 2026-10-02 王建锋 round 8 取证:复位不丢的"进段打点"(.noinit 槽 + + * PREVRUN 开机上报 + STALL 行附 seg)——round 7 结论为 + * "poller 自身停摆",需定位 defaultTask 主循环阻塞点 + * v1.3 2026-10-02 王建锋 round 8b 取证:第二个 .noinit 槽"时间源存活性" + * (TIM7 ISR 每 1ms 写 uwTick/FreeRTOS tick/计数)+ + * TICKRUN 开机上报(含相对进段时刻的增量 duw/dfr)—— + * round 8a 定位到 console 通道后,需钉死是 TIM7(HAL tick) + * 还是 SysTick(FreeRTOS tick) 停摆(实机测试规划.md §0.5.4.3) + * + * v1.4 2026-10-02 王建锋 **P0 修复**:wd_platform_start() 补写 IWDG 的 + * PR/RLR(= WD_IWDG_PRESCALER / WD_IWDG_RELOAD,即设计值 + * /64 + 1249 → 1701~2498ms)。此前为"延后启动"删掉 + * HAL_IWDG_Init() 时,一并丢掉了**唯一**写这两只寄存器的 + * 路径 → 实机停在复位默认值 PR=0/RLR=4095(≈349~512ms), + * 比设计短约 5 倍 → 800ms 容忍分支成死代码、任何 >400ms + * 心跳缺口直接 IWDG 误复位(round 3~8 全部"空 STOR 必 + * 复位"的共同根因)。SWD 现场改回设计值后同一用例 100% 通过 * * 设计依据:docs/子系统设计/看门狗设计说明.md * - §3.1 为何不用"每任务各自喂狗"(那只检测全系统同时挂死) @@ -30,6 +51,15 @@ #include "main.h" /* HAL_GetTick()(HAL 时基 = TIM7,1ms) */ #include "iwdg.h" /* hiwdg 参数来源(CubeMX 生成,见 Src/iwdg.c) */ +#ifndef TASK_WATCHDOG_UNIT_TEST +#include /* strlen(取证行裸 UART 直发长度) */ +#include "FreeRTOS.h" /* xTaskGetCurrentTaskHandle / TaskHandle_t(FTL 心跳路由) */ +#include "task.h" +#include "lftpd.h" /* lftpd_task_wd_beat() 弱钩子声明(本文件提供强符号,见下方) */ +#include "sys_log.h" /* SYSLOG_RUN(flush 诱导落盘)+ sys_log_evidence()(round 7 取证落盘) */ +#include "console_log.h" /* dbg_console_tx_fault()(僵尸通道计数) */ +#endif + #define DBG_TAG "[WD]" #include "dbg_log.h" @@ -45,10 +75,24 @@ /* IWDG 超时参数:PR=4 → 64 分频;RLR=1249 * 标称 64 × 1249 / 32000 ≈ 2498 ms - * 最坏 64 × 1249 / 47000 ≈ 1701 ms(LSI 偏快时超时变短,按最坏校核) */ + * 最坏 64 × 1249 / 47000 ≈ 1701 ms(LSI 偏快时超时变短,按最坏校核) + * ⚠ 这两只值**必须写进寄存器**才生效——由 wd_platform_start() 在启动前写入 + * (PR/RLR 的写访问需先写 KR=0x5555 解锁)。2026-10-02 之前无人写它们, + * 寄存器一直停在**复位默认值**(PR=0 → /4、RLR=4095 ≈ 349~512ms), + * 详见 wd_platform_start() 的函数说明。 */ #define WD_IWDG_PRESCALER 4u #define WD_IWDG_RELOAD 1249u +/* IWDG 寄存器键值(RM0090 §21.4.4;KR 为只写寄存器,读回恒为 0) */ +#define WD_IWDG_KEY_UNLOCK 0x00005555u /* 解锁 PR/RLR 写访问 */ +#define WD_IWDG_KEY_FEED 0x0000AAAAu /* 重装计数器(喂狗) */ +#define WD_IWDG_KEY_START 0x0000CCCCu /* 启动(此后不可停,只能靠复位) */ + +/* PVU/RVU 同步等待上界:PR/RLR 写入后需 ≤5 个 LSI 周期(≈0.15ms)同步到 + * LSI 域,其间对应 PVU/RVU 位置 1 且**后续写会被丢弃**。此上界(168MHz 下 + * 约 1ms)只为避免 LSI 异常时死等,正常路径远用不到。 */ +#define WD_IWDG_SYNC_SPIN 0x10000u + /* Private types -------------------------------------------------------------*/ /* Private variables ---------------------------------------------------------*/ @@ -63,10 +107,61 @@ static uint32_t s_last_mask; /* 上次抽样到的位图(定位卡死任 static uint32_t s_started; /* 1 = IWDG 已启动 */ static uint32_t s_last_tick; /* 上次抽样时刻(100ms 节流基准) */ +/* ==== round 7 纯取证状态(仅 MCU 构建;判定状态机逐字不变)==== + * 设计见 docs/测试说明/实机测试规划.md §0.5.4:误复位后 RAM 态随复位消失, + * 故把"停喂现场"快照落盘(/log/wdev.log)供复位后回读定性。 */ +#ifndef TASK_WATCHDOG_UNIT_TEST +#define WD_SNAP_HIST_DEPTH 8u +static uint32_t s_snap_hist[WD_SNAP_HIST_DEPTH]; /* 近 8 次抽样位图(环形,定位缺席位) */ +static uint32_t s_snap_hist_idx; /* 环形写索引(写入后自增) */ +static uint32_t s_last_feed_tick; /* 上次喂狗时刻(ms) */ +static uint32_t s_max_feed_gap; /* 观测到的最大喂狗间隔(ms) */ +static uint32_t s_feed_seen; /* 1 = 已记录首个喂狗时刻 */ + +/* ==== round 8 取证:复位不丢的"进段打点"槽(.noinit → SRAM 保留)==== + * IWDG/引脚复位不改变 SRAM 内容,故复位后仍能读出"上一轮 defaultTask 主循环 + * 最后走到哪一段"。magic 用于区分"本模块写过"与上电时的随机值。 + * 布局:[0]=magic [1]=段号 [2]=进段时刻(ms) [3]=循环序号 + * ⚠ `zero_init` 必须与 `section` 同时给:只给 section 时 armcc 会把该段当 + * "带初值 RW"(有 load 地址 → 启动 scatterload 会拷贝初值 = 抹掉保留内容); + * zero_init 使其成为 ZI,配合 scatter 的 UNINIT 执行区 → 启动**完全不碰**该段。 */ +#define WD_SEG_MAGIC 0x57445347u /* 'WDSG' */ +__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_seg_trail[4]; + +/* ==== round 8b 取证:复位不丢的"时间源存活性"槽(.noinit → SRAM 保留)==== + * 两个槽(均在 UNINIT_CCM,复位不清): + * s_wd_tick_cur [0]=magic [1]=uwTick(最新) [2]=本轮 TIM7 中断计数 [3]=保留 + * —— TIM7 ISR 每 1ms 更新("本轮活动值") + * s_wd_tick_prev[0]=magic [1]=uwTick(上一轮最后) [2]=上一轮 TIM7 中断计数 + * —— 由 task_wd_tick_snapshot() 在 main() 开头(**HAL_Init 之前**)拷一次 + * ⚠ 为何必须在 HAL_Init 之前快照:TIM7 在 HAL_Init 里就 `HAL_TIM_Base_Start_IT` + * 开始中断,1ms 后就会把 cur 覆盖成本轮的值;而开机上报 task_wd_seg_report() + * 要等到 app_main_init(≈117ms)才跑——不先搬走,上一轮的终值就丢了 + * (2026-10-02 台架实测:报告里的 uw 恒为 ~117 = 本轮开机时刻,而非上一轮终值)。 + * 判读(duw = prev[1] - t,t = seg 槽的进段时刻): + * duw ≈ 0 → TIM7 与 defaultTask 同刻停摆(HAL 时基停摆) + * duw ≈ 2000+ → TIM7 活到复位(冻结不是 HAL 时基停摆,另有阻塞点) + * prev[2] → 上一轮 TIM7 总共跳了多少次(≈ 该轮存活毫秒数,可交叉核对) + * ⚠ 严禁在本 ISR 里调用任何 FreeRTOS API(含 xTaskGetTickCountFromISR): + * TIM7 远早于 xPortStartScheduler(),此时 port.c 的 ulMaxPRIGROUPValue 仍为 0 + * → vPortValidateInterruptPriority() 的 configASSERT 必失败 → 断言打印在 + * "USART1 尚未初始化"(huart1.Instance == NULL)的上下文里 → 卡死在 TIM7 ISR 内、 + * 开机零输出(2026-10-02 台架实测:IPSR=0x47=TIM7、uwTick 冻结于 1)。 + * 本探针只写 SRAM + 读 uwTick,无任何内核对象/API 依赖。 */ +#define WD_TICK_MAGIC 0x5744544Bu /* 'WDTK' */ +__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_tick_cur[4]; +__attribute__((section(".noinit"), zero_init)) volatile uint32_t s_wd_tick_prev[4]; +#endif + /* Private function prototypes -----------------------------------------------*/ static uint32_t wd_required_mask(void); +#ifndef TASK_WATCHDOG_UNIT_TEST +static void wd_feed_track(void); +static void wd_stall_evidence(uint32_t snapshot); +#endif + /* Private functions ---------------------------------------------------------*/ /* @@ -75,11 +170,19 @@ static uint32_t wd_required_mask(void); * 返回值:本次构建要求"必须动过"的任务位集合 * 限定条件:无 * 函数说明:按 §4.1/§4.2 的设计,监视范围随构建配置变化: - * - testTask 仅在启用测试套件时创建 - * - netTask 在 ENABLE_HW_LAYER_TESTS 下入口即自挂起(不干活), - * 不要求其心跳,否则该构建永不喂狗 - * 注:ftpTask 当前统一纳入(其主循环为 lftpd 事件循环,需在 - * lftpd 侧埋点;若某构建下 ftpTask 不创建,句柄检查兜底)。 + * - testTask 仅在启用测试套件时创建(其不置心跳位,任何构建均不 + * 入掩码;注意抽样为精确匹配,"会置位者"的位集合须恰等于掩码) + * - ENABLE_HW_LAYER_TESTS 下 netTask 入口自挂起(CH395F 由 testTask + * 独占)→ NET_READY 位永不置位 → ftpTask / uiTask / timeSyncTask + * 永久阻塞于屏障等待,但自 2026-10-02 起等待按 100ms 分片喂心跳 + * → 该构建"会置位者"= default + ftp + ui + timesync(netTask 的 + * 1s 挂起心跳无法持续供位,仍剔除;抽样为精确匹配,掩码必须恰等于 + * 会置位的任务集合)(2026-10-02 台架:旧版"仅剔 NET 位 + 等待者 + * 不置位"两种旧状态都会导致测试构建 IWDG 复位循环) + * - ftpTask 统一纳入(其主循环为 lftpd 事件循环):驱动侧节拍点 + * 调用弱钩子 lftpd_task_wd_beat()(见 lftpd.h),本文件提供强 + * 符号将其路由至 TASK_WD_FTP(2026-10-02 落地,台架 IWDG 复位 + * 循环的根因修复:此前 FTP 位无埋点,产品构建必需掩码永不满足) */ static uint32_t wd_required_mask(void) { uint32_t m = (1u << (uint32_t)TASK_WD_DEFAULT) @@ -88,14 +191,188 @@ static uint32_t wd_required_mask(void) { | (1u << (uint32_t)TASK_WD_UI) | (1u << (uint32_t)TASK_WD_TIMESYNC); - /* netTask 自挂起(CH395F 由 testTask 独占):不要求心跳 */ #if defined(ENABLE_HW_LAYER_TESTS) - m &= ~(1u << (uint32_t)TASK_WD_NET); + /* 见函数说明:CH395F 硬件层测试构建 = default + 屏障等待者(等待分片喂位) */ + m = (1u << (uint32_t)TASK_WD_DEFAULT) + | (1u << (uint32_t)TASK_WD_FTP) + | (1u << (uint32_t)TASK_WD_UI) + | (1u << (uint32_t)TASK_WD_TIMESYNC); #endif return m; } +#ifndef TASK_WATCHDOG_UNIT_TEST +/* + * 函数功能:记录喂狗时刻并累计最大喂狗间隔(round 7 纯取证) + * 入口参数:无 + * 返回值:无 + * 限定条件:由 wd_platform_feed() 内调用(任意喂狗路径均经过) + * 函数说明:直接量化"停喂前喂狗节奏是否已变慢"——若 s_max_feed_gap 在停喂 + * 前已 > 100ms,则属节奏性饥饿(喂狗周期被拉长)而非硬停摆。 + * 只更新观测变量,不参与判定(判定状态机逐字不变)。 + */ +static void wd_feed_track(void) { + uint32_t now = wd_platform_tick_ms(); + + if (s_feed_seen != 0u) { + uint32_t gap = now - s_last_feed_tick; /* 无符号差:回绕安全 */ + if (gap > s_max_feed_gap) { + s_max_feed_gap = gap; + } + } + s_last_feed_tick = now; + s_feed_seen = 1u; +} + +/* + * 函数功能:把停喂现场落盘到 /log/wdev.log(round 7 纯取证) + * 入口参数:snapshot - 本次抽样到的位图 uint32_t + * 返回值:无 + * 限定条件:task_wd_poll 停喂分支(defaultTask 上下文) + * 函数说明:一行内含 snap/req(本次与必需掩码)、h(近 8 次抽样历史,定位 + * 缺席位)、mg(最大喂狗间隔)、cf(console tx_fault 僵尸通道计数)。 + * 经 sys_log_evidence 落盘(内部含 flush 诱导);只取证,不改判定。 + * 串口 stall 行经僵尸通道可能丢失,故此处独立于串口留盘上证据。 + */ +static void wd_stall_evidence(uint32_t snapshot) { + char hbuf[(WD_SNAP_HIST_DEPTH * 9u) + 1u]; + char line[192]; + unsigned i; + unsigned o = 0u; + int n; + + /* 按时间顺序拼接历史(环形起点 = 最旧一条) */ + for (i = 0u; i < WD_SNAP_HIST_DEPTH; i++) { + uint32_t v = s_snap_hist[(s_snap_hist_idx + i) % WD_SNAP_HIST_DEPTH]; + o += (unsigned)snprintf(hbuf + o, sizeof(hbuf) - o, + (i == 0u) ? "%08X" : ",%08X", (unsigned)v); + } + n = snprintf(line, sizeof(line), + "STALL snap=0x%08X req=0x%08X h=%s mg=%lu cf=%lu seg=%lu", + (unsigned)snapshot, (unsigned)wd_required_mask(), hbuf, + (unsigned long)s_max_feed_gap, + (unsigned long)dbg_console_tx_fault(), + (unsigned long)s_wd_seg_trail[1]); + if (n > 0) { + /* 裸 UART 直发(不经环形缓冲/不依赖 defaultTask 排空):停喂窗口里 + * 环形缓冲的排空本身可能停摆,裸发是本行证据的可靠通道(与盘上 + * 证据互补——卷锁被阻塞方持有时盘上通道会失败,见 §0.5.4 判据缺口) */ + dbg_console_write_raw(line, (unsigned)strlen(line)); + dbg_console_write_raw("\r\n", 2u); + (void)sys_log_evidence(line); + } +} + +/* ==================== round 8 取证:复位不丢的"进段打点" ==================== + * 见 task_watchdog.h 的说明与 实机测试规划.md §0.5.4 round 7 结论(假设 2: + * poller 自身停摆 → 需定位 defaultTask 主循环死在哪个调用点)。 + * 槽放 .noinit(scatter UNINIT_CCM):IWDG/引脚复位不改变 SRAM,复位后仍可读。 */ + +/* + * 函数功能:记录"即将进入的主循环段"到复位不丢的 .noinit 槽(round 8 取证) + * 入口参数:seg - 段号(task_wd_seg_t) uint32_t + * 返回值:无 + * 限定条件:defaultTask 主循环调用(任意上下文亦可,纯 SRAM 写) + * 函数说明:写 [magic, 段号, 当前 ms, 循环序号];复位后由 task_wd_seg_report() + * 读出"上一轮最后进段"。纯 4 字写,无阻塞、无内核对象依赖。 + */ +void task_wd_seg(uint32_t seg) { + s_wd_seg_trail[0] = WD_SEG_MAGIC; + s_wd_seg_trail[1] = seg; + s_wd_seg_trail[2] = wd_platform_tick_ms(); + s_wd_seg_trail[3] = s_wd_seg_trail[3] + 1u; +} + +/* + * 函数功能:开机快照上一轮的"时间源存活性"(round 8b 取证) + * 入口参数:无 + * 返回值:无 + * 限定条件:**必须在 HAL_Init() 之前调用**(main() 的 USER CODE BEGIN 1) + * 函数说明:把 s_wd_tick_cur(上一轮的终值,此刻尚未被本轮的 TIM7 中断覆盖) + * 搬到 s_wd_tick_prev,再把 cur 清零开始本轮累积。 + * ⚠ 时序是本函数存在的唯一理由:TIM7 在 HAL_Init 里即开始 1ms 中断, + * 而开机上报要到 app_main_init(≈117ms)才跑——不先搬走,上一轮终值 + * 会被本轮开机瞬间的 tick 覆盖(2026-10-02 台架实测踩坑)。 + */ +void task_wd_tick_snapshot(void) { + if (s_wd_tick_cur[0] == WD_TICK_MAGIC) { + s_wd_tick_prev[0] = WD_TICK_MAGIC; + s_wd_tick_prev[1] = s_wd_tick_cur[1]; /* 上一轮最后一次 uwTick */ + s_wd_tick_prev[2] = s_wd_tick_cur[2]; /* 上一轮 TIM7 中断总数 */ + s_wd_tick_prev[3] = s_wd_tick_cur[3]; + } else { + s_wd_tick_prev[0] = 0u; /* 上电首启:无上一轮数据 */ + } + s_wd_tick_cur[0] = 0u; /* 本轮重新累积 */ + s_wd_tick_cur[1] = 0u; + s_wd_tick_cur[2] = 0u; + s_wd_tick_cur[3] = 0u; +} + +/* + * 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(round 8b 取证) + * 入口参数:无 + * 返回值:无 + * 限定条件:TIM7 中断上下文调用(HAL_TIM_PeriodElapsedCallback 内,每 1ms) + * 函数说明:更新 s_wd_tick_cur = [magic, uwTick, 本轮中断计数, 0]。纯 3 字 SRAM 写 + * + 读 uwTick,**不调用任何 FreeRTOS/CMSIS-RTOS API**——TIM7 在 HAL_Init + * 阶段即开始中断,远早于 xPortStartScheduler(),此时任何 ISR 版内核 API + * 都会触发 port.c 的优先级断言(ulMaxPRIGROUPValue 尚为 0)并把系统卡死 + * 在"USART1 未初始化"的断言打印路径上(2026-10-02 台架实测根因)。 + */ +void task_wd_tick_probe(void) { + s_wd_tick_cur[0] = WD_TICK_MAGIC; + s_wd_tick_cur[1] = wd_platform_tick_ms(); + s_wd_tick_cur[2] = s_wd_tick_cur[2] + 1u; +} + +/* + * 函数功能:开机上报上一轮最后进段 + 时间源存活性(round 8 取证) + * 入口参数:无 + * 返回值:无 + * 限定条件:启动早期调用(内核启动前亦可,裸 UART 直发) + * 函数说明:magic 命中才打一行 `PREVRUN seg=<段> seq=<序号> t=`(裸 UART, + * 不依赖环形缓冲写者),随后清 magic(一次复位只报一次)。 + * round 8b:紧接着打 `TICKRUN uw= n= duw=`——uw 为 TIM7 最后一次记下的 + * HAL tick、n 为 TIM7 中断计数、duw = uw - t(时间源是否活到复位)。 + */ +void task_wd_seg_report(void) { + char m[128]; + int n; + uint32_t t_seg; + + if (s_wd_seg_trail[0] != WD_SEG_MAGIC) { + return; /* 上电首启或已被清:无上一轮轨迹 */ + } + t_seg = s_wd_seg_trail[2]; + /* 裸 UART 直发:启动早期环形缓冲写者尚未注册,不能走 DBG_* */ + n = snprintf(m, sizeof(m), "PREVRUN seg=%lu seq=%lu t=%lu\r\n", + (unsigned long)s_wd_seg_trail[1], + (unsigned long)s_wd_seg_trail[3], + (unsigned long)t_seg); + if (n > 0) { + dbg_console_write_raw(m, (unsigned)strlen(m)); + } + s_wd_seg_trail[0] = 0u; /* 一次复位只报一次 */ + + /* round 8b:时间源存活性(取自开机快照 prev,增量相对上一行的 t = 最后进段时刻)。 + * duw≈0 → TIM7 与 defaultTask 同刻停摆(HAL 时基停摆);duw≈2000+ → TIM7 活到 + * 复位(冻结另有阻塞点);n = 上一轮 TIM7 中断总数(≈该轮存活毫秒数)。 */ + if (s_wd_tick_prev[0] == WD_TICK_MAGIC) { + uint32_t uw = s_wd_tick_prev[1]; + + n = snprintf(m, sizeof(m), "TICKRUN uw=%lu n=%lu duw=%lu\r\n", + (unsigned long)uw, + (unsigned long)s_wd_tick_prev[2], + (unsigned long)(uint32_t)(uw - t_seg)); + if (n > 0) { + dbg_console_write_raw(m, (unsigned)strlen(m)); + } + } +} +#endif /* !TASK_WATCHDOG_UNIT_TEST */ + /* Public functions ----------------------------------------------------------*/ /* @@ -116,10 +393,13 @@ void task_wd_beat(task_wd_id_t id) { * 入口参数:无 * 返回值:无 * 限定条件:仅 defaultTask 调用;须在 osKernelStart 之后(本函数只用 HAL/寄存器) - * 函数说明:MX_IWDG_Init() 已把参数填入全局 hiwdg(Prescaler/Reload 由 CubeMX - * 在 Src/iwdg.c 生成区维护),但不启动(见该文件 IWDG_DEFER_START 注释)。 - * 此处才真正写 IWDG_Key 启动——使"启动段不喂狗"成立(§4.5)。 - * IWDG 一旦启动不可停,只能靠复位终止。 + * 函数说明:MX_IWDG_Init() 只把参数填进全局 hiwdg(Prescaler/Reload 由 CubeMX + * 在 Src/iwdg.c 生成区维护),**既不写寄存器也不启动**(见该文件 + * IWDG-DEFER-START-ANCHOR 注释)。此处才真正启动——使"启动段不喂狗" + * 成立(§4.5)。IWDG 一旦启动不可停,只能靠复位终止。 + * ⚠ PR/RLR 的写入由 wd_platform_start() 一并完成(v1.4 修复): + * 删掉 HAL_IWDG_Init() 后它是唯一写这两只寄存器的路径,否则寄存器 + * 停在复位默认值、超时只有 ~400ms(详见该函数说明)。 */ void task_wd_start(void) { if (s_started != 0u) { @@ -164,6 +444,12 @@ void task_wd_poll(void) { s_hb_bits = 0u; /* 清:语义 = "自上次抽样以来谁动过" */ s_last_mask = snapshot; +#ifndef TASK_WATCHDOG_UNIT_TEST + /* round 7 取证:留存近 8 次抽样位图(环形),停喂时随证据落盘 */ + s_snap_hist[s_snap_hist_idx] = snapshot; + s_snap_hist_idx = (s_snap_hist_idx + 1u) % WD_SNAP_HIST_DEPTH; +#endif + if (snapshot == wd_required_mask()) { s_miss_streak = 0u; wd_platform_feed(); /* 全员健康:喂狗 */ @@ -176,6 +462,21 @@ void task_wd_poll(void) { s_miss_streak++; } +#ifndef TASK_WATCHDOG_UNIT_TEST + if (s_miss_streak == 1u) { + /* 停喂起点裸 UART 标记(每次缺席 episode 仅一条):证明 poller 仍在 + * 抽样、并给出**首次**缺席的位图——定位是哪一任务先停摆(round 7) */ + char m[96]; + int mn = snprintf(m, sizeof(m), + "MISS start snap=0x%08X req=0x%08X t=%lu\r\n", + (unsigned)snapshot, (unsigned)wd_required_mask(), + (unsigned long)now); + if (mn > 0) { + dbg_console_write_raw(m, (unsigned)strlen(m)); + } + } +#endif + if (s_miss_streak >= WD_MISS_TOLERANCE) { /* 连续 800ms 有任务未动过 → 已停喂 → 约 2.5s 后 IWDG 复位。 * 最后遗言走裸 UART(不依赖内核、不遍历指针、有界长度)。 */ @@ -183,6 +484,11 @@ void task_wd_poll(void) { (unsigned)snapshot, (unsigned)wd_required_mask(), (unsigned long)s_miss_total); +#ifndef TASK_WATCHDOG_UNIT_TEST + /* round 7 取证:把现场落 /log/wdev.log(掉电不丢)。 + * 串口 stall 行经僵尸通道可能丢失,故此处独立留盘上证据。 */ + wd_stall_evidence(snapshot); +#endif } } @@ -224,20 +530,57 @@ uint32_t task_wd_is_armed(void) { * 函数功能:重装 IWDG 计数器(喂狗)——MCU 端默认实现 * 入口参数:无 * 返回值:无 - * 函数说明:写 IWDG->KR = 0xAAAA。100ms 级高频路径,故不经 HAL 封装。 + * 函数说明:写 IWDG->KR = WD_IWDG_KEY_FEED(0xAAAA)。100ms 级高频路径, + * 故不经 HAL 封装。 */ __weak void wd_platform_feed(void) { - IWDG->KR = 0x0000AAAAu; /* 重装计数器(喂狗) */ + IWDG->KR = WD_IWDG_KEY_FEED; /* 重装计数器(喂狗) */ + wd_feed_track(); /* round 7 取证:记录喂狗时刻/最大间隔 */ } +/* ==== 为什么 IWDG 参数必须在这里写(2026-10-02 台架实测,P0 根因)==== + * `Src/iwdg.c` 为实现"延后启动"删掉了 `HAL_IWDG_Init()`(见该文件 + * IWDG-DEFER-START-ANCHOR),而 `HAL_IWDG_Init()` 是**唯一**会把 + * `hiwdg.Init.Prescaler/Reload` 写进寄存器的路径——删掉后 `MX_IWDG_Init()` + * 只填结构体,PR/RLR 保持**复位默认值**(PR=0 → /4、RLR=4095)。 + * 实测读数:`IWDG->PR = 0`、`IWDG->RLR = 4095` → 实际超时 4×4096/LSI + * ≈ 349~512ms,比设计的 1701~2498ms **短约 5 倍**。后果有二: + * ① `WD_MISS_TOLERANCE`(8×100ms=800ms)**永远等不到**就被 IWDG 复位 + * → 停喂判定分支成为死代码(这正是 round 7 观察到"STALL 从未执行" + * 的原因,当时误判为"poller 自身停摆"); + * ② 任何 >400ms 的心跳缺口都直接 IWDG 误复位。空 STOR(0 字节截断) + * → dhara/FTL flush 使 NET/FTP/UI 心跳缺 ~400ms → 复位(round 3~8 + * 全部"空 STOR 必复位"的共同根因,P0-B 出口阻塞点)。 + * 实测验证:SWD 现场把 PR/RLR 改成 4/1249 后,同一个空 STOR 由 5/5 复位 + * 变为 100% 成功(`226 Closing data connection.`)。 + * ==================================================================== */ + /* - * 函数功能:启动 IWDG——MCU 端默认实现 + * 函数功能:按设计参数配置并启动 IWDG——MCU 端默认实现 * 入口参数:无 * 返回值:无 - * 函数说明:写 IWDG->KR = 0xCCCC(不可逆,之后只能靠复位停止)。 + * 函数说明:解锁 PR/RLR → 写 WD_IWDG_PRESCALER / WD_IWDG_RELOAD(/64 + 1249 + * → 1701~2498ms)→ 写 0xCCCC 启动(不可逆,只能靠复位停止)。 + * 为何必须在此配置、否则超时短 5 倍,见上方长注释。 + * ⚠ 顺序不可省:PR/RLR 有写保护,须先写 0x5555 解锁;每只寄存器写后要等 + * `SR.PVU`/`SR.RVU` 清零(配置同步到 LSI 域,≤5 个 LSI 周期),否则**后写 + * 的那只会被静默丢弃**(2026-10-02 SWD 实测:不等同步直接连写,RLR 未生效)。 */ __weak void wd_platform_start(void) { - IWDG->KR = 0x0000CCCCu; /* 启动看门狗(不可逆) */ + uint32_t spin; + + IWDG->KR = WD_IWDG_KEY_UNLOCK; /* 解锁 PR/RLR 写访问 */ + IWDG->PR = WD_IWDG_PRESCALER; /* 4 → /64 */ + spin = WD_IWDG_SYNC_SPIN; + while (((IWDG->SR & IWDG_SR_PVU) != 0u) && (spin != 0u)) { + spin--; /* 等 PVU 清零:PR 已同步到 LSI 域 */ + } + IWDG->RLR = WD_IWDG_RELOAD; /* 1249 */ + spin = WD_IWDG_SYNC_SPIN; + while (((IWDG->SR & IWDG_SR_RVU) != 0u) && (spin != 0u)) { + spin--; /* 等 RVU 清零:RLR 已同步到 LSI 域 */ + } + IWDG->KR = WD_IWDG_KEY_START; /* 启动看门狗(不可逆) */ } /* @@ -250,6 +593,91 @@ __weak uint32_t wd_platform_tick_ms(void) { return (uint32_t)HAL_GetTick(); } +/* + * 函数功能:lftpd 看门狗心跳钩子——MCU 端强符号实现 + * 入口参数:无 + * 返回值:无 + * 限定条件:无(IWDG 启动前置位只写位图,不影响判定状态机) + * 函数说明:ftpTask 主循环 = lftpd 事件循环 / 传输等待点(埋点见 lftpd.c / + * lftpd_inet.c);驱动层只调用弱钩子 lftpd_task_wd_beat() + * (声明于 lftpd.h,弱空实现于 lftpd_inet.c),在此路由到任务 + * 心跳位图的 FTP 位,使驱动层不向上依赖应用层(分层铁律)。 + * 强符号覆盖同名弱实现(与 wd_platform_* 钩子同机制)。 + * 背景:2026-10-02 台架实机——FTP 位无埋点 → 必需掩码永不满足 + * → IWDG 启动后 800ms 停喂 → 约每秒一次 IWDG 复位、无限重启, + * 设备无法启动(P0-B 基线测试失败根因)。 + */ +void lftpd_task_wd_beat(void) { + task_wd_beat(TASK_WD_FTP); +} + +/* FTL 心跳路由表:任务句柄(0 = 未登记),下标 = 任务 ID */ +static TaskHandle_t s_wd_bind[(uint32_t)TASK_WD_ID_MAX]; + +/* + * 函数功能:NAND FTL 看门狗心跳钩子 —— MCU 端强符号实现 + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文可调(内核启动前 / 未登记任务为空操作) + * 函数说明:gd5f_wait_busy 的长耗时轮询(块擦除等待最长 5 s、GC 页拷贝、 + * 日志写)都运行在调用者任务上下文(default/ftp/ui/timesync 等), + * 此处按 task_wd_bind() 登记的句柄路由到调用者任务的心跳位, + * 防止长耗时窗口心跳缺席导致 IWDG 误复位。 + * 背景:2026-10-02 台架——0 字节 STOR 截断 → dhara 日志块 GC + * (> 2 s,期间无埋点)→ FTP 位缺席 800 ms → 停喂 → IWDG 误复位。 + * testTask 刻意不绑定:它不在必需掩码内(抽样为精确匹配), + * 其上下文置位会引入多余位反而误杀。 + */ +void nand_ftl_wd_beat(void) { + TaskHandle_t h; + uint32_t i; + + h = xTaskGetCurrentTaskHandle(); + if (h == 0) { + return; /* 内核启动前:IWDG 未启动,无需心跳 */ + } + for (i = 0u; i < (uint32_t)TASK_WD_ID_MAX; i++) { + if ((s_wd_bind[i] != 0) && (s_wd_bind[i] == h)) { + task_wd_beat((task_wd_id_t)i); + return; + } + } + /* 未登记任务(如 testTask):不置任何位(精确匹配抽样禁多余位) */ +} + +/* + * 函数功能:把当前任务句柄登记进 FTL 心跳路由表 + * 入口参数:id - 任务 ID + * 返回值:无 + * 限定条件:任务上下文调用(内核启动后) + * 函数说明:default/net/ftp/ui/timesync 五任务入口各调一次; + * 每任务 ID 一个句柄槽,重复登记覆盖(幂等)。 + */ +void task_wd_bind(task_wd_id_t id) { + s_wd_bind[(uint32_t)id] = xTaskGetCurrentTaskHandle(); +} + +/* + * 函数功能:NAND FTL 看门狗直接喂狗钩子 —— MCU 端强符号实现 + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文可调(IWDG 未启动时为空操作) + * 函数说明:心跳钩子只维持心跳位,喂狗动作在 task_wd_poll(defaultTask 主 + * 循环)。长耗时 FTL 操作(GC/整卷格式化)发生在 defaultTask 上下文 + * 时,主循环阻塞在 FTL 调用内 → 喂狗点停摆 → 心跳位再新也无人喂 → + * IWDG 误复位(2026-10-02 台架:console tx fault 条目落盘触发 GC, + * poller 饥饿 ~2 s → IWDG 复位、无 stall 日志)。此处让 FTL 长路径 + * 在操作进行中直接写 IWDG->KR,把"喂狗点自身被阻塞"的误复位形态 + * 一并覆盖。语义边界(显式声明):NAND 侧真实卡死(wait_busy 自旋 + * 超时后 dhara 恢复重试再卡死)时本路径持续喂狗 = 设备存活但功能 + * 冻结(与 console ORE 自愈同类边界,V5 长跑观察)。 + */ +void nand_ftl_wd_feed(void) { + if (s_started != 0u) { + wd_platform_feed(); + } +} + #endif /* !TASK_WATCHDOG_UNIT_TEST */ /* Test-only hooks (compiled out unless TASK_WATCHDOG_UNIT_TEST) --------------*/ diff --git a/App/util/task_watchdog.h b/App/util/task_watchdog.h index b9add5de..eaffb7e3 100644 --- a/App/util/task_watchdog.h +++ b/App/util/task_watchdog.h @@ -42,6 +42,18 @@ typedef enum { */ void task_wd_beat(task_wd_id_t id); +/* + * 函数功能:把当前任务句柄登记进 FTL 心跳路由表(任务入口调用一次) + * 入口参数:id - 任务 ID + * 返回值:无 + * 限定条件:任务上下文调用(内核启动后) + * 函数说明:nand_ftl_wd_beat() 强符号(本模块)按此表把 FTL 长耗时操作 + * 的心跳路由到调用者任务的心跳位(见 gd5f2gq5ue.h 钩子说明)。 + * testTask 刻意不登记:它不在必需掩码内(抽样为精确匹配), + * 其上下文置位会引入多余位反而误杀。 + */ +void task_wd_bind(task_wd_id_t id); + /* * 函数功能:启动 IWDG(**延后启动**) * 入口参数:无 @@ -69,6 +81,85 @@ uint32_t task_wd_last_mask(void); /* 上次抽样到的位图(0 位 = 缺 uint32_t task_wd_miss_count(void); /* 累计未达标轮数 */ uint32_t task_wd_is_armed(void); /* 1 = IWDG 已启动 */ +/* + * ==================== round 8 取证:复位不丢的"进段打点" ==================== + * 背景(实机测试规划.md §0.5.4 round 7 结论):误复位时判定分支(STALL)从未 + * 执行 → **喂狗点 task_wd_poll 自身停摆**(假设 2:poller 被阻塞),而非某个被 + * 监视任务饿死。RAM 态随复位消失、串口又可能来不及打,故需要一条**复位后仍可读** + * 的"defaultTask 主循环最后走到哪一段"轨迹。 + * + * 实现:轨迹槽放在 .noinit(scatter 的 UNINIT 执行区,见 + * MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct 的 UNINIT_CCM)——IWDG/引脚复位 + * 不改变 SRAM 内容,故复位后仍能读出上一轮的最后进段。槽布局: + * [0]=magic(校验是否为本模块写过)[1]=段号 [2]=进段时刻(ms) [3]=循环序号 + * round 8b 追加"时间源存活性"槽(同样在 .noinit): + * cur = [magic, uwTick, 本轮 TIM7 计数, 0](TIM7 ISR 每 1ms 更新) + * prev = [magic, uwTick, 上一轮 TIM7 计数, 0](main() 在 HAL_Init 之前快照) + * 注意:**仅 MCU 构建存在**(TASK_WATCHDOG_UNIT_TEST 下不编入,主机单测不受影响)。 + * ========================================================================== + */ + +/* defaultTask 主循环"进段"编号(task_wd_seg 的入参;复位后从 PREVRUN 行读出) */ +typedef enum { + TASK_WD_SEG_IDLE = 0, /* 未进入任何段(初值) */ + TASK_WD_SEG_POLL = 1, /* task_wd_poll(喂狗判定) */ + TASK_WD_SEG_EVTWAIT = 2, /* osEventFlagsWait(50ms 兜底等待) */ + TASK_WD_SEG_DRAIN = 3, /* dbg_console_drain(控制台排空) */ + TASK_WD_SEG_CFGSAVE = 4, /* net_cfg_save_run(配置保存) */ + TASK_WD_SEG_CFGPOLL = 5, /* cfg_file_poll(2s 配置轮询) */ + TASK_WD_SEG_LED = 6, /* LED 心跳 */ + TASK_WD_SEG_SYSLOG = 7, /* syslog_drain(运行期日志落盘) */ + TASK_WD_SEG_STACK = 8 /* stack_probe_poll(栈水位观测) */ +} task_wd_seg_t; + +/* + * 函数功能:记录"即将进入的主循环段"(round 8 取证;**仅 MCU 构建**) + * 入口参数:seg - 段号(task_wd_seg_t) + * 返回值:无 + * 限定条件:defaultTask 主循环调用(任意上下文亦可,纯 SRAM 写) + * 函数说明:4 字写(magic/段/时刻/序号),进 .noinit 槽;复位不丢。 + */ +void task_wd_seg(uint32_t seg); + +/* + * 函数功能:开机上报上一轮最后进段 + 时间源存活性(round 8 取证;**仅 MCU 构建**) + * 入口参数:无 + * 返回值:无 + * 限定条件:启动早期调用(内核启动前亦可,裸 UART 直发) + * 函数说明:magic 命中才打一行 `PREVRUN seg=<段> seq=<序号> t=`(裸 UART, + * 不依赖环形缓冲写者),随后清 magic(一次复位只报一次)。 + * round 8b:紧接着打一行 `TICKRUN uw= n= duw=`——uw 为 TIM7 在**上一轮** + * 最后一次记下的 HAL tick、n 为上一轮 TIM7 中断总数、duw = uw - t + * (相对进段时刻的增量),据此判定 TIM7 是否在冻结同刻停摆。 + */ +void task_wd_seg_report(void); + +/* + * 函数功能:开机快照上一轮的"时间源存活性"(round 8b 取证;**仅 MCU 构建**) + * 入口参数:无 + * 返回值:无 + * 限定条件:**必须在 HAL_Init() 之前调用**(main() 的 USER CODE BEGIN 1) + * 函数说明:TIM7 在 HAL_Init 里即开始 1ms 中断,而开机上报要到 app_main_init + * (≈117ms)才跑;本函数把上一轮的终值先搬到 prev 槽再清空 cur, + * 否则终值会被本轮开机瞬间的 tick 覆盖(2026-10-02 台架实测踩坑)。 + */ +void task_wd_tick_snapshot(void); + +/* + * 函数功能:记录"时间源存活性"到复位不丢的 .noinit 槽(round 8b 取证;**仅 MCU 构建**) + * 入口参数:无 + * 返回值:无 + * 限定条件:TIM7 中断上下文调用(`HAL_TIM_PeriodElapsedCallback` 内,每 1ms) + * 函数说明:更新 cur 槽 = [magic, uwTick, 本轮 TIM7 中断计数, 0](纯 SRAM 写,无阻塞)。 + * 与 task_wd_seg 的"进段打点"配合,复位后由 task_wd_seg_report() 上报。 + * 背景(实机测试规划.md §0.5.4.3 第 2 条):HAL_UART_Transmit 的超时判据 + * 依赖 uwTick,时间源停摆即死等——必须先把"TIM7 是否停摆"钉死再定向修复。 + * ⚠ **本函数内严禁调用任何 FreeRTOS/CMSIS-RTOS API**:TIM7 在 HAL_Init + * 阶段即开始中断(远早于 xPortStartScheduler),此时 ISR 版内核 API 会触发 + * port.c 的优先级断言并把系统卡死在 TIM7 ISR 内(2026-10-02 台架实测根因)。 + */ +void task_wd_tick_probe(void); + /* * ============================ 平台后端(内部) ============================ * 把判定状态机中的硬件访问收敛到三个薄钩子,使"取走清零 / 掩码判定 / 缺失 @@ -94,9 +185,15 @@ uint32_t task_wd_is_armed(void); /* 1 = IWDG 已启动 */ void wd_platform_feed(void); /* - * 函数功能:启动 IWDG(不可逆,启动后只能靠复位停止) + * 函数功能:按设计参数配置并启动 IWDG(不可逆,启动后只能靠复位停止) * 返回值:无 - * 函数说明:MCU 默认实现 = `IWDG->KR = 0x0000CCCCu;` + * 函数说明:MCU 默认实现 = 解锁 PR/RLR → 写 WD_IWDG_PRESCALER/WD_IWDG_RELOAD + * (/64 + 1249 → 1701~2498ms,等 SR.PVU/RVU 同步)→ `IWDG->KR = 0xCCCC`。 + * ⚠ 配置**必须**在此完成:`Src/iwdg.c` 为实现延后启动删掉了 + * `HAL_IWDG_Init()`,而它是唯一把 `hiwdg.Init.*` 写进寄存器的路径; + * 缺了这一步 PR/RLR 停在复位默认值(PR=0/RLR=4095 ≈ 400ms), + * 超时比设计短约 5 倍 → 800ms 容忍分支成死代码、易 IWDG 误复位。 + * 主机侧单测以强符号覆盖本钩子(不碰寄存器),故其行为不受影响。 */ void wd_platform_start(void); @@ -108,6 +205,28 @@ void wd_platform_start(void); */ uint32_t wd_platform_tick_ms(void); +/* + * 函数功能:lftpd 看门狗心跳钩子(**本模块提供强符号实现**) + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文(IWDG 启动前只置位图,不影响判定) + * 函数说明:驱动层(lftpd.c / lftpd_inet.c 的节拍点)只调用弱钩子,本模块以 + * 强符号覆盖并路由到 TASK_WD_FTP 心跳位——驱动层不向上依赖应用层 + * (分层铁律)。声明于 lftpd.h 供驱动侧引用,此处为"本模块提供"侧的声明。 + */ +void lftpd_task_wd_beat(void); + +/* + * 函数功能:NAND FTL 直接喂狗钩子(**本模块提供强符号实现**) + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文(IWDG 未启动时为空操作) + * 函数说明:FTL 长耗时路径(GC/块擦除等待)在调用者任务上下文**直接写 IWDG->KR**, + * 覆盖"喂狗点(defaultTask 主循环)自身被长 I/O 阻塞"的误复位形态。 + * 声明于 gd5f2gq5ue.h 供驱动侧引用,此处为"本模块提供"侧的声明。 + */ +void nand_ftl_wd_feed(void); + /* * 函数功能:复位判定状态机(**仅主机侧单元测试使用**) * 返回值:无 diff --git a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c index 64e7cb26..f4eadb0c 100644 --- a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c +++ b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c @@ -144,11 +144,26 @@ static int gd5f_dma_buf_ok(const void *p_buf, size_t size) { * 限定条件:SPI 已初始化 * 函数说明:循环读取状态寄存器直到 OIP 位清零或超时 */ +/* 看门狗心跳弱钩子:默认空实现(无看门狗/模拟器构建);强符号由 + * App/util/task_watchdog.c 提供,路由到调用者任务的心跳位 */ +__weak void nand_ftl_wd_beat(void) { +} + +/* 看门狗直接喂狗弱钩子:默认空实现;强符号由 App/util/task_watchdog.c + * 提供(IWDG 已启动时写 KR)——覆盖 defaultTask(喂狗点)自身陷于长 FTL + * 操作、主循环喂狗停摆的形态(2026-10-02 台架实证) */ +__weak void nand_ftl_wd_feed(void) { +} + int gd5f_wait_busy(uint32_t timeout_ms) { uint8_t cmd = GD5F_CMD_GET_FEATURE; uint8_t addr = GD5F_REG_STATUS; uint8_t status = 0; uint32_t tick_start = HAL_GetTick(); + uint32_t last_beat = tick_start; + + nand_ftl_wd_beat(); /* 看门狗:入口心跳(长操作在调用者任务上下文;内核前调用为空操作) */ + nand_ftl_wd_feed(); /* 看门狗:入口直接喂狗(覆盖 defaultTask 长 I/O 阻塞主循环喂狗点的形态) */ while (1) { GD5F_CS_LOW(); @@ -163,6 +178,11 @@ int gd5f_wait_busy(uint32_t timeout_ms) { if ((HAL_GetTick() - tick_start) >= timeout_ms) { return GD5F_BUSY_TIMEOUT; } + if ((uint32_t)(HAL_GetTick() - last_beat) >= 100u) { + last_beat = HAL_GetTick(); + nand_ftl_wd_beat(); /* 看门狗:循环内每 100 ms 心跳(擦除等待最长 5 s) */ + nand_ftl_wd_feed(); /* 看门狗:同步直接喂狗(同节奏) */ + } } } diff --git a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h index 884e03ad..8bb87c37 100644 --- a/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h +++ b/Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h @@ -247,6 +247,36 @@ int32_t gd5f_program_exec(uint32_t page_addr); int32_t gd5f_block_erase(uint32_t block_addr); int32_t gd5f_check_ecc(void); +/* + * 函数功能:NAND 长耗时操作看门狗心跳弱钩子 + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文可调(内核启动前调用为空操作) + * 函数说明:gd5f_wait_busy 的长轮询(块擦除等待最长 5 s、GC 页拷贝、 + * 日志写)都运行在调用者任务上下文(default/ftp/ui/timesync 等); + * 本钩子默认空实现,App/util/task_watchdog.c 提供强符号,按调用者 + * 任务句柄路由到该任务的心跳位,防止长耗时窗口心跳缺席导致 IWDG + * 误复位(2026-10-02 台架:0 字节 STOR 截断 → dhara GC > 800 ms + * 无心跳 → 停喂 → IWDG 误复位)。 + */ +void nand_ftl_wd_beat(void); + +/* + * 函数功能:NAND 长耗时操作看门狗直接喂狗弱钩子 + * 入口参数:无 + * 返回值:无 + * 限定条件:任意上下文可调(内核启动前 / IWDG 未启动时为空操作) + * 函数说明:心跳钩子(nand_ftl_wd_beat)只能维持"心跳位",喂狗动作本身在 + * defaultTask 主循环(task_wd_poll)——若长耗时 FTL 操作(GC/整卷 + * 格式化)发生在 defaultTask 上下文,主循环被阻塞在 FTL 调用里, + * 喂狗点停摆 → 心跳位再新也无人喂 → IWDG 误复位(2026-10-02 台架 + * 实证:console tx fault 条目落盘触发 GC,poller 饥饿 ~2 s → IWDG + * 复位、无任何 stall 日志)。本钩子让 FTL 长路径在操作进行中直接 + * 喂狗,覆盖"喂狗点自身被长 I/O 阻塞"的形态。强符号由 + * App/util/task_watchdog.c 提供(仅 IWDG 已启动时生效)。 + */ +void nand_ftl_wd_feed(void); + #ifdef __cplusplus } #endif diff --git a/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c b/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c index a59d998a..2a6c059f 100644 --- a/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c +++ b/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c @@ -8,6 +8,10 @@ * 2026-07-20 王建锋 创建初始版本 * 2026-09-21 E-31 resume 失败按错误分类:仅真空白判新卷,其余失败 * 返回 mount 层(禁止静默清 map 触发整卷重格式化) + * 2026-10-02 王建锋 resume 成功后增打 journal head/tail/epoch:复盘 + * "复位后所有写入丢失"时 sectors used 不足以判定 + * journal 回退(覆盖写不改 count),head/tail/epoch 是 + * 唯一能暴露 resume 锚错 checkpoint 的证据 */ /* 头文件包含区 */ @@ -245,6 +249,15 @@ DSTATUS disk_initialize(BYTE pdrv) { } else { DBG_INFO("Map resumed: %lu/%lu sectors used", dhara_map_size(&s_map), dhara_map_capacity(&s_map)); + /* journal 头/尾/epoch 一并打印:2026-10-02 台架复盘"复位后写入全部 + * 丢失"时,只看 sectors used 判断不出 journal 是否回退(覆盖写场景 + * count 不变),必须看 head/tail/epoch 才能发现 resume 锚错了 + * checkpoint(详见 Lib/dhara/journal.c 的 2026-10-02 本地修复注释)。 + * 纯内存字段、零 I/O,保留作长期可观测性。 */ + DBG_INFO("Map head=%lu tail=%lu epoch=%u", + (unsigned long)s_map.journal.head, + (unsigned long)s_map.journal.tail, + (unsigned)s_map.journal.epoch); } } diff --git a/Drivers/BSP/NET/lftpd/lftpd.c b/Drivers/BSP/NET/lftpd/lftpd.c index 2b9cee69..d2f404d2 100644 --- a/Drivers/BSP/NET/lftpd/lftpd.c +++ b/Drivers/BSP/NET/lftpd/lftpd.c @@ -159,6 +159,7 @@ static int send_file(int socket, const char *path) int read_len; while ((read_len = lftpd_io_read(fh, buffer, sizeof(buffer))) > 0) { + lftpd_task_wd_beat(); /* 看门狗:每分片喂心跳(文件传输分片处,RETR) */ unsigned char *p = buffer; while (read_len > 0) { int write_len = lftpd_inet_write(socket, p, read_len); @@ -189,6 +190,7 @@ static int receive_file(int socket, const char *path) int total = 0; while ((err = lftpd_inet_read(socket, buffer, sizeof(buffer))) > 0) { + lftpd_task_wd_beat(); /* 看门狗:每分片喂心跳(文件传输分片处,STOR) */ total += err; if (lftpd_io_write(fh, buffer, err) != err) { err = -1; @@ -204,11 +206,8 @@ static int receive_file(int socket, const char *path) lftpd_io_unlink(path); return err; } - if (total == 0) { - lftpd_log_error("received 0 bytes"); - lftpd_io_unlink(path); - return -1; - } + /* EOF 正常结束:total==0 是合法空文件上传(如以空内容 STOR 清台账), + * 回报 226 成功(2026-10-02 台架:0 字节 STOR 曾被误判故障并删文件) */ return 0; } @@ -642,12 +641,20 @@ int lftpd_start(const char *directory, int port, lftpd_t *lftpd) lftpd->server_socket = -1; /* memset 清零后显式置"无监听器"(0 是合法 fd) */ while (true) { + lftpd_task_wd_beat(); /* 看门狗:事件循环每轮喂心跳(ftpTask 主循环节拍点) */ if (lftpd->server_socket < 0) { /* 首次启动 / 网络重建后:全量创建监听器(socket+bind+listen) */ lftpd->server_socket = lftpd_inet_listen(port); if (lftpd->server_socket < 0) { lftpd_log_error("error creating listener"); - vTaskDelay(pdMS_TO_TICKS(3000)); + /* 看门狗:3s 重试等待拆短片喂心跳(单次 3s 挂起超 IWDG 最坏窗 + * ~1.7s → 误复位;与 time_sync.c 监听重试同模式) */ + uint32_t wd_left = 3000U; + while (wd_left >= LFTPD_WAIT_SLICE_MS) { + lftpd_task_wd_beat(); + vTaskDelay(pdMS_TO_TICKS(LFTPD_WAIT_SLICE_MS)); + wd_left -= LFTPD_WAIT_SLICE_MS; + } continue; } } diff --git a/Drivers/BSP/NET/lftpd/lftpd.h b/Drivers/BSP/NET/lftpd/lftpd.h index f2ec057c..6dbc54c6 100644 --- a/Drivers/BSP/NET/lftpd/lftpd.h +++ b/Drivers/BSP/NET/lftpd/lftpd.h @@ -17,3 +17,13 @@ typedef struct { int lftpd_start(const char *directory, int port, lftpd_t *lftpd); int lftpd_stop(lftpd_t *lftpd); + +/* + * 看门狗心跳钩子(弱符号,默认空实现见 lftpd_inet.c): + * lftpd 事件循环与长等待点周期调用,为 ftpTask 喂任务心跳; + * 应用层(App/util/task_watchdog.c)提供强符号路由到 + * task_wd_beat(TASK_WD_FTP),驱动层因此不向上依赖应用层 + * (分层铁律);未接线的构建(如模拟器)空实现生效、行为不变。 + * 2026-10-02 台架:FTP 心跳缺失致 IWDG 复位循环,本钩子为修复的一部分。 + */ +void lftpd_task_wd_beat(void); diff --git a/Drivers/BSP/NET/lftpd/lftpd_inet.c b/Drivers/BSP/NET/lftpd/lftpd_inet.c index f8a18e0b..109e2bbc 100644 --- a/Drivers/BSP/NET/lftpd/lftpd_inet.c +++ b/Drivers/BSP/NET/lftpd/lftpd_inet.c @@ -19,11 +19,25 @@ */ #define LFTPD_TOTAL_BUDGET_MS 30000U +/* + * 看门狗心跳钩子弱默认实现:空(未接应用层的构建——如模拟器——不喂心跳, + * 看门狗为 MCU 构建专属概念)。MCU 构建下 App/util/task_watchdog.c 提供 + * 同名强符号(路由到 task_wd_beat(TASK_WD_FTP)),链接器取强符号 + * (与 wd_platform_* 钩子同机制)。 + */ +#if defined(__CC_ARM) +__weak +#endif +void lftpd_task_wd_beat(void) +{ +} + int lftpd_inet_listen(int port) { int retry; for (retry = 0; retry < 5; retry++) { + lftpd_task_wd_beat(); /* 看门狗:每轮喂心跳(重试间隔 500ms 在 800ms 容差内) */ int s = net_socket(NET_AF_INET, NET_SOCK_STREAM); if (s < 0) { if (retry == 0) DBG_ERROR("create socket"); @@ -84,6 +98,7 @@ int lftpd_inet_accept(int socket) DBG_ERROR("network re-init: abort accept, relisten"); return -1; } + lftpd_task_wd_beat(); /* 看门狗:自旋轮次喂心跳(无限等待,可能长时间无客户端) */ vTaskDelay(pdMS_TO_TICKS(100)); } } @@ -103,6 +118,7 @@ int lftpd_inet_accept_timeout(int socket, int timeout_ms) DBG_ERROR("data socket closed, accept aborted"); return -1; } + lftpd_task_wd_beat(); /* 看门狗:每轮喂心跳(数据连接等待最长 5s) */ vTaskDelay(pdMS_TO_TICKS(100)); elapsed += 100; } @@ -127,6 +143,10 @@ int lftpd_inet_read_line(int socket, char *buffer, size_t buffer_len) if (rem == 0U) { return -1; /* 总预算耗尽(语义同旧 30s 超时) */ } + lftpd_task_wd_beat(); /* 看门狗:每片前喂心跳(长等待无数据期保持节奏) */ + if (rem > LFTPD_WAIT_SLICE_MS) { + rem = LFTPD_WAIT_SLICE_MS; /* 分片:单次 30s 挂起拆为 ≤200ms 片 */ + } int read_len = net_recv_to(socket, buffer + total_read_len, (int)(buffer_len - total_read_len - 1), @@ -208,6 +228,10 @@ int lftpd_inet_read(int socket, void *buf, int len) if (rem == 0U) { return -1; /* 总预算耗尽(语义同旧 30s 超时) */ } + lftpd_task_wd_beat(); /* 看门狗:每片前喂心跳(长等待无数据期保持节奏) */ + if (rem > LFTPD_WAIT_SLICE_MS) { + rem = LFTPD_WAIT_SLICE_MS; /* 分片:单次 30s 挂起拆为 ≤200ms 片 */ + } int n = net_recv_to(socket, buf, len, rem); if (n >= 0) { return n; /* >0 数据 / 0 EOF */ diff --git a/Drivers/BSP/NET/lftpd/lftpd_io.c b/Drivers/BSP/NET/lftpd/lftpd_io.c index 7b7168de..f91e386e 100644 --- a/Drivers/BSP/NET/lftpd/lftpd_io.c +++ b/Drivers/BSP/NET/lftpd/lftpd_io.c @@ -7,6 +7,8 @@ #include "ff.h" #include "private/lftpd_io.h" +#include "lftpd.h" +#include "vol_lock.h" #define DBG_TAG "[LFT_IO]" #include "dbg_log.h" @@ -32,6 +34,24 @@ static const char *to_fatfs_path(const char *path) static int s_file_open = 0; static FIL s_ftp_file; +/* + * 卷锁获取(分片阻塞 + 心跳):FatFs/FTL 卷状态非线程安全,文件操作序列 + * 必须与 defaultTask 的 syslog/配置盘写互斥(2026-10-02 台架实证:并发 + * FTL 写 → dhara journal 竞争 → 长耗时恢复旋转饥饿 defaultTask 主循环 + * (看门狗喂狗点)→ IWDG 误复位)。等待按 100ms 分片、每片置 FTP 心跳位 + * (与就绪屏障等待同模式),防长等待造成 FTP 心跳位缺席误复位。 + * 持有约定:open...close / opendir...closedir 全程持锁;lftpd_io_read/ + * write 假定锁已由 open 持有;stat/unlink 自行短持。 + */ +static void lftpd_io_vol_lock_take(void) { + for (;;) { + if (vol_lock_wait(100u) != 0) { + return; + } + lftpd_task_wd_beat(); /* 看门狗:分片心跳(卷锁等待中) */ + } +} + char *lftpd_io_canonicalize_path(const char *base, const char *name) { if (base == NULL) { @@ -100,10 +120,12 @@ int lftpd_io_open_read(const char *path) DBG_ERROR("open_read '%s': already open", path); return -1; } + lftpd_io_vol_lock_take(); const char *fpath = to_fatfs_path(path); FRESULT fr = f_open(&s_ftp_file, fpath, FA_READ); if (fr != FR_OK) { DBG_ERROR("open_read '%s': f_open err=%d", fpath, (int)fr); + vol_lock_release(); return -1; } s_file_open = 1; @@ -116,10 +138,12 @@ int lftpd_io_open_write(const char *path) DBG_ERROR("open_write '%s': already open", path); return -1; } + lftpd_io_vol_lock_take(); const char *fpath = to_fatfs_path(path); FRESULT fr = f_open(&s_ftp_file, fpath, FA_WRITE | FA_CREATE_ALWAYS); if (fr != FR_OK) { DBG_ERROR("open_write '%s': f_open err=%d", fpath, (int)fr); + vol_lock_release(); return -1; } s_file_open = 1; @@ -155,6 +179,7 @@ void lftpd_io_close(int handle) if (s_file_open) { f_close(&s_ftp_file); s_file_open = 0; + vol_lock_release(); /* 卷锁:open...close 序列结束 */ } } @@ -163,7 +188,9 @@ int lftpd_io_stat(const char *path, uint32_t *p_size, int *p_is_dir) FILINFO fno; const char *fpath = to_fatfs_path(path); + lftpd_io_vol_lock_take(); if (f_stat(fpath, &fno) != FR_OK) { + vol_lock_release(); return -1; } @@ -174,15 +201,19 @@ int lftpd_io_stat(const char *path, uint32_t *p_size, int *p_is_dir) *p_is_dir = (fno.fattrib & AM_DIR) ? 1 : 0; } + vol_lock_release(); return 0; } int lftpd_io_unlink(const char *path) { const char *fpath = to_fatfs_path(path); + lftpd_io_vol_lock_take(); if (f_unlink(fpath) == FR_OK) { + vol_lock_release(); return 0; } + vol_lock_release(); return -1; } @@ -193,11 +224,13 @@ void *lftpd_io_opendir(const char *path) return NULL; } const char *fpath = to_fatfs_path(path); + lftpd_io_vol_lock_take(); if (f_opendir(dp, fpath) != FR_OK) { vPortFree(dp); + vol_lock_release(); return NULL; } - return (void *)dp; + return (void *)dp; /* 卷锁持有至 lftpd_io_closedir */ } int lftpd_io_readdir(void *dp, char *name, int name_max, uint32_t *p_size, int *p_is_dir) @@ -233,5 +266,6 @@ void lftpd_io_closedir(void *dp) if (dp != NULL) { f_closedir((DIR *)dp); vPortFree(dp); + vol_lock_release(); /* 卷锁:opendir...closedir 序列结束 */ } } diff --git a/Drivers/BSP/NET/lftpd/private/lftpd_inet.h b/Drivers/BSP/NET/lftpd/private/lftpd_inet.h index 88a155da..479e853e 100644 --- a/Drivers/BSP/NET/lftpd/private/lftpd_inet.h +++ b/Drivers/BSP/NET/lftpd/private/lftpd_inet.h @@ -3,6 +3,15 @@ #include #include +/* + * 看门狗分片等待:单次 net_recv_to 等待片长上限。须显著小于看门狗抽样窗 + * (100ms)的容错窗口(8 窗 = 800ms),200ms 留足余量;语义上仅把一次长 + * 等待拆成多次短等待,总预算不变(与 time_sync.c 同款,见看门狗设计说明 + * §4.4 长耗时白名单)。2026-10-02 台架:FTP 心跳缺失 + 30s 不分片等待 → + * IWDG 复位循环,本分片为修复的一部分。 + */ +#define LFTPD_WAIT_SLICE_MS 200U + int lftpd_inet_listen(int port); int lftpd_inet_get_socket_port(int socket); int lftpd_inet_accept(int socket); diff --git a/Lib/dhara/journal.c b/Lib/dhara/journal.c index d0214219..2ff36832 100644 --- a/Lib/dhara/journal.c +++ b/Lib/dhara/journal.c @@ -553,7 +553,7 @@ int dhara_journal_resume(struct dhara_journal *j, dhara_error_t *err) { dhara_block_t first, last, last_any; dhara_page_t last_group; - uint8_t first_epoch; + uint8_t first_epoch, last_epoch; /* Find the first checkpoint-containing block */ if (find_checkblock(j, 0, &first, err) < 0) { @@ -572,13 +572,42 @@ int dhara_journal_resume(struct dhara_journal *j, dhara_error_t *err) * lifetime on the next resume. Measured 2026-09-20: this made a * self-heal volume reformat vanish on every reset (infinite * reformat loop, no data persistence). + * + * 2026-10-02 (local fix): the previous anchor picked the checkpoint + * at the HIGHEST block address. That is only the newest when the + * ring has not wrapped. The ring writes sequentially and bumps the + * epoch at each wrap, so once it HAS wrapped the newest checkpoints + * live at the LOWEST addresses and the highest-address ones are + * leftovers from the previous pass (one epoch older). Anchoring on + * those made every resume roll the journal back to the pre-wrap + * state: measured on the bench, head went 96 -> 130752 and tail + * 115143 -> 115068 on every reset, so NOTHING written after the + * wrap (log records, config saves, FTP uploads) ever survived a + * reset. + * + * Both cases are decided by comparing the two candidate epochs, + * which are always adjacent because the ring wraps at most once + * per pass: + * - equal -> no wrap between them; either is fine. + * - eFirst == eLast + 1 -> the ring wrapped; the low-address + * checkpoints are the newest. + * - otherwise -> the low-address checkpoints are stale + * leftovers; the high ones are newest. */ last_any = find_last_checkblock_any(j, first); if (read_checkpage(j, last_any, err) < 0) { reset_journal(j); return -1; } - j->epoch = hdr_get_epoch(j->page_buf); + last_epoch = hdr_get_epoch(j->page_buf); + + if (first_epoch == last_epoch) { + j->epoch = first_epoch; + } else if ((uint8_t)(first_epoch - last_epoch) == 1u) { + j->epoch = first_epoch; + } else { + j->epoch = last_epoch; + } if (first_epoch == j->epoch) { /* Single-epoch ring: original code path, unchanged. */ diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 665d0a6c..0568effb 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -340,7 +340,7 @@ --diag_suppress=1,68,111,188,191,546,1293 USE_HAL_DRIVER,STM32F407xx,NDEBUG - ../Inc;../Drivers/STM32F4xx_HAL_Driver/Inc;../Drivers/STM32F4xx_HAL_Driver/Inc/Legacy;../Drivers/CMSIS/Device/ST/STM32F4xx/Include;../Drivers/CMSIS/Include;../Drivers/BSP;../Drivers/BSP/CH395F;../Drivers/BSP/GD5F2GQ5UE;../Drivers/BSP/TPAFE5160;../Drivers/BSP/SD2506;../Drivers/BSP/RS485;../Drivers/BSP/NET;../Drivers/BSP/NET/lftpd;../Drivers/BSP/NETCFG;../App;../App/app;../App/cfg;../App/log;../App/sync;../App/task;../App/util;../Middlewares/Third_Party/FreeRTOS/Source/include;../Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2;../Middlewares/Third_Party/FreeRTOS/Source/portable/RVDS/ARM_CM4F;../Lib/dhara;../Lib/FatFs;../test/board;../Lib/lvgl;../Drivers/BSP/UI;../Lib + ../Inc;../Drivers/STM32F4xx_HAL_Driver/Inc;../Drivers/STM32F4xx_HAL_Driver/Inc/Legacy;../Drivers/CMSIS/Device/ST/STM32F4xx/Include;../Drivers/CMSIS/Include;../Drivers/BSP;../Drivers/BSP/CH395F;../Drivers/BSP/GD5F2GQ5UE;../Drivers/BSP/TPAFE5160;../Drivers/BSP/SD2506;../Drivers/BSP/RS485;../Drivers/BSP/NET;../Drivers/BSP/NET/lftpd;../Drivers/BSP/NETCFG;../App;../App/app;../App/cfg;../App/fs;../App/log;../App/sync;../App/task;../App/util;../Middlewares/Third_Party/FreeRTOS/Source/include;../Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2;../Middlewares/Third_Party/FreeRTOS/Source/portable/RVDS/ARM_CM4F;../Lib/dhara;../Lib/FatFs;../test/board;../Lib/lvgl;../Drivers/BSP/UI;../Lib @@ -846,6 +846,11 @@ 1 ..\App\util\task_watchdog.c + + vol_lock.c + 1 + ..\App\fs\vol_lock.c + time_sync.c 1 diff --git a/MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct b/MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct index 51529b4e..b51cffcf 100644 --- a/MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct +++ b/MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct @@ -22,7 +22,10 @@ LR_IROM1 0x08000000 0x00100000 { ; load region size_region RW_IRAM1 0x20000000 0x00020000 { ; SRAM1+SRAM2, 128KB (DMA-safe zone) .ANY (+RW +ZI) } - RW_CCM 0x10000000 0x00010000 { ; CCM 64KB: FreeRTOS heap only (no DMA!) + RW_CCM 0x10000000 0x0000FF00 { ; CCM 64KB 减 256B 尾: FreeRTOS 堆 only (no DMA!) heap_4.o (+RW +ZI) ; ucHeap 48KB: 任务栈/队列/定时器 } + UNINIT_CCM 0x1000FF00 UNINIT 0x00000100 { ; round 8 取证: 复位不丢"进段打点"槽 + *(.noinit) ; IWDG/引脚复位不清 SRAM → 复位后仍可读 + } } diff --git a/Src/freertos.c b/Src/freertos.c index e005ef89..26d51acb 100644 --- a/Src/freertos.c +++ b/Src/freertos.c @@ -32,6 +32,7 @@ #include "dbg_log.h" #include "net_socket.h" #include "lftpd.h" +#include "task_watchdog.h" #include "../test/board/ch395f_test_task.h" #include "../test/board/ch395f_test.h" /* ENABLE_NET_LAYER_TESTS:netTask 条件创建 */ #include "time_sync.h" @@ -235,21 +236,32 @@ void StartNetTask(void *argument) __weak void StartFtpTask(void *argument) { /* USER CODE BEGIN StartFtpTask */ + task_wd_bind(TASK_WD_FTP); /* FTL 心跳路由:登记本任务句柄(入口一次) */ /* 等网络 + 存储都真正就绪后再 listen,取代拍脑袋的 7s 魔法延时 */ /* sys_evtHandle:等待哪个事件组 SYS_EVT_NET_READY | SYS_EVT_STORAGE_READY:要等哪几位(1u<<0 网络、1u<<1 存储) osFlagsWaitAll:请求的位全部置位才唤醒(即"网络且存储都就绪") - osWaitForever:无限期等待,不设超时——任务会挂起(让出 CPU,零占用),直到生产者置位对应位才被唤醒 + 100u:单次等待分片时长——2026-10-02 台架修复由 osWaitForever 改为 100ms + 分片循环:IWDG 必需掩码含 FTP 位,屏障等待态必须能喂心跳(见下方注释) */ if (sys_evtHandle != NULL) { /* osFlagsNoClear 必须带:就绪位是**状态**不是命令,消费型等待会把位清掉, * 导致后面 WaitAll(两位) 的等待者永远等不到(2026-09-21 实测:netTask 先 * 消费 STORAGE_READY、uiTask/timeSync 再消费 NET_READY → 本任务永不唤醒、 - * FTP 21 端口从未监听)。 */ - (void)osEventFlagsWait(sys_evtHandle, - SYS_EVT_NET_READY | SYS_EVT_STORAGE_READY, - osFlagsWaitAll | osFlagsNoClear, osWaitForever); + * FTP 21 端口从未监听)。 + * 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳(与 timeSync/uiTask/ + * netTask 入口同模式)——IWDG 必需掩码含 FTP 位,原 osWaitForever 无心跳, + * IWDG 启动(存储就绪)时网络未必就绪 → 掩码 800ms 不可达 → 停喂 → 复位循环 */ + for (;;) { + uint32_t r = osEventFlagsWait(sys_evtHandle, + SYS_EVT_NET_READY | SYS_EVT_STORAGE_READY, + osFlagsWaitAll | osFlagsNoClear, 100u); + if ((r & osFlagsError) == 0u) { + break; /* 两位均就绪(状态位,未被消费) */ + } + lftpd_task_wd_beat(); /* 看门狗:分片喂心跳(屏障等待态) */ + } } lftpd_t ftp; lftpd_start("/", 21, &ftp); diff --git a/Src/main.c b/Src/main.c index 23cd6fd5..687d7d3e 100644 --- a/Src/main.c +++ b/Src/main.c @@ -39,6 +39,7 @@ #include "net_select.h" #include "app_main.h" #include "sys_clock.h" +#include "task_watchdog.h" /* task_wd_tick_probe():round 8b 时间源存活性取证 */ /* USER CODE END Includes */ /* Private typedef -----------------------------------------------------------*/ @@ -83,6 +84,11 @@ int main(void) { /* USER CODE BEGIN 1 */ + /* round 8b 取证:**必须在 HAL_Init() 之前**把上一轮的"时间源存活性"终值 + * 快照到 .noinit 的 prev 槽——TIM7 在 HAL_Init 里就 HAL_TIM_Base_Start_IT + * 开始 1ms 中断,而开机上报要到 app_main_init(≈117ms)才跑;不先搬走, + * 上一轮终值会被本轮开机瞬间的 tick 覆盖(实机测试规划.md §0.5.4.3)。 */ + task_wd_tick_snapshot(); /* USER CODE END 1 */ /* MCU Configuration--------------------------------------------------------*/ @@ -223,7 +229,18 @@ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) HAL_IncTick(); } /* USER CODE BEGIN Callback 1 */ - + /* round 8b 取证:TIM7 每 1ms 把"时间源存活性"写进复位不丢的 .noinit 槽 + * (uwTick + 中断计数)。复位后由 task_wd_seg_report() 打 TICKRUN 行, + * 与"进段打点"时刻对比,判定 TIM7(HAL 时基)是否在冻结同刻停摆 + * (实机测试规划.md §0.5.4.3)。 + * ⚠ task_wd_tick_probe() 内不得调用任何 FreeRTOS API:TIM7 在 HAL_Init + * 阶段即开始中断,远早于调度器启动,此时 ISR 版内核 API 会触发 port.c 的 + * 优先级断言并把系统卡死在本 ISR 内(2026-10-02 台架实测)。 + * 放 USER CODE 区(L2):CubeMX 重生成不丢。 */ + if (htim->Instance == TIM7) + { + task_wd_tick_probe(); + } /* USER CODE END Callback 1 */ } diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 105317fb..3a5b6b20 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 262,100 B (255.96 KB) | 1,048,576 B (1024 KB) | 25.0% | -| RW_IRAM1 | 84,864 B (82.88 KB) | 131,072 B (128 KB) | 64.7% | -| RW_CCM | 49,184 B (48.03 KB) | 65,536 B (64 KB) | 75.0% | -| **Flash (ROM 合计)** | **262,800 B (256.6 KB)** | 1,048,576 B (1024 KB) | **25.1%** | +| ER_IROM1 | 264,284 B (258.09 KB) | 1,048,576 B (1024 KB) | 25.2% | +| RW_IRAM1 | 84,952 B (82.96 KB) | 131,072 B (128 KB) | 64.8% | +| RW_CCM | 49,184 B (48.03 KB) | 65,280 B (64 KB) | 75.3% | +| **Flash (ROM 合计)** | **265,012 B (258.8 KB)** | 1,048,576 B (1024 KB) | **25.3%** | 各区的容量与常量来源: @@ -344,7 +344,7 @@ void vApplicationMallocFailedHook(void) 依据 RM0090 §3.1.1:F407 的 64KB CCM @0x10000000 仅 CPU 可访问(DMA 不可达、无 cache)。 -**进度(2026-09-21)**:第一阶段已落地——scatter 新增 `RW_CCM` 区(`sct:25`,注释 "FreeRTOS heap only")并把 **FreeRTOS 堆整体迁入**(49,184 B,CCM 占用 75.0%)。下表缓冲迁入清单为**后续剩余项**(LVGL 池/UI 缓冲现仍在主 SRAM);`APP_CCM_ENABLE` 门禁宏**尚未实现**(scatter 无开关,见下"风险门禁")。 +**进度(2026-09-21)**:第一阶段已落地——scatter 新增 `RW_CCM` 区(`sct:25`,注释 "FreeRTOS heap only")并把 **FreeRTOS 堆整体迁入**(49,184 B,CCM 占用 75.3%)。下表缓冲迁入清单为**后续剩余项**(LVGL 池/UI 缓冲现仍在主 SRAM);`APP_CCM_ENABLE` 门禁宏**尚未实现**(scatter 无开关,见下"风险门禁")。 **迁入 CCM 的缓冲(全部纯 CPU 读写):** diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index 5e4f8f8b..db09273f 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -351,6 +351,16 @@ T_任务循环 < T_监视窗口 < T_IWDG超时 < T_最坏超时(打七折) > 若后续需要覆盖,可在 `app_main_init` 完成后启动一个"短超时 + 手动多喂" > 的临时监视(复杂度显著上升,暂不采用)。 +> ⚠️ **决策 C 的必配项(2026-10-02 P0 实测补记,曾漏做并致 5 倍超时偏差)**: +> 既然"启动"被延后到 `wd_platform_start()`,**PR/RLR 的写入也必须一并搬过去**。 +> 删 `HAL_IWDG_Init()` 的初衷只是阻止它"立即启动",但它同时是**唯一**写这两只 +> 寄存器的路径——漏搬的后果是寄存器停在**复位默认值**(`PR=0`→÷4、`RLR=4095`, +> 实测 ≈349~512ms,比设计短约 5 倍),使 §4.3 的"超时 ⊃ 监视窗口"包含关系失效、 +> §3.2 的停喂判定分支沦为**死代码**,任何 >400ms 心跳缺口都直接误复位。 +> 现已在 `wd_platform_start()` 内补写(见 §5.3 与变更记录)。 +> **通用教训:凡"把某个 HAL 初始化函数拆开/延后"的改动,必须逐一核对被拆掉的 +> 函数还有哪些副作用没有搬到新位置——HAL 的 `Init()` 往往不只是"赋值 + 调用"。** + ### 4.6 决策六:`configASSERT` 改为"先记录后复位" 现状(`Inc/FreeRTOSConfig.h:163`): @@ -1103,3 +1113,4 @@ python scripts/check_cubemx_survival.py --write # 重建基线(确认改动 | 2026-09-29 | **3 个 Fault 升级为崩溃采集** | `MemManage` / `BusFault` / `UsageFault` 由空 `while(1)`(死机静默)升级为与 `HardFault` 同一套采集:生成区整函数替换(各带独立锚点 `MEMFAULT-ANCHOR` / `BUSFAULT-ANCHOR` / `USAGEFAULT-ANCHOR`)+ `#define X X_Gen` 改名保留生成实体(链接器各移除 2 字节)。新增 `fault_handler_enable()` 使能 `SHCSR` 后三者(默认只开 HardFault,不开则故障被升级、看不出根因),声明入 `Inc/stm32f4xx_it.h`、调用在 `app_main_init()` 第一行。因 ARMCC `__asm` 取不到 C 数据符号,改为 per-fault C 入口 `fault_entry_{hard,mem,bus,usage}` 带故障名调 `hard_fault_report(tag, frame)`。**刻意不启用** `UNALIGN_TRP` / `DIV_0_TRP`(避免把普通 bug 升级成死机)。审计脚本新增 `fault-handlers-3` / `fault-handler-enable` 两项、`UNPROTECTED_LEGACY` 清掉 3 个 Fault(WARN 2→1)、清单 16→18 项。`@build` 0 错误 0 警告;审计 18 通过 / 1 警告 / 0 失败 | | 2026-10-01 | **查证 `fault_handler_enable()` 的 CubeMX 可行性** | 用户问"能否通过 CubeMX 配置"→ **否**。查 CubeMX 安装目录模板源码得三重证据:① `tpl_main_c.ftl` 的 `MX_NVIC_Init()` 只在 `systemHandler=="false"` 时生成 `HAL_NVIC_EnableIRQ`,而 `NVIC-STM32F417_Modes.xml` 中三个 Fault 都标 `Y`(=systemHandler)→ 永不生成使能代码;② **全部模板中 `SHCSR` 只出现 1 次**且是 MPU 清理的 `&= ~MEMFAULTENA`(禁用),**无任何 `\|=` 置位**;③ `.ioc` 无 MPU(`Mcu.IP` 列表),且 `HAL_MPU_Enable` 只置 `MEMFAULTENA` 一位。实测印证:`HAL_MspInit()` 里三个 Fault 一行代码都没生成。结论:只能手工写,但属 **L2**(`USER CODE BEGIN 1` 区)→ 重生成不丢,有审计项兜底。另新增警示:`.ioc` 的 `NVIC.MemoryManagement_IRQn=true` **不代表已使能**(第 1 段只是"向量可见")。写入 §10.2 与四个 Fault 采集小节 | | 2026-10-01 | **新增 §7.3 主机侧单元测试(L0.5)** | 硬件访问收敛到薄平台层(`App/util/task_watchdog.h` 末段声明 3 个 `__weak` 钩子:喂狗/启动/取 tick),MCU 端行为零变化(`@build` 0/0)。新建 `test/host/watchdog/`(`watchdog_test.c` 39 用例 + `run_unit_test.py` MSVC 运行器 + `stub/` 三桩头),`#include` 生产源码以保证测的是真代码。挂 pre-commit `watchdog-unit-test` 钩子。新增 `docs/测试说明/看门狗单元测试说明.md`;`App/util/README.md`、`test/host/README.md`、`测试开发标准流程.md`(新增 L0.5 层)同步。顺带改善:`s_last_tick` 由函数局部 static 提为文件 static(可测性 + 模块状态归一)。§5.1 文件表与 §10 步骤表登记;§7.2 S1 优先级降低(位图逻辑已由 U1 覆盖) | +| 2026-10-02 | **P0 根因修复:IWDG 超时被静默配成复位默认值(比设计短 5 倍)** | round 3~8 反复出现的"空 STOR(0 字节截断)→ IWDG 误复位"**根因不是"poller 自身停摆"**。真因:`Src/iwdg.c` 为实现**延后启动**(§4.5 决策 C)删掉了 `HAL_IWDG_Init()`(`IWDG-DEFER-START-ANCHOR`),而 `HAL_IWDG_Init()` 是**唯一**会把 `hiwdg.Init.Prescaler/Reload` 写进 IWDG 寄存器的路径——删掉后 `MX_IWDG_Init()` 只填结构体,`WD_IWDG_PRESCALER`/`WD_IWDG_RELOAD` 沦为**死宏**,寄存器停在**复位默认值** `PR=0`(÷4)/`RLR=4095`。实测(SWD 回读)实际超时 = 4×4096/LSI ≈ **349~512 ms**,而设计是 64×1250/LSI ≈ **1701~2498 ms**。两个后果:① `WD_MISS_TOLERANCE`(8×100ms=800ms)**永远等不到**就被 IWDG 复位 → **停喂判定分支成死代码**(这正是 §7/`实机测试规划.md` §0.5.4.1 "STALL 从未执行"的真正原因,当时据此误判为"poller 停摆");② 任何 >400ms 的心跳缺口都直接误复位(空 STOR 的 FTL flush 使 NET/FTP/UI 心跳缺 ~400ms)。**修复**:`wd_platform_start()`(`__weak`,**不在 CubeMX 生成区**,重生成不丢)补上按设计值配置:解锁(`KR=0x5555`) → `PR` → 等 `SR.PVU` 清 → `RLR` → 等 `SR.RVU` 清 → `KR=0xCCCC`(不等同步会静默丢弃后写的那只,SWD 实测)。`.map` 证实 `Removing stm32f4xx_hal_iwdg.o(i.HAL_IWDG_Init), (94 bytes)`,故这是全固件**唯一**写 PR/RLR 的点。**验证**:SWD 现场改回设计值后,同一空 STOR 由 **5/5 复位 → 100% 成功**;固件烧录后 3 次开机回读均为 `PR=4/RLR=1249`。`@build` 0/0;本地 CI L0+L0.5 全绿(278/278 断言) | diff --git a/docs/架构设计/STM32F4-Base存储架构说明.md b/docs/架构设计/STM32F4-Base存储架构说明.md index 10242cb6..137f09c7 100644 --- a/docs/架构设计/STM32F4-Base存储架构说明.md +++ b/docs/架构设计/STM32F4-Base存储架构说明.md @@ -340,6 +340,34 @@ dhara 通过 7 个回调函数访问物理 NAND: 4. 页面缓存初始化 (s_cache_buf, s_cached_lpn, s_cache_dirty) ``` +#### 5.4.1 resume 的 epoch 锚点 —— 回绕后必须锚"最新"而非"最高地址"(2026-10-02 修复) + +> ⚠️ **这是"复位后所有写入全部丢失"的曾经真凶**,排查存储丢失时**第一顺位**检查它。 + +**背景**:dhara 的 checkpoint 头页带 1 字节 `epoch`,journal 每绕满一圈 +(`roll_stats()`,即 head 回绕到 0)`epoch++`。resume 时用 epoch 过滤,只认"当前圈"的 +checkpoint。 + +**坑**:环**回绕之后**,"最高地址"的 checkpoint 属于**上一圈**(旧 epoch),而**最新** +checkpoint 落在**低地址**(块 0 一侧,epoch = 旧 + 1)。若把锚点取成 +`find_last_checkblock_any()`(= 最高地址块),就会锚到**旧 epoch**,随后的 +`find_last_checkblock()` 只能选中旧块 → `find_head()` 在旧块里找空页 → **journal 整体回退到 +回绕之前**,回绕之后写的一切(日志、配置、FTP 上传)复位即丢。 +(2026-09-20 的本地补丁正是这么改的,用于修"自愈卷重格式化每次开机丢失",属顾此失彼的回归。) + +**现行实现**(`Lib/dhara/journal.c`,代码内附详细注释)—— 比较两个候选 epoch: + +| `eFirst`(最低地址块)与 `eLast`(最高地址块) | 含义 | 锚点 | +|---|---|---| +| 相等 | 未回绕 | 相同,任取 | +| `eFirst == eLast + 1 (mod 256)` | **已回绕,低地址更新** | **`eFirst`** | +| 其它 | 低地址是上一圈残留 | `eLast` | + +**可观测性**:`NAND_FTL` 开机加打一行 `Map head=… tail=… epoch=…`。 +**只看 `Map resumed: N/M sectors used` 判断不出回退**(覆盖写不改 `m->count`, +实测该值在回退期间连续 2 小时一字不差);判据是 **head 复位后是否倒退**。 +台架实测:回退时 head 96 → **130752**、tail 115143 → 115068;修复后 head=96 / epoch=1。 + ### 5.5 页面缓存策略 FTL 之上还有一个 **单页写回缓存 (write-back cache)**: diff --git a/docs/测试说明/实机测试规划.md b/docs/测试说明/实机测试规划.md index 729ff709..01c6ef69 100644 --- a/docs/测试说明/实机测试规划.md +++ b/docs/测试说明/实机测试规划.md @@ -33,6 +33,415 @@ --- +## 0.5 台架执行现状与交接(2026-10-02 台架窗口) + +> **读法**:接手的 Agent **先读本块**(现状 + 立即下一步),再按需读 §1 起的批次规划。 +> 本块随台架窗口更新;每轮修复的完整证据在 P0-B 出口统一销入设计文档前,**以本块 + 串口日志为准**。 + +### 0.5.1 一句话现状 + +**P0-A 通过;P0-B 卡在一处**:`/log/selftest.log` 台账清空(0 字节 STOR)会触发 +**IWDG 误复位**(round 3~6 共四轮修复后仍 **5/5 复现**)。台账因此"清不掉", +S7(已落地并提交)拿不到 PASS。 + +**round 7 取证已完成(§0.5.4.1)**:连续复现 4 次,**判定"假设 2:poller 自身停摆"成立** +——假设 1(accept_timeout 分片节拍)与假设 3(僵尸通道吞证据)均被否。 +**round 8a 取证已完成(§0.5.4.2)**:以复位不丢的 `.noinit`"进段打点"定位到 +**defaultTask 冻结在 `stack_probe_poll()` 的 `DBG_INFO`→console 通道内**, +机理 = **时间源停摆使 `HAL_UART_Transmit` 超时永不成立 → 死等**。 +**立即下一步 = round 8b(§0.5.4.3)**:给 console 通道加与时间源无关的硬上界 + 加 tick 存活性探针 +定位停摆源 → 再清台账 → 全量 `@test` → 回读 → 销项 → 提交。 + +### 0.5.2 已完成(本窗口实测) + +| 项 | 状态 | 证据 | +|---|---|---| +| P0-A 环境预检 | **PASS**(A5 待出口前复核) | A2 构建 `BUILD_OK: 0 errors, 0 warnings`;A3 四个 `TEST_SUITE_*` 全注释(产品态);A4 `.map` `ER_IROM1 Size=0x000402B4` = **262,836 B**(含 round 1~6 修复增量,基线 262,100 已上移;266,540 才是套件态);A6/A7/A8 ST-LINK/串口/daemon 可用 | +| **S7 自检判定**(§2.1) | **已落地并提交**(`4a4cd29`) | `verify_log.py --require-selftest-pass` + `--selftest-log`;`run_boot_test.py` 同名参数,未给本地副本时**自动 FTP 抓取** `/log/selftest.log`;语义 = 缺失/0 字节 PASS、任一 64B 记录 FAIL。**但本轮尚未取得 PASS**(台账有 409 条记录) | +| 看门狗误复位修复 round 1~6 | **代码已落地、未提交**(等 P0-B 出口统一提交) | 见 §0.5.7 陷阱与下表 | + +**round 1~6 修复集摘要**(全部未提交,**切勿 `git checkout`/stash**): + +| 轮 | 修复 | 针对现象 | +|---|---|---| +| 1~2 | 就绪屏障位一律 `osFlagsNoClear` | 屏障位被消费 → 消费者永不就绪 | +| 3 | console 单次 drain 预算 250ms + UART 软复位自愈;lftpd STOR/accept 路径修复 | ORE 僵尸通道(USART1 TX_RX + PA10 悬空) | +| 4 | FTL 长窗口心跳 `nand_ftl_wd_beat`(按任务路由) | 0 字节 STOR 截断 → dhara GC 长尾 → FTP 位缺席 | +| 5 | FTL 直喂 `nand_ftl_wd_feed` + `vol_lock` 卷锁(新增 `App/fs/vol_lock.{h,c}`) | poller 自身长 FTL 窗口;并发 FTL 写竞争 journal | +| 6 | console 发送故障即止 + 300ms 发送冷却 | 背靠连发块超时链拉长主循环 → 喂狗节奏饥饿 | + +### 0.5.3 当前阻断点:0 字节 STOR → IWDG 误复位 + +**复现步骤**(每轮台架都跑):`SIZE /log/selftest.log`(记基线)→ 空 STOR 该文件(ftplib +`storbinary` 传空 `BytesIO`)→ 观察串口与复位原因。 + +**最近一次时间线**(设备时刻,镜像 = round-6 构建): + +``` +09:00:23.0 [FTP] CMD STOR /log/selftest.log +09:00:23.0 [NET] sock3 DISCONNECTED, releasing (chip tcp=0x00) ← 芯片侧丢弃数据连接 +09:00:23.1 [NET] sock3 auto-relisten ok / relisten retry ok +09:00:24.4 reset cause: IWDG (independent watchdog) (CSR=0x24000000) ← 误复位 +09:00:24.x 重启 → 写 1 条 `watchdog reset` 自检记录 → 台账反而 +1 +``` + +**已排除**(逐条有证据,勿重复怀疑): + +1. **单次 console drain 过长**:round-3 已限 250ms;round-6 再加"故障即止 + 300ms 冷却", + 本轮误复位仍复现 → 非 drain 时长问题(但僵尸通道本身未根除)。 +2. **poller 自身长 FTL 窗口**:round-5 `nand_ftl_wd_feed` 直喂已覆盖;本轮停喂时刻**无 FTL 操作**。 +3. **并发 FTL 写**:round-5 卷锁已串行化(`lftpd_io` 全序列持锁、`syslog`/`cfg` try 语义)。 +4. **主循环无界阻塞**:`default_task.c` 主循环逐段核对(drain ≤ ~110ms(round-6 后)、 + 事件等待 ≤50ms、卷锁 try、2s 门为 tick 判断非延时),未找到可阻塞 1.7s 的段。 + +**尚存假设**(round 7 取证定性,三者互斥)——**已于 §0.5.4.1 判定:假设 2 成立,假设 1/3 均否**: + +| # | 假设 | 若成立的取证特征 | +|---|---|---| +| 1 | **FTP 位缺席**:0 字节 STOR 落在 `lftpd_inet_accept_timeout(..., 5000)` 等待中;该等待虽每 100ms 分片喂心跳,但分片周期 = 100ms + netTask 消息往返,**抽样窗口恰为 100ms** → 可能整窗缺席 → 800ms 判定停喂 | WDEV 记录 `snap=0x36`(其余位齐) | +| 2 | **poller 自身被阻塞**(判定代码根本没跑到) | **WDEV.LOG 无记录**——前提是先确认证据通道可用(文件已存在且此前有记录),否则与"通道写失败"混淆 | +| 3 | **僵尸通道吞掉证据**:停喂真因是假设 1,但 `[WD] task stall` 行经僵尸串口丢失 → "无 stall 日志"**不能**用来排除 1 | WDEV 记录 `cf>0`(console tx_fault 计数 > 0) | + +> ⚠️ **判据缺口**(本轮绕圈的根因):误复位后**无法区分**上述三类——stall 只写串口 +> (僵尸时丢失),RAM 态随复位消失。故 round 7 必须把证据落到**掉电不丢**的盘上。 + +### 0.5.4 立即下一步:round 7 纯取证埋点(不改判定语义) + +> 目标:让"停喂真因"在下一次误复位后**仍可读**。判定逻辑一字不改(主机单测不受影响)。 + +**改动 1 — 新增 `App/log/sys_log_mcu.h`**(MCU 专用头,头文件保护宏 `SYS_LOG_MCU_H`): +声明 `int sys_log_evidence(const char *p_desc);` + +**改动 2 — `App/log/sys_log_mcu.c` 增实现 `sys_log_evidence`**: + +1. `vol_lock_wait(0u)` **try** 取卷锁(调用方 = poller 上下文,绝不阻塞); +2. `f_open("/log/wdev.log", FA_WRITE | FA_OPEN_APPEND)` 写证据行 + `"\r\n"`(8.3 合法名; + 文件 > 8KB 时先 `f_unlink` 重建,防长跑无界增长);释放锁; +3. **随后立刻调 `s_append(SYSLOG_RUN, "wd stall declared")`** —— + ⚠️ **关键**:FTL 单页缓存的语义是"写到**不同 LPN** 才把前一脏页刷进 NAND", + 没有这一步,WDEV 证据会在 1.7s 后的复位中**回滚丢失**(409 条里被回滚的 07:56 记录即同类教训); +4. 返回值语义:0 成功 / -1 卷占用或写失败(证据可能回滚,**不阻断判定**)。 + +**改动 3 — `App/util/task_watchdog.c`**(**全部放进 `#ifndef TASK_WATCHDOG_UNIT_TEST`**, +保证 `watchdog-unit-test` 主机单测 39 条断言与判定状态机逐字不变): + +| 位置 | 内容 | +|---|---| +| 私有变量区(**须在 `task_wd_poll` 之前**,否则未声明) | 8 深度快照环形缓冲 `s_snap_hist[8]` + 索引;`s_last_feed_tick` / `s_max_feed_gap` / `s_feed_seen` | +| 弱实现 `wd_platform_feed()` 内 | 追加 `wd_feed_track()`(更新最近喂狗时刻 + **最大喂狗间隔**)——直接量化"停喂前节奏是否已变慢" | +| `task_wd_poll()` 取快照处(L180-182 后) | `#ifndef` 包住,写环形缓冲 | +| 停喂分支(L196-203) | 保留原 `DBG_ERROR`,追加 `wd_stall_evidence(snapshot)`:格式化 `STALL snap=0x%08X req=0x%08X h=<8×8hex> mg= cf=<次>` → `sys_log_evidence(...)` | +| 文件头包含区(守卫内) | `#include "sys_log.h"`(`SYSLOG_RUN`)+ `#include "console_log.h"`(`dbg_console_tx_fault()`) | + +**执行与判据**: + +```bash +# 1) 构建 + 烧录 + 开机校验(铁律仪式,见 §0.5.7) +& "MDK-ARM\build.bat" # 判据:BUILD_OK: 0 errors, 0 warnings +& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py clear +& "MDK-ARM\reset.bat" flash +& '.venv\Scripts\python.exe' tools/serial_tool/serial_tool.py wait-idle --timeout 90 --settle-ms 8000 --max-ms 45000 +# 2) 复现:记 SIZE 基线 → 空 STOR /log/selftest.log → 等误复位 +# 3) 复位后读证据(FTP 绝对路径): +# SIZE /log/wdev.log → 非 0 即已落盘(掉电不丢证明 commit 成功) +# RETR /log/wdev.log → 逐行读 STALL 记录 +``` + +| 观察 | 结论 → 下一步(round 8 定向修复) | +|---|---| +| `snap=0x36` 且 `cf` 任意 | **假设 1**:FTP 任务侧心跳漏窗 → 修 `lftpd_inet_accept_timeout` 分片节拍(心跳周期必须 **< 100ms**,或把该等待改为"状态查询 + 短延迟")| +| `snap` 缺其它位 | 对应任务(ui/net/timesync)停摆,按位定位 | +| **无 WDEV.LOG / 无记录** | **假设 2**:poller 自身被阻塞 → 转查主循环各调用点(下一步加"进段打点"或用 `[STACK]`/tick 埋点定位)| +| `mg`(最大喂狗间隔)在停喂前已 > 100ms | 节奏性饥饿(喂狗周期被拉长)而非硬停摆 | +| `cf>0` | 僵尸通道在该窗口活跃 → 解释"无 stall 日志",同时印证"证据必须落盘"的设计 | + +> **注意**:round 7 是**纯取证**,预期仍会误复位一次(每复位一次台账 +1 条 +> `watchdog reset`);取证完成后按 round 8 修复,再做 §0.5.5 的出口闭环。 + +--- + +### 0.5.4.1 round 7 取证结果(2026-10-02 台架)——**假设 2 成立** + +**执行**:§0.5.4 三处改动已落地(`App/log/sys_log_mcu.{h,c}` 新增 `sys_log_evidence`; +`App/util/task_watchdog.c` round 7 埋点,全部在 `TASK_WATCHDOG_UNIT_TEST` 守卫内,判定状态机 +逐字不变)→ `@build` 0/0 → 主机单测 **39/39 PASS** → 烧录 → 空 STOR 复现。 +串口全量捕获(自建 pyserial 落地,`%TEMP%\opencode\sink.log`,1217 行)**连续复现 4 次 IWDG 误复位**。 + +**关键读数(4 次复位无一例外)**: + +| 读数 | 值 | 含义 | +|---|---|---| +| 稳态快照 | `MISS start snap=0x00000017 req=0x00000037`,间隔 ≈204ms | 缺 **TIMESYNC** 位;与 100ms 抽样交替 → **良性节拍差**(streak 反复 0→1,永不累积到 8)| +| 停喂前最后一次 MISS | `snap=0x17`(复位 #2/#3/#4)/ `snap=0x01`(#1)| **从不是 `0x36`** → **假设 1(FTP 位缺席)被否**:`lftpd_inet_accept_timeout` 的 100ms 分片心跳**有效** | +| `STALL` 行(裸 UART) | **0 条** | 停喂分支从未执行 | +| `[WD] task stall`(环形缓冲) | **0 条** | 同上 | +| `/log/wdev.log` | **从未创建** | 盘上证据通道未产出 | +| `CONSOLE tx fault` / `dropped` | **0 条** | 裸 UART 通道**全程健康**(MISS 行一直打到复位前一瞬)| +| 复位原因 | `IWDG (CSR=0x24000000)`(IWDGRSTF,非 SFTRSTF)| 真看门狗溢出,非软件复位 | + +**推理链(§0.5.3 的判据缺口已闭合)**:`MISS` 行证明裸 UART 通道**可用且一直可用**(`cf=0`), +故"无 STALL 行"**不能**归因于僵尸通道吞证据 → 停喂分支确实没跑;而 MISS 行在复位前**戛然而止** +(稳态每 204ms 一条,此后完全静默)→ **`task_wd_poll` 停止被调用** → **poller(defaultTask)自身停摆**。 + +⇒ **结论:假设 2 成立;假设 1、假设 3 均否。round 8 走"查 poller 阻塞点",不走"修 accept_timeout 分片节拍"。** + +**顺带确立的两条事实**: +1. 稳态 `0x17` 是**良性**的(抽样 100ms vs timesync 心跳 ~200ms 的节拍差),但它是**掩码精确匹配** + 设计的脆弱点:timesync 心跳一旦滑出两个连续窗口就会开始累积 streak。建议 round 8 顺手把 + timeSyncTask 的等待也按 ≤50ms 分片喂心跳(低成本消抖)——**但它不是本次复位的原因**。 +2. `/log/wdev.log` 未创建**不能**单独当判据:本轮"无记录"来自**分支未执行**,而非写失败。 + +### 0.5.4.2 round 8a 取证(复位不丢的"进段打点")——**定位到 console 通道** + +**改动**(round 8 定向取证的"进段打点",即 §0.5.4 表内"下一步加'进段打点'"的落地): + +| 文件 | 改动 | +|---|---| +| `App/util/task_watchdog.{h,c}` | 新增 `task_wd_seg(seg)` / `task_wd_seg_report()`;轨迹槽 `s_wd_seg_trail[4]`(magic/段号/时刻/序号)置于 **`.noinit`**;`wd_stall_evidence` 的 STALL 行追加 `seg=` | +| `App/task/default_task.c` | 主循环**每步前** `task_wd_seg(TASK_WD_SEG_*)`(poll/evtwait/drain/cfgsave/cfgpoll/led/syslog/stack 共 8 段)| +| `App/app/app_main.c` | `reset cause` 行之后调 `task_wd_seg_report()`(裸 UART 打 `PREVRUN seg=N seq=N t=N`)| +| `MDK-ARM/STM32F407-Demo/STM32F407-Demo.sct` | 新增 `UNINIT_CCM 0x1000FF00 UNINIT 0x100 { *(.noinit) }`(RW_CCM 由 `0x10000` 缩到 `0xFF00`;CCM 尾部 16KB 本就是 FreeRTOS 堆之外的闲置区)| + +> **为何用 `.noinit` 而非新任务**:IWDG/引脚复位**不清 SRAM**(VDD 保持),故复位后仍可读; +> 新任务方案在"tick 停摆"时自身也会停,取不到数。**已验证**:镜像 `Region$$Table` 共 4 条 +> (`RW_IRAM1` copy/zero、`RW_CCM` copy/zero),**无一条指向 `0x1000FF00`** → 启动完全不碰该槽。 +> (`zero_init` 属性必须与 `section` 同给,否则 armcc 视其为"带初值 RW",scatterload 会拷初值抹掉保留内容。) + +**执行**:`@build` 0/0 → 单测 39/39 → 烧录(`reset.bat flash`,`Programming Complete`)→ 空 STOR 复现。 + +**读数(复位后开机第一行)**: + +``` +[----/--/-- --:--:--][APP] reset cause: IWDG (independent watchdog) (CSR=0x24000000) +PREVRUN seg=8 seq=1814443510 t=38069 +``` + +**结论(round 8a)**: + +1. **`seg=8` = `TASK_WD_SEG_STACK` = `stack_probe_poll()`** —— defaultTask 主循环**最后进入的是栈水位观测段**, + 再没走到下一轮的 `seg=1`(poll)。`seq` 为大随机值 → 反证 `.noinit` **确实未被零初始化**(保留成功)。 +2. 串口在该段**被切断在 `[STACK]` 报告的第 2 行**(`[STACK] default …` 完整、`[STACK] net` 半行)→ + defaultTask 冻结在 `stack_probe_poll()` 的 `DBG_INFO` 突发行内。 +3. **无 `[ERR] STACK OVERFLOW`、无 HardFault**(复位原因是 IWDGRSTF 而非 SFTRSTF)→ 排除栈溢出与故障。 +4. 停喂前最后快照 `snap=0x00000007`(缺 **UI + TIMESYNC**)→ 两个任务**更早**停摆(UI 为 BelowNormal, + 与"CPU 被更高优先级上下文抢占"的形态一致)。 + +**机理(唯一自洽解释)**:`stack_probe_poll()` 的 `DBG_INFO` → `con_append` → `dbg_console_drain` +→ **`HAL_UART_Transmit(..., 100ms)`**。该超时的实现依赖 `HAL_GetTick()`(= `uwTick`, +由 **TIM7** 的 `HAL_TIM_PeriodElapsedCallback` 递增,见 `Src/main.c:216`)。 +`con_append` 的"缓冲满等待"(`vTaskDelay(1)` × 200)与 `HAL_UART_Transmit` 的超时**都只靠时间源兜底**—— +一旦**时间源停摆**,两者都会**永久阻塞**(HAL 的超时判据永不成立 → 死等 TXE)。 +这与"冻结在 console 行内 + 无 STALL + IWDGRSTF + UI/timesync 先饿死"**全部吻合**。 + +⇒ **round 8b 方向:不是"分片节拍",而是"时间源停摆 + HAL 阻塞调用无硬上界"两件事。** + +### 0.5.4.3 round 8b~8f 结论(2026-10-02 台架)——**根因不是"时间源停摆",是 IWDG 超时被静默配成默认值(短 5 倍)** + +原计划的两步已全部执行完,但**第 2 步的实测判据推翻了自己的前提**: + +| 步骤 | 状态 | 说明 | +|---|---|---| +| ① console 加与时间源无关的硬上界 | ✅ 完成(round 8b) | `App/log/console_log.c`:`con_tx_bounded()` 用 `DWT->CYCCNT` 做 100ms 硬上界、`con_append` 用 200ms 硬上界。**保留**——消除"tick 停摆 → `HAL_UART_Transmit` 死等"这条单点,属净收益 | +| ② tick 存活性探针 + 定位停摆源 | ✅ 完成(round 8b/8c/8e) | 槽与 `TICKRUN uw/n/duw` 上报已落地,**但结论与原假设相反**(见下) | +| ③ timeSyncTask 200ms 等待分片喂心跳 | ✅ 完成 | 见 `task_watchdog.c` 的 `wd_required_mask()` 说明 | + +#### 关键更正:`duw` 小 ≠ "同刻停摆" + +`duw = uw − 进段时刻`,而 `uw` 是 TIM7 的**最后一次**跳——它必然就发生在复位前一刻 +(IWDG 复位即停 TIM7)。所以 `duw=9..41` **只说明** defaultTask 最后一次进段打点就在复位前 +9~41ms,**不能推出时间源停摆**。round 8b 据此写下的"假设 2:poller 自身停摆"是**误判**: +实测 `n`(TIM7 中断计数)一路递增到复位那一刻,**时间源从未停摆**,系统全程健康, +不存在"谁屏蔽中断/谁在 ISR 长驻"。 + +#### 真正的根因 + +`Src/iwdg.c` 为实现"延后启动"删掉了 `HAL_IWDG_Init()`(`IWDG-DEFER-START-ANCHOR`), +而 `HAL_IWDG_Init()` 是**唯一**会把 `hiwdg.Init.Prescaler/Reload` 写进 IWDG 寄存器的路径。 +删掉后 `MX_IWDG_Init()` 只填结构体;`App/util/task_watchdog.c` 里的 +`WD_IWDG_PRESCALER` / `WD_IWDG_RELOAD` 只被 `#define` 出来、**从未写进寄存器**。 +IWDG 于是停在**硬件复位默认值**(SWD 实测读数 `PR=0`、`RLR=4095`): + +| | 分频 | RLR | 超时(LSI 32k~47k) | +|---|---|---|---| +| **设计值**(`WD_IWDG_*`) | /64 | 1249 | **1701~2498 ms** | +| **实机值**(复位默认) | /4 | 4095 | **349~512 ms** | + +短约 **5 倍** → `WD_MISS_TOLERANCE`(8×100ms=800ms)**永远等不到**就被 IWDG 复位, +停喂判定分支成为**死代码**(这正是 §0.5.4.1"STALL 从未执行"的真正原因);且任何 >400ms +的心跳缺口都直接误复位——空 STOR 截断 → FTL flush/GC 使 NET/FTP/UI 心跳缺 ~400ms → 复位。 +**round 3~8 的"空 STOR 必复位"是同一个根因。** + +#### 免烧录的决定性验证 + +用 `ST-LINK_CLI -W32` 在**一次**调用内把 PR/RLR 现场改成 4/1249 +(`KR=0x5555 → PR=4 → RLR=0x4E1 → KR=0xAAAA`;分多次调用会超过 ~400ms 超时、中途被复位), +再跑同一个空 STOR:**由 5/5 复位变为 100% 成功** +(`226 Closing data connection.` + `[FTP] UPLOAD '/log/selftest.log' 0 bytes OK`, +`n` 全程递增、无复位;`MISS start snap=0x27` 仍出现但被 800ms 容忍吸收)。 + +> ⚠️ 现场改寄存器只是**验证手段**(IWDG 参数随复位丢失);真正的修复是让固件每次启动都写。 + +#### 修复 + +`App/util/task_watchdog.c` 的 `wd_platform_start()`(`__weak`,**不在 CubeMX 生成区**, +重生成不丢)补上按设计值配置 PR/RLR:解锁 → PR → 等 `SR.PVU` 清 → RLR → 等 `SR.RVU` 清 +→ `0xCCCC`。键值与同步上界提为具名宏(`WD_IWDG_KEY_*` / `WD_IWDG_SYNC_SPIN`)。 +主机侧单测以强符号覆盖该钩子,**不受影响**。 + +#### 取证工具的三个硬知识(后续 round 必读,可省一天) + +1. **`-R32` 不会 halt 内核**(`-CoreReg` 才 halt,实测约 30ms/次且可重复)→ 用 `-R32` 做 + **无扰动**存活性采样,只在需要 PC 时用 `-CoreReg`。(round 8c 每 0.43s 采一次样, + 而"停摆→复位"窗口实测只有 ~150ms —— **采样周期长于窗口**才系统性错过,不是采不到; + 另外 halt 期间 TIM7 ISR 不会执行,会让 `n` 出现"假停顿",须用非 halt 读法甄别。) +2. **`-R32 ` 的 `count` 单位是「字节」**(`-R32 0x1000FF00 16` 得 4 个字)。 + 按字理解会把 `.noinit` 槽**静默截断**(round 8b/8c 因此只读到 magic+段号)。 +3. **`-W32 ` 的 `data` 按「十六进制」解析**(写 `1249` 会变成 `0x1249`, + 被 12 位 RLR 截断成 `0x249`=585)。要写十进制 1249 得传 `0x4E1`。另:`IWDG->KR` 是 + **只写**寄存器(读回恒 0),故 `-W32` 写 KR 必报 `Error!`——那是 ST-LINK 的写-回读 + 校验失败,**写入其实成功**。 + +> ⚠️ 仍**不要** `git checkout`/stash;round 1~8f 全部改动未提交。 + +### 0.5.5 之后:P0-B 出口闭环(判据见本文件各节) + +> **状态:已闭环(2026-10-02 20:40)** —— 全量 `@test --require-selftest-pass` 退出码 0、 +> 标记全绿、`[OK] selftest: no fault record`;ST-LINK 回读 `No difference found`。 +> 闭环途中额外挖出并修复了比原 P0-B 更严重的存储 P0 —— **round 9:dhara resume 回滚**, +> 见 §0.5.5.1(它是"清台账清不掉"的真正根因,原 §0.5.5 第 1 步已被其推翻)。 + +1. **清台账** —— **不需要再"flush 诱导"**。原第 1 步(空 STOR + flushchk)清不掉,根因 + 不是 FTL 页缓存,而是 §0.5.5.1 的 resume 回滚:**回绕之后的任何写入在复位后整段丢弃**, + 再多 flush 也救不回来。修复后 `DELE /log/selftest.log` 一次即持久。 + - 实测:2048 B `STOR /log/probe.bin` 复位后**仍在**;`DELE` 后复位**仍不在**; + 台账现为空(`RETR /log/selftest.log` → 450 = 文件不存在)。 + - ⚠️ §0.5.7 陷阱 1 的"FTL 单页缓存回滚"表述**已被 §0.5.5.1 修正**:真正的回滚点是 + dhara checkpoint。`CTRL_SYNC` 早就把页缓存刷进了 dhara,丢的是 resume 之后的整段。 +2. **全量 `@test`**(已执行,PASS): + `& '.venv\Scripts\python.exe' test/host/run_boot_test.py --require-selftest-pass + --device-ip 192.168.1.100` + - 退出码 **0**;标记全绿:`App init OK` / `Network Init` / `net init: OK` 均在, + `FAIL`/`ERROR`/`HardFault`/`[ERR]` 皆缺席; + - `[OK] selftest: no fault record (file missing or empty)` ← **P0-B 正式 PASS**。 + - 开机日志另证两处修复生效:`[WD] armed: timeout ~2498ms (worst ~1701ms)`(= 设计值, + 修复前被静默配成 ~349~512 ms)、`[NAND_FTL] Map head=368 tail=115400 epoch=1`。 + - ⚠️ 配套修了 `test/host/run_boot_test.py` 的 `_fetch_selftest_log()`:原先只 + `except OSError`,而 `ftplib.error_temp` 继承自 `Exception`——台账"仅故障才写", + 干净设备上文件本就不存在,`RETR` 返回 **450** 会抛未捕获异常让 `@test` 在最后一步崩掉 + (本该是 PASS 路径)。现已把"设备侧无此文件"单列为正常路径。 +3. **回读**(已执行):`ST-LINK_CLI -c SWD -CmpFile "MDK-ARM\STM32F407-Demo\STM32F407-Demo.hex" + 0x08000000` → **`No difference found`** ✓ +4. **uvprojx 取舍**:保留 `App\fs\vol_lock.c` 挂载 + `../App/fs` 包含路径两个**真实差异**, + 丢弃 UV4 XML 格式 / `PackID` 噪声(判据见 `AGENTS.md`)。 +5. **文档销项**(P0-B 出口统一做): + - `子系统设计/看门狗设计说明.md`:§4.3/§4.4 白名单、§5.4、§6.1 埋点、§9/§10 状态、 + 变更记录写入**五次误复位事件**(07:56 ORE 僵尸 / 16:19 FTL-GC / 16:29 poller 饥饿 / + 16:50 STOR 前停喂 / 17:00 本轮)+ round 1~6 修复链; + - `子系统设计/控制台单写者日志设计说明.md`:ORE 僵尸根因 + round-3 缓解 + round-6 冷却, + 并登记"TX-only 结构根治"仍待办; + - 本文件 §9 留一行;`待处理内容.md` 销项(① `.ioc` USART1 TX-only 结构根治 / + ② dhara 重试自旋残留 / ③ 可选全序列卷锁;N-03 至 P1-A)。 +6. **提交**:单行中文标题 <100 字符(`git commit -F`)→ push `v2-on-ui`; + 预提交含 `watchdog-unit-test` / `cubemx-survival` / `test-suite-switch`(确认产品构建)。 +7. 之后按 §2.2/§3 起进入 **A1~A4** 与 **P1 批次**。 + +### 0.5.5.1 round 9(2026-10-02 台架):dhara resume 回滚 —— 复位后所有写入全部丢失 + +> 这是清台账清不掉的**真正根因**,且与 IWDG(§0.5.4.3)**互相独立**:即使看门狗完全修好, +> 只要这一条不修,日志/配置/FTP 上传在**任何**复位后都会丢。 + +**现象**:IWDG 修好后,清台账仍然"清了又回来"——`DELE /log/selftest.log` 后 `SIZE` 立刻 +报 550(不存在),复位后又变回 26112 B / 408 条。进一步实测: +`STOR /log/probe.bin` 无论 **2048 B 还是 65536 B,复位后都消失**;`run.log` 永远停在 92 条 +(每次开机只留下"最后一次"的 `power on`,此前各次的记录全被回滚)。 +→ 既不是 DELE 专有,也不是"没跨 checkpoint",而是**整个卷在复位后回到旧状态**。 + +**取证链**(逐步缩小,不猜): + +1. `f_unlink → sync_fs → disk_ioctl(CTRL_SYNC)` 全程无 `[ERR][NAND_FTL]` + (`@test` 也确认 `[ERR]` 缺席)→ **不是写盘报错**。 +2. 给 `disk_ioctl` 的 `CTRL_SYNC` 打点(head/tail/tail_sync/dirty/count)实测: + `dirty=1→0`,head 70→80、82→96,**每次都跨过 checkpoint 头页** + (`push_meta` 在 `head ≡ 14 (mod 16)` 写 checkpoint)→ `dhara_map_sync()` **是有效的**, + 写盘侧没问题。 +3. **决定性证据** —— 复位前后 journal 状态对比: + + | | head | tail | epoch | + |---|---|---|---| + | 复位前(`CTRL_SYNC` 之后) | 96 | 115143 | 1 | + | 复位后(`resume` 完成) | **130752** | **115068** | **0** | + + head 倒退 92 页、tail 倒退 75 页 → **`dhara_journal_resume()` 恢复回了旧 checkpoint**。 +4. 直读 NAND 两端的 checkpoint 头页(页 15 与最高块页 15)确认: + `CHK-FIRST: Dha epoch=1`(块 0 = **最新**,因为环已回绕);而 resume 锚定的是 **epoch=0** + 的旧 checkpoint。 +5. **旁证(最容易踩的坑)**:`[NAND_FTL] Map resumed: 11773/96064 sectors used` 在 + 10:05~12:17 的**每一次开机都一字不差**。覆盖写场景 `m->count` 不变,所以这个指标 + **看不出回退**;必须看 head/tail/epoch。为此已在开机日志保留一行 + `Map head=… tail=… epoch=…`(纯内存字段、零 I/O)。 + +**根因**(`Lib/dhara/journal.c`,**2026-09-20 本地补丁引入的回归**): +环**回绕之后**,"最高地址"的 checkpoint 属于**上一圈**(旧 epoch),而**最新** checkpoint 落在 +**低地址**(块 0 一侧,epoch = 旧 + 1)。2026-09-20 那次补丁为修"自愈卷重格式化每次开机丢失", +把 epoch 锚点改成 `find_last_checkblock_any()`(= 最高地址块)—— **恰好在回绕后取到旧 epoch**; +随后 `find_last_checkblock()` 用这个旧 epoch 过滤 → 选中旧块 → `find_head()` 在旧块里找空页 → +journal 整体回退到回绕之前 → **回绕之后写的一切(日志、配置、FTP 上传)复位即丢**。 + +**修复**:锚点改为"比较两个候选 epoch 谁更新"(`Lib/dhara/journal.c` 内附详细注释): + +| 两候选 epoch 关系 | 含义 | 锚点 | +|---|---|---| +| `eFirst == eLast` | 未回绕 | 相同,任取 | +| `eFirst == eLast + 1 (mod 256)` | **已回绕,低地址更新** | **`eFirst`** | +| 其它 | 低地址是上一圈残留 | `eLast`(保留 2026-09-20 修复的语义) | + +**验证**:`RSUM head=96 tail=115142 epoch=1`(回退消失)→ 2048 B STOR 与 DELE 均跨复位存活 → +`Map resumed` 计数从冻结的 11773 变为 **11774**(开机记录真正落盘)→ 全量 `@test` PASS。 + +--- + +### 0.5.6 设备 / 仓库 / 证据快照(2026-10-02 17:05) + +| 对象 | 状态 | +|---|---| +| 设备 | 运行 **round-6 镜像**(含 console 故障冷却),已过 17:00:26 误复位并重启,开机标记全绿,已进 standby;`[STACK]`/`heap` 健康(`heap free=8688 min_ever=4728`,6 任务 OK) | +| `/log/selftest.log` | **26176 B = 409 条 × 64 B**;最后一条 = `2026-10-02 09:00:24 … watchdog reset`(本轮误复位记录,**已提交落盘**)→ **S7 必 FAIL,须先清台账** | +| `/log/run.log` | 5888 B = 92 条;最后一条 = `07:24:16 reboot`(此后各次开机的 run.log 记录均被复位回滚,印证 FTL 缓存语义) | +| 工作区 | 19 个文件已改 + `App/fs/` 新增(round 1~6 修复集,**未提交**);HEAD = `d1a2eaa`;分支 `v2-on-ui` | +| 本地证据副本 | `C:\Users\67242\AppData\Local\Temp\opencode\`:`selftest_409_20261002_1700.log`(26176B/409 条,含 09:00:24 watchdog reset)、`runlog_20261002_1700.log`、`selftest_log_408_backup_20261002.log`(26112B/408 条旧态) | + +### 0.5.7 本窗口实测的关键陷阱(必读,照做可少绕一天) + +1. **写入"复位后消失"的两层原因**(本窗口最大坑,2026-10-02 修订): + - **L1 dhara checkpoint 回滚(主因,见 §0.5.5.1)**:环回绕后 `dhara_journal_resume()` + 会恢复旧 checkpoint,**之后写的一切整段丢弃**。这是"台账清了又回来""probe.bin 复位就没" + 的真正根因,**flush 诱导救不了**(已修)。 + - **L2 FTL 单页写回缓存**:任何磁盘改动(截断/新建/追加)在**写到另一个 LPN / 另一个文件 + 或 `CTRL_SYNC`** 之前只存在于 RAM(`nand_ftl.c` 的 `s_cache_buf/s_cache_dirty`); + 硬复位(含 IWDG 误复位)会丢失最后一次未跨页的写(`N-14` 已登记;`CTRL_SYNC` 已实现, + 但**没有周期性/掉电前强制 flush**,属残余风险)。 + → 排查写入丢失时**先看 §0.5.5.1 的 head/tail/epoch**(开机日志 `Map head=… tail=… epoch=…`): + head 复位后倒退 = L1(resume 错);head 正常 = L2(缓存未刷)。 + 日常"清台账/写证据后写另一个文件"的 flush 诱导只对 L2 有效。 +2. **FTP 路径**:lftpd 相对路径基准是**根目录**(`SIZE selftest.log` → 550),一律用 + 绝对路径 `/log/xxx`;`LIST /log` 实测列的是**根目录**内容(lftpd 怪癖)——查状态只用 + `SIZE` / `RETR`。 +3. **0 字节 STOR 的数据连接**存活期仅 ~10~50ms,短于芯片/网络层轮询粒度 → 芯片只见 + `DISCONNECTED (chip tcp=0x00)` → accept 失败(425 或控制连接被重置);本轮实测客户端 + `PASV→STOR` 间隔达 **11 s**(数据口 LISTEN 半开)。这是**与看门狗误复位不同的另一类 + 问题**(CH395F 数据通道);若修复后仍复现 425,转 §6(P2-B / N-2/N-3)专项。 +4. **串口采集铁律**:`open` → `clear` → 烧录/复位 → `wait-idle --timeout 90 --settle-ms 8000 + --max-ms 45000` → **直接 Read/Grep `tools/serial_tool/state/serial.log`** + (`log --grep` 子命令不可信);daemon 残留 → `taskkill /F /PID <旧>` 后重新 `open`。 +5. **只用 `.venv\Scripts\python.exe`**(PATH 里的 `python` 是 msys64);每次烧录后**必须** + ST-LINK 全片回读 `No difference found`。 +6. **误复位会使台账 +1 条** `watchdog reset`:取证/修复期间台账只会增长, + 属预期(清台账留到最后一次修复后的出口闭环)。 +7. **未提交改动是 round 1~6 的全部成果**:禁止 `git checkout` / `git stash` / `git clean`, + 提交时机 = P0-B 出口(§0.5.5 第 6 步)。 + +--- + ## 1. 环境预检(P0-A,每次上台架前必跑) > **目的**:避免"改动没问题、台架有问题"的误判(历史上多次把接线/权限问题误判为代码缺陷)。 @@ -42,7 +451,7 @@ | A1 | 工作区干净 | `git status --short` | 无输出(或有明确待提交项) | | A2 | 产品构建 0/0 | `& "MDK-ARM\build.bat"` | 末行 `BUILD_OK: 0 errors, 0 warnings` | | A3 | 构建产物为**产品态** | 检查 `test/board/test_config.h` L30-33 | 四个 `TEST_SUITE_*` **全部注释** | -| A4 | `.map` 非测试态 | 看 `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map` 的 `ER_IROM1` | ≈ **262,100 B**(产品固件);若 ≈266,540 = 停在 RTC 套件态 → 重跑 A2 | +| A4 | `.map` 非测试态 | 看 `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map` 的 `ER_IROM1` | 产品态基线**随已接受的修复增长**:2026-10-02 台架窗口实测 `0x000402B4` = **262,836 B**(初版基线 262,100 B + round 1~6 修复增量);明显阶跃到 ≈**266,540 B** = 停在套件态 → 重跑 A2 | | A5 | 全量静态门禁 | `pre-commit run --all-files` | **16/16 Passed** | | A6 | ST-LINK 探针可见 | `"C:\Program Files (x86)\STMicroelectronics\STM32 ST-LINK Utility\ST-LINK Utility\ST-LINK_CLI.exe" -c SWD -List` | 列出探针 SN `0B250D12A218303030303032` | | A7 | 串口端口正确 | 检查 `.opencode/serial_config.json` 的 `portName` | 与设备管理器一致(默认 COM7) | @@ -81,7 +490,7 @@ # 客户端连 <设备IP>:21,匿名/配置账号,GET /log/selftest.log # 或经串口工具触发 FTP 拉取(如已有脚本)。 -# 2) 对本轮串口日志 + selftest.log 联合判定(命令形态待 S7 落地后确认): +# 2) 对本轮串口日志 + selftest.log 联合判定(已落地;`--selftest-log` 可省,脚本自动 FTP 抓取): & '.venv\Scripts\python.exe' test/host/verify_log.py \ --log-path <本轮串口日志> \ --require-selftest-pass \ @@ -89,7 +498,7 @@ # 判据:退出码 0;输出含 "[OK] selftest: no fault record" ``` -**S7 落地形态(待实施,本文只给规划)**: +**S7 落地形态(✅ 2026-10-02 已落地并提交 `4a4cd29`;当前因台账未清空拿不到 PASS,见 §0.5)**: | 步骤 | 内容 | 备注 | |---|---|---| @@ -375,3 +784,4 @@ cppcheck --enable=warning --error-exitcode=1 test/ 2>&1 | tee test_cppcheck_base | 日期 | 变更 | |---|---| | 2026-10-02 | 初版:无 MCU 窗口建立。汇总 P0-A 环境预检 / P0-B `@test`+S7 / P1-A IWDG V2/V4/V5 / P1-B S11 / P2-A S9 / P2-B P1-8+N-2+N-3 / P3 S5+S4+待核实项,共 **7 批**。每项含现状证据 / 目标 / 判据(量化)/ 执行步骤(精确命令)/ 风险回滚。18 处自检调用点清单 + 模拟器差距清单 §3 决策规则交叉引用 | +| 2026-10-02 | **台架窗口执行交接**:新增 **§0.5**(P0-A 通过;S7 已落地并提交 `4a4cd29`;P0-B 唯一阻断 = 0 字节 STOR → IWDG 误复位(round 3~6 后仍 5/5 复现);round 1~6 修复集摘要;**round 7 纯取证埋点设计 + 判据表**;P0-B 出口闭环(含 FTL flush 诱导式清台账);设备/台账/本地证据快照;**7 条实测陷阱**);§2.1 标注 S7 已落地、命令形态已确认 | diff --git a/test/host/run_boot_test.py b/test/host/run_boot_test.py index 6aa53131..2d5eaafc 100644 --- a/test/host/run_boot_test.py +++ b/test/host/run_boot_test.py @@ -86,31 +86,51 @@ def _tool_cmd(py, cmd, *rest, timeout=15): return data, out +def _ftp_quit(ftp): + """尽力关闭 FTP 控制连接(QUIT 失败不影响判定)。""" + try: + ftp.quit() + except (OSError, ftplib.Error): + pass + + def _fetch_selftest_log(device_ip, out_dir, stamp): """从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。 成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 "" (按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。 + + 2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp / + error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上 + /log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个 + `@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。 + 现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。 """ local = Path(out_dir) / "selftest_log_{}.log".format(stamp) + ftp = ftplib.FTP() try: - ftp = ftplib.FTP() ftp.connect(device_ip, 21, timeout=10) ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS) - buf = bytearray() - ftp.retrbinary("RETR /log/selftest.log", buf.extend) - try: - ftp.quit() - except OSError: - pass - local.write_bytes(bytes(buf)) - print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf))) - return str(local) - except OSError as e: + except (OSError, ftplib.Error) as e: print("RUN_TEST_WARN: selftest fetch failed: {} " "(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e)) return "" + buf = bytearray() + try: + ftp.retrbinary("RETR /log/selftest.log", buf.extend) + except ftplib.all_errors as e: + # 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障) + print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录" + .format(device_ip, str(e).strip())) + _ftp_quit(ftp) + return "" + + _ftp_quit(ftp) + local.write_bytes(bytes(buf)) + print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf))) + return str(local) + def main(argv=None): # 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃