本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:STM32F103系列基于ARM Cortex-M3内核,广泛用于嵌入式系统。本项目聚焦于在该平台上实现SPI通信协议,完成对W25Q系列SPI Flash存储器的读写操作。内容涵盖SPI协议原理、GPIO与SPI外设配置、时钟设置及数据传输流程,并结合HAL库进行底层驱动开发。此外,项目探索通过Lua脚本(如probablylua)封装SPI操作,提升应用层开发效率。适合掌握嵌入式C编程、外设控制及嵌入式系统通信协议的实践学习。
STM32F103-SPI程序_spi_f103spi程序_STM32F103_probablylua_stm32f103spi

1. STM32F103与SPI通信概述

SPI(Serial Peripheral Interface)是一种高速、全双工、同步串行通信接口,广泛应用于嵌入式系统中,尤其在STM32系列微控制器中具有重要的地位。STM32F103作为一款基于ARM Cortex-M3内核的经典MCU,其内置的SPI外设支持主从模式、多种时钟极性与相位配置,能够灵活适配各类外设通信需求。本章将从整体架构出发,介绍SPI通信的基本原理及其在STM32F103中的硬件实现机制。

重点阐述SPI在片上外设中的定位、典型应用场景(如Flash存储器、传感器、显示屏驱动等),以及为何选择SPI而非I2C或UART进行高速数据传输。SPI具备高达18MHz以上的通信速率(在STM32F103上可达PCLK/2),无起始/停止位开销,适合批量数据连续传输,显著优于I2C的速率限制和UART的异步特性。

同时,结合W25Q系列Flash和Lua脚本运行环境的需求,说明SPI在实际项目开发中的核心作用:通过SPI接口读取存储于外部Flash中的Lua字节码,实现动态脚本加载与远程配置更新,为构建可扩展的智能控制终端提供硬件基础。通过本章内容,读者将建立起对STM32F103上SPI通信的整体认知框架,为后续深入理解协议细节与编程实践打下坚实基础。

2. SPI协议信号线(SCK、MISO、MOSI、NSS)详解

SPI(Serial Peripheral Interface)作为一种经典的同步串行通信接口,其核心优势在于结构简洁、传输速率高以及支持全双工通信。在STM32F103这类基于ARM Cortex-M3内核的微控制器中,SPI外设通过四条基本信号线实现与外部从设备的数据交互: SCK(时钟) MOSI(主出从入) MISO(主入从出) NSS(片选) 。这四根信号构成了SPI通信的基础物理层,理解它们的功能特性、电气行为以及时序关系,是实现稳定可靠通信的前提。

本章将深入剖析SPI四线制通信的每一根信号线,结合STM32F103硬件平台和W25Q64 Flash存储器的实际应用,系统性地讲解各信号的作用机制、连接方式、电气设计要点以及时序匹配原则。同时,针对多设备拓扑结构中的NSS管理策略、长距离布线对高频信号的影响等问题进行工程级分析,并通过示波器实测波形的方法提供调试指导。

2.1 SPI四线制通信结构分析

SPI采用主从架构,通常由一个主设备(Master)控制多个从设备(Slave)。通信过程中,所有数据交换均受主设备产生的时钟信号驱动,且每根信号线具有明确的方向性和功能定义。标准SPI使用四条信号线完成全双工通信:

  • SCK(Serial Clock) :由主设备生成的同步时钟信号,用于协调数据在MOSI和MISO线上的采样与输出;
  • MOSI(Master Out Slave In) :主设备向从设备发送数据的数据线;
  • MISO(Master In Slave Out) :从设备向主设备返回数据的数据线;
  • NSS(Slave Select,又称CS或SS) :片选信号,用于激活特定从设备,确保总线上只有一个从机响应通信。

这种结构允许主设备在同一时刻既发送又接收数据,形成真正的全双工通信能力。

2.1.1 SCK时钟信号的作用与时序关系

SCK是SPI通信的核心驱动力量。它决定了整个通信过程的时间基准,所有数据位的采样和输出都依赖于SCK的上升沿或下降沿。在STM32F103中,当SPI配置为主模式时,SCK由内部波特率发生器根据APB总线时钟分频后输出;若为从模式,则SCK由外部主设备提供。

SCK的关键参数包括:
- 频率 :决定最大传输速率,典型值可达几MHz至数十MHz(取决于MCU和外设能力);
- 极性(CPOL) :空闲状态下的电平(0表示低电平,1表示高电平);
- 相位(CPHA) :数据采样的边沿(0表示第一个边沿采样,1表示第二个边沿采样)。

这两者共同构成四种工作模式(Mode 0~3),直接影响数据的有效窗口。例如,在W25Q系列Flash中,推荐使用 CPOL=0, CPHA=0 模式,即SCK空闲为低电平,数据在第一个上升沿采样。

下面是一个典型的Mode 0下SCK与MOSI/MISO的时序图(使用Mermaid绘制):

timing
    title SPI Mode 0 时序示意图 (CPOL=0, CPHA=0)
    axis: 0 1 2 3 4 5 6 7 8 9 10

    SCK : ||--|  |--|  |--|  |--|  |--|  |--|  |--|  |--|  |--|
          :     ↑     ↓     ↑     ↓     ↑     ↓     ↑     ↓
    MOSI: |---D0---|---D1---|---D2---|---D3---|---D4---|...
          :   ↗       ↗       ↗       ↗       ↗
    MISO: |--------R0-------|--------R1-------|--------...

说明 :在每个SCK周期内,主设备通过MOSI发送一位数据(如D0~D7),从设备在同一周期通过MISO返回响应数据(R0~Rn)。由于CPHA=0,数据在SCK上升沿被采样,因此数据必须在上升沿前稳定建立。

在STM32F103中,SCK的频率由 SPI_CR1 寄存器中的 BR[2:0] 字段控制,可对PCLK进行2~256分频。例如:

// 设置SPI1 SCK = PCLK2 / 64
SPI1->CR1 &= ~SPI_CR1_BR;        // 清除原有分频设置
SPI1->CR1 |= SPI_CR1_BR_2 | SPI_CR1_BR_1;  // BR[2:0] = 110 → 分频系数64

参数说明
- SPI_CR1_BR 是波特率控制位,占3位(BR[2:0]);
- 分频系数范围为2、4、8、16、32、64、128、256;
- 若PCLK2 = 72MHz,则SCK = 72MHz / 64 ≈ 1.125MHz,适合大多数Flash操作。

该配置需结合外设手册要求调整,避免因时钟过快导致采样失败。

2.1.2 MOSI与MISO的数据流向及双工特性

MOSI和MISO分别承担主设备到从设备、从设备到主设备的数据传输任务。两者独立运作,支持 全双工并行通信 ——即在同一SCK周期内,主设备发送一字节的同时也能接收到从设备返回的一字节。

这一特性使得SPI非常适合需要高吞吐量的应用场景,如图像数据传输、音频流处理或快速读写Flash。相比之下,I2C仅支持半双工,UART更是单工或半双工,无法实现真正意义上的并发收发。

以STM32F103与W25Q64通信为例,执行“读取状态寄存器”命令(0x05)的过程如下:

uint8_t tx_buf[2] = {0x05, 0x00};  // 发送读状态寄存器指令 + 哑元字节
uint8_t rx_buf[2];

for (int i = 0; i < 2; i++) {
    SPI1->DR = tx_buf[i];                    // 写入待发送数据
    while (!(SPI1->SR & SPI_SR_TXE));        // 等待发送缓冲区空
    while (SPI1->SR & SPI_SR_BSY);           // 等待传输完成
    rx_buf[i] = SPI1->DR;                    // 读取接收到的数据
}

逐行逻辑分析
1. tx_buf[2] = {0x05, 0x00} :第一条字节是命令码,第二条是哑元(dummy byte),用于产生额外的SCK脉冲以便从设备返回数据;
2. SPI1->DR = tx_buf[i] :向数据寄存器写入触发SCK输出;
3. while (!(SPI1->SR & SPI_SR_TXE)) :等待发送缓冲区为空,防止溢出;
4. while (SPI1->SR & SPI_SR_BSY) :检测BSY标志,确保当前传输已完成;
5. rx_buf[i] = SPI1->DR :读取DR寄存器获取MISO线上返回的数据。

此过程体现了SPI全双工的本质:虽然我们只关心第二字节的返回值(即状态寄存器内容),但必须发送两个字节才能获得一次完整的响应。这也说明了为什么SPI通信常采用“发送即接收”的编程模型。

