STM32 零基础可移植教程 22:SPI 入门,先读一个外部 Flash

STM32 零基础可移植教程 22:SPI 入门,先读一个外部 Flash

前面几篇我们讲完了 I2C:

bash 复制代码
第 18 篇:I2C Scanner,先确认总线上有没有设备
第 19 篇:I2C 读写寄存器,先读一个设备 ID

这一篇开始讲 SPI。但在动手之前,我们先搞清楚 SPI 到底是什么。

SPI 是什么?

SPI 全称是 Serial Peripheral Interface(串行外设接口)。

它是一种让单片机(主机)和外部芯片(从机)之间互相通信的协议。听起来很抽象?拆开看就简单了。

串行(Serial):数据是一位一位发的,不是 8 位一起发。好处是引脚少。

同步(Synchronous):通信双方共用一根时钟线(SCK)。时钟就像节拍器------主机敲一下,双方同时收发一位数据。

这和 UART 不一样。UART 没有独立的时钟线,双方要靠提前约定好的波特率来猜对方什么时候开始发。SPI 不用猜------时钟线会明确告诉双方"现在收/发一位"。

全双工(Full-Duplex):SPI 可以同时收发。主机通过 MOSI 发数据的同时,从机也在通过 MISO 回数据。就像两条独立车道,一进一出互不影响。

这是 SPI 很关键的一个特点------主机每发一个字节,必然同时收到一个字节。后面写代码时会反复体会到这一点。

SPI 的四根线

SPI 最少需要 4 根线:

|

信号

|

全称

|

方向

|

干什么的

|

| --- | --- | --- | --- |

|

SCK

|

Serial Clock

|

主机→从机

|

时钟线,主机控制通信节奏

|

|

MOSI

|

Master Out Slave In

|

主机→从机

|

主机发给从机的数据线

|

|

MISO

|

Master In Slave Out

|

从机→主机

|

从机回给主机的数据线

|

|

CS

|

Chip Select

|

主机→从机

|

片选,告诉哪个从机"轮到你了"

|

两个容易记混的名字:

bash 复制代码
MOSI = Master Output, Slave Input  → 主机出,从机进
MISO = Master Input, Slave Output  → 主机进,从机出

接线的时候别接反,这是 SPI 调试中最常见的错误。

一次 SPI 通信长什么样

假设你想读一个 Flash 芯片的 ID:

  1. 主机把 Flash 的 CS 拉低 → "Flash,我要跟你说话了"

  2. 主机通过 MOSI 发送命令字节(比如 0x9F,意思是"报上你的 ID")

  3. 主机继续发几个哑字节 (dummy byte,通常是 0xFF)------目的是让时钟继续跳。因为时钟每跳一次,从机才能通过 MISO 回传一位数据

  4. 从机通过 MISO 把 ID 数据一位一位传回来

  5. 主机把 CS 拉高 → "我说完了"

流程图:

bash 复制代码
CS:   \___________________/
      拉低              拉高

SCK:   |‾|_|‾|_|‾|_|‾|_|   (时钟一直在跳)

MOSI:  [0x9F][0xFF][0xFF][0xFF]  (主机发送)

MISO:  [??  ][ID1 ][ID2 ][ID3 ]  (从机回复)

注意:主机发的后 3 个字节(0xFF)本身对 Flash 没有意义,但它们的作用很大------制造时钟。因为 SPI 的时钟是主机控制的,只有主机发数据时钟才会跳,时钟跳了从机才能回数据。

为什么 SPI 没有地址?

这是 SPI 和 I2C 最核心的区别。

I2C 是地址寻址:总线上挂多个设备,每个有一个 7 位地址。主机先发地址,地址匹配的从机回应。

SPI 是硬件片选

bash 复制代码
SCK、MOSI、MISO 三根线 → 所有从机共用
CS 线 → 每个从机独占一根,接到主机的一个 GPIO

主机想跟谁说话,就把谁的 CS 拉低。其他从机的 CS 保持高电平,它们会自动忽略总线上的数据。

