news 2026/8/31 15:43:00

STM32 ADC采集与DAC输出实战:从硬件设计到信号质量优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 ADC采集与DAC输出实战:从硬件设计到信号质量优化

简介:本资源是一套面向嵌入式初学者与STM32进阶开发者的ADC采集与DAC输出实战工程,聚焦模拟信号链路的完整闭环实现,解决信号采样、处理与重建中的同步控制、速率匹配及CPU负载优化等典型问题。压缩包共208个文件,含40个.h头文件(定义外设寄存器与接口)、39个.c源文件(涵盖ADC初始化、DMA配置、DAC触发输出及主循环调度逻辑)、27个.o目标文件与26个.d依赖文件,辅以uvproj工程文件、hex可烧录镜像及map/lnp等调试支持文件,整体大小为4.39MB。已有104人学习下载。资源基于STM32F10x系列,代码结构清晰,包含stm32f10x_adc.__i、stm32f10x_dac.__i等关键预编译头,完整呈现无DMA模式下ADC读取与DAC写入的时序协同、中断服务函数设计及采样失真边界分析(如700Hz有效采集上限与4kHz以上打点不全现象),便于读者深入理解模数/数模转换底层机制并快速复现验证。 STM32的ADC采集和DAC输出,这对组合在嵌入式里就像人的眼睛和手——一个负责感知外面的模拟世界,一个负责把计算结果重新变回模拟信号。做仪器仪表、信号发生器、电机控制、音频处理的朋友应该都有体会:单独用ADC或者单独用DAC都不难,难的是把这一进一出放在同一块板子上,还能保证信号质量不崩。

这篇文章我打算完整记录一次基于STM32的ADC采集和DAC输出实现过程,从芯片选型、硬件电路设计、CubeMX配置、代码编写,到联调时遇到的各种信号质量问题、软件滤波方案、以及几个很有意思的进阶玩法,全部串起来讲。我踩过不少坑,这篇文章里的每一段基本都有对应的真实调试经历,希望能帮你在自己的项目里少走弯路。

1. 硬件选型和模拟链路设计:ADC/DAC的“底子”决定上限

很多人做STM32的ADC和DAC项目时掉进一个误区:上来就写代码,觉得CubeMX配置一下、调几个HAL库函数就完事了。但我做了几个项目之后的体会是,代码只能决定功能能不能跑起来,信号质量好不好,八九成取决于硬件设计。ADC/DAC电路不像数字电路那样只要电平对就行,它对参考电压、电源纹波、输入输出阻抗都特别敏感。

1.1 选型前先看清楚:不是所有STM32都带DAC

先说一个特别容易踩的坑。很多新手用STM32F103C8T6(就是最常见的那种蓝板子)做实验,配置的时候发现怎么都找不到DAC外设——因为F103系列里只有大容量产品(比如RCT6、ZET6)和互联型产品才有DAC,C8T6这种中等容量芯片根本没有DAC模块。我第一次做需要同时采集和输出的项目时也在这上面耽误了半天,最后换成了F103RCT6才解决问题。

如果是新项目选型,我建议先花几分钟把芯片的ADC/DAC资源摸清楚。我自己常用的几款芯片对比如下:

型号ADC资源DAC资源内部参考电压适合场景
STM32F103RCT612位ADC × 312位DAC × 2无,需外部VREF入门、资料最多、性价比高
STM32F407ZET612位ADC × 312位DAC × 2无,VREF+引脚主频高、定时器多、适合波形发生器
STM32G071系列12位ADC + 硬件过采样12位DAC × 2内置VREFINT低功耗、可以省掉外部基准芯片

选型的关键不仅是看有没有DAC,还要看ADC的输入通道够不够、定时器资源是否能用来触发采样、以及有没有内部基准电压。G071这样带内部基准的芯片做便携设备就很方便,但如果你对精度要求高,无论用哪款芯片,最终还是得外接基准电压源。

1.2 输入侧设计:ADC不只是一个“读电压”的引脚

STM32的ADC内部结构是“采样电容+逐次逼近比较器”。采样的时候,内部采样电容会瞬间连接到输入引脚,从一个等效阻抗较高的信号源抽取电荷。如果信号源的内阻太高,采样电容充不满,读出来的值就会偏低,而且随着采样时间不同漂移。这就是为什么规格书里会给出最大输入源阻抗的推荐值,一般F1系列建议不超过10kΩ。

处理方式有三种:

  • 信号源内阻本身很低(比如运放输出),可以直接连到ADC引脚。
  • 信号源内阻中等,用RC低通滤波,R取值控制在几百欧到几kΩ,C选1nF到100nF,但要留出采样时间让电容完成充电。
  • 信号源内阻很高(比如一个几十kΩ的分压器输出),最稳妥的做法是加一级电压跟随器。用一个轨到轨运放(比如LMV321、OPA340)接成跟随器,把高阻信号转成低阻信号再送给ADC。

