去年深夜,我在折腾一块吃灰的ESP32开发板。刚用手机装完一个APP,脑子里突然冒出一个念头:这块板子能不能像手机一样“安装应用”?我当时把这句话发进交流群,立刻有人回“单片机只能烧固件,装什么应用,醒醒”。这话只说对了一半。我们完全可以把“应用”重新定义成一段由解释器执行的脚本,把“安装”定义为把脚本写进板载文件系统并注册到菜单。沿着这个思路,我花了两周做出来一个小型应用平台:设备上跑MicroPython解释器,浏览器打开IP就能上传应用包,串口终端里可以选择应用、运行完自动返回菜单。整个过程就像在一台老功能机上装软件,只是屏幕换成了REPL。
这篇文章会把整套方案的底层逻辑、分区设计、应用格式、菜单调度、网页安装通道和踩坑记录都放出来。它适合正在玩ESP32、受够了“改一行代码就要重新编译烧录”的开发者,也适合想给设备做动态扩展能力的创客。我们先从“为什么能用应用思路玩ESP32”聊起。
1. 整体设计:让ESP32支持“安装应用”的底层逻辑
1.1 为什么不能直接装二进制应用:内存结构和进程模型的坑
手机安装App时,操作系统负责把可执行文件加载到内存,链接动态库,然后创建独立进程。进程有独立的地址空间,靠MMU隔离,一个App崩溃不会直接拖垮整个系统。但ESP32没有MMU,所有代码和数据都跑在同一个物理地址空间里。即使它内部有FreeRTOS,任务也只是编译期就链接好的函数,不是运行时从文件系统动态加载出来的“程序”。
理论上,ESP32可以自己写一个ELF加载器,把.elf文件读进RAM然后跳转执行。但动态链接、重定位、Flash Cache对齐、内存保护这些问题会让可维护性变得很差。所以想做一个“能装应用”的平台,最务实的入口不是加载二进制,而是跑脚本语言。
这也就是为什么我在对比了STM32和ESP32之后,最终选择ESP32。STM32也能跑MicroPython,但Wi-Fi和蓝牙生态弱了一大截。ESP32双核240MHz、520KB RAM、4MB Flash起步,跑一个MicroPython解释器和网络服务,性能足够,而且开箱就有网络能力。一个没有网络能力的“应用商店”是没有灵魂的,所以ESP32天然更适合这件事。
1.2 三种可行方案对比:固件覆盖、脚本平台、虚拟机
| 方案 | 原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 编译固件覆盖 | 每个应用编译成独立固件,用OTA整体覆盖Flash | 性能最好、外设控制完整 | 只能存在一个应用,切换要重新刷写,不能多应用共存 | 量产单一功能设备 |
| 脚本解释平台 | 固件内置MicroPython,应用是.py脚本 | 灵活、文件即应用、安装迁移方便 | 运行速度慢、内存受限 | 快速原型、创客教育、IoT节点 |
| 字节码虚拟机 | 移植WASM或小型VM | 跨平台、隔离性强 | ESP32资源紧张,生态弱,工程量巨大 | 实验性项目 |
编译固件覆盖的方案确实最接近“原生应用”,但体验很反人类。每换一个应用就要擦除整个Flash、重新烧录,手机App做不到这么粗暴。虚拟机方案看起来很高级,但是要把WASM运行时跑在ESP32上,光内存就够呛。真正能落地、又能保证开发效率的,只有脚本解释平台。
1.3 选型结论:MicroPython凭什么能撑起一个应用平台
MicroPython替我们做好了内存管理、垃圾回收、文件系统、网络协议栈和常用外设驱动。官方固件编译出来只有1.5MB左右,剩下一大半Flash都可以分给应用存储。这样平台的核心工作就不是从零写系统,而是做一个“应用调度器”和“安装通道”。
有人可能会说Lua脚本也可以做到类似的事。Lua解释器更小,但ESP32上成熟的硬件库、蓝牙库、Wi-Fi库基本都集中在MicroPython生态。写一个“小应用”的开发者不一定懂底层寄存器,但他们大概率会一点Python。用MicroPython把应用开发门槛降到这个程度,平台才真正有价值。
选型确定后,接下来就是解决几个核心问题:Flash空间怎么分、应用包长什么样、菜单调度怎么写。
2. 分区、应用格式与菜单调度:把核心骨架搭起来
2.1 给Flash分区:系统、数据、应用存储分开管理
“安装应用”意味着应用代码要持久化存储,所以Flash分区里必须划出一块可读写的文件系统区域。官方MicroPython固件已经做了这件事,它会把一个数据分区自动挂载为/目录,我们只需要在根目录下建一个/apps文件夹放各个应用即可。
但如果你想预留更大的应用空间,或者用16MB的大Flash颗粒,建议自己编译固件并修改分区表。以4MB Flash为例,我用的参考分区表长这样:
# Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x5000 phy_init, data, phy, 0xf000, 0x1000 factory, app, factory, 0x10000, 0x1F0000 storage, data, spiffs, 0x200000, 0x200000这个表里factory区从0x10000开始,大小接近1.9MB,足够放MicroPython固件;storage区从0x200000开始,总共2MB,作为应用和数据的家。注意,Type为app的分区只能放固件,data分区才会被MicroPython挂载成文件系统。如果你想在应用里放音频、图片、离线网页这类大资源,建议直接用外部Flash或换成16MB的模组,把storage扩到8MB甚至12MB。
分区表改完之后,在系统里可以用os.statvfs('/')查看剩余容量。设计安装功能时,一定要先检查空间再写入,不然写到一半OSError: [Errno 28] No space left on device会让你很难受。
2.2 设计应用包:manifest.json 加目录结构
为了让平台能够识别“什么是一个应用”,我规定每个应用是/apps目录下的一个子目录,里面必须包含一个manifest.json和一个入口脚本main.py。
一个最基本的应用目录结构:
/apps/hello/ manifest.json main.pymanifest.json用来描述元信息,MicroPython自带ujson,解析很简单。示例:
{ "name": "hello", "version": "1.0.0", "description": "Blink builtin LED and print hello", "author": "your_name", "entry": "main.py", "deps": [] }字段没必要做得很复杂,name用于显示,entry用于指定入口脚本,deps暂时留空,后续可以扩展成依赖包列表。把元信息单独放到JSON里,是为了以后网页端可以扫描目录、展示应用列表,而不需要去读取每个.py文件的内容。
为什么要用目录而不是单个.py文件?因为实际应用往往会拆成多个模块,还可能需要配置文件、静态资源。目录天然适合组织这些内容,后续做删除/覆盖安装时也很方便,一个rmtree就能清干净。
2.3 实现应用管理器:菜单循环和异常兜底
应用管理器的本质就是一个死循环:列出应用、读取输入、启动应用、捕获异常、回收内存。这个循环不复杂,但有两个细节必须处理好。
第一个细节是异常兜底。MicroPython没有进程隔离,应用一旦抛异常,如果不在外层try/except接住,整个平台就会崩回REPL。所以启动应用的外层一定要捕获Exception,打印堆栈后回到菜单。
第二个细节是资源路径。应用在启动时应该切换当前目录到自己的应用目录,这样它内部写open('logo.bmp')就能直接读到自己目录下的资源。平台跑完应用之后,还要把当前目录切回根目录,否则下一次菜单读取绝对路径会出问题。
一个精简的Launcher结构如下,完整代码我在后面第4节再逐行拆开说:
while True: apps = list_apps() show_menu(apps) cmd = input("choise: ") if cmd.isdigit(): launch(apps[int(cmd) - 1])看起来像极了老式功能机的Java应用管理器。把菜单显示、启动、退出这几个状态拆开,后面想接OLED屏幕,或者改成语音控制,都只是在换前端交互方式,核心调度不用动。
3. 两种安装方式:网页上传和串口命令
3.1 局域网网页应用商店:不解析multipart也能稳定安装
Wi-Fi版“应用商店”是平台的面子工程。我在ESP32上开了一个HTTP服务器,浏览器访问设备IP,能看到已安装的应用列表、删除按钮、上传表单。
但这里有个坑:很多教程让你在嵌入式HTTP服务器里解析multipart/form-data,这对单片机来说非常痛苦。multipart协议的boundary解析容易出错,文件一大还会内存爆掉。我最后放弃了标准文件上传,改为在网页端用FileReader读取文件内容,转成Base64字符串,再通过POST把JSON发给ESP32。设备端收到JSON后,用ubinascii.a2b_base64解码,再写入文件。
简化的接收核心:
import socket, ujson, ubinascii, os def handle_upload(conn, body): info = ujson.loads(body) app_name = info['name'].replace('/', '') raw = ubinascii.a2b_base64(info['data']) path = '/apps/{}/main.py'.format(app_name) os.mkdir('/apps/' + app_name) with open(path, 'wb') as f: f.write(raw) conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n') conn.send(ujson.dumps({'status': 'ok'}))Base64会让传输体积增加约33%,但对于几十KB的小应用完全没问题。这个方案的好处是逻辑简单,不需要处理boundary,也不容易出现接收一半断掉导致文件截断的问题。网页端用fetch发送JSON,整个链路都在同一个协议里,排查起来很省心。
3.2 串口备用通道:mpremote 直接拷贝应用
网页上传适合发布应用,但开发调试阶段,效率最高的是mpremote。它是MicroPython官方提供的命令行工具,可以直接连接串口操作设备文件系统。
假设开发机是Linux,设备串口是/dev/ttyUSB0:
mpremote connect /dev/ttyUSB0 mkdir /apps/hello mpremote connect /dev/ttyUSB0 cp main.py :/apps/hello/main.py第一句在设备上创建应用目录,第二句把本地main.py拷贝到设备里。更爽的是mount模式:
mpremote connect /dev/ttyUSB0 mount .这个命令会把本地目录挂载到设备上,应用直接在电脑上编辑,ESP32实时读取。适合反复调试Launcher本身,也适合写一个需要频繁修改配置的传感器应用。
这里要区分两个概念:烧录方式和应用安装。用esptool.py write_flash 0x1000 firmware.bin刷进去的是MicroPython固件,是“系统”。用mpremote cp或者网页上传进去的是应用文件,是“软件”。很多刚接触ESP32的玩家把这两个混在一起,总觉得每次装应用都要重新烧录,其实完全不用。
3.3 实战:给平台接LAN8720以太网模块的三个老坑和接线图
Wi-Fi在某些现场环境总是不太稳,尤其是网页上传大文件时容易超时。为了给应用平台一条更稳定的网络通道,我试了外接LAN8720以太网模块。这里分享一套完整接线参考,以及三个常遇到的问题。
| LAN8720模块引脚 | ESP32 GPIO |
|---|---|
| REF_CLK | GPIO0 |
| MDIO | GPIO18 |
| MDC | GPIO23 |
| TX_EN | GPIO21 |
| TXD0 | GPIO19 |
| TXD1 | GPIO22 |
| RXD0 | GPIO25 |
| RXD1 | GPIO26 |
| CRS_DV | GPIO27 |
| VCC | 3.3V |
| GND | GND |
注意,不同开发板和模块排针定义可能有差异,飞线前最好对着模块原理图核对一遍。初始化代码如下:
import network eth = network.LAN(mdc=23, mdio=18, power=None, phy_type=network.PHY_LAN8720, phy_addr=0) eth.active(True)第一个老坑是初始化失败,返回负值或直接报错。原因90%是接线问题,尤其是REF_CLK没接到GPIO0,或者MDC/MDIO接反。我踩过一次GPIO23和GPIO18接反的坑,现象就是eth.active(True)没有任何报错,但eth.status('link')一直是0。
第二个老坑是链接成功但获取不到IP。eth.active(True)之后不要立刻查eth.ifconfig(),给PHY一点时间,至少sleep(2)再读。如果还是不行,先检查eth.status('link')是否为1。如果为1但没IP,手动设置静态IP是最高效的:
eth.ifconfig(('192.168.1.150', '255.255.255.0', '192.168.1.1', '8.8.8.8'))第三个老坑是传输丢包。硬件上LAN8720对3.3V供电质量很敏感,从ESP32开发板的3.3V引脚取电,一旦电流波动,数据就会时不时空掉。服务器端表现为上传到一半连接重置。我最后给LAN8720单独用一路3.3V供电,并缩短REF_CLK杜邦线,问题才消失。
4. 实操记录:从烧录固件到跑通第一个应用
4.1 开发环境和烧录方式:别把刷固件和装应用混搞
推荐直接用MicroPython官方固件,省去自己编译的麻烦。从官网下载ESP32的.bin后,安装esptool:
pip install esptool esptool.py --chip esp32 --port /dev/ttyUSB0 erase_flash esptool.py --chip esp32 --port /dev/ttyUSB0 write_flash -z 0x1000 esp32-20240602-v1.23.0.bin如果你像我一样要改分区表,就需要从源码编译。克隆MicroPython仓库后,在ports/esp32目录下修改partitions.csv,然后执行:
make submodules make BOARD=ESP32_GENERIC生成的固件仍然用esptool烧到0x1000。注意,自定义分区表后,第一次烧完可能文件系统是空的,需要手动os.mkdir('/apps'),或者在boot.py里做自动初始化。
我见过不少朋友还在用Arduino IDE的ESP32支持包,界面友好,但Arduino生态不适合做“应用平台”。它本质是每次把C++代码编译成一个完整固件,没有动态文件系统执行脚本的概念。如果要做这个项目,开发环境直接切到MicroPython,效率会高很多。
4.2 最小可用的Launcher代码逐段解析
下面给一个能直接运行的Launcher,代码量不大,但完整实现了菜单、启动、异常兜底、内存回收:
# main.py —— ESP32 小型应用平台 Launcher import os, sys, gc APPS_DIR = '/apps' def ensure_dir(): if not os.path.isdir(APPS_DIR): os.mkdir(APPS_DIR) def list_apps(): ensure_dir() return sorted([d for d in os.listdir(APPS_DIR) if os.path.isdir(APPS_DIR + '/' + d)]) def launch(name): app_dir = APPS_DIR + '/' + name if app_dir not in sys.path: sys.path.insert(0, app_dir) old_cwd = os.getcwd() os.chdir(app_dir) try: with open('main.py', 'r') as f: src = f.read() g = {'__name__': '__app__', 'APPDIR': app_dir} exec(src, g) except SystemExit: pass except Exception as e: sys.print_exception(e) finally: os.chdir(old_cwd) gc.collect() def main(): while True: apps = list_apps() print('可用应用: {} 个'.format(len(apps))) for i, name in enumerate(apps): print('{}. {}'.format(i + 1, name)) cmd = input('输入序号启动,q退出: ').strip().lower() if cmd == 'q': break try: idx = int(cmd) - 1 if 0 <= idx < len(apps): launch(apps[idx]) except ValueError: pass if __name__ == '__main__': main()几个关键点:
sys.path.insert(0, app_dir)是为了让应用内部可以用import local_lib导入自己目录下的兄弟模块。因为MicroPython的sys.path默认不包含每个应用目录,不加这一行,应用一import就会报ModuleNotFoundError。
os.chdir(app_dir)会把当前工作目录切到应用目录。这样应用里写open('config.json')就直接是本应用的配置文件,不会有路径歧义。finally里再os.chdir(old_cwd)切回来,否则下一次菜单读取绝对路径会找不到。
gc.collect()放在finally的最后,是为了应用运行结束后立刻回收临时对象。如果不主动回收,连续启动几个大应用后,内存碎片会越来越严重,最终出现MemoryError。
exec(src, g)表示把应用脚本放在一个新的全局字典里执行。这意味着应用A定义的全局变量不会污染应用B。虽然比不上进程级隔离,但至少能挡掉一部分变量冲突。
4.3 内存和存储管理:为什么小应用也不能乱写
ESP32的RAM只有520KB,MicroPython启动后,能自由使用的内存通常在100KB左右。写平台时,一个很自然的误区是“反正有大Flash,我可以把资源文件都放进去”。但运行时如果一次性把资源读进内存,内存很快会爆。
我自己的一个测速应用,最初在启动时读取一整张128x64的BMP图片并转成bytearray,结果直接MemoryError。后来改成边读取边绘制,每次只处理一行像素,内存占用从接近90KB降到几KB。
写应用时建议养成两个检查习惯:
import gc, os print('free RAM:', gc.mem_free()) print('disk free:', os.statvfs('/apps')[0] * os.statvfs('/apps')[3])安装功能里也一定要做容量预检。比如在网页上传接口里,解析完Base64后先比对写入字节数和原始长度,不一致就返回“写入不完整”。这样用户能立刻知道是网络问题还是文件系统问题,而不是应用启动时才发现数据坏了。
4.4 扩展:让平台具备“应用商店”雏形
一旦跑通了本地安装,再加一个“远程拉取应用”的接口并不复杂。设备端可以做一个appstore.py,它的作用是从局域网内的一台HTTP服务器下载catalog.json,里面描述了一批可安装应用的名字、版本、下载地址。
设备端用MicroPython自带的urequests发起GET请求:
import urequests r = urequests.get('http://192.168.1.10/catalog.json') apps = r.json()然后选择其中某一个,继续下载它的main.py,保存到/apps/xxx/。这就是一个微缩版“应用商店”。
不过要提醒一点:这种下载安装方式没有代码签名和来源校验,只适合可信局域网或开发环境。千万别直接暴露到公网,否则别人随便传一个脚本进来,就相当于在你设备上执行任意代码了。
5. 常见问题与排查技巧实录
5.1 文件写入不完整与安装失败
网页上传最典型的问题是文件传完只有0字节,或者内容是半截的。我遇到几次后发现,问题不在文件系统,而在HTTP请求处理逻辑:浏览器可能把POST请求分成多个TCP段发送,服务器只recv了一次就认为拿到完整body,结果收到的数据不完整。
解决方式有两个方向。一是循环接收直到拿到完整长度,但这会增加不少代码。二是页面端先统计文件大小,转成Base64之后再把长度一起发过来,设备端判断收到的Base64字符串长度是否等于预期。如果不等,直接返回失败,让用户重传。
大文件安装建议分块写入,不要一次性f.write(whole_data)。比如每次写4KB,然后f.flush()一下,即使断电或断网,损失也能控制在一个块内。
5.2 应用启动崩溃、看门狗复位、电源不稳
应用启动时崩溃最常见的原因是ImportError和NameError。Launcher里的异常捕获会把这些错误打印到串口,然后回到菜单,所以不会死机。真正麻烦的是看门狗复位。
如果你在应用里写了一个死循环,比如while True:不做任何延时,那么MicroPython的系统任务可能抢不到CPU,硬件看门狗就会把芯片复位。排查方法是看串口是否出现WDT Reset或Brownout detector was triggered。前者说明代码里缺少time.sleep_ms(),后者说明电源扛不住了。
尤其接了LAN8720、摄像头、喇叭这类外设之后,瞬时电流很容易超过USB口能提供的500mA。我后来统一改成5V/2A电源,外设单独供电,再也没有出现过莫名其妙的复位。
5.3 应用目录的import问题
应用里写了import mylib,但启动时一直报ModuleNotFoundError。这是因为MicroPython只会从sys.path列出的目录导入模块,普通应用目录不在默认搜索路径里。
解决方式已经写进了Launcher:在launch()开始时,判断应用目录是否已经在sys.path中,如果没有就insert(0, app_dir)。要注意,因为平台会反复启动多个应用,如果不检查直接重复插入,sys.path会越来越长,虽然不至于致命,但没必要。
5.4 网页服务卡死与并发连接
手写socket服务器是单线程的,一个连接没处理完,后续连接只能排队。浏览器请求网页时通常会同时请求/favicon.ico,这个请求会把服务器堵住,导致真正的上传请求卡死。
最简单的处理是在路由解析时直接忽略非业务路径:
if path == '/favicon.ico': conn.close() else: route(conn, req)给每个conn设置接收超时也很有用。否则浏览器异常断开后,ESP32的socket.recv()会一直阻塞,把服务器“冻住”几十秒:
conn.settimeout(3)5.5 问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 上传文件0字节 | 请求体接收不完整或解析错误 | 改用Base64传输,校验数据长度 |
ModuleNotFoundError | 应用目录不在sys.path中 | 在Launcher启动前sys.path.insert |
MemoryError | 内存不足或碎片化 | 应用退出后gc.collect(),避免大对象 |
| 看门狗复位 | 死循环阻塞系统任务 | 循环内加time.sleep_ms(50) |
| LAN8720初始化失败 | 接线错误 | 核对REF_CLK、MDIO、MDC引脚 |
| 获取不到IP | DHCP异常或网线未插 | 检查eth.status('link'),改静态IP |
| 网页服务器卡死 | 浏览器并发请求阻塞 | 忽略favicon.ico,设置连接超时 |
把ESP32做成“能装应用”的小平台,对我来说最大的收获不是代码能跑,而是换了一套设计嵌入式软件的思路。以前改一个功能要重新编译、烧录,来回折腾半分钟;现在直接在串口写一个.py文件,重启就生效。这种“应用化”的迭代速度,确实会让人上瘾。
如果你想复现,我建议千万别一上来就模仿我做网页应用商店。先跑通串口菜单,体验一次“安装应用”的正反馈,再慢慢加网络上传、以太网这些花活。只要自己踩过一次“写入0字节”的坑,你对文件系统、内存和网络协议的理解绝对会上一个新台阶。