第九课时:SPI 使用方法
本课主题:SPI 使用方法,实战部分包括与 FLASH 相连、按键消抖,以及用 FLASH 做断电保护。
课程视频:STM32 HAL库(SPI 按钮实验)
一、与 FLASH 相连的实战演练
这一课的目标是,用一块 FLASH 把断电前的状态保存下来,下次上电还能恢复。

图左边是这次要用的硬件:PA0 接一个按键到地,PC13 是板载 LED,用开漏输出;右边是面包板上的实际接线,按键、LED、FLASH 模块和 ST-Link 都接在板子上。
1.1 检测按钮按下的瞬间(轮询)
这里要捕捉的是按钮按下的那一瞬间,而不是按住没按住。做法是声明两个变量,PRE 和 CUR,一个存上一刻的值,一个存当前的值,通过比较这两个值来判断按钮的状态。这种方式叫轮询。

代码里先 pre = cur,再读当前的引脚值给 cur,然后比较这两个值:从 1 变 0 就是按下,从 0 变 1 就是松开。图右边把按下和松开的两个瞬间标了出来。

按下是 1 变 0 的下降沿,松开是 0 变 1 的上升沿,把这个跳变抓出来,就能做到按一下触发一次。

检测到按下之后,用 ledState 记一个标记,确认灯是亮着还是灭着,然后写引脚、翻转状态。这里改成开漏模式的通用写法,也就是写 0 亮、写 1 灭,逻辑更顺一些。
还有一个教训要记住,以后遇到问题,先看看硬件有没有接错。
1.2 按键消抖
接下来是按键消抖,这是很重要的一部分,要学好。

机械按键在按下和松开的一瞬间,触点会弹跳,电平并不是干净地一跳到底,而是会抖动出一串毛刺。如果不处理,一次按下可能被误判成好几次。常见的做法是在检测到变化后,用 HAL_Delay 等一小会儿再确认,也就是在每个关键部分延迟一下,产生消抖的效果。
不过这种阻塞式的 HAL_Delay 会让 CPU 干等,感觉不太好,下面单独说。
1.3 疑问解答
问 1:这种方式叫什么名字,有什么好处,和前面学的按钮输入输出有什么区别,哪个更好?
它属于轮询,其中拿上一刻的值和当前的值比较、找出变化这一步,叫边沿检测,也叫状态变化检测。比如从 1 变 0 就是下降沿,也就是按下的瞬间。
好处是,实现简单,两个变量加一次比较就够了;而且能抓到你按下的那一瞬间,而不只是现在按住了没有,所以可以做到按一下切换一次,亮、灭、亮这样循环。
和前面学的按钮输入输出的区别在于:前面第三课时、第五课时是读电平,HAL_GPIO_ReadPin(PA9) 看现在有没有按下,按住就一直亮,属于按住才有效;现在是看变化,只关心上一刻到这一刻的跳变,属于按一下触发一次,松手不影响。所以区别就是按住才生效和按一下切换,看你想要哪种效果。
哪个更好,看场景。轮询简单直观,但要 CPU 一直去查,用 HAL_Delay 消抖还会阻塞,也就是下面要担心的点。中断,也就是 EXTI 加中断服务函数,平时不占 CPU,有按键才响应,更专业,但要处理中断和消抖,代码稍复杂。结论是,入门和简单场合用轮询,要求响应及时、不想占 CPU 就用中断,课程后面会讲中断。
问 2:消抖用 HAL_Delay 这种阻塞式,感觉不太好?
这个直觉是对的。HAL_Delay 是阻塞延时,延时这段时间 CPU 就在干等着,别的活,比如读传感器、刷屏,都干不了。更好的做法由易到难有三种:一是缩短阻塞,只在刚检测到变化时延时十几毫秒再确认,其余时间不延时,能缓解但还是会小卡;二是用定时器做非阻塞消抖,定时中断里每一毫秒采样一次按键,连续两三次一致才算稳定,这是主流做法,讲定时器时就能实现;三是硬件消抖,按键两端加 RC 滤波,也就是并个小电容,再加施密特触发器,从根上减少抖动。
一句话,现在用 HAL_Delay 是为了好理解,真正做项目要换成定时器或中断的非阻塞消抖。
二、在实际的项目中是怎么实现的 FLASH
先看接线。

