☰
ADC采样精度提升指南:从原理到滤波与校准的工程实践
2026/10/23 7:06:24 网站建设 项目流程

ADC 这块内容,我本来觉得“读个电压而已”没什么好聊的,直到上个月帮人排查一路电池采样,被一个来回跳的读数折腾到半夜,才发现很多看似“入门级”的问题,真放到工程现场一点都不入门。ADC(模数转换器)是把连续模拟量变成离散数字量的关口,几乎每个带模拟采集的嵌入式系统都绕不开它。这篇东西是我把采样原理、取平均、精度三个点串起来做的一个汇总,适合刚接触 ADC 不久、或者正在被采样结果不稳折磨的开发者,也适合想回头把“底层细节”补齐的老手。我会尽量用我能复现的实测数据、代码片段和排查思路来讲,尽量少讲教科书废话。

1. 采样到底发生在物理上:先从 ADC 内部工作原理说起

1.1 采样保持与量化阶段

理解 ADC,不能只把它看成一个“电压转数字”的魔法盒。绝大多数单片机内部集成的是逐次逼近型 ADC,工作过程可以拆成三步。

第一步是采样。ADC 内部有一个模拟开关和一个采样电容,开关闭合后,外部电压通过引脚对内部电容充电,这个过程需要时间,时间长短由外部源阻抗、引脚寄生电容和内部采样电容共同决定。如果充电时间不够,电容上的电压还没有“跟到”外部信号,采样结果就偏了,要么偏低,要么不稳定。

第二步是保持。开关断开后,采样电容上的电荷固定下来,此时电压被“冻结”,ADC 后端开始做转换。保持阶段最关键的要求是电压不能明显跌落,如果漏电流太大或者保持时间太长,电压就会漂移,导致转换结果出现难以察觉的误差。

第三步是量化。逐次逼近型结构内部有一个比较器和一个由 DAC 构成的参考电压发生器。它会从最高位开始逐位尝试,把内部 DAC 输出电压与被测电压比较,比较一次决定一个 bit,N 位分辨率就需要 N 次比较。这也是“逐次逼近”这个名称的由来。12 位 ADC 完成一次转换通常需要十几个 ADC 时钟周期,而不是“瞬间”完成。

注意:采样阶段最容易被忽视,软件上多配置几个周期的采样时间,通常比改滤波算法更能解决读数跳变问题。

1.2 不同结构 ADC 的故事差异

经常有人问“为什么高精度设备要用 Sigma-Delta 型 ADC,而单片机内置的大多是逐次逼近型”。这个问题背后其实是速度和分辨率之间的取舍。

逐次逼近型 ADC 的特点是转换速度快、功耗适中、结构简单,N 位需要 N 个时钟周期,所以常见的是 12 位、1 MSPS 左右。它适合采集变化较快的信号,比如电机电流、音频波形、电源电压瞬态。但它的缺点也很明显:需要外部参考源稳定,容易受噪声干扰,抗混叠能力完全靠外部滤波器。

Sigma-Delta 型 ADC 则走了另一条路,它用极低的采样率换超高的分辨率,内部过采样加数字滤波,经常能做到 16 位以上甚至 24 位。它特别适合缓慢变化的信号,比如温度、称重、电池电压。对于单片机外部外挂一片 Sigma-Delta ADC,效果通常比内置 ADC 好一个数量级。

还有流水线型 ADC,主要用于高速数据采集、示波器这类场景,功耗高、成本高,普通嵌入式项目很少接触。知道它存在即可,不用深究。

1.3 采样时间、转换时间和吞吐率的换算

实际操作中,配置 ADC 时会看到几个参数,采样时间、转换时间、总转换时间。它们的换算逻辑是这样的:

  • 采样时间:采样开关闭合的时间,通常可配置为几个到几十个 ADC 时钟周期。
  • 转换时间:逐次逼近阶段的时间,一般等于分辨率加若干额外周期,12 位常见是 12 个或 15 个周期。
  • 总时间:两者相加,再乘上 ADC 时钟周期,就是一次完整转换的耗时。
  • 吞吐率:1 除以总耗时,代表每秒能完成多少次转换。

