STM32 DMA+ADC多通道采集:解放CPU的高效数据搬运方案

📅 2026/7/30 4:24:19 👁️ 阅读次数 📝 编程学习
STM32 DMA+ADC多通道采集:解放CPU的高效数据搬运方案

1. 项目概述:DMA与ADC多通道采集的强强联合

在嵌入式开发,尤其是基于STM32这类MCU的数据采集项目中,我们常常会遇到一个经典矛盾:CPU的算力是宝贵的,而像模数转换器(ADC)这样的外设一旦启动,就会源源不断地产生数据。如果让CPU亲自去搬运每一个转换完成的数据,无异于让总经理去干快递员的活,效率低下且严重浪费核心资源。这时,直接存储器访问(DMA)技术就成了我们的“王牌快递员”。本次分享的项目核心,就是如何利用STM32的DMA功能,高效、自动地搬运多通道ADC采集到的数据,实现“数据转运”,从而彻底解放CPU。

简单来说,这个项目要解决的问题是:如何让ADC同时(或交替)采集多个通道的模拟信号(比如多个传感器的电压),并让DMA自动、无声无息地把转换结果搬运到我们指定的内存数组中,整个过程无需CPU干预。这对于需要连续、高速采集多路信号的场景至关重要,例如环境监测(多路温湿度、光照)、电机控制(多相电流检测)、音频处理等。通过DMA转运,CPU只需要在数据积累到一定量(或采集完成)后,一次性处理整块内存数据即可,极大地提升了系统效率和实时性。

2. 核心思路与方案设计解析

2.1 为什么是“DMA + ADC多通道”?

在STM32中,ADC多通道采集通常有两种模式:扫描模式非扫描模式。非扫描模式一次只转换一个指定的通道,显然不适合我们的多路需求。因此,我们必须启用扫描模式。

在扫描模式下,ADC会按照我们预先配置好的通道序列(比如通道1、2、3、4)依次进行转换。这里的关键问题来了:每个通道转换完成后,结果都会放在同一个数据寄存器(ADCx->DR)里。如果我们只用ADC中断,在中断服务函数里读取DR寄存器,那么当通道2的数据覆盖了通道1的数据时,我们就丢失了通道1的数据。除非你在每个通道转换完成后都进一次中断并立刻读取,但这会引入大量中断开销,同样不高效。

DMA的引入完美解决了这个“数据覆盖”和“CPU搬运”的双重难题。我们可以将ADC的DMA请求与扫描模式绑定。配置为:每次ADC转换完成(无论是哪个通道),都触发一次DMA传输,将ADC数据寄存器(DR)中的值,搬运到内存中的一个数组里。这样,通道1的结果被搬到数组[0],通道2的结果到数组[1],依次类推,形成一个自动、有序的数据流水线。

2.2 方案选型:单次、连续与循环模式

STM32的ADC和DMA提供了多种组合,需要根据应用场景仔细选择:

  1. ADC单次扫描 + DMA单次传输

    • 工作流程:启动一次ADC,ADC按序列扫描完所有配置的通道(每个通道转换一次),每转换完一个通道就触发DMA搬运一次。所有通道扫描完毕后,ADC停止,DMA也完成了指定数据量的搬运。
    • 适用场景:需要按需采集,比如每秒采集一组数据。需要CPU每次手动启动ADC。
  2. ADC连续扫描 + DMA循环传输(本项目推荐方案)

    • 工作流程:启动一次ADC后,ADC会连续不断地循环扫描所有通道。同时,将DMA配置为循环模式,并设置传输数据总量为“通道数 x 期望的缓冲区大小”。DMA会周而复始地将ADC数据搬运到指定的内存缓冲区。
    • 优势:实现了真正的“后台”连续采集。CPU完全不用管数据怎么来的,只需要定期去处理缓冲区里已经积累好的数据即可。这是最常用、最省心的方案。
    • 内存布局:通常我们会定义一个二维数组或一个一维大数组作为缓冲区。例如,采集4个通道,希望缓存100组数据,可以定义uint16_t ADC_Value[100][4]。DMA的目标地址指向这个数组的首地址,每搬运一个数据,地址自动递增。当DMA搬运完100*4=400个数据后,由于其工作在循环模式,它会自动回到数组开头重新开始搬运,覆盖旧数据。这种结构也叫乒乓缓冲区环形缓冲区,CPU在处理前半部分数据时,DMA可以往后半部分写入,互不干扰。
  3. ADC连续扫描 + DMA单次传输(配合中断)

    • 这种模式不常用,因为DMA单次传输完就停止了,而ADC还在连续产生数据,后续的数据会丢失。通常需要DMA传输完成中断中,重新配置DMA计数并启动,实现软件层面的循环,不如直接使用DMA硬件循环模式方便。