RC截止频率的计算公式是:f_c = 1 / (2πRC)。比如R=1kΩ、C=100nF时,截止频率约为1.6kHz,适合采样几百Hz以内的缓变信号。如果你想采集更快的信号,就要缩小R或C的值,但缩小以后要重新评估源阻抗匹配问题。

1.3 输出侧设计:DAC的驱动能力和缓冲器问题

DAC输出端同样有讲究。STM32的DAC本质上是一个电阻网络,它内部集成了输出缓冲器,但缓冲器不是万能的。开启Buffer之后输出阻抗会降低,能带动一定的负载,但会引入额外的失调电压和少量的非线性;关闭Buffer则输出阻抗较高,带负载能力差,但精度会稍好一些。

我的做法是:如果DAC后面接的是高输入阻抗的下一级电路(比如运放同相输入端),就直接开Buffer,简单省事;如果DAC要直接驱动几十欧以下的负载,那就必须在外面再接一级运放做功率放大,不能指望片内缓冲器扛太重的负载。

另外,DAC输出的毛刺电压(glitch)也是一个常见问题。快速改变DAC输出值时,内部开关切换会产生短暂的尖峰。在输出端并联一个几十到几百pF的电容,能明显改善。但电容也不能太大,否则会影响输出信号的上升沿,这个平衡在第三章讲方波波形时会重点展开。

1.4 供电方案和模拟地处理:信号质量的隐形杀手

这一节可能是整个项目里最“便宜”但最有效的优化。ADC/DAC性能再好,如果给它供电的是一个纹波很大的开关电源,输出和测量结果都会带上周期性噪声。

我常用的供电方案是两级处理:数字电源进来之后,先经过一个LC滤波(比如10μH电感+10μF电容),再给模拟部分供电。如果是3.3V供电,还可以单独用一个低噪声LDO(如AMS1117分出来的模拟3.3V)给VREF+供电。F1系列虽然没有真正独立的AVDD引脚,但VREF+引脚可以独立供电,我就把VREF+单独接到一个高精度基准源(比如REF3030,输出3.0V/30ppm℃)上,ADC/DAC满量程精度会提升一个档次。

地线处理记住一句话:模拟地和数字地没有物理分离,再贵的基准芯片也白搭。我习惯在PCB上把模拟电路区域和数字电路区域分开铺铜,然后在一个单点(通常是电源地接入点附近)做单点连接,或者用0Ω电阻/磁珠连接。这样数字电路产生的地弹噪声就不会直接串进ADC的采样回路里。

2. ADC采集:从单通道阻塞读值到多通道DMA循环采样

ADC这部分的代码逻辑其实不复杂,HAL库把底层寄存器的操作都封装好了。但很多人配置完之后发现数据有问题——要么多通道采集的数据顺序和通道号对不上,要么采样速率远低于预期,要么DMA中断一直进不去。这些问题我在实践中都遇到并逐一解决掉,下面按我的使用习惯从头过一遍。

2.1 CubeMX里的关键配置项,逐项说人话

在CubeMX里配置ADC1,按照你的项目需求选择Enable Regular Conversion Mode(常规转换模式)。几个关键参数的含义:

  • Resolution(分辨率):F103和F4系列都是12位,G0系列也支持12位。如果没有特殊需求就保持12位,位数越低采样速度越快,但精度下降。
  • Scan Conversion Mode(扫描模式):只用单通道就关掉;要采集多个通道就必须打开。打开后,ADC会按你配置的通道顺序依次转换,转换结果按顺序存放在数据寄存器里。
  • Continuous Conversion Mode(连续转换模式):开启后ADC转换完一轮就马上开始下一轮,适合配合DMA做连续采样。如果只用软件启动单次读取,这个模式可以关。
  • Discontinuous Conversion Mode(不连续模式):一般用不到,它是把多个通道分成若干小组轮询转换,省电流但影响速率。
  • DMA Continuous Requests(DMA连续请求):如果开了连续转换模式,再配合这个选项,ADC每转换完一个通道就会自动触发DMA搬运,不需要CPU参与。

采样时间(Sampling Time)是最需要根据实际信号源来设置的参数。采样时间越长,采样电容充电越充分,精度越高,但单位时间内能转换的次数就越少。我一般先按最保守的配置(如239.5周期),跑通之后再去逐渐减小采样时间,直到精度仍然满足要求为止。

2.2 单通道读取的最简实现

先看一个最简单的单通道阻塞式读取,适合低速、无实时的场景,做做传感器慢速采集完全够用:

#include "adc.h" #include "main.h" extern ADC_HandleTypeDef hadc1; uint32_t read_adc_value(void) { uint32_t adc_value = 0; HAL_ADC_Start(&hadc1); // 等待转换完成,超时设为100ms if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK) { adc_value = HAL_ADC_GetValue(&hadc1); } HAL_ADC_Stop(&hadc1); return adc_value; }

