STM32 DMA实战:从ADC多通道采集到USART通信的完整配置指南
1. 项目概述从“轮询”到“解放CPU”的思维跃迁搞嵌入式开发的朋友尤其是从51、AVR这类8位机转过来的对“轮询”和“中断”这两种处理外设数据的方式一定不陌生。轮询简单粗暴但效率低下CPU大部分时间都在空转等待中断响应及时但频繁进出中断上下文开销也不小尤其是在高速数据流场景下CPU依然会被频繁打断。当我第一次接触STM32看到数据手册里那个叫做DMADirect Memory Access的模块时有种豁然开朗的感觉——原来数据搬运这种“苦力活”真的可以完全甩给一个专门的“搬运工”让CPU这个“大脑”彻底解放出来去处理更复杂的逻辑和算法。简单来说STM32的DMA就是一个独立于Cortex-M3内核的硬件模块它能在存储器和存储器、存储器和外设、外设和外设之间建立直接的数据传输通道。这个过程不需要CPU介入传输完成后发个中断通知一下CPU即可。这就好比你在厨房做饭CPU处理核心算法需要从冰箱存储器拿菜到案板外设寄存器。轮询是你隔几秒就跑过去打开冰箱看一眼中断是冰箱门装了个铃菜到了就响铃叫你而DMA是你雇了个小助手DMA你只需要告诉它“把冰箱里的西红柿和鸡蛋拿到灶台边”它就能自己完成全套动作完事了告诉你一声期间你完全可以专心炒菜。这次我想分享的就是如何在实际项目中特别是数据采集和通信这类对实时性和效率有要求的场景下把STM32的DMA用起来、用好。我会以一个具体的“ADC多通道采集DMA传输USART上报”实例为线索拆解DMA配置的每一个步骤解释其背后的设计逻辑并穿插我在调试过程中踩过的坑和总结的经验。无论你是刚接触STM32的新手还是想优化现有项目性能的老鸟希望这篇内容都能带来一些切实的参考。2. DMA核心机制与设计思路解析在动手写代码之前我们必须先理解DMA是怎么工作的以及STM32为它设计了怎样的规则。这能帮助我们在后续配置时清楚地知道每一个参数设置的意义而不是机械地照抄代码。2.1 DMA的“桥梁”模型与通道概念你可以把STM32内部的DMA控制器想象成一个立交桥系统而数据是上面跑的车。这个立交桥有多个固定的“入口”和“出口”也就是外设如ADC、USART、SPI、I2C、TIMER等的数据寄存器。DMA控制器提供了多条独立的“车道”这就是DMA通道Channel。以STM32F103系列为例它有两个DMA控制器DMA1和DMA2共提供了12个通道DMA1有7个DMA2有5个。每个通道在某个时刻只能连接一个“入口”和一个“出口”。关键点在于通道与外设的映射是固定的不是你想用哪个通道接ADC就用哪个。例如DMA1的通道1固定用于ADC1的数据传输通道4固定用于USART1_TX通道5固定用于USART1_RX。这个映射关系在芯片的参考手册中有详细的表格配置前必须查清楚。这种硬件固定连接的设计简化了仲裁逻辑保证了数据传输的确定性。2.2 数据传输的三种模式与方向DMA传输有三个核心要素源Source、目标Destination、数据量Data Size。根据源和目标的组合传输主要分为三种模式外设到存储器Peripheral-to-Memory最常见的一种比如ADC转换完成后数据自动从ADC数据寄存器外设搬运到你定义的数组存储器中。存储器到外设Memory-to-Peripheral也很常用比如你需要通过USART发送一大段数据可以把数据先放在数组里然后启动DMA将数组内容自动搬运到USART发送数据寄存器。存储器到存储器Memory-to-Memory这是DMA1才支持的功能DMA2不支持。可以在两个内存区域之间快速拷贝数据比如从一个缓冲区拷贝到另一个缓冲区速度远高于CPU用循环搬运。传输方向DMA_DIR就是用来设置这个的。此外DMA传输有两种工作模式普通模式Normal ModeDMA在传输完预设的数据量比如100个字节后会自动停止需要软件重新使能才能进行下一次传输。适合非连续、触发式的场景。循环模式Circular Mode传输完预设数据量后DMA会自动重置传输计数器从头开始下一轮传输周而复始。这是实现连续、无缝数据缓冲的关键。比如ADC连续采样用循环模式DMA数据就会源源不断地填满你的缓冲区形成一个“乒乓”缓冲或环形队列CPU只需要定期来处理缓冲好的数据块即可。2.3 地址指针的递增逻辑这是DMA配置中容易混淆但至关重要的部分。DMA有两个地址指针外设基地址指针DMA_PeripheralBaseAddr和存储器基地址指针DMA_MemoryBaseAddr。它们指向传输的起点。外设地址递增DMA_PeripheralInc通常禁用Disable。因为外设的数据寄存器地址是固定的。比如ADC1的数据寄存器ADC1-DR地址不会变每次传输都从这个固定地址读或写。存储器地址递增DMA_MemoryInc通常启用Enable。因为我们要把数据存放到一个数组里或者从一个数组里依次取出数据。DMA每搬运完一个数据单元比如一个16位的半字存储器的地址指针会自动增加指向数组的下一个元素。如果你只想往同一个内存地址反复写入比如作为一个实时显示的最新值则可以禁用递增。2.4 数据宽度与缓冲区大小数据宽度DMA_PeripheralDataSize和DMA_MemoryDataSize必须根据源和目标的实际情况设置且通常两者应保持一致。常见的有字节Byte8位、半字HalfWord16位、字Word32位。例如STM32的ADC是12位精度数据放在一个16位的寄存器里所以数据宽度应设为半字。缓冲区大小DMA_BufferSize的单位是“数据项”的数量而不是字节数。如果你设置数据宽度为半字BufferSize设为100那么DMA会传输100个半字总计200字节。这个参数决定了DMA在传输多少数据后产生传输完成中断如果使能了的话。注意BufferSize的值在初始化时写入传输开始后DMA通道的CNDTR寄存器会作为递减计数器。当它减到0如果工作在循环模式会自动重载初始值如果在普通模式则传输停止。读取这个寄存器可以知道还剩多少数据未传输。3. 实战配置ADC多通道采集与USART上报理论铺垫完毕我们进入实战环节。我将构建一个经典的应用场景使用STM32的ADC1以DMA循环模式采集两个通道例如通道0和通道1的模拟电压并将采集到的数据通过USART1实时发送到上位机显示。3.1 系统架构与流程设计整个系统的数据流如下ADC配置为扫描模式、连续转换并启用DMA请求。DMA配置为循环模式从ADC数据寄存器固定地址搬运数据到内存中的一个二维数组ADC_ConvertedValue[2]。ADC启动后会自动连续进行多通道扫描转换每转换完一个通道的数据就触发一次DMA请求。DMA响应请求将数据搬运到数组的对应位置。由于是循环模式这个搬运过程永不停歇数组里的数据始终是最新的采样值。主循环或定时器中断中定期例如每秒将ADC_ConvertedValue数组中的最新值通过USART发送出去。这里为了简化我们也可以在DMA传输完成一半或全部完成的中断里处理但此例我们先在主循环处理。3.2 外设与DMA的详细配置步骤下面我们分模块详细解释每个配置函数及其参数的意义。3.2.1 GPIO与时钟配置任何外设使用前时钟必须开启。DMA本身也是一个外设。// 使能各模块时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1 | RCC_APB2Periph_USART1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // DMA时钟在AHB总线别忘记 RCC_ADCCLKConfig(RCC_PCLK2_Div6); // 设置ADC时钟不能超过14MHz踩坑记录这是我最初疏忽的地方。习惯了APB2、APB1上的外设时钟很容易忽略DMA的时钟在AHB总线上。如果没开RCC_AHBPeriph_DMA1DMA配置得再对也无法工作调试时会发现数据根本不动。务必检查时钟树确保所有参与模块的时钟都已使能。ADC通道对应的GPIO需要设置为模拟输入模式。GPIO_InitTypeDef GPIO_InitStructure; // PA0, PA1 作为ADC输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; // 模拟输入 GPIO_Init(GPIOA, GPIO_InitStructure); // USART1 TX(PA9) RX(PA10) 配置此处省略...3.2.2 DMA配置详解这是核心部分我们对照结构体成员逐一分析。DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA_Channel1); // 复位DMA1通道1使其恢复默认状态 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(ADC1-DR); // 源地址ADC1数据寄存器的地址。这是一个固定的外设寄存器地址。 // 使用(ADC1-DR)是库函数提供的获取外设寄存器地址的安全方法。 DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)ADC_ConvertedValue; // 目标地址内存中数组的首地址。ADC_ConvertedValue需要定义为全局数组如 uint16_t ADC_ConvertedValue[2]; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 传输方向外设为源Peripheral Source即从ADC到内存。 DMA_InitStructure.DMA_BufferSize 2; // 缓冲区大小我们采集2个通道所以设为2。单位是“数据项”具体宽度由下面的参数决定。 DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; // 外设地址不递增。ADC1-DR的地址是固定的。 DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; // 内存地址递增。因为我们有两个数据要存到数组的不同位置。 DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; // 数据宽度ADC数据寄存器是16位实际有效位12位所以用半字。 DMA_InitStructure.DMA_Mode DMA_Mode_Circular; // 模式循环模式。ADC连续转换DMA就连续搬运。 DMA_InitStructure.DMA_Priority DMA_Priority_High; // 优先级如果有多个DMA通道同时请求高优先级的先服务。这里设为高。 DMA_InitStructure.DMA_M2M DMA_M2M_Disable; // 禁用存储器到存储器模式。我们是从外设到存储器。 DMA_Init(DMA_Channel1, DMA_InitStructure); // 初始化通道1 DMA_Cmd(DMA_Channel1, ENABLE); // 使能DMA通道1关键点解析DMA_BufferSize2与DMA_MemoryInc_Enable配合实现了将通道0的数据存到ADC_ConvertedValue[0]通道1的数据存到ADC_ConvertedValue[1]。DMA会严格按照这个顺序搬运。循环模式下当搬运完第2个数据BufferSize减到0DMA会自动将CNDTR重置为2并从内存基地址数组开头重新开始形成环形缓冲。这意味着数组里的数据会被持续覆盖更新。3.2.3 ADC配置与DMA关联ADC需要配置为支持DMA请求的模式。ADC_InitTypeDef ADC_InitStructure; ADC_DeInit(ADC1); // 复位ADC1 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; // 独立模式 ADC_InitStructure.ADC_ScanConvMode ENABLE; // 启用扫描模式多通道必须 ADC_InitStructure.ADC_ContinuousConvMode ENABLE; // 启用连续转换模式 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; // 软件触发 ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; // 数据右对齐 ADC_InitStructure.ADC_NbrOfChannel 2; // 转换的通道数为2 ADC_Init(ADC1, ADC_InitStructure); // 配置ADC通道0和1的转换顺序和采样时间 ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); // 使能ADC的DMA请求这是连接ADC和DMA的关键一步。 ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); // 使能ADC1 // 校准ADC建议步骤 ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 软件启动连续转换核心连接ADC_DMACmd(ADC1, ENABLE);这行代码至关重要。它告诉ADC模块“当你每次转换完成时不要只是把数据放在DR寄存器里还要同时向DMA控制器具体是映射好的DMA1_Channel1发出一个传输请求。” 这样DMA和ADC的硬件协作链路就打通了。3.2.4 USART配置用于数据输出USART配置相对标准我们使用查询方式发送数据。USART_InitTypeDef USART_InitStructure; USART_InitStructure.USART_BaudRate 9600; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE);3.3 主程序逻辑与数据流验证外设和DMA都配置好后主程序就非常简洁了。uint16_t ADC_ConvertedValue[2]; // DMA目标数组 char usart_send_buf[64]; // 串口发送缓冲区 int main(void) { // 系统时钟、GPIO、DMA、ADC、USART初始化调用上述配置函数 SystemInit(); GPIO_Configuration(); DMA_Configuration(); ADC_Configuration(); USART_Configuration(); while(1) { // 简单的延时模拟其他任务处理或使用定时器 Delay_ms(1000); // 将DMA自动更新的ADC值通过USART发送出去 sprintf(usart_send_buf, CH0: %04d, CH1: %04d\r\n, ADC_ConvertedValue[0], ADC_ConvertedValue[1]); for(int i0; usart_send_buf[i]!\0; i) { USART_SendData(USART1, usart_send_buf[i]); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); } } }运行现象上电后ADC会以最高优先级由ADC采样时间决定连续对PA0和PA1进行采样每完成一个点的采样DMA就立即将其搬运到对应的数组元素中。主循环每隔1秒读取一次这个数组并将格式化后的字符串通过串口发送。你在串口助手上会看到每秒刷新一次的两个通道的ADC原始值。改变PA0或PA1的输入电压例如用电位器下一次发送的数据就会随之改变。实测心得这种架构下CPU利用率极低。主循环中的Delay_ms(1000)期间ADC和DMA一直在后台全速工作互不干扰。你可以把延时换成复杂的算法任务系统的实时数据采集也完全不受影响。这就是DMA带来的真正优势——并行处理与确定性响应。4. 进阶技巧与深度优化掌握了基本用法后我们可以探讨一些更高级的用法和优化策略让DMA发挥更大威力。4.1 双缓冲Ping-Pong Buffer与半传输中断在循环模式下DMA会不断覆盖缓冲区。如果CPU处理数据的速度慢于DMA填充的速度就会发生数据丢失新数据覆盖了还未被处理的老数据。双缓冲是解决这个问题的经典方案。思路是准备两个一样大的缓冲区BufferA和BufferB。DMA配置为循环模式但BufferSize设为单个缓冲区的大小。同时使能DMA的半传输完成中断HT和传输完成中断TC。当DMA填充了半个缓冲区例如BufferA的前一半时产生HT中断。在中断服务程序ISR中CPU可以安全地处理BufferB此时DMA正在填充BufferA。当DMA填充完整个缓冲区例如BufferA时产生TC中断。在ISR中CPU可以安全地处理BufferA此时DMA已经切换到填充BufferB的前一半。这样DMA和CPU交替使用两个缓冲区实现了无锁、无数据竞争的高效数据流。这在音频处理、高速数据采集等场景非常有用。配置关键// 使能DMA中断 DMA_ITConfig(DMA_Channel1, DMA_IT_TC | DMA_IT_HT, ENABLE); // 在NVIC中配置DMA通道中断 NVIC_InitStructure.NVIC_IRQChannel DMA1_Channel1_IRQn; ... NVIC_Init(NVIC_InitStructure); // 在中断服务函数中 void DMA1_Channel1_IRQHandler(void) { if(DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); // 处理“传输完成”对应的缓冲区例如BufferA ProcessBuffer(BufferA); } if(DMA_GetITStatus(DMA1_IT_HT1)) { DMA_ClearITPendingBit(DMA1_IT_HT1); // 处理“半传输完成”对应的缓冲区例如BufferB ProcessBuffer(BufferB); } }4.2 与外设触发器的联动上面的例子ADC使用了连续转换、软件触发。但在很多场景我们希望ADC的转换也就是DMA的传输由特定事件精确触发比如定时器TIM的更新事件。这样可以实现固定频率的采样对于数字信号处理DSP至关重要。配置步骤配置一个定时器如TIM2产生固定频率的更新事件UEV。将ADC的触发源ADC_ExternalTrigConv设置为该定时器例如ADC_ExternalTrigConv_T2_TRGO。配置定时器以触发TRGO输出。ADC配置为单次转换模式ADC_ContinuousConvMode DISABLE或扫描模式但由外部触发。DMA模式可以设为普通模式DMA_Mode_Normal每次ADC转换完成触发一次DMA搬运。也可以设为循环模式定时器会周期性地触发ADC和DMA。这样每次定时器“滴答”ADC就采样一次DMA随之搬运一次数据。采样率由定时器频率精确控制数据同步性极好。4.3 存储器到存储器模式的应用虽然DMA1的M2M模式不涉及外设但在某些内存拷贝任务繁重的场景下能大幅提升效率。例如图像处理中拷贝帧缓冲区或者快速初始化一大片内存区域。配置时源和目标地址都设置为内存地址并使能地址递增。传输模式通常为普通模式传输完成后产生中断通知CPU。需要注意的是M2M模式的传输速度可能不如外设触发模式快因为它需要占用总线带宽且优先级可能较低但对于大数据块拷贝依然远胜于CPU的memcpy。5. 常见问题排查与调试心得即使理解了原理实际调试中还是会遇到各种问题。下面是我总结的一些常见“坑点”和排查思路。5.1 DMA不传输数据这是最常见的问题。请按以下清单逐一核对排查项可能原因解决方法时钟DMA或相关外设时钟未使能检查RCC_AHBPeriphClockCmd和对应外设的APB时钟是否已开启。通道映射使用了错误DMA通道查阅芯片参考手册的DMA请求映射表确认外设对应的DMA控制器和通道号。外设DMA使能未在外设端启用DMA功能调用ADC_DMACmd,USART_DMACmd等函数使能外设的DMA请求输出。DMA使能顺序先使能了DMA后启动外设建议顺序初始化DMA - 初始化外设 - 使能外设DMA请求 - 使能DMA通道 - 启动外设工作如ADC开始转换。缓冲区地址存储器地址设置错误确保DMA_MemoryBaseAddr指向有效的内存区域如全局数组。检查数组是否被编译器优化掉可加volatile关键字。传输计数器DMA_BufferSize设为0检查初始化值必须大于0。传输开始后DMA_CNDTRx寄存器会从该值递减。中断冲突高优先级中断长时间阻塞检查是否有其他中断服务程序执行时间过长导致DMA请求得不到及时响应。5.2 数据错位或乱码表现为接收到的数据不是预期的顺序或值。地址递增配置错误最常见。如果DMA_MemoryInc设为Disable但希望数据存到数组那么所有数据都会写到同一个地址只有最后一个数据有效。反之如果外设地址递增被错误启用则会从错误的外设寄存器地址读数。数据宽度不匹配源和目标的DataSize设置不一致。例如ADC是16位数据内存设置为8位会导致数据被截断或组合错误。缓冲区大小不足BufferSize小于实际需要传输的数据项数量。在循环模式下这会导致数据覆盖逻辑混乱。外设数据寄存器特性有些外设的数据寄存器读取后会自动清除标志。如果同时用DMA和中断或轮询去读同一个寄存器可能会出问题。确保数据通路唯一。5.3 性能优化要点总线仲裁DMA与CPU共享总线AHB、APB。当它们同时访问同一块内存或外设时总线仲裁器会根据优先级决定谁先使用。给DMA通道设置更高的优先级DMA_Priority_High/VeryHigh可以确保数据流不被CPU访问过多打断。但也要注意过高的DMA优先级可能阻塞CPU影响系统整体响应。内存对齐如果可能将DMA使用的缓冲区地址按照数据宽度对齐如半字对齐到2字节边界字对齐到4字节边界。某些架构的非对齐访问会导致额外的时钟周期降低性能。使用__align关键字在定义DMA缓冲区数组时可以使用__align(4)对于IAR或__attribute__((aligned(4)))对于GCC来强制编译器进行内存对齐这对发挥DMA和CPU缓存的最佳性能有益。关闭缓存如果使用了带Cache的Cortex-M7等高端内核并且DMA缓冲区会被DMA和CPU共同访问需要特别注意缓存一致性问题。通常需要将缓冲区所在的内存区域设置为非缓存Non-Cacheable或使用缓存维护操作Clean/Invalidate否则会出现CPU读到旧数据或DMA写到错误位置的问题。调试技巧在初期可以不用中断而是在主循环中打印DMA通道的当前传输计数器DMA_CNDTRx和缓冲区内的数据。观察计数器是否在递减数据是否在更新可以快速定位问题是出在DMA传输本身还是出在数据读取环节。利用IDE的实时变量观察窗口Live Watch监视DMA相关的寄存器也是极好的调试手段。回顾整个DMA的应用它不仅仅是节省CPU时间的工具更是一种设计思维的转变——从“CPU中心论”转向“数据流中心论”。在设计系统时我们开始更多地思考数据如何自动、高效地在各个硬件模块间流动而CPU则退居幕后扮演调度者和处理者的角色。这种思维对于构建高效、实时的嵌入式系统至关重要。从我个人的经验来看一旦熟练掌握了DMA你就会发现很多以前觉得棘手的高速数据吞吐问题突然有了优雅的解决方案。