树莓派Pico ADC深度解析:从SAR原理到DMA多通道采集
2026/9/9 7:10:36 网站建设 项目流程

很多人拿到树莓派 Pico 第一件事就是玩点灯和串口,等真正要用 ADC 采样电压时,SDK 里一个adc_read()调完就把板子扔一边了。但用这个函数读到的数字只是表象,背后那块 SAR ADC、状态机、寄存器,甚至 FIFO 和 DMA 的配合,才是决定项目能不能稳健跑起来的真正分水岭。这篇文章就把树莓派 Pico ADC 从硬件架构、工作模式、寄存器到应用避坑逐层拆一遍,最后结合我实际踩过的坑给出可以直接抄作业的代码和排查思路。无论你是第一次接触单片机 ADC,还是已经会调 Pico SDK、想上 DMA 多通道采集,后面这些内容都值得认真过一遍。调试期间遇到问题,可以直接翻到第 4 章的速查表对号入座。

从我开始用 Pico 做电压采集到现在,最深的感受是:ADC 这东西,看着简单,真正把它放到一个真实项目里,几乎每个环节都有暗坑。这篇就按“硬件原理 → 工作模式 → 寄存器/SDK 实操 → 避坑”这条线写下来,尽量做到不但告诉你怎么写,还告诉你为什么要这么写。

1. 硬件架构:别再把 ADC 当成一个黑盒子

1.1 SAR ADC 的原理:为什么每次转换要花 96 个时钟周期

树莓派 Pico 用的是 RP2040 芯片,内置的 ADC 是一颗 12 位逐次逼近型(SAR ADC)转换器,不是积分型,也不是 Σ-Δ 型。SAR 这个词很多人见过,但未必清楚内部到底怎么工作。它里面有一个比较器、一个内部 DAC、一个逐次逼近寄存器,工作过程很像“用手掂重量”:先拿一半满量程电压去比,看信号比这个大还是小,从而确定最高位;然后根据结果再把范围缩小一半,继续比下一位。这样一个 bit 一个 bit 地逼近,12 位分辨率就要比 12 次,所以叫逐次逼近。

RP2040 的 ADC 时钟基数是固定的 48MHz,而完成一次转换至少要花 96 个 ADC 时钟周期。这 96 个周期里既包括了采样保持电容的充电时间,也包括了 SAR 逐位比较和内部 DAC 建立的时间。如果分频器设置成 0,也就是不分频时,最大采样率大约就是 500kS/s。这个 500k 听起来不高,但对一个通用 MCU 来说已经够用。很多人在代码里完全不关心采样率,以为读得越快越好,其实这个 96 周期约束直接决定了你的采样率上限,也决定了高阻抗信号源能不能采准。

这里要纠正一个常见误区:ADC 的分辨率是 12 位,不代表它每次读数都能达到 12 位精度。分辨率只是把 3.3V 电压范围切成了 4096 格,每一格对应约 0.806mV;真实的精度还受参考电压噪声、采样电容充电程度、芯片制造偏差等因素影响,实际有效位数可能只有 10 到 11 位。把它当成一个“裸奔的 12 位传感器”来用,往往会在项目中吃亏。

1.2 输入通道和参考电压:4 个外部引脚加 1 路内部温度

Pico 的 ADC 通道规划是很多人第一天就会踩坑的地方。RP2040 一共有 5 个可供使用的模拟输入通道,其中 4 个是外部引脚,1 个是内部温度传感器。

通道编号引脚/来源说明
ADC0 / AIN0GPIO26外部模拟输入
ADC1 / AIN1GPIO27外部模拟输入
ADC2 / AIN2GPIO28外部模拟输入
ADC3 / AIN3GPIO29外部模拟输入,注意部分板卡被占用
ADC4 / AIN4内部温度传感器测量芯片温度,不是环境温度

参考电压方面,ADC 的满量程对应 VREF,也就是 3.3V。转换结果和输入电压之间是线性关系:raw = Vin / 3.3 * 4095。反推电压自然就是Vin = raw * 3.3 / 4095。这个公式永远要记住,看到 raw 先换算成电压再谈分析。1 个 LSB 对应的电压是 3.3V 除以 4096,约 0.806mV,有的地方写 4095,有的写 4096,工程上差距极小,不必纠结。

