• 正文
  • 相关推荐
申请入驻 产业图谱

ESP32-C3替换ESP8266:内存扩容、OLED 中文屏、网页 + 小程序控制踩坑实录

11/03 09:22
677
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

• 连载第五篇:换了板子,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。

相关推荐

专注嵌入式Linux应用开发,C/C++技术交流;Qt编程技术交流。