2.1.3 NSS片选信号的主动与被动控制方式

NSS(Chip Select)是SPI总线中用于选择目标从设备的控制信号。只有当某个从设备的NSS引脚被拉低时,它才会响应SCK和MOSI上的数据;否则保持高阻态,不干扰总线。

NSS有两种控制方式:
- 硬件NSS(Hardware NSS) :由SPI外设自动控制,适用于单从机系统;
- 软件NSS(Software NSS) :通过GPIO手动控制,灵活性更高,适用于多从机或多协议共存场景。

在STM32F103中,若启用硬件NSS,可通过 SPI_CR1 寄存器的 SSM (Software Slave Management)和 SSI 位进行配置。但对于W25Q64等独立Flash设备,通常采用 软件控制NSS ,即将PB12配置为通用推挽输出口:

// 配置PB12为NSS输出
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;         // 使能GPIOB时钟
GPIOB->CRH &= ~GPIO_CRH_CNF12;              // 清除CNF12[1:0]
GPIOB->CRH |= GPIO_CRH_MODE12_0;            // 输出模式,最大速度10MHz
GPIOB->ODR |= GPIO_ODR_ODR12;               // 初始置高(非选中)

// 片选操作宏
#define CS_LOW()   (GPIOB->BRR  = GPIO_BRR_BR12)   // 拉低NSS
#define CS_HIGH()  (GPIOB->BSRR = GPIO_BSRR_BS12)  // 拉高NSS

参数说明
- BRR (Bit Reset Register)用于清零指定引脚;
- BSRR (Bit Set/Reset Register)可用于置位或复位,此处用 BS12 置高;
- 使用原子操作(BRR/BSRR)提高效率,避免读-改-写风险。

软件NSS的优势在于可以精确控制片选时机,尤其在复杂指令序列中(如写使能→地址发送→数据写入),能有效避免误触发。

2.2 信号线电气特性与物理连接规范

SPI虽结构简单,但在实际布线和电气设计中仍需注意信号完整性问题,尤其是在高频、长距离或多负载情况下。

2.2.1 推挽输出与上拉电阻设计考量

在STM32F103中,SPI相关的GPIO(PA5-SCK、PA6-MISO、PA7-MOSI)应配置为 复用推挽输出模式 ,以保证足够的驱动能力和稳定的高低电平切换速度。

// 配置SPI1引脚为AFIO推挽输出
GPIOA->CRL &= ~(GPIO_CRL_MODE5 | GPIO_CRL_CNF5 |
                GPIO_CRL_MODE6 | GPIO_CRL_CNF6 |
                GPIO_CRL_MODE7 | GPIO_CRL_CNF7);

GPIOA->CRL |= (GPIO_CRL_MODE5_1 | GPIO_CRL_CNF5_1) |  // PA5(SCK): AF PP, 10MHz
              (GPIO_CRL_MODE6_1 | GPIO_CRL_CNF6_0) |  // PA6(MISO): Input floating
              (GPIO_CRL_MODE7_1 | GPIO_CRL_CNF7_1);   // PA7(MOSI): AF PP, 10MHz

参数说明
- MODE[1:0] 控制输出速度(00:输入,01:10MHz,11:50MHz);
- CNF[1:0] 在输出模式下设置为 10 表示 复用功能推挽输出
- MISO作为输入,建议设为浮空输入( CNF=01 )或上拉输入(视从设备输出类型而定)。

对于 开漏输出型从设备 (较少见),可能需要添加外部上拉电阻(4.7kΩ~10kΩ)以确保高电平建立。但对于大多数CMOS电平器件(如W25Q64),推挽输出即可满足需求,无需额外上拉。

引脚 功能 推荐配置 是否需要上拉
SCK 输出 复用推挽,高速
MOSI 输出 复用推挽,高速
MISO 输入 浮空/上拉输入 视情况而定
NSS 输出 推挽输出 否(除非总线共享)

⚠️ 注意:避免在SCK线上加装大容量滤波电容或强上拉,可能导致边沿变缓,影响高速通信稳定性。

2.2.2 多从机拓扑结构:独立NSS与菊花链比较

当系统中存在多个SPI从设备时,常见的两种连接方式为:

  1. 独立NSS方式(Separate Chip Select)
  2. 菊花链方式(Daisy Chain)
独立NSS方式

每个从设备拥有独立的NSS引脚,由主控单独控制。优点是地址清晰、易于管理、支持不同通信参数(如时钟频率、极性等)。

主设备
  │
  ├── SCK ──┬── SCK ── W25Q64 (Flash)
  │         └── SCK ── OLED Display
  ├── MOSI ─┬── MOSI ── W25Q64
  │         └── MOSI ── OLED
  ├── MISO ─┬── MISO ── W25Q64
  │         └── MISO ── OLED
  ├── NSS1 ─┘             (PB12)
  └── NSS2 ────────────── (PB13)

优点
- 各设备完全隔离,互不影响;
- 支持异步访问;
- 易于调试。

缺点
- 占用较多GPIO资源;
- 不适合大规模扩展。

菊花链方式

所有从设备串联,前一个的MISO连接后一个的MOSI,最终只有一个MISO回到主设备。常用于移位寄存器级联或FPGA配置。

主设备
  │
  ├── SCK ──┬── SCK ── Dev1 ── SCK ── Dev2 ── SCK ── ...
  ├── MOSI ── MOSI ── Dev1 ── MOSI ── Dev2 ── ...
  └── MISO ←─ MISO ←─ Dev1 ←─ MISO ←─ Dev2 ←─ ...

优点
- 节省MOSI/NSS引脚;
- 适合固定长度数据广播。

缺点
- 所有设备必须同一种时序模式;
- 数据延迟随链长增加;
- 故障排查困难。

在嵌入式项目中, 独立NSS更常用 ,特别是涉及Flash、传感器、显示屏等不同类型外设时。

2.2.3 布线长度与时钟频率的关系影响

SPI是一种高速同步接口,其最大可靠通信距离受限于 信号完整性 ,主要受以下因素影响:

  • 走线长度 :越长,分布电容和电感越大,导致信号边沿畸变;
  • 时钟频率 :越高,对上升/下降时间要求越严格;
  • 负载数量 :挂载设备越多,总线电容越大,驱动能力要求越高。

一般经验法则:
- 当SCK > 10MHz 或走线 > 10cm 时,应考虑加入 串联阻尼电阻 (22Ω~100Ω)抑制反射;
- PCB布线尽量短直,避免锐角;
- SCK与其他信号线保持适当间距,减少串扰;
- 若使用排线,建议采用屏蔽线或差分对替代。

下表列出不同频率下的推荐最大布线长度:

SCK频率 最大推荐布线长度(FR4 PCB) 建议措施
≤ 1MHz < 50cm 可忽略
1~5MHz < 20cm 使用短线,避免分支
5~10MHz < 10cm 加串联电阻(33Ω)
>10MHz < 5cm 差分布线或降低频率

📌 实践建议:在调试阶段使用示波器观察SCK波形是否出现振铃、过冲或斜率不足,及时优化布局。

2.3 CPOL与CPHA时钟模式组合解析

SPI定义了四种标准时钟模式,由 CPOL(Clock Polarity) CPHA(Clock Phase) 共同决定。主从设备必须配置为相同模式才能正常通信。

2.3.1 四种工作模式(Mode 0~3)的波形特征

模式 CPOL CPHA 空闲电平 采样边沿
0 0 0 上升沿
1 0 1 下降沿
2 1 0 下降沿
3 1 1 上升沿

可通过Mermaid绘制对比图:

graph LR
    subgraph Mode 0 [Mode 0: CPOL=0, CPHA=0]
        direction TB
        SCK_idle["SCK空闲: Low"]
        Sample_edge["采样边沿: Rising"]
    end

    subgraph Mode 1 [Mode 1: CPOL=0, CPHA=1]
        direction TB
        SCK_idle1["SCK空闲: Low"]
        Sample_edge1["采样边沿: Falling"]
    end

    subgraph Mode 2 [Mode 2: CPOL=1, CPHA=0]
        direction TB
        SCK_idle2["SCK空闲: High"]
        Sample_edge2["采样边沿: Falling"]
    end

    subgraph Mode 3 [Mode 3: CPOL=1, CPHA=1]
        direction TB
        SCK_idle3["SCK空闲: High"]
        Sample_edge3["采样边沿: Rising"]
    end

关键点 :无论哪种模式,主设备在SCK的第一个有效边沿之前必须将数据放置在MOSI上,从设备也应在MISO上准备好响应数据。