bash 复制代码
         SCK / MOSI / MISO(三根线所有设备共用)
              |
     +--------+--------+
     |        |        |
    CS1      CS2      CS3
     |        |        |
   从机1    从机2    从机3

所以 SPI 没有 I2C Scanner 那种"扫一遍总线看谁在"的功能。你必须事先知道:接的是哪个芯片、它的 CS 是哪个 GPIO、它支持什么命令、它的 SPI Mode 是什么。

SPI Mode(CPOL 和 CPHA)

SPI 有一个让新手容易困惑的配置:SPI Mode。它由两个参数决定:

  • CPOL(Clock Polarity,时钟极性):时钟空闲时是高电平还是低电平

  • CPHA(Clock Phase,时钟相位):在时钟的第几个边沿采样数据

组合出 4 种模式:

|

Mode

|

CPOL

|

CPHA

|

空闲时 SCK

|

在哪个边沿采样

|

| --- | --- | --- | --- | --- |

|

Mode 0

|

0

|

0

|

低电平

|

第 1 个边沿

|

|

Mode 1

|

0

|

1

|

低电平

|

第 2 个边沿

|

|

Mode 2

|

1

|

0

|

高电平

|

第 1 个边沿

|

|

Mode 3

|

1

|

1

|

高电平

|

第 2 个边沿

|

不要被这 4 种模式吓到。 对于 W25Qxx 这类 SPI Flash,入门阶段记住一句话:

先用 Mode 0(CPOL = Low,CPHA = 1 Edge)。绝大多数 Flash 都支持。等调通了再研究其他模式。

模式配错的典型现象:读出来全是 0xFF、全是 0x00、或者每次读的值不一样。

SPI、I2C、UART 快速对比

| |

SPI

|

I2C

|

UART

|

| --- | --- | --- | --- |

|

最少线数

|

4 根

|

2 根

|

2 根(TX/RX)

|

|

通信方式

|

同步

|

同步

|

异步

|

|

双工

|

全双工

|

半双工

|

全双工

|

|

速度

|

快(MHz 级)

|

较慢

|

中等

|

|

区分设备

|

CS 硬件片选

|

地址寻址

|

点对点,无寻址

|

|

总线扫描

|

|

有(I2C Scanner)

|

|

|

典型场景

|

Flash、屏幕、传感器

|

传感器、RTC、EEPROM

|

串口调试、GPS、蓝牙

|

有了这些基础概念,再来看具体怎么用 STM32 的 SPI 外设。

所以这一篇只做一个明确目标:

bash 复制代码
用 STM32 SPI 读取一个外部 SPI Flash 的 JEDEC ID

这类 SPI Flash 常见型号是 W25Qxx。

它们通常支持一个标准命令:

bash 复制代码
0x9F:Read JEDEC ID

主机发 0x9F,Flash 会返回 3 个字节:

bash 复制代码
Manufacturer ID
Memory Type
Capacity

这非常适合做 SPI 入门验证。

本篇目标

最终现象:

串口打印类似:

bash 复制代码
SPI Flash ID test
JEDEC ID: manufacturer=0xEF, type=0x40, capacity=0x17
Device ID: manufacturer=0xEF, device=0x16

不同 Flash 型号读出来的值可能不一样。

比如 W25Q32、W25Q64、W25Q128 的容量字节就会不同。

本篇用到的外设:

bash 复制代码
SPI
GPIO Output
USART printf

本篇跑通标准:

  • CubeMX 能正确配置 SPI Master;

  • CS 引脚能作为普通 GPIO 输出;

  • 串口能打印 JEDEC ID;

  • 知道 SCK/MISO/MOSI/CS 分别是什么;

  • 知道 SPI 没有 I2C Scanner 那种地址扫描;

  • 换 SPI Flash 或换板子时,知道要改 SPI 实例、引脚、CS 和 SPI Mode。

准备工作

你需要准备:

|

项目

|

说明

|

| --- | --- |

|

STM32 开发板

|

任意带 SPI 的 STM32 都可以

|

|

SPI Flash 模块

|

W25Q32/W25Q64/W25Q128 等都可以

|

|

下载器

|

ST-LINK/V2 或板载 ST-LINK

|

|

