STM32 零基础可移植教程 24:SPI Flash 读数据,先从指定地址读几个字节
本篇学什么
读完这一篇,你会:
-
知道 SPI Flash 芯片长什么样,引脚怎么接
-
理解 Flash 的"地址"是什么意思------像书架编号一样简单
-
知道读数据命令
0x03后面为什么要跟 3 个地址字节 -
自己写出从 Flash 任意位置读数据的代码
-
用串口把读到的数据按十六进制一行一行打印出来
-
能判断"读出来全 FF"是通信失败还是正常现象
本篇之前你已经有这些基础
前面两篇我们已经把 SPI 的入门链路走了一遍:
bash
第 22 篇:读取 SPI Flash 的 JEDEC ID
第 23 篇:SPI 读到 0xFF、0x00、数据乱跳时怎么排查
能稳定读到 Flash ID,说明这几件事基本没问题:
bash
SPI 外设能工作
SCK/MOSI/MISO/CS 接线大概率正确
CS 片选时序大概率正确
SPI Mode 和速度至少能满足当前通信
下一步,我们不要急着写入。
因为 SPI Flash 写入之前还有几个新概念:
bash
写使能(Write Enable)
页写入(Page Program)
扇区擦除(Sector Erase)
忙状态等待(BUSY polling)
如果一上来就写,很容易把"读不对""没擦除""写保护""忙等待"几个问题混在一起,根本不知道从哪查。
所以这一篇只做一个明确目标:
bash
从 SPI Flash 的指定地址读取一段数据,并用串口按十六进制打印出来
先把"读数据"这条链路跑通,再进入写入和擦除。
先认识一下 SPI Flash 芯片
在写代码之前,先看一眼你手上的东西长什么样。
SPI Flash 是一个黑色的小芯片,常见的是 8 个引脚(SOP-8 封装),比指甲盖还小:

