
1. 项目概述为什么SPI是嵌入式开发者的必修课如果你正在玩STM32或者任何一款主流的MCU那么SPISerial Peripheral Interface这个通信外设绝对是你绕不开的一道坎。它不像UART那样“傻白甜”接上TX、RX就能聊也不像I2C那样“规矩多”还得考虑地址和应答。SPI更像一个高效、直接的“数据搬运工”在芯片之间以高速率、全双工的方式交换数据。从驱动一块小小的OLED屏幕、读写一片Flash存储器到与高精度的ADC、DAC传感器对话甚至连接无线模块SPI的身影无处不在。我刚开始接触时觉得它不就是四根线嘛但真正用起来各种模式、相位、极性的配置加上DMA的搅和没少踩坑。今天我就结合在STM32上的实际项目经验把SPI从协议原理到代码实操再到那些手册上不会写的“坑点”给你彻底捋清楚。无论你是刚拿到一块STM32开发板的新手还是正在调试某个SPI外设却卡住了的老鸟这篇笔记都能给你提供一条清晰的排查路径和可靠的代码参考。2. SPI协议核心四线制下的“同步对话”艺术很多人把SPI简单地理解为四根线SCK时钟、MOSI主机输出从机输入、MISO主机输入从机输出、NSS片选。这没错但只看到了表象。SPI的本质是一种同步的、全双工的、主从式的串行通信协议。关键在于“同步”主机完全掌控着通信的节奏。2.1 时钟极性(CPOL)与相位(CPHA)理解数据采样的“节拍”这是SPI最让人头晕的地方但也是其灵活性的体现。它定义了时钟信号在空闲时的状态CPOL以及数据在时钟的哪个边沿被采样CPHA。两者组合形成了SPI的四种模式Mode 0, 1, 2, 3。CPOL (Clock Polarity)CPOL0时钟空闲时为低电平。CPOL1时钟空闲时为高电平。你可以把它想象成指挥家的指挥棒默认指的方向。CPHA (Clock Phase)CPHA0数据在时钟的第一个边沿对于CPOL0是上升沿对于CPOL1是下降沿被采样在下一个边沿切换。CPHA1数据在时钟的第二个边沿被采样在第一个边沿切换。这定义了乐手是在指挥棒挥起时第一个边沿开始演奏还是在挥下时第二个边沿开始演奏。为了更直观我们通常用模式号来记忆模式CPOLCPHA时钟空闲状态数据采样边沿数据切换边沿Mode 000低电平上升沿下降沿Mode 101低电平下降沿上升沿Mode 210高电平下降沿上升沿Mode 311高电平上升沿下降沿关键经验绝大多数常见的SPI器件如W25Qxx系列FlashILI9341屏幕都工作在Mode 0或Mode 3。在开始编程前第一件事就是去查阅你所用外设的数据手册确认其要求的SPI模式。主从设备的模式必须严格一致否则读回来的全是乱码。2.2 数据帧格式与传输顺序MSB还是LSBSPI协议本身没有规定一帧数据是多少位常见的是8位或16位。STM32的SPI外设通常支持4位到16位可配置。另一个容易忽略的点是数据位顺序。MSB First最高位先发送。这是最常见、默认的设置。LSB First最低位先发送。有些特定的器件或协议如某些音频Codec会这样要求。STM32的SPI外设寄存器中有一个FRF(Frame Format) 位和LSBFIRST控制位来配置这些。同样必须与外设器件的要求匹配。2.3 片选(NSS)管理硬件与软件的权衡NSS信号用于主机选择要与哪个从机通信。STM32提供了两种管理方式硬件NSS从机模式当SPI配置为从机时NSS引脚作为输入由外部主机控制。STM32的SPI硬件会自动检测NSS引脚的电平当其为低时从机被选中并准备接收时钟和数据。软件NSS主机模式当SPI配置为主机时更常用的方式是软件管理片选。即你不使用SPI外设硬件自动控制的NSS引脚而是自己随便用一个普通的GPIO口比如PA4来连接从机的片选引脚。在通信开始前手动拉低这个GPIO通信结束后再手动拉高。优势灵活。你可以在一次通信过程中比如发送命令字和读取数据之间保持片选有效这是硬件NSS难以做到的。操作在STM32 HAL库中即使你使能了硬件NSS在主机模式下也强烈建议结合软件控制。我的习惯是初始化时将片选GPIO设置为推挽输出并先置为高电平无效。在HAL_SPI_TransmitReceive()等函数前后手动控制其电平。// 示例软件片选控制 #define SPI_CS_GPIO_PORT GPIOA #define SPI_CS_GPIO_PIN GPIO_PIN_4 // 通信开始前 HAL_GPIO_WritePin(SPI_CS_GPIO_PORT, SPI_CS_GPIO_PIN, GPIO_PIN_RESET); // 拉低选中 HAL_Delay(1); // 稍作延时确保从机准备好视器件而定 HAL_SPI_TransmitReceive(hspi1, tx_data, rx_data, data_size, timeout); // 通信结束后 HAL_GPIO_WritePin(SPI_CS_GPIO_PORT, SPI_CS_GPIO_PIN, GPIO_PIN_SET); // 拉高取消选中3. STM32的SPI外设架构与CubeMX配置STM32的SPI外设功能相当完整支持主/从模式、全双工/半双工、硬件CRC等。我们以STM32F4系列为例拆解其内部结构。3.1 SPI外设内部数据流数据从你的应用程序写入SPI_DR(Data Register) 寄存器开始。在主机模式下一旦DR寄存器被写入或通过DMA写入SPI外设就会根据配置CPOL, CPHA产生SCK时钟。通过MOSI线将数据移位输出。同时通过MISO线采样输入数据并移位存入接收缓冲区。当一帧数据传输完成接收缓冲区的数据会被并行加载到SPI_DR寄存器中供你读取并置位RXNE(Receive Buffer Not Empty) 标志。同时如果发送缓冲区已空会置位TXE(Transmit Buffer Empty) 标志。中断和DMA正是基于这些标志位来工作的你可以使能RXNE和TXE中断在中断服务程序里读写数据或者更高效地为这两个事件配置DMA通道让DMA控制器自动在内存和SPI_DR寄存器之间搬运数据CPU得以解放。3.2 使用STM32CubeMX进行图形化配置对于新手CubeMX是快速上手的利器。假设我们配置SPI1为主机全双工Mode 0。Pinout Configuration:在Connectivity-SPI1下将Mode设置为Full-Duplex Master。Hardware NSS Signal选择Disable因为我们用软件片选。下方的引脚会自动分配为PA5(SCK),PA6(MISO),PA7(MOSI)。检查一下是否有冲突。Configuration:Basic Parameters:Clock Prescaler: 这是决定SPI时钟频率的关键。SCK频率 APB2总线时钟/Prescaler。例如APB2为84MHz选择8分频则SCK为10.5MHz。务必确保此频率不超过从机器件支持的最大SCK频率。Data Size: 选择8 Bits或16 Bits。First Bit: 选择MSB First通常。Clock Polarity和Clock Phase: 根据从机要求选择例如Low和1 Edge对应 Mode 0。Advanced Parameters:NSS Signal Type: 选择Software。CRC Calculation: 通常禁用除非协议要求。Master SS Output: 禁用。生成代码:在Project Manager设置好项目名、路径、IDE如MDK-ARM。点击GENERATE CODE。CubeMX会生成完整的初始化代码MX_SPI1_Init()以及一个全局的SPI句柄hspi1。踩坑记录CubeMX生成的代码有时不会自动开启SPI外设时钟__HAL_RCC_SPI1_CLK_ENABLE()但通常在HAL_SPI_MspInit()函数里它会做。不过如果你手动移植代码或者复用初始化函数一定要确认SPI和所用GPIO的时钟都已使能。这是“没反应”的最常见原因之一。4. 从阻塞式到DMA三种SPI通信方式详解STM32 HAL库提供了不同抽象层次的API适应不同场景。4.1 阻塞式传输最简单但效率最低这是最基础的API函数会一直等待直到整个数据传输完成或超时。HAL_SPI_Transmit(hspi, pData, Size, Timeout): 只发送。HAL_SPI_Receive(hspi, pData, Size, Timeout): 只接收。HAL_SPI_TransmitReceive(hspi, pTxData, pRxData, Size, Timeout): 同时发送和接收全双工。// 示例阻塞式读取W25Q128 Flash的制造商和设备ID uint8_t tx_buf[4] {0x90, 0x00, 0x00, 0x00}; // 读ID命令3字节地址 uint8_t rx_buf[6] {0}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, tx_buf, rx_buf, 4, 100); // 先发送命令和地址 // 此时rx_buf的前4个字节是发送时的“回显”我们需要继续接收2字节ID HAL_SPI_Receive(hspi1, rx_buf[4], 2, 100); // 继续接收制造商ID和设备ID HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); uint16_t manufacturer_id rx_buf[4]; uint16_t device_id rx_buf[5];缺点在传输期间CPU被完全挂起无法处理其他任务。对于低速或偶尔通信的场景可以接受但对于高速或实时性要求高的系统如驱动LCD刷屏这是灾难。4.2 中断式传输解放CPU但增加复杂度函数调用后立即返回传输在后台进行完成后触发中断。HAL_SPI_Transmit_IT(hspi, pData, Size)HAL_SPI_Receive_IT(hspi, pData, Size)HAL_SPI_TransmitReceive_IT(hspi, pTxData, pRxData, Size)你需要编写对应的中断回调函数HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi): 发送完成回调。HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi): 接收完成回调。HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi): 收发完成回调。volatile uint8_t spi_tx_done 0; volatile uint8_t spi_rx_done 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { spi_tx_done 1; } } void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { spi_rx_done 1; } } // 在主程序中 spi_tx_done 0; HAL_SPI_Transmit_IT(hspi1, tx_data, 100); while(!spi_tx_done) { /* 可以在这里处理其他任务 */ }优点CPU在传输过程中可以处理其他事情。缺点每个字节或每帧数据都可能产生中断当数据量很大时中断频繁进出消耗的CPU时间也很可观且编程模型稍复杂。4.3 DMA传输终极性能之选也是“坑”最多的地方DMA直接存储器访问控制器可以在不打扰CPU的情况下完成外设如SPI的DR寄存器和内存之间的大批量数据搬运。这是实现高速、连续SPI通信如驱动LCD、高速ADC采样的必备技能。配置步骤以SPI1_TX使用DMA1 Stream3为例CubeMX配置:在SPI1的DMA Settings选项卡点击Add。选择SPI1_TX 模式为Normal单次传输或Circular循环模式用于连续传输如音频。类似地添加SPI1_RX。DMA数据流Stream和通道Channel是固定的需要查数据手册。例如SPI1_TX对应DMA1 Stream3/Stream5 Channel3。代码中使用DMA传输:HAL_SPI_Transmit_DMA(hspi, pData, Size)HAL_SPI_Receive_DMA(hspi, pData, Size)HAL_SPI_TransmitReceive_DMA(hspi, pTxData, pRxData, Size)同样有对应的DMA传输完成回调函数HAL_SPI_TxHalfCpltCallback,HAL_SPI_TxCpltCallback等。DMA使用中的核心“坑点”与经验内存对齐与缓存一致性这是STM32使用DMA时最隐蔽的坑。如果你的CPU有D-Cache数据缓存如STM32H7系列而DMA操作的是普通内存DMA通常绕过Cache直接访问内存就会导致缓存一致性问题。你写入缓冲区的数据可能还在Cache里没刷到实际内存中DMA就读走了旧数据或者DMA写入内存的数据CPU从Cache里读到了旧数据。解决方案对于需要被DMA读取的发送缓冲区在启动DMA传输前使用SCB_CleanDCache_by_Addr()函数清理其缓存。对于被DMA写入的接收缓冲区在CPU读取前使用SCB_InvalidateDCache_by_Addr()函数无效化其缓存。对于F4等无Cache的系列则无需此操作。数据宽度匹配确保SPI的数据位宽8/16位、DMA的外设数据宽度Peripheral Data Width和内存数据宽度Memory Data Width三者匹配。例如SPI配置为16位数据那么DMA也应该设置外设和内存的数据宽度为Half Word16位。DMA传输完成标志与重复启动在Normal模式下一次DMA传输完成后相应的DMA流会被禁用。如果你需要再次启动传输必须重新调用HAL_SPI_Transmit_DMA等函数。而在Circular模式下DMA会循环传输适合持续不断的数据流但要注意缓冲区管理和数据同步。SPI DR寄存器与DMA的配合SPI的发送和接收是同步的。即使你只调用HAL_SPI_Transmit_DMASPI也会在发送的同时从MISO线接收数据并存入DR寄存器。如果此时接收DMA没有开启这些数据就会丢失并可能置位溢出错误标志OVR。对于全双工通信强烈建议总是使用TransmitReceive配对即使你不在乎接收到的数据也要提供一个缓冲区来“吃掉”它们。// 示例使用DMA驱动ILI9341 LCD刷屏假设已初始化并设置好窗口 uint16_t screen_buffer[320*240]; // 假设是RGB565的16位数据 // 填充缓冲区... // 1. 清理缓存 (对于H7等系列) SCB_CleanDCache_by_Addr((uint32_t*)screen_buffer, sizeof(screen_buffer)); // 2. 启动SPI DMA传输 HAL_SPI_Transmit_DMA(hspi1, (uint8_t*)screen_buffer, sizeof(screen_buffer)/2); // 注意单位是字节 // 3. 在传输完成回调函数中可以处理下一步如切换缓冲区 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { // 一帧图像发送完成可以准备下一帧或进行其他操作 } }5. 实战案例驱动W25Q64 Flash存储器我们以一个完整的、工业级的W25Q648MB SPI Flash驱动为例串联前面讲的所有知识点。这个芯片非常经典驱动它几乎涵盖了SPI应用的所有要点。5.1 器件识别与初始化序列任何SPI器件的通信第一步都是正确的初始化SPI外设并验证连接。W25Q64支持标准SPIMode 0和Mode 3、双线输出和四线输出模式。我们先用标准SPI。// spi_flash.h #define FLASH_SPI_HANDLE hspi1 #define FLASH_CS_PIN GPIO_PIN_4 #define FLASH_CS_PORT GPIOA // 函数声明 uint8_t SPI_FLASH_Init(void); uint16_t SPI_FLASH_ReadID(void);// spi_flash.c uint8_t SPI_FLASH_Init(void) { // 1. 硬件初始化假设已由CubeMX完成hspi1已配置为Mode 0, 8位数据MSB First // 2. 片选引脚初始化已在主函数或其他地方初始化推挽输出默认高 // 3. 尝试读取ID验证通信 uint16_t flash_id SPI_FLASH_ReadID(); if(flash_id 0xEF16) { // W25Q64的ID是0xEF16 return 0; // 成功 } else { return 1; // 失败 } } uint16_t SPI_FLASH_ReadID(void) { uint8_t tx_cmd[4] {0x9F, 0x00, 0x00, 0x00}; // 读ID命令JEDEC ID (0x9F) uint8_t rx_buf[4] {0}; HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(FLASH_SPI_HANDLE, tx_cmd, rx_buf, 4, 100); // 注意发送0x9F后芯片会连续回传制造商ID、存储器类型、容量ID // 我们发送了4字节但命令只有1字节后3字节是哑元用于提供时钟以便接收 // 实际上在发送0x9F后芯片就已经在MISO上输出数据了 // 更精确的做法是先发送0x9F然后连续接收3个字节 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); // rx_buf[1], rx_buf[2], rx_buf[3] 分别是制造商ID(0xEF), 类型(0x40), 容量(0x16) uint16_t full_id (rx_buf[2] 8) | rx_buf[3]; // 通常用后两个字节组合成一个16位ID return full_id; }5.2 状态寄存器操作与写使能Flash存储器在写入编程或擦除前必须先进行写使能并且要等待当前操作完成。// 发送写使能命令 (0x06) void SPI_FLASH_WriteEnable(void) { uint8_t cmd 0x06; HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmd, 1, 100); HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); } // 读取状态寄存器1 (0x05) uint8_t SPI_FLASH_ReadStatusReg1(void) { uint8_t cmd 0x05; uint8_t status 0; HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmd, 1, 100); HAL_SPI_Receive(FLASH_SPI_HANDLE, status, 1, 100); HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); return status; } // 等待Flash忙状态结束 (检查状态寄存器1的BUSY位bit0) void SPI_FLASH_WaitForBusy(void) { uint8_t status; do { status SPI_FLASH_ReadStatusReg1(); } while(status 0x01); // BUSY位为1表示忙 }5.3 页编程与扇区擦除注意地址对齐Flash的写入有两大限制必须先擦除变为0xFF才能写入且写入操作必须以“页”为单位进行且不能跨页W25Q64一页是256字节。// 扇区擦除 (一个扇区4KB命令0x20) void SPI_FLASH_SectorErase(uint32_t sector_addr) { uint8_t cmd[4]; cmd[0] 0x20; // 扇区擦除命令 cmd[1] (sector_addr 16) 0xFF; // 24位地址高位在前 cmd[2] (sector_addr 8) 0xFF; cmd[3] sector_addr 0xFF; SPI_FLASH_WriteEnable(); // 第一步写使能 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmd, 4, 100); HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); SPI_FLASH_WaitForBusy(); // 等待擦除完成这需要几十毫秒 } // 页编程 (最多256字节命令0x02) void SPI_FLASH_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { if(len 256) len 256; // 确保不超过一页 uint8_t cmd_buf[4]; cmd_buf[0] 0x02; // 页编程命令 cmd_buf[1] (addr 16) 0xFF; cmd_buf[2] (addr 8) 0xFF; cmd_buf[3] addr 0xFF; SPI_FLASH_WriteEnable(); // 第一步写使能 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmd_buf, 4, 100); // 发送命令和地址 HAL_SPI_Transmit(FLASH_SPI_HANDLE, data, len, 1000); // 发送数据 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); SPI_FLASH_WaitForBusy(); // 等待编程完成 } // 读取数据 (命令0x03)可以读取任意长度 void SPI_FLASH_ReadData(uint32_t addr, uint8_t *buffer, uint32_t len) { uint8_t cmd_buf[4]; cmd_buf[0] 0x03; // 读数据命令 cmd_buf[1] (addr 16) 0xFF; cmd_buf[2] (addr 8) 0xFF; cmd_buf[3] addr 0xFF; HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmd_buf, 4, 100); // 发送命令和地址 HAL_SPI_Receive(FLASH_SPI_HANDLE, buffer, len, 1000); // 读取数据 HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); }关键细节Flash的地址是24位的。在发送地址时必须是大端模式MSB First即先发送地址的最高字节。这与我们SPI配置的MSB First是一致的。另外PageProgram函数中HAL_SPI_Transmit连续发送了命令/地址和数据期间片选始终保持低电平这是一次完整的SPI事务。如果中间片选被拉高从机会认为命令结束。5.4 使用DMA进行大数据量读写当需要读写大量数据时例如从Flash加载一幅图片到内存使用DMA可以极大提升效率。// 使用DMA读取Flash数据到内存 void SPI_FLASH_ReadData_DMA(uint32_t addr, uint8_t *buffer, uint32_t len) { uint8_t cmd_buf[4]; cmd_buf[0] 0x03; // 读命令 cmd_buf[1] (addr 16) 0xFF; cmd_buf[2] (addr 8) 0xFF; cmd_buf[3] addr 0xFF; HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_RESET); // 先阻塞式发送命令和地址数据量小用DMA不划算 HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmd_buf, 4, 10); // 然后启动DMA接收数据 HAL_SPI_Receive_DMA(FLASH_SPI_HANDLE, buffer, len); // 注意此时函数立即返回DMA在后台接收。 // 必须在DMA接收完成回调函数中拉高片选 } // DMA接收完成回调 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { HAL_GPIO_WritePin(FLASH_CS_PORT, FLASH_CS_PIN, GPIO_PIN_SET); // 拉高片选 // 可以在这里设置标志位通知主程序读取完成 } }这里有一个大坑如上代码所示DMA传输是异步的。如果你在启动DMA接收后函数立刻返回然后主程序去干别的了那么片选信号会在DMA还没完成时就被拉高吗不会因为拉高片选的操作被放在了DMA完成回调里。但是这要求你的整个SPI事务从命令到数据必须在一次片选低电平期间完成。这种“先发命令后DMA收数据”的模式是可行的因为SPI是同步的主机在提供SCK时钟从机才会输出数据。只要SCK在动数据就在传输。DMA只是自动帮我们把数据从DR寄存器搬到内存不影响SPI线上的时序。6. 高级话题与深度排错指南掌握了基础操作后一些更复杂的情况和疑难杂症才会浮现。6.1 SPI与DMA的故障排查清单当你的SPI DMA不工作、数据错乱或者程序卡死时请按以下顺序检查时钟与电源SPI外设时钟APB1/APB2使能了吗GPIO端口时钟使能了吗DMA控制器时钟使能了吗从机设备供电正常吗引脚配置GPIO是否配置为正确的复用功能AF5 for SPI1上拉/下拉设置是否正确对于开漏输出的I2C我们常配上拉电阻但SPI通常用推挽输出速度更快。MISO引脚应配置为浮空输入或上拉输入。SPI配置一致性主从设备的模式CPOL/CPHA、数据位宽、位序MSB/LSB是否100%一致用逻辑分析仪抓取SCK、MOSI、MISO波形是最直接的验证方法。DMA配置数据流Stream和通道Channel映射对吗查参考手册的DMA请求映射表外设地址hspi-Instance-DR和内存地址是否正确数据宽度外设宽度、内存宽度是否与SPI数据位宽匹配传输方向是外设到内存接收还是内存到外设发送内存地址是否自增外设地址通常不自增。模式是Normal还是CircularNormal模式传输完一次后需要重新使能。中断与回调DMA传输完成中断TCIE使能了吗对应的DMA流中断服务函数如DMA1_Stream3_IRQHandler中是否调用了HAL_DMA_IRQHandler你的回调函数如HAL_SPI_TxCpltCallback是否被正确定义和调用缓存一致性仅限Cortex-M7等带Cache的系列这是最诡异的问题。症状是数据看起来写了但DMA发出去的是错的或者DMA接收的数据程序读出来是错的。务必使用Clean和Invalidate操作。片选时序确保片选信号在通信开始前有足够的建立时间tCSS在通信结束后有足够的保持时间tCSH。有些苛刻的器件需要几十纳秒的延时。在HAL_GPIO_WritePin拉低片选后加一个__NOP()或短延时再开始传输。6.2 单线双向半双工SPI模式有些器件如某些ADC为了节省引脚使用单线双向模式SIMO/SOMI共用一根线。STM32的SPI支持这种模式通过CR1寄存器的BIDIMODE和BIDIOE位控制。在这种模式下同一时刻数据线只能用于发送或接收由软件切换方向。HAL库提供了HAL_SPI_TransmitReceive()函数但在单线模式下你需要更精细地控制方向切换的时机通常要结合器件的具体命令序列来操作。6.3 SPI中断与DMA的混合使用在一些复杂场景下你可能需要混合使用中断和DMA。例如用DMA发送一大块显示数据但同时需要及时响应来自另一个SPI从机如触摸屏控制器的中断请求。这时你需要合理设置SPI和DMA的中断优先级NVIC并处理好资源竞争比如共享的SPI外设。通常将实时性要求高的中断如触摸屏设置为更高优先级而大数据量传输的DMA完成中断设置为较低优先级。调试SPI尤其是带DMA的SPI逻辑分析仪是你的最佳伙伴。它能直观地展示SCK、MOSI、MISO、CS线上的每一位数据和时间关系帮你快速定位是配置错误、时序问题还是软件逻辑缺陷。没有硬件分析仪也可以尝试用STM32的GPIO翻转来模拟“软件逻辑分析仪”在关键代码处翻转一个测试引脚然后用示波器观察其波形间接判断程序执行到哪一步卡住了。SPI是一个看似简单却内涵丰富的通信外设。从理解时钟极性和相位开始到熟练运用阻塞、中断、DMA三种方式再到解决实际项目中遇到的各类硬件和软件问题这个过程是每个STM32开发者成长的必经之路。我最深的体会是数据手册永远是你最好的朋友。无论是STM32的参考手册还是外设器件的数据手册里面藏着所有问题的答案。遇到问题静下心来对照波形如果有仔细核对手册上的时序要求和寄存器配置大部分难题都能迎刃而解。希望这篇结合了大量实战踩坑经验的笔记能帮你少走些弯路更顺畅地驾驭STM32的SPI外设。