☰
SPI总线协议实战笔记:四线机制、四种模式与调试避坑
2026/10/21 9:22:40 网站建设 项目流程

学通信协议的第三天,我给自己定的目标很明确:把SPI总线协议彻底吃透。前两天刚把UART和I2C调通,本以为SPI就是多几根线、速度快一点,结果真正把一块SPI接口的彩屏和一颗SPI存储芯片接到主板上之后才发现,这个“简单”的总线里藏着不少容易栽跟头的细节。这篇就当第三天的实战笔记,讲清楚四根线怎么配合、四种工作模式怎么选、主机驱动怎么写,以及我实际踩过的三个坑。适合正在学单片机和嵌入式通信的新手,也适合一直用库函数却从没深究过时序的老手——很多问题其实就出在“以为懂了”的地方。

1. 为什么第三天才轮到SPI:它和UART、I2C的定位差异

1.1 三条总线的“性格”完全不同

前两天调UART和I2C的时候,我一直在想一个问题:既然已经有了两种串行总线,SPI存在的意义到底是什么?调下来的感受是,这三种协议不是竞争关系,而是各自解决了不同的痛点。

UART最随性,两根线就能跑,异步通信不需要时钟线,但速率做不高,而且收发双方得提前约定好波特率。I2C省引脚,两根线还能挂一堆设备,每个设备有地址,但通信过程带应答机制,速率上限相对保守,而且每传一个字节都要处理ACK,读大量数据时协议开销很重。SPI则完全是另一个路子:它把片选线单独拉出来,一根CS就解决“跟谁说话”的问题,不需要设备地址;全双工四线同步传输,时钟由主设备提供,数据边沿完全确定。

用一句话概括我第三天的体会:UART适合两个设备之间简单透传,I2C适合低速挂载一群小外设,而SPI适合“把数据高速灌进屏幕、flash、ADC这种需要连续吞吐的芯片里”。

下面这张表是我当时整理的对比,拿来入门很够用:

对比项UARTI2CSPI
数据线数量2线(TX/RX)2线(SDA/SCL)4线(CS/SCLK/MOSI/MISO)
通信方式异步同步同步
时钟来源双方各自约定波特率主设备提供SCL主设备提供SCLK
设备寻址无需地址,点对点7位或10位地址靠CS片选,无需地址
全双工支持半双工全双工
协议开销低中(有ACK/NACK)低
典型速率常见115200bps级别100k/400kHz级别几MHz到几十MHz
最强场景日志、调试串口传感器挂载屏幕、Flash、高速外设

1.2 SPI最常出现的面孔:屏幕、Flash与高速采集

我这次调试的外设是入门级彩屏和一颗SPI Nor Flash芯片。屏幕需要不断刷新像素点,数据量大,I2C那个速率刷新起来会肉眼可见地卡;UART得额外处理帧格式,也不适合。SPI就非常合适:主设备只管把像素字节顺着MOSI送出去,MISO甚至可以不用接,因为屏幕大部分时候不回数据。

Flash芯片则是另一个典型用法,往里面写固件或者记录数据,需要高速连续写入;读出的时候主设备发一个读命令,紧接着就能在MISO上拿到数据,这个“发命令的同时读数据”的能力是SPI的灵魂。

除了这两类,我还见过用SPI接高精度ADC、DAC、SD卡模块、传感器集线器的场景。判断一个外设适不适合走SPI,就看两点:第一,它是否需要持续大量传数据;第二,它能不能容忍主设备用片选线“独占”整条总线。如果两个条件都满足,SPI基本就是最优解。

2. 四根线怎么把一次读写跑通:SPI的底层配合逻辑

2.1 从CS拉低开始:一次事务的生命周期

我第一次看SPI时序图时最大的困惑是:明明有SCLK时钟线,为什么还要CS这个片选信号?后来才想明白,SCLK是全局时钟,所有挂在总线上的从设备都能看到,但到底哪个设备应该响应,就必须靠CS来区分。

所以一次完整的SPI事务是这样的:主设备先把目标从设备对应的CS引脚拉低,告诉对方“我要跟你通信了”;然后SCLK开始产生连续的时钟脉冲,MOSI上一位一位发送数据,MISO上一位一位回收数据;通信结束后主设备把CS拉高,从设备知道自己这一轮的活儿干完了。

这里有个关键习惯:CS拉低到SCLK开始之间,以及SCLK结束后到CS拉高之前,都要预留一点时间。原因是从设备内部需要识别片选有效、准备好自己的状态机。有些从设备芯片手册会给出最小建立时间,比如CS拉到低电平后,至少要过几十纳秒才能给第一个时钟沿。我之前偷懒,把CS和第一个SCLK边沿挤得太近,设备偶尔工作偶尔不工作,后来加上延时就好了。

