
做嵌入式项目的人早晚都得跟Nor flash和Nand flash打交道。我印象最深的一次是做一个同时需要存储代码镜像和运行日志的设备——板上既有SPI Nor也挂了一片并行Nand。当时我以为两类Flash读写差不多无非“发命令、等状态、搬数据”三步走结果被页边界、块擦除、坏块跳过这些细节折腾了好几个晚上。回头看课程015-016里讲的Nor flash和Nand flash读写讲的是入门框架但真正到了项目里值得掰开揉碎的地方远比课程代码多。这篇文章不按课本章节复述只聊我在实际调试里验证过的那些东西两种Flash的物理差异、驱动框架怎么搭、坏块怎么处理以及同一个系统里两种Flash并存时的存储规划。1. 同样是Flash为什么Nor和Nand的读写代码几乎无法通用很多人一上来就背命令表SPI Nor读数据是0x03Nand读页是0x00加0x30。命令能背下来但换个芯片、换个场景就抓瞎。根子在于这两种Flash从存储单元的物理结构开始就是两条路线理解到这个层面驱动代码里那些“奇怪的多余步骤”才解释得通。1.1 存储单元结构差异并联与串联决定访问方式Nor flash的名字来自NOR门。它的存储单元并联在位线和地线之间每个单元相当于独立的电阻通路外部电路能通过行选和列选直接定位到任意一个字节的地址。这意味着Nor天然支持随机读取、按字节寻址甚至可以直接挂在CPU地址总线上代码原地执行这就是常说的XIPExecute In Place。很多路由器、板卡把Bootloader和核心固件放在Nor里上电直接取指不用先把代码搬到RAM。Nand flash的单元则是串联结构一列单元串成一条链位线只接在链的顶端。想读某一页数据芯片必须把整条链上的状态组合搬进页缓存主机再通过IO引脚把缓存内容一口口取走。所以Nand根本没有“随机访问某个字节”的说法最小读取单位是页。这个差异会连锁影响到所有操作寻址方式不同Nor用线性地址SPI帧里发一个地址偏移就行Nand要分命令周期、地址周期、数据周期地址还要拆成列地址和行地址两段发送。擦除粒度不同Nor擦除最小单位通常是4KB扇区或32KB/64KB块Nand擦除最小单位是128KB甚至更大的块。编程粒度不同Nor可以字节编程也可以按页编程比如256字节页Nand编程最小单位就是一整页半个字节能写进去的情况基本不存在。这些差异不是设计者的偏好是物理结构逼出来的。Nor要做到随机读取就必须让每个单元都能独立寻址代价是单位容量成本高、容量做不大Nand为了高密度串成链牺牲了随机访问换来了大容量和相对低的单位成本。1.2 容量、寿命与场景的天然分工把两类Flash放在一张表里看分工非常清楚。对比项Nor flashNand flash典型容量1Mb ~ 128Mb128Mb ~ 数Gb读取方式按字节/字随机读按页读取2KB/4KB/8KB擦除单位扇区4KB、块32KB/64KB块128KB/256KB编程单位字节或页页是否支持XIP支持不支持典型擦写寿命10万次量级SLC约10万次MLC/TLC几千到几万次随机读表现好一般有建立时间顺序读吞吐受SPI时钟限制高页缓存大Nor的优势在“小而快”启动代码、关键配置、设备参数这类小数据随机读、按字节改、寿命长非常合适。Nand的优势在“大而密”文件系统镜像、日志、媒体资源、升级包动不动几十几百MB用Nor要么贵要么塞不下Nand一片就解决。顺便说一句Nor的擦写寿命标称通常不低但实际项目里如果频繁改写配置参数还是建议做磨损均衡Nand的寿命和制程关系很大SLC耐用MLC/TLC就娇贵一些。1.3 课程里最容易带过的两个“系统级差异”第一是文件系统复杂度。Nor因为可以随机读、按字节写裸设备直接当一块带偏移的地盘用自己维护一份“配置头数据区”就行。Nand不行页读写、块擦除、块会坏、数据会翻转任何像样的文件系统都得有逻辑块映射、坏块表、磨损均衡。这就是为什么同样跑文件系统Nand方案比Nor方案笨重得多。第二是“延迟”的含义完全不同。Nor的随机读延迟很小但连续搬大块数据时SPI时钟频率就是天花板想快得开快闪模式。Nand的单页读吞吐很高可每次访问都要先把命令和地址喂进去随机搬运小块数据反而吃亏。所以不要把Nor的“读得快”直接等同于“大块读得快”也不要把Nand的“吞吐高”理解为“随便读都高”。理解了这些再回头看课程015和016的代码很多细节就顺了。比如Nor为什么要先发写使能再擦除Nand为什么每次读页都要发一整串地址——不是芯片厂商故意刁难是访问机制本来如此。2. Nor flash读写实操指令集、驱动框架与四个经典坑Nor的驱动在SPI Nor里面一般比较直接。常见的SPI Nor器件不管哪个品牌核心命令基本一致0x06写使能、0x03读数据、0x20扇区擦除、0x02页编程、0x05读状态寄存器。下面的流程以这类通用命令为例具体型号请以数据手册为准。2.1 从SPI帧结构理解Nor指令集先看最简单的读操作。主机把片选CS拉低在时钟线上发送0x03命令码紧跟着24位或32位地址然后主机模式切换成接收芯片从该地址开始连续吐数据。整个传输过程中CS一直保持低电平读多少字节由主机决定。这里有个容易被忽略的点Nor的连续读是线性的地址会从当前地址不断递增跨页也继续递增不需要额外命令。所以读数据几乎不用关心页边界写数据才要关心。再看写操作。Nor写数据叫“页编程”常见页大小是256字节。操作过程是CS拉低发0x06写使能然后发0x02页编程命令、三字节地址、数据内容最后拉高CS。芯片内部会把数据从页缓存写入存储阵列。页编程前必须擦除过否则写入结果不对。擦除操作更粗暴发0x06发0x20发三字节地址拉高CS等着。扇区擦除把地址所在的那一整块4KB区域全部写成0xFF。擦除期间芯片忙状态寄存器的BIT0会一直为1。写使能命令在这里至关重要每一次擦除和编程之前都必须重新发0x06这是一个独立的SPI事务不能和后续命令拼在一个帧里。2.2 最小可用Nor驱动框架实际工程里我习惯封装四个基础接口读、写、擦除、等空闲。伪代码框架如下void nor_write_enable(void) { spi_cs_low(); spi_transmit(0x06); spi_cs_high(); } uint8_t nor_read_status(void) { uint8_t status; spi_cs_low(); spi_transmit(0x05); status spi_receive(); // 此处同时发0x00 spi_cs_high(); return status; } void nor_wait_busy(void) { while (nor_read_status() 0x01) { delay_us(10); } } void nor_read(uint32_t addr, uint8_t *buf, uint32_t len) { spi_cs_low(); spi_transmit(0x03); spi_transmit((addr 16) 0xFF); spi_transmit((addr 8) 0xFF); spi_transmit(addr 0xFF); for (uint32_t i 0; i len; i) { buf[i] spi_receive(); } spi_cs_high(); } void nor_page_program(uint32_t addr, const uint8_t *buf, uint32_t len) { nor_write_enable(); spi_cs_low(); spi_transmit(0x02); spi_transmit((addr 16) 0xFF); spi_transmit((addr 8) 0xFF); spi_transmit(addr 0xFF); for (uint32_t i 0; i len; i) { spi_transmit(buf[i]); } spi_cs_high(); nor_wait_busy(); }注意一点nor_page_program里我限制len不能跨页。不同芯片页大小可能不同256字节最常见。如果要在跨页地址写更长数据必须拆成多笔页编程前一页写满下一笔从新页地址开始。有的芯片也支持1-1-4或1-4-4模式传输效率更高前提是主机SPI控制器和引脚配置支持。2.3 四个经典坑跨页、0xFF、编程不可逆、忙状态轮询坑一页编程跨页。这是新手最容易踩的。地址0x00FF开头的页往里写300字节芯片不会乖乖跨到下一页继续写而是可能回卷或直接写入失败。我见过有人写日志一页缓存攒满256字节就写了第二遍结果日志内容被覆盖成乱码。正确做法是每次写前先算剩余页空间不够就拆。坑二擦除后必须读到0xFF才算干净。判断Nor一个扇区是否可用最直接的办法是读回来每一个字节都应该等于0xFF。我曾经遇到擦除命令返回正常、但后面写数据再读回出现随机错误的情况排查很久才发现是供电不稳导致擦除不彻底。写数据前若有条件把目标区域读一遍确认虽然耗时但在关键分区比如Bootloader备份区很值。坑三编程是不可逆的只能把1变成0。如果要改一个字节从0x00改成0xFF编程操作做不到必须先整扇区擦除才能重新编程。这个特性让Nor的“按字节写”看起来灵活实际上频繁小改动也会带来擦除放大。设计配置存储时尽量把“新配置写入新位置、旧位置标记失效”这类思路用起来比每次都擦扇区高效得多。坑四忙状态轮询不要只看一条命令。写状态寄存器、擦除、编程都可能忙。有些芯片的读状态命令是0x05但状态寄存器里BIT0才是WIP有些还细分补写了、擦除了等标志。驱动里最好封装nor_wait_busy并且给一个超时上限比如扇区擦除正常是几百毫秒给3秒上限就够避免芯片异常时驱动死循环。3. Nand flash读写实操页块寻址、坏块管理与ECC一个都不能少Nand的驱动比Nor高一个难度等级。不只是命令多而是Nand本身带“傻大粗”的属性容量大、密度高但不保证每个块都可靠。驱动里如果少了坏块跳过和ECC时间一长数据就花。3.1 先建立“页-块”的空间概念以常见的大页Nand为例一个页通常2KB用户数据加64字节备用区OOB64个页组成一个块所以一个块是128KB。备用区不全是给用户用的里面有坏块标记位、ECC校验值、逻辑地址信息实际用户数据就是那2KB。由于一页2KB而IO通常是8位或16位并行访问一页需要拆成多个周期。关键是地址Nand地址由列地址和行地址组成。列地址选的是页内偏移从0到2047需要两字节12位有效高位补零行地址选的是块号和页号按器件容量可能需要三字节。所以一次读页操作命令后通常跟五个地址周期两字节列地址加三字节行地址顺序不能反。我第一次写并行Nand驱动时把行地址和列地址发送顺序写反了读出来的数据全乱。后来查手册才发现地址周期是“先列后行”数据手册上的表格写得很清楚但当时急着调代码压根没细看。3.2 读、写、擦除的完整操作链并行Nand的读页流程长这样void nand_send_cmd(uint8_t cmd); void nand_send_addr(uint8_t addr); uint8_t nand_read_io(void); void nand_write_io(uint8_t val); int nand_read_page(uint32_t row, uint32_t col, uint8_t *buf, uint32_t len) { nand_send_cmd(0x00); nand_send_addr(col 0xFF); nand_send_addr((col 8) 0xFF); nand_send_addr(row 0xFF); nand_send_addr((row 8) 0xFF); nand_send_addr((row 16) 0xFF); nand_send_cmd(0x30); // 等待RB引脚变高或轮询状态寄存器 nand_wait_ready(); for (uint32_t i 0; i len; i) { buf[i] nand_read_io(); } return 0; }注意0x00表示从页缓存第0字节开始读0x30表示“真正触发读数据装载到缓存”。命令发完要等芯片忙完判断方式有两种查RB引脚或者发0x70命令查状态寄存器BIT6。轮询状态更通用因为有些系统RB引脚没连。写页流程刚好反过来int nand_write_page(uint32_t row, uint32_t col, const uint8_t *buf, uint32_t len) { nand_send_cmd(0x80); nand_send_addr(col 0xFF); nand_send_addr((col 8) 0xFF); nand_send_addr(row 0xFF); nand_send_addr((row 8) 0xFF); nand_send_addr((row 16) 0xFF); for (uint32_t i 0; i len; i) { nand_write_io(buf[i]); } nand_send_cmd(0x10); // 编程命令 nand_wait_ready(); // 读状态检查BIT00表示编程成功1表示失败 return (nand_get_status() 0x01) ? -1 : 0; }擦除流程更短按块擦int nand_erase_block(uint32_t block) { uint32_t row block * 64; // 假设每块64页 nand_send_cmd(0x60); nand_send_addr(row 0xFF); nand_send_addr((row 8) 0xFF); nand_send_addr((row 16) 0xFF); nand_send_cmd(0xD0); nand_wait_ready(); return (nand_get_status() 0x01) ? -1 : 0; }块号转行地址时行地址直接是“块的起始页号”。比如块号0行地址就是0块号1行地址就是64块号2行地址是128。这地方算错会擦到错误块我建议在驱动里单独封装block_to_row函数不要到处散算。3.3 坏块管理出厂坏块与使用坏块都要面对坏块是Nand绕不开的问题。制造时就可能有坏块称为出厂坏块厂家会在块内某一页的OOB区域做标记通常是把OOB第一个字节写成非0xFF值。使用过程中还会继续产生坏块原因包括擦写次数到了、块损坏、甚至意外掉电导致数据写一半。所以每颗Nand出厂自带的“坏块表”只能作为起点运行期还要靠驱动持续记录新坏块。我做坏块管理的心得是不要试图在驱动层把所有逻辑做完而是分两层处理。第一层扫描层。上电初始化时遍历所有块读第一页或第二页的OOB第一个字节不是0xFF就标记为坏块。这一层的同时最好把坏块表读进RAM。如果Nand容量大遍历会花一点时间但初始化阶段做一次值得。第二层映射层。维护一张“逻辑块号到物理块号”的映射表。应用层只认逻辑块0、1、2驱动层看到逻辑块就查映射表找到物理块。坏块从映射表里剔除分配块时跳过。映射表本身要存在好块里最好复制多份防止一个块坏了丢了全表的映射信息。有人会问坏块表存哪里小容量方案可以直接存在Nor的专用分区每次Nand初始化时读取大容量Nand也可在Nand本身保留两个区块存坏块表但要保证这两个区块本身不是坏块。我倾向于存Nor稳定、简单、坏的概率小。3.4 ECC与位翻转为什么Nand驱动少了它不稳Nand的位翻转是常态某一页里有一个或多个比特发生0变1、1变0读出来的数据和写进去的不一样。这不是设备坏了而是Nand的物理特性。写入和读出的过程电荷在浮置栅极里可能会有微小偏移越落后的制程越明显。配合MLC/TLC位翻转概率比SLC高不少。SoC自带的Nand控制器一般都有硬件ECC但这只在SoC方案里。如果是MCU外挂Nand或者RTOS里直接用软件驱动就得自己计算校验值。最简单常用的是汉明码每256字节数据可以算出3字节校验能纠正1位错误、检测2位错误。这是很多入门级小系统够用的方案。写入时先把数据送进芯片同时算好这页数据的ECC值然后把ECC值写入该页的OOB区域。读取时读出数据和OOB里的ECC重新计算一遍比对不一致就尝试纠错。纠错逻辑一旦判定错误位数超过能力比如发现两位翻转这一页数据只能视为不可靠通常的做法是把这个块标记为坏块重新搬运数据到好的块。别做“省掉ECC”的决定。少了ECCNand看似读写都正常跑一段时间后数据静默损坏等到发现时可能整个文件系统都起不来。我在日志存储项目里就见过这种连续跑了几天日志文件随机花掉最后定位就是Nand位翻转导致加一层简单汉明码就全部解决了。4. 两种Flash并存系统的选型与调试心得课程015-016是分开讲Nor和Nand但真实产品里两者往往共存。怎么规划存储怎么调试这比单独写某个驱动更考验经验。4.1 存储规划谁放进Nor谁放进Nand我在一个双Flash系统里最终是这样划分的存储内容介质原因BootloaderNor上电就能XIP执行稳定可靠核心固件镜像Nor可原地跑升级方便、风险小设备配置参数Nor经常随机小改动Nor按字节写灵活运行日志Nand数据量大追加写场景符合页写特性升级包暂存区Nand体积大Nand容量便宜文件系统数据Nand需要大容量、连续读写利于磨损均衡这个方案的关键考量是需要随机访问、频繁小改动、稳定高可靠的内容放Nor容量大、顺序写多、可容忍一定坏块的内容放Nand。两边各自的弱点正好被对方补上。4.2 调试三板斧读回比对、状态嗅探、地址打印第一板斧是“写后读回”。不管Nor还是Nand写完数据不要马上信芯片说成功必须把数据读回来比对。Nor的页编程如果数据线时序不对写进去的可能是垃圾Nand的编程有状态位但状态位正常也可能有静默位翻转。写后读回是成本最低的完整性检查。第二板斧是“状态嗅探”。Nor轮询状态寄存器0x05的WIPNand轮询0x70的RDY位和编程/擦除失败位。调试阶段我习惯把状态值打印到终端连续监控几次能看出芯片是不是频繁超时。如果某次擦除等了几百毫秒才完成且经常发生就要怀疑供电、时钟频率或者芯片本身到了寿命末期。第三板斧是“地址打印”。Nand的行列地址拆分很容易错调试时把每次访问的列地址、行地址、块号换算关系全部打印出来对照数据手册核对。别直接调代码先看地址对不对。我有一次排查Nand写错页就是靠打印地址定位到块号转行地址少乘了页数导致整片数据错位。4.3 一些个人心得先说磨损均衡。Nor寿命虽然长但如果固定扇区频繁写配置时间长了还是会出问题。最简单的方法是“轮换区”安排多个扇区循环使用每次写新的旧的标记无效偶尔做一次回收。Nand的磨损均衡更复杂简单文件系统里可以用“顺序整块写”的思路避免同一物理块反复擦写。再说初始化时间。Nand上电扫描坏块在大容量下可能要几秒系统如果对启动时间敏感可以考虑把坏块表缓存到Nor下次启动直接用缓存只在后台异步校验。这招能让设备启动肉眼可见地变快。最后是升级安全。固件放Nor、升级包放Nand的方案升级时先把包写到Nand暂存区校验通过再搬进Nor的备份区最后切换启动标志。这样即使升级过程断电最多回到旧固件不会变砖。别问我是怎么想到这招的——都是被“升级断电花屏”逼出来的。Nor和Nand听起来都是Flash用起来却是完全不同的两门手艺。Nor的驱动像绣花精细、直接、干净Nand的驱动像清障得处理坏块、ECC、映射一堆事。但把它们放在一个系统里各管一摊恰恰能互补长短。把两边的特性吃透存代码和存日志就不会再打架了。