1. 从一次硬件调试的“玄学”问题说起
去年,我在调试一块基于STM32H750的工控板时,遇到了一个让我头疼了好几天的问题。板子上挂载了一块SPI接口的LCD屏,为了提升刷新效率,我启用了DMA进行数据传输。理论上,这应该是一劳永逸的优化,但实际跑起来,屏幕上的图像时不时就会出现撕裂、错位,甚至整屏花掉。更“玄学”的是,这个问题并非每次必现,有时连续运行几小时都正常,有时刚上电就出问题。我排查了电源、时钟、内存配置,甚至怀疑过LCD屏本身的质量,但都无果。最终,问题的根源锁定在了SPI通信的时序细节和DMA传输的边界处理上——一个在数据手册里只有寥寥几行描述,但在实际应用中却至关重要的点。
这个经历让我再次深刻体会到,对于SPI这种“看似简单”的通信协议,仅仅知道它有四根线、主从模式、全双工是远远不够的。无论是嵌入式新手在驱动ILI9341屏时遇到的初始化失败,还是老手在实现SPI NAND Flash的BP(块保护)功能时的困惑,亦或是FPGA工程师在编写可综合SPI IP核时对时序约束的纠结,其本质都是对SPI协议底层机制的理解深度不够。
今天,我们就抛开那些千篇一律的理论介绍,从一个一线开发者的视角,重新梳理SPI通信。我会结合STM32、ESP32等常见平台,以及LCD、ADC、编码器等具体外设的驱动经验,不仅讲清楚SPI的四种模式、时序波形、菊花链拓扑,更会深入到DMA配合、软件/硬件片选优劣、约束与调试等实战中必然遇到的“深水区”。无论你是在调MT6701磁性编码器,还是在写ICM-42688P陀螺仪的SPI驱动,抑或是苦恼于RTS5735主控的SPI Flash启动问题,希望这篇整理能成为你手边一份实用的“避坑指南”。
2. SPI的核心:不止四根线,更是一种“对话节奏”
提到SPI,大家的第一反应通常是那四根标准线:SCLK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。这没错,但如果我们只停留在引脚定义,那就错过了SPI的精髓。SPI本质上是一种同步的、全双工的、主从式的串行通信协议。关键在于“同步”,这意味着通信双方必须遵循一套严格的“对话节奏”,而这个节奏,就由时钟极性(CPOL)和时钟相位(CPHA)这两个参数共同定义,也就是我们常说的SPI模式。
2.1 深入理解四种模式:从波形图到代码配置
为什么会有四种模式?这是因为不同的从设备(Slave)内部采样数据的方式可能不同。主设备(Master)必须迁就从设备的节奏,否则数据就会错位。
- CPOL (Clock Polarity):时钟极性。它定义了SCLK线在空闲状态(即CS有效、但数据传输未开始或已结束时)的电平。
- CPOL=0:空闲时SCLK为低电平。
- CPOL=1:空闲时SCLK为高电平。
- CPHA (Clock Phase):时钟相位。它定义了数据在时钟的哪个边沿被采样(捕获)。
- CPHA=0:数据在时钟的第一个边沿(即SCLK从空闲状态跳变到相反状态的第一个边沿)被采样。对于CPOL=0,第一个边沿是上升沿;对于CPOL=1,第一个边沿是下降沿。
- CPHA=1:数据在时钟的第二个边沿(即第一个边沿之后的那个边沿)被采样。
这四种组合(CPOL, CPHA)就是Mode 0-3。我强烈建议你不要死记硬背,而是学会看时序图。以最常见的Mode 0 (CPOL=0, CPHA=0)为例:
- 空闲时,SCLK为低(CPOL=0)。
- 当CS拉低,通信开始。
- 数据在SCLK的上升沿被采样(CPHA=0,第一个边沿是上升沿)。
- 数据(无论是MOSI还是MISO)需要在SCLK上升沿到来之前就已经稳定在数据线上,这个提前的时间就是“建立时间”(Setup Time)。在上升沿之后,数据还需要保持一段时间,即“保持时间”(Hold Time)。
实战配置(以STM32 HAL库为例):当你使用HAL_SPI_Init()初始化时,需要填充一个SPI_HandleTypeDef结构体。其中,模式配置通常如下:
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; // 主模式 hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 全双工 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; // 数据位宽 hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL = 0 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA = 0 (注意:HAL库中1EDGE对应CPHA=0) hspi1.Init.NSS = SPI_NSS_SOFT; // 软件管理片选 hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_32; // 波特率分频 hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; // 高位先行 hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); }注意:不同厂商的库对CPHA的定义可能不同!STM32 HAL库中
SPI_PHASE_1EDGE表示在第一个边沿采样(即CPHA=0),而有些平台或数据手册可能反过来。最可靠的方法是核对从设备的数据手册时序图,并用自己的逻辑分析仪抓取波形进行验证。我调试ICM-42688P时,就曾因为这个小差异浪费了半天时间。
2.2 数据帧格式:MSB先行还是LSB先行?
另一个容易忽略的细节是数据位的传输顺序。大部分SPI设备是MSB(最高有效位)先行,但有些设备(例如某些型号的ADC)可能是LSB(最低有效位)先行。这需要在初始化时正确配置。如果顺序错了,你读取的数据值将是完全混乱的。
如何确定?老规矩——查数据手册。在SPI通信章节,一定会有一张时序图,图上会标明D7、D6...D0(对于8位数据)对应的位置,第一个传输的就是最高位或最低位。
3. 硬件片选 vs. 软件片选:不仅仅是节省一个GPIO
片选(CS/SS)信号用于选择当前与主设备通信的从设备。它的管理方式有两种:硬件片选(Hardware NSS)和软件片选(Software NSS)。
- 硬件片选:使用SPI控制器内置的NSS引脚功能。当SPI配置为主模式且硬件NSS使能时,控制器会自动在数据传输开始前拉低指定的NSS引脚,在传输结束后拉高。优点是节省CPU开销,时序精准严格。缺点是通常一个SPI外设只有一个硬件NSS输出,难以直接驱动多个片选(除非配合外部译码器)。
- 软件片选:使用普通的GPIO引脚来模拟片选信号。在调用SPI传输函数前,手动拉低该GPIO;传输完成后,再手动拉高。这是最灵活、最常用的方式,可以轻松控制多个从设备。但需要开发者自己管理时序。
我的经验选择:
- 单个从设备,且时序要求极其严格:可以考虑使用硬件片选,但务必确认你的MCU SPI外设支持此模式,且NSS引脚确实被正确配置为复用推挽输出模式。
- 绝大多数情况,尤其是多从设备场景:毫不犹豫地使用软件片选。它的可控性更强。例如,在连续发送命令和数据时,你可能需要在整个命令-数据序列期间保持CS有效,而不是每传输一个字节就开关一次。软件片选可以轻松实现这一点。
软件片选的注意事项:
- 建立和保持时间:在拉低CS后,应等待一小段时间(通常至少是1个SCLK周期)再启动SPI时钟,确保从设备已准备好。在传输最后一个字节的最后一个时钟边沿后,也应等待一小段时间再拉高CS。很多SPI Flash器件的数据手册会明确要求这个时间。
// 示例:驱动SPI Flash的写使能指令 void SPI_Flash_WriteEnable(void) { CS_LOW(); // 拉低片选 delay_us(1); // 等待建立时间,具体值查Flash数据手册 HAL_SPI_Transmit(&hspi1, (uint8_t[]){0x06}, 1, 1000); // 发送写使能指令码 0x06 delay_us(1); // 等待保持时间 CS_HIGH(); // 拉高片选 } - 多从设备隔离:确保在任何时刻,只有一个从设备的片选是有效的。否则,多个从设备的MISO线可能会发生冲突(多个输出短路到一起),导致数据错误甚至损坏硬件。
4. 进阶拓扑:菊花链(Daisy Chain)与多从机配置
当需要连接多个相同型号的SPI从设备时,除了每个设备独立占用一组片选线这种“星型”连接,还可以考虑菊花链连接。
菊花链原理:将所有设备的MISO和MOSI首尾相接。主设备的MOSI连接到第一个设备的MOSI,第一个设备的MISO连接到第二个设备的MOSI,以此类推,最后一个设备的MISO连接回主设备的MISO。所有设备共享SCLK和CS信号。
- 工作过程:主设备发出N个字节的数据。在第一个SCLK周期,主设备发出的位进入设备1的移位寄存器,设备1移出的位(可能是其寄存器中的值)进入设备2,依此类推。经过N*8个时钟后,主设备发出的N字节数据依次填满了从设备1到N的移位寄存器,同时,从设备N到1的数据也依次被移入主设备的接收寄存器。
- 优点:极大节省了片选GPIO和布线。常用于驱动多个串联的LED驱动芯片(如WS2812的SPI模拟方案)、数字电位器等。
- 缺点:所有从设备必须同时参与每次传输,无法独立寻址某个设备进行读写。通信效率取决于链上设备数量,且软件处理数据时需要做移位拼接。
多从机独立片选配置:这是更通用的方式。每个从设备有独立的CS线,但共享SCLK、MOSI、MISO三根线。主设备通过拉低对应设备的CS来激活它。
- 关键挑战:MISO线的冲突。必须确保未被选中的从设备将其MISO引脚置于高阻态(High-Impedance)。绝大多数SPI从设备都具备三态输出功能,当自己的CS无效时,会自动释放MISO线。但你在硬件设计时仍需确认这一点。
- 软件实现:就是简单的分时复用。操作A设备时,拉低A_CS,操作完成后拉高;再操作B设备,拉低B_CS。
5. 性能之刃:SPI与DMA的联姻与陷阱
直接使用CPU通过HAL_SPI_Transmit/Receive来搬运数据,在低速或小数据量时没问题。但当你需要驱动高分辨率LCD(如通过SPI刷屏)、高速ADC连续采样(如ADS1256)、或与FPGA进行大数据块交换时,CPU频繁被中断占用,系统效率会急剧下降。此时,必须请出DMA(直接存储器访问)。
5.1 如何正确配置SPI DMA?
以STM32驱动SPI LCD为例,目标是使用DMA将帧缓冲区(Frame Buffer)的数据源源不断地发送到LCD的GRAM。
外设与内存配置:
- 外设地址:固定为SPI数据寄存器(DR)的地址。DMA会持续向这个地址写入数据。
- 内存地址:是你的帧缓冲区数组的首地址。DMA会从这里读取数据。
- 方向:内存到外设(MEM2PERIPH)。
- 数据宽度:需要匹配。如果SPI是8位数据,则DMA源(内存)和目标(外设)的数据宽度都应设为Byte。如果SPI是16位数据(例如某些LCD支持16位颜色数据一次传输),则DMA宽度应设为HalfWord。不匹配会导致数据错位。
传输模式:
- 普通模式(Normal):DMA传输完指定数据量后自动停止。适合单次传输。
- 循环模式(Circular):DMA传输完指定数据量后,自动从头开始循环传输。这是驱动LCD刷新的关键模式。你只需要初始化一次DMA,它就会在后台循环地将帧缓冲区的内容发送出去,CPU完全被解放。
关键代码片段(STM32 HAL库):
// 假设 hspi1, hdma_spi1_tx 已初始化 // 启动SPI TX DMA循环传输 HAL_StatusTypeDef ret = HAL_SPI_Transmit_DMA(&hspi1, frame_buffer, FRAME_BUFFER_SIZE); if (ret != HAL_OK) { // 错误处理 } // 此时,SPI会以最大速率(由BaudRatePrescaler决定)持续发送frame_buffer中的数据。 // 你需要做的就是在CPU侧更新frame_buffer的内容,屏幕显示就会随之改变。
5.2 我踩过的那个“玄学”坑:DMA传输边界与SPI时钟的同步
回到开头我提到的STM32H750 DMA驱动SPI LCD花屏的问题。现象是随机花屏、撕裂。排查过程如下:
- 怀疑内存速度:H750的帧缓冲区放在DTCM(高速紧耦合内存)里,速度绝对够,排除。
- 怀疑SPI时钟:降低波特率,问题依旧,排除。
- 怀疑时序:用逻辑分析仪抓取SPI波形,发现数据、时钟、片选信号本身都很干净,时序参数也完全满足LCD数据手册要求。
- 关键发现:在长时间抓取波形后,我偶然发现,在出现花屏的那一帧数据传输的起始位置,第一个SCLK时钟边沿与CS信号下降沿的间隔,偶尔会比其他正常帧的间隔短那么几十纳秒。虽然这个时间仍然在LCD器件的建立时间要求之内,但似乎触发了某种不稳定状态。
根因分析:STM32的SPI外设,当使用DMA进行连续、循环传输时,DMA控制器负责搬运数据到SPI数据寄存器(DR)。SPI外设则在数据寄存器非空时,自动启动一次发送。然而,在DMA完成一次“突发传输”(比如搬完一行像素数据)到下一次“突发传输”开始的间隙,存在一个极短的时间窗口。在这个窗口里,如果SPI的时钟恰好处于某个临界状态,而CS信号又由软件控制(我使用的是软件片选,且为了效率,在整个刷屏期间CS一直保持有效),就可能导致从设备(LCD)在第一个时钟边沿到来时,对第一个数据位的采样出现亚稳态或偏差。
解决方案:这并不是SPI或DMA的bug,而是一个需要特别注意的设计边界。我采取了组合方案:
- 方案一(软件调整):在启动DMA传输前,先手动发送一个虚拟字节(Dummy Byte),让SPI时钟先“跑起来”,进入稳定状态,然后再开始传输有效帧数据。对于循环DMA,可以在初始化帧缓冲区时,将第一个字节设为虚拟字节。
- 方案二(硬件调整):将片选(CS)也交给硬件SPI外设管理(如果支持),或者使用一个定时器精确控制CS信号相对于SCLK的时序。但对于我的场景,软件方案一已足够。
- 方案三(最有效):在SPI初始化时,将CPHA从0改为1(Mode 0 -> Mode 1)。这样,数据采样的边沿从时钟的第一个边沿移到了第二个边沿。相当于给数据在时钟线上稳定下来预留了更多的时间(多出了半个时钟周期),容错性大大增强。修改后,问题彻底消失。
核心教训:在高速SPI DMA传输中,特别是使用软件片选时,时钟相位(CPHA)的选择可能比想象中更重要。Mode 1或Mode 3(在第二个边沿采样)通常比Mode 0或Mode 2具有更好的抗干扰能力,因为数据有更长的稳定时间。在满足从设备要求的前提下,可以优先尝试。
6. 特殊变体与常见外设驱动要点
SPI协议有一些常见的变体,用于适应不同的硬件限制或应用场景。
- 三线SPI(半双工):只使用一根数据线进行双向通信(有时标记为SIO),通过方向控制来切换读写。这节省了一个引脚,但牺牲了全双工能力,通信协议需要更复杂的状态管理。一些简单的传感器会采用此模式。
- 单线双向SPI:类似于三线,但可能通过极性的切换来实现双向,更不常见。在驱动某些ADC时可能会遇到,需要仔细阅读数据手册的通信章节。
常见外设驱动要点:
SPI Flash (如 W25Qxx, GD25Qxx):
- 关键指令:写使能(WREN)、页编程(PP)、扇区擦除(SE)、读数据(READ)。
- 注意:写操作前必须先发WREN;写入地址必须对齐到页(通常256字节);擦除以扇区(通常4KB)为单位。必须处理“忙状态”,在写或擦除操作后,需要循环读取状态寄存器,直到“忙”位清零才能进行下一步操作。
- BP保护位:SPI NAND/NOR Flash中的BP(Block Protect)位用于写保护。误操作可能导致芯片被锁死,无法写入。操作前务必清楚这些非易失性保护位的状态。
SPI LCD (如 ILI9341, ST7789):
- 初始化序列:通常很长,包含几十条命令和参数。务必使用厂家提供的初始化代码,并注意电源上电时序(Reset、VCC、IOVCC的先后)。
- GRAM写入:设置好行列地址窗口后,连续写入颜色数据。使用DMA可以极大提升刷屏效率。
- 像素格式:注意是RGB565还是RGB666,数据位是8位传输还是16位传输。
SPI 编码器 (如 MT6701, BRT38):
- 绝对位置读取:这类编码器通常提供多圈绝对位置值,数据长度可能是14位、16位或更多。读取时要注意SPI的数据位宽设置,可能需要分多次读取再拼接。
- CRC校验:高端编码器通信可能包含CRC校验字段,需要在软件中实现校验,确保数据可靠性。
SPI 传感器 (如 ICM-42688P):
- 寄存器映射:传感器通常有一组配置寄存器。读写寄存器时,注意寄存器地址的最高位通常用于指示读(1)或写(0)操作。
- 突发读取:为了高效读取多个连续的传感器数据(如加速度XYZ、角速度XYZ),可以使用SPI的“突发读”功能,即先发送读命令和起始地址,然后连续读取多个字节,传感器会自动递增内部地址。
7. 调试艺术:逻辑分析仪与示波器是你的眼睛
再好的理论,没有调试工具验证都是空中楼阁。对于SPI调试,逻辑分析仪是首选,数字示波器则是补充。
- 逻辑分析仪(如Saleae):
- 用途:解码SPI协议。连接SCLK, MOSI, MISO, CS四根线,设置好阈值电压、采样率(通常几MHz就够)和SPI模式(CPOL, CPHA)。
- 你能看到:清晰的、按字节解码的十六进制数据流,可以直观地看到主设备发送的命令、地址、数据,以及从设备返回的数据。可以轻松检查数据内容、顺序、时序间隔是否正确。这是我排查通信问题最依赖的工具。
- 数字示波器:
- 用途:测量精确的时序参数(建立时间、保持时间、时钟频率、上升/下降时间)和信号质量(过冲、振铃、毛刺)。
- 当通信不稳定时:用示波器查看SCLK和MOSI/MISO的波形,看是否有明显的畸变、毛刺。测量CS下降沿到第一个SCLK边沿的时间(建立时间),以及最后一个SCLK边沿到CS上升沿的时间(保持时间),看是否满足从设备数据手册的要求。
一个典型的调试流程:
- 通信失败时,先用逻辑分析仪抓取一次完整的通信波形。
- 检查解码出的数据:命令对不对?地址对不对?CRC(如果有)对不对?从设备有没有回数据?
- 如果数据内容看起来都对,但设备还是不响应,就用示波器去测量关键时序点的信号质量。
- 对照从设备数据手册的“AC Timing Characteristics”表格,逐一核对参数是否达标。
8. 跨越边界:SPI在FPGA与高速系统中的考量
当SPI的主设备或从设备是FPGA时,情况又有所不同。
- FPGA实现SPI Slave:你需要用Verilog/VHDL编写一个状态机,精确地响应SCLK的边沿,在正确的边沿采样MOSI数据,并在另一个边沿更新MISO数据。同时要处理CS信号,在CS无效时,MISO输出应设为高阻态(
z)。- 关键点:对输入信号(SCLK, MOSI, CS)进行同步化处理(打两拍),避免亚稳态。内部状态机的时钟域最好使用比SPI时钟频率高得多的系统时钟,通过边沿检测来响应SPI时钟。
// 简化的SPI Slave接收边沿检测示例(Verilog) reg sclk_dly1, sclk_dly2; always @(posedge sys_clk) begin sclk_dly1 <= spi_sclk; sclk_dly2 <= sclk_dly1; end wire sclk_rising_edge = (~sclk_dly2 & sclk_dly1); // 检测上升沿 wire sclk_falling_edge = (sclk_dly2 & ~sclk_dly1); // 检测下降沿 // 根据CPHA和CPOL,决定在哪个边沿采样或输出数据 - SPI约束(SDC Timing Constraints):在FPGA项目中,如果SPI是连接外部芯片的接口,你必须为这些引脚添加正确的时序约束,告诉综合布线工具这些信号之间的时序关系(如输入延迟、输出延迟),否则工具无法保证FPGA内部逻辑采样到的数据是稳定的,可能导致随机错误。
- 例如,对于SPI输入(MISO):你需要告诉工具,数据在SCLK的哪个边沿之后多久会稳定(
set_input_delay)。 - 对于SPI输出(MOSI, CS):你需要告诉工具,数据必须在SCLK的哪个边沿之前多久准备好(
set_output_delay)。 - 这些约束值来源于从设备数据手册的时序参数。没有正确的约束,你的FPGA设计在高速下几乎无法稳定工作。
- 例如,对于SPI输入(MISO):你需要告诉工具,数据在SCLK的哪个边沿之后多久会稳定(
SPI协议,其简洁性之下隐藏着无数影响稳定性的细节。从模式选择、片选管理到DMA配合、时序约束,每一步都需要理论与实践紧密结合。记住,数据手册是你的第一参考,逻辑分析仪是你的第二双眼睛,而不断的实践与踩坑,则是将知识转化为经验的唯一途径。希望这篇结合了大量实战案例的整理,能帮助你在下一次SPI项目开发中,少走一些弯路,多一份从容。