这是 W25QXX 系列 FLASH 模块,引出 VCC、CS、DO、CLK、GND、DI 几个引脚。

把它和 STM32 对应起来:模块的 DI 是主机输出、从机输入,也就是 MOSI;DO 是主机输入、从机输出,也就是 MISO;CLK 是串行时钟,也就是 SCK;CS 是片选,也就是从机选择。
接着在 CubeMX 里配置 SPI,先是模式。

SPI 的模式有全双工主机、全双工从机、半双工主机、半双工从机、只收主机、只收从机、只发主机、只发从机这么多。其中半双工是双向但不同时。

图里把这几种模式画了出来。全双工主机是两条数据线同时收发;半双工是双向但不同时;还有只发送和只接收两种。通常选全双工主机。
然后是硬件 NSS 信号这一项。

Disable 是不使用硬件 NSS,Hardware NSS Input Signal 是用硬件 NSS 且作为输入,Hardware NSS Output Signal 是用硬件 NSS 且作为输出。
对应视频位置:05:32

这里选 Disable,也就是不用硬件 NSS。

配置完成后自动分配好了三个引脚:PA5 是 SPI1_SCK,PA6 是 SPI1_MISO,PA7 是 SPI1_MOSI。
那 NSS 呢,它本来是从机那边的信号,所以主机这里不设置硬件 NSS,作为从机的 FLASH 模块需要自己处理。这里另外把 PA4 单独配成普通 GPIO,初值 High、推挽、最大速度 High,为什么要这样配,放在下面 2.1 里说。
接下来配置 SPI 的参数。

参数分四组:基本参数、数据帧格式、时钟参数、高级参数。
先看数据帧格式。不同情况不一样,但通常都会选摩托罗拉格式。

图里对比了几种格式。摩托罗拉是标准 SPI 总线,用 MOSI、MISO,没有帧同步;TI 带帧同步信号;Microwire 是简化版,用 SI、SO;Dual SPI 用 I/O0、I/O1,两倍带宽;Quad SPI 用 I/O0 到 I/O3,四倍带宽。日常外设几乎都用摩托罗拉格式。
再看数据位长度和位序。

数据位长度分 8 位和 16 位,8 位就是每次传一个字节;位序分 MSB First 和 LSB First,前者先传最高有效位。所以这里选 8 Bit、MSB。
W25Q 的手册里给了读状态寄存器的时序图,上面同时标了模式 0 和模式 3,说明这两种时钟模式它都支持,我们这里用模式 0。

接着看波特率,它是通过分频系数改出来的。分频系数是 2 的幂,取 2、4、8、16、32、64、128、256。

波特率和时钟树里的 Clock Configuration 关系很大。公式是,SCK 频率等于该 SPI 所在总线的时钟除以分频系数。SPI1 挂在 APB2 上,PCLK2 是 72MHz;SPI2、SPI3 挂在 APB1 上,PCLK1 是 36MHz。比如 SPI1 用 72MHz 除以 4 得 18MHz,除以 8 得 9MHz,除以 256 得 281.25kHz。所以波特率不是随便填的,而是由总线时钟除出来的。

再下面是时钟的极性,CPOL,也就是空闲状态下时钟的极性。

最后是时钟的相位,也就是边沿采集。第一边沿采集和第二边沿采集的区别,前面讲 SPI 总线结构时说过。

