1. 采样原理拆解:ADC到底在干什么
做嵌入式这些年,ADC采样几乎贯穿了每一个项目。从简单的电池电压监测,到高精度的传感器采集,ADC(模数转换器)都是绕不开的一环。但很多人对ADC的理解停留在"读一个寄存器,拿一个数值",真到了采样飘、精度差、换通道出错的时候,才发现里面水很深。这篇汇总把采样原理、取平均滤波、精度提升这几块内容串起来讲,结合我在STM32和Cortex-M平台上的实操经验,尽量把原理讲明白,把代码给你,把坑也列出来。适合刚接触单片机的同学,也适合做了几年想系统梳理一下的朋友。
1.1 从电压到数字:一次转换经历了什么
ADC就是把连续的模拟电压,变成离散的数字码。这个过程看起来简单,但内部至少要经过三步:采样、保持、量化编码。
采样不是"看一眼电压",而是把某个瞬间的电压值"拍下来"存住。由于转换需要时间,如果电压一直在变,转换过程就没法稳定,所以ADC前端都有一个采样保持电路(sample-and-hold),先快速把电压充到一个保持电容上,再把外部输入断开,靠电容把电压"钉住",让后面的比较器慢慢去量化。这个环节有个重要参数叫采样时间(sampling time),说的就是给电容充电的时间。
用生活里的场景类比:你拿手机拍一张高速运动的汽车,快门速度不够快,照片就会糊。ADC的采样时间不够长,保持电容上的电压还没充满外部信号就变了,转换出来的值自然不准。所以采样时间不是随意配置的,得根据信号源阻抗和内部采样电容的大小来估算,后面单独讲。
量化是把模拟电压划分成若干等级。比如一个10bit的ADC,满量程3.3V,那最小量化单位就是3.3/1024≈3.22mV,这个值叫一个LSB(最低有效位)。如果你的电压是1.000V和1.004V,在10bit下很可能读出同一个码值,因为它们的差连一个LSB都不到。这就直接引出了分辨率的概念——分辨率越高,能把模拟量切得越细,但能切多细和测得准不准是两码事,后面第三节细说。
1.2 逐次逼近型(SAR)ADC的工作原理
绝大多数单片机片内ADC都是逐次逼近型(SAR,Successive Approximation Register)。它的核心是三个部分:比较器、内部DAC(数模转换器)、SAR寄存器。
工作过程像一场"猜数字"游戏。假设你心里想一个0~1023的数,让别人猜。猜的人先问"大于512吗?"你说"是";再问"大于768吗?"你说"不是";依次二分下去,10次就能锁定答案。SAR ADC就是干这个:先把最高位置1,用内部DAC把数字码变成电压,和输入电压比大小,比较器告诉你"输入比这个电压大还是小",根据结果保留或清零这个位,然后继续下一个位。一次转换就是若干次二分比较,位数越多比较轮数越多,转换时间越长。
这也是为什么SAR ADC对比较器的建立时间和内部DAC的匹配精度要求极高。片内集成虽然省事,但受工艺和面积限制,内部DAC的匹配精度在设计时就已经固定,你没法通过软件改变。所以对16位以上高精度场景,外置的Σ-Δ型ADC更常见,它们用过采样和噪声整形换取高分辨率,原理完全是另一套,这次不展开,只提醒一句:不要拿片内ADC去硬扛仪表级需求。
1.3 采样率与奈奎斯特采样定理
聊采样就避不开奈奎斯特采样定理:为了能无失真地恢复一个最高频率为f的模拟信号,采样频率至少要大于2f。为什么强调"大于"而不是"等于"?因为现实里不存在理想的低通滤波器和无限长的采样序列,工程上通常建议采样率取信号最高频率的5到10倍以上,留足余量。比如你要测的信号最高1kHz,那采样率至少2kHz,实际做10kHz比较稳妥。
低于奈奎斯特频率会有什么后果?高频信号会被混叠(aliasing)成低频假信号,你没测到它,它却伪装成别的频率混进你的数据里。对付混叠,理论利器是抗混叠滤波器,硬件上简单点在ADC输入端加一个RC低通,把高于采样率一半的高频成分在进入ADC之前滤掉。很多人在单片机上做音频、振动采集,采样率设高了还是看到异常波形,十有八九是没处理混叠。
这里还要纠正一个常见误解:采样率高不代表精度高。采样率管的是时间维度上取多少个点,精度管的是幅值维度上切得多细,两者互不替代。但后面会讲到,过采样(用远高于需求的采样率)确实能换来一些有效位数,那个是另一个逻辑了。
1.4 ADC的几个核心参数先过一遍
| 参数 | 含义 | 举例 |
|---|---|---|
| 分辨率 | ADC输出数字码的位数 | 12bit对应4096级 |
| LSB | 最小量化单位 = Vref / 2^N | 3.3V / 4096 ≈ 0.806mV |
| 采样时间 | 采样开关闭合给电容充电的时间 | STM32可配1.5~239.5个ADC时钟周期 |
| 转换时间 | 采样时间 + 量化比较时间 | 12bit SAR典型十几个时钟周期 |
| 满量程范围 | 输入电压允许范围 | 0~Vref,或带差分输入的±Vref |
| 采样率 | 每秒完成转换的次数 | 1M Samples/s |
| ENOB | 有效位数,考虑噪声后的真实分辨率 | 12bit ADC实际可能只有10.5bit |
以STM32F4为例,ADC时钟最高36MHz(ADCCLK),12bit分辨率下最小转换周期约12个ADC时钟,加上采样时间1.5个周期,理论最快约0.375μs一次转换,也就是约2.6Msps。实际工程上往往降到几百Ksps用,因为还要留时间给DMA搬运和软件处理,还要照顾信号稳定性和滤波开销。
2. 取平均:最朴素也最实用的滤波手段
2.1 平均能过滤掉什么
ADC读数跳变,主要来源是随机噪声:电源纹波、环境电磁干扰、参考电压跳动、量化噪声等等。这些噪声的特点是随机性强,正负分布基本对称,均值趋近于零。对N次采样做算术平均,相当于把噪声"分摊"掉,理论上信噪比按√N的规律改善——采样次数翻4倍,随机噪声的方差近似减半,等效信噪比大约提升6dB。
这个数学关系很有用:每4倍采样换来约1bit的精度提升,和后面要讲的过采样算法是同一理论来源。但注意前提是噪声是随机的,如果系统有固定的偏差,比如参考电压本身偏了20mV,你平均一万次也还是偏20mV。平均处理的是随机误差,不是系统误差。区分这两个概念,是先把滤波方向搞对的前提。
什么时候该用平均?传感器慢变量采集(温度、液位、气压),或者对实时性要求不高的监控场景,平均又简单又稳。什么时候不该用?快速控制环里,一个PID控制器周期内如果做32次平均,引入的时间延迟足以让环路振荡。选滤波方式之前,先问一句:这个信号是慢变还是快变,实时性要求是多少。
2.2 四种常见平均算法的C语言实现
算术平均:最简单,一次性采N个点求和除N。缺点是响应慢,而且如果中间混入一个粗大误差(比如一次强干扰导致的跳变),平均值会被拉偏。
// 算术平均:采集N点后一次性平均 uint16_t average_once(uint16_t *buf, uint8_t n) { uint32_t sum = 0; for (uint8_t i = 0; i < n; i++) { sum += buf[i]; } return (uint16_t)(sum / n); }注意这里的sum必须用uint32_t,不是uint16_t。N个12bit值累加,比如32个点的和最大接近32×4095≈13万,早就超过16bit的65535上限了。这个细节在8位单片机上特别容易翻车,因为默认的int可能只有16位。
滑动平均:维护一个固定长度的队列,新数据进来,旧数据出去,每次输出队列均值。和算术平均的区别是它"平滑地跟随",实时性更好,是工程里最常见的折中方案。
// 滑动平均:长度为WINDOW_SIZE的环形缓冲 #define WINDOW_SIZE 16 uint16_t sliding_average(uint16_t new_sample) { static uint16_t buf[WINDOW_SIZE]; static uint8_t idx = 0; static uint32_t sum = 0; sum -= buf[idx]; // 减去即将被覆盖的旧值 buf[idx] = new_sample; // 写入新值 sum += new_sample; idx = (idx + 1) % WINDOW_SIZE; return (uint16_t)(sum / WINDOW_SIZE); }滑动平均有个小坑:缓冲区初始值是0,前16次运行期间的均值会被显著拉低。要么初始化时把所有buf填成第一次采样值,要么在前面若干次不输出结果,直接积累够了再说。
中值滤波:取N个样本排序后取中间值。它对脉冲噪声(偶发毛刺、静电放电干扰)特别有效,因为N为奇数时,几个异常大的毛刺不会影响中间位置的值。注意它不擅长抑制连续小幅噪声,而且需要排序,开销比平均更大。
// 冒泡排序取中值,样本数建议5~7 uint16_t median_filter(uint16_t *buf, uint8_t n) { uint8_t i, j; uint16_t tmp; for (i = 0; i < n - 1; i++) { for (j = i + 1; j < n; j++) { if (buf[j] < buf[i]) { tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } return buf[n / 2]; }注意这里排序是原地操作,会把你传入的数组改乱。如果这个数组还要继续给DMA或别的模块用,一定要传一个副本进去,这个问题我后面还会强调一次,因为我在实际项目里被坑过。
一阶滤波(EMA):只保留上一次输出和最新输入,做一个递推式。EMA非常适合实时性要求高的平滑,资源消耗极小,对突变的跟随速度可以通过系数调整,是控制领域用得最多的一种。
// 一阶低通滤波:ALPHA越大跟随越快,越小越平滑 // 这里 ALPHA 取 32,等效系数 1/32 ≈ 0.03125 #define ALPHA 32 uint16_t ema_filter(uint16_t new_sample) { static uint32_t last = 0; last = ((uint32_t)(ALPHA - 1) * last + new_sample) / ALPHA; return (uint16_t)last; }这里的定点实现是工程里很常见的处理:系数写成1/32这种2的幂,编译器可以把除法优化成移位,整个滤波不需要浮点库,在Cortex-M0甚至8位单片机上也能跑得飞快。
2.3 平均算法怎么选
直接说结论,省得你纠结:
- 慢变量、离线分析、要求极稳:滑动平均窗口16~32,或者做"先中值后平均"的组合。
- 控制环内部、实时性敏感:EMA,系数0.05~0.2之间调试,兼顾响应和平滑。
- 偶发干扰明显(比如静电打一下、继电器吸合瞬间):中值滤波,或者"中值+滑动平均"两级串联。
- 极低速、有资源余量:把原始ADC值在内存里囤48个点,先剔除最大最小再平均,叫截断平均,抗粗大误差效果很好。
实际项目里我经常组合用:先做一次5点中值剔除毛刺,再过一级16点滑动平均平滑波形。两级处理的代码量不大,但效果比单级好很多。注意如果输入本身就有很强的确定性纹波(比如开关电源上那200mV的纹波),平均是滤不掉的,得先从电源和布局上解决。软件滤波救不了硬件问题,这句话我说过很多次,后面还会说。
3. 精度:分辨率≠精度,别再搞混了
3.1 分辨率、精度、准确度到底差在哪
三个概念经常被混用,但工程上必须区分:
- 分辨率:刻度有多细。12bit ADC能把0~3.3V切成4096格,一格约0.8mV。它只代表"能区分的最小变化",不代表"测的是对的"。
- 准确度(Accuracy):测出来的值和真值的偏离程度。比如标准电压1.000V,你测出来1.032V,那就是32mV的系统偏差,这是准确度问题。
- 精度/重复性(Precision):多次测量结果的一致程度。同样1V你每次测出来都在1.029~1.031之间跳动,波动范围小,说明精度好,但准确度差。
打个比方:分辨率像尺子上的刻度,准确度像尺子本身有没有做准,精度像你量同一个东西时手抖不抖。一杆刻度很细但本身刻度刻歪了的尺子,量出来照样是错的。ADC手册里给的INL(积分非线性)、DNL(微分非线性)描述的就是这把"尺子"刻度的好坏,而不是分辨率高低。买芯片、看数据手册的时候,先看这两项,再说自己是几bit。
3.2 影响精度的四大元凶
第一是参考电压。ADC输出的数字码本质是"输入电压/Vref×满码",Vref一旦偏移,所有转换结果等比偏移。片内参考通常精度一般,如果对精度有要求,一定考虑外接基准芯片,比如REF3030、ADR423这类低温漂基准,配合0.1μF和10μF的去耦电容。实测当中,用MCU的3.3V电源当Vref,电源纹波100mV的情况下,ADC结果低比特位跳变会非常明显,平均都救不回来。
第二是电源与地噪声。ADC的模拟供电和数字供电尽量分开,模拟地单独铺铜,在ADC引脚附近放磁珠和电容隔离。很多ST芯片有VDDA和VSSA独立引脚,这不是摆设,是把模拟供电从数字电源的毛刺里隔离出来的手段。开关电源的底噪、电机驱动的尖峰,如果直接串进模拟地,ADC数据会惨不忍睹。
第三是信号源内阻。前面说过采样电容充电需要时间,如果信号源内阻很大(比如几kΩ分压电阻直接进ADC),采样时间内电容充不满,读数就会偏低且不稳。估算可以用一阶RC模型:τ=(R_source+R_internal)×C_sample,建议留5τ以上的充电时间再采样。内阻大怎么办?加一个单位增益运放缓冲,或者在软件里增加采样时间。
第四是温度漂移。片内基准、内部DAC、比较器温漂都很明显,尤其在工业现场温差大的地方,同一个电压早上测和下午测可能差好几个LSB。对温度敏感的场景,要做两点温度校准,或者干脆用带内部温度传感器的方案做补偿。
3.3 有效位数ENOB:别被标称分辨率忽悠了
ADC标称12bit,不代表你每次转换都能拿到12bit有效信息。受热噪声、参考噪声、时钟抖动影响,低位比特往往是"薛定谔的比特"——一直在跳。衡量真实性能要看ENOB(有效位数):
ENOB = (SINAD - 1.76) / 6.02
SINAD是信噪比加失真,单位dB。如果一颗12bit ADC实测SINAD是63dB,代入算出来约10.2bit,有效位数就是10.2bit,剩下的1.8bit被噪声占掉了。这就是为什么很多24bit仪表芯片,数据手册里SINAD一算,ENOB也就18~20bit。选ADC、评估测量系统的时候,用ENOB而不是标称位数来算最终能到多少精度,能少交不少学费。
3.4 过采样:用采样率换有效位数
既然随机噪声在多次平均后按√N衰减,那我可以让ADC跑得飞快,采很多点再做抽取(decimation),等效于"数字低通",提升有效位数。理论规律是:每提升1bit有效位数,过采样率需要变为原来的4倍,也就是采4^N个点平均。
例如12bit ADC想拿到等效14bit精度,过采样率4×4=16,采16个点平均一次,输出一个14bit级别的结果。前提是输入信号本身带有足够的随机噪声(热噪声),让量化误差被"打散";如果信号源非常干净,相邻采样值几乎一样,平均只能去掉部分量化噪声,收益远低于理论值。验证方法很简单:输入一个稳定的直流电压,看原始采样的低位码是否在随机跳动。如果在跳,过采样有效;如果纹丝不动,就得考虑注入抖动(dithering),否则白忙活。
过采样在低速传感器上的应用最直接:每秒只需要10个有效结果,但ADC能跑100Ksps,过采样根本不占用吞吐率。单片机上实现也很简单,DMA连续采集256点,取平均输出一次,代价只是内存和一点CPU时间。
4. STM32 实操:从单通道采集到多通道稳定切换
4.1 硬件上先要处理好这几件事
很多人软件滤波调到怀疑人生,最后发现是硬件没弄好。我做过一个小项目,用STM32的ADC测一个300kΩ电位器的分压,读数乱跳,后来发现电位器直接进ADC引脚,完全没有缓冲。内阻大加上采样时间默认短,保持电容根本没充到稳态。解决很简单:软件里把采样时间从12周期调成239.5周期,读数立刻稳定下来。如果还不稳,就该上运放缓冲了。
硬件清单可以照着做:
- Vref引脚就近放一个10μF钽电容并联0.1μF陶瓷电容
- VDDA/VSSA独立走线,用磁珠隔离数字电源
- 信号线尽量短,远离PWM线和晶振
- 采样信号经过RC低通,R取1k~10k,C取100pF~10nF,既抗混叠也滤高频
- 高内阻源必须加缓冲运放
4.2 单通道ADC配置的关键参数
以STM32 HAL库为例,常规配置里最容易搞错的几个参数:
ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES; // 根据源阻抗调整 HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADCEx_Calibration_Start(&hadc1, ADC_CALIB_OFFSET, ADC_SINGLE_ENDED); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t raw = HAL_ADC_GetValue(&hadc1);采样时间这个参数,官方范围从1.5周期到239.5周期。怎么选?给一个工程参考:源阻抗0~2kΩ用1.5~12周期,10kΩ左右用84~144周期,50kΩ以上直接239.5周期外加运放缓冲。注意采样时间越长,最大采样率越低,这是个取舍。
我还想提醒一句:不少HAL版本在启动转换前需要做校准,尤其是ADC内部偏移校准。新代码里不调校准函数,低码段线性度会差一截。这玩意儿一次校准可以管比较久,但如果你换过供电电压或温度变化大,建议重新校准。
4.3 DMA批量采集配合双缓冲
单次轮询的效率太低,而且在RTOS里一直阻塞等着也是浪费。现代实现主流做法是DMA+连续模式:ADC转换完成后DMA自动搬运,CPU只在DMA半满或全满中断时取数据处理。
需要严格等间隔采样时,用定时器触发ADC(TRGO),而不是在主循环里随机发启动命令。定时器溢出事件作为ADC触发源,采样周期由定时器分频精确控制,这在音视频同步、振动分析这类对时间精度有要求的场景里,是必要的做法。
DMA配置几个要点:
- 数据宽度:ADC输出和DMA都要设成HalfWord(16位),设错会导致数据错位
- DMA循环模式(Circular)开起来,配合双缓冲,实现无间隙连续采集
- ADC连续转换模式(ContinuousConvMode)开启,否则一次转换完就停了
- 开启DMA半传输中断和传输完成中断,在上下半区交替处理数据
// DMA双缓冲思路:buf为2*buf_size,数据填满前半触发半传输中断 uint16_t adc_buf[2][128]; uint32_t buf_idx = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { // 处理 adc_buf[buf_idx] 里的 128 个点,滤波平均 process_adc_block(adc_buf[buf_idx], 128); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { buf_idx ^= 1; // 切换半区 } }这样做的最大好处,是把ADC吞吐和CPU处理解耦:采集128个点,CPU只处理一次,滤波平均的计算量被摊薄了,主循环还能干别的事。回调里别做耗时操作,只负责触发标记或搬运,具体滤波放在主循环里根据标志位执行。
4.4 多通道切换:三个被问爆的坑
多通道采集有几个典型坑,我按遇到频率排个序。
第一个坑:切换通道后第一次采样是上一通道的残留。硬件上采样电容每次都要重新充电到新通道的电压,切换后第一次转换很可能还是旧电压的"余温",必须丢弃。我在做温度加电压交替采集的项目里,循环里多采一次丢掉再存,问题马上消失。这个丢弃动作不能省,不管你用的是规则组还是注入组。
第二个坑:通道间串扰。如果一个通道接的是高内阻弱信号,另一个通道接了方波或PWM信号,切换时高阻通道会被"串"一下。解决思路是把高阻信号放到通道切换序列的最后,让它有更多时间稳定;或者在高阻通道前插入一个丢弃转换。
第三个坑:规则组和注入组分不清。规则组(Regular)适合顺序扫描,注入组(Injected)适合"打断当前转换,优先采集紧急信号"。很多人做多通道全部用规则组顺序扫,一旦中间某个通道要做外部触发同步,时间对齐就出问题。对需要同步的通道,应该用注入组配合外部触发或者定时器触发来做。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方向与解决办法 |
|---|---|---|
| 读数整体偏低但稳定 | 采样时间过短,电容未充饱 | 增大SamplingTime;或加运放缓冲 |
| 读数随机跳变,低位闪 | 参考/电源噪声、接地不良 | Vref去耦、VDDA隔离、模拟地处理 |
| 偶发毛刺很大 | 脉冲干扰、接触不良 | 中值滤波;检查信号线屏蔽 |
| 固定偏差,重复性好 | 参考电压偏、增益误差 | 两点校准;换基准芯片 |
| 数值台阶状变化 | 量化噪声,分辨率不足 | 过采样;换高bit ADC |
| 换通道后第一个值错误 | 通道切换未settle | 丢弃首次转换,加延时 |
| 数据全是0或满量程 | 引脚配置错、信号超量程 | 查GPIO复用;检查输入范围 |
5.2 我踩过的几个坑(照抄能避雷)
第一,初始化阶段ADC引脚是浮空输入,不接信号源时读出的Code随机乱跳,这不是坏了,是浮空输入导致的。要上拉到已知电平,或者接个真实信号再评估。
第二,曾经把Vref直接接到板子3.3V,而这个3.3V同时给一个电机驱动芯片供电。电机一启动,ADC读数跟着抖。用示波器一测,Vref上几百mV的毛刺。后来增加了一个独立LDO给模拟部分供电,抖动肉眼可见地消失了。从那以后,凡是精度要求高的板子,我优先把Vref和模拟供电单独规划。
第三,做多通道扫描时,我在DMA回调里对每个通道都做平均,但是忘记对切换后的首个样本做丢弃处理,导致每一轮数据里第一个通道的平均值都偏高。后来统一改成"先丢一个、再采N个"的流程,数据立即整齐。
第四,DMA搬运和滤波共用了同一个缓冲区,滤波函数把原始数组原地排序,结果下一轮DMA写进来的数据被污染了。给DMA用的缓冲区要单独开,排序滤波用临时副本。这个坑特别隐蔽,因为大多数时候ADC值是连续的,偶尔才露出一两个异常值,排查了很久才定位到。
5.3 两点收尾经验
最后说两个容易被忽略的动作。第一个是标定:ADC链路里的增益误差和偏移误差,靠软件平均和过采样都消不掉,必须在产品出厂前或上电时做一次两点标定,采一个0V(或Vss)和一个精密基准(比如2.048V),算出scale和offset,存到Flash或EEPROM,运行时把raw值换算成真实电压。这一步做完,精度往往能提升一个数量级,比任何滤波都立竿见影。
第二个是触发源的选择。不是为了做项目演示,而是任何需要"每隔固定时间采一组"的场景,都不要在主循环里靠延时或计数去凑,直接用定时器触发ADC,TIM的TRGO接ADC触发引脚,配合DMA,采样节奏才能做到真正稳定等间隔。
我个人做ADC相关的项目,习惯性的顺序是先解决硬件供电和参考,再解决源阻抗匹配,然后软件做滤波,最后做标定。每一步都有它的位置,跳步就会在后面阶段反复折腾。这篇汇总算是把我这几年ADC踩坑和沉淀的经验压缩成了一份快速参考,从采样原理到平均滤波再到精度标定,一条线串下来。真遇到问题的时候,照着速查表先定位方向,再回到对应章节看细节,能少走不少弯路。