搞嵌入式这行,ADC 是每天都要打交道的外设。电压采集、电流检测、温度读数、电池电量、示波器输入,本质都是把模拟量变成一串数字。最近不少同行问我,ADC 采出来的数总跳,加了求平均还是不稳;多通道切换采数,第一个点总感觉不对;同一块板子换个电源适配器,读数差了几十毫伏。这些问题表面看是软件没写好,根子其实都在采样原理和精度设计上。
这篇文章就把 ADC 转换涉及的核心点一次性串起来:采样到底怎么完成的、采样周期该怎么配、取平均有哪些讲究、精度到底由什么决定,最后用 STM32 做一个多通道高精度采样的完整实操。中间的代码和配置可以直接抄,背后的原因我也会讲清楚。
1. ADC采样原理:从“开闸放水”到“读水量”
1.1 采样保持与量化:ADC到底在干什么
先说底层机制。绝大多数单片机内置的 ADC 是逐次逼近型(SAR)结构,它不是像电压表一样一直盯着输入信号看,而是先“抓拍”一个瞬间的电压值,再把它转换成一个数字。这个过程分两步:采样和量化。
- 采样:内部有一个采样开关和一个采样电容。开关闭合一段时间,让外部电压给电容充电,电容电压追到和输入信号基本一致。
- 保持:开关断开,电容上保持刚才的电压。此时输入信号再怎么变,ADC 内部转换的都是“抓拍”到的那个定格值。
- 量化:逐次逼近寄存器从高位到低位依次猜测电压大小,用内部 DAC 产生比较电压,和保持电容上的电压做比较,最后输出一个二进制数。
用生活类比就是:不用一直开闸放水,先打开水龙头接满一桶水,关掉龙头再量这桶水有多少。水桶就是采样电容,接水的时间就是采样时间,量水的过程就是逐次逼近。
这个“抓拍”机制带来一个关键限制:如果输入信号在采样期间变化太快,电容充电会来不及跟上,采到的值就会偏低或失真。所以 ADC 前面通常会加 RC 低通滤波,一方面抑制高频噪声,另一方面也是在帮采样电容“喂水”。
1.2 采样周期配置:采样时间不是越长越好
ADC 一次完整转换需要的时间是:
Ttotal = 采样时间 + 逐次逼近转换时间以 STM32F103 为例,ADC 时钟通常配置为 12MHz 或 14MHz。逐次逼近阶段每个通道至少需要 12.5 个时钟周期(12 位精度),而采样时间可以通过寄存器配置。常见选项有 1.5、7.5、13.5、28.5、41.5、71.5、239.5 周期。
这里有个很容易踩的坑:很多初学者直接把采样时间拉到最大 239.5 周期,觉得“采得越久越准”。实际情况是,对低内阻的信号源(比如稳压后的电源电压、运放输出),1.5 周期都够;对高内阻的信号源(比如直接接一个 100kΩ 电位器分压),采样时间太短会让电容充不满,转换结果偏低。反过来,如果采样时间太长,遇到快速变化的信号,采到的反而可能不是你要的那个瞬时值。所以采样时间的选取原则是:在保证电容充电到足够精度的前提下,尽量用短采样时间。工程上一般从 7.5 周期起步,遇到信号源高阻再往上调。
1.3 参考电压、满量程与增益误差
ADC 输出的数字值本身没有意义,它对应实际电压的公式是:
Vin = ADC值 × Vref / (2^N - 1)其中 Vref 是参考电压,N 是分辨率位数。多数 MCU 把 Vref 内部连接到 VCCA,也就是模拟电源引脚。所以模拟电源的稳定性直接决定了满量程的准确性。如果你把 VCCA 接错了,或者电源波纹很大,那即便软件写得再漂亮,采集结果也永远不会准。
还有一个容易被忽略的点:部分 MCU 的 ADC 存在增益误差和偏移误差。比如输入 0V 时理论输出 0,实际可能输出 3 或 4 个 LSB;输入接近满量程时,输出会少几十个 LSB。这种误差用“拿标准电压校准”的方式最直接:测两个已知电压,做一个一次函数标定,把 ADC 原始值映射到真实电压,精度提升非常明显。
2. 取平均,把所有花样都玩明白
2.1 简单累加平均的适用边界
ADC 采集到的每个数字都包含两部分:真实的信号分量和噪声分量。对于直流或慢变信号,最朴素的处理方法就是连续采样 N 次,求和后除以 N。这个操作能把随机噪声的方差缩小 N 倍,理论上信噪比能提升约 10×log10(N) dB。
但简单累加平均有两个坑。
第一个是求和溢出。你用的是 uint16_t 存 ADC 值,采 256 次再累加,最高会到 65535 附近,一不留神就溢出了。正确的做法是:边累加边判断,分多次平均,或者用 uint32_t 累加,最后再截断。
第二个是反应迟钝。固定次数平均会拖慢数据的刷新率。比如采样速率 1kHz,一次平均 100 次,那有效数据率只有 10Hz。如果系统需要快速响应(比如过流保护),这个延迟可能直接导致设备损坏。所以固定平均适合慢变信号,不适合实时性要求高的场景。
2.2 滑动平均与实时数据流
滑动平均是比固定平均更工程化的方案。它维护一个固定长度的窗口(比如 16 个样本),每采一个新样本,就丢掉窗口里最老的一个,然后对窗口内的 16 个数求平均。这样每个周期都会产出一个新的滤波结果,数据率不下降,延迟也只有窗口长度的一半。
实现上有两种办法:环形缓冲区和移位法。环形缓冲区适合窗口较大、数据率较高的场景,代码稍复杂但内存开销固定。移位法适合窗口较小的场景,代码简单,但窗口越大 CPU 开销越高。实际项目里我常用环形缓冲区,C 语言实现放后面实操部分。
窗口长度怎么选?原则是让窗口时间跨度大于噪声的主要周期。如果噪声主要来自 50Hz 工频,一个周期是 20ms,那么窗口至少覆盖一个完整工频周期,取平均才能抵消掉工频的起伏。如果你的采样率是 1kHz,窗口取 20 到 32 比较合适。
2.3 中值、限幅和加权:复合滤波才是王道
滑动平均对高斯随机噪声很有效,但对尖峰脉冲几乎无能为力。一次强干扰产生的野值,会直接拉偏整个窗口的平均值。这时候需要引入其他滤波器组合。
- 限幅滤波:把当前采样值和上一次有效值做差,差值超过阈值就丢弃,认为这是干扰。适合处理通信错误、开关毛刺。
- 中值滤波:连续采 N 次,排序后取中间值。对脉冲噪声、尖峰干扰有奇效,但实时性差,适合传感器信号后处理。
- 一阶低通滤波(EMA):
y = y_old + alpha × (x - y_old),其中 alpha 取 0 到 1 之间。alpha 越小,滤波越强,响应越慢。这个算法的好处是不需要大数组,内存占用极小,适合内存宝贵的 MCU。 - 加权平均:给不同时刻的采样值分配不同权重,越新的数据权重越大。典型做法是窗函数加权,这本质上是 FIR 滤波器的雏形。
实际项目中组合策略一般是:限幅滤掉大毛刺,滑动平均压制随机噪声,如果还嫌波动大,再叠一层 EMA。三层下来,波形会平稳很多,但注意响应速度也会变慢,具体取舍需要根据应用来调。
3. 精度不是位数,是信噪比
3.1 分辨率与有效位数:12位ADC不等于12位精度
很多刚入行的朋友以为“12 位 ADC 就是 4096 分之一的分辨率,精度一定很高”。这句话只对了一半。分辨率只是理论上能分辨的最小电压步进,实际能达到的有效精度用另一个指标衡量:有效位数(ENOB,Effective Number of Bits)。
ENOB 和信噪比(SNR)的关系是:
ENOB = (SNR - 1.76) / 6.02如果一颗 12 位 ADC 的信噪比只有 64dB,那它的有效位数其实只有 10.3 位。也就是说,最低的 1 到 2 位基本都在翻动,是噪声贡献的,不包含信息。
信噪比由什么决定?一是 ADC 本身的热噪声和比较器噪声,二是外部耦合进来的电源噪声和地噪声,三是参考电压的抖动。前两者靠器件选型,后者靠硬件设计。很多 STM32 系列内置 ADC 的有效位数在 10.5 到 11.5 位之间,并不是标称的 12 位。如果你真的要 16 位精度,光靠 MCU 内置 ADC 是不够的,需要外挂 ΔΣ ADC 或者先做放大和滤波。
3.2 电源、参考电压和地噪声:精度问题八成在硬件
实际调试中我发现,ADC 采集数据不稳定的原因,软件只占一小部分,大部分都在硬件布局。以下几个因素对精度影响最大:
- 参考电压(Vref)质量。Vref 上哪怕有几十 mV 的纹波,都会直接叠加到转换结果上。很多 MCU 的 Vref 和 VCCA 是同一个引脚,模拟电源必须单独滤波,最好用磁珠或电阻把模拟电源和数字电源隔开。
- 地弹噪声。ADC 的数字地和模拟地如果不做分区处理,数字电路的开关噪声会通过地平面耦合到模拟部分。画 PCB 时尽量让模拟电路和数字电路的地单点连接。
- 电源的去耦。ADC 电源引脚附近要有足够容量的去耦电容,通常是 100nF + 10uF 并联,电容要尽量靠近引脚。
- 参考电压漂移。Vref 会随温度漂移,要求高的场合要用外部精密基准源,比如 REF3030、ADR3425 之类的芯片,而不要指望内部基准有多准。
一句话总结:想提高 ADC 精度,先看电源、再接基准、再查布局,最后才查软件。软件只能解决随机噪声,永远救不了硬件带进来的系统性误差。
3.3 源阻抗、采样电容与电容充电误差
采样电容的充电过程不是瞬间完成的。信号源的输出阻抗和采样电容构成一个 RC 回路,充电时间常数 τ = R_source × C_sample。如果源阻抗太大,在有限的采样时间内电容充不满,转换结果就会偏低。这是一个很容易被忽视的误差源。
公式上,如果采样时间为 Ts,充电时间常数为 τ,那么充电误差大致为:
误差 ≈ V_input × e^(-Ts / τ)假设 C_sample = 4pF,R_source = 100kΩ,τ = 0.4us。如果采样时间只有 1us,误差比例还有 e^(-2.5) ≈ 8%,这已经非常可观。解决办法有三个:
- 选更长的采样时间,让电容有时间充电;
- 在信号进入 ADC 之前加一个运放跟随器,输出阻抗降到几十欧姆;
- 在 ADC 引脚对地放一个 1nF 到 10nF 的电容,作为“蓄水池”提供瞬时充电电流。这个方法很有效,但注意它会和源电阻构成低通滤波,影响高频信号带宽。
4. 实操:STM32多通道ADC高精度采集
4.1 硬件准备与引脚连接
用 STM32F103 做示范,这个型号的 ADC 寄存器结构和时序在绝大多数 STM32 上大同小异。硬件准备如下:
- 一块 STM32F103 核心板,ADC1 的通道 0、1、2 分别接到 PA0、PA1、PA2。
- 三个可调电位器,分别接到三个引脚上,中间抽头输出 0 到 3.3V 可调电压。
- PA0、PA1、PA2 引脚上各并联一个 100nF 电容到地,给采样电容提供瞬时充电电流。
- 串口通过 USB 转 TTL 连接到 PC,用来打印采集结果。
注意 VREF 引脚(如果有单独引脚)要接到模拟电源上,并加 100nF + 10uF 去耦电容。F103 的 VREF 通常内部固定接 VCCA,所以 VCCA 要处理好。
4.2 多通道扫描+DMA配置
多通道采样最省事的方案是使用扫描模式和 DMA。扫描模式会让 ADC 按照规则序列(SQR 寄存器)依次转换所有列出的通道,DMA 在每次转换完成后自动把结果搬到内存。这样 CPU 不用每个数据都去读寄存器,采集 3 个通道的平均成本很低。
参考代码如下,直接配置 HAL 库:
ADC_HandleTypeDef hadc1; DMA_HandleTypeDef hdma_adc1; static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = ADC_SCAN_ENABLE; // 扫描模式 hadc1.Init.ContinuousConvMode = DISABLE; // 用定时器触发,不设连续转换 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; // TIM2触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 3; // 3个通道 HAL_ADC_Init(&hadc1); sConfig.SamplingTime = ADC_SAMPLETIME_71CYCLES_5; // 通道0 -> 等级1,通道1 -> 等级2,通道2 -> 等级3 sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = 2; HAL_ADC_ConfigChannel(&hadc1, &sConfig); sConfig.Channel = ADC_CHANNEL_2; sConfig.Rank = 3; HAL_ADC_ConfigChannel(&hadc1, &sConfig); }DMA 配置改成循环模式,大小和 ADC 通道数对应。如果一次触发需要采集 3 个通道,缓冲区长度设为 3 的整数倍即可。
static void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn); hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式 HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); }多通道切换的坑在于:第一次扫描时,ADC 内部可能残留上次转换的状态。所以很多项目会先触发一次空转换,把第一个结果丢弃,再开始正式采集。后面常见问题章节会专门讲。
4.3 定时器触发与数据采集节奏
如果只是做电压采集,用查询或中断方式就够了。但如果你想让采样率恒定,或者想让 ADC 和 PWM 波形同步,就需要定时器触发。定时器 TRGO 信号作为 ADC 外部触发源,可以把采样点精确锁在时间轴上。
用 TIM2 产生 1kHz 触发信号:
static void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz / 72 = 1MHz htim2.Init.Period = 999; // 1MHz / 1000 = 1kHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim2); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); }定时器初始化后,ADC 每次触发都会按规则序列完成 3 个通道的转换,DMA 再把 3 个数值连续搬到内存。这个节奏非常稳定,适合做后续的数字信号处理。
4.4 软件层取平均与滤波实现
这里直接给出两个常用的 C 函数,一个是环形缓冲区滑动平均,一个是限幅 + 滑动平均的组合。
限幅 + 滑动平均:
#define ADC_WINDOW_SIZE 16 typedef struct { uint16_t buf[ADC_WINDOW_SIZE]; uint8_t index; uint32_t sum; uint8_t filled; } adc_filter_t; uint16_t adc_filter_process(adc_filter_t *f, uint16_t new_sample) { if (f->filled) { f->sum -= f->buf[f->index]; } else { // 窗口未满时不做减法,等待填满 } f->buf[f->index] = new_sample; f->sum += new_sample; f->index = (f->index + 1) % ADC_WINDOW_SIZE; if (f->index == 0) f->filled = 1; if (f->filled) { return (uint16_t)((f->sum + ADC_WINDOW_SIZE / 2) / ADC_WINDOW_SIZE); } else { return new_sample; } }取平均时我习惯加一个ADC_WINDOW_SIZE / 2的舍入补偿,避免整数除法永远向下取整造成零点几 LSB 的系统偏差。这个细节在累加平均 100 次以上时能看出区别。
限幅滤波可以放在滑动平均之前:
uint16_t adc_limiter(uint16_t prev, uint16_t curr, uint16_t max_step) { if (curr > prev + max_step || curr + max_step < prev) { return prev; } return curr; }max_step 根据信号的实际变化速率和 ADC 有效位数来定。比如 12 位 ADC,信号每周期正常变化 20 个 LSB,那 max_step 取 40 左右比较合适。取太小会把真实信号也滤掉,取太大毛刺滤不干净。
如果需要更强的平滑效果,再叠加一层 EMA:
float ema_alpha = 0.1f; uint16_t adc_ema(uint16_t prev, uint16_t curr) { return (uint16_t)(prev + ema_alpha * (curr - prev)); }注意 EMA 的初始值建议用第一次采样直接赋给 prev,否则从 0 开始爬坡会有一段很长的过渡时间。
4.5 校准与验证
配置完成后不要直接相信读数,要做两件验证。
第一,短接 ADC 输入到 GND,读一次原始值。理想情况是 0,实际可能有几个 LSB 的偏移。如果偏移大于 5 个 LSB,检查地线和 PCB 布局。
第二,用一个稳定的 2.048V 精密基准源接进去,读一次原始值。理想值是2.048 / 3.3 × 4095 ≈ 2543。如果差很多,检查 VREF 电压是否准确,然后做两点标定:
// 两个已知点: raw0->volt0, raw1->volt1 float scale = (volt1 - volt0) / (float)(raw1 - raw0); float offset = volt0 - scale * raw0; float raw_to_volt(uint32_t raw) { return scale * raw + offset; }标定之后的误差可以压到 1 到 2 个 LSB 以内。这一步比换任何滤波算法都管用。
5. 常见问题排查与避坑经验
5.1 多通道切换的第一个数据不准
这是最高频的问题:STM32 开启扫描模式和 DMA 后,读到的第一个数据的通道对不上,或者通道间的数据错位。原因是 ADC 规则序列中的每个转换完成后,结果会自动存入同一个数据寄存器,DMA 在转换完成中断后搬运数据。首次启动时,DMA 可能从第 0 号缓冲开始搬,而 ADC 已完成通道 0 的转换,但首次采样的电容充电过程可能不完整。
解决办法有几个,我一般建议直接在初始化后执行一次“假转换”:
// 初始化完成后,启动一次转换并等待完成,丢弃数据 HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); HAL_ADC_Stop(&hadc1); // 此时再启动DMA采集,数据就稳定了 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 3);还有一个更稳妥的办法:DMA 循环模式配合HAL_ADC_ConvHalfCpltCallback处理数据,用环形缓冲索引来保证三个通道的分组始终对齐。不要指望硬件每次都非常“听话”,软件上做一次通道对齐校验更可靠。
5.2 采样结果漂移与跳动
ADC 结果缓慢漂移,或者同一电压下多次采集差别很大,这类问题要从三个方向排查。
第一查电源。用示波器看 VCCA 引脚电压,波纹超过 50mV 时 ADC 结果基本不可能稳定。处理方式是加强去耦,模拟电源和数字电源之间串磁珠。
第二查基准。内部基准受温度影响明显,温度变化 10 度可能漂移几十到上百 ppm。如果环境温度变化大,用外部基准芯片替换。
第三查采样时间。信号源阻抗高时,加大采样时间;或者给 ADC 输入并 1nF 电容。这个前面已经论证过,不再赘述。
5.3 几个我踩过的实际坑
我最初做多通道采集时,遇到的第一个诡异现象是:通道 0 的读数每次都比实际高约 3%,通道 1 和 2 却正常。排查了半天,发现问题出在 PCBA 布局上——通道 0 的走线从 DC-DC 电感旁边经过,高频开关噪声被耦合进了信号线。加上一个 10nF 对地电容后,读数立刻恢复正常。
第二个坑是 DMA 缓冲区数据类型。我用uint16_t数组存放 ADC 数据,但 DMA 配置里如果意外把内存数据宽度设成了DMA_MDATAALIGN_BYTE,搬运结果就会错乱,数据变成乱序甚至翻倍。这类问题不看配置代码基本找不到原因。
第三个坑更隐蔽:开启连续转换后,我以为 DMA 循环模式会自动触发新一轮转换,结果发现 DMA 在缓冲区填满后停住了。原因是连续转换模式下,ADC 转换完成事件和 DMA 请求之间的联动受DMA_CTRL寄存器的配置影响,有些型号必须额外开启DMA_Request功能,否则只搬一次。解决方法是改用定时器触发循环转换,或者去查具体芯片手册里的 DMA 请求使能位。
5.4 快速排查表
| 现象 | 优先排查原因 | 常用解决手段 |
|---|---|---|
| 读数整体偏高或偏低 | VREF 电压不准 | 量 VREF,校准,或换外部基准 |
| 读数跳变很大 | 电源纹波/地噪声 | 加强去耦,分区布线 |
| 多通道数据错位 | DMA 起始阶段未同步 | 初始化后假转换,环形缓冲分组对齐 |
| 高内阻信号源读数偏低 | 采样时间不足 | 加大采样时间,或加运放跟随 |
| 零点有固定偏移 | 芯片 ADC 偏移误差 | 两点标定,软件修正 |
| 采集值偶发毛刺 | 强干扰脉冲 | 限幅滤波或中值滤波 |
| DMA 不循环 | 未使能循环模式 | 配置DMA_CIRCULAR |
上面这张表基本覆盖了我这几年接触到的 90% 的 ADC 问题。大多数所谓的“ADC 精度差”,最终都不是芯片不行,而是硬件设计或者配置方式有问题。
写到最后,还是想重复那句老话:ADC 这活儿,七分硬件三分软件。先把电源、基准和布局做好,再用平均和滤波把残留的噪声压干净,数据自然就稳了。这套方法我在十来个项目里反复验证过,照着做基本不会翻车。如果你手头的精度要求更高,可以考虑外部基准、程控增益放大、或者外挂高分辨率 ADC,但前面讲的采样原理和滤波逻辑依然适用。