
简介这套资源是针对STM32平台的W25Q系列SPI闪存驱动代码面向嵌入式开发者和单片机学习者解决W25Q32、W25Q64、W25Q128等存储芯片的底层读写与擦除控制问题。压缩包内共2个文件包含1个头文件和1个C源文件文件结构精简便于直接移植到工程中整包大小仅4KB适合快速集成到现有STM32项目。驱动覆盖芯片ID读取、状态寄存器操作、扇区擦除、页编程及连续读取等常用功能兼容标准库和HAL库的开发方式并预留了硬件SPI与模拟SPI的适配接口方便在不同型号STM32之间迁移。已有4607人学习下载对于需要为STM32扩展外部存储的开发者来说这份轻量驱动代码能节省查阅数据手册和调试底层时序的时间尤其适合用于固件升级、数据记录或配置文件存储等常见场景。 W25Q系列这几款SPI Flash芯片在STM32项目里太常用了几乎属于“标配外设”。很多刚开始做嵌入式的小伙伴一看到512Mb64MB、256Mb32MB这些容量就发怵感觉驱动很复杂其实W25Q32、W25Q64、W25Q128这个系列驱动逻辑高度一致核心差异就三点容量大小、地址位宽、JEDEC ID。我这次直接把这套驱动代码拆开揉碎从原理到实操一条龙讲清楚看完你不仅能在自己的项目里直接移植还能搞清楚它内部到底怎么工作的出了问题也能自己排查不用到处找现成代码瞎凑。1. 内容整体设计与思路拆解1.1 核心需求解析W25Q64、W25Q128、W25Q32都是华邦Winbond出品的SPI NOR Flash芯片区别只是容量不同。这类芯片在STM32项目里通常用来存字库、存配置参数、做OTA固件升级的临时存储区甚至有的项目直接把它当成“小硬盘”用。写驱动之前要先想明白一个事这个驱动的核心职责不是“像读数组一样读Flash”而是要负责管理擦除、写入、状态轮询这套完整流程。NOR Flash的物理特性决定了它只能把1写成0想把0恢复成1就必须先擦除擦除后全部变成0xFF。这个特性是所有Flash操作的底层逻辑也是新手最容易踩坑的地方。因为W25Q32/Q64/Q128来自同一系列所以它们的指令集读、写、擦除是完全一致的唯一需要区分的是地址位数。W25Q32的容量是32Mbit4MB需要24位地址W25Q64是64Mbit8MB也是24位地址W25Q128是128Mbit16MB同样是24位地址。这个系列都不需要32位地址模式那是W25Q256以上的事所以同一份驱动改一下容量宏定义就能通吃这三个型号。1.2 尺寸差异驱动的关键适配点芯片型号容量bit容量Byte扇区数4KB块数64KB最大地址W25Q3232Mbit4MB1024640x3FFFFFW25Q6464Mbit8MB20481280x7FFFFFW25Q128128Mbit16MB40962560xFFFFFF这三个芯片的命令集完全一致选型需要确认具体数据手册但同系列常用型号命令都相同每颗的页大小都是256字节扇区4KB块64KB。命令集包括读 ID0x9F返回三个字节的制造商ID和设备ID写使能0x06每次写操作前必须发读状态寄存器0x05查询BUSY位判断操作是否完成写数据0x02页编程一次最多写256字节扇区擦除0x20擦除4KB最常用块擦除0xD8擦除64KB整片擦除0xC7全片擦除理解这些命令后驱动框架基本就出来了。我会在下面几节把每一块逻辑拆开讲并且直接给出现成的代码。2. 核心细节解析与实操要点2.1 SPI通信配置与硬件接线W25Q系列是标准SPI设备支持Mode 0CPOL0CPHA0和Mode 3CPOL1CPHA1。我习惯用Mode 0因为STM32的SPI外设默认配置就是Mode 0比较省事。接线方面标准四线SPISTM32引脚W25Q引脚说明PA5 (SPI1_SCK)CLK时钟PA6 (SPI1_MISO)DO主设备输入从设备输出PA7 (SPI1_MOSI)DI主设备输出从设备输入PA4 (GPIO)CS片选拉低使能拉高释放CS片选这个引脚有个细节要注意它必须用普通GPIO来控制不能用SPI硬件自动管理。因为Flash的所有命令读、写、擦除都需要在CS拉低后发指令字节并且整个命令序列期间CS必须保持低电平中间不能有断档。如果交给SPI硬件NSS自动管理往往在每次收发之间会抖动直接导致命令时序错乱。我是用STM32CubeMX配置的SPI1速率设置为18MHz左右STM32F103最大18MHz实测稳定。如果项目里SPI总线上还挂了别的设备速度可能要降一点具体看整体时序设计。2.2 驱动代码中的关键函数设计设计驱动的时候我按功能把代码拆成6大块底层SPI收发函数SPI1_ReadWriteByteFlash状态查询函数W25Q_ReadSR、W25Q_WaitBusy写使能函数W25Q_WriteEnable读写函数W25Q_ReadID、W25Q_ReadData、W25Q_WritePage擦除函数W25Q_EraseSector、W25Q_EraseChip通用封装W25Q_WriteData管理跨页写入这里重点说一下状态查询函数。为什么写Flash这么慢因为NOR Flash编程需要内部电荷泵升压这个过程要几毫秒。芯片通过BUSY位告诉我们“好了没有”所以每次写或擦除之后必须轮询读状态寄存器第0位直到它变成0。uint8_t SPI1_ReadWriteByte(uint8_t byte) { uint8_t receivedbyte 0; HAL_SPI_TransmitReceive(hspi1, byte, receivedbyte, 1, 100); return receivedbyte; } void W25Q_WriteEnable(void) { W25Q_CS_LOW(); SPI1_ReadWriteByte(0x06); // 写使能命令 W25Q_CS_HIGH(); } void W25Q_WaitBusy(void) { uint8_t status; W25Q_CS_LOW(); SPI1_ReadWriteByte(0x05); // 读状态寄存器命令 do { status SPI1_ReadWriteByte(0xFF); // 连续读取状态 } while (status 0x01); // BUSY位为1则继续等待 W25Q_CS_HIGH(); }这里有个容易忽略的编程细节读状态寄存器的时候你发完0x05命令后不能把CS拉高再重新拉低去读数据否则命令就断了。正确的做法是CS拉低后第一个字节发命令后续每个字节都是在读状态值直到BUSY位为0再把CS拉高。这也是为什么W25Q_WaitBusy函数里把CS拉低放在了外面读循环一直保持CS有效。3. 实操过程与核心环节实现3.1 初始化与读ID验证拿到一块新的Flash第一步永远是先读ID。这一步能验证三件事接线是否正确、SPI配置是否正常、芯片是不是你期望的那颗型号。uint16_t W25Q_ReadID(void) { uint16_t id 0; W25Q_CS_LOW(); SPI1_ReadWriteByte(0x9F); // JEDEC ID命令 id | SPI1_ReadWriteByte(0xFF) 8; // 制造商ID0xEF Winbond id | SPI1_ReadWriteByte(0xFF); // 设备ID W25Q_CS_HIGH(); return id; }初始化流程很简单先配置好SPI和CS引脚然后读ID打印出来确认。W25Q64的设备ID是0x40170x40是容量和版本位0x17是该系列子编号W25Q128是0x4018W25Q32是0x4016。我在调试的时候把ID直接通过串口打到上位机一眼就能看到芯片有没有正常工作。如果读出来ID全是0xFF或者0x00先别急着查驱动代码99%的情况是硬件问题或者SPI配置问题这个后面专门讲。3.2 数据读取的实现读数据是最容易的函数因为SPI Flash的读取操作不涉及擦除也不需要写使能。只要把CS拉低发读命令0x03再发三个字节的目标地址高地址在前、中地址在后、低地址最后之后就可以连续读数据了。void W25Q_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q_CS_LOW(); SPI1_ReadWriteByte(0x03); SPI1_ReadWriteByte((addr 16) 0xFF); SPI1_ReadWriteByte((addr 8) 0xFF); SPI1_ReadWriteByte(addr 0xFF); for (uint32_t i 0; i len; i) { buf[i] SPI1_ReadWriteByte(0xFF); } W25Q_CS_HIGH(); }这里有个值得说的点为什么读数据时收发字节要发0xFF因为SPI是全双工协议主机不发时钟从机就无法输出数据。你每想从MISO线上读一个字节就必须先在MOSI线上“推”一个字节出去产生时钟。0xFF只是占位符发送的内容本身无意义但时钟必须有。这也是SPI初学者最容易困惑的地方。3.3 页编程与跨页写入处理写入操作比读取复杂得多因为涉及三个约束必须先擦除、需要写使能、不能跨页写。前两个好理解第三个是新手最容易翻车的地方。所谓“不能跨页写”是指一次写命令最多只能写256字节一页而且这256字节必须落在同一个页的地址空间内。如果你从0x00FE地址开始写10个字节那这10个字节跨了0x00FE-0x00FF和0x0100-0x0107两个页一次写命令根本搞不定必须先写前2个字节再写后8个字节。所以封装一个处理跨页的写入函数就非常关键void W25Q_WriteData(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t page_remain 256 - (addr 0xFF); // 当前页剩余空间 while (len 0) { uint32_t write_len (len page_remain) ? len : page_remain; W25Q_WriteEnable(); W25Q_PageProgram(addr, buf, write_len); W25Q_WaitBusy(); addr write_len; buf write_len; len - write_len; page_remain 256; // 后续都从页首开始 } }计算当前页剩余空间的公式其实很巧妙addr 0xFF就是地址在当前页内的偏移量用256减去它得到的就是这一页还能写多少字节。比如地址是0x0103页内偏移就是3还能写253个字节。若一次性要写超过这个数就先把这253字节写掉再把目标地址对齐到下一页开头依次类推。我在实际项目中这个函数是使用频率最高的字库升级、配置存储全靠它。写之前务必确保目标扇区已擦除否则你会看到写入的数据和期望完全对不上。3.4 擦除操作与耗时管理擦除操作是Flash驱动里最耗时的环节。扇区擦除一般是几十毫秒到几百毫秒不等整片擦除可能需要几秒钟。我测过W25Q64整片擦除大概在2到3秒左右不同批次略有差异。void W25Q_EraseSector(uint32_t addr) { W25Q_WriteEnable(); W25Q_CS_LOW(); SPI1_ReadWriteByte(0x20); // 扇区擦除命令 SPI1_ReadWriteByte((addr 16) 0xFF); SPI1_ReadWriteByte((addr 8) 0xFF); SPI1_ReadWriteByte(addr 0xFF); W25Q_CS_HIGH(); W25Q_WaitBusy(); }注意擦除地址必须是4KB对齐的也就是说地址的低12位必须为0否则擦除结果会不符合预期。有的芯片允许地址包含扇区内的任意偏移但我建议写驱动时直接按对齐要求来省得后面出问题。擦除期间整个SPI总线实际上“被占用”了项目里如果有其他SPI设备挂在同一条总线上比如SD卡、LCD屏幕那擦除期间它们也必须等待。这时候就需要评估一下系统的实时性需求必要时把擦除操作放到低优先级任务里执行被更高优先级的任务打断。3.5 三个型号的通用性封装为了让驱动代码同时兼容W25Q32、W25Q64、W25Q128我在头文件里放了一个容量宏定义和容量相关的计算#define W25Q_SPI hspi1 #define W25Q_CS_GPIO_PORT GPIOA #define W25Q_CS_PIN GPIO_PIN_4 #define W25Q_SECTOR_SIZE 4096 #define W25Q_PAGE_SIZE 256 // 根据实际型号选一个 #define W25Q_TOTAL_SIZE (8 * 1024 * 1024) // W25Q64: 8MB #define W25Q_SECTOR_COUNT 2048 // W25Q64: 2048个扇区换芯片型号的时候只需要改W25Q_TOTAL_SIZE和W25Q_SECTOR_COUNT这两个宏其他代码完全不用动。这就是这一系列芯片驱动可以“一份代码养全家”的原因也是选用这个系列做产品设计的便利之处。4. 常见问题与排查技巧实录4.1 读ID失败全是0xFF这个问题的排查优先级和思路我给新手列一个顺序排查顺序检查项可能的问题1CS引脚配置GPIO模式错误未推挽输出2SPI引脚复用引脚没有被正确的复用功能配置3SPI时钟极性/相位Mode 0和Mode 3不匹配4电源和地Flash芯片没供电或者地没接好5焊接手工焊接时引脚虚焊、短路我自己遇到最多的就是CS引脚漏配置或者用了STM32CubeMX自动生成的代码但忘了手动初始化CS引脚为输出模式。这种问题有个特点调试的时候怎么查都查不出逻辑问题因为代码逻辑确实没毛病就是引脚没配置。4.2 写入数据校验失败写入数据读回后发现不对最常见的原因有两个。第一个是没有先擦除就直接写——从0变1的操作在NOR Flash里是不允许的你写之前那个字节如果是0x00你想把它写成0x01结果读回来还是0x00。排查方法很简单写入前先读目标区域的数据如果不是全0xFF先擦除再写。第二个原因是跨页写入没有处理。之前调试一个OTA升级模块每次升级到一半就失败查了半天发现是分包写入的时候某个数据包刚好横跨了两页边界程序只发了一次PageProgram命令数据就乱掉了。后来把跨页写入处理加上去问题立刻消失。4.3 擦除超时与死循环W25Q_WaitBusy函数如果设计成死循环等BUSY位变0极端情况下可能会卡死系统。比如SPI通信异常、芯片损坏、或者硬件干扰导致状态寄存器一直读不到0。稳妥的做法是加一个超时计数void W25Q_WaitBusy(void) { uint32_t timeout 0xFFFFFF; uint8_t status; W25Q_CS_LOW(); SPI1_ReadWriteByte(0x05); do { status SPI1_ReadWriteByte(0xFF); } while ((status 0x01) --timeout); W25Q_CS_HIGH(); if (timeout 0) { // 超时处理置错误标志重启SPI或者上报错误 } }这个超时机制在量产烧录、老化测试时特别有用能避免机器在生产线上无限期卡住。每次大循环里能看到这个细节我都会夸一句这个项目“靠谱”因为这种保护措施不显眼但关键时刻能救命。4.4 速率相关的奇怪问题如果把SPI时钟配置得太高超过芯片支持的极限会出现一种诡异的现场读ID正常读数据也正常但擦除后整片数据偶尔会变成随机值。这种问题非常难排查因为“偶尔”两个字会让你怀疑逻辑错误。我的经验是先把SPI分频降下来比如STM32F103的SPI1最高18MHz但实际调试时可以先用4.5MHz跑通所有验证再逐步提速。W25Q系列标称支持最高到104MHz不同型号有差异但在实际PCB走线较长、干扰较大的情况下跑太高很容易出现边缘性偶发错误。4.5 地址错位的问题因为24位地址需要分三字节发送顺序是高字节、中字节、低字节。如果顺序写反了就会出现数据写到了错误地址的情况。我见过一个案例同事地址拼接写成(addr 0xFF) 16看起来每个字节都发对了但字节顺序反了结果就是读地址0x000100的时候实际访问的是0x010000。这种问题用逻辑分析仪看一眼MOSI上的数据就一清二楚代码review反而容易漏掉。5. 测试与验证方案驱动写完别急着往项目里集成先在裸机或者单独任务里跑一遍完整测试。我常用的测试步骤如下读ID确认芯片型号正确读整片数据确认全为0xFF新的空芯片在最后一个扇区写入测试数据比如从0x00到0xFF递增回读校验擦除该扇区确认已恢复为0xFF执行整片擦除检查耗时是否在预期范围写满整片再逐扇区校验这轮测试走完驱动基本就是稳定的了。为这一步专门留一个测试用的main函数后续换其他型号芯片时也方便回归。另外如果是在RTOS环境下跑在写Flash或者擦除Flash期间要留意任务调度。如果其他任务需要访问SPI总线就需要加互斥锁。我踩过的坑是两个任务同时调用写函数结果第二个任务在第一个任务写入途中插入了一条指令直接把命令时序破坏掉了。因此我给所有Flash操作函数加上互斥保护这是RTOS环境下的必选项。6. 通用性与移植展望如果项目里用到的Flash容量比较大比如W25Q25632MB以上那驱动会稍微复杂一点因为地址位数从24位变成了32位。不过核心逻辑是一样的JEDEC ID命令、页编程命令、擦除命令的帧格式都类似只是地址字段多了一个字节需要在驱动里增加地址模式切换。W25Q32/Q64/Q128的驱动我已经在至少五个项目里直接复用过了包括智能家居网关的配置存储模块、一款工业采集设备的历史数据记录模块、还有一个小型的OTA升级接收区。每次都只需要改容量宏、核对一下SPI引脚的初始化即可稳定性和可移植性都非常好。这也是我个人觉得这个系列芯片驱动值得好好写一份的核心原因——投入一次时间省下后续无数次重复填坑。最后再分享一个小技巧如果担心Flash里关键配置数据被意外修改可以在驱动里加一个“写保护”开关默认状态下把芯片的状态寄存器SRP位设为1需要写入配置时临时解锁写完再重新上锁。这个功能在写驱动初期就预留好接口后续产品量产、现场维护时会让你省下很大心。本文还有配套的精品资源点击获取