ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

把ESP32变成能安装应用的平台:基于MicroPython的脚本应用管理器

把ESP32变成能安装应用的平台:基于MicroPython的脚本应用管理器 去年深夜我在折腾一块吃灰的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(bHTTP/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/ttyUSB0mpremote 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 GPIOREF_CLKGPIO0MDIOGPIO18MDCGPIO23TX_ENGPIO21TXD0GPIO19TXD1GPIO22RXD0GPIO25RXD1GPIO26CRS_DVGPIO27VCC3.3VGNDGND注意不同开发板和模块排针定义可能有差异飞线前最好对着模块原理图核对一遍。初始化代码如下import network eth network.LAN(mdc23, mdio18, powerNone, phy_typenetwork.PHY_LAN8720, phy_addr0) 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后安装esptoolpip 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 BOARDESP32_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只有520KBMicroPython启动后能自由使用的内存通常在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.insertMemoryError内存不足或碎片化应用退出后gc.collect()避免大对象看门狗复位死循环阻塞系统任务循环内加time.sleep_ms(50)LAN8720初始化失败接线错误核对REF_CLK、MDIO、MDC引脚获取不到IPDHCP异常或网线未插检查eth.status(link)改静态IP网页服务器卡死浏览器并发请求阻塞忽略favicon.ico设置连接超时把ESP32做成“能装应用”的小平台对我来说最大的收获不是代码能跑而是换了一套设计嵌入式软件的思路。以前改一个功能要重新编译、烧录来回折腾半分钟现在直接在串口写一个.py文件重启就生效。这种“应用化”的迭代速度确实会让人上瘾。如果你想复现我建议千万别一上来就模仿我做网页应用商店。先跑通串口菜单体验一次“安装应用”的正反馈再慢慢加网络上传、以太网这些花活。只要自己踩过一次“写入0字节”的坑你对文件系统、内存和网络协议的理解绝对会上一个新台阶。
返回列表