- 从烧固件到手机配网,一个下午踩了 7 个坑
- 我全程没打开 IDE,也没查过一次数据手册
- 板子不骗人:那些只有真硬件才能抓到的 bug
引子
抽屉里翻出一块 NodeMCU,就是这个小板,ESP8266 芯片,MicroPython 时代的老朋友,大概是10年前买的。插上电脑,设备管理器里冒出个 COM10,芯片是 CP210x。


我打开 WorkBuddy,敲了一句话:
我这有一块 ESP8266 的小板子,NodeMcu 的板子,现在连接到电脑上了,串口号是 COM10,你能不能直接帮我写个脚本程序下载进去运行+调试?
后面发生的事,比我预想的有意思得多。不是因为它多顺利,恰恰是因为它一路翻车,而每一次翻车都翻得很有教育意义。
这篇文章把整个过程摊开讲:烧固件、写脚本、改网页、加配网,以及那 7 个坑。如果你手边也有一块吃灰的 ESP8266,照着走一遍,大概能省掉一个下午。
一、先让它能说话
选型:为什么是 MicroPython 而不是 Arduino
理由很朴素:脚本改完直接传,不用编译。而且 MicroPython 自带 REPL,板子在跑的时候你还能连上去敲命令,看变量、读内存、临时改个引脚电平。
C 语言当然更省资源,但调试成本高一个量级。这次的目标是"快速验证一块板子到底好不好使",MicroPython 是对的选择。
烧固件:三个必须显式写出来的参数
先探芯片:
bash
esptool --port COM10 --baud 115200 flash-id
Chip is ESP8266EX
Flash size: 4MB
MAC: ec:fa:bc:12:df:bd
确认无误,擦除、烧写 MicroPython v1.29.0(643KB,镜像首字节 0xE9 是合法标志)。
这里有个必须注意的细节 :write-flash 一定要显式给 Flash 参数,不让 esptool 自己猜:
bash
esptool --port COM10 --baud 460800 write-flash \
--flash-mode dout --flash-freq 40m --flash-size 4MB \
0x0 fw/ESP8266_GENERIC-v1.29.0.bin
烧完 Hash 校验通过,一切看起来完美。
然后我就撞上了第一个坑。
二、坑 1:3 秒重启 29 次
烧完固件,打开串口一看------全是乱码,而且在无限重启。
抓 4 秒,数出 29 次启动,每一次都是:
ets Jan 8 2013,rst cause:2, boot mode:(3,7)
rf_cal[0] !=0x05,is 0x...
一开始我以为是固件坏了、Flash 参数不匹配,甚至怀疑整片擦除破坏了射频校准区。重刷了两遍,症状不变。
真正的原因跟固件一点关系都没有:复位脉冲的保持时间太短。
NodeMCU 这颗 CP210x 的串口芯片,DTR 接 GPIO0、RTS 接 EN(复位脚)。我最初的复位代码是这样的:
python
s.setRTS(True) # 拉低复位
time.sleep(0.15) # 只保持 0.15 秒
s.setRTS(False) # 放开
0.15 秒,芯片在"复位"和"释放"之间来回抖,既没真正复位干净,也没进入下载模式,卡在临界点上疯狂重启。rst cause:2 的意思是"外部复位引脚触发"------不是软件崩了,是硬件一直在按它。
改成 0.3 秒,一次就好。 这是 esptool 内部用的标准值,抄它准没错。
python
s.setRTS(True)
time.sleep(0.3)
s.setRTS(False)
第一次跑通的时候,串口吐出:
MicroPython v1.29.0 on 2026-08-24; ESP module with ESP8266
>>>
经验:ESP8266 出现"无限重启 + 全是乱码",十次有八次是复位时序问题,不是固件问题。先怀疑 DTR/RTS,再怀疑 Flash。
顺带一个坑:这块板子有两个波特率
这是新手最容易懵的地方:
| 输出阶段 | 波特率 | 你看到什么 |
|---|---|---|
| 上电/复位后最初几十毫秒(ROM 层) | 74880 | rst cause、boot mode、rf_cal |
| 固件接管之后(应用层) | 115200 | MicroPython v1.29.0 ...、你的 print |
所以用 115200 打开串口,每次复位开头都会有一小段乱码------那是正常的,不是板子坏了。
我一开始就在这儿误判了,以为烧写失败。
三、第一版脚本:让它自己汇报状态
链路通了,先写个自检脚本,确认板子的各个部件都活着:
- 扫一遍周边 WiFi,按信号强度排序打印前 10 个
- 板载 LED(GPIO2)每轮闪 3 下
- 每 5 秒打一行心跳:运行时长、空闲内存、CPU 频率
实测扫到 24~26 个 AP,心跳长这样:
[WIFI] found 24 AP(s)
[ 0s] tick=1 mem_free=33312 freq=80MHz
[ 5s] tick=2 mem_free=33312 freq=80MHz
这里有个小毛病,属于"能跑但不好看":心跳周期跑成了 6 秒。因为我在循环里先闪 LED(耗时约 1 秒)再 sleep(5s),累计就多了。正确做法是按绝对时间点对齐,而不是每次睡固定时长:
python
next_tick = time.ticks_add(time.ticks_ms(), 5000)
while True:
# ...干活...
now = time.ticks_ms()
if time.ticks_diff(next_tick, now) > 0:
time.sleep_ms(time.ticks_diff(next_tick, now))
next_tick = time.ticks_add(next_tick, 5000)
改完,心跳稳稳 5 秒一格。
这一步的意义:它证明了"写脚本 → 传进去 → 跑起来 → 看日志 → 改 → 再传"这条闭环是通的。后面所有的事都建立在这条闭环上。
四、第二版:把它变成网页
自检太无聊了。真正想验证的是整条 TCP/IP 链路------能扫到 WiFi 只能说明射频好,不代表网络栈好用。
目标:在浏览器里控制板载 LED。
GET / 控制页面
GET /api/led?state=on|off|toggle 控灯
GET /api/status JSON 状态
用 socket 手写了一个极简 HTTP 服务端。没有用框架------ESP8266 那点内存,框架进来就没地方放业务代码了。
一个聪明的做法:先在电脑上把它跑一遍
板子调试最痛苦的地方是每次改代码都要等上传 + 重启 + 连 WiFi,一轮 30 秒。如果逻辑有低级错误,这个成本会被放大十倍。
所以我先做了一件事:写桩模块,在电脑上把 HTTP 逻辑整个跑一遍。
用假的 machine、network、gc 顶替板子的硬件模块,然后直接把伪造的 HTTP 请求喂给处理函数:
python
# 伪造一个 socket
class FakeSock:
def recv(self, n): return self.rx[:n] # 预置的请求字节
def send(self, data): self.out += data
# 伪造一次请求
c = FakeSock(b"GET /api/led?state=on HTTP/1.1\r\nHost: x\r\n\r\n")
handle_sta(c, fake_sta, "1.2.3.4", "myssid")
assert c.out.startswith(b"HTTP/1.1 200 ")
34 项断言,覆盖状态码、Content-Length 一致性、JSON 字段、LED 电平跟随、404/204、空请求不崩。
然后它抓到了一个我绝对想不到的 bug。
坑 2:CSS 里的百分号,把整个页面干崩了
页面是用 Python 的 % 格式化的:
python
PAGE = """<style>.dot{border-radius:50%%}</style>...""" % {"host": HOSTNAME}
看着没问题对吧?但真实代码里写的是 50%,而这个 % 后面跟着 ;background ------ Python 解析器认为这是一个格式占位符,然后告诉你:
not enough arguments for format string
后果是什么?首页直接 500,而你在串口里只会看到一行看不懂的异常。 找原因的时间会远超你的想象,因为你的目光会一直在 socket、在 WiFi、在内存上,就是不会想到 CSS。
解法:改用占位符替换,彻底绕开 % 语法。
python
PAGE = PAGE.replace("{{HOST}}", config.HOSTNAME)
经验 :往模板里塞 HTML/CSS/JS 的时候,永远不要用
%或.format()。str.replace()笨,但它不会咬你。
五、坑 3:MemoryError,ESP8266 上最大的那道坎
功能加完,代码涨到 19.3KB。传上去,板子直接报:
MemoryError:
我一开始以为是内存泄漏,查了半天 gc.mem_free()。不是。ESP8266 的堆只有约 35KB,而 MicroPython 每次 import 一个模块,都要把整份 .py 源码读进 RAM,然后在 RAM 里编译成字节码。 19KB 的源码,光是读进来 + 编译,就把堆吃干净了。
那就拆呗。拆成 4 个模块:board.py(LED)、pages.py(模板)、web.py(HTTP)、main.py(入口)。
再传。还是 MemoryError。
一看,pages.py 单独一个文件 6.8KB,import pages 的时候照样炸。
这时候才想明白:问题不是总量,是单个文件的大小。只要任何一个源文件超过 6~7KB,导入它就会炸。
正解:预编译成 .mpy
MicroPython 有个交叉编译器 mpy-cross,可以在电脑上把 .py 编译成 .mpy 字节码。板子加载 .mpy 时直接读字节码,不需要在 RAM 里编译,内存占用立刻降下来。
关键点:mpy-cross 的版本必须和固件版本严格一致。
bash
pip install mpy-cross
mpy-cross --version
# mpy-cross v1.29.0 / mpy v6.3 ← 和板子固件 v1.29.0 严丝合缝
部署脚本里加了这一步,自动化 --all:先编译全部 .mpy,再上传,同时删掉板上同名的 .py ------因为 Python 的导入优先级里 .py 高于 .mpy,不删等于白干。
效果:
| 指标 | 改之前 | 改之后 |
|---|---|---|
| 源码体积 | 19.3 KB | --- |
| 上传体积(.mpy) | --- | 12.2 KB |
| 能否启动 | MemoryError | 正常,空闲 18.7KB |
六、三个只有真板子才能抓到的 bug
前面那个 CSS 的 bug 是 PC 端测试抓的。但接下来这三个,PC 端测试一个都抓不到------因为它们是 MicroPython 和真实 socket 的差异。
坑 4:MicroPython 的 bytes 没有 partition()
解析 HTTP 头需要把请求切成"头 / 空行 / body"三部分。CPython 里天经地义:
python
head, _, body = data.partition(b"\r\n\r\n")
在板子上:
AttributeError: 'bytes' object has no attribute 'partition'
MicroPython 为了省空间,砍掉了不少 CPython 的便利方法。改成最原始的写法:
python
idx = data.find(b"\r\n\r\n")
head = data[:idx] if idx >= 0 else data
body = data[idx + 4:] if idx >= 0 else b""
这个 bug 是怎么抓到的? 我写了个"板上冒烟测试"------把测试脚本直接放到板子上跑,用伪 socket 喂请求给真实的服务端代码:
bash
mpremote connect COM10 run tools/onboard_smoke.py
这一步的价值极高:它跑的是真正的 MicroPython 解释器 + 真正的固件 API,只是不经过网络。PC 端桩测试负责逻辑,板上冒烟负责 API 兼容性,两者缺一不可。
坑 5:socket.send() 会只发一部分
这个最阴。板上冒烟全绿,接口测试全绿,浏览器一打开------页面渲染一半就断。
curl: (56) Recv failure: Connection reset by peer
# 只有 947 字节,而页面实际 3779 字节
原因是 MicroPython 的 socket.send() 语义和你想的不一样:它只保证"发出去了一部分",返回实际发送的字节数 ,不保证整包发完。而我在发完之后立刻 close(),剩下的数据就丢了。
必须用 sendall()(如果固件支持),或者自己循环:
python
def send_all(cl, data):
try:
cl.sendall(data)
return
except AttributeError:
pass
n = 0
total = len(data)
while n < total:
n += cl.send(data[n:])
这个 bug 只有在真实网络上才会暴露。 因为桩测试里
send()是"全部接收",永远成功。
坑 6:心跳漂移(已在前文解决)
顺手记一笔:这类"能跑但不对"的小问题,如果你放任不管,后面排查大问题时会被它误导。
七、第三版:让手机给它配网
到这一步功能是通的,但有个现实问题:WiFi 密码得写死在代码里。
这不行。换个环境就得改代码重刷。于是改成 AP 配网:板子自己开热点,手机连上去填密码。
完整流程
板子开机:
- 读板内
wifi.json - 有配置 → 直接连,连不上重试 2 次
- 没有 或 连不上 → 开热点
nodemcu-setup,IP192.168.4.1 - 手机连热点 → 浏览器打开
http://192.168.4.1 - 页面真的扫一遍周边 WiFi列表出来(点一下自动填 SSID)→ 填密码 → 保存
- 板子重启去连,热点消失
- 想重配?工作页底部「重新配网」,一键清配置回第 3 步
一个小细节,但很关键:密码里的特殊字符 。用户的密码是 test@#$214 这种,含 #、@、$。表单提交时用 URL 编码,服务端必须自己实现解码(MicroPython 没有 urllib.parse.unquote):
python
def unquote(s):
"""解码 x-www-form-urlencoded。密码里的 # @ $ 空格全靠这个还原"""
out = []
i = 0
n = len(s)
while i < n:
c = s[i]
if c == "%" and i + 2 < n:
try:
out.append(chr(int(s[i+1:i+3], 16)))
i += 3
continue
except ValueError:
pass
elif c == "+":
out.append(" ")
i += 1
continue
out.append(c)
i += 1
return "".join(out)
测试里专门断言了test%40%23%214 能还原成 test@#$214。这种地方一旦错,表现是"密码错误、连不上",你会怀疑路由器、怀疑信道、怀疑天线,就是不会怀疑编码。
八、那个"打不开网页"的乌龙
配网成功,板子连上路由器。用户手机连着板子热点,打开 192.168.4.1:
无法访问此网站
192.168.4.1 拒绝了我们的连接请求。
ERR_CONNECTION_REFUSED
第一反应肯定是:板子的 HTTP 服务挂了。
但串口日志说的是另一回事:
[MAIN] try 1/2 ssid=TEST
[WIFI] connected ip=192.168.1.142 gw=192.168.1.1 rssi=-55 dBm ch=1
[HTTP] ready -> http://192.168.1.142/
板子明明活得好好的,而且已经在工作模式了。
原因很简单:一旦连上路由器,板子就关掉了配网热点,开始在自己的新 IP 上服务。这时候 192.168.4.1 自然没人应答------那不是故障,那是成功的表现。
判据只有一条:看串口有没有打出
[HTTP] ready -> http://<IP>/。 有,就说明它在正常服务,你去访问那个 IP 就行。
最终验证(从电脑 curl):
首页 http=200 bytes=3779 耗时=0.15s
/api/status {"led":0,"uptime":75,"mem":19056,"rssi":-58,"ip":"192.168.1.142",...}
控灯 {"ok":true,"led":1}
顺带两个环境层面的坑
排查过程中还遇到两个跟板子无关、但会浪费你一小时的坑:
① 串口被占用 。想抓日志,报 PermissionError(13, '拒绝访问')。因为 串口调试助手正开着 COM10。串口是独占资源,同一时刻只能一个程序用。
② 电脑上的深信服 。任务列表里躺着 SangforPWEx.exe 和 SangforUDProtectEx.exe(SSL VPN + 终端防护)。这类客户端会接管网络栈、改写路由表,是内网设备访问被拒的常见干扰源。如果你电脑上板子打不开、手机却正常,先怀疑它,别怀疑板子。
九、方法论:三层测试,缺一不可
整个过程最有价值的不是代码,是这套测试结构。我把它固化下来了:
| 层级 | 工具 | 跑在哪 | 能抓什么 | 耗时 |
|---|---|---|---|---|
| ① 离线桩测试 | tools/devtest_pc.py |
电脑 | 业务逻辑、响应格式、边界条件 | < 1 秒 |
| ② 板上冒烟测试 | tools/onboard_smoke.py |
板子 | MicroPython API 差异、内存 | ~ 5 秒 |
| ③ 真链路测试 | curl / 浏览器 | 真实网络 | socket 语义、大包传输、并发 | 手动 |
每一层都抓到了别人抓不到的东西:
- ① 抓到的是 CSS 里的
%撞了格式化------纯逻辑 bug - ② 抓到的是
bytes.partition()不存在------API 兼容性 bug - ③ 抓到的是
send()只发了一部分------真实网络语义 bug
如果跳过第 ② 层直接上板,你会以为代码没问题,然后花一整晚排查网络。
如果跳过第 ③ 层,你会在演示的时候当众翻车。
十、最后,它现在是这样
一块 2016 年的开发板,MicroPython v1.29.0,5 个 Python 文件(编译后 12.2KB 字节码),一个手写 HTTP 服务端,跑在 35KB 的堆上:
- 手机连热点配网,配完自动重启入网
- 浏览器控制板载 LED,页面每 5 秒自刷新
/api/status输出 JSON,Zabbix / Home Assistant 直接能接- WiFi 断了 5 秒内自愈重连
- 想重配,页面点一下
指标:
| 项目 | 数值 |
|---|---|
| 空闲内存(工作模式) | 18.7 KB |
| 首页传输 | 3779 字节,0.15 秒 |
| 连续 10 次 API 请求 | 全部 200 |
| 渲染配网页时最低内存 | 10.1 KB(红线) |
最后那行是提醒:10.1KB 是余量红线。如果以后要加继电器多路控制、数据图表、OTA,就得继续压页面体积,别再无脑往上堆 CSS 了。
附:7 个坑速查表
| # | 现象 | 真正原因 | 解法 |
|---|---|---|---|
| 1 | 无限重启,3 秒 29 次,rst cause:2 |
复位脉冲只保持 0.15s | 改成 ≥0.3s |
| 2 | 串口开头一小段乱码 | ROM 层固定 74880,应用层 115200 | 正常现象,别当故障 |
| 3 | 烧完校验失败 / 起不来 | 没给 Flash 参数 | 显式 dout/40m/4MB |
| 4 | 首页 500,串口一行英文异常 | CSS 50% 撞了 Python % 格式化 |
用 {``{HOST}} + replace |
| 5 | MemoryError |
单文件 >6~7KB,import 时在 RAM 编译 |
mpy-cross 预编译 .mpy |
| 6 | 'bytes' has no attribute 'partition' |
MicroPython 砍了 CPython 便利方法 | find() + 切片 |
| 7 | 页面只传回一半就断,curl exit 56 | send() 只发一部分 |
sendall() / 循环发 |
附上这个小模块的小网页:

本文所有数据、日志、报错均来自一次真实的硬件调试过程,硬件为 ESP8266EX / 4MB Flash / CP210x,固件 MicroPython v1.29.0。