树莓派Pico ADC完全指南:从read_u16到定时温度采集与中断避坑

发布时间:2026/9/7 12:02:52
树莓派Pico ADC完全指南:从read_u16到定时温度采集与中断避坑 树莓派 Pico 的 ADC 是我在 MicroPython 项目里用得最多的外设之一但也是我见过翻车率最高的外设。很多人觉得 ADC 不就是read_u16()读个数值回来吗按键检测用得好好的一到模拟量采集就出各种诡异现象数值跳变、温度偏高、中断里读数据一会儿就死机。这些坑我全踩过所以这篇内容打算把 Pico 的 ADC 完整解剖一遍从machine.ADC的底层行为讲到具体的定时温度采集代码最后把 ISR 中断里那些致命细节掰开揉碎讲清楚。这套内容适合刚拿到 Pico 想玩模拟量采集的新手也适合已经在用 MicroPython 做小项目、但被 ADC 精度和中断问题折磨的朋友。看完你至少能少走一两个月的弯路尤其是最后那部分中断避坑很多老手都不一定意识到问题出在哪。1. 先搞明白 Pico 的 ADC 外设别急着写代码1.1 Pico 的 ADC 硬件参数与通道分配树莓派 Pico 用的是 RP2040 芯片内置一个 12 位逐次逼近型SARADC。没错逐次逼近不是积分型也不是 Δ-Σ 型。这意味着它有一个采样电容每次采样前需要给电容充电充电时间不够或者信号源阻抗太高采出来的值就会偏低甚至跳动。从 MicroPython 的角度看machine.ADC通道和物理引脚的对应关系是固定的通道号引脚用途ADC0GP26 / ADC_VIN0通用模拟输入ADC1GP27 / ADC_VIN1通用模拟输入ADC2GP28 / ADC_VIN2通用模拟输入ADC3ADC_VREF / 内部温度传感器通过配置选择有一点特别容易忽略RP2040 的 ADC 输入电压范围是 0 到 3.3V超过 3.3V 就会烧引脚不能直接测 5V。ADC3 在 MicroPython 固件里默认映射到了片内温度传感器这个传感器输出的是随温度变化的电压需要经过换算才能得到温度值。另外RP2040 的 ADC 参考电压默认就是 3.3V 电源电压也就是ADC_VREF引脚。如果板子供电不稳参考电压跟着抖ADC 读数就会跟着抖。很多初学者把数据跳变归咎于“传感器噪声”其实问题是出在参考电压上。1.2 machine.ADC 的常用方法与返回值解析MicroPython 的machine.ADC类在 Pico 上提供的方法不算多但每个方法的返回值含义和工作方式差别很大ADC(pin_id)创建 ADC 对象可以传引脚编号如 26、27、28也可以传 Pin 对象。read_u16()返回 0~65535 的整数。注意这是把 12 位 ADC 原始值左移 4 位得到的不是真实的 16 位精度。read_uv()返回微伏µV单位的电压值整数类型。这个方法内部做了参考电压换算通常在较新的固件里才有。read_vref()返回参考电压的微伏值可以用来判断当前供电电压是否稳定。从实际使用角度我强烈建议用read_uv()而不是read_u16()。原因很简单read_u16()的数值只是个比例你还要自己除 65535 再乘以 3.3万一哪一手误把 3.3 写成了 3300整个计算结果就差了一千倍。而read_uv()直接给你微伏值uv / 1_000_000就是伏特单位换算清晰明了。1.3 为什么说 read_u16 是个“温柔陷阱”我见过不少人在网上发帖问为什么我 Pico 的 ADC 读数到最后几位一直在跳一看代码全是read_u16()之后手动算电压。这里有两个问题叠加第一个是read_u16()本身放大噪声。12 位 ADC 满量程对应 4096 个离散值映射到 16 位后是 0~65535相当于把每一位原始值放大 16 倍。如果原始值的低 1~2 位存在正常抖动放大以后看起来就是 16~32 个 LSB 的跳变观感上特别像信号质量差。第二个原因是参考电压的噪声被直接映射到了结果里。Pico 的 ADC 参考电压来自芯片的 3.3V 电源当板载稳压器负载变化或 USB 供电有纹波时参考电压会有几十毫伏的波动。这几十毫伏叠加在 3.3V 的参考上换算到 12 位有效值大约就是几个 LSB 的误差你要是再手动放大 16 倍去看那跳变就非常明显了。我自己的做法是从项目一开始就统一用read_uv()配合软件滤波既避免了单位换算错误也减少了对噪声的放大观感。这一点后面在温度采集实战里会再展开。2. 单通道电压采集与多通道复用先打牢基础2.1 模拟采集最基本的接线原则写代码之前先把接线这块说清楚。Pico 的 ADC 输入是高阻状态但高阻不等于没要求。信号源的输出阻抗如果太高ADC 内部的采样电容没法在采样窗口内充到稳定电压结果自然不准。对于常见的电位器10kΩ 或更低分压输出、模拟温度传感器如 TMP36、NTC 热敏电阻分压电路直接接到 GP26~GP28 问题不大。但如果你的信号源输出阻抗超过几十千欧比如某些电化学传感器或者高阻分压网络建议加一个运放电压跟随器缓冲不然读出来的值会明显偏低。接线时还要注意模拟地和数字地尽量短距离连接避免形成大的地环路。ADC 输入线不要太长超过 20cm 就容易引入工频干扰尤其是靠近电机、继电器这些感性负载时。如果信号和环境噪声比较多可以在 ADC 引脚和 GND 之间并联一个 0.1µF 陶瓷电容做简单低通滤波。这些看似基础但实际项目里很多诡异的数据抖动问题排查到最后发现是接线和布线的锅而不是代码的锅。2.2 单通道电压采集的标准代码框架下面这段代码是我在 Pico 上测量外部电压时的标准模板集成了多次采样平均和电压转换from machine import ADC import time adc ADC(26) # GP26 def read_voltage(samples16): total_uv 0 for _ in range(samples): total_uv adc.read_uv() time.sleep_us(200) # 给采样电容一点稳定时间 avg_uv total_uv // samples return avg_uv / 1_000_000 # 转为浮点伏特数 while True: v read_voltage() print(Voltage: %.3f V % v) time.sleep(0.5)这里的time.sleep_us(200)不是多余的。ADC 内部采样保持电容需要时间从输入源取电连续快速读取时如果信号源驱动能力不足每一次转换后电容电压没有完全建立结果会逐次偏低。加一个小延时后数据稳定很多。2.3 多通道复用时必须注意的“串扰”问题Pico 只有一个 ADC 硬件模块多通道是靠内部模拟开关切换实现的。切换通道后采样电容里存的是上一个通道的电压如果立即读取会得到一个夹在两个通道之间的过渡值这就是“通道串扰”。解决办法很简单每次切换通道后先做一次空读来丢弃过渡值再做真正的采样。代码实现如下from machine import ADC adc0 ADC(26) adc1 ADC(27) def read_channel_stable(adc, discard1, samples8): # 丢弃切换后的第一个结果 for _ in range(discard): adc.read_u16() total_uv 0 for _ in range(samples): total_uv adc.read_uv() return total_uv // samples v0 read_channel_stable(adc0) v1 read_channel_stable(adc1)实际测试中丢弃第一次读取后相邻通道的串扰影响基本可以忽略。如果你要采集两个相差很大的电压比如一个是 0V一个是 3V不丢弃第一次读取的话两个读数都会明显偏移。2.4 参考电压校准精度要求的必修课Pico 板载的 3.3V 参考电压名义上是 3.3V但实际出厂不会精确校正到小数点后四位。如果你只是做个大概的电压显示没问题。但如果你要拿 ADC 做仪表级测量就必须校准参考电压。校准方法很简单找一个已知精度的电压源比如 1.000V 的参考电压模块或者用万用表实测的稳压源接到 ADC 输入然后反推实际的参考电压值from machine import ADC adc ADC(26) known_voltage 1.000 # 已知输入电压单位 V reading adc.read_uv() / 1_000_000 actual_vref 3.3 * known_voltage / reading print(Actual VREF: %.4f V % actual_vref)拿到实际参考电压后后续所有电压计算都用这个值代替 3.3。如果要求更高可以做三点校准甚至多点拟合把 ADC 的积分非线性和增益误差一起补偿掉。对于 Pico 自带的 12 位 ADC做到 0.5% 以内的精度是比较现实的期望再高就得外接高精度 ADC 芯片了。3. 定时温度采集实战从传感器选型到成品代码3.1 温度传感器选型对比与电路设计做温度采集传感器选型直接决定电路复杂度。我常用的有三种方案方案输出类型接线复杂度精度典型值适合场景RP2040 片内温度传感器模拟电压内部无需接线±2°C 左右测芯片自身温度、粗略环境温度NTC 热敏电阻 分压电阻模拟电压分压简单校准后 ±0.5°C低成本环境温度测量TMP36 / LM35 模拟温度传感器模拟电压线性简单±1~2°C通用仪表、教学演示片内温度传感器的优点是不需要外部电路但缺点是它测的是芯片内部温度和空气温度有滞后而且 Pico 在运行中自身发热会影响读数所以只适合做粗略参考。NTC 成本最低但需要标定。TMP36 输出线性电压换算最简单。我这里的实战案例用 NTC 方案来演示因为它涉及分压电路计算能顺带把 ADC 的工程化问题讲透。电路很简单NTC 和一个 10kΩ 精密电阻串联中间抽头接 GP26。电源接 3.3V电阻一端接 3.3V一端接 GNDNTC 放在 3.3V 和采样点之间10k 电阻放在采样点和 GND 之间。这样就构成一个分压器温度变化引起 NTC 阻值变化采样点电压跟着变。3.2 NTC 温度计算公式与代码实现NTC 的阻值和温度的关系可以用 B 值公式近似R R25 * exp(B * (1/T - 1/T25))其中 T25 298.15KR25 是 25°C 时的阻值B 是 NTC 的 B 值常数。反过来已知当前阻值求温度T 1 / (1/T25 (1/B) * ln(R / R25))而分压电路里NTC 的阻值可以通过采样点电压算出来。假设 NTC 在上、固定电阻在下那么R_ntc R_fixed * (VCC - V_out) / V_out把这三步串起来我写了一个完整的温度读取函数from machine import ADC import math ADC_PIN 26 R_FIXED 10000 # 固定电阻阻值单位 Ω R25 10000 # NTC 在 25°C 时的阻值 B_VALUE 3950 # NTC 的 B 值 VCC 3.3 # 参考电压 adc ADC(ADC_PIN) def read_resistance(samples16): total_uv 0 for _ in range(samples): total_uv adc.read_uv() time.sleep_us(100) v_out (total_uv // samples) / 1_000_000 if v_out 0: return float(inf) r_ntc R_FIXED * (VCC - v_out) / v_out return r_ntc def resistance_to_temp(r_ntc): t25 298.15 inv_t 1.0 / t25 (1.0 / B_VALUE) * math.log(r_ntc / R25) return 1.0 / inv_t - 273.15 while True: r read_resistance() temp resistance_to_temp(r) print(R%.1f Ohm, Temp%.2f C % (r, temp)) time.sleep(1)实际用下来这个方案的精度取决于两个因素一是 VCC 的稳定度二是 R_FIXED 的精度。VCC 的波动可以通过前面说的参考电压校准来修正或者干脆每次都读一次read_vref()做动态补偿。3.3 定时采集的两种调度方式别再只会 delay温度采集通常不需要连续满速采样隔几十秒甚至几分钟采一次就够了。MicroPython 里实现定时采集主要有两种方式第一种是最直观的主循环里time.sleep()睡够时间再采。这种写法简单但采集期间 CPU 会被阻塞你要同时跑显示屏刷新、按键检测或者网络请求就得把代码改成分时调度复杂度一下子就上来了。第二种是用machine.Timer定时器让 ADC 采集在定时器中断里触发主循环继续干别的事。MicroPython 在 RP2040 上对Timer的支持还算稳定可以创建软定时器来做周期任务。我这里推荐一个折中方案定时器中断只负责设置一个“该采集了”的全局标志位主循环检测到标志后再执行实际的 ADC 读取和数据处理。这样既不用在中断里做耗时操作原因后面会详细说又不会因为采集任务阻塞主循环。3.4 完整的定时温度采集代码下面这段代码是我在实际项目里跑过的完整框架实现了每秒采集一次温度、滑动平均滤波、串口输出from machine import ADC, Timer import time, math ADC_PIN 26 R_FIXED 10000 R25 10000 B_VALUE 3950 adc ADC(ADC_PIN) adc_readings [] # 滑动窗口存最近 N 次原始阻值 WINDOW_SIZE 10 sample_flag False def timer_callback(t): global sample_flag sample_flag True def read_resistance(): total_uv 0 for _ in range(8): total_uv adc.read_uv() v_out (total_uv // 8) / 1_000_000 if v_out 0: return None return R_FIXED * (3.3 - v_out) / v_out def resistance_to_temp(r): t25 298.15 inv_t 1.0 / t25 (1.0 / B_VALUE) * math.log(r / R25) return 1.0 / inv_t - 273.15 timer Timer() timer.init(period1000, modeTimer.PERIODIC, callbacktimer_callback) while True: if sample_flag: sample_flag False r read_resistance() if r is not None: adc_readings.append(r) if len(adc_readings) WINDOW_SIZE: adc_readings.pop(0) avg_r sum(adc_readings) / len(adc_readings) temp resistance_to_temp(avg_r) print(Temp: %.2f C % temp) # 主循环里可以继续做其他事情比如按键检测、OLED刷新 time.sleep(0.01)这个结构的核心优点在于定时器回调只改一个布尔变量主循环每秒处理一次温度采集其他时间可以自由执行其他任务不会因为采集跑飞而影响整个系统。4. ISR 避坑指南中断里做错一步整机白跑4.1 MicroPython 中断回调的“三不”原则很多新手学过 C 语言里的中断习惯性地把大量逻辑塞进中断回调函数。在 MicroPython 的Timer回调里这是最大的坑。MicroPython 运行在 Python 解释器之上中断回调执行时的情况和裸机 C 不一样。解释器在处理回调时内存分配器、垃圾回收器不会正常工作在可重入的安全状态。简单说你在中断回调里做了下面这些事随时可能触发崩溃或硬故障执行内存分配操作比如创建列表、字典、字符串拼接、append操作。调用print()输出调试信息。做占用时间长的计算比如大量浮点运算或复杂的math调用。读取或写入文件系统、操作网络堆栈。调用time.sleep()。我看到太多人在Timer回调里写print(adc.read_u16())一开始能跑过一会就报错或死机。原因就是print内部涉及字符串格式化和输出缓冲在中断上下文执行很容易触发异常。我把这个总结成一个“三不”原则不在中断里分配内存、不在中断里打印、不在中断里做耗时操作。定时器回调里最安全的操作就是翻转一个全局标志位或者给一个全局变量赋值。4.2 为什么 read_uv 放在中断里那么危险回到 ADC 的话题read_uv()这个方法内部需要调用底层固件读取 ADC 寄存器然后把原始值换算成微伏。这个过程会涉及乘除法在 MicroPython 里整数运算还好浮点运算就慢了。更重要的是连续读取多次 ADC 并进行平均的耗时并不短。如果你把 8 次采样平均放在Timer回调里既不安全又会导致中断占用时间过长。假设你设置定时器周期为 10ms而回调中 ADC 采样耗时 5ms那定时器几乎没法正常触发整个系统的时间基准就乱了。实践中我发现即使只是把adc.read_u16()放进 Timer 高频回调时间长了也可能触发MemoryError或者系统卡死。因为read_uv内部可能在堆上创建临时对象而垃圾回收在中断上下文是被禁用的。临时对象无法回收内存就一点点泄漏直到崩溃。4.3 ISR 标志位模式我的标准做法基于上面的教训我在所有 MicroPython 项目里都强制自己使用“中断只置标志、主循环干活”的模式。具体分为三个角色定时器回调只做一件事把flag True。主循环检查标志位执行 ADC 采样、滤波、传感器计算、数据输出。时间基准如果标志位置位说明一个定时周期到了主循环处理完后再清除标志。有人会问如果主循环处理得太慢定时器已经又触发了好几次标志位会不会丢确实会。比如定时器每秒触发 10 次而主循环耗时 500ms 才处理完一次那中间最多只能记录一次标志。解决办法有两个一是根据系统实际负载选择合适的定时周期确保主循环处理速度大于中断触发速度二是用计数器代替布尔标志中断里做count 1主循环每次处理一个计数。Pico 的 MicroPython 对全局变量操作做了优化count 1这种操作在中断里是可以接受的但要注意用global声明。4.4 定时中断读传感器的进阶方案轮询与中断结合既然中断里不能直接读 ADC那我还能不能用中断方式做“边沿触发”的采集比如外部信号触发时立刻采样答案是可以但要用中断触发主循环而不是在中断里本身采样。具体做法是把外部信号接到一个普通 GPIO 引脚配置为下降沿或上升沿中断。中断回调里只置标志位。主循环检测到标志后立即读取 ADC并把这次读取标记为一次有效触发事件。这套方案兼顾了实时性和稳定性。虽然从 GPIO 中断信号到主循环实际执行采样之间会有一点延迟通常几毫秒级别但对于绝大多数传感器采集场景来说完全够用。如果你需要的是微秒级的精确采样那就不应该用 MicroPython 了老老实实写 C SDK 或者用 DMA 方式吧。5. 常见问题与排查实录我的踩坑记录5.1 问题速查表下面这张表是我在实际项目里遇到过的典型问题每条都对应过真实的排查过程现象可能原因解决方法读数整体偏低且输入阻抗越高越明显采样电容充电时间不足每次采样间加延时或降低信号源阻抗数值持续跳变最后几位一直变参考电压不稳 / 未做软件滤波用read_uv加滑动平均或中值滤波通道切换后读数异常模拟开关未稳定前次残留电压切换后先丢弃第一次采样定时器初始化后程序间歇性死机中断回调里做了耗时操作或内存分配回调只置标志位主循环处理采集NTC 温度读数明显偏高分压电路接反 / VCC 用 3.3 但实际偏高重新核对电路或用read_vref校准用 USB 供电时读数随负载波动参考电压受电源纹波影响外部稳压供电或增加电容滤波连续采集一段时间后报 MemoryError中断里或循环里创建了临时对象避免在中断里创建对象减少频繁字符串拼接5.2 一个真实排查案例为什么温度越测越高有一次我给一个保温箱做温度监控用的是 Pico 加 NTC 方案。调试的时候发现一个怪现象温度读数每隔几分钟会突然上升 1~2°C然后慢慢降回来周而复始。一开始我以为是 NTC 的问题换了传感器没用。后来用万用表直接量分压点的电压发现电压是稳定的说明传感器电路没问题。接着怀疑是 ADC 参考电压漂移但read_vref()的读数也很稳。最后查了半天发现是保温箱的加热器启动时Pico 的 3.3V 稳压器输入端电压被拉低了一瞬间导致 3.3V 输出也跟着轻微跌落。这 50mV 级别的跌落在电压测量里看着不大但用 B 值公式换算成温度就放大了很多倍。解决方法是把 NTC 分压电路的电源从 3.3V 挪到了一个独立的精密参考电压源上或者用 ADC 的read_vref()做动态功率补偿。我在代码里实现的是动态补偿v_ref adc.read_vref() / 1_000_000 r_ntc R_FIXED * (v_ref - v_out) / v_out这样即使电源有波动read_vref()也能实时捕捉到计算出的电阻值就不会受到参考电压漂移的影响。这招在电池供电的项目里尤其好用因为电池电压会随着电量下降而变化。5.3 提高 ADC 数据可靠性的几个小技巧除了上面那些系统性的解决方案我还有几个日常项目里积累的小技巧熟练使用后 ADC 数据质量能提高一个档次采样结果用“先去除最大最小值再平均”的方法比单纯平均更能抗脉冲干扰。比如采 10 次去掉一个最大和一个最小再求平均。NTC 温度转换里的math.log()运算比较慢如果是一次性计算没问题高频采样时可以先算好查找表牺牲一点内存换速度。如果项目里对温度精度要求不高用片内温度传感器可以省掉所有外部电路但记得要在代码里减去 Pico 自身发热的温升这个数值大致在 2~5°C 之间和环境温度及负载有关。把 ADC 采样频率和数据上报频率解耦。采样可以快一点比如每秒采 10 次做平均数据上报可以慢一点比如每 10 秒打印一次平均结果。这样既平滑了噪声又不会刷屏。5.4 热噪声、工频干扰与外壳屏蔽最后再说一个容易被忽略的点如果你做的温度采集设备要放在工业环境或者大功率电器旁边ADC 引线会感应到很强的工频干扰和电磁噪声。表现就是读数有规律的波动波动频率接近 50Hz 或 100Hz。我在一个电机控制项目里就被这个问题折磨过加了 0.1µF 滤波电容改善仍然有限。后来把 ADC 引线换成了屏蔽线屏蔽层单端接地并在代码里采用“采样 20 次、每两次间隔 1ms”的均匀时间采样数据才稳定下来。如果干扰实在太严重可以考虑用多次平均 中值滤波组合先把毛刺削掉再平滑输出。6. 写在最后的经验补充折腾 Pico 的 ADC 这几年我最深的体会是ADC 的问题很少是单点问题它往往是电源、接线、采样时序和数据处理四者共同作用的结果。很多人一看到数据不准就开始怀疑传感器换了一堆硬件也没解决其实根源可能在参考电压漂移或者采样的时序上。如果你现在正要开始一个使用 Pico ADC 的项目我建议不要一上来就追求高精度先把数据链路打通——从 ADC 原始值到标度值到最终物理量——然后逐段标定误差。每多做一个项目你就会对这套流程更加得心应手。最后分享一个小技巧调试时不要把print放在高频循环里它会严重影响时序尤其是你还开着 REPL 连接时。更好的办法是每隔几十次采样打印一次或者用一个数组记录最近的数据在需要时一次性导出来看。这样既能拿到完整数据又不会干扰采集节奏。