读出来的ADC原始值怎么换算成实际电压呢?公式是:

实际电压 = ADC原始值 / 4095 × 参考电压

如果参考电压是3.3V,ADC读到2048,对应的电压就是 2048 / 4095 × 3.3 ≈ 1.65V。这里面有个很容易忽略的问题:参考电压到底精确到多少?假如你用的3.3V实际是3.28V,算出来的电压值就会整体偏高。所以关键应用里最好用万用表实测一次VREF+电压,然后把实测值代入公式。

2.3 多通道+DMA循环采样:做实时采集的正确姿势

一旦要连续采集多路模拟量,阻塞式和中断式就都不合适了。中断式虽然不阻塞CPU,但每次ADC转换完成都要进一次中断,频繁进出会拖慢主循环;而且中断处理逻辑里读数据、存数组,稍不留神就会丢数据。DMA方式由硬件把ADC的数据寄存器搬到内存数组里,CPU只在整轮数据准备好的时候去取结果,这才是嵌入式采集系统的常规做法。

CubeMX里的配置步骤是:把Scan Conversion Mode打开,然后在ADC的配置界面里把要用的通道(比如IN0、IN1、IN2、IN3)都添加进去。DMA设置里添加ADC1,Mode选择Circular(循环模式),Data Width选择Half Word(半字,因为12位的数据类型是uint16_t)。

DMA的目标内存是一个数组,顺序很关键:数组的第0个元素永远对应第一个配置的通道,第1个元素对应第二个通道,以此类推。如果你在CubeMX里先加的IN2后加的IN0,那么数组[0]就是IN2的数据,这点经常有人搞混。

生成代码后,启动方式如下:

uint16_t adc_buf[4]; // 对应4个通道 void start_adc_dma(void) { // 参数2是数组首地址,参数3是要搬运的数据个数 // 这个个数等于通道数 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4); } // 当一轮转换完成,数据已全部搬运到数组时,HAL库会回调这个函数 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { for (int i = 0; i < 4; i++) { // 在这里处理adc_buf[i]的数据 g_adc_raw[i] = adc_buf[i]; } g_adc_ready = 1; } }

注意HAL_ADC_Start_DMA的第二个参数类型是uint32_t*,但实际传递的是uint16_t数组指针,编译时可能需要强转一下。数组在DMA循环模式下会持续被硬件更新,所以不要在主循环之外的地方读取这个数组,防止读到一半数据被改写。我一般会先拷贝到另一个变量里再处理,虽然多花一点内存和时间,但能避免很多莫名其妙的问题。

如果数据量更大,比如要一次性采集128个点做FFT,DMA的Half Transfer中断(半传输中断)就很有用了。在采集前半段时,主循环可以处理上一次的后半段数据,相当于把处理时间藏在了采集时间里。这也是很多音频采集项目用“DMA半传输+传输完成双回调”做乒乓缓冲的套路。

2.4 采样率到底能到多少:自己动手推一遍

很多人拿示波器去测ADC采样率,发现和理论值差很多,根本原因是没有搞清楚时钟树和转换周期的关系。我用F103举例,完整推导一遍。

F103的ADC时钟来源于APB2总线时钟,默认72MHz,但ADC外设的最大时钟是14MHz,所以必须分频。CubeMX里ADC Clock Prescaler可以选2/4/6/8分频,选6分频就是12MHz,选8分频就是9MHz,选4分频就是18MHz——如果你不小心选了4分频,ADC时钟超频了,转换结果精度会显著下降。这一步超频导致数据跳动,是新手经常忽视的问题。

ADC完成一次12位转换需要固定的12.5个ADC时钟周期,再加上你配置的采样时间。比如配置采样时间为1.5周期,那么总周期数 = 12.5 + 1.5 = 14个周期。

ADC时钟12MHz、采样时间1.5周期时,最大采样率 = 12MHz / 14 ≈ 857kSPS。

这个速率是针对单通道连续转换的。如果是多通道,速率要除以通道数:4个通道扫描时,每个通道的实际采样率约214kSPS。

但注意这是ADC模块本身的理论极限,实际系统中DMA带宽、主循环处理速度、以及等待中断的时间都会有影响。所以估算时要留出余量,不能在理论上限附近设计系统。

2.5 上电校准:不做校准,精度就输在起跑线上

F1系列和F4系列的ADC内部都有校准功能,但它不是自动的,必须由软件触发。如果不做校准,ADC的零点偏移误差可能导致读值偏大或偏小几十个LSB。这在高精度采集场景是不可接受的。

CubeMX不会自动生成校准代码,需要手动在初始化之后加一行:

HAL_ADCEx_Calibration_Start(&hadc1);

