1. 项目概述为什么需要软件模拟SPI在嵌入式开发或者单片机项目中SPISerial Peripheral Interface总线是连接传感器、存储器、显示屏等外设的“高速公路”。硬件SPI控制器速度快、效率高是开发者的首选。但现实情况往往很骨感你的主控芯片可能只有一个硬件SPI接口却需要同时驱动一个TFT屏幕和一个SD卡或者你手头正好有一款性价比极高但偏偏不带硬件SPI的MCU又或者为了调试和验证时序逻辑你需要一个完全可控、可以随时“暂停”和“观察”的SPI信号源。在这些场景下硬件SPI就显得捉襟见肘了。这时“软件模拟SPI”Software SPI或Bit-Banging SPI就登场了。它不依赖芯片内置的专用硬件控制器而是通过程序控制普通的GPIO通用输入输出引脚按照SPI的通信协议一位一位地“捏”出时钟SCLK、数据输出MOSI等信号。听起来是不是有点“手工打造”的味道没错它的本质就是用软件代码去模拟一个硬件模块的行为。我最初接触软件SPI是为了驱动一块老式的诺基亚5110 LCD屏用的是一块只有单硬件SPI的STM32不得已而为之。但深入使用后我发现这不仅仅是一个“备胎”方案它更是一个理解SPI通信底层时序、调试复杂外设、以及应对资源受限场景的利器。2. 软件模拟SPI的核心原理与模式解析要动手模拟必须先吃透协议。SPI通信的核心是主从设备之间通过四根线进行同步、全双工的数据交换。这四根线是SCLK (Serial Clock)时钟信号由主设备产生用于同步数据位。MOSI (Master Out Slave In)主设备数据输出从设备数据输入。MISO (Master In Slave Out)从设备数据输出主设备数据输入。SS/CS (Slave Select / Chip Select)片选信号由主设备控制低电平有效时选中对应的从设备。软件模拟SPI就是我们要用代码在GPIO上精确地控制这些信号的时序。这里的关键在于两个概念时钟极性CPOL和时钟相位CPHA。它们共同定义了四种SPI模式决定了时钟空闲状态和数据的采样边沿。CPOL (Clock Polarity)CPOL0时钟空闲时为低电平。CPOL1时钟空闲时为高电平。CPHA (Clock Phase)CPHA0数据在时钟的第一个边沿对于CPOL0是上升沿对于CPOL1是下降沿被采样在第二个边沿变化。CPHA1数据在时钟的第二个边沿被采样在第一个边沿变化。这组合起来就是常见的Mode 0, 1, 2, 3。绝大多数SPI设备如Flash、ADC、OLED屏都工作在Mode 0 (CPOL0, CPHA0)或Mode 3 (CPOL1, CPHA1)。这两种模式的数据采样边沿都是时钟的上升沿。我个人的经验是先确认你的外设数据手册要求哪种模式这是成功通信的第一步。软件模拟的核心流程以8位数据、Mode 0为例发送一个字节0xAA二进制10101010拉低片选CS选中设备。确保初始时钟SCLK为低CPOL0。循环8次 a. 根据要发送的字节的最高位MSB First或最低位LSB First设置MOSI引脚为高或低电平对于0xAAMSB是1所以置高。 b. 拉高SCLK产生上升沿。此时从设备会在SCLK的这个上升沿采样MOSI上的数据位。 c. 可选如果同时需要读取在此刻或稍后读取MISO引脚上的电平作为接收到的数据位。 d. 拉低SCLK产生下降沿。此时主从设备可以准备下一位数据对于CPHA0数据在下降沿变化。 e. 将要发送的字节左移或右移一位准备下一位。循环结束拉高CS结束本次传输。注意数据移位的顺序MSB还是LSB first同样由外设决定必须严格遵循数据手册。很多通信失败就源于模式或位序设错。2.1 模拟SPI的优缺点与适用场景理解了原理我们再来客观看待它的价值。优点极致灵活不占用硬件外设理论上可以用任意GPIO模拟数量不限。你可以用PA5, PA6, PA7模拟一组SPI驱动屏幕同时用PB10, PB11, PB12模拟另一组SPI读写SD卡完美解决硬件资源冲突。完全可控你可以精确控制每个时钟沿的时机插入微秒级的延时进行调试或者实现非常规的、低速的特殊通信时序。这对于研究协议、逆向工程或者驱动一些非标设备非常有用。跨平台通用代码逻辑几乎可以不加修改地在不同架构的MCU如STM32、ESP32、Arduino、51单片机上运行只要提供基本的GPIO操作和延时函数即可移植性极强。成本与入门在一些超低成本的8位单片机可能无硬件SPI上实现SPI功能降低了硬件成本和学习门槛。缺点速度慢这是最显著的缺点。因为每个位的操作都需要多条CPU指令置高低电平、移位、循环判断其速度远低于硬件SPI的DMA传输。通常软件SPI的极限频率在几百KHz到一两MHz取决于CPU主频和代码优化而硬件SPI轻松可达数十MHz。CPU占用率高通信期间CPU被完全占用无法执行其他任务不适合在需要高实时性或复杂多任务的系统中高速、频繁地通信。时序精度依赖CPU时序由软件延时循环保证容易受到中断干扰。如果通信过程中发生中断可能导致时钟脉宽变形通信失败。适用场景总结驱动低速SPI设备如OLED屏通常几MHz以内、温湿度传感器、IO扩展芯片等。硬件SPI引脚被占用或数量不足时的补充方案。教学、调试、理解SPI协议底层。在无硬件SPI的廉价MCU上实现SPI功能。需要特殊、非标准时序的场合。3. 软件模拟SPI的代码实现与深度优化理论说再多不如一行代码。下面我将以ARM Cortex-M内核的STM32为例使用标准外设库Standard Peripheral Library风格进行实现和解析。之所以选它是因为其寄存器操作清晰便于理解本质且易于移植到HAL库或其他平台。3.1 基础架构与GPIO初始化首先我们需要定义一个结构体来管理一组模拟SPI所需的引脚和配置。这比使用分散的全局变量要清晰得多。typedef struct { GPIO_TypeDef* GPIOx_SCLK; // SCLK引脚所在的GPIO组 uint16_t Pin_SCLK; // SCLK引脚号 GPIO_TypeDef* GPIOx_MOSI; // MOSI引脚所在的GPIO组 uint16_t Pin_MOSI; // MOSI引脚号 GPIO_TypeDef* GPIOx_MISO; // MISO引脚所在的GPIO组如果只发送则可省略 uint16_t Pin_MISO; // MISO引脚号 GPIO_TypeDef* GPIOx_CS; // CS引脚所在的GPIO组 uint16_t Pin_CS; // CS引脚号 uint8_t CPOL; // 时钟极性0或1 uint8_t CPHA; // 时钟相位0或1 uint8_t BitOrder; // 位顺序0为MSB First1为LSB First uint32_t Delay; // 时钟半周期延时粗略控制频率 } SoftSPI_HandleTypeDef;初始化函数负责将这些GPIO配置为推挽输出SCLK, MOSI, CS和上拉输入MISO。void SoftSPI_Init(SoftSPI_HandleTypeDef *hspi) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 初始化时钟假设已开启 // RCC_APB2PeriphClockCmd(对应RCC, ENABLE); // 配置SCLK, MOSI, CS为推挽输出 GPIO_InitStruct.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; // 高速输出以获得更陡峭的边沿 GPIO_InitStruct.GPIO_Pin hspi-Pin_SCLK; GPIO_Init(hspi-GPIOx_SCLK, GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin hspi-Pin_MOSI; GPIO_Init(hspi-GPIOx_MOSI, GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin hspi-Pin_CS; GPIO_Init(hspi-GPIOx_CS, GPIO_InitStruct); // 配置MISO为上拉输入如果使用 if (hspi-GPIOx_MISO ! NULL) { GPIO_InitStruct.GPIO_Mode GPIO_Mode_IPU; GPIO_InitStruct.GPIO_Pin hspi-Pin_MISO; GPIO_Init(hspi-GPIOx_MISO, GPIO_InitStruct); } // 设置初始状态CS高不选中SCLK根据CPOL设置空闲电平 GPIO_SetBits(hspi-GPIOx_CS, hspi-Pin_CS); if (hspi-CPOL 0) { GPIO_ResetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // CPOL0, 空闲低 } else { GPIO_SetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // CPOL1, 空闲高 } // MOSI初始状态可高可低通常置低或置高取决于设备要求 GPIO_ResetBits(hspi-GPIOx_MOSI, hspi-Pin_MOSI); }3.2 核心收发函数与四种模式实现这是软件SPI的“心脏”。我们需要实现一个函数能处理四种模式。下面是一个支持全双工同时收发的SoftSPI_TransmitReceiveByte函数。uint8_t SoftSPI_TransmitReceiveByte(SoftSPI_HandleTypeDef *hspi, uint8_t txData) { uint8_t rxData 0; uint8_t i; uint8_t bitMask; // 根据位序决定掩码和移位方向 if (hspi-BitOrder 0) { // MSB First bitMask 0x80; // 1000 0000 } else { // LSB First bitMask 0x01; // 0000 0001 } // 循环8次处理每个数据位 for (i 0; i 8; i) { // ---------- 第一个时钟边沿的处理 (与CPHA相关) ---------- if (hspi-CPHA 0) { // CPHA0: 数据在第一个边沿被采样因此需要在第一个边沿之前设置好数据 if (txData bitMask) { GPIO_SetBits(hspi-GPIOx_MOSI, hspi-Pin_MOSI); } else { GPIO_ResetBits(hspi-GPIOx_MOSI, hspi-Pin_MOSI); } // 产生第一个边沿对于CPOL0是上升沿CPOL1是下降沿 // 即将SCLK从空闲状态翻转到非空闲状态 if (hspi-CPOL 0) { GPIO_SetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 低-高上升沿 } else { GPIO_ResetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 高-低下降沿 } // 采样点在第一个边沿后短暂延时然后读取MISO SoftSPI_Delay(hspi-Delay); if (hspi-GPIOx_MISO ! NULL) { if (GPIO_ReadInputDataBit(hspi-GPIOx_MISO, hspi-Pin_MISO)) { if (hspi-BitOrder 0) { rxData | bitMask; } else { rxData | bitMask; } } } // 保持一段时间 SoftSPI_Delay(hspi-Delay); // 产生第二个边沿回到空闲状态 if (hspi-CPOL 0) { GPIO_ResetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 高-低 } else { GPIO_SetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 低-高 } } else { // CPHA 1 // CPHA1: 数据在第二个边沿被采样第一个边沿用于数据建立 // 产生第一个边沿数据变化边沿 if (hspi-CPOL 0) { GPIO_SetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 低-高 } else { GPIO_ResetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 高-低 } // 在第一个边沿后设置数据 SoftSPI_Delay(hspi-Delay); if (txData bitMask) { GPIO_SetBits(hspi-GPIOx_MOSI, hspi-Pin_MOSI); } else { GPIO_ResetBits(hspi-GPIOx_MOSI, hspi-Pin_MOSI); } SoftSPI_Delay(hspi-Delay); // 产生第二个边沿采样边沿 if (hspi-CPOL 0) { GPIO_ResetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 高-低下降沿 } else { GPIO_SetBits(hspi-GPIOx_SCLK, hspi-Pin_SCLK); // 低-高上升沿 } // 采样点在第二个边沿后读取MISO if (hspi-GPIOx_MISO ! NULL) { if (GPIO_ReadInputDataBit(hspi-GPIOx_MISO, hspi-Pin_MISO)) { if (hspi-BitOrder 0) { rxData | bitMask; } else { rxData | bitMask; } } } } // 移位准备下一位 if (hspi-BitOrder 0) { bitMask 1; // MSB First右移 } else { bitMask 1; // LSB First左移 } // 可选在回到空闲状态后加一个微小延时确保时序稳定 // SoftSPI_Delay(hspi-Delay/2); } return rxData; }为了方便使用我们通常封装只发送和只接收的函数void SoftSPI_Transmit(SoftSPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size) { GPIO_ResetBits(hspi-GPIOx_CS, hspi-Pin_CS); // 拉低CS for (uint16_t i 0; i Size; i) { (void)SoftSPI_TransmitReceiveByte(hspi, pData[i]); // 忽略接收值 } GPIO_SetBits(hspi-GPIOx_CS, hspi-Pin_CS); // 拉高CS } void SoftSPI_Receive(SoftSPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size) { GPIO_ResetBits(hspi-GPIOx_CS, hspi-Pin_CS); for (uint16_t i 0; i Size; i) { pData[i] SoftSPI_TransmitReceiveByte(hspi, 0xFF); // 通常发送0xFF以读取数据 } GPIO_SetBits(hspi-GPIOx_CS, hspi-Pin_CS); }SoftSPI_Delay函数是一个简单的忙等待延时用于控制时钟频率。它的精度直接决定了SPI的速率和稳定性。static void SoftSPI_Delay(uint32_t count) { while (count--) { __NOP(); // 空操作指令产生一个CPU周期的延时 } }Delay参数需要根据你的CPU主频进行校准。例如主频72MHz时__NOP()大约耗时1/72us ≈ 13.9ns。一个count循环除了__NOP()还有自减和跳转指令总周期数更多。你需要通过示波器测量或计算来调整count值以达到目标SCLK频率例如1MHz的周期是1us半周期就是500ns。3.3 性能优化实战技巧基础版本能工作但可能很慢。在资源紧张的系统中每一微秒都值得争取。以下是我在实践中总结的优化手段寄存器直接操作替代库函数这是最有效的优化。像GPIO_SetBits、GPIO_ResetBits这类库函数内部有判断和跳转开销大。直接操作GPIOx-BSRR置位/复位寄存器或GPIOx-ODR输出数据寄存器速度极快。// 优化前 GPIO_SetBits(GPIOA, GPIO_Pin_5); // 优化后 GPIOA-BSRR GPIO_Pin_5; // 置位 GPIOA-BSRR (uint32_t)GPIO_Pin_5 16; // 复位 // 或者直接操作ODR需先读取-修改-写入 GPIOA-ODR | GPIO_Pin_5; GPIOA-ODR ~GPIO_Pin_5;循环展开将8次循环展开成顺序执行的8组语句消除循环判断和自增的开销。代码会变长但速度显著提升。// 优化前for(i0; i8; i) { ... } // 优化后MSB First示例 // 处理第7位 if(txData 0x80) MOSI_HIGH(); else MOSI_LOW(); SCLK_TOGGLE(); DELAY(); READ_MISO_BIT(7); DELAY(); SCLK_TOGGLE(); // 处理第6位 if(txData 0x40) MOSI_HIGH(); else MOSI_LOW(); SCLK_TOGGLE(); DELAY(); READ_MISO_BIT(6); DELAY(); SCLK_TOGGLE(); // ... 处理第0位使用内联汇编或编译器内置函数对于延时可以用精确的汇编指令如__asm__ volatile(“nop”)或编译器提供的__NOP()。在IAR或Keil中可以插入精确的NOP指令序列来控制延时。根据CPOL/CPHA预编译分支如果你的SPI模式是固定的不要在运行时用if判断CPHA。可以写成两个独立的函数如SoftSPI_TransmitReceiveByte_Mode0和SoftSPI_TransmitReceiveByte_Mode3或者使用宏定义在编译时选择代码路径消除条件判断的开销。降低延时精度要求对于很多低速设备如传感器对时钟的占空比和精确频率并不敏感。你可以适当减少延时循环的次数只要通信能稳定即可。用示波器观察波形找到稳定工作的最小延时。实操心得优化是一把双刃剑。在追求极致速度前先用一个稳定、清晰的基础版本实现功能并测试通过。优化后的代码可读性和可移植性会下降。我的建议是为同一个软件SPI模块维护两个版本一个“清晰版”用于调试和移植一个“优化版”用于最终产品。优化时务必用逻辑分析仪或示波器验证波形是否合规。4. 软件模拟SPI的调试与实战问题排查代码写好了但设备没反应这是最考验耐心和经验的环节。下面是我用逻辑分析仪和示波器调试软件SPI时总结的一套排查流程和常见问题。4.1 调试工具与连接工欲善其事必先利其器。逻辑分析仪必备神器。推荐Saleae Logic系列或其国产兼容版。它能同时捕获多路数字信号SCLK, MOSI, MISO, CS并以时序图方式显示直观看到每一位数据、每个边沿的关系。配合解码功能如SPI解码器能直接将波形翻译成十六进制数据极大提升调试效率。示波器用于观察信号质量如上升/下降时间、过冲、振铃等模拟特性。当通信距离较长或速度较高时特别有用。连接确保逻辑分析仪的探针地线GND与你的MCU板共地。探针连接到对应的GPIO引脚。如果引脚是3.3V电平确保逻辑分析仪阈值电压设置为2.0V左右CMOS电平。4.2 常见问题排查清单当你发送了数据但设备无响应时请按以下顺序排查问题现象可能原因排查方法与解决方案完全没有波形1. GPIO初始化错误未配置为输出。2. 代码未执行到SPI发送函数。3. 硬件连接断开或短路。1. 检查初始化代码用万用表测量引脚在代码运行后是否为高阻态错误或固定电平。2. 在SPI函数入口设置断点或翻转一个LED确认函数被调用。3. 检查杜邦线、焊点。有SCLK和MOSI波形但设备不响应1.SPI模式CPOL/CPHA设置错误。这是最常见的原因2. 片选CS信号异常常高、常低或时序不对。3. 数据位序MSB/LSB错误。4. 时钟频率太快设备跟不上。1.用逻辑分析仪捕获波形。对照设备数据手册的时序图检查SCLK空闲电平CPOL和数据采样边沿CPHA是否匹配。Mode 0和Mode 3最易混淆。2. 确认CS在传输前拉低传输后拉高。检查CS是否有毛刺或意外抖动。3. 检查数据解码是否正确。尝试切换位序。4. 增大Delay参数降低SCLK频率再试。波形畸形上升沿缓慢、过冲1. GPIO驱动能力不足或负载过重。2. 导线过长引入寄生电容。3. 未使能GPIO引脚的内置上拉/下拉。1. 将GPIO速度配置为最高如50MHz。2. 缩短连接线或在靠近MCU引脚处加一个几十欧姆的串联电阻阻尼振荡。3. 对于MISO输入引脚使能上拉电阻避免悬空。通信偶尔出错数据位跳变1. 软件延时被中断打断导致时钟脉宽异常。2. 电源噪声干扰。3. 多个设备总线冲突MISO线竞争。1. 在SPI收发函数的关键时序部分整个for循环关闭全局中断__disable_irq()操作完毕后再开启__enable_irq()。这是软件SPI稳定的关键2. 检查电源稳定性在MCU和设备的电源引脚就近加退耦电容如100nF。3. 确保同一时刻只有一个设备的CS被选中未选中的设备MISO应呈高阻态。能写不能读1. MISO引脚未正确配置为输入。2. 读取MISO的时机不对CPHA影响。3. 从设备需要特定命令才输出数据。1. 确认MISO GPIO模式为浮空输入或上拉输入。2. 用逻辑分析仪对比MISO信号和你的代码读取点调整读取语句在时钟沿后的位置。3. 查阅设备手册确认读操作的正确命令序列。4.3 一个具体的调试案例驱动W25Qxx Flash以驱动常见的SPI Flash芯片W25Q64为例。其典型模式是Mode 0MSB First。数据手册要求在发送命令后需要等待一个“忙状态”解除才能进行下一步。问题发送“写使能”命令0x06后紧接着发送“页编程”命令0x02和数据但数据写入失败。排查过程逻辑分析仪抓取波形发现命令字节0x06, 0x02的波形完全正确模式、时序都对。对比手册发现“页编程”命令前必须确保“写使能锁存器”已被置位。而“写使能”命令执行后芯片需要一小段时间t_WEL典型值几微秒来置位这个锁存器。根源我的代码在发送0x06后没有等待t_WEL就立刻拉高CS结束传输然后马上开始下一次传输发送0x02。这可能导致芯片内部还未准备好。解决在“写使能”命令后拉高CS插入一个Delay_us(10)的延时再拉低CS开始发送“页编程”命令。或者更规范的做法是在“页编程”前发送“读状态寄存器1”命令0x05轮询BUSY位等待其变为0。这个案例告诉我们软件模拟SPI不仅要关注波形本身还要严格遵守外设芯片的命令序列和时序要求。硬件SPI的库函数有时会封装这些等待而软件模拟需要我们自己实现。5. 进阶应用多设备管理与特殊时序模拟掌握了单设备通信我们可以玩点更复杂的。5.1 模拟多路SPI总线假设我们需要用软件模拟驱动三个设备一个OLED只写一个温度传感器读写一个Flash读写。它们的CS引脚分别接在PA4、PA3、PA2上但共享SCLK(PA5)、MOSI(PA6)、MISO(PA7)。实现思路初始化时将所有共享引脚SCLK, MOSI, MISO和所有CS引脚都初始化为输出MISO为输入。为每个设备定义一个独立的SoftSPI_HandleTypeDef结构体实例其中CS引脚各自独立其他引脚指针指向相同的GPIO和Pin。在每次传输函数SoftSPI_Transmit内部操作CS引脚时操作的是当前设备实例中指定的CS引脚。这样当向OLED发送数据时只有OLED的CS被拉低其他设备的CS保持高电平避免了总线冲突。代码示例SoftSPI_HandleTypeDef hspi_oled, hspi_sensor, hspi_flash; // 初始化共享引脚 void SoftSPI_Multi_Init(void) { // 初始化SCLK, MOSI, MISO (PA5, PA6, PA7) // ... // 初始化三个CS引脚 (PA4, PA3, PA2) 为输出高 // ... // 配置OLED只写无需MISO hspi_oled.GPIOx_SCLK GPIOA; hspi_oled.Pin_SCLK GPIO_Pin_5; hspi_oled.GPIOx_MOSI GPIOA; hspi_oled.Pin_MOSI GPIO_Pin_6; hspi_oled.GPIOx_MISO NULL; hspi_oled.Pin_MISO 0; hspi_oled.GPIOx_CS GPIOA; hspi_oled.Pin_CS GPIO_Pin_4; hspi_oled.CPOL 0; hspi_oled.CPHA 0; hspi_oled.BitOrder 0; // 配置传感器和Flash类似CS引脚不同MISO指向PA7 } // 使用时 SoftSPI_Transmit(hspi_oled, oled_data, sizeof(oled_data)); // 只有PA4拉低 uint8_t temp SoftSPI_TransmitReceiveByte(hspi_sensor, 0x00); // 只有PA3拉低5.2 模拟特殊时序应对非标设备我曾遇到过一个老式的数码管驱动芯片它使用类似SPI的协议但数据是在SCLK的上升沿和下降沿都有效即每个时钟周期传输2位数据。硬件SPI无法直接生成这种波形但软件模拟可以轻松实现。思路在for循环内每位数据操作两次SCLK翻转并在不同的边沿放置数据。// 伪代码每个时钟周期传输2位 (bit1在上升沿bit2在下降沿) for(int i0; i16; i2) { // 假设16位数据 // 准备bit1 set_mosi(get_bit(data, i)); // 产生上升沿 sclk_high(); delay(); // 准备bit2 set_mosi(get_bit(data, i1)); // 产生下降沿 sclk_low(); delay(); }这种灵活性是硬件SPI无法比拟的。软件模拟让你成为时序的“导演”可以创造出符合任何非标准设备要求的通信波形。6. 软件模拟SPI与硬件SPI的协同与选型建议最后我们来谈谈在项目中如何做选择。何时选择软件模拟SPI低速设备通信频率要求低于1MHz且对CPU占用不敏感。引脚资源冲突硬件SPI引脚被其他功能占用如重映射失败。需要多个SPI接口硬件SPI数量不足。协议调试与学习需要观察和操控每一个时钟沿。非标准时序设备时序不符合标准SPI模式。低成本MCU使用的MCU没有硬件SPI外设。何时必须使用硬件SPI高速通信频率超过1MHz特别是需要传输大量数据如图像刷新、音频流。低功耗要求硬件SPI由外设自动完成CPU可以进入睡眠模式。高实时性系统CPU需要处理其他紧急任务不能被SPI通信长时间阻塞。需要DMA进行大数据量、零CPU占用的传输。混合使用方案一个务实的方案是**“硬件SPI为主软件模拟为辅”**。例如用硬件SPIDMA驱动高速TFT屏同时用软件SPI与一个低速的传感器通信。两者可以共存只要使用不同的GPIO引脚即可。软件模拟SPI是一项看似简单却内涵丰富的技能。它强迫你去理解通信协议最底层的时序逻辑这种理解对于调试复杂的硬件问题、阅读外设数据手册、乃至设计自己的数字接口都大有裨益。从“它能用”到“它稳定高效”中间隔着一堆需要踩的坑和需要优化的细节。希望这篇长文里分享的原理、代码和踩坑经验能让你在下次遇到SPI资源紧张或者时序难题时多一份从容和底气。