前情提要
第一篇到第三篇用的是一块吃灰的 NodeMCU(ESP8266 / 4MB Flash)。到第三篇结束时,它能自己开热点配网、连路由器、网页控 LED 和 4 路继电器、OTA 传 .mpy、开机 UDP 广播 IP,还配了个微信小程序。
本来不打算折腾了,但是买的 AHT20 温湿度模块到货了,先在这块板子跑一下吧,不然还是有点遗憾。
AHT20 是 I2C 接口,测温度 -4085°C、湿度 0100%RH,价格十块出头。它有两个兄弟常被拿来替代:SHT30(精度更好、贵一些)、DHT11(单总线、便宜、精度差)。选 AHT20 的理由是它同时有 I2C 和出厂校准,接线只有四根。
先算这块板子还剩哪两个脚
8266 的引脚不能凭印象挑。这块 NodeMCU 上已经分出去的东西:
| GPIO | 板上用途 | 能不能给 I2C |
| 0 | 空(D3),有内部上拉 | 可以 |
| 1 | UART0 TX,就是 COM10 的日志口 | 不能,占了看不到调试信息 |
| 2 | 板载 LED,低电平点亮 | 能用,但通信期间灯会一直闪 |
| 3 | UART0 RX | 不能 |
| 4 | 继电器第 1 路(D2) | 不能 |
| 5 | 继电器第 2 路(D1) | 不能 |
| 6 ~ 11 | 接 SPI Flash | 碰了不启动 |
| 12 | 继电器第 3 路 | 不能 |
| 13 | 空(丝印 MTCK) | 可以 |
| 14 | 继电器第 4 路 | 不能 |
| 15 | 板上有 10k 下拉电阻 | 不能做 SCL,上升沿爬不起来 |
| 16 | RTC 域,没有内部上拉 | 有条件可用 |
这里有个容易踩的地方:网上关于 NodeMCU 接 I2C 的教程,几乎都写 SCL = D1 = GPIO5、SDA = D2 = GPIO4。那是默认板子上这两个脚是空的。这块板子的 5 和 4 已经给了继电器第 1、2 路,照抄教程的结果是接线那一刻继电器就开始动。
所以第一版方案定成 SCL = GPIO13、SDA = GPIO0:两个都不在继电器占用的四个脚里,GPIO0 有内部上拉,GPIO13 空着没接东西。
方案听着合理,但后面第五节的结论是 SDA 实际上插在了 GPIO16 上。先记着这件事。
8266 没有硬件 I2C
这一点和 C3 不一样,值得先讲清楚,因为它决定了后面所有的行为。
ESP8266 芯片内部没有 I2C 外设。machine.I2C 在这块板子上不存在,能用的只有 machine.SoftI2C——拿两个普通 GPIO 拉时序软件模拟出来的。三个直接后果:
一,引脚可以随便挑。 不需要找"硬件 I2C 脚",只要不是特殊功能脚(串口、Flash、带固定上下拉的)都能当 SCL/SDA。这也是为什么第一节能自由改到 13 和 0。
二,速率受 CPU 和中断影响。 硬件 I2C 的时序由外设保证,软件模拟的靠 sleep_us 卡时间,WiFi 中断一插进来就可能被拉长。所以 100kHz 未必稳,跑不通要往下降。
三,上拉电阻必须真的存在。 I2C 是开漏总线,高电平靠上拉拉起来。8266 内部上拉大约 30~100kΩ,偏弱,配上线上电容之后上升沿变得很缓,400kHz 下肯定读错。AHT20 模块自带 4.7k 上拉,正好补上这个缺口。
第二、三点合起来解释了一件事:同一份代码在 C3 上直接能跑(C3 有硬件 I2C、内部上拉也够),搬到 8266 上要重新调参数。这不是代码质量问题,是两个芯片的结构差异。
驱动里三个不明显的决定
sens.py 一百多行,其中三个决定值得单独说,因为它们都是被硬件特性逼出来的。
第一个:读数缓存 3 秒。
AHT20 每次测量要发命令、等 80ms、再读数据。这 80ms 是芯片内部转换时间,改不了。而网页每 5 秒拉一次 /api/status,如果每次都真读一次总线,页面响应就会固定多出近 100ms。
加一层 3 秒缓存后,多个请求共用同一次读数:
CACHE_MS = 3000
if not force and _t is not None and time.ticks_diff(now, _last) < CACHE_MS:
return (_t, _h)
温湿度这种物理量,3 秒内的变化小于传感器分辨率,缓存不损失信息。
第二个:连续失败 5 次就停用。
传感器线松了、模块坏了,readfrom 会抛 OSError。如果不处理,之后每个请求都要等一次 80ms 超时,页面会一顿一顿,而且看不出原因。
except Exception as e:
_fail += 1
if _fail >= 5:
_ok = False
print("[SENS] lost after %d failures: %s" % (_fail, e))
停用之后直接返回缓存值,页面不卡,串口留一行日志说明什么时候掉的。
第三个:20 bit 数据的拆法。
这是手写 AHT20 驱动最容易写错的地方。读回来 7 个字节,格式是这样:
d[0] 状态字节,bit7 = busy
d[1] d[2] 湿度高 16 位
d[3] 的高 4 位 湿度低 4 位
d[3] 的低 4 位 温度高 4 位
d[4] d[5] 温度低 16 位
关键在 d[3] 被两个量共用:高 4 位是湿度的最低 4 位,低 4 位是温度的最高 4 位。
hr = ((d[1] << 16) | (d[2] << 8) | d[3]) >> 4
tr = ((d[3] & 0x0F) << 16) | (d[4] << 8) | d[5]
两个地方容易漏:湿度那个不右移 4 位,结果会大 16 倍;温度那个忘了 & 0x0F,就会把湿度的高 4 位当成温度的最高位,读出一个几万度的值。
换算按手册给的两个公式:
t = tr * 200.0 / 1048576.0 - 50.0 # 200°C 量程,-50 偏移
h = hr * 100.0 / 1048576.0 # 0~100%RH
1048576 是 2 的 20 次方。这个数字在第十节还会再出现一次,用法不一样。
扫不到:四轮排查,全空
固件烧上去,第一次上电,串口打出来是这样:
[SENS] SCL=13 SDA=0 -> []
scan() 返回空列表,总线上没有任何器件应答。到这里有两种可能:引脚选错了,或者线没接好。分不清,只能一层层往下排。
第一轮,用下拉判断"有没有接东西"。
思路是给 SDA 配上内部下拉,如果模块接上了,模块自带的上拉会把线拉住高电平;没接的话就是低。
写下去直接报错:Pin.PULL_DOWN 在 8266 上不存在。8266 只有内部上拉,没有内部下拉。这条路走不通。
第二轮,用高阻输入判断外部上拉。
换成输入模式读电平:接了模块的话,模块的 4.7k 上拉会让线读到 1;悬空的话应该飘。
结果是除了 GPIO15 读到 0(板载 10k 下拉,和引脚图一致),其它脚全读 1。因为 8266 的 Pin.IN 默认就带上拉,读到的 1 是自己拉的,不是模块拉的。这个测法在 STM32 上有效,在这里无效。
第三轮,降速重扫。
怀疑是软件 I2C 时序偏快。10kHz、50kHz、100kHz 三个速率 × 四种引脚接法,共 12 次扫描,全部为空。
第四轮,手工 bit-bang。
SoftI2C.scan() 只告诉你"扫到/没扫到",中间的过程完全看不到。于是绕开它,用最原始的时序一位一位发,每步延时 100μs(约 5kHz,比标准 I2C 慢 20 倍),把每个 ACK 位的实际电平打出来:
[BB] 引脚 SCL=13 SDA=0 延时 100us
[BB] 1) 空转检查
[BB] SCL 输出低 -> SDA 读到 1 (两线没短路)
[BB] SDA 输出低 -> SCL 读到 1
[BB] 都释放后 SDA=1 SCL=1
[BB] 2) 逐个地址试 ACK
[BB] 0x38 -> ACK=1 无应答
[BB] 0x39 -> ACK=1 无应答
[BB] 0x44 -> ACK=1 无应答
[BB] 0x45 -> ACK=1 无应答
[BB] 0x3C -> ACK=1 无应答
[BB] 3) 对 0x38 发 AHT20 测量命令
[BB] ACK 链: 1 1 1 1
这三段信息量不同:
- • 第一段证明了引脚本身是好的:能拉低、能释放、两根线不短路。• 第二段证明了 5kHz 下也没有任何地址应答,可以基本排除"时序偏快"。• 第三段是发完整命令序列,四个字节全部无应答,说明不是地址问题。
到这里,代码和时序都能排除。剩下的可能只有一个:线没接在我以为的那两个脚上。
不猜了,72 种组合一次跑完
"接到别的脚上了"这件事没法靠猜。8266 上可用的 GPIO 一共 9 个(排除串口 1/3 和 Flash 6~11),两两组合 72 种,全扫一遍只要十几秒。
PINS = (0, 2, 4, 5, 12, 13, 14, 15, 16)
for scl in PINS:
for sda in PINS:
if scl == sda:
continue
i2c = machine.SoftI2C(scl=machine.Pin(scl), sda=machine.Pin(sda),
freq=50000, timeout=1000)
found = i2c.scan()
(扫描期间会把继电器那四个脚也拉来拉去,接的如果是机械继电器,能听到轻微的动作声。属正常。)
第一次跑出来 9 个"命中",全部集中在 SDA=15 和 SDA=16 上。看着像找到设备了,但其中一条扫描结果暴露了问题:地址列表从 0x08 一路排到 0x77,一个不落。
一次扫出 0x08 到 0x77 全部地址。这不是几十个设备挂在总线上,这是总线被拉死在低电平。
原理不难:主机每发完 8 位会释放 SDA,读第 9 个时钟的电平作为 ACK。如果 SDA 一直是 0,那么不管从机在不在,读到的永远是"有人拉低",于是每个地址都算应答。
加一条判据就能过滤掉:
if len(found) > 8: # 一次扫出 8 个以上,是总线短地,不是设备
fake += 1
continue
重跑:
[ALL] pins = (0, 2, 4, 5, 12, 13, 14, 15, 16)
[ALL] HIT SCL=13 SDA=16 -> ['0x38'] (总 1 个地址)
[ALL] 试了 72 种组合,真命中 1 个,总线拉低的假象 8 个
[ALL] done
模块的 SDA 实际接在 GPIO16 上,也就是排针最后一格,不是我说的 GPIO0。
为什么假象全在 15 和 16 上,也能解释清楚:GPIO15 板上有 10k 下拉,做 SDA 时被钉在低电平;GPIO16 在 RTC 域,没有内部上拉,如果没有外部上拉同样钉在低。这两个脚"看起来能扫到东西",其实是自己把总线拉死了。
同一个现象在别的场景里出现过:一根 SDA 对地短路,扫描会返回同样的一长串地址。把"一次扫出超过 8 个地址"当成固定判据,比逐个去查电路快得多。
读一次真数据
换到实测引脚,先单读几次确认模块是活的:
AHT20 @ 0x38 calibration ok
26.86 °C 33.90 %RH
26.84 °C 33.86 %RH
26.86 °C 33.77 %RH
三次一致,湿度在小幅变化。说明是真的在测量转换,不是返回一个固定值。
(这是接好线后第一次读到的值。板子放了一段时间之后室内温度升到 28°C 附近,后面的采样表是那时的数据。)
然后改配置:
SENSOR_ENABLE = True
SENSOR_SCL = 13 # 丝印 GPIO13 MTCK
SENSOR_SDA = 16 # 丝印 GPIO16(排针最后一格)
SENSOR_FREQ = 50000 # GPIO16 无内部上拉,50kHz 实测稳定
SENSOR_ADDR = 0 # 0 = 自动探测(0x38=AHT20,0x44=SHT3x)
重新部署,串口:
[SENS] SCL=13 SDA=16 -> ['0x38']
[SENS] AHT20 at 0x38 ready
八次连续采样,间隔 3.2 秒(大于缓存时间,每次都是真读):
| 次数 | 温度 | 湿度 | 响应 |
| 1 | 28.1°C | 31.3% | 151ms |
| 2 | 28.1°C | 31.2% | 239ms |
| 3 | 28.1°C | 31.1% | 165ms |
| 4 | 28.1°C | 31.0% | 204ms |
| 5 | 28.1°C | 30.9% | 150ms |
| 6 | 28.0°C | 31.0% | 135ms |
| 7 | 28.0°C | 30.9% | 161ms |
| 8 | 28.0°C | 30.9% | 145ms |
温度从 28.1 缓慢降到 28.0,湿度从 31.3 降到 30.9,变化幅度都在传感器的分辨率量级上,符合室内环境的正常波动。
响应时间 135~239ms 里,约 80ms 是 AHT20 的固有转换时间,剩下的是 HTTP 往返加软 I2C 一位一位搬 7 个字节的开销。这也是为什么必须做缓存——不做的话每次请求都要付这 130ms 以上。
GPIO16 能用,但不理想
GPIO16 在 ESP8266 上属于 RTC 域,限制很清楚:没有内部上拉、不支持中断。它最常用的场景是 deep-sleep 唤醒,不是当普通 IO。现在能正常工作,靠的是 AHT20 模块自带的 4.7k 上拉把线拉起来,频率也降到了 50kHz。
这意味着它对布线比较敏感。线超过 15cm、或者换一个没带上拉的传感器,就可能又不通了。
想更稳的话,把 SDA 那根线从排针最后一格挪到 GPIO0(左侧第 12 格),config.py 里 SENSOR_SDA 改成 0,别的都不用动。这次没改,因为现场能用就先不折腾,但这是个已知的隐患,记在这里。
网页这边:没有传感器就给 null
接口上做了一个小设计:sensor 字段在没传感器时给 null,不是给 0。
{"led":0,"uptime":600,"mem":8928,"rssi":-57,
"ip":"192.168.151.67","ssid":"TEST","relay":[0,0,0,0],"sensor":null}
接了之后:
"sensor":{"kind":"AHT20","t":27.9,"h":30.8}
网页上的温湿度卡片初始是隐藏的,只有拿到 sensor 才显示出来。这样区分得开三种情况:没接传感器(null)、接了但读失败(null + 串口日志)、读数真的是 0(0.0)。
如果统一给 0,页面上会出现"0°C 0%RH"这种看着像设备坏了、其实只是没接的状态。
内存:一个传感器占 3.5KB
8266 的堆一直是紧张的,前三篇里多次出现"加功能之前先算这一笔"。这次的实际数字:
| 状态 | free |
| 装 sens 模块之前,开机后 | 15520 B |
| 装之后,刚启动 | 12016 B |
| 同一块板子跑 10 分钟后 | 8928 B |
第一行到第二行差 3.5KB,是 .mpy 代码、I2C 缓冲、几个缓存变量。
第三行值得单独看:什么都没改,只是跑了一会儿,free 就从 12016 掉到 8928。8266 的 WiFi 协议栈和 lwIP 会按需申请缓冲,加上长时间运行产生的堆碎片,gc.mem_free() 会持续下降。所以这个数字是瞬时值,不是稳定值——写代码时按最低点算,别按开机那一刻的数字算。
正因为内存紧,8266 这边必须把 .py 编译成 .mpy 再部署(.mpy 从 flash 读进 RAM 执行,省堆),而 .mpy 的格式有版本号,要和固件对上。
本机原本没装 mpy-cross。装的过程中碰到一个和代码无关的坑:清华镜像走系统代理一直返回 502,换官方 PyPI 直接就下到了:
pip install mpy-cross -i https://pypi.tuna.tsinghua.edu.cn/simple # 502
pip download mpy-cross # ok
版本下来是 1.29.0.post2,正好对上板子固件的 MicroPython 1.29.0。编译出来的头字节里 ver=6,和板上原有 .mpy 一致。
部署时还有一个反复出现的现象:8266 启动日志会打断上传,报 could not enter raw repl。原因是板子正在跑主循环、串口被占着。重跑一次就行,不用改代码——但要知道这是正常现象,不是坏板。
离线测试:不接硬件也能验一半
传感器只有一块,反复烧板验证很慢。所以先在 PC 上把协议逻辑验穿:用假的 I2C 对象注入 7 字节数据帧,跑 tools/test_sens_pc.py。
通过 24 项,失败 0 项
覆盖的内容:
-
- • 换算正确性(0°C/0%RH、-20°C、-40°C、45.5°C/99%RH、100%RH 这些点)• 校准命令
0xBE 0x08 0x00
-
- 和测量命令
0xAC 0x33 0x00
- 的字节顺序• 3 秒内不重复访问总线• busy 位为 1 时不更新读数• SHT30 分支的 CRC8 校验,CRC 错了不用脏数据• 连败 5 次后自动停用
写测试桩的时候自己踩了一个边界,值得单独讲:
# 错的做法:把 2 的 20 次方当满量程
hr = int(h / 100 * 1048576) & 0xFFFFF
100% 湿度按这个算出来是 1048576,& 0xFFFFF 截断之后变成 0。测试报"100% 那条读回 0",查了半天以为是驱动溢出。
实际原因是:20 位能表示的最大值是 1048575,不是 1048576。驱动里用 1048576 做除数是对的(量程是 2^20,端点取不到),但生成测试数据时不能用 2^20 当最大值——一个数在两个方向上的用法不一样,这是典型的差一错误。
改掉之后 24 项全过。
现在它长这样
esp8266-nodemcu-demo/
├── src/
│ ├── config.py 引脚、传感器、OTA 白名单
│ ├── main.py GPIO → 传感器 → WiFi/AP → HTTP 主循环
│ ├── board.py 板载 LED(8266 上没被冻结,不用改名)
│ ├── gpio.py D0~D8 上电归低
│ ├── relay.py 四路继电器
│ ├── sens.py AHT20 / SHT3x 自动探测,软 I2C + 3 秒缓存
│ ├── pages.py 模板渲染 + form 解码
│ ├── web.py HTTP 服务、路由、OTA
│ └── sta.tpl / ap.tpl / ota.tpl / style.css
└── tools/
├── deploy.py 编译 .mpy 并上传
├── find_board.py UDP 找板子
├── probe_i2c_all.py 72 种组合穷举
├── probe_i2c_bitbang.py 手工时序,打每个 ACK
└── test_sens_pc.py 离线协议测试,24 项
新增的两个接口:
| 用途 | 请求 |
| 状态(含温湿度) | GET /api/status → sensor 字段 |
| 只读温湿度 | GET /api/sensor |
页面上的温湿度卡片在 LED 卡片下面,两个 32px 的大字显示 27.9 °C 和 30.8 %RH。
附现在的页面:
web页面
附:这篇的坑(7 个)
| # | 现象 | 真正原因 | 解法 |
| 1 | 按常见教程准备接 D1/D2 | 这块板子的 GPIO5/4 已分给继电器第 1、2 路 | 接线前先数一遍已被占用的脚 |
| 2 | Pin.PULL_DOWN 报错 |
8266 只有内部上拉,没有下拉 | 只能靠外部电路判断 |
| 3 | 高阻输入判断外部上拉,全部读 1 | Pin.IN 在 8266 上默认带上拉 |
这个测法在 8266 上无效 |
| 4 | 扫描返回 0x08~0x77 全部地址 | SDA 被钉在低电平,每个地址都"ACK" | 判据:一次扫出 >8 个地址即为假象 |
| 5 | SCL=13 SDA=0 一直扫不到 |
SDA 实际接在 GPIO16(排针最后一格) | 穷举 72 种组合定位 |
| 6 | 100% 湿度测试读回 0 | 测试桩用 2^20 当 20 位满量程,截断溢出 | 数据生成用 1048575,驱动除数仍用 1048576 |
| 7 | pip install mpy-cross 一直 502 |
镜像走系统代理 | 换官方 PyPI,下到 1.29.0.post2 |
前四篇的坑(复位时序、.mpy 版本号在低字节、frozen 模块覆盖、LED 极性、请求没到达板子等)不重复了,见连载第一到第四篇。
本文所有日志、读数、内存数字均来自真实环境:Windows + CP210x@COM10 / 固件 MicroPython 1.29.0 / NodeMCU ESP8266 4MB Flash / AHT20 @ I2C 0x38(SCL=GPIO13, SDA=GPIO16, 50kHz)。
64