三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

STM32 ADC多通道定时采集:CubeMX配置DMA+TIM触发实战指南

STM32 ADC多通道定时采集:CubeMX配置DMA+TIM触发实战指南

1. 项目缘起:为什么需要“ADC+DMA+TIM触发”这套组合拳?

如果你正在用STM32做数据采集,无论是电机电流采样、传感器信号读取还是音频信号处理,大概率都绕不开ADC(模数转换器)。新手阶段,你可能习惯在主循环里调用HAL_ADC_Start()HAL_ADC_PollForConversion(),这种轮询方式简单直接,但效率极低,CPU被死死绑在等待转换完成这件事上,什么也干不了。

随着项目复杂度提升,你很快会遇到两个核心痛点:一是采集速率和定时精度要求,比如需要以固定1kHz的频率精准采样;二是多通道数据的管理,几个通道的数据混在一起,处理起来非常麻烦。这时,“ADC+DMA+TMA触发”这套经典的硬件自动化方案就成了必选项。它的核心思想是让硬件自己干活,把CPU彻底解放出来:TIM(定时器)负责提供精准的触发时钟,ADC收到触发信号后自动启动转换,转换完成的数据通过DMA(直接存储器访问)自动搬运到指定的内存数组中,整个过程无需CPU干预。等你需要时,直接去内存数组里取处理好的数据就行了。

我最初在做一个振动监测项目时,就深刻体会到了这套组合的威力。我需要同时采集三轴加速度传感器的信号,并以2.4kHz的频率连续采样。如果还用轮询,系统根本跑不动其他算法。切换到“CubeMX配置+DMA+TIM触发”后,CPU占用率从接近100%骤降到个位数,整个系统的实时性和稳定性得到了质的飞跃。接下来,我就结合这个实战项目,带你从CubeMX配置到代码调试,完整走通这条路。

2. CubeMX工程配置:从零搭建硬件自动化流水线

配置是第一步,也是最容易出错的一步。很多问题都源于配置时的疏忽。我们以STM32F4系列为例,目标是配置ADC1,使用TIM2的更新事件作为触发源,通过DMA将通道0、1、2的转换结果循环存储到一个数组中。

2.1 时钟树与ADC基础配置

首先,打开CubeMX,选择你的芯片型号。在Pinout & Configuration标签页下,找到Analog中的ADC1

  1. 启用ADC1:勾选IN0IN1IN2(对应PA0, PA1, PA2),将它们设置为模拟输入模式。
  2. 配置ADC参数:在左侧的Analog->ADC1->Parameter Settings中:
    • Resolution:根据需求选择。12位分辨率最常见,精度和速度平衡得好。如果你的信号动态范围大,可以考虑降低到8位以换取更快速度。
    • Scan Conversion Mode必须启用(Enabled)。这是多通道采集的前提,ADC会按照你设定的顺序自动扫描多个通道。
    • Continuous Conversion Mode必须禁用(Disabled)。因为我们使用外部触发(TIM),每次转换都由触发信号启动,而不是让ADC不停地自己转换。
    • Discontinuous Conversion Mode:禁用。
    • DMA Continuous Requests必须启用(Enabled)。这个选项非常关键,它告诉DMA在ADC转换完一轮(所有使能的通道)后,自动发起下一次DMA请求,为接收下一次触发转换的数据做准备。如果禁用,DMA只搬运一次数据就停止了。
    • End Of Conversion Selection:选择EOC flag at the end of all conversions。这意味着每次所有通道转换完成后,才产生一个转换完成标志,DMA也在这个时候搬运数据。对于多通道扫描模式,这是标准配置。
  3. 配置ADC通道:切换到ADC1->Regular Conversion Mode
    • 点击Add,依次添加Channel 0Channel 1Channel 2
    • 为每个通道设置Rank(顺序),这里1,2,3即可。
    • Sampling Time(采样时间)需要计算。采样时间 = (采样周期数 + 12.5) / ADC时钟频率。ADC时钟频率在时钟树里配置(通常由APB2分频得到,不要超过芯片允许的最大值,如F4是36MHz)。采样周期数需要根据信号源阻抗来选,阻抗越大,需要越长的采样时间来让采样电容充放电稳定。对于直接连接MCU引脚的低阻抗信号,15 Cycles84 Cycles是常见选择。这里我们先选15 Cycles

2.2 关键一步:配置TIM作为触发源

