做嵌入式这几年,我用过不少单片机的片上ADC,树莓派Pico的ADC是让我觉得“看着简单、用起来却最容易翻车”的外设之一。GPIO26到GPIO29能直接读模拟量,官方C SDK几行代码就能拿到数据,MicroPython里一句machine.ADC(26)也就完事了。可是只要你把真实电压用万用表量一遍,再对比代码读回来的原始值,误差就会冒出来——有些板子偏了2%,有些偏了5%,甚至有人发现到不了满量程。
这篇文章我不打算停留在“调库就行”的层面,而是直接把Pico的ADC从硬件架构、工作模式、寄存器操作一直拆到应用层的避坑。不管你用的是C SDK、MicroPython,还是打算纯寄存器手写驱动,搞清楚底层逻辑之后,再回去看那些封装好的接口,你会知道它们到底做了什么、没做什么。适合刚入手Pico的硬件爱好者,也适合想把这个12位ADC真正调准的开发者。
1. 先搞清楚RP2040的ADC硬件架构
1.1 逐次逼近型ADC是怎么工作的
RP2040内部集成的ADC是典型的逐次逼近型结构,也就是常说的SAR ADC。这类ADC的核心思想可以类比成天平称重:先放一个最大的砝码,看是不是太重,太重就撤掉,不重就保留;再放第二个小一点的砝码,继续比较,一路试到最小刻度。SAR ADC也是这么干的,内部有一个比较器和一个逐次逼近寄存器,从最高位开始,一位一位地和输入电压做比较,12位分辨率就需要比较12轮,每一轮都得出一个二进制比特。
这个结构决定了两个特性。一是转换速度相对固定,每次比较需要若干个ADC时钟周期,所以采样率和时钟分频有直接关系;二是它前面必须有一个采样保持电路,在转换开始前把输入电压“抓住”并锁存在内部电容上。如果输入信号源输出阻抗太高,或者输入电压在采样窗口内变化太快,保持电容上的电压就会和真实值有偏差。这个在后面应用避坑部分是个大重点。
1.2 Pico上到底有几个ADC通道
树莓派Pico的ADC在物理上只有一个,但是通过输入多路复用器扩展出了5个采样通道。其中4个是外部引脚,分别是GPIO26、GPIO27、GPIO28、GPIO29,对应ADC0到ADC3。这些引脚默认情况下就是ADC功能,不需要像普通GPIO那样切换复用,当然你也可以通过GPIO控制把它们改成数字输入输出,只是改了之后就没办法再读模拟量了。
通道4比较特殊,它没有外部引脚,内部连到了一个板载温度传感器。这个温度传感器本质上是利用半导体PN结电压随温度变化的特性来测温的。RP2040的数据手册里给出了一个参考计算公式:温度(摄氏度)约等于27减去除以0.001721的某个差值,但实际用起来误差比较大,后面我会专门说这件事。
还有一个不常见的知识点:虽然GPIO26到GPIO29在Pico开发板上都引出了,但RP2040的ADC前端是共享的,也就是说同一个时刻只能选中一个通道进行转换。如果你想快速扫描多个通道,必须依赖硬件轮询模式或者软件切换,并不存在多个ADC并行采样这回事。
1.3 内部参考电压和输入范围的坑
这是Pico ADC最大的坑,我把它提前到架构部分来讲,因为它直接决定了你所有读数的解释方式。
RP2040的ADC内部集成了一个带隙参考电压源,这个参考电压的标称值是2.0V,不是很多资料里想当然的3.3V。ADC的满量程转换结果0xFFF对应的电压理论上是VREF,也就是大约2.0V,超过这个电压之后读数就会饱和在4095。也就是说,如果你直接把GPIO26接到3.3V去测,得到的结果不是某个接近满量程的值,而是直接顶到4095,根本无法区分2.1V和3.3V。
那颗内部参考电压的实际值在不同芯片之间还有分散性,有些板子实测在2.09V左右,有些在2.14V左右。这带来的问题是:即使你用原始值 / 4095 * 3.3去换算电压,算出来的结果也大概率偏大,因为满量程根本就不是3.3V。MicroPython里read_u16()虽然是按16位返回,但底层仍然是12位ADC左对齐后的结果,同样的换算误区在MicroPython里也一样存在。正确的做法是把内部参考电压的真实值当成一个未知数,通过外部已知电压去校准,具体方法放到第4章。
2. 工作模式与关键寄存器逐字段拆解
2.1 寄存器地图:从基地址说起
树莓派Pico的主控RP2040是一颗Cortex-M0+双核芯片,ADC外设在内存映射中的基地址是0x4004C000。围绕这个基地址,一共分布了不到十个主要寄存器,它们是:
| 偏移 | 名称 | 作用 |
|---|---|---|
| 0x00 | CS | 控制与状态,负责开关、触发、通道选择、状态标志 |
| 0x04 | RESULT | 12位转换结果 |
| 0x08 | FCS | FIFO控制状态,负责FIFO开关、DMA请求、水位线 |
| 0x0C | FIFO | FIFO数据读取口 |
| 0x10 | DIV | ADC时钟分频,控制采样率 |
| 0x14 | INTR | 中断标志 |
| 0x18 | INTE | 中断使能 |
| 0x1C | INTF | 中断强制 |
| 0x20 | INTS | 中断状态 |
很多人在SDK里只见过adc_read()函数,不知道它背后其实只碰了CS和RESULT这两个寄存器。想真正掌控Pico的ADC,最好把上面这组寄存器当成自己的工具。
CS寄存器里最关键的几个字段包括:EN用来使能ADC模块,START_ONCE是单次触发的开关,START_MANY是连续自由运行的开关,DONE是单次转换完成标志,READY表示内部参考电压是否稳定,AINSEL用于选择当前要采样的通道,RROBIN用于开启多通道轮询,TS_EN用来把温度传感器接入内部通道。这里面只要搞明白前几个字段,单通道的读写流程就已经通了。
2.2 单次转换模式
单次转换模式是最常用也最容易理解的工作方式。先确保EN置1,等待READY变为1,确认内部参考电压稳定;然后写入START_ONCE触发一次转换;接着轮询DONE标志,等它变成1;最后读RESULT寄存器低12位,读完之后DONE会自动清除,为下一次转换做好准备。
这里有一个容易被忽略的细节:AINSEL和START_ONCE的写入顺序。建议每次都先把通道选好,再触发转换。如果边转换边切通道,采样保持电路抓到的可能是通道切换瞬间的毛刺。单次模式配合12位精度,在低速传感器读取场景下完全够用,稳定性也最好,因为在两次转换之间ADC可以彻底复位,不存在累积误差。
2.3 连续采集模式与FIFO缓冲
如果只是偶尔读一次电压,单次模式很合适;但当你需要连续采样,比如采集一段音频或者做一个数据记录器时,反复轮询DONE就会浪费CPU时间。这时可以写START_MANY进入连续自由运行模式,ADC会不断循环转换,每完成一次立刻开始下一次,不需要软件干预。
连续模式最大的问题是结果可能来不及读走。如果CPU去处理别的任务,转换结果就会被新数据覆盖。为此RP2040给ADC配了一个4级深度的FIFO。把FCS寄存器里的FIFO使能位打开后,每次转换完成的12位结果会压入FIFO,你可以从FIFO数据寄存器依次弹出,不用焦虑单次丢失。FIFO还有一个水位线(阈值)设置,当里面堆积超过指定条数后,可以触发中断或者DMA请求,这样CPU可以批量读取。
连续模式的采样率由DIV寄存器控制。DIV是一个定点分频器,整数部分16位、小数部分8位,输入时钟是48MHz。分频值越大,采样率越低;分频值为0时,ADC以最高速度运行,大约500kS/s。如果你需要精确控制采样间隔,比如音频项目里常用的44.1kHz,就要按48MHz / 目标采样率 - 1的设置去推算分频值,而且小数部分可以让分频做到非常接近整数频率。
2.4 中断和DMA是怎么挂上去的
ADC的中断逻辑并不复杂。INTR寄存器会记录当前有没有触发转换完成事件,INTE决定把这个事件广播到中断控制器。比如你可以让FIFO的水位线到达4条时触发一次中断,中断服务程序里一次读走4个数据,这样CPU的开销很低。
如果要做到完全不占用CPU,那就得靠DMA。RP2040的DMA控制器可以从ADC外设的DREQ请求信号得到通知,每来一个请求就搬运一个数据,搬运到哪里由DMA通道的目标地址决定。很多人用adc_fifo_setup配置FIFO时只开启了FIFO和DREQ,却忘了确认DREQ信号对应的通道索引,结果DMA搬了一堆空数据。严格来说,ADC的DREQ通道号是固定的,需要在DMA初始化时把外设请求选择到ADC的DREQ上,这样才能实现“采样结果一出,DMA自动搬走”的效果。
2.5 多通道轮询采样
当你需要同时测量多个模拟量时,有两种常见方案。一是软件轮询:每轮依次选中一个通道,触发一次单次转换,读完再切换下一个通道,这种方式代码简单,但通道之间会有切换时间和触发延迟。二是硬件轮询:利用CS寄存器里的RROBIN字段,把要参与轮询的通道对应位置1,比如想让ADC0和ADC1轮流采样,就把这两个通道的轮询使能位置位,然后让ADC进入连续模式。硬件会自动逐个通道采样,并且每个结果在FIFO里会附带通道编号信息,你从FIFO读出数据后需要根据编号区分数据属于哪一路。
推荐在采样率不高、多通道同步性要求不极端的场景使用硬件轮询;如果你需要多路严格同步采样,这已经不是片上ADC能解决的范畴了,外面得加多通道同步采样芯片,比如ADS131系列。
3. 寄存器级实操:从零写一个ADC驱动
3.1 准备时钟和GPIO
不管用寄存器还是SDK,开ADC之前都逃不掉两件事:给ADC模块提供时钟,以及确认GPIO处于模拟复用状态。
Raspberry Pi Pico的系统时钟经过PLL后会有48MHz的外设时钟,ADC就是从这个时钟取源的。如果你用的是SDK,clocks_init()阶段已经把时钟打开;如果你做的是裸机开发,需要自己确认ADC时钟没有被关闭。GPIO26到GPIO29上电后默认就是ADC功能,但如果你之前把它们配置成了输入或输出,要重新恢复成ADC模拟功能,否则引脚内部是数字通路,模拟采样结果会异常。这一点被很多人忽略,我在一次项目里就是这个原因导致读数一直不稳定。
3.2 单次读取的完整代码
为了还原最底层的行为,下面这段代码完全不依赖Pico SDK,只用寄存器地址。我以读取GPIO26也就是ADC通道0为例:
#define ADC_BASE 0x4004C000u #define ADC_CS (*(volatile uint32_t *)(ADC_BASE + 0x00)) #define ADC_RESULT (*(volatile uint32_t *)(ADC_BASE + 0x04)) #define ADC_FCS (*(volatile uint32_t *)(ADC_BASE + 0x08)) #define ADC_FIFO (*(volatile uint32_t *)(ADC_BASE + 0x0C)) #define ADC_DIV (*(volatile uint32_t *)(ADC_BASE + 0x10)) #define ADC_CS_EN (1u << 0) #define ADC_CS_START_ONCE (1u << 1) #define ADC_CS_START_MANY (1u << 2) #define ADC_CS_DONE (1u << 3) #define ADC_CS_READY (1u << 5) #define ADC_CS_AINSEL(ch) (((uint32_t)(ch) & 0x7u) << 6) uint16_t adc_read_channel(uint8_t ch) { // 1. 使能ADC ADC_CS |= ADC_CS_EN; // 2. 等待内部参考稳定 while (!(ADC_CS & ADC_CS_READY)) { // 如果参考异常,这里会卡死,可加超时 } // 3. 退出连续模式,选择通道 ADC_CS &= ~ADC_CS_START_MANY; ADC_CS = (ADC_CS & ~ADC_CS_AINSEL(0x7)) | ADC_CS_AINSEL(ch); // 4. 触发单次转换 ADC_CS |= ADC_CS_START_ONCE; // 5. 等待完成 while (!(ADC_CS & ADC_CS_DONE)) { // 等待转换结束 } // 6. 读结果(读完DONE自动清0) return (uint16_t)(ADC_RESULT & 0xFFFu); }这段代码的核心思路就是“等稳定、选通道、触发、等完成、读结果”。注意第6步读的是RESULT寄存器,这个寄存器只有一个12位有效字段,高位的其他内容不要动。实际工程里建议给每个while加超时处理,避免硬件异常时程序死循环。
3.3 带FIFO的连续采集
单次采集很简单,但如果你要用DMA或者高频率连续采样,就要把FIFO用起来。SDK里对应的函数是adc_fifo_setup,它本质上是在操作FCS寄存器。手动操作时,步骤大致如下:
// 使能FIFO,设置阈值为1,不启用DREQ(此处只做FIFO缓冲) ADC_FCS |= (1u << 0); // FCS_EN 使能FIFO // 进入连续模式 ADC_CS |= ADC_CS_START_MANY; // 读数据 while (ADC_FCS & (1u << 1)) { // FCS_EMPTY 为0表示FIFO有数据 uint32_t raw = ADC_FIFO; // 低12位是ADC结果,高位可能带通道编号 }实际开发中我建议优先使用官方SDK的adc_fifo_setup(false, 1, true, false, false)这种方式来初始化FIFO,因为它在跨平台移植时更可靠,寄存器操作适合用来理解原理和调试疑难问题。FIFO还有一个位宽和移位设置,可以配置成8位或者12位左对齐出栈,这个要根据你后续DMA搬运的数据宽度来想清楚。
3.4 与C SDK和MicroPython的对应关系
官方C SDK把上面这些寄存器操作都封装好了一层,比如adc_init对应使能和等待READY,adc_select_input对应设置AINSEL,adc_read对应触发一次并读取RESULT,adc_fifo_setup对应配置FCS,adc_set_clkdiv对应设置DIV。所以如果你不想写寄存器,直接调用这些函数完全可行。但理解寄存器的作用仍然有意义,因为很多坑藏在封装的“没说出口”的细节里,比如通道切换后是否需要延时、FIFO是否默认开启、DMA请求号是多少。
MicroPython里就更简单了,ADC(26)就够用,底层固件帮你完成了所有初始化。不过MicroPython的read_u16()返回的是16位值,很多新手误以为这个值是直接除以65535再乘以3.3V,结果得到明显偏大的电压。要把MicroPython的16位结果还原成12位,其实只要右移4位,再按真实参考电压换算。这也是我坚持从底层理解问题的原因。
4. 应用环节的避坑指南
4.1 输入阻抗过高导致读数漂移
这是Pico ADC“抽风”的第一大原因。SAR ADC在采样阶段会在采样电容和信号源之间形成短暂的电荷转移,如果信号源阻抗太高,电容来不及充到输入电压,采样到的值就会偏低,而且这个偏差会随着采样率的提高越来越明显。
最典型的场景是直接用一个10kΩ电位器分压去测电压。电位器的输出端口等效内阻随滑片位置变化,读回来的值和万用表量出来的值经常对不上。解决思路有几个:最简单是降低ADC时钟频率,给采样电容留出更长的充电时间,也就是调大DIV寄存器;更彻底的办法是在信号源和ADC之间加一个单位增益的运算放大器,也就是电压跟随器,把信号源的输出阻抗降低到几欧姆级别。对Pico而言,ADC前端还有模拟多路复用开关,输入阻抗会比理想情况更敏感,所以电压跟随器几乎是采集外部真实传感器信号时的标配。
4.2 参考电压实测校准方法
内部VREF不精确的问题靠硬件改不了,只能在软件里做校准。校准的思路是先测量几个已知电压,再反推出增益和偏移。
我的做法是这样:找一颗高精度参考电压源,比如TL431搭的2.5V基准,然后用高精度万用表量出它的实际输出电压V1,接到GPIO26上读回原始值raw1;再用一个精密电阻分压得到另一个已知电压V2,读回raw2。之后用两点式直线方程把原始码值映射到真实电压:
V = V1 + (raw - raw1) * (V2 - V1) / (raw2 - raw1)把这个公式写进代码里,每次读到原始值后做一次线性变换即可。校准之后,Pico的ADC误差可以从百分之几降到千分之一左右,受限于12位分辨率和内部噪声,再往下压就比较难了。需要说明的是,校准只对当前这颗芯片、当前环境温度有效。如果环境温度变化很大,VREF还会轻微漂移,要求高的场合最好定期重新校准。
4.3 噪声抑制和软件滤波
Pico的ADC在高速采样时读数会有轻微跳动,这是正常的,毕竟12位分辨率在2V满量程下对应每个LSB约0.5mV,而板上开关电源、USB供电都会引入纹波。要想稳定读数,先做好硬件:模拟地AGND要和数字地单点连接,GPIO26附近的电源引脚加0.1μF去耦电容,信号线尽量短。软件侧可以做的就更多了,最常见的是滑动平均、中值滤波和两者的结合。
我测过一个环境光传感器,直接用单次读取时波动有±15个LSB,用5次滑动平均后波动缩小到±4个LSB,采集频率不高时效果很明显。如果你需要保留信号的实时性,可以用带时间常数的低通滤波,也就是递推平均,对每个新样本做加权更新,权重系数根据信号带宽去调。注意别把所有滤波都堆在最后做均值,那样对突发尖峰不友好;先用中值滤波剔除野值,再做滑动平均,效果通常更好。
4.4 温度传感器:不是这么用的
通道4连接的温度传感器,它的精度真心一般。官方手册里给出的转换公式是在特定条件下测得的,实际用下来误差可能到正负5摄氏度以上,传感器本身还有自热效应,芯片长时间跑高频任务时温度读数也会跟着漂。所以我的建议是:这个内部温度传感器更适合做“趋势监测”,比如判断CPU有没有过热,而不是作为精准温度计去测环境温度。真要测准,外挂一个DS18B20或者NTC热敏电阻,可靠性高得多。
使用它的时候还有一个寄存器细节:必须把CS寄存器里的TS_EN位置位,温度传感器通道才会真正接入ADC。直接在AINSEL里选通道4而忘记使能TS_EN,读回来的数据没有意义。这个坑说大不大,但排查起来很绕。
4.5 其它容易踩的坑
还有一个常见问题是FIFO溢出。连续采样时如果CPU读数据不够快,FIFO满了之后新数据会把旧数据冲掉,FCS寄存器里的溢出标志会置位。解决办法要么提高读取频率,要么降低采样率,要么用DMA把数据批量搬到内存缓冲区。哪怕是MicroPython用户也会遇到类似问题:用while True循环打印ADC读数,打印本身就耗时间,高速采样时会发现打印出来的数据明显变稀疏,这就是因为读取速率跟不上采样速率。
还有一点要说清楚:ADC的输入绝对不能超过它的绝对最大额定值。虽然推荐输入范围是0到VREF,但在上电瞬间或者信号毛刺很大时,引脚电压一旦超过规定值或者低于GND,内部ESD保护二极管可能导通,导致采样数据异常甚至损坏芯片。所以外部信号源电压范围如果不可控,最好先做一下钳位或分压处理。
5. 常见问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 读数整体偏高 | 按3.3V换算电压,但实际VREF约2.0V | 用已知电压校准,或改用原始值加两点校正 |
| 读数整体偏低且随阻抗变化 | 信号源输出阻抗太高 | 加电压跟随器,或降低ADC时钟频率 |
| 读数跳动剧烈 | 电源噪声、采样率过高、滤波不够 | 硬件加去耦,软件加中值+滑动平均 |
| 所有通道读到4095附近 | 输入电压超过VREF输入范围 | 用分压电路把电压拉到VREF以下 |
| 温度传感器读数异常 | 忘记置位TS_EN,或通道选择错误 | 确认CS寄存器TS_EN位和AINSEL都为4 |
| 连续采样时数据丢失 | FIFO溢出或读取太慢 | 提高读取速率、降低采样率、启用DMA |
| 高速采样时结果严重偏小 | 采样时间不够 | 调大ADC_DIV,降低采样时钟频率 |
| MicroPython读数不对 | read_u16返回16位左对齐结果 | 右移4位得到12位原始值,再按VREF换算 |
每个人的使用环境不同,但上面这些问题是Pico ADC项目里出现频率最高的。排查的时候不要一上来就怀疑芯片坏了,先按顺序看一下输入电路、参考电压、采样时钟和FIFO状态,大部分问题都能在这个表里找到方向。
最后再分享一个我自己的经验教训:第一次用Pico做电池电压监测项目时,我花了一整天调精度,最后发现不是ADC的问题,而是电池分压电阻的精度只有5%,把电阻换成了1%精度的金属膜电阻之后,读数立刻稳了。ADC再准,前端的模拟链路才是决定整体精度的瓶颈。建议大家在调试时准备一个精度可靠的万用表,用它去验证每一个环节的真实电压,这样才能确认误差到底出在ADC、参考源还是电阻网络上。