在STM32F103中,通过 SPI_CR1 寄存器设置:

// 设置为Mode 0: CPOL=0, CPHA=0
SPI1->CR1 &= ~(SPI_CR1_CPOL | SPI_CR1_CPHA);

2.3.2 W25Qxx Flash推荐的CPOL=0, CPHA=0模式验证

查阅W25Q64数据手册可知,其SPI接口支持Mode 0和Mode 3,但出厂默认为Mode 0。因此,在初始化SPI时应优先配置为Mode 0。

验证方法如下:

  1. 发送读ID命令(0x9F);
  2. 接收3字节制造商ID和设备ID;
  3. 若返回值为 0xEF, 0x40, 0x17 ,则表明通信成功且时序正确。
uint8_t id[4];
CS_LOW();
SPI1->DR = 0x9F;
while (!(SPI1->SR & SPI_SR_TXE));
while (SPI1->SR & SPI_SR_BSY);
SPI1->DR; // Dummy read to flush TX

for (int i = 0; i < 3; i++) {
    SPI1->DR = 0x00;
    while (!(SPI1->SR & SPI_SR_TXE));
    while (SPI1->SR & SPI_SR_BSY);
    id[i] = SPI1->DR;
}
CS_HIGH();

若返回错误ID,首先检查CPOL/CPHA设置是否匹配。

2.3.3 主从设备间时序匹配的关键点

确保主从设备时序一致的关键在于:
- 确认从设备支持的SPI模式 (查手册);
- 主设备配置相应CPOL/CPHA
- SCK频率不超过从设备最大限制 (W25Q64支持最高104MHz,但供电<3.6V时建议≤50MHz);
- NSS建立与保持时间满足要求 (一般>50ns)。

2.4 实际电路连接实例与常见错误排查

2.4.1 STM32F103与W25Q64引脚对应关系图示

STM32F103 功能 W25Q64
PA5 SCK SCK
PA6 MISO DO(IO1)
PA7 MOSI DI(IO0)
PB12 NSS CS
3.3V VCC VCC
GND GND GND
NC - WP, HOLD(可拉高)

🔧 注意:W25Q64的DI/DO分别为MOSI/MISO,部分厂商标注为IO0/IO1。

2.4.2 示波器观测SPI波形的方法与问题诊断

使用示波器探头分别测量SCK、MOSI、MISO和NSS信号,观察:

  • NSS是否按时拉低/释放
  • SCK是否有完整脉冲且无失真
  • MOSI是否正确输出命令码
  • MISO是否在预期位置返回有效数据

常见问题及解决方案:

问题现象 可能原因 解决方案
返回ID为0xFF或0x00 接触不良或未供电 检查电源、焊接
SCK无波形 SPI未使能或GPIO配置错误 检查SPE位和AFIO设置
数据错乱 CPOL/CPHA不匹配 改为Mode 0再试
写入失败 未发送写使能命令 添加WREN(0x06)

✅ 推荐工具:Saleae Logic Analyzer 或 DSLogic 配合SPI解码插件,可直观查看协议层数据帧。

综上所述,深入理解SPI四根信号线的功能与交互机制,是构建稳定嵌入式通信系统的基础。后续章节将进一步展开GPIO配置与SPI初始化的具体实现。

3. GPIO引脚配置与SPI外设初始化

在嵌入式系统开发中,STM32F103作为一款广泛应用的Cortex-M3架构微控制器,其SPI(Serial Peripheral Interface)外设为高速、可靠的数据通信提供了强有力的硬件支持。然而,在使用SPI进行数据传输之前,必须完成两个关键步骤:一是对相关GPIO引脚进行正确复用功能配置;二是对SPI外设寄存器进行系统化初始化设置。这两个过程直接决定了后续通信是否能够正常启动和稳定运行。

本章将深入剖析从底层寄存器层面到实际编程逻辑中的完整初始化流程,涵盖时钟使能、引脚复用、主模式设定、波特率控制、极性与相位匹配以及状态监控机制等核心环节。尤其针对W25Qxx系列Flash存储器的应用场景,所有配置都将围绕确保与外部设备严格时序兼容这一目标展开。通过本节内容的学习,读者不仅能够掌握STM32F103上SPI模块的初始化方法,还将理解每一步操作背后的硬件原理与工程考量。

3.1 GPIO复用功能配置流程

在STM32F103中,SPI1通常使用PA5(SCK)、PA6(MISO)、PA7(MOSI)这三个引脚作为标准通信接口,而NSS片选信号可以根据设计需求选择由硬件自动管理或软件模拟控制。当采用软件控制NSS时,例如使用PB12作为通用输出引脚来手动拉低/拉高以选择从机,则该引脚需配置为普通推挽输出模式而非复用功能。因此,合理的GPIO配置是SPI通信成功的第一步。

3.1.1 PA5(SCK)、PA6(MISO)、PA7(MOSI)设置为AFIO推挽输出

为了启用SPI1的硬件外设功能,PA5、PA6、PA7必须被配置为“复用推挽输出”模式。其中:

  • PA5 (SCK) :串行时钟输出引脚,由主设备驱动,用于同步数据传输。
  • PA7 (MOSI) :主设备发送、从设备接收的数据线,同样需要配置为复用推挽输出。
  • PA6 (MISO) :从设备发送、主设备接收的数据线,由于数据流向是从外设到MCU,因此应配置为“浮空输入”或“上拉输入”,但在多数情况下推荐使用浮空输入模式配合内部上拉电阻。

下面是基于STM32F1标准外设库的手动寄存器配置代码示例:

// 启用GPIOA和AFIO时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN;

// 配置PA5(SCK)为复用推挽输出,最大速度50MHz
GPIOA->CRL &= ~(0xF << 20);  // 清除PA5原有配置(bit[23:20])
GPIOA->CRL |= (0xB << 20);   // MODE5[1:0]=11(最大50MHz),CNF5[1:0]=10(复用推挽)

// 配置PA7(MOSI)为复用推挽输出
GPIOA->CRL &= ~(0xF << 28);
GPIOA->CRL |= (0xB << 28);

// 配置PA6(MISO)为浮空输入
GPIOA->CRL &= ~(0xF << 24);
GPIOA->CRL |= (0x4 << 24);   // MODE6=00(输入模式),CNF6[1:0]=01(浮空输入)
逻辑分析与参数说明
寄存器 字段 说明
RCC->APB2ENR IOPAEN, AFIOEN 置1 开启GPIOA和AFIO时钟,否则无法访问对应端口寄存器
GPIOA->CRL MODE[1:0] 11(0b11) 输出模式下最大速度为50MHz
GPIOA->CRL CNF[1:0] for SCK/MOSI 10(0b10) 复用功能推挽输出
GPIOA->CRL CNF[1:0] for MISO 01(0b01) 浮空输入,适合SPI总线

⚠️ 注意:若未开启AFIO时钟,即使其他配置正确,也可能导致SPI无法正常工作,尤其是在涉及重映射的情况下。

3.1.2 PB12(NSS)作为软件控制IO口的输入/输出切换

虽然SPI协议定义了NSS(Slave Select)信号用于选择从设备,但STM32允许将其配置为软件管理模式,即不启用硬件NSS控制,而是通过一个普通GPIO(如PB12)手动控制片选电平。

// 启用GPIOB时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;

// 配置PB12为通用推挽输出,初始为高电平(非选中状态)
GPIOB->CRH &= ~(0xF << 16);      // 清除PB12配置位(bit[19:16])
GPIOB->CRH |= (0x3 << 16);       // MODE12=11(50MHz输出),CNF12=00(通用推挽)

// 初始化NSS为高(释放从设备)
GPIOB->BSRR = GPIO_BSRR_BS12;    // 设置PB12为高

在每次SPI通信前,程序需先拉低PB12以选中从设备,通信结束后再拉高以释放总线:

// 选中Flash芯片(拉低NSS)
GPIOB->BRR = GPIO_BRR_BR12;

// ...执行SPI发送/接收...

// 释放Flash芯片(拉高NSS)
GPIOB->BSRR = GPIO_BSRR_BS12;
表格:PB12 GPIO配置参数对照表
参数 取值 说明
引脚 PB12 用户自定义NSS引脚
模式 推挽输出 支持强驱动能力,确保电平稳定
初始状态 高电平 避免误触发从设备
控制方式 软件BSRR/BRR寄存器操作 提供原子级置位/清零,避免竞争条件

这种软件控制方式的优点在于灵活性高,特别是在多从机系统中可独立控制每个设备的片选信号,避免总线冲突。

