简介:基于STM32H743单片机实现串行SPI Flash W25QXX读写操作的完整例程,面向嵌入式开发者与入门学习者,解决高速非易失性存储扩展需求。源码包共1120个文件,约17.76MB,以C语言源文件(573个)、头文件(281个)、汇编启动文件(71个)及工程配置文件(uvprojx/uvoptx/icf/sct等)为主,涵盖MDK与IAR多平台工程,便于直接导入编译。例程采用中断方式驱动SPI通信,包含W25QXX初始化、页编程、扇区擦除、芯片擦除及状态寄存器查询等关键函数,并展示如何借助HAL库与NVIC配置实现高效可靠的数据读写。已有196人学习下载。通过学习可掌握STM32H743的SPI接口配置、中断服务程序编写、Flash存储管理与异常处理思路,为后续嵌入式存储应用开发提供可复用的参考模板。
1. 中断方式读写W25QXX:先从“为什么要用中断”说起
W25Q128这类NOR Flash挂在STM32H743的SPI上,网上能找到的读写例程大多用阻塞轮询。凡是调过HAL_SPI_Transmit的人都知道,一个页编程指令发出去,后面要跟着等待WIP位清零,阻塞期间CPU什么也干不了。基于stm32h743的工程一旦上了RTOS或者要并行采集传感器数据,这种写法就会变成整机瓶颈。另一个极端是直接上DMA,但DMA要同时准备接收和发送两条通道,缓存对齐、传输长度边界都要小心处理,学习成本并不低。
对比之下,中断方式是“SPI外设在收发过程中触发中断,CPU在中断里准备下一个数据”的折中方案:不用管DMA描述符,又能把CPU从轮询等待里解放出来。需要说明的是,W25QXX的读写不是简单字节流,指令、地址、数据、等待忙构成一个完整的SPI事务,中断方式必须用状态机把事务拆成阶段,逐段推进。
这篇内容围绕“stm32h743 + SPI + W25QXX + 中断方式 + 读写例程”展开,适合已经用HAL库跑通轮询、想往异步驱动过渡的开发者。后面会给出CubeMX配置参数、可抄的状态机代码、排错步骤以及RTOS下的落地写法。
2. 搭建STM32H743 SPI工程:CubeMX时钟、引脚与W25QXX接线配置
2.1 先分清H743的SPI时钟源与最高可用波特率
H743的SPI外设时钟不只有APB一条路。在CubeMX的时钟树里,SPI1/2/3挂在APB2上,SPI4/5/6挂在APB1上,但它们的时钟源可以选择PLL2P、PLL3P等专用输出,最高可以超过100MHz。实际用W25Q128时,芯片手册标称最快支持133MHz,但板级信号质量和走线长度往往不允许跑那么高。常见做法是把SPI时钟配置成20MHz~45MHz之间,由APB2的90MHz分频到11.25MHz或22.5MHz,留出充足的信号裕量,也能降低逻辑分析仪抓波形的难度。
中断方式对时钟频率的敏感度比轮询更高,因为中断响应和寄存器操作需要时间,SPI时钟太高时数据可能来不及搬移。初次调试建议从低速起步,确认ID读取稳定后再往上提分频系数。
2.2 CubeMX里SPI参数表:CPOL/CPHA、数据长度与软件片选
打开STM32CubeMX,在SPI1的Mode配置里选“Full-Duplex Master”,下方SPI Mode下拉里选Interrupt,其余参数按这张表设置。
| 参数项 | 推荐值 | 理由 |
|---|---|---|
| SPI Mode | Full-Duplex Master | 读指令需要MISO同时回数据,全双工统一处理收发 |
| 数据尺寸 | 8 bits | W25QXX指令和地址都按字节组织 |
| 时钟极性 CPOL | Low | 配合CPHA=0构成SPI Mode 0,W25QXX手册时序图默认此模式 |
| 时钟相位 CPHA | 1 Edge | 第一个时钟沿采样数据 |
| NSS | Software | 片选由GPIO控制,避免硬件NSS在传输间隙自动拉高 |
| 波特率分频 | /8 | 按APB2约90MHz得到11.25MHz,稳定且利于抓波形 |
| CRC | Disabled | NOR Flash不需要CRC帧 |
| 首字节 | MSB First | SPI协议默认高位先发,W25QXX指令按MSB解释 |
NSS为什么必须选Software,这是中断方式读写W25QXX比较容易忽略的一点。开启硬件NSS后,SPI外设会在每帧之间自动拉高CS,而W25QXX要求在页编程命令或读数据命令期间CS全程保持低电平。硬件NSS的自动行为会把一次本该连续的事务拆成几段,指令被截断后芯片直接忽略后续字节。用普通GPIO软件拉片选,才能保证CS的释放时机完全由驱动代码控制。这也是“软件片选”和“硬件片选”在Flash场景下的本质区别。
2.3 W25QXX与H743接线:MISO上拉与电平匹配
W25QXX有VCC、GND、CS、CLK、DI、DO、WP、HOLD八个引脚。DO(MISO)和DI(MOSI)串联22Ω电阻有助于缓解振铃;CS、CLK、DI上默认加10kΩ上拉,DO不一定有上拉,但H743的SPI输入内部能配置上拉,在GPIO配置里打开PULLUP即可。硬件上WP和HOLD不能悬空,拉高或接到GPIO控制,否则写保护和暂停功能可能在传输中被意外触发。
实际接线使用最常用的SPI1引脚复用:PA5=SPI1_SCK、PA6=SPI1_MISO、PA7=SPI1_MOSI、PA4=CS(任意普通GPIO)。CubeMX里直接选择复用功能,CS引脚配成GPIO_Output即可。
2.4 初始化代码与关键宏定义
CubeMX生成的MX_SPI1_Init里,波特率、CPOL、CPHA都已经按参数表填好。为了避免在业务代码里到处写0x9F、0x02这种裸数字,在头文件里建立命令宏和片选宏:
#define W25QXX_CMD_READ_JEDEC_ID 0x9Fu #define W25QXX_CMD_WRITE_ENABLE 0x06u #define W25QXX_CMD_READ_STATUS 0x05u #define W25QXX_CMD_PAGE_PROGRAM 0x02u #define W25QXX_CMD_READ_DATA 0x03u #define W25QXX_CMD_SECTOR_ERASE 0x20u #define W25QXX_CS_LOW() HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_RESET) #define W25QXX_CS_HIGH() HAL_GPIO_WritePin(SPI1_CS_GPIO_Port, SPI1_CS_Pin, GPIO_PIN_SET)片选宏直接操作GPIO,不经过HAL的嵌套锁,能减少一点上下文开销。需要强调的是,CS的拉高不能放在中断回调的最末尾立即执行——如果此时SPI移位寄存器里还有最后一个字节没送完,提前拉高会截断数据。HAL库的发送完成回调发生时机是“最后一字节已移出”之后,在这个回调里拉高CS是安全的;不过一旦启用了发送FIFO,要留意FIFO空标志的时机,保守做法是把CS释放放在主循环或下一个状态检查里。
3. W25QXX指令集与中断状态机设计:一页编程怎么拆成多个阶段
3.1 不查手册也要记住的核心指令
W25QXX的价值不在高级接口,而在它的指令模型非常统一。下面是读写例程里最常碰到的六条指令,全部按JEDEC标准组织。
| 指令 | 地址 | 数据 | 说明 |
|---|---|---|---|
| 0x9F 读JEDEC ID | 无 | 输出3字节 | 如0xEF 0x40 0x18对应W25Q128,GD25Q128E等兼容颗粒同样适用 |
| 0x06 写使能 | 无 | 无 | 一次写周期前必须发一次,执行完自动失效 |
| 0x05 读状态寄存器 | 无 | 输出1字节 | bit0是WIP,1表示Flash正在执行擦除或页编程 |
| 0x02 页编程 | 3字节 | 1~256字节 | 数据不能跨页,跨页必须拆成两次 |
| 0x03 读数据 | 3字节 | 连续输出 | 读取不限长度,CS拉低期间可一直读 |
| 0x20 扇区擦除 | 3字节 | 无 | 擦除4KB,擦除后扇区内容全为0xFF |
把六条指令放在一起,能看出W25QXX的事务模型:页编程、擦除操作都遵循“写使能→发指令→等待WIP”的固定顺序。发送0x06后,CS必须拉高再拉低,写使能才真正生效;页编程结束后,CS拉高这个动作本身触发编程开始。此后主机要等待WIP位清零,期间不能再发其他写指令。
3.2 为什么轮询代码不能直接改成中断调用
轮询写法通常是这样的:
W25QXX_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 1, HAL_MAX_DELAY); HAL_SPI_Transmit(&hspi1, addr, 3, HAL_MAX_DELAY); HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY); W25QXX_CS_HIGH();改成中断方式后,如果照搬这段代码,把三个HAL_SPI_Transmit换成HAL_SPI_Transmit_IT,会得到一个错误结果:HAL_SPI_Transmit_IT只负责发起传输,发完最后一个字节后立刻返回,紧接着的CS拉低周期被下一个状态打断,后面的地址字节无法继续。SPI是同步总线,MOSI上的字节能否被从机正确接收,取决于主机是否持续提供时钟。因此,中断方式下每个“CS低→CS高”的完整帧,必须抽象为一个事务。帧内部的所有指令字节、地址字节、数据字节在代码层面连续注册,依靠状态机依次推进,而不是在回调里重新调用发送函数。
3.3 读ID中断流程的最小实现
先给出一个完整的“读JEDEC ID”中断流程。定义状态机和设备结构体:
typedef enum { W25_IDLE = 0, W25_READ_ID_CMD, W25_READ_ID_WAIT, W25_PAGE_PROGRAM_ENABLE, W25_PAGE_PROGRAM_CMD, W25_PAGE_PROGRAM_DATA, W25_WAIT_BUSY, W25_ERROR } w25_state_t; typedef struct { uint8_t jedec_id[3]; uint8_t tx_buf[264]; uint8_t rx_buf[264]; uint32_t addr; uint16_t page_len; w25_state_t state; volatile uint8_t busy; } w25qxx_t;tx_buf[264]的尺寸是按“1字节命令 + 3字节地址 + 256字节数据”设计的,加上页编程不能跨页,这个缓冲区足够覆盖W25QXX全部指令。发起读ID的异步函数如下:
void w25qxx_read_id_async(w25qxx_t *dev) { dev->tx_buf[0] = W25QXX_CMD_READ_JEDEC_ID; dev->tx_buf[1] = 0xFF; dev->tx_buf[2] = 0xFF; dev->tx_buf[3] = 0xFF; dev->state = W25_READ_ID_CMD; dev->busy = 1; W25QXX_CS_LOW(); HAL_SPI_TransmitReceive_IT(&hspi1, dev->tx_buf, dev->rx_buf, 4); }注意这里用的是HAL_SPI_TransmitReceive_IT,不是HAL_SPI_Transmit_IT。读ID的完整事务是“主机发送0x9F,从机继续接收3个时钟周期后回传3字节ID”,这段过程发生在同一个CS低周期内。如果先发命令、再切换成接收模式,中间片选不能断开,用全双工接口一次传输4字节最干净。回调里做状态推进:
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance != SPI1) return; w25qxx_t *dev = &g_w25qxx; switch (dev->state) { case W25_READ_ID_CMD: dev->jedec_id[0] = dev->rx_buf[1]; dev->jedec_id[1] = dev->rx_buf[2]; dev->jedec_id[2] = dev->rx_buf[3]; W25QXX_CS_HIGH(); dev->state = W25_IDLE; dev->busy = 0; break; default: break; } }这段代码把“CS拉低→传输4字节→CS拉高”作为一个原子事务处理,整个过程中CS不会中途翻转。回调结束后,dev->jedec_id里就是厂商ID和设备ID。实际抓波形时,读ID也是最容易确认SPI时序是否正确的指令。
3.4 页编程:从写使能到等待忙,中断之间不能断片
页编程的异步写法比读ID多一个写使能阶段,还要处理“等待忙”。发起函数如下:
uint8_t w25qxx_page_program_async(w25qxx_t *dev, uint32_t addr, const uint8_t *data, uint16_t len) { if (dev->busy) return 0; if (len > 256 || (addr & 0xFF) + len > 256) return 0; dev->tx_buf[0] = W25QXX_CMD_PAGE_PROGRAM; dev->tx_buf[1] = (addr >> 16) & 0xFF; dev->tx_buf[2] = (addr >> 8) & 0xFF; dev->tx_buf[3] = addr & 0xFF; memcpy(&dev->tx_buf[4], data, len); dev->page_len = len; dev->addr = addr; dev->tx_buf[0xC0] = W25QXX_CMD_WRITE_ENABLE; dev->state = W25_PAGE_PROGRAM_ENABLE; dev->busy = 1; W25QXX_CS_LOW(); HAL_SPI_Transmit_IT(&hspi1, &dev->tx_buf[0xC0], 1); return 1; }先写使能,因为HAL_SPI_Transmit_IT会自动发送完整个缓冲区?这里要小心:HAL_SPI_Transmit_IT(&hspi1, &dev->tx_buf[0xC0], 1)是发1字节的写使能指令。为什么要单独发一个字节?因为W25QXX要求写使能指令要以CS拉高结束,之后才能开始页编程命令。如果写使能和页编程命令放在同一帧里,两个指令之间CS没有释放,页编程会被当作写使能指令的一部分处理,整个流程全部错位。
发送完成回调里推进状态:
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance != SPI1) return; w25qxx_t *dev = &g_w25qxx; switch (dev->state) { case W25_PAGE_PROGRAM_ENABLE: W25QXX_CS_HIGH(); /* CS拉高,写使能生效 */ W25QXX_CS_LOW(); /* 重新拉低,开始页编程帧 */ dev->state = W25_PAGE_PROGRAM_CMD; HAL_SPI_Transmit_IT(&hspi1, dev->tx_buf, 4 + dev->page_len); break; case W25_PAGE_PROGRAM_CMD: W25QXX_CS_HIGH(); /* 帧结束,Flash开始真正写入 */ dev->state = W25_WAIT_BUSY; /* 进入等待忙,交给任务级代码或定时器轮询 */ break; default: break; } }这里能看到中断状态机的关键设计:写使能是一个独立帧,页编程命令和数据是另一个独立帧,两个帧之间CS必须有一次完整的拉高再拉低。回调里不能做耗时操作,所以等待WIP位清零的循环不能放在中断里。常见做法是交给主循环,用HAL_GetTick()或RTOS的延时,每1ms读一次状态寄存器,直到bit0清零。中断负责响应SPI数据搬运,状态等待交给任务级代码,结构反而更清晰。
4. 调试SPI中断读写W25QXX:逻辑分析仪、ID错误与Cache一致性
4.1 先用回环验证外设,再上Flash
调试顺序不要本末倒置。第一次上板,先什么都不接,把SPI1的MOSI通过杜邦线短接到MISO上,发一个字节,回读一字节,验证中断路径是否正常。能通过回环的代码,问题通常出在Flash侧的接线、电平或时序参数。换回W25QXX后,第一步先读JEDEC ID。读ID失败的表现大致有几种:读到0xFFFFFF、读到FF FF FF、读到数值漂移不稳定。对应的排查方向见下表。
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| 全部0x00 | 没上拉或MISO对地短路 | 万用表量MISO电压,检查DO焊接 |
| 全部0xFF | 片选没拉低、CLK频率过高、Flash未供电 | 逻辑分析仪看CS是否到0,确认供电3.3V |
| 读到EF 40 D8 | 芯片型号和预期不一致 | 确认颗粒是W25Q128JV还是GD25Q128E |
| 偶发读ID错误 | CPOL/CPHA不匹配,数据在错误沿采样 | 确认Mode 0是否生效,看时钟波形 |
| 中断回调卡死 | NVIC中断优先级配置异常 | 检查SPI1全局中断是否在CubeMX里使能 |
W25Q128JV的ID是EF 40 18,W25Q64JV是EF 40 17,GD25Q128E是C8 40 18。如果读到其他值,先查芯片手册,确认是不是买到了非原厂兼容料。
4.2 逻辑分析仪抓SPI时序的三个关键位置
SPI协议看着简单,实际波形比想象中敏感。逻辑分析仪采样率建议设成SPI时钟的4倍以上,否则边沿不清晰。抓到波形后先看CS:发送一个页编程指令的完整过程中CS应该保持低,中间任何一次意外拉高都会让Flash把后面的字节当成新指令。第二看时钟连续性:每字节之间的短暂间隙不要紧,但CS拉低期间的SCK缺失不能太严重。第三看数据采样沿:Mode 0是上升沿采样数据,MISO数据变化发生在下降沿,如果看到的波形数据翻转位置和时钟沿错位,检查是不是有额外滤波电容。
软件模拟SPI和高频硬件SPI在调试上最大的差异是触发点:软SPI每bit时序都由CPU控制,出错时能断点单步;硬件SPI一旦中断路径跑飞,只能靠逻辑分析仪确认帧结构。遇到读ID失败但波形看起来正常的情况,试着把波特率分频从8改成16或32,排除信号质量问题。
4.3 中断方式也要处理D-Cache
H743是Cortex-M7核心,D-Cache默认可能处于开启状态。CPU往缓冲区写的数据会先进Cache,等到某个时间点再写回内存;SPI外设通过总线从内存取数据时,如果Cache里的数据还没写回,发出去的就是旧内容,收数据同样可能读到Cache里的过期值。
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); /* 启动发送 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); /* 读取接收数据 */发送前对缓冲区做一次Clean,接收完成后做一次Invalidate。这个处理在页编程大数据写入时格外重要。缓冲区建议32字节对齐,H743的Cache line长度是32字节,对齐后CleanDCache_by_Addr的边界处理更高效,也不会误伤相邻变量。
擦除后读回全是0xFF时,还要检查扇区地址是否正确。W25QXX的地址按3字节偏移,如果地址高位写错,擦除的就是另一个扇区。配合逻辑分析仪看地址字节在MOSI上的顺序,比反复改代码更快定位。
5. 中断方式与RTOS共存:事件标志、双缓冲与CRC校验
5.1 用事件标志组替代裸机标志位
进入RTOS后,裸机里的volatile uint8_t busy换成语义明确的事件标志组更可控。在发送完成回调、接收完成回调这些节点上给不同事件赋位,阻塞在事件组上的任务就能被精确唤醒。
#define EVT_W25_TX_DONE (1UL << 0) #define EVT_W25_RX_DONE (1UL << 1) void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xEventGroupSetBitsFromISR(xW25EventGroup, EVT_W25_RX_DONE, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }任务侧用xEventGroupWaitBits等待对应事件,这样驱动层不再暴露复杂的回调状态,线程安全性依靠事件组天然保证。注意xEventGroupSetBitsFromISR不能在临界区内调用,启动SPI中断传输的动作也应该放在临界区外,因为这本身会触发一次调度。
5.2 双缓冲与CRC验证
中断方式发一页256字节数据,每字节触发中断,发送完成后还有状态等待窗口。如果任务等发送完成事件后再把下一笔数据填进缓冲区,帧与帧之间会出现明显停顿,Flash整体写吞吐下降。双缓冲做法是维护两个发送缓冲区,任务填充一个,SPI中断发送另一个,等发送完成事件回来后再交换角色,让总线上几乎没有等待指令数据的空窗。
可靠性验证建议做一轮大块区域的“写读回比”。写入已知模式(0x00、0xFF、0xA5、递增计数),读回后逐字节对比。H743内置CRC外设,写端算好一段数据的CRC值,读回后再算一次,两值相等则认为这一块写读成功。CRC校验比全量比对高效得多,尤其适合判断Flash是否损坏或上次写了一半就断电的场景。
最后提一个实际写分区时会用到的串行SPI Flash事务惯例:擦除和页编程之间的写使能指令0x06必须在一次CS低周期内单独完成,写使能后不要立刻把同一个CS周期扩展到页编程命令上。按“CS低→发0x06→CS高→CS低→发0x02+地址+数据→CS高”的顺序执行,是中断状态下最不容易出错的事务序列。
本文还有配套的精品资源,点击获取