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 通信场景。注意 TransmitReceive 在 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.capp_spi_flash_dump.c

  3. 点击 OK

如果跳过这一步,编译会报 undefined symbol App_SPIFlash_ReadData 或类似错误。

另外确认 Keil 的 Options for TargetTarget → 勾选了 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 BEGINUSER 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_HANDLEhspi1 问题

默认代码使用:

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 EnablePage ProgramSector EraseBUSY 等待。

相关推荐
John_ToDebug2 小时前
在 Windows 上搭建 Chromium 148 内核编译环境:一份实战笔记
chrome·经验分享·笔记
崇山峻岭之间2 小时前
单片机汉字显示实验
单片机·嵌入式硬件
guygg882 小时前
基于C# + Halcon的通用ROI绘制工具
stm32·单片机·c#
yugi9878382 小时前
基于 RFID 的智能公交刷卡系统
stm32·嵌入式硬件
点灯小铭3 小时前
基于单片机的雨量检测智能汽车雨刮器模拟系统设计与实现
单片机·嵌入式硬件·汽车·毕业设计·课程设计·期末大作业
三佛科技-134163842124 小时前
腕式血压计方案开发设计,腕式血压计MCU控制芯片选择
单片机·嵌入式硬件·物联网·智能家居
lzhdim4 小时前
C盘空间多出来4GB:谷歌服软 Chrome本地AI大模型可禁用、删除了
前端·人工智能·chrome
cici158744 小时前
C# LAS 点云读取与处理工具
stm32·单片机·c#
三佛科技-187366133975 小时前
GD32F103RCT6兆易创新LQFP64,32 位 ARM Cortex-M3 微控制器芯片解析
单片机·嵌入式硬件