
树莓派 Pico 这块小板子我前后用了两年多从最初只会点亮 LED到后面拿它做温湿度记录仪、小型温控系统中间绕了很多弯路。每次在网上搜“Pico ADC 采集”出来的要么是几行官方示例要么是某个论坛里语焉不详的帖子真正把 machine.ADC API、定时采集和中断处理串起来讲清楚的几乎没有。这篇内容我打算一次性说透从 ADC 的硬件原理、machine.ADC 每个方法怎么用到一个真实可用的定时温度采集程序再到中断服务函数里那些让人头疼的坑。这篇内容适合这么几类人手里有 Pico 但不知道怎么写 ADC 采集的入门玩家已经能跑通一次采样、想做得更稳更专业的进阶用户以及在定时采集过程中遇到中断卡死、数据漂移等问题、正在到处找答案的实战派。读完你能得到的是一套可以直接抄走、改一改就能跑的代码以及我在真实项目里踩过的各种雷。1. 项目定位与整体设计思路1.1 标题拆解表面是 API实则是三层需求先把这个标题拆开看。表面上它讲的是 machine.ADC API但仔细分析会发现这里其实压缩了三层需求。第一层是理解 ADC 本身它负责把物理世界的模拟电压转换成数字量这是所有传感器采集的起点。第二层是掌握 MicroPython 在 Pico 上的 ADC 调用方式API 用得对不对直接影响采集精度和代码稳定性。第三层才是定时温度采集温度不是读一次就完事的实际项目里往往需要每隔几秒、几分钟甚至按天记录这背后涉及定时机制而定时器回调又天然和中断绑定在一起。换句话说ADC 是硬件基础API 是操作手段定时采集是工程目标。很多教程只讲中间一层导致读者抄完代码后一换场景就懵。比如把采样周期从 1 秒改成 10 毫秒原代码就撑不住了比如想边采集边用 OLED 显示结果发现 sleep 把整个程序堵死。这些都是因为没把三层逻辑打通。所以这篇内容不会只讲“怎么调 API”而是顺着“硬件原理 - API 使用 - 定时采集 - 中断避坑”这条线一路走到底。1.2 方案选型为什么是 Pico 而不是其他开发板选择树莓派 Pico 做这类项目有它非常现实的理由。首先是硬件层面RP2040 芯片自带 3 个外部 ADC 通道GP26、GP27、GP28还集成了一个内部温度传感器通道。也就是说你不用额外买任何温度模块就能直接做入门级温度采集这对新手来说非常友好。其次是软件层面MicroPython 把原本需要查寄存器手册的 ADC 操作封装成了几个简单函数学习曲线很平缓。但这里有个反直觉的点越是方便越容易让人掉以轻心。因为 machine.ADC 的 API 调用太简单了很多人在代码里随手一写就完事没想过底层发生了什么。等需求升级到定时采集、多传感器切换、低功耗运行时才发现原来的代码根本扛不住。我见过不少人在论坛上提问说“同样的代码为什么改了引脚就读不到数”“为什么加了定时器之后程序频繁重启”这些问题绝大多数都源于对 ADC 和中断机制的理解不够扎实。所以如果你打算长期用 Pico 做传感类项目花时间把这两块底层逻辑吃透远比多抄几段代码划算。1.3 内容组织逻辑先把地基打牢再谈进阶这篇内容的组织方式遵循的是“从原理到代码从简单到复杂”的递进路径。前面先讲 ADC 的硬件原理和 machine.ADC 的完整 API让你明白每一次 read_u16() 背后发生了什么接着用板载温度传感器做实战从读一次温度开始逐步升级到定时采集然后再引入中断处理把它单独拎出来讲是因为定时器回调和 ISR 是这个项目里最容易出问题的地方最后汇总一批我实际踩过的坑和排查方法方便你未来遇到类似问题时按图索骥。这样走下来即使你是第一次接触 Pico也完全跟得上。2. machine.ADC API 深度拆解不只是 read_u16()2.1 ADC 基础原理它就是一把电压尺子ADC全称 Analog-to-Digital Converter模拟数字转换器。它的工作方式可以理解成一把带刻度的尺子输入引脚上有一个模拟电压ADC 把 0V 到参考电压之间的整个范围分成很多个小格然后判断当前电压落在哪个格子里输出一个对应的数字。Pico 用的 RP2040 芯片ADC 分辨率是 12 位也就是能把电压范围分成 2^12、即 4096 个刻度。这里有一个最容易让新手懵的地方MicroPython 的 machine.ADC 在 Pico 上调用 read_u16() 时返回值的范围是 0 到 65535也就是 16 位。硬件明明是 12 位为什么返回 16 位的结果原因很简单这是 MicroPython 为了保持不同开发板 API 一致做的处理它把 12 位结果左移到了 16 位的语义空间。换句话说你读到的 65535 不代表硬件真的能分辨出 65535 个等级实际分辨率仍然受限于 12 位 ADC 的物理精度也就是约 3.3V / 4096 ≈ 0.8mV。搞清楚这一点你在后续做电压换算的时候就不会犯“直接除以 65535 再乘 3.3然后误以为精度很高”的低级错误。2.2 通道与引脚对应关系别接错线RP2040 的 ADC 通道和引脚对应关系是固定的接错线就读错数。我整理了一个速查表建议直接存起来ADC 通道对应引脚用途说明ADC0GP26外部模拟输入ADC1GP27外部模拟输入ADC2GP28外部模拟输入ADC3板载 VSYS 分压测量系统供电电压ADC4内部温度传感器芯片内部温度这里要特别提一下 ADC3。在 Pico 板子上VSYS 电压经过两个电阻分压后接到 ADC3所以理论上你可以通过它读取电池或 USB 输入电压。不过 ADC3 能测量的电压范围受分压比限制而且在 MicroPython 里并不是所有固件版本都开放了 ADC3 的直接读取实际使用时需要先确认固件行为否则容易报错或读到恒定值。我的建议是日常项目里优先使用 ADC0、ADC1、ADC2 三个外部通道把内部温度传感器 ADC4 当作低成本温度参考。2.3 创建 ADC 对象与两个核心读值方法在 Pico 的 MicroPython 固件里创建一个 ADC 对象有两种常见写法。第一种是直接传引脚对象import machine adc machine.ADC(machine.Pin(26)) # GP26 对应 ADC0第二种是直接传通道编号adc machine.ADC(0) # 同样表示 ADC0两种方式的结果一致我习惯用第一种因为传引脚对象更直观。读值的核心方法有两个read_u16() 和 read_uv()。read_u16() 返回一个 0 到 65535 的整数这是最通用、最常用的 API。read_uv() 直接返回微伏为单位的电压值省去了手动换算的步骤。raw adc.read_u16() # 返回 0~65535 的原始读数 voltage_uv adc.read_uv() # 返回微伏电压如 1650000 表示 1.65V不过 read_uv() 在不同固件版本里的存在性不完全一致。我在一些较老的 Pico MicroPython 固件上运行 read_uv() 时就遇到过 AttributeError。为了保证代码的可移植性我几乎总是用 read_u16() 配合手动换算。手写公式还能让后续校准更灵活比如想在换算公式里加入修正系数直接改常量就行不用去动 API 调用层。注意Pico 的 ADC 引脚输入电压上限是 3.3V。如果被测电压超过 3.3V必须先经过电阻分压或者运放调理电路否则轻则读数异常重则直接烧坏引脚。这个坑我在早期项目里踩过一次后果就是报废了一个 ADC 通道。2.4 电压换算与原值解读从数字量回到物理世界read_u16() 返回的原始值本身没有物理意义必须和参考电压关联起来。Pico 上 ADC 的参考电压是 3.3V所以电压值的换算公式是电压值 raw × 3.3 / 65535对应代码如下voltage raw * 3.3 / 65535举个例子如果 read_u16() 返回 32767那么电压大约是 1.65V正好是 3.3V 的一半。如果你的传感器输出是线性的比如 0V 对应 0 度、3.3V 对应 100 度就可以进一步把电压值映射到物理量。温度传感器的情况略有不同因为它输出的是电压随温度线性下降后面我会单独展开。还有一个容易忽略的细节是输入阻抗。ADC 引脚内部有一个采样保持电路如果输入源阻抗过高采样电容在极短的采样时间内充不满读数就会偏低。普通温度模块的输出阻抗一般很低问题不大但如果接的是高阻输出的传感器比如某些电化学电极或者纯粹用电阻分压出来的高阻信号就需要在中间加一级运放缓冲。这个问题在教材里很少被提到但实际工程中非常影响测量准确性。3. 定时温度采集实战从读一次到连续记录3.1 板载温度传感器的原理与局限RP2040 芯片内部集成了一个温度传感器它被连接到第 4 个 ADC 通道上。在 MicroPython 里创建指向内部温度传感器的 ADC 对象代码是这样的import machine sensor_temp machine.ADC(4) conversion_factor 3.3 / 65535这个传感器的输出电压和温度呈近似线性关系。根据芯片手册给出的模型在 27 摄氏度时输出电压约为 0.706V温度每升高 1 摄氏度电压下降约 1.721mV。换算公式如下reading sensor_temp.read_u16() voltage reading * conversion_factor temperature 27 - (voltage - 0.706) / 0.001721注意这个传感器和常见的 NTC 热敏电阻完全不同。NTC 的阻值随温度变化需要搭配分压电路读取而这里的内部传感器是直接输出线性电压的硅基温度传感器不需要外部电路用起来非常省事。但省事不代表精准它的绝对误差通常在正负 2 到 3 摄氏度左右。我们拿它做环境温度的趋势监控、判断热失控、或者做粗粒度温控是够用的但如果要拿它当医疗级体温计或者实验室温度基准那就不现实了。3.2 先跑通基础代码读一次温度并打印万事开头难但 Pico 读温度的开头确实不难。先把最基础的版本跑通import machine import time sensor_temp machine.ADC(4) conversion_factor 3.3 / 65535 while True: reading sensor_temp.read_u16() voltage reading * conversion_factor temperature 27 - (voltage - 0.706) / 0.001721 print(ftemperature: {temperature:.2f} degC) time.sleep(1)把这段代码保存为 main.py 扔到 Pico 上跑起来打开串口监视器就能看到每秒输出一行温度值。功能很简单但它是后面所有进阶写法的地基。如果这一步就跑不通先检查三件事固件是否刷成了 MicroPython、串口连接是否正确、代码里的除法结果是不是因为整数运算出了问题。MicroPython 里默认的除法和浮点运算通常没问题但如果你在别的环境里跑 Python 2 风格的代码三个整数相除可能会被截断那就不是 Pico 的问题了。3.3 从 sleep 到 Timer定时周期为什么不能靠 sleep基础版代码用 time.sleep(1) 实现了“大约每秒读一次”的效果但这不算真正的定时采集。原因有两个。第一sleep 会阻塞整个主线程sleep 期间程序什么都干不了如果项目里还要驱动 OLED、处理按键、上报网络数据这种写法直接把系统堵死。第二sleep 的定时精度并不高。print 本身是耗时的串口输出在低速波特率下尤其慢循环一次实际花费的时间是“读取耗时 计算耗时 print 耗时 sleep 1 秒”累积下来真实采样周期会比 1 秒长不少。所以做定时采集更专业的做法是用硬件定时器。Pico 的 machine.Timer 模块提供了周期中断功能from machine import Timer tim Timer() def tick(t): print(timer tick) tim.init(freq1, modeTimer.PERIODIC, callbacktick)这段代码会让 tick 函数每秒被调用一次。注意这里的 freq1 表示 1Hz也就是每秒一次。如果你想每 100ms 采一次样就用 freq10每 10ms 一次就用 freq100。Timer.PERIODIC 表示周期触发对应地还有一个 Timer.ONE_SHOT 模式适合做延时执行。但这里引出了一个关键问题tick 函数到底运行在什么上下文里很多人忽视这一点而它恰恰是整个项目最关键的陷阱之一。3.4 采样滤波平均值和滑动平均怎么选在进入定时器中断之前先把滤波这层补上。ADC 采集本身有噪声内部温度传感器的读数也存在小幅抖动单次读取值往往不够稳定。最简单的处理是连续多次采样取平均def read_temperature_averaged(samples10): total 0 for _ in range(samples): reading sensor_temp.read_u16() voltage reading * conversion_factor total 27 - (voltage - 0.706) / 0.001721 time.sleep_ms(10) # 每次采样之间留一点间隔 return total / samples为什么采样之间要加 10ms 间隔因为 ADC 的采样保持电容需要时间重新充电连续高速读取时后一次读数可能受前一次残留电荷影响加一点间隔能获得更独立的样本。另一个更优雅的方案是滑动平均维护一个固定长度的队列每次读入新值就淘汰最旧的值再计算平均值。温度变化相对缓慢滑动窗口取 5 到 10 个点就能兼顾响应速度和稳定性。我的经验是对温度这种慢变量滤波窗口别太长否则温度真实变化也会被“磨平”掉系统反应太迟钝。4. ISR 避坑指南定时器回调里最容易翻车的地方4.1 先搞清楚定时器回调就是一次中断处理很多人觉得 Timer 的 callback 只是“到了一个时间点执行一下”但它的真实身份是一次硬件中断触发的服务例程也就是 ISR。这意味着 callback 和你主循环里的普通函数运行环境完全不同。回调执行期间主循环会被打断其他中断也可能被延迟响应如果回调执行时间过长它甚至会影响系统的实时性。中断处理函数最核心的原则就四个字短小精悍。中断越短系统响应其他事件的能力越强。很多新手在回调里写一堆代码看起来功能正常但一旦采样频率提高、或者有其他中断需要响应系统就变得迟钝甚至崩溃。理解这一点是避开后面所有坑的前提。4.2 在回调里做重活会发生什么具体来说回调里不能做的事包括不能长时间阻塞等待不能做大量内存分配不能执行可能触发垃圾回收的操作不能无限循环。下面这段代码就是典型的“看着没事实际上很危险”def bad_callback(t): # 反例在回调里做重活 total 0 for _ in range(10): reading sensor_temp.read_u16() voltage reading * conversion_factor total 27 - (voltage - 0.706) / 0.001721 time.sleep_ms(20) print(ftemp: {total / 10:.2f}) time.sleep(0.5)这段代码放进 Timer 回调后表面看能工作但隐患非常大。time.sleep_ms 会让回调阻塞几十甚至几百毫秒期间主循环被完全暂停其他中断也没法及时响应。如果定时器周期很短前一个回调还没执行完后一个中断又触发了MicroPython 会按照底层机制决定是丢弃还是排队最终表现就是采样周期忽长忽短数据乱掉。更隐蔽的问题在于内存分配。MicroPython 在中断上下文里使用 Python 的 list、dict 等容器做修改操作时可能会触发内存分配而内存分配又可能触发垃圾回收这是中断上下文里最危险的操作之一。garbage collector 一旦在中断里运作整个系统的实时性就完全失控了。4.3 经典解法回调只置标志位重活留给主循环正确的做法是把回调做得极其轻量只用它更新一个标志位或简单计数把真正的采集、计算、打印全部放到主循环里执行。下面是一个完整的温度定时采集示例这已经是一个接近生产环境的架构了import machine import time from machine import Timer sensor_temp machine.ADC(4) conversion_factor 3.3 / 65535 sample_flag False def timer_callback(t): global sample_flag sample_flag True def read_temperature(): total 0 samples 5 for _ in range(samples): reading sensor_temp.read_u16() voltage reading * conversion_factor total 27 - (voltage - 0.706) / 0.001721 time.sleep_ms(10) return total / samples tim Timer() tim.init(freq1, modeTimer.PERIODIC, callbacktimer_callback) while True: if sample_flag: sample_flag False temperature read_temperature() print(ftemp: {temperature:.2f} degC) # 在这里可以保存到文件、判断温度阈值、上报服务器等这段代码的精髓在于职责分离硬件定时器负责精准地每隔 1 秒把 sample_flag 置为 True这个置位操作本身只需要几个机器周期完全符合 ISR 短小的原则真正的温度采样、平均、打印则交给主循环。即使打印耗时 200ms也不会影响下一次定时器触发因为中断并不会因为主循环忙而停止计时。4.4 回调里绝对不要碰的清单基于实际项目里的教训我整理了一份回调内操作黑名单凡是碰过这些操作的出现过问题time.sleep 或 time.sleep_ms任何形式的阻塞等待都不要出现在回调里。大规模字符串拼接或者复杂格式化。MicroPython 下字符串是不可变对象拼接会反复创建新对象触发内存分配。动态创建 list、dict 并往里塞数据同理可能触发垃圾回收。长时间的文件写入比如在回调里打开文件写一行数据再关闭整个过程可能耗时数毫秒到数十毫秒。无限循环或者依赖外部状态才能退出的逻辑一旦死循环整个 Pico 就当场罢工。如果你在回调里只是给一个预分配好的 bytearray 某个索引赋值那是安全的如果你在回调里执行 print多数情况下也能跑但多个中断同时使用串口输出时会发生输出互相覆盖造成乱码。所以我的建议是回调里干脆什么都别做只置标志位所有输出和逻辑都放到主循环。5. 常见问题与排查技巧实录5.1 读数漂移供电和参考电压是第一嫌疑实际项目中我遇到过 ADC 读数莫名其妙漂移的情况最典型的诱因是供电质量。用 USB 直连电脑供电时读数相对稳定换到某些纹波较大的手机充电器供电后ADC 读数开始上下抖动尤其在系统里同时有电机、舵机这类大电流负载时漂移更明显。Pico 板载的 3.3V 稳压芯片能滤掉一部分纹波但如果你的 ADC 基准电压直接从 3.3V 电源轨取电源质量还是会直接影响转换结果。解决思路有两个。第一在 ADC 参考电压引脚和地之间加一个低 ESR 的滤波电容常见做法是并联一个 0.1uF 陶瓷电容和一个 10uF 钽电容。第二如果项目对精度要求很高考虑用独立的电压基准芯片给 ADC 提供参考电压。另外ADC 采样前的信号路径上如果有长导线也容易耦合噪声可以用双绞线或者在引脚附近加一个 RC 低通滤波截止频率根据你的信号带宽来定。5.2 温度不准内部传感器的线性校准姿势内部温度传感器出厂时并不保证高精度实际测下来偏差 2 到 3 度很正常。如果项目对绝对温度有要求我的建议是别纠结出厂的理想公式直接做一次现场校准。方法不复杂找一支校准过的数字温度计把 Pico 和温度计放在同一个恒温环境里记录 3 到 5 个温度点下的 Pico 读数和温度计读数然后用线性拟合算出修正系数。举个例子如果在 25 度时 Pico 读数是 26.5 度在 40 度时读数是 41.2 度那就能算出近似增益误差约 1.06偏移约 0.75 度。在代码里把计算结果做一次线性变换即可corrected_temp (measured_temp - 0.75) / 1.06这种校准思路不只适用于内部温度传感器几乎适用于所有廉价传感器。做数据采集项目多校准一次数据质量就能上一个台阶。5.3 定时周期错位主循环被阻塞的后果采用标志位架构之后还有一个容易忽略的边界情况如果主循环在检测到 sample_flag 之后、清零之前被某个长任务卡住那么定时器可能在此期间再次触发回调又一次把标志位置为 True。此时主循环恢复后只清一次标志就会漏掉“这一秒”的采样。表面现象是采样总数少于预期数据时间戳对不齐。解决办法有两个。一是把主循环里所有可能耗时的操作都严格控制时长确保单次循环时间远小于定时周期二是在检测到标志后立即清零而不是处理完全部逻辑后再清while True: if sample_flag: sample_flag False # 先清零再干活 temperature read_temperature() process_and_store(temperature)先清零再处理即使处理过程耗时超过一个周期也不会造成采样事件丢失积累。代价是如果有一次处理时间过长中间那次标志就“白触发”了但从数据完整性的角度看漏一次比积压一堆未处理的标志位更安全。5.4 常见问题速查表现象可能原因解决办法读值始终接近 0 或 65535引脚接线错误、未接有效输入检查通道对应引脚连接实际模拟信号读数波动大、跳变厉害电源纹波、传感器噪声多次采样取平均加强滤波电容温度读数明显偏高或偏低内部传感器出厂误差用外接温度计做线性校准Timer 回调里 print 输出乱序回调与主循环同时抢串口回调只置标志位print 移到主循环回调里 sleep 后程序卡死ISR 上下文里做了阻塞操作把耗时操作移出回调采样周期越拉越长主循环内 print 或文件写入耗时过长缩短主循环耗时或改用缓冲队列异步处理结尾一点真实的项目体会这篇文章算是我把过去踩过的坑和整理过的代码一次性倒了出来。树莓派 Pico 的 ADC 功能在硬件层面上并不复杂真正决定项目成败的往往是代码组织方式和对中断机制的理解程度。如果你照着上面的思路先从读一次温度开始再逐步改成定时器加标志位的架构后续即使换成土壤湿度传感器、光敏电阻这类模拟量采集也能很快迁移过去。我自己做小型温控系统时就是靠这套架构在几块板子之间快速切换传感器而不用每次推倒重写代码。最后再分享一个小技巧写完定时采集程序后用一条打印语句把“循环实际耗时”打出来看一次比如在每次进入循环体时记录 machine.ticks_ms()退出时再算差值。你会很直观地看到 sleep 版本和 Timer 加标志位版本之间的巨大差异这个体验比任何文档都更能帮你建立对定时采集的直觉。