这个函数会执行一次内部校准流程,完成后校准因子会保存在ADC的寄存器里,之后转换结果会自动修正。G0系列还有偏移校准和线性校准,用法类似。每次上电后执行一次校准就够了,不需要在运行中反复校准。唯一的例外是如果你在运行中忽然改变了ADC时钟或参考电压,建议重新校准一次。

还有一个常见问题是:有人把校准函数放在HAL_ADC_Init之前调用,结果校验失败。正确顺序是:先调用MX_ADC1_Init()完成初始化,再调用校准,最后启动转换。

3. DAC输出:方波过冲、正弦波生成和波形质量改善

DAC部分的实战中,我印象最深的不是代码写不出来,而是波形在示波器上露馅——明明寄存器里写的是完美的数字波形,输出到引脚上就变了样。

3.1 DAC输出原理和计算公式

STM32的DAC内部结构是R-2R梯形电阻网络加输出缓冲器。DAC输入数字量经过电阻网络转换成电流,再通过内部的输出放大器转成电压。用户能控制的寄存器是DOR(数据输出寄存器),计算公式是:

输出电压 = DOR / 4095 × VREF+

举个例子:VREF+=3.3V,想输出1.0V,DOR = 1.0 / 3.3 × 4095 ≈ 1241,转换成十六进制就是0x4D9。

使用HAL库写DAC值的函数是:

HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 1241);

第三参数DAC_ALIGN_12B_R表示右对齐12位数据。如果你需要更高分辨率但DAC本身位数不足,可以用PWM+DAC的实现方式,但STM32G0系列内部有硬件过采样,也能起到类似提升有效位数的效果。

3.2 输出直流电压:误差校准不可省

DAC输出一个固定直流电压,用万用表测出来往往不是理论值。比如我想输出1.0V,实测可能只有0.98V,再换个电压点测,误差比例还不太一样。这体现的是DAC的增益误差和偏移误差叠加结果。

解决办法是两点校准。我通常先输出满量程的10%(约0.33V)和90%(约2.97V),用高精度万用表记录实测值,然后建立线性关系:

实际电压 = 实测10%点 + (DOR - DOR_10%) / (DOR_90% - DOR_10%) × (实测90%点 - 实测10%点)

把这个线性关系保存成校准参数,在每次设置DAC输出之前反算实际应该填写的DOR值。校准之后,DAC输出精度一般能从±30mV提升到±5mV以内,前提是参考电压稳定。

3.3 方波输出:上升沿过冲和振荡的解决过程

这是我在做信号发生器时遇到的问题。DAC配置好之后,写一个简单的方波输出程序:

void output_square_wave(void) { while (1) { HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 0); delay_us(50); HAL_DAC_SetValue(&hdac, DAC_CHANNEL_1, DAC_ALIGN_12B_R, 4095); delay_us(50); } }

用示波器看波形,发现方波上升沿并不理想:电压先冲到目标值以上约20%,然后又跌回来,反复振荡几次才稳定。下降沿也有类似的下冲。这一看就是典型的过冲+振铃现象。

这个问题有三层原因,从主到次分别是:

首先,输出回路的寄生电感和负载电容组成了一个LC谐振回路。DAC引脚到示波器探头之间会有几十nH的寄生电感,加上示波器探头自身约10pF的输入电容,谐振频率可能在几十MHz。方波的上升沿包含丰富的高频分量,正好激励起这个谐振。

其次,DAC内部的输出缓冲器有一个有限的带宽,快速跳变时输出级会出现过冲。

最后,如果示波器探头的地线夹用的是那种十几厘米长的鳄鱼夹线,探头回路引入了额外的电感,会让振铃更严重。

解决方案我试过几种,效果最好的是在DAC输出端串联一个小电阻(100Ω左右),再并联一个10nF电容到地。这个RC组合的截止频率约160kHz,它能明显压制高频振铃。代价是方波的上升沿变缓了,但只要能满足系统对边沿的要求,换来的稳定波形是值得的。

如果对边沿速度有要求,可以改用两级结构:DAC输出先进一个单位增益运放,在运放输出端再做RC整形。运放的压摆率决定了边沿能有多快,同时它的低输出阻抗会削弱寄生谐振。

3.4 查表法生成正弦波:DAC+DMA的经典组合

正弦波比直流和方波更进一步。实现思路是先做一个正弦波查找表,存256个点的数据,然后周期性把表里的数据依次写入DAC。用DMA循环搬运可以获得非常稳定的波形,CPU完全不用参与。

256点正弦表的生成可以用Python提前算好:

import math points = 256 with open("sin_table.h", "w") as f: f.write("const uint16_t sin_table[256] = {\n") for i in range(points): # 12位DAC,取0~4095范围,中点偏置2047.5 val = int((math.sin(2 * math.pi * i / points) + 1) * 2047.5) if i % 8 == 7: f.write(f"{val},\n") else: f.write(f"{val}, ") f.write("};\n")

