资讯动态

嵌入式DMA原理与实战:从定时采集到双缓冲进阶

发布时间:2026/10/25 2:53:58 来源:尧图企业网站定制
1. 这不是“搬运数据”的通道而是嵌入式系统里最懂节奏的调度员DMA——全称Direct Memory Access直译是“直接内存访问”但这么叫根本没抓住它的魂。我在某工业控制板项目里第一次把它用稳是在一个需要每200微秒精准采集16路ADC电压值、同时还要把结果实时打包发给上位机的场景里。当时主控芯片是Cortex-M4内核主频180MHz如果全靠CPU轮询读取ADC寄存器再搬进内存缓冲区光是中断响应寄存器读写内存拷贝这三步一次就要耗掉近3500个时钟周期。算下来200μs时间窗里最多只能处理不到4路通道离16路差得不是一星半点。后来我把ADC数据流改接DMA通道CPU只在DMA传输完成中断里做一次数据校验和协议封装整个采集周期压到了192μs以内误差控制在±0.8μs。这时候我才真正明白DMA从来不是什么“省CPU负载”的锦上添花它是嵌入式实时系统里决定你能不能守住硬实时底线的关键齿轮。它解决的核心问题非常具体当外设比如ADC、SPI、UART、I2S和内存之间存在高频、定长、可预测的数据搬运需求时让CPU从“快递员”回归“指挥官”。CPU不再需要亲自抓起每个字节、迈开腿跑一趟内存、再折返确认——它只需要在开始前告诉DMA控制器“从ADC数据寄存器这个地址每次取2字节连续搬1024次目标放在我给你划好的SRAM区域0x20001000起始处搬完喊我一声。”然后CPU就可以去干别的事了比如解析上一轮数据、更新PID控制参数、或者干脆休眠省电。整个过程就像给工厂流水线装上了自动传送带传感器是原料入口内存是暂存仓库DMA就是那条永不疲倦、节拍精准的传送带而CPU是站在中控室看仪表盘、调参数的老师傅。这个机制特别适合三类典型场景一是高速数据采集如音频采样、电机编码器计数、示波器前端二是大块数据搬运如SD卡批量读写、LCD显存刷屏、USB设备固件升级三是低功耗待机唤醒比如RTC闹钟触发DMA从备份域RAM搬唤醒数据CPU全程不启动。如果你正在做的项目里有“必须在固定时间点拿到数据”、“数据量大到CPU轮询会丢包”、“系统要求待机功耗低于10μA”这类指标那DMA就不是“可以考虑”而是“必须深挖”的核心能力。它不像RTOS那样有厚厚的文档和例程但一旦用错轻则数据错位、时序紊乱重则内存越界覆盖关键变量、系统莫名死机——这种问题往往复现率低、定位极难我见过有团队为一个DMA触发的偶发总线错误连续三天熬夜查硬件逻辑分析仪波形最后发现只是初始化时少配了一个“传输完成中断使能”位。2. 理解DMA控制器先拆开它的四块“积木”市面上主流MCU的DMA控制器无论ST的STM32、NXP的i.MX RT系列还是国产的GD32、CH32其底层架构都高度同源基本由四个功能模块构成。理解这四块“积木”怎么咬合比死记寄存器手册重要十倍。我习惯用“快递公司”来类比DMA控制器就是这家公司的总部而它的四大部门分别是——2.1 地址生成器你的专属GPS导航员它负责精确计算每一次搬运的“出发地”和“目的地”。关键参数有两个源地址Source Address和目标地址Destination Address。注意这里说的“地址”不是某个固定值而是一个基地址偏移量的动态组合。比如你要从ADC_DR寄存器假设地址是0x4001204C读数据DMA不会每次都去读这个固定地址——它会先读一次然后根据你配置的“地址增量模式”自动加2因为ADC数据是16位占2字节下一次就读0x4001204E再下一次是0x40012050……这样就能连续读取多个通道的转换结果。这个“自动加几”的规则就是地址增量Address Increment可选“不增”、“加1字节”、“加2字节”、“加4字节”。实操中最大的坑就在这里比如你用32位ADC却把目标地址增量设成“加2”结果数据就会像漏勺一样每隔一个字节写一次剩下全是0。我调试过一个音频项目就是因为SPI接收DMA的目标地址增量设错了导致PCM数据左声道全0、右声道正常听起来像单耳耳机折腾半天才发现是这个参数。2.2 数据宽度协调器管尺寸匹配的质检科它确保“搬的东西”和“放的地方”严丝合缝。参数叫数据宽度Data Width分源端宽度Source Data Width和目标端宽度Destination Data Width常见选项是8/16/32位。举个真实例子某项目用SPI接口读取一个16位分辨率的温度传感器SPI外设寄存器是32位宽为了兼容不同长度帧但实际有效数据只占低16位。如果DMA源端宽度设成32位它就会把整个32位寄存器内容含高16位无效0一起搬走导致后续解析温度值时高位全是0数值被砍掉一半。正确做法是把源端宽度设为16位DMA会自动从32位寄存器里提取低16位有效数据。这个细节在STM32的HAL库里藏得很深HAL_DMA_Start()之前必须调用HAL_DMAEx_ConfigChannelAttributes()明确指定否则默认按外设寄存器宽度走新手极易踩坑。2.3 传输计数中枢掐秒表的计数员它决定这条传送带跑多远、运多少。核心参数是数据数量Number of Data也就是要搬运多少个“数据单元”。注意这个“单元”的大小是由上面说的“数据宽度”定义的。比如你设数据宽度为16位数量为1024那就是搬1024个16位数据总共2048字节如果宽度是32位数量还是1024那就是搬4096字节。很多初学者以为“数量字节数”结果配置完发现内存里数据稀疏得像筛子。更关键的是这个计数器是递减式的初始值写入后每搬一次就减1减到0时触发传输完成事件。所以如果你要做循环采集比如一直采集ADC就必须开启“循环模式Circular Mode”这样计数器减到0会自动重载初始值传送带永不停歇。但循环模式有个隐藏代价你永远无法通过“计数器归零”来判断“这一轮完整数据已收齐”因为它是不断重载的。解决方案是配合“半传输中断Half Transfer Interrupt”——当计数器减到初始值一半时触发一次中断这时你可以安全处理前半缓冲区数据而后半区继续接收实现零延迟流水线处理。2.4 传输触发与仲裁器管发车指令和抢道的调度室它决定“什么时候开始搬”以及“多个DMA请求来了谁先走”。触发源Trigger Source来自外设事件比如“ADC转换完成”、“SPI接收缓冲区非空”、“定时器更新事件”。这里有个易忽略的要点触发信号的有效边沿。有些MCU如早期STM32F1只支持上升沿触发而有些如STM32H7可配置上升沿/下降沿/任意边沿。如果外设产生的触发脉冲很窄1个系统时钟周期而你又没配对边沿DMA可能根本捕获不到导致传输永远不启动。至于仲裁当多个外设同时申请DMA服务时比如ADC和SPI都在传数据控制器要按优先级裁决。优先级分硬件固定和软件可配两种硬件优先级由DMA通道号决定通道0永远最高软件优先级则通过寄存器设置如STM32的DMA_CCRx寄存器中的PL位。我的经验是对时序要求最苛刻的外设如高速编码器计数务必分配最高优先级通道而对实时性要求稍低的如LED亮度PWM更新可以放低优先级避免抢占关键通道。3. 从零手写一个稳定可靠的ADCDMA采集驱动下面以STM32G4系列MCU为例带你一步步实现一个生产环境可用的ADCDMA采集驱动。不依赖HAL库的抽象层直接操作寄存器这样你能看清每一行代码背后的硬件动作。整个流程分为初始化、启动、中断处理、数据获取四步我会把每个步骤的“为什么这么写”和“不这么写会怎样”都摊开讲。3.1 初始化给DMA和ADC办一张“联合通行证”首先配置DMA通道。以ADC1的规则通道数据寄存器ADC1-DR为源目标地址设为一块预分配的SRAM缓冲区uint16_t adc_buffer[1024]// 1. 使能DMA1时钟RCC-AHB1ENR RCC-AHB1ENR | RCC_AHB1ENR_DMA1EN; // 2. 配置DMA通道4ADC1专用- 关键参数逐个解释 DMA1_Channel4-CPAR (uint32_t)(ADC1-DR); // 源地址ADC1数据寄存器 DMA1_Channel4-CMAR (uint32_t)adc_buffer; // 目标地址我们准备的缓冲区 DMA1_Channel4-CNDTR 1024; // 传输数量1024个16位数据 DMA1_Channel4-CCR 0; // 先清零再逐位设置 DMA1_Channel4-CCR | DMA_CCR_MINC; // 目标地址增量使能每次搬完自动2 DMA1_Channel4-CCR | DMA_CCR_PSIZE_1; // 源端数据宽度16位PSIZE01b DMA1_Channel4-CCR | DMA_CCR_MSIZE_1; // 目标端数据宽度16位MSIZE01b DMA1_Channel4-CCR | DMA_CCR_CIRC; // 循环模式缓冲区用完自动重载 DMA1_Channel4-CCR | DMA_CCR_TEIE | DMA_CCR_HTIE | DMA_CCR_TCIE; // 开启三类中断传输错误/半传输/传输完成 DMA1_Channel4-CCR | DMA_CCR_EN; // 最后一步使能通道提示CPAR外设地址和CMAR内存地址必须是32位对齐的地址否则DMA会触发总线错误。adc_buffer数组声明时要用__attribute__((aligned(4)))强制4字节对齐这是很多野火、正点原子教程里没强调的致命细节。接着配置ADC本身。重点在于触发源必须指向DMA// 1. 使能ADC1时钟RCC-APB2ENR RCC-APB2ENR | RCC_APB2ENR_ADC1EN; // 2. 配置ADC时钟分频确保ADCCLK ≤ 36MHz RCC-CFGR3 | RCC_CFGR3_ADCPRE_1; // ADCCLK SYSCLK/4 // 3. 配置ADC规则序列只用通道0PA0采样时间设最长239.5周期 ADC1-SQR1 0; // 清空序列寄存器 ADC1-SQR1 | ADC_SQR1_L_0; // 序列长度1只采1个通道 ADC1-SQR3 0; // 清空通道选择 ADC1-SQR3 | 0 ADC_SQR3_SQ1_Pos; // 通道0作为第一个也是唯一一个转换 // 4. 关键配置ADC为DMA模式并选择触发源 ADC1-CR2 | ADC_CR2_EXTSEL_2 | ADC_CR2_EXTSEL_1; // 触发源TIM1 CC1可自定义 ADC1-CR2 | ADC_CR2_EXTTRIG; // 使能外部触发 ADC1-CR2 | ADC_CR2_DMA; // 使能DMA模式这才是核心 ADC1-CR2 | ADC_CR2_CONT; // 连续转换模式配合DMA循环 // 5. 校准并开启ADC ADC1-CR2 | ADC_CR2_ADON; while(!(ADC1-SR ADC_SR_ADON)); // 等待ADC稳定 ADC1-CR2 | ADC_CR2_RSTCAL; // 复位校准寄存器 while(ADC1-CR2 ADC_CR2_RSTCAL); ADC1-CR2 | ADC_CR2_CAL; // 启动校准 while(ADC1-CR2 ADC_CR2_CAL);注意ADC_CR2_DMA位必须置1ADC才会在每次转换完成后向DMA控制器发出请求信号。如果漏了这一步DMA通道再完美配置也纹丝不动——这是新人调试时最常见的“假死”原因。3.2 启动一声令下流水线开转启动只需两步且顺序不能错// 第一步启动定时器触发源假设用TIM1产生200kHz触发脉冲 TIM1-PSC 89; // 假设SYSCLK180MHzPSC89 → TIM1CLK2MHz TIM1-ARR 9; // ARR9 → 更新频率2MHz/(91)200kHz TIM1-DIER | TIM_DIER_UDE; // 使能更新事件DMA请求注意这里是TIM的DMA请求不是ADC的 TIM1-CR1 | TIM_CR1_CEN; // 启动TIM1 // 第二步启动ADC转换此时ADC会等待TIM1的更新事件 ADC1-CR2 | ADC_CR2_SWSTART; // 软件启动第一次转换或等第一个TIM1更新事件实操心得触发源一定要先启动再启动ADC。如果ADC先启动它会在没有触发信号的情况下按默认的软件触发方式执行一次转换这次转换的数据不会走DMA因为DMA还没准备好会残留在ADC_DR寄存器里污染后续DMA流。我曾在一个电机FOC项目里遇到电流采样值跳变最终发现就是这个启动时序问题。3.3 中断处理在数据洪流中稳住舵盘DMA中断服务函数ISR是整个驱动的神经中枢。我们只处理最关键的“半传输”和“传输完成”中断void DMA1_Channel4_IRQHandler(void) { uint32_t flags DMA1-ISR; // 读取状态寄存器自动清除标志位 // 半传输中断前512个数据已就绪可安全处理 if(flags DMA_ISR_HTIF4) { DMA1-IFCR | DMA_IFCR_CHTIF4; // 手动清除半传输标志部分MCU需手动 process_adc_data_half(adc_buffer, 512); // 处理前半区数据 } // 传输完成中断后512个数据已就绪因循环模式实际是新一轮的前512 if(flags DMA_ISR_TCIF4) { DMA1-IFCR | DMA_IFCR_CTCIF4; // 清除完成标志 process_adc_data_full(adc_buffer 512, 512); // 处理后半区数据 } // 传输错误中断必须处理否则会锁死 if(flags (DMA_ISR_TEIF4 | DMA_ISR_FEIF4)) { DMA1-IFCR | DMA_IFCR_CTEIF4 | DMA_IFCR_CFEIF4; // 记录错误日志复位DMA通道 DMA1_Channel4-CCR ~DMA_CCR_EN; DMA1_Channel4-CCR | DMA_CCR_EN; // 可选触发系统复位或进入安全模式 } }注意process_adc_data_half()和process_adc_data_full()这两个函数必须是无阻塞、超短时的。它们只做数据搬移如复制到另一个处理缓冲区、简单滤波如滑动平均前3个点、或置位标志位。任何耗时操作如浮点运算、串口打印、复杂FFT都必须放到主循环或更高优先级任务里去做。否则当下一轮DMA数据涌来时中断还在处理上一轮就会造成数据覆盖——这就是传说中的“DMA溢出”。3.4 数据获取如何从流动的缓冲区里“捞”出你要的那一瓢由于我们开启了循环模式adc_buffer始终是一块被DMA持续覆盖的“活水池”。获取最新有效数据的关键在于理解DMA的指针位置。最稳妥的方法是利用半传输/完成中断的时间戳volatile uint32_t last_ht_time 0; // 上次半传输中断时刻SysTick计数 volatile uint32_t last_tc_time 0; // 上次完成中断时刻 void process_adc_data_half(uint16_t* buf, uint16_t len) { // 此时buf指向adc_buffer[0]len512 // 记录此刻时间戳用于后续计算采样相位 last_ht_time HAL_GetTick(); // 将这512个数据复制到安全处理区 memcpy(processing_buffer, buf, len * sizeof(uint16_t)); ht_ready_flag 1; // 置位半区就绪标志 } // 主循环中检查标志位 if(ht_ready_flag) { // 此时processing_buffer里存着最新的512点数据 // 可以放心做FFT、峰值检测、阈值比较等耗时操作 run_fft_analysis(processing_buffer, 512); ht_ready_flag 0; }实操心得永远不要在中断里直接访问adc_buffer全局数组做复杂计算。我见过一个音频降噪项目工程师在DMA中断里直接对adc_buffer做1024点FFT结果FFT计算耗时超过200μs导致下一轮DMA数据覆盖了未处理完的旧数据输出全是杂音。正确姿势是“中断只搬主循环只算”用双缓冲或环形缓冲区解耦。4. 踩过的坑、绕不开的雷、还有那些手册里没写的秘密DMA驱动看似逻辑清晰但实际落地时90%的问题都出在那些“手册里一笔带过、论坛里没人提、但一碰就炸”的细节上。我把这些年在多个项目里填过的坑按严重程度排序给你列个避坑清单。4.1 内存对齐不是“建议”是铁律几乎所有ARM Cortex-M系列MCU的DMA控制器都要求源地址和目标地址必须是数据宽度的整数倍对齐。比如你用16位传输地址必须是2字节对齐末位为0用32位传输必须是4字节对齐末两位为0。违反这条轻则DMA静默失败不报错就是不干活重则触发HardFault。解决方案很简单// 声明缓冲区时强制对齐 __attribute__((aligned(4))) uint16_t adc_buffer[1024]; // 4字节对齐兼容16/32位传输 // 或者用编译器内置函数GCC uint16_t *adc_buffer (uint16_t*) __builtin_assume_aligned( malloc(1024 * sizeof(uint16_t)), 4);提示malloc()分配的内存通常满足8字节对齐但不保证4字节对齐尤其在小内存系统里所以必须用__builtin_assume_aligned显式声明否则优化器可能生成错误指令。4.2 缓冲区大小别迷信“2的幂”要看外设限制很多教程说“DMA缓冲区大小必须是2的幂”这是误解。真正限制你的是外设的DMA请求粒度。比如某些MCU的SPI外设在DMA模式下每次请求只能传输1、2、4、8、16、32、64、128、256、512或1024个字节超出范围会截断。我调试一个SPI Flash烧录驱动时设缓冲区为1000字节结果DMA只传了512字节就停了剩余488字节石沉大海。查手册才发现SPI的DMA最大突发长度Burst Size是512字节。解决方案是要么把缓冲区设为512的整数倍如1024要么在传输前手动分片每次不超过512字节。4.3 时钟门控开了DMA别忘了开外设时钟这是最隐蔽的“玄学故障”。现象是DMA配置全对中断也进了但adc_buffer里全是0。排查思路要倒过来想DMA能进中断说明DMA控制器本身工作正常数据是0说明源地址读出来就是0。那问题一定在源外设——ADC没输出数据。再往前推ADC没输出可能是没供电没校准最后发现是RCC-APB2ENR里ADC时钟位没置1ADC根本没上电寄存器全是复位值0DMA当然读到一堆0。所以初始化清单第一条永远是“检查所有相关外设的时钟使能位”。4.4 优先级冲突当DMA遇上高优先级中断假设你的系统里有一个10kHz的PWM更新中断优先级2和ADCDMA的传输完成中断优先级3。当PWM中断正在执行时DMA完成了传输触发了TC中断但CPU正在处理PWMTC中断被挂起。如果PWM中断处理时间过长1个ADC采样周期等CPU回来处理TC中断时DMA已经开始了下一轮传输adc_buffer里的数据已被覆盖。解决方案有两个一是把DMA中断优先级设得比所有可能阻塞它的中断都高比如设为1二是改用“半传输中断”做数据处理因为半传输发生在传输中途留给CPU的响应窗口更大。我在一个无人机飞控项目里就把IMU传感器的SPI DMA中断优先级设为最高0确保姿态解算数据零丢失。4.5 调试技巧用逻辑分析仪看DMA请求信号当一切配置看起来都对但DMA就是不启动别急着怀疑代码。拿出逻辑分析仪测两个信号一是外设的DMA请求引脚如ADC的EOC信号线二是DMA控制器的请求应答线如DMA_REQ。如果EOC有规律脉冲但DMA_REQ没反应说明DMA通道没使能或配置错误如果DMA_REQ有脉冲但adc_buffer没变化说明地址或宽度配置错。这个方法比看寄存器值快十倍是我定位DMA问题的终极手段。5. DMA不止于“搬运”三个进阶用法打开新世界当你把基础DMA用熟会发现它其实是个可编程的“硬件协处理器”。以下三个高级用法在工业、汽车、医疗电子里越来越常见掌握它们你的驱动开发水平就跨过了初级门槛。5.1 链式DMALinked List DMA让传送带自己换轨道传统DMA一次只能搬一块连续内存。但现实场景中数据常分散在不同内存段比如一个协议帧头部是固定结构体16字节中间是可变长负载0~1024字节尾部是CRC校验4字节。链式DMA允许你预先配置多个“传输节点”每个节点包含自己的源地址、目标地址、数据数量、下一个节点地址。DMA完成一个节点后自动加载下一个节点配置无需CPU干预。STM32H7和GD32H系列已原生支持。配置要点是每个节点必须4字节对齐且“下一个节点地址”字段要指向下一个节点的首地址。这相当于用硬件实现了“零拷贝”的协议组包CPU只在整帧完成后处理。5.2 双缓冲DMADouble Buffer DMA彻底告别数据覆盖循环模式虽好但处理延迟风险高。双缓冲模式提供两个独立缓冲区A和BDMA轮流往A和B写数据。当A满时DMA自动切到B并触发“缓冲区切换中断”此时CPU处理A当B满时切回A触发中断处理B。这样CPU永远有完整的一份数据可处理且处理时间只要小于一个缓冲区填充时间即可。配置关键在于DMA_CCR_DBM位双缓冲模式使能和DMA_CNDTR寄存器的动态重载。某医疗监护仪项目用此模式实现ECG波形的实时显示与存储效果极稳。5.3 内存到内存DMAMEM2MEMCPU的“后台复印机”虽然多数MCU不推荐用DMA做内存拷贝CPU指令更快但在特定场景下它不可替代比如需要在CPU休眠时由低功耗DMA控制器把备份域RAM里的关键参数如校准系数自动复制到主SRAM供唤醒后使用或者在安全启动流程中DMA将加密固件从Flash搬运到RAM并触发硬件解密引擎。此时DMA成了系统级的“可信搬运工”不受CPU状态影响。启用MEM2MEM只需把DMA_CCR_MEM2MEM位置1并把源地址设为内存地址而非外设地址。最后再分享一个小技巧在调试DMA驱动时别只盯着adc_buffer的数值用调试器观察DMA的CNDTR寄存器值。它实时显示剩余传输数量如果这个值卡在某个数字不动说明DMA被卡住了如果它飞速归零又重载说明循环模式生效如果它从不变化那一定是触发源没来信号。这个寄存器是你窥探DMA内部状态的唯一窗口。

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

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

免费获取报价 →
↑