2026/10/18 2:19:57

嵌入式JavaScript运行时实战:如何在6MB Flash内实现高效脚本执行

嵌入式JavaScript运行时实战:如何在6MB Flash内实现高效脚本执行 年前接了个活儿要给一块只有 8MB Flash、512KB SRAM 的嵌入式设备做一套 JavaScript 运行时。最后交付的产物连同基础固件和脚本存储池一起压进了 6MB 的 Flash 空间。这个项目内部代号叫 xuan名字没什么正经来由纯粹是群里打错字把“玄”打成了拼音喊着喊着就定下来了。整套东西做完我自己都觉得有点玄一个能让嵌入式设备跑 JS 脚本的运行时居然能塞进这么小的空间而且跑得还挺稳。在动手之前先想清楚一个问题这块设备上到底需不需要 JavaScript这个问题想不清楚后面做的所有设计都是自嗨。如果你也在考虑给嵌入式设备加脚本能力或者正在纠结“资源这么少怎么跑得动 JS”这篇文章应该对你有用。我会把整个实现过程、踩过的坑、性能底牌一次性讲透。1. 先说清楚为什么要在嵌入式设备上跑 JavaScript 脚本1.1 一个典型的“看起来合理”的业务场景我接到这个项目时设备的固件功能已经相对完整了但业务侧有一个很头疼的问题设备部署出去之后客户经常要调整报警阈值、上报策略、联动规则这类逻辑。每次调整都走一遍“改 C 代码 → 交叉编译 → 打包固件 → OTA 升级”的流程效率低不说升级失败还有变砖风险。举个例子设备上有几个传感器客户希望“温度大于 50 度且持续 5 秒就触发报警同时把历史数据打包上报”。这种逻辑写死在 C 里当然可以但客户过几天又说“改成 45 度吧”“持续 3 秒就行”“顺便把湿度也加进去”。如果把所有分支都做成配置项那配置表会迅速膨胀成一套比 C 还难维护的“伪语言”。用脚本描述业务逻辑是这种情况下最自然的选择。1.2 脚本方案那么多为什么偏偏选 JavaScript嵌入式设备上常见的脚本方案其实不止 JS 一种。我做过一轮对比把 L 语言、Python 的精简版、以及 JavaScript 放在一起比对比维度L 语言精简版 PythonJavaScript语法熟悉度偏小众多数工程师不熟熟悉但精简版特性缺失严重前端、后端工程师都会与 JSON 的亲和性一般需要额外处理一般原生支持天然契合运行时体积极小中等偏大可控选对内核可以很紧凑嵌入式生态有但配套工具少工具链重调试/语法检查工具丰富团队上手成本需要专门学习需要适应精简版限制基本零成本这里我不谈具体选型细节但结论很明确JS 在这方面优势太明显了。团队的嵌入式工程师可能没写过 JS但看懂 JS 业务逻辑完全没问题而后端同事拿到设备也能很快上手写脚本。这个“团队沟通成本”是硬指标很多时候比技术指标更重要。另外JS 的异步模型事件循环、定时器、回调和嵌入式设备的“主循环 中断”模型天然接近。传感器触发一个中断转换成 JS 的一个回调这个思维模型几乎不用翻译。相比之下L 语言的协程模型虽然也有优势但对团队里大多数同事来说理解成本还是高了一截。1.3 “写运行时”和“移植引擎”是两回事当时我心里很清楚真要自己从零写一个 ECMAScript 解析器和执行器那工作量不是几个月能搞定的。所以 xuan 的定位是“运行时”不是“引擎”。底层解释器内核我们评估用了一款现成的开源微型 JS 引擎它负责把 JS 源码解析成字节码并执行。但引擎只是骨架真正让 JS 在设备上“活”起来的是上面的运行时内存管理策略、标准库裁剪、外设绑定、事件循环整合、热更新机制这些才是 xuan 的主要工作量。你可以把引擎理解成一台裸机xuan 是在上面做了一个完整的操作系统——虽然这个比喻不太严谨但意思差不多。这样的分工还有一个好处关注点分离。引擎的 bug 属于底层问题但可预期运行时和业务侧的 bug则能在测试阶段通过系统性的方法暴露出来。事实证明这个决策帮我省了大量时间。2. 6MB 的预算怎么拆才够用2.1 6MB 到底是谁的 6MB很多人一看到“6MB”就以为整个设备 Flash 只有 6MB其实不是。这台设备的存储配置是 8MB SPI NOR Flash 加 512KB SRAM6MB 是指“JavaScript 运行时 固件 脚本存储池”加在一起的预算。听起来宽裕实际拆下来每一项都得精打细算。我先把 Flash 分区画出来再逐个填数分区容量内容引导加载区1MB一级引导和升级入口固件本体区1MBC 主程序、驱动、RTOS、协议栈运行时区3MBJS 引擎、标准库裁剪后产物、宿主绑定层、GC 与分配器脚本存储池 A1MB当前生效的用户 JS 脚本预编译字节码脚本存储池 B1MB备用槽位用于热更新切换预留区1MB日志、配置项、掉电保护暂存区运行时区加固件 1MB加上脚本池的 2MB正好是 6MB。这套分区的核心思路是脚本存储池必须双份否则热更新过程中掉电设备就成砖了。这个后面在踩坑部分会细讲。2.2 每一块空间花在了哪里运行时区的 3MB 也不是一口气划过去的里面每一部分我都单独量过解释器内核约 1.8MB。这是引擎编译后的基础体积包含词法分析、语法分析、字节码执行器。标准库裁剪产物约 800KB。按依赖图静态裁剪只保留 JSON、字符串处理、数组、定时器、基础错误处理别的都在编译期删掉。宿主绑定与驱动桥约 500KB。包含 GPIO、I2C、SPI、UART、看门狗、Flash 读写等外设的 JS API 封装。GC 与内存分配器约 400KB。这块比想象中大因为要考虑 freelist 管理、GC 根集扫描和调试辅助功能。为什么这些数字是“约”因为裁剪开关不同体积浮动挺大。内核部分是死的其他部分就看你怎么取舍。比如标准库里的正则表达式如果业务完全不需要关掉能省 300KB 以上。这一点非常关键** 不要为了“将来可能用到”保留代码Flash 里的每一 KB 都是有成本的。**2.3 选内核时的三个硬性指标选定内核这件事我们对比了三款开源引擎最后选中的那款体积处于中间位置但特性完整度和内存控制能力最符合要求。当时筛选只看三个硬指标别的宣传词一律不看解释器内核的二进制体积必须在 1.5MB ~ 2MB 之间超过就砍掉因为留给内核的空间就这么多。内存峰值可控性引擎在执行一段脚本时峰值内存不能超过 150KB因为整机 SRAM 才 512KB固件和驱动还要占掉一部分。许可协议的友好程度必须是能用于商业闭源产品的宽松协议这条直接否决了一款功能最全的候选。还有一个软指标社区的活跃度。我不是为了“有问题有人回答”而是希望趁引擎本身还在活跃迭代时把已知的坑避开。这款引擎当时的测试用例非常丰富后续又发布过修复版本这是加分项。3. 运行时是怎么搭起来的从讲解器到宿主世界的完整链路3.1 运行时最小循环加载 → 绑定 → 执行 → 派发xuan 的启动流程和 PC 上跑 Node.js 不太一样。设备上没有文件系统那种弹性的“读取源码再编译”概念而是提前把 JS 源码交叉编译成字节码快照烧录或热更新进脚本存储池。启动时直接加载快照省掉了设备端解释源码的开销。整个运行时的核心循环简化成三步1. 从脚本池加载字节码快照反序列化对象、字符串表、函数表 2. 初始化宿主绑定层注册 GPIO/UART/I2C 等对象到 JS 全局空间 3. 进入事件循环处理硬件事件队列 → 派发 JS 回调 → 执行定时器任务 → 空闲时触发 GC 检查。这和你平时写一个 C 语言死循环外面包一层 while(1) 没什么本质区别只是循环体内的事件处理函数换成了“从 JS 回调表里找对应函数并调用”。关键点是整个过程不能阻塞。如果 JS 脚本里写了一个 while(true) 死循环那整个设备的业务就冻结了。我在宿主层加了脚本执行时间片限制配合看门狗使用——超时就把当前脚本挂起记录错误位置。这个机制虽然粗暴但在 MCU 上是必须的因为你不可能让一个脚本 bug 拖垮整个设备。3.2 内存管理器与 GC 策略确定性优先于吞吐512KB SRAM 是非常紧张的环境所以内存管理这块必须“算计”着设计。我采用的是“分级 freelist 标记清除 GC”的组合不是传统 PC 上那种分代式 GC。分配器把堆按大小分成 16/32/64/128/256 等几个 bucket小对象走对应 bucket 的 freelist大对象走整块分配。这样做的原因是嵌入式场景的内存分配有很强的局部性定时器对象、字符串副本都是同构的按大小分级能显著降低外部碎片。GC以标记-清除为主不搞分代、不搞并发。每次 GC 触发时从根集出发把所有可达对象标记一遍然后清掉未标记的。触发时机有三个分配失败时立即触、每 500ms 周期检查一次、系统空闲时提前做一次。为什么不用分代 GC分代 GC 的写屏障、跨代引用处理会让代码量翻一倍,而且对实时性有不可控的停顿。对 512KB 的设备来说一次全量标记清除的停顿时间是可以预算的实测大约 8~15ms业务上完全可接受。内存分配里最容易翻车的是“用户 JS 对象”和“C 侧宿主对象”之间的引用关系。举个例子C 侧创建了一个 UART 对象传给 JSJS 把它存在一个全局变量里GC 如果不知道这个根就会把这个对象当成垃圾回收掉。这个坑我后面专门讲因为它的排查链路值得每个人走一遍。3.3 标准库裁剪不是“少”而是“对齐业务”标准库不是按模块整个裁剪的而是做到函数级别。比如字符串模块只保留 concat、substring、indexOf、length不需要 split 的高级正则模式就直接编译掉。裁剪逻辑是构建时从“入口脚本”出发做依赖分析把用不到的模块和函数标记成死代码从二进制里剥掉。听起来简单做起来有个坑JS 动态语言的特性导致依赖分析很难静态化。比如脚本里用了JSON.stringify(obj, null, 2)如果裁剪器只按函数名匹配可能误删配套的缩进逻辑。解决方式是让裁剪器保留“一个函数引用的全部辅助代码”宁可多留一圈不可少留一根。这会导致体积略微超标但比运行时碰到 undefined is not a function 要省心得多。4. 真正难的地方把 JS 世界接到嵌入式外设上4.1 事件循环怎么和嵌入式主循环讲和PC 上的 JS 事件循环是自成一体的但在 MCU 上主循环往往已经被 RTOS 或裸机业务占满了。让 JS 的事件循环另起一个线程在这么小的设备上不现实。所以 xuan 的做法是“合并循环”把 JS 的定时器和硬件事件统一合并到一个事件源队列里由设备原有的主循环统一驱动。伪代码大致是这个形状for (;;) { // 1. 处理硬件事件中断里只往队列塞事件不直接执行 JS while (evt_queue_has_item()) { js_event_t* evt evt_queue_pop(); js_call_callback(evt-cb, evt-data); } // 2. 处理 JS 定时器 js_process_timers(); // 3. 非紧急 GC 检查 js_maybe_gc(); // 4. 原业务空闲任务 legacy_idle_task(); }这套设计的核心原则是中断上下文只投递、不执行。中断里直接调用 JS 回调听起来很诱人但中断里做内存分配、执行解释器指令任何一个环节跟主循环产生竞争都会导致不可控的死锁或堆损坏。这个坑我踩过第五部分会把它当典型案例讲。真正写业务脚本的人不需要关心这些他只要这样写// 打开 GPIO2 作为输出 let led gpio.open(2, output, push_pull); // 每 500ms 翻转一次电平 setInterval(() { led.toggle(); }, 500); // 串口收到一帧完整数据后回调 uart.on(data, (frame) { let json JSON.parse(frame); if (json.cmd setLed) led.write(json.value); });这段脚本的每一行最终都会落到宿主层的一组 C 函数上。对设备业务来说JS 就是“胶水”把外设读到的数据粘起来把控制指令发出去。真正的数据采集、协议解析、底层驱动都还是 C 干。4.2 外设的 JS API 长什么样外设绑定的本质是JS 调用一个名字C 层执行一段驱动代码。比如上面例子里的gpio.openC 侧绑定层大概是这个骨架static int js_gpio_open(js_context_t* ctx, js_value_t* args) { int pin js_value_to_int(args[0]); const char* mode js_value_to_string(args[1]); gpio_t* dev malloc(sizeof(gpio_t)); dev-pin pin; gpio_init(dev, mode, args[2] ? args[2] : push_pull); // 关键把 C 对象包装成 JS 对象并且登记到 GC 根集 js_value_t* obj js_create_object(ctx); js_object_set_native_handle(obj, dev); js_gc_add_root(obj); // 防止被回收 return js_return_object(obj); }这段代码中最容易出问题的就是js_gc_add_root(obj)这一句。如果漏了JS 端拿到的外设对象可能随时被 GC 当成垃圾收走。更隐蔽的是很多对象不是创建立即用而是先存到某个模块级的变量里等回调触发时才用。那些“看起来一直存在”的对象GC 根本不知道除非你在根集里显式登记。外设对象的生命周期我是这样约定的打开时创建并登记到根集关闭时从根集摘除并释放原生资源。这样 JS 端不需要自行释放对象但又能保证任何回调里拿到句柄都一定可用。4.3 必须处理的两个边界可重入与 DMA 对齐第一个边界是可重入。JS 里的回调可能被嵌套触发比如在 GPIO 中断回调里调用了uart.write而 UART 发送完成中断又触发了另一个 JS 回调。这就会导致同一个外设的驱动函数被嵌套调用。xuan 的做法是给每个外设对象加“忙碌标志”如果驱动函数正在执行再次调用直接在事件队列里排队。这比“用互斥锁保护”更简单而且不会阻塞中断上下文。第二个边界是 DMA 缓冲区的对齐。很多 MCU 的 DMA 要求 buffer 按 4 字节对齐而 JS 的 ArrayBuffer 由运行时分配器管理malloc 出来的地址不保证对齐。我的解决办法是分配时对齐到 8 字节并且在小对象 bucket 里专门预留一个 DMA 专用区域。这个问题不解决会出现极其诡异的现象脚本逻辑完全正确但串口偶尔发出乱码定位一天都查不到根因。5. 实测阶段踩过的坑完整的排查链路这里挑三个我印象最深的坑每个都是排查了好几天才定位到的。写出来是希望后来者能直接跳过这些弯路。5.1 坑一GC 之后外设对象失效设备随机失控现象设备运行几小时到几天不等LED 控制突然失效串口也不响应了。看门狗偶尔能拉回来但日志里没有任何异常代码。排查链路第一个直觉是驱动代码的 bug于是反复检查 GPIO 和 UART 的寄存器操作没有发现异常在 JS 端加异常捕获把所有回调包一层 try/catch仍然没有打印任何错误尝试打印obj的类型发现有时能打印object有时直接抛 “invalid object handle”开始怀疑 GC。把所有外设对象在每次回调入口都做一次“可达性检查”终于发现这些对象在 GC 运行前是可用的GC 跑过一次后句柄仍然存在但底层原生指针已经被置为 null。根因外设对象创建后我只在创建时把它加入了根集但后续没有再触发任何“保活”操作。GC 扫描时如果这个对象没有在根集之外的任何 JS 对象空间被引用比如局部变量已经出栈它就会被标记为不活跃。更坑的是我为了让对象支持热插拔把原生句柄存放在一个弱引用表里GC 认为外层对象死了弱引用表也会被顺带清理。修复调整根集策略不是“创建时加一次根”而是“每次回调派发前把所有注册过的宿主对象统一加入本次 GC 的临时根集”。同时在弱引用表的清理逻辑里增加“宿主对象类型”判断外设句柄必须由关闭函数显式释放GC 不能代为清理。经验绑定层和 GC 的关系必须在一开始就定好规则。我的新规则很简单所有从 C 侧借给 JS 的对象在 JS 侧没有显式关闭之前都算根。宁可漏收几个垃圾也不能让一个还在用的外设对象被误杀。5.2 坑二脚本热更新过程中掉电Flash 损坏现象某次升级测试中升级到一半人为断电再上电设备无法启动只能通过烧录器恢复。不是每次都复现但一旦出现就是致命问题。排查链路最开始以为是 Flash 写入时序问题反复检查擦写函数逻辑没有问题抓了写入日志发现写入目标地址是脚本存储池 A而当前正在运行的脚本也在存储池 A考虑到运行时是从存储池 A 加载的升级过程中如果写入半个块当前脚本和升级脚本就混在一起了进一步发现我写的升级流程没有“双备份”概念只写了 A 区而 B 区还是上一个版本的残留数据但启动逻辑只认 A 区于是直接加载了一段不完整的字节码。修复重新设计了升级流程核心原则是“A/B 切换 三段式提交”1. 下载新脚本到存储池 B边写边校验 CRC 2. B 区数据完整后写入“切换标志”到独立的小块区域 3. 重启后引导代码检查切换标志若有效则从 B 区加载并标记 B 为当前区 4. 下次升级时写 A如此交替。切换标志放独立区域意味着即使写 B 区时掉电A 区仍完好设备还能用旧脚本跑。三段式提交的思路其实就是数据库事务的朴素版本先写数据再写提交记录提交记录必须在一个原子操作里完成。经验热更新最怕的不是“更新失败”而是“失败后没有任何可用版本”。只要有一个保底槽位就算脚本写得完全不能跑至少还能回滚到上一版。还有就是写 Flash 前必须先擦后写写入后立刻回读校验任何一步失败都回滚切换标志。5.3 坑三中断上下文调用 JS 回调导致死锁现象接上某个外设后系统运行几分钟至十几分钟整机死锁。严重时看门狗都救不回来只能冷启动。排查链路先用示波器抓相关的 IO 信号发现死锁前的中断触发频率异常高在中断服务函数里加日志发现中断处理里直接调用了 JS 回调函数而这个 JS 回调里恰好有uart.write最终落到 C 侧分配内存、加锁和主循环里另一个外设操作产生了锁竞争更隐蔽的是中断里的内存分配还可能导致堆管理器内部链表被破坏一旦发生后面的一切行为都是未定义。根因我最初为了追求“事件响应实时性”允许中断里直接派发 JS 回调以为只要回调本身不调用阻塞函数就安全。但实际上JS 回调执行过程中解释器自身就要分配内存、维护调用栈这些操作都不是中断安全的。修复改成“中断只塞事件队列主循环统一派发”之后问题彻底消失。这个设计原则后来我写到了项目文档的第一页任何中断上下文内不得执行 JS 代码、不得调用内存分配器、不得调用可能加锁的驱动函数。中断要做的事情就是用环形队列或位标志告诉主循环“有事情发生了”剩下的交给主循环。经验嵌入式 JS 的事件响应延迟确实会比纯 C 中断稍高但在绝大多数场景下完全够用。我的实测是从硬件中断触发到 JS 回调的第一条指令执行平均延迟约 12~18 微秒。对于传感器采样、通信帧处理这些场景这个数字完全可以接受。6. 跑完性能测试后的一些实话6.1 我的基准测试数据测试是在正式硬件上跑的CPU 主频我保守地跑在官方推荐的典型工作频率SRAM 就是那 512KB没有额外优化内存布局。测试项实测结果备注冷启动加载 400KB 字节码快照18ms不含外设初始化空闲时 SRAM 占用约 310KB引擎 宿主 用户堆执行 1 万次空循环约 4.6ms字节码执行效率JSON.parse解析 1KB 数据约 28us内核自带解析器JSON.stringify序列化 1KB 对象约 35us同上GPIO 写翻转一次JS 到驱动全链路约 3.2us不含中断延迟UART 发送 64 字节JS 到外设约 45us包含缓冲区管理这些数字相对现代 PC 上的 JS 引擎慢了不止一个量级但对于这个设备要干的活读传感器、解析简单协议、控制 GPIO、上报数据完全够用。空循环 4.6ms 跑一万次意味着即便每秒执行上万条 JS 指令也只占 CPU 时间的几个百分点。有一个现象值得注意JSON.parse的性能比预期好很多原因是引擎内部对 JSON 解析做了专门的字节码优化不经过通用解释器路径。所以在设计业务脚本时用 JSON 作为设备间交换数据格式开销比用字符串拼接再逐字节解析要低得多。6.2 这个运行时适合做什么、不适合做什么实测跑下来我对 xuan 的边界有了很清楚的认识。适合的场景可配置的联动规则比如“传感器超过阈值持续 N 秒→执行动作”这种逻辑脚本化之后客户现场就能调整上报策略决定什么数据需要上报、什么时候上报、上报前如何聚合简化的协议适配UART 数据帧的分包、校验、字段提取设备间的简单业务协作几个外设之间按条件互相触发。不适合的场景高频信号处理比如音频采样、振动分析这种每毫秒都要做大量运算的必须留在 C 层强实时控制对响应延迟有硬性要求的闭环控制JS 层的调度开销不可接受大量数值运算普通 MCU 上 JS 浮点性能比 C 慢几十倍跑矩阵运算纯属自虐超长状态机脚本可以描述状态机但状态特别多、迁移条件特别复杂的还是 C 里画表实现更可靠。性能边界我在设计时就明确过** 高频、低延迟的活给 C低频、多变的活给 JS。** 这个边界划得越早后面越省心。xuan 的价值不是替代 C而是把“经常变”和“不太变”两部分的开发节奏分离底层驱动半年才动一次业务脚本每周可以热更新迭代速度完全不一样。整个项目做完我最大的体会是嵌入式 JS 运行时不是一个“跑起来就行”的东西它需要在引擎之上补齐大量宿主侧工程。GC 与宿主对象的生命周期、事件循环的合并、热更新的可靠性每一项单独拿出来都是可以写一篇文章的深度。如果你的设备也有类似需求我会建议先花几天时间把 C 与 JS 的边界彻底想清楚再动手写第一行绑定代码。这个边界想清楚了6MB 不大但足够做出一个真正能落地的运行时。