
简介面向STM32H743平台嵌入式开发者这份源码例程实现了W25QXX系列SPI Flash的中断方式读写解决大容量非易失性存储与高速数据传输问题适合初学者入门也适合项目中原型验证。例程覆盖SPI接口初始化、HAL库外设配置、NVIC中断管理以及页编程、扇区擦除、芯片擦除、状态寄存器忙标志检查等关键操作并给出硬件引脚、时钟速率、数据格式等适配示例中断服务程序中还包含错误处理与恢复流程可帮助理解在高并发数据流中保持系统稳定。压缩包共1120个文件以573个C源码和281个头文件为主体另含汇编启动文件、IAR与Keil工程配置文件、链接脚本、文本说明、脚本及预编译库文件整体约17.76MB目录层级清楚便于按模块查阅和移植。目前已有196人学习该资源适合在STM32H743平台上快速验证W25QXX驱动并加深对中断式SPI通信和Flash存储管理的理解。1. 中断方式读写W25QXX到底解决什么问题一块 W25Q64 虽然只有 8MB但在很多采集设备里是唯一的非易失存储。用 STM32H743 驱动它时最常见的选择是轮询 SPI调用一次 HAL_SPI_TransmitCPU 就在 while 循环里等传输完成一次读 4KB 要等接近 1ms。如果系统里还有 AD 采样、PID 运算这 1ms 就卡死了整条控制链路。中断方式的核心区别在于发起传输后函数立刻返回数据的移位、收发都由 SPI 外设硬件完成完成后再进入中断回调继续处理。对于 W25QXX 这种需要“先发命令、再连续读或写”的串行 Flash中断方式能释放 CPU但代价是读写流程要拆成多个异步步骤。这篇文章就围绕 STM32H743 W25Q64讲清楚一个可复现的中断方式读写例程该怎么设计以及状态机、回调、分页和 Cache 这些坑在哪里。2. STM32H743的SPI外设与W25QXX硬件连接2.1 引脚连接用硬件 NSS 还是软件片选W25Q64 这类 SPI NOR Flash 的片选信号 CS 不只是选中设备它还定义了一条命令的起始和结束。W25Q 的所有操作都以 CS 下降沿作为开始以 CS 上升沿作为结束。也就是说一次读操作中 CS 必须全程保持低电平不能在每个字节传输之间被拉高再拉低。我一般不用 STM32H743 的硬件 NSS 管脚做片选而是把普通 GPIO 配置成推挽输出做软件片选。原因有两个第一SPI 外设的硬件 NSS 在一次传输结束后可能依据配置自动释放 CS但 HAL 库每次传输调用只管理一次 CS 状态命令、地址、数据被拆成多次传输时硬件 NSS 很难保证中间不翻转第二软件片选可以精确控制 CS 拉低和拉高的时机非常适合中断方式下分阶段执行命令。推荐连接方式如下以 SPI1 为例信号STM32H743 引脚W25Q64 引脚备注SCKPA5CLKSPI 时钟波形干净时能跑到 80MHz 以上MISOPA6DO主设备输入从设备输出MOSIPA7DI主设备输出从设备输入CSPA4CS#软件片选推挽输出默认高电平VCC3.3VVCC工作电流约 20mALDO 直接供电即可GNDGNDGND必须共地注意H743 的 GPIO 耐压是 3.3VW25Q系列也是 3.3V 供电不需要电平转换。如果板子上换成 1.8V 版 Flash就要单独加电平转换芯片不能直接接。对于大多数开发板PA5/PA6/PA7 正好是 SPI1 的默认引脚这样连接也方便在 STM32CubeMX 里直接映射。2.2 CubeMX 的 SPI 配置Mode 3 还是 Mode 0W25QXX 数据手册支持 SPI Mode 0 和 Mode 3。两者在时钟极性和相位上差一个边沿实际项目里用 Mode 0CPOLLowCPHA1Edge最常见。在 CubeMX 中先把 SPI1 的 Mode 设为 Full-Duplex Master再按下面参数配置参数项推荐值说明ModeFull-Duplex Master读操作需要同时收和发Hardware NSS SignalDisable用软件 GPIO 片选Data Size8 BitsW25QXX 按字节寻址First BitMSB FirstSPI NOR 命令都是 MSB 先发Prescaler按 APB 时钟计算让 SCK 落在 50~80MHz 区间CPOLLow空闲时钟为低CPHA1 Edge第一个边沿采样数据TX/RX DMANot Used中断方式下不需要开启Prescaler 值得单独说。H743 的 APB2 频率通常配置在 120MHz 左右SPI1 挂在 APB2 上所以每次换系统时钟后都要重新确认实际 SCLK。我一般把分频系数设成让 SCK 落在 50MHz~80MHz而不顶到最高 100MHz。原因很简单杜邦线和洞洞板在 100MHz 下会有明显过冲读出的数据偶发错位。先按 50MHz 把功能跑通再根据逻辑分析仪波形逐步提高分频。用 CubeMX 生成代码后CS 引脚会被初始化为输出高电平。这里建议把 CS 的 GPIO 速度设置成 Medium而 SPI 的三个引脚要设置为 Very High Speed。如果三个 SPI 引脚用了高速CS 却用了低速CS 的建立时间会明显比 SCK 慢在中断方式下数据阶段第二次传输开始时 CS 可能还没稳定到低电平W25Q 会误判命令结束。这是比较隐蔽的信号完整性问题。提示用软件片选时务必把 SPI 外设的 NSS 引脚功能完全旁路否则 CubeMX 可能在 PA4 上同时生成两种复用定义编译后引脚冲突极难排查。3. 中断方式读写W25QXX状态机与传输组织3.1 一个命令被拆成几次 SPI 传输先梳理主要命令。W25Q64 的读命令是 0x03页编程命令是 0x02读状态寄存器命令是 0x05。无论读还是写一次完整操作在物理层都由多个阶段组成阶段CS内容SPI 传输次数命令地址拉低0x03 / 0x02 / 0x05 3 字节地址1 次数据读保持低连续读取 N 字节1 次数据写保持低发送最多 256 字节1 次结束拉高命令边界-在中断方式下每次HAL_SPI_TransmitReceive_IT调用就是一次完整的物理阶段。如果试图把“命令地址数据”全塞进一个发送缓冲区读操作时发送和接收长度必须相等会造成内存浪费。更好的做法是分两个状态处理地址阶段和数据阶段每个阶段只管理自己的缓冲区。状态机在这里的作用就是记住当前进行到哪个阶段以及下一次回调应该做什么。3.2 用一个请求结构体存放异步参数中断方式下函数走到一半就返回了后续的数据要放到回调函数里继续。因此我通常定义一个请求结构体把一次读写操作所需的参数全部快照下来避免在多个全局变量之间来回切换typedef enum { STAGE_CMD_ADDR 0, STAGE_DATA, STAGE_DONE, STAGE_ERROR } FlashStage_t; typedef struct { SPI_HandleTypeDef *hspi; GPIO_TypeDef *cs_port; uint16_t cs_pin; FlashStage_t stage; uint8_t tx[4]; // 命令阶段的发送缓冲区 uint8_t junk[4]; // 命令阶段的接收缓冲区丢弃 uint8_t *data; // 数据阶段的数据缓冲区 uint16_t data_len; // 数据阶段长度 volatile uint8_t busy; volatile uint8_t result; } FlashRequest_t;这个结构体的核心是stage。读操作启动后stage先设为STAGE_CMD_ADDR命令阶段传输完成后在回调里推进到STAGE_DATA最终进入STAGE_DONE。data指针直接指向调用方的缓冲区读操作不需要额外的接收副本写操作也直接使用调用方传入的数组。busy标志用于外部等待传输进行中busy1回调里清零外部可以用while (req.busy);或者 RTOS 信号量等待完成。3.3 为什么选 TransmitReceive_IT 而不是 Transmit_IT读数据的时候主机必须同时发送“空字节”来产生时钟。如果只用HAL_SPI_Transmit_IT发命令再用HAL_SPI_Receive_IT收数据会带来两个问题。第一两次传输之间 CS 必须靠手动控制一旦回调晚到CS 低电平时长超过 W25QXX 规格内部地址指针可能错乱。第二单独HAL_SPI_Receive_IT时 MOSI 线保持旧值虽然 W25QXX 收到足够空字节后也会输出数据但时序上不如显式发送 0xFF 干净。HAL_SPI_TransmitReceive_IT要求发送和接收缓冲区长度相同这强制调用方同时管理两个缓冲区但也让时序变得规整。下面是最简的读操作启动函数uint8_t flash_read_start(FlashRequest_t *req, uint32_t addr, uint8_t *buf, uint16_t len) { if (req-busy) return 1; req-stage STAGE_CMD_ADDR; req-data buf; req-data_len len; // 填充命令阶段0x03 读数据命令 24 位地址 req-tx[0] 0x03; req-tx[1] (addr 16) 0xFF; req-tx[2] (addr 8) 0xFF; req-tx[3] addr 0xFF; req-busy 1; req-result 0; HAL_GPIO_WritePin(req-cs_port, req-cs_pin, GPIO_PIN_RESET); // 命令阶段传 4 字节接收数据丢到 junk HAL_SPI_TransmitReceive_IT(req-hspi, req-tx, req-junk, 4); return 0; }这个函数末尾的HAL_SPI_TransmitReceive_IT发出命令和地址后立即返回busy保持为 1。调用方可以继续做其他事也可以在 RTOS 里让出 CPU。命令阶段完成时HAL 库会调用HAL_SPI_TxRxCpltCallback我们需要在这个回调里判断stage并推进到数据阶段。注意HAL_SPI_TransmitReceive_IT的长度参数是 uint16_t而 W25QXX 单次最多支持 65535 字节连续读读更大范围只能拆成多个请求或者换 DMA。提示H743 的 SPI 中断回调运行在中断上下文不要在回调里做硬延时、打印或者信号量等待只需推进状态机并启动下一段传输。4. W25QXX 中断方式读写例程完整落地4.1 初始化片选、SPI 与读 JEDEC ID初始化代码先打开 SPI1 和 GPIOA 时钟配置 PA5/PA6/PA7 为 SPI1 复用功能PA4 为推挽输出。下面是一段可以直接放到裸机工程里的初始化static SPI_HandleTypeDef hspi1; void flash_spi_init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); // SPI1 引脚PA5 SCK, PA6 MISO, PA7 MOSI 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_VERY_HIGH; gpio.Alternate GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOA, gpio); // 软件片选PA4 gpio.Pin GPIO_PIN_4; gpio.Mode GPIO_MODE_OUTPUT_PP; gpio.Speed GPIO_SPEED_FREQ_MEDIUM; HAL_GPIO_Init(GPIOA, gpio); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // SPI 参数Mode 0, 8bit, MSB hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_2; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial 7; if (HAL_SPI_Init(hspi1) ! HAL_OK) { Error_Handler(); } }BaudRatePrescaler 这里的2分频在 APB2 为 120MHz 时 SCK 是 60MHz对 W25Q64 来说是安全的高速值。如果要跑更高频率需要检查 Flash 手册中的最大时钟和工作电压。初始化完成后可以读一次 JEDEC ID 验证接线使用 0x9F 命令回读 3 字节。通过中断方式读 ID也是验证通信链路最直接的办法。4.2 读操作的中断状态机读操作分两个阶段命令阶段和读数据阶段。命令阶段启动函数在前面已经给出下面重点写回调里的状态推进。static FlashRequest_t flash; void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi ! flash.hspi) return; switch (flash.stage) { case STAGE_CMD_ADDR: // 命令和地址发完进入数据阶段 flash.stage STAGE_DATA; memset(flash.tx, 0xFF, flash.data_len); HAL_SPI_TransmitReceive_IT(hspi, flash.tx, flash.data, flash.data_len); break; case STAGE_DATA: // 数据收完拉高 CS标记完成 flash.stage STAGE_DONE; flash.busy 0; flash.result 0; HAL_GPIO_WritePin(flash.cs_port, flash.cs_pin, GPIO_PIN_SET); break; default: break; } }STAGE_CMD_ADDR分支中memset把发送缓冲区填成 0xFF因为主控在整个读数据阶段都必须产生时钟。HAL_SPI_TransmitReceive_IT同时向 Flash 发送 0xFF 并从 MISO 采样数据数据会原封不动落入flash.data指向的缓冲区。数据阶段结束后CS 拉高一次读操作完成。外部调用方式uint8_t buf[64]; flash_read_start(flash, 0x00, buf, sizeof(buf)); while (flash.busy); // 此时 buf 里就是 W25Q64 地址 0x00 处读出的 64 字节注意while (flash.busy)在裸机演示里可以接受。如果使用 FreeRTOS应该改成在完成回调里xSemaphoreGiveFromISR任务侧再xSemaphoreTake避免把 CPU 空耗在轮询上。4.3 写操作写使能、页编程和状态检查写 W25QXX 比读复杂因为必须先写使能写完页再读状态寄存器确认内部编程结束。中断方式下我一般用一个独立的状态枚举来组织typedef enum { SM_IDLE 0, SM_WRITE_ENABLE_CMD, SM_PAGE_PROG_CMD, SM_PAGE_PROG_DATA, SM_READ_STATUS, SM_DONE, SM_ERROR } FlashState_t; static volatile FlashState_t flash_state; static uint8_t flash_tx[260]; static uint8_t flash_rx[260]; static uint32_t flash_op_addr; static uint16_t flash_op_len;写使能命令 0x06 只有 1 个字节CS 拉低后发送结束拉高。执行完写使能后直接开始页编程命令阶段void flash_write_page_start(uint32_t addr, uint8_t *data, uint16_t len) { if (flash_state ! SM_IDLE) return; flash_op_addr addr; flash_op_len len; // 第一步发写使能 flash_state SM_WRITE_ENABLE_CMD; HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); flash_tx[0] 0x06; HAL_SPI_TransmitReceive_IT(hspi1, flash_tx, flash_rx, 1); }写使能完成后的回调推进是关键。命令完成回调需要区分当前状态再启动下一步传输void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi ! hspi1) return; switch (flash_state) { case SM_WRITE_ENABLE_CMD: // 写使能结束CS 拉高 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); // 准备页编程命令0x02 3 字节地址 数据 flash_state SM_PAGE_PROG_CMD; flash_tx[0] 0x02; flash_tx[1] (flash_op_addr 16) 0xFF; flash_tx[2] (flash_op_addr 8) 0xFF; flash_tx[3] flash_op_addr 0xFF; memcpy(flash_tx[4], page_buf, flash_op_len); HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive_IT(hspi, flash_tx, flash_rx, 4 flash_op_len); break; case SM_PAGE_PROG_CMD: // 页编程命令和数据发完CS 拉高开始查忙 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); flash_state SM_READ_STATUS; flash_tx[0] 0x05; // 读状态寄存器 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive_IT(hspi, flash_tx, flash_rx, 2); break; case SM_READ_STATUS: // flash_rx[1] 是状态值WIP 位为 bit0 if (flash_rx[1] 0x01) { // 还在擦写继续读状态 HAL_SPI_TransmitReceive_IT(hspi, flash_tx, flash_rx, 2); } else { HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); flash_state SM_DONE; } break; default: break; } }这个回调中反复调用HAL_SPI_TransmitReceive_IT每次调用之间都是在中断里直接切换。SM_READ_STATUS分支在 WIP 位为 1 时继续发 0x05 读状态直到硬件完成内部编程。这个状态机的优点是写数据、等待、结束全异步完成主循环完全不会被阻塞。需要注意4 flash_op_len最大是 260 字节flash_tx[260]正好满足 256 数据字节加 4 字节约翰斯头。如果一次要写超过一页必须做分页。分页的边界计算是 W25QXX 写操作最容易踩的坑。一页是 256 字节页编程地址不能跨页比如地址0x01FF再写 1 字节就会回绕到页首导致数据错乱。我一般先算本页剩余空间uint16_t chunk len; uint16_t remain_in_page 256 - (addr 0xFF); if (chunk remain_in_page) { chunk remain_in_page; } // chunk 就是本次安全写入的长度得到chunk之后先写入一页等SM_DONE后把地址和长度减去已写部分继续下一页。这个分页判断在中断方式下尤其重要因为状态机不会自动拆分跨页数据必须在触发页编程前算好边界。5. 中断方式与 DMA 的边界以及调试技巧5.1 什么时候坚持中断什么时候换 DMA很多工程师做完中断例程后第一反应是“为什么不用 DMA”。实际上两者各有限制。在 H743 上DMA 适合一次传输大量固定长度数据比如 OTA 升级时一次读 8KB 到 RAMDMA 无需逐字节进中断。但 DMA 在 H743 上要面对 Cache 一致性问题DMA 写内存时不会通知 CPU 的 D-Cache读 DMA 数据前必须先执行SCB_InvalidateDCache_by_Addr否则拿到的是过期数据。中断方式则天然没有这个负担因为 SPI 中断读到的数据已经由 CPU 显式写入缓冲区缓存一致性由 CPU 保证。因此我一般这样取舍少于 512 字节的读写、命令时序复杂的操作用中断方式超过 1KB 的连续块读或写用 DMA。对于 W25QXX 的日志型应用单次写入往往只有几十字节中断方式是最平衡的方案。5.2 用逻辑分析仪验证时序中断方式调试时逻辑分析仪比调试器直观得多。把通道接在 SCK、MOSI、MISO、CS 上打开 SPI 协议解码重点看两个地方。第一CS 拉低期间命令和地址是否连续出现在 MOSI 上中间不能有超过几个时钟周期的空闲第二页编程完成后状态寄存器轮询命令 0x05 是否在 CS 低电平期间完成WIP 位是否在第二次轮询时变 0。如果发现 CS 拉低后 SCK 要经过明显延迟才开始多半是 GPIO 速度配置不一致或中断响应不及时。这时把 CS 速度改成与 SCK 相同再试。SPI 时序里 CS 建立时间通常需要大于几十纳秒H743 的 GPIO 翻转速度远高于此但若 CS 用慢速输出在高分频时可能成为瓶颈。5.3 三个容易被忽略的细节问题现象处理方式NVIC 中断优先级过低读大块数据时偶发超时回调被其他高频中断抢占将 SPI1 中断优先级设为比 SysTick 高通常选 1~3回调里重复启动传输HAL_SPI_TransmitReceive_IT返回HAL_BUSY状态机卡死启动传输前检查hspi-State或依赖busy标志防止重复调用变量未加 volatile编译器优化后while (flash.busy)死循环busy、result、state全部声明为volatile最后一个细节回调里拉高 CS 之后不建议立刻开始下一笔操作最好加一个__NOP()短延时或者在硬件上确保 CS 高电平保持至少一个 SPI 时钟周期。W25QXX 有些命令要求 CS 高电平时间以保证命令被锁存比如写使能 0x06 之后必须在 CS 上升沿锁存写使能状态拉高后马上再拉低发起页编程一般没问题但如果存在板级 RC 延迟就需要适度拉开间隔。中断方式下每个状态由独立回调推进天然给了 CS 翻转间隙这也是它比轮询更适合可靠驱动 W25QXX 的原因之一。本文还有配套的精品资源点击获取