F429-HAL-SPI5读取W25Q256(2026/7/25)

📅 2026/7/26 0:39:23 👁️ 阅读次数 📝 编程学习
F429-HAL-SPI5读取W25Q256(2026/7/25)

目录

一、SPI Init 字段逐个解析

① Instance — 用哪个 SPI 硬件

② BaudRatePrescaler — SPI 时钟 = APB 时钟 / 分频数

③ Direction — 通信方向

④⑤ CLKPolarity + CLKPhase — SPI 模式

⑥⑦ CRCCalculation + CRCPolynomial

⑧ DataSize — 数据位宽

⑨ FirstBit — 先发高位还是低位

⑩ NSS — 片选引脚控制方式

⑪ TIMode — TI 专用模式

⑫ Mode — 主设备还是从设备

为什么 HAL_SPI_Init 后还要 __HAL_SPI_ENABLE?

跟 UART / I2C Init 对比

二、野火源码流程分析

1: 分清 HAL 函数和野火封装函数

2: 跟 I2C 的关键区别

3: 最核心的底层函数:SPI_FLASH_SendByte

4: 所有操作的固定套路(四步模式)

5: 读 ID

6: 擦除扇区

7: 写页

8: 读数据

9: 跨页写入逻辑

10: WaitForWriteEnd — 等芯片忙完

11: 跟 I2C AT24C02 对比

12: W25Q256 芯片参数

三、写我们自己的 SPI_Flash 驱动(轮询版)

改动清单

关键改动

驱动架构

四、SPI 模式 0 vs 模式 3

五、SPI 中断读写

1: 策略:数据走中断

4: HAL_SPI_TransmitReceive_IT 形参

2: IT 写完整跳转链(以 16 字节为例)

3: 跟 I2C 对比

六、SPI DMA 读写

1: DMA 映射

2: 策略:命令+地址轮询,数据走 DMA

3: DMA 写完整跳转链(以 16 字节为例)

4: 跟 IT 版对比

5: IT/DMA 跨页写入

6: 三种模式对比


一、SPI Init 字段逐个解析

SpiHandle.Instance = SPI5; // ① 选硬件 SpiHandle.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; // ② 时钟分频 SpiHandle.Init.Direction = SPI_DIRECTION_2LINES; // ③ 全双工 SpiHandle.Init.CLKPhase = SPI_PHASE_2EDGE; // ④ CPHA=1 SpiHandle.Init.CLKPolarity = SPI_POLARITY_HIGH; // ⑤ CPOL=1 SpiHandle.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; // ⑥ CRC 关 SpiHandle.Init.CRCPolynomial = 7; // ⑦ 用不到 SpiHandle.Init.DataSize = SPI_DATASIZE_8BIT; // ⑧ 8 位 SpiHandle.Init.FirstBit = SPI_FIRSTBIT_MSB; // ⑨ 高位先行 SpiHandle.Init.NSS = SPI_NSS_SOFT; // ⑩ 软件控制 CS SpiHandle.Init.TIMode = SPI_TIMODE_DISABLE; // ⑪ TI 模式关 SpiHandle.Init.Mode = SPI_MODE_MASTER; // ⑫ STM32 做主 ​ HAL_SPI_Init(&SpiHandle); __HAL_SPI_ENABLE(&SpiHandle); // ← SPI 需要手动使能

① Instance — 用哪个 SPI 硬件

SPI5。F429 有 6 个 SPI(SPI1~SPI6),W25Q256 挂在 SPI5(PF7=SCK, PF8=MISO, PF9=MOSI, PF6=CS)。

② BaudRatePrescaler — SPI 时钟 = APB 时钟 / 分频数

_2(二分频)。SPI5 在 APB2(90MHz),90/2 =45MHz

分频时钟场景
_245MHzFlash 高速读写
_256352KHz慢速外设

UART 叫 BaudRate,I2C 叫 ClockSpeed,SPI 叫 BaudRatePrescaler——本质都是"通信速率"。

③ Direction — 通信方向

SPI_DIRECTION_2LINES(全双工):MOSI + MISO 两根数据线,发的同时也在收。I2C 也是 2 根线但半双工(同一时刻只能发或收),SPI 全双工是真正的收发并行。

