做嵌入式开发,跟单片机打交道时间长了,你会发现SPI出镜率高得离谱。MCU外接Flash、显示屏、SD卡、各种传感器,几乎都能看到SPI的身影。很多朋友第一次接触STM32或者ESP32的时候,被问到的第一个实验往往是:能不能用SPI把屏幕点亮?能不能用SPI把Flash里的数据读出来?我也是从这种一脸懵的状态一路踩坑过来的,所以想好好聊聊SPI通信这件事。
SPI全称是Serial Peripheral Interface,串行外设接口。它是一种同步的、全双工的串行通信协议。“同步”意味着收发双方跟着同一个时钟节奏走,“全双工”意味着同一时刻既能发数据也能收数据。这两个特性决定了SPI在需要高速小数据量传输的场景里非常吃香,时钟可以轻松跑到几十兆赫兹,比UART和I2C快得多。这篇内容主要围绕SPI的基本架构、协议选型、时序细节、实际操作、以及常见问题展开。不管你是刚入门想搞懂SPI的新手,还是已经做过项目想再梳理一遍细节的朋友,应该都能从中找到有用的东西。
1. 先说清楚SPI的四根线:架构与分工
1.1 SCK、MOSI、MISO、CS:每根线到底在干什么
SPI总线最核心的部分就是四根信号线,很多初学者一开始搞不清它们的关系,其实可以这么记:SCK是心跳,MOSI是主发从收,MISO是主收从发,CS是点名。
SCK(Serial Clock)由主机产生,给整个通信提供时钟节拍。SPI是同步通信,所有数据位都按照这个时钟边沿来采样,SCK不存在的时候,总线就是静止的。SCK的频率也决定了通信速度,后面会单独讲。
MOSI(Master Out Slave In)和MISO(Master In Slave Out)分别是两条数据线。主机通过MOSI把数据发给从机,从机通过MISO把数据回给主机。因为是两条独立的线,SPI天然支持全双工——主机发送和从机回传可以在同一个时钟周期内完成,这在很多实时交互场景里非常关键。
CS(Chip Select)是片选信号,低电平有效。主机想跟哪个从机通信,就把那个从机的CS拉低,等于在点名:“我现在要跟你说话了。”同一时刻只能有一个从机被选中,否则多个从机同时驱动MISO会产生总线冲突。
1.2 主从模型与一主多从的接法
SPI总线的结构是典型的主从模型,主机永远是时钟的掌控者,从机被动响应。这种架构最大的好处是协议简单,不需要像I2C那样处理地址仲裁和时钟同步,代价则是从机之间无法主动通信,所有交互都要靠主机发起。
一主一从是最常用的场景,直接接四根线就行。一主多从时,通常有两种接法。第一种是每个从机各拉一根CS线,SCK、MOSI、MISO共享,主机通过片选选择与哪个从机通信。这种接法速度快,适合从机数量不多的情况。第二种是菊花链(Daisy Chain)接法,从机的MISO接到下一个从机的MOSI,串联成一条链。这种接法节省IO口,但延迟会随链上设备数量叠加,实际项目里用得不多。
我在做ESP32驱动屏幕的时候用的就是第一种接法,屏幕占一个CS,SD卡占另一个CS,共享SCK、MOSI、MISO三条线。刚开始没注意CS线的上拉,结果屏幕和SD卡互相干扰,后来在每个CS上都加了10kΩ上拉电阻才稳定下来。
2. 协议选型背后的逻辑:SPI为什么能活到今天
2.1 一张表看懂SPI、I2C、UART、CAN的区别
很多初学者在选通信协议的时候会纠结,SPI、I2C、UART、CAN到底有什么区别?我整理了一张对比表,基本覆盖了日常选型需要的维度。
| 特性 | SPI | I2C | UART | CAN |
|---|---|---|---|---|
| 信号线数量 | 4根(SCK/MOSI/MISO/CS) | 2根(SCL/SDA) | 2根(TX/RX) | 2根(CANH/CANL) |
| 通信方式 | 同步全双工 | 同步半双工 | 异步全双工 | 异步差分半双工 |
| 时钟来源 | 主机产生 | 主机产生 | 双方各自约定波特率 | 总线仲裁,无主从之分 |
| 速率典型值 | 10MHz~50MHz+ | 100kHz~3.4MHz | 9600bps~几Mbps | 125kbps~1Mbps |
| 多设备支持 | 靠多CS选通 | 靠地址寻址 | 点对点 | 多节点仲裁 |
| 抗干扰能力 | 一般 | 一般 | 一般 | 强,差分信号 |
| 硬件复杂度 | 低 | 低 | 低 | 高,需要收发器 |
SPI的速率优势非常明显,在MCU内部总线上,SPI往往能跑到几十兆赫兹,I2C一般最多3.4MHz,UART在板级通信里通常也就一两Mbps。CAN虽然速率不算特别高,但它的差分信号和仲裁机制是为了长距离、多节点、强干扰环境设计的,和SPI的应用场景基本不重叠。
2.2 什么时候选SPI,什么时候换总线
选型这件事,主要看三个维度:速率、距离、设备数量。
如果你要驱动TFT屏幕、刷波形、读高速ADC,SPI基本是首选。屏幕刷新一帧数据往往要几百KB甚至上MB,I2C那个速率太慢了,UART虽然能到几Mbps,但还要考虑双方波特率误差,同步性不如SPI。SPI在全双工模式下,主从两边同时收发的特性做传感器读取也非常好用,主机发一字节指令的同时,从机上一字节数据就已经在MISO上等着了,效率很高。
如果设备数量多、线束要省、速率要求不高,I2C更合适。I2C靠地址区分设备,一根SDA线就能挂十几个器件,省IO口也省PCB面积。像温度传感器、EEPROM、RTC这类低速设备,用I2C绰绰有余。还有一点,I2C是半双工,只有一根数据线,代码实现比SPI稍麻烦一点,因为要处理ACK信号和起始停止条件。
CAN则完全不是一类东西,它是为工业控制、车载网络这类长距离、强干扰、多节点场景设计的。如果通信距离超过一米,或者现场电机变频器一堆导致干扰很大,SPI那两根数据线根本扛不住,这时候必须上差分总线。
2.3 硬件片选与软件片选:不是随便选的
SPI片选有一个经常被忽略的细节:硬件片选和软件片选。
硬件片选指MCU的SPI外设自带NSS引脚,可以用硬件自动控制片选的高低电平变换。它的好处是响应快,时序精准,主机在发送前自动拉低CS,发送完自动拉高。但对于很多MCU来说,硬件NSS引脚是固定的,如果你的板子已经画好了,引脚恰好被占用,那就会很被动。
软件片选就是随便找一个GPIO,手动控制高低电平。代码里先拉低CS,然后操作SPI发送数据,结束后再拉高CS。这种方式灵活,想用哪个引脚都行,是目前项目里最主流的做法。缺点也很明显,如果SPI时钟跑得飞快,GPIO翻转速度跟不上,CS的建立时间和释放时间可能不够。特别是在读Flash这类对时序要求敏感的器件时,CS拉低后必须保证至少tCSS(片选建立时间)的延时,数据才能在SCK上稳定采样。
我的习惯是:凡是能翻GPIO就翻GPIO,软件片选优先。只有在对时序要求苛刻的场景(比如高速DMA搬运数据)才考虑用硬件片选,避免因为人为延时而把整个链路拖慢。
3. 新手最容易翻车的地方:SPI模式与时序细节
3.1 CPOL和CPHA到底在决定什么
SPI协议里最容易让人懵的就是四种工作模式,由CPOL(Clock Polarity,时钟极性)和CPHA(Clock Phase,时钟相位)共同决定。
CPOL决定的是SCK空闲时的电平。CPOL=0时,SCK空闲为低电平,有效时拉高;CPOL=1时,SCK空闲为高电平,有效时拉低。
CPHA决定的是数据采样发生在时钟的哪个边沿。CPHA=0时,在第一个边沿采样,数据在片选拉低后的第一个时钟边沿就被锁存;CPHA=1时,在第二个边沿采样,也就是数据在第二个边沿稳定后读取。
把这两个参数排列组合,就得到模式0到模式3。模式0(CPOL=0,CPHA=0)是最常用的,大部分器件默认都是模式0。模式3(CPOL=1,CPHA=1)也比较常见。中间的模式1和模式2用得少,但偶尔会遇到。
提示:拿到一个从机器件,第一件事永远是查数据手册里的时序图,看它要求的是哪种模式。手册都会明确标注“Mode 0”还是“Mode 3”,照做就行,不要凭猜测。
3.2 从波形图看一次完整的数据交换
理解SPI时序,最好是在逻辑分析仪上看一次真实波形。假设是模式0,CPOL=0,CPHA=0。CS先拉低,此时SCK处于低电平,主机的MOSI引脚已经在发送第一位数据了,等到SCK第一个上升沿到来时,从机采样MOSI,主机同时采样MISO,一个时钟周期就完成了一位的收发。整个过程中,数据在SCK上升沿被锁存,SCK下降沿时数据切换。
如果用文字描述波形:CS下拉表示通信开始,紧接着SCK开始输出方波,MOSI上的数据在SCK下降沿附近切换,MISO上的数据随着SCK上升沿被主机采样。一次典型的数据帧通常是8位或者16位,由主机的数据长度配置决定。
这里有一个容易踩的坑:数据位到底是MSB在前还是LSB在前。SPI协议本身没有规定字节序,取决于器件手册。很多MCU的SPI外设默认是MSB first,如果器件要求LSB first,读出来的数据就是反的。之前调一块传感器,读ID总是差一位,折腾了半天才发现是字节序配错了。
3.3 速率上限、数据长度与DMA的配合
SPI的时钟速率理论上由主机决定,但实际能跑多快受制于从机的最高支持频率。比如W25Q64 Flash最高支持约100MHz,普通SPI屏幕大概支持几十MHz,而有些传感器只支持几MHz。如果时钟超了从机的规格,轻则数据错乱,重则烧坏器件接口。
还有一个容易忽略的问题:如果你的信号线很长,或者走线经过连接器、杜邦线,高速时钟会产生严重的反射和串扰。杜邦线超过10厘米,跑10MHz以上就开始出现波形畸变了,跑20MHz甚至直接通信失败。这时候要么降低时钟,要么把信号线改短,要么在SCK和MOSI上串联33Ω~100Ω的电阻做阻抗匹配,实测下来对信号质量改善很明显。
数据长度方面,常见的SPI外设支持4位到32位可配,通常用8位一帧。16位一帧在某些场合更高效,比如操作SPI屏的RGB888接口,一个16位字刚好是一个像素的高低位。DMA的配合则是在大量连续数据传输时把CPU解放出来,直接用DMA把内存里的数据块搬给SPI外设。配置DMA的时候要注意数据宽度和FIFO的匹配,否则搬运过程中容易丢数据。
4. 动手实操:用CubeMX和HAL库半小时跑通SPI
4.1 CubeMX里的具体配置步骤
用STM32跑SPI,我习惯直接走CubeMX生成初版工程,再把细节改成自己的。
打开CubeMX之后,选中芯片型号,在Pinout & Configuration界面里找到SPI1,点击启用。Mode选择Full-Duplex Master,Hardware NSS Signal选Disable,片选信号后面用GPIO软件控制。Parameter Settings里重点看几个参数:Baud Rate Prescaler,这个分频系数决定SPI时钟;CPOL选Low,CPHA选1 Edge,即模式0;Data Size选8 Bits;First Bit选MSB First;Prescaler具体值看你芯片主频和从机支持的最高频率。
假设STM32F103主频72MHz,从机是W25Q64,最高支持100MHz,但F103的SPI最大只能到18MHz,所以分频系数选4,得到18MHz时钟。如果从机只支持5MHz,分频就得选16,得到4.5MHz。原则是:SPI时钟不能超过从机规格,同时留一点余量。
配置完片选引脚,设置为GPIO Output,初始电平为High,因为CS低有效。在HAL库代码里,每次通信前先HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET)把CS拉低,调用HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, len, timeout)完成收发,最后再把CS拉高。
4.2 读一个Flash ID加深理解
读Flash ID是经典的SPI入门实验,因为代码量少,又能验证时序配置是否正确。以W25Q64为例,读ID的命令是0x90,格式是:拉低CS,发送0x90,发送三个空字节0x00,然后连续读两个字节,读完拉高CS。第一个字节是Manufacturer ID,第二个是Device ID。
HAL库代码大致这样写:
uint8_t cmd = 0x90; uint8_t dummy[3] = {0x00, 0x00, 0x00}; uint8_t id[2] = {0}; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Transmit(&hspi1, dummy, 3, 100); HAL_SPI_Receive(&hspi1, id, 2, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);W25Q64的Manufacturer ID应该是0xEF,Device ID根据型号不同是0x14(64Mbit)或者0x15(128Mbit)。如果读出来是全0xFF,说明MISO线没接好或者模式不对;如果读出来是全0x00,说明器件根本没响应,先查CS是否被正确拉低、电源是否正常。
当初我第一次调的时候,读ID全FF,排查了半天发现是杜邦线松了导致MISO虚焊。这类硬件小问题比代码问题更隐蔽,所以拿到板子先检查物理连接永远是第一步。
4.3 屏幕和SD卡共享SPI总线的处理思路
热搜里有个问题问“ESP32屏幕与SD卡共享SPI哪个好”,这确实是实际项目中经常遇到的场景。屏幕和SD卡共享同一组SPI总线,只要保证同一时刻只有一个设备被选中就行。屏幕一个CS,SD卡一个CS,软件上严格控制片选时机。
但这里有个坑:有些屏幕模块板上自带SD卡槽,两者在模块内部就可能共用SPI总线,主机侧需要仔细看原理图,分清哪个CS对应屏幕、哪个对应SD卡,不要搞混。另外,屏幕的数据写入通常要求连续时钟,如果在写屏幕过程中错误地拉高了SD卡的CS,SD卡是没事的,因为片选未被选中,但如果反过来在SD卡操作过程中触碰了屏幕CS,可能造成屏幕显示错乱。
另一个实际问题是复位时序。屏幕初始化时需要拉低RST一段时间再释放,这个过程必须在CS为高的状态下进行,否则容易把屏幕内部状态机搞乱。SD卡则要求上电后等待至少1ms再发命令。把这些初始化时序都处理好,共享总线完全可以稳定工作。我自己的习惯是,给总线上的每个设备各写一个单独的驱动模块,所有通信都从模块内部发起,不在外部随意操作SPI,这样能避免很多竞态问题。
5. 进阶玩法:IO口模拟SPI与Verilog实现
5.1 什么时候要模拟SPI
不是所有场景都方便用MCU自带的SPI外设。有些时候芯片SPI外设数量不够,或者引脚被占用,就得靠GPIO直接翻转电平来模拟SPI时序,也就是IO模拟三线SPI、四线SPI。
IO模拟的好处是引脚随意、实现透明,你能清楚地看到每个时钟边沿发生了什么。缺点也明显,时钟频率受限于GPIO翻转速度和代码循环效率,一般跑到几MHz就很吃力了,不像硬件SPI能跑到几十MHz。
模拟SPI的关键是严格按照时序来。以模式0为例:CS拉低后,循环8次,每次先把MOSI数据位设置好,然后SCK拉高,从设备在上升沿采样,接着SCK拉低,在下降沿读取MISO,最后设置下一位。代码就几十行:
static void spi_sim_transfer(uint8_t *tx, uint8_t *rx, uint8_t len) { for (int i = 0; i < len; i++) { uint8_t out_data = tx ? tx[i] : 0xFF; uint8_t in_data = 0; for (int bit = 7; bit >= 0; bit--) { // 先设置数据 MOSI_GPIO = (out_data & (1 << bit)) ? 1 : 0; // 上升沿,数据锁存 SCK_GPIO = 1; __NOP(); // 下降沿,主机读取MISO in_data |= (MISO_GPIO ? 1 : 0) << bit; SCK_GPIO = 0; __NOP(); } if (rx) rx[i] = in_data; } }注意延时不能太短,至少要给GPIO和从机留出响应时间,尤其用高速MCU模拟时别忘了加优化屏障或空指令,防止编译器把顺序优化坏。实际调试中,示波器看波形是最直接的方法,看SCK是否正常翻转、MOSI和MISO数据是否在正确的边沿采样。
5.2 用Verilog写一个最简Master
FPGA和CPLD上做SPI主机的场景也很常见,尤其涉及到高速采集、多通道同步这类需求。Verilog实现的最简SPI Master,核心是一个状态机,控制CS、SCK和MOSI的时序。
以模式0、8位数据为例,状态机可以分成IDLE、START、SHIFT、STOP四个状态。IDLE状态时CS为高,SCK为低;START状态拉低CS,输出第一位数据;SHIFT状态循环8次,SCK先拉高再拉低,同时移位发送数据;STOP状态拉高CS,完成一次传输。
核心代码片段如下:
module spi_master ( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire start, output reg cs_n, output reg sck, output reg mosi, input wire miso, output reg done ); localparam IDLE = 2'd0; localparam START = 2'd1; localparam SHIFT = 2'd2; localparam STOP = 2'd3; reg [1:0] state; reg [2:0] bit_cnt; reg [7:0] sh_reg; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; sck <= 1'b0; mosi <= 1'b0; done <= 1'b0; end else begin case (state) IDLE: begin done <= 1'b0; cs_n <= 1'b1; sck <= 1'b0; if (start) begin sh_reg <= tx_data; cs_n <= 1'b0; state <= START; end end START: begin mosi <= sh_reg[7]; state <= SHIFT; bit_cnt <= 3'd7; end SHIFT: begin sck <= 1'b1; // 上升沿,从机采样 #1; // 模拟数据建立时间,实际请用非阻塞赋值和计数器替代 sck <= 1'b0; // 下降沿,切换下一位数据 mosi <= sh_reg[bit_cnt-1]; if (bit_cnt == 0) state <= STOP; else bit_cnt <= bit_cnt - 1; end STOP: begin cs_n <= 1'b1; done <= 1'b1; state <= IDLE; end endcase end end endmodule这个写法是教学简化版,真正的工程代码还要考虑时钟分频、MISO采样窗口的建立保持时间。特别是MISO的数据在SCK下降沿切换,主机应该在SCK上升沿采样,如果直接用组合逻辑去采,很容易采到不稳定电平,时序不过关。更稳妥的做法是让SCK由计数分频器产生,然后在SCK上升沿后延时四分之一周期再采样MISO,这样能避开数据翻转的窗口。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
调试SPI这么多年,我把遇到过的典型问题整理成了一张表,大部分情况你对照着查基本就能定位。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 读出来全是0xFF | MISO接线错误/虚焊,从机未供电 | 用示波器或万用表量MISO有无电平跳变 |
| 读出来全是0x00 | 从机未响应,CS没拉低 | 检查CS控制逻辑,示波器看CS是否有下降沿 |
| 数据错位,字节顺序反了 | MSB/LSB字节序配置错误 | 核对器件手册,切换First Bit设置 |
| 偶发数据错误,时好时坏 | SCK时序边沿没有对齐 | 换一种SPI模式,逐步排查是模式几 |
| 高速时通信失败 | 线太长/干扰/反射 | 降低时钟,缩短线缆,加串阻或改屏蔽线 |
| 主从片选互斥太慢导致冲突 | 软件片选切换有延迟 | 在CS切换之间加延时,或改用硬件片选 |
| DMA传输丢数据 | FIFO宽度/突发长度不匹配 | 核对DMA数据宽度和SPI数据宽度一致 |
还有一个搜索引擎里常常搜到的场景,比如“rm1135 读取SPI reg fail”,这其实是SPI控制器固件在某个环节读取寄存器失败的问题,大概率原因是片选时序不满足要求或者时钟太快导致寄存器访问超时。排查思路一样:先量波形,再核对模式,最后看寄存器地址和设备树配置。
6.2 排查思路和实战心得
排查SPI问题的通用方法,我总结成四步。
第一步,确认接线。看着原理图和实际板子一根线一根线对,特别是复用引脚有没有被别的功能占用。很多MCU的SPI引脚都有复用功能,一旦配置成GPIO输出其它信号,SPI就废了。
第二步,确认模式。先别急着跑复杂逻辑,写个最简单的回环测试。把MOSI和MISO用杜邦线短接,发送0xA5,看能不能原样收回来。回环通了,说明SPI外设工作正常,问题在外部设备;回环不通,问题就在寄存器配置或引脚复用。
第三步,看波形。没有逻辑分析仪的时候,用示波器也能凑合看,至少能确认CS、SCK、MOSI有没有产生正确的电平变化。有条件的话上逻辑分析仪,直接抓CS和SCK的时序关系,跟数据手册里的时序图比对,看相位是否一致。
第四步,加长超时。早期调试时,HAL库的timeout参数不要设太小,尤其刚上电时从机可能还没准备好,超时设100ms甚至更大,能减少很多“偶尔通信失败”的假象。等通信稳定后再把超时缩短。
根据我的经验,SPI通信问题里,硬件连线问题占四成,模式配置问题占三成,代码逻辑问题占两成,剩下的是一些玄学问题,比如电源纹波、地线干扰、DMA优先级配置不合理。所以排查顺序永远是:硬件 -> 时序 -> 代码,不要一上来就怀疑库函数有Bug。
我个人在实际调试中的体会是,SPI协议本身并不难,难的是你对总线上每个设备的时序细节是否足够了解。那些看起来像“随机”的数据错误,十有八九是某个边沿没对齐、某个建立时间没满足导致的。强烈建议刚接触SPI的朋友,哪怕只是点亮一块屏幕,也尽量把逻辑分析仪接上,亲眼看一下CS、SCK、MOSI、MISO四根线在通信时的真实波形。看懂了波形,你才算是真正懂了SPI;看不看波形,调不出问题的时候差别非常大。
最后再分享一个小技巧:如果SPI总线上挂着多个器件,你在代码里给每个器件单独封装收发函数,并在函数开始和结束时各加一条片选断言,比如DEBUG_PRINT("cs on")和DEBUG_PRINT("cs off")。一旦遇到总线互斥、数据错乱的问题,看日志就能立刻定位是不是哪个地方片选时序没配对。这个方法帮我排查过不少隐蔽问题,至少能省好几次接示波器的功夫。