STM32F407通过CubeMX配置SPI读写W25Q128 NorFlash完整指南

发布时间:2026/9/28 18:05:59
STM32F407通过CubeMX配置SPI读写W25Q128 NorFlash完整指南 手里这块STM32F407开发板吃灰了半年多最近接了个小需求要在设备掉电之后保存一批配置参数正好把SPI接口的norflash用起来。用STM32CubeMX配SPI读写norflash这事儿听起来不算复杂真正动手才发现坑比想象中要多时钟树配错、片选没拉低、擦除等待不够、跨页写入回卷……一轮折腾下来把W25Q128的手册翻了好几遍今天把整套流程彻底捋一遍代码、配置、踩坑全部放出来给准备玩STM32F407 SPI norflash的朋友做个参考。这篇文章适合两类人一类是刚接触STM32CubeMX想搞清楚图形化配置和HAL库代码之间怎么衔接的新手另一类是已经在用SPI但因为通信不稳定、数据读出来不对而头疼的开发者。我会把从CubeMX建工程、配置SPI、编写norflash驱动到实测验证的全过程都讲清楚重点说明每一步为什么要这么做而不是简单地贴代码。最后还会整理一份排查手册把常见的翻车现场和解决方案列出来。1. 先把SPI和norflash这对组合搞清楚1.1 SPI协议核心机制以及四种模式怎么选SPI全称Serial Peripheral Interface是一种同步串行通信接口由Motorola提出后基本成了行业默认标准。它用四根线完成全双工通信SCK提供时钟MOSI主出从入MISO主入从出CS片选负责选中设备。SPI的一大特点就是灵活没有像I2C那样复杂的地址帧、应答位数据线怎么用全看主设备和从设备之间怎么约定。这里最容易被新手忽略的是CPOL和CPHA这两个参数。CPOL决定SCK空闲时的电平CPHA决定数据在时钟的哪个边沿采样。两者组合出SPI Mode 0到Mode 3四种模式主设备和从设备必须工作在完全相同的方式下否则通信直接乱套。绝大多数norflash芯片默认支持Mode 0和Mode 3W25Q系列在数据手册里明确写了支持SPI Mode 0和Mode 3我习惯选Mode 0也就是CPOL为Low、CPHA为1 Edge。选择模式0的原因很朴素兼容性最广绝大多数SPI从设备默认都支持这个模式后续换芯片时不用再改配置。除了模式之外还有一个容易踩的选择题MSB还是LSB先行。W25Q系列的指令格式是高位在前所以SPI设置里First Bit必须选MSB First。如果有人把这里配成了LSB First命令字和数据位序全部反转读回来的数据就是乱的。1.2 norflash内部结构为什么参数存储场景常用它norflash和nandflash是两类完全不同的存储芯片。norflash的优点是随机读取速度快、支持片上执行XIP缺点是写入前必须先擦除、擦除单位大、容量相对小。在嵌入式设备里norflash通常用来存固件、字库、配置文件这类数据量不大但对随机读取性能有要求的场景。W25Q128这个型号在市面上非常常见容量128Mbit也就是16MB3.3V供电支持标准SPI、Dual SPI和Quad SPI读写速度足够绝大多数应用使用。W25Q128内部结构是分层的16MB分成256个Block每个Block 64KB每个Block分成16个Sector每个Sector 4KB每个Sector再分成16个Page每个Page 256字节。这里的核心约束是擦除操作的最小单位是扇区4KB写入操作的最小单位是页256字节而数据的读取没有长度限制可以任意地址连续读取。这就意味着你改了其中一个字节也得先把整个扇区擦掉再重写整个扇区的数据。理解这个结构对后续写驱动代码至关重要。2. STM32CubeMX里30分钟搞定基础配置2.1 新建工程、时钟树和调试接口配置打开STM32CubeMX第一步是新建工程并选择MCU型号。我这边用的是STM32F407VET6大家在CubeMX的Part Number搜索框里输入自己板子对应的型号就行。选型的时候注意后缀VET6是512KB Flash的版本如果用的是ZGT6或者别的型号引脚数量和Flash容量不同但SPI配置思路完全一致。进入Pinout视图后先做三件基础配置。第一调试接口如果板载的是ST-Link在System Core - SYS - Debug下选择Serial Wire否则生成的工程默认禁用调试口下载一次程序之后第二次就连接不上了这个问题非常隐蔽。第二RCC时钟在System Core - RCC下将HSE设为Crystal/Ceramic Resonator这样会使用外部晶振作为高速时钟源。第三时钟树切到Clock Configuration页面输入HCLK为168MHzCubeMX会自动计算出PLL参数。这里要注意APB2外设时钟总线频率SPI1挂在APB2上最终得到的是84MHz。我这次用的是SPI1为什么不选SPI2或SPI3主要看引脚复用和PCB布局。SPI1的默认引脚是PA5、PA6、PA7刚好和很多开发板上预留的排针对齐方便接外部模块。SPI1挂在APB2总线上时钟频率比APB1上的SPI2高一倍虽然对于10MHz级别的SPI通信来说差距不明显但谁不喜欢性能余量呢。2.2 SPI外设参数配置与引脚确认在Pinout视图中点击SPI1Mode选择Full-Duplex Master也就是全双工主机模式。硬件NSS那里我直接选择Disable后面用软件控制片选原因下面细说。此时CubeMX会自动分配PA5为SCK、PA6为MISO、PA7为MOSI。接着在Configuration面板打开SPI1的参数设置界面重点看这几项ModeFull-Duplex MasterHardware NSS SignalDisableData Size8 BitsFirst BitMSB FirstPrescaler8分频CPOLLowCPHA1 Edge计算一下通信时钟SPI1的输入时钟是APB2的84MHz8分频之后SCK频率为10.5MHz。W25Q128的最高SPI时钟可以达到104MHz10.5MHz完全在规格范围内稳定性也没有问题。如果你用的是杜邦线连接外部模块而不是PCB布线建议分频系数再拉高一点比如16分频到5.25MHz长线对信号完整性的影响会小很多。这里有新手经常犯的错Prescaler数字越小SPI时钟越快但有些开发板上的主控芯片对高速SPI并不感冒特别是当MISO线上拉电阻阻值不合适、线路过长时时钟快了反而容易采到错误的电平。稳定优先速度后排。2.3 硬件片选和软件片选实测下来我推荐后者SPI通信里CS片选是控制从设备是否被激活的信号。STM32的SPI外设支持硬件NSS和软件NSS两种方式。硬件NSS模式下SPI外设会在每帧数据传输时自动拉低NSS引脚传输结束自动拉高听起来很省事但实际调试时我遇到过NSS信号和SCK时序配合不好的情况尤其是连续多字节读写时CS的释放时机并不完全受控。软件片选就直白得多随便挑一个GPIO在每次通信开始前手动拉低、结束后手动拉高。这样做的好处是时序完全由自己掌控换芯片、改协议时不用重新配置SPI外设灵活度高很多。唯一的代价是多占用一个GPIO但这在STM32F407这种引脚丰富的芯片上根本不是问题。我这次用PB0作为CS控制引脚。在CubeMX里右键PB0选择GPIO_Output然后在GPIO设置里把初始电平设为High、输出类型设为Push Pull。CS引脚空闲时必须保持高电平这一点后面还会再提。2.4 工程生成与代码框架的首次确认在Project Manager页面设置工程名称、保存路径和工具链。我多年习惯用MDK-ARM V5CubeMX版本不同这里选项稍有差异选你本机装好的编译环境就行。勾选Generate peripheral initialization as a pair of.c/.hfiles这样外设初始化代码会拆分成独立文件不会全部堆在main.c里维护起来清爽很多。生成工程后用Keil打开先看一眼main.c里生成的MX_SPI1_Init函数确认SPI1的初始化参数和我们配置的一致。接着编译一次确保基础工程没问题再往下写驱动。这个习惯我一直保留着每做一步编译一次等所有问题堆积在一起再来找bug排查成本是成倍增长的。3. norflash驱动编写从指令集到读写实现3.1 W25Q指令集三条主线的组合就够了W25Q系列的指令集很规整全部是8位命令字。实现基本的读写操作只需要掌握这几条指令写使能0x06、读状态寄存器0x05、读数据0x03、页编程0x02、扇区擦除0x20、块擦除0xD8。很多人不理解为什么每次写操作前都要发写使能指令。这是norflash的一种写保护机制芯片上电或者完成一次编程/擦除操作后写使能锁存器WEL自动清零必须重新发送Write Enable指令才能进行下一次编程或擦除。如果跳过这一步直接发写命令芯片会直接忽略数据写不进去。这个设计是为了防止误操作或者供电异常时对Flash内容造成破坏。读数据指令0x03最简单先发命令字再发24位目标地址然后连续读取任意字节的数据。整个读取过程CS保持低电平芯片会按照地址自动递增输出数据直到CS拉高。这里ALU内部会有地址计数器地址到达该芯片最大容量后会发生回卷所以读取长度不要超出芯片容量范围。3.2 驱动层封装CS控制、写使能与状态轮询现在开始写驱动代码。我习惯为norflash单独建一个norflash.c和norflash.h文件不要把所有代码都塞进main.c后续维护和其他模块复用会轻松很多。先定义片选控制宏#define NORFLASH_CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET) #define NORFLASH_CS_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET)然后是写使能函数void NORFLASH_WriteEnable(void) { uint8_t cmd 0x06; NORFLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); NORFLASH_CS_HIGH(); }接着是读状态寄存器。这里要注意时序发完读状态指令后主设备必须继续产生时钟才能把状态寄存器内容移出来。因为SPI是全双工通信每次主设备发送一个字节的同时也会收到一个字节发送0x00只是为了产生时钟uint8_t NORFLASH_ReadStatus(void) { uint8_t cmd 0x05; uint8_t status 0; NORFLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_SPI_Receive(hspi1, status, 1, 100); NORFLASH_CS_HIGH(); return status; }擦除和编程操作都需要时间芯片在忙时状态寄存器bit0为1。所以驱动里必须有一个等待忙标志清除的函数void NORFLASH_WaitBusy(void) { while (NORFLASH_ReadStatus() 0x01) { /* 轮询等待 */ } }为什么这里用轮询而不是固定延时因为擦除时间随温度、工艺和擦除次数变化手册给的是典型值150ms最坏情况可能要400ms。如果只给一个固定的保守延时每次操作都白等一段时间整体效率很低。轮询方式能自适应芯片的实际状态该等多久等多久。3.3 扇区擦除、页编程与跨页写入问题擦除函数以扇区擦除为例void NORFLASH_SectorErase(uint32_t addr) { uint8_t cmd[4]; NORFLASH_WriteEnable(); cmd[0] 0x20; cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; NORFLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 4, 100); NORFLASH_CS_HIGH(); NORFLASH_WaitBusy(); }这里有个细节写使能和擦除命令必须在同一次CS低电平周期内完成吗不是的。Write Enable指令发送完毕后CS拉高芯片内部把WEL位置1之后再发擦除命令即可。两个命令可以分开只要确保WEL位是1就行。页编程函数void NORFLASH_WritePage(uint32_t addr, uint8_t *buf, uint16_t len) { uint8_t cmd[4]; NORFLASH_WriteEnable(); cmd[0] 0x02; cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; NORFLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_SPI_Transmit(hspi1, buf, len, 100); NORFLASH_CS_HIGH(); NORFLASH_WaitBusy(); }页编程最大的坑是跨页问题。W25Q128的每个Page是256字节写入数据时地址计数器只能在一个Page范围内递增一旦越过Page边界地址会从当前页的开头重新开始也就是发生回卷。这意味着如果你要写入的数据跨越了Page边界后一段会覆盖掉当前页开头的已有数据造成无法预料的错误。处理跨页的思路是拆分写入。比如起始地址是0x0100要写300字节这300字节会从Page 1中间跨到Page 2开头。正确做法是先写从0x0100到0x01FF的256字节再写Page 2开头的剩余44字节。在实际项目中我通常封装一个NORFLASH_WriteData函数内部自动计算当前地址到Page边界的剩余空间分段调用NORFLASH_WritePage调用者完全不用关心数据落在哪个Page里。3.4 读数据与主函数验证流程读数据函数void NORFLASH_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] 0x03; cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; NORFLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_SPI_Receive(hspi1, buf, len, 100); NORFLASH_CS_HIGH(); }注意读操作不需要写使能也不需要等待忙状态。读取前芯片是否处于忙状态原则上应该检查但实际应用中大多数代码直接读也不会出问题因为SPI主设备产生时钟、从设备输出数据这套流程不受内部状态机影响。不过严谨一点的做法是读之前也调用NORFLASH_WaitBusy()确保芯片没有被擦除操作占用。在主函数里做一次完整的验证实验先擦除一片扇区写入一组测试数据再读回比对。串口打印每一阶段的执行结果这样可以直观地看到每个环节是否正常int main(void) { uint8_t tx_buf[256]; uint8_t rx_buf[256]; uint32_t i; HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); printf(Norflash test start\r\n); NORFLASH_SectorErase(0x000000); for (i 0; i 256; i) { tx_buf[i] i; } NORFLASH_WritePage(0x000000, tx_buf, 256); NORFLASH_ReadData(0x000000, rx_buf, 256); for (i 0; i 256; i) { if (rx_buf[i] ! tx_buf[i]) { printf(verify error at %lu\r\n, i); while (1); } } printf(verify pass\r\n); while (1) { } }这里擦除地址选0x000000也就是起始扇区。擦除完成后整个扇区内容全为0xFF页编程再写入数据。校验通过后串口打印verify pass说明SPI通信链路、norflash指令流程、数据读写全部正常。4. 实测中踩过的坑与排查思路4.1 读回数据全是0xFF先别急着怀疑芯片读回全0xFF是大概率翻车现场。遇到这个现象首先想想是不是刚擦除还没写入或者写入流程压根没执行成功。有一个很常见的低级错误写完数据直接读中间没等Busy清除。Flash编程需要时间擦除和写使能命令发完后立刻读此时芯片还在忙数据自然读不出来。第二个要检查的是CS电平控制和硬件接线。CS必须在整个命令数据传输期间保持低电平如果CS在命令发出后、数据接收前就被提前拉高从设备后面的输出全部无效。用一个示波器或者逻辑分析仪抓一下CS、SCK和MOSI的时序很快就能定位。没有示波器的朋友可以在代码里加调试输出在CS拉低和拉高之间加一个延时人为延长CS低电平时间看是否有改善。第三个可能的原因是MISO没有正确连接。SPI是全双工总线主设备发送数据的同时从设备在MISO上返回数据。很多新手只接了SCK、MOSI、CS三根线忘记接MISO这种情况读回来的数据永远是0xFF。检查一下板子的丝印和原理图确保MISO确实连到了PA6。第四个原因是写保护引脚WP被拉低。W25Q128的WP引脚低电平时状态寄存器被锁死写命令不生效这让很多人在调试时一头雾水。确保WP引脚在硬件上接高电平或者用软件不触发写保护相关的状态寄存器位。HOLD引脚同样要注意在通信过程中如果被拉低芯片会暂停传输并忽略SCK信号。很多模块把WP和HOLD直接上拉到3.3V但如果你的板子是自行设计的务必确认这两根线的电平。4.2 数据乱码八成是时序模式不对如果写进去的是一串递增数据读出来却是完全无规律的乱码首先检查SPI的CPOL和CPHA配置。主设备的SPI模式必须和从设备完全一致。W25Q系列支持Mode 0和Mode 3如果你CubeMX里默认配的是Mode 2那通信必然错乱。把模式改成Mode 0CPOLLow、CPHA1 Edge绝大多数情况下问题就消失了。还有一个容易踩的位置是MSB/LSB位序。SPI的First Bit配置成LSB First之后每个字节的位顺序翻转。比如原本要发送0x060000 0110LSB First发送出去变成0x600110 0000芯片收到后既不认识这个命令也不会做任何响应。这就是为什么我在前面不厌其烦地强调First Bit必须设为MSB First。时钟频率太高也可能引起误码。当SPI时钟接近芯片极限或者传输线较长时信号反射、串扰会导致数据采样错误。把时钟分频从8改成16甚至32如果问题消失就说明是信号完整性问题。工业生产环境下10MHz以上的SPI建议用PCB布线而非杜邦线杜邦线超过10cm就尽量不要超过10MHz的时钟了。4.3 擦除后写入失败以及状态寄存器WEL位的坑擦除命令发完读状态寄存器发现BUSY位一直为1死循环卡在等待函数里。这种情况先检查CS低电平持续时间。有些芯片手册规定CS拉低后需要一定的建立时间才能开始传输数据或者命令发送完毕后CS需要在拉高前保持一段时间。在CS拉低和命令发送之间加1us左右的延时在命令发送完后CS拉高前再加一点余量很多时候就好了。另一个典型问题Write Enable命令发完之后读状态寄存器发现WEL位bit1始终为0说明芯片没有接受写使能。原因有三个WP引脚拉低导致状态寄存器被保护通信时序有问题命令根本没发对或者芯片已经处于忙状态此时使能命令会被忽略。逐一排查先确保WP接高、等待芯片不忙再发写使能再看时序波形。我在实际项目里习惯在Write Enable之后主动读一次状态寄存器把WEL位的值打出来作为调试信息。这样能最快定位问题出在使能阶段还是后续的编程/擦除阶段。4.4 HAL库SPI通信的一些细节笔记HAL_SPI_Transmit和HAL_SPI_Receive的最后一个参数是Timeout很多人不理解这个参数的含义。它表示的是函数内部等待SPI外设状态寄存器更新的时间单位是毫秒。如果SPI配置正确、外设没有卡死这个超时基本不会触发。但当SPI状态异常比如从设备没有正确响应、SCK没有输出函数会返回HAL_TIMEOUT。建议调用这些函数后检查返回值尤其是刚调试的时候。我见过项目里完全不检查返回值通信已经断开还继续往下走最终排查了整整一天。使用HAL_SPI_TransmitReceive和分开调用Transmit、Receive有什么区别TransmitReceive是同时收发一个字节如果使用不当容易搞混。在norflash这种先发命令/地址、再单独接收数据的场景我更推荐分开调用先HAL_SPI_Transmit发送命令和地址再用HAL_SPI_Receive接收数据。这样代码逻辑清楚先发送什么后接收什么一目了然调试时也容易单步跟踪。最后一个HAL库相关的坑CubeMX生成的SPI初始化代码默认是polling模式也就是阻塞式收发。如果后续要在中断里操作norflash小心SPI外设的忙碌标志没有完全清除导致重入问题。在中断上下文调用阻塞式HAL_SPI_Transmit很容易造成中断卡死如果要使用中断建议CubeMX里把SPI中断打开并用HAL_SPI_Transmit_IT或者干脆用DMA方式下一节细说。5. 还能怎么玩DMA、多设备与存储方案扩展5.1 用DMA搬运批量数据释放CPU占用普通轮询方式读写norflashCPU要一直等待SPI完成每个字节的传输。比如读取64KB数据按10.5MHz时钟算传输时间大约50毫秒这段时间CPU一直空转等待白白浪费。DMA方式可以帮CPU分担这部分工作配置好DMA通道后SPI数据在内存和外围设备之间自动搬运传输完成后触发中断通知CPU。CubeMX里配置DMA的步骤很简单打开SPI1的DMA Settings分别添加TX和RX两个DMA请求方向对应Memory To Peripheral和Peripheral To Memory模式选Normal。生成代码后传输函数换成HAL_SPI_Transmit_DMA(hspi1, tx_buf, len); HAL_SPI_Receive_DMA(hspi1, rx_buf, len);使用DMA要注意几个点第一缓冲区必须保持有效直到传输完成不能把局部变量直接丢进去然后函数返回否则DMA还在搬运但内存已经释放数据就乱了。第二DMA传输完成后进入中断回调在回调里置一个标志位主循环通过标志位判断传输是否完成。第三如果同时配置了TX和RX两个DMA通道收发完成的回调是独立的要分别处理。STM32F407的DMA1和DMA2有不同的请求映射表。SPI1_TX和SPI1_RX具体映射到哪个DMA控制器和通道可以查参考手册的DMA request mapping表。CubeMX会按照外设配置自动分配一般不会出错但如果手动改过CubeMX配置记得重新生成代码不要手工拼凑初始化。5.2 一个SPI总线上挂多个设备软件和硬件NSS的协同实际项目中不一定只挂一颗norflash可能还有SD卡、传感器、显示屏等等。SPI总线可以共享SCK、MOSI、MISO三根线每个设备独占一个CS引脚。通信时准备操作哪个设备就拉低哪个设备的CS其他设备的CS保持高电平等于把其他设备从总线上逻辑隔离。多设备场景下要注意的总线竞争问题如果某个设备的CS没有被正确拉高它可能会一直驱动MISO线导致其他设备通信异常。排查时先看所有CS引脚的电平状态确保只有一个CS处于低电平。另外不同设备额定SPI时钟可能不一样有的支持40MHz有的只能跑1MHz。这时总线速度只能取所有设备的最小公倍数或者给不支持高速的设备单独增大分频。但如果两个设备都挂在同一SPI外设上时钟分频只能设一个值。一个变通方案是把高速设备和低速设备挂到不同的SPI控制器上SPI1给高速设备、SPI2给低速设备互不干扰。5.3 参数存储的磨损均衡和掉电保护思路norflash有擦写寿命限制W25Q系列号称十万次擦写。如果每次修改参数都擦写同一个扇区十万次看似很多但配合频繁掉电保存的场景可能一两年就磨穿了。实际产品里通常用磨损均衡算法也就是轮流使用多个扇区每个扇区头部记录逻辑地址和序列号启动时扫描所有扇区找出最新的一份数据来加载。这套机制做起来不复杂但能显著延长Flash寿命。掉电保护是另一个被忽视的问题。如果写入过程中突然掉电闪存内容可能处于半更新状态轻则本次参数丢失重则导致保存区域数据损坏。比较简单可靠的做法是双备份方案写参数时先写备份区校验成功后再更新主区下次启动时如果主区数据校验失败就自动从备份区恢复。稍微复杂一点的还可以在数据尾部加CRC校验这样不但能判断数据是否存在还能判断数据是否正确。我自己在做这种存储方案时会先在纸上把数据布局画出来哪个扇区放主数据、哪个扇区放备份、头部预留多少字节存版本号、校验字段放什么算法计算结果。规划好再编码比边写边想靠谱得多。写在最后的实操体会如果只让我说一条最重要的经验那就是先把SPI时序吃透再碰CubeMX配置和代码。模式0、MSB先行、CS低有效这三件事搞清楚了后面才走得顺。调试时不要一口气写完所有代码再上电测试分步来先读状态寄存器确认SPI底层通了再测试读ID确认指令通路正常最后才做擦除和数据写读。每一步都能验证出了错定位范围也小。我在第一次跑通这个实验后把W25Q128的手册又翻了整整三遍很多细节都是踩了坑才回去看手册发现的比如跨页回卷、状态寄存器的WEL位、擦除和编程的时间参数。如果你也想把这片norflash真正用到项目里建议先把这篇文章里的代码跑通再自己动手去手册里查一遍指令集和状态寄存器的每位定义。只有亲手试过、亲手绕过坑后面的扩展才不会心虚。