④⑤ CLKPolarity + CLKPhase — SPI 模式

CLKPolarity = HIGH → CPOL=1(空闲时 SCK 高电平) CLKPhase = 2EDGE → CPHA=1(第二个边沿采样) → 模式 3
模式CPOLCPHA空闲 SCK采样边沿
模式 000第 1 个(上升)
模式 101第 2 个(下降)
模式 210第 1 个(下降)
模式 311第 2 个(上升)

W25Q256 支持模式 0 和 3,野火选了模式 3。

⑥⑦ CRCCalculation + CRCPolynomial

CRC 校验关掉,多项式值无效。

⑧ DataSize — 数据位宽

8BIT。W25Q256 指令和数据都是 8 位。

⑨ FirstBit — 先发高位还是低位

MSB(高位先行),SPI 协议标准做法。

⑩ NSS — 片选引脚控制方式

NSS_SOFT(软件控制)。手动调用CS_LOW()拉低选芯片,CS_HIGH()拉高释放。选硬件模式的话硬件自动管,但不灵活。

⑪ TIMode — TI 专用模式

关掉,德州仪器的特殊 SPI 变体,用不到。

⑫ Mode — 主设备还是从设备

SPI_MODE_MASTER。STM32 控制时钟 + CS,W25Q256 是从设备,等着被指挥。

为什么HAL_SPI_Init后还要__HAL_SPI_ENABLE

I2C 的HAL_I2C_Init内部自动使能,SPI 需要手动补一刀才能跑起来。

跟 UART / I2C Init 对比

字段UARTI2CSPI
速率BaudRateClockSpeedBaudRatePrescaler
数据位宽WordLengthAddressingModeDataSize
模式Mode=TX_RXMode=MASTER
特有StopBits/ParityDualAddress/ClockStretchCPOL/CPHA/NSS

12 个字段,6 个跟 I2C/UART 同理,4 个 SPI 特有(CPOL/CPHA/NSS/FirstBit),2 个关了不用。

二、野火源码流程分析

1: 分清 HAL 函数和野火封装函数

HAL 库自带的函数(ST 提供):

HAL 函数作用
HAL_SPI_Init()SPI 初始化
__HAL_SPI_ENABLE()使能 SPI 硬件
__HAL_SPI_GET_FLAG()读 SPI 状态标志(TXE、RXNE)
WRITE_REG()CMSIS 宏,直接写硬件寄存器
READ_REG()CMSIS 宏,直接读硬件寄存器

野火自己写的封装函数(bsp_spi_flash.c 里):

函数做了什么
底层SPI_FLASH_SendByte()逐字节收发(用WRITE_REG/READ_REG操作 DR 寄存器)
底层SPI_FLASH_CS_LOW/HIGH()宏,手动拉低/拉高 CS 引脚
中层SPI_FLASH_WriteEnable()发写使能命令:CS 低 → 发 0x06 → CS 高
中层SPI_FLASH_WaitForWriteEnd()死等 WIP 忙标志清零
中层SPI_FLASH_ReadID()发 0x9F → 收 3 字节 ID
高层SPI_FLASH_SectorErase()擦除:写使能 → 发 0x20+地址 → 等完成
高层SPI_FLASH_PageWrite()页写:CS 低 → 0x02 → 地址 → 数据 → CS 高
高层SPI_FLASH_BufferWrite()跨页自动拆分
高层SPI_FLASH_BufferRead()CS 低 → 0x03 → 地址 → 收数据 → CS 高

2: 跟 I2C 的关键区别

I2C AT24C02: SPI W25Q256: ─────────── ─────────── HAL_I2C_Mem_Write() ❌ HAL 没有 SPI Flash 专用的读写函数 HAL_I2C_Mem_Read() 需要自己用 SendByte 搭: CS 低 → 发命令 → 发地址 → 收发数据 → CS 高

I2C 的 HAL 提供了Mem_Write/Mem_Read一键读写 EEPROM。SPI 的 HAL没有Flash 专用函数——因为不同厂家 Flash 的命令集不一样,ST 没法统一封装。野火从底层 SendByte 开始自己搭了一套。

