
做嵌入式开发这几年我越来越觉得ADC这玩意儿就像是单片机感知物理世界的一扇窗。说它是嵌入式里最重要的外设之一一点都不夸张。温度、电压、电流、压力、光照这些真实世界里的模拟量最终都要靠 ADC 变成单片机认识的数字才能参与逻辑判断和算法处理。今天就把我从零入门时踩过的坑、总结出来的经验连同 ADC 底层的那些工作原理一块儿聊透。这篇内容既有从模拟信号和数字信号说起的基础扫盲也有基于 STM32 的实操记录和参数计算适合刚接触嵌入式的新手也适合那些 ADC 用得很熟但对内部机制一知半解的朋友再补一刀。1. 信号那点事为什么嵌入式里绕不开模拟与数字1.1 模拟信号连续的世界我们生活的物理世界本质上是模拟的。温度从 25.1 度慢慢升到 25.2 度中间经过了 25.11、25.12它是连续变化的没有跳变。麦克风收到的声音扬声器发出的声波电热偶产生的微弱电压全都是随时间连续变化的物理量。模拟信号的特点就是“在时间和幅值上都是连续的”你用示波器去看它就是一条平滑的曲线。这条曲线携带着无限的信息。理论上你把信号放大多少倍总能看到更细的细节。但也恰恰因为连续它就特别容易受到干扰。一根长长的模拟信号线就是一个完美的天线把周围的电磁噪声全收进来叠加在本来就微弱的信号上导致结果漂移、抖动。这也是为什么在工业现场4~20mA 电流环比 0~5V 电压传输更受欢迎电流信号抗干扰能力更强。很多人刚开始做采集拿到一个模拟量就直接往单片机引脚上一接然后发现读数乱跳就开始怀疑是 MCU 坏了。其实多半不是是信号的干净程度出了问题。模拟世界里噪声无处不在这一点从入门第一天就要有心理准备。1.2 数字信号0 和 1 的简明世界数字信号就简单多了。高电平是 1低电平是 0幅值上只有两个状态时间上由时钟节拍划分。哪怕线上混进去一点噪声只要没有大到把 0 变成 1、把 1 变成 0信息就不会丢失。这种“二值化”的表达方式带来的是超强的抗干扰能力和精确的逻辑运算能力。你可能要问既然数字信号这么好那直接把传感器做成数字输出不就行了吗对现在很多数字传感器像 I2C 接口的 SHT30、单总线的 DS18B20就是这么干的它们在芯片内部已经完成了模拟到数字的转换吐出来的直接是数据。但问题是第一不是所有物理量都能轻易集成为数字传感器很多工业变送器、电流采样、电池电压监测仍然以模拟电压形式输出第二即便传感器输出数字单片机内部在采集某些内部参数比如芯片温度、电源电压时依旧要用到模拟采集通道。所以 ADC 这个外设是无论如何也绕不开的。1.3 为什么需要 ADC现实与逻辑之间的翻译官单片机本质上是个纯粹的数字逻辑机器它只会算 0 和 1。而现实世界的物理量全是连续的模拟信号。ADC 就是这两者之间的“翻译官”。ADC 全称是 Analog-to-Digital Converter模数转换器它的任务是对模拟信号按照一定的规律“抽样”和“量化”用有限位数的二进制数去近似表示某个时刻模拟信号的瞬时值。比如一个 12 位的 ADC参考电压是 3.3V那么它把 0~3.3V 的电压范围切成 4096 份每一个读数对应一个 3.3/4096 ≈ 0.806mV 的电压台阶。采集到的数值 2048大约就代表 1.65V。这个过程里最核心的就是“量化”——精度越高台阶越细数字与模拟之间的误差就越小。但精度越高成本和转换时间越长工程上需要平衡。2. ADC 工作原理核心知识点一次拆透2.1 三步走采样、保持、转换很多资料把 ADC 和 SAR ADC 混着讲其实搞清楚一个 ADC 工作的完整流程心里就有谱了。整个过程可以分成采样、保持、量化编码三个阶段。采样是指在一个特定的时间点上把模拟信号的电压值“抓住”并记录下来。抓住之后不能马上扔掉得保持住因为后级的转换电路需要稳定的电压来做比较。保持阶段就像给人拍了一张照片把瞬间的状态定格下来期间电压不能变化太大否则转换结果就不准了。真正出数字结果的是转换阶段。转换器把保持住的电压和内部的参考电压进行比较、对拼最终生成 N 位二进制数。这个过程需要时间这个时间就是 ADC 的转换时间。采样保持的时间则取决于你设置的采样周期。很多人只关注 ADC 的位数忽略采样保持阶段其实采样时间不够会导致结果偏差尤其是当信号源内阻很大的时候。2.2 SAR 逐次逼近一个二分查找的芯片版市面上单片机内置的 ADC 绝大多数是 SAR 型全称 Successive Approximation Register逐次逼近寄存器型。它的原理特别像你猜一个 0~100 之间的数字猜法不是从 1 到 100 一个一个试而是先猜 50对方说大了再猜 25再猜 12……每猜一次范围缩小一半很快就逼近真实值。在电路上SAR ADC 内部有一个比较器、一个 DAC 和一个逐次逼近寄存器。第一次把最高位置 1也就是让 DAC 输出二分之一参考电压去和采样的电压进行比较。如果采样电压大说明真实值在上半区那这个 1 保留否则置 0真实值在下半区。接着移动下一位再比较再缩小范围。N 位的 ADC 就需要比较 N 次所以 12 位 ADC 的转换周期通常是固定的 N 个时钟周期再加一点开销。这就是为什么从热词里看到“逐次逼近”这个词它就是 SAR ADC 的内核逻辑。这个方案的优点是速度适中、功耗低、面积小、价格便宜非常适合集成在 MCU 里做中等速度的通用采集。如果你遇到高精度、低速度、高分辨率的场景比如称重、温度测量就得换用 Delta-Sigma 型 ADC比如 HX711、ADS1232、MCP3561 这类外挂芯片它们位数能做到 24 位原理是完全不同的思路我后面会延伸讲一下。2.3 分辨率、参考电压与量化误差的连带关系关于分辨率三个变量必须绑在一起理解位数 N、参考电压 Vref、转换结果 D。公式极简单输入电压 Vin D × Vref / 2^N。举例STM32F103 内部 ADC 是 12 位Vref 接 3.3V那么一个 LSB最低有效位代表的电压就是 3.3 / 4096 0.8057mV。你读到的 ADC 数值 1000换算回电压就是 1000 × 0.8057 805.7mV。这里有几个反直觉的细节第一个是参考电压越不稳整个转换结果就越不准。Vref 是 ADC 的“标尺”标尺本身伸缩了你再怎么读都是错的。所以低噪声应用中Vref 一定要单独滤波最好用专门的基准芯片供电。第二个是量化误差的固有存在。模拟电压落在某个区间内最后都只能变成一个离散的数字码这个“四舍五入”造成的误差叫量化误差理论最大值是 ±0.5 LSB。想彻底消除不可能只能靠增加位数来减小它。12 位 ADC 的量化误差约 0.4mV如果你做千分之一精度的电压表靠内置 12 位 ADC 就不太够看了至少得外挂 16 位或者 24 位。3. STM32 实操从零配通一颗 ADC3.1 硬件连接与引脚选择以 STM32F103 系列为例它的 ADC1、ADC2、ADC3 各自有多个外部通道我们最常用的就是 PA0~PA7 对应 ADC1 的通道 0~通道 7。虽然 PA0 和 PA1 都标了 ADC 输入功能但要注意如果引脚同时被配置成其他复用功能ADC 通道是无效的所以建工程的时候要检查引脚冲突。硬件上最简单的做法是传感器输出直接连 PA0同时 PA0 对地接一个 100nF 滤波电容。别小看这个电容它能滤掉高频噪声让采样结果稳定很多。如果传感器输出阻抗比较大比如分压电阻都是 10K 级别的那一定要在 ADC 引脚前加一个运放跟随器否则采样保持电容充不满读数会偏低。参考电压引脚 VREF 和 VREF-在 F103 的小封装上VREF 内部已经和 VDDA 绑定了所以 VDDA 的电源一定要干净。我见过不少板子VDDA 直接连了开关电源的输出ADC 读出来的数据和实际电压差了十几毫伏怎么校准都拉不平。加一个磁珠加 10uF 电容的 LC 滤波问题就消失了。3.2 用 HAL 库还是寄存器两条路都给你HAL 库的代码写起来逻辑清晰适合快速开发我先写一段 ADC1 单通道配置ADC_HandleTypeDef hadc1; static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig {0}; hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_DISABLE; // 单通道模式 hadc1.Init.ContinuousConvMode DISABLE; // 单次转换 hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; // 软件触发 hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; // 右对齐 hadc1.Init.NbrOfConversion 1; // 转换通道数 if (HAL_ADC_Init(hadc1) ! HAL_OK) { Error_Handler(); } sConfig.Channel ADC_CHANNEL_0; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; // 55.5个采样周期 if (HAL_ADC_ConfigChannel(hadc1, sConfig) ! HAL_OK) { Error_Handler(); } }启动一次转换并读取值的代码uint32_t adc_read(void) { HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 100); return HAL_ADC_GetValue(hadc1); }如果你追求实时性、要打造高效代码寄存器操作是必须掌握的。F103 的 ADC 读取核心就三个寄存器CR1、CR2 和 DR。配置流程是这样void adc_gpio_init(void) { RCC-APB2ENR | RCC_APB2ENR_ADC1EN | RCC_APB2ENR_IOPAEN; GPIOA-CRL ~(0xF 0); // PA0 配置为模拟输入 GPIOA-CRL | (0x0 0); RCC-CFGR ~(0x3 14); // PCLK2 72MHzADC 预分频 6ADC 时钟 12MHz RCC-CFGR | (0x2 14); }你看到 RCC_CFGR 里那个 2意思是 ADC 工作在 12MHz这是 F103 的极限。12 位 ADC 在 12MHz 下采样周期设 55.5总周期就是 71.5转换时间大约为 71.5/12MHz ≈ 5.96us换算成采样率大概是 168kSPS。这个速度对多数传感器采集足够了。然后启动软件转换等待 EOC 标志位即可void adc_start_conversion(void) { ADC1-CR2 | ADC_CR2_ADON; // 开启 ADC ADC1-CR2 | ADC_CR2_SWSTART; // 启动转换 while (!(ADC1-SR ADC_SR_EOC)); // 等待转换完成 uint16_t value ADC1-DR; // 读取结果 }寄存器方式虽然啰嗦但能让你把每个位都看清楚调试的时候心里更有底。建议初学者把两种方式都写一遍你才会真正理解哪些参数是挂在时钟树上的哪些是挂在配置寄存器里的。3.3 采样周期怎么选内阻、电容与时间常数的博弈STM32 的 ADC 采样时间是可以程序化的有 1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5 等几个档位可选。很多人随手选个最快的 1.5 周期结果发现测出来的电压偏低这跟信号源内阻大有很大关系。ADC 内部等效电路是一个采样电容典型值 4pF 左右加上引脚寄生电容总共大概 10pF。当你切换通道或者开始采样时采样开关导通外部信号源要通过自身内阻给这个电容充电。如果信号源内阻是 10KΩ那么时间常数就是 10K × 10pF 0.1us。要让采样电容上的电压在误差范围内跟得上信号源至少需要 5 倍时间常数那就是 0.5us。ADC 时钟 12MHz 下一个周期是 0.083us所以至少要选 7.5 个周期稳妥一点选 55.5 周期。如果信号源内阻是 100KΩ情况就严峻多了时间常数变成 1us5 倍就是 5us换算成时钟周期就得选 55.5 甚至 239.5 了。所以并不是越快越好要根据源阻抗综合考虑。高内阻信号源还有一个更科学的解法在引脚前加运放跟随器。运放的输出阻抗极低可以瞬间灌满采样电容这样哪怕采样周期选 1.5 都没问题。不过加运放会增加成本和面积对精度要求不高的场景老老实实加长采样时间最划算。4. 让数据更可靠多通道、DMA 与数据后处理4.1 多路采集的几种实现思路只采一路 ADC 太浪费了实际项目中往往要同时监测多个电压、电流信号。多通道并发时就引出了扫描模式和 DMA 两个概念。STM32 的 ADC 支持扫描模式你把多个通道按顺序排好启动一次转换它会自动把 0 通道、1 通道、2 通道挨个转换一遍结果依次存入数据寄存器。如果开了 DMA转换完一个通道数据会自动搬到内存里CPU 全程不用干预转换完所有通道再产生一次中断通知你。这种做法在高速、连续采集场景下特别爽。下面是基于 HAL 库配置 ADC1 四通道加 DMA 的核心代码static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig {0}; hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; // 扫描模式 hadc1.Init.ContinuousConvMode ENABLE; // 连续转换 hadc1.Init.NbrOfConversion 4; // 其他略 sConfig.Channel ADC_CHANNEL_0; sConfig.Rank 1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; 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); sConfig.Channel ADC_CHANNEL_3; sConfig.Rank 4; HAL_ADC_ConfigChannel(hadc1, sConfig); }DMA 的配置也很关键数据宽度必须匹配。F103 的 ADC 数据寄存器是 16 位的DMA 传输宽度要设成 Half Word半字如果设成 Word内存里数据会错位乱跳我第一次搞就栽在这上面。static void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); 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); }启用后定义一个数组volatile uint16_t adc_buf[4]DMA 就会不断刷新这四路数据CPU 可以干别的事需要时直接读数组非常优雅。4.2 滤波又不过度钝化几种常用的软件滤波算法硬件上把信号调理好了软件滤波仍然不能省。真实世界里工频干扰、电磁噪声、开关电源纹波都会让 ADC 读数跳来跳去。我常用的三种滤波方案各有适用场景。第一种是限幅滤波说白了就是“防突变”。如果本次读数与上次读数之差超过了设定的最大偏差阈值比如 50 个 LSB就丢弃本次沿用上次值。这个方案对付随机毛刺特别有效但不处理连续缓慢的噪声。第二种是中位值滤波。连续采 N 次排序后取中间值。N 通常取 5 或 7偶数的中间要取平均比较繁琐。这个方法对脉冲干扰有奇效你连续采 5 次哪怕两次被干扰了排序后取中间也大概率是正常值。第三种是滑动平均滤波。维护一个环形缓冲区每来一个新值就把最早的丢掉对窗口内 N 个数取平均。平滑度不错适合温度、湿度这种慢变量。要注意的是窗口越大延迟越大对需要快速响应的电流环控制滑动平均反而会坏事。下面是滑动平均的一个极简 C 实现直接用数组做环形缓冲#define FILTER_N 8 static uint16_t filter_buf[FILTER_N]; static uint8_t filter_index 0; static uint32_t filter_sum 0; uint16_t moving_average_filter(uint16_t new_value) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] new_value; filter_sum new_value; filter_index (filter_index 1) % FILTER_N; return (uint16_t)(filter_sum / FILTER_N); }另外必须提示一点软件滤波替代不了硬件滤波。你花一百行代码平均一万次也滤不掉一个有规律的共模噪声该上的 RC 低通滤波、磁珠、屏蔽地一个都不能少。这两者是叠加关系不是替代关系。4.3 校准为什么你的 ADC 读数和万用表对不上等你能测出一个稳定数字了就该跟万用表较个真了。很多人的 ADC 读数会整体偏高或偏低这不是 ADC 坏了而是增益误差和偏移误差。偏移误差是指输入为 0V 时ADC 读出的不是 0而是一个十或几十的小数字。增益误差是指输入接近满量程时比例关系跑偏了。这两类误差在单片机制造时就有虽然出厂前做过调整但温度变化和供电差异都会让误差漂移。校准的基本思路是做两点校准。比如你的实际输入电压是 0.5VADC 读数是 620实际输入电压是 3.0VADC 读数是 3720。那么比例系数就是 (3.0 - 0.5) / (3720 - 620) 2.5 / 3100 ≈ 0.00080645偏移量是 0.5 - 620 × 0.00080645 ≈ -0.000001。以后每次读到的数字 D真实电压就按 V D × 0.00080645 - 0.000001 来算。更省事的办法是直接接一个已知的精密电压源采集 10 个点用最小二乘法拟合一条直线把斜率和截距存到 Flash 里每次上电后读取并校准。这也是很多商用仪表背后做的事情。我们在普通 MCU 项目里至少做 0V 和参考电压两个点的校准就能把误差压到很低。5. 嵌入式中 ADC 的延伸场景与选型思路5.1 什么时候该换外挂 ADC从精度和速度维度分析内置 12 位 ADC 确实够用但当精度要求到 0.1% 以上或者需要真正的高分辨率时就得考虑外挂 ADC 了。单从位数看16 位的 ADS1115 算是性价比之选I2C 接口4 通道内带可编程增益放大器特别适合电池电压监测、温度采集这类中低速场景。而称重传感器这类应用输出信号极微小毫伏级需要高增益和高分辨率一般选 24 位的 HX711。HX711 内部集成了低噪声放大器可以直接接电桥转换速度反而不高典型就是 10SPS 到 80SPS。类似的还有 ADS1232对比起 HX711ADS1232 的速度和精度更稳定工业场景用得更多。如果你要测的是音频或者振动信号动辄几十 kHz 的采样率那就得看 SAR 型的高速外挂 ADC 了比如 ADS9224R 这类。外挂 ADC 虽然精度高但通信和驱动的工作量也上来了I2C 或 SPI 协议要自己写校准逻辑也要自己做成本也远高于 MCU 内置 ADC。所以我的经验是能用内置就内置内置不够用了再去外挂不要为了“音质”盲目上高位数 ADC。5.2 模数混合系统设计里最容易犯的几个错模数混合电路设计有几条血泪教训我几乎在每次评审里都要提一遍第一数字地与模拟地必须单点连接。如果在 ADC 附近直接把模拟地和数字地大面积短接数字噪声会直接灌进模拟采样回路。通常做法是在 PCB 上把模拟区域单独划一块地通过一个 0 欧电阻或者磁珠单点连回主地。第二ADC 参考电压引脚别偷懒。F103 这类芯片虽然没有独立 VREF 引脚但许多型号比如 STM32F407是有的VREF 不能直接接电源得用低噪声基准源或者至少加一阶 LC 滤波。第三软件上也要时刻提防“数据撕裂”。如果在主循环里直接读 16 位 ADC 寄存器而高位和低位是分两次读取的中间万一被 DMA 或其他中断更新了那读到的就是一个撕裂的数。最稳妥的方案是定义成 volatile并做两次读取比对或者干脆停掉中断再读。5.3 从“会用”到“会调”我的一点心里话经常有初学者问我嵌入式学习路线到底该怎么走要不要一上来就直接啃嵌入式内核源码。我始终认为先把手上的外设玩透彻、把每个寄存器吃透比什么都强。ADC 就是这样一面镜子你把它的采样时序、触发方式、DMA 搬运、滤波算法、校准逻辑都弄明白了再往后的 CAN、以太网、USB 这些复杂外设都只是时间和经验的问题。我自己也踩过不少坑比如第一次做多通道采集配了 ADC1 的通道 0~3却忘了初始化对应的 GPIO结果读到的数据全是 4095愣是花了半天才查出来。后来习惯写驱动前先把原理图和数据手册对应着看一遍再下笔写代码都快成强迫症了。这种经验没办法靠理论补只能靠实际项目慢慢攒。这篇文章你要是能从头看到这儿说明你是真想搞懂 ADC。别着急找个开发板把单通道跑通再扩展多通道和 DMA最后把采集的数据用串口发出来和万用表对比一下。这个过程走完你对模拟信号、数字信号和单片机外设的理解会上一个台阶。以后做项目再遇到采集不准、数据乱跳你脑海里会自动排列出好几种排查方向这就是入门到进阶的标志。