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 时序与位序
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 加状态机。
掉电安全设计
设备随时可能断电,写入只做一半是最常见的数据损坏场景。掉电安全三板斧:先擦后写、双缓冲、写后回读校验。
- 先擦后写:擦除完成且状态非忙后再写,杜绝边擦边写。
- 双备份:关键配置写两份,主备份和副备份交替更新,启动时校验主备份,坏了自动用副备份并重建主备份。
- 写后校验:写完一页立刻读回比对,不一致重写,重试三次仍失败就标记坏块并迁移。
- 上电初始化:每次启动扫描配置区,检测魔数和 CRC,魔数不对说明上次断电在写入中,走恢复流程。
参数频繁变化(比如计数器、日志)还会带来磨损问题:NOR Flash 擦写寿命约十万次。高频写入字段要分散到多个扇区轮转写,或者用日志型结构避免每次都擦同一块。
文件系统与日志
数据多了,直接用扇区地址管理太痛苦,可以上掉电安全的小型文件系统。嵌入式里最常用的是 littlefs,专为 MCU 设计,掉电不丢数据,支持磨损均衡和坏块处理。
// littlefs 挂载示例
lfs_t lfs;
lfs_config cfg = {
.read = lfs_flash_read, // 封装 SPI 读
.prog = lfs_flash_prog, // 封装页编程
.erase = lfs_flash_erase, // 封装扇区擦除
.sync = lfs_flash_sync,
.read_size = 256,
.prog_size = 256,
.block_size = 4096,
.block_count = 128,
.cache_size = 256,
.lookahead_size = 128,
.block_cycles = 500,
};
lfs_format(&lfs, &cfg);
lfs_mount(&lfs, &cfg);
日志类数据更适合顺序写:一个追加日志扇区,满了再换下一个,天然低磨损且断电恢复简单。OTA 固件副本则直接按块存,配合 CRC 校验表,升级失败还能回滚旧版本。
常见问题排查
// 1. 读 ID 全是 0xFF:CS 没拉低或 MOSI/MISO 接反
// 2. 读 ID 全是 0x00:SPI 模式不匹配,检查 CPOL/CPHA
// 3. 写不进数据:忘记写使能 0x06,或擦除没完成
// 4. 读回数据错位:地址超过 3 字节范围,或用错命令集
// 5. 写入特别慢:跨页写导致回卷重写,按页切分
// 6. 掉电后配置丢失:没有双备份和回读校验
SPI Flash 调试,先读 ID 再读写,ID 通了九成问题都解决了。
把片外存储用稳,参数、日志、OTA 都有了家,产品的可靠性就上了一个台阶。