STM32 SPI驱动W25Q64存储整数与浮点数:字节化、擦写与掉电保护实战
发布时间:2026/10/19 8:12:57 锦皓数字建站

简介这份资源面向正在学习STM32嵌入式开发、希望掌握外部Flash数据存取方法的开发者聚焦于通过SPI接口驱动W25Q64串行Flash实现整数与小数两类数据的写入和读取。内容涵盖SPI四线通信机制、W25Q64指令集调用、数据类型转换与二进制存储格式并涉及数据对齐、错误处理、擦除操作及SPI速率优化等实践要点适合具备一定C语言与单片机基础、想扩展存储能力或深入理解SPI协议的中级学习者。压缩包共77个文件约304KB以30个c源文件与32个h头文件为主体配合8个汇编启动文件、2个hex固件、Keil工程文件及sct分散加载与批处理脚本工程结构完整可直接编译调试。目前已有399人学习下载。借助现成的读写函数与工程模板读者能快速上手W25Q64的整数、小数存取流程理解底层存储原理并在此基础上完成自己的数据记录与参数保存需求。1. 从一份 W25Q64 工程包说起STM32 用 SPI 存小数和整数到底难在哪很多人第一次把温湿度、电压、电流这类带小数的数据往外部 FLASH 里写都会遇到同一个翻车现场整数写进去读出来是对的小数写进去读出来变成 0 或者一个莫名其妙的巨大值。这份名为「2-W25Q64存储小数和整数」的工程包解决的正是这个具体问题——在 STM32 上通过 SPI 驱动 W25Q64 这颗 8MB 的外部 FLASH把整数和浮点数都可靠地存进去、读出来。它适合正在做数据记录仪、参数掉电保存、日志缓存这类需求的嵌入式开发者尤其是那些已经跑通了 W25Q64 扇区擦除和页写入、但一碰到 float 就卡住的同学。W25Q64 本身只认字节它不理解什么是 int、什么是 float所有类型最终都得拆成一个个 8 位数据。这个工程包的价值不在于驱动有多复杂而在于它把「类型 → 字节流 → 存储 → 还原」这条链路走通了并且给出了可直接复用的封装思路。下面我按自己拆包复现的顺序把里面的门道和坑一条条讲清楚。2. W25Q64 与 SPI 读写先把底层时序和地址映射吃透2.1 为什么是 W25Q64而不是片内 FLASH 或 EEPROMSTM32 片内 FLASH 写之前要先擦整页擦写寿命大概 1 万次做频繁记录很快就把扇区写废了。片内 EEPROM 很多型号根本没有外挂 I2C EEPROM 容量又太小存几百条浮点记录就满了。W25Q64 是 64Mbit也就是 8MB按扇区 4KB、块 64KB 组织擦写寿命约 10 万次SPI 接口占引脚少成本低适合做中等容量的数据缓存。选它的核心理由是容量和寿命的平衡比 EEPROM 大两个数量级比 SD 卡简单不需要文件系统也能裸写。常见做法是把它当成一块「按地址访问的字节数组」自己在上层定义记录格式。2.2 SPI 模式与命令时序别在模式上栽跟头W25Q64 支持 SPI 模式 0 和模式 3CPOL 和 CPHA 的组合必须和 STM32 端一致。我一般用模式 0空闲时 SCK 低电平第一个边沿采样。初始化时先把 CS 拉高发 0xAB 读设备 ID能读到 0xEF14 这类值说明接线和模式对了。下面这段是初始化加读 ID 的骨架// SPI 初始化后先读 JEDEC ID 确认芯片在线 // 0x9F 是读 JEDEC ID 命令返回 3 字节厂商 容量高 容量低 uint8_t cmd 0x9F; uint8_t id[3]; W25Q_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_SPI_Receive(hspi1, id, 3, 100); W25Q_CS_HIGH(); // W25Q64 正常应读到 0xEF 0x40 0x17逻辑说明CS 拉低选中芯片发命令字节再连续读 3 字节。参数上超时给 100ms 足够SPI 时钟建议先降到 1MHz 调通再往上提到 10MHz 以上。如果读出来全是 0xFF先查 CS 有没有接对、MISO 有没有虚焊再查 SPI 模式。写之前必须发 0x06 写使能写完要轮询状态寄存器 0x05 的 BUSY 位等它变 0 才能发下一条命令这一步漏了就会出现「写进去读出来是旧数据」的玄学现象。2.3 地址映射扇区、页、偏移量三者关系W25Q64 的地址是 24 位范围 0x000000 到 0x7FFFFF。擦除最小单位是扇区 4KB写入最小单位是页 256 字节。跨页写会回卷到页首这是最容易踩的坑。我一般把存储区按扇区划分第 0 扇区放配置参数第 1 扇区往后放记录。每条记录固定长度比如 16 字节这样第 N 条记录的地址就是base N * 16。固定长度记录的好处是寻址简单坏处是浪费空间但对 8MB 来说无所谓。下面这个宏定义把地址计算封装起来#define SECTOR_SIZE 4096 #define PAGE_SIZE 256 #define RECORD_SIZE 16 #define RECORD_BASE 0x001000 // 从第 1 个扇区开始存记录 // 根据记录序号算绝对地址 #define RECORD_ADDR(n) (RECORD_BASE (n) * RECORD_SIZE)参数说明RECORD_BASE 选 0x001000 是为了避开第 0 扇区留给配置。RECORD_SIZE 取 16 是 2 的幂方便对齐。改记录大小时只要保证不跨扇区边界导致擦除误伤就行。3. 整数与浮点数的字节化memcpy 与 union 两条路怎么选3.1 整数存储小端序与字节拼接STM32 是小端序一个 uint32_t 变量在内存里低字节在前。存整数最直接的办法是用 memcpy 把变量的字节原样拷到缓冲区再写进 FLASH。读的时候反向 memcpy 回来。这样做的好处是不用关心字节序因为写入和读取在同一颗 MCU 上字节序天然一致。但如果数据要被上位机解析就得约定好字节序否则上位机按大端解析会得到完全错误的值。我一般会在记录头加一个字节的版本号或标志位方便以后扩展。// 存一个 int32_t 到缓冲区 int32_t temp -12345; uint8_t buf[RECORD_SIZE]; memcpy(buf, temp, sizeof(temp)); // 此时 buf[0..3] 就是 temp 的 4 个字节小端排列逻辑说明memcpy 按字节拷贝不关心类型。参数上 sizeof(temp) 是 4所以只占 buf 前 4 字节后面可以继续放别的字段。读回来时同样用 memcpy 把 4 字节还原成 int32_t。注意 buf 必须至少 4 字节且地址对齐不是必须的memcpy 会处理。3.2 浮点数存储直接 memcpy 还是放大成整数浮点数在内存里是 IEEE 754 格式4 字节 float 同样可以用 memcpy 存。但很多老工程师习惯把小数乘以 100 或 1000 变成整数再存读出来再除。两种做法各有场景直接存 float 精度不丢但跨平台解析要处理字节序和浮点格式放大成整数可读性好上位机不用懂浮点但会损失精度且要约定放大倍数。这个工程包里两种都给了示例。我一般对温度、电压这类量程固定的用放大法对通用数据用 memcpy。// 方式一直接存 float float voltage 3.14159f; memcpy(buf, voltage, sizeof(voltage)); // 方式二放大 1000 倍存整数 int32_t scaled (int32_t)(voltage * 1000.0f); memcpy(buf, scaled, sizeof(scaled)); // 读出来再除以 1000.0f 还原参数说明放大倍数取 1000 时量程要保证voltage * 1000不超过 int32_t 范围约 ±214 万对一般传感器足够。如果量程更大改用 int64_t 或降低倍数。注意浮点乘法可能有舍入误差3.14159 乘 1000 可能得到 3141.5899强转后是 3141这是正常的截断不是 bug。3.3 union 的用法与对齐陷阱union 可以让同一块内存按不同类型访问写 float 读 uint8_t 数组省去 memcpy。但 union 有对齐要求成员里如果有 double 或 int64_t整个 union 会按 8 字节对齐放在结构体里可能引入填充字节导致记录长度和预期不符。我一般不用 union 做存储因为 memcpy 更直观编译器优化后效率一样。如果非要用记得用__attribute__((packed))或#pragma pack关掉填充但这样可能影响访问效率在 Cortex-M 上非对齐访问会触发 HardFault要小心。4. 擦除、写入与读取的完整流程一个可复现的记录读写封装4.1 写记录前必须擦除擦除粒度是扇区W25Q64 只能把 1 写成 0不能把 0 写回 1所以改写数据前必须先擦除擦除后整个扇区变成 0xFF。擦除命令是 0x20后跟 24 位地址。擦一个 4KB 扇区典型耗时 45ms期间 BUSY 位为 1。如果每条记录都擦一次扇区寿命和速度都受不了。常见做法是一个扇区存 256 条 16 字节记录写满 256 条后再擦整个扇区从头写。这样擦除次数降到 1/256。下面是一个带擦除判断的写记录函数// 写第 n 条记录如果该扇区是首次写先擦除 void W25Q_WriteRecord(uint32_t n, uint8_t *data) { uint32_t addr RECORD_ADDR(n); // 判断是否扇区首条记录是则擦除整个扇区 if ((addr % SECTOR_SIZE) 0) { W25Q_SectorErase(addr); } W25Q_PageWrite(addr, data, RECORD_SIZE); }逻辑说明addr % SECTOR_SIZE 0 说明这条记录正好落在扇区开头需要先擦。参数上 RECORD_SIZE 必须小于等于 PAGE_SIZE否则要拆成多次页写。W25Q_PageWrite 内部要先发 0x06 写使能再发 0x02 页写命令加地址加数据最后轮询 BUSY。4.2 读记录与数据还原读用 0x03 命令可以连续读任意长度不受页限制。读回来先判断是不是 0xFF如果是说明这条记录还没写过。还原时按写入时的类型反向操作。// 读第 n 条记录到缓冲区 void W25Q_ReadRecord(uint32_t n, uint8_t *buf) { uint32_t addr RECORD_ADDR(n); W25Q_Read(addr, buf, RECORD_SIZE); } // 从缓冲区还原 float float GetFloat(uint8_t *buf) { float v; memcpy(v, buf, sizeof(v)); return v; }参数说明W25Q_Read 内部拉低 CS发 0x03 加 24 位地址然后连续读 RECORD_SIZE 字节。注意读之前不需要写使能。GetFloat 的 buf 必须至少 4 字节有效数据。4.3 记录格式设计头 数据 校验裸存数据没有校验读出来不知道对不对。我一般每条记录留 1 字节头比如 0xA5 表示有效、若干字节数据、1 字节校验和。校验和用累加和或 CRC8 都行。这样读的时候先看头对不对再算校验不对就丢弃。代价是记录变长但可靠性提升明显。字段长度说明头1 字节0xA5 表示有效记录序号2 字节记录写入顺序用于判断新旧数据12 字节可放 3 个 float 或 6 个 int16校验1 字节前面所有字节累加和低 8 位这个 16 字节格式正好对齐 RECORD_SIZE改字段时保持总长不变即可。5. 避坑与排查那些让我熬夜的 W25Q64 存储问题5.1 写进去读出来全是 0xFF现象调用写函数后立刻读数据全是 0xFF。原因写使能没发或者 BUSY 没等就发下一条命令或者 CS 时序不对导致命令没被识别。解决在每次写、擦前都发 0x06写完轮询 0x05 直到 BUSY 为 0用逻辑分析仪抓 CS、CLK、MOSI 三根线确认命令字节和地址正确。5.2 浮点数读出来是 0 或极大值现象存 3.14 读出来是 0 或 1.5e38。原因字节序或类型长度不匹配比如写入用 float 4 字节读取时按 int 解析或者 memcpy 长度写错。解决写入和读取用同一套类型定义加静态断言_Static_assert(sizeof(float)4, )读回来先打印原始字节对比。5.3 跨页写导致数据回卷现象写 300 字节数据前 256 字节对后 44 字节覆盖了页首。原因W25Q64 页写不能跨页超过 256 字节会回卷到当前页开头。解决写之前算好addr % PAGE_SIZE len超过 256 就拆成两次页写第二次地址跳到下一页。5.4 擦除误伤相邻数据现象擦一个扇区后隔壁扇区的数据也没了。原因擦除地址没对齐到 4KB 边界或者地址算错落到了别的扇区。解决擦除前强制addr ~(SECTOR_SIZE-1)并打印实际擦除地址确认。5.5 频繁擦写导致扇区寿命耗尽现象用了一段时间后某个扇区写不进去。原因固定往同一地址写擦除次数集中。解决做磨损均衡轮流使用多个扇区或者用「写满再擦」策略把擦除次数摊薄。6. 进阶技巧把记录区做成环形缓冲并验证掉电可靠性固定地址顺序写有个问题写满之后要么停止记录要么擦掉重来丢失历史。更好的做法是把记录区做成环形缓冲用两个指针写指针和读指针管理写满一圈后覆盖最旧记录。实现上每条记录的头字节里带一个递增序号读的时候按序号判断新旧。下面是一个简化的环形写逻辑#define MAX_RECORDS (SECTOR_SIZE * 4 / RECORD_SIZE) // 用 4 个扇区做环形 static uint32_t write_idx 0; void Ring_Write(uint8_t *data) { uint8_t rec[RECORD_SIZE]; static uint16_t seq 0; rec[0] 0xA5; memcpy(rec[1], seq, 2); memcpy(rec[3], data, 12); rec[15] 0; for (int i 0; i 15; i) rec[15] rec[i]; W25Q_WriteRecord(write_idx, rec); write_idx (write_idx 1) % MAX_RECORDS; seq; }逻辑说明write_idx 循环递增到 MAX_RECORDS 归零。seq 用于判断记录新旧读的时候找 seq 最大的那条就是最新。参数上 MAX_RECORDS 取决于分配几个扇区4 个扇区就是 1024 条记录。注意 seq 溢出后回绕比较时用有符号差值判断。验证掉电可靠性有个土办法写一条记录后立刻断电再上电读看数据是否完整。如果经常读到半条记录说明写过程中掉电了可以在记录头加一个「写完标志」只有标志置位才算有效。我一般会在写完数据后再写一个尾标志读的时候头尾都对才认。从那以后我每次做掉电存储都强制走一遍「写—断电—上电读」的循环至少测 50 次确认没有半写记录才敢交付。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。