☰
ARM嵌入式ADC开发实战:初始化配置、滤波校准与DMA采集
2026/10/20 18:08:25 网站建设 项目流程

做ARM嵌入式开发,绕不开的外设除了GPIO和串口,就是ADC。ADC看起来就是往寄存器里写几个数值、再读一个转换结果的事,但真正要把它调到稳定、精确、能在产品里扛住环境变化,并不轻松。我上一个项目做的是某电压采集模块,要同时采集一路NTC温度、一路电源电压和一路外部传感器信号,刚调完第一版固件,数据就一直上下抖,还把整条ADC链路反复测了个遍。

所以这篇分享就把ARM嵌入式里的ADC从需求拆解、初始化配置、滤波校准、DMA采集,到排错思路全部过一遍。内容按一个项目的完整推进顺序来写,不是简单贴一段例程,而是把每一步为什么这么配、容易在哪里翻车都说清楚。无论你用的是哪颗Cortex-M内核芯片、官方库还是寄存器开发,配置思路基本通用。

1. 先把需求拆清楚,再谈ADC配置

1.1 分辨率、采样率和通道数怎么定

很多人一上来就直接开干,先把ADC初始化为12位,设个定时器触发,然后开始读数据。结果做到一半发现采样率不够,或者某路信号精度差,再回头改配置,非常浪费时间。我现在的习惯是先把信号源、量程、更新频率列成一张表,再回头对着手册选配置。

分辨率看信号范围。电池电压监测、电源电压监测、各种模块输出的0-3.3V模拟量,10位或12位足够。以12位ADC为例,满量程对应4096个台阶,3.3V基准时每个LSB大约0.8mV,做常规采集绰绰有余。如果测称重传感器、热电偶、微弱电流这类信号,我的建议是前面加仪表放大器把信号放到合适范围,而不是单纯靠提高ADC位数,因为位数再高,噪声不解决一样会白搭。

采样率也不是越高越好。理论上根据奈奎斯特定理,ADC采样率要大于信号最高频率的两倍,实际工程里我一般留5到10倍以上余量。对温湿度、电池这类缓慢信号,100Hz都嫌多;对电机相电流、开关电源输入纹波,可能需要几十kHz甚至更高。先把信号最高频率确认下来,再决定ADC时钟和转换周期。

通道数量直接影响你选哪种采集模式。大部分ARM MCU的ADC多通道是依次扫描的,也就是“串行”采样,各路信号之间有一个时间差。如果系统要求各通道必须是同一时刻的快照,那单靠一个ADC核很难做到,要么用多个ADC核同步采样,要么在外部加采样保持电路。下面这张表是我选型时常用的参考起点,不是死规矩,但能帮新手少走弯路。

应用类型推荐分辨率采样率需求通道数量备注
电池/电源电压监测10-12位1kHz以下1-2信号变化慢,重点看参考电压稳定性
电机相电流采样12位几十kHz1-3需要定时器触发固定相位采样
温度/NTC采集12位100Hz左右1需要滤波和长采样时间
微弱传感器信号16位外置ADC或加放大按传感器带宽1-2运放和基准精度比ADC位数更重要

1.2 输入信号调理是精度的一半

ADC的输入范围通常在0到Vref之间。信号如果超过量程,轻则波形削顶,读数一直卡在满量程,重则直接把引脚打坏;信号如果太小,又没法发挥ADC的动态范围。所以第一件事就是把外部信号调到ADC量程内。

测电池或电源电压时,最常见的方式是电阻分压。比如要测0-32V电压,用100k和10k电阻分压,分压比10:1,最大32V输入时ADC引脚大约是2.91V。这个电路很简单,但有个很容易忽略的坑:分压后从ADC引脚看进去,等效源阻抗是100k和10k的并联,大约9k左右。ADC采样瞬间,内部采样电容会从引脚抽取电流,如果源阻抗太高,充电时间不够,转换结果就会偏低,而且还不稳定。

