第2章:Flash驱动接口设计:标准SPI Flash驱动框架、初始化、读ID、读写擦除函数封装
好,咱们接着往下聊。上一章我们把SPI Flash的硬件协议和时序理清楚了,这一章就该动手写代码了。说白了,驱动接口设计就是给上层应用提供一套“傻瓜式”的操作函数——你调用初始化,它就准备好;你调用读ID,它就返回芯片型号;你调用读写擦除,它就老老实实干活。
我个人习惯把驱动分成三层:硬件抽象层、核心操作层、API接口层。这样分层的好处是,哪天换了MCU,你只需要改最底层的几行SPI收发代码,上层纹丝不动。我在项目中吃过亏,早期把所有代码揉在一起,换芯片时改得想哭……后来就老老实实分层了。
2.1 标准SPI Flash驱动框架
先搭个架子。一个标准的SPI Flash驱动,至少包含下面这些文件:
spi_flash.h—— 头文件,声明所有接口和数据结构spi_flash.c—— 实现文件,核心逻辑都在这里spi_flash_cfg.h—— 配置文件,管脚、时钟、延时等参数spi_flash_port.c—— 移植层,封装MCU的SPI收发函数
你想想看,这种结构是不是很眼熟?对,就是嵌入式里常见的“分层+回调”模式。我建议你在项目里也这么搞,哪怕只是个小Demo,养成习惯后收益很大。
核心思想:驱动接口只关心“发什么命令、收什么数据”,不关心“SPI寄存器怎么配”。把硬件相关的东西隔离到port层,移植时只改port文件。
2.2 初始化函数封装
初始化函数,我一般叫它 spi_flash_init()。它要做三件事:
- 配置SPI外设(时钟、极性、相位、速率)
- 配置片选引脚(GPIO输出,默认拉高)
- 发送一条“唤醒”命令(有些Flash上电后处于掉电模式)
代码大概长这样:
/**
* @brief 初始化SPI Flash
* @retval 0: 成功, -1: 失败
*/
int spi_flash_init(void)
{
// 1. 初始化SPI硬件(移植层实现)
spi_port_init();
// 2. 片选引脚初始化,默认高电平
gpio_port_cs_init();
spi_flash_cs_high();
// 3. 有些Flash上电后处于掉电模式,需要唤醒
// 发送0xAB命令(Release from Deep Power-down)
spi_flash_send_cmd(CMD_RELEASE_POWER_DOWN);
delay_ms(10); // 等待芯片稳定
// 4. 读取设备ID,验证通信是否正常
uint32_t id = spi_flash_read_id();
if (id == 0xFFFFFFFF || id == 0x00000000) {
// 读不到ID,说明初始化失败
return -1;
}
return 0;
}
嗯,这里要注意:唤醒命令后的延时不能省。我记得有一次调试,把延时从10ms改成了1ms,结果读ID偶尔会失败。查了半天,发现是Flash还没完全醒来就发命令了。这种坑,踩过一次就记住了。
警告:初始化函数里不要做“写使能”操作。写使能是每次写操作前单独做的,初始化时只做基本配置和通信验证。
2.3 读ID函数封装
读ID是验证通信是否正常的最快方法。标准命令是 0x9F(JEDEC ID),返回3个字节:厂商ID、内存类型、容量。
我习惯封装成 spi_flash_read_id(),返回一个32位整数:
/**
* @brief 读取Flash的JEDEC ID
* @return 32位ID值 (高8位为厂商ID,中间8位为类型,低8位为容量)
*/
uint32_t spi_flash_read_id(void)
{
uint8_t cmd = CMD_READ_JEDEC_ID; // 0x9F
uint8_t buf[3] = {0};
spi_flash_cs_low();
spi_port_transfer(&cmd, 1); // 发送命令
spi_port_transfer(buf, 3); // 读取3字节ID
spi_flash_cs_high();
// 拼成32位值
return ((uint32_t)buf[0] << 16) | ((uint32_t)buf[1] << 8) | buf[2];
}
读回来的ID怎么用?举个例子:W25Q64的ID是 0xEF4017,其中 0xEF 是华邦(Winbond),0x40 是类型,0x17 表示64Mbit(8MB)。
我曾经在项目里遇到过一件怪事:读ID总是返回 0xFFFFFF。排查了半天,发现是片选引脚配置成了推挽输出但没拉高,导致SPI通信时片选一直低电平。嗯,这种低级错误,犯过一次就再也不会犯了。
小技巧:调试时可以在初始化后立即打印ID,如果ID正确,说明硬件连接和底层SPI配置都没问题。这是最快速的“冒烟测试”。
2.4 读函数封装
读操作相对简单,因为不需要先写使能。标准读命令是 0x03,后面跟3字节地址,然后连续读取数据。
我封装的接口长这样:
/**
* @brief 从指定地址读取数据
* @param addr 起始地址(24位)
* @param buf 数据缓冲区
* @param len 读取长度(字节)
* @return 0: 成功, -1: 失败
*/
int spi_flash_read(uint32_t addr, uint8_t *buf, uint32_t len)
{
// 参数检查
if (buf == NULL || len == 0) return -1;
if (addr + len > FLASH_TOTAL_SIZE) return -1;
uint8_t cmd[4];
cmd[0] = CMD_READ_DATA; // 0x03
cmd[1] = (addr >> 16) & 0xFF; // 地址高8位
cmd[2] = (addr >> 8) & 0xFF; // 地址中8位
cmd[3] = addr & 0xFF; // 地址低8位
spi_flash_cs_low();
spi_port_transfer(cmd, 4); // 发送命令+地址
spi_port_transfer(buf, len); // 读取数据
spi_flash_cs_high();
return 0;
}
这里有个细节:读操作不需要等待忙状态。因为读操作不会改变Flash内部状态,随时可以读。但要注意,如果Flash正在执行擦除或写操作,读操作可能会被忽略或返回错误数据。所以,安全起见,读之前最好检查一下状态寄存器的“忙标志位”。
2.5 写函数封装
写操作就麻烦一些了。SPI Flash的写操作有3个步骤:
- 发送写使能命令(
0x06) - 发送页编程命令(
0x02)+ 地址 + 数据 - 等待忙状态结束
而且,一次最多写一页(通常是256字节)。如果数据跨页了,得分多次写。
/**
* @brief 向指定地址写入数据(自动处理跨页)
* @param addr 起始地址
* @param buf 数据缓冲区
* @param len 写入长度
* @return 0: 成功, -1: 失败
*/
int spi_flash_write(uint32_t addr, const uint8_t *buf, uint32_t len)
{
uint32_t remain = len;
uint32_t offset = 0;
while (remain > 0) {
// 计算当前页内剩余空间
uint32_t page_offset = addr & (FLASH_PAGE_SIZE - 1);
uint32_t chunk = FLASH_PAGE_SIZE - page_offset;
if (chunk > remain) chunk = remain;
// 写使能
spi_flash_write_enable();
// 发送页编程命令
spi_flash_cs_low();
uint8_t cmd[4];
cmd[0] = CMD_PAGE_PROGRAM; // 0x02
cmd[1] = (addr >> 16) & 0xFF;
cmd[2] = (addr >> 8) & 0xFF;
cmd[3] = addr & 0xFF;
spi_port_transfer(cmd, 4);
spi_port_transfer((uint8_t*)(buf + offset), chunk);
spi_flash_cs_high();
// 等待写完成
spi_flash_wait_busy();
addr += chunk;
offset += chunk;
remain -= chunk;
}
return 0;
}
你可能会问:为什么每次写之前都要写使能?这是Flash的硬件设计决定的——写使能命令相当于一个“安全锁”,防止误写。每次写操作完成后,这个锁会自动关上,下次写之前必须重新打开。
重要:写操作前,目标地址所在的扇区(通常是4KB)必须先擦除。Flash的特性是“只能将1写成0,不能将0写成1”。擦除操作会把整个扇区恢复成全1(0xFF),然后才能写。
2.6 擦除函数封装
擦除操作有3种粒度:
| 命令 | 操作码 | 擦除大小 | 典型时间 |
|---|---|---|---|
| 扇区擦除 | 0x20 | 4KB | ~150ms |
| 块擦除 | 0xD8 | 64KB | ~1s |
| 整片擦除 | 0xC7 | 全片 | ~10s |
我一般只封装扇区擦除和整片擦除,块擦除用得少。扇区擦除的代码:
/**
* @brief 擦除指定地址所在的扇区(4KB)
* @param addr 扇区内的任意地址
* @return 0: 成功, -1: 失败
*/
int spi_flash_sector_erase(uint32_t addr)
{
// 写使能
spi_flash_write_enable();
// 发送扇区擦除命令
spi_flash_cs_low();
uint8_t cmd[4];
cmd[0] = CMD_SECTOR_ERASE; // 0x20
cmd[1] = (addr >> 16) & 0xFF;
cmd[2] = (addr >> 8) & 0xFF;
cmd[3] = addr & 0xFF;
spi_port_transfer(cmd, 4);
spi_flash_cs_high();
// 等待擦除完成
spi_flash_wait_busy();
return 0;
}
擦除操作比较耗时,150ms左右。如果在实时性要求高的系统里,建议用“查询忙状态”的方式,而不是死等。你可以把 spi_flash_wait_busy() 实现成轮询状态寄存器,每次查询间隔1ms,直到忙标志清零。
注意:整片擦除命令(0xC7)执行时间很长,而且不可中断。如果在擦除过程中掉电,Flash内部数据可能损坏。我建议非必要不用整片擦除,改用扇区擦除逐个清理。
2.7 状态寄存器与忙检测
前面多次提到“等待忙状态”,这里单独说一下。状态寄存器(Status Register)的第0位是“忙标志位”(BUSY),1表示正在忙,0表示空闲。
检测忙状态的代码:
/**
* @brief 等待Flash空闲(忙标志清零)
* @param timeout_ms 超时时间(毫秒)
* @return 0: 成功, -1: 超时
*/
int spi_flash_wait_busy(uint32_t timeout_ms)
{
uint32_t start = get_tick_ms();
uint8_t cmd = CMD_READ_STATUS; // 0x05
uint8_t status;
do {
spi_flash_cs_low();
spi_port_transfer(&cmd, 1);
spi_port_transfer(&status, 1);
spi_flash_cs_high();
if ((status & 0x01) == 0) {
return 0; // 空闲了
}
delay_ms(1); // 每次查询间隔1ms
} while (get_tick_ms() - start < timeout_ms);
return -1; // 超时
}
超时时间怎么设?我一般给擦除操作设500ms,写操作设100ms。如果超时了,大概率是芯片挂了或者通信异常,这时候可以尝试复位Flash或者重新初始化。
2.8 接口封装总结
好了,到这里我们就把SPI Flash的核心操作都封装好了。总结一下,一个完整的驱动接口应该包含:
spi_flash_init()—— 初始化spi_flash_read_id()—— 读IDspi_flash_read()—— 读数据spi_flash_write()—— 写数据(自动跨页处理)spi_flash_sector_erase()—— 扇区擦除spi_flash_chip_erase()—— 整片擦除spi_flash_wait_busy()—— 等待空闲spi_flash_write_enable()—— 写使能
这些接口足够覆盖90%的应用场景了。下一章我们会基于这些接口,开始搭建Bootloader的核心框架——分区管理、固件校验、跳转逻辑。到时候你会发现,驱动层写得越干净,上层逻辑就越清爽。
个人建议:写完驱动后,写一个简单的测试函数,依次执行:初始化→读ID→擦除一个扇区→写入一串数据→读出对比。如果全部通过,说明驱动基本可靠。这个测试用例我每个项目都会保留,换芯片或改驱动后先跑一遍,心里踏实。