DAC配合定时器触发,每个定时器周期搬一个数据到DAC数据寄存器。DMA配置为Circular模式,内存地址指向正弦表,外设地址指向DAC_DHR12R1寄存器。

启动代码大致是:

HAL_TIM_Base_Start(&htim6); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sin_table, 256, DAC_ALIGN_12B_R);

这里用DMA搬运的同时,HAL_DAC_Start_DMA内部已经使能了DAC输出。定时器每来一次更新事件,DMA就搬运一个数据到DAC,波形频率为:

f = TIM_CLK / (PSC + 1) / (ARR + 1) / 256

假设TIM_CLK = 72MHz,PSC = 0,ARR = 281,则定时器频率 = 72MHz / 282 ≈ 255kHz,再除以256个点,正弦波频率约997Hz。调整PSC和ARR就能在很大范围内改频率。实测下来,这种方式生成的波形稳定度很高,抖动比纯软件循环写DAC小了一个量级。

3.5 输出波形的谐波失真来源

用FFT功能看DAC输出的正弦波频谱,会发现在主峰之外还有若干个较小的峰值,这些就是谐波。谐波的主要来源有三个:

一是量化噪声。12位DAC在低电平区域的量化步长约为VREF/4095,对正弦波这种连续信号来说,量化误差会表现为宽带的噪声底和少量谐波。

二是查找表点数太少。256点正弦表本身是对理想正弦波的近似,如果输出频率较高,点数不足导致的阶梯效应会产生高频谐波。要改善就增加表长度,比如1024点或2048点,代价是占用更多Flash空间。

三是定时器抖动。DMA在定时器触发下搬运数据,理论上间隔均匀,但总线仲裁、中断抢占等会引入微小的时间抖动。这种抖动也会在频谱上体现为噪声升高。

实际项目中,我通常会在DAC输出后面加一个有源低通滤波器,截止频率设在输出信号最高频率的2~3倍。这样既能滤除高频量化噪声,又能保留信号本身的谐波成分不至于严重失真。

4. 联调中的信号质量问题:纹波、跳动和软件滤波

ADC和DAC同时跑的时候,问题往往不是单个模块的,而是整个模拟链路的。我在一块板子上同时跑双通道ADC采集和DAC波形输出时,遇到的最典型问题就是:只要DAC在输出,ADC的采样值就开始不定期跳动。

4.1 噪声源分析:一块板子上同时跑ADC和DAC,噪声从哪来

先把噪声来源理清楚,排查才有方向。我总结了几类:

第一类是通过电源耦合。DAC输出状态切换时,内部电阻网络和输出缓冲会对电源产生电流脉冲,这个脉冲经过电源走线电阻和电感,会形成电压跌落或尖峰。如果ADC的参考电压和DAC共用同一条电源走线,ADC的转换结果自然就被干扰了。

第二类是通过地线耦合。数字电路的开关电流(比如GPIO翻转、DMA搬数据、串口发送)会在地平面上产生噪声。假如模拟地和数字地没有分割,或者分割后单点连接位置不合理,ADC采样瞬间采样电容采到的电压就包含了地噪声差。

第三类是电磁辐射耦合。DAC输出引脚和ADC输入引脚在PCB上相邻且平行走线较长时,DAC引脚上的高频分量会通过寄生电容串扰到ADC输入端。这种干扰在示波器上看起来像尖峰,在ADC结果上表现为不规则的跳变。

4.2 一次完整的采样值跳动排查链路

我拿一次实际排查过程做例子,完整走一遍。这个排查顺序可以复用到大多数ADC/DAC信号质量问题中。

现象:ADC采集一个稳定的1.5V直流信号,实测读值在1200到1280之间来回跳,幅度大约80个LSB。万用表测量输入电压非常稳定,问题不在信号源。

排查步骤:

第一步,检查软件配置。确认ADC时钟没有超频、采样时间不是最短档、DMA没有和别的外设冲突。这一步排除软件配置问题,没有改善。

第二步,断开外部输入,把ADC输入引脚直接短接到GND。理论上应该读到接近0的值,但实测仍有跳动,大约在20到60之间。这说明噪声是从ADC内部或引脚外部耦合进来的,不是输入信号原始的问题。

第三步,把ADC输入引脚短接到VREF+。读值应该在4095附近,但实测在3980到4050之间跳。这个现象透露了一个关键信息:即使输入引脚接到理想的参考电压,转换结果也不稳定。

第四步,用示波器AC耦合看VREF+引脚上的纹波。发现VREF+上叠加了幅度约50mV、频率约30kHz的周期性纹波。这个频率和我板子上DC-DC开关电源的开关频率对上了。

到这里,问题基本锁定:电源纹波通过VREF+进入了ADC转换链路。解决方案是给VREF+加二级滤波:用一个10Ω电阻串联、10μF钽电容并联1μF陶瓷电容对地,构成RC低通,截止频率约1.6kHz,把30kHz的开关纹波衰减掉。同时把DC-DC的输出经过π型滤波再给数字部分供电,也让DAC的电源更干净一些。

