树莓派Pico ADC实战:从read_u16到定时温度采集与ISR避坑指南

树莓派Pico ADC实战:从read_u16到定时温度采集与ISR避坑指南 去年年底我用树莓派 Pico 做一个小型温湿度记录仪原本以为最麻烦的是传感器选型结果真正把我按在地上摩擦的是machine.ADC这套看起来简单到不行的接口。read_u16()三秒钟就能跑通但等到要上定时采集 中断回调这种正规架构时问题一个接一个地冒出来通道编号对不上、温度公式算出来离谱、ISR 里一print就 MemoryError、定时器回调里做浮点运算温度值满天飞。这篇文章就是围绕 Pico 的 ADC 软件控制从machine.ADCAPI 的底层逻辑讲起一路写到定时温度采集的完整实战最后把 ISR 里我踩过的坑、排查思路、验证过的解法全部摊开。适合刚入门 Pico 的嵌入式新手也适合已经在用 MicroPython、但想搞明白中断上下文到底有什么限制的老手。1. 先把 RP2040 这颗 ADC 的底细摸清楚很多时候代码写得没问题数据却不对根源在于对硬件本身的理解有偏差。Pico 上的 ADC 不是一颗万能采样器它有一堆隐藏的性格摸清了才不会在后面被坑。1.1 SAR 架构、5 个待选通道与 48MHz 时钟RP2040 内置的是一颗 12 位逐次逼近型SARADC。所谓逐次逼近通俗地讲就是内部放了一个 DAC 和一个比较器从最高位开始一位一位试先假设电压是量程一半拿 DAC 生成的电压和输入比大了就把这一位清零、小了就保留然后继续试下一位。12 位精度就要比 12 次所以转换时间大致是固定的没有流水线延迟这是 SAR 的优点缺点是对输入源阻抗敏感因为内部采样保持电容很小如果你直接在引脚上挂一个几百 kΩ 的高阻分压器采样瞬间电容充不满读数会系统性偏低。这解释了为什么很多人拿电位器分压测电压数值总是对不上。通道方面Pico 实际引出了 3 个外部模拟输入GPIO26、GPIO27、GPIO28分别对应硬件通道 0、1、2。另外还有一个内部温度传感器通道。还有个细节ADC 的时钟来自 USB PLL典型值是 48MHz完成一次转换大约需要 96 个 ADC 时钟周期算下来单次硬件转换约 2µs理论极限约 500kSPS。记住这个数字后面我要说为什么实际根本跑不到。1.2 通道编号的怪癖为什么温度传感器是 ADC(4)这是新手最容易懵的地方。硬件上温度传感器通道明明是第三个通道但你在 MicroPython 里必须写machine.ADC(4)才能读它。原因在于 RP2 移植版把 ADC 构造函数的第一个参数设计成两用传 26、27、28 这类数字时它被当成 GPIO 编号走adc_gpio_init分支去初始化对应引脚而4是一个预留的魔法值专门用来打开温度传感器走的是adc_set_temp_sensor_enabled分支。也就是说这里不是硬件通道号的直观映射而是移植层为了方便用户直接传引脚号而做的特殊约定。所以我在代码里从不用裸数字而是用带注释的常量ADC_EXTERNAL_0 26 # GPIO26硬件 ADC 通道 0 ADC_TEMP_SENSOR 4 # 特殊通道内部温度传感器像这样把编号语义固定下来过两个月回看代码不会犯糊涂。1.3 read_u16() 返回的其实不是 12 位数据read_u16()返回的是 0~65535 的整数很多人因此误以为 Pico 的 ADC 是 16 位分辨率。实际上硬件只有 12 位MicroPython 把 12 位结果左移了 4 位搬到 16 位刻度上展示。这么设计是为了让不同开发板之间的 API 归一化不管底层 ADC 是 10 位、12 位还是 16 位上层都能用read_u16()拿到一个统一刻度。如果你想要原始 12 位值右移 4 位就行raw12 adc.read_u16() 4这个细节在做滤波、FFT或者拿数据手册里的表格对照时尤其重要。否则你用 16 位刻度值直接代进温度公式结果偏到姥姥家还以为自己公式抄错了。2. machine.ADC 核心 API 用法与实测速度API 本身很简单但简单和理解它是两回事。我建议你把read_u16()、read_uv()和采样耗时这三件事放在一起学因为它们共同决定了你能拿 ADC 做什么、不能做什么。2.1 三行代码读电压import machine adc machine.ADC(26) # GPIO26 voltage_v adc.read_uv() / 1_000_000 # 返回微伏转成伏 print(voltage_v)read_uv()是比read_u16()更省事的接口它直接返回微伏省得你自己拿参考电压换算。它内部还是走同样的 12 位采样只是帮你完成了从原始值到电压的定标精度没有提升也没有损失。对于只需要大概看一下电压的场景这个接口最直接。2.2 read_u16() 和 read_uv() 到底怎么选两者底层是一样的采样过程区别在于你拿数据来干什么。方法返回适合场景read_u16()0~65535 整数滤波、平均值、环形缓冲、相对比较、存储read_uv()微伏整数直接换算电压、套用传感器公式我个人的习惯是凡是数据要进缓冲区做后续处理的一律用read_u16()因为整数运算快、占内存小、不会产生浮点误差只有到最终换算时才把平均值转成电压或温度。反过来如果你只是临时量一下电池电压直接用read_uv()最省心。2.3 采样周期实测Python 开销才是真瓶颈前面说硬件理论极限 500kSPS但那只是 ADC 模块本身的转换速度。MicroPython 里做一次read_u16()实际包含函数调用开销、寄存器读写、等待转换完成的忙轮询开销远大于 2µs。我在 125MHz 的 Pico 上跑过下面这个测量脚本import machine import time adc machine.ADC(26) N 2000 t0 time.ticks_us() for _ in range(N): adc.read_u16() t1 time.ticks_us() dt time.ticks_diff(t1, t0) print(单次 read_u16 平均耗时: {:.1f} us.format(dt / N))实测下来单次调用大约在 12~20µs 之间对应纯 Python 循环里的有效采样率也只有 5 万次每秒左右。这意味着如果你要做 16 次平均一次平均后的读数就要花掉两三百微秒如果要在主循环里同时做多个通道、日志、显示有效采样率还会继续掉。而对于温度这种慢变量这个速度完全够用。真正要警惕的是你不能拿它去采音频或做高速振动分析——那是另一个量级的需求后面我会给出一条升级路线。3. 内部温度传感器读数与校准实战Pico 最方便的一点是板上自带温度传感器不需要外接任何东西就能测芯片附近温度。但它不是为高精度计量设计的想拿到靠谱的数值必须理解官方公式背后的物理模型再配合校准。3.1 官方公式与电压随温度下降的直觉RP2040 内部温度传感器的输出是一个随温度近似线性变化的电压数据手册给出的模型是27°C 时输出约 0.706V斜率约 1.721mV/°C。注意符号——温度升高时电压是下降的。所以官方推荐公式是temperature 27.0 - (voltage - 0.706) / 0.001721这个减号是很多人抄错的地方。我做过最简单的验证用手指按住芯片两三秒read_u16()的读数应当明显变小说明电压在下降。如果按上去读数反而变大说明你把公式方向写反了。3.2 用 read_uv() 把公式写干净不建议每次调用都新建 ADC 对象那会白白增加创建对象的开销。把 ADC 对象放在模块级别一次性创建换算函数只做数学运算import machine _sensor machine.ADC(4) # 模块级只创建一次 def read_temp_celsius(): uv _sensor.read_uv() v uv / 1_000_000 return 27.0 - (v - 0.706) / 0.001721这种方式既直观又避免在循环里反复初始化外设。3.3 三点校准法把 ±2℃ 误差压到 ±0.3℃数据手册给的温度传感器典型精度是 ±2°C这个误差主要来自芯片之间的个体偏移。好消息是同一颗芯片上偏移往往是系统性的可以通过校准大幅修正。我的做法是三点校准 线性插值具体分几步。准备一支可信的参考温度计水银温度计、工业 PT100 探头都行分别测三个参考点冰水混合物约 0°C、室温约 25°C、沸水约 100°C注意安全。在每个点把 Pico 放进去稳定几分钟等读数不再漂移后记录本机温度。假设我得到这样一组数据校准点参考温度本机读数偏差冰水0.01.81.8室温25.026.51.5沸水100.0102.22.2先用两个点做线性校正最常用的是室温 沸水这两点计算增益和偏置gain (100.0 - 25.0) / (102.2 - 26.5) # 参考温升 / 读数温升 offset 25.0 - gain * 26.5 def corrected(temp_reading): return gain * temp_reading offset如果希望 0~100°C 全范围误差更均匀就用三个点做二阶拟合公式是corrected a * T^2 b * T c。你可以在 PC 端用 numpy 的np.polyfit([1.8, 26.5, 102.2], [0.0, 25.0, 100.0], 2)得到系数然后把系数写死在固件里。我实测下来两点线性校正后中段误差能压到 ±0.3°C 以内三点二阶拟合在全范围也基本能守住 ±0.3°C。对于大多数环境监测场景这个精度已经够用了。4. 定时采集的正确姿势定时器 ISR 架构温度采样不是越快越好而是间隔要准。如果你在主循环里while True:加sleep()每次循环里做的事情一旦变多比如print、写文件、刷新屏幕实际采样间隔就会飘。这种抖动对慢速温度记录来说可能还能忍但对采样节奏严格、每个点代表固定时间间隔的数据分析场景就是个隐患。4.1 为什么不能用 while sleep 硬扛MicroPython 主循环的周期性受三样东西干扰解释器字节码执行时间不稳定、垃圾回收偶尔卡顿、串口print在缓冲区满时会阻塞。综合下来抖动几十毫秒很正常。要拿到像样的时间序列正确做法是把产生数据和消费数据拆开硬件定时器负责在每个固定周期触发一次采样采样结果放进缓冲区主循环只负责取走数据、换算、输出。这样即使主循环偶尔被print卡住采样时刻依然是准的丢点也只丢消费端的点。4.2 Timer 回调就是 ISR在 RP2 移植版里machine.Timer的回调是从硬件定时器中断上下文直接分发的也就是说你在回调里写的每一行代码本质上都是中断服务程序ISR。它和普通 Python 函数有本质区别不能安全地做内存分配比如创建 list、字符串格式化、print动态拼接都可能触发 MemoryError运气差直接崩。必须短平快执行时间超过下一个定时周期就会丢中断或造成周期漂移。浮点运算虽然能用但开销不可控能不用就不用。理解这一点你就能明白为什么很多网上抄来的定时采样代码会莫名其妙崩溃。4.3 预分配 环形缓冲ISR 与主循环的干净接口ISR 里只需做三件事连续采样取平均、把结果写进预分配的数组、更新指针并置一个标志。所有对象都在启动时创建好ISR 里不产生任何分配。下面是核心框架import machine import array PERIOD_MS 500 # 每 500ms 采一次 RING_LEN 360 # 环形缓冲长度能存 360 个点 N_AVG 16 # 每次采 16 个原始读数取平均 _sensor machine.ADC(4) # 温度传感器通道 ring array.array(H, [0]) * RING_LEN # 预分配ISR 外创建 head 0 count 0 new_data False def _isr_temp(timer): global head, count, new_data total 0 for _ in range(N_AVG): total _sensor.read_u16() avg total // N_AVG ring[head] avg head (head 1) % RING_LEN if count RING_LEN: count 1 new_data True _timer machine.Timer() _timer.init(periodPERIOD_MS, modemachine.Timer.PERIODIC, callback_isr_temp)为什么用array.array(H)而不是 list因为H是 16 位无符号整数array在创建时就一次性分配好连续内存ISR 里往里面写值不会触发堆分配而 list 在扩容或写入新对象时可能分配内存这在 ISR 里是禁区。ISR 的耗时预算也要心里有数。按单次read_u16()约 15µs 算16 次平均大约 240µs在 500ms 周期里只占 0.05%非常安全。如果你把N_AVG提到 1000ISR 就要跑 15ms虽然还是小于周期但这个时间预算必须算清楚否则叠加其他中断就会出事。主循环这边就轻松了只做取数和浮点换算while True: if new_data: new_data False latest (head - 1) % RING_LEN u16_avg ring[latest] temp 27.0 - (u16_avg * (3.3 / 65535.0) - 0.706) / 0.001721 print({:.2f}.format(temp))这里把浮点运算全部放在主循环ISR 只做整数累加和整数除法两者职责非常干净。5. ISR 避坑指南那些让 Pico 直接 Reset 的写法这一节是整篇最值钱的部分。下面每个坑我都实际踩过也帮别人排查过现象、原因、解决方式一次说清。5.1 坑一ISR 里创建对象或 print直接 MemoryError很多人第一次写定时回调时会顺手在回调里写print(temp , temp)或者把采集值append到一个 list。刚开始可能没事跑几分钟后突然出现MemoryError: memory allocation failed, allocating 56 bytes原因很明确MicroPython 的print和 listappend都可能触发堆内存分配而中断上下文里不允许分配。更糟的是有些情况不会给你 MemoryError而是直接硬 fault 复位连提示都看不到。提示如果你的 Pico 在跑定时采样时不定时重启先查回调里有没有print、有没有字符串拼接、有没有 list 操作。这三样是 ISR 内存雷区的头号嫌疑。解决方式是预分配像上一节那样用array需要调试时在主循环里做printISR 只置标志。5.2 坑二ISR 里的浮点运算代价比你想的大温度换算公式里有乘法和除法看着很简单。但 MicroPython 在中断上下文里做浮点运算开销远高于整数而且不同固件版本的行为还不一致。早期移植版里浮点运算可能触发堆分配跑久了照样崩。我踩过的一个典型现象是把温度换算放进回调后数据偶尔出现一个巨大的尖峰数值从 25°C 突然跳到 180°C然后又跳回来——那不是温度真的跳了是浮点运算被打断或状态被污染。所以我的铁律是ISR 里只做整数累加、整数除法、数组写入所有浮点换算一律移出中断。5.3 坑三在回调里操作定时器本身有人想实现采集 N 次后自动停止于是在回调里写了timer.deinit()。这在某些固件版本上能跑但在另一些版本上会直接卡死或复位。原因很简单中断回调还在执行时把自己的中断源销毁了这个动作在底层是不可预期的。正确的做法是设一个标志主循环检测到已采集 N 次后在主循环上下文里调用deinit()。5.4 坑四多个变量共享时读写模式没设计好ISR 和主循环共享变量时最安全的模型是一个写、一个读。ISR 只写head和new_data主循环只读它们。千万不要在主循环里改head、在 ISR 里也改head两边同时改同一个索引逻辑一旦交错轻则丢数据重则数组越界直接崩。如果数据量再大建议把缓冲设计成ISR 写尾部主循环读头部的经典 FIFO两个指针各归各管。MicroPython 虽然不存在真正意义的多线程竞争但中断打断主循环的时机不可预测必须按并发编程的思路来设计。5.5 如何判断 ISR 是否超时或丢中断如果怀疑定时不准不要靠感觉。用一个计数器来验证ISR 里每次自增一个整数主循环每隔固定时间读一次跟理论值对比。isr_count 0 def _isr_tick(timer): global isr_count isr_count 1 # 主循环中 time.sleep_ms(2000) expected 2000 // PERIOD_MS actual isr_count - last_isr_count if actual expected - 1: print(疑似丢中断: 期望 {} 实际 {}.format(expected, actual)) last_isr_count isr_count如果actual经常少于expected只有一个原因ISR 执行时间太长超过了定时周期下一次中断触发时上一次还没跑完。这时候要么缩短 ISR要么把周期拉长没有第三条路。5.6 坑五采样时刻和其他外设的负载撞车最后一个是容易被忽视的软坑。如果你的系统里同时有 PWM 驱动电机、LED 大电流开关ADC 采样点很容易落在电源噪声最大的时刻读数出现周期性尖刺。解决思路有三种采多个点取中值而不是平均值中值滤波对尖刺更鲁棒把采样周期和 PWM 周期错开或者在硬件上给模拟部分做好滤波。温度场景下最简单有效的还是16 次取平均甚至更多因为温度变化慢多采几次的代价可以忽略。6. 实战收尾完整的定时温度采集程序把所有内容合成一个可直接跑的完整程序。这个版本我用了很久稳定性和可读性都比较满意。6.1 完整代码import machine import array import time # ---------- 配置 ---------- PERIOD_MS 1000 # 每秒一个点 RING_LEN 600 # 10 分钟环形历史 N_AVG 32 # 单点平均次数 ADC_TEMP 4 # 温度传感器特殊通道 # 校准参数按 3.3 节方法得到 CAL_GAIN 0.9775 CAL_OFFSET -0.35 # ---------- 初始化 ---------- _sensor machine.ADC(ADC_TEMP) ring array.array(H, [0]) * RING_LEN head 0 count 0 new_data False # ---------- ISR ---------- def _isr_sample(timer): global head, count, new_data total 0 for _ in range(N_AVG): total _sensor.read_u16() avg total // N_AVG ring[head] avg head (head 1) % RING_LEN if count RING_LEN: count 1 new_data True _timer machine.Timer() _timer.init(periodPERIOD_MS, modemachine.Timer.PERIODIC, callback_isr_sample) # ---------- 换算主循环内浮点 ---------- def _to_temp(u16): v u16 * (3.3 / 65535.0) t 27.0 - (v - 0.706) / 0.001721 return CAL_GAIN * t CAL_OFFSET # ---------- 主循环 ---------- while True: if new_data: new_data False latest (head - 1) % RING_LEN temp _to_temp(ring[latest]) # CSV 格式方便 PC 端分析 print({:.3f},{:.2f}.format(time.ticks_ms() / 1000, temp))运行后打开串口监视器每行就是时间戳,温度复制到 Excel 里就能画曲线。6.2 CSVP 记录与简单可视化如果不想靠在 PC 端手动复制可以用 Thonny 的绘图器直接把数值画出来把print改成只输出温度数字Thonny 右上角的绘图面板会自动出波形。想长期记录就用串口工具把输出重定到文本文件之后丢给 matplotlib 或者 Excel 做分析。我习惯在 PC 端跑一个小脚本实时读串口并追加写入 CSVimport serial with serial.Serial(COMxx, 115200, timeout1) as s: with open(temperature_log.csv, a) as f: while True: line s.readline().decode().strip() if line: f.write(line \n) f.flush() print(line)6.3 如果要更进一步高速采样的路线图如果你做完这个项目后开始琢磨能不能拿 Pico 采音频能不能四个通道同时高速采样我的建议是换个思路。MicroPython 的machine.ADC接口根本没暴露 DMA、多通道扫描、采样时钟分频这些底层能力纯 Python 硬顶到天也就是几万次每秒还是单通道。真正的高速方案是走 C SDK用 ADC 的 FIFO DMA让采样结果自动搬运到内存CPU 几乎不参与或者用 PIO 配合 ADC 做精确到周期的采样触发。这个复杂度已经不是本文范畴了但如果你有需求方向是明确的别再 Python 里抠性能直接下沉到 C 层。我自己这块板子做完三点校准后冰水、室温、沸水三个点的残差都在 0.2°C 以内连续跑了一周没丢一个中断点。最后分享一个老生常谈但必须提醒的细节ADC 采样引脚的飞线越短越好别跟 PWM 驱动线走在一起如果板子上有电机或继电器给模拟部分单独滤波。很多你以为是代码问题的诡异数据其实是从天线一样的长飞线里灌进来的噪声。把硬件上的坑填平软件层面的滤波才能发挥真正的作用。