STM32 DMA循环与非循环模式:原理、配置与工程实践

STM32 DMA循环与非循环模式:原理、配置与工程实践
1. 从“搬运工”到“流水线”DMA模式选择的底层逻辑搞过STM32的朋友对DMADirect Memory Access肯定不陌生。它就像一个任劳任怨的“数据搬运工”能在CPU不干预的情况下在内存和外设之间高效地搬数据。但很多人包括我早期对DMA的“循环模式”和“非循环模式”也叫“正常模式”的理解可能就停留在“一个能循环搬一个搬一次就停”的层面。这没错但太浅了。今天我想结合自己踩过的坑和项目经验深挖一下这两种模式的选择逻辑、配置细节以及那些手册上不会写的“潜规则”。为什么模式选择这么重要因为它直接决定了你系统的数据流架构、中断触发时机和资源占用策略。选错了轻则数据错乱、效率低下重则系统卡死、调试到怀疑人生。比如你用非循环模式去处理一个持续不断的ADC采样流那数据很快就会丢失反之如果你用循环模式去处理一个只需要单次触发的数据包发送就可能陷入无意义的空转甚至引发意料之外的中断风暴。简单来说非循环模式像是一次性的快递员送完指定数量的包裹数据就下班并通知你产生传输完成中断。而循环模式则像是一条环形流水线数据在固定的缓冲区里首尾相接、循环往复地流动永远不会“送完”只有在特定条件下如半传输、传输完成才会给你发个信号告诉你当前流水线走到了哪个位置。理解这两种模式是玩转DMA进行高效、稳定数据搬运的基石。下面我们就从最核心的原理差异开始拆解。2. 核心原理拆解两种模式到底有何不同要理解差异我们必须先回到DMA控制器的几个核心寄存器NDTR数据数量寄存器、CMAR存储器地址寄存器、CPAR外设地址寄存器以及模式控制位。它们的交互方式是区分两种模式的根本。2.1 非循环模式一次性的精准投递在非循环模式下DMA控制器的工作逻辑非常直接初始化你设定好要传输的总数据量写入NDTR源地址CMAR和目的地址CPAR。启动传输使能DMA通道后每当外设如ADC、UART发出一个数据请求或定时器触发DMA就搬运一个数据单元字节、半字或字。递减与移动每搬运一次NDTR寄存器值就减1。同时根据你配置的地址递增模式PINC/MINC源或目的地址指针会自动向后移动指向下一个待搬运或待存放的位置。传输完成当NDTR递减到0时意味着预设数量的数据全部搬完。此时DMA控制器会自动关闭该通道EN位被硬件清零停止响应外设请求并产生一个传输完成中断如果使能了的话。这个过程的关键在于NDTR归零即停止。它是一个一次性消耗品。如果你想再次传输必须重新配置NDTR和地址并重新使能通道。这种模式适用于已知长度、单次触发的数据块传输比如从内存发送一个固定长度的字符串到UART或者从SPI接收一个特定大小的数据包。注意在非循环模式下传输完成后通道被禁用是一个非常重要的特性。这意味着如果你在中断服务程序中不重新初始化并启动DMA后续的外设数据请求将被忽略可能导致数据丢失。这是新手常踩的坑。2.2 循环模式永不停止的环形缓冲区循环模式则完全不同它的核心是构建一个环形的数据缓冲区。其工作逻辑如下初始化同样设定NDTR、CMAR、CPAR。这里的NDTR定义的是环形缓冲区的大小。启动传输使能通道开始搬运数据。循环与回转每搬运一个数据NDTR同样减1。但当NDTR减到0时魔法发生了硬件不会停止通道而是自动将NDTR重新装载为初始值同时将地址指针CMAR或CPAR取决于你的配置重置回缓冲区起始地址。持续运行上述过程周而复始只要通道使能DMA就会永远在这个环形缓冲区里打转旧数据被新数据覆盖。传输完成中断TC仍然会在每次NDTR归零时产生但通道不会停止。这就形成了一条数据流水线。它完美适用于持续不断、流式的数据场景比如ADC对音频信号的连续采样、UART持续接收不定长数据流配合空闲中断、或是向LCD刷新连续的画面数据。你不需要关心数据“什么时候送完”只需要定期去缓冲区里读取最新数据即可。2.3 对比表格与核心差异点为了更直观我把核心差异总结成下表特性非循环模式循环模式传输终点NDTR减至0后停止NDTR减至0后自动重装循环继续通道状态传输完成后自动禁用 (EN0)传输完成后保持使能 (EN1)中断意义传输完成中断 任务全部结束传输完成中断 缓冲区完成一轮覆盖典型应用已知长度的单次数据块传输持续不断的流式数据传输缓冲区管理线性缓冲区用完即止环形缓冲区循环复用重新启动需手动重配NDTR和地址再使能无需手动重启自动循环这里有一个极其关键的细节也是我早期调试时浪费了大量时间的地方地址指针的重置行为。在循环模式下当NDTR归零并重装时只有被配置为循环的地址指针才会重置。STM32的DMA通常允许你选择是外设地址循环还是存储器地址循环通过CIRC位控制通常我们让存储器地址循环。例如在ADC连续采样到内存的场景你配置为存储器地址循环模式。CMAR指向一个数组adc_buffer[100]NDTR100。当DMA搬完第100个数据到adc_buffer[99]后NDTR归零。此时硬件自动将NDTR重置为100同时将CMAR重置回adc_buffer[0]。下一个ADC数据到来时会被搬运到adc_buffer[0]覆盖掉旧数据。而CPARADC数据寄存器地址在整个过程中保持不变。这就实现了数据源源不断地流入一个固定大小的环形数组。如果你错误地配置成了非循环模式那么采满100个点后DMA就停了ADC的新数据无处可去会造成溢出或丢失。3. 实战配置详解以ADC和UART为例理解了原理我们来看具体怎么用。这里我以最常用的ADC连续采集和UART不定长接收为例展示两种模式下的配置代码和思维差异。我会使用HAL库因为它更通用但会穿插讲解底层寄存器操作让你明白HAL在背后做了什么。3.1 案例一ADC多通道连续采集循环模式这是循环模式的经典应用。我们希望ADC不间断地轮流采集多个通道比如3个数据自动存入数组。第一步CubeMX配置ADC配置为连续转换模式Continuous Conversion Mode Enable。设置规则组添加多个通道如CH1, CH2, CH3。开启DMA模式选择“Circular”循环模式。这是关键数据宽度根据ADC分辨率选择12位ADC用半字。第二步代码解析// 定义缓冲区 #define ADC_BUFF_SIZE 300 // 每个通道100个点共300个数据 uint16_t adc_buffer[ADC_BUFF_SIZE]; // 启动DMA的ADC采集 HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_buffer, ADC_BUFF_SIZE);这行代码背后HAL库帮你做了几件事将adc_buffer的首地址写入DMA通道的CMAR。将ADC数据寄存器地址写入CPAR。将ADC_BUFF_SIZE写入NDTR。设置DMA为存储器递增、循环模式数据宽度为半字。使能DMA通道然后启动ADC转换。第三步数据处理由于是循环模式adc_buffer数组会被不断覆盖。你需要定期、及时地读取数据。一个常见的策略是利用DMA的半传输完成中断HT和传输完成中断TC。你可以使能这两个中断。当HT中断触发说明DMA已经填满了缓冲区的前一半例如前150个数据此时你可以安全读取这前半部分的数据进行处理而此时DMA正在向后半部分写入。当TC中断触发说明DMA已经填满了整个缓冲区300个数据并从头开始覆盖此时你可以处理后半部分数据。 这种“乒乓缓冲”机制能有效避免处理数据时和DMA写入数据发生冲突是高效实时系统的常用技巧。实操心得ADC的采样率、DMA搬运速度、你的数据处理函数耗时这三者必须匹配。如果数据处理太慢缓冲区很快就被新数据覆盖导致丢数据。你需要根据采样率计算缓冲区大小确保在缓冲区被覆盖前你有足够的时间处理完一批数据。公式可以粗略估算为缓冲区大小 采样率 * 单次处理最大耗时。3.2 案例二UART发送固定数据包非循环模式现在我们需要通过UART发送一个固定的指令包比如{0xAA, 0x55, 0x01, 0x02, 0x03, 0x04}。第一步CubeMX配置UART配置好波特率等参数。开启UART的DMA发送请求。DMA模式选择“Normal”非循环模式。第二步代码解析uint8_t tx_packet[] {0xAA, 0x55, 0x01, 0x02, 0x03, 0x04}; #define PACKET_LEN sizeof(tx_packet) // 发送函数 void UART_Send_Packet(void) { // 等待上一次DMA传输完成。非循环模式下传输完成DMA会自动关闭但再次调用前仍需确保通道空闲。 while(HAL_DMA_GetState(hdma_usart1_tx) HAL_DMA_STATE_BUSY) { // 可以加入超时机制 } // 启动DMA传输 HAL_UART_Transmit_DMA(huart1, tx_packet, PACKET_LEN); }这里HAL_UART_Transmit_DMA函数内部会配置DMA并启动。因为是非循环模式当6个字节全部发送完毕后DMA通道的NDTR变为0通道自动禁用并触发传输完成中断如果使能了。此时UART的发送寄存器将不再获得新数据。第三步中断处理你可以在DMA传输完成中断或UART的TC中断里做一些标志位清零、通知主循环等操作。但请注意你不需要在这个中断里重新启动DMA因为它是一次性任务。下次要发送时必须像上面代码那样重新调用HAL_UART_Transmit_DMA。踩坑记录我曾遇到过在非循环模式下连续快速调用两次HAL_UART_Transmit_DMA导致数据错乱的问题。原因是第一次传输的DMA通道尚未完全关闭状态还未从BUSY变为READY第二次调用就开始了造成了配置冲突。所以像上面代码那样加入等待状态或使用信号量进行同步是必须的。4. 模式选择背后的工程权衡了解了怎么用我们更需要知道“为什么用这个”。模式选择不是拍脑袋而是基于具体场景的工程权衡。4.1 何时选择非循环模式任务明确长度已知这是首要条件。你要发送/接收的数据块大小在传输前就是确定的。比如读写Flash的特定扇区、传输一个完整的文件块、发送一条协议格式固定的指令。资源敏感型场景非循环模式传输完成后自动释放DMA通道。在复杂系统中DMA通道是稀缺资源。用非循环模式可以让通道在使用后立即释放供其他外设使用提高资源利用率。需要精确的结束通知传输完成中断TC是一个清晰、准确的“任务完成”信号。你可以基于此信号进行严格的状态机切换或触发后续任务逻辑清晰。4.2 何时选择循环模式数据流是持续不断的比如传感器实时采样ADC、音频流输入输出I2S、摄像头数据流DCMI、网络数据包持续接收ETH。这些场景下数据没有明确的“结束”概念。对实时性和连续性要求极高任何由软件重新初始化DMA带来的延迟都是不可接受的。循环模式实现了硬件级别的无缝衔接数据搬运的延迟是确定且最小的。构建生产者-消费者模型DMA作为生产者不断向环形缓冲区写入数据你的CPU作为消费者定期如利用HT/TC中断从缓冲区读取数据。这是嵌入式实时系统中解耦数据采集与处理的经典架构。4.3 一个混合场景的思考UART不定长接收这是一个特别能体现模式选择艺术的场景。UART接收的数据是不定长的你怎么知道什么时候算“完成”方案A非循环模式 空闲中断DMA配置为非循环模式指向一个足够大的线性缓冲区比如200字节。使能UART的空闲中断IDLE。当一帧数据接收完毕总线出现空闲时会触发此中断。在空闲中断服务函数里计算本次接收到的数据长度已配置长度 - 剩余NDTR值。然后重新初始化DMA重置NDTR和地址准备接收下一帧。优点能精确获取每一帧数据缓冲区管理简单。缺点帧与帧之间需要CPU介入重新配置DMA有微小延迟。如果帧间隔极短可能来不及处理。方案B循环模式 双缓冲区与长度计算DMA配置为循环模式指向一个环形缓冲区比如256字节。同样使能UART空闲中断。在空闲中断里通过比较当前DMA的写入位置可通过CNDTR寄存器计算和上一次记录的位置计算出本次接收的数据长度和存储位置。优点DMA永不停止吞吐量极限高帧间无延迟。缺点缓冲区管理和长度计算逻辑稍复杂需要处理环形缓冲区的“折返”问题。在实际项目中如果波特率不高如115200帧间隔充足我倾向于用方案A逻辑清晰可靠。如果波特率高达1M甚至更高追求极限吞吐则必须采用方案B。我曾经在一个高速日志传输系统中就因为用了方案A导致在密集日志下丢帧后来切换到方案B才解决问题。5. 进阶技巧与深度避坑指南掌握了基础我们来看看那些容易让人栽跟头的高级问题和优化技巧。5.1 内存对齐与数据宽度看不见的性能杀手DMA对效率有极致追求但它对数据访问有对齐要求。例如如果你配置DMA传输数据宽度为字32位那么存储器和外设的地址都必须是4字节对齐的。如果没有对齐DMA控制器可能会执行多次非对齐访问导致性能下降甚至在有些严格的外设如以太网MAC上直接产生错误。// 错误的例子可能导致非对齐访问效率低下 uint8_t buffer[100]; // 起始地址可能不是4字节对齐 HAL_UART_Transmit_DMA(huart1, buffer, 100); // UART是8位但DMA可能以32位访问内存 // 正确的做法使用编译器属性强制对齐 __attribute__((aligned(4))) uint8_t buffer[100]; // 强制4字节对齐在HAL库中很多函数内部已经做了处理但自己管理缓冲区时一定要留心。特别是定义用于DMA传输的全局数组时最好加上对齐属性。5.2 缓存一致性Cortex-M7内核的专属大坑如果你在使用高性能的Cortex-M7内核的STM32如H7系列那么“缓存一致性”是你绝对无法绕过的问题。M7内核有数据缓存D-CacheCPU读写数据时操作的是缓存而不是真实的物理内存SRAM。而DMA控制器直接访问的是物理内存。这就导致了致命的问题CPU写入缓冲区的数据可能还留在Cache里没写回内存DMA读到的就是旧数据反之DMA从外设搬来的新数据已经写入内存但CPU读到的还是Cache里的旧数据。解决方案是手动维护缓存一致性DMA传输前CPU写DMA读在启动DMA之前需要将CPU写入的数据刷出缓存到内存。使用SCB_CleanDCache_by_Addr()函数。DMA传输后DMA写CPU读在DMA传输完成中断中在CPU读取数据之前需要将缓存中对应区域无效化迫使CPU从内存重新加载。使用SCB_InvalidateDCache_by_Addr()函数。// 示例ADC DMA循环采集CPU处理数据前 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 1. 无效化缓存确保读到的是DMA刚写入内存的最新数据 SCB_InvalidateDCache_by_Addr((uint32_t*)adc_buffer, ADC_BUFF_SIZE * sizeof(uint16_t)); // 2. 现在可以安全地处理adc_buffer中的数据了 process_adc_data(); }忽略这一步你的系统会出现极其随机、难以复现的数据错误调试起来如同噩梦。这是从M3/M4转向M7开发时必须建立的新常识。5.3 中断与标志位管理避免陷入泥潭DMA的中断半传输HT、传输完成TC非常有用但管理不当也会带来麻烦。问题1中断使能冲突在CubeMX和HAL库中你可能会在两个地方看到中断使能DMA通道本身的中断以及外设如UART的DMA传输完成中断。它们是什么关系DMA通道中断由DMA控制器产生标志DMA传输本身的状态HT/TC/错误。外设的DMA中断通常由外设在DMA传输满足其条件时产生。例如UART的TC中断在DMA将最后一个数据搬到发送数据寄存器后触发。 通常我们只需要使能DMA通道的中断就够了。同时使能两者可能导致同一个事件被处理两次。我的建议是除非外设有特殊需求比如UART的TC中断用于控制GPIO否则只使用DMA通道中断。问题2中断标志未及时清除DMA的中断标志如HTIFTCIF需要在中断服务函数或回调函数中手动清除。HAL库的通用中断处理函数HAL_DMA_IRQHandler会帮你清除但如果你直接操作寄存器或者使用了LL库千万别忘了这件事。未清除的中断标志会导致中断持续触发系统卡死。问题3回调函数的上下文HAL库提供了各种回调函数如HAL_ADC_ConvCpltCallback。请注意这些回调函数是在中断上下文中被调用的。这意味着在里面不能执行耗时操作。不能调用printf、HAL_Delay等可能阻塞或非重入的函数。最佳实践是在回调函数中仅设置标志位、释放信号量或通知任务将实际的数据处理移到主循环或低优先级任务中。5.4 动态改变传输长度非循环模式的灵活应用有时我们需要在非循环模式下动态改变每次传输的数据量。例如一个通信协议每次发送的数据包长度不同。你不能简单地修改NDTR寄存器因为在传输过程中修改它是未定义行为。正确的做法是停止当前DMA传输如果需要的话。修改源/目标地址和NDTR值。重新使能DMA通道。HAL库提供了HAL_DMA_Abort函数来安全地停止一个正在进行的DMA传输。但更优雅的方式是等待上一次传输完成利用TC中断然后在启动下一次传输前通过HAL_UART_Transmit_DMA这样的函数重新配置并启动。HAL库函数内部已经封装了“停止-重配-启动”的流程。6. 调试实战当DMA不工作时你的排查清单即使配置看起来正确DMA也可能“罢工”。下面是我总结的排查清单按优先级从高到低时钟使能了吗这是最最最低级的错误但也最常见。DMA控制器和外设的时钟都必须使能。在CubeMX的时钟图里仔细检查或者查看代码中__HAL_RCC_DMAx_CLK_ENABLE()和__HAL_RCC_xxx_CLK_ENABLE()是否被调用。地址对齐和宽度匹配吗检查源地址、目标地址是否满足对齐要求。检查外设数据寄存器宽度如UART是8位ADC是16位与DMA配置的数据宽度是否匹配。不匹配会导致数据截断或混乱。外设的DMA请求使能了吗以UART发送为例除了使能UART和DMA时钟、配置DMA通道还必须使能UART本身的DMA发送请求__HAL_UART_ENABLE_IT(huart1, UART_IT_DMATX)。在HAL库的HAL_UART_Transmit_DMA函数内部会做这件事但如果你用LL库或寄存器操作很容易漏掉。中断优先级和使能了吗如果依赖中断检查NVIC中对应DMA通道的中断是否使能优先级设置是否合理避免在DMA中断中嵌套耗时操作导致其他中断被阻塞过久。缓冲区是否在有效内存区域确保你定义的数组缓冲区没有超出栈空间局部变量最好是全局变量或静态变量。同时对于有Cache的MCU检查缓存一致性操作是否正确。硬件连接与信号问题对于接收场景如UART RX、ADC确保外设有正确的信号输入。用逻辑分析仪或示波器抓一下信号线确认数据确实到达了芯片引脚。DMA只是搬运工没有数据进来它也无事可做。使用调试器观察寄存器这是终极手段。在调试模式下暂停MCU查看DMA通道的CCR寄存器EN位是否为1CIRC位是否正确DIR方向对吗CNDTR寄存器传输过程中数值在递减吗CMAR和CPAR寄存器地址是你期望的值吗外设的状态寄存器是否产生了DMA请求如UART的TXE/RXNE标志通过这份清单我解决了90%以上的DMA初始化问题。剩下的10%往往是芯片勘误手册里提到的硬件BUG这就需要去ST官网搜索对应芯片的勘误表了。7. 超越HAL直接寄存器操作与性能考量HAL库极大提高了开发效率但有时为了极致性能或特殊需求我们需要直接操作寄存器。理解寄存器操作也能让你更懂HAL在做什么。以STM32F4的DMA2 Stream0为例配置为存储器到外设的非循环传输// 1. 使能DMA2时钟 RCC-AHB1ENR | RCC_AHB1ENR_DMA2EN; // 2. 停止当前流先禁用 DMA2_Stream0-CR ~DMA_SxCR_EN; while((DMA2_Stream0-CR DMA_SxCR_EN) ! 0); // 等待使能位清零 // 3. 清除所有中断标志写1清零 DMA2-LIFCR DMA_LIFCR_CTCIF0 | DMA_LIFCR_CHTIF0 | DMA_LIFCR_CTEIF0 | DMA_LIFCR_CDMEIF0 | DMA_LIFCR_CFEIF0; // 4. 配置参数 DMA2_Stream0-PAR (uint32_t)(USART1-DR); // 外设地址 DMA2_Stream0-M0AR (uint32_t)tx_buffer; // 存储器地址 DMA2_Stream0-NDTR buffer_size; // 数据数量 // 5. 配置控制寄存器 CR uint32_t cr_reg 0; cr_reg | DMA_SxCR_CHSEL_0; // 通道选择需查表例如USART1_TX在DMA2 Stream7 Channel4 cr_reg | DMA_SxCR_MINC; // 存储器地址递增 cr_reg | DMA_SxCR_DIR_0; // 方向存储器到外设 cr_reg | DMA_SxCR_TCIE; // 使能传输完成中断 cr_reg | DMA_SxCR_PL_0; // 优先级中 // ... 其他配置数据宽度等 DMA2_Stream0-CR cr_reg; // 6. 使能流 DMA2_Stream0-CR | DMA_SxCR_EN;直接操作寄存器的好处是控制粒度细没有HAL库的函数调用开销特别适合在频繁启停DMA的超高性能场景。但缺点也很明显代码可读性差容易出错移植性低。我的建议是在项目初期或性能瓶颈不明显时优先使用HAL库当确实需要榨干最后一点性能时再针对关键路径进行寄存器级优化。最后关于模式选择我想再强调一点没有最好的模式只有最合适的场景。循环模式和非循环模式是DMA给你的两把利器理解它们的秉性根据你的数据流特征、实时性要求和系统资源状况做出明智选择才能真正让这个强大的“数据搬运工”为你的系统效力而不是添乱。每一次对DMA的深入理解都会让你在嵌入式开发的路上走得更稳、更远。