树莓派Pico ADC实战:从硬件原理到MicroPython ISR避坑

发布时间:2026/9/8 20:55:41
树莓派Pico ADC实战:从硬件原理到MicroPython ISR避坑 树莓派 Pico 的 ADC 经常被当成一个read_u16()就能完事的简单外设但实际调试过的人都知道从硬件参数、API 细节到定时采集再到中断处理每一步都可能埋雷。这篇文章是我用 Pico 做电池电压监测和 NTC 温度采集的真实经验整理目标是从零开始把 ADC 用明白顺便把 MicroPython 里 ISR 那堆让人抓狂的坑提前拆掉。无论你是刚拿到 Pico 想测个电位器电压还是准备做定时温度采集的小项目这篇都值得完整读一遍第四章是我踩过最多坑的地方建议重点看。1. Pico ADC 硬件基础与选型思路1.1 Pico 的 ADC 到底是什么水平树莓派 Pico 使用的 RP2040 芯片内部集成了一个 12 位 SAR ADC工作范围是 0 到 3.3V。外部引出了 3 个模拟输入通道分别对应 GPIO26、GPIO27、GPIO28在 MicroPython 里叫 ADC0、ADC1、ADC2。另外还有一个内部温度传感器通道对应 ADC4。这个传感器的用途比较局限后面我会专门讲。和很多 STM32 系列不同RP2040 没有独立的 REF 参考电压引脚ADC 参考电压直接取自芯片供电 3.3V。这就带来一个现实问题如果你用 USB 给 Pico 供电USB 的 5V 经过板上 LDO 变成 3.3V整体精度还可以但如果你用电池、DCDC 或者外部稳压模块供电3.3V 本身会波动ADC 的测量结果也会跟着飘。很多新手发现“为什么同一个电压每次读数不一样”第一个怀疑 ADC 坏掉了实际上往往是参考电压不稳。官方规格里RP2040 ADC 的最高转换速率是 500 kSPS听起来还挺快。但注意这是硬件极限在 MicroPython 里用read_u16()轮询时解释器开销非常大实际能达到每秒上万次就已经不错了如果再带上浮点换算和滤波吞吐量还会进一步下降。所以我在实际项目里很少指望这块内置 ADC 做高速连续采样它的定位更多是“低频低成本的电压感知”。1.2 为什么建议了解一点 SAR ADC 采样原理12 位 SAR ADC 全称是逐次逼近型 ADC工作原理可以类比成一杆天平称重。想象面前有一堆砝码重量分别是最大值的一半、四分之一、八分之一……依次往下。称重时先放最大的砝码如果太重就拿掉如果太轻就保留再试下一个更小的砝码。通过 12 次比较就能把输入电压逼近到一个由 12 个二进制位表达的数字。这种结构的好处是成本低、转换速度快缺点是它对输入信号的建立时间要求很高。具体到使用上ADC 内部有个采样保持电容测量前电容要先充电到输入电压。如果输入源阻抗太高电容充电时间就不够导致采样电压偏低。在 Pico 的 GPIO 上直接接一个 100kΩ 电位器你会发现读数往往比理论值偏小这就是源阻抗过高造成的采样误差。解决办法是降低外部电阻阻值或者在信号进入 ADC 前加一级运放电压跟随器。另外SAR ADC 在开始转换后输入信号如果还在快速变化也可能导致转换结果不确定。所以高频信号直接进内置 ADC 通常效果很差常见的做法是先用 RC 低通滤波或者运放做采样保持。理解这一点对后面排查读数跳动、偏小这类问题非常有帮助。1.3 什么时候需要外置 ADCRP2040 内置 ADC 虽然有 12 位分辨率但实际有效精度受参考电压和噪声影响能做到 10 位左右就很不错了。对于测电池电压、温度、电位器这类要求不高的模拟量完全够用。但遇到称重传感器、微电流检测、应变片桥式电路这类信号输出往往只有几毫伏内置 ADC 的分辨率和噪声水平根本不够看这时候就需要外置高精度 ADC。市面上常见的 24 位 ADC 有 HX711、ADS1232、MCP3561 等它们的定位差异很大。我在项目里用过 HX711 做电子秤它专门为称重传感器设计内部带增益放大可以直接接差分信号ADS1232 同样适合桥式传感器抗干扰不错而 MCP3561 是通用型高精度 SPI ADC通道多、采样率可选范围大适合多点测量。如果只是测个 NTC 温度完全没必要上这玩意内置 ADC 加一点滤波就够用了。选型时先问自己三个问题要多少有效分辨率信号是单端还是差分更新速度要多快答案明确后再去看芯片比盲目追求 24 位靠谱得多。2. machine.ADC API 详解与使用要点2.1 基础读取从 ADC(pin) 到 read_u16()MicroPython 里读取 Pico ADC 非常简单三行代码就能看到一个原始值import machine adc machine.ADC(26) # GPIO26 对应 ADC0 val adc.read_u16() # 返回 0~65535 的整数 print(val)需要说明的是read_u16()返回的并不是 16 位精度的结果。RP2040 底层是 12 位 ADCMicroPython 把 12 位结果左移 4 位填充到 16 位范围所以返回值的低 4 位基本是零。转换回实际电压可以用voltage val / 65535 * 3.3 print(f{voltage:.3f} V)这里有一个容易踩的坑3.3 只是一个理论参考电压实际 Pico 板上的3V3引脚可能输出 3.28V也可能输出 3.34V不同板子之间差异不小。如果你对电压测量精度有要求不要直接写死 3.3应该用万用表实测一下3V3引脚电压再带入公式计算。很多时候“读数偏了 0.05V”的根源就在这里。另外machine.ADC()既可以传 GPIO 编号也可以传引脚对象但通道对应关系要记牢GPIO26 是 ADC0GPIO27 是 ADC1GPIO28 是 ADC2。GPIO29 在 Pico 板上有特殊用途标准封装里不建议当普通 ADC 用。如果你在某个坑爹的教程里看到 ADC3 接 GPIO29 的写法建议直接绕开除非你在做定制板。2.2 内部温度传感器读取与换算RP2040 内置了一个温度传感器在 MicroPython 里用ADC(4)访问。读取方法和其他通道一样但换算成温度需要按官方公式走import machine adc_temp machine.ADC(4) reading adc_temp.read_u16() voltage reading / 65535 * 3.3 temp_c 27 - (voltage - 0.706) / 0.001721 print(fCPU温度: {temp_c:.1f} C)这个公式的含义是在 27 摄氏度时传感器输出电压约为 0.706V温度每升高 1 摄氏度电压下降约 1.721mV。不同批次芯片会有个体差异所以计算出来的温度只能作为芯片温度的大致参考。我实测下来用它看芯片发热趋势还行但想拿来测环境温度或者精确控温精度远远不够。有一个细节需要注意内部温度传感器的输出阻抗很高如果连续多次读取结果会很跳。因为 CPU 运行负载也会影响芯片温度同一个程序在空转和跑浮点运算时测到的温度可能差好几度。如果你的项目里需要“相对稳定的芯片温度监控”建议在读取后加滑动平均而不是直接拿单次读数做判断。2.3 外部 ADC 三点校准到底校准什么很多做 ADC 采样的人会搜到“三点校准”这个词但未必清楚它具体校的是什么。简单说内置 ADC 存在两个主要误差一是参考电压不准二是 ADC 的增益和失调误差。两点校准能修正一条直线三点校准则可以修正传感器或者信号链中非线性的部分。以我测电池电压为例的硬件是一组分压电阻把 12V 电池降到 3V 以内再进 Pico ADC。分压电阻有 1% 或 5% 的误差加上参考电压偏差最终测量结果可能偏 2% 以上。我做了三点校准用可调电源分别输出 3V、6V、9V在每个点记录 ADC 计算值和万用表实测值得到三组计算值真实值然后分段线性插值。这样校准后整个量程内的误差能控制在 0.5% 以内。在 MicroPython 里实现分段线性插值并不复杂def cal_voltage(x): # 校准点 (adc_calc, real_voltage) cal_points [(2.95, 3.00), (6.05, 6.00), (9.02, 9.00)] if x cal_points[0][0]: return cal_points[0][1] if x cal_points[-1][0]: return cal_points[-1][1] for i in range(len(cal_points) - 1): x0, y0 cal_points[i] x1, y1 cal_points[i 1] if x0 x x1: return y0 (y1 - y0) * (x - x0) / (x1 - x0)这个思路同样适用于 NTC 温度测量甚至可以扩展成查表法。校准的本质是“建立 ADC 值和真实物理量之间的映射”不用迷信三点校准这个词两点对线性系统够用三点能覆盖轻微非线性但前提是校准用的源要可靠。3. 定时温度采集实战从轮询到定时器3.1 硬件连接NTC 热敏电阻与分压电路NTC 热敏电阻是负温度系数电阻温度升高阻值下降。常用接法是 NTC 和固定电阻组成分压ADC 采集中间点电压。假设 NTC 接 3.3V固定电阻 R 接到 GND中间点接 ADC0那么输出电压满足Vout 3.3 * R / (R_ntc R)反解出 NTC 阻值R_ntc R * (3.3 / Vout - 1)得到阻值后用 B 值公式换算温度T 1 / (1 / T0 (1 / B) * ln(R_ntc / R0)) - 273.15其中 R0 是 25 摄氏度时的标称阻值T0 是 298.15KB 是热敏电阻的 B 值通常在 3435 到 4300 之间型号不同差异很大所以一定以自己手里那颗电阻的实际参数为准。很多新手套公式算出来温度偏十几度大概率就是 B 值填错了。固定电阻 R 的选择也有讲究如果 R 和 NTC 阻值差太远分压点会偏向电源轨ADC 分辨率的利用率变低。假设 25 摄氏度时 NTC 标称 10kΩ固定电阻也选 10kΩ这样常温下分压点接近 1.65V处于 ADC 量程中间效果最好。同时需要注意流过 NTC 的电流会造成自热效应电流越大温度越高测量误差越明显。用 10kΩ 固定电阻接 3.3V 时通过 NTC 的电流通常在几十微安到几百微安之间自热影响还比较小。如果你把固定电阻换成 1kΩ自热就会明显增大。3.2 用 Timer 实现定时采集的代码与问题先看一个最常见的定时采集写法import machine import math adc machine.ADC(26) R_FIXED 10000 R0 10000 B 3950 T0 298.15 def read_temp_cb(t): val adc.read_u16() vout val / 65535 * 3.3 rntc R_FIXED * (3.3 / vout - 1) temp 1 / (1 / T0 (1 / B) * math.log(rntc / R0)) - 273.15 print({:.2f} C.format(temp)) timer machine.Timer() timer.init(period1000, modemachine.Timer.PERIODIC, callbackread_temp_cb)这段代码在短时间测试里可能一切正常但它犯了几个很典型的“ISR 大忌”在定时器回调里做了 ADC 读取、浮点运算、math.log和对数运算还用了print。MicroPython 的定时器回调运行在中断上下文在这个上下文里进行堆分配、大量浮点运算或者外设访问轻则造成计时漂移重则触发MemoryError或者系统直接复位。我自己遇到最典型的现象是程序跑几十分钟后突然卡死重启之后又正常但不知道什么时候又死一次。用串口监视器看卡死前往往有一行MemoryError: memory allocation failed, allocating 8 bytes。如果你也在定时器回调里做复杂操作十有八九就是这个原因。正确做法是把回调做得尽可能短。可以先让回调只置一个标志位主循环再完成读取、计算和打印。后面第四章我会专门展开这部分这里先给一个改良版import machine import math adc machine.ADC(26) R_FIXED 10000 R0 10000 B 3950 T0 298.15 sample_ready False def timer_cb(t): global sample_ready sample_ready True timer machine.Timer() timer.init(period1000, modemachine.Timer.PERIODIC, callbacktimer_cb) while True: if sample_ready: sample_ready False val adc.read_u16() vout val / 65535 * 3.3 rntc R_FIXED * (3.3 / vout - 1) temp 1 / (1 / T0 (1 / B) * math.log(rntc / R0)) - 273.15 print({:.2f} C.format(temp))这样的结构即使回调和主循环之间偶尔有一点延迟系统整体稳定性也远好于在回调里做复杂操作。3.3 数据滤波滑动平均、中值滤波、一阶低通ADC 读数跳动是常态尤其是接 NTC 这类高阻传感器时电磁干扰很容易引入噪声。滤波手段主要分硬件和软件两类先说软件。滑动平均是最容易上手的滤波维护一个固定长度的队列每次取平均值from collections import deque WINDOW 16 buf deque([0] * WINDOW, maxlenWINDOW) def moving_average(x): buf.append(x) return sum(buf) / len(buf)窗口越大越平滑但响应越慢。如果你需要快速响应温度变化窗口 8 到 16 比较合适如果只是监控缓慢变化的温度32 甚至 64 都行。中值滤波对脉冲噪声更有效比如静电放电导致的尖刺。做法是连续采样 N 次排序后取中间值。MicroPython 里直接sorted()会分配列表数据量小倒还好但如果放在热路径上要小心。C 语言版则可以写成冒泡法取中间值这也是很多嵌入式面试题喜欢考的一段代码核心思路是找到 11 个采样值中的第 6 大值不需要完整排序用“冒泡 N/2 次”的方式就够了。一阶低通滤波适合需要平滑但不想维护大数组的场景alpha 0.3 filtered 0 def lowpass(x): global filtered filtered alpha * x (1 - alpha) * filtered return filteredalpha越大越相信新数据越小越平滑。我用得比较多的是滑动平均加一阶低通的组合先用小窗口滑动平均去毛刺再套一层低通抑制高频噪声。实际效果比单用任何一种都稳而且计算开销不大。4. ISR 避坑指南中断回调里不能做什么4.1 经典错误在中断里做 ADC 读取和计算MicroPython 的定时器回调本质上是中断处理的一部分官方文档和社区一直在强调中断回调应该尽可能短小精悍不要在回调里做耗时操作。但很多人不理解“耗时”到底指什么直到程序跑崩了才回头查。我拆解一下问题。adc.read_u16()虽然看起来只是一次寄存器读取但在 MicroPython 内部会经过方法调用、运行时检查、对象模型转换等流程必然涉及堆管理。math.log()更是浮点库的重型操作执行时间可能达到几十微秒甚至上百微秒。再加上print()需要格式化字符串和输出耗时会进一步拉长。这些操作叠加在一起定时器中断服务的时间可能比主循环的运行时间还长系统实时性自然变得一塌糊涂。不仅如此MicroPython 的中断处理上下文里垃圾回收器可能处于不一致状态如果你在中断中分配了对象或者触发了内存回收轻则造成内存碎片重则直接抛异常。官方建议是ISR 里只做最基本的变量赋值不要分配 list、不要调用会分配对象的函数、不要做浮点运算、不要用time.sleep()。4.2 正确姿势ISR 只置标志位最稳妥的架构是中断回调只负责记录“事件发生了”具体的数据采集和业务处理全部放在主循环里。这样中断服务时间会缩短到微秒级别系统稳定性大幅提升。用标志位需要注意一个问题如果主循环忙别的事情标志位的响应会有延迟。对于温度采集这种低频任务1ms 的延迟完全无所谓所以根本不用担心。如果你要做高频采样比如 10kHz那就不适合定时器回调加轮询的方式应该用原生 C 扩展或者 PIO/DMA 方案而不是在 MicroPython 里硬扛。另外如果担心回调里读取adc.read_u16()还是不保险可以选择在主循环里读取回调只负责置位。这样中断里连外设访问都省了。数据采样时刻会比定时器设定时刻晚一点点但周期性并没有乱只是整体向后偏移。对于绝大多数传感器应用这个偏移完全可以接受。4.3 实验对比三种写法的实际表现我自己用同一个 NTC 采集任务做过对比定时周期 100ms连续运行 24 小时结果很有参考价值。写法回调内容实测表现A完整读取计算print半小时到几小时不等会随机崩一次打印延迟严重B回调里仅read_u16()主循环负责计算能长时间运行但偶尔会出现一次中断占用过长导致时间戳抖动C回调仅置标志位主循环读取并计算稳定运行 24h 以上计时误差在可接受范围当然这个结果受 MicroPython 版本和具体代码影响不同版本表现可能有差异。但结论是一致的不要在中断上下文里做复杂工作。如果你写的是 C SDK 而不是 MicroPythonISR 的约束略有不同但“ISR 要短”这个原则依然成立。5. 常见问题与排查技巧实录5.1 读取值跳动大、噪声高怎么办ADC 读数跳动第一反应不要急着写滤波先判断噪声来源。把 ADC 输入端直接接地或接 3.3V如果读数还是跳说明是参考电压或 ADC 自身的问题如果读数稳定说明噪声是从外部传感器和线路串进来的。Pico 的 ADC 对电源噪声敏感用 USB 供电时电机、舵机这种大电流负载会把噪声耦合进 3.3V导致 ADC 读数跳。玩过“树莓派 Pico 控制舵机”的朋友应该深有体会舵机一动作其他模拟量采样就开始飘。硬件上可以给 ADC 输入加一个 RC 低通滤波器比如 1kΩ 串联电阻加 100nF 到地截止频率约 1.6kHz能显著衰减高频干扰。但要注意RC 会增加源阻抗而 SAR ADC 对源阻抗有要求。经验值是信号源等效阻抗尽量控制在 10kΩ 以内如果传感器本身是高阻输出建议先用运放缓冲再进 RC 滤波。软件上滑动平均和中值滤波是首选。如果只是低频采样可以每次连续采 8 次去掉最大最小值后取平均效果非常好代价是采样频率降低到原来的八分之一。5.2 ADC 采样周期、建立时间怎么理解“采样周期”这个说法经常被混淆。它至少包含两部分采样保持电容充电的建立时间以及逐次逼近转换时间。SAR ADC 在转换前有一个采样窗口窗口内必须把外部信号“采”到内部电容上。如果你的信号源内阻太大电容来不及充满转换结果就会偏低。Pico 的read_u16()每次调用都会发起一次完整转换MicroPython 底层处理了时序我们不需要手动配置采样周期但这不意味着可以忽略源阻抗影响。低内阻信号源电压建立快测量准确高内阻信号源充电慢误差大。如果你看到“ADC 读数在小电压时偏小大电压时还行”很可能就是建立时间问题因为低电平到内部电容充电需要更多时间。信噪比也是老生常谈。理想 12 位 ADC 的 SNR 理论值约 74dB实际因为参考电压噪声、地弹和干扰能到 60dB 就不错。如果你要测量微弱信号内置 ADC 的 SNR 可能不够这时就得加放大或外置 ADC。5.3 外部 24 位 ADC 怎么选HX711、ADS1232、MCP3561如果你确实走上了外置 ADC 的路我有几个选型建议。表格是精简版更详细的差异要根据具体手册确认。型号分辨率接口典型应用场景注意点HX71124 bit类串行接口电子秤、称重传感器速度低增益大内置稳压ADS123224 bit类 SPI桥式应变传感器抗干扰强时序简单MCP356124 bitSPI多通道高精度采集可编程增益采样率可选配置复杂HX711 在称重领域用得多因为它专为电桥传感器设计内部包含可编程增益放大器可以直接接几毫伏的差分信号。但它的输出协议不是标准 SPI速度也不快不适合高速采样。ADS1232 和 HX711 定位类似某些场景抗干扰更好。MCP3561 则是更通用、更灵活的选择适合需要多个模拟通道或者变采样率的项目。另外提一句很多朋友在选择 MCU 时会对比其他开发板比如问“STC8G1K08A 有 ADC 吗”。这类问题其实看选型手册最靠谱不同型号内置外设差异很大。但选片的核心逻辑一样够用就好不要为了一个温度采样硬上一颗 24 位 ADCPico 内置 ADC 加校准和滤波在很多场景下已经能交出不错的成绩。5.4 用 ADC 做一个简单按键方案ADC 按键是一个经典应用原理是把多个按键通过不同电阻分压到不同电压一个 ADC 引脚就能识别多个按键。这种方案在需要节省 IO 的小项目里很实用但也容易被忽略细节。比如用 4 个按键和 1 个电阻分压网络每个按键按下时 ADC 读到的电压都不同。软件上只需要在 ADC 值落在某个阈值区间内时判定对应按键。要注意的是每个按键对应的 ADC 值区间要留足够的余量避免阻值误差导致误判。按键按下瞬间可能产生抖动最好配合定时器去抖比如连续两次读取结果一致才确认。如果有 LED 或多位 ADC 同时刷新按键脚的高阻状态容易受干扰硬件上加一个小电容到地会更稳。ADC 按键的精度要求不高12 位 ADC 分 3.3V理论上可以分很多档但实际考虑到电阻误差和噪声按键数量一般不超过 8 个。别为了省 IO 硬塞太多档位误触带来的麻烦远大于节省的引脚价值。5.5 多通道连续采样STM32 有 DMAPico 怎么玩很多从 STM32 转过来的朋友会问“ADC 四通道使用 DMA”怎么在 Pico 上玩。STM32F4 这类芯片可以用 ADC 扫描模式加 DMA把多个通道的转换结果连续搬运到内存全程几乎不占 CPU。但在 Pico 的 MicroPython 环境里官方machine.ADC没有直接暴露 DMA 接口所以不能照搬 STM32 那套。Pico 比较简单的多通道方案是顺序读取比如adc0 machine.ADC(26) adc1 machine.ADC(27) adc2 machine.ADC(28) while True: v0 adc0.read_u16() v1 adc1.read_u16() v2 adc2.read_u16()注意通道切换后ADC 内部采样保持需要一点稳定时间所以连续读多个通道时中间可以加一个很短的time.sleep_ms(1)。如果你要更高性能的多通道采样可以用 C SDK 直接操作寄存器或者通过 PIO 配合硬件外设实现但这已经超出 MicroPython 的舒适区属于后续优化方向。关于多通道和 DMA还有一个更现实的提醒不要试图在主循环里满速轮询三个通道加打印MicroPython 的print会把吞吐拖垮。实际项目里应该只保存最新数据按需上报或者把打印频率降下来。我最后想说的几个习惯我在实际项目里反复踩这些坑之后养成了几个习惯第一任何 ADC 项目先确认参考电压用万用表实测 3V3绝不写死 3.3第二定时采样一律用“中断置标志 主循环干活”的架构哪怕刚开始觉得多此一举后面也会省下大量排查时间第三滤波宁可早加不可不写一个小窗口滑动平均能省掉很多“为什么读数乱跳”的烦恼第四遇到读数不对先怀疑硬件连接和源阻抗再怀疑软件逻辑千万别上来就怼滤波算法。最后再给想用内部温度传感器做环境测温的朋友一句忠告RP2040 的内部温度传感器受 CPU 温度影响太大跑浮点运算时读数会明显升高拿它测芯片温度或者看趋势可以测室温还是老老实实外接 NTC成本几毛钱效果天差地别。