
1. 模拟信号与数字信号到底差在哪ADC这玩意几乎是嵌入式开发里避不开的第一道坎。我见过不少初学者代码写得挺溜GPIO点亮LED、串口打印字符串都没问题结果一碰到ADC就开始懵什么是模拟信号什么是数字信号为什么好好的电压还要“转换”一下才能用其实这些概念没那么玄乎用大白话讲清楚你就发现ADC只不过是一个帮MCU“看懂”真实世界的翻译官。1.1 模拟信号真实世界原本的样子我们生活的世界里绝大多数物理量都是连续变化的。温度可以从25.1摄氏度慢慢升到25.2摄氏度中间有无限多个取值房间里的光强度、说话的声音大小、电池的电压这些量在时间上和数值上都是连续变化的这种信号就叫模拟信号。打个比方模拟信号就像水龙头里流出来的水。你拧开阀门水流可以从一滴一滴慢慢变成细线、再变成粗流这个过程中水量是连续变化的中间不存在断档。把这种连续变化的物理量转换成电信号通常表现为电压就是我们在电路里处理的模拟量。嵌入式系统里常见的模拟量来源包括电位器分压得到的电压、光敏电阻随光照变化的电压、热敏电阻随温度变化的电压、麦克风输出的音频信号等等。这些信号都有一个共同特点数值是连续且无穷多的电压可以是1.000001V也可以是1.000002V理论上你想分多细就有多细。1.2 数字信号单片机只认0和1单片机内部是个数字世界CPU、内存、总线全是按0和1工作的。数字信号的特点是离散的时间和数值都只有有限个状态——高电平代表1低电平代表0中间状态我们不关心。还拿水来类比数字信号就像家里那种只能开或关的水龙头要么全开1要么全关0没有中间状态。当然实际数字电路里会有高低电平阈值判断比如TTL电平规定2V以上算高电平0.8V以下算低电平但这本质上仍然是二值逻辑。问题来了单片机只会处理0和1而现实世界的温度、光照、声音都是连续变化的模拟量。单片机要怎么才能“看懂”这些模拟量总不能只告诉它“热”或“冷”两个状态吧那也太粗糙了。这时候就需要ADC出场了。ADC的全称是Analog-to-Digital Converter中文叫模数转换器。它的作用就是把这个连续变化的模拟电压按照一定的精度“切成”一个个离散的数字值交给单片机处理。就好比你要给一个装满水的杯子拍照不可能记录下每一个水分子的位置只能拍出有限像素的照片——你看到的是一个由有限像素点组成的、足够接近原貌的图像。ADC做的事情本质上就是这个“拍照”过程采样率越高、分辨率越高照片就越清晰越接近真实的模拟信号。2. ADC外设工作原理拆解逐次逼近型ADC是怎么干活的了解了为什么要ADC接下来就是核心问题了ADC到底是怎么把模拟电压变成数字的市面上ADC类型不少有Flash型、积分型、Sigma-Delta型但嵌入式MCU里最常用的还是逐次逼近型SARSuccessive Approximation RegisterADC。STM32、GD32、NXP的很多MCU用的都是这种结构。2.1 逐次逼近的“二分法”思想逐次逼近型ADC的工作方式特别像咱们玩的“猜数字”游戏我心里想一个0到100之间的数你每次猜一个数我只告诉你“大了”还是“小了”你怎么用最少的次数猜中答案是从50开始猜如果大了就猜25如果小了就猜75每次取中间值这样最多7次就能猜中任何一个数。ADC内部的逐次逼近寄存器就是干这个事的。假设一个8位的ADC参考电压是3.3V它会把3.3V分成2的8次方等于256份每份对应的电压大约是12.9mV。转换过程如下第一步寄存器先猜一个中间值也就是二进制10000000这个值通过内部DAC转换成一个模拟电压相当于3.3V的一半大约是1.65V。把这个电压和待测模拟电压送入比较器比较如果待测电压比猜测值大说明真实值比128大保留最高位的1如果待测电压比猜测值小说明真实值比128小最高位清零。第二步根据第一次比较结果在剩下的128个等级里再猜中间值继续比较。如此反复从最高位到最低位逐位比较经过8次比较后就能确定一个最接近真实电压的数字值。这个过程非常快每次比较只需要一个时钟周期。所以SAR ADC的转换速度可以做到很快STM32F103的ADC最快转换时间能做到1us出头也就是采样率接近1MHz对绝大多数传感器采集场景来说完全够用。2.2 采样保持和量化编码逐次逼近的过程说起来简单但有个前提在比较的这段时间里待测电压必须保持不变。如果电压一直在变前面比较的结果到后面就作废了。所以ADC前端还有一个采样保持电路相当于一个“快门”采样瞬间把模拟电压“冻结”在一个电容上保持住这个值然后逐次逼近电路再慢慢比较。这也是为什么ADC转换会有一个“采样时间”参数——你得给电容充电的时间让它稳定在输入电压上。完整的一次ADC转换过程用STM32的话来说就是选择通道、启动转换、等待采样保持完成、逐次逼近比较、转换结束置标志位、从数据寄存器读出结果。数据寄存器里的二进制数值和真实电压之间的关系很简单实际电压 ADC转换结果 × 参考电压 / 分辨率最大值以12位ADC、参考电压3.3V为例如果读到的值是2048那实际电压就是2048 × 3.3 / 4096 1.65V正好是参考电压的一半。其中分辨率最大值就是2的ADC位数次方减112位ADC对应4095。这个公式是ADC应用最基础的计算式几乎所有电压采集程序都离不开它。3. ADC关键参数别被数据手册绕晕很多初学者拿到芯片数据手册看到采样率、分辨率、参考电压、转换时间这些参数头都大了。其实ADC参数就那么几个关键的把它们的含义和相互之间的关系搞明白选型和编程就顺理成章了。3.1 分辨率能把电压切得多细分辨率是ADC最重要的参数之一它表示ADC能分辨的最小电压变化量。N位ADC的输出范围是0到2^N-1能把参考电压分成2^N份。12位ADC就是4096份16位ADC就是65536份。拿称重来类比分辨率就是秤的精度一把最小刻度1g的秤你称1.5g的东西它只能告诉你“2g”而一把最小刻度0.1g的秤同样的东西就能称出“1.5g”。12位ADC配合3.3V参考电压理论上最小能分辨3.3/4096≈0.8mV的电压变化。需要注意的是分辨率并不等于精度。打个比方一把尺子的刻度做到0.1mm但如果尺子本身是歪的量出来的数据照样不准。ADC的精度还受参考电压稳定性、内部噪声、PCB布局等因素影响。实际使用中12位ADC的有效精度往往达不到12位可能实际有效位数只有10位左右这是正常现象。3.2 采样率与奈奎斯特定理采样率表示每秒钟能完成多少次转换单位是Hz或者SPSSamples Per Second。采样率不是越高越好也不是越低越好而是要满足奈奎斯特定理为了能从采样后的离散信号无失真地恢复原始模拟信号采样频率必须大于信号最高频率的两倍。这个概念可以类比成拍电影。电影胶片每秒拍24帧为什么能还原连续的动作因为人眼对快速变化的画面有视觉暂留效应24帧就够“骗”过眼睛了。但如果被拍摄的对象动得太快比如子弹飞行24帧就会模糊不清需要高速摄影机。ADC采样也是一个道理信号变化越快采样率就得越高。实际工程里采样率一般取信号最高频率的5到10倍留出足够余量。比如采集一个1kHz的音频信号ADC采样率至少需要2kHz但实际工程中大家通常会跑到10kHz以上就是为了后面滤波和处理更从容。3.3 参考电压ADC的“标尺”参考电压Vref决定了ADC的转换量程也就是ADC能测量的最大电压范围。12位ADC在3.3V参考电压下转换结果4095对应的就是3.3V如果参考电压是5V同样的4095对应的就是5V。这里有个很容易踩的坑参考电压必须是稳定、干净的。如果Vref上有纹波或噪声ADC的转换结果就会跟着抖动。我在实际项目中就遇到过板子上的3.3V电源纹波比较大ADC采集出来的值跳动很厉害后来在Vref引脚旁边加了10uF和100nF的去耦电容情况才好转。有的MCU内部有参考电压源但精度一般要求高的场合需要用外部高精度基准电压源芯片比如TL431、REF3330这些。内部基准适合对精度要求不高的场合外部基准适合做仪器仪表类需要精确测量的产品。3.4 通道、转换模式与触发源除了分辨率和采样率ADC外设还有一些使用层面的参数需要关注。通道数决定了你能同时接多少个模拟信号源STM32的ADC通常有十几个通道但同一时刻只能转换一个通道规则通道多通道需要轮询切换或者用注入通道和DMA配合。转换模式一般有单次转换、连续转换和扫描模式。单次转换适合低频采集转换一次就停了连续转换适合需要帧率比较高的场合ADC会一直不停转换刷新数据扫描模式适合多通道自动轮询配合DMA可以实现“后台自动采集、CPU零介入”的效果。触发源也挺有意思。ADC转换可以由软件启动也可以由定时器、外部引脚等硬件触发启动。实际产品里常用定时器触发ADC采样这样采样间隔就非常精确不会受软件执行时间波动影响——这在做数字电源、电机控制这类对采样时序敏感的项目里尤其重要。4. 实操手把手配置一个ADC通道采集电压理论说了一大堆接下来进入实操环节。我以STM32F103为例带大家从零配置一次ADC单通道采集把内部温度传感器或者外部电位器的电压读出来通过串口打印到电脑上。这个流程在GD32、MM32等国产Cortex-M3芯片上同样适用寄存器级操作思路是一致的。工程环境用的是标准外设库虽然现在很多人用HAL库但标准库的寄存器封装更直观更能理解ADC工作的底层逻辑。HAL库用多了容易变成“调包侠”出问题不知道怎么排查。4.1 初始化GPIO和ADC外设第一步开启时钟并配置GPIO为模拟输入。ADC采集的引脚必须配置为模拟输入模式这一点新手经常搞错。配置成复用推挽输出或者浮空输入ADC读取的值都会有问题。然后是ADC外设的配置。下面这段代码是标准库风格的ADC初始化ADC_InitTypeDef ADC_InitStructure; // 开启GPIOA和ADC1的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); // 配置PA1为模拟输入 GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); // ADC1配置 ADC_InitStructure.ADC_Mode ADC_Mode_Independent; // 独立模式 ADC_InitStructure.ADC_ScanConvMode DISABLE; // 单通道不扫描 ADC_InitStructure.ADC_ContinuousConvMode DISABLE; // 单次转换 ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; // 软件触发 ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; // 右对齐 ADC_InitStructure.ADC_NbrOfChannel 1; // 规则通道数量 ADC_Init(ADC1, ADC_InitStructure); // 设置采样时间并选择通道1 ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5); // 使能ADC并校准 ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1));这里有个细节值得展开说说采样时间。代码里用的是55.5个ADC时钟周期如果ADC时钟为14MHz那么采样时间就是55.5/14MHz≈3.96us。采样时间越长采样电容充得越足采到的值越准但转换速度也越慢。如果你接的是高阻抗信号源比如光敏电阻直接分压采样时间设短了会导致充电不足、测量值偏低。一般外部信号源内阻越大的场合采样时间就要设得越长。另外那个校准步骤ResetCalibration和StartCalibration很多初学者以为没啥用跳过去没做。实际上SAR ADC内部的比较器和电容阵列是有失调误差的校准就是消除这个过程。跳过校准虽然也能跑但精度会受影响。写代码的时候别图省事把这步省了。4.2 读取转换结果并换算成电压配置完成之后读取ADC值就是一个简单的三步操作软件启动转换、等待转换结束标志位、读数据寄存器。下面是完整的读取函数uint16_t ADC_Read(void) { uint16_t adc_val; // 启动一次转换 ADC_SoftwareStartConvCmd(ADC1, ENABLE); // 等待转换结束 while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) RESET); // 读取转换结果 adc_val ADC_GetConversionValue(ADC1); return adc_val; } // 在主函数中计算电压值 float voltage; uint16_t adc_val ADC_Read(); voltage (float)adc_val * 3.3f / 4095.0f; printf(ADC%d, Voltage%.3fV\r\n, adc_val, voltage);电压换算公式前面说过就是“比例换算”。12位ADC满量程是4095对应参考电压3.3V所以adc_val乘以3.3再除以4095就是实际电压。注意这里用浮点数计算其实也可以用定点数优化先算adc_val乘以3300再除以4095得到的结果就是毫伏单位的整数电压值避免浮点运算的开销。实际项目中我都是这么干的电压值用整型毫伏表示串口打印时再除以1000得到伏特数。嵌入式MCU浮点运算能力弱能用整数解决的坚决不用浮点。4.3 多通道采集和DMA配合单个通道采集会了多通道采集也不难。比如我想同时采PA1和PA2两个通道的电压需要做两件事把扫描模式打开把规则通道序列长度设为2。每次启动转换后ADC会依次采样这两个通道数据依次存入数据寄存器。但有个很别扭的地方规则通道的数据寄存器只有一个如果不用DMA每次通道1转换完还没读走通道2的结果就把数据寄存器覆盖了。解决方案就是开DMA让ADC每次转换完成自动把数据搬到内存数组里。配置代码如下DMA_InitTypeDef DMA_InitStructure; uint16_t adc_buf[2]; // 开启DMA1时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize 2; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStructure); // 使能DMA通道 DMA_Cmd(DMA1_Channel1, ENABLE); // ADC配置改为扫描模式、连续转换并开启DMA请求 ADC_InitStructure.ADC_ScanConvMode ENABLE; ADC_InitStructure.ADC_ContinuousConvMode ENABLE; ADC_InitStructure.ADC_NbrOfChannel 2; ADC_Init(ADC1, ADC_InitStructure); ADC_DMACmd(ADC1, ENABLE); ADC_SoftwareStartConvCmd(ADC1, ENABLE);DMA模式的好处是ADC在后台不停循环采样数据源源不断搬进数组CPU完全不用管需要数据的时候直接读adc_buf数组就行。这对做连续的波形采集、音频采集这类场景简直是刚需。我做过一个用STM32采集两路电池电压的板子就是用这个方案CPU占用几乎为零主循环跑别的逻辑完全不受影响。5. 常见问题与排查技巧实录ADC采集搞不定的情况我见得太多太多了。这里把几个高频问题整理出来都是实际开发中踩过的坑排一次能省你好几个小时。5.1 采集值一直在跳怎么滤都滤不平这是最常遇到的问题。代码逻辑没问题接线也正确但串口打印出来的ADC值就是不稳定前后两次能差几十个LSB。可能的原因有三个方向电源纹波、参考电压不稳、外部信号本身抖动。排查思路是先做短路测试把ADC输入引脚直接短接到GND看看读出来是不是接近0再短接到VCC看看是不是接近4095。如果这两个极端值读出来都不稳说明问题在ADC本身或者电源如果两个极端值都稳只在采集外部信号时跳那大概率是信号源或者线路引入的噪声。针对噪声问题硬件层面可以加RC低通滤波比如在ADC引脚前串一个1kΩ电阻、对地接一个100nF电容构成一个截止频率约1.6kHz的低通滤波器。软件层面就是各种滤波算法最简单的有中值滤波连续采5次取中间值、均值滤波采10次求平均、滑动平均滤波。如果要求响应速度滑动平均比较合适如果信号毛刺特别多中值滤波效果更好。还有一个小技巧如果板子上有开关电源或者电机驱动ADC采集一定要避开这些噪声源的工作时间或者至少把ADC的采样时刻和PWM开关时刻错开这在电机控制里特别关键。5.2 读出来始终是0或者始终是4095这个问题的原因就很好定位了。始终读0先查引脚是不是有电压——用万用表直接量ADC引脚对地电压如果实际有电压但ADC读出来是0那问题在配置GPIO模式是不是设成了模拟输入ADC通道号和引脚对应关系对不对参考电压引脚Vref有没有接对始终读4095也就是满量程大概率是引脚悬空没接信号。ADC引脚悬空时输入是高阻态测到的是一个不确定的悬浮电压很多MCU在这种状态下会读到接近满量程的值这不是ADC坏了是输入没接好。还有一种情况是引脚内部有上拉电阻导致悬空时被拉到高电平也会读满量程。我遇到过最离谱的一次是把ADC输入引脚配置成了GPIO_Mode_Out_PP推挽输出因为代码是从LED那个工程复制改的GPIO配置忘了改。结果ADC读取的值完全乱来搞了半天才发现是配置问题。所以排查的时候第一个动作就是检查GPIO初始化——这是ADC采集的“第一颗扣子”扣错了后面全歪。5.3 采集小电压信号不准有的场景需要采集毫伏级别的微小信号比如压力传感器、电流采样电阻上的压降这时候直接用MCU内部ADC会发现精度远远不够读数在很小的范围内跳动换算下来的误差很大。这里面有两个层面的问题。第一是MCU ADC本身的精度限制12位ADC在3.3V参考下理论上能分辨0.8mV但实际噪声和失调会吃掉不少有效位数。第二是外部放大问题如果你的传感器输出范围只有0-50mV直接接ADC的话整个输出只占到ADC量程的1.5%白白浪费了分辨率。解决方法是加运算放大器把信号放大后再进ADC。比如把一个0-50mV的信号放大50倍变成0-2.5V再送入ADC12位的分辨率就能充分利用。另外用小量程的外部ADC芯片比如16位的ADS1115也是常见的做法。这类外置ADC芯片一般通过I2C或SPI接口和MCU通信选型时注意位数、采样率、接口类型就好。5.4 多通道采集中通道之间互相干扰多通道轮询采集时常常会发现某一路的读数会“串”到旁边通道去——通道A接的是0V但先采了通道B之后再去采A读到的A电压明显偏高要等好几次转换之后才恢复正常。根本原因是ADC内部采样电容上的残留电荷没有完全泄放掉。解决思路就是增大采样时间让采样电容每次都有足够时间充分充放电。STM32里就是调整ADC_SampleTime从1.5周期改到7.5甚至55.5周期串扰现象一般会有明显改善。如果再怎么调都还串那就要考虑在硬件上给每个通道加RC滤波器或者缓冲器从源头减小信号源的输出阻抗。6. 写在最后两个从实战中沉淀下来的建议ADC这东西理论看十遍不如上手调一次。我刚入行那会儿为了让ADC采样值稳定下来前前后后折腾了两三天最后发现就是一个滤电容焊没焊的问题。后来我把每次踩坑的经历都记在本子上时间久了慢慢攒出一套自己的排查方法论先查硬件基础接线、供电、参考电压再查初始化配置GPIO模式、通道号、采样时间最后才考虑软件算法——这个顺序能帮你绕开80%的弯路。第二个建议是做ADC采样相关的项目从一开始就养成“数据可视化”的习惯。别只盯着串口打印的数字发呆把波形图工具用起来。电脑端用匿名上位机、VOFA这类软件或者自己写个Python脚本读串口画图。当你把ADC数据画成曲线信号长什么样、噪声有多严重、滤波有没有效果一眼就看明白了。这比你在脑海里凭空想象数据的样子高效得多。嵌入式ADC的入门核心就是搞清楚模拟信号要变成数字信号中间发生了什么再搞明白MCU里的ADC外设能配置哪些参数、这些参数分别影响什么。把一个单通道电压采集调通再逐步扩展到多通道、DMA、定时器触发你就已经把ADC外设玩明白了大半。后面的进阶方向——比如过采样提高分辨率、用DMA做环形缓冲、结合定时器做等间隔采样——都是在这个基础上叠加的。先把基础打牢路还长着呢。