资讯动态

SPI接口原理与STM32实战:从CPOL/CPHA到OLED驱动全解析

发布时间:2026/10/14 20:09:29 来源:尧图企业网站定制
断断续续写了这么多期 STM32 的学习笔记前面把 GPIO、定时器、串口都从原理到实验折腾过一遍后台私信里催更最多的问题之一就是关于SPI 接口的。确实SPI接口论线数只有四根接线看起来比串口还清爽但真正要把它跑明白却不是把几个寄存器填上就能完事的事。无论是外接 Flash、OLED 屏还是各类传感器SPI 都是出现频率极高的通信方式。这一篇我打算把 SPI 接口原理、STM32 的 SPI 外设架构、标准库下的配置方法以及一个真实跑通的显示实验完整过一遍。尤其是时钟极性和时钟相位这两个概念我会尽量用大白话拆开讲。如果你也被 SPI 的四种模式绕晕过或者初始化之后发现数据读回来全是 0xFF、屏怎么点都不亮这篇文章应该能帮你把思路理顺。1. SPI接口原理拆解四根线的默契配合1.1 物理层分工主设备提供时钟从设备跟节奏走SPI 全称 Serial Peripheral Interface串行外设接口是一种同步串行通信协议。它和 I2C 那种半双工、一主多从、需要应答的方式不一样SPI 从诞生起就走的是“全双工 主从直连”的路线。四根线各司其职分工非常明确SCKSerial Clock时钟线由主设备产生。数据交换的节奏完全由这条线上的时钟脉冲决定没有时钟就没有通信。MOSIMaster Out Slave In主设备数据输出线主设备的数据顺着这根线送进从设备。MISOMaster In Slave Out从设备数据输出线从设备的数据顺着这根线送进主设备。CS / NSSChip Select / Slave Select片选线低电平有效。谁是当前通信对象由这条线说了算。我把第一次见到 SPI 时的感受类比成两个人对暗号主设备把时钟当作“口令节奏”MOSI 和 MISO 是两条并行传纸条的通道片选则是“只跟某个人说话”的开关。因为是主设备只负责打节拍从设备不产生时钟所以主设备选谁、发多少拍从设备就跟着收多少拍。这在硬件上天然支持全双工主设备发出去一个字节的同时从设备也可以回一个字节两边互不耽误。与之相对I2C 只有一根数据线 SDA收发轮流来所以是半双工。SPI 把发送和接收分到两根物理线上效率自然高不少。但同时也要注意SPI 没有标准的总线仲裁、应答机制通信是否成功很大程度上靠协议层自行约定这也是后面排查问题时最容易忽略的环节。1.2 CPOL 和 CPHA四种工作模式的来源接触过 SPI 的朋友一定见过 CPOL 和 CPHA 这两个参数。很多人第一次看到它们直接卡在这里。其实拆开看并不复杂CPOLClock Polarity决定空闲状态下 SCK 是高电平还是低电平。CPOL 0空闲低电平CPOL 1空闲高电平。CPHAClock Phase决定数据是在时钟的第一个边沿采样还是在第二个边沿采样。CPHA 0第一个边沿采样CPHA 1第二个边沿采样。这两个参数组合起来就是业界常说的 SPI Mode 0 到 Mode 3。工作模式CPOLCPHA空闲时钟电平数据采样边沿Mode 000低电平上升沿采样Mode 101低电平下降沿采样Mode 210高电平下降沿采样Mode 311高电平上升沿采样为什么会有这么多种组合因为 SPI 从诞生起就没有强制规定统一的采样时机各家厂商在生产从设备芯片时按自己的开发习惯选择了不同的时序方案。因此主设备和从设备想要正常通信必须把 CPOL 和 CPHA 配置成完全一致。我在第一次调一个传感器时就栽在这个上面从设备手册明确写了“数据在 SCK 上升沿被采样空闲时 SCK 为高”我却想当然默认成 Mode 0结果读回来的寄存器数据全都错位。后来对着手册时序图一格一格数边沿才明白必须设置成 Mode 3CPOL1CPHA1。如果不太确定怎么判断就记住一个土办法打开从设备 datasheet 里的时序图看空闲电平是高还是低这是 CPOL再看数据变化和采样点相对于 SCLK 的位置第一个边沿变化就是 CPHA0第二个边沿变化就是 CPHA1。把这两个问题回答清楚模式自然就推出来了。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 的默认引脚一般包括 PA4NSS、PA5SCK、PA6MISO、PA7MOSI。如果这些引脚被板上的其他功能占用了还可以通过重映射把它们换成 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 引脚如下信号引脚说明CSPA4软件控制片选DCPA2命令/数据切换RSTPA3复位低电平复位SCKPA5SPI1 时钟MOSIPA7SPI1 主设备输出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 最大的敌人不是寄存器而是对时序图的理解不够。静下心来数一遍边沿比盲目改代码有用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