调试嵌入式设备的时候,我总习惯先把存储芯片“认”出来,再谈后面的读写擦。就拿“第十五课(2)Nor Flash编程_识别”这个标题来说,很多人第一次接触Nor Flash驱动,上来就照着数据手册敲写指令,结果数据写不进去、读出来全FF,折腾半天最后发现芯片型号都没确认对。这一课之所以把“识别”单独拎出来讲,是因为它在Nor Flash编程链路里是真正的第一步,也是个“一步错步步错”的环节。
这篇文章就把Nor Flash编程里的识别环节彻底拆开,从SPI接口怎么接、JEDEC ID怎么读,到读出来之后怎么解析成容量和厂商信息,再顺带把识别之后的写使能、页编程、扇区擦除整条链路走一遍。不管你是刚学会GPIO和SPI操作、正准备移植文件系统的初学者,还是想把自己写的驱动从“能跑”变成“可靠”的开发者,都能从里面对上号。
1. 为什么“识别”是Nor Flash编程的第一关
1.1 NOR Flash在嵌入式系统里到底扮演什么角色
嵌入式系统里最常用的两类存储芯片,NAND Flash和NOR Flash,很多人只记得“NOR贵、容量小、NAND便宜、容量大”,但选型时真正看中的是特性差异。NOR Flash支持随机读取,地址线直接映射到线性空间,MCU可以从Flash里直接取指执行,这就是常说的XIP(Execute in Place),很多BootLoader和启动代码就放在NOR Flash里,上电后CPU直接从芯片读指令运行。NAND Flash则是按页和块寻址,没法线形映射,更适合大容量数据存储比如文件系统、日志和媒体文件。
所以开发者接触Nor Flash,多半是在做启动代码、OTA升级存储区、配置参数保存这类场景。这些场景对数据可靠性要求很高,你总不希望BootLoader把自己所在的扇区擦掉,也不希望升级程序写到一半因为Flash型号识别错误导致指令集不匹配。这就引出一个问题:不同厂商、不同容量的Nor Flash,虽然大部分SPI指令是兼容的,但在擦除块大小、状态寄存器定义、指令集扩展项上存在差异,程序启动后第一件事就是通过识别拿到准确参数,后面所有操作才有依据。
1.2 “识别(Identification)”究竟要识别哪些信息
标题里“Nor Flash编程_识别”这个表述,界定的就是这个环节。具体来说,识别要做三件事:确认总线上确实有一颗Flash芯片;拿到这颗芯片的JEDEC ID,也就是厂商ID加设备ID;根据ID信息确认容量密度、电压等级、指令集特征。
第一件事听起来多余,实际上很多故障就是这么排查出来的。芯片虚焊、引脚接触不良、SPI片选接错位,都会让总线上的设备“不存在”,这种时候发任何读写指令都得不到有效回复。第二件事是识别的核心,标准做法是发一条读JEDEC ID指令,芯片会回传一串固定字节,比如常见做法里厂商ID是高字节、设备ID是两字节,根据设备ID就能确定密度。第三件事是扩展信息,有些场景下需要读取SFDP(Serial Flash Discoverable Parameters)表,里面以标准化格式记录了芯片容量、扇区大小、擦除时间、供电电压等参数,比固定ID更完整。
很多时候项目里代码是从别人工程上抄来的,芯片换了个更大容量的型号,如果不做识别直接按旧参数操作,擦除扇区、写保护地址全都会错位。我自己遇到过用32Mbit芯片的工程去操作64Mbit芯片,页编程地址超出实际容量时芯片直接忽略高段地址,数据写没写进去都不知道,浪费了一整天调试时间。
1.3 识别失败的连锁反应
识别不只是读一个ID那么简单,它是后续所有操作的基石。芯片时间序参数、状态寄存器布局、保护位机制,都跟具体型号息息相关。识别失败之后,你写的页编程指令可能在某个厂商芯片上根本不支持,发送之后芯片不做任何响应,状态寄存器永远停在旧值;擦除指令可能把保护区域一并清了,或者擦除后读出来不是干净的0xFF。这些现象看起来像是驱动代码问题,根因却是识别环节出了问题。
所以我一直建议,拿到一块新的SPI Nor Flash,不要急着一上来就写大段的“读-写-擦-校验”流程,先单独写一个识别函数,把ID读出来打印到串口或者屏幕上,确认芯片信息完全匹配数据手册,再继续后面的操作。这一步能省下很多莫名其妙的调试时间。
2. SPI接口与Nor Flash的连接细节
2.1 SPI四线通信里的“四根线”到底怎么接
绝大多数Nor Flash芯片都用SPI接口通信,标准连接就是四根线:片选CS、时钟CLK、主出从入MOSI、主入从出MISO。片选是低电平有效,一次完整的指令操作都要以CS拉低开始、以CS拉高结束。操作过程中CS必须一直保持低电平,中间不能松开,否则芯片认为事务结束了。
硬件连接上有一个容易被忽略的细节:WP引脚和HOLD引脚。很多小封装的Nor Flash芯片把WP和HOLD引脚裸露出来,如果板上没接东西,默认状态悬空。WP是写保护输入,低电平有效,悬空时内部上拉不一定可靠,一旦被干扰拉低,状态寄存器里的块保护位就会被锁住,写使能指令发送成功但是实际写入操作全部被忽略。HOLD引脚在SPI通信过程中拉低会暂停通信,悬空同样存在干扰风险。我画板子处理这两根引脚的标准做法是:WP接3.3V上拉电阻,HOLD同样接上拉电阻,保证默认不触发保护也不触发暂停。
另外,供电电压和电平匹配也要注意。老一些的NOR Flash分3.3V和1.8V两种规格,用错了电压等级芯片可能直接无响应或者读写异常。大多数MCU的SPI引脚是3.3V电平,如果配合1.8V Flash,需要查一下引脚是否支持开漏加外部上拉到1.8V,或者直接选相同电压等级的芯片,从源头规避问题。
2.2 SPI Mode 0与时序参数
SPI有四种模式,由时钟极性CPOL和时钟相位CPHA组合决定。绝大多数Nor Flash芯片支持Mode 0和Mode 3,也就是CPOL=0/CPHA=0或者CPOL=1/CPHA=1,两种模式在通信前先把时钟极性设置成空闲状态,然后规定数据在哪个边沿采样。实际项目里最常用的是Mode 0:空闲时CLK为低电平,数据在上升沿采样,下降沿切换。如果你初始化的SPI模式不对,识别指令发出去芯片回传的数据会整个错位,读出来的ID完全不是预期值。
时钟频率也不能盲目拉高。不同型号的Nor Flash支持的最高SPI时钟频率不一样,常见值在50MHz到120MHz之间,但是读JEDEC ID指令一般建议用较低频率,因为识别阶段还没有获取到芯片的最高频率参数。尤其是刚上电或者刚复位之后,芯片内部时钟还没完全稳定,我在实测中发现某些芯片以高速率发RDID偶尔会回传错误字节,降低时钟频率之后就正常了。驱动初始化阶段把SPI时钟压在10MHz以下,等识别完成、确认芯片型号之后再按需提高,是比较稳妥的顺序。
3. 识别指令的核心:RDID与JEDEC ID
3.1 0x9F指令的时序与代码实现
读取JEDEC ID的标准指令是RDID,指令码0x9F,也是SPI Nor Flash最常见的识别方式。操作流程是:CS拉低,主机发送0x9F,然后持续输出时钟,从MISO读取三个字节,最后CS拉高。这三个字节一般定义为一个字节厂商ID加两个字节设备ID,不同厂家的设备ID长度统一是两字节。
伪代码实现大概长这样:
uint8_t nor_read_jedec_id(uint8_t *buf) { uint8_t cmd = 0x9F; uint8_t rx[3] = {0}; spi_cs_low(); spi_transfer_byte(cmd); // 发送RDID指令 rx[0] = spi_transfer_byte(0x00); rx[1] = spi_transfer_byte(0x00); rx[2] = spi_transfer_byte(0x00); spi_cs_high(); buf[0] = rx[0]; // 厂商ID buf[1] = rx[1]; // 设备ID高字节 buf[2] = rx[2]; // 设备ID低字节 return 0; }spi_transfer_byte实现的是全双工通信,主机发一个字节的同时接收一个字节,发0x00只是为了产生时钟。CS拉高之后,一次完整的事务结束。值得注意的坑是:有些MCU的SPI外设发送单字节后需要清标志位才能继续写下一个字节,如果清标志不及时可能导致时钟异常,读回来的字节错位。
很多芯片还支持在RDID事务中继续追加读取字节,会继续返回扩展ID信息,但标准三字节读取已经足够覆盖绝大多数识别场景。对应到生活里,这就类似你拿到一本书先看封面和版权页,确认书名、版次、出版社,然后再决定要不要细读内容。
3.2 把ID解码成具体的芯片信息
拿到三个字节以后,怎么知道这颗芯片容量多大、电压多少?这需要一份厂商ID对照表和设备ID对照表。ID是有行业标准约定的,比如出厂ID统一由JEDEC分配。下面这张表是我整理的一个常见对照,方便你在调试现场快速判断:
| 设备ID(两字节) | 容量 | 备注 |
|---|---|---|
| 0x4014 | 8Mbit (1MB) | 扇区大小4KB |
| 0x4015 | 16Mbit (2MB) | 扇区大小4KB |
| 0x4016 | 32Mbit (4MB) | 扇区大小4KB |
| 0x4017 | 64Mbit (8MB) | 扇区大小4KB |
| 0x4018 | 128Mbit (16MB) | 扇区大小4KB |
这些ID数值可以在芯片数据手册里查到,也可以把芯片焊到测试板上实测。实测识别的过程很简单:用逻辑分析仪抓取MISO上的数据,跟数据手册对照。有一次我拿到一片丝印磨掉一半的Flash芯片,就是靠RDID读出来的设备ID反查容量,再从丝印残余信息和封装尺寸推断出具体型号,这才编好对应的驱动。
解码时有一个容易误解的地方:设备ID的低字节和容量不是简单的线性对应关系,不同厂商对ID的定义不一样。所以不能只凭ID猜容量,最好对照数据手册里完整芯片型号和ID映射表。一个小技巧是看设备ID低字节的规律,同一个厂商内部不同容量往往按照0x14、0x15、0x16这样的递增写法,但不同厂商之间不一定遵循同样规律。
3.3 识别之后的扩展读取:SFDP参数表
JEDEC ID只给了芯片最基本的信息,如果想要拿到更丰富的参数,比如擦除时间、编程时间、支持的指令集、一次可编程的最大字节数,可以用SFDP(Serial Flash Discoverable Parameters)机制。SFDP是一条专门的标准指令,指令码一般是0x5A,芯片会返回一个结构化的参数表。
SFDP表里记录了符合JEDEC标准的能力参数,驱动可以统一从SFDP读取而不需要为每个型号写死参数表,这对提高代码通用性是很大的帮助。不过SFDP解析逻辑有点繁琐,要处理表头、DWORD对齐、参数组使能标志等,很多老芯片也不支持。我的建议是:项目里优先使用JEDEC ID识别核心信息,SFDP作为进阶手段,在需要跨平台通用驱动时才引入。如果识别阶段只做了JEDEC ID,遇到SFDP判断错误导致参数异常的情况,可以加一条诊断输出的日志,临时打印SFDP原始表内容,对照着分析会快得多。
4. 识别完成之后:从读ID到完整读写擦链路
4.1 写使能指令与状态寄存器轮询
读ID解决了“芯片是谁”的问题,真正往里面写数据,需要理解Nor Flash的两条行为规则:写入前必须先擦除;写入必须经过写使能确认。第一条是因为NOR Flash的存储单元只能从1写成0,想从0变回1只能靠块/扇区擦除操作。第二条则是为了安全,几乎每一条写指令(页编程、扇区擦除、状态寄存器写)执行前都要先发0x06写使能,芯片内部的状态寄存器里会置WEL位,WEL为1时才允许执行非易失性写操作。
写使能指令很简单:
void nor_write_enable(void) { uint8_t cmd = 0x06; spi_cs_low(); spi_transfer_byte(cmd); spi_cs_high(); }发送写使能之后,并不能立刻开始写数据,因为芯片需要时间把WEL位置位。最可靠的办法是读状态寄存器里的WIP(写进行中)位和WEL位判断是否就绪。标准做法是发0x05指令读取状态寄存器,循环检查BIT0(WIP位)是否为0。WIP为1说明芯片正在执行内部擦写操作,此时任何写指令都会被忽略。哪怕你已经按顺序发了写使能,WIP不为0时写操作照样不会启动。
我在代码里会这样实现等待:
void nor_wait_busy(void) { uint8_t status; do { spi_cs_low(); spi_transfer_byte(0x05); status = spi_transfer_byte(0x00); spi_cs_high(); } while (status & 0x01); }注意每次读状态寄存器都要完整地执行CS拉低、发指令、读数、CS拉高这样一个事务,不能把CS长时间拉低然后循环读字节,那样会把整个状态机搞乱。
4.2 页编程0x02与扇区擦除0x20
识别完成、等待就绪之后,最常用的两个操作是页编程和扇区擦除。页编程指令码是0x02,一次可以写最多256字节,但要写明目标地址。地址一般是24位或者32位,根据芯片容量选择字节数。页编程的关键限制是:一次事务内不能跨越256字节页边界。如果你的数据缓冲区正好跨页,必须拆成两次或多次页编程操作。
分页规则听起来让新人很懵,实际上就是“一次最多写当前页的剩余空间”。比如从地址0x0100开始写200字节,不跨页,没问题;但从地址0x00F0开始写40字节,就会从页的尾部跨到下一页,必须拆成先写16字节到页尾,再写24字节到下一页起始地址。否则芯片会把地址自动绕回页首,覆盖掉已经写好的数据,这是页编程最容易踩的坑。
扇区擦除指令码是0x20,一次擦除一个4KB扇区。擦除是把扇区所有位都写成1,也就是读出来的每个字节都是0xFF。擦除前同样要先发写使能,擦除过程耗时比页编程多得多,短则几十毫秒,长则数百毫秒,期间必须轮询WIP位等待完成。有些芯片还支持32KB/64KB块擦除和整片擦除,但项目里用得最少的就是整片擦除,因为一次几MB的擦除时间太长,中途断电还会损坏整个芯片的数据。
4.3 校验与回读:写进去的还要读得出来
写完数据之后不要直接认定成功,要执行回读校验。正常流程是:发读指令0x03,给定目标地址,连续读回数据,和缓冲区逐字节比较。读指令不需要写使能,只要芯片不忙就可以随时读取。
校验看起来简单,实际项目里还是有很多细节。比如擦除完一个扇区后,回读全部字节是否为0xFF;页编程完成后,回读页内字节是否和缓冲区一致。如果只是看状态寄存器WIP位从忙变成不忙就认为写成功,遇到芯片内部坏块或者地址映射异常,驱动也不会发现。在OTA或者关键参数存储场景,建议每次写完都做校验,校验数据不一致就要报错重试,不能硬往下走,否则固件损坏了都不知道。
5. 实操记录:一次从识别到写入的完整实验
5.1 硬件准备与接线清单
我在实验板上跑通识别流程时用的是一颗常见的SPI NOR Flash芯片,容量64Mbit,板载3.3V供电。接线清单如下:VCC接3.3V电源,GND接地,CS接MCU的PA4引脚,CLK接PA5,MOSI接PA7,MISO接PA6。WP和HOLD各接一个10kΩ上拉电阻到3.3V,这是我一直强调的硬件细节。
如果你自己搭电路,还要在VCC和GND之间加一个0.1uF去耦电容,芯片供电不稳会导致SPI通信偶发错误,表现为读取ID有一次正常有一次乱码。另外连线尽量短,SPI高速模式下长线会引入信号反射,影响数据采样。做实验阶段频率不高,短杜邦线也能正常工作,但要是发现ID读取不稳定,第一步检查的就是接线和供电,不是代码。
5.2 初始化与识别代码示例
初始化部分包括配置SPI外设和GPIO。以下是一段不依赖具体MCU库的示例,重点看逻辑层次:
void spi_nor_init(void) { gpio_init(CS_PIN, OUTPUT_PUSH_PULL); gpio_set(CS_PIN, HIGH); // 片选默认拉高 spi_init(SPI_MODE0, 1000000); // 1MHz开始,识别完成后再提速 } uint8_t spi_nor_identify(void) { uint8_t id[3]; nor_read_jedec_id(id); printf("JEDEC ID: 0x%02X 0x%02X 0x%02X\r\n", id[0], id[1], id[2]); if (id[0] != EXPECTED_MANUFACTURER_ID) { return 1; // 厂商ID不匹配 } if (id[2] != EXPECTED_DENSITY_ID) { return 2; // 容量ID不匹配 } return 0; }运行这段代码,串口输出类似“JEDEC ID: 0x00 0x00 0x00”或者“JEDEC ID: 0xFF 0xFF 0xFF”时,不要急着改代码。先把SPI时钟降到100kHz,排除速率因素;再用示波器看MOSI上有没有指令字节波形;最后检查MISO线上有没有数据回传。如果都正常,大概率是芯片没上电或者引脚没接对。
5.3 识别完成后的写入测试
识别成功之后,我会做一个最朴素的功能测试:选一个扇区,擦除它,回读确认全0xFF;然后写入一组已知数据,比如“ABCDEF0123456789”,回读逐字节比较;最后输出测试结果。这个测试能同时验证读、写、擦三条核心链路是否正常。
操作顺序上来讲,先擦除再写,不然在旧数据上做页编程会出现奇怪的结果。比如原来扇区数据不是全FF,部分位是0,你写入的新数据如果对应位是1,根本写不进去,回读数据跟缓冲区不一致。这类问题排查出来之后会让人非常困惑,实际就是没擦除干净。
我特意把测试数据定成包含0x00、0xFF、0x5A、0xA5这些特殊值,因为0x00和0xFF最容易暴露“写失败”和“擦除不干净”的问题。全部通过之后,再进入实际业务逻辑,比如BootLoader升级功能或者参数存储模块的开发。
6. 常见问题与排查技巧实录
6.1 读ID全是0xFF或者数据错乱
读JEDEC ID得到全0xFF,通常意味着芯片没有正确响应。排查顺序是先确认供电和地线,再看CS片选信号是否正常拉低,最后检查MISO引脚是否连对。我曾经遇到过MCU和Flash芯片的MISO引脚因为板子设计问题连到了另一个外设的MOSI上,通信完全错乱,读到的ID跟真实值完全对不上。
还有一种情况是SPI模式不匹配。模式0和模式3在通信协议层面只有极细微的时钟相位差异,肉眼很难看出来,但是读回来的数据会错位。如果怀疑模式有问题,直接切换SPI模式重新读取ID,对比两次结果。
ID数据半对半错,比如第一字节正确、后两字节不对,优先怀疑时钟频率过高或者主机在读数据时发送的哑元字节格式不对。RDID指令之后不管发什么字节,芯片返回的都是固定ID序列,但如果主机在读取阶段把MOSI上的数据影响到了内部状态,可能导致后续字节返回异常。比较少见但值得警惕。
6.2 识别成功但写入不生效
识别正常说明通信链路没问题,写入不生效一般集中在几个原因:没发写使能、WP引脚保护、状态寄存器块保护位被置位、地址超过容量范围。排查时可以先把状态寄存器完整打印出来,对照数据手册逐位查看。如果块保护位不为0,先用软件指令清掉保护,再检查WP引脚电平。
写使能这个坑特别常见。有些初学者认为SPI传输本身就代表“允许写”,但实际上Nor Flash必须严格按“写使能→写指令→等待空闲”这个顺序执行。写使能发完以后如果马上发页编程,一部分芯片会正确处理,另一部分芯片因为内部还没来得及置WEL位会直接忽略写操作,导致数据没写进去。稳妥的做法是在写使能后先读状态寄存器确认WEL位为1,再执行写入。
6.3 擦除后读不到干净的0xFF
擦除后还有部分地址不是0xFF,通常是你用的擦除指令和你的寻址粒度不匹配。比如芯片支持4KB扇区擦除,但你错误地只擦除了前256字节,后面的旧数据还在。使用扇区擦除时还要精确给出扇区起始地址,地址必须对齐到4KB边界,不能是扇区内的任意偏移。很多芯片对非对齐地址的擦除请求会直接忽略,或者擦除到偏移位置开始的区域,表现就是看起来擦成功了,实际数据残留。
如果确定地址对齐了还是擦不干净,检查芯片是否有块保护寄存器或者OTP区域。有些芯片出厂默认把某些区域设为写保护,需要先解除保护再擦除。这类问题最容易在换芯片型号时冒出来,上一个型号没有保护位,新换的型号默认开了保护,代码复用过来就罢工。
6.4 快速排查表
| 现象 | 优先排查项 | 可能原因 |
|---|---|---|
| 读ID全FF | 供电与接线 | 芯片未上电、MISO断开 |
| 读ID乱码 | SPI模式、时钟频率 | 模式不匹配、频率过高 |
| 写不进去 | 状态寄存器、WP引脚 | WEL未置位、写保护 |
| 擦除不干净 | 地址对齐、保护位 | 地址非扇区边界、块保护 |
| 随机偶发异常 | 去耦电容、线长 | 供电不稳、信号反射 |
这张表基本覆盖了Nor Flash调试初期会碰到的大部分问题。真碰到疑难杂症时,我还会加一条诊断手段:用逻辑分析仪抓取SPI三根线的完整时序,逐条对照数据手册里的时序图。很多看着像芯片坏了的问题,最后都是因为代码把CS拉低的时机不对或者命令中间CS被意外拉高。
7. 一点实操体会
我最开始调Nor Flash也走过弯路,上来就写整个文件系统驱动,结果连底层读写都没验证过,一出问题根本不知道从哪下手。后来学乖了,每接触一片新的Flash,先写一个单独的“探针”小程序,只做识别、擦除、读写校验三件事。这个习惯帮我省下了特别多时间,项目里很多同事拿着别人改过的驱动调试,调不出来还得回头补这步。
如果你现在正好卡在Nor Flash识别或者读写异常上,我建议你也先别急着改业务层代码,静下心把驱动拆到最简,从读ID开始一步步验证。识别这一关过去了,后面页编程、扇区擦除、校验、坏块管理都有自己的章法,一马平川不敢说,但至少不会再被基础的“0xFF风暴”折磨到怀疑人生。