树莓派Pico ADC实战:从采样原理到滤波校准的完整指南

📅 发布时间:2026/9/9 7:27:05
树莓派Pico ADC实战:从采样原理到滤波校准的完整指南
1. 先摸清 Pico 的 ADC 硬件底子12bit SAR、输入范围和采样原理1.1 3 路外部通道 内部温度传感器先别急着写代码很多人拿到树莓派 Pico第一件事就是打开 MicroPython 的 REPL输入ADC(26).read_u16()看到屏幕上跳出一个数字就开始欢呼。但这个数字到底代表什么、能不能信、为什么换根杜邦线之后读数就漂得离谱大多数人并没有搞清楚。我建议在做任何 ADC 项目之前先把 Pico 的 ADC 硬件结构摸一遍。Pico 用的主控是 RP2040这颗芯片内部集成了一颗 12bit SAR 型模数转换器官方在板子上引出了 3 个外部模拟输入引脚GPIO26 对应 ADC0GPIO27 对应 ADC1GPIO28 对应 ADC2。注意它们全部工作在 3.3V 电源域输入电压不能超过 3.3V否则不仅读数不对还极有可能烧掉引脚。除了这 3 个外部通道MicroPython 里还保留了两个特殊通道ADC(3)对应 GPIO29在 Pico 板上已经通过分压电阻网络和 VSYS 相连可以用来监测供电电压ADC(4)是芯片内部的温度传感器读出来的是硅片结温不是环境温度平时做板级保护很有用但想拿它当室温计还是趁早打消念头。通道对应引脚典型用途备注ADC0GPIO26外部模拟量输入最常用ADC1GPIO27外部模拟量输入ADC2GPIO28外部模拟量输入ADC3GPIO29板载 VSYS 分压监测默认已接分压网络ADC4内部芯片温度监测结温非环境温度这里有一个非常容易踩的坑GPIO26 到 GPIO28 在引脚图上同时也会标注 I2C、SPI、UART 等复用功能比如在自定义扩展板上有人把 I2C1 的 SCL/SDA 配到了 GP26/GP27然后又想用同一个引脚做 ADC 输入结果读数全是 0。其实 MicroPython 的Pin对象会对引脚功能做互斥检查但在 C 底层的 SDK 模式下不会强制约束所以做硬件设计时就得先规划好引脚的最终归属。1.2 12bit SAR 的采样过程为什么“读数跳”不一定是代码问题RP2040 的 ADC 属于逐次逼近型SAR ADC原理可以类比成一台“砝码天平”内部有一个比较器、一个电容阵列和一个逐次逼近寄存器。开始转换后寄存器从最高位开始依次置 1每一次都把当前数字值对应的模拟电压和输入电压做比较根据比较结果决定这一位保留还是清除重复 12 次之后就得到一个 12bit 的数字结果。这个过程听起来简单但有两个容易被忽视的细节直接决定了你拿到的数据质量。第一个是采样保持电容。SAR ADC 在做比较之前必须先给内部的采样电容充电让它两端的电压稳定地等于输入电压。如果输入信号源的阻抗很高充电时间就不够电容还没充满就开始逐位比较结果是高位虽然定了低位却在浮动读数就会“跳”。这也是为什么数据手册里会强调源阻抗不能太高。第二个是建立时间。ADC 每次转换前都需要一段时间让采样电容稳定。你在代码里连续调用read_u16()看起来是两行代码实际上内部经历了“采样—保持—逐位比较—输出”多个阶段。如果前一次刚读完别的通道紧接着立刻读下一个通道没有给内部模拟开关留出短暂的稳定窗口读数就可能残留上一通道的痕迹。有个生活化的理解方式拿一支温度计去测一杯热水刚插进去 10 秒就读数肯定偏低等 1 分钟再读才接近真实水温。ADC 的采样电容就是那支温度计它需要时间“浸泡”在输入信号里。后面我会专门说采样周期和稳定时间的经验值这里先记住一个结论ADC 读数异常大概率不是 MicroPython 的库写错了而是模拟链路上的阻抗、噪声和时序没处理干净。1.3 什么时候不加外部 24bit ADC 不行搜索 ADC 相关资料时总会看到 HX711、ADS1232、MCP3561 这些 24bit 芯片的名字于是有人就会怀疑 Pico 自带的 12bit ADC 是不是不够用。我的看法是先搞清楚你要测的信号有多大的动态范围。12bit 在 0 到 3.3V 量程下最低有效位 LSB 大约是 0.806mV3.3V / 4096。如果你要测的是电池电压、NTC 温度、电位器位置、光敏电阻亮度这一类本身变化范围很大的信号0.8mV 的分辨率完全够用。ADC 精度不够表现出来的不是有效位不足而更多是电源噪声和参考电压漂移。但如果你要面对的是称重传感器应变片桥式输出只有几毫伏或者需要分辨微小的电流变化12bit 确实不够。这时候外接 HX711、ADS1232 或 MCP3561 这类 24bit 芯片才是正路。选型逻辑大致是HX711专为称重传感器设计24bit内部带 PGA 放大器数据速率通常只有 10Hz 或 80Hz适合静态称重不适合高频采集。ADS1232同样是桥式传感器应用稳定性好适合需要长期漂移小的工业称重场景。MCP3561多通道、可编程数据速率SPI 接口适合需要稍微快一点的高精度多通道采集。所以在项目选型阶段先问自己“最小可分辨电压是多少”和“采集频率要多少”。Pico 内置 ADC 解决 90% 的控制类应用没问题剩下的 10% 才需要外挂而且外挂芯片通常用 SPI/I2C 接口读回来的已经是数字量反而不需要折腾 Pico 的machine.ADC了。2. machine.ADC 从初始化到读数API 背后的真实行为2.1 构造函数到底该传 Pin 还是通道号MicroPython 的machine.ADC在不同开发板上的构造方式并不完全一致这是新手最容易踩的兼容性坑。在树莓派 Pico 上官方固件最常用的写法有两种from machine import ADC, Pin adc0 ADC(26) # 直接传 GPIO 编号 adc1 ADC(Pin(27, Pin.IN)) # 传 Pin 对象两种写法都能工作但要注意ADC(26)里的 26 是 GPIO 编号不是通道号也不是引脚丝印上的 ADC0 那种抽象编号。如果你误写成ADC(0)固件大概率会直接抛出ValueError因为 GPIO0 根本不在 ADC 输入映射表里。传Pin对象看起来更“面向对象”但这里有个隐蔽细节Pin需要先被配置成输入模式至少得是浮空输入或带上拉输入。如果你在代码前面已经把这个引脚配成了 PWM 输出再去创建 ADC 对象实际读取时会得到错误结果因为引脚仍然被数字输出驱动着。所以比较稳妥的做法是p Pin(27, Pin.IN) adc1 ADC(p)先明确把引脚切成模拟输入再交给 ADC 对象避免因为历史配置导致问题。另一个兼容性提醒少数第三方固件可能支持ADC(ADC0)这样的字符串参数但在树莓派 Pico 官方 MicroPython 固件上不保证可用跨板卡复用代码时尽量只使用GPIO 编号或Pin 对象这两种最保守的写法。2.2 read_u16() 和 read_uv() 返回值的“坑”machine.ADC对象有几个读取方法read_u16()、read_uv()以及一些早期版本可能有的read()。命名很有迷惑性很多人以为read_u16()返回的就是 16bit 的真实采样值然后直接把 4096 这个分辨率忘到脑后。实际上Pico 的 ADC 硬件是 12bit但 MicroPython 为了上层软件处理方便把 12bit 结果左移了 4 位映射到 0 到 65535 这个 16bit 范围内。也就是说read_u16()返回的数值并不是“真的有 16bit 精度”而是把 12bit 结果“垫高”了。低 4 位要么是 0要么是完全没有意义的噪声。转换电压的时候正确公式是这样def read_voltage_u16(adc): raw adc.read_u16() return raw * 3.3 / 65535.0如果不想关心这些位运算细节可以直接用read_uv()它返回的是整数微伏def read_voltage_uv(adc): return adc.read_uv() / 1_000_000.0我实际用下来read_uv()的数值是对齐到微伏的但请一定记住这只是软件层面的“精确”不代表硬件真的能测到微伏级别。它本质还是 12bit ADC 换算过来的真实有效分辨率就是 3.3V / 4096约 0.8mV。一个容易忽略的性能差异是read_u16()比read_uv()少一次乘除法运算在某些固件上速度略快。如果你要做高速采样或频繁轮询建议在回调里先用read_u16()拿原始码值保存整型到主循环再统一转换成电压这样既省时间又避免在中断里做浮点运算后面 ISR 章节还会重点讲。2.3 多通道轮询与引脚复用ADC 和 PWM、I2C 打架怎么办多通道轮询是日常项目里非常常见的设计。比如一个环境监测节点既要读 NTC 温度又要读光敏电阻还要时不时读一下 VSYS 供电电压。直接这样写是最容易出问题的adc0 ADC(26) adc1 ADC(27) v0 adc0.read_u16() v1 adc1.read_u16() # 切换通道后立即读取问题不出在 API而出在模拟开关切换后的建立时间。通道切换之后内部采样电容需要从新输入信号源重新充电如果外部电路的源阻抗较高第一次读取的值可能偏大或偏小而且带有上一通道的“记忆”。推荐的多通道读取模板是“先读后弃 短延时”import time from machine import ADC def read_multi(adc_list, settle_us100): values [] for adc in adc_list: adc.read_u16() # 第一次读取用于刷新采样电容 time.sleep_us(settle_us) # 等待建立稳定 values.append(adc.read_u16()) # 第二次读取才是有效值 return values a0 ADC(26) a1 ADC(27) v0, v1 read_multi([a0, a1])这里的settle_us我习惯给 100 微秒也就是 0.1 毫秒。对 NTC、电位器这类慢变信号已经绰绰有余即使源阻抗高到 10kΩ 级别也够稳。至于引脚复用冲突最典型的就是 ADC 引脚被同时分配给 PWM 或其他数字外设。MicroPython 的Pin对象默认会记录当前功能但如果你用纯 C SDK 或者底层寄存器改过功能MicroPython 层的状态可能已经不同步。碰到异常读数时一个有效的排查手段是p Pin(26, Pin.IN) # 强制重置成普通输入 adc0 ADC(p) # 再重新创建 ADC 对象另外GPIO26 到 GPIO28 在默认引脚映射上并不和 Pico 板载的 I2C0GP0/GP1、SPI0GP16-GP19冲突但很多扩展板会把 ADC 引脚旁边的 GPIO 接到舵机、大电流 LED 等负载。你在读取 ADC 的同时去翻转旁边的数字引脚会有一定概率在读数上看到毛刺这就是“串扰”。解决办法是物理上让模拟信号线尽量远离高频翻转的数字线或者软件上让 ADC 的采样时间避开数字翻转的瞬间。3. 定时温度采集实战NTC 分压、换算与 Timer 调度3.1 传感器选型为什么实战用 NTC 而不是 DS18B20ADC 最经典的应用之一就是温度采集而温度传感器又分好几种类型有人会问既然 DS18B20 是数字输出、一根线就能读温度为什么还要用 NTC 连 ADC 这么麻烦我的理由是这篇内容的主角是 ADC把 DS18B20 接进来ADC 就失业了。NTC 热敏电阻是典型的模拟传感器阻值随温度变化必须通过分压网络转换成电压后再交给 ADC正好能把整个模拟信号链路串起来。学会 NTC ADC 之后其他模拟传感器光敏、电位器、应变片都能触类旁通。简单对比一下常用温度方案NTC便宜、体积小、响应快、非线性需要分压电路适合 -40℃ 到 125℃ 范围内的常规测温。LM35线性电压输出10mV/℃好换算但输出从 0V 开始测负温需要负电源使用上略麻烦。DS18B20数字接口不需要 ADC精度也能做到 ±0.5℃但采样速度慢、需要单总线时序而且学不到模拟链路。热电偶测温范围极大但信号非常微弱需要冷端补偿和放大器对新手不友好。所以如果你想真正搞懂 ADC 的“信号调理”过程NTC 是最合适的教材。3.2 分压电路设计和参数计算NTC 的典型参数是 25℃ 时阻值 10kΩB 值 3950。B 值衡量的是阻值随温度变化的敏感程度B 值越大温度变化引起的阻值变化越剧烈。最简单的分压电路是把 NTC 和一个固定电阻串联在 3.3V 电源和地之间然后把 ADC 输入引脚接到两个电阻的中间节点。这里有两个接法选择测 NTC 上的电压或者测固定电阻上的电压。两种都可以但公式不一样我习惯测固定电阻上的电压因为在常见温度范围里它的电压随温度上升而上升变化方向更直观。电路公式Vout VCC * R_fix / (R_ntc R_fix)25℃ 时NTC 阻值等于 10kΩ固定电阻也取 10kΩVout 3.3 * 10 / (10 10) 1.65V正好在 ADC 量程中间这是最理想的静态工作点。温度变化后假设 0℃ 时 NTC 阻值约等于 33.2kΩVout 3.3 * 10 / (33.2 10) ≈ 0.764V50℃ 时 NTC 阻值约等于 3.6kΩVout 3.3 * 10 / (3.6 10) ≈ 2.426V。可以看到0℃ 到 50℃ 对应电压范围大概在 0.76V 到 2.43V 之间虽然没占满整个 0 到 3.3V 量程但已经足够分辨 0.1℃ 级别的温度变化因为 12bit ADC 在这个区间内仍能提供约 5200 个有效码值。唯一要注意的是源阻抗。NTC 分压网络的等效源阻抗在 NTC 阻值等于固定电阻阻值时最大约等于两者并联值10kΩ // 10kΩ 5kΩ。这个阻抗对 Pico ADC 的内部采样电容来说稍微有点高但不是不能用。稳妥的做法是并一个 0.1uF 陶瓷电容在 ADC 引脚和地之间它有两个作用滤除高频噪声以及为采样电容提供一个低阻抗的电荷源能明显改善读数稳定性。3.3 定时采集的两种实现while 循环延时 vs machine.Timer温度不是一个快速变化的物理量通常每秒采一次就够了所以“定时”是刚需。最简单的实现是主循环里time.sleep(1)然后读 ADC、计算、打印while True: v adc.read_uv() / 1_000_000.0 print(v) time.sleep(1)这个写法在演示阶段没问题但一旦项目变大主循环还要同时处理按键、OLED 刷新、上位机通信阻塞在sleep里就会让其他任务失去响应。比如你一边做温度采集一边想用同一个 Pico 读取按键状态按下按键的那一刻恰好卡在sleep里按键事件可能被延迟 1 秒才响应。正确的做法是用machine.Timer周期性地触发回调from machine import Timer sample_ready False def on_timer(t): global sample_ready sample_ready True timer Timer() timer.init(period1000, modeTimer.PERIODIC, callbackon_timer) while True: if sample_ready: sample_ready False do_temperature_reading() # 主循环还能做其他事情这里我先不急着在回调里读 ADC而是只置一个标志位原因会在 ISR 章节详细展开。定时器和主循环解耦之后主循环的空闲时间可以用来处理显示、通信定时任务到点就触发互不阻塞。3.4 温度换算B 值公式和完整代码NTC 阻值和温度的关系近似用 B 值公式描述R R0 * exp(B * (1/T - 1/T0))反过来已知阻值求温度T 1 / (1/T0 ln(R/R0) / B) - 273.15其中 T 和 T0 是开尔文温度。整套完整代码可以这样写import math import time from machine import ADC, Pin ADC_PIN 26 R_FIX 10000.0 BETA 3950.0 T0 298.15 # 25℃对应的开尔文温度 R0 10000.0 # 25℃时的 NTC 阻值 adc ADC(Pin(ADC_PIN, Pin.IN)) def read_voltage(adc, samples5): v 0.0 for _ in range(samples): v adc.read_uv() / 1_000_000.0 time.sleep_us(500) return v / samples def volt_to_resistance(v_out, vcc3.3): if v_out 0 or v_out vcc: return float(inf) return R_FIX * (vcc - v_out) / v_out def resistance_to_temp(r): if r 0: return -273.15 return 1.0 / (1.0 / T0 math.log(r / R0) / BETA) - 273.15 def read_temp(): v read_voltage(adc) r volt_to_resistance(v) t resistance_to_temp(r) return v, r, t while True: v, r, t read_temp() print(V{:.4f}V R{:.2f}k T{:.2f}C.format(v, r / 1000.0, t)) time.sleep(1)这里有三个细节值得说明。第一read_voltage里做了 5 次采样取平均这是最简单有效的数据稳定手段代价是一次读取变成 5 次但温度采样本来就不追求高频完全值得。第二volt_to_resistance里判断了边界情况Vout 接近 0 表示 NTC 阻值无穷大可能是断线Vout 接近 VCC 表示 NTC 短路这两种异常情况在工业现场很常见代码里必须兜住。第三如果不想在对数运算上消耗太多资源可以预先把“阻值—温度”表算好运行时查表但 Pico 的 Cortex-M0 跑浮点对数也就几微秒对 1Hz 的采样周期来说无所谓查表法不是必需。4. ISR 避坑指南在中断回调里读 ADC 的崩溃与正确姿势4.1 现场回放Timer 回调里直接 read_u16() 后随机死机下面这段代码是我早期做保温箱温度记录器时真实遇到过的坑。当时图省事把温度采集的逻辑全塞进了 Timer 回调结果程序跑几分钟就会随机重启或者突然卡死from machine import ADC, Timer import utime adc ADC(26) data [] def timer_cb(t): v adc.read_u16() temp v * 100.0 / 65535.0 # 其实还要公式换算 data.append(temp) # 中断里做内存分配 print(temp:, temp) # 中断里做字符串格式化 timer Timer() timer.init(period1000, modeTimer.PERIODIC, callbacktimer_cb) while True: utime.sleep(1)表面上看每个函数都是有效的但实际运行结果就是不稳定。原因在于 MicroPython 的中断回调运行在受限的上下文里和主程序共享解释器执行环境却不像主循环那样能安全地处理各种异常。4.2 MicroPython 中断上下文的三条红线分配、浮点、阻塞如果你用 C 语言写 STM32 或 RP2040 的 SDK中断服务函数里本来就有很多默认规则要快进快出、不能调用非中断安全函数。但 MicroPython 把这些规则“隐藏”在了解释器背后很多新手根本意识不到。红线一内存分配。在中断回调里创建新对象、往列表里append、字符串拼接、调用str.format这些操作都可能触发堆内存分配。而在 MicroPython 的中断处理路径中内存分配很可能直接失败并抛出 MemoryError。最糟的是这个异常发生在中断上下文里解释器无法像主循环那样干净地抛给用户结果就是打印一些看不懂的乱码紧接着系统重启。红线二浮点运算。MicroPython 在 Cortex-M0 这种没有硬件浮点单元的内核上浮点运算靠软件模拟耗时大且占用较多栈空间。中断里做一两次浮点乘除也许能扛过去但经过复杂换算、取对数、开平方这类运算后风险迅速上升。不同固件版本在中断浮点上的容忍度不一样最保险的做法是中断里只处理整数。红线三阻塞操作。time.sleep()、I2C/SPI 通信、UART 写入、刷新 OLED这些操作在中断里都不应该出现。它们要么会阻塞其他更高优先级中断要么内部会等待某个外设状态一旦外设又依赖同一个解释器完成流程就直接卡死。我后来查了很多 issue 和官方文档结论很清楚MicroPython 中断回调里“绝对安全”的操作其实非常有限只有修改整型全局变量、读一个简单的寄存器、设置标志位这类极小操作。4.3 官方推荐的 ISR 模式标志位 主循环消费经过那次崩溃之后我改成“中断只放旗帜主循环扛大旗”的模式。定时器到点后回调里只置一个标志位主循环发现标志置位后再去读 ADC、做滤波、换算温度、上报数据。from machine import ADC, Pin, Timer adc ADC(Pin(26, Pin.IN)) sample_ready False sample_raw 0 def on_timer(t): global sample_ready, sample_raw sample_raw adc.read_u16() # 一个整数读取无分配安全 sample_ready True timer Timer() timer.init(period1000, modeTimer.PERIODIC, callbackon_timer) while True: if sample_ready: sample_ready False v_out sample_raw * 3.3 / 65535.0 # 在这里做滤波、温度换算、显示、存储、通信这个版本里回调仍然直接调用了一次read_u16()但它返回整数内部不分配堆内存也不做浮点转换所以是安全的。如果连这个都不放心可以把回调简化到只剩sample_ready True在主循环里读 ADC。两种方案我都试过在读取单个通道且使用官方固件时第一种方案的定时抖动更小因为采样时刻由 Timer 硬件触发主循环再忙也不会影响“到点采样”这个动作的起始精度。但要注意一个反向问题如果主循环被一个特别耗时的任务卡住太久比如进行一次 Flash 写入那么sample_ready标志就算置位了也要等主循环腾出手来才能处理读取结果依然会被延迟。要完全避免这个问题需要用环形缓冲区在中断里暂存一批整数原始码值主循环按顺序消费。比如预先创建一个array.array(H, [0]) * BUFFER_SIZE中断里往固定索引写数据主循环读取后移动写指针这样既避开了内存分配又能缓解主循环抖动带来的数据丢失。4.4 实测对比两种写法的稳定性差异我给一个实际测试数据的参考用同一个保温箱同样的period1000ms定时器第一种“回调里全干”的写法连续运行平均 20 分钟左右就会遇到一次重启有时打印出MemoryError有时直接看门狗复位第二种“标志位 主循环”写法连续运行 48 小时没有异常温度曲线也很平滑。这说明什么不是 MicroPython 的machine.Timer不稳而是中断上下文的使用方式完全超纲了。规则记一条进入 ISR 之前先问自己这个操作会不会分配内存、会不会做浮点、会不会阻塞只要有一个“会”就把它挪出中断。这条规则不仅适用于 Pico也适用于后续接触的任何 MicroPython 设备甚至对学习 C 语言中断编程也有帮助。5. 采样周期、滤波与校准从“能读到数”到“读得准”5.1 采样周期与建立时间多久采一次才合理很多人在搜索框里输入“adc采样周期”“adc建立时间”说明大家踩的坑都一样采集到的数据要么波动大要么看起来像被什么东西“拖慢”了。先说结论Pico 内置 12bit SAR ADC 的单个转换时间大约在微秒到十几微秒量级但在真实电路里采样周期主要由信号源阻抗和外部电容决定而不是 ADC 转换本身。如果你用的是 10kΩ NTC 10kΩ 电阻分压再外接 0.1uF 电容RC 时间常数大约是 5kΩ * 0.1uF 0.5ms。想让电容充到 99% 以上大约需要 5 个时间常数也就是 2.5ms。所以每次读取前至少要让 ADC 通道有 2.5ms 的稳定时间。如果代码里没有外加电容只是纯电阻分压那么源阻抗 5kΩ 面对采样电容本身稳定时间可以短到几十微秒上面给到的settle_us100就够了。温度信号本身变化很慢采样周期取 100ms 到 1s 都合理。如果采样太快、周期太短只会看到同一温度附近的随机噪声信息量并不增加。我自己做环境监测常用的周期是 500ms 到 2s。真正需要高采样率的是电机电流、音频包络这类快速信号那种场景通常也不会选 Pico 内置 ADC。5.2 ADC 值滤波函数滑动平均、中值、限幅别只看平均搜索热词里也有“c语言adc值滤波函数”说明很多项目里都需要在代码层面对 ADC 原始值做软件滤波。注意滤波并不能把噪声变成真实信号它只是帮你从含噪数据里提取更接近期望值的读数。滑动平均是最常用也是最容易理解的。维护一个固定长度的缓冲区新数据进来就覆盖最老的数据输出缓冲区平均值。在 MicroPython 里的实现class MovingAverage: def __init__(self, size10): self.buf [0] * size self.idx 0 self.sum 0 self.size size def add(self, x): self.sum - self.buf[self.idx] self.buf[self.idx] x self.sum x self.idx (self.idx 1) % self.size return self.sum / self.size avg MovingAverage(10) while True: raw adc.read_u16() smoothed avg.add(raw) time.sleep_ms(50)滑动平均适合白噪声和高频抖动但对突然出现的尖峰干扰比如舵机转动的瞬间毛刺显得无能为力因为尖峰会被累加进平均值里。中值滤波更适合去除尖峰取最近 N 个数据排序后取中间值N 一般取奇数5 个就够了。缺点是排序需要额外算力而且对高频噪声抑制一般。限幅滤波的思路是如果当前值和上一次值的差值超过某个阈值就判定为干扰并丢弃保留上一次的值。它特别适合保护数据曲线不出现“跳崖式”突变。我在实际项目里常用“限幅 滑动平均”组合先限幅滤掉瞬时尖峰再滑动平均平滑噪声。效果比单独用任何一种都好。C 语言版本的核心逻辑完全一样只是把类换成结构体和函数环形缓冲区用取模指针操作。5.3 借鉴外部 ADC 三点校准思路Pico 上做两点、三点电压校准外置 ADC 选型资料里经常提到“外部adc三点校准”但内置 ADC 同样需要校准。Pico 板载 3.3V 电源虽然名义上是 3.3V但实际可能是 3.28V、3.31V 这种值而且 ADC 内部参考电压直接取自电源。另外ADC 本身可能存在增益误差和偏置误差。校准的核心思想是用已知的精确电压测量 ADC 的实际输出然后建立修正模型。两点校准适用于线性响应系统准备两个已知电压比如从精密可调电源取 0.5V 和 3.0V或者用低噪声电阻分压从 3.3V 分出两个标准点。用 Pico 分别读出它们得到adc_low和adc_high。计算增益和偏置k (v_high_real - v_low_real) / (adc_high - adc_low) b v_low_real - k * adc_low def calibrated_voltage(raw_v): return k * raw_v b三点校准则是在两点基础上再加一个中间点用分段线性拟合或最小二乘法拟合补偿 ADC 在中段可能存在的非线性。对于 12bit SAR非线性通常很小三点法已经足够了。一个更省事的思路不需要外部精密电源直接用万用表量 Pico 的 3.3V 引脚和分压节点把万用表读数当作真实电压再和 Pico 读出的电压组成数据对也能做两三点校准。误差主要取决于万用表精度家用手持万用表虽然达不到计量级但对绝大多数项目够用。校准参数需要持久化存储。我通常把计算出的k、b写成 JSON 存到 Pico 的 Flash 文件系统里每次启动时读取。如果项目里没有文件系统也可以直接以常量形式写在代码顶部便于维护。5.4 实测误差源参考电压、电源噪声、引脚串扰做完校准之后数据就准了吗不一定因为误差源不止 ADC 本身的偏移。我总结三个高频误差源参考电压漂移。Pico 的 3.3V 由板载电源产生不是专用的高精度基准源。当板子负载突然增大比如连接 WiFi 模块、驱动舵机时3.3V 会短暂跌落ADC 读数也会跟着偏。校准的前提是参考电压稳定所以大功率负载和 ADC 采样要避免同时进行。电源噪声。数字电路翻转、电机启动都会通过电源线引入噪声。这个噪声传到模拟输入端后直接表现为 ADC 采样值的随机波动。改善手段是在 ADC 输入引脚和地之间加 0.1uF 到 1uF 电容模拟地与数字地单点连接必要时用 LDO 给传感器单独供电。引脚串扰。ADC 引脚旁边的数字引脚如果快速翻转容性耦合会让 ADC 读数产生毛刺。硬件上尽量让模拟走线远离数字走线软件上不要让采样时刻撞上数字引脚翻转的瞬间。做舵机控制时这一点尤其明显舵机 PWM 信号边沿的干扰会在 ADC 读数上留下规律性毛刺。记住一个核心思路校准负责修正系统误差滤波负责抑制随机噪声硬件布线负责减少干扰源。三者配合12bit ADC 才能发挥出接近理论精度的水平。6. 延伸当 ADC 和舵机、低功耗、外部高分辨率芯片一起出现6.1 树莓派 Pico 控制舵机用 ADC 读电位器映射舵机角度“树莓派pico控制舵机”和“adc”总是同时出现在搜索里因为舵机控制本质是 PWM而 PWM 脉宽又经常需要根据模拟输入来动态调整。最简单的场景是用电位器当角度输入ADC 读取电位器分压电压再映射成舵机 PWM 脉宽。舵机通常工作在 50Hz对应周期 20ms标准脉宽范围是 0.5ms 到 2.5ms。在 Pico 上PWM 的分辨率可以通过duty_u16()设置这里需要把脉宽转换成 duty 值。假设 PWM 频率 50Hz那么 20ms 周期对应 65535 这个 16bit 周期值0.5ms 脉宽大约对应 16382.5ms 对应 8192from machine import ADC, Pin, PWM, time import time adc ADC(26) servo PWM(Pin(15)) servo.freq(50) def map_range(x, in_min, in_max, out_min, out_max): return (x - in_min) * (out_max - out_min) / (in_max - in_min) out_min while True: v adc.read_u16() / 65535.0 * 3.3 duty int(map_range(v, 0.0, 3.3, 1638, 8192)) servo.duty_u16(duty) time.sleep_ms(20)舵机控制最大的坑是瞬态电流。舵机堵转或启动瞬间电流可能超过 1A如果电源功率不足3.3V 会塌陷这时 ADC 读数会产生很明显的毛刺。我在实际项目里给舵机单独供电并把模拟部分和舵机电源用磁珠或小电阻隔离效果好了很多。6.2 为什么 Pico 的 ADC 没有 DMA 通道高分辨率多通道采集怎么办搜索热词里有很多“adc四通道使用dma”“s32k312 adc”“stm32 adc”这些是 STM32、S32K 平台上的功能熟悉这些名词的人往往期待 Pico 也能用 DMA 连续搬运 ADC 数据。很遗憾RP2040 的 ADC 外设并没有完整的 DMA 触发链至少在 MicroPython 层也没有提供这样的接口。想用 Pico 内置 ADC 做多通道连续高速采样只能靠代码轮询采样率和通道数都有限。如果项目确定需要高分辨率、多通道、连续采样我更推荐外接独立 ADC 芯片。HX711、ADS1232 用于称重和应变片测量MCP3561 这种多通道 SPI ADC 适合精度要求更高的通用数据采集。接口上SPI 读取速度远高于内置 ADC 的轮询而且芯片有自己的数位滤波和校准寄存器数据质量更容易保证。选择外置芯片时要先看数据速率。HX711 的典型速率为 10SPS 或 80SPS只适合静态称重如果要做动态力测量就要选速率更高的 ADS1232 或 MCP3561。另外一个容易被忽略的点是外置 ADC 对模拟电源质量要求更高供电纹波会直接影响 24bit 数据的低位稳定性建议用低噪声 LDO 独立供电。6.3 低功耗定时采集定时器唤醒还是主循环睡眠做电池供电的温度采集节点时ADC 本身耗电不大真正的敌人是主控持续运行和无线模块的待机电流。Pico 的machine.lightsleep()可以在保留 RAM 的情况下进入低功耗状态并支持定时器唤醒非常适合“每隔一段时间醒一次采完继续睡”的场景。一个最简设计思路import machine import time def read_and_upload(): # 读 ADC、换算、通过无线模块或存储上报 pass while True: read_and_upload() machine.lightsleep(5000) # 睡 5 秒需要注意lightsleep唤醒后某些外设可能处于复位状态ADC 需要重新初始化或者至少要再等一小段时间让内部参考稳定。我一般会在每次唤醒后重建 ADC 对象并丢弃第一次读取结果。这个习惯能省掉大量“为什么唤醒后第一次读数特别怪”的排查时间。另外如果定时周期需要精确用machine.Timer硬件定时唤醒会比lightsleep(5000)这种软件定时更可靠因为后者依赖主循环的执行节奏略有抖动。低功耗设计本质是功耗、响应速度和代码复杂度的权衡没有绝对最优解先把基础 ADC 链路做稳定再逐步调节参数。做模拟采集这行我个人的体感是软件层面的machine.ADC只是最小的一环真正决定项目上限的是对硬件信号链的理解——源阻抗、建立时间、参考电压、噪声抑制每一样都比“把 API 背下来”更重要。如果哪天你遇到了一个读完 Pico ADC 数据堪用的项目却发现换一套电源、换一根线之后数字漂得离谱别先怀疑固件拿万用表去量一下实际电压很多问题当场就破了。