1. 先搞清楚 SPI 到底解决了什么问题,以及它和 UART、I2C 的根本区别
如果你刚开始接触单片机或嵌入式开发,面对 UART、I2C、SPI 这一堆通信协议,很容易一头雾水。它们都叫“通信协议”,但解决的问题和适用场景完全不同。今天我们不绕圈子,直接说 SPI 的核心价值:它是在板级短距离内,用最少的硬件开销和最简单的时序,实现全双工、高速、点对点数据交换的“硬核”方案。
简单来说,SPI 就像一个高效的“对讲机”系统。一个主设备(比如你的 STM32 单片机)可以同时和多个从设备(比如 Flash 存储器 W25Q64、显示屏 ILI9341、传感器等)对话,但它同一时刻只能和一个从设备“通话”。这个通话有几个关键特点:
- 全双工:主设备和从设备可以同时发送和接收数据,数据线是分开的,效率高。
- 同步时钟:通信节奏完全由主设备发出的时钟信号(SCLK)控制,收发双方步调一致,没有像 UART 那样的波特率误差问题。
- 硬件简单:协议本身很简单,主要靠硬件移位寄存器实现,对 CPU 干预少,容易实现高速度。
那么,它和 UART、I2C 的区别在哪?这是新手最容易混淆的地方,我画个简单的对比表,你一看就懂:
| 特性 | SPI | UART | I2C |
|---|---|---|---|
| 通信方式 | 同步(有专用时钟线) | 异步(无时钟线,靠波特率) | 同步(有专用时钟线) |
| 数据线 | 全双工(MOSI, MISO) | 全双工/半双工(TX, RX) | 半双工(SDA) |
| 拓扑结构 | 一主多从(点对点/菊花链) | 点对点或多点(需地址) | 多主多从(总线式) |
| 速度 | 高(可达数十 MHz) | 低到中(通常 < 4Mbps) | 中(标准模式 100kbps,快速模式 400kbps) |
| 硬件开销 | 较多(4 根线起) | 较少(2-3 根线) | 最少(2 根线) |
| 软件复杂度 | 低(时序简单) | 中(需处理波特率、起始/停止位) | 高(需处理仲裁、应答) |
| 典型应用 | Flash、显示屏、高速 ADC/DAC | 电脑串口、蓝牙模块、GPS 模块 | 传感器(如温湿度)、EEPROM |
所以,当你需要高速、稳定地读取一块 SPI Flash(如 W25Q64),或者驱动一块 SPI 接口的屏幕(如 ILI9341、ST7789),SPI 通常是首选。它的缺点也很明显:线多(至少4根),没有硬件级的寻址和应答机制,距离短(通常板级)。但这恰恰是它的定位:在电路板这个小世界里,用速度和简单性换距离和复杂性。
2. 拆解 SPI 的硬件连接和四种工作模式:从接线到第一个字节
理解了 SPI 的定位,我们来看怎么把它用起来。第一步不是写代码,而是把线接对,并把模式设对。这是大部分 SPI 通信失败的根源。
2.1 硬件连接:四根线是基础,片选是关键
一个最基本的 SPI 系统需要 4 根信号线:
- SCLK (Serial Clock):时钟信号,由主设备产生,是所有数据收发的节拍器。
- MOSI (Master Out Slave In):主设备输出,从设备输入。数据从主设备流向从设备。
- MISO (Master In Slave Out):主设备输入,从设备输出。数据从从设备流向主设备。
- CS/SS (Chip Select / Slave Select):片选信号,由主设备控制。低电平有效,即当主设备将某个从设备的 CS 线拉低时,表示选中该从设备进行通信。
注意:很多新手会忽略 CS 线的重要性。SPI 协议本身没有地址概念,全靠这根 CS 线来“点名”要和哪个从设备通信。如果 CS 线接错、控制时序不对,或者从设备不支持多从机模式却共享了 MISO 线,通信必定失败。
对于多个从设备,有两种接法:
- 标准接法(独立片选):每个从设备独占一组 MISO/MOSI/SCLK,但 CS 线各自独立。这是最常用、最稳定的方式。
- 菊花链接法:所有从设备的 MISO 和 MOSI 串联起来,数据像接力一样传递。这种方式节省 CS 线,但要求所有从设备都支持,且软件驱动复杂,不常见。
硬件片选 vs 软件片选:这是一个实践细节。硬件片选指使用 MCU 专用的 SPI 外设的 NSS 引脚,硬件会自动控制片选电平。软件片选则是你用任意一个 GPIO 口来模拟 CS 信号。在项目初期或从设备不多时,我强烈建议先用软件片选,因为控制更灵活,便于调试和排查问题。HAL 库的HAL_SPI_TransmitReceive等函数通常需要你手动控制 CS 脚。
2.2 四种工作模式:由 CPOL 和 CPHA 决定
这是 SPI 最核心也最容易出错的概念。SPI 没有统一的时钟相位和极性标准,因此衍生出四种模式,由两个参数决定:
- CPOL (Clock Polarity):时钟极性。0 表示空闲时 SCLK 为低电平;1 表示空闲时 SCLK 为高电平。
- CPHA (Clock Phase):时钟相位。0 表示数据在时钟的第一个边沿(上升沿或下降沿)采样;1 表示数据在时钟的第二个边沿采样。
组合起来就是四种模式:
| 模式 | CPOL | CPHA | 时钟空闲状态 | 数据采样时刻 | 数据变化时刻 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | SCLK 上升沿 | SCLK 下降沿 |
| Mode 1 | 0 | 1 | 低电平 | SCLK 下降沿 | SCLK 上升沿 |
| Mode 2 | 1 | 0 | 高电平 | SCLK 下降沿 | SCLK 上升沿 |
| Mode 3 | 1 | 1 | 高电平 | SCLK 上升沿 | SCLK 下降沿 |
怎么记?看你的从设备手册!这是铁律。比如 W25Q64 Flash 通常用 Mode 0 或 Mode 3,而一些传感器可能用 Mode 1。主从设备的模式必须完全一致,否则读到的数据全是错的。
一个简单的判断方法是看时序图。找到数据线(MOSI/MISO)和时钟线(SCLK)的关系。数据稳定的窗口必须覆盖住采样边沿。在项目初期,如果你不确定,可以先用 Mode 0 和 Mode 3 尝试,因为这两种最常用。
2.3 从时序图到第一个字节:理解数据是如何流动的
光看理论不够,我们结合 Mode 0 的时序图,看看一个字节(比如 0xAA)是怎么从主设备发到从设备,同时从设备返回一个字节(比如 0x55)的。
CS (低有效) \____________________________/ SCLK (CPOL=0) __ __ __ __ __ ___| |__| |__| |__| |__| |___... ^ ^ ^ ^ ^ ^ MOSI (主出) ___ ___ ___ ___ ...| 1 |__| 0 |__| 1 |__| 0 |... (假设发送0xAA: 1010 1010) ^ ^ ^ ^ ^ ^ MISO (主入) ___ ___ ___ ___ ...| 0 |__| 1 |__| 0 |__| 1 |... (假设接收0x55: 0101 0101)(注:上图是简化示意图,实际数据位顺序可能 MSB 或 LSB 在前)
过程解析:
- 主设备拉低 CS 线,选中从设备。
- 主设备开始产生 SCLK 时钟(空闲为低)。
- 在 SCLK 的第一个下降沿(Mode 0),主设备将第一位数据(0xAA 的最高位或最低位,取决于设置)放到 MOSI 线上。
- 在接下来的 SCLK 上升沿,从设备采样 MOSI 线上的数据位,同时,从设备也会将它的第一位数据放到 MISO 线上。
- 在同一个 SCLK 的下降沿,主设备采样 MISO 线上的数据位,并准备好下一位数据。
- 如此循环 8 次,完成一个字节的交换。注意,发送和接收是同时完成的,
HAL_SPI_TransmitReceive这个函数名就体现了这一点。 - 通信结束,主设备拉高 CS 线。
关键点:SPI 是“交换”数据,而不是单纯的“发送”或“接收”。即使你只想读从设备的数据,主设备也必须发送一些东西(通常是 dummy byte,如 0xFF 或 0x00)来产生时钟,从而把从设备的数据“挤”出来。
3. 在 STM32 上动手:从 HAL 库配置到读写 W25Q64 Flash
理论懂了,我们上实战。以 STM32 和 SPI Flash W25Q64 为例,这是最经典的组合。我们目标是:用 STM32 的硬件 SPI 读取 W25Q64 的制造商 ID 和器件 ID。
3.1 环境准备与 CubeMX 配置
硬件连接:
STM32 SPI1_MOSI (PA7)->W25Q64 DI (IO0)STM32 SPI1_MISO (PA6)->W25Q64 DO (IO1)STM32 SPI1_SCK (PA5)->W25Q64 CLK (CLK)STM32 GPIO (如 PA4)->W25Q64 CS (CS)(使用软件片选)3.3V->VCCGND->GND
CubeMX 配置:
- 打开 SPI1 外设,选择
Full-Duplex Master模式。 - 基础参数设置(必须与 W25Q64 一致):
Prescaler:先设大一点,如PCLK/256,确保初始通信稳定,后续可提速。CPOL:Low。 (对应 Mode 0 或 1)CPHA:1 Edge。 (对应 Mode 0)Data Size:8 bits。First Bit:MSB First。 (W25Q64 通常要求 MSB 在前)NSS:选择Disable或Hardware NSS Output Disable。因为我们用软件控制 GPIO 作 CS。
- 配置用于 CS 的 GPIO(PA4)为
Output Push-Pull,默认输出高电平(不选中)。 - 生成代码。
- 打开 SPI1 外设,选择
3.2 编写驱动代码:封装基础读写函数
生成的代码只是配置了硬件,我们需要自己写驱动层。首先封装几个基础函数:
// spi_flash.c #include "spi_flash.h" #include "main.h" // 包含 HAL 和 GPIO 定义 extern SPI_HandleTypeDef hspi1; // CubeMX 生成的 SPI 句柄 #define W25Q64_CS_PIN GPIO_PIN_4 #define W25Q64_CS_PORT GPIOA // 软件片选控制 static void CS_Select(void) { HAL_GPIO_WritePin(W25Q64_CS_PORT, W25Q64_CS_PIN, GPIO_PIN_RESET); // 拉低,选中 // 微小延时,确保从设备识别到片选信号,特别是高速时 for (volatile int i = 0; i < 10; i++); } static void CS_Deselect(void) { // 微小延时,确保最后一个时钟周期完成 for (volatile int i = 0; i < 10; i++); HAL_GPIO_WritePin(W25Q64_CS_PORT, W25Q64_CS_PIN, GPIO_PIN_SET); // 拉高,取消选中 } // 基础 SPI 交换一个字节 uint8_t SPI_Flash_SendByte(uint8_t byte) { uint8_t rx_data; // HAL_SPI_TransmitReceive 是阻塞式的,会等待发送完成并接收数据 if (HAL_SPI_TransmitReceive(&hspi1, &byte, &rx_data, 1, HAL_MAX_DELAY) == HAL_OK) { return rx_data; } return 0xFF; // 发送失败,返回一个默认值 } // 读取 Flash 的 ID (制造商ID + 器件ID) uint32_t W25Q64_ReadID(void) { uint32_t id = 0; uint8_t cmd = 0x90; // W25Q64 的读ID命令 uint8_t dummy_addr[3] = {0x00, 0x00, 0x00}; // 读ID命令后需要跟3个地址字节(dummy) CS_Select(); SPI_Flash_SendByte(cmd); // 发送命令 // 发送3个 dummy 地址字节 SPI_Flash_SendByte(dummy_addr[0]); SPI_Flash_SendByte(dummy_addr[1]); SPI_Flash_SendByte(dummy_addr[2]); // 接收制造商ID (通常为 0xEF) 和器件ID (对于 W25Q64,高字节为 0x40) id = (SPI_Flash_SendByte(0xFF) << 16) | (SPI_Flash_SendByte(0xFF) << 8) | SPI_Flash_SendByte(0xFF); CS_Deselect(); return id; // 返回的 ID 格式可能是 0xEF4017,具体看手册 }// spi_flash.h #ifndef __SPI_FLASH_H #define __SPI_FLASH_H #include <stdint.h> uint8_t SPI_Flash_SendByte(uint8_t byte); uint32_t W25Q64_ReadID(void); #endif3.3 验证与调试:如何判断通信成功
在主函数中调用并打印 ID:
// main.c #include "spi_flash.h" #include <stdio.h> // 如果使用 printf int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); // 初始化你的串口用于打印... uint32_t flash_id = W25Q64_ReadID(); printf("Flash ID: 0x%06lX\r\n", flash_id); // 判断:W25Q64 的 ID 通常为 0xEF4017 或类似 if ((flash_id >> 16) == 0xEF) { printf("Manufacturer: Winbond (OK)\r\n"); uint16_t device_id = flash_id & 0xFFFF; if (device_id == 0x4017) { // 具体值查 W25Q64 手册 printf("Device: W25Q64 (OK)\r\n"); } } else { printf("Communication FAILED or Wrong Device!\r\n"); } while (1) {} }如果读出的 ID 不对(比如一直是 0xFFFFFF 或 0x000000),按以下顺序排查:
- 检查硬件连接:用万用表通断档,确保四根线(SCK, MOSI, MISO, CS)和电源、地都连接正确且牢固。这是最常见的问题。
- 检查电源:确保 W25Q64 的 VCC 是 3.3V,并且稳定。
- 检查 SPI 模式:确认 CubeMX 里 SPI 的 CPOL 和 CPHA 设置与 W25Q64 要求的一致(通常是 Mode 0 或 Mode 3)。这是第二大常见问题。
- 检查片选时序:在
CS_Select()和CS_Deselect()函数里增加或减少延时,确保片选信号在时钟产生前有效,在时钟停止后失效。 - 检查字节顺序:确认
First Bit是MSB First。 - 降低时钟速度:将 SPI 预分频器
Prescaler调到最大(最低速),排除时序不稳定的问题。 - 用逻辑分析仪抓波形:这是终极武器。直接看 SCLK、MOSI、MISO、CS 四根线的实际波形,对照 W25Q64 数据手册的时序图,看命令、地址、数据是否在正确的时钟边沿上。
4. 进阶应用与避坑指南:DMA、多从机与稳定性
当基础读写成功后,你会遇到更实际的需求:如何提高速度?如何连接多个设备?如何保证长时间稳定运行?
4.1 使用 DMA 提升效率与释放 CPU
当需要读写大量数据时(比如更新显示屏整屏数据、读写 Flash 大块数据),使用阻塞式的HAL_SPI_TransmitReceive会长时间占用 CPU。此时应使用 DMA(直接存储器访问)。
CubeMX 配置 DMA:
- 在 SPI1 配置页,找到
DMA Settings。 - 点击
Add,为SPI1_TX和SPI1_RX分别添加一个 DMA 通道(如DMA1 Channel3和Channel2)。 - 模式选择
Normal(单次传输)或Circular(循环传输),方向根据需求选择。 - 生成代码。
使用 DMA 发送数据示例:
uint8_t tx_buffer[1024], rx_buffer[1024]; // 填充要发送的数据到 tx_buffer... CS_Select(); // 启动 DMA 传输,函数立即返回,传输在后台进行 if (HAL_SPI_TransmitReceive_DMA(&hspi1, tx_buffer, rx_buffer, 1024) != HAL_OK) { // 错误处理 } // 此时 CPU 可以去做其他事情 // 需要等待传输完成,可以通过查询标志位或使用中断回调函数 while (HAL_SPI_GetState(&hspi1) != HAL_SPI_STATE_READY) { // 等待或处理其他任务 } CS_Deselect();DMA 使用要点:
- 缓冲区生命周期:确保
tx_buffer和rx_buffer在 DMA 传输完成前一直有效(通常是全局数组或静态数组)。 - 传输完成判断:不要用死等,可以结合
HAL_SPI_GetState或HAL_SPI_TxRxCpltCallback中断回调函数。 - CS 信号控制:DMA 传输不会自动控制 CS,你需要在传输开始前拉低 CS,在传输完成回调函数中拉高 CS。
4.2 驱动多个 SPI 从设备:硬件与软件策略
当你有多个 SPI 设备(如一个 Flash,一个显示屏,一个传感器),你需要管理它们。
- 独立片选(推荐):每个设备独占一组 MOSI/MISO/SCLK,但 CS 脚接 MCU 的不同 GPIO。这是最清晰、干扰最小的方式。软件上就是为每个设备写一套独立的
CS_Select/CS_Deselect函数。 - 分时复用 SPI 总线:所有设备共享 MOSI、MISO、SCLK,CS 独立。这是最需要注意的!
- 关键规则:在切换设备前,必须确保当前设备的 CS 已拉高(取消选中),并且等待一小段时间(Bus Free Time),然后再拉低下一个设备的 CS。否则,总线上的数据可能会被错误的设备接收。
- 代码示例:
void SPI_SwitchToDevice(GPIO_TypeDef* CS_Port, uint16_t CS_Pin) { // 1. 确保所有其他设备的 CS 为高(假设之前已处理) // 2. 微小延时,确保总线空闲 HAL_Delay(1); // 或使用 for 循环延时 // 3. 选中新设备 HAL_GPIO_WritePin(CS_Port, CS_Pin, GPIO_PIN_RESET); // 4. 再次微小延时 for (volatile int i = 0; i < 10; i++); } - MISO 线冲突:如果两个从设备同时被选中(CS 都为低),它们的 MISO 输出会冲突,可能损坏 IO 口。因此软件必须保证任何时刻最多只有一个设备的 CS 为低。
4.3 常见问题与稳定性保障
通信偶尔出错(数据错位):
- 检查电源完整性:SPI 高速运行时对电源噪声敏感,确保电源去耦电容(通常 0.1uF 和 10uF)靠近芯片电源引脚。
- 检查信号完整性:如果线过长(>10cm)或速度很高(>10MHz),考虑使用串联电阻(如 22-100 欧姆)来阻尼信号反射。
- 检查地线:确保主从设备之间有良好的共地。
HAL 库 SPI 锁(Lock)机制:你可能会遇到
HAL_SPI_StateTypeDef状态为HAL_SPI_STATE_BUSY而无法启动新传输的情况。这是因为 HAL 库用了一个简单的锁来防止重入。确保你的代码逻辑是:等待上一次传输完成(状态变为READY)后再发起下一次传输。对于 DMA 或中断方式,要在传输完成回调函数中清理状态。上电顺序与初始化:有些 SPI 从设备对上电和初始化顺序有要求。确保 MCU 的 IO 口在上电后处于确定状态(通常配置为上拉或下拉),避免在初始化完成前产生毛刺时钟选中设备。标准的做法是:系统初始化 -> 配置 SPI 和 GPIO -> 将所有 CS 脚置高(不选中)-> 然后才进行设备读写。
超时处理:HAL 库的阻塞函数都有超时参数。不要使用
HAL_MAX_DELAY一劳永逸,在生产代码中应设置一个合理的超时时间,并在超时后执行错误恢复流程(如重新初始化 SPI)。
最后,关于 SPI 和 I2C、UART 的选择,我的经验是:追求速度和简单性,用 SPI;追求省引脚和多设备,用 I2C;需要跨板或与电脑通信,用 UART。把 SPI 的四种模式、片选控制和全双工特性吃透,你就能搞定市面上绝大多数 SPI 器件,从 Flash、屏幕到复杂的电机驱动芯片(如 DRV8711)。动手时,逻辑分析仪是你的好朋友,它能让你从“猜”问题变成“看”问题。