1. SPI协议到底是什么:先把它从“神秘黑盒”变成“四根线的事”
做嵌入式开发这几年,我越来越觉得SPI(Serial Peripheral Interface,串行外设接口)是日常打交道最多的通信接口之一。为什么这么说?因为你在板子上看到的Flash芯片、屏幕驱动、SD卡、传感器、ADC/DAC,几乎都能用SPI接。它的地位和IIC、UART基本并列,属于嵌入式工程师默认“必须刻进DNA”的三板斧。
但很多刚入行的朋友对SPI的理解停留在“照着例程抄,能跑就行”。一旦遇到显示花屏、Flash读写偶发失败、DMA数据错位这类问题,就完全无从下手。这不是你不够努力,而是对协议底层的机制缺乏系统性理解。这篇文章我打算把SPI从原理到实践完整拆一遍,重点放在那些文档里不会写、但实际调试中一定会踩的坑上。
SPI是一个主从架构的四线同步串行接口。所谓“四线”,就是SCLK(时钟线)、MOSI(主出从入)、MISO(主入从出)、CS/SS(片选线)。所谓“同步”,就是指数据的收发完全由主设备产生的时钟驱动,从设备自己不产生时钟,只跟着主设备的节奏走。
它解决的什么问题呢?简单说,就是在嵌入式系统内部,让主控芯片能高速、可靠地与周边的外设芯片交换数据。注意这里有个关键词“内部”——SPI不用于远距离传输,它的战场就在一块板子内部,走线长度一般不超过十几厘米。你要是想把SPI拉到一两米外,时序基本就崩了。
谁适合认真看这篇文章?我觉得只要你写过STM32、ESP32、Arduino这类主控的驱动代码,或者用过FPGA做接口逻辑,又或者只是好奇屏幕/Flash芯片背后到底怎么通信的,都值得把下面的内容通读一遍。尤其是那些准备自己画板子、做产品的朋友,SPI的理解深度直接决定你后期调试的痛苦程度。
我写这篇文章的底气来自这几年实际调过的设备:从最普通的W25Q64 Flash,到各种SPI接口的LCD屏幕(ST7789、ILI9341),再到传感器、EEPROM、FPGA之间的互连。踩过的坑多了,自然就知道哪些环节容易出问题、哪些配置看起来不起眼却致命。
2. SPI通信机制拆解:四根线背后的时钟、相位和片选博弈
2.1 四根线各自干什么,以及为什么是“同步”
先看SPI的物理层。四条线各司其职:
SCLK(Serial Clock):主设备产生的时钟信号线。它决定了数据传输的速率,也决定了数据在哪个边沿被采样。没有时钟就没有SPI通信,这是“同步”二字的根基。
MOSI(Master Out Slave In):主设备发送数据到从设备的数据线。注意方向是主→从。
MISO(Master In Slave Out):从设备返回数据给主设备的数据线。方向是从→主。
CS/SS(Chip Select / Slave Select):片选线,低电平有效。主设备把某个从设备的CS拉低,就表示“我现在要跟你讲话了”,其他没被拉低的从设备则保持沉默,不占用总线。
如果类比日常生活,SPI有点像老师(主设备)点名提问:SCLK是老师的节奏(拍子),MOSI是老师说的话,MISO是学生的回答,CS是老师点到的那个学生的名字。老师敲一下桌子(时钟边沿),学生就得在那一下的瞬间把话说完,双方必须严格对齐。
这里值得强调一个很多人忽略的点:SPI是全双工通信。主设备发数据的同时,从设备也在往MISO线上送数据。硬件上,数据是通过移位寄存器实现的——主设备把要发的数据移出到MOSI,同时把MISO上收到的位移入自己的寄存器。所以一次时钟脉冲,既有“发”,也有“收”,两边同步进行。这在很多人的直观理解里容易被忽略。
2.2 时钟极性(CPOL)和时钟相位(CPHA)到底怎么影响数据
SPI最劝退人的地方就是CPOL和CPHA,也就是所谓的4种SPI Mode。
CPOL(Clock Polarity,时钟极性):决定空闲时SCLK的电平是低还是高。CPOL=0表示空闲低电平,CPOL=1表示空闲高电平。
CPHA(Clock Phase,时钟相位):决定数据是在SCLK的第一个边沿采样,还是第二个边沿采样。CPHA=0表示第一个边沿采样,CPHA=1表示第二个边沿采样。
组合出来就是以下4种模式:
| 模式 | CPOL | CPHA | 采样边沿 | 数据输出边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿 | 下降沿 |
| Mode 1 | 0 | 1 | 下降沿 | 上升沿 |
| Mode 2 | 1 | 0 | 下降沿 | 上升沿 |
| Mode 3 | 1 | 1 | 上升沿 | 下降沿 |
实际刷屏、读写Flash、读取传感器时,最常见的是Mode 0和Mode 3。为什么?因为很多芯片默认推荐这两种,而且它们的本质都是“在时钟边沿中间采样,留给数据足够的建立时间和保持时间”,抗干扰能力强。Mode 1和Mode 2不是不能用,而是用得少。
调试的时候怎么确认应该用哪个模式?两个办法。第一,查从设备数据手册里SPI时序图,图上会明确标注CPOL和CPHA,或者直接告诉你支持Mode几。第二,如果数据手册写得含糊,就用示波器抓波形对比——在初始化时写一个读ID的指令,逐个模式试,能读到正确ID的模式就是对的。这办法笨但有效。
2.3 硬件片选和软件片选:各有各的坑
片选信号是SPI里最容易出问题的环节,特别是当你在一块总线上挂了多个从设备时。
硬件片选(硬件NSS/CS引脚):由MCU的SPI外设自动控制。比如STM32的NSS硬件模式,主设备在每次通信开始时自动拉低片选,通信结束自动拉高。
软件片选:片选引脚不作为SPI外设功能,而是普通GPIO,通信前手动拉低,通信结束手动拉高。
两者的核心区别在于时序控制的精度和灵活性。
如果用硬件片选,优点是CPU不需要额外干预片选状态机,减轻负载;缺点也不少。第一,硬件片选的高低切换时机由外设时序决定,某些芯片(比如一些传感器)对片选信号的建立时间、保持时间要求比较苛刻,硬件片选可能来不及满足。第二,如果你的MCU的NSS引脚冲突了,或者你需要在一次“原子操作”中只传输几个字节,硬件片选会更难控制。第三,DMA和高负载场景下,硬件片选提前释放也会导致从设备数据错乱。
软件片选是我个人更推荐的做法,几乎是各厂驱动库里的事实标准。理由很简单:你完全掌控片选拉低、等待、传输、拉高的每个时间点。在从设备要求严格的片选时序时(比如Flash写状态寄存器时需要片选拉低直到命令完成),软件片选就是救命稻草。缺点是CPU要花几条指令去翻转GPIO,但对绝大多数应用来说这个额外开销可以忽略不计。
实际操作中,我还建议一件事:片选引脚一定要配置成推挽输出,且初始状态须为高电平。很多人在初始化GPIO时忘了置高,导致上电后从设备被意外选中,一直处于通信状态,功耗异常甚至通信失败。
3. SPI与IIC、UART的对比:什么场景该选谁
“SPI和IIC有什么区别?”这是嵌入式初学者问得最多的问题之一。热搜词里也有“iic和spi的区别”,说明大家确实在这两个协议之间纠结。我直接说结论:没有谁更好,只有谁更合适。
把三者放在一张表里对比一下:
| 特性 | SPI | IIC | UART |
|---|---|---|---|
| 线数 | 4根(SCLK/MOSI/MISO/CS) | 2根(SCL/SDA) | 2根(TX/RX) |
| 通信方式 | 同步全双工 | 同步半双工 | 异步全双工 |
| 速率 | 很高(数十MHz轻松可达) | 中低(标准100k/400k,高速可达3.4M) | 中低(常见9600到几M) |
| 多设备支持 | 通过片选(每设备一根CS) | 通过地址(最多127个设备) | 点对点 |
| 硬件复杂度 | 较高(线多) | 低 | 低 |
| 抗干扰能力 | 中(高速下对走线敏感) | 中 | 较强(差分版如RS485更强) |
我一般按这个逻辑选型:如果只有一个从设备,且对速度要求高(屏幕刷新、Flash读写),优先SPI;如果总线上要挂多个设备且不想多拉线,优先IIC;如果两个设备距离超过20厘米,或者环境干扰大,就考虑UART(必要时转RS232/RS485)。
值得注意的一点是:IIC的地址机制让它在“一条总线挂多个设备”的场景下非常优雅,但IIC的速率天花板太低,刷屏和写Flash就是生死攸关的问题。我试过用IIC刷一个128x160的小屏,刷新率惨不忍睹;换成SPI之后,速度直接提升了一个数量级。这也是为什么绝大多数中高分辨率屏幕都选择SPI接口的原因。
4. 软件模拟SPI:从零手写底层协议,彻底搞懂时序本质
4.1 为什么工作中还要“造轮子”
看到这你可能想问:“现在的MCU不是都有硬件SPI外设吗?直接配置寄存器不就行了,为什么还要软件模拟?”这个问题我当年也纠结过。
原因有两个。第一,不是所有MCU都有足够的硬件SPI外设。比如你用一颗只有2个SPI的MCU,但板子上挂了LCD、Flash、SD卡、传感器,外设不够怎么办?最灵活的办法就是GPIO模拟。第二,某些国产芯片、低端MCU的硬件SPI写得很烂,时序不稳定,模拟反而更可靠。
还有一种常见情况是“三线SPI”。有些从设备没有MISO线,比如纯写设备,只需要CLK、MOSI、CS三根线。这时硬件SPI外设依然可以配置成只发不收,但用软件模拟更简单直接。
4.2 基于GPIO的完整软件SPI实现
我以最常见的Mode 0(CPOL=0, CPHA=0)为例,写一份可以直接套用的模拟代码。这段代码其实没什么高深的地方,但它把SPI的核心原理体现得淋漓尽致。
// 伪代码,适配不同MCU时替换为对应GPIO操作宏 #define SPI_CLK_H() gpio_write(CLK_PIN, 1) #define SPI_CLK_L() gpio_write(CLK_PIN, 0) #define SPI_MOSI_H() gpio_write(MOSI_PIN, 1) #define SPI_MOSI_L() gpio_write(MOSI_PIN, 0) #define SPI_MISO_READ() gpio_read(MISO_PIN) #define SPI_CS_L() gpio_write(CS_PIN, 0) #define SPI_CS_H() gpio_write(CS_PIN, 1) void spi_init(void) { // 配置CLK、MOSI、CS为推挽输出,初始高电平 // 配置MISO为输入,如果是硬件引脚则开上拉(视从设备而定) spi_cs_high(); spi_clk_low(); // CPOL=0时空闲为低,注意初始状态 } void spi_write_byte(uint8_t data) { for (int i = 7; i >= 0; i--) { // 先准备好数据线上的电平 if (data & (1 << i)) { SPI_MOSI_H(); } else { SPI_MOSI_L(); } // 产生一个上升沿:先拉低再拉高(从低到高是上升沿) // 对于CPHA=0,数据在上升沿被采样,所以数据必须提前准备好 SPI_CLK_L(); delay_dummy(); // 稍微延时,保证建立时间 SPI_CLK_H(); delay_dummy(); // 保证保持时间 } } uint8_t spi_read_byte(void) { uint8_t data = 0; for (int i = 7; i >= 0; i--) { // 对于读,在上升沿采样MISO SPI_CLK_H(); // 先拉高,产生上升沿 delay_dummy(); if (SPI_MISO_READ()) { data |= (1 << i); } SPI_CLK_L(); delay_dummy(); } return data; } void spi_transfer(uint8_t *tx, uint8_t *rx, uint32_t len) { SPI_CS_L(); for (uint32_t i = 0; i < len; i++) { uint8_t byte = 0; for (int bit = 7; bit >= 0; bit--) { // 发 if (tx && (tx[i] & (1 << bit))) SPI_MOSI_H(); else SPI_MOSI_L(); SPI_CLK_H(); // 收 if (rx && SPI_MISO_READ()) byte |= (1 << bit); SPI_CLK_L(); } if (rx) rx[i] = byte; } SPI_CS_H(); }这里最核心的一点是:发送时,数据电平必须在时钟边沿之前就稳定。然后时钟的跳变才会让从设备采样到正确的电平。如果你把顺序搞反了,比如先拉高时钟再设置MOSI,从设备采样到的就是上一次的数据,整个通信直接错乱。
我见过不少新手抄了网上的模拟代码跑不通,最后查出来都是GPIO初始电平不对。以CPOL=0为例,空闲时CLK必须为低。如果你上电后拉了高,那么第一个“上升沿”其实是从高到低(是下降沿而非上升沿),所有数据边沿对不上,后面全乱。
4.3 三线SPI和半双工变种
有些项目里要做“三线SPI”或“半双工SPI”(比如DHT11这类单总线传感器虽然严格说不算SPI,但原理是边沿采样)。模拟实现时,只需要把MOSI和MISO复用在同一个引脚上,收发切换需要在函数内部增加方向控制。这种场景下,软件模拟比硬件外设更容易实现,因为你完全掌握引脚的输入输出方向切换时机。
5. STM32硬件SPI与CubeMX配置:从HAL库到DMA的实战经验
5.1 CubeMX里这些参数到底怎么选
很多人在CubeMX里看到SPI配置界面一脸懵:Baud Rate Prescaler选多少?Clock Polarity和Clock Phase选啥?Data Size是8位还是16位?First Bit是MSB还是LSB?
我先给一份“照着填不会错”的默认配置(以STM32F1/F4系列为例):
- Mode:选择Master(主模式)
- Hardware NSS Signal:如果用软件片选,就选Disable或NSS Soft;这里强烈建议选Disable然后用普通GPIO管理片选。
- Data Size:8位,绝大多数SPI从设备都是8位字节操作
- First Bit:MSB First(绝大多数芯片高位先发,但也有少数存储器低位先发,需查手册)
- Prescaler:先选一个较低的分频(如32分频),调通后再逐步提高
- CPOL/CPHA:照从设备手册填,不确定就逐个试
- TX/RX DMA:如果需要高吞吐,勾上DMA通道
关于预分频(Baud Rate Prescaler)有一个容易忽略的点:SPI的时钟速率不是越高越好,而是要在从设备能承受的范围内,并且要考虑PCB走线质量。比如W25Q系列Flash支持最高104MHz写时钟,但实际布线不好、过孔多的情况下,跑到40MHz就不稳定了,花屏、读写错乱接踵而至。我一般先把分频调到10MHz左右验证基本功能,稳定后逐级提速,每级都做读写校验。
5.2 HAL库收发函数的选择与典型坑
STM32的HAL库提供了几个常用的SPI收发函数:
HAL_SPI_Transmit():只发不收HAL_SPI_Receive():只收不发(对于需要主设备主动提供时钟的从设备,这个函数会发送一个dummy字节)HAL_SPI_TransmitReceive():同时收发- DMA版本:
HAL_SPI_Transmit_DMA()/HAL_SPI_Receive_DMA()/HAL_SPI_TransmitReceive_DMA()
实际调试中最大的坑在只收不发这个函数上。很多从设备(比如Flash、传感器)读取数据时,主设备必须先发送一个命令字节(可能是读命令+地址),然后从设备才会在后续时钟下返回数据。如果你直接调用HAL_SPI_Receive(),它虽然会输出时钟,但输出的数据是0x00或0xFF,很多从设备不认这个空命令,读回来的就是垃圾数据。
正确做法是使用HAL_SPI_TransmitReceive(),把tx buffer填成你要发的命令字节,rx buffer用来收数据。比如读W25Q64的JEDEC ID:
uint8_t tx[4] = {0x9F, 0x00, 0x00, 0x00}; // 0x9F是读JEDEC ID命令 uint8_t rx[4] = {0}; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(&hspi1, tx, rx, 4, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); // 此时rx[1]是厂商ID,rx[2]是容量等5.3 DMA传输:为什么能提高性能,又带来什么新麻烦
当要传输的数据量大时(比如刷一张全屏图片、写入一页Flash),CPU一直盯着SPI寄存器会浪费大量时间。用DMA可以把数据在内存和外设之间自动搬运,CPU去干别的事。这是很常见的性能优化手段。
但DMA有它自己的坑。第一个是缓冲区生命周期问题:如果在DMA还没传完时,你的tx buffer是局部变量或者被释放了,DMA会继续从废弃的内存读数据,产生不可预测的结果。解决办法是确保缓冲区在DMA传输完成前一直有效,使用HAL_SPI_Transmit_DMA()前把buffer定义为全局或静态变量,并在回调函数中确认传输完成。
第二个是片选时序问题:硬件片选时,DMA结束后片选才恢复;但软件片选时,你必须在DMA传输完成回调函数中再拉高片选,这就增加了异步逻辑的复杂度。我的建议是:小数据量(几十字节内)用阻塞式收发,大数据量(屏幕缓冲、多页Flash写入)才用DMA。
有一个非常实用的小技巧:把“发命令字+收数据”合并成一个DMA收发流程。在tx buffer前面填充好命令字节,在接收buffer中跳过前面几个字节,一次性完成整个读写周期。这样可以减少片选拉低/拉高的频次,提升整体效率。缺点是逻辑复杂度增加,需要做好数组偏移处理。
6. SPI在常见外设上的应用实践:Flash、屏幕、SD卡与传感器
6.1 SPI Flash:读写时序与软件下载算法
SPI Flash(以W25Q系列为代表)是我用过最多的SPI从设备。它支持标准的读、写、擦除操作,本质都是通过发送命令字节+地址来完成的。读懂Flash数据手册里的命令表和时序图,是写好驱动的前提。
以W25Q64为例,几个关键命令:
- 0x9F:读JEDEC ID(判断芯片型号)
- 0x06:写使能(每次写操作前都要发送)
- 0x02:页编程(一次最多写256字节)
- 0x20:扇区擦除(一次擦除4KB)
- 0x03:读数据
这里最容易忽略的是写使能这一步骤。很多新手直接发页编程命令,结果Flash没有任何反应,查了半天发现忘了发0x06。而且每次写操作后,状态寄存器会显示忙状态(BUSY位),必须轮询状态寄存器直到不忙才能进行下一次操作。
另外,如果你在做STM32F429这类MCU的外部Flash下载算法(热搜里的“stm32f429的w25q256的spi下载算法”),本质就是实现一个Bootloader能识别的编程接口:Init、EraseChip、EraseSector、ProgramPage、Verify等。这里特别要提醒:下载算法的代码运行在RAM中,不能用Flash存储,否则算法加载时会自己把自己覆盖。
在实际开发中,我见过有人把W25Q64的页编程当普通写操作,一次写超过256字节,结果数据错乱。原因就是页编程超过一页边界后会自动回卷,覆盖当前页开头的数据。正确做法是:如果写入数据跨页,要拆分成多次页编程,每次不超过页边界。
6.2 SPI显示屏:ST7789/ILI9341刷新率与通信优化
Arduino接ST7789这类SPI屏幕,是很多人的入门作品。但每次全屏刷新卡顿、花屏的问题也让不少人头疼。
SPI屏幕刷新率受三个因素影响:SPI时钟频率、像素格式、显示控制器性能。ST7789一般支持最高几十MHz的SPI时钟。如果你以典型的240x240分辨率、16位色深刷一帧,数据量=240×240×2=115200字节。假设SPI时钟为20MHz,理论刷新时间约46ms,但实际考虑命令发送、等待TE信号、DMA启动开销,一帧在60-80ms很正常,对应13-16fps。
如果想提升刷屏速度,我有几条实测有效的经验:
- 使用DMA传输像素数据,CPU不用逐字节搬运。ST7789的初始化命令交互频率低,可以阻塞发命令,但图像数据一定要走DMA。
- 配置“写RAM”命令后的连续写模式,避免每写一行就重复发送命令和坐标。
- 如果屏幕支持8位并口或QSPI,优先用这些方式,刷新率能比标准SPI再快很多。不过对普通玩家,标准SPI加DMA已经够用。
另外,中高分辨率彩屏的花屏问题,很多是SPI时钟过快且走线过长导致的。我建议屏幕排线超过10cm时,SPI时钟保守一点,不要超过10MHz;有条件的话在SCLK上串一个33Ω电阻,减少过冲和振铃。
6.3 ESP32上屏幕与SD卡共享SPI的取舍
热搜里有个非常具体的问题:“esp32屏幕与sd卡共享spi哪个好?”这个问题很典型,因为很多离线语音助手、相册项目都要同时挂屏幕和SD卡,而ESP32的可用SPI外设有限。
为了节省IO,不少人把屏幕和SD卡挂到同一条SPI总线上,用不同的CS片选来区分。这个方案的优点是省IO、接线简单;缺点也很明显:
- 总线仲裁问题:两个设备不能同时占用总线,驱动层必须做好互斥。用完SD卡要立刻释放总线,屏幕刷新才能继续。
- 速度互相拖累:SD卡读文件时屏幕不能刷新,反之亦然,整体体验会卡顿。
- 干扰问题:SD卡的数据线往往走线较长,高频信号在SD卡操作时会对屏幕信号产生干扰,表现为花屏或偶发闪烁。
我的建议是:如果项目不复杂,直接共享SPI没问题,只要在应用层设计好“先读卡、再刷屏”或“刷屏间隙读卡”的调度逻辑。如果对流畅度要求较高,预算IO充足,就把屏幕和SD卡分开,屏幕用高速SPI,SD卡用SDMMC接口或另一路SPI,各跑各的,互不干扰。
我做离线语音相册时踩过这个坑:一开始共享SPI刷屏加读卡,发现每次切图都卡得不行,后来把SD卡换到SDMMC接口才彻底解决。所以如果你在ESP32上做类似项目,这是一条值得提前避开的弯路。
6.4 SPI传感器:DHT11、AFE4490这类特殊从设备
并不是所有“长得像SPI”的器件都老老实实按标准SPI工作。比如DHT11,虽然它有CLK和DATA线,但它的时序是单总线式的,和标准SPI并不兼容。这种器件用软件模拟反而更方便,因为它要求的是精确的us级延时和边沿判断,硬件SPI外设反而不容易满足。
另一个例子是AFE4490,这是一颗血氧模拟前端芯片,它的SPI接口就是比较标准的从设备。用它时的重点是读出来的数据是24位或32位的原始ADC值,必须配合采样时序和通道配置寄存器才能正确解析。所以这种传感器的驱动开发,SPI时序本身不是难点,数据手册里的寄存器映射才是核心。
通用的经验是:对于任何SPI传感器,第一步一定是读ID或状态寄存器,确认SPI配置正确,再去琢磨功能寄存器。否则如果SPI都没配对,后面写再多寄存器都是白费。
7. 高云FPGA的SPI远程升级、Verilog实现和SPI时钟约束
7.1 SPI在FPGA里的地位:主控与外设的桥梁
FPGA场景下,SPI常常以两种角色出现:一是作为从设备,接收MCU发来的配置数据;二是作为主设备,去控制板上的Flash、ADC等外设。热搜里“高云 基于spi接口的fpga远程升级实现”,以及“spi slave verilog”、“spi verilog”都指向同一个需求:用Verilog/VHDL自己写SPI控制器,或做在线升级逻辑。
写Verilog SPI模块时,核心就是状态机。一个最简单的SPI Master发送模块,至少有这样几个状态:空闲(IDLE)、发送(TRANSFER)、结束(DONE)。在TRANSFER状态中,通过一个计数器产生SCLK边沿,同时在恰当的边沿把MOSI数据送出,或者采样MISO数据。
我简单贴一个单字节SPI发送模块的思路(Mode 0):
module spi_master( input clk, // 系统时钟,比如50MHz input start, // 启动发送脉冲 input [7:0] tx_data, output reg sclk, output reg mosi, output reg done ); localparam IDLE = 2'b00, TRANS = 2'b01, DONE = 2'b10; reg [1:0] state; reg [3:0] bit_cnt; reg [15:0] clk_div; always @(posedge clk) begin case (state) IDLE: begin sclk <= 0; done <= 0; if (start) begin state <= TRANS; bit_cnt <= 0; clk_div <= 0; end end TRANS: begin clk_div <= clk_div + 1; if (clk_div == 0) begin sclk <= ~sclk; if (sclk) begin mosi <= tx_data[7 - bit_cnt]; bit_cnt <= bit_cnt + 1; end end if (bit_cnt == 8) state <= DONE; end DONE: begin done <= 1; state <= IDLE; end endcase end endmodule注意最后强调一点:在FPGA中使用SPI时,时序约束非常重要。如果输入时钟频率很高,而你在FPGA内部用组合逻辑再分频产生SCLK,很容易出现时钟树偏差和毛刺。推荐做法是使用FPGA内部的PLL/DLL产生SPI时钟,并加上适当的约束文件(SDC)来保证建立保持时间。这也是“fpga spi”项目里最容易被忽略、但直接影响稳定性的环节。
7.2 基于SPI的FPGA远程升级:实现思路与坑
简单说说远程升级的思路:通过某种通道(网口、串口、或者纯SPI Bootloader)接收新固件,写入外部Flash,然后重启引导加载。如果用在SPI Flash上,通常流程是:擦除对应扇区→写入新固件→设置启动标志→软复位。这个过程对SPI的可靠性要求极高,因为一旦写坏启动区,板子就变砖了。
所以在升级方案里,我一般建议留一个“双备份”机制:固化一个最小Bootloader在内部Flash(或OTP区),上电先检查外部Flash中是否有有效固件,有效才跳转。这样可以大大降低升级失败导致变砖的风险。另外在擦除和写入过程中要加CRC校验或者长度校验,防止写入不完整就被启动。
8. 常见问题排查与避坑技巧:实测经验速查表
8.1 SPI调试必知的排查顺序
每次SPI通信有问题,我几乎都按下面这个顺序排查,大多数问题都能在这些步骤里找到:
- 先确认接线:SCLK、MOSI、MISO、CS四根线有没有接对。特别是MOSI和MISO很容易接反,从者的MOSI要接主者的MOSI吗?不,从者的MOSI要接主者的MOSI,这个一定要看主从定义。我见过太多人把主机的MOSI接到从机的MISO上,结果完全不通。
- 再确认电平:用万用表量一下空闲时的CS是否为高、SCLK是否符合CPOL配置。如果CS空闲被拉低,说明GPIO初始化有问题。
- 确认时钟配置:CPOL/CPHA、分频值是否合适。分频太高(太快)时可能不稳定,先降到较慢速度验证。
- 用示波器或逻辑分析仪抓波形:这是最快定位问题的方式。看CS是否正常拉低,SCLK是否有脉冲,MOSI上的数据是否符合预期,MISO上从设备有没有返回数据。
- 最后怀疑芯片自身:如果以上都正常但就是不通,有可能是从设备的供电、复位、使能引脚没配置好。很多芯片有EN或RESET引脚,必须正确控制才会响应SPI。
8.2 几个“看起来诡异但真实存在”的经典问题
问题一:SPI通信偶尔出错,但不是每次。
这是最磨人的问题。通常诱因有三类:时钟太快导致建立/保持时间不足;电源纹波大、地线阻抗高导致逻辑电平抖动;片选时序没满足从设备要求。处理办法就是降速、加强电源滤波、检查片选时序。如果数据量不大,可以在关键操作后加一个“读回来校验”的机制,发现错误就重试,这比单纯追求高速可靠得多。
问题二:用DMA刷屏会花屏,但阻塞发送就正常。
这大概率是缓冲区和时序问题。花屏多发生在DMA和屏幕控制器时序不同步时,常见原因是:DMA传输完成后屏幕还在处理上一帧数据,此时开始下一次传输就会造成显示撕裂或花屏。解决办法是用TE(Tearing Effect)信号或延时等待前一帧完成;另外检查DMA的buffer是否被意外释放或覆盖。
问题三:读回的数据全是0xFF或0x00。
排除接线问题后,大概率是读命令没被从设备识别。原因是主设备在“读”之前发送的数据不是从设备期望的命令字。解决办法是用TransmitReceive函数同时收发,不要单独用Receive函数。
8.3 SPI调试小工具箱
工欲善其事,必先利其器。SPI调试时下面这几样东西能救你命:
- 逻辑分析仪:八通道、24MHz采样率的逻辑分析仪就够用了。抓取CS/SCLK/MOSI/MISO四根线的时序,一秒定位问题。社区版的PulseView软件使用PulseView时可以查看总线协议解码,SPI解码非常方便。
- 示波器:逻辑分析仪只能看数字电平高低,如果存在信号完整性问题(过冲、振铃、边沿太慢),必须用示波器。带宽100MHz起步。
- USB转SPI适配器:有些时候你想在不写主控代码的情况下先用电脑验证从设备是否正常,USB转SPI适配器(比如FT2232H模块)就很有用了。
8.4 ARDUINO引脚映射、极路由SPI编程器等趣味案例
热搜里“arduino st7789 spi屏”还有一个典型问题是Arduino的SPI引脚映射。UNO的SPI引脚固定在D13(SCLK)、D11(MOSI)、D12(MISO)、D10(CS,但可以改到任意引脚)。很多国产板或自定义板卡引脚的映射不同,网上抄的例程如果不改引脚定义,必然白屏。所以用Arduino接SPI屏幕时,要么用官方SPI库的默认引脚定义,要么自己在初始化函数里指定引脚。
至于“极路由4增强版 spi 编程器 固件”这种关键词,反映的是路由器主板上的SPI Flash编程应用。这类操作需要把Flash芯片吹下来或用烧录夹夹住,用编程器直接读写Flash。原理和普通SPI Flash读写完全一致,只是工具换成了专用编程器。自己玩的时候注意备份原始固件,设置烧录前校验,避免变砖。
9. SPI通信的未来方向与协议升级:从单线到多线、从并行到点到点
很多初学者以为SPI是一个“过去的技术”,跟不上时代了。但事实恰好相反,SPI在嵌入式内部通信中的地位依然稳固,而且在不断演化。
QSPI(Quad SPI)就是在标准SPI基础上增加了4根数据线(IO0~IO3),一次时钟可以传4个bit,速度和吞吐量大幅提升。新型Flash、屏幕驱动芯片普遍支持QSPI。OSPI(Octa SPI)更进一步,使用8根数据线,速率可以跑到数百MHz。很多高端MCU(比如STM32H7系列)内置QSPI/OSPI控制器,就是为了匹配高吞吐外设。
同时,SPI在多设备总线上也有改进:传统的“一根CS对应一个设备”在设备很多时会消耗大量GPIO。一些芯片开始支持“菊花链”(Daisy Chain)拓扑,或者使用带地址编码的SPI协议变体(比如SPI with addressable slave),从而减少片选线数量。
从协议层面看,SPI的简单性本身就是它最强的生命力。它没有IIC那种复杂的总线仲裁、也没有UART的波特率对齐问题,时序直白,调试方便。只要你在选型和实现上多留一个心眼,它就能稳定跑很多年。
在可预见的未来,SPI依然会活跃在MCU与传感器、存储、显示设备之间的“最后一厘米”通信中。哪怕有了更高速的接口,SPI作为一种优雅、简单、高效的点对点同步通信方式,仍然值得每一个嵌入式开发者花时间去学透、用活。