MicroPython传感器实战:从ADC、DHT22到I2C的环境监测小站
不少朋友问MicroPython里传感器应用到底难不难。我的回答是不难但坑不少。这篇是系列文章的第四篇默认你已经把开发板跑通了点过灯、玩过GPIO、知道怎么往板子上传main.py。今天聊的是把这层基础真正用起来——把温湿度、气压、光照这些传感器接到开发板上让板子读到真实世界的模拟量、数字量、总线数据最后攒出一个能稳定跑的环境监测小站。适合刚把MicroPython环境搭建好、想接真实传感器又不想一上来就啃芯片手册的开发者。传感器应用这条线的核心就三件事选对接口、读懂数据、处理异常。先说选型再说模拟量、单总线、I2C三条最常走的路最后用一个综合性案例把前面所有知识点串起来。每段我都会带上自己实测踩过的坑和最后采用的解法比芯片手册实在一些。1. 传感器选型时我踩过的三道坎接口、电平与模块化1.1 数字量和模拟量怎么选很多入门者对着传感器清单发懵温湿度、气压、光照、测距、人体红外名字一大堆不知道先买哪个。我的建议是不要按功能选按信号类型选。MicroPython里传感器就分两类一类输出模拟电压比如光敏电阻模块、土壤湿度模块你要用ADC去读另一类输出数字信号比如DHT22温湿度、BMP280气压、VL53L0X测距这类又分成单总线、I2C、SPI等不同协议。模拟量传感器的优点是简单你可以随时读精确控制采样时机适合变化平缓的光照、湿度、电池电压监测。缺点是抗干扰能力弱杜邦线稍微长一点、电源有点纹波读数就飘。数字量传感器则把模拟处理和数字化全部做在了模块内部微控制器只负责收发数据稳定性明显更好但缺点是通信时序灵活不同芯片的寄存器定义千差万别调试起来要花心思。从学习顺序上我强烈建议先买一两个模拟量传感器练手再上一个单总线的DHT22最后上I2C总线的气压计和OLED屏幕。这样技能树是递进的先懂ADC采样和滤波再懂时序协议和位操作最后懂总线寻址和多设备共存。1.2 电平匹配的教训这块我吃过大亏。MicroPython开发板绝大多数是3.3V逻辑电平但市面上大量传感器模块是面向Arduino做的供电5V、信号输出5V。把5V信号直接怼到3.3V的GPIO引脚上短时间内能工作时间一长芯片内部保护二极管会慢慢损坏最终表现为引脚失灵、板子随机重启。我做过一次比较冒进的测试用一个5V供电的声波测距模块把回波信号线直接接在3.3V开发板的输入引脚上。刚开始读数完全正常用了大概三天后这个引脚就彻底废了读什么都悬空。后来才反应过来问题不在软件在电平。关于电平匹配根据我后来的实践有三条经验值得分享。第一优先选3.3V供电的模块很多传感器厂商现在专门出了3.3V版本比如带稳压和电平转换的模块价格贵几块钱但省心很多。第二如果只有5V模块信号线上加一个分压电阻比如10k和20k分压到3.3V或者用逻辑电平转换板。第三不少5V供电的模块其信号输出引脚其实兼容3.3V输入因为模块内部有分压或开漏输出像声波测距模块这类就要单独查不要想当然。1.3 买模块还是买裸传感器我的长期经验是学阶段买模块做产品再考虑裸芯片。模块已经帮你处理好了上拉电阻、去耦电容、电平转换甚至稳压电路你只需要接四根线。裸芯片虽然便宜一半但你要自己翻手册看时序、焊接、加外围电路一块板子在MicroPython里能解决的问题可能要在硬件上折腾一晚上。以我常用的温湿度传感器为例裸芯片版本只有三个引脚但要求数据线上接一个4.7k到10k的上拉电阻且对电源纹波敏感。而模块版本上电阻、指示灯、滤波电容都焊好了插线就能用。对系列教程这种学习场景时间和注意力应花在理解MicroPython特性和数据解析上而不是跟焊接较劲。2. 模拟量读取ADC不是拿过来就能用的2.1 ADC基础位宽、衰减与量程模拟量传感器输出的电压通常落在0到3.3V之间开发板上的ADC外设负责把这个电压换算成一个整数。以ESP32为例内置ADC位数是12位用MicroPython的ADC.read()读出来是0到4095这个不难理解。难理解的是衰减atten设置我见过不少人在这一步翻车。machine.ADC在创建引脚对象时可以调用atten()方法设置全量程衰减。具体来说ADC.ATTN_0DB对应约0到1.1V的测量范围ADC.ATTN_6DB约0到2.2VADC.ATTN_11DB约0到3.3V。很多模块直接输出0到3.3V的电压信号如果不加衰减配置读数到一定值后就会封顶不再变化你以为是传感器坏了其实是量程没设对。我第一次接光敏电阻模块就碰到这个现象光照变强后读数卡在4095上不动调了半天才发现是默认量程的问题。另一个容易忽略的是可用引脚。ESP32上并不是每个GPIO都能做ADC比如ADC2和WiFi有冲突WiFi打开发射时ADC2读取会不稳定甚至返回错误。如果你发现传感器读数在开WiFi后变得诡异优先怀疑踩中了这条。稳定的做法是只用ADC1的引脚做模拟采集像GPIO32、GPIO33、GPIO34、GPIO35这些都比较可靠。2.2 从单次读取到滑动平均滤波ADC原始读数直接拿来用效果往往很差。电源纹波、传感器噪声、引脚抖动都会造成相邻两次采样数值差出几十甚至上百。我试过最简单有效的方案就是多次采样取平均代码很短效果立竿见影。from machine import ADC, Pin from utime import sleep_ms adc ADC(Pin(34)) adc.atten(ADC.ATTN_11DB) def read_average(samples20): total 0 for i in range(samples): total adc.read() sleep_ms(2) return total // samples采样20次取平均基本能把随机噪声压掉实测光敏电阻读数波动从原来的±80降到±10以内。代价是每个采样点多花40毫秒对变化缓慢的物理量完全可以接受。如果你希望更高阶一点可以在滑动平均基础上引入一阶滞后滤波。核心是让新读数以一定比例逐渐取代旧值平滑程度用系数alpha控制alpha越大越灵敏越小越平滑。公式是filtered alpha * raw (1 - alpha) * filtered其中filtered是上一次滤波结果。这个滤波器很适合光线、土壤湿度这类变化缓慢、但偶尔有尖峰噪声的信号。我有一段时间用它处理光敏电阻数据配合阈值判断做日出日落模拟效果很好。需要注意一阶滤波的初始值要设为第一次原始读数否则启动时会有几秒的偏置期。2.3 标定把ADC值变成物理量滤波只能让数据稳定还没解决ADC值到底是什么的问题。4095不等于光照强度也不等于土壤含水率它只是一个相对值。想要得到有意义的物理量必须标定。标定的思路是找一个线性关系y k * x bx是ADC读数y是要换算的物理量。两个已知点就能算出k和b。比如对于光敏模块我在暗处实测ADC读数是800对应照度接近0在标准室内光下读数是3000用照度计测出来大约500勒克斯。两点就能算出斜率之后每个ADC值都能换算成照度估算值。这个映射不一定在所有光强范围内都精确但对大部分应用已经够用。如果你要更严谨可以采集多个已知点做线性回归MicroPython里用纯Python实现最小二乘法也不难。要注意传感器标定有温漂温度变化大的场景最好在相近温度下标定。还有就是不要迷信网上别人贴的系数每一片传感器个体差异都不小尤其是土壤湿度模块同一个探头插在不同土质里读数完全不同必须自己标。3. 单总线温湿度传感器DHT家族的真实时序坑3.1 单总线通信在干什么DHT11、DHT22这类温湿度传感器用的是单总线协议一根数据线既做供电传输又做双向通信。主机先把总线拉低一段时间发起开始信号然后释放总线传感器应答后再从同一个引脚上发出一串40位数据。这40位包含湿度整数、湿度小数、温度整数、温度小数和校验和每段都是8位。听起来很麻烦但MicroPython官方库已经把这个时序封装好了你要做的只是创建一个dht.DHT22对象然后调measure()方法再通过temperature()和humidity()拿到结果。对于只想用传感器的人来说这一层封装非常香你不用去管时序延迟到底要精确到多少微秒。3.2 MicroPython里读取DHT的怪脾气但封装归封装不代表没有坑。第一个坑是传感器首次上电需要预热。我刚买回DHT22时每次烧录程序后第一次读取必然超时报OSError: [Errno 116] ETIMEDOUT重启后又好了。后来是因为传感器上电稳定需要至少一秒尤其是刚从冷启动通上电的时候。解决办法是在程序最开始sleep(1)之后再调用measure()。第二个坑更隐蔽DHT的读取间隔有着严格限制。DHT11建议间隔不小于1秒DHT22建议间隔不小于2秒连续快速读取会让传感器内部状态还没准备好返回数据要么报错要么是上一次的值。我在一个循环里忘记加time.sleep(2)每500毫秒读一次结果屏幕上温湿度数字基本不动看起来像是传感器坏了。后来查序列发现确实是读取频率过高。如果你发现DHT数据卡住先检查两件事一是sleep间隔够不够二是初始化后有没有预热。第三个坑是数据偶发报错。DHT时序对线长和干扰非常敏感杜邦线超过20厘米、附近有电机或者继电器切换都有概率读出一段损坏数据。官方驱动会对校验和做检查数据不合规就抛OSError。很多人在这一步会写一个不管什么异常都往串口打日志的循环但正确的做法是捕获异常后短暂延时重试连续重试三到五次再判定传感器故障。import dht from machine import Pin import time sensor dht.DHT22(Pin(4)) time.sleep(1) def read_dht(retries5): for i in range(retries): try: sensor.measure() return sensor.temperature(), sensor.humidity() except OSError: time.sleep(0.3) return None, None3.3 硬件层面的排查软件重试能掩盖掉一部分偶发问题但如果你发现DHT报错率持续偏高基本可以确定是硬件问题而非时序问题。我排查过几次最常见的原因是缺少上拉电阻。DHT模块的数据线在主机释放总线后需要靠上拉电阻把电平拉高如果模块本身没有带这个电阻数据线悬空时噪声很大。我的建议是优先买带板载上拉电阻的模块省事很多。如果用的是裸传感器在数据线和3.3V之间接一个4.7k到10k的电阻。第二个原因是供电电压。DHT22在3.3V下可以工作但数据时序余量比5V供电时小一些如果电源质量不好读取更容易失败。为了稳定我后来把DHT模块独立用3.3V供电但电源线上并联一个10微法电解电容报错率明显下降。第三个原因是杜邦线太长。20厘米以内最稳超过这个长度就建议换短线或者用屏蔽线。4. I2C总线上的传感器与调试三板斧4.1 硬I2C还是软I2CI2C是传感器应用里最高频的总线协议气压计、加速度计、测距传感器、OLED屏幕基本都走I2C。MicroPython创建I2C对象时有硬I2C和软I2C两种方式。硬I2C复用芯片上的专用外设时序稳定、占用CPU少但引脚必须用硬件约定的那一组。软I2C则完全靠代码模拟时序GPIO随便指定灵活度高但CPU占用高数据量大时容易不稳定。实际项目中我优先用硬I2C原因是稳定。ESP32上通常I2C0的默认引脚是SCLGPIO22、SDAGPIO21。但如果这些引脚被其他功能占用也可以换用软I2C只要总线设备不超过几米距离、通信频率不高表现完全可以接受。我见过有人用软I2C同时挂三四个传感器跑了几星期也没事。关键是要限制通信频率一般用100kHz或400kHz不要盲目开1MHz。4.2 总线扫描与地址冲突多设备接同一条I2C总线时最容易踩的坑是地址冲突。每个I2C设备都有固定的七位地址比如某型号的气压计地址通常是0x76或0x77但如果你买了两片同型号的模块它们默认地址一样同时接在同一总线上就会互相干扰。MicroPython里想查设备地址最快的方式是先接一个设备然后跑扫描脚本。from machine import Pin, I2C i2c I2C(0, sclPin(22), sdaPin(21), freq400000) devices i2c.scan() for d in devices: print(0x{:02x}.format(d))如果扫描结果显示多个地址说明设备都正常上线如果某个地址时有时无优先怀疑接触不良或电源不足。地址冲突的解决方案有三条查芯片手册看有没有地址选择引脚很多传感器模块上留了跳线短接后能把地址改到另一个值用带地址切换功能的模块如果实在改不了地址那就给这个设备单独拉一条I2C总线ESP32上多几个I2C对象也消耗不了多少资源。4.3 寄存器读取的正确姿势用MicroPython读I2C传感器核心是readfrom_mem和writeto_mem这两个方法它们相当于先向指定寄存器写入指令再从指定地址读取若干字节。很多入门者会误用readfrom和writeto这两个方法是纯读写缓冲区不带寄存器地址一旦设备有多个寄存器就会读到错误的寄存器数据。以常见的某气压传感器为例读取校准系数时需要依次从0x88、0xA1等寄存器读数据。用i2c.readfrom_mem(addr, 0x88, 24)一次就能拿到一串数据。写操作同理向控制寄存器0xF4写入0x2E表示执行一次温度测量用i2c.writeto_mem(addr, 0xF4, b\x2e)。明白了这个套路几乎所有I2C传感器都能通过芯片手册快速写驱动而不用依赖第三方库。需要补充的是不同传感器对寄存器连续读的支持不同。有的支持一次读多个字节有的只支持单字节读。特别是EEPROM类存储芯片连续读地址会自动递增但很多传感芯片的校准参数寄存器并不连续递增。我踩过这个坑用readfrom_mem连续读24字节结果前几字节正常后面全是乱码最终发现是芯片不支持跨寄存器连续读。解决方法是分成多次单字节读再在Python层拼装。4.4 上拉电阻最容易忽视的稳定因素I2C总线靠开漏输出工作SCL和SDA线上必须有上拉电阻才能把电平拉高。如果模块本身板载了上拉电阻接线后测一下SCL对地电压正常应该在3.3V附近说明上拉没问题。如果测出来只有零点几伏总线被某个设备拉低或者上拉电阻缺失那就必须自己加4.7k左右的电阻到电源。你可能觉得这事和软件无关但总线不稳定会呈现各种奇怪的软件表现。我在调试一个双传感器项目时气压计偶发报错扫描地址时有时只能扫到一个设备查了很久的代码最后发现是两条杜邦线太长、模块上拉电阻偏弱高频下波形变形。把I2C频率从400kHz降到100kHz后立即稳定。这个经验非常管用遇到I2C不稳定先降频再去量波形、查上拉。5. 综合实战攒一个环境监测小站5.1 硬件组合与接线前四节的内容在真实项目里是同时出现的所以我做了一个综合案例一个便携环境监测小站采集温度、湿度、气压、光照四个量在OLED上显示并把数据以行文本形式写到开发板文件系统里。整套由DHT22、某气压计模块、光敏电阻模块和一块0.96寸OLED组成。接线方案如下DHT22数据脚接GPIO4供电接3.3V气压计和OLED共用I2C0总线SCL接GPIO22、SDA接GPIO21电源都接3.3V光敏模块的模拟输出引脚AO接GPIO34。气压计和OLED地址不同一个常见是0x76一个常见是0x3C可以安全挂同一条总线。如果你手里的模块没有板载上拉电阻在总线上额外加两个4.7k电阻到3.3V。5.2 数据采集主循环与容错设计环境小站最大的问题不是采集而是一个传感器异常把整个程序带崩。所以我设计了独立采集函数每个传感器单独try/except任何一路失败都只记录错误不影响其他数据。def read_all_sensors(): data {} try: dht.measure() data[temp] dht.temperature() data[hum] dht.humidity() except OSError: data[temp] None data[hum] None try: data[pressure] bmp.pressure / 1000.0 except Exception: data[pressure] None data[light] read_average() return data主循环每5秒采集一次第一次睡眠放在DHT初始化之后。每次采集结束把字典转成一行文本追加写入日志文件同时刷新OLED上对应的行。睡眠用time.sleep(5)这是最省脑的写法。如果要更精细可以计算每次循环实际耗时再减去这个耗时作为sleep时间让采集周期更准确。我实际跑这个系统时发现一个有意思的问题OLED刷新本身会阻塞几百毫秒如果在采集DHT的同时刷屏偶尔会引发DHT报错。解决起来很简单把采集和显示拆成两个函数采集完成、数据整理好之后才去刷屏不要让显示过程夹在两次DHT读取中间。5.3 日志写入与文件系统注意事项MicroPython默认把脚本和数据都存在板载Flash里频繁写入会缩短Flash寿命且让文件系统碎片化。我在日志写入这里做了一点妥协只在内存里维护最近的100条数据每隔10分钟才批量写一次文件。这样既保留了点历史记录又减少了写入次数。如果你需要长期记录建议外接一张SD卡模块SD卡比板载Flash耐操得多而且在电脑上直接读卡处理数据也方便。日志格式可以用CSV也可以直接用JSON。我习惯每行一个JSON对象因为后续用脚本做统计时可以直接按行解析。import json log_line json.dumps(read_all_sensors()) with open(env.log, a) as f: f.write(log_line \n)这里还有个容易踩的坑MicroPython对文件打开句柄有限制我在循环里频繁open、close运行了几天后报OSError: [Errno 12] ENOMEM其实是句柄泄漏。后来我把文件打开放在循环外整个运行期间保持句柄写入后flush()消耗稳定多了。5.4 如果做电池版必须考虑深睡上面这套系统一直常驻电力的功耗不算高但用电池也撑不了几天。我后来的一个版本加了深睡模式采集完成后开发板进入deepsleep(60000)一分钟醒来一次采样、写入、立即再次沉睡。为了保留已采集数据的完整性我把每次采集的数据先写到RTC内存里DeepSleep唤醒后读取RTC内存再叠加新数据一起写文件。当然这样就不太适合每次都在日志里追加了因为文件系统打开和写入本身很耗电。更合理的做法是每小时写入一批中间全部存在RTC内存里。如果你从来没有碰过DeepSleep建议先看官方文档确认哪几个GPIO能唤醒然后从最简单的每秒唤醒点亮一盏LED开始试。这块内容展开会很长我计划在系列的下一篇专门写低功耗传感器的完整经验。说回这套环境小站本身。它在我的桌面上连续运行了一周最不稳定的环节依然是DHT22那根数据线偶尔报一次超时气压计和OLED一直很稳光敏电阻的读数在白天有规律地变化晚上则平缓得像一条直线。如果你也在做类似的环境采集建议先把最不稳定的那一路传感器抓到手上反复测试——把DHT读取间隔拉长、把I2C频率降下来、把每个传感器的异常都单独兜住。等所有数据都能稳定稳定打印出来了再去考虑数据上云、联动控制那些花活。数据可靠永远是第一位的这一点在MicroPython里和在别处没有任何区别。