所以实际产品里我通常会把分压电阻阻值压低,比如10k和1k,源阻抗降到900欧以内,代价是分压电路自身功耗变大。电池供电的低功耗设备就要权衡,另一个更优解是在分压后加一级运放跟随器。运放输入阻抗几十兆欧,输出阻抗接近0,ADC这边看到的就是一个非常理想的低阻抗信号源。高阻抗信号源,比如pH电极、压电传感器,直接接ADC基本测不准,用运放做缓冲是最稳的做法。

还有一个容易忽略的点:调理电路的输出不要太贴着ADC量程上限。环境温度变化、器件老化、信号过冲都可能导致实际电压超出预期。我习惯把最大正常信号设计在ADC量程的80%左右,留出余量,避免削顶。

提示:高阻抗信号源直接接ADC,即使信号幅值正确,转换结果也可能随采样时间参数变化而明显漂移。最典型的例子是用一根又长又细的导线连接电位器,转动电位器时读数提前变化且不稳定。遇到这个问题,先加运放或者把采样时间拉长,再谈滤波。

2. 初始化ADC的核心配置逻辑

2.1 时钟分频:采样精度与速度的平衡

ADC外设的工作时钟一般由系统时钟分频得到,这是初始化里非常关键、也最容易被忽视的一步。ADC时钟太高,内部比较器和电容阵列来不及稳定,转换精度会下降;几乎所有MCU的手册都会标出ADC时钟上限,比如十几MHz到几十MHz不等。

举个例子,某颗系统主频72MHz的芯片,ADC分频系数选4的话,ADC时钟是18MHz,很可能超过上限;改成6,ADC时钟降到12MHz,才落到安全范围。不同芯片差异很大,不能拿别人的配置盲抄,必须打开数据手册确认。我实际调项目时会特意看一眼APB总线的分频,因为ADC时钟往往是从APB时钟再分频得到的,总线上挂的外设多,APB时钟频率变化,ADC实际时钟也会跟着变。

分频系数选择的原则很简单:在满足所需采样率的前提下,尽量压低ADC时钟、尽量拉长采样时间。也就是说,精度优先,速度够用就行。毕竟很多项目里的模拟信号本来就是慢变量,没必要为了用不到的采样率把噪声放大。

我算笔账,方便新人理解采样时间怎么影响速度。假设ADC时钟12MHz,一个时钟周期约83ns。12位转换通常需要12.5个ADC时钟周期,如果采样时间设成1.5周期,一次完整转换大约是14个周期,折合1.16us,理论最高采样率超过860kHz。如果采样时间拉长到71.5周期,一次完整转换变成84个周期,约7us,采样率大约142kHz。对于温湿度、电池电压这类场景,142kHz依旧非常充裕,而精度明显比拉满速度好得多。

2.2 采样时间、通道顺序和触发源

采样时间是我在所有ADC参数里最看重的一个。采样阶段,ADC内部的采样电容会通过外部源阻抗充电,等效为一个RC电路。充电时间不够,电容电压就停在半路,转换结果当然偏低。源阻抗越高,需要的充电时间越长。我通常直接把采样时间设到手册允许的中等偏上档位,比如71.5周期甚至更长,除非确实在做高速采集,否则没必要为了零点几微秒的时间牺牲稳定性和精度。

多通道扫描时,通道在通道列表里的顺序也不要随意排。ADC是按列表顺序依次采样的,我一般把变化快的信号放前面,变化慢的放后面,让每路信号拿到尽量接近“当前时刻”的数据。如果某个关键通道要求更高,可以在列表里把它重复放两次或三次,软件侧再取平均,效果等同硬件级多次采样。

触发源是另一个容易理解错的地方。软件触发只适合调试和单次采集。如果要在固定相位点采样,比如电机控制里要精确在PWM中点时刻采集电流,就一定要用定时器触发。我做开关电源纹波测量时,用软件循环触发采到的数据乱得没法看,换成定时器触发后数据规律性立刻出来了,因为采样点相对纹波的相位被固定住了。触发源选错,采到的点会和预期时刻不一致,这不是滤波能解决的。

2.3 结果换算公式与参考电压

第一次看到ADC寄存器里读数跳动的朋友,很容易把注意力全放在“怎么把数字读出来”上。实际上寄存器里的数字本身没有意义,换算成物理量才是重点。最基础的计算公式是:电压 = 读数 / (2^分辨率) × Vref。例如12位ADC读数为2048,参考电压3.3V,换算出来就是1.65V。