串口工具

|

用来看 ID 输出

|

|

杜邦线

|

外接 SPI Flash 模块时使用

|

|

原理图/模块资料

|

确认引脚和供电电压

|

如果你的开发板板载了 SPI Flash,也可以直接用板载 Flash。

但一定要看原理图,确认它接到哪个 SPI:

bash 复制代码
SPI1 还是 SPI2
SCK/MISO/MOSI 是哪些引脚
CS 接到哪个 GPIO

如果你暂时没有 SPI Flash,就不太适合完整验证这一篇。

SPI 没有 I2C Scanner 那种"空总线也能扫一遍"的验证方式。

没有从机时,MISO 可能读到:

bash 复制代码
0xFF
0x00
随机值

这些都不能证明 SPI 真的通了。

硬件连接

SPI Flash 常见引脚:

|

SPI Flash

|

STM32

|

| --- | --- |

|

VCC

|

3.3V

|

|

GND

|

GND

|

|

SCK / CLK

|

SPI SCK

|

|

DO / SO / MISO

|

SPI MISO

|

|

DI / SI / MOSI

|

SPI MOSI

|

|

CS / NSS

|

任意普通 GPIO 输出

|

注意几个命名:

bash 复制代码
MOSI:Master Out Slave In,主机输出,从机输入
MISO:Master In Slave Out,主机输入,从机输出

所以接线是:

bash 复制代码
STM32 MOSI -> Flash DI/SI
STM32 MISO -> Flash DO/SO
STM32 SCK  -> Flash CLK/SCK
STM32 GPIO -> Flash CS

别把 MOSI 和 MISO 接反。

很多模块丝印写的是:

bash 复制代码
DO
DI
CLK
CS

其中:

bash 复制代码
DO -> 接 STM32 MISO
DI -> 接 STM32 MOSI

这点很容易接错。

CS 为什么单独用 GPIO 控制

SPI 的 CS 很关键。

它的作用是:

bash 复制代码
选中当前要通信的从机

通常:

bash 复制代码
CS 拉低 -> 选中 Flash
CS 拉高 -> 结束本次通信

本篇不依赖硬件 NSS。

我们把 CS 当成普通 GPIO 输出,自己控制:

bash 复制代码
CS 拉低
发送命令并接收数据
CS 拉高

这样做的好处是:

  • 新手更容易理解一帧 SPI 通信的开始和结束;

  • 后面一个 SPI 总线上挂多个设备时,每个设备都可以有自己的 CS;

  • 不同开发板移植时,只要改 CS 引脚即可。

本篇要求在 CubeMX 里给 CS 引脚设置 User Label:

bash 复制代码
SPI_FLASH_CS

这样代码里会使用:

bash 复制代码
SPI_FLASH_CS_GPIO_Port
SPI_FLASH_CS_Pin

如果没设置标签,编译时会直接报错提醒。

CubeMX 配置步骤

1. 新建或复制工程

建议从第 07 篇 USART printf 工程复制一份,改名为:

bash 复制代码
22_spi_flash_id

因为这篇需要串口打印 ID。

如果你重新建工程,也按前面流程:

  1. 选择芯片型号;

  2. SYS -> Debug 设置为 Serial Wire

  3. 配置 USART printf;

  4. 配置 SPI;

  5. 配置 CS GPIO;

  6. 生成 Keil 工程。

2. 配置 SPI

选择一个 SPI,比如:

bash 复制代码
SPI1

模式选择:

bash 复制代码
Full-Duplex Master

常见 SPI1 引脚示例:

|

SPI1 信号

|

常见引脚

|

| --- | --- |

|

SCK

|

PA5

|

|

MISO

|

PA6

|

|

MOSI

|

PA7

|

具体以你的芯片和开发板原理图为准。

3. 设置 SPI 参数

推荐入门配置:

|

配置项

|

推荐值

|

说明

|

| --- | --- | --- |

|

Mode

|

Full-Duplex Master

|

STM32 当主机

|

|

Hardware NSS Signal

|

Disable / Software

|

CS 用普通 GPIO 控制

|

|

Data Size

|

8 Bits

