把 ESP8266 那套搬到 ESP32-C3 上:内存多了 9 倍

发布时间:2026/11/2 6:38:42
把 ESP8266 那套搬到 ESP32-C3 上:内存多了 9 倍
  • 连载第五篇:换了板子,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 / 9strapping 脚绝对不能拉低
11 ~ 17接 SPI Flash动了不启动
18 / 19USB D-/D+不用
20 / 21UART0,就是 COM14不用

strapping 脚这三个要单独说:它们在上电瞬间的电平决定芯片从哪启动、进不进下载模式。拉低了,板子就不启动,表现是串口一片空白、esptool 也连不上——很容易误判成"板子坏了",然后去怀疑 USB 线。

所以"上电把引脚全部归低"这种图省事的写法在这里不成立。配置里只能列安全脚:

# 真正能归低的自由脚只有 0/1/3/4/10BOOT_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的模块,不在文件系统里,直接用就有:

importboard board.oled# SSD1312_I2C 实例,128x64,I2C 0x3Cboard.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):return2iford(ch)>0x7Felse1def_wrap(s,width):lines=[]forparains.split("\n"):# 支持主动换行cur,w="",0forchinpara:cw=_char_w(ch)ifw+cw>width:lines.append(cur)cur,w=ch,cwelse:cur+=ch w+=cw lines.append(cur)returnlines

按width=18折,一行刚好塞满 9 个汉字。这个数字不是猜的,是 128 / 13 向下取整再折算出来的。


六、超过一屏,就分页翻

第一版只显示一屏,用户发来的文字超过 4 行,后面的就看不见了。

改法是"折行 → 按 4 行切页 → 循环播放":

lines=_wrap(AD_TEXT,AD_LINE_WIDTH)_pages=[lines[i:i+AD_MAX_LINES]foriinrange(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:

elifpath.startswith("/api/display"):q=query(path)m,t=q.get("mode",""),q.get("text","")ifmethod=="POST":form=parse_form(read_body(cl,hdrs,rest,limit=4096))m=form.get("mode",m)t=form.get("text",t)ifmin("ad","normal"):display.set_mode(m,tifmethod=="POST"elsepages.unquote(t))

GET 保留着,回读状态和短文本还能用。POST 的 body 上限放到 4096 字节,服务端再按AD_MAX_CHARS = 500截一刀兜底,防止有人往里塞一整篇文章把页数堆到几百页。

500 个字符大概 28 页,翻完一轮接近两分钟。够用了。


七、一个汉字被拆成三个拉丁字母

广告文字改成 POST 之后,中文上屏出现乱码:"温湿度"变成一串奇怪的符号。

原因在表单解码。application/x-www-form-urlencoded里的中文是按 UTF-8 编码后再转成%XX的,一个汉字三个字节,变成三个%E6%B8%A9这样的序列。原来的实现是逐字节处理:

# 错的做法ifc=="%":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))# 存字节,不是存字符...returnbytes(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 分钟后再试:

deftick(online):ifnotonline:# AP 配网模式直接跳过returnsince=time.ticks_diff(time.ticks_ms(),_last_try)if_DATAisnotNoneandsince<config.WEATHER_INTERVAL*1000:returnif_DATAisNoneand_last_tryandsince<_FAIL_BACKOFF*1000:return_last_try=time.ticks_ms()fetch()

一次urequests.get()会阻塞一到两秒。30 分钟一次、一秒的卡顿,可以接受;如果设成 60 秒一次,网页操作就会明显一顿一顿的——这是这类单线程 HTTP 服务的固有代价,只能靠降低频率绕开。

显示这边,正常模式下两页轮换:

iftime.ticks_diff(now,_page_ms)>=config.PAGE_INTERVAL*1000:_page_ms=now _page_flip^=1# 0/1 翻转if_page_flipandweather.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:

用途请求
发现UDP8266端口广播 → 板子单播回 JSON
状态GET /api/status
LEDGET /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.pyimport 失败清单同步加,串口看ImportError
8urllib测试本地接口返回 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。


《一句话,让 AI 把一块 ESP8266 从头调到能上网》
《 ESP8266 加三件套:继电器、OTA 升级、自动找 IP》
《给 ESP8266 配个微信小程序》
《给ESP8266接一个温湿度传感器》