ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

树莓派Pico ADC应用全攻略:从machine.ADC API到定时器中断实战

树莓派Pico ADC应用全攻略:从machine.ADC API到定时器中断实战 几个月前有个朋友问我树莓派 Pico 的 ADC 到底怎么用他买了一堆传感器结果到手发现读出来的数要么跳得离谱要么电压永远不对。后来我跟他说问题大概率不在传感器而在你对 Pico 这个 12 位 ADC 的理解方式。Pico 能跑 MicroPythonmachine.ADC用起来确实简单可简单不等于没讲究。这篇东西就把我从 API 到定时温度采集的全部经验捋一遍包含定时器中断的踩坑记录给准备用 Pico 做数据采集的兄弟们当个参考。文章会按这个顺序推进先搞清楚 RP2040 的 ADC 硬件底细再聊machine.ADC的常用姿势和换算公式然后拿“定时采集内部温度”这种典型场景做实战最后重点讲定时器中断里调 ADC 的那些坑。内容不难但都是能直接抄作业的经验。1. 项目概述Pico 的 ADC 到底怎么定位1.1 硬件上先看清楚ADC 引脚与参考电压树莓派 Pico 的主控 RP2040 内置一个 SAR 型 ADC12 位分辨率最多支持 5 个输入通道。其中 4 个从 GPIO26~GPIO29 引出对应内部 ADC0~ADC3第 5 个通道比较特殊接在芯片内部温度传感器上。普通 MicroPython 用户最常用的就是 GPIO26~28因为 GPIO29 在很多最小系统板子上被 VSYS 分压电路占掉了你接到别的信号会导致读数异常。参考电压对读数影响很大。RP2040 的 ADC 直接以电源电压 VDDA 作为参考Pico 开发板上 VDDA 是 3.3V所以输入范围就是 0~3.3V。这意味着如果你把 5V 电平输入直接怼进去芯片会损坏一定要先分压或用运放做电平转换。这一点新手特别容易翻车我在社区里见过不止一次“GPIO 冒烟”的帖子全是忽略参考电压导致的。1.2 12 位分辨率够不够用心里要有数ADC 分辨率 12 位意味着满量程 4096 个台阶在 3.3V 下每一步大约 0.806mV。注意这是理论值实际会受到电源噪声、参考电压温漂、PCB 布局等影响有效位数可能只有 10 位左右。所以如果你追求 0.1% 以上的高精度测量直接拿 Pico 内置 ADC 做精密仪器是不现实的你的瓶颈根本不在软件 API而在模拟链路。但是做常规的电池电压监测、温度采集、旋钮控制、电流粗略检测12 位完全够用。比如我们要测温度传感器输出变化几十毫伏对应几摄氏度用 12 位分辨率能轻松分辨 0.5 摄氏度配合后续软件滤波工程上很香。很多人容易犯的毛病是拿着 12 位 ADC 想干 24 位 Δ-Σ ADC 的活那不是 Pico 该承担的工作。1.3 ADC 采样原理SAR 是逐次逼近不是采样就完事很多初学者以为 ADC 就是一个“电压整形”的映射实际上 SAR ADC 内部有一个采样保持电路先把模拟电压快速“抓住”到内部电容上然后通过一个比较器从最高位到最低位逐次比较12 位就需要 12 个时钟周期完成逼近。所以每次转换至少需要一个采样周期加一个转换周期再加上软件读取时间MicroPython 的read_u16()底层已经把这些周期封装好了我们不用碰寄存器但心里要明白你不能像读数字 GPIO 那样高频连续采集每次转换之间必须留足时间。RP2040 的 ADC 支持最高 500ksps 采样率也就是每秒 50 万次理论转换周期约 2us。但 MicroPython 因为解释开销实际上你连续调用read_u16()的速度远达不到这个上限实测大概每秒十几万到几十万次主要看固件版本和代码复杂度。如果你需要几 MHz 的高速 ADC那 Pico 不太合适考虑带独立高速 ADC 或换更强平台才是正道。2. machine.ADC API 使用全解2.1 初始化 ADC 的三种姿势千万搞清 GPIO 编号和通道号MicroPython 的machine模块提供了极其简洁的 ADC 类初始化方式拢共就三种from machine import ADC, Pin # 方式一直接传 Pin 对象 adc ADC(Pin(26)) # 方式二传 GPIO 编号 adc ADC(26) # 方式三读内部温度传感器传特殊通道号 temp_sensor ADC(4)这里就有个坑在 RP2040 移植版里ADC(26)中的 26 是 GPIO 引脚号不是 ADC 通道号而ADC(4)里的 4 又代表内部温度传感器通道不是 GPIO4。不同固件对整数参数的解析有差异网上有的教程写ADC(0)也能读到 GPIO26 的数据那是因为某些版本做了通道号兼容。为了跨固件稳定我建议传递 Pin 对象或者 GPIO 编号不要用通道号。内部温度传感器则固定用ADC(4)这是官方约定几乎所有主流固件都支持。还有一个注意事项如果你用的是 Pico WADC 引脚位置和经典 Pico 一样但 W 版增加了无线模块有些引脚不能再用需要查 datasheet。比如 Pico W 的 GPIO29 被用于测量无线模块供电VSYS所以如果你在 W 板子上用 GP29 做普通 ADC 采集会读到固定的分压值而不是你想要的信号。2.2 读数方法read_u16 默认 16 位但本质是 12 位向左漂移MicroPython 的 ADC 对象常用方法有两个read_u16()和read_ut16()。后者在较新固件里已经废弃别再用。read_u16()返回 0~65535 的整数看起来像 16 位分辨率实际上是把 12 位 ADC 原始值左移 4 位填充出来的。也就是说原始 12 位读数0~4095乘以 16就得到read_u16()结果。这一点必须想清楚否则你会误以为 Pico 的 ADC 有 16 位分辨率。想得到真正的 12 位原始值直接右移 4 位就行raw_12 adc.read_u16() 4如果你只关心相对位置比如判断电位器转了多少直接拿read_u16()的值做比例计算也可以但和电压对应时务必先右移回 12 位再计算或者直接用read_u16() / 65535 * 3.3的公式结果是一样的。2.3 数值到电压、温度的换算这才是关键假设我们读了一个外部电压信号接在 GPIO26 上参考电压为 3.3V。那么电压计算公式是raw adc.read_u16() # 0~65535 voltage raw * 3.3 / 65535 # 单位 V或者更贴近底层raw_12 adc.read_u16() 4 voltage raw_12 * 3.3 / 4095两个公式的差别很小因为 65535 和 409516 并不完全相等40951665520所以两种算法在满量程时会有约 0.02% 的差异实际使用可以忽略。读内部温度传感器时官方数据手册给出的计算公式为温度(°C) 27 - (电压 - 0.706) / 0.001721这个公式怎么来的RP2040 的温度传感器在 27°C 时典型输出电压为 0.706V温度每升高 1°C输出电压下降约 1.721mV。所以你读到的电压越高实际温度越低。这和很多负温度系数热敏电阻的趋势类似。换算代码from machine import ADC import time sensor ADC(4) raw sensor.read_u16() voltage raw * 3.3 / 65535 temp_c 27 - (voltage - 0.706) / 0.001721 print(temp_c)运行上面的代码如果读数比室温高个五到十度别奇怪因为芯片自身发热会影响内部温度传感器读数。想要准确测环境温度还是乖乖外接 NTC 或数字温度传感器。3. 实战定时温度采集系统的设计3.1 内部温度传感器校准数据去哪了刚才的公式是典型值受制造工艺影响每颗芯片的斜率1.721mV/°C和零偏0.706V都有一定偏差。好在 RP2040 出厂时芯片 OTP 里已经存了两个校准点SDK 里提供接口可以直接读取。可惜 MicroPython 固件默认没有把这组值暴露给我们所以你如果直接用典型公式可能读出来偏差几度。想在没有官方 API 的情况下校准有两条路一条是自己记录几个温度点做线性插值或最小二乘拟合另一条是直接用经过校准的外部温度传感器作为基准调换公式里的 0.706 和 0.001721。后面第 5 章会写校准方法。3.2 用 machine.Timer 做定时采集先不看中断的坑我们设计一个需求每 5 秒采集一次内部温度连续采集 120 个点并打印出来。最简单的方案就是用machine.Timer的周期模式from machine import ADC, Timer import time temp_sensor ADC(4) count 0 def read_temp(): raw temp_sensor.read_u16() voltage raw * 3.3 / 65535 return 27 - (voltage - 0.706) / 0.001721 def on_timer(t): global count t_c read_temp() print(f{count:03d}: {t_c:.2f} °C) count 1 if count 120: t.deinit() timer Timer() timer.init(freq0.2, modeTimer.PERIODIC, callbackon_timer)这段代码能从功能上完成任务但存在隐患print在中断回调里执行如果串口缓冲区满了或者阻塞会把定时器回调卡住而且每次回调都会新建浮点对象、打印字符串这在 MicroPython 的 ISR 上下文里是不推荐的。下面马上说架构问题。3.3 中断回调还是轮询架构取舍在 MCU 开发里定时采集这类需求有两种典型架构中断驱动和主循环轮询。对 Pico 这种轻量级单片机我倾向这样选如果采集周期较大几百毫秒甚至几秒而且允许有几十毫秒抖动用主循环配合time.sleep()或utime.ticks_diff()就行代码简单、可读性强。如果采集周期要求严格必须精确到毫秒比如 1kHz 采样率做音频分析那就用定时器中断但在中断里只做最基本的数据搬移和置位不要做任何耗时操作。如果只是“每秒读一次温度”不是特别苛刻直接主循环轮询足够否则你会把精力和调试时间浪费在处理中断优先级、资源竞争等问题上。很多新手觉得用定时器中断才显得专业其实过度设计比不用中断更痛苦。底下我会给一个折中方案定时器中断只置标志位主循环看到标志后再去采集这样既不丢节奏又躲开了 ISR 的雷区。3.4 完整代码定时器置标志 主循环采集下面这个版本是我在实际项目中常用的一种模式。定时器每 200ms 触发一次回调回调里什么都不做只把全局变量flag置为 True。主循环里检测到flag为 True就执行一次 ADC 采集、滤波、记录和打印。这样调度精度由定时器保证而 ADC 读取这种耗时操作全部放在主循环里安全系数高很多。from machine import ADC, Timer # 采集配置 ADC_PIN 26 # 外部信号也可以用 ADC(4) SAMPLE_INTERVAL_MS 200 adc ADC(ADC_PIN) flag False samples [] def on_timer(t): global flag flag True timer Timer() timer.init(periodSAMPLE_INTERVAL_MS, modeTimer.PERIODIC, callbackon_timer) while True: if flag: flag False raw adc.read_u16() # 简单滑动滤波保留最近 4 个读数 samples.append(raw) if len(samples) 4: samples.pop(0) filtered sum(samples) // len(samples) voltage filtered * 3.3 / 65535 print(fraw{raw}, filtered{filtered}, uv{voltage*1000:.2f}mV)注意flag在中断回调和主循环之间共享理论上存在竞争但因为 MicroPython 的字节码执行有 GIL同一时刻只有一个线程执行而且中断回调会打断主循环赋值操作简单这里不会出问题。更讲究的做法是把flag换成machine.IRQ相关机制但没必要。如果需要批量记录 120 个点可以在主循环里用count控制退出并灵活地把数据存入数组或列表。列表 append 和 pop 在主循环里没问题但绝不要放进 ISR。4. ISR 避坑指南在中断里调 ADC 是门玄学4.1 为什么不能在 ISR 里做完整采集流程MicroPython 的定时器回调其实是在硬件中断上下文执行的。虽然它比纯 C 的 ISR 要“宽容”但在回调里依然要遵守三条铁律不要做阻塞操作比如time.sleep()、print()在串口繁忙时可能阻塞会拖垮整个系统。不要动态分配内存MicroPython 的某些函数比如往列表里append、创建字符串、做浮点运算都可能触发内存分配内部会申请堆内存这在中断上下文里风险和开销都很大严重时会造成内存碎片或直接触发异常。不要执行太长操作ADC 读取本身虽然只有几十微秒但换算成浮点温度、格式化字符串、输出日志叠加起来可能远超一个定时器周期导致中断堆积和漂移。所以我的原则是ISR 里只置标志位和记录计数器其他事情全部放主循环。4.2 标志位模式把采集放回主循环前面 3.4 的代码就是标准示范。这里我再加一个复杂一点的例子用定时器 ISR 固定采样频率比如 100Hz但主循环里负责滤波、存储和显示from machine import ADC, Timer adc ADC(26) sample_flag False sample_cnt 0 def isr(t): global sample_flag, sample_cnt sample_flag True sample_cnt 1 timer Timer() timer.init(freq100, modeTimer.PERIODIC, callbackisr) while True: if sample_flag: sample_flag False raw adc.read_u16() # 到这里处理 raw滤波、存储、转换等 print(sample_cnt, raw)注意我把sample_cnt 1放在 ISR 里这简单整数操作是安全的。但不能在 ISR 里写samples.append(raw)因为这里 raw 还没读出来呢。如果把读取也放进去其实大部分情况也能运行但我见过不少人因此遇到MemoryError或定时器不规律所以不建议赌这个运气。4.3 我已踩过的坑和排查记录分享一个真实经历我之前做一个环境监测盒想在每个整分记录一次温度图省事直接用定时器回调read_temp()然后print()。结果跑了十几分钟后串口输出开始乱序再过一会整个程序直接死机看门狗都没救回来。排查后来发现是print内部需要把字符串送入 USB CDC 缓冲区当电脑端串口工具没及时读取时缓冲区满了print会被阻塞。而阻塞发生在硬中断回调里相当于整个定时器被卡死后面的任务全部宕掉。后来我把打印移到主循环中断里只置一个sample_ready标志程序稳定跑了一周没出问题。所以大家记住凡是跟外部设备交互串口、网络、文件写入都尽量不要出现在 ISR 回调里。如果确实需要在 ISR 中记录数据用array模块预分配固定长度数组在索引处赋值这是少数在 ISR 中相对安全的容器操作。另外一个坑是定时器初始化参数Timer.init(period200)的单位是毫秒Timer.init(freq0.2)是 Hz两种方式别混用。如果你写了freq0.2表示 5 秒一次如果写period0.2那是 0.2 毫秒也就是 5000Hz开始疯狂采集板子一下子就卡成 PPT 了。5. 常见问题与工程化建议5.1 读数抖动、漂移怎么滤波和屏蔽ADC 读数抖动多数来自电源纹波、参考电压波动和引脚阻抗。解决办法按优先级排序引脚串联一个 1kΩ 电阻再加一个 0.1uF 电容到地组成低通滤波器能滤掉大部分高频噪声。软件上做滑动平均最简单的移动平均窗口取 8~16 个点即可。Pico 的 ADC 噪声不大窗口太大会拖慢响应。如果测量对象是缓慢变化量比如温度可以不用平均直接每隔 100ms 采一次再用一阶低通滤波filtered (1-alpha) * filtered alpha * new_samplealpha 取 0.2~0.5 就挺好。下面给一个简单中值滤波的 MicroPython 函数适合偶尔出现尖峰的场景def median_filter(samples): s sorted(samples) n len(s) if n % 2 1: return s[n // 2] return (s[n // 2 - 1] s[n // 2]) // 2 # 使用 buf [adc.read_u16() for _ in range(7)] value median_filter(buf)5.2 温度不准校准才是关键如果你非要拿内部温度传感器做相对准确的测量建议做一次两点甚至三点校准。具体做法用一只已经校准的数字温度计和 Pico 放在同一环境稳定后记录下 Pico 的换算电压然后用公式反推出当前芯片的零偏和斜率。最简单的线性校准模型是电压 a * 温度 b用两个温度点比如冰水混合 0°C 和恒温箱 25°C测出两组电压就能求出 a 和 b再反向计算温度。三个点更好可以检验线性度并用最小二乘法拟合。对应代码# 校准点 (temp_c, voltage) p1 (0.0, 0.721) p2 (25.0, 0.708) # 由两点求斜率 a 和截距 b a (p2[1] - p1[1]) / (p2[0] - p1[0]) b p1[1] - a * p1[0] def voltage_to_temp(v): return (v - b) / a实际校准要注意让温度充分稳定且传感器不要受芯片发热影响最好把 Pico 断电一段时间再测或者外接热源稳定后读取。5.3 多通道采集与未来扩展Pico 的 ADC 内部虽然有多个通道但只有一个转换器MicroPython 层面没有提供并行采集 API多通道只能依次读取。如果你需要同时采集多路信号并且对时间对齐要求很高会有一定误差。一种方法是先把每路信号保持采样再逐个转换但这需要外部采样保持电路。对普通低速传感器采集依次读也够。还有一个常被问到的问题Pico 能用 DMA 做 ADC 连续采集吗答案是 RP2040 硬件支持 ADC 的 FCS 队列和 DMA 请求但官方 MicroPython 固件并没有暴露这一整套机制。如果你非要 DMA 连续采样要么用 C SDK 写底层代码要么选 STM32 系列那边基于 HAL 库的 ADCDMA 多通道扫描已经非常成熟。Pico 的定位是简单好用真要拼 ADC 生产力它不是首选。最后提一句如果你想把 ADC 精度往极限推除了软件滤波和校准可以在硬件上把参考电压做得更干净比如用一个低噪声 LDO 单独给 VDDA 供电。这个方法在 Pico 板子上不太好改但用 RP2040 自己画板时可以这样处理实测有效位数能明显提升。
返回列表