3. SPI Flash驱动核心:SPI初始化、读写单字节、读写多字节

好,咱们进入正题。上一章我们把SPI Flash的硬件接口和协议捋了一遍,这一章就要真刀真枪地写代码了。说白了,驱动核心就三件事:初始化、读写单字节、读写多字节。这三板斧抡好了,后面封装文件系统、加载LVGL图片就水到渠成。

我个人习惯,写任何外设驱动,第一步不是翻手册,而是先画一张驱动架构图。把层次理清楚,写代码时才不会乱。

驱动分层思想:底层硬件操作(SPI寄存器)→ 中间层(单字节/多字节读写)→ 上层(Flash命令封装)。每一层只干自己的事,别混在一起。

SPI Flash驱动核心架构 应用层(LVGL / 文件系统) Flash命令封装层(读ID/读数据/写使能/擦除) 核心读写层(本章重点) SPI初始化 | 读写单字节 | 读写多字节 硬件抽象层(HAL:GPIO/SPI寄存器操作) 每一层只依赖下一层提供的接口,上层不关心底层寄存器怎么配的

3.1 SPI初始化——把硬件调通

初始化是第一步,也是坑最多的一步。我记得刚入行那会儿,调一个SPI Flash死活读不到数据,折腾了两天,最后发现是SPI极性配反了。嗯,这种经历一次就记住了。

SPI初始化要配的东西其实不多,但每项都得对:

配置项 典型值 说明
时钟极性 (CPOL) 0 空闲时SCK为低电平
时钟相位 (CPHA) 0 第一个时钟沿采样数据
数据位宽 8位 SPI Flash基本都是8位传输
主从模式 主机模式 MCU做主机,Flash做从机
时钟频率 ≤ Flash最大频率 常见W25Q64最大133MHz,保守点用50MHz
片选极性 低电平有效 CS拉低选中设备

我曾经踩过的坑:SPI时钟频率不是越高越好。有一次我把频率设到80MHz,读数据偶尔出错,降回50MHz就稳了。后来查手册发现,Flash虽然标称133MHz,但那是在4线QSPI模式下,普通SPI模式建议不超过50MHz。

下面给一个STM32 HAL库的初始化示例。其他平台思路一样,换一下寄存器操作就行。

// SPI Flash 初始化函数
void spi_flash_init(void)
{
    // 1. 配置GPIO:SCK, MOSI, MISO, CS
    GPIO_InitTypeDef gpio = {0};
    
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_SPI1_CLK_ENABLE();
    
    // SCK - PA5, MOSI - PA7, MISO - PA6
    gpio.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
    gpio.Mode = GPIO_MODE_AF_PP;
    gpio.Pull = GPIO_NOPULL;
    gpio.Speed = GPIO_SPEED_FREQ_HIGH;
    gpio.Alternate = GPIO_AF5_SPI1;
    HAL_GPIO_Init(GPIOA, &gpio);
    
    // CS - PA4,推挽输出
    gpio.Pin = GPIO_PIN_4;
    gpio.Mode = GPIO_MODE_OUTPUT_PP;
    gpio.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &gpio);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);  // CS默认高电平
    
    // 2. 配置SPI外设
    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;   // CPOL=0
    hspi.Init.CLKPhase = SPI_PHASE_1EDGE;       // CPHA=0
    hspi.Init.NSS = SPI_NSS_SOFT;               // 软件控制CS
    hspi.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4;  // 72/4=18MHz
    hspi.Init.FirstBit = SPI_FIRSTBIT_MSB;
    hspi.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi.Init.CRCPolynomial = 10;
    
    HAL_SPI_Init(&hspi);
    
    // 3. 验证通信:读取Flash ID
    uint32_t id = spi_flash_read_id();
    if (id == 0xEF4018) {  // W25Q64的JEDEC ID
        // 初始化成功
    } else {
        // 初始化失败,检查硬件连接和配置
    }
}

小技巧:初始化完成后,第一件事就是读Flash的JEDEC ID。这能快速验证SPI通信是否正常,比盲目读写数据靠谱得多。我每次移植驱动,都是先读ID,ID对了再往下走。

3.2 读写单字节——最基础的原子操作

单字节读写是SPI通信的基本单元。你想想看,不管后面要读多少数据,底层最终都是一个个字节传过去的。

SPI是全双工的,意思是主机发一个字节的同时,也会收到一个字节。所以读和写其实是同一件事——你发一个命令字节,同时收到一个垃圾字节;然后你发一个垃圾字节,同时收到一个有效数据字节。

// SPI 收发单字节
uint8_t spi_flash_transfer_byte(uint8_t tx_data)
{
    uint8_t rx_data = 0;
    
    // 等待发送缓冲区空
    while (!(SPI1->SR & SPI_SR_TXE));
    // 发送数据
    SPI1->DR = tx_data;
    // 等待接收缓冲区非空
    while (!(SPI1->SR & SPI_SR_RXNE));
    // 读取接收数据
    rx_data = SPI1->DR;
    
    return rx_data;
}

