树莓派Pico ADC实战:从API用法到定时采集与ISR避坑指南
树莓派 Pico 这块板子很多人买回来第一件事就是点灯第二件事可能就是玩 ADC。毕竟在物联网、数据采集、传感器这些场景里ADC 是绕不开的硬骨头。我最初用 Pico 做环境监测的时候也踩过不少 ADC 的坑——比如读数漂移、通道串扰、中断里做采集导致系统卡死这些问题排查起来真是让人头大。今天这篇就把 Pico 的 ADC 从 API 用法到实战定时采集完整拆解一遍重点是把我实际踩过、修过的坑都列出来尤其是 ISR 中断里的那些不能做和千万不要做希望你看完能少走弯路。这篇内容适合谁如果你刚拿到 Pico 想用 ADC 读传感器或者已经会用但被精度、稳定性、定时采集这些问题折磨过又或者你正准备把 Pico 用在小型数据采集项目上那这篇就是为你准备的。我会从硬件原理讲起逐步过渡到 MicroPython 的 machine.ADC 接口再带你做一个完整的定时温度采集系统最后是 ISR 避坑专题。整个链路走下来你对 Pico 的 ADC 就算真正摸透了。1. 硬件底子先搞清楚Pico 的 ADC 到底是个什么水平1.1 RP2040 的 ADC 内核12 位 SAR 型 ADC 的前世今生树莓派 Pico 用的主控是 RP2040它的 ADC 模块是一颗 12 位逐次逼近型SARADC。SAR 型 ADC 的工作原理可以类比成称重你要称一个物体的重量先放一个最大的砝码如果太重了就换小一号的逐次逼近最终得到物体的重量。ADC 内部的比较器和 DAC 也是这么干的从最高位开始逐位判断12 位精度就需要 12 次比较得到 0 ~ 4095 的数字量。这颗 ADC 核心有几个关键参数你必须要知道。采样率最高 500kSPS每秒五十万次采样在实际 MicroPython 环境下很难跑满因为 Python 解释器开销太大但这并不妨碍我们理解它。输入电压范围是 0 ~ 3.3V超过 3.3V 就可能烧毁 ADC 引脚这点千万记住。分辨率 12 位但 MicroPython 暴露给我们的 API 默认返回的是 16 位范围内的值这个细节后面会细说。工作电压方面RP2040 的 ADC 参考电压是 3.3V 供电电压也就是说 ADC 的满量程基准直接依赖你的 3.3V 电源质量。如果你的供电纹波大、稳压不稳ADC 的读数就会跟着抖。这也是很多人在面包板上用 USB 供电做 ADC 采集时发现数据噪声很大的根本原因之一。1.2 五个通道的分布与限制不是所有引脚都能 ADCRP2040 上 ADC 一共有 5 个输入通道编号是 ADC0 ~ ADC4。在 Pico 板上对应的物理引脚要记清楚通道GPIO物理引脚Pico 板载丝印备注ADC0GPIO26ADC0通用输入ADC1GPIO27ADC1通用输入ADC2GPIO28ADC2通用输入ADC3GPIO29ADC3此引脚也用于板载 I2S 或其它功能需要注意复用ADC4内部无接内部温度传感器有个许多新手不知道的限制ADC3GPIO29在 Pico 板上没有独立的排针引出标记其实板上是有 ADC3 标注的这个引脚在部分开发板上会被用于电池电压检测比如一些扩展板会通过电阻分压把电池电压引到这个脚使用前一定要确认没有其他外设占用了它。还要注意ADC 通道是共享一个采样保持电路的不能同时采样多个通道。如果你在代码里连续切换通道需要给 ADC 留一点稳定时间否则读数会带有上一个通道的残留电压。这一点在后续多通道采集里很关键。1.3 精度与噪声12 位 ADC 实际能用多少位标称 12 位实际能不能达到 12 位精度答案是你得看条件。RP2040 的数据手册给出的有效位数ENOB和噪声指标并不是特别亮眼实测下来在良好供电、良好接地的条件下ADC 读数的底部噪声大约有 ±2±4 个 LSB12 位模式下。这个水平做温度、光照、湿度这类慢变传感器完全足够但如果你要做高精度的电压测量那可能需要外部 ADC比如 ADS1115、HX711或者做软件滤波。这里我强调一个概念ADC 精度和分辨率是两回事。分辨率是 ADC 能分辨的最小电压变化计算公式是 Vref / 2^N12 位就是 3.3V / 4096 ≈ 0.8mV。精度则是读到的值和真实电压的偏差受参考电压精度、非线性误差、噪声等影响。Pico 的参考电压是电源电压所以电源越稳精度越高。另外Pico 上的 ADC 有一个众所周知的非线性问题特别是在靠近 0V 和靠近 3.3V 的两端更加明显。如果你的传感器输出范围恰好落在这两个极端区域需要对结果做校准。这个在后面的实操部分我会给出具体的校准方法。2. machine.ADC 快速上手从 API 到十六位数据的秘密2.1 创建 ADC 对象的三种方式MicroPython 的 machine.ADC 类在 RP2040 上有几种构造方式。常见的是直接传引脚编号或通道编号。以官方文档为准最稳妥的方式是传 GPIO 编号from machine import ADC # 方式一传 GPIO 编号最常用 adc ADC(26) # GPIO26 对应 ADC0 # 方式二传 ADC 通道编号在 RP2040 上也是可以的 adc ADC(0) # 也是 ADC0这里唯一要注意的是ADC(26) 和 ADC(0) 在 RP2040 上都能工作但为了代码可读性强烈建议统一使用 GPIO 编号这样别人看代码不用去查通道映射表。另外ADC 引脚的输入模式在创建对象时已经自动配置不需要像 GPIO 输出那样手动 Pin.init。如果你之前手动设置过某个引脚为输出模式想改回 ADC 输入直接重新创建 ADC 对象即可。2.2 read_u16() 与 read_ut16()同一个读数不同的返回值在 Pico 的 MicroPython 固件中ADC 对象提供了几个读取方法。最常用的是read_u16()它返回一个 0 ~ 65535 的 16 位无符号整数。这里有个非常容易让新手迷糊的地方Pico 的 ADC 是 12 位的为什么 read_u16() 返回 16 位的数据原因在于 MicroPython 为了让代码在不同开发板上通用将 ADC 的原始分辨率统一归一化到 16 位范围。也就是说ADC 硬件采集到的 12 位数值0~4095会被左移 4 位变成 0~65535。这一点和 ESP32 上的 ADC 行为类似目的是让上层代码不用关心底层 ADC 是多少位。我见过有人不知道这个映射关系直接拿 65535 当作满量程去算电压结果自然是错的。正确的电压换算公式是voltage adc.read_u16() / 65535 * 3.3如果你不想经过 16 位归一化想要原始的 12 位结果在较新的固件版本RP2040 专用固件中可以使用read_ut16()。这个方法返回的是未经归一化的原始 12 位值右移 4 位的结果这里需要更精确地说read_ut16()在 RP2040 上返回的是未经校准的原始 ADC 值扩展到 16 位的结果注意它跳过了 MicroPython 内置的 ADC 校正offset 和 scale 校准。我在实测中发现read_u16()和read_ut16()的结果存在几个 LSB 的差异这是因为read_u16()会应用 RP2040 出厂时烧录在芯片里的 ADC 校正参数。日常使用中直接用read_u16()即可如果你对数据做了自己的校准而且觉得 MicroPython 的校正参数不适合你的应用再用read_ut16()。2.3 读取电压、判断量程一个超简单的电压表把上面的知识串起来我们可以用 3 行代码做一个最简单的电压表from machine import ADC adc ADC(26) voltage adc.read_u16() / 65535 * 3.3 print(f当前电压: {voltage:.3f} V)这已经能用了但你要是把它接到实际电路上就会发现读数不太稳定——同一个电压源每次读取可能相差几十毫伏。解决方法是多次采样取平均。我一般取 10 ~ 50 次平均采样次数越多越平滑但响应越慢。对于电压监测这种场景50 次平均完全够用而且几乎感觉不到延迟。def read_voltage(adc, samples20): total 0 for _ in range(samples): total adc.read_u16() return total / samples / 65535 * 3.3这里有个细节在 for 循环里连续采样ADC 的采样电容需要时间重新充电。如果循环速度太快前一次读数的残余电荷会影响下一次结果。我在实测中发现MicroPython 的 read_u16() 单次调用本身就有大约几十微秒的开销这个时间对采样电容充电来说是足够的所以不用额外加 sleep。但如果你做了更底层的优化比如用 PIO 或 DMA就必须考虑采样时间Sample Time的问题。3. 实战前哨温度传感器的类型与选型逻辑3.1 模拟温度传感器 vs 数字温度传感器什么时候用 ADCADC 最常见的实战场景之一就是采集温度。但温度传感器分两大类模拟输出型和数字输出型。模拟输出型传感器的代表是 TMP36、LM35、热敏电阻NTC。它们的输出是电压值或电阻值需要经过 ADC 转换成数字量再计算温度。这类传感器的优点是无缝接入 ADC不需要复杂的通信协议缺点是精度受 ADC 精度和参考电压影响且信号线较长时容易受干扰。数字输出型传感器比如 DS18B20、SHT30、BME280它们内部自带 ADC 和校准通过单总线或 I2C/SPI 输出数字温度值。这类传感器精度高、抗干扰强但需要额外的通信协议支持而且我们这篇文章的主角 ADC 就没它什么事了。选择哪种主要看应用场景。如果你只是想在 Pico 上随手测个温、学习 ADC 的使用选模拟传感器最合适——因为整个链路传感器输出 → 分压/放大电路 → ADC → 软件换算都能亲手摸一遍。如果你要做高精度的多点温度采集还是用 DS18B20 这类数字传感器更靠谱。这篇文章既然讲 ADC我们就用模拟传感器来做实战。3.2 NTC 热敏电阻的分压电路设计一个电阻的智慧热敏电阻NTC是最便宜的测温方案一颗几分钱配合一个固定电阻做分压就能把温度映射成电压。NTC 的阻值随温度升高而减小负温度系数典型型号 100K NTC 在 25 度时阻值为 100K在 50 度时可能降到 40K 左右。分压电路的设计思路是将 NTC 与固定电阻串联接在 3.3V 和 GND 之间中间节点接 ADC 输入。固定电阻的选择直接影响测量灵敏度。若 NTC 在 25 度时阻值为 R0固定电阻也取 R0则 25 度时分压输出正好是 1.65V处于 ADC 量程的中部对应最大灵敏度区间。我用的是 100K NTC 100K 固定电阻的组合在 0 ~ 100 度范围内输出大致在 0.3V ~ 3.0V覆盖 ADC 大约 90% 的量程非常理想。需要特别注意的是NTC 分压电路的输出阻抗较高几十 K 级别而 ADC 输入端的采样电容在采样瞬间会拉低电压。如果天线过长或没有加缓冲读数会偏低。解决方案有两个一是降低分压电阻阻值比如 10K NTC 10K 固定电阻代价是功耗增大、灵敏度略微下降二是在 ADC 输入端并联一个 100nF 的电容给采样电容一个瞬时电荷来源。3.3 板载温度传感器的秘密ADC4 通道的读取姿势RP2040 芯片内部内置了一个温度传感器连接到 ADC4 通道。它的精度一般但有总比没有强——至少可以用来监测芯片自己的工作温度判断散热是否异常。读取内部温度传感器的方法不是直接 ADC()而是用import machine import math sensor_temp machine.ADC(4) conversion_factor 3.3 / 65535 reading sensor_temp.read_u16() * conversion_factor temperature 27 - (reading - 0.706) / 0.001721这里 0.706V 是 27 度时的典型输出电压0.001721 V/度是电压随温度变化的斜率。这个公式是数据手册上的标准换算公式。不过要注意每个芯片的实际参数略有差异如果你需要精确测量环境温度还是要用外部传感器。我实测下来ADC4 读取的温度比环境温度通常偏高 5 ~ 10 度因为芯片本身在运行时会产生热量。如果你看到 35 度左右其实环境温度可能只有 28 度。所以它只适合做趋势监测不适合做精确测温。4. 定时温度采集实战小系统里的架构设计4.1 需求分析与整体设计要稳定、低功耗、可扩展接下来我们做一个完整的实战项目定时采集温度数据。需求是每隔 5 秒采集一次温度把数据通过串口输出或存储到文件。这个场景很典型比如小型气象站、温度记录仪、温控系统的雏形。设计上有几个考虑点。第一是采集方式用轮询还是中断。轮询简单可靠但在等待期间 CPU 被占用中断定时器可以在等待期间做其他事情但需要注意中断内的代码不能太复杂。我们这次先用轮询做一个基线版本再升级到定时器中断版本对比一下两种写法的差异。第二是滤波策略单次采样必然有噪声需要软件滤波。滑动平均滤波在这里最合适——保留最近 N 次采样值每次求平均既平滑又不会像一阶低通那样有严重的相位滞后。第三是低功耗如果以后要用电池供电每 5 秒唤醒一次采集其余时间深度睡眠这是另一个优化方向。这次先不展开低功耗。4.2 传感器接线图和硬件清单用到的硬件清单树莓派 Pico 一块100K NTC 热敏电阻一个100K 固定电阻一个面包板一块、杜邦线若干可选100nF 电容一个接线方式3.3V —— 固定电阻(100K) —— A点 —— NTC(100K) —— GND | GPIO26 (ADC0)A 点就是分压中点接 GPIO26。如果加了 100nF 电容请把电容的一端接 A 点另一端接 GND注意尽量靠近 Pico 的 GPIO 引脚。插上电以后先用万用表测一下 A 点的电压确认在合理范围内室温下大约 1.5 ~ 1.8V然后再接到 Pico 上避免意外短路。这里有个新手容易犯的错误NTC 是无极性器件正反接都能通但接反了之后分压输出的变化方向会反过来温度升高时电压升高而不是降低虽然也能算出温度但公式和查表全乱了。建议用万用表先测阻值确认当前环境温度对应的阻值再对照 NTC 数据手册确保接线无误。4.3 轮询版温度采集基线代码逐行解析先上最基础的轮询版本它的代码最简单适合作为理解整个流程的基线。from machine import ADC, Pin import time NTC_PIN 26 FIXED_RESISTOR 100_000 # 欧姆 # 温度采样值列表用于滑动平均 samples [] def adc_to_resistance(adc_value): 将 ADC 16位读数转换为 NTC 的当前阻值。 voltage_ratio adc_value / 65535.0 # 分压公式: Vout 3.3 * R_ntc / (R_ntc R_fixed) # 反解 R_ntc R_fixed * Vout / (3.3 - Vout) # 由于电源电压会浮动使用电压比例比直接用 3.3 更具鲁棒性。 if voltage_ratio 0.999: # 避免除零 return float(inf) resistance FIXED_RESISTOR * voltage_ratio / (1 - voltage_ratio) return resistance def steinhart_hart_temperature(r_ntc, r0100_000, b3950, t0298.15): 使用 B 值方程计算温度。适用于 NTC 热敏电阻。 公式: 1/T 1/T0 (1/B) * ln(R/R0) 返回单位: 摄氏度 import math t_kelvin 1.0 / (1.0/t0 (1.0/b) * math.log(r_ntc / r0)) return t_kelvin - 273.15 def read_temperature(samples10): 读取多次 ADC取平均然后计算温度。 total 0 for _ in range(samples): total ADC(NTC_PIN).read_u16() avg total / samples resistance adc_to_resistance(avg) return steinhart_hart_temperature(resistance) while True: temp read_temperature() # 滑动平均保留最近 5 次结果 samples.append(temp) if len(samples) 5: samples.pop(0) smoothed sum(samples) / len(samples) print(f{time.time()} - 温度: {smoothed:.2f} °C, 原始电阻: {adc_to_resistance(ADC(NTC_PIN).read_u16()):.1f} Ω) time.sleep(5)这段代码有几个点要解释一下。创建 ADC 对象放在循环外更好但我故意把它放在循环里面这样能演示一种常见但略微低效的做法。实际使用时每次循环重新创建 ADC 对象有额外开销虽然对性能影响不大但不推荐。更好的做法是在函数外创建全局 ADC 对象函数里直接读取。滑动平均的实现我用了列表模拟队列每次 append 后如果超过长度就 pop(0)。效果是输出更平滑但注意 pop(0) 的时间复杂度是 O(N)不过样本数只有 5完全无所谓。B 值方程是 NTC 最常见的数学模型。它用一个参数 B 描述热敏电阻的温度特性虽然不如 Steinhart-Hart 公式三个参数精确但在 -20 ~ 100 度的范围内误差已经足够小。4.4 温度换算公式为什么用 B 值方程而不是查表查表法也能算温度但查表需要提前准备一张温度-阻值对应的大表占用 flash 空间而且相邻表项之间的温度只能靠插值。B 值方程则是一个简洁的数学公式几行代码就能覆盖整个测温范围。B 值方程的形式是1/T 1/T0 (1/B) * ln(R/R0)其中 T0 是标称温度 298.15K25 度R0 是 25 度时的阻值100K 或 10K 等B 是 B 值常见 3950、3435、3380 等R 是当前阻值T 是当前开尔文温度。注意这里用 ln 是自然对数。实际用的时候B 值与温度范围有关厂家提供的 B 值通常是在 25/85 度之间测得的。如果你的测温范围很大建议用三参数 Steinhart-Hart 公式1/T A Bln(R) C(ln(R))^3A、B、C 三个参数需要从数据手册或者多点校准中获得。对大多数人来说B 值方程足够了。4.5 数据上云前的一步保存到 CSV 与串口可视化完成了定时采集数据存在哪里最简单的方案是通过串口输出在电脑上用串口助手或者脚本收集。将数据保存成 CSV 格式方便后续用 Excel 或 Python pandas 分析。在 Pico 上往文件系统写数据要注意Pico 的 flash 写入次数有限频繁写入容易磨损。每 5 秒写一次、24 小时不间断一天大约 17280 次长此以往 flash 寿命是个问题。我的建议是内存中积累数据定时批量写入或者接一个 microSD 卡模块把数据存到 SD 卡上。代码扩展方向是# 追加写入 CSV 示例 def log_temperature(temp): with open(temp_log.csv, a) as f: f.write(f{time.time()},{temp:.2f}\n)每次打开关闭文件如果采集频率高开销较大。改进做法是预打开文件句柄程序结束时 flush 并 close。在 while 循环里还要定期 flush防止断电丢失数据。5. ISR 避坑指南定时器中断里的独门心法5.1 定时器中断基本姿势用 machine.Timer 实现定时采集轮询模式简单但有个问题在主循环里 sleep 5 秒这段时间 CPU 被白白占着。如果你同时要驱动屏幕、处理用户按键、执行其他逻辑就会互相阻塞。解决办法是用定时器中断ISR。MicroPython 的 machine.Timer 在 RP2040 上使用方式如下from machine import Timer, ADC import time adc ADC(26) temperature 0.0 def adc_timer_isr(timer): global temperature raw adc.read_u16() voltage raw / 65535 * 3.3 # 简化计算假设传感器为 TMP36输出 10mV/°C 且 0°C 时输出 500mV temperature (voltage - 0.5) * 100 temp_timer Timer() temp_timer.init(period5000, modeTimer.PERIODIC, callbackadc_timer_isr) while True: # 主循环可以做别的事情 print(f最新温度: {temperature:.2f} °C) time.sleep(1)当定时器回调执行时它会打断主循环中正在执行的任何代码。这个打断是硬件级别的不受 Python 垃圾回收、MicroPython 虚拟机调度的影响。因此 ISR 里的代码必须短小精悍只做必要的事情然后尽快返回。把计算结果先放到全局变量中主循环再读取这个变量做事这样的架构是没问题的。5.2 ISR 里的大忌不要在中断中做阻塞操作这是我踩过最深的坑之一详细展开说一下。ISR 中绝对不能做以下几类事情不要调用 time.sleep() 或任何带有延时的阻塞式函数不要做 I2C/SPI 通信尤其是读取外部传感器、EEPROM、RTC 等不要做文件写入、print 串口输出等耗时的 I/O 操作不要动态分配内存比如拼接字符串、创建列表为什么不能做中断触发的本质是暂停当前正在执行的程序跳到 ISR 中去运行。如果 ISR 里有一个 sleep(0.5)整整 500ms主程序的实时性荡然无存。如果在 ISR 里读取 I2C 传感器而 I2C 总线上恰好挂着一个正在通信的外设I2C 的 SCL 时钟可能被拉低超时导致通信错乱。更危险的是如果在中断函数里动态分配内存触发垃圾回收GC而 GC 正在整理堆内存时被中断打断可能造成内存状态不一致轻则卡死重则系统崩溃。实测中最典型的问题把 print() 放到 ISR 里。Python 的 print 本质是往 UART/串口写数据UART 写数据是有缓冲区、甚至可能等待对方接收的这个过程一旦慢下来中断时间被拉长主循环可能就错过了时序要求严格的逻辑比如 PWM 的更新、按键消抖。正确的做法是ISR 里只做标志位的置位或者数据拷贝主循环检测到标志位后再做耗时操作。例子如下adc_data [] data_ready False def adc_timer_isr(timer): global data_ready, adc_data adc_data.append(adc.read_u16()) # 简单追加仍然涉及内存分配 data_ready True while True: if data_ready: data_ready False # 主循环中做计算、打印、存储 raw adc_data.pop() temp raw / 65535 * 3.3 print(f温度: {temp:.2f} °C) else: # 做其他事情 pass注意上面的adc_data.append()实际上会触发内存分配严格来说也不推荐在 ISR 里做。但如果只是追加少量数据、且缓冲区已预先分配好风险不大。更稳妥的方式是预先分配固定大小的数组用游标在 ISR 里写入import array BUFFER_SIZE 10 adc_buffer array.array(H, [0]) * BUFFER_SIZE buffer_index 0 def adc_timer_isr(timer): global buffer_index if buffer_index BUFFER_SIZE: adc_buffer[buffer_index] adc.read_u16() buffer_index 1这样 ISR 里没有内存分配、没有 print、没有额外调用速度飞快不会给主程序制造任何麻烦。5.3 时间基准的坑回调周期为什么不准定时器回调的时间并不是精确的 5 秒。MicroPython 的定时器基于底层硬件定时器但由于 Python 解释器的调度延迟、垃圾回收的暂停、ISR 本身的执行时间等因素实际回调间隔会有几毫秒甚至十几毫秒的抖动。测试方法是记录每次回调的 time.ticks_ms()然后比较间隔last_tick 0 def adc_timer_isr(timer): global last_tick now time.ticks_ms() interval time.ticks_diff(now, last_tick) last_tick now # interval 应该在 5000 附近但会有浮动经过实测5 秒定时器在 RP2040 上回调间隔的波动通常在 ±5ms 以内这对温度采集完全够用。但如果你要做需要精确定时的采样比如音频采样、示波器建议不要依赖 MicroPython 的定时器回调而要用 PIO 或者 DMA 来驱动 ADC这部分后续有机会再单独写一篇。还有一个小细节定时器回调里如果你运行的是大量浮点运算Python 解释器处理浮点数会有额外开销这会拉长 ISR 执行时间。所以计算尽量移出 ISR或者在 ISR 里只把原始 ADC 值存起来由主循环去做温度换算。5.4 中断标志位与主循环的同步竞争ISR 和主循环共享全局变量时存在一个经典的竞争问题主循环读数据的同时ISR 又写入了新数据可能导致读到半个旧值半个新值。MicroPython 在 RP2040 上运行在单线程环境正常情况下 ISR 和主循环不会真正并行但还是在主循环被中断打断的瞬间切换仍可能产生脏读。避免竞争最简单的方法就是 flag 标志位模式ISR 只把数据写入缓冲区并置位标志主循环检测到标志后先取数据再清除标志。在单核 MCU 上这种模式已经足够可靠。另外需要小心的是访问共享变量时尽量使用不可变类型赋值比如temp_value 25.6这种简单的更新不会出问题。但如果是列表、字典这种可变对象在 ISR 和主循环同时修改时建议用锁或者干脆避免在两边都修改同一个容器。5.5 不要在 ISR 里调用的函数黑名单根据我自己的踩坑经历列一个 ISR 里千万别用的黑名单函数/操作风险time.sleep()阻塞整个系统中断时间失控print() / uart.write()耗时不确定串口缓冲区满时更危险I2C/SPI 读传感器总线时序被破坏可能出现锁死文件写入open/writeflash 写入时间长且不可重入内存分配如 list.append、字符串拼接触发 GC轻则抖动重则崩溃浮点数运算拉长 ISR 执行时间但不是绝对禁止我自己有一个经验法则ISR 里只做三类事情——置标志、拷贝原始数据、启动一次性硬件外设比如 DMA。其他所有计算和 IO 全部挪到主循环。这个法则能帮我避免 90% 的嵌入式实时性问题。6. 中断版本的温度采集实例把技巧落地6.1 完整代码定时器 滑动平均 主循环输出把前面讨论的内容整合成一个完整的定时温度采集程序。这个程序的架构是定时器每 5 秒触发一次 ISRISR 里读取 ADC 原始值写入固定缓冲区主循环检测到新数据后读取缓冲区、计算温度、滑动平均、输出from machine import ADC, Pin, Timer import time import array # 全局配置 NTC_PIN 26 FIXED_RESISTOR 100_000 R0 100_000 B_VALUE 3950 BUFFER_SIZE 10 # 全局变量 adc ADC(NTC_PIN) buffer array.array(H, [0]) * BUFFER_SIZE # 无符号短整型数组 buffer_index 0 data_ready False last_time 0 def adc_isr(timer): global buffer_index, data_ready, last_time, buffer # ISR 中只做数据的快速拷贝 if buffer_index BUFFER_SIZE: buffer[buffer_index] adc.read_u16() buffer_index 1 else: data_ready True def resistance_from_ratio(ratio): if ratio 0.999: return float(inf) if ratio 0.001: return 0.0 return FIXED_RESISTOR * ratio / (1 - ratio) def temperature_from_resistance(r_ntc): import math if r_ntc 0 or r_ntc float(inf): return None t_k 1.0 / (1.0 / 298.15 (1.0 / B_VALUE) * math.log(r_ntc / R0)) return t_k - 273.15 # 初始化定时器 temp_timer Timer() temp_timer.init(period5000, modeTimer.PERIODIC, callbackadc_isr) # 主循环 temp_history [] print(开始定时采集每5秒一次...) while True: if data_ready: data_ready False # 取缓冲区中的原始 ADC 值 if buffer_index 0: total 0 count 0 for i in range(buffer_index): total buffer[i] count 1 avg_raw total / count buffer_index 0 # 计算温度 ratio avg_raw / 65535.0 r_ntc resistance_from_ratio(ratio) temp temperature_from_resistance(r_ntc) if temp is not None: temp_history.append(temp) if len(temp_history) 5: temp_history.pop(0) smoothed_temp sum(temp_history) / len(temp_history) print(f{time.time()} - 温度: {smoothed_temp:.2f} °C ({temp:.2f} °C)) else: # 主循环可以执行其他任务 time.sleep(0.5)这段代码里值得一提的地方是ISR 中对数据做批量缓冲而不是逐条标记当缓冲区满了之后才通知主循环。每 5 秒触发一次每次收集 10 个 ADC 原始读数然后取平均这样相当于做了 10 倍过采样能有效降低单次采样噪声。buffer 里存的是原始 ADC 值温度换算留在主循环完全符合 ISR 规范。还有一点buffer_index 变成了 0 之后ISR 下一次触发会从缓冲区的开头重新写入这个设计简单够用。如果你想做无缝的乒乓缓冲双缓冲可以让 ISR 在两个缓冲区之间交替写入主循环处理另一个缓冲区这样可以避免数据丢失适合采样频率较高的场景。6.2 如何验证定时精度与数据稳定性程序跑起来之后怎么判断它是不是正常工作我的做法是分三步验证。第一步验证 ADC 数据稳定性。在固定温度下比如把传感器放在一个恒温的杯子里持续采集 100 组数据计算均值、最大最小值、标准差。标准差如果在 0.1 度以内说明系统噪声在可接受范围如果波动超过 0.5 度可能是接线不良、电源噪声或者分压电阻温漂太大。第二步验证定时精度。在 ISR 里用 time.ticks_ms() 记录两次回调的间隔避免在 ISR 里 print可以把间隔值保存到全局变量主循环再输出分析间隔的抖动范围。如果抖动超过 ±10%检查主循环中是否有耗时操作影响到了 ISR 调度。第三步验证温度正确性。用一个已知温度源冰水混合物 0 度、沸水 100 度、或者腋下 36.5 度来对比传感器读数。如果偏差超过 2 度大概率是 B 值和真实 NTC 不匹配需要针对你手头这颗 NTC 校准。6.3 过采样与软件滤波不要混为一谈前面代码里做了两件事对 10 个 ADC 原始值取平均又对 5 次温度结果做了滑动平均。这两者的侧重点完全不同。过采样对 ADC 原始值取平均针对的是 ADC 本身的随机噪声。在 ADC 量化噪声近似白噪声的条件下对 4 倍数量的采样取平均有效分辨率可以提高 1 位。10 次采样取平均理论上能换来约 1.66 位的额外有效位数虽然在实际电路中受限于其他噪声源效果会打折扣但方向是对的。滑动平均对温度结果取平均针对的是传感器的响应波动和环境中温度的随机扰动。它让温度曲线变得更平滑但代价是响应变慢。5 次平均的响应时间大概是 5 秒 × 5 25 秒传感器从一处移到另一处要等大约 25 秒读数才会稳定到新值附近。如果你需要更快的响应可以减少滑动平均的窗口大小或者改用加权移动平均给最近的样本更大的权重。这个可以根据项目需求灵活调整。7. 常见问题与排查技巧实录7.1 读数一直为 0 或 4095满量程这是最常见的故障现象。读数恒为 0先查接线ADC 引脚是否接到了 GNDNTC 和固定电阻的分压节点是否真的连到了 GPIO26用万用表测一下引脚电压确认电压在合理范围。读数恒为 65535满量程几乎可以断定 ADC 输入引脚被拉到了高电平可能分压电路中没有接 GND、NTC 断路或者引脚被其他外设占用。我遇到过一个特别隐蔽的问题GPIO26 在板子上和 SPI 的 CS 引脚共用丝印区域如果你同时在扩展板上用了 SPI 设备恰好把 CS 接到了 GPIO26那么 ADC 和 SPI 就会互相干扰。7.2 读数漂移严重前一次采集影响下一次如果你做了多通道采集先读 ADC0再读 ADC1发现 ADC1 读到的值带有 ADC0 的残留电压这说明通道之间没有足够的切换稳定时间。解决方法是每次切换通道后先进行几次空读丢弃采样再读取正式数据。例如def read_adc_stable(adc_channel, samples10): adc ADC(adc_channel) # 空读几次让采样电容稳定 for _ in range(3): adc.read_u16() total 0 for _ in range(samples): total adc.read_u16() return total / samples7.3 温度偏离真实值太多如何进行三点校准NTC 的 B 值参数往往不够精确尤其是从不同供应商买来的热敏电阻B 值可能漂移。如果你需要更准确的测量建议做三点校准。方法很简单准备三个已知温度源比如冰水混合物0 度、室温用标准温度计实测、沸水100 度但注意高原地区沸点会变化。将 NTC 置于每个温度源中稳定 5 分钟后用 ADC 读数换算成电阻值记录三个温度下的真实电阻 R1、R2、R3。然后用这三组数据反解 Steinhart-Hart 方程的三个参数 A、B、C。在 MicroPython 里手动解三参数方程稍显复杂这里提供一个简化思路直接修改代码中 R0 和 B 的取值让计算值逼近标准温度计的读数。比如冰水混合物是 0 度如果代码算出 2.5 度说明 B 值偏小或 R0 偏大手动微调这两个参数反复几次就能校准到 ±0.5 度以内。更严谨的三参数标定可以在电脑上用 numpy 做曲线拟合然后把拟合得到的 A、B、C 写进代码在 Pico 端做计算。7.4 ADC 采样的建立时间效应为什么读数会滞后传感器变化ADC 输入端有一个采样保持电容每次采样时这个电容会被充电到输入电压。如果输入信号的源阻抗很高就像 NTC 分压电路的几十 K 电阻电容充电需要时间。如果采样时间太短电容还没有充到最终电压采样结果就会偏低。这解释了为什么直接接 NTC 分压电路、并且代码里不做额外等待时读到的电压会偏低。解决方案就是我在分压电路一节提到的在 ADC 引脚对地并联 100nF 电容或者多次空读给电容充电时间。使用过采样取平均的方法一部分作用就是让采样电容有足够的充电时间。7.5 定时器中断不执行或执行一次后停止如果你发现定时器回调只触发一次就不再触发首先检查你的固件版本。比较旧的 MicroPython 版本在 RP2040 上的 Timer 回调有已知问题升级到最新稳定版即可解决。其次检查代码是否存在内存泄漏ISR 里做了内存分配随着时间推移内存耗尽GC 频繁触发定时器回调被无限期推迟。尝试在 ISR 里减少对象创建按照前面推荐的固定缓冲区方案重写。另一个容易忽略的问题ISR 里抛出异常不会打印错误信息而是会静默地停止回调。你可以在 ISR 外层加一个 try-except把异常信息保存下来在主循环打印isr_error None def adc_isr(timer): global isr_error try: # ISR 内容 pass except Exception as e: isr_error e然后主循环检查 isr_error打印异常信息方便定位问题。7.6 我的经验一个完整的调试流程最后分享一个我常用的 ADC 调理流程特别适合新手。拿到 ADC 模块后不要直接接传感器先用一个干净的直流电压源或者滑线变阻器分压输入到 ADC 引脚从 0V 开始逐步加到 3.3V观察 read_u16() 的返回值是否线性变化。此步骤验证 ADC 硬件正常。然后接上传感器用万用表测量传感器输出引脚的电压和代码里换算的电压对比误差如果在 5% 以内属于正常因为万用表读的是真实电压而 ADC 读的电压受到参考电压和量化误差影响。最后再做长时间稳定性测试比如连续跑 24 小时看温度数据有没有漂移。如果漂移明显检查电源是否发热、NTC 自热是否严重、分压电阻的温漂有多大。这套流程走下来你基本能排除掉绝大多数 ADC 相关的硬件和软件问题。8. 持续进阶从定时采集到多通道数据记录仪8.1 多通道输入四路温度采集的扩展Pico 共有 3 个通用的外部 ADC 引脚ADC0~ADC2如果你需要监测多个位置的温度可以用三个 NTC 分别接三个 ADC 引脚。代码扩展非常容易创建一个 ADC 对象列表adc_pins [26, 27, 28] adcs [ADC(pin) for pin in adc_pins] while True: temps [] for adc in adcs: raw adc.read_u16() ratio raw / 65535.0 r resistance_from_ratio(ratio) t temperature_from_resistance(r) temps.append(t) print(f通道1: {temps[0]:.2f} °C, 通道2: {temps[1]:.2f} °C, 通道3: {temps[2]:.2f} °C) time.sleep(5)注意多通道采集时每次循环按顺序读三个 ADCADC 切换的时间比单通道时长一点但只要不是高速采样影响不大。如果三路传感器的响应速度差异大可以分别配置采样次数不必统一。8.2 与 WiFi/蓝牙模块组合Pico W 的上云之路如果你的板子是 Pico W带 WiFi 的版本可以把温度数据通过 MQTT 或 HTTP 发送到云平台。我自己搭过一个简单的温度上报系统每 60 秒上传一次数据到本地 MQTT broker延时稳定在 200ms 以内。核心思路是保持传感器采集和网络上传解耦定时器 ISR 采集温度存入全局变量主循环每 N 次检查后通过 WiFi 发送。不要在网络发送过程中用 ISR 采集数据否则网络协议栈的中断优先级和占用时长可能导致采集时序错乱。这里顺便提一个 Wi-Fi 干扰 ADC 的问题Pico W 的 WiFi 射频工作时会对模拟前端造成电磁干扰。实测中关闭 WiFi 时 ADC 读数波动 ±3 LSB开启 WiFi 并收发数据时波动增大到 ±8 LSB。如果你在 Pico W 上做高精度 ADC 采集建议在采集期间暂时关闭 WiFi或者对 ADC 输入加 RC 滤波器。8.3 低功耗设计让电池供电的采集设备跑得更久Pico 的 ADC 本身功耗不大整个系统的耗电大头是 Pico 板载的 LED、电源调节器以及你的分压电路。NTC 分压电路虽然电流很小3.3V / 200K ≈ 16.5μA但如果要追求极致续航还是应该在测量时给分压电路供电、平时断开。利用 Pico 的 GPIO 输出高电平给分压电路供电然后采样再拉低断电是常见的低功耗设计。配合 Pico 的 light sleep / deep sleep 模式可以做到每 60 秒唤醒一次、测量 10ms、然后继续睡理论平均电流能压到几十微安。8.4 一个更稳的方案外部 ADC 什么时候值得换如果你尝试了所有办法ADC 数据仍然满足不了你的精度要求比如需要测量 1mV 级别的变化那就不要再跟 Pico 内置的 12 位 ADC 死磕了外接一颗 ADS111516 位、I2C 接口或者 HX71124 位、专门针对称重传感器是更务实的选择。ADS1115 自带可编程增益放大器PGA最高可测量 ±256mV 的小信号而且抗干扰性能比 Pico 内置 ADC 好很多。代码也简单用 Adafruit 的驱动库几行就能读取。虽然这会占据一个 I2C 接口但换来的精度提升是非常值得的。用外部 ADC 时ISR 的注意点依然有效不要在中断里发起 I2C 读取。正确做法是定时器唤醒标志位主循环里发起 I2C 读取然后记录数据。8.5 我的扩展推荐做一个 2 通道温度记录仪如果你看完文章想动手做个完整的小项目我推荐做一个 2 通道温度记录仪。硬件很简单Pico 2 个 100K NTC 2 个 100K 电阻 一个小 OLED 屏幕SSD1306I2C。功能是每 2 秒采集两路温度、在 OLED 上显示实时曲线或数值、按按键把当前数据存到 flash 的 CSV 文件里、再接一个 USB 串口输出数据。这个项目能把 ADC 定时器 ISR I2C 文件系统全部串起来做完之后你对 Pico 的掌握会有一个质的提升。而且它有真实的使用场景——比如监测室内外温度差、冷柜化霜周期、养殖水箱温度变化等做完就能用上。9. 写在最后的实操心得Pico 的 ADC 说简单也简单读个电压也就两行代码说复杂也复杂一旦涉及到精度、时序、中断各种意想不到的坑就会冒出来。就我自己的经验来看最重要的一点是永远不要跳过硬件验证就直接写代码。先用万用表把各路电压量清楚再写软件问题会少一半。第二个体会是ISR 里少做事、慢做事、不做耗时的事这个原则我在树莓派 Pico、STM32、ESP32 上都验证过通用性极强。最后一个建议是ADC 数据的准确性是靠测出来的不是靠算出来的。同样的 NTC、同样的电路不同批次器件的参数差异可能很大拿到新传感器时先做一次温度对比实验心里才有底。