|

Flash 命令按字节收发

|

|

First Bit

|

MSB First

|

常见 SPI Flash 要求

|

|

Prescaler

|

64 或 128

|

先慢一点,稳定优先

|

|

Clock Polarity

|

Low

|

Mode 0

|

|

Clock Phase

|

1 Edge

|

Mode 0

|

|

CRC Calculation

|

Disable

|

入门先不用

|

为什么分频先设大一点?

因为刚开始排查接线和模式,速度越慢越好排。

等 ID 读通了,再逐步提高 SPI 速度。

4. 配置 CS 引脚

找一个普通 GPIO 作为 Flash 的 CS。

配置为:

bash 复制代码
GPIO_Output

初始电平建议:

bash 复制代码
High

因为 CS 通常低电平有效。

User Label 设置为:

bash 复制代码
SPI_FLASH_CS

CubeMX 生成后,main.h 里会有:

bash 复制代码
#define SPI_FLASH_CS_Pin ...
#define SPI_FLASH_CS_GPIO_Port ...

本篇代码就靠这两个宏控制片选。

5. 配置 USART printf

继续使用前面的串口打印配置:

bash 复制代码
115200
8 数据位
无校验
1 停止位

6. 生成 Keil 工程

点击:

bash 复制代码
GENERATE CODE

打开 Keil,先编译 CubeMX 原始工程。

确认:

bash 复制代码
0 Error(s)

再加本篇代码。

Keil 工程生成和编译

本篇新增两个文件:

bash 复制代码
Core/Inc/app_spi_flash.h
Core/Src/app_spi_flash.c

如果你手动新建 .c 文件,记得在 Keil 工程树里添加:

bash 复制代码
Core/Src/app_spi_flash.c

否则会报:

bash 复制代码
undefined symbol App_SPIFlash_ReadJedecID

这不是函数写错,而是 .c 文件没有参与编译。

完整代码

1. 新建 Core/Inc/app_spi_flash.h

bash 复制代码
#ifndef APP_SPI_FLASH_H
#define APP_SPI_FLASH_H

#include "main.h"
#include <stdint.h>

/*
 * JEDEC ID 结构体
 * 通过 0x9F 命令从 SPI Flash 读取,包含制造商、存储类型、容量三个字节
 */
typedef struct
{
    uint8_t manufacturer_id;  /* 制造商 ID,例如 0xEF = Winbond       */
    uint8_t memory_type;      /* 存储类型,不同型号返回不同值          */
    uint8_t capacity;         /* 容量标识,W25Q32/W25Q64/W25Q128 不同  */
} App_SPIFlash_JedecID;

/* 初始化:将 CS 引脚拉高,确保 Flash 处于未选中状态 */
void App_SPIFlash_Init(void);

/* 读取 JEDEC ID(命令 0x9F),结果存入 id 指向的结构体 */
HAL_StatusTypeDef App_SPIFlash_ReadJedecID(App_SPIFlash_JedecID *id);

/* 读取 Device ID(命令 0x90),返回制造商 ID 和设备 ID */
HAL_StatusTypeDef App_SPIFlash_ReadDeviceID(uint8_t *manufacturer_id, uint8_t *device_id);

#endif

2. 新建 Core/Src/app_spi_flash.c

bash 复制代码
#include "app_spi_flash.h"

/*
 * 为什么 SPI 和 CS 要分开控制?
 * SPI 外设只负责 SCK/MOSI/MISO 三根线的时序。
 * CS(片选)由应用层通过普通 GPIO 手动拉低/拉高。
 * 好处:一帧通信的开始和结束明确;一个 SPI 总线上挂多个设备时,
 *       每个设备各用一个 GPIO 做 CS;换板子移植时只改 CS 引脚。
 */

/* 默认使用 SPI1。如果 Flash 接到 SPI2,改为 hspi2 即可 */
#ifndef APP_SPI_FLASH_HANDLE
#define APP_SPI_FLASH_HANDLE hspi1
#endif

/* SPI 通信超时时间(毫秒),读 ID 这种几个字节的操作 100ms 足够 */
#ifndef APP_SPI_FLASH_TIMEOUT_MS
#define APP_SPI_FLASH_TIMEOUT_MS 100u
#endif

