• 连载第五篇:换了板子,ESP32-C3,内存终于"够用"了
• 有个屏幕就好玩多了,可以和手机互动了,屏幕直接显示手机发送的内容~
前情提要
前四篇篇用的是一块吃灰的 NodeMCU(ESP8266 / 4MB Flash)。到第三篇结束时,它已经能自己开热点配网、连路由器、网页控 LED 和 4 路继电器、采集温湿度、OTA 传 .mpy、开机 UDP 广播自己的 IP,并且配了个微信小程序,在局域网里搜到就能控。
能做的都做了。再往前一步,卡在内存上:ESP8266 那边堆只剩 14.8KB,加任何一个功能之前都得先算这一笔。
于是换了一块 ESP32-C3-PRO。二十几块钱,4MB Flash,板子自带一块 1.28 寸的 128×64 OLED,芯片是 RISC-V 单核 160MHz。
功能清单没变:网页控 LED / 四路继电器、OLED 状态页、自定义文字上屏、长文字自动翻页、天气页轮播、UDP 发现、小程序远程控。变的是"这块板子和上一块不一样"这件事本身——大部分时间花在这里。
先量内存,别信宣传页
板子到手,先不写业务代码。烧厂家的 MicroPython 固件,连上串口(这块板是 CH340K,COM14,115200),敲两行看 gc.mem_free():
| 阶段 | 可用内存 |
| 裸机起来,什么都没做 | 147008 B(143.6KB) |
| 连上 WiFi 之后 | 146928 B |
| 起 socket 监听 HTTP 后 | 146464 B |
| 加载 OLED 驱动、跑起页面 | 142720 B |
| 全部功能跑起来(HTTP + 天气 + 屏轮播) | 93 ~ 105KB |
第二行是这次最想确认的:连 WiFi 只掉了 80 字节。
在 ESP8266 上,连一次 WiFi 要吃掉 5KB 左右的堆,因为 WiFi 协议栈和 Python 解释器在抢同一块内存。C3 不是——它的 WiFi 协议栈跑在独立的 FreeRTOS 任务里,有自己的栈,不占 MicroPython 的堆。这是两块芯片的结构差异,不是谁优化得好。
80 字节这个数字小到不像真的,所以我又让它连发了几十次 HTTP 才下结论:free 一直在 93~105KB 之间波动,掉下去又被回收回来,说明确实没拿堆去换。
运行时那 93KB 和开机时的 143KB 差了 40 多 KB,是 Python 对象、模板字符串、HTTP 缓冲区这些一笔一笔加起来的结果。这个量级下,前三篇里那些"模板不要写在代码里""CSS 走流式发送"的体积纪律就不用天天念了。
对比一下:14.8KB → 143.6KB,大约 9 倍。
22 个 GPIO,能用的只有 5 个
ESP8266 上的习惯是"D0 到 D8 挑一个空的用"。到了 C3,先看一遍引脚表,因为很多脚不能碰:
| GPIO | 用途 | 能不能动 |
| 0 / 1 / 3 / 4 | 四路继电器(同时是 ADC1_0~1_3) | 可以,但占了就没模拟输入了 |
| 5 / 6 | 板载 OLED 的 I2C(SDA/SCL) | 不能,固件接管了 |
| 7 | 板载 LED | 可以,注意极性(下一节讲) |
| 10 | 空着 | 可以 |
| 2 / 8 / 9 | strapping 脚 | 绝对不能拉低 |
| 11 ~ 17 | 接 SPI Flash | 动了不启动 |
| 18 / 19 | USB D-/D+ | 不用 |
| 20 / 21 | UART0,就是 COM14 | 不用 |
strapping 脚这三个要单独说:它们在上电瞬间的电平决定芯片从哪启动、进不进下载模式。拉低了,板子就不启动,表现是串口一片空白、esptool 也连不上——很容易误判成"板子坏了",然后去怀疑 USB 线。
所以"上电把引脚全部归低"这种图省事的写法在这里不成立。配置里只能列安全脚:
# 真正能归低的自由脚只有 0/1/3/4/10
BOOT_LOW_PINS = (0, 1, 3, 4, 10)
gpio.init() 遍历这个元组逐个设成输出低,单个脚失败就跳过,不让一个脚拖死整块板子。
顺带一个验证手段。设完低电平,怎么确认真的低了?用 machine.Pin(pin, machine.Pin.IN).value() 读是不准的——重建 Pin 为输入态会引入上拉和浮空干扰,读到的往往不是输出锁存值。C3 的 GPIO 输出寄存器在 0x60004000,每个 bit 对应一个脚,直接读这个才有意义:
reg = machine.mem32[0x60004000]
print((reg >> pin) & 1)
六个脚读出来全是 0,这一步才算验证完。这种"绕过 API 直接看寄存器"的确认方式,在怀疑库有问题的时候挺好用。
第一个坑:不能写 board.py
这块板子的固件是厂家定制的 MicroPython v1.24.0(ESP32C3-Pro with ESP32C3-4M),里面冻结(frozen)了一个叫 board 的模块,不在文件系统里,直接用就有:
import board
board.oled # SSD1312_I2C 实例,128x64,I2C 0x3C
board.i2c
board.wifi
board.UI # ProgressBar / stripBar / qr_code
看起来很方便,问题在于 MicroPython 的导入顺序是文件系统优先于 frozen 模块。
我一开始按惯例写了个 board.py 放 LED 控制,结果是:写进去、重启、屏幕黑掉。因为自己那个 board.py 把厂家的整个盖掉了,board.oled 不存在了。
改法很简单——文件改名叫 led.py,board 这个名字留给固件。但值得记一笔的是这个错误的反馈形式:不报错,不提示冲突,就是屏幕不亮。而且它是在"我以为在写一个普通驱动文件"的时候发生的,跟 LED 一点关系都没有。
现在代码里凡是 import board 的地方,用的都是厂家的 OLED / I2C,import led 才是自己写的那个脚。
第二个坑:LED 亮灭是反的
板载 LED 在 GPIO7。第一版写完之后,网页上点"开",灯灭;点"关",灯亮。
查下来是两层问题叠在一起:
第一层,极性假设反了。我按"低电平点亮"写的——很多开发板是这么接的,因为灌电流比拉电流能带得动更多。这块板子实测是高电平点亮。
第二层,配置里的 BOOT_LOW_PINS 当时包含 7。也就是说开机上电那一刻,这个脚就被拉低了。按高电平点亮的接法,配合第一层的错误极性,正好形成"上电就亮、逻辑反着来"的组合。
改了三处:
1.BOOT_LOW_PINS 去掉 7,上电不再碰它;
2.led.set(1) 才是亮,set(0) 是灭;
3. WiFi 连上之后主动set(0),保证开机默认是灭的。
改完再测,点开就亮、点关就灭。
这件事的教训不在 LED,在**"极性"这类硬件假设必须实测确认,不能靠经验套**。同一批板子上,这种差异很常见,而且它不会以报错的形式出现,只会让你觉得"程序逻辑有问题",然后在软件里绕圈子。
中文怎么上屏
这块屏的驱动里有个 DispChar,是画中文的。手册上没写参数,只能试。
试出来的规格:
- • 汉字 12×12 点阵,实际占宽 13px,ASCII 占 7px,字高约 12
- • 128×64 的屏,一行最多 9 个汉字(按半角算 18 个当量),竖着能放 4 行(y = 0 / 16 / 32 / 48)
怎么确认真的是按点阵画的、不是把方框当汉字显示?数显存里的像素。写"温湿度"三个字,统计点亮的像素数是 170;写"AAAA"四个字母是 72。像素数和字形对得上,说明驱动里有中文字库,不是画的空框。
有了这个规格,折行就好写了。宽度按"半角当量"算,汉字记 2、ASCII 记 1:
def _char_w(ch):
return 2 if ord(ch) > 0x7F else 1
def _wrap(s, width):
lines = []
for para in s.split("n"): # 支持主动换行
cur, w = "", 0
for ch in para:
cw = _char_w(ch)
if w + cw > width:
lines.append(cur)
cur, w = ch, cw
else:
cur += ch
w += cw
lines.append(cur)
return lines
按 width=18 折,一行刚好塞满 9 个汉字。这个数字不是猜的,是 128 / 13 向下取整再折算出来的。
超过一屏,就分页翻
第一版只显示一屏,用户发来的文字超过 4 行,后面的就看不见了。
改法是"折行 → 按 4 行切页 → 循环播放":
lines = _wrap(AD_TEXT, AD_LINE_WIDTH)
_pages = [lines[i:i + AD_MAX_LINES] for i in range(0, len(lines), AD_MAX_LINES)]
_page = 0
刷新时取 _pages[_page % len(_pages)],画完 _page += 1。取模是关键,这样页数变成几页都能一直转下去,不用判断边界。翻页间隔单独给一个配置 AD_PAGE_INTERVAL = 4 秒,跟状态页的刷新间隔(2 秒)分开——状态页要勤刷,广告页翻得快了来不及看清。
剩下一个问题是长度。原来接口是 GET,/api/display?mode=ad&text=...,文字一长就出问题:URL 有长度上限,板子这边的请求行缓冲区也是有限的,超了就被截断,症状是"只显示了前半段,后半段没了"。
改成 POST 收 form:
elif path.startswith("/api/display"):
q = query(path)
m, t = q.get("mode", ""), q.get("text", "")
if method == "POST":
form = parse_form(read_body(cl, hdrs, rest, limit=4096))
m = form.get("mode", m)
t = form.get("text", t)
if m in ("ad", "normal"):
display.set_mode(m, t if method == "POST" else pages.unquote(t))
GET 保留着,回读状态和短文本还能用。POST 的 body 上限放到 4096 字节,服务端再按 AD_MAX_CHARS = 500 截一刀兜底,防止有人往里塞一整篇文章把页数堆到几百页。
500 个字符大概 28 页,翻完一轮接近两分钟。够用了。
一个汉字被拆成三个拉丁字母
广告文字改成 POST 之后,中文上屏出现乱码:"温湿度"变成一串奇怪的符号。
原因在表单解码。application/x-www-form-urlencoded 里的中文是按 UTF-8 编码后再转成 %XX 的,一个汉字三个字节,变成三个 %E6%B8%A9 这样的序列。原来的实现是逐字节处理:
# 错的做法
if c == "%":
out += chr(int(s[i+1:i+3], 16)) # chr(0xE6) → "æ"
chr(0xE6) 得到的是一个拉丁字符 æ,不是字节 0xE6。三个字节被拆成三个拉丁字符,拼起来就是乱码。
正确做法是先还原成 字节,最后一次性按 UTF-8 解码:
out = bytearray()
...
out.append(int(s[i+1:i+3], 16)) # 存字节,不是存字符
...
return bytes(out).decode("utf-8")
这个 bug 影响的不只是广告文字——配网页面填 WiFi 密码时,如果 SSID 是中文的,同样会错。当时以为是微信端编码问题,绕了一圈才回到板子上这十几行代码。
顺带一个调试验证的问题:在 PC 上用 urllib 模拟请求,会莫名其妙返回 502,因为走了系统代理。要么显式禁掉:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
要么 curl 加 --noproxy '*'。本地设备调试时,代理是个容易被忽略的干扰源——请求根本没到板子,全被代理劫走了。
天气:先测哪条路通,再写代码
需求是在状态页之外加第二页显示当天天气,两页隔 5 秒轮换。
选数据源之前先做了个连通性测试,因为"某个免费 API 能不能直连"这件事不能凭印象:
| 数据源 | 结果 |
https://api.open-meteo.com |
直连 ETIMEDOUT,不通 |
http://api.open-meteo.com |
通,响应 343 字节 |
http://wttr.in |
通,但响应 39KB,没必要,弃用 |
http://ip-api.com/json |
通,可按出口 IP 定位城市 |
Open-Meteo 的 HTTPS 在国内直连超时,明文 HTTP 反而通。它免 key、免注册、免费额度一天一万次,返回的 JSON 结构也简单,就用它了。顺带说一句,明文 HTTP 意味着传输过程不加密——对"显示今天几度"这种公开数据可以接受,涉及账号密码的请求不能这么干。
城市不写死,用 ip-api.com 按出口 IP 自动识别,实测识别到郑州,经纬度对得上。想固定就填 WEATHER_LAT/LON,跳过定位这一步。
拿到的天气码是 WMO 标准,得转成中文才放得下:
_WMO = {0: "晴", 1: "多云", 2: "多云", 3: "阴",
45: "雾", 48: "雾",
51: "毛雨", 53: "毛雨", 55: "毛雨",
61: "小雨", 63: "中雨", 65: "大雨",
71: "小雪", 73: "中雪", 75: "大雪",
80: "阵雨", 81: "阵雨", 82: "暴雨",
95: "雷雨", 96: "雷雨冰雹", 99: "雷雨冰雹"}
风向按角度切成八方位:int((deg + 22.5) // 45) % 8,加 22.5 是为了让"北"覆盖 ±22.5° 而不是只有正北那一条线。
拉取节奏定成 30 分钟一次,失败 5 分钟后再试:
def tick(online):
if not online: # AP 配网模式直接跳过
return
since = time.ticks_diff(time.ticks_ms(), _last_try)
if _DATA is not None and since < config.WEATHER_INTERVAL * 1000:
return
if _DATA is None and _last_try and since < _FAIL_BACKOFF * 1000:
return
_last_try = time.ticks_ms()
fetch()
一次 urequests.get() 会阻塞一到两秒。30 分钟一次、一秒的卡顿,可以接受;如果设成 60 秒一次,网页操作就会明显一顿一顿的——这是这类单线程 HTTP 服务的固有代价,只能靠降低频率绕开。
显示这边,正常模式下两页轮换:
if time.ticks_diff(now, _page_ms) >= config.PAGE_INTERVAL * 1000:
_page_ms = now
_page_flip ^= 1 # 0/1 翻转
if _page_flip and weather.get():
_render_weather()
return
# 否则画状态页
weather.get() 为空时(还没拉到数据,或者一直失败)自动回落成状态页,不会让屏幕停在一个"等待天气数据…"的界面上不动。
天气页四行,每行都在 18 个半角当量以内:
郑州 晴
21.1/13.4C 湿48%
东北风 8.2km/h
体感19.6C 降水0%
广告模式不受这套轮播影响,两套逻辑各自判断模式。
现在它长这样
esp32c3-demo/
├── src/
│ ├── config.py 引脚、间隔、天气、OTA 白名单,改这里不用动别的
│ ├── main.py 启动流程:GPIO → OLED → WiFi/AP → HTTP 主循环
│ ├── gpio.py 安全脚上电归低 + 寄存器回读验证
│ ├── led.py GPIO7(原名 board.py,改名避开 frozen 模块)
│ ├── relay.py 四路继电器,含 all 和 toggle
│ ├── sens.py SHT3x / AHT20 自动探测,没有就跳过
│ ├── display.py 状态页 / 天气页轮播 + 广告分页
│ ├── weather.py Open-Meteo + IP 定位 + WMO 转中文
│ ├── web.py HTTP 服务、路由、OTA
│ ├── pages.py 模板渲染 + form 解码
│ ├── sta.tpl / ap.tpl / ota.tpl 三个页面模板
└── tools/
├── deploy.py 批量上传(直接传 .py,不转 .mpy)
├── find_board.py UDP 找板子
└── onboard_smoke.py 板上冒烟测试
接口和 ESP8266 那版基本一致,小程序只改了显示接口的 GET → POST:
| 用途 | 请求 |
| 发现 | UDP 8266 端口广播 → 板子单播回 JSON |
| 状态 | GET /api/status |
| LED | GET /api/led?state=on|off |
| 继电器 | GET /api/relay?ch=N&state=on|off(ch=all 全部) |
| 屏幕 | POST /api/display,body: mode=ad|normal&text=...(GET 可回读) |
| 天气 | GET /api/weather |
| 重新配网 | GET /api/forget(清配置并重启进 AP) |
关于部署方式有个变化:ESP8266 那边必须转 .mpy,因为 .mpy 是从 flash 读进 RAM 执行、能省堆;C3 这边堆够了,直接传 .py,省掉 mpy-cross 的版本对齐问题(厂家固件是 v1.24.0,本机 mpy-cross 是 v1.29.0,虽然 _mpy=12038 的低字节是 6、能对上,但没必要冒这个险)。
deploy.py 里有个写死的模块清单,新增文件必须同步加进去。漏了 weather.py 那次的症状是:上传全部返回成功,板子重启后 HTTP 完全不通,串口里一句 ImportError: no module named 'weather'——main.py 在 import 阶段就挂了,后面的服务一行都没执行。这类"上传成功但服务没起来"的情况,看串口比看网页快。
附几张图
首屏:
首屏
天气信息:
天气预报
广告显示:
小程序页面
小程序搜索
十、还没做的事
•温湿度没实测。sens.py 里 SHT3x(0x44)和 AHT20(0x38)的探测都写好了,I2C 扫描目前只看到 OLED 的 0x3C,传感器还没到货。代码路径是通的(没有传感器时/api/sensor 返回{"ok":false},页面和屏幕都不显示这一块),但真数据还没跑过。
•广告文字不持久化。断电恢复状态页。当时判断"广告内容会经常改,存下来意义不大",如果要存,加个display.json 就行,没做。
•天气数据不缓存。重启后要等第一次拉取成功才有天气页,中间那段时间仍然显示状态页。如果要秒开,得把上一次的数据落到文件里。
•继电器只测了板载 LED 对应的那一路通断,四路都验证过 HTTP 层面返回正确,实物负载还没接。
启动时串口里会有一行看着像错误的日志:
E (361) i2c: i2c_driver_delete(438): i2c driver install error
这是厂家冻结的 board 模块内部重复初始化 I2C 打出来的,scan() 和 OLED 都正常,不影响功能。这种"固件自带噪音"没法从业务代码里清掉,知道它是什么就行,不用去改。
附:这篇的坑(9 个)
| # | 现象 | 真正原因 | 解法 |
| 1 | 写了自己的 board.py,OLED 黑屏 |
MicroPython 文件系统模块优先于 frozen 模块,被整体覆盖 | 改名 led.py,board 留给固件 |
| 2 | 网页点"开"灯灭、点"关"灯亮 | 极性写反(实际高电平点亮),且 BOOT_LOW_PINS 含 GPIO7 |
去掉 7,set(1) 为亮,连上 WiFi 后置 0 |
| 3 | 文字超过 4 行只显示前半段 | 折行后未分页 | 折行 → 按 4 行切页 → 取模循环 |
| 4 | 长文字 GET 请求被截断 | URL 长度与请求行缓冲上限 | 改 POST,body 上限 4096,服务端按 500 字符截断 |
| 5 | 中文上屏变乱码 | unquote 逐字节 chr(),一个汉字 3 字节被拆成 3 个拉丁字符 |
收进 bytearray 后整体 decode("utf-8") |
| 6 | 天气接口一直超时 | https://api.open-meteo.com 国内直连不通 |
改明文 HTTP,实测 343 字节可达 |
| 7 | 上传成功但板子 HTTP 不通 | deploy.py 的模块清单漏了 weather.py,main.py import 失败 |
清单同步加,串口看 ImportError |
| 8 | urllib 测试本地接口返回 502 |
走了系统代理 | build_opener(ProxyHandler({})) 或 curl --noproxy '*' |
本文所有内存数字、日志、报错均来自真实环境:Windows + CH340K@COM14 / 固件 MicroPython v1.24.0(ESP32C3-Pro with ESP32C3-4M)/ ESP32-C3 QFN32 rev v0.4 160MHz / 4MB XMC Flash / 板载 SSD1312 128×64 OLED @ I2C 0x3C。
677