diff --git a/AGENTS.md b/AGENTS.md index 0d39b211..b5727bd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - FreeRTOS V10.3.1(CMSIS-RTOS V2);HAL 时基 = TIM7(非 SysTick),tick = 1ms;NVIC 4 位优先级分组,外设中断优先级 ≥ 5 - **内核启动(`osKernelStart`)前禁止任何 FreeRTOS/CMSIS-RTOS API 调用**(2026-09-20 启动失败实证根因,修复 `82411cf`):`osMessageQueueNew`/`osMessageQueuePut`/`osMutexNew`/`osSemaphoreNew` 等在内核启动前调用会杀死启动(09/19~09/20 "每次必死" 的代码层根因 = `syslog_mcu_init` 在内核启动前调用 `osMessageQueueNew`)。内核启动前代码的标准模式:纯寄存器/HAL 轮询操作 + **无锁静态缓冲**(如 `s_preboot[8]` 暂存内核前日志)+ **内核启动后惰性创建真队列**(任务上下文首次使用时,见 `syslog_drain`);凡在内核启动前执行的初始化函数里不得创建任何 CMSIS 对象 - CH395F SPI:每次事务须 `ch395f_spi_begin()/end()` 包裹;分频系数**必须为 8**(PCB 约束,勿"优化") -- **内存预算紧张**:新增任何任务/缓冲/栈前必须先核算 RAM 余量(`docs/内存管理/`、`待处理内容.md`) +- **就绪屏障位必须 `osFlagsNoClear`**(2026-09-21 实证):`osEventFlagsWait` **默认消费**所等位,而 `SYS_EVT_NET_READY`/`SYS_EVT_STORAGE_READY` 是**状态位**——被 netTask/uiTask/timeSync 先消费掉后,`ftpTask` 的 `WaitAll(两位)` 永不成立 → FTP 21 端口静默不监听(无日志、无报错,纯靠可观测性才揪出来)。就绪位一律 `osFlagsWaitAny|osFlagsNoClear` / `osFlagsWaitAll|osFlagsNoClear`;命令位(`SYS_EVT_CFG_SAVE`/`SYS_EVT_NET_RECONFIG`)保持消费型等待 +- **内存预算紧张**:新增任何任务/缓冲/栈前必须先核算 RAM 余量(`docs/内存管理/`、`待处理内容.md`);**栈数据来源**:`& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log`(Keil 静态调用图 → 每任务最坏链,+ 串口 `[STACK]` 运行时水位)与 `scripts/ram_report.py`(.map 静态占用);改栈或加大局部变量前后各跑一次 - **自研代码禁用 C 库堆分配**(2026-09-18,P0-1):禁止 `malloc/calloc/realloc/strdup/free`——本机工具链 MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL 且**模拟器(宿主 CRT 堆)完全正常、照不出来**。需要动态内存只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆 `configTOTAL_HEAP_SIZE`),或像配置引擎那样把分配收进平台后端(`cfg_store_alloc/free/heap_free`:MCU=FreeRTOS 堆、模拟器=宿主堆,共享核心零分配符号);分配失败必须有 `FAIL` 日志 + 自检上报,暂态峰值要写编译期预算 - **FatFs 打开模式陷阱(2026-09-20 台架实测,`Lib/FatFs` 移植版)**:`FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(/log 写失败真根因);追加写必须用 `FA_WRITE|FA_OPEN_APPEND`。该移植的 `f_write` 簇分配失败时返回 `FR_OK`+短写(无错误码),判失败必须校验写回字节数。标志位为旧式编号(`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(ff.c 内部定义)),与上游 FatFs R0.14 不同,勿照抄上游代码示例 - **FatFs 文件名约束(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)**:本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**——主名≤8 字符、扩展≤3 字符、**不得以点开头**(`.fschk` 会被 `ff.c create_short` 把点后当扩展域解析超限 → `f_open` 恒返 `FR_INVALID_NAME`(6),纯 RAM 名字判定、不碰盘)。ff.c `create_short` 全部拒绝点已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`,非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入卷损坏触发集(整卷重格式化不可逆) @@ -55,7 +56,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v | uiTask | 4096 | BelowNormal | | timeSyncTask | 512 | Normal | -(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM) +(实际值以 `Src/freertos.c` 为准;testTask 仅在启用测试套件时创建。改动栈/缓冲前先核对此表与实测。2026-09-21 上调:defaultTask 512→1024(扛 mount/mkfs/FAT探针/cfg/syslog 深层 FatFS I/O,旧值经 `configCHECK_FOR_STACK_OVERFLOW=2` 钩子实测溢出)、testTask 1024→2048(存储套件 deep call chain + 局部缓冲溢出)。FreeRTOS 堆已迁 CCM,任务栈合计已不占 SRAM;2026-09-21 可观测性落地后实测:**ftpTask 峰值 3480B/4096B(余量仅 616B = 15%,低于 1024B 底线)**,处置见 `docs/内存管理/RAM使用分析与优化方案.md` §0.6——建议先做 §1.4(lftpd 大局部变量静态化)再定栈) ## 架构要点 diff --git a/App/task/net_task.c b/App/task/net_task.c index 790ece87..76541549 100644 --- a/App/task/net_task.c +++ b/App/task/net_task.c @@ -173,8 +173,9 @@ 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, 10000u); + osFlagsWaitAny | osFlagsNoClear, 10000u); } if (net_stack_bring_up() != 0) { diff --git a/App/task/ui_task.c b/App/task/ui_task.c index 1ad33d34..da62144d 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -44,8 +44,9 @@ 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, osWaitForever); + osFlagsWaitAny | osFlagsNoClear, osWaitForever); } } diff --git a/App/time_sync.c b/App/time_sync.c index 9815f8ed..03614fda 100644 --- a/App/time_sync.c +++ b/App/time_sync.c @@ -107,10 +107,11 @@ static void time_sync_handle_conn(int conn_sock) { void time_sync_task(void *argument) { (void)argument; - /* 等网络栈真正就绪(取代拍脑袋的 8s 魔法延时),确保 netTask 已开始处理消息 */ + /* 等网络栈真正就绪(取代拍脑袋的 8s 魔法延时),确保 netTask 已开始处理消息; + * osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒) */ if (sys_evtHandle != NULL) { (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, - osFlagsWaitAny, osWaitForever); + osFlagsWaitAny | osFlagsNoClear, osWaitForever); } for (;;) { diff --git a/Src/freertos.c b/Src/freertos.c index 709b9018..b64e8e9b 100644 --- a/Src/freertos.c +++ b/Src/freertos.c @@ -52,6 +52,10 @@ /* Private define ------------------------------------------------------------*/ /* USER CODE BEGIN PD */ +/* 栈水位观测节奏:首次报告须晚于 bring-up 深链(mount/mkfs/探针/cfg/syslog), + * 之后每周期只判定"是否出现更深峰值",无新峰值不打印(保持串口静默窗口) */ +#define STACK_PROBE_FIRST_MS 5000u +#define STACK_PROBE_PERIOD_MS 2000u /* USER CODE END PD */ /* Private macro -------------------------------------------------------------*/ @@ -121,6 +125,7 @@ const osEventFlagsAttr_t sys_evt_attributes = { /* Private function prototypes -----------------------------------------------*/ /* USER CODE BEGIN FunctionPrototypes */ void ui_task_func(void *argument); +static void stack_probe_poll(void); /* 栈水位观测(可观测性,实现见文件末尾) */ #if !defined(TEST_SUITE_STORAGE) static void default_task_net_cfg_save_notify(void); #endif @@ -372,6 +377,8 @@ void StartDefaultTask(void *argument) /* 排空运行期日志(上电/重启分类与自检故障落盘) */ syslog_drain(); #endif + /* 栈水位观测:首次 5s 后报告,此后每 2s 采样、出现更深峰值才打印 */ + stack_probe_poll(); } /* USER CODE END StartDefaultTask */ } @@ -408,9 +415,13 @@ __weak void StartFtpTask(void *argument) osWaitForever:无限期等待,不设超时——任务会挂起(让出 CPU,零占用),直到生产者置位对应位才被唤醒 */ 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, osWaitForever); + osFlagsWaitAll | osFlagsNoClear, osWaitForever); } lftpd_t ftp; lftpd_start("/", 21, &ftp); @@ -455,6 +466,101 @@ void StartUiTask(void *argument) /* Private application code --------------------------------------------------*/ /* USER CODE BEGIN Application */ +/* + * 栈水位观测(可观测性,2026-09-21) + * 数据源:任务栈"历史最小剩余"——内核按特征值填充法统计(与 + * configCHECK_FOR_STACK_OVERFLOW=2 同一机制),osThreadGetStackSpace() 取剩余 + * 字节,配置字节取各任务 *_attributes.stack_size(唯一定义处,避免常量重复; + * 本版 CMSIS 包装只有 GetStackSpace 实现,无 osThreadGetStackSize)。 + * 峰值占用 = 配置 - 剩余。 + * 定栈规则(docs/内存管理/RAM使用分析与优化方案.md):新栈 = 峰值*1.5 + 1KB ISR + * 余量,并要求 free_min >= 新栈*30% 才算合格。 + * 节奏:首次 STACK_PROBE_FIRST_MS 后报告(bring-up 深链已跑过),此后每 + * STACK_PROBE_PERIOD_MS 采样一次,只在出现更深峰值时打印——既不改串口静默 + * 窗口,也不漏掉之后才走到的更深分支(如卷修复/配置保存路径)。 + * 输出(纯 ASCII,便于 @test 采集): + * [STACK] peak_used=/ free_min= (%) + * [STACK] heap free= min_ever= + * 未覆盖 idle/timer 任务(内核静态小栈、纯循环,未取句柄)。 + */ +typedef struct { + const char *p_name; + const osThreadId_t *p_id; + const osThreadAttr_t *p_attr; /* 配置栈大小来源(stack_size) */ +} stack_probe_task_t; + +static const stack_probe_task_t s_stack_probe[] = { + { "default", &defaultTaskHandle, &defaultTask_attributes }, + { "net", &netTaskHandle, &netTask_attributes }, + { "ftp", &ftpTaskHandle, &ftpTask_attributes }, + { "test", &testTaskHandle, &testTask_attributes }, + { "ui", &uiTaskHandle, &uiTask_attributes }, + { "timesync", &timeSyncTaskHandle, &timeSyncTask_attributes }, +}; + +#define STACK_PROBE_COUNT (sizeof(s_stack_probe) / sizeof(s_stack_probe[0])) + +static void stack_probe_poll(void) +{ + static uint32_t s_free_min[STACK_PROBE_COUNT]; /* 历史最小剩余(B),0=未采样 */ + static uint32_t s_last_tick; + static uint8_t s_reported; + const TickType_t now = xTaskGetTickCount(); + uint32_t i; + uint8_t deeper = 0u; + + if (s_reported == 0u) { + if (now < pdMS_TO_TICKS(STACK_PROBE_FIRST_MS)) { + return; + } + } else if ((uint32_t)(now - s_last_tick) < (uint32_t)pdMS_TO_TICKS(STACK_PROBE_PERIOD_MS)) { + return; + } + s_last_tick = (uint32_t)now; + + for (i = 0u; i < (uint32_t)STACK_PROBE_COUNT; i++) { + const osThreadId_t th = *(s_stack_probe[i].p_id); + uint32_t free_now; + + if (th == NULL) { + continue; /* 任务未创建 */ + } + free_now = osThreadGetStackSpace(th); + if (free_now == 0u) { + continue; /* 句柄不可用:跳过 */ + } + if ((s_free_min[i] == 0u) || (free_now < s_free_min[i])) { + s_free_min[i] = free_now; /* 刷新历史最小(= 最深峰值) */ + deeper = 1u; + } + } + + if ((s_reported != 0u) && (deeper == 0u)) { + return; /* 无新峰值:静默 */ + } + s_reported = 1u; + + for (i = 0u; i < (uint32_t)STACK_PROBE_COUNT; i++) { + const uint32_t free_min = s_free_min[i]; + const uint32_t cfg = (uint32_t)s_stack_probe[i].p_attr->stack_size; + + if (free_min == 0u) { + continue; + } + if (cfg == 0u) { + continue; + } + DBG_INFO("[STACK] %s peak_used=%lu/%lu free_min=%lu (%lu%%)", + s_stack_probe[i].p_name, + (unsigned long)(cfg - free_min), (unsigned long)cfg, + (unsigned long)free_min, + (unsigned long)((free_min * 100u) / cfg)); + } + DBG_INFO("[STACK] heap free=%lu min_ever=%lu", + (unsigned long)xPortGetFreeHeapSize(), + (unsigned long)xPortGetMinimumEverFreeHeapSize()); +} + /* * "保存网络配置"请求通知(注册给 net_cfg 的保存交接): * uiTask 投递保存请求 → 置 SYS_EVT_CFG_SAVE → defaultTask 循环立即唤醒执行写盘 diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index aec556cf..4f7f4571 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -131,46 +131,38 @@ void vApplicationMallocFailedHook(void) 成本:每次上下文切换几十周期,不占 RAM。 -#### 0.2 新增 `app_mem_stats` 模块(每任务消耗 + 堆余量报告) +#### 0.2 运行时栈水位(已落地:`stack_probe_poll`,2026-09-21) -新文件 `App/util/app_mem_stats.h/.c`(**需手动加入 Keil 工程**,见 AGENTS.md;命名/头文件保护宏遵守代码规范 V1.0): +实现位置:`Src/freertos.c` 的 USER CODE 区(**不新增文件**,无 Keil/CMake 双端注册成本)。 +数据源是本版 CMSIS/FreeRTOS **已有**的 API:`osThreadGetStackSpace()`(剩余字节,内部走 +`uxTaskGetStackHighWaterMark`,特征值法与 `configCHECK_FOR_STACK_OVERFLOW=2` 同源)。 +本版**没有** `osThreadGetStackSize` 实现,也**没有** `ulTaskGetStackDepth` / +`xPortGetTotalHeapSize`,因此配置栈统一取各任务 `*_attributes.stack_size`(唯一定义处,不重复常量)。 -```c -/* 核心逻辑(骨架):枚举全部任务 → 逐个取栈配置/高水位 → 报告堆 */ -void app_mem_stats_report(const char *tag) -{ - osThreadId_t a_ids[16]; - uint32_t n = osThreadEnumerate(NULL, a_ids, 16); /* configUSE_OS2_THREAD_ENUMERATE=1 已开 */ - DBG_INFO("[MEM][%s] ---- task ram ----", tag); - for (uint32_t i = 0; i < n; i++) { - TaskHandle_t th = xTaskGetHandle(a_ids[i]); - if (th == NULL) continue; - uint32_t total = ulTaskGetStackDepth(th); /* 分配栈,单位 word */ - uint32_t hwm = uxTaskGetStackHighWaterMark(th); /* 历史最小剩余,word */ - DBG_INFO("[MEM] %-14s stack=%5uB used=%5uB free_min=%5uB", - pcTaskGetName(th), total * 4U, - (total - hwm) * 4U, hwm * 4U); - } - DBG_INFO("[MEM] heap free=%uB total=%uB", - (unsigned)xPortGetFreeHeapSize(), (unsigned)xPortGetTotalHeapSize()); -} +行为:挂在 defaultTask 主循环——首次 5s 后报告一次(bring-up 深链 mount/mkfs/探针/cfg/syslog +均已跑过),此后每 2s 采样,**只在出现更深峰值时打印**:既不破坏串口静默窗口,也不漏掉 +之后才走到的更深分支(卷修复、配置保存等)。输出纯 ASCII,便于 `@test` 采集: + +``` +[STACK] peak_used=/ free_min= (%) +[STACK] heap free= min_ever= ``` 要点: -- `uxTaskGetStackHighWaterMark` 返回**任务创建以来**的最小剩余栈(单位 word=4B),`used = (栈配置 − 高水位)×4` 即该任务历史峰值栈占用。API 已在 `FreeRTOSConfig.h:116` 启用。 -- 报告时机:① 启动完成后一次;② 每 5 分钟周期性一次(挂在 defaultTask 的 LED 循环里);③ 关键动作后(FTP 连接/大文件传输、f_mkfs、菜单遍历到最深层级)。 -- **测量必须覆盖全功能路径**(高水位是历史最小值,只反映跑过的路径):全菜单遍历、FTP 上传/下载大文件、CH395F 断链重连、NAND 格式化、时间同步。建议跑一轮 `@test` 全回归 + 1 小时浸泡。 +- `uxTaskGetStackHighWaterMark` 返回**任务创建以来**的最小剩余栈(单位 word=4B);`used = 配置 − 剩余` 即该任务历史峰值栈占用。 +- **测量必须覆盖全功能路径**(高水位是历史最小值,只反映跑过的路径):全菜单遍历、FTP 上传/下载大文件、CH395F 断链重连、NAND 格式化、时间同步。建议 `@test` 全回归 + 1 小时浸泡。 +- 未覆盖 idle/timer(内核静态小栈、纯循环,未取句柄);构建期静态侧见 §0.6。 #### 0.3 map 静态占用报告脚本 -新增 `scripts/ram_report.ps1`: +脚本 `scripts/ram_report.py`(Python,已随仓库提供): - 解析 `MDK-ARM/STM32F407-Demo/STM32F407-Demo.map`: - RW_IRAM1 Region 的 `Size` / `Max` → 主 SRAM 静态水位 - "Image component sizes" 表 → 每 .o 的 ZI/RW 排序 Top20 -- 与基线 `scripts/ram_baseline.tsv` 做 diff,任何对象 ZI 增量 >1KB 即高亮 -- 用法:每次 `@build` 后运行 `powershell scripts/ram_report.ps1`,把 diff 贴进改动说明 +- 用法:`& '.venv\Scripts\python.exe' scripts/ram_report.py -o docs/内存管理/data/ram_report_<日期>.md`,把结果贴进改动说明 +- (待补)与基线 TSV 做 diff,任何对象 ZI 增量 >1KB 即高亮 #### 0.4 (可选)运行时间统计 @@ -180,11 +172,75 @@ void app_mem_stats_report(const char *tag) 启动期跑在 startup 的 1KB C 栈上(CubeMX 初始化 + 各驱动 init,含 BBT 全扫描)。ARMCC 可用 `__current_stack_usage()` 在 `app_main_init` 末尾打点,确认 1KB 够用;不够则调 scatter 的 `STACK` 节(与任务栈无关,单独预算)。 +#### 0.6 静态栈分析 + 汇总工具(构建期,零目标依赖;2026-09-21 落地) + +**UV4 每次构建已自动生成静态调用图** `MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm` +(标题 "Static Call Graph",随 `@build` 刷新,**无需**给 armlink 加 `--callgraph`)。每个函数 +条目自带 `Stack size N bytes`(该函数自身帧)与 `Max Depth = N`(其子树最坏累计深度) +以及完整调用链——即"不烧板子也能看到最坏链有多深、多宽"。 + +**用法:`scripts/stack_report.py`**(纯标准库,任意 python3 均可;解析上述 HTM + +`Src/freertos.c` 的栈配置,带 `-s` 时叠加串口 `[STACK]` 运行时水位): + +```powershell +# 最小用法(只看静态:每任务最坏链 + 余量判定) +& '.venv\Scripts\python.exe' scripts/stack_report.py + +# 推荐用法(静态 + 实测一起判,判定取两者更坏者;报告归档到 data/) +& '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log ` + -o 'docs\内存管理\data\stack_report_<日期>.md' +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `-i/--html` | `MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm` | 静态调用图(Keil 构建产物) | +| `-s/--serial` | 无 | 串口日志,解析其中 `[STACK]` 行(daemon 全量历史:`tools/serial_tool/state/serial.log`) | +| `-o/--output` | 无(打印到 stdout) | 报告输出文件(Markdown) | +| `--isr-margin` | `1024` | ISR 嵌套预留(字节) | +| `--min-free-pct` | `30` | 要求的最小余量百分比 | + +- **判定规则**:`余量 = 配置 − max(静态最坏深度, 实测峰值)`,须 `≥ max(1024 B, 配置×30%)`;任一任务不达标 → 脚本退出码 **1**(可挂门禁),全达标 → 0,HTM 缺失 → 2。 +- **何时跑**:① 改栈或加大局部变量前后;② 改 FatFs/lftpd/LVGL 等第三方配置后;③ 任务职责变化时。串口日志覆盖的路径越全越准(路径覆盖要求见 §0.2)。 +- **报告内容**:每任务预算表(配置/静态/实测/最坏/余量/判定)、各任务最坏调用链、帧宽 Top15(找"宽度大户")、堆 free/min_ever。 + +首份基线(2026-09-21,产品构建 + FTP 会话实测;产物 `docs/内存管理/data/stack_report_20260921.md`): + +| 任务 | 配置 (B) | 静态最坏 (B) | 实测峰值 (B) | 最坏 (B) | 余量 | 判定 | +|---|---|---|---|---|---|---| +| defaultTask | 4096 | **2776** | 2136 | 2776 | 1320 (32%) | PASS | +| **ftpTask** | 4096 | 2528 | **3480** | **3480** | **616 (15%)** | **FAIL** | +| netTask | 4096 | 440 | 600 | 600 | 3496 (85%) | PASS | +| uiTask | 16384 | 912 | 3804 | 3804 | 12580 (76%) | PASS | +| timeSyncTask | 2048 | 336 | 420 | 420 | 1628 (79%) | PASS | +| testTask(产品构建) | 8192 | 168 | 348 | 348 | 7844 (95%) | PASS | + +实测堆:`free=8784 B / min_ever=4848 B`(48KB 池)。 + +**待办(由本次实测直接得出)**:ftpTask 余量仅 616 B(15%),低于 1024 B / 30% 底线。两条路: +① 按 §1.3 定栈规则 `峰值×1.5 + 1KB ≈ 6.2KB` → ftpTask 提到 **1600 words (6400 B)**(最省事); +② 先做 §1.4(lftpd 大局部变量移静态:`handle_control_channel` 1104 B、`send_file`/`receive_file` 1088 B、`send_response` 800 B)再复测,栈只需小步上调。建议 **②→①** 顺序:先砍宽度,避免长期背 6.4KB。 + +**静态与实测是互补的两套数据(必须都留)**: + +- defaultTask:静态 2776 > 实测 2136——静态是**上界**(最深分支未必走到),实测是**已走路径**。**判栈够不够看两者更坏者,做定栈优化看实测。** +- netTask / uiTask / testTask / timeSyncTask / **ftpTask**:实测 > 静态——静态是**下界**(互递归、间接调用、ISR 上下文保存 ~100B 未计入)。ftpTask 最典型:静态 2528 B(控制通道握手链)**低估了 950 B**,真实峰值 3480 B 只在 128KB STOR 传输时暴露 → **不跑真实业务就定栈 = 拍脑袋。** +- 本次可观测性的第一个产出不是数字而是**缺陷**:ftpTask 因就绪位被其它等待者消费而**从未启动**(21 端口一直无监听),修好后才有了上面这组数据——详见 `docs/测试说明/FTP_Test_Guide.md` §8.8。 + +要点与边界: + +- defaultTask 的静态最坏链是**配置保存/旧档迁移**路径,不是首启探针路径: + `net_cfg_load → net_cfg_legacy_migrate → net_cfg_save → cfg_file_save → s_write_template → s_write_doc → cfg_store_write_atomic → f_rename → follow_path → dir_next → create_chain → put_fat → move_window → sync_window → disk_write → ftl_flush_cache → dhara_map_write → prepare_write → auto_gc → dhara_map_gc → ... → trace_path → dhara_journal_read_meta → dhara_nand_read → gd5f_read_from_cache → HAL_SPI_Receive → ... → SPI_WaitFlagStateUntilTimeout`。 + **2776 B > 2048 B** → 512 words 栈必然溢出,与 2026-09-21 实测完全一致。 +- 帧宽榜直接点名宽度大户(自身帧):`handle_control_channel` 1104 B、`receive_file`/`send_file` 1088 B、`send_response` 800 B、**`s_append` 704 B**(FIL 512 + 记录 64 + 时间戳)、**`StartDefaultTask` 560 B**(探针 `FIL __chk`)、`cfg_store_read`/`cfg_store_write_atomic` 576 B——与 §1.3/§1.4 的整改项一一对得上。 +- **静态分析的天然盲区**:递归/互递归(LVGL widget 树、`layout_update_core`、`apply_theme` 等)与间接调用只能按 "Cycle" 近似,armlink 明确标注 `+ Unknown(Cycles, Untraceable Function Pointers)`。因此 uiTask 的 912 B 是**下限**(实测 3804 B),必须靠运行时水位。 +- testTask 在产品构建里只是 `osDelay` 空循环(168 B);2048 words 是给**存储套件构建**(`TEST_SUITE_STORAGE`)预留的,测套件时应重跑本脚本看该构建的静态值。 +- **测 ftpTask 的复现配方**:设备就绪后跑 `& '.venv\Scripts\python.exe' test/ftp_speed_test.py --size 128`(STOR 128KB + RETR + 逐字节比对),再按上面命令叠加串口日志出报告。**不要用裸 TCP 工具探测 21 端口**——CH395F 单客户端复用,探测会占掉唯一会话(见 FTP 指南 §8.9)。 + #### 阶段 0 验收 -- 串口可见 `[MEM]` 报告:每任务 `stack/used/free_min` + 堆 free/total +- 串口可见 `[STACK]` 报告:每任务 `peak_used/free_min` + 堆 `free/min_ever` - 人为制造栈溢出(临时把某任务栈改到 64 字节)能触发 `STACK OVERFLOW` 钩子 -- `ram_report.ps1` 能输出 Top20 与基线 diff +- `scripts/ram_report.py` 输出 .map 静态占用 Top20;`scripts/stack_report.py` 输出每任务静态余量(可叠加运行时水位) ### 阶段 1:静态大项削减(收益最大,3~5 天) diff --git a/docs/内存管理/data/stack_report_20260921.md b/docs/内存管理/data/stack_report_20260921.md new file mode 100644 index 00000000..5071b383 --- /dev/null +++ b/docs/内存管理/data/stack_report_20260921.md @@ -0,0 +1,79 @@ +# Stack budget report + +- Source: `MDK-ARM\STM32F407-Demo\STM32F407-Demo.htm` (Keil static call graph) +- Linker run: Mon Sep 21 04:08:58 2026 +- Global maximum stack usage: **2776 B** (any call tree) +- Runtime watermark: 6 task(s) from `tools\serial_tool\state\serial.log` + +Rule: `margin = cfg - max(static_max_depth, runtime_peak)` must be >= max(1024 B ISR, 30% of cfg). + +| Task | Entry | Cfg (B) | Static max (B) | Runtime peak (B) | Worst (B) | Margin (B) | Margin % | Verdict | +|---|---|---|---|---|---|---|---|---| +| defaultTask | `StartDefaultTask` | 4096 | 2776 | 2136 | 2776 | 1320 | 32% | PASS | +| netTask | `StartNetTask` | 4096 | 440 | 600 | 600 | 3496 | 85% | PASS | +| ftpTask | `StartFtpTask` | 4096 | 2528 | 3480 | 3480 | 616 | 15% | FAIL | +| uiTask | `StartUiTask` | 16384 | 912 | 3804 | 3804 | 12580 | 76% | PASS | +| testTask | `start_test_task` | 8192 | 168 | 348 | 348 | 7844 | 95% | PASS | +| timeSyncTask | `time_sync_task` | 2048 | 336 | 420 | 420 | 1628 | 79% | PASS | + +`Static max` = armlink worst-case call-tree depth; `Runtime peak` = measured high-water mark (boot + whatever workload ran before the sample); `Worst` = max of the two, which is what the verdict is computed from. + +Runtime heap: free=8784 B, min_ever=4848 B + +## Worst call chain per task + +### defaultTask: 2776 B (own frame 560 B) + +``` +StartDefaultTask -> net_cfg_load -> net_cfg_legacy_migrate -> net_cfg_save -> cfg_file_save -> s_write_template -> s_write_doc -> cfg_store_write_atomic -> f_rename -> follow_path -> dir_next -> create_chain -> put_fat -> move_window -> sync_window -> disk_write -> ftl_flush_cache -> dhara_map_write -> prepare_write -> auto_gc -> dhara_map_gc -> try_recover -> raw_gc -> trace_path -> dhara_journal_read_meta -> dhara_nand_read -> gd5f_read_from_cache -> HAL_SPI_Receive -> HAL_SPI_TransmitReceive -> SPI_EndRxTxTransaction -> SPI_WaitFlagStateUntilTimeout +``` + +### netTask: 440 B (own frame 0 B) + +``` +StartNetTask -> net_task_func -> net_stack_bring_up -> net_init -> ch395f_reset -> sys_clock_get_str -> unix_to_sd2506 -> __aeabi_uldivmod +``` + +### ftpTask: 2528 B (own frame 24 B) + +``` +StartFtpTask -> lftpd_start -> handle_control_channel -> send_response -> lftpd_inet_write_string -> net_send -> osMessageQueuePut -> xQueueGenericSend -> xTaskResumeAll -> xTaskIncrementTick +``` + +### uiTask: 912 B (own frame 0 B) + +``` +StartUiTask -> ui_task_func -> ui_menu_init -> ui_menu_view_frame_build -> view_apply_sel -> lv_obj_scroll_to_y -> lv_obj_scroll_by_bounded -> lv_obj_update_layout -> layout_update_core -> layout_update_core (Cycle) +``` + +### testTask: 168 B (own frame 32 B) + +``` +start_test_task -> sys_clock_get_str -> unix_to_sd2506 -> __aeabi_uldivmod +``` + +### timeSyncTask: 336 B (own frame 32 B) + +``` +time_sync_task -> time_sync_handle_conn -> sys_clock_set_unix -> sys_clock_set -> sd2506_set_time -> sd2506_write_enable -> sd2506_write_reg -> HAL_I2C_Mem_Write -> I2C_RequestMemoryWrite -> I2C_WaitOnMasterAddressFlagUntilTimeout +``` + +## Frame-size ranking (own frame, top 15) + +| Bytes | Function | +|---|---| +| 1104 | `handle_control_channel` | +| 1088 | `receive_file` | +| 1088 | `send_file` | +| 800 | `send_response` | +| 704 | `s_append` | +| 624 | `lv_draw_sw_arc` | +| 624 | `draw_indic` | +| 576 | `cfg_store_read` | +| 576 | `cfg_store_write_atomic` | +| 576 | `s_read_raw` | +| 560 | `StartDefaultTask` | +| 560 | `draw_main` | +| 400 | `draw_line_skew` | +| 352 | `lv_draw_sw_triangle` | +| 336 | `lftpd_start` | diff --git a/docs/测试说明/FTP_Test_Guide.md b/docs/测试说明/FTP_Test_Guide.md index a4797aa8..73ebaada 100644 --- a/docs/测试说明/FTP_Test_Guide.md +++ b/docs/测试说明/FTP_Test_Guide.md @@ -23,7 +23,9 @@ - CH395F 网口经以太网连接到 PC 或同一台交换机。 - PC 网卡与固件 IP **同网段**。固件默认静态 IP **192.168.1.100**(掩码 255.255.255.0);PC 可设 192.168.1.x(如 192.168.1.77)。 -- 启动时 `netTask` 完成 `net_init()` + PHY 稳定(约 >2s),`ftpTask` 另有 `osDelay(7000)` 等待后才 `listen`;若 `open_socket` 失败会 `vTaskDelay(3000)` 重试(最多 3 次)。**客户端应在上电约 7s 后、串口出现 `[FTP] waiting for connection...` 再连接。** +- **启动时序(2026-09-21 现行实现,取代旧文档的 `osDelay(7000)`)**:`netTask` 完成 `net_init()` + PHY 稳定后置 `SYS_EVT_NET_READY`;`defaultTask` 挂载卷 + 加载配置后置 `SYS_EVT_STORAGE_READY`;`ftpTask` 以 `osEventFlagsWait(两位, osFlagsWaitAll | osFlagsNoClear)` 等**两位同时就绪**再 `lftpd_start`。串口出现 `[FTP] waiting for connection...` 即可连接(实测:上电后约 0.5~1s,取决于存储挂载耗时)。 +- ⚠ **就绪位等待必须带 `osFlagsNoClear`**(2026-09-21 实证,详见 §8.6):`osEventFlagsWait` 默认**消费**所等待的位,若被其它等待者清掉,`ftpTask` 的 `WaitAll(两位)` 永不成立 → FTP 静默不监听。 +- **单客户端**:监听 socket 与数据通道复用,同一时刻只服务 1 个会话;上一个会话未 `QUIT` 就断开时,服务端约 30s 后才丢弃旧会话并重新 listen,期间新连接被拒(`WinError 10061`)。脚本已内置连接重试;**不要用裸 TCP 工具探测 21 端口**(探测本身会占掉唯一会话,见 §8.7)。 ### 2.2 软件 @@ -46,7 +48,8 @@ ## 4. PC 端脚本手测(主要手段) -所有脚本支持 `--host`(默认 `192.168.1.100`)、`--port`(默认 21),在 `test/` 目录运行。 +所有脚本支持 `--host`(默认 `192.168.1.100`)、`--port`(默认 21),在 `test/` 目录运行;`ftp_up_dl_test.py` / `ftp_speed_test.py` 内置**连接重试**(单客户端:旧会话未释放时自动等待重试,默认 6 次 × 3s)。 +推荐入口:`& '.venv\Scripts\python.exe' test/ftp_up_dl_test.py`(功能闭环:登录/TYPE I/PASV/STOR/LIST/RETR/比对/DELE/QUIT)与 `& '.venv\Scripts\python.exe' test/ftp_speed_test.py --size 128`(大文件 + 吞吐);后者同时是 **ftpTask 栈水位测量**的负载(见 `docs/内存管理/RAM使用分析与优化方案.md` §0.6)。 ### 4.1 冒烟测试:`ftp_test.py` @@ -103,10 +106,10 @@ ## 7. 测试清单(Checklist) -- [ ] 默认构建烧录,串口见 `[FTP] waiting for connection`。 +- [ ] 默认构建烧录,串口见 `[FTP] waiting for connection`(缺此行 → 见 §8.6)。 - [ ] `ftp_test.py` 冒烟 PASS(连接 / 登录 / PASV / LIST)。 -- [ ] `ftp_up_dl_test.py` 上传下载比对 `MATCH`。 -- [ ] `ftp_speed_test.py` 大文件 `MATCH` + 记录速度基线。 +- [ ] `ftp_up_dl_test.py` 上传下载比对 `MATCH`(10 项全 PASS)。 +- [ ] `ftp_speed_test.py` 大文件 `MATCH` + 记录速度基线;同时用 `scripts/stack_report.py` 复核 ftpTask 栈余量(内存方案 §0.6)。 - [ ] (可选)GUI 客户端手动上传/下载成功。 - [ ] (可选)阶段 8 自动化 `TC-NET-801/802/803` PASS。 @@ -139,11 +142,27 @@ - **TRAP-12**:FatFS 根路径 `/` 前缀由 `to_fatfs_path()` 统一处理;客户端用绝对路径(如 `/UPLOAD.DAT`)即可。 -### 8.6 单客户端限制 +### 8.6 FTP 从不监听(21 端口一直拒绝) -- FTP 仅接受 1 个并发客户端;上一个会话未 `QUIT` 前,新连接会被拒。每次测完务必 `QUIT` / 断开。 +- 现象:串口无 `[FTP] waiting for connection...`、无 `[LFT_INET]` 报错,`[STACK]` 行里 ftpTask 峰值极低(实测 156 B,仅为任务入口),21 端口始终 `connection refused`。 +- 根因(2026-09-21 定位并修复):`osEventFlagsWait` 默认**消费**所等待的位,而 `SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` 是**状态位**。`netTask` 等 `STORAGE_READY` 时清掉 bit1,`uiTask` / `timeSyncTask` 等 `NET_READY` 时清掉 bit0 → `ftpTask` 的 `WaitAll(bit0|bit1)` 条件永不成立,任务永久阻塞在内核等待队列(无日志、无报错、静默失效)。 +- 修复:**所有就绪位等待加 `osFlagsNoClear`**(`Src/freertos.c` 的 ftpTask、`App/task/ui_task.c`、`App/time_sync.c`、`App/task/net_task.c`、`test/ch395f_test_task.c`);命令位(`SYS_EVT_CFG_SAVE` / `SYS_EVT_NET_RECONFIG`)保持消费型等待不变。 +- 验证:修后串口出现 `[FTP] waiting for connection...`、21 端口可连;ftpTask 的 `[STACK]` 峰值握手后升到 ~2.5KB、128KB 传输后升到 ~3.4KB(见内存方案 §0.6,余量告警)。 -### 8.7 LIST/STOR/RETR 全部 550:卷未挂载 / 无 FAT 文件系统 +### 8.7 单客户端限制:端口"通"但随后连接被拒(旧会话未释放) + +- **FTP 仅接受 1 个并发客户端**:上一个会话未 `QUIT` 前,新连接会被拒;每次测完务必 `QUIT` / 断开。 +- 现象:用 `Test-NetConnection` / `nc` 探测 21 端口显示可达,紧接着 FTP 脚本却 `10061 connection refused`。 +- 根因:探测本身完成了一次 TCP 握手 → 占用了 CH395F 唯一的会话 socket;服务端进入控制通道等待,直到读超时(约 30s)才打印 `[ERR] [FTP] error reading next command` 并回到 `waiting for connection...`。 +- 处理:**别用裸 TCP 工具探测 21 端口**;用 FTP 脚本(已内置重试:默认 6 次 × 3s)。若已被占用,等约 30s 或复位设备。 + +### 8.8 吞吐远低于基线(本次实测 ~50~60 KB/s @128KB) + +- 本次 128KB STOR/RETR 实测上/下行约 49 / 60 KB/s,明显低于阶段 5 记录的 300~450 KB/s(`CH395F_Test_Guide.md`)。 +- 已知差异:FTP 路径每次读 512B 经 FatFs→FTL→NAND 写/读,而阶段 5 基线是裸 socket 吞吐;小文件(128KB)还叠加了会话建立/关闭开销。 +- 待办:如需吞吐结论,用 `ftp_speed_test.py --size 512`~`2048` 复测并与阶段 5 基线对齐;必要时再查 `WRITE_SEND_BUF` 分帧与 `net_poll` 周期。 + +### 8.9 LIST/STOR/RETR 全部 550:卷未挂载 / 无 FAT 文件系统 - 根因:FTP 服务的是 NAND 上的 FatFS 卷。固件在 `StartDefaultTask` 启动期执行 `f_mount(&fs, "", 1)`(会触发 `disk_initialize` → `nand_ftl_init`),使卷可用。 - **首启自动建卷**:若 `f_mount` 返回 `FR_NO_FILESYSTEM`(如坏块重建/恢复测试后卷被清空),固件会自动 `f_mkfs`(FM_FAT32、无分区表,参数与存储测试一致)再挂载。该过程上电会多花数秒(格式化),串口可见 `[FS] no FAT volume, formatting NAND...` → `[FS] FATFS mounted`。 diff --git a/docs/系统墙钟时间维护说明.md b/docs/系统墙钟时间维护说明.md index 274337e4..662123b4 100644 --- a/docs/系统墙钟时间维护说明.md +++ b/docs/系统墙钟时间维护说明.md @@ -329,7 +329,9 @@ PC 每轮发送固定 8 字节帧: `timeSyncTask` 启动后先等待 `SYS_EVT_NET_READY` 事件标志(取代旧版 8s 魔法延时;网络不就绪则一直等待),进入循环: ```c -osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny, osWaitForever); +/* osFlagsNoClear:就绪位是"状态"不是"事件",任何消费者都不得把它清掉 + * (否则其它等待者永不唤醒——2026-09-21 FTP 永不监听事故的根因) */ +osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny | osFlagsNoClear, osWaitForever); for (;;) { ls = time_sync_open_listener(); /* net_socket + net_bind + net_listen,失败重试 */ diff --git a/docs/项目启动顺序管理.md b/docs/项目启动顺序管理.md index c24950e7..5211aea1 100644 --- a/docs/项目启动顺序管理.md +++ b/docs/项目启动顺序管理.md @@ -11,9 +11,9 @@ |------|------|--------------| | `defaultTask` | FTL + FatFS 存储挂载/测试(一次性) | 无(最先跑) | | `netTask` | `net_init()` → `net_poll()` 周期轮询 | 无 | -| `ftpTask` | `lftpd_start("/", 21, &ftp)` | 等 `SYS_EVT_NET_READY | SYS_EVT_STORAGE_READY`(`osFlagsWaitAll`) | -| `start_test_task`(testTask) | 统一测试入口,按 `TEST_SUITE_*` 分发 | NET 模式下 `osEventFlagsWait(SYS_EVT_NET_READY, osFlagsWaitAny, osWaitForever)` 阻塞等待 | -| `timeSyncTask` | 墙钟时间同步 | `osEventFlagsWait(SYS_EVT_NET_READY, …)` 后开始同步 | +| `ftpTask` | `lftpd_start("/", 21, &ftp)` | 等 `SYS_EVT_NET_READY + SYS_EVT_STORAGE_READY`(`osFlagsWaitAll \| osFlagsNoClear`) | +| `start_test_task`(testTask) | 统一测试入口,按 `TEST_SUITE_*` 分发 | NET 模式下 `osEventFlagsWait(SYS_EVT_NET_READY, osFlagsWaitAny \| osFlagsNoClear, osWaitForever)` 阻塞等待 | +| `timeSyncTask` | 墙钟时间同步 | `osEventFlagsWait(SYS_EVT_NET_READY, … \| osFlagsNoClear)` 后开始同步 | ### 1.2 改造前(已废弃)的依赖管理手段 > 以下为引入 Event Flags 之前的历史做法,仅作背景记录,当前代码已不再使用。 @@ -36,6 +36,7 @@ - 每个子系统(网络、存储、FTP 服务等)在**真正准备好对外服务**的那一刻,广播一个"我好了"的事件标志。 - 任何依赖该子系统的任务,在入口处**阻塞等待**对应事件标志,收到才继续;此前不占 CPU、不被调度唤醒。 - 生产者与消费者**互相不知道对方存在**,只通过事件组解耦——新增任务只需在入口 `wait` 它需要的 bit,无需改动任何已有任务的代码。 +- **就绪位是"状态",不是"事件"**:消费者必须带 `osFlagsNoClear` 等待,否则先到的消费者会把位清掉,后面的消费者(尤其 `osFlagsWaitAll` 组合)永远等不到——2026-09-21 实测即由此导致 `ftpTask` 永久阻塞、FTP 21 端口从未监听(详见 §3.2 注与 §6 第 7 条)。 这与 CubeMX"一次性创建全部任务"的模式完全兼容:任务仍由 `MX_FREERTOS_Init` 统一创建,但各自在入口 `wait`,相当于"创建即挂起、就绪才放行"。 @@ -50,7 +51,7 @@ | 等待方式 | 忙等轮询(浪费 tick) | 阻塞等待(零占用) | | 多依赖组合 | 不支持 | `osFlagsWaitAll` / `osFlagsWaitAny` | | 类型/作用域 | 裸 `volatile`,易误用 | 内核对象,API 受控 | -| 清空语义 | 手动维护 | `osFlagsNoClear` 可配 | +| 清空语义 | 手动维护 | 默认**消费**(唤醒即清);**就绪位必须 `osFlagsNoClear`**,只有命令位才用消费语义 | | 调试可见性 | 不可观测 | 可在 IDE 观测事件组值 | --- @@ -85,6 +86,11 @@ extern osEventFlagsId_t sys_evtHandle; | `SYS_EVT_NET_RECONFIG` | 配置保存成功后(配置引擎属主 `defaultTask` 写盘后经 `net_cfg` 应用回调置位) | `netTask` 主循环:消费后 `net_stack_bring_up()` 重建网络(新网口参数立即生效),并递增网络代次号 | | `SYS_EVT_CFG_SAVE` | UI 网口设置页保存行(经 `net_cfg` 保存交接回调置位) | `defaultTask` 主循环:立即唤醒执行 `net_cfg_save_run()`(写盘;成功后置 `SYS_EVT_NET_RECONFIG` 触发应用) | +> **各消费点的等待选项(2026-09-21 修正,务必照此写)**: +> - **状态位**(`NET_READY` / `STORAGE_READY`):`osFlagsWaitAny | osFlagsNoClear`(单依赖)或 `osFlagsWaitAll | osFlagsNoClear`(组合依赖,如 `ftpTask`)——**任何消费者都不得消费它**; +> - **命令位**(`NET_RECONFIG` / `CFG_SAVE`):消费型等待(不加 `osFlagsNoClear`)——必须恰好消费一次,否则会被反复执行(重复写盘 / 反复重建网络)。 +> 判据:这个位表达的是"环境已具备"(状态)还是"请做一件事"(事件)。 + ### 3.3 事件组创建时机 `sys_evtHandle` 必须在**任何任务 `wait` 之前**存在。`main.c` 调用顺序为: ``` @@ -144,11 +150,13 @@ lftpd_start("/", 21, &ftp); ``` 改为(网络 **且** 存储都就绪才 listen): ```c -/* 等网络 + 存储都真正就绪后再 listen,取代拍脑袋的 7s 魔法延时 */ +/* 等网络 + 存储都真正就绪后再 listen,取代拍脑袋的 7s 魔法延时。 + * osFlagsNoClear 必须带:就绪位是状态,被别的消费者清掉后本任务的 + * WaitAll(两位) 将永不成立(2026-09-21 实测:FTP 21 端口从未监听) */ if (sys_evtHandle != NULL) { (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY | SYS_EVT_STORAGE_READY, - osFlagsWaitAll, osWaitForever); + osFlagsWaitAll | osFlagsNoClear, osWaitForever); } lftpd_start("/", 21, &ftp); osEventFlagsSet(sys_evtHandle, SYS_EVT_FTP_READY); /* 自身就绪也广播(可选) */ @@ -163,21 +171,21 @@ while (!g_net_ready) { /* 旧:忙等轮询 */ ``` 改为: ```c -(void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny, osWaitForever); +(void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny | osFlagsNoClear, osWaitForever); ``` 若测试套件同时需要文件系统,可组合等待: ```c uint32_t need = SYS_EVT_NET_READY | SYS_EVT_STORAGE_READY; -osEventFlagsWait(sys_evtHandle, need, osFlagsWaitAll, osWaitForever); +osEventFlagsWait(sys_evtHandle, need, osFlagsWaitAll | osFlagsNoClear, osWaitForever); ``` ### Step 6 timeSyncTask 必须等网络就绪(已实现) -`timeSyncTask` **依赖网络**,`App/time_sync.c` 中已在入口 `wait SYS_EVT_NET_READY`(`osFlagsWaitAny, osWaitForever`)后才开始时间同步,取代原先 8s 魔法延时。代码已落地: +`timeSyncTask` **依赖网络**,`App/time_sync.c` 中已在入口 `wait SYS_EVT_NET_READY`(`osFlagsWaitAny | osFlagsNoClear, osWaitForever`)后才开始时间同步,取代原先 8s 魔法延时。代码已落地: ```c -/* 等网络栈真正就绪,确保 netTask 已开始处理消息 */ +/* 等网络栈真正就绪,确保 netTask 已开始处理消息(NoClear:状态位不得被消费) */ if (sys_evtHandle != NULL) { (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, - osFlagsWaitAny, osWaitForever); + osFlagsWaitAny | osFlagsNoClear, osWaitForever); } ``` > 注:`SYS_EVT_TIMESYNC_READY` 目前仅定义、**暂无生产者置位**(预留给对时精度敏感的业务),故其生产者列为"可选/预留",消费者亦暂不存在。 @@ -188,9 +196,9 @@ if (sys_evtHandle != NULL) { | 场景 | 改造前 | 改造后 | |------|------|--------| -| `ftpTask` 等网络 | `osDelay(7000)` 硬等 | `osEventFlagsWait(NET_READY, …, osWaitForever)` 就绪即放行 | -| `start_test_task` 等网络 | `while(!g_net_ready) osDelay(100)` 每 100ms 空转 | `osEventFlagsWait(NET_READY, …, osWaitForever)` 零占用阻塞 | -| "网络且存储都就绪" | 无法实现 | `osEventFlagsWait(NET_READY|STORAGE_READY, osFlagsWaitAll, …)` | +| `ftpTask` 等网络 | `osDelay(7000)` 硬等 | `osEventFlagsWait(NET_READY+STORAGE_READY, osFlagsWaitAll+osFlagsNoClear, …)` 就绪即放行 | +| `start_test_task` 等网络 | `while(!g_net_ready) osDelay(100)` 每 100ms 空转 | `osEventFlagsWait(NET_READY, osFlagsWaitAny+osFlagsNoClear, …)` 零占用阻塞 | +| "网络且存储都就绪" | 无法实现 | `osEventFlagsWait(NET_READY+STORAGE_READY, osFlagsWaitAll+osFlagsNoClear, …)` | | 新增子系统 X | 手动在依赖任务里加延时/轮询 | 定义 `SYS_EVT_X`,生产者置位、消费者 `wait`,互不改动 | --- @@ -198,10 +206,12 @@ if (sys_evtHandle != NULL) { ## 6. 注意事项 / 潜在坑 1. **事件组必须先建后用**:`sys_evtHandle` 由 CubeMX(FREERTOS Events 对象 `sys_evt`)在 `MX_FREERTOS_Init` 的任务创建之前自动创建,因此早于所有 `wait`。若句柄异常为 `NULL`,`osEventFlagsSet/Wait` 会返回 `osError` 且不阻塞——消费者会直接越过等待往下跑,重新引入竞态;各等待点已用 `if (sys_evtHandle != NULL)` 防御。 -2. **`osWaitForever` 死锁风险**:若某生产者永远不置位(如初始化失败提前 `return`),消费者会永久阻塞。建议给关键 `wait` 加有限超时 + 错误日志,便于定位: +2. **`osWaitForever` 死锁风险**:若某生产者永远不置位(如初始化失败提前 `return`),消费者会永久阻塞。建议给关键 `wait` 加有限超时 + 错误日志,便于定位(另见第 7 条:**位被别的消费者吃掉**同样造成永久阻塞,而且更隐蔽): ```c + /* 状态位必须带 osFlagsNoClear,否则这条带超时的探测会把 NET_READY 吃掉, + * 后面的消费者(含 ftpTask 的 WaitAll)就再也等不到了 */ if (osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, - osFlagsWaitAny, 30000U) == 0) { + osFlagsWaitAny | osFlagsNoClear, 30000U) == 0) { DBG_ERROR("wait NET_READY timeout"); /* 后续可走降级/出错处理 */ } ``` @@ -209,6 +219,9 @@ if (sys_evtHandle != NULL) { 4. **bit 上限**:事件标志为 32 位,最高位(1<<31)被 CMSIS-RTOS 保留,可用 bit 0~30。 5. **测试构建(HW 层模式)的特殊性**:`TEST_SUITE_CH395F` 硬件层模式下 `netTask`(基础线程)入口自挂起(不 bring_up、不轮询、不广播 `SYS_EVT_NET_READY`),`start_test_task` 自己 `net_stack_bring_up()` 独占 CH395F。此时**无人置 `SYS_EVT_NET_READY`**(置位点只在 `net_task_func` 的未挂起路径内)。结果:`ftpTask` / `timeSyncTask` 会一直阻塞在 NET_READY 上——这正符合"硬件层模式不需要 FTP / 网络时间同步、CH395F 被 testTask 独占"的设计,属预期行为,不构成冲突。若将来需要在硬件层模式也起 FTP,可让 `start_test_task` 在 bring-up 成功后自行 `osEventFlagsSet(NET_READY)`。 6. **`g_net_ready` 去留**:**已完成淘汰**。`g_net_ready` 原全局标志已从代码中彻底移除(定义、置位、所有消费方均改为 Event Flags 等待),仅保留事件标志表达就绪。 +7. **⚠ 就绪位被消费 → 永久阻塞(2026-09-21 实测,最隐蔽的一类)**:`osEventFlagsWait` 默认 `xClearOnExit = pdTRUE`(CMSIS 包装见 `cmsis_os2.c:1243-1249`),**唤醒时就把满足条件的位清掉**。修前 `netTask` 等 `STORAGE_READY`(Any,消费 bit1)、`uiTask`/`timeSyncTask` 等 `NET_READY`(Any,消费 bit0),于是 `ftpTask` 的 `WaitAll(bit0|bit1)` 需要"两位同时为 1"的条件**永不可能成立**(bit1 在 netTask 醒来时消失、bit0 在 uiTask/timeSync 醒来时消失)→ 任务永久阻塞,**无日志、无错误码、无超时**(`osWaitForever` + `(void)` 丢返回值 = 完全静默),FTP 21 端口从未监听。 + - **修法**:所有**状态位**等待加 `osFlagsNoClear`(已改:`Src/freertos.c` 的 ftpTask、`App/task/ui_task.c`、`App/time_sync.c`、`App/task/net_task.c`、`test/ch395f_test_task.c`);**命令位**(`CFG_SAVE`/`NET_RECONFIG`)保持消费型等待。 + - **工程化防线**:① 关键消费者加"启动超时告警"(如 ftpTask 10s 内未到 listen 就 `DBG_ERROR`);② 用 `scripts/stack_report.py` 看任务栈水位——**某任务水位异常低 = 它根本没干活**(本次正是靠 ftpTask 只有 156B 才发现)。 --- diff --git a/docs/项目架构总览.md b/docs/项目架构总览.md index 04b9e651..f7fbb598 100644 --- a/docs/项目架构总览.md +++ b/docs/项目架构总览.md @@ -52,14 +52,17 @@ | 任务 | 栈 (字节) | 优先级 | 职责 | 入口 | |---|---|---|---|---| -| `defaultTask` | 2048 | Normal | FatFS 挂载(无卷 `f_mkfs`)→ `net_cfg_load`(配置引擎属主)→ 置 `STORAGE_READY`(= 存储+配置就绪)→ 日志落盘(唯一写者,`syslog_drain`)+ 配置保存执行/2s poll(配置引擎属主)+ LED 闪烁 | `StartDefaultTask` | -| `netTask` | 4096 | Normal | 等存储(配置已就绪)→ `net_stack_bring_up()`(只读 RAM 副本,不触碰配置文件引擎)→ 置 `NET_READY` → `net_poll` 轮询(CH395F 硬件层测试模式下入口自挂起) | `StartNetTask` | -| `ftpTask` | 4096 | Normal | 等网络+存储 → `lftpd_start("/", 21)` | `StartFtpTask` | -| `testTask` | 4096 | BelowNormal | 测试套件入口(`test/test_config.h` 选择;产品构建空转) | `start_test_task` | +| `defaultTask` | 4096 | Normal | FatFS 挂载(无卷 `f_mkfs`)→ 卷健康探针/自愈 → `net_cfg_load`(配置引擎属主)→ 置 `STORAGE_READY`(= 存储+配置就绪)→ 日志落盘(唯一写者,`syslog_drain`)+ 配置保存执行/2s poll + LED 闪烁 + **栈水位观测**(`stack_probe_poll`:首次 5s 后报告,此后仅在出现更深峰值时打印) | `StartDefaultTask` | +| `netTask` | 4096 | Normal | 等存储(配置已由 defaultTask 就绪,本任务只读 RAM 副本)→ `net_stack_bring_up()`(不触碰配置文件引擎)→ 置 `NET_READY` → `net_poll` 轮询(CH395F 硬件层测试模式下入口自挂起) | `StartNetTask` | +| `ftpTask` | 4096 | Normal | 等网络**且**存储(`WaitAll`)→ `lftpd_start("/", 21)` → 单客户端会话循环(断开后重开监听) | `StartFtpTask` | +| `testTask` | 8192 | BelowNormal | 测试套件入口(`test/test_config.h` 选择;产品构建空转) | `start_test_task` | | `uiTask` | 16384 | BelowNormal | 等网络 → `ui_port_init`/`ui_menu_init`/`ui_standby_init`(空闲 60s 切待机屏)/`ui_net_sink` → 10ms 刷新 | `ui_task_func` | | `timeSyncTask` | 2048 | Normal | 等网络 → TCP :8888 收 PC 推送时间 → 写 RTC | `time_sync_task` | > `idle`(128 words)/ `timer`(256 words)由 FreeRTOS 内核创建。所有任务栈从 48KB heap_4 堆分配。 +> **栈实测与预算**(2026-09-21,工具 `scripts/stack_report.py` = Keil 静态调用图 + 串口 `[STACK]` 水位;详见 `docs/内存管理/RAM使用分析与优化方案.md` §0.6): +> defaultTask 静态最坏 2776B/4096B(余量 32%)、**ftpTask 实测 3480B/4096B(余量仅 616B = 15%,低于 1024B 底线,待处置)**、uiTask 实测 3804B/16384B(缩栈空间大,内存方案 §1.3 目标 6KB)、netTask 600B、timeSyncTask 420B、testTask(产品构建)348B。 +> ⚠ 改栈或加大栈内局部变量前后**必须**重跑该脚本——静态分析低估含递归/间接调用的链(ftpTask 静态 2528B vs 实测 3480B)。 ### 2.2 就绪屏障(Event Flags,取代魔法延时) @@ -72,6 +75,15 @@ **设计原则**:生产者与消费者互不知晓,只经事件组解耦;新增子系统只需定义新 bit + 各自 wait/set,不改已有任务。 (详见 `docs/项目启动顺序管理.md`) +**⚠ 就绪位必须 `osFlagsNoClear`(2026-09-21 实证,FTP 永不监听事故)**:`osEventFlagsWait` **默认消费**所等位(CMSIS 包装 → `xEventGroupWaitBits(xClearOnExit=pdTRUE)`,`cmsis_os2.c:1243-1249`),而就绪位表达的是**持久状态**——多人等待时必须人人可读。修前 `netTask` 等 `STORAGE_READY` 时清掉 bit1、`uiTask`/`timeSyncTask` 等 `NET_READY` 时清掉 bit0,导致 `ftpTask` 的 `WaitAll(bit0|bit1)` 两位永不同时为 1 → **任务永久阻塞、零日志零报错、21 端口从未监听**(靠栈水位异常才定位到)。 + +| 位类型 | 例子 | 等待方式 | +|---|---|---| +| **状态位**("环境已就绪") | `SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` | `osFlagsWaitAny\|osFlagsNoClear`、`osFlagsWaitAll\|osFlagsNoClear`——**任何消费者都不许消费它** | +| **命令位**("请做一件事") | `SYS_EVT_CFG_SAVE` / `SYS_EVT_NET_RECONFIG` | 消费型等待(默认,不加 NoClear)——必须恰好被消费一次,否则会反复写盘/重建网络 | + +本节图中 `ftpTask ← bit0 + bit1(All 汇合点)`依赖的正是"状态位常驻"这一前提。 + ### 2.3 线程间通信 | 机制 | 对象 | 用途 | @@ -106,17 +118,19 @@ sequenceDiagram Note over K: MX_FREERTOS_Init 已创建 6 任务 + 事件组 + 队列 K->>D: 调度 - D->>D: f_mount(无卷则 f_mkfs FAT32)→ LED 循环 + D->>D: f_mount(无卷则 f_mkfs FAT32)→ 卷健康探针/自愈 → net_cfg_load()(配置引擎属主) D-->>N: set SYS_EVT_STORAGE_READY + D->>D: 进入 LED / syslog / 配置 poll 循环(含 stack_probe_poll 栈水位采样) - N->>N: wait STORAGE_READY(≤10s,超时回退工厂默认) - N->>N: net_cfg_load() → net_init(ip,mask,gw,mac)
→ PHY 强制 100M 全双工 → 等链路 ≤3s → MAC 回写影子 + N->>N: wait STORAGE_READY(Any|NoClear,≤10s,超时回退工厂默认) + N->>N: 读 RAM 配置副本 → net_init(ip,mask,gw,mac)
→ PHY 强制 100M 全双工 → 等链路 ≤3s → MAC 回写影子 N-->>F: set SYS_EVT_NET_READY N->>N: 循环 net_poll() + net_process_messages() + osDelay(1) - F->>F: wait NET_READY|STORAGE_READY(All)→ lftpd_start("/", 21) - U->>U: wait NET_READY → ui_port_init → ui_menu_init → ui_net_sink_start(7003) - T->>T: wait NET_READY → TCP listen :8888 + F->>F: wait NET_READY|STORAGE_READY(All|NoClear)→ lftpd_start("/", 21)
→ 串口输出:waiting for connection... + U->>U: wait NET_READY(Any|NoClear)→ ui_port_init → ui_menu_init → ui_net_sink_start(7003) + T->>T: wait NET_READY(Any|NoClear)→ TCP listen :8888 + Note over D,N,F,U,T: 就绪位一律 osFlagsNoClear(状态常驻、可被多任务重复读取);
仅命令位 CFG_SAVE / NET_RECONFIG 用消费型等待 ``` --- @@ -175,6 +189,10 @@ flowchart TB **单连接约束**:CH395F 配置为多 Socket + TCP Server 单连接(`set_fun_para(0x09)`),Socket 0 复用监听与数据通道;FTP / 对时 / 远程显示三个 TCP 服务**串行**占用连接,连接断开后自动回到 LISTEN。 +> **FTP 启动门(2026-09-21 修复)**:`ftpTask` 必须等 `NET_READY` **且** `STORAGE_READY`(两位均 `NoClear`)才 `lftpd_start`——此前就绪位被其它等待者消费,FTP **静默不监听**(详见 §2.2 与 `docs/测试说明/FTP_Test_Guide.md` §8.6)。 +> **单客户端实测注意**:同一时刻只服务 1 个会话,旧会话异常断开后约 30s 才恢复监听。测试脚本必须内置连接重试(`test/ftp_up_dl_test.py`、`test/ftp_speed_test.py` 已内置),且**不要用裸 TCP 工具探测 21 端口**(探测会占掉唯一会话,见该指南 §8.7)。 +> 2026-09-21 实测:登录/TYPE I/PASV/STOR/RETR/LIST/DELE/QUIT 全通,128KB 上传下载逐字节一致(约 49/60 KB/s,低于阶段 5 裸 socket 基线,待查)。 + --- ## 6. UI 层架构(框架 + 页面插件) @@ -231,12 +249,12 @@ flowchart TB ``` STM32F4-Base/ ├── Src/ Inc/ CubeMX 生成:main.c / freertos.c / HAL 外设 / 中断 / 时基 -│ ├── freertos.c 6 任务 + netMsgQueue + sys_evt 创建(USER CODE 区自定义) +│ ├── freertos.c 6 任务 + netMsgQueue + sys_evt 创建(USER CODE 区自定义,含栈水位探针 stack_probe_poll) │ └── main.c 入口 → app_main_init → osKernelStart ├── App/ 应用层(业务编排,非 CubeMX) │ ├── app_main.c 启动编排(时间子系统优先) │ ├── time_sync.c TCP 对时服务 -│ ├── sys_events.h 就绪屏障 bit 定义 +│ ├── sys_events.h 就绪屏障 bit 定义(就绪位等待须 osFlagsNoClear,见 §2.2) │ ├── app_cfg.h 功能编译开关(APP_NET/ADC/RS485_ENABLE) │ ├── task/ net_task / ui_task / adc_task* / rs485_task* │ ├── log/ sys_log 记录引擎(定长 64B;MCU 后端落盘 /log/*.log) @@ -257,7 +275,7 @@ STM32F4-Base/ ├── simulator/ PC 模拟器(CMake + SDL2,共用 UI 源码 + 无头回归测试) ├── test/ 测试套件(test_config.h 互斥选择,产品构建空转) ├── tools/ font_gen(字体生成)/ menu_gen(菜单表生成) -├── scripts/ 代码规范检查、RAM 报告等 +├── scripts/ 代码规范/乱码检查、RAM 报告(ram_report.py)、栈报告(stack_report.py) └── docs/ 设计/测试/问题记录文档 ``` @@ -291,6 +309,8 @@ flowchart LR | 存储架构(NAND/FTL/FatFs) | `docs/STM32F4-Base存储架构说明.md` | | 墙钟时间维护 | `docs/系统墙钟时间维护说明.md` | | RAM 预算与优化 | `docs/内存管理/RAM使用分析与优化方案.md` | +| 栈/内存观测工具 | `scripts/stack_report.py`(Keil 静态调用图 × 串口 `[STACK]` 水位,判定取两者更坏者)· `scripts/ram_report.py`(.map 静态占用)—— 用法与基线见内存方案 §0.6 | +| FTP 功能与陷阱 | `docs/测试说明/FTP_Test_Guide.md`(启动门 §8.6 / 单客户端 §8.7)· 脚本 `test/ftp_up_dl_test.py`、`test/ftp_speed_test.py` | | UI 设计架构 / 内容页 / 菜单 | `docs/UI 设计/UI_设计架构.md` · `内容页架构.md` · `菜单页面设计.md` | | UI 自动化测试 | `docs/UI 设计/UI_自动化测试流程.md` | | 驱动已知陷阱 | `docs/问题记录/{CH395F,GD5F2GQ5UE,SD2506}_Trap_Records.md` | @@ -316,3 +336,4 @@ flowchart LR | 2026-09-13 | `sys_clock` 由 ③基础服务层改归 **④中间件层**(通用平台时间服务,日志/文件/对时共用;文件移入 `App/util/` 与 crc/ringbuf 同目录,include 路径不变):`FatFs` → `sys_clock` 成为**层内引用**,工程反向依赖消除;同日新增的 §1.2 "反向依赖例外" 条目撤销 | | 2026-09-14 | 通用配置文件(P3):网口配置存储由 `/cfg/netcfg.bin`(NCFG 二进制)迁移至 `/config.ini`(INI `[network] 段`,`App/cfg_file` 引擎 + `cfg_file_store` 双端后端);`net_cfg` 对外 API 不变;旧档首启自动迁移;`cfg_file` 列为 MCU/模拟器共享源码 | | 2026-09-14 | 运行/自检记录:新增 `App/log/sys_log`(定长 64B 文本记录引擎,MCU 后端 `/log/run.log`·`selftest.log`,defaultTask 唯一写者)+ UI 页面插件 `ui_page_runlog`/`ui_page_selftest`(动态列表)+ `ui_standby` 待机屏(空闲超时切入);密码页错误提示;字体 111→122 字(217 字形);详见 `docs/运行与自检记录设计说明.md` | +| 2026-09-21 | **§2.2 屏障语义修正 + FTP 永不监听缺陷修复**:`osEventFlagsWait` 默认消费所等位,就绪位(`NET_READY`/`STORAGE_READY`)被 netTask/uiTask/timeSync 先消费后,`ftpTask` 的 `WaitAll(两位)` 永不成立 → 任务永久阻塞、21 端口从未监听(静默);5 处就绪位等待统一加 `osFlagsNoClear`(命令位 `CFG_SAVE`/`NET_RECONFIG` 保持消费型)。同轮新增**栈可观测性**:`Src/freertos.c` 的 `stack_probe_poll`(串口 `[STACK]` 水位)+ `scripts/stack_report.py`(Keil 静态调用图 × 运行时水位,判定取两者更坏者);§2.1 任务表补齐实测栈数据(ftpTask 3480B/4096B 余量 15% 待处置)并修正过期的 defaultTask/testTask 栈配置,§3 时序图补 `NoClear` 与 FTP 启动日志,§5 补 FTP 启动门/单客户端实测注意 | diff --git a/scripts/stack_report.py b/scripts/stack_report.py new file mode 100644 index 00000000..83c25885 --- /dev/null +++ b/scripts/stack_report.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Parse the Keil static call graph (*.htm) and report per-task stack budgets. + +UV4 / armlink writes "MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm" on every build +("Static Call Graph"). Every function entry carries: + + StartDefaultTask (Thumb, 508 bytes, Stack size 560 bytes, ...) + [Stack]
  • Max Depth = 2776
  • Call Chain = StartDefaultTask => ... => SPI_WaitFlagStateUntilTimeout
+ +i.e. the function's own frame ("Stack size N bytes") plus the worst-case +cumulative depth of its whole subtree ("Max Depth = N") and the chain that +reaches it. That is a build-time stack observability source: no target needed. + +This tool turns it into: + 1. a per-task budget table (static worst-case depth vs configured stack), + 2. the worst call chain of every task, + 3. a frame-size ranking (which functions are the width hogs), + 4. optionally, runtime high-water-mark data overlaid from a serial log. + +Runtime lines are produced by stack_probe_poll() in Src/freertos.c: + [STACK] peak_used=/ free_min= (%) + [STACK] heap free= min_ever= + +Usage: + python scripts/stack_report.py [-i HTML] [-s SERIAL_LOG] [-o OUTPUT] + [--isr-margin 1024] [--min-free-pct 30] + +Exit code: 0 = all tasks pass, 1 = at least one task fails, 2 = parse error. +""" +import argparse +import os +import pathlib +import re +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +HTM_DEFAULT = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.htm" +FREERTOS_C = REPO_ROOT / "Src" / "freertos.c" + +# Entry function of each task -> FreeRTOS task name (attribute prefix in freertos.c). +ROOTS = { + "StartDefaultTask": "defaultTask", + "StartNetTask": "netTask", + "StartFtpTask": "ftpTask", + "StartUiTask": "uiTask", + "start_test_task": "testTask", + "time_sync_task": "timeSyncTask", +} + +# Runtime lines use the short labels emitted by stack_probe_poll() in freertos.c. +RUNTIME_ALIAS = { + "defaultTask": "default", + "netTask": "net", + "ftpTask": "ftp", + "uiTask": "ui", + "testTask": "test", + "timeSyncTask": "timesync", +} + +FUNC_RE = re.compile( + r'([^<]+)\s*\(([^)]*)\)', re.I) +FRAME_RE = re.compile(r'Stack size\s+(\d+)\s+bytes', re.I) +DEPTH_RE = re.compile(r'Max Depth\s*=\s*(\d+)', re.I) +CHAIN_RE = re.compile(r'Call Chain\s*=\s*(.*?)', re.I | re.S) +UPDATED_RE = re.compile(r'Last Updated:\s*(.*?)$', re.M) +MAXSTACK_RE = re.compile(r'Maximum Stack Usage\s*=\s*(\d+)\s*bytes', re.I) +ATTR_RE = re.compile(r'osThreadAttr_t\s+(\w+?)_attributes\s*=\s*\{(.*?)\};', re.S) +STACKSIZE_RE = re.compile(r'\.stack_size\s*=\s*(\d+)\s*(?:\*\s*(\d+))?') +RUNTIME_RE = re.compile( + r'\[STACK\]\s+(\w+)\s+peak_used=(\d+)/(\d+)\s+free_min=(\d+)\s+\((\d+)%\)') +HEAP_RE = re.compile(r'\[STACK\]\s+heap\s+free=(\d+)\s+min_ever=(\d+)') +TAG_RE = re.compile(r'<[^>]+>') + + +def _clean(text): + """Strip HTML markup/entities from a chain fragment.""" + text = TAG_RE.sub('', text) + text = text.replace('⇒', '->').replace('>', '>').replace('<', '<') + text = text.replace(' ', ' ').replace('&', '&') + return ' '.join(text.split()) + + +def parse_callgraph(path): + """Return (meta, functions); functions maps name -> frame/depth/chain bytes.""" + text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace') + meta = {"max_stack": 0, "updated": ""} + m = MAXSTACK_RE.search(text) + if m: + meta["max_stack"] = int(m.group(1)) + m = UPDATED_RE.search(text) + if m: + meta["updated"] = m.group(1).strip() + + functions = {} + matches = list(FUNC_RE.finditer(text)) + for i, hit in enumerate(matches): + name = hit.group(1).strip() + head = hit.group(2) + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) + body = text[hit.end():end] + fm = FRAME_RE.search(head) + dm = DEPTH_RE.search(body) + cm = CHAIN_RE.search(body) + frame = int(fm.group(1)) if fm else 0 + depth = int(dm.group(1)) if dm else frame + chain = _clean(cm.group(1)) if cm else name + # Same name may appear once per object; keep the deepest entry. + old = functions.get(name) + if old is None or depth > old["depth"]: + functions[name] = {"frame": frame, "depth": depth, "chain": chain} + return meta, functions + + +def parse_task_stacks(path): + """Parse configured stack sizes (bytes) from Src/freertos.c attributes.""" + text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace') + stacks = {} + for attr_name, body in ATTR_RE.findall(text): + m = STACKSIZE_RE.search(body) + if not m: + continue + size = int(m.group(1)) * (int(m.group(2)) if m.group(2) else 1) + stacks[attr_name] = size + return stacks + + +def parse_runtime(path): + """Extract the last [STACK] block from a serial log: task -> peak/free.""" + text = pathlib.Path(path).read_text(encoding='utf-8', errors='replace') + tasks = {} + for m in RUNTIME_RE.finditer(text): + tasks[m.group(1)] = { + "peak": int(m.group(2)), "cfg": int(m.group(3)), + "free": int(m.group(4)), "pct": int(m.group(5)), + } + heap = None + hits = list(HEAP_RE.finditer(text)) + if hits: + heap = {"free": int(hits[-1].group(1)), "min_ever": int(hits[-1].group(2))} + return tasks, heap + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('-i', '--html', default=str(HTM_DEFAULT), + help='static call graph HTML (default: MDK-ARM output)') + ap.add_argument('-s', '--serial', default=None, + help='serial log with [STACK] lines (optional, runtime overlay)') + ap.add_argument('-o', '--output', default=None, help='write markdown report here') + ap.add_argument('--isr-margin', type=int, default=1024, + help='bytes reserved for ISR nesting (default 1024)') + ap.add_argument('--min-free-pct', type=int, default=30, + help='required free margin in %% of the stack (default 30)') + args = ap.parse_args() + + html_path = pathlib.Path(args.html) + if not html_path.is_file(): + print(f'ERROR: call graph HTML not found: {html_path}', file=sys.stderr) + print(' build first: & "MDK-ARM\\build.bat"', file=sys.stderr) + return 2 + + meta, functions = parse_callgraph(html_path) + stacks = parse_task_stacks(FREERTOS_C) + runtime, heap = ({}, None) + if args.serial: + runtime, heap = parse_runtime(args.serial) + + out = [] + add = out.append + add('# Stack budget report') + add('') + add(f'- Source: `{html_path.relative_to(REPO_ROOT)}` (Keil static call graph)') + if meta["updated"]: + add(f'- Linker run: {meta["updated"]}') + add(f'- Global maximum stack usage: **{meta["max_stack"]} B** (any call tree)') + if runtime: + add(f'- Runtime watermark: {len(runtime)} task(s) from `{args.serial}`') + add('') + add('Rule: `margin = cfg - max(static_max_depth, runtime_peak)` must be ' + f'>= max({args.isr_margin} B ISR, {args.min_free_pct}% of cfg).') + add('') + add('| Task | Entry | Cfg (B) | Static max (B) | Runtime peak (B) | Worst (B) | Margin (B) | Margin % | Verdict |') + add('|---|---|---|---|---|---|---|---|---|') + + failures = 0 + rows = [] + for entry, task in ROOTS.items(): + fn = functions.get(entry) + cfg = stacks.get(task, 0) + if fn is None or cfg == 0: + rows.append((task, entry, cfg, None, None, None, None, None, + 'N/A (not in image)')) + continue + depth = fn["depth"] + rt = runtime.get(task) or runtime.get(RUNTIME_ALIAS.get(task, "")) + rt_peak = rt["peak"] if rt else 0 + # The verdict must use the worse of the two sources: the static graph is an + # upper bound for straight-line code but a LOWER bound once indirect calls / + # recursion are involved, and only the runtime watermark covers paths that + # actually ran (e.g. ftpTask's file-transfer loop). + worst = max(depth, rt_peak) + margin = cfg - worst + pct = (margin * 100) // cfg if cfg else 0 + need = max(args.isr_margin, (cfg * args.min_free_pct) // 100) + ok = margin >= need + if not ok: + failures += 1 + rows.append((task, entry, cfg, depth, rt_peak if rt else None, worst, + margin, pct, 'PASS' if ok else 'FAIL')) + for (task, entry, cfg, depth, rt_peak, worst, margin, pct, verdict) in rows: + if depth is None: + add(f'| {task} | `{entry}` | {cfg or "-"} | - | {rt_peak if rt_peak else "-"} ' + f'| - | - | - | {verdict} |') + else: + add(f'| {task} | `{entry}` | {cfg} | {depth} | {rt_peak if rt_peak else "-"} ' + f'| {worst} | {margin} | {pct}% | {verdict} |') + add('') + add('`Static max` = armlink worst-case call-tree depth; `Runtime peak` = measured ' + 'high-water mark (boot + whatever workload ran before the sample); ' + '`Worst` = max of the two, which is what the verdict is computed from.') + add('') + + if heap: + add(f'Runtime heap: free={heap["free"]} B, min_ever={heap["min_ever"]} B') + add('') + + add('## Worst call chain per task') + add('') + for entry, task in ROOTS.items(): + fn = functions.get(entry) + if fn is None: + continue + add(f'### {task}: {fn["depth"]} B (own frame {fn["frame"]} B)') + add('') + add(f'```\n{fn["chain"]}\n```') + add('') + + add('## Frame-size ranking (own frame, top 15)') + add('') + add('| Bytes | Function |') + add('|---|---|') + for name, info in sorted(functions.items(), key=lambda kv: -kv[1]["frame"])[:15]: + add(f'| {info["frame"]} | `{name}` |') + add('') + + report = '\n'.join(out) + if args.output: + pathlib.Path(args.output).write_text(report, encoding='utf-8') + print(f'written: {args.output}') + else: + print(report) + + return 1 if failures else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/test/ch395f_test_task.c b/test/ch395f_test_task.c index 604f3fee..c92ecf17 100644 --- a/test/ch395f_test_task.c +++ b/test/ch395f_test_task.c @@ -1384,7 +1384,7 @@ void ch395f_test_run(void) * Phase 7(TCP Client)与阶段 6/8~11(TCP Server 监听循环)互斥, * 二者共用单连接 Socket 0,故按宏选择入口。 */ /* 等待 netTask 广播网络就绪(取代 g_net_ready 忙等轮询) */ - (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny, osWaitForever); + (void)osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny | osFlagsNoClear, osWaitForever); DBG_INFO("netTask ready"); #if defined(ENABLE_PHASE7_TESTS) net_layer_client_test_main(); diff --git a/test/ftp_speed_test.py b/test/ftp_speed_test.py index f96d48b9..587cfea2 100644 --- a/test/ftp_speed_test.py +++ b/test/ftp_speed_test.py @@ -37,6 +37,33 @@ def send_cmd(sock, cmd): time.sleep(0.1) return recv_line(sock) +def connect_with_retry(host, port, timeout=15.0, attempts=6, delay=3.0): + """连接控制通道(带重试)。 + + CH395F 单客户端复用:上一会话未 QUIT 就断开时,服务端可能仍在处理旧连接, + 新连接会被 RST(WinError 10061);实测约 30s 后服务端丢弃旧会话并重新 listen。 + 注意不要用裸 TCP 探测端口(探测会占掉唯一会话)。 + """ + last_err = None + for i in range(1, attempts + 1): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, port)) + return s, None + except Exception as e: + last_err = e + try: + s.close() + except Exception: + pass + if i < attempts: + print(f" connect failed ({e}); retry {i}/{attempts - 1} in {delay:.0f}s " + f"(single-client: stale session blocks new connections)") + time.sleep(delay) + return None, last_err + + def parse_pasv(resp): m = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", resp.decode()) if m: @@ -80,22 +107,23 @@ def test_speed(host, port, size_kb): # ---- Connect + Login ---- print("[Connect] Connecting...") - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(60.0) - try: - sock.connect((host, port)) - except Exception as e: - print(f" CONNECT FAILED: {e}") - check("Connect", False, str(e)) - return finish(sock) + sock, err = connect_with_retry(host, port, timeout=60.0) + if sock is None: + print(f" CONNECT FAILED: {err}") + check("Connect", False, str(err)) + return finish(None) recv_line(sock) # 220 - if not send_cmd(sock, "USER anonymous"): - check("Login USER", False) + resp = send_cmd(sock, "USER anonymous") + if not (resp and resp.startswith(b"230")): + check("Login USER 230", False, + resp.decode(errors='replace').rstrip() if resp else "no resp") return finish(sock) - if not send_cmd(sock, "PASS test@test.com"): - check("Login PASS", False) + resp = send_cmd(sock, "PASS test@test.com") + if not (resp and resp.startswith(b"230")): + check("Login PASS 230", False, + resp.decode(errors='replace').rstrip() if resp else "no resp") return finish(sock) - check("Login (USER/PASS)", True) + check("Login (USER/PASS 230)", True) send_cmd(sock, "TYPE I") # ---- Upload ---- diff --git a/test/ftp_up_dl_test.py b/test/ftp_up_dl_test.py index 2c65d1b9..0dd5741f 100644 --- a/test/ftp_up_dl_test.py +++ b/test/ftp_up_dl_test.py @@ -48,6 +48,34 @@ def send_cmd(sock, cmd, expect_code=None): print(" < [NO RESPONSE]") return None +def connect_with_retry(host, port, timeout=TIMEOUT, attempts=6, delay=3.0): + """连接控制通道(带重试)。 + + CH395F 是单客户端复用:上一会话未 QUIT 就断开时,服务端仍在处理旧连接, + 新连接会被 RST(WinError 10061)。实测旧会话约 30s 后被服务端丢弃并重新 + listen,故这里等一会儿重试即可,不要用裸 TCP 探测端口(探测本身会占掉 + 唯一会话)。 + """ + last_err = None + for i in range(1, attempts + 1): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((host, port)) + return s, None + except Exception as e: + last_err = e + try: + s.close() + except Exception: + pass + if i < attempts: + print(f" connect failed ({e}); retry {i}/{attempts - 1} in {delay:.0f}s " + f"(single-client: stale session blocks new connections)") + time.sleep(delay) + return None, last_err + + def parse_pasv(resp): m = re.search(r"(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)", resp.decode()) if m: @@ -55,77 +83,6 @@ def parse_pasv(resp): return f"{a}.{b}.{c}.{d}", e * 256 + f return None, None -def ftp_transfer_data(host, pasv_ip, pasv_port, cmd, data_to_send=None, read_data=False): - """通用数据通道传输:发送命令 + 连数据端口 + 收发数据""" - print(f"\n [{cmd}] Opening data channel {pasv_ip}:{pasv_port}...") - data_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - data_sock.settimeout(10.0) - try: - data_sock.connect((pasv_ip, pasv_port)) - print(f" Data channel connected.") - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(TIMEOUT) - sock.connect((host, PORT)) - recv_line(sock) # 220 - send_cmd(sock, "USER anonymous") - send_cmd(sock, "PASS test@test.com") - - # PASV - resp = send_cmd(sock, "PASV") - _, data_port = parse_pasv(resp) - print(f" PASV port: {data_port}") - - # Connect data FIRST - data_sock2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - data_sock2.settimeout(10.0) - data_sock2.connect((pasv_ip, data_port)) - - # Send command - time.sleep(0.2) - send_cmd(sock, cmd) - - result = None - if data_to_send: - print(f" Sending {len(data_to_send)} bytes to data channel...") - total = 0 - while total < len(data_to_send): - n = data_sock2.send(data_to_send[total:]) - if n <= 0: - break - total += n - print(f" Sent {total} bytes.") - data_sock2.shutdown(socket.SHUT_WR) - - if read_data: - result = b"" - while True: - try: - chunk = data_sock2.recv(4096) - if not chunk: - break - result += chunk - except socket.timeout: - break - print(f" Received {len(result)} bytes from data channel.") - - data_sock2.close() - resp = recv_line(sock) - if resp: - print(f" Control response: {resp.decode().rstrip()}") - - send_cmd(sock, "QUIT") - sock.close() - data_sock.close() - return result - except Exception as e: - print(f" Data transfer error: {e}") - try: - data_sock.close() - except: - pass - return None - def test_upload_download(host, port): print(f"=== FTP Upload/Download Test: {host}:{port} ===\n") checks = [] @@ -156,14 +113,11 @@ def test_upload_download(host, port): # 1. Connect + Login print("[1] Connect + Login") - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(TIMEOUT) - try: - sock.connect((host, port)) - except Exception as e: - print(f" CONNECT FAILED: {e}") - check("Connect", False, str(e)) - return finish(sock) + sock, err = connect_with_retry(host, port) + if sock is None: + print(f" CONNECT FAILED: {err}") + check("Connect", False, str(err)) + return finish(None) recv_line(sock) # 220 if not send_cmd(sock, "USER anonymous", 230):