深入解析STM32 SPI TX FIFO:从硬件机制到高效发送策略
1. 项目概述:为什么需要关注SPI的TX FIFO?
在嵌入式开发,尤其是基于STM32这类MCU的项目中,SPI(Serial Peripheral Interface)总线因其高速、全双工的特性,被广泛用于连接Flash、传感器、显示屏等外设。很多开发者,尤其是刚接触STM32 HAL库或LL库的朋友,在实现SPI主设备发送数据时,常常会陷入一个误区:认为只要调用了HAL_SPI_Transmit或类似的发送函数,数据就会“立刻”、“连续”地被发送出去。于是,当需要发送一个较长的数据包时,他们可能会简单地在一个循环里连续调用发送函数,结果却发现通信不稳定、数据丢失,或者CPU被大量占用在等待发送完成的状态上。
问题的根源,往往在于对SPI发送FIFO(First In, First Out,先进先出队列)的工作机制理解不够深入。STM32的SPI外设内部通常集成了硬件FIFO,对于发送端(TX)而言,这个FIFO是一个小型的缓冲区。你的程序将数据写入这个缓冲区(通过写数据寄存器DR),然后SPI外设的移位寄存器会按照设定的波特率,自动从FIFO中取出数据,一位一位地通过MOSI线发送出去。核心矛盾在于:FIFO的容量是有限的(通常是2级、4级或8级),而你的写入速度可能远高于SPI的物理发送速度。如果不对写入过程进行管理,就会导致FIFO溢出,或者程序低效地空转等待。
因此,深入理解并妥善处理STM32 SPI的TX FIFO,是实现高效、稳定SPI通信的关键一步。这不仅关乎发送数据的正确性,更直接影响整个系统的实时性和CPU利用率。本文将从一个实际开发者的角度,拆解TX FIFO的工作原理,分享几种经典的处理策略,并剖析其中容易踩坑的细节。
2. TX FIFO的硬件机制与核心状态位
要驾驭TX FIFO,首先得明白它在硬件层面是如何运作的,以及MCU提供了哪些“信号灯”让我们知道FIFO的状态。我们以STM32中常见的SPI外设为例进行说明。
2.1 FIFO的基本工作流程
你可以把TX FIFO想象成一个微型传送带,或者一个有几个格子的小仓库。
- 写入端(软件):你的程序通过向
SPI->DR(数据寄存器)写入数据,实际上是把数据放入了FIFO的入口。 - 缓存区(FIFO):写入的数据在这里排队。例如,一个4级深度的TX FIFO最多可以暂存4个待发送的数据帧(帧大小可以是8位或16位)。
- 读取端(硬件):SPI外设的发送逻辑电路会自动从FIFO的出口取出数据,加载到移位寄存器中。
- 发送端(硬件):移位寄存器在时钟SCK的驱动下,将数据位依次推到MOSI引脚上。
整个过程是硬件自动完成的。软件的角色是“供货商”,需要确保在FIFO有空位时及时补货,但又不能供货太快导致仓库爆仓(溢出),也不能让仓库断货(发送间隙过大,在某些严格时序的协议中可能有问题)。
2.2 关键状态标志位:你的“仓库监控系统”
STM32的SPI状态寄存器(SPI_SR)提供了几个至关重要的标志位,用于监控FIFO和发送状态:
TXE(Transmit buffer Empty):这是处理TX FIFO时最常用的标志位。当它被置1时,表示发送数据寄存器(或FIFO)为空,可以写入新的数据。注意,对于有FIFO的SPI,这个“空”可能意味着整个FIFO都空了(例如,在FIFO深度为4时,你写入了1个数据,TXE不会立刻变0;只有当你写入第4个数据后,FIFO满,TXE才会变0。当硬件发送完1个数据,FIFO空出一个位置,TXE就又变1了)。更精确的理解是:当TXE=1时,表示至少可以向FIFO写入1个数据而不会覆盖未发送的数据。因此,我们的发送逻辑通常以“等待TXE变1”作为可以安全写入下一个数据的前提。
BSY(Busy):这个标志位表示SPI通信正在进行中(包括发送和接收)。它通常在数据传输开始时被置1,在最后一帧数据传输完成、总线恢复空闲后被清零。在查询方式发送多字节数据时,我们通常在一个数据块的开始检查BSY是否清零(确保上次传输完成),在发送过程中则主要依赖TXE。
OVR(Overrun):接收溢出错误。虽然主要针对接收,但在全双工通信中也需要关注。当接收FIFO已满(数据未被及时读取),而新的数据已经到来时,会发生溢出。这提醒我们,发送和接收的节奏需要匹配。
注意:不同系列的STM32,或者同一系列中不同型号的SPI,其FIFO深度和标志位行为可能有细微差别。例如,某些型号的SPI可能没有硬件FIFO,TXE就严格代表数据寄存器DR本身为空。因此,最权威的参考永远是当前所用芯片型号的《参考手册》中关于SPI章节的描述。在动手写代码前,花十分钟阅读相关章节,能避免后续很多莫名其妙的错误。
2.3 HAL库的封装与局限
ST的HAL库提供了像HAL_SPI_Transmit(&hspi, pData, Size, Timeout)这样的函数。这个函数内部实现了一个简单的循环:检查TXE,写入数据;等待直到所有数据发送完成(检查BSY标志)。对于一次性发送少量数据,它非常方便。
但是,它的工作模式是“阻塞式”的:在Timeout参数设定的时间内,函数会一直等待,直到整个数据块发送完毕才返回。在这期间,CPU被挂起,无法执行其他任务。对于发送一个几百字节的配置命令,这可能没问题。但对于需要连续、高速发送音频流、图像数据,或者在一个实时操作系统中,这种阻塞式发送是不可接受的,它会严重影响系统的响应性。
因此,要高效处理TX FIFO,我们往往需要绕过HAL库的阻塞函数,直接与寄存器打交道,或者采用更高级的中断、DMA方式。下面我们就来探讨这几种策略。
3. 策略一:查询(Polling)方式与精细控制
查询方式是最基础、最直接的控制方法。它不依赖中断,完全由主程序循环检查状态位并操作。虽然效率不如中断或DMA,但逻辑清晰,易于理解和调试,在很多对实时性要求不苛刻的场景下完全够用。
3.1 基础查询发送模式
一个健壮的查询发送函数应该做什么?它不仅仅是写数据,还要管理好FIFO的填充节奏。
/** * @brief 通过查询方式发送一段数据(非阻塞式,可中途超时返回) * @param hspi: SPI句柄指针 * @param pData: 待发送数据缓冲区指针 * @param Size: 要发送的数据大小(以字节或字为单位,取决于数据帧格式) * @param Timeout: 超时时间(毫秒) * @retval HAL status (HAL_OK, HAL_ERROR, HAL_TIMEOUT) */ HAL_StatusTypeDef SPI_TxPolling(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout) { uint32_t tickstart = HAL_GetTick(); uint16_t tx_count = 0; // 1. 等待SPI总线就绪(非BSY状态) if(SPI_WaitOnFlagUntilTimeout(hspi, SPI_FLAG_BSY, SET, tickstart, Timeout) != HAL_OK) { return HAL_TIMEOUT; } // 2. 循环发送所有数据 while(tx_count < Size) { // 2.1 等待TX FIFO有空位(TXE标志置位) if(SPI_WaitOnFlagUntilTimeout(hspi, SPI_FLAG_TXE, RESET, tickstart, Timeout) != HAL_OK) { return HAL_TIMEOUT; } // 2.2 根据数据帧大小写入数据 if(hspi->Init.DataSize == SPI_DATASIZE_16BIT) { // 16位数据帧 hspi->Instance->DR = *((uint16_t*)pData); pData += 2; tx_count += 2; } else { // 8位数据帧 hspi->Instance->DR = *pData; pData++; tx_count++; } // 注意:这里没有等待BSY结束。我们只负责把数据塞进FIFO。 // 如果需要确保所有数据都从移位寄存器发出,可以在循环结束后额外等待BSY清零。 } // 3. (可选) 等待最后一帧数据完全移出 // if(SPI_WaitOnFlagUntilTimeout(hspi, SPI_FLAG_BSY, SET, tickstart, Timeout) != HAL_OK) // { // return HAL_TIMEOUT; // } return HAL_OK; }这个函数比简单的HAL_SPI_Transmit更“友好”一些。它在每次写入前都检查TXE,确保了不会发生FIFO溢出。但它仍然是“忙等待”,CPU在每次等待TXE时都被占用。
3.2 优化:非阻塞式查询与主循环集成
在实时操作系统或事件驱动的主循环中,我们通常不希望一个发送函数阻塞太久。我们可以实现一个基于状态机的非阻塞发送器。
思路是:将发送任务分解为多个步骤,每次只发送FIFO能容纳的数据量,然后立即返回,让出CPU。下次被调用时,接着上次的进度继续发送。
typedef struct { SPI_HandleTypeDef *hspi; uint8_t *pData; uint16_t totalSize; uint16_t sentSize; enum {TX_IDLE, TX_BUSY, TX_DONE} state; } SPI_NonBlockingTx_t; void SPI_TxNonBlocking_Init(SPI_NonBlockingTx_t *ctx, SPI_HandleTypeDef *hspi, uint8_t *data, uint16_t size) { ctx->hspi = hspi; ctx->pData = data; ctx->totalSize = size; ctx->sentSize = 0; ctx->state = TX_BUSY; // 启动任务 } HAL_StatusTypeDef SPI_TxNonBlocking_Process(SPI_NonBlockingTx_t *ctx) { if(ctx->state != TX_BUSY) { return HAL_OK; // 任务未启动或已完成 } // 检查并尽可能多地填充FIFO while(__HAL_SPI_GET_FLAG(ctx->hspi, SPI_FLAG_TXE) && (ctx->sentSize < ctx->totalSize)) { if(ctx->hspi->Init.DataSize == SPI_DATASIZE_16BIT) { ctx->hspi->Instance->DR = *((uint16_t*)(ctx->pData + ctx->sentSize)); ctx->sentSize += 2; } else { ctx->hspi->Instance->DR = ctx->pData[ctx->sentSize]; ctx->sentSize++; } } // 检查是否全部发送完毕(数据已全部写入FIFO,且移位寄存器也发送完毕) if((ctx->sentSize >= ctx->totalSize) && (__HAL_SPI_GET_FLAG(ctx->hspi, SPI_FLAG_TXE)) && (!__HAL_SPI_GET_FLAG(ctx->hspi, SPI_FLAG_BSY))) { ctx->state = TX_DONE; return HAL_OK; // 发送完成 } return HAL_BUSY; // 还在发送中 }在你的主循环或RTOS任务中,可以定期(例如每1ms)调用SPI_TxNonBlocking_Process。只要TX FIFO有空位,它就填入数据,然后立刻返回。这样CPU在等待FIFO空位的绝大部分时间里,都可以去处理其他任务,极大地提高了系统效率。这是从阻塞式查询迈向高效处理的关键一步。
4. 策略二:中断(Interrupt)方式与事件驱动
中断方式将“等待”的工作交给了硬件。我们只需要在TX FIFO有空位(TXE)时,由硬件触发中断,然后在中断服务程序(ISR)中写入数据。这种方式CPU利用率高,响应及时,适合中等数据量、需要快速响应的发送场景。
4.1 中断发送的基本框架
使用中断发送,通常需要维护一个软件缓冲区(tx_buffer)和相关的索引指针(tx_index,tx_size)。
#define TX_BUFFER_SIZE 256 uint8_t spi_tx_buffer[TX_BUFFER_SIZE]; volatile uint16_t spi_tx_index = 0; volatile uint16_t spi_tx_size = 0; volatile uint8_t spi_tx_busy = 0; // 发送状态标志 void SPI_StartTransmitIT(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size) { // 1. 检查当前是否正在发送 if(spi_tx_busy) { // 可以返回错误,或者设计一个队列来管理多个发送请求 return; } // 2. 复制数据到内部缓冲区(如果数据量小且发送快,也可以直接使用用户缓冲区,但需确保其生命周期) // 这里为了安全,使用复制。对于大数据量,应考虑零拷贝或DMA。 uint16_t copy_size = (Size > TX_BUFFER_SIZE) ? TX_BUFFER_SIZE : Size; memcpy(spi_tx_buffer, pData, copy_size); spi_tx_index = 0; spi_tx_size = copy_size; spi_tx_busy = 1; // 3. 使能TXE中断(当FIFO有空位时触发中断) __HAL_SPI_ENABLE_IT(hspi, SPI_IT_TXE); // 注意:不要在这里手动写入第一个数据。等待TXE中断发生。 // 因为使能中断后,如果FIFO本来就是空的,会立即进入中断。 } // SPI TXE中断服务程序 void SPI_IRQHandler(SPI_HandleTypeDef *hspi) // 实际中断函数名需根据具体型号调整 { // 处理TXE中断 if(__HAL_SPI_GET_IT_SOURCE(hspi, SPI_IT_TXE) && __HAL_SPI_GET_FLAG(hspi, SPI_FLAG_TXE)) { // TX FIFO有空位,可以写入数据 if(spi_tx_index < spi_tx_size) { // 写入一个数据 if(hspi->Init.DataSize == SPI_DATASIZE_16BIT) { hspi->Instance->DR = *((uint16_t*)(spi_tx_buffer + spi_tx_index)); spi_tx_index += 2; } else { hspi->Instance->DR = spi_tx_buffer[spi_tx_index]; spi_tx_index++; } } else { // 所有数据已写入FIFO,关闭TXE中断,防止空触发 __HAL_SPI_DISABLE_IT(hspi, SPI_IT_TXE); // 可选:如果需要知道所有数据已从移位寄存器发出,可以开启BSY中断或使用其他方式判断 // 更常见的做法是,在发送完最后一个数据后,使能TX完成中断(SPI_IT_TXC,如果支持)。 // 对于STM32,可以等待BSY标志清零,但这不能在中断里长时间等待。 // 更好的方法是:关闭TXE后,设置一个标志,在主循环中检查BSY。 spi_tx_busy = 0; // 标记发送完成(从软件缓冲区角度) // 或者触发一个自定义的“发送完成”回调函数 if(hspi->TxCpltCallback != NULL) { hspi->TxCpltCallback(hspi); } } } // ... 可能还需要处理其他SPI中断(如RXNE, ERR等) }4.2 中断方式的注意事项与陷阱
缓冲区管理与竞争条件:
spi_tx_index,spi_tx_size,spi_tx_busy这些变量在中断和主程序中都可能被访问,必须声明为volatile,并且对于多字节变量(如16位的index),在32位MCU上访问是原子的,但在更复杂的场景或8位MCU上,可能需要关中断进行保护。中断使能与关闭的时机:这是最容易出错的地方。一定要在所有数据都写入FIFO后(
spi_tx_index >= spi_tx_size)立即关闭TXE中断。否则,FIFO一旦有空位就会不断进入中断,而中断里没有数据可写,可能造成死循环或系统卡死。“发送完成”的定义:数据写入FIFO并不等于数据已经通过MOSI线发送完毕。FIFO里的数据还需要时间被移位寄存器发出去。如果你需要严格知道“最后一比特数据已出现在引脚上”这个时刻(例如,之后要立刻拉高片选CS),那么需要利用传输完成中断(TX Complete, TXC)或监控BSY标志。不是所有STM32 SPI都支持TXC中断,需要查数据手册。通常的做法是:关闭TXE中断后,启动一个短延时或轮询BSY标志,直到其清零。
中断优先级与执行时间:SPI中断,尤其是TXE中断,可能频繁触发。确保它的优先级设置合理,并且中断服务程序尽可能短小精悍,只做必要的数据搬运和标志位操作,复杂的处理应放到主循环或任务中。
与接收中断的协调:在全双工通信中,发送和接收是同步的。通常TXE和RXNE(接收缓冲区非空)中断会协同工作。在TXE中断里写数据,在RXNE中断里读数据。要小心处理两者的节奏,避免一方过快或过慢导致问题。
5. 策略三:DMA(直接存储器访问)方式与终极效率
对于大数据量、高带宽、要求CPU占用率极低的SPI发送场景,DMA是终极解决方案。DMA控制器就像一个“数据搬运工”,它可以在不打扰CPU的情况下,自动将内存中的数据搬运到SPI的数据寄存器(DR)中,从而填充TX FIFO。
5.1 DMA发送的配置流程
使用DMA发送SPI数据,通常涉及以下步骤:
- 配置SPI为发送模式,并确保时钟等参数正确。
- 配置DMA通道:
- 设置源地址:内存中数据缓冲区的地址(递增)。
- 设置目标地址:SPI->DR寄存器的地址(固定)。
- 设置数据传输方向:内存到外设(MEM2PERIPH)。
- 设置数据宽度:与SPI数据帧大小匹配(字节或半字)。
- 设置传输数据量。
- 设置循环模式(通常不用于单次发送)和优先级。
- 使能DMA传输完成中断(TC)和半传输完成中断(HT,可选)。
- 关联DMA到SPI:调用
__HAL_LINKDMA()或直接配置SPI的DMA发送请求。 - 启动传输:使能SPI的DMA发送请求(
__HAL_SPI_ENABLE_IT(hspi, SPI_IT_TXE)?不对,对于DMA,是__HAL_SPI_ENABLE_IT(hspi, SPI_IT_TXDMAREQ)?更常见的HAL库操作是HAL_SPI_Transmit_DMA())。- 实际上,HAL库函数
HAL_SPI_Transmit_DMA(&hspi, pData, Size)会帮你完成上述大部分配置和启动工作。
- 实际上,HAL库函数
5.2 HAL库DMA发送函数剖析
HAL_StatusTypeDef HAL_SPI_Transmit_DMA(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size) { // ... 内部会进行状态检查、参数校验 ... // 设置DMA传输参数 hspi->hdmatx->Instance->CNDTR = Size; // 设置传输数量 hspi->hdmatx->Instance->CMAR = (uint32_t)pData; // 内存地址 hspi->hdmatx->Instance->CPAR = (uint32_t)&hspi->Instance->DR; // 外设地址 // 清除DMA标志,使能DMA通道 __HAL_DMA_CLEAR_FLAG(hspi->hdmatx, ...); __HAL_DMA_ENABLE(hspi->hdmatx); // 使能SPI的DMA发送请求 SET_BIT(hspi->Instance->CR2, SPI_CR2_TXDMAEN); // 如果SPI还未使能,则使能SPI(这一步可能在更早进行) __HAL_SPI_ENABLE(hspi); // ... 设置状态等 ... }启动后,DMA控制器就会在每次SPI的TX FIFO有空位时,自动发起一次数据传输,将内存中的数据搬到SPI->DR。整个过程完全由硬件协调,CPU只需在DMA传输开始和结束时被中断通知即可。
5.3 DMA发送的进阶技巧与坑点
内存对齐:如果SPI数据帧是16位的,那么你的数据缓冲区在内存中的地址最好也是半字(2字节)对齐的。虽然DMA通常支持非对齐访问,但可能会损失性能或在某些芯片上导致错误。使用
__ALIGNED(2)等编译器指令来确保缓冲区对齐。缓冲区生命周期:DMA传输是异步的。在DMA传输完成中断触发之前,必须保证
pData指向的缓冲区内存有效且内容不被修改。不能使用栈上的局部变量(函数返回后栈空间失效),应使用全局数组、静态数组或动态分配的内存。双缓冲(Ping-Pong Buffer):为了实现连续无间隔的数据流(如音频播放),可以采用双缓冲技术。准备两个缓冲区A和B。当DMA正在从缓冲区A发送数据时,CPU可以填充缓冲区B。当DMA发送完A(触发TC中断),立即将DMA的目标切换到缓冲区B,同时CPU开始填充A。如此循环,实现无缝衔接。HAL库的DMA半传输(HT)和传输完成(TC)中断正好可以用来管理这种切换。
DMA与SPI的使能顺序:这是一个经典坑点。错误的顺序可能导致DMA只搬运一次数据就停止。推荐的顺序是:
- 配置好SPI(但先不使能)。
- 配置并使能DMA通道。
- 最后,使能SPI的DMA请求(
SPI_CR2.TXDMAEN = 1)和SPI本身(SPI_CR1.SPE = 1)。 有些工程师发现,在SPI已经使能的情况下再开启DMA请求,DMA无法正常工作。遵循上述顺序可以避免大部分问题。
传输完成判断:DMA传输完成中断(TC)触发,只表示数据已经从内存搬运到了SPI的DR寄存器(即填入了FIFO)。和中断方式一样,这不代表数据已经物理发送完毕。如果需要,仍然需要等待SPI的BSY标志清零或TXC中断。可以在DMA TC中断中,关闭SPI的DMA请求,然后轮询或启用另一个机制来等待BSY清零。
错误处理:使能DMA错误中断(
HAL_DMA_RegisterCallback(&hdma_spi_tx, HAL_DMA_XFER_ERROR_CB_ID, MyErrorCallback))。DMA可能因为总线错误、配置错误等原因停止,良好的错误处理机制是产品稳定性的保障。
6. 混合策略与实战场景选择
在实际项目中,我们往往需要根据具体场景灵活选择或组合上述策略。
场景一:初始化配置,发送少量命令字
- 策略:简单的阻塞式查询(
HAL_SPI_Transmit)即可。代码简单,不易出错,因为发送时间极短,阻塞不影响系统。
- 策略:简单的阻塞式查询(
场景二:中等速率、间歇性发送传感器数据
- 策略:中断方式。例如,一个温度传感器每100ms需要读取一次,主机MCU通过SPI发送读取命令。使用中断发送,可以在发送命令后立即让CPU进入低功耗模式,由中断唤醒处理后续的接收,能效比高。
场景三:高速、连续发送数据流(如刷新SPI TFT屏幕、向音频Codec发送数据)
- 策略:DMA方式,并强烈建议使用双缓冲。这是唯一能满足高带宽、低延迟要求的选择。CPU只需在缓冲区切换的间隙进行数据处理,负担极轻。
场景四:复杂的通信协议,需要同时处理发送和接收
- 策略:DMA用于发送,DMA或中断用于接收。全双工DMA是最佳选择,但配置稍复杂。也可以发送用DMA,接收用中断,在接收中断中处理数据。关键在于协调好两者的节奏,避免接收溢出。
场景五:主循环任务繁重,但发送不能有不可预测的延迟
- 策略:非阻塞查询(状态机)或中断。如果发送任务不是最紧急的,非阻塞查询可以很好地集成到主循环中。如果发送时机要求更精确,则用中断。
一个重要的经验法则:永远优先考虑DMA,除非数据量太小或芯片资源紧张。DMA将CPU从繁琐的数据搬运中解放出来,是提升系统整体性能的利器。在资源允许的情况下,即使发送几十个字节,使用DMA也能让程序结构更清晰,CPU可以专注于业务逻辑。
最后,无论采用哪种策略,逻辑分析仪或示波器都是你调试SPI通信的“眼睛”。通过抓取SCK、MOSI、CS信号,你可以直观地看到数据是否按预期发送、字节间间隔是否过大、时序是否符合从设备要求,从而快速定位是软件FIFO处理逻辑问题,还是硬件配置问题。结合芯片的调试功能(如STM32的串行线查看器SWV),可以输出内部状态日志,让你对TX FIFO的填充、清空过程有更透彻的理解。