【江科大STM32学习笔记-11】SPI通信协议 - 11.2 软件SPI读写W25Q64

目录

[1. SPI 通信协议基础](#1. SPI 通信协议基础)

[1.1 SPI 物理层](#1.1 SPI 物理层)

[1.1.1 SPI 总线结构与通信线](#1.1.1 SPI 总线结构与通信线)

[1.1.2 总线冲突规避](#1.1.2 总线冲突规避)

[1.2 SPI 协议层](#1.2 SPI 协议层)

[1.2.1 SPI 基本通讯过程](#1.2.1 SPI 基本通讯过程)

[1.2.2 通讯的起始和停止信号](#1.2.2 通讯的起始和停止信号)

[1.2.3 数据有效性](#1.2.3 数据有效性)

[1.2.4 通讯模式](#1.2.4 通讯模式)

[1.2.4.1 四种标准模式概览](#1.2.4.1 四种标准模式概览)

[1.2.4.2 CPOL & CPHA定义](#1.2.4.2 CPOL & CPHA定义)

[1.2.4.3 时序分析](#1.2.4.3 时序分析)

[1.2.4.4 模式 0 (CPOL=0, CPHA=0) 详解](#1.2.4.4 模式 0 (CPOL=0, CPHA=0) 详解)

[1.3 数据交换机制:移位寄存器](#1.3 数据交换机制:移位寄存器)

[1.3.1 物理结构:双 8 位环形回路](#1.3.1 物理结构:双 8 位环形回路)

[1.3.2 交换过程:移位与锁存](#1.3.2 交换过程:移位与锁存)

[1.3.3 软件工程层面的推论](#1.3.3 软件工程层面的推论)

[2. W25Q64 存储芯片介绍](#2. W25Q64 存储芯片介绍)

[2.1 引脚功能与典型外围电路](#2.1 引脚功能与典型外围电路)

[2.2 存储空间划分:块、扇区与页](#2.2 存储空间划分:块、扇区与页)

[2.3 Flash 读写规则:擦除后再写入](#2.3 Flash 读写规则:擦除后再写入)

[2.4 状态寄存器与忙 (BUSY) 检测](#2.4 状态寄存器与忙 (BUSY) 检测)

[2.5 通信逻辑:四种基础数据交互模型](#2.5 通信逻辑:四种基础数据交互模型)

[2.5.1 纯指令模型 (仅发指令码)](#2.5.1 纯指令模型 (仅发指令码))

[2.5.2 指令 + 数据模型 (指令码 + 数据流)](#2.5.2 指令 + 数据模型 (指令码 + 数据流))

[2.5.3 指令 + 地址模型 (指令码 + 24位地址)](#2.5.3 指令 + 地址模型 (指令码 + 24位地址))

[2.5.4 指令 + 地址 + 数据模型 (全要素模型)](#2.5.4 指令 + 地址 + 数据模型 (全要素模型))

[3. 软件模拟 SPI 的代码实现 (MySPI)](#3. 软件模拟 SPI 的代码实现 (MySPI))

[3.1 引脚初始化与底层操作封装](#3.1 引脚初始化与底层操作封装)

[3.1.1 引脚模式配置与初始电平设定](#3.1.1 引脚模式配置与初始电平设定)

[3.1.2 引脚操作封装](#3.1.2 引脚操作封装)

[3.2 基本时序控制:起始与停止信号](#3.2 基本时序控制:起始与停止信号)

[3.3 核心功能:模式 0 交换一个字节](#3.3 核心功能:模式 0 交换一个字节)

[4. W25Q64 驱动程序封装](#4. W25Q64 驱动程序封装)

[4.1 读取 ID 号:验证通信是否正常](#4.1 读取 ID 号:验证通信是否正常)

[4.2 基础指令实现:写使能与读状态](#4.2 基础指令实现:写使能与读状态)

[4.3 数据操作功能:擦除、写入与读取](#4.3 数据操作功能:擦除、写入与读取)

[4.4 流程优化:自动处理写使能与等待忙](#4.4 流程优化:自动处理写使能与等待忙)

[5. 本章节实验](#5. 本章节实验)

[5.1 软件 SPI 读写 W25Q64](#5.1 软件 SPI 读写 W25Q64)

[5.1.1 实验目标](#5.1.1 实验目标)

[5.1.2 硬件设计](#5.1.2 硬件设计)

[5.1.3 软件设计](#5.1.3 软件设计)

[5.1.4 实验现象](#5.1.4 实验现象)


1. SPI 通信协议基础

SPI(Serial Peripheral Interface,串行外设接口)是由 Motorola 公司提出的一种同步、全双工、一主多从的串行通信总线。该协议主要用于微控制器(MCU)与外部存储器、传感器或显示屏等外围设备之间的高速数据交换。SPI 协议没有定义复杂的寻址机制与应答逻辑,而是通过独立的硬件引脚实现物理层面的直接控制,这种设计使得 SPI 能够提供比 I2C 总线更高的数据传输速率。

1.1 SPI 物理层

1.1.1 SPI 总线结构与通信线

标准 SPI 总线采用四根独立的通信线连接主机与从机。系统通常采用"一主多从"架构:主机一般为单片机(如 STM32),从机为各类外设芯片(如 Flash、传感器等)。在该架构中,时钟线与数据线在所有设备之间共享,而片选信号由主机单独引出并分别连接至各个从机,用于实现目标设备的选择。如下图所示:

四根通信线的功能定义如下:

  • SCK (Serial Clock,串行时钟):该引脚用于提供通信的同步时钟信号,由主机产生并完全控制,从机被动接收。SPI 属于同步通信协议,数据的移位与采样均依赖时钟信号的边沿触发,因此通信速率直接取决于 SCK 的翻转频率。主机可以根据需要灵活调整时钟频率,甚至在通信过程中暂停时钟,从机都会同步等待。

  • MOSI (Master Output Slave Input,主机输出从机输入):该信号线用于主机向从机发送数据,是一条单向输出通道。主机通过 MOSI 发送指令码、地址以及写入数据等信息。在多从机场景下,所有从机的 MOSI 引脚通常并联连接,但只有被选中的从机会对数据进行解析。

  • MISO (Master Input Slave Output,主机输入从机输出):该信号线用于从机向主机返回数据,是一条单向输入通道。主机通过 MISO 接收从机的数据输出,例如读取到的存储数据或状态信息。在多从机系统中,所有从机的 MISO 引脚同样并联到一条总线上,因此需要额外机制避免总线冲突。

  • SS (Slave Select,从机选择) / CS (Chip Select,片选):从机选择信号线,通常为低电平有效,也常记作 NSS 或 CS。该引脚用于由主机指定当前参与通信的目标从机。当主机将某一从机的 SS 拉低时,该从机被选中并开始响应总线上的时钟与数据;当 SS 为高电平时,从机忽略总线上的通信信号,处于未选中状态。在一主多从结构中,每个从机通常对应一根独立的 SS 信号线,主机通过控制不同的 SS 实现设备选择,而无需像 I2C 那样进行地址寻址。

1.1.2 总线冲突规避

在多从机并行的 SPI 拓扑结构中,所有从机的 MISO(从机输出)引脚物理连接在同一条公共总线上。为了确保通信的可靠性与硬件安全,系统必须解决总线冲突问题。

由于 SPI 引脚通常采用驱动能力较强的推挽输出结构,若多个从机同时激活并尝试驱动总线输出不同的电平(例如一个输出高电平 3.3V,另一个输出低电平 0V),则会在从机引脚之间形成直接的短路通路。由此产生的巨大总线冲突电流 (Bus Contention) 不仅会导致数据逻辑错误,还可能造成半导体器件的永久性物理损坏。

为了规避电气冲突,SPI 协议利用高阻态实现逻辑隔离。高阻态在电气特性上等效于引脚与内部电路物理断开,其输入阻抗趋于无穷大,既不驱动电平也不吸收电流。在 W25Q64 等标准 SPI 设备中,MISO(DO)引脚的状态受片选信号 /CS 的直接控制:

  • 未选中状态 (/CS = 1):当片选引脚为高电平时,从机内部硬件会将 MISO 引脚切换至高阻态。此时,该从机对总线呈现"透明"状态,不干扰总线上其他正在进行的通信。

  • 选中状态 (/CS = 0):只有当主控 MCU 拉低特定从机的片选信号时,该从机才被激活。其 MISO 引脚立即由高阻态转为有效的推挽输出模式,独占总线向主机返回数据。

这种由 /CS 信号控制的电气隔离机制,确保了共享总线环境下的发送源唯一性。因此,/CS 信号不仅是设备的选择标识,更是定义通信帧边界的关键时序。如果 /CS 切换时序与数据时钟不严格对齐,会导致以下后果:

  • 总线释放滞后:从机未能及时回到高阻态,引发与下一个通信设备的电平碰撞。

  • 状态机异常:芯片内部的指令译码器依靠 /CS 的上升沿来复位逻辑状态。若时序错乱,会导致指令偏移或后续数据帧解析错误。


1.2 SPI 协议层

与 I2C 协议类似,SPI 协议也严格定义了通讯的起始和停止信号、数据有效性、时钟同步等核心环节。

1.2.1 SPI 基本通讯过程

先来看看一个 SPI 通讯的基本时序,见下方插图:

这是一个典型的主机通讯时序图。在 SPI 总线中,NSS(片选信号Slave Select)、SCK(时钟信号Clock)、MOSI 均由主机控制产生;而 MISO由从机产生,主机通过该信号线读取从机发送的数据。需要特别注意的是,MOSI 与 MISO 上的数据信号只有在 NSS 为低电平(即从机被选中)的时候才有效。在 SCK 的每个时钟周期内,MOSI 和 MISO 各自传输一位数据。

接下来,我们将对以上通讯流程中包含的各个关键信号进行分解讲解:

1.2.2 通讯的起始和停止信号

如下图所示,NSS 信号线由高电平变为低电平,这便是 SPI 通讯的起始信号。由于 NSS 是每个从机各自独占的信号线,当从机在自己的 NSS 线检测到下降沿的起始信号后,就会知道自己被主机选中了,从而开始准备与主机进行通讯。

NSS 信号由低电平变回高电平,这是 SPI 通讯的停止信号,表示本次通讯结束,从机的选中状态被取消。

1.2.3 数据有效性

SPI 协议通过 MOSI 与 MISO 信号线实现物理层的数据传输,并依靠 SCK 同步时钟信号来精确控制每一位数据的读写时序。

在 SCK 的每个时钟周期内,MOSI 与 MISO 数据线同时传输一位数据,这种机制实现了全双工(Full-Duplex)的高效通信。关于数据传输的位序(Bit Order),SPI 标准本身并未做强制规定,但收发双方必须遵循一致的协定。在 W25Q64 存储器及大多数嵌入式应用中,均严格采用 高位先行 (MSB First) 模式。(图片来源:野火STM32 库开发实战指南文档)
图 - SPI通讯时序

观察上图中的标号 ②、③、④、⑤ 处,可以清晰地识别出 SPI 通信中的 触发(Trigger) 与 **采样(Sample)**过程:

  • 电平切换(触发沿):在标号 ②、④ 处(SCK 上升沿),MOSI 与 MISO 的数据电平发生跳转。此时,发送端根据要传输的位数据(0 或 1)切换引脚电平,为下一次采样做准备。

  • 数据有效(采样沿):在标号 ③、⑤ 处(SCK 下降沿),数据线电平已进入稳定状态,接收端在此刻对电平进行采样锁存。此时数据被视为有效,高电平逻辑表示1,低电平逻辑表示0。

在两次采样之间的电平跳变期间,数据处于不稳定状态,属于无效时段。

此外,SPI 通信的基本数据单元通常为 8 位(1 字节)16 位。在 W25Q64 的具体实现中,所有指令、地址和存储数据均以 8 位为一个标准字节进行传输。在一个完整的 /CS 片选周期内,传输的字节总数不受协议硬性限制,但必须符合芯片内部的业务逻辑(如页编程的 256 字节限制)。

**注意:**在 NSS 信号线为高电平的非选中状态下,从机的 MISO 信号线通常处于高阻态(High-Z),在时序图中表现为电平处于"0"和"1"逻辑电平之间的中间水平线。 此时从机引脚相当于从总线上断开,不对信号线产生驱动作用,这一特性确保了在同一总线上挂载多个从机时,只有当前被选中的从机能够驱动 MISO 线路,有效避免了信号冲突。

1.2.4 通讯模式

前面图-SPI通讯时序中讲述的时序仅仅是 SPI 通讯中的其中一种模式(模式 1)。为了兼容不同硬件厂商的芯片设计,SPI 协议并没有对时钟SCK空闲时的电平状态与数据的采样时机进行强制绑定。通过配置时钟极性(CPOL, Clock Polarity)时钟相位(CPHA, Clock Phase),SPI 衍生出了四种标准的工作模式。

1.2.4.1 四种标准模式概览

四种工作模式的物理特性与采样规律如下表所示:

SPI 模式 CPOL (时钟极性) CPHA (时钟相位) 空闲时 SCK 时钟状态 采样时刻 (数据锁存)
模式 0 0 0 低电平 奇数边沿 (上升沿)
模式 1 0 1 低电平 偶数边沿 (下降沿)
模式 2 1 0 高电平 奇数边沿 (下降沿)
模式 3 1 1 高电平 偶数边沿 (上升沿)
1.2.4.2 CPOL & CPHA定义

为方便理解后续的时序图,需先明确这两个核心控制参数的物理含义。

(1)时钟极性(CPOL)

定义 SCK 在通信空闲状态(即 SPI 通讯开始前、NSS 处于高电平时)的默认电平。

  • CPOL = 0:空闲时 SCK 保持低电平。

  • CPOL = 1:空闲时 SCK 保持高电平。

(2)时钟相位(CPHA)

定义数据移入(采样)与数据移出(输出)所对应的时钟边沿次序。

  • CPHA = 0 :数据在 SCK 的 第 1 个边沿 被采样移入寄存器。这意味着数据的首次移出动作必须在第 1 个边沿到来之前完成(通常在 NSS 下降沿时触发首次移出)。

  • CPHA = 1 :数据在 SCK 的 第 2 个边沿 被采样移入寄存器。这意味着数据的首次移出动作发生在 SCK 的第 1 个边沿。

1.2.4.3 时序分析

SPI 的位同步逻辑并非由简单的"上升沿"或"下降沿"固定,而是取决于时钟边沿的奇偶次序

(1)CPHA = 0 时的时序特征

在此配置下,无论空闲时 SCK 为低电平 (CPOL=0) 还是高电平 (CPOL=1),数据采样时刻均固定发生在 SCK 的奇数边沿(即第 1, 3, 5... 个跳变沿)。见下图:
图 - CPHA=0时的SPI通讯模式

  • 采样逻辑

    • 当 CPOL=0 时,奇数边沿为上升沿。

    • 当 CPOL=1 时,奇数边沿为下降沿。

  • 触发与切换:MOSI 和 MISO 的有效电平在奇数边沿必须保持稳定以供采样;而在偶数边沿(非采样时刻),数据线进行电平切换,为下一位传输做准备。

  • 关键点:由于第一个奇数边沿即执行采样,发送方必须在 NSS 片选信号拉低后、第一个 SCK 边沿到来前,就提前将最高位 (MSB) 数据输出至总线上。

(2)CPHA = 1 时的时序特征

当 CPHA=1 时,采样逻辑发生反转。无论 CPOL 取值如何,数据电平的采样操作均延迟至 SCK 的偶数边沿(即第 2, 4, 6... 个跳变沿)执行。具体时序见下图:
图 - CPHA=1时的SPI通讯模式

  • 采样逻辑

    • 当 CPOL=0(空闲低电平)时,第一个边沿(上升沿)用于触发输出,第二个边沿(偶数沿,下降沿)用于采样。

    • 当 CPOL=1(空闲高电平)时,第一个边沿(下降沿)用于触发输出,第二个边沿(偶数沿,上升沿)用于采样。

  • 触发与切换:与 CPHA=0 相反,数据线的电平切换发生在每一个奇数边沿。这意味着主从设备在检测到第一个时钟脉冲跳变时才开始输出数据,并在随后到来的第二个边沿捕获电平。

通过上述分析可知,SPI 的四种模式实质上定义了 触发沿(Trigger Edge) 与**采样沿(Sample Edge)**的交替规律:

配置参数 数据采样时刻 (Sample) 数据切换时刻 (Trigger)
CPHA = 0 SCK 奇数边沿 (1st, 3rd...) SCK 偶数边沿 (2nd, 4th...)
CPHA = 1 SCK 偶数边沿 (2nd, 4th...) SCK 奇数边沿 (1st, 3rd...)

在实际工程开发(如配置 STM32 的 SPI_InitTypeDef 结构体)时,开发者应根据从机手册(如 W25Q64 兼容模式 0 和模式 3)准确选择 CPOL 与 CPHA。若主从设备的时钟相位配置不匹配,将导致数据采样点落在电平跳转的瞬间,产生严重的指令偏移或数据帧解析错误。

1.2.4.4 模式 0 (CPOL=0, CPHA=0) 详解

在绝大多数的 SPI 外设(例如常见的 W25Q64 存储芯片)中,模式 0 是最常用的通信配置。结合其物理电平变化与寄存器移位逻辑,具体的时序推进过程如下:
图 - 模式 0 时序图

  1. 空闲状态:NSS 引脚保持高电平,SCK 引脚保持低电平。

  2. 通信起始与首次电平输出:主机将 NSS 引脚电平拉低,产生下降沿启动通信。在此硬件触发下,主机与从机根据各自移位寄存器中最高位(MSB)的逻辑值,提前分别驱动 MOSI 和 MISO 引脚输出对应的物理电平状态(逻辑 1 对应高电平,逻辑 0 对应低电平),完成首次数据位的输出配置。

  3. 数据采样(电平锁存):主机将 SCK 引脚拉高,产生上升沿(第 1 个边沿)。此时 MOSI 与 MISO 引脚上的物理电平已经处于稳定状态,主机与从机同时在此时钟边沿读取对应数据线上的电平,并将其作为逻辑值存入各自移位寄存器的最低位(LSB)。

  4. 后续数据输出(电平切换):主机将 SCK 引脚拉低,产生下降沿(第 2 个边沿)。在此时钟边沿驱动下,主机与从机的内部移位寄存器完成移位操作,并根据新的最高位(MSB)逻辑值,再次更新并驱动 MOSI 与 MISO 引脚的物理电平状态。

  5. 循环往复:重复执行步骤 3 与步骤 4,直至 8 个完整的时钟周期结束,8 位数据全部交换完毕。最后,主机将 NSS 引脚电平拉高,结束本次通信。

在实际工程应用中,主机与从机必须配置并工作在完全相同的模式下才可以建立正常通讯。在所有四种 SPI 标准模式中,业界采用最为广泛的是"模式 0"与"模式 3"。


1.3 数据交换机制:移位寄存器

SPI 协议的底层通信并非简单的"发送"或"接收",其物理本质是主从设备之间通过移位寄存器 实现的闭环数据交换(Data Exchange)

1.3.1 物理结构:双 8 位环形回路

在硬件层面,主设备(Master)与从设备(Slave)内部各维护一个 8 位移位寄存器。主机的移位寄存器受其内部波特率发生器的驱动,同时该发生器产生的 SCK 时钟信号同步驱动从机的寄存器。

通过两条信号线的交叉连接------主机的 MOSI 输出连接从机的 DI(Data Input),从机的 DO(Data Output)连接主机的 MISO------主从双方在物理上构成了一个 16 位环形移位寄存器。如下图所示,该动图展示了随着 SCK 时钟脉冲的驱动,主从机内部数据位在闭环回路中循环移动的动态过程:
图 - SPI数据交换机制

1.3.2 交换过程:移位与锁存

为了与上方动图的演示逻辑保持直观对应,本节以 低位先行 (LSB First) 模式为例进行拆解。虽然在 W25Q64 的实际应用中普遍采用的是高位先行 (MSB First),但两者的物理本质完全一致,仅在移位寄存器的方向选择上有所区别。

在一个完整的 SCK 时钟周期内,主从设备协同执行以下两个严格同步的物理动作:

  • 移出 (Shift Out) :在 SCK 的触发沿(如上升沿),主机的移位寄存器将其最低位 (Bit0) 的电平驱动至 MOSI 线;同步地,从机也将自身寄存器的最低位通过 MISO 线移出。此时,数据位从寄存器"溢出"并占据总线电平。

  • 移入 (Shift In) :在随后的采样沿(如下降沿),主机读取 MISO 线上的电平状态,并将其存入自身移位寄存器的最高位 (Bit7);与此同时,从机读取 MOSI 上的电平并存入其寄存器的最高位。此时,新接收的位数据填补了因移位产生的空位。

经过 8 个完整的 SCK 时钟周期,原本存储在主机中的 8 位数据(01234567)将完全转移到从机中,而从机中的数据(01234567)也通过闭环回路完全进入了主机的缓冲区。

这种"低位移出、高位补进"的循环过程(在 MSB 模式下则是"高位移出、低位补进")确保了主从设备在每一帧通信结束时,都能精准地完成一次等长的数据置换。这就是 SPI 能够实现全双工字节交换的底层物理逻辑。

1.3.3 软件工程层面的推论

基于这种强制交换的物理特性,实际编程时必须遵循以下逻辑:

  • 发即是收:在 SPI 通信中,任何发送操作都会伴随着接收动作。如果主机仅需向从机发送指令(如写使能 06h),在操作完成后,主机接收到的冗余数据应在软件层面直接丢弃。

  • 占位字节(Dummy Byte):由于 SCK 时钟仅能由主机产生,如果主机需要从从机读取数据(如读状态寄存器 05h),它必须通过发送一个无意义的占位字节(通常为 0xFF 或 0x00)来换取从机的有效电平。只有主机的移位寄存器产生动作,时钟脉冲才会产生,进而驱动从机的数据移入主机。

这种基于移位寄存器的交换机制,确保了 SPI 通信的极高性能与位级同步,但也要求开发者在处理驱动逻辑时,必须严谨地平衡主从双方的数据流动。


2. W25Q64 存储芯片介绍

W25Q64 是一款由华邦电子(Winbond)生产的非易失性 NOR Flash 存储芯片。该芯片采用 SPI 总线接口,具备高存储密度、低功耗与小型化封装的特点,在嵌入式系统中常用于存储字库、图片文件、音频数据或系统固件。W25QXX 储存模块实物如下如图所示:

2.1 引脚功能与典型外围电路

在常见的 SOP8 封装中,W25Q64 提供 8 个物理引脚,如下图所示:

除了标准的电源引脚(VCC 与 GND)和 SPI 通信引脚(CS、CLK、DI、DO)外,还包含两个用于特殊硬件控制的引脚(/HOLD、/WP),其引脚定义与外围电路配置如下:

  • VCC 与 GND:芯片标准供电电压通常为 3.3V。在典型电路中,VCC 与 GND 之间并联了一个标号为 104(0.1μF)的电容(C1)。这是一个高频旁路电容,用于滤除电源总线上的高频噪声,保障芯片在高频时钟下读写数据的稳定性。此外,电路上并联的 1K 电阻(R1)与发光二极管(D1)构成了模块的上电状态指示灯。

  • /CS (Chip Select):片选输入引脚,低电平有效。对应 SPI 总线的主机 NSS(或 SS)引脚。

  • CLK (Clock):串行时钟输入引脚。对应 SPI 总线的主机 SCK 引脚。

  • DI (Data In) / DO (Data Out):串行数据输入与输出引脚。DI 对应主机的 MOSI,DO 对应主机的 MISO。

  • /WP (Write Protect):硬件写保护引脚,低电平有效。当该引脚拉低时,芯片内部的状态寄存器将被锁定,无法通过软件指令修改保护控制位,从而实现硬件级别的数据防篡改。在不需要硬件写保护的典型电路中,该引脚通常直接接 VCC 保持高电平。

  • /HOLD (Hold):总线保持引脚,低电平有效。在多从机 SPI 系统中,当主机需要临时中断与 W25Q64 的通信去处理更高优先级的任务时,可拉低此引脚。此时芯片将暂停当前的数据传输,挂起内部状态,并将 DO 引脚置为高阻态。恢复高电平后,通信可从中断处继续。在常规应用中,该引脚通常接 VCC。

其典型外围电路如下图所示:

2.2 存储空间划分:块、扇区与页

W25Q64 的总存储容量为 64 Mbit,即 8 MB。芯片内部采用 24 位地址总线进行数据寻址。根据二进制寻址原理,24 位地址能够覆盖的最大寻址空间计算如下:

W25Q64 的实际容量为 8 MB,因此其有效地址范围占据了 24 位地址空间的前半部分,即 0x000000 至 0x7FFFFF。

为了实现高效的物理结构管理与寻址映射,Flash 内部的存储空间被严格划分为三个层级:

  • 块 (Block):全容量 8 MB 被均匀划分为 128 个块(Block 0 ~ Block 127),每个块的物理容量为 64 KB。
  • 扇区 (Sector):每个 64 KB 的块被进一步等分为 16 个扇区(Sector 0 ~ Sector 15),每个扇区的容量为 4 KB。扇区是 W25Q64 执行物理擦除操作的最小单元。
  • 页 (Page):每个 4 KB 的扇区被细分为 16 页(Page 0 ~ Page 15),每页的容量为 256 Bytes。页是 W25Q64 执行单次编程(写入)操作的最大物理边界。从下面所示的图 - W25Q64BV 模块图来看,图中存储空间的每一行逻辑上即代表一页(Beginning Page Address 至 Ending Page Address),在软件编程时,单次连续写入的数据地址不能跨越这一行的物理边界。

图 - W25Q64BV 模块图

2.3 Flash 读写规则:擦除后再写入

对 W25Q64 进行数据修改必须严格遵循 NOR Flash 的物理电气规律,具体表现为以下四条核心机制:

  • 物理改写限制 (Bit-Alteration Restriction) :Flash 存储单元的物理结构决定了其编程(写入)操作只能通过硬件电路将逻辑位从 1 改写为 0。如果在写操作中尝试将逻辑 0 改写为逻辑 1,该位的电平状态将被硬件忽略,维持 0 不变。

  • 写前擦除机制 (Erase-Before-Write) :为了克服上述单向位改写的限制,在更新数据前必须先执行擦除指令。擦除电路通过施加高压,将指定物理区域内的所有数据位统一置位为 1。因此,擦除后的存储区域读取结果全为 0xFF。由于擦除操作依赖特定的硬件区块,其执行粒度远大于写入,W25Q64 支持的最小擦除单位为一个扇区(4 KB)。

  • 写使能保护 (Write Enable) :所有涉及修改 Flash 内部状态的指令(如页编程、扇区擦除、修改状态寄存器等),在执行前必须先单独发送一条"写使能 (0x06)"指令。该指令会将芯片内部的写使能锁存器(WEL, Write Enable Latch)置位。单次擦除或写入操作完成后,WEL 位会自动由硬件清零。因此,连续的写操作必须在每次操作前重新发送写使能指令。

  • 页缓冲与边界回环 (Page Boundary Roll-over):W25Q64 内部集成了一个 256 Bytes 的 SRAM 页缓存区,如下图所示(256-Byte Page Buffer)。页编程操作会先将 SPI 总线接收到的数据按序存入该缓存,随后由内部高压发生器(High Voltage Generators)统一烧录至 Flash 矩阵。如果单次连续写入的数据超过 256 Bytes,或者写入的起始地址加上数据长度跨越了当前物理页的末尾边界(例如跨越 0000FFh),内部的地址计数器不会自动跳转至下一页的首地址(000100h),而是会回环(Roll-over)至当前页的首地址(000000h)进行覆盖写入,从而导致严重的数据错乱。

工程应用区分:

  • 读取操作(如读数据 03h):芯片内部的地址计数器会自动递增,主控可以跨页、跨扇区、无视物理边界连续读取,直至芯片最大容量。

  • 写入操作 (如页编程 02h):受限于内部硬件架构,连续写入的数据量必须受到 256 字节页缓冲边界 (Page Buffer) 的物理限制。若单次写入时序中发送超过 256 字节,内部地址指针将在当前页内发生回卷 (Roll-over),导致新数据覆盖最初写入的数据。

2.4 状态寄存器与忙 (BUSY) 检测

SPI 总线的通信速率可达数十兆赫兹,但 Flash 的物理擦写操作具有明显的延迟特性。典型情况下,单次页编程耗时约为 0.7 ms,扇区擦除耗时约为 30 ms,整片擦除甚至需要数十秒。

为了解决这种总线速率与物理执行速率的严重不匹配,软件驱动必须建立状态同步机制。W25Q64 内部包含多个状态寄存器,最核心的为状态寄存器 1 (Status Register-1),如下图所示:
图 - Status Register-1

状态寄存器 2 (Status Register-2),如下图所示:
图 - Status Register-2

依据寄存器位图定义,在数据通信控制中最关键的是最低两位:

  • S0 (BUSY, Erase/Write In Progress):忙标志位。

    • BUSY = 1:表示芯片内部的高压发生器和控制逻辑正在执行页编程、擦除或写状态寄存器等耗时物理操作。在此期间,芯片处于完全受控的忙碌状态,除了"读状态寄存器"指令外,硬件将忽略总线上的所有其他指令。

    • BUSY = 0:表示内部物理操作已完成,芯片当前处于空闲状态,可以接收并执行新的操作指令。

  • S1 (WEL, Write Enable Latch) :写使能锁存位。该位为 1 时表示已准备好接收写操作指令;在硬件复位、写禁能 (0x04) 或任何写/擦除操作完成后,该位自动清零。

在主控 MCU 发起任何写入或擦除操作后,必须进入一个 while 循环:首先发送一次"读状态寄存器 1 (0x05)"指令,随后在片选维持低电平的状态下,通过 SPI 不断交换哑元数据(Dummy Byte)以持续采样状态字节,并实时提取返回值的最低位进行判断。只有当检测到 BUSY 位恢复为 0 时,程序才能退出循环并拉高片选结束通信。这是保障系统操作时序一致性、防止数据丢失的核心软件工程实践。

cpp 复制代码
void W25Q64_WaitBusy(void)
{
	uint32_t Timeout;
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);				//交换发送读状态寄存器1的指令
	Timeout = 100000;							//给定超时计数时间
	while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01)	//循环等待忙标志位
	{
		Timeout --;								//等待时,计数值自减
		if (Timeout == 0)						//自减到0后,等待超时
		{
			/*超时的错误处理代码,可以添加到此处*/
			break;								//跳出等待,不等了
		}
	}
	MySPI_Stop();								//SPI终止
}

2.5 通信逻辑:四种基础数据交互模型

W25Q64 将底层的 SPI 字节置换机制抽象为了具有特定业务逻辑的通信协议。基于官方指令集表(Instruction Set Table),所有的设备访问都遵循"指令码 ---> 地址流 ---> 数据流 "的结构化帧模型,且所有数据字节严格按照高位先行 (MSB First) 的规则移位传输。指令集表如下图所示:
图 - Instruction Set Table 1
注意: 依照图中 Note 1 的提示,带括号 () 的字节表示从W25Q64的 DO 引脚输出的数据。所以,原厂手册中指令集表1的 Page Program (02h) 与 Write Status Register (01h) 两行中的数据字节: (D7-D0) 与 (S7-S0)(S15-S8),被标注上了 ( ) 括号,实属标注失误,这几个字段均为向设备W25Q64写入的数据(从W25Q64 的 DI 引脚输入),所以不应该带括号。请开发者在对照时序时注意识别。
图 - Instruction Set Table 2 (Read Instructions)

在一次完整的 /CS 片选周期内(即片选引脚拉低到拉高之间),总线上传输的第一个字节永远被解析为指令码 (Instruction Code)。芯片内部的指令译码器根据这 8 位数据判断当前请求的操作类型,并决定后续通信的时序结构。

结合时序图,W25Q64 的数据交互模型可划分为以下四个基础类别:

2.5.1 纯指令模型 (仅发指令码)

此模型仅需发送一个字节的指令码,无需附加任何物理地址或数据流。
图 - 写使能 (Write Enable, 06h)

"写使能 (Write Enable, 06h)" 为例,主控 MCU 拉低 /CS,在 DI 引脚通过 8 个时钟周期移入 06h,随后直接拉高 /CS 结束当前通信。芯片内部逻辑在捕获到 /CS 的上升沿信号后,即刻置位状态寄存器中的写使能锁存位 (WEL = 1)。


2.5.2 指令 + 数据模型 (指令码 + 数据流)

发送指令码后,立即进行数据的读取或写入操作,该模型通常用于读取芯片状态或设备 ID,不需要物理地址寻址。
图 - 读状态寄存器 1 (Read Status Register-1, 05h)

"读状态寄存器 1 (Read Status Register-1, 05h)" 为例,主控发送完 05h 指令后,保持 /CS 为低电平,并继续提供 SCK 时钟脉冲。从第 8 个时钟周期的下降沿开始,W25Q64 的 DO 引脚脱离高阻态,按 MSB First 依次输出 8 位状态数据。只要时钟持续产生,同一个状态字节就会被循环不断地输出。


2.5.3 指令 + 地址模型 (指令码 + 24位地址)

必须在指令码之后严格提供 3 个字节(24 位,A23-A0)的物理地址以锁定芯片内部的特定存储区域。
图 - 扇区擦除时序图 (Sector Erase, 20h)

"扇区擦除 (Sector Erase, 20h)" 为例,主控发送 20h 后,紧接着移入 24 位目标地址。关键操作点在于,必须在第 32 个时钟周期的末尾(即最后一个地址位 A0 被芯片锁存后)准时拉高 /CS。只有片选引脚产生这个上升沿,才会正式触发芯片内部的高压发生器对该 4KB 扇区执行硬件擦除流程。


2.5.4 指令 + 地址 + 数据模型 (全要素模型)

这是最完整的全要素交互模型。主控依次发送指令码、24 位物理地址后,总线不间断,继续提供时钟脉冲以实现大量数据的连续传输。
图 - 页编程时序图 (Page Program, 02h)

"页编程 (Page Program, 02h)" 为例,主控写入 24 位起始地址后,通过 DI 引脚连续移入多达 256 字节的有效数据(Data Byte 1 至 Data Byte 256),最后拉高 /CS 触发物理写入。


3. 软件模拟 SPI 的代码实现 (MySPI)

本章内容对应工程中的 MySPI.c 文件。该模块属于底层硬件抽象层(HAL),其核心任务是屏蔽具体硬件平台的寄存器细节,向上层设备驱动(W25Q64)提供统一、标准化的 SPI 通信接口。通过控制 STM32 的 GPIO 电平翻转,软件精确复现了 SPI 模式 0 的物理底层时序。

3.1 引脚初始化与底层操作封装

软件模拟 SPI 的第一步是建立 MCU 与 Flash 芯片之间的物理电气连接。本驱动分配 STM32 的 GPIOA 端口外设来实现 SPI 通信所需的四根信号线。

3.1.1 引脚模式配置与初始电平设定

  • PA4 (SS)、PA5 (SCK)、PA7 (MOSI) :这三个引脚负责向外输出控制信号与数据,统一配置为推挽输出模式(GPIO_Mode_Out_PP),输出翻转速率设置为 50MHz。推挽输出结构能够提供极低的输出阻抗和强劲的驱动电流,确保在高频通信下信号边沿保持陡峭,避免因电容负载导致的波形畸变。

  • PA6 (MISO) :该引脚负责接收从机的数据,配置为上拉输入模式(GPIO_Mode_IPU)。在多从机系统中,当所有从机的 SS 均未被激活时,从机的 MISO 引脚呈高阻态。配置内部上拉电阻可保证总线在悬空状态下维持稳定的高电平,防止引脚输入状态浮动导致 MCU 内部数字电路产生额外的功耗或误触发。

在端口配置完成后,必须根据 SPI 模式 0 的规范确立总线的空闲电平:将 SS 置为高电平以取消选中,将 SCK 置为低电平满足 CPOL=0 的物理要求。

cpp 复制代码
/**
  * 函    数:SPI初始化
  * 功能描述:初始化SPI通信所需的GPIO引脚,并配置初始的空闲电平状态
  */
void MySPI_Init(void)
{
    /* 1. 开启GPIOA外设时钟 */
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    /* 2. 配置主机输出引脚:SS(PA4), SCK(PA5), MOSI(PA7) 为推挽输出 */
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    /* 3. 配置主机输入引脚:MISO(PA6) 为上拉输入 */
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    /* 4. 设置SPI模式0的默认总线空闲状态 */
    MySPI_W_SS(1);       // SS默认高电平,释放总线,不选中任何从机
    MySPI_W_SCK(0);      // SCK默认低电平,满足模式0(CPOL=0)的空闲状态要求
}

3.1.2引脚操作封装

为了提升代码的可读性与跨平台可移植性,驱动采用基础函数封装的形式对 GPIO 读写操作进行了隔离。将特定的 0 或 1 逻辑电平映射至库函数GPIO_WriteBit 和GPIO_ReadInputDataBit。这种封装隔离了底层的硬件寄存器调用,使得后续的时序逻辑更加直观清晰。

cpp 复制代码
/*引脚配置层:基础电平控制原语*/

// 写SS引脚电平:BitValue为0置低电平(选中),为1置高电平(释放)
void MySPI_W_SS(uint8_t BitValue) {
    GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}

// 写SCK引脚电平:控制时钟线的高低电平翻转
void MySPI_W_SCK(uint8_t BitValue) {
    GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);
}

// 写MOSI引脚电平:向外输出数据,BitValue需实现非0即1的特性
void MySPI_W_MOSI(uint8_t BitValue) {
    GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);
}

// 读MISO引脚电平:返回当前MISO总线的物理电平状态(0或1)
uint8_t MySPI_R_MISO(void) {
    return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);
}

3.2 基本时序控制:起始与停止信号

SPI 总线以 CS/SS 引脚的电平跳变作为一次完整数据帧的边界标识。所有的 Flash 操作指令均必须严格包裹在 MySPI_Start() 与 MySPI_Stop() 函数之间。

  • 起始信号 (MySPI_Start):调用底层封装函数,将 SS 引脚电平强制拉低(置 0)。此操作产生一个物理下降沿,触发 W25Q64 内部状态机的唤醒机制,使其准备接收总线上的时钟与数据信号。

  • 停止信号 (MySPI_Stop):调用底层封装函数,将 SS 引脚电平恢复拉高(置 1)。此操作产生一个物理上升沿,指示当前指令周期结束,W25Q64 将终止数据传输,并将内部逻辑复位至待机状态。

cpp 复制代码
/*协议层:通信边界控制*/

/**
  * 函    数:SPI起始信号
  * 功能描述:拉低SS片选线,激活从机设备,标志一帧通信的开始
  */
void MySPI_Start(void)
{
    MySPI_W_SS(0);  
}

/**
  * 函    数:SPI终止信号
  * 功能描述:拉高SS片选线,取消从机选中状态,标志一帧通信的结束
  */
void MySPI_Stop(void)
{
    MySPI_W_SS(1);  
}

3.3 核心功能:模式 0 交换一个字节

字节交换函数 MySPI_SwapByte() 是整个软件 SPI 驱动的核心引擎。该函数负责在软件层面将输入的 8 位并行数据串行化输出,同时将串行线上的电平状态并行化组装,其内部通过一个循环 8 次的结构完整复现了 SPI 模式 0(CPHA = 0)的时序要求。

在模式 0 下,数据在时钟的第 1 个边沿(上升沿)被采样,这意味着主机的 MOSI 数据线必须在上升沿到来前准备就绪。STM32 的软件执行速度(基于 72MHz 主频)远低于 W25Q64 支持的最高物理通信速率(80MHz),因此连续的电平翻转语句之间无需额外插入微秒级延迟函数。

cpp 复制代码
/**
  * 函    数:SPI交换传输一个字节 (模式0)
  * 参    数:ByteSend 要发送给从机的8位数据
  * 返 回 值:从机返回给主机的8位数据
  */
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
    uint8_t i;
    uint8_t ByteReceive = 0x00;  // 定义接收变量,必须初始化为0x00,便于后续按位或运算
    
    for (i = 0; i < 8; i ++)     // 循环8次,完成8个数据位的物理移位交换 (MSB先行)
    {
        /* 1. 主机移出数据到 MOSI (在空闲状态或下降沿之后进行) */
        // 使用掩码 (0x80 >> i) 从高位到低位依次提取发送字节的特定位
        // 利用 !! 双重逻辑非,将提取的结果强行归一化为标准的逻辑电平 1 或 0
        MySPI_W_MOSI(!!(ByteSend & (0x80 >> i)));
        
        /* 2. 产生SCK时钟上升沿 */
        // 时钟由低变高。此时MOSI引脚电平已稳定,从机硬件将在此边缘采样MOSI数据
        MySPI_W_SCK(1);
        
        /* 3. 主机在上升沿期间采样 MISO */
        // 读取从机输出在MISO线上的电平状态
        if (MySPI_R_MISO()) 
        {
            // 若读到高电平,则通过掩码和按位或运算,将ByteReceive对应的位置1
            // 若读到低电平则不处理,保持初始的0状态
            ByteReceive |= (0x80 >> i);
        }
        
        /* 4. 产生SCK时钟下降沿 */
        // 时钟恢复低电平。从机硬件检测到此边缘后,会将内部寄存器的下一位数据移出至MISO线
        MySPI_W_SCK(0);
    }
    
    return ByteReceive;  // 8次循环结束,返回完整拼接出的8位接收数据
}

逻辑细节深入解析:

  1. 数据移出位提取: 鉴于 SPI 协议采用高位先行(MSB First)格式,发送数据前需从最高位(Bit 7)开始依次提取。代码中使用的掩码 (0x80 >> i) 非常精炼,随着循环变量 i 的增加,掩码依次变为 0x80、0x40、0x20 等。结合双重逻辑非操作符 !!,剥离出的非零数值被转换为干净的逻辑 1 或 0 并写入 MOSI 硬件引脚,完成物理驱动。

  2. 时钟上升沿驱动与采样: 将 SCK 引脚置 1 时,总线产生物理上升沿。由于通信双方遵循严谨的同步协议,从机硬件会在此时刻锁存 MOSI 的电平;同时,主机在此高电平窗口期读取 MISO 引脚状态,通过 | 运算将电平数据重构回 ByteReceive 变量中。

  3. 时钟下降沿驱动: 将 SCK 引脚恢复置 0。这个动作不仅仅是恢复空闲电平,它还是一个物理触发信号------驱动 W25Q64 内部移位寄存器将其下一位数据推送至 MISO 引脚上,为即将到来的下一个循环做好电气准备。循环往复 8 次,完成完整字节的同步置换。


4. W25Q64 驱动程序封装

本章内容对应工程中的 W25Q64.c 文件与 W25Q64_Ins.h 指令头文件。驱动程序在应用协议层将底层的字节交换函数进行组合,并严格按照 W25Q64 的数据手册规范,封装出具有明确业务意义的数据操作接口。

4.1 读取 ID 号:验证通信是否正常

读取设备 ID 是驱动开发阶段验证 SPI 物理链路与时序逻辑是否正确的首要步骤。W25Q64 支持 JEDEC 标准的 ID 读取指令(指令码 0x9F)。

在 W25Q64_ReadID 函数中,主机首先输出起始信号拉低 NSS 引脚,随后发送指令码 0x9F。发送完毕后,芯片内部会将厂商 ID 与设备 ID 依次推入发送移位寄存器。此时,主机需要连续三次调用 MySPI_SwapByte(0xFF) 函数。这里发送的 0xFF 被称为占位字节(Dummy Byte),其唯一的物理作用是产生 8 个 SCK 时钟边沿,以此将从机端的数据置换至主机端。

前三次交换分别获取:

  • 厂商 ID (MID) :华邦电子的固定标识为 0xEF

  • 设备 ID 高 8 位 :表示存储器类型,读取值为 0x40

  • 设备 ID 低 8 位 :表示容量标识,读取值为 0x17

获取后,程序通过左移操作符(<<)与按位或(|)逻辑,将两个 8 位的设备 ID 拼接为 16 位的完整 DID 变量。操作完成后,主机输出停止信号,结束本次通信。

cpp 复制代码
/**
  * 函    数:W25Q64读取ID号
  * 功能描述:发送JEDEC ID读取指令,获取厂商ID与设备ID,用于验证SPI通信链路
  * 参    数:MID 厂商ID,通过指针输出返回
  * 参    数:DID 设备ID,通过指针输出返回
  */
void W25Q64_ReadID(uint8_t *MID, uint16_t *DID)
{
    MySPI_Start();                              // 产生起始信号,选中W25Q64芯片
    MySPI_SwapByte(W25Q64_JEDEC_ID);            // 交换发送读取JEDEC ID指令 (0x9F)
    
    // 接收数据时,必须发送占位字节 (Dummy Byte, 0xFF) 以产生SCK时钟驱动从机输出
    *MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);   // 接收第1个字节:厂商ID (MID, 0xEF)
    
    *DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);   // 接收第2个字节:设备ID高8位 (Memory Type, 0x40)
    *DID <<= 8;                                 // 将高8位左移至16位变量的高位
    *DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);  // 接收第3个字节:设备ID低8位 (Capacity, 0x17) 并合并
    
    MySPI_Stop();                               // 产生停止信号,结束通信
}

4.2 基础指令实现:写使能与读状态

对 Flash 存储矩阵的任何修改操作均具备较高的风险,因此 W25Q64 规定了严格的前置与后置状态控制指令。

  • 写使能 (W25Q64_WriteEnable) :该函数用于解除芯片内部的硬件写保护锁定。主机发起通信起始信号,单次发送指令码 0x06,随后立即发起停止信号。该指令执行后,芯片状态寄存器中的 WEL(Write Enable Latch)位被置 1。如果不执行此步骤,后续所有的擦除与编程指令均会被芯片解码器拦截并丢弃。

  • 等待忙碌 (W25Q64_WaitBusy):Flash 物理擦写耗时处于毫秒级别,软件必须具备阻塞等待机制。该函数通过发送读状态寄存器 1 的指令(0x05),并进入 while 循环连续发送占位字节以获取状态寄存器的实时值。程序提取接收字节的最低位(BUSY 位),若判别结果为 1,程序维持循环继续轮询;若为 0,表明芯片内部物理操作结束,跳出循环并终止通信。为了防止芯片损坏导致死循环,工程实现中引入了超时计数器(Timeout)。

cpp 复制代码
/**
  * 函    数:W25Q64写使能
  * 功能描述:发送写使能指令,解除芯片内部硬件写保护,允许后续的擦除与编程操作
  */
void W25Q64_WriteEnable(void)
{
    MySPI_Start();                              // 产生起始信号
    MySPI_SwapByte(W25Q64_WRITE_ENABLE);        // 发送写使能指令 (0x06)
    MySPI_Stop();                               // 产生停止信号
}

/**
  * 函    数:W25Q64等待忙碌
  * 功能描述:轮询读取状态寄存器1的BUSY位,阻塞等待内部物理擦写操作完成
  */
void W25Q64_WaitBusy(void)
{
    uint32_t Timeout = 100000;                  // 设定超时阈值,防止异常硬件状态导致死循环
    
    MySPI_Start();                              // 产生起始信号
    MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1); // 发送读取状态寄存器1指令 (0x05)
    
    // 连续发送占位字节获取状态寄存器实时值,并通过掩码 (& 0x01) 提取最低位(BUSY位)进行判断
    // BUSY位为1表示正在执行内部写操作,为0表示空闲
    while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01)
    {
        Timeout--;                              // 等待时,超时计数器递减
        if (Timeout == 0)                       // 超时时间到
        {
            /* 此处可添加超时错误处理机制 */
            break;                              // 异常退出,跳出阻塞
        }
    }
    MySPI_Stop();                               // 物理操作完成或超时,产生停止信号
}

4.3 数据操作功能:擦除、写入与读取

数据操作是 W25Q64 驱动的核心功能,此类函数严格遵循"指令 + 24位地址 + 数据流"的通信架构。在传入 32 位的整型地址变量时,必须通过右移操作符将其拆分为 3 个独立的 8 位字节依次发送:即高位字节 Address >> 16、中位字节 Address >> 8 以及低位字节 Address(默认截断高位)。

  • 扇区擦除 (W25Q64_SectorErase) :主机发送指令码 0x20,随后连续发送拆分后的 3 个地址字节,最后停止通信。该指令会触发芯片内部高压电路,将目标地址所在的 4KB 扇区内的所有数据位擦除为逻辑 1(即字节数据变更为 0xFF)。

  • 页编程 (W25Q64_PageProgram) :主机发送指令码 0x02 以及 3 个地址字节后,通过 for 循环依次将数组中的数据发送至总线。页编程受到 256 字节页缓存的物理限制,传入的地址起点与数据长度(Count)必须保证操作落在同一个物理页边界内。

  • 读取数据 (W25Q64_ReadData) :主机发送指令码 0x03 与 3 个地址字节。寻址完成后,主机通过 for 循环连续发送占位字节,将从机端的数据逐个接收并存入本地数组。读取操作不改变物理存储阵列状态,且内部地址指针会自动递增,因此不受页边界限制,支持跨页甚至全片连续读取。

(注:擦除与页编程的具体代码实现融合了 4.4 小节的优化策略,详见下方代码块)

4.4 流程优化:自动处理写使能与等待忙

在裸机环境的驱动设计中,为了降低应用层(如 main.c 中的业务逻辑)的调用复杂度与出错概率,底层驱动需具备高度的封装性与稳定性。

在原始的 Flash 操作规范中,执行一次有效的数据写入实际上需要三个独立的步骤:发送写使能 ---> 发送写指令与数据 ---> 轮询等待操作完成。如果应用层开发人员遗漏其中任意一步,都会导致写入失败或后续指令冲突。

为此,在 W25Q64_SectorErase 与 W25Q64_PageProgram 这两个涉及内部存储状态变更的函数中,程序引入了自动化的流程控制机制:

  1. 事前写使能:在触发 MySPI_Start() 之前,函数内部直接调用一次W25Q64_WriteEnable()。

  2. 事后忙等待:在触发 MySPI_Stop() 之后,函数内部直接调用一次 W25Q64_WaitBusy()。

这种封装策略将写使能与阻塞轮询收敛于驱动层内部。应用层无需关注 Flash 底层的锁存机制与时序等待要求,即可像操作普通内存变量一样直接调用擦除与写入接口,显著提升了代码的易用性与系统稳定性。具体实现代码如下:

cpp 复制代码
/**
  * 函    数:W25Q64扇区擦除(4KB)
  * 参    数:Address 目标扇区的24位地址,范围:0x000000~0x7FFFFF
  */
void W25Q64_SectorErase(uint32_t Address)
{
    W25Q64_WriteEnable();                       // 【流程优化】前置执行写使能,解除写保护
    
    MySPI_Start();                              // 产生起始信号
    MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);    // 发送扇区擦除指令 (0x20)
    MySPI_SwapByte(Address >> 16);              // 拆分并发送24位地址的高8位
    MySPI_SwapByte(Address >> 8);               // 拆分并发送24位地址的中8位
    MySPI_SwapByte(Address);                    // 拆分并发送24位地址的低8位
    MySPI_Stop();                               // 产生停止信号,触发芯片内部执行擦除操作
    
    W25Q64_WaitBusy();                          // 【流程优化】后置执行忙等待,阻塞至擦除完成
}

/**
  * 函    数:W25Q64页编程
  * 参    数:Address 页编程的起始24位地址,范围:0x000000~0x7FFFFF
  * 参    数:DataArray 待写入数据的数组指针
  * 参    数:Count 写入字节数,范围:0~256 (不可跨越256字节的物理页边界)
  */
void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count)
{
    uint16_t i;
    
    W25Q64_WriteEnable();                       // 【流程优化】前置执行写使能,解除写保护
    
    MySPI_Start();                              // 产生起始信号
    MySPI_SwapByte(W25Q64_PAGE_PROGRAM);        // 发送页编程指令 (0x02)
    MySPI_SwapByte(Address >> 16);              // 发送地址高8位
    MySPI_SwapByte(Address >> 8);               // 发送地址中8位
    MySPI_SwapByte(Address);                    // 发送地址低8位
    
    for (i = 0; i < Count; i++)                 // 循环发送连续数据
    {
        MySPI_SwapByte(DataArray[i]);           // 将数组数据依次发送至总线并存入Flash页缓存
    }
    MySPI_Stop();                               // 产生停止信号,触发芯片将页缓存数据烧录至Flash阵列
    
    W25Q64_WaitBusy();                          // 【流程优化】后置执行忙等待,阻塞至烧录完成
}

/**
  * 函    数:W25Q64读取数据
  * 参    数:Address 读取的起始24位地址,范围:0x000000~0x7FFFFF
  * 参    数:DataArray 用于接收读取数据的数组指针
  * 参    数:Count 读取的连续字节数,支持全片跨页读取
  */
void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count)
{
    uint32_t i;
    
    MySPI_Start();                              // 产生起始信号
    MySPI_SwapByte(W25Q64_READ_DATA);           // 发送读取数据指令 (0x03)
    MySPI_SwapByte(Address >> 16);              // 发送地址高8位
    MySPI_SwapByte(Address >> 8);               // 发送地址中8位
    MySPI_SwapByte(Address);                    // 发送地址低8位
    
    for (i = 0; i < Count; i++)                 // 循环接收连续数据
    {
        // 发送占位字节 (0xFF) 驱动时钟,将从机数据读取至本地数组中
        DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);
    }
    MySPI_Stop();                               // 产生停止信号,结束读取操作
}

5. 本章节实验

5.1 软件 SPI 读写 W25Q64

5.1.1 实验目标

  • 理解软件 SPI 的时序模拟: 掌握如何通过 GPIO 翻转电平(Bit-Banging)模拟 SPI 的 SCK、MOSI 和 SS 信号,并利用输入引脚读取 MISO 状态。

  • 掌握字节交换(Swap Byte)原理: 深入理解 SPI 全双工通信的核心逻辑,即在同一时钟脉冲下,主机发送 8 位数据并同时接收 8 位数据的移位过程。

  • 熟悉 W25Q64 硬件特性: 掌握 Flash 存储器的扇区擦除(Sector Erase)、页编程(Page Program)以及非易失性存储的物理限制(如写前必须擦除、擦除后数据全为 1 等)。

  • 构建层次化驱动架构: 实现从底层 GPIO 操作到中间层 SPI 时序、再到应用层 W25Q64 指令封装的完整驱动栈。

5.1.2 硬件设计

5.1.3 软件设计

本实验采用软件模拟 SPI 时序的方案,通过手动控制 GPIO 电平实现对 W25Q64 的读写访问,具体流程如下:

(1)底层 GPIO 模拟模块(基于 MySPI.c)

  • 引脚映射与初始化: 配置 PA4 (SS)、PA5 (SCK)、PA7 (MOSI) 为推挽输出,配置 PA6 (MISO) 为上拉输入。

  • 时序实现: 封装 MySPI_W_SS、MySPI_W_SCK 等基础操作函数,用于精准控制物理引脚电平。

  • 核心交换函数: 编写 MySPI_SwapByte 函数。在循环中通过手动拉高/拉低 SCK 模拟 SPI 模式 0 时序,在上升沿通过移位掩码输出 MOSI 电平,在下降沿读取 MISO 电平,完成 8 位数据的同步置换。

(2)W25Q64 协议驱动模块(基于 W25Q64.c 与 W25Q64_Ins.h)

  • 指令集定义: 在头文件中定义 W25Q64 的标准指令码(如写使能 0x06、扇区擦除 0x20、页编程 0x02 等)。

  • 状态控制封装: 实现 W25Q64_WaitBusy 逻辑,通过循环读取状态寄存器 1,监测 BUSY 位的状态,确保在前一次写操作(如擦除或编程)完成后再发起新指令。

  • 功能接口封装: 将底层 SPI 交换函数组合,封装出 W25Q64_SectorErase(扇区擦除)、W25Q64_PageProgram(页编程)和 W25Q64_ReadData(读取数据)等高级应用接口。

(3)应用层读写逻辑(基于 main.c)

  • 设备身份校验: 上电后首先调用 W25Q64_ReadID 获取厂商 ID (MID) 与设备 ID (DID),验证 SPI 通信链路是否畅通。

  • 非易失性测试: 定义写入和读取测试缓冲区,先对 0x000000 目标扇区执行擦除操作,随后将特定数据序列写入该地址。

  • 数据回读验证: 读取刚才写入的地址空间,将结果显示在 OLED 屏幕上,通过对比写入值与回读值验证驱动的准确性。

具体代码如下:

main.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "W25Q64.h"

uint8_t MID;							//定义用于存放MID号的变量
uint16_t DID;							//定义用于存放DID号的变量

uint8_t ArrayWrite[] = {0x01, 0x02, 0x03, 0x04};	//定义要写入数据的测试数组
uint8_t ArrayRead[4];								//定义要读取数据的测试数组

int main(void)
{
	/*模块初始化*/
	OLED_Init();						//OLED初始化
	W25Q64_Init();						//W25Q64初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "MID:   DID:");
	OLED_ShowString(2, 1, "W:");
	OLED_ShowString(3, 1, "R:");
	
	/*显示ID号*/
	W25Q64_ReadID(&MID, &DID);			//获取W25Q64的ID号
	OLED_ShowHexNum(1, 5, MID, 2);		//显示MID
	OLED_ShowHexNum(1, 12, DID, 4);		//显示DID
	
	/*W25Q64功能函数测试*/
	W25Q64_SectorErase(0x000000);					//扇区擦除
	W25Q64_PageProgram(0x000000, ArrayWrite, 4);	//将写入数据的测试数组写入到W25Q64中
	
	W25Q64_ReadData(0x000000, ArrayRead, 4);		//读取刚写入的测试数据到读取数据的测试数组中
	
	/*显示数据*/
	OLED_ShowHexNum(2, 3, ArrayWrite[0], 2);		//显示写入数据的测试数组
	OLED_ShowHexNum(2, 6, ArrayWrite[1], 2);
	OLED_ShowHexNum(2, 9, ArrayWrite[2], 2);
	OLED_ShowHexNum(2, 12, ArrayWrite[3], 2);
	
	OLED_ShowHexNum(3, 3, ArrayRead[0], 2);			//显示读取数据的测试数组
	OLED_ShowHexNum(3, 6, ArrayRead[1], 2);
	OLED_ShowHexNum(3, 9, ArrayRead[2], 2);
	OLED_ShowHexNum(3, 12, ArrayRead[3], 2);
	
	while (1)
	{
		
	}
}

MySPI.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header

/*引脚配置层*/

/**
  * 函    数:SPI写SS引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入SS的电平,范围0~1
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SS为低电平,当BitValue为1时,需要置SS为高电平
  */
void MySPI_W_SS(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);		//根据BitValue,设置SS引脚的电平
}

/**
  * 函    数:SPI写SCK引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入SCK的电平,范围0~1
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置SCK为低电平,当BitValue为1时,需要置SCK为高电平
  */
void MySPI_W_SCK(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);		//根据BitValue,设置SCK引脚的电平
}

/**
  * 函    数:SPI写MOSI引脚电平
  * 参    数:BitValue 协议层传入的当前需要写入MOSI的电平,范围0~1
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,当BitValue为0时,需要置MOSI为低电平,当BitValue为1时,需要置MOSI为高电平
  */
void MySPI_W_MOSI(uint8_t BitValue)
{
	GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);		//根据BitValue,设置MOSI引脚的电平,BitValue要实现非0即1的特性
}

/**
  * 函    数:SPI读MISO引脚电平
  * 参    数:无
  * 返 回 值:协议层需要得到的当前MISO的电平,范围0~1
  * 注意事项:此函数需要用户实现内容,当前MISO为低电平时,返回0,当前MISO为高电平时,返回1
  */
uint8_t MySPI_R_MISO(void)
{
	return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);			//读取MISO电平并返回
}

/**
  * 函    数:SPI初始化
  * 参    数:无
  * 返 回 值:无
  * 注意事项:此函数需要用户实现内容,实现SS、SCK、MOSI和MISO引脚的初始化
  */
void MySPI_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);	//开启GPIOA的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA4、PA5和PA7引脚初始化为推挽输出
	
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOA, &GPIO_InitStructure);					//将PA6引脚初始化为上拉输入
	
	/*设置默认电平*/
	MySPI_W_SS(1);											//SS默认高电平
	MySPI_W_SCK(0);											//SCK默认低电平
}

/*协议层*/

/**
  * 函    数:SPI起始
  * 参    数:无
  * 返 回 值:无
  */
void MySPI_Start(void)
{
	MySPI_W_SS(0);				//拉低SS,开始时序
}

/**
  * 函    数:SPI终止
  * 参    数:无
  * 返 回 值:无
  */
void MySPI_Stop(void)
{
	MySPI_W_SS(1);				//拉高SS,终止时序
}

/**
  * 函    数:SPI交换传输一个字节,使用SPI模式0
  * 参    数:ByteSend 要发送的一个字节
  * 返 回 值:接收的一个字节
  */
uint8_t MySPI_SwapByte(uint8_t ByteSend)
{
	uint8_t i, ByteReceive = 0x00;					//定义接收的数据,并赋初值0x00,此处必须赋初值0x00,后面会用到
	
	for (i = 0; i < 8; i ++)						//循环8次,依次交换每一位数据
	{
		/*两个!可以对数据进行两次逻辑取反,作用是把非0值统一转换为1,即:!!(0) = 0,!!(非0) = 1*/
		MySPI_W_MOSI(!!(ByteSend & (0x80 >> i)));	//使用掩码的方式取出ByteSend的指定一位数据并写入到MOSI线
		MySPI_W_SCK(1);								//拉高SCK,上升沿移出数据
		if (MySPI_R_MISO()){ByteReceive |= (0x80 >> i);}	//读取MISO数据,并存储到Byte变量
															//当MISO为1时,置变量指定位为1,当MISO为0时,不做处理,指定位为默认的初值0
		MySPI_W_SCK(0);								//拉低SCK,下降沿移入数据
	}
	
	return ByteReceive;								//返回接收到的一个字节数据
}

W25Q64.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include "MySPI.h"
#include "W25Q64_Ins.h"

/**
  * 函    数:W25Q64初始化
  * 参    数:无
  * 返 回 值:无
  */
void W25Q64_Init(void)
{
	MySPI_Init();					//先初始化底层的SPI
}

/**
  * 函    数:W25Q64读取ID号
  * 参    数:MID 工厂ID,使用输出参数的形式返回
  * 参    数:DID 设备ID,使用输出参数的形式返回
  * 返 回 值:无
  */
void W25Q64_ReadID(uint8_t *MID, uint16_t *DID)
{
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_JEDEC_ID);			//交换发送读取ID的指令
	*MID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//交换接收MID,通过输出参数返回
	*DID = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//交换接收DID高8位
	*DID <<= 8;									//高8位移到高位
	*DID |= MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//或上交换接收DID的低8位,通过输出参数返回
	MySPI_Stop();								//SPI终止
}

/**
  * 函    数:W25Q64写使能
  * 参    数:无
  * 返 回 值:无
  */
void W25Q64_WriteEnable(void)
{
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_WRITE_ENABLE);		//交换发送写使能的指令
	MySPI_Stop();								//SPI终止
}

/**
  * 函    数:W25Q64等待忙
  * 参    数:无
  * 返 回 值:无
  */
void W25Q64_WaitBusy(void)
{
	uint32_t Timeout;
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_READ_STATUS_REGISTER_1);				//交换发送读状态寄存器1的指令
	Timeout = 100000;							//给定超时计数时间
	while ((MySPI_SwapByte(W25Q64_DUMMY_BYTE) & 0x01) == 0x01)	//循环等待忙标志位
	{
		Timeout --;								//等待时,计数值自减
		if (Timeout == 0)						//自减到0后,等待超时
		{
			/*超时的错误处理代码,可以添加到此处*/
			break;								//跳出等待,不等了
		}
	}
	MySPI_Stop();								//SPI终止
}

/**
  * 函    数:W25Q64页编程
  * 参    数:Address 页编程的起始地址,范围:0x000000~0x7FFFFF
  * 参    数:DataArray	用于写入数据的数组
  * 参    数:Count 要写入数据的数量,范围:0~256
  * 返 回 值:无
  * 注意事项:写入的地址范围不能跨页
  */
void W25Q64_PageProgram(uint32_t Address, uint8_t *DataArray, uint16_t Count)
{
	uint16_t i;
	
	W25Q64_WriteEnable();						//写使能
	
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_PAGE_PROGRAM);		//交换发送页编程的指令
	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
	MySPI_SwapByte(Address);					//交换发送地址7~0位
	for (i = 0; i < Count; i ++)				//循环Count次
	{
		MySPI_SwapByte(DataArray[i]);			//依次在起始地址后写入数据
	}
	MySPI_Stop();								//SPI终止
	
	W25Q64_WaitBusy();							//等待忙
}

/**
  * 函    数:W25Q64扇区擦除(4KB)
  * 参    数:Address 指定扇区的地址,范围:0x000000~0x7FFFFF
  * 返 回 值:无
  */
void W25Q64_SectorErase(uint32_t Address)
{
	W25Q64_WriteEnable();						//写使能
	
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_SECTOR_ERASE_4KB);	//交换发送扇区擦除的指令
	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
	MySPI_SwapByte(Address);					//交换发送地址7~0位
	MySPI_Stop();								//SPI终止
	
	W25Q64_WaitBusy();							//等待忙
}

/**
  * 函    数:W25Q64读取数据
  * 参    数:Address 读取数据的起始地址,范围:0x000000~0x7FFFFF
  * 参    数:DataArray 用于接收读取数据的数组,通过输出参数返回
  * 参    数:Count 要读取数据的数量,范围:0~0x800000
  * 返 回 值:无
  */
void W25Q64_ReadData(uint32_t Address, uint8_t *DataArray, uint32_t Count)
{
	uint32_t i;
	MySPI_Start();								//SPI起始
	MySPI_SwapByte(W25Q64_READ_DATA);			//交换发送读取数据的指令
	MySPI_SwapByte(Address >> 16);				//交换发送地址23~16位
	MySPI_SwapByte(Address >> 8);				//交换发送地址15~8位
	MySPI_SwapByte(Address);					//交换发送地址7~0位
	for (i = 0; i < Count; i ++)				//循环Count次
	{
		DataArray[i] = MySPI_SwapByte(W25Q64_DUMMY_BYTE);	//依次在起始地址后读取数据
	}
	MySPI_Stop();								//SPI终止
}

W25Q64_Ins.c文件:

cpp 复制代码
#ifndef __W25Q64_INS_H
#define __W25Q64_INS_H

#define W25Q64_WRITE_ENABLE							0x06
#define W25Q64_WRITE_DISABLE						0x04
#define W25Q64_READ_STATUS_REGISTER_1				0x05
#define W25Q64_READ_STATUS_REGISTER_2				0x35
#define W25Q64_WRITE_STATUS_REGISTER				0x01
#define W25Q64_PAGE_PROGRAM							0x02
#define W25Q64_QUAD_PAGE_PROGRAM					0x32
#define W25Q64_BLOCK_ERASE_64KB						0xD8
#define W25Q64_BLOCK_ERASE_32KB						0x52
#define W25Q64_SECTOR_ERASE_4KB						0x20
#define W25Q64_CHIP_ERASE							0xC7
#define W25Q64_ERASE_SUSPEND						0x75
#define W25Q64_ERASE_RESUME							0x7A
#define W25Q64_POWER_DOWN							0xB9
#define W25Q64_HIGH_PERFORMANCE_MODE				0xA3
#define W25Q64_CONTINUOUS_READ_MODE_RESET			0xFF
#define W25Q64_RELEASE_POWER_DOWN_HPM_DEVICE_ID		0xAB
#define W25Q64_MANUFACTURER_DEVICE_ID				0x90
#define W25Q64_READ_UNIQUE_ID						0x4B
#define W25Q64_JEDEC_ID								0x9F
#define W25Q64_READ_DATA							0x03
#define W25Q64_FAST_READ							0x0B
#define W25Q64_FAST_READ_DUAL_OUTPUT				0x3B
#define W25Q64_FAST_READ_DUAL_IO					0xBB
#define W25Q64_FAST_READ_QUAD_OUTPUT				0x6B
#define W25Q64_FAST_READ_QUAD_IO					0xEB
#define W25Q64_OCTAL_WORD_READ_QUAD_IO				0xE3

#define W25Q64_DUMMY_BYTE							0xFF

#endif

5.1.4 实验现象

上电后,OLED 屏幕第一行会显示读取到的 MID(0xEF)和 DID(0x4017)。后续显示如下:

  • 写入过程: 屏幕第二行显示预设的写入数组内容(即 0x01, 0x02, 0x03, 0x04)。

  • 读取结果: 屏幕第三行显示从 W25Q64 对应地址回读的数据。若驱动正常,读出值应与写入值完全一致。

  • 掉电保持验证: 手动按下复位键或重新上电,如果在代码中注释掉擦除和写入操作直接读取,屏幕仍能正确显示之前存入的数据,体现 Flash 的非易失特性。

相关推荐
披着羊皮不是狼2 小时前
Git完整学习总结
git·学习·elasticsearch
我是发哥哈2 小时前
主流AI培训机构能力横向评测:核心维度与选型要点解析
大数据·人工智能·学习·机器学习·ai·chatgpt·aigc
LCG元2 小时前
STM32实战:基于STM32F103的智能饮水机温度控制
stm32·单片机·嵌入式硬件
molong9312 小时前
SIM 卡监听(电话监听)
android·学习·kotlin
小宋加油啊2 小时前
claude学习
学习
ouliten2 小时前
cuda编程笔记(40)--Pipelines(流水线)
笔记·cuda
DeepModel2 小时前
机器学习数据预处理:特征构造
人工智能·学习·算法·机器学习
EVERSPIN2 小时前
MCU单片机FOC汽车水泵方案
单片机·嵌入式硬件·mcu·汽车·mcu单片机
qq_348231852 小时前
企业级避坑指南
人工智能·学习