举一个我常用的配置:ADC 时钟 1 MHz,采样时间 10 个周期,转换时间 12 个周期,总耗时 22 微秒,吞吐率约 45 kSPS。这个速度采集电池电压、温湿度完全够用,但如果要采集音频,就明显偏低,需要把采样时间缩短到 2 个周期,提到上百 kSPS。

有个容易被忽略的点:ADC 时钟不是越快越好。逐次逼近型 ADC 内部 DAC 建立需要时间,时钟太快,内部还没稳定就被比较器读取,结果会产生随机误差。很多芯片手册会给一个 ADC 时钟上限,超过之后精度反而下降。

2. 为什么 ADC 结果要取平均

2.1 噪声、抖动与量化误差:平均能解决什么

这是很多人最熟悉又最说不清的部分。先说结论:取平均能压制随机噪声,不能消除固定偏差。这里的随机噪声包括热噪声、参考电压抖动、电源纹波耦合等,它们的特点是围绕真实值上下随机波动,正负分布基本对称。多次采样后取算术平均,正负误差会相互抵消,均值会更接近真实值。

取平均对量化误差的改善是另一个机制。一个 12 位 ADC 的量化步长是参考电压除以 4096,如果参考电压是 3.3V,步长是 0.8058 mV。由于步长不是无限小,真实电压落在两个量化电平之间时,单次读数只能取其中一个,误差最大可达半个 LSB。但如果输入信号本身叠加了一点噪声,读数会在这个边界附近抖动,多次平均后相当于获得了步长以下的信息,这就是“过采样提高分辨率”的粗糙解释。

但要明确一个概念:平均只是统计意义上的改善,不是真的把 ADC 物理分辨率变高了。它能把噪声均摊,却无法把一个恒定偏大的基准电压“拉回来”。后面讲精度时你会看到,最坑人的往往就是固定偏差。

2.2 几种平均方案的取舍

平均不是只有一种写法,不同场景适合不同方案,我用一个表格直接对比。

方案优点缺点适用场景
连续算术平均简单,抑制白噪声效果好对突变信号响应慢直流或缓变信号、电池电压
滑动窗口平均实时性好,始终用最近 N 个点需要缓存数组,占用内存实时监测、UI 显示
去极值平均抗脉冲干扰强计算量大,需排序工业环境、电磁干扰明显场所
一阶低通滤波平滑、代码量极小响应有滞后,参数需调温湿度、压力等缓变信号
中值滤波对尖峰脉冲极有效对高斯噪声效果一般偶发干扰、通信误码

我自己的习惯是:系统里对实时性要求高的信号用滑动窗口平均,窗口长度取 8 或 16,因为 2 的幂次方便位运算;对不需要特别实时、只需要稳定的信号,直接做 32 次连续采样取平均,简单粗暴有效。

注意:别为了“更平均”而无限增加采样次数。平均次数从 1 加到 16,噪声能降低 4 倍,但加到 256,噪声只再降 4 倍,此时收益递减,响应延迟已经大到没法用。采样次数要在响应速度和稳定性之间找平衡。

2.3 滑动窗口平均与过采样

滑动窗口平均的代码实现很简单,但有一个细节很多人踩过坑。假设我维护一个长度为 16 的数组,每次采样后把最早的数据挤出去、新数据加进来,然后对整个数组求和再除以 16。如果不做优化,求 16 个数的和每次都要循环 16 次,耗费 CPU。标准做法是维护一个运行总和,新数据进来加一次,旧数据出去减一次,再除窗口长度。

#define ADC_WINDOW_SIZE 16 static uint16_t buffer[ADC_WINDOW_SIZE]; static uint8_t index = 0; static uint32_t sum = 0; static uint8_t filled = 0; uint16_t adc_sliding_average(uint16_t new_sample) { if (filled < ADC_WINDOW_SIZE) { sum += new_sample; buffer[index++] = new_sample; filled++; return sum / filled; } sum -= buffer[index]; sum += new_sample; buffer[index] = new_sample; index = (index + 1) % ADC_WINDOW_SIZE; return sum / ADC_WINDOW_SIZE; }

