STM32HAL 实战解析(三十三):SPI Flash(W25Q64)驱动开发与数据管理
1. W25Q64驱动开发基础第一次接触SPI Flash时我被W25Q64这个小小的芯片惊艳到了。8MB的存储空间SPI接口价格还便宜简直就是嵌入式系统的完美搭档。不过在实际开发中我发现要充分发挥它的性能还是有不少坑要踩的。W25Q64采用标准的SPI接口支持三种工作模式标准SPI、Dual SPI和Quad SPI。对于大多数应用场景标准SPI就足够了。在STM32上使用HAL库开发时我们需要先初始化SPI外设。这里有个小技巧SPI时钟频率不要设得太高实测超过25MHz就容易出现通信错误。// SPI初始化示例 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_8; // 假设系统时钟72MHz这里分频后是9MHz 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(); }初始化完成后我们需要实现几个基础函数来操作W25Q64。首先是读取设备ID这个函数特别重要可以用来检测Flash是否连接正常。我遇到过好几次因为焊接问题导致ID读取失败的情况。2. 核心功能函数实现2.1 读设备ID实现读设备ID是验证硬件连接的第一步。W25Q64的读ID命令是0x90需要发送命令后跟3字节的dummy数据然后才能收到2字节的ID。这里有个细节要注意SPI传输时CS片选信号要保持低电平。uint16_t W25Q64_ReadID(void) { uint8_t cmd 0x90; // 读ID命令 uint8_t dummy[3] {0x00, 0x00, 0x00}; uint8_t id[2] {0}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_SPI_Transmit(hspi1, dummy, 3, HAL_MAX_DELAY); HAL_SPI_Receive(hspi1, id, 2, HAL_MAX_DELAY); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); return (id[0] 8) | id[1]; }正常情况应该返回0xEF17其中0xEF是厂商ID(Winbond)0x17是设备ID。如果读到的值不对首先要检查硬件连接然后是SPI配置是否正确。2.2 写使能与状态检查Flash有个特殊之处所有写操作(包括擦除)前都必须先使能写操作。这个设计是为了防止意外写入导致数据丢失。写使能命令很简单就是发送0x06但实际使用时我发现经常忘记检查状态寄存器。void W25Q64_WriteEnable(void) { uint8_t cmd 0x06; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); } uint8_t W25Q64_ReadStatusReg(uint8_t reg_num) { uint8_t cmd 0x05; // 默认读状态寄存器1 if(reg_num 2) cmd 0x35; else if(reg_num 3) cmd 0x15; uint8_t status; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); HAL_SPI_Receive(hspi1, status, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); return status; }状态寄存器1的第0位(BUSY)表示芯片是否忙第1位(WEL)表示写使能是否生效。在每次写操作前我都习惯先检查这两个位// 等待Flash空闲 void W25Q64_WaitForReady(void) { while(W25Q64_ReadStatusReg(1) 0x01); // 检查BUSY位 } // 检查写使能 uint8_t W25Q64_IsWriteEnabled(void) { return (W25Q64_ReadStatusReg(1) 0x02) ? 1 : 0; }3. 数据存储操作实战3.1 扇区擦除实现Flash的特性是必须先擦除才能写入擦除的最小单位是扇区(4KB)。这个限制在实际使用中需要特别注意因为这意味着即使你只想修改一个字节也得擦除整个4KB的扇区。void W25Q64_SectorErase(uint32_t addr) { uint8_t cmd[4]; cmd[0] 0x20; // 扇区擦除命令 cmd[1] (addr 16) 0xFF; // 地址高位 cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; W25Q64_WriteEnable(); W25Q64_WaitForReady(); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, HAL_MAX_DELAY); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); W25Q64_WaitForReady(); }这里有个重要细节擦除地址必须是扇区起始地址(即地址的低12位必须为0)。我曾经犯过错误传入的地址不是扇区起始地址结果擦除了错误的扇区导致重要数据丢失。3.2 页编程实现页编程是写入数据的主要方式每次最多写入256字节。超过256字节会自动回卷到页开头这会覆盖已有数据所以要特别注意。void W25Q64_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { if(len 256) len 256; // 不能超过页大小 uint8_t cmd[4]; cmd[0] 0x02; // 页编程命令 cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; W25Q64_WriteEnable(); W25Q64_WaitForReady(); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, HAL_MAX_DELAY); HAL_SPI_Transmit(hspi1, data, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); W25Q64_WaitForReady(); }在实际项目中我通常会封装一个更通用的写函数自动处理跨页写入的情况void W25Q64_WriteData(uint32_t addr, uint8_t *data, uint32_t len) { while(len 0) { uint16_t chunk 256 - (addr % 256); // 当前页剩余空间 if(chunk len) chunk len; W25Q64_PageProgram(addr, data, chunk); addr chunk; data chunk; len - chunk; } }3.3 数据读取实现读取数据相对简单没有擦除和写入的那些限制。可以连续读取任意长度的数据Flash会自动递增地址。void W25Q64_ReadData(uint32_t addr, uint8_t *buffer, uint32_t len) { uint8_t cmd[4]; cmd[0] 0x03; // 读数据命令 cmd[1] (addr 16) 0xFF; cmd[2] (addr 8) 0xFF; cmd[3] addr 0xFF; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, HAL_MAX_DELAY); HAL_SPI_Receive(hspi1, buffer, len, HAL_MAX_DELAY); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); }为了提高读取速度可以考虑使用DMA传输。特别是在需要读取大量数据时DMA可以显著降低CPU占用率。4. 高级数据管理策略4.1 文件系统设计在实际项目中直接操作原始Flash接口很不方便。我通常会实现一个简单的文件系统来管理数据。下面是一个简单的设计思路将Flash划分为多个逻辑扇区每个文件包含一个文件头记录文件名、大小等信息维护一个文件分配表(FAT)来跟踪文件位置typedef struct { char name[16]; // 文件名 uint32_t size; // 文件大小 uint32_t addr; // 起始地址 uint32_t crc; // CRC校验值 } FileHeader; #define SECTOR_SIZE 4096 #define FILE_TABLE_ADDR 0x000000 // 文件分配表放在起始地址 void FS_Init(void) { // 检查文件系统是否已初始化 uint8_t buf[SECTOR_SIZE]; W25Q64_ReadData(FILE_TABLE_ADDR, buf, SECTOR_SIZE); // 如果第一个字节是0xFF说明是全新Flash需要初始化 if(buf[0] 0xFF) { memset(buf, 0, SECTOR_SIZE); W25Q64_SectorErase(FILE_TABLE_ADDR); W25Q64_WriteData(FILE_TABLE_ADDR, buf, SECTOR_SIZE); } }4.2 磨损均衡实现Flash的每个扇区都有擦写次数限制(通常是10万次左右)。为了避免某些扇区过早损坏需要实现磨损均衡算法。我常用的方法是记录每个扇区的擦除次数写入新数据时选择擦除次数最少的扇区定期将数据迁移到其他扇区typedef struct { uint32_t erase_count; // 擦除次数 uint32_t write_pos; // 当前写入位置 } SectorInfo; void WearLeveling_Write(uint8_t *data, uint32_t len) { // 找出擦除次数最少的扇区 uint32_t min_erase 0xFFFFFFFF; uint32_t target_sector 0; for(int i1; i8*1024*1024/SECTOR_SIZE; i) { SectorInfo info; W25Q64_ReadData(FILE_TABLE_ADDR i*sizeof(SectorInfo), (uint8_t*)info, sizeof(SectorInfo)); if(info.erase_count min_erase) { min_erase info.erase_count; target_sector i; } } // 更新扇区信息 SectorInfo new_info; new_info.erase_count min_erase 1; new_info.write_pos 0; // 擦除目标扇区 W25Q64_SectorErase(target_sector * SECTOR_SIZE); // 写入数据 W25Q64_WriteData(target_sector * SECTOR_SIZE, data, len); // 更新扇区信息 W25Q64_WriteData(FILE_TABLE_ADDR target_sector*sizeof(SectorInfo), (uint8_t*)new_info, sizeof(SectorInfo)); }4.3 掉电保护机制在嵌入式系统中突然断电是常见问题。为了保证数据完整性我通常会采用以下策略重要数据写入前先备份使用校验和(如CRC32)验证数据完整性采用原子操作设计确保操作要么完全成功要么完全失败uint32_t Calculate_CRC32(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; for(uint32_t i0; ilen; i) { crc ^ data[i]; for(uint8_t j0; j8; j) { crc (crc 1) ^ (0xEDB88320 -(crc 1)); } } return ~crc; } void Safe_Write(uint32_t addr, uint8_t *data, uint32_t len) { // 1. 备份原始数据 uint8_t backup[len]; W25Q64_ReadData(addr, backup, len); // 2. 计算新数据的CRC uint32_t new_crc Calculate_CRC32(data, len); // 3. 准备写入的数据包(数据CRC) uint8_t packet[len 4]; memcpy(packet, data, len); memcpy(packet len, new_crc, 4); // 4. 写入数据 W25Q64_SectorErase(addr); W25Q64_WriteData(addr, packet, len 4); // 5. 验证写入是否正确 uint8_t verify[len 4]; W25Q64_ReadData(addr, verify, len 4); if(memcmp(packet, verify, len 4) ! 0) { // 写入失败恢复备份 W25Q64_SectorErase(addr); W25Q64_WriteData(addr, backup, len); } }5. 实际应用案例5.1 系统日志存储方案在物联网设备中系统日志非常重要。我设计了一个循环日志系统可以自动覆盖旧日志永远不会耗尽空间。#define LOG_START_ADDR 0x100000 // 日志区起始地址 #define LOG_SIZE (2*1024*1024) // 分配2MB空间给日志 typedef struct { uint32_t write_ptr; // 当前写入位置 uint32_t wrap_count; // 循环次数 } LogHeader; void Log_Init(void) { // 初始化日志系统 LogHeader header; W25Q64_ReadData(LOG_START_ADDR, (uint8_t*)header, sizeof(LogHeader)); if(header.write_ptr LOG_SIZE) { // 无效的写入指针重置日志系统 header.write_ptr sizeof(LogHeader); header.wrap_count 0; W25Q64_SectorErase(LOG_START_ADDR); W25Q64_WriteData(LOG_START_ADDR, (uint8_t*)header, sizeof(LogHeader)); } } void Log_Write(const char *message) { LogHeader header; uint16_t len strlen(message); // 读取当前状态 W25Q64_ReadData(LOG_START_ADDR, (uint8_t*)header, sizeof(LogHeader)); // 检查是否需要擦除新扇区 uint32_t sector_start (LOG_START_ADDR header.write_ptr) ~(SECTOR_SIZE-1); uint32_t sector_end sector_start SECTOR_SIZE; uint32_t new_write_ptr header.write_ptr len 2; // 预留长度字段 if(new_write_ptr LOG_SIZE) { // 循环回到开头 header.write_ptr sizeof(LogHeader); header.wrap_count; new_write_ptr header.write_ptr len 2; } else if(new_write_ptr sector_end) { // 需要擦除下一个扇区 W25Q64_SectorErase(LOG_START_ADDR sector_end); } // 写入日志长度 uint8_t len_buf[2] {len 8, len 0xFF}; W25Q64_WriteData(LOG_START_ADDR header.write_ptr, len_buf, 2); // 写入日志内容 W25Q64_WriteData(LOG_START_ADDR header.write_ptr 2, (uint8_t*)message, len); // 更新写入指针 header.write_ptr new_write_ptr; W25Q64_WriteData(LOG_START_ADDR, (uint8_t*)header, sizeof(LogHeader)); }5.2 参数配置存储方案设备参数需要频繁更新但又不能丢失。我的解决方案是使用双备份存储确保任何时候都至少有一份完整数据。#define PARAM_ADDR_1 0x200000 #define PARAM_ADDR_2 0x201000 #define PARAM_SIZE 1024 void Param_Write(uint8_t *data) { static uint8_t current_slot 0; // 0表示使用第一个slot // 计算CRC uint32_t crc Calculate_CRC32(data, PARAM_SIZE); // 准备数据包(数据CRC) uint8_t packet[PARAM_SIZE 4]; memcpy(packet, data, PARAM_SIZE); memcpy(packet PARAM_SIZE, crc, 4); // 写入非当前slot if(current_slot 0) { W25Q64_SectorErase(PARAM_ADDR_2); W25Q64_WriteData(PARAM_ADDR_2, packet, PARAM_SIZE 4); current_slot 1; } else { W25Q64_SectorErase(PARAM_ADDR_1); W25Q64_WriteData(PARAM_ADDR_1, packet, PARAM_SIZE 4); current_slot 0; } } uint8_t Param_Read(uint8_t *data) { uint8_t packet1[PARAM_SIZE 4]; uint8_t packet2[PARAM_SIZE 4]; uint32_t crc1, crc2; // 读取两个slot的数据 W25Q64_ReadData(PARAM_ADDR_1, packet1, PARAM_SIZE 4); W25Q64_ReadData(PARAM_ADDR_2, packet2, PARAM_SIZE 4); // 提取CRC memcpy(crc1, packet1 PARAM_SIZE, 4); memcpy(crc2, packet2 PARAM_SIZE, 4); // 验证CRC uint32_t calc_crc1 Calculate_CRC32(packet1, PARAM_SIZE); uint32_t calc_crc2 Calculate_CRC32(packet2, PARAM_SIZE); // 选择有效的数据 if(calc_crc1 crc1) { memcpy(data, packet1, PARAM_SIZE); return 1; } else if(calc_crc2 crc2) { memcpy(data, packet2, PARAM_SIZE); return 1; } return 0; // 两个slot都损坏 }在最近的一个智能家居项目中这套参数存储方案成功经受住了频繁断电测试没有出现一次参数丢失的情况。