From 720f8414949c85a4bd8c8c98b61a7e915696878f Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 15 Oct 2026 12:34:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(storage):=20dhara=20=E7=A7=BB=E6=A4=8D?= =?UTF-8?q?=E5=B1=82=20is=5Ffree=20=E6=95=B4=E9=A1=B5=E7=B2=BE=E5=BA=A6=20?= =?UTF-8?q?+=20HAL=20=E8=B6=8A=E7=95=8C=E5=AE=88=E5=8D=AB=EF=BC=9B?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=AF=BB=E5=86=99=E6=89=B9=E9=87=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登记簿 N-21(报告 N-12/N-13)与 N-24(报告 N-16)两项未闭环问题修复并销项。 N-12:dhara_nand_is_free() 旧实现只读页首 64 B 判全 0xFF,会把"页首 0xFF、后段有数据"的已编程页误判空闲,dhara 遂往已编程页再编程 = 位碰撞、 journal/map 指向垃圾且不报任何错。改整页扫描(栈上 64 B 分块、页首命中即 早退,故"已编程且页首有数据"的绝大多数页成本与旧实现同价)。 N-13:报告原建议经核对不成立——本仓裁剪版 dhara 既无 DHARA_NO_PAGE(哨兵 实为 DHARA_PAGE_NONE,journal.h:46)也无 DHARA_E_UNSUPPORTED(error.h 枚举 里没有,照抄直接编译失败),且 map.c 三处 dhara_journal_copy 调用点各自已 挡掉哨兵。真风险是移植层零上界校验:越界页号被芯片地址位截断成某个真实页 → 静默读写错页。改为六个 HAL 入口统一几何上界守卫 + DHARA_E_CORRUPT_MAP。 错误码选择是刻意的:BAD_BLOCK 会让 journal.c:904 标坏无辜好块,RECOVER 会 让 map.c:275 启动回收;无错误通道的 is_free/is_bad/mark_bad 一律取保守值。 N-16 写侧:一次排空合并成"每文件一次 open/write/close"(≤8 条 = 512 B 静态缓冲)。掉电语义不变——批次仍在同一次 drain 调用内 close,而 close 回写目录项这个不同 LPN 会把 FTL 单页写回缓存逼出到 NAND;失败尾部回投 队列,条目不丢(旧实现 -2 卷忙会直接丢掉那一条)。容量轮转仍只在按条慢 路径做,批量路径发现装不下一批就返回 -3 退回。 N-16 读侧:s_read_raw() 加 8 条滑动读窗口 + 写者代数失效,API 与 UI 零改动。 验证:@build 0 错 0 警(Code 213,540 / ER_IROM1 232,280 B = 22.2%, RW_IRAM1 75,232 B = 57.4%,ZI +1,640 B 全为静态缓冲、不吃任务栈); @test PASS,且台架 journal 恢复点与修复前同轨——连续四次启动 sectors used 恒 14,094、Map head 每轮 +128 单调推进、epoch 稳定 3 → 整页 is_free 未 改变恢复语义、无双编程、无回退;stack_report 9 个任务全 PASS(defaultTask 静态最坏链 3,024 B / 余 40%,实测峰值 2,164 B / 余 57%)。 新增主机侧移植层单测 test/host/nand_port/(第 16 个 -unit-test 钩子): RAM 假芯片 + 真实 dhara map/journal 源,149 断言,含"旧算法只看页首 64 B" 对照用例(将来有人把 is_free 改回去会立刻红)与 dhara 级 resume 双编程计数 为 0 的场景。L0.5 由 15 runner / 955 断言 → 16 runner / 1,104 断言。 文档:存储架构说明 §5.3 加移植层两条硬约束;运行与自检记录设计说明 §4.4/§4.5 写批量化与读窗口;GD5F Trap Records 新增 Trap 07(含三条遗留 代价与一条待办编译期断言);登记簿 N-21/N-24 删行销项并同步 README §1/§2.0/§2.1、排期详述 C3、报告 §0.1/§0.2/§0.4/§4.x/§7 与去处表; 钩子计数 33→34、主机侧单测 15→16 全库同步。 --- .pre-commit-config.yaml | 19 + AGENTS.md | 4 +- App/log/sys_log_mcu.c | 361 ++++++-- Drivers/BSP/GD5F2GQ5UE/nand_ftl.c | 147 ++- docs/代码规范/pre-commit使用说明.md | 13 +- docs/任务规划/README.md | 20 +- docs/任务规划/排期详述.md | 2 +- docs/任务规划/登记簿.md | 2 - docs/内存管理/RAM使用分析与优化方案.md | 6 +- docs/内存管理/RAM预算台账.md | 6 +- docs/子系统设计/运行与自检记录设计说明.md | 14 +- docs/架构设计/STM32F4-Base存储架构说明.md | 7 +- docs/测试说明/测试开发标准流程.md | 5 +- docs/环境与部署/并行任务工作树方案.md | 2 +- docs/问题记录/GD5F2GQ5UE_Trap_Records.md | 67 ++ docs/问题记录/项目审查与优化建议报告.md | 60 +- scripts/ram_baseline.tsv | 2 +- test/host/README.md | 34 +- test/host/nand_port/nand_port_test.c | 1029 +++++++++++++++++++++ test/host/nand_port/run_unit_test.py | 62 ++ test/host/nand_port/stub/dbg_log.h | 38 + test/host/nand_port/stub/gd5f2gq5ue.h | 84 ++ 22 files changed, 1830 insertions(+), 154 deletions(-) create mode 100644 test/host/nand_port/nand_port_test.c create mode 100644 test/host/nand_port/run_unit_test.py create mode 100644 test/host/nand_port/stub/dbg_log.h create mode 100644 test/host/nand_port/stub/gd5f2gq5ue.h diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce620cd4..fa60d851 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -414,6 +414,25 @@ repos: files: ^(App/log/soe_rec\.(c|h)|App/log/soe_store\.(c|h)|test/host/soe/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false + # 7n) dhara NAND 移植层(nand_ftl)主机侧单元测试(2026-10-15 加,N-21 / 报告 N-12+N-13) + # 背景:移植层六个 HAL 入口是**纯地址算术**,此前对越界页/块号(含 + # DHARA_PAGE_NONE 哨兵)**不做任何上界校验**——加偏移送进 SPI 命令后被地址位 + # 截断成某个真实页 → 静默读写/搬走无辜数据;而 is_free 旧实现只看页首 64 B, + # 会把"页首 0xFF、后段有数据"的已编程页误判空闲 → dhara 往已编程页再编程 + # = 数据丢失。两类缺陷真机**极难复现**(要恰好撞上特定页形态 / 被污染的 meta), + # 板端只能表现为"某次复位后丢数据",无从定位。 + # 本钩子用 MSVC 离线编译真实 nand_ftl.c(#include 纳入)+ **RAM 假芯片** + # (32 块 x 64 页 x 2048 B):把"双编程"做成可数事件(N-12 探针)、记录全部 + # 访问次数,于是"越界不产生任何芯片访问""越界标坏块次数为 0"可机械断言; + # 并内联一份"只看页首 64 B"的旧算法做对照,证明这组断言真能咬人。 + # 触发面含 dhara 本体(测试一并 #include map.c/journal.c 跑真链路场景)。 + - id: nand-port-unit-test + name: dhara NAND 移植层单测(主机侧) + entry: python test/host/nand_port/run_unit_test.py + language: system + files: ^(Drivers/BSP/GD5F2GQ5UE/nand_ftl\.(c|h)|Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue\.h|Lib/dhara/(map|journal|nand|error|bytes)\.(c|h)|test/host/nand_port/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + # 8) 测试套件开关门禁(2026-10-01 加) # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 diff --git a/AGENTS.md b/AGENTS.md index a92d87fe..f1e8ca7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ ## 提交门禁(pre-commit) -- 任一失败即拦截,共 **33 个钩子**(逐个详解 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + 主机侧单测 15 个(`-unit-test` 后缀)+ `test-suite-switch` + `registration-check`(双端注册反向校验,N-23)+ `mem-budget`(RAM 92% 预算门禁,N-27) +- 任一失败即拦截,共 **34 个钩子**(逐个详解 → `docs/代码规范/pre-commit使用说明.md`):通用 5 + `gitlint` + `cppcheck` / `test-cppcheck` + `no-mojibake` + `coding-standard` + 文档三件套 `doc-links` / `task-table` / `mem-numbers` + `lv-conf-sync`(两份 `lv_conf.h` 特性子集一致)+ `cubemx-survival` + 主机侧单测 16 个(`-unit-test` 后缀)+ `test-suite-switch` + `registration-check`(双端注册反向校验,N-23)+ `mem-budget`(RAM 92% 预算门禁,N-27) - **范围 = 项目代码**:第三方(`Lib/`、`Middlewares/`、CMSIS、HAL、`lftpd`、`dhara`、`MDK-ARM`)排除;`cppcheck`/`coding-standard` 另排除 `test/`,规范检查再排除 CubeMX 生成的 `Src/`/`Inc/`。**`simulator/` 已纳入全部钩子**(2026-10-11)——生成物只靠 `.gitignore` 挡,**不在 `exclude` 里写目录名**(教训见 `docs/代码规范/pre-commit使用说明.md` §5.5) - ⚠ **pre-commit 4.x 已移除 `always: true`**(实测对 `local` 钩子只告警 `Unexpected key(s)` 并跳过)→ 想"总是跑"的钩子**必须让 `files:` 覆盖到所有会触发它的文件**,没有兜底开关 - **改写型钩子(`trailing-whitespace` + `end-of-file-fixer`)豁免 CubeMX/Keil 生成文件**(配置里 YAML 锚点 `&cubemx-generated`);⚠ **只读检查不受影响**:`cppcheck` / 乱码 / `cubemx-survival` / `task-table` / `mem-numbers` 仍覆盖 `Src/`、`Inc/` @@ -173,7 +173,7 @@ | ADC 采样节拍 / 通道映射 / V0.1 硬件缺陷 | `docs/子系统设计/ADC采样节拍设计说明.md`、`docs/硬件平台/ADC通道映射说明.md` | | 存储栈(FatFs / dhara / lftpd / 两条 FatFs 陷阱) | `docs/架构设计/STM32F4-Base存储架构说明.md` | | RAM / 栈预算 / 容量台账 | `docs/内存管理/RAM使用分析与优化方案.md`、`内存内容分配策略.md` | -| pre-commit 33 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` | +| pre-commit 34 个钩子逐个详解 | `docs/代码规范/pre-commit使用说明.md` | | 代码规范 V1.0 / cppcheck 口径 | `docs/代码规范/嵌入式C语言代码规范_V1.0.md`、`cppcheck使用说明.md` | | 自动化测试 / 实机测试 / 板级套件 | `docs/测试说明/自动化测试流程.md`、`实机测试实施指南.md`、`board-test-suite` skill | | 烧录与复位语义 | `docs/测试说明/ST-LINK_CLI_复位使用说明.md` | diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c index a5530985..57e8b6fb 100644 --- a/App/log/sys_log_mcu.c +++ b/App/log/sys_log_mcu.c @@ -45,6 +45,12 @@ static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_M #define SYSLOG_QUEUE_MAX 8u +/* N-24(2026-10-15,报告 N-16 写侧):一次排空的合并写入上限。 + * 8 条 × 64 B = 512 B 静态缓冲(不吃任务栈),与队列深度 SYSLOG_QUEUE_MAX 同值 + * ——即"一轮排空最多一次 open/write/close"。 */ +#define SYSLOG_BATCH_MAX 8u +static uint8_t s_batch[SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE]; + /* 待发条目:desc 为调用方静态串(Flash 字面量),只存指针不拷贝 */ typedef struct { uint8_t id; @@ -52,32 +58,81 @@ typedef struct { } syslog_entry_t; static syslog_entry_t s_preboot[SYSLOG_QUEUE_MAX]; /* 启动前待发数组(RTOS 前单生产者/后单消费者,免锁) */ +static syslog_entry_t s_batch_src[SYSLOG_BATCH_MAX]; /* 本批条目的原始来源(N-24:未落盘的尾部要回投队列,故必须留住 id + desc 指针); + * 唯一写者 defaultTask,走静态不吃栈 */ static volatile uint8_t s_preboot_cnt; /* 数组条目数 */ static osMessageQueueId_t s_queue; /* 运行期消息队列(syslog_drain 首次调用时惰性创建) */ static const osMessageQueueAttr_t s_queue_attr = { .name = "syslog" }; static volatile uint8_t s_fs_ready; /* 卷挂载 + /log 目录就绪后置位 */ static volatile uint8_t s_mount_fail_reported; /* P2-9:建目录失败日志去重(成功复位) */ +/* N-24(2026-10-15,报告 N-16 读侧):8 条滑动读窗口 + 写者代数。 + * 窗口 512 B 走静态(不吃任务栈;defaultTask/uiTask 最坏链见 stack_report.py, + * 预算余量见 docs/内存管理/RAM预算台账.md)。s_rd_gen 由写者自增、读者比对, + * 对齐 32 位在 ARM 上原子,跨任务无需临界区。 */ +#define SYSLOG_RD_WINDOW 8u +static uint8_t s_rd_win[SYSLOG_RD_WINDOW * SYSLOG_REC_SIZE]; +static int32_t s_rd_win_id = -1; /* -1 = 窗口空 */ +static uint32_t s_rd_win_base; /* 窗口首条序号 */ +static uint32_t s_rd_win_cnt; /* 窗口内有效条数 */ +static uint32_t s_rd_win_gen; /* 填充时看到的写者代数 */ +static volatile uint32_t s_rd_gen[SYSLOG_COUNT]; /* 每次成功落盘 / unlink 后自增 */ + /* 函数定义区 */ /* - * 函数功能:追加一条记录(格式化 64B + FA_OPEN_APPEND;容量满先删除重建) - * 入口参数:id - 日志 ID syslog_id_t - * desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char * - * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int - * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 + * 函数功能:格式化一条 64 B 定长记录(时间戳 + 空格 + epoch + 空格 + 描述) + * 入口参数:id - 日志 ID(当前不参与编码,仅为接口对称) syslog_id_t + * desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char * + * p_rec - 输出缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) uint8_t * + * 返回值:无 + * 限定条件:时间戳未就绪时落 "1970-01-01 08:00:00" 占位串 + * 函数说明:N-24(2026-10-15,报告 N-16)从 s_append 抽出,供"单条写"与 + * "批量写"共用——两条路径必须产出逐字节相同的记录,否则 UI 按定长 + * 字段偏移解析会错位。 */ -static int s_append(syslog_id_t id, const char *desc) { - static FIL f; /* static:FF_FS_TINY=0 → 局部 FIL 吃 528 B 栈; - * 本函数全程在 vol_lock 区间内,跨任务被卷锁串行化 */ - FRESULT fr; - UINT bw = 0u; - uint8_t rec[SYSLOG_REC_SIZE]; +static void s_format_rec(syslog_id_t id, const char *desc, uint8_t *p_rec) { char ts[SYSLOG_TS_LEN + 1u]; uint32_t epoch; size_t dl; - if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { + (void)id; + memset(p_rec, 0u, SYSLOG_REC_SIZE); + if (sys_clock_get_str(ts, sizeof(ts)) == NULL) { + memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN); + } + ts[SYSLOG_TS_LEN] = '\0'; + epoch = sys_clock_get(); + if (epoch == UINT32_MAX) { + epoch = 0u; + } + memcpy(p_rec, ts, SYSLOG_TS_LEN); + p_rec[SYSLOG_TS_LEN] = ' '; + (void)snprintf((char *)(p_rec + SYSLOG_EPOCH_OFFS), SYSLOG_EPOCH_LEN + 1u, "%010u", (unsigned int)epoch); + p_rec[SYSLOG_DESC_OFFS - 1u] = ' '; + dl = strlen(desc); + if (dl > SYSLOG_DESC_MAX) { + dl = SYSLOG_DESC_MAX; + } + memcpy(p_rec + SYSLOG_DESC_OFFS, desc, dl); +} + +/* + * 函数功能:把一条**已格式化**的 64 B 记录追加落盘(容量满先删除重建) + * 入口参数:id - 日志 ID syslog_id_t + * p_rec - 记录缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) const uint8_t * + * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int + * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 + */ +static int s_append_rec(syslog_id_t id, const uint8_t *p_rec) { + static FIL f; /* static:FF_FS_TINY=0 → 局部 FIL 吃 528 B 栈; + * 本函数全程在 vol_lock 区间内,跨任务被卷锁串行化 */ + static uint8_t s_fault_reported[SYSLOG_COUNT]; + FRESULT fr; + UINT bw = 0u; + int rc = 0; + + if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || p_rec == NULL) { return -1; } /* 卷锁 try:本任务 = 看门狗喂狗点,绝不允许阻塞等锁(阻塞 = 喂狗停摆 = @@ -94,33 +149,11 @@ static int s_append(syslog_id_t id, const char *desc) { return -1; } } - memset(rec, 0u, sizeof(rec)); - if (sys_clock_get_str(ts, sizeof(ts)) == NULL) { - memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN); - } - ts[SYSLOG_TS_LEN] = '\0'; - epoch = sys_clock_get(); - if (epoch == UINT32_MAX) { - epoch = 0u; - } - memcpy(rec, ts, SYSLOG_TS_LEN); - rec[SYSLOG_TS_LEN] = ' '; - (void)snprintf((char *)(rec + SYSLOG_EPOCH_OFFS), SYSLOG_EPOCH_LEN + 1u, "%010u", (unsigned int)epoch); - rec[SYSLOG_DESC_OFFS - 1u] = ' '; - dl = strlen(desc); - if (dl > SYSLOG_DESC_MAX) { - dl = SYSLOG_DESC_MAX; - } - memcpy(rec + SYSLOG_DESC_OFFS, desc, dl); - /* L1 文件级自愈:陈旧/损坏条目 → 丢弃重建、重试一次(与上方容量满重建 * 同一机制);L2 错误去重:持续故障只报第一次,恢复前静默(消除"每次 * 开机同一错误刷屏")。打开模式必须带 FA_WRITE 位:本 FatFs 移植的 * FA_OPEN_APPEND(0x30) 不含 FA_WRITE(0x02),不带写位打开的文件 f_write * 恒返回 FR_DENIED(7)——2026-09-20 台架实测 /log 写失败 ×5/开机 真根因 */ - static uint8_t s_fault_reported[SYSLOG_COUNT]; - int rc = 0; - fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); if (fr != FR_OK && (fr == FR_DENIED || fr == FR_INVALID_OBJECT || fr == FR_INT_ERR || fr == FR_DISK_ERR || @@ -130,14 +163,14 @@ static int s_append(syslog_id_t id, const char *desc) { fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); } if (fr == FR_OK) { - fr = f_write(&f, rec, SYSLOG_REC_SIZE, &bw); + fr = f_write(&f, p_rec, SYSLOG_REC_SIZE, &bw); (void)f_close(&f); if (fr != FR_OK || bw != SYSLOG_REC_SIZE) { (void)f_unlink(s_paths[id]); bw = 0u; fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); if (fr == FR_OK) { - fr = f_write(&f, rec, SYSLOG_REC_SIZE, &bw); + fr = f_write(&f, p_rec, SYSLOG_REC_SIZE, &bw); (void)f_close(&f); } if (fr != FR_OK || bw != SYSLOG_REC_SIZE) { @@ -153,6 +186,106 @@ static int s_append(syslog_id_t id, const char *desc) { s_fault_reported[id] = 1u; DBG_ERROR("write %s fail (%d)", s_paths[id], (int)fr); } + /* 读窗口失效:本函数可能改过文件内容(追加 / 容量满重建 / 自愈 unlink), + * 一律自增代数让读者重填——失败路径多失效一次只是多读一轮,不会读错。 */ + s_rd_gen[id]++; + vol_lock_release(); + return rc; +} + +/* + * 函数功能:追加一条记录(格式化 + 单条落盘) + * 入口参数:id - 日志 ID syslog_id_t + * desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char * + * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int + * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 + * 函数说明:慢路径 / 兼容入口。批量排空走 s_append_many();容量轮转、 + * sys_log_evidence() 的 flush 诱导、开机首条"power on/reboot"仍走这里。 + */ +static int s_append(syslog_id_t id, const char *desc) { + uint8_t rec[SYSLOG_REC_SIZE]; + + if (desc == NULL) { + return -1; + } + s_format_rec(id, desc, rec); + return s_append_rec(id, rec); +} + +/* + * 函数功能:把 n 条**已格式化**的记录一次性追加到同一日志文件(一次 open/write/close) + * 入口参数:id - 日志 ID syslog_id_t + * p_buf - 连续 n 条 64 B 记录(n × SYSLOG_REC_SIZE 字节) const uint8_t * + * n - 记录条数(1 ~ SYSLOG_BATCH_MAX) uint32_t + * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障); + * -3 - 剩余容量不足 n 条,本批不能走快路径(交回按条慢路径做轮转) int + * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 + * 函数说明:N-24(2026-10-15,报告 N-16 写侧)。旧实现每条记录一轮 + * f_open + f_write(64 B) + f_close,而 f_close 要回写目录项、f_write + * 要对目标扇区做读-改-写 → 每条日志 2~4 次 NAND 页读写。本函数把 + * 同一次排空里属于同一文件的最多 8 条合并成一次 512 B 追加写。 + * **不改掉电语义**:批次仍在同一次 syslog_drain() 调用内写完并 + * f_close;close 回写目录项(另一个 LPN)会把 FTL 的单页写回缓存逼出 + * 到 NAND——该"写到不同 LPN 才刷前一脏页"的语义见 sys_log_evidence() + * 的 flush 诱导注释。落盘时机与旧实现每条 close 等价,批次不跨调用驻留 RAM。 + * 自愈重试与错误去重的判据与 s_append_rec 一致;容量不足时返回 -3 + * 而不是就地 unlink——"满了就重建"必须由按条慢路径统一执行, + * 否则一批 8 条会跨越多轮重建、语义说不清。 + */ +static int s_append_many(syslog_id_t id, const uint8_t *p_buf, uint32_t n) { + static FIL f; + static uint8_t s_batch_fault[SYSLOG_COUNT]; + FRESULT fr; + UINT bw = 0u; + UINT total; + int rc = 0; + + if (((uint32_t)id >= (uint32_t)SYSLOG_COUNT) || (p_buf == NULL) || + (n == 0u) || (n > SYSLOG_BATCH_MAX)) { + return -1; + } + if (vol_lock_wait(0u) == 0u) { + return -2; + } + if ((syslog_count(id) + n) > s_caps[id]) { + vol_lock_release(); + return -3; + } + total = (UINT)(n * SYSLOG_REC_SIZE); + + fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); + if (fr != FR_OK && (fr == FR_DENIED || fr == FR_INVALID_OBJECT || + fr == FR_INT_ERR || fr == FR_DISK_ERR || + fr == FR_NO_FILE || fr == FR_NO_PATH || + fr == FR_INVALID_NAME || fr == FR_NO_FILESYSTEM)) { + (void)f_unlink(s_paths[id]); + fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); + } + if (fr == FR_OK) { + fr = f_write(&f, p_buf, total, &bw); + (void)f_close(&f); + if (fr != FR_OK || bw != total) { + (void)f_unlink(s_paths[id]); + bw = 0u; + fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); + if (fr == FR_OK) { + fr = f_write(&f, p_buf, total, &bw); + (void)f_close(&f); + } + if (fr != FR_OK || bw != total) { + rc = -1; + } + } + } else { + rc = -1; + } + if (rc == 0) { + s_batch_fault[id] = 0u; + } else if (s_batch_fault[id] == 0u) { + s_batch_fault[id] = 1u; + DBG_ERROR("write %s x%lu fail (%d)", s_paths[id], (unsigned long)n, (int)fr); + } + s_rd_gen[id]++; vol_lock_release(); return rc; } @@ -168,33 +301,78 @@ static int s_append(syslog_id_t id, const char *desc) { * open/read 可交错,读到的可能是半删除态文件。try 语义(timeout=0): * 本路径可由 uiTask 调用,uiTask 在 required_mask 内、须每 100ms 喂 * 心跳,绝不能阻塞等锁;busy = 本次读失败(UI 下轮刷新重读,非故障)。 + * + * N-24(2026-10-15,报告 N-16 读侧):加**8 条滑动窗口**。旧实现每读 + * 一条记录都要 open + lseek + read + close 一轮,UI 翻 20 条记录 = + * 20 次目录项/ FAT 往返(报告 N-16 的"UI 翻记录逐条 open/seek/close")。 + * 记录定长 64 B,故一次 open + 一次 lseek + 一次 f_read 就能取回连续 + * 8 条(512 B),命中窗口时零 I/O 直接从 RAM 返回。API 与调用方 + * (ui_page_runlog / ui_page_selftest 的逐行取数)一律不变。 + * 失效机制:写者每次成功落盘/重建后把 s_rd_gen[id] 自增,读者比对 + * 填充时记录的代数——不匹配就重填。ARM 上对齐的 32 位读写是原子的, + * 跨任务(uiTask 填、defaultTask 增)无需额外临界区;最坏情况是 + * 多读一轮,不会读到错数据。 */ static int s_read_raw(syslog_id_t id, uint32_t idx, uint8_t *p_rec) { static FIL f; /* static:省 528 B 栈;open~close 全在卷锁区间内 * (uiTask 与写者任务由 vol_lock 串行化,见上方 P2-10 说明) */ FRESULT fr; UINT br = 0u; - int rc; + uint32_t total; + uint32_t base; + uint32_t want; - if (p_rec == NULL || idx >= syslog_count(id)) { + if (p_rec == NULL) { return -1; } + total = syslog_count(id); + if (idx >= total) { + return -1; + } + /* 快路径:窗口命中(同 id、代数未变、落在窗口区间内)→ 零 I/O */ + if ((s_rd_win_id == (int32_t)id) && (s_rd_win_gen == s_rd_gen[id]) && + (idx >= s_rd_win_base) && (idx < s_rd_win_base + s_rd_win_cnt)) { + (void)memcpy(p_rec, s_rd_win + ((size_t)(idx - s_rd_win_base) * SYSLOG_REC_SIZE), + SYSLOG_REC_SIZE); + return 0; + } if (vol_lock_wait(0u) == 0u) { return -1; /* 卷被占用:本次读失败(非故障,下轮重读) */ } + /* 重新校验:等锁期间写者可能已推进(虽然 try 语义下不会阻塞等待) */ + total = syslog_count(id); + if (idx >= total) { + vol_lock_release(); + return -1; + } + base = (idx / SYSLOG_RD_WINDOW) * SYSLOG_RD_WINDOW; /* 窗口按 8 条对齐 */ + want = total - base; + if (want > SYSLOG_RD_WINDOW) { + want = SYSLOG_RD_WINDOW; + } fr = f_open(&f, s_paths[id], FA_READ); if (fr != FR_OK) { vol_lock_release(); return -1; } - fr = f_lseek(&f, (FSIZE_t)idx * SYSLOG_REC_SIZE); + fr = f_lseek(&f, (FSIZE_t)base * SYSLOG_REC_SIZE); if (fr == FR_OK) { - fr = f_read(&f, p_rec, SYSLOG_REC_SIZE, &br); + fr = f_read(&f, s_rd_win, (UINT)(want * SYSLOG_REC_SIZE), &br); } (void)f_close(&f); - rc = (fr == FR_OK && br == SYSLOG_REC_SIZE) ? 0 : -1; vol_lock_release(); - return rc; + + if ((fr != FR_OK) || (br != (UINT)(want * SYSLOG_REC_SIZE))) { + s_rd_win_cnt = 0u; /* 填充失败:作废窗口,避免半窗被当有效命中 */ + s_rd_win_id = -1; + return -1; + } + s_rd_win_base = base; + s_rd_win_cnt = want; + s_rd_win_id = (int32_t)id; + s_rd_win_gen = s_rd_gen[id]; + (void)memcpy(p_rec, s_rd_win + ((size_t)(idx - base) * SYSLOG_REC_SIZE), SYSLOG_REC_SIZE); + return 0; } /* @@ -271,8 +449,6 @@ void syslog_mcu_mounted(void) { * 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试) */ void syslog_drain(void) { - syslog_entry_t e; - /* 运行期惰性创建消息队列:本函数仅由 defaultTask(调度器运行中)调用, 保证内核对象创建发生在 osKernelStart 之后 */ if (s_queue == NULL) { @@ -284,29 +460,90 @@ void syslog_drain(void) { if (!s_fs_ready || s_queue == NULL) { return; } - while (s_preboot_cnt > 0u) { - e = s_preboot[0]; - if (s_append((syslog_id_t)e.id, e.desc) != 0) { - break; /* 落盘失败:保留剩余条目,下轮重试 */ - } - memmove(&s_preboot[0], &s_preboot[1], sizeof(syslog_entry_t) * (size_t)(s_preboot_cnt - 1u)); - s_preboot_cnt--; - } + + /* N-24(2026-10-15,报告 N-16 写侧):一批一批地排空——同一文件、最多 + * SYSLOG_BATCH_MAX 条合并成一次 open/write/close(旧实现每条一轮 + * open/write/close,每条日志 2~4 次 NAND 页读写)。 + * 取数规则:preboot 数组按下标"看而不删",整批落盘成功才真正删除; + * 队列必须取出才能看下一条,故未落盘的尾部一律回投队列—— + * **条目不丢**(旧实现 -2 卷忙会直接丢掉那一条)。 */ for (;;) { - if (osMessageQueueGet(s_queue, &e, 0u, 0u) != osOK) { - break; /* 队列空 */ + syslog_entry_t e; + uint32_t n = 0u; + uint32_t n_pre = 0u; + uint32_t i; + uint32_t done; + int rc; + syslog_id_t bid = SYSLOG_RUN; + + while (n < SYSLOG_BATCH_MAX) { + uint8_t from_pre = 0u; + + if (n_pre < s_preboot_cnt) { + e = s_preboot[n_pre]; + n_pre++; + from_pre = 1u; + } else if (osMessageQueueGet(s_queue, &e, 0u, 0u) != osOK) { + break; /* 队列空 */ + } + if (n == 0u) { + bid = (syslog_id_t)e.id; + } else if ((syslog_id_t)e.id != bid) { + /* 换文件:本批先落盘,这条留给下一批(原处不动或回投队列) */ + if (from_pre != 0u) { + n_pre--; + } else { + (void)osMessageQueuePut(s_queue, &e, 0u, 0u); + } + break; + } + s_batch_src[n] = e; + s_format_rec(bid, e.desc, s_batch + ((size_t)n * SYSLOG_REC_SIZE)); + n++; } - { - int arc = s_append((syslog_id_t)e.id, e.desc); - if (arc == -2) { - break; /* 卷被占用(FTP 文件操作进行中):本轮顺延,静默 - (条目已出队;run.log 尽力而为语义,不计故障) */ + if (n == 0u) { + break; /* 无更多条目 */ + } + + rc = s_append_many(bid, s_batch, n); + done = n; + if (rc == -3) { + /* 剩余容量装不下一批:退回按条慢路径("满了 unlink 重建"只在 + * 那里做,避免一批跨越多轮重建、语义说不清) */ + done = 0u; + for (i = 0u; i < n; i++) { + int r1 = s_append(bid, s_batch_src[i].desc); + + if (r1 == -2) { + break; /* 卷忙:尾部回投队列 */ + } + done++; + if (r1 != 0) { + DBG_ERROR("drop entry id=%d", (int)bid); + break; /* 写失败:该条已丢,尾部回投 */ + } } - if (arc != 0) { - DBG_ERROR("drop entry id=%d", (int)e.id); - break; /* 落盘失败:停止排空,避免连续丢弃 */ + } else if (rc == -1) { + done = 0u; + DBG_ERROR("drop %lu entries id=%d", (unsigned long)n, (int)bid); + } + /* rc == -2(卷忙):整批回投队列,静默顺延,不计故障 */ + + for (i = done; i < n; i++) { + if (osMessageQueuePut(s_queue, &s_batch_src[i], 0u, 0u) != osOK) { + DBG_ERROR("hold-over drop (queue full): id=%d", (int)s_batch_src[i].id); } } + /* 本批消耗的 preboot 条目:已落盘的删除;未落盘的已转投队列,同样删除 + * (否则会重复落盘) */ + if (n_pre > 0u) { + memmove(&s_preboot[0], &s_preboot[n_pre], + sizeof(syslog_entry_t) * (size_t)(s_preboot_cnt - n_pre)); + s_preboot_cnt = (uint8_t)(s_preboot_cnt - n_pre); + } + if (rc != 0) { + break; /* 本轮到此为止,下轮再试 */ + } } /* B9:SOE 落盘挂本函数尾部——同一唯一写者(defaultTask)、同一卷就绪门 (上方 s_fs_ready 早退蕴含 /log 已建)、同一卷锁 try 语义;protectTask diff --git a/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c b/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c index a217052a..93840991 100644 --- a/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c +++ b/Drivers/BSP/GD5F2GQ5UE/nand_ftl.c @@ -12,6 +12,15 @@ * "复位后所有写入丢失"时 sectors used 不足以判定 * journal 回退(覆盖写不改 count),head/tail/epoch 是 * 唯一能暴露 resume 锚错 checkpoint 的证据 + * 2026-10-15 王建锋 N-21(报告 N-12/N-13):① is_free 改**整页扫描**—— + * 旧实现只看页首 64 B,会把"页首 0xFF、后段有数据"的 + * 已编程页误判空闲,dhara 遂往已编程页再编程 = 数据丢失 + * (页首有数据的绝大多数页走快路径,成本与旧实现同价); + * ② HAL 六个入口全部加几何上界守卫——越界页/块号(含 + * DHARA_PAGE_NONE)旧会被地址位截断成真实页 → 静默读写 + * 错页,现报 DHARA_E_CORRUPT_MAP(不能用 BAD_BLOCK:会 + * 误标无辜好块;不能用 RECOVER:会触发回收); + * ③ 补主机侧移植层单测 test/host/nand_port/(L0.5) */ /* 头文件包含区 */ @@ -45,6 +54,38 @@ static inline uint32_t ftl_phy_block(dhara_block_t b) { return b + FTL_START_BLOCK; } static inline uint32_t ftl_phy_page(dhara_page_t p) { return p + FTL_START_PAGE; } +/* + * N-21(2026-10-15,报告 N-13):移植层地址守卫 + * + * 旧实现把 dhara 传来的页/块号直接加偏移送进 SPI 命令,**不做任何上界校验**。 + * 越界页号(含 dhara 的"无页"哨兵 DHARA_PAGE_NONE = 0xffffffff,journal.h:46) + * 经 ftl_phy_page() 相加后会被芯片地址位截断成某个**真实存在的页** → 静默读/写 + * 错页,比报错严重得多(数据错而传输"成功",与 CCM-DMA 那条同族)。 + * + * 为什么报 DHARA_E_CORRUPT_MAP 而不是报告原建议的 DHARA_E_UNSUPPORTED: + * 本仓裁剪版 dhara 的错误枚举(Lib/dhara/error.h:20-31)里**没有** + * E_UNSUPPORTED,写上去直接编译失败。 + * 为什么不用 DHARA_E_BAD_BLOCK:journal.c:904 的 recover_from 只对 BAD_BLOCK + * 走"标坏块 + 跳块重试"——对一个凭空的地址标坏块 = 毁掉一个无辜的好块。 + * 为什么不会触发静默清卷:map.c:275 的 try_recover 只对 E_RECOVER 启动回收, + * 其余错误一律原样上抛(E-31 教训:单次瞬态毛刺不得销毁整卷数据)。 + * is_free / is_bad 没有错误通道 → 一律保守取值(判非空 / 判坏),与 E-42 同族 + * 口径一致:宁可少用一个块,不可把不可信的内容当有效。 + */ +static int ftl_page_in_range(const struct dhara_nand *n, dhara_page_t p) { + if ((n == NULL) || (n->num_blocks == 0u)) { + return 0; + } + return p < ((dhara_page_t)n->num_blocks << n->log2_ppb); +} + +static int ftl_block_in_range(const struct dhara_nand *n, dhara_block_t b) { + if ((n == NULL) || (n->num_blocks == 0u)) { + return 0; + } + return b < n->num_blocks; +} + /* ======================== 私有变量 ======================== */ /* dhara 核心数据结构 */ @@ -103,18 +144,28 @@ static int ftl_read_page(dhara_sector_t lpn) { /* ======================== dhara NAND HAL ======================== */ int dhara_nand_is_bad(const struct dhara_nand *n, dhara_block_t b) { - (void)n; + if (ftl_block_in_range(n, b) == 0) { + return 1; /* 越界块:保守判坏,让 dhara 跳过 */ + } return gd5f2gq5ue_is_block_bad(ftl_phy_block(b)); } void dhara_nand_mark_bad(const struct dhara_nand *n, dhara_block_t b) { - (void)n; + if (ftl_block_in_range(n, b) == 0) { + return; /* 越界块:绝不标坏,避免毁掉无辜好块 */ + } gd5f2gq5ue_mark_block_bad(ftl_phy_block(b)); } int dhara_nand_erase(const struct dhara_nand *n, dhara_block_t b, dhara_error_t *err) { - (void)n; - int ret = gd5f_block_erase(ftl_phy_block(b)); + int ret; + + if (ftl_block_in_range(n, b) == 0) { + dhara_set_error(err, DHARA_E_CORRUPT_MAP); + return -1; + } + + ret = gd5f_block_erase(ftl_phy_block(b)); if (ret == GD5F_ERASE_FAIL || ret != GD5F_OK) { dhara_nand_mark_bad(n, b); dhara_set_error(err, DHARA_E_BAD_BLOCK); @@ -125,7 +176,13 @@ int dhara_nand_erase(const struct dhara_nand *n, dhara_block_t b, dhara_error_t int dhara_nand_prog(const struct dhara_nand *n, dhara_page_t p, const uint8_t *data, dhara_error_t *err) { int ret; - uint32_t phy_pg = ftl_phy_page(p); + uint32_t phy_pg; + + if ((ftl_page_in_range(n, p) == 0) || (data == NULL)) { + dhara_set_error(err, DHARA_E_CORRUPT_MAP); + return -1; + } + phy_pg = ftl_phy_page(p); gd5f_write_enable(); ret = gd5f_program_load(0, data, FTL_PAGE_SIZE); @@ -149,38 +206,61 @@ int dhara_nand_prog(const struct dhara_nand *n, dhara_page_t p, const uint8_t *d return 0; } +/* + * N-21(2026-10-15,报告 N-12):整页扫描 + * + * 旧实现只看页首 64 B。dhara 的 journal 用 is_free 判断"检查点组是否未编程" + * (journal.c:416 cp_free、journal.c:520 找下一空闲页),其正确性前提是 + * **is_free 答"空闲"的页必须真的全 0xFF**——journal.c:405-414 的注释论证了 + * "只要组内被编程过的页含非 0xFF 字节就能识别出来",可若那一页恰好页首 64 B + * 是 0xFF、后段才有数据,旧实现就会误判空闲。误判的后果不是报错,而是 + * **往已编程的页再编程一次** → 位碰撞、journal/map 指向垃圾 = 数据丢失。 + * 本卷上跑的是 FatFS 数据页,"页首一段 0xFF、后段有数据"完全常见(刚格式化的 + * 卷、稀疏文件块、日志尾部),所以必须整页判。 + * + * 成本处理:先查页首 64 B——页首就有数据的页(绝大多数已编程页)当场返回非空, + * 与旧实现同价;只有"目前读到的全是 0xFF"的页才继续扫后段。分块用栈上 64 B + * 缓冲,不新增 2 KB 静态 RAM(页缓冲三块不可合并的评估见报告 R-3)。 + */ int dhara_nand_is_free(const struct dhara_nand *n, dhara_page_t p) { - (void)n; - uint32_t phy_pg = ftl_phy_page(p); - int fre; + uint8_t buf[64]; + uint32_t phy_pg; + uint32_t off; + uint32_t i; + + if (ftl_page_in_range(n, p) == 0) { + return 0; /* 越界页号:保守判非空 */ + } + phy_pg = ftl_phy_page(p); if (gd5f_page_read(phy_pg) != GD5F_OK) { - fre = 0; - } else { - uint8_t buf[64]; + return 0; + } - if (gd5f_read_from_cache(0, buf, sizeof(buf)) != GD5F_OK) { + for (off = 0; off < FTL_PAGE_SIZE; off += sizeof(buf)) { + if (gd5f_read_from_cache((uint16_t)off, buf, sizeof(buf)) != GD5F_OK) { /* E-42 同族(2026-10-03):读失败 → buf 可能为陈旧/垃圾内容, * 保守判为非空(块不可复用),避免把"没读到内容的块"判成空闲。 */ - fre = 0; - } else { - uint32_t i; - - fre = 1; - for (i = 0; i < sizeof(buf); i++) { - if (buf[i] != 0xFF) { - fre = 0; - break; - } + return 0; + } + for (i = 0; i < sizeof(buf); i++) { + if (buf[i] != 0xFFu) { + return 0; /* 页首命中即返回 = 快路径,不读后段 */ } } } - return fre; + return 1; } int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, size_t length, uint8_t *data, dhara_error_t *err) { - (void)n; - uint32_t phy_pg = ftl_phy_page(p); + uint32_t phy_pg; + + if ((ftl_page_in_range(n, p) == 0) || (data == NULL) || + (offset >= FTL_PAGE_SIZE) || (length > (FTL_PAGE_SIZE - offset))) { + dhara_set_error(err, DHARA_E_CORRUPT_MAP); + return -1; + } + phy_pg = ftl_phy_page(p); if (gd5f_page_read(phy_pg) != GD5F_OK) { dhara_set_error(err, DHARA_E_ECC); @@ -201,8 +281,23 @@ int dhara_nand_read(const struct dhara_nand *n, dhara_page_t p, size_t offset, s return 0; } +/* + * N-21(2026-10-15,报告 N-13):src / dst 双端守卫 + * + * 报告原判断"dhara map 在回收/擦除路径会用 DHARA_NO_PAGE 试探"经逐点核对 + * **不成立**:本仓裁剪版没有 DHARA_NO_PAGE,哨兵叫 DHARA_PAGE_NONE + * (journal.h:46),而 map.c 三处 dhara_journal_copy 调用点都已先挡掉它 + * (map.c:260 挡 pad_queue 的 p、map.c:434/440 挡 alt_page、map.c:242 的 src + * 来自 trace_path 的真实页)。但"移植层不做任何上界校验"这个风险是真的: + * 哨兵一旦从别处漏进来(未来改动、被污染的 meta),ftl_phy_page() 会把 + * 0xffffffff 截断成某个真实页 → **静默搬走无辜数据**。故 src/dst 各挡一次, + * dst 越界尤其致命——那是一次写入。 + */ int dhara_nand_copy(const struct dhara_nand *n, dhara_page_t src, dhara_page_t dst, dhara_error_t *err) { - (void)n; + if ((ftl_page_in_range(n, src) == 0) || (ftl_page_in_range(n, dst) == 0)) { + dhara_set_error(err, DHARA_E_CORRUPT_MAP); + return -1; + } if (dhara_nand_read(n, src, 0, FTL_PAGE_SIZE, s_copy_buf, err) < 0) { return -1; diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index c643604f..699febc8 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -418,6 +418,17 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 | **为什么需要** | 记录格式是**磁盘上的字节级契约**——编解码错了要 FTP 下载 + 解码才看得见,板端日志照不出来;与 `/log/run.log` 同款定长 64 B 语义 | | **运行器** | `test/host/soe/run_unit_test.py`(MSVC `cl.exe`,L0.5) | +### 2.23 nand-port-unit-test — dhara NAND 移植层主机侧单测(2026-10-15 加,N-21) + +| 项 | 说明 | +|---|---| +| **触发条件** | `Drivers/BSP/GD5F2GQ5UE/nand_ftl.(c\|h)`、`gd5f2gq5ue.h`、`Lib/dhara/(map\|journal\|nand\|error\|bytes).(c\|h)`、`test/host/nand_port/**` 或 `test/host/common/unit_test_harness.py` 变动 | +| **处理方式** | **阻止提交**(单测失败即拦截) | +| **被测对象** | `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 的 dhara NAND HAL 六个入口(`is_bad` / `mark_bad` / `erase` / `prog` / `is_free` / `read` / `copy`)——纯地址算术 + 边界判定;测试单元 `#include` 真实 `nand_ftl.c` + 真实 `Lib/dhara/journal.c` / `map.c`,芯片侧换成 **RAM 假芯片**(32 块 × 64 页 × 2048 B,两段式读缓存、NOR 叠加编程、双编程违规计数、访问次数记账) | +| **用例** | 149 断言:整页扫描(页首 64 B 全 0xFF + 第 1000 字节有数据 / 页尾单字节 / 第 64 字节)+ **旧算法对照**(内联"只看页首 64 B"的旧实现,同一页答"空闲"而新实现答"非空")+ 六个入口的越界守卫(`DHARA_PAGE_NONE` / `total_pages` / `num_blocks`,断言 `-1` + `DHARA_E_CORRUPT_MAP` + **零芯片访问 / 零标坏块**)+ 正常往返 + 故障注入(ECC / read_from_cache / program_load / program_exec / erase 失败)+ dhara map 级"写入 → 重新 resume"场景(用 64 B 列读指纹证明 resume 真走了 is_free,再断言全程**双编程计数为 0**) | +| **为什么需要** | N-12 / N-13 两类缺陷真机**极难复现**:is_free 误判要恰好撞上"页首 0xFF、后段有数据"的页形态,越界地址要哨兵从别处漏进来;两者发作后都只表现为"某次复位后丢数据",无从定位。移植层是纯逻辑 + 假芯片,能钉死真机极难复现的越界/误判路径,且"越界不产生任何芯片访问"这类判据在板端**根本拿不到观测面** | +| **运行器** | `test/host/nand_port/run_unit_test.py`(MSVC `cl.exe`,L0.5);套件说明见 `test/host/README.md` | + --- ## 3. 日常工作流 @@ -545,7 +556,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | 文件 | 位置 | 说明 | |---|---|---| -| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**33 个钩子**,2026-10-12 清点;本文 §2 详解非单测类——N-19 新增的两个单测钩子见 §2.16/§2.17,N-23 双端注册反向校验见 §2.18,N-27 RAM 预算门禁见 §2.19,B4 IEC104 协议栈单测见 §2.20,B8 频率测量单测见 §2.21,B9 SOE 动作记录单测见 §2.22,其余主机侧单测见 AGENTS.md 门禁条;主机侧单测共 15 个) | +| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(**34 个钩子**,2026-10-15 清点;本文 §2 详解非单测类——N-19 新增的两个单测钩子见 §2.16/§2.17,N-23 双端注册反向校验见 §2.18,N-27 RAM 预算门禁见 §2.19,B4 IEC104 协议栈单测见 §2.20,B8 频率测量单测见 §2.21,B9 SOE 动作记录单测见 §2.22,N-21 dhara NAND 移植层单测见 §2.23,其余主机侧单测见 AGENTS.md 门禁条;主机侧单测共 16 个) | | `.gitlint` | 仓库根 | gitlint 规则(宽松,兼容中文) | | `.cubemx_survival_baseline.json` | 仓库根 | CubeMX 易失定制基线(哈希;`--write` 重建) | | `scripts/check_mojibake.py` | scripts/ | U+FFFD 检测脚本 | diff --git a/docs/任务规划/README.md b/docs/任务规划/README.md index 2aea8f40..7d4ba8ee 100644 --- a/docs/任务规划/README.md +++ b/docs/任务规划/README.md @@ -66,8 +66,7 @@ | P1 收尾 | 1 | P1-10 测试固件构建实测(`测试与架构专项.md` §1) | | 网络 | 4 | N-2(49s 日志静默,**低风险观察项**)· N-3(L5 基线重验)· N-16(A2/A4 负向台架)· N-17(探针窗口心跳缺口) | | 存储 / 日志 | 3 | **S-3(USART1 TX-only 根治)** · S-4(dhara 重试自旋)· S-5(可选全序列卷锁) | -| 数据正确性 | 1 | **N-21(dhara `is_free`/`copy`,全簿最高风险)** | -| 结构 / 健壮性 | 6 | N-22(写回 flush)· N-23(注册校验残余白名单)· N-24(日志逐条开关)· N-25(性能/语义簇)· N-28(Flash 死代码簇)· **N-33(栈余量收口,W1)** | +| 结构 / 健壮性 | 5 | N-22(写回 flush)· N-23(注册校验残余白名单)· N-25(性能/语义簇)· N-28(Flash 死代码簇)· **N-33(栈余量收口,W1)** | | 仓库 | 1 | N-29(`.git` **235 MB**,**已决策暂不处理、登记观察**) | | 硬件 | 1 | N-18(V0.2 改版项) | | 测试 / 基础设施 | 6 | T-7(CI runner + 推送闭环 + 第二机交叉验证)· S12(断言宏)· S1-c(可选单测)· A-4(采集链路测试约束,RS485 侧随 B2 暂缓)· A-5 残余(CI 体积门禁盲区)· D7(串口助手 A6/A7 待 CH340) | @@ -78,14 +77,14 @@ > `docs/编号规范.md` §4.3):E-25 / E-27 / E-28 挂 C1(net 层跨上下文收口,见 `排期详述.md` §1); > E-47 待 CH395F 驱动侧定案;E-12 为 `FF_FS_LOCK` / 单写者纪律记录项;E-31 后续与 E-62 > 为回归监督项(纪律已固化于 `AGENTS.md`,无需改动)。 -> UI 标准化簇与覆盖率缺口(lftpd/dhara 移植层无主机测试)为整簇排期项,见 §2 W6 / N-21。 +> UI 标准化簇与 lftpd 主机测试缺口为整簇排期项,见 §2 W6(dhara 移植层主机单测已随登记簿 +> N-21 销项落地 `test/host/nand_port/`,2026-10-15)。 ### 1.1 结构性判断(排序依据) 1. **用户决定(2026-10-12):先把 ADC 采集、保护等相关功能完成**——"转发 + 保护"两环代码已落地(B4/B5,2026-10-12),但**尚未台架闭环、功能面不完整**(定值不落盘、无频率保护、无动作记录/SOE、栈安全线两处 FAIL)。新 W1 = 采集→相量→保护→上送链路的**收尾与闭环**(B6~B10 + N-33,见 `W1-采集保护收尾.md`),其余波次顺延。**RS-485 对下采集(B2)按用户决定暂缓(2026-10-12,`暂缓-RS485.md`),主线不再等它**。 -2. **数据正确性最高风险 = N-21**(dhara `is_free` 只查页首 64 B / `copy` 不处理哨兵):低概率、后果重(数据丢失),且至今只能靠人工读代码发现——修复须配主机侧移植层测试(一并补报告"覆盖率"缺口)。 -3. **内存预算红线已机械化(2026-10-12,W1-0)**:`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%,子系统配额 / CCM 台账 / 趋势基线 → `docs/内存管理/RAM预算台账.md`。实际优化只做了 **R-9(2026-10-15 落地,SRAM1 74.9% → 56.1%)**;**R-2 后半与 R-3 / R-4 / R-6 已按用户裁定 2026-10-15 整簇暂缓**(当前 CCM 只剩 **7,904 B**,重开条件见 `../问题记录/项目审查与优化建议报告.md` §5.3)——**门禁与台账继续常开,它们正是"何时该重开"的传感器**。 -4. **验证债仅剩窗口性事项**(D2/D5/D6/D7 + P1-10 收尾),不单独排期、逢窗口顺手做。 +2. **内存预算红线已机械化(2026-10-12,W1-0)**:`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%,子系统配额 / CCM 台账 / 趋势基线 → `docs/内存管理/RAM预算台账.md`。实际优化只做了 **R-9(2026-10-15 落地,SRAM1 74.9% → 56.1%)**;**R-2 后半与 R-3 / R-4 / R-6 已按用户裁定 2026-10-15 整簇暂缓**(当前 CCM 只剩 **7,904 B**,重开条件见 `../问题记录/项目审查与优化建议报告.md` §5.3)——**门禁与台账继续常开,它们正是"何时该重开"的传感器**。 +3. **验证债仅剩窗口性事项**(D2/D5/D6/D7 + P1-10 收尾),不单独排期、逢窗口顺手做。 --- @@ -101,26 +100,25 @@ | 波次 | 内容 | 为什么在这 | |---|---|---| | **W1 采集+保护收尾** | **里程碑制 + 并行道**:M1 台架闭环 ✅ 达成(B6 ✅→N-33 ✅)∥ M2 功能面(代码道 B7 ✅→{B8∥B9})→ M3 送检(B10,Fixed Date)——任务卡/判据/写范围见 `W1-采集保护收尾.md` | **用户决定(2026-10-12):先把 ADC 采集、保护等相关功能完成**。B4/B5 代码已落地但未台架闭环、功能面不完整(定值不落盘 / 无频率保护 / 无动作记录 / 栈两处 FAIL)——本波收口到"可用 + 可验收";真约束只有**台架**与**送检窗口**两个稀缺资源,其余任务按写范围切分并行 | -| **W2 数据正确性** | **N-21** dhara `is_free`/`copy`(报告 N-12/13) | 低概率、后果重(数据丢失);修复须配主机侧移植层测试,一并补报告"覆盖率"缺口 | | **W3 预算与内存** | ⏸ **整簇暂缓**(用户裁定 2026-10-15):~~缩堆 56→32 KB → R-2 后半(29.5 KB→CCM)→ R-3 / R-4 / R-6 逐项评估~~。本簇已落地的只有 **R-9 ✅(2026-10-15)**;R-5 已判定不可行 | **不再排期**:没有功能在等这块预算,而搬迁要动 scatter、要逐条过 DMA 可达性清单、缩堆还要上板复测。**W3 槽位保留、不重编号**(避免全库改 W4~W7 引用)。重开条件 → `../问题记录/项目审查与优化建议报告.md` §5.3 | -| **W4 结构债** | **C6(S-3)→ C3(N-2+N-24)→ C1 net 层跨上下文收口 → C5(含 N-23 残余)→ N-22/N-25/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 消化 E-25/E-27/E-28(原 ACCEPT+SEND pending 化已收口),体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5) | +| **W4 结构债** | **C6(S-3)→ C3(N-2)→ C1 net 层跨上下文收口 → C5(含 N-23 残余)→ N-22/N-25/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 消化 E-25/E-27/E-28(原 ACCEPT+SEND pending 化已收口),体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5)。⚠ 原挂 C3 的 **N-24(日志逐条开关)已于 2026-10-15 单独修复销项**,C3 只剩 N-2 观察项 | | **W5 验证常态** | P1-10 收尾 · T-7 CI · D2 N-3 · D5 N-16 · D6 N-17 · D7 串口助手 · S12 · S1-c · A-5(详表 `排期详述.md` §2) | 窗口性,逢机顺带,不单独排期 | | **W6 UI 扩展** | U4 残余(golden + 字符集差集挂门禁)→ U1 令牌层 → U2/U3 → **27 页**落地 | 产品驱动、体量大,需单独立项(顺序按 `docs/问题记录/项目审查与优化建议报告.md` §6.5);**本轮不排期(2026-10-11 已确认)** | | **W7 仓库决策** | **N-29** `.git` **235 MB** + 图片 36.3 MB | **已决策(2026-10-11):暂不处理、登记观察**——推送/协作前重评(`git filter-repo` 历史重写影响未推送提交与协作者) | | **暂缓** | **B2 RS-485 整簇**(`暂缓-RS485.md`) | **用户决定(2026-10-12):当前不处理 RS-485** | | **硬件改版** | N-18 8 路电气量全接 TPAFE(V0.2) | 下一版硬件;固件侧已用语义映射隔离,改版只动映射表 | +> ✅ **W2 数据正确性已清空销项(2026-10-15)**:登记簿 N-21(dhara `dhara_nand_is_free()` 只查页首 64 B / HAL 越界无校验)已修复并配主机侧移植层单测 `test/host/nand_port/` → 实施记录 `../架构设计/STM32F4-Base存储架构说明.md` §5.3、验证链 `../问题记录/项目审查与优化建议报告.md` 变更记录。**W2 槽位与 W3 同理保留、不复用也不重编号 W3~W7**(避免全库改引用)。 + ### 2.1 顺序一图 ``` W1 收尾 台架道: B6✅→N-33✅(M1 ✅ 2026-10-12) ∥ 代码道: B7✅→B8✅→B9✅(M2 ✅ 2026-10-12) → B10 送检(M3, Fixed Date) 详表 W1-采集保护收尾.md (B4 IEC104 ✅ / B5 protectTask ✅ / B6 台架复测 ✅ / B7 定值持久化+软压板 ✅ / N-33 栈收口 ✅ / B8 频率保护 ✅ / B9 SOE 动作记录 ✅ 2026-10-12 均已销项;台账三件套 ✅) ↓ -W2 数据 N-21 dhara is_free/copy(配主机侧移植层测试) - ↓ W3 预算 ⏸ 整簇暂缓(2026-10-15 用户裁定);R-9 池瘦身 ✅ 已落地不回退 ↓ -W4 结构 C6(S-3) → C3(N-2/N-24) → C1 net 层跨上下文收口 → C5(含 N-23 残余) → N-22/25/28 +W4 结构 C6(S-3) → C3(N-2) → C1 net 层跨上下文收口 → C5(含 N-23 残余) → N-22/25/28 ↓ W5 验证 P1-10 · T-7 CI · D2/D5/D6/D7 · S12 · S1-c · A-5 ↓ diff --git a/docs/任务规划/排期详述.md b/docs/任务规划/排期详述.md index 2c93d63f..88f58e4c 100644 --- a/docs/任务规划/排期详述.md +++ b/docs/任务规划/排期详述.md @@ -10,7 +10,7 @@ | 序 | 任务 | 说明 | 出处 | |---|---|---|---| | **C6** | **USART1 TX-only 结构根治(S-3)** | 灭 ORE 僵尸机制根源(2026-10-02 台架 IWDG 误复位的机制根源):改 `.ioc` 令 USART1 不使能 RX / PA10 不复用为 `USART1_RX` → CubeMX 重生成 → 跑 `check_cubemx_survival.py`。结构性改动,需单开一轮 + 复跑台架闭环 | S-3 | -| **C3** | **存储侧日志管线(N-2 + N-24)** | 丢弃可观测性**代码已具备**(`console_log.c` `s_con_drop`+`[ERR] CONSOLE dropped`、`sys_log_mcu.c` 队列满 `DBG_ERROR`、TX 僵尸通道自愈);V5 30 min 日志**未复现 49s 静默**(运行期 >10s 静默全为 ~28s、均在 `e02_probe` 窗口内=N-17)。剩余:与 S8(非阻塞日志)合并评估 + 日志逐条 `f_open/f_write/f_close` 改造评估(N-24) | N-2 / N-24 | +| **C3** | **存储侧日志管线(N-2)** | 丢弃可观测性**代码已具备**(`console_log.c` `s_con_drop`+`[ERR] CONSOLE dropped`、`sys_log_mcu.c` 队列满 `DBG_ERROR`、TX 僵尸通道自愈);V5 30 min 日志**未复现 49s 静默**(运行期 >10s 静默全为 ~28s、均在 `e02_probe` 窗口内=N-17)。剩余:与 S8(非阻塞日志)合并评估。⚠ 原挂本卡的 **N-24**(日志逐条 `f_open/f_write/f_close`)已于 2026-10-15 修复销项(写侧一次排空合并成每文件一次开关、读侧 8 条滑动窗口)→ 实施记录 `../子系统设计/运行与自检记录设计说明.md` §4.4 / §4.5 | N-2 | | **C1** | **net 层跨上下文收口** | 消化 E-25(`net_select` 语义)、E-27/E-28(裸指针收口为状态查询 API)。原 ACCEPT + SEND pending 化已收口:SEND 2026-10-14 落地、ACCEPT 决定不实施(2026-10-15) | E-25 / E-27 / E-28 | | **C5** | 一档剩余 | **N-23 残余**(`KNOWN_GAPS` 白名单清理 + `app_cfg.h` 未接线开关,见 `登记簿.md`)。**RS-485 DE 收尾已移入 `暂缓-RS485.md`** | 一档/二档 | | **N-22/25/28** | 评估簇 | 写回 flush / 性能与语义一致性 / Flash 死代码——逐项见 `登记簿.md` 对应行,随窗口评估 | 报告各条 | diff --git a/docs/任务规划/登记簿.md b/docs/任务规划/登记簿.md index 45f666de..f0114787 100644 --- a/docs/任务规划/登记簿.md +++ b/docs/任务规划/登记簿.md @@ -41,10 +41,8 @@ | # | 事项 | 影响 / 现状 | 处置方向 | 波次 | 出处 | |---|---|---|---|---|---| -| N-21 | **dhara 两处**:`dhara_nand_is_free()` 只查页首 64 B;`dhara_nand_copy()` 不处理 `DHARA_NO_PAGE` 哨兵 | 映射恢复误判 → **数据丢失风险**(低概率、后果重);哨兵页号被当真实页 → 未定义行为;至今只能靠人工读代码发现 | 整页(或首/中/尾)扫描 + 返回 `DHARA_E_UNSUPPORTED`;**必须配主机侧移植层单测**(一并补报告"覆盖率"缺口) | **W2** | 报告 N-12/N-13 + §7 | | N-22 | 写回缓存无空闲/掉电强制 flush(整页写与换页已 flush) | 写回窗口内掉电丢最近日志/配置写 | defaultTask 空闲 N ms flush | W4 | 报告 N-14 | | N-23 残余 | **钩子已落地**(2026-10-12 `registration-check` 挂 pre-commit,存量缺口走 `KNOWN_GAPS` 白名单只 WARN);**残余** = 白名单只剩 `rs485_task.c` 一条**刻意**缺口(E-59 门禁下随 B2 暂缓,见 `暂缓-RS485.md`);`app_cfg.h` 未接线开关(C5)。⚠ `lftpd_log.c` 已随 N-19 删除并入 `lftpd_auth.c`(2026-10-11),不再是缺口 | 白名单不清 = "写了不编译"盲区仍在 | 随 B2 一并出白名单(`--strict` 供审计复跑);开关随 C5 处置 | W4(C5) | 报告 N-06 残余 + 报告 S-3 | -| N-24 | 日志每条 `f_open+f_write+f_close`(`sys_log_mcu.c:123-140`) | 每条日志 2–4 次 NAND 页读写;UI 翻记录逐条 open/seek/close | 与 C3 日志管线合并评估(现仅 `FIL` static 化省 528 B 栈) | W4 | 报告 N-16(**≠ 本登记簿 N-16**) | | N-25 | 性能/资源建议簇:PASV 每次新建监听;PHY 强制 100M 无自协商回退;`NET_MAX_SOCKETS 8` 与单连接语义不符;`HAL_Delay(1)` 阻塞 DE(**DE 项随 B2 暂缓,见 `暂缓-RS485.md`**)。⚠ 原列的 `NET_RECV_TIMEOUT_MS 30000` 已随 2026-10-13「recv 语义主流化」删除(挂起 recv 模型),不再是待办 | 次级性能/语义一致性 | 逐项见 `../问题记录/项目审查与优化建议报告.md` §4.2/§4.7,随窗口评估 | W4 | 报告 N-09/PHY/P3-x/rs485 | | N-27 | **内存优化簇**:**R-9 ✅ 已落地(2026-10-15)**——`LV_MEM_SIZE` 40→16 KB → SRAM1 由 74.9% 降到 **56.1%(余 57,504 B)**;待迁 CCM 体量随之由 54,146 B 降到 **29,570 B**,而 CCM 只剩 **7,904 B**(`RW_CCM` 上限 65,280 − 57,376) | ⏸ **整簇暂缓(用户裁定 2026-10-15)**:R-2 后半(缩堆 56→32 KB → 迁 CCM)与 R-3 / R-4 / R-6 **不再排期**——没有功能在等这块预算,而搬迁要动 scatter、要逐条过 DMA 可达性清单、缩堆还要上板复测,收益不抵风险与工时。⚠ 代价落在"以后加任务 / 抬栈 / 加堆"那一刻:CCM 只剩 7,904 B、FreeRTOS 堆实测 `min_ever` 8,464 B,届时 `mem-budget` 会先报警 → 按报告 §5.3 重开条件评估 | **三件套 ✅ 2026-10-12 落地**(`ram_baseline.py` 四模式 + `ram_baseline.tsv` 趋势 + `docs/内存管理/RAM预算台账.md` + `mem-budget` 钩子 92% 门禁)→ **R-9 ✅ 2026-10-15**(实测峰值 6,568 B = 池的 40%,水位表见 `../内存管理/RAM使用分析与优化方案.md` §2.6.2)→ **⏸ 其余整簇暂缓**(2026-10-15 裁定;理由与重开条件 → `../问题记录/项目审查与优化建议报告.md` §5.3)。⚠ **R-5 已判定不可行、从本簇剔除**:CMSIS-RTOS2 `freertos_os2.h` 有 `#if (configMAX_PRIORITIES != 56) #error`,改小直接编译失败(为 480 B 改第三方包装层不划算) | **暂缓** | 报告 R-2 剩余/R-3/R-4/R-6/§5.5 | | N-28 | Flash 死代码评估簇:剩余 ≈10 KB(`box_shadow` 3,246 / `draw_sw_img` 2,960 / `bin_decoder` 1,972 / `image_decoder` 952 / `triangle` 930)+ 双精度 `dadd` 等 ≈1 KB;uvprojx 仍挂 201 个空 LVGL TU(F-1/F-2/F-3 已闭环,省的是编译时长) | 编译时长 + 文档噪音 | 逐项实测再关;⚠ `mask`/`mask_rect`/`grad` 是文字与矩形绘制必经路径,**不可盲关** | W4 | 报告 F-4/F-5/F-6 | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 8967853b..9c46fa3f 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 231,084 B (225.67 KB) | 1,048,576 B (1024 KB) | 22.0% | -| RW_IRAM1 | 73,568 B (71.84 KB) | 131,072 B (128 KB) | 56.1% | +| ER_IROM1 | 232,280 B (226.84 KB) | 1,048,576 B (1024 KB) | 22.2% | +| RW_IRAM1 | 75,232 B (73.47 KB) | 131,072 B (128 KB) | 57.4% | | RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% | -| **Flash (ROM 合计)** | **232,196 B (226.8 KB)** | 1,048,576 B (1024 KB) | **22.1%** | +| **Flash (ROM 合计)** | **233,416 B (227.9 KB)** | 1,048,576 B (1024 KB) | **22.3%** | 各区的容量与常量来源: diff --git a/docs/内存管理/RAM预算台账.md b/docs/内存管理/RAM预算台账.md index 0fef72bd..e0b5c06b 100644 --- a/docs/内存管理/RAM预算台账.md +++ b/docs/内存管理/RAM预算台账.md @@ -15,10 +15,10 @@ | 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) | |---|---|---|---|---| -| ER_IROM1 (Flash) | 231,084 B | 1,048,576 B | 22.0% | 参考 | +| ER_IROM1 (Flash) | 232,280 B | 1,048,576 B | 22.2% | 参考 | | RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS | | UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 | -| RW_IRAM1 | 73,568 B | 131,072 B | 56.1% | PASS | +| RW_IRAM1 | 75,232 B | 131,072 B | 57.4% | PASS | | 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 | |---|---|---|---| @@ -26,7 +26,7 @@ | 存储 | 7,056 B | 12 KB | OK | | UI | 30,412 B | 16 KB | ⚠ 超 13.7 KB | | RTOS 堆 | 57,376 B | 56 KB | 堆=配额 | -| 应用 | 13,247 B | 8 KB | ⚠ 超 4.9 KB | +| 应用 | 14,911 B | 8 KB | ⚠ 超 6.6 KB | | 其它 | 9,184 B | — | 参考 | diff --git a/docs/子系统设计/运行与自检记录设计说明.md b/docs/子系统设计/运行与自检记录设计说明.md index 045fe0b7..43e79f66 100644 --- a/docs/子系统设计/运行与自检记录设计说明.md +++ b/docs/子系统设计/运行与自检记录设计说明.md @@ -123,12 +123,18 @@ defaultTask(唯一写者,挂载成功后): 1) f_mkdir("/log")(幂等) - 2) 排空启动前数组 + 队列 → 逐条落盘(f_open FA_OPEN_APPEND → f_write(64B) → f_close) + 2) 排空启动前数组 + 队列 → 按文件合并落盘(一批最多 8 条:一次 + f_open FA_OPEN_APPEND → f_write(n×64B) → f_close,见下方 N-24 说明) 3) 写"上电/重启"分类记录(§5.3,基于 run.log 末条 epoch) 4) 主循环:osMessageQueueGet(阻塞, 10s 超时) → 落盘;超时空转继续 LED 闪烁 ``` -- 读路径与写路径并发安全:UI 页在 uiTask 直接 `f_open(FA_READ)` 按偏移读记录(只读既有内容,写者只在 EOF 追加);FatFS 系统锁保护单次调用。 +- **N-24(2026-10-15,报告 N-16 写侧):一次排空合并成"每文件一次 open/write/close"**。旧实现每条记录一轮,而 `f_close` 要回写目录项、`f_write` 要对目标扇区做读-改-写 → **每条日志 2~4 次 NAND 页读写**。现在同一文件的一批(≤8 条 = 512 B 静态缓冲 `s_batch`)合并成一次追加写: + - **掉电语义不变**:批次仍在**同一次 `syslog_drain()` 调用内**写完并 `f_close`;close 回写目录项(另一个 LPN)会把 FTL 的单页写回缓存逼出到 NAND——该"写到不同 LPN 才刷前一脏页"的语义见 `sys_log_evidence()` 的 flush 诱导注释。落盘时机与旧实现每条 close 等价,**批次不跨调用驻留 RAM**。 + - **条目不丢**:启动前数组按下标"看而不删",整批成功才删;队列条目在落盘失败(-2 卷忙 / -1 写失败 / -3 容量不足)时**回投队列**——旧实现 -2 会直接丢掉那一条。 + - **容量轮转仍只在按条慢路径做**:批量路径发现剩余容量装不下一批时返回 -3,退回逐条 `s_append()`("满了 unlink 重建"只保留一处实现,避免一批跨多轮重建)。 + - 自愈重试(L1 丢弃重建 + 重试一次)与错误去重(L2 只报第一次)判据与单条路径一致。 +- 读路径与写路径并发安全:UI 页在 uiTask 经 `syslog_get_record()` 读记录(内部走 `s_read_raw()`,见 §4.5);读写两侧都取卷锁 try 语义(P2-10),FatFS 系统锁保护单次调用。 - **存储未就绪/挂载失败**:待发条目滞留 RAM(下次上电丢失),串口打印告警——存储故障时本就无法持久化,属预期行为。 ### 4.5 读路径(UI / 模拟器) @@ -139,7 +145,9 @@ int syslog_get_record(syslog_id_t id, uint32_t idx, char *ts, size_t ts_len char *desc, size_t desc_len); /* 越界返回 -1 */ ``` -- MCU:`f_open(FA_READ) → f_lseek(idx*64) → f_read(64B) → f_close`,内联拆分函数切 ts/desc。 +- MCU:**8 条滑动读窗口**(N-24,报告 N-16 读侧)——`s_read_raw()` 先查窗口(同 id + 代数未变 + 落在区间内 → **零 I/O** 直接从 512 B 静态窗口返回);未命中才 `f_open(FA_READ) → f_lseek(窗口首条×64) → f_read(≤8×64B) → f_close` **一次取回连续 8 条**,再从中切出请求的那条。旧实现每读一条都要 open/lseek/read/close 一轮,UI 翻 20 条记录 = 20 次目录项/FAT 往返。 + - 失效机制:写者每次落盘后把 `s_rd_gen[id]` 自增,读者比对填充时记录的代数,不匹配就重填。对齐 32 位在 ARM 上原子,跨任务(uiTask 填、defaultTask 增)无需临界区;最坏情况多读一轮,不会读错。 + - **API 与调用方一律不变**(`syslog_get_record()` 签名照旧,`ui_page_runlog` / `ui_page_selftest` 零改动)。 - 模拟器:宿主文件同语义(UI 页双端零差异)。 - UI 页每帧仅读当前窗口 ≤8 条记录(每次重绘 8×64B SPI 读,用户翻页触发,频率极低)。 diff --git a/docs/架构设计/STM32F4-Base存储架构说明.md b/docs/架构设计/STM32F4-Base存储架构说明.md index da17ecfe..079bee36 100644 --- a/docs/架构设计/STM32F4-Base存储架构说明.md +++ b/docs/架构设计/STM32F4-Base存储架构说明.md @@ -331,11 +331,16 @@ dhara 通过 7 个回调函数访问物理 NAND: | `dhara_nand_erase` | 擦除块 | `nand_block_erase(block + 1024)` | | `dhara_nand_prog` | 写页 | `nand_program_load + nand_program_exec` | | `dhara_nand_read` | 读页 | `nand_page_read_to_cache + nand_read_from_cache` | -| `dhara_nand_is_free` | 检查页空闲 | 读前 64 字节判断全为 0xFF | +| `dhara_nand_is_free` | 检查页空闲 | **整页扫描 2048 B 判全 0xFF**(栈上 64 B 缓冲分块读;页首命中即早退,见下方 ⚠) | | `dhara_nand_copy` | 页拷贝 (GC 用) | read + prog 组合 | 所有回调自动将 dhara 逻辑块/页加 `FTL_START_BLOCK` 偏移转换为物理地址。 +⚠ **两条移植层硬约束(2026-10-15,登记簿 N-21 / 报告 N-12、N-13)**: + +1. **`is_free` 必须整页精确**。dhara 的 journal 用它判断"检查点组是否未编程"(`journal.c:416 cp_free`、`journal.c:520` 找下一空闲页),其正确性前提是"答空闲的页必须真的全 0xFF"(`journal.c:405-414` 的注释论证)。旧实现只看页首 64 B → 一个"页首 0xFF、后段有数据"的已编程页会被误判空闲,dhara 遂**往已编程页再编程一次** = 位碰撞、journal/map 指向垃圾(数据丢失,且不报任何错)。本卷跑的是 FatFS 数据页,这种页型很常见(刚格式化的卷、稀疏文件块、日志尾部)。成本上做了快路径:页首 64 B 有数据就当场返回非空,与旧实现同价,只有"目前读到的全是 0xFF"的页才继续扫后段。 +2. **六个 HAL 入口一律做几何上界校验**(上界 = `n->num_blocks << n->log2_ppb`)。越界页/块号(含 dhara 哨兵 `DHARA_PAGE_NONE` = 0xffffffff)旧实现直接加偏移送进 SPI 命令,会被芯片地址位**截断成某个真实页** → 静默读写/搬走无辜数据。现越界时 prog/read/copy/erase 返回 -1 且 `*err == DHARA_E_CORRUPT_MAP`,`is_free` 返回 0(保守判非空)、`is_bad` 返回 1(保守判坏)、`mark_bad` 直接返回(绝不标无辜好块)。**错误码选择是刻意的**:不能用 `DHARA_E_BAD_BLOCK`(`journal.c:904 recover_from` 会据此标坏块并跳块),也不能用 `DHARA_E_RECOVER`(`map.c:275 try_recover` 会据此启动回收);其余错误一律原样上抛,不会静默清 map(E-31 教训)。 + ### 5.4 初始化流程 (disk_initialize) ``` diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md index 975ab08c..cd9ba4a1 100644 --- a/docs/测试说明/测试开发标准流程.md +++ b/docs/测试说明/测试开发标准流程.md @@ -273,6 +273,7 @@ static void phaseN_run(void) { | **`test/host/watchdog/run_unit_test.py`** | `test/host/watchdog/` | **L0.5 主机侧单测**:声明 `TestBuild` 后调 harness,穷举看门狗判定状态机(零硬件、秒级)。**需本机装有 VS(`cl.exe`)**,见 §4.2 环境要求 | | **`test/host/sys_clock/run_unit_test.py`** | `test/host/sys_clock/` | **L0.5 主机侧单测**:穷举 `sys_clock` 时间换算 / 星期 / 惰性再基准化 / RTC 故障降级(100 断言,S1-a) | | **`test/host/cfg_file/run_unit_test.py`** | `test/host/cfg_file/` | **L0.5 主机侧单测**:INI 解析 / 值编解码 / 段注册 / load-save-poll 契约,内存虚拟 FS 驱动(139 断言,S1-b) | +| **`test/host/nand_port/run_unit_test.py`** | `test/host/nand_port/` | **L0.5 主机侧单测**:离线编译真实 `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` + 真实 dhara,以 **RAM 假芯片**驱动,149 断言钉死 is_free 整页扫描(N-12)与六个入口的越界守卫(N-13) | | **`scripts/ci_local.py`** | `scripts/` | **本地跑与 CI 等价的门禁**(L0 + L0.5):自动选用装了 pre-commit 的解释器;`.map` 自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。见 §9.3 | | `.serialtest/config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 | | `test/host/ftp_*.py`(4 个)/ `ch395f_socket_test.py` / `time_sync_server.py` | `test/host/` | PC 端交互测试工具(FTP 客户端 / socket 回显 / 对时推送) | @@ -329,9 +330,10 @@ static void phaseN_run(void) { | `App/util/time_sync.c` | — | ⬜ 候选 | 协议帧编解码 | 报文组装/解析纯逻辑;网络收发不测 | | `Drivers/BSP/NETCFG/net_cfg_legacy.c` | — | ⛔ 已删除(2026-10-10) | — | 旧档 NCFG 迁移整体移除(旧档 `/cfg/netcfg.bin` 不读、不删、不迁移),测试候选随之撤销 | | `App/protocol/iec104/*`(104 协议栈) | — | ✅ 已覆盖(82 断言,2026-10-12 B4) | 数据源 vtable + tick 注入 + 发送回调注入 | APCI 重组/总召分片/COV/k-w-t1~t3/两段式全离线穷举;`iec104-unit-test` 钩子 | +| `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c`(dhara 移植层) | 573 行 | ✅ 已覆盖(149 断言,2026-10-15 N-21) | 11 个 SPI 原语(`gd5f2gq5ue.h`)→ **RAM 假芯片** | 移植层是纯地址算术 + 边界判定,换掉芯片即可离线跑;钉死 N-12(is_free 整页扫描)与 N-13(六入口越界守卫),并内联旧算法做对照证明断言有牙 | | `App/util/rs485.c` | — | ⏸ 待功能实现 | — | 上游 B2;当前为空桩 | | `Drivers/BSP/TPAFE5160/*.c` | — | ❌ 不适格 | — | 寄存器序列为主,需真机时序 | -| 其余驱动(CH395F/GD5F2GQ5/SD2506) | — | ❌ 不适格 | — | 需硬件时序 / SPI / I2C 总线 | +| 其余驱动(CH395F/GD5F2GQ5UE 芯片层/SD2506) | — | ❌ 不适格 | — | 需硬件时序 / SPI / I2C 总线。⚠ **例外**:GD5F 之上的 `nand_ftl.c` 移植层**适格**(见上一行)——"驱动不适格"不等于"驱动之上的胶水层不适格" | | `App/util/crc.c` · `ringbuf.c` | 120/154 行 | ⛔ 已删除(2026-10-02 S8) | — | 孤儿文件,零引用,已 `git rm` | **使用方式**:新增/改动上述模块前,按表判断是否需补 L0.5;`⬜ 候选` 表示"适格但未做", @@ -623,6 +625,7 @@ CI 就静默停摆(2026-10-11 实测 run 16~21 共 12 个 job 零日志)。 | 日期 | 变更 | |---|---| +| 2026-10-15 | **新增 `test/host/nand_port/`(dhara NAND 移植层 L0.5,N-21 / 报告 N-12+N-13)**:`#include` 真实 `nand_ftl.c` + 真实 `Lib/dhara/{journal,map}.c`,芯片侧换成 **RAM 假芯片**(32 块 × 64 页 × 2048 B,两段式读缓存 + NOR 叠加编程 + 双编程违规计数 + 访问次数记账),149 断言覆盖 is_free 整页扫描、六入口越界守卫(`-1` + `DHARA_E_CORRUPT_MAP` + 零芯片访问 / 零标坏块)、故障注入与 dhara map"写入 → 重新 resume"场景;**内联一份"只看页首 64 B"的旧算法做对照**,证明断言能咬人。挂 pre-commit 钩子 `nand-port-unit-test`(第 16 个主机侧单测,钩子总数 33→34)。§4.1 登记运行器、§4.2.1 台账新增该模块并给"其余驱动不适格"补例外说明(驱动不适格 ≠ 驱动之上的胶水层不适格) | | 2026-10-12 | **§9 瘦身:机器侧内容迁出**(远程 CI 文档独立成 `docs/CI/`)。§9.2 两个 job 的**步骤级细节**并入 `docs/CI/远程CI完整指南.md` §5.5、§9.4 部署前置表并入其 §0.2;本文 §9 只保留**测试分层视角**的内容(CI 覆盖哪些层、两个 job 各守哪层、触发与 paths-ignore 语义、本地等价 `ci_local.py`、"CI 不替代本地门禁")。§9.1 两个 🔴 块压缩为结论 + 指针(机制根因与排障口径归 `docs/CI/`)。相关文档表两行 CI 条目合并为一行指向 `docs/CI/README.md` | | 2026-10-10 | §4.2.1 台账:`Drivers/BSP/NETCFG/net_cfg_legacy.c` 由 `⬜ 候选` 改为 **`⛔ 已删除`**——旧档 NCFG 迁移整体移除(`net_cfg_legacy.{c,h}` 删除、`cfg_store_legacy_path()` 撤销;旧档 `/cfg/netcfg.bin` 不读、不删、不迁移,配置回落工厂默认),该测试候选随之撤销 | | 2026-10-02 | **S6**:`test/run_all_suites.py` 新增 **`--check-build`**(只编译不烧录,纯离机,不需要 MCU/串口/ST-LINK);新增 **§3.6.1** 说明其解决的问题(测试构建无独立门禁,2026-08~09 CH395F 套件实例)、判定语义与用法。**实测 4 套件全部 `BUILD_OK: 0 errors, 0 warnings`**(即 T-4 提到的"CH395F 不可编译"已修复) | diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md index 93aeb231..396ab9bb 100644 --- a/docs/环境与部署/并行任务工作树方案.md +++ b/docs/环境与部署/并行任务工作树方案.md @@ -397,7 +397,7 @@ docs\内存管理\RAM使用分析与优化方案.md:585 [路径引用] docs/内 | 主题 | 去处 | |---|---| -| 提交门禁 30 个钩子 | `docs/代码规范/pre-commit使用说明.md` | +| 提交门禁 34 个钩子 | `docs/代码规范/pre-commit使用说明.md` | | 自动化测试与台架流程 | `docs/测试说明/自动化测试流程.md` | | 文档写作规范(编码/链接/登记) | `docs/文档规范.md` | | 工具路径环境变量(换机) | `docs/环境与部署/本地工具路径环境变量方案.md` | diff --git a/docs/问题记录/GD5F2GQ5UE_Trap_Records.md b/docs/问题记录/GD5F2GQ5UE_Trap_Records.md index 23703305..c78ce423 100644 --- a/docs/问题记录/GD5F2GQ5UE_Trap_Records.md +++ b/docs/问题记录/GD5F2GQ5UE_Trap_Records.md @@ -231,3 +231,70 @@ if (ok) { - 坏块表这类**持久化状态**,扫描失败时必须"放弃持久化"而非"照常保存"—— 保护既有好副本比刷新更重要; - 使能类配置(ECC)必须**回读校验**,不能只信写命令的返回。 + +## Trap 07 — dhara 移植层:`is_free` 只看页首 + HAL 无几何校验(已修复,2026-10-15) + +**发现时间**:2026-10-15(`项目审查与优化建议报告.md` N-12 / N-13 复核,登记簿原 N-21) + +**现象 / 机制**(两个独立缺陷,同一层): + +1. `dhara_nand_is_free()` 只读页首 64 B 判全 0xFF。dhara journal 用它论证"检查点组尚未编程" + (`journal.c:405-414` 的前提是**组内最后一页一旦编程必然含非 0xFF 字节**),而 + `cp_free`(`journal.c:422`)与"找下一空闲页"(`journal.c:520`)都直接信这个返回值。 + 一个"页首恰好全 0xFF、后段有数据"的已编程页(刚格式化的卷、稀疏文件块、日志尾部都很常见) + 会被**误判空闲** → dhara 往已编程页**再编程一次** = 位碰撞,journal / map 指向垃圾, + **且不报任何错**。 +2. 六个 HAL 入口(`is_bad` / `mark_bad` / `erase` / `prog` / `read` / `copy`)对页号、块号 + **零上界校验**。越界值(含 dhara 哨兵 `DHARA_PAGE_NONE` = 0xffffffff,`journal.h:46`) + 加完 `FTL_START_BLOCK` 偏移送进 SPI 命令后,被芯片地址位**截断成某个真实页** + → 静默读写、甚至把无辜页的数据 GC 搬走。 + +**与 Trap 03 的关系(本条的核心教训)**:Trap 03 已为**裸驱动字节偏移 API** +(`gd5f2gq5ue_read/write/erase`)补过容量检查,但**dhara 移植层是另一个入口**,同一教训在那儿 +原样重演。边界校验必须**逐层**做——"下层查过了"推不出"上层调用也安全",何况本例下层查的是 +字节偏移、上层传的是页号,两者根本不是同一个量。 + +**修复(2026-10-15)**: + +- `is_free` 改**整页扫描**(栈上 64 B 缓冲分块读满 2048 B;**页首命中即早退**,所以"已编程且 + 页首有数据"的绝大多数页成本与旧实现同价,只有前缀全 0xFF 的页才多读几块)。 +- 新增 `ftl_page_in_range()` / `ftl_block_in_range()`(上界 = `n->num_blocks << n->log2_ppb`), + 六个入口统一守卫。 +- **错误码选择是刻意的**:越界返回 `DHARA_E_CORRUPT_MAP`。**不能**用 `DHARA_E_BAD_BLOCK` + ——`journal.c:904 recover_from` 只对该码执行"标坏块 + 跳块",会把无辜好块标掉; + **不能**用 `DHARA_E_RECOVER`——`map.c:275 try_recover` 只对该码启动回收。其余错误一律 + 原样上抛,不会静默清 map(E-31 教训)。 +- 无错误通道的两个函数取**保守值**:`is_free` 越界返回 0(判非空,最多少用一页)、 + `is_bad` 越界返回 1(判坏,绝不读写)、`mark_bad` 越界直接返回(绝不标无辜好块)。 +- `is_free` 内 `gd5f_page_read` / `gd5f_read_from_cache` 任一失败 → 返回 0 + (Trap 06 同族:读失败**不得**伪装成"空闲")。 + +**验证**: + +- `@build` 0 错 0 警;`@test` PASS,且**台架 journal 恢复点与修复前同轨**——连续四次启动 + `sectors used` 恒 14,094、`Map head` 每轮 +128 单调推进(37,472 → 37,600 → 37,728 → 37,856)、 + `epoch` 稳定 3 → 整页 `is_free` 未改变恢复语义、无双编程、无回退。 +- 主机侧移植层单测 `test/host/nand_port/`(RAM 假芯片 + "旧算法只看页首 64 B"**对照用例**, + 将来有人把 `is_free` 改回去会立刻红)。 + +**经验教训**: + +- **移植层的"廉价近似"会被上游当成精确契约**:dhara 已在注释里论证 `is_free` 必须精确,端口却 + 按"页首大概会有数据"实现。契约类回调不许采样式实现——要嘛精确,要嘛按保守值失败。 +- 擦除态检测(判"空闲")的正确做法是扫全页;性能用**早退快路径**去拿,而不是靠缩小扫描范围赌。 +- 哨兵值(`DHARA_PAGE_NONE`)在 C 里就是个普通整数,**下层不校验就一定会被当地址用**。 + +**遗留与代价(2026-10-15 主机侧单测落地时复核提出,未修)**: + +1. **`is_free` 读失败返 0 的方向性代价**:比"误判空闲 → 再编程"安全(dhara 会去读 checkpage, + 被 magic / CRC 门挡住),但一次**瞬态 SPI 毛刺**就可能让整次 mount 失败——旧实现反而会被 + "页首恰好读到 0xFF"的快路径掩盖过去。这是**刻意选择**:宁可挂载失败,不可误判空闲 + (E-31 纪律:绝不拿销毁数据换可用性)。排障时记住"挂载失败 + 有 SPI 错误"先看这里。 +2. **空白卷首启成本**:dhara resume 扫到的每个检查点页都是全 0xFF → 每页要 32 次列读 + (旧实现 1 次)。量级仍小(O(log N) × ppc),但**首次挂载(新格式化卷)会明显变慢**。 + 台架若观察到该现象,根因在此,**不是回归**。 +3. **守卫上界依赖硬编码 `log2_ppb = 6`**:`ftl_page_in_range()` 的上界 = + `n->num_blocks << n->log2_ppb`,而这两个 `log2_*` 在 `disk_initialize` 里是字面量。 + 将来换 128 页/块的芯片,守卫会**静默算错**(偏大 = 越界守卫失效,正是它要防的那类)。 + → 待办:加编译期断言 `GD5F_PAGES_PER_BLOCK == (1u << 6)`(本仓 `gd5f2gq5ue.h` 已有 + "负数组 typedef"式契约断言先例,照抄即可)。 diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index 7f96b7da..06611b03 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -25,27 +25,29 @@ |------|------|-----------|-----------------| | 项目结构 | **A−** | 六层分层真实可执行、命名一致、门禁到位;反向依赖控制在 4 处 | `NETCFG`/`UI` 语义层错位(S-1)、4 处裸 `extern`(S-2)、`rs485_task.c` 仍在注册白名单里等 B2 | | 文档分布 | **A−** | 工程文档 **87 篇 / 30,781 行**(2026-10-15 随 `错误码传播审计.md` 删除而 −1 篇 / −635 行),`docs/README.md` 索引 + `docs/文档规范.md` + 三档一致性门禁全部落地,`check_doc_links --all` = 0 失效 | **D-1**:`docs/` 占仓库 **71.8%**、手册图片 **36.3 MB**、`.git` 已 **235 MB**(已决策暂不处理,登记观察) | -| 模块设计 | **A−** | 看门狗(IWDG + 心跳位图 + 4 个 Fault 现场 + `.noinit`)、就绪屏障、卷锁、FTP 鉴权、`Error_Handler` 自恢复、ADC 全链均已落地 | **N-12/N-13** dhara 移植两处缺陷(数据丢失风险)、**N-16** 日志逐条开关、驱动层无统一错误码约定(S-5)、RS485 无协议栈(随 B2 暂缓) | -| 内存使用 | **B+** | 堆迁 CCM + 栈/堆三面观测常开 + `mem-budget` 92% 门禁 + 预算台账与趋势基线;**R-9 已落地(2026-10-15)→ SRAM1 由 74.9% 降到 56.1%(余 57,504 B),不再是卡点** | **内存优化簇已整簇暂缓**(用户裁定 2026-10-15):R-2 后半(待迁 29,570 B → CCM,只差缩堆 56→32 KB)与 R-3 / R-4 / R-6 不再排期,理由与重开条件见 §5.3;**当前真正的约束是 CCM 只剩 7,904 B**——加任务 / 抬栈 / 加堆会先在这里撞墙,届时按 §5.3 重开;MicroLIB 512 B C 堆仍无机械检查(§5.5) | -| Flash 使用 | **A−** | F-1/F-2/F-3 落地省 64,576 B → 占用 **22.0%(231,084 B)** | F-4/F-5/F-6 ≈10 KB + 编译时长(§5.4) | +| 模块设计 | **A−** | 看门狗(IWDG + 心跳位图 + 4 个 Fault 现场 + `.noinit`)、就绪屏障、卷锁、FTP 鉴权、`Error_Handler` 自恢复、ADC 全链均已落地;**原列的 dhara 移植层两处缺陷(N-12/N-13)与日志逐条开关(N-16)已于 2026-10-15 修复** → `GD5F2GQ5UE_Trap_Records.md` Trap 07、`../架构设计/STM32F4-Base存储架构说明.md` §5.3、`../子系统设计/运行与自检记录设计说明.md` §4.4/§4.5 | 驱动层无统一错误码约定(S-5)、RS485 无协议栈(随 B2 暂缓) | +| 内存使用 | **B+** | 堆迁 CCM + 栈/堆三面观测常开 + `mem-budget` 92% 门禁 + 预算台账与趋势基线;**R-9 已落地(2026-10-15)→ SRAM1 由 74.9% 降到 56.1%;同日移植层与日志修复 +1.6 KB → 现 **57.4%(余 55,840 B)**,不再是卡点** | **内存优化簇已整簇暂缓**(用户裁定 2026-10-15):R-2 后半(待迁 29,570 B → CCM,只差缩堆 56→32 KB)与 R-3 / R-4 / R-6 不再排期,理由与重开条件见 §5.3;**当前真正的约束是 CCM 只剩 7,904 B**——加任务 / 抬栈 / 加堆会先在这里撞墙,届时按 §5.3 重开;MicroLIB 512 B C 堆仍无机械检查(§5.5) | +| Flash 使用 | **A−** | F-1/F-2/F-3 落地省 64,576 B → 占用 **22.2%(232,280 B)** | F-4/F-5/F-6 ≈10 KB + 编译时长(§5.4) | | UI 标准化 | **B** | golden 回归已建(`simulator/tests/` 四脚本,含池水位门禁) | 无令牌层 / 无组件库 / 无页注册表(手写 `case` 仍在)、**27 页**未落地、golden 与字符集差集未挂门禁(§6) | -### 0.2 未闭环问题(Top 2,2026-10-15) +### 0.2 未闭环问题(2026-10-15 复核) -| # | 编号 | 问题 | 影响 | 现状 | -|---|------|------|------|------| -| 1 | **N-12 / N-13**(登记簿 **N-21**,W2) | `dhara_nand_is_free()` 只查页首 64 B(`nand_ftl.c:152-179`);`dhara_nand_copy()` 不处理 `DHARA_NO_PAGE` 哨兵(`:204-216`) | 映射恢复误判 → **数据丢失风险**(低概率、后果重);哨兵页号被当真实页 → 未定义行为 | ❌ 两处原样;至今只能靠人工读代码发现(主机侧无 dhara 移植层测试) | -| 2 | **N-16**(登记簿 **N-24**,W4) | 日志每条记录一次 `f_open + f_write + f_close`(`sys_log_mcu.c:124-141`) | 每条日志 2–4 次 NAND 页读写;UI 翻记录逐条 open/seek/close | ❌ 未办(仅 `FIL` static 化省 528 B 栈) | +> ✅ **原 Top 2 已全部修复并销项(2026-10-15)**: +> ① **N-12 / N-13**(登记簿 N-21,W2)——`dhara_nand_is_free()` 改整页精确扫描、HAL 六个入口加几何上界守卫; +> ② **N-16**(登记簿 N-24,W4)——日志写侧一次排空合并成"每文件一次 open/write/close"、读侧加 8 条滑动窗口。 +> 实施记录与硬约束 → `../架构设计/STM32F4-Base存储架构说明.md` §5.3(移植层两条硬约束)、`../子系统设计/运行与自检记录设计说明.md` §4.4 / §4.5(写批量化 + 读窗口);验证链见文末变更记录(含台架 journal 恢复点同轨对照)。 + +剩余未闭环项**均非紧急级**(W4 结构债 / 评估簇 / 已裁定暂缓的内存优化簇),逐条见 §0.4;推进顺序的唯一权威仍是 `../任务规划/README.md` §2。 ⚠ 另有两条**不在上表但需知晓**:`.git` 已膨胀到 **235 MB**(D-1,§3.1,2026-10-11 决策"暂不处理、登记观察");`rs485_task.c` 仍不在两端构建清单里——**这是 E-59 门禁下的刻意缺口**(随 B2 暂缓),已登记在 `check_registration.py` 的 `KNOWN_GAPS`,不是漏注册。 -⏸ **内存优化簇已整簇暂缓**(用户裁定 2026-10-15,原 Top 2 的 R-2 剩余已移出):R-2 后半(把 LVGL 池 + UI 两份缓冲 ≈29.6 KB 搬进 CCM)与 R-3 / R-4 / R-6 不再排期。**已落地的 R-9 与治理面保留常开**(`mem-budget` 92% 门禁、预算台账、趋势基线、栈/堆观测)。理由与重开条件 → §5.3;登记簿 N-27 已标暂缓。 +⏸ **内存优化簇已整簇暂缓**(用户裁定 2026-10-15,未闭环清单里的 R-2 剩余已移出):R-2 后半(把 LVGL 池 + UI 两份缓冲 ≈29.6 KB 搬进 CCM)与 R-3 / R-4 / R-6 不再排期。**已落地的 R-9 与治理面保留常开**(`mem-budget` 92% 门禁、预算台账、趋势基线、栈/堆观测)。理由与重开条件 → §5.3;登记簿 N-27 已标暂缓。 ### 0.3 两条主线建议 > **推进顺序的唯一权威是 [`../任务规划/README.md`](../任务规划/README.md) §2**;本节只是体系评审视角的两条结构性建议,不另立顺序。 -1. **内存这条线到此为止**(用户裁定 2026-10-15,**覆盖本报告的原建议**):R-9 已把 LVGL 池 40→16 KB、SRAM1 降到 **56.1%(余 57,504 B)**,够用了。原建议的"缩堆腾 CCM → 把 LVGL 池与 UI 帧缓冲(现 ≈29.6 KB)搬进 CCM 作为后续功能预算来源"**整簇暂缓,不再作为主线**——没有功能在等这块预算,而搬迁要动 scatter、要逐条过 DMA 可达性清单、缩堆还得上板复测,收益不抵风险与工时。**保留的是治理面**(92% 门禁 + 台账 + 趋势 + 栈/堆观测):它会在真正吃紧时自动报警,届时再按 §5.3 的重开条件评估,而不是现在预先腾预算。 +1. **内存这条线到此为止**(用户裁定 2026-10-15,**覆盖本报告的原建议**):R-9 已把 LVGL 池 40→16 KB、SRAM1 降到 **57.4%(余 55,840 B)**,够用了。原建议的"缩堆腾 CCM → 把 LVGL 池与 UI 帧缓冲(现 ≈29.6 KB)搬进 CCM 作为后续功能预算来源"**整簇暂缓,不再作为主线**——没有功能在等这块预算,而搬迁要动 scatter、要逐条过 DMA 可达性清单、缩堆还得上板复测,收益不抵风险与工时。**保留的是治理面**(92% 门禁 + 台账 + 趋势 + 栈/堆观测):它会在真正吃紧时自动报警,届时再按 §5.3 的重开条件评估,而不是现在预先腾预算。 2. **把 UI 从"每页手写"变"声明 + 原型库"**:令牌层 → 原型库 → 生成器补全注册 → golden 入门禁,使新增一页的成本从 8 处手工改动降到 1 处声明(§6)。 --- @@ -57,7 +59,6 @@ | 类别 | 编号 | 未闭环内容 | 下一步 | |------|------|-----------|--------| -| **数据正确性** | **N-12 / N-13** ❌(N-21,W2) | `is_free` 只查页首 64 B;`copy` 不处理 `DHARA_NO_PAGE` | 整页(或首/中/尾)扫描 + 返回 `DHARA_E_UNSUPPORTED` + **必须配主机侧移植层单测** | | 内存 RAM | **R-2 剩余** ⏸ **暂缓**(N-27,原 W3) | 待迁体量已由 54,146 B 降到 **29,570 B**(R-9 落地后);CCM 只剩 7,904 B | **用户裁定 2026-10-15:暂不做**——没有功能在等这块预算;重开条件 → §5.3 | | 内存 RAM | R-3 / R-4 / R-6 ⏸ **暂缓**(N-27,原 W3) | 页缓冲未合并 / UI 帧 4 副本 / 栈仍动态 | **同上裁定,暂不做**。本簇已落地的只有 R-9(2026-10-15,SRAM1 74.9%→56.1%,见 §5.3);**R-5 已判定不可行**(§5.3) | | 内存 RAM | N-14 🟡(N-22,W4) | 写回缓存无空闲/掉电强制 flush(整页写与换页已 flush) | 空闲 N ms 由 defaultTask flush | @@ -108,8 +109,8 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 术语 | 定义 | |------|------| | 自研代码 | `App/`、`Drivers/BSP/`(不含 `Lib/`、`Middlewares/`、HAL/CMSIS)、`test/`;不含 CubeMX 生成的 `Src/`、`Inc/` | -| Flash 占用 | `Code + RO-data` = `ER_IROM1` **231,084 B(22.0% of 1 MB)**;`ROM` 再加 `RW-data` 初始化表 = 232,196 B | -| RAM 占用 | `RW-data + ZI-data`,**跨两个区域** = SRAM1 `RW_IRAM1` **73,568 / 131,072(56.1%)** + CCM `RW_CCM` **57,376 / 65,280(87.9%)** + `UNINIT_CCM` 48 B(`.noinit` 崩溃现场保留区)。⚠ 原报告"CCM 不在 scatter 内计 0"的口径已不成立 | +| Flash 占用 | `Code + RO-data` = `ER_IROM1` **232,280 B(22.2% of 1 MB)**;`ROM` 再加 `RW-data` 初始化表 = 233,416 B | +| RAM 占用 | `RW-data + ZI-data`,**跨两个区域** = SRAM1 `RW_IRAM1` **75,232 / 131,072(57.4%)** + CCM `RW_CCM` **57,376 / 65,280(87.9%)** + `UNINIT_CCM` 48 B(`.noinit` 崩溃现场保留区)。⚠ 原报告"CCM 不在 scatter 内计 0"的口径已不成立 | | "死代码" | 该对象在镜像中有码,但其功能被工程配置(如 `LV_COLOR_DEPTH 1`)或调用点扫描(零调用者)证明不可达 | > **内存数字的权威源**:区域水位与子系统配额由 `../内存管理/RAM预算台账.md`(受管区,`ram_baseline.py --write` 生成)持有,趋势在 `scripts/ram_baseline.tsv`。本报告只在支撑结论处引用,不与之竞争事实源。 @@ -272,8 +273,6 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 编号 | 问题 | 证据 | 影响/建议 | |------|------|------|-----------| -| **N-12** | `dhara_nand_is_free()` 只检查页首 64 B | `nand_ftl.c:152-179`(仍 `uint8_t buf[64]`) | dhara 依赖该判定做映射恢复;页首 64 B 全 FF 但后半页有数据时会误判为空闲 → 数据丢失风险(低概率但后果重)。建议整页扫描或至少首/中/尾三处采样 | -| **N-13** | `dhara_nand_copy()` 未处理 `DHARA_NO_PAGE` 语义(dst 为哨兵值时仍执行"读+编程") | `nand_ftl.c:204-216`(read→prog 直传) | dhara map 在回收/擦除路径会用 `DHARA_NO_PAGE` 试探;当前实现会把它当作真实页号 → 未定义行为。建议:`if (dst == DHARA_NO_PAGE) { dhara_set_error(err, DHARA_E_UNSUPPORTED); return -1; }` | | N-14 | 写回缓存 `s_cache_buf/s_cache_dirty` 无掉电保护策略:`CTRL_SYNC` 已实现但**没有空闲时强制 flush** | `nand_ftl.c:418` | 掉电窗口 = 最后一次未跨页的写。已改善:整页写立即 flush、换页时 flush、`CTRL_SYNC` 走 flush + `dhara_map_sync`;仍无空闲定时 flush → 建议 defaultTask 空闲 N ms flush | | N-15 | 页缓冲重复:`nand_ftl` 3×2,048(`s_page_buf/s_cache_buf/s_copy_buf`) | 对象 ZI:nand_ftl 6,216 | 与 `s_copy_buf` 复用可省 2 KB(§5.3 R-3)。🟡 `gd5f` 的 `s_bbt_page_buf` 已由 2,048 B 收紧为 272 B | | — | `disk_ioctl GET_BLOCK_SIZE = 1` | `nand_ftl.c:442-444` | FatFs 的擦除块提示失效(FAT32 对齐优化拿不到信息);建议返回 64 页(=1 物理块) | @@ -296,7 +295,6 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 编号 | 问题 | 证据 | 建议 | |------|------|------|------| -| **N-16** | **每条记录一次 `f_open + f_write + f_close`**,读侧同样每条 `f_open/f_lseek/f_close` | `sys_log_mcu.c:124-141`(失败重建重试一次)、`wdev.log` 同模式 `:486-489` | 写侧:常驻 `FIL`(仅 defaultTask 持有)+ 批量 flush;读侧:打开一次 + 缓存目录偏移(UI 已有 `s_cache_valid/s_last_rec` 雏形,可升级为页缓存)。🟡 唯一改善 = `FIL` 已 static 化(省 528 B 栈) | | P2-10 | 容量轮转 `f_unlink` 与 UI 读并发 | | 轮转只在写者、读者持版本号;卷锁覆盖面评估 → 登记簿 S-5 | ### 4.6 UI 子系统 @@ -316,7 +314,7 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap ### 4.8 测试体系 -**设计良好**:`test/board/test_config.h` 的 `TEST_SUITE_*` 互斥(`#error` 保护)、CH395F 硬件层/NET 层 Phase 互斥、`@test` 一键闭环(烧录→wait-idle→标记校验)、模拟器 `--cfgtest/--logtest`、主机侧单测 15 个钩子(含 `sha256` / `ftp_auth` / `vol_lock` / 采集算法族)。 +**设计良好**:`test/board/test_config.h` 的 `TEST_SUITE_*` 互斥(`#error` 保护)、CH395F 硬件层/NET 层 Phase 互斥、`@test` 一键闭环(烧录→wait-idle→标记校验)、模拟器 `--cfgtest/--logtest`、主机侧单测 16 个钩子(含 `sha256` / `ftp_auth` / `vol_lock` / 采集算法族 / **`nand_port` dhara 移植层**)。 > ⚠ 仍缺:**覆盖率概念**、**主机侧对 `dhara` 移植层的测试**(N-12/N-13 这类缺陷至今只能靠人工读代码发现)。 @@ -334,13 +332,13 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 指标 | 数值 | 上限 | 占用 | |------|------|------|------| -| `Code + RO-data`(`ER_IROM1`) | 231,084 B | 1 MB Flash | **22.0%** | -| `ROM`(`Code + RO + RW` 初始化表) | 232,196 B | 1 MB | 22.1% | -| **SRAM1(0x20000000,128 KB)** `RW_IRAM1` | **73,568 B** | 131,072 B | **56.1%(余 57,504 B)** | +| `Code + RO-data`(`ER_IROM1`) | 232,280 B | 1 MB Flash | **22.2%** | +| `ROM`(`Code + RO + RW` 初始化表) | 233,416 B | 1 MB | 22.3% | +| **SRAM1(0x20000000,128 KB)** `RW_IRAM1` | **75,232 B** | 131,072 B | **57.4%(余 55,840 B)** | | **CCM(0x10000000,64 KB)** `RW_CCM` | **57,376 B** | 65,280 B | **87.9%(余 7,904 B)** | | `UNINIT_CCM`(`.noinit` 崩溃现场) | 48 B | 256 B | 18.8% | -> **R-9 已落地(2026-10-15)**:`LV_MEM_SIZE` 40→16 KB → SRAM1 由 98,144 B / **74.9%** 降到 **73,568 B / 56.1%**(−24,576 B,与池缩减量逐字节吻合)。SRAM1 首次从"最紧的一块"变成**余 57.5 KB**;**当前瓶颈换到 CCM(87.9%,只剩 7,904 B)**。⏸ 该簇后续动作(缩堆 → 迁 CCM)已按用户裁定 2026-10-15 **整簇暂缓**,见 §5.3。 +> **R-9 已落地(2026-10-15)**:`LV_MEM_SIZE` 40→16 KB → SRAM1 由 98,144 B / **74.9%** 降到 **73,568 B / 56.1%**(−24,576 B,与池缩减量逐字节吻合)。SRAM1 首次从"最紧的一块"变成**余 57.5 KB**(现 75,232 B / 57.4%、余 55,840 B,增量来自同日移植层守卫与日志缓冲 +1,664 B);**当前瓶颈换到 CCM(87.9%,只剩 7,904 B)**。⏸ 该簇后续动作(缩堆 → 迁 CCM)已按用户裁定 2026-10-15 **整簇暂缓**,见 §5.3。 > **权威源**:区域水位与子系统配额 → `../内存管理/RAM预算台账.md`(受管区);趋势 → `scripts/ram_baseline.tsv`(`ram_baseline.py --trend` 高亮 RW+ZI 增长 >1 KB 的对象)。本节只列支撑结论所需的数。 @@ -375,13 +373,13 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap | 其余(`App/` 应用与驱动自研、startup、C 库、填充) | ≈45,497 | ≈19.7% | | **合计** | **231,084** | 100% | -> 归类按 `.map` 组件表对象名前缀,合计与区域总量可差几十字节(链接器填充)。前 7 行逐字节与 2026-10-14 那次构建相同;并行未提交改动(`cfg_file` / `sd2506`)的 +48 B 落在"其余"。 +> 归类按 `.map` 组件表对象名前缀,合计与区域总量可差几十字节(链接器填充)。前 7 行逐字节与 2026-10-14 那次构建相同;并行未提交改动(`cfg_file` / `sd2506`)的 +48 B 落在"其余"。⚠ **本表是那次构建的构成快照**(合计 231,084 B);当前镜像 **232,280 B / 22.2%**,增量 +1,192 B `Code` 来自同日 dhara 移植层守卫与日志批量化(登记簿原 N-21 / N-24),**不改变"LVGL 是最大单一子系统"的结论**。 **要点**:LVGL 的绝对量自 F-1/F-2/F-3 裁剪后**未再增长**(87,706 B),但仍是镜像最大单一子系统 → §5.4 的剩余裁剪(≈10 KB)与 F-5 的编译时长收益依旧成立。 ### 5.2 结构性诊断(三条) -1. **瓶颈已从 SRAM1 换到 CCM**:R-9 落地后 SRAM1 只剩 56.1%(**余 57,504 B**),而 **CCM 被堆占掉 87.9%(只剩 7,904 B)**,待迁的 LVGL 池 + UI 两份缓冲 = **29,570 B**。 +1. **瓶颈已从 SRAM1 换到 CCM**:R-9 落地后 SRAM1 只剩 56.1%(**现 57.4%、余 55,840 B**),而 **CCM 被堆占掉 87.9%(只剩 7,904 B)**,待迁的 LVGL 池 + UI 两份缓冲 = **29,570 B**。 → R-2 的剩余部分**只差"缩堆"这一步**(56→32 KB,`../内存管理/RAM使用分析与优化方案.md` §2.2;⚠ 2026-10-12 B5 是反向扩容,缩回前必须重跑 `stack_report.py` + 上板 `min_ever` 复测);顺序不可颠倒(`../内存管理/RAM预算台账.md` §3)。 ⏸ **本簇已按用户裁定 2026-10-15 整簇暂缓**(§5.3)——这条诊断保留作将来重开时的现状基线,**不代表现在要动**。 2. **任务栈全部动态取自 FreeRTOS 堆**:`osThreadAttr_t` 只设 `stack_size` 不设 `stack_mem`(`Src/freertos.c`)→ 8 条产品任务栈合计 **9,792 words = 39,168 B**(另 testTask 2,048 words 仅测试构建)连同 idle/timer 栈、TCB、IPC 全部从堆里扣,**留给动态分配的实际余量很紧**(堆 56 KB 是 2026-10-12 B5 扩容后的值)。 @@ -392,7 +390,7 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap ### 5.3 RAM 优化路线(收益/风险/优先级) > ⏸ **本表除已落地的 R-9 外整簇暂缓(用户裁定 2026-10-15)**,理由三条: -> ① **没有功能在等这块预算**——SRAM1 已降到 56.1%(余 57,504 B)、Flash 22.0%,离 `mem-budget` 的 92% 红线很远; +> ① **没有功能在等这块预算**——SRAM1 已降到 57.4%(余 55,840 B)、Flash 22.2%,离 `mem-budget` 的 92% 红线很远; > ② **搬迁本身有风险和工时**——R-2 要动 scatter 并逐条过 DMA 可达性清单(规则 R2/R3,违例是**静默传错**不是报错),R-4 要重排 sink 与 LVGL 刷新的生命周期,R-6 静态化后栈溢出更难发现,缩堆还得上板复测 `min_ever`; > ③ **收益与代价不对称**——腾出的 CCM 是给"以后可能有的功能",代价是现在动存储与显示两条主链。 > @@ -419,7 +417,7 @@ Select-String -Path 'MDK-ARM\STM32F407-Demo\STM32F407-Demo.map' -Pattern '__heap ### 5.4 Flash 优化(未闭环:F-4 / F-5 / F-6) -> **F-1 / F-2 / F-3 已落地并销项(2026-10-11)**:两份 `lv_conf.h` 对齐为同一特性子集 → **实测省 64,576 B**,Flash 26.3% → 20.1%(现 22.0%,增量来自 B4/B5 功能代码)。完整记录见 `../内存管理/RAM使用分析与优化方案.md` §2.6.4 与 `../UI设计/UI_设计架构.md` §1.4 第 10 条。 +> **F-1 / F-2 / F-3 已落地并销项(2026-10-11)**:两份 `lv_conf.h` 对齐为同一特性子集 → **实测省 64,576 B**,Flash 26.3% → 20.1%(现 22.2%,增量来自 B4/B5 功能代码)。完整记录见 `../内存管理/RAM使用分析与优化方案.md` §2.6.4 与 `../UI设计/UI_设计架构.md` §1.4 第 10 条。 | 编号 | 措施 | 可省 Flash | 依据 | 状态 | |------|------|-----------|------|------| @@ -550,7 +548,7 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea |------|------| | 页面**不得**新增静态大缓冲;文本缓冲统一用 `UI_BUF_STRLEN` | 现有 `s_ts/s_desc` 已是范式 | | 每页**只用** `lv_style_const_prop_t`,禁止运行期 `lv_style_init` | 已验证动态样式每次泄漏 88 B | -| 优先自绘(`lv_draw_*` + `lv_text_get_width`),新控件需评审(会破坏 §5.4 F-2 **已落地**的裁剪,且须两端同步) | LVGL 现占镜像 **38.0%**(87,706 / 231,084 B) | +| 优先自绘(`lv_draw_*` + `lv_text_get_width`),新控件需评审(会破坏 §5.4 F-2 **已落地**的裁剪,且须两端同步) | LVGL 现占镜像 **37.8%**(87,706 / 232,280 B) | | 帧缓冲任何改动必须遵守 R-4(副本数 ≤ 2) | 当前 4 副本 12.8 KB | | `LVGL 池`用量必须经 `lv_mem_monitor` / 池水位门禁实测后才允许扩大 | **16 KB**(R-9 于 2026-10-15 由 40 KB 降下;实测峰值 6,568 B = 池的 40%,余 ≈9.8 KB) | @@ -570,13 +568,12 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea ## 7. 统一改进路线图 -> **顺序的唯一权威 = [`../任务规划/README.md`](../任务规划/README.md) §2**(W1 采集保护收尾 → W2 数据正确性 → ~~W3 预算与内存~~ **⏸ 已整簇暂缓** → W4 结构债 → W5 验证常态 → W6 UI 扩展 → W7 仓库决策;B2 RS-485 暂缓)。本节只保留"本报告条目 → 波次"的映射,不另立顺序。 +> **顺序的唯一权威 = [`../任务规划/README.md`](../任务规划/README.md) §2**(W1 采集保护收尾 → ~~W2 数据正确性~~ **✅ 已清空销项 2026-10-15** → ~~W3 预算与内存~~ **⏸ 已整簇暂缓** → W4 结构债 → W5 验证常态 → W6 UI 扩展 → W7 仓库决策;B2 RS-485 暂缓)。本节只保留"本报告条目 → 波次"的映射,不另立顺序。 | 波次 | 本报告条目 | 验收要点 | |------|-----------|---------| -| **W2 数据正确性** | N-12 / N-13(登记簿 N-21) | 整页扫描 + `DHARA_E_UNSUPPORTED` + **主机侧 dhara 移植层单测**(一并补覆盖率缺口) | | ~~**W3 预算与内存**~~ ⏸ | **整簇暂缓(用户裁定 2026-10-15)**:R-9 ✅ 已落地不回退;R-2 后半 / R-3 / R-4 / R-6 不再排期 | 暂缓期间**无验收动作**;`mem-budget` 92% 门禁与台账、趋势、`stack_report.py` / `ram_report.py` 观测**照常跑**(它们是判断何时重开的输入)→ 重开条件见 §5.3 | -| **W4 结构债** | N-16、N-14、F-4/F-5/F-6、S-1/S-2/S-4/S-5;`rs485_task.c` 随 B2 出注册白名单 | `--strict` 默认化;`bsp_err_t` 进规范 | +| **W4 结构债** | N-14、F-4/F-5/F-6、S-1/S-2/S-4/S-5;`rs485_task.c` 随 B2 出注册白名单 | `--strict` 默认化;`bsp_err_t` 进规范 | | **W5 验证常态** | P1-10 收尾(构建实测链接 + `@test` 闭环) | 逢窗口顺手做 | | **W6 UI 扩展** | U4 残余 → U1 → U2/U3 → U5(27 页) | 新增一页 ≤30 行;**本轮不排期** | | **W7 仓库决策** | D-1(登记簿 N-29) | **已决策暂不处理**;推送/协作前重评 | @@ -598,7 +595,7 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea | 结论 | 证据(2026-10-15 `.map`) | |------|---------------------| -| SRAM1 56.1%、余 57,504 B;CCM 87.9%、余 7,904 B | map `Execution Region RW_IRAM1 Size: 0x00011f60, Max: 0x00020000` / `RW_CCM Size: 0x0000e020, Max: 0x0000ff00`;`ram_baseline.tsv` 末行 | +| SRAM1 57.4%、余 55,840 B;CCM 87.9%、余 7,904 B | map `Execution Region RW_IRAM1 Size: 0x00012600, Max: 0x00020000` / `RW_CCM Size: 0x0000e020, Max: 0x0000ff00`;`ram_baseline.tsv` 末行 | | MicroLIB C 堆 = 512 B | `MDK-ARM/startup_stm32f407xx.s` `Heap_Size EQU 0x200`;map `Removing startup_stm32f407xx.o(HEAP), (512 bytes)`(未被引用 → 整段移除) | | 任务栈全部动态分配 | `Src/freertos.c` 的 `osThreadAttr_t` 无 `.stack_mem`;8 条产品任务栈合计 9,792 words(R-6 未办) | | DMA 可达性三层防线(原 N-11 的处置) | 编译期窗口断言 `gd5f2gq5ue.h:96-107`(负数组 typedef)→ 启动期自检 `nand_ftl.c:241-243` → 运行时守卫 `gd5f_dma_buf_ok()`(`gd5f2gq5ue.c:121-139`);规则族 R1~R4 → `../内存管理/内存内容分配策略.md` §4;负路径用例 TC-GD5F-704/705 | @@ -644,6 +641,7 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea | **2026-10-15** | **修 `netcfg_save` 假失败:回归改为自洽运行** | 上一条那个"唯一差异"查到真因,**不是代码回归**:`--cfgtest` 的 E-01 场景 2 伪造一份 4000 B 纯注释配置档并断言"拒写后原文件不动",**连通过路径也不删除它**;留在工作目录后每次运行都被加载 → 外科手术保存要在 4000 B 注释后补写受管段 → 输出必超 `CFG_FILE_MAX_LEN` → 引擎**按设计拒写** → 保存行显示 `ERR`。修三处:`simulator/main.c` 给 `--cfgtest` 加外壳(无论成败一律删除伪造档)、`check_menu_rows.py` 与 `check_content_pages.py` 改在**空临时目录**里跑(`cwd=tmp`)、脚本里已作废的 `sim_netcfg.bin` 兼容清理改为只提示不删除。修后 **11/11 页 + 往返池水位 5,448 B 全过,未刷任何黄金参考**(参考本来就是对的)。排查口径 → `../UI设计/UI_自动化测试流程.md` §五 | | **2026-10-15** | **补漏:§0.1 六维评分与口径/附录数字追平 R-9 后状态** | 前面那条只改了 §0.2/§0.3/§0.4/§5.x,**漏掉 §0.1、§1 术语表、附录 A 证据表**(用户指出):① 内存使用行仍写"54,146 B 仍在 SRAM1 / SRAM1 74.9% / 须先 R-9"→ 改为 SRAM1 **56.1%(余 57,504 B)**、瓶颈收敛到 CCM 单点、只差"缩堆"一步,评级 **B → B+**(理由:SRAM1 由卡点变富裕,治理三件套与观测齐备;扣分项换成 CCM 单点 + C 堆无机械检查)② Flash `231,036` → **`231,084`**(§0.1 / §1 术语表 / §6.4 三处),并修掉术语表里 `ROM = 231,324 B` 这个**算术错**(`Code + RO + RW` 实为 **232,196 B**)③ RAM 占用定义 `98,144 / 74.9%` → **`73,568 / 56.1%`** ④ 附录 A 证据行改 `RW_IRAM1 Size: 0x00011f60`、余量 57,504 B ⑤ 文档分布 **88 篇 / 31,416 行 → 87 篇 / 30,781 行**、§3.1 体量表受控文件 **2,466 / 79.3 MB → 2,465 / 79.2 MB**(均随 `错误码传播审计.md` 删除,−1 篇 / −635 行;同表 `docs/` **56.9 MB**、手册图片 **703 张 / 36.3 MB** 复核未变)⑥ §0.1/§0.2/§0.4 与附录的复核日期统一标 **2026-10-15**,最近复核行的 `HEAD` 改指 `a7f5265` 并注明"后续文档与模拟器侧提交不影响固件",避免每提交一次就过期 | | **2026-10-15** | **裁定:内存优化簇整簇暂缓** | 用户判定"把 LVGL 池与 UI 帧缓冲(现 ≈29.6 KB)搬进 CCM 作为后续功能预算来源"这个决定不合适,内存优化的事先放一放。据此:§0.2 **Top 3 → Top 2**(R-2 剩余移出名列,表下加一条 ⏸ 说明);§0.3 主线一由"把内存从卡死变富裕"改写为"**这条线到此为止**";§0.4 两条内存条目 🟡/❌ → **⏸ 暂缓**;§5.1 结论段与 §5.2 诊断 1 补"不代表现在要动";**§5.3 加暂缓横幅**(三条理由 + 保留常开的治理面 + 三条重开条件),R-2 / R-3 / R-4 / R-6 优先级列一律 ~~P1/P2~~ → **⏸ 暂缓**。**已落地的 R-9 不回退**(−24,576 B 是净收益且已台架验收);`mem-budget` 92% 门禁、预算台账、趋势基线、栈/堆观测**全部保留常开**——它们是传感器,不是优化动作。同步 `../任务规划/登记簿.md` N-27、`../任务规划/README.md` W3、`../内存管理/RAM预算台账.md` §3、`../内存管理/RAM使用分析与优化方案.md` 阶段 1.2 / 2 | +| **2026-10-15** | **执行本报告条目:N-12 / N-13 / N-16 落地并销项** | **N-12**:`dhara_nand_is_free()` 改整页扫描(栈上 64 B 分块、页首命中即早退,成本与旧实现同价)。**N-13:本报告原建议经核对不成立,已按实际契约改修**——本仓裁剪版 dhara 既没有 `DHARA_NO_PAGE`(哨兵实为 `DHARA_PAGE_NONE`,`journal.h:46`)也没有 `DHARA_E_UNSUPPORTED`(`error.h:20-31` 枚举里没有,照抄直接编译失败),且 `map.c` 三处 `dhara_journal_copy` 调用点各自已挡掉哨兵(`map.c:260`、`:434`+`:440`、`:242`);真风险是**移植层零上界校验**(越界页号被地址位截断成真实页 → 静默读写错页),故改为六个 HAL 入口统一几何上界守卫 + `DHARA_E_CORRUPT_MAP`(不能用 `BAD_BLOCK`:`journal.c:904` 会据此标坏块;不能用 `RECOVER`:`map.c:275` 会据此启动回收)。**N-16**:写侧一次排空合并成"每文件一次 open/write/close"(≤8 条 = 512 B 静态缓冲;**掉电语义不变**——批次仍在同一次 drain 内 close,close 回写目录项这个不同 LPN 会逼出 FTL 单页写回缓存;失败尾部回投队列,条目不丢);读侧 8 条滑动窗口 + 写者代数失效(API 与 UI 零改动)。**刻意不批量**:`sys_log_evidence()` 的 `wdev.log` 仍逐条落盘——取证路径要求立刻进 NAND 并靠 flush 诱导逼出脏页。**验证**:`@build` 0 错 0 警(Code 212,344→**213,536**、ZI 129,880→**131,520**,+1,640 B 全为静态缓冲,不吃栈);`@test` PASS 且**台架 journal 恢复点与修复前同轨**(`sectors used` 恒 14,094、`Map head` 每轮 +128 单调推进、`epoch` 稳定 3 → 整页 `is_free` 未改变恢复语义、无双编程);`stack_report.py` 9 个任务全 PASS(defaultTask 静态最坏链 3,024 B / 余 40%,实测峰值 2,164 B / 余 57%);新增主机侧移植层单测 `test/host/nand_port/`(假芯片 + 旧算法对照用例)。实施记录 → `../架构设计/STM32F4-Base存储架构说明.md` §5.3、`../子系统设计/运行与自检记录设计说明.md` §4.4/§4.5 | | **2026-10-14** | **清理:删除已完成与过时内容** | ① **已修复项按销项删除**(去处见下表):N-02(FTP 鉴权)、N-03c(`Error_Handler` 降级)、**N-11(DMA 可达性三层防线 + 规则族 R1~R4)**、S-3(注册反向校验)、P2-5/P2-6(SD2506 负温度与 WRTC)、P2-8(`NET_RECV_TIMEOUT_MS` 已删)、P2-9(`mkdir` 幂等重试)、§5.5 前三条(内存治理三件套)、交付物清单已建项。② **删除过时层**:2026-09-16 / 2026-10-11 双列基线表与"复核期间未提交改动"口径说明、旧"组合结果预估"表、逐条"2026-10-11 复核"批注、历史变更记录行——内存数字改由 `../内存管理/RAM预算台账.md` 与 `scripts/ram_baseline.tsv` 持有。③ **按 2026-10-14 `.map` 与代码重测**全部保留结论的数字(含 `lftpd_log.c` 已删除并入 `lftpd_auth.c`,N-06 残余只剩 `rs485_task.c` 一条刻意缺口)。④ **修正三处错误**:R-5(`configMAX_PRIORITIES` 56→32)**判定不可行**(CMSIS-RTOS2 `#error`);原 §7 把报告 S-5(驱动统一错误码)与登记簿 S-5(卷锁覆盖面)混为一谈;原 §5.5 第 4 条建议的 `_Static_assert` 形态在 AC5 上取不到链接期地址,已被"窗口断言 + 启动自检 + 运行时守卫"三层取代。⑤ 报告自有编号**不回改、不重排**(`../编号规范.md`) | ### 已销项条目的记录去处 @@ -671,3 +669,5 @@ const ui_page_desc_t ui_page_deadzone = UI_FORM_PAGE("遥测死区", s_items_dea | §5.5 前三条(92% 门禁 / 趋势基线 / 预算台账,登记簿 N-27 前半) | `../内存管理/RAM预算台账.md`(阈值与配额的**唯一权威**)+ `scripts/ram_baseline.py`(`--gate/--update/--trend/--write`)+ `../代码规范/pre-commit使用说明.md`(`mem-budget`) | | F-1 / F-2 / F-3(LVGL 死代码裁剪) | `../内存管理/RAM使用分析与优化方案.md` §2.6.4;`../UI设计/UI_设计架构.md` §1.4 第 10 条(**两份 `lv_conf.h` 必须同一特性子集**);`../UI设计/UI_自动化测试流程.md` 变更记录(池水位基线) | | **R-9(`LV_MEM_SIZE` 40→16 KB,登记簿 N-27 原 W3 的第一步,2026-10-15 已执行;该簇其余部分同日整簇暂缓)** | 取值依据与再降 8 KB 的前置写在 `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 的池注释;实测水位 → `../内存管理/RAM使用分析与优化方案.md` §2.6.2 与 §阶段 1.1(已执行);解锁链第 ① 步 ✅(后续步骤暂缓)→ `../内存管理/RAM预算台账.md` §3;池容量口径 → `../UI设计/UI_设计架构.md` §1.2、`../UI设计/UI_界面制作流程.md` C4 | +| **N-12 / N-13**(dhara 移植层 `is_free` 精度 + HAL 越界守卫,登记簿 **N-21**,2026-10-15 已销项) | `../架构设计/STM32F4-Base存储架构说明.md` §5.3(移植层两条硬约束:整页精确的原因链 + 错误码取舍与"为什么不会静默清卷")+ `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 的 `ftl_page_in_range` / `ftl_block_in_range` / `dhara_nand_is_free` 注释 + 主机侧单测 `test/host/nand_port/`(含"旧算法只看页首 64 B"的对照用例,防止将来被改回去) | +| **N-16**(日志读写放大,登记簿 **N-24**,2026-10-15 已销项) | `../子系统设计/运行与自检记录设计说明.md` §4.4(写侧批量合并 + **掉电语义不变**的论证 + 失败回投不丢条目 + 容量轮转只在慢路径)与 §4.5(读侧 8 条窗口 + 写者代数失效 + API 不变)+ `App/log/sys_log_mcu.c` 的 `s_format_rec` / `s_append_rec` / `s_append_many` / `s_read_raw` 注释 | diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index d5b18078..70c76aa3 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -2,4 +2,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 2026-10-12 209492 18332 1104 154184 227824 21.7 97864 74.7 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=40960;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;console_log.o=4120;ch395f.o=4106;default_task.o=2736;sys_log_mcu.o=1924 2026-10-13 211456 18760 1104 154232 230216 22.0 97912 74.7 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=40960;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4120;default_task.o=2736;sys_log_mcu.o=1924 2026-10-14 212296 18740 1112 154456 231036 22.0 98144 74.9 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=40960;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4120;default_task.o=2736;sys_log_mcu.o=1924 -2026-10-15 212344 18740 1112 129880 231084 22.0 73568 56.1 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4120;default_task.o=2736;sys_log_mcu.o=1924 +2026-10-15 213540 18740 1136 131520 232280 22.2 75232 57.4 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6766;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4120;sys_log_mcu.o=3588;default_task.o=2736 diff --git a/test/host/README.md b/test/host/README.md index acc75113..2c0c45a3 100644 --- a/test/host/README.md +++ b/test/host/README.md @@ -66,14 +66,34 @@ | `time_sync/time_sync_test.c` | 测试单元本体(`#include` 生产源码 + 网络 / 事件 / RTOS 可脚本化桩 + `longjmp` 跳出 `for(;;)` 任务循环) | | `time_sync/stub/` | 桩头文件(`FreeRTOS.h` / `task.h` / `cmsis_os2.h` / `net_socket.h` / `sys_clock.h` / `sys_events.h` / `dbg_log.h`) | | `iec104/run_unit_test.py` | **IEC 60870-5-104 协议栈单测(B4)**:离线编译真实 `App/protocol/iec104/` 三模块 + 数据源(prot_set/prot_elm/phasor/sample_ring),**82** 用例覆盖 CP56 往返 / APCI 半帧·粘帧·坏帧 / STARTDT 握手 / 总召分片+RBT / COV 死区 / k=12 窗满 / w=8 触发 S / t1·t2·t3 / N(R) 倒退判故障 / 遥控·定值两段式与 PN / C_CS / 未知类型·地址错误应答 | +| `nand_port/run_unit_test.py` | **dhara NAND 移植层单测(N-21)**:离线编译真实 `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` + 真实 `Lib/dhara/{journal,map}.c`,以 **RAM 假芯片**驱动,**149** 断言覆盖 is_free 整页扫描(N-12)/ 六个 HAL 入口的越界守卫(N-13)/ 故障注入 / dhara map"写入 → 重新 resume"场景 | +| `nand_port/nand_port_test.c` | 测试单元本体(`#include` 生产源码 + RAM 假芯片;内联一份"只看页首 64 B"的**旧算法对照**,证明断言能咬人) | +| `nand_port/stub/` | 桩头文件(`gd5f2gq5ue.h` 芯片几何与 SPI 原语声明 / `dbg_log.h` 日志宏计数) | > 详见 `docs/测试说明/看门狗单元测试说明.md`、`docs/测试说明/系统时钟单元测试说明.md`、 > `docs/测试说明/配置文件单元测试说明.md`、`docs/测试说明/ADC输入层单元测试说明.md`、 > `docs/测试说明/相量层单元测试说明.md`、`docs/测试说明/卷锁单元测试说明.md`、 > `docs/测试说明/时间同步单元测试说明.md`; > harness 用法见 [`common/README.md`](common/README.md)。 -> 已挂 pre-commit 钩子 `watchdog-unit-test`、`sys-clock-unit-test`、`cfg-file-unit-test`、 -> `phasor-unit-test`、`adc-input-unit-test`、`vol-lock-unit-test`、`time-sync-unit-test`。 +> 已挂 pre-commit 钩子(各模块同名 `*-unit-test`,共 **16** 个;逐个说明见 +> `docs/代码规范/pre-commit使用说明.md` §2 与 AGENTS.md 门禁条)。 + +### nand_port —— dhara NAND 移植层为什么能在 PC 上测 + +**一句话**:移植层是**纯逻辑 + 假芯片**,能钉死真机极难复现的越界/误判路径。 + +`nand_ftl.c` 的 dhara HAL 六个入口只做地址算术与边界判定,芯片侧全部经 11 个 SPI +原语打交道——把它们换成一块 RAM 假芯片,整层即可离线跑。而它要防的两类缺陷恰恰是 +板端**拿不到观测面**的: + +- **N-12**:`is_free` 旧实现只看页首 64 B,"页首 0xFF、后段有数据"的已编程页被误判 + 空闲 → dhara 往已编程页再编程 = 静默数据丢失。真机要恰好撞上那种页形态才发作, + 发作后只表现为"某次复位后丢数据"。假芯片把**双编程做成可数事件**(违规计数), + 测试还内联一份旧算法做对照:同一页旧算法答"空闲"、新实现答"非空"。 +- **N-13**:越界页/块号(含 `DHARA_PAGE_NONE` 哨兵)旧会被地址位截断成**真实页** + → 静默读写/搬走无辜数据。真机上没有任何路径会主动喂进越界号。假芯片记录**全部 + 访问次数**,于是"越界 → 返回 -1 + `DHARA_E_CORRUPT_MAP` 且零芯片访问、零标坏块" + 可机械断言。 ## 运行方式 @@ -106,6 +126,9 @@ # 时间同步单测(纯逻辑,无需真机) & '.venv\Scripts\python.exe' test/host/time_sync/run_unit_test.py + +# dhara NAND 移植层单测(纯逻辑 + RAM 假芯片,无需真机) +& '.venv\Scripts\python.exe' test/host/nand_port/run_unit_test.py ``` > 串口端口配置在 `.serialtest/config.json`(单一配置源,当前 COM6/115200)。 @@ -114,14 +137,13 @@ - **PYTHONPATH/路径敏感**:脚本按仓库根相对路径读写,**从仓库根执行** - 本目录**被 pre-commit 门禁的 cppcheck / 代码规范排除**(`test/` 豁免); - 但各模块的 `run_unit_test.py` 自身**作为独立钩子**参与门禁(见上,共 7 个) + 但各模块的 `run_unit_test.py` 自身**作为独立钩子**参与门禁(见上,共 16 个) - 台架探针族分工(2026-10-13 起):**自愈/Trap 常备回归腿在本目录**(`relisten_stress` / `ftp_abort_probe` / `n30_repro` / `e02_probe` / `e6_soak` 等);`MDK-ARM/*.py` 只余 历史一次性探针(`ftp_freeze_probe` / `tcp_reconnect_probe` / `ftp_retr_abort_probe` / `bench_verdict`) -- 全部主机侧单测(`watchdog/`、`sys_clock/`、`cfg_file/`、`phasor/`、`adc_input/`、 - `vol_lock/`、`time_sync/`)都需要本机装有 MSVC(VS2019/2022/2026 的 C++ 工具链); - 无则脚本报环境错误(退出码 2) +- 全部主机侧单测(16 个模块目录,含本目录新增的 `nand_port/`)都需要本机装有 MSVC + (VS2019/2022/2026 的 C++ 工具链);无则脚本报环境错误(退出码 2) ## 相关文档 diff --git a/test/host/nand_port/nand_port_test.c b/test/host/nand_port/nand_port_test.c new file mode 100644 index 00000000..fb22635c --- /dev/null +++ b/test/host/nand_port/nand_port_test.c @@ -0,0 +1,1029 @@ +/* + * 模块名称:dhara NAND 移植层(nand_ftl)主机侧单元测试 + * 被测对象:真实源文件 Drivers/BSP/GD5F2GQ5UE/nand_ftl.c(以 #include 原样纳入, + * 保证测的是生产代码而非副本)。底层 SPI 原语由本文件提供的 + * **RAM 假芯片**(stub/gd5f2gq5ue.h 声明的同名 API)实现。 + * + * 为什么要主机侧单测(而不是只用板端套件): + * 移植层(dhara HAL 六个入口)是**纯地址算术 + 边界判定**,与 SPI 时序无关; + * 而它要防的两类缺陷恰恰是真机**极难复现**的: + * 1. N-12:is_free 只看页首 64 B → "页首 0xFF、后段有数据"的页被误判空闲 + * → dhara 往已编程页再编程 = 静默数据丢失。真机上要恰好撞上那种页形态 + * 才发作,且发作后只表现为"某次复位后丢数据",无从定位。 + * 2. N-13:越界页/块号(含 DHARA_PAGE_NONE 哨兵)被地址位截断成真实页 + * → 静默读写/搬走无辜数据。真机上没有任何一条路径会主动喂进越界号, + * 只有被污染的 meta 或将来改动才会触发——主机侧一次构造即钉死。 + * 假芯片把"双编程"做成**可数事件**(N-12 探针),并记录全部访问次数, + * 于是"越界不产生任何芯片访问""越界标坏块次数为 0"这类判据可机械断言 + * ——这是板端永远拿不到的观测面。 + * + * 编码:字符串字面量**纯 ASCII**(中文只进注释),与 cfg_file_test.c 同口径; + * 主机侧由 harness 的 /utf-8 保证注释里的中文被正确解码。 + * + * 用例前缀:TC-NP-NNN(NP = NAND Port);分组: + * TC-NP-000 假芯片自洽(几何 / 上电态) + * TC-NP-100..108 dhara_nand_is_free 整页扫描(N-12) + * TC-NP-110..112 dhara_nand_is_free 越界守卫(N-13) + * TC-NP-200..207 dhara_nand_prog(越界 / NULL / 往返 / 双编程探针 / 失败注入) + * TC-NP-300..307 dhara_nand_read(越界 / 越窗 offset+length / 部分读 / ECC / E-42) + * TC-NP-400..405 dhara_nand_copy(src 与 dst 双端守卫 / 正常搬页) + * TC-NP-500..504 dhara_nand_erase(越界块零标坏块 / 整块回 0xFF / 失败注入) + * TC-NP-600..605 dhara_nand_is_bad / dhara_nand_mark_bad(越界保守判坏、BBT 不变) + * TC-NP-700 dhara map 级场景(写入 -> 重新 resume,双编程计数为 0) + * + * 通过:全部 CHECK 通过(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-15 + */ + +#include +#include +#include +#include + +/* ===================== 平台桩(先纳入,覆盖真实头) ===================== */ +#include "dbg_log.h" +#include "gd5f2gq5ue.h" + +/* DBG_* 计数(stub/dbg_log.h 声明) */ +int g_nandtest_err_count; +int g_nandtest_info_count; +int g_nandtest_debug_count; + +/* ======================== RAM 假芯片 ======================== + * + * 形态:一块 32 块 x 64 页 x 2048 B = 4 MB 的字节数组,上电全 0xFF。 + * 语义保真点: + * - 读:gd5f_page_read(pg) 把该页**锁进内部缓存**,之后必须由 + * gd5f_read_from_cache(column, buf, len) 按列取数;未锁页就取数、 + * 或列区间越过页尾 → 返回失败(真实芯片同样是"先 13h 后 0Bh"两段式); + * - 写:gd5f_program_load 只写"数据寄存器",gd5f_program_exec 才落到阵列, + * 且落盘是 **NOR 叠加**(new = old & written)——真实 NAND 的位语义; + * - 严格探针:exec 时若目标页已有非 0xFF 字节,记一次**双编程违规** + * (g_fake_double_program_cnt)。这是抓 N-12 回归的核心:任何 + * "把已编程页判成空闲"的实现都会让它立刻非 0; + * - 擦除:整块回 0xFF;坏块表是 RAM 位图,mark_bad 记录次数与块号序列。 + */ + +#define FAKE_BLOCKS ((uint32_t)GD5F_TOTAL_BLOCKS) /* 32 */ +#define FAKE_PAGES_PER_BLOCK ((uint32_t)GD5F_PAGES_PER_BLOCK) /* 64 */ +#define FAKE_PAGE_SIZE ((uint32_t)GD5F_PAGE_SIZE) /* 2048 */ +#define FAKE_TOTAL_PAGES (FAKE_BLOCKS * FAKE_PAGES_PER_BLOCK) /* 2048 */ +#define FAKE_USABLE_BLOCKS (FAKE_BLOCKS - (uint32_t)GD5F_BBT_POOL_COUNT) /* 28 */ + +/* 阵列 + 数据寄存器 + 内部读缓存锁 */ +static uint8_t s_chip[FAKE_TOTAL_PAGES][FAKE_PAGE_SIZE]; +static uint8_t s_load_reg[FAKE_PAGE_SIZE]; +static int32_t s_cache_page = -1; /* gd5f_page_read 锁进的页;-1 = 无 */ +static int s_load_valid; + +/* 坏块表(RAM) */ +static uint8_t s_bad[FAKE_BLOCKS]; + +/* 访问计数(供"越界不产生任何芯片访问"断言) */ +static unsigned long g_fake_write_enable_cnt; +static unsigned long g_fake_page_read_cnt; +static unsigned long g_fake_read_cache_cnt; +static unsigned long g_fake_check_ecc_cnt; +static unsigned long g_fake_program_load_cnt; +static unsigned long g_fake_program_exec_cnt; +static unsigned long g_fake_block_erase_cnt; +static unsigned long g_fake_is_bad_cnt; +static unsigned long g_fake_mark_bad_cnt; +static unsigned long g_fake_isfree_scan_cnt; /* 64 B 列读次数(is_free 指纹) */ + +/* N-12 探针:往已编程页再编程的次数与页号 */ +static unsigned long g_fake_double_program_cnt; +static uint32_t g_fake_last_double_program_page; + +/* mark_bad 记账(次数 + 块号序列) */ +#define FAKE_MARKBAD_LOG 16u +static uint32_t g_fake_mark_bad_log[FAKE_MARKBAD_LOG]; +static unsigned long g_fake_mark_bad_log_n; + +/* 故障注入(复现真机极难构造的 I/O 失败) */ +static int s_fail_page_read; +static int s_fail_read_cache; +static int s_fail_check_ecc; +static int s_fail_program_load; +static int s_fail_program_exec; +static int s_fail_block_erase; + +/* ---- SPI 原语(stub/gd5f2gq5ue.h 声明,签名与真实驱动逐字一致) ---- */ + +int32_t gd5f_write_enable(void) { + g_fake_write_enable_cnt++; + return GD5F_OK; +} + +int32_t gd5f_page_read(uint32_t page_addr) { + g_fake_page_read_cnt++; + if (page_addr >= FAKE_TOTAL_PAGES) { + return GD5F_ERROR; + } + if (s_fail_page_read != 0) { + return GD5F_ERROR; + } + s_cache_page = (int32_t)page_addr; + return GD5F_OK; +} + +int32_t gd5f_read_from_cache(uint16_t column, uint8_t *p_buf, uint32_t size) { + g_fake_read_cache_cnt++; + /* 64 B 列读 = dhara_nand_is_free 分块扫描的形状指纹。 + * dhara 其它读路径的尺寸是 16(header)/ 132(meta)/ 2048(整页), + * 故本计数可用来证明场景用例确实走到了 is_free(而不是"没跑到也算过")。 */ + if (size == 64u) { + g_fake_isfree_scan_cnt++; + } + if (p_buf == NULL) { + return GD5F_ERROR; + } + if (s_cache_page < 0) { + return GD5F_ERROR; /* 未先 page_read 就取缓存 = 无数据 */ + } + if (s_fail_read_cache != 0) { + return GD5F_ERROR; + } + if (((uint32_t)column + size) > FAKE_PAGE_SIZE) { + return GD5F_ERROR; /* 列越界 */ + } + (void)memcpy(p_buf, &s_chip[(uint32_t)s_cache_page][column], size); + return GD5F_OK; +} + +int32_t gd5f_check_ecc(void) { + g_fake_check_ecc_cnt++; + return (s_fail_check_ecc != 0) ? GD5F_ECC_ERROR : GD5F_OK; +} + +int32_t gd5f_program_load(uint16_t column, const uint8_t *p_buf, uint32_t size) { + g_fake_program_load_cnt++; + if (p_buf == NULL) { + return GD5F_ERROR; + } + if (s_fail_program_load != 0) { + return GD5F_ERROR; + } + if (((uint32_t)column + size) > FAKE_PAGE_SIZE) { + return GD5F_ERROR; + } + if (column == 0u) { + (void)memset(s_load_reg, 0xFF, sizeof(s_load_reg)); + } + (void)memcpy(&s_load_reg[column], p_buf, size); + s_load_valid = 1; + return GD5F_OK; +} + +int32_t gd5f_program_exec(uint32_t page_addr) { + uint32_t i; + + g_fake_program_exec_cnt++; + if (page_addr >= FAKE_TOTAL_PAGES) { + return GD5F_ERROR; + } + if (s_load_valid == 0) { + return GD5F_ERROR; /* 没装载就 exec = 命令序列错 */ + } + if (s_fail_program_exec != 0) { + return GD5F_PROGRAM_FAIL; + } + + /* 严格语义探针:目标页已有非 0xFF 字节 = 双编程(N-12 的现场证据) */ + for (i = 0; i < FAKE_PAGE_SIZE; i++) { + if (s_chip[page_addr][i] != 0xFFu) { + g_fake_double_program_cnt++; + g_fake_last_double_program_page = page_addr; + break; + } + } + + /* NOR 叠加:真实 NAND 只能把 1 写成 0,双编程的结果是位碰撞 */ + for (i = 0; i < FAKE_PAGE_SIZE; i++) { + s_chip[page_addr][i] = (uint8_t)(s_chip[page_addr][i] & s_load_reg[i]); + } + + s_load_valid = 0; + return GD5F_OK; +} + +int32_t gd5f_block_erase(uint32_t block_addr) { + g_fake_block_erase_cnt++; + if (block_addr >= FAKE_BLOCKS) { + return GD5F_ERROR; + } + if (s_fail_block_erase != 0) { + return GD5F_ERASE_FAIL; + } + (void)memset(&s_chip[block_addr * FAKE_PAGES_PER_BLOCK][0], 0xFF, + (size_t)FAKE_PAGES_PER_BLOCK * FAKE_PAGE_SIZE); + return GD5F_OK; +} + +int32_t gd5f2gq5ue_is_block_bad(uint32_t block) { + g_fake_is_bad_cnt++; + if (block >= FAKE_BLOCKS) { + return 1; /* 越界:保守判坏(与真实驱动同口径) */ + } + return (s_bad[block] != 0u) ? 1 : 0; +} + +void gd5f2gq5ue_mark_block_bad(uint32_t block) { + g_fake_mark_bad_cnt++; + if (g_fake_mark_bad_log_n < FAKE_MARKBAD_LOG) { + g_fake_mark_bad_log[g_fake_mark_bad_log_n] = block; + } + g_fake_mark_bad_log_n++; + if (block < FAKE_BLOCKS) { + s_bad[block] = 1u; + } +} + +uint32_t gd5f_get_usable_blocks(void) { + return FAKE_USABLE_BLOCKS; +} + +int gd5f_dma_buf_ok(const void *p_buf, size_t size) { + /* PC 上没有"DMA 不可达内存"这种物理形态;恒返 1 让 disk_initialize 继续。 + * 该守卫本身由 gd5f2gq5ue.c 负责,不在本套件射程内。 */ + (void)p_buf; + (void)size; + return 1; +} + +/* ---- 假芯片辅助(测试脚手架,非驱动 API) ---- */ + +/* 复位到"刚上电":阵列全 0xFF、坏块表清空、全部计数与注入清零 */ +static void fake_chip_reset(void) { + (void)memset(s_chip, 0xFF, sizeof(s_chip)); + (void)memset(s_load_reg, 0xFF, sizeof(s_load_reg)); + (void)memset(s_bad, 0, sizeof(s_bad)); + (void)memset(g_fake_mark_bad_log, 0, sizeof(g_fake_mark_bad_log)); + s_cache_page = -1; + s_load_valid = 0; + g_fake_write_enable_cnt = 0u; + g_fake_page_read_cnt = 0u; + g_fake_read_cache_cnt = 0u; + g_fake_check_ecc_cnt = 0u; + g_fake_program_load_cnt = 0u; + g_fake_program_exec_cnt = 0u; + g_fake_block_erase_cnt = 0u; + g_fake_is_bad_cnt = 0u; + g_fake_mark_bad_cnt = 0u; + g_fake_isfree_scan_cnt = 0u; + g_fake_mark_bad_log_n = 0u; + g_fake_double_program_cnt = 0u; + g_fake_last_double_program_page = 0xFFFFFFFFu; + s_fail_page_read = 0; + s_fail_read_cache = 0; + s_fail_check_ecc = 0; + s_fail_program_load = 0; + s_fail_program_exec = 0; + s_fail_block_erase = 0; + g_nandtest_err_count = 0; + g_nandtest_info_count = 0; + g_nandtest_debug_count = 0; +} + +/* 全部芯片侧访问次数之和("越界不产生任何芯片访问"的判据量) */ +static unsigned long fake_chip_access_total(void) { + return g_fake_write_enable_cnt + g_fake_page_read_cnt + g_fake_read_cache_cnt + + g_fake_check_ecc_cnt + g_fake_program_load_cnt + g_fake_program_exec_cnt + + g_fake_block_erase_cnt + g_fake_is_bad_cnt + g_fake_mark_bad_cnt; +} + +/* 绕过驱动语义直写一页(构造"已编程页"的初始形态,不计双编程) */ +static void fake_poke_page(uint32_t page, const uint8_t *p_src, uint32_t len) { + if (page < FAKE_TOTAL_PAGES) { + (void)memcpy(&s_chip[page][0], p_src, len); + } +} + +/* 整页是否仍为擦除态 */ +static int fake_page_all_ff(uint32_t page) { + uint32_t i; + + if (page >= FAKE_TOTAL_PAGES) { + return 0; + } + for (i = 0; i < FAKE_PAGE_SIZE; i++) { + if (s_chip[page][i] != 0xFFu) { + return 0; + } + } + return 1; +} + +/* 整页是否与给定内容一致 */ +static int fake_page_equals(uint32_t page, const uint8_t *p_src, uint32_t len) { + if (page >= FAKE_TOTAL_PAGES) { + return 0; + } + return memcmp(&s_chip[page][0], p_src, len) == 0; +} + +/* 块内所有页是否都是擦除态 */ +static int fake_block_all_ff(uint32_t block) { + uint32_t i; + + if (block >= FAKE_BLOCKS) { + return 0; + } + for (i = 0; i < FAKE_PAGES_PER_BLOCK; i++) { + if (!fake_page_all_ff(block * FAKE_PAGES_PER_BLOCK + i)) { + return 0; + } + } + return 1; +} + +/* 生成可复现的伪随机页内容(非 0xFF 密集,用于往返比对) */ +static void fake_fill_pattern(uint8_t *p_buf, uint32_t len, uint32_t seed) { + uint32_t i; + uint32_t x = seed | 1u; + + for (i = 0; i < len; i++) { + x = (x * 1664525u) + 1013904223u; + p_buf[i] = (uint8_t)(x >> 24); + } +} + +/* 坏块表快照 / 比对(断言"越界 mark_bad 后坏块表不变") */ +static uint8_t s_bad_snapshot[FAKE_BLOCKS]; + +static void fake_bad_snapshot(void) { + (void)memcpy(s_bad_snapshot, s_bad, sizeof(s_bad_snapshot)); +} + +static int fake_bad_unchanged(void) { + return memcmp(s_bad_snapshot, s_bad, sizeof(s_bad_snapshot)) == 0; +} + +/* 复位假芯片并在页 64(块 1 首页)铺一份已知内容:read / copy 用例的公共前置。 + * 每次 fake_chip_reset() 后阵列回到 0xFF,**必须重新铺数据**,否则"内容比对" + * 会拿全 0xFF 去比模式串——测试自己假绿。 */ +#define UT_SRC_PAGE 64u + +static void prep_content_page(uint8_t *p_buf, uint32_t seed) { + fake_chip_reset(); + fake_fill_pattern(p_buf, FAKE_PAGE_SIZE, seed); + fake_poke_page(UT_SRC_PAGE, p_buf, FAKE_PAGE_SIZE); +} + +/* ===================== 断言 ===================== */ +static int s_total; +static int s_passed; +static int s_failed; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码(原样纳入生产源) ===================== */ +/* + * MSVC /W4 下的**主机平台专属**噪音,与 MCU 构建(ARMCC,size_t = 32 bit)无关, + * 只在这几处 include 周围关闭;本测试单元自身仍保持 /W4 全开: + * 4267 nand_ftl.c:277 把 size_t length 交给 uint32_t 形参——x64 上 size_t 是 + * 64 位才成立,ARM 上同宽(ARMCC 构建 0 错 0 警); + * 4244 / 4334 / 4100 / 4701 第三方 Lib/dhara(本仓裁剪版,只读、cppcheck 亦排除)。 + */ +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4267) +#endif +#include "nand_ftl.c" +#if defined(_MSC_VER) +#pragma warning(pop) +#endif + +/* dhara 核心(真实源,非桩):nand_ftl.c 引用 dhara_map_*,必须一并纳入才能链接。 + * 纳入后本编译单元 = 移植层 + 它服务的 FTL 本体,故 dhara map 级场景 + * (TC-NP-700 写入 -> 重新 resume)测的是真链路。顺序:journal 在 map 之前。 */ +#if defined(_MSC_VER) +#pragma warning(push) +#pragma warning(disable : 4100 4244 4334 4701) +#endif +#include "journal.c" +#include "map.c" +#if defined(_MSC_VER) +#pragma warning(pop) +#endif + +/* ===================== 测试几何 ===================== + * 与 disk_initialize 里给 s_nand 的赋值逐项一致(log2_page_size=11 -> 2048 B、 + * log2_ppb=6 -> 64 页/块),块数取假芯片的 dhara 可见块数(32 - 4 保留块池)。 + * FTL_START_BLOCK = 0 -> 逻辑页号 == 物理页号,越界期望值可直接算。 */ +static struct dhara_nand s_ut_nand; + +#define UT_TOTAL_PAGES ((dhara_page_t)s_ut_nand.num_blocks << s_ut_nand.log2_ppb) +#define UT_OOB_PAGE (UT_TOTAL_PAGES) /* 第一个越界页 */ +#define UT_OOB_BLOCK ((dhara_block_t)s_ut_nand.num_blocks) + +/* ===================== 旧算法对照(N-12 的"牙齿") ===================== + * + * N-12 修复**之前**的 dhara_nand_is_free:只读页首 64 B 就回答"空闲"。 + * 本函数按当时的形状内联一份(同样经假芯片的两段式读),用来证明本套件**有牙**: + * 对同一页,旧算法答 1(误判空闲)、新实现答 0。将来有人把 is_free 改回只看 + * 页首,TC-NP-105 立刻变红——而不是"测试跟着实现一起改回去"。 + */ +static int old_is_free_first64(const struct dhara_nand *n, dhara_page_t p) { + uint8_t buf[64]; + uint32_t i; + + (void)n; + if (gd5f_page_read(ftl_phy_page(p)) != GD5F_OK) { + return 0; + } + if (gd5f_read_from_cache(0u, buf, sizeof(buf)) != GD5F_OK) { + return 0; + } + for (i = 0; i < sizeof(buf); i++) { + if (buf[i] != 0xFFu) { + return 0; + } + } + return 1; +} + +/* ===================== TC-NP-100..108:dhara_nand_is_free(整页扫描) ===================== + * + * N-12:旧实现只看页首 64 B,会把"页首 0xFF、后段有数据"的已编程页误判空闲 + * -> dhara 往已编程页再编程 = 数据丢失。本组把"必须扫完整页"钉死, + * 并用 TC-NP-105 的旧算法对照证明这组断言真的能咬人。 + */ +static void tc_is_free(void) { + static uint8_t page_buf[FAKE_PAGE_SIZE]; + struct dhara_nand zeroed; + + /* TC-NP-100:整页擦除 -> 1 */ + printf("[TC-NP-100] erased page is free\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_free(&s_ut_nand, 0u) == 1, "erased page -> is_free = 1"); + CHECK(g_fake_page_read_cnt == 1u, "one page_read only (no per-byte commands)"); + CHECK(g_fake_read_cache_cnt == (FAKE_PAGE_SIZE / 64u), + "erased page scans the whole page = 32 column reads (N-12 evidence)"); + CHECK(g_fake_isfree_scan_cnt == (FAKE_PAGE_SIZE / 64u), + "64B column-read fingerprint calibrated (proves scenario really calls is_free)"); + + /* TC-NP-101:第 0 字节有数据 -> 0(快路径) */ + printf("[TC-NP-101] byte 0 programmed -> not free\n"); + fake_chip_reset(); + (void)memset(page_buf, 0xFF, sizeof(page_buf)); + page_buf[0] = 0x00u; + fake_poke_page(1u, page_buf, FAKE_PAGE_SIZE); + CHECK(dhara_nand_is_free(&s_ut_nand, 1u) == 0, "byte 0 programmed -> is_free = 0"); + CHECK(g_fake_read_cache_cnt == 1u, + "early exit on first chunk = fast path (same cost as the old impl)"); + + /* TC-NP-102:N-12 回归用例 + * 页首 64 B 全 0xFF、第 1000 字节 = 0x00 -> 必须答"非空"。 + * 旧实现只看页首 64 B 会误判空闲,dhara 遂往这一页再编程。 */ + printf("[TC-NP-102] N-12 regression: hole in first 64B, data at byte 1000\n"); + fake_chip_reset(); + (void)memset(page_buf, 0xFF, sizeof(page_buf)); + page_buf[1000] = 0x00u; + fake_poke_page(2u, page_buf, FAKE_PAGE_SIZE); + CHECK(dhara_nand_is_free(&s_ut_nand, 2u) == 0, + "first 64B all 0xFF, byte 1000 = 0x00 -> is_free = 0 (N-12)"); + CHECK(g_fake_read_cache_cnt > 1u, + "scan continues past the first 64B (old impl stopped here = root cause)"); + + /* TC-NP-103:只有最后一个字节非 0xFF -> 0 */ + printf("[TC-NP-103] only last byte programmed -> not free\n"); + fake_chip_reset(); + (void)memset(page_buf, 0xFF, sizeof(page_buf)); + page_buf[FAKE_PAGE_SIZE - 1u] = 0x5Au; + fake_poke_page(3u, page_buf, FAKE_PAGE_SIZE); + CHECK(dhara_nand_is_free(&s_ut_nand, 3u) == 0, + "only the very last byte non-0xFF -> is_free = 0"); + CHECK(g_fake_read_cache_cnt == (FAKE_PAGE_SIZE / 64u), + "full scan to the page tail before answering (32 column reads)"); + + /* TC-NP-104:只有第 64 字节非 0xFF -> 0(旧窗口右边界外的第一个字节) */ + printf("[TC-NP-104] only byte 64 programmed -> not free\n"); + fake_chip_reset(); + (void)memset(page_buf, 0xFF, sizeof(page_buf)); + page_buf[64u] = 0x00u; + fake_poke_page(4u, page_buf, FAKE_PAGE_SIZE); + CHECK(dhara_nand_is_free(&s_ut_nand, 4u) == 0, + "only byte 64 (first byte outside the old window) -> is_free = 0"); + + /* TC-NP-105:旧算法对照(证明本套件有牙) + * 同一页形态:旧算法答 1 = 误判空闲,新实现答 0。 + * 任何人把 is_free 改回"只看页首",这条立刻红。 */ + printf("[TC-NP-105] old-algorithm contrast (test has teeth)\n"); + fake_chip_reset(); + (void)memset(page_buf, 0xFF, sizeof(page_buf)); + page_buf[1000] = 0x00u; + fake_poke_page(5u, page_buf, FAKE_PAGE_SIZE); + CHECK(old_is_free_first64(&s_ut_nand, 5u) == 1, + "old 64B-only algorithm misjudges the same page as free -> 1"); + CHECK(dhara_nand_is_free(&s_ut_nand, 5u) == 0, + "new implementation answers not-free -> 0 (opposite verdict = teeth)"); + + /* TC-NP-106:读失败保守判非空(E-42 同族) */ + printf("[TC-NP-106] read failure is answered conservatively\n"); + fake_chip_reset(); + s_fail_page_read = 1; + CHECK(dhara_nand_is_free(&s_ut_nand, 6u) == 0, + "page_read failure -> not free (never treat unreadable as free)"); + fake_chip_reset(); + s_fail_read_cache = 1; + CHECK(dhara_nand_is_free(&s_ut_nand, 7u) == 0, + "read_from_cache failure -> not free (E-42 family)"); + + /* TC-NP-107:NULL / num_blocks = 0 的几何守卫 */ + printf("[TC-NP-107] NULL geometry / zero blocks guard\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_free(NULL, 0u) == 0, "n = NULL -> not free, no crash"); + CHECK(fake_chip_access_total() == 0u, "n = NULL causes zero chip access"); + zeroed.log2_page_size = 11u; + zeroed.log2_ppb = 6u; + zeroed.num_blocks = 0u; + CHECK(dhara_nand_is_free(&zeroed, 0u) == 0, "num_blocks = 0 -> not free"); + CHECK(fake_chip_access_total() == 0u, "num_blocks = 0 causes zero chip access"); + + /* TC-NP-108:擦除后重新判空(假芯片语义自洽,防"擦除没生效"假绿) */ + printf("[TC-NP-108] fake chip erase really clears the array\n"); + fake_chip_reset(); + fake_fill_pattern(page_buf, FAKE_PAGE_SIZE, 7u); + fake_poke_page(8u, page_buf, FAKE_PAGE_SIZE); + CHECK(dhara_nand_is_free(&s_ut_nand, 8u) == 0, "programmed page -> not free"); + CHECK(gd5f_block_erase(0u) == GD5F_OK, "erase the containing block (helper)"); + CHECK(dhara_nand_is_free(&s_ut_nand, 8u) == 1, "after erase -> free (fake chip sane)"); +} + +/* ===================== TC-NP-110..112:is_free 越界守卫(N-13) ===================== */ +static void tc_is_free_oob(void) { + printf("[TC-NP-110] is_free out-of-range pages (N-13)\n"); + + fake_chip_reset(); + CHECK(dhara_nand_is_free(&s_ut_nand, DHARA_PAGE_NONE) == 0, + "DHARA_PAGE_NONE -> is_free = 0"); + CHECK(fake_chip_access_total() == 0u, + "DHARA_PAGE_NONE causes zero chip access (no truncated address)"); + + printf("[TC-NP-111] is_free at total_pages boundary\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_free(&s_ut_nand, UT_OOB_PAGE) == 0, + "page == total_pages -> is_free = 0"); + CHECK(fake_chip_access_total() == 0u, "page == total_pages -> zero chip access"); + + printf("[TC-NP-112] is_free beyond total_pages\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_free(&s_ut_nand, UT_OOB_PAGE + 1u) == 0, + "page == total_pages + 1 -> is_free = 0"); + CHECK(fake_chip_access_total() == 0u, "page == total_pages + 1 -> zero chip access"); +} + +/* 无任何写侧芯片访问("越界零写入"的判据量) */ +static int fake_no_write_access(void) { + return (g_fake_write_enable_cnt == 0u) && (g_fake_program_load_cnt == 0u) && + (g_fake_program_exec_cnt == 0u) && (g_fake_block_erase_cnt == 0u); +} + +/* ===================== TC-NP-200..205:dhara_nand_prog ===================== */ +static void tc_prog(void) { + static uint8_t page_buf[FAKE_PAGE_SIZE]; + dhara_error_t err; + + /* TC-NP-200:越界页 -> -1 + CORRUPT_MAP + 零写入 */ + printf("[TC-NP-200] prog out-of-range page -> -1, zero write\n"); + fake_chip_reset(); + fake_fill_pattern(page_buf, FAKE_PAGE_SIZE, 11u); + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, UT_OOB_PAGE, page_buf, &err) == -1, + "prog page == total_pages -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "prog out-of-range -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_no_write_access(), "prog out-of-range -> zero write access"); + CHECK(g_fake_mark_bad_cnt == 0u, "prog out-of-range -> no block marked bad"); + + /* TC-NP-201:DHARA_PAGE_NONE -> -1 + CORRUPT_MAP + 零写入 + * 旧实现会把 0xffffffff 加偏移送进 SPI 命令,被地址位截断成某个真实页 + * -> 静默写坏无辜数据;现在必须在守卫处就挡住。 */ + printf("[TC-NP-201] prog DHARA_PAGE_NONE -> -1, zero write (N-13)\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, DHARA_PAGE_NONE, page_buf, &err) == -1, + "prog DHARA_PAGE_NONE -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "prog DHARA_PAGE_NONE -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_chip_access_total() == 0u, "prog DHARA_PAGE_NONE -> zero chip access"); + + /* TC-NP-202:data == NULL -> -1(守卫顺带挡住空指针) */ + printf("[TC-NP-202] prog with NULL data -> -1\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, 0u, NULL, &err) == -1, "prog data = NULL -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "prog data = NULL -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_no_write_access(), "prog data = NULL -> zero write access"); + + /* TC-NP-203:正常页 -> 0 且回读一致 */ + printf("[TC-NP-203] prog a valid page -> 0, read-back matches\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, 64u, page_buf, &err) == 0, "prog page 64 -> 0"); + CHECK(err == DHARA_E_NONE, "prog success leaves err = NONE"); + CHECK(fake_page_equals(64u, page_buf, FAKE_PAGE_SIZE), "prog page 64 content on chip"); + CHECK(g_fake_double_program_cnt == 0u, "prog onto an erased page -> no violation"); + + /* TC-NP-204:最后一个合法页(上界内边界) */ + printf("[TC-NP-204] prog last valid page (boundary inside)\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, UT_TOTAL_PAGES - 1u, page_buf, &err) == 0, + "prog page == total_pages - 1 -> 0"); + CHECK(fake_page_equals(UT_TOTAL_PAGES - 1u, page_buf, FAKE_PAGE_SIZE), + "last valid page content on chip"); + + /* TC-NP-205:双编程探针自检——同一页再编程必须被记违规(防探针是死码) */ + printf("[TC-NP-205] double-program probe really fires\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + (void)dhara_nand_prog(&s_ut_nand, 128u, page_buf, &err); + (void)dhara_nand_prog(&s_ut_nand, 128u, page_buf, &err); + CHECK(g_fake_double_program_cnt == 1u, "reprogramming a programmed page -> 1 violation"); + CHECK(g_fake_last_double_program_page == 128u, "violation records the offending page"); + + /* TC-NP-206:编程失败 -> BAD_BLOCK + 标坏该块(错误分类不被守卫改动破坏) */ + printf("[TC-NP-206] program failure -> BAD_BLOCK + mark bad\n"); + fake_chip_reset(); + s_fail_program_exec = 1; + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, 2u * 64u, page_buf, &err) == -1, + "program_exec failure -> -1"); + CHECK(err == DHARA_E_BAD_BLOCK, "program_exec failure -> DHARA_E_BAD_BLOCK"); + CHECK(g_fake_mark_bad_cnt == 1u, "program failure marks exactly one block bad"); + CHECK(g_fake_mark_bad_log[0] == 2u, "the block marked bad is the page's own block"); + + /* TC-NP-207:program_load 失败(装载阶段)-> 同样 BAD_BLOCK + 标坏该块, + * 且在 exec 之前就短路(真实芯片:02h 都没成,10h 自然不发) */ + printf("[TC-NP-207] program_load failure -> BAD_BLOCK + mark bad\n"); + fake_chip_reset(); + s_fail_program_load = 1; + err = DHARA_E_NONE; + CHECK(dhara_nand_prog(&s_ut_nand, 3u * 64u, page_buf, &err) == -1, + "program_load failure -> -1"); + CHECK(err == DHARA_E_BAD_BLOCK, "program_load failure -> DHARA_E_BAD_BLOCK"); + CHECK(g_fake_program_exec_cnt == 0u, "load failure short-circuits before program_exec"); + CHECK(g_fake_mark_bad_cnt == 1u, "load failure marks exactly one block bad"); + CHECK(g_fake_mark_bad_log[0] == 3u, "the block marked bad is the page's own block"); +} + +/* ===================== TC-NP-300..307:dhara_nand_read ===================== */ +static void tc_read(void) { + static uint8_t page_buf[FAKE_PAGE_SIZE]; + static uint8_t rd[FAKE_PAGE_SIZE]; + dhara_error_t err; + + /* TC-NP-300/301:越界页 -> -1 + CORRUPT_MAP + 零访问 */ + printf("[TC-NP-300] read out-of-range page -> -1, zero access\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, UT_OOB_PAGE, 0u, 512u, rd, &err) == -1, + "read page == total_pages -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "read out-of-range -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_chip_access_total() == 0u, "read out-of-range -> zero chip access"); + + printf("[TC-NP-301] read DHARA_PAGE_NONE -> -1, zero access\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, DHARA_PAGE_NONE, 0u, 512u, rd, &err) == -1, + "read DHARA_PAGE_NONE -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "read DHARA_PAGE_NONE -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_chip_access_total() == 0u, "read DHARA_PAGE_NONE -> zero chip access"); + + /* TC-NP-302:offset + length 越过 2048 -> -1 */ + printf("[TC-NP-302] read spanning past the page end -> -1\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, 0u, 2000u, 100u, rd, &err) == -1, + "read offset 2000 + length 100 (> 2048) -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "read overrun -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_chip_access_total() == 0u, "read overrun -> zero chip access"); + + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, 0u, FAKE_PAGE_SIZE, 0u, rd, &err) == -1, + "read offset == 2048 (even with length 0) -> -1"); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, 0u, 0u, FAKE_PAGE_SIZE + 1u, rd, &err) == -1, + "read length 2049 -> -1"); + + /* TC-NP-303:NULL data -> -1 */ + printf("[TC-NP-303] read with NULL data -> -1\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, 0u, 0u, 512u, NULL, &err) == -1, + "read data = NULL -> -1"); + CHECK(fake_chip_access_total() == 0u, "read data = NULL -> zero chip access"); + + /* TC-NP-304:正常部分读 -> 0 且内容正确 */ + printf("[TC-NP-304] partial read returns the right slice\n"); + prep_content_page(page_buf, 21u); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, UT_SRC_PAGE, 1024u, 512u, rd, &err) == 0, + "partial read (offset 1024, len 512) -> 0"); + CHECK(memcmp(rd, &page_buf[1024], 512u) == 0, "partial read returns the right bytes"); + CHECK(g_fake_page_read_cnt == 1u, "partial read issues one page_read"); + + /* TC-NP-305:整页读 -> 0 且内容正确(边界 length == 2048) */ + printf("[TC-NP-305] full page read\n"); + prep_content_page(page_buf, 21u); + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, UT_SRC_PAGE, 0u, FAKE_PAGE_SIZE, rd, &err) == 0, + "full page read (length == 2048) -> 0"); + CHECK(memcmp(rd, page_buf, FAKE_PAGE_SIZE) == 0, "full page read matches the chip"); + + /* TC-NP-306:ECC 不可纠正 -> -1 + DHARA_E_ECC */ + printf("[TC-NP-306] uncorrectable ECC -> -1 + DHARA_E_ECC\n"); + prep_content_page(page_buf, 21u); + s_fail_check_ecc = 1; + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, UT_SRC_PAGE, 0u, 512u, rd, &err) == -1, + "check_ecc failure -> -1"); + CHECK(err == DHARA_E_ECC, "check_ecc failure -> DHARA_E_ECC"); + + /* TC-NP-307:E-42 同族——read_from_cache 失败必须 -1(旧实现返回 0 交垃圾) */ + printf("[TC-NP-307] E-42: read_from_cache failure must not report success\n"); + prep_content_page(page_buf, 21u); + s_fail_read_cache = 1; + err = DHARA_E_NONE; + CHECK(dhara_nand_read(&s_ut_nand, UT_SRC_PAGE, 0u, 512u, rd, &err) == -1, + "read_from_cache failure -> -1 (not 0 with garbage data)"); + CHECK(err == DHARA_E_ECC, "read_from_cache failure -> DHARA_E_ECC"); +} + +/* ===================== TC-NP-400..405:dhara_nand_copy ===================== */ +static void tc_copy(void) { + static uint8_t page_buf[FAKE_PAGE_SIZE]; + dhara_error_t err; + + /* TC-NP-400:src 越界 -> -1 + CORRUPT_MAP + 零写入 */ + printf("[TC-NP-400] copy with out-of-range src -> -1, zero write\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_copy(&s_ut_nand, UT_OOB_PAGE, 100u, &err) == -1, + "copy src == total_pages -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "copy bad src -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_no_write_access(), "copy bad src -> zero write access"); + CHECK(fake_chip_access_total() == 0u, "copy bad src -> zero chip access at all"); + + /* TC-NP-401:src = DHARA_PAGE_NONE -> -1 + CORRUPT_MAP */ + printf("[TC-NP-401] copy with DHARA_PAGE_NONE src -> -1\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_copy(&s_ut_nand, DHARA_PAGE_NONE, 100u, &err) == -1, + "copy src = DHARA_PAGE_NONE -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "copy DHARA_PAGE_NONE src -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_chip_access_total() == 0u, "copy DHARA_PAGE_NONE src -> zero chip access"); + + /* TC-NP-402:dst 越界 -> -1 + CORRUPT_MAP + 零写入 + 零标坏块 + * dst 越界最致命:那是一次写入,旧实现会静默写进被截断出来的无辜页。 */ + printf("[TC-NP-402] copy with out-of-range dst -> -1, zero write, zero mark-bad\n"); + prep_content_page(page_buf, 31u); + err = DHARA_E_NONE; + CHECK(dhara_nand_copy(&s_ut_nand, UT_SRC_PAGE, UT_OOB_PAGE, &err) == -1, + "copy dst == total_pages -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "copy bad dst -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_no_write_access(), "copy bad dst -> zero write access"); + CHECK(g_fake_mark_bad_cnt == 0u, "copy bad dst -> no block marked bad"); + + /* TC-NP-403:dst = DHARA_PAGE_NONE(哨兵漏进来的真实形态) */ + printf("[TC-NP-403] copy with DHARA_PAGE_NONE dst -> -1, zero write\n"); + prep_content_page(page_buf, 31u); + err = DHARA_E_NONE; + CHECK(dhara_nand_copy(&s_ut_nand, UT_SRC_PAGE, DHARA_PAGE_NONE, &err) == -1, + "copy dst = DHARA_PAGE_NONE -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "copy DHARA_PAGE_NONE dst -> DHARA_E_CORRUPT_MAP"); + CHECK(fake_no_write_access(), "copy DHARA_PAGE_NONE dst -> zero write access"); + CHECK(g_fake_mark_bad_cnt == 0u, "copy DHARA_PAGE_NONE dst -> zero mark_bad"); + + /* TC-NP-404:正常 copy -> 0 且目标页与源一致 */ + printf("[TC-NP-404] normal copy moves the page content\n"); + prep_content_page(page_buf, 31u); + err = DHARA_E_NONE; + CHECK(dhara_nand_copy(&s_ut_nand, UT_SRC_PAGE, 128u, &err) == 0, + "copy src -> erased dst -> 0"); + CHECK(err == DHARA_E_NONE, "copy success leaves err = NONE"); + CHECK(fake_page_equals(128u, page_buf, FAKE_PAGE_SIZE), "dst page equals src content"); + CHECK(g_fake_double_program_cnt == 0u, "copy onto an erased page -> no violation"); + + /* TC-NP-405:copy 到已编程页 -> 探针记下双编程(场景层的判据基础) */ + printf("[TC-NP-405] copy onto a programmed page is recorded as a violation\n"); + prep_content_page(page_buf, 31u); + err = DHARA_E_NONE; + (void)dhara_nand_copy(&s_ut_nand, UT_SRC_PAGE, 128u, &err); + (void)dhara_nand_copy(&s_ut_nand, UT_SRC_PAGE, 128u, &err); + CHECK(g_fake_double_program_cnt == 1u, "second copy onto the same dst -> 1 violation"); +} + +/* ===================== TC-NP-500..504:dhara_nand_erase ===================== */ +static void tc_erase(void) { + static uint8_t page_buf[FAKE_PAGE_SIZE]; + dhara_error_t err; + uint32_t i; + + /* TC-NP-500:越界块 -> -1 + CORRUPT_MAP + 零标坏块 */ + printf("[TC-NP-500] erase out-of-range block -> -1, zero mark-bad\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_erase(&s_ut_nand, UT_OOB_BLOCK, &err) == -1, + "erase block == num_blocks -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "erase out-of-range -> DHARA_E_CORRUPT_MAP"); + CHECK(g_fake_mark_bad_cnt == 0u, "erase out-of-range -> no block marked bad"); + CHECK(fake_chip_access_total() == 0u, "erase out-of-range -> zero chip access"); + + /* TC-NP-501:块号 = 0xffffffff(哨兵当块号用) */ + printf("[TC-NP-501] erase block 0xffffffff -> -1\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_erase(&s_ut_nand, 0xFFFFFFFFu, &err) == -1, + "erase block = 0xffffffff -> -1"); + CHECK(err == DHARA_E_CORRUPT_MAP, "erase block 0xffffffff -> DHARA_E_CORRUPT_MAP"); + CHECK(g_fake_mark_bad_cnt == 0u, "erase block 0xffffffff -> zero mark_bad"); + CHECK(fake_chip_access_total() == 0u, "erase block 0xffffffff -> zero chip access"); + + /* TC-NP-502:正常擦除 -> 0 且该块全页回 0xFF */ + printf("[TC-NP-502] erase a valid block clears every page\n"); + fake_chip_reset(); + fake_fill_pattern(page_buf, FAKE_PAGE_SIZE, 41u); + for (i = 0; i < FAKE_PAGES_PER_BLOCK; i++) { + fake_poke_page(2u * FAKE_PAGES_PER_BLOCK + i, page_buf, FAKE_PAGE_SIZE); + } + CHECK(!fake_block_all_ff(2u), "block 2 programmed before erase"); + err = DHARA_E_NONE; + CHECK(dhara_nand_erase(&s_ut_nand, 2u, &err) == 0, "erase block 2 -> 0"); + CHECK(err == DHARA_E_NONE, "erase success leaves err = NONE"); + CHECK(fake_block_all_ff(2u), "after erase every page of block 2 reads 0xFF"); + CHECK(g_fake_block_erase_cnt == 1u, "erase issues one block-erase command"); + + /* TC-NP-503:最后一个合法块(上界内边界) */ + printf("[TC-NP-503] erase last valid block (boundary inside)\n"); + fake_chip_reset(); + err = DHARA_E_NONE; + CHECK(dhara_nand_erase(&s_ut_nand, UT_OOB_BLOCK - 1u, &err) == 0, + "erase block == num_blocks - 1 -> 0"); + + /* TC-NP-504:擦除失败 -> BAD_BLOCK + 标坏该块 */ + printf("[TC-NP-504] erase failure -> BAD_BLOCK + mark bad\n"); + fake_chip_reset(); + s_fail_block_erase = 1; + err = DHARA_E_NONE; + CHECK(dhara_nand_erase(&s_ut_nand, 3u, &err) == -1, "block_erase failure -> -1"); + CHECK(err == DHARA_E_BAD_BLOCK, "block_erase failure -> DHARA_E_BAD_BLOCK"); + CHECK(g_fake_mark_bad_cnt == 1u, "erase failure marks exactly one block bad"); + CHECK(g_fake_mark_bad_log[0] == 3u, "the block marked bad is the one that failed"); +} + +/* ===================== TC-NP-600..605:is_bad / mark_bad ===================== */ +static void tc_bad_block(void) { + /* TC-NP-600:越界块 is_bad -> 1 且不碰驱动 */ + printf("[TC-NP-600] is_bad out-of-range block -> 1, zero access\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_bad(&s_ut_nand, UT_OOB_BLOCK) == 1, + "is_bad block == num_blocks -> 1 (conservative)"); + CHECK(dhara_nand_is_bad(&s_ut_nand, 0xFFFFFFFFu) == 1, + "is_bad block = 0xffffffff -> 1 (conservative)"); + CHECK(g_fake_is_bad_cnt == 0u, "is_bad out-of-range never reaches the driver"); + + /* TC-NP-601:NULL 几何 is_bad -> 1 */ + printf("[TC-NP-601] is_bad with NULL geometry -> 1\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_bad(NULL, 0u) == 1, "is_bad n = NULL -> 1, no crash"); + CHECK(g_fake_is_bad_cnt == 0u, "is_bad n = NULL -> zero driver access"); + + /* TC-NP-602:合法好块 -> 0(透传驱动) */ + printf("[TC-NP-602] is_bad on a valid block passes through\n"); + fake_chip_reset(); + CHECK(dhara_nand_is_bad(&s_ut_nand, 5u) == 0, "good block -> is_bad = 0"); + CHECK(g_fake_is_bad_cnt == 1u, "valid block reaches the driver once"); + + /* TC-NP-603/604:越界 mark_bad -> 坏块表不变、计数为 0 */ + printf("[TC-NP-603] mark_bad out-of-range leaves the BBT untouched\n"); + fake_chip_reset(); + fake_bad_snapshot(); + dhara_nand_mark_bad(&s_ut_nand, UT_OOB_BLOCK); + CHECK(g_fake_mark_bad_cnt == 0u, "mark_bad block == num_blocks -> driver not called"); + CHECK(fake_bad_unchanged(), "mark_bad out-of-range -> BBT unchanged"); + CHECK(fake_chip_access_total() == 0u, "mark_bad out-of-range -> zero chip access"); + + printf("[TC-NP-604] mark_bad with 0xffffffff leaves the BBT untouched\n"); + fake_chip_reset(); + fake_bad_snapshot(); + dhara_nand_mark_bad(&s_ut_nand, 0xFFFFFFFFu); + CHECK(g_fake_mark_bad_cnt == 0u, "mark_bad block = 0xffffffff -> driver not called"); + CHECK(fake_bad_unchanged(), "mark_bad 0xffffffff -> BBT unchanged"); + dhara_nand_mark_bad(NULL, 0u); + CHECK(g_fake_mark_bad_cnt == 0u, "mark_bad n = NULL -> driver not called, no crash"); + + /* TC-NP-605:合法块 mark_bad -> 驱动收到一次、块号正确、随后 is_bad = 1 */ + printf("[TC-NP-605] mark_bad on a valid block really marks it\n"); + fake_chip_reset(); + dhara_nand_mark_bad(&s_ut_nand, 7u); + CHECK(g_fake_mark_bad_cnt == 1u, "valid mark_bad reaches the driver once"); + CHECK(g_fake_mark_bad_log[0] == 7u, "driver receives the expected block number"); + CHECK(dhara_nand_is_bad(&s_ut_nand, 7u) == 1, "marked block now reports bad"); +} + +/* ===================== TC-NP-700:dhara map 级场景(真链路) ===================== + * + * 用真实 dhara_map_* 跑一轮"写入 -> sync -> 重新 resume -> 读回",断言: + * - 数据往返一致; + * - **全程双编程计数为 0**:is_free 一旦退回"只看页首 64 B",journal 会把 + * 已编程的检查点页当空闲再编程一次,这个计数立刻非 0(N-12 的场景级证据)。 + */ +static void tc_dhara_scenario(void) { + static uint8_t wr[FAKE_PAGE_SIZE]; + static uint8_t rd[FAKE_PAGE_SIZE]; + dhara_error_t err; + int rc; + + printf("[TC-NP-700] dhara map write -> resume -> read round-trip\n"); + fake_chip_reset(); + s_initialized = 0; + CHECK(nand_ftl_init() == 0, "first init on a blank chip creates the volume"); + CHECK(s_nand.num_blocks == (unsigned int)FAKE_USABLE_BLOCKS, + "dhara sees gd5f_get_usable_blocks() blocks"); + + fake_fill_pattern(wr, FAKE_PAGE_SIZE, 0x5EEDu); + err = DHARA_E_NONE; + rc = dhara_map_write(&s_map, 0u, wr, &err); + CHECK(rc == 0, "dhara_map_write sector 0 -> 0"); + err = DHARA_E_NONE; + rc = dhara_map_write(&s_map, 1u, wr, &err); + CHECK(rc == 0, "dhara_map_write sector 1 -> 0"); + err = DHARA_E_NONE; + rc = dhara_map_sync(&s_map, &err); + CHECK(rc == 0, "dhara_map_sync -> 0"); + CHECK(dhara_map_size(&s_map) == 2u, "map size = 2 sectors after two writes"); + CHECK(g_fake_double_program_cnt == 0u, "write phase: zero double-program violations"); + + /* 模拟掉电重启:FTL 状态复位后重新 resume(同一块假芯片,内容保留) */ + nand_ftl_deinit(); + CHECK(nand_ftl_init() == 0, "re-init resumes the volume"); + CHECK(g_fake_isfree_scan_cnt > 0u, + "resume really consulted dhara_nand_is_free (64B scan fingerprint > 0)"); + CHECK(dhara_map_size(&s_map) == 2u, "resumed map still holds 2 sectors"); + memset(rd, 0, sizeof(rd)); + err = DHARA_E_NONE; + rc = dhara_map_read(&s_map, 0u, rd, &err); + CHECK(rc == 0, "read sector 0 after resume -> 0"); + CHECK(memcmp(rd, wr, FAKE_PAGE_SIZE) == 0, "sector 0 data survives the resume"); + memset(rd, 0, sizeof(rd)); + err = DHARA_E_NONE; + rc = dhara_map_read(&s_map, 1u, rd, &err); + CHECK(rc == 0, "read sector 1 after resume -> 0"); + CHECK(memcmp(rd, wr, FAKE_PAGE_SIZE) == 0, "sector 1 data survives the resume"); + CHECK(g_fake_double_program_cnt == 0u, + "write + resume scenario: zero double-program violations (N-12 scenario guard)"); +} + +/* ===================== main ===================== */ + +int main(void) { + s_ut_nand.log2_page_size = 11u; + s_ut_nand.log2_ppb = 6u; + s_ut_nand.num_blocks = (unsigned int)FAKE_USABLE_BLOCKS; + + printf("=== NAND Port Tests ===\n"); + printf(" fake chip: %u blocks x %u pages x %u B; dhara sees %u blocks = %lu pages\n", + FAKE_BLOCKS, FAKE_PAGES_PER_BLOCK, FAKE_PAGE_SIZE, + s_ut_nand.num_blocks, (unsigned long)UT_TOTAL_PAGES); + + fake_chip_reset(); + CHECK(FAKE_TOTAL_PAGES == 2048u, "TC-NP-000 fake chip geometry (32 x 64 pages)"); + CHECK(fake_page_all_ff(0u), "TC-NP-000b power-on state is all 0xFF"); + CHECK(UT_TOTAL_PAGES == ((dhara_page_t)FAKE_USABLE_BLOCKS * FAKE_PAGES_PER_BLOCK), + "TC-NP-000c guard bound = num_blocks << log2_ppb"); + + printf("-- dhara_nand_is_free: whole-page scan (N-12) --\n"); + tc_is_free(); + + printf("-- dhara_nand_is_free: out-of-range guard (N-13) --\n"); + tc_is_free_oob(); + + printf("-- dhara_nand_prog --\n"); + tc_prog(); + + printf("-- dhara_nand_read --\n"); + tc_read(); + + printf("-- dhara_nand_copy --\n"); + tc_copy(); + + printf("-- dhara_nand_erase --\n"); + tc_erase(); + + printf("-- dhara_nand_is_bad / dhara_nand_mark_bad --\n"); + tc_bad_block(); + + printf("-- dhara map scenario (real dhara, fake chip) --\n"); + tc_dhara_scenario(); + + printf("\n=== NAND Port Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/nand_port/run_unit_test.py b/test/host/nand_port/run_unit_test.py new file mode 100644 index 00000000..169fe6a6 --- /dev/null +++ b/test/host/nand_port/run_unit_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""dhara NAND 移植层(nand_ftl)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/nand_port/nand_port_test.c(它以 #include 纳入 + **生产源文件** Drivers/BSP/GD5F2GQ5UE/nand_ftl.c),在 PC 上离线跑 dhara NAND + HAL 六个入口(is_bad / mark_bad / erase / prog / is_free / read / copy)的 + 几何守卫与整页判空,失败即非 0 退出。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用板端套件): + 移植层是**纯地址算术 + 边界判定**,芯片侧只经 11 个 SPI 原语打交道——把它换成 + 一块 RAM 假芯片,整层就完全可在 PC 上跑。而它要防的两类缺陷恰恰是真机极难复现的: + - N-12:is_free 只看页首 64 B → "页首 0xFF、后段有数据"的页被误判空闲 → + dhara 往已编程页再编程 = 静默数据丢失(真机要恰好撞上那种页形态才发作); + - N-13:越界页/块号(含 DHARA_PAGE_NONE 哨兵)被地址位截断成真实页 → + 静默读写/搬走无辜数据(真机上没有任何路径会主动喂进越界号)。 + 假芯片把"双编程"做成可数事件、并记录全部访问次数,于是"越界不产生任何芯片 + 访问""越界标坏块次数为 0"这类判据可机械断言——这是板端永远拿不到的观测面。 + +用法(仓库根目录): + python test/host/nand_port/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/nand_port/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/问题记录/项目审查与优化建议报告.md N-12/N-13(报告 R-3 建议的 +`test/host/dhara/` 移植层单测,落位为 test/host/nand_port/); +创建日期:2026-10-15 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 顺序敏感:stub 目录必须**先于**生产目录,才能覆盖 gd5f2gq5ue.h(真实头会拖入 +# main.h + 整套 ST HAL/CMSIS,PC 上不可编译)与 dbg_log.h(真实头依赖 sys_clock)。 +# nand.h / journal.h / map.h / error.h(Lib/dhara)与 ff.h / diskio.h / ffconf.h +# (Lib/FatFs)用**真实头**——接口契约原汁,桩与真实 API 一旦漂移编译期即报错。 +BUILD = TestBuild( + name="NAND Port Tests", + src=os.path.join(HERE, "nand_port_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "GD5F2GQ5UE"), + os.path.join(ROOT, "Lib", "dhara"), + os.path.join(ROOT, "Lib", "FatFs"), + HERE, + ], + summary_marker="=== NAND Port Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/nand_port/stub/dbg_log.h b/test/host/nand_port/stub/dbg_log.h new file mode 100644 index 00000000..1c70791e --- /dev/null +++ b/test/host/nand_port/stub/dbg_log.h @@ -0,0 +1,38 @@ +/* + * 主机侧单测桩:dbg_log.h + * 模块功能:为 Drivers/BSP/GD5F2GQ5UE/nand_ftl.c 提供主机可编译的日志宏 + * (DBG_* 映射为计数,不真正打印,避免污染测试输出)。 + * + * ⚠ 仅供 test/host/nand_port/ 使用,不参与 MCU 构建。 + * MCU 版(Drivers/BSP/dbg_log.h)依赖 sys_clock(RTC 时间戳)与 console, + * 并在 DBG_ENABLE_* 开关下展开成行缓冲 + 时间戳格式化。 + * + * 被测源在 `#include "dbg_log.h"` 之前自定义了 DBG_LINE_BUF_MAX / DBG_TAG, + * 本桩不使用它们(宏重定义无副作用:本桩不定义同名宏)。 + * + * 为什么计数而不是直通 printf: + * nand_ftl.c 的日志只在 disk_initialize / 缓存回写路径上,本套件测的是 + * dhara HAL 六个入口;直通打印会让"越界不产生任何芯片访问"这类断言的 + * 输出被日志淹没。留计数则仍可断言"错误路径确有留痕"。 + * + * 创建日期:2026-10-15 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +#include + +/* 测试观测计数(在 nand_port_test.c 实现): + * g_nandtest_err_count —— DBG_ERROR 次数 + * g_nandtest_info_count —— DBG_INFO 次数 + * g_nandtest_debug_count —— DBG_DEBUG 次数 */ +extern int g_nandtest_err_count; +extern int g_nandtest_info_count; +extern int g_nandtest_debug_count; + +#define DBG_INFO(fmt, ...) do { g_nandtest_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { g_nandtest_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { g_nandtest_err_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/nand_port/stub/gd5f2gq5ue.h b/test/host/nand_port/stub/gd5f2gq5ue.h new file mode 100644 index 00000000..5fa2d141 --- /dev/null +++ b/test/host/nand_port/stub/gd5f2gq5ue.h @@ -0,0 +1,84 @@ +/* + * 主机侧单测桩:gd5f2gq5ue.h(GD5F2GQ5UE SPI NAND 驱动 API) + * 模块功能:为 Drivers/BSP/GD5F2GQ5UE/nand_ftl.c 提供**可在 PC 上编译链接**的 + * 芯片几何常量与 SPI 原语声明;实现体(RAM 假芯片)在 + * nand_port_test.c 里给出。 + * + * ⚠ 仅供 test/host/nand_port/ 使用,不参与 MCU 构建。 + * 真实头 Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h 会 `#include "main.h"` 拖入整套 + * ST HAL / CMSIS(GPIO、SPI、HAL_GetTick),主机侧不引入。 + * + * 保真点(与真实头逐项对齐,只有容量是刻意缩小的): + * - 几何常量 GD5F_PAGE_SIZE / GD5F_PAGES_PER_BLOCK / GD5F_BLOCK_SIZE / + * GD5F_SPARE_SIZE / GD5F_BBT_POOL_COUNT **同名同值**; + * - GD5F_TOTAL_BLOCKS 真实 2048(256 MB)→ 本桩 **32**(4 MB), + * 够跑 dhara map/journal 与全部越界用例; + * - 返回值 GD5F_OK / GD5F_ERROR / GD5F_PROGRAM_FAIL / GD5F_ERASE_FAIL 同值; + * - SPI 原语**签名逐字照抄**真实头(gd5f_page_read/gd5f_read_from_cache/ + * gd5f_program_load/gd5f_program_exec/gd5f_block_erase/gd5f_check_ecc/ + * gd5f_write_enable/gd5f_dma_buf_ok/gd5f_get_usable_blocks/ + * gd5f2gq5ue_is_block_bad/gd5f2gq5ue_mark_block_bad), + * 桩与真实 API 一旦漂移,编译期即报错。 + * + * 刻意**不搬**的部分:SPI 命令码 / 状态位 / 控制引脚宏 / DMA 窗口常量与 + * 编译期守卫(nand_ftl.c 不使用它们;DMA 可达性在 PC 上没有物理意义, + * 由假芯片的 gd5f_dma_buf_ok 恒返 1 代替)。 + * + * 创建日期:2026-10-15 + */ + +#ifndef GD5F2GQ5UE_H +#define GD5F2GQ5UE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ======================== 芯片几何(与真实头同名同值,除总块数) ======================== */ + +#define GD5F_PAGE_SIZE 2048 +#define GD5F_SPARE_SIZE 64 +#define GD5F_TOTAL_PAGE_SIZE 2112 +#define GD5F_PAGES_PER_BLOCK 64 +#define GD5F_BLOCK_SIZE (GD5F_PAGES_PER_BLOCK * GD5F_PAGE_SIZE) + +/* 真实 2048 块(256 MB)→ 假芯片 32 块(4 MB),见文件头说明 */ +#define GD5F_TOTAL_BLOCKS 32 +#define GD5F_TOTAL_SIZE (GD5F_TOTAL_BLOCKS * GD5F_BLOCK_SIZE) + +/* 坏块表保留块池(真实头同值):dhara 可见块数 = 总块数 - 本值 */ +#define GD5F_BBT_POOL_COUNT 4 + +/* ======================== 返回值(与真实头同值) ======================== */ + +#define GD5F_OK 0 +#define GD5F_ERROR -1 +#define GD5F_BUSY_TIMEOUT -2 +#define GD5F_ECC_ERROR -3 +#define GD5F_PROGRAM_FAIL -4 +#define GD5F_ERASE_FAIL -5 +#define GD5F_ID_MISMATCH -6 + +/* ======================== SPI 原语(签名逐字照抄真实头) ======================== */ + +int32_t gd5f_write_enable(void); +int32_t gd5f_page_read(uint32_t page_addr); +int32_t gd5f_read_from_cache(uint16_t column, uint8_t *p_buf, uint32_t size); +int32_t gd5f_program_load(uint16_t column, const uint8_t *p_buf, uint32_t size); +int32_t gd5f_program_exec(uint32_t page_addr); +int32_t gd5f_block_erase(uint32_t block_addr); +int32_t gd5f_check_ecc(void); + +int32_t gd5f2gq5ue_is_block_bad(uint32_t block); +void gd5f2gq5ue_mark_block_bad(uint32_t block); +uint32_t gd5f_get_usable_blocks(void); +int gd5f_dma_buf_ok(const void *p_buf, size_t size); + +#ifdef __cplusplus +} +#endif + +#endif /* GD5F2GQ5UE_H */