3: 最核心的底层函数:SPI_FLASH_SendByte

uint8_t SPI_FLASH_SendByte(uint8_t byte) { while (__HAL_SPI_GET_FLAG(&SpiHandle, SPI_FLAG_TXE) == RESET); // ① 等 TXE:发送缓冲区空 WRITE_REG(SpiHandle.Instance->DR, byte); // ② 写进 DR → 硬件开始发 ​ while (__HAL_SPI_GET_FLAG(&SpiHandle, SPI_FLAG_RXNE) == RESET); // ③ 等 RXNE:接收缓冲区有数据 return READ_REG(SpiHandle.Instance->DR); // ④ 读走收到的字节 }

SPI 全双工的本质:发一个字节的同时必然收到一个字节。即使不想收(比如发命令),也得读走 DR,否则下一个字节堵住。

发 0x9F(读 ID 命令) │ ▼ SCK: ┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐ MOSI: 1 0 0 1 1 1 1 1 ← STM32 发出 0x9F MISO: x x x x x x x x ← 同时收到垃圾数据(不管) ↑ 每个时钟周期,一发一收同时发生

4: 所有操作的固定套路(四步模式)

① CS 拉低 — 选中芯片,"我要跟你说话了" ② 发命令 — 告诉芯片"我要读/写/擦/读 ID" ③ 发/收数据 — 具体数据在这一步 ④ CS 拉高 — 释放芯片,"我说完了"

5: 读 ID

CS_LOW(); SendByte(W25X_JedecDeviceID); // ② 命令 0x9F Temp0 = SendByte(Dummy_Byte); // ③ 收 3 字节 Temp1 = SendByte(Dummy_Byte); Temp2 = SendByte(Dummy_Byte); CS_HIGH(); // ④ // ID = (Temp0<<16) | (Temp1<<8) | Temp2

收 ID 时SendByte(Dummy_Byte)——发 0xFF 无关数据,目的是产生时钟,让芯片把 ID 推到 MISO 上给我们收。

6: 擦除扇区

CS_LOW(); SendByte(W25X_SectorErase); // ② 命令 0x20 SendByte((addr >> 24) & 0xFF); // ③ 4 字节地址 SendByte((addr >> 16) & 0xFF); SendByte((addr >> 8) & 0xFF); SendByte(addr & 0xFF); CS_HIGH(); // ④ SPI_FLASH_WaitForWriteEnd(); // 等芯片擦完

写之前必须先擦!W25Q256 写之前必须把对应扇区擦成0xFF(全 1)。这是 NOR Flash 的物理特性。

7: 写页

CS_LOW(); SendByte(W25X_PageProgram); // ② 命令 0x02 SendByte((addr >> 24) & 0xFF); // ③ 4 字节地址 // ... while (NumByteToWrite--) { SendByte(*pBuffer++); // 逐个字节发数据 } CS_HIGH(); // ④ SPI_FLASH_WaitForWriteEnd(); // 等写完

一页最多 256 字节,写多了会绕回页开头覆盖。

8: 读数据

CS_LOW(); SendByte(W25X_ReadData); // ② 命令 0x03 // ③ 4 字节地址 while (NumByteToRead--) { *pBuffer++ = SendByte(Dummy_Byte); // 逐个字节收 } CS_HIGH();

读没有跨页限制,可以一口气读到底。

9: 跨页写入逻辑

W25Q256 一页 256 字节。如果起始地址不对齐页边界,SPI_FLASH_BufferWrite拆成多次PageWrite

想从地址 0x00F0(离页边界差 16 字节)写 300 字节: 第 1 页: 0x00F0, 16 字节 → 凑满当前页 第 2 页: 0x0100, 256 字节 → 整页 第 3 页: 0x0200, 28 字节 → 剩余

跟 AT24C02 的跨页逻辑一样,只是页大小从 8 变成了 256。

10: WaitForWriteEnd — 等芯片忙完

void SPI_FLASH_WaitForWriteEnd(void) { uint8_t status; CS_LOW(); SendByte(W25X_ReadStatusReg); // 发 0x05:读状态寄存器 do { status = SendByte(Dummy_Byte); // 读状态 } while (status & WIP_Flag); // 忙标志=1 继续等 CS_HIGH(); }

擦除和写入不是瞬间完成的——擦 4KB 扇区要几百毫秒。芯片干活期间忙标志置 1,写完清零。

11: 跟 I2C AT24C02 对比

AT24C02(I2C)W25Q256(SPI)
选中芯片发设备地址 0xA0CS 引脚拉低
发命令不需要先发 1 字节命令
地址字节1 字节(0~255)4 字节(0~32MB)
写前擦除不需要必须擦
完成等待HAL_Delay(5)WaitForWriteEnd(轮询忙标志)
页大小8 字节256 字节
底层收发HAL_I2C_Mem_Write/Read自己写SendByte(逐字节轮询)

12: W25Q256 芯片参数

参数
容量256Mbit =32MB
制造商Winbond(华邦)
JEDEC ID0xEF4019(EF=Winbond, 40=SPI Flash, 19=型号)
页大小256 字节
扇区大小4KB(4096 字节)
擦除单位扇区(4KB)/ 块(64KB)/ 全片
写入限制必须先擦再写(Flash 特性),擦后全为0xFF
写入前需发写使能命令0x06
写入后需等 WIP 忙标志清零(读状态寄存器0x05
地址模式上电默认 3 字节(24 位),可切 4 字节(32 位)
SPI 模式支持模式 0 和模式 3
最大 SPI 时钟133MHz
接口SPI5,PF7(SCK) / PF8(MISO) / PF9(MOSI) / PF6(CS)

JEDEC ID 读回来是 3 字节(24 位),用uint32_t存是因为 C 语言没有uint24_t类型——高位自动补零。地址模式下所有地址操作(读/写/擦)都跟 3 字节地址,切 4 字节模式后跟 4 字节。

三、写我们自己的 SPI_Flash 驱动(轮询版)

改动清单

文件改了啥
SPI_Flash.h引脚宏 + W25Q256 命令 + 函数声明
SPI_Flash.cInit → SendByte(HAL版) → WriteEnable → WaitBusy → ReadID → EraseSector → WritePage → BufferWrite(跨页) → BufferRead
main.c读 ID 验证 → 擦 → 写 16 字节 → 读回 → 对比
hal_conf.h启用HAL_SPI_MODULE_ENABLED

关键改动

野火原版我们的简化版
MSP 回调里配 GPIOInit 里直接写
SendByteWRITE_REG/READ_REGHAL_SPI_TransmitReceive一步搞定
CS 用BSRR寄存器HAL_GPIO_WritePin
SPI_FLASH_Mode_Init()(4 字节模式)去掉(地址 0 不需要)
一堆调试宏砍掉

驱动架构

main ├─ SPI_Flash_ReadID() ───── CS低→发0x9F→收3字节→CS高 ├─ SPI_Flash_EraseSector() ─ 写使能→发0x20+地址→等忙完 ├─ SPI_Flash_BufferWrite() ─ 自动跨页拆分→循环调WritePage └─ SPI_Flash_BufferRead() ── CS低→发0x03+地址→收N字节→CS高 │ ├─ WritePage ────────── 写使能→CS低→0x02→地址→数据→CS高→等忙完 ├─ WriteEnable ──────── CS低→发0x06→CS高 └─ WaitBusy ─────────── CS低→发0x05→死等WIP清零→CS高 │ └─ SendByte ────── HAL_SPI_TransmitReceive(发1收1)
W25Q256 = 32MB 1 页 = 256 字节 → 共 131,072 页 1 扇区 = 4KB = 16 页 → 共 8,192 扇区 1 块 = 64KB = 16 扇区 → 共 512 块

擦除只能按扇区(4KB)块(64KB)、或全片来,不能只擦一页或几个字节。这是 Flash 跟 EEPROM(AT24C02 可以按字节写不用擦)最大的区别。

四、SPI 模式 0 vs 模式 3

切模式只改 Init 里两行:

/* ── 模式 0:CPOL=0, CPHA=0 ── */ hspi_flash.Init.CLKPolarity = SPI_POLARITY_LOW; // 空闲低 hspi_flash.Init.CLKPhase = SPI_PHASE_1EDGE; // 第一个边沿采样 /* ── 模式 3:CPOL=1, CPHA=1 ── */ //hspi_flash.Init.CLKPolarity = SPI_POLARITY_HIGH; // 空闲高 //hspi_flash.Init.CLKPhase = SPI_PHASE_2EDGE; // 第二个边沿采样

时序对比:

模式3:SCK 空闲高  ̄ ̄\___/ ̄ ̄\___/ ̄ ̄ 第二个边沿锁存 模式0:SCK 空闲低 ___/ ̄ ̄\___/ ̄ ̄\___ 第一个边沿锁存

W25Q256 两种都支持,实测模式 0 和模式 3 均能正常读 ID、擦写、回读,验证通过。

五、SPI 中断读写

1: 策略:数据走中断

Flash 操作分两段——命令+地址就 4 字节,轮询处理;批量数据才走 IT:

/* ── IT 读 ── */ void SPI_Flash_BufferRead_IT(uint8_t *pData, uint32_t addr, uint32_t len) { spi_rx_done = 0; FLASH_CS_LOW(); SPI_Flash_SendByte(CMD_READ_DATA); // ① 命令(轮询) SPI_Flash_SendByte((addr >> 16) & 0xFF); // ② 地址(轮询) SPI_Flash_SendByte((addr >> 8) & 0xFF); SPI_Flash_SendByte(addr & 0xFF); // ③ 填充哑字节,启动 IT 传输 for (uint32_t i = 0; i < len; i++) spi_dummy[i] = DUMMY_BYTE; HAL_SPI_TransmitReceive_IT(&hspi_flash, spi_dummy, pData, len); }
/* ── IT 写(单页)── */ void SPI_Flash_BufferWrite_IT(uint8_t *pData, uint32_t addr, uint32_t len) { spi_tx_done = 0; SPI_Flash_WriteEnable(); // ① 写使能(轮询) FLASH_CS_LOW(); SPI_Flash_SendByte(CMD_PAGE_PROGRAM); // ② 命令(轮询) SPI_Flash_SendByte((addr >> 16) & 0xFF); // ③ 地址(轮询) SPI_Flash_SendByte((addr >> 8) & 0xFF); SPI_Flash_SendByte(addr & 0xFF); // ④ 数据段走中断 HAL_SPI_TransmitReceive_IT(&hspi_flash, pData, spi_dummy, len); }
/* ── 收发完成回调(读和写都走这里)── */ void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI5) { FLASH_CS_HIGH(); spi_tx_done = 1; spi_rx_done = 1; } }

4: HAL_SPI_TransmitReceive_IT 形参

HAL_StatusTypeDef HAL_SPI_TransmitReceive_IT( SPI_HandleTypeDef *hspi, // ① SPI 句柄 uint8_t *pTxData, // ② 发送数据来源(发完地址自增,从这读下一字节) uint8_t *pRxData, // ③ 接收数据去处(每收一字节往这存,地址自增) uint16_t Size // ④ 收发字节数(发完 Size 个自动回调 TxRxCpltCallback) );
形参IT 读时传入IT 写时传入
pTxDataspi_dummy(全 0xFF,只为产生时钟)pData(要写进 Flash 的真实数据)
pRxDatapData(收到的就是 Flash 读出的数据)spi_dummy(收到的垃圾,覆盖了无所谓)
Sizelenlen

调用后立刻返回,SPI 硬件在后台逐字节收发,CPU 该干嘛干嘛。Size 发完后触发TxRxCpltCallback

2: IT 写完整跳转链(以 16 字节为例)

SPI_Flash_BufferWrite_IT 调用 │ ▼ HAL_SPI_TransmitReceive_IT 启动,装第一个字节进 DR │ ▼ CPU 返回 main,卡在 while (!spi_tx_done) │ ▼ 硬件 SPI 发出第一个字节 → TXE 中断 → SPI5_IRQHandler │ └─ HAL_SPI_IRQHandler:从 buf 取下一个字节装进 DR,收到的垃圾存 spi_dummy │ └─ 没发完,不调回调,直接返回 │ ▼ 硬件 SPI 发出第二个字节 → TXE 中断 → 同上循环... │ ▼ ...发完最后一字节 → TXE 中断 → HAL_SPI_IRQHandler │ └─ 计数归零!调 HAL_SPI_TxRxCpltCallback │ └─ FLASH_CS_HIGH() + spi_tx_done = 1 │ ▼ main 看到 spi_tx_done = 1 → 退出 while → 继续往下

CPU 每字节进一次 TXE 中断,但 HAL 内部自动处理——取下一字节、装 DR、判断是否发完。你只在最后一次收到回调。

3: 跟 I2C 对比

SPI ITI2C IT
ISR 数量1 个(SPI5_IRQHandler2 个(EV+ER
回调TxRxCpltCallback(收发同一个)MemTxCpltCallback+MemRxCpltCallback
命名坑无(就一个回调)Master vs Mem 两个家族
命令+地址轮询(省事)全走 IT

六、SPI DMA 读写

1: DMA 映射

方向DMAStreamChannel
SPI5_TXDMA2Stream 4Channel 2
SPI5_RXDMA2Stream 3Channel 2

2: 策略:命令+地址轮询,数据走 DMA

IT 和 DMA 共用同一个回调HAL_SPI_TxRxCpltCallback

3: DMA 写完整跳转链(以 16 字节为例)

SPI_Flash_BufferWrite_DMA 调用 │ ① 写使能 + 命令 + 地址(轮询 4 字节) │ ② HAL_SPI_TransmitReceive_DMA 启动 │ DMA 被配好:源=你的buf,目标=SPI5 DR,次数=16 │ 立刻返回 ← CPU 走了 │ ▼ CPU 返回 main,卡在 while (!spi_tx_done) │ ▼ DMA2_Stream4 自己干活:从 buf 取字节 → 装进 SPI5 DR → 下一个 → ... │ CPU 完全不知道,也没有中断 │ ▼ DMA 搬完第 16 个字节 → DMA 计数器归零 → TC 信号 │ ├─ DMA2_Stream4_IRQHandler ← ① 第一波:DMA 中断 │ └─ HAL_DMA_IRQHandler ← 清 DMA 标志 │ └─ 同时硬件 TC 信号触发 SPI 传输完成 │ ▼ SPI5_IRQHandler ← ② 第二波:SPI 中断 └─ HAL_SPI_IRQHandler ← HAL 知道传输完成 └─ HAL_SPI_TxRxCpltCallback ← ③ 回调(IT 和 DMA 共用同一个) └─ FLASH_CS_HIGH() + spi_tx_done = 1 │ ▼ main 看到 spi_tx_done = 1 → 退出 while → 继续往下

4: 跟 IT 版对比

IT 写: DMA 写: 每字节触发 1 次 TXE 中断 DMA 全包,16 字节 0 次数据中断 16 字节 = 16 次 IRQ DMA 只触发 1 组收尾中断(DMA_TC → SPI_TC) CPU 每字节进一次 ISR CPU 全程旁观,收尾时被通知一次

5: IT/DMA 跨页写入

轮询版的跨页逻辑搬到 IT/DMA,每页之间等done标志 +WaitBusy。300 字节从地址 0x00F0 写,自动拆为 16 + 256 + 28 三页,实测通过。

6: 三种模式对比

轮询 (Polling)中断 (IT)DMA
底层 HAL 函数HAL_SPI_TransmitReceiveHAL_SPI_TransmitReceive_ITHAL_SPI_TransmitReceive_DMA
搬运工CPU 逐字节CPU 每字节进一次 ISRDMA 全包,CPU 旁观
命令+地址轮询轮询轮询
数据段中断次数0(阻塞等)N 次 TXE(每字节 1 次)0 次(DMA 收尾 2 次中断)
回调TxRxCpltCallback同一个TxRxCpltCallback
ISR 数量01(SPI5_IRQHandler3(DMA2_Stream4+DMA2_Stream3+SPI5
CPU 在干嘛死等每字节中断存一次全程旁观
适合场景小数据、调试中等数据大数据、跨页

SPI 比 I2C 简单的原因:只有一个回调、没有 Master vs Mem 家族之分、不用手搓 IDLE——HAL 数到 Size 归零自动回调。