ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Nor Flash与Nand Flash读写核心差异及嵌入式驱动实战

Nor Flash与Nand Flash读写核心差异及嵌入式驱动实战

做嵌入式项目的人,早晚都得跟Nor flash和Nand flash打交道。我印象最深的一次,是做一个同时需要存储代码镜像和运行日志的设备——板上既有SPI Nor,也挂了一片并行Nand。当时我以为两类Flash读写差不多,无非“发命令、等状态、搬数据”三步走,结果被页边界、块擦除、坏块跳过这些细节折腾了好几个晚上。回头看课程015-016里讲的Nor flash和Nand flash读写,讲的是入门框架,但真正到了项目里,值得掰开揉碎的地方远比课程代码多。这篇文章不按课本章节复述,只聊我在实际调试里验证过的那些东西:两种Flash的物理差异、驱动框架怎么搭、坏块怎么处理,以及同一个系统里两种Flash并存时的存储规划。

1. 同样是Flash,为什么Nor和Nand的读写代码几乎无法通用

很多人一上来就背命令表:SPI Nor读数据是0x03,Nand读页是0x00加0x30。命令能背下来,但换个芯片、换个场景就抓瞎。根子在于,这两种Flash从存储单元的物理结构开始就是两条路线,理解到这个层面,驱动代码里那些“奇怪的多余步骤”才解释得通。

1.1 存储单元结构差异:并联与串联决定访问方式

Nor flash的名字来自NOR门。它的存储单元并联在位线和地线之间,每个单元相当于独立的电阻通路,外部电路能通过行选和列选直接定位到任意一个字节的地址。这意味着Nor天然支持随机读取、按字节寻址,甚至可以直接挂在CPU地址总线上,代码原地执行,这就是常说的XIP(Execute 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字节备用区(OOB),64个页组成一个块,所以一个块是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(); // 读状态检查BIT0:0表示编程成功,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”的决定。少了ECC,Nand看似读写都正常,跑一段时间后数据静默损坏,等到发现时可能整个文件系统都起不来。我在日志存储项目里就见过这种:连续跑了几天,日志文件随机花掉,最后定位就是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的WIP,Nand轮询0x70的RDY位和编程/擦除失败位。调试阶段我习惯把状态值打印到终端,连续监控几次,能看出芯片是不是频繁超时。如果某次擦除等了几百毫秒才完成,且经常发生,就要怀疑供电、时钟频率或者芯片本身到了寿命末期。

第三板斧是“地址打印”。Nand的行列地址拆分很容易错,调试时把每次访问的列地址、行地址、块号换算关系全部打印出来,对照数据手册核对。别直接调代码,先看地址对不对。我有一次排查Nand写错页,就是靠打印地址定位到块号转行地址少乘了页数,导致整片数据错位。

4.3 一些个人心得

先说磨损均衡。Nor寿命虽然长,但如果固定扇区频繁写配置,时间长了还是会出问题。最简单的方法是“轮换区”,安排多个扇区循环使用,每次写新的,旧的标记无效,偶尔做一次回收。Nand的磨损均衡更复杂,简单文件系统里可以用“顺序整块写”的思路,避免同一物理块反复擦写。

再说初始化时间。Nand上电扫描坏块在大容量下可能要几秒,系统如果对启动时间敏感,可以考虑把坏块表缓存到Nor,下次启动直接用缓存,只在后台异步校验。这招能让设备启动肉眼可见地变快。

最后是升级安全。固件放Nor、升级包放Nand的方案,升级时先把包写到Nand暂存区,校验通过再搬进Nor的备份区,最后切换启动标志。这样即使升级过程断电,最多回到旧固件,不会变砖。别问我是怎么想到这招的——都是被“升级断电花屏”逼出来的。

Nor和Nand听起来都是Flash,用起来却是完全不同的两门手艺。Nor的驱动像绣花,精细、直接、干净;Nand的驱动像清障,得处理坏块、ECC、映射一堆事。但把它们放在一个系统里各管一摊,恰恰能互补长短。把两边的特性吃透,存代码和存日志就不会再打架了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进