裸机驱动的约束与骨架
这一章解决:裸机环境下的存储驱动与 RTOS 驱动到底差在哪、代码该分成几层、目录怎么组织。分清这三点,后面的每一章都是在同一套骨架上填内容。
1.1 编写目标、适用范围与读者
本文的目标是让读者能够独立完成一颗存储器件在裸机环境下的驱动开发与量产落地:从手册提取参数、写寄存器访问层、实现命令协议、处理 ECC 与坏块、做掉电保护、通过波形与掉电验证,并具备产线可测性。
适用平台:Cortex-M0/M0+/M3/M4/M7/M33 与 RISC-V 等 MCU,运行裸机(无 RTOS、无 Linux)。典型产品:bootloader、电机控制板、仪表盘、可穿戴设备、成本敏感的小家电与一次性消费品。
读者假设:具备嵌入式 C 与裸机开发基础------会看数据手册与时序图,理解寄存器、位域、时序等级(tSHSL / tWP / tPP),写过至少一个串口或 SPI 收发函数。不需要 RTOS 经验,因为本文明确不涉及任何 OS API。
1.2 裸机与 RTOS 驱动的本质差异
把同一颗 SPI NOR 分别用裸机和 RTOS 实现,差异并不在「代码长什么样」,而在**「谁替我管了等待和并发」**。RTOS 提供了阻塞等待、互斥保护、动态内存这三样东西,裸机里全部要自己扛。
同一颗 SPI NOR,裸机与 RTOS 下驱动代码的差异分布环节裸机实现要点RTOS 实现要点裸机更易错的点寄存器访问直接读写,无保护无保护(裸机一样裸)编译器优化可能缓存读值片选管理自己控制 CS 时序框架管理 CSCS 释放过早导致数据丢忙等循环轮询 + 超时轮询 / 工作队列 / 延迟提交无超时 = 永久卡死并发单线程,无需锁互斥锁 / 临界区中断打断读写序列缓冲区静态数组malloc / 内存池栈上大数组爆栈错误处理返回码 + 全局 errno返回码 + 日志错误被忽略,脏数据上盘地址换算手工位运算flash_map 查分区页内偏移算错掉电保护双份 + 版本号 + CRC日志式文件系统半写状态被当成有效数据核心差异只有一句:RTOS 把「等待」和「并发」抽象掉了,裸机里这两件事全部回到你手上。因此裸机代码的正确性几乎完全取决于三处:时序等待有没有上限、缓冲区位置与对齐对不对、错误有没有被处理。本章与第 2、3 章解决前两处,第 4~11 章解决第三处。
图 1 · 裸机驱动与 RTOS 驱动的差异对照
这张表里「裸机更易错的点」一列,是本文重点关注的:
| 共性错误 | 为什么裸机更容易错 | 对策 |
|---|---|---|
| 读寄存器拿到的值不对 | 编译器把 volatile 之外的读优化到循环外,只读一次 | 寄存器宏一律加 volatile;写后读回校验 |
| CS 释放过早 | 没有框架管 CS,函数返回就随手拉高 CS | CS 释放只放在传输函数末尾;读命令的 dummy 周期发完才释放 |
| 忙等无超时 | 没有调度器兜底,卡住就是整机卡住 | 所有写擦操作带超时;超时返回错误并记录 |
| 中断打断读写序列 | 没有互斥锁保护 SPI 事务 | 关中断或用软件锁包住「命令+地址+数据」的完整序列 |
| 栈上大数组 | 没有内存池与栈保护,爆栈即跑飞 | 大缓冲区全部 static 或置于专用段 |
| 写后不校验 | 没有错误码上行,错误被忽略 | 关键数据写后必须读回 CRC 校验 |
红线:三个绝对不能省的地方
① volatile 不能省 ------读状态寄存器时,编译器可能把 while 循环里的读优化成只读一次,导致死循环。② 超时不能省 ------任何等待器件完成的循环都必须有退出条件。③ volatile 大缓冲区不能省 ------DMA 传输的缓冲区必须是 volatile 或带内存序标注,否则编译器可能复用寄存器内容。这三处省掉,现场表现都是「偶发、难复现、无法定位」。
1.3 硬件抽象层的三种组织方式
裸机代码可维护性的核心手段是分层:把「会变的东西」(不同主控的寄存器、不同器件的协议)与「不变的东西」(命令语义、地址换算、ECC、磨损逻辑)分开。常见有三种组织方式:
| 组织方式 | 形态 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 直接写寄存器 | 每个驱动文件里直接调 REG32(SPI_BASE+0x10) |
最直接,代码量最少 | 换芯片全盘重写 | 产品单一、只做一款板 |
| 分层 + 端口接口(本文推荐) | ① bsp 提供收发函数 → ② 器件驱动只调端口 → ③ 抽象层做算法 | 换器件与换芯片互不影响 | 初期要多想一层接口 | 多器件 / 多芯片复用 |
| 代码生成 | 用脚本/配置生成寄存器定义与驱动骨架 | 一致性好,适合大团队 | 需要工具链与规范 | 产品线多、芯片族多 |
把「会变的东西」和「不变的东西」分开,是裸机驱动可维护的前提④ 应用层 调用者(业务代码、Bootloader、上位机协议)配置读写参数区日志落盘数据记录固件更新镜像写入产线测试读写校验③ 存储抽象层 统一 API(本层的存在是为了让应用与器件解耦)扇区读写read/write sector块擦除erase block缓存与对齐page cacheECC / 坏块 / 磨损算法层② 器件驱动层 命令协议实现(本文主体)JEDEC 指令0x9F / 0x03 / 0x02状态位WEL / WIP / ECCID 表容量与参数四字节地址QE 位① 总线与寄存器层 SoC 特有(移植时唯一需要重写的部分)SPI 控制器字节收发SDMMC 控制器命令与数据GPIO / DMA片选与搬运时钟与上电时序
图 2 · 裸机存储驱动的四层结构
本文采用分层 + 端口接口 。核心是 ② 与 ① 之间那一层 storage_port.h:它只声明「怎么发一个字节、怎么拉 CS、怎么延时」这几件事,② 里的器件驱动完全不出现任何 SoC 寄存器名。这样换主控时只需重写 bsp/ 下的实现,②③④ 一行不改。
1.4 目录结构与命名约定
目录组织的目标很直接:让「换器件」与「换芯片」成为两个互不影响的局部改动。
一种可交付的目录组织:换器件只动 ②,换芯片只动 ①project/工程根bsp/板级支持(SoC 寄存器、引脚、时钟)bsp_spi.c/.h① SPI 控制器收发、片选、延时bsp_gpio.c/.h① GPIO 与电源门控driver/② 器件驱动(可跨工程复用)storage_nor_spi.c/.hSPI NOR:JEDEC 指令 + ID 表storage_nand_spi.c/.hSPI NAND:ONFI + 坏块storage_sdmmc.c/.hSD/eMMC:CMD 状态机storage_port.h②→① 的接口声明(函数指针或宏)mid/③ 存储抽象层(扇区、缓存、ECC、磨损)storage_if.c/.h统一 API:read/write/erasenand_bbm.c/.h坏块表管理wear_level.c/.h磨损均衡fs/④ 文件系统(LittleFS / FatFs)移植时的影响面(这是本文的组织逻辑)换主控芯片 → 只改 ① bsp/ 与 storage_port.h 的实现,②③④ 完全不动。换存储器件型号 → 只改 ② 的 ID 表与初始化序列,①③④ 完全不动。加新功能(如支持 QSPI)→ 只在 ② 加一条传输路径,①③ 只需扩展接口。这就是把 ② 与 ① 之间用 storage_port.h 隔开的全部理由------它让「器件差异」与「芯片差异」互不污染。
图 3 · 裸机存储驱动的目录组织
| 命名约定 | 规则 | 示例 |
|---|---|---|
| 文件前缀 | 按层与器件类型命名,不用缩写混用 | storage_nor_spi.c |
| 寄存器访问宏 | REG32/REG16/REG8,不裸写指针强转 |
REG32(SPI1_BASE + SPI_CTRL) |
| 位操作宏 | xxx_GET / xxx_SET / xxx_CLR 三件套齐全 |
SPI_CR_EN_GET() / _SET() / _CLR() |
| 命令码 | CMD_ 前缀 + 助记名,不用裸十六进制 |
CMD_PAGE_PROGRAM 0x02 |
| 状态位 | SR_ 前缀,与手册位名对应 |
SR_WIP / SR_WEL |
| API 前缀 | 按对象命名,避免全局重名 | nor_read() / nand_bbm_load() |
| 错误码 | 统一 int 返回,负值错误,定义在头文件 |
ST_OK / ST_ERR_TIMEOUT |
为什么寄存器宏比裸指针强转好
裸写 *(volatile uint32_t *)(SPI1_BASE + 0x10) = x 有三个问题:地址算错时编译期不报错;寄存器名与手册对不上时无法检索;换 SoC 时无法批量替换。用 REG32(SPI1_BASE + SPI_CTRL) 至少让「这是哪个寄存器」可读、让批量替换可行。更重要的是:把寄存器名定义成枚举而非裸数字,评审时一眼能对照手册核对。
1.5 本文覆盖的器件与边界
本文按接口类型组织,而不是按容量。接口类型决定了协议族、命令集与软件结构,是驱动实现方式的第一决定因素。
| 器件类型 | 接口 | 本文覆盖章节 | 协议族 |
|---|---|---|---|
| SPI NOR | SPI x1/x2/x4 | 第 3、4 章 | JEDEC 标准 SPI NOR 指令集 |
| SPI NAND | SPI x1/x2/x4 | 第 5 章 | ONFI 兼容的 SPI NAND 指令集 |
| PPI NAND(并口) | x8/x16 并行 | 第 6 章 | 异步并行 NAND 命令 |
| SD NAND | SDMMC x1/x4 | 第 7、8 章 | SD 协议族(物理层与命令集) |
| eMMC | SDMMC x1/x4/x8 | 第 7、8 章 | MMC 协议族(CMD3/CMD6/EXT_CSD) |
边界说明:本文不讨论具体 SoC 的外设寄存器定义(那是 SoC 手册的范畴,第 1、2 章只讲「怎么正确访问寄存器」这一通用层),也不讨论文件系统内部的算法实现(LittleFS 与 FatFs 的移植只讲「要提供哪几个函数」)。器件的电气设计(电压域、PCB 走线、时序预算)请参考各器件的电路设计指南。
裸机 vs RTOS:什么时候该换到 RTOS
裸机存储驱动的复杂度主要来自「等待」与「并发」。如果你发现:① 需要同时管理多个存储器件的异步请求 、② 需要文件系统与网络同时在跑 、③ 业务逻辑已经需要事件驱动 ,那么继续在裸机上堆 volatile 与 if (done) 的收益会迅速递减。此时切到 RTOS(FreeRTOS / RT-Thread / Zephyr)比继续加固裸机更划算。本文的内容在 RTOS 下同样适用------第 2、3、4、5、6、7 章的协议与时序部分可以直接复用,只需把「忙等」改成「提交工作队列」、把「软件锁」改成「互斥锁」。
2 · 寄存器访问与时序基元
这一章是全文最基础也最容易出错的部分。裸机驱动里 90% 的「偶发、难复现」问题都能追到这一章的三件事上:寄存器访问没有 volatile、内存访问顺序没约束、等待没有超时。这一章把这三件事一次讲透。
2.1 内存映射 I/O 与寄存器基址
MCU 的外设寄存器通过固定地址访问。Cortex-M 上可以直接把地址当指针用;RISC-V 上如果内存映射与内核地址空间一致(多数 MCU 都这样),同样可以直接访问。核心是把裸数字变成有意义的名字。
从「手册里的一个位」到「代码里的一次写」之间有五道关① 手册位定义SPI_CR 的 bit0 是 EN(使能),手册给了位号与读写属性② 寄存器地址枚举在头文件里 enum { SPI_CR = 0x10 },不用裸数字③ 基址与偏移宏#define REG32(a) (*(volatile uint32_t *)(uintptr_t)(a))④ 位操作宏SET_BIT(REG32(BASE+SPI_CR), SPI_CR_EN) ------ 带 volatile⑤ 读回校验写后读回该寄存器,确认位已生效(调试期必做)漏掉任何一关的后果:位号写错 → 功能反了;没有 volatile → 循环读到旧值死循环。
图 4 · 寄存器访问的完整链条
/* ---- bsp_regs.h:SoC 寄存器基址与偏移 ---- */
/* 基址:以头文件方式集中定义,不要散落在各 .c 里 */
#define SPI1_BASE (0x40013000UL)
#define SPI2_BASE (0x40003800UL)
#define GPIOA_BASE (0x40020000UL)
#define RCM_BASE (0x40021000UL) /* 时钟控制 */
/* 寄存器偏移:用 enum 而不是 #define,评审时更容易与手册核对 */
enum {
SPI_CR = 0x00, /* 控制寄存器:bit0 EN, bit1 START, bit2 SSOE */
SPI_SR = 0x04, /* 状态寄存器:bit0 TXE, bit1 RXNE, bit2 BSY */
SPI_DR = 0x08, /* 数据寄存器:收发共用 */
SPI_CRCPR = 0x0C, /* CRC 多项式寄存器 */
SPI_RXDLYR = 0x10, /* 接收延时寄存器(dummy 周期) */
};
/* ---- REG32 / REG16 / REG8:统一的寄存器访问宏 ---- */
/* volatile 是必须的:告诉编译器每次都真正访问内存 */
#define REG8(a) (*(volatile uint8_t )(uintptr_t)(a))
#define REG16(a) (*(volatile uint16_t*)(uintptr_t)(a))
#define REG32(a) (*(volatile uint32_t*)(uintptr_t)(a))
/* RISC-V / 某些架构需要显式内存序时,把这里替换为带 barrier 的版本 */
#define REG32_BARRIER(a) (*(volatile uint32_t*)(uintptr_t)(a))
两处需要注意:
① 强制类型转换要经过 uintptr_t 。直接 *(volatile uint32_t *)0x40013000 在某些编译器上会告警(整数到指针的转换),经过 uintptr_t 中转更规范。
② 有 Cache 的 MCU 要额外处理 。Cortex-M7/M33 等带 D-Cache,如果外设地址区间被 Cache 覆盖,CPU 读到的可能是缓存值而不是寄存器真实状态。两种解法:在链接脚本里把外设区间标记为 non-cacheable (推荐,一劳永逸),或每次访问前后手动 invalidate(麻烦且容易漏)。链接脚本的写法见 11.1 节。
2.2 位域操作:掩码、置位、清零、翻转
寄存器的读写几乎总是「保留其他位,改其中几位」。必须用「读-改-写」而不是「直接赋值」,否则会把别人配置的位清掉。
/* ---- bsp_bits.h:通用位操作 ---- */
/* 置位:R |= (1 << n) ------ 读-改-写,只影响目标位 */
#define BIT_SET(reg, n) ((reg) |= (uint32_t)(1UL << (n)))
/* 清零:R &= ~(1 << n) */
#define BIT_CLR(reg, n) ((reg) &= ~(uint32_t)(1UL << (n)))
/* 翻转:R ^= (1 << n) */
#define BIT_TOG(reg, n) ((reg) ^= (uint32_t)(1UL << (n)))
/* 读位:R & (1 << n) */
#define BIT_RD(reg, n) (((reg) >> (n)) & 1UL)
/* ---- 复合式:直接对寄存器地址操作 ---- */
/* 注意:传入的必须是「可写的左值」,所以写成一个语句而不是返回函数 */
#define REG32_BIT_SET(addr, n) do { (addr) |= (uint32_t)(1UL << (n)); } while (0)
#define REG32_BIT_CLR(addr, n) do { (addr) &= ~(uint32_t)(1UL << (n)); } while (0)
#define REG32_BIT_RD(addr, n) (((addr) >> (n)) & 1UL)
/* ---- 字段操作:处理多位字段 ---- */
/* 把 val 写入从 shift 开始、宽度 width 位的字段 */
#define FIELD_W(field, shift, width, val) \
((field) = (((field) & ~((((uint32_t)1 << (width)) - 1) << (shift))) | \
(((uint32_t)(val) & ((uint32_t)1 << (width)) - 1) << (shift))))
/* 从 field 中读出该字段的值 */
#define FIELD_R(field, shift, width) \
(((field) >> (shift)) & ((uint32_t)1 << (width)) - 1)
/* ---- 寄存器三件套:与手册位名一一对应,可检索 ---- */
/* 用法:SPI_CR_EN_SET(); SPI_CR_EN_CLR(); if (SPI_CR_EN_RD()) {...} */
#define SPI_CR_EN_GET() BIT_RD(REG32(SPI1_BASE + SPI_CR), 0)
#define SPI_CR_EN_SET() REG32_BIT_SET(REG32(SPI1_BASE + SPI_CR), 0)
#define SPI_CR_EN_CLR() REG32_BIT_CLR(REG32(SPI1_BASE + SPI_CR), 0)
红线:写外设寄存器后必须读回确认
调试期(以及量产后的首次上电自检 )强烈建议对关键寄存器做「写后读回」:SPI_CR_EN_SET(); assert(SPI_CR_EN_RD() == 1);。这一条能在联调阶段立刻暴露三类高频问题:① 基址或偏移写错(读回 0) ;② 位号写错(读回的是另一个位);③ 该位是只读或被硬件清掉(读回 0 但你确实写了)。上板后这三类问题的排查成本是调试期的十倍。
另一个高频错误是在 BIT_SET 里传表达式 :BIT_SET(reg, n) 展开成 (reg) |= ...,如果 reg 是一个带副作用的表达式(如 *ptr++),会被求值两次。约定:位操作宏的寄存器参数只允许传「可写的左值」,不要传函数返回值或复杂表达式。
2.3 屏障与内存序
编译器与 CPU 都会为了「更快」而重排访问顺序。在存储驱动里,有两处必须显式约束,否则会出现「代码看起来对,但硬件行为不对」的诡异问题。
为什么「写了缓冲区再启动 DMA」之间必须加屏障场景 1:内存 → 外设(写寄存器后立刻读硬件)① CPU 写 FIFO 寄存器② 编译器重排?需要 DSB 阻止④ CPU 读 FIFO 状态关键点:② 编译器和 ③ CPU 都可能「提前或延后」对内存的访问顺序,必须显式约束。场景 2:外设 → 内存(DMA 收完成后读缓冲区)① CPU 启动 DMA 接收② DMA 写 SRAM需要 DMB 才能读④ CPU 读缓冲区校验关键点:带 Cache 的 MCU(Cortex-M7/M33)必须先 invalidate D-Cache,否则读到旧数据。场景 3:清 WIP 的极短临界区(SPI 命令序列不可被打断)① 关中断(短)② 发命令+地址+数据③ 开中断④ 恢复原中断状态关键点:临界区只包「一条命令的完整序列」,不要把「等待 WIP 清零」包进去。
图 5 · 屏障在存储驱动中的三个真实场景
| 屏障 | 语义 | 存储驱动里的典型用法 |
|---|---|---|
DMB()(数据内存屏障) |
保证此前的内存访问先于此后的完成 | DMA 完成后读缓冲区前;写寄存器后读状态前 |
DSB()(数据同步屏障) |
此前的访存全部完成才继续 | 写 FIFO 后启动传输;写状态位触发中断前 |
ISB()(指令同步屏障) |
刷新流水线(改执行流时用) | 改 VTOR 后;改 PC 时 |
WFI()(等待中断) |
进入低功耗等待 | 等待器件完成时,比空转省电 |
/* ---- bsp_barrier.h ---- */
/* 多数 Cortex-M 编译器提供内建函数;没有时用下面的内联汇编 */
#if defined(__GNUC__) || defined(__clang__)
# define DMB() __asm volatile ("dmb 0xF" ::: "memory")
# define DSB() __asm volatile ("dsb 0xF" ::: "memory")
# define ISB() __asm volatile ("isb 0xF" ::: "memory")
# define WFI() __asm volatile ("wfi")
# define WFE() __asm volatile ("wfe")
#else
/* 非 GCC 工具链:请查对应编译器手册的内存屏障写法 */
#endif
/* 带 Cache 的 MCU:DMA 缓冲区操作前后需要维护 D-Cache */
static inline void cache_dmb(void)
{
DMB();
#if defined(CORTEX_M7) || defined(CORTEX_M33) || defined(__DCACHE_PRESENT)
SCB_CleanInvalidateDCache_by_Addr((void *)dma_buf, DMA_LEN); /* 型号相关 */
#endif
DMB();
}
/* 读回校验的标准写法:写 -> 屏障 -> 读回 */
static void spi_write_cr(uint32_t val)
{
REG32(SPI1_BASE + SPI_CR) = val;
DMB(); /* 确保写已落地 */
(void)REG32(SPI1_BASE + SPI_CR); /* 读回,触发读-改-写副作用 */
}
**Cache 的坑值得单独强调。**Cortex-M7/M33 这类带 D-Cache 的 MCU 上,DMA 写入的 SRAM 缓冲区在 CPU 侧可能仍是旧值(因为 Cache 里有旧副本)。有两种解法:
| 解法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 地址对齐到 Cache 边界(推荐) | 把 DMA 缓冲区用 __attribute__((aligned(32))) 对齐到 Cache line,并保证长度是 line 的整数倍 |
开销最小,几乎无性能损失 | 需要在链接脚本或属性里都写一遍 |
| 维护 Cache | 每次 DMA 前后调用 clean/invalidate | 不占额外对齐空间 | 每次几十到几百周期,长时间传输不可接受 |
| 把缓冲区放 non-cacheable 区 | 链接脚本里划一段 DTCM 或 non-cacheable SRAM | 彻底不用管 Cache | DTCM 容量有限(通常 64~128 KB) |
2.4 字节序与内存屏障编译器指令
存储器件的数据手册里,经常出现「MSB first」与「LSB first」两种表述------前者指数据手册里表格的排列顺序 ,后者指总线传输时先发哪个字节。SPI 协议本身固定是 MSB first(高位先出),而 MCU 的多字节变量在内存里取决于架构:
| 架构 | 内存中 0x12345678 的字节序 | 存储驱动中的注意事项 |
|---|---|---|
| Cortex-M(小端) | 78 56 34 12(LSB 在低地址) | 写多字节寄存器值时,确认手册要求的字节序与架构一致 |
| Cortex-M(大端变体) | 12 34 56 78 | 同上,需用 __builtin_bswap 系列转换 |
| RISC-V(小端) | 同 Cortex-M 小端 | 多数 RISC-V MCU 是小端 |
| SDR / 外部总线(大端) | 取决于控制器配置 | 需确认 DMA 是否做字节序转换 |
/* 字节序转换:CMSIS / GCC 内建,无需自己实现 */
__builtin_bswap16(x) /* 16 位交换 */
__builtin_bswap32(x) /* 32 位交换 */
__builtin_bswap64(x) /* 64 位交换 */
uint16_t __builtin_bswap16 (uint16_t) __REV16 (uint16_t) /* CMSIS 命名 */
uint32_t __builtin_bswap32 (uint32_t) __REV (uint32_t)
uint16_t __builtin_bswap16 (uint16_t) __REV16 (uint16_t)
uint32_t __builtin_bswap32 (uint32_t) __REV (uint32_t)
/* 存储驱动里的实际用例:器件要求大端送地址,但 MCU 是小端 */
static void send_addr_be(const uint8_t *addr, size_t n)
{
for (size_t i = 0; i < n; i++) {
spi_write_byte(addr[n - 1 - i]); /* 高字节先发 */
}
}
/* 反过来:读回一个大端 32 位寄存器值 */
static uint32_t read_reg_be(uint32_t addr)
{
return __builtin_bswap32(REG32(addr));
}
存储驱动里最常见的字节序问题 不是 32 位变量的交换,而是多字节缓冲区与结构体的落盘格式 。如果你要把一个结构体写进 Flash 再读出来,必须保证:① 结构体里没有指针(指针值落盘无意义);② 不用位域(不同编译器的位序不同);③ 跨编译器/跨平台时用显式的序列化(按字节手工打包)。第 3 条是最保险的做法,见 10.5 节。
2.5 延时:精确延时与 DWT 计时
两类延时需求必须区分清楚:① 协议要求的固定延时 (如上电后等待电源稳定、模式切换后等 1~2 个时钟);② 等待器件完成 (如等 WIP 清零)。第 ① 类用固定延时函数,第 ② 类必须用轮询 + 超时(见 2.6)。把第 ② 类写成固定延时的,是裸机存储驱动的经典错误。
| 延时方式 | 精度 | 适用场景 | 风险 |
|---|---|---|---|
for 空循环 |
很差(受编译器优化影响) | 禁止使用 | 可能被优化掉,时序完全不可控 |
| DWT 周期计数器 | 极好(1 周期) | 协议固定延时、超时判定、耗时测量 | 需要 DWT 使能;M0 无 DWT |
| 硬件定时器中断 | 好(1 tick) | 毫秒级固定延时 | 需要中断,短延时精度不够 |
| SysTick 计数法 | 好(1 tick 或更细) | 超时判定、耗时统计 | 与系统 tick 耦合,需谨慎处理临界区 |
| 示波器/逻辑分析仪 | 最真实 | 上板验证时序 | 只能验证不能运行期使用 |
/* ---- bsp_delay.h ---- */
/* DWT 周期计数器:STM32 等 Cortex-M3/M4/M7 都有,M0/M0+ 没有 */
static inline void dwt_enable(void)
{
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 打开trace */
DWT->CYCCNT = 0; /* 计数清零 */
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; /* 使能计数器 */
}
/* 等待 n 个 CPU 周期(-O2 下也稳定) */
static inline void delay_cycles(uint32_t n)
{
uint32_t start = DWT->CYCCNT;
while ((DWT->CYCCNT - start) < n) {
/* 有符号相减,处理计数器回绕 */
}
}
/* 换算成微秒(先配置好 DWT 的时钟源为系统时钟) */
#define DWT_CYCLES_PER_US (SystemCoreClock / 1000000UL)
static inline void delay_us(uint32_t us)
{
delay_cycles(us * DWT_CYCLES_PER_US);
}
/* 注意:delay_ms(500) 这类大延时不要用上面的实现,会长时间占用 CPU。
* 毫秒级延时请用定时器或 WFI(见下)。 */
/* WFI 版本:让出 CPU,等 SysTick 或其他中断唤醒 */
static inline void delay_ms_wfi(uint32_t ms)
{
while (ms--) {
__WFI(); /* 睡眠直到下一个中断(通常是 1 ms 的 SysTick) */
}
}
/* Cortex-M0/M0+ 没有 DWT,用 SysTick 兜底 */
#if !defined(__CORTEX_M) || (__CORTEX_M == 0)
static volatile uint32_t g_ms_ticks;
void SysTick_Handler(void) { g_ms_ticks++; }
static inline void delay_ms(uint32_t ms)
{
uint32_t start = g_ms_ticks;
while ((g_ms_ticks - start) < ms) { }
}
#endif
关于 DWT 用作超时基准的注意点 :如果驱动运行在低功耗模式 下(MCU 停钟或降频),DWT 也可能停或变慢,超时判定会失真。这在「用 Flash 存数据后进入 STOP 模式」的场景里是真实风险。两个对策:① 超时判定用不受低频影响的时钟源 (如 LSI 定时器或 RTC);② 简单系统(无低功耗)直接用 SysTick 毫秒计数,实现简单且可靠。
2.6 超时与错误返回的统一约定
所有「等待器件完成」的地方都应该是同一个形状:发命令 → 记录起点 → 轮询状态 → 完成则返回,超时则报错并留下现场。把它做成一个可复用的模式,全工程的可靠性立刻上一个台阶。
所有「等待器件完成」的地方都应是这一个形状发命令记录 start循环轮询读 WIP 位WIP = 0成功返回超时返回错误 + 记日志超时值怎么定手册典型值 × 4~10 倍,且不小于「一次轮询周期 × 1000」超时之后还要做什么① 记录现场(寄存器快照)② 尝试复位器件③ 若此前是写操作,必须标记该块不可信错误写法(裸机死机第一杀手)while (flash_read_status() & SR_WIP) { } // 无超时、无法退出、无现场信息补充:超时是「器件异常」的兜底,不是正常路径。正常路径不应该触发超时;频繁触发说明时序参数或电压有问题,要回去查原因,而不是把超时值调大。轮询间隔不要小于 1~2 个系统 tick,也不要大于超时值的 1/10。写操作超时后把该块标记为「不可信」是关键动作------否则上层会认为写入成功,读回时才发现数据不对。
图 6 · 统一的超时判定逻辑
/* ---- st_error.h:统一错误码 ---- */
typedef enum {
ST_OK = 0, /* 成功 */
ST_ERR_PARAM = -1, /* 参数非法:地址越界、长度不对齐、空指针 */
ST_ERR_IO = -2, /* 总线收发失败:SPI 错误、SD 命令响应错 */
ST_ERR_TIMEOUT = -3, /* 等待器件完成超时 */
ST_ERR_VERIFY = -4, /* 写后读回校验失败 */
ST_ERR_NODEV = -5, /* 器件不在场:读不到 ID */
ST_ERR_PROT = -6, /* 器件受写保护(WP# 有效或已进入保护态) */
ST_ERR_NOSPACE = -7, /* 无可用空间(文件系统/逻辑块管理) */
ST_ERR_CORRUPT = -8, /* 数据校验失败(ECC 不可纠 / CRC 不匹配) */
} st_err_t;
/* ---- 状态轮询的通用骨架 ---- */
/* t0_ns: 手册给出的典型耗时;timeout_ms: 实测 × 4~10 的余量 */
static st_err_t wait_bits_cleared(uint32_t reg_addr, uint32_t mask,
uint32_t timeout_ms)
{
uint32_t t0 = millis_get();
do {
if ((REG32(reg_addr) & mask) == 0U) {
return ST_OK; /* 条件已满足 */
}
if (millis_elapsed(t0) > timeout_ms) {
return ST_ERR_TIMEOUT; /* 超时退出,绝不无限等 */
}
/* 短暂让出:避免在等待期间占满 CPU,也给中断留机会 */
__WFI();
} while (1);
}
超时之后必须做的三件事
只返回 ST_ERR_TIMEOUT 是不够的,还应该:① 记录现场 (把相关寄存器值与状态位打进日志或存入一个「故障快照」结构体,供售后读取);② 尝试恢复 (复位器件、清状态位、必要时重发一次命令);③ 如果是写/擦操作超时,把该块标记为不可信(置一个「待校验」标志,下次读这块时先全块校验)。第三点最容易被忽略,但它是「数据静默损坏」的根源。
本章的三个检查点(可写进 code review)
① grep 搜索「while」 :每一个 while 循环都应有一个明确的退出条件(计数上限或时间上限),没有就是隐患。② grep 搜索「REG32(」 :确认每处都在 volatile 宏里,没有裸指针强转。③ 检查所有 DMA 缓冲区 :是否有 Cache 对齐处理、是否声明为 volatile 或 __attribute__((aligned(32)))。这三条能挡掉裸机存储驱动里大部分的偶发问题。
3 · SPI 总线裸机实现
这一章解决:怎么把一颗 SoC 的 SPI 控制器变成一个可复用的字节收发原语,以及为什么绝大多数「读不到 Flash ID」的问题都出在这一层。这一章不涉及任何存储器件,纯粹是总线。
3.1 SPI 四种模式与器件匹配
SPI 的模式由两个参数决定:CPOL (Clock Polarity,时钟空闲电平)与 CPHA(Clock Phase,采样相位)。组合出四种模式,决定了「在哪个边沿采样数据、在哪个边沿更新数据」。
模式选错是「读不到 ID」的第一大原因------先看手册时序图,再定 CPOL/CPHA模式 0CPOL=0 CPHA=0CLK 空闲低上升沿采样下降沿更新绝大多数 SPI NORSPI NAND / EEPROM模式 1CPOL=0 CPHA=1CLK 空闲低下降沿采样上升沿更新少数传感器特定 ADC模式 2CPOL=1 CPHA=0CLK 空闲高上升沿采样下降沿更新部分 SD 卡的SPI 模式模式 3CPOL=1 CPHA=1CLK 空闲高下降沿采样上升沿更新极少数器件需查手册确认红线:模式不是「随便选一个都能通」。CPOL 决定空闲电平,CPHA 决定第几个边沿采样,两者错一个就全是 0xFF 或 0x00。模式 0 实际波形(示例:读 ID,命令 0x9F,主机收到 0xC8)CS(低有效)CLKMOSI(主机出)MISO(主机入)CS 拉低上升沿:主机在此采样 MISO下降沿:从机更新 MISO虚线 = 采样时刻MOSI 在下降沿改变,故上升沿前数据已稳定
图 7 · SPI 四种模式与存储器件的匹配
对存储器件而言,规律非常稳定:SPI NOR、SPI NAND、SD 卡的 SPI 模式几乎全部是模式 0 或模式 3 ,其中模式 0 占绝大多数。判断方法不是猜,而是看手册时序图:
- 手册里
tSLQ/tMSL这类「数据有效到时钟边沿」的时间标注,箭头指向的是上升沿还是下降沿; - 若标注指向上升沿 且时钟空闲为低电平 → 模式 0;
- 若标注指向下降沿 且时钟空闲为低电平 → 模式 3;
- 不确定时用四种模式全试一遍 :上电后依次用模式 0/3 发
0x9F读 ID,能读到非 0xFF/0x00 的就是它。这是嵌入式调试里最实用的技巧之一。
不要用「试到能读」代替「查手册确认」
四种模式全试一遍是调试手段 ,不是设计方法。量产代码里必须把确认后的模式写死,并把该器件的模式写进注释------否则半年后有人「优化」代码时把它改成模式 0,整批产品同时失效且现场无法复现。
3.2 寄存器级配置流程
裸机配置 SPI 没有库函数帮忙,所有步骤都要自己按顺序做。顺序本身就是知识点:时钟 → 引脚复用 → 参数 → 使能 → 验证,少一步或错一步的表现都是「读不到数据」,但根因完全不同。
顺序错一步,后面全是 0xFF:时钟 → 复用 → 参数 → 使能 → 验证第一步:必须按顺序完成的三件事使能外设时钟RCC_APB2ENR |= SPI1EN配置引脚复用PA4/PA5/PA6 AF 推挽写 CR1 / CR2模式、位序、8 位、SSM第二步:使能之后必须验证的三件事配置分频与 CPOL/CPHACR1.BR + CR2.CPOL/CPHA使能 SPI 控制器CR1.SPE = 1试收发一个字节0x9F 看是否回 ID顺序错误的后果对照(按发生频率排序)① 忘记使能时钟 → 往总线写寄存器全部无响应,表现为「SPI 控制器不存在」② 忘记配引脚复用 → 寄存器配置正确但引脚输出电平不变,MOSI 一直是高③ 忘记把 CS 配成软件控制(SSM=1)→ 片选被硬件接管,多器件共享时只有第一个能选中④ 使能后立刻发命令、未等 SPE 真正生效 → 首字节丢失,表现为「有时能读有时不能」
图 8 · SPI 控制器的寄存器级配置流程
几个容易忽略的细节:
-
**引脚必须配成复用推挽,不能配成浮空输入。**SPI 的引脚由外设驱动,配置成 GPIO 后外设电平会被 GPIO 的输出锁存覆盖,表现为「寄存器全对但引脚无波形」。
-
必须把片选配成软件控制。 寄存器里的
SSM(Software Slave Management)置 1 之后,片选才由软件 GPIO 控制;否则片选被硬件 NSS 管住,多器件共享时无法独立拉低其中一颗。 -
使能(
SPE)之后先读回状态位确认,再发第一条命令。有些 SoC 的时钟使能到外设真正可访问之间有 1~2 个 APB 周期延迟,紧接着发命令会丢首字节。 -
**波特率分频只改频率不改模式。**调试时先用最低频(如 1 MHz)打通,再逐步提到目标频率------这样一旦提速出问题,能立刻确定是信号完整性而不是逻辑错误。
/* bsp_spi.c:SoC 相关的 SPI 初始化(这一层是移植时唯一要重写的) */
#include "bsp_spi.h"st_err_t bsp_spi_init(const bsp_spi_desc_t d)
{
/ ① 外设时钟 ------ 忘记这步,后面全部无响应 /
RCM_APB2EN |= (1UL << 12); / SPI1EN /
(void)RCM_APB2EN; / 写穿:防止编译器把这次写优化掉 *//* ② 引脚复用:PA4=SCK / PA5=MISO / PA6=MOSI,50 MHz 推挽、无上下拉 */ GPIOA_MODER = (GPIOA_MODER & ~AFR_MASK(4, 5, 6)) | AF_SET(4, 5, 6, 5); GPIOA_OSPEEDR = (GPIOA_OSPEEDR & ~OSPEED_MASK(4, 5, 6)) | OSPEED_HIGH(4, 5, 6); GPIOA_PUPDR &= ~(PUPD_MASK(4, 5, 6)); /* 片选单独用普通推挽 GPIO */ d->cs_port->BSRR = (1UL << d->cs_pin); /* ③ 主机模式、8 位、MSB first、软件片选 */ SPI1_CR1 = (0UL << 8) /* BR = f_pclk / 16,调试起步用慢速 */ | (1UL << 2) /* MSTR = 1,主机 */ | (2UL << 3) /* SSI = 1,必须置 1 否则挂死 */ | (1UL << 8) /* SSM = 1,软件片选 */ | (0UL << 11); /* DFF = 0,8 位 */ SPI1_CR2 = (1UL << 0) /* CPHA = 1(与 CPOL 一起决定模式,此处为模式 3) */ | (1UL << 1) /* CPOL = 1 */ | (7UL << 8); /* DS = 7,8 位 */ SPI1_RXDLYR = 12UL; /* 接收延时:换成模式 0 且器件要求 dummy 时才需要 */ /* ④ 使能并确认 */ SPI1_CR1 |= (1UL << 6); /* SPE */ (void)SPI1_CR1; if (SPI1_SR & SPI_SR_BSY) { return ST_ERR_IO; /* 使能后总线仍忙,说明时钟或引脚没配对 */ } return ST_OK;}
3.3 字节收发与全双工交换
SPI 控制器内部通常有两个移位寄存器:发送缓冲(TX)与接收缓冲(RX) 。你每写一次数据寄存器,硬件就开始移出 8 位;每收满 8 位,RX 就绪位置 1。关键点是:SPI 永远是全双工的,写一个字节必然同时收一个字节------即使你只想发,也必须把收到的字节读走。
SPI 永远是全双工:每移一个字节出去,必然移一个字节回来拉低 CS(等 tCSS)写 DR:待发字节 tx等 TXE=1(发缓冲空)读 DR:收字节 rx等 RXNE=1(收缓冲满)写下一字节或拉高 CS三个高频错误① 只写不等 TXE 就连写第二个字节→ 前一字节被覆盖,发出错数据② 发完不读 DR,RXNE 一直置位→ 下次读到的第一个字节是上次的残留③ 中途只等 TXE 不等 RXNE→ 全双工下会随机错一个字节,极难复现推荐的收发骨架for (i = 0; i < len; i++) {while (!SPI_SR_TXE()) ; // 等发空SPI_DR = txi; // 写入while (!SPI_SR_RXNE()) ; // 等收满rxi = SPI_DR; // 必须读走}两个 while 都要有超时,见 2.6 节
图 9 · SPI 字节收发与全双工交换
写裸机 SPI 收发函数时,两个等待都要有超时。TXE 迟迟不置位通常意味着总线被卡死(比如上一条命令的 CS 没拉高,器件一直在输出);RXNE 迟迟不置位通常意味着 MISO 引脚被拉死或虚短。这两种情况在无超时实现里都是永久死机。
/* bsp_spi_xfer:全双工等长交换。tx / rx 允许传同一个指针(原地交换) */
st_err_t bsp_spi_xfer(const bsp_spi_desc_t *d,
const uint8_t *tx, uint8_t *rx, uint32_t len)
{
uint32_t t0 = millis_get();
for (uint32_t i = 0; i < len; i++) {
/* 等发送缓冲空:最多等 1 ms */
if (wait_bits_cleared(SPI1_BASE + SPI_SR, SPI_SR_TXB, 1U) != ST_OK) {
return ST_ERR_TIMEOUT;
}
SPI1_DR = (tx != NULL) ? tx[i] : 0xFFU; /* 只发也要写,占位 0xFF */
/* 等接收缓冲满:最多等 1 ms */
if (wait_bits_cleared(SPI1_BASE + SPI_SR, SPI_SR_RXF, 1U) != ST_OK) {
return ST_ERR_TIMEOUT;
}
if (rx != NULL) {
rx[i] = (uint8_t)SPI1_DR; /* 必须读走,否则残留会污染下次 */
} else {
(void)SPI1_DR;
}
}
if (millis_elapsed(t0) > (len / 4U + 2U)) {
return ST_ERR_TIMEOUT; /* 整体速率兜底 */
}
return ST_OK;
}
/* 只发不收:仍然必须读 DR,否则 RXNE 一直置位后所有后续读都错位 */
st_err_t bsp_spi_send(const bsp_spi_desc_t *d,
const uint8_t *tx, uint32_t len)
{
return bsp_spi_xfer(d, tx, NULL, len);
}
/* 只收不发的典型写法:先发 0xFF 填满时钟,再收数据 */
st_err_t bsp_spi_recv(const bsp_spi_desc_t *d,
uint8_t *rx, uint32_t len)
{
return bsp_spi_xfer(d, NULL, rx, len);
}
为什么「只发不收」也要读 DR
很多工程师写 send() 时只写 DR 不读 DR。在 8 位模式下,RX 缓冲里已经有一个字节了但没人取走,RXNE 一直为 1。下一次调用 recv() 时,第一步「等 RXNE」会立刻通过,读到的是上一次发送时被时钟带回来的残留字节------数据整体错位一字节,而且现象随机,非常难查。
正确做法只有一条:每次写 DR 之后必须读一次 DR,哪怕并不需要这个数据。
3.4 片选管理与多器件
一颗 SPI 控制器挂多颗存储器件时,SCK / MOSI / MISO 是共享的,只有片选是独占的 。这带来两条约束:任何时刻最多一根 CS 为低;CS 之间必须有足够的不激活间隔(手册的 tCSH 与下一条命令的 tCSS 之和)。
同一颗 SPI 挂多颗器件:CS 任何时刻最多只能有一根为低访问器件 A(SPI NOR)访问器件 B(SPI NAND)访问器件 C(EEPROM)总线空闲CS1CS2CS3SCKMOSI / MISO 三器件共享,SCK 共享,只有 CS 独占------所以 CS 冲突就是「同时写了两颗器件」,总线数据直接损坏。片选的三条铁律① CS 拉低到第一个时钟沿之间要满足 tCSS;最后一个时钟沿到 CS 拉高之间要满足 tCSH------太快会丢最后一个字节。② 任何退出路径(超时、校验失败、断言失败)都必须拉高 CS,否则器件一直处于选中态,下一条命令全部失效。
图 10 · 片选管理与多器件共享总线
把片选管理封装成一个「事务」接口,是让驱动不易出错的关键:拉低 CS、收发、抬高 CS 三步绑在一起,调用者不可能只做一半。
/* 器件操作的事务封装:把「拉低 → 收发 → 拉高」绑死,杜绝半途退出 */
st_err_t spi_xfer_cs(uint8_t cs_port, uint8_t cs_pin,
const uint8_t *tx, uint8_t *rx, uint32_t len)
{
st_err_t ret;
gpio_write(cs_port, cs_pin, 0); /* 有效低 */
delay_us(T_CSS); /* 片选建立时间:手册查,最小 50 ns 级 */
ret = bsp_spi_xfer(NULL, tx, rx, len);
delay_us(T_CSH); /* 片选保持时间 */
gpio_write(cs_port, cs_pin, 1); /* 无论如何都要拉高 */
if (ret != ST_OK) {
bsp_spi_reset(); /* 失败后复位控制器,避免残留状态 */
}
return ret;
}
/* 读寄存器:命令 0x05,返回 1 字节 */
st_err_t nor_read_sr(uint8_t cs, uint8_t pin, uint8_t *sr)
{
const uint8_t cmd = 0x05U;
st_err_t ret = spi_xfer_cs(cs, pin, &cmd, NULL, 1);
if (ret != ST_OK) return ret;
return spi_xfer_cs(cs, pin, NULL, sr, 1);
}
3.5 软件 SPI 回退实现
有些场景没有可用的 SPI 控制器,或者引脚紧张只能拿普通 GPIO 凑。这时只能用 GPIO 模拟时序 。软件 SPI 不是不能用,但它有两个硬约束:速度慢 与抗干扰差。
不是所有项目都该用硬件 SPI,但所有项目都该知道差距在哪对比维度硬件 SPI(推荐)软件 SPI(回退)最高频率由分频器决定,可到 50 MHz 以上受 GPIO 翻转与中断抖动限制,通常 1~2 MHzCPU 占用DMA 或硬件移位,CPU 几乎不参与每字节数十条指令,读 4 KB 约占毫秒级引脚占用SCK / MOSI / MISO 三个专用引脚任意 GPIO,可与其它功能复用同一批引脚时序精度分频器保证,抖动小于一个周期受中断打断,抖动不可控且不可复现典型用途量产产品的全部存储器件产线自检、读少量 EEPROM、极限精简设计主要风险DMA 与 Cache、时钟树配置中断打断导致时序违规,器件行为随机异常软件 SPI 的正确写法:关中断保护整条命令序列,或至少保证「拉低 CS 到抬高 CS」之间不被抢占。量产产品用软件 SPI 存储器件的绝大多数故障,根因不是「速度慢」,而是「被中断打断后时序违规」。
图 11 · 硬件 SPI 与软件 SPI 的取舍
软件 SPI 的核心实现只有四条语句,但细节决定成败:
/* 软件 SPI:位操作实现。速度约 1~2 MHz @ 72 MHz 主频 */
#define SW_SPI_SCK_L() do { SCK_PORT->BRR = SCK_PIN; } while (0)
#define SW_SPI_SCK_H() do { SCK_PORT->BSRR = SCK_PIN; } while (0)
#define SW_SPI_MOSI_L() do { MOSI_PORT->BRR = MOSI_PIN; } while (0)
#define SW_SPI_MOSI_H() do { MOSI_PORT->BSRR = MOSI_PIN; } while (0)
#define SW_SPI_MISO_R() ((MISO_PORT->IDR & MISO_PIN) ? 1U : 0U)
#define SW_SPI_CS_L() do { CS_PORT->BRR = CS_PIN; } while (0)
#define SW_SPI_CS_H() do { CS_PORT->BSRR = CS_PIN; } while (0)
static void sw_spi_byte(uint8_t tx, uint8_t *rx)
{
uint8_t r = 0;
for (int8_t i = 7; i >= 0; i--) { /* MSB first */
SW_SPI_SCK_L();
if (tx & (1U << i)) { SW_SPI_MOSI_H(); } else { SW_SPI_MOSI_L(); }
/* 上升沿采样(模式 0):先拉高再采,中间插入足够的建立时间 */
delay_cycles(8);
SW_SPI_SCK_H();
delay_cycles(8);
r = (uint8_t)((r << 1) | SW_SPI_MISO_R());
}
SW_SPI_SCK_L();
if (rx != NULL) { *rx = r; }
}
static void sw_spi_xfer(const uint8_t *tx, uint8_t *rx, uint32_t len)
{
uint32_t primask = irq_lock(); /* 关键:整条命令序列不许被打断 */
SW_SPI_CS_L();
for (uint32_t i = 0; i < len; i++) {
sw_spi_byte((tx != NULL) ? tx[i] : 0xFFU,
(rx != NULL) ? &rx[i] : NULL);
}
SW_SPI_CS_H();
irq_unlock(primask);
}
软件 SPI 必须关中断,硬件 SPI 的「短命令」也建议关中断
软件 SPI 一个字节的移位需要几十条指令、几微秒时间。如果在移位过程中被中断打断,中断里如果也操作了同一条总线(例如另一个驱动读 EEPROM),回来后时序已经全乱,器件会收到一条拼接出来的错误命令。这类故障的特征是「低速时正常,高负载时偶发」,极难定位。
硬件 SPI 的情况稍好(移位由硬件完成,中断只影响 CPU 取数的时机),但为了确定性,发送「一条命令的完整序列」这段仍建议关中断 ;而等待 WIP 清零的那几百毫秒绝不能关中断------否则系统在这段时间内无法响应任何中断,等于死机。4.7 节会给出这条边界的精确位置。
3.6 频率与信号完整性
SPI 频率的上限由三件事共同决定,取最小值:
- 器件手册给出的最高频率(通常是 50 MHz / 104 MHz / 133 MHz 等,且分电压档位);
- 走线长度与负载 。经验值:1 MHz ≈ 1 cm(含过孔、拐弯、邻近信号干扰的折算)。10 cm 的排线通常不超过 10 MHz;FPC 排线要再降一档。
- 上升沿时间 。真正的杀手不是频率而是边沿------边沿越缓,高频下信号完整性越差。GPIO 速度档位必须配到最高档(50 MHz 及以上),很多板子之所以「10 MHz 就读不出」是因为 GPIO 配成了低速档,边沿缓到 500 ns 级别。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 完全读不到 ID(全 0xFF) | 模式错 / CS 无效 / MOSI 无波形 | 逻辑分析仪看 CS 与 CLK 是否有波形;依次试四种模式 |
| ID 首字节对、后两字节全 0 | MISO 未配置成输入 / 悬空 | 检查 GPIO 模式;示波器量 MISO 是否被拉住 |
| 低速正常、高速读错 | 边沿速率不够 / 走线过长 / 有干扰源 | GPIO 提到最高速档;缩短走线;远离时钟线与开关电源 |
| 偶发读错,重试就好 | 时序余量不足 / 电源纹波 | 降低频率一档验证;用示波器看 VCC 纹波是否超标 |
| 读数据整体错位一字节 | RX 残留未取走(3.3 节) | 检查 send 是否读了 DR;检查是否每次都等 RXNE |
还有两个只能靠测量、不能靠推理的参数:
- 建立保持时间:CS 拉低到第一个 CLK 上升沿、最后一个 CLK 下降沿到 CS 拉高。如果板子上有走线偏差或器件型号不一致,这两项可能已经贴到手册下限。用示波器量,不要省这一步。
- 串扰与反射 :SCK 是唯一的高速周期信号,是最容易产生反射的。多根信号并排走时,SCK 与相邻信号的间距建议大于 3 倍线宽;跨层时 SCK 上下相邻层不要走平行信号。
上拉与电平匹配:SPI 不怕慢,只怕悬空
所有 SPI 信号线在器件未选中或未上电时都会高阻。此时如果外部没有确定电平,总线电容会导致上升沿变成缓慢的爬坡------低频时正常,高频时器件就采不到正确电平。
工程做法:MOSI 与 CS 必须有确定的上拉 (10 kΩ 级即可,不要用 100 Ω,会拉低边沿速率);MISO 不建议加上拉(主机与从机的输出可能冲突),但要保证从机侧在未上电时不会反向拉电流(多数 Flash 的 IO 内部有弱上拉,不会反灌)。
另外:器件未上电而主控已上电时,MISO 线会把主控 IO 拉向器件的 VDD(通过 ESD 二极管),形成「反向供电」的隐性漏电路径。排查休眠电流时这是一条常被忽略的来源,详见 13.4 节。
4 · SPI NOR 驱动实现
这一章是全文第一个完整落地:从「读得到 ID」到「能安全写一个扇区」。SPI NOR 是所有存储器件里协议最简单的一种,把它的命令流程彻底吃透,后面的 NAND、SDMMC 都是同一套骨架换指令集。
4.1 JEDEC 指令集与状态寄存器
SPI NOR 的命令遵循 JEDEC 标准,命令字是 8 位。绝大多数厂商的 NOR 器件都实现了这套标准命令,只有厂商私有命令(参数设置、加密、OTP)才需要查手册。这意味着移植一颗新 NOR 到已有驱动,通常只需要加一行 ID 表。
所有命令都由四段拼成:命令字(1 字节)+ 地址(0~4 字节)+ dummy(0~4 字节)+ 数据(N 字节)。搞清这四段的长度和顺序,SPI NOR 就算入门了。
所有 SPI NOR 命令都是「命令字 + 地址 + dummy + 数据」四段拼出来的读 ID0x9F命令 1B+无地址+无 dummy+数据 3B上电第一步就靠它判断器件在不在普通读0x03命令 1B+地址 3B+无 dummy+数据 N Bdummy = 0 但仍要发满 4 个字节头快速读0x0B命令 1B+地址 3B+dummy 1B+数据 N Bdummy 数量随频率与模式变化页编程0x02命令 1B+地址 3B+无 dummy+数据 ≤256B数据超过一页会绕回,必须拆分最容易错的一处:普通读 0x03 的「4 字节头」0x03 没有 dummy 周期,但器件内部仍按 8 位对齐------必须连发 4 个字节(命令 1 + 地址 3)后才开始出数据,少发一个字节读回来全是 0xFF。
图 12 · SPI NOR 命令帧的四种基本形态
最常用的标准命令整理如下。命令码以 JEDEC 常见值为准,务必与你的器件手册核对。
| 命令码 | 名称 | 四段结构 | 用途与注意 |
|---|---|---|---|
0x05 |
Read Status Register 1 | 命令 + 数据 1B | 读 WIP / WEL / BP 保护位 |
0x06 |
Write Enable | 仅命令字 | 置 WEL;发擦/写命令前必须先发 |
0x9F |
JEDEC ID / RDID | 命令 + 数据 3B | 识别厂商与容量;上电第一步 |
0x03 |
Read Data | 命令 + 地址 + 数据 | 三字节地址;无 dummy 但要发满 4 字节头 |
0x0B |
Fast Read | 命令 + 地址 + dummy + 数据 | dummy 数量随频率与模式变,必须查表 |
0x0C |
Fast Read Quad Output | 命令 + 地址 + 4 dummy + 数据 | 四线读;需先置 QE |
0x02 |
Page Program | 命令 + 地址 + 数据 | 单次 ≤ 页大小,跨页必须拆 |
0xD8 |
Block Erase 64KB | 仅命令 + 地址 | 对齐到 64 KB 边界 |
0x20 |
Sector Erase 4KB | 仅命令 + 地址 | 对齐到 4 KB 边界;最常用 |
0xC7 |
Chip Erase | 仅命令字 | 整片擦除;耗时可达数秒 |
0x35 |
Read Status Register 2 | 命令 + 数据 1B | SR2:QE 位、地址模式位的所在 |
0xB7 |
Enter 4-Byte Address Mode | 仅命令字 | 容量 > 128 Mb 时进入四字节地址 |
状态寄存器是驱动与器件之间的全部状态通道。SR1(0x05)里有两个决定性的位:
SR0 的两个位决定「能不能写」和「写完没有」,其余位决定「写到哪里」bit7SR1bit6SR2bit5SR3bit4SR4bit3BP2bit2BP1bit1BP0bit0WRS状态寄存器 1(0x05)bit7SRP1bit6SRP0bit5TBbit4BP2bit3BP1bit2BP0bit1WELbit0WIP状态寄存器 2(0x35):WIP 在 bit0 或 bit1(看手册),QE 在另一个位,4 字节地址模式也在这一页写操作的合法状态转移(任何一次擦或写都必须走完这条链)空闲WEL=0 WIP=0发 0x06已解锁WEL=1 WIP=0发擦/写命令忙WEL=0 WIP=1轮询到 0完成WEL=0 WIP=0红线:WEL 是「一次性」的。发完 0x06 之后如果中间读了别的寄存器,WEL 会被清零,后续写命令被静默丢弃且状态位毫无提示。因此「解锁 → 发命令」必须紧挨着,中间不要插任何读寄存器、也不要写别的器件的 CS。WIP 只能在写/擦期间为 1。读 ID、读数据时 WIP 恒为 0;如果读 ID 时 WIP 就是 1,说明上一次写操作异常退出,此时应当复位器件而不是继续发命令。
图 13 · 状态寄存器与 WEL / WIP 的正确用法
WEL 是一次性的,这是最隐蔽的坑
很多驱动写成「先发 0x06 解锁,后面做一堆别的事,最后才发擦写命令」。这样必错 :器件在完成一次写操作(或读到 SR1)之后会自动清 WEL。中间插进来的任何一次读寄存器、任何一次对其它器件的操作(如果共用了 SPI 时序),都可能让 WEL 提前清零,结果是写命令被静默忽略,状态位什么都不提示,函数却返回成功。
正确写法只有一种:解锁、地址、命令、数据、读回校验,五步连着做完,中间不插入任何别的操作。
4.2 上电时序与初始上电指令
器件上电到能正常响应命令,中间有若干个必须等待的时间点。跳过任何一个,现象都是「读不到 ID」,但根因各不相同。
上电后不能立刻发命令:VCC 稳定、CS 保持高、tPU 等待缺一不可① VCC 上电VCC 到达工作电压▶② 等待 tPU等 tPU 之后控制器才响应指令▶③ CS 保持高上电到第一条命令前 CS 为高▶④ Release PD发 0xAB 退出深度掉电模式▶⑤ 等 tRES1等器件自复位完成(最长 30 ms)▶⑥ 读 ID 确认0x9F 读三个字节确认在场各阶段到底在等什么(以常见 3 V / 133 MHz 器件为例,具体数值查手册)VCC 上电到稳定:不是「电源芯片输出 3.3 V」就完事,而是 VCC 达到手册规定值的 90% 并且稳定不再爬升。tPU:上电到控制器可接受指令的最小时间。期间发任何命令都会被忽略,且器件状态不确定。Release Power-Down(0xAB):从深度掉电(Deep Power-Down)唤醒,必须等 tRES1 之后器件才真正就绪。上电阶段的六个高频错误① 上电后第一条命令直接是 0x9F ------ 器件还没退出上电态,ID 全 0xFF,看起来像「芯片不响应」② 忘了发 0xAB 唤醒 ------ 器件一直处于深度掉电,SPI 侧完全无响应,但 datasheet 上看着一切正常③ CS 上电瞬间就是低 ------ 部分器件把上电时的低电平边沿当作一条残缺命令,导致进入未知状态④ 用固定延时代替 tPU ------ 换批次、换温度、换电源芯片后失效;正确做法是等 VCC 电压门限 + 固定余量⑤ 唤醒后立刻读 ID ------ tRES1 未到就读,偶发失败;⑥ 多次反复上下电不重新走唤醒流程
图 14 · SPI NOR 上电时序与初始上电指令
初始上电指令的完整序列:
/* nor_init:上电到可用的完整流程。任何一步失败都要返回具体错误码 */
st_err_t nor_init(nor_t *d)
{
st_err_t ret;
/* ① 等 VCC 稳定。两种做法:读电压比较器,或用固定延时 + 电压余量 */
if (vcc_stable(1500, 2700) != ST_OK) { /* 目标 1.65~2.7 V 视器件而定 */
return ST_ERR_TIMEOUT; /* 电源没起来,别急着发命令 */
}
/* ② 总线相关:使能时钟、引脚复用、配置 SPI(第 3 章) */
ret = bsp_spi_init(&d->bus);
if (ret != ST_OK) { return ret; }
/* ③ CS 置高。上电瞬间 CS 就是低的话,部分器件会进入未知状态 */
d->cs_set(1);
delay_us(10);
/* ④ 等 tPU:上电到可接受指令的最小时间(手册查,加余量) */
delay_ms(5);
/* ⑤ 退出深度掉电模式。器件若在 Deep Power-Down,SPI 侧完全无响应 */
ret = nor_send_cmd(d, 0xABU, NULL, 0, NULL, 0);
if (ret != ST_OK) { return ret; }
/* ⑥ 等 tRES1:器件内部复位完成,之后才接受新指令(手册查,最长 30 ms) */
delay_ms(35);
/* ⑦ 读 ID 确认器件在场 */
ret = nor_read_id(d, &d->id);
if (ret != ST_OK) { return ret; }
/* ⑧ 按 ID 查表确定容量与参数(下一步细化) */
ret = nor_match_id(d);
if (ret != ST_OK) {
/* 读到了 ID 但不在支持列表里:可能是支持的,需要扩表 */
return ST_ERR_NODEV;
}
/* ⑨ 复位到已知状态:解除所有保护位,避免出厂保护影响后续写入 */
ret = nor_write_sr1(d, 0x00U);
if (ret != ST_OK) { return ret; }
return ST_OK;
}
4.3 读 ID 与器件识别
读 ID(0x9F)返回一个字节的厂商代码与两个字节的器件代码。驱动靠它做三件事:判断器件在不在场、判断型号、判断容量。
ID 判读有两条铁律:
-
不要用「只要不是 0xFF 就认为在」 。总线短路、上拉错误、模式错误都可能读出
0xFF或0x00,但也可能读出「看起来像但不匹配」的字节。必须与 ID 表逐字节比对 ,匹配不上就报ST_ERR_NODEV。 -
ID 表里要留「同厂商同系列」的通配项 。同型号不同批次、或同系列不同容量的 ID 第三字节会变,容量要从 ID 计算而不是写死。
/* ID 表:第三字节通常是容量代码,容量 = 1 << code(单位 Mb) /
typedef struct {
uint8_t vendor; / 第一字节:厂商代码 /
uint16_t dev; / 第二、三字节:器件代码 /
uint32_t size_mb; / 容量(Mb);若按 code 推导则填 0 并置 calc 标志 /
uint32_t page_size; / 页大小(字节) /
uint32_t sector_size; / 扇区大小(字节) /
uint8_t addr_bytes; / 3 或 4 /
uint8_t need_4byte_cmd; / 是否需要发 0xB7 进四字节模式 /
uint8_t dummy_fast; / 快速读的 dummy 字节数(按当前频率取值) */
} nor_id_entry_t;/* 示例表:只列格式,实际必须逐条对照手册补全 /
static const nor_id_entry_t g_nor_id_table[] = {
{ 0xC8U, 0x2014U, 16U, 256U, 4096U, 3U, 0U, 1U }, / 16 Mb /
{ 0xC8U, 0x4014U, 64U, 256U, 4096U, 3U, 0U, 1U }, / 64 Mb /
{ 0xC8U, 0x6014U, 128U, 256U, 4096U, 3U, 0U, 2U }, / 128 Mb /
{ 0xC8U, 0x7014U, 256U, 256U, 4096U, 4U, 1U, 2U }, / 256 Mb,需四字节 /
{ 0xC8U, 0x8014U, 512U, 256U, 4096U, 4U, 1U, 2U }, / 512 Mb,需四字节 /
{ 0xEFU, 0x4014U, 64U, 256U, 4096U, 3U, 0U, 1U }, / 64 Mb /
{ 0xEFU, 0x7014U, 256U, 256U, 4096U, 4U, 1U, 2U }, / 256 Mb /
{ 0x01U, 0x6014U, 128U, 256U, 4096U, 3U, 0U, 2U }, / 128 Mb */
};st_err_t nor_match_id(nor_t *d)
{
const nor_id_entry_t hit = NULL;
for (uint32_t i = 0; i < ARRAY_SIZE(g_nor_id_table); i++) {
if ((d->id.vendor == g_nor_id_table[i].vendor) &&
(d->id.dev == g_nor_id_table[i].dev)) {
hit = &g_nor_id_table[i];
break;
}
}
if (hit == NULL) {
LOG_ERR("unknown NOR id %02X %02X%02X", d->id.vendor,
(d->id.dev >> 8) & 0xFFU, d->id.dev & 0xFFU);
return ST_ERR_NODEV; / 明确报「不认识的器件」,不猜 */
}
d->size = (uint32_t)(hit->size_mb * 1024U * 1024U / 8U);
d->page_size = hit->page_size;
d->sector = hit->sector_size;
d->addr_bytes = hit->addr_bytes;
return ST_OK;
}
读不到 ID 时的四步排查顺序
① 量 CS 是否有低电平 ------用万用表或示波器看,怀疑驱动没走到收发函数就返回错误;② 看 CLK 有无波形 ------有 CLK 无 MISO 说明 MOSI 或模式错;③ 把频率降到 1 MHz 再试 ------排除信号完整性;④ 四种模式全试一遍------排除模式不匹配。这四步能覆盖 95% 的读不到 ID 问题。
4.4 Read 与快速读
普通读(0x03)结构最简单,但它每次只能一个字节一个字节地移出数据,在 50 MHz 下也就几 MB/s。快速读(0x0B)在地址之后插入 dummy 周期,让器件内部的输出缓冲区提前填好,从而能跑更高频率。
/* nor_read:读任意长度。内部按器件的最大突发长度分段 */
st_err_t nor_read(nor_t *d, uint32_t addr, uint8_t *buf, uint32_t len)
{
uint8_t cmd[5];
uint32_t n, done = 0;
st_err_t ret;
if (d == NULL || buf == NULL) { return ST_ERR_PARAM; }
if ((addr + len) > d->size) { return ST_ERR_PARAM; } /* 越界必须查 */
while (done < len) {
n = len - done;
if (n > d->max_burst) { n = d->max_burst; }
/* 组帧:命令 + 地址(3 或 4 字节) + dummy(0 或 1 字节) */
cmd[0] = 0x0BU; /* Fast Read */
if (d->addr_bytes == 3U) {
cmd[1] = (uint8_t)((addr + done) >> 16);
cmd[2] = (uint8_t)((addr + done) >> 8);
cmd[3] = (uint8_t)(addr + done);
cmd[4] = 0x00U; /* dummy 1 字节 */
} else {
cmd[1] = (uint8_t)((addr + done) >> 24);
cmd[2] = (uint8_t)((addr + done) >> 16);
cmd[3] = (uint8_t)((addr + done) >> 8);
cmd[4] = (uint8_t)(addr + done);
/* 四字节地址下 dummy 可能变成 2 或 4 字节,必须按手册拼接 */
ret = spi_xfer_cs(d->cs, cmd, NULL, 4U);
if (ret != ST_OK) { return ret; }
cmd[0] = 0x00U; /* dummy 按频率取值 */
for (uint32_t k = 0; k < d->dummy4; k++) {
ret = spi_xfer_cs(d->cs, cmd, NULL, 1U);
if (ret != ST_OK) { return ret; }
}
ret = spi_xfer_cs(d->cs, NULL, &buf[done], n);
if (ret != ST_OK) { return ret; }
done += n;
continue;
}
ret = spi_xfer_cs(d->cs, cmd, NULL, 5U); /* 发头 */
if (ret != ST_OK) { return ret; }
ret = spi_xfer_cs(d->cs, NULL, &buf[done], n); /* 收数据 */
if (ret != ST_OK) { return ret; }
done += n;
}
return ST_OK;
}
/* 普通读:不用 dummy,适合低速与调试 */
st_err_t nor_read_slow(nor_t *d, uint32_t addr, uint8_t *buf, uint32_t len)
{
uint8_t cmd[4];
if (d->addr_bytes == 4U) { return ST_ERR_PARAM; } /* 需先退四字节模式 */
cmd[0] = 0x03U;
cmd[1] = (uint8_t)(addr >> 16);
cmd[2] = (uint8_t)(addr >> 8);
cmd[3] = (uint8_t)addr;
if (spi_xfer_cs(d->cs, cmd, NULL, 4U) != ST_OK) { return ST_ERR_IO; }
return spi_xfer_cs(d->cs, NULL, buf, len);
}
读操作本身不会失败 (除非器件不在场或总线错),因此驱动不需要为读做写回校验------数据正确性由上层 CRC 或 ECC 负责。但有一个必须处理的边界:如果上一次写操作超时退出,读同一地址得到的是未定义内容。这靠 4.7 节的「标记不可信」机制处理。
4.5 Page Program 与页边界处理
页编程(0x02)是写操作里最需要小心的:它不会自动跳到下一页 。写到页尾时地址会绕回页首,把刚写的数据覆盖掉。手册里通常用一句话描述这个行为:「the address wraps around to the beginning of the same page」。
页编程不会自动跨页:写到页尾会绕回页首,覆盖刚写的数据一整页 256 字节内的地址分布(页内偏移 0~255)页 A:偏移 0 ~ 255页 B:偏移 0 ~ 255页 C:...错误:想写 300 字节(从页 A 偏移 200 开始)200 ~ 255(56 B)✓本该写页 B 的 0~243,实际被写到了页 A 的 0~243 ------ 前面刚写的 200~255 中的一部分被覆盖正确:拆成两次页编程第 1 次:addr = 0x0 + 200,len = 56写到页 A 末尾,不越界第 2 次:addr = 0x100 + 0,len = 244写满页 B 或剩余长度,取小者拆分算法的三个要点① 每段长度 = min(剩余长度, 页大小 - 当前页内偏移),绝不能写「页大小」这个常数。② 地址累加用实际写入长度,不能用请求长度------否则拆分后地址会错位。③ 每次拆分都是一次独立的「0x06 → 0x02 → 等 WIP」,因此跨页写的耗时是页数倍,估时要算进去。反过来说:把写入长度按页对齐后再发命令,可以省掉运行时判断------缓冲区按页对齐,代价是浪费一点空间与一次内存拷贝。
图 15 · Page Program 的页边界处理
/* nor_page_program_raw:写一段数据,假定 addr + len 不超过一页 */
static st_err_t nor_page_program_raw(nor_t *d, uint32_t addr,
const uint8_t *buf, uint32_t len)
{
uint8_t hdr[5];
st_err_t ret;
/* ① 写使能。必须与后续命令紧挨着,中间不能有别的操作 */
ret = nor_write_enable(d);
if (ret != ST_OK) { return ret; }
/* ② 组帧并连发:命令 + 地址 + 数据,一段 CS 内完成 */
hdr[0] = 0x02U;
hdr[1] = (uint8_t)(addr >> 16);
hdr[2] = (uint8_t)(addr >> 8);
hdr[3] = (uint8_t)addr;
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { return ST_ERR_IO; }
if (spi_xfer_cs(d->cs, buf, NULL, len) != ST_OK) { return ST_ERR_IO; }
/* ③ 等 WIP 清零(关中断只包住前半段,等待期间必须可响应) */
ret = nor_wait_ready(d, d->t_page_max);
if (ret != ST_OK) { return ret; }
return ST_OK;
}
/* nor_program:面向任意长度的写,自动拆页。这是扇区层调用的入口 */
st_err_t nor_program(nor_t *d, uint32_t addr, const uint8_t *buf, uint32_t len)
{
uint32_t off, chunk, done = 0;
st_err_t ret;
if (d == NULL || buf == NULL) { return ST_ERR_PARAM; }
if ((addr + len) > d->size) { return ST_ERR_PARAM; }
while (done < len) {
off = (addr + done) % d->page_size; /* 页内偏移 */
chunk = d->page_size - off; /* 本页还能写多少 */
if (chunk > (len - done)) { chunk = len - done; } /* 也不超过剩余长度 */
ret = nor_page_program_raw(d, addr + done, &buf[done], chunk);
if (ret != ST_OK) {
/* 写失败时,已写入的部分是「不确定」的:标记区间待校验 */
d->mark_dirty(d, addr, done);
return ret;
}
done += chunk;
}
return ST_OK;
}
为什么写完不校验就返回成功,是最贵的省事
页编程「写完」只代表器件把电荷注入了,不代表数据是对的 。以下情况会写入成功但内容错误:写入前该区域受写保护(BP 位被置位) 、VCC 在写入中途跌落 、地址算错写到了别处。这三种情况的状态位都不会报错。
工程做法有两种,按成本选:关键数据(配置、固件、CRC 头)写完立刻读回逐字节比对 ;批量数据(日志、采样流)不逐字节比对,但启动时做一次全量 CRC,发现不一致就标记该卷为脏并进入恢复流程。
4.6 擦除:Sector / Block / Chip
擦除是唯一比写入慢一到两个数量级的操作,也是最容易在估时上翻车的地方。三个层级必须严格区分:地址对齐要求不同、耗时量级不同、用法不同。
擦除是存储驱动里最慢的操作,量级差 150 倍,估时不能拍脑袋层级命令码擦除范围典型耗时最大耗时页数典型用途Sector 擦除0x204 KB40 ms200 ms1~2改一扇区配置 / 日志块Block 擦除0xD864 KB(16 个扇区)150 ms800 ms8~20文件系统块 / 数据区Chip 擦除0xC7 / 0x60整片80 ms4800 ms80~200整盘回收 / 出厂初始化Chip 擦除的最大耗时可达数秒------这意味着任何「上电先整片擦一遍」的逻辑都会让产品开机体验不可接受,量产产品必须避免。为什么耗时差这么多:擦除靠的是给浮栅注入高压电荷,物理上比编程慢一到两个数量级。对写入策略的三条直接影响:① 避免小范围改数据改 100 字节也要擦 4 KB且这段时间占住总线② 擦除要能被打断Chip 擦除可达数秒不能关中断干等③ 超时值按层级给Sector 用 200 ms 没问题Chip 用 200 ms 必误判工程做法:把「擦除」当成一个需要排队的资源------写日志时攒够一个块再批量擦,代价是读的时候要处理「本块可能不存在」。
图 16 · 擦除命令的三个层级与耗时量级
/* nor_erase:统一擦除入口,自动选择最合适的擦除层级 */
st_err_t nor_erase(nor_t *d, uint32_t addr, uint32_t len)
{
uint32_t timeout;
st_err_t ret;
if (d == NULL) { return ST_ERR_PARAM; }
if (len == 0U) { return ST_OK; }
if ((addr + len) > d->size) { return ST_ERR_PARAM; }
if ((addr % d->sector) != 0U) { return ST_ERR_PARAM; } /* 必须扇区对齐 */
if (((addr + len) % d->sector) != 0U) { return ST_ERR_PARAM; }
if (((addr + len) % d->block) != 0U) { return ST_ERR_PARAM; } /* 整块或整片才用块擦 */
/* 优先用块擦:把范围内的大块一次擦掉,剩余零头用扇区擦 */
while (len > 0U) {
if ((d->block != 0U) && (len >= d->block) &&
((addr % d->block) == 0U)) {
ret = nor_erase_one(d, addr, 0xD8U, d->t_block_max);
timeout = d->t_block_max;
} else {
ret = nor_erase_one(d, addr, 0x20U, d->t_sector_max);
timeout = d->t_sector_max;
}
if (ret != ST_OK) {
d->mark_dirty(d, addr, d->sector); /* 擦失败:整块不可信 */
return ret;
}
addr += (timeout == d->t_block_max) ? d->block : d->sector;
len -= (timeout == d->t_block_max) ? d->block : d->sector;
}
return ST_OK;
}
static st_err_t nor_erase_one(nor_t *d, uint32_t addr, uint8_t cmd, uint32_t t_max)
{
uint8_t hdr[4];
st_err_t ret = nor_write_enable(d);
if (ret != ST_OK) { return ret; }
hdr[0] = cmd;
hdr[1] = (uint8_t)(addr >> 16);
hdr[2] = (uint8_t)(addr >> 8);
hdr[3] = (uint8_t)addr;
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { return ST_ERR_IO; }
return nor_wait_ready(d, t_max);
}
还有两个必须知道的细节:
- 写保护区(BP 位) :如果出厂时 BP2/BP1/BP0 被厂商置位,擦写命令会被静默忽略,状态位不报错。量产前必须显式把 SR1 写 0 解除保护(见 4.2 节第 ⑨ 步)。
- 擦除不能中断。 擦除过程中断电,器件内部的电荷分布会停在中间态,该块可能变成不可靠但看起来正常的数据。这是 NAND 类器件需要「坏块」概念、而 NOR 也建议做全量 CRC 的根本原因。
4.7 忙等与超时
WIP 等待是裸机存储驱动里唯一一个「可能要等几秒」的地方。写法上有两种风格,各自有明确的适用场景,混用会出问题。
| 写法 | 实现方式 | 优点 | 风险与适用场景 |
|---|---|---|---|
| 轮询 + WFI | 循环读状态寄存器,间隙执行 __WFI() |
CPU 空出来,中断能响应,功耗低 | 有 WFI 唤醒延迟;适合绝大多数产品 |
| 轮询 + 空转 | 循环读状态寄存器,不 WFI | 响应最快,无唤醒延迟 | CPU 空转、功耗高、中断响应变差;适合短等待(< 1 ms) |
| 中断回调 | 发命令后立即返回,WIP 变 0 时进回调 | 完全不占 CPU,可做深度低功耗 | 需要事件机制与状态机;适合有事件驱动的框架 |
/* nor_wait_ready:等待 WIP 清零。这是驱动里最该被复用的一个函数 */
st_err_t nor_wait_ready(nor_t *d, uint32_t timeout_ms)
{
uint32_t t0 = millis_get();
uint8_t sr;
for (;;) {
/* 发状态读命令 → 读回 1 字节 SR1 */
if (spi_xfer_cs(d->cs, &(uint8_t){ 0x05U }, NULL, 1U) != ST_OK) {
return ST_ERR_IO;
}
if (spi_xfer_cs(d->cs, NULL, &sr, 1U) != ST_OK) {
return ST_ERR_IO;
}
if ((sr & 0x01U) == 0U) { /* WIP = 0,完成 */
/* 顺带检查写保护:WIP=0 但 BP 位置位说明写被拒绝 */
if ((sr & 0x1CU) != 0U) {
LOG_ERR("write blocked by BP=0x%02X", (sr >> 2) & 0x07U);
return ST_ERR_PROT;
}
return ST_OK;
}
if (millis_elapsed(t0) > timeout_ms) {
/* 超时:先记现场,再决定恢复动作 */
d->snapshot(d, 0x05U, sr, timeout_ms);
d->flag_block_dirty = 1U; /* 本块进入「待校验」状态 */
d->reset_dev(d); /* 复位器件,避免残留状态 */
return ST_ERR_TIMEOUT;
}
/* 短延时:既不空转也不过度睡眠,1~2 ms 间隔对所有 NOR 都够 */
delay_ms(2);
}
}
为什么等待 WIP 期间绝不能关中断
关中断等待几百毫秒到几秒,等于让系统在这段时间内无法响应任何中断:串口丢数据、电机控制抖动、看门狗被喂、按键无响应。正确做法是关中断只包住「命令本身」,一发出就立刻开,然后在等待循环里正常响应中断。
反过来说,发命令那一段(解锁 + 命令 + 地址 + 数据)必须关中断 ,因为这段只有几十个时钟周期,而被打断会导致器件收到拼接命令。4.4 节的 spi_xfer_cs 已经把这段包好了。
4.8 四字节地址与 QE 位
容量超过 128 Mb(16 MB)时,三字节地址不够用,必须切换到四字节地址模式。这个切换有两条路径,且切换后所有命令的地址长度与 dummy 数量都会变------这是「大容量 NOR 读出全 0」的常见原因。
容量超过 128 Mb 必须切四字节地址,QE 位决定能不能用四线高速读地址字节数的判定容量 ≤ 128 Mb三字节地址容量 > 128 Mb四字节地址切模式之后dummy 数量可能变化两条进入四字节模式的路径(选器件支持的那条)路径 A · 专用命令0xB7发 0xB7▶回读 SR2 确认▶此后全部命令用 4 字节地址支持此命令的器件最常用,一次命令搞定路径 B · 写寄存器位SR2 的 AD4 / 四字节使能位读 SR2▶置位 AD4▶重读确认生效部分器件只支持这种方式;bit 位置查手册QE 位(Quad Enable)不在 SR1 而在 SR2 的另一个位:四字节模式下要用 0x0B/0x6B 四线读,必须先置 QE,否则按两线读会收到全 0。QE 置位后若想退回两线,必须先清 QE 再清 AD4------顺序反了会锁死器件,需要上电复位才能恢复,这是常见的「砖化」原因。
图 17 · 四字节地址模式的进入与 QE 位
四字节地址下还有一个容易忽略的变化:快速读的 dummy 周期数量可能改变 。三字节地址下常见是 1 个 dummy 字节,四字节地址下可能需要 2 个甚至 4 个。dummy 少了会读出错误的值,多了会整体错位。取值必须查手册的时序表,并按当前频率确认。
/* 进入四字节地址模式,并按需置 QE */
st_err_t nor_enter_4byte(nor_t *d, int enable_quad)
{
uint8_t sr2 = 0;
st_err_t ret;
if (d->addr_bytes == 4U) { return ST_OK; } /* 已经在四字节模式 */
/* 读 SR2 拿到当前值,后面要按位改,不能整字节覆盖 */
ret = nor_read_sr2(d, &sr2);
if (ret != ST_OK) { return ret; }
/* 方式一:专用命令 0xB7(器件支持时优先用) */
if (d->id_entry->need_4byte_cmd) {
if (spi_xfer_cs(d->cs, &(uint8_t){ 0xB7U }, NULL, 1U) != ST_OK) {
return ST_ERR_IO;
}
} else {
/* 方式二:改 SR2 里的地址模式位。bit 位置因器件而异,必须查手册 */
sr2 |= (uint8_t)(d->id_entry->ad4_mask);
ret = nor_write_sr2(d, sr2);
if (ret != ST_OK) { return ret; }
}
/* QE 位:四线读的前置条件。位在 SR2 里,与 AD4 不同 bit */
if (enable_quad) {
ret = nor_read_sr2(d, &sr2);
if (ret != ST_OK) { return ret; }
if ((sr2 & d->id_entry->qe_mask) == 0U) {
sr2 |= (uint8_t)d->id_entry->qe_mask;
ret = nor_write_sr2(d, sr2);
if (ret != ST_OK) { return ret; }
}
}
d->addr_bytes = 4U;
d->dummy4 = d->id_entry->dummy_4byte;
/* 回读校验:这一步不能省,否则模式是否生效只能靠现象判断 */
ret = nor_read_sr2(d, &sr2);
if (ret != ST_OK) { return ret; }
if ((sr2 & d->id_entry->ad4_mask) == 0U) {
LOG_ERR("enter 4-byte mode failed, SR2=0x%02X", sr2);
return ST_ERR_IO;
}
return ST_OK;
}
/* 退出四字节模式:QE 必须先清,否则会锁死器件 */
st_err_t nor_exit_4byte(nor_t *d)
{
uint8_t sr2 = 0;
st_err_t ret = nor_read_sr2(d, &sr2);
if (ret != ST_OK) { return ret; }
sr2 &= (uint8_t)~(d->id_entry->qe_mask); /* ① 先清 QE */
ret = nor_write_sr2(d, sr2);
if (ret != ST_OK) { return ret; }
sr2 &= (uint8_t)~(d->id_entry->ad4_mask); /* ② 再清 AD4 */
ret = nor_write_sr2(d, sr2);
if (ret != ST_OK) { return ret; }
d->addr_bytes = 3U;
d->dummy4 = 0U;
return ST_OK;
}
4.9 完整 API 与移植清单
把前面八节的内容收敛成一份可交付的接口清单。这份清单是本文推荐的驱动对外全部接口------功能不多不少,覆盖所有场景,且每一项都有明确的错误码约定。
上层的 API 不该关心页边界与 WIP,抽象的价值就在这里应用层(调用者)nor_write(buf, 128); nor_read(buf, 128); nor_erase_sector(0);本层可以单独测试:不接上层也能验证扇区层(面向文件系统)对齐检查 / 读改写 / 跨页拆分 / 擦除调度 ------ 全部在这里收敛本层可以单独测试:不接上层也能验证命令层(面向器件协议)WEL→命令→等 WIP→读回校验,四段拼装成一条完整事务本层可以单独测试:不接上层也能验证原始层(面向 SPI 总线)spi_xfer_cs(cs, tx, rx, len) ------ 第 3 章的实现,本层不碰 Flash 语义本层可以单独测试:不接上层也能验证分层的实际收益:把 64 MB 的器件换成 128 MB,只需改命令层的 ID 表;把 SPI1 换成 SPI2,只需改原始层的 cs 参数。更重要的收益是可测性------命令层可以脱离文件系统用单元测试覆盖,原始层可以用逻辑分析仪直接核对波形。
图 18 · SPI NOR 驱动的四层 API 与调用关系
| 接口 | 层级 | 说明与要点 |
|---|---|---|
st_err_t nor_init(nor_t *d) |
命令层 | 上电流程:等 VCC → 总线 → 唤醒 → 读 ID → 匹配 → 解保护 |
st_err_t nor_read(d, addr, buf, len) |
扇区层 | 任意长度读;自动分段、越界检查、地址宽度切换 |
st_err_t nor_program(d, addr, buf, len) |
扇区层 | 任意长度写;自动拆页;失败时标记脏区间 |
st_err_t nor_erase(d, addr, len) |
扇区层 | 任意长度擦;自动选块擦/扇区擦;校验对齐 |
st_err_t nor_read_id(d, &id) |
命令层 | 0x9F 读三字节;必须逐字节与 ID 表比对 |
st_err_t nor_wait_ready(d, t_max) |
命令层 | 等 WIP 清零;超时值随命令层级而不同 |
st_err_t nor_write_enable(d) |
命令层 | 发 0x06;必须与后续命令紧挨着 |
st_err_t nor_enter_4byte(d, quad) |
命令层 | 进四字节地址;顺带置 QE;回读校验 |
st_err_t nor_self_test(d, level) |
命令层 | 自检,见 12.3 节 |
st_err_t nor_get_info(d, &info) |
扇区层 | 返回容量、页大小、扇区大小、块大小、坏块统计 |
void nor_mark_dirty(d, addr, len) |
扇区层 | 标记区间待校验;下一次读该区间先做全量 CRC |
void nor_snapshot(d, cmd, sr, t) |
命令层 | 故障现场记录:命令码、状态位、耗时、调用位置 |
移植到一颗新器件时,只需回答四个问题:
- ID 是什么? 发一次
0x9F读三个字节(若是双输出模式则要发0x90),填进 ID 表。 - **容量多大、页与扇区多大?**从手册的「Organization」或「Block Erasure」表里抄,填进 ID 表。
- 三字节还是四字节地址? 容量 ≤ 128 Mb 用三字节;更大则看手册给的进入方式(
0xB7命令还是 SR 位),填进 ID 表。 - 各操作的时序数值? 抄
tPP(页编程)、tSE(扇区擦)、tBE(块擦)、tCE(整片擦)、tPP_max等的最大值,乘 4 作为超时值。
本章验收自查
① 上电后能稳定读出正确的三字节 ID,型号与容量与实际芯片一致;② 在页边界上做 300 字节跨页写,读回逐字节正确;③ 擦除一个扇区后读回全 0xFF;④ 把 WIP 等待超时人为改到 1 ms,函数能返回 ST_ERR_TIMEOUT 而不是死循环;⑤ 掉电重启后器件仍能读到 ID(验证掉电恢复能力)。这五条通过,第 4 章就算交付了。
5 · SPI NAND 驱动实现
这一章解决:NAND 与 NOR 的本质差别是什么、坏块表怎么建、ECC 怎么用、多平面怎么用。SPI NAND 的命令流程比 NOR 还简单,真正的工作量在算法层。
5.1 ONFI 参数表与能力探测
SPI NAND 遵循 ONFI(Open NAND Flash Interface)规范,所有器件都实现了一套标准命令(0x9F 读 ID、0x0F 读参数表、0x02/0x10 写页、0x13/0x03 读页、0xD8 擦块、0x0D 复位)。厂商私有命令只出现在特殊功能上(OTP、参数配置、坏块管理策略)。
NAND 不是「更小的 NOR」------它坏在物理结构上,驱动必须为此负责维度NORNAND对驱动的后果物理结构浮栅独立单元浮栅串连成页,串扰敏感单元数越多,擦除电压越高,可靠性越低出厂坏块无有(典型 2%~5%)必须维护坏块表,不能假定块都能用出厂ECC无有(部分器件)读出后必须过 ECC 引擎,不能直接交给上层擦除粒度4 KB / 64 KB / 整片整块(2 MB~16 MB)不能小范围改数据,必须日志式写入读取粒度任意字节最小读取单位是页(2 KB~8 KB)读也要按页对齐,随机读需缓存页内已写数据重写不需先擦只能写 0,不能把 0 写回 1改一位也可能要整块擦除重写寿命10 万次擦除量级1 千~1 万次擦除量级必须磨损均衡,否则热点块先坏单价 / 容量贵,小容量为主便宜,大容量为主选型时用容量换成本,但可靠性要求更高结论:NOR 的复杂度在「时序」,NAND 的复杂度在「管理」。NAND 驱动真正要写的东西是坏块表、ECC、磨损均衡与掉电保护------命令流程本身比 NOR 还简单。所以从 NOR 转到 NAND,代码量会增加三到五倍,但增加的部分几乎全在算法层而不是协议层。
图 19 · NAND 与 NOR 的本质差异
上电后的第一件事是读参数表 。参数表里给出了这块 NAND 的全部真实能力:页大小、OOB 大小、块大小、平面数、支持的最大频率、ECC 要求、是否支持多平面并发。驱动应该从参数表读这些值,而不是写死在代码里------同型号不同批次、不同厂商的同名型号参数可能不同,写死迟早出事。
/* nand_read_param:读 ONFI 参数表。不同厂商的页内偏移不同,先试 ONFI 偏移 */
st_err_t nand_read_param(nand_t *d, nand_param_t *p)
{
const uint32_t param_pg = d->page_size + d->oob_size; /* 参数页在第 0 页 */
st_err_t ret;
uint8_t hdr[4];
uint32_t tries[] = { 1U, 2U, 3U }; /* ONFI 规定参数页可能在第 1/2/3 页 */
uint32_t i;
for (i = 0; i < ARRAY_SIZE(tries); i++) {
d->cs_set(0);
/* 命令 0x9F:读参数表,带 32 位地址 */
hdr[0] = 0x9FU;
hdr[1] = (uint8_t)(param_pg * tries[i] >> 24);
hdr[2] = (uint8_t)(param_pg * tries[i] >> 16);
hdr[3] = (uint8_t)(param_pg * tries[i] >> 8);
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
if (spi_xfer_cs(d->cs, NULL, d->buf, 256U) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
d->cs_set(1);
/* 参数表前 4 字节是签名:ONFI 规定为 0x4F 0x4E 0x46 0x49 ("ONFI") */
if ((d->buf[0] == 0x4FU) && (d->buf[1] == 0x4EU) &&
(d->buf[2] == 0x46U) && (d->buf[3] == 0x49U)) {
break;
}
}
if (i == ARRAY_SIZE(tries)) { return ST_ERR_NODEV; } /* 三处都没有,不是 NAND */
/* 解析:所有字段都是大端,注意字节序 */
p->page_size = ((uint32_t)d->buf[80] << 24) | ((uint32_t)d->buf[81] << 16)
| ((uint32_t)d->buf[82] << 8) | (uint32_t)d->buf[83];
p->block_size = ((uint32_t)d->buf[86] << 24) | ((uint32_t)d->buf[87] << 16)
| ((uint32_t)d->buf[88] << 8) | (uint32_t)d->buf[89];
p->planes = (d->buf[92] & 0x07U) + 1U;
p->ecc_bits = (d->buf[102] & 0x07U) == 0x01U ? 2U :
(d->buf[102] & 0x07U) == 0x02U ? 4U :
(d->buf[102] & 0x07U) == 0x03U ? 8U :
(d->buf[102] & 0x07U) == 0x04U ? 12U : 24U;
p->max_freq_mhz = d->buf[103];
/* 合理性校验:这些值一旦解析错,后续所有计算全错 */
if ((p->page_size == 0U) || ((p->page_size & (p->page_size - 1U)) != 0U)) {
return ST_ERR_CORRUPT; /* 不是 2 的幂,一定是解析错了 */
}
if (p->page_size < 512U || p->page_size > 16384U) { return ST_ERR_CORRUPT; }
if (p->block_size < p->page_size) { return ST_ERR_CORRUPT; }
p->oob_size = d->oob_size_from_id_table;
p->pages_per_block = p->block_size / p->page_size;
p->block_count = d->size / p->block_size;
return ST_OK;
}
参数表解析必须做合理性校验
参数表的字段是大端多字节,如果字节序处理错,得到的会是一个「看起来合法但完全错误」的页大小------比如本该 2048 字节的页被解析成 8388608 字节。后续所有地址计算都会偏移,写到别的块甚至越界,而且现象是「数据写到了奇怪的地方」,极难定位。
所以解析后必须校验:页大小是 2 的幂、在 512~16384 之间;块大小是页大小的整数倍;块数乘块大小等于总容量。三条校验能挡住 99% 的解析错误。
5.2 坏块表:扫描、建立、持久化
NAND 出厂时就带有一定比例的坏块(质量等级越低比例越高),随着擦写次数增加还会增长。驱动必须把这些块管理起来,绝不分配给上层使用。
坏块表是 NAND 驱动里唯一「必须持久化」的数据结构① 首次上电:全片扫描对每一块执行「读标识页 → 判坏」出厂坏块在标识页里 FF 位置 1▶② 建立内存坏块表位图或查找表,索引到物理块号常驻 RAM,不占持久化空间▶③ 运行时:标记新坏块擦除失败 / 读 ECC 不可纠 / 写校验失败→ 立即标记并切换到备用块▶④ 关机/掉电前落盘写到独立的两份元数据块双份 + 版本号 + CRC为什么坏块表必须持久化(这是 NAND 与 NOR 最大的运维差异)如果每次上电都全片扫描:512 MB 的 NAND 有 256 个块,扫描一遍要几十秒到几分钟,用户每次开机都要等。如果只存在 RAM 里:一次掉电,新增的坏块标记就丢了,下次分配时又会把数据写进那个已经坏了的块------数据静默丢失。正确做法:内存里维护完整表(用于分配),同时在关机与掉电恢复后把「新增部分」追加写入元数据块。坏块的两种来源与两种处理类型判定方式何时发现处理出厂坏块标识页(Block Lock / 厂商页)里 FF 位为 1首次扫描永久加入坏块表,永不使用增长坏块擦除失败 / 写后校验失败 / ECC 不可纠错运行时任意时刻立即标记 + 换备用块,落盘持久化判断擦除是否真的把块变「好」了:擦除后再读一页全是 0xFF 才算成功。部分器件擦除后仍是旧数据,说明擦除失败。标记新坏块时必须把「块号 + 时间戳 + 原因」一起记,只记块号会让售后无法判断是设计问题还是工艺问题。备用块数量建议为总块数的 1%~2% 且不小于 2 块:全部备用块用尽时,驱动必须拒绝写入而不是静默丢数据。
图 20 · 坏块表的扫描、建立与持久化
坏块表的数据结构选择也有讲究:
| 结构 | 空间开销 | 查询速度 | 适用容量 |
|---|---|---|---|
| 位图(1 bit/块) | 512 MB 器件 = 256 块 = 32 字节 | O(1) 直接取位 | 所有容量;首选 |
| 块号数组 | 4 字节/坏块 | 需搜索 | 坏块数 < 2000 时可用 |
| 块号区间表 | 8 字节/连续区间 | 二分查找 | 坏块极少的场景 |
| 不建表 | 0 | --- | 不可接受:运行中新坏块无处记录 |
位图是最优解:bitmap[block / 8] & (1 << (block % 8)) 即可判断,一次位运算。但它不适合直接持久化 (256 块的位图只有 32 字节,可读;但 8 GB 器件有 32768 块,位图 4 KB,且一位翻转让整个位图失效),所以落盘时用坏块号列表更合适。
/* 坏块表:内存位图 + 持久化列表。这是 NAND 驱动的核心数据结构 */
typedef struct {
uint8_t *bitmap; /* 位图:1 = 好块,0 = 坏块 */
uint32_t block_count;
uint16_t bad_count; /* 当前坏块总数(含出厂) */
uint16_t spare_count; /* 预留备用块数(建议 1%~2% 或至少 2 块) */
} nand_bbm_t;
static inline int bbm_is_bad(const nand_bbm_t *b, uint32_t blk)
{
if (blk >= b->block_count) { return 1; } /* 越界视为坏块 */
return (b->bitmap[blk >> 3] & (1U << (blk & 7U))) ? 0 : 1;
}
static void bbm_mark_bad(nand_bbm_t *b, uint32_t blk, uint8_t reason)
{
if ((blk >= b->block_count) || bbm_is_bad(b, blk)) { return; }
b->bitmap[blk >> 3] &= (uint8_t)~(1U << (blk & 7U));
b->bad_count++;
b->last_bad = blk;
b->last_bad_reason = reason; /* 记原因,供售后分析 */
b->dirty = 1; /* 标记需要落盘 */
}
/* 分配一个可用块:跳过坏块,找不到返回 0xFFFFFFFF */
static uint32_t bbm_alloc_block(const nand_bbm_t *b)
{
uint32_t start = b->cursor;
for (uint32_t i = 0; i < b->block_count; i++) {
uint32_t blk = (start + i) % b->block_count;
if (!bbm_is_bad(b, blk)) {
b->cursor = (blk + 1U) % b->block_count; /* 轮转,天然均衡 */
return blk;
}
}
return 0xFFFFFFFFU; /* 全坏了 */
}
/* 首次上电:全片扫描出厂坏块。厂商在「块地址第一页的第一个字节」放 0xFF 表示坏 */
st_err_t bbm_scan_factory(const nand_t *d, nand_bbm_t *b)
{
for (uint32_t blk = 0; blk < b->block_count; blk++) {
if (nand_read_block_marker(d, blk, &marker) != ST_OK) {
return ST_ERR_IO; /* 扫描中途出错,宁可失败也不要半张表 */
}
if (marker == 0xFFU) {
bbm_mark_bad(b, blk, 0x01U); /* 原因 1 = 出厂坏块 */
}
}
return ST_OK;
}
5.3 Page Program 两步式与 Execute
NAND 写页是两步式的:先把数据装载进页缓存(Load),再发执行命令(Execute)才真正写入。这一点与 NOR 完全不同,也是最容易写错的地方。
NAND 的写和读都不是一条命令,而是「准备 + 执行」两段写:Load Random Data → 填缓存 → Program Execute → 才真正写入① 发命令 0x02页编程命令带页地址▶② 发数据 2048B往页缓存里写装载待写数据▶③ 发 0x10 Execute带页地址确认写哪一页▶④ 等 OIP 清零内部真正写入数百微秒红线:② 与 ③ 之间绝对不能插入任何操作。往页缓存里写完数据后必须立刻发 Execute,否则缓存被清,写入内容变成全 0xFF 且状态位不报错------这就是 NAND「写丢了」的典型现象。读:Read(整页进缓存)→ 从缓存里随机取字节① 发 0x13 Read整页搬进读缓存 OOB▶② 等 OIP 清零页数据进缓存约几十微秒▶③ 发 0x03 + 列地址指定从缓存第几字节开始▶④ 连续读出数据读多少字节就在缓存里取多少随机读缓存是 NAND 读效率的关键:读 16 字节也必须先整页搬进缓存(读 2 KB 只需约几十微秒),但搬一次可以反复取。因此上层应做「读放大优化」:把同一页内的多次小读合并成一次,避免反复触发整页搬运。参数页的读取是特例:它不在普通数据区内,要用专门的 Read Parameter Page 命令,且只能读第 0 页的第 0 页。另有一条命令序列在多平面器件上要特别注意:读缓存(0x13)之后再发随机读缓存(0x03),两个命令之间不允许有 CS 跳变,否则缓存失效。
图 21 · NAND 的两步式写与随机读缓存
两步之间还夹着一个容易忽略的问题:装载与执行时的页地址必须一致。有些驱动在装载时用「目标页地址」,在执行时用「当前指针」,一旦中间有别的操作修改了指针,就会把数据写到 A 块的页而 B 块被标记为已写------最终表现为「数据写到了错误的位置且无任何报错」。
/* nand_program_page:两步式写一页。len 必须是页大小的整数倍或不足一页 */
st_err_t nand_program_page(nand_t *d, uint32_t blk, uint32_t page,
const uint8_t *data, uint32_t len)
{
uint8_t hdr[4];
uint32_t row_addr;
st_err_t ret;
row_addr = (blk * d->pages_per_block + page) * d->row_bytes;
/* 步骤 1:Load Random Data(0x02),先把数据装进页缓存 */
d->cs_set(0);
hdr[0] = 0x02U;
hdr[1] = (uint8_t)(row_addr >> 16);
hdr[2] = (uint8_t)(row_addr >> 8);
hdr[3] = (uint8_t)row_addr;
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
if (spi_xfer_cs(d->cs, data, NULL, len) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
d->cs_set(1);
/* 关键:装载完成到发 Execute 之间不能插入任何别的 SPI 操作 */
/* 步骤 2:Program Execute(0x10),带行地址,确认写哪一页 */
d->cs_set(0);
hdr[0] = 0x10U;
hdr[1] = (uint8_t)(row_addr >> 16);
hdr[2] = (uint8_t)(row_addr >> 8);
hdr[3] = (uint8_t)row_addr;
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
d->cs_set(1);
/* 步骤 3:等 OIP(操作进行中)清零 */
ret = nand_wait_ready(d, d->t_prog_max);
if (ret != ST_OK) { return ret; }
/* 步骤 4:读状态位确认写成功。OIP=0 不等于写成功 */
return nand_check_status(d, 0, "program");
}
5.4 Read Page 与随机读缓存
读页同样是两步:先用 0x13 把整页(含 OOB)搬进读缓存,再用 0x03 加列地址从缓存里取数据。列地址指向页内字节偏移,可以在同一页内反复取。
/* nand_read_page:读整页(含 OOB)到缓冲区 */
st_err_t nand_read_page(nand_t *d, uint32_t blk, uint32_t page,
uint8_t *buf, uint32_t len, uint32_t oob_skip)
{
uint8_t hdr[4];
uint32_t row_addr = (blk * d->pages_per_block + page) * d->row_bytes;
st_err_t ret;
/* 步骤 1:Page Read(0x13)------ 整页搬进读缓存 */
d->cs_set(0);
hdr[0] = 0x13U;
hdr[1] = (uint8_t)(row_addr >> 16);
hdr[2] = (uint8_t)(row_addr >> 8);
hdr[3] = (uint8_t)row_addr;
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
d->cs_set(1);
ret = nand_wait_ready(d, d->t_read_max);
if (ret != ST_OK) { return ret; }
/* 步骤 2:Read From Cache(0x03)+ 列地址。列地址 = OOB 起始 + oob_skip */
d->cs_set(0);
hdr[0] = 0x03U;
hdr[1] = (uint8_t)(oob_skip >> 8);
hdr[2] = (uint8_t)oob_skip;
hdr[3] = 0x00U;
if (spi_xfer_cs(d->cs, hdr, NULL, 4U) != ST_OK) { d->cs_set(1); return ST_ERR_IO; }
ret = spi_xfer_cs(d->cs, NULL, buf, len);
d->cs_set(1);
return ret;
}
/* 读一页并做 ECC 校验。这是所有 NAND 读路径的必经步骤 */
st_err_t nand_read_page_checked(nand_t *d, uint32_t blk, uint32_t page,
uint8_t *data, uint32_t len)
{
st_err_t ret = nand_read_page(d, blk, page, data, len, 0);
if (ret != ST_OK) { return ret; }
/* ECC 校验:把 OOB 里的 ECC 字节与算出来的比较 */
ret = ecc_verify(d->ecc_ctx, data, len);
if (ret == ST_ECC_UNCORRECTABLE) {
bbm_mark_bad(&d->bbm, blk, 0x02U); /* 原因 2 = ECC 不可纠 */
return ST_ERR_CORRUPT;
}
if (ret == ST_ECC_CORRECTED) {
d->ecc_stats.corrected++; /* 记进磨损表,接近失效 */
}
return ST_OK;
}
5.5 多平面与 LUN
多平面是 NAND 提升吞吐的主要手段:一条命令同时让多个平面执行同一个操作(读、写、擦)。LUN 则是把多颗 die 并联到同一条 SPI 上,用独立片选区分。两者叠加,容量与吞吐都能成倍增长。
把「平面」当成并行单元来用,NAND 吞吐能翻数倍单平面 vs 多平面(以 4 平面器件为例)单平面(Single Plane)Plane 0:一次只能操作一个Plane 1:一次只能操作一个Plane 2:一次只能操作一个Plane 3:一次只能操作一个多平面(Multi Plane)Plane 0:发一次命令四个都动Plane 1:发一次命令四个都动Plane 2:发一次命令四个都动Plane 3:发一次命令四个都动串行:4 页要 4 个命令周期并行:4 页只需 1 个命令周期代价:多平面写必须逐页确认写成功。若 Plane 1 成功而 Plane 3 失败,需要「部分页编程」命令只补写失败的平面,否则数据不一致。而且多平面的块不能被磨损均衡单独挑出来擦------必须四页一起动,磨损均衡的算法复杂度上一个台阶。LUN(Logical Unit):把多颗 NAND 的 die 挂在同一条 SPI 上,用独立片选区分LUN 0CS1 · 2 GbLUN 1CS1 · 2 GbLUN 2CS2 · 4 GbLUN 3CS2 · 4 Gb驱动视角:LUN 是「逻辑上的一颗器件」,坏块表、磨损表、容量计算都要按 LUN 分别维护,容量是各 LUN 之和。多平面与多 LUN 可以叠加使用,但两者都只提升吞吐,不改变可靠性模型------坏块表与磨损均衡的复杂度却是叠加的。
图 22 · 多平面与 LUN:吞吐量的来源
多平面带来的两个额外约束
① 部分页编程(Partial Program):多平面写时若只有部分平面成功,需要发部分页编程命令只补写失败的平面。如果器件不支持,就必须在上层做「先写临时块、再搬移」的处理,代码复杂度显著上升。
② 磨损均衡粒度变粗 :多平面下四个平面对应的块是绑定的,磨损均衡不能单独挑一个好的块来用。因此多平面器件的磨损均衡必须按「平面组」为单位做擦除计数分配,算法比单平面复杂。
结论:**多平面是性能优化的最后一步,不是一开始就该用的东西。**先把单平面跑对、跑稳,再考虑上多平面。
5.6 内部 ECC 与状态位
NAND 的读可靠性靠 ECC 兜底。ECC 信息存在 OOB 区的固定位置,读出来之后要么由器件内置引擎比对,要么由驱动自己算一遍。
一页 2048+64 字节里,多出来的 64 字节才是 NAND 可靠性的真正载体数据区 2048 字节OOB 区(备用区)2 KB + 64 B = 一页;一个块 = 64 或 128 页;一块典型 128 KB 或 256 KBOOB 区内部布局(不同器件略有差异,必须查手册)厂商信息6 BECC 信息16~32 B坏块标记1~2 B用户元数据剩余ECC 的两种形态与处理路径硬 ECC(器件内置引擎)CPU 开销小、代码简单;但换厂商要改 OOB 偏移读整页到 SRAM(含 OOB)▶把 OOB 里 ECC 字节一起读出▶比对算出的 ECC 与存储值▶不一致 → 标为不可纠错坏块软 ECC(驱动自己算)可换算法、跨厂商通用;但吃CPU 与 RAM读数据区 + 读 OOB 里的用户 ECC▶CPU 或硬件加速算一遍▶按 bit 数分级处理▶配合磨损均衡与备用块ECC 纠正 0 bit 与纠正 3 bit 是两件事:后者说明这块正在走向失效,必须记进磨损表并换块。
图 23 · OOB 区、ECC 与状态位的分工
状态位寄存器(0x0F 读)里对驱动有用的位:WEL(写使能)、OIP(操作进行中)、FAIL(操作失败)、ECC 状态位(无错 / 已纠正 / 不可纠)。写完不看 FAIL 位是常见疏漏------写命令被接受了、也执行完了,但因为目标块已是坏块而失败,不看状态位就会以为写成功。
5.7 掉电一致性问题
NAND 的掉电一致性比 NOR 更难,原因是擦除粒度大得多:一次擦除 128 KB,一次写入 2 KB。一旦在擦除过程中掉电,整个 128 KB 全部不可信。
| 掉电时刻 | 数据状态 | 恢复策略 |
|---|---|---|
| 擦除过程中 | 该块内容未定义(可能是旧数据、半擦状态或随机值) | 整块标记为无效,靠元数据里的块级 CRC 发现 |
| 写页过程中 | 该页可能是半写状态;同一块内其他页完好 | 读到该页时 CRC 校验失败 → 标记无效 |
| 写元数据过程中 | 元数据半写(最危险) | 双份 + 版本号 + CRC,另一份仍然完整 |
| 写完但未更新元数据 | 数据在盘上但不可见 | 需要日志式写入或新空间标记才能找到 |
最后一行是 NAND 独有的问题:数据写成功但元数据没来得及更新 ,掉电后这段数据就成了「孤儿」------它确实在 Flash 里,但没有任何记录指向它。解决办法是日志式写入(Log-Structured):不原地改数据,而是顺序追加新记录,读者按顺序串联。9.4 节的超级块与日志卷会详细讲这个机制。
NAND 掉电的黄金准则
**任何时刻掉电,系统都必须能从上一次「已知良好」的状态恢复,且不丢已确认写入的数据。**落到实现上有三条:
① 元数据永远双份------两份结构相同、版本号不同、CRC 不同,读取时选 CRC 通过且版本号大的那份;
② 数据先写、元数据后写------顺序不能反。反过来的话,元数据指向了还没写的数据,掉电后就指向了无效块;
③ 写完必须回读校验再更新元数据------这是唯一能确认「数据真的落盘了」的办法。跳过这一步,元数据可能指向一个写失败的块。
后续章节请下载「芯参谋」查看
......(篇幅原因, 还有15章· 详见芯参谋→方案设计)
打开路径:🔧解决方案&应用市场分析 -> 原理方案设计 -> 嵌入式驱动&系统开发 -> 裸机_存储驱动开发指南
相关报告 · 1
- 中星微(Vimicro)_开发指南原理方案设计
- 智能家居(Smart Home)_市场分析市场应用分析
- GPS定位模块(GPSModule)_市场分析市场应用分析
- SPI_NAND_BP块保护功能_设计方案调试&解决方案
- Verify_Failed_偶发校验失败_量产排查调试&解决方案
相关报告 · 2
- 富瀚微(FullHan)_开发指南原理方案设计
- 全志(Allwinner)平台_SPI_NAND_NOR_量产排查调试&解决方案
- eMMC_Boot分区配置错误_量产排查调试&解决方案
- 翻译耳机(Translator Earbuds)_市场分析市场应用分析
- USB Type-C(USB Type-C)_市场分析市场应用分析