fix(storage): dhara 移植层 is_free 整页精度 + HAL 越界守卫;日志读写批量化

登记簿 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 全库同步。
This commit is contained in:
2026-10-15 12:34:39 +08:00
parent 8739faa7fe
commit 720f841494
22 changed files with 1830 additions and 154 deletions
+28 -6
View File
@@ -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)
## 相关文档
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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))
+38
View File
@@ -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 <stdio.h>
/* 测试观测计数(在 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 */
+84
View File
@@ -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 <stddef.h>
#include <stdint.h>
#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 */