做嵌入式开发绕不开ADC,树莓派Pico这颗RP2040也不例外。很多人把Pico当Arduino用,analogRead一把梭,等到项目里要采传感器、要做波形、要同步多路电压时,才发现ADC这块水挺深。这篇笔记就把Pico ADC从底层讲到应用层:硬件架构、工作模式、寄存器、SDK封装,以及我实际踩过的坑,一次性捋清楚。适合正在用Pico做模拟量采集、想深入底层原理、或者被ADC跳动问题逼疯的朋友。
1. 这块12位ADC到底是什么来头
1.1 从SAR ADC原理说起:为什么Pico的ADC“有脾气”
RP2040的ADC是一个12位逐次逼近型(SAR)ADC,最高0.5MSPS。SAR的工作原理有点像用天平称东西:内部有一个比较器,从最高位到最低位逐位猜测输入电压,每一步都通过内部DAC产生一个猜测值跟输入比,12位就猜12次,最后把所有猜测结果拼起来。优点是结构简单、响应快、没有积分型ADC那么长的转换周期;缺点是它对输入信号源阻抗、参考电压的稳定性都敏感。很多人发现Pico的ADC读出来有点飘,其实不是芯片坏了,是SAR的结构决定了它容易受这些因素影响。
RP2040上这颗ADC还有一个特点:很多状态字段都暴露在寄存器里,这在便宜MCU里算厚道的,方便排查问题,但也意味着你要是完全不看寄存器,很多异常会被白白吞掉。比如ADC的采样保持时间、时钟分频、FIFO状态、错误标志都能从寄存器里读到,想做到底层可控并不难,难的是你愿不愿意在文档里多翻几页。
1.2 引脚与输入通道:真正能用的模拟输入只有三根
RP2040的ADC通道不是很多:外部可用的只有ADC0、ADC1、ADC2,对应GPIO26、GPIO27、GPIO28;ADC3内部接GND,几乎没有实用价值,通常用来校准零点;ADC4内部接芯片温度传感器。注意,Pico官方板子一共有40个引脚,大多数引脚都能做数字IO,但能当模拟输入的就这三根,这一点在规划电路时要提前想清楚,不然画完板子再去改引脚分配就很被动。
参考电压来自VDDA引脚上的3.3V,在Pico官方板上和电源网络的3.3V是同一个网络。也就是说,输入0~3.3V映射到数字量0~4095,换算公式是raw / 4095 * 3.3。这里有个很容易忽略的点:参考电压不是内部基准,而是外部电源经过芯片供电的。如果VDD附近有开关稳压器噪声,ADC的读数就会跟着抖。这是很多项目“电压明明很干净,ADC读数却乱跳”的根源之一。
1.3 ADC时钟和采样率:48MHz分频到500kHz
ADC的时钟来自48MHz,需要通过ADC_DIV寄存器分频。规格书建议的转换速率上限是500kSPS,对应ADC时钟在500kHz左右,再高只能得到“看起来在转、但精度不可保证”的结果。实际项目中我会把采样率压到100kHz以内,信号源是几十kΩ高阻的话再往下压到10kHz,换来的是稳定的低几位。
还有一个概念:ADC转换不是瞬时的,每个周期里有一段采样保持时间,用来让内部采样电容充电到输入电压。如果你用长线从传感器引到GPIO26,或者前级驱动能力不够,采样电容还没充满就被强行转换,电阻值自然不对。这个问题在轮询通道切换时更明显。
注意:ADC输入电压低于0V或高于3.3V,都会导致内部保护二极管导通,轻则读数异常,重则烧掉引脚。外部模拟信号在3.3V以上时,必须先分压。
2. 工作模式与寄存器底层映射
2.1 单通道轮询:CS、RESULT两个寄存器就能跑通
单通道轮询是最简单的模式。SDK里的流程是:adc_init()使能ADC并清FIFO、adc_gpio_init(26)把GPIO26设成输入、adc_select_input(0)挑通道、adc_read()启动转换并等待结果。完整代码如下:
#include "pico/stdlib.h" #include "hardware/adc.h" int main(void) { stdio_init_all(); adc_init(); adc_gpio_init(26); // GPIO26 -> ADC0 adc_select_input(0); while (true) { uint16_t raw = adc_read(); float v = raw * 3.3f / 4095.0f; printf("raw=%d, volt=%.3f V\n", raw, v); sleep_ms(100); } }这一串函数落在寄存器层面,其实就是操作ADC_CS、ADC_RESULT。CS寄存器里AINSEL选通道,START_ONCE触发一次转换,DONE标志表示转换完成,结果从ADC_RESULT读取。初学者最容易漏掉的是adc_select_input,如果你初始化完直接调adc_read(),读回来的可能是上一个通道的值。我在调试多个传感器的时候也犯过这个错误,数据看起来没问题,其实是旧数据。
这里把几个核心寄存器列出来,后面看SDK源码时能对上号:
| 寄存器 | 偏移 | 主要作用 |
|---|---|---|
| ADC_CS | 0x00 | 通道选择、使能、单次/连续触发、DONE标志、轮询位 |
| ADC_RESULT | 0x04 | 转换结果,12位有效;FIFO模式下读它返回FIFO数据 |
| ADC_FCS | 0x08 | FIFO使能、阈值、DREQ使能、状态标志 |
| ADC_DIV | 0x0C | 时钟分频,16位整数加8位小数 |
| ADC_INTR/INTE/INTF/INTS | 0x10~0x1C | 中断原始状态、使能、强制、清除 |
2.2 多通道连续采样:Round-Robin轮询与FIFO缓冲
多通道场景下,最笨的是每个通道分别select加read,这样做问题很大:通道切换后ADC内部采样保持电容还残留上一个通道的电荷,立刻转换会串扰。正确做法是给切换留建立时间,或者干脆让硬件轮询。
RP2040支持round-robin模式,在ADC_CS里把要循环的通道位置1,ADC会自动按顺序转换,结果从ADC_RESULT或FIFO里读。SDK对应adc_set_round_robin(),配合adc_fifo_setup()把结果先存进硬件FIFO,你只管按节奏取数据。配置代码大致长这样:
adc_init(); adc_gpio_init(26); adc_gpio_init(27); adc_gpio_init(28); adc_set_round_robin(0b111); // 循环采样 ADC0、ADC1、ADC2 adc_fifo_setup(true, true, 1, false, false); adc_set_clkdiv(100); // 拉低采样率,提高稳定性 adc_run(true);FIFO的好处是:CPU不用每次转换都盯着状态寄存器,可以一批批取数据;配合DREQ信号,还能让DMA在FIFO达到阈值时自动搬数据到内存,CPU彻底解放。这个模式在采集音频波形、振动信号、连续电压监测时非常实用。缺点是你得习惯“数据是批量来的”,而不是调用一次函数就立刻得到最新值。
2.3 中断模式和DMA批量采集:从“手动查结果”到“自动搬运”
如果对实时性有要求,用轮询会卡死CPU。可以用ADC中断:当FIFO里的数据量达到阈值,硬件触发IRQ_ADC_IRQ,中断服务里批量取走。另一种更推荐的方式是DMA:先把DMA通道配置成“外设到内存”,数据来源是ADC_RESULT,触发信号是ADC的DREQ,DMA就会在FIFO非空时自动搬运。
DMA模式下,CPU只需要在缓冲区装满后处理数据,采样过程中完全不参与。这对连续采集意义很大,可以把ADC采样率稳定在不同载荷下,不会因为主循环里干别的事导致采样间隔忽长忽短。
注意:开启DMA后,读取ADC_RESULT的时序交给DMA,CPU不该再调
adc_read(),否则会和DMA抢数据,造成结果错位。这个坑我遇到过,一旦既开DMA又用adc_read()读数据,采出来的序列就会偶尔跳一个值,排查半天才发现是两边在抢。
3. 从SDK源码角度拆解几个关键函数
3.1 adc_init()与adc_gpio_init():先想清楚引脚路由
先拆adc_init()和adc_gpio_init()。adc_init()主要就是打开ADC并复位FIFO状态,还有使能ADC相关时钟。adc_gpio_init(26)则是gpio_init(26)加gpio_set_dir(26, GPIO_IN)。为什么是输入而不是“复用为ADC功能”?因为RP2040的GPIO26到GPIO28没有一个专门的AF选择位,它们的模拟输入路径是单独的,只要IO方向是输入、不输出,模拟开关就会连到ADC前端。
所以你想用GPIO27当ADC1,就是adc_gpio_init(27);千万别顺手加个内部上拉或下拉,那样会把输入电压硬生生拉偏。我在早期调试NTC测温时,就是初始化时顺手加了上拉,导致读数整体偏高,而且温度越高偏差越大,因为NTC阻值变化和上拉电阻并联后,分压比完全变了。
这里也解释了一个常见误区:Pico板载LED在GPIO25,很多新手以为能把GPIO25当模拟输入用,或者把LED引脚和ADC混在一起。GPIO25只是普通数字输出,跟ADC没有任何关系。真正能用的模拟输入就是GPIO26、GPIO27、GPIO28三根。
3.2 adc_read()的执行链路:一次完整转换是怎么挤出来的
adc_read()底层大致逻辑:把AINSEL设成你选好的通道,CS寄存器写START_ONCE;接着循环读DONE位,等到1;最后从ADC_RESULT读12位结果。注意在这条链路里,adc_select_input()和adc_read()顺序不能反,反了你可能读到的是上一次的结果。SDK源码里,adc_read()会先检查是否处于运行状态,没有就自动使能。
还有一点,如果开了FIFO,读ADC_RESULT寄存器返回的是FIFO的数据,不是当前实时转换值。很多人开了FIFO之后又去调adc_read(),读到的数据总是慢半拍,就是没分清这个寄存器路径。我在第一次调多通道FIFO时就被这个坑过,总是觉得“数据好像延迟了几个周期”,最后翻寄存器手册才意识到,FIFO模式下读取地址的语义已经变了。
3.3 adc_fifo_setup()与adc_set_round_robin():批量数据怎么到内存
adc_fifo_setup(enable, dreq_en, dreq_thresh, err_in_fifo, byte_shift)对应直接操作ADC_FCS。各种参数里,dreq_thresh最常用到:比如设成8,FIFO里攒够8个样本后才发出DREQ请求,DMA一次搬8个,减少中断次数。byte_shift可以把12位结果右移成8位,适合只需要8位精度的场景,可以省一半存储空间。
adc_set_round_robin(mask)写的是ADC_CS里的轮询字段,mask的bit0对应ADC0、bit1对应ADC1、bit2对应ADC2,其他通道如果不需要就别置1,不然会把AGND或温度传感器也拉进轮询队列,浪费采样点。这里特别提一下温度传感器:如果你没有实际测温需求,不要把它放进轮询,否则数据流里会多出一路混进来的温度值,让你误以为是外部电压发生了跳变。
4. 实测数据与避坑清单
4.1 实测:不同分频下的转换表现
我自己用可调稳压电源给GPIO26加了一个1.250V基准电压,分别把分频设成不同值,各采1000次看分布。实验环境就是Pico插面包板,杜邦线大约15cm,没做特殊滤波。结果大致是这样的:
| 分频/采样率 | 读数均值 | 最大跳变(LSB) | 表现 |
|---|---|---|---|
| 接近500kSPS | 1551.x | 5~7 | 低两位完全没法看,数据像噪声 |
| 约100kSPS | 1550.8 | 2~3 | 大致稳,偶有波动 |
| 约10kSPS | 1550.3 | 0~1 | 很稳,适合较高精度测量 |
这个数据只代表我手上的板子和环境,但趋势很典型。结论是,除非你追求波形采样,否则没必要把采样率拉到500k。做传感器采集,我通常用10k到50k,再配合软件滤波,读数会舒服很多。如果某个项目要求高精度又必须跑满速,那就要在硬件上认真处理输入驱动和参考电压,光靠软件平均很难救回来。
4.2 几大经典坑:超压、通道切换偏移、参考电压漂移
第一坑是输入超压。ADC输入高于3.3V会打坏引脚,低于GND也不行。处理办法是分压电阻、稳压二极管或运放跟随。如果是NTC这类电阻分压,注意高边电阻和低边电阻的阻值选择,保证最大输出在3.0V左右最好,留一点余量。我见过有人直接测12V电池电压,分压电阻算完发现最高也是3.2V,看着没问题,但电池电压波动时瞬间超过3.3V,引脚就出问题了。
第二坑是通道切换串扰。多通道轮询时,如果信号源阻抗不同,刚切过来就读,值可能会偏。我一般会在切换后抛弃前1到2个样本,或者把ADC_DIV调大,让每次转换前有足够采样时间。比如三路里面有两路是低阻输出,有一路是高阻传感器,那高阻通道读出来的偏差通常更大,这不是ADC精度问题,是RC充放电时间不够。
第三坑是参考电压。因为参考就是VDDA,如果把Pico用USB供电,USB的5V经过板载稳压器给3.3V,小电流下的纹波不大,但如果你外接电机、舵机,负载突变会让3.3V波动,ADC也会跟着跳。解决办法是给Pico的3.3V单独加滤波,或者模拟信号处理电路单独用高精度基准源。真正做精密采集时,可以考虑外部基准芯片,把参考电压从系统电源中独立出来。
4.3 读取值乱跳?先加滤波再怀疑硬件
软件滤波是ADC应用里绕不开的一步。我这里会先做中值滤波,再做滑动平均,效果比单一平均好很多。中值能剔除偶发的尖峰,滑动平均能压低高频抖动。一个比较实用的函数片段如下:
#define WINDOW_SIZE 15 uint32_t adc_filtered_read(void) { uint16_t buf[WINDOW_SIZE]; for (int i = 0; i < WINDOW_SIZE; i++) { buf[i] = adc_read(); sleep_us(10); } // 简单选择排序,取中值 for (int i = 0; i < WINDOW_SIZE - 1; i++) { for (int j = i + 1; j < WINDOW_SIZE; j++) { if (buf[j] < buf[i]) { uint16_t tmp = buf[i]; buf[i] = buf[j]; buf[j] = tmp; } } } uint32_t sum = 0; int start = WINDOW_SIZE / 4; int end = WINDOW_SIZE - WINDOW_SIZE / 4; for (int i = start; i < end; i++) { sum += buf[i]; } return sum / (end - start); }注意:滤波只能掩盖问题。如果你发现滤波之后数据依然偶尔抽风,先去查电源、查接线、查通道切换,而不是继续把窗口加长。窗口加长会让人对真实信号失去灵敏度,这一点在调试时很坑。我之前调试一个压力传感器,就是不断加滤波窗口,最后数据是稳了,但实际压力变化要几百毫秒才能反映出来,实时性完全不可用。
5. 常见问题速查表与项目实战经验
5.1 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读到的值始终是0 | 输入接地、通道没选对、GPIO没初始化成输入 | 用万用表量引脚电压,检查adc_select_input |
| 读到的值接近满量程 | 输入悬空、内部上拉误开、参考电压偏低 | 确认引脚没有上拉,输入信号真正连接 |
| 读数整体偏小或偏大 | 分压电阻选错、参考电压受负载波动影响 | 用高精度万用表校准,实测VDDA电压 |
| 多通道数据错位 | round-robin掩码和读取顺序对不上 | 让通道顺序固定,并在数据里加通道标记 |
| 开DMA后数据乱跳 | CPU又调了adc_read(),和DMA抢数据 | DMA模式下读取全部交给DMA,CPU只处理缓冲区 |
| 温度传感器读数不准 | 热敏参数差异、芯片自热 | 用公式离线校准,测量时避免长时间高负载 |
| 滤波后仍跳得厉害 | 电源纹波、长线耦合、参考电压不稳 | 先查硬件,别继续加大滤波窗口 |
5.2 让ADC更稳的几条实战经验
几条经验挨个说。第一,PCB布线上,模拟输入线尽量短,远离PWM信号线和电机驱动线。数字边沿的高频分量会通过寄生电容耦合到模拟输入上,即使你软件滤波够狠,也只是亡羊补牢。第二,如果需要更高精度,可以做过采样:连续采64次求平均,等效分辨率能提高几位。注意这个前提是噪声满足随机分布,如果噪声是有规律的纹波,过采样效果有限。
第三,如果想要多路同步采样,Pico不具备真同步ADC,只能靠轮询,且要明白各路之间有微小时间差。如果你的应用需要严格同步,比如三相电压电流同时采样,Pico在硬件上就不合适。第四,用电池供电时,注意电池电压下降导致稳压器输出电压变化,ADC参考也会变。可以先测量3.3V实际值,再反推真实电压,这是个好习惯。
最后再说一个细节:不要在充满热噪声的电路板上把ADC引脚走过孔,Pico的ADC输入阻抗不低,PCB漏电流会在高阻信号源场景被放大。能短就短,能用屏蔽线就用屏蔽线。我做过一个电池监测板,最初ADC走线绕了板子大半圈,读数总是有周期性干扰,后来把走线缩短直连,问题立刻消失。
最后分享一个我在实际项目里的小技巧:如果你给ADC输入端串联一个1kΩ左右的电阻,再在引脚到地并联一个100nF电容,构成一个简单低通滤波,对高频噪声会有不错的压制;但要注意这个RC会拉长建立时间,所以采样率要相应降低。Pico的ADC说简单也简单,说复杂也确实值得花时间把它喂明白,摸清它的脾气之后,做传感器采集会顺手很多。