Advanced Parameters 里还有 CRC 校验和 NSS 类型,这两个用不到。
2.1 疑问解答
问 1:半双工是双向但不同时,I2C 是不是也这样?
对。半双工就是两个方向都能传,但同一时刻只能一个方向。I2C 正是如此,只有一根数据线 SDA,同一时刻要么主机写、要么主机读,不能同时,所以 I2C 是半双工。对照一下,全双工是两个方向同时传,比如 SPI 的 MOSI 和 MISO,还有 UART 的 TX 和 RX。
问 2:PA4 为什么设置成 High 初值加推挽、High 速度?
PA4 就是 SPI1 的 NSS,也就是片选引脚,这里把它当普通 GPIO 输出用,也就是软件片选。初值 High,是因为片选低电平有效,写 0 才选中从机,所以空闲时必须是高电平,保证 FLASH 不被误选中,这就是初始化就拉高的原因。推挽,是因为它是普通输出脚,需要能主动输出高和低,推挽最合适,开漏还得外加上拉。最大速度选 High,是因为片选要跟着通信快速翻转,高速切换更利索。一句话,NSS 空闲必须为高,所以初值 High,推挽保证能主动拉高拉低。
问 3:波特率是怎么用分频系数算出来的?
公式是 SCK 频率等于该 SPI 所在总线的时钟除以分频系数。SPI1 挂在 APB2,PCLK2 是 72MHz;SPI2、SPI3 挂在 APB1,PCLK1 是 36MHz。分频系数是 2 的幂,2、4、8、16、32、64、128、256。比如 SPI1 用 72MHz 除以 4 得 18MHz,除以 8 得 9MHz,除以 256 得 281.25kHz。所以波特率是由总线时钟除出来的,这也解释了它为什么和时钟树里的 PCLK 有关系。
补充一下数据帧格式里的摩托罗拉和 TI。CubeMX 的 Frame Format 有两项,Motorola 和 TI。日常外设,比如 Flash、传感器,几乎都用 Motorola 格式,照它选就对了;TI 是一种用 NSS 脉冲的特殊模式,一般用不到。
三、FLASH 的数据存取实战编码
本课重点讲 SPI 的数据读写,Flash 模块只做简单介绍,更多内容见进阶阶段。

SPI 的本质是交换数据,用主机发送的数据去跟从机的数据交换。所以主机发送数据的时候,从机也把数据发给了主机,这才有交换、接收这类函数。

HAL 里一共三个函数:HAL_SPI_Transmit 发送,HAL_SPI_Receive 接收,HAL_SPI_TransmitReceive 发送的同时接收。
3.1 三个 SPI 函数怎么用(配 NSS)
在每次 SPI 传输之前,要先把 NSS 拉低,告诉从机开始传输,结束后再拉高。
图片
发送用 HAL_SPI_Transmit:
c
uint8_t dataToSend[] = {0x5a, 0x33}; // 要发送的数据
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 选中从机1(拉低 NSS)
HAL_SPI_Transmit(&hspi1, // SPI 句柄的指针
dataToSend, // 要发送的数据
2, // 要发送数据的数量(字节)
HAL_MAX_DELAY); // 超时时间
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 取消选中(拉高 NSS)
接收的写法一样,只是换个函数。

c
uint8_t dataRcvd[] = {0xff, 0xff}; // 接收缓冲区
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 选中从机1
HAL_SPI_Receive(&hspi1, // SPI 句柄的指针
dataRcvd, // 接收缓冲区的指针
2, // 要接收数据的数量(字节)
HAL_MAX_DELAY); // 超时时间
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 取消选中
收的时候 MOSI 上会发 0xff 这种无效数据,因为你收一位的同时必须发一位,这就是交换。

发送的同时接收,用 HAL_SPI_TransmitReceive,它的参数是 hspi 句柄指针、pTxData 要发送的数据、pRxData 接收缓冲区、Size 数量、Timeout 超时。

c
uint8_t txData[] = {0x5a, 0x33}; // 要发送的数据
uint8_t rxData[2]; // 接收缓冲区
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 选中从机1
HAL_SPI_TransmitReceive(&hspi1, // SPI 句柄的指针
txData, // 要发送的数据
rxData, // 接收缓冲区
2, // 数量(发送 = 接收)
HAL_MAX_DELAY); // 超时时间
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 取消选中
三个函数的参数对照:
| 函数 | 参数说明 |
|---|---|
| HAL_SPI_Transmit | hspi 填 SPI 句柄的指针;pData 填要发送的数据;Size 是要发送的数量(字节);Timeout 是超时时间,单位 ms,HAL_MAX_DELAY 表示无限长 |
| HAL_SPI_Receive | hspi 填 SPI 句柄的指针;pData 填接收缓冲区;Size 是要接收的数量(字节);Timeout 同上 |
| HAL_SPI_TransmitReceive | hspi 同上;pTxData 是要发送的数据;pRxData 是接收缓冲区;Size 是发送数据的数量,等于接收数据的数量;Timeout 同上 |
这里发送和接收缓冲区都要先赋好初始值,否则会出现故障。

