Files
STM32F4-Base/docs/架构设计/STM32F4-Base存储架构说明.md
T
super_agent_bot 72b4b0943c docs: clear task and layer shorthand IDs
Replace task/batch shorthand IDs and residual layer labels in
human-facing docs with semantic phrases. Add the new cleanup
policy to docs/编号规范.md and update the entry docs.

Machine keys (TC/CODE-RULE/MENU-STATE/TRAP/FR), hardware
designators/pins/MAC, protocol/version numbers and frozen
historical evidence are intentionally kept.

Docs only; no code behavior change. Verified with
check_doc_links.py --all and git diff --check.
2026-11-02 03:33:51 +08:00

658 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# STM32F4-Base 存储架构说明
## 1. 概述
本 项目在 GD5F2GQ5UE SPI NAND Flash(256MB)上实现了三层存储软件栈:
![存储软件栈分层(应用 → FatFs → dhara FTL → 驱动 → SPI1)](../images/存储栈分层.svg)
> 上图由 `tools/arch_diagram/gen_storage_stack_svg.py` 生成(手绘 SVG,布局确定、层间单向箭头);改分层后编辑脚本顶部 `L_*` / `ROWS` 数据重跑即可。
![物理分区布局(256MB 单分区)](../images/物理分区布局.svg)
> 上图由 `tools/arch_diagram/gen_partition_svg.py` 生成(分区宽 ∝ MB 数 + 偏移标尺);改分区后编辑脚本顶部 `FIELDS` 数据重跑即可。
**数据路径:**
- **应用** → `FatFS` → `dhara FTL` → `gd5f2gq5ue 驱动`。FTL 提供 LBA 到物理页的映射、磨损均衡、坏块管理和垃圾回收,FatFS 通过标准 `disk_*` 接口访问 FTL 提供的块设备。
**初始化顺序:**
```
HAL_Init → SystemClock_Config → MX_GPIO/DMA/USART1/SPI2/SPI1/I2C1/UART4/IWDG/ADC1/TIM3_Init
→ app_main_init() // 应用层初始化(全程内核前)
├─ syslog_mcu_init() // 日志先进 RAM 暂存(内核前安全空操作)
├─ sd2506_init() → sys_clock_init() // 时间子系统优先(墙钟基准)
├─ rs485_init()
├─ gd5f2gq5ue_init() // NAND 底层驱动(含 BBT 全片扫描 + ECC 使能)
├─ tpafe5160_init() → adc_input_init() // TPAFE + 语义取数与有效性层(语义映射 + ADC1 DMA)
├─ phasor_init() / freq_init() / prot_set_init() + prot_elm_init() // 全周傅氏相量/保护判据内核前就绪
├─ SD2506 诊断(ID/时间/温度/电池)
└─ HAL_TIM_Base_Start_IT(&htim3) // 启动 TIM3 1kHz 采样节拍
→ osKernelInitialize() → MX_FREERTOS_Init() → osKernelStart() // FreeRTOS 启动
├─ defaultTask → f_mount() → 卷健康探针/自愈 + 配置引擎 + syslog 落盘
├─ netTask → 网络协议栈轮询(等 STORAGE_READY)
├─ ftpTask → lftpd_start("/", 21)(等 NET_READY + STORAGE_READY)
├─ adcTask → TIM3 1kHz 唤醒,ADC 采集(纯消费者)
├─ iec104Task → IEC 60870-5-104 上行(默认 :2404)
├─ protectTask → 保护判据 20ms 节拍
└─ rs485Task → 未创建(RS-485 暂缓,见 docs/任务规划/暂缓-RS485.md)
```
---
## 2. 硬件规格:GD5F2GQ5UE
| 参数 | 值 |
|------|-----|
| 容量 | 256 MB (2 Gb) |
| 页大小 (主区) | 2048 bytes (2 KB) |
| 备校区 (Spare) | 64 bytes/页 |
| 每块页数 | 64 |
| 块大小 | **128 KB** (64 × 2 KB) |
| 总块数 | 2048 |
| 总页面数 | 131072 |
| 内部 ECC | 支持 (每页 8bit ECC) |
| 接口 | SPI (Mode 0, CPOL=0 CPHA=0) |
| 最高时钟 | 42 MHz |
| 读页延迟 | < 60 μs |
| 编程延迟 | < 600 μs |
| 块擦除延迟 | < 3 ms |
**SPI 引脚分配:**
| 信号 | GPIO | 说明 |
|------|------|------|
| CS# | PE0 | 片选 (低有效) |
| SCK | PB3 | SPI1 SCK |
| MISO | PB4 | SPI1 MISO (主入从出) |
| MOSI | PB5 | SPI1 MOSI (主出从入) |
| WP# | PB8 | 写保护 (本驱动恒拉高) |
| HOLD# | PE1 | 保持 (本驱动恒拉高) |
---
## 3. 分区布局
单个分区,配置在 `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c`:
```c
#define FTL_FATFS_OFFSET 0 // 偏移 0,占用全部 256MB
```
### 3.1 分区对照表
| 分区名 | 偏移 | 大小 | 物理块范围 | 逻辑用途 |
|--------|------|------|-----------|----------|
| `ftl_fatfs` | 0 | 256 MB | Block 0~2047 | dhara FTL + FatFS |
### 3.2 配置联动
- **FTL** — `nand_ftl.c` 中 `FTL_START_BLOCK = 0`,`FTL_NUM_BLOCKS = 2048`
- **FatFS** — 通过 `disk_ioctl(GET_SECTOR_COUNT)` 获取 FTL 管理的扇区数
---
## 4. 驱动层:gd5f2gq5ue.c/h
### 4.1 SPI 命令集
| 命令 | 编码 | 用途 |
|------|------|------|
| `WRITE_ENABLE` | `0x06` | 写使能 (每个写/擦除前必须发送) |
| `WRITE_DISABLE` | `0x04` | 写禁止 |
| `GET_FEATURE` | `0x0F` | 读状态/feature 寄存器 |
| `SET_FEATURE` | `0x1F` | 写 feature 寄存器 (前需写使能) |
| `READ_ID` | `0x9F` | 读芯片 ID |
| `PAGE_READ` | `0x13` | 将页数据从存储阵列载入内部缓存 |
| `READ_FROM_CACHE` | `0x0B` | 从内部缓存读取数据 |
| `PROGRAM_LOAD` | `0x02` | 将数据写入内部缓存 |
| `PROGRAM_EXEC` | `0x10` | 将缓存数据编程到存储阵列 |
| `BLOCK_ERASE` | `0xD8` | 块擦除 (128KB) |
| `RESET` | `0xFF` | 芯片复位 |
### 4.2 Feature 寄存器
| 地址 | 名称 | 说明 |
|------|------|------|
| `0xA0` | Protect | 块保护 (写入 `0x00` 解除全部保护) |
| `0xB0` | Feature | ECC 使能 (bit4=1 启用) |
| `0xC0` | Status | 状态标志 (OIP/WEL/E_FAIL/P_FAIL/ECC) |
| `0xD0` | Driver | 驱动强度 |
### 4.3 初始化序列
```
1. RESET (FFh)
└─ 等待 5ms
2. READ_ID (9Fh)
└─ 校验 MID=0xC8, DID=0x52
3. BBT 扫描
└─ 读每块第一页 (page 0) spare byte 0(列地址 0x800,`docs/芯片手册/GD5F2GQ5UExxG.md` §12.4 初始坏块标记位)
└─ 非 0xFF 即出厂坏块,写入 s_bbt[]
4. SET_FEATURE (B0h=10h) — 使能内部 8bit ECC
5. SET_FEATURE (A0h=00h) — 解除全部块保护
```
### 4.4 读写擦除操作
**页读取**(任何字节偏移均可,驱动自动定位到页):
```
PAGE_READ (13h + 3字节行地址) → 等待 OIP 清零 → READ_FROM_CACHE (0Bh + 2字节列地址 + dummy)
```
**页编程**(NAND 只能将 bit 从 1→0 翻转,编程前必须擦除):
```
WRITE_ENABLE → PROGRAM_LOAD (02h + 列地址 + 数据) → PROGRAM_EXEC (10h + 行地址) → 等待完成 → 检查 P_FAIL
```
**块擦除**(最小擦除单位 128KB,参数是字节地址而非块编号):
```
WRITE_ENABLE → BLOCK_ERASE (D8h + 3字节地址) → 等待完成 → 检查 E_FAIL
```
### 4.5 DMA 传输策略(2026-07-21 新增)
SPI1 使用 DMA2 实现页数据级别的不阻塞传输,配置如下:
| 通道 | DMA | 流 | 通道 | 方向 | 优先级 | 模式 |
|------|-----|----|------|------|--------|------|
| SPI1_RX | DMA2 | Stream 0 | CH3 | 外设→内存 | LOW | NORMAL |
| SPI1_TX | DMA2 | Stream 3 | CH3 | 内存→外设 | LOW | NORMAL |
**传输策略:**
- **小包轮询(≤ 32 bytes)**:命令字、地址、状态寄存器等短数据使用 `HAL_SPI_Transmit/Receive` 轮询模式,避免 DMA 初始化开销
- **页数据 DMA(> 32 bytes)**:`READ_FROM_CACHE` 和 `PROGRAM_LOAD` 的页数据段使用 `HAL_SPI_Receive_DMA` / `HAL_SPI_Transmit_DMA`
**HAL 内部路由说明:**
`HAL_SPI_Receive_DMA()` 在 2 线 Master 模式内部调用 `HAL_SPI_TransmitReceive_DMA()`,但由于 SPI 状态设置为 `HAL_SPI_STATE_BUSY_RX`,HAL 的完成回调为 `HAL_SPI_RxCpltCallback`(而非 `TxRxCpltCallback`),需确保该回调在 `gd5f2gq5ue.c` 中实现。
**完整回调链条:**
| 操作 | 触发回调 | 实现位置 |
|------|---------|---------|
| `PROGRAM_LOAD` TX DMA | `HAL_SPI_TxCpltCallback` | `gd5f2gq5ue.c` |
| `READ_FROM_CACHE` RX DMA | `HAL_SPI_RxCpltCallback` | `gd5f2gq5ue.c` |
| 任意外设错误 | `HAL_SPI_ErrorCallback` | `ch395f.c`(含 SPI1 分支) |
**性能(顺序 128KB 读写,FatFS + FTL):**
| 模式 | 写 | 读 |
|------|----|----|
| 轮询(改造前) | 330 KB/s | 547 KB/s |
| DMA(改造后) | **831 KB/s** | **1855 KB/s** |
| 42MHz SPI 理论极限 | ~5.25 MB/s(受 NAND tPROG ≈ 500μs/页 限制) | ~5.25 MB/s |
DMA 消除了轮询模式下 SPI 状态寄存器查检的逐字节 CPU 开销,读写性能分别提升 **2.5×** 和 **3.4×**。进一步优化需考虑 Cache Read 模式(重叠 NAND 内部 tR 延迟)或批量编程(减少 tPROG 次数)。
### 4.6 BBT (Bad Block Table)
- 初始化时扫描全部 2048 块第一页(page 0)的 spare byte 0(列地址 0x800)
- `s_bbt[256]` 位图数组,1 bit 标识 1 个块 (0=好, 1=坏)
- `gd5f2gq5ue_is_block_bad(block)` — 查询坏块状态
- `gd5f2gq5ue_mark_block_bad(block)` — 标记坏块 (FTL 层在擦除/编程失败时调用)
- `gd5f2gq5ue_bbt_clear()` — 清空 BBT(慎用,仅在重建时使用)
- `gd5f_bbt_scan()` — 在 `gd5f2gq5ue_init()` 阶段、ECC 使能前扫描构建 BBT;运行时不再提供公开重扫接口(见 GD5F2GQ5UE_Trap_Records.md)
- `gd5f2gq5ue_print_bbt()` — 打印 BBT 摘要(调试用)
> **注意**:`gd5f_private_bbt_scan()` 选择在 ECC 使能前扫描。按`docs/芯片手册/GD5F2GQ5UExxG.md` §12.6,ECC 使能后整段 spare 仍可读取(仅 0x840~0x87F 的 ECC 校验区禁止编程),因此读取 0x800(spare byte 0)即便 ECC 开启也安全;但扫描放在 ECC 禁用阶段是更稳妥的默认做法。运行时坏块只来源于真实擦除/编程失败,不要主动重新扫描。
### 4.7 页内寻址模型(行地址 / 列地址)
NAND 访问是**二维寻址**,两个地址含义不同:
| 维度 | 含义 | 由谁发出 | 决定什么 |
|------|------|----------|----------|
| 行地址(页地址 / page address) | 第几页 | `gd5f_page_read(page)` | 把哪一页从存储阵列搬进芯片内部 cache 寄存器 |
| 列地址(column address) | 该页内第几个字节 | `gd5f_read_from_cache(column, ...)`(发送 `0Bh` + 2 字节列地址) | 从 cache 里偏移 `column` 处开始吐数据 |
**一页的内部布局**(`docs/芯片手册/GD5F2GQ5UExxG.md` §12.6 Table 12-8):一页 = 2KB 主数据 + 64B 备用区(spare) = 2112 字节,页内连续编址为 column 0 ~ 2111:
```
page (2112B) = main(2048B) + spare(64B)
column: 0 2047 2048 2111
↑
spare[0] / 坏块标记位 (0x800)
```
- `column 0 ~ 2047` → 主数据区(main data)
- `column 2048 ~ 2111` → 备用区(spare),其中 **2048(0x800)就是 spare 的第 0 字节**
**为什么坏块标记的列地址是 2048**:main 区占据了前 2048 字节,spare 区从 2048 起算,故 spare byte 0 落在列地址 2048(0x800)。驱动把 `GD5F_PAGE_SIZE`(=2048)作为 `column` 传给 `gd5f_read_from_cache()`(`gd5f2gq5ue.c:307-308` 将其拼成 2 字节列地址),芯片即从 spare[0] 开始回数据——这正是出厂坏块标记位(`docs/芯片手册/GD5F2GQ5UExxG.md` §12.4 / Table 12-6:First spare area location = Byte 2048)。
> 一句话:**page 选页,column 选页内字节偏移;main 占前 2048 字节,spare 从 0x800 起算。**
### 4.8 BBT 持久化存储(2026-08-27 新增)
**需求背景**:出厂坏块标记写在每块首页 `spare[0]`(page 0),但 NAND 的**块擦除会同时清空主区与 spare 区**,因此运行时坏块(擦除/编程失败产生)无法回写到原厂标记位——否则该块一旦被擦除,坏块标记即丢失。必须把运行期坏块信息存到独立的、不参与擦写的**保留区域**,并在每次上电时加载回来。
**保留块池(BBT Pool)**:
- 从 NAND 顶部向下挑选 `GD5F_BBT_POOL_COUNT`(=4)个**出厂好块**作为 BBT 保留池,不交给 dhara 管理。
- `gd5f_private_bbt_locate_pool()` 在 init 阶段从 `GD5F_TOTAL_BLOCKS-1` 向下收集前 4 个 `s_bbt[b]==0`(出厂好)的块号,存入 `s_bbt_pool_blocks[]`,并把 `s_usable_blocks` 设为其中**最低的块号**。
- dhara 看到的可用块数 = `gd5f_get_usable_blocks()` = `s_usable_blocks`,即保留池以下的块;整个保留池(顶部 4 块)对文件系统不可见。文件系统可用块数由 2048 降为约 2044(若顶部块含出厂坏块则略少)。
**闪存上的 BBT 记录格式**(每个保留块 page 0):
```
偏移 长度 字段
0 4 magic = "GBBT"
4 4 version (uint32 LE, 单调递增)
8 4 len (uint32 LE, = GD5F_BBT_SIZE = 256)
12 4 crc32 (uint32, 对下方 bitmap 计算, 多项式 0xEDB88320)
16 256 bitmap (GD5F_BBT_SIZE 字节, 1 bit / 块, 与 RAM s_bbt 同布局)
────────────────────────────────────
合计 272 字节 (GD5F_BBT_HDR_SIZE + GD5F_BBT_SIZE)
```
**写入(掉电安全,轮转)— `gd5f_bbt_save()`**:
- 版本号自增:`ver = s_bbt_version + 1`
- 轮转写入下一保留槽 `s_bbt_write_slot`(环形指针,遇损坏槽 `s_bbt_slot_dead` 跳过)
- 先 `BLOCK_ERASE` 目标保留块,再 `PROGRAM_LOAD(0)` 写入整页(header+bitmap),最后 `PROGRAM_EXEC`
- 若某保留块自身擦除/编程失败,置 `s_bbt_slot_dead` 对应位并跳到下一槽
- 每次 `gd5f2gq5ue_mark_block_bad()` 标记新坏块后都会调用 `gd5f_bbt_save()`;写入极罕见(仅坏块事件触发),磨损可忽略
**加载 — `gd5f_bbt_load()` → `gd5f_private_bbt_find_best()`**:
- 遍历全部保留块,校验 `magic` / `len` / `crc32`,丢弃损坏副本
- 选出**版本号最高**的有效副本,将其 bitmap `OR` 进 RAM `s_bbt`(RAM 此时已含出厂坏块,`gd5f_bbt_scan` 先于本步执行)
- 记录 `s_bbt_version` 与下一轮转槽 `s_bbt_write_slot`
- 掉电保护:每次写入都是"擦除 + 整页编程"的完整新副本;若写入中途掉电,旧的高版本有效副本仍在,上电时按最高版本选取,不会读到半成品
**上电初始化顺序(与 §4.3 对应补充)**:
```
1. RESET → READ_ID → 校验 MID/DID
2. gd5f_bbt_scan() // 出厂坏块扫描 (ECC 关闭), 填充 s_bbt
3. SET_FEATURE(0xB0,0x10) // 使能内部 8bit ECC
4. SET_FEATURE(保护,0x00) // 解除块保护
5. gd5f_private_bbt_locate_pool() // 选保留池, 设定 s_usable_blocks
6. gd5f_bbt_load() // 叠加持久化的运行期坏块
```
**调试 / 测试 API**:
| 函数 | 说明 |
|------|------|
| `gd5f_bbt_dump_flash(p_bbt, p_version)` | 读取闪存中当前生效的 BBT 位图与版本(验证持久化用) |
| `gd5f_bbt_wipe_pool()` | 擦除保留块池(撤销持久化,回到仅出厂扫描态;出厂重置/测试用) |
| `gd5f_bbt_reload()` | 从保留池重新加载持久化 BBT 到 RAM |
**关键常量**:
| 宏 / 变量 | 位置 | 值 | 说明 |
|-----------|------|----|------|
| `GD5F_BBT_POOL_COUNT` | `gd5f2gq5ue.h` | 4 | 保留块数量 |
| `GD5F_BBT_SIZE` | `gd5f2gq5ue.c` | 256 (`=TOTAL_BLOCKS/8`) | 位图字节数(覆盖 2048 块,每块 1 bit) |
| `GD5F_BBT_HDR_SIZE` | `gd5f2gq5ue.c` | 16 | 头部长度(magic+ver+len+crc) |
| `s_bbt_magic` | `gd5f2gq5ue.c` | `"GBBT"` | BBT 存储魔数 |
---
## 5. dhara FTL (Flash Translation Layer)
### 5.1 概述
FTL 是 NAND Flash 上方最重要的组件,功能包括:
| 功能 | 说明 |
|------|------|
| **地址映射** | 将 FatFS 的逻辑扇区号 (LBA) 映射到 NAND 物理页 |
| **磨损均衡** | 均匀分布擦除次数,延长芯片寿命 |
| **垃圾回收** | 回收无效页面空间 (GC) |
| **坏块管理** | 擦除/编程失败时自动跳过并标记坏块 |
| **ECC 处理** | 检测和上报硬件 ECC 不可纠正错误 |
### 5.2 数据结构
```c
struct dhara_nand {
int log2_page_size; // 页大小对数 (2KB → 11)
int log2_ppb; // 每块页数对数 (64 → 6)
int num_blocks; // 管理的物理块数 (1024)
};
struct dhara_map {
// 内部状态:GC 队列、journal、映射表等
};
```
### 5.3 NAND HAL (nand_ftl.c)
dhara 通过 7 个回调函数访问物理 NAND:
| 回调 | 功能 | 对应底层函数 |
|------|------|-------------|
| `dhara_nand_is_bad` | 查询坏块 | `gd5f2gq5ue_is_block_bad(block + 1024)` |
| `dhara_nand_mark_bad` | 标记坏块 | `gd5f2gq5ue_mark_block_bad(block + 1024)` |
| `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` | 检查页空闲 | **整页扫描 2048 B 判全 0xFF**(栈上 64 B 缓冲分块读;页首命中即早退,见下方 ⚠) |
| `dhara_nand_copy` | 页拷贝 (GC 用) | read + prog 组合 |
所有回调自动将 dhara 逻辑块/页加 `FTL_START_BLOCK` 偏移转换为物理地址。
⚠ **两条移植层硬约束(2026-10-15)**:
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(教训:宁报错不销毁数据)。
### 5.4 初始化流程 (disk_initialize)
```
1. 设置 nand 参数 (log2_page_size=11, log2_ppb=6, num_blocks=1024)
2. dhara_map_init(&s_map, &s_nand, s_page_buf, 4)
├─ s_page_buf: dhara 内部使用的 2KB 工作缓冲区
└─ 4: journal 页面数量 (影响 GC 效率, 增大可减少写入放大)
3. dhara_map_resume(&s_map)
├─ 成功: 加载已有映射表
└─ 失败: dhara_map_clear 创建空映射表
4. 页面缓存初始化 (s_cache_buf, s_cached_lpn, s_cache_dirty)
```
#### 5.4.1 resume 的 epoch 锚点 —— 回绕后必须锚"最新"而非"最高地址"(2026-10-02 修复)
> ⚠️ **这是"复位后所有写入全部丢失"的曾经真凶**,排查存储丢失时**第一顺位**检查它。
**背景**:dhara 的 checkpoint 头页带 1 字节 `epoch`,journal 每绕满一圈
(`roll_stats()`,即 head 回绕到 0)`epoch++`。resume 时用 epoch 过滤,只认"当前圈"的
checkpoint。
**坑**:环**回绕之后**,"最高地址"的 checkpoint 属于**上一圈**(旧 epoch),而**最新**
checkpoint 落在**低地址**(块 0 一侧,epoch = 旧 + 1)。若把锚点取成
`find_last_checkblock_any()`(= 最高地址块),就会锚到**旧 epoch**,随后的
`find_last_checkblock()` 只能选中旧块 → `find_head()` 在旧块里找空页 → **journal 整体回退到
回绕之前**,回绕之后写的一切(日志、配置、FTP 上传)复位即丢。
(2026-09-20 的本地补丁正是这么改的,用于修"自愈卷重格式化每次开机丢失",属顾此失彼的回归。)
**现行实现**(`Lib/dhara/journal.c`,代码内附详细注释)—— 比较两个候选 epoch:
| `eFirst`(最低地址块)与 `eLast`(最高地址块) | 含义 | 锚点 |
|---|---|---|
| 相等 | 未回绕 | 相同,任取 |
| `eFirst == eLast + 1 (mod 256)` | **已回绕,低地址更新** | **`eFirst`** |
| 其它 | 低地址是上一圈残留 | `eLast` |
**可观测性**:`NAND_FTL` 开机加打一行 `Map head=… tail=… epoch=…`。
**只看 `Map resumed: N/M sectors used` 判断不出回退**(覆盖写不改 `m->count`,
实测该值在回退期间连续 2 小时一字不差);判据是 **head 复位后是否倒退**。
台架实测:回退时 head 96 → **130752**、tail 115143 → 115068;修复后 head=96 / epoch=1。
### 5.5 页面缓存策略
FTL 之上还有一个 **单页写回缓存 (write-back cache)**:
- **读命中**: 直接返回 s_cache_buf 数据
- **读未命中**: 刷出脏页 → 读新页到缓存
- **写**: 写入缓存 → 标记脏
- **全页写入**: 直接刷出 (跳过缓存)
- **同步 (CTRL_SYNC)**: 刷出脏页 + dhara_map_sync
- **空闲 flush 兜底(2026-10-25,道F 第二轮)**: defaultTask 主循环按"存储就绪 +
卷空闲(`vol_lock_wait(0)` try)+ 周期节流(5 s)"周期性下发一次 `CTRL_SYNC`,
把"缓存留脏、之后再无调用触发 sync"的漏网脏页其掉电驻留窗封顶为一个周期;
卷锁被持有(syslog 落盘 / 配置保存 / lftpd open...close 进行中)时跳过,
**不改变文件持有期的既有语义**。clean 态 = 脏标志 + journal 双短路、零 NAND
触点;脏态成本与一次 `f_close` 的同步段同型。实现:`App/fs/vol_idle_sync.[ch]`
(单测 `test/host/vol_idle_sync/`)。
- ⚠ **边界(勿当成掉电保护)**: 本板**无掉电检测硬件**,"掉电前强制 flush"
不可行;单次写事务进行中掉电、以及 FTP STOR 文件持有期的落盘边界,均不因
空闲兜底改变。
---
## 6. FatFS 集成
### 6.1 配置 (ffconf.h)
```c
#define FF_FS_READONLY 0 // 读写模式
#define FF_USE_MKFS 1 // 启用格式化
#define FF_MIN_SS 512 // 最小扇区大小
#define FF_MAX_SS 512 // 最大扇区大小
#define FF_VOLUMES 1 // 单卷
#define FF_FS_TINY 0 // 非 tiny 模式
#define FF_FS_NORTC 1 // 无 RTC (固定时间戳)
```
### 6.2 disk I/O 接口
| 函数 | 功能 | 关键实现 |
|------|------|----------|
| `disk_initialize` | 初始化 FTL | 见 7.4 节 |
| `disk_status` | 查询状态 | 返回初始化状态 |
| `disk_read` | 读扇区 | 通过 FTL 映射读物理页 |
| `disk_write` | 写扇区 | 通过 FTL 映射写 (缓存优化) |
| `disk_ioctl` | 控制命令 | GET_SECTOR_COUNT/SIZE/BLOCK_SIZE + CTRL_SYNC |
### 6.3 容量计算
```
FTL 管理块数 = 2048 (Block 0~2047)
每块页数 = 64
每页扇区数 (512B) = 4
总扇区数 = 2048 × 64 × 4 = 524288
总容量 = 524288 × 512 = 256MB (原始容量)
FTL 开销后 ≈ 186 MB (随 GC 和 journal 使用量波动)
```
---
## 7. 完整数据流
### 7.1 写文件流程
```
f_write("hello.txt")
└─ FatFS: 计算 LBA, 写扇区
└─ disk_write(0, data, sector=100, count=2)
├─ 计算 LPN = sector / 4 = 25
├─ 缓存未命中 → ftl_flush_cache() → ftl_read_page(25)
├─ 拷贝数据到 s_cache_buf → 标记脏
└─ 全页面写入 → ftl_flush_cache()
└─ dhara_map_write(&s_map, 25, s_cache_buf)
├─ 查找页映射 (或分配新页)
├─ dhara_nand_prog(pg, data) → 物理写
└─ 更新映射表
f_close → disk_ioctl(CTRL_SYNC)
└─ ftl_flush_cache() → dhara_map_sync()
└─ 写 journal 到 NAND (持久化映射表)
```
### 7.2 f_mkfs 格式化流程
```
f_mkfs("", &opts, work, size)
├─ disk_initialize(0) → FTL 初始化
├─ disk_write: 写入引导扇区 (MBR/PBR)
├─ disk_write: 写入 FAT 表
├─ disk_write: 创建根目录
└─ disk_ioctl(CTRL_SYNC) → FTL sync
```
---
## 8. API 参考
### 8.1 底层 NAND 驱动 (gd5f2gq5ue.h)
| 函数 | 说明 |
|------|------|
| `gd5f2gq5ue_init()` | 初始化 NAND (BBT 扫描 + ECC 使能 + 块保护解除) |
| `gd5f2gq5ue_read_id(p_mid, p_did)` | 读芯片 ID |
| `gd5f2gq5ue_read(offset, buf, size)` | 读数据 (支持跨页) |
| `gd5f2gq5ue_write(offset, buf, size)` | 写数据 (支持跨页) |
| `gd5f2gq5ue_erase(offset, size)` | 块擦除 (需块对齐) |
| `gd5f2gq5ue_reset()` | 复位芯片 |
| `gd5f2gq5ue_is_block_bad(block)` | 查询坏块 |
| `gd5f2gq5ue_mark_block_bad(block)` | 标记坏块 |
| `gd5f2gq5ue_bbt_clear()` | 清空 BBT |
| `gd5f_bbt_scan()` | init 时扫描构建 BBT(内部静态) |
| `gd5f2gq5ue_print_bbt()` | 打印 BBT |
**SPI 原语(FTL 和测试共享):**
| 函数 | 说明 |
|------|------|
| `gd5f_wait_busy(timeout_ms)` | 轮询等待 OIP 清零 |
| `gd5f_write_enable()` | 发送 06h 写使能 |
| `gd5f_read_status(p_status)` | 读状态寄存器 (C0h) |
| `gd5f_page_read(page_addr)` | 页读取 (13h) |
| `gd5f_read_from_cache(column, buf, size)` | 读内部缓存 (0Bh) |
| `gd5f_program_load(column, buf, size)` | PROGRAM LOAD (02h) |
| `gd5f_program_exec(page_addr)` | PROGRAM EXEC (10h) |
| `gd5f_block_erase(block_addr)` | 块擦除 (D8h) |
| `gd5f_check_ecc()` | 检查 ECC 状态位 |
### 8.2 FTL/FatFS (via nand_ftl.c / ff.h)
| 函数 | 说明 |
|------|------|
| `f_mount(&fs, "", 1)` | 挂载文件系统 (首次调用自动初始化 FTL) |
| `f_mkfs("", &opts, work, size)` | 格式化 FAT32 |
| `f_open/f_close/f_read/f_write` | 标准文件操作 |
| `f_unlink("test.txt")` | 删除文件 |
| `disk_initialize(0)` | 显式初始化 FTL (通常由 f_mount 自动调用) |
| `nand_ftl_format()` | 直接格式化 FTL (清空映射表, 慎用) |
---
## 9. 使用示例
### 9.1 FatFS 文件操作
```c
#include "ff.h"
static FATFS fs;
static int fatfs_init(void) {
FRESULT res = f_mount(&fs, "", 1);
if (res == FR_NO_FILESYSTEM) {
/* 首次使用需格式化 */
MKFS_PARM opts = {FM_FAT32, 0, 0, 0, 0};
uint8_t work[512];
res = f_mkfs("", &opts, work, sizeof(work));
if (res != FR_OK) return -1;
res = f_mount(&fs, "", 1);
}
return (res == FR_OK) ? 0 : -1;
}
static void fatfs_write_read(void) {
FIL fil;
UINT bw, br;
const char *msg = "Hello Storage!";
char buf[32];
/* 写文件 */
f_open(&fil, "data.txt", FA_CREATE_ALWAYS | FA_WRITE);
f_write(&fil, msg, strlen(msg), &bw);
f_close(&fil);
/* 读文件 */
f_open(&fil, "data.txt", FA_READ);
f_read(&fil, buf, sizeof(buf), &br);
buf[br] = '\0';
f_close(&fil);
}
```
### 9.2 FTL 格式化
```c
#include "nand_ftl.h"
/* 注意: 此操作将清空 FTL 分区全部数据, 慎用 */
if (nand_ftl_format() == 0) {
DBG_INFO("FTL formatted");
}
```
---
## 10. 注意事项
1. **擦除对齐** — `gd5f2gq5ue_erase()` 的 offset 和 size 必须严格按 GD5F_BLOCK_SIZE (128KB) 对齐和整数倍。
2. **写前擦除** — NAND 不能原地覆写,FTL 内部自动管理擦除,但直接调用 `gd5f2gq5ue_write()` 前必须确保目标块已擦除。
3. **FTL 首个扇区** — `dhara_map_init` 的第 5 个参数 (journal 页数) 影响 GC 效率,当前为 4,增大可减少写入放大但占用更多内存。
4. **坏块传播** — FTL 在擦除/编程失败后自动调用 `dhara_nand_mark_bad` → `gd5f2gq5ue_mark_block_bad`,BBT 在 RAM 中更新并立即通过 `gd5f_bbt_save()` 持久化到保留块池;下次复位后 `gd5f_bbt_scan()` 重建出厂坏块,`gd5f_bbt_load()` 再叠加保留池中保存的运行期坏块,因此运行期坏块在掉电后依然有效(详见 §4.8)。
5. **功耗** — 擦除操作最大耗时约 5ms (驱动超时设为 5s),页编程约 600ms (超时 1s),读写操作快。在低功耗场景需注意合理安排操作时序。
6. **缓存一致性** — 单页缓存 (s_cache_buf) 仅对 FatFS 层可见,多任务读写同一文件需在应用层同步。
### 10.1 台架实证增补(2026-09,CCM 扩容 + 存储套件排障沉淀)
7. **单写者规则** — 存储由 **defaultTask 独占**:挂载 / `f_mkfs` / 卷健康探针 / `net_cfg_load` / syslog 落盘 / 配置引擎全收敛于它。netTask / uiTask **不得**直接触碰配置引擎或 FatFS。启用存储测试套件(`TEST_SUITE_STORAGE`)时存储所有权整体移交 testTask,defaultTask 必须**完全跳过**存储 bring-up(`App/task/default_task.c` 中 `#if !defined(TEST_SUITE_STORAGE)` 包裹,2026-09-25 自 `Src/freertos.c` 迁出)——否则后台文件写与测试的 `nand_ftl_deinit` 掉电模拟并发,互相踩坏 FTL/FatFs 状态(实测:mkfs 半途冻结 + BBT 被误写)。
8. **内存布局与 DMA/CCM 约束** — FreeRTOS 堆(含全部任务栈)已迁至 **CCM `0x10000000`**(64KB,直连 M4 零等待),但 **DMA1/DMA2 物理不可达 CCM**。GD5F 驱动 `GD5F_DMA_THRESHOLD = 1024`:栈上的小缓冲(dhara meta 读 132B、`dhara_nand_is_free` 64B)一律走 **CPU 轮询**、绝不经 DMA;页级大块(2048)才走 DMA 且源/目的**全部是文件作用域静态缓冲**(落 SRAM)。新增任何 DMA 收发缓冲必须①文件作用域静态②大小≥阈值③确认落 SRAM。违反任一条 = 静默数据损坏(CCM 首轮扩容实验实证)。换料注意:克隆片可能缺 SRAM3(CCM),必须重测。
9. **LVGL 绘制缓冲越界护栏** — LVGL 9.3.0 对 I1(1bpp) 整帧渲染会在其算出的 `stride*h`(160×160/8=3200) 之后**多写 1 个字节**,紧邻 `g_ui_draw_buf` 之后的 `.bss`(如 `ui_menu_view` 的 `s_frame_box`)指针最低字节被打成 `0xFF` → 野指针 → HardFault(`PC=lv_obj_get_scroll_y`)。`ui_port.c` 的 `g_ui_draw_buf` 已预留 `UI_DRAW_BUF_GUARD=32` 字节护栏吸收该越界写(逻辑尺寸仍按 `UI_FB_SIZE` 上报,渲染数学不变)。**勿删护栏、勿把缓冲缩到与邻接 `.bss` 紧邻**。Lib/LVGL 为第三方不改。
10. **FatFs 严格 8.3 短名** — 本移植 `Lib/FatFs/ffconf.h` 为 `FF_USE_LFN=0`,**只支持严格 8.3 短名**:主名 ≤8 字符、扩展 ≤3 字符、**不得以点开头**(2026-09-20 台架实证,`.fschk` 无限重格式化事故根因)。
- **推论(2026-10-24 补齐,代码侧唯一入口 `s_is_selfheal_code()`)**:既然名字/参数类错误码在 8.3 配置下**必然重现**(同名同因),它们就**严禁**被归进任何"卷/文件坏了"的自愈触发集——否则自愈会退化成反复删自己的数据。本仓落点:`App/log/sys_log_mcu.c` 的触发集只收 `FR_DISK_ERR` / `FR_INT_ERR` / `FR_NO_FILESYSTEM` / `FR_DENIED`,`FR_INVALID_NAME` / `FR_NO_PATH` / `FR_NO_FILE` / `FR_INVALID_OBJECT` 一律立即失败(逐码依据 + `Lib/FatFs/ff.c` 行号写在该函数上方资料块;`App/log/soe_store.c` 侧同源取更保守子集并互指)。**本条拦下的真事**:旧集合含名字类码,后果是路径/卷异常期间**每轮排空删一次自己的历史日志且永远修不好**,由主机单测照出(TC-SYSLOG-101 / 102、TC-SOESTORE-022b),详见 `docs/测试说明/单元测试/主机单测覆盖率口径.md` §9「第 1 轮修复处置」。。`.fschk` 会被 `ff.c` 的 `create_short` 把「点之后」当扩展域解析而超限 → `f_open` 恒返 `FR_INVALID_NAME`(6);这是**纯 RAM 里的名字判定,不碰盘**。`ff.c` `create_short` 全部拒绝点的地方已加 DEBUG 打印:`[ERR][FATFS] SFN reject (原因) seg='名字片段' bad_ch=坏字符`——非法名字调用看开机日志即可定位。自愈类逻辑**严禁**把名字/参数类错误码(`FR_INVALID_NAME` 等)归入"卷损坏"触发集(整卷重格式化不可逆)。补充:含冒号 `:` 的名字被 FatFs 先解析为**盘符前缀** → `f_open` 返回 `FR_INVALID_DRIVE`(11) 而非 `FR_INVALID_NAME`(6);两者都属合法拒绝(测试用例须同时接受)。
11. **FA_OPEN_APPEND 陷阱 / 短写** — `FA_OPEN_APPEND`(0x30) = `FA_OPEN_ALWAYS|FA_SEEKEND`,**不含 `FA_WRITE`(0x02)**——不带写位打开的文件 `f_write` 恒返回 `FR_DENIED`(7)(这是当年 `/log` 写失败的真根因);追加写必须写 `FA_WRITE | FA_OPEN_APPEND`(2026-09-20 台架实测,`Lib/FatFs` 移植版)。该移植的 `f_write` 在簇分配失败时返回 `FR_OK` + 短写(**没有错误码**)→ 判失败必须校验写回的字节数。标志位为**旧式编号**:`FA_READ`=0x01 / `FA_WRITE`=0x02 / `FA_CREATE_NEW`=0x04 / `FA_CREATE_ALWAYS`=0x08 / `FA_OPEN_ALWAYS`=0x10 / `FA_OPEN_APPEND`=0x30 / `FA_SEEKEND`=0x20(`ff.c` 内部定义),与上游 FatFs R0.14 不同,**勿照抄上游代码示例**。
12. **崩溃可观测性(勿屏蔽)** — 已开启 `configCHECK_FOR_STACK_OVERFLOW=2` + `configUSE_MALLOC_FAILED_HOOK` + HardFault 现场报告(`Src/stm32f4xx_it.c`:裸 UART 打印 PC/LR/xPSR + CFSR/HFSR/BFAR/MMFAR 后复位,对照 `.map` 定位符号)。defaultTask **1024** words、testTask **2048** words 是**实测下限,勿降**。开机日志出现 `[ERR] STACK OVERFLOW task=` / `[ERR] HARDFAULT ...` / `MALLOC FAILED` 是精确定位线索,禁止屏蔽或降级。
13. **BBT 恢复模式** — 运行期坏块会随 TC-STORAGE-03 注入累积;当介质被**误标脏**(如 DMA 误打 CCM 栈导致随机块被写坏并持久化)时,打开 `test/board/storage_test.h` 的 `ENABLE_STORAGE_RECOVERY_TESTS` 编译**恢复构建**,testTask 会执行 `gd5f2gq5ue_bbt_rebuild()`(出厂扫描清除运行期误标)+ `nand_ftl_format()` 清账后复位。恢复后容量回到基线(即扣除 BBT 保留池后的正常容量,非出厂裸容量)。
14. **lftpd 路径口径(归档更正 2026-10-13:原误记于 `docs/问题记录/CH395F_Trap_Records.md`——非 CH395F 议题,移入本节)** — FTP STOR/CWD 曾频发 `FR_INVALID_NAME`(6):真约束即上条第 10 项的严格 8.3。原记录"FatFS 不接受 `/` 前缀 / 空字符串失败"两条与仓内 `Lib/FatFs/ff.c` 矛盾(`ff.c:3101-3109` 会剥前导分隔符、`:3127-3129` 空名返回根目录),**已删**。现行口径:`lftpd_io.c` 的 `to_fatfs_path()` 剥 `/` 前缀(行为正确,保留)+ 本仓路径用 `"0:"` 卷前缀(`FF_FS_RPATH=0` 下 `"."` 不是合法路径段)。长文件名需 `FF_USE_LFN=1` + `FF_LFN_UNICODE`(显著增 RAM,未启用)。
### 10.2 FTP 服务(lftpd)鉴权语义(2026-10-11 起启用)
FTP 根目录 = 卷根,lftpd 是卷的网络文件入口(`Drivers/BSP/NET/lftpd/`)。FTP 鉴权启用——`[ftp]` 配置段、鉴权流程与改口令方法的**权威口径**在 `docs/子系统设计/网络配置说明.md` §3.4,此处只登记存储侧语义:
- **未登录命令门** — 登录成功前仅 `USER`/`PASS`/`QUIT`/`FEAT`/`SYST`/`NOOP` 放行,其余命令(含 RETR/STOR/LIST/DELE)一律 `530`。修复前 `cmd_user`/`cmd_pass` 对任意口令无条件回 `230` = **匿名全读写整卷**(鉴权缺失的根因)。
- **STOR/DELE 写门** — `[ftp] write=0` 时写命令一律 `550` 拒,卷对 FTP 侧只读(`/log`、`/config.ini` 仍可下载不可改写)。
- **全局失败锁定** — 连续 5 次鉴权失败 → 锁定 60 s(跨会话全局、易失、重启清零);纯 lftpd 层状态,不触存储。
- **默认口令告警锚点** — 出厂 `pwd_hash` 在用期间每次启动打 INFO `[FTP] using factory default password`。
### 10.3 FTP 日志级别划分:对端行为 vs 固件故障(2026-10-22)
lftpd 原先把全部异常路径一律按 error 级打(串口上带 `[ERR]`),其中**九处其实由外部输入决
定**:客户端关窗口、连上就走、PASV 之后不建数据连接、运维改 IP 触发监听重建。代价实测两条:
1. `/serial-boot-test` 的 Forbidden 标记含 `[ERR]`(它同时是四个 Fault 崩溃日志的前缀)——"FileZilla 关了
个窗口"就能把一次启动测试判 FAIL。实测就是这样踩到的:抓自检台账基线那次 FTP 会话结束后,
设备打 `[ERR] [FTP] error reading next command`,本次 `/serial-boot-test` 直接 FAIL。
2. 任何对 21 端口"连一下就断开"的外部行为,都能**无限制造固件级 ERROR 日志**——日志面与门禁
面都可被外部输入驱动,这在工业现场是过不了审的。
| 级别 | 串口形态 | 覆盖的事件 | 判据 |
|---|---|---|---|
| error(`lftpd_log_error` / `DBG_ERROR`) | `[ERR] …` | 建 socket / bind / listen 失败、取端口失败、写通道失败、文件打开失败(读/写/重复打开)、`DOWNLOAD FAILED` / `UPLOAD FAILED`(传输未完成)、`data listener disarmed`(控制连接还活着而我方数据监听已不在服役) | 本侧缺陷,或**数据完整性事件** |
| peer(`lftpd_log_peer` → info 级) | 普通 INFO 行 | 控制通道被对端关闭(原 `error reading next command`)、发送 220 之前对端已走、`listener rebuilt`(网络重建,原因由 accept 层自己记)、四处 `data accept failed`(PASV 后没来数据连接)、`accept timeout` | **对端决定的正常收尾** |
- **新增专门的 `lftpd_log_peer()` 而不是就地改成 `DBG_INFO`**:让"谁才算真故障"在读代码时
一目了然,也防止后来者顺手把对端行为又升回 error。
- **"传输未完成"仍留 error**:对端中途退出确实让一次 STOR/RETR 没走完,那是需要留痕的完整性
事件(`../问题记录/CH395F_Trap_Records.md` 的中止家族就靠这些锚点定位)。
- **边界(实测划清)**:客户端**干净收尾**(发 `QUIT`,或 FIN 半关)现在整条链路上没有任何 `[ERR]`;
但用 **RST 硬拆**(`SO_LINGER` 清零、进程被杀、网线拔了)仍会留一条 `[ERR][NET] sockN TIMEOUT`——
那是 net 层的套接字超时锚点,网络自愈架构的判据要读它,**不在本次降级范围**。所以"采集窗口内
不要跑 FTP"这条规避口径仍然有效,只是不再因为一次正常下载而中弹。
- 排障信息量不减:peer 级照旧打印,且比原来更准(带 `ret=` / `sk=` / `timeout(ms)`,措辞从
"error reading next command"改成"control channel closed by peer"——原先那句把正常收尾
写成了故障,误导过排查方向。
### 10.4 dhara 失败类:ECC / 预算耗尽 / 完整性故障(2026-10-23)
一次 NAND 读写要穿过 **SPI → `gd5f_*` → dhara(`dhara_error_t`)→ FTL glue → FatFs `RES_ERROR`**
五段,而 glue 以前把 dhara 的所有失败**压成一个 `GD5F_ERROR`**——上层分不清"该重试"、
"该标坏块"、还是"块池见底了,重格式化也没用"。现在 `nand_ftl.c` 在**最早同时知道
LPN 与原始码的位置**就地分类并打印(`ftl_err_name()` 给可读名;**本仓 dhara 没有编
`error.c`,所以没有 `dhara_strerror()`,用它直接链接失败**——表是自己的一张短表):
| dhara 错误 | 归类 | 处置上为什么不同 |
|---|---|---|
| `ECC` / `TOO_BAD` | `BSP_ERR_CRC` | 位翻转不可纠或页太坏:**是页级问题**,读重试 / 由 dhara 换页,不代表卷坏 |
| `JOURNAL_FULL` / `MAP_FULL` | `BSP_ERR_NO_SPACE` | 介质还健康,是 **journal / 映射层块池见底**:该走容量恢复(`../测试说明/专项验证/存储维护与容量恢复.md`),此时重格式化只是把问题推回去 |
| `BAD_BLOCK` / `RECOVER` / `NOT_FOUND` / `CORRUPT_MAP` / 越界 | `BSP_ERR_IO` | 映射或介质**完整性**故障:保卷 + 上报,不许自愈重格式化 |
- 这条分类与真空白卷判据是同一条纪律的两端:那边规定"只有扫描全程 I/O 干净且无
checkpoint(`DHARA_E_TOO_BAD`)才算真空白卷",本表则保证失败**可读**——两者合起来
才是"别把瞬态毛刺当卷损坏去 `f_mkfs`"(旧行为:单次 I2C/SPI 抖动 → 清 map → 整卷
重格式化 → 静默销毁全部用户数据)。
- **仍未闭环**:分类目前**只进日志**;`disk_read`/`disk_write` 对 FatFs 仍只能回 `RES_ERROR`
(那是 FatFs 的契约)。要让 defaultTask 的卷健康探针**按类分支**,还需要一个跨调用
存活的失败类快照(`nand_ftl_last_error()`,8 B;代价是要先定多写者纪律——
defaultTask 与 testTask 都会碰存储)→ 即登记簿条目「可选全序列卷锁」的待还项。