还有一个关键点:Pico 板子上ADC_VREF引脚通常和 3.3V 电源直接连在一起,芯片的参考电压就是板子自己的 3V3,因此 ADC 精度会被供电质量直接绑架。你就算代码写得再漂亮,3.3V 电源纹波大,读出来的数据一样会抖。如果需要高精度,要么把供电做好,要么考虑外接高精度基准电压源,但这需要动板级设计,新手不建议折腾。

另外,Pico W 这类带无线的板子,GPIO29 往往已经被板载射频或控制功能占用,所以默认拿它当 ADC3 用基本是踩坑。带无线版本的板子,外部 ADC 通道优先用 GPIO26、GPIO27、GPIO28,遇到问题先看官方原理图确认哪个引脚是干净的。

1.3 精度瓶颈不在 ADC 本身,而在你给的信号源

很多人调 ADC 第一反应是怀疑芯片不行,其实大多数精度问题出在信号源阻抗上。SAR ADC 内部有一个采样保持电容,每次转换前需要对这个电容充电。如果信号源内阻很高,充电时间就不够,采样到的电压会低于真实值,而且会跟着温度、噪声漂移。

RP2040 数据手册对源阻抗的要求其实挺严苛,量级在百欧姆级别。这不是说接个 1k 电阻就完全不能读,而是你会明显看到读数偏低和跳动。我用一个 10k 电位器直接连到 GPIO26 测试时,读数从头到尾都在跳,加了一级运放跟随器之后立刻稳定下来。如果你的信号是高阻输出,比如光电二极管、某些化学传感器,最稳妥的办法是先用一个低输出阻抗的运放缓冲,再进 ADC。

除了源阻抗,输入范围也必须在 0 到 3.3V 之间。超过 3.3V 不仅读不出更高值,还可能损坏引脚。要测电池这种超过 3.3V 的电压,必须用电阻分压。比如要测 5V 信号,可以用 10k 和 18k 分压,5V * 18 / (10+18) ≈ 3.21V,刚好卡在安全范围内。分压电阻也不能选太大,否则又变成高阻源问题,要综合取舍。

2. 工作模式与寄存器:手动操作背后的状态机

2.1 单次转换:START_ONCE、DONE 和 AINSEL

Pico 的 ADC 寄存器基地址是 0x4004C000。SDK 封装得很好,但了解寄存器才能理解那些函数到底做了什么。

单次转换是最常用的模式,流程非常简单:先把CS寄存器里的AINSEL字段设为要采的通道号,然后写START_ONCE触发一次转换,接着轮询DONE标志,等硬件完成后从RESULT寄存器读出 12 位数据。SDK 的adc_read()底层干的就是这件事,只不过帮你把寄存器操作和等待都封装好了。

如果你不想用 SDK,核心寄存器操作可以精简成这样:

#define ADC_BASE 0x4004c000 #define ADC_CS (*(volatile uint32_t *)(ADC_BASE + 0x00)) #define ADC_RESULT (*(volatile uint32_t *)(ADC_BASE + 0x04)) uint16_t adc_read_reg(int ch) { ADC_CS &= ~(0x7u << 9); // 清 AINSEL ADC_CS |= ((uint32_t)ch) << 9; // 选通道 ADC_CS |= (1u << 1); // START_ONCE while (!(ADC_CS & (1u << 3))); // 等 DONE return ADC_RESULT & 0xfff; // 低 12 位 }

这段代码的前提是已经初始化好外设时钟、GPIO 复用,并且CS.EN已经置 1。它给你展示的是 ADC 工作的核心状态机:选通道、触发、等待、读结果。理解了这个,SDK 里那些封装函数的调用顺序就不会再玄学。

CS寄存器里的字段要记清楚:EN是总开关,START_ONCE是单次触发,START_MANY是连续采样开关,DONE是转换完成标志,RROBIN[4:0]控制轮询通道,AINSEL[2:0]控制当前选择的输入通道。了解了这些位,后面的所有模式都是在这个基础上叠加。

2.2 连续采样与轮询:RROBIN 怎么让四个通道自己转圈