/* 编译期检查:CS 引脚必须在 CubeMX 中设置 User Label = "SPI_FLASH_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

/*
 * SPI Flash 常用命令
 * 0x9F = JEDEC ID:读取制造商 + 存储类型 + 容量(3 字节)
 * 0x90 = Device ID:需要先发 3 字节地址,返回制造商 + 设备 ID
 */
#define APP_SPI_FLASH_CMD_JEDEC_ID  0x9Fu
#define APP_SPI_FLASH_CMD_DEVICE_ID 0x90u

/*
 * 哑字节(Dummy Byte)
 * SPI 是全双工的:主机每发一个字节,必然同时收到一个字节。
 * 当主机只想收数据时,必须发一些无意义的字节来维持时钟------这就是哑字节。
 * 为什么是 0xFF?Flash 的 MISO 空闲时通常被上拉,发 0xFF 干扰最小。
 */
#define APP_SPI_FLASH_DUMMY_BYTE    0xFFu

/* 引用 CubeMX 在 spi.c 中生成的 SPI 句柄 */
extern SPI_HandleTypeDef APP_SPI_FLASH_HANDLE;

/* CS 拉低:选中 Flash。CS 低电平有效------Flash 看到 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,通信结束 */
static void App_SPIFlash_CS_High(void)
{
    HAL_GPIO_WritePin(SPI_FLASH_CS_GPIO_Port, SPI_FLASH_CS_Pin, GPIO_PIN_SET);
}

/*
 * 封装 HAL 的发送+接收函数。
 * 为什么用 HAL_SPI_TransmitReceive 而不是分开写?
 * 因为 SPI 是全双工的------收发在同一个时钟周期完成,
 * 分开调用会导致中间时钟停顿,Flash 可能误判。
 */
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);
}

/* 初始化:上电后调用一次,确保 CS 为高(Flash 未选中) */
void App_SPIFlash_Init(void)
{
    App_SPIFlash_CS_High();
}

/*
 * 读取 JEDEC ID(命令 0x9F)
 *
 * 通信时序(参考前面"一次 SPI 通信长什么样"的图):
 *   CS 拉低
 *   MOSI 发送: [0x9F] [0xFF] [0xFF] [0xFF]
 *   MISO 收到: [??  ] [ID1 ] [ID2 ] [ID3 ]
 *   CS 拉高
 *
 * rx_buffer 下标说明:
 *   [0] = 发送 0x9F 时收到的(无效,Flash 还没反应过来)
 *   [1] = 第 1 个哑字节换回 → Manufacturer ID
 *   [2] = 第 2 个哑字节换回 → Memory Type
 *   [3] = 第 3 个哑字节换回 → Capacity
 */
HAL_StatusTypeDef App_SPIFlash_ReadJedecID(App_SPIFlash_JedecID *id)
{
    uint8_t tx_buffer[4] =
    {
        APP_SPI_FLASH_CMD_JEDEC_ID,   /* [0] 命令                        */
        APP_SPI_FLASH_DUMMY_BYTE,     /* [1] 哑字节 → 换 Manufacturer ID */
        APP_SPI_FLASH_DUMMY_BYTE,     /* [2] 哑字节 → 换 Memory Type     */
        APP_SPI_FLASH_DUMMY_BYTE      /* [3] 哑字节 → 换 Capacity         */
    };
    uint8_t rx_buffer[4] = {0u};
    HAL_StatusTypeDef status;

    if (id == 0)                     /* 空指针保护 */
    {
        return HAL_ERROR;
    }

    /* 标准 SPI 通信帧:CS 拉低 → 收发数据 → CS 拉高 */
    App_SPIFlash_CS_Low();
    status = App_SPIFlash_TransmitReceive(tx_buffer, rx_buffer, 4u);
    App_SPIFlash_CS_High();

    if (status != HAL_OK)
    {
        return status;               /* SPI 通信失败,直接返回错误码 */
    }

    /* 从接收缓冲区提取 3 字节 ID(跳过 [0],那是命令阶段的无效数据) */
    id->manufacturer_id = rx_buffer[1];
    id->memory_type      = rx_buffer[2];
    id->capacity         = rx_buffer[3];

    return HAL_OK;
}