这句话里最微妙的地方在于Vref是“实际参考电压”,而不是你心里以为的3.3V。很多开发板的3.3V来自一颗稳压芯片,但稳压芯片的输出精度、负载电流大小、板内噪声都会让实际电压停在3.28V、3.31V这样的数值上。你如果直接把代码里的基准写死为3.3V,系统就带上了一个固定的测量偏差。精度要求高时,要用万用表实测基准电压,再把实测值代入换算公式;更稳妥的做法是外接一颗基准电压芯片,把基准误差做到0.1%以内。

还有一个不少新手会踩的误区:看到结果寄存器里读出4095,就认为输入已经到满量程了。实际上输入超量程、引脚悬空、参考电压噪声太大,都可能让读数长时间停在4095。所以应用层里要做边界判断,把“最大读数”当作异常状态而不是有效数据来对待,否则后面连着一堆控制逻辑会被这个假数据带跑偏。

注意:不要在应用层把“满量程读数等于满电压”这种换算关系写死。尽量在量产前做一次整机校准,把基准电压偏差、分压电阻误差、ADC自身零点误差一次性吸收掉,比靠理论公式换算可靠得多。

3. 让ADC数据真正可用的三件套

3.1 滑动平均与中值滤波代码

原始ADC数据就算在理想环境下也不会绝对稳定,因为噪声永远存在。数字滤波是成本最低的降噪手段。我用得最多的是两种:滑动平均和中值滤波。

滑动平均适合应用在缓慢变化的信号上,比如温度、电压、液位,可以有效压低随机噪声。它的核心思想是维护一个固定长度的窗口,每次取窗口内所有样本的平均值。下面这段代码用流水式累加来做滑动平均,不需要每次重新遍历求和,效率高很多。

#define ADC_AVG_WINDOW 8 uint16_t adc_moving_average(uint16_t raw) { static uint16_t sample_buf[ADC_AVG_WINDOW]; static uint8_t idx = 0; static uint32_t sum = 0; sum += raw; sum -= sample_buf[idx]; sample_buf[idx] = raw; idx = (idx + 1) % ADC_AVG_WINDOW; return (uint16_t)(sum / ADC_AVG_WINDOW); }

中值滤波对付脉冲干扰更有效。继电器触点火花、电机电刷打火这类噪声会在ADC输入端产生尖峰脉冲。滑动平均会把一个尖峰的影响摊进多个输出点,结果你还能看到毛刺;中值滤波则是直接把异常样本剔除掉。它的代价是每次要做排序,MCU开销比滑动平均大一些。下面是窗口为5的中值滤波实现,窗口选5是因为排序量不大、实时性损失可控。