3.2 W25Q64 与写入流程

这次的 FLASH 型号是 W25Q64,容量 64MBit,也就是 8MB,地址范围 0x000000 到 0x7FFFFF。它的结构分三层:块 64K、扇区 4K、页 256B。

往 FLASH 写入数据之前必须先擦除,擦除要 100ms;而且每次擦除和编程之前,都要先写使能,也就是解锁。整个写入流程是:写使能、扇区擦除、等 100ms、写使能、页编程、等 10ms。另外要记住,擦除的最小单元是扇区 4K,写入的最小单元是页 256 字节。

自己写的函数声明,放在私有函数声明区域,也就是 USER CODE BEGIN PFP 和 USER CODE END PFP 之间。

函数体则写在 USER CODE BEGIN 0 后面,这里先把 SaveLEDState 的骨架搭出来,里面依次是写使能、扇区擦除、延迟 100ms、写使能、页编程、延迟 10ms。

扇区擦除的指令码是 0x20,第一个字节填指令码,后面跟 24 位扇区首地址,所以指令数组是 {0x20, 0x00, 0x00, 0x00}。图左边的时序图也标了出来,先是 8 位指令,再是 24 位地址。

写进代码里,就是配上 NSS 拉低、HAL_SPI_Transmit 发送这个 4 字节数组、再把 NSS 拉高。其实每一步都要重新处理一次 NSS 的拉低和拉高。

页编程的指令码是 0x02,同样先发指令,再发 24 位地址,然后跟上要写入的数据,一次可以写多个字节,最多一页 256 字节。

