前阵子给一套电池管理小板写固件,三个采样点要同时读电压、温度和外壳温升。一开始图省事,直接在 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 时钟最高可配到 14MHz,CubeMX 里一般用 12MHz。假如每个通道设成 55.5 个采样周期,那么单通道总转换时间就是:
55.5(采样周期)+ 12.5(12位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 数据寄存器搬到这块内存数组”,之后快递员就会在每一次转换完成后自动跑一趟,老板自己该干嘛干嘛,全部搬完后再汇报一次。
对于 STM32F103,ADC1 的 DMA 请求被映射到 DMA1 的通道1。配置成循环模式后,DMA 会不断地把 ADC_DR 里的新值搬到内存数组中,无论 ADC 转换多少次,数据都会按顺序写入数组。CPU 最终只需要在“整轮转换完成”之后,把处理好的数据拿去做运算、显示或组包发送。
这样做最直接的好处是:ADC 的转换和数据的搬运完全由硬件完成,CPU 只在需要时介入。采样率能拉得很高,同时主循环还能处理其他业务,实时性和代码简洁度都上来了。
2. CubeMX 里的关键配置:照着勾就行
2.1 时钟树:先把 ADC 时钟喂饱
打开 CubeMX,先配置 RCC 和时钟树。HSE 选择 Crystal/Ceramic Resonator,SYS 的 Debug 如果要用串口下载可以选 Serial Wire。时钟树里把 SYSCLK 配到 72MHz,这是 F103 最常用的主频,也就是 8MHz 外部晶振通过 PLL 倍频得到。
ADC 的时钟来自 APB2,而 APB2 最高也就是 72MHz。ADC 本身有个预分频器,在 Clock Configuration 里能看到 ADC Prescaler,可选项是 2、4、6、8 分频。F103 的 ADC 最高只能跑 14MHz,72MHz 分频后只有 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 Mode | Enabled | 多通道必须开启扫描模式,否则只会转换 Rank1 |
| Continuous Conversion Mode | Enabled | 自动持续触发转换,配合 DMA 循环模式才能连续采集 |
| Discontinuous Conversion Mode | Disabled | 断断续续转换只适合特定场景,平时用不上 |
| DMA Continuous Requests | Enabled | 未开启时 DMA 只响应一次请求,数据不会再更新 |
| End of Conversion Selection | EOC flag at the end of each conversion | 让 DMA 在每通道转换完成后都触发一次搬运 |
| Number Of Conversion | 3 | 和第几路通道对应,必须和你实际通道数一致 |
| 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 通道。对于 F103,ADC1 使用的是 DMA1 的 Channel1。此时要重点确认下面几个参数:
| 参数名 | 建议配置 | 原因 |
|---|---|---|
| Mode | Circular | 循环搬运,和 ADC 连续转换配合,数据自动持续刷新 |
| Direction | Peripheral To Memory | 从外设 ADC 数据寄存器读到内存数组 |
| Peripheral Increment | Disabled | ADC 数据寄存器地址固定,不需要递增 |
| Memory Increment | Enabled | 内存数组地址需要逐个递增,才能分开放不同通道结果 |
| Periph Data Alignment | Half Word | ADC 数据寄存器低 16 位有效,半字读取正好 |
| Memory Data Alignment | Half Word | 缓存数组用 uint16_t,和上述对齐方式匹配 |
| Priority | High | 保证采样数据不被其他 DMA 请求抢占带宽 |
这里最容易被忽略的是数据宽度。很多新手把缓存数组定义成uint32_t adc_data[3],DMA 又选了 Word(4字节)宽度,结果读到的数据莫名其妙错位。ADC 虽然地址是 32 位寄存器,但有效转换结果只有低 12 位,半字传输刚好 16 位。所以最干净的搭配是:缓存数组用uint16_t,DMA 数据宽度选 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_t,DMA 实际会按半字宽度写数据。第三个参数传的是数据个数,不是字节数,3 通道就写 3。如果这里误写成 1,DMA 每次只搬运一个半字,其他通道的数据永远不会进入数组。
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_IN2(PA2),即使你心里把它当成“第一个通道”,它的数据也会出现在数组下标 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)); }N=8 去掉最大最小后还有 6 个数参与平均,效果比纯滑动平均更稳。唯一需要注意的是排序会改变原数组内容,如果后续还要用原始数据,记得先拷贝一份再排序。
4.3 一阶低通滤波的系数选择
一阶低通滤波,也叫指数移动平均(EMA),是嵌入式里代码量最少、效果直观的滤波方式。公式很简单:
Y(n) = α * X(n) + (1 - α) * Y(n-1)
其中 X(n) 是本次采样原始值,Y(n-1) 是上次滤波输出,α 是滤波系数,介于 0 到 1 之间。α 越大,滤波越弱、响应越快;α 越小,滤波越强、滞后越明显。
为了在 MCU 上避免浮点运算,可以选一个 2 的幂次倒数作为 α,用移位实现。比如 α = 1 / 16:
static 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 一直有值,其他下标为 0 | Number 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 的工程写着写着要迁到 F4,F4 系列 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、滤波、业务逻辑全部写完再调试,那样出现问题你根本不知道是哪一环的锅。