改完硬件之后,再把输入引脚接到GND,读值跳到0~3 LSB;短接VREF+读值稳定在4090~4095。问题解决。

这段排查链路的要点在于:先软件后硬件,先数字后模拟,用短接法缩小范围,最后用示波器锁定噪声源。别一上来就怀疑ADC坏了或者SPI通信不对,按顺序来能省很多时间。

4.3 软件滤波:中值、滑动平均、限幅融合

硬件改好之后,软件滤波依然是嵌入式采集里不能省略的一环。尤其是传感器信号本身带有随机噪声时,软件滤波是成本最低、见效最快的方案。我常用的三种基础滤波各自有不同的适用场景:

中值滤波适合去除尖峰脉冲。一次取N个样本排序取中间值,对突发性干扰非常有效,但会引入较大的滞后。滑动平均适合平滑随机噪声,计算量小,响应快,但对尖峰脉冲无能为力。限幅滤波适合抑制突变,做法是如果当前值与上一次值的差超过阈值,就用上一次值或线性插值代替。三者结合能对付绝大多数场景。

下面是一个我常用的三级融合滤波代码,先限幅再中值再滑动平均:

#define FILTER_N 5 // 中值滤波窗口 #define SLIDING_N 8 // 滑动平均窗口 #define LIMIT_THRESHOLD 100 // 限幅阈值 uint32_t adc_value_filtered(uint32_t new_value) { static uint32_t last_value = 0; static uint32_t median_buffer[FILTER_N]; static uint32_t sliding_buffer[SLIDING_N]; static uint8_t median_index = 0; static uint8_t sliding_index = 0; uint32_t temp[FILTER_N]; uint32_t sum = 0; // 第一步:限幅 if (last_value != 0 && (new_value > last_value + LIMIT_THRESHOLD || new_value < last_value - LIMIT_THRESHOLD)) { new_value = last_value; } last_value = new_value; // 第二步:中值滤波 median_buffer[median_index] = new_value; median_index = (median_index + 1) % FILTER_N; for (int i = 0; i < FILTER_N; i++) { temp[i] = median_buffer[i]; } // 冒泡排序取中间值,数据量小,足够快 for (int i = 0; i < FILTER_N - 1; i++) { for (int j = i + 1; j < FILTER_N; j++) { if (temp[j] < temp[i]) { uint32_t t = temp[i]; temp[i] = temp[j]; temp[j] = t; } } } uint32_t median_value = temp[FILTER_N / 2]; // 第三步:滑动平均 sliding_buffer[sliding_index] = median_value; sliding_index = (sliding_index + 1) % SLIDING_N; for (int i = 0; i < SLIDING_N; i++) { sum += sliding_buffer[i]; } return sum / SLIDING_N; }

这个滤波组合的实际效果是:对随机白噪声衰减约一个数量级,对尖峰干扰完全抑制,对真实的缓变信号延迟约2~3个采样周期。如果你做闭环控制,滤波的延迟要纳入系统延迟预算中分析,否则可能引发稳定性问题。

4.4 信噪比和有效位数:为什么12位ADC很难真的做到12位

很多人在选型时只看ADC“多少位”,但实际性能要看的是有效位数(ENOB,Effective Number of Bits)。12位ADC在理想条件下信噪比约为74dB,实际电路中电源噪声、参考电压噪声、PCB布局不良都会让SNR下降,ENOB也就跟着降。

ENOB = (SNR - 1.76) / 6.02

如果你的系统实测信噪比只有60dB,ENOB约9.7位,也就是说你买了一个12位ADC,实际可用精度只有10位左右。多出来的那2位,被噪声吃掉了。

所以热搜词里那句“20位ADC需要电源精度”——高分辨率ADC对电源纹波的要求是极为苛刻的。20位ADC的LSB电压在5V满量程时约为4.8μV,如果电源纹波有1mV,直接就把最小的4位吞没了。这种场景下,参考电压源和电源滤波器成了系统里最重要的器件。

对于STM32内置的12位ADC,我的经验法则是:如果PCB布局和电源处理得当,可以把有效位数做到10.5位左右;如果什么都不做就拿杜邦线加面包板飞线,可能只有8到9位。别抱怨芯片不好,先看看自己给的电源条件配不配得上这个精度。

4.5 测量工具本身也会骗人

调试过程中还有一类问题来自测量手段本身。示波器探头的地线夹如果用鳄鱼夹加十几厘米长的线,探头回路会形成一个大环天线,几十MHz的共模噪声会被耦合进测量结果,让你误以为DAC输出真有那么多噪声。我做过一个对比实验:同样的DAC方波输出,用短地线弹簧探头测,振铃幅度约5%;换成长地线夹测,振铃幅度变成15%。

