STM32 零基础可移植教程 24:SPI Flash 读数据,先从指定地址读几个字节

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 几乎通用的"普通读"命令。

数据手册里还会给时序图------这是比文字更重要的信息。时序图通常长这样:

从这个时序图上能看出几个关键信息:

  1. CS 拉低后,整个操作不能中断------命令、地址、读数据要在同一次 CS 低电平内完成。

  2. MOSI 上先发命令 0x03,然后发 3 个地址字节。

  3. 发完地址后,MISO 上开始出数据。Flash 每收到一个时钟,就吐出一个 bit。

  4. 数据可以连续读------你一直给时钟,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 正常的前提下,可能只是这片区域是擦除态

判断方法:

  1. 先读 JEDEC ID。

  2. ID 正常 → 通信没问题。

  3. 再看数据区。数据区全 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 字节?

两个原因:

  1. 打印层 buffer 只有 16 字节,读完一组立刻打印,不占用大片内存。

  2. 如果某次读取出错,能精确知道是在哪个地址失败的。

为什么驱动层不直接一次读 64 字节?

驱动层的 App_SPIFlash_ReadData() 有处理任意长度的能力。但打印层刻意拆成 16 字节一组,是为了"读一组、打印一组、再读下一组"的节奏。这样串口输出是逐行出现的,用户在串口助手里看着更有条理。

Keil 工程设置

在 CubeMX 生成代码后,打开 Keil 工程。需要手动添加两个 .c 文件:

  1. 在 Keil 左侧工程树,右键 Core/Src → "Add Existing Files to Group"

  2. 选择 app_spi_flash.c 和 app_spi_flash_dump.c

  3. 点击 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 等待。

相关推荐
硅基手札2 小时前
【串口技术系列文档 10】低功耗与唤醒机制
驱动开发·stm32·单片机·嵌入式硬件·mcu·计算机外设
逐光者9333 小时前
STM32——SPI 屏 · Flash · 高速
java·前端·stm32
yuehuaxin014 小时前
上海贝岭 BL6523GX(D78) 单相计量芯片开发笔记:引脚定义、寄存器配置与校表流程
嵌入式硬件·智能家居·适配器模式·智能硬件
Kuakewei8884 小时前
可调OVP/OCP USB限流保护芯片IC的50μs过流响应测试
嵌入式硬件
雨田言炎5 小时前
STM32之SysTick--系统定时器(内容简单但重要,附件有程序源码)
stm32·单片机·嵌入式硬件
Frag0ut5 小时前
Chrome数据备份指南:本地备份还原书签密码与扩展配置工具
chrome·google·谷歌浏览器·数据同步·备份工具·配置迁移·书签密码备份
ccbw165 小时前
CH32M007K8U7:集成48V三相预驱的RISC-V电机MCU
单片机·嵌入式硬件
编程圈子6 小时前
I.MX6U 裸机开发 24. 电容触摸屏与 I2C 驱动
单片机·嵌入式硬件
FakeOccupational8 小时前
【电路笔记 STM32】Cortex-M7 UART 中断模式(串口接收中断+串口空闲中断)
笔记·stm32·单片机
小李不想当小白8 小时前
USART串口协议(STM32标准库学习笔记)
经验分享·笔记·stm32·单片机·嵌入式硬件·学习