
简介面向嵌入式初学者与STM32L431RCT6评估验证的SPI Flash读写工程源码包基于STM32CubeMX生成主芯片采用STM32L431RCT6Cortex-M480MHz外接8MHz晶振配套Keil MDK工程与串口调试助手可直接编译烧写验证。包内共1151个文件以c/h源码、s启动文件、icf链接脚本、o/axf/hex编译产物等为主完整呈现CubeMX初始化配置、驱动库及编译输出链便于对照学习外设配置与底层启动流程。资源压缩包约42.75MB已有715人学习下载。内容覆盖SPI Flash驱动、读写存储示例适合新手从零理解CubeMX图形化配置、SPI时序与Flash操作也方便工程师快速评估L431芯片资源或替换选型。目录结构清晰源码注释完整借助Keil工程与调试助手可直观观察读写结果是一份能边看边练的入门验证资料。 朋友问我的STM32L431RCT6板子为什么还挂一颗SPI Flash其实这个组合不是随手加的它是我在做MCU验证时最常用的搭建方式。展开说说这次实践用STM32CubeMX配置STM32L431RCT6的SPI外设驱动一颗W25Q系列的SPI Flash完成读写擦除全流程验证。这篇文章既是学习笔记也当一次完整的动手复盘新手可以从头到尾照着做老手可以直接跳到最后看踩坑记录。先交代一下为什么我坚持用CubeMX而不是纯寄存器开发。在STM32L431这类Cortex-M4内核的MCU上SPI、DMA、Flash控制器这些外设的寄存器数量不少纯手写配置不仅费劲还容易在时钟树和引脚复用上出错。CubeMX生成的是HAL库代码对新手最大的好处是你只需要在图形界面上把引脚、时钟、外设模式点出来剩下的初始化代码框架全自动生成。更关键的是STM32CubeMX的时钟树配置是交互式的它能直观显示当前CPU频率、外设总线频率避免了你把SPI时钟配出问题来。MCU验证阶段的重点本来就不应该耗在搭环境上而是把精力放在理解SPI协议本身和Flash芯片的时序行为上。1. 硬件选型为什么是L431RCT6 SPI Flash1.1 L431RCT6这颗芯片比F103更适合新手验证很多人一说到STM32就默认用F103C8T6“神板”但如果你做的是低功耗、带I2C/SPI多路外设通信验证L431其实是更合适的选择。L431RCT6属于STM32L4系列运行主频最高80MHz虽然比F103的72MHz高不了太多但它的亮点在于内置128KB RAM256KB Flash调试时变量缓冲区放得很宽裕不用抠内存。支持1.71V到3.6V宽电压范围适合做低功耗原型验证。通信外设丰富3路SPI、3路I2C、5路UART全都有DMA映射后期做多传感器接入非常方便。拥有FPU如果你想在验证阶段顺带跑一些浮点算法不会像F103那样吃力。这套配置对一个“MCU外设芯片”验证平台来说性能完全是溢出的但溢出不是坏事它给了你折腾的余量比如后面挂更多传感器或者同时驱动多个SPI设备。1.2 为什么选SPI Flash而不是I2C EEPROM新手最常见的疑问我要存数据随便用个EEPROM不就行了吗这背后是容量和速度的代差。对比项SPI FlashW25Q64I2C EEPROMAT24C256典型容量8MB64Mbit256KB2Mbit擦写方式扇区擦除4KB字节擦写写速度页编程约0.7ms/页(256B)单字节约5ms接口速率SPI时钟可达几十MHzI2C 400kHzFast Mode使用寿命10万次擦写100万次擦写如果只是存几个配置参数、校准系数I2C EEPROM当然够用还省引脚。但MCU验证场景里经常要做的几件事——音频采样数据暂存、OTA固件备份、GUI的图标字库存储、传感器长时间日志记录——这些动辄几百KB甚至几MB的数据量EEPROM根本扛不住。SPI Flash在容量上直接高出两个数量级接口速率也快得多。所以我从一开始就把SPI Flash定为标准配置。1.3 这颗W25Q64是验证SPI时序的最佳搭档我用的Flash型号是W25Q64你换成W25Q128也几乎无差别它遵循标准的SPI NOR Flash指令集指令码固定、时序清晰、数据手册写得极其详细。对新手来说它是一个非常“友好”的SPI从设备所有操作都能通过简单的读/写命令完成寄存器状态一眼能看懂不像SD卡那样有复杂的初始化流程和命令回复格式。用裸机代码验证SPI外设是否配置正确时W25Q64只需回读一个JEDEC ID读ID指令0x9F就能确认通信链路通没通这比任何debug工具都好使。2. CubeMX工程配置从引脚到代码生成的完整步骤2.1 新建工程与芯片选择打开STM32CubeMX选择Board Selector或者直接输芯片型号STM32L431RCTx双击进入配置界面。如果你用的是带板载ST-Link的开发板Debug选项选Serial Wire这样省出两个引脚做别的验证。这块要特别提醒新建工程时不要图省事直接搜“STM32L431RCT6”空格都不打CubeMX搜索时要输入完整型号比如STM32L431RCT6在列表里看准封装和Flash/RAM容量确认是256KB Flash、64KB RAM别选成L431RBTx128KB Flash。2.2 RCC时钟配置把HSE和时钟树搞对在System Core - RCC里把HSEHigh Speed External设为Crystal/Ceramic Resonator。如果板子上有外部8MHz晶振这个选项就是让你用外部高速时钟作为系统时钟源比内部HSI要准得多后续SPI波特率计算也更省心。然后进入Clock Configuration标签页这是很多新手看着头晕的地方。L431的时钟树比F103复杂但CubeMX已经做了自动求解你只需要把HCLK那里输入80回车让工具自动分配AHB、APB1、APB2总线时钟。我个人的习惯是让APB2和APB1外设时钟尽量跑满80MHz和80MHz这样给SPI外设提供充足的时钟源。注意L431有两个SPI时钟源PCLK1APB1和PCLK2APB2分别对应SPI1/SPI2和SPI3需要在配置SPI时确认当前挂在哪个总线上。你在CubeMX的SPI配置界面能看到它的时钟频率来源如果主频80MHzAPB1是80MHz那么SPI2外设时钟就是80MHz属于最优状态。2.3 SPI引脚初始化和参数设置在Connectivity里找到SPI1Mode勾选Full-Duplex Master硬件NSS不要勾用软件控制CS引脚。为什么要用软件NSS因为SPI总线上通常挂不止一个从设备硬件NSS在多设备仲裁时特别容易出问题用普通GPIO手动拉低/拉高逻辑最直观、最可控。关于SPI参数我在验证项目里用的是参数配置值说明ModeFull-Duplex Master全双工主机模式Data Size8 BitsFlash按字节寻址First BitMSB FirstSPI Flash默认MSB先行Prescaler280MHz / 2 40MHzBaud Rate40 Mbit/sW25Q64最高支持104MHz40MHz留足余量CPOL / CPHAMode 0 (0,0)W25Q系列默认Mode 0和Mode 3都支持常用Mode 0NSSDisable软件CSPrescaler这一步是SPI配置里最容易被坑的地方。Flash芯片数据手册里会给出最大SCK时钟频率过了会导致通信不稳定。我习惯把预分频设置为2得到40MHz即使PCB走线一般信号质量也足够稳定。你要是用杜邦线飞线建议再降一档到20MHz别急着拼速度。CS引脚我用的是GPIOB12原因是我这块板子上这个引脚空余而且离SPI1的引脚PA5、PA6、PA7物理距离近飞线走线时不会跨得太远。PB12配置为GPIO_Output初始电平设为High这个也很关键Flash从设备在CS为高时处于待机状态只有当CS拉低后才开始响应。2.4 生成代码与工程结构在Project Manager里填工程名Toolchain选择MDK-ARM如果你用Keil或者选STM32CubeIDE。代码生成选项里勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”这样每个外设独立成文件SPI的代码在spi.c里GPIO在gpio.c里模块清晰。生成后打开工程你会看到main.c里已经有MX_GPIO_Init()和MX_SPI1_Init()的调用这时候先编译烧录一次确认板子能跑起来。这就是一个验证链路先保证CubeMX生成的初始化代码本身没有问题再开始写业务层代码。3. Flash驱动业务代码从读ID到写读校验3.1 为什么先读JEDEC ID拿到一块SPI Flash我做的第一件事永远是读JEDEC ID。发送命令0x9FFlash会返回三个字节Manufacturer ID厂商IDW25Q系列是0xEF、Memory Type内存类型W25Q64是0x40、Capacity容量代码W25Q64是0x17。这一条命令通关基本能说明SPI的引脚连接、GPIO配置、时钟极性、通信速率全都没问题是最有性价比的测试工具。void spi_flash_read_id(uint8_t *id) { uint8_t cmd 0x9F; HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, cmd, id, 3, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); }这里有个细节ID读取前不需要发写使能WREN命令0x06因为读操作不改变Flash内部状态。而且这条命令要连续读取三个字节中间的CS信号不能拉高必须保持低电平否则Flash会认为命令被中断。3.2 SPI发送和接收的HAL函数选择HAL库里有两个主要的SPI传输函数HAL_SPI_Transmit()只发送不接收HAL_SPI_TransmitReceive()同时收发。对SPI协议来说收发其实是同步发生的主机发一个字节的同时从机也在移位寄存器里回一个字节。所以读Flash数据时必须用TransmitReceive发0x00占位同时把从设备的数据收回来。HAL_StatusTypeDef spi_flash_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] 0x03; // Read Data命令 cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_StatusTypeDef status HAL_SPI_Transmit(hspi1, cmd, 4, HAL_MAX_DELAY); if (status HAL_OK) { uint8_t dummy 0x00; status HAL_SPI_TransmitReceive(hspi1, dummy, buf, len, HAL_MAX_DELAY); } HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); return status; }新手最容易犯的错误是在读数据阶段用HAL_SPI_Receive()这个函数在L431这种带硬件FIFO的外设上没问题但有些MCU的SPI外设需要在接收的同时发送时钟纯Receive模式可能发不出SCK时钟。最保险的做法是用TransmitReceive你发什么不重要重要的是时钟出来了数据就进来了。3.3 写Flash的完整状态机流程写使能-擦除-写入-等待写Flash比读复杂核心原因是NOR Flash的物理特性只能把1写成0要恢复成1只能通过擦除。所以写入之前必须确保目标扇区是空白的全0xFF。操作顺序是这样的发送写使能命令0x06让Flash进入可写状态。如果有旧数据先做扇区擦除0x20擦除后整个扇区4KB全部变为0xFF。发送页编程命令0x02一次最多写256字节。每发一个命令后都要轮询状态寄存器检查忙碌位BUSY位是否清零才能进行下一步。void spi_flash_write_page(uint32_t addr, uint8_t *buf, uint16_t len) { // 1. 写使能 uint8_t cmd 0x06; HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); // 2. 页编程命令 uint8_t write_cmd[4]; write_cmd[0] 0x02; write_cmd[1] (addr 16) 0xFF; write_cmd[2] (addr 8) 0xFF; write_cmd[3] addr 0xFF; HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, write_cmd, 4, HAL_MAX_DELAY); HAL_SPI_Transmit(hspi1, buf, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); // 3. 等待空闲 spi_flash_wait_busy(); }等待忙碌的函数也很简单void spi_flash_wait_busy(void) { uint8_t cmd 0x05; // Read Status Register-1 uint8_t status 0x00; do { HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, cmd, status, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET); } while (status 0x01); // BUSY位为1时继续等 }写使能这个步骤特别容易漏。之前有个朋友问为什么写不进去我一看代码跳过了WREN命令。SPI NOR Flash的写操作有个保护机制上电后默认状态是非写使能必须在每次写之前发送0x06命令打开写锁存器。每次即使你上次写入刚成功过这次还要再发一次。3.4 跨页写入的边界处理写Flash必须遵守一个硬性限制页编程命令不能跨256字节页边界。比如你从地址0x00FF开始写10个字节前1个字节落在当前页剩下9个字节会回卷到该页起始地址也就是0x0000把不相关的数据覆盖了。这是W25Q系列最常见的“神秘Bug”。解决办法是在写入前做边界判断如果剩余待写数据超出当前页末尾就拆成两次写。我在验证代码里直接封装了一个边界判断void spi_flash_write_data(uint32_t addr, uint8_t *buf, uint32_t len) { uint16_t page_size 256; uint16_t offset addr % page_size; // 当前地址在页内偏移 uint16_t remaining_in_page page_size - offset; uint16_t chunk; while (len 0) { chunk (len remaining_in_page) ? remaining_in_page : len; spi_flash_write_page(addr, buf, chunk); addr chunk; buf chunk; len - chunk; remaining_in_page page_size; // 跨页后重新计算 } }3.5 完整的验证函数写一包随机数读回来对比业务层写完我在main里写了一个自检流程uint8_t write_buf[256]; uint8_t read_buf[256]; for (int i 0; i 256; i) { write_buf[i] i * 3 1; } spi_flash_write_data(0x00001000, write_buf, sizeof(write_buf)); spi_flash_read(0x00001000, read_buf, sizeof(read_buf)); if (memcmp(write_buf, read_buf, sizeof(write_buf)) 0) { // 验证通过 } else { // 验证失败错误处理 }选地址0x00001000是因为W25Q64的扇区00x000000~0x000FFF往往存放着出厂信息或者用户习惯性保留避开头部地址是一个好习惯。你完全可以用逻辑分析仪抓SPI波形能直观看到每个字节的时序是否符合预期这可是纯软件debug给不了的证据。实操心得写读校验时不要只用全0、全1这类固定数据用递增数或者伪随机数序列更能暴露数据线粘连、字节错位之类的问题。我吃过一次亏某颗芯片D2线虚焊写全0全1测不出来换成递增数后立刻现形。4. 深入应用场景MCU验证还能顺便玩出这些花样4.1 做一份简易日志存储系统既然SPI Flash容量有8MB浪费太可惜。验证完通信我做了一个简易环形日志系统启动后每秒钟把系统运行状态当前温度、ADC采样值、主循环执行时间写入Flash掉电后上电再读出来。这个场景能验证两件事一是Flash的连续写入稳定性页编程扇区擦除循环二是MCU在频繁访问Flash时主循环的实时性是否会受影响。实际做下来用40MHz SPI时钟单次页编程加等待时间大约在1ms内写256字节日志条目完全无感。但要注意日志到了扇区边界一定要做擦除擦除一个4KB扇区大约需要60ms左右这段时间主机必须等待。如果这时候系统里有实时性要求高的任务要么挂DMA并回调通知要么先把待写数据缓存起来等擦除完成后再写入。4.2 SPIDMA释放CPU的一大步MCU验证到中后期你会发现SPI阻塞式传输虽然简单但大量时间浪费在while循环里等SPI外设把数据搬完。DMA模式能把这部分时间还给CPU。CubeMX里配置SPI1的TX/RX DMA通道为Normal模式传输完成后进中断回调。DMA验证的坑主要在缓冲区生命周期上DMA是“后台”搬运的你的源数组在传输结束前不能释放或修改。我习惯用__ALIGN_BEGIN声明缓冲区确保内存对齐避免DMA访问非对齐地址时出错。另一个坑是DMA中断优先级如果优先级太低在系统繁忙时可能延迟导致SPI FIFO溢出。我的经验是SPI DMA中断优先级配成最高或次高不要跟delay死磕。4.3 用Flash做程序OTA的起点做MCU验证的最终形态之一就是OTA升级。基本思路App程序接收升级固件包暂存到外部SPI Flash等收完整后在Bootloader中跳过去从Flash逐页搬回MCU内部Flash。这个过程我会单独拆一篇说但这里可以给你一个方向把SPI Flash读写封装好之后OTA的存储层就直接有底了。至少在我当时搭建验证平台的时候这一步是必不可少的基础设施。4.4 配合PWM触发ADC采样做一个完整数据采集链路如果你对“PWM触发ADC采样”这个热搜词感兴趣其实跟SPI Flash也能串成一个完整的验证项目用定时器PWM输出脉冲信号触发ADC在精确的时间点采样采集结果通过DMA搬运缓存到一定大小后写入外部SPI Flash。这样你就把STM32的定时器、ADC、DMA、外部存储全部串在一起比一个一个外设单独测试有价值得多。这个链路里最需要注意的是触发频率和Flash写入吞吐率的匹配。如果ADC采样率是10kHz每通道两个字节一秒就是20KB数据8MB Flash大概能存400秒也就是6分钟左右。想持续记录就要考虑循环覆盖或压缩后再存储了。做这类验证时你很快会意识到MCU的瓶颈往往不是主频而是数据搬运和存储结构的设计合理性。5. 常见问题实况这些坑我都是逐个踩过来的5.1 读ID一直是0xFF或0x00这个现象基本说明SPI总线上根本没有设备响应。排查顺序放在这里你们照着捋一遍先查电源Flash的VCC是不是正常3.3V用万用笔量芯片供电引脚别只看板子电源灯亮。查接线MOSI接到了芯片的SI输入、MISO接到了SO输出这两个极易接反。我有一块万用板就是因为MOSI和MISO杜邦线插反调试了整整一个下午。查CS信号SPI从设备没有CS低电平是不会理你的。确认CS引脚复用没有被CubeMX默认占用我曾经把CS引脚配置成模拟输入怎么拉都不低。查SCK频率如果SPI时钟高于Flash支持范围也会出现通信失败把Prescaler调大降到10MHz以下再试。5.2 写使能发送成功状态寄存器却一直是0x00这种情况通常是写保护WP功能被触发了。看看Flash的WP引脚在硬件上是不是拉低了如果WP引脚为低状态寄存器里的SRP、BP位会被锁存写操作会被拒绝。市面上绝大多数W25Q开发板WP引脚直连VCC但如果是自己画的板子查一下这颗电阻和走线是否正常。还有一种隐蔽情况你对状态寄存器的非易失位比如BP3、BP4做过修改改完忘了写状态寄存器保护位掉电重上电后Flash进入了写保护模式。解决方法是执行一次0x06写使能后用0x01命令把状态寄存器写成0x00再进入正常读写流程。5.3 数据写入正常读出来高4位丢失W25Q系列默认8位数据宽度但如果你在CubeMX里把SPI的Data Size配成了16Bits就会出现高字节/低字节错位读出来的数据高4位变成了0。检查你的SPI配置是不是8位Data Size在HAL库初始化里对应SPI_DATASIZE_8BIT。这个问题很隐蔽因为不是完全不对而是“部分对”看起来像是校验公式错了。5.4 写入速度慢得离谱如果发现写256字节竟然要几百毫秒大概率是你在等待BUSY位时用了单次发送模式即每条完整命令之间CS信号被拉高拉低多次导致时间全部耗在命令拆解上。另一种可能是SPI时钟没跑到预期值用示波器或逻辑分析仪实测SCK频率确认CubeMX配置里Prescaler是否生效。实操体验后来我写Flash驱动都会做一个“超时保护”。因为HAL_SPI_TransmitReceive如果传HAL_MAX_DELAY而硬件处于异常状态函数可能永远等下去。加上超时判断比如单次操作超过100ms认为异常对后期维护省心不少。5.5 调试经验逻辑分析仪比示波器更能帮你理解时序新手阶段我强烈建议入一个便宜的逻辑分析仪几十块的8通道款就够用。SPI调试时抓SCK、MOSI、MISO、CS四根线波形一展开就能看到命令头对不对、地址对不对、数据段时序对不对、CS有没有在正确位置拉低拉高。比起用示波器一通道一通道戳针脚效率高太多了。驱动写完最好再把CubeMX生成代码里的引脚复用配置截图存一份方便后面排查“莫名其妙的引脚冲突”。6. 从“超级大循环”到“事件驱动”这次验证对架构思维的启发最后聊点软的东西也是做这套验证给我印象最深的一点裸机开发最朴素的思路都是“超级大循环”——while(1)里一个个轮询外设状态。写个读Flash函数CPU就在那儿等SPI传输完再等Flash内部擦除完一切同步执行简单、直接、容易理解。MCU验证阶段这样做完全合理。但当你把Flash驱动的DMA、中断、回调函数都加上再把ADC采样、定时器触发、串口打印全部塞进一个工程你会发现“超级大循环”已经撑不住了。多个外设都在抢CPU时间一个阻塞式轮询会让其他所有任务卡顿。这时候就需要把架构切换到“事件驱动”用中断和回调函数做事件通知主循环只做低优先级的状态机和数据搬运。这是我第一次在真实项目里体验到嵌入式架构升级的分水岭不是代码写得多花哨而是你开始用事件驱动思维替代盲目轮询。SPI Flash这套验证项目就是一个很好的起点先用阻塞方式把时序搞懂再用DMA中断把性能榨出来你会发现同样一颗芯片同样的功能代码结构却完全不同了。这种体验只有在动手做、踩过坑之后才能真正理解。这次分享就到这里。如果你也在跑这套配置有几个方向值得继续深入一个是Flash磨损均衡算法的简化实现一个是掉电时怎么保证最后一条日志不丢失还有一个是双Flash备份和恢复机制。等等真写起来又是几篇长文了下次有机会再聊。最后送一句实测总结CubeMX生成代码只是起点真正值钱的在于你对Flash时序的理解和对边缘情况跨页、写保护、掉电的敬畏。本文还有配套的精品资源点击获取