简介:这份资源面向正在学习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 或 EEPROM
STM32 片内 FLASH 写之前要先擦整页,擦写寿命大概 1 万次,做频繁记录很快就把扇区写废了。片内 EEPROM 很多型号根本没有,外挂 I2C EEPROM 容量又太小,存几百条浮点记录就满了。W25Q64 是 64Mbit,也就是 8MB,按扇区 4KB、块 64KB 组织,擦写寿命约 10 万次,SPI 接口占引脚少,成本低,适合做中等容量的数据缓存。选它的核心理由是容量和寿命的平衡:比 EEPROM 大两个数量级,比 SD 卡简单,不需要文件系统也能裸写。常见做法是把它当成一块「按地址访问的字节数组」,自己在上层定义记录格式。
2.2 SPI 模式与命令时序,别在模式上栽跟头
W25Q64 支持 SPI 模式 0 和模式 3,CPOL 和 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 次,确认没有半写记录才敢交付。希望帮到你。
本文还有配套的精品资源,点击获取