3.1.3 RCC_APB2ENR寄存器使能IO端口时钟

STM32的所有外设都需要在相应总线上使能时钟后才能正常工作。SPI1位于APB2总线,其相关的GPIOA和GPIOB也属于APB2外设。因此,必须首先通过 RCC->APB2ENR 寄存器开启这些模块的时钟。

// 一次性使能所需外设时钟
RCC->APB2ENR |= 
    RCC_APB2ENR_IOPAEN |   // GPIOA时钟
    RCC_APB2ENR_IOPBEN |   // GPIOB时钟
    RCC_APB2ENR_SPI1EN |   // SPI1外设时钟
    RCC_APB2ENR_AFIOEN;    // AFIO时钟(用于复用功能)
时钟使能依赖关系图(Mermaid流程图)
graph TD
    A[RCC APB2 Clock Source] --> B{Enable in RCC_APB2ENR?}
    B -- No --> C[Peripheral Registers Inaccessible]
    B -- Yes --> D[GPIOA/B Accessible]
    D --> E[Configure SCK/MISO/MOSI Pins]
    D --> F[Configure NSS as GPIO]
    E --> G[SPI1 Registers Accessible]
    G --> H[Initialize SPI_CR1, CR2...]

💡 提示:如果忘记使能SPI1时钟( SPI1EN 位),即使SPI寄存器写入成功,硬件也不会响应任何动作,这是初学者常见的“静默失败”问题之一。

3.2 SPI外设主模式初始化步骤

完成GPIO配置之后,接下来是对SPI1外设本身进行初始化。这包括设置为主机模式、帧格式、波特率、数据顺序等关键参数。

3.2.1 设置SPI_CR1寄存器的工作模式为主机(Master Mode)

SPI_CR1是SPI控制寄存器1,其中多个位域共同决定SPI的工作行为。要配置为主机模式,需设置 MSTR=1 SPE=0 (在配置期间关闭SPI)。

// 关闭SPI以便配置
SPI1->CR1 &= ~SPI_CR1_SPE;

// 清除MSTR和SPE之前的设置
SPI1->CR1 &= ~(SPI_CR1_MSTR | SPI_CR1_SSM);

// 设置为主机模式,软件NSS管理
SPI1->CR1 |= SPI_CR1_MSTR | SPI_CR1_SSI | SPI_CR1_SSM;

// 最后使能SPI(见3.4节)
参数解析:
位域 名称 推荐值 作用
MSTR Master Selection 1 选择主机模式
SSM Software Slave Management 1 使用软件NSS控制
SSI Internal Slave Select 1 内部NSS保持高,防止模式错误
SPE SPI Enable 0(暂不使能) 配置完成后才开启

🔍 解析: SSM=1 SSI=1 组合表示禁用硬件NSS引脚,由软件完全控制片选逻辑,适用于大多数应用场景。

3.2.2 配置数据帧格式为8位(DFF=0)与LSB/MSB顺序

W25Qxx Flash默认采用8位数据帧格式,并按MSB优先顺序传输。因此需确保 DFF=0 (8位帧),并保留 LSBFIRST=0 (MSB先行)。

// 设置8位数据帧,MSB先行
SPI1->CR1 &= ~(SPI_CR1_DFF | SPI_CR1_LSBFIRST);
  • DFF=0 → 8位数据长度
  • DFF=1 → 16位数据长度(仅用于特殊场合)

📌 应用提示:绝大多数SPI Flash和传感器都使用8位模式,除非特别说明,建议始终设置 DFF=0

3.2.3 确定波特率分频系数(BR[2:0]字段设置)

SPI通信速率由主设备的时钟源经分频得到。STM32F103的APB2总线通常运行在72MHz(假设系统时钟为72MHz),SPI1挂载于此,因此最高理论速率可达36MHz(PCLK/2)。但W25Q64最大支持104MHz,实际常用速率在10~20MHz之间。

可通过 BR[2:0] 字段设置分频系数:

BR[2:0] 分频系数 实际速率(PCLK=72MHz)
000 2 36 MHz
001 4 18 MHz ✅ 推荐
010 8 9 MHz
011 16 4.5 MHz
100 32 2.25 MHz
101 64 1.125 MHz
110 128 562.5 kHz
111 256 281.25 kHz
// 设置波特率分频为4(即18MHz)
SPI1->CR1 &= ~SPI_CR1_BR;
SPI1->CR1 |= SPI_CR1_BR_0;  // 001 -> /4
实际应用建议:

对于W25Q64这类高速Flash,在电源稳定、布线良好的条件下, 18MHz 是一个兼顾性能与可靠性的理想选择。若发现通信不稳定,可逐步降低至9MHz或更低。

3.3 时钟极性与相位参数设定

SPI通信的时序特性由CPOL(Clock Polarity)和CPHA(Clock Phase)两个参数共同决定,共形成四种工作模式(Mode 0~3)。W25Qxx系列Flash普遍要求 Mode 0 :即 CPOL=0 , CPHA=0

3.3.1 CPOL=0表示空闲状态低电平

CPOL=0 意味着SCK在空闲状态下保持低电平,第一个时钟边沿为上升沿。

SPI1->CR1 &= ~SPI_CR1_CPOL;  // CPOL = 0

3.3.2 CPHA=0表示第一个边沿采样

CPHA=0 表示数据在第一个时钟边沿采样(上升沿),第二个边沿输出。

SPI1->CR1 &= ~SPI_CR1_CPHA;  // CPHA = 0

3.3.3 结合Flash手册验证时序一致性

查阅W25Q64 datasheet中的时序图(Timing Diagram),可以确认其读取ID命令(0x90)的操作如下:

  • NSS下降沿开始
  • 第一个上升沿采样指令码
  • 数据在奇数个边沿采样,偶数个边沿输出

这正符合 Mode 0 (CPOL=0, CPHA=0) 的特征。

四种SPI模式对比表
模式 CPOL CPHA 空闲电平 采样边沿
0 0 0 上升沿
1 0 1 下降沿
2 1 0 下降沿
3 1 1 上升沿

❗ 错误配置CPOL/CPHA会导致严重数据错乱。务必根据外设手册精确匹配!

3.4 SPI模块使能与状态标志监控

最后一步是启用SPI外设并建立对传输状态的实时监控机制。

3.4.1 置位SPI_CR1寄存器的SPE位启动外设

配置完毕后,通过设置 SPE=1 启动SPI模块:

SPI1->CR1 |= SPI_CR1_SPE;  // 使能SPI1

此时,SPI进入就绪状态,等待数据写入DR寄存器触发通信。

3.4.2 监控TXE(发送缓冲区空)、RXNE(接收缓冲区非空)标志

SPI的状态寄存器 SPI_SR 提供多个状态位,最常用的是:

  • TXE :发送缓冲区为空,可写入新数据
  • RXNE :接收缓冲区非空,可读取数据
  • BUSY :总线忙,仍在传输中

典型轮询式发送/接收函数如下:

uint8_t spi_transfer(uint8_t data) {
    // 等待发送缓冲区空
    while (!(SPI1->SR & SPI_SR_TXE));
    // 写入数据到DR寄存器,触发SCK输出
    SPI1->DR = data;

    // 等待接收完成
    while (!(SPI1->SR & SPI_SR_RXNE));

    // 返回接收到的数据
    return SPI1->DR;
}
状态标志处理流程图(Mermaid)
graph LR
    A[开始传输] --> B{TXE == 1?}
    B -- 否 --> B
    B -- 是 --> C[写入SPI_DR]
    C --> D{RXNE == 1?}
    D -- 否 --> D
    D -- 是 --> E[读取SPI_DR]
    E --> F[返回接收数据]

⚠️ 注意事项:
- 必须在 TXE 置位后才能写入DR,否则可能发生覆盖错误。
- 读取DR前必须确认 RXNE 已置位,否则可能读取无效数据。
- 在全双工模式下,每次写操作都会同时产生一次读结果,因此即使只发不收,也应丢弃无用的接收字节。

综上所述,完整的SPI初始化流程涵盖了从时钟使能、引脚配置、模式设定到最终使能的全过程。每一环节都需严格按照硬件规范执行,方能保障后续数据通信的准确性与稳定性。下一章将进一步探讨如何利用已初始化的SPI接口实现具体的数据传输与Flash操作指令交互。

4. SPI数据传输机制与Flash操作指令实现