单次转换之外,第二种常用模式是连续采样。把START_MANY置 1,ADC 会启动后不停转换,不需要一次次手动触发。这个模式下,你只需要在需要时读取最新结果。单独跑一个通道的连续采样很简单,真正有意思的是轮询模式。

RROBIN是一个 5 位的掩码,每一位对应一个通道:bit0 到 bit3 对应 ADC0 到 ADC3,bit4 对应内部温度传感器。哪些位置 1,ADC 在连续模式下就会自动在这些通道之间循环采样。

adc_set_round_robin((1 << 0) | (1 << 1) | (1 << 2) | (1 << 3)); adc_run(true);

开启轮询后,硬件会自动更新AINSEL,所以你不需要手动切频道,FIFO 里出来的数据顺序固定是 0、1、2、3、0、1、2、3……这样的循环。这里有个经验:轮询模式刚启动时,前几个样本可能受之前通道残留电荷影响,所以多通道采集时,我习惯把第一批数据丢掉,或者用 DMA 配合循环缓冲区做后期对齐。

2.3 FIFO、DREQ 和 DMA:数据搬运的正确姿势

单通道慢速采集,CPU 每次读一个值无所谓。但如果你要四通道连续采样,或者每秒钟要几千个点,CPU 忙等会浪费大量时间。这时候 FIFO 和 DMA 就派上用场了。

Pico 的 ADC 自带一个 8 项深度的 FIFO,转换完成后结果可以先暂存到 FIFO 里。FCS寄存器里的THRESH字段用来设置触发阈值,当 FIFO 里的数据量达到这个阈值,硬件会拉高 DREQ 信号,通过 DMA 控制器自动把数据搬到内存。

我自己做四通道 DMA 时,最直观的经验是:DREQ 阈值设在 1 或 2,逻辑最直白,每次 FIFO 有货就搬走;如果采样率很高,可以把阈值调到 4 或 8,减少 DMA 请求次数,但一定要注意别让 FIFO 溢出。FIFO 溢出时FCS里的OVERFLOW标志会置位,从标志位就能看出搬运速度跟不上采样速度。

这一整套机制的意义是:ADC 采样过程中 CPU 基本可以撒手不管,DMA 把数据从外设搬到内存数组里,你只需要在 DMA 完成中断里处理整包数据。这就是“数据搬运的正确姿势”。

2.4 关键寄存器速查表

偏移寄存器关键位用途
0x00CSEN、START_ONCE、START_MANY、DONE、RROBIN[4:0]、AINSEL[2:0]ADC 控制与状态
0x04RESULTRESULT[11:0]单次转换结果
0x08FCSTHRESH[2:0]、LEVEL[2:0]、FULL、EMPTY、UNDERFLOW、OVERFLOW、DREQ_EN、DMA_ENFIFO 控制与状态
0x0CFIFOFIFO 数据读取 FIFO 结果
0x10DIVINT[15:0]、FRAC[7:0]采样分频,16.8 定点
0x14INTRFIFO_THRESH、FIFO_OVER、FIFO_UNDER、DONE原始中断状态
0x18INTE同上中断使能
0x1CINTF同上中断强制
0x20INTS同上中断状态

表格里这些寄存器主要在数据手册第 4.9 章。SDK 用多了再看这张表,会非常有画面感。

2.5 采样率计算与常用档位

采样率的计算式特别重要,几乎所有采样相关的问题最后都会回到这个公式:

采样率 = 48MHz / ((DIV + 1) * 96)

DIV 是分频寄存器的值,这是个 16.8 定点数,也就是整数部分加小数部分。SDK 里adc_set_clkdiv()可以直接传浮点数,很方便。

我整理几个常用档位:

DIV 设置采样率
0500kHz
1250kHz
3125kHz
950kHz
1925kHz
995kHz
999500Hz

空气采样率 500kHz 只是上限,不代表每次都要跑满。采样率越低,每个采样点的充电时间越长,对高阻源越友好。很多传感器本身响应很慢,完全没必要跑 500kHz。低速高精度场景,把分频调到 100 或 1000 都是常见操作。

3. 基于 SDK 的实操:从单通道到四通道 DMA

3.1 SDK 常用函数一览

Pico SDK 把这些寄存器操作封装得比较顺手,核心里面就这些函数:

