只读分享解决方案文档原理方案设计📅 2026-10-05🔖 Rev 1.4⏳ 26天有效(至 2026-11-05)
🔧解决方案&应用市场分析 -> 原理方案设计 -> 裸机_存储驱动开发指南

裸机_存储驱动开发指南

生成时间 2026-10-06 15:32:43 有效期至 2026-11-05 15:38:18(26天有效(至 2026-11-05))
同系列 · 原理方案设计

裸机(无 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,纯手写可交付代码

0 依赖
不依赖任何 OS
寄存器级实现,可直接跑在最小系统
3 类总线
SPI / SDMMC / 并口
覆盖 XTX 全产品线接口形态
超时必填
写擦上限
无上限忙等是裸机死机头号原因
掉电安全
原子更新
双份 + 版本号 + CRC 三件套
量产视角
可测性
产线怎么测、售后怎么修,写在驱动里

一句话结论:裸机存储驱动的全部难点集中在「时间」与「断电」两件事上。 时间——Flash 的页编程与块擦除期间不可被读打断,忙等必须有超时上限,否则器件异常时整机永久卡死;断电——写操作是「先擦后写」的多步过程,任何一步断电都可能留下不一致的数据,必须靠双份 + 版本号 + CRC做原子更新。 把这两处做对,裸机存储驱动的量产可靠性就解决了八成;反之,寄存器写错、地址算错这类问题在实验室就能暴露,不构成量产风险。所有寄存器位定义以手册为准,本文出现的数值与命令码均为常见标准的示例,必须逐项对照你的器件手册确认。

这份文档解决什么

本文面向没有 RTOS、没有操作系统裸机环境(bootloader、电机控制、仪表盘、可穿戴、成本敏感小家电)的存储驱动开发。它解决三件事:① 寄存器与时序基元怎么写才不出隐性 bug(位域操作、内存屏障、精确延时、超时约定);② 各类存储器件的命令流程怎么落地成可交付代码(SPI NOR / SPI NAND / PPI NAND / SDMMC 与 SD NAND / eMMC);③ 怎么让它在量产现场活下来(ECC、磨损均衡、掉电安全、Bootloader、可测性、低功耗)。 本文不涉及操作系统内核 API、设备树、Kconfig、进程调度——需要这些请参考同系列的《Zephyr 系统开发指南》与《Zephyr 驱动开发指南》。

路线 1 · 第一次写存储驱动
从寄存器到能读 ID
第 2 章寄存器与时序基元 → 第 3 章 SPI 总线 → 第 4 章 SPI NOR 读 ID 与读数据 → 第 15.2 节 API 骨架速查。
重点:位域操作宏(2.2)、SPI 模式匹配(3.1)、上电时序(4.2)
路线 2 · 读不到 ID / 数据乱
上板就不工作
第 14.1 节读不到 ID → 第 3.6 节频率与信号完整性 → 第 4.3 节读 ID 辨错 → 第 12.2 节逻辑分析仪抓总线。
重点:模式不匹配、WEL/WIP 位、地址字节序、dummy 周期
路线 3 · 做 NAND / 大容量
NAND 类器件落地
第 5 章 SPI NAND(含坏块表)→ 第 6 章 PPI NAND → 第 10 章 ECC 与磨损均衡 → 第 9 章文件系统。
重点:坏块表持久化(5.2)、两步式写(5.3)、动态磨损均衡(10.3)
路线 4 · 做 SD NAND / eMMC
块设备与大容量文件
第 7 章 SDMMC 状态机 → 7.5 节 RCA 与 EXT_CSD → 第 9 章 FatFs → 第 8 章 扇区与对齐。
重点:RCA=0 陷阱(7.5)、CMD 顺序理由(7.3)、多块命令(7.6)
路线 5 · 量产前把关
可靠性与产线
第 10 章可靠性 → 第 11 章 Bootloader 与更新 → 第 12 章测试验证 → 第 15.4 节上线核查清单。
重点:原子更新(10.4)、试运行回滚(11.6)、产线测点(12.6)

使用约定

  • 代码示例以 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 提供了阻塞等待、互斥保护、动态内存这三样东西,裸机里全部要自己扛。

同一颗 SPI NOR,裸机与 RTOS 下驱动代码的差异分布环节裸机实现要点RTOS 实现要点裸机更易错的点寄存器访问直接读写,无保护无保护(裸机一样裸)编译器优化可能缓存读值片选管理自己控制 CS 时序框架管理 CSCS 释放过早导致数据丢忙等循环轮询 + 超时轮询 / 工作队列 / 延迟提交无超时 = 永久卡死并发单线程,无需锁互斥锁 / 临界区中断打断读写序列缓冲区静态数组malloc / 内存池栈上大数组爆栈错误处理返回码 + 全局 errno返回码 + 日志错误被忽略,脏数据上盘地址换算手工位运算flash_map 查分区页内偏移算错掉电保护双份 + 版本号 + CRC日志式文件系统半写状态被当成有效数据核心差异只有一句:RTOS 把「等待」和「并发」抽象掉了,裸机里这两件事全部回到你手上。因此裸机代码的正确性几乎完全取决于三处:时序等待有没有上限、缓冲区位置与对齐对不对、错误有没有被处理。本章与第 2、3 章解决前两处,第 4~11 章解决第三处。
图 1 · 裸机驱动与 RTOS 驱动的差异对照

这张表里「裸机更易错的点」一列,是本文重点关注的:

共性错误为什么裸机更容易错对策
读寄存器拿到的值不对编译器把 volatile 之外的读优化到循环外,只读一次寄存器宏一律加 volatile;写后读回校验
CS 释放过早没有框架管 CS,函数返回就随手拉高 CSCS 释放只放在传输函数末尾;读命令的 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/erase nand_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 NORSPI x1/x2/x4第 3、4 章JEDEC 标准 SPI NOR 指令集
SPI NANDSPI x1/x2/x4第 5 章ONFI 兼容的 SPI NAND 指令集
PPI NAND(并口)x8/x16 并行第 6 章异步并行 NAND 命令
SD NANDSDMMC x1/x4第 7、8 章SD 协议族(物理层与命令集)
eMMCSDMMC 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彻底不用管 CacheDTCM 容量有限(通常 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 = tx[i]; // 写入 while (!SPI_SR_RXNE()) ; // 等收满 rx[i] = 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 首字节对、后两字节全 0MISO 未配置成输入 / 悬空检查 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 常见值为准,务必与你的器件手册核对。

命令码名称四段结构用途与注意
0x05Read Status Register 1命令 + 数据 1B读 WIP / WEL / BP 保护位
0x06Write Enable仅命令字置 WEL;发擦/写命令前必须先发
0x9FJEDEC ID / RDID命令 + 数据 3B识别厂商与容量;上电第一步
0x03Read Data命令 + 地址 + 数据三字节地址;无 dummy 但要发满 4 字节头
0x0BFast Read命令 + 地址 + dummy + 数据dummy 数量随频率与模式变,必须查表
0x0CFast Read Quad Output命令 + 地址 + 4 dummy + 数据四线读;需先置 QE
0x02Page Program命令 + 地址 + 数据单次 ≤ 页大小,跨页必须拆
0xD8Block Erase 64KB仅命令 + 地址对齐到 64 KB 边界
0x20Sector Erase 4KB仅命令 + 地址对齐到 4 KB 边界;最常用
0xC7Chip Erase仅命令字整片擦除;耗时可达数秒
0x35Read Status Register 2命令 + 数据 1BSR2:QE 位、地址模式位的所在
0xB7Enter 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)命令层故障现场记录:命令码、状态位、耗时、调用位置

移植到一颗新器件时,只需回答四个问题:

  1. ID 是什么?发一次 0x9F 读三个字节(若是双输出模式则要发 0x90),填进 ID 表。
  2. 容量多大、页与扇区多大?从手册的「Organization」或「Block Erasure」表里抄,填进 ID 表。
  3. 三字节还是四字节地址?容量 ≤ 128 Mb 用三字节;更大则看手册给的进入方式(0xB7 命令还是 SR 位),填进 ID 表。
  4. 各操作的时序数值?抄 tPP(页编程)、tSE(扇区擦)、tBE(块擦)、tCE(整片擦)、tPP_max 等的最大值,乘 4 作为超时值。
本章验收自查

① 上电后能稳定读出正确的三字节 ID,型号与容量与实际芯片一致;② 在页边界上做 300 字节跨页写,读回逐字节正确;③ 擦除一个扇区后读回全 0xFF;④ 把 WIP 等待超时人为改到 1 ms,函数能返回 ST_ERR_TIMEOUT 而不是死循环;⑤ 掉电重启后器件仍能读到 ID(验证掉电恢复能力)。这五条通过,第 4 章就算交付了。

扫码打开本页
微信扫码 · 展会可扫

再分享给同事

同事可直接打开这份资料《裸机_存储驱动开发指南》,在线阅读;完整章节请下载芯参谋。