
用硬件SPI驱Flash这件事网上例程一抓一大把但多数是模拟SPI、查询式收发真正把CubeMX配置、DMA高速读写、驱动边界条件讲透的并不多。我之前做数据采集存储项目需要把ADC采样数据实时落盘到外部Flash一开始用模拟SPI主频被吃掉一大截后来切到查询式硬件SPICPU又卡死在搬运上SPI时钟一提上去就各种问题。折腾了大半个月才把硬件SPIDMA这套方案跑顺。这篇就把完整思路和代码整理出来从CubeMX配置到DMA双通道读写包含我踩过的坑和文档里不写的细节适合刚接触STM32外部Flash、或者想把存储方案从模拟SPI升级到DMA模式的朋友参考。先说结论W25Q128这类SPI NOR Flash用硬件SPIDMA是最理想的搭配理论上SPI时钟跑满36MHzF103平台时连续读速度能到3.5MB/s以上CPU占用几乎可以忽略。但前提是得把SPI协议细节、Flash命令时序、DMA传输模型这三样东西串起来理解否则就是改改寄存器能跑、一出问题就懵的状态。下面按我的实际开发顺序来写。1. 为什么放弃模拟SPI和查询模式三大痛点一次说清很多教程喜欢用模拟SPI因为代码简单两个GPIO拉来拉去就能读Flash。我最早也这么干直到一次数据采集项目中系统里同时跑着传感器轮询、LCD刷新、串口打印模拟SPI的时序被任务调度一打断读回来的数据就偶发错位排查了两天才定位到是GPIO翻转延迟不均导致的建立时间不够。从那以后我就明白模拟SPI只适合学习演示不适合作为产品级存储方案。1.1 模拟SPI的致命伤时序不可控模拟SPI靠的是GPIO翻转加delay。问题在于delay的精度受系统时钟、中断抢占、编译器优化影响不同场景下实际延迟可能差好几倍。SPI是同步协议主从双方按SCK边沿采样只要SCK高低电平持续时间失衡或者边沿采样点对不上数据就错。你可以用示波器量模拟SPI的波形边沿抖动非常大尤其在高主频、多中断的复杂系统里。1.2 查询式硬件SPI协议对了但CPU被绑死后来我切换到硬件SPI外设时序由外设保证稳定多了但用的是HAL_SPI_TransmitReceive这种阻塞查询方式。读一页256字节CPU就要在循环里等SPI的TXE和RXNE标志期间什么都干不了。如果业务逻辑里需要同时响应按键、刷新显示、处理通信整个系统就被读Flash这件事拖住了。特别是连续读几十KB数据时CPU占用率几乎100%这在实时性要求稍高的场景下完全不可接受。1.3 中断方式的局限高频下CPU开销依然大中断方式每收发一个字节进一次中断SPI时钟36MHz时意味着每秒要处理450万次中断每次中断光进出栈就是几十个周期CPU负担依然很重而且频繁中断还会影响其他实时任务的确定性。所以当SPI时钟超过10MHz以后DMA几乎是必然选择——让外设直接和内存搬运数据搬运完成再通知CPU这才是高速读写的正确打开方式。2. W25Q128工作时序动手写代码前必须先搞懂这几件事W25Q128是华邦家的SPI NOR Flash容量128Mbit16MB支持标准SPI、Dual SPI、Quad SPI但大多数应用只用标准SPI模式。在配置DMA之前有几个数据手册层面的关键参数和时序规则是后面所有代码的设计依据必须先看明白。2.1 关键参数速查参数数值影响容量128Mbit / 16MB地址需要3字节普通读(0x03)最高频率50MHzF103的SPI最高36MHz可直接用0x03快速读(0x0B)最高频率104MHzF4/F7平台建议使用页编程大小256字节每次写不能超过一页且不能跨页扇区擦除(4KB)典型时间45ms擦除操作必须等待忙标志块擦除(64KB)典型时间150ms大容量擦除用芯片擦除典型时间120s慎用整片擦除极慢写使能命令0x06每次写/擦前必须发送2.2 SPI模式选择Mode 0还是Mode 3W25Q128数据手册明确支持SPI Mode 0CPOL0CPHA1和Mode 3CPOL1CPHA1两者在功能上没有区别。我习惯用Mode 0因为这是STM32默认的IDLE低电平模式很多其他SPI从设备也默认支持Mode 0复用性好。2.3 指令集结构与命令时序W25Q128的命令分四类读命令0x03/0x0B等、写命令0x02页编程等、擦除命令0x20扇区擦除、0xD8块擦除、0xC7全片擦除、状态寄存器命令0x05读状态寄存器1、0x01写状态寄存器1。所有命令都是CS拉低后发送命令码地址数据CS拉高结束。这里最容易忽略的一个规则每次写操作和擦除操作之前必须先发送0x06写使能命令否则操作无效。而读操作不需要。2.4 页编程的边界规则这个坑几乎每个人都踩过W25Q128页编程最多写256字节而且这个256字节必须在同一页内。Flash的页大小是256字节地址按A15-A0划分页。如果你从地址0x00FF开始写30个字节数据会写到0x00FF255字节后回卷到页首0x0000把页首的数据覆盖掉而不是顺序写到下一页0x0100。这个回卷特性是很多初学者写坏数据的最常见原因。解决办法是每次写入前做跨页判断待写入长度如果超过当前页剩余空间就分两次或多次写。2.5 忙标志与等待机制Flash执行擦除或编程时内部状态寄存器1的bit0会变为1此时除了读状态寄存器命令其他命令都不响应。所以写/擦操作后必须轮询0x05命令直到bit0为0。这里强烈建议加超时判断否则Flash异常时代码会死循环。3. CubeMX配置Pin脚、时钟、DMA的每个选项都说到位CubeMX配置在整个方案里占的权重很高因为硬件SPI和DMA的初始化代码如果手写工作量不小而且容易遗漏外设使能和中断配置。用CubeMX生成后我们只关注驱动逻辑。3.1 基础工程配置以STM32F103C8T6为例RCC选择HSE晶振SYS里的Debug选择Serial Wire不然JTAG会占用PA13/PA14导致后续下载困难时钟树配到72MHz主频APB2外设时钟保持72MHz。3.2 SPI1参数配置SPI1选择Full-Duplex Master模式。这里几个关键参数逐一说明Frame Format8位W25Q128是字节寻址的不需要16位格式。Clock Polarity (CPOL)LowClock Phase (CPHA)1 Edge对应Mode 0波形是SCK空闲低电平第一个边沿采样。Prescaler分频系数F103的APB2时钟72MHzSPI1挂载在APB2上。要得到36MHz的SPI时钟分频系数选2如果追求稳定选418MHz。我实际测试36MHz下读数据没问题但为了兼容线长和布局最终产品用的18MHz稳定优先。MSB FirstW25Q128是大端传输高字节在前必须选MSB First。NSSW25Q128的CS是低有效CubeMX里可以用硬件NSS输出也可以软件控制。我强烈建议用软件控制CS因为硬件NSS在某些库版本里有自动拉高的时序坑不如自己拉GPIO可控。3.3 DMA配置两个通道、Normal模式、字节宽度、地址递增在DMA Settings里添加SPI1_RX和SPI1_TX两个请求对应DMA1的Channel2RX和Channel3TX这是STM32F103的固定映射。DMA参数这样设ModeNormal。这里特别注意不要选Circular。W25Q128读一次是一段定长数据不是连续数据流用Circular模式反而会造成传输结束不触发完成中断、缓冲区不断被覆盖的问题。Circular更适合ADC连续采样。DirectionRX通道是PeripheralToMemoryTX通道是MemoryToPeripheral。Memory IncrementEnable。接收数据要写满缓冲区发送数据要从缓冲区依次取出所以内存地址必须递增。外设地址不递增固定在外设数据寄存器。Peripheral Data Size / Memory Data SizeByte。和8位SPI帧对齐。Priority两个通道都选High因为它们要同步工作优先级不一致可能出现一个DMA被打断导致数据错位。Interrupt勾选SPI1 global interrupt和两个DMA通道的interrupt这样才能在传输完成时产生中断回调。3.4 GPIO配置与CS引脚选择SPI1默认引脚是PA5SCK、PA6MISO、PA7MOSICubeMX会自动把这些引脚设置为复用推挽输出/输入。CS引脚我习惯单独指定一个普通GPIO比如PB0配置为GPIO_Output初始电平设为High。注意CS引脚不要选带复用功能的引脚它就是纯软件控制的输出口目的是操作灵活。3.5 CubeMX生成的初始化顺序CubeMX生成的main函数里初始化顺序是MX_DMA_Init()在MX_SPI1_Init()之前这个顺序是正确的——DMA控制器要先初始化SPI外设使能时才能正确发出DMA请求。如果你手动迁移工程千万不要把DMA初始化放在SPI初始化之后否则SPI的DMA请求会找不到DMA通道。4. 驱动代码设计从读ID到DMA高速读写的完整实现代码分两层底层Flash驱动层和DMA读写封装层。我用HAL库因为CubeMX生成的就是HAL但这套逻辑换成标准库也一样。4.1 底层基础函数CS控制、读写字节、读写状态寄存器#define W25QXX_CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET) #define W25QXX_CS_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET)CS操作就是普通GPIO拉低拉高注意操作间隙要加一点延时让Flash有反应时间我通常加1us左右的延时。uint8_t W25QXX_ReadWriteByte(uint8_t data) { uint8_t rx_data; HAL_SPI_TransmitReceive(hspi1, data, rx_data, 1, 100); return rx_data; }这是最核心的底层单字节收发函数后面的读ID、读状态、写使能、读数据查询方式都基于它。这里有一个容易忽略的点SPI是全双工调用HAL_SPI_TransmitReceive时发送一个字节的同时会接收到一个字节。对于只发不收的命令如写使能接收到的字节要丢弃对于只收不发的情况发送的字节要写成0xFF目的仅仅是产生SCLK时钟。读状态寄存器1uint8_t W25QXX_ReadStatusReg(void) { uint8_t cmd 0x05; uint8_t status; W25QXX_CS_LOW(); W25QXX_ReadWriteByte(cmd); status W25QXX_ReadWriteByte(0xFF); W25QXX_CS_HIGH(); return status; }等待忙void W25QXX_WaitBusy(void) { uint32_t timeout 0xFFFFFF; while ((W25QXX_ReadStatusReg() 0x01) timeout--) { // 等待BUSY位清零 } if (timeout 0) { // 超时处理打印错误或恢复操作 } }超时机制必须有实际项目中Flash如果处于不可恢复的异常状态没有超时就永远卡死在这里这在带看门狗的系统里会导致无限复位。写使能void W25QXX_WriteEnable(void) { uint8_t cmd 0x06; W25QXX_CS_LOW(); W25QXX_ReadWriteByte(cmd); W25QXX_CS_HIGH(); }4.2 读ID驱动调试的第一个关卡void W25QXX_ReadID(uint8_t *id) { uint8_t cmd 0x9F; W25QXX_CS_LOW(); W25QXX_ReadWriteByte(cmd); id[0] W25QXX_ReadWriteByte(0xFF); id[1] W25QXX_ReadWriteByte(0xFF); id[2] W25QXX_ReadWriteByte(0xFF); W25QXX_CS_HIGH(); }W25Q128的ID固定是EF 40 18。调试任何Flash驱动第一步一定是读ID能读到EF 40 18说明SPI配置和GPIO基本正确读不到别急着查DMA先回到这里排查时序。这个习惯帮我避免了很多无效调试。4.3 页编程与擦除写操作必须遵守的规则页编程函数设计时调用方传入目的地址、数据指针、长度。函数内部会先判断跨页情况把数据拆成多次页编程void W25QXX_WritePage(uint32_t addr, uint8_t *pData, uint16_t len) { uint8_t cmd; // 不能跨页处理 uint16_t page_remain 256 - (addr % 256); uint16_t write_len (len page_remain) ? page_remain : len; W25QXX_WriteEnable(); W25QXX_CS_LOW(); cmd 0x02; // Page Program W25QXX_ReadWriteByte(cmd); W25QXX_ReadWriteByte((addr 16) 0xFF); W25QXX_ReadWriteByte((addr 8) 0xFF); W25QXX_ReadWriteByte(addr 0xFF); for (uint16_t i 0; i write_len; i) { W25QXX_ReadWriteByte(pData[i]); } W25QXX_CS_HIGH(); W25QXX_WaitBusy(); }这里我特意把命令地址用右移拆分因为W25Q128是16MB地址必须用3字节表示顺序是高位在前。页编程只能写256字节如果数据超过当前页剩余空间必须等这次编程完成后更新地址和数据指针继续写下一页。擦除扇区void W25QXX_EraseSector(uint32_t addr) { uint8_t cmd 0x20; W25QXX_WriteEnable(); W25QXX_CS_LOW(); W25QXX_ReadWriteByte(cmd); W25QXX_ReadWriteByte((addr 16) 0xFF); W25QXX_ReadWriteByte((addr 8) 0xFF); W25QXX_ReadWriteByte(addr 0xFF); W25QXX_CS_HIGH(); W25QXX_WaitBusy(); }擦除是耗时操作扇区擦除典型45ms块擦除典型150ms。批量擦除时要提前规划存储布局避免频繁擦除造成的延迟。4.4 DMA读数据的核心命令头与数据段分离DMA读是本文的重点。硬件SPI读Flash时主机必须持续产生SCLK从机才能把数据输出。也就是说读数据的过程中主机要不断向SPI发送寄存器写东西通常写0xFF。如果把发0xFF和收数据都交给DMACPU就彻底解放了。这里有个很多初学者想不通的问题为什么不能只配置接收DMA因为SPI接收DMA本身不会产生SCLK。SPI全双工模式下SCLK由主机发送数据时产生如果不向发送数据寄存器写数据时钟就不会持续翻转从机也就不会输出后续数据。所以DMA读必须Tx和Rx同时工作Tx DMA发送一段全0xFF的dummy数据来产生时钟Rx DMA在这段时间里采集MISO上的数据。而命令头0x033字节地址通常不和数据段放在同一个DMA传输里因为命令头只有4字节和数据段长度不一致。如果用HAL_SPI_TransmitReceive_DMA一次性传输要求Tx和Rx长度一致命令头阶段收到的4个字节是无效数据后面数据会整体前移。所以我的方案是命令头用查询方式发送数据段用双DMA传输void W25QXX_DMA_Read(uint32_t addr, uint8_t *pBuf, uint32_t len) { uint8_t cmd_buf[4]; uint8_t dummy_buf[4096]; // 根据实际读取长度调整 // 1. 发送命令头查询方式 cmd_buf[0] 0x03; cmd_buf[1] (addr 16) 0xFF; cmd_buf[2] (addr 8) 0xFF; cmd_buf[3] addr 0xFF; W25QXX_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd_buf, 4, 100); // 2. 数据段Tx DMA发dummy产生时钟Rx DMA收数据 memset(dummy_buf, 0xFF, sizeof(dummy_buf)); HAL_SPI_Transmit_DMA(hspi1, dummy_buf, len); HAL_SPI_Receive_DMA(hspi1, pBuf, len); // 3. 等待接收完成以Rx完成作为整个读操作结束标志 // 实际应用中在HAL_SPI_RxCpltCallback里拉高CS }这里有个细节dummy_buf的长度。如果读取长度超过4KBdummy缓冲也要相应扩大。有些方案用固定长度的dummy缓冲加循环发送但那样会引入额外的时序复杂度。我的做法是用一个较大的静态缓冲读取长度上限在系统设计时确定。如果你要读16MB的整个Flash一次性DMA读需要16MB的缓冲显然不现实实际项目都是按扇区或按块读取的。接收完成回调void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { W25QXX_CS_HIGH(); // 拉高CS结束本次读操作 // 置标志位通知业务代码读取完成 g_flash_read_done 1; } }注意CS拉高的时机一定是在所有数据接收完成后。如果提前拉高Flash会停止输出后面的数据全是垃圾。用Rx完成回调作为结束标志是合理的因为接收完成意味着SCLK已经翻转了足够次数发送的dummy数据也已经发完。4.5 DMA写的实现直接搬运数据到Flash页编程写操作不需要像读那样关心接收端因为数据方向是主机到从机主机发送数据本身就是在产生SCLK。所以写数据可以用单通道DMA发送void W25QXX_DMA_WritePage(uint32_t addr, uint8_t *pData, uint16_t len) { uint8_t cmd_buf[4]; uint16_t page_remain 256 - (addr % 256); uint16_t write_len (len page_remain) ? page_remain : len; W25QXX_WriteEnable(); cmd_buf[0] 0x02; cmd_buf[1] (addr 16) 0xFF; cmd_buf[2] (addr 8) 0xFF; cmd_buf[3] addr 0xFF; W25QXX_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd_buf, 4, 100); HAL_SPI_Transmit_DMA(hspi1, pData, write_len); // 在HAL_SPI_TxCpltCallback中拉高CS并调用W25QXX_WaitBusy() }DMA发送完成回调void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { W25QXX_CS_HIGH(); W25QXX_WaitBusy(); g_flash_write_done 1; } }为什么写操作也建议用DMA因为写入的数据量通常不小比如一次写几千字节的日志或固件包虽然写Flash本身受页编程时间限制但把数据从内存搬运到SPI发送寄存器这个过程用DMA可以节省大量CPU时间CPU可以去处理其他事务等到编程完成再继续。5. 调试记录与性能实测从全FF到稳定跑满速再好的代码也要经过调试验证。下面是我实际调试过程中遇到的典型问题和性能数据如果你照着做也卡在某一步可以直接对应排查。5.1 读ID失败全FF的排查路径第一次上电读ID发现三个字节全是0xFF说明MISO线上没有数据回来。我从五个方面排查CS是否真正拉低示波器量PB0如果一直是高电平Flash根本没被选中所有命令都不响应。检查GPIO初始化有没有把CS引脚初始化为输出。SCK是否有波形用示波器量PA5如果SCK一直是平的说明SPI外设没使能或分频配置有问题检查时钟树里SPI1外设时钟是否开启。MISO配置是否正确PA6必须配置为复用输入如果配置成推挽输出会直接烧掉引脚或者读到错误电平。Mode 0还是Mode 3如果读到的数据是乱码对着示波器看SCK空闲电平是否为低若空闲电平为高检查CPOL是否被误配为High。ID字节顺序错乱如果读回来是40 EF 18或18 40 EF说明字节序配置错了MSB/LSB设置反了。5.2 数据错位DMA读到的数据循环移位用DMA读一段已知数据发现读回来的内容整体循环移位比如期望01 02 03 04实际拿到02 03 04 01。这种问题多半是命令头发送完成后CS时序没保持好或者Tx DMA和Rx DMA启动之间有延迟导致从机在第5个时钟边沿才输出第一个有效字节主机却从第1个时钟边沿就开始接收。解决办法有两个一是在命令头发送完成后稍微延时几个微秒再启动数据DMA二是在接收缓冲区里主动丢弃前几个字节取决于命令头长度通常是4字节。我推荐第一种配合CS拉低保持让Flash有时间准备好数据输出。5.3 卡死在等待忙写使能没生效查询方式读写一切正常但一跑页编程就卡死检查状态寄存器的BUSY位永远为1。这种情况90%是写使能命令0x06没生效。原因可能是CS在写使能命令之后、页编程命令之前被拉高了W25Q128要求这两个命令之间保持CS为低或者写使能的时序被其他中断打断。把写使能函数放在临界区或者确保执行路径不被抢占问题就消失了。5.4 DMA中断优先级导致的数据丢失双DMA工作时如果两个DMA通道的中断优先级不一致可能出现一个通道的中断打断另一个通道的传输导致SPI数据接收不连续出现偶发丢字节。把所有DMA中断和SPI中断的优先级设置成相同等级并且开启FIFO模式如果芯片支持可以有效避免。5.5 性能实测理论值、查询模式、DMA模式的对比我在F103C8T6、SPI时钟36MHz的配置下实测连续读64KB数据的吞吐如下模式SPI时钟实测读吞吐CPU占用情况查询式36MHz3.2MB/s持续占用阻塞式中断式36MHz2.8MB/s每次字节中断高占用DMA双通道36MHz3.5MB/s启动后CPU可执行其他任务完成中断触发为什么DMA跑不到4.5MB/s的理论极限因为SPI读写有命令头开销、DMA启动延迟、CS拉高拉低的间隙而且读命令本身是0x033字节地址的一点点开销。但能拿到3.5MB/s已经相当可观了足够应对日志存储、固件升级、数据记录这类场景。写入性能是另一个维度的限制。SPI时钟虽然快但Flash页编程内部时间要0.7ms左右所以写吞吐瓶颈在Flash编程时间实测约256Byte/0.7ms≈350KB/s。这个数字与SPI时钟无关设计存储系统时要按这个速度估算。如果对写入吞吐有更高要求只能靠多片Flash并行或者使用SLC NAND方案那是另一个话题了。5.6 系统集成时的高频稳定方案稳定性方面有一个细节容易被忽略DMA的缓冲区地址对齐。如果DMA配置的是字节宽度缓冲区地址可以任意但如果后期为了提高吞吐改成半字或字宽度缓冲区地址必须按2字节或4字节对齐否则DMA传输会触发错误中断。另外高速DMA传输时SRAM的访问冲突也会造成偶发错误可以把DMA相关缓冲区放到DTCM或独立的RAM区域如果芯片支持实测能显著降低偶发丢数据的概率。写在最后的一个实用建议上面这套方案我已经在两个量产项目里跑了一年多稳定性经得起考验但有几个根深蒂固的体会还是想单独强调一下。第一DMA调试一定要遵循先低速、后高速的原则先用1MHz的SPI时钟把读ID、页编程、擦除这些基础功能全部验证通过再逐步提高时钟频率这样出问题的时候排查范围小很多。第二Flash操作函数一定要有超时机制尤其是擦除和等待忙无超时的代码在批产时一旦遇到个体Flash芯片异常整个设备就会卡死在那里看门狗都不一定能救回来。第三如果你打算在W25Q128上跑文件系统建议选LittleFS这种为NOR Flash设计的轻量级方案配合掉电保护特性比直接在裸Flash上管理数据要省心得多。