
我最早动这个念头是在一次项目收尾的时候板子上跑的好几个功能模块每次想换一个都要重新编译、重新烧录拆了焊了擦出各种麻烦。我当时就在想手机能通过应用商店“装一个应用”自由加功能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 的烧录功能去烧 MicroPythonMicroPython 的 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(errorsreplace) 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(errorsreplace) 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(errorsreplace) 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 解析很容易碰内存问题而应用文件通常只有几 KBurlencoded 足够。真正要“上传文件”可以用curl -d nameblinkcode...或者写一个极简的 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 清单应用列表里就能显示版本号和作者。我实际使用中最大的体会是这套东西的价值不在“能装多少应用”而在“让我不再害怕给板子加功能”。以前加一个功能编译、烧录、接线折腾半小时现在只要浏览器上传一段代码几秒钟之后板子就按新逻辑跑了。对一个玩硬件的人来说这种反馈速度是很爽的。如果你也想尝试建议先做最简单的一个应用一个闪灯安装、启动、退出把平台机制验证通再逐步往里加网络、传感器和交互逻辑。