这是实现定时采样的核心。

  1. 启用TIM2:在Timers中找到TIM2,选择Clock SourceInternal Clock
  2. 配置TIM2参数:目的是让它产生一个固定频率的更新事件(UEV)。
    • Prescaler(预分频器): 假设系统主频是84MHz(APB1 timer clock),我们希望TIM2的计数频率为1MHz。则Prescaler = (84MHz / 1MHz) - 1 = 83
    • Counter ModeUp(向上计数)。
    • Counter Period(自动重装载值ARR): 我们希望触发频率为1kHz。计数频率是1MHz,那么Period = (1MHz / 1kHz) - 1 = 999
    • auto-reload preload: 启用(Enable)。这样修改ARR值时,会在下次更新事件生效,避免当前周期计数出错。
  3. 连接ADC与TIM2:回到ADC1Parameter Settings
    • 找到External Trigger Conversion Source, 选择Timer 2 Update Event。不同型号的选项名可能略有差异,但一定是找TIMx_TRGOTIMx_Update这类事件。
    • External Trigger Conversion Edge: 选择Rising EdgeFalling Edge均可,通常选上升沿。这意味着TIM2每次产生更新事件时,会给ADC一个上升沿触发信号。

注意:这里有个常见坑点。TIM2的时钟源是APB1,而ADC的时钟源是APB2。务必在时钟树(Clock Configuration)里确认两者的时钟频率都已正确配置,并且ADC时钟未超频。一个稳定的时钟源是精准触发的基石。

2.3 配置DMA:设置数据搬运工

DMA配置是数据能否正确存储的关键。

  1. 添加DMA请求:在ADC1的配置页,找到DMA Settings,点击Add
  2. 配置DMA流/通道
    • DMA Request: 选择ADC1
    • DirectionPeripheral To Memory(外设到内存)。ADC是外设,我们的数组在内存里。
    • Priority: 根据系统实时性要求选择,HighVery High
  3. 配置DMA模式
    • Mode必须选择Circular(循环模式)。这是实现连续、不间断采集的关键。DMA会在搬运数据到数组末尾后,自动回到数组开头等待下一次ADC数据,形成一个闭环。
    • Increment Address
      • Peripheral禁用(No)。ADC的数据寄存器地址是固定的,不会变。
      • Memory启用(Yes)。我们的目标数组地址需要每次搬运后自动增加,以存储下一个数据。
    • Data Width
      • PeripheralMemory都选择Word(32位)。虽然ADC结果是12位(占16位半字),但STM32的ADC数据寄存器是32位的,对齐到字(Word)访问效率最高,也能避免一些硬件对齐问题。我们在代码中定义数组时也应用uint32_t类型。

2.4 生成工程代码

完成以上配置后,在Project Manager中设置好工程路径、IDE(如MDK-ARM),然后点击GENERATE CODE。CubeMX会生成所有初始化代码。

3. 代码实现与数据缓冲区设计

生成的代码提供了硬件初始化框架,但核心的业务逻辑需要我们自己填充。首要任务就是设计好数据缓冲区。

3.1 定义数据缓冲区与相关变量

main.c/* USER CODE BEGIN PV */私有变量区域,我们定义DMA搬运的目标缓冲区。

/* USER CODE BEGIN PV */ #define ADC_CHANNEL_NUM 3 // 我们使用了3个通道 #define ADC_BUFF_SIZE 1024 // 缓冲区大小,决定能存多少“组”数据 volatile uint32_t adc_dma_buffer[ADC_BUFF_SIZE]; // DMA目标缓冲区 volatile uint32_t adc_data_ch0[ADC_BUFF_SIZE / ADC_CHANNEL_NUM]; // 通道0数据 volatile uint32_t adc_data_ch1[ADC_BUFF_SIZE / ADC_CHANNEL_NUM]; // 通道1数据 volatile uint32_t adc_data_ch2[ADC_BUFF_SIZE / ADC_CHANNEL_NUM]; // 通道2数据 volatile uint16_t data_index = 0; // 用于在回调函数中整理数据的索引 /* USER CODE END PV */

这里我定义了四个数组:

  • adc_dma_buffer: DMA直接搬运的目的地。它的长度是ADC_BUFF_SIZE。DMA会不停地把ADC转换结果(三个通道按顺序转换的结果)依次填到这个数组里。
  • adc_data_ch0/1/2: 用于存放分离后的单个通道数据。每个数组的长度是总缓冲区大小除以通道数。因为DMA搬运的是交错的数据流,我们需要后期把它“解复用”成每个通道独立的数组,便于处理。
  • data_index: 一个索引,用于在DMA传输完成一半或全部的回调函数中,知道当前处理到缓冲区的哪个位置。

为什么缓冲区要定义为volatile uint32_tvolatile关键字告诉编译器,这个变量的值可能会被硬件(DMA)在程序流程之外改变,禁止编译器对它进行优化(比如把读操作缓存到寄存器)。uint32_t是为了和DMA的Word宽度配置对齐。

3.2 启动ADC与DMA,并开启TIM

