1. 项目缘起与整体设计思路拆解
W25N01G 这颗 SPI NAND 闪存芯片,做嵌入式存储的朋友大概率不陌生——1Gbit 容量、标准 SPI 接口、成本比 NOR 低一大截,做数据记录、固件存储、日志缓存都很合适。但很多人第一次上手就栽在同一个地方:NAND 天生有坏块,出厂就可能带,用着用着还会新增。你按 NOR 的思路去读写,地址一算、数据一写,读回来全是 0xFF 或者乱码,查半天以为是时序问题,其实是踩到坏块了。
这个项目要解决的就是这件事:把 W25N01G 的坏块从识别、标记到读写跳过的完整链路跑通。说白了,就是让驱动层知道哪些块不能用、怎么在读写时自动绕开它们,让上层应用感觉不到坏块的存在。适合谁看?正在用 SPI NAND 做产品固件、写过裸机驱动、或者正准备从 NOR 迁移到 NAND 的嵌入式开发者。如果你只是调过 SPI Flash 的读写、没碰过坏块管理,这篇正好补上这块短板。
先说清楚一个前提:NAND 的坏块管理不是"可选项",是"必选项"。NOR 可以按字节随机读写、擦写寿命长、出厂基本无坏块,所以很多人用 NOR 时压根不关心坏块。NAND 不一样,它的存储单元是串行连接的,一个块里只要有一个坏位,整块数据都不可靠。厂商在出厂时会做全片测试,把不合格的块标记出来,这个标记就是坏块标记(Bad Block Marker),存在每个块第一页的特定位置(spare area 里)。W25N01G 的坏块标记位置在每块第 0 页的 column 2048 处(也就是主区之后的 spare 区第一个字节),值为非 0xFF 即表示坏块。
整体设计思路我分三层来考虑。第一层是识别:上电初始化时扫描全片,把坏块信息读出来,建立一张坏块表。第二层是管理:坏块表怎么存、怎么查、新增坏块怎么更新。第三层是读写跳过:上层给的是逻辑块号,驱动层要映射到物理块号,遇到坏块自动跳到下一个可用块。这三层是递进关系,识别不准后面全白搭,管理不牢掉电就丢,跳过逻辑写错数据就串块。
为什么这么设计?我试过几种方案。最粗暴的是每次读写都现场查坏块标记,但这样每次操作都要多读一次 spare 区,速度慢不说,代码里到处是判断,维护起来很痛苦。另一种是把坏块表放 RAM 里,上电扫一遍,读写时查表——这是主流做法,速度快、逻辑清晰,代价是占一点 RAM(W25N01G 共 1024 个块,一个块一个 bit 也就 128 字节,完全可接受)。我最终选的就是 RAM 坏块表方案,下面展开讲细节。
注意:坏块表放 RAM 意味着掉电丢失,所以每次上电必须重新扫描。扫描本身要读 1024 次 spare 区,如果逐块读会很慢,实际实现时可以用连续读或者只读每块第 0 页的方式优化,后面实操部分细说。
2. 核心细节解析与实操要点
2.1 坏块标记的读取位置与判定逻辑
W25N01G 的页大小是 2048 字节主区 + 64 字节 spare 区,块大小是 64 页,总共 1024 个块。坏块标记存在每个块第 0 页的 spare 区第 0 个字节,也就是整页偏移 2048 的位置。判定规则很简单:读出来如果是 0xFF,说明是好块;如果是其他值(常见 0x00),就是坏块。
这里有个坑我踩过:不能只读主区。很多人用页读命令 0x13 读数据,默认只读主区 2048 字节,spare 区根本没读出来,自然看不到坏块标记。要读 spare 区,得用带 spare 的读命令,或者用 0x03 标准读命令配合地址偏移。W25N01G 支持READ PAGE命令(0x13)后跟 4 字节地址,其中 bit12 是 ECC 使能位,读的时候如果开了片上 ECC,spare 区内容会被 ECC 校验影响。我的做法是扫描坏块时关闭 ECC,直接读原始 spare 数据,避免 ECC 纠错把坏块标记"修正"掉导致误判。
具体命令序列是这样的:先发 0x13(Page Read),跟 24 位地址(块号左移 6 位,页内偏移 0),然后发 0x0F(Get Feature)读状态寄存器确认读完成,最后发 0x03(Read)从 column 2048 开始读 1 字节。这一字节就是坏块标记。实测下来这个流程稳定,但要注意每次 Page Read 之后必须等状态寄存器的 OIP 位清零才能读数据,否则读到的是旧缓存。
2.2 坏块表的组织与存储策略
坏块表我用了最简单的位图结构:1024 个块对应 1024 个 bit,共 128 字节。块号 n 对应第 n/8 字节的第 n%8 位,置 1 表示坏块。查表就是一次位运算,O(1) 复杂度,比链表、数组都省。
但这里有个设计取舍要讲清楚:坏块表要不要持久化。RAM 表掉电丢失,每次上电重扫没问题,但如果产品要求快速启动,重扫 1024 个块可能要几十毫秒甚至上百毫秒,对启动时间敏感的场景就不合适。我的折中方案是:首次上电全扫,把坏块表写到 Flash 的一个保留块里(比如最后几个块),下次上电先读保留块,再抽查几个块验证表是否有效。不过这个方案增加了复杂度,对于大多数应用,每次上电重扫完全够用,因为扫描可以优化到很快。
扫描优化技巧:不需要逐块发 Page Read。W25N01G 支持连续读模式,发一次 0x13 读第 0 块第 0 页,然后连续发 0x03 读后续地址,芯片内部会自动递增。但坏块标记分散在每块第 0 页,跨块读需要重新发 Page Read。实测 1024 次 Page Read + Read 的组合,在 10MHz SPI 下大约 80ms,可以接受。如果嫌慢,可以只扫前若干个块(比如前 64 个块坏块率高,后面低),但这样不严谨,我不推荐。
2.3 逻辑块到物理块的映射与跳过规则
这是整个项目最核心也最容易写错的部分。上层应用通常按逻辑块号操作,驱动层负责映射到物理块号。映射规则:从逻辑块号开始,顺序查找物理块,遇到坏块就跳过,直到找到好块。比如逻辑块 5 要写,物理块 5 是坏块,就跳到物理块 6;如果 6 也是坏块,跳到 7,以此类推。
但这里有个致命细节:映射必须双向一致。写的时候逻辑块 5 映射到物理块 7,读的时候逻辑块 5 也必须映射到物理块 7,否则数据就串了。所以映射关系不能每次现算,必须固定下来。我的做法是在坏块表之外,再维护一张逻辑到物理的映射表(1024 个 uint16,共 2KB),初始化时一次性算好:遍历逻辑块,跳过坏块分配物理块,建立映射。这样读写都查同一张表,绝对一致。
为什么不用"现算"?因为现算依赖坏块表不变,但 NAND 使用过程中可能新增坏块(擦写次数多了某些块会失效)。如果新增坏块后现算,逻辑块 5 可能从物理块 7 变成物理块 8,之前写在 7 的数据就读不到了。所以映射表必须在坏块表更新时同步重建,或者采用更复杂的重映射策略。对于 W25N01G 这种小容量芯片,我选择初始化时建表、运行中不动态重映射,新增坏块通过上层文件系统或应用层处理,驱动层只保证初始映射稳定。
提示:如果你的应用需要动态坏块管理(运行中发现新坏块并迁移数据),那就要引入更复杂的 FTL(Flash Translation Layer)逻辑,这超出本文范围。W25N01G 定位是低成本存储,大多数场景用静态映射 + 上电重扫就够了。
2.4 擦除与写入的坏块规避
擦除操作以块为单位,命令 0xD8 后跟块地址。如果擦除的块是坏块,芯片可能返回失败,也可能"成功"但数据不可靠。所以擦除前必须查坏块表,坏块直接跳过。写入以页为单位,命令 0x02(Program Load)加载数据到缓存,0x10(Program Execute)执行写入。写入前同样要确认目标块不是坏块。
这里有个实操要点:写入后的状态检查不能省。W25N01G 的 Program Execute 完成后,状态寄存器的 P_FAIL 位(bit0)如果置 1,说明写入失败,可能是坏块或者 ECC 无法纠正。我的做法是每次写页后读状态,P_FAIL 置位就把该块标记为坏块,更新坏块表,并返回错误给上层。这样运行中发现的坏块也能被记录,虽然不动态迁移数据,但至少后续操作会跳过它。
擦除同理,E_FAIL 位(bit2)置位表示擦除失败,标记坏块。实测中 W25N01G 出厂坏块率很低(我手上几片都是 0 到 2 个坏块),但运行中偶发写入失败是有的,尤其是反复擦写同一区域后。所以状态检查是最后一道防线,不能图省事跳过。
3. 实操过程与核心环节实现
3.1 硬件连接与 SPI 初始化
先讲硬件。W25N01G 是 8 引脚 SOP 或 WSON 封装,SPI 接口四线:CS、CLK、MOSI、MISO,加上 VCC、GND,还有 WP(写保护)和 HOLD(保持)引脚。WP 和 HOLD 如果不用,直接上拉到 VCC。我用的开发板是某款 Cortex-M4 主控,SPI 时钟配到 10MHz,模式 0(CPOL=0,CPHA=0),这是 W25N01G 支持的标准模式。
初始化序列:上电后先拉低 CS 发 0xAB(Release Power Down)唤醒芯片,然后发 0xFF(Reset)复位,等 1ms 让芯片稳定。接着读 JEDEC ID 确认通信正常:发 0x9F,读回 3 字节,W25N01G 应该是 0xEF 0xAA 0x21。如果读回全 0xFF 或全 0x00,检查接线和时钟极性。我遇到过读回 0x00 的情况,查了半天是 CS 拉低太早,芯片还没准备好,加 10us 延时就好了。
SPI 配置参数我列个表,方便对照:
| 参数 | 值 | 说明 |
|---|---|---|
| 时钟频率 | 10MHz | 最高支持 104MHz,但 10MHz 足够稳定 |
| 模式 | Mode 0 | CPOL=0, CPHA=0 |
| 数据位 | 8 位 | 标准 SPI |
| 片选 | 软件控制 | 每次命令前拉低,命令后拉高 |
| 字节序 | MSB first | 地址和命令都是高位在前 |
3.2 全片坏块扫描的实现
扫描函数的核心逻辑:循环 1024 个块,对每块读第 0 页 spare 区第 0 字节,非 0xFF 则置位坏块表。代码框架如下(伪代码,基于常见 HAL 库风格):
#define BLOCK_COUNT 1024 #define PAGE_SIZE 2048 #define BAD_BLOCK_OFFSET 2048 uint8_t bad_block_map[BLOCK_COUNT / 8]; void scan_bad_blocks(void) { memset(bad_block_map, 0, sizeof(bad_block_map)); for (uint16_t blk = 0; blk < BLOCK_COUNT; blk++) { uint32_t addr = (uint32_t)blk << 6; // 块号左移6位得页地址 nand_page_read(addr); // 发0x13读第0页 nand_wait_ready(); // 等OIP清零 uint8_t marker = nand_read_byte(BAD_BLOCK_OFFSET); // 读spare区 if (marker != 0xFF) { bad_block_map[blk / 8] |= (1 << (blk % 8)); } } }这里nand_page_read发 0x13 加 24 位地址,nand_wait_ready轮询状态寄存器 bit0(OIP),nand_read_byte发 0x03 加 16 位 column 地址读 1 字节。实测这个循环在 10MHz 下约 80ms,可以接受。
优化点:如果嫌 80ms 长,可以把 Page Read 和 Read 合并——发 0x13 后不等 ready 直接发 0x03?不行,必须等。但可以批量:先对所有块发 Page Read(每块之间不等 ready,靠芯片内部队列),再统一读。W25N01G 不支持命令队列,所以这个优化不成立。另一个思路是降低 SPI 时钟到 5MHz 反而更稳,因为高速下信号完整性差可能导致误读,我实测 10MHz 没问题,但如果你板子走线长,建议降到 5MHz 先跑通再提速。
3.3 映射表构建与读写跳过实现
映射表构建:遍历逻辑块 0 到 1023,维护一个物理块指针,从 0 开始,遇到坏块就递增跳过,给每个逻辑块分配一个物理块。代码:
uint16_t logic_to_phys[BLOCK_COUNT]; void build_mapping(void) { uint16_t phys = 0; for (uint16_t logic = 0; logic < BLOCK_COUNT; logic++) { while (phys < BLOCK_COUNT && is_bad_block(phys)) { phys++; } if (phys >= BLOCK_COUNT) { // 可用块耗尽,错误处理 break; } logic_to_phys[logic] = phys; phys++; } }读写时,上层给逻辑块号,查logic_to_phys得物理块号,再操作。比如写逻辑块 5 第 3 页:物理块 =logic_to_phys[5],页地址 = 物理块 << 6 | 3,发 Program Load + Program Execute。读同理。
这里有个边界情况:可用块数少于逻辑块数。1024 个块里如果有坏块,可用块就少于 1024,映射表末尾的逻辑块会分不到物理块。我的处理是:映射表初始化为 0xFFFF,表示无效,上层访问到无效逻辑块时返回错误。实际产品中,坏块率通常低于 2%,损失 20 个块对 1Gbit 容量影响很小,上层文件系统会避开这些无效块。
3.4 写入失败与坏块动态标记
每次写页后检查状态寄存器 P_FAIL 位:
int nand_program_page(uint16_t phys_blk, uint8_t page, uint8_t *data) { uint32_t addr = ((uint32_t)phys_blk << 6) | page; nand_write_enable(); nand_program_load(addr, data, PAGE_SIZE); nand_program_execute(addr); nand_wait_ready(); uint8_t status = nand_get_status(); if (status & 0x01) { // P_FAIL mark_bad_block(phys_blk); // 更新坏块表 return -1; } return 0; }mark_bad_block置位坏块表对应位,但不重建映射表(因为重建会导致已有数据错位)。这个块在本次上电周期内被标记,后续映射表里指向它的逻辑块会读到旧数据或错误,上层需要处理。下次上电重扫时,这个块会被正式识别为坏块,映射表重建时跳过它。这种"延迟生效"策略是权衡的结果:保证当前数据一致性优先,坏块迁移交给上层或下次上电。
注意:动态标记坏块后,如果该块存有关键数据,数据就丢了。所以关键数据建议做冗余存储(比如双备份),或者上层文件系统带校验和重试机制。W25N01G 本身不带坏块替换池,这是低成本方案的固有局限。
4. 常见问题与排查技巧实录
4.1 坏块识别不准的典型原因
问题一:读回坏块标记全是 0xFF,但明明有坏块。排查顺序:先确认读的是 spare 区不是主区,column 地址是不是 2048;再确认 Page Read 后有没有等 ready,没等就读缓存是旧数据;最后确认 ECC 是否关闭,开了 ECC 可能把坏块标记纠正成 0xFF。我遇到过第三种,查了两天才发现是 ECC 使能位没清。
问题二:坏块标记读出来是随机值。大概率是 SPI 时序问题,时钟太快或 CS 控制不当。降到 5MHz 试试,或者在 CS 拉低后加 1us 延时。还有一种可能是地址算错,块号左移 6 位得页地址,但有人误左移 5 位(页大小 2048 是 2 的 11 次方,块内页数 64 是 2 的 6 次方,所以块号左移 6 位得页地址,没错)。
问题三:扫描时好时坏,同一块两次扫描结果不同。检查电源稳定性,NAND 对电源纹波敏感,VCC 上并个 0.1uF 电容。另外确认扫描前有没有发 Reset 命令,芯片状态不干净会导致读异常。
4.2 读写跳过逻辑的常见坑
坑一:映射表没初始化就查。logic_to_phys是全局数组,默认全 0,如果build_mapping没调用就读写,所有逻辑块都映射到物理块 0,数据全串。解决:初始化时显式 memset 为 0xFF,并在读写函数入口检查映射表是否有效。
坑二:坏块表位运算写错。bad_block_map[blk / 8] |= (1 << (blk % 8))这个写法没问题,但有人写成1 << blk导致越界。建议封装成set_bad_block(blk)和is_bad_block(blk)两个函数,内部处理位运算,外部不直接操作数组。
坑三:擦除整块时跨了坏块。如果上层按连续地址擦除,可能一次擦多个块,其中夹着坏块。驱动层要按块拆分擦除请求,逐块检查坏块表,坏块跳过。我见过有人直接发连续擦除命令,结果坏块擦除失败导致后续好块也没擦干净。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 读 JEDEC ID 全 0xFF | SPI 未连通 | 查 CS/CLK/MOSI/MISO 接线 | 修正接线,确认模式 0 |
| 读 JEDEC ID 全 0x00 | 芯片未就绪 | 上电延时是否足够 | 加 1ms 延时再发命令 |
| 坏块标记全 0xFF | 读了主区或 ECC 干扰 | 确认 column=2048,关 ECC | 改读 spare 区,清 ECC 位 |
| 坏块标记随机 | SPI 时序不稳 | 降时钟到 5MHz 测试 | 降频或加 CS 延时 |
| 写入返回失败 | 目标块是坏块 | 查坏块表 | 跳过坏块,标记并重试下一块 |
| 读回数据错位 | 映射表不一致 | 检查读写是否查同一张表 | 统一用 logic_to_phys |
| 扫描耗时过长 | 逐块 Page Read 慢 | 测量实际耗时 | 可接受则保留,否则优化 |
4.4 独家避坑心得
第一个心得:扫描坏块前先发一次全片 Reset。W25N01G 上电状态不确定,直接扫描可能读到脏数据。发 0xFF 复位后等 1ms,再开始扫描,稳定性明显提升。这个细节手册里没强调,但实测有效。
第二个心得:坏块表打印出来人工核对。调试阶段把坏块表以十六进制打印,对照芯片手册的坏块分布(通常集中在特定区域),如果发现坏块数量异常多(比如超过 50 个),大概率是扫描逻辑有 bug 而不是芯片真坏了。我有一批板子扫出 200 多个坏块,最后发现是 SPI 读函数在特定地址返回错误值,修好读函数后坏块数降到 2 个。
第三个心得:写保护引脚别悬空。WP 引脚悬空可能被干扰导致写入被保护,表现是 Program Execute 后数据没写进去但状态显示成功。把 WP 上拉到 VCC,问题消失。这个坑很隐蔽,因为状态寄存器不报错,只有读回数据才发现没写进去。
第四个心得:保留块不要用最后几个块。有些方案把坏块表存在最后几个块,但最后几个块恰好是坏块高发区(厂商测试时末尾块容易出问题)。如果要持久化坏块表,选中间区域的块,并且做双备份。
5. 性能优化与扩展思考
5.1 扫描速度的实测优化
前面说 1024 次 Page Read 约 80ms,我实测在 10MHz SPI 下是 75ms 左右。如果产品要求上电 50ms 内完成初始化,这个时间就超了。优化思路:只扫描实际使用的块范围。如果应用只用前 256 个块,就只扫 256 个,时间降到 20ms。但这样后面块如果有坏块,用到时才暴露,需要动态标记兜底。我的建议是:启动时间敏感的场景用范围扫描 + 动态标记,不敏感的场景全扫,简单可靠。
另一个优化:用 Cache Read 命令(0x1B)替代 Page Read + Read。0x1B 是快速读,直接从指定地址读数据,不需要先 Page Read。但 0x1B 读的是主区,spare 区还是要 Page Read。所以这个优化对坏块扫描不适用,只对数据读有用。
5.2 坏块管理与上层文件系统的配合
驱动层的坏块管理是"块级"的,上层文件系统(如果有)是"文件级"的。两者要配合:驱动层保证逻辑块到物理块的映射稳定,文件系统保证数据有冗余。比如用 LittleFS 或 SPIFFS 这类带磨损均衡和坏块处理的文件系统,它们自己会处理坏块,驱动层只需要提供原始的块读写接口和坏块查询接口。我的做法是驱动层暴露is_block_bad(logic_blk)接口,文件系统格式化时跳过坏块,运行时写入失败时通知文件系统重试。
如果不用文件系统、直接裸写,那应用层要自己做数据校验(CRC 或校验和),写入失败时换块重写。这种方案简单但可靠性依赖应用层实现,适合数据量小、逻辑简单的场景。
5.3 从 W25N01G 迁移到其他 NAND 的注意点
W25N01G 的坏块管理逻辑可以迁移到同系列(W25N02G、W25N04G),主要改块数和页数参数。但迁移到其他厂商的 SPI NAND 要注意:坏块标记位置可能不同。有的芯片标记在第 0 页 spare 区第 0 字节,有的在第 1 字节,有的用两个字节。迁移前必须查目标芯片手册的 Bad Block Management 章节,确认标记位置和判定值。另外 ECC 配置也不同,有的芯片 ECC 是硬件自动的,关不掉,那扫描时就要考虑 ECC 对 spare 区的影响。
我个人的体会是:坏块管理这套逻辑本身不复杂,复杂的是各种边界情况和芯片差异。把识别、映射、跳过三个环节各自封装成独立函数,参数化块数、页大小、标记偏移,迁移时只改配置不改逻辑,能省很多事。踩过几次坑之后,我现在写任何 NAND 驱动,第一件事就是把坏块扫描和映射表建好,再写读写,顺序反了后面全是返工。