在嵌入式系统中,SPI(Serial Peripheral Interface)不仅是连接高速外设的关键通道,更是实现复杂功能模块通信的基石。当STM32F103通过SPI接口与W25Qxx系列串行Flash芯片协同工作时,其核心任务不仅在于建立物理层通信链路,更在于构建一套高效、可靠的数据传输机制,并在此基础上封装出可复用的Flash操作指令集。本章将深入剖析SPI主控设备如何完成一次完整的全双工数据交换过程,重点解析软件控制NSS片选信号的策略设计,详细解读W25Qxx Flash的核心命令及其执行时序要求,并最终展示如何基于HAL库对底层SPI驱动进行抽象封装,形成易于调用的应用级API函数。

4.1 数据发送与接收过程剖析

SPI通信的本质是同步移位操作,主从双方依靠共享的SCK时钟信号逐位传输数据。在STM32F103平台上,这一过程由专用的SPI外设硬件自动完成,开发者只需正确配置寄存器并合理管理DR(Data Register)即可实现稳定收发。理解该过程对于排查通信异常、优化传输效率至关重要。

4.1.1 写DR寄存器触发SCK时钟输出

当STM32作为SPI主机运行时,向 SPI_DR 寄存器写入一个字节数据会立即启动一次完整的8位(或16位)数据传输周期。此时,SPI外设内部的状态机开始工作:首先将待发送数据加载到发送移位寄存器中,随后在每个SCK时钟上升沿(或下降沿,取决于CPHA设置)将一位数据推送到MOSI线上;与此同时,来自从设备的响应数据也通过MISO线同步输入至接收移位寄存器。

// 示例代码:手动写入DR寄存器发送单字节
SPI1->DR = 0x9F; // 发送读取ID命令

逻辑分析与参数说明:

  • SPI1->DR 是STM32F103上SPI1外设的数据寄存器地址映射。
  • 写入值 0x9F 表示W25Qxx Flash的“读取JEDEC ID”命令。
  • 一旦该语句执行,硬件自动产生SCK脉冲序列,持续输出8个时钟周期,在此期间MOSI逐位发送 10011111 二进制流。
  • 注意:必须确保SPI外设已使能(SPE=1),且TXE标志为空,否则可能引发总线冲突或数据丢失。

⚠️ 关键点提醒 :直接操作寄存器虽高效,但缺乏错误检查机制。推荐使用HAL库中的 HAL_SPI_Transmit() 函数以增强健壮性。

4.1.2 读DR寄存器获取MISO线上返回数据

接收数据的过程通常发生在发送之后。由于SPI为全双工模式,即使只希望读取从机数据,也必须主动发起一次“假写”操作(dummy write),例如发送 0xFF ,从而驱动SCK时钟生成,促使从机回传信息。

uint8_t tx_data = 0xFF;
uint8_t rx_data;

// 手动模拟半双工读取(适用于裸机编程)
SPI1->DR = tx_data;                    // 触发时钟
while (!(SPI1->SR & SPI_SR_RXNE));     // 等待接收完成
rx_data = SPI1->DR;                    // 读取接收到的数据

逐行解读分析:

行号 操作 解释
1 tx_data = 0xFF 准备一个填充字节用于驱动时钟
2 SPI1->DR = tx_data 启动传输,触发SCK输出
3 while (!(SPI1->SR & SPI_SR_RXNE)) 轮询RXNE标志位,判断是否接收到完整字节
4 rx_data = SPI1->DR 从DR读取结果,同时清除RXNE标志

提示 :读取 SPI_DR 会自动清除RXNE标志,因此不可重复读取同一数据。

此外,状态寄存器 SPI_SR 中包含多个重要标志:

标志位 名称 含义
TXE Transmit buffer Empty 发送缓冲区空,可写入新数据
RXNE Receive buffer Not Empty 接收缓冲区非空,可读取数据
BSY Busy flag SPI总线处于忙状态

这些标志可用于实现阻塞/非阻塞式通信控制。

4.1.3 全双工通信中收发同步机制

SPI的独特优势在于真正的全双工能力——在同一时刻,主从设备可以同时发送和接收数据。这种特性决定了其高吞吐量表现,但也带来了时序匹配的挑战。

以下是一个典型的全双工交互流程图,使用Mermaid绘制:

sequenceDiagram
    participant MCU as STM32 (Master)
    participant Flash as W25Qxx (Slave)

    MCU->>Flash: SCK + MOSI[bit0]
    Flash-->>MCU: MISO[bit0]
    MCU->>Flash: SCK + MOSI[bit1]
    Flash-->>MCU: MISO[bit1]
    ... Continue for 8 bits ...
    MCU->>Flash: End of Frame
    Note right of MCU: DR filled → TXE set<br>RXNE set after last bit

如上图所示:
- 每个SCK周期完成一对数据位的交换;
- 主机发送第一个bit的同时,就能收到从机的第一个bit;
- 整个过程无需额外延迟,极大提升了实时性。

然而,在实际应用中,许多Flash芯片并不真正支持“并发响应”,比如在执行擦除命令后需要等待数百毫秒才能响应后续请求。因此,尽管物理层是全双工,协议层往往表现为“伪双工”或“半双工仿真”。

为了应对此类情况,常用策略包括:
- 使用独立的GPIO控制NSS,精确界定每次事务边界;
- 在发送指令后插入延时或轮询状态寄存器;
- 利用DMA+中断方式提升CPU利用率。

4.2 软件NSS管理策略

虽然STM32的SPI外设支持硬件NSS(SSM=0, SSI=1),但在多从机系统或多协议共存场景下,采用软件方式控制NSS引脚更为灵活。本节探讨如何通过普通GPIO精准掌控片选信号,避免总线竞争。

4.2.1 手动拉低PB12选择从设备

假设W25Q64挂载于SPI1总线,其CS引脚连接至PB12。初始化阶段需将其配置为推挽输出模式:

__HAL_RCC_GPIOB_CLK_ENABLE();
GPIO_InitTypeDef gpio = {0};
gpio.Pin = GPIO_PIN_12;
gpio.Mode = GPIO_MODE_OUTPUT_PP;
gpio.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(GPIOB, &gpio);

// 选中设备
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET); // 拉低CS

参数说明:
- GPIO_MODE_OUTPUT_PP :推挽输出,确保强驱动能力;
- GPIO_SPEED_FREQ_HIGH :适配高频SPI通信(如18MHz);
- GPIO_PIN_RESET :低电平有效,符合SPI标准。

🔍 扩展思考 :若使用硬件NSS,则必须启用STM32的NSS输出功能(SSOE=1),否则无法作为主机正常工作。

4.2.2 传输结束后及时释放NSS避免总线冲突

保持NSS长时间拉低可能导致从设备误认为仍在通信中,甚至影响其他挂载在同一SPI总线上的外设。因此,严格遵守“先选中→传输→释放”的时序至关重要。

void spi_flash_transmit(uint8_t *tx_buf, uint16_t len) {
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET); // CS低
    HAL_SPI_Transmit(&hspi1, tx_buf, len, HAL_MAX_DELAY);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);   // CS高
}

注意事项:
- 若传输过程中发生异常(如超时),应强制释放NSS;
- 可结合RTOS互斥量防止多任务并发访问同一SPI设备。

4.2.3 多从机场景下的片选调度逻辑

在复杂系统中,常有多个SPI外设共享同一组SCK/MOSI/MISO线。此时,各设备各自拥有独立的NSS引脚,由MCU分别控制。

设备 NSS引脚 功能
W25Q64 PB12 存储芯片
OLED显示屏 PA4 图形显示
温湿度传感器 PC5 环境监测

调度逻辑可用如下表格表示:

步骤 操作 目标设备
1 PB12=0, PA4=1, PC5=1 选中Flash
2 发送读ID命令 W25Q64
3 PB12=1 释放Flash
4 PA4=0 选中OLED
5 发送初始化序列 OLED

此机制允许资源复用,显著节省MCU引脚数量。

4.3 W25Qxx Flash常用命令解析

W25Qxx系列Flash支持丰富的操作指令集,掌握其典型命令及响应机制是实现存储管理的前提。

4.3.1 读取芯片ID(0x90)、读状态寄存器(0x05)

命令 HEX 描述
Read JEDEC ID 0x9F 返回厂商ID + 设备ID
Read Status Register 0x05 查询BUSY、WEL等状态

示例:读取ID的完整流程

uint8_t cmd[] = {0x9F, 0x00, 0x00, 0x00}; // 后三字节为地址占位
uint8_t id[4];

HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);
HAL_SPI_TransmitReceive(&hspi1, cmd, id, 4, HAL_MAX_DELAY);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);

// id[1]=0xEF (Winbond), id[2]=0x40 (W25Q64)

时序要求:
- 需连续发送4字节,第2~4字节无意义(但必须提供);
- 返回数据从第2字节起有效。

4.3.2 扇区擦除(0x20)、页写入(0x02)、连续读取(0x03)

命令 地址长度 特点
Sector Erase (0x20) 3字节 最小擦除单位4KB
Page Program (0x02) 3字节 单次最多写入256字节
Read Data (0x03) 3字节 支持任意地址连续读

⚠️ 重要规则
- 写前必须发送 Write Enable (0x06)
- 擦除后方可写入;
- 每次写不超过一页(256B);
- 写/擦操作期间BUSY=1,需轮询状态寄存器。

4.3.3 指令时序图与响应等待处理(轮询BUSY位)

以下是典型的“写使能 → 写数据”流程时序图:

timeline
    title SPI Flash 写操作时序
    section 发送写使能
        主机发送 0x06      : 1ms
        Flash 设置WEL=1    : 自动
    section 发送页写命令
        发送 0x02 + 地址    : 4Byte
        发送数据 ≤256Byte  : 可变
    section 等待完成
        轮询0x05命令       : 每1ms一次
        BUSY=0则结束       : 完成

轮询代码示例:

void wait_for_flash_ready(void) {
    uint8_t status;
    do {
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);
        HAL_SPI_Transmit(&hspi1, (uint8_t[]){0x05}, 1, HAL_MAX_DELAY);
        HAL_SPI_Receive(&hspi1, &status, 1, HAL_MAX_DELAY);
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);
    } while (status & 0x01); // BUSY位为1表示仍在操作
}

4.4 基于HAL库的SPI Flash驱动函数封装

为提高代码可维护性和移植性,建议将底层SPI操作封装为高级API。

4.4.1 初始化函数MX_SPI1_Init()调用与配置

CubeMX生成的标准初始化代码:

void MX_SPI1_Init(void) {
    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;
    hspi1.Init.Direction = SPI_DIRECTION_2LINES;
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;
    hspi1.Init.NSS = SPI_NSS_SOFT;
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; // ~9MHz @72MHz APB2
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
    HAL_SPI_Init(&hspi1);
}

参数详解:
- BaudRatePrescaler=8 → SCK = 72MHz / 8 = 9MHz;
- CLKPolarity=LOW , CLKPhase=1EDGE → Mode 0,兼容W25Qxx;
- NSS=SOFT → 软件控制PB12。

4.4.2 封装w25q_read_id(), w25q_sector_erase()等应用接口

uint32_t w25q_read_id(void) {
    uint8_t tx[4] = {0x9F, 0, 0, 0};
    uint8_t rx[4];
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);
    HAL_SPI_TransmitReceive(&hspi1, tx, rx, 4, HAL_MAX_DELAY);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);
    return (rx[1] << 16) | (rx[2] << 8) | rx[3];
}

void w25q_sector_erase(uint32_t addr) {
    send_write_enable();
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);
    HAL_SPI_Transmit(&hspi1, (uint8_t[]){0x20,
        (addr>>16)&0xFF, (addr>>8)&0xFF, addr&0xFF}, 4, HAL_MAX_DELAY);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);
    wait_for_flash_ready();
}

上述函数构成基本驱动框架,可在Lua集成项目中作为C-Lua绑定接口的基础。

5. 基于HAL库的SPI Flash读写程序设计与调试

在嵌入式系统开发中,STM32F103系列微控制器因其高性能、低成本和丰富的外设资源而被广泛应用于工业控制、物联网终端及智能设备中。当项目涉及非易失性数据存储时,W25Qxx系列SPI Flash芯片成为首选方案之一。本章节将围绕 如何使用STM32 HAL库实现对W25Q64等SPI Flash芯片的高效读写操作 展开详细讲解,并结合实际工程案例进行程序设计与调试分析。

通过本章内容的学习,读者将掌握从CubeMX配置到代码编写、再到故障排查的完整开发流程,理解关键API的调用机制与数据帧构造逻辑,具备独立完成SPI Flash驱动开发的能力。

5.1 工程创建与CubeMX配置流程

现代嵌入式开发已普遍采用图形化配置工具以提升效率并降低出错概率。STM32CubeMX作为ST官方提供的初始化代码生成工具,在SPI外设配置方面提供了直观且可靠的界面支持。本节将详细介绍如何利用CubeMX搭建一个支持DMA传输的SPI主模式工程,并将其导入主流IDE(如Keil uVision)进行后续开发。

5.1.1 使用STM32CubeMX生成SPI1主模式代码

首先启动STM32CubeMX,选择目标芯片(例如:STM32F103C8T6),进入引脚布局视图(Pinout View)。找到SPI1外设,默认情况下其推荐引脚如下:

  • SCK → PA5
  • MISO → PA6
  • MOSI → PA7
  • NSS → PA4 或软件控制PB12(更灵活)

点击相应引脚设置为“SPI1”功能,CubeMX会自动将其配置为复用推挽输出模式。接着进入“Configuration”标签页,打开SPI1模块配置面板。

// 示例:CubeMX自动生成的GPIO初始化片段(位于gpio.c)
void MX_GPIO_Init(void)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};

  /* GPIO Ports Clock Enable */
  __HAL_RCC_GPIOA_CLK_ENABLE();
  __HAL_RCC_GPIOB_CLK_ENABLE();

  /* Configure SPI1 pins: SCK, MISO, MOSI */
  GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;
  GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;        // 复用推挽输出
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;  // 高速模式,适配SPI
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

  /* Software-controlled NSS on PB12 */
  GPIO_InitStruct.Pin = GPIO_PIN_12;
  GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;    // 普通推挽输出
  HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
}

代码逻辑逐行解读

  • __HAL_RCC_GPIOA_CLK_ENABLE() :使能GPIOA时钟,确保后续寄存器访问有效。
  • GPIO_InitStruct.Mode = GPIO_MODE_AF_PP :表示该引脚用于外设功能(SPI),输出方式为推挽,可提供较强驱动能力。
  • GPIO_SPEED_FREQ_HIGH :设置高速翻转频率,满足SPI高频通信需求(最高可达18MHz,取决于APB2分频)。
  • PB12单独配置为普通输出,便于软件精确控制片选信号。

5.1.2 启用DMA选项提升大数据量传输效率

对于需要连续读取或写入大量数据的应用场景(如固件升级、日志记录、图像缓存),直接使用CPU轮询DR寄存器会导致性能瓶颈。启用DMA(Direct Memory Access)可以解放CPU资源,提高系统响应速度。

在CubeMX的SPI1配置界面中,勾选“DMA Settings”选项卡,添加新的DMA请求:

参数 设置值
Direction Peripheral to Memory / Memory to Peripheral
Mode Normal 或 Circular(根据用途)
Data Width Byte
Channel DMA1 Channel3(对应SPI1_TX)和Channel2(SPI1_RX)
graph TD
    A[CPU发起SPI传输] --> B{是否启用DMA?}
    B -- 是 --> C[配置DMA通道参数]
    C --> D[启动SPI + DMA同步传输]
    D --> E[数据自动搬运至内存/外设]
    E --> F[触发DMA中断完成回调]
    B -- 否 --> G[循环检查TXE/RXNE标志位]
    G --> H[手动读写DR寄存器]

上述流程图展示了两种SPI数据传输路径:DMA模式显著减少了CPU干预次数,尤其适合批量数据处理。

此外,在 spi.c 文件中,CubeMX会自动生成DMA初始化函数:

static void MX_DMA_Init(void)
{
  hdma_spi1_tx.Instance = DMA1_Channel3;
  hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
  hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
  hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE;
  hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
  hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
  hdma_spi1_tx.Init.Mode = DMA_NORMAL;
  hdma_spi1_tx.Init.Priority = DMA_PRIORITY_LOW;
  HAL_DMA_Init(&hdma_spi1_tx);

  __HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);
}

参数说明

  • Direction : 内存→外设,适用于发送数据;
  • MemInc = DMA_MINC_ENABLE : 内存地址递增,确保数组元素依次取出;
  • PeriphDataAlignment = DMA_PDATAALIGN_BYTE : 数据宽度按字节对齐;
  • __HAL_LINKDMA() : 将DMA句柄与SPI句柄关联,供HAL库内部调用。

5.1.3 生成初始化代码并导入Keil/IAR开发环境

