STM32 零基础可移植教程 25:SPI Flash 写数据,为什么写之前必须先擦除
STM32 零基础可移植教程 25:SPI Flash 写数据,为什么写之前必须先擦除
上一篇我们已经能从 SPI Flash 的指定地址读取数据。
读数据这件事比较直接:
CS 拉低
发送 0x03
发送 3 字节地址
读取数据
CS 拉高
但是写数据不一样。
很多新手第一次写 SPI Flash,会自然地以为:
我想写什么,就直接往这个地址写什么
结果发现:
-
第一次好像能写;
-
第二次写进去不对;
-
明明写了
0x55,读出来却不是0x55; -
写完马上读,有时还是旧数据;
-
不加延时就失败,加了延时又好像能跑。
这些现象背后,其实不是玄学。
零基础读者必读:
如果你之前没接触过 Flash 存储器,这里先用一句话建立直觉:
Flash 的"写"不像在纸上写字。更像在黑板上写字——你要先把旧字擦掉,才能写新字。而且你不能只擦一个字,必须擦掉一整块区域。这篇文章就是教会你怎么"擦黑板"和"写字"。
本篇会带你从零开始理解 SPI Flash 写入的每一个步骤,以及每一步为什么必须这样做。
SPI Flash 写数据要先理解三件事:
写之前要擦除
写之前要写使能
写完或擦完要等 Flash 忙完
这一篇只做一个明确目标:
擦除一个扇区,写入一段字符串,再读回来校验
先把最小写入链路跑通。
零基础热身:几个必须提前理解的概念
在动手写代码之前,先花 5 分钟理解几个关键词。它们会在整篇文章里反复出现。
1. Flash 存储器是什么
Flash 是一种掉电不丢数据的存储器。你写进去的数据,断电再上电还在。
你的 STM32 芯片内部就有 Flash(存程序用的),本篇说的是外部 SPI Flash,通过 SPI 接口挂在 STM32 外面。
常见型号:W25Q16、W25Q32、W25Q64……后面的数字代表容量。本篇的代码适用于几乎所有 W25Qxx 系列。
2. 十六进制(Hex)是什么,为什么到处都是 0x
计算机里,一个字节(Byte)可以表示 0~255 共 256 种状态。用十进制写出来很长,用二进制更是一大串。
十六进制刚好可以用两个字符表示一个字节的 256 种可能:
十进制 255 = 二进制 1111 1111 = 十六进制 FF
十进制 170 = 二进制 1010 1010 = 十六进制 AA
十进制 0 = 二进制 0000 0000 = 十六进制 00
编程时在数字前面加 0x 表示这是十六进制数:
0xFF = 255(十进制)
0x55 = 85(十进制)
0x00 = 0(十进制)
后面你会频繁看到 0xFF、0x06、0x05 这样的写法。它们不是魔法数字,只是 SPI Flash 的"暗号"——不同的数字代表不同的命令。
3. 页(Page)和扇区(Sector):两种不同大小的"格子"
SPI Flash 芯片内部把存储空间划分成了大大小小的格子:
|
单位
|
大小
|
说明
|
| — | — | — |
|
字节(Byte)
|
1 字节
|
最小的读写单位
|
|
页(Page)
|
通常 256 字节
| 一次写入最大范围
,不能跨页写
|
|
扇区(Sector)
|
通常 4KB(4096 字节)
| 一次擦除最小单位
,要擦必须擦整个扇区
|
|
块(Block)
|
通常 64KB
|
更大级别的擦除单位
|
关键关系:
1 个扇区 = 16 页
1 页 = 256 字节
本文中你只需要记住两件事:
-
写数据时,不能一次跨过 256 字节的页边界
-
擦数据时,最少擦一个扇区(4KB),不能只擦几个字节

4. 命令(Command):你怎么"通知"Flash 做什么
你和 SPI Flash 之间的沟通方式很简单:
你通过 SPI 总线发送一个命令码,Flash 识别后执行相应操作。
比如:
|
命令码
|
含义
|
| — | — |
| 0x06 |
“我要写东西了,请打开写权限”(Write Enable)
|
| 0x05 |
“告诉我你现在忙不忙”(Read Status Register)
|
| 0x20 |
“擦除我指定的那个扇区”(Sector Erase)
|
| 0x02 |
“在指定页内写入数据”(Page Program)
|
| 0x03 |
“读出指定地址的数据”(Read Data)
|
| 0x9F |
“告诉我你是谁”(Read JEDEC ID)
|
这些命令码是芯片厂写在数据手册里的,不是随便定的。不同厂家的芯片可能略有不同,但 W25Qxx 系列基本一致。
5. 状态寄存器(Status Register):Flash 的"我忙不忙"灯
当你命令 Flash 执行擦除或写入后,它需要时间来完成。就像你让烤箱烤面包——你下了指令,但面包不会瞬间烤好。
Flash 内部有一个状态寄存器,你可以随时读取它。其中第 0 位是 BUSY 位:
BUSY = 1 → "我还在忙,先别给我发新命令"
BUSY = 0 → "忙完了,可以继续"
代码里读状态寄存器的命令是 0x05。
本篇目标
最终现象:
串口输出类似:
SPI Flash write demo
JEDEC ID: EF 40 17
Erase sector at 0x001000...
Program 27 bytes...
Read back text: STM32 SPI Flash write demo
Read back hex:
0x001000: 53 54 4D 33 32 20 53 50 49 20 46 6C 61 73 68 20
0x001010: 77 72 69 74 65 20 64 65 6D 6F 00
Verify OK.
本篇用到的外设:
SPI
GPIO Output
USART printf
本篇跑通标准:
-
能稳定读取 JEDEC ID;
-
能擦除指定扇区;
-
能写入一段字符串;
-
能读回并校验一致;
-
知道为什么写之前要擦除;
-
知道
Write Enable、Page Program、Sector Erase、BUSY分别是什么; -
知道不要在
while里反复擦写 Flash。
准备工作
本篇建议从第 24 篇工程继续做。
你需要已经具备:
|
项目
|
说明
|
| — | — |
|
STM32 开发板
|
已经能跑 SPI
|
|
SPI Flash
|
W25Qxx 或类似 SPI Flash
|
|
串口打印
|
用来看过程和校验结果
|
|
第 22 篇基础
|
能读 JEDEC ID
|
|
第 24 篇读数据
|
能从指定地址读出连续字节
|
动手前的检查清单:
在开始写本篇代码之前,请确认以下每一项:
-
[ ] 烧录上一篇的工程,串口能打印 JEDEC ID(三个十六进制数)
-
[ ] 串口输出稳定,不是乱码
-
[ ] 重复上电 3 次,JEDEC ID 每次都能读到且一致
-
[ ] SPI Flash 的 CS 引脚 User Label 设置为
SPI_FLASH_CS -
[ ] 知道自己的 SPI 用的是 SPI1 还是 SPI2
-
[ ] 万用表确认 Flash 供电电压正常(通常 3.3V)
如果 JEDEC ID 还读不出来,先回去看第 22、23 篇。写入一定能成功的必要前提是通信已经稳定。
这一篇会擦除一个扇区。
示例地址使用:
0x001000
也就是从 4KB 边界开始的一个扇区。
为什么不直接用 0x000000?
因为工程习惯上,别一上来就擦最开头的位置。
有些板子的外部 Flash 可能被用来放字体、图片、参数、日志或者其他数据。虽然你现在可能只是空模块,但教程里先养成这个习惯比较好:
演示写入,选一个明确的测试扇区
0x001000 是什么意思?
这是十六进制表示的地址。换算成十进制:
0x001000= 4096,也就是第 4KB 的位置。 Flash 地址从0x000000开始编号,0x001000正好是第 5 个扇区的开头(如果扇区大小是 4KB)。
先理解:为什么写之前必须擦除
SPI Flash 的存储单元有一个很重要的特点:
写入只能把 1 变成 0
擦除才能把 0 变回 1
Flash 擦除后的状态通常是:
0xFF
因为 0xFF 的二进制是:
1111 1111
如果你写入 0x55:
0101 0101
这相当于把某些位从 1 写成 0。
这个可以做到。
但如果一个字节原来是:
0x00 = 0000 0000
你想直接写成:
0xFF = 1111 1111
这就不行。
因为这需要把 0 变成 1。
这个动作必须靠擦除完成。
用橡皮擦来理解
想象 Flash 的每个存储单元是一张白纸上的格子:
-
擦除 = 用橡皮把所有格子擦干净(恢复成全白 /
0xFF) -
写入 = 在某些格子里用铅笔打勾(把 1 变成 0)
-
你不能用橡皮只擦一个格子——擦除一擦就是一整片(一个扇区)
-
你也不能用橡皮把铅笔打勾的格子恢复成空白——铅笔印只能靠橡皮,不能靠再描一笔
所以你可以把 SPI Flash 的写入规则先记成一句话:
写入负责把 1 改成 0,擦除负责把整片区域恢复成 1

