SPI 与 NOR Flash 基础

MCU 片内的 Flash 往往只有几百 KB,要存字库、参数、日志、OTA 固件副本,就得外挂一颗 SPI NOR Flash。它便宜、容量大(1MB 到 128MB 都有)、按字节随机读,非常适合嵌入式系统的片外存储。

SPI(Serial Peripheral Interface)是四线全双工总线:SCLK 时钟、MOSI 主发从收、MISO 主收从发、CS 片选。Flash 挂在 SPI 总线上,CS 拉低表示选中,一次命令由操作码加地址和数据组成。

本文核心知识点
  • 四线 SPISCLK/MOSI/MISO/CS。
  • NOR 特性按字节读、按扇区擦除。
  • 写流程写使能、擦除、页编程。
  • 掉电安全拷贝校验、双备份。

NOR 和 NAND 是两类主流 Flash。NOR 读快、随机寻址、可以直接映射执行(XIP),但擦除粒度大、写慢;NAND 容量大、按页读写,但需要坏块管理和纠错。嵌入式里存代码和配置首选 NOR,存大容量媒体数据才考虑 NAND 或 eMMC。

SPI NOR Flash 配图:深蓝渐变背景上发光的方形芯片与环形光轨,芯片连接多个文件图标光点的扁平科技插画
一颗 SPI NOR Flash,是 MCU 的片外仓库。

SPI 时序与位序

SPI 有四种模式,由 CPOL(时钟极性)和 CPHA(时钟相位)决定。绝大多数 SPI NOR Flash 工作在模式 0:空闲时钟为低,第一个边沿采样。主从都要配对,否则读回来的数据全是乱的。

  • CPOL=0,CPHA=0:空闲低电平,上升沿采样,模式 0,最常见。
  • CPOL=1,CPHA=1:空闲高电平,下降沿采样,模式 3,部分器件用。
  • 位序:一般 MSB 在前,命令、地址、数据都从高位开始传。
  • 时钟频率:标准 SPI 25MHz,QSPI 四线模式可到 80MHz 以上。

操作码和地址长度要看数据手册:25Q 系列读命令 0x03 是 3 字节地址,QPI 模式下还有 4 字节地址变体。写驱动前先确认器件型号和命令集,不要拿 A 家的命令去操作 B 家的片子。

初始化与读 ID

第一步先把 GPIO 和 SPI 外设配好。CS 用普通 GPIO 推挽输出即可,SCLK/MOSI/MISO 复用成 SPI 功能。

// STM32 HAL 配置 SPI1,模式 0,8 位,1MHz
SPI_HandleTypeDef hspi;
hspi.Instance = SPI1;
hspi.Init.Mode = SPI_MODE_MASTER;
hspi.Init.Direction = SPI_DIRECTION_2LINES;
hspi.Init.DataSize = SPI_DATASIZE_8BIT;
hspi.Init.CLKPolarity = SPI_POLARITY_LOW;
hspi.Init.CLKPhase = SPI_PHASE_1EDGE;
hspi.Init.NSS = SPI_NSS_SOFT;
hspi.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_32;
HAL_SPI_Init(&hspi);

// 读 JEDEC ID:命令 0x9F,返回厂商/型号/容量
uint8_t cmd = 0x9F, rx[3] = {0};
CS_LOW();
HAL_SPI_TransmitReceive(&hspi, &cmd, &rx[0], 1, 100);
HAL_SPI_TransmitReceive(&hspi, &cmd, &rx[1], 1, 100);
HAL_SPI_TransmitReceive(&hspi, &cmd, &rx[2], 1, 100);
CS_HIGH();
// 0xEF 0x40 0x18 表示 Winbond W25Q128

连续传输时保持 CS 为低,命令发完紧接着发地址和数据,中间不能拉高 CS,否则器件认为命令结束。用 TransmitReceive 逐字节收发要注意:收数据时也要持续发时钟,一般发 0x00 占位。

擦除与页写入

NOR Flash 写之前必须擦除:擦除把位全部置 1,然后编程把需要的位写 0。擦除按扇区(4KB)、块(64KB)或整片进行,写入按页(256 字节)。

// 写使能 -> 扇区擦除 -> 等待 BUSY -> 页编程
void flash_erase_sector(uint32_t addr) {
  uint8_t buf[4] = {0x06};                 // 写使能
  CS_LOW(); spi_tx(buf, 1); CS_HIGH();
  buf[0] = 0x20;                           // 扇区擦除
  buf[1] = (addr >> 16) & 0xFF;
  buf[2] = (addr >> 8) & 0xFF;
  buf[3] = addr & 0xFF;
  CS_LOW(); spi_tx(buf, 4); CS_HIGH();
  flash_wait_busy();                       // 轮询 0x05 状态寄存器
}

void flash_page_write(uint32_t addr, const uint8_t *data, uint16_t len) {
  uint8_t cmd[4] = {0x02};                 // 页编程,len 不超过 256
  cmd[1] = (addr >> 16) & 0xFF;
  cmd[2] = (addr >> 8) & 0xFF;
  cmd[3] = addr & 0xFF;
  CS_LOW(); spi_tx(cmd, 4); spi_tx(data, len); CS_HIGH();
  flash_wait_busy();
}

void flash_wait_busy(void) {
  uint8_t cmd = 0x05, st = 0;
  do {
    CS_LOW(); spi_tx(&cmd, 1); spi_rx(&st, 1); CS_HIGH();
  } while (st & 0x01);                     // WIP 位为 1 表示忙
}

常见坑:跨页写。如果数据跨过 256 字节页边界,页编程会回卷到页首覆盖旧数据,所以驱动里要按页切分循环写。擦除一个 4KB 扇区大约 50ms,写满一页大约 1ms,大文件写入要有耐心,必要时上 DMA 加状态机。