前言
上一章我们深入掌握了I2C低速串行总线的底层原理与工业外设驱动开发,而SPI是嵌入式开发中高速外设通信的核心总线,是大容量Flash、高速传感器、显示屏、ADC/DAC等外设的首选通信方式。对应51单片机开发,我们通常通过软件翻转IO模拟SPI时序,存在时序精度差、CPU占用率100%、传输速度上限低、全双工收发逻辑复杂的痛点;而STM32内置3路硬件SPI控制器,支持全双工同步通信、可编程波特率、4种工作模式,可与DMA无缝联动实现零CPU占用的高速传输,完美适配工业场景下大容量数据、高速率的通信需求。新手入门SPI普遍面临三大痛点:4种工作模式混淆导致通信异常、Flash读写数据错乱、高速传输丢包、多从机总线冲突。本章将严格遵循「先寄存器原理拆解,再HAL库封装逻辑」的顺序,联动51单片机对应知识点,从底层时序到工业级实战全面吃透SPI通信,完成W25Qxx系列Flash芯片的全功能驱动开发。
本章目录
- 一、本章学习目标
- 二、核心知识点
- 2.1 SPI协议基础与51单片机驱动方案核心对比
- 2.2 SPI协议底层时序与4种工作模式核心规则
- 2.3 SPI多从机总线架构与寻址逻辑
- 2.4 STM32硬件SPI核心寄存器与C语言位操作联动
- 2.5 HAL库SPI封装逻辑与核心API深度解析
- 三、STM32CubeMX+Keil5保姆式实操:W25Qxx Flash驱动全场景实战
- 3.1 工程创建与基础配置
- 3.2 SPI外设与GPIO图形化配置
- 3.3 工程代码生成与W25Qxx驱动文件移植
- 3.4 Flash读写擦除全功能业务代码编写
- 3.5 编译、烧录与效果验证
- 四、保姆式排错指南
- 五、我的踩坑记录
- 六、课后小练习(附完整标准答案)
- 6.1 基础巩固练习
- 6.2 进阶实战练习
- 七、核心知识点速记
- 八、本章小结
一、本章学习目标
- 掌握SPI串行通信协议的底层工作原理、全双工同步通信机制与4种工作模式的核心规则,对比51单片机软件模拟方案的核心差异,理解SPI在工业高速通信中的工程价值
- 吃透STM32硬件SPI的内核架构、寄存器级工作原理,联动C语言位操作、指针知识点,能独立实现寄存器级的SPI初始化与全双工读写操作
- 掌握HAL库SPI的封装逻辑与核心API使用,区分阻塞/中断/DMA三种传输模式的选型逻辑,适配不同工业开发场景的速率与实时性需求
- 熟练完成W25Qxx系列Flash芯片的全功能驱动开发,实现芯片ID读取、扇区擦除、页编程、随机读、批量读写等核心功能,完成大容量数据掉电存储实战
- 能独立排查SPI通信无响应、数据错乱、Flash读写失败、DMA传输异常等高频问题,掌握高速SPI通信的硬件与软件优化方案
二、核心知识点
2.1 SPI协议基础与51单片机驱动方案核心对比
术语通俗解释 :SPI全称串行外设接口,是一种四线式全双工同步串行通信总线,由摩托罗拉公司推出,通过SCK(串行时钟)、MOSI(主发从收)、MISO(主收从发)、CS(片选)四根线实现主设备与从设备之间的高速同步通信。类比两台同步工作的移位寄存器:主设备通过SCK提供统一的时钟节拍,每一个时钟周期,主从设备同时移位发送1位数据、接收1位数据,实现收发同步完成,全双工通信。
SPI协议核心特性:
- 四线制:SCK、MOSI、MISO、CS,极简硬件设计,节省IO资源;
- 主从架构:通信由主机发起,从机响应,支持单主机多从机架构,通过独立CS引脚寻址从机;
- 全双工通信:同一时钟周期内,主机与从机可同时发送和接收数据,传输效率远高于I2C半双工通信;
- 同步通信:收发双方共用主机提供的SCK时钟,无需约定波特率,无时钟误差问题,通信稳定性强;
- 速率上限高:STM32F103 SPI最高时钟频率为36MHz(系统主频72MHz的1/2),传输速率可达36Mbps,是I2C快速模式的90倍;
- 无硬件应答机制:通信可靠性由软件协议保证,无需硬件应答,时序更简单,传输效率更高。
我们从51单片机的软件模拟方案出发,无缝衔接理解STM32硬件SPI的核心优势:
| 驱动方案 | 51单片机软件模拟SPI | STM32硬件SPI | 对开发的核心影响 |
|---|---|---|---|
| 时序实现 | 手动翻转IO口,通过延时控制SCK时序,精度差,受主频、中断干扰极大 | 硬件控制器自动生成标准时序,精度高,不受软件、中断干扰,支持可编程波特率 | 软件模拟时序高速下极易出现数据错位,不同主频芯片需重新调整延时;硬件时序兼容性极强,代码可跨芯片移植,支持超高速稳定传输 |
| CPU占用 | 通信全程CPU需循环翻转IO、读写数据,占用率100%,无法同步执行其他任务 | 仅需配置初始参数,数据收发全程硬件自动完成,配合DMA可实现零CPU占用 | 软件模拟方案大容量数据传输时CPU完全卡死,无法同步执行采集、控制逻辑;硬件方案传输期间CPU可正常执行核心业务,系统实时性大幅提升 |
| 传输效率 | 半双工收发,最高速率约500Kbps,全双工逻辑复杂,极易出错 | 原生全双工同步收发,最高速率36Mbps,传输效率提升70倍以上 | 软件模拟方案无法实现大容量Flash、高速显示屏的流畅驱动;硬件SPI可实现毫秒级的大容量数据读写,适配工业高速数据存储场景 |
| 多从机支持 | 多从机切换需重新编写时序代码,兼容性差,极易出现总线冲突 | 原生支持多从机架构,通过独立CS引脚切换从机,硬件保证总线隔离,无冲突风险 | 软件模拟方案一条总线最多挂载2-3个从机;硬件SPI一条总线可挂载数十个从机,完美适配工业多外设场景 |
| 开发难度 | 需开发者完全掌握协议底层时序,手动实现SCK时序、全双工收发,代码量大,逻辑复杂 | HAL库已封装标准通信API,仅需调用收发函数即可完成全双工通信,代码极简,稳定性强 | 软件模拟方案开发周期长,易出bug;硬件方案可快速实现外设驱动,聚焦业务逻辑开发,效率提升数十倍 |
2.2 SPI协议底层时序与4种工作模式核心规则
1. SPI总线核心信号线定义
| 信号线 | 名称 | 方向 | 核心功能 |
|---|---|---|---|
| SCK | 串行时钟线 | 主机→从机 | 主机输出同步时钟,控制通信节拍,决定通信速率 |
| MOSI | 主发从收数据线 | 主机→从机 | 主机向从机发送数据,从机接收数据 |
| MISO | 主收从发数据线 | 从机→主机 | 从机向主机发送数据,主机接收数据 |
| CS/SS | 片选线 | 主机→从机 | 主机拉低对应从机的CS引脚,选中目标从机,通信结束后拉高,一条总线每个从机对应独立的CS引脚 |
核心规则:SPI通信必须先拉低目标从机的CS引脚,选中从机后才能发起通信,通信全程CS必须保持低电平,通信结束后必须拉高CS引脚,释放总线。
2. SPI全双工通信底层原理
SPI的核心是主从机同步移位寄存器,主机与从机各有一个移位寄存器,通过MOSI、MISO线首尾相连,形成一个环形移位链路。
- 通信前,主机与从机将待发送的数据写入各自的移位寄存器;
- 主机输出SCK时钟,每一个时钟周期,移位寄存器移位1位,主机通过MOSI线向从机发送1位数据,同时从机通过MISO线向主机发送1位数据;
- 8个时钟周期后,主机与从机的移位寄存器完成一次数据交换,实现了1字节的全双工收发,主机发送1字节的同时,必然收到从机的1字节数据。
通俗类比:两个面对面的人,手里各有8张牌,每喊一个节拍(SCK时钟),两人同时把自己最左边的牌递给对方,8个节拍后,两人手里的牌完成了完整交换,实现了同时发送和接收。
3. SPI 4种工作模式核心规则
SPI的4种工作模式由时钟极性CPOL 和时钟相位CPHA两个参数组合决定,核心是定义SCK时钟的空闲电平与数据采样、更新的时刻,这是新手入门的头号难点,也是通信异常的核心根源。
核心参数定义
- CPOL(时钟极性) :定义SPI总线空闲时SCK的电平状态
- CPOL=0:总线空闲时SCK为低电平
- CPOL=1:总线空闲时SCK为高电平
- CPHA(时钟相位) :定义数据采样与更新的时钟边沿
- CPHA=0:在SCK的第一个跳变沿(上升沿/下降沿)采样数据,第二个跳变沿更新数据
- CPHA=1:在SCK的第二个跳变沿采样数据,第一个跳变沿更新数据
4种工作模式详解
| 工作模式 | CPOL | CPHA | 空闲SCK电平 | 数据采样时刻 | 数据更新时刻 | 工业常用度 |
|---|---|---|---|---|---|---|
| 模式0 | 0 | 0 | 低电平 | SCK上升沿 | SCK下降沿 | 最高,W25Qxx、OLED、绝大多数传感器均使用此模式 |
| 模式1 | 0 | 1 | 低电平 | SCK下降沿 | SCK上升沿 | 极少使用 |
| 模式2 | 1 | 0 | 高电平 | SCK下降沿 | SCK上升沿 | 较少使用 |
| 模式3 | 1 | 1 | 高电平 | SCK上升沿 | SCK下降沿 | 常用,部分高速Flash、显示屏使用此模式 |
核心选型规则:SPI主机的工作模式必须与从机完全一致,否则会出现数据采样错误,导致通信完全异常。开发前必须查阅从机芯片手册,确认其支持的SPI工作模式,严格匹配CPOL与CPHA参数。
4. 完整的SPI单字节读写时序(模式0)
工业最常用的模式0完整时序流程:
- 总线空闲状态:SCK为低电平,CS为高电平,MOSI/MISO电平任意;
- 主机拉低目标从机的CS引脚,选中从机,启动一次通信;
- 主机准备待发送的1字节数据,写入SPI数据寄存器;
- 主机输出SCK时钟,每个时钟周期:
- SCK上升沿:主机采样MISO线上的从机数据,从机采样MOSI线上的主机数据;
- SCK下降沿:主机更新MOSI线上的下一位数据,从机更新MISO线上的下一位数据;
- 8个时钟周期后,1字节数据收发完成,主机收到从机发送的1字节数据;
- 主机拉高CS引脚,结束本次通信,总线回到空闲状态。
2.3 SPI多从机总线架构与寻址逻辑
SPI总线支持单主机多从机架构,核心分为两种接线方式,适配不同的应用场景:
1. 独立片选模式(工业首选)
- 接线方式:所有从机的SCK、MOSI、MISO引脚分别并联到主机的对应引脚,每个从机的CS引脚单独连接到主机的一个GPIO引脚,主机通过拉低对应从机的CS引脚选中目标设备。
- 核心优势:总线隔离性好,同一时间只有一个从机被选中,无总线冲突风险,时序简单,兼容性强,支持任意数量的从机(仅受主机GPIO数量限制)。
- 适用场景:绝大多数工业开发场景,多外设、高稳定性需求的项目。
2. 菊花链模式(级联模式)
- 接线方式:主机SCK并联到所有从机,主机MOSI接第一个从机的MOSI,第一个从机的MISO接第二个从机的MOSI,以此类推,最后一个从机的MISO接主机的MISO,所有从机共用一个CS引脚。
- 核心优势:节省主机GPIO引脚,仅需1个CS引脚即可控制多个从机。
- 核心劣势:通信数据需经过所有从机的移位寄存器,传输延迟大,仅支持同步收发,无法单独寻址单个从机,兼容性差。
- 适用场景:LED点阵、多通道DAC等需要同步控制的串行级联设备。
SPI与I2C多从机架构核心对比:
| 特性 | SPI独立片选模式 | I2C总线 |
|---|---|---|
| 寻址方式 | 硬件CS引脚寻址,每个从机独立GPIO | 软件7位地址寻址,无需额外GPIO |
| 总线冲突 | 无冲突,同一时间仅一个从机被选中 | 存在地址冲突风险,需保证从机地址唯一 |
| GPIO占用 | 3条公共线+每个从机1条CS线,从机越多占用GPIO越多 | 仅需2条线,与从机数量无关 |
| 通信效率 | 全双工高速通信,无地址、应答开销,效率极高 | 半双工通信,需地址、应答开销,效率较低 |
| 多机通信 | 仅支持单主机多从机,不支持多主机 | 支持多主机多从机架构 |
2.4 STM32硬件SPI核心寄存器与C语言位操作联动
STM32F103内置3个SPI控制器,SPI1挂载在APB2总线(最高72MHz),SPI2/SPI3挂载在APB1总线(最高36MHz)。我们以SPI1为例,拆解核心寄存器的功能与C语言位操作实现,联动51单片机软件模拟逻辑,实现无缝衔接。
| 寄存器名称 | 结构体成员 | 读写属性 | 核心功能与位操作详解 |
|---|---|---|---|
| 控制寄存器1 | SPI1->CR1 |
读写 | SPI核心配置寄存器,关键位: - 位0(CPHA):时钟相位配置,0=模式0/2,1=模式1/3 - 位1(CPOL):时钟极性配置,0=空闲低电平,1=空闲高电平 - 位2(MSTR):主从模式选择,1=主机模式,0=从机模式 - 位3-5(BR):波特率分频,000=2分频,001=4分频,以此类推,72MHz主频下2分频得到36MHz最高速率 - 位6(SPE):SPI使能位,写1开启SPI外设 - 位7(LSBFIRST):数据位序,0=高位在前(MSB),1=低位在前(LSB) - 位8(SSI):软件内部从机选择,软件CS模式使用 - 位9(SSM):软件从机管理使能,1=软件CS模式,0=硬件CS模式 - 位10(RXONLY):只读模式,0=全双工模式 - 位11(DFF):数据帧格式,0=8位数据,1=16位数据 |
| 控制寄存器2 | SPI1->CR2 |
读写 | 中断与DMA控制寄存器,关键位: - 位0(RXDMAEN):接收DMA使能位 - 位1(TXDMAEN):发送DMA使能位 - 位6(TXEIE):发送缓冲区空中断使能 - 位7(RXNEIE):接收缓冲区非空中断使能 |
| 状态寄存器 | SPI1->SR |
只读 | SPI状态标志位,关键位: - 位0(RXNE):接收缓冲区非空,收到1字节数据后置1,读取DR寄存器后清零 - 位1(TXE):发送缓冲区空,可写入下一个发送数据 - 位7(BSY):总线忙标志位,通信期间置1,通信完成后清零 |
| 数据寄存器 | SPI1->DR |
读写 | 低8/16位有效,写入数据启动发送,读取数据获取接收缓冲区的内容,全双工收发的核心寄存器 |
C语言寄存器操作完整示例:SPI1主机模式初始化(模式0,8位数据,高位在前,18MHz波特率)与全双工字节读写函数,完全对应51单片机软件模拟逻辑
c
#include "stm32f1xx.h"
// SPI1主机模式初始化,模式0,8位,MSB在前,18MHz波特率
void SPI1_Init(void)
{
// 1. 开启GPIOA与SPI1时钟
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_SPI1EN;
// 2. 配置SPI引脚:PA5(SCK)、PA7(MOSI)复用推挽输出,PA6(MISO)浮空输入,PA4(CS)推挽输出
GPIOA->CRL &= ~(GPIO_CRL_MODE4 | GPIO_CRL_CNF4 | GPIO_CRL_MODE5 | GPIO_CRL_CNF5 | GPIO_CRL_MODE6 | GPIO_CRL_CNF6 | GPIO_CRL_MODE7 | GPIO_CRL_CNF7);
// PA4(CS):推挽输出,50MHz
GPIOA->CRL |= GPIO_CRL_MODE4_1;
// PA5(SCK)、PA7(MOSI):复用推挽输出,50MHz
GPIOA->CRL |= GPIO_CRL_MODE5_1 | GPIO_CRL_CNF5_1 | GPIO_CRL_MODE7_1 | GPIO_CRL_CNF7_1;
// PA6(MISO):浮空输入
GPIOA->CRL |= GPIO_CRL_CNF6_0;
// 3. CS引脚默认拉高
GPIOA->BSRR = GPIO_BSRR_BS4;
// 4. 配置SPI1 CR1寄存器:主机模式,模式0,8位,MSB在前,4分频(72/4=18MHz)
SPI1->CR1 = 0;
SPI1->CR1 |= SPI_CR1_MSTR | SPI_CR1_BR_1 | SPI_CR1_SSM | SPI_CR1_SSI;
// 5. 开启SPI外设
SPI1->CR1 |= SPI_CR1_SPE;
}
// SPI全双工读写1字节数据
uint8_t SPI_ReadWrite_Byte(uint8_t tx_data)
{
// 等待发送缓冲区空
while(!(SPI1->SR & SPI_SR_TXE));
// 写入发送数据,启动传输
SPI1->DR = tx_data;
// 等待接收完成
while(!(SPI1->SR & SPI_SR_RXNE));
// 返回读取到的数据
return (uint8_t)SPI1->DR;
}
// W25Qxx读芯片ID示例
#define W25QXX_CMD_READ_ID 0x90
uint16_t W25Qxx_Read_ID(void)
{
uint16_t id;
// 拉低CS,选中芯片
GPIOA->BSRR = GPIO_BSRR_BR4;
// 发送读ID命令
SPI_ReadWrite_Byte(W25QXX_CMD_READ_ID);
// 发送3字节地址0x000000
SPI_ReadWrite_Byte(0x00);
SPI_ReadWrite_Byte(0x00);
SPI_ReadWrite_Byte(0x00);
// 读取厂商ID和设备ID
uint8_t manu_id = SPI_ReadWrite_Byte(0xFF);
uint8_t dev_id = SPI_ReadWrite_Byte(0xFF);
// 拉高CS,结束通信
GPIOA->BSRR = GPIO_BSRR_BS4;
id = (manu_id << 8) | dev_id;
return id;
}
2.5 HAL库SPI封装逻辑与核心API深度解析
HAL库将SPI的底层寄存器操作封装为标准化的结构体与API函数,无需手动处理时序、标志位,仅需简单调用即可完成稳定的全双工通信,大幅提升开发效率。
1. SPI核心配置结构体
HAL库用SPI_HandleTypeDef结构体封装SPI外设的所有配置参数,与寄存器一一对应,联动C语言结构体知识点:
c
typedef struct {
SPI_TypeDef *Instance; // SPI外设基地址,SPI1/SPI2/SPI3
SPI_InitTypeDef Init; // SPI核心初始化参数
uint8_t *pTxBuffPtr; // 发送缓存指针
uint16_t TxXferSize; // 发送数据长度
__IO uint16_t TxXferCount; // 剩余发送长度
uint8_t *pRxBuffPtr; // 接收缓存指针
uint16_t RxXferSize; // 接收数据长度
__IO uint16_t RxXferCount; // 剩余接收长度
DMA_HandleTypeDef *hdmatx; // 发送DMA句柄
DMA_HandleTypeDef *hdmarx; // 接收DMA句柄
HAL_LockTypeDef Lock; // 锁保护
__IO HAL_SPI_StateTypeDef State; // SPI运行状态
__IO uint32_t ErrorCode; // 错误代码
} SPI_HandleTypeDef;
// SPI核心初始化参数结构体
typedef struct {
uint32_t Mode; // 主从模式,SPI_MODE_MASTER主机模式
uint32_t Direction; // 通信方向,SPI_DIRECTION_2LINES全双工
uint32_t DataSize; // 数据位宽,SPI_DATASIZE_8BIT
uint32_t CLKPolarity; // 时钟极性CPOL,SPI_POLARITY_LOW对应CPOL=0
uint32_t CLKPhase; // 时钟相位CPHA,SPI_PHASE_1EDGE对应CPHA=0
uint32_t NSS; // 片选模式,SPI_NSS_SOFT软件CS模式
uint32_t BaudRatePrescaler; // 波特率分频,SPI_BAUDRATEPRESCALER_4
uint32_t FirstBit; // 位序,SPI_FIRSTBIT_MSB高位在前
uint32_t TIMode; // TI模式,关闭
uint32_t CRCCalculation; // CRC校验,关闭
uint32_t CRCPolynomial; // CRC多项式
} SPI_InitTypeDef;
2. HAL库SPI核心API与底层对应关系
工业开发中最常用的全双工通信API,核心分为阻塞、中断、DMA三类,适配不同场景:
| HAL库API函数 | 核心功能 | 传输模式 | 适用场景 |
|---|---|---|---|
HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
阻塞式发送数据,发送完成后返回 | 轮询阻塞 | 短数据、低频率发送,如寄存器配置、命令发送 |
HAL_SPI_Receive(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
阻塞式接收数据,接收完成后返回 | 轮询阻塞 | 纯接收场景,如传感器数据读取 |
HAL_SPI_TransmitReceive(SPI_HandleTypeDef *hspi, uint8_t *pTxData, uint8_t *pRxData, uint16_t Size, uint32_t Timeout) |
阻塞式全双工收发,发送与接收同步完成 | 轮询阻塞 | 绝大多数SPI外设的读写操作,如Flash读写、寄存器配置,工业开发最常用 |
HAL_SPI_Transmit_IT() |
中断模式发送数据,非阻塞,发送完成触发回调 | 中断非阻塞 | 长数据发送,不希望阻塞主循环的场景 |
HAL_SPI_Receive_IT() |
中断模式接收数据,非阻塞,接收完成触发回调 | 中断非阻塞 | 长数据接收,不希望阻塞主循环的场景 |
HAL_SPI_TransmitReceive_IT() |
中断模式全双工收发,非阻塞,收发完成触发回调 | 中断非阻塞 | 中等长度数据全双工收发,实时性要求较高的场景 |
HAL_SPI_Transmit_DMA() |
DMA模式发送数据,全程零CPU占用,发送完成触发回调 | DMA非阻塞 | 高速大批量数据发送,如显示屏全屏刷新、固件升级 |
HAL_SPI_Receive_DMA() |
DMA模式接收数据,全程零CPU占用,接收完成触发回调 | DMA非阻塞 | 高速大批量数据接收,如高速ADC采样、Flash批量读取 |
HAL_SPI_TransmitReceive_DMA() |
DMA模式全双工收发,全程零CPU占用,收发完成触发回调 | DMA非阻塞 | 高速全双工大批量数据传输,工业高速数据存储场景 |
核心回调函数:
c
// 发送完成回调函数
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi);
// 接收完成回调函数
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi);
// 全双工收发完成回调函数
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi);
// 传输错误回调函数
void HAL_SPI_ErrorCallback(SPI_HandleTypeDef *hspi);
核心使用规则:SPI通信前必须拉低对应从机的CS引脚,通信全程保持低电平,通信完成后必须拉高CS引脚,释放总线。
三、STM32CubeMX+Keil5保姆式实操:W25Qxx Flash驱动全场景实战
本次实操适配STM32F103C8T6核心板,以工业最常用的W25Q64JV Flash芯片(8MB容量)为例,实现芯片ID读取、扇区擦除、页编程、随机读、批量掉电存储全功能开发,全程无跳步,零基础可零报错跟随完成。
硬件说明
| 外设型号 | 引脚分配 | 核心参数 | 接线说明 |
|---|---|---|---|
| W25Q64JV Flash | CS→PA4、SCK→PA5、MOSI→PA7、MISO→PA6 | 64Mbit/8MB容量,SPI模式0/3,最高时钟频率133MHz | VCC接3.3V,GND接核心板GND,WP、HOLD引脚接3.3V,严禁接5V |
| 串口USART1 | PA9(TX)、PA10(RX) | 波特率115200,8位数据、1位停止位、无校验 | 接USB-TTL模块,TX-RX交叉接线,GND共地 |
3.1 工程创建与基础配置
- 打开STM32CubeMX,点击
ACCESS TO MCU SELECTOR,搜索选择STM32F103C8T6,点击Start Project创建工程。 - 调试接口配置:点击左侧
System Core -> SYS,Debug选项选择Serial Wire,开启SWD串行调试。 - 时钟配置:点击
RCC,HSE选项选择Crystal/Ceramic Resonator(外部8MHz晶振);进入Clock Configuration选项卡,配置PLL倍频为x9,系统时钟设置为72MHz,APB2总线时钟72MHz,无红色错误提示。
3.2 SPI外设与GPIO图形化配置
- SPI1配置:
- 点击左侧
Connectivity -> SPI1,Mode选择Full-Duplex Master(全双工主机模式); - 配置参数:
- Mode:Full-Duplex Master
- Hardware NSS Signal:Disable(软件CS模式,更灵活)
- Data Size:8 Bits
- First Bit:MSB First
- Clock Polarity (CPOL):Low(CPOL=0)
- Clock Phase (CPHA):1 Edge(CPHA=0,模式0,匹配W25Qxx)
- Baud Rate Prescaler:4分频,72MHz/4=18MHz,符合W25Qxx时序要求
- CRC Calculation:Disabled
- 引脚自动映射为PA5(SPI1_SCK)、PA6(SPI1_MISO)、PA7(SPI1_MOSI),均为复用推挽输出模式。
- 点击左侧
- GPIO配置:
- PA4引脚选择
GPIO_Output,配置为推挽输出、高速、默认高电平,User Label设为SPI_CS;
- PA4引脚选择
- USART1配置:
- 点击左侧
Connectivity -> USART1,Mode选择Asynchronous; - 配置参数:Baud Rate=115200,Word Length=8 Bits,Parity=None,Stop Bits=1;
- 点击左侧
- DMA配置(SPI1高速传输):
- 点击SPI1配置界面的
DMA Settings,点击Add添加2个DMA通道:- 发送DMA:DMA Request=SPI1_TX,Channel=DMA1 Channel 3,Direction=Memory To Peripheral,Priority=High,Mode=Normal,Data Width=Byte,Memory地址自增;
- 接收DMA:DMA Request=SPI1_RX,Channel=DMA1 Channel 2,Direction=Peripheral To Memory,Priority=High,Mode=Normal,Data Width=Byte,Memory地址自增;
- 点击SPI1配置界面的
- NVIC配置:
- 点击左侧
System Core -> NVIC,优先级分组选择Priority Group 2; - 勾选
SPI1 global interrupt、USART1 global interrupt、DMA1 channel2 global interrupt、DMA1 channel3 global interrupt,抢占优先级均设为1。
- 点击左侧
3.3 工程代码生成与W25Qxx驱动文件移植
- 工程生成配置:进入
Project Manager,设置全英文无空格的工程名与保存路径,Toolchain/IDE选择MDK-ARM V5;进入Code Generator,勾选Generate peripheral initialization as a pair of '.c/.h' files per peripheral和Keep User Code when re-generating,点击GENERATE CODE生成工程,完成后点击Open Project打开Keil5工程。 - 驱动文件移植:
- 在工程中新建
w25qxx.c、w25qxx.h两个文件,添加到工程中; - 所有驱动代码均适配STM32F103C8T6与HAL库,可直接复制使用,核心代码见下文。
- 在工程中新建
w25qxx.h头文件
c
#ifndef __W25QXX_H
#define __W25QXX_H
#include "stm32f1xx_hal.h"
#include "spi.h"
#include <string.h>
// W25Qxx指令定义
#define W25QXX_CMD_WRITE_ENABLE 0x06 // 写使能
#define W25QXX_CMD_WRITE_DISABLE 0x04 // 写禁用
#define W25QXX_CMD_READ_STATUS1 0x05 // 读状态寄存器1
#define W25QXX_CMD_WRITE_STATUS1 0x01 // 写状态寄存器1
#define W25QXX_CMD_PAGE_PROGRAM 0x02 // 页编程
#define W25QXX_CMD_SECTOR_ERASE 0x20 // 扇区擦除(4KB)
#define W25QXX_CMD_BLOCK_ERASE 0xD8 // 块擦除(64KB)
#define W25QXX_CMD_CHIP_ERASE 0xC7 // 全片擦除
#define W25QXX_CMD_READ_DATA 0x03 // 读数据
#define W25QXX_CMD_FAST_READ 0x0B // 快速读数据
#define W25QXX_CMD_READ_MANU_ID 0x90 // 读厂商ID
#define W25QXX_CMD_READ_JEDEC_ID 0x9F // 读JEDEC ID
// W25Qxx芯片型号定义
#define W25Q64 0XEF17 // W25Q64,8MB容量
#define W25Q128 0XEF18 // W25Q128,16MB容量
// CS引脚操作宏定义
#define W25QXX_CS_LOW() HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET)
#define W25QXX_CS_HIGH() HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET)
// 函数声明
uint16_t W25Qxx_Read_ID(void);
uint32_t W25Qxx_Read_JEDEC_ID(void);
void W25Qxx_Wait_Busy(void);
void W25Qxx_Write_Enable(void);
void W25Qxx_Sector_Erase(uint32_t sector_addr);
void W25Qxx_Block_Erase(uint32_t block_addr);
void W25Qxx_Chip_Erase(void);
void W25Qxx_Page_Write(uint32_t addr, uint8_t *buf, uint16_t len);
void W25Qxx_Read_Data(uint32_t addr, uint8_t *buf, uint16_t len);
void W25Qxx_Write_Data(uint32_t addr, uint8_t *buf, uint32_t len);
#endif
w25qxx.c驱动文件
c
#include "w25qxx.h"
// 芯片ID缓存
static uint16_t w25qxx_id = 0;
// 读取厂商ID与设备ID
uint16_t W25Qxx_Read_ID(void)
{
uint8_t tx_buf[4] = {W25QXX_CMD_READ_MANU_ID, 0x00, 0x00, 0x00};
uint8_t rx_buf[4] = {0};
W25QXX_CS_LOW();
HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, 4, 100);
W25QXX_CS_HIGH();
w25qxx_id = (rx_buf[2] << 8) | rx_buf[3];
return w25qxx_id;
}
// 读取JEDEC ID
uint32_t W25Qxx_Read_JEDEC_ID(void)
{
uint8_t tx_buf[4] = {W25QXX_CMD_READ_JEDEC_ID, 0xFF, 0xFF, 0xFF};
uint8_t rx_buf[4] = {0};
W25QXX_CS_LOW();
HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, 4, 100);
W25QXX_CS_HIGH();
return (rx_buf[1] << 16) | (rx_buf[2] << 8) | rx_buf[3];
}
// 等待芯片忙状态结束
void W25Qxx_Wait_Busy(void)
{
uint8_t status = 0;
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, (uint8_t *)&W25QXX_CMD_READ_STATUS1, 1, 100);
do
{
HAL_SPI_Receive(&hspi1, &status, 1, 100);
} while(status & 0x01); // 位0为1表示忙
W25QXX_CS_HIGH();
}
// 写使能
void W25Qxx_Write_Enable(void)
{
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, (uint8_t *)&W25QXX_CMD_WRITE_ENABLE, 1, 100);
W25QXX_CS_HIGH();
}
// 扇区擦除,4KB/扇区,addr为扇区首地址
void W25Qxx_Sector_Erase(uint32_t sector_addr)
{
sector_addr *= 4096; // 转换为字节地址
W25Qxx_Write_Enable();
W25Qxx_Wait_Busy();
uint8_t tx_buf[4] = {W25QXX_CMD_SECTOR_ERASE, (sector_addr >> 16) & 0xFF, (sector_addr >> 8) & 0xFF, sector_addr & 0xFF};
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, tx_buf, 4, 100);
W25QXX_CS_HIGH();
W25Qxx_Wait_Busy(); // 等待擦除完成
}
// 块擦除,64KB/块
void W25Qxx_Block_Erase(uint32_t block_addr)
{
block_addr *= 65536;
W25Qxx_Write_Enable();
W25Qxx_Wait_Busy();
uint8_t tx_buf[4] = {W25QXX_CMD_BLOCK_ERASE, (block_addr >> 16) & 0xFF, (block_addr >> 8) & 0xFF, block_addr & 0xFF};
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, tx_buf, 4, 100);
W25QXX_CS_HIGH();
W25Qxx_Wait_Busy();
}
// 全片擦除
void W25Qxx_Chip_Erase(void)
{
W25Qxx_Write_Enable();
W25Qxx_Wait_Busy();
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, (uint8_t *)&W25QXX_CMD_CHIP_ERASE, 1, 100);
W25QXX_CS_HIGH();
W25Qxx_Wait_Busy(); // 全片擦除耗时约10秒
}
// 页编程,最多256字节/页,不能跨页
void W25Qxx_Page_Write(uint32_t addr, uint8_t *buf, uint16_t len)
{
if(len > 256) len = 256;
W25Qxx_Write_Enable();
W25Qxx_Wait_Busy();
uint8_t tx_buf[4] = {W25QXX_CMD_PAGE_PROGRAM, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF};
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, tx_buf, 4, 100);
HAL_SPI_Transmit(&hspi1, buf, len, 100);
W25QXX_CS_HIGH();
W25Qxx_Wait_Busy();
}
// 读数据,无长度限制
void W25Qxx_Read_Data(uint32_t addr, uint8_t *buf, uint16_t len)
{
uint8_t tx_buf[4] = {W25QXX_CMD_READ_DATA, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF};
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, tx_buf, 4, 100);
HAL_SPI_Receive(&hspi1, buf, len, 100);
W25QXX_CS_HIGH();
}
// 任意地址写数据,自动处理跨页、擦除
void W25Qxx_Write_Data(uint32_t addr, uint8_t *buf, uint32_t len)
{
uint32_t page_remain = 256 - (addr % 256); // 当前页剩余字节数
if(len <= page_remain) page_remain = len;
while(1)
{
W25Qxx_Page_Write(addr, buf, page_remain);
if(page_remain == len) break; // 写入完成
buf += page_remain;
addr += page_remain;
len -= page_remain;
page_remain = (len > 256) ? 256 : len;
}
}
3.4 Flash读写擦除全功能业务代码编写
在main.c文件中编写业务代码,所有代码必须写在用户代码区,避免重新生成时被覆盖。
c
/* USER CODE BEGIN Includes */
#include "w25qxx.h"
#include <stdio.h>
/* USER CODE END Includes */
/* USER CODE BEGIN PV */
// 重定向printf到USART1
int fputc(int ch, FILE *f)
{
HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF);
return ch;
}
// 测试数据
uint8_t write_buf[256] = "STM32 SPI W25Qxx Flash Test! 掉电存储测试数据";
uint8_t read_buf[256] = {0};
// 掉电存储结构体
typedef struct {
uint32_t boot_count; // 开机次数
float param1; // 校准参数1
float param2; // 校准参数2
char device_name[16];// 设备名称
} Device_Param_Typedef;
Device_Param_Typedef dev_param = {0, 1.25f, 3.78f, "STM32_Device"};
Device_Param_Typedef read_param = {0};
/* USER CODE END PV */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_SPI1_Init();
MX_USART1_UART_Init();
/* USER CODE BEGIN 2 */
printf("===== STM32 SPI W25Qxx Test Start =====\r\n");
// ===================== 1. 读取芯片ID =====================
uint16_t chip_id = W25Qxx_Read_ID();
uint32_t jedec_id = W25Qxx_Read_JEDEC_ID();
printf("Chip ID: 0x%04X\r\n", chip_id);
printf("JEDEC ID: 0x%06lX\r\n", jedec_id);
if(chip_id == W25Q64)
printf("Chip Model: W25Q64JV, 8MB Capacity\r\n");
else
printf("Chip Not Recognized!\r\n");
// ===================== 2. 扇区擦除 =====================
printf("Start Erase Sector 0...\r\n");
W25Qxx_Sector_Erase(0); // 擦除第0扇区,地址0x00000000
printf("Sector Erase Complete!\r\n");
// ===================== 3. 写入数据 =====================
printf("Write Data: %s\r\n", write_buf);
W25Qxx_Write_Data(0x00000000, write_buf, sizeof(write_buf));
printf("Write Complete!\r\n");
// ===================== 4. 读取数据 =====================
W25Qxx_Read_Data(0x00000000, read_buf, sizeof(read_buf));
printf("Read Data: %s\r\n", read_buf);
// ===================== 5. 数据校验 =====================
if(memcmp(write_buf, read_buf, sizeof(write_buf)) == 0)
printf("Data Read/Write Test Success!\r\n");
else
printf("Data Read/Write Test Failed!\r\n");
// ===================== 6. 结构体掉电存储 =====================
printf("===== Device Param Storage Test =====\r\n");
// 读取开机次数,累加后写入
W25Qxx_Read_Data(0x00001000, (uint8_t *)&read_param, sizeof(Device_Param_Typedef));
dev_param.boot_count = read_param.boot_count + 1;
printf("Boot Count: %ld\r\n", dev_param.boot_count);
// 擦除扇区1,写入新参数
W25Qxx_Sector_Erase(1);
W25Qxx_Write_Data(0x00001000, (uint8_t *)&dev_param, sizeof(Device_Param_Typedef));
printf("Device Param Write Complete!\r\n");
/* USER CODE END 2 */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
HAL_Delay(1000);
}
/* USER CODE END 3 */
}
3.5 编译、烧录与效果验证
- 点击Keil顶部Build按钮(F7快捷键)编译工程,底部提示
0 Error(s), 0 Warning(s),说明编译成功。 - 仿真器配置:点击魔法棒图标(Alt+F7快捷键),Debug选项卡选择
ST-Link Debugger,Settings中确认Port为SW,能识别到芯片;Flash Download选项卡勾选Reset and Run,点击OK保存。 - 硬件接线核心注意事项:
- W25Qxx芯片必须接3.3V供电,严禁接5V,否则会烧毁芯片;
- WP、HOLD引脚必须接3.3V,禁止悬空,否则会出现通信异常;
- 所有外设GND必须与核心板GND可靠共地,保证电平基准一致;
- SPI接线严格对应:CS→PA4、SCK→PA5、MOSI→PA7、MISO→PA6,严禁接反。
- 效果验证:
- 打开串口助手,配置115200波特率、8位数据、1位停止位、无校验,打开串口;
- 核心板上电后,串口打印芯片ID、JEDEC ID,识别W25Q64芯片;
- 完成扇区擦除、数据写入、读取校验,打印写入与读取的数据,测试成功;
- 打印开机次数,每次重启核心板,开机次数自动累加,实现掉电不丢失存储;
- 断电重启核心板,重新读取写入的数据,与写入内容完全一致,验证掉电存储功能正常。
四、保姆式排错指南
| 异常现象/报错信息 | 核心根因 | 一步到位解决方法 |
|---|---|---|
| SPI通信无响应,读取芯片ID全是0xFF | 1. 接线错误,SCK/MOSI/MISO接反;2. CS引脚未拉低,未选中芯片;3. 工作模式不匹配,CPOL/CPHA与从机不一致;4. 芯片未供电、未共地,电平基准错误;5. SPI外设未使能,时钟配置错误 | 1. 严格核对接线:SCK→SCK、MOSI→MOSI、MISO→MISO,严禁接反;2. 通信前必须拉低CS引脚,全程保持低电平,通信结束后拉高;3. 查阅从机手册,严格匹配CPOL/CPHA参数,模式0是绝大多数外设的默认配置;4. 确认芯片3.3V供电正常,GND与核心板可靠共地;5. 检查CubeMX配置,确认SPI外设已使能,时钟分频配置正确 |
| Flash写入数据后,读取的数据与写入不一致,只有0能正确写入 | 1. 写入数据前未执行擦除操作,Flash只能将1改写为0,不能将0改写为1;2. 写入后未等待忙状态结束,就发起读操作;3. 未开启写使能,写入操作未生效;4. 写入数据跨页,地址回卷覆盖 | 1. Flash写入前必须先擦除对应扇区,擦除后所有位为1,才能写入数据;2. 每次写入/擦除操作后,必须调用W25Qxx_Wait_Busy()等待芯片操作完成;3. 写入前必须调用W25Qxx_Write_Enable()开启写使能;4. 页写入最多256字节,不能跨页,跨页需分多次写入 |
| SPI通信数据错位,高低位颠倒,接收数据异常 | 1. 位序配置错误,配置为LSB低位在前,而从机要求MSB高位在前;2. 时钟分频过高,超过从机最大支持频率;3. 总线走线过长,信号畸变,无终端匹配电阻;4. 通信期间CS引脚电平波动,被意外拉高 | 1. 配置SPI为MSB First高位在前,与从机位序一致;2. 降低SPI波特率分频,如改为8分频/16分频,匹配从机最大时钟频率;3. 缩短总线走线长度,SCK/MOSI/MISO线串联33Ω终端匹配电阻;4. 确保CS引脚配置为推挽输出,通信全程保持稳定低电平,无干扰 |
| DMA模式SPI传输卡死,程序进入HardFault | 1. 传输数组定义为局部变量,栈空间溢出;2. 传输长度与数组长度不匹配,内存越界;3. CS引脚控制错误,DMA传输期间未拉低CS;4. DMA传输完成后未等待SPI总线空闲,提前拉高CS | 1. 传输用的数组必须定义为全局变量,禁止使用函数内的局部数组;2. 严格核对传输长度,不得超过数组的最大长度,避免内存越界;3. DMA传输前必须拉低CS引脚,传输完成回调中再拉高;4. DMA传输完成后,等待SPI的BSY标志位清零,确认总线空闲后再拉高CS |
| 多从机SPI总线,只有一个设备能正常通信,其他设备无响应 | 1. 多个从机共用同一个CS引脚,同时被选中,总线冲突;2. 未使用的从机CS引脚未拉高,一直处于选中状态,干扰总线;3. 多个从机的MISO引脚同时输出,总线电平冲突;4. SPI工作模式与部分从机不匹配 | 1. 每个从机必须使用独立的CS引脚,同一时间仅拉低一个从机的CS引脚;2. 所有从机的CS引脚默认拉高,仅通信时拉低对应从机的CS;3. 从机MISO引脚为开漏输出,需外接上拉电阻,避免多设备输出冲突;4. 确保所有从机的SPI工作模式一致,或切换模式后再与对应从机通信 |
| SPI通信偶尔丢包,数据时对时错,稳定性差 | 1. 未接共地,电平基准波动;2. 电源噪声大,无滤波电容;3. SCK时钟频率过高,信号完整性差;4. 未处理SPI状态标志位,读写时序错误 | 1. 所有从机与主机必须可靠共地,避免电平漂移;2. 芯片VCC引脚就近接100nF滤波电容到GND,滤除电源噪声;3. 降低SPI时钟频率,提升通信稳定性;4. 严格遵循SPI读写时序,等待TXE/RXNE标志位后再读写数据寄存器 |
五、我的踩坑记录
-
踩坑现象 :第一次调试W25Qxx,读取芯片ID全是0xFF,SPI通信完全无响应,换了软件模拟SPI就正常,硬件SPI一直不行。
底层原因 :我在CubeMX里配置SPI时,CPHA选成了2 Edge,CPOL是Low,相当于模式1,而W25Qxx默认支持模式0和模式3,模式不匹配,从机无法正确采样数据,自然不会响应。同时我忘记了SPI通信前必须拉低CS引脚,直接发起了传输,从机根本没被选中,51单片机软件模拟时我是先拉低CS的,换了硬件SPI反而忘了这个核心步骤。
最终解决方案:将SPI配置改为模式0(CPOL=Low,CPHA=1 Edge),严格匹配W25Qxx的时序要求,同时在每次SPI通信前拉低CS引脚,通信结束后拉高,重新烧录后,芯片ID读取正常,通信完全稳定。 -
踩坑现象 :向Flash写入数据后,读取出来的数据大部分是错的,只有0的位置是对的,1的位置全变成了0,写入0x55,读出来是0x44。
底层原因 :我完全忽略了Flash的写入特性:Flash的存储单元只能将1改写为0,不能将0改写为1,写入前必须先擦除对应扇区,擦除后所有位都是1,才能正常写入数据。我直接向未擦除的扇区写入数据,原本是0的位无法改写为1,导致写入的数据完全错乱。51单片机用的AT24C02 EEPROM是字节改写,无需擦除,我照搬了这个逻辑,完全没考虑Flash的擦除特性,踩了大坑。
最终解决方案:在写入数据前,先擦除对应地址的扇区,确保所有位为1后再执行写入操作,修改后写入与读取的数据完全一致,无错乱。 -
踩坑现象 :页写入数据时,只要写入长度超过256字节,前256字节正常,后面的数据全是错的,甚至覆盖了前面的内容。
底层原因 :W25Qxx的页编程最大只能写入256字节,且不能跨页,一旦写入的地址超过当前页的边界,地址会自动回卷到当前页的起始地址,导致后面的数据覆盖了当前页的开头内容。我误以为连续写入可以自动跨页,直接写入了512字节数据,导致数据回卷覆盖,出现错乱。
最终解决方案:重写写入函数,增加跨页判断,计算当前页的剩余字节数,分多次页写入,每次写入不超过当前页的剩余字节数,修改后任意长度的写入都完全正常,无跨页覆盖问题。 -
踩坑现象 :SPI高速分频下(2分频36MHz),通信偶尔出错,数据时对时错,降低到8分频后就完全正常。
底层原因 :我用的杜邦线长度超过20cm,36MHz的高速信号在长线上出现了严重的信号畸变,上升沿和下降沿变缓,不满足SPI的时序要求,导致从机采样错误。同时芯片VCC引脚没有接滤波电容,电源噪声耦合到了信号线上,加剧了信号畸变。
最终解决方案:缩短杜邦线长度到10cm以内,在W25Qxx的VCC引脚就近接了一个100nF的陶瓷滤波电容到GND,同时在SCK、MOSI线上串联了33Ω的终端匹配电阻,修改后即使2分频36MHz,通信也完全稳定,无丢包、无错乱。
六、课后小练习(附完整标准答案)
6.1 基础巩固练习
练习1:实现SPI Flash在线检测函数,自动识别W25Qxx芯片型号与容量,串口打印。
标准答案:
c
// W25Qxx芯片型号列表
typedef struct {
uint16_t id;
char *name;
uint32_t capacity; // 单位KB
} W25Qxx_Model_Typedef;
W25Qxx_Model_Typedef w25qxx_model_list[] = {
{0xEF14, "W25Q80", 1024},
{0xEF15, "W25Q16", 2048},
{0xEF16, "W25Q32", 4096},
{0xEF17, "W25Q64", 8192},
{0xEF18, "W25Q128", 16384},
{0, NULL, 0}
};
// 芯片检测函数
void W25Qxx_Detect_Chip(void)
{
uint16_t chip_id = W25Qxx_Read_ID();
printf("Chip ID: 0x%04X\r\n", chip_id);
for(uint8_t i=0; w25qxx_model_list[i].name != NULL; i++)
{
if(w25qxx_model_list[i].id == chip_id)
{
printf("Chip Model: %s\r\n", w25qxx_model_list[i].name);
printf("Capacity: %ld KB / %ld MB\r\n", w25qxx_model_list[i].capacity, w25qxx_model_list[i].capacity/1024);
return;
}
}
printf("Unknown Chip Model!\r\n");
}
// 主函数调用
/* USER CODE BEGIN 2 */
W25Qxx_Detect_Chip();
/* USER CODE END 2 */
练习2:基于SPI Flash实现设备参数掉电存储,支持参数的读取、写入、恢复出厂设置功能。
标准答案:
c
/* USER CODE BEGIN PV */
#define PARAM_ADDR 0x00001000 // 参数存储地址
#define PARAM_SECTOR 1 // 参数存储扇区
// 设备参数结构体
typedef struct {
uint8_t valid_flag; // 有效标志,0xAA表示有效
float kp; // PID参数
float ki;
float kd;
uint16_t sample_rate; // 采样率
char device_sn[16]; // 设备序列号
} Device_Config_Typedef;
Device_Config_Typedef dev_config = {0};
// 出厂默认参数
const Device_Config_Typedef default_config = {
.valid_flag = 0xAA,
.kp = 2.5f,
.ki = 0.8f,
.kd = 0.2f,
.sample_rate = 100,
.device_sn = "STM32_001"
};
// 读取参数
uint8_t Dev_Config_Read(void)
{
W25Qxx_Read_Data(PARAM_ADDR, (uint8_t *)&dev_config, sizeof(Device_Config_Typedef));
if(dev_config.valid_flag != 0xAA)
{
printf("Config Invalid, Restore Default!\r\n");
memcpy(&dev_config, &default_config, sizeof(Device_Config_Typedef));
return 1;
}
printf("Config Read Success!\r\n");
return 0;
}
// 写入参数
void Dev_Config_Write(void)
{
dev_config.valid_flag = 0xAA;
W25Qxx_Sector_Erase(PARAM_SECTOR);
W25Qxx_Write_Data(PARAM_ADDR, (uint8_t *)&dev_config, sizeof(Device_Config_Typedef));
printf("Config Write Success!\r\n");
}
// 恢复出厂设置
void Dev_Config_Restore_Default(void)
{
memcpy(&dev_config, &default_config, sizeof(Device_Config_Typedef));
Dev_Config_Write();
printf("Restore Default Config Success!\r\n");
}
/* USER CODE END PV */
练习3:实现SPI中断模式批量读取Flash数据,非阻塞式传输,传输完成后在回调函数中进行数据校验。
标准答案:
c
/* USER CODE BEGIN PV */
#define READ_BUF_SIZE 512
uint8_t it_read_buf[READ_BUF_SIZE] = {0};
uint8_t it_read_done = 0;
/* USER CODE END PV */
/* USER CODE BEGIN 0 */
// SPI接收完成回调
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
if(hspi->Instance == SPI1)
{
it_read_done = 1;
W25QXX_CS_HIGH(); // 接收完成后拉高CS
}
}
/* USER CODE END 0 */
// 中断模式读数据函数
void W25Qxx_Read_Data_IT(uint32_t addr, uint8_t *buf, uint16_t len)
{
uint8_t tx_buf[4] = {W25QXX_CMD_READ_DATA, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF};
it_read_done = 0;
W25QXX_CS_LOW();
// 发送读命令
HAL_SPI_Transmit(&hspi1, tx_buf, 4, 100);
// 中断模式接收数据
HAL_SPI_Receive_IT(&hspi1, buf, len);
}
// 主函数调用
/* USER CODE BEGIN 2 */
W25Qxx_Read_Data_IT(0x00000000, it_read_buf, READ_BUF_SIZE);
/* USER CODE END 2 */
/* USER CODE BEGIN WHILE */
while (1)
{
if(it_read_done == 1)
{
printf("IT Read Complete!\r\n");
// 数据校验
if(memcmp(it_read_buf, write_buf, READ_BUF_SIZE) == 0)
printf("IT Read Test Success!\r\n");
else
printf("IT Read Test Failed!\r\n");
it_read_done = 0;
}
HAL_Delay(10);
}
/* USER CODE END WHILE */
6.2 进阶实战练习
练习1:基于SPI+DMA实现Flash高速批量读写,双缓冲模式实现无缝连续数据采集与存储。
标准答案:
c
/* USER CODE BEGIN PV */
#define BUF_SIZE 4096
uint8_t dma_tx_buf[BUF_SIZE] = {0};
uint8_t dma_rx_buf[BUF_SIZE] = {0};
uint8_t dma_done = 0;
uint32_t write_addr = 0;
/* USER CODE END PV */
/* USER CODE BEGIN 0 */
// DMA收发完成回调
void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi)
{
if(hspi->Instance == SPI1)
{
dma_done = 1;
W25QXX_CS_HIGH();
}
}
/* USER CODE END 0 */
// DMA模式读数据
void W25Qxx_Read_Data_DMA(uint32_t addr, uint8_t *buf, uint16_t len)
{
uint8_t tx_buf[4] = {W25QXX_CMD_FAST_READ, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF, 0x00};
dma_done = 0;
W25QXX_CS_LOW();
HAL_SPI_Transmit(&hspi1, tx_buf, 5, 100); // 快速读命令+1字节哑周期
HAL_SPI_Receive_DMA(&hspi1, buf, len);
}
// 主循环双缓冲存储
/* USER CODE BEGIN WHILE */
while (1)
{
// 模拟采集数据,填充发送缓存
for(uint32_t i=0; i<BUF_SIZE; i++)
{
dma_tx_buf[i] = (uint8_t)(i + HAL_GetTick() % 256);
}
// 擦除对应扇区
W25Qxx_Sector_Erase(write_addr / 4096);
// 写入数据
W25Qxx_Write_Data(write_addr, dma_tx_buf, BUF_SIZE);
printf("Write Data To Address: 0x%08lX\r\n", write_addr);
// DMA读取数据
W25Qxx_Read_Data_DMA(write_addr, dma_rx_buf, BUF_SIZE);
// 等待DMA完成
while(!dma_done);
// 数据校验
if(memcmp(dma_tx_buf, dma_rx_buf, BUF_SIZE) == 0)
printf("DMA Read/Write Success!\r\n");
else
printf("DMA Read/Write Failed!\r\n");
// 地址递增
write_addr += BUF_SIZE;
if(write_addr >= 8*1024*1024) write_addr = 0; // 8MB容量循环
HAL_Delay(1000);
}
/* USER CODE END WHILE */
练习2:实现W25Qxx Flash的循环日志存储系统,支持日志写入、读取、清空,掉电不丢失。
标准答案:
c
/* USER CODE BEGIN PV */
#define LOG_START_ADDR 0x00100000 // 日志存储起始地址,1MB偏移
#define LOG_SECTOR_SIZE 4096
#define LOG_ENTRY_SIZE 64
#define LOG_MAX_ENTRY 1024 // 最多1024条日志
#define LOG_MAGIC 0x5A5A // 日志有效标志
// 日志条目结构体
typedef struct {
uint16_t magic; // 有效标志
uint32_t timestamp; // 时间戳
uint8_t level; // 日志等级
char content[56]; // 日志内容
} Log_Entry_Typedef;
Log_Entry_Typedef log_entry = {0};
uint32_t log_count = 0;
/* USER CODE END PV */
// 查找下一个空的日志条目地址
uint32_t Log_Find_Empty_Entry(void)
{
Log_Entry_Typedef temp_entry;
for(uint32_t i=0; i<LOG_MAX_ENTRY; i++)
{
uint32_t addr = LOG_START_ADDR + i * LOG_ENTRY_SIZE;
W25Qxx_Read_Data(addr, (uint8_t *)&temp_entry, LOG_ENTRY_SIZE);
if(temp_entry.magic != LOG_MAGIC)
{
log_count = i;
return addr;
}
}
return 0xFFFFFFFF; // 日志满
}
// 写入一条日志
uint8_t Log_Write(uint8_t level, char *content)
{
uint32_t addr = Log_Find_Empty_Entry();
if(addr == 0xFFFFFFFF) return 1; // 日志满
// 填充日志条目
log_entry.magic = LOG_MAGIC;
log_entry.timestamp = HAL_GetTick() / 1000;
log_entry.level = level;
strncpy(log_entry.content, content, 55);
// 写入日志
W25Qxx_Write_Data(addr, (uint8_t *)&log_entry, LOG_ENTRY_SIZE);
log_count++;
printf("Log Write Success, Total Log: %ld\r\n", log_count);
return 0;
}
// 读取指定序号的日志
uint8_t Log_Read(uint32_t index, Log_Entry_Typedef *entry)
{
if(index >= log_count) return 1;
uint32_t addr = LOG_START_ADDR + index * LOG_ENTRY_SIZE;
W25Qxx_Read_Data(addr, (uint8_t *)entry, LOG_ENTRY_SIZE);
if(entry->magic != LOG_MAGIC) return 1;
return 0;
}
// 清空所有日志
void Log_Clear_All(void)
{
printf("Start Clear All Log...\r\n");
// 擦除日志区域所有扇区
uint32_t sector_start = LOG_START_ADDR / LOG_SECTOR_SIZE;
uint32_t sector_end = (LOG_START_ADDR + LOG_MAX_ENTRY * LOG_ENTRY_SIZE) / LOG_SECTOR_SIZE;
for(uint32_t i=sector_start; i<=sector_end; i++)
{
W25Qxx_Sector_Erase(i);
}
log_count = 0;
printf("Log Clear Complete!\r\n");
}
七、核心知识点速记
- SPI是四线全双工同步串行总线,SCK为同步时钟,MOSI主发从收,MISO主收从发,CS片选,通信前必须拉低CS,结束后拉高。
- SPI 4种工作模式由CPOL(时钟极性)和CPHA(时钟相位)决定,工业最常用模式0(CPOL=0、CPHA=0),主机模式必须与从机完全一致,否则通信异常。
- SPI全双工通信的核心是同步移位寄存器,每一个时钟周期,主机与从机同时发送和接收1位数据,8个时钟周期完成1字节的收发交换。
- W25Qxx Flash写入前必须先擦除,最小擦除单位是4KB扇区,页编程最大256字节,不能跨页,只能将1改写为0,不能将0改写为1。
- HAL库SPI核心API:
HAL_SPI_TransmitReceive()全双工阻塞收发,工业开发最常用;高速大批量数据传输使用DMA模式,实现零CPU占用。 - SPI多从机架构必须使用独立CS引脚,同一时间仅拉低一个从机的CS,避免总线冲突,未使用的从机CS必须保持高电平。
- SPI最高速率:SPI1挂载APB2总线,最高36MHz(72MHz主频2分频),SPI2/SPI3挂载APB1总线,最高18MHz。
- SPI无硬件应答机制,通信可靠性由软件协议保证,需通过芯片ID、校验和等方式验证通信的正确性。
- SPI通信异常头号排查顺序:CS引脚时序→CPOL/CPHA模式匹配→接线与共地→时钟频率→电源滤波。
八、本章小结
本章我们深入拆解了SPI串行通信协议的底层时序与4种工作模式,对比了51单片机软件模拟与STM32硬件SPI的核心差异,掌握了硬件SPI的寄存器级原理与HAL库封装逻辑,完成了W25Qxx系列Flash芯片的全功能驱动开发,实现了大容量数据掉电存储、循环日志系统等工业级实战,解决了通信无响应、数据错乱、读写失败等高频问题。SPI是高速外设的核心通信总线,下一章我们将学习CAN总线通信,掌握工业现场最常用的差分串行总线的底层原理与双机通信实战,适配工业控制场景的高可靠性通信需求。