完成所有配置后,点击“Project Manager”设置以下参数:

字段 推荐设置
Project Name SPI_FLASH_DEMO
Toolchain / IDE MDK-ARM (Keil)
Firmware Location STM32Cube FW_F1 V1.8.5
Code Generator Copy all used libraries into the project

点击“Generate Code”,CubeMX将在指定目录生成完整工程结构,包括核心文件:

  • main.c
  • spi.c , gpio.c , dma.c
  • stm32f1xx_hal_spi.c , stm32f1xx_hal_dma.c

随后可在Keil中打开 .uvprojx 文件编译下载。首次编译前建议检查:

  • 编译器优化等级设为 -O0 便于调试;
  • 启动文件是否正确包含(startup_stm32f103xb.s);
  • 是否定义了正确的晶振频率(HSE_VALUE=8000000)。

至此,基础工程框架已准备就绪,下一步可开始编写SPI Flash操作函数。

5.2 关键API函数调用与执行流程

HAL库为SPI通信提供了高度封装的接口函数,极大简化了开发者的工作量。然而,要实现稳定可靠的Flash读写,必须深入理解这些API的行为特征、超时机制以及组合使用技巧。

5.2.1 HAL_SPI_Transmit()与HAL_SPI_Receive()使用方法

这两个是最常用的阻塞式SPI通信函数,适用于小数据包传输。

HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, 
                                   uint8_t *pData, 
                                   uint16_t Size, 
                                   uint32_t Timeout);

HAL_StatusTypeDef HAL_SPI_Receive(SPI_HandleTypeDef *hspi, 
                                  uint8_t *pData, 
                                  uint16_t Size, 
                                  uint32_t Timeout);

参数说明

  • hspi : SPI句柄指针(通常为 &hspi1 );
  • pData : 数据缓冲区首地址;
  • Size : 要传输的字节数;
  • Timeout : 超时时间(单位ms),推荐设为 HAL_MAX_DELAY 或具体数值(如100);

示例:读取W25Q64的制造商ID

uint8_t tx_buf[4] = {0x90, 0x00, 0x00, 0x00}; // 命令+地址高位+低位+空字节
uint8_t rx_buf[4];
uint8_t man_id, dev_id;

// 步骤1:拉低片选
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);

// 步骤2:发送命令序列
if (HAL_SPI_Transmit(&hspi1, tx_buf, 4, 100) != HAL_OK) {
    Error_Handler();
}

// 步骤3:接收返回数据(需再次发送dummy clock)
if (HAL_SPI_Receive(&hspi1, rx_buf, 2, 100) != HAL_OK) {
    Error_Handler();
}

man_id = rx_buf[0];  // 应为0xEF(Winbond)
dev_id = rx_buf[1];  // 应为0x17(W25Q64)

// 步骤4:释放片选
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);

逻辑分析

  • W25Qxx规定读ID命令后需继续输入4个dummy时钟才能获取数据;
  • 因此先发4字节触发SCK,再用 HAL_SPI_Receive() 读取结果;
  • 片选全程由软件控制,保证总线独占性。

5.2.2 组合指令+地址+数据的完整帧构造技巧

多数Flash操作需按“命令+地址+可选数据”格式组织报文。例如页写入(0x02)流程如下:

阶段 数据内容
Command 0x02
Address 3 bytes (A23-A0)
Data 1~256 bytes
void w25q_page_program(uint32_t addr, uint8_t* data, uint16_t len)
{
    uint8_t cmd_addr[4];
    // 构造命令+地址
    cmd_addr[0] = 0x02;                    // Page Program命令
    cmd_addr[1] = (addr >> 16) & 0xFF;     // 地址高字节
    cmd_addr[2] = (addr >> 8)  & 0xFF;
    cmd_addr[3] = addr         & 0xFF;

    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);

    HAL_SPI_Transmit(&hspi1, cmd_addr, 4, 100);      // 发送命令+地址
    HAL_SPI_Transmit(&hspi1, data, len, 100);        // 发送数据

    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);

    w25q_wait_busy();  // 等待编程完成
}

注意事项

  • 写入前必须发送“Write Enable”(0x06)命令;
  • 每次写入不得超过一页(256字节);
  • 地址不能跨越页边界。

5.2.3 超时机制防止死锁(HAL_TIMEOUT)

在实际运行中,若硬件连接异常或Flash未准备好,SPI函数可能长期挂起。为此,应始终设定合理超时值,并配合错误处理机制。

#define SPI_TIMEOUT_MS  100

if (HAL_SPI_Transmit(&hspi1, buffer, size, SPI_TIMEOUT_MS) != HAL_OK) {
    // 可尝试复位SPI或重新初始化
    HAL_SPI_Abort(&hspi1);
    MX_SPI1_Init();  // 重新初始化
    return -1;
}

表格:常见返回状态码含义

返回值 含义 建议动作
HAL_OK 成功 继续执行
HAL_ERROR 通信失败 检查接线、电源
HAL_BUSY 设备忙 延迟重试
HAL_TIMEOUT 超时 检查NSS、时钟

5.3 数据读写测试案例设计

理论知识需通过实践验证。本节设计三个典型测试用例,覆盖基本功能、擦除特性与调试手段。

5.3.1 向指定地址写入字符串并读回验证

目标:验证SPI通信链路完整性与数据一致性。

char write_str[] = "Hello, W25Q64!";
char read_buf[32];

// 步骤1:擦除目标扇区(4KB)
w25q_sector_erase(0x000000);

// 步骤2:允许写入
w25q_write_enable();

// 步骤3:写入数据
w25q_page_program(0x000000, (uint8_t*)write_str, strlen(write_str));

// 步骤4:读取验证
w25q_read_data(0x000000, (uint8_t*)read_buf, strlen(write_str));
read_buf[strlen(write_str)] = '\0';

if (strcmp(write_str, read_buf) == 0) {
    printf("✅ 数据读写成功!\n");
} else {
    printf("❌ 数据不一致!\n");
}

预期结果 :串口输出“✅ 数据读写成功!”表明整个流程正常。

5.3.2 测试扇区擦除后写入权限是否恢复

Flash特性:仅在擦除后的区域允许写入。若未擦除即写入,数据无效。

// 先写一次
w25q_write_enable();
w25q_page_program(0x001000, "OLD_DATA", 8);

// 不擦除,直接写新数据
w25q_write_enable();
w25q_page_program(0x001000, "NEW_DATA", 8);

// 读取验证
w25q_read_data(0x001000, buf, 8);
// 实际仍为"OLD_DATA"

结论: 必须先擦除再写入 ,否则写操作无效。

5.3.3 利用串口打印调试信息辅助分析

添加串口日志是快速定位问题的有效手段。可通过 printf 重定向输出:

int _write(int fd, char *ptr, int len)
{
    HAL_UART_Transmit(&huart1, (uint8_t*)ptr, len, HAL_MAX_DELAY);
    return len;
}

然后在关键节点插入日志:

printf("👉 正在读取ID...\n");
w25q_read_id(&man_id, &dev_id);
printf("🏭 Manufacturer ID: 0x%02X\n", man_id);
printf("🔧 Device ID: 0x%02X\n", dev_id);

最终输出示例:

👉 正在读取ID...
🏭 Manufacturer ID: 0xEF
🔧 Device ID: 0x17
✅ 检测到W25Q64

5.4 常见故障与解决方案总结

即使严格按照手册操作,初学者仍常遇到各种问题。以下是典型故障及其应对策略。

5.4.1 返回ID异常 → 检查接线与时序模式

现象:读出ID为0xFF或0x00。

原因分析:

可能原因 解决方案
接触不良或虚焊 使用万用表测量连通性
NSS未正确拉低 示波器观测PB12电平变化
CPOL/CPHA配置错误 改为Mode 0(CPOL=0, CPHA=0)
Flash未上电 测量VCC是否为3.3V

建议使用示波器抓取SCK、MOSI波形,确认命令确实发出。

5.4.2 写入失败 → 确认写使能命令已发送

W25Qxx具有写保护机制,每次写/擦除前必须执行 0x06 命令。

void w25q_write_enable(void)
{
    uint8_t cmd = 0x06;
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);
    HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
    HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);
}

遗漏此步骤将导致写操作无效,但无报错提示。

5.4.3 数据错乱 → 校准时钟分频与缓冲区长度

问题表现:部分字节错误、顺序颠倒。

排查方向:

  1. 检查SPI时钟分频设置

c hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_32; // APB2=72MHz → SCK=2.25MHz