/*
 * 读取 Device ID(命令 0x90)
 *
 * 通信时序:
 *   MOSI: [0x90] [0x00] [0x00] [0x00] [0xFF] [0xFF]
 *         \命令/ \---- 3 字节地址 ----/ \-- 哑字节 --/
 *   MISO: [??  ] [??  ] [??  ] [??  ] [ID1 ] [ID2 ]
 *
 * 和 0x9F 的区别:0x9F 不需要地址直接返回,0x90 需要先发 3 字节地址
 * 两个命令返回的 manufacturer ID 应该一致,可以互相印证
 *
 * rx_buffer 下标说明:
 *   [0..3] = 命令+地址阶段的无效数据
 *   [4] = Manufacturer ID
 *   [5] = Device ID
 */
HAL_StatusTypeDef App_SPIFlash_ReadDeviceID(uint8_t *manufacturer_id, uint8_t *device_id)
{
    uint8_t tx_buffer[6] =
    {
        APP_SPI_FLASH_CMD_DEVICE_ID,  /* [0] 命令 0x90                     */
        0x00u,                        /* [1] 地址字节 1(通常填 0)         */
        0x00u,                        /* [2] 地址字节 2(通常填 0)         */
        0x00u,                        /* [3] 地址字节 3(通常填 0)         */
        APP_SPI_FLASH_DUMMY_BYTE,     /* [4] 哑字节 → 换 Manufacturer ID   */
        APP_SPI_FLASH_DUMMY_BYTE      /* [5] 哑字节 → 换 Device ID         */
    };
    uint8_t rx_buffer[6] = {0u};
    HAL_StatusTypeDef status;

    if ((manufacturer_id == 0) || (device_id == 0))  /* 空指针保护 */
    {
        return HAL_ERROR;
    }

    App_SPIFlash_CS_Low();
    status = App_SPIFlash_TransmitReceive(tx_buffer, rx_buffer, 6u);
    App_SPIFlash_CS_High();

    if (status != HAL_OK)
    {
        return status;
    }

    /* 提取后 2 字节:跳过前面命令+地址阶段的无效数据 */
    *manufacturer_id = rx_buffer[4];
    *device_id       = rx_buffer[5];

    return HAL_OK;
}

这里最关键的是 CS 拉低→收发→CS 拉高这个顺序:

bash 复制代码
App_SPIFlash_CS_Low();                                    // 1. 选中 Flash
status = App_SPIFlash_TransmitReceive(tx_buffer, rx_buffer, 4u); // 2. 发送命令+收数据
App_SPIFlash_CS_High();                                   // 3. 释放 Flash

SPI Flash 看到 CS 拉低后,才认为一帧通信开始。

通信过程中 CS 要保持低。

读完以后 CS 拉高,Flash 才认为这一帧结束。

main.c 调用方式

1. 添加头文件

main.c 顶部添加:

bash 复制代码
/* USER CODE BEGIN Includes */
#include "app_spi_flash.h"  /* SPI Flash 驱动 */
#include <stdio.h>           /* printf 需要                   */
/* USER CODE END Includes */

2. 初始化后读取 ID

确认 CubeMX 已生成:

bash 复制代码
MX_GPIO_Init();
MX_SPI1_Init();
MX_USART1_UART_Init();

然后在 USER CODE BEGIN 2 中添加:

bash 复制代码
/* USER CODE BEGIN 2 */
App_SPIFlash_JedecID jedec_id;       /* 存放 JEDEC ID(3 字节)    */
uint8_t manufacturer_id = 0u;        /* 存放 Device ID 的制造商字节 */
uint8_t device_id = 0u;              /* 存放 Device ID 的设备字节   */

App_SPIFlash_Init();                 /* 初始化 CS 为高电平         */

printf("\r\nSPI Flash ID test\r\n");