图里把指令码、24 位地址、要写入的数据三段都标了出来。
读数据用指令码 0x03,先发指令加 24 位地址,再接收数据。
3.3 FLASH 断电保护:完整代码
整体目标,是让板载 LED 的状态断电后不丢。按键切换 LED 时把状态写进 W25Q64,每次上电时先从 FLASH 读回来,恢复 LED。
分三步:上电时调用 LoadLEDState() 从 FLASH 读出上次保存的状态,按它点亮或熄灭 LED;按键切换时,改完 LED 后调用 SaveLEDState(ledState) 把新状态写进 FLASH;读写规矩按 W25Q64 手册,拉低 NSS、发指令码加地址、收发数据、拉高 NSS。
先在私有函数声明区域声明两个函数:
c
/* USER CODE BEGIN PFP */
static void SaveLEDState(uint8_t ledState); // 保存 LED 状态到 FLASH
static uint8_t LoadLEDState(void); // 从 FLASH 读回 LED 状态
/* USER CODE END PFP */
保存函数,顺序是写使能、扇区擦除、延时 100ms、写使能、页编程、延时 10ms:
c
static void SaveLEDState(uint8_t ledState)
{
uint8_t writeEnableCmd[] = {0x06}; // 写使能(解锁)
uint8_t sectorEraseCmd[] = {0x20, 0x00, 0x00, 0x00}; // 扇区擦除:0x20 + 24 位扇区首地址
uint8_t pageProgramCmd[] = {0x02, 0x00, 0x00, 0x00, ledState}; // 页编程:0x02 + 24 位地址 + 要写入的数据
// #1. 写使能
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 拉低 NSS,选中 FLASH
HAL_SPI_Transmit(&hspi1, writeEnableCmd, 1, HAL_MAX_DELAY);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 拉高 NSS
// #2. 扇区擦除(擦除最小单元 = 扇区 4K)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_SPI_Transmit(&hspi1, sectorEraseCmd, 4, HAL_MAX_DELAY); // 4 字节 = 1 指令 + 3 地址
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
HAL_Delay(100); // 擦除要 100ms
// #3. 写使能(擦除完成后芯片会把写使能自动清掉,编程前必须再发一次)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_SPI_Transmit(&hspi1, writeEnableCmd, 1, HAL_MAX_DELAY);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
// #4. 页编程(把 ledState 写到地址 0x000000)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
HAL_SPI_Transmit(&hspi1, pageProgramCmd, 5, HAL_MAX_DELAY); // 5 字节 = 1 指令 + 3 地址 + 1 数据
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
HAL_Delay(10); // 写入要 10ms
}
载入函数,先发 0x03 加地址,再读回一个字节:
c
static uint8_t LoadLEDState(void)
{
uint8_t readDataCmd[] = {0x03, 0x00, 0x00, 0x00}; // 读数据:0x03 + 24 位地址
uint8_t ledState; // 用来接收读出的数据
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 拉低 NSS
HAL_SPI_Transmit(&hspi1, readDataCmd, 4, HAL_MAX_DELAY); // 发送命令
HAL_SPI_Receive(&hspi1, &ledState, 1, HAL_MAX_DELAY); // 接收数据(1 字节)
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 拉高 NSS
return ledState;
}
上电时加载,放在程序开始运行之前,也就是 USER CODE BEGIN 2 里。
图片:Pasted image 20261006182448.png
c
/* USER CODE BEGIN 2 */
ledState = LoadLEDState(); // 从 Flash 加载 LED 的状态
if (ledState == 1) // ledState 等于 1 → 点亮 LED
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET);
}
else // 否则熄灭 LED
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET);
}
/* USER CODE END 2 */
按键切换时保存,结合前面的轮询加边沿检测,放在 while(1) 里:
c
/* USER CODE BEGIN 3 */
prev = cur; // 记录上一刻的值
cur = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_9); // 读当前按钮(PA9)
if (prev == 1 && cur == 0) // 检测到按下的下降沿
{
ledState = !ledState; // 状态翻转(0 ↔ 1)
if (ledState == 1)
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // 点亮
}
else
{
HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 熄灭
}
SaveLEDState(ledState); // 把新状态写进 FLASH(断电也不丢)
}
HAL_Delay(10); // 简单去抖
/* USER CODE END 3 */
代码原理小结,把每一步的原因说清楚:
| 步骤 | 为什么 |
|---|---|
| 拉低 / 拉高 NSS | 片选低电平有效,拉低是选中 FLASH,拉高是结束一次通信,一条完整命令必须在一次选中里发完 |
| 每次都要写使能 | W25Q64 出于保护,擦除和编程前必须先解锁,也就是发 0x06,否则命令被忽略;而且擦除、编程完成后芯片会自动清掉写使能,所以编程前要再发一次 |
| 先擦除再写 | Flash 只能把 1 变成 0,不能把 0 变回 1,所以要先把整块擦成全 1,再写入 |
| 擦除等 100ms、写入等 10ms | 芯片内部在真正干活,手册要求等待,不等的话后续命令会失败 |
| 地址用 24 位 | W25Q64 有 8MB 空间,0x000000 到 0x7FFFFF,3 个字节正好装下 |
| 读的时候先发 0x03 加地址,再收 | 读命令只要告诉地址,随后 MOSI 发 dummy,也就是 0xff,从机在这期间把数据从 MISO 移回来 |
这里有一个容易踩的坑。原视频截图里写的是 if(ledState = 1),把比较写成了赋值,这样条件永远为真,LED 会一直亮。正确写法是两个等号,if (ledState == 1),上面的代码已经改正。
复习速记
- SPI 的本质是交换数据,三个函数:HAL_SPI_Transmit 发、HAL_SPI_Receive 收、HAL_SPI_TransmitReceive 同时收发。
- 每次通信都要拉低 NSS 选中、收发、再拉高 NSS,NSS 接 PA4。
- 轮询加边沿检测:用 pre 和 cur 比较上一刻和这一刻,抓按下的瞬间,做到按一下切换一次;
HAL_Delay消抖是阻塞的,以后用定时器或中断更好。 - W25Q64 容量 8MB,块 64K、扇区 4K、页 256B。
- 擦除最小单位是扇区 4K,写入最小单位是页 256B。
- 指令码:0x06 写使能、0x20 扇区擦除、0x02 页编程、0x03 读数据,地址都是 24 位。
- 擦除和编程前都要先发写使能,而且芯片完成后会自动清掉,所以每次都得重发。
- 写入流程背下来:写使能、扇区擦除、等 100ms、写使能、页编程、等 10ms。
- Flash 只能 1 变 0,不能 0 变 1,所以必须先擦除再写。
- 读的时候先发 0x03 加地址,再收 1 字节,读的时候 MOSI 发 dummy。
if (x = 1)是赋值不是比较,要写==。