正确的测量方式是使用探头自带的短地线弹簧,或者用接地环。测量ADC参考电压纹波时,探头应直接接触VREF引脚旁边的测试点,而不是隔着一根长线去接GND。测量高频波形时,还可以在探头尖端并联一个几十pF的小电容,形成高频补偿。

这个细节虽然看起来很小,但在排查信号质量时,如果误把测量工件的伪影当成真实问题来调电路,会浪费大量时间。

5. 玩法延伸:定时器触发、任意波形发生器和ADC-DAC闭环

基础功能跑通之后,这个系统能玩的花样就多了。这里分享三个我实际做过、并且觉得很有价值的延伸方向,每一个都能让这套ADC+DAC组合发挥更大的威力。

5.1 用定时器触发ADC,获得精确采样率

在前面的DMA连续采样方案里,ADC是“自由运行”的,采样间隔由ADC转换时间决定,无法做到严格等间隔。但很多算法(比如FFT、数字滤波)要求采样间隔必须严格均匀,这时就要用定时器触发ADC。

在CubeMX里把ADC的Trigger Source(触发源)配置为某个定时器的更新事件(比如TIM2 Update Event),然后在定时器配置里把更新频率设为你想要的采样率。启动顺序是先启动定时器,再启动ADC的DMA采集:

HAL_TIM_Base_Start(&htim2); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4);

定时器每产生一次更新事件,ADC就启动一次转换,转换完成后由DMA搬走数据。这样采样间隔完全由定时器决定,不受主循环代码执行时间影响。我在做音频采集时,把采样率精确设到48kHz,数据直接给FFT做频谱分析,效果比自由运行方式好得多。

5.2 DAC+DMA+定时器生成任意波形

正弦波查表法的本质是把“波形形状”存成一段数据,再按固定的时间间隔送出去。这个方法对任意波形都适用。只要把正弦表换成任意形状的表,就能输出任意波形。

比如要生成三角波,可以用一个计数器在0到4095之间循环递增递减;要生成自定义的任意波形,可以先用Python把波形数据离线算好,生成一个数组烧录到Flash里。如果需要输出连续可变的波形(比如电子琴里的音符),只要把表的内容指向不同音色的数据,就能实现简单的波表合成器。

在MCU Flash够用的情况下,我甚至试过把一段真实的音频数据变成数组,用DAC循环播放。44.1kHz采样率、8位精度、10秒音频,只要约440KB Flash,F407完全放得下。把音频数据填进DMA环形缓冲区,再用定时器触发,出来的声音虽然离Hi-Fi还有距离,但作为自制的“单片机录音机”已经很有意思了。

5.3 ADC+DAC闭环:从测量到控制的最后一公里

把ADC和DAC放在同一个系统里,最典型的应用就是闭环控制。一个简单的例子是自动增益控制(AGC):ADC采集信号幅度,DAC输出一个增益控制电压给模拟可变增益放大器,保持ADC输入信号幅度基本恒定。

我在做的一个简易信号发生器项目就用到了类似的闭环:DAC输出一个幅度固定的正弦波,ADC监测实际输出电压,根据测量结果修正DAC的增益系数。这样即使用户负载发生变化导致输出衰减,系统也能自动补回来,整个过程相当于做了一次“数字反馈+模拟输出”。

这个闭环的实时性要求取决于控制带宽。低速应用(比如几十Hz以下)可以直接在主循环里完成;高速应用(比如音频频段的AGC)就需要把反馈算法搬进定时器中断或DMA回调里执行。这也是为什么在前面几章一直强调DMA和定时器触发——它们不仅让采样更稳,也给了闭环控制更干净的时间基准。

5.4 代码工程化的一点经验

延伸玩法多了之后,代码组织就变得重要了。我踩过的几个坑可以供参考:

第一个坑是DMA缓冲区地址对齐。部分DMA外设要求缓冲区地址按半字对齐,如果编译器把数组分配到了一个未对齐的地址,DMA搬运会直接错误或性能下降。解决方法是给数组加上__attribute__((aligned(4))),或者用ALIGN_32BYTES宏。

第二个坑是CubeMX重新生成代码时,把用户手写的代码覆盖了。后来我遵循两个约定:一是用户代码必须放在CubeMX保留的USER CODE BEGINUSER CODE END注释块之间;二是所有DMA回调函数里的业务逻辑只做标志位通知,不直接在回调里做耗时处理。

第三个坑是不要在中断或DMA回调里调用HAL_DAC_SetValue这类函数。DMA回调本身是在DMA中断里执行的,如果你在回调里又操作DAC外设,可能会触发优先级反转或死锁。正确做法是回调里只置标志位,主循环里再根据标志位去设置DAC。

第四个坑是浮点运算在无FPU的F103上开销很大。如果一个采样周期内要做浮点滤波或浮点校准计算,主循环可能跟不上采样速率。解决办法是全部用定点运算,或者换带FPU的F4系列,或者把算法分解到多个采样周期里分时完成。这个优化思路在做高采样率实时处理时尤其重要。

