STM32 SPI从机模式实战:HAL库配置、中断与DMA驱动详解

1. 项目概述:从“主”到“从”的思维转换

搞了这么多年STM32,SPI通信大家肯定不陌生,但绝大多数教程和项目经验都集中在“主机模式”上。主机嘛,发时钟、发数据、控制一切,逻辑清晰,主动权在手。然而,当你需要让STM32作为一个“听话”的设备,去响应另一个主控芯片(比如另一个MCU、FPGA甚至某些专用主控)的指令时,SPI从机模式的配置就成了一个不大不小的坎。这个基于STM32的SPI从机实验,核心就是完成这个思维转换:从发号施令的主,变成时刻准备响应的从。

这不仅仅是改个配置参数那么简单。从机模式下,STM32的SPI外设失去了对时钟线SCK的掌控权,它的时钟、数据收发节奏完全由外部主机决定。这意味着你的程序架构、数据处理时机、甚至中断服务程序的设计逻辑,都需要进行根本性的调整。很多朋友第一次做从机时,会遇到数据收不到、数据错位、或者响应不及时导致主机通信失败等问题,其根源往往在于对从机模式的“被动性”理解不够深入。

为什么用HAL库?在裸机寄存器操作、标准外设库和HAL库之间,我选择HAL库来做这个实验分享,原因很实际。HAL库的抽象层虽然有时显得“臃肿”,但在快速构建、代码可读性和跨STM32系列移植方面优势明显。对于从机这种需要精细时序配合的场景,HAL库提供的清晰回调函数机制(如HAL_SPI_RxCpltCallback)能让我们更专注于应用逻辑,而不是埋头于繁琐的寄存器标志位查询。当然,理解其背后的机制同样重要,我会在细节部分穿插说明。

这个实验适合谁?如果你已经玩转过STM32的SPI主机,想拓展技能树到双向通信或主从系统;或者你的项目需要STM32作为协处理器、传感器数据采集模块或专用功能单元,受控于一个更强大的主处理器,那么掌握SPI从机模式是必经之路。接下来,我会从设计思路、HAL库配置的每一个参数、数据收发实战到避坑指南,完整拆解这个过程。

2. SPI从机模式的核心逻辑与HAL库配置解析

2.1 理解SPI从机的“被动”本质

在主机模式下,我们初始化SPI,然后调用HAL_SPI_Transmit()HAL_SPI_Receive(),函数内部会操作寄存器启动传输,控制SCK时钟产生,直到传输完成(或超时)函数返回。整个过程是“主动发起,同步等待”。

而在从机模式下,这个逻辑完全颠倒:

  1. 时钟由外部提供:SCK引脚被配置为输入。从机无法启动一次传输,只能等待主机发出的SCK时钟信号。
  2. 传输由主机发起:从机时刻“监听”着NSS(片选)引脚。当主机拉低NSS(假设低电平有效)时,表示一次通信帧的开始。从机必须在这个信号有效期间,准备好与主机进行数据交换。
  3. 数据同步于外部时钟:在主机提供的每个SCK边沿,从机根据配置的时钟极性(CPOL)和相位(CPHA)进行数据的采样(接收)或输出(发送)。时序必须严格匹配主机。

这种被动性带来了两个关键编程模型:中断驱动DMA驱动。轮询模式在从机中几乎不可用,因为你无法预知主机何时会发起通信,死等HAL_SPI_Transmit()只会让程序卡死。最常用的方式是使能SPI的RXNE(接收缓冲区非空)和TXE(发送缓冲区空)中断,或者配置DMA在数据到达时自动搬运。

2.2 HAL库配置参数逐项解读

使用STM32CubeMX或直接编写代码初始化时,以下几个参数需要格外关注:

SPI_HandleTypeDef hspi1; void SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_SLAVE; // 核心:设置为从机模式 hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 通常使用全双工 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; // 数据大小,必须与主机一致 hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL: 时钟极性 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA: 时钟相位 hspi1.Init.NSS = SPI_NSS_SOFT; // 重点:片选管理方式 hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; // 从机模式下此参数通常无效 hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; // 位顺序,必须与主机一致 hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }

关键参数深度解析:

  • SPI_NSS_SOFTvsSPI_NSS_HARD_INPUT

    • 软件NSS (SPI_NSS_SOFT):这是我最推荐在从机模式下使用的方式。此时,硬件NSS引脚(通常是PA4或PA15)可以被释放为普通GPIO。你需要在代码中手动控制一个GPIO引脚来模拟片选信号。这样做的好处是灵活性极高,你可以通过外部中断来检测主机片选信号的下降沿,从而精确知道传输开始的时间点,便于进行缓冲区准备或状态重置。在初始化后,你需要调用HAL_SPI_Init,但片选的实际控制权在你自己手里。
    • 硬件NSS (SPI_NSS_HARD_INPUT):将STM32的NSS引脚配置为硬件输入模式。当主机拉低这个引脚时,STM32的SPI外设硬件会自动将自己设置为从机并准备通信。这种方式看似“自动”,但有个巨大陷阱:STM32的硬件NSS管理有时会有特定的时序要求,并且在多从机系统中,管理起来可能不如软件模拟直观。更重要的是,调试时你无法主动“断开”片选,灵活性较差。对于初学者,我强烈建议先从软件NSS入手,把所有控制权掌握在自己代码中。
  • BaudRatePrescaler(波特率分频器):在从机模式下,这个参数不起任何作用,因为时钟源是外部的。但为什么还要配置呢?主要是HAL库数据结构的要求,你可以随便填一个合法值(如SPI_BAUDRATEPRESCALER_2),它不会被使用。这一点很多文档没强调,但心里一定要清楚。

  • CPOLCPHA:这是SPI通信的基石,主从机必须绝对一致。通常有四种模式(0,0), (0,1), (1,0), (1,1)。你需要查阅主机芯片的数据手册或代码来确定其模式。一个记忆技巧:CPOL决定SCK空闲时的电平(0=低电平,1=高电平);CPHA决定数据在哪个时钟边沿采样(0=第一个边沿,1=第二个边沿)。在示波器上,对照SCK和MOSI/MISO的波形,这是排查通信问题的第一步。

2.3 中断与DMA的选型考量

  • 纯中断模式:使能SPI的SPI_IT_RXNESPI_IT_TXE中断。当接收缓冲区有数据时,产生RXNE中断,你需要在中断服务程序(ISR)中调用HAL_SPI_IRQHandler,它最终会调用你重写的HAL_SPI_RxCpltCallback回调函数。同时,TXE中断表示发送缓冲区已空,可以填入下一个要发送的数据。这种方式响应及时,编程模型清晰,适合单次传输数据量不大(比如几个到几十个字节)的场景。缺点是频繁中断可能带来CPU开销。
  • DMA模式:这是处理流式数据大数据块的利器。你可以为SPI的接收和发送分别配置一条DMA通道。当主机发起连续传输时,DMA会自动将接收到的数据搬运到你指定的内存数组,并在发送端自动从内存数组取出数据送到SPI数据寄存器。整个过程几乎不占用CPU。配置的关键在于设置好DMA的数据宽度(与SPI数据位宽一致)、内存地址自增、以及传输完成中断。在从机模式下使用DMA,尤其要注意传输长度的确定性。因为从机无法控制传输何时结束,你通常需要预设一个DMA传输长度(例如256字节),或者结合软件NSS信号(片选拉高表示帧结束)来触发DMA传输完成中断。

注意:在实际项目中,我经常采用“软件NSS + DMA循环模式”来处理不定长但以帧为单位的数据。将DMA配置为循环模式(Circular Mode),并设置一个足够大的缓冲区。当检测到片选下降沿(外部中断触发)时,记录缓冲区起始索引;当片选上升沿时,记录结束索引。然后在主循环或回调函数中处理这一帧数据。这样既能享受DMA的自动搬运,又能灵活处理帧边界。

3. 实战:中断驱动SPI从机完整代码实现

我们以一个具体场景为例:STM32作为从机,等待主机发送一个命令字节(CMD),根据命令字节回复不同的数据。采用软件NSS(PA4配置为外部中断输入)SPI中断方式。

3.1 硬件连接与CubeMX设置

假设使用SPI1:

  • 主机<->STM32从机
  • SCK (PA5) <-> SCK
  • MOSI (PA7) <-> MOSI (主机输出,从机输入)
  • MISO (PA6) <-> MISO (主机输入,从机输出)
  • NSS (主机输出) <-> PA4 (配置为GPIO_EXTI4,下降沿和上升沿触发中断)

在CubeMX中:

  1. 配置SPI1为Slave,全双工,8位数据。
  2. 设置正确的CPOL/CPHA(假设为Mode 0: CPOL=0, CPHA=0)。
  3. NSS选择Software
  4. 使能SPI1全局中断(NVIC Settings)。
  5. 配置PA4为GPIO_Input,并开启上升沿和下降沿触发的外部中断。

