第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()。它要做三件事:

  1. 配置SPI外设(时钟、极性、相位、速率)
  2. 配置片选引脚(GPIO输出,默认拉高)
  3. 发送一条“唤醒”命令(有些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个步骤:

  1. 发送写使能命令(0x06
  2. 发送页编程命令(0x02)+ 地址 + 数据
  3. 等待忙状态结束

而且,一次最多写一页(通常是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() —— 读ID
  • spi_flash_read() —— 读数据
  • spi_flash_write() —— 写数据(自动跨页处理)
  • spi_flash_sector_erase() —— 扇区擦除
  • spi_flash_chip_erase() —— 整片擦除
  • spi_flash_wait_busy() —— 等待空闲
  • spi_flash_write_enable() —— 写使能

这些接口足够覆盖90%的应用场景了。下一章我们会基于这些接口,开始搭建Bootloader的核心框架——分区管理、固件校验、跳转逻辑。到时候你会发现,驱动层写得越干净,上层逻辑就越清爽。

个人建议:写完驱动后,写一个简单的测试函数,依次执行:初始化→读ID→擦除一个扇区→写入一串数据→读出对比。如果全部通过,说明驱动基本可靠。这个测试用例我每个项目都会保留,换芯片或改驱动后先跑一遍,心里踏实。