头一回用树莓派 Pico 的 ADC,我做的是一个电位器音量旋钮:电位器一转,PWM 占空比跟着变。看起来是嵌入式入门里最简单不过的玩法,结果读数在中间段跳得厉害,还带着明显的非线性。追了一下午,发现根子全在 RP2040 这颗芯片的 ADC 底层设计上——参考电压直接和电源共用、逐次逼近型的采样电容充电机制、还有那个 9.9 位小数分频寄存器。如果你也遇到过"ADC 数值不准、乱跳、多通道切换后第一笔数据是脏的"这类问题,这篇文章就是想帮你把从硅片到寄存器的整条链路看明白。
我不想只贴 API 调用代码,那东西 SDK 文档里都有。这篇会把 RP2040 的 ADC 分成五块来讲:硬件架构、寄存器字段、三种工作模式、实战中踩过的坑,最后再给一份不经过 SDK 的手写寄存器读取示例。适合两类人:一是刚上手 Pico、想搞清楚 ADC 内部到底在干什么的初学者;二是已经在用 ADC 但被精度、噪声、采样率搞到头大的进阶玩家。
1. 先看芯片里那坨硅:SAR 架构决定了 Pico ADC 的性格
1.1 逐次逼近型 ADC 的工作原理,用一个猜数字游戏就能讲清楚
RP2040 的 ADC 是经典的 SAR 架构,也就是 Successive Approximation Register,逐次逼近。很多人只背结论"12 位分辨率、500kSPS 采样率",却不理解为什么是这个指标、为什么会有一条条看不见的坑。
SAR ADC 的工作过程可以拆成两个阶段:采样和逼近。
采样阶段,芯片内部会合上一个模拟开关,让输入引脚上的电压对内部的采样电容充电。这个电容就是整个转换的基础,它的电压必须和输入电压精确一致,后面的比较才有意义。
逼近阶段就更有意思了。SAR 转换器里有一个内置 DAC 和一个比较器,负责玩猜数字游戏。12 位分辨率意味着它要把输入电压猜成一个 0 到 4095 之间的数字,猜测方式是二分法:
- 先把最高位置 1,也就是猜 2048。如果输入电压高于 DAC 输出的 2048 对应的电压值,这一位保留,否则清零。
- 然后移到下一位,再猜一次。
- 如此反复 12 次,每一次都根据比较器的结果锁定一位,最后得到完整的 12 位二进制结果。
这个"猜数"的过程非常像你玩那种"我心里想了一个 1 到 4096 之间的数,你猜,我告诉你大了还是小了"的游戏。每次都砍掉一半的区间,12 次就能锁定唯一答案。这就是为什么 12 位分辨率对应 12 次比较,也是为什么 RP2040 一次完整转换需要固定数量的 ADC 时钟周期——数据手册里给的是每个样本 96 个 ADC 时钟周期。注意,这个数字是固定的,你不能像操作某些 STM32 那样单独把采样保持时间拉长,只能整体改变 ADC 的时钟频率或分频系数,这一点后面细说。
既然采样电容要充电到和输入源电压一致,就引申出一个极其重要的参数:源阻抗。如果输入信号源的内阻太高,在采样开关闭合的那短短一瞬间,采样电容会"吸"走一部分电荷,导致还没充到目标电压开关就断开了,读到的值自然偏低。这种误差不是线性的,越靠近中间档位表现越像"抛物线",因为它取决于 RC 时间常数和采样时间的比值。这是后文避坑部分的第一个大主题,先在这里埋个伏笔。
1.2 5 路输入的真相:3 个默认可用,还有 1 路藏着电源监控的秘密
RP2040 官方说法是有 5 个 ADC 输入通道,编号从 0 到 4。但这里面的门道很多,光看数据手册的框图很容易误判。
- 通道 0 到 2,分别对应 GPIO26、GPIO27、GPIO28,这三个是标准意义上的外部模拟输入引脚,也是你拿到 Pico 板子后最常用的。
- 通道 3 对应 GPIO29。这个引脚在裸芯片上确实是一个 ADC 通道,但在树莓派 Pico 成品板上,GPIO29 默认被板卡设计用来做 VSYS 电源电压检测,通过板上电阻分压网络接到了电源路径上。所以你要是想在 GPIO29 上测外部模拟信号,先去看一眼官方原理图,确认分压网络不会跟你的信号打架,否则结果会非常诡异。
- 通道 4 是内部温度传感器,它不经过任何 GPIO,专门用来测量芯片内部的温度。
我对 Pico 板上 VSYS 检测这件事特别有感触。很多初学者在 GPIO29 上莫名其妙读到 1.65V 左右的固定电压,以为是芯片出了问题,其实是板卡默认把 VSYS 分压接上去了。这不是 bug,是设计特性。
1.3 参考电压 ADC_AVDD:为什么 3.3V 既是供电也是尺子
ADC 把模拟电压变成数字,总得有一把刻度尺,这把尺子就是参考电压 VREF。RP2040 的 ADC 参考电压直接取自 ADC_AVDD 引脚,在 Pico 板上 ADC_AVDD 接到 3.3V 电源轨。
这带来一个容易被忽略的事实:参考电压不是实验室里那种精密基准源,而是板级电源。Pico 的 3.3V 电源本身有一定纹波,负载变化时也会轻微波动,这些波动会直接体现在 ADC 转换结果里。理想情况下,16 位 ADC 的 LSB 是 3.3V / 65536,但 RP2040 只有 12 位,单步就是 3.3V / 4096,约等于 0.8mV。
听到 0.8mV,你可能会觉得精度不错。但是请注意,如果 3.3V 电源上叠加了 10mV 的纹波,换算到读数上就是十几个 LSB 的跳动。这就是为什么很多人在 Pico 上做 ADC 采样时,发现数值跳动幅度远超理论量化噪声的原因之一。
另一个容易踩的点:ADC_AVDD 是模拟供电,Pico 板上通常会做一点滤波处理,但毕竟共用 3.3V 轨。如果你同时开着大电流的外设,比如 WiFi、电机驱动、大量 LED,ADC 读数大概率会跟着抖。硬件层面的解决思路是在 ADC_AVDD 和 3.3V 之间加磁珠或 RC 滤波,软件层面就只能靠滤波算法兜底,这部分放到避坑章节展开。
1.4 12 位分辨率到底够不够用
12 位在 MCU 领域算入门水平,不算高。对 Pico 定位的业余项目和原型验证来说,12 位足够处理绝大多数传感器信号,比如电位器、光敏电阻、NTC 热敏电阻、电池电压监测等。但如果你要做精密测量,比如称重传感器的毫伏级信号,12 位 ADC 就明显不够用,需要外接 16 位甚至 24 位的 ADC 芯片,比如 HX711、MCP3561 这一类的。
实际使用中还需要注意一个概念:有效位数 ENOB。12 位是理想分辨率,但由于噪声、非线性、参考电压波动等因素,实际有效位数一定低于 12。所以不要对最后一位的精度抱有不切实际的期待,理解这一点能省下很多无意义的调试时间。
2. 寄存器视角:直接站在 0x4004C000 头上看 ADC 干活
SDK 封装得很好,adc_read() 一行代码就能拿到结果。但很多诡异问题的根源都在寄存器层面,你如果不知道它背后动了哪些位,排查问题时就像闭着眼修水管。RP2040 的 ADC 寄存器基址是 0x4004C000,总共有 10 个 32 位寄存器,下面一个个拆。
2.1 寄存器总览表
| 偏移量 | 寄存器名 | 作用 |
|---|---|---|
| 0x00 | ADC_CS | 控制状态寄存器,使能、启动转换、通道选择、轮询模式都在这里 |
| 0x04 | ADC_RESULT | 转换结果寄存器,读取最近一次转换结果 |
| 0x08 | ADC_FCS | FIFO 控制状态寄存器,管理 FIFO 的开关、阈值、溢出状态 |
| 0x0C | ADC_FIFO | FIFO 数据寄存器,从 FIFO 中取样本就靠读它 |
| 0x10 | ADC_DIV | 时钟分频寄存器,9 位整数 + 9 位小数的定点格式 |
| 0x14 | ADC_INTR | 中断标志寄存器 |
| 0x18 | ADC_INTER | 中断使能寄存器 |
| 0x1C | ADC_INTF | 中断强制寄存器 |
| 0x20 | ADC_INTS | 中断状态寄存器 |
| 0x24 | ADC_PAD | 模拟输入引脚 PAD 控制寄存器 |
这里我先提一个关键点:整个 RP2040 的 ADC 只有这一个模块,5 个通道共用同一个转换核心,不能同时转换多路。所谓多通道同时采样在 RP2040 原生 ADC 上是不存在的,只能分时切换。
2.2 ADC_CS 控制寄存器:所有动作的开关都在这里
ADC_CS 是这个模块最核心的寄存器,关键字段包括:
- EN,使能位。这一位必须置 1,ADC 才会真正开始工作。关掉后 ADC 会进入低功耗状态。
- START_ONCE,单次启动位。写 1 启动一次转换,转换完成后硬件自动清零。你不需要手动清它。
- START_MANY,连续模式启动位。写 1 后 ADC 会持续不断地转换,相当于让它进入自由运行状态。要想停下来,得手动清掉这位。
- READY,就绪标志位。ADC 空闲时是 1,转换进行中会变成 0。单次转换场景下,轮询 READY 位就能判断结果有没有准备好。
- AINSEL,输入通道选择字段。0 到 4 分别对应前面讲的五个通道,写入超出范围的值属于未定义行为。
- ERR 和 ERR_STICKY,错误标志。FIFO 溢出、通道选择异常等情况可能置位,ERR_STICKY 只置不清,需要软件手动清除。
使用 ADC_CS 时要特别注意一个操作习惯:修改 AINSEL 字段时,常见做法是先读出整个寄存器的值,再按位修改需要的 bit,最后写回。直接用等号覆盖整个寄存器,很容易把其他状态位搞乱。SDK 内部也是这么小心翼翼地操作的。
2.3 ADC_DIV 分频寄存器:这是唯一能调节采样时间的旋钮
ADC_DIV 是一个 18 位的定点数,高 9 位是整数部分,低 9 位是小数部分。它的作用不是给 ADC 提供时钟,而是对 ADC 时钟再进行一次分频,用来控制每次转换的周期长度,最终影响的就是 ADC 的采样率。
在 SDK 里,Pico 的 clk_adc 默认配置为 48MHz。在每个样本固定消耗 96 个 ADC 时钟周期的情况下,DIV 为 0 时的最高采样率大约是 500kSPS。这个数值是 RP2040 数据手册上标称的极限值。
如果你希望降低采样率,DIV 的设置公式可以这样理解:
- 实际单次转换占用的 ADC 时钟周期数 = 96 × (1 + DIV)。
- 比如想得到 100kSPS 的采样率,48MHz 除以 100k 等于 480 个周期,那么 480 / 96 - 1 = 4,所以 DIV 写 4 即可。
DIV 的实用性不止于此。我前面说到,如果信号源阻抗高,采样电容来不及充电,读数会偏低。提高采样时间是最直接的补偿手段。在 RP2040 上,你没法单独延长采样保持阶段,但可以通过增大 DIV 降低整个转换速度,让采样电容有更充裕的时间充电。这是应对高阻抗信号源最廉价、最有效的方法。
2.4 ADC_RESULT 与 FIFO:单次读取和连续采样的取数差异
ADC_RESULT 是 16 位只读寄存器,有效数据在低 12 位。当 FIFO 未启用时,读它就能拿到最近一次转换结果。
FIFO 则是另一套机制。看 ADC_FCS 寄存器的关键字段:
- EN,FIFO 使能。只有打开 FIFO,连续采样才有地方暂存数据。
- DREQ_EN,DMA 请求使能。配合 DMA 搬运大批数据时,这一位必须打开。
- OVER 和 UNDER,溢出和下溢标志。FIFO 满了你还在往里面塞数据,OVER 置位;FIFO 空了你还去读,UNDER 置位。写 1 可以清除这两个标志。
- LEVEL,FIFO 当前存储的样本数量。DMA 或中断判断数据量就靠它。
- THRESH,FIFO 阈值。当 LEVEL 达到 THRESH 时,会触发中断或 DMA 请求。RP2040 的 ADC FIFO 深度只有 4,所以这个阈值可以配置的范围很小,但这个机制非常实用。
FIFO 最大的价值是解耦:ADC 转换不需要 CPU 实时盯着,结果先进 FIFO 排队,CPU 或者 DMA 忙完了再来取。这在高频采样场景下几乎必备。
2.5 中断寄存器组:什么时候才需要用到中断
ADC_INTR、ADC_INTER、ADC_INTF、ADC_INTS 这四个寄存器组成中断链路。最简单的工作方式:当 FIFO 里数据量到达阈值时,INTR 相应位置位,如果 INTE 使能了,就会产生中断请求,CPU 可以在中断里读走数据。
对多数低速应用来说,轮询就够用。但如果你的程序在持续处理其他任务,比如跑显示刷新、通信协议,不能一直死等在 ADC 上,中断或 DMA 几乎是唯一选择。我的建议是:先用轮询把逻辑调通,性能不够再上中断或 DMA。不要一开始就引入异步机制,否则调试难度会叠加。
3. 三种采样姿势:单次、自由运行、FIFO + DMA 怎么选
寄存器层面讲完了,接下来看实际编程时的三种典型采样方式。这一节我会把每种方式的适用场景、代码要点和代价都给你过一遍。
3.1 单次采样:最简单也最容易被忽略启动开销
单次采样的流程是:选择通道、写 START_ONCE 位、等待 READY 位变 1、读取 ADC_RESULT。
#include "hardware/adc.h" adc_init(); adc_gpio_init(26); adc_select_input(0); uint16_t read_once() { adc_run(false); adc_select_input(0); adc_run(true); adc_fifo_setup(true, false, 1, false, false); adc_fifo_drain(); return adc_read(); }上面这段里 adc_fifo_setup 和 adc_fifo_drain 是我实测后加的。为什么要 drain?因为如果之前 FIFO 里积压了旧数据,下一次读取会拿到脏数据。就算你只是单次采样,我也建议养成在取数前清空 FIFO 的习惯。
单次采样适合低频业务,比如每秒读几次电位器、温度值,完全够用。注意,单次采样实际上也会消耗几百个 ADC 时钟周期的启动和转换时间,如果在一个循环里高频调用,总耗时可能比你预期的高,但这也意味着它的时序是可控和可预测的。
3.2 自由运行模式:连续转换、及时取数,小心 FIFO 溢出
自由运行模式对应 START_MANY 置 1。开启后 ADC 会一直转换,不管你有没有及时取走结果。如果 CPU 来不及时,FIFO 会被填满,之后新的样本就会丢弃,OVER 标志置位。
adc_init(); adc_gpio_init(26); adc_select_input(0); adc_fifo_setup(true, false, 1, false, false); adc_run(true); while (1) { if (!adc_fifo_is_empty()) { uint16_t value = adc_fifo_get(); // 处理 value } }自由运行模式的致命弱点是 CPU 必须及时取数,否则丢失样本。你可能会觉得 FIFO 深度有 4,够撑一阵子,但关键时刻根本不够。所以在使用自由运行模式时,你要对 CPU 的主循环耗时心里有数,别在主循环里做阻塞操作,比如 printf 串口打印,否则大概率丢数据。
3.3 FIFO + DMA:把数据搬运交给硬件,让 CPU 专心算
这是最能发挥 Pico 性能的组合。ADC 不停转换,结果通过 FIFO 和 DREQ 信号告诉 DMA 控制器,DMA 把数据搬运到内存缓冲区,全程不占 CPU。
#include "hardware/dma.h" int dma_channel; uint16_t adc_buffer[1024]; void adc_dma_init() { adc_init(); adc_gpio_init(26); adc_select_input(0); adc_fifo_setup(true, true, 1, false, false); adc_set_round_robin(1 << 0); dma_channel = dma_claim_unused_channel(true); dma_channel_config cfg = dma_channel_get_default_config(dma_channel); 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, DREQ_ADC); dma_channel_configure(dma_channel, &cfg, adc_buffer, &adc_hw->fifo, 1024, true); adc_run(true); }这段代码有几个关键点:
- channel_config_set_read_increment 必须设 false,因为 ADC FIFO 是固定地址,读地址不递增。
- channel_config_set_dreq 填 DREQ_ADC,表示 DMA 传输由 ADC FIFO 的请求信号驱动。
- dma_channel_configure 的最后一个参数 true 表示立刻启动 DMA 传输。
DMA 完成中断可以在 dma_channel_set_irq0_enabled 后触发,在中断处理函数里处理这 1024 个样本。这种方式适合需要持续采集的场景,比如音频采样、电机电流监测、振动分析。
3.4 三种模式的选型对比
| 采样模式 | 典型场景 | CPU 占用 | 复杂度 | 丢数据风险 |
|---|---|---|---|---|
| 单次采样 | 电位器、温度、低频传感器 | 低 | 低 | 低 |
| 自由运行 | 中速连续采样 | 高 | 中 | 高,取决于读取速度 |
| FIFO + DMA | 高速批量采样、音频 | 极低 | 高 | 低 |
我的建议很直接:低于 1kHz 的采样频率用单次轮询;1kHz 左右、主循环不忙可以用自由运行;超过 1kHz 或者你的 CPU 还有大量其他任务,直接用 DMA。别在自由运行模式上死扛,扛到最后一定会在数据完整性上吃亏。
4. 应用层踩坑实录:从读数偏低到温度乱飘
这一节是我最想写的部分。下面每一条都是我在实际项目里踩过、排查过、修复过的,不是从手册里抄来的理论。
4.1 坑一:采样值整体偏低且非线性,问题出在源阻抗
现象很典型:用高阻电位器或者 NTC 热敏电阻分压电路接到 ADC 引脚,读数在量程中间段明显偏低,而且曲线不是一条直线。
根因就是第一节讲的采样电容充电问题。高阻抗信号源无法在采样时间内给电容充满电,相当于每次测量都被"打折扣"。阻值越高的信号源,折扣越严重。
排查链路可以这样走:
- 拔掉外部信号,把 ADC 引脚直接接到 3.3V 或 GND,确认读数接近满量程或零。如果正常,说明 ADC 本身没问题。
- 接上信号,把采样率降低到 10kSPS 以下,再看读数是否回归线性。如果改善明显,基本锁定是源阻抗问题。
- 用万用表测信号源的戴维南等效阻抗,估算 RC 时间常数。
解决方案有几个层次:
- 最简单的是增大 ADC_DIV,降低采样速率。Pico SDK 里 adc_set_clk_div(10) 这种写法就能让采样周期拉长 10 倍,效果立竿见影。
- 如果源阻抗非常高,比如几兆欧,单靠分频已经不够。在 ADC 引脚和地之间并联一个 0.1uF 电容,作为一个"电荷蓄水池",采样瞬间电容为采样电容提供电荷,之后信号源慢慢给电容充电。这个办法实测效果极好。
- 终极方案是加一个运放跟随器,输入阻抗极高,输出阻抗极低,任何规模的高阻抗信号都能被它"驯服"。
我建议优先尝试增大 DIV,成本最低。并联电容则要注意,它会降低信号带宽,如果采集的是快速变化的信号就不适合。
4.2 坑二:数据跳动剧烈,硬件噪声和软件滤波必须双管齐下
现象:ADC 读数在某个值附近跳来跳去,幅度有几个甚至十几个 LSB。很多人第一反应是接触不良或 ADC 坏了,其实大概率是参考电源噪声和 PCB 布局问题。
排查时先做个对照实验:用一节全新的碱性电池,经电阻分压后接到 ADC,看看读数的稳定性。如果电池供电时很稳,而用 Pico 的 3.3V 时乱跳,说明噪声来自电源而不是 ADC 本身。
Pico 板上的 3.3V 电源轨不是精密基准源。WiFi 活动、屏幕刷新、LED 大电流切换,都会在电源上叠加纹波。另外,Pico 的 ADC_AVDD 与数字电源域在芯片内部共享参考路径,数字开关噪声会耦合进来。
我的处理套路:
- 硬件上,在 ADC_AVDD 和 3.3V 之间加一个 10 欧姆电阻和 10uF 电容组成低通滤波,实测噪声能压下去一大截。注意 ADC_AVDD 已经由板载电路连接,你要做的是在尽量靠近引脚的位置加滤波。
- 软件上,用滑动平均、中值滤波或一阶低通滤波。下面是实际在用的五点滑动平均函数:
uint16_t moving_average(uint16_t *buf, uint16_t new_sample) { static uint8_t idx; static uint32_t sum; sum -= buf[idx]; buf[idx] = new_sample; sum += new_sample; idx = (idx + 1) % 5; return sum / 5; }- 一阶低通滤波更适合周期性数据,平滑度更好,但会有滞后:
float lowpass(float alpha, float prev, float sample) { return prev + alpha * (sample - prev); }alpha 越小越平滑,但响应越慢。我通常从 0.2 开始调。
4.3 坑三:多通道切换后第一笔数据总是不对
现象:用 ADC 同时测两路电压,切换通道后读到的第一个数据总是不对,丢也不是,错也不是。
根因是模拟通道切换后,采样电容里还残留着上一个通道的电荷,需要一定时间才能完全过渡到新通道的电压。这不是 RP2040 的 bug,是几乎所有单片机的 ADC 都存在的问题,只是很多数据手册不会特别强调。
解决办法有几种:
- 切换通道后,先做一次或两次空读,丢弃结果,第三次的读数才用。
- 在 ADC 输入引脚并联一个小电容到地,也能加速电荷稳定。
- 如果用的是轮询模式连续采集多通道,可以利用这个机制:每次切换后插入一个 dummy 转换。
SDK 的 adc_select_input() 只负责改 AINSEL,并没有帮你清理电容上的残余电荷。所以这个坑一定要自己扛。
4.4 坑四:温度传感器数值离谱,切记它没有出厂校准
RP2040 的内部温度传感器通道,也就是 ADC4,确实能读温度,但相位差和精度远不如外置传感器。官方给出的温压关系公式是:
- 27 度时的典型输出电压约为 0.706V。
- 斜率约为 -1.721mV/度,也就是温度升高,电压下降。
- 计算公式:T = 27 - (V - 0.706) / 0.001721。
用 C 语言实现就是:
float adc_to_temp(uint16_t raw) { float v = raw * 3.3f / 4096.0f; return 27.0f - (v - 0.706f) / 0.001721f; }关键问题是:这个 0.706V 和 -1.721mV/度都是典型值,不是每一颗芯片都精确如此。RP2040 出厂前没有对温度传感器做逐颗校准,所以直接用公式算出来的温度偏差可能让你怀疑人生,差距几度甚至十几度都很正常。
如果要用它做精确测温,你需要自己做两点校准:测一次已知低温,再测一次已知高温,算出这颗芯片真实的斜率和偏移,替换公式里的参数。否则它只能用来判断"大概烫不烫",不能用来做仪表级测量。
4.5 坑五:模拟输入过压和负压是芯片杀手
Pico 的 ADC 输入电压范围是 0 到 3.3V。超出这个范围,轻则读数异常,重则损坏 IO 引脚甚至整颗芯片。内部 ESD 保护二极管虽然能挡住一部分过压电流,但它不是为你设计的正常通路,长期过压一定会缩短芯片寿命。
实测中常见的过压场景:
- 用锂电池供电时,电池满电电压 4.2V,直接接 ADC 引脚必挂。
- 测量电阻分压后的电压时,分压网络设计失误导致输出超过 3.3V。
- 传感器输出是 0 到 5V 或者更高,直接接入。
正确的信号调理思路是:
- 电阻分压:把过高的电压先降到 3.3V 以下。计算时留出裕量,比如最大测量值是 5V,可以按 3.0V 来设计分压比,给参考电压波动留一点空间。
- 并联钳位二极管:在 ADC 引脚对地和对 3.3V 之间加肖特基二极管,比如 BAT54S,电压超限时自动导通,把多余电流引走。
- 串联限流电阻:在信号源和 ADC 引脚之间串一个 1k 到 10k 的电阻,如果真的出现过压,这个电阻能限制流过保护二极管的电流。但要记住,串电阻也会增大源阻抗,所以需要和采样时间做权衡。
4.6 坑六:低功耗场景别忘了把 ADC 关掉
如果你用 Pico 做电池供电设备,休眠前的最后一步往往是 ADC 模块还开着。ADC 使能状态下的静态电流比你想象的要大,长期待机场景里这点电流就是致命伤。
操作很简单:
adc_run(false); adc_hw->cs &= ~ADC_CS_EN_BITS;另一个细节是 ADC 时钟。如果 clk_adc 还在跑,即使 ADC 模块关闭,时钟树上的功耗依然存在。在深度休眠场景里,需要把 clk_adc 也停掉,让时钟管理器把对应的时钟源关闭。这一块在不同板子和 SDK 版本里行为不太一样,建议用电流表实测去验证。
5. 绕开 SDK 手写寄存器读取:顺便解决温度校准
SDK 很方便,但有时候你需要在没有 SDK 的裸机环境里干活,比如自己写 RTOS 移植、或者纯粹想深挖寄存器。这一节给你一份不用 SDK 的寄存器级读取代码,顺便把温度通道的完整处理方式讲清楚。
5.1 手写寄存器版单次读取
下面的代码直接操作 RP2040 的 ADC 硬件结构体。头文件来自 pico-sdk 的 hardware/structs/adc.h,它本质上就是为寄存器访问提供的 C 语言映射,里面每个字段都对应数据手册上的地址和位段。
#include "hardware/regs/adc.h" #include "hardware/structs/adc.h" #include "pico/stdlib.h" uint16_t raw_adc_read(uint input) { // 如果 ADC 还没使能,先打开使能位 adc_hw->cs |= ADC_CS_EN_BITS; // 清掉旧的通道选择,再写入新的通道号 adc_hw->cs = (adc_hw->cs & ~ADC_CS_AINSEL_BITS) | ((input << ADC_CS_AINSEL_LSB) & ADC_CS_AINSEL_BITS); // 触发一次转换 adc_hw->cs |= ADC_CS_START_ONCE_BITS; // 等待 READY 位重新置 1,表示转换已完成 while (!(adc_hw->cs & ADC_CS_READY_BITS)) { tight_loop_contents(); } // 从 RESULT 寄存器取低 12 位 return (uint16_t)(adc_hw->result & ADC_RESULT_RESULT_BITS); }这段流程有几个细节值得说明:
- 读取 RESULT 前一定要确保 READY 已经置位,否则读到的可能是上一轮的旧数据。
- START_ONCE 在转换完成后自动清零,不需要手动恢复。
- AINSEL 的写入用了"先清后写"的方式,避免残留位干扰。
- 这段代码假设 ADC 时钟已经被正确初始化。如果在裸机环境下没有初始化时钟,ADC 不会工作。SDK 的默认初始化会把 clk_adc 配置到 48MHz,裸机环境下你需要自己完成这一步。
如果你用的是 MicroPython,底层寄存器已经被固件封装好了,你看不到 FIFO 和 DMA。MicroPython 的 machine.ADC 适合快速验证,C SDK 适合做深度控制,两者各有取舍。
5.2 温度传感器的完整校准思路
前面说了公式是典型值,实际芯片有偏差。校准的思路其实很简单,就是两点法。准备一个室温的参考温度计和一个冰水混合物或恒温箱,用 Pico 分别读 ADC4 的输出电压,然后反推真实的 V0 和 K。
设 ADC4 在已知温度 T1 和 T2 下读到的电压分别是 V1 和 V2,那么:
- 斜率 K = (V2 - V1) / (T2 - T1)
- 偏移 V0 = V1 - K × T1
- 温度公式变为:T = (V - V0) / K
实测下来,校准后的精度能提升很多。如果你只是想要一个"大概趋势",直接用官方公式就行,省事。
5.3 一个实用的电池电压监测方案
把前面所有知识串起来,给一个可抄作业的电池电压监测方案。
以 4.2V 锂电池为例,用 100k 和 100k 两个电阻分压,中点电压最高 2.1V,处于安全量程。在 ADC 引脚和地之间并联一个 0.1uF 电容做滤波。
代码层面,单次采样加滑动平均就够了:
uint16_t battery_raw_read(void) { adc_select_input(3); // GPIO29 上的 VSYS 路 adc_read(); // 第一次空读,让采样电容稳定 return adc_read(); }注意我用了 GPIO29 对应通道 3 做电池电压检测,这是 Pico 板卡设计好的用途。但如果你用的不是 Pico 而是自制的 RP2040 板,GPIO29 就是一个普通模拟输入,需要按你自己的原理图来。
电池电压通过分压网络后,实际电压等于 ADC 读数乘以分压系数。如果分压是 1/2,那真实电压 = ADC 电压 × 2。在程序里把这些换算关系集中到一个函数里管理,后续改分压电阻或者换电池类型时只改一处。
最后再分享一个我自己的习惯:所有 ADC 相关的换算系数、分压比、参考电压值,我一律写在注释里,并且把每次测量的时间戳也打出来。这样一旦后面写滤波算法或者做故障分析,每个数据点都能回溯到当时的硬件状态。多通道切换和滤波参数调试这事,最怕的就是日志里只有一串裸数值,没有任何上下文。