2.2 全双工的直觉:读的时候到底发生了什么

“全双工”这个词在SPI里有一层反直觉的理解:主设备读数据的同时,必须也“写”点什么。因为时钟是主设备产生的,从设备自己没有时钟,无法主动把数据推给主设备;它只能借着主设备提供的SCLK信号,在某个边沿把MISO上的数据推出去。

也就是说,想从Flash里读一个字节,主设备不能干等着,而是要在发送读命令后,继续在MOSI上发送一个“哑字节”(通常发0xFF或0x00)。SCLK每跳一次,从设备就往MISO上放一位数据;主设备内心其实不关心这个哑字节的内容,它关心的是从MISO上采到的这8个bit。

我刚学的时候一直犯迷糊:为什么读Flash要先发一个0xFF?后来在逻辑分析仪上看到波形就明白了,0xFF只是给从设备提供时钟节拍的工具。这个机制也解释了为什么SPI读速度可以做到很高:写和读在同一个时钟周期内完成,效率天然比I2C高。

2.3 时钟边沿与数据变化:先分清“变化”和“采样”

要理解SPI模式,绕不开两个动作:数据变化和数据采样。它们分别发生在时钟的不同边沿。

拿模式0来说,SCLK空闲时是低电平。主设备在SCLK产生一个上升沿之前,先把MOSI上的数据准备好;然后在上升沿这个时刻,从设备把MOSI上的电平抄走。而MISO上的数据变化发生在SCLK的下降沿,也就是说从设备在下降沿之后才开始切换输出,主设备等下一个上升沿再去采样。

这就像发牌员和你之间的配合:发牌员先把牌放到桌上,然后喊一声“拿牌”,你才拿起牌;而你把牌放回去的动作,得等他喊完“下一张”之后再做。如果两个人节奏错位,牌就会放错地方、拿错时间。SPI四种模式本质上就是“空闲电平”和“第几个边沿采样”这两个维度的组合,下一节展开讲。

3. CPOL与CPHA:四种模式不是四个选项,而是两种判断

3.1 用“空闲电平+采样边沿”理解四个组合

很多新手看到手册里的CPOL、CPHA就头皮发麻,其实拆开看就两个问题:

CPOL决定SCLK空闲时的电平是低还是高。CPOL=0,空闲低;CPOL=1,空闲高。

CPHA决定数据是在SCLK的第一个边沿采样,还是第二个边沿采样。CPHA=0,第一个边沿采样;CPHA=1,第二个边沿采样。

把两个维度组合起来正好是四种模式。以CPOL=0、CPHA=0的模式0为例:时钟空闲为低,第一个边沿是上升沿,所以数据在上升沿被采样,在下降沿发生变化。最常见的SPI Flash、屏幕控制器,绝大多数默认就是模式0或模式3。

我理解这个事有一个笨办法:把SCLK的一个完整周期想象成“低-高-低”或者“高-低-高”的台阶。CPHA=0意味着主设备在踩上第一个台阶的瞬间去拿数据;CPHA=1意味着主设备在踩上第二个台阶的瞬间才去拿数据。先想清楚台阶是谁、什么时候被踩,相位问题就解决了一大半。

3.2 四种模式速查表:从从设备手册读到正确配置

实际开发中最大的困惑不是记不住表,而是不知道怎么从手册里反推出该用哪个模式。我的经验是:不背默认值,看手册里的时序图。

模式CPOLCPHASCLK空闲电平采样边沿数据变化边沿
模式000低上升沿下降沿
模式101低下降沿上升沿
模式210高下降沿上升沿
模式311高上升沿下降沿

看手册时找三个信号:SCLK、MOSI(主机输出)、MISO(从机输出)。先看SCLK空闲状态是高还是低,判断CPOL;再看数据线的跳变和时钟边沿谁先谁后。如果MOSI上的数据在SCLK的某个边沿右侧稳定下来,而这个边沿之后下一个边沿才是采样点,那就要看第一个边沿是上升沿还是下降沿,从而确定CPHA。

举个实际例子:某存储芯片手册上写着“在SCLK上升沿采样数据”,并且时序图里时钟空闲为低,那就是模式0;如果写着“数据在SCLK下降沿之前有效”,但空闲为高,那多半是模式3。确认模式后,直接到寄存器里设置CPOL、CPHA两个位即可。

3.3 一个容易被忽略的细节:模式选择与初始化顺序

