1. 项目概述从“点灯”到“对话”为什么SPI是嵌入式开发的必修课玩过STM32的朋友从点灯到串口打印算是迈入了嵌入式世界的大门。但当你开始驱动一块TFT屏幕、读写一个SD卡或者连接一个高精度的传感器时会发现GPIO和UART有点力不从心了。这时候一个名字就会高频出现SPI。我刚开始接触SPI时觉得它比I2C复杂比UART“事儿多”但真正用熟了才发现它是连接外部高速器件的“高速公路”是项目从玩具级迈向产品级的关键一步。SPI全称Serial Peripheral Interface中文叫串行外设接口是ST、NXP、TI这些芯片大厂在自家MCU里普遍集成的一种高速、全双工、同步的通信总线。简单说它就像一条多车道的马路主设备通常是你的STM32可以同时向从设备发送和接收数据而且大家踩着同一个时钟节拍走效率非常高。这个笔记我们就来彻底拆解STM32的SPI外设。它适合谁呢如果你已经会用STM32的HAL库点灯、调串口想驱动SPI Flash如W25Q64、OLED屏SSD1306、或者像MPU6050这样的传感器那这篇内容就是为你准备的。我会避开枯燥的协议手册翻译直接从一个实际开发者的角度带你理解SPI在STM32里怎么配置、怎么用以及那些手册里不提但实际项目里一定会踩的坑。我们的目标很明确看完之后你能独立搞定一个SPI设备的驱动并且明白每一个配置项背后的“为什么”。2. SPI通信核心原理与STM32的实现方式2.1 SPI协议的精髓四线制与主从时钟同步要驾驭STM32的SPI外设死记寄存器不如先吃透协议本身。SPI协议的核心思想其实非常直观它用四根线解决所有问题SCK (Serial Clock)时钟线由主设备产生。这是总线的“指挥棒”所有数据位的传输都严格跟随它的上升沿或下降沿。这是SPI“同步”二字的来源也是其高速稳定的基础。MOSI (Master Output Slave Input)主设备输出从设备输入线。主设备通过这根线发送数据给从设备。MISO (Master Input Slave Output)主设备输入从设备输出线。主设备通过这根线接收从设备的数据。NSS (Slave Select)从设备选择线通常也叫做CSChip Select或SS。这是主设备用来“点名”哪个从设备来跟自己通信的。当NSS线被拉低有效对应的从设备被激活开始监听时钟和数据线当被拉高从设备进入“休眠”状态忽略总线。这是一个硬件上的片选机制。这里有一个关键点MOSI和MISO是独立的这意味着数据可以同时双向流动这就是“全双工”。想象一下打电话SPI就像视频通话双方能同时说和听而像I2C或单线UART则更像对讲机同一时间只能一方说。SPI的通信模式由两个关键参数决定时钟极性CPOL和时钟相位CPHA。它们组合成了四种模式Mode 0, 1, 2, 3。很多新手在这里会懵我用一个生活化的比喻来解释CPOL (Clock Polarity)时钟空闲时的状态。CPOL0表示SCK在空闲时为低电平CPOL1则为高电平。你可以把它想象成指挥棒的“预备姿势”是放下低还是举起高。CPHA (Clock Phase)数据采样的时刻。CPHA0表示在时钟的第一个边沿如果CPOL0就是上升沿CPOL1就是下降沿采样数据CPHA1则表示在时钟的第二个边沿采样。这相当于指挥棒挥动一下有两个动作起和落你选择在“起”的时候抓取数据还是在“落”的时候抓取。重要提示SPI模式必须主从设备严格匹配这是通信失败的头号杀手。你用的传感器或Flash芯片的数据手册里一定会明确写明它支持哪种SPI模式。STM32的配置必须与之完全一致差一点都不行。通常Mode 0和Mode 3最为常见。2.2 STM32 SPI外设的架构与工作模式STM32的SPI外设是一个高度集成化的硬件模块它把上述协议逻辑用硬件实现大大减轻了CPU的负担。其核心架构包括移位寄存器负责数据的并串转换发送时和串并转换接收时。当你写入数据到发送数据寄存器DR时硬件会自动将其移出到MOSI线同时MISO线上的数据也会被移入接收数据寄存器。波特率发生器由APB总线时钟分频得到SCK时钟。分频系数可以配置如2, 4, 8, 16, 32...256等这决定了通信速率。STM32F1系列的SPI最高可达18MbpsF4系列可达37.5MbpsH7系列更是能到上百Mbps。控制逻辑负责生成SCK、管理NSS信号、控制数据帧格式数据位宽通常是8位或16位、以及决定MSB最高位还是LSB最低位先传输。STM32的SPI主要有三种工作模式你需要根据应用场景选择全双工主模式最常用STM32作为主机主动产生SCK并通过MOSI发送数据同时通过MISO接收数据。这是驱动绝大多数SPI从设备如Flash、ADC的标准模式。全双工从模式STM32作为从机等待外部主机提供的SCK并根据时钟在MISO上输出数据从MOSI上输入数据。这常用于STM32作为协处理器与另一个更强大的主MCU通信。半双工单线双向模式可以配置为只使用一根数据线要么当MOSI用要么当MISO用进行单向或分时双向通信。这可以节省一个GPIO引脚但速率和灵活性下降。有些特殊的ADC芯片会用到这种模式。NSS引脚管理的坑点STM32的NSS引脚管理有“硬件模式”和“软件模式”之分。硬件模式NSSHardwareNSS引脚由硬件自动管理。在主模式下你需要将该引脚配置为复用推挽输出STM32会在通信开始时自动将其拉低结束时拉高。在从模式下该引脚必须配置为浮空输入等待外部主机拉低。硬件模式的缺点是一个SPI外设只能控制一个从设备。软件模式NSSSoftware这是更常用、更灵活的方式。你将NSS引脚配置为普通的GPIO输出比如PA4然后在代码里手动控制它的高低电平。在发起传输前手动拉低对应的CS引脚传输完成后再手动拉高。这样你可以用同一个SPI外设同一组SCK、MOSI、MISO通过不同的GPIO片选控制多个从设备实现“一主多从”。3. 使用STM32CubeMX与HAL库配置SPI的完整流程理论懂了我们上手实操。我会以驱动一块常见的SPI Flash芯片W25Q64为例使用STM32CubeMX图形化工具和HAL库展示从零到一的配置过程。选择W25Q64是因为它协议标准应用广泛且包含了SPI通信的典型操作写使能、读ID、读数据、写数据、擦除扇区。3.1 CubeMX工程初始化与SPI参数配置首先打开STM32CubeMX选择你的芯片型号例如STM32F103C8T6。启用SPI外设在Pinout Configuration标签页的左侧找到Connectivity-SPI1。将Mode设置为Full-Duplex Master全双工主机。这时对应的引脚PA5/SCK, PA6/MISO, PA7/MOSI会被自动分配。配置GPIO for NSS我们不使用硬件NSS。在左侧System Core-GPIO中找一个空闲的GPIO例如PA4作为软件片选。将其模式设置为GPIO_Output初始输出电平设为High默认不选中从设备。精调SPI参数点击SPI1进入参数设置。Basic Parameters:Prescaler (波特率预分频)根据你的主频和从设备支持的速度计算。例如主频72MHz选择256分频则SCK频率约为281.25kHz。初次调试建议从低速开始稳定后再提高。W25Q64最高支持104MHz但我们先保守点。Data Size (数据大小)选择8 bits。绝大多数SPI设备以8位为一个数据帧。First Bit (首位)选择MSB First。这是最常用的格式即最高位先发送。Clock Polarity (CPOL)和Clock Phase (CPHA)这里必须查W25Q64的数据手册手册会写明其支持的模式。通常W25Q64支持Mode 0和Mode 3。我们选择最常见的Low(CPOL0) 和1 Edge(CPHA0)即Mode 0。这意味着时钟空闲为低在第一个边沿上升沿采样数据。NSS Signal Type (NSS信号类型)选择Software。这样CubeMX就不会占用硬件NSS引脚PA4而是由我们通过GPIO控制。CRC Calculation禁用。除非你的通信协议要求CRC校验否则保持关闭。生成代码配置好系统时钟、工程名和IDEKeil或IAR等点击Generate Code。3.2 HAL库SPI驱动函数详解与封装CubeMX生成的代码为我们初始化好了SPI和GPIO。接下来我们编写应用层驱动。HAL库提供了几个核心的SPI传输函数阻塞式传输最简单HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_SPI_Receive(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_SPI_TransmitReceive(SPI_HandleTypeDef *hspi, uint8_t *pTxData, uint8_t *pRxData, uint16_t Size, uint32_t Timeout);这些函数会一直等待直到传输完成或超时期间CPU被挂起。适合简单的、非实时的操作。例如发送一个命令uint8_t cmd 0x9F; // W25Q64的读ID命令 HAL_SPI_Transmit(hspi1, cmd, 1, 100); // 发送1个字节超时100ms中断式传输HAL_StatusTypeDef HAL_SPI_Transmit_IT(...); HAL_StatusTypeDef HAL_SPI_Receive_IT(...); HAL_StatusTypeDef HAL_SPI_TransmitReceive_IT(...);函数调用后立即返回传输完成后会产生中断在中断回调函数HAL_SPI_TxRxCpltCallback()中处理后续逻辑。适合需要并行处理其他任务的场景。DMA传输高效推荐用于大数据量HAL_StatusTypeDef HAL_SPI_Transmit_DMA(...); HAL_StatusTypeDef HAL_SPI_Receive_DMA(...); HAL_StatusTypeDef HAL_SPI_TransmitReceive_DMA(...);利用DMA控制器在内存和SPI数据寄存器之间搬运数据完全不占用CPU时间。这是传输大量数据如读写Flash、刷新屏幕的首选方式效率极高。封装软件片选和基础读写函数 为了让代码更清晰我们封装几个基础函数// spi_flash.c #define W25Q64_CS_PIN GPIO_PIN_4 #define W25Q64_CS_PORT GPIOA static void CS_Low(void) { HAL_GPIO_WritePin(W25Q64_CS_PORT, W25Q64_CS_PIN, GPIO_PIN_RESET); } static void CS_High(void) { HAL_GPIO_WritePin(W25Q64_CS_PORT, W25Q64_CS_PIN, GPIO_PIN_SET); } // 单字节读写全双工同时收發 uint8_t SPI_ReadWriteByte(uint8_t TxData) { uint8_t RxData; // 使用阻塞式传输同时发送和接收一个字节 HAL_SPI_TransmitReceive(hspi1, TxData, RxData, 1, 100); return RxData; // 返回接收到的数据 } // 发送一段数据只发不收 void SPI_WriteBuffer(uint8_t* pBuffer, uint16_t len) { HAL_SPI_Transmit(hspi1, pBuffer, len, 1000); } // 接收一段数据只收不发通常需要先发送哑元数据如0xFF void SPI_ReadBuffer(uint8_t* pBuffer, uint16_t len) { // 注意对于只接收HAL_SPI_Receive内部会发送0xFF。确保从设备在MISO上有输出。 HAL_SPI_Receive(hspi1, pBuffer, len, 1000); }3.3 W25Q64 Flash驱动实现命令、读ID与数据读写有了基础函数我们就可以实现W25Q64的具体驱动了。SPI设备驱动通常遵循“命令地址数据”的格式。1. 读器件ID验证通信 这是验证SPI硬件连接和配置是否正确的最关键一步。uint32_t W25Q64_ReadID(void) { uint32_t ID 0; CS_Low(); SPI_ReadWriteByte(0x9F); // 发送读ID命令 ID | (SPI_ReadWriteByte(0xFF) 16); // 读制造商ID如0xEF (Winbond) ID | (SPI_ReadWriteByte(0xFF) 8); // 读存储器类型如0x40 ID | SPI_ReadWriteByte(0xFF); // 读容量ID如0x17 (64Mbit) CS_High(); return ID; // 期望结果可能是 0xEF4017 }如果读回来的ID是0xFFFFFF或0x000000基本可以断定SPI通信失败需要检查硬件连接、模式配置和片选信号。2. 读取数据void W25Q64_ReadData(uint32_t addr, uint8_t* pBuffer, uint16_t len) { CS_Low(); SPI_ReadWriteByte(0x03); // 发送读数据命令 SPI_ReadWriteByte((addr 16) 0xFF); // 发送24位地址的高8位 SPI_ReadWriteByte((addr 8) 0xFF); // 中8位 SPI_ReadWriteByte(addr 0xFF); // 低8位 // 连续读取数据 for(uint16_t i0; ilen; i) { pBuffer[i] SPI_ReadWriteByte(0xFF); // 发送哑元时钟读取数据 } CS_High(); }3. 写入数据需要先擦除 Flash的写入必须先擦除变为0xFF然后才能编程将1变为0。W25Q64最小的擦除单位是扇区4KB。// 等待Flash空闲检查BUSY位 void W25Q64_WaitBusy(void) { uint8_t status; do { CS_Low(); SPI_ReadWriteByte(0x05); // 读状态寄存器1命令 status SPI_ReadWriteByte(0xFF); CS_High(); } while(status 0x01); // 检查BUSY位bit0 } // 写使能 void W25Q64_WriteEnable(void) { CS_Low(); SPI_ReadWriteByte(0x06); // 写使能命令 CS_High(); } // 擦除一个扇区4KB void W25Q64_EraseSector(uint32_t addr) { W25Q64_WriteEnable(); CS_Low(); SPI_ReadWriteByte(0x20); // 扇区擦除命令 SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); CS_High(); W25Q64_WaitBusy(); // 等待擦除完成 } // 页编程最多256字节 void W25Q64_PageProgram(uint32_t addr, uint8_t* pData, uint16_t len) { if(len 256) len 256; // 页编程不能跨页 W25Q64_WriteEnable(); CS_Low(); SPI_ReadWriteByte(0x02); // 页编程命令 SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); SPI_WriteBuffer(pData, len); // 发送数据 CS_High(); W25Q64_WaitBusy(); }4. SPI实战进阶DMA传输、多从设备管理与性能优化4.1 使用DMA实现高速SPI数据传输当需要读写大量数据时例如从Flash读取一幅图片到LCD阻塞式传输会长时间占用CPU中断式传输也会产生大量中断开销。此时DMA直接存储器访问是唯一正确的选择。它让数据在内存和外设之间自动搬运CPU只需发起和等待完成通知。CubeMX中配置SPI DMA在Connectivity-SPI1的DMA Settings标签页中点击Add。添加两个DMA请求SPI1_TX方向Memory To Peripheral模式Normal单次传输或Circular循环传输用于连续流。SPI1_RX方向Peripheral To Memory模式Normal。生成代码后HAL库会自动初始化DMA通道。使用DMA传输数据// 准备发送和接收缓冲区 uint8_t tx_buffer[1024]; uint8_t rx_buffer[1024]; // ... 填充tx_buffer ... // 启动DMA双工传输 CS_Low(); if(HAL_SPI_TransmitReceive_DMA(hspi1, tx_buffer, rx_buffer, 1024) ! HAL_OK) { // 错误处理 } // 此时CPU被释放可以处理其他任务 // 等待传输完成可以通过查询标志位、中断或信号量 while(HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { // 或者使用 osDelay(1) 如果用了RTOS } CS_High(); // 此时 rx_buffer 中已填充了接收到的数据DMA使用心得缓冲区对齐确保DMA操作的缓冲区在内存中对齐通常是4字节可以提升性能并避免某些芯片的硬件问题。可以使用__attribute__((aligned(4)))修饰。传输完成回调在HAL_SPI_TxRxCpltCallback()回调函数中处理完成事件并释放信号量或设置标志位是RTOS应用中的标准做法。DMA与CPU缓存在带有Cache的MCU如STM32H7上如果DMA操作的内存区域开启了缓存必须在DMA传输前执行SCB_CleanDCache_by_Addr()发送前清理在传输后执行SCB_InvalidateDCache_by_Addr()接收后失效以确保数据一致性。这是H7系列开发的一个大坑。4.2 一主多从的SPI总线管理策略一个SPI主机通过软件片选控制多个从设备是常见的设计。关键在于严格的时序管理和总线冲突避免。硬件连接SCK、MOSI、MISO是所有设备共享的。每个从设备独占一个GPIO作为片选CS1, CS2, CS3...。软件管理要点互斥访问在任何时刻只能有一个从设备的CS引脚被拉低。在发起对某个设备的通信序列前必须确保其他所有设备的CS都为高。总线状态在切换从设备时要留意SCK的时钟极性。有些设备在CS拉高后要求SCK处于空闲状态根据CPOL。安全的做法是在拉高一个设备的CS后稍微延迟几个微秒再拉低下一个设备的CS。上拉电阻对于MISO线如果总线上有多个从设备当它们都不被选中时MISO输出为高阻态会导致总线浮空。最好在MISO线上加一个上拉电阻如4.7kΩ到VCC确保空闲时为确定的高电平。代码示例typedef enum { DEV_FLASH 0, DEV_SENSOR, DEV_LCD, // ... 其他设备 } SPI_Device_t; void SPI_SelectDevice(SPI_Device_t dev) { // 首先取消所有片选 HAL_GPIO_WritePin(CS_FLASH_GPIO_Port, CS_FLASH_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(CS_SENSOR_GPIO_Port, CS_SENSOR_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(CS_LCD_GPIO_Port, CS_LCD_Pin, GPIO_PIN_SET); // 短暂延时确保总线稳定 DWT_Delay_us(5); // 选中目标设备 switch(dev) { case DEV_FLASH: HAL_GPIO_WritePin(CS_FLASH_GPIO_Port, CS_FLASH_Pin, GPIO_PIN_RESET); break; case DEV_SENSOR: HAL_GPIO_WritePin(CS_SENSOR_GPIO_Port, CS_SENSOR_Pin, GPIO_PIN_RESET); break; case DEV_LCD: HAL_GPIO_WritePin(CS_LCD_GPIO_Port, CS_LCD_Pin, GPIO_PIN_RESET); break; } DWT_Delay_us(2); // 片选建立时间根据从设备要求调整 }4.3 SPI时钟配置与信号完整性优化随着通信速率提高比如到几十MHz信号完整性问题会凸显出来导致通信错误。时钟分频与最大速率不要盲目使用最高速率。计算实际SCK频率SCK APBx_Clock / Prescaler。确保它不超过从设备标称的最大SCK频率并留有一定余量比如80%。GPIO速度设置在CubeMX的GPIO配置中将SCK、MOSI、MISO引脚的速度Speed设置为High或Very High。这会影响IO口的压摆率高速率下必须设为高速模式否则波形会畸变。PCB布局与走线等长走线对于高速SPI10MHz尽量让SCK、MOSI、MISO、CS这几根线走线长度接近以减少信号偏移。远离干扰源远离电源、电机驱动、晶振等噪声源。加串行电阻在靠近STM32输出端SCK MOSI串联一个22-33欧姆的小电阻可以抑制过冲和振铃改善信号质量。使用示波器调试这是最直接的调试手段。测量SCK、MOSI、MISO波形检查幅值是否达标3.3V或5V。上升/下降沿是否陡峭有无明显的圆角或振铃。数据在正确的时钟边沿是否稳定建立时间和保持时间。5. SPI调试实战常见问题排查与解决方案实录即使按照手册配置SPI通信也常常出问题。下面是我在项目中遇到的一些典型问题及排查思路希望能帮你快速定位。5.1 通信完全失败读回数据全为0xFF或0x00这是最常遇到的问题表现为发送任何命令读回来的数据都是0xFF上拉状态或0x00。排查清单硬件连接这是第一嫌疑。用万用表蜂鸣档逐根线检查SCK、MOSI、MISO、CS是否连通有无虚焊、短路。特别注意MOSI和MISI是否接反了这是新手经典错误。电源与地确保从设备供电正常且与STM32共地。GPIO模式确认STM32的SPI引脚模式是否正确。SCK和MOSI应配置为Alternate Function Push-Pull复用推挽输出MISO应配置为Input或Alternate Function复用功能但方向是输入。CS引脚如果软件控制必须是Output Push-Pull。SPI模式CPOL/CPHA99%的通信失败源于此再次、反复、确认从设备数据手册要求的SPI模式与CubeMX中的设置一字不差地匹配。尝试四种模式逐一测试。片选信号用示波器或逻辑分析仪看CS引脚。通信序列开始时CS是否被拉低序列结束后是否被拉高拉低和拉高的时机是否正确有些设备要求CS在两次传输之间必须拉高至少几个微秒。时钟信号用示波器看SCK引脚。在CS拉低后发送数据时是否有时钟波形频率是否符合预期如果根本没波形检查SPI外设是否使能__HAL_SPI_ENABLE(hspi1)或者是否被其他代码禁用了。5.2 数据错位或字节错乱表现为能收到数据但数据内容不对比如命令响应错位或者多出/少了几个字节。排查清单数据位序MSB/LSB检查CubeMX中First Bit设置。绝大多数设备是MSB First但极少数可能是LSB First。数据帧大小确认是8 bit还是16 bit。W25Q64是8位但有些音频编解码器可能是16位。时钟相位CPHA如果CPHA设置错误可能会导致采样点落在数据变化的边缘造成数据不稳定。在示波器上同时观察SCK和MOSI看数据是否在正确的时钟边沿保持稳定。软件时序在发送命令、地址、数据的字节之间是否留有足够的时间有些低速设备需要微秒级的延时。可以在SPI_ReadWriteByte函数调用之间加入DWT_Delay_us(1)试试。缓冲区溢出在使用DMA或中断时如果数据处理太慢而下一帧数据已经到来会导致数据被覆盖。检查DMA/中断的完成处理是否及时缓冲区是否够大。5.3 DMA传输不稳定或卡死DMA配置相对复杂容易出问题。排查清单DMA通道冲突确保SPI的TX和RX DMA请求分配到了不同的DMA流Stream和通道Channel或者同一流但优先级不同避免冲突。内存对齐如前所述DMA缓冲区地址对齐问题。使用malloc分配的动态内存可能未对齐建议使用静态数组或对齐分配。Cache一致性问题Cortex-M7等这是最高频的坑如果你的CPU有Cache而DMA操作的内存区域开启了Cache必须手动维护一致性。发送前Clean接收后Invalidate。传输完成判断不要单纯依赖HAL_SPI_GetState它可能返回BUSY状态。结合使用DMA传输完成回调函数和自定义的标志位更为可靠。超时设置虽然DMA不依赖超时但HAL库的DMA传输启动函数可能有超时参数。如果设置过短可能在DMA还没真正完成时就返回超时错误。可以适当加长或使用中断回调。5.4 多从设备相互干扰当一个设备工作时另一个设备的数据异常。排查清单片选隔离确保你的SPI_SelectDevice函数在选中新设备前确实拉高了所有其他设备的片选。用逻辑分析仪同时抓取多个CS信号验证。从设备输出使能有些从设备在CS为高时其MISO引脚不是高阻态而是仍然有输出可能是低电平。这会与当前选中设备在MISO线上的输出冲突。检查从设备手册必要时在硬件上为每个从设备的MISO线加上隔离缓冲器如74HC125。总线负载挂载的设备太多可能导致总线电容过大信号上升沿变缓在高速下出错。减少设备数量或降低通信速率或在总线上串联小电阻。调试SPI逻辑分析仪是比示波器更高效的利器。它能同时解码多路信号直接显示出十六进制的数据字节让你一眼就能看出命令、地址、数据是否正确极大提升调试效率。