/* * 模块名称:NAND FTL 适配层(dhara + FatFS diskio 胶水) * 模块功能:实现 dhara NAND HAL 和 FatFS 磁盘 I/O,连接 GD5F2GQ5UE 底层驱动 * 适用平台:STM32F407ZGT6 * 作者:王建锋 * 创建日期:2026-07-20 * 修改记录: * 2026-07-20 王建锋 创建初始版本 * 2026-09-21 resume 失败按错误分类:仅真空白判新卷,其余失败 * 返回 mount 层(禁止静默清 map 触发整卷重格式化) * 2026-10-02 王建锋 resume 成功后增打 journal head/tail/epoch:复盘 * "复位后所有写入丢失"时 sectors used 不足以判定 * journal 回退(覆盖写不改 count),head/tail/epoch 是 * 唯一能暴露 resume 锚错 checkpoint 的证据 * 2026-10-15 王建锋 落实 dhara 移植层两条硬约束:① is_free 改**整页扫描**—— * 旧实现只看页首 64 B,会把"页首 0xFF、后段有数据"的 * 已编程页误判空闲,dhara 遂往已编程页再编程 = 数据丢失 * (页首有数据的绝大多数页走快路径,成本与旧实现同价); * ② HAL 六个入口全部加几何上界守卫——越界页/块号(含 * DHARA_PAGE_NONE)旧会被地址位截断成真实页 → 静默读写 * 错页,现报 DHARA_E_CORRUPT_MAP(不能用 BAD_BLOCK:会 * 误标无辜好块;不能用 RECOVER:会触发回收); * ③ 补主机侧移植层纯逻辑单测 test/host/nand_port/; * ④ 几何字面量收成 FTL_LOG2_PAGE_SIZE / FTL_LOG2_PPB * 单一真源 + 两条负数组编译期守卫:越界上界依赖 * log2_ppb,换料时只改 GD5F_* 而忘改字面量会**静默** * 把守卫算大 = 越界守卫失效,现改为直接编译错 * 2026-11-04 王建锋 问题 #79(台架 LPN=211 恒 TOO_BAD 致 f_mount 失败、 * 整卷禁用)两处修复: * ① `nand_ftl_format()` 不再被脏缓存 flush 失败挡住—— * 旧实现先 flush、失败即 return,导致**恢复动作自己在 * 同一页上死锁**(map 从未清过,f_mkfs 在旧映射上必失败)。 * 格式化语义本就是销毁映射,故尽力刷一次后无条件丢缓存; * ② 补 C4 跨调用失败类快照(`nand_ftl_fault_get/clear`), * 让 disk_initialize/读/刷/同步的失败归类能穿过 FatFs 的 * RES_ERROR 边界,供 defaultTask 探针按类分流(见 存储架构 * 说明 §10.4);③ .c 自包含 include 本模块头(主机侧单测 * 直接 #include 本文件,缺头则类型不可见)。 * ④(真机取证后定案)`dhara_nand_erase/prog()` 不再把 * **传输层/等忙失败**当坏块证据:只有芯片明确回 E_FAIL / * P_FAIL 才标坏。真凶即在此——非坏类失败被标坏后由 * mark_block_bad 立即持久化(不可撤销),台架 BBT 遂固化 * 129 个误标块(1016–1142 连续段),journal prepare_head() * 连续 8 次 is_bad → TOO_BAD → 整卷禁用;已固化的误标由 * 恢复动作 A 重建 BBT 清除(见 存储维护与容量恢复 §2.1)。 */ /* 头文件包含区 */ #include #include "nand_ftl.h" /* 本模块公开契约:失败快照类型(C4)与 nand_ftl_* 原型。 * 必须自包含——旧版靠调用方先 include 本头,主机侧单测 * 直接 #include "nand_ftl.c" 时类型不可见(实测编译错)。 */ #include "gd5f2gq5ue.h" #include "ff.h" #include "diskio.h" #include "nand.h" #include "map.h" #include "bsp_err.h" /* C2:把 dhara 错误归类成跨模块可判定的失败类 */ /* FTL 分区起始地址(块 0,偏移 0,占用全部 256MB) */ #define FTL_FATFS_OFFSET 0U /* 调试输出配置 */ /* 深调用链模块(FatFS → 本 FTL → dhara → NAND 是 defaultTask 最坏链的主干): * 日志行缓冲收到 128 B(默认 192)。本模块最长行 "[2026-10-10 08:00:00][ERR][NAND_FTL] * Cache flush failed: LPN=4294967295 err=-2147483648" ≈ 84 B < 128 → 不截断。 * 核算 scripts/stack_report.py,见 docs/内存管理/RAM使用分析与优化方案.md */ #define DBG_LINE_BUF_MAX 128U #define DBG_TAG "[NAND_FTL]" #include "dbg_log.h" /* 私有宏定义区 */ #define FTL_PAGE_SIZE GD5F_PAGE_SIZE #define FTL_SECTORS_PER_PAGE (FTL_PAGE_SIZE / 512) #define FTL_START_BLOCK (FTL_FATFS_OFFSET / GD5F_BLOCK_SIZE) #define FTL_NUM_BLOCKS (GD5F_TOTAL_BLOCKS - FTL_START_BLOCK) #define FTL_START_PAGE (FTL_START_BLOCK * GD5F_PAGES_PER_BLOCK) /* dhara 几何字面量的**单一真源**(TRAP-GD5F-07 遗留 3 的收口):disk_initialize 与 * 下方守卫都取这两个宏,不再各写一遍 11 / 6。 */ #define FTL_LOG2_PAGE_SIZE 11u #define FTL_LOG2_PPB 6u /* 编译期守卫:`ftl_page_in_range()` 的越界上界 = `num_blocks << log2_ppb`, * **完全依赖 log2_ppb**。将来换料(例如 128 页/块的兼容片)只改了 GD5F_* 常量 * 而忘了同步这里的字面量,守卫会静默算错——偏大就是越界守卫失效,正是它要防的 * 那一类错误,而且不报任何错。下面两条把"字面量 == GD5F 常量的 log2"钉死, * 不一致直接编译错。负数组 typedef 是 AC5 下的项目惯用法(AC5 不支持 C11 * _Static_assert),同本目录 gd5f2gq5ue.h 的 DMA 契约守卫。 */ typedef char ftl_geometry_log2_page_size_check_t[ (FTL_PAGE_SIZE == (1 << FTL_LOG2_PAGE_SIZE)) ? 1 : -1]; typedef char ftl_geometry_log2_ppb_check_t[ (GD5F_PAGES_PER_BLOCK == (1 << FTL_LOG2_PPB)) ? 1 : -1]; /* dhara 逻辑地址到物理地址转换 */ 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; } /* * dhara 移植层硬约束(2026-10-15):移植层地址守卫 * * 旧实现把 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 启动回收, * 其余错误一律原样上抛(教训:单次瞬态毛刺不得销毁整卷数据)。 * is_free / is_bad 没有错误通道 → 一律保守取值(判非空 / 判坏),与同族 * 口径一致:宁可少用一个块,不可把不可信的内容当有效。 */ 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 核心数据结构 */ static struct dhara_nand s_nand; static struct dhara_map s_map; static uint8_t s_page_buf[FTL_PAGE_SIZE]; /* dhara 内部页缓冲区 */ /* 页面缓存(FatFS 读写缓存) */ static uint8_t s_cache_buf[FTL_PAGE_SIZE]; static dhara_sector_t s_cached_lpn; static uint8_t s_cache_dirty; /* 拷贝临时缓冲区 */ static uint8_t s_copy_buf[FTL_PAGE_SIZE]; /* 【#85 P0 台架实证】写提交回读探针缓冲 + 计数 */ static uint8_t s_prog_verify_buf[FTL_PAGE_SIZE]; static volatile uint32_t s_prog_verify_mismatch; /* 初始化状态 */ static uint8_t s_initialized; /* 失败快照(C4):把"最近一次存储失败"的归类带出函数边界。 * 为什么需要:disk_read/disk_write 对 FatFs 只能回 RES_ERROR(FatFs 契约), * 失败类在 return 那一刻就被抹掉,上层因此无法区分「页级 TOO_BAD,重格式化可解」 * 与「块池见底 / 映射损坏,重格式化只会把问题推回去」。 * 多写者纪律:defaultTask 与 testTask 都会碰存储,故语义是"最近一次"而非账本; * 并发下可能被另一路覆盖——诊断用途可接受,记在此处以免日后误当权威计数。 */ static nand_ftl_fault_t s_fault; /* ======================== 私有函数声明 ======================== */ static bsp_err_t ftl_flush_cache(void); static bsp_err_t ftl_read_page(dhara_sector_t lpn); static void ftl_fault_note(uint32_t op, bsp_err_t cls, dhara_sector_t lpn); /* SPI 原语从 gd5f2gq5ue.h 获取 */ /* ======================== dhara 错误的可读名与失败类 ======================== */ /* * 函数功能:把 dhara 错误枚举翻成短字符串(**本仓 dhara 没编 error.c,没有 dhara_strerror()**, * 用它会链接失败,故自己给一张小表) * 入口参数:err - dhara 错误枚举值 * 返回值:静态字符串常量("NONE" / "ECC" / "MAP_FULL" ...,越界返回 "OOB") * 限定条件:只用于日志,不得用于判等 * 函数说明:以前这里只打 `err=%d`——现场拿到一串数字还得翻 Lib/dhara/error.h 才知道是什么, * 存储故障又大多是"看完日志就没机会复现"的那类,可读名是排障成本最低的改法。 */ static const char *ftl_err_name(dhara_error_t err) { static const char *const s_names[] = { "NONE", "BAD_BLOCK", "ECC", "TOO_BAD", "RECOVER", "JOURNAL_FULL", "NOT_FOUND", "MAP_FULL", "CORRUPT_MAP", }; if ((unsigned int)err < (sizeof(s_names) / sizeof(s_names[0]))) { return s_names[err]; } return "OOB"; } /* * 函数功能:把 dhara 错误归类成统一失败类(C2) * 入口参数:err - dhara 错误枚举值 * 返回值:BSP_ERR_CRC(位翻转不可纠 / 页太坏);BSP_ERR_NO_SPACE(journal 或映射层预算 * 耗尽);其余(坏块 / 找回失败 / 映射损坏 / 越界)→ BSP_ERR_IO * 限定条件:入参应是**失败**的 err(DHARA_E_NONE 会得 BSP_OK) * 函数说明:分这三类的理由是它们在**处置上真的不同**,不是为了码好看: * · ECC/TOO_BAD → 该重试读或标页坏,**不是**卷的问题; * · JOURNAL_FULL/MAP_FULL → 介质还健康,是**块池/日志预算见底**,此时重格式化只是 * 把问题推回去(教训正是"别把非空白错误当新卷清 map"),该走容量恢复流程; * · BAD_BLOCK/CORRUPT_MAP/RECOVER → 映射或介质完整性故障,该保卷 + 上报,不许自愈重格式化。 * 本函数先把这三类**打出来**(就地日志,最早且唯一同时知道 LPN 与原始码的位置); * 要让 defaultTask 的卷健康探针按类分支,还需要一个跨调用存活的快照(C4 待还)。 */ static bsp_err_t ftl_class_from_dhara(dhara_error_t err) { switch (err) { case DHARA_E_NONE: return BSP_OK; case DHARA_E_ECC: case DHARA_E_TOO_BAD: return BSP_ERR_CRC; case DHARA_E_JOURNAL_FULL: case DHARA_E_MAP_FULL: return BSP_ERR_NO_SPACE; default: return BSP_ERR_IO; } } /* ======================== 失败快照(C4) ======================== */ /* * 函数功能:记录一次存储失败到跨调用快照 * 入口参数:op - 失败的操作(NAND_FTL_OP_*) * cls - 失败类(ftl_class_from_dhara 的结果) * lpn - 涉及逻辑页号(未知传 NAND_FTL_LPN_NONE) * 返回值:无 void * 限定条件:可在任何上下文调用;本函数只写几个字,不加锁(见下方顺序说明) * 函数说明:**不覆盖已有的非 NONE 归类除非新失败更具体**——不,刻意选择 * "后写覆盖":快照回答的是"最近一次失败是什么",最靠近处置点的现场才有用。 * count 单独累计,用来回答另一个问题:"是不是一直在撞同一页"(本单现场正是 * LPN=211 反复失败 15 次)。两者分开存,避免为了统计把最近现场挤掉。 * * 写入顺序是有意的:先写内容字段,最后置 active=1(写侧无锁,读侧可能 * 在任意指令边界被抢占)。这样读到 active=1 时内容字段必然已经写好; * 最坏情况是读到旧内容 —— 诊断用途可接受,且不会比"没有快照"更差。 */ static void ftl_fault_note(uint32_t op, bsp_err_t cls, dhara_sector_t lpn) { s_fault.op = op; s_fault.cls = (int32_t)cls; s_fault.lpn = (uint32_t)lpn; if (s_fault.count < 0xFFFFFFFFu) { s_fault.count++; } s_fault.active = 1u; } void nand_ftl_fault_get(nand_ftl_fault_t *p_out) { if (p_out == NULL) { return; } /* 整结构拷贝:调用方拿到的是一致快照,不会一半新一半旧 */ *p_out = s_fault; } void nand_ftl_fault_clear(void) { /* 先清 active 再清内容:反序会让读侧看到 active=0 但 count 仍是旧值, * 虽然无害,但"active=0 时其余字段无意义"这条契约更该由写法保证 */ s_fault.active = 0u; s_fault.op = NAND_FTL_OP_NONE; s_fault.cls = (int32_t)BSP_OK; s_fault.lpn = NAND_FTL_LPN_NONE; s_fault.count = 0u; } /* ======================== 页面缓存管理 ======================== */ static bsp_err_t ftl_flush_cache(void) { if (!s_cache_dirty) { return BSP_OK; } dhara_error_t err; if (dhara_map_write(&s_map, s_cached_lpn, s_cache_buf, &err) < 0) { bsp_err_t cls = ftl_class_from_dhara(err); /* 只在失败分支需要(cppcheck variableScope) */ DBG_ERROR("Cache flush FAIL: LPN=%lu dhara=%s class=%d", s_cached_lpn, ftl_err_name(err), cls); ftl_fault_note(NAND_FTL_OP_FLUSH, cls, s_cached_lpn); return cls; } /* 【#85 P0 台架实证】map 状态日志:每次写后打 root/head/tail/epoch/count, * 对照台架打回时刻的 map 状态,确认 root 是否回退。仅台架实证用,量产前移除。 */ DBG_INFO("map_write: root=%lu head=%lu tail=%lu epoch=%u count=%lu", (unsigned long)s_map.journal.root, (unsigned long)s_map.journal.head, (unsigned long)s_map.journal.tail, (unsigned)s_map.journal.epoch, (unsigned long)s_map.count); s_cache_dirty = 0; return BSP_OK; } static bsp_err_t ftl_read_page(dhara_sector_t lpn) { dhara_error_t err; if (dhara_map_read(&s_map, lpn, s_cache_buf, &err) < 0) { bsp_err_t cls = ftl_class_from_dhara(err); /* 只在失败分支需要(cppcheck variableScope) */ DBG_ERROR("Page read FAIL: LPN=%lu dhara=%s class=%d", lpn, ftl_err_name(err), cls); ftl_fault_note(NAND_FTL_OP_READ, cls, lpn); return cls; } s_cached_lpn = lpn; s_cache_dirty = 0; return BSP_OK; } /* ======================== dhara NAND HAL ======================== */ int dhara_nand_is_bad(const struct dhara_nand *n, dhara_block_t b) { 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) { 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) { 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_OK) { if (ret == GD5F_ERASE_FAIL) { /* 芯片明确回 E_FAIL:该块确实擦不净,标坏是正确处置 */ DBG_ERROR("Erase failed with E_FAIL: block=%lu", (unsigned long)b); dhara_nand_mark_bad(n, b); dhara_set_error(err, DHARA_E_BAD_BLOCK); } else { /* 【#79 核心修复】传输层/等忙失败(GD5F_ERROR / GD5F_WAIT_EXPIRED) * 不是坏块证据:芯片根本没回 E_FAIL,把块标坏等于把"线抖了一下" * 固化成"这块永久报废",并立即写进持久化 BBT(不可撤销)。 * 这类失败只报 BAD_BLOCK 让 dhara 重试,**不标坏**。 * 真机取证(2026-11-04 17:53):出厂扫描 0 坏块、底层 prog/erase * 全部成功,而持久化 BBT 把 1016–1142 连续段判坏,prepare_head() * 连续 8 次撞 is_bad → DHARA_E_TOO_BAD → 整卷禁用。 */ DBG_ERROR("Erase transport/timeout failure (not a bad block): " "block=%lu ret=%d", (unsigned long)b, ret); dhara_set_error(err, DHARA_E_BAD_BLOCK); } return -1; } return 0; } 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; 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); if (ret != GD5F_OK) { /* 【#79】装载失败是传输层问题(SPI/DMA/等忙),芯片未回 P_FAIL, * 不得标坏块;只报 BAD_BLOCK 让 dhara 重试。 */ DBG_ERROR("Program load transport failure (not a bad block): " "page=%lu ret=%d", (unsigned long)phy_pg, ret); dhara_set_error(err, DHARA_E_BAD_BLOCK); return -1; } ret = gd5f_program_exec(phy_pg); if (ret != GD5F_OK) { if (ret == GD5F_PROGRAM_FAIL) { /* 芯片明确回 P_FAIL:页确实写不进去,标坏是正确处置 */ DBG_ERROR("Program failed with P_FAIL: page=%lu", (unsigned long)phy_pg); dhara_nand_mark_bad(n, p >> n->log2_ppb); dhara_set_error(err, DHARA_E_BAD_BLOCK); } else { /* 【#79】传输层/等忙失败不是坏块证据,不标坏,只重试 */ DBG_ERROR("Program transport/timeout failure (not a bad block): " "page=%lu ret=%d", (unsigned long)phy_pg, ret); dhara_set_error(err, DHARA_E_BAD_BLOCK); } return -1; } /* 【#85 P0 台架实证】写提交回读探针:编程成功后立即回读同 PPN 做字节比对。 * 2026-09-20 曾加回读校验(GD5F_PROG_VERIFY),2026-09-21 经评估整体移除。 * 现以低频探针形式恢复:仅台架实证用,量产前移除。 * 目的:在 FTL 层直接抓到"编程成功但回读不一致"的窗口, * 区分"NAND 编程未落地" vs "map 指旧 PPN"。 */ { int32_t vret; vret = gd5f_page_read(phy_pg); if (vret == GD5F_OK) { vret = gd5f_read_from_cache(0, s_prog_verify_buf, FTL_PAGE_SIZE); if (vret == GD5F_OK) { if (gd5f_check_ecc() == GD5F_ECC_ERROR) { DBG_ERROR("Prog verify: ECC error on readback page=%lu", (unsigned long)phy_pg); s_prog_verify_mismatch++; } else if (memcmp(data, s_prog_verify_buf, FTL_PAGE_SIZE) != 0) { DBG_ERROR("Prog verify: MISMATCH page=%lu (data differs after prog)", (unsigned long)phy_pg); s_prog_verify_mismatch++; } } } } return 0; } /* * dhara 移植层硬约束(2026-10-15):整页扫描 * * 旧实现只看页首 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(页缓冲三块不可合并的论证见 * docs/内存管理/存储栈RAM优化调研.md §5)。 */ int dhara_nand_is_free(const struct dhara_nand *n, dhara_page_t p) { 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) { return 0; } for (off = 0; off < FTL_PAGE_SIZE; off += sizeof(buf)) { if (gd5f_read_from_cache((uint16_t)off, buf, sizeof(buf)) != GD5F_OK) { /* 同族(2026-10-03):读失败 → buf 可能为陈旧/垃圾内容, * 保守判为非空(块不可复用),避免把"没读到内容的块"判成空闲。 */ return 0; } for (i = 0; i < sizeof(buf); i++) { if (buf[i] != 0xFFu) { return 0; /* 页首命中即返回 = 快路径,不读后段 */ } } } 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) { 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); return -1; } if (gd5f_check_ecc() != GD5F_OK) { dhara_set_error(err, DHARA_E_ECC); return -1; } /* 同族(2026-10-03):read_from_cache 返回值必须检查——失败时 data 内容 * 不可信,原实现仍返回 0(成功),会把未读到的数据当有效数据交给上层。 */ if (gd5f_read_from_cache((uint16_t)offset, data, length) != GD5F_OK) { dhara_set_error(err, DHARA_E_ECC); return -1; } return 0; } /* * dhara 移植层硬约束(2026-10-15):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) { 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; } if (dhara_nand_prog(n, dst, s_copy_buf, err) < 0) { return -1; } return 0; } /* ======================== FatFS 磁盘 I/O ======================== */ DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) { return STA_NOINIT; } if (s_initialized) { return 0; } DBG_INFO("Initializing NAND FTL..."); /* DMA 可达性三层防线——启动期地址自检(2026-10-12):本文件三个页缓冲是 SPI1 DMA 路径上 * 唯一的**静态**缓冲(dhara_map_read/write → dhara_nand_read/prog/copy, * 2048 B > GD5F_DMA_THRESHOLD;s_page_buf 页内虽只攒 132 B 元数据, * 但编程的是**整页**(journal.c:992),传输长度同为 2048 B、同样走 DMA, * 一并守住防将来越窗)。 * DMA1/DMA2 不可达 CCM——若 scatter 改动/段放置把它们挪进 CCM,DMA 会 * **静默不落地**(数据错而传输"成功")。缓冲地址链接期才确定,armcc * 无法编译期断言(见 gd5f2gq5ue.h 的窗口常量注释),故在 init 期自检: * 任一缓冲越出 DMA 白名单窗口 → 上报并拒绝挂载(存储降级、不挂死, * 与"保留卷、上报自检"形态一致)。每次传输的运行期守卫 * (gd5f_read_from_cache/gd5f_program_load 内)保留作第二层兜底。 */ if ((gd5f_dma_buf_ok(s_cache_buf, FTL_PAGE_SIZE) == 0) || (gd5f_dma_buf_ok(s_copy_buf, FTL_PAGE_SIZE) == 0) || (gd5f_dma_buf_ok(s_page_buf, FTL_PAGE_SIZE) == 0)) { DBG_ERROR("FTL page buffer not DMA-reachable (cache=0x%08lX), " "storage refused", (unsigned long)(uintptr_t)s_cache_buf); return STA_NOINIT; } s_nand.log2_page_size = FTL_LOG2_PAGE_SIZE; s_nand.log2_ppb = FTL_LOG2_PPB; /* dhara 可见块数 = 总块数 - 保留块池(BBT 存储区不交给 dhara,避免被擦写) */ s_nand.num_blocks = gd5f_get_usable_blocks(); dhara_map_init(&s_map, &s_nand, s_page_buf, 4); memset(s_cache_buf, 0, FTL_PAGE_SIZE); s_cached_lpn = (dhara_sector_t)-1; /* -1 表示缓存未加载;LPN 0 合法,不能用 0 作哨兵 */ s_cache_dirty = 0; /* 失败快照初值:一次上电从"无失败"起算。刻意放在 map_init 之后、 * resume 之前——resume 失败本身就是要被记进快照的第一件事。 */ s_fault.active = 0u; s_fault.op = NAND_FTL_OP_NONE; s_fault.cls = (int32_t)BSP_OK; s_fault.lpn = NAND_FTL_LPN_NONE; s_fault.count = 0u; { dhara_error_t err = DHARA_E_NONE; if (dhara_map_resume(&s_map, &err) < 0) { if (err == DHARA_E_TOO_BAD) { /* 扫描全程 I/O 干净且无 checkpoint = 真空白卷(出厂首启 / * 显式格式化后);失败的 resume 已把 journal 复位为空态 → * 按新卷处理,由上层自动建卷(此处 dhara_map_clear 为空操作: * resume 失败时 count 已清零,map 本已为空)。 */ DBG_INFO("No valid map found (dhara=%s), creating fresh", ftl_err_name(err)); dhara_map_clear(&s_map); } else { /* (2026-09-21 审计 P0 修复):非"空白"错误(ECC / * BAD_BLOCK / CORRUPT_MAP / 瞬态 I/O 等)= 真实完整性故障—— * 芯片上 journal 可能仍持有有效 checkpoint。旧行为把任何失败 * 当新卷清 map,上层随即整卷重格式化 → 单次瞬态毛刺即静默销毁 * 全部用户数据。现把失败返回给 mount 层:f_mount 失败 → * defaultTask 自检上报 "filesystem fault" 并保留卷;瞬态故障 * 由主循环重试/下次上电重新 resume;重格式化只走显式命令 * (nand_ftl_format)。 */ DBG_ERROR("Map resume failed (dhara=%s class=%d), volume kept, " "storage unavailable", ftl_err_name(err), ftl_class_from_dhara(err)); ftl_fault_note(NAND_FTL_OP_MAP_RESUME, ftl_class_from_dhara(err), (dhara_sector_t)NAND_FTL_LPN_NONE); return STA_NOINIT; } } else { DBG_INFO("Map resumed: %lu/%lu sectors used", dhara_map_size(&s_map), dhara_map_capacity(&s_map)); /* journal 头/尾/epoch 一并打印:2026-10-02 台架复盘"复位后写入全部 * 丢失"时,只看 sectors used 判断不出 journal 是否回退(覆盖写场景 * count 不变),必须看 head/tail/epoch 才能发现 resume 锚错了 * checkpoint(详见 Lib/dhara/journal.c 的 2026-10-02 本地修复注释)。 * 纯内存字段、零 I/O,保留作长期可观测性。 */ DBG_INFO("Map head=%lu tail=%lu epoch=%u", (unsigned long)s_map.journal.head, (unsigned long)s_map.journal.tail, (unsigned)s_map.journal.epoch); } } s_initialized = 1; DBG_INFO("NAND FTL ready (capacity: %lu pages = %lu MB)", dhara_map_capacity(&s_map), (dhara_map_capacity(&s_map) * FTL_PAGE_SIZE) / (1024 * 1024)); return 0; } int nand_ftl_init(void) { DSTATUS sta = disk_initialize(0); return (sta == 0) ? 0 : -1; } void nand_ftl_deinit(void) { /* 复位 FTL 状态,使下次 nand_ftl_init() 真正重新走 dhara_map_resume, 用于测试"掉电恢复"与"坏块注入后重建"场景(模拟设备重启) */ s_initialized = 0; s_cached_lpn = (dhara_sector_t)-1; s_cache_dirty = 0; } DSTATUS disk_status(BYTE pdrv) { if (pdrv != 0) { return STA_NOINIT; } if (!s_initialized) { return STA_NOINIT; } return 0; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || !buff) { return RES_PARERR; } if (!s_initialized && disk_initialize(pdrv) != 0) { return RES_NOTRDY; } while (count > 0) { dhara_sector_t lpn = (dhara_sector_t)(sector / FTL_SECTORS_PER_PAGE); uint32_t offset = (uint32_t)(sector % FTL_SECTORS_PER_PAGE) * 512; uint32_t batch = FTL_SECTORS_PER_PAGE - (sector % FTL_SECTORS_PER_PAGE); if (batch > count) { batch = count; } if (lpn != s_cached_lpn) { if (ftl_flush_cache() != BSP_OK) { return RES_ERROR; } if (ftl_read_page(lpn) != BSP_OK) { return RES_ERROR; } } memcpy(buff, s_cache_buf + offset, batch * 512); buff += batch * 512; sector += batch; count -= (UINT)batch; } return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || !buff) { return RES_PARERR; } if (!s_initialized && disk_initialize(pdrv) != 0) { return RES_NOTRDY; } while (count > 0) { dhara_sector_t lpn = (dhara_sector_t)(sector / FTL_SECTORS_PER_PAGE); uint32_t offset = (uint32_t)(sector % FTL_SECTORS_PER_PAGE) * 512; uint32_t batch = FTL_SECTORS_PER_PAGE - (sector % FTL_SECTORS_PER_PAGE); if (batch > count) { batch = count; } if (lpn != s_cached_lpn) { if (ftl_flush_cache() != BSP_OK) { return RES_ERROR; } if (ftl_read_page(lpn) != BSP_OK) { return RES_ERROR; } } memcpy(s_cache_buf + offset, buff, batch * 512); s_cache_dirty = 1; if (offset == 0 && batch == FTL_SECTORS_PER_PAGE) { if (ftl_flush_cache() != BSP_OK) { return RES_ERROR; } } buff += batch * 512; sector += batch; count -= (UINT)batch; } return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != 0) { return RES_PARERR; } if (!s_initialized) { return RES_NOTRDY; } switch (cmd) { case CTRL_SYNC: if (ftl_flush_cache() != BSP_OK) { return RES_ERROR; } { dhara_error_t err; if (dhara_map_sync(&s_map, &err) < 0) { DBG_ERROR("Map sync failed: %d", err); ftl_fault_note(NAND_FTL_OP_SYNC, ftl_class_from_dhara(err), (dhara_sector_t)NAND_FTL_LPN_NONE); return RES_ERROR; } } return RES_OK; case GET_SECTOR_COUNT: { LBA_t *p_sectors = (LBA_t *)buff; dhara_sector_t pages = dhara_map_capacity(&s_map); *p_sectors = pages * FTL_SECTORS_PER_PAGE; return RES_OK; } case GET_SECTOR_SIZE: *(WORD *)buff = 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff = 1; return RES_OK; default: return RES_PARERR; } } /* ======================== 公共函数定义 ======================== */ int nand_ftl_format(void) { dhara_error_t err; if (!s_initialized) { if (disk_initialize(0) != 0) { return GD5F_ERROR; } } /* 处置开始:清掉历史失败快照,此后新失败重新记账 * (否则一次旧失败会永久污染"本次格式化到底成没成"的判定) */ nand_ftl_fault_clear(); /* 【本单核心修复 2026-11-04,问题 #79】 * 旧实现先 `if (ftl_flush_cache() != BSP_OK) return GD5F_ERROR;`——而 * ftl_flush_cache() 在失败分支**不清 s_cache_dirty**(那是刻意的:写不进去 * 就不能假装写成功了),于是只要脏页落在"写不动的那个 LPN"上, * 本函数就在第一道闸门返回,**dhara_map_clear() 永远执行不到**。 * * 台架现场(11-04 08:16 与 08:42 两次独立捕获,head/tail/epoch 一字不变): * 探针写 /fschk.tmp → disk_write → flush LPN=211 失败(TOO_BAD) * → 重试同页再次失败 → 判卷损坏 → 走本函数 → 又被同一页挡住 * → f_mkfs 在旧映射上跑 → 失败 → 3 轮重试耗尽 → storage disabled。 * 每次启动必现、断电重启不恢复,因为 s_cache_dirty 与 LPN=211 的脏数据 * 在**同一次启动内**恒存,而每轮启动都会重新踩上同一条路。 * * 为什么"跳过 flush 直接丢缓存"在这里是正确的: * 本函数的语义是**销毁整卷逻辑映射**(头文件:「破坏性:整卷逻辑地址→ * 物理映射作废」,只允许显式命令/恢复流程调用;现存两个调用点 * ——首启无卷与 FAT 探针判卷损坏——都紧接着 f_mkfs 重建卷)。既然这份 * 脏页映射注定被扔掉,为它挡住格式化既救不回数据,又让**恢复动作本身 * 永久失效**。故:尽力刷一次(成功则顺带落盘、日志更干净),失败就 * 丢缓存并继续——这正是"格式化"该有的优先级。 * * ⚠ 不得把本改动挪到普通写路径:disk_write/disk_read 里的 flush 失败 * 必须继续向上报错(那是"这次写没落地"的真相),只有本函数有权丢弃缓存。 */ if (ftl_flush_cache() != BSP_OK) { DBG_ERROR("format: dirty cache at LPN=%lu undiscardable, " "dropping it (map is being destroyed anyway)", s_cached_lpn); } /* 无条件失效页面缓存:格式化后旧映射作废,绝不能让它再被当成有效缓存 */ s_cache_dirty = 0; s_cached_lpn = (dhara_sector_t)-1; dhara_map_clear(&s_map); if (dhara_map_sync(&s_map, &err) < 0) { DBG_ERROR("Map sync after clear failed: %s class=%d", ftl_err_name(err), ftl_class_from_dhara(err)); ftl_fault_note(NAND_FTL_OP_SYNC, ftl_class_from_dhara(err), (dhara_sector_t)NAND_FTL_LPN_NONE); return GD5F_ERROR; } DBG_INFO("NAND FTL formatted, capacity: %lu pages", dhara_map_capacity(&s_map)); return GD5F_OK; }