本项目将采用第二种方案:ADC连续扫描 + DMA循环传输。这是平衡性能、效率和实现复杂度的最佳实践。

2.3 外设与引脚依赖关系梳理

要实现这个功能,我们需要配置和联动三个主要的STM32内部外设:

  1. ADC(模数转换器):负责将模拟信号转换为数字值。需要配置其工作模式(连续扫描)、采样时间、通道序列、触发源(通常用软件触发启动,然后靠自身连续运行),并使能DMA请求
  2. DMA控制器:负责数据搬运。需要配置其通道(与特定的ADC数据流绑定)、传输方向(外设到存储器)、数据宽度(与ADC分辨率对齐,如12位ADC对应16位半字)、地址是否递增(外设地址不增,存储器地址递增)、循环模式。
  3. GPIO:将需要采集模拟信号的引脚配置为模拟输入模式。例如,如果使用ADC1的通道1、2、3、4,就需要将PA1、PA2、PA3、PA4(对于大多数STM32F1/F4系列)配置为模拟输入。

它们之间的关系是:ADC作为DMA的“请求方”,GPIO作为ADC的“信号输入方”。CPU的职责仅仅是初始化这些外设,然后启动整个数据流水线。一旦启动,ADC->DMA->Memory这条数据通路就完全自主运行。

3. 关键配置详解与实操要点

3.1 ADC模块的配置细节

ADC的配置是整个功能的基础,有几个关键参数需要深刻理解:

  • 扫描模式(Scan Mode):必须启用。此模式使ADC按照规则序列寄存器(SQR)或注入序列寄存器(JSQR)中设定的通道顺序进行转换。
  • 连续转换模式(Continuous Conversion Mode):必须启用。这样ADC在完成一次序列扫描后,会自动开始下一次扫描,实现不间断采集。
  • DMA请求(DMA Requests):必须启用。对于规则通道组(我们常用的多通道采集都是规则通道),需要使能ADC_CR2寄存器中的DMA位(对于F1系列)或使用ADC_CR2中的DDS位并使能DMA流(对于F4/H7系列)。这告诉ADC:“你每转换完一个数据,就去叫DMA来搬。”
  • 数据对齐(Data Alignment):ADC转换结果在数据寄存器中的对齐方式。通常选择右对齐。对于一个12位的ADC,转换结果范围是0~4095。右对齐时,这个12位的值存放在16位数据寄存器的低12位,高4位为0,我们直接读取16位值然后与0xFFF掩码运算即可得到真实值。这简化了数据处理。
  • 采样时间(Sampling Time):每个通道对输入电压进行采样的时间长度。时间越长,采样越准确,抗噪声能力越强,但转换速率会降低。需要根据信号源的内阻和精度要求来权衡。对于一般的传感器(如电位器、温度传感器),使用ADC时钟周期数中等(如239.5周期)的配置即可。
  • 规则通道序列(Regular Sequence):这是配置多通道顺序的地方。你需要指定总转换通道数(SQR1[L]),以及每个转换顺序对应的通道编号(SQR3, SQR2, SQR1中的SQx位域)。顺序决定了DMA搬运到内存数组中的顺序。

