模拟信号和数字信号之间的那道桥,说到底就是ADC。做嵌入式这几年,我见过太多人一上来就对着寄存器猛啃,结果连“为什么采样要保持时间”“为什么12位ADC读出来不是4095”这种关节都没打通。这篇东西不端着,就从模拟信号和数字信号这对老冤家讲起,把ADC外设的骨架、SAR型逐次逼近的套路、STM32上的实操配置,还有校准和滤波那些破事,一次说清楚。
1. 先把地基夯实:模拟信号与数字信号到底差在哪
1.1 连续与离散:一个像水流,一个像台阶
模拟信号最大的特点是连续。你在物理世界里摸到的电压、温度、压力、声音振动,本质上都是连续变化的量。比如一个NTC热敏电阻分压出来的电压,会随着温度平滑地上下浮动,理论上你可以取到小数点后无限位的值。这种信号信息量极大,但缺点也很明显:容易被噪声污染,难以直接存储和运算,更没法被单片机这种数字器件理解。
数字信号则是离散的,它只有高低电平两种状态(或者是有限个离散码值)。单片机内部跑的都是这种0和1的二进制逻辑,处理速度快、抗干扰强、可以无限复制不损耗。但数字世界有个先天缺陷:它天生对连续的东西“过敏”。你没法直接告诉CPU“当前温度是26.5312度”,CPU只认识一串二进制数。所以模拟世界和数字世界之间,必须有一座桥——这就是ADC存在的根本原因。
我经常拿楼梯和斜坡来打比方。模拟信号是斜坡,你可以站在任意位置;数字信号是楼梯,你只能踩在离散的台阶上。ADC要做的事,就是拿一把“数字标尺”去量斜坡上的某个点,量出来的结果必然落在某一个台阶上。台阶的级数越高(位数越多),量得就越精细,但永远不可能完全等于真实值——这就是量化误差的本质。
1.2 采样定理与“微积分”那道坎
很多初学者会忽略一个关键概念:ADC不只是把电压变成数字,它是在“一顿一顿”地看这个世界。AD转换的过程,是对连续的模拟信号做周期性的“快照”,这叫采样。每秒钟快照多少次,叫采样率。这里就引出了一个绕不开的理论:奈奎斯特采样定理——采样率必须大于信号最高频率的两倍,否则会混叠,恢复出来的波形是假的。
这个道理我用一个实际场景说明白:假设你要测一个100Hz的正弦波,采样率如果只有150Hz,采出来的点连起来会变成一个低频的假波形,频率完全不对,数据就是错的。实际工程中我一般会留足裕量,采样率至少是信号频率的5到10倍,比如采集音频做到44.1kHz就是为覆盖20kHz人耳上限并留了余量。另外还有个容易被忽略的点:ADC的采样保持电路需要一定时间把外部信号“锁住”,这段时间内信号必须保持稳定,否则采出来的就是模糊值。这对应到实际参数就是采样周期和建立时间,后面讲配置的时候会具体展开。
其实这里面还藏着一层跟“微积分”有关的理解。信号重建时,数字值通过DAC(数模转换器)变成模拟量,再通过低通滤波平滑成连续的波形。从数学上讲,采样是对信号做离散化,重建是做插值,本质上就是微积分领域的操作。做嵌入式不一定要会推公式,但心里得有这个框架:采样是离散化,重建是平滑化,两头都通了,ADC才算真正入门。
2. ADC外设工作原理:看看里面到底怎么折腾
2.1 SAR型逐次逼近:像用天平称电压
市面上嵌入式的ADC种类不少,有Flash型、积分型(双斜率)、Sigma-Delta型和SAR型(逐次逼近寄存器型)。单片机里最主流的是SAR型,STM32、NXP的S32K、STC8G这些基本都是SAR架构。SAR的核心逻辑是二分法比较,我拿天平称东西来类比,一下就通了:假设砝码库是128克、64克、32克、16克、8克、4克、2克、1克,要称130克的物体,第一次放128克,不够,加上64克就超了,于是64克去掉,逻辑简单——每一步都在“试错”,通过比较器判定是偏大还是偏小,偏大就丢掉这个砝码,偏小就保留。
SAR ADC内部有四个关键部分:采样保持电路、比较器、DAC电阻网络和逐次逼近寄存器(SAR)。启动转换后,先让采样保持电路捕获输入电压VIN,然后SAR先把最高位置1(比如12位ADC就把第11位置1),对应的DAC输出半个满量程电压。比较器比较VIN和VDAC,如果VIN大于VDAC,这个位就保留为1,否则清零。然后换下一位继续同样的操作。12位ADC需要12个时钟周期做完12次比较,每个周期只解出1位,从最高位到最低位依次逼近,最后输出完整的数字码。
这种结构的好处是转换时间确定、速度快、功耗低,适合中高采样率的场景。缺点是精度受限于DAC网络的电阻匹配度,做不到特别高的位数。工业上常见的12位SAR ADC做电机电流采样、电源电压监控、温湿度采集完全够用。如果你需要称重传感器那种高精度场景,HX711这类24位Sigma-Delta型才是正解,原理不同,SAR搞不定那么低的噪声和那么高的分辨率。
2.2 关键参数不能只会看位数:分辨率、参考电压与建立时间
很多人选ADC只看“是12位还是16位”,这是最常见的误区。位数只是分辨率,它决定你把参考电压切成多少份。12位即满量程被切成4096份,16位是65536份。但真正决定你能测多小电压的,是分辨率对应的最小量化步进。计算公式很简单:LSB = VREF / 2^N。比如参考电压3.3V、12位ADC,每个LSB约0.806mV。这意味着低于0.8mV的电压变化,ADC根本分不出来。说白了你没法用12位ADC去做微伏级别的测量,因为量化噪声就已经把这个量级的信号淹没了。
参考电压这个参数更是个大坑。ADC的输出码值和参考电压是直接挂钩的,参考电压不稳,转换结果就跟着抖。STM32的VREF+引脚如果直接接3.3V而3.3V是从LDO出来的,LDO本身的纹波噪声会直接耦合进采样结果里。我做过一个采集电池电压的项目,用内部参考电压(比如STM32的VREFINT内部参考)而不是外部VREF,稳定性明显好很多。精度要求高的场合必须用外部基准源芯片,比如REF3030这种,温漂低到几个ppm,价格也不贵。
还有两个参数经常被忽略,一个是采样保持时间(采样周期),另一个是转换时钟频率。ADC内部开关导通给采样电容充电需要时间,时间太短,电容电压还没稳定到输入电压就开始比较,会产生不可预测的误差。STM32每个ADC通道都可以配置独立的采样时间,默认值通常偏短,我实际测过,配置成最大采样时间比如239.5个周期,比配置成1.5个周期,采集同一个稳定电压的抖动明显小很多。另一个关键是ADC时钟不能超过外设允许的最大值,比如大部分STM32的ADC时钟限制在36MHz或更低,超了转换结果就不准,这是数据手册里的绝对最大值,不能抱着侥幸心理去超。
2.3 DAC和ADC的关系:一对桥梁兄弟
讲DAC是因为ADC外设往往和DAC成对出现,而且理解DAC的工作原理能反向帮助你理解ADC。DAC负责把数字码变成模拟电压,它的基本实现方式就是电阻分压网络,比如R-2R梯形网络或者权电阻网络。12位DAC内部有一串电阻,每个位对应一个开关,把所有开关状态组合起来,输出端就得到一个和输入数字码成正比的电压。
为什么我建议你连着学DAC?因为调试ADC的时候,最直接的办法就是用一个已知的稳定电压灌进ADC,然后看读到的码值是否符合预期。如果你手头有DAC,就可以自己生成0到参考电压之间任意精度的电压,无需外接信号发生器,这个闭环调试思路非常实用。
3. 实操:从CubeMX配置到代码,一步步把ADC跑起来
3.1 基于STM32 HAL库的单通道采集
嵌入式ADC说一千道一万,最终要落到代码上。我以STM32F103系列的HAL库为例,因为这是最普及的开发平台。其实逻辑是通用的,换到任何芯片都一样:开时钟、配引脚、配ADC参数、校准、启动转换、轮询或中断读取结果。
第一步,在STM32CubeMX里把ADC1的IN0通道打开,对应引脚通常是PA0。参数配置里需要关注三个:分辨率(Resolution)选12位,对齐方式选右对齐(右对齐方便直接当整数用),采样时间(Sampling Time)选最大档,F103上是239.5周期。转换模式选单次,触发方式选软件触发,用轮询方式读取。
生成工程后,核心代码其实就是几行。初始化部分CubeMX已经帮你生成了,你需要写的只有采集函数:
uint16_t ADC_GetValue(void) { uint16_t adc_value = 0; HAL_ADC_Start(&hadc1); // 启动一次转换 if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) // 等待转换完成 { adc_value = HAL_ADC_GetValue(&hadc1); // 读取12位转换结果 } HAL_ADC_Stop(&hadc1); // 停止ADC return adc_value; }这段代码看着简单,但有三个细节必须说透。第一,如果你的转换开启了连续模式,就不需要每次读都Start和Stop,启动一次,反复读取即可。第二,PollForConversion的等待超时时间不能给太短,10毫秒是经验值,如果你把采样时间设得很大,单次转换的总时间可能超过10毫秒,要相应调大超时值。第三,HAL_ADC_GetValue返回的是寄存器里的16位数据,但分辨率是12位,所以实际有效范围是0到4095,不是0到65535。我第一次用HAL库时没注意这个,拿返回值直接算电压,结果算出来的电压全是错的。
3.2 多通道与DMA:读4个通道要怎么设计
做项目时很少只采一路信号,更多情况是多路传感器、多路电压监控。多通道采集有几种方案:轮询逐个通道读、中断里切换通道、DMA自动搬运。我最推荐DMA方案,因为转换过程中CPU完全不用管,DMA把结果自动搬到内存数组里,CPU等DMA传输完成中断就行,效率最高。
多通道DMA的配置里有个反直觉的地方:把ADC的扫描模式(Scan Mode)打开,然后根据通道数配置转换序列长度。比如用ADC1的IN0到IN3四个通道,序列长度设为4,序列1到4分别映射到IN0到IN3。DMA设置为循环模式(Circular),数据宽度半字(16位),内存地址指向一个uint16_t adc_values[4]数组。注意DMA的buffer大小一定要和通道数匹配,否则数据会错位——这个错位问题特别容易让人怀疑人生,我debug过一整天才发现是DMA接收数组长度配错了。
代码层面,先启动ADC的DMA传输:
uint16_t adc_values[4]; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_values, 4);之后DMA会自动循环采集四个通道,每次转换序列完成都会更新一遍数组。在回调函数里做数据处理即可:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { // 此时 adc_values[0]~[3] 分别对应 IN0~IN3 的最新值 } }这里有个坑:Stm32的DMA传输完成回调是在整个转换序列结束后触发的,不是你每转换完一个通道就触发一次。所以你拿到的四个值,虽然在同一批完成中断里,但它们的时间戳并不一致,有先后顺序。如果四个通道的信号都是缓变的,比如温度、湿度之类的,影响不大。但如果你要采集的是快速变化的波形,又想对齐时间点,那就得用注入组或者多ADC同步模式,这是后话了。
3.3 软件滤波:别急着上卡尔曼,先用滑动平均
从ADC读回来的原始数据往往都有噪声,尤其是工业环境里电机启停带来的共模干扰,会让你看到采集值在几十个数之间来回蹦。新手第一反应是上卡尔曼滤波,我劝你先冷静——卡尔曼滤波需要建模系统噪声和观测噪声,参数调不好,效果反而不如简单滤波。
我实际项目里用得最多的是滑动平均滤波(移动平均),思路是维护一个固定长度的缓冲区,每次新采集值进来,就丢掉最老的那个值,对缓冲区所有值取平均。这个滤波方法的关键是窗口长度的选择:窗口太长,响应变慢;窗口太短,滤波效果差。采集直流信号比如电源电压,窗口给到16到32都很稳;采集动态信号比如旋钮电位器的电压变化,窗口给到8就差不多,再长就会觉得手感“黏”。C语言实现很简单:
#define FILTER_LEN 16 static uint16_t filter_buf[FILTER_LEN]; static uint8_t filter_index = 0; static uint32_t filter_sum = 0; uint16_t ADC_Smooth(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_LEN; return (uint16_t)(filter_sum / FILTER_LEN); }这个实现里有个细节要注意:filter_sum是32位的,因为16个12位值加起来最大是65520,已经超过16位无符号整数的上限了。如果你把filter_sum定义成uint16_t,溢出之后结果会变得莫名其妙。我见过很多人写滤波代码踩这个坑,排查半天还以为是ADC坏了。
比滑动平均更省内存的中位值滤波也值得掌握——连续采N次排序取中间值,对脉冲噪声有奇效。但在STM32上要自己写排序代码,占用CPU时间较长,所以通常只用在低速场合,比如温度采集,1秒采一次的中位值滤波就非常稳。还有一种一阶低通滤波(也叫指数加权移动平均),代码极短,占用资源极小,适合实时性要求高的控制回路。核心代码只有一行:new_value = (uint16_t)((old_value * (100 - alpha) + adc_value * alpha) / 100);其中alpha是滤波系数,越小滤波越强、响应越慢,需要根据你的信号频率去调。
4. 误差、校准与常见问题排查实录
4.1 偏移误差、增益误差,三点校准是怎么回事
ADC不是完美的,即使用SAR架构,内部比较器的失调、DAC电阻网络的失配,都会让转换结果偏离理想值。最常见的两种误差是偏移误差和增益误差。偏移误差是零点不准,输入0V时ADC读出来的数字不是0而是某个偏移值,比如读出来是+5;增益误差是满量程不准,输入3.3V(满量程)时读出的数字不是4095,而是比如4010,相当于放大倍数不对。
解决偏移误差的办法很简单:软件清零。先把ADC输入端接地(或者采样一个已知的0V通道),读到的值就是偏移量,之后每次测量结果减去这个偏移量。解决增益误差需要两点校准:采一个接近满量程的已知电压(比如2.5V基准电压),算出理想值和实际值之间的比值,然后把这个比值应用到所有测量上。
实际工程中更稳妥的是做三点校准:零点采一个电压,比如0V,满量程附近采一个已知电压,比如2.5V,中间再采一个1.25V。三点校准能同时修正偏移误差、增益误差和非线性误差。校准的计算其实就是一个直线拟合:拿到两组(实际电压,ADC读数)数据,求斜率k和截距b,之后任意读数经过voltage = adc_reading * k + b换算就是校准后的电压。有条件的还可以线性插值做分段校准,精度更高,但代码量和标定工作量也会翻倍。
我之前做过一个电池电量检测项目,电池电压范围2.8V到4.2V,用12位ADC采集,未校准时误差有几十毫伏,做了一次两点校准之后误差压到了10毫伏以内。这个精度对电量显示完全够用,根本不用升级16位ADC。校准的另一个前提是参考电压必须稳定,如果你用内部VREFINT做参考,温度变化会引入新的误差,这种情况还需要温度补偿,属于进阶玩法。
4.2 高频噪声、参考电压漂移与地弹:三个实战案例
案例一:采集值跳得像心电图,且每次跳的幅度远大于1个LSB。这种情况我第一反应是看电源和地。ADC外设和数字电路共用电源时,数字部分的高速翻转会在地层产生大电流涌流,造成地电位波动,这就是地弹。ADC的模拟输入地(AGND)和数字地(DGND)在PCB上必须单点连接,而且模拟电源要用磁珠或电感做隔离。如果你的板子是两层板、布局已经定死了怎么办?有一个偏方:把ADC采样时间拉到最大,同时把信号源输出阻抗降下来,比如在输入引脚和地之间加一个0.1uF电容,组成一个低通滤波器,能显著压制高频毛刺。
案例二:采集值整体漂移,上午和下午读同一个电压,结果能差十几个数。这种大概率是参考电压温漂。LDO的输出电压随温度会有变化,直接把LDO输出当ADC参考电压,等于把LDO的温漂全吃进去了。解决办法是加外部基准源,或者用内部参考电压并读内部温度传感器做补偿。
案例三:ADC读值在某个特定阈值附近跳变,比如采电位器电压时,输出码在409附近和410附近来回跳。这类问题根源可能是信号源有微小纹波,你采样的时刻恰好落在纹波的上升或下降沿上。解决办法不是加滤波(滤波会把响应速度拖慢),而是把采样时间尽量缩短,同时用比较稳定的触发源,比如定时器触发ADC而不是软件触发。定时器触发的好处是每次采样点的时间间隔严格相等,信号处于相同相位时采到的值更稳定。
4.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 读到的值始终是0 | 引脚没复用为ADC功能,或通道没使能 | 检查CubeMX引脚配置和通道初始化 |
| 读到的值始终是4095 | 输入电压超过参考电压,或者参考电压没配好 | 用万用表量输入引脚对地电压 |
| 读到的值跳动超过几十个LSB | 参考电压不稳、采样时间不够或地弹干扰 | 先加最大采样时间,再检查电源纹波 |
| 多通道DMA读出的数据错位 | DMA缓冲区长度和通道数不匹配 | 检查DMA设置的Buffer Size是否为通道数 |
| 连续模式读数异常 | 没有正确停止ADC就再次启动 | 确认每次读取的启停逻辑是否成对出现 |
| 软件触发后卡死在PollForConversion | ADC时钟没开启或配置错误 | 检查RCC时钟树,确认ADC时钟频率在允许范围 |
这表里列的几个问题,我基本都在实际项目中踩过。其中“多通道DMA数据错位”和“卡死在等待转换完成”是最容易让新手崩溃的,前者是配置不仔细,后者通常是时钟树配错了,ADC根本没跑起来。排查时不要急着看数据手册,先从CubeMX的配置界面过一遍参数,再在代码里打几个断点确认每个函数返回值,问题基本都能定位。我在调试ADC时一定会做的第一件事是先把一个已知电压(比如直接从3.3V分压得到的1.65V)灌进某一路通道,确认读数在理想值附近,再开始怀疑整个采集链路。这种做法能把“问题在硬件还是软件”的边界快速切分出来,少走大量弯路。
5. 学习路线与避坑建议:别让ADC成为入门的拦路虎
5.1 循序渐进的几步走法
我把嵌入式ADC的学习路径梳理成六步,每一步都有明确的练习目标。第一步,理解模拟信号和数字信号的概念差异,能说出各自优缺点和适用场景,找一块开发板测一下板载电位器电压,看看读数变化。第二步,手算一遍12位ADC的LSB和满量程对应关系,用万用表量几个固定电压点,对比ADC读数和万用表的差异。第三步,学会裸机配置单通道ADC,轮询方式读值,用串口打印结果,这个阶段目标是熟练配置流程。
第四步是上DMA多通道,把三路以上信号同时采集,并且验证数据不串位。第五步做滤波算法,把原始数据和滤波后的数据同时打印出来对比,直观感受滑动平均和低通滤波的效果差异。第六步理解误差来源和校准方法,做一个有真实测量意义的项目,比如电池电量监测或者温度采集。这六步走完,你对ADC的掌握程度完全可以应对大多数嵌入式岗位的面试题和实际工作需求。网上那些嵌入式八股重点考的ADC问题,无非就是采样定理、SAR原理、关键参数、参考电压选择、DMA配置这些,都在这个路径覆盖范围内。
5.2 别迷信高位数,先搞清楚需求再选型
很多初学者选ADC型号时总觉得位数越高越好,12位觉得不够看,要上16位、24位。真到了项目里你会发现,决定精度上限的是整个链路里最弱的一环——信号调理电路的信噪比、参考电压的温度漂移、PCB布局的干扰水平,这些往往都比ADC本身的位数更拉胯。你用了24位的HX711,但传感器桥路的激励电压纹波都超过1mV,那24位的分辨率就纯属浪费。
我做过一个称重项目,传感器满量程输出是2mV/V,5V激励时满量程10mV。想测0.1克的变化,对应的是微伏级别的信号变化。这种场景下,即便用ADS1232这种24位ADC,前面没有仪表放大器把信号放大到合适的量程,照样是白搭。所以选型的时候先算清楚:传感器的输出范围是多少,需要分辨到多少,参考电压能稳定到什么程度,信号链路的噪声有多大,最后再决定ADC的位数和架构。
5.3 从模电基本功到嵌入式内核:一个方向的延伸思考
还有一些热搜词很有意思,比如“嵌入式内核源码”“C语言面向对象编程:嵌入式实战”“树莓派如何解除外设功率输出限制”“4D radar的人体特征原始ADC数据下载”。这些词反映出一个趋势:ADC只是起点,往上游走,你会碰到传感器融合、信号处理、甚至毫米波雷达原始ADC数据的处理;往下游走,你会碰到DMA引擎、中断系统、实时操作系统,再往深就是内核驱动和外设管理。
我个人建议学习ADC时,也应该顺带了解外设在操作系统层面是怎么被管理的。比如Linux下ADC驱动用的是Industrial I/O(IIO)框架,它的逻辑是把ADC设备抽象成channel,通过sysfs接口导出原始值和换算值。理解了MCU上的寄存器操作,再去看Linux的IIO驱动,你会发现思路是相通的,只不过套了一层框架。这种“由底向上”的知识迁移能力,才是嵌入式工程师真正的核心竞争力。你现在花时间搞懂SAR ADC那几位比较器翻转的细节,未来看任何复杂外设的数据手册都会快得多。