这里要注意除法时机。窗口没填满时返回的是“到目前为止的平均值”,此时数据量少,波动还比较大,显示端可能出现开头几帧跳变。我的做法是前 16 次采样只填数组不输出,满窗后再输出稳定值,虽然启动阶段会有 16 次采样的延迟,但换来的是界面上没有“开机乱跳”的观感。

过采样和平均的关系也需要厘清。严格意义上的过采样提升分辨率,要求每次采样的噪声符合随机分布且有足够的幅度,采样次数每增加 4 倍,理论上有效位数增加 1 位。但对普通带内置 ADC 的单片机来说,这个“理论”经常打折扣,因为信号本身太稳、噪声太小,量化误差并不随机,平均只是把同一个固定值算了很多遍,提升不了精度。

3. 取平均不能救命:精度问题的主要来源

3.1 有效位数和参考电压

先说一个残酷的事实:标称 12 位 ADC,实际有效位数往往只有 10 位左右。数据手册上的 12 位是无杂散动态范围指标最好的情况下测出来的,实际使用时受到参考电压噪声、电源纹波、布局耦合和采样时序等影响,ENOB(有效位数)会下降。所以在系统设计时,不要拿 12 位去倒推“我能测到 0.8 mV 分辨率”,那是理想情况,工程上一般按 10 位甚至 9 位来估。

参考电压是另一个大头。ADC 的量化结果本质上是“被测电压与参考电压的比值”,如果参考电压不是准确的 3.3V 而是 3.28V,那么每个码值对应的真实电压就偏小,最终读出来的电压会整体偏大或偏小。这种误差是系统性的,平均多少万次都消不掉。

要校准时,最简单的方法是抓两个稳定点。比如用精密电压源分别输出 1.0V 和 2.0V,记录 ADC 读数,然后按两点线性校准公式修正。没有精密电压源时,也可以直接用板上已有的稳压芯片输出作为参考,但要注意稳压芯片精度普遍在 1% 到 2% 之间,校准意义有限。

3.2 源阻抗与采样电容

这个知识点特别容易被忽略,因为很多人只会查 ADC 的分辨率、转换时间,从来不看源阻抗。前面提到,ADC 内部采样电容充电需要电流,如果信号源内阻太大,充电时间常数变大,采样结束时电容电压还没稳定到外部电压,读数就偏低,尤其在高采样频率时更明显。

举个例子,一个 12 位 ADC,采样电容约 10 pF,采样时间 1 μs,如果源阻抗是 10 kΩ,时间常数约 100 ns,理论上 10 个时间常数才需要 1 μs,勉强够。但如果源阻抗是 100 kΩ,时间常数 1 μs,采样时间相同的情况下,电容只能充到目标值的 63% 左右,读数严重偏低。

解决办法有两个方向:一个是软件上把采样时间跳到最大档,给电容更多充电时间;另一个是硬件上在 ADC 引脚前面加一个低阻抗缓冲,常见做法是用运放做跟随器,把高阻信号源变成低阻输出。对于 10 kΩ 以内的信号源,通常只调采样时间就够了;高于这个值,最好上缓冲。

注意:分压电阻采电池电压时,很多人为了降低功耗把分压电阻做到 1 MΩ,此时 ADC 直接连在分压点上,结果就会一边偏高一边偏低,而且读数跟采样时间强相关。这种情况建议加一个 100 nF 电容到地,用电荷共享的方式供采样电容充电,效果立竿见影。

3.3 布局、地与电源完整性对读数的影响

ADC 对模拟信号的敏感程度超乎想象。数字部分的高速翻转电流会通过地平面产生噪声,而这个噪声会直接叠加到模拟输入上。一个常见错误是,ADC 和 PWM 驱动、无线模块共用一个地,采样时候正好碰上射频发射,读数纹波暴涨。

我的经验是:模拟输入走线要尽量短,远离数字信号线,尤其是时钟、PWM、串口这类高频翻转信号。如果 PCB 面积允许,在 ADC 附近单独铺一小块模拟地,再用单点连接到主地,能把采样噪声降一个档次。ADC 的参考电压引脚旁边必须放去耦电容,而且要靠近引脚放,不要超过 5 mm。

