
1. 从“串行”到“高效”为什么SPI是嵌入式通信的常青树在STM32这类微控制器的世界里通信协议就像设备之间对话的语言。UART、I2C、SPI是最常见的三种。如果你刚开始接触可能会觉得UART最简单因为它只需要两根线TX和RX配置好波特率就能聊起来。I2C呢两根线SDA和SCL能挂一堆设备听起来很省事。但当你真正开始驱动一块高速ADC、一块TFT屏幕或者一个Flash存储器时SPISerial Peripheral Interface往往会成为那个让你又爱又恨但最终离不开的选择。我最初接触SPI是为了驱动一块W25Q64 Flash芯片来存储数据。当时觉得不就是发数据收数据嘛用GPIO模拟一下时序不就行了结果自己写的模拟代码速度慢不说还时不时丢数据调试起来极其痛苦。直到后来硬着头皮去啃STM32的SPI外设手册配置好DMA直接存储器访问才发现原来“专业的事要交给专业的模块去做”SPI硬件外设带来的稳定性和效率提升是巨大的。它绝不仅仅是一个“四根线的串行接口”其背后的全双工、主从架构、时钟极性和相位等概念构成了一个精巧而高效的通信体系。这篇文章我就结合自己在STM32上折腾SPI的无数次踩坑经历从协议本质、硬件配置到实战代码和高级玩法比如DMA为你拆解清楚。无论你是正在为驱动一个SPI器件发愁还是想优化现有通信的速率和稳定性这里的内容都能给你提供一条清晰的路径。我们不止讲“怎么配”更要讲清楚“为什么这么配”以及“配错了会怎样”。2. 深入骨髓SPI协议的核心四要素与四种模式很多人学SPI一上来就去看STM32 CubeMX里那四个模式Mode 0, 1, 2, 3然后对照着数据手册去匹配这当然没错。但如果不理解这四个模式背后的两个根本参数你永远只能死记硬背一旦换一个器件或者时序上出了点玄学问题就会束手无策。这两个根本参数是时钟极性CPOL和时钟相位CPHA。它们共同决定了数据在时钟信号的哪个边沿被采样这是SPI通信正确与否的生命线。CPOL (Clock Polarity): 时钟极性。它定义的是SPI总线在空闲状态时时钟线SCK的电平。CPOL 0: SCK空闲时为低电平。CPOL 1: SCK空闲时为高电平。 你可以把它想象成信号的“初始状态”。这决定了通信开始前和结束后SCK线所处的电平。CPHA (Clock Phase): 时钟相位。它定义的是数据在时钟的第几个边沿被采样捕获。CPHA 0: 数据在时钟的第一个边沿如果CPOL0第一个边沿就是上升沿如果CPOL1第一个边沿就是下降沿被采样。数据输出则在这个边沿之前就需准备好。CPHA 1: 数据在时钟的第二个边沿被采样。数据输出则在这个边沿之前就需准备好。这两个参数的组合就形成了那四种模式Mode 0: CPOL0, CPHA0。SCK空闲低电平数据在SCK的上升沿被采样。Mode 1: CPOL0, CPHA1。SCK空闲低电平数据在SCK的下降沿被采样。Mode 2: CPOL1, CPHA0。SCK空闲高电平数据在SCK的下降沿被采样。Mode 3: CPOL1, CPHA1。SCK空闲高电平数据在SCK的上升沿被采样。一个极易混淆的“坑”很多资料和工具包括CubeMX的图示画的数据线MOSI/MISO变化沿和采样沿都是针对主设备视角的。对于从设备来说它采样数据的边沿正好与主设备输出数据的边沿相对应。所以主从设备的模式必须严格一致。通常从设备如W25Q64 Flash、ILI9341屏幕的数据手册会明确规定它工作在哪种模式主设备STM32必须去匹配它。怎么快速判断该用哪种模式我自己的经验是永远以从设备的数据手册为准。找到Timing Diagram时序图看SCK线和数据线的对应关系。重点关注两个点1. SCK空闲时的电平确定CPOL。2. 数据线在SCK的哪个边沿是稳定的确定CPHA数据稳定的中心被采样的边沿就是采样边沿。除了时钟模式SPI的物理线也很重要通常我们说的四线制是SCK (Serial Clock): 时钟线由主设备产生。MOSI (Master Out Slave In): 主设备输出从设备输入。MISO (Master In Slave Out): 主设备输入从设备输出。CS/SS (Chip Select / Slave Select): 片选线低电平有效由主设备控制。这是实现一主多从的关键每个从设备独占一根片选线。这里就引出一个实战中常见的选择硬件片选NSS vs 软件片选GPIO。硬件NSS使用STM32 SPI外设自带的NSS引脚。它可以配置为硬件模式在主模式下输出自动控制在从模式下自动检测输入。优点是省心硬件自动管理。缺点是灵活性差通常一个SPI外设只能硬件管理一个从设备在多主模式下逻辑更复杂。软件片选随便用一个普通的GPIO口来充当片选。在通信开始前拉低通信结束后拉高。这是最常用、最推荐的方式。因为你可以用多个GPIO控制多个从设备非常灵活。代码上也清晰直观。我几乎在所有项目中都使用软件片选。除非从设备有非常特殊的NSS信号要求极少见否则软件片选是万金油。2.1 数据帧格式与传输顺序MSB还是LSB另一个容易忽略的配置是数据帧格式主要是数据大小和位顺序。数据大小 (Data Size): 通常是8位或16位。大部分SPI器件是8位通信的但有些音频编解码器、特定传感器可能支持16位。STM32的SPI可以配置为4位到16位。位顺序 (Bit Order):MSB (Most Significant Bit) 先行还是 LSB (Least Significant Bit) 先行。这决定了你是先发送一个字节的最高位bit7还是最低位bit0。绝大多数SPI器件都是MSB先行包括W25Q64、ILI9341等。如果你发现通信数据错乱但模式又没错可以查查是不是这里配置反了。STM32的SPI外设通过CR1寄存器中的LSBFIRST位控制。0表示MSB先行默认1表示LSB先行。务必与从设备手册核对。3. 从零搭建STM32 CubeMX配置SPI的细节魔鬼理论懂了我们上手配置。以STM32F4系列驱动W25Q64 FlashMode 0 MSB先行为例使用CubeMX。第一步引脚配置与模式选择打开CubeMX找到你想用的SPI外设比如SPI1。将模式选为“Full-Duplex Master”全双工主机。这是最常用的模式MOSI和MISO都启用。查看右侧的引脚分配SCK、MOSI、MISO会自动分配到特定引脚。这里有个大坑STM32的很多引脚有复用功能重映射一定要根据你的硬件原理图确认这些自动分配的引脚是否是你实际连接使用的引脚。如果不是需要在“Pinout view”里找到对应引脚手动选择其复用功能为SPI。片选NSS我们选择“GPIO Output”然后手动指定一个GPIO口比如PA4作为软件片选。将其初始输出电平设置为高因为片选低有效。第二步参数配置关键步骤在SPI的“Parameter Settings”标签页Basic Parameters:Clock Prescaler (时钟预分频器): 这是决定SPI通信速率的核心。SPI时钟 APB总线时钟 / 预分频值。对于STM32F407APB2总线时钟最高84MHz。W25Q64最高支持104MHz但我们刚开始调试建议先选一个低速的比如PCLK2 / 256得到约328kHz的SCK稳定后再提高。原则先求稳再求快。Data Size: 选择8 Bits。First Bit: 选择MSB First。Slave Management从设备管理:因为我们用软件片选所以这里的Hardware NSS Signal选择Disable。Communication Mode通信模式:Mode: 根据从设备选择。W25Q64是Mode 0所以这里CPOL选Low CPHA选1 Edge对应CPHA0。注意CubeMX的CPHA描述“1 Edge”表示在第一个时钟边沿采样“2 Edge”表示在第二个时钟边沿采样。这需要结合CPOL来看对于CPOL0 “1 Edge”就是上升沿采样即Mode 0。CRC Calculation: 通常禁用除非协议要求。Advanced Parameters高级参数:NSSP Mode: 禁用。Fifo Threshold: 保持默认。第三步生成代码与基础驱动函数生成代码后HAL库为我们提供了几个核心函数HAL_SPI_Transmit(hspi1, pData, Size, Timeout): 主设备发送数据。HAL_SPI_Receive(hspi1, pData, Size, Timeout): 主设备接收数据。HAL_SPI_TransmitReceive(hspi1, pTxData, pRxData, Size, Timeout): 主设备同时收发数据全双工。对于W25Q64我们通常需要先写一个基础的读写字节函数// 软件片选控制宏 #define W25Qxx_CS_LOW() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET) #define W25Qxx_CS_HIGH() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET) // 读写一个字节 uint8_t SPI1_ReadWriteByte(uint8_t TxData) { uint8_t RxData; HAL_SPI_TransmitReceive(hspi1, TxData, RxData, 1, 1000); // 超时时间可调 return RxData; } // 读取Flash ID的示例 uint32_t W25Qxx_ReadID(void) { uint32_t ID 0; W25Qxx_CS_LOW(); SPI1_ReadWriteByte(0x9F); // 发送读ID命令 ID | SPI1_ReadWriteByte(0xFF) 16; ID | SPI1_ReadWriteByte(0xFF) 8; ID | SPI1_ReadWriteByte(0xFF); W25Qxx_CS_HIGH(); return ID; }注意HAL_SPI_TransmitReceive在发送TxData的同时也会把从MISO线读到的数据存入RxData。即使你只想发送命令也需要提供一个缓冲区来接收虽然可能无用反之亦然。4. 性能飞跃引入DMA释放CPU并解决其特有难题当你用上面的轮询方式HAL_SPI_TransmitReceive带超时等待成功驱动设备后很快就会遇到瓶颈CPU被长时间阻塞在SPI通信上。比如读写一幅320x240的图片到LCDILI9341几万字节的数据会让CPU什么都干不了。这时DMA就是你的救星。DMADirect Memory Access允许外设如SPI直接与内存交换数据无需CPU介入。配置SPI DMA后你只需要启动传输然后CPU就可以去处理其他任务等传输完成产生一个中断通知你。CubeMX配置DMA在SPI配置页切换到“DMA Settings”标签页。点击“Add”添加一个DMA请求。对于SPI1_TX发送通常选择通道3具体查数据手册的DMA请求映射表。方向设为Memory To Peripheral优先级中等。同样为SPI1_RX接收添加一个DMA请求。方向设为Peripheral To Memory。在“System Core” - “DMA”设置中可以配置更详细的DMA参数如模式Normal普通模式或Circular循环模式、数据宽度通常Peripheral和Memory都选Byte与SPI的8位对应、内存地址是否自增等。代码实现DMA传输使用HAL库的DMA函数// 启动SPI DMA发送 HAL_SPI_Transmit_DMA(hspi1, pData, Size); // 启动SPI DMA接收 HAL_SPI_Receive_DMA(hspi1, pData, Size); // 启动SPI DMA同时收发全双工 HAL_SPI_TransmitReceive_DMA(hspi1, pTxData, pRxData, Size);启动后传输在后台进行。你需要利用回调函数来处理传输完成事件// 在合适的地方如main.c重写传输完成回调函数 void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { // SPI1 DMA收发完成在这里处理数据或通知任务 // 例如设置一个标志位 spi_dma_complete_flag 1; } }4.1 DMA实战中的三大“坑”与解决方案然而DMA用起来爽调试起来却可能让你头疼。下面是我踩过的几个典型大坑坑一DMA传输完成中断不触发或数据错乱现象代码卡住或者回调函数永远进不去或者收到的数据全是0或0xFF。排查与解决检查缓存一致性这是ARM Cortex-M系列最常见的问题。CPU和DMA共同访问内存如果CPU有缓存如STM32H7的Cache而DMA不经过缓存直接访问物理内存就会导致数据不一致。解决方案对于DMA传输用的缓冲区务必进行缓存对齐和清理/无效化操作。对于发送缓冲区在启动DMA前调用SCB_CleanDCache_by_Addr对于接收缓冲区在DMA完成后调用SCB_InvalidateDCache_by_Addr。或者更简单粗暴地在CubeMX中启用MPU配置该内存区域为“Device”或“Normal Non-cacheable”类型。检查DMA配置确认DMA的源/目标地址、数据宽度、传输数量是否正确。例如SPI是8位DMA的数据宽度也应设为Byte。内存地址自增要开启外设地址自增要关闭。检查SPI DR寄存器确保SPI的CR2寄存器中TXDMAEN和RXDMAEN位已使能HAL库通常会设置。坑二全双工DMA收发时TX和RX长度不一致导致卡死现象使用HAL_SPI_TransmitReceive_DMA时如果只关心发送把接收缓冲区设为NULL或者长度设0程序可能会卡在HAL库的断言或直接硬件错误。解决方案全双工模式下即使你不需要接收数据也必须提供一个有效的接收缓冲区并且发送和接收的长度参数必须相同。因为硬件上时钟在跑MISO线确实有数据进来DMA必须有个地方放这些数据。你可以提供一个“垃圾桶”缓冲区来接收无用数据。坑三DMA传输与软件片选的时序竞争现象数据似乎发送了但从设备没反应。用逻辑分析仪看发现片选CS的下降沿开始或上升沿结束与SCK时钟的时序不对可能CS变化时正好在传输数据位中间。解决方案这是软件片选和硬件DMA异步操作导致的。标准的操作流程应该是拉低CS片选。稍微延时一点点比如一个NOP指令或几个时钟周期等待从设备识别到片选有效。特别是对于某些低速或需要准备时间的从设备。启动SPI DMA传输。在DMA传输完成回调函数中再拉高CS。 确保CS的拉低和拉高操作都在SPI数据传输的“空闲期”进行。对于高速SPI这个延时可能需要非常精确有时需要配合SPI的NSSP脉冲模式硬件管理或使用定时器来精确控制GPIO。5. 进阶与调试逻辑分析仪是你的第三只眼当你遇到SPI通信不通、数据错误等疑难杂症时光靠printf打印是远远不够的。一个几十块钱的USB逻辑分析仪配合上位机软件如Saleae Logic、PulseView是你最值得投资的调试工具。连接与使用 将逻辑分析仪的通道分别连接到SCK、MOSI、MISO、CS软件片选用的GPIO线上。设置合适的采样率通常为SCK频率的4-8倍以上。如何分析看波形首先看CS线是否在通信前后有正确的跳变。然后看SCK线是否有脉冲频率是否符合预期与你的预分频配置计算值对比。解码数据在软件中设置SPI解码器指定哪个通道是SCK、MOSI、MISO、CS并设置正确的模式CPOL CPHA、位顺序和片选极性。软件会自动将高低电平解析成十六进制或二进制数据。对比验证将解码出的数据与你代码中准备发送的数据进行对比。如果发送的命令字不对那问题出在代码如果命令字对了但从设备没回复或者回复不对那可能是从设备本身问题、电源问题、或模式/时序不匹配。我曾经用逻辑分析仪抓到一个诡异的问题STM32发送的片选信号下降沿太快导致某个Flash芯片的输入寄存器没锁存住第一个命令位。后来在拉低CS后加了非常短暂的延时__NOP(); __NOP();问题就解决了。没有逻辑分析仪这种问题靠猜是永远找不到的。5.1 常见SPI器件驱动要点速查W25Qxx系列FlashMode 0或Mode 3 MSB先行。写操作前必须先发送“写使能”命令0x06写完后要等待“忙状态”位清除读状态寄存器1 bit0。特别注意芯片有扇区、块等结构写之前必须擦除Erase擦除单位较大通常4KB扇区起规划好数据存储结构。ILI9341等TFT屏幕通常也支持SPI除了并行接口。通信分为命令DC/RS引脚拉低和数据DC/RS引脚拉高两种。初始化序列长且固定必须严格按照厂家提供的示例代码顺序和延时来。刷屏时先发送设置坐标窗口的命令然后连续发送像素数据RGB565格式常见。使用DMA刷屏能极大提升帧率。ADS1256等高精度ADC对SPI时序要求苛刻SCK空闲状态、数据采样边沿必须严格匹配。有时需要降低SPI时钟速率以保证稳定。它的数据是24位的需要注意多字节读取的顺序和对齐问题。6. 跨越平台SPI思想在FPGA与Linux下的延伸虽然本文聚焦STM32但SPI协议的思想是通用的。在FPGA中你可以用状态机精确地实现SPI的主机或从机逻辑完全掌控每一个时钟周期这对于需要定制化高速SPI通信或与非常规设备对接的场景非常有用。在Linux下如使用树莓派、或嵌入式Linux开发板SPI通常通过/dev/spidev设备文件来操作你可以用ioctl调用设置模式、速度和位顺序然后用read/write进行数据传输。Linux内核的SPI子系统已经处理了大部分底层细节让你更专注于应用逻辑。无论是单片机、FPGA还是Linux理解SPI的时钟极性、相位、片选机制和全双工特性都是你打通任督二脉的关键。在STM32上扎实地走过一遍配置、轮询、DMA、调试的完整流程后再去接触其他平台你会发现核心概念一脉相承只是实现的工具和接口不同而已。最后关于STM32的SPI我个人最深刻的体会是数据手册和参考手册永远是你最好的朋友。CubeMX和HAL库极大提高了开发效率但它们有时会隐藏细节。当你遇到无法解释的行为时回归到寄存器的层面查看SPI-SR状态寄存器查看SPI-DR数据寄存器的进出往往能直击问题本质。比如通过判断TXE发送缓冲区空和RXNE接收缓冲区非空标志来编写更高效的轮询驱动或者排查DMA传输为何卡住。把硬件的工作原理吃透你才能从“调库侠”成长为真正能驾驭硬件的开发者。