常见引脚说明:
|
引脚
|
常见标识
|
方向
|
说明
|
| --- | --- | --- | --- |
|
1
|
CS / CE
|
输入
|
片选。拉低后芯片才理你。
|
|
2
|
DO / MISO
|
输出
|
数据输出。Flash 从这里把数据吐给 STM32。
|
|
3
|
WP
|
输入
|
写保护。本篇先不管,接 VCC 拉高。
|
|
4
|
GND
|
电源
|
接地。
|
|
5
|
DI / MOSI
|
输入
|
数据输入。STM32 从这里把命令发给 Flash。
|
|
6
|
SCK / CLK
|
输入
|
时钟。STM32 提供。
|
|
7
|
HOLD
|
输入
|
保持。本篇先不管,接 VCC 拉高。
|
|
8
|
VCC
|
电源
|
接 3.3V(不是 5V!)。
|
你手里的 Flash 可能是 W25Q32、W25Q64 或 W25Q128。数字代表容量:
bash
W25Q32 = 32 Mbit = 4 MB
W25Q64 = 64 Mbit = 8 MB
W25Q128 = 128 Mbit = 16 MB
容量不同,但读数据的命令和时序完全一样。本篇代码对所有 W25Qxx 通用。
硬件接线:Flash 怎么连到 STM32
这是最容易被跳过的一步------很多教程上来就贴代码,但初学者卡住的地方往往是线没接对。
最少接线(本篇只需要 6 根线)
|
Flash 引脚
|
接 STM32 什么引脚
|
说明
|
| --- | --- | --- |
|
CS (pin 1)
|
任意 GPIO(如 PA4)
|
片选,输出模式
|
|
DO (pin 2)
|
SPI1_MISO(如 PA6)
|
主入从出
|
|
WP (pin 3)
|
3.3V
|
拉高禁用写保护
|
|
GND (pin 4)
|
GND
|
共地
|
|
DI (pin 5)
|
SPI1_MOSI(如 PA7)
|
主出从入
|
|
SCK (pin 6)
|
SPI1_SCK(如 PA5)
|
时钟
|
|
HOLD (pin 7)
|
3.3V
|
拉高禁用保持
|
|
VCC (pin 8)
|
3.3V
|
供电
|
接线最容易犯的三个错误
错误 1:Flash 接 5V。
绝大多数 SPI Flash 是 3.3V 器件。接 5V 可能烧坏芯片。如果你用 5V 开发板(如某些 Arduino),需要电平转换模块。
错误 2:忘了共地。
STM32 的 GND 和 Flash 的 GND 必须连在一起。没共地 = 电平没有参考点 = 通信必然失败。
错误 3:MISO/MOSI 接反。
记忆技巧:MISO = Master In Slave Out,MOSI = Master Out Slave In。"主入"接"从出","主出"接"从入"------也就是交叉接:
bash
STM32 MOSI ──── Flash DI
STM32 MISO ──── Flash DO
如果是模块(带小板子的 Flash 模块),直接按模块丝印接就行,不用管交叉。
实物检查清单
上电前确认一遍:
-
VCC 接了 3.3V(不是 5V)
-
GND 和 STM32 共地
-
SCK、MOSI、MISO 对应正确的 SPI 引脚
-
CS 引脚配置成了 GPIO 输出
-
WP 和 HOLD 接了 3.3V(或悬空,但拉高更稳)
理解 Flash 的"地址"------像书架一样
SPI Flash 可以理解成一个大书架。
bash
Flash 内部就像一个超长的书架:
┌────────┬────────┬────────┬────────┬────────┬─────
│ 第 0 │ 第 1 │ 第 2 │ 第 3 │ 第 4 │ ...
│ 个格子 │ 个格子 │ 个格子 │ 个格子 │ 个格子 │
└────────┴────────┴────────┴────────┴────────┴─────
每个格子里放一个字节(8 个 bit)。每个格子有一个编号,这个编号就是"地址":
bash
地址 0x000000:第 0 个字节
地址 0x000001:第 1 个字节
地址 0x000002:第 2 个字节
地址 0x000003:第 3 个字节
...
W25Q32 有 4MB 空间,也就是大约 400 万个格子,地址范围是:
bash
0x000000 到 0x3FFFFF
为什么地址是 24 位(3 个字节)?
4MB = 4 × 1024 × 1024 = 4,194,304 个地址。
用二进制表示 4,194,303 需要 22 位。SPI Flash 标准用 24 位(3 个字节)地址,最大可以寻址 16MB,覆盖了绝大多数常见 Flash 的容量。
bash
24 位地址 = 3 个字节:
┌──────────┬──────────┬──────────┐
│ 高字节 │ 中字节 │ 低字节 │
│ Addr[23 │ Addr[15 │ Addr[7 │
│ :16] │ :8] │ :0] │
└──────────┴──────────┴──────────┘
举个例子------假设要读地址 0x001234:
bash
0x001234 拆成三个字节:
高字节 = 0x00
中字节 = 0x12
低字节 = 0x34
发送顺序:先发高字节,再中字节,再低字节
SPI 总线上:0x00 → 0x12 → 0x34
注意:SPI Flash 地址发送顺序是高字节在前(MSB First),这和很多人的直觉(先发低位)不一样。
和 I2C EEPROM 对比(帮助理解)
如果你之前用过 I2C 的 EEPROM(比如 AT24C02),可能会习惯"设备地址 + 寄存器地址"的概念。
SPI Flash 不一样:
bash
I2C EEPROM:设备地址 + 存储地址
SPI Flash: CS 拉低选中芯片 + 命令字节 + 存储地址
SPI 没有"设备地址"的概念。CS 拉低哪个芯片,就选中了哪个。所以一个 SPI 总线上挂多个 Flash 时,每个 Flash 要有自己独立的 CS 引脚。
读数据命令 0x03 是怎么工作的
先看 Flash 数据手册里怎么说
打开 W25Q64 的数据手册,翻到指令表,你会看到类似这样的一行:
bash
Command | Code | Description
Read Data| 03h | Read data from memory
这就是读数据命令。0x03 是业界 SPI Flash 几乎通用的"普通读"命令。
数据手册里还会给时序图------这是比文字更重要的信息。时序图通常长这样:

