3. SPI Flash驱动核心:SPI初始化、读写单字节、读写多字节
好,咱们进入正题。上一章我们把SPI Flash的硬件接口和协议捋了一遍,这一章就要真刀真枪地写代码了。说白了,驱动核心就三件事:初始化、读写单字节、读写多字节。这三板斧抡好了,后面封装文件系统、加载LVGL图片就水到渠成。
我个人习惯,写任何外设驱动,第一步不是翻手册,而是先画一张驱动架构图。把层次理清楚,写代码时才不会乱。
驱动分层思想:底层硬件操作(SPI寄存器)→ 中间层(单字节/多字节读写)→ 上层(Flash命令封装)。每一层只干自己的事,别混在一起。
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保持一致,不容易记混。