这也是为什么很多存储逻辑会先:
擦除扇区
再写入数据
再读回校验
如果不擦除直接写,会发生什么
假设扇区里原来存了数据 0xAA(二进制 1010 1010),你想直接写成 0x55(二进制 0101 0101):
原数据: 1 0 1 0 1 0 1 0 = 0xAA
想写成: 0 1 0 1 0 1 0 1 = 0x55
逐位看:
位7: 1→0 ✓ 可以(写入能把 1 变 0)
位6: 0→1 ✗ 不行(写入不能把 0 变 1,需要擦除)
位5: 1→0 ✓ 可以
位4: 0→1 ✗ 不行
...以此类推
结果你读回来的既不是 0xAA,也不是 0x55,而是一个奇怪的值。这就是新手最常见的困惑来源。
正确的做法永远是:先擦除整个扇区(全部变 0xFF),再写入新数据。
SPI Flash 写入的基本流程
本篇写入一段字符串,完整流程是:
看起来步骤多,但每一步都有原因。
|
步骤
|
命令
|
作用
|
| — | — | — |
|
Write Enable
| 0x06 |
打开本次写/擦权限
|
|
Sector Erase
| 0x20 |
擦除一个 4KB 扇区
|
|
Read Status Register-1
| 0x05 |
读取 BUSY 位,判断 Flash 是否忙
|
|
Page Program
| 0x02 |
写入一页内的数据
|
|
Read Data
| 0x03 |
读回校验
|
这里先强调两件事。
第一,Write Enable 不是配置一次就永久有效。
很多 Flash 在一次写入或擦除完成后,写使能状态会自动清掉。
所以一般做法是:
每次擦除前,先 Write Enable
每次页写入前,也先 Write Enable
第二,Page Program 通常不能跨页随便写。
常见 W25Qxx 的页大小是:
256 字节
如果你的数据跨过 256 字节页边界,就要拆成多次页写。
本篇代码已经在 App_SPIFlash_WriteData() 里做了拆分。
每个步骤到底在做什么(新手版)
如果你是第一次接触这个流程,这里用最直白的话解释每一步:
步骤 1:读 JEDEC ID
就像打电话先问"喂,是 W25Q32 吗?"确认对方在线且型号对。连不上就说明接线或配置有问题。
步骤 2:Write Enable(写使能)
告诉 Flash “接下来我要给你发一个擦除或写入命令”。Flash 默认是锁着的,不先发这个,后面的擦除/写入全都会被忽略。
步骤 3:Sector Erase(扇区擦除)
把目标地址所在的那个 4KB 扇区全部擦成
0xFF。这步需要时间(通常几十到几百毫秒),发完命令要等着。
步骤 4:等待 BUSY 清零
反复读状态寄存器,看 Flash 是否擦完了。没擦完就不能发下一个命令。
步骤 5:再次 Write Enable
因为上一次擦除完成后,写使能自动清掉了。要写入,必须重新开一次。
步骤 6:Page Program(页写入)
把数据实际写入 Flash。注意不能跨 256 字节的页边界。
步骤 7:再次等待 BUSY 清零
写入也需要时间(通常几毫秒),等它完成。
步骤 8:读回校验
从同一地址读出来,和原始数据对比。不一致就说明某一步出了问题。

CubeMX 配置步骤
本篇不新增外设。
仍然沿用第 22 篇到第 24 篇的配置。
1. SPI 配置
建议先保持:
|
配置项
|
推荐值
|
| — | — |
|
Mode
|
Full-Duplex Master
|
|
Data Size
|
8 Bits
|
|
First Bit
|
MSB First
|
|
CPOL
|
Low
|
|
CPHA
|
1 Edge
|
|
Prescaler
|
先慢一点,比如 64 或 128
|
|
NSS
|
Software / Disable Hardware NSS
|

如果读 ID 和读数据已经稳定,不要为了写入再乱改 SPI Mode。
写入失败时,先看写使能、擦除、忙等待和地址。
零基础提示:这些 SPI 参数是什么意思?
|
参数
|
通俗解释
|
| — | — |
|
Full-Duplex Master
|
STM32 作为主设备,可以同时收发
|
|
8 Bits
|
每次传输 8 位(1 字节)数据
|
|
MSB First
|
高位先发送(比如 0x80 的 1 最先传)
|
|
CPOL = Low
|
时钟空闲时是低电平
|
|
CPHA = 1 Edge
|
在时钟的第 1 个边沿采样数据
|
|
Prescaler
|
分频系数,数值越大 SPI 越慢。初学保持低速更稳
|
|
NSS = Software
|
不用硬件 CS,我们用 GPIO 手动控制
|
SPI Mode 不要乱改。W25Qxx 支持 Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1),如果你之前能正常读 ID,说明 Mode 已经对了。写入失败几乎从来不是 Mode 的问题。
2. CS 引脚配置
CS 仍然是普通 GPIO 输出:
|
配置项
|
推荐值
|
| — | — |
|
GPIO mode
|
Output Push Pull
|
|
Output Level
|
High
|
|
Pull-up/Pull-down
|
No pull-up and no pull-down
|
|
User Label
|
SPI_FLASH_CS
|