从这个时序图上能看出几个关键信息:
-
CS 拉低后,整个操作不能中断------命令、地址、读数据要在同一次 CS 低电平内完成。
-
MOSI 上先发命令 0x03,然后发 3 个地址字节。
-
发完地址后,MISO 上开始出数据。Flash 每收到一个时钟,就吐出一个 bit。
-
数据可以连续读------你一直给时钟,Flash 就一直往后吐数据。地址会自动递增。
一步一步拆解读取过程
假设要从地址 0x000000 读 4 个字节,完整流程如下:
bash
步骤 1:CS 拉低
告诉 Flash:"注意,我要跟你说话了。"
步骤 2:STM32 发送 0x03
Flash 收到后知道:"哦,这是读数据命令。"
步骤 3:STM32 发送 0x00 0x00 0x00(3 字节地址)
Flash 知道:"要从第 0 个字节开始读。"
步骤 4:STM32 继续产生时钟,同时从 MISO 读取数据
Flash 从地址 0 开始,一个字节一个字节往外吐。
第 1 个时钟周期 → 吐出地址 0x000000 的数据
第 2 个时钟周期 → 吐出地址 0x000001 的数据
第 3 个时钟周期 → 吐出地址 0x000002 的数据
第 4 个时钟周期 → 吐出地址 0x000003 的数据
...
步骤 5:读完需要的字节数,CS 拉高
告诉 Flash:"我说完了,你休息吧。"
"CS 不能中途拉高"------这是最重要的规则
CS 拉低期间做的事,Flash 认为是一次完整操作。
错误示范:
bash
CS Low → 发 0x03 → CS High ❌
CS Low → 发地址 → CS High ❌
CS Low → 读数据 → CS High ❌
Flash 会把上面三次当成三个独立操作,完全不知道你想干什么。
正确示范:
bash
CS Low → 发 0x03 → 发地址 → 读数据 → CS High ✅
一个常见的 bug 就是:封装函数时,在 发送命令 函数末尾不小心写了一句 CS_High(),然后又调用 读数据 函数时重新 CS_Low()。这会导致读命令失效。
所以在代码里,App_SPIFlash_ReadData() 整个函数只有开头一次 CS 拉低,结尾一次 CS 拉高,中间不释放。
读数据时 MISO 上的第一个字节是什么?
这是很多人困惑的地方。
看这个时序:
bash
MOSI: 0x03 AddrH AddrM AddrL 0xFF 0xFF ...
MISO: ?? ?? ?? ?? D0 D1 ...
在发送命令和地址期间,MISO 上的数据是无效的(Flash 还没准备好)。只有当 3 个地址字节发送完毕后,MISO 上才开始出现真正的数据。
所以我们的代码先用 HAL_SPI_Transmit() 发完命令+地址(4 个字节),再用 HAL_SPI_Receive() 收数据。这两个函数调用之间 CS 保持低电平。
关于"空片读出来全是 FF"
SPI Flash 出厂时或擦除后,所有 bit 都是 1。8 个 1 组成一个字节 = 0xFF。
所以:
bash
JEDEC ID 全 FF → 大概率通信有问题,Flash 根本没回应
数据区全 FF → ID 正常的前提下,可能只是这片区域是擦除态
判断方法:
-
先读 JEDEC ID。
-
ID 正常 → 通信没问题。
-
再看数据区。数据区全 FF 也正常(空片),不必惊慌。
本篇最终现象
串口输出类似:
bash
SPI Flash read data test
JEDEC ID: EF 40 17
Status Register-1: 0x00
Read 64 bytes from address 0x000000:
0x000000: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
0x000010: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
0x000020: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
0x000030: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
如果你的 Flash 是空片,读出来全是 FF 很正常。这说明:
-
SPI 通信没问题(因为 JEDEC ID 读对了)
-
Flash 这片区域确实处于擦除态(全是 1)
本篇跑通标准:
-
能稳定读到 JEDEC ID
-
能读取地址
0x000000开始的 64 字节 -
串口能按地址一行一行打印十六进制数据
-
知道读数据命令
0x03后面要跟 3 个地址字节 -
知道 Flash 读出来全
FF不一定是错 -
换 Flash 或换板子时,知道要改 SPI 实例、CS、地址长度和命令
本篇用到的外设:
bash
SPI
GPIO Output
USART printf
准备工作
|
项目
|
说明
|
| --- | --- |
|
STM32 开发板
|
任意带 SPI 的 STM32 都可以
|
|
SPI Flash
|
W25Q32/W25Q64/W25Q128 等常见 Flash
|
|
串口打印
|
使用第 07 篇的 printf() 输出
|
|
第 22 篇工程
|
已经能读 JEDEC ID
|
|
第 23 篇排坑思路
|
读不到时先按表查
|
这篇默认你已经完成了:
bash
SPI Master 配置
CS GPIO 配置
USART printf 配置
如果还没完成,先回到第 22 篇做完再过来。
CubeMX 配置步骤
这篇的 CubeMX 配置和第 22 篇一样。这里按排查顺序再确认一次,并加上每一步为什么这么配的解释。
1. SPI 配置
在 CubeMX 的 Pinout 界面,先选一个 SPI 外设(通常用 SPI1),Mode 选 Full-Duplex Master。
然后点 Configuration → Parameter Settings:
|
配置项
|
推荐值
|
为什么这么设
|
| --- | --- | --- |
|
Mode
|
Full-Duplex Master
|
STM32 是主机,控制整个通信
|
|
Data Size
|
8 Bits
|
Flash 命令和数据都是按字节的
|
|
First Bit
|
MSB First
|
Flash 数据手册要求高位在前
|
|
CPOL
|
Low
|
时钟空闲时为低电平(SPI Mode 0 或 3)
|
|
CPHA
|
1 Edge
|
在第一个时钟沿采样数据(SPI Mode 0)
|
|
Prescaler
|
64 或 128
|
先慢一点,确认通了再提速
|
|
NSS
|
Software / Disable
|
我们手动控制 CS 引脚
|
CPOL 和 CPHA 合起来决定 SPI Mode。W25Qxx 支持 Mode 0 和 Mode 3,CPOL=Low + CPHA=1 Edge 就是 Mode 0。大多数 SPI Flash 用 Mode 0 都可以工作。