模式不是初始化时随便配一下就完事了,它还牵涉初始化顺序。我踩过一个很隐蔽的问题:先拉低了CS,再配置时钟极性和相位,结果从设备在CS刚拉低时就检测到了SCLK空闲电平异常,直接进入错误状态。

正确的顺序应该是:先把GPIO引脚模式设好,确保CS默认高、SCLK正确空闲电平、MOSI有确定的电平;再配置SPI外设的CPOL、CPHA、分频系数;最后使能SPI外设。全部准备好之后,才去拉低CS发起事务。

用库函数开发时,不少人喜欢芯片复位后立刻拉低CS去读ID,这样很容易踩到“模式还没配好,片选已经有效”的坑。把CS看作整个通信的“门闩”,门闩落下之前,屋里所有东西都得摆好,否则从设备看到的是一片混乱。

4. 手写SPI主机:从寄存器配置到波形验证的完整流程

4.1 先选IO模拟还是硬件SPI:我为什么建议先软后硬

开发板的SPI外设很方便,但初学阶段我更推荐先拿GPIO模拟一遍,哪怕后面真正量产用的是硬件SPI。原因有三个:

第一,IO模拟能逼着你理解时序。写代码的时候你不得不考虑“先放数据还是先拉时钟”“采样点在哪里”,这些细节在硬件SPI里全被外设逻辑藏住了。第二,IO模拟便于调试。写错了可以加打印、加延时,问题定位到具体某一位;硬件SPI出错时黑盒感很强,只能反复看寄存器状态。第三,低速下IO模拟的波形其实很干净,很多传感器、屏幕对速率不敏感,完全够用。

等IO模拟版本的读写流程跑通之后,再切换到硬件SPI:把速率提上来,加上DMA,你会发现整个框架早就搭好了,只是把“手动拉脚”换成了“写寄存器”。

4.2 GPIO模拟SPI的C语言实现:一个可直接抄的框架

下面这段是我在测试板上调通的模式0主机收发框架。关键点在于:数据在SCLK拉高之前先放到MOSI上,并且MISO的采样放在SCLK高电平期间进行。

#include "chip.h" #define CS_HIGH() gpio_write(CS_PIN, 1) #define CS_LOW() gpio_write(CS_PIN, 0) #define SCK_HIGH() gpio_write(SCK_PIN, 1) #define SCK_LOW() gpio_write(SCK_PIN, 0) #define MOSI_HIGH() gpio_write(MOSI_PIN, 1) #define MOSI_LOW() gpio_write(MOSI_PIN, 0) #define MISO_READ() gpio_read(MISO_PIN) void spi_gpio_init(void) { gpio_mode(CS_PIN, OUTPUT); gpio_mode(SCK_PIN, OUTPUT); gpio_mode(MOSI_PIN, OUTPUT); gpio_mode(MISO_PIN, INPUT_PULLUP); CS_HIGH(); SCK_LOW(); // 模式0:空闲低电平 MOSI_LOW(); } static void delay_half_bit(void) { volatile int i; for (i = 0; i < 50; i++) ; // 延时代替,实际按主频调整 } uint8_t spi_exchange_byte(uint8_t data_out) { uint8_t data_in = 0; int i; for (i = 7; i >= 0; i--) { // 1. 在SCLK变化前,把当前bit放到MOSI上(MSB先发) if (data_out & (1 << i)) MOSI_HIGH(); else MOSI_LOW(); // 2. 拉高时钟,产生上升沿 SCK_HIGH(); delay_half_bit(); // 3. 高电平期间,从MISO采样 if (MISO_READ()) data_in |= (1 << i); // 4. 拉低时钟,产生下降沿,完成一个bit周期 SCK_LOW(); delay_half_bit(); } return data_in; } void spi_transfer(const uint8_t *tx_buf, uint8_t *rx_buf, uint32_t len) { uint32_t i; CS_LOW(); // 拉低片选,开始事务 for (i = 0; i < len; i++) rx_buf[i] = spi_exchange_byte(tx_buf ? tx_buf[i] : 0xFF); CS_HIGH(); // 拉高片选,结束事务 }

这段代码对应模式0:空闲低、上升沿采样、下降沿换数据。如果你用的设备是模式3(空闲高、上升沿采样),只需把初始化里的SCK_LOW()改成SCK_HIGH(),并且把收发循环里的时钟拉高拉低顺序反过来即可。其他模式以此类推,核心思路永远是“先把输出数据准备好,再产生采样边沿”。