函数作用
adc_init()初始化 ADC 外设,使能时钟和内部电源
adc_gpio_init(gpio)配置 GPIO26-29 为 ADC 模拟输入
adc_select_input(ch)选择当前输入通道 0-4
adc_read()执行一次单通道采样,返回 12 位结果
adc_run(start)启动或停止连续采样
adc_set_round_robin(mask)设置轮询通道掩码
adc_fifo_setup(en, dreq_en, thresh, err_in_fifo, byte_shift)配置 FIFO 和 DMA 请求
adc_fifo_read()从 FIFO 读取一个结果
adc_fifo_get_level()获取 FIFO 内数据个数
adc_set_clkdiv(div)设置分频
adc_set_temp_sensor_enabled(en)使能内部温度传感器

这个函数表就是一个地图。后面几节我会把最常写的三种代码贴出来,并且解释每一步在干什么。

3.2 单通道读电压:最基础也是最容易忽略的流程

最简单的单通道采样代码,网上一搜一大把,但很多人直接抄完发现读数为 0 或者满天飞,就是因为少了adc_gpio_init或者选错了通道编号。

#include <stdio.h> #include "pico/stdlib.h" #include "hardware/adc.h" int main() { stdio_init_all(); adc_init(); adc_gpio_init(26); // GPIO26 配置为模拟输入 adc_select_input(0); // 选择 ADC0 while (true) { uint16_t raw = adc_read(); float v = raw * 3.3f / 4095.0f; printf("raw=%u v=%.3f\n", raw, v); sleep_ms(100); } }

注意第二行adc_gpio_init(26)和第三行adc_select_input(0)是不同的操作。前者是把 GPIO26 的引脚复用功能切换到模拟输入,后者是告诉 ADC 内部多路选择器选通道 0。漏掉任何一个,结果都不对。如果你用 GPIO27 采样,记住 GPIO27 是 ADC1,通道号和引脚号不是一回事。

3.3 温度传感器:内部通道的正确打开方式

读取内部温度传感器是一个很常见的测试例程,代码看起来简单,但坑也不少。

adc_init(); adc_set_temp_sensor_enabled(true); adc_select_input(4); // 通道 4 是温度传感器 uint16_t raw = adc_read(); float v = raw * 3.3f / 4095.0f; float t = 27.0f - (v - 0.706f) / 0.001721f;

温度传感器输出一个随温度变化的电压,典型参数是 27 摄氏度时输出电压约 0.706V,温度系数约 1.721mV/摄氏度。所以芯片温度大约是T = 27 - (V - 0.706) / 0.001721。用这个公式时要把电压先算出来,然后再转温度。

有几个容易忽略的点:温度传感器通道必须先用adc_set_temp_sensor_enabled(true)使能,否则读数可能为 0;测到的是芯片内部温度,不是环境温度;这个传感器精度有限,当成 ±2 到 3 摄氏度的参考值可以,真拿去做温度控制容易被坑。出厂校准参数存在个体差异,不同批次芯片读数可能会差好几度,不要指望它替代数字温度传感器。

3.4 四通道轮询 + FIFO + DMA:多通道采集的正确写法

多通道高速采集是把这个 ADC 用到极致的地方,也是热搜词里出现最频繁的场景。我直接把一个能跑的四通道采样核心片段贴出来,先说结论:用 DMA 之后,CPU 负担极小,数据按通道顺序排列在内存数组里,处理很方便。

#include <string.h> #include "pico/stdlib.h" #include "hardware/adc.h" #include "hardware/dma.h" #define CHANNELS 4 #define SAMPLES 100 uint16_t data[CHANNELS * SAMPLES]; void adc_dma_sample(void) { adc_init(); adc_gpio_init(26); adc_gpio_init(27); adc_gpio_init(28); adc_gpio_init(29); int ch = dma_claim_unused_channel(true); dma_channel_config cfg = dma_channel_get_default_config(ch); channel_config_set_transfer_data_size(&cfg, DMA_SIZE_16); channel_config_set_read_increment(&cfg, false); channel_config_set_write_increment(&cfg, true); channel_config_set_dreq(&cfg, DMA_DREQ_ADC); dma_channel_configure(ch, &cfg, data, // 写入地址:内存数组 &adc_hw->fifo, // 读取地址:ADC FIFO CHANNELS * SAMPLES); adc_fifo_setup(true, true, 1, false, false); adc_set_round_robin((1 << 0) | (1 << 1) | (1 << 2) | (1 << 3)); adc_run(true); dma_channel_wait_for_finish_blocking(ch); adc_run(false); adc_fifo_drain(); }

