裸机开发这摊事,绕不开的一个器件就是 Nor Flash。你可能帮它干过最简单的活:把编译好的代码烧进去、上电启动、跑起来。但你有没有想过,它内部到底是个什么结构?为什么写之前非得先擦除?为什么命令序列要按固定顺序发?我当年刚接触ARM裸板的时候,光是把“读ID”调通就折腾了整整一个下午,后来才发现是时序模式没配对。这篇文章就把我这些年在Nor Flash上攒下的基础知识、编程套路和调试教训一次讲清楚,配合可复现的裸机代码示例,希望能让刚入门的同学少走点弯路,也让已经写过驱动的人能回头把底层的“Why”补一补。
先给个共识:Nor Flash 是嵌入式系统里常见的一种非易失性存储器,掉电不丢数据,支持按字节随机读取,所以很多ARM处理器可以直接从它里面执行代码。它区别于另一个大家族 Nand Flash 的点,本质上来自存储单元的电路结构,这个结构决定了读写擦三种操作的行为差异,也决定了你写驱动的时候必须遵循那些看似“奇怪”的规则。
1. 从存储单元的底层结构说起
1.1 NOR 结构的“并联”特性决定了什么
Nor Flash 每个存储单元的核心是一只浮栅晶体管。这个管子的栅极悬浮在绝缘层中间,编程的时候往浮栅里注入电荷,相当于把阈值电压抬得很高;擦除的时候把电荷抽走,阈值电压又掉回低状态。关键是阵列的接法:所有存储单元的漏极(或者位线)并在一根线上,源极公共接地,所以每一位都能被独立选中、快速读出来——这一点和 CPU 访问内存的方式很像。
这种并联结构带来的直接优势就是读速度快、随机性好。地址信号过去,片选一拉,数据位上电信号很快稳定下来,完全能接在 SoC 的静态存储控制器上,像读内存一样直接读。但代价是:想写一个位只能从 1 改成 0,想从 0 改回 1 必须对整个块做擦除,把块里所有位全部恢复到 1。所以业界有个非常形象的总结:编程就是把 1 改成 0,擦除就是把整块恢复成全 1。
很多人刚接触的时候会问:为什么不能像 SRAM 那样直接覆盖写?原因就是物理结构决定的,浮栅注入电荷这个动作是按“位”单向进行的。你可以在已擦除(全 1)的区域里任意把若干位写成 0,但如果你想把这个 0 再变回 1,只能把整块区域擦掉重来。明白了这一点,你再看编程示例里的“先擦除再写”逻辑,就一点都不会觉得多余。
1.2 Nor Flash 和 Nand Flash,不是容量和价格那么简单
很多资料喜欢直接列对比表:Nor 容量小、价格贵、读快写慢;Nand 容量大、便宜、写快读慢。这些结论背后的原因恰好也是“结构决定行为”的延伸。Nand 的单元是串在一条位线上的,好处是面积小、密度高,坏处是没法按字节随机访问,只能按页读、按页写,擦除按块。Nor 的并联结构面积大,但随机访问性好,所以 4MB、8MB 甚至 16MB 就差不多了,再做大成本压不住。
从系统设计角度看,Nor 最大的价值是XIP(片内执行)。代码直接放在 Nor 里,处理器取指就能跑,不需要先搬运到 RAM。很多老式 ARM 板卡、单片机的小系统、BIOS ROM 都是这么干的。Nand 就不行,坏块管理、ECC 校验、页读取机制这些因素导致它不适合直接映射地址空间执行代码,一般要引导代码先把自己拷到内存里。
选型的时候参考这个对比会更清楚:
| 维度 | Nor Flash | Nand Flash |
|---|---|---|
| 存储单元连接 | 并联 | 串联 |
| 随机读取 | 支持,按字节 | 不支持,按页 |
| XIP 执行 | 可以 | 不可以 |
| 编程粒度 | 字节/页编程 | 页编程 |
| 擦除粒度 | 扇区(通常 4KB) | 块(通常 128KB+) |
| 容量/成本 | 小/高 | 大/低 |
| 寿命 | 通常 10 万次 | 通常 1 万~10 万次(不同工艺差异大) |
| 常见用途 | 启动代码、固件、参数存储 | 文件系统、大容量数据 |
2. 开发板上的 Nor Flash 是怎么接入 ARM 的
2.1 并行接口与内存映射式访问
早期的 ARM 裸板,Nor Flash 基本都是并口接法。地址线、数据线、片选、读信号、写信号,一一对到处理器的外部存储控制器上。有些 SoC 支持“内存映射”模式,上电后 Nor 的内容直接被映射到 CPU 地址空间的某个区域,比如 0x00000000 或者 0x10000000,代码直接跳过去就能执行。这种模式下,读操作就是普通的 load 指令,处理器在总线上拉低片选和读使能,Flash 再把对应地址的数据放上数据总线。
写操作则要小心了。你不能直接对一个普通地址执行 store 指令就期望把数据写进 Flash,因为 Flash 有自己的状态机。你需要先往特定地址写入命令字、地址、数据,让内部状态机进入编程或擦除流程,然后再通过查询状态寄存器判断是否完成。芯片手册里会给很详细的命令序列表,比如“读 JEDEC ID”要先往地址 0x000000 写入命令 0x90,再读取 ID 数据。这种时序本质上是把 Flash 当成一个“带有隐藏控制线的外围设备”来访问。
并行接口的好处是速度上限高、访问逻辑简单,坏处是引脚占用太多。一个 16 位并口 Nor 动不动就占几十个引脚,小封装芯片根本放不下,所以现代小型嵌入式系统里并口 Nor 越来越少,反而是 SPI Nor 大行其道。
2.2 SPI Nor Flash 的接线和访问方式
现在的常见做法是 SPI Nor Flash 挂在 SoC 的 SPI 控制器(或者 QSPI)上。四根线:时钟 SCK、主出从入 MOSI、主入从出 MISO、片选 CS。地址不再存在总线上,而是你用指令发给它,比如发送“读数据”指令 0x03,后面跟 3 个字节地址,然后从 MISO 上把内容读回来。这种串行访问自然比并口慢一些,但对大多数 BootLoader、配置文件、固件存储场景完全够用。
我在实际裸板项目里接 SPI Nor 的时候,一定会先确认三件事:
- SCK 空闲电平和采样沿,也就是 SPI Mode。Nor Flash 一般支持 Mode 0(CPOL=0,CPHA=0)和 Mode 3(CPOL=1,CPHA=1),所以初始化 SPI 控制器时别拍脑袋写代码,查 arm 芯片手册或者直接看 Flash 手册的时序图。
- CS 低电平持续整个命令序列,中间不能抬。很多人调试时发现读出来的数偶尔对偶尔错,就是因为 GPIO 模拟 CS 的时候在字节之间拉了高电平,把器件状态机搞乱了。
- 时钟频率别拉到芯片不支持的值。某些杂牌 Nor Flash 芯片最大时钟没标清楚,你按 66MHz 跑,数据错乱了好几天,最后发现降频到 33MHz 一切正常。
2.3 内存映射模式下访问 SPI Nor?也可以
部分 SoC 设计了 QSPI 控制器,支持把 SPI Nor 映射到内存空间。原理是:初始化时往 QSPI 控制器里下发一条“读数据”指令,并配置好地址模式,然后控制器硬件自动产生时钟、在总线上形成存储映射访问。CPU 对这种区域执行普通读指令,控制器就会按时序去摸 Flash,读到数据后从数据总线返回。
这种模式下写操作就麻烦一点,要么切回指令模式,要么用控制器的特殊寄存器下发写命令。裸机编程如果只是想把上电引导代码放进去,我一般建议退出映射模式、用标准的 SPI 指令操作来写,写完了再重新映射读回来验证,避免被控制器的状态机细节坑到。
3. 指令集、状态寄存器和基本操作原理
3.1 为什么 Nor Flash 需要一套“命令序列”
你可能会想:既然是个存储器件,为什么不设计成“给地址就出数据、给数据和地址就写进去”?因为要平衡成本、可靠性和可编程性:内部状态机必须区分普通读操作和控制操作,还要控制高压编程/擦除的开关。所以 Nor Flash 规定了一些特定地址上特定数据值的组合作为命令入口,比如写使能指令 WREN 是 0x06 单字节命令,不需要地址。芯片检测到这个命令后,会把内部锁存位置位,允许后续编程/擦除操作,否则直接发编程命令会被忽略。
这套命令设计也带来一个常见问题:裸板程序里如果误把一个普通数据写到了命令地址,等于给 Flash 下了一条奇怪的指令。所以代码里操作 Flash 的地址和数据缓冲,一定要严格分开管理,别用同一块 buffer 又存命令地址又存有效载荷数据。我在工程实践中见过不止一次,某人改了个共用缓冲区的代码,结果“擦除正常、写入的数据变成指令码”,排错排到怀疑人生。
3.2 最常用的几条指令和状态寄存器位
以大多数 SPI Nor Flash 为例,几条核心指令像呼吸一样常用:
| 指令 | 命令码 | 地址/数据 | 用途 |
|---|---|---|---|
| WREN | 0x06 | 无 | 写使能,编程/擦除前必须发 |
| RDSR | 0x05 | 无地址,读1字节 | 读状态寄存器 |
| WRSR | 0x01 | 写1字节 | 配置保护位/状态位 |
| READ | 0x03 | 3字节地址,连续读 | 普通读取数据 |
| PP | 0x02 | 3字节地址+数据 | 页编程,通常每页256字节 |
| SE | 0x20 | 3字节地址 | 扇区擦除,常见4KB扇区 |
| BE | 0xC7 | 无地址 | 整片擦除 |
| RDID | 0x9F | 无地址,读多字节 | 读厂商ID和设备ID |
状态寄存器最关键的就是bit0 BUSY:1 表示 Flash 正在执行擦除或编程,0 表示空闲;还有bit1 WEL:写使能锁存位,1 表示允许写操作,0 表示写操作被禁止。所以在每个编程或擦除指令发出后,要循环读 RDSR 直到 BUSY=0,这个“轮询等待”是裸机代码里最常见的忙碌循环。有的同学在编程里加了一些奇怪延时代替轮询,结果不是等太短没写完,就是等太长拖慢速度,最稳的做法就是老老实实轮询。
3.3 为什么擦除一定是“扇区”或“整片”
前面已经说了,存储单元的物理结构决定了 0 到 1 必须靠擦除。但擦除动作本身是对一大片区域施加高压脉冲,内部电路设计决定了最小粒度为扇区(Sector),通常 4KB 或 64KB,更细粒度做不到。所以如果你只想改 1 个字节,也得先把所在的 4KB 扇区整个读出来放到 RAM,把那个字节改好,再整个扇区擦掉,最后把整块内容写回去。这个“读-改-写”流程在参数存储场景里非常常见。
还有一个容易忽略的点:扇区擦除是有时间参数的,不同芯片差别很大,典型值在 50ms 到几百毫秒之间。轮询 BUSY 的时候要有超时保护,不能死循环无限等。我习惯在等待函数里加一个超时计数,超过比如 5 倍典型擦除时间就报错退出,避免 Flash 芯片异常挂起时整个系统卡死。
3.4 多字节写入要小心“页”边界
SPI Nor Flash 的编程指令是页编程(Page Program),一页通常是 256 字节。你可以在一个 PP 指令里连续写入最多一页的数据,但地址超过页边界时会自动回卷到页首,覆盖掉页开头的旧数据,这绝对是裸机新手最容易踩的坑。
比如你想往地址 0x10 开始写 0x120 字节数据,直接发一个 PP 命令,地址 0x10 会写数据的前 0xF0 字节,然后地址计数器回卷到页首,后面 0x30 字节会覆盖页头 0x10~0x3F 的内容,数据全乱了。所以“跨页写入”必须在上层代码里手动分段,每段长度不超过当前页剩余空间,目标地址每段递增,每段发一次 PP 命令。下面代码会给出这个分段逻辑的完整实现。
4. 裸机编程示例:一个可复现的 SPI Nor Flash 驱动
4.1 先说清楚开发环境和我用的抽象方式
下面代码面向一个典型的 ARM 处理器,SPI 控制器寄存器和 GPIO 寄存器我用宏定义代替,方便移植到不同芯片。实际项目中你要替换成自己 SoC 手册里的寄存器定义。为了表达清楚,我假设有以下基础设施:
spi_cs_low()/spi_cs_high():拉低/拉高片选线spi_read_write_byte():通过 SPI 数据寄存器收发一个字节,返回收到的数据spi_set_mode():设置时钟极性和相位spi_set_speed():设置 SCK 频率udelay():微秒延时
这种抽象方式在裸机项目里很常见,你可以直接抄到自己的 BSP 层。
4.2 初始化 SPI 控制器和 Nor Flash
初始化的步骤是:先配置 GPIO 复用功能,把 SCK/MOSI/MISO/CS 脚设为 SPI 模式,然后设置 SPI 控制器为主模式、时钟极性相位为 Mode 0 或 Mode 3(Nor Flash 手册通常会推荐其中一个),同时把时钟频率控制在一个安全范围内。如果频率太高,板子的走线寄生电容或信号完整性问题就会出现,导致误码。
void nor_flash_init(void) { // 1. 配置 GPIO 复用,具体寄存器以 SoC 手册为准 gpio_set_alt_function(PIN_SCK, ALT_SPI); gpio_set_alt_function(PIN_MOSI, ALT_SPI); gpio_set_alt_function(PIN_MISO, ALT_SPI); gpio_set_alt_function(PIN_CS, ALT_SPI); // 也可以用普通 GPIO 控制,灵活起见很多人喜欢 GPIO 模拟 // 2. 配置 SPI 主模式,Mode 0:CPOL=0,CPHA=0 spi_set_mode(SPI_MODE_0); // 3. 工作频率初始设保守一点,比如 20MHz spi_set_speed(20000000); // 4. 片选默认高电平 spi_cs_high(); }这里我特意把 CS 拉高放在初始化最后一步。上电的时候 Flash 的状态机要求 CS 从高往低跳变才算一帧命令的开始,所以默认保持 CS 高电平很重要。如果你在自己板子上发现“复位后第一条命令发不出去”,多半是初始化过程中 CS 有过一段低电平的毛刺,Flash 状态机被弄糊涂了。
初始化完成后,可以马上读一次 JEDEC ID 验证通信链路。读 ID 的指令是 0x9F,指令后不需要地址,直接连续读厂商 ID 和设备 ID。这是裸机上硬件调试的“Hello World”:
void nor_flash_read_id(uint8_t *id_buf, uint32_t len) { uint32_t i; spi_cs_low(); spi_read_write_byte(0x9F); for (i = 0; i < len; i++) { id_buf[i] = spi_read_write_byte(0x00); } spi_cs_high(); }如果读回来的全是 0x00 或者 0xFF,先别急着怀疑 Flash 芯片坏了。很大概率是 SPI 模式反了(应该用 Mode 3 你用了 Mode 0),或者 MISO/MOSI 接反了,或者芯片供电没起来。
4.3 写使能、等待空闲和状态寄存器操作
Nor Flash 的规矩是:编程和擦除之前,必须先发写使能命令 0x06,把状态寄存器的 WEL 位置 1。这个命令非常简单,但我建议把它封装成一个函数,因为每次编程/擦除前都要调用:
static void nor_flash_write_enable(void) { spi_cs_low(); spi_read_write_byte(0x06); spi_cs_high(); } static uint8_t nor_flash_read_status(void) { uint8_t status; spi_cs_low(); spi_read_write_byte(0x05); status = spi_read_write_byte(0x00); spi_cs_high(); return status; } static void nor_flash_wait_busy(uint32_t timeout_ms) { uint32_t count = 0; while (nor_flash_read_status() & 0x01) { udelay(1000); count++; if (count > timeout_ms) { // 这里可以返回错误码,实际代码里建议用返回值上报 break; } } }等待循环里我用的是毫秒级计数。如果你做的是对时间敏感的场景,比如把擦除操作放在中断上下文里,那就得改造成非阻塞方式,依靠状态轮询回调推进状态机。不过裸机单线程项目里,阻塞等待通常完全够用,关键是超时上限设好。
4.4 读取数据、页编程和扇区擦除的实现
读数据指令 0x03 后面跟 3 字节地址,然后从 MISO 连续读数据。地址是大端顺序:高字节、中字节、低字节。代码实现:
void nor_flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; spi_cs_low(); spi_read_write_byte(0x03); spi_read_write_byte((addr >> 16) & 0xFF); spi_read_write_byte((addr >> 8) & 0xFF); spi_read_write_byte(addr & 0xFF); for (i = 0; i < len; i++) { buf[i] = spi_read_write_byte(0x00); } spi_cs_high(); }页编程的代码要点有两个:第一,编程前要写使能;第二,数据长度不能超过当前页剩余空间。下面这个函数实现“向任意地址写入连续数据,自动分段成多次页编程”,这也是我实际项目里直接用的一版:
void nor_flash_program(uint32_t addr, const uint8_t *data, uint32_t len) { uint32_t page_size = 256; uint32_t written = 0; while (written < len) { uint32_t remain_in_page = page_size - (addr % page_size); uint32_t chunk = (len - written) < remain_in_page ? (len - written) : remain_in_page; uint32_t i; nor_flash_write_enable(); spi_cs_low(); spi_read_write_byte(0x02); spi_read_write_byte((addr >> 16) & 0xFF); spi_read_write_byte((addr >> 8) & 0xFF); spi_read_write_byte(addr & 0xFF); for (i = 0; i < chunk; i++) { spi_read_write_byte(data[written + i]); } spi_cs_high(); nor_flash_wait_busy(100); written += chunk; addr += chunk; } }扇区擦除的命令是 0x20,后面跟 3 字节目标扇区地址。注意地址得落在本扇区内的任意位置,芯片内部会按照扇区对齐来擦除。我通常习惯把地址低位清零再发命令,逻辑更严谨一些:
void nor_flash_erase_sector(uint32_t addr) { addr &= ~(4096UL - 1); // 对齐到 4K 扇区边界 nor_flash_write_enable(); spi_cs_low(); spi_read_write_byte(0x20); spi_read_write_byte((addr >> 16) & 0xFF); spi_read_write_byte((addr >> 8) & 0xFF); spi_read_write_byte(addr & 0xFF); spi_cs_high(); nor_flash_wait_busy(1000); }整片擦除就更简单了,一条 0xC7 命令,随后等待。注意整片擦除时间可能长达几秒,超时时间必须给足,我习惯给 5 秒以上。整片擦除是一次性把全部扇区恢复成 1,适合量产时清空芯片或者调试时快速擦掉整个固件区域。
4.5 写一个简单的验证流程
代码写到这一步,我强烈建议你写一个自检函数,把读写擦串起来验证,免得后面 BootLoader 调不通时无法定位是 Flash 还是之前的内存问题。最简单的验证流程是:
- 擦除某个扇区(比如地址 0)。
- 读回整个扇区,全部应为 0xFF。
- 往扇区里写一组递增模式的数据,比如 0x00、0x01、0x02……
- 读回并逐字节比对。
- 对于跨页测试,可以从 0xF0 地址开始写 0x40 字节数据,专门考验跨页分段逻辑。
比如:
uint8_t read_buf[256]; uint8_t write_buf[256]; uint32_t i; nor_flash_erase_sector(0); nor_flash_read(0, read_buf, 256); for (i = 0; i < 256; i++) { if (read_buf[i] != 0xFF) { /* 擦除验证失败 */ } } for (i = 0; i < 256; i++) { write_buf[i] = (uint8_t)i; } nor_flash_program(0xF0, write_buf, 256); nor_flash_read(0xF0, read_buf, 256); for (i = 0; i < 256; i++) { if (read_buf[i] != write_buf[i]) { /* 数据不一致 */ } }这套流程每次移植驱动后我会跑一遍,确认过了再继续写上层逻辑。
4.6 更可靠一点:尝试状态码和坏块意识
无论并行还是 SPI Nor,理论上都有坏块,只是坏块比例通常很低。Nor Flash 不像 Nand 那样要求强制坏块管理,但严谨一点也可以在擦除或编程后读回校验,发现异常就把扇区标记为坏。这个开销对引导代码场景基本可以接受。量产烧写的时候,校验不仅是防止芯片坏,还可以发现夹持不稳、电压跌落等问题,所以我建议烧写器或者裸机烧录引导程序里都要做读回校验。
5. 实际调试中的坑与排查方法
5.1 写不进去、擦不掉最先查什么
遇到这种问题,我会按以下顺序排查,这个顺序是踩坑踩出来的经验:
- 写使能漏了。编程/擦除前不发 0x06,Flash 直接忽略操作,状态寄存器 WEL 一直为 0。代码里用
nor_flash_write_enable()的地方漏掉一个,现象就是别的扇区能写、某个扇区不能。 - CS 时序不正确。CS 必须在完整命令序列期间保持低电平,指令和地址/数据的字节之间不能断。如果是 GPIO 模拟 CS,查一下有没有在
spi_read_write_byte()中间意外抬高了 CS。 - BP 位保护。状态寄存器 bit2~bit5 可能定义了区域写保护,如果之前有人往状态寄存器写过一个值保护了全部空间,你的指令都会被忽略。解决办法是读 RDSR 看保护位,必要时用 WRSR 指令把状态寄存器设为 0。
- 硬件写保护引脚。很多板子把 Flash 的 WP 引脚(写保护引脚)拉到地或者做成了可配置电阻,如果没拉高,写入功能压根不使能。这是硬件原理图层面最隐蔽的坑。
5.2 读出来的数据不对,大概率是模式或接线问题
如果你能读到 ID 但数据错乱,先检查 SPI Mode。Nor Flash 全系列通常工作于 Mode 0 或 Mode 3,就两种选择,挨个试就知道。重点在于你必须确认SCK 空闲电平和数据采样沿之间的组合,有些芯片手册画得含含糊糊,直接看时序图比看文字准。
另外 MSB 先行还是 LSB 先行,也特别坑人。SPI Nor Flash 几乎都是 MSB first,但某个 ARM 的 SPI 控制器可能有个“LSB first”位,默认值有时候不是 0。一看到数据位序完全反了,先把这位置 0 试一下。
如果读出来的数据是“隔一个字节错位”之类,那基本可以怀疑 MOSI/MISO 在 PCB 上走线交叉,或者 GPIO 复用搞错了。用逻辑分析仪抓一拍就能快速确认,示波器其实未必比逻辑分析仪好用,因为 SPI 是数字协议,看波形不如直接看解码结果。
5.3 时钟太快引起偶发错误
偶发性错误最烦人。一个典型场景是:刚焊好的板子测试一切正常,跑了半小时偶尔读错一个字节。这种十有八九跟信号完整性相关。排查手段是降频,如果降频后问题消失,就取当前速度的一半作为量产默认值,这是最实在的解决办法。SCK 走线过长或过孔过多时,频率越高边沿越钝,数据采样点越不稳定,不一定要给系统极限值。
5.4 擦除时间太长导致的超时误判
我以前犯过一个错:把扇区擦除超时时间设成 100ms,结果有些牌的 Flash 芯片典型擦除时间就是 100ms 多一点,导致偶尔报错。后来我把超时设置成“典型值的 5~10 倍”,也就是扇区擦除 1~2 秒、整片擦除 5 秒,问题就消失了。嵌入式里也有很多类似场景,超时值不是越短越好,关键是要区分“操作失败”和“操作慢”。
5.5 怎么快速验证通信链路
一定要学会用读 ID 作为第一条调试手段,而不是直接读写数据。读 ID 只需要发送一个字节指令,不需要地址和数据,出错概率小,更容易定位是 SPI 层的问题还是 Flash 内部逻辑的问题。如果你手头有逻辑分析仪,抓 CS、SCK、MOSI、MISO 四个信号,看一眼帧格式就能判断大部分问题。我调试驱动的时候永远先看逻辑分析仪上的帧结构,确认第一个字节命令码、地址字节顺序都对,再去追查数据内容。
6. 再补充几个工程实操的细节
6.1 地址空间划分和“分区表”思想
如果你用 Nor Flash 同时存 BootLoader、应用固件和配置参数,那最好在程序设计之初就规划好分区:哪个地址区间放 BootLoader,哪个区间放应用程序,哪个区间放参数。Nor Flash 擦除粒度是扇区,所以分区大小尽量按扇区整倍数来画,不然到了集成阶段,你会发现一个扇区里既有配置参数又有固件尾巴,升级固件时把配置也给擦没了。
我常用的做法是定义一张分区表结构体,包含名称、起始地址、长度、属性(只读/可擦写)。裸机程序里做成 const 数组,放在代码里,方便所有模块引用:
typedef struct { const char *name; uint32_t start; uint32_t size; } flash_partition_t; const flash_partition_t g_partitions[] = { { "bootloader", 0x000000, 0x010000 }, { "app", 0x010000, 0x100000 }, { "config", 0x110000, 0x010000 }, };这样无论是写烧录脚本、做固件升级,还是调试时查看指定位置的数据,都有一份统一依据,不容易出错。
6.2 启动代码里的 Nor Flash 操作注意什么
如果 Nor Flash 用来放启动代码,那上电瞬间的行为就得仔细想清楚。芯片复位后,处理器从启动地址取指令,如果你的 BootLoader 就放在 Nor 里而被映射到了 0 地址,那么第一条指令会被当成普通读,这个没问题。但你绝不能在上电初期就对 Nor 做擦写操作,因为此时可能是只读映射,写操作会导致处理器抓取完全混乱的指令流,比死机还难查。
我见过一种稳妥的方案:BootLoader 前 4KB 是第一阶段引导代码,只做最基本的硬件初始化和 DDR 初始化,然后把完整的第二阶段代码从 Nor 复制到 DDR 执行,第二阶段才具备擦写 Nor 的能力。这样即使升级固件失败,第一阶段还在 Nor 里,不会把板子“变砖”。至少对复杂一点的系统,这种分层引导模式很值得参考。
6.3 固件升级场景下的断点续写
稍微提一下固件升级:升级程序写 Nor 的时候如果中途断电,Flash 里可能出现一个“半写”的固件。为了减少变砖风险,可以加一个升级标志区域:升级前先把标志位写成“升级中”,写完新固件并校验成功后,再把标志改成“完成”。启动代码看到“升级中”就自动进入恢复模式,从备份区或外部通道重新获取固件。这个方案不复杂,但对实际产品的可靠性提升非常明显。
标志区本身最好固定在一个小扇区,单独划分,别跟固件混在一起。因为标志位会频繁改写,每次改写都要整扇区擦除重写,放独立小扇区可以避免频繁擦写主固件区。
6.4 底层的性能优化思路
如果你对 Nor Flash 的写入性能有要求,能优化最明显的一点就是合并连续页编程。一页数据能一次写完就不要拆成多次,因为每次编程前都有写使能和轮询等待。我做固件烧录时,会在内存里把数据分批重组到 256 字节页对齐,然后一次页编程写一整页,速率比逐字节编程高好几倍。
读取侧也有类似空间:如果 Flash 支持双倍速率或四线模式(比如 QSPI),在初始化时直接启用可以显著提高吞吐。不过这涉及 QSPI 指令集和 IO 配置,代码复杂度会高一些,一般到需要给显示 framebuffer 或音频缓冲供数据时才值得上。
7. 最后分享一点个人习惯
跟 Nor Flash 打交道久了,我养成了几个比较“土”但特别有效的习惯。第一个习惯是所有驱动函数都留一个超时返回值的口子,哪怕当前调用者不检查,也比死循环强。第二个习惯是每个板子到手先写一个“Flash 体检”小程序,把读写擦验证跑 10 遍,全部通过才把它当做好使的硬件平台,要不然后面调试应用,Flash 偶尔出错会把锅甩给完全无关的模块。第三个习惯是文档里所有地址、长度、指令码都以 Flash 手册为准,但我会在代码注释里再标注“参考手册第几页的 Table”,这样三个月后自己回来看代码,不用再翻几十页 PDF。
Nor Flash 这片领域看起来小,但它承载了嵌入式系统里最底层的启动和存储职责。把它的结构、命令、时序和调试手段吃透,后面再看 QSPI、XIP、固件升级这些上层话题,都会顺畅很多。如果你手头正在调一块板子,不妨照着我上面的自检流程走一遍,大概率能帮你少熬一个夜。