注意:在STM32CubeMX或标准外设库中配置时,务必注意通道的“Rank”(顺序)。你添加通道的顺序就是它的Rank,也就是它在扫描序列和最终内存数组中的位置。

3.2 DMA通道的配置核心

DMA的配置要确保其能正确响应ADC的呼叫,并准确无误地搬运到指定位置。

  • 通道/数据流选择:这是最容易出错的地方。不同的ADC(ADC1, ADC2, ADC3)对应着不同的DMA控制器(DMA1, DMA2)和不同的通道/数据流。必须查阅芯片的参考手册(Reference Manual)中的“DMA请求映射”表格。例如,STM32F103系列,ADC1的DMA请求对应DMA1的通道1。STM32F407系列,ADC1对应DMA2的数据流0/通道0。选错通道,DMA将永远不会被触发。
  • 传输方向:配置为外设到存储器。外设地址是ADC数据寄存器(如&ADC1->DR)的地址,存储器地址是我们定义的数组(如ADC_Value)的地址。
  • 外设地址不递增,存储器地址递增:ADC的数据始终来自同一个寄存器(DR),所以外设地址固定。而我们要把数据依次存放到数组的不同位置,所以存储器地址需要在每次传输后自动增加。增量大小由数据宽度决定(半字传输,地址+2)。
  • 数据宽度:外设和存储器的数据宽度都要设置为半字(16位),以匹配ADC数据寄存器的实际有效宽度(即使ADC是12位,它也占用16位寄存器)。
  • 循环模式:这是实现连续采集的关键。使能循环模式后,当DMA传输完指定的数据量(NDTR寄存器值),它会自动重置传输计数器并从头开始,继续响应ADC的请求进行搬运,形成一个闭环。
  • 传输数据量:这个值(NDTR)需要仔细计算。它代表DMA在重新循环前要完成多少次传输。在我们的方案里,这个值应该设置为缓冲区总大小 = 通道数 × 每组数据深度。例如,4个通道,想缓存100组数据,那么NDTR = 4 * 100 = 400。这意味着DMA每搬运400个ADC数据(即100轮完整的4通道扫描),指针就回到数组开头。

3.3 内存缓冲区设计策略

内存缓冲区的设计直接影响CPU处理数据的方便程度。

  • 一维数组 vs 二维数组
    • 一维大数组uint16_t ADCBuffer[400];。简单直接,DMA配置简单。但CPU处理时,需要自己计算偏移量来分离不同通道的数据。例如,第i组数据的通道1在ADCBuffer[i*4],通道2在ADCBuffer[i*4+1]
    • 二维数组uint16_t ADCBuffer[100][4];。逻辑更清晰,ADCBuffer[n][0]直接就是第n组数据的通道1。但需要注意内存布局!在C语言中,二维数组在内存中是按行连续存储的。ADCBuffer[0][0], ADCBuffer[0][1], ADCBuffer[0][2], ADCBuffer[0][3], ADCBuffer[1][0]...这个顺序正好符合ADC扫描顺序(先通道1,2,3,4,再下一轮)。因此,我们可以将DMA的目标地址指向&ADCBuffer[0][0],并设置NDTR=100*4=400,就能完美匹配。
  • 双缓冲区(Ping-Pong Buffer)进阶技巧:对于极高速度或要求数据无丢失的处理,可以设置两个一样大的缓冲区(A和B)。DMA配置为循环模式,但目标地址在两个缓冲区之间切换(通常利用DMA的双缓冲区模式或传输完成中断来手动切换)。当DMA在向缓冲区A写入时,CPU处理缓冲区B的数据,反之亦然。这实现了数据生产和消费的完全解耦,避免了处理速度跟不上采集速度时数据被覆盖的问题。对于大多数应用,简单的循环缓冲区已足够。

4. 基于HAL库的完整实现步骤

下面以STM32F407芯片,使用ADC1采集3个通道(IN1, IN2, IN3),CubeMX配合HAL库为例,展示实现步骤。

