
1. 项目概述当ADC遇到USARTDMA如何成为CPU的“救星”在嵌入式开发尤其是涉及实时数据采集与传输的场景里我们常常面临一个经典矛盾一边是模数转换器ADC在兢兢业业地采集外部世界的模拟信号另一边是通用同步异步收发器USART需要稳定地将这些数字化的数据发送出去。如果让中央处理器CPU亲自来搬运每一个ADC转换完成的数据再填进USART的发送数据寄存器你会发现CPU大部分时间都在做这种简单重复的“搬运工”工作其利用率会居高不下甚至达到90%以上导致它无暇处理更复杂的业务逻辑、算法或响应其他中断。这时直接存储器访问DMA技术就闪亮登场了。它就像一个专职的“数据快递员”能在存储器和外设之间或者存储器与存储器之间建立一条独立于CPU的高速数据通道。本次我们就以“ADC采集数据并通过USART发送”这一典型任务为例深入剖析DMA的工作原理并定量分析它究竟能为CPU“减负”多少。这个项目看似简单却是理解现代嵌入式系统优化内核的绝佳切入点。无论你用的是STM32、GD32还是其他ARM Cortex-M系列芯片其DMA思想都是相通的。我们将从原理入手拆解DMA的配置要点然后通过一个具体的STM32 HAL库例程展示如何实现ADC的DMA连续采集与USART的DMA发送最后通过实测数据对比直观展示启用DMA前后CPU利用率的巨大差异。你会发现合理使用DMA不仅能将CPU从繁琐的IO操作中解放出来更是实现低功耗、高实时性系统的关键。2. DMA技术核心原理深度拆解2.1 DMA是什么不仅仅是“数据搬运工”DMA全称Direct Memory Access直接存储器访问。它的核心思想是“窃取”总线周期。在传统的程序控制传输中PIO Programmed I/O每个数据的移动都需要CPU执行加载Load和存储Store指令占用CPU时间和系统总线。而DMA控制器DMAC作为一个独立的外设可以在CPU不介入的情况下接管系统总线完成数据在外设寄存器与内存之间或者内存不同区域之间的传输。你可以把系统总线想象成一条高速公路CPU是唯一的调度员兼司机数据是货物。PIO模式下每运一件货都需要调度员亲自开车跑一趟。而DMA模式下调度员CPU只需要在开始时告诉DMA这个“专职货车司机”DMAC货在哪里源地址送到哪去目标地址有多少货数据量然后就可以去忙别的事了。DMA司机会自己开车上高速申请并占用总线完成运输最后回来报告“货已送到”产生传输完成中断。这个过程涉及几个关键角色DMA控制器DMAC硬件实体负责管理传输请求、仲裁总线、执行数据传输。通道Channel每个DMA控制器通常有多个独立的通道每个通道可以服务于一个特定的外设如ADC1、USART1_TX。通道之间可以设置优先级。请求Request传输的发起者。可以是外设如ADC转换完成标志、USART发送数据寄存器空标志向DMA控制器发出请求也可以是软件触发。仲裁器Arbiter当多个通道同时请求时根据预设的优先级决定哪个通道先使用总线。2.2 DMA传输的关键配置参数详解配置一次DMA传输本质上是初始化DMA控制器里的几个核心寄存器。理解这些参数是灵活运用DMA的基础。源地址与目标地址Source Destination Address这是传输的起点和终点。在我们的例子中当ADC使用DMA时源地址是ADC数据寄存器如ADC1-DR的地址目标地址是我们程序中定义的一个内存数组如uint16_t adc_buffer[BUFFER_SIZE]的首地址。当USART使用DMA发送时源地址是内存中待发送数据数组的首地址目标地址是USART发送数据寄存器如USART1-TDR的地址。注意地址必须是对齐的。例如外设寄存器地址通常是32位对齐的。在STM32的HAL库中我们经常看到(uint32_t)adc_buffer这样的强制转换就是为了确保地址类型与DMA外设寄存器期望的宽度一致。这也是网络热词中“dma的基地址前为什么要加uint32_t”的答案DMA外设的地址寄存器通常是32位宽的传递一个uint32_t类型的值可以避免编译器警告并确保地址值被正确解释。数据宽度Data Width指单次传输操作移动的数据位数。常见的有字节8位、半字16位、字32位。必须与源和目标的自然对齐方式匹配。例如ADC数据寄存器是16位对于12位ADC那么数据宽度应设置为半字16位。如果设置为字节会导致数据错位设置为字可能会读取到无关数据。网络热词中的“stm32 dma 错位”问题很多时候就源于数据宽度或地址对齐配置不当。传输模式Transfer Mode外设到存储器如ADC采集。存储器到外设如USART发送。存储器到存储器如内存块拷贝某些DMA控制器支持。传输数量Number of Data Items / Data Length需要传输的总数据项数量。注意这个“项”的单位是前面设置的“数据宽度”。例如数据宽度为半字传输数量设置为100意味着要传输100个16位的数据。循环模式Circular Mode这是实现连续采集或发送的关键。当传输数量递减到0时如果使能了循环模式DMA控制器会自动将传输数量寄存器重载为初始值并从头开始新一轮传输。对于ADC连续采集填充环形缓冲区或者USART连续发送流数据这个模式至关重要。增量模式Increment Mode决定每次传输后地址指针是否自动增加。对于存储器地址通常是数组我们肯定希望它递增以填充或读取连续的内存空间。对于外设寄存器地址如ADC-DR USART-TDR这个地址是固定的不应该递增必须设置为非增量模式。中断InterruptDMA传输完成特定阶段如半传输完成、传输全部完成时可以产生中断通知CPU进行后续处理如处理半缓冲区的数据。这是实现“双缓冲”或“乒乓缓冲”等高级数据流管理技术的基础。2.3 DMA与CPU的协作模型及总线仲裁DMA并非完全与CPU并行工作因为它们共享同一套系统总线数据总线、地址总线、控制总线。当DMA需要传输数据时它会向总线仲裁器发出请求。仲裁器根据优先级决定将总线控制权交给CPU还是DMA。周期窃取Cycle StealingDMA趁CPU不访问总线比如正在执行不需要访存的ALU指令的间隙“偷”几个总线周期来传输一个数据单元。这是最常见的模式对CPU的影响是“偶尔卡顿一下”。突发传输Burst TransferDMA一旦获得总线权会连续传输多个数据单元一个突发包然后再释放总线。这种方式传输效率高但会导致CPU被阻塞较长时间。透明模式DMA只在CPU肯定不使用总线的时候如某些架构的特定时钟相位进行传输对CPU完全透明但实现复杂效率受限。在Cortex-M系列中通常采用周期窃取或突发传输。这意味着即使使用了DMACPU的利用率也不会降到0%因为总线竞争依然存在。但相比于CPU亲自执行加载/存储指令DMA传输的单位效率高得多CPU只需响应极少的中断总体利用率会大幅下降。3. 实战构建ADC DMA采集与USART DMA发送全流程我们以STM32F4系列其他系列原理类似和STM32CubeMX/HAL库为例构建一个完整的系统ADC1以定时器触发进行规则通道采样通过DMA将数据存入内存缓冲区当缓冲区半满或全满时通过DMA将数据从缓冲区发送到USART1。3.1 硬件与软件环境准备硬件STM32F407 Discovery板或其他支持ADC和USART的板卡。ADC输入连接一个可调电位器到PA0ADC1通道0。USART输出连接USART1PA9 TX到USB转串口模块以便在PC端串口助手查看数据。软件STM32CubeMX v6.xKeil MDK-ARM或STM32CubeIDE串口调试助手如Putty、SecureCRT3.2 使用STM32CubeMX进行图形化配置时钟树配置确保系统时钟SYSCLK运行在最高频率如168MHz为ADC、USART和DMA提供稳定的时钟源。ADC的时钟ADCCLK通常由APB2分频而来注意不要超过ADC支持的最大时钟对于F4通常为36MHz。ADC1配置Mode: 启用“Independent mode”。Resolution: 选择“12-bit”分辨率越高转换时间越长。Scan Conversion Mode: 禁用我们只用一个通道。如果多通道则需启用。Continuous Conversion Mode: 禁用。我们将使用定时器触发。Discontinuous Conversion Mode: 禁用。DMA Continuous Requests:启用。这是关键它允许在一次DMA请求后ADC转换完成自动触发下一次DMA传输直到传输数量完成。End Of Conversion Selection: 选择“EOC after each conversion”每次转换后产生EOC。Channel 0: 设置采样时间Sample Time。采样时间越长转换精度越高但速度越慢。对于音频~20kHz或中等速度信号15 Cycles或84 Cycles是常见选择。网络热词“adc采样时间设置多少合适”取决于你的信号频率和精度要求需要权衡。External Trigger Conversion Source: 选择“Timer 2 Trigger Out event”。这意味着ADC转换将由TIM2的更新事件来启动。TIM2配置用于触发ADCClock Source: Internal Clock.Prescaler: 计算值使得计数器时钟为所需频率。例如如果APB1 Timer时钟为84MHz我们希望ADC采样率为10kHz。计算Update Event Frequency Timer Clock / ((Prescaler 1) * (Counter Period 1))。设Prescaler 8399则计数器时钟 84MHz / (83991) 10kHz。设Counter Period 0则更新频率 10kHz / (01) 10kHz。这样TIM2每100us产生一次更新事件触发一次ADC转换。Trigger Event Selection: 在Master Mode中选择“Update Event”作为TRGO输出。DMA配置在DMA Settings标签页点击Add。DMA Request: 选择“ADC1”。Direction: “Peripheral To Memory”。Priority: “Medium”。Mode: “Circular”循环模式实现连续采集。Increment Address: “Peripheral”选No“Memory”选Yes。Data Width: “Peripheral”和“Memory”都选“Half Word”因为ADC数据寄存器是16位。USART1配置Mode: “Asynchronous”。Baud Rate: 设置为115200。USART1的DMA配置在DMA Settings标签页再次点击Add。DMA Request: 选择“USART1_TX”。Direction: “Memory To Peripheral”。Priority: “Medium”。Mode: “Normal”发送完一批数据就停止由软件重新启动。Increment Address: “Peripheral”选No“Memory”选Yes。Data Width: “Peripheral”选“Byte”USART数据寄存器是8位“Memory”也选“Byte”因为我们发送的是8位字节流。这里是个关键点如果ADC数据是16位的而USART发送是8位的我们需要在内存中处理好数据格式转换例如将16位数据拆成两个8位字节或者配置DMA为半字到字节的传输如果DMA支持。更常见的做法是在内存中准备一个uint8_t的发送缓冲区将uint16_t的ADC数据格式化后比如转换成ASCII字符串再填入。生成代码配置好工程名、路径和IDE后生成代码。3.3 核心代码实现与解析打开生成的工程我们在main.c的用户代码区添加逻辑。/* 私有变量定义 */ #define ADC_BUFFER_SIZE 1024 #define UART_TX_BUFFER_SIZE (ADC_BUFFER_SIZE * 5) // 预留空间用于格式化 uint16_t adc_dma_buffer[ADC_BUFFER_SIZE]; uint8_t uart_tx_buffer[UART_TX_BUFFER_SIZE]; volatile uint8_t half_buffer_ready 0; volatile uint8_t full_buffer_ready 0; /* 私有函数声明 */ void Process_ADC_Data(uint16_t* buffer, uint32_t size); int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_USART1_UART_Init(); MX_TIM2_Init(); /* 启动ADC的DMA采集 */ if (HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFFER_SIZE) ! HAL_OK) { Error_Handler(); } /* 启动定时器以触发ADC */ HAL_TIM_Base_Start(htim2); while (1) { /* 检查DMA半传输或全传输完成标志 */ if (half_buffer_ready) { __disable_irq(); // 短暂关中断安全地操作标志位 half_buffer_ready 0; __enable_irq(); // 处理前半缓冲区数据 (adc_dma_buffer[0 .. ADC_BUFFER_SIZE/2 -1]) Process_ADC_Data(adc_dma_buffer, ADC_BUFFER_SIZE / 2); // 可以将处理后的数据启动USART DMA发送 // 例如格式化后调用 HAL_UART_Transmit_DMA(huart1, formatted_data, len); } if (full_buffer_ready) { __disable_irq(); full_buffer_ready 0; __enable_irq(); // 处理后半缓冲区数据 (adc_dma_buffer[ADC_BUFFER_SIZE/2 .. ADC_BUFFER_SIZE-1]) Process_ADC_Data(adc_dma_buffer[ADC_BUFFER_SIZE/2], ADC_BUFFER_SIZE / 2); // 同上启动发送 } /* 此处CPU可以执行其他低优先级任务如按键扫描、状态机更新等 */ // User_Task(); } } /* ADC DMA传输完成一半的回调函数 */ void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { half_buffer_ready 1; } } /* ADC DMA传输全部完成的回调函数 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { full_buffer_ready 1; } } /* 处理ADC数据的示例函数转换为电压值并格式化到UART发送缓冲区 */ void Process_ADC_Data(uint16_t* buffer, uint32_t size) { uint32_t idx 0; for (uint32_t i 0; i size; i) { // 假设参考电压Vref3.3V12位ADC float voltage (buffer[i] * 3.3f) / 4095.0f; // 将浮点数格式化为字符串存入uart_tx_buffer idx sprintf((char*)uart_tx_buffer[idx], %.3f,, voltage); // 简单限制防止溢出 if (idx UART_TX_BUFFER_SIZE - 20) break; } // 添加换行 uart_tx_buffer[idx-1] \r; // 替换最后一个逗号 uart_tx_buffer[idx] \n; idx; // 通过DMA发送格式化后的数据 if (HAL_UART_Transmit_DMA(huart1, uart_tx_buffer, idx) ! HAL_OK) { // 发送错误处理例如上次发送未完成可以设置重试或丢弃 } }代码关键点解析双缓冲机制我们利用DMA的“半传输完成”和“全传输完成”中断将adc_dma_buffer在逻辑上分为前后两半。当DMA填充前半部分时CPU可以处理后半部分的数据Process_ADC_Data反之亦然。这避免了处理数据时DMA覆盖正在使用的内存区域是实现高效、无冲突数据流的关键。数据处理与发送分离Process_ADC_Data函数负责将原始的ADC数值0-4095转换为有实际意义的电压值并格式化为字符串。这个处理过程是CPU密集型的浮点运算、sprintf。处理完成后才调用HAL_UART_Transmit_DMA启动异步发送。这样数据处理和USART发送在时间上是重叠的DMA发送时CPU可以处理下一批数据进一步提升了效率。中断标志安全访问在主循环中检查half_buffer_ready和full_buffer_ready标志时我们使用了__disable_irq()和__enable_irq()来保护。因为这两个标志在中断回调函数中被置位如果不加保护在主循环读取标志的“半中间”发生中断可能导致状态判断错误。这是一种简单的临界区保护。错误处理HAL_UART_Transmit_DMA可能因为上一次发送未完成而返回HAL_BUSY。在实际项目中需要更健壮的队列机制来管理待发送的数据包而不是简单地丢弃。4. CPU利用率定量分析与对比测试理论说再多不如实际数据有说服力。我们来设计一个测试量化DMA带来的性能提升。4.1 测试方法设计我们创建两个版本的程序版本A轮询PIO模式在主循环中轮询ADC的转换完成标志EOC读取数据然后轮询USART的发送数据寄存器空标志TXE写入数据。除了必要的延时CPU 100%忙于数据搬运。版本BDMA模式即上面实现的版本。ADC和USART均使用DMACPU仅在半/全缓冲区中断中处理数据格式转换以及执行主循环中的其他任务。测量工具GPIO翻转法在程序开始时将一个GPIO引脚拉高在while(1)主循环末尾将其拉低。用示波器或逻辑分析仪测量该引脚高电平的脉宽其倒数近似等于CPU执行一次主循环的时间。通过分析主循环中不同任务纯空转、仅处理数据、处理数据其他任务时的脉宽变化可以估算CPU在不同阶段的繁忙程度。系统滴答定时器SysTick在while(1)循环开始和结束时读取SysTick-VAL的值计算差值可以精确得到一次循环的CPU时钟周期数。结合循环中执行的任务可以推算出CPU用于核心任务的时间比例。性能分析器如Keil的Event Statistics如果使用MDK Professional版可以直接查看CPU在各部分代码的执行时间占比。4.2 测试场景与数据假设条件ADC采样率10 kHz (每秒10000次转换)。ADC数据位宽12位 (存储为16位)。USART波特率115200 bps。ADC缓冲区大小1024个样本。CPU主频168 MHz。版本A轮询CPU利用率估算每次ADC转换后CPU需要至少执行以下操作检查EOC标志读寄存器、读取ADC-DR读寄存器、检查USART TXE标志读寄存器、写入USART-TDR写寄存器。这至少是4次内存/外设访问指令。在168MHz下执行这些指令大约需要几十个时钟周期。我们保守估计一次完整的“读ADC-写USART”操作需要50个周期。每秒需要执行10000次这样的操作。总周期消耗 10000 * 50 500,000 周期。CPU总可用周期/秒 168,000,000。CPU利用率 ≈ (500,000 / 168,000,000) * 100% ≈ 0.3%。 等等这个数字看起来很低这是因为我们只计算了核心搬运指令。实际上在轮询模式下CPU在“等待”标志位就绪时通常处于忙等待循环while(!(ADC1-SR ADC_SR_EOC));这会消耗巨量的无效周期。真正的轮询模式利用率接近100%因为CPU一直在高速检查标志位几乎不做其他事。上面的估算忽略了等待时间是不准确的。更准确的模型是CPU时间几乎全部花在了等待和搬运上利用率95%。版本BDMACPU利用率估算DMA传输时间DMA传输1024个半字2字节数据。DMA通常每个总线周期传输一个数据宽度16位。在AHB总线168MHz下一次传输约需6ns。1024次传输约需6.1us。这6.1us期间DMA占用总线CPU可能被短暂阻塞周期窃取但影响微乎其微。CPU中断处理时间半传输中断每512个样本一次中断入口、保存上下文、设置标志、退出中断。约需1-2us。全传输中断每1024个样本一次同样约1-2us。每秒中断次数 10000 / 512 10000 / 1024 ≈ 19.5 9.8 ≈ 30次。总中断处理时间 ≈ 30 * 2us 60us。数据处理时间Process_ADC_Data这是大头。假设处理一个样本转换电压、sprintf需要200个CPU周期这是一个非常保守的估计实际sprintf很慢。处理512个样本需要 512 * 200 102,400 周期。在168MHz下耗时约 102,400 / 168,000,000 ≈ 0.61 ms。每秒处理数据次数 10000 / 512 ≈ 19.5次。总数据处理时间 ≈ 19.5 * 0.61ms ≈ 11.9 ms。USART DMA发送此操作由DMA完成CPU仅在启动发送时消耗极少量周期可忽略。总CPU占用时间≈ 中断时间(0.06ms) 数据处理时间(11.9ms) ≈ 12.0 ms。CPU利用率 ≈ (12.0ms / 1000ms) * 100% 1.2%。对比结论轮询模式CPU利用率 95%几乎被数据IO独占无法执行其他有效任务。DMA模式CPU利用率约1.2%节省了超过93%的CPU时间这些时间可以用来运行复杂的控制算法、图形界面、网络协议栈等极大地提升了系统的整体性能和响应能力。实操心得这个估算中数据处理特别是浮点格式转换是主要的CPU消耗点。在实际产品中如果采样率更高或处理算法更复杂这个比例会上升。但即便如此与轮询模式相比DMA带来的性能解放也是数量级的。优化方向如果CPU利用率仍然紧张可以考虑1) 降低不必要的处理精度如用定点数代替浮点数2) 优化数据处理算法3) 使用更高效的传输格式如直接发送二进制数据而非ASCII4) 甚至使用第二个DMA将处理好的数据直接从处理缓冲区搬运到USART发送缓冲区实现“DMA链”进一步解放CPU。5. 常见问题排查与深度优化技巧5.1 DMA传输典型问题与解决方案问题现象可能原因排查步骤与解决方案数据错位如ADC数据高低字节颠倒1. 数据宽度配置错误。2. 存储器/外设地址增量模式错误。3. 字节序大小端问题。1. 检查CubeMX或代码中Data Width设置确保与源/目标匹配ADC半字内存半字。2. 检查Increment Address外设地址不应递增内存地址应递增。3. 对于涉及字节拼接的场景检查芯片的字节序。ARM Cortex-M通常是小端模式。DMA传输不启动或只传输一次1. DMA或外设时钟未使能。2. DMA通道未正确映射到外设请求。3. 传输模式设为Normal而非Circular。4. 外设未正确启动DMA请求如ADC未使能DMA Continuous Requests。1. 在RCC配置中确认DMA和外设时钟已开启。2. 查阅芯片参考手册的DMA请求映射表确认通道选择正确。3. 对于连续传输模式必须为Circular。4. 对于ADC确保调用HAL_ADC_Start_DMA对于USART发送确保调用HAL_UART_Transmit_DMA。传输完成中断不触发1. DMA传输完成中断未使能。2. 中断服务函数IRQHandler未实现或未正确清除中断标志。3. 中断优先级配置过低被其他中断屏蔽。1. 在CubeMX的NVIC设置中勾选对应的DMA通道全局中断或流中断。2. 在stm32f4xx_it.c中确认中断函数存在并在其中调用HAL_DMA_IRQHandler。HAL库会自动清除标志。3. 合理配置中断优先级确保DMA中断能及时响应。USART DMA发送卡住HAL_BUSY1. 上一次DMA发送未完成就发起新的发送。2. DMA或USART状态错误未清除。1. 在发起新发送前检查huart-gState是否为HAL_UART_STATE_READY。或使用非阻塞式队列管理发送请求。2. 在错误回调函数HAL_UART_ErrorCallback中处理错误必要时重新初始化外设。ADC DMA数据更新慢或不连续1. ADC采样时间或转换时间过长跟不上DMA请求节奏。2. DMA总线带宽被更高优先级外设如USB、SDIO大量占用。3. 中断处理函数如半传输中断执行时间过长影响了DMA下一次请求的响应。1. 降低ADC采样时间或降低触发ADC的定时器频率。2. 调整DMA通道优先级或优化高带宽外设的使用。3. 优化中断服务函数只做最必要的标志设置繁重任务放到主循环。5.2 高级优化技巧双缓冲与内存管理双缓冲Double Buffering 我们上面的例子已经使用了基于中断的半缓冲机制这是一种软件双缓冲。更极致的做法是使用DMA硬件双缓冲模式如果芯片支持。在这种模式下DMA控制器有两个内存地址寄存器M0AR和M1AR。当对第一个缓冲区M0AR的传输完成时自动切换到第二个缓冲区M1AR并产生中断。这避免了软件切换缓冲区的延迟数据流更加平滑。内存对齐与Cache一致性 对于高性能芯片如STM32H7如果使用了数据缓存D-Cache需要特别注意DMA操作的内存区域。因为DMA直接访问物理内存而CPU访问的是缓存中的数据副本这会导致数据不一致问题。解决方法将DMA使用的缓冲区定义在非缓存区域通过MPU配置或者在进行DMA操作前后使用SCB_CleanDCache_by_Addr()和SCB_InvalidateDCache_by_Addr()函数清洗和无效化缓存。网络热词中“ddr4 内存 dma 用不了”可能就与Cache配置有关。使用IDLE中断与可变长度接收 对于USART DMA接收可以开启串口IDLE空闲中断。当总线上一段时间没有数据产生IDLE中断时结合DMA当前传输数量计数器CNDTR可以计算出本次接收到的数据包长度从而实现不定长数据包的可靠接收。这是实现高效串口通信协议的常用技巧。DMA与RTOS的协作 在实时操作系统如FreeRTOS中DMA传输完成中断通常用于释放信号量或发送任务通知唤醒等待数据的处理任务。这样可以将数据处理任务完全挂起直到数据就绪进一步节省CPU资源。注意在RTOS中中断服务函数应尽可能短快速通知任务即可繁重的处理交给任务线程。通过以上原理剖析、实战演练和深度优化讨论我们可以看到DMA远不止是一个简单的数据搬运工具。它是构建高效、实时、低功耗嵌入式系统的基石。理解并熟练运用DMA是嵌入式工程师从入门走向精通的关键一步。下次当你设计一个需要频繁数据交换的系统时首先问问自己“这里能用DMA吗”