我最早动这个念头,是在一次项目收尾的时候:板子上跑的好几个功能模块,每次想换一个都要重新编译、重新烧录,拆了焊了擦出各种麻烦。我当时就在想,手机能通过应用商店“装一个应用”自由加功能,ESP32 为什么不行?既然它也有 Flash、有网络、有文件系统,那能不能做一个很小的“应用平台”,让应用像手机 App 一样从网页、从局域网里直接装上去?
答案是:可以,而且不用把问题想得太大。这篇文章就是我实际做出来的一个小型应用平台,跑在 ESP32 上,包含应用列表、安装入口、启动和退出协议,以及我在折腾烧录、分区和文件系统时踩过的一系列坑。
先说结论:ESP32 的“应用平台”和手机上的应用商店在原理上有本质区别,但如果你选对方案,最终体验确实可以做到“浏览器打开页面——上传应用——回到菜单启动应用”。我会把两条技术路线都讲清楚,重点给出一条普通开发者也能复现、不需要专门画板子就能跑通的做法。
1. 手机装App和给ESP32装程序,差的不是操作而是运行时
想给 ESP32 造“应用平台”,必须先承认一个基础事实:手机 App 和 ESP32 程序,打包和运行方式完全不一样。
手机里装的 App 本质上是一个由操作系统加载的独立程序包。操作系统帮你管理内存、文件、进程和权限,App 之间可以切换,可以后台挂起,可以安装和卸载,这一切都由系统运行时兜底。ESP32 则不一样,它的 Flash 里存放的是统一固件镜像,启动时由 Bootloader 选中某一个分区,然后跳到那个地址执行。你在 Arduino IDE 或 ESP-IDF 里编译出的.bin,并不是一个能独立运行的程序,而是“和启动代码、编译参数、内存布局绑死在一起”的镜像。
这就带来一个很关键的问题:ESP32 上到底什么是“应用”?
我认为可以定义成两类。第一类是“固件型应用”,你把一段独立编译的固件烧录到某个分区里,通过切换 Bootloader 的启动目标来运行它,这就是原生二进制方案。第二类是“脚本型应用”,你用解释器来加载应用,最常见的就是 MicroPython,应用本体是.py文件,平台负责执行和切换。两类方案各有各的脾气,并不能简单地说谁更好。
固件型应用的优点是执行效率接近裸奔,能处理高实时性任务,但它的约束非常多:每个应用都要单独编译,编译时往往还需要指定固定分区地址,安装时要有跟编译时匹配的 Flash 布局,否则应用跑起来就是莫名其妙的复位。脚本型应用则相反,它的运行时由 MicroPython 提供,应用文件只是文本或字节码,装起来非常轻,代价当然是执行速度和实时性打折。
我最后选择了脚本型方案作为主力,因为我的核心诉求是“快速安装、快速切换、能折腾”,而不是造一个工业级实时系统。
2. 平台架构定稿:一个“启动器菜单 + Web应用仓库”的微型系统
既然选了解释器路线,整个平台的架构其实很清爽:ESP32 跑一个 MicroPython 固件,Flash 里开辟一个应用目录,平台启动后扫描这个目录,把能运行的应用列出来;安装动作通过一个 Web 端口对外提供,用户拿手机浏览器访问 ESP32 的 IP,就能看到应用列表、执行安装和卸载。
我用到的硬件非常普通:一块 ESP32 DevKitC,一个 0.96 寸 OLED 显示菜单,两个按钮用来转菜单和启动应用。如果你手头没有屏幕,完全可以把菜单放在 Web 页面上,用串口输出也一样。平台的关键不在界面,而在于这些模块之间的协作方式:
- 应用存放目录:统一放在
/apps目录下,每个应用是一个.py文件; - 安装入口:ESP32 内置一个轻量 HTTP 服务,接收应用代码,写入
/apps; - 启动器:平台主循环扫描目录,显示菜单,读取用户选择,动态加载应用;
- 退出协议:应用运行中检测一个“返回平台”的事件,主动退出并交还控制权。
选择 MicroPython 还有一个很实际的原因:它自带文件系统和网络模块,省去了直接在裸机代码里造 HTTP 服务和文件目录管理的巨大工作量。同样的功能如果用 ESP-IDF 去写,光一个 multipart 上传解析就得写几百行 C,何况还要处理 SPIFFS 的挂载和磨损平衡。
有人会问,为什么不直接用 Arduino 框架加 SPIFFS?不是说不行,但 Arduino 下做动态加载脚本的效率其实不高,你要手动把一个文件系统镜像提前烧进某个 SPIFFS 分区,或者自己在运行时写文件系统层。MicroPython 天然把文件系统当作基础能力,应用要落地就是写一个文本文件的事,这是它最随手的地方。
3. 三步让ESP32支持“安装应用”:基础烧录、应用目录、安装入口
整个平台落地拆成三步,第一步先把环境烧对,第二步准备目录,第三步才是写安装接口。
第一步,给 ESP32 烧录 MicroPython 固件。别用 Arduino IDE 的烧录功能去烧 MicroPython,MicroPython 的 ESP32 固件是全集成镜像,Bootloader、分区表和 App 都合在同一个文件里,正确的写入地址是0x1000。命令行操作如下:
esptool.py --chip esp32 --port COM3 --baud 460800 erase_flash esptool.py --chip esp32 --port COM3 --baud 460800 write_flash -z 0x1000 ESP32_GENERIC-20240101-v1.23.0.binerase_flash这步我非常建议做一次,尤其是你这块板子之前烧过别的项目。Arduino 或者 ESP-IDF 的程序会在 Flash 里留下各种配置数据,旧的分区表残留会对 MicroPython 的文件系统产生干扰,表现出来就是“固件烧进去了,但一启动就反复报错甚至重启”。
第二步,处理应用目录。MicroPython 上电后会把 Flash 文件系统挂载为/根目录,我们直接在 main.py 里做一次目录初始化:
import os APPS_DIR = '/apps' def ensure_apps_dir(): try: os.mkdir(APPS_DIR) except OSError: pass这里不用刻意去烧 SPIFFS 镜像,因为 MicroPython 的文件系统是它自己管理的一套分区,不是 Arduino 那种需要预先生成 spiffs.bin 再烧进去的模式。你在电脑上看到的“上传文件”动作,在 MicroPython 里就是 Python 代码直接open('/apps/xx.py', 'w')。
第三步,写安装入口。我实现了一个极简 Web 服务,只做三件事:返回应用列表 JSON、接收新应用代码写入/apps、删除应用。核心逻辑如下:
import socket import urllib.parse def handle(conn): data = conn.recv(2048) lines = data.split(b'\r\n') first_line = lines[0].decode(errors='replace') parts = first_line.split(' ', 2) if len(parts) < 2: conn.close() return method, url = parts[0], parts[1] if url.startswith('/api/install') and method == 'POST': body = data.split(b'\r\n\r\n', 1)[1].decode(errors='replace') params = urllib.parse.parse_qs(body) name = params.get('name', [''])[0] code = params.get('code', [''])[0] if valid_name(name) and 0 < len(code) < 8192: write_app(name, code) send_response(conn, '{"ok": true}') else: send_response(conn, '{"ok": false, "error": "invalid app"}') elif url.startswith('/api/list'): apps = list_apps() send_response(conn, json.dumps(apps)) elif url.startswith('/api/remove'): body = data.split(b'\r\n\r\n', 1)[1].decode(errors='replace') params = urllib.parse.parse_qs(body) name = params.get('name', [''])[0] remove_app(name) send_response(conn, '{"ok": true}') else: send_response(conn, html_menu()) conn.close()关于传输方式,我没用 multipart 文件上传,而是直接用application/x-www-form-urlencoded传纯文本。原因是 MicroPython 的 HTTP 实现能力有限,multipart 解析很容易碰内存问题,而应用文件通常只有几 KB,urlencoded 足够。真正要“上传文件”,可以用curl -d "name=blink&code=..."或者写一个极简的 HTML 表单。
菜单页面其实就是一个刷新列表的 HTML,用户打开http://esp32-ip/就能看到已有的应用和安装框,体验非常接近手机应用商店的网页版。
4. 让应用能“一键启动、顺手退出”:一个轻量协作协议
安装入口只是平台的一半,另外一半是怎么把应用跑起来,并且还能回到菜单。这个问题看着简单,做起来容易在“应用卡死,平台失去控制”上翻车。
我设计的协议是:每个应用都是一个模块,必须暴露start()和stop()两个函数;平台启动应用时不直接执行它的主循环,而是把它加载到模块空间里,然后调用start()。应用内部要自己检查一个全局退出标记,一旦检测到返回请求,就清理资源退出。
平台侧代码类似下面这样:
import sys import time import machine sys.path.insert(0, APPS_DIR) _quit_flag = False current_module = None def request_quit(): global _quit_flag _quit_flag = True def launch_app(name): global _quit_flag, current_module _quit_flag = False try: mod = __import__(name) current_module = mod if hasattr(mod, 'start'): mod.start() except Exception as e: print('app error:', e) finally: if hasattr(current_module, 'stop'): current_module.stop() current_module = None这里有个很关键的细节:__import__(name)是把/apps目录下的.py文件当作模块导入,而不是用exec去执行字符串。用模块导入的好处是应用可以有自己的函数、全局变量、子模块结构,平台也能通过mod.stop()清理退出。
再看一个具体应用。比如一个呼吸灯应用,代码可以这么写:
# /apps/breath_led.py import machine import time from platform_api import get_quit_flag led = machine.Pin(2, machine.Pin.OUT) def start(): brightness = 0 direction = 1 while not get_quit_flag(): led.value(brightness) time.sleep_ms(20) brightness += direction if brightness >= 100 or brightness <= 0: direction = -direction def stop(): led.value(0)平台主循环里检测“返回键”的动作,可以是 GPIO 中断,也可以是 Web 请求。比如 OLED 菜单下按某个按钮,或者 Web 页面上点击“返回平台”,都会调用request_quit(),应用就会自动退出。这就保证了平台永远是“最后兜底”的那一个,而不是把控制权完全交出去后干瞪眼。
我踩过的一个典型问题是直接把应用代码写成while True死循环,然后平台就永远卡住了。后来我在协作协议里明确要求应用必须把长循环拆成带退出判断的分片循环,这才从根本上解决“应用崩了平台也废了”的问题。代码习惯上,while True在平台应用里就是禁区。
5. 如果想装原生二进制:OTA分区的完整路径
脚本型平台跑通之后,你可能会想问:原生二进制方案是不是完全没有适用场景?不是。如果你的应用对实时性要求高,比如要做电机闭环、音频采样、复杂的传感器融合,MicroPython 可能扛不住,这时候就得走原生固件路线。
原生方案的做法,是利用 ESP32 的 OTA 分区机制。你把平台当作app0分区,编译好的应用当作ota_1分区,运行时通过esp_ota_set_boot_partition()把启动目标切到应用分区,然后重启。分区表示例:
# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, app0, app, ota_0, 0x10000, 0x1C0000, app1, app, ota_1, 0x1D0000, 0x1C0000, spiffs, data, spiffs, 0x390000, 0x70000,在 ESP-IDF 里切换启动分区只需要几行代码:
const esp_partition_t *partition = esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); esp_ota_set_boot_partition(partition); esp_restart();安装应用的路径也顺理成章:平台固件里内置一个 HTTP 客户端,从指定 URL 下载.bin文件,通过esp_ota_write把数据流式写入空闲分区,写完再设置启动分区。不过这条路有非常多的限制,最现实的一条是:ESP32 开机后只能执行一个固件,你切到应用分区去了,平台菜单在运行期间是不存在的,想回菜单必须再重启,甚至要设计额外的引脚组合才能决定“启动平台还是启动应用”。
所以我个人对原生方案的态度是:能做,但它不像“应用平台”,更像“多固件引导切换器”。如果你做的是产品原型、需要跑多个正式固件,可以用这套思路;如果你想玩出手机应用商店的体验,脚本型平台才是更贴近目标的选择。
6. 我在调这堆东西时踩过的坑:烧录、分区和SPIFFS
折腾这套平台的过程中,我栽过的跟头基本都集中在烧录和 Flash 布局上。我把最典型的几个列出来,每一个都持续折磨过我至少一个晚上。
第一个坑,烧录地址选错。MicroPython 固件的入口地址是0x1000,而 Arduino 的 app bin 镜像通常写在0xE000或0x10000。如果你习惯性地用 Flash Download Tools 去烧 MicroPython,却把地址填成了常见 Arduino 地址,结果就是上电不断复位,串口要么没输出,要么出现类似rst:0x10 (RTCWDT_RTC_RESET)的崩溃。我后来给自己定的规矩是:烧录前先确认我要烧的东西是“完整固件”还是“APP 子镜像”,完整固件一律0x1000起步。
第二个坑,Flash Download Tools 烧录时没有先擦除整个 Flash,导致旧配置残留。尤其是从 Arduino 项目切到 MicroPython 的场景,旧项目的 NVS 分区和 microPython 的 NVS 会相互污染。表现是 WiFi 能连上但一直断,或者os.listdir()报错。解决办法就是刷机前老老实实erase_flash,这个操作成本很低,却能为后面省掉大量无头问题。
第三个坑,Arduino 方案里的 SPIFFS 分区偏移不匹配。如果你是在 Arduino 框架下做类似平台,用 Arduino IDE 的 SPIFFS 插件生成spiffs.bin时,插件默认按照当前分区表烧写,但实际上传到某个固定偏移。一旦分区表的spiffs起始地址和插件默认不一致,就会出现文件系统挂载成功但读到全 0xFF 数据,所有文件都显示损坏。排查方法是打印SPIFFS.begin()的返回值,以及用十六进制工具看spiffs.img在 Flash 里的包络线是否在spiffs分区内。
第四个坑,MicroPython 应用文件的中文名字。我的 Web 安装接口一开始允许直接保存任意文件名,结果某次传了一个带中文的应用名,文件系统里显示正常,但__import__()怎么都导入不了。后来我把应用名的合法字符限定为[a-z0-9_],所有非法字符统一替换,这类问题瞬间消失。这个规则也可以推广到所有嵌入式文件场景,省心。
第五个坑,内存泄漏。MicroPython 本身有 GC,但反复通过 Web 安装和启动应用时,模块对象不会完全释放,尤其是应用自己创建的全局对象。我后来在每次安装、启动、卸载后都手动调gc.collect(),并在launch_app的finally里把current_module置空,让 GC 能真正回收。就算这样,长期跑下来内存仍然会缓慢增长,重启一次是最干净的解决方案。
7. 平台的边界和后续可玩方向
做完这套东西,我对“ESP32 应用平台”这件事的边界有非常清楚的认识。它能做的是:小体积脚本应用的快速安装、启动、切换,适合 IoT 原型验证、创客教育、快速换装工具类应用。它做不了的是:真正的进程隔离、内存保护、App 之间的权限管理,以及高实时性任务。
在这套限制下,我反而觉得它能玩的点很多。比如安装接口可以加一个简单的哈希校验,Web 端传代码的同时传一个 SHA-256,平台校验一致才写入,这样至少能挡掉传输过程中的数据损坏。再比如平台的菜单可以扩展成“应用仓库”模式,内置几个模板应用,用户不用自己写代码,一键把“温湿度读数”“LED 灯效”装进去。把应用元数据做成 JSON 清单,应用列表里就能显示版本号和作者。
我实际使用中最大的体会是:这套东西的价值不在“能装多少应用”,而在“让我不再害怕给板子加功能”。以前加一个功能,编译、烧录、接线,折腾半小时;现在只要浏览器上传一段代码,几秒钟之后板子就按新逻辑跑了。对一个玩硬件的人来说,这种反馈速度是很爽的。如果你也想尝试,建议先做最简单的一个应用:一个闪灯,安装、启动、退出,把平台机制验证通,再逐步往里加网络、传感器和交互逻辑。