ESP32-S3硬件SPI驱动W25Q64 Flash实战从理论到完整代码实现最近在做一个ESP32-S3的项目需要存储一些配置数据和日志W25Q64这颗8MB的SPI Flash芯片就成了我的首选。刚开始调驱动的时候被ESP-IDF的SPI配置搞得有点懵特别是那些地址位、命令位、dummy位的设置。今天我就把整个实战过程整理出来从SPI基础原理到完整的驱动代码手把手教你搞定ESP32-S3与W25Q64的通信。这篇文章适合正在学习ESP32-S3嵌入式开发的朋友无论你是学生还是工程师跟着做一遍就能掌握SPI Flash的实际应用。咱们不扯虚的直接上干货。1. SPI通信基础先搞懂原理再写代码1.1 SPI是什么为什么用它SPISerial Peripheral Interface是一种同步串行通信协议你可以把它想象成单片机和外设之间的一条“高速公路”。这条公路有专门的规则一个主设备比如ESP32-S3控制着交通可以连接多个从设备比如W25Q64 Flash但每次只能和一个从设备“聊天”。和I2C相比SPI最大的特点是速度快、全双工能同时收发数据。对于W25Q64这种需要快速读写的存储芯片SPI是最合适的选择。1.2 SPI的四根线各司其职SPI通信需要四根线咱们来认识一下信号线全称方向作用SCLKSerial Clock主→从时钟信号主设备发出同步数据传输MOSIMaster Out Slave In主→从主设备发送数据给从设备MISOMaster In Slave Out从→主从设备发送数据给主设备CS/SSChip Select/Slave Select主→从片选信号低电平选中从设备接线时要注意主设备的MOSI接从设备的MOSI主设备的MISO接从设备的MISOSCLK直接相连。CS线是每个从设备单独一根主设备通过拉低对应的CS线来选择要和哪个从设备通信。1.3 SPI的四种模式时钟的脾气这是很多初学者容易搞混的地方。SPI有四种工作模式由两个参数决定时钟极性CPOL时钟空闲时的电平CPOL0空闲时SCLK为低电平CPOL1空闲时SCLK为高电平时钟相位CPHA数据在哪个时钟边沿采样CPHA0在第一个时钟边沿采样数据CPHA1在第二个时钟边沿采样数据组合起来就是四种模式模式CPOLCPHA空闲电平采样边沿模式000低电平上升沿模式101低电平下降沿模式210高电平下降沿模式311高电平上升沿注意W25Q64默认使用模式0这也是最常用的模式。如果你用其他SPI设备一定要查数据手册确认模式2. ESP32-S3的SPI控制器硬件资源分配2.1 四个SPI控制器我们能用哪两个ESP32-S3内置了四个SPI控制器SPI0和SPI1内部专用用于访问外部Flash和PSRAM我们动不了SPI2和SPI3通用SPI控制器我们可以自由使用这意味着我们只能用SPI2或SPI3来连接W25Q64。我一般用SPI2因为它的引脚比较灵活。2.2 硬件SPI的优势让CPU喘口气硬件SPI最大的好处是解放CPU。配置好参数后数据传输由硬件自动完成CPU可以去做其他事情。ESP32-S3的硬件SPI支持最高80MHz时钟频率IO直连时DMA传输不占用CPU可配置的命令、地址、数据位长度多种SPI模式3. W25Q64 Flash芯片认识我们的存储仓库3.1 芯片基本信息W25Q64是一颗64Mbit8MB的SPI Flash芯片内部按以下结构组织页Page256字节是最小的编程单位扇区Sector4KB16页是最小的擦除单位块Block64KB16个扇区重要提醒Flash在写入数据前必须先擦除而且擦除是以扇区4KB为单位的。你不能只擦除几个字节必须整扇区擦除。3.2 关键操作指令W25Q64通过指令来操作常用的几个指令我列在下面后面写代码时会用到指令名称指令码功能说明读设备ID0x9F读取芯片的制造商ID和设备ID写使能0x06在执行写或擦除操作前必须发送读状态寄存器0x05读取状态判断芯片是否忙扇区擦除0x20擦除一个4KB扇区页编程0x02写入一页数据最多256字节读数据0x03从指定地址读取数据4. 硬件连接把线接对是成功的一半4.1 引脚连接表根据原始文章中的代码定义ESP32-S3与W25Q64的连接如下ESP32-S3引脚W25Q64引脚功能说明GPIO14CLK时钟信号GPIO13DI或MOSI主设备输出从设备输入GPIO12DO或MISO从设备输出主设备输入GPIO15CS片选信号低电平有效3.3VVCC电源3.3VGNDGND地提示WP写保护和HOLD保持引脚在开发板上通常悬空或接高电平我们的代码中设置为-1表示不使用。5. 驱动代码实现手把手写bsp_spi5.1 头文件定义先把规矩定好先来看bsp_spi.h这里定义了所有常量和函数声明#ifndef _BSP_SPI_H__ #define _BSP_SPI_H__ #include driver/spi_master.h #include driver/spi_common.h #include hal/gpio_types.h #include string.h // 定义Flash实验所需要的引脚 #define Flash_SPI SPI2_HOST // 使用SPI2控制器 #define Flash_SPI_MISO GPIO_NUM_12 // MISO引脚 #define Flash_SPI_MOSI GPIO_NUM_13 // MOSI引脚 #define Flash_SPI_SCLK GPIO_NUM_14 // 时钟引脚 #define Flash_SPI_CS GPIO_NUM_15 // 片选引脚 #define Flash_SPI_WP -1 // 写保护引脚未使用 #define Flash_SPI_HD -1 // 保持引脚未使用 // 定义设备参数 #define Flash_CLK_SPEED 6 * 1000 * 1000 // 6MHz时钟 #define Flash_Address_Bits 3*8 // 地址位长度3字节24位 #define Flash_Command_Bits 1*8 // 命令位长度1字节 #define Flash_Dummy_Bits 0*8 // dummy位长度0字节 #define SPI_Flash_PageSize 256 // 页写入最大值 // 定义W25Q64操作指令 #define W25X_JedecDeviceID 0x9F // 获取flash ID #define W25X_WriteEnable 0x06 // 写入使能 #define W25X_WriteDisable 0x04 // 禁止写入 #define W25X_ReadStatusReg 0x05 // 读取状态寄存器 #define W25X_SectorErase 0x20 // 扇区擦除4KB #define W25X_BlockErase 0xD8 // 块擦除64KB #define W25X_ChipErase 0xC7 // 芯片擦除 #define W25X_PageProgram 0x02 // 页写入 #define W25X_ReadData 0x03 // 数据读取 #define Dummy_Byte 0xFF // 空指令用于填充发送缓冲区 #define WIP_Flag 0x01 // flash忙碌标志位状态寄存器第0位 #define WIP_SET 1 // 函数声明 esp_err_t w25q64_init_config(spi_device_handle_t* handle); uint32_t bsp_spi_flash_ReadID(spi_device_handle_t handle); void bsp_spi_flash_SectorErase(spi_device_handle_t handle, uint32_t SectorAddr); void bsp_spi_flash_PageWrite(spi_device_handle_t handle, uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite); void bsp_spi_flash_BufferWrite(spi_device_handle_t handle, uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite); void bsp_spi_flash_BufferRead(spi_device_handle_t handle, uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite); #endif5.2 初始化函数搭建通信桥梁初始化是第一步也是最关键的一步。这里要配置SPI总线和设备参数esp_err_t w25q64_init_config(spi_device_handle_t* handle) { esp_err_t e; // 错误标志 // 1. 配置SPI总线 static spi_bus_config_t bus_cfg; bus_cfg.miso_io_num Flash_SPI_MISO; // MISO引脚 bus_cfg.mosi_io_num Flash_SPI_MOSI; // MOSI引脚 bus_cfg.sclk_io_num Flash_SPI_SCLK; // 时钟引脚 bus_cfg.quadhd_io_num Flash_SPI_HD; // Quad模式HD引脚未使用 bus_cfg.quadwp_io_num Flash_SPI_WP; // Quad模式WP引脚未使用 bus_cfg.max_transfer_sz 4092; // 最大传输大小DMA模式 bus_cfg.flags SPICOMMON_BUSFLAG_MASTER; // 设置为主机模式 // 初始化SPI总线 e spi_bus_initialize(Flash_SPI, bus_cfg, SPI_DMA_CH_AUTO); if (e ! ESP_OK) { printf(SPI总线初始化失败!\n); return e; } // 2. 配置SPI设备W25Q64 static spi_device_interface_config_t interface_cfg; interface_cfg.address_bits Flash_Address_Bits; // 地址位24位3字节 interface_cfg.command_bits Flash_Command_Bits; // 命令位8位1字节 interface_cfg.dummy_bits Flash_Dummy_Bits; // dummy位0 interface_cfg.clock_speed_hz Flash_CLK_SPEED; // 时钟频率6MHz interface_cfg.mode 0; // SPI模式0 interface_cfg.spics_io_num Flash_SPI_CS; // 片选引脚 interface_cfg.duty_cycle_pos 0; // 时钟占空比50% interface_cfg.queue_size 6; // 传输队列长度 // 3. 添加SPI设备到总线 e spi_bus_add_device(Flash_SPI, interface_cfg, handle); if (e ! ESP_OK) { printf(SPI设备添加失败!\n); return e; } return ESP_OK; }这里有几个关键点需要注意max_transfer_szDMA模式下最大传输4092字节非DMA模式只有64字节clock_speed_hz我设为6MHz比较稳定。如果IO直连最高可以到80MHzmode 0W25Q64使用SPI模式0queue_size传输队列长度表示可以挂起多少个SPI事务5.3 读取设备ID验证通信是否正常读取设备ID是验证SPI通信是否成功的第一个测试uint32_t bsp_spi_flash_ReadID(spi_device_handle_t handle) { esp_err_t e; // 发送空指令用于接收数据 uint8_t data[3] {Dummy_Byte, Dummy_Byte, Dummy_Byte}; uint32_t Temp; // 使用扩展事务结构体因为指令长度与默认不同 spi_transaction_ext_t ext; memset(ext, 0, sizeof(ext)); ext.command_bits 8; // 指令位长度8位 ext.address_bits 0; // 地址位长度0 ext.base.cmd W25X_JedecDeviceID; // 读取ID指令0x9F ext.base.length 3 * 8; // 接收3字节数据 ext.base.tx_buffer data; // 发送缓冲区发送空字节 ext.base.rx_buffer NULL; // 使用内部接收缓冲区 ext.base.flags SPI_TRANS_VARIABLE_CMD | SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_USE_RXDATA; // 执行SPI传输 e spi_device_polling_transmit(handle, ext.base); if (e ! ESP_OK) { printf(读取ID失败!\n); return 0; } // 组合接收到的3字节ID uint8_t temp0 ext.base.rx_data[0]; // 制造商ID uint8_t temp1 ext.base.rx_data[1]; // 存储器类型 uint8_t temp2 ext.base.rx_data[2]; // 容量 Temp (temp0 16) | (temp1 8) | temp2; return Temp; }提示W25Q64的设备ID通常是0xEF4017Winbond制造商。如果读出来是这个值说明SPI通信正常。5.4 写使能和等待Flash操作的前置条件Flash在写入或擦除前必须先使能写操作而且操作完成后要等待芯片就绪void bsp_spi_flash_WriteEnable(spi_device_handle_t handle) { esp_err_t e; spi_transaction_ext_t ext; memset(ext, 0, sizeof(ext)); ext.command_bits 8; // 指令位长度8位 ext.address_bits 0; // 地址位长度0 ext.base.cmd W25X_WriteEnable; // 写使能指令0x06 ext.base.length 0; // 不需要发送数据 ext.base.flags SPI_TRANS_VARIABLE_CMD | SPI_TRANS_VARIABLE_ADDR; e spi_device_polling_transmit(handle, ext.base); if (e ! ESP_OK) { printf(写使能失败!\n); } } void bsp_spi_flash_WaitForWriteEnd(spi_device_handle_t handle) { spi_transaction_ext_t ext; memset(ext, 0, sizeof(ext)); ext.command_bits 8; // 指令位长度8位 ext.address_bits 0; // 地址位长度0 ext.base.cmd W25X_ReadStatusReg; // 读状态寄存器指令0x05 ext.base.length 1 * 8; // 读取1字节 ext.base.rx_buffer NULL; // 使用内部缓冲区 ext.base.tx_buffer NULL; // 使用内部缓冲区 ext.base.tx_data[0] Dummy_Byte; // 发送空字节 ext.base.flags SPI_TRANS_VARIABLE_CMD | SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_USE_RXDATA | SPI_TRANS_USE_TXDATA; // 循环读取状态寄存器直到芯片不忙 do { spi_device_polling_transmit(handle, ext.base); } while ((ext.base.rx_data[0] WIP_Flag) WIP_SET); }这里有个坑我踩过写使能指令执行后状态寄存器的WEL位会置1但写操作完成后不会自动清零。所以每次写操作前都要重新发送写使能。5.5 扇区擦除清空存储空间Flash写入前必须先擦除擦除的最小单位是扇区4KBvoid bsp_spi_flash_SectorErase(spi_device_handle_t handle, uint32_t SectorAddr) { // 1. 使能写操作 bsp_spi_flash_WriteEnable(handle); bsp_spi_flash_WaitForWriteEnd(handle); // 2. 发送擦除指令 spi_transaction_t t; memset(t, 0, sizeof(t)); t.cmd W25X_SectorErase; // 扇区擦除指令0x20 t.addr SectorAddr; // 擦除地址必须是4KB对齐 t.length 0; // 不需要额外数据 // 3. 执行擦除 spi_device_polling_transmit(handle, t); // 4. 等待擦除完成 bsp_spi_flash_WaitForWriteEnd(handle); }重要扇区地址必须是4KB的整数倍0、4096、8192...。擦除一个扇区大约需要几十毫秒具体时间看芯片手册。5.6 页编程写入数据Flash写入的最小单位是页256字节但可以写入少于256字节void bsp_spi_flash_PageWrite(spi_device_handle_t handle, uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite) { // 1. 使能写操作 bsp_spi_flash_WriteEnable(handle); // 2. 检查写入长度 if (NumByteToWrite SPI_Flash_PageSize) { printf(写入长度超过一页!\n); return; } // 3. 配置页编程事务 spi_transaction_t t; memset(t, 0, sizeof(t)); t.cmd W25X_PageProgram; // 页编程指令0x02 t.addr WriteAddr; // 写入地址 t.length 8 * NumByteToWrite; // 数据长度位 t.tx_buffer pBuffer; // 要写入的数据 t.rx_buffer NULL; // 不需要读取数据 // 4. 执行写入 spi_device_polling_transmit(handle, t); // 5. 等待写入完成 bsp_spi_flash_WaitForWriteEnd(handle); }这里有个关键限制页编程不能跨页写入。比如从地址250开始写10个字节前6个字节写入当前页后4个字节会自动从下一页的0地址开始写而不是从260地址。5.7 缓冲写入处理任意长度数据实际项目中我们经常需要写入任意长度的数据。下面这个函数会自动处理页边界void bsp_spi_flash_BufferWrite(spi_device_handle_t handle, uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite) { uint8_t NumOfPage 0, NumOfSingle 0, Addr 0, count 0, temp 0; // 计算地址偏移是否页对齐 Addr WriteAddr % SPI_Flash_PageSize; // 计算距离页边界还有多少字节 count SPI_Flash_PageSize - Addr; // 计算完整页数 NumOfPage NumByteToWrite / SPI_Flash_PageSize; // 计算剩余字节数 NumOfSingle NumByteToWrite % SPI_Flash_PageSize; // 情况1地址页对齐 if (Addr 0) { if (NumOfPage 0) { // 不满一页 bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, NumByteToWrite); } else { // 超过一页 // 先写完整页 while (NumOfPage--) { bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, SPI_Flash_PageSize); WriteAddr SPI_Flash_PageSize; pBuffer SPI_Flash_PageSize; } // 再写剩余部分 bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, NumOfSingle); } } // 情况2地址不对齐 else { if (NumOfPage 0) { // 不满一页 if (NumOfSingle count) { // 当前页写不完 temp NumOfSingle - count; // 先写当前页剩余部分 bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, count); WriteAddr count; pBuffer count; // 再写下一页开始部分 bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, temp); } else { // 当前页能写完 bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, NumByteToWrite); } } else { // 超过一页 // 先写第一页不对齐部分 NumByteToWrite - count; NumOfPage NumByteToWrite / SPI_Flash_PageSize; NumOfSingle NumByteToWrite % SPI_Flash_PageSize; bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, count); WriteAddr count; pBuffer count; // 写完整页 while (NumOfPage--) { bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, SPI_Flash_PageSize); pBuffer SPI_Flash_PageSize; WriteAddr SPI_Flash_PageSize; } // 写剩余部分 if (NumOfSingle ! 0) { bsp_spi_flash_PageWrite(handle, pBuffer, WriteAddr, NumOfSingle); } } } }这个函数看起来复杂但逻辑很清晰处理三种情况地址页对齐数据不超过一页地址页对齐数据超过一页地址不对齐最复杂的情况5.8 数据读取最简单的操作读取操作相对简单没有页边界限制void bsp_spi_flash_BufferRead(spi_device_handle_t handle, uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead) { // 使能写操作读取前不需要但保持一致性 bsp_spi_flash_WriteEnable(handle); // 配置读取事务 spi_transaction_t t; memset(t, 0, sizeof(t)); t.cmd W25X_ReadData; // 读数据指令0x03 t.addr ReadAddr; // 读取地址 t.length 8 * NumByteToRead; // 读取长度位 t.tx_buffer NULL; // 不需要发送数据 t.rx_buffer pBuffer; // 接收缓冲区 // 执行读取 spi_device_polling_transmit(handle, t); // 等待操作完成 bsp_spi_flash_WaitForWriteEnd(handle); }6. 主函数测试验证驱动是否工作最后我们在main.c中写个测试程序验证整个驱动是否正常工作#include stdio.h #include inttypes.h #include sdkconfig.h #include freertos/FreeRTOS.h #include freertos/task.h #include esp_chip_info.h #include esp_flash.h #include bsp_spi.h #include esp_log.h spi_device_handle_t spi2_handle; static const char * TAG SPI_Flash_Test; void app_main(void) { uint8_t write_data[10] {0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0x00}; uint8_t read_data[10] {0}; int flash_id 0; ESP_LOGI(TAG, 开始W25Q64 Flash测试...); // 1. 初始化W25Q64 esp_err_t ret w25q64_init_config(spi2_handle); if (ret ! ESP_OK) { ESP_LOGE(TAG, Flash初始化失败!); return; } ESP_LOGI(TAG, Flash初始化成功); // 2. 读取设备ID flash_id bsp_spi_flash_ReadID(spi2_handle); ESP_LOGI(TAG, Flash ID 0x%06X, flash_id); // 3. 擦除扇区0地址0-4095 ESP_LOGI(TAG, 开始擦除扇区0...); bsp_spi_flash_SectorErase(spi2_handle, 0); ESP_LOGI(TAG, 扇区擦除完成); // 4. 写入10个字节数据到地址0 ESP_LOGI(TAG, 写入数据到地址0...); bsp_spi_flash_BufferWrite(spi2_handle, write_data, 0, 10); ESP_LOGI(TAG, 数据写入完成); // 5. 从地址0读取10个字节 ESP_LOGI(TAG, 从地址0读取数据...); bsp_spi_flash_BufferRead(spi2_handle, read_data, 0, 10); // 6. 打印读取的数据 ESP_LOGI(TAG, 读取到的数据:); for (int i 0; i 10; i) { ESP_LOGI(TAG, 地址%d: 写入0x%02X, 读取0x%02X, i, write_data[i], read_data[i]); } // 7. 验证数据是否正确 bool verify_ok true; for (int i 0; i 10; i) { if (write_data[i] ! read_data[i]) { verify_ok false; break; } } if (verify_ok) { ESP_LOGI(TAG, 数据验证成功! Flash驱动工作正常); } else { ESP_LOGE(TAG, 数据验证失败!); } // 保持运行方便查看日志 while(1) { vTaskDelay(1000 / portTICK_PERIOD_MS); } }7. 实际调试中的几个坑点时钟频率不要设太高刚开始我设了40MHz结果读写不稳定。后来降到6MHz就稳定了。如果你的布线不好或者线太长建议从低频开始测试。SPI模式一定要对W25Q64默认是模式0如果你设成模式1、2、3通信肯定失败。擦除后才能写入这是Flash的特性新买的芯片可能是空的但如果你之前写过数据再次写入前必须擦除。页边界问题这是最容易出错的地方。写入数据时如果跨页后一部分数据会从下一页的0地址开始写而不是紧跟着前一页的末尾。等待操作完成每次写或擦除操作后都要检查状态寄存器等待芯片就绪。否则后续操作可能失败。电源要稳定SPI Flash对电源比较敏感如果电源有毛刺可能导致数据错误。建议在VCC和GND之间加一个0.1μF的电容。这套驱动代码我在实际项目中用过多次稳定性很好。你可以直接复制使用根据实际需求调整引脚定义和时钟频率。如果遇到问题先用逻辑分析仪抓一下SPI波形看看时钟、数据、片选信号是否正常。