ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

深入解析STM32 SPI TX FIFO:从硬件机制到高效发送策略

深入解析STM32 SPI TX FIFO:从硬件机制到高效发送策略 1. 项目概述为什么需要关注SPI的TX FIFO在嵌入式开发尤其是基于STM32这类MCU的项目中SPISerial Peripheral Interface总线因其高速、全双工的特性被广泛用于连接Flash、传感器、显示屏等外设。很多开发者尤其是刚接触STM32 HAL库或LL库的朋友在实现SPI主设备发送数据时常常会陷入一个误区认为只要调用了HAL_SPI_Transmit或类似的发送函数数据就会“立刻”、“连续”地被发送出去。于是当需要发送一个较长的数据包时他们可能会简单地在一个循环里连续调用发送函数结果却发现通信不稳定、数据丢失或者CPU被大量占用在等待发送完成的状态上。问题的根源往往在于对SPI发送FIFOFirst 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和发送状态TXETransmit buffer Empty这是处理TX FIFO时最常用的标志位。当它被置1时表示发送数据寄存器或FIFO为空可以写入新的数据。注意对于有FIFO的SPI这个“空”可能意味着整个FIFO都空了例如在FIFO深度为4时你写入了1个数据TXE不会立刻变0只有当你写入第4个数据后FIFO满TXE才会变0。当硬件发送完1个数据FIFO空出一个位置TXE就又变1了。更精确的理解是当TXE1时表示至少可以向FIFO写入1个数据而不会覆盖未发送的数据。因此我们的发送逻辑通常以“等待TXE变1”作为可以安全写入下一个数据的前提。BSYBusy这个标志位表示SPI通信正在进行中包括发送和接收。它通常在数据传输开始时被置1在最后一帧数据传输完成、总线恢复空闲后被清零。在查询方式发送多字节数据时我们通常在一个数据块的开始检查BSY是否清零确保上次传输完成在发送过程中则主要依赖TXE。OVROverrun接收溢出错误。虽然主要针对接收但在全双工通信中也需要关注。当接收FIFO已满数据未被及时读取而新的数据已经到来时会发生溢出。这提醒我们发送和接收的节奏需要匹配。注意不同系列的STM32或者同一系列中不同型号的SPI其FIFO深度和标志位行为可能有细微差别。例如某些型号的SPI可能没有硬件FIFOTXE就严格代表数据寄存器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)会帮你完成上述大部分配置和启动工作。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的填充、清空过程有更透彻的理解。
返回列表