3.2 核心代码实现

// spi_slave.h #define SPI_RX_BUFFER_SIZE 64 #define SPI_TX_BUFFER_SIZE 64 extern SPI_HandleTypeDef hspi1; extern volatile uint8_t spi_rx_buf[SPI_RX_BUFFER_SIZE]; extern volatile uint8_t spi_tx_buf[SPI_TX_BUFFER_SIZE]; extern volatile uint8_t spi_rx_index; extern volatile uint8_t spi_tx_index; extern volatile uint8_t spi_frame_ready; extern volatile uint8_t spi_cs_active; // 片选活动标志 void SPI_Slave_Init(void); void SPI_Slave_Start_Receive(void);
// spi_slave.c #include "spi_slave.h" SPI_HandleTypeDef hspi1; volatile uint8_t spi_rx_buf[SPI_RX_BUFFER_SIZE] = {0}; volatile uint8_t spi_tx_buf[SPI_TX_BUFFER_SIZE] = {0}; volatile uint8_t spi_rx_index = 0; volatile uint8_t spi_tx_index = 0; volatile uint8_t spi_frame_ready = 0; volatile uint8_t spi_cs_active = 0; // 0: 空闲, 1: 正在传输 // PA4外部中断服务程序 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_4) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4) == GPIO_PIN_RESET) { // 片选下降沿:传输开始 spi_cs_active = 1; spi_rx_index = 0; spi_tx_index = 0; spi_frame_ready = 0; // 预先放入第一个要发送的字节(例如设备ID) spi_tx_buf[0] = 0xA5; // 启动SPI接收(以中断方式) // 注意:从机模式下,调用HAL_SPI_Receive_IT会准备接收,但实际传输由主机时钟驱动 // 更常见的做法是使能SPI中断,并在TXE中断中填充发送缓冲区 __HAL_SPI_ENABLE_IT(&hspi1, SPI_IT_RXNE | SPI_IT_TXE); } else { // 片选上升沿:传输结束 spi_cs_active = 0; __HAL_SPI_DISABLE_IT(&hspi1, SPI_IT_RXNE | SPI_IT_TXE); spi_frame_ready = 1; // 标志一帧数据接收完成,可供主循环处理 } } } // SPI中断服务程序中会调用此回调 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { // 当通过HAL_SPI_Receive_IT启动的接收完成时调用 // 在从机中断驱动模型中,我们通常不用这个,而是用下面的TXE/RXNE中断处理逻辑 } // 更底层的处理:在stm32f1xx_it.c的SPI1_IRQHandler中调用HAL_SPI_IRQHandler后, // HAL库会根据中断标志位调用以下函数: void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { // 全双工传输完成回调 } // 但为了更精细控制,我们有时需要直接处理中断标志。这里展示在IRQHandler中的简化逻辑: // 在 SPI1_IRQHandler 函数中(通常由CubeMX生成在 stm32f1xx_it.c) void SPI1_IRQHandler(void) { HAL_SPI_IRQHandler(&hspi1); // 必须调用这个以处理HAL库状态机 // 或者,你可以选择直接操作寄存器(更高效,但可移植性差): // if (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_RXNE)) { // // 读取数据寄存器,清除RXNE标志 // spi_rx_buf[spi_rx_index++] = hspi1.Instance->DR; // // 如果接收缓冲区满了,可以处理或丢弃 // if (spi_rx_index >= SPI_RX_BUFFER_SIZE) spi_rx_index = 0; // } // if (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_TXE)) { // // 发送缓冲区空,填入下一个要发送的字节 // if (spi_tx_index < SPI_TX_BUFFER_SIZE) { // hspi1.Instance->DR = spi_tx_buf[spi_tx_index++]; // } else { // // 没有更多数据要发,可以发送哑元数据(如0xFF) // hspi1.Instance->DR = 0xFF; // } // } } // 主循环中处理接收到的数据 void Application_Process(void) { if (spi_frame_ready) { spi_frame_ready = 0; uint8_t cmd = spi_rx_buf[0]; // 假设第一个字节是命令 switch(cmd) { case 0x01: // 准备下一次的回复数据到 spi_tx_buf spi_tx_buf[0] = 0x01; spi_tx_buf[1] = 0x02; spi_tx_buf[2] = 0x03; break; case 0x02: // 读取ADC值并准备发送 // ... 准备数据到 spi_tx_buf ... break; default: // 无效命令回复错误码 spi_tx_buf[0] = 0xFF; break; } // 注意:这里准备的数据会在**下一次**主机发起传输时被发送出去。 // 从机的发送总是滞后主机一个字节(全双工特性)。 } }