// 读单字节:发一个0xFF(空字节),收一个有效数据
uint8_t spi_flash_read_byte(void)
{
    return spi_flash_transfer_byte(0xFF);
}

// 写单字节:直接发数据,收的垃圾数据丢弃
void spi_flash_write_byte(uint8_t data)
{
    spi_flash_transfer_byte(data);
}

这里有个细节要注意:读数据的时候,主机必须发一个字节来触发时钟。发什么值呢?一般是0xFF,因为0xFF让MOSI保持高电平,对Flash来说最安全。我见过有人发0x00,也能用,但有些Flash在特定模式下会误解这个0x00。

3.3 读写多字节——批量操作才是王道

单字节操作效率太低了。你想啊,读一张LVGL的图片,少说几十KB,一个字节一个字节读,CPU全耗在等待上了。所以实际项目中,我们用的都是多字节读写。

多字节读写的原理很简单:连续发字节,连续收字节。但实现上有两种方式:

方式 原理 优点 缺点
轮询方式 CPU循环等待每个字节收发完成 实现简单,代码量小 占用CPU,不能做其他事
DMA方式 硬件自动搬运数据,CPU去干别的 效率高,不占CPU 配置复杂,需要DMA通道
中断方式 每收发一个字节触发中断 CPU利用率适中 频繁中断,大块数据效率不如DMA

我的建议:小数据量(< 64字节)用轮询,大数据量(> 256字节)用DMA。LVGL加载图片动不动几十KB,必须上DMA,否则CPU被拖死,界面卡成PPT。

下面给一个DMA方式的多字节读写实现:

// SPI Flash 多字节读(DMA方式)
void spi_flash_read_multi(uint32_t addr, uint8_t *buf, uint32_t len)
{
    // 1. 拉低CS,选中Flash
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET);
    
    // 2. 发送读命令(0x03)和地址(3字节)
    uint8_t cmd[4] = {0x03, (addr >> 16) & 0xFF, 
                      (addr >> 8) & 0xFF, addr & 0xFF};
    HAL_SPI_Transmit(&hspi, cmd, 4, HAL_MAX_DELAY);
    
    // 3. DMA接收数据
    // 注意:DMA接收的同时,要发0xFF来产生时钟
    uint8_t dummy = 0xFF;
    HAL_SPI_TransmitReceive_DMA(&hspi, &dummy, buf, len);
    
    // 等待DMA传输完成
    // 可以用回调或信号量
    while (HAL_SPI_GetState(&hspi) != HAL_SPI_STATE_READY);
    
    // 4. 拉高CS,释放Flash
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET);
}

// SPI Flash 多字节写(页写入,最大256字节)
void spi_flash_write_multi(uint32_t addr, const uint8_t *data, uint32_t len)
{
    // 写之前必须先使能写操作
    spi_flash_write_enable();
    
    // 1. 拉低CS
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET);
    
    // 2. 发送页写命令(0x02)和地址
    uint8_t cmd[4] = {0x02, (addr >> 16) & 0xFF,
                      (addr >> 8) & 0xFF, addr & 0xFF};
    HAL_SPI_Transmit(&hspi, cmd, 4, HAL_MAX_DELAY);
    
    // 3. DMA发送数据
    HAL_SPI_Transmit_DMA(&hspi, (uint8_t *)data, len);
    
    while (HAL_SPI_GetState(&hspi) != HAL_SPI_STATE_READY);
    
    // 4. 拉高CS
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET);
    
    // 5. 等待写操作完成(查询BUSY位)
    spi_flash_wait_busy();
}

注意:写多字节时,不能跨页!W25Q64的页大小是256字节。如果你要写地址0x100到0x200,必须分两次:先写0x100-0x1FF,再写0x200-0x2FF。跨页写入会导致数据回绕到页开头,覆盖已有数据。这个坑我掉进去过,调试了一下午才发现。

3.4 封装成统一接口

好了,单字节和多字节都有了,最后一步是封装。我习惯把底层操作封装成几个基础API,上层调用时不用关心SPI细节:

// spi_flash_driver.h
#ifndef __SPI_FLASH_DRIVER_H
#define __SPI_FLASH_DRIVER_H

#include <stdint.h>

// 初始化
void    spi_flash_init(void);

// 单字节操作
uint8_t spi_flash_read_byte(void);
void    spi_flash_write_byte(uint8_t data);

// 多字节操作
void    spi_flash_read_multi(uint32_t addr, uint8_t *buf, uint32_t len);
void    spi_flash_write_multi(uint32_t addr, const uint8_t *data, uint32_t len);

// 状态查询
uint8_t spi_flash_read_status(void);
void    spi_flash_wait_busy(void);

#endif

这组接口就是整个SPI Flash驱动的基石。后面不管是要实现文件系统,还是直接读写图片数据,都调用这几个函数就够了。

经验之谈:封装接口时,参数顺序尽量统一。我见过有人把地址放在最后,有人放在最前,换来换去容易出错。我个人统一用 (addr, buf, len) 的顺序,跟memcpy保持一致,不容易记混。