如果第 22 篇已经能稳定读 ID,这里不用乱改。
2. CS 引脚配置
CS 不用 SPI 硬件管理,而是作为普通 GPIO 输出。这样更灵活,换芯片也方便。
在 Pinout 界面随便选一个空闲 GPIO(比如 PA4),设成 GPIO_Output,然后右键给它起个名字:
|
配置项
|
推荐值
|
为什么这么设
|
| --- | --- | --- |
|
GPIO mode
|
Output Push Pull
|
推挽输出,驱动能力够用
|
|
Output Level
|
High
|
默认拉高,CS 高电平时 Flash 不工作
|
|
Pull-up/Pull-down
|
No pull-up and no pull-down
|
用外部上拉或推挽就够了
|
|
User Label
|
SPI_FLASH_CS
|
这个标签会生成宏定义,代码里直接用
|
|
Maximum output speed
|
Low
|
CS 不需要高速切换
|
User Label 很关键:设成 SPI_FLASH_CS 后,CubeMX 会自动生成:
bash
#define SPI_FLASH_CS_Pin GPIO_PIN_4
#define SPI_FLASH_CS_GPIO_Port GPIOA
这样换板子改引脚时,只要在 CubeMX 图形界面里改,不用去代码里翻来翻去。

3. USART 配置
这篇需要串口打印。如果你已经完成第 07 篇 printf(),直接用。
常见配置:
bash
USART1
Asynchronous(异步模式)
Baud Rate:115200
Word Length:8 Bits
Parity:None
Stop Bits:1
即 "115200, 8-N-1"。