电源方面,如果给 ADC 供电的 LDO 纹波很大,参考电压也会跟着抖。实测过一个项目,系统待机时 ADC 读数波动只有两三个码,一旦电机转起来,波动飙到十几个码,最后查出来是电源走线太长,电机电流在地上产生压降,干扰了参考电压。

4. 一个真实小项目的精度改造实录

4.1 原型遇到的问题

我在帮朋友调一个电池组电压监测模块时,遇到了典型的 ADC 综合症。硬件结构很简单:电阻分压后直达单片机 ADC 引脚,参考电压用板载 3.3V LDO,软件里做了 64 次平均。

现象也很明确:电池电压 12V 时,分压后应该在 2.75V 左右,但屏幕上读数上下跳,幅度大概 30 到 40 个码,而且整体偏低约 80 个码。按照 12 位分辨率算,30 个码约等于 24 mV 的波动,已经远远超过电池电压所应有的稳定程度。

4.2 排查过程

第一个怀疑对象是参考电压。我用万用表测 LDO 输出,实际只有 3.22V,和标称 3.3V 差了 0.08V。这解释了为什么整体读数偏高,因为 ADC 在计算时把 3.3V 当成基准,而实际基准偏低,导致每个码对应的电压偏小,读出来的电压偏大。

第二个问题是源阻抗。我查了分压电阻,用的是 100 kΩ 和 30 kΩ,等效源阻抗不是 30 kΩ 而是两个电阻并联值,约 23 kΩ。这个值对 1 μs 采样时间来说已经偏高,确实会欠充电。

第三个问题是参考电压噪声。用示波器看 3.3V 引脚,纹波大约 40 mV,带有明显的高频成分,而 ADC 的参考电压直接取自这个引脚,等于把纹波全部叠到了采样结果里。

4.3 固化后的配置清单

经过一轮改造,最终的方案是:

  • 分压电阻从 100 kΩ / 30 kΩ 改成 10 kΩ / 3 kΩ,等效源阻抗降到约 2.3 kΩ。
  • ADC 采样时间从默认的 1.5 μs 改成 12 μs,保证采样电容充分充电。
  • 参考电压引脚旁边加了一个 1 μF 陶瓷电容和 0.1 μF 高频电容并联去耦。
  • 软件上做两点校准,用万用表实测值修正增益和偏移。
  • 平均次数从 64 次降到 16 次,通过校准修正后精度反而更好。

改造后的实测数据:同一块电池,读数从原来的 11.72V 波动 ±0.03V,变成了 11.84V 波动 ±0.01V,而且数值和万用表实测的 11.85V 接近。这个结果说明,很多精度问题不是靠堆平均次数能解决的,先查源阻抗和参考电压才是正路。

5. 常见问题速查表

5.1 读数跳变

我把最常见的“读数跳变”场景和排查方向整理成一张表,方便你直接对着查。

现象特征最可能原因排查方向
开机一段时间后读数来回跳芯片发热导致参考电压漂移测量参考引脚电压,看温度相关性
PWM 开启时读数跳变增大地回路耦合或电源纹波示波器看电源波形,检查地走线
读数在某个码附近来回跳量化边界 + 少量噪声适当增加平均次数,或在输入端加容值
远程通信时读数跳天线辐射耦合进模拟走线缩短模拟线,远离射频电路
采样值随采样频率变化采样时间不足,电容未充满增大采样时间,检查源阻抗

跳变问题里,最不值得花时间的就是试图用更复杂的滤波算法去硬扛。算法只能处理噪声,处理不了硬件设计上的问题,先把硬件滤波和布局优化做好,再谈软件滤波。

5.2 读数单调偏大或偏小

读数整体偏大或偏小,通常不是噪声问题,而是系统误差。系统误差的来源主要有三个:参考电压不准确、分压电阻精度不足、ADC 偏移误差。

