裸机(无 OS)存储驱动开发指南
编制日期 2026-10-05
面向 Cortex-M0/M3/M4/M7/M33、M0+ 与 RISC-V 的寄存器级存储驱动实现规范 · 覆盖寄存器访问与时序基元、SPI 总线、SPI NOR / SPI NAND / PPI NAND / SDMMC 与 SD NAND / eMMC、存储抽象层、LittleFS / FatFs、ECC 与磨损均衡与掉电安全、Bootloader 与原地更新、低功耗、调试与量产验证 · 无 RTOS、无设备树、无 Kconfig,纯手写可交付代码
一句话结论:裸机存储驱动的全部难点集中在「时间」与「断电」两件事上。 时间——Flash 的页编程与块擦除期间不可被读打断,忙等必须有超时上限,否则器件异常时整机永久卡死;断电——写操作是「先擦后写」的多步过程,任何一步断电都可能留下不一致的数据,必须靠双份 + 版本号 + CRC做原子更新。 把这两处做对,裸机存储驱动的量产可靠性就解决了八成;反之,寄存器写错、地址算错这类问题在实验室就能暴露,不构成量产风险。所有寄存器位定义以手册为准,本文出现的数值与命令码均为常见标准的示例,必须逐项对照你的器件手册确认。
这份文档解决什么
本文面向没有 RTOS、没有操作系统裸机环境(bootloader、电机控制、仪表盘、可穿戴、成本敏感小家电)的存储驱动开发。它解决三件事:① 寄存器与时序基元怎么写才不出隐性 bug(位域操作、内存屏障、精确延时、超时约定);② 各类存储器件的命令流程怎么落地成可交付代码(SPI NOR / SPI NAND / PPI NAND / SDMMC 与 SD NAND / eMMC);③ 怎么让它在量产现场活下来(ECC、磨损均衡、掉电安全、Bootloader、可测性、低功耗)。 本文不涉及操作系统内核 API、设备树、Kconfig、进程调度——需要这些请参考同系列的《Zephyr 系统开发指南》与《Zephyr 驱动开发指南》。
使用约定
- 代码示例以 Cortex-M + C99/C11 为主,寄存器访问用
REG32(addr)形式的宏;RISC-V 的差异在 2.1 节单独说明。编译期检查用static_assert(C11)或项目自备的等价宏。 - 命令码、时序参数、容量与电气特性一律以你手上器件的数据手册为唯一依据。本文出现的
0x9F、0x02等是JEDEC 标准协议的常见命令,绝大多数 NOR 与 NAND 通用,但务必逐项对照手册确认,尤其是厂商自定义命令与特殊时序。 - 标 红线 为强制约束,标 警惕 为高频踩坑点,标 建议 为经验推荐值。
- 时序数字的写法规律:典型值用于计算超时余量,最大值用于判定「器件异常」。用典型值设超时会导致慢板子误判超时,用最大值设超时会让故障卡得更久——本文给出的是取值方法而非具体数字。
- 本文不讨论具体芯片型号的外设寄存器定义(那是 SoC 手册的范畴),只讨论「怎么正确地访问寄存器」这一层。
1 · 裸机驱动的约束与骨架
这一章解决:裸机环境下的存储驱动与 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 提供了阻塞等待、互斥保护、动态内存这三样东西,裸机里全部要自己扛。
这张表里「裸机更易错的点」一列,是本文重点关注的:
| 共性错误 | 为什么裸机更容易错 | 对策 |
|---|---|---|
| 读寄存器拿到的值不对 | 编译器把 volatile 之外的读优化到循环外,只读一次 | 寄存器宏一律加 volatile;写后读回校验 |
| CS 释放过早 | 没有框架管 CS,函数返回就随手拉高 CS | CS 释放只放在传输函数末尾;读命令的 dummy 周期发完才释放 |
| 忙等无超时 | 没有调度器兜底,卡住就是整机卡住 | 所有写擦操作带超时;超时返回错误并记录 |
| 中断打断读写序列 | 没有互斥锁保护 SPI 事务 | 关中断或用软件锁包住「命令+地址+数据」的完整序列 |
| 栈上大数组 | 没有内存池与栈保护,爆栈即跑飞 | 大缓冲区全部 static 或置于专用段 |
| 写后不校验 | 没有错误码上行,错误被忽略 | 关键数据写后必须读回 CRC 校验 |
① volatile 不能省——读状态寄存器时,编译器可能把 while 循环里的读优化成只读一次,导致死循环。② 超时不能省——任何等待器件完成的循环都必须有退出条件。③ volatile 大缓冲区不能省——DMA 传输的缓冲区必须是 volatile 或带内存序标注,否则编译器可能复用寄存器内容。这三处省掉,现场表现都是「偶发、难复现、无法定位」。
1.3 硬件抽象层的三种组织方式
裸机代码可维护性的核心手段是分层:把「会变的东西」(不同主控的寄存器、不同器件的协议)与「不变的东西」(命令语义、地址换算、ECC、磨损逻辑)分开。常见有三种组织方式:
| 组织方式 | 形态 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 直接写寄存器 | 每个驱动文件里直接调 REG32(SPI_BASE+0x10) | 最直接,代码量最少 | 换芯片全盘重写 | 产品单一、只做一款板 |
| 分层 + 端口接口(本文推荐) | ① bsp 提供收发函数 → ② 器件驱动只调端口 → ③ 抽象层做算法 | 换器件与换芯片互不影响 | 初期要多想一层接口 | 多器件 / 多芯片复用 |
| 代码生成 | 用脚本/配置生成寄存器定义与驱动骨架 | 一致性好,适合大团队 | 需要工具链与规范 | 产品线多、芯片族多 |
本文采用分层 + 端口接口。核心是 ② 与 ① 之间那一层 storage_port.h:它只声明「怎么发一个字节、怎么拉 CS、怎么延时」这几件事,② 里的器件驱动完全不出现任何 SoC 寄存器名。这样换主控时只需重写 bsp/ 下的实现,②③④ 一行不改。
1.4 目录结构与命名约定
目录组织的目标很直接:让「换器件」与「换芯片」成为两个互不影响的局部改动。
| 命名约定 | 规则 | 示例 |
|---|---|---|
| 文件前缀 | 按层与器件类型命名,不用缩写混用 | 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 走线、时序预算)请参考各器件的电路设计指南。
裸机存储驱动的复杂度主要来自「等待」与「并发」。如果你发现:① 需要同时管理多个存储器件的异步请求、② 需要文件系统与网络同时在跑、③ 业务逻辑已经需要事件驱动,那么继续在裸机上堆 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 都这样),同样可以直接访问。核心是把裸数字变成有意义的名字。
/* ---- 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 都会为了「更快」而重排访问顺序。在存储驱动里,有两处必须显式约束,否则会出现「代码看起来对,但硬件行为不对」的诡异问题。
| 屏障 | 语义 | 存储驱动里的典型用法 |
|---|---|---|
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 超时与错误返回的统一约定
所有「等待器件完成」的地方都应该是同一个形状:发命令 → 记录起点 → 轮询状态 → 完成则返回,超时则报错并留下现场。把它做成一个可复用的模式,全工程的可靠性立刻上一个台阶。
/* ---- 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 是不够的,还应该:① 记录现场(把相关寄存器值与状态位打进日志或存入一个「故障快照」结构体,供售后读取);② 尝试恢复(复位器件、清状态位、必要时重发一次命令);③ 如果是写/擦操作超时,把该块标记为不可信(置一个「待校验」标志,下次读这块时先全块校验)。第三点最容易被忽略,但它是「数据静默损坏」的根源。
① 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,采样相位)。组合出四种模式,决定了「在哪个边沿采样数据、在哪个边沿更新数据」。
对存储器件而言,规律非常稳定:SPI NOR、SPI NAND、SD 卡的 SPI 模式几乎全部是模式 0 或模式 3,其中模式 0 占绝大多数。判断方法不是猜,而是看手册时序图:
- 手册里
tSLQ/tMSL这类「数据有效到时钟边沿」的时间标注,箭头指向的是上升沿还是下降沿; - 若标注指向上升沿且时钟空闲为低电平 → 模式 0;
- 若标注指向下降沿且时钟空闲为低电平 → 模式 3;
- 不确定时用四种模式全试一遍:上电后依次用模式 0/3 发
0x9F读 ID,能读到非 0xFF/0x00 的就是它。这是嵌入式调试里最实用的技巧之一。
四种模式全试一遍是调试手段,不是设计方法。量产代码里必须把确认后的模式写死,并把该器件的模式写进注释——否则半年后有人「优化」代码时把它改成模式 0,整批产品同时失效且现场无法复现。
3.2 寄存器级配置流程
裸机配置 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 收发函数时,两个等待都要有超时。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);
}
很多工程师写 send() 时只写 DR 不读 DR。在 8 位模式下,RX 缓冲里已经有一个字节了但没人取走,RXNE 一直为 1。下一次调用 recv() 时,第一步「等 RXNE」会立刻通过,读到的是上一次发送时被时钟带回来的残留字节——数据整体错位一字节,而且现象随机,非常难查。
正确做法只有一条:每次写 DR 之后必须读一次 DR,哪怕并不需要这个数据。
3.4 片选管理与多器件
一颗 SPI 控制器挂多颗存储器件时,SCK / MOSI / MISO 是共享的,只有片选是独占的。这带来两条约束:任何时刻最多一根 CS 为低;CS 之间必须有足够的不激活间隔(手册的 tCSH 与下一条命令的 tCSS 之和)。
把片选管理封装成一个「事务」接口,是让驱动不易出错的关键:拉低 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:位操作实现。速度约 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 一个字节的移位需要几十条指令、几微秒时间。如果在移位过程中被中断打断,中断里如果也操作了同一条总线(例如另一个驱动读 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 信号线在器件未选中或未上电时都会高阻。此时如果外部没有确定电平,总线电容会导致上升沿变成缓慢的爬坡——低频时正常,高频时器件就采不到正确电平。
工程做法: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 就算入门了。
最常用的标准命令整理如下。命令码以 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)里有两个决定性的位:
很多驱动写成「先发 0x06 解锁,后面做一堆别的事,最后才发擦写命令」。这样必错:器件在完成一次写操作(或读到 SR1)之后会自动清 WEL。中间插进来的任何一次读寄存器、任何一次对其它器件的操作(如果共用了 SPI 时序),都可能让 WEL 提前清零,结果是写命令被静默忽略,状态位什么都不提示,函数却返回成功。
正确写法只有一种:解锁、地址、命令、数据、读回校验,五步连着做完,中间不插入任何别的操作。
4.2 上电时序与初始上电指令
器件上电到能正常响应命令,中间有若干个必须等待的时间点。跳过任何一个,现象都是「读不到 ID」,但根因各不相同。
初始上电指令的完整序列:
/* 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;
}
① 量 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」。
/* 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
擦除是唯一比写入慢一到两个数量级的操作,也是最容易在估时上翻车的地方。三个层级必须严格区分:地址对齐要求不同、耗时量级不同、用法不同。
/* 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);
}
}
关中断等待几百毫秒到几秒,等于让系统在这段时间内无法响应任何中断:串口丢数据、电机控制抖动、看门狗被喂、按键无响应。正确做法是关中断只包住「命令本身」,一发出就立刻开,然后在等待循环里正常响应中断。
反过来说,发命令那一段(解锁 + 命令 + 地址 + 数据)必须关中断,因为这段只有几十个时钟周期,而被打断会导致器件收到拼接命令。4.4 节的 spi_xfer_cs 已经把这段包好了。
4.8 四字节地址与 QE 位
容量超过 128 Mb(16 MB)时,三字节地址不够用,必须切换到四字节地址模式。这个切换有两条路径,且切换后所有命令的地址长度与 dummy 数量都会变——这是「大容量 NOR 读出全 0」的常见原因。
四字节地址下还有一个容易忽略的变化:快速读的 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 与移植清单
把前面八节的内容收敛成一份可交付的接口清单。这份清单是本文推荐的驱动对外全部接口——功能不多不少,覆盖所有场景,且每一项都有明确的错误码约定。
| 接口 | 层级 | 说明与要点 |
|---|---|---|
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 章就算交付了。