CubeMX 配置完成后
点击 "GENERATE CODE" 生成工程。确认以下文件已经在工程里:
bash
Core/Src/main.c (CubeMX 自动生成)
Core/Src/spi.c (CubeMX 自动生成)
Core/Src/gpio.c (CubeMX 自动生成)
Core/Src/usart.c (CubeMX 自动生成)
Core/Inc/main.h
Core/Inc/spi.h
Core/Inc/gpio.h
如果 Keil 打开后提示找不到文件,先确认 CubeMX 生成的代码已经覆盖到工程目录。
代码结构和分层思路
在写代码之前,先理解为什么要分两个文件。
本篇新增:
bash
Core/Inc/app_spi_flash.h ← 驱动层头文件
Core/Src/app_spi_flash.c ← 驱动层:负责和 Flash 通信
Core/Inc/app_spi_flash_dump.h ← 打印层头文件
Core/Src/app_spi_flash_dump.c ← 打印层:负责格式化输出
分层的原因:
bash
main.c
└─ 调用 App_SPIFlashDump_ReadAndPrint() ← 打印层,只管"怎么显示"
└─ 调用 App_SPIFlash_ReadData() ← 驱动层,只管"怎么通信"
└─ 调用 HAL_SPI_xxx() ← HAL 层,操作寄存器
这样后面做写入、擦除、保存参数时,打印还是归打印,驱动还是归驱动,不会搅在一起。
完整代码
1. Core/Inc/app_spi_flash.h
新建这个头文件,声明所有 Flash 相关的接口函数:
bash
#ifndef APP_SPI_FLASH_H
#define APP_SPI_FLASH_H
#include "main.h"
#include <stdint.h>
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_ReadDeviceID(uint8_t *manufacturer_id, uint8_t *device_id);
HAL_StatusTypeDef App_SPIFlash_ReadStatusReg1(uint8_t *status_reg);
HAL_StatusTypeDef App_SPIFlash_ReadData(uint32_t address, uint8_t *buffer, uint16_t length);
#endif
2. Core/Src/app_spi_flash.c
这是核心文件。我们逐段拆解。
2.1 文件头部:宏定义和默认配置
bash
#include "app_spi_flash.h"
// 如果你用的是 SPI2,把下面这行改成 hspi2
#ifndef APP_SPI_FLASH_HANDLE
#define APP_SPI_FLASH_HANDLE hspi1
#endif
// 超时时间。通信慢的话可以适当调大,但不建议超过 500ms
#ifndef APP_SPI_FLASH_TIMEOUT_MS
#define APP_SPI_FLASH_TIMEOUT_MS 100u
#endif
// 安全检查:如果 CubeMX 里的 CS 引脚标签不对,编译直接报错
#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
// Flash 命令定义------来自数据手册的命令表
#define APP_SPI_FLASH_CMD_JEDEC_ID 0x9Fu // 读 JEDEC ID
#define APP_SPI_FLASH_CMD_DEVICE_ID 0x90u // 读制造商+设备 ID
#define APP_SPI_FLASH_CMD_READ_SR1 0x05u // 读状态寄存器 1
#define APP_SPI_FLASH_CMD_READ_DATA 0x03u // 读数据(本篇主角)
#define APP_SPI_FLASH_DUMMY_BYTE 0xFFu // 填充字节,发 0xFF 就行
extern SPI_HandleTypeDef APP_SPI_FLASH_HANDLE;
为什么用 [#ifndef](javascript:;) 而不是直接 [#define](javascript:;)?
这是留给移植时的后门。如果你的 CubeMX 用的是 SPI2,有两种改法:
-
改 CubeMX 后重新生成(推荐)
-
或者在你自己的文件里先定义
[#define](javascript:;) APP_SPI_FLASH_HANDLE hspi2,这里就不会被覆盖
2.2 CS 控制和 SPI 通信封装
bash
// 拉低 CS------告诉 Flash:"注意,我在跟你说话"
static void App_SPIFlash_CS_Low(void)
{
HAL_GPIO_WritePin(SPI_FLASH_CS_GPIO_Port, SPI_FLASH_CS_Pin, GPIO_PIN_RESET);
}
// 拉高 CS------告诉 Flash:"我说完了"
static void App_SPIFlash_CS_High(void)
{
HAL_GPIO_WritePin(SPI_FLASH_CS_GPIO_Port, SPI_FLASH_CS_Pin, GPIO_PIN_SET);
}
这两个函数很简单,但它们的作用贯穿所有 Flash 操作。static 关键字表示它们只在当前 .c 文件内可见,外面不能直接调------这样可以防止别的文件不小心在错误时机操作 CS。
bash
// 只发数据,不收(适合只需要发命令的场景)
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);
}
// 边发边收(适合发命令同时收响应的场景------比如读 JEDEC ID)
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);
}
三个封装函数分别对应三种 SPI 通信场景。注意 Transmit 和 Receive 在 HAL 里是分开的两个函数。HAL 没有"只发不收"或"只收不发"的物理区别------这两个本质上都是全双工的,只是 HAL 帮你忽略了一侧的数据。
2.3 初始化
bash
void App_SPIFlash_Init(void)
{
// 上电后先把 CS 拉高,让 Flash 处于待机状态
App_SPIFlash_CS_High();
}
非常简单,就是确保 CS 初始状态为高。
2.4 读 JEDEC ID(第 22 篇的核心)
bash
HAL_StatusTypeDef App_SPIFlash_ReadJedecID(App_SPIFlash_JedecID *id)
{
// 发送缓冲区:1 字节命令 + 3 字节填充(因为读 ID 有 3 字节空周期)
uint8_t tx_buffer[4] =
{
APP_SPI_FLASH_CMD_JEDEC_ID, // 0x9F
APP_SPI_FLASH_DUMMY_BYTE, // 0xFF(填充)
APP_SPI_FLASH_DUMMY_BYTE, // 0xFF(填充)
APP_SPI_FLASH_DUMMY_BYTE, // 0xFF(填充)
};
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;
}
// rx_buffer[0] 是发 0x9F 时 MISO 上的垃圾数据,丢掉
// rx_buffer[1] = Manufacturer ID(比如 Winbond = 0xEF)
// rx_buffer[2] = Memory Type(比如 W25Q64 = 0x40)
// rx_buffer[3] = Capacity(比如 W25Q64 = 0x17)
id->manufacturer_id = rx_buffer[1];
id->memory_type = rx_buffer[2];
id->capacity = rx_buffer[3];
return HAL_OK;
}
这里用 TransmitReceive(边发边收),因为我们发 4 个字节的同时也要收 4 个字节。第 0 个收到的是无效数据,真正的 ID 从第 1 个字节开始。
2.5 读状态寄存器 1
bash
HAL_StatusTypeDef App_SPIFlash_ReadStatusReg1(uint8_t *status_reg)
{
uint8_t tx_buffer[2] =
{
APP_SPI_FLASH_CMD_READ_SR1, // 0x05
APP_SPI_FLASH_DUMMY_BYTE // 0xFF
};
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;
}
状态寄存器 1 里有两个关键位:
|
位
|
常见名字
|
含义
|
值
|
| --- | --- | --- | --- |
|
bit0
|
BUSY / WIP
|
1 = Flash 正在忙(擦除/写入中);0 = 空闲
|
读出来 & 0x01 判断
|
|
bit1
|
WEL
|
1 = 写使能已打开;0 = 写使能关闭
|
读出来 & 0x02 判断
|
这一篇只读不写,所以暂时不需要一直等 BUSY。但先把状态寄存器读出来看看------上电后通常读出来是 0x00。
2.6 读数据------本篇最核心的函数
bash
HAL_StatusTypeDef App_SPIFlash_ReadData(uint32_t address, uint8_t *buffer, uint16_t length)
{
uint8_t command[4]; // 1 字节命令 + 3 字节地址
HAL_StatusTypeDef status;
if ((buffer == 0) || (length == 0u))
{
return HAL_ERROR;
}
// 组装命令和地址
command[0] = APP_SPI_FLASH_CMD_READ_DATA; // 0x03,读数据命令
command[1] = (uint8_t)((address >> 16) & 0xFFu); // 地址高字节 A[23:16]
command[2] = (uint8_t)((address >> 8) & 0xFFu); // 地址中字节 A[15:8]
command[3] = (uint8_t)(address & 0xFFu); // 地址低字节 A[7:0]
// 整个操作在 CS 拉低期间完成
App_SPIFlash_CS_Low();
// 第一步:发 0x03 + 3 字节地址
status = App_SPIFlash_Transmit(command, 4u);
if (status == HAL_OK)
{
// 第二步:收数据(Flash 从指定地址开始往外吐)
status = App_SPIFlash_Receive(buffer, length);
}
App_SPIFlash_CS_High();
return status;
}
逐行讲解:
command[1] = (uint8_t)((address >> 16) & 0xFFu);
假设 address = 0x001234:
-
0x001234 >> 16=0x000012(右移 16 位,取出高 8 位) -
0x000012 & 0xFF=0x12(只保留最低 8 位) -
结果就是地址的高字节
command[2]:右移 8 位取出中间字节,0x001234 >> 8 = 0x0012 → 0x12
command[3]:不右移,直接取最低字节,0x34
所以最终 command\[\] = {0x03, 0x00, 0x12, 0x34}。
读数据用 Transmit + Receive,而不是 TransmitReceive 的原因:
TransmitReceive 要求发送和接收的长度一样,且同时收发。但读数据是"先发 4 字节,再收 N 字节",长度不对称。所以拆成两个 HAL 调用------Transmit(4字节) 再 Receive(N字节),中间 CS 保持低。
3. Core/Inc/app_spi_flash_dump.h
bash
#ifndef APP_SPI_FLASH_DUMP_H
#define APP_SPI_FLASH_DUMP_H
#include "main.h"
#include <stdint.h>
void App_SPIFlashDump_PrintBuffer(uint32_t address, const uint8_t *buffer, uint16_t length);
void App_SPIFlashDump_ReadAndPrint(uint32_t address, uint16_t length);
#endif
4. Core/Src/app_spi_flash_dump.c
这个文件只负责"把读到的数据打印成好看的样子"。
bash
#include "app_spi_flash_dump.h"
#include "app_spi_flash.h"
#include <stdio.h>
// 每行打印 16 个字节(一行 16 个十六进制数)
#ifndef APP_SPI_FLASH_DUMP_LINE_BYTES
#define APP_SPI_FLASH_DUMP_LINE_BYTES 16u
#endif
void App_SPIFlashDump_PrintBuffer(uint32_t address, const uint8_t *buffer, uint16_t length)
{
uint16_t i;
if ((buffer == 0) || (length == 0u))
{
return;
}
for (i = 0u; i < length; i++)
{
// 每 16 个字节换一行,行首打印当前地址
if ((i % APP_SPI_FLASH_DUMP_LINE_BYTES) == 0u)
{
printf("\r\n0x%06lX: ", (unsigned long)(address + i));
}
// 每个字节打印两位十六进制数
printf("%02X ", buffer[i]);
}
printf("\r\n");
}
void App_SPIFlashDump_ReadAndPrint(uint32_t address, uint16_t length)
{
uint8_t buffer[APP_SPI_FLASH_DUMP_LINE_BYTES]; // 一次读 16 字节
uint16_t offset = 0u;
uint16_t current_len;
HAL_StatusTypeDef status;
while (offset < length)
{
// 还剩多少没读
current_len = (uint16_t)(length - offset);
// 一次最多读 16 字节(buffer 就这么大)
if (current_len > APP_SPI_FLASH_DUMP_LINE_BYTES)
{
current_len = APP_SPI_FLASH_DUMP_LINE_BYTES;
}
status = App_SPIFlash_ReadData(address + offset, buffer, current_len);
if (status != HAL_OK)
{
printf("\r\nRead failed at 0x%06lX, status=%lu\r\n",
(unsigned long)(address + offset),
(unsigned long)status);
return;
}
App_SPIFlashDump_PrintBuffer(address + offset, buffer, current_len);
offset = (uint16_t)(offset + current_len);
}
}
为什么要分 16 字节一组去读,而不是一口气读 64 字节?
两个原因:
-
打印层 buffer 只有 16 字节,读完一组立刻打印,不占用大片内存。
-
如果某次读取出错,能精确知道是在哪个地址失败的。
为什么驱动层不直接一次读 64 字节?
驱动层的 App_SPIFlash_ReadData() 有处理任意长度的能力。但打印层刻意拆成 16 字节一组,是为了"读一组、打印一组、再读下一组"的节奏。这样串口输出是逐行出现的,用户在串口助手里看着更有条理。
Keil 工程设置
在 CubeMX 生成代码后,打开 Keil 工程。需要手动添加两个 .c 文件:
-
在 Keil 左侧工程树,右键
Core/Src→ "Add Existing Files to Group" -
选择
app_spi_flash.c和app_spi_flash_dump.c -
点击 OK
如果跳过这一步,编译会报 undefined symbol App_SPIFlash_ReadData 或类似错误。