这段代码的逻辑是:DMA 把 ADC FIFO 里的数据源源不断搬进data数组,传输总数是CHANNELS * SAMPLES。由于轮询顺序固定,数据在数组里的排列是data[0]=CH0, data[1]=CH1, data[2]=CH2, data[3]=CH3, data[4]=CH0...,处理时按索引取整就是对应通道。

这里我踩过的坑是dreq_thresh设为 1 虽然逻辑最简单,但在 500kHz 满速采样时,DMA 请求频率太高,CPU 中断反而可能受影响。实际项目里如果发现 FIFO 溢出,可以把adc_fifo_setup的第三个参数调大,比如 4,让硬件凑够 4 个数据再搬一次。代价是数据响应多了一点延迟,但对大多数慢速传感器毫无影响。

3.5 C 语言 ADC 值滤波函数:放对位置才有效

采集到原始数据之后,不能直接拿起来就用,噪声和抖动是家常便饭。我常用三种滤波方式,不同场景选不同方案。

滑动平均适合平稳信号,比如电池电压、温度这类变化慢的量,代码简单有效:

#define FILTER_N 8 static uint32_t buf[FILTER_N]; static uint8_t idx = 0; static uint32_t sum = 0; static bool full = false; uint16_t moving_average(uint16_t x) { if (full) sum -= buf[idx]; buf[idx] = x; sum += x; idx = (idx + 1) % FILTER_N; if (idx == 0) full = true; return (uint16_t)(sum / FILTER_N); }

中值滤波适合有明显毛刺的场景,比如电机启动时把 ADC 信号瞬间拉高或拉低。取 5 个点排序取中间值,稳定性和响应速度都比较均衡:

static int cmp16(const void *a, const void *b) { return (*(uint16_t *)a) - (*(uint16_t *)b); } uint16_t median_filter(uint16_t x) { static uint16_t hist[5]; static uint8_t i = 0; uint16_t tmp[5]; hist[i++] = x; if (i == 5) i = 0; memcpy(tmp, hist, sizeof(tmp)); qsort(tmp, 5, sizeof(uint16_t), cmp16); return tmp[2]; }

一阶低通适合需要实时平滑、又想要快响应的场景,代码最轻量:

static float y = 0; static bool inited = false; uint16_t lowpass(uint16_t x) { if (!inited) { y = x; inited = true; return x; } y = 0.9f * y + 0.1f * x; return (uint16_t)y; }

注意滤波函数要放在哪个位置。我在项目里的习惯是:每个通道先各取 5 到 10 个点,做中值或滑动平均,再进入业务逻辑。如果放到 DMA 中断后统一过滤,注意数组长度要够,别越界。还有一点,滤波不是万能的,如果源阻抗和电源问题没解决,滤波只会掩盖问题,治标不治本。

4. 应用避坑与排查实录:这些坑我全踩过

4.1 读数跳得离谱:先怀疑悬空和接线

ADC 引脚悬空时读数会随机漂移,这是正常现象,不是芯片坏了。我见过不少新手拿一根杜邦线直接怼到 GPIO26 上,另一端什么也不接,然后跑来问为什么读数不稳定。ADC 引脚必须有确定的电压信号,这是第一课。

接线里的坑还包括:ADC 引脚不能和数字输出直接并联,否则数字引脚的推挽信号会把模拟电压强行拉高拉低;GPIO 复用配置错了也可能导致功能冲突。我调试时常做的一件事是拿万用表直接量引脚电压,和程序读到的电压对一下。如果万用表显示的是 1.65V,程序读出来是 1.65V,那 ADC 本身没问题,问题多半在信号源或者接线。这个习惯能帮你快速分层定位。

4.2 高阻源带来的读值偏低和拖尾

