资讯动态

STM32 TIM FIFO与DMA高效数据搬运实战指南

发布时间:2026/8/20 13:17:55 来源:尧图企业网站定制
1. 项目概述当TIM的FIFO遇上DMA搬运在嵌入式开发特别是基于STM32这类MCU的项目里我们经常需要处理定时器TIM产生的周期性数据流。比如用TIM的输入捕获模式测量高频PWM的脉宽或者用TIM触发ADC进行等间隔采样。这些场景下数据会源源不断地进入TIM的捕获/比较寄存器。如果每个数据都靠CPU中断来读取不仅会消耗大量CPU时间在高频或连续数据流下还可能导致中断丢失或系统响应迟缓。这时TIM内置的FIFOFirst In, First Out先进先出缓冲区和DMADirect Memory Access直接存储器访问的组合就成了一种非常优雅的解决方案。简单来说你可以把TIM的FIFO想象成一个临时的“蓄水池”它先缓存几个数据点而DMA则是一个不知疲倦的“搬运工”一旦“蓄水池”里的数据达到预设水位比如半满或全满DMA就会自动、无声无息地把这批数据搬运到你指定的内存数组里整个过程完全不需要CPU干预。这篇文章我就结合自己最近在STM32H7系列上做的一个高频信号采集项目来详细拆解一下“如何使用DMA对TIM中FIFO的数据进行搬运”这个核心操作。我会从为什么需要这么干讲起然后深入到配置的每一个细节最后分享几个我踩过的坑和验证技巧。无论你用的是STM32F4、H7还是其他系列这套思路都是相通的。2. 核心概念拆解TIM、FIFO与DMA如何协同工作在动手配置之前我们必须先理清TIM、FIFO和DMA这三者各自扮演的角色以及它们是如何被“连接”起来的。很多配置错误根源就在于对数据流的方向和触发机制理解模糊。2.1 TIM的数据产出与FIFO的缓冲角色以最典型的场景——TIM输入捕获为例。当外部信号边沿触发捕获时当前计数器的值CNT会被锁存到捕获/比较寄存器CCR中。如果没有FIFO这个CCR寄存器只有一个值新来的捕获事件会直接覆盖旧值。为了应对连续、高速的捕获许多TIM特别是高级定时器如TIM1、TIM8以及通用定时器的某些模式提供了一个深度通常为4的FIFO。这个FIFO的工作机制是每次发生捕获事件CCR的值并不是直接更新而是被压入Push到这个FIFO队列的尾部。同时FIFO会有一个状态位指示当前存有多少个有效数据。你可以通过配置决定在什么条件下让DMA来读取这个FIFO。常见的触发条件包括FIFO半满当FIFO中数据数量达到其深度的一半例如存了2个数据时产生DMA请求。FIFO全满当FIFO被完全填满例如存了4个数据时产生DMA请求。这样设计的好处是显而易见的它把零散的、高频的单个数据中断变成了批量的、低频的DMA传输请求极大地减轻了系统负担。2.2 DMA作为无声的搬运工DMA控制器独立于CPU核心它可以在外设这里是TIM的某个寄存器即FIFO的“读出端口”和内存通常是你预先定义好的一个数组之间直接搬运数据。对于TIM FIFO到内存的搬运配置DMA时需要明确几个关键点源地址Source Address这是TIM的哪个寄存器对于输入捕获通常是TIMx_CCR1通道1的捕获比较寄存器。重要即使你使能了FIFODMA的源地址依然指向这个CCR寄存器。硬件会自动管理从FIFO到该寄存器的读出操作。你不需要也无法直接访问FIFO本身。目标地址Destination Address这就是你准备好的内存数组的首地址比如uint32_t captureBuffer[BUFFER_SIZE]。传输宽度Data WidthTIM的CCR寄存器通常是16位或32位取决于定时器位数你的内存数组元素类型应与之匹配uint16_t或uint32_t。触发信号TriggerDMA由什么事件触发一次传输这里就是TIM的“捕获/比较事件”对应的DMA请求。例如TIM_CH1的捕获事件会映射到某个特定的DMA请求流如DMA1_Stream0。这个映射关系在芯片参考手册的DMA请求映射表里可以查到必须严格对应配错了DMA就不会动。传输模式Mode通常配置为“外设到内存”Peripheral-to-Memory传输完成后地址自动递增。2.3 三者联动的数据流全景图让我们把整个过程串起来看外部信号触发TIM输入捕获。捕获到的CNT值被存入TIM通道对应的FIFO假设深度为4。当FIFO中数据积累到预设条件如半满即2个数据时TIM硬件会自动向DMA控制器发出一个请求信号。DMA控制器收到请求启动一次传输它从TIMx_CCRy寄存器“读”出一个数据这个读操作会自动从FIFO头部弹出一个数据然后写入到你指定的内存数组captureBuffer[0]。由于DMA通常被配置为循环模式Circular Mode且内存地址自增下一次DMA请求到来时它会自动将数据写入captureBuffer[1]依此类推。当captureBuffer被填满例如DMA传输了BUFFER_SIZE次DMA会产生一个传输完成中断TC此时你可以在中断服务程序里处理这批数据比如计算周期、上传等然后DMA会自动回到数组开头等待下一轮实现连续不断的采集。理解了这个数据流配置起来就有了清晰的“地图”不会再像对着寄存器列表盲目尝试了。3. 实战配置以STM32H743的TIM2输入捕获为例下面我将以STM32H743ZI的TIM2通道1输入捕获为例展示如何一步步配置实现FIFO数据通过DMA搬运到内存。我会使用HAL库结合直接寄存器操作当HAL库支持不完善时因为这种“混合编程”在复杂外设配置中往往更可靠。3.1 硬件与软件环境准备MCUSTM32H743ZIT6TIMTIM2通用定时器支持DMA引脚PA5TIM2_CH1IDESTM32CubeIDE目标测量输入到PA5的PWM信号高电平脉宽使用DMA将捕获值批量搬运至内存。首先在CubeMX中进行基础配置启用TIM2设置时钟源为内部时钟。配置通道1为“输入捕获直接模式”Input Capture direct mode。在参数设置中极性Polarity设置为上升沿捕获Rising Edge用于捕获脉冲开始。输入预分频器IC Prescaler无分频No division每个有效边沿都捕获。滤波器Filter根据信号噪声情况设置如果信号干净可以设为0。关键一步启用DMA请求。在DMA Settings标签页点击Add选择TIM2_CH1。配置DMA流Stream和通道Channel参考数据手册TIM2_CH1的DMA请求可能映射到DMA1_Stream5的通道3。配置方向为“外设到内存”优先级高外设和内存的数据宽度都设为Word32位因为TIM2是32位内存地址自增外设地址不自增。模式选择“循环模式”Circular。数据项数量Data Width先填一个较大的值比如1000后续在代码中动态调整。生成代码后我们进入核心的手动配置环节。3.2 关键寄存器配置与代码实现CubeMX生成的代码通常只完成了最基础的DMA和TIM配置对于FIFO和DMA的精细控制我们需要手动修改。// 1. 定义捕获缓冲区 #define CAPTURE_BUFFER_SIZE 1024 volatile uint32_t g_captureBuffer[CAPTURE_BUFFER_SIZE]; // volatile防止编译器优化 volatile uint32_t g_captureIndex 0; // 用于在DMA中断中记录处理进度 // 2. 在main初始化阶段用户代码区域添加配置 void MX_TIM2_Init_Custom(void) { // 先停止定时器和DMA HAL_TIM_IC_Stop_DMA(htim2, TIM_CHANNEL_1); __HAL_TIM_DISABLE(htim2); // 配置TIM2的DMA功能使能捕获比较事件触发DMA请求 // 这是FIFO DMA搬运的关键使能位 TIM2-DIER | TIM_DIER_CC1DE; // 使能通道1的捕获/比较DMA请求 // 配置FIFO控制部分高级定时器或系列特有TIM2可能无独立FIFO控制寄存器 // 对于STM32H7的通用定时器其“连续模式”和DMA请求的配合等效于管理了一个隐式的数据流。 // 更通用的做法是配置DMA为每次请求传输多个数据项Burst但这需要外设支持。 // 对于TIM输入捕获常见做法是每个捕获事件触发一次DMA传输。要实现“批量化” // 更依赖于合理设置DMA的双缓冲区Double Buffer或链表Linked List模式而非TIM的FIFO。 // 经过查阅H7参考手册通用TIM的输入捕获DMA通常是一个事件对应一次传输。 // 因此本示例的重点在于正确连接DMA与TIM事件。 // 重新配置DMACubeMX生成的hdma_tim2_ch1结构体 // 停止DMA HAL_DMA_Abort(hdma_tim2_ch1); // 重新初始化明确设置传输数量 hdma_tim2_ch1.Init.PeriphBurst DMA_PBURST_SINGLE; // 外设端单次传输 hdma_tim2_ch1.Init.MemBurst DMA_MBURST_INC4; // 内存端可以配置为增量突发可选 if (HAL_DMA_Init(hdma_tim2_ch1) ! HAL_OK) { Error_Handler(); } // 关联DMA到TIM通道 __HAL_LINKDMA(htim2, hdma[TIM_DMA_ID_CC1], hdma_tim2_ch1); // 启动DMA传输 // 参数TIM句柄 通道 目标数组 传输数据项数量注意是数据项个数不是字节数 if (HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t*)g_captureBuffer, CAPTURE_BUFFER_SIZE) ! HAL_OK) { Error_Handler(); } // 使能定时器 __HAL_TIM_ENABLE(htim2); } // 3. 编写DMA传输完成中断回调函数 // 这个函数会在DMA传输了 CAPTURE_BUFFER_SIZE 个数据后自动被调用 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { // 此时g_captureBuffer 中已经存满了 CAPTURE_BUFFER_SIZE 个捕获值 // 我们可以在这里处理数据例如计算占空比、频率或者将数据复制到另一个缓冲区进行后续处理。 // 计算示例假设是上升沿捕获相邻两个值之差就是一个周期的高电平时间如果时钟分频正确 // 注意DMA是循环的处理要快否则新数据会覆盖旧数据。 // 方法使用双缓冲区索引。DMA传输完成中断告诉我们上一批数据准备好了。 uint32_t bufferToProcess g_captureIndex; g_captureIndex ^ 1; // 切换到另一个逻辑缓冲区实际物理缓冲区还是同一个但我们可以用两个指针来管理 // 发出信号量或设置标志位通知低优先级任务如主循环来处理 g_captureBuffer 中的数据 g_dataReadyFlag 1; } } // 4. 主循环中处理数据 void ProcessCaptureData(void) { if (g_dataReadyFlag) { g_dataReadyFlag 0; // 计算脉宽或频率 for (int i 1; i CAPTURE_BUFFER_SIZE; i) { uint32_t pulseWidth g_captureBuffer[i] - g_captureBuffer[i-1]; // 考虑计数器溢出的情况 if (pulseWidth 0x7FFFFFFF) { // 简单溢出判断根据计数器位数调整 pulseWidth (0xFFFFFFFF - g_captureBuffer[i-1]) g_captureBuffer[i] 1; } // 将 pulseWidth 转换为时间微秒time_us pulseWidth * (1 / TIM_CLK_MHz) // ... 你的处理逻辑 ... } } }注意上述代码是一个通用框架。对于STM32H7特别是涉及到数据缓存D-Cache时情况会复杂很多。因为DMA直接写入物理内存而CPU通过缓存读取如果缓存配置不一致会导致CPU读不到DMA刚写入的新数据缓存一致性问题。我将在下一章节重点讲这个“坑”。4. 避坑指南STM32H7的D-Cache与DMA数据一致性这是我在此项目中最耗时的一个坑现象是DMA配置看起来完全正确中断也能触发但g_captureBuffer数组里的数据全是0或者旧数据仿佛DMA没写进去。如果你用的是STM32H7、F7等带有Cache的系列那么这个问题几乎一定会遇到。4.1 问题根源缓存一致性与内存区域属性现代高性能MCU如STM32H7引入了数据缓存D-Cache来加速内存访问。CPU访问内存时会先经过Cache。如果数据在Cache中Cache Hit则直接读取速度极快如果不在Cache Miss则从真实内存中加载一块数据到Cache。DMA控制器则完全绕过Cache直接与物理内存DDR或SRAM交互。这就导致了数据不一致场景一CPU先读DMA启动前CPU读取了g_captureBuffer的某个地址导致该地址所在的一整条缓存线Cache Line通常32字节被加载到Cache中并且标记为“有效”。随后DMA向该物理内存地址写入了新数据但Cache里的数据还是旧的。CPU再次读取时命中Cache拿到的是旧数据。场景二DMA先写DMA已经将新数据写入物理内存。但CPU对应的Cache Line可能还处于“脏”状态即Cache中的数据比内存新尚未写回或者即使无效CPU在读取时也可能因为预取等操作拿到错误数据。4.2 解决方案正确配置MPU与使用缓存维护函数STM32CubeH7 HAL库提供了一套机制来处理这个问题核心是MPU内存保护单元和缓存维护指令。方案A将DMA缓冲区定义在不带Cache的内存区域这是最彻底、最推荐的方法。STM32H7的内存地址空间被划分为多个区域你可以通过MPU配置某些区域比如SRAM4为“Non-Cacheable”不可缓存。在链接脚本.ld文件中定义非缓存内存段。在STM32CubeIDE中可以在Project Properties C/C Build Settings Tool Settings MCU GCC Linker General里指定自定义链接脚本或者在默认脚本里添加.dma_buffer (NOLOAD) : { . ALIGN(32); *(.dma_buffer) . ALIGN(32); } RAM_D1 AT FLASH这里RAM_D1是SRAM区域你需要根据你的芯片具体内存映射选择一个合适的RAM区域如RAM_D2、RAM_D3或AXI_SRAM的非缓存部分。在代码中将缓冲区分配到该段// 使用GCC特性将变量放到特定段 __attribute__((section(.dma_buffer))) volatile uint32_t g_captureBuffer[CAPTURE_BUFFER_SIZE];在main()初始化时通过MPU配置该内存区域为不可缓存。STM32CubeH7的SystemInit函数里通常已经配置了MPU你需要修改或添加一个区域描述符void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct {0}; HAL_MPU_Disable(); // 配置DMA缓冲区所在的内存区域例如SRAM4地址0x38000000 MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress 0x38000000; // 你的缓冲区实际地址 MPU_InitStruct.Size MPU_REGION_SIZE_64KB; // 根据缓冲区大小选择 MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable MPU_ACCESS_NOT_BUFFERABLE; // 重要 MPU_InitStruct.IsCacheable MPU_ACCESS_NOT_CACHEABLE; // 重要不可缓存 MPU_InitStruct.IsShareable MPU_ACCESS_SHAREABLE; // 通常需要共享DMA和CPU MPU_InitStruct.Number MPU_REGION_NUMBER2; // 选择一个未使用的区域编号 MPU_InitStruct.TypeExtField MPU_TEX_LEVEL0; MPU_InitStruct.SubRegionDisable 0x00; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_ENABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); } // 在 main() 开始处调用 MPU_Config()方案B使用缓存维护函数在关键位置进行清洗和无效化如果不想动MPU或者缓冲区分散可以在DMA传输前后手动维护缓存一致性。在启动DMA传输前CPU可能写过缓冲区如果CPU可能在DMA读取缓冲区之前修改过它本例是DMA写CPU读所以这个场景不适用则需要“清洗”CleanCache确保Cache中已修改的数据写回内存。在DMA传输完成后CPU读取数据前这是我们的场景。必须对DMA写入的缓冲区内存区域进行“无效化”Invalidate操作告诉CPU Cache“这块内存区域的数据已过期下次读取请直接从内存加载。”// 在DMA传输完成中断回调函数中或CPU准备处理数据之前 #include “stm32h7xx_hal.h” // 确保包含 // 计算缓冲区的起始地址和大小字节 uint32_t bufferAddr (uint32_t)g_captureBuffer; uint32_t bufferSize CAPTURE_BUFFER_SIZE * sizeof(uint32_t); // 无效化数据缓存确保CPU读取到DMA写入的最新数据 SCB_InvalidateDCache_by_Addr((uint32_t*)bufferAddr, bufferSize); // 现在可以安全地读取 g_captureBuffer 了 ProcessCaptureData();我的经验对于固定的、大块的DMA缓冲区方案A非缓存内存区域是首选它一劳永逸性能开销最小。方案B更灵活但需要在每次数据交换时调用维护函数有性能开销且容易忘记。在我的项目中将缓冲区放在SRAM40x38000000并配置为Non-Cacheable后数据一致性问题立刻消失。5. 进阶话题双缓冲区与连续传输优化基础的DMA循环模式能工作但在处理连续数据流时CPU在“处理数据”和“DMA写入数据”之间存在竞争风险。DMA可能在CPU处理完之前就覆盖了尚未处理的数据。双缓冲区Double Buffer模式是解决这个问题的标准方案。5.1 双缓冲区DMA的工作原理DMA控制器支持配置两个内存地址M0AR和M1AR和两个长度。当指向M0AR的传输完成后DMA会自动切换到M1AR并产生一个“半传输完成”HT中断或“传输完成”TC中断通知CPU处理刚刚填满的那个缓冲区。与此同时DMA继续向另一个缓冲区写入数据。如此交替实现了数据生产和消费的无缝衔接。配置要点使用HAL库定义两个物理缓冲区bufferA[BUFF_SIZE]和bufferB[BUFF_SIZE]。在DMA初始化结构体hdma_tim2_ch1.Init中设置模式为DMA_CIRCULAR循环模式并启用双缓冲区模式hdma_tim2_ch1.Init.Mode DMA_CIRCULAR;和hdma_tim2_ch1.Init.DoubleBufferMode DMA_DOUBLE_BUFFER_MODE_ENABLE;。使用HAL_DMAEx_MultiBufferStart_IT()函数启动DMA传入两个缓冲区的地址和长度。在DMA的中断回调函数HAL_TIM_IC_CaptureHalfCpltCallback半传输完成和HAL_TIM_IC_CaptureCpltCallback全传输完成中分别处理缓冲区A和缓冲区B。volatile uint32_t dmaBuffer0[CAPTURE_BUFFER_SIZE]; volatile uint32_t dmaBuffer1[CAPTURE_BUFFER_SIZE]; // 启动双缓冲区DMA if (HAL_DMAEx_MultiBufferStart_IT(hdma_tim2_ch1, (uint32_t)TIM2-CCR1, // 外设地址 (uint32_t)dmaBuffer0, // 内存0地址 (uint32_t)dmaBuffer1, // 内存1地址 CAPTURE_BUFFER_SIZE) ! HAL_OK) { Error_Handler(); } // 然后启动TIM的输入捕获DMA模式 HAL_TIM_IC_Start_DMA(htim2, TIM_CHANNEL_1, (uint32_t*)dmaBuffer0, CAPTURE_BUFFER_SIZE); // 注意这里参数用法可能不同需参考HAL库最新实现 // 中断回调 void HAL_TIM_IC_CaptureHalfCpltCallback(TIM_HandleTypeDef *htim) { // DMA填满了 buffer0 的前半部分对于双缓冲区通常HalfCplt表示第一个缓冲区满。 // 具体语义需查证这里处理 buffer0 InvalidateCache_and_Process(dmaBuffer0, CAPTURE_BUFFER_SIZE); } void HAL_TIM_IC_CaptureCpltCallback(TIM_HandleTypeDef *htim) { // DMA填满了 buffer1 InvalidateCache_and_Process(dmaBuffer1, CAPTURE_BUFFER_SIZE); }5.2 关于“连续DMA请求”与FIFO深度优化在搜索热词中看到了“DMA continuous requests”。在一些外设如ADC中可以配置DMA为连续请求模式即使外设没有新数据DMA也会不断发起请求。但对于TIM的输入捕获这通常不是我们想要的因为DMA请求应该严格由实际的捕获事件触发。我们更关心的是如何减少DMA请求的频率以降低系统总线占用。这就是TIM FIFO的价值所在。但如前所述许多通用TIM的FIFO对用户是不可直接编程的或者深度固定。一个变通的方法是使用TIM的“DMA突发传输”功能如果支持。你可以配置DMA使得一次捕获事件请求DMA传输多个数据项比如4个Word。但这需要TIM能在一次事件中提供多个数据对于输入捕获这通常意味着需要配合TIM的“预装载寄存器”或“重复计数器”等高级功能将多个捕获值先暂存起来。这涉及到更底层的定时器控制逻辑实现起来比较复杂通常仅在数据吞吐量要求极高的场景下才需要深究。对于大多数应用确保DMA和TIM正确连接处理好缓存一致性并采用双缓冲区模式就已经能构建一个非常稳定、高效的数据采集系统了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价