4.1 CubeMX图形化配置

  1. 引脚配置:找到并启用ADC1。在Analog下拉菜单中,将PA1(ADC123_IN1), PA2(ADC123_IN2), PA3(ADC123_IN3)配置为ADC1_IN1,ADC1_IN2,ADC1_IN3。CubeMX会自动将其设置为模拟模式。
  2. ADC1参数配置
    • Mode: Independent mode (独立模式)。
    • Scan Conversion Mode: Enabled (启用扫描模式)。
    • Continuous Conversion Mode: Enabled (启用连续转换模式)。
    • DMA Continuous Requests: Enabled (对于F4系列,使能DMA连续请求,确保扫描模式下的连续DMA传输)。
    • End Of Conversion Selection: EOC flag after each conversion (每次转换后产生EOC标志,这通常也是触发DMA请求的条件)。
    • Low Power Auto Wait: Disabled。
    • Resolution: 12位。
    • Data Alignment: Right alignment (右对齐)。
    • Sampling Time: 根据需要选择,例如Cycles 84
    • Rank设置:在Parameter SettingsRegular Conversion Mode下,点击Add添加通道。添加IN1,IN2,IN3。这里的Rank顺序就是1,2,3。采样时间可以统一设置,也可以为每个Rank单独设置。
  3. DMA配置
    • DMA Settings标签页点击Add
    • DMA Request: 选择ADC1
    • Stream: 根据数据手册选择,对于ADC1通常是DMA2 Stream0Stream4。查看Mode列确认是ADC1
    • Direction: Peripheral To Memory。
    • Priority: Medium。
    • Mode: Circular (循环模式)。
    • Increment Address: Peripheral端选Disable,Memory端选Enable
    • Data Width: Peripheral和Memory都选Half Word(16位)。

4.2 生成代码与用户程序编写