代码逻辑解读:

  1. 片选同步:利用PA4的外部中断,精确捕获每一次通信帧的开始(下降沿)和结束(上升沿)。这是协调数据收发的“节拍器”。
  2. 中断使能/失能:在帧开始时使能SPI的RXNE和TXE中断,在帧结束时失能。防止帧间干扰。
  3. 发送缓冲区管理:在TXE中断中,程序从spi_tx_buf中取出下一个字节放入SPI数据寄存器(DR)。这里有个关键点:在全双工SPI中,主机发送一个字节的同时,从机也必须发送一个字节。所以,从机需要提前准备好要发送的数据,或者在TXE中断中实时生成。示例中是在上一帧处理完后,就为下一帧预置了回复数据。
  4. 接收数据处理:在RXNE中断中,读取DR寄存器得到主机发来的字节,存入spi_rx_buf。一帧结束后(片选上升沿),设置spi_frame_ready标志,主循环再处理,避免在中断中做耗时操作。

4. 进阶:DMA驱动SPI从机与不定长数据处理

对于需要连续传输大量数据的场景(如固件升级、音频流传输),中断方式每个字节都进中断,CPU开销太大。DMA是更好的选择。

4.1 DMA循环模式配置

我们配置SPI1_RX和SPI1_TX分别使用DMA1的通道2和通道3(具体通道需查数据手册),并设置为循环模式。

// CubeMX中配置DMA: // SPI1_RX -> DMA1 Channel2, Direction: Peripheral To Memory, Mode: Circular, Increment Address: Memory // SPI1_TX -> DMA1 Channel3, Direction: Memory To Peripheral, Mode: Circular, Increment Address: Memory

初始化后,启动DMA接收和发送:

// 启动循环DMA接收 HAL_SPI_Receive_DMA(&hspi1, (uint8_t*)spi_rx_dma_buffer, SPI_DMA_BUFFER_SIZE); // 启动循环DMA发送(可以先填充缓冲区为默认值,如0xFF) memset(spi_tx_dma_buffer, 0xFF, SPI_DMA_BUFFER_SIZE); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)spi_tx_dma_buffer, SPI_DMA_BUFFER_SIZE);

一旦启动,DMA就会在后台自动工作。当主机发起通信时,数据会自动流入spi_rx_dma_buffer,同时spi_tx_dma_buffer的数据会自动发送出去。

4.2 结合软件NSS处理数据帧

循环DMA的问题在于,它像一个环形的传送带,数据不断被覆盖。我们需要知道哪些数据是有效的。这时,软件NSS的外部中断再次成为关键。