另外确认 Keil 的 Options for Target → Target → 勾选了 Use MicroLIB (printf 重定向需要),或者你已经用第 07 篇的方式实现了 fputc()。
main.c 调用方式
1. Includes 区域(文件顶部)
找到 /* USER CODE BEGIN Includes */ 和 /* USER CODE END Includes */ 这对注释,在中间添加:
bash
/* USER CODE BEGIN Includes */
#include "app_spi_flash.h"
#include "app_spi_flash_dump.h"
#include <stdio.h>
/* USER CODE END Includes */
重要: CubeMX 不会删除 USER CODE 区域内的代码,但你写在别的地方的代码可能会被覆盖。始终写在 USER CODE BEGIN 和 USER CODE END 之间。
2. 主初始化后(USER CODE BEGIN 2)
确认以下初始化函数已由 CubeMX 生成在 main() 函数里:
bash
MX_GPIO_Init();
MX_SPI1_Init();
MX_USART1_UART_Init();
然后在 /* USER CODE BEGIN 2 */ 和 /* USER CODE END 2 */ 之间添加:
bash
/* USER CODE BEGIN 2 */
App_SPIFlash_JedecID id;
uint8_t status_reg1 = 0u;
App_SPIFlash_Init();
printf("\r\nSPI Flash read data test\r\n");
if (App_SPIFlash_ReadJedecID(&id) == HAL_OK)
{
printf("JEDEC ID: %02X %02X %02X\r\n",
id.manufacturer_id,
id.memory_type,
id.capacity);
}
else
{
printf("Read JEDEC ID failed.\r\n");
}
if (App_SPIFlash_ReadStatusReg1(&status_reg1) == HAL_OK)
{
printf("Status Register-1: 0x%02X\r\n", status_reg1);
}
else
{
printf("Read status register failed.\r\n");
}
printf("Read 64 bytes from address 0x000000:");
App_SPIFlashDump_ReadAndPrint(0x000000u, 64u);
/* USER CODE END 2 */
3. while 循环
本篇先不要一直刷屏,上电后只读一次就好:
bash
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
HAL_Delay(1000);
/* USER CODE END 3 */
}
怎么看串口输出
用串口助手(比如 SSCOM、Putty、MobaXterm)打开对应 COM 口,波特率 115200,复位 STM32。