关于这套系统的最终调试心得

做ADC采集和DAC输出这个组合项目,整体上给我最大的感受是:功能跑通不难,难的是把信号质量做到位。你在数据手册上看到的12位精度、74dB信噪比,全部是理想电源和理想布局下才能达到的。实际做出来的有效位数,完全取决于你对参考电压、电源滤波、地线分割和采样时间的处理。

在一个完整的ADC/DAC项目里,我最后整理出来的优先级是:先把电源和地做好,再把参考电压独立干净,然后配好RC滤波和运放跟随,最后才是代码和软件滤波。代码里面,优先把DMA和定时器用好,让硬件自己干活,CPU只在数据准备好的时候去做有意义的事——比如控制算法、数据存储或者人机交互。

反过来,如果一开始就整天纠结软件滤波算法,却不去检查VREF引脚上的纹波和地线布局,那相当于在噪声和问题的源头还没堵住的情况下,试图在结果端做复杂的补救。两者不是完全互相替代的方案,硬件底子打好了,软件滤波才真的有效;软件滤波用好了,硬件上的小瑕疵也能被掩盖一部分。

调试工具的重要性也不亚于设计本身。一台带宽够的示波器能让你直接看到过冲、振铃、纹波和串扰,省去大量猜测时间。如果没有示波器,哪怕只用万用表多测几个点,也比盲调代码要强得多。

这套ADC+DAC系统的后续扩展空间还很大。串口或者以太网把采集数据传到上位机做分析和显示,PID控制算法把ADC测量值和DAC输出执行器连成闭环,甚至多个STM32之间通过ADC采样和DAC输出互相通信,组成一个模拟域的信号链路——每一种玩法都值得单独开一个项目来折腾。在这个基础上,剩下的就交给你的具体应用场景决定了。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 15:42:32

线上线下C++编程班的避坑筛选清单

整理了适配四年级零基础孩子、针对GESP C备考的线上线下编程班避坑筛选清单&#xff0c;所有判断标准都不用你懂技术&#xff0c;对照着一条条核对&#xff0c;就能直接筛掉90%以上不靠谱的机构。 一、师资硬筛选标准 1、核心资质‌&#xff1a; 优先选持有CCF PTA C/C认证的…

作者头像 李华
网站建设 2026/8/31 15:40:03

IDM多线程下载管理器实战:断点续传、浏览器集成与调优

之前给团队搭建离线资源库时&#xff0c;最头疼的就是从公网下载大体积的 ISO 镜像、开发工具包和数据集。浏览器自带下载器一旦断网就要从头再来&#xff0c;多线程工具又担心不安全。后来我们把 Internet Download Manager&#xff08;简称 IDM&#xff09;纳入常用工具链&am…

作者头像 李华
网站建设 2026/8/31 15:31:28

虚拟机一键端部署全指南:从虚拟化配置到故障排查

很多人在拿到一份本地一键端资料包时&#xff0c;最先遇到的难点往往不是游戏或应用本身&#xff0c;而是虚拟机能不能被正确启动。这类资料包通常把数据库、缓存、服务端程序、运行库和系统环境统一封装进虚拟机镜像&#xff0c;使用者只需要用 VMware 或 VirtualBox 导入镜像…

作者头像 李华
网站建设 2026/8/31 15:30:59

无类型条件工作流:用Map+SpEL实现动态规则引擎

条件工作流在很多系统中都存在&#xff0c;比如订单审批、风控规则链、营销活动编排、数据稽核等场景。刚接触这类需求时&#xff0c;最容易走的路子是为每一个条件都定义一套强类型模型&#xff1a;订单金额条件写一个类、会员等级条件写一个类、风险分条件再写一个类&#xf…

作者头像 李华
网站建设 2026/8/31 15:30:53

【花雕动手做】直流有感带霍尔无刷电机 DC8-21V 2A 驱动板控制器

这款圆形 PCB 驱动板为带霍尔三相无刷电机专用驱动板&#xff0c;适配内置霍尔传感器的内转子 / 外转子无刷电机&#xff0c;常用于小型风机、小家电、微型动力 DIY、小型云台等场景。 1、核心电气参数 供电电压&#xff1a;DC8V~21V额定持续电流&#xff1a;2A控制方式&#…

作者头像 李华
网站建设 2026/8/31 15:30:18

1+X传感网应用开发中级实操题备考指南:硬件平台与代码模板全解析

简介&#xff1a;本资源是面向1X《传感网应用开发》中级认证考生的实操题库详解资料&#xff0c;聚焦环境监测、智能农业等典型应用场景&#xff0c;系统覆盖传感网搭建、多源传感器数据采集&#xff08;温湿度/光照/运动等&#xff09;、Zigbee/LoRa协议组网、嵌入式数据预处理…

作者头像 李华