生成代码后,在IDE(如Keil)中进行补充。

  1. 定义全局缓冲区

    // 假设采集3个通道,缓存50组数据 #define ADC_CHANNEL_NUM 3 #define ADC_BUFFER_DEPTH 50 uint16_t g_adc_dma_buffer[ADC_BUFFER_DEPTH][ADC_CHANNEL_NUM]; // 二维数组,逻辑清晰
  2. 启动DMA的ADC采集: 在main.c/* USER CODE BEGIN 2 */部分,启动ADC的DMA传输。

    // 启动ADC,并通过DMA将数据搬运到指定缓冲区 if (HAL_ADC_Start_DMA(&hadc1, (uint32_t*)g_adc_dma_buffer, // 转换为uint32_t指针,HAL库要求 ADC_BUFFER_DEPTH * ADC_CHANNEL_NUM) != HAL_OK) { Error_Handler(); // 启动失败处理 }

    关键点HAL_ADC_Start_DMA函数的第二个参数需要uint32_t*类型,但我们的缓冲区是uint16_t类型。这里进行强制类型转换是安全的,因为本质上我们传递的是内存起始地址。第三个参数是总数据单元数,即NDTR寄存器的值,必须是通道数 × 缓冲区深度

  3. 数据处理: 由于DMA在后台循环工作,g_adc_dma_buffer中的数据会不断被更新。我们可以在主循环中,或者在一个定时器中断里,定期读取和处理这些数据。

    // 示例:在主循环中计算最近一轮采集的三个通道的平均值 void ProcessADCData(void) { static uint32_t last_processed_index = 0; uint32_t current_dma_index; // 获取DMA当前还剩多少数据未传输(NDTR寄存器值) // 这个值从初始值递减。当前写入位置 = 总大小 - 剩余计数 current_dma_index = (ADC_BUFFER_DEPTH * ADC_CHANNEL_NUM) - __HAL_DMA_GET_COUNTER(hadc1.DMA_Handle); // 防止索引越界计算 if(current_dma_index >= ADC_BUFFER_DEPTH * ADC_CHANNEL_NUM) { current_dma_index = 0; } // 计算上一轮完整数据的位置 // 假设我们想处理DMA刚刚写完的上一组完整数据(3个值) // 找到这组数据的起始索引。由于DMA是连续写的,我们需要根据当前索引倒推。 // 更稳健的方法是设置一个“数据就绪”标志,在DMA传输完成一半中断(HT)或完成中断(TC)中设置。 // 这里演示一个简单逻辑:定期处理固定位置的数据(例如,总是处理缓冲区开头的一组) // 因为DMA是循环的,旧数据会被覆盖,所以我们处理的数据需要是“相对新鲜”的。 uint32_t process_start_index = 0; // 简单起见,每次都处理缓冲区最开头的一组数据 // 但实际上,由于DMA在循环写入,直接取开头可能正在被写入。更安全的方法是使用双缓冲区或索引比较。 uint16_t ch1_val = g_adc_dma_buffer[0][0]; // 第一组数据,通道1 uint16_t ch2_val = g_adc_dma_buffer[0][1]; // 第一组数据,通道2 uint16_t ch3_val = g_adc_dma_buffer[0][2]; // 第一组数据,通道3 // 将ADC值转换为电压 (假设Vref=3.3V) float voltage_ch1 = (ch1_val * 3.3f) / 4095.0f; float voltage_ch2 = (ch2_val * 3.3f) / 4095.0f; float voltage_ch3 = (ch3_val * 3.3f) / 4095.0f; // ... 后续使用这些电压值 ... printf("CH1: %.2fV, CH2: %.2fV, CH3: %.2fV\r\n", voltage_ch1, voltage_ch2, voltage_ch3); }

    重要提示:上面的数据处理示例ProcessADCData函数中的索引计算是简化的,直接读取数组开头存在数据竞争风险(CPU读的时候DMA可能正在写)。这是裸机编程中一个经典的并发问题。更安全的做法有两种:

    1. 使用DMA中断:使能DMA的“传输完成中断”或“半传输中断”。在半传输中断中,处理缓冲区的前一半数据;在传输完成中断中,处理缓冲区的后一半数据。这样CPU和DMA操作的是缓冲区的不同部分,避免了冲突。HAL库提供了HAL_ADC_ConvHalfCpltCallbackHAL_ADC_ConvCpltCallback回调函数。
    2. 使用索引原子性判断:定义一个volatile的写索引,在DMA中断中更新它。CPU读取数据时,只读取小于该写索引的“已稳定”数据区。这种方法更灵活但稍复杂。

5. 调试技巧与常见问题排查

5.1 调试基础:确认配置与信号

  • 验证ADC引脚配置:首先用万用表或示波器确认模拟输入引脚上有正确的电压信号。
  • 检查CubeMX配置:反复核对ADC的扫描模式、连续模式、DMA使能是否打开;DMA的通道、方向、循环模式、数据宽度、地址递增是否正确。
  • 使用调试器查看寄存器:在调试模式下,暂停芯片,查看:
    • ADCx->CR2:确认ADONCONTDMADDS等位是否置位。
    • DMAy_Streamx->CR:确认EN位是否使能,CIRC位(循环模式)、DIR位(方向)、PSIZE/MSIZE(数据宽度)、PINC/MINC(地址递增)是否正确。
    • DMAy_Streamx->NDTR:查看剩余传输计数是否在递减,这是判断DMA是否被触发的直接证据。
    • ADCx->DR寄存器:在ADC单次转换模式下,可以手动触发一次转换,看这个寄存器是否有值变化。在连续模式下,这个值会快速变化,不易观察。

5.2 典型问题与解决方案

