ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SPI通信协议详解:从原理到嵌入式开发实战

SPI通信协议详解:从原理到嵌入式开发实战 1. 项目概述从“串口”到“SPI”的认知升级搞嵌入式开发的朋友对“串口”UART肯定不陌生这是调试和打印日志的“生命线”。但当你开始驱动一块屏幕、读写一个SD卡或者配置一个传感器时光靠串口那“你一句我一句”的聊天方式效率就太低了。这时候你大概率会碰到一个更高效的“内部沟通协议”——SPI。SPI全称Serial Peripheral Interface中文叫串行外设接口。我第一次接触它是为了驱动一块OLED屏幕。当时用串口发送一屏图像数据慢得让人抓狂而切换到SPI后画面刷新瞬间流畅那种速度提升带来的爽快感让我立刻意识到在芯片和外围器件之间进行大量、高速的数据搬运SPI才是真正的“实力派”。它不像I2C那样需要地址寻址和应答确认更像是一个高速的、单向或双向的“数据管道”主设备一声令下数据就开始同步、全双工地流动。简单来说SPI是一种高速、全双工、同步的串行通信总线。它被设计用来在短距离内通常是板级连接实现微控制器主设备与各种外围器件从设备如存储器、传感器、ADC、DAC、数字电位器、无线模块等之间的快速数据交换。它的核心思想是“用四根线实现主从设备间时钟同步的串行数据收发”结构简单但效率极高。对于需要频繁读写、实时性要求高的场景比如显示驱动、音频编解码、高速数据采集SPI几乎是首选方案。2. SPI通信的核心原理与硬件架构拆解要玩转SPI不能只停留在调用库函数的层面必须理解它的硬件工作原理。这就像开车知道油门刹车是基础但了解发动机和变速箱如何协同工作才能应对复杂路况。2.1 四线制与信号定义SPI通信最少需要四根线这也是它最经典的模式。这四根线各司其职缺一不可SCLK (Serial Clock) - 串行时钟线由主设备产生并输出给所有从设备。这是整个通信系统的“心跳”所有数据的发送和接收都严格跟随这个时钟的边沿进行。时钟频率决定了通信速度主设备可以控制SCLK的快慢。MOSI (Master Out Slave In) - 主设备输出从设备输入线数据从主设备流向从设备。主设备通过这根线发送命令或数据给从设备。MISO (Master In Slave Out) - 主设备输入从设备输出线数据从从设备流向主设备。主设备通过这根线读取从设备的数据。SS/CS (Slave Select / Chip Select) - 从设备选择线由主设备控制用于选择要与哪个从设备进行通信。通常为低电平有效。当主设备将某个从设备的SS线拉低时表示“我选中你了准备和我通信”。未被选中的从设备必须将其MISO线置于高阻态以避免总线冲突。这里有一个关键点MOSI和MISO是以主设备为视角命名的。对于主设备数据从自己“出去”(Out)到从设备就是MOSI数据从从设备“进来”(In)到自己就是MISO。对于从设备它的“输入”脚连接的是主设备的MOSI线它的“输出”脚连接的是主设备的MISO线。理解这一点在画原理图或飞线调试时能避免很多低级错误。2.2 时钟极性(CPOL)与时钟相位(CPHA)通信的“节奏”这是SPI配置中最容易让人混淆但也最关键的两个参数。它们共同定义了数据在时钟信号的哪个边沿被采样捕获和哪个边沿被更新输出。时钟极性 CPOL (Clock Polarity)定义了SCLK线在空闲状态即两次传输之间SS为高电平时的电平。CPOL0SCLK空闲时为低电平。CPOL1SCLK空闲时为高电平。时钟相位 CPHA (Clock Phase)定义了数据采样的边沿。CPHA0数据在时钟的第一个边沿即SCLK从空闲状态跳变到有效状态的第一个边沿被采样在下一个边沿更新。CPHA1数据在时钟的第二个边沿被采样在第一个边沿更新。这两个参数组合起来就形成了SPI的四种工作模式Mode 0, 1, 2, 3Mode 0: CPOL0, CPHA0Mode 1: CPOL0, CPHA1Mode 2: CPOL1, CPHA0Mode 3: CPOL1, CPHA1如何选择模式这完全取决于你的从设备芯片的数据手册Datasheet。手册的SPI接口时序图部分会明确规定该芯片支持哪种模式。主设备你的MCU必须配置成与从设备完全相同的模式否则通信必然失败。我个人的经验是Mode 0和Mode 3最为常见。在初始化外设时第一件事就是翻手册确认这个模式并写在代码注释里这能节省大量后续调试时间。2.3 单主多从的拓扑结构一个SPI主设备可以连接多个从设备这是通过多根SS线实现的。常见的连接方式有两种独立片选型这是最标准、最推荐的方式。主设备为每个从设备提供一根独立的SS线。通信时主设备只将目标从设备的SS线拉低其他保持高电平。这种方式互不干扰软件控制简单但会占用主设备较多的GPIO口。菊花链型所有从设备的MISO和MOSI依次串联形成一个环。主设备只需要一根SS线连接第一个从设备。数据从主设备MOSI发出经过第一个从设备再传到第二个依次类推读取时数据从最后一个从设备传回主设备MISO。这种方式节省GPIO但所有从设备必须支持菊花链模式且软件上需要处理数据在链中的移位灵活性较差通常用于特定场景如多个级联的移位寄存器。在绝大多数项目中我们使用的都是独立片选型。布线时SCLK、MOSI、MISO这三根线是所有从设备并联到主设备上的只有SS线是各自独立的。务必注意并联的从设备其MISO引脚必须具有高阻态输出能力当它未被选中时MISO引脚要“断开”与总线的连接否则会总线冲突。3. SPI通信的完整数据交换过程解析理解了硬件和模式我们来看数据是如何在线上流动的。这个过程是同步的、全双工的意味着发送和接收同时进行。3.1 一次完整的传输时序假设我们配置为Mode 0 (CPOL0, CPHA0)主设备需要向从设备写入一个字节0xAA同时读取从设备返回的一个字节。初始化与选择通信开始前SCLK根据CPOL0保持为低电平。主设备将目标从设备的SS线拉低表示通信开始。第一个时钟边沿采样边沿在Mode 0下第一个边沿是SCLK的上升沿从低到高。在这个上升沿到来之前主设备必须将要发送数据的最高位MSB对于0xAA即1放到MOSI线上同时从设备也必须将其要发送数据的最高位放到MISO线上。第一个时钟边沿采样动作SCLK上升沿到来时主设备在MISO线上采样读取从设备发出的数据位从设备在MOSI线上采样读取主设备发出的数据位。至此双方完成了第一个数据位的交换。第二个时钟边沿更新边沿紧接着SCLK变为下降沿。在这个下降沿到来之前通信双方需要准备好下一个要发送的数据位0xAA的次高位。循环上述步骤2-4重复8次完成一个字节8位的传输。每次时钟跳变都同时完成一位的发送和一位的接收。传输结束8位传输完成后主设备将SS线拉高本次传输结束。SCLK根据CPOL恢复空闲低电平。关键提示很多初学者会疑惑“我明明只想发送为什么还要读” 这是由于SPI硬件移位寄存器的特性决定的。主设备每向移位寄存器写入一位准备从MOSI移出就会同时从MISO接收一位从移位寄存器移入。即使你不在乎接收的数据这个物理过程也会发生。因此SPI的收发函数通常是统一的发送一个字节的同时会返回一个接收到的字节。3.2 数据帧格式与大小端SPI协议本身没有规定一个“帧”里有多少位也没有规定先传高位(MSB)还是低位(LSB)。这些都是可配置的但同样必须与从设备保持一致。数据位宽最常见的是8位一个字节。但有些设备支持16位、32位甚至更长的传输。例如某些高精度ADC的转换结果是24位的就可以配置SPI为24位传输模式一次读回完整数据效率更高。位顺序绝大多数设备采用MSB First即先传输最高位。在初始化SPI外设时这是一个需要重点检查的配置项。如果主从设备位顺序不一致读回来的数据将是完全错乱的。3.3 全双工、半双工与单工模式全双工上述过程就是标准的全双工MOSI和MISO同时工作收发同时进行。这是SPI最高效的模式。半双工有些简单的从设备如某些传感器可能只使用一根数据线如只用MOSI或只用MISO或者主从设备约定好在某段时间内只进行一个方向的数据传输。这需要软件上进行控制。单工只朝一个方向传输数据例如只向DAC写数据或只从ADC读数据。硬件上可以只连接MOSI或MISO线。即使在硬件上是全双工连接软件上也可以实现半双工或单工通信关键在于你如何管理SS线和时钟的启停。4. SPI在嵌入式系统中的软件实现与驱动要点理论懂了最终要落到代码上。不同MCU的SPI外设库函数可能不同但核心逻辑相通。4.1 外设初始化配置清单在初始化MCU的SPI外设时你需要配置以下参数这份清单请务必对照芯片手册逐一核对工作模式设置为主模式Master。时钟极性(CPOL)与相位(CPHA)根据从设备手册选择正确的Mode。数据大小通常为8位或16位。位顺序通常为MSB First。时钟预分频器这是设置SPI通信速率的关键。SCLK频率 MCU的SPI外设时钟源频率 / 预分频系数。你需要根据从设备支持的最高时钟频率在从设备手册中查找如10MHz和你的应用需求来设置。初期调试时可以先用一个较低的频率如1MHz确保通信稳定后再逐步提高。NSS片选管理选择硬件管理还是软件管理。硬件管理MCU的SPI外设自动控制一根专用的NSS引脚。这种方式在通信开始和结束时自动拉低/拉高方便但不够灵活尤其是在多从设备系统中。软件管理强烈推荐这种方式。将SS引脚配置为普通的GPIO输出模式在代码中手动控制其电平。这样你可以自由控制通信的起始和结束也方便连接多个从设备。spi_transfer()函数内部不应对SS引脚进行操作。4.2 核心收发函数与代码示例一个典型的SPI字节收发函数软件片选的伪代码逻辑如下/** * brief 通过SPI交换一个字节 * param data: 要发送的字节 * retval 接收到的字节 */ uint8_t spi_swap_byte(uint8_t data) { uint8_t rx_data 0; // 1. 等待发送缓冲区为空确保可以写入新数据 while(SPI_GetFlagStatus(SPI_FLAG_TXE) RESET); // 2. 向数据寄存器写入要发送的数据启动传输 SPI_SendData(data); // 3. 等待接收缓冲区非空数据已接收 while(SPI_GetFlagStatus(SPI_FLAG_RXNE) RESET); // 4. 读取接收到的数据 rx_data SPI_ReceiveData(); return rx_data; } /** * brief 与特定从设备交换多个字节 * param cs_pin: 从设备的片选GPIO引脚 * param tx_buf: 发送数据缓冲区指针 * param rx_buf: 接收数据缓冲区指针 * param size: 要交换的数据长度 */ void spi_transfer(GPIO_Pin cs_pin, uint8_t *tx_buf, uint8_t *rx_buf, uint16_t size) { // 选中从设备 GPIO_WriteLow(cs_pin); for(uint16_t i 0; i size; i) { uint8_t tx_byte (tx_buf ! NULL) ? tx_buf[i] : 0xFF; // 如果只读通常发送0xFF uint8_t rx_byte spi_swap_byte(tx_byte); if(rx_buf ! NULL) { rx_buf[i] rx_byte; } } // 取消选中从设备 GPIO_WriteHigh(cs_pin); }实操心得在只读操作时例如读取传感器ID我们通常发送0xFF全1或0x00作为“哑元”Dummy Byte来产生时钟从而驱动从设备输出数据。具体发送什么有些器件手册会有规定没规定时用0xFF是通用做法。4.3 多从设备管理与片选策略当有多个SPI从设备时软件架构需要清晰// 定义各从设备的片选引脚 #define CS_FLASH_PIN GPIO_PIN_4 #define CS_SENSOR_PIN GPIO_PIN_5 #define CS_SDCARD_PIN GPIO_PIN_6 // 不同的传输函数 void flash_read_id(void) { uint8_t cmd 0x9F; // 读ID命令 uint8_t id[3]; spi_transfer(CS_FLASH_PIN, cmd, NULL, 1); // 发送命令 spi_transfer(CS_FLASH_PIN, NULL, id, 3); // 读取3字节ID发送哑元 // ... 处理id } void sensor_read_data(void) { uint8_t cmd 0x80; // 假设的读数据寄存器命令 uint8_t data[6]; spi_transfer(CS_SENSOR_PIN, cmd, NULL, 1); spi_transfer(CS_SENSOR_PIN, NULL, data, 6); // ... 处理data }关键点每次spi_transfer函数调用都必须严格包含“拉低CS - 交换数据 - 拉高CS”的完整过程。即使在一次逻辑操作中需要连续发送多个命令和数据只要中间没有其他设备操作CS可以一直保持低电平。但一旦操作完成必须立即拉高CS。CS引脚上的一个毛刺或过长的低电平都可能被从设备误认为是一次新的通信开始。5. SPI实战应用驱动OLED屏幕与Flash存储器让我们通过两个最典型的例子把上面的理论串联起来。5.1 驱动SPI OLED屏幕以SSD1306为例这类屏幕通常支持4线SPI模式。通信过程主要是主设备MCU向屏幕从设备发送命令和数据。硬件连接MCU.SCK - OLED.D0(SCLK)MCU.MOSI - OLED.D1(MOSI)MCU.GPIO - OLED.DC (数据/命令选择线非SPI标准但必需)MCU.GPIO - OLED.RES (复位线可选但推荐)MCU.GPIO - OLED.CS (片选)通信流程写命令拉低DC引脚然后通过SPI发送命令字节。写数据拉高DC引脚然后通过SPI发送数据字节通常是显存数据。每次操作都需要先拉低CS操作完成后拉高CS。初始化序列这是一系列固定的命令用于开启振荡器、设置对比度、扫描方向、开启显示等。必须严格按照数据手册中的顺序和延时要求发送。void oled_write_cmd(uint8_t cmd) { GPIO_WriteLow(OLED_DC_PIN); // DC置低表示命令 spi_transfer(OLED_CS_PIN, cmd, NULL, 1); } void oled_write_data(uint8_t data) { GPIO_WriteHigh(OLED_DC_PIN); // DC置高表示数据 spi_transfer(OLED_CS_PIN, data, NULL, 1); } // 初始化函数中的一部分 void oled_init(void) { // ... 硬件复位等操作 oled_write_cmd(0xAE); // 关闭显示 HAL_Delay(10); oled_write_cmd(0xD5); // 设置显示时钟分频比/振荡器频率 oled_write_cmd(0x80); // ... 发送更多初始化命令 oled_write_cmd(0xAF); // 开启显示 }避坑技巧SSD1306这类屏幕对SPI时钟速度有一定上限通常约10MHz。过高的速度会导致显示错乱。如果发现屏幕花屏首先尝试降低SPI时钟频率。另外发送完整帧图像数据时如果中间CS被意外拉高会导致显示不完整确保你的数据传输函数是可靠的。5.2 读写SPI Flash存储器以W25Q64为例SPI Flash的读写比屏幕复杂因为它涉及扇区擦除、页编程、状态寄存器查询等操作。基本操作指令每个操作都对应一个指令码1字节例如0x03- 读数据0x02- 页编程写数据0x20- 扇区擦除4KB0x05- 读状态寄存器10x06- 写使能读数据流程发送指令0x03。发送24位地址3字节高位在前。持续读取数据直到CS拉高。时钟由主设备持续提供。void flash_read_bytes(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] 0x03; // 读指令 cmd[1] (addr 16) 0xFF; // 地址高8位 cmd[2] (addr 8) 0xFF; // 地址中8位 cmd[3] addr 0xFF; // 地址低8位 spi_transfer(FLASH_CS_PIN, cmd, NULL, 4); // 发送指令和地址 spi_transfer(FLASH_CS_PIN, NULL, buf, len); // 连续读取数据 }写数据流程必须遵循写使能发送0x06指令。等待就绪读取状态寄存器检查BUSY位是否为0。页编程发送0x02指令 24位地址 数据最多256字节。注意写入的地址必须在同一页内跨页需要分多次写。等待编程完成再次读取状态寄存器等待BUSY位变0。致命陷阱Flash的写操作必须以扇区通常4KB或更大的块为单位进行擦除所有位变为1后才能进行编程将1变为0。不能直接覆盖写。一个常见的错误流程是想修改某个地址的一个字节直接发编程指令。这是行不通的必须先将整个扇区读出来在内存中修改对应字节然后擦除该扇区最后将整个修改后的数据写回去。这个过程需要仔细处理避免断电导致数据丢失。6. SPI调试实战常见问题排查与示波器使用SPI通信失败是嵌入式开发的常态。掌握排查方法比记住成功更重要。6.1 软件逻辑排查模式不匹配这是头号杀手。用示波器或逻辑分析仪抓取SCLK和MOSI波形对照从设备手册的时序图检查CPOL和CPHA是否一致。检查MCU初始化代码和从设备手册至少三遍。片选(CS)信号问题CS未拉低/拉高通信前必须拉低通信后必须拉高。检查GPIO初始化和控制代码。CS抖动或毛刺在数据传输过程中CS必须保持稳定低电平。检查是否有中断或其他任务干扰了GPIO操作。时钟速度过快从设备有最大SCLK频率限制。如果通信不稳定时好时坏首先将SPI时钟分频调到最低如系统时钟/256看是否能通信成功再逐步提高。位顺序错误检查主从设备是否都是MSB First。如果相反读回来的数据位序是颠倒的。数据帧大小不匹配有些设备是8位有些是16位。确保配置一致。6.2 硬件问题排查线路连接错误MOSI和MISI接反是最常见的硬件错误。务必对照原理图确认主设备的MOSI接从设备的MOSI或SDI主设备的MISO接从设备的MISO或SDO。上拉电阻缺失对于开漏输出的MISO线或者总线较长时可能需要上拉电阻通常4.7kΩ-10kΩ以确保空闲时为高电平。但多数现代CMOS器件推挽输出不需要。电源与地问题确保主从设备共地。电源电压是否匹配3.3V器件和5V器件直接连接可能损坏或无法通信。信号完整性在高速10MHz或长距离传输时需要考虑信号反射、过冲等问题。可能需要在SCLK和MOSI/MISO线上串联小电阻22Ω-100Ω进行阻抗匹配。6.3 示波器/逻辑分析仪抓包实战这是定位SPI问题的终极武器。你需要观察四根线SCLK, MOSI, MISO, CS的波形。看CS是否在数据包开始前拉低结束后拉高拉低的时间是否覆盖了整个数据包看SCLK空闲电平是否符合CPOL设置时钟频率是否是你配置的频率波形是否干净方波看MOSI/MISO数据是否在正确的时钟边沿根据CPHA稳定数据位是否符合你的预期解码使用示波器的SPI解码功能可以直接将波形翻译成十六进制数据与你的发送/接收缓冲区对比一目了然。我曾经调试一个温湿度传感器死活读不回数据。用逻辑分析仪抓包后发现我的MCU在发送读指令后只产生了8个SCLK脉冲用于发送指令但没有继续产生SCLK去读取传感器返回的数据。原来是我错误地在发送指令后立即拉高了CS正确的做法是发送指令后保持CS为低并继续产生SCLK时钟来读取数据。这个细节在数据手册的时序图里清清楚楚但阅读时被我忽略了。7. SPI的变体、局限与选型考量虽然经典4线SPI应用最广但也要了解其变体和局限性。7.1 三线SPI与双线SPI为了节省引脚衍生出简化版三线SPI合并MOSI和MISO为一条双向数据线SIO。通过方向控制分时复用。通信效率减半但节省一根线。有些传感器如BME280支持此模式。双线SPI在3线基础上进一步去掉CS线通过特定的唤醒序列来选通设备。极少使用兼容性差。除非引脚资源极度紧张否则建议优先使用标准的4线SPI其稳定性和软件生态最好。7.2 SPI的局限性无流控与应答机制SPI没有像I2C那样的ACK/NACK应答位。主设备无法知道从设备是否成功接收数据。通信可靠性完全依赖于硬件连接的稳定性和时序的正确性。无多主设备支持标准SPI是单主系统无法像I2C或CAN那样实现多主竞争总线。如果需要多主需要复杂的软件仲裁和硬件设计通常不这么做。无寻址机制依赖独立的CS线选择从设备当从设备数量很多时会占用大量GPIO。而I2C通过7位地址理论上可以挂载上百个设备虽然实际受总线电容限制。通信距离短由于是高速同步通信且通常无差分信号SPI的有效通信距离很短一般限于同一PCB板或通过排线连接的设备通常小于0.5米。不适合长距离通信。7.3 SPI vs. I2C vs. UART 选型指南如何为你的项目选择通信协议特性SPII2CUART通信方式同步全双工同步半双工异步全双工线数4线标准2线SDA, SCL2线TX, RX速度高(可达50Mbps)中 (标准模式100kbps快速模式400kbps高速模式3.4Mbps)低 (通常1Mbps受限于波特率精度)寻址硬件片选(CS)软件地址 (7位/10位)无点对点多主设备不支持支持不支持需额外协议复杂度简单硬件中等需处理协议简单硬件典型应用Flash, 显示屏, ADC/DAC, 高速传感器低速传感器, EEPROM, RTC, IO扩展芯片调试日志, 电脑通信, 模块间通信选型建议追求极致速度、点对点或点对少量设备选SPI。例如驱动TFT彩屏、读写大容量Flash、高速ADC。设备多、引脚资源紧张、速度要求不高选I2C。例如连接多个温湿度传感器、气压计、EEPROM。简单、灵活、需要与PC通信或作为调试接口选UART。例如GPS模块、蓝牙/Wi-Fi模块的AT指令通信、打印调试信息。最后分享一个我自己的习惯在每一个SPI从设备的驱动文件开头我都会用注释块清晰地记录下该设备的关键SPI参数比如/* Device: W25Q64JV SPI Flash * Mode: SPI Mode 0 (CPOL0, CPHA0) * Data Size: 8-bit * Bit Order: MSB First * Max SCK Freq: 104 MHz (for Fast Read) * CS: Software controlled, Active LOW */这个简单的习惯在几个月后回头修改代码或者移植到新平台时能为你省下大量重新翻手册的时间。SPI是一个“配置简单但配置错了就完全不通”的协议细节决定成败。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进