
做这个项目之前我其实已经用标准库写过好几版SPI Flash驱动了但每次换芯片、换工程都要重新翻寄存器手册过程相当折腾。后来彻底切到STM32CubeMX HAL库才发现用图形化配置SPI外设、生成初始化代码、再补业务逻辑整个开发节奏快得不是一点半点。这篇文章就基于STM32F407完整走一遍用STM32CubeMX配置SPI、驱动NOR Flash以W25Q系列为例实现数据读写的全过程包括CubeMX里的关键设置、底层驱动封装、读写擦除的完整代码以及几个我在调试时反复踩过的坑。如果你之前只用过I2C的EEPROM或者刚接触SPI协议希望往NOR Flash里存点参数、日志甚至音频数据这篇内容应该能帮你少走不少弯路。无论是准备做Bootloader、数据记录仪还是简单的离线存储SPI Flash这套玩法都是非常通用的基础技能。1. 项目概述为什么存储方案选了NOR Flash选存储芯片这件事很多新手会纠结EEPROM、NOR Flash、NAND Flash、SD卡到底用哪个。实际项目里NOR Flash最常见的应用场景就是存固件、配置参数、运行日志这类对随机读取要求高、写入量不大的数据。在STM32F407平台上通过SPI接口挂一颗W25Q128这类NOR Flash读取速度能轻松跑到几十兆比特每秒而且引脚占用极少4根信号线加电源就搞定。1.1 和EEPROM、NAND Flash对比NOR Flash的优势在哪先说说共性认识。I2C EEPROM比如AT24C256容量普遍在几KB到几百KB写入按字节操作很方便但容量一旦上到MB级别价格就很离谱。NAND Flash容量大、成本低但坏块管理、ECC校验这些逻辑复杂度直接劝退很多人更适合做文件系统级别的大容量存储。NOR Flash属于中间位置容量从1MB到64MB常见支持按字节随机读取写入时需要先擦除再写擦除粒度通常为4KB扇区。在F407这类MCU平台上SPI NOR Flash还有一个特殊价值支持内存映射模式Memory-Mapped Mode也就是XIPExecute in Place。这意味着你可以把固件放在外部Flash里上电后MCU直接像访问内部Flash一样读指令执行很多Bootloader方案就是这么设计的。虽然我们这篇文章主要讲普通读写但理解这层特性对你以后做OTA升级会有帮助。1.2 硬件接线与芯片选型参考我的测试平台用的是STM32F407VET6核心板外接一颗W25Q12816MB这是非常经典的国产兼容型号市面上常见的有华邦、兆易创新GD25Q系列、博通集成等指令集基本通用。接线建议优先使用SPI1因为STM32CubeMX对SPI1的默认引脚映射最顺手而且SPI1挂在APB2总线上时钟频率更高。信号STM32F407引脚说明SCKPA5时钟由主机输出MOSIPA7主机输出从机输入MISOPA6主机输入从机输出CSPG12可任意GPIO片选低电平有效VCC3.3VW25Q系列支持2.7~3.6VGNDGND共地必须接片选引脚我特意强调一下CubeMX里可以选硬件NSSPA4但我建议用普通GPIO做软件片选。原因放在后面章节详细说简单讲就是软件片选在控制时序上更灵活尤其在做多从机或者需要严格时序的场景时不容易踩坑。2. STM32CubeMX配置时钟树与SPI参数这张表最务实很多初学者打开STM32CubeMX就蒙了界面里那么多选项不知道点哪里。其实配置SPI外设核心就三件事时钟树、SPI参数、GPIO模式。按照下面这套流程操作基本不会出错。2.1 新建工程与时钟树设置要点打开STM32CubeMX选芯片型号时直接搜STM32F407VET6。选好后先不要急着配置引脚优先把RCC时钟树搞定。在System Core - RCC里把High Speed ClockHSE设为Crystal/Ceramic Resonator。然后进入Clock Configuration页面把HCLK那栏填到168MHzCubeMX会自动计算各总线分频。这里要特别注意SPI1挂在APB2上APB2的最高频率是84MHz而SPI外设的时钟源就是APB2时钟。所以SPI1的输入时钟是84MHz后面算波特率要用这个数。很多人会忽略的细节是如果没正确配置HSE直接用内部HSI时钟系统频率只能跑到16MHz左右SPI速率上不去之后读Flash ID时序不稳表现就是偶尔读到0xFF或者数据错乱。所以时钟树一定要先搞定这一步是为后面所有外设打基础的。2.2 SPI参数设置里的几个关键选项在Pinout Configuration页面搜索SPI1并点击然后选择Full-Duplex Master模式。此时左侧引脚图会自动把PA5、PA6、PA7分配为SPI1的SCK、MISO、MOSI这个映射对F407来说是默认的基本不会变。接着看Parameter Settings这几项是核心ModeFull-Duplex Master全双工主机模式Hardware NSS SignalDisable这里选了Disable就表示我们用软件管理片选NSS引脚不会被SPI外设自动控制Data Size8 bitsW25Q系列的命令和数据长度都是8位这个不用犹豫First BitMSB FirstSPI Flash协议规定高位先传别改Prescaler根据你要的波特率来定这个要算CPOL / CPHA设为Low / 1 Edge也就是SPI Mode 0这是W25Q系列上电默认支持的时序模式波特率这块我实际测试下来发现飞线连接时跑到10MHz以上会开始不稳定。APB2总时钟84MHzPrescaler选8的话SPI时钟就是84/810.5MHz这个速度对W25Q128来说完全在规格书范围内但实际布线环境要留余量。我建议调试阶段先用Prescaler64即1.3125MHz确保通信逻辑先跑通再逐步提高频率。注意SPI Mode 0CPOL0, CPHA0是绝大多数SPI Flash默认支持的时序模式但遇到其他外设比如某些传感器时一定要去查数据手册确认CPOL和CPHA不能想当然。3. NOR Flash工作原理为什么写之前必须先擦除第一次用SPI Flash的人最容易困惑的问题就是为什么明明写了一个字节读出来却是0xFF为什么往某个地址写数据却影响到了整个扇区这些问题背后就是NOR Flash的物理结构决定的。3.1 存储单元结构与擦除粒度NOR Flash的存储单元逻辑上是一个大数组里面分了很多扇区SectorW25Q128的扇区大小是4KB还有一个Block的概念一个Block包含16个扇区即64KB。写入数据时只能把1变成0不能把0变成1。要把0变回1只能执行擦除操作擦除的最小单位是扇区4KB。这个特性和EEPROM差别很大。EEPROM可以按字节改NOR Flash不能。所以你的写入逻辑在设计时就要提前规划写数据前如果目标地址区域已经写过数据需要先擦除整个扇区否则写进去的数据会和旧数据做“与”运算结果完全不是你想要的。我用一个生活化的比喻来理解NOR Flash就像一张只能涂黑的答题卡你每次填答案只能涂黑选项想改答案就必须把整块卡片用橡皮擦干净重来而橡皮擦的最小单位是一小块区域不能只擦一个点。3.2 状态寄存器与写操作流程W25Q系列芯片执行擦除或写入命令后内部会自动处理这个过程需要时间。怎么知道它处理完了读状态寄存器命令码05h看Bit0也就是WIPWrite In Progress位为1说明还在忙为0说明可以继续发下一条命令。这个轮询机制是SPI Flash驱动里最基本的流程控制。整个写操作流程总结如下发送写使能命令06h把状态寄存器的WEL位置1发送页编程命令02h带上24位地址和最多256字节数据轮询状态寄存器等待WIP位变为0如果需要验证读回数据比对写使能这一步很多人会漏掉。W25Q系列进入页编程或擦除前必须先把WEL位置1否则命令会被忽略。如果发现读写不生效先检查是不是忘了发写使能。3.3 W25Q128常用命令整理我把项目里会用到的核心命令整理成一张表方便你后续查命令名称命令码功能说明Write Enable0x06置位WEL擦写前必须执行Read Status Register0x05读状态寄存器查询WIP和WELRead Data0x03按字节读支持任意地址连续读Page Program0x02页编程一次最多256字节Sector Erase0x20扇区擦除4KB对齐Block Erase0xD864KB块擦除批量操作时比较快Chip Erase0xC7整片擦除耗时较长且不可逆Read JEDEC ID0x9F读芯片厂商ID和型号ID调试神器4. 代码实现从CubeMX生成到完整读写流程配置完CubeMX之后点击GENERATE CODESTM32CubeIDE会自动生成初始化代码。接下来要做的就是在生成的工程基础上封装SPI Flash的底层驱动然后写业务测试逻辑。4.1 CubeMX生成的SPI初始化代码解读打开工程里的main.c看到SPI1的初始化函数内容大概是这样的static void MX_SPI1_Init(void) { 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_64; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial 10; if (HAL_SPI_Init(hspi1) ! HAL_OK) { Error_Handler(); } }这里有个细节__HAL_RCC_SPI1_CLK_ENABLE()和 GPIO复用的初始化代码已经由CubeMX写好了也就是说SPI1的时钟和引脚复用模式AF5都已经处理完不用你手动配置。GPIO初始化部分会生成在MX_GPIO_Init()里我们把CS引脚设为推挽输出、默认拉高这个操作在CubeMX里直接配置Pin OUT就好。HAL库的SPI收发最常用的是这两个函数HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_SPI_Receive(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout);注意HAL_SPI_Transmit和HAL_SPI_Receive是分开的但SPI是全双工协议主机发送数据的同时从机也在发送。所以有些场景我们需要用HAL_SPI_TransmitReceive()同时收发。对NOR Flash这种协议读数据时就是发送命令字节的同时接收从机返回的数据因此读操作要特别处理。4.2 底层驱动封装CS控制、读ID、读数据首先封装片选控制。软件片选的精髓就是CS引脚完全由你手动拉低拉高什么时候拉低什么时候释放全在掌控中。这里我习惯用宏定义#define FLASH_CS_LOW() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET) #define FLASH_CS_HIGH() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET)读JEDEC ID是验证通信最简单的办法。W25Q128的ID是0xEF 0x40 0x18华邦GD25Q128CS则是0xC8 0x40 0x18。读ID的时序很标准拉低CS发送0x9F然后连续读3个字节最后拉高CS。void flash_read_id(uint8_t *id) { uint8_t cmd 0x9F; FLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_SPI_Receive(hspi1, id, 3, 100); FLASH_CS_HIGH(); }这里有个细节读数据时主机必须产生时钟从机才会在MISO上输出数据。而HAL_SPI_Receive在接收的同时主机SCK会持续翻转MOSI上发送的内容由硬件决定通常是0xFF或0x00。所以读数据的本质是“主机发空字节、收有效字节”。再看读数据函数。读任意地址数据不需要擦除直接发0x03命令加24位地址然后连续读地址会自动递增。比如从地址addr读len个字节到bufvoid flash_read(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; FLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 4, 100); // 连续读一边发0xFF一边收数据 uint8_t dummy 0xFF; for (uint32_t i 0; i len; i) { HAL_SPI_TransmitReceive(hspi1, dummy, buf[i], 1, 100); } FLASH_CS_HIGH(); }这段代码在数据量大的时候效率不高因为循环里每次调用HAL函数都有开销。如果追求读速度可以改成一次HAL_SPI_TransmitReceive(hspi1, dummy_buf, buf, len, timeout)用一块全0xFF的缓冲区同时发送和接收。下面的写操作函数里我也会提到类似思路。4.3 写使能、页编程与扇区擦除实现写使能是擦写前必须执行的第一步。实现很简单void flash_write_enable(void) { uint8_t cmd 0x06; FLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); FLASH_CS_HIGH(); }注意写使能命令必须在CS拉低之后马上发送发送完立刻释放CS这是固定时序。发完06h之后芯片内部就把WEL位置1了这个状态会一直保持到下一次上电、执行擦写命令、或者发送写禁用命令04h。写完使能就能擦除了。扇区擦除命令是0x20后面跟24位地址且地址必须是4KB对齐。如果地址不是4KB的整数倍芯片会把地址向下对齐到所在扇区直接擦掉整个扇区这个坑我踩过一次必须提醒你。void flash_sector_erase(uint32_t addr) { uint8_t cmd[4]; cmd[0] 0x20; cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; flash_write_enable(); FLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 4, 100); FLASH_CS_HIGH(); flash_wait_busy(); }等待忙状态函数flash_wait_busy()是驱动里非常重要的一个环节。它做的事就是轮询状态寄存器直到WIP位清零void flash_wait_busy(void) { uint8_t cmd 0x05; uint8_t status 0x01; while (status 0x01) { FLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_SPI_Receive(hspi1, status, 1, 100); FLASH_CS_HIGH(); } }页编程命令是0x02一次能写1到256字节。这里有个最关键的坑页编程不能跨页。芯片内部是按页Page管理的一页256字节如果地址落在页边界附近写入长度超过了当前页剩余空间数据会回卷到页开头把页开头的旧数据覆盖掉。所以写驱动时必须分段处理保证每一段的写入范围都在当前页内。void flash_write(uint32_t addr, const uint8_t *buf, uint32_t len) { // 按页切分每页最多写(256 - (addr % 256))字节 while (len 0) { uint32_t page_remain 256 - (addr % 256); uint32_t write_len (len page_remain) ? len : page_remain; flash_write_enable(); FLASH_CS_LOW(); uint8_t cmd[4]; cmd[0] 0x02; cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; HAL_SPI_Transmit(hspi1, cmd, 4, 100); // 连续写入write_len个字节HAL_SPI_Transmit可以一次传完 HAL_SPI_Transmit(hspi1, (uint8_t *)buf, write_len, 100); FLASH_CS_HIGH(); flash_wait_busy(); addr write_len; buf write_len; len - write_len; } }这段代码里面有个强制类型转换(uint8_t *)buf如果传入的数组是const的这样做没问题如果入口参数定义成了uint8_t *可以直接省略。HAL库的Transmit函数不修改数据内容但形参类型是uint8_t *所以有时需要做个cast。4.4 主函数里的业务验证流程写完驱动之后我在main函数里先跑一段验证逻辑确认SPI通信正常、读写数据一致int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); uint8_t id[3] {0}; flash_read_id(id); // 应该是 0xEF 0x40 0x18 uint8_t tx_buf[512]; uint8_t rx_buf[512]; memset(tx_buf, 0x00, sizeof(tx_buf)); memcpy(tx_buf, SPI NOR Flash Test Data, 23); // 擦除首扇区 flash_sector_erase(0x000000); // 写入到偏移100的位置测试跨页处理 flash_write(0x000064, tx_buf, 300); // 读回验证 memset(rx_buf, 0x00, sizeof(rx_buf)); flash_read(0x000064, rx_buf, 300); // 比对tx_buf和rx_buf一致则说明读写成功 if (memcmp(tx_buf, rx_buf, 300) 0) { // 通信和读写都正确 } else { // 走到这里就按后续章节的排查思路来 } while (1) { } }这里我把写入长度故意设为300字节就是为了验证跨页分支逻辑是否正确。从地址0x64开始写第一页剩余256-100156字节写完后剩下的144字节会写到下一页正好测试代码里page_remain的处理。5. 常见问题与排查技巧实录这段内容是我实际调试过程中最值钱的部分。很多问题书上都查不到只有亲手踩过才能体会其中的微妙。5.1 读ID一直返回0xFF或者0x00这是SPI Flash调试最常见的问题。排查思路按顺序来先确认供电W25Q128的工作电压是2.7~3.6V很多开发板用3.3V供电没问题但电源纹波大的话在擦除瞬间电流拉高会出现偶发失败再查接线MISO和MOSI是最容易接反的主机MOSI接从机MOSI主机MISO接从机MISO别把MOSI和MISO交叉对接检查CS引脚释放状态CS必须默认高电平如果悬空可能在外部噪声干扰下随机选中芯片最后用示波器看SCK波形如果SCK上没有时钟输出大概率是CubeMX里SPI模式没配成Master还有一个容易被忽略的问题如果你用的是STM32F407ZGT6这种带有多个SPI的芯片引脚复用要看清PA5/PA6/PA7在F407上确实是SPI1但如果你不小心在CubeMX里改动了某个引脚的复用功能生成的代码就可能把SPI1信号引到了别的引脚上。建议每次生成工程后先看一眼HAL_SPI_MspInit()里的GPIO初始化部分。5.2 通信偶尔正常偶尔读写数据错乱这种问题多半是时序和速率的问题。SPI时钟频率过高、线缆过长、或没有进行阻抗匹配时边沿采样会不稳定。我实测F407核心板用杜邦线连接W25Q模块1.3125MHz非常稳10.5MHz开始偶尔出错21MHz基本没法用。所以我的建议是功能调试阶段Prescaler放到64或128让通信稳定运行先把业务逻辑跑通然后在示波器配合下逐步提高频率。工业产品定型时再考虑PCB走线优化和更高速率的可能性。另外如果你的SPI设备有某个引脚需要接上拉电阻比如WP写保护引脚、HOLD引脚一定要确认它们处于无效状态通常接高电平。这两个引脚悬空时芯片内部逻辑可能莫名其妙进保护状态表现就是擦写偶尔失败。5.3 写数据后读回全是0xFF这个问题十有八九是忘了擦除。NOR Flash写入前目标扇区必须是擦除态全0xFF然后通过写数据把某些位从1变成0。如果你没擦除就往里面写读出来的结果就是旧数据和新数据做了一次“逻辑与”你看到的数据就很奇怪。另一个可能是指针越界或者页回卷。如果写入长度跨越了页边界而驱动没有做分页处理数据会回卷到页首页首原本擦除掉的数据区域就会被新数据覆盖而页尾你想要的数据根本没写进去。读回来自然对不上。5.4 擦除时间很长程序像卡死了扇区擦除时间通常在30ms到400ms之间W25Q128的规格书上标的最大值是3秒我实测一般几十毫秒。如果等待超时设置得太短或者中断频繁打断轮询循环程序可能误判超时。建议把flash_wait_busy()里的轮询超时设长一点比如一个循环计数50000次不要直接依赖HAL函数的Timeout参数因为超时参数只是HAL库内部判断用的不是硬实时。另外如果擦除整个芯片Chip Erase时间会比较长期间不能掉电。工业场景下如果你在擦除过程中突然断电坏块风险会增加。所以批量擦除时一定要确保供电稳定或者加掉电检测电路在电压跌落前停止写操作。5.5 提高速度的几个方向基础驱动跑通之后如果你想进一步榨干SPI Flash的性能可以考虑这些方向用DMA方式传输数据解放CPU尤其在大批量写日志的场景下使用双倍速率模式不STM32F407的SPI不支持DDR模式这条路走不通页编程尽量写满256字节避免小包多次传输如果数据量特别大优先用块擦除0xD8代替扇区擦除时间能省很多对于连续的大数据块考虑直接内存映射模式读取省去命令开销这些内容展开讲又是一篇文章了。建议你先跑通基础读写再根据应用场景优化。6. 最后的个人经验根据我做过的几个量产项目这里分享三点体会。第一驱动层的通用性很重要。别看现在是W25Q128将来可能换GD25Q64或者别的兼容型号。所以写驱动时把命令码、页大小、扇区大小这些参数提成宏定义换芯片时只需要改宏不要为了省事写死在代码里。第二数据可靠性设计要做在前头。NOR Flash的擦写寿命一般标称10万次看起来很多但如果你的产品频繁记录日志某个扇区很快就会逼近寿命上限。建议做磨损均衡或者至少定期迁移数据到备用扇区。一个简单做法是固定一个扇区做计数统计写满后整体搬移到下一扇区。第三调试时保留一组“裸命令”接口非常有帮助。比如预留一个测试函数可以直接通过串口命令read、write、erase这样你在现场排查问题时不用重新烧录程序就能验证Flash状态。我在项目里就是这么做的省了大量来回改代码重新编译的时间。这套SPI NOR Flash驱动的方法不只适用于F407换到F103、G0系列CubeMX配置步骤类似驱动代码稍微改改引脚和句柄就能复用。希望这篇文章能帮你快速上手少踩几个坑。