main()函数的/* USER CODE BEGIN 2 */区域,启动整个采集链条。

/* USER CODE BEGIN 2 */ // 启动DMA,将ADC数据寄存器连接到我们定义的缓冲区 // 参数:ADC句柄, 目标缓冲区, 缓冲区长度(注意是“数据项”的个数) if (HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFF_SIZE) != HAL_OK) { Error_Handler(); // 启动失败,进入错误处理 } // 启动TIM2,让它开始产生更新事件,从而触发ADC HAL_TIM_Base_Start(&htim2); /* USER CODE END 2 */

HAL_ADC_Start_DMA这个函数非常关键,它做了三件事:1. 启动ADC;2. 启动DMA;3. 将ADC和DMA关联起来。调用之后,ADC就处于等待触发状态,DMA也准备好搬运了。一旦我们启动TIM2,触发信号就开始产生,整个硬件流水线便全自动运行起来。

3.3 数据处理:DMA传输完成回调函数

数据自动存到了adc_dma_buffer,但它是[CH0, CH1, CH2, CH0, CH1, CH2, ...]这样交错存储的。我们需要在合适的时机把它整理到单独的通道数组里。最佳时机是利用DMA的传输完成一半(Half Transfer Complete, HT)和传输完成(Transfer Complete, TC)中断回调函数。

CubeMX生成的代码默认可能没有启用DMA的HT和TC中断。我们需要在CubeMX中,DMA配置里,将ModeNormal改为Circular后,下方通常会有一个NVIC Settings选项卡,需要勾选上DMAx streamx global interrupt以启用中断。

然后,在stm32f4xx_it.c中,中断服务函数会调用HAL库的通用处理函数,最终会调用到我们在用户文件中可以重写的回调函数。

我们在main.c或者单独的adc.c文件中,重写这两个回调函数:

/* USER CODE BEGIN 4 */ // DMA传输完成一半的回调函数(前半缓冲区满了) void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc->Instance == ADC1) { process_adc_buffer(0, ADC_BUFF_SIZE / 2); // 处理前半部分数据 } } // DMA传输全部完成的回调函数(后半缓冲区满了) void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc->Instance == ADC1) { process_adc_buffer(ADC_BUFF_SIZE / 2, ADC_BUFF_SIZE); // 处理后半部分数据 } } // 处理缓冲区的函数 void process_adc_buffer(uint16_t start_idx, uint16_t end_idx) { static uint16_t ch_idx[3] = {0}; // 每个通道自己的存储索引 for(uint16_t i = start_idx; i < end_idx; i++) { // 根据在DMA缓冲区中的位置,判断当前数据属于哪个通道 switch(i % ADC_CHANNEL_NUM) { case 0: // 通道0的数据 adc_data_ch0[ch_idx[0]++] = adc_dma_buffer[i]; break; case 1: // 通道1的数据 adc_data_ch1[ch_idx[1]++] = adc_dma_buffer[i]; break; case 2: // 通道2的数据 adc_data_ch2[ch_idx[2]++] = adc_dma_buffer[i]; break; } // 防止索引溢出,当单个通道数组满时,回到开头覆盖旧数据(循环缓冲区) for(int j=0; j<ADC_CHANNEL_NUM; j++) { if(ch_idx[j] >= (ADC_BUFF_SIZE / ADC_CHANNEL_NUM)) { ch_idx[j] = 0; } } } } /* USER CODE END 4 */

这种“双缓冲”或“半缓冲”处理是高效数据采集的经典模式。当DMA在填充后半部分缓冲区时,CPU可以安全地处理前半部分的数据,反之亦然,避免了处理数据时DMA覆盖正在读取的内存区域。process_adc_buffer函数就是根据索引对3取模,将交错的数据流分离到三个独立的数组中。

4. 调试、验证与性能优化要点

配置和代码写完了,不代表就能跑通。调试阶段会遇到各种问题,以下是几个关键的验证点和优化思路。

4.1 基础功能验证:数据是否在动?

首先,用最直观的方法验证流水线是否工作。

  1. 检查TIM2是否运行:可以在main函数的while(1)循环里,翻转一个GPIO引脚,用示波器测量其频率。如果TIM2配置正确,并且HAL_TIM_Base_Start被调用,这个翻转频率应该是稳定的。
  2. 检查DMA缓冲区:在调试器(如ST-Link配合IDE)中,实时查看adc_dma_buffer数组的内存。给ADC的输入引脚(PA0, PA1, PA2)施加不同的电压(例如,一个接3.3V,一个接GND,一个接分压),观察缓冲区里的数值是否在变化,并且变化规律是否符合预期(接3.3V的通道值接近4095,接GND的接近0)。如果数据完全不变,可能是触发信号没进来,或者DMA配置有误。
  3. 使用断点:在HAL_ADC_ConvHalfCpltCallback回调函数里打一个断点。如果程序能进入这个断点,说明DMA传输已经成功发生了一半,整个ADC-DMA链路基本是通的。