volatile uint32_t dma_rx_start_index = 0; volatile uint32_t dma_rx_frame_length = 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == GPIO_PIN_4) { if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4) == GPIO_PIN_RESET) { // 帧开始:记录当前DMA接收计数器值作为起始点 dma_rx_start_index = SPI_DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_spi1_rx.Instance); } else { // 帧结束:计算接收到的字节数 uint32_t current_index = SPI_DMA_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_spi1_rx.Instance); dma_rx_frame_length = (current_index >= dma_rx_start_index) ? (current_index - dma_rx_start_index) : (SPI_DMA_BUFFER_SIZE - dma_rx_start_index + current_index); // 触发一个信号量或标志,通知任务处理从 dma_rx_start_index 开始,长度为 dma_rx_frame_length 的数据 osSemaphoreRelease(spi_frame_semHandle); // 如果使用了RTOS } } }

通过计算DMA传输计数器(CNDTR)的变化,我们可以精确计算出在片选有效期间接收到了多少字节的数据,从而在环形缓冲区中定位出一帧完整的数据。对于发送,我们也可以在片选开始时,将需要发送的数据拷贝到DMA发送缓冲区的对应位置。

实操心得:DMA循环模式+软件NSS中断是处理高速、流式SPI从机数据的黄金组合。但要注意DMA缓冲区的对齐和大小。缓冲区大小最好是2的幂次方(如256、512),这样索引回绕计算非常高效(可以用& (BUFFER_SIZE-1))。同时,确保处理数据帧的速度快于主机发送帧的速度,否则会发生数据覆盖。

5. 调试技巧与常见问题排查实录

调试SPI从机,逻辑分析仪或示波器几乎是必备的。没有它,就像在黑暗中摸索。

5.1 问题一:完全收不到任何数据

  • 检查清单
    1. 电气连接:用万用表检查SCK、MOSI、MISO、NSS四根线是否连通,有无短路到地或电源。
    2. 基础配置:确认主从机CPOL/CPHA模式完全一致。用示波器看SCK和MOSI,对照数据手册的时序图。
    3. 片选信号:如果使用硬件NSS,确保主机拉低NSS的时间足够长,且STM32的NSS引脚确实配置为硬件输入。强烈建议先用软件NSS(GPIO中断)调试,在中断里翻转一个测试引脚,用示波器看是否能抓到片选边沿。
    4. SPI使能:确保在初始化后调用了__HAL_SPI_ENABLE(&hspi1)或HAL库的初始化函数已包含此操作。
    5. 中断/DMA使能:检查NVIC中断是否开启,DMA通道是否配置并启动。

5.2 问题二:收到的数据错位或全是0xFF/0x00

  • 数据错位(如预期0xAA收到0x55):检查SPI_FIRSTBIT设置。主机是MSB在先还是LSB在先?必须一致。0xAA (0b10101010) 如果位序反了,就会变成0x55 (0b01010101)。
  • 全是0xFF:通常意味着MISO线有问题。0xFF是SPI总线在空闲时的上拉值。
    • 检查STM32的MISO引脚配置是否正确(应为复用推挽输出AF_PP)。
    • 检查主机端是否将MISO引脚正确配置为输入。
    • 在从机TXE中断或DMA发送缓冲区中,你是否正确填充了要发送的数据?如果没填,默认发送的就是0xFF。
  • 全是0x00
    • 检查MOSI线连接,主机是否确实在发送数据。
    • 检查从机的接收中断或DMA是否正常工作。在RXNE中断或DMA完成中断中设置断点,看是否能进入。
    • 逻辑分析仪查看MOSI线上是否有数据变化。

5.3 问题三:通信不稳定,偶尔丢数据

  • 时钟速度:虽然从机不控制时钟,但STM32的SPI从机有最大支持时钟频率(见芯片数据手册)。如果主机SCK太快,从机可能无法正确采样。尝试降低主机波特率。
  • 中断优先级:如果SPI中断被更高优先级的中断长时间阻塞,可能导致数据来不及处理而丢失。确保SPI中断(或DMA中断)有足够高的优先级,尤其是接收中断。
  • 缓冲区溢出:在中断服务程序中,如果接收数据的速度快于处理速度,缓冲区会被覆盖。增加缓冲区大小,或者使用双缓冲区(Ping-Pong Buffer)机制:一个缓冲区用于接收,另一个用于处理,接收满后交换。
  • 电源噪声:长距离或恶劣环境下的SPI通信,需考虑信号完整性。可以尝试降低速率,或在线上串联小电阻(如22-100欧姆)抑制振铃。

5.4 一个典型的调试流程

  1. 静态验证:不接主机,将STM32的MOSI引脚通过上拉电阻接到3.3V或GND,将MISO引脚接到逻辑分析仪。在代码中固定发送一个字节(如0xAA)。用另一块板子模拟主机,手动控制NSS和SCK,看逻辑分析仪上MISO是否有正确波形输出。这验证了从机的发送通路。
  2. 动态监听:接上主机,用逻辑分析仪同时抓取SCK、MOSI、MISO、NSS四路信号。发起一次通信,对照时序图,看数据是否对齐,片选是否正常。
  3. 软件调试:在片选中断、SPI接收中断、DMA传输完成中断入口设置断点,观察程序流是否按预期执行。在内存窗口中观察接收缓冲区是否被正确写入。

最后分享一个我踩过的坑:有一次调试,从机数据总是慢一个字节。后来发现,我在片选下降沿中断里才去准备要发送的第一个字节数据。而SPI是全双工的,主机在发出第一个SCK时钟边沿时,从机就必须在MISO上输出第一个数据位。因此,从机需要提前准备好至少第一个字节的发送数据。我的解决方法是,在每次回复完一帧数据后,立即将下一帧可能用到的第一个字节(比如状态字)预加载到发送缓冲区或准备好,确保片选一有效,数据就位。这个细节在标准文档里很少强调,但实际中至关重要。