80_聊聊SPI以及它们的变体

文章目录

  • [一、发展历史:从 `Motorola` 实验室到每一块 `MCU` 的标配](#一、发展历史:从 Motorola 实验室到每一块 MCU 的标配)
    • [1. 诞生:为板级通信定下四线规矩](#1. 诞生:为板级通信定下四线规矩)
    • [2. 嵌入化:被 `MCU` 厂商收归成标配外设](#2. 嵌入化:被 MCU 厂商收归成标配外设)
    • [3. 技术演进:从单工到全双工,再到多设备高速互联](#3. 技术演进:从单工到全双工,再到多设备高速互联)
  • 二、工作原理:四根线的全双工华尔兹
    • [1. 最直白的比喻:一个邮局 + N 个家庭](#1. 最直白的比喻:一个邮局 + N 个家庭)
    • [2. 核心机制:两个环起来的移位寄存器](#2. 核心机制:两个环起来的移位寄存器)
    • [3. 关键特性一:一主多从,片选独占](#3. 关键特性一:一主多从,片选独占)
    • [4. 关键特性二:四种时序模式,选对才能通](#4. 关键特性二:四种时序模式,选对才能通)
      • [(1) 模式 `0`(`CPOL=0, CPHA=0`)](#(1) 模式 0(CPOL=0, CPHA=0))
      • [(2) 模式 `1`(`CPOL=0, CPHA=1`)](#(2) 模式 1(CPOL=0, CPHA=1))
      • [(3) 模式 `2`(`CPOL=1, CPHA=0`)](#(3) 模式 2(CPOL=1, CPHA=0))
      • [(4) 模式 `3`(`CPOL=1, CPHA=1`)](#(4) 模式 3(CPOL=1, CPHA=1))
      • [(5) 一张表记住选型口诀](#(5) 一张表记住选型口诀)
  • 三、技术规格:四根线的参数世界
    • [1. 引脚定义与连接](#1. 引脚定义与连接)
    • [2. 通信格式与速率](#2. 通信格式与速率)
    • [3. 常用 API(标准库)](#3. 常用 API(标准库))
    • [4. 通信协议](#4. 通信协议)
      • [(1) 起始条件](#(1) 起始条件)
      • [(2) 终止条件](#(2) 终止条件)
      • [(3) 发送指令](#(3) 发送指令)
      • [(4) 指定地址写](#(4) 指定地址写)
      • [(5) 指定地址读](#(5) 指定地址读)
      • [(6) 模式的选择](#(6) 模式的选择)
        • [a. 交换一个字节(模式 `0`)](#a. 交换一个字节(模式 0))
        • [b. 交换一个字节(模式 `1`)](#b. 交换一个字节(模式 1))
        • [c. 交换一个字节(模式 `2`)](#c. 交换一个字节(模式 2))
        • [d. 交换一个字节(模式 `3`)](#d. 交换一个字节(模式 3))
  • [四、硬件实现:`STM32` 的 `SPI` 外设](#四、硬件实现:STM32 的 SPI 外设)
    • [1. 功能框图:硬件替你踩点收发](#1. 功能框图:硬件替你踩点收发)
    • [2. 特殊机制:软件管理 `NSS`](#2. 特殊机制:软件管理 NSS)
  • [五、实践指南:以 `SPI1` 驱动 `W25Q32` 为例](#五、实践指南:以 SPI1 驱动 W25Q32 为例)
    • [1. 配置流程四步走](#1. 配置流程四步走)
      • [(1) `SCK(时钟线)`------时钟线永远是主机往外发,所以配推挽复用输出](#(1) SCK(时钟线)——时钟线永远是主机往外发,所以配推挽复用输出)
      • [(2) `MOSI(主出从入线)`------主出从入,数据方向决定谁是输出](#(2) MOSI(主出从入线)——主出从入,数据方向决定谁是输出)
      • [(3) `MISO(主入从出线)`------主入从出,方向与 `MOSI(主出从入线)` 正好反着来](#(3) MISO(主入从出线)——主入从出,方向与 MOSI(主出从入线) 正好反着来)
      • [(4) `NSS`------软件管理时和 `SPI` 外设没关系,也叫片选线就当普通 `GPIO`](#(4) NSS——软件管理时和 SPI 外设没关系,也叫片选线就当普通 GPIO)
      • [(5) 一句话刻在脑子里](#(5) 一句话刻在脑子里)
    • [2. 以 `SPI1` 连接 `W25Q32` 为例](#2. 以 SPI1 连接 W25Q32 为例)
      • [(1) 参数确定](#(1) 参数确定)
      • [(2) 初始化代码](#(2) 初始化代码)
      • [(3) 读写函数](#(3) 读写函数)
  • [六、多设备通信示例:同一条总线挂两片 `W25Q32`](#六、多设备通信示例:同一条总线挂两片 W25Q32)
    • [1. 统一片选管理宏](#1. 统一片选管理宏)
    • [2. 从机分路读写示例](#2. 从机分路读写示例)
  • [七、多设备挂载同一 `SPI` 总线](#七、多设备挂载同一 SPI 总线)
    • 1、你的理解完全正确
    • [2. 编程思路:片选就是"点名"](#2. 编程思路:片选就是“点名”)
    • [3. 片选的机理](#3. 片选的机理)
      • [(1) 阶段一:`SS` 下降沿------吹哨,起跑](#(1) 阶段一:SS 下降沿——吹哨,起跑)
      • [(2) 阶段二:`8` 个时钟------两位一体,边发边收](#(2) 阶段二:8 个时钟——两位一体,边发边收)
      • [(3) 阶段三:`SS` 上升沿------锁存,执行](#(3) 阶段三:SS 上升沿——锁存,执行)
      • [(4) 阶段四:连续的 `8` 位×N------指令、地址、数据串在一起](#(4) 阶段四:连续的 8 位×N——指令、地址、数据串在一起)
      • [(5) 一张表看清楚三个字节在移位寄存器里的流转](#(5) 一张表看清楚三个字节在移位寄存器里的流转)
      • [(6) 一句话刻进脑子里](#(6) 一句话刻进脑子里)
    • [4. 完整代码示例(四设备)](#4. 完整代码示例(四设备))
      • [(1) 片选引脚定义](#(1) 片选引脚定义)
      • [(3) 各设备读写函数](#(3) 各设备读写函数)
    • [5. 底层驱动层 vs 应用层](#5. 底层驱动层 vs 应用层)
  • [八、手搓纯软件模拟 `SPI`](#八、手搓纯软件模拟 SPI)
    • [1. 核心思想:用 `GPIO` 手工踩出时钟与数据](#1. 核心思想:用 GPIO 手工踩出时钟与数据)
    • [2. 平台适配宏](#2. 平台适配宏)
    • [3. 软件 `SPI` 读写函数(模式 `0`)](#3. 软件 SPI 读写函数(模式 0))
    • [4. 软件方式 vs 硬件方式](#4. 软件方式 vs 硬件方式)
  • 九、提高稳定性的技巧
    • [1. 片选信号"先拉后放"](#1. 片选信号“先拉后放”)
    • [2. 上拉电阻防悬空](#2. 上拉电阻防悬空)
    • [3. 模式与分频严格匹配从机数据手册](#3. 模式与分频严格匹配从机数据手册)
  • 十、结语:全双工的高速舞者

  你可能已经发现,面对一块小小的电路板,那些传感器、存储器、屏幕之间总需要几根线来快速交换数据,而在众多通信协议中,SPI 总是以"高速、全双工"的姿态出现。 它就是 SPI,全称 Serial Peripheral Interface (串行外设接口),由 Motorola 打造的一种同步串行总线。今天这篇博客,咱们把它从四根线说起,一路拆到硬件寄存器与软件模拟,让你不仅会用,更知道它为什么能跑这么快。

一、发展历史:从 Motorola 实验室到每一块 MCU 的标配

1. 诞生:为板级通信定下四线规矩

  SPI 诞生于 20 世纪 80 年代,Motorola 在开发微控制器时发现:传统并行总线虽然快,但引脚太多,不利于小型化设计。

  于是他们定义了一种只用四根线的串行总线------SCK(时钟线)(串行时钟)、MOSI(主出从入线)(主出从入)、MISO(主入从出线)(主入从出)、SS(从机选择)。这四根线一拉出来,所有设备共享时钟和数据线,唯独片选线各用一根,既省引脚又容易扩展。

2. 嵌入化:被 MCU 厂商收归成标配外设

  SPI 因其简洁高效,迅速被各大半导体厂商采纳。从 51 单片机用 GPIO 软件模拟时序,到 AVR、PIC 集成硬件 SPI 模块,再到 STM32 把 SPI 做进内核级外设,自动生成时钟、收发数据,CPU 只需读数据寄存器即可。 STM32F103C8T6 就内置了 SPI1 和 SPI2 两组外设,最高时钟频率可达 18MHz(SPI1 挂载高速总线 APB2),还支持 DMA 与 I2S 音频协议。

3. 技术演进:从单工到全双工,再到多设备高速互联

阶段 代表平台 关键指标 核心特性
1980s Motorola 68HC11 1MHz 以下 四线制,主从架构
1990s AVR、PIC 2--8MHz 硬件移位寄存器,支持 LSB/MSB
2000s STM32、NXP LPC 18--50MHz DMA 支持、多主机模式、I2S 兼容
至今 多核 MCU、FPGA >100MHz 多 I/O SPI(Quad SPI)、Octal SPI

二、工作原理:四根线的全双工华尔兹

1. 最直白的比喻:一个邮局 + N 个家庭

  SPI 通信就像**一个邮局(主机)和多个家庭(从机)**之间的投递系统:

  • SCK(时钟线):邮局的大钟,每敲一下,所有投递动作就发生一次。
  • MOSI(主出从入线):邮局投递到家庭的包裹通道。
  • MISO(主入从出线):家庭寄回邮局的回执通道。
  • SS:每家独有的门牌号,被叫到的家庭才开门收包裹、寄回执,其他家庭关门不管。

  主机敲钟的同时,从 MOSI(主出从入线) 发出一个包裹,从 MISO(主入从出线) 收到一个回执,一个钟点完成一次双向交换------这就是全双工。

2. 核心机制:两个环起来的移位寄存器

  SPI 通信的物理基础是主机与从机内部各有一个移位寄存器 ,两者通过 MOSI(主出从入线) 和 MISO(主入从出线) 首尾相连,形成一个环。SCK(时钟线) 每产生一个时钟脉冲,两个寄存器里的数据就左移或右移一位,主机将最高位送到从机的最低位,同时从机的最高位送到主机的最低位。经过 8 个时钟,两者就"互换"了一个字节。

  这就是 SPI 那句经典口诀的来源:"为发而收,为收而发" ------即使你只想读从机一个字节,也必须先发送一个空字节去触发从机移位。

3. 关键特性一:一主多从,片选独占

  所有从机的 SCK(时钟线)、MOSI(主出从入线)、MISO(主入从出线) 并联在同一条总线上,唯独 SS 片选线每个从机独占一根。主机想和哪个从机说话,就把对应的 SS 拉低,其他从机保持高电平,互不干扰。一个主机挂 4 个从机,总共只需 3 + 4 = 7 根线。

4. 关键特性二:四种时序模式,选对才能通

  SPI 设备在数据采样边沿上并不统一,而是通过 CPOL(时钟极性) 和 CPHA(时钟相位) 组合出四种模式。选错模式,双方采样点错位,通信必败。

模式 CPOL CPHA 空闲时 SCK(时钟线) 电平 数据采样边沿
模式 0 0 0 低电平 上升沿采样
模式 1 0 1 低电平 下降沿采样
模式 2 1 0 高电平 下降沿采样
模式 3 1 1 高电平 上升沿采样

  一般的设备只支持其中一种,比如 W25Q32 支持模式 0 和模式 3。主机配置模式前,必须查阅从机数据手册。

  • 这些时序一般什么情况下使用呢?
      从应用角度看,四种模式的差异源于不同芯片厂商的设计习惯,本质上没有"谁更好",只有"谁匹配"。选型时要根据从机数据手册的时序图来决定:

(1) 模式 0(CPOL=0, CPHA=0)

  这是最常用 的模式,Motorola 最初定义 SPI 时的默认配置。空闲时 SCK(时钟线) 为低电平,主机在上升沿采样数据,下降沿切换数据。

  典型设备 :W25Q 系列 Flash、SD 卡(SPI 模式)、大多数 ADC/DAC、MCU 上的硬件 SPI 默认模式。

(2) 模式 1(CPOL=0, CPHA=1)

  空闲时 SCK(时钟线) 仍为低电平,但数据在下降沿采样,上升沿切换。相比模式 0,采样点往后挪了半个时钟周期,给了数据信号更多建立时间,适合信号线较长或布线不太理想的板子上。

  典型设备 :部分 EEPROM、某些低速 ADC。

(3) 模式 2(CPOL=1, CPHA=0)

  空闲时 SCK(时钟线) 为高电平,数据在下降沿采样。这种模式比较少见,通常出现在一些 DSP 与 CODEC 之间的音频数据接口里。由于空闲为高,SCK(时钟线) 的第一个边沿是下降沿,接收方可以在这个边沿就开始采样,不一定等到上升沿。

  典型设备 :TI 的部分 DSP、某些音频 CODEC。

(4) 模式 3(CPOL=1, CPHA=1)

  空闲时 SCK(时钟线) 为高电平,数据在上升沿采样。这是仅次于模式 0 的第二常用模式,很多 Flash 和传感器同时支持模式 0 和模式 3。实际使用中如果模式 0 通信异常,直接切模式 3 试一下往往就通了。

  典型设备 :W25Q 系列 Flash(同时支持模式 0 和 3)、NXP 的某些 SPI 从机。

(5) 一张表记住选型口诀

场景 推荐模式 原因
通用 Flash、SD 卡、传感器 模式 0 最广泛的兼容性,默认就配它
模式 0 异常时备选 模式 3 W25Q 等同时支持,直接切
长距离布线、信号质量差 模式 1 采样点后移,额外半个周期建立时间
专用 DSP、CODEC 模式 2 特定厂商历史习惯

  一句话总结:没看数据手册先上模式 0,不通就切模式 3,再不通翻时序图对采样边沿------别瞎蒙所有模式试一遍,容易把从机试出未定义状态。

三、技术规格:四根线的参数世界

1. 引脚定义与连接

  SPI 总线的四根线各有角色,连接时须严格对应:

|---------------|--------------|----------------|
| 引脚名 | 方向(主机角度) | 功能 |
| SCK(时钟线) | 输出 | 串行时钟,总线节拍器 |
| MOSI(主出从入线) | 输出 | 主出从入,主机发送数据到从机 |
| MISO(主入从出线) | 输入 | 主入从出,从机发送数据到主机 |
| SS | 输出 | 片选,低电平选中某个从设备 |

  所有设备的 SCK(时钟线)、MOSI(主出从入线)、MISO(主入从出线) 分别对应相连,主机另外引出多根 SS 线,每个从机独占一根。输出引脚配置为推挽复用输出 ,输入引脚配置为浮空输入或带上拉输入。

2. 通信格式与速率

  STM32 的硬件 SPI 可配置 8 位或 16 位数据帧,支持 MSB 先行或 LSB 先行。时钟频率由 PCLK 分频而来,分频系数可选 2、4、8、16、32、64、128、256。SPI1 挂载在高速总线 APB2(最高 72MHz),理论最高 SPI 时钟可达 18MHz(4 分频),实践中受 PCB 布线、从机能力限制,常用 9MHz 或更低。

3. 常用 API(标准库)

  STM32 标准库为 SPI 封装了以下核心函数:

|-------------------------|--------------------------------------|
| 函数名 | 功能说明 |
| SPI_Init | 根据结构体参数初始化指定 SPI 外设(模式、数据格式、分频等) |
| SPI_Cmd | 使能或失能 SPI 外设 |
| SPI_I2S_SendData | 向发送缓冲区写入一个数据帧(8 位或 16 位) |
| SPI_I2S_ReceiveData | 从接收缓冲区读取一个数据帧 |
| SPI_I2S_GetFlagStatus | 检查状态标志位(发送空 `TXE`、接收非空 `RXNE` 等) |
| SPI_I2S_ITConfig | 使能或失能指定的 SPI 中断源 |
| SPI_I2S_DMACmd | 使能或失能 SPI 的 DMA 请求 |

4. 通信协议

  SPI 的通信协议不像 I2C 那样有严格的起始/停止/应答定义,它的"协议"更像一套约定俗成的操作序列------片选的边沿做开关,时钟的相位做节拍,指令和数据在 MOSI/MISO 上双向流动。

(1) 起始条件

SS 从高电平切换到低电平

  片选线拉低的瞬间,被选中的从机被"唤醒",开始监听 SCK 上的时钟信号。此时主机还没有发出时钟,总线处于"待命"状态。

(2) 终止条件

SS 从低电平切换到高电平

  片选线拉高的瞬间,从机认为本次通信结束,不再理会 SCK 上的任何时钟。主机之后就算继续翻转时钟,从机也当没听见。

(3) 发送指令

向 SS 指定的设备,发送单字节指令(如 0x06)

  拉低片选后,主机通过 MOSI 发送一个字节的指令码。这个字节会被从机解码为"操作命令",例如 0x06 在 W25Q32 里是"写使能",0x9F 是"读设备 ID"。指令发送完成后拉高片选结束。

(4) 指定地址写

向 SS 指定的设备,发送写指令(0x02),随后在指定地址(Address[23:0])下,写入指定数据(Data)

  拉低片选后,主机先发一个字节的写指令 0x02,紧接着发 3 个字节的地址(MSB 先行,23 位 → 8 位×3),告诉从机"往这儿写"。再接着发一个或多个字节的实际数据,从机会把它们依次写入对应地址。全部发送完成后拉高片选结束。

(5) 指定地址读

向 SS 指定的设备,发送读指令(0x03),随后在指定地址(Address[23:0])下,读取从机数据(Data)

  拉低片选后,主机先发一个字节的读指令 0x03,紧接着发 3 个字节的地址。从这之后,主机每发一个空字节(0xFF),从机就把对应地址的 1 个字节数据通过 MISO 还给主机。主机会持续发空字节直到读完所需数据量,最后拉高片选结束。

(6) 模式的选择

  SPI 四种模式由 CPOL(时钟极性)和 CPHA(时钟相位)组合而成。选型时必须对照从机数据手册的时序图,否则采样点错位,指令和数据全乱。

a. 交换一个字节(模式 0)
  • CPOL=0 :空闲状态时,SCK 为低电平。
  • CPHA=0 :SCK 第一个边沿(上升沿)移入 数据,第二个边沿(下降沿)移出数据。

  这是最通用的模式,数据在上升沿被采样,下降沿被切换。W25Q32 默认支持。

b. 交换一个字节(模式 1)
  • CPOL=0 :空闲状态时,SCK 为低电平。
  • CPHA=1 :SCK 第一个边沿(上升沿)移出 数据,第二个边沿(下降沿)移入数据。

  数据采样点比模式 0 后移半个周期,给数据线更多建立时间,适合信号质量不佳的长布线场景。

c. 交换一个字节(模式 2)
  • CPOL=1 :空闲状态时,SCK 为高电平。
  • CPHA=0 :SCK 第一个边沿(下降沿)移入 数据,第二个边沿(上升沿)移出数据。

  空闲时 SCK 拉高,第一个有效边沿是下降沿。常见于 TI 部分 DSP 与音频 CODEC 的接口。

d. 交换一个字节(模式 3)
  • CPOL=1 :空闲状态时,SCK 为高电平。
  • CPHA=1 :SCK 第一个边沿(下降沿)移出 数据,第二个边沿(上升沿)移入数据。

  模式 3 是模式 0 的"全反相版",数据在上升沿被采样。W25Q32 同时支持模式 0 和模式 3,模式 0 不通时直接切模式 3,往往是最快的问题定位手段。

四、硬件实现:STM32 的 SPI 外设

1. 功能框图:硬件替你踩点收发

  STM32 内部 SPI 模块的核心是一条双向移位寄存器、一个波特率发生器和一个控制状态机。当主机写入 SPI_DR 寄存器时,硬件自动完成以下操作:

  1. 按照设定的时钟极性和相位,在 SCK(时钟线) 上产生相应波形。
  2. 每个时钟边沿将 MOSI(主出从入线) 数据线置为数据寄存器中的高位。
  3. 同时采样 MISO(主入从出线) 引脚,移入接收寄存器。
  4. 传输完成后置标志位 RXNE(接收非空)或 TXE(发送空)。

  整个过程 CPU 只需在开始时写一次数据,结束时读一次数据,中间时钟生成和位操作全由硬件完成。

2. 特殊机制:软件管理 NSS

  STM32 的 SPI_NSS 有两种管理模式:硬件模式和软件模式。硬件模式下 NSS 引脚由 SPI 外设硬件控制,主机模式下自动拉低、从机模式下自动检测。但实际工程中,为了灵活控制多个从机的片选,几乎全部使用软件管理模式 (SPI_NSS_Soft),即用普通 GPIO 手动拉低拉高片选线。

五、实践指南:以 SPI1 驱动 W25Q32 为例

1. 配置流程四步走

  配置一个 SPI 外设的标准流程如下:

  1. 查原理图定引脚 :确认 SPI1 四根线所用 GPIO 的复用关系。
  1. 配 GPIO :时钟线与数据输出线配成复用推挽输出,数据输入线配成浮空输入,片选线单独配成通用推挽输出。
  2. 配 SPI:设置主模式、全双工、分频系数、时钟极性与相位、数据帧大小、先行位等。
  3. 使能 SPI :调用 SPI_Cmd 启动外设。

  为什么时钟线与数据输出线配成复用推挽输出,数据输入线配成浮空输入,片选线单独配成通用推挽输出? 这不是凭空规定的,而是由 STM32 参考手册中 SPI 引脚的配置表严格决定的。手册明确列出了每种引脚在不同模式下的 GPIO 配置:

|--------------------|------------------|------------------|
| SPI 引脚 | 模式 | GPIO 配置 |
| SPIx_SCK(时钟线) | 主模式 | 推挽复用输出 |
| SPIx_SCK(时钟线) | 从模式 | 浮空输入 |
| SPIx_MOSI(主出从入线) | 全双工模式 / 主模式 | 推挽复用输出 |
| SPIx_MOSI(主出从入线) | 全双工模式 / 从模式 | 浮空输入或带上拉输入 |
| SPIx_MISO(主入从出线) | 全双工模式 / 主模式 | 浮空输入或带上拉输入 |
| SPIx_MISO(主入从出线) | 全双工模式 / 从模式 | 推挽复用输出 |
| SPIx_NSS | 硬件主/从模式 | 浮空输入或带上拉输入或带下拉输入 |
| SPIx_NSS | 硬件主模式 / NSS 输出使能 | 推挽复用输出 |
| SPIx_NSS | 软件模式 | 未用,可作为通用 I/O |

  用人话把每条规则吃透:

(1) SCK(时钟线)------时钟线永远是主机往外发,所以配推挽复用输出

  在 SPI 通信中,只有主机有权力生成时钟。SCK(时钟线) 是主机驱动的一根信号线,时钟信号从主机推向从机,因此主模式下必须配成推挽复用输出 ------推挽保证驱动能力,复用表示引脚控制权交给 SPI 外设而非 GPIO。

  反过来,从模式下 SCK(时钟线) 是输入------从机只管听主机的钟声,自己不产生任何时钟跳变,因此配浮空输入。

(2) MOSI(主出从入线)------主出从入,数据方向决定谁是输出

  MOSI(主出从入线) 的全称是 Master Output Slave Input,字面上已经把方向写死了:主机用它往外发数据,所以主模式下配推挽复用输出 ;从机用它收数据,所以从模式下配浮空输入或带上拉输入,上拉的作用是防止主机没发数据时引脚悬空电平乱跳。

(3) MISO(主入从出线)------主入从出,方向与 MOSI(主出从入线) 正好反着来

  MISO(主入从出线) 的全称是 Master Input Slave Output,和 MOSI(主出从入线) 方向完全相反:从机用它往外发数据,主模式下主机是收数据的那一方。因此主模式下 MISO(主入从出线) 配浮空输入或带上拉输入 ------主机只管听,不驱动电平,上拉保证片选释放后电平确定。从模式下 MISO(主入从出线) 才配推挽复用输出,因为此时轮到从机往外推数据了。

(4) NSS------软件管理时和 SPI 外设没关系,也叫片选线就当普通 GPIO

  NSS(也就是 SS 片选线)在硬件模式下由 SPI 外设自动控制------主机模式下外设会自动拉低,从机模式下外设会自动检测。但实际工程里为了灵活挂多个从机,几乎全部采用软件管理模式 。软件模式下 NSS 引脚和 SPI 外设彻底解耦,手册明确说"未用,可作为通用 I/O",所以直接配通用推挽输出,程序里手动拉低拉高。

(5) 一句话刻在脑子里

主机往外发的线(SCK(时钟线)、MOSI(主出从入线))配推挽复用输出,主机往里收的线(MISO(主入从出线))配浮空输入,片选用软件管就当普通 GPIO 拉高拉低。一切原则都来自数据手册那张表。

2. 以 SPI1 连接 W25Q32 为例

  硬件连接(STM32F103RC 与 W25Q32):

  • SPI1_CS(片选线)(片选线)(片选线) → PA4(GPIO 管理)
  • SPI1_SCK(时钟线)(时钟线)(时钟线) → PA5(SPI1 管理)
  • SPI1_MISO(主入从出线)(主入从出线)(主入从出线) → PA6(SPI1 管理)
  • SPI1_MOSI(主出从入线)(主出从入线)(主出从入线) → PA7(SPI1 管理)

(1) 参数确定

  • SPI 模式:W25Q32 支持模式 0 和模式 3,此处选模式 0 (CPOL=0, CPHA=0,上升沿采样)。
  • 数据帧:8 位,MSB 先行。
  • 分频系数:选择 SPI_BaudRatePrescaler_4,即 4 分频,SPI1 时钟 72MHz / 4 = 18MHz,在 W25Q32 允许范围内。
  • NSS:软件管理模式。

(2) 初始化代码

  初始化过程中用到的库函数 API 及其功能说明如下:

|--------------------------|------------------------------|
| 函数名 | 功能说明 |
| RCC_APB2PeriphClockCmd | 使能 APB2 总线上的外设时钟(GPIOA、SPI1) |
| GPIO_Init | 根据结构体参数初始化指定 GPIO 引脚的模式和速度 |
| SPI_Init | 根据结构体参数初始化 SPI 外设 |
| SPI_Cmd | 使能或失能 SPI 外设 |

c 复制代码
/**
 * Function:    SPI1_Init
 * Description: CN:初始化SPI1,配置为主机模式、全双工、模式0、18MHz、软件NSS--EN:Initialize SPI1 as master, full-duplex, mode 0, 18MHz, software NSS
 * Parameters:  mode       - CN:SPI模式,如SPI_MODE_0--EN:SPI mode, e.g. SPI_MODE_0
 *              firstbit   - CN:先行位,SPI_FirstBit_MSB或SPI_FirstBit_LSB--EN:First bit, SPI_FirstBit_MSB or SPI_FirstBit_LSB
 *              prescaler  - CN:预分频因子,如SPI_BaudRatePrescaler_4--EN:Baud rate prescaler, e.g. SPI_BaudRatePrescaler_4
 * Return value:无
 */
void SPI1_Init(uint8_t mode, uint16_t firstbit, uint16_t prescaler)
{
    GPIO_InitTypeDef GPIO_InitStruct;                /*CN:GPIO初始化结构体--EN:GPIO init structure*/
    SPI_InitTypeDef SPI_InitStruct;                  /*CN:SPI初始化结构体--EN:SPI init structure*/

    /*CN:使能GPIOA和SPI1时钟--EN:Enable GPIOA and SPI1 clocks*/
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE);

    /*CN:配置PA6为浮空输入(MISO(主入从出线))--EN:Configure PA6 as floating input (MISO(主入从出线))*/
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    /*CN:配置PA5(SCK(时钟线))和PA7(MOSI(主出从入线))为复用推挽输出--EN:Configure PA5 (SCK(时钟线)) and PA7 (MOSI(主出从入线)) as AF push-pull output*/
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    /*CN:配置PA4为通用推挽输出作为片选(SS)--EN:Configure PA4 as general push-pull output for SS*/
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_4;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    GPIO_SetBits(GPIOA, GPIO_Pin_4);                   /*CN:片选默认拉高不选中--EN:Pull SS high by default (deselect)*/

    /*CN:SPI参数配置--EN:SPI parameter configuration*/
    SPI_InitStruct.SPI_Direction = SPI_Direction_2Lines_FullDuplex; /*CN:全双工模式--EN:Full-duplex mode*/
    SPI_InitStruct.SPI_Mode = SPI_Mode_Master;                     /*CN:主机模式--EN:Master mode*/
    SPI_InitStruct.SPI_DataSize = SPI_DataSize_8b;                 /*CN:8位数据帧--EN:8-bit data frame*/
    SPI_InitStruct.SPI_CPOL = (mode & 2) ? SPI_CPOL_High : SPI_CPOL_Low;  /*CN:CPOL由mode位1决定--EN:CPOL determined by bit 1 of mode*/
    SPI_InitStruct.SPI_CPHA = (mode & 1) ? SPI_CPHA_2Edge : SPI_CPHA_1Edge; /*CN:CPHA由mode位0决定--EN:CPHA determined by bit 0 of mode*/
    SPI_InitStruct.SPI_NSS = SPI_NSS_Soft;                         /*CN:软件管理NSS--EN:Software NSS management*/
    SPI_InitStruct.SPI_BaudRatePrescaler = prescaler;              /*CN:波特率预分频--EN:Baud rate prescaler*/
    SPI_InitStruct.SPI_FirstBit = firstbit;                        /*CN:MSB或LSB先行--EN:MSB or LSB first*/
    SPI_InitStruct.SPI_CRCPolynomial = 7;                          /*CN:CRC校验多项式--EN:CRC polynomial*/
    SPI_Init(SPI1, &SPI_InitStruct);

    /*CN:使能SPI1--EN:Enable SPI1*/
    SPI_Cmd(SPI1, ENABLE);
}

(3) 读写函数

  读写操作共用一个函数,发送一个字节的同时接收一个字节:

c 复制代码
/**
 * Function:    SPI1_Read_Write
 * Description: CN:通过SPI1发送一个字节并返回接收到的字节--EN:Send one byte via SPI1 and return received byte
 * Parameters:  data - CN:要发送的字节--EN:Byte to send
 * Return value:CN:接收到的字节--EN:Received byte
 */
uint8_t SPI1_Read_Write(uint8_t data)
{
    /*CN:等待发送缓冲区空--EN:Wait for transmit buffer empty*/
    while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);

    /*CN:发送一字节数据--EN:Send one byte*/
    SPI_I2S_SendData(SPI1, data);

    /*CN:等待接收缓冲区非空--EN:Wait for receive buffer not empty*/
    while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);

    /*CN:返回接收到的字节--EN:Return received byte*/
    return (uint8_t)SPI_I2S_ReceiveData(SPI1);
}

六、多设备通信示例:同一条总线挂两片 W25Q32

  当一个 SPI 总线上挂载多个从机时,除片选线外所有引脚并联。假设 SPI1 上挂了两片 W25Q32,片选分别由 PA4 和 PB0 控制。

|------------|----------|------------|-------------------|
| 设备 | 片选引脚 | SPI 总线 | 操作方法 |
| W25Q32-A | PA4 | SPI1 | 读写前拉低 PA4,读写后拉高 |
| W25Q32-B | PB0 | SPI1 | 读写前拉低 PB0,读写后拉高 |

  用到的 GPIO 控制函数:

|------------------|--------------|
| 函数名 | 功能说明 |
| GPIO_ResetBits | 拉低指定引脚(选中从机) |
| GPIO_SetBits | 拉高指定引脚(释放从机) |

1. 统一片选管理宏

c 复制代码
/*CN:片选宏:带参数选择要操作的从机--EN:CS(片选线) macros: select target slave by parameter*/
#define SPI1_CS(片选线)(片选线)_A_LOW()    GPIO_ResetBits(GPIOA, GPIO_Pin_4)
#define SPI1_CS(片选线)(片选线)_A_HIGH()   GPIO_SetBits(GPIOA, GPIO_Pin_4)
#define SPI1_CS(片选线)(片选线)_B_LOW()    GPIO_ResetBits(GPIOB, GPIO_Pin_0)
#define SPI1_CS(片选线)(片选线)_B_HIGH()   GPIO_SetBits(GPIOB, GPIO_Pin_0)

2. 从机分路读写示例

c 复制代码
/**
 * Function:    W25Q32_Read_ID
 * Description: CN:读取指定从机的设备ID(0x9F指令)--EN:Read device ID (0x9F command) from specified slave
 * Parameters:  slave - CN:从机选择,0=A, 1=B--EN:Slave select, 0=A, 1=B
 * Return value:CN:16位设备ID--EN:16-bit device ID
 */
uint16_t W25Q32_Read_ID(uint8_t slave)
{
    uint16_t id;
    /*CN:选中目标从机--EN:Select target slave*/
    if (slave == 0) SPI1_CS(片选线)(片选线)_A_LOW();
    else            SPI1_CS(片选线)(片选线)_B_LOW();

    SPI1_Read_Write(0x9F);                             /*CN:发送读ID命令--EN:Send read ID command*/
    id = SPI1_Read_Write(0xFF) << 8;                   /*CN:读取高字节--EN:Read high byte*/
    id |= SPI1_Read_Write(0xFF);                       /*CN:读取低字节--EN:Read low byte*/

    /*CN:释放片选--EN:Release CS(片选线)*/
    if (slave == 0) SPI1_CS(片选线)(片选线)_A_HIGH();
    else            SPI1_CS(片选线)(片选线)_B_HIGH();
    return id;
}

要点:每个从机的片选线必须在读写前单独拉低 ,读写完成后立刻拉高,不可同时拉低两个,否则总线冲突。

七、多设备挂载同一 SPI 总线

  你这个疑问问得非常关键,正是从"懂原理"到"能写代码"的最后一步。我直接给你一个带四个设备的完整工程代码框架,你看完就能照着改。

1、你的理解完全正确

  先确认你说的:A、B、C、D 四个 SPI 从设备,它们的 SCK、MOSI、MISO 全部并联在 SPI1 的三根线上,但片选各用一根独立的 GPIO:

|--------|----------|------------|-------------|-------------|
| 设备 | 片选引脚 | 时钟线 | 主出从入 | 主入从出 |
| A | PA0 | SPI1_SCK | SPI1_MOSI | SPI1_MISO |
| B | PB0 | SPI1_SCK | SPI1_MOSI | SPI1_MISO |
| C | PC1 | SPI1_SCK | SPI1_MOSI | SPI1_MISO |
| D | PD5 | SPI1_SCK | SPI1_MOSI | SPI1_MISO |

  SPI 总线本身不管你有多少个从机,它只负责在 SCK 上生成时钟、在 MOSI 上发数据、在 MISO 上收数据。至于哪个从机有权响应,全靠你亲手拉的片选线决定。

2. 编程思路:片选就是"点名"

  主机在每次发起通信之前,先把想对话的那根片选线拉低,通信结束后再把它拉高。同时只拉低一根,其他三根保持高电平。整个过程就像点名------叫到谁,谁才站起来答话。代码层面只需要做三件事:

  1. 定义每个设备的片选引脚和端口,方便统一管理。
  2. 封装一个通用的"选中-释放"机制,选错设备就全乱。
  3. 读写函数在片选拉低之后立刻调用 SPI1_Read_Write,完事后立刻拉高。

3. 片选的机理

  SPI 协议规定激活从设备时,SS(片选线)从高电平切换到低电平作为起始条件 ,SS(片选线)从低电平切换到高电平作为终止条件 。这两个边沿告诉从机"准备接数据"和"通信结束,可以休息了"。但你是否疑惑:数据手册里的时序图,SS 一直画在最低位,但代码里又看不到 SPI 外设去自动拉它------软件层面到底是怎么实现的?答案在于连接方式:SS 线并没有接在 SPI 外设的 NSS 引脚上,而是单独接在一个普通 GPIO 上,由软件手动控制高低。

  代码中用到的常量与函数说明如下:

|-------------------------|--------------------------------------------------------------|
| 常量/函数名 | 说明 |
| 0x9F | W25Q32 的"读取设备 ID"指令码,发送此字节后从机会返回两字节的制造商 ID 和设备 ID |
| SPI_I2S_GetFlagStatus | 库函数,检查 SPI 的状态标志位(如 TXE 发送空、RXNE 接收非空),用于判断是否可发送或已接收数据 |
| SPI_I2S_SendData | 库函数,向 SPI 发送缓冲区写入一个字节,同时硬件自动开始移位发送 |
| SPI_I2S_ReceiveData | 库函数,从 SPI 接收缓冲区读取一个字节,读取后自动清除 RXNE 标志 |
| GPIO_ResetBits | 库函数,将指定 GPIO 端口的指定引脚拉低(输出 0),在此用于拉低片选线激活从机 |
| GPIO_SetBits | 库函数,将指定 GPIO 端口的指定引脚拉高(输出 1),在此用于拉高片选线释放从机 |

c 复制代码
/*CN:拉低片选,开始通信--EN:Pull SS low, start communication*/
GPIO_ResetBits(GPIOA, GPIO_Pin_4);

/*CN:发送读ID指令(0x9F)--EN:Send read ID command (0x9F)*/
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPI1, 0x9F);
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
SPI_I2S_ReceiveData(SPI1);                             /*CN:丢弃第一字节(发送0x9F时收到的无用数据)--EN:Discard first byte (dummy data received while sending 0x9F)*/

/*CN:发送空字节(0xFF),读取制造商ID--EN:Send dummy byte (0xFF), read manufacturer ID*/
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPI1, 0xFF);
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
uint8_t manufacturer_id = SPI_I2S_ReceiveData(SPI1);   /*CN:制造商ID,W25Q32应为0xEF--EN:Manufacturer ID, should be 0xEF for W25Q32*/

/*CN:发送空字节(0xFF),读取设备ID--EN:Send dummy byte (0xFF), read device ID*/
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
SPI_I2S_SendData(SPI1, 0xFF);
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
uint8_t device_id = SPI_I2S_ReceiveData(SPI1);         /*CN:设备ID,W25Q32应为0x15或0x16--EN:Device ID, should be 0x15 or 0x16 for W25Q32*/

/*CN:拉高片选,结束通信--EN:Pull SS high, end communication*/
GPIO_SetBits(GPIOA, GPIO_Pin_4);

  也就是说,SPI 外设本身只管 SCK、MOSI、MISO 这三根线的时序,SS 的拉低和拉高全靠软件在传输前后手动操作 GPIO 。这就是 SPI_NSS_Soft(软件管理模式)的精髓------硬件不插手片选,控制权完全交给程序员。

那实际硬件内部在 SS 拉低之后发生了什么?答案藏在从机内部的移位寄存器里:整个过程拆成四个阶段,一步步看清楚一个指令字节是怎么从主机跑进从机、从机又是怎么同时把数据送回主机的。

(1) 阶段一:SS 下降沿------吹哨,起跑

  主机把 PA4 从高拉低的一瞬间,从机 W25Q32 的 CS 引脚检测到一个下降沿,内部硬件立刻做三件事:

  • 清空移位寄存器 :上一次通信残留的 8 位数据全部清零,保证这次收的 8 位不会被上次的残留污染。
  • 复位位计数器 :从机内部有个隐形的"已收到几位"计数器,此时归零,准备从第 1 位开始数。
  • 使能时钟检测电路 :之前 SCK 上的电平变化从机一概不理,现在开始竖起耳朵,每个 SCK 边沿都要响应。

(2) 阶段二:8 个时钟------两位一体,边发边收

  接下来主机在 SCK 上连续产生 8 个时钟脉冲。以模式 0(CPOL=0, CPHA=0)为例,每个时钟周期拆成两步:

  第 1 个时钟:

  • SCK 低电平期间 :主机把数据字节的最高位(Bit 7)放到 MOSI 引脚上。此时 SCK 为低,从机不采样,数据可以安全地慢慢稳定。
  • SCK 上升沿 :从机检测到上升沿,做两个动作:
    • 把 MOSI 引脚上的电平(1 或 0)锁存 进自己移位寄存器的最低位 Bit 0,原有数据集体向左挪一位。
    • 同时把自己移位寄存器原本的 Bit 7 推到 MISO 引脚上,主机在同一个上升沿把它读走。
  • SCK 高电平期间 :从机保持 MISO 输出稳定,主机在这个阶段采样 MISO,读走从机送出的那一位。

  第 2 到第 8 个时钟: 上述过程重复 7 次,每次主机在低电平期间准备好下一位放到 MOSI,上升沿双方同时"吞一位吐一位"。8 个时钟走完,主机 MOSI 上先后出现的 8 位数据已经全部装进从机的移位寄存器,从机的 8 位数据也全部装进了主机的移位寄存器。

(3) 阶段三:SS 上升沿------锁存,执行

  主机把 PA4 从低拉高,W25Q32 的 CS 引脚检测到上升沿,触发内部指令解码逻辑:

  • 移位寄存器里现在存着一个完整的 8 位数据(比如 0x9F),这 8 位被并行锁存到指令寄存器。
  • 指令解码器一看:0x9F = "读取设备 ID",这个指令后面还要再收两个 0xFF 才能吐出 ID 数据。于是从机内部状态机切换到"准备吐 ID"状态。
  • 如果是写指令(如 0x02),指令锁存后从机会把后续的数据字节锁存进地址寄存器和数据缓冲区,等 SS 拉高后统一启动 Flash 编程操作。

(4) 阶段四:连续的 8 位×N------指令、地址、数据串在一起

  对于多字节指令(如"指定地址读"),整个通信过程就是把多个 8 位收发首尾相连。以读 W25Q32 的 ID 为例,连续发三个字节:

复制代码
SS 拉低 ─→ 发 0x9F(指令) ─→ 发 0xFF(空,收制造商ID)─→ 发 0xFF(空,收设备ID)─→ SS 拉高

  每个字节的 8 个时钟内部,都重复一次阶段二的"边发边收"。发 0x9F 时收回来的是无效垃圾字节,发第一个 0xFF 时收回来的是 0xEF(W25Q32 制造商 ID),发第二个 0xFF 时收回来的是 0x15 或 0x16(设备 ID)。这就是为什么读数据必须发空字节------你是用空字节当"时钟发动机",驱动从机把你要的数据顺着 MISO 推回来。

(5) 一张表看清楚三个字节在移位寄存器里的流转

阶段 主机送进 MOSI 从机移位寄存器内容 主机从 MISO 读回
第 1 个 8 时钟 0x9F(指令) 刚装入 0x9F 无效垃圾(从机还没准备数据)
第 2 个 8 时钟 0xFF(空) 把上次 0x9F 锁存到指令寄存器,同时准备 ID 高位字节 0xEF 0xEF(制造商 ID)
第 3 个 8 时钟 0xFF(空) 把 ID 低位字节 0x15 装入移位寄存器 0x15(设备 ID)

(6) 一句话刻进脑子里

SS 拉低是让从机"起立准备",8 个时钟是双方"交换一个字节的握手",SS 拉高是让从机"执行你刚才给的那个命令"。空字节不是数据,是驱动从机吐出数据的时钟燃料。

  一句话总结:SS 下降沿是"起跑旗",让从机从空闲进入接收状态;上升沿是"结束旗",让从机把刚才收的一串位移进命令寄存器去执行。中间的八个时钟就是两个移位寄存器首尾相连、互换一个字节的过程。软件负责挥旗,硬件负责跑步。

4. 完整代码示例(四设备)

(1) 片选引脚定义

c 复制代码
/*========================================*/
/*CN:四设备片选引脚定义--EN:Four-device CS pin definitions*/
/*========================================*/

#define DEV_A_CS_PORT      GPIOA
#define DEV_A_CS_PIN       GPIO_Pin_0

#define DEV_B_CS_PORT      GPIOB
#define DEV_B_CS_PIN       GPIO_Pin_0

#define DEV_C_CS_PORT      GPIOC
#define DEV_C_CS_PIN       GPIO_Pin_1

#define DEV_D_CS_PORT      GPIOD
#define DEV_D_CS_PIN       GPIO_Pin_5

/*CN:片选宏------低电平选中,高电平释放--EN:CS macros --- low to select, high to release*/
#define CS_SELECT(port, pin)    GPIO_ResetBits(port, pin)
#define CS_RELEASE(port, pin)   GPIO_SetBits(port, pin)

(3) 各设备读写函数

  用到的库函数 API 及其功能说明如下:

|-------------------|---------------------------------|
| 函数名 | 功能说明 |
| GPIO_ResetBits | 将指定 GPIO 端口的指定引脚拉低(输出低电平) |
| GPIO_SetBits | 将指定 GPIO 端口的指定引脚拉高(输出高电平) |
| SPI1_Read_Write | 通过 SPI1 发送一个字节并返回接收到的字节(底层收发函数) |

  • 选中设备A
c 复制代码
/**
 * Function:    DEV_A_Read_Write
 * Description: CN:选中设备A,发送一字节并返回接收字节--EN:Select device A, send one byte and return received byte
 * Parameters:  data - CN:要发送的字节--EN:Byte to send
 * Return value:CN:接收到的字节--EN:Received byte
 */
uint8_t DEV_A_Read_Write(uint8_t data)
{
    uint8_t rx;
    CS_SELECT(DEV_A_CS_PORT, DEV_A_CS_PIN);               /*CN:拉低PA0,选中设备A--EN:Pull PA0 low, select device A*/
    rx = SPI1_Read_Write(data);                           /*CN:通过SPI1发送并接收一字节--EN:Send and receive one byte via SPI1*/
    CS_RELEASE(DEV_A_CS_PORT, DEV_A_CS_PIN);              /*CN:拉高PA0,释放设备A--EN:Pull PA0 high, release device A*/
    return rx;
}
  • 选中设备B
      用到的库函数 API 及其功能说明如下:

|-------------------|---------------------------------|
| 函数名 | 功能说明 |
| GPIO_ResetBits | 将指定 GPIO 端口的指定引脚拉低(输出低电平) |
| GPIO_SetBits | 将指定 GPIO 端口的指定引脚拉高(输出高电平) |
| SPI1_Read_Write | 通过 SPI1 发送一个字节并返回接收到的字节(底层收发函数) |

c 复制代码
/**
 * Function:    DEV_B_Read_Write
 * Description: CN:选中设备B,发送一字节并返回接收字节--EN:Select device B, send one byte and return received byte
 * Parameters:  data - CN:要发送的字节--EN:Byte to send
 * Return value:CN:接收到的字节--EN:Received byte
 */
uint8_t DEV_B_Read_Write(uint8_t data)
{
    uint8_t rx;
    CS_SELECT(DEV_B_CS_PORT, DEV_B_CS_PIN);               /*CN:拉低PB0,选中设备B--EN:Pull PB0 low, select device B*/
    rx = SPI1_Read_Write(data);                           /*CN:通过SPI1发送并接收一字节--EN:Send and receive one byte via SPI1*/
    CS_RELEASE(DEV_B_CS_PORT, DEV_B_CS_PIN);              /*CN:拉高PB0,释放设备B--EN:Pull PB0 high, release device B*/
    return rx;
}
  • 选中设备C
      用到的库函数 API 及其功能说明如下:

|-------------------|---------------------------------|
| 函数名 | 功能说明 |
| GPIO_ResetBits | 将指定 GPIO 端口的指定引脚拉低(输出低电平) |
| GPIO_SetBits | 将指定 GPIO 端口的指定引脚拉高(输出高电平) |
| SPI1_Read_Write | 通过 SPI1 发送一个字节并返回接收到的字节(底层收发函数) |

c 复制代码
/**
 * Function:    DEV_C_Read_Write
 * Description: CN:选中设备C,发送一字节并返回接收字节--EN:Select device C, send one byte and return received byte
 * Parameters:  data - CN:要发送的字节--EN:Byte to send
 * Return value:CN:接收到的字节--EN:Received byte
 */
uint8_t DEV_C_Read_Write(uint8_t data)
{
    uint8_t rx;
    CS_SELECT(DEV_C_CS_PORT, DEV_C_CS_PIN);               /*CN:拉低PC1,选中设备C--EN:Pull PC1 low, select device C*/
    rx = SPI1_Read_Write(data);                           /*CN:通过SPI1发送并接收一字节--EN:Send and receive one byte via SPI1*/
    CS_RELEASE(DEV_C_CS_PORT, DEV_C_CS_PIN);              /*CN:拉高PC1,释放设备C--EN:Pull PC1 high, release device C*/
    return rx;
}
  • 选中设备D
      用到的库函数 API 及其功能说明如下:

|-------------------|---------------------------------|
| 函数名 | 功能说明 |
| GPIO_ResetBits | 将指定 GPIO 端口的指定引脚拉低(输出低电平) |
| GPIO_SetBits | 将指定 GPIO 端口的指定引脚拉高(输出高电平) |
| SPI1_Read_Write | 通过 SPI1 发送一个字节并返回接收到的字节(底层收发函数) |

c 复制代码
/**
 * Function:    DEV_D_Read_Write
 * Description: CN:选中设备D,发送一字节并返回接收字节--EN:Select device D, send one byte and return received byte
 * Parameters:  data - CN:要发送的字节--EN:Byte to send
 * Return value:CN:接收到的字节--EN:Received byte
 */
uint8_t DEV_D_Read_Write(uint8_t data)
{
    uint8_t rx;
    CS_SELECT(DEV_D_CS_PORT, DEV_D_CS_PIN);               /*CN:拉低PD5,选中设备D--EN:Pull PD5 low, select device D*/
    rx = SPI1_Read_Write(data);                           /*CN:通过SPI1发送并接收一字节--EN:Send and receive one byte via SPI1*/
    CS_RELEASE(DEV_D_CS_PORT, DEV_D_CS_PIN);              /*CN:拉高PD5,释放设备D--EN:Pull PD5 high, release device D*/
    return rx;
}

5. 底层驱动层 vs 应用层

  你写好的这些 SPI1_Init 和 DEV_x_Read_Write 都是底层驱动层 的代码,它们只负责完成单个字节的收发和片选控制。真正读写 W25Q32 的 ID、写使能、页写、扇区擦除,是应用层的逻辑,它们通过调用底层读写函数来拼出完整指令。

  例如读 DEV_A 的设备 ID:

c 复制代码
uint16_t id = DEV_A_Read_Write(0x9F) << 8;               /*CN:发读ID指令0x9F,收高字节--EN:Send read ID cmd 0x9F, receive high byte*/
id |= DEV_A_Read_Write(0xFF);                            /*CN:发空字节,收低字节--EN:Send dummy byte, receive low byte*/

  同一个 SPI1 通道,四个设备的片选互斥拉低,就是标准的多从机编程范式。你担心的"I2C那样地址对不上就打成一团"的情况,在SPI里不存在------片选线就是物理隔离,一根线负责一个设备,硬件层面互不影响。

八、手搓纯软件模拟 SPI

  如果 MCU 没有硬件 SPI 外设,或者硬件通道已被全部占用,用几个 GPIO 加内嵌汇编宏就能纯手工捏出 SPI 时序。下面以任意 MCU 的 P10(SCK(时钟线))、P11(MOSI(主出从入线))、P12(MISO(主入从出线))、P13(SS)为例。

c 复制代码
/*========================================*/
/*CN:内嵌汇编指令宏--EN:Inline assembly instruction macros*/
/*========================================*/

#define DISI()          _asm {disi}               /*CN:关总中断--EN:Disable global interrupt*/
#define WDTC()          _asm {wdtc}               /*CN:清看门狗--EN:Clear watchdog timer*/
#define SLEP()          _asm {slep}               /*CN:进入休眠模式--EN:Enter sleep mode*/
#define NOP()           _asm {nop}                /*CN:空操作,单周期延时--EN:No operation, single-cycle delay*/
#define ENI()           _asm {eni}                /*CN:开总中断--EN:Enable global interrupt*/

1. 核心思想:用 GPIO 手工踩出时钟与数据

  软件 SPI 的本质就是按模式 0 或模式 3 的要求,手动控制 SCK(时钟线) 高低并移动 MOSI(主出从入线)/MISO(主入从出线) 数据。

  • 发送一位 :先将数据位放上 MOSI(主出从入线),再制造 SCK(时钟线) 的相应跳变。
  • 接收一位 :在 SCK(时钟线) 采样边沿前或后读取 MISO(主入从出线) 电平。
  • 全双工:发送与接收在同一时钟周期内完成。

2. 平台适配宏

c 复制代码
/*========================================*/
/*CN:平台适配宏(按实际MCU修改以下定义)--EN:Platform adaptation macros (modify per actual MCU)*/
/*========================================*/

#define PIN_SCK(时钟线)_HIGH()      /*CN:SCK(时钟线)输出高--EN:SCK(时钟线) output high*/
#define PIN_SCK(时钟线)_LOW()       /*CN:SCK(时钟线)输出低--EN:SCK(时钟线) output low*/
#define PIN_MOSI(主出从入线)_SET(b)     /*CN:MOSI(主出从入线)输出比特b(1/0)--EN:MOSI(主出从入线) output bit b*/
#define PIN_MISO(主入从出线)_READ()     /*CN:读MISO(主入从出线)引脚电平(0/1)--EN:Read MISO(主入从出线) pin level*/
#define PIN_SS_LOW()        /*CN:拉低片选--EN:Pull SS low*/
#define PIN_SS_HIGH()       /*CN:拉高片选--EN:Pull SS high*/
#define PIN_DELAY()         NOP();NOP();NOP();NOP();  /*CN:四分之一时钟周期延时--EN:Quarter clock cycle delay*/

3. 软件 SPI 读写函数(模式 0)

  • 软件模拟SPI模式0交换一个字节
c 复制代码
/**
 * Function:    Soft_SPI_Read_Write
 * Description: CN:软件模拟SPI模式0交换一个字节--EN:Bit-bang SPI mode 0 exchange one byte
 * Parameters:  data - CN:要发送的字节--EN:Byte to send
 * Return value:CN:接收到的字节--EN:Received byte
 */
uint8_t Soft_SPI_Read_Write(uint8_t data)
{
    uint8_t i, rx = 0;
    DISI();                                             /*CN:关中断,保护时序--EN:Disable interrupt to protect timing*/

    for (i = 0; i < 8; i++)
    {
        PIN_MOSI(主出从入线)_SET(data & 0x80);                      /*CN:放MSB到MOSI(主出从入线)--EN:Place MSB on MOSI(主出从入线)*/
        data <<= 1;                                     /*CN:准备下一位--EN:Prepare next bit*/

        PIN_SCK(时钟线)_LOW();                                  /*CN:SCK(时钟线)低电平(空闲)--EN:SCK(时钟线) low (idle)*/
        PIN_DELAY();                                    /*CN:延时--EN:Delay*/
        PIN_SCK(时钟线)_HIGH();                                 /*CN:上升沿:从机采样MOSI(主出从入线),主机采样MISO(主入从出线)--EN:Rising edge: slave samples MOSI(主出从入线), master samples MISO(主入从出线)*/
        if (PIN_MISO(主入从出线)_READ()) rx |= 1;                   /*CN:读MISO(主入从出线)--EN:Read MISO(主入从出线)*/
        PIN_DELAY();                                    /*CN:延时--EN:Delay*/
        rx <<= 1;                                       /*CN:移动接收缓冲--EN:Shift receive buffer*/
    }

    PIN_SCK(时钟线)_LOW();                                      /*CN:恢复SCK(时钟线)空闲电平--EN:Restore SCK(时钟线) idle level*/
    ENI();                                              /*CN:开中断--EN:Enable interrupt*/
    return rx;
}
  • 软件SPI传输(含片选控制)
c 复制代码
/**
 * Function:    Soft_SPI_Transfer
 * Description: CN:软件SPI传输(含片选控制)--EN:Software SPI transfer with CS(片选线) control
 * Parameters:  tx_buf - CN:发送缓冲区--EN:Transmit buffer
 *              rx_buf - CN:接收缓冲区--EN:Receive buffer
 *              len    - CN:字节数--EN:Number of bytes
 * Return value:无
 */
void Soft_SPI_Transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint8_t len)
{
    uint8_t k;
    PIN_SS_LOW();                                        /*CN:拉低片选开始通信--EN:Pull SS low to start*/
    for (k = 0; k < len; k++)
    {
        rx_buf[k] = Soft_SPI_Read_Write(tx_buf[k]);      /*CN:逐字节交换--EN:Exchange byte by byte*/
    }
    PIN_SS_HIGH();                                       /*CN:拉高片选结束通信--EN:Pull SS high to end*/
}

4. 软件方式 vs 硬件方式

|----------|---------------|-----------------------|
| 维度 | 硬件 SPI | 软件模拟 SPI |
| 响应速度 | 纳秒级,硬件自动生成时钟 | 微秒级,受 NOP 延时和指令周期限制 |
| CPU 占用 | 极低,可配合 DMA | 整个传输期间 CPU 阻塞 |
| 时钟精度 | 高精度、高速度 | 精度取决于指令循环,易受中断干扰 |
| 适用场景 | 所有带 SPI 外设的场合 | 无硬件 SPI 的极简 MCU,或教学演示 |

九、提高稳定性的技巧

1. 片选信号"先拉后放"

  SPI 从机通常以 SS 的下降沿作为通信起始标志,上升沿作为结束标志。因此每次读写操作务必保证:先拉低 SS → 执行若干字节传输 → 再拉高 SS。每个指令(如读 ID、页写)都应完整包裹在对 SS 的一次拉低/拉高之间。

2. 上拉电阻防悬空

  MISO(主入从出线) 在无设备选中时处于高阻态,容易受干扰。可在 MISO(主入从出线) 上加上拉电阻(10kΩ 左右),或者将 GPIO 配置为内部上拉输入,保证空闲时电平确定。

3. 模式与分频严格匹配从机数据手册

  从机 W25Q32 支持的最高 SPI 时钟是 80MHz,但实际所用 MCU 和布线可能达不到,建议先用 4 分频或更低速率测试,稳定后再提速。

十、结语:全双工的高速舞者

  SPI 用四根线搭建起一个高速、全双工、可扩展的通信世界。你每一次调用 SPI1_Read_Write,底层都是那两个环起来的移位寄存器在 SCK(时钟线) 的节奏下默契对舞。

  掌握 SPI,就是掌握了一种高效与从设备对话的方式。下次你在板子上看到那四条飞线,愿你能会心一笑:这是主机和从机之间的一场同步华尔兹。

扩展阅读:

  • STM32F10xxx Reference Manual -- SPI 章节
  • W25Q32JV Data Sheet
  • 《Serial Peripheral Interface》 -- Motorola SPI Block Guide
  • 《Mastering the SPI Bus》 -- John J. Davies
相关推荐
zhangzhangkeji11 分钟前
stm32 14-1 WDG 看门狗:理论部分
stm32
liliangcsdn25 分钟前
派息率指标的应用探索和分析
大数据·算法
xh didida33 分钟前
Linux -- 基础IO
linux·服务器·开发语言·c++
不会代码的小猴1 小时前
启动文件
stm32·嵌入式硬件
垆边人似月.1 小时前
华为机试题 :两数之和
c++·算法·华为
默默开发1 小时前
物流行业可用算法方向
算法
深圳老胡1 小时前
STM32CubeMX 生成 CMake 工程,用 VSCode 编译与调试的完整流程
笔记·stm32·单片机·代码规范
垆边人似月.1 小时前
华为机试题 :有效的括号
c++·算法·华为
甜甜的大香瓜1 小时前
【MT32F006】MT32F006之Timer定时
单片机
奋斗的阿狸_19861 小时前
ESP32-S3 + ES7210 四通道麦克风录音上传方案
c语言·开发语言·fpga开发