尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32 ADC多通道采集:DMA配置与软件滤波实战

STM32 ADC多通道采集:DMA配置与软件滤波实战 前阵子给一套电池管理小板写固件三个采样点要同时读电压、温度和外壳温升。一开始图省事直接在 while(1) 里轮流启动 ADC 转换每个通道转换完再去等 EOC、从数据寄存器搬结果。逻辑是简单了但不光是 CPU 被拖得死死的关键问题是高频率下其他任务全被 ADC 采集挡住最后被逼到死角才认真把 DMA 这套搞明白。后来用 STM32CubeMX 重新配置了一遍改用 DMA 模式做 ADC 多通道采集CPU 只需要在整批数据搬运完成后处理一次结果那个爽快感确实不一样。这篇文章把我从踩坑到跑通的完整思路写出来重点是 CubeMX 里的几个关键配置项以及实际代码里怎么拿数据、怎么做软件滤波。适合刚入门 STM32 的朋友也适合被 ADC 多通道采集搞到头大的开发者。配置部分基于 STM32F103 系列但思路在 F4、H7 上同样成立。1. 多通道采集的痛点为什么常规轮询和中断不够用1.1 ADC 多通道采集到底在采什么ADC 本身是个单通道转换器所谓多通道本质上就是靠模拟开关分时切换一次只能采一个通道。STM32F103 的规则组最多支持 16 个外部通道加上内部温度传感器和 Vrefint 参考电压通道组成了一个转换序列。配置好 Rank1、Rank2、Rank3 之后ADC 会按照你排好的顺序依次切换通道并完成转换转换结果依次放进 ADC_DR 数据寄存器。但问题正好出在“依次放进 ADC_DR”。多通道一轮转换下来数据寄存器里的值被刷新了好几次如果你不及时把上一次的结果搬走下一次转换一完成旧数据就被覆盖了。所以在多通道采集里最重要的不是“怎么让 ADC 转换”而是“怎么把每一路的转换结果分门别类地搬到内存里存好”。如果只用轮询方式你需要每转换完一个通道就立刻去读一次数据寄存器。三个通道就分三次读每个通道间隔还得等待转换完成。用中断方式好一点EOC 一置位就会触发中断但每个通道都进一次中断CPU 的上下文切换开销也不小。这两种方式都能用但都算不上优雅尤其在采样率拉高之后CPU 几乎就是在为 ADC 打工。1.2 轮询与中断方式的时间账我们来算一笔时间账这样你会更直观地理解为什么要上 DMA。STM32F103 的 ADC 时钟最高可配到 14MHzCubeMX 里一般用 12MHz。假如每个通道设成 55.5 个采样周期那么单通道总转换时间就是55.5采样周期 12.512位ADC固定转换周期 68 个 ADCCLK 周期在 12MHz 时钟下68 个周期大约是 5.67μs。一轮 3 个通道就是约 17μs。如果是轮询方式每 5.67μs 你就要停下当前任务等转换完成再读一次数据如果是中断方式每 5.67μs 就会触发一次中断进入中断处理函数完成一次搬运。表面看 5.67μs 并不长但如果你还要处理显示刷新、按键扫描、通信协议栈主循环里的任务会被频繁打断。中断方式更严重高优先级中断如果来得太频繁低优先级任务几乎得不到执行时间。我当时的项目里把采样率提到 10kHz 左右时其他逻辑已经明显卡顿了LED 刷新都开始闪。这就是典型的中断风暴。1.3 DMA 是怎么把 CPU 解放出来的DMA 的全称是 Direct Memory Access直译叫直接内存访问它的核心能力就是在外设和内存之间直接搬运数据全程不占用 CPU。你可以把它理解成一个专职快递员CPU 是老板ADC 是仓库出货口内存是收件区。老板只需要告诉快递员“从 ADC 数据寄存器搬到这块内存数组”之后快递员就会在每一次转换完成后自动跑一趟老板自己该干嘛干嘛全部搬完后再汇报一次。对于 STM32F103ADC1 的 DMA 请求被映射到 DMA1 的通道1。配置成循环模式后DMA 会不断地把 ADC_DR 里的新值搬到内存数组中无论 ADC 转换多少次数据都会按顺序写入数组。CPU 最终只需要在“整轮转换完成”之后把处理好的数据拿去做运算、显示或组包发送。这样做最直接的好处是ADC 的转换和数据的搬运完全由硬件完成CPU 只在需要时介入。采样率能拉得很高同时主循环还能处理其他业务实时性和代码简洁度都上来了。2. CubeMX 里的关键配置照着勾就行2.1 时钟树先把 ADC 时钟喂饱打开 CubeMX先配置 RCC 和时钟树。HSE 选择 Crystal/Ceramic ResonatorSYS 的 Debug 如果要用串口下载可以选 Serial Wire。时钟树里把 SYSCLK 配到 72MHz这是 F103 最常用的主频也就是 8MHz 外部晶振通过 PLL 倍频得到。ADC 的时钟来自 APB2而 APB2 最高也就是 72MHz。ADC 本身有个预分频器在 Clock Configuration 里能看到 ADC Prescaler可选项是 2、4、6、8 分频。F103 的 ADC 最高只能跑 14MHz72MHz 分频后只有 6 分频的 12MHz 是既满足上限又接近最高速度的选择。72 / 6 12MHz这基本是社区里大家默认的配置。不要选 4 分频算出来 18MHz已经超出 ADC 规格长时间跑会有稳定性风险。2.2 ADC 参数配置扫描、连续、DMA 请求在 Pinout Configuration 里找到 Analog - ADC1把需要用到的通道引脚勾选为 ADC_IN 模式。比如三个通道分别用 PA0、PA1、PA2就对应 ADC1_IN0、ADC1_IN1、ADC1_IN2。然后打开 ADC1 的 Parameter Settings这里每一项都别乱选。我把这套配置整理成了表格按这个来基本不会错。参数名建议配置原因Scan Conversion ModeEnabled多通道必须开启扫描模式否则只会转换 Rank1Continuous Conversion ModeEnabled自动持续触发转换配合 DMA 循环模式才能连续采集Discontinuous Conversion ModeDisabled断断续续转换只适合特定场景平时用不上DMA Continuous RequestsEnabled未开启时 DMA 只响应一次请求数据不会再更新End of Conversion SelectionEOC flag at the end of each conversion让 DMA 在每通道转换完成后都触发一次搬运Number Of Conversion3和第几路通道对应必须和你实际通道数一致Sampling Time根据信号源阻抗选择高阻抗信号源需要更长采样时间后文细说关于 End of Conversion Selection 这个选项不同版本的 CubeMX 显示的文字略有差异老版本可能叫 EOCSelection选项是 Single conversion 或 Sequence conversions。注意一定要选“每次转换结束都产生 EOC”也就是 Single conversion。如果选成“整个序列转换结束才触发 EOC”DMA 就会变成一个批次只搬一次数据三个通道的结果全挤在同一个位置后面必然错乱。2.3 DMA 配置循环模式与数据宽度在 ADC1 配置页面切到 DMA Settings 标签点击 Add会自动添加 DMA Request 为 ADC1 的 DMA 通道。对于 F103ADC1 使用的是 DMA1 的 Channel1。此时要重点确认下面几个参数参数名建议配置原因ModeCircular循环搬运和 ADC 连续转换配合数据自动持续刷新DirectionPeripheral To Memory从外设 ADC 数据寄存器读到内存数组Peripheral IncrementDisabledADC 数据寄存器地址固定不需要递增Memory IncrementEnabled内存数组地址需要逐个递增才能分开放不同通道结果Periph Data AlignmentHalf WordADC 数据寄存器低 16 位有效半字读取正好Memory Data AlignmentHalf Word缓存数组用 uint16_t和上述对齐方式匹配PriorityHigh保证采样数据不被其他 DMA 请求抢占带宽这里最容易被忽略的是数据宽度。很多新手把缓存数组定义成uint32_t adc_data[3]DMA 又选了 Word4字节宽度结果读到的数据莫名其妙错位。ADC 虽然地址是 32 位寄存器但有效转换结果只有低 12 位半字传输刚好 16 位。所以最干净的搭配是缓存数组用uint16_tDMA 数据宽度选 Half Word。DMA Settings 里还有一个中断开关叫 Interrupt Mode。要让它处于 Enabled否则 DMA 虽然能搬运数据但传输完成中断不会触发HAL 库里的转换完成回调函数永远不会被调用。勾选后 CubeMX 会自动配置 DMA1_Channel1_IRQn 的 NVIC。2.4 代码生成后的注意点配置完成点击 Generate Code。生成的工程里主要内容集中在 main.c、adc.c、dma.c 三个文件。CubeMX 自动生成的初始化顺序一般是 MX_GPIO_Init、MX_DMA_Init、MX_ADC1_Init。DMA 先于 ADC 初始化是必要的因为 ADC 的 MSP 初始化回调里会用到 DMA 句柄。如果你手动调整顺序出现硬件错误别太意外。必须提醒的是CubeMX 在重新生成代码时会保留两个 USER CODE 标记之间的内容。所有你手动添加的变量定义、初始化代码、业务逻辑都要写在/* USER CODE BEGIN ... */和/* USER CODE END ... */之间否则下次微调配置后代码会被覆盖得干干净净。这是很多人刚开始用 CubeMX 时被坑得最惨的一点。3. 代码实现从启动 DMA 到拿数据3.1 核心代码骨架在 main.c 中定义全局缓存数组数组大小必须是通道数。三个通道就三个元素多了浪费内存少了 DMA 会把数据写到数组边界之外造成溢出这种 bug 极难排查。/* USER CODE BEGIN PV */ #define ADC_CHANNEL_NUM 3 uint16_t adc_raw_values[ADC_CHANNEL_NUM] {0}; uint16_t adc_process_values[ADC_CHANNEL_NUM] {0}; volatile uint8_t adc_data_ready 0; /* USER CODE END PV */main 函数里的初始化部分保持 CubeMX 生成的内容不变然后在用户代码区启动 DMA 采集。启动代码放在 while(1) 之前即可。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); /* USER CODE BEGIN 2 */ HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_raw_values, ADC_CHANNEL_NUM); /* USER CODE END 2 */ while (1) { } }HAL_ADC_Start_DMA的第二个参数是目标内存地址这里强制转成uint32_t*是 HAL 库的接口要求但数组本身仍是 uint16_tDMA 实际会按半字宽度写数据。第三个参数传的是数据个数不是字节数3 通道就写 3。如果这里误写成 1DMA 每次只搬运一个半字其他通道的数据永远不会进入数组。3.2 用回调函数处理“整批数据已就绪”DMA 在循环模式下会持续不断地搬运数据CPU 如果随时去读adc_raw_values很有可能读到一半的数据因为 DMA 很可能正巧写到某个元素CPU 读出的这一批数据是“撕裂”的。稳妥的做法是在回调函数里将数据整体拷贝到另一块处理数组然后再设置标志位。/* USER CODE BEGIN 4 */ void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { for (uint8_t i 0; i ADC_CHANNEL_NUM; i) { adc_process_values[i] adc_raw_values[i]; } adc_data_ready 1; } } /* USER CODE END 4 */这个回调在 DMA 数据传输完成中断里被调用。注意不要在这个回调里做重活比如浮点运算、显示刷新、串口打印。原因是中断上下文里占用时间过长会影响下一次 DMA 中断的及时响应高频采集下可能丢中断。最好的做法就是只做内存拷贝、置标志位真正的数据处理全部放到主循环里。主循环里的处理大致长这样while (1) { if (adc_data_ready) { adc_data_ready 0; float volt0 adc_process_values[0] * 3.3f / 4095.0f; float volt1 adc_process_values[1] * 3.3f / 4095.0f; float volt2 adc_process_values[2] * 3.3f / 4095.0f; } }电压值换算用的是最简单的等比例公式12 位 ADC 满量程是 4095参考电压是 3.3V那么每个 LSB 对应的电压就是 3.3 / 4095约等于 0.8058mV。实际项目中如果参考电压有波动可以直接用内部 Vrefint 通道做修正这里先不展开。3.3 数据对应关系别搞混通道顺序很多人在多通道采集时出现“明明采的是 PA0结果却是 PA2 的值”这类问题原因在于 ADC 规则组的转换顺序完全取决于 Rank 配置。CubeMX 的配置界面里Number Of Conversion 设为 3 之后下面会出现 Rank1、Rank2、Rank3每个 Rank 可以选择不同的 Channel。数据缓冲区的填充顺序严格按照 Rank 排列Rank1 对应的通道转换结果写入adc_raw_values[0]Rank2 写入adc_raw_values[1]Rank3 写入adc_raw_values[2]。比如你把 Rank1 设成了 ADC1_IN2PA2即使你心里把它当成“第一个通道”它的数据也会出现在数组下标 0 的位置。建议在工程注释里把通道和数组下标的对应关系写清楚不然过两个星期自己回来改代码大概率会记错对应关系。3.4 关于双缓冲和替代方案F103 的 DMA 不带硬件双缓冲功能这是 F4 系列才开始有的特性。如果工程跑在 F4、H7 上可以用HAL_DMAEx_MultiBufferStart配置两个内存缓冲区轮流接收数据CPU 处理一个缓冲区的同时DMA 往另一个缓冲区写数据互不干扰。在 F1 上实现类似效果最实用的方案就是我前面说的“拷贝到处理数组”。虽然多一次内存拷贝但成本很低三个半字一共才 6 字节可以忽略不计。另一个替代做法是使用 ADC 的注入通道组注入通道转换完成后也可以触发中断同时不影响规则组的连续采集。但注入通道的配置逻辑比规则组复杂多通道场景下并不比 DMA 更省事我的建议是直接把规则组 DMA 这套方案吃透就够了。4. 数据抖动别急着抱怨硬件先上软件滤波4.1 滑动窗口平均滤波原理与代码ADC 原始数据出现小幅度跳动非常正常开关电源纹波、传感器噪声、电磁干扰都会混进来。如果直接把原始值拿去做显示或控制数值会在一两个 LSB 之间跳来跳去。软件滤波不需要额外硬件成本优先处理。滑动窗口平均滤波是最常用的手段。它的思路是维护一个长度为 N 的队列每来一个新值就丢进队尾同时把最旧的值从队首移出然后对整个队列求和取平均。N 越大平滑效果越好但响应越慢。N 选 8 或 16 在多数场合都比较合适。#define FILTER_N 16 static uint16_t filter_buf[FILTER_N]; static uint8_t filter_index 0; static uint32_t filter_sum 0; uint16_t sliding_average_filter(uint16_t new_value) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] new_value; filter_sum new_value; filter_index (filter_index 1) % FILTER_N; return (uint16_t)(filter_sum / FILTER_N); }这段代码用环形缓冲实现每个新值只需要做一次减法和一次加法时间复杂度 O(1)。如果你把窗口长度设置成 2 的幂比如 8、16、32还可以把取模运算优化成按位与filter_index (filter_index 1) (FILTER_N - 1);。MCU 上取模运算虽然不贵但这种小优化在采样频率很高时还是有点意义的。4.2 中位值滤波与去极值平均滑动平均对高斯白噪声效果好但遇到突发脉冲噪声比如电机打火、继电器通断带来的尖峰平均滤波会把尖峰影响摊到整个窗口导致输出有明显凸起。这时更适合用中位值滤波。中位值滤波的做法是连续采集 N 个样本排序后取中间值作为输出。N 通常取奇数比如 5、7、9。它对脉冲干扰非常有效但数据平滑度一般。工程里更常用的是“去极值平均滤波”也叫中位值平均滤波先采样 N 个值排序后去掉最大值和最小值再对剩下的值求平均。这样既抑制了脉冲干扰又保证了平滑度。#define FILTER_N 8 uint16_t remove_extreme_average_filter(uint16_t *data, uint8_t len) { uint16_t tmp; uint32_t sum 0; for (uint8_t i 0; i len - 1; i) { for (uint8_t j 0; j len - 1 - i; j) { if (data[j] data[j 1]) { tmp data[j]; data[j] data[j 1]; data[j 1] tmp; } } } for (uint8_t i 1; i len - 1; i) { sum data[i]; } return (uint16_t)(sum / (len - 2)); }N8 去掉最大最小后还有 6 个数参与平均效果比纯滑动平均更稳。唯一需要注意的是排序会改变原数组内容如果后续还要用原始数据记得先拷贝一份再排序。4.3 一阶低通滤波的系数选择一阶低通滤波也叫指数移动平均EMA是嵌入式里代码量最少、效果直观的滤波方式。公式很简单Y(n) α * X(n) (1 - α) * Y(n-1)其中 X(n) 是本次采样原始值Y(n-1) 是上次滤波输出α 是滤波系数介于 0 到 1 之间。α 越大滤波越弱、响应越快α 越小滤波越强、滞后越明显。为了在 MCU 上避免浮点运算可以选一个 2 的幂次倒数作为 α用移位实现。比如 α 1 / 16static uint32_t ema_value 0; uint16_t ema_filter(uint16_t new_value) { ema_value ((uint32_t)new_value 15u * ema_value) 4; return (uint16_t)ema_value; }如果采样率是 1kHzα 取 1/16对应的截止频率粗略估算在 10Hz 到 20Hz 之间适合慢速物理量的平滑比如温度、容器液位。如果采样率提高到 10kHz同样 α 的截止频率会相应抬高。实际调试时先把 α 调大保证响应再逐步调小到波形稳定这是经验做法。4.4 滤波与信号延迟之间的权衡滤波不是万能的。滤得越狠信号的真实变化被“抹平”得越厉害系统响应就越迟钝。做温度采集滤波强一点无所谓做电流环、速度环这类实时控制滤波引入的相位滞后可能导致系统振荡。我的建议是先不要盲目加滤波。用示波器或者串口把原始 ADC 波形导出观察噪声幅值到底有多大是随机噪声还是固定频率的纹波。固定频率纹波可以用陷波器或提高硬件滤波性能解决随机噪声才适合软件平均。如果原始信号本身就干净完全没必要滤波省下来的 CPU 周期能做很多事情。顺带提一句如果项目里用 FPGA 做高速采集滑动窗口滤波在 Verilog 里的实现思路不同通常是用移位寄存器链和流水线加法器完成每个时钟周期输出一次均值。MCU 的软件滤波受限于采样率FPGA 是把窗口逻辑做成纯硬件电路处理速度不在一个量级。这两种技术路线各有适用场景但解决的都是同一类问题用时间换平滑。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因排查与处理DMA 只搬了一次数据就停止DMA Continuous Requests 未开启或 Continuous Conversion 未开启在 CubeMX 中确认这两个选项均为 Enabled缓存数组下标 0 一直有值其他下标为 0Number Of Conversion 设置偏小确认设为实际通道数另外确认启动函数的第三个参数为通道数数据错位PA0 的采样值出现在其他位置Rank 通道顺序和预期不一致对照 CubeMX Rank 配置和代码数组下标的关系缓存数组使用 uint32_t结果数据每隔一个元素才有值DMA 数据宽度和数组类型不匹配统一使用 uint16_t 数组 Half Word 宽度回调函数始终不执行DMA 中断未使能在 DMA Settings 中勾选 Interrupt Mode采样值满偏 4095 或数值乱跳输入引脚悬空或信号源阻抗过高先接低阻抗信号源测试检查引脚是否配成 Analog 模式对比 F4 示例代码发现编译不过F103 没有 ADC 校准寄存器去掉 HAL_ADCEx_Calibration_Start 相关代码5.2 现场排查实录我从这些坑里爬出来的经验第一个坑是引脚配置。F103 的 ADC 输入引脚必须在 GPIO 配置里设置为 Analog 模式。如果保持默认的输入浮空模式ADC 也能读到值但引脚内部的上拉或下拉电阻会对采样结果产生偏移尤其在高阻抗信号源下读数会明显偏低。CubeMX 在勾选 ADC_IN 功能时通常会自动把引脚设为 Analog但如果你又在 GPIO 标签里手动改过引脚模式就需要回来检查一下。第二个坑与数据撕裂有关。一开始我的主循环直接读adc_raw_values发现偶发会有一次电压偏高。分析后确认是 DMA 正在写入数组、CPU 同时读取造成的竞争问题。改成回调函数里先拷贝到adc_process_values再在主循环里处理之后问题彻底消失。这个坑在高频采样下更容易出现低频时概率小但也不是没有趁早养成拷贝的习惯没有坏处。第三个坑是串口打印调试时直接把float类型通过 sprintf 格式化输出费 CPU 不说还容易踩printf浮点支持未开启的问题。调试 ADC 数据时我习惯先打印原始整数比如 2048、3125 这种 0 到 4095 的值确认数据关系后再换算电压做显示。第四个坑是供电噪声。ADC 的参考电压 VREF 和模拟供电 VDDA 如果不够干净采样值会周期性跳动。给 VDDA 加一个 4.7μF 钽电容加 100nF 陶瓷电容效果立竿见影。如果项目里用开关电源供电这个步骤基本是必做的。第五个坑针对 F4 用户。很多 F103 的工程写着写着要迁到 F4F4 系列 ADC 有硬件校准机制初始化后需要调用HAL_ADCEx_Calibration_Start(hadc)否则转换结果可能会有几十个 LSB 的偏差。迁移代码时别漏掉这一步也别在 F103 上硬套这个函数F1 压根没有校准寄存器编译都过不去。6. 这套方法还能怎么延伸6.1 串口 DMA 与 SPI DMA同一个套路ADC 用 DMA 的思路放到串口、SPI、I2C 上也完全通用。串口收发用 DMA 的话发送大量日志时 CPU 不再逐个字符等待移位寄存器只需要调用一次HAL_UART_Transmit_DMA剩下的交给 DMA 完成。SPI 读取外部 Flash 或传感器DMA 更是高频场景下的标准做法。一旦理解了“外设数据寄存器到内存的自动搬运”这条路子你会在串口、SPI、I2C 几乎所有外设上发现 DMA 的用武之地。6.2 把 DMA 和空闲中断结合做不定长串口接收在较新的 HAL 库中有一个很实用的组合HAL_UARTEx_ReceiveToIdle_DMA。它把空闲中断和 DMA 结合到一起一帧串口数据接收完成或者线路空闲时就会触发回调。配合 DMA 的循环模式可以实现不定长数据的连续接收而 CPU 大部分时间不需要介入。这套方案在 Modbus 轮询、串口协议解析等场景中非常常见效率比纯中断逐字节接收高得多。6.3 不同芯片平台的注意点STM32F4 系列开始DMA 数量和灵活度大幅提升引进了双缓冲模式DMA 控制器可以自动切换两个内存缓冲区CPU 处理数据和 DMA 采集数据真正做到无冲突。H7 系列甚至把 DMA 细分成了普通 DMA 和 BDMA还有 DMA2D 这种专门为图形加速服务的 DMA。芯片越高级DMA 的寄存器越复杂但底层思想不变明确搬运方向、数据宽度、地址递增模式、传输模式然后把它和对应的外设事件绑定起来。CubeMX 把大部分复杂度屏蔽掉了你要做的就是理解每个配置项背后的含义。这套基于 F103 的 ADC 多通道配置经验迁移到其他平台时只需要微调 DMA 通道号和时钟配置核心逻辑完全是通的。最后再分享一个在实际使用中的习惯任何外设接上 DMA 后先把中断打开在回调里留一个简洁的标志位或者做一次缓存数组的转存然后编译烧录在调试模式下打断点看数组内容。确认数据在刷新再继续调后续业务代码。永远不要一次性把 DMA、滤波、业务逻辑全部写完再调试那样出现问题你根本不知道是哪一环的锅。
返回列表