问题现象可能原因排查步骤与解决方案
DMA不搬运数据,缓冲区全为01. DMA通道未正确映射到ADC。
2. ADC的DMA请求未使能。
3. ADC未启动或触发。
4. DMA配置错误(如方向、数据宽度)。
1.核对参考手册的DMA请求映射表,确保Stream/Channel选择正确。
2. 检查CubeMX中ADC配置的DMA Continuous Requests或代码中hadc1.Init.DMAContinuousRequests是否使能。
3. 确认调用了HAL_ADC_Start_DMA且返回HAL_OK
4. 单步调试,查看上述关键寄存器位。
数据顺序错乱1. ADC规则通道序列(Rank)顺序配置错误。
2. 内存缓冲区理解错误,一维数组索引计算不对。
1. 在CubeMX的ADC参数设置中,检查Rank的顺序是否与预期一致。
2. 如果使用一维数组,确认索引计算公式buffer[i * channel_num + ch]是否正确。建议先用二维数组测试。
数据值不稳定,跳动大1. 模拟信号噪声大。
2. ADC采样时间太短。
3. 电源或参考电压不稳。
4. PCB布局布线不良,数字信号干扰模拟部分。
1. 在信号源端或ADC输入端增加滤波电容(如100nF)。
2.增加ADC采样时间,这是最有效的软件手段。在CubeMX中增大Sampling Time的时钟周期数。
3. 确保模拟部分供电(VDDA)稳定,并尽可能使用独立的LDO供电,且VREF+引脚连接了高质量的滤波电容。
4. 遵循硬件设计规范,模拟走线远离数字走线,特别是时钟线和高速数据线。
CPU读取的数据时对时错数据竞争:CPU读取内存时,DMA正在写入同一片区域。1.启用DMA中断方案:使用半传输和传输完成中断,在中断回调函数中处理数据。这是推荐的最佳实践
2.复制数据法:在定时中断或主循环中,先禁用全局中断,快速将DMA缓冲区数据复制到一个临时处理数组,然后开启中断,再处理临时数组。
DMA传输一段时间后停止1. DMA未配置为循环模式。
2. 缓冲区大小(NDTR)设置过大或过小,导致预期外的行为。
3. 发生了DMA传输错误中断。
1. 检查DMA配置中的Mode是否为Circular
2. 确认HAL_ADC_Start_DMA的第三个参数(Size)计算正确。
3. 实现DMA的错误回调函数HAL_ADC_ErrorCallback,并在其中打印错误信息进行调试。
多ADC同步采集需求需要同时采集多个ADC的数据,并保持同步性。对于STM32F4/H7等高级系列,可以使用双ADC模式(如交替、同步、组合模式),并配合DMA的不同数据流。这涉及更复杂的触发源(如定时器)和DMA配置(可能需使用双缓冲或链表模式)。需要深入研究参考手册的ADC双模式章节。

5.3 进阶优化建议

  • 使用定时器触发ADC:上述例子用的是软件触发或自动连续转换。更精准的控制方式是使用一个定时器(如TIM2)的更新事件来触发ADC转换。这样可以将采样率精确到定时器的频率,实现固定时间间隔的采样,对于数字信号处理(如FFT)至关重要。在CubeMX中,将ADC的External Trigger Conversion Source设置为相应的定时器触发事件即可。
  • 校准ADC:STM32的ADC有内置自校准功能。在上电初始化阶段,执行一次校准可以提高精度。HAL库提供了HAL_ADCEx_Calibration_Start()函数。
  • 过采样技术:对于慢变信号,可以通过软件对多次采样结果求平均来提升有效分辨率,抑制噪声。这可以在DMA搬运完成后,在CPU端进行。
  • 关注DMA总线带宽:在超高速多通道采集(如多个ADC同时工作,或使用高分辨率高速ADC)时,DMA可能会占用大量总线带宽,影响CPU或其他外设访问Flash/RAM的速度。此时需要合理规划总线矩阵,或者使用带DMA的多核芯片(如STM32H7),将数据搬运任务分配到特定核心的总线上。

实现STM32的DMA+ADC多通道采集,就像搭建了一条自动化生产线。ADC是生产线上的传感器,DMA是无人搬运车,内存缓冲区是仓库,而CPU是仓库管理员。管理员只需要设定好规则(初始化),生产线就能7x24小时运转(连续采集),管理员只需定期去仓库清点成品(处理数据)即可。掌握这个模式,你的STM32数据采集项目就拥有了一个高效、可靠的核心引擎。