现象 1:ID 正常,数据全 FF
bash
JEDEC ID: EF 40 17
0x000000: FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF
这是最常见的正常现象。ID 读对了说明通信没问题。数据全 FF 说明这片区域是空白的(擦除态)。
判断公式:
bash
ID 正常 + 数据全 FF → 通信 OK,区域空白
ID 全 FF + 数据全 FF → 通信有问题,先排查接线和 SPI 配置
现象 2:ID 正常,数据有变化
bash
0x000000: 12 34 56 78 FF FF 00 AA ...
说明这片区域以前写过数据。本篇目标达成:能从指定地址读出连续字节。
现象 3:ID 读出来是全 FF
bash
JEDEC ID: FF FF FF
回到第 23 篇,按表格排查 SPI 通信:接线、CS、Mode、速度、供电。
现象 4:串口完全没输出
先不考虑 SPI 问题。检查串口链路:
-
USART 是否在 CubeMX 里配置并生成代码
-
printf()是否重定向(第 07 篇) -
串口助手波特率是否 115200
-
TX 和 RX 是否接反(TX 接 RX,交叉接)
-
Keil 是否勾选了 MicroLIB(或使用了正确的
fputc()) -
串口助手的 COM 口是否选对了
移植到其他板子的修改点
换 STM32 型号或换 Flash 芯片时,确认以下几点:
|
要改的地方
|
为什么要改
|
在哪里改
|
| --- | --- | --- |
|
SPI 实例
|
可能用 SPI1/SPI2/SPI3
|
CubeMX 和 APP_SPI_FLASH_HANDLE
|
|
SCK/MISO/MOSI
|
不同板子复用引脚不同
|
CubeMX Pinout
|
|
CS 引脚
|
CS 通常是普通 GPIO
|
CubeMX,User Label = SPI_FLASH_CS
|
|
SPI Mode
|
不同 SPI 设备要求不同
|
CubeMX SPI 参数
|
|
SPI 速度
|
线长和模块质量不同
|
Prescaler
|
|
地址长度
|
常见 W25Qxx 用 24 位地址;大于 128Mbit 的 Flash 可能需要 4 字节地址模式
| App_SPIFlash_ReadData() |
|
读命令
|
普通读常见 0x03,其他器件可能不同
|
Flash 数据手册
|
如果你换成别的 SPI 设备,不要直接套 0x03。 0x03 是常见 SPI Flash 的读数据命令,不代表所有 SPI 外设都这么读。换芯片时,第一步永远是翻数据手册的命令表。
常见问题排查
1. 读数据全是 FF
先分两种情况。
JEDEC ID 也全是 FF:
优先回到第 23 篇排查 SPI 通信。
JEDEC ID 正常,只有数据区全 FF:
大概率只是这片区域是擦除态。
SPI Flash 擦除后,每一位都会变成 1,所以一个字节就是 0xFF(8 个 1)。这不是 bug。
2. ID 正常,但读数据失败(返回非 HAL_OK)
优先查:
|
检查项
|
说明
|
| --- | --- |
|
CS 是否保持低电平
|
命令、地址、数据读取必须在同一次 CS 低电平中完成。检查代码是否在中途拉了 CS 高
|
|
地址是否超范围
|
不要读超过 Flash 容量的地址。W25Q32 最大地址 = 0x3FFFFF
|
| buffer
指针是否为 NULL
|
不能传空指针
|
| length
是否为 0
|
本篇代码会直接返回 HAL_ERROR
|
|
SPI 速度是否太快
|
先降速(比如 Prescaler 设 256)确认,通了再慢慢加
|
3. 读出来每次都不一样
常见原因:
-
杜邦线接触不良------换短线或者直接焊接
-
SPI 速度太快------降速
-
GND 不可靠------检查共地
-
SPI Mode 不匹配------对照 Flash 数据手册确认 CPOL/CPHA
-
CS 时序被别的代码打断------检查是否有中断或别的任务在操作同一个 SPI 或 GPIO
先用第 23 篇的方式连续读 ID。如果 ID 都不稳定,先别看读数据函数。
4. 编译报 APP_SPI_FLASH_HANDLE 或 hspi1 问题
默认代码使用:
bash
#define APP_SPI_FLASH_HANDLE hspi1
如果你的 CubeMX 用的是 SPI2,就改成:
bash
#define APP_SPI_FLASH_HANDLE hspi2
或者在 CubeMX 里把 SPI 外设改成 SPI1 再重新生成。
5. 编译报 undefined symbol App_SPIFlashDump_ReadAndPrint
说明 app_spi_flash_dump.c 没有加入 Keil 工程。去 Keil 工程树里右键添加:
bash
Core/Src/app_spi_flash_dump.c
6. 编译报 SPI_FLASH_CS_GPIO_Port is not defined
说明 CS 引脚的 User Label 不是 SPI_FLASH_CS。去 CubeMX → Pinout → 右键 CS 引脚 → Enter User Label → 输入 SPI_FLASH_CS → 重新生成代码。
7. 上电后 Flash 发烫
Flash 可能接反了或者接了 5V。立刻断电,检查 VCC 和 GND,确认供电是 3.3V。
本篇小结
这一篇我们完成了 SPI Flash 的"读数据"。从接线到代码再到串口输出,完整的链路是:
bash
硬件接线正确 → CubeMX 配置 SPI + CS + USART
→ 写驱动层 app_spi_flash.c(发 0x03 + 地址,收数据)
→ 写打印层 app_spi_flash_dump.c(格式化输出到串口)
→ main.c 里调用一次 → 串口助手看到结果
你现在应该知道:
-
CS 拉低时要把命令、地址、数据读取连续完成,中间不能拉高
-
普通读数据命令常见是
0x03,后面跟 3 个地址字节(24 位地址,高字节在前) -
读 JEDEC ID 是确认通信,读数据是访问存储空间
-
数据区全
FF不一定是通信失败------先看 ID 是否正常 -
驱动层负责通信,打印层负责格式化,分层清晰
-
换芯片时要确认读命令、地址长度和容量范围
下一篇:
STM32 SPI Flash 写数据:为什么写之前必须先擦除。
写入这一篇会讲 Write Enable、Page Program、Sector Erase 和 BUSY 等待。