真正在项目里用的时候,我还会加两个小细节:delay_half_bit不要用太短的固定循环,最好按主频和当前SCLK目标频率算好分频;spi_transfer里发送缓冲区为空时,统一替换成0xFF,这样读Flash时就不用单独写一个“只读”函数了。

4.3 硬件SPI初始化要点:寄存器字段背后的含义

IO模拟跑通后,硬件SPI的寄存器配置就顺理成章了。不同芯片寄存器名字不一样,但字段含义基本一致,我把常见字段列一下:

  • 主从模式(Master/Slave):设为主机模式,由本机产生SCLK。
  • 波特率分频(Baud Rate Prescaler):SCLK频率 = 主机外设时钟 / 分频系数。初学先分频到1MHz以内,稳定后再往上提。
  • CPOL/CPHA:对应四种工作模式,和上面表格一致。
  • 数据帧格式:通常是8位一帧,MSB先行。
  • 软件/硬件片选管理:大多数MCU的SPI外设可以自动控制CS,但我个人更喜欢软件控制CS,因为可以精确控制片选拉低/拉高的时间点,而且多从设备总线切换更灵活。
  • 使能位:初始化顺序里,所有参数配好之后,最后写使能位。

只做一个方向的传输时,可以关掉未使用的接收或发送功能省一点功耗,但调试阶段建议全开。我记得有一次调Flash读操作死活读不到数据,最后发现是接收功能没使能,FIFO里的数据根本没进接收寄存器。细节决定成败,在SPI上体现得非常彻底。

5. 实测记录:三次调试翻车的完整排查链路

5.1 翻车一:时钟极性和相位不匹配,读回来的字节全是错位

现象很典型:初始化完代码,读Flash的厂商ID,返回的全是0xFF,偶尔还会出现0x00和0x01这种“半错位”数据。屏幕则干脆全白,像没通电一样。

一开始我怀疑是接线问题,用万用表量了一遍,MOSI、MISO、SCLK、CS通断全部正常,供电也正常。接着用逻辑分析仪抓MOSI波形,发现发出去的命令字节其实是正确的,说明主机发送链路没问题。于是把注意力转向MISO,结果发现MISO上偶尔有波形,但边沿位置看起来和SCLK的上升沿贴得很近。

问题最后定位在CPHA上。设备手册的时序图画得比较含糊,我开始按默认的模式0跑,但实际上这颗芯片要求的是模式0的“采样点稍微靠后”。把CPHA从0改成1之后,读回来的ID完全正确。这件事给我最大的教训是:不要默认“大家都用模式0”,尤其是用库函数开发时,库封装的默认参数很可能是0,但不一定匹配你的从机器件。每次接新芯片,第一件事就是查它手册里明确描述的采样边沿。

5.2 翻车二:提前释放片选,最后一个字节的神秘丢失

第二坑出现在往Flash里写数据。写入一页数据,回读校验的时候总是最后一个字节不对,要么是旧值,要么是0xFF。我一度以为是Flash品质问题,换了一颗还是同样现象。

排查思路是这样的:因为只有最后一个字节出错,说明前面的数据链路都正常,问题大概率出在事务的“收尾动作”上。我在代码里找到了问题:执行完最后一字节的发送后,立刻拉高了CS。从Flash芯片的角度看,它还在处理最后一个字节的数据写入,此时CS突然变高,芯片会认为主机中断了本次事务,于是最后一笔数据被丢弃。

解决方法是:发送完最后一个字节的SCLK边沿与CS拉高之间增加一个极短的延时,或者额外产生一个完整的时钟周期再拉高CS。这个延时不需要很长,但必须存在。后来我把这个“CS释放时间”作为代码里的固定配置,每次初始化和传输结束时都检查一遍,再没出过类似问题。

5.3 翻车三:MOSI和MISO接线反了,却“看起来”正常

这是一个非常误导人的故障。某次调一块SPI转接板,读设备寄存器,发现所有值都是0x00,但设备命令“看起来”发出去了,逻辑分析仪上MOSI也确实有波形。奇怪的是,即使我换了一台主机,现象依旧。

后来把逻辑分析仪的四个通道分别接到转接板的两侧,才发现问题:板子上丝印标注的MOSI和MISO其实和另一边是对调的,我的线缆按直觉接到主机上,等效于把主机的MOSI接到了从机的MOSI,主机和从机都在往同一个方向上发数据,谁也没法收。

这类故障的特征是:主设备发送正常,但接收永远为0;偶尔有些芯片会在MISO线上配置上拉电阻,于是读回的全是1。排查方法很简单:把CS拉低后,主设备故意发一个0x00给从机,观察逻辑分析仪上从机方向的数据线上有没有变化;如果完全静止,很可能是收发两根线接反了,或者从机压根没被唤醒。我后来习惯在每个SPI外设的初始化代码里先做一次“环回自测”——把主机的MOSI短接到MISO,发一个字节,看能不能原样收回来,能收回来至少说明主机侧没问题。

