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(十进制)

后面你会频繁看到 0xFF0x060x05 这样的写法。它们不是魔法数字,只是 SPI Flash 的"暗号"——不同的数字代表不同的命令。

3. 页(Page)和扇区(Sector):两种不同大小的"格子"

SPI Flash 芯片内部把存储空间划分成了大大小小的格子:

|
单位
|
大小
|
说明
|
| — | — | — |
|
字节(Byte)
|
1 字节
|
最小的读写单位
|
|
页(Page)
|
通常 256 字节
| 一次写入最大范围
,不能跨页写
|
|
扇区(Sector)
|
通常 4KB(4096 字节)
| 一次擦除最小单位
,要擦必须擦整个扇区
|
|
块(Block)
|
通常 64KB
|
更大级别的擦除单位
|

关键关系:

1 个扇区 = 161= 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 EnablePage ProgramSector EraseBUSY 分别是什么;

  • 知道不要在 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  ✓ 可以(写入能把 10)
位6: 0→1  ✗ 不行(写入不能把 01,需要擦除)
位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 添加文件的步骤(零基础版):

  1. 在 Keil 左侧工程树里找到 Core/Src 分组

  2. 右键点击 Core/Src → Add Existing Files to Group

  3. 浏览到工程目录下的 Core/Src/app_spi_flash.c,选中添加

  4. 同样方法添加 Core/Src/app_spi_flash_demo.c

  5. 确认两个文件出现在工程树里

如果编译报 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()

顺序:

  1. 拼命令:0x20 + 3 字节地址

  2. Write Enable

  3. CS 拉低 → 发命令 → CS 拉高

  4. 等待 BUSY 清零(擦除需要时间)

注意步骤 4 的超时给了 5 秒,因为扇区擦除是 Flash 最慢的操作之一。

App_SPIFlash_PageProgram()

顺序:

  1. 检查参数是否合法(数据指针非空、不跨页)

  2. 拼命令:0x02 + 3 字节地址

  3. Write Enable

  4. CS 拉低 → 发命令 → 发数据 → CS 拉高

  5. 等待 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 主流程:

  1. printf("SPI Flash write demo") — 打标题

  2. App_SPIFlash_ReadJedecID(&id) — 读 ID,确认通信

  3. 如果 ID 读失败 → 打印错误,return 退出

  4. App_SPIFlash_SectorErase(0x001000) — 擦除目标扇区

  5. 如果擦除失败 → 打印错误退出

  6. App_SPIFlash_WriteData(...) — 写入字符串

  7. 如果写失败 → 打印错误退出

  8. App_SPIFlash_ReadData(...) — 读回来

  9. printf 打印读到的文本和 hex

  10. 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.

如果看到的不是这样,下面是分步骤的诊断方法:

  1. 如果第一行 “SPI Flash write demo” 都没出来 → 串口没配好,回头看第 07 篇

  2. 如果 JEDEC ID 报错 → Flash 通信不通,回头看第 22、23 篇

  3. 如果 Erase 失败 → 检查地址、Write Enable、写保护(参考下文常见问题排查)

  4. 如果 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

你需要改的地方:

  1. app_spi_flash.h 中 APP_SPI_FLASH_PAGE_SIZE 改成 512u

  2. app_spi_flash.c 中 APP_SPI_FLASH_CMD_SECTOR_ERASE 改成 0xD7u

  3. 其他基本不用动


常见问题排查

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

  • [ ] 断电再上电,数据还在

  • [ ] 能用自己的话解释"为什么写之前要擦除"

  • [ ] 能说出 0x060x050x200x02 分别是什么命令

  • [ ] while 循环里没有反复调用擦写函数

下一篇我们继续把这个能力做得更像真实工程:

STM32 SPI Flash 保存参数:做一个掉电不丢的配置结构体。

到那一篇,我们会把"读-改-擦-写"和校验字段结合起来,不再只是写一段字符串。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