1. 项目概述从“点灯”到“通信”SPI是绕不开的坎玩STM32的朋友从最开始的GPIO点灯到定时器、串口一路打怪升级总会遇到一个看起来有点“麻烦”的家伙——SPI。我刚开始接触时也觉得它比UART复杂又是主从模式又是时钟极性相位还有那几根线。但当你真正用它驱动了一块屏幕、读写了一个Flash芯片或者和某个传感器顺畅地“对话”后你就会发现SPI其实是一个高效、可靠的好帮手。这篇笔记就是我这些年折腾STM32 SPI外设的实战总结不扯虚的只聊怎么把它用起来、用明白。无论你是正在做毕业设计的学生还是工作中需要快速实现设备间通信的工程师希望这些踩过的坑和总结的经验能让你少走点弯路。SPI全称Serial Peripheral Interface翻译过来叫串行外设接口。它是一种高速、全双工、同步的通信总线。说人话就是通信速度可以很快数据能同时收和发并且需要一根时钟线来同步数据。在STM32的世界里几乎所有的系列都内置了至少一个SPI外设它常被用来连接那些对速度有要求或者需要频繁交换数据的器件比如液晶屏LCD、Flash存储器W25Q64、SD卡、数字传感器如陀螺仪、加速度计以及一些音频编解码芯片等。理解了SPI你就打开了连接众多外部高性能器件的大门。2. SPI协议核心原理与STM32的实现2.1 SPI通信的“四线制”与工作模式SPI通常需要4根线这是它最基本的物理形态SCK (Serial Clock) 时钟信号线由主机产生用于同步数据。所有的数据收发都根据这个时钟的边沿来锁定。MOSI (Master Output, Slave Input) 主机输出从机输入。数据从主机流向从机。MISO (Master Input, Slave Output) 主机输入从机输出。数据从从机流向主机。NSS (Slave Select) 从机选择线通常低电平有效。主机通过拉低这根线来“选中”想要通信的从机。一根NSS对应一个从机所以当有多个从机时主机需要多个GPIO来分别控制。这四根线构成了SPI通信的骨架。但仅仅有骨架还不够通信双方必须约定好“说话”的节奏这就是时钟极性CPOL和时钟相位CPHA它们共同定义了四种SPI模式。CPOL (Clock Polarity) 时钟空闲时的电平。CPOL0 SCK在空闲时为低电平。CPOL1 SCK在空闲时为高电平。CPHA (Clock Phase) 数据采样的时钟边沿。CPHA0 在时钟的第一个边沿如果CPOL0就是上升沿CPOL1就是下降沿采样数据。CPHA1 在时钟的第二个边沿采样数据。这四种模式Mode 0, 1, 2, 3一定要和你连接的从机器件数据手册上的要求严格匹配否则通信必然失败。最常见的是Mode 0和Mode 3。实操心得 如何快速确定模式看从机数据手册的时序图找到SCK线和数据线的对应关系。一个笨但绝对有效的方法是用逻辑分析仪抓一下已知能正常工作的设备的波形对照着设置你的STM32。我手边常备一个几十块的逻辑分析仪排查通信问题效率倍增。2.2 STM32 SPI外设架构浅析STM32的SPI外设是一个相对独立的数字通信引擎。当你通过库函数或者寄存器配置好通信参数模式、波特率、数据大小等后只需要把要发送的数据填进发送数据寄存器DR外设就会自动按照你设定的时序在SCK的节拍下把数据从MOSI线一位一位地“推”出去。同时它也会从MISO线一位一位地“收”数据并存入接收数据寄存器DR供你读取。这里有几个关键点全双工与半双工 标准SPI是全双工的收发同时进行。但STM32的SPI也支持“单线双向”模式可以只用一根数据线分时进行收发这在引脚紧张时很有用。数据帧格式 可以是8位或16位。注意通信时是高位MSB先行还是低位LSB先行也需要根据从机器件设置通过SPI_FirstBit配置。大部分器件是MSB先行。NSS管理 分为硬件管理和软件管理。硬件NSS 将STM32的NSS引脚配置为硬件模式当SPI使能时硬件会自动控制NSS电平。在多主机系统中用得较多在单主机多从机系统中反而不太方便。软件NSS 这是我最推荐也是最常用的方式。将NSS引脚配置为普通的GPIO输出模式在通信前手动拉低选中从机通信结束后再拉高。这样控制权完全在程序手里非常灵活可以轻松控制多个从机。3. 实战使用STM32CubeMX与HAL库配置SPI理论说再多不如动手配一遍。我们以STM32F103系列为例使用STM32CubeMX图形化工具和HAL库配置一个SPI主设备来驱动一个SPI Flash芯片如W25Q64。3.1 CubeMX基础配置选择型号与引脚分配 在CubeMX中创建新工程选择你的具体型号如STM32F103C8T6。在引脚图界面找到SPI1或其他你想用的SPI。模式选择 将SPI1的模式设置为“Full-Duplex Master”全双工主机。此时CubeMX会自动为你分配SCK、MOSI、MISO引脚通常是PA5、PA7、PA6。参数配置 在SPI1的配置选项卡中设置以下关键参数Baud Rate 波特率预分频器。根据从机器件支持的最高速度和你的系统时钟来选。初期可以设得慢一点比如PCLK2 / 64确保通信稳定。Data Size 数据大小选8 bits。First Bit 首选位选MSB First。Clock Polarity和Clock Phase 根据你的从机器件手册选择。假设我们选Low和1 Edge即Mode 0。NSS Signal Type NSS信号类型选择Software软件管理。这样就不会占用硬件NSS引脚PA4我们可以用其他任意GPIO比如PB0来作为片选。GPIO配置 在引脚图界面将一个空闲的GPIO如PB0设置为GPIO_Output并给它起个有意义的标签如“FLASH_CS”。这个引脚将作为我们软件控制的片选信号。生成代码 配置好系统时钟、工程名和路径后生成代码。3.2 HAL库SPI收发函数详解CubeMX生成的代码基于HAL库它封装了底层的寄存器操作让我们可以更关注业务逻辑。核心的收发函数是HAL_SPI_TransmitReceive。// 全双工阻塞式收发 HAL_StatusTypeDef HAL_SPI_TransmitReceive(SPI_HandleTypeDef *hspi, uint8_t *pTxData, uint8_t *pRxData, uint16_t Size, uint32_t Timeout);hspi: SPI句柄如hspi1。pTxData: 指向发送数据缓冲区的指针。pRxData: 指向接收数据缓冲区的指针。即使你只想发送也需要提供一个有效的缓冲区来接收“垃圾数据”反之亦然。因为SPI是全双工的时钟一旦产生收发通道都在工作。Size: 要收发数据的数量以数据帧为单位我们配置的是8位所以就是字节数。Timeout: 超时时间毫秒。阻塞式与非阻塞式 上面这个是阻塞式函数调用后程序会停在这里等待整个收发完成或超时。对于简单的、非实时的操作这很直观。但对于复杂的系统可能会阻塞其他任务。HAL库也提供了非阻塞式的HAL_SPI_TransmitReceive_IT中断方式和HAL_SPI_TransmitReceive_DMADMA方式我们后面会讲到。3.3 封装读写函数以W25Q64为例现在我们来封装一个针对W25Q64 Flash芯片的读写函数。首先我们需要定义片选引脚的操作宏。// 在 main.c 的开头或专门的驱动文件里定义 #define FLASH_CS_GPIO_PORT GPIOB #define FLASH_CS_GPIO_PIN GPIO_PIN_0 #define FLASH_CS_LOW() HAL_GPIO_WritePin(FLASH_CS_GPIO_PORT, FLASH_CS_GPIO_PIN, GPIO_PIN_RESET) #define FLASH_CS_HIGH() HAL_GPIO_WritePin(FLASH_CS_GPIO_PORT, FLASH_CS_GPIO_PIN, GPIO_PIN_SET)然后实现一个基础的SPI交换字节函数。这个函数是SPI通信的基石。/** * brief 通过SPI1交换一个字节同时收发 * param txData: 要发送的字节 * retval 接收到的字节 */ uint8_t SPI1_ReadWriteByte(uint8_t txData) { uint8_t rxData; // 调用HAL库的阻塞式收发函数收发一个字节 HAL_SPI_TransmitReceive(hspi1, txData, rxData, 1, 100); return rxData; }接着实现读取Flash芯片ID的函数。这是验证SPI通信是否成功的常用第一步。/** * brief 读取W25Q64的制造商和设备ID * param manufacturerID: 指向存储制造商ID的变量的指针 * param deviceID: 指向存储设备ID的变量的指针 * retval HAL status */ HAL_StatusTypeDef W25Q64_ReadID(uint8_t *manufacturerID, uint16_t *deviceID) { uint8_t cmd 0x90; // W25Q64的读ID命令 uint8_t dummyAddr[3] {0x00, 0x00, 0x00}; // 读ID命令后需要跟3个地址字节dummy uint8_t idBuffer[2] {0}; FLASH_CS_LOW(); // 选中芯片 SPI1_ReadWriteByte(cmd); // 发送命令 // 发送3个dummy地址字节 for(int i0; i3; i) { SPI1_ReadWriteByte(dummyAddr[i]); } // 读取制造商ID应该是0xEF和设备ID高字节 idBuffer[0] SPI1_ReadWriteByte(0xFF); // 读取设备ID低字节 idBuffer[1] SPI1_ReadWriteByte(0xFF); FLASH_CS_HIGH(); // 取消选中 *manufacturerID idBuffer[0]; *deviceID (idBuffer[1] 8) | idBuffer[0]; // 注意组合方式具体看芯片手册 // 简单判断W25Q64的设备ID通常是0x4017或0x4018 if(*manufacturerID 0xEF (*deviceID 0x4017 || *deviceID 0x4018)) { return HAL_OK; } else { return HAL_ERROR; } }注意事项 每个SPI器件的命令集和时序都可能不同。务必、务必、务必仔细阅读数据手册上面代码中的命令字、地址长度、dummy周期都是W25Q64特有的。驱动其他器件如LCDILI9341、传感器ICM-42688P流程类似但具体命令和时序天差地别。4. 进阶SPI与DMA的联姻——释放CPU当你需要高速、连续地传输大量数据时比如刷LCD屏、读写大块Flash、高速采集传感器数据如果还用阻塞式或中断式的单字节收发CPU会被频繁占用效率低下系统也会卡顿。这时就该DMA直接存储器访问登场了。4.1 DMA原理与SPI搭配的优势DMA就像一个“数据搬运工”它可以在不打扰CPU的情况下在外设如SPI的数据寄存器和内存如一个数组之间自动搬运数据。对于SPI来说配置好DMA后你只需要启动一次传输DMA就会自动把内存中要发送的数据源源不断地送到SPI的发送端同时把接收到的数据搬回内存的指定位置整个过程CPU几乎可以“袖手旁观”去处理其他任务。4.2 使用CubeMX配置SPI DMA启用DMA 在CubeMX的SPI配置界面找到DMA Settings选项卡。添加DMA请求 点击Add为SPI1_TX发送添加一个DMA流Stream或通道Channel不同系列名称不同如F1是通道F4/F7/H7是流。通常选择优先级为“中”或“高”。配置DMA参数Mode:Normal传输指定数量后停止或Circular循环模式传输完自动从头开始适合连续不断的数据流如音频。Increment Address: 对于发送内存地址我们的数组需要递增所以Memory侧选Increment外设地址SPI-DR寄存器是固定的所以Peripheral侧选No Increment。对于接收同理。生成代码 重新生成代码CubeMX会自动初始化DMA。4.3 HAL库DMA收发实战配置完成后我们可以使用DMA函数进行收发。这里以向SPI Flash写入一页数据256字节为例。// 假设我们已经定义好了发送缓冲区 txBuffer 和接收缓冲区 rxBuffer uint8_t txBuffer[256]; uint8_t rxBuffer[256]; // 1. 填充要发送的数据例如Flash的页编程命令和数据 txBuffer[0] 0x02; // Page Program命令 txBuffer[1] 0x00; // 地址字节2 (24位地址的高位) txBuffer[2] 0x00; // 地址字节1 txBuffer[3] 0x00; // 地址字节0 (低位) // ... 填充后续252字节的用户数据 ... // 2. 启动DMA发送此例中我们只关心发送接收的数据可以忽略 FLASH_CS_LOW(); if(HAL_SPI_Transmit_DMA(hspi1, txBuffer, 256) ! HAL_OK) { // 错误处理 Error_Handler(); } // 此时函数立即返回DMA在后台搬运数据 // 3. 等待DMA传输完成 // 可以通过查询标志位或者使用DMA传输完成中断 while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { // 可以在这里执行一些低优先级的任务 } // 4. 传输完成后拉高片选 FLASH_CS_HIGH();对于接收使用HAL_SPI_Receive_DMA。对于全双工同时收发使用HAL_SPI_TransmitReceive_DMA。踩坑实录STM32H750 DMA驱动SPI LCD问题这是网络热词中提到的一个典型问题。在高性能芯片如H750上使用DMA驱动SPI LCD如ILI9341时可能会遇到花屏、数据错位等问题。常见原因和解决思路时钟配置问题 H750时钟树复杂确保SPI和DMA的时钟源正确且使能。特别是DMA的时钟HCLK或D1域的时钟不能太低。Cache一致性这是最容易被忽略的坑H750有Cache。DMA直接操作的是物理内存DMA缓冲区而CPU操作的是经过Cache的内存视图。如果你在CPU填充了txBuffer后直接启动DMADMA可能读到的是Cache里的旧数据而非内存中刚填的新数据。解决方法在启动DMA前清理Clean数据Cache对应缓冲区确保内存数据是最新的。使用SCB_CleanDCache_by_Addr()函数。内存区域 确保DMA缓冲区位于支持DMA的内存区域如DTCM、AXI SRAM、SDRAM。不要使用默认的紧耦合存储器TCM作为大容量DMA缓冲区可能访问不到。DMA流/通道配置 检查DMA的数据宽度Byte/HalfWord/Word是否与SPI的数据帧大小匹配。不匹配会导致数据移位。SPI时序 LCD屏对SPI时序尤其是片选CS和命令/数据DC线的时序可能非常敏感。在DMA传输开始和结束时确保CS和DC线的电平变化有足够的延时几个NOP指令或者将控制线也纳入DMA控制这需要更高级的技巧。5. SPI单线双向模式与软件片选的艺术5.1 何时使用单线双向模式当你的MCU引脚资源非常紧张或者连接的从机本身只支持单线数据接口时可以使用SPI的单线双向Half-Duplex模式。STM32的SPI外设支持通过配置在同一个物理引脚通常是MOSI引脚上分时进行发送和接收。在CubeMX中将SPI模式选为“Transmit only master”或“Receive only master”只是关闭了另一方向并非真正的单线双向。真正的单线双向需要在代码中动态切换引脚的数据方向。一种常见的做法是将MOSI和MISO引脚都映射到同一个物理引脚如PA7。发送时将该引脚配置为推挽输出复用功能。接收时将该引脚配置为浮空输入或上拉输入取决于从机。注意 这种切换会引入额外的软件开销和时序不确定性不适合极高速度的通信。更推荐使用SPI外设内置的“单线双向”功能如果支持通过设置BIDIMODE和BIDIOE位来控制方向这样切换由硬件完成更可靠。5.2 硬件片选 vs. 软件片选这是另一个初学者容易困惑的点。硬件片选NSS 前面提到配置为硬件模式后SPI硬件会自动管理NSS引脚电平。在“主模式”下当你使能SPISPE1时NSS引脚会输出低电平禁用SPI时输出高电平。这听起来很自动化但在单主机、多从机的场景下非常不便因为它的NSS输出是固定的你无法用一根NSS线去选通不同的从机。它主要用于多主机仲裁。软件片选强烈推荐在绝大多数应用中使用。将NSS引脚配置为普通GPIO输出开漏或推挽通常推挽即可。在通信序列的开始手动拉低在结束时拉高。你可以用多个不同的GPIO来控制多个从机灵活且直观。// 控制两个从机的例子 #define DEVICE_A_CS_PIN GPIO_PIN_0 #define DEVICE_A_CS_PORT GPIOB #define DEVICE_B_CS_PIN GPIO_PIN_1 #define DEVICE_B_CS_PORT GPIOB void SPI_SelectDevice(GPIO_TypeDef* port, uint16_t pin) { // 先取消所有设备的选择拉高所有片选 HAL_GPIO_WritePin(DEVICE_A_CS_PORT, DEVICE_A_CS_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(DEVICE_B_CS_PORT, DEVICE_B_CS_PIN, GPIO_PIN_SET); // 短暂延时确保电平稳定某些器件需要 // __NOP(); __NOP(); // 选中目标设备 HAL_GPIO_WritePin(port, pin, GPIO_PIN_RESET); // 再次短暂延时满足从机片选建立时间要求 // __NOP(); __NOP(); } // 使用时 SPI_SelectDevice(DEVICE_A_CS_PORT, DEVICE_A_CS_PIN); // ... 与Device A通信 ... HAL_GPIO_WritePin(DEVICE_A_CS_PORT, DEVICE_A_CS_PIN, GPIO_PIN_SET); // 取消选择6. 调试技巧与常见问题排查SPI通信不出数据或者数据不对是家常便饭。一套高效的排查流程至关重要。6.1 排查流程清单检查物理连接 这是第一步也是最容易出错的一步。确保SCK、MOSI、MISO、CS四根或三根线连接正确、牢固。特别注意主机的MOSI接从机的MOSI主机的MISO接从机的MISO不要接反。电源和地是否稳定。确认电气电平 确保主机和从机的IO电平匹配如都是3.3V。如果不匹配需要电平转换。核对配置参数 这是软件排查的核心。逐项对比STM32的SPI配置与从机器件数据手册的要求模式CPOL/CPHA 99%的通信失败源于此。波特率 初始调试时请设置为最低速。数据大小8/16位 必须一致。位顺序MSB/LSB先行 必须一致。片选极性 大部分是低电平有效但也有例外。使用逻辑分析仪 这是终极武器。将探头连接到SCK、MOSI、MISO、CS线上抓取通信波形。你可以清晰地看到时钟是否产生频率对不对片选信号是否在正确的时间拉低/拉高MOSI线上发送的数据是什么是否符合你发送的命令MISO线上是否有数据返回返回的数据是什么时钟边沿和数据采样点是否符合你设置的模式 逻辑分析仪软件如Saleae Logic通常自带SPI协议分析器可以自动解析出十六进制数据一目了然。简化测试代码 写一个最简单的测试函数只发送一个已知的命令如读ID命令0x9F或0x90然后循环读取返回的数据并打印出来。排除复杂业务逻辑的干扰。检查初始化顺序 确保先初始化GPIO和SPI外设最后再操作片选引脚。有些器件对上下电序列有要求。6.2 典型问题与解决思路问题现象可能原因排查思路与解决方案完全无数据1. 电源或地未接好。2. 片选信号未拉低。3. SPI外设未使能。4. 引脚复用功能未正确配置。1. 用万用表测量电压。2. 用逻辑分析仪或示波器看CS波形。3. 检查代码中HAL_SPI_Init是否成功调用。4. 检查CubeMX中引脚是否正确映射到SPI功能。发送数据但MISO无返回1. 主从机模式设反。2. MISO线接错或损坏。3. 从机器件未正确初始化如需要先写配置寄存器。4. 从机器件损坏。1. 确认MCU是主模式从机是从模式。2. 交换MISO/MOSI线测试。3. 仔细阅读从机手册确认上电后的初始化流程。4. 更换从机器件测试。返回数据全为0xFF或0x001. SPI模式不匹配最常见。2. 波特率过高从机跟不上。3. 从机处于省电模式或写保护状态。1.重点检查CPOL和CPHA用逻辑分析仪对照时序图。2. 将波特率降到最低测试。3. 发送唤醒命令或解除写保护命令。数据错位如读到0x12变成0x481. 位顺序MSB/LSB设置错误。2. 数据帧大小不匹配如主机8位从机16位。1. 更改SPI_FirstBit配置试试。2. 统一设置为8位或16位。DMA传输数据错误或不全1. DMA缓冲区Cache一致性问题H7等带Cache的系列。2. DMA内存地址或数据长度设置错误。3. DMA传输完成中断中未正确处理。1. 清理/无效化Cache相关缓冲区。2. 检查HAL_SPI_Transmit_DMA的参数。3. 在DMA传输完成回调函数中检查状态并重新使能相关外设或缓冲区。6.3 关于“SPI和UART的区别”这是初学者常问的问题。简单来说UART 异步通信没有时钟线双方依靠预先约定好的波特率来通信。需要起始位、停止位效率相对较低但连线简单仅RX/TX两根数据线适合长距离、对速率要求不高的场合如打印调试信息。SPI 同步通信有时钟线数据收发完全由时钟边沿同步。效率高速率可以轻松达到几十Mbps。需要至少3-4根线适合板内短距离、高速、与各种外设通信的场景。选择哪一个取决于你的具体需求要简单和距离选UART要速度和丰富的器件支持选SPI。