免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

树莓派Pico ADC软件控制全解析:API、定时采集与ISR避坑

树莓派Pico ADC软件控制全解析:API、定时采集与ISR避坑 我没有找到与“全网最详细树莓派 Pico ADC 软件控制全解析从machine.ADC API 到定时温度采集实战含 ISR 避坑指南”直接相关的信息。不过我可以根据这个话题为你撰写一篇关于树莓派 Pico ADC 软件控制的详细技术博文重点涵盖 machine.ADC API 的使用、定时温度采集的实现以及中断服务程序ISR的常见陷阱与规避方法。1. 别急着接传感器先把 Pico 的 ADC 底细摸清楚树莓派 Pico 这块板子论性价比和生态在嵌入式圈子里确实能打。很多人拿到手第一件事就是点灯、跑个呼吸灯然后就开始接各种各样的传感器。可一旦涉及到模拟量采集——比如电位器、光敏电阻、或者本次要讲的温度传感器——不少人就开始踩坑了。最典型的现象是读数跳得离谱、电压范围对不上、接上传感器之后数值一动不动。这些问题的根源往往不是传感器坏了而是对 RP2040 这颗芯片的 ADC 外设理解不到位。Pico 的 ADC 是一个 12 位逐次逼近型SARADC理论上有 0~4095 的读数范围。但注意它内部没有独立的电压基准源参考电压直接接在3.3V 电源轨上。这就意味着电源纹波会直接影响采集精度而且如果你用 USB 供电再接一些大电流外设读数抖动几乎是必然的。另外一个特别容易坑人的地方是Pico 的 ADC 输入引脚耐压是有限的。GPIO26~GPIO28 以及 ADC_VREF 引脚绝对不能直接输入高于 3.3V 的电压。很多人拿 Pico 去测 5V 逻辑电平或者锂电池电压结果就是引脚烧毁、芯片报废。板子上其实丝印也写了但新手一般注意不到。所以做 ADC 项目之前得先建立一个认知ADC 不只是读寄存器它涉及参考源、输入阻抗、采样时间、引脚复用、以及软件层面的滤波策略。这篇文章就围绕 machine.ADC 这个 API 展开一步一步把 Pico 的 ADC 用到实处。2. machine.ADC API 的核心用法与三个隐藏细节MicroPython 的 machine.ADC 接口看起来很简单创建对象、读值、完事。但实际用下来里面有三个细节如果没搞清楚够你调半天的。2.1 通道号与引脚号的对应关系Pico 上一共有 5 个 ADC 通道但其中 4 个是真正引出到引脚的通道号对应引脚默认功能备注0GPIO26ADC0常用1GPIO27ADC1常用2GPIO28ADC2常用3GPIO29ADC3通常用于测量 VSYS通过电阻分压4内部温度传感器ADC4只能通过 machine.ADC(4) 读取这里特别要注意machine.ADC(4)这个写法。它和前面几个不一样不需要指定引脚而是直接传入通道编号 4。很多人写machine.ADC(Pin(26))习惯了到了内部温度传感器就懵了不知道传什么参数进去。另外MicroPython 的 ADC 构造函数有两种传参方式传 Pin 对象或者直接传整数通道号。传 Pin 对象更直观但如果你想用 GPIO29 测系统电压直接传machine.ADC(3)会更方便因为那个引脚默认被复用为 ADC 功能了。2.2 read_u16() 与 read_uv() 的区别这是我认为 machine.ADC 里最容易被忽略的设计。read_u16()返回的是一个 0~65535 的整数看起来好像是 16 位 ADC但实际上它只是把 12 位的原始值左移了 4 位。也就是说真实分辨率仍然是 12 位只是数值范围被撑大了。而read_uv()返回的是微伏单位的电压值内部会根据 3.3V 参考电压做换算。我建议能用read_uv()就用它因为省去了你自己换算的步骤而且代码可读性更强。如果你非要自己算公式是实际电压V read_u16() / 65535 * 3.3不过说实话如果你需要比较高精度的电压测量3.3V 这个值最好实际用万用表测一下很多 Pico 板子的 3.3V 实际输出可能只有 3.29V 甚至 3.28V理论计算和实际值之间差个几十毫伏很正常对精度要求高的场景会有影响。2.3 采样时间的坑RP2040 的 ADC 支持配置采样时间。MicroPython 目前没有直接暴露这个配置项但固件编译时是有默认值的。默认采样时间是 96 个 ADC 时钟周期。对于大多数低内阻的传感器信号源比如运放输出、稳压电源分压这个时间是足够的。但如果你直接接一个高内阻的信号源比如某些不带缓冲的电位器、或者几百千欧的分压网络ADC 内部的采样电容在有限时间内充不满读数就会偏低并且不稳定。解决办法有两个一是外部加一个运放做缓冲二是用软件多次采样取平均勉强缓解。真到了精密测量场景建议还是老老实实加运放。3. 从读寄存器的角度理解为什么 ISR 里不能做复杂事到了这一节才算是进入标题里ISR 避坑指南的前置知识铺垫。本期主要讲定时温度采集必然涉及定时器中断。但很多人对中断服务函数里能写什么这个概念比较模糊我在这里用大白话拆解一下。3.1 中断服务函数的黄金准则先抛结论ISR 里只做标记不做计算不打印不延时不动态分配内存。为什么因为中断是对正常程序流程的插队。CPU 正在执行主循环里的y x * 2突然定时器到期CPU 得停下来去执行 ISR。如果 ISR 一执行就是几毫秒比如里面放了个print那主循环里的实时响应就全部被破坏了。具体到 RP2040 和 MicroPython 上print走 UART 输出波特率 115200 时一个字符就要差不多 87 微秒打印一行 20 个字符就是 1.7 毫秒。在 1kHz 的中断频率下这直接就占了 170% 的 CPU 时间——直接把系统拖死。3.2 正确姿势置标志位 主循环处理标准做法是ISR 里只设置一个布尔标志或者累加一个计数器然后回到主循环去检查这个标记再做耗时操作。虽然从实时性角度看主循环处理有延迟但对于定时采集温度这种毫秒级甚至秒级任务来说完全够用。MicroPython 的 machine.Timer 回调本质上已经是一个软件中断环境了回调函数里直接操作全局变量是安全的前提是 GIL 的保护同一时刻只有一个 Python 线程在跑但千万别在里面做print、time.sleep、或者申请大列表这种操作。那如果真的需要在采集时刻记录一个精确的时间戳呢答案是用ticks_ms()或者ticks_us()拿一个数值然后回主循环再处理。这样既保证时间精度又不在 ISR 里做耗时的字符串拼接和输出操作。4. 定时温度采集实战用 NTC 热敏电阻做一个可用系统现在进入正题完整实现一个每 5 秒采集一次环境温度并打印输出的小系统。我选 NTC 热敏电阻做传感器因为它是性价比最高、也最容易买到的一种温度传感器而且 B 值公式能体现出 ADC 软件控制的完整链条分压电路 - ADC 采样 - 查表或公式计算 - 输出。4.1 NTC 分压电路设计NTC负温度系数热敏电阻的阻值随温度升高而降低。单个 NTC 没法直接接到 ADC 上因为 ADC 要测的是电压不是电阻。所以需要一个分压电路把阻值变化转换成电压变化。我这里选的是 10k NTC 10k 固定电阻接法如下3.3V ──[ 10k 固定电阻 ]──┬── GPIO26 (ADC0) │ [ NTC 10k ] │ GND这个分压电路的特点是在 25°C 时NTC 阻值正好是 10k分压点电压正好是 1.65V也就是 ADC 量程的中间值。温度升高NTC 阻值下降分压点电压下降温度降低NTC 阻值上升分压点电压上升。4.2 B 值公式与代码实现NTC 的阻值与温度的关系可以用 B 值公式描述1/T 1/T0 (1/B) * ln(R/R0)其中 T0 是 298.15K25°CR0 是 10kB 值一般取 3950常见 NTC 型号参数。整理之后温度计算公式是T 1 / (1/298.15 (1/3950) * ln(R / 10000)) - 273.15对应的 MicroPython 代码如下import machine import math import time adc machine.ADC(26) FIXED_RESISTOR 10000 # 固定电阻阻值单位欧姆 B_VALUE 3950 # NTC B值 ROOM_TEMP_K 298.15 # 25摄氏度的开尔文温度 R0 10000 # NTC在25度时的阻值 def read_ntc_temperature(): # 读取ADC原始值取100次平均降低噪声 raw 0 for _ in range(100): raw adc.read_u16() raw / 100 # 将16位读数转换为分压点的电压 voltage raw / 65535.0 * 3.3 # 根据分压电路公式反推NTC当前阻值 # Vout 3.3 * R_ntc / (R_fixed R_ntc) # 所以: R_ntc Vout * R_fixed / (3.3 - Vout) r_ntc voltage * FIXED_RESISTOR / (3.3 - voltage) # 使用B值公式计算温度开尔文 temp_k 1.0 / (1.0 / ROOM_TEMP_K (1.0 / B_VALUE) * math.log(r_ntc / R0)) temp_c temp_k - 273.15 return temp_c while True: temp read_ntc_temperature() print(Temperature: {:.2f} C.format(temp)) time.sleep(5)这段代码跑起来就能每 5 秒输出一次温度。但注意几个细节每次采集取了 100 次样本求平均是为了降低单次 ADC 读数的随机噪声计算 NTC 阻值用的是电阻分压反推公式B 值公式里 ln 是对自然对数别写成了 log10。4.3 为什么取 100 次平均而不是 10 次有人可能会问取平均不仅能降噪但取多少次合适这里有一个平衡问题。ADC 本身有量化噪声和参考源噪声加上电磁干扰单次读数的随机误差可能有 ±10~20 个 LSB对应电压大概 8~16mV。取 10 次平均噪声降到原来的 1/sqrt(10) ≈ 0.32 倍取 100 次平均降到 0.1 倍。但 100 次采样是有时间成本的——每次 read_u16() 大约要 20~30 微秒取决于固件实现和时钟频率100 次就是 2~3 毫秒。对于 5 秒一次的采集间隔来说3 毫秒微不足道所以取 100 次完全划算。如果你做的是高速采样比如几千赫兹那就不适合这样暴力平均了得考虑硬件滤波或者更高级的软件滤波算法比如滑动平均、中值滤波、卡尔曼滤波之类的。幅度和响应速度之间永远是个 trade-off。5. 定时器 标志位实现定时采集的正确架构真实的嵌入式系统里不会像上面的示例那样用while True sleep(5)来定时。虽然对于这个简单场景能用但如果主循环里还有其他任务比如处理按键、刷新 OLED、保存数据到 Flashsleep 会阻塞整个主循环。更合理的做法是使用machine.Timer中断来产生节拍主循环快速巡检标志位。5.1 定时器初始化的完整代码import machine import time # 定义全局标志 sample_flag False def timer_callback(timer): # 这里不做任何耗时操作只置标志位 global sample_flag sample_flag True # 初始化定时器每5秒触发一次回调 timer machine.Timer() timer.init(period5000, modemachine.Timer.PERIODIC, callbacktimer_callback) while True: if sample_flag: sample_flag False # 先清标志再处理数据 temp read_ntc_temperature() print(Temperature: {:.2f} C.format(temp)) # 其他主循环任务可以在这里做这个结构最大的好处是主循环不会被阻塞即使温度采集计算遇到了耗时操作比如 float 运算、print 输出定时器中断依然能准时置标志位不会丢拍。5.2 为什么先清标志位再处理数据这是一个经典的小技巧。如果在处理完数据之后再清标志可能会出现这种情况处理数据途中定时器又触发了一次标志位被置为 True你刚处理完还没来得及清下一次循环就再触发一次同样的采集。虽然最终结果无非是多采了一次但在某些实时性要求高的场景这会累积误差。所以规矩是进入处理之前先把标志位清掉让下一轮中断的触发能够被有效记录。5.3 定时器精度的讨论RP2040 的 machine.Timer 是基于内部定时器外设实现的精度在几十微秒级别。对于 5 秒这种量级的定时误差完全可以忽略。但如果你需要高精度采样间隔比如精确到微秒的 ADC 采样时钟那得看 RP2040 的 PWM 模块或者 DMAPIO 那套方案了machine.Timer 本身就力不从心了。microPython 的 Timer 更适合做周期性提醒而不是高精度采样时钟。6. 实测遇到的坑从读数跳动到无输出的完整排查链路我自己在 Pico 上用 ADC 做温度采集时可以说每一步都踩过坑。下面把最典型的几个问题按排查顺序列出来希望能帮大家省下排查时间。6.1 通电后读数完全不变化这是一个非常经典的问题。你烧好固件接好电路读到的 ADC 值永远是同一个数字——或者永远是 0或者永远是 4095也就是 read_u16 的 65535。这时候先别怀疑传感器接反了先用万用表测分压点的电压。我遇到过的真实原因NTC 引脚和固定电阻之间的导线接触不良。面包板插得不紧、杜邦线质量差、或者焊接处虚焊都会导致 ADC 引脚实际上悬空。悬空时 ADC 引脚电压不确定读值可能随机漂移也可能固定在一个值。解决方法是重新插拔、换杜邦线、或者直接焊上。另一种可能是GPIO26 旁边那个引脚 ADC_VREFPin 35没有接好。虽然大部分 Pico 板子上 ADC_VREF 已经通过 0 欧电阻连到 3.3V 了但如果你的板子不是标准的比如某些定制版ADC_VREF 悬空的话ADC 模块根本没有参考电压读数就会归零。6.2 读数在某个小范围内剧烈跳动这种情况非常常见尤其是用 NTC 的时候。表现为同一温度下读到的温度值在 24.5°C 到 26.8°C 之间剧烈跳动。排查链路如下先确认是不是电源问题用 USB 供电时如果同时接了大负载如 WiFi 模块、舵机、LED 灯带3.3V 会有很大的纹波。ADC 的参考源直接取自 3.3V参考源在抖读数当然跟着抖。解决方法给 ADC 部分单独用 LDO 供电或者至少给 3.3V 加一个大一点的电容滤波比如 100uF。再用read_uv()读数确认跳动幅度。如果跳了 30mV 以上基本可以确定是参考源问题或者外部干扰。最后检查连接线。NTC 分压点的导线如果过长超过 20cm相当于给 ADC 输入端加了一根天线环境中的电磁干扰会直接耦合进去。解决方法使用双绞线或者屏蔽线尽量缩短距离。6.3 温度值明显系统性偏高或偏低如果读数稳定但数值不对比如实际 25°C 但读出来 28°C问题通常在分压电路计算或者固定电阻精度。固定电阻我经常看到有人用 5% 精度的碳膜电阻误差 5% 意味着 10k 实际可能是 9.5k 到 10.5k。这个误差会直接通过分压公式传导到温度值上。你需要做的用万用表实测固定电阻的真实阻值然后修改FIXED_RESISTOR常量。另外B 值公式里的B_VALUE也是近似值真正的 B 值在芯片手册里通常给一个范围比如 3800~4100不同批次可能不一样。如果精度要求高建议做两点或三点校准反推出实际 B 值。6.4 定时器回调里直接 print 导致死机这个坑我在前面已经埋了伏笔。如果直接在timer_callback里写print第一次触发可能没问题第二次可能也没问题但多触发几十次后你会发现程序卡死或者输出乱掉。原因是print会申请锁UART 输出锁如果主循环也在 print两个 print 就会竞争同一个锁在 MicroPython 这种实时性有限的环境下可能出现死锁。所以再次强调ISR 里只置标志位不要在中断回调里打印输出。把 print 移到主循环去你会省下很多莫名其妙的调试时间。7. 进阶校准、滤波与数据落地基础的温度采集跑通之后如果想把它变成一个真正可靠的小工具还有几件事值得做。这里选三个最实用的方向展开。7.1 单点校准法修正系统误差前面 B 值公式计算出的温度在 25°C 附近一般是准的因为 R0 和 T0 本来就是这个点定义的但温度偏离后误差会变大。我们可以做一个简单校准用冰水混合物0°C和沸水100°C但高原地区沸点会变或者体温37°C做参考测量实际读数然后做一个两点线性修正。实现逻辑很简单def calibrated_temp(raw_temp_c): # 假设校准前25°C时读为26.5°C实际为25.0°C # 假设校准前60°C时读为63.2°C实际为60.0°C cal_point1 (26.5, 25.0) # (读数值, 实际值) cal_point2 (63.2, 60.0) slope (cal_point2[1] - cal_point1[1]) / (cal_point2[0] - cal_point1[0]) intercept cal_point1[1] - slope * cal_point1[0] return slope * raw_temp_c intercept注意这个校准的前提是传感器的响应曲线比较接近线性。对于 NTC 这种指数型曲线跨度过大的线性校准确实会有一些残余误差但如果你只在 10°C~50°C 这个范围内使用线性修正的误差通常能控制在 ±0.5°C 以内已经能满足大多数场景了。7.2 滑动平均滤波与中值滤波的取舍前面已经提过多次采样取平均的方法。这里补充一个更高级一点但同样简单的方案滑动平均滤波器。它适合连续采样的场景一边采一边更新不像采 100 次算完再采下一次那样有整段延迟。class MovingAverageFilter: def __init__(self, window_size10): self.window [] self.window_size window_size def add_sample(self, value): self.window.append(value) if len(self.window) self.window_size: self.window.pop(0) return sum(self.window) / len(self.window)但滑动平均有一个致命弱点如果信号里有尖峰毛刺比如电机启动时的电磁干扰导致读数瞬间跳到 3000它会把这个毛刺平均进去导致输出值偏高。这个时候用中值滤波更合适取 N 个样本排序后取中间值可以完美滤掉孤立的毛刺。我自己在项目里一般是先做中值滤波剔除毛刺再做滑动平均平滑曲线两层叠加效果非常稳。代价是内存占用多一点要缓存 N M 个样本但对 Pico 来说完全不是问题。7.3 把数据记录到文件MicroPython 的 Pico 有个闪存文件系统可以把温度数据写入 CSV 文件后续用 Excel 或者 Python 分析。一个最小实现是def log_temperature(filename, temp_c, timestamp_ms): with open(filename, a) as f: f.write({},{:.2f}\n.format(timestamp_ms, temp_c)) # 在while循环里调用 # log_temperature(temp_log.csv, temp, time.ticks_ms() - start_time)这里有几个值得注意的点第一不要在 ISR 里写文件。Flash 写入是有延迟的而且频繁写入会损耗 Flash 寿命。应该把日志写入放到主循环里。第二注意文件句柄的打开和关闭。MicroPython 的with open会每次打开再关闭如果采集频率很高每秒上百次这种频繁的开关会严重影响效率。更好的做法是定时集中写入每 10 分钟打开文件写一次、写入一批数据、然后关闭。第三Pico 的文件系统是挂在 Flash 上的频繁小数据写入容易造成磨损。如果要长期记录数据建议外接 SD 卡或者通过 USB 串口把数据发送到上位机存储。8. 终极避坑总结ISR、ADC 与电源管理的黄金法则文章最后把这段时间积累的经验浓缩成几条可以贴在工位上的法则。这些不是建议而是我在实际项目中交了学费换来的硬经验。第一条ADC 参考电压就是 3.3V别指望它精准。如果你的项目需要测量绝对电压比如测电池电量一定要用万用表实测 3.3V 的实际值并做软件校准。如果实测值是 3.28V而你代码里硬写 3.3V那么测 2.5V 的电压时误差会达到 15mV 左右。对 12 位 ADC 来说这已经丢了将近 20 个 LSB 的精度。第二条ISR 是标记处不是处理处。定时器回调里永远不要 print、不要 sleep、不要申请内存、不要操作文件系统。只做三件事置标志位、累加计数器、读取一个时钟 tick 值。其他的统统留给主循环。这个原则不止适用于 Pico适用于所有嵌入式平台的微控制器开发。第三条传感器信号线越短越好。NTC 分压点之后的引线长了引入的噪声是灾难级的。如果实在没办法要长距离传输可以考虑在传感器端加一个电压跟随器运放或者用恒流源驱动 差分传输。总之ADC 输入端的阻抗越低、引线越短信噪比越高。第四条电源质量决定 ADC 精度。用 USB 直接供电时如果你的板子上还接了舵机、电机、或者功率较大的 LED几乎必然在 ADC 读数上看到周期性抖动。这时候要么用独立 LDO 给 ADC 模拟部分供电要么在程序和硬件上做滤波。我见过最狠的做法是直接外接一个 18650 锂电池给 Pico 供电然后 USB 只用来做串口调试ADC 读数瞬间就稳了。第五条MicroPython 的 Float 运算不算快但够用。每次温度采集都要做对数运算、除法、乘方MCU 上确实需要一点时间。实测下来一次完整的 NTC 温度换算B 值公式大约需要 1~2 毫秒。对于秒级的采集间隔来说毫无压力。但如果你要做 kHz 级别的实时采样这种计算方式就不合适了得预处理成查找表LUT的方式来查表计算温度把计算时间压缩到微秒级。9. 一个值得尝试的扩展方向多通道轮询采集既然 ADC 有 4 个外部通道只用 GPIO26 接一个 NTC 有点浪费。做一个多通道轮询采集相当于同时监控多个位置的温度这是非常实用的扩展。思路不复杂把 NTC 分压电路分别接在 GPIO26、GPIO27、GPIO28 上软件里循环读取三个通道。一个很重要的硬件注意点是每个 NTC 需要有独立的分压电路不能三个 NTC 复用一个固定电阻不然会互相干扰分压关系就彻底乱套了。软件上最朴素的做法就是建一个列表循环创建 ADC 对象并读取adc_channels [machine.ADC(26), machine.ADC(27), machine.ADC(28)] for adc_ch in adc_channels: # 对每个通道执行读取和计算 # ...但要注意切换通道后ADC 内部需要一小段稳定时间microPython 的库已经处理了但如果是自己写驱动得在切换后加一点延时。另外多通道采集对电源的要求更高因为 3.3V 上接了三组分压电路总电流会大不少。如果电源不稳三路读数都会飘。更进一步可以用machine.UART把数据发送给上位机配合 pyserial 在电脑上实时绘图或者接一个 OLED 显示屏做一个便携式多路温度计。这些扩展做下来ADC 的软件控制能力基本就算打通了。就我个人而言Pico 的 ADC 虽然算不上高端但配合 MicroPython 的开发效率和 RP2040 这块芯片的超低价格做原型验证、课程实验、小工具类项目完全够用。关键是先把机器.ADC 的底层逻辑搞清楚再上手撸代码思路顺了踩坑自然就少了。希望这篇长文能让你少走一些我走过的弯路。
返回列表