参考电压不准确的特征是,读数乘以实际参考电压后,所有档位都成比例偏差。分压电阻精度不足的特征是误差不是固定比例,而是随电压变化。ADC 偏移误差则表现为 0V 输入时读数不为 0,也就是常说的零点偏移。

处理方式是我前面提到过的两点校准。在系统范围内取两个已知电压点,算出增益系数和偏移量,然后对每次采样结果做一次线性修正。很多工程师觉得校准麻烦,其实写代码也就是一次乘加:

float adc_to_voltage(uint16_t raw) { // 校正系数由标准源两点标定得到 const float gain = 0.0007986f; const float offset = -0.0032f; return (float)raw * gain + offset; }

校准要注意一个问题:校准只对当前参考电压有效,一旦换了 LDO、改了分压电阻或温度大幅变化,之前的系数就会失准。所以如果产品需要长期稳定精度,要么选择低温漂精密参考源,要么做在线自校准。

5.3 平均后仍不准

平均后仍然不准,说明误差不是随机噪声而是固定偏差,或者信号本身就没稳定下来。这时候再做更大量的平均基本无用,要把思路从“滤波”转到“溯源”。

固定偏差的排查顺序是:先用万用表实测输入引脚上的电压,确认 ADC 输入前的模拟信号是否准确;再实测参考电压,确认基准是否准确;最后看 ADC 原始码值,对比理论码值确认增益偏移误差。经过这三层排查,大多数“平均后仍不准”的问题都能定位到硬件环节。

另外一个容易忽略的点:校准和平均的顺序。我见过有人在平均前校准,也有人平均后再校准,两者差异其实不大,只要注意校准系数的作用对象是原始码值还是平均后的码值,别搞混就行。如果输入信号本身有很大尖峰干扰,必须先做去极值或中值滤波,再做算术平均,否则尖峰会把平均值拉偏。

结尾

我做了几年嵌入式,最深的体会是 ADC 调试的问题从来不只是算法或配置问题,它把电路、布局、电源、时序、软件全部绑在一起。很多时候你觉得“读数不稳定,加一个滤波就好”,其实真正的问题在分压电阻太大、参考电容太远、采样时间太短。我现在的做法是:先拿示波器看模拟输入、参考电源两处波形,再查 ADC 寄存器配置,最后才讨论滤波算法。这个顺序帮我省了不少时间。最后分享一个小习惯:我会在固件里保留一个不用平均的原始值输出口,调试时直接看原始码值而不是加工后的结果,能更快暴露真实问题。希望这篇汇总能让你少走几步弯路。

附录:ADC 采样编程参考(基于常见单片机)

A.1 初始化 ADC 的步骤模板

下面这个是通用化的初始化流程,具体寄存器名称不同单片机会有差异,但逻辑一致。第一步开启 ADC 外设时钟,第二步配置引脚为模拟功能,第三步设置分辨率、采样时间、对齐方式,第四步选择参考电压源和输入通道,第五步使能 ADC 并做一次校准或启动转换。

void adc_init(void) { adc_clock_enable(ADC_CLOCK_SOURCE_1MHZ); adc_pin_config(ADC_CHANNEL_0, PIN_MODE_ANALOG); adc_set_resolution(ADC_RESOLUTION_12BIT); adc_set_sample_time(ADC_SAMPLE_TIME_12US); adc_set_alignment(ADC_ALIGN_RIGHT); adc_set_reference(ADC_REFERENCE_INTERNAL_3V3); adc_set_channel(ADC_CHANNEL_0); adc_enable(); }

A.2 读取一次值的通用功能

uint16_t adc_read_once(void) { adc_start_conversion(); while (!adc_conversion_done()) { // 等待转换完成 } return adc_get_raw_value(); }

A.3 带滤波的连续读取功能

uint16_t adc_read_filtered(void) { uint32_t sum = 0; const uint8_t count = 16; for (uint8_t i = 0; i < count; i++) { sum += adc_read_once(); } return (uint16_t)(sum / count); }

这组代码属于最基础的“能用”级别,真正要拿到项目里还要加上校准系数、异常检测、阻塞超时和轮询间隔控制,但骨架应该够你起步了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询