/* 方式一:用 0x9F 命令读 JEDEC ID */
if (App_SPIFlash_ReadJedecID(&jedec_id) == HAL_OK)
{
    printf("JEDEC ID: manufacturer=0x%02X, type=0x%02X, capacity=0x%02X\r\n",
           jedec_id.manufacturer_id,
           jedec_id.memory_type,
           jedec_id.capacity);
}
else
{
    printf("Read JEDEC ID failed.\r\n");
}

/* 方式二:用 0x90 命令读 Device ID,两个命令返回的 manufacturer 可以互相印证 */
if (App_SPIFlash_ReadDeviceID(&manufacturer_id, &device_id) == HAL_OK)
{
    printf("Device ID: manufacturer=0x%02X, device=0x%02X\r\n",
           manufacturer_id,
           device_id);
}
else
{
    printf("Read Device ID failed.\r\n");
}
/* USER CODE END 2 */

3. while 循环

本篇只在上电后读一次 ID,while 里先不用写。

如果你想每 2 秒读一次,也可以放到 USER CODE BEGIN 3

但入门调试时,建议先读一次,串口输出更干净。

编译、下载和验证

代码加完后:

  1. Keil 编译;

  2. 下载程序;

  3. 打开串口助手;

  4. 复位开发板;

  5. 查看输出。

正常情况下可能看到:

bash 复制代码
SPI Flash ID test
JEDEC ID: manufacturer=0xEF, type=0x40, capacity=0x17
Device ID: manufacturer=0xEF, device=0x16

这里 0xEF 常见于 Winbond。

不同 Flash 返回值可能不同。

如果你看到:

bash 复制代码
JEDEC ID: manufacturer=0xFF, type=0xFF, capacity=0xFF

或者:

bash 复制代码
JEDEC ID: manufacturer=0x00, type=0x00, capacity=0x00

通常说明 SPI 还没真正通。

优先查:

bash 复制代码
CS
MISO/MOSI 是否接反
SPI Mode
Flash 供电
SPI 速度

移植到其他板子的修改点

|

要改的地方

|

为什么要改

|

在哪里改

|

| --- | --- | --- |

|

SPI 实例

|

可能用 SPI1、SPI2、SPI3

|

CubeMX,APP_SPI_FLASH_HANDLE

|

|

SCK/MISO/MOSI 引脚

|

不同板子 SPI 引脚不同

|

CubeMX Pinout

|

|

CS 引脚

|

CS 通常是任意 GPIO

|

CubeMX GPIO,User Label = SPI_FLASH_CS

|

|

SPI Mode

|

不同设备要求不同

|

CubeMX CPOL/CPHA

|

|

SPI 分频

|

线长和模块影响速度

|

CubeMX Prescaler

|

|

Flash 命令

|

不同 SPI 设备命令不同

| APP_SPI_FLASH_CMD_* |

|

ID 含义

|

不同芯片返回 ID 不同

|

查芯片手册

|

如果你用 SPI2,把代码里的默认句柄改成:

bash 复制代码
#define APP_SPI_FLASH_HANDLE hspi2

如果你 CS 引脚标签不是 SPI_FLASH_CS,建议回 CubeMX 改成统一标签。

这样应用代码不用到处换名字。

常见问题排查

1. 编译报 SPI_FLASH_CS_GPIO_Port is not defined

说明 CubeMX 没有生成:

bash 复制代码
SPI_FLASH_CS_GPIO_Port
SPI_FLASH_CS_Pin

解决方法:

  1. 回 CubeMX;

  2. 找到 CS 引脚;

  3. 设置为 GPIO_Output

  4. User Label 填 SPI_FLASH_CS

  5. 重新 Generate Code。

2. 编译报 hspi1 未定义

说明你的工程没有开启 SPI1,或者实际用的是 SPI2。

打开 spi.c 看句柄:

bash 复制代码
SPI_HandleTypeDef hspi1;

还是:

bash 复制代码
SPI_HandleTypeDef hspi2;

如果实际是 SPI2,就改:

bash 复制代码
#define APP_SPI_FLASH_HANDLE hspi2

3. 读出来全是 0xFF

