ADC 读出来的数字一定准吗?只要实际调过程序,你就知道答案是不一定。很多刚入门的朋友拿着 12 位 ADC,算出的分辨率是 0.8mV,心里觉得“0.1V 误差应该不存在吧”,结果一测发现读数能差出几十毫伏甚至上百毫伏。这不是芯片坏了,也不是代码写错,而是 ADC 自身带了一套“出厂随机误差”,再加上外部参考电压、电源噪声、PCB 布局这些因素共同作用。
这期标题是“ADC 电压采集的软件校准”,也就是常说的标定和拟合。一句话解释它的价值:用数学方法把 ADC 读数“拉”回真实电压上,把误差从百分之一压到千分之几以内。这个方法在电池电量采集、传感器检测、工业仪表、嵌入式手持设备里几乎都能用得上。适合的人也很宽:刚学会 ADC 基础操作的新手,到正在做量产仪器想提升精度的工程师,都可以参考。
我会把为什么会有误差、标定背后的数学原理、STM32 上具体怎么落地、以及现场调试中容易踩的坑都过一遍,尽量讲得能直接抄作业。
1. 为什么一个12位ADC,实际精度却不到12位
很多芯片手册写得挺漂亮:12 位分辨率,可你用在电路里,发现实测出来大概只能到 10 位甚至更差的水平。这不是手册骗你,而是 ADC 的精度被好几类误差叠加吃掉了。搞清楚这些误差的来源,后面做软件补偿才更有针对性。
1.1 三个最典型的固有误差
第一个是偏移误差。理想情况下输入 0V 时 ADC 应该输出 0,但实际器件里运放输入失调电压、采样开关的电荷注入、内部比较器不对称这些因素会导致零点不在原点上。实际表现就是:输入接地,读出个 3、5、甚至 10 多个 LSB 的码值。
第二个是增益误差。也就是实际转换曲线的斜率跟理想曲线不一样。原因是参考电压不精准、内部电容阵列比例偏差、前端缓冲器增益不为 1 等。表现是:输入电压明明到了满量程附近,读到码值却不是理论上的 4095,而是 4050 或者 4120(当然超过 4095 会被截住)。多数低成本的 MCU 内建 ADC,增益偏差在 0.2% 到 1% 之间,换算成满量程电压就是好几毫伏到几十毫伏。
第三个是非线性误差,包含积分非线性 INL 和差分非线性 DNL。INL 是指整个转换曲线偏离直线的程度,DNL 是指相邻两个码对应的电压间隔不一致。非线性严重时会出现“跳码”或者某些码值缺失。对于 MCU 内建 ADC,INL 一般都有要求,像 STM32 这类芯片的 INL 通常在 ±2 LSB 以内,但加上外部电路就未必守得住了。
1.2 指标到底能折算成多少毫伏
我们拿一个很常见的组合来算:STM32F103 的 ADC,12 位,参考电压 VREF = 3.3V。理论分辨率:
[ \frac{3.3}{4096} = 0.8057\text{mV/LSB} ]
假设芯片的增益误差典型值是 ±5 LSB,偏移误差是 ±3 LSB,那最坏情况下就有 ±8 LSB 的误差,换算成电压就是:
[ 8 \times 0.8057 \approx 6.5\text{mV} ]
这个数看起来不大?但别忘了这是“芯片自身误差”,没算外部 VREF 偏差。很多开发板用的 3.3V LDO 本身就是 ±2% 精度的,3.3V 偏差 66mV,按 4096 个码摊开,误差还能再添一大截。两者叠加,最终电压误差超过 30mV 很正常。
如果用这种精度去测量 3.7V 锂电池的剩余电量,30mV 的误差已经足够让电量百分比算出好几个百分点的偏差了。要是测量的是传感器小信号,比如热电偶、应变片电桥,误差会被放大得非常难看。
1.3 不校准的典型现场现象
根据我的经验,不校准的系统在实测中有几个很典型的“症状”。
一是“万用表说 2.500V,单片机读出来 2.478V”,整条曲线都偏低或偏高。这通常是增益误差或 VREF 偏低导致的。
二是“零点附近还行,越靠近满量程偏得越多”。这说明增益误差占主导,偏移反而很小。
三是“换一片芯片,读数变化了”。因为每颗芯片出厂偏移、增益都不完全一样,虽然同一型号但个体离散度是存在的。所以做量产设备时,芯片内部自校准或出厂标定的重要性立刻体现出来。
还有一种是“读数缓慢飘移”。工位上一开始还准,过十几分钟再看就偏了。这种情况大多是温漂引起的,后面讲参数保存和温漂处理时会提到对策。
2. 校准标定与数据拟合:底层就是一次线性变换
软件校准的数学本质其实很简单:把 ADC 读到的原始码值经过一个“修正函数”,映射成真实电压。绝大多数 ADC 在正常情况下呈现的都是一条近似直线,所以用一次线性函数处理就够了。
2.1 用“偏移+增益”模型描述ADC
如果用坐标轴来理解,横轴是真实电压,纵轴是 ADC 原始码值,理想状态是一条经过原点的直线:
[ ADC_{实际} = V \times \frac{4096}{VREF} ]
但实际芯片因为偏移和增益误差,这条线的斜率和截距都变了。我们做软件校准时,做的是一次“逆变换”:已知 ADC 原始码值 x,反推真实电压 y。
[ y = k \cdot x + b ]
其中 k 就是“每个 LSB 对应的真实电压”,b 是整个系统在零点附近的“修正电压”。这里的 k 不是 3.3/4096,而是实测标定出来的、把 VREF 实际值以及增益误差都吸收进去的斜率。b 也不是单纯芯片零点,它把输入通道的直流偏置、分压电阻误差等一起吸收掉了。也就是说,软件校准不仅修了芯片,还把外围电路的一部分误差也“一笔勾销”了,这正是它性价比高的地方。
2.2 两点标定的完整计算示例
两点标定是应用最多的基础方法,原理就是在测量范围内取两个电压点,一高一低,记下对应的 ADC 原始码值,然后解一元一次方程。
举个例子。给 ADC 输入端加一个精密电压源,实测到两个点:
- 第一点:真实电压 0.5035V,ADC 原始码值 634
- 第二点:真实电压 2.9870V,ADC 原始码值 3691
那么斜率 k:
[ k = \frac{2.9870 - 0.5035}{3691 - 634} = \frac{2.4835}{3057} \approx 0.0008124\text{V/LSB} ]
截距 b:
[ b = 0.5035 - 0.0008124 \times 634 = 0.5035 - 0.5151 = -0.0116\text{V} ]
最后得到的换算公式就是:
[ V = 0.0008124 \times ADC - 0.0116 ]
用中间一个点验证:假如读回码值 2000,代入:
[ 0.0008124 \times 2000 - 0.0116 = 1.6132\text{V} ]
再用万用表实测中间电压,如果恰好是 1.613V 左右,说明这次的线性模型成立。注意 b 是负数,表示零点有负向偏移,这在真实的运放输入电路里很常见。
2.3 多点标定与最小二乘拟合
两点标定速度快,但只用了两个点,中间段如果有轻微非线性,误差就会潜伏。更可靠的方式是在测量范围内均匀取 5 到 8 个点,然后用最小二乘法拟合一条最优直线。
最小二乘的思路是:找一组 k 和 b,使所有标定点的“拟合电压”与“实测真实电压”之间的误差平方和最小。公式是:
[ k = \frac{\sum_{i}(x_i - \bar{x})(y_i - \bar{y})}{\sum_{i}(x_i - \bar{x})^2} ]
[ b = \bar{y} - k \cdot \bar{x} ]
这在 PC 上用 Python 几行就能算完。
import numpy as np adc_raw = np.array([634, 1287, 1923, 2810, 3691]) volt_actual = np.array([0.5035, 1.0042, 1.5120, 2.1959, 2.9870]) k, b = np.polyfit(adc_raw, volt_actual, 1) print(f"k = {k:.8f}, b = {b:.6f}") print(f"电压 = {k:.8f} * ADC + {b:.6f}")跑完就能得到 k 和 b。建议顺便算一下“残差”,也就是每个标定点拟合电压减去真实电压的差值。如果残差很小且方向随机,说明线性拟合已经足够;如果残差明显呈现“两头翘、中间塌”的抛物线形,说明 ADC 本身存在非线性,这时候可以换分段标定:把测量范围分成两段或三段,每段单独算 k 和 b。多段分段标定,在机器视觉和运动控制里被称为“多点标定/九点标定”的思路,本质都是同一个东西。
2.4 拟合里的“过拟合”陷阱
聊到“拟合”,就绕不过“过拟合”这个词。在 ADC 标定这个场景里,过拟合指的是:为了提高标定点和拟合曲线的吻合度,不断增加拟合多项式的次数,结果曲线虽然在标定点上非常准,但在标定点之间反而更失真。
举个例子。你用 6 个点去拟合,如果用一个 5 次多项式,几乎能保证每个标定点都完美穿过,误差接近 0。但中间任意位置的读数可能被曲线的波浪形带出几十毫伏的偏差。这不是模型变强了,而是模型开始“背答案”,把测量噪声和操作误差也当成规律学进去了。
对一般 12 位 MCU 内建 ADC,实际特性以线性为主,非线性只占很小一部分。稳妥的做法是:先用 1 次拟合,看残差,残差若是随机小量就收工;只有残差明显有规律且数值不可接受时,才考虑分段线性或二次拟合。我的经验是,90% 的民用嵌入式采集场景用一次拟合就够了;传感器非线性严重时,优先考虑分段线性,别去碰高阶多项式。
3. 标定前先看硬件:几处布局与电路要点
软件标定不是万能的。如果硬件条件太差,读数本身反复跳变、噪声大到淹没真实信号,这时候再牛的拟合公式也救不回来。所以标定之前,先把硬件底子做好。
3.1 参考电压和电源是精度的地基
ADC 采样的本质是把输入电压和参考电压做比例,所以 VREF 的稳定度直接决定转换精度。VREF 漂 1%,哪怕 ADC 自身再准,读数照样漂 1%。
有几个基础动作值得做:VREF+ 引脚旁边放一颗低 ESL 的 0.1uF 陶瓷电容,再并联一颗 1uF 到 10uF 的钽电容或 MLCC,共同给高频噪声和低频起伏兜底。如果芯片有独立的 VDDA 引脚,最好通过一个小磁珠或几欧姆电阻从主电源分出来,避免数字逻辑翻转时把毛刺耦合进模拟电源。
如果项目允许,尽量把参考电压交给外部基准芯片,常见的有 TL431、REF3030、LM4040 这类。成本不多,但精度能立刻从“LDO 百分之几”提升到“千分之二以内”,这对后面的软件校准帮助巨大。
3.2 输入通道:分压、滤波、采样保持的配合
ADC 输入信号常见的是先经过电阻分压,再经 RC 低通滤波,然后进芯片引脚。需要注意的是,ADC 内部采样保持过程本质是一个开关电容电路:采样瞬间会从外部汲取一小股电荷,如果外部阻抗太高,充电跟不上,采样结果就会偏低,而且看起来像“噪声大”。
内部的采样电容大约是几皮法到十几皮法,如果外部源阻抗是 10kΩ,时间常数也就几十纳秒,看起来挺快。但 ADC 需要的是把电容充到 12 位精度,也就是误差要小于 0.024%,所以时间要留到 8 个时间常数以上。倒推下来,采样周期设置得太短就会准不了。
这里给一个很多人忽略的细节:外部 RC 滤波器如果用了比较大的电容,比如 100nF,对采样电荷来说反而是个蓄水池,能提供瞬时电能,但电阻 R 也不能取得太大,否则标定时读数会稳定得很慢,每次切换通道要等几百毫秒才能用。
3.3 PCB布局规避噪声的3个要点
关于 ADC/DAC 的 PCB 布局,我总结过三个很实用的注意点。
第一,模拟参考地和数字地不要大面积割裂。很多人一听到“模拟地数字地分开”就立刻在 PCB 上切开一整条,然后只有一个点相连。但切完之后,数字噪声回流路径被拉长,反而让噪声灌进模拟区。层数足够的前提下,最好是完整的参考地平面,模拟器件集中在固定区域,数字信号走线尽量不穿越模拟区域。
第二,VREF 去耦电容尽量靠近芯片引脚,并且用短而粗的走线直接连到引脚和地。如果去耦电容放远了,引脚的寄生电感会让高频去耦效果大打折扣。
第三,ADC 输入走线远离 PWM、SPI、I2C 这类边沿陡峭的数字信号。挨着走或者长距离平行走,开关噪声会通过寄生电容耦合到模拟输入。实在避不开,就在两条线之间加一根接地的隔离走线。
3.4 标定环境怎么搭
标定不是随便插个电位器拧两下就行。你想得到可复现的结果,得有一个已知且稳定的电压源和一块精度足够高的万用表。
最理想的方案是一台可编程直流稳压源,精度 0.1% 以上,通过 USB 或本地按键逐点输出。如果没有,用稳定的 LDO 配合精密电阻分压也能凑合,但每个点都要用万用表实测。注意万用表的精度至少要 4 位半,普通 3 位半表只有 0.5% 左右的直流精度,可能和 ADC 的误差同一量级,用它当“真实值”去标定,结果只会越标越偏。
标定前让设备上电预热至少 5 到 10 分钟。很多“一开始挺准,过一会儿就偏”的问题,其实是芯片和外部器件还在温漂爬坡阶段,这阶段的标定数据没有参考价值。
4. STM32上落地软件校准:从采集到系数烧录
理论讲完了,硬件也排查完了,下面以 STM32 为例,走一遍完整的软件标定流程。整体分四段:采数据、算系数、写代码、存参数。
4.1 标定数据的采集流程
实测时先把程序里 ADC 的采样配置固定下来。校准后的换算只对你实际用的这套配置负责,包括采样周期、通道、参考电压。配置变了,k 和 b 基本要重算。
每个电压点都别只读一次,我习惯连续采 20 到 50 次取平均,先把随机噪声抹掉。记录格式建议直接整理成表格。
| 序号 | 万用表实测电压 (V) | ADC原始码值(平均) |
|---|---|---|
| 1 | 0.5035 | 634 |
| 2 | 1.0042 | 1287 |
| 3 | 1.5120 | 1923 |
| 4 | 2.1959 | 2810 |
| 5 | 2.9870 | 3691 |
采集时注意电压从低到高逐点调整,每到一个电压点等读数稳定几秒再记录。不要从高往低跳,因为分压电容和滤波网络会有放电滞回,读数一时半会儿稳不下来。
4.2 两点校准确数在MCU里的实现
两点标定不需要电脑端辅助,代码可以全部跑在单片机上。过程是:上电进入烧录模式,程序通过串口接收两个标定点,调用函数计算斜率 k 和截距 b,然后保存在 Flash 里。
typedef struct { float k; float b; } adc_calibration_t; void cal_calc_two_points( adc_calibration_t *cal, uint16_t raw_low, float volt_low, uint16_t raw_high, float volt_high) { cal->k = (volt_high - volt_low) / ((float)raw_high - (float)raw_low); cal->b = volt_low - cal->k * (float)raw_low; } float cal_voltage(adc_calibration_t *cal, uint16_t raw) { return cal->k * (float)raw + cal->b; }对应读取平均值的地方也一起写好:
uint16_t adc_read_average(uint8_t channel, uint8_t times) { uint32_t sum = 0; for (uint8_t i = 0; i < times; i++) { sum += read_adc_channel(channel); } return (uint16_t)(sum / times); }调用时先cal_calc_two_points算好系数,然后每次采样只做一次乘法一次加法。整个过程代价很小,读一次电压也就是几微秒的事。
4.3 用最小二乘算系数
虽然单片机性能足够跑最小二乘,但通常没必要让 MCU 在运行时做这个计算。标准做法是把前面那张表带回电脑,用 Python 或 Excel 算好 k 和 b,然后把数值写进代码或者烧进 Flash。这样计算过程透明,也方便反复核对残差。
先在电脑上跑一次:
import numpy as np adc_raw = np.array([634, 1287, 1923, 2810, 3691]) volt_actual = np.array([0.5035, 1.0042, 1.5120, 2.1959, 2.9870]) k, b = np.polyfit(adc_raw, volt_actual, 1) volt_fit = k * adc_raw + b residual = volt_fit - volt_actual print("k=%.8f, b=%.6f" % (k, b)) print("residual:", residual)如果打印出来的残差都在 ±0.5mV 以内且没有固定趋势,说明这套线性模型很稳。然后直接把 k 和 b 填到代码里:
adc_calibration_t cal = { .k = 0.00081238f, .b = -0.011618f, };有一点要提醒:浮点数在 C 里默认是 double,STM32 这类 MCU 如果不开启硬件浮点或 FPU,double 运算会比 float 慢不少。但校准换算只是每秒几十上百次的操作,用 float 完全够,记得在赋值后面加 f 后缀,避免编译器送你一份隐式 double 转换的开销。
4.4 校准参数存储与温度漂移的处理
k 和 b 算出来之后,最好别写在代码里编译死,尤其批量产的时候,每块板子的 k、b 都可能不一样。我的习惯是烧录固件后进入一个“产线标定模式”,通过串口下发两个标定点,由程序算好系数,再写到指定 Flash 扇区或外挂 EEPROM。
存储时加一个魔法字节和一个简单校验,我惯用 CRC16。
#define CAL_MAGIC 0xA5A5A5A5 typedef struct { uint32_t magic; float k; float b; uint16_t crc; } cal_storage_t;上电时先读出来检查 magic 和 CRC,通过就启用,不通过就退回默认系数V = 3.3 / 4096 * raw,并用一个标志量告诉系统“当前未校准”。这个设计在生产中非常实用,省得板子出问题时还要猜是没校准还是校错了。
温度漂移这件事,低成本 MCU 内建 ADC 比较明显,通常是十几 ppm/°C 到几十 ppm/°C 的样子,看起来不大,但实际包含 VREF 和内部基准的温漂,现场高低温差 40°C 时仍然可能偏出零点几个百分比。如果产品对环境温度有要求,可以考虑把温度传感器读数一并存到 Flash,做“温度补偿表”;在严苛场合,直接选外部低温漂基准更省心。
4.5 在线标定的延伸思路
在真正大批量或车载控制器领域,“标定”往往不是一次性操作,而是一种在线能力。像汽车电子里用 CANape、CANoe 这类工具通过 XCP 协议,把 k、b 这些变量做成“可标定量”,程序运行中就能修改。平时做嵌入式小项目,即使不上这么重的工具,也建议把校准参数模块化,预留一个串口命令来动态修改和保存参数,后续产线调试能省大量时间。
5. 新手最容易踩的坑,以及排查技巧
做 ADC 软件校准时间长了,会陆续碰到一些“看似玄学、其实有因可循”的问题。我挑四个最常遇到的场景,附上排查路线,相当于给文章补一张速查表。
5.1 读数跳变、码值乱跳
如果校准后电压还是上下乱跳,先别怀疑数学,回去看采样配置。最常见的是采样周期设得太短。STM32 里 ADC 采样周期可选 1.5、7.5、13.5、28.5、41.5、71.5、239.5 周期。除非输入阻抗极低而且软件实时性要求极高,否则我强烈建议用 71.5 周期以上。
其次外部电路原因。分压电阻几十千欧、旁边又没有滤波电容时,采样瞬间电荷不稳定,读数自然跳。此时在 ADC 引脚对地加一颗 100nF 电容能明显改善。再不行就是电源噪声,特别是参考电压 VREF 上叠了开关电源的纹波,这时候换低噪声 LDO 或给 VREF 加 LC 滤波,效果立竿见影。
5.2 标定完低端准、高端偏
两个标定点都压在低位段,然后拉到满量程一测,发现高端偏了不少。这不是校准标错了,而是标定点选取范围太窄,外推误差被放大了。标定点的原则是“覆盖整个实际工作范围”,低点靠近最低工作电压,高点靠近最高工作电压。如果量程是 0 到 3V,低点取 0.1V 左右,高点取 2.8V 以上,中间再插几个点用最小二乘做整体拟合,这样测量范围内几乎没有外推区间,误差自然收敛。
5.3 系统一运行ADC值就漂移
有一个很经典的场景:电机一转、屏幕一刷、继电器一吸合,ADC 读数就飘。这种问题往往是地回路和噪声耦合,校准方程无能为力。排查时先看示波器探头地夹子夹在 MCU 的模拟地引脚上,观察 ADC 输入波形在干扰瞬间有没有毛刺或电压跌落。
解决思路有几个方向:干扰是高频噪声就加强输入滤波电容;干扰沿地线走就独立模拟地、电源分开走线;干扰通过 VREF 传入就给 VREF 加干净的基准和滤波。不要觉得“反正软件有校准”就能包住所有噪声,校准只解决静态误差,解决不了动态干扰。
5.4 校准后误差仍超标的排查路线
把最容易忽略的原因按优先级排一下,是我现场排查的标准流程:
- 万用表本身精度不够吗?
- VREF 是否稳定?实测过实际电压吗?
- ADC 引脚外部电阻网络有没有虚焊、偏位?
- 采样周期是否足够,平均次数是否合适?
- 标定点是否足够覆盖工作范围?
- 标定系数的存储和读取过程有没有被覆盖、错位?
- 芯片温度有没有异常,温漂被放大了吗?
这串排查走下来,90% 的“怎么校都不准”都能定位到原因。
最后想说的是,软件校准是 ADC 采集里性价比最高的一道工序,它不需要换更贵的芯片,不需要改 PCB,只要标定流程合理,很多时候能把系统精度从无语的 2% 拉回 0.1% 以内。但我也劝一句,软件校准不能替代硬件设计,它的前提是该铺的电容铺了、该走的走线走对了、参考电压没离谱。先做好硬件,再用软件去修正剩下的系统误差,这才是稳妥的套路。