为什么 CS 初始要拉高?
SPI Flash 的 CS 引脚是低电平有效(Active Low)。CS = 高电平时,Flash 处于"休眠/不理会"状态,总线上的数据它一概不理。只有 CS 拉低,Flash 才开始听你说话。
所以初始化时拉高 CS,让 Flash 处于安全的"待机"状态。
3. USART 配置
需要串口打印写入过程。
复用第 07 篇:
USART1
115200
8-N-1
printf 重定向

Keil 工程生成和编译
本篇配套文件:
Core/Inc/app_spi_flash.h
Core/Src/app_spi_flash.c
Core/Inc/app_spi_flash_demo.h
Core/Src/app_spi_flash_demo.c
如果你从第 24 篇工程继续做,可以用本篇 app_spi_flash.h/.c 替换上一版。
新增能力包括:
写使能
扇区擦除
页写入
多页写入拆分
忙等待
读回校验 Demo
Keil 里别忘了添加:
Core/Src/app_spi_flash.c
Core/Src/app_spi_flash_demo.c

Keil 添加文件的步骤(零基础版):
-
在 Keil 左侧工程树里找到
Core/Src分组 -
右键点击
Core/Src→ Add Existing Files to Group -
浏览到工程目录下的
Core/Src/app_spi_flash.c,选中添加 -
同样方法添加
Core/Src/app_spi_flash_demo.c -
确认两个文件出现在工程树里
如果编译报 undefined symbol,99% 是因为这步没做。
完整代码
代码文件总览
本篇涉及 4 个文件:
|
文件
|
作用
|
| — | — |
| app_spi_flash.h |
Flash 驱动头文件:声明函数、定义页/扇区大小
|
| app_spi_flash.c |
Flash 驱动实现:所有 SPI Flash 操作函数
|
| app_spi_flash_demo.h |
Demo 头文件:声明 App_SPIFlashDemo_Run()
|
| app_spi_flash_demo.c |
Demo 实现:一个完整的擦→写→读→校验流程
|
1. 新建或更新 Core/Inc/app_spi_flash.h
#ifndef APP_SPI_FLASH_H
#define APP_SPI_FLASH_H
#include "main.h"
#include <stdint.h>
#define APP_SPI_FLASH_PAGE_SIZE 256u
#define APP_SPI_FLASH_SECTOR_SIZE 4096u
typedef struct
{
uint8_t manufacturer_id;
uint8_t memory_type;
uint8_t capacity;
} App_SPIFlash_JedecID;
void App_SPIFlash_Init(void);
HAL_StatusTypeDef App_SPIFlash_ReadJedecID(App_SPIFlash_JedecID *id);
HAL_StatusTypeDef App_SPIFlash_ReadStatusReg1(uint8_t *status_reg);
HAL_StatusTypeDef App_SPIFlash_WaitUntilReady(uint32_t timeout_ms);
HAL_StatusTypeDef App_SPIFlash_WriteEnable(void);
HAL_StatusTypeDef App_SPIFlash_SectorErase(uint32_t address);
HAL_StatusTypeDef App_SPIFlash_ReadData(uint32_t address, uint8_t *buffer, uint16_t length);
HAL_StatusTypeDef App_SPIFlash_PageProgram(uint32_t address, const uint8_t *data, uint16_t length);
HAL_StatusTypeDef App_SPIFlash_WriteData(uint32_t address, const uint8_t *data, uint16_t length);
#endif
头文件里声明了什么(零基础解释):
-
APP_SPI_FLASH_PAGE_SIZE = 256:告诉代码,这个 Flash 的页是 256 字节。如果你的 Flash 页大小不同(看数据手册),改这里。 -
APP_SPI_FLASH_SECTOR_SIZE = 4096:扇区大小 4KB。同样,如果你的 Flash 不同,改这里。 -
App_SPIFlash_JedecID结构体:一个"身份证"结构,存厂商 ID(比如EF代表 Winbond)、型号(40)、容量(17)。 -
下面 9 个函数声明:就是本篇会实现的所有 Flash 操作函数。
2. 新建或更新 Core/Src/app_spi_flash.c
这份代码比较长,但逻辑不复杂。
重点看这几个函数:
App_SPIFlash_WriteEnable()
App_SPIFlash_SectorErase()
App_SPIFlash_WaitUntilReady()
App_SPIFlash_PageProgram()
App_SPIFlash_WriteData()
#include "app_spi_flash.h"
#ifndef APP_SPI_FLASH_HANDLE
#define APP_SPI_FLASH_HANDLE hspi1
#endif
#ifndef APP_SPI_FLASH_TIMEOUT_MS
#define APP_SPI_FLASH_TIMEOUT_MS 100u
#endif
#ifndef APP_SPI_FLASH_WRITE_TIMEOUT_MS
#define APP_SPI_FLASH_WRITE_TIMEOUT_MS 500u
#endif
#ifndef APP_SPI_FLASH_ERASE_TIMEOUT_MS
#define APP_SPI_FLASH_ERASE_TIMEOUT_MS 5000u
#endif
#ifndef SPI_FLASH_CS_GPIO_Port
#error "SPI_FLASH_CS_GPIO_Port is not defined. Set CS pin User Label to SPI_FLASH_CS in CubeMX."
#endif
#ifndef SPI_FLASH_CS_Pin
#error "SPI_FLASH_CS_Pin is not defined. Set CS pin User Label to SPI_FLASH_CS in CubeMX."
#endif
#define APP_SPI_FLASH_CMD_WRITE_ENABLE 0x06u
#define APP_SPI_FLASH_CMD_READ_SR1 0x05u
#define APP_SPI_FLASH_CMD_PAGE_PROGRAM 0x02u
#define APP_SPI_FLASH_CMD_READ_DATA 0x03u
#define APP_SPI_FLASH_CMD_SECTOR_ERASE 0x20u
#define APP_SPI_FLASH_CMD_JEDEC_ID 0x9Fu
#define APP_SPI_FLASH_DUMMY_BYTE 0xFFu
#define APP_SPI_FLASH_SR1_BUSY 0x01u
extern SPI_HandleTypeDef APP_SPI_FLASH_HANDLE;
static void App_SPIFlash_CS_Low(void)
{
HAL_GPIO_WritePin(SPI_FLASH_CS_GPIO_Port, SPI_FLASH_CS_Pin, GPIO_PIN_RESET);
}
static void App_SPIFlash_CS_High(void)
{
HAL_GPIO_WritePin(SPI_FLASH_CS_GPIO_Port, SPI_FLASH_CS_Pin, GPIO_PIN_SET);
}
static HAL_StatusTypeDef App_SPIFlash_Transmit(const uint8_t *tx_data, uint16_t length)
{
return HAL_SPI_Transmit(&APP_SPI_FLASH_HANDLE,
(uint8_t *)tx_data,
length,
APP_SPI_FLASH_TIMEOUT_MS);
}
static HAL_StatusTypeDef App_SPIFlash_Receive(uint8_t *rx_data, uint16_t length)
{
return HAL_SPI_Receive(&APP_SPI_FLASH_HANDLE,
rx_data,
length,
APP_SPI_FLASH_TIMEOUT_MS);
}
static HAL_StatusTypeDef App_SPIFlash_TransmitReceive(const uint8_t *tx_data,
uint8_t *rx_data,
uint16_t length)
{
return HAL_SPI_TransmitReceive(&APP_SPI_FLASH_HANDLE,
(uint8_t *)tx_data,
rx_data,
length,
APP_SPI_FLASH_TIMEOUT_MS);
}
static void App_SPIFlash_FillAddress(uint8_t *command, uint8_t cmd, uint32_t address)
{
command[0] = cmd;
command[1] = (uint8_t)((address >> 16) & 0xFFu);
command[2] = (uint8_t)((address >> 8) & 0xFFu);
command[3] = (uint8_t)(address & 0xFFu);
}
void App_SPIFlash_Init(void)
{
App_SPIFlash_CS_High();
}
HAL_StatusTypeDef App_SPIFlash_ReadJedecID(App_SPIFlash_JedecID *id)
{
uint8_t tx_buffer[4] =
{
APP_SPI_FLASH_CMD_JEDEC_ID,
APP_SPI_FLASH_DUMMY_BYTE,
APP_SPI_FLASH_DUMMY_BYTE,
APP_SPI_FLASH_DUMMY_BYTE
};
uint8_t rx_buffer[4] = {0u};
HAL_StatusTypeDef status;
if (id == 0)
{
return HAL_ERROR;
}
App_SPIFlash_CS_Low();
status = App_SPIFlash_TransmitReceive(tx_buffer, rx_buffer, 4u);
App_SPIFlash_CS_High();
if (status != HAL_OK)
{
return status;
}
id->manufacturer_id = rx_buffer[1];
id->memory_type = rx_buffer[2];
id->capacity = rx_buffer[3];
return HAL_OK;
}
HAL_StatusTypeDef App_SPIFlash_ReadStatusReg1(uint8_t *status_reg)
{
uint8_t tx_buffer[2] =
{
APP_SPI_FLASH_CMD_READ_SR1,
APP_SPI_FLASH_DUMMY_BYTE
};
uint8_t rx_buffer[2] = {0u};
HAL_StatusTypeDef status;
if (status_reg == 0)
{
return HAL_ERROR;
}
App_SPIFlash_CS_Low();
status = App_SPIFlash_TransmitReceive(tx_buffer, rx_buffer, 2u);
App_SPIFlash_CS_High();
if (status != HAL_OK)
{
return status;
}
*status_reg = rx_buffer[1];
return HAL_OK;
}
HAL_StatusTypeDef App_SPIFlash_WaitUntilReady(uint32_t timeout_ms)
{
uint32_t start_tick = HAL_GetTick();
uint8_t status_reg = 0u;
HAL_StatusTypeDef status;
do
{
status = App_SPIFlash_ReadStatusReg1(&status_reg);
if (status != HAL_OK)
{
return status;
}
if ((status_reg & APP_SPI_FLASH_SR1_BUSY) == 0u)
{
return HAL_OK;
}
} while ((HAL_GetTick() - start_tick) < timeout_ms);
return HAL_TIMEOUT;
}
HAL_StatusTypeDef App_SPIFlash_WriteEnable(void)
{
uint8_t command = APP_SPI_FLASH_CMD_WRITE_ENABLE;
HAL_StatusTypeDef status;
App_SPIFlash_CS_Low();
status = App_SPIFlash_Transmit(&command, 1u);
App_SPIFlash_CS_High();
return status;
}
HAL_StatusTypeDef App_SPIFlash_SectorErase(uint32_t address)
{
uint8_t command[4];
HAL_StatusTypeDef status;
App_SPIFlash_FillAddress(command, APP_SPI_FLASH_CMD_SECTOR_ERASE, address);
status = App_SPIFlash_WriteEnable();
if (status != HAL_OK)
{
return status;
}
App_SPIFlash_CS_Low();
status = App_SPIFlash_Transmit(command, 4u);
App_SPIFlash_CS_High();
if (status != HAL_OK)
{
return status;
}
return App_SPIFlash_WaitUntilReady(APP_SPI_FLASH_ERASE_TIMEOUT_MS);
}
HAL_StatusTypeDef App_SPIFlash_ReadData(uint32_t address, uint8_t *buffer, uint16_t length)
{
uint8_t command[4];
HAL_StatusTypeDef status;
if ((buffer == 0) || (length == 0u))
{
return HAL_ERROR;
}
App_SPIFlash_FillAddress(command, APP_SPI_FLASH_CMD_READ_DATA, address);
App_SPIFlash_CS_Low();
status = App_SPIFlash_Transmit(command, 4u);
if (status == HAL_OK)
{
status = App_SPIFlash_Receive(buffer, length);
}
App_SPIFlash_CS_High();
return status;
}
HAL_StatusTypeDef App_SPIFlash_PageProgram(uint32_t address, const uint8_t *data, uint16_t length)
{
uint8_t command[4];
uint16_t page_offset;
HAL_StatusTypeDef status;
if ((data == 0) || (length == 0u))
{
return HAL_ERROR;
}
page_offset = (uint16_t)(address % APP_SPI_FLASH_PAGE_SIZE);
if ((page_offset + length) > APP_SPI_FLASH_PAGE_SIZE)
{
return HAL_ERROR;
}
App_SPIFlash_FillAddress(command, APP_SPI_FLASH_CMD_PAGE_PROGRAM, address);
status = App_SPIFlash_WriteEnable();
if (status != HAL_OK)
{
return status;
}
App_SPIFlash_CS_Low();
status = App_SPIFlash_Transmit(command, 4u);
if (status == HAL_OK)
{
status = App_SPIFlash_Transmit(data, length);
}
App_SPIFlash_CS_High();
if (status != HAL_OK)
{
return status;
}
return App_SPIFlash_WaitUntilReady(APP_SPI_FLASH_WRITE_TIMEOUT_MS);
}
HAL_StatusTypeDef App_SPIFlash_WriteData(uint32_t address, const uint8_t *data, uint16_t length)
{
uint16_t offset = 0u;
uint16_t page_offset;
uint16_t current_len;
HAL_StatusTypeDef status;
if ((data == 0) || (length == 0u))
{
return HAL_ERROR;
}
while (offset < length)
{
page_offset = (uint16_t)((address + offset) % APP_SPI_FLASH_PAGE_SIZE);
current_len = (uint16_t)(APP_SPI_FLASH_PAGE_SIZE - page_offset);
if (current_len > (length - offset))
{
current_len = (uint16_t)(length - offset);
}
status = App_SPIFlash_PageProgram(address + offset, &data[offset], current_len);
if (status != HAL_OK)
{
return status;
}
offset = (uint16_t)(offset + current_len);
}
return HAL_OK;
}
app_spi_flash.c 逐块解释(零基础版)
上面代码虽然长,但结构很清楚。下面按功能块逐一解释。
第一部分:可配置的宏定义
#ifndef APP_SPI_FLASH_HANDLE
#define APP_SPI_FLASH_HANDLE hspi1
#endif
如果你的 SPI Flash 接在 SPI2 上,在包含 app_spi_flash.h 之前定义:
#define APP_SPI_FLASH_HANDLE hspi2
超时时间的三个宏:
|
宏
|
默认值
|
含义
|
| — | — | — |
| APP_SPI_FLASH_TIMEOUT_MS |
100ms
|
普通 SPI 收发超时
|
| APP_SPI_FLASH_WRITE_TIMEOUT_MS |
500ms
|
等待页写入完成的最大时间
|
| APP_SPI_FLASH_ERASE_TIMEOUT_MS |
5000ms
|
等待扇区擦除完成的最大时间
|
擦除比写入慢得多(因为要擦的面积大),所以擦除超时给了 5 秒。
第二部分:命令码定义
#define APP_SPI_FLASH_CMD_WRITE_ENABLE 0x06u
#define APP_SPI_FLASH_CMD_READ_SR1 0x05u
#define APP_SPI_FLASH_CMD_PAGE_PROGRAM 0x02u
#define APP_SPI_FLASH_CMD_READ_DATA 0x03u
#define APP_SPI_FLASH_CMD_SECTOR_ERASE 0x20u
#define APP_SPI_FLASH_CMD_JEDEC_ID 0x9Fu
这些就是 SPI Flash 的"暗号"。芯片收到 0x06 就知道你接下来要写或擦,收到 0x20 就知道你要擦一个扇区。
第三部分:CS 控制函数
static void App_SPIFlash_CS_Low(void)
{
HAL_GPIO_WritePin(SPI_FLASH_CS_GPIO_Port, SPI_FLASH_CS_Pin, GPIO_PIN_RESET);
}
CS 拉低 = 选中 Flash,后面的 SPI 数据 Flash 会接收。 CS 拉高 = 释放 Flash,Flash 开始执行刚才收到的命令。
**CS 的时序非常重要。**一个完整的 SPI Flash 命令一定是:
CS 拉低 → 发送命令+地址+数据 → CS 拉高
如果在发送中途 CS 被意外拉高,整个命令就废了。
第四部分:SPI 收发包装函数
static HAL_StatusTypeDef App_SPIFlash_Transmit(const uint8_t *tx_data, uint16_t length)
static HAL_StatusTypeDef App_SPIFlash_Receive(uint8_t *rx_data, uint16_t length)
static HAL_StatusTypeDef App_SPIFlash_TransmitReceive(const uint8_t *tx_data, uint8_t *rx_data, uint16_t length)
这三个函数只是对 HAL 库 SPI 函数的简单包装,让后面的代码写起来更短。它们的区别:
|
函数
|
作用
|
| — | — |
| Transmit |
只发不收(如发 Write Enable 命令,不需要收数据)
|
| Receive |
只收不发(如读回数据,发完命令后纯接收)
|
| TransmitReceive |
同时收发(如读 JEDEC ID,发命令的同时接收返回数据)
|
注意 TransmitReceive 会同时收发:你发出去的每一个字节,会同时收到一个字节。这是 SPI 协议的特性——它是全双工的。
第五部分:地址填充函数
static void App_SPIFlash_FillAddress(uint8_t *command, uint8_t cmd, uint32_t address)
{
command[0] = cmd;
command[1] = (uint8_t)((address >> 16) & 0xFFu);
command[2] = (uint8_t)((address >> 8) & 0xFFu);
command[3] = (uint8_t)(address & 0xFFu);
}
这个函数把 1 字节命令码 + 3 字节地址拼成 4 字节数组:
command[0] = 命令码(比如 0x20 = Sector Erase)
command[1] = 地址的高字节(bits 23~16)
command[2] = 地址的中字节(bits 15~8)
command[3] = 地址的低字节(bits 7~0)
举例:擦除 0x001000 时,拼出来的数组是:
0x20, 0x00, 0x10, 0x00
第六部分:核心函数逐一看
App_SPIFlash_WriteEnable()
HAL_StatusTypeDef App_SPIFlash_WriteEnable(void)
{
uint8_t command = APP_SPI_FLASH_CMD_WRITE_ENABLE;
HAL_StatusTypeDef status;
App_SPIFlash_CS_Low();
status = App_SPIFlash_Transmit(&command, 1u);
App_SPIFlash_CS_High();
return status;
}
这就是发一个字节 0x06。Flash 收到后把内部的"写使能锁存器"置位,允许接下来的一次擦除或写入。
关键: 这个函数只发不收。因为 Write Enable 命令不需要从 Flash 读任何东西。
App_SPIFlash_ReadStatusReg1()
读状态寄存器 1。发了 0x05 之后,Flash 会返回一个字节,其中第 0 位(最低位)是 BUSY:
status_reg & 0x01 == 1 → 忙
status_reg & 0x01 == 0 → 闲
App_SPIFlash_WaitUntilReady()
do
{
status = App_SPIFlash_ReadStatusReg1(&status_reg);
if (status != HAL_OK) return status;
if ((status_reg & APP_SPI_FLASH_SR1_BUSY) == 0u) return HAL_OK;
} while ((HAL_GetTick() - start_tick) < timeout_ms);
return HAL_TIMEOUT;
这个循环不停地问 Flash:"你忙完了吗?"直到 BUSY 变成 0,或者超时。
HAL_GetTick() 返回从开机到现在的毫秒数,用它来计时。
App_SPIFlash_SectorErase()
顺序:
-
拼命令:
0x20+ 3 字节地址 -
Write Enable
-
CS 拉低 → 发命令 → CS 拉高
-
等待 BUSY 清零(擦除需要时间)
注意步骤 4 的超时给了 5 秒,因为扇区擦除是 Flash 最慢的操作之一。
App_SPIFlash_PageProgram()
顺序:
-
检查参数是否合法(数据指针非空、不跨页)
-
拼命令:
0x02+ 3 字节地址 -
Write Enable
-
CS 拉低 → 发命令 → 发数据 → CS 拉高
-
等待 BUSY 清零
跨页检查:
page_offset = address % 256;
if (page_offset + length > 256) → 拒绝,返回错误
这是故意的。跨页写入必须由更上层的 WriteData() 拆分。
App_SPIFlash_WriteData()
这是最上层、最常用的写入函数。它会自动拆页:
while (offset < length)
{
// 计算当前页还剩多少字节可以写
page_offset = (address + offset) % 256;
current_len = 256 - page_offset;
// 如果这一页的剩余空间足够写下剩余数据,就全部写入
if (current_len > (length - offset))
current_len = length - offset;
// 调用 PageProgram 写入这一块
App_SPIFlash_PageProgram(address + offset, &data[offset], current_len);
offset += current_len; // 往后挪
}
举例:从 0x0000F0 写 40 字节:
-
第一轮:
current_len = 256 - 240 = 16,写 16 字节到0x0000F0 -
第二轮:从
0x000100开始,current_len = 24,写 24 字节
main.c 调用方式
为了让 main.c 干净一点,本篇单独做一个 demo 文件。
1. 新建 Core/Inc/app_spi_flash_demo.h
#ifndef APP_SPI_FLASH_DEMO_H
#define APP_SPI_FLASH_DEMO_H
#include "main.h"
void App_SPIFlashDemo_Run(void);
#endif
2. 新建 Core/Src/app_spi_flash_demo.c
#include "app_spi_flash_demo.h"
#include "app_spi_flash.h"
#include <stdio.h>
#include <string.h>
#ifndef APP_SPI_FLASH_DEMO_ADDRESS
#define APP_SPI_FLASH_DEMO_ADDRESS 0x001000u
#endif
static void App_SPIFlashDemo_PrintHex(uint32_t address, const uint8_t *buffer, uint16_t length)
{
uint16_t i;
for (i = 0u; i < length; i++)
{
if ((i % 16u) == 0u)
{
printf("\r\n0x%06lX: ", (unsigned long)(address + i));
}
printf("%02X ", buffer[i]);
}
printf("\r\n");
}
void App_SPIFlashDemo_Run(void)
{
static const uint8_t write_data[] = "STM32 SPI Flash write demo";
uint8_t read_buffer[sizeof(write_data)] = {0u};
App_SPIFlash_JedecID id = {0u};
HAL_StatusTypeDef status;
printf("\r\nSPI Flash write demo\r\n");
status = App_SPIFlash_ReadJedecID(&id);
if (status != HAL_OK)
{
printf("Read JEDEC ID failed, status=%lu\r\n", (unsigned long)status);
return;
}
printf("JEDEC ID: %02X %02X %02X\r\n",
id.manufacturer_id,
id.memory_type,
id.capacity);
printf("Erase sector at 0x%06lX...\r\n", (unsigned long)APP_SPI_FLASH_DEMO_ADDRESS);
status = App_SPIFlash_SectorErase(APP_SPI_FLASH_DEMO_ADDRESS);
if (status != HAL_OK)
{
printf("Sector erase failed, status=%lu\r\n", (unsigned long)status);
return;
}
printf("Program %u bytes...\r\n", (unsigned int)sizeof(write_data));
status = App_SPIFlash_WriteData(APP_SPI_FLASH_DEMO_ADDRESS,
write_data,
(uint16_t)sizeof(write_data));
if (status != HAL_OK)
{
printf("Write data failed, status=%lu\r\n", (unsigned long)status);
return;
}
status = App_SPIFlash_ReadData(APP_SPI_FLASH_DEMO_ADDRESS,
read_buffer,
(uint16_t)sizeof(read_buffer));
if (status != HAL_OK)
{
printf("Read back failed, status=%lu\r\n", (unsigned long)status);
return;
}
printf("Read back text: %s\r\n", read_buffer);
printf("Read back hex:");
App_SPIFlashDemo_PrintHex(APP_SPI_FLASH_DEMO_ADDRESS,
read_buffer,
(uint16_t)sizeof(read_buffer));
if (memcmp(write_data, read_buffer, sizeof(write_data)) == 0)
{
printf("Verify OK.\r\n");
}
else
{
printf("Verify failed.\r\n");
}
}
Demo 代码做了什么(逐段解释)
Hex 打印函数:
static void App_SPIFlashDemo_PrintHex(...)
{
for (i = 0; i < length; i++)
{
if ((i % 16) == 0) // 每 16 个字节换一行
printf("\r\n0x%06lX: ", address + i);
printf("%02X ", buffer[i]);
}
}
这个函数把读回来的数据按常见的 hex dump 格式打印:每行 16 个字节,行首标地址。
Demo 主流程:
-
printf("SPI Flash write demo")— 打标题 -
App_SPIFlash_ReadJedecID(&id)— 读 ID,确认通信 -
如果 ID 读失败 → 打印错误,
return退出 -
App_SPIFlash_SectorErase(0x001000)— 擦除目标扇区 -
如果擦除失败 → 打印错误退出
-
App_SPIFlash_WriteData(...)— 写入字符串 -
如果写失败 → 打印错误退出
-
App_SPIFlash_ReadData(...)— 读回来 -
printf打印读到的文本和 hex -
memcmp比较写入和读回的数据,一致就打印Verify OK
3. 在 main.c 里包含头文件
找到:
/* USER CODE BEGIN Includes */
/* USER CODE END Includes */
改成:
/* USER CODE BEGIN Includes */
#include "app_spi_flash.h"
#include "app_spi_flash_demo.h"
#include <stdio.h>
/* USER CODE END Includes */
为什么写在
USER CODE BEGIN/END之间?CubeMX 重新生成代码时,只保留
USER CODE BEGIN和USER CODE END之间的内容。写在别处,下次生成代码就被覆盖了。
4. 在初始化区域调用
确认前面已经执行:
MX_GPIO_Init();
MX_SPI1_Init();
MX_USART1_UART_Init();
然后在 USER CODE BEGIN 2 中添加:
/* USER CODE BEGIN 2 */
App_SPIFlash_Init();
App_SPIFlashDemo_Run();
/* USER CODE END 2 */
5. while 循环不要反复擦写
这一点很重要。
不要这样写:
while (1)
{
App_SPIFlashDemo_Run();
}
因为 Flash 有擦写寿命。
入门验证时,上电执行一次就够了。
while 里保持空闲即可:
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
HAL_Delay(1000);
/* USER CODE END 3 */
}
第一次上电运行:你会看到什么
烧录程序后打开串口(115200 波特率),按一下复位,你应该看到:
SPI Flash write demo
JEDEC ID: EF 40 17
Erase sector at 0x001000...
Program 27 bytes...
Read back text: STM32 SPI Flash write demo
Read back hex:
0x001000: 53 54 4D 33 32 20 53 50 49 20 46 6C 61 73 68 20
0x001010: 77 72 69 74 65 20 64 65 6D 6F 00
Verify OK.
如果看到的不是这样,下面是分步骤的诊断方法:
-
如果第一行 “SPI Flash write demo” 都没出来 → 串口没配好,回头看第 07 篇
-
如果 JEDEC ID 报错 → Flash 通信不通,回头看第 22、23 篇
-
如果 Erase 失败 → 检查地址、Write Enable、写保护(参考下文常见问题排查)
-
如果 Program 成功但 Verify 失败 → 数据不对,检查写入地址和读回地址是否一致
验证通过的标志: 断电,等 5 秒,重新上电。Verify OK. 每次都能出现。
关键代码解释
1. Write Enable 为什么每次都要发
写使能命令是:
0x06
它的作用是告诉 Flash:
接下来允许进行一次写入或擦除操作
注意,是"一次"。
一般擦除或写入完成后,Flash 会自动清掉写使能状态。
所以本篇代码里:
App_SPIFlash_SectorErase()
内部会先调用:
App_SPIFlash_WriteEnable()
App_SPIFlash_PageProgram() 内部也会先调用:
App_SPIFlash_WriteEnable()
这样每次动作都比较稳。
可以课前发一次 Write Enable,然后连续擦+写吗?
不行。不是因为代码不让,而是 Flash 芯片设计就是一次 Write Enable 只允许一次操作。
0x06 → 擦除 → (写使能自动清) → 0x06 → 页写 → (写使能自动清) 这个顺序是芯片的硬件行为决定的,不是软件可以绕过的。
2. 为什么要等 BUSY 清零
擦除和写入不是瞬间完成的。
STM32 发送完命令以后,Flash 内部还要慢慢操作存储阵列。
这段时间状态寄存器里的 BUSY 位会置 1。
本篇用:
App_SPIFlash_WaitUntilReady()
反复读取状态寄存器 1:
0x05
直到:
BUSY = 0
再继续下一步。
如果你不等 BUSY,可能会出现:
-
擦除还没完成就开始写;
-
写入还没完成就读回;
-
读到旧数据;
-
后面的命令被 Flash 忽略。
典型的时间参考(W25Q32):
|
操作
|
典型时间
|
最大时间
|
| — | — | — |
|
扇区擦除(4KB)
|
~45ms
|
~400ms
|
|
页写入(256B)
|
~0.7ms
|
~3ms
|
|
芯片擦除(全片)
|
~20s
|
~80s
|
可以看到擦除比写入慢几十倍,所以两边的超时时间设置不一样。
3. 为什么页写入不能随便跨页
常见 SPI Flash 页大小是:
256 字节
Page Program 一次通常只适合写同一页里的数据。
如果从地址 0x0000F0 开始写 32 字节:
0x0000F0 ~ 0x0000FF:第一页最后 16 字节
0x000100 ~ 0x00010F:下一页前 16 字节
这就跨页了。
本篇代码在 App_SPIFlash_WriteData() 里会自动拆成两次写。
先写第一页剩下的 16 字节,再写下一页 16 字节。
为什么会跨页就出问题?
Flash 内部有一个页缓冲区(256 字节)。Page Program 命令会把你要写的数据先放进这个缓冲区。如果数据跨页了,超过 256 字节边界的部分会"绕回"到当前页的开头覆盖数据,而不是自动跳到下一页——这是芯片的硬件设计。
4. 为什么示例先擦除整个扇区
常见扇区大小是:
4KB
哪怕你只想写几十个字节,也不能只擦这几十个字节。
Flash 的擦除粒度通常比写入粒度大。
所以常见流程是:
读出整个扇区旧数据
修改其中一小段
擦除整个扇区
把修改后的整个扇区写回
这套"读-改-擦-写"的参数保存流程,我们后面可以专门写一篇。
本篇为了入门,只在测试地址写一段字符串,不展开参数管理。
移植到其他板子的修改点
|
要改的地方
|
为什么要改
|
在哪里改
|
| — | — | — |
|
SPI 实例
|
可能不是 SPI1
| APP_SPI_FLASH_HANDLE |
|
CS 引脚
|
不同板子 CS 不同
|
CubeMX User Label = SPI_FLASH_CS
|
|
SPI Mode
|
不同器件要求不同
|
CubeMX SPI 参数
|
|
SPI 速度
|
写入阶段也可能受线长影响
|
Prescaler
|
|
页大小
|
常见 256 字节,但要看手册
| APP_SPI_FLASH_PAGE_SIZE |
|
扇区大小
|
常见 4KB,但要看手册
| APP_SPI_FLASH_SECTOR_SIZE
和擦除命令
|
|
擦除命令
|
常见 4KB 扇区擦除是 0x20
| APP_SPI_FLASH_CMD_SECTOR_ERASE |
|
测试地址
|
避免擦到已有数据
| APP_SPI_FLASH_DEMO_ADDRESS |
如果你换成别的 SPI Flash,最先查数据手册里的这几项:
Read JEDEC ID 命令
Read Data 命令
Write Enable 命令
Page Program 命令
Sector Erase 命令
Page Size
Sector Size
Status Register BUSY 位
举个移植例子:从 W25Q32 换到别的 Flash
假设你换了一颗 Flash,数据手册里说:
-
Page Size = 512 字节(不是 256)
-
Sector Erase 命令 =
0xD7(不是0x20) -
Sector Size = 4KB
你需要改的地方:
-
app_spi_flash.h中APP_SPI_FLASH_PAGE_SIZE改成512u -
app_spi_flash.c中APP_SPI_FLASH_CMD_SECTOR_ERASE改成0xD7u -
其他基本不用动
常见问题排查
1. ID 都读不出来
先别看写入代码。
回到第 22、23 篇:
-
供电;
-
共地;
-
SCK/MOSI/MISO/CS;
-
SPI Mode;
-
CS 时序;
-
SPI_FLASH_CS标签; -
hspi1/hspi2句柄。
ID 不稳定时,写入一定不可靠。
2. 擦除失败
优先查:
|
检查项
|
说明
|
| — | — |
|
是否先 Write Enable
|
不写使能,擦除命令可能无效
|
|
地址是否对齐
|
建议扇区擦除地址按 4KB 边界
|
|
超时时间是否太短
|
擦除比写入慢,超时要更长
|
|
Flash 是否写保护
|
有些模块或状态寄存器可能开启保护
|
本篇默认超时:
擦除 5000 ms
写入 500 ms
如果你的芯片擦除时间更长,要按数据手册调整。
如何快速判断是不是 Write Enable 的问题?
在 App_SPIFlash_SectorErase() 的 Write Enable 调用后,加一行读状态寄存器看看写使能锁存位(WEL, bit 1)是不是 1:
status = App_SPIFlash_WriteEnable();
// 调试用:读完 SR1,看 bit 1 (WEL) 是否为 1
uint8_t sr;
App_SPIFlash_ReadStatusReg1(&sr);
printf("SR1 after Write Enable: 0x%02X\r\n", sr);
// 正常应该看到 0x02(WEL=1, BUSY=0)
如果 WEL 不是 1,要么 Write Enable 命令没发出去,要么 Flash 处于写保护状态。
3. 写入后读回还是 FF
常见原因:
-
没有写使能;
-
页写入命令没发成功;
-
写完没有等 BUSY;
-
写入地址和读回地址不一样;
-
CS 中途被拉高,导致命令不完整;
-
Flash 区域被写保护。
先看串口输出停在哪一步。
如果 Erase sector 成功,但 Program 后读回仍然 FF,优先怀疑 Write Enable、页写入命令和写保护。
一个快速测试:降低 SPI 速度
把 CubeMX 里 SPI 的 Prescaler 调到最大(比如 256),让 SPI 通信慢下来。
如果降速后写入成功,说明是信号完整性问题(线太长、布线不好、没有上拉电阻等)。
4. 写入后读回不是原数据
优先查:
|
原因
|
说明
|
| — | — |
|
写之前没擦除
|
旧数据里的 0 不能直接变回 1
|
|
跨页处理不对
|
一次 Page Program 不要跨 256 字节页
|
|
数据长度不对
|
注意字符串末尾 \0 是否也写入
|
|
SPI 通信不稳定
|
降低 SPI 速度再试
|
本篇 demo 用:
sizeof(write_data)
会把字符串末尾的 \0 一起写进去。
所以读回来可以直接用 %s 打印。
5. 程序每次运行都擦写,会不会损坏 Flash
Flash 有擦写寿命。
不同芯片规格不同,具体看数据手册。典型 W25Qxx 的擦写寿命在 10 万次以上。
入门验证时,不要在 while 里一直擦写。
建议:
上电执行一次 demo
验证完成后注释掉写入 demo
后面如果做参数保存,也不要频繁保存。
比如不要每 1 ms 写一次参数。
6. 编译报 undefined symbol App_SPIFlashDemo_Run
说明:
app_spi_flash_demo.c 没有加入 Keil 工程
去 Keil 工程树里添加:
Core/Src/app_spi_flash_demo.c
7. 编译报 hspi1 未定义
如果你的 CubeMX 使用的是 SPI2,把默认句柄改成:
#define APP_SPI_FLASH_HANDLE hspi2
要保证代码里的 SPI 句柄和 CubeMX 生成的一致。
8. 编译报 SPI_FLASH_CS_GPIO_Port is not defined
这说明 CubeMX 里 CS 引脚的 User Label 没有设为 SPI_FLASH_CS。
去 CubeMX 里找到 CS 引脚(你接 Flash CS 的那根 GPIO),在配置面板里把 User Label 改成 SPI_FLASH_CS,然后重新生成代码。
9. 烧录后串口输出乱码
-
串口波特率是不是 115200?
-
STM32 的时钟配置是否正确?(HSE/HIS、PLL 等)
-
如果串口之前正常、加了 SPI Flash 之后乱码 → 检查是否 SPI 和 USART 引脚冲突
10. 写入成功,但断电再上电数据没了
如果你用的是某些开发板,板载的 SPI Flash 可能被跳线帽或拨码开关控制供电。检查 Flash 芯片的 VCC 是否始终有电。
如果断电数据就丢,也可能你根本没写到 Flash——检查一下是不是写了但是 Verify 那步被跳过了(比如某个 if 判断让代码提前 return 了)。
SPI Flash 术语速查表
|
术语
|
全称
|
简要解释
|
| — | — | — |
| JEDEC ID |
Joint Electron Device Engineering Council ID
|
Flash 芯片的"身份证号",包含厂商、型号、容量
|
| Write Enable |
—
|
写使能,发送 0x06 命令解除芯片写入锁
|
| WEL |
Write Enable Latch
|
状态寄存器中的第 1 位,表示写使能是否打开
|
| BUSY |
—
|
状态寄存器中的第 0 位,表示芯片正在执行擦除或写入
|
| Page Program |
—
|
页写入命令 0x02,一次最多写入一页(256 字节)
|
| Sector Erase |
—
|
扇区擦除命令 0x20,擦除 4KB 扇区
|
| Chip Erase |
—
|
全片擦除命令,擦除整个芯片(通常 0xC7 或 0x60)
|
| SR1 |
Status Register 1
|
状态寄存器 1,包含 BUSY、WEL、保护位等
|
| CS |
Chip Select
|
片选引脚,低电平选中 Flash
|
| SCK |
Serial Clock
|
SPI 时钟线
|
| MOSI |
Master Out Slave In
|
STM32 发给 Flash 的数据线
|
| MISO |
Master In Slave Out
|
Flash 发给 STM32 的数据线
|
| Dummy Byte |
—
|
哑字节(0xFF),SPI Flash 通信中用于"占位"以产生时钟
|
| CPOL / CPHA |
Clock Polarity / Clock Phase
|
SPI 时钟极性和相位,共同决定 SPI Mode
|
本篇小结
这一篇我们完成了 SPI Flash 最小写入链路:
擦除扇区 -> 写入字符串 -> 读回校验
你现在应该知道:
-
SPI Flash 写入不能随便覆盖;
-
写入只能把 1 变成 0;
-
擦除才能把 0 变回 1;
-
擦除的最小单位是扇区(通常 4KB),不能只擦几个字节;
-
写入或擦除前要先
Write Enable; -
一次
Write Enable只允许一次擦或写操作; -
擦除和写入后要等待 BUSY 清零;
-
擦除比写入慢很多,超时时间要区分设置;
-
Page Program要注意 256 字节页边界,跨页要拆分; -
不要在
while里反复擦写 Flash; -
写完一定要读回校验。
本篇完成检查清单:
-
[ ] JEDEC ID 每次上电都能读到且一致
-
[ ] 扇区擦除成功(程序不报错)
-
[ ] 字符串写入后能读回
-
[ ]
memcmp校验通过,打印Verify OK -
[ ] 断电再上电,数据还在
-
[ ] 能用自己的话解释"为什么写之前要擦除"
-
[ ] 能说出
0x06、0x05、0x20、0x02分别是什么命令 -
[ ]
while循环里没有反复调用擦写函数
下一篇我们继续把这个能力做得更像真实工程:
STM32 SPI Flash 保存参数:做一个掉电不丢的配置结构体。
到那一篇,我们会把"读-改-擦-写"和校验字段结合起来,不再只是写一段字符串。
更多推荐



所有评论(0)