基于CW32F030C8T6与W25Q64的SPI Flash读写驱动开发实战最近在做一个需要存储配置参数的小项目用到了W25Q64这颗SPI Flash芯片。我发现很多刚开始接触嵌入式存储的朋友对SPI Flash的驱动编写有点发怵特别是时序和擦写规则这块。今天我就以立创·地文星开发板主控是CW32F030C8T6为例手把手带你从硬件连接到代码实现完整地走一遍W25Q64的驱动开发流程。跟着做下来你就能掌握SPI Flash的基本操作以后用到类似的芯片也能举一反三。1. 认识我们的“存储卡”W25Q64在开始写代码之前咱们先得搞清楚要驱动的对象是什么。W25Q64是一颗非常常见的串行Flash存储器你可以把它理解成一张微型的、通过SPI接口访问的“SD卡”。核心特性容量64M-bit也就是8M-byte。对于存储一些日志、配置参数或者小图片资源来说完全够用。接口标准的SPI接口通信简单只需要几根线。内部结构它的存储空间不是随意读写的。内部被组织成扇区Sector和块Block。1个扇区 4KB1个块 16个扇区 64KB一个重要特性坑点Flash存储器在写入数据前必须确保目标位置是擦除状态全为0xFF。擦除的最小单位通常是扇区4KB。也就是说你想改一个字节也得先把它所在的整个4KB扇区擦成0xFF然后再写入新数据。这个特性直接决定了我们的写入流程。2. 硬件连接与引脚配置首先把开发板和W25Q64模块连起来。连接非常简单就是四根SPI线加一根片选线。连线对照表立创·地文星开发板引脚W25Q64模块引脚作用说明PA4CS (片选)主机用来选择与哪个从设备通信低电平有效。PA5CLK (时钟)主机提供的同步时钟信号。PA6DO / MISO主机输入从机输出。数据从Flash读回到MCU。PA7DI / MOSI主机输出从机输入。数据从MCU发送到Flash。注意MISO/MOSI是从主机的角度命名的。务必接对否则数据方向就反了。连接好硬件接下来在代码里配置这些引脚。我们使用CW32F030的硬件SPI1外设它正好复用在PA4~PA7这组引脚上。首先在头文件比如spi_flash.h里定义好相关的引脚和宏这样后面修改起来方便。// spi_flash.h #define BSP_GPIO_RCC() __RCC_GPIOA_CLK_ENABLE() // 使能GPIOA时钟 #define BSP_SPI1_RCC() __RCC_SPI1_CLK_ENABLE() // 使能SPI1时钟 #define BSP_GPIO_PORT CW_GPIOA // 使用的GPIO端口 #define BSP_SPI1 CW_SPI1 // 使用的SPI外设 // 引脚定义 #define BSP_SPI_NSS GPIO_PIN_4 // 片选CS 对应PA4 #define BSP_SPI_SCK GPIO_PIN_5 // 时钟SCK对应PA5 #define BSP_SPI_MISO GPIO_PIN_6 // MISO 对应PA6 #define BSP_SPI_MOSI GPIO_PIN_7 // MOSI 对应PA7 // 引脚复用功能宏将PA5/6/7设置为SPI1功能 #define W25QXX_SPI_AF_SCK() PA05_AFx_SPI1SCK() #define W25QXX_SPI_AF_MISO() PA06_AFx_SPI1MISO() #define W25QXX_SPI_AF_MOSI() PA07_AFx_SPI1MOSI() // 片选控制宏x1为高电平不选中x0为低电平选中 #define W25QXX_CS_ON(x) GPIO_WritePin(BSP_GPIO_PORT, BSP_SPI_NSS, x ? GPIO_Pin_SET : GPIO_Pin_RESET)定义好之后在初始化函数里配置引脚模式。这里有个细节SPI的NSS片选引脚我们通常用软件控制即用普通的GPIO输出模式来控制PA4而不是硬件SPI自动控制这样更灵活可以一个SPI接口挂多个设备。// spi_flash.c - bsp_spi_init() 函数的一部分 void bsp_spi_init(void) { GPIO_InitTypeDef GPIO_InitStruct1; GPIO_InitTypeDef GPIO_InitStruct2; // 1. 打开时钟 BSP_GPIO_RCC(); // 使能GPIOA时钟 BSP_SPI1_RCC(); // 使能SPI1时钟 // 2. 将PA5, PA6, PA7复用为SPI1功能 W25QXX_SPI_AF_SCK(); W25QXX_SPI_AF_MISO(); W25QXX_SPI_AF_MOSI(); // 3. 初始化SCK, MOSI, NSS引脚为推挽输出 GPIO_InitStruct1.Pins BSP_SPI_NSS | BSP_SPI_SCK | BSP_SPI_MOSI; GPIO_InitStruct1.Mode GPIO_MODE_OUTPUT_PP; // 推挽输出 GPIO_InitStruct1.Speed GPIO_SPEED_HIGH; // 高速 GPIO_Init(BSP_GPIO_PORT, GPIO_InitStruct1); // 4. 初始化MISO引脚为上拉输入用于接收数据 GPIO_InitStruct2.Pins BSP_SPI_MISO; GPIO_InitStruct2.Mode GPIO_MODE_INPUT_PULLUP; // 上拉输入 GPIO_Init(BSP_GPIO_PORT, GPIO_InitStruct2); // 5. 初始状态片选拉高不选中Flash W25QXX_CS_ON(1); // ... SPI外设初始化部分稍后继续 }3. 配置硬件SPI外设引脚配好了该配置SPI通信本身了。配置SPI就是设置通信的“规则”比如时钟多快、数据位怎么对齐等。我们需要参考W25Q64的数据手册来设置。根据手册W25Q64支持SPI模式0和模式3。我们选择模式3即时钟极性(CPOL) 1时钟空闲时为高电平。时钟相位(CPHA) 1在时钟的第二个边沿对模式3来说是下降沿采样数据。其他关键配置如下模式主机模式MCU作为主机发起通信。数据大小8位虽然地址可能是24位但我们可以分次发送。片选控制软件控制前面已经用GPIO做了。波特率预分频这里选择了8分频。CW32F030的APB2总线时钟是48MHz8分频后SPI时钟为6MHz对于W25Q64最高支持104MHz来说非常安全。数据顺序高位(MSB)在前。// spi_flash.c - bsp_spi_init() 函数的后续部分 void bsp_spi_init(void) { // ... 前面的GPIO初始化代码 SPI_InitTypeDef SPI_InitStructure; SPI_InitStructure.SPI_Direction SPI_Direction_2Lines_FullDuplex; // 双线全双工 SPI_InitStructure.SPI_Mode SPI_Mode_Master; // 主机模式 SPI_InitStructure.SPI_DataSize SPI_DataSize_8b; // 8位数据帧 SPI_InitStructure.SPI_CPOL SPI_CPOL_High; // 时钟极性高 SPI_InitStructure.SPI_CPHA SPI_CPHA_2Edge; // 第二个边沿采样 SPI_InitStructure.SPI_NSS SPI_NSS_Soft; // 软件控制片选 SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; // 8分频 SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; // 高位在前 SPI_InitStructure.SPI_Speed SPI_Speed_Low; // 低速模式 SPI_Init(BSP_SPI1, SPI_InitStructure); // 初始化SPI1 SPI_Cmd(BSP_SPI1, ENABLE); // 使能SPI1 }SPI初始化完成后我们需要一个最基础的字节收发函数。硬件SPI是全双工的发送和接收同时进行。这个函数会发送一个字节并返回接收到的字节。// spi_flash.c uint8_t spi_read_write_byte(uint8_t dat) { // 等待发送缓冲区为空表示上一个数据已发送完 while (SPI_GetFlagStatus(BSP_SPI1, SPI_FLAG_TXE) RESET); SPI_SendData(BSP_SPI1, dat); // 写入数据启动发送 // 等待接收缓冲区非空表示数据已收到 while (SPI_GetFlagStatus(BSP_SPI1, SPI_FLAG_RXNE) RESET); return SPI_ReceiveData(BSP_SPI1); // 读取接收到的数据并返回 }这个函数是后续所有高级操作读ID、读数据、写数据的基石。4. 基础操作实战读取设备ID现在硬件和底层SPI驱动都准备好了我们可以开始和W25Q64“对话”了。第一个常见的操作是读取设备ID这可以用来检测Flash芯片是否连接正常。根据W25Q64数据手册发送命令0x90再跟一个24位的地址0x000000它会先返回制造商ID0xEF再返回设备ID0x16代表W25Q64。通信时序永远是拉低CS - 发送命令/地址/数据 - 拉高CS。// spi_flash.c uint16_t W25Q64_readID(void) { uint16_t id 0; // 1. 选中设备拉低CS W25QXX_CS_ON(0); // 2. 发送读取ID命令 0x90 spi_read_write_byte(0x90); // 3. 发送24位地址 0x000000 spi_read_write_byte(0x00); spi_read_write_byte(0x00); spi_read_write_byte(0x00); // 4. 读取制造商ID高8位和设备ID低8位 id spi_read_write_byte(0xFF) 8; // 发送0xFF无意义以产生时钟同时读取数据 id | spi_read_write_byte(0xFF); // 5. 取消选中拉高CS W25QXX_CS_ON(1); return id; // 返回结果如 0xEF16 }在main函数里调用这个函数并打印如果看到输出ID EF16恭喜你硬件连接和SPI通信基本成功了5. 核心功能实现写入与读取数据读写数据是Flash的核心功能但写入流程比读取复杂因为涉及擦除和写使能。5.1 关键前提写使能与忙状态检测写使能Write EnableW25Q64默认是写保护的防止误操作。在每次擦除或写入数据前必须发送写使能命令0x06。void W25Q64_write_enable(void) { W25QXX_CS_ON(0); spi_read_write_byte(0x06); // 写使能命令 W25QXX_CS_ON(1); }忙状态检测Flash芯片执行擦除或写入操作需要时间几毫秒到几十毫秒。在此期间它处于“忙”状态不应接受新的指令。我们通过读取状态寄存器1命令0x05并检查其最低位BUSY位来判断。void W25Q64_wait_busy(void) { uint8_t status; do { W25QXX_CS_ON(0); spi_read_write_byte(0x05); // 读状态寄存器1命令 status spi_read_write_byte(0xFF); // 读取状态值 W25QXX_CS_ON(1); } while ((status 0x01) 1); // 判断BUSY位是否为1为1则等待 }提示在发送擦除或写入命令后必须调用W25Q64_wait_busy()等待操作完成才能进行下一步。5.2 扇区擦除如前所述写入前必须先擦除。擦除的最小单位是扇区4KB命令是0x20后面跟要擦除扇区的24位起始地址。void W25Q64_erase_sector(uint32_t sector_num) { // 将扇区号转换为字节地址扇区号 * 4096 uint32_t addr sector_num * 4096; W25Q64_write_enable(); // 第一步写使能 W25Q64_wait_busy(); // 等待写使能完成虽然很快但规范操作 // 发送扇区擦除命令 W25QXX_CS_ON(0); spi_read_write_byte(0x20); // 扇区擦除命令 // 发送24位地址 spi_read_write_byte((uint8_t)((addr) 16)); spi_read_write_byte((uint8_t)((addr) 8)); spi_read_write_byte((uint8_t)addr); W25QXX_CS_ON(1); W25Q64_wait_busy(); // 重要等待擦除操作完成 }5.3 页编程写入数据擦除完成后就可以写入数据了。写入命令是0x02后面跟24位起始地址然后是你要写入的数据流。W25Q64支持“页编程”一次最多可以连续写入256个字节一页。如果超过一页地址会回绕到本页开头覆盖之前写入的数据所以编程时要注意。void W25Q64_write(uint8_t* buffer, uint32_t addr, uint16_t numbyte) { uint16_t i; // 1. 擦除目标地址所在的整个扇区简化流程实际应用可能需优化 W25Q64_erase_sector(addr / 4096); // 2. 写使能 W25Q64_write_enable(); W25Q64_wait_busy(); // 3. 发送页编程命令和地址 W25QXX_CS_ON(0); spi_read_write_byte(0x02); // 页编程命令 spi_read_write_byte((uint8_t)((addr) 16)); spi_read_write_byte((uint8_t)((addr) 8)); spi_read_write_byte((uint8_t)addr); // 4. 连续写入数据 for(i 0; i numbyte; i) { spi_read_write_byte(buffer[i]); } W25QXX_CS_ON(1); // 5. 等待写入完成 W25Q64_wait_busy(); }注意上面的代码为了演示在每次write前都先擦除整个扇区。在实际项目中为了提升速度和减少擦写损耗你需要设计更复杂的管理策略比如缓存一页数据、判断是否需要擦除等。5.4 读取数据读取数据就简单多了没有擦除和忙状态等待。命令是0x03后面跟24位起始地址然后Flash就会从该地址开始持续输出数据直到CS被拉高。void W25Q64_read(uint8_t* buffer, uint32_t read_addr, uint16_t read_length) { uint16_t i; W25QXX_CS_ON(0); spi_read_write_byte(0x03); // 读数据命令 // 发送24位读取地址 spi_read_write_byte((uint8_t)((read_addr) 16)); spi_read_write_byte((uint8_t)((read_addr) 8)); spi_read_write_byte((uint8_t)read_addr); // 连续读取数据 for(i 0; i read_length; i) { // 发送0xFF产生时钟同时读取数据 buffer[i] spi_read_write_byte(0xFF); } W25QXX_CS_ON(1); }6. 整合测试让代码跑起来把上面所有的函数整合到spi_flash.c和spi_flash.h文件中。然后在主函数里写一个简单的测试流程// main.c #include board.h #include stdio.h #include bsp_uart.h #include spi_flash.h int main(void) { board_init(); // 系统初始化 uart1_init(115200); // 初始化串口用于打印 bsp_spi_init(); // 初始化SPI delay_ms(100); // 解除写保护可选有些新芯片或擦除后可能需要 // W25Q64_WriteStatus(); // 1. 读取ID printf(W25Q64 ID 0x%X\r\n, W25Q64_readID()); uint8_t buffer[20] {0}; // 2. 先读取地址0的数据应该是空的或随机值 W25Q64_read(buffer, 0, 10); printf(Before write: %s\r\n, buffer); delay_ms(10); // 3. 向地址0写入字符串 W25Q64_write((uint8_t *)Hello Flash!, 0, 12); // 注意长度包含结束符 delay_ms(10); // 等待写入完成 // 4. 再次读取验证写入是否成功 W25Q64_read(buffer, 0, 12); printf(After write: %s\r\n, buffer); // 5. 最后可以擦除这个扇区清理数据 // W25Q64_erase_sector(0); while(1) { // 主循环 } }烧录程序打开串口助手你应该能看到类似以下的输出这表明你的SPI Flash驱动已经成功运行了W25Q64 ID 0xEF16 Before write: After write: Hello Flash!7. 几个实战中的注意事项擦写寿命Flash有擦写次数限制通常10万次左右。避免在循环中频繁擦写同一扇区好的做法是使用磨损均衡算法。数据备份擦除操作是不可逆的重要数据一定要在擦除前做好备份或者确保程序逻辑正确。跨页与跨扇区写入写入数据如果跨越页边界256字节或扇区边界4096字节需要分多次操作并处理好地址计算。电源稳定性在擦除和写入期间务必保证电源稳定突然断电可能导致数据损坏或芯片锁死。这套驱动代码已经可以直接用在你的CW32F030项目里了。理解了这个流程你再遇到其他SPI接口的Flash芯片比如W25Q32、GD25Q64等只需要对照它们的数据手册修改一下命令字和地址长度基本上就能快速移植。嵌入式开发就是这样打通了一个一片就都通了。