static void insertion_sort(uint16_t *arr, uint8_t len) { uint8_t i, j; for (i = 1; i < len; i++) { uint16_t key = arr[i]; j = i; while (j > 0 && arr[j - 1] > key) { arr[j] = arr[j - 1]; j--; } arr[j] = key; } } uint16_t adc_median_filter(uint16_t raw) { static uint16_t pool[5] = {0}; static uint8_t pool_cnt = 0; pool[pool_cnt++ % 5] = raw; if (pool_cnt < 5) { return raw; // 样本不够时直接输出原始值 } uint16_t tmp[5]; for (uint8_t i = 0; i < 5; i++) { tmp[i] = pool[i]; } insertion_sort(tmp, 5); return tmp[2]; }

实际产品里我经常把两种滤波串联:先做一次中值平滑掉尖峰,再做滑动平均压低随机噪声。但要记住,滤波会引入相位滞后,如果ADC数据要送给PID这类反馈控制,滞后太大会影响系统稳定性。控制类场景要评估滞后量,甚至考虑用预测补偿。

3.2 零点与增益的两点校准

MCU内部ADC虽然逐次逼近结构很成熟,但实际芯片里的晶体管失调、参考源误差、电源波动都会带来零点偏移和增益误差。如果不校准,按理论公式换算出来的电压和万用表实测值总差一点,而且这个差值不是噪声,是固定的系统偏差,单纯滤波永远去不掉。

两点校准法是最实用的校准方式。第一步,让ADC输入接地,读取一个零点读数zero_raw。第二步,让ADC输入接一个已知且稳定的精确电压ref_voltage,读取对应读数ref_raw。然后代入下面的换算公式:

真实电压 = (adc_value - zero_raw) × ref_voltage / (ref_raw - zero_raw)

这个公式本质上是一个两点线性拟合,把零点误差和增益误差同时修正掉。比如零点读数是5,参考电压2.048V时读数为2555,那么实际读取到1024的时候,真实电压就是(1024-5) × 2.048 / (2555-5) ≈ 0.817V。如果不校准,直接按理论公式算1024 / 4096 × 3.3V ≈ 0.825V,差了8mV,高精度场景完全不能接受。

校准数据要存进非易失区,Flash或EEPROM都可以。量产时每台设备单独校准,才能真正消除个体差异。如果产品精度要求不高,也可以只做零点校准,增益误差用芯片手册给出的典型值来代替,能省一些产线工时,但精度上限有限。

3.3 DMA解放CPU:多通道连续采样

如果每一路通道都靠轮询等转换完成,CPU大部分时间就浪费在死循环等待上。在带操作系统的嵌入式项目里,这种浪费更加明显。DMA可以把ADC完成的数据自动搬运到内存,转换完自动开始下一次,CPU只需要在数据准备好之后做一次处理。

多通道连续采样时,DMA配置有几个要点。AdcHandle里填上通道数量;DMA设置成循环模式;搬运长度等于通道数;每次搬运的数据宽度要和ADC寄存器位宽一致,一般是半字。DMA传输完成中断里只做置位,或者把数据拷贝到应用缓冲,千万不要在中断里做滤波、存储、通信这类耗时操作。中断处理时间越长,丢数据的风险越大。

/* 伪代码:以HAL库风格为例,实际函数名因芯片而异 */ static uint16_t adc_raw[4]; void adc_with_dma_init(void) { /* 1. 使能ADC与DMA时钟 */ /* 2. 配置4个采样引脚为模拟输入模式 */ /* 3. 配置ADC为多通道扫描模式、连续转换模式 */ /* 4. 配置DMA循环模式,数据宽度半字 */ /* 5. 将DMA请求连接到ADC数据寄存器 */ /* 6. 启动DMA,再启动ADC */ } void adc_dma_irq_handler(void) { /* 只置标志位,不在这里做滤波 */ g_adc_ready = 1; }

DMA还有一个天然好处:如果开了循环模式,数据会连续不断地填充内存数组,每轮覆盖上一轮。配合定时器触发,可以实现严格等间隔的采样序列。做FFT或者功率计算时,采样时刻均匀性非常重要,软件循环很难保证这一点。也就是为什么我会特别推荐“定时器触发+DMA搬运”这个组合,它几乎是我做多通道采集的默认方案。

4. 一个多通道电压采集模块的完整设计实例

4.1 电路设计的三个关键点

回到我前面提到的那个项目。这个电压采集模块需要采集两路外部0-5V模拟信号、一路内部3.3V电源电压、一路NTC温度。整板电路看起来不复杂,但硬件我前后改了三版,问题全部集中在电路设计上,而不是主芯片的代码。

第一版分压电阻选的是100k和200k,分压比2:3,从ADC引脚看进去等效源阻抗约66.7k。结果不管软件怎么调采样时间,数据始终偏低,而且受温度影响明显。后来换成10k和20k,源阻抗降到6.7k左右,精度立刻恢复正常。代价是分压电路功耗从微安级升到百微安级,对电池设备来说还在可接受范围内,但需要设计时提前评估。

第二版问题是基准电压。直接用板内LDO的3.3V当Vref,LDO在满载时输出电压有几十毫伏纹波,ADC采样值也跟着跳。后来加了一颗基准源,参考电压纹波降到2mV以下,采样波动肉眼可见地变小了。基准源的成本也就几块钱,却能省掉很多调试时间,非常值得。

第三版是输入保护。每路外部模拟输入串一个1k电阻,对地加一个稳压二极管,防止误接超过量程的电压打坏主芯片。串入1k电阻会增加一点源阻抗,所以我在软件里把采样时间相应地拉长了。保护电路和采样性能之间需要平衡,不能只图防护效果。

4.2 初始化与DMA采集代码走读

初始化代码基调是这样的:开时钟、配引脚、校准ADC、配多通道和DMA、启动转换。看起来简单,但每一步里都有细节。

时钟必须确认ADC时钟没超上限,DMA时钟不能漏开。引脚要配置成模拟输入模式,如果设置成GPIO推挽输出,ADC采到的就不是外部信号,而是芯片内部把引脚拉成高电平或低电平。校准则一定要放在ADC配置完成之后、正式转换开始之前,而且校准结束后要查询标志位确认完成,再启动转换。

DMA传输完成中断和ADC中断是两个不同的事件源。有些MCU的DMA中断标志区分传输完成、半传输和错误,寄存器状态要查询对。我踩过的一次坑是误开了DMA的半传输中断,结果数据搬了一半就触发处理,算出来的值全部偏高,排查了好久才发现是中断源搞错了。

整个代码骨架如下,核心流程就是这个顺序:

#define AD_CH_NUM 4 static uint16_t adc_raw[AD_CH_NUM]; static volatile uint8_t g_adc_ready = 0; void adc_hw_init(void) { /* 使能ADC、DMA、GPIO时钟 */ /* 配置4个通道引脚为模拟输入 */ /* 复位并校准ADC内部模块 */ /* 设置ADC时钟分频,使ADC时钟低于手册上限 */ /* 配置4个通道的采样时间,拉长至中高档位 */ /* 配置DMA循环模式,数据宽度半字,缓冲区为adc_raw */ /* 使能DMA请求,开启ADC连续转换 */ } void app_start(void) { adc_hw_init(); while (1) { if (g_adc_ready) { g_adc_ready = 0; /* 此时adc_raw[0..3]是4路最新的原始采样值 */ /* 在应用层做滤波、校准、换算 */ } } }

这里需要注意,应用层拿到的adc_raw是原始值,先做滤波,再做校准换算,顺序不要颠倒。如果先换算成电压再滤波,偶发尖峰会被放大成明显错误的电压值,滑动平均也难以彻底压掉。

4.3 实测效果与参数调优

整版硬件改完、软件调到最后一版后,我用台式万用表做了细致对比。输入标准1.000V电压,环境温度稳定在25度左右,记录了几组不同配置下的数据,差异非常直观。

配置方案采样值波动范围(12位)换算电压范围与万用表误差
1.5周期采样,无滤波±12 LSB0.989-0.995V0.5%
84周期采样,无滤波±6 LSB0.993-0.995V0.3%
84周期采样+中值+滑动平均±2 LSB0.994-0.996V0.2%
整机校准后±2 LSB1.000-1.001V0.1%

从这张表能清楚看到三层改善之间的区别。拉长采样时间解决的是源阻抗带来的电压跌落误差;数字滤波解决的是随机噪声;整机两点校准解决的是系统固定偏差。三者解决的问题不一样,不能互相替代。这也是我反复强调“先把硬件和采样时间做对,再谈滤波”的原因。

调优过程中还有一个很有价值的发现:NTC温度通道采样值出现一种规律性纹波,不是杂乱的跳动,而是每几个周期重复一次。排查了很久,最后定位到是PCB走线寄生电容和ADC内部采样电容并联,导致采样保持充电时间被拉长,而每次采样间隔内充电不完全。解决办法是软件采样时间拉到最大,同时在信号走线周围铺地隔离。这种问题从原理图上都看不出来,只能靠实测波形和数据分析去找。

5. 采样值跳动、初始化卡死、误差偏大的排查链路

5.1 采样值乱跳的排查顺序

每次看到有人问ADC采样值乱跳怎么解决,我的第一反应都是:先别急着改代码。我自己的排查顺序是固定的,按怀疑概率排下来,比随机试参数高效得多。

第一步,查参考电压干不干净。用示波器看Vref和GND之间有没有纹波,有没有几十毫伏以上的噪声。有纹波就换基准源,或者在基准引脚旁边加大去耦电容。这个环节90%的跳动问题都能被揪出来。

第二步,查信号源阻抗是不是太高。直接把采样时间调到手册允许的最大值,如果数据明显变稳,说明就是源阻抗问题。要么减小分压电阻阻值,要么加运放跟随器。

第三步,查软件读寄存器时机。开了连续转换却没在转换完成标志位置位后读值,读到的是上一次的残值。这种问题看起来像是随机跳动,其实是时序错位造成的假象。

第四步,查数字部分干扰。板上的DC-DC开关节点、时钟走线如果贴着模拟输入线,噪声会直接耦合进ADC。这类问题要靠布局和屏蔽解决,软件手段基本无效。

按这个顺序排查,一般十分钟内能定位问题来源。我见过很多人一上来就调滤波窗口长度、改采样时间,折腾一整天,结果最后发现是电源纹波,连方向都错了。

5.2 初始化卡死的几个经典原因

初始化卡在等待转换结束标志位那里,是最常见的卡死现象。我总结过,高频原因基本可以归为三类。

第一类是漏使能时钟。忘记开启ADC外设时钟或者DMA时钟,程序访问寄存器时要么直接卡死,要么HAL库返回错误码,后续流程全部停摆。解决方法是初始化之前把系统时钟树完整画一遍,确认每个外设的时钟门都打开了。

第二类是通道配置错误。选择了没有连接到模拟引脚的内部通道,或者通道编号和引脚复用错位。多通道扫描时,如果列表里包含悬空且内部上下拉被开启的引脚,读数会满量程或者无意义,还会干扰整轮扫描。检查方法是用调试器读ADC寄存器里每个通道的采样结果,和实际电压逐个核对。

第三类是校准未完成就启动了转换。某些芯片要求校准期间不能开始转换,如果软件把校准和启动转换代码连续执行,硬件校准还没跑完,软件已经在傻等EOC,结果就是固件里死等。解决方法是启动转换前先查询校准完成标志,确认确实完成后再继续。

排查这类卡死时,我习惯在初始化代码里加串口打印,每完成一个步骤输出一个标记。卡在哪一步非常明确,比盯着调试器猜状态寄存器高效得多。需要注意的是,芯片卡死后不能直接重新初始化,有些外设状态机会一直停留在异常状态,必须做一次完整的外设复位,否则第二次初始化照样卡。

5.3 测量误差偏大的校准流程

误差偏大和跳动是两回事。跳动是反复读值不稳定,可以用滤波压制;误差是读值很稳定,但和真实电压对不上,这时候再滤波也没用,要做系统级校准。

误差的主要来源通常是四个:分压电阻本身的精度、参考电压偏差、ADC零点误差、增益误差,以及PCB上的漏电流。产品校准一般在产线进行:用标准源打两个电压点,比如0V和2.048V,把ADC原始读数记录下来,写入产品的存储区域。应用层再用两点公式换算。如果还想修正非线性,可以多打几个点,做成查表加线性插值。

这里有两个实践细节。第一,校准点电压必须非常稳定,仪器要预热到正常状态,每个点的采样值要多次读取取平均,否则校准数据本身就有误差,整个校准就白做了。第二,校准参数写入Flash之后要再读出来交叉验证一次,确认写入没有出错。我见过因为Flash写入时序被中断打断,导致校准值只写了一半,产品上电后所有测量都偏到离谱方向的案例。

提示:有人喜欢用“软件修正”去掩盖硬件偏差,比如发现读数偏大就在代码里减一个固定数。这在单台调试时有效,但放到量产设备上几乎没有意义,因为每颗芯片的偏移都不一样。正确的做法是做每台设备的独立校准,或者换掉不合格的硬件,而不是打补丁。

最后说一点个人体会。把ADC从“能读到数”调到“读得准、读得稳”,这中间的经验大多是踩坑踩出来的。我自己遇到问题时的顺序从来没有变过:先看参考电压,再看信号源阻抗,然后调采样时间,最后才动滤波和校准。数字滤波不是万能药,它治不了硬件上的病,最多把症状压住。真正稳定的ADC设计,一定是电源、分压、基准、采样时间、滤波和校准几个环节一起到位。先做对硬件,再谈优化,调试前手边放一块精度过得去的万用表,你的眼睛不会骗你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询