4.2 常见问题排查与解决思路

  • 问题一:DMA缓冲区数据全为0或不变。

    • 检查触发源:确认TIM2已启动(HAL_TIM_Base_Start)。用调试器查看TIM2的CNT寄存器是否在递增。
    • 检查ADC状态:单步调试,检查HAL_ADC_Start_DMA的返回值是否为HAL_OK
    • 检查DMA配置:重点核对Mode是否为CircularData Width是否匹配,Memory Increment是否使能。
    • 检查引脚配置:确认ADC通道对应的GPIO引脚已正确配置为模拟输入(Analog),没有和其他功能(如复用功能)冲突。
  • 问题二:数据错位或混乱。

    • 检查缓冲区大小和DMA传输长度HAL_ADC_Start_DMA的第三个参数是传输数据项(Data Item)的数量。我们配置的DMA数据宽度是Word,ADC转换结果寄存器是32位,所以这里传入ADC_BUFF_SIZE(数组元素个数)是正确的。如果传入ADC_BUFF_SIZE * sizeof(uint32_t)就错了。
    • 检查通道顺序(Rank):在CubeMX的ADC规则通道配置中,Rank的顺序就是ADC扫描转换的顺序,也是DMA搬运的顺序。务必确认Rank1,2,3对应Channel 0,1,2
    • 数据处理逻辑:仔细检查process_adc_buffer函数中的取模运算i % ADC_CHANNEL_NUM,确保逻辑与你的通道数量和Rank顺序一致。
  • 问题三:采样频率不准。

    • 计算实际频率:实际触发频率 = TIM2时钟源 / ((PSC+1) * (ARR+1))。请根据时钟树仔细计算TIM2的输入时钟频率(注意APB1预分频器的影响)。
    • 示波器测量:最可靠的方法是用示波器测量ADC的转换完成引脚(如果有)或者用一个GPIO在转换完成回调函数里翻转,实测频率。

4.3 性能优化与进阶技巧

当基础功能稳定后,可以考虑以下优化:

  1. 使用双重ADC模式(适用于支持的双ADC型号):对于需要同步采集两个相关信号(如电机三相电流中的两相)的应用,可以配置ADC1和ADC2工作在双重模式(Dual Mode),一个TIM触发,两个ADC同步转换,数据通过一个DMA流交错存储,极大地提高了同步性和效率。
  2. 调整采样时间与时钟:更短的采样时间意味着更高的最大采样率,但可能降低精度。你需要根据信号源的输出阻抗来权衡。公式是:采样时间(秒) >= (信号源阻抗 + 模拟开关阻抗) * 采样电容 * ln(2^n),其中n是ADC分辨率位数。如果不确定,可以通过实验,逐步减小采样周期数,直到转换结果开始出现明显误差,然后留出一定余量。
  3. DMA传输到内存的不同区域:除了用回调函数处理,还可以配置DMA进行Memory to Memory传输,将原始缓冲区数据搬运到另一个处理缓冲区,实现更复杂的数据流管理。或者使用DMA的Double Buffer Mode(如果MCU支持),硬件自动切换两个缓冲区,软件处理起来更简单。
  4. 降低CPU中断负载:我们的例子中,每个ADC_BUFF_SIZE/2个数据产生一次中断。如果ADC_BUFF_SIZE设置得很大(比如4096),中断频率会很低,CPU负载小。但如果设置得很小,高频中断反而会成为负担。需要根据数据处理任务的耗时来权衡缓冲区大小。
  5. 数据对齐与处理:ADC的12位结果在32位寄存器中是右对齐的(默认)。我们定义的uint32_t数组存储的是原始寄存器值。在处理时,可能需要将其转换为电压值:Voltage = (adc_value * Vref) / 4095.0f。如果为了节省空间,可以在DMA搬运后,在回调函数中立即进行右移操作,将数据压缩成uint16_t数组存储。

这套“STM32CubeMX+ADC+DMA+TIM触发+多通道”的方案,本质上构建了一个高可靠性、高实时性的数据采集前端。一旦调试通过,它就像工厂里的自动化流水线,稳定且不知疲倦地工作,让你的CPU资源可以集中在更高级的算法和逻辑控制上。我自己的项目从轮询切换到这套方案后,系统响应速度和稳定性提升了好几个档次,再也没有因为数据采集堵塞而出现控制延迟的问题。花时间吃透它,绝对是STM32开发中一项高回报的投资。

← 返回列表