修复就绪屏障位被消费致FTP永不监听;新增栈可观测(水位探针+静态调用图报告)并补FTP实测

This commit is contained in:
2026-09-21 04:26:52 +08:00
parent 03f175e7ba
commit bbdd059e25
15 changed files with 707 additions and 167 deletions
+3 -2
View File
@@ -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 大局部变量静态化)再定栈)
## 架构要点
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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);
}
}
+3 -2
View File
@@ -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 (;;) {
+107 -1
View File
@@ -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] <task> peak_used=<B>/<cfg> free_min=<B> (<pct>%)
* [STACK] heap free=<B> min_ever=<B>
* 未覆盖 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 循环立即唤醒执行写盘
@@ -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] <task> peak_used=<B>/<cfg> free_min=<B> (<pct>%)
[STACK] heap free=<B> min_ever=<B>
```
要点:
- `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 天)
@@ -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` |
+27 -8
View File
@@ -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`。
+3 -1
View File
@@ -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,失败重试 */
+29 -16
View File
@@ -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 才发现)。
---
+34 -13
View File
@@ -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)<br/>→ PHY 强制 100M 全双工 → 等链路 ≤3s → MAC 回写影子
N->>N: wait STORAGE_READY(Any|NoClear,≤10s,超时回退工厂默认)
N->>N: 读 RAM 配置副本 → net_init(ip,mask,gw,mac)<br/>→ 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)<br/>→ 串口输出: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(状态常驻、可被多任务重复读取);<br/>仅命令位 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 启动门/单客户端实测注意 |
+258
View File
@@ -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:
<STRONG><a name="[7a]"></a>StartDefaultTask</STRONG> (Thumb, 508 bytes, Stack size 560 bytes, ...)
[Stack]<UL><LI>Max Depth = 2776<LI>Call Chain = StartDefaultTask => ... => SPI_WaitFlagStateUntilTimeout</UL>
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] <task> peak_used=<B>/<cfg> free_min=<B> (<pct>%)
[STACK] heap free=<B> min_ever=<B>
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'<STRONG><a name="\[[0-9a-f]+\]"></a>([^<]+)</STRONG>\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*(.*?)</UL>', 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('&rArr;', '->').replace('&gt;', '>').replace('&lt;', '<')
text = text.replace('&nbsp;', ' ').replace('&amp;', '&')
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())
+1 -1
View File
@@ -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();
+41 -13
View File
@@ -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 ----
+33 -79
View File
@@ -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):