常见原因:

  • MISO 没接好;

  • Flash 没有被 CS 选中;

  • CS 一直高;

  • Flash 没供电;

  • MISO 被上拉;

  • SPI Mode 不对。

先量一下 CS:

bash 复制代码
读 ID 时 CS 应该从高变低,再回到高

如果 CS 从来不变,先查 CS GPIO。

4. 读出来全是 0x00

常见原因:

  • MISO 被拉低;

  • Flash 供电异常;

  • MISO/MOSI 接错;

  • SPI Mode 不对;

  • 设备不是 SPI Flash,命令不支持 0x9F

5. ID 每次都不一样

优先考虑:

  • 杜邦线接触不良;

  • SPI 速度太快;

  • GND 没接好;

  • 线太长;

  • CPOL/CPHA 不匹配;

  • Flash 电源不稳。

先把 Prescaler 调大,让 SPI 慢下来。

6. MISO 和 MOSI 到底怎么接

记住:

bash 复制代码
STM32 MOSI -> Flash DI/SI
STM32 MISO -> Flash DO/SO

有些模块不会写 MOSI/MISO,而是写:

bash 复制代码
DI
DO

从 Flash 角度看:

bash 复制代码
DI = Data In  = Flash 输入  = 接 STM32 MOSI
DO = Data Out = Flash 输出  = 接 STM32 MISO

7. SPI 没有扫描,那怎么知道设备在不在

SPI 没有 I2C 地址扫描。

你只能通过具体设备支持的命令验证。

对 SPI Flash 来说,常用:

bash 复制代码
0x9F 读 JEDEC ID

如果是其他 SPI 设备,就要查它的数据手册。

比如某些传感器有:

bash 复制代码
WHO_AM_I
ID
Product ID

但命令格式可能和 Flash 完全不同。

不要拿 0x9F 去读所有 SPI 设备。

本篇小结

这一篇我们完成了 SPI 入门的第一步:读取外部 SPI Flash ID。

你现在应该知道:

  • SPI 常见信号是 SCK、MOSI、MISO、CS;

  • SPI 没有 I2C 那种地址扫描,靠 CS 选择设备;

  • CS 通常用普通 GPIO 手动控制;

  • 读 Flash JEDEC ID 常用命令是 0x9F

  • SPI Mode 由 CPOL/CPHA 决定,入门先用 Mode 0;

  • 读到全 0xFF 或全 0x00 时,先查 CS、MISO/MOSI、供电和 SPI Mode;

  • 换板子时重点改 SPI 实例、SCK/MISO/MOSI、CS、分频和命令。

下一篇继续 SPI:

STM32 SPI 排坑:读出来全是 0xFF、0x00 或 ID 乱跳,先查什么。

SPI 的坑和 I2C 不一样,下一篇会重点讲 CS 时序、MISO/MOSI、Mode 0/3、速度、接线和逻辑分析仪怎么看。

相关推荐
崇山峻岭之间1 小时前
单片机USB 鼠标键盘实验
单片机·嵌入式硬件·计算机外设
大卡片1 小时前
单片机第二次答辩
单片机·嵌入式硬件
广州灵眸科技有限公司10 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) 开发(编译)方式说明
linux·服务器·单片机·嵌入式硬件·电脑
IT_阿水11 小时前
STM32 HAL库输入捕获配置
stm32·单片机·嵌入式硬件
nuoxin11411 小时前
WILX1200HC-5TG144I替代 LCMXO2-1200HC-5TG144I(富利威)
人工智能·嵌入式硬件·fpga开发·电脑·硬件工程·dsp开发
zlinear数据采集卡12 小时前
555触摸延时开关深度解析:从电路原理到智能楼道灯应用
单片机·嵌入式硬件
国科安芯15 小时前
国科安芯推出商业航天级抗辐照全双工 RS485/422 收发器 ASC491S2Y
网络·分布式·单片机·架构·安全性测试
czhaii15 小时前
LCD320240间接接口 RA8835控制器 温度MAX6675显示
单片机·嵌入式硬件·硬件工程
破晓单片机15 小时前
030、STM32项目分享:计时充电桩系统
stm32·单片机·嵌入式硬件