高阻抗源是 ADC 精度杀手。我曾经用一个 10k 电位器中间抽头直接接 GPIO26,读数跳得完全没法用,后来接了一级运放跟随器才稳定。原理很简单:ADC 内部采样电容充电需要时间,信号源内阻太高,电容充不满,读数自然偏低。高速采样时这个问题更明显,因为留给电容充电的时间更短。

解决思路有三个:第一,降低信号源阻抗,最标准的方法是加电压跟随器,这是首选;第二,降低采样率,给采样电容更多的充电时间,对某些中高阻源有一定改善;第三,在 ADC 引脚加一个小电容,比如 0.1uF 到 GND,但加完以后信号响应会变慢,要看你的信号频率能不能接受。真实项目里,如果信号频率不高,我会同时用跟随器和弱滤波电容,效果最稳。

4.3 多通道切换后数据错位

多通道轮询采集最常见的现象是数据串位,比如明明采的是通道 0,读出来却像是通道 1 的残留值。这是因为切换通道后,采样电容里还残留上一通道的电荷,需要一点建立时间才能稳定。别指望硬件会帮你自动丢掉无效数据,它只是按顺序转。

我的处理办法是:轮询模式下第一组数据直接丢弃;或者 DMA 启动后先把缓冲区填满一次再触发业务处理;或者在每个通道切换后多采一个点但不用它。数据错位问题在低速采样时不太明显,一旦跑高速,通道数变多,这个坑就会暴露,处理思路要提前设计好。

4.4 电源纹波与参考电压:精度瓶颈大部分在供电

这是最容易被忽略、也最容易翻车的一环。Pico 的 ADC 参考电压直接和 3.3V 绑在一起,3.3V 上有 20mV 纹波,对应到 ADC 上就是大约 25 个 LSB 的跳动,你滤波都未必救得回来。很多嵌入式系统的 3.3V 是从 DC-DC 输出的,纹波往往不小。

改善方案按性价比排序:先把供电做好,比如用 LDO 给模拟部分供电;然后在 ADC_VREF 引脚附近加合适的去耦电容;如果项目对精度要求高,就不要用板内 ADC,直接上外部 ADC 芯片。这里再提一个软件校准思路:如果只是增益和偏置误差,可以用两点或三点校准。三点校准一般是采三个已知电压,比如接近 0V、中间值 1.65V、接近 3.3V 的满量程点,拟合出线性参数或分段补偿表,把 raw 映射到更贴近真实电压的值。注意这只能解决系统误差,解决不了随机噪声。

4.5 常见问题速查表

现象可能原因解决办法
读数一直 4095输入超量程、通道悬空接高、选错通道检查信号电压,确认 AINSEL 和接线
读数一直 0输入接地、未使能 ADC、引脚复用错误检查接线,先执行 adc_init 和 adc_gpio_init
读数乱跳悬空、高阻源、电源纹波、线缆感应加跟随器、降低采样率、改善供电、加滤波
多通道数据串位通道切换建立时间不足丢弃前几个样本,降低采样率
温度通道读数异常未使能温度传感器、直接套用室温先 adc_set_temp_sensor_enabled(true),再按芯片温度公式换算
FIFO overflow 置位DMA 搬运速度跟不上采样调大 DREQ 阈值,或降低采样率
DMA 一直不触发fifo_setup 里 dreq_en 没开、通道配置错误检查 adc_fifo_setup 参数,确认 dreq 设置
GPIO29 采样异常板载资源占用该引脚(如 Pico W)换 GPIO26-28,查阅官方原理图

写到这里,我个人在实际操作中最大的体会是:用 Pico ADC 之前,先花十分钟想清楚两件事——信号源阻抗多大,参考电压干不干净。这两个问题想明白了,后面的读数和滤波都会轻松很多。

最后再分享一个小技巧。调试任何 ADC 问题,第一步永远是拿万用表直接量你要采的节点,确认硬件电压确实是你预期的数值。这一步看似多余,但在排查中能帮你排除一半以上的低级错误,省下大量时间。Pico 的 ADC 是一颗“够用但别指望发高精度财”的外设,做电池电压检测、温度监控、按键电平判断、慢速传感器采集都没问题;要真的做高精度仪表,还是老老实实上一颗外部 ADC,比在 MCU 内部和外设搏斗舒心得多。

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

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

立即咨询