4. SPI Flash 驱动封装:抽象层设计、接口定义
驱动封装这件事,说白了就是给底层硬件穿上「标准化外衣」。你想想看,LVGL 要读图片,它才不管你是 W25Q64 还是 MX25L128,它只想要一个统一的接口:给我地址,我把数据吐出来。
所以这一章的核心任务,就是设计一套抽象层,把 SPI Flash 的读写擦除操作包装成干净、可移植的 API。我在项目里吃过不少「驱动写死」的亏,后来学乖了——先定接口,再写实现,顺序不能乱。
4.1 为什么需要抽象层?
直接操作 SPI 寄存器当然也能用,但问题在于:
- 硬件耦合太紧:换个 Flash 型号,代码得重写一半
- LVGL 集成困难:LVGL 的图片解码层需要标准化的 read/write 回调
- 调试维护成本高:每个项目都从零写一遍,浪费时间
抽象层的作用,就是在这两层之间加一个「翻译官」:
核心思想:上层(LVGL/应用)只调用抽象接口,下层(具体 Flash 驱动)实现这些接口。中间用结构体指针连接,松耦合、易替换。
4.2 接口定义:spi_flash_ops_t 结构体
我个人习惯用函数指针结构体来定义驱动接口。这样上层代码只需要持有这个结构体的指针,具体实现可以后期注入。
/* spi_flash_ops.h */
#ifndef _SPI_FLASH_OPS_H_
#define _SPI_FLASH_OPS_H_
#include <stdint.h>
/* 状态码定义 */
typedef enum {
FLASH_OK = 0,
FLASH_ERR_PARAM = -1,
FLASH_ERR_BUSY = -2,
FLASH_ERR_TIMEOUT = -3,
FLASH_ERR_IO = -4
} flash_status_t;
/* 驱动操作结构体 */
typedef struct {
flash_status_t (*init)(void);
flash_status_t (*deinit)(void);
flash_status_t (*read)(uint32_t addr, uint8_t *buf, uint32_t len);
flash_status_t (*write)(uint32_t addr, const uint8_t *buf, uint32_t len);
flash_status_t (*erase)(uint32_t addr, uint32_t len);
flash_status_t (*status)(uint8_t *sr);
flash_status_t (*sync)(void);
} spi_flash_ops_t;
#endif /* _SPI_FLASH_OPS_H_ */
设计要点:
- 返回值统一用
flash_status_t,上层只判断是否等于FLASH_OK sync()用于等待内部操作完成,比如擦除后的忙检测- 地址参数用
uint32_t,覆盖最大 128Mbit 的 Flash 寻址
4.3 各接口的职责与实现要点
| 接口 | 功能 | 典型实现要点 |
|---|---|---|
init() |
初始化 SPI 外设、GPIO、Flash 芯片 | 拉高 CS、配置 SPI 模式 0、读取芯片 ID 校验 |
deinit() |
反初始化,释放资源 | 关闭 SPI 时钟、GPIO 恢复默认状态 |
read() |
从指定地址读取 len 字节 | 发送 0x03 指令 + 3 字节地址,连续读取 |
write() |
向指定地址写入 len 字节 | 先写使能 (0x06),再发 0x02 指令 + 地址 + 数据 |
erase() |
擦除指定区域 | 4KB 扇区擦除 (0x20) 或 64KB 块擦除 (0xD8) |
status() |
读取状态寄存器 | 发送 0x05,读取 BUSY 位判断是否空闲 |
sync() |
等待 Flash 内部操作完成 | 轮询 status() 直到 BUSY 位清零,带超时机制 |
4.4 避坑指南:我踩过的几个坑
接口定义看起来简单,但实际落地时有不少细节。我把自己踩过的坑列出来,你写代码时留个心眼:
- 写之前必须使能:每次 write/erase 之前,必须先发 0x06(写使能指令)。我刚开始忘了这步,数据死活写不进去,查了半天才发现。
- 擦除粒度不是字节:SPI Flash 的最小擦除单位是扇区(通常 4KB)。你不能只擦一个字节,必须整扇区擦。LVGL 图片更新时要注意对齐。
- 读操作不需要使能:读指令 0x03 不需要先发写使能,直接发就行。这个和写操作不一样,别搞混。
- 超时机制必须有:擦除操作可能耗时几百毫秒,sync() 里一定要加超时退出,否则芯片异常时程序会卡死。
重要提醒:SPI Flash 的写操作只能把 1 变成 0,不能把 0 变成 1。要把 0 变回 1,必须先擦除。所以更新数据时,通常是「读-改-擦-写」四步走。
4.5 实现示例:基于 W25Q64 的驱动封装
下面是一个简化的实现片段,展示如何把具体操作挂接到抽象接口上:
/* w25q64_drv.c */
#include "spi_flash_ops.h"
#include "spi_hw.h" /* 底层 SPI 硬件操作 */
static flash_status_t w25q64_init(void) {
uint8_t id[3];
/* 读取芯片 ID 校验 */
if (spi_flash_read_id(id) != 0) return FLASH_ERR_IO;
if (id[0] != 0xEF || id[1] != 0x40) return FLASH_ERR_IO;
return FLASH_OK;
}
static flash_status_t w25q64_read(uint32_t addr, uint8_t *buf, uint32_t len) {
uint8_t cmd[4] = {0x03, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF};
spi_cs_low();
spi_transmit(cmd, 4);
spi_receive(buf, len);
spi_cs_high();
return FLASH_OK;
}
/* 其他函数实现类似,此处省略 */
/* 导出操作结构体 */
const spi_flash_ops_t w25q64_ops = {
.init = w25q64_init,
.deinit = w25q64_deinit,
.read = w25q64_read,
.write = w25q64_write,
.erase = w25q64_erase,
.status = w25q64_status,
.sync = w25q64_sync
};
4.6 如何使用这套接口
上层代码只需要持有 spi_flash_ops_t 指针,调用时完全不用关心底层是什么芯片:
/* 应用层代码 */
#include "spi_flash_ops.h"
extern const spi_flash_ops_t w25q64_ops;
void flash_demo(void) {
const spi_flash_ops_t *flash = &w25q64_ops;
uint8_t buf[256];
flash->init();
flash->erase(0x000000, 4096); /* 擦除第一个扇区 */
flash->sync(); /* 等待擦除完成 */
flash->write(0x000000, buf, 256);
flash->sync();
flash->read(0x000000, buf, 256);
}
换芯片时:只需要把 w25q64_ops 换成另一个芯片的 mx25l128_ops,应用层代码一行都不用改。这就是抽象层的价值。
4.7 状态查询与错误处理
状态查询接口 status() 和 sync() 是容易被忽略但极其重要的部分。我见过不少项目,读写擦除都写了,唯独没做状态查询,结果芯片忙的时候发指令,数据全乱套。
建议的 sync 实现逻辑:
- 读取状态寄存器
- 检查 BUSY 位(bit 0)
- 如果为 1,延时后继续轮询
- 如果超时(比如 5 秒),返回
FLASH_ERR_TIMEOUT
嗯,这里要注意:不同 Flash 芯片的状态寄存器布局可能不同。W25Q64 的 BUSY 位在 bit 0,但有些芯片可能在 bit 1。封装时最好在驱动内部做适配,对外统一返回「忙/空闲」即可。