断断续续写了这么多期 STM32 的学习笔记,前面把 GPIO、定时器、串口都从原理到实验折腾过一遍,后台私信里催更最多的问题之一,就是关于SPI 接口的。确实SPI接口论线数只有四根,接线看起来比串口还清爽,但真正要把它跑明白,却不是把几个寄存器填上就能完事的事。无论是外接 Flash、OLED 屏,还是各类传感器,SPI 都是出现频率极高的通信方式。
这一篇我打算把 SPI 接口原理、STM32 的 SPI 外设架构、标准库下的配置方法,以及一个真实跑通的显示实验完整过一遍。尤其是时钟极性和时钟相位这两个概念,我会尽量用大白话拆开讲。如果你也被 SPI 的四种模式绕晕过,或者初始化之后发现数据读回来全是 0xFF、屏怎么点都不亮,这篇文章应该能帮你把思路理顺。
1. SPI接口原理拆解:四根线的默契配合
1.1 物理层分工:主设备提供时钟,从设备跟节奏走
SPI 全称 Serial Peripheral Interface,串行外设接口,是一种同步串行通信协议。它和 I2C 那种半双工、一主多从、需要应答的方式不一样,SPI 从诞生起就走的是“全双工 + 主从直连”的路线。四根线各司其职,分工非常明确:
- SCK(Serial Clock):时钟线,由主设备产生。数据交换的节奏完全由这条线上的时钟脉冲决定,没有时钟就没有通信。
- MOSI(Master Out Slave In):主设备数据输出线,主设备的数据顺着这根线送进从设备。
- MISO(Master In Slave Out):从设备数据输出线,从设备的数据顺着这根线送进主设备。
- CS / NSS(Chip Select / Slave Select):片选线,低电平有效。谁是当前通信对象,由这条线说了算。
我把第一次见到 SPI 时的感受类比成两个人对暗号:主设备把时钟当作“口令节奏”,MOSI 和 MISO 是两条并行传纸条的通道,片选则是“只跟某个人说话”的开关。因为是主设备只负责打节拍,从设备不产生时钟,所以主设备选谁、发多少拍,从设备就跟着收多少拍。这在硬件上天然支持全双工,主设备发出去一个字节的同时,从设备也可以回一个字节,两边互不耽误。
与之相对,I2C 只有一根数据线 SDA,收发轮流来,所以是半双工。SPI 把发送和接收分到两根物理线上,效率自然高不少。但同时也要注意,SPI 没有标准的总线仲裁、应答机制,通信是否成功,很大程度上靠协议层自行约定,这也是后面排查问题时最容易忽略的环节。
1.2 CPOL 和 CPHA:四种工作模式的来源
接触过 SPI 的朋友,一定见过 CPOL 和 CPHA 这两个参数。很多人第一次看到它们,直接卡在这里。其实拆开看并不复杂:
- CPOL(Clock Polarity):决定空闲状态下 SCK 是高电平还是低电平。CPOL = 0,空闲低电平;CPOL = 1,空闲高电平。
- CPHA(Clock Phase):决定数据是在时钟的第一个边沿采样,还是在第二个边沿采样。CPHA = 0,第一个边沿采样;CPHA = 1,第二个边沿采样。
这两个参数组合起来,就是业界常说的 SPI Mode 0 到 Mode 3。
| 工作模式 | CPOL | CPHA | 空闲时钟电平 | 数据采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿采样 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿采样 |
| Mode 2 | 1 | 0 | 高电平 | 下降沿采样 |
| Mode 3 | 1 | 1 | 高电平 | 上升沿采样 |
为什么会有这么多种组合?因为 SPI 从诞生起就没有强制规定统一的采样时机,各家厂商在生产从设备芯片时,按自己的开发习惯选择了不同的时序方案。因此主设备和从设备想要正常通信,必须把 CPOL 和 CPHA 配置成完全一致。我在第一次调一个传感器时,就栽在这个上面:从设备手册明确写了“数据在 SCK 上升沿被采样,空闲时 SCK 为高”,我却想当然默认成 Mode 0,结果读回来的寄存器数据全都错位。后来对着手册时序图一格一格数边沿,才明白必须设置成 Mode 3(CPOL=1,CPHA=1)。
如果不太确定怎么判断,就记住一个土办法:打开从设备 datasheet 里的时序图,看空闲电平是高还是低,这是 CPOL;再看数据变化和采样点相对于 SCLK 的位置,第一个边沿变化就是 CPHA=0,第二个边沿变化就是 CPHA=1。把这两个问题回答清楚,模式自然就推出来了。
1.3 帧格式与位序:先发高位还是低位
SPI 数据帧一般是 8 位或 16 位,STM32 的 SPI 外设也支持这两种配置。大多数从设备默认是 8 位帧、MSB first(高位在前),这也是最常见的情况。但也有一些特殊外设要求 16 位帧,或者要求 LSB first(低位在前),比如某些音频芯片、特定的传感器。
在 STM32 标准库中,SPI_FirstBit 这个字段就是用来选择位序的,取值只有 SPI_FirstBit_MSB 和 SPI_FirstBit_LSB 两种。位序不对时的表现非常隐蔽:数据并不会完全发错,而是会左右颠倒,比如发送 0x01,接收端看到的是 0x80。这种问题光靠看逻辑分析仪波形很难一眼发现,必须结合从设备的协议描述来判断。
此外,SPI 的帧传输还有一个特点:主设备没有发送动作,就没有时钟;没有时钟,从设备就不会在 MISO 上产生有效数据。所以主设备想读一个字节,必须先“假装发一个字节”,把时钟跑起来,然后从 MISO 上取回数据。这个特点贯穿整个 SPI 编程,我在后面写收发驱动时还会具体讲。
2. STM32的SPI外设体系:从总线挂载到引脚复用
2.1 APB 总线、SPI 外设与波特率预分频
STM32 片上通常有多个 SPI 外设,拿常见的型号来说,SPI1 挂在 APB2 总线上,SPI2 和 SPI3 则挂在 APB1 总线上。APB1 的最高时钟通常比 APB2 低,所以在追求较高 SPI 波特率时,大家一般优先选 SPI1。当然实际选择还要综合考虑引脚位置、走线、布局,不能一味追求速度。
SPI 的波特率由 APB 时钟再分频得到,寄存器里的分频系数可选 2、4、8、16、32、64、128、256。举个例子,如果 SPI1 挂在 72MHz 的 APB2 总线上,设置分频系数为 16,那么 SCK 频率就是 72MHz / 16 = 4.5MHz。这个速率驱动市面上多数 Flash 芯片和 OLED 模块都足够。分频系数为 2 时理论速率能上 36MHz,但实际还要受芯片最高支持速率、PCB 走线长度、负载电容等因素限制,并不是分频系数越小越好。我一般在调试阶段用较大分频,比如 16 或 32,先跑通功能再逐步提速度,这样能大幅降低信号完整性带来的干扰。
这里还要强调一个细节:使能 SPI 外设时钟与使能 GPIO 时钟是两回事。很多人初始化后引脚毫无反应,就是因为只开了 SPI 外设时钟,忘了开 GPIO 所在总线上的时钟。
2.2 引脚复用:为什么 GPIO 要配成复用功能
SPI1 的默认引脚一般包括 PA4(NSS)、PA5(SCK)、PA6(MISO)、PA7(MOSI)。如果这些引脚被板上的其他功能占用了,还可以通过重映射把它们换成 PB3、PB4、PB5。但不管是默认引脚还是重映射引脚,配置 GPIO 时都不能当成普通推挽输出来用,而要启用“复用功能”。
在标准库中,SCK 和 MOSI 作为输出信号,要配置为 GPIO_Mode_AF_PP(复用推挽输出),MISO 作为输入信号,要配置为 GPIO_Mode_IN_FLOATING(浮空输入)或带上拉输入。不少人图省事,把三根线全部配置成复用推挽输出,这在部分板子上碰巧能跑,但严格来说不符合硬件规范,在外设电路阻抗不匹配时容易出问题。
如果使用引脚重映射,还需要额外开启 AFIO 时钟,并调用 GPIO_PinRemapConfig 函数使能对应外设的重映射。同步开启 GPIO 时钟、AFIO 时钟、SPI 外设时钟,再设置 GPIO 模式,顺序最好固定下来,不然漏一步都够排查半天。
2.3 片选线:硬件 NSS 与软件 GPIO 的选择
STM32 的 SPI 外设本身带有 NSS 引脚,也有硬件化的 NSS 管理模式。但在我做过的绝大多数项目里,更推荐直接把片选线配成普通 GPIO 输出,用程序手动拉低拉高。原因很简单:软件片选对时序的控制更直观,能避免硬件 NSS 模式下“主从角色切换”“多主机冲突”等隐性问题。
尤其是在一主多从的场合,主设备需要分时访问多个从设备,片选逻辑完全由自己控制,硬件 NSS 反而容易带来麻烦。比如从设备工作在高电平有效的片选逻辑下,或者从设备的片选和其他引脚共用,这时通用 GPIO 方案最灵活。标准库初始化结构体里有一个 SPI_NSS 字段,做主设备时一般填 SPI_NSS_Soft,然后自行操作 CS 引脚。初始化完成后,每次通信前拉低对应从设备的片选线,通信结束后拉高,心里非常有数。
3. 标准库下的 SPI 配置实战:把外设真正跑起来
3.1 SPI_InitTypeDef 结构体字段逐个拆解
标准库把 SPI 初始化信息封装在一个结构体里,理解每一个字段是写出可靠代码的前提。以最常用的主模式、8 位数据帧、Mode 0、MSB first 为例,完整的初始化逻辑如下。
void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; // 使能 GPIOA、SPI1 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); // PA5(SCK)、PA7(MOSI) 配置为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA6(MISO) 配置为浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // SPI1 配置为主模式、8 位数据、Mode 0 SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_16; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }逐个字段看,SPI_Direction 选了 SPI_Direction_2Lines_FullDuplex,也就是双线全双工,MOSI 和 MISO 都用。SPI_Mode 选 Master,决定本机是产生时钟的一方。SPI_DataSize 选择 8 位数据帧。SPI_CPOL 和 SPI_CPHA 就是前面讲的模式设置,这里 CPOL 为低、CPHA 为第一个边沿采样,对应 Mode 0。SPI_NSS 选择 Soft,表示片选由软件管理。SPI_BaudRatePrescaler 和 SPI_FirstBit 分别决定波特率和位序。SPI_CRCPolynomial 是 CRC 多项式值,在不使用硬件 CRC 校验时必须给一个非零初值,写成 7 是惯例做法,实际填多少不影响常规功能。
3.2 发送与接收的真相:TXE、RXNE 与一次“假发送”
SPI 主设备想要读取从设备数据时,必须先生成时钟。生成时钟的方式,就是主动向发送数据寄存器写一个字节。就算主设备并不关心这个字节是什么,也必须“发”一次,否则 SCK 不会有脉冲,从设备不会响应。理解了这一点,很多代码就通了。
发送和接收的核心状态标志有两个:TXE(发送缓冲区空)和 RXNE(接收缓冲区非空)。发送一个字节时,先等待 TXE 置位,再把数据写入 DR;写入后硬件自动开始移位传输,传输完成后 RXNE 会置位,此时读出 DR 里的内容,才是从设备这次真正回复的数据。下面是常用的单字节收发函数。
uint8_t SPI_ReadWriteByte(uint8_t byte) { // 等待发送缓冲区为空 while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); // 写入数据,启动传输 SPI_I2S_SendData(SPI1, byte); // 等待接收缓冲区非空 while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); // 读出从设备回复的数据 return SPI_I2S_ReceiveData(SPI1); }这个函数虽然短,但把 SPI 主模式的工作方式体现得很完整。想读一个字节,就调用一次 SPI_ReadWriteByte(0x00),返回来的就是从设备回填的数据。想写一个字节,直接调用 SPI_ReadWriteByte(待写数据),返回值如果不需要可以忽略。有些新手只关心发送,写完就退出,不读 RXNE,结果下一次发送时状态错乱。虽然标准库不会真的报错,但这种做法会让从设备后续的数据错位,尤其每次传输的字节数不一致时特别明显。
3.3 多字节传输与后续扩展
上面的函数是轮询方式,速度不快也不慢,适合绝大多数普通场景。但在读 SPI Flash 的大块数据或者持续刷新大尺寸屏幕时,逐字节轮询会占用大量 CPU 时间。这时可以借助 DMA 传输,把数据从内存直接搬运到 SPI 发送寄存器,或者从接收寄存器直接搬回内存。DMA 模式虽然初始化代码稍多,但原理并不复杂,核心还是先把 SPI 外设配好,再配置 DMA 通道的外设地址、内存地址、传输长度和数据宽度。
这期先把轮询方式吃透,后面如果写 DMA 专题,我会拿 SPI 刷屏这个经典场景来拆解。到时候你会发现,底层原理不变,只是数据搬运方式换了条更快的路。
4. 实验:SPI 驱动 OLED 屏,用一块屏幕检验配置
4.1 接线与硬件准备:这片屏没有 MISO
我一直觉得,初学 SPI 最好的实验对象不是 Flash 芯片,而是一块 SPI 接口的 OLED 屏。原因很朴素:Flash 是纯数字器件,读回的数据全是十六进制数,调了半天也不容易直观判断对错;OLED 屏会把通信结果直接显示出来,点亮了就是成功,点不亮或者花屏,至少能肉眼看到。这次的实验就用一块常见的 0.96 寸 OLED 模块,驱动控制器是通用款,工作电压 3.3V,可以兼容 5V 输入但要确认好模块电平转换。
这类 OLED 虽然是“SPI 接口”,但一般只有 CS、DC、RST、SCK、MOSI 这五根控制线,MISO 并不存在,因为这类屏幕在整个生命周期里只会从主设备接收数据,不会向主设备回传任何内容。所以我选用的 STM32 引脚如下:
| 信号 | 引脚 | 说明 |
|---|---|---|
| CS | PA4 | 软件控制片选 |
| DC | PA2 | 命令/数据切换 |
| RST | PA3 | 复位,低电平复位 |
| SCK | PA5 | SPI1 时钟 |
| MOSI | PA7 | SPI1 主设备输出 |
PA2 和 PA3 本来是普通 GPIO,用作 DC 和 RST 正好合适。PA4 片选我也没有配置成 NSS 复用模式,而是当成普通推挽输出直接操作。这种接线方式比“硬件 NSS”版本少踩很多坑。
4.2 初始化时序:复位、命令集合与写命令/数据函数
OLED 驱动模块上电后,先要在软件里做一次复位:把 RST 拉低一段时间再拉高,让模块内部状态机进入确定的初始状态。之后通过 SPI 发送一串初始化命令,比如打开内部升压、设置显示时钟分频、设置段重映射、清屏、开启显示等。这些命令的数值和顺序通常由模块厂家提供,不同驱动 IC 的命令编号可能不一样,使用前必须对着自己的屏型号查。
这里最关键的不是记住命令列表,而是理解 CS、DC、SPI 三者如何配合。写命令时,先把片选拉低,再把 DC 拉低表示“这条线上要来的是命令”;然后调用 SPI_ReadWriteByte 把命令字节发出去;最后片选拉高。写数据时,同样步骤,但 DC 要拉高。
#define OLED_CS_Clr() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define OLED_CS_Set() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define OLED_DC_Clr() GPIO_ResetBits(GPIOA, GPIO_Pin_2) #define OLED_DC_Set() GPIO_SetBits(GPIOA, GPIO_Pin_2) #define OLED_RST_Clr() GPIO_ResetBits(GPIOA, GPIO_Pin_3) #define OLED_RST_Set() GPIO_SetBits(GPIOA, GPIO_Pin_3) void OLED_WriteCmd(uint8_t cmd) { OLED_CS_Clr(); OLED_DC_Clr(); SPI_ReadWriteByte(cmd); OLED_CS_Set(); } void OLED_WriteData(uint8_t dat) { OLED_CS_Clr(); OLED_DC_Set(); SPI_ReadWriteByte(dat); OLED_CS_Set(); }每次通信都完整执行“拉底片选、切换 DC、发一个字节、拉高片选”,这个流程看起来啰嗦,却是 SPI 可靠通信的基本功。有些模块对片选的边沿不敏感,省略拉高片选也能工作,但不同芯片之间差异很大,最稳妥的做法就是片选保持严格的低有效窗口,窗口内只做这一件事。
4.3 点亮验证与波形观察
初始化命令发送完毕、清屏并设置显示开关之后,如果整块屏能正常点亮,说明 SPI 基本配置没有大问题。接着往显存区域写固定的显示数据,比如全屏写入一个图案,通过肉眼比对上屏效果。
我习惯在调试时把逻辑分析仪的通道挂着 SCK、MOSI、CS 三条线上。观察到的典型波形应当是:CS 拉低后,SCK 出现连续时钟脉冲,MOSI 上每一个时钟沿都能对应一个稳定的数据电平,待整帧字节发送完成后 CS 再拉高。如果 CS 低电平期间 SCK 没有动静,多半是初始化顺序错了,或者发送函数没有成功触发。如果 CS 正常拉低、SCK 也有时钟,但屏幕内容完全不对,就要重点检查 DC 切换是否正确、命令序列有没有漏发。
另外,OLED 屏的数据线和时钟线在高频下对电平很敏感,连线尽量短一些,杜邦线不要飞得太长,否则偶尔出现花屏、雪花点,十有八九是信号质量问题而不是 SPI 参数错误。
5. SPI 调试踩坑纪实与几点个人心得
5.1 从设备返回 0xFF 或 0x00:先从时钟相位反推
读 Flash 芯片的厂商 ID 时,如果 MISO 上一律返回 0xFF,首先怀疑方向没接错的前提下,最可能的原因就是 SPI 模式不匹配。0xFF 意味着 MISO 引脚一直处于空闲高电平状态,从设备根本没有在正确的位置输出有效数据。这时候不要把代码翻来覆去改,赶紧去把从设备手册里的时序图画出来,对着 SCK 的极性和采样边沿,把 CPOL、CPHA 逐个试一遍。Mode 0 和 Mode 3 的混淆是最常见的,几乎每个新手都会在这上面消磨一晚上。
如果返回的是 0x00,说明 MISO 一直被拉低,那可能是从设备压根没被选中,也就是片选逻辑不对,或者从设备处于复位状态。这种情况下先从片选波形查起,而不是怀疑时钟配置。
5.2 时钟缝隙、状态标志与 GPIO 时钟的低级陷阱
SPI 调试中常见的小问题有三类,我总结下来都是低级但致命的:
- 时钟使能不完整。只开了 SPI 外设时钟,没开 GPIO 时钟;或者用重映射时漏开了 AFIO 时钟。
- GPIO 模式配错。把 MISO 配成了复用推挽,浮空输入应有的高阻特性没了,从设备输出的数据直接被拉变形。
- 状态标志读错。发送时只等 TXE,却不等 RXNE;或者连续发送时中间没有确认传输完成。对需要连续读写的 Flash 芯片,这种错误在连续读写时才会暴露,单字节测试根本发现不了。
关于时钟顺序,我的建议是固化一套流程:先使能 GPIO 时钟和 SPI 时钟,再配置 GPIO 模式,然后填 SPI 初始化结构体,再次调用 SPI_Init,最后 SPI_Cmd 使能。这套顺序我用了很多年,几乎没再因为初始化顺序踩过坑。
5.3 裸机调试到了瓶颈之后的几点经验
把 SPI 真正搞明白之后,再看那些基于硬件 SPI 的 HAL 库代码,会发现一切都变得很透明。HAL 的 SPI_Transmit、SPI_Receive 说到底还是在处理 TXE、RXNE 这两个标志,只是封装得更好用而已。前几天我甚至拿 IO 口软件模拟的方式重新驱动了一遍这块 OLED,速度虽然比不上硬件 SPI,但整个过程让我对时序有了更深的理解。我强烈建议,如果时间允许,先用 GPIO 模拟一轮 SPI,再用硬件 SPI 跑一遍,对比两者的波形差异。
另外,SPI 虽然叫“串行外设接口”,但它不是一个万金油接口。做产品选型时,还要考虑从设备是否支持标准 SPI、需不需要双向应答、时钟频率上限是多少,这些因素直接决定项目能不能稳定运行。同样的接法和代码,在 STM32F1 上能跑通,换到 F4 或者另一颗主控上,往往只是外设基址和时钟树不同,通信逻辑几乎没有变化。
这一篇从原理到代码再到实验,把 SPI 的主线走了一遍。如果你照着上面的流程把 OLED 或者 Flash 调通一遍,之后再见任何 SPI 从设备,应该都不会发怵了。我自己的体会是,调试 SPI 最大的敌人不是寄存器,而是对时序图的理解不够。静下心来数一遍边沿,比盲目改代码有用得多。