若过快(如/2),可能导致信号畸变。

  1. 缓冲区溢出

c uint8_t buf[256]; HAL_SPI_Receive(&hspi1, buf, 512, 100); // ❌ 错误!超出数组大小

必须确保 Size ≤ buffer_length

  1. DMA双缓冲冲突

若同时启用TX/RX DMA,需确保两者缓冲区独立,避免内存覆盖。

综上所述,基于HAL库的SPI Flash开发虽简化了底层寄存器操作,但仍需严谨对待协议细节与硬件协同。通过合理的工程配置、规范的API调用、充分的测试验证与系统的调试手段,能够构建出稳定高效的嵌入式存储系统。

6. Lua脚本在STM32中的集成与项目实战整合

6.1 probablylua简介与轻量级解释器优势

Lua是一种高效的嵌入式脚本语言,以其简洁的语法、动态类型系统和极佳的可嵌入性著称。在资源受限的嵌入式平台中,标准Lua解释器虽然功能完整,但其内存占用偏高。为此, probablylua 应运而生——这是一个专为MCU优化的轻量级Lua子集解释器,去除了部分复杂特性(如协程、元表高级操作),保留了核心语法结构(变量、循环、函数、表基础操作)。

6.1.1 Lua语言特点:简洁、可嵌入、动态类型

Lua语法接近JavaScript,学习成本低,适合非专业程序员进行设备逻辑配置。例如:

-- 示例:控制LED闪烁频率
function blink_led(pin, delay_ms)
    while true do
        gpio.set(pin, 1)
        tmr.delay(delay_ms)
        gpio.set(pin, 0)
        tmr.delay(delay_ms)
    end
end

该脚本可在运行时动态加载执行,无需重新编译固件。

6.1.2 probablylua针对资源受限MCU的优化策略

  • 栈式虚拟机设计 :避免堆分配频繁,提升执行效率。
  • 无浮点支持(可选关闭) :节省Flash空间约4KB。
  • 静态内存池管理 :预分配字符串、表、闭包等对象空间,防止碎片化。
  • 精简标准库 :仅保留 string.sub , table.insert 等常用函数。
特性 标准Lua 5.1 probablylua(STM32优化版)
Flash 占用 ~28 KB ~16 KB
RAM 运行需求 ≥32 KB ≥20 KB
执行速度(MHz级Cortex-M3) 中等 接近原生C的1/5~1/3
支持表(table) 完整 基础键值对、数组
是否支持require模块 否(需手动注册)

6.1.3 在STM32F103上运行Lua的基本条件(RAM≥20KB)

STM32F103RCT6具备64KB SRAM 和 512KB Flash,足以容纳 probablylua 解释器 + 用户脚本 + 外设驱动。关键配置如下:
- 分配 8KB 作为Lua堆空间(通过 lua_open() 传入内存池指针)
- 使用链接脚本定义 .luastack
- 关闭 LUA_USE_DBLIB LUA_USE_IOLIB 以减小体积

// 初始化Lua状态机
uint8_t lua_heap[8192] __attribute__((aligned(4)));
lua_State *L = lua_setup(lua_heap, sizeof(lua_heap));
if (!L) {
    Error_Handler();
}

此初始化确保了解释器在无malloc环境下稳定运行。

6.2 ChibiOS/FreeRTOS环境下Lua运行时部署

将Lua集成至RTOS环境可实现多任务协同,提升系统响应能力。

6.2.1 创建任务运行Lua虚拟机主循环

以FreeRTOS为例,创建独立任务执行脚本:

void vLuaTask(void *pvParameters) {
    lua_State *L = (lua_State*)pvParameters;
    // 加载内置C函数
    luaL_openlibs(L); 
    register_spi_flash_api(L); // 绑定SPI Flash操作
    // 执行启动脚本
    if (luaL_dostring(L, "print('Lua started'); init_system()") != 0) {
        printf("Lua error: %s\n", lua_tostring(L, -1));
    }

    for(;;) {
        // 定期执行心跳脚本或事件处理
        osDelay(100);
    }
}

任务优先级建议设为 osPriorityNormal ,避免阻塞高实时性任务。

6.2.2 分配堆空间与垃圾回收调优

由于MCU内存有限,需手动控制GC行为:

// 每秒强制一次垃圾回收
void gc_monitor_task(void *arg) {
    for (;;) {
        lua_gc(L, LUA_GCCOLLECT, 0);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

同时限制表的嵌套深度,防栈溢出。

6.2.3 实现Lua调用C函数的绑定接口(如spi_flash_write)

使用 lua_register 暴露C函数:

static int l_spi_flash_write(lua_State *L) {
    uint32_t addr = luaL_checkinteger(L, 1);
    size_t len;
    const char* data = luaL_checklstring(L, 2, &len);
    w25q_page_program(addr, (uint8_t*)data, len);
    return 0; // 无返回值
}

void register_spi_flash_api(lua_State *L) {
    lua_register(L, "spi_flash_write", l_spi_flash_write);
}

Lua脚本即可调用:

spi_flash_write(0x1000, "Hello from Lua!")

6.3 SPI Flash作为Lua脚本存储介质的设计方案

外部W25Q64(8MB)提供充足的脚本存储空间。

6.3.1 将.lua文件编译为字节码存入W25Qxx

利用 luac 工具预编译脚本:

luac -o main.luac main.lua

通过PC端工具烧录到Flash指定地址(如 0x00010000 )。

6.3.2 上电后从Flash加载脚本到内存执行

uint8_t script_buf[2048];
w25q_read_data(0x00010000, script_buf, sizeof(script_buf));

if (luaL_loadbuffer(L, (char*)script_buf, strlen((char*)script_buf), "main")) {
    printf("Load failed: %s\n", lua_tostring(L, -1));
} else {
    lua_call(L, 0, 0);
}

支持热更新:新版本脚本写入备用区,下次重启切换入口地址。

6.3.3 动态更新脚本实现远程配置升级

结合串口或Wi-Fi模组接收新脚本,验证CRC后写入Flash:

void update_script_from_uart(uint8_t* buf, uint32_t size) {
    uint32_t addr = SCRIPT_AREA_ADDR;
    w25q_sector_erase(addr);
    for(int i=0; i<size; i+=256) {
        w25q_page_program(addr+i, buf+i, min(256, size-i));
    }
}

6.4 完整开发流程与综合项目实战

构建一个“智能日志记录器”原型系统。

6.4.1 构建“Lua控制LED+按键+SPI Flash日志”系统

硬件连接:
- LED → PC13
- 按键 → PA0(外部中断)
- W25Q64 → SPI1(PA5/6/7, PB12)

Lua脚本示例:

function on_button_press()
    local ts = get_timestamp()
    local log = string.format("Event at %d\n", ts)
    spi_flash_write(LOG_ADDR, log)
    led_toggle(1)
end

6.4.2 实现Lua脚本调用SPI API完成数据记录

C层封装日志追加逻辑:

int l_log_append(lua_State *L) {
    const char* msg = luaL_checkstring(L, 1);
    static uint32_t log_ptr = 0x100000;
    w25q_page_program(log_ptr, (uint8_t*)msg, strlen(msg));
    log_ptr += strlen(msg);
    return 0;
}

6.4.3 评估性能瓶颈与内存占用优化路径

使用 SEGGER SystemView 分析任务调度:

操作 平均耗时(72MHz)
解释执行一条赋值语句 ~120μs
调用spi_flash_write ~3.2ms(含BUSY轮询)
GC全收集 ~8ms

优化建议:
- 缓存常用字符串避免重复创建
- 使用固定大小的对象池替代动态分配
- 将高频逻辑移回C实现

graph TD
    A[上电] --> B{是否有新脚本?}
    B -- 是 --> C[从UART加载脚本]
    B -- 否 --> D[从Flash读取脚本]
    C --> E[验证CRC]
    E --> F[写入W25Qxx]
    D --> G[加载到RAM]
    G --> H[启动Lua VM]
    H --> I[执行init()]
    I --> J[进入事件循环]

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:STM32F103系列基于ARM Cortex-M3内核,广泛用于嵌入式系统。本项目聚焦于在该平台上实现SPI通信协议,完成对W25Q系列SPI Flash存储器的读写操作。内容涵盖SPI协议原理、GPIO与SPI外设配置、时钟设置及数据传输流程,并结合HAL库进行底层驱动开发。此外,项目探索通过Lua脚本(如probablylua)封装SPI操作,提升应用层开发效率。适合掌握嵌入式C编程、外设控制及嵌入式系统通信协议的实践学习。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