5.4 排查顺序建议:遇到SPI不工作,我现在的第一反应

经过三天的折腾,我总结了一套排查顺序,以后遇到SPI不工作,不再手忙脚乱:

  1. 用万用表量四根线的通断,排除接触不良和反接。这一步最便宜最省事,却最容易被跳过。
  2. 用逻辑分析仪抓一次完整事务,确认CS确实拉低了、SCLK确实在走、MOSI有没有波形。
  3. 确认从设备供电和复位引脚正常。很多SPI从设备有独立复位脚,如果复位脚被误接低电平,芯片根本不工作。
  4. 核对CPOL和CPHA。不要凭空猜,去看手册上的时序图,对照采样边沿。
  5. 降速重试。把SCLK降到几百kHz甚至几十kHz,如果故障消失,大概率是信号完整性问题,比如线太长、沿太陡、电平不匹配。
  6. 检查CS时序。看看CS拉低到首个SCLK边沿的时间、SCLK结束到CS拉高的时间是否满足手册要求。

这套流程下来,绝大多数SPI问题都能定位到具体环节,而不是像无头苍蝇一样反复换芯片。

6. 调试工具与波形判读:没有逻辑分析仪也能定位大方向

6.1 逻辑分析仪看四线关系:一次错误的波形长什么样

逻辑分析仪是调SPI最好用的工具。不用买太贵的,采样率几十MHz的四通道就够了。接线时把四个通道分别接CS、SCLK、MOSI、MISO,公共地必须和主板共地。

看波形时我习惯先找CS的下降沿,再从左往右数SCLK上升沿。在第一个SCLK边沿处,看MOSI上第一位数据是否已经稳定;再看MISO上有没有跟着变化。有一次抓Flash读ID,发现MISO在第四个字节之后才出现波形,我立刻意识到前面的发送时序里多了一个命令字节,而芯片在等待更多数据。没有逻辑分析仪的时候,这种“看不见摸不着”的问题只能靠猜,有了波形图,几分钟就能定位。

6.2 示波器单次触发:把信号完整性看明白

逻辑分析仪能看时序关系,但看不了信号完整性。比如SCLK信号存在明显振铃,逻辑分析仪会把它当成多个边沿,导致数据错乱。这时候需要示波器。

我的做法是:将示波器触发模式设为“单次触发”,触发源选SCLK,触发电平设为SCLK高电平的一半;然后在代码里只执行一次短暂的数据传输,让示波器抓到完整的一帧。观察MOSI线上数据电平是否在采样边沿处已经稳定,稳定不少于半个时钟周期才算可靠。

如果发现边沿过冲超过0.3V,我会优先检查这几件事:SCLK线上是不是串联了过长导线,信号线之间是不是有平行走线带来的串扰,SPI速率是不是设得太高,末端有没有加匹配电阻。低速调通的系统在提高速率时经常在这里栽跟头。

6.3 没有仪器时:用串口回读和LED闪烁缩小范围

不是所有环境都有逻辑分析仪和示波器,第一天调UART时我也只能靠串口打印。调试SPI时,如果手头只有串口,我依然能缩小范围。

先写一个最简传输函数:固定发送一串递增字节,比如0x00、0x01、0x02一直到0x0F,然后把MISO上的返回值通过UART打印出来。如果返回值完全不等于发送值,先别慌,检查MISO引脚是否确实收到数据;如果接收结果全是0xFF,很可能是MISO没通或者从设备没上电。还可以配置一个不用的GPIO,在CS拉低时点亮LED,拉高时熄灭,用肉眼看片选动作是否发生。

这些土办法不如逻辑分析仪直观,但在现场没有仪器的条件下,能帮我确认最基础的问题:信号有没有过去、设备有没有响应、数据线通不通。至少能避免“调了半天发现原来是从设备根本没工作”的尴尬。

我在实际调试中的体会是,SPI总线协议真正难的地方不在协议本身,而在那些“看不见的时序细节”:CS释放时间不够会导致最后一个字节丢失,CPOL/CPHA不匹配会导致数据全乱,MOSI和MISO接反了却不知道。如果你最近也在被SPI折磨,建议按照我上面的排查顺序走一遍,大部分问题都能在半小时内水落石出。第三天能把这些东西彻底搞明白,后面再碰高速SPI、DMA传输、多从设备总线,心里就有底了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询