资讯动态

6MB嵌入式JavaScript运行时:中等算力设备上的自研实践

发布时间:2026/10/15 1:46:51 来源:尧图企业网站定制
如果你只听说某个嵌入式项目塞了一个 6MB 的 JavaScript 运行时第一反应大概率是“这么大这也能叫嵌入式”但实际情况是并不是所有嵌入式设备都是 8 位单片机。过去一年我给自己参与的一类中等算力的智能硬件设备手写了一套 JavaScript 运行时连编译产物、只读数据、初始内存堆池全部加在一起稳定压在 6MB 上下内部代号叫 Xuan。它解决的问题非常具体让主频三五百兆赫、带有几兆字节 PSRAM 的设备能跑接近现代 JavaScript 开发体验的业务脚本而不是只能执行几十行玩具代码。如果你也在嵌入式平台上折腾过脚本语言应该懂那种痛苦——轻量方案语法阉割得厉害重型方案又根本塞不进去。这篇就把我的选型理由、内部架构、内存布局和踩坑记录完整摊开。1. 6MB 的预算到底落在哪里先聊聊目标设备和体积构成1.1 不是所有嵌入式设备都是八位单片机先说清楚 Xuan 跑在什么设备上。如果你默认“嵌入式设备 一个单片机 几 KB SRAM”那 6MB 确实大得离谱。但现实里有一种非常常见的中间形态主频 300800MHzNor Flash 416MBPSRAM 28MB带一个简单的 UI 屏、网络模块或者工业总线接口跑轻量 RTOS甚至干脆裸机加调度器。这种设备跑不了 Linux也没法指望一个完整的大型 JS 引擎但同时又希望批量生产后业务逻辑能远程升级能由熟悉 JavaScript 的应用工程师而不是嵌入式工程师来维护代码。Xuan 的目标就是这一档。它和超低端方案、重型方案之间的定位差别我用一张表说明设备等级典型主频Flash/RAM适合的 JS 方案业务脚本复杂度超低端单片机几十一百 MHz256KB1MB极简子集方案几十行逻辑固定中端智能硬件300800MHz4MB / 2MB 以上自研或中型运行时上千行带异步和模块高端多媒体盒1GHz可跑 Linux64MB 以上直接上 Node/浏览器级引擎完整应用Xuan 就是为中间那行设计的。6MB 的预算很大方但它换来了接近完整语言的表达能力。如果你手里只有 256KB 的 Flash我不建议你参考这套做法。1.2 体积是怎么算出来的一个不那么严谨的预算过程6MB 不是拍脑袋定出来的。那是一个被功能需求一步步推上去最后用红线框住的结果。编译完的产物被拆成几块逻辑组成部分可执行代码.text、只读数据、模块字节码快照、对象池初始大小、解释栈和一些保留空间。组成部分典型体积说明Code编译出的机器码约 2.1MB解析器、解释器、GC、内建函数 C 实现RO Data只读数据约 1.2MB内置对象名、字符串表、常量池、函数表模块字节码快照约 0.6MB挂在虚拟文件系统里的预编译业务模块对象池初始大小约 1.6MB动态JS 对象、数组、闭包等运行时对象分配空间解释栈 / 线程栈约 0.4MB每个协程默认 8KB按并发数预留保留 / 对齐约 0.1MBGC 暂存区、内部排序缓冲等这 6.0MB 相加就是我在编译配置里设定的红色警戒线低于它功能列表就得砍高于它就不能保证在目标板子上顺利启动。最早只有一个语法分析器和最简解释器时整个工程只有 3.8MB后来一个一个往上加 GC、模块加载、Promise/async、Date、RegExp一路涨到了 5.4MB再砍掉一些不常用的内建对象回落到 4.9MB最后加上调试钩子和绑定层样板代码稳定在 5.86.2MB 之间。所谓“6MB 运行时”其实是个妥协后的结果不是一个精心设计的整数。1.3 我为什么没直接“白嫖”现成开源运行时动手写之前我拿市面上的两类方案做了实测当场就把它们排除了。第一类是为极小内存设备设计的轻量运行时体积能压到 200500KB内存占用也控制得漂亮但它对 ECMAScript 特性做了大量子集化class 要部分支持async/await 基本没有模块生态更是想都别想。第二类是出厂自带完整语法和 JIT 的重型方案功能是全面但移植到这种板子上要碰运气裁剪完体积依然可观调试器和构建体系也都是为 Linux 环境设计的对交叉编译并不友好。Xuan 卡在中间这个位置是我主动选的。自研的核心理由不是“别人不好”而是这个量级恰好能塞下我要的完整语法、现代异步模型和模块系统同时所有代码都可控出问题我能自己定位、自己修。如果你就是想在 500KB 以下跑几十行控制逻辑真的别学我直接用轻量方案更合理。2. 运行时内部结构拆解从解析器到事件循环的选择与取舍2.1 语法分析器手写递归下降而不是自动生成一套新玩法语法分析器这块我一开始就定了方向手写递归下降不用现成的语法分析器生成工具。原因很现实在交叉编译环境里生成工具多一层依赖构建链会变复杂出错信息不好控制而且生成器产出的代码一旦有性能或内存问题调试体验会非常难受。手写虽然累但“出错时报告哪个文件的哪一行字段”这件事完全由我控制。解析阶段的内存使用我统一走了 arena 分配一个大型脚本解析完所有临时节点一次性归还不会留下反复增删节点的碎片。// 简化版 parse 入口实际节点会分散到 expr/stmt 子函数 ParsedProgram* xuan_parse(Parser* p) { ProgramScope* scope new_scope(p); while (!at_eof(p)) { Stmt* s parse_statement(p); if (error(p)) return emit_syntax_error(p); append_stmt(scope, s); } return make_program(scope); } static Stmt* parse_statement(Parser* p) { Token t peek(p); if (t.type TOK_LET || t.type TOK_CONST) return parse_variable_decl(p); if (t.type TOK_ASYNC) return parse_function_decl(p, true); if (t.type TOK_IF) return parse_if(p); if (t.type TOK_TRY) return parse_try(p); return parse_expression_stmt(p); }手写解析器的工作量比想象中大得多模板字符串里的嵌套括号、反引号转义、正则字面量和除法符号的消歧包括 BigInt 字面量后缀这些都是在正式脚本里必然会碰到的东西不是“玩具代码”能测出来的。如果你也想走这条路请做好心理准备光是一个健壮的字符串解析器就能消耗掉你两个完整的周末。2.2 字节码解释器从“能理解 AST”到“能跑得高效”最初的原型里我试图直接遍历 AST 解释执行代码确实短但很快就发现了问题每走一个节点都要经历一次函数调用和分支判断对象属性和变量访问没有统一编码想加断点、想统计热点都无从下手。所以第二个大版本我就改成先编译成字节码再用栈式虚拟机执行。指令集最后稳定在 200 条左右热门指令用单字节 opcode冷门指令按需附加操作数。解释器主循环在外面看就是一个大 switch实际发布版本我换成了 computed goto在支持的编译器上能明显减少分支预测开销。// 简化的 switch 版本实际版本用 computed goto 加速 void xuan_vm_run(VM* vm) { for (;;) { uint8_t op *vm-pc; switch (op) { case OP_PUSH_I32: { int32_t v read_i32(vm-pc); push_i32(vm, v); break; } case OP_GET_PROP: { Value obj pop(vm); uint32_t id read_interning_id(vm-pc); push(vm, get_prop_by_interned_id(obj, id)); break; } case OP_CALL0: // ... break; case OP_RET: return; } } }当时有一个关键决定影响很大属性访问不用字符串名字而是先把属性名编译成一个全局 intern id。运行时拿 id 查对象速度比每次全量比较字符串快得多。这个设计在后面的性能排查里帮了大忙但代价是对象内部需要维护一张“intern id 到属性槽”的映射表裁剪某些内置对象时容易出问题这个坑后面单独讲。2.3 GC 策略引用计数先行标记清扫兜底GC 选型我前后推翻过三次。纯引用计数实现简单、内存回收及时但环引用回收不掉闭包互相引用、事件监听器挂来挂去很容易漏纯标记清扫能解决环但全停顿时长在嵌入式设备上不可控而且每次都要从根集合重新枚举C 层持有 JS 对象引用时也容易漏 root。最终我选了混合策略绝大多数对象走引用计数临时对象用完即还环处理和对存量对象的整理则通过周期性的标记清扫来做触发条件不是固定时间而是“分配压力 新对象增长”的组合。GC 策略优点缺点我的取舍纯引用计数回收及时、暂停短环无法回收、原子操作开销不用纯标记清扫能回收环、内存紧凑停顿不可控、root 枚举复杂不用引用计数 标记清扫两者优点兼顾实现复杂、裁剪风险高最终方案// 触发标记清扫的大致逻辑 void maybe_gc(VM* vm) { bool pressure heap_allocated(vm) vm-gc_threshold; bool growth vm-alloc_since_last_gc 64 * 1024; if (pressure growth) { xuan_gc_mark_sweep(vm); } }混合策略也带来一个新的用户体验问题开发者无法确定对象到底什么时候被回收。所以我在运行时里暴露了一个xuan.gc()强制回收入口并给所有对象加了可选的finalizer钩子。实际测试里大部分业务脚本对 GC 没有特别强的时间要求但如果你做的是实时性较高的传感器采集务必先在开发板上压一遍对象创建和销毁的高频场景。2.4 模块系统和事件循环让脚本写起来像现代 JS如果只写一个能执行var a 1的解释器那可能几千行就完事了。真正把 Xuan 从“玩具”推向“可用”的是模块系统和事件循环。业务脚本不是一段孤立的代码它需要require(hw/uart1)拿到底层串口的能力需要setTimeout做周期性任务需要Promise去等一个硬件中断。下面这段脚本就是这类设备上一个非常典型的业务const uart require(hw/uart1); const scheduler require(sys/scheduler); async function readAndLog() { const data await uart.read(256); scheduler.log([uart] got %d bytes, data.length); } setInterval(readAndLog, 1000);模块加载采用 CommonJS 风格而不是浏览器式 ESM主要原因是嵌入式环境里同步加载更符合直觉也不需要在构建阶段做复杂的静态 import 分析。所有预编译好的模块字节码都挂在一个虚拟文件系统上可以是链接时烧入的固定快照也可以是运行时通过 OTA 挂载的增量包。事件循环在 C 层实现一个最小二叉堆管理定时器一个 pollfd 数组管理 IO一个微任务队列处理 Promise。整套 JS 世界是单线程的async/await 不会引入新的线程所以边界非常清晰。3. 内存布局、特性裁剪和最低移植层让 6MB 真正可落地的工程细节3.1 一套 features.h 引发的血案特性裁剪与组合验证6MB 能塞进目标设备靠的不只是优化还有特性裁剪。我维护了一个巨大的features.h里面每一个宏控制一组功能是否编译。为了让“关掉某些功能后体积下降”是可预期的我测过每一组宏单独关闭后的体积变化产出过一张内部参考表特性宏开启时近似体积增量关闭后主要影响XUAN_FEATURE_PROMISE248KB没有 async/await业务逻辑严重受限XUAN_FEATURE_REGEXP412KB正则全部不可用字符串处理能力下降XUAN_FEATURE_TYPED_ARRAY96KB无法高效处理二进制协议数据XUAN_FEATURE_PROXY168KB部分框架写法会失败XUAN_FEATURE_EVAL80KB不允许动态执行代码XUAN_FEATURE_DEBUG_HOOK120KB失去运行时诊断能力建议开发板开启、量产关掉这里我踩过一个很重的坑在某一版里我把 Proxy 关掉之后只跑了一遍基础回归结果某个对象布局判断走到了未处理的 switch 分支整个设备在运行时崩了。后来排查发现关闭 Proxy 后与 Proxy 相关的 intern 表字段没有被清掉对象类型判断的 switch 里出现了“理论上不会出现”的实际值。从那以后我写了一个全组合构建脚本把主要特性开关的“开/关”矩阵跑一遍每个组合至少执行冒烟测试。虽然每次发版要跑几个小时但它阻止了至少三次潜在事故。3.2 内存池布局静态区、对象池和 no_malloc 模式内存布局是嵌入式 JS 运行时里最容易被低估的部分。Xuan 不直接依赖 libc 的 malloc 去分配每个 JS 对象因为那样会造成严重碎片化。我按对象大小分桶小对象字符串、数字盒、中对象数组、普通对象、大对象字节码缓冲区、堆快照各自从独立池里分配只有在池不够时才向系统申请新块。对象池初始 1.6MB但这个值不是写死的它在启动时根据编译配置和剩余 PSRAM 自动调整。还有一类特殊布局模块字节码快照和内置字符串表放在 Flash 映射区执行时直接读 Flash不搬进 RAM。这省了 0.6MB 的 RAM代价是这部分读取比内存慢一些。如果在某些对启动速度要求极高的场景里也可以预加载到 RAM这是个移植时可以通过配置项切换的开关。此外我在底层保留了一个 no_malloc 模式整个运行时所有内存都来自预分配的固定池池满直接抛 OOM不跟系统申请任何增量。这种模式特别适合对实时性敏感、不允许运行中出现长时间分配失败的设备。3.3 移植一个新板子只需要改 6 个函数我把移植层收敛到了 6 个函数目标是让团队里的嵌入式工程师两天内能把 Xuan 带起来。这 6 个函数覆盖了运行时和硬件之间最基础的交集读取系统毫秒、往调试串口写数据、分配内存、释放内存、加载模块字节码、注册原生绑定表。其他一切如 GC 根枚举、事件循环、线程栈管理等都在运行时内部完成。移植函数作用注意点xuan_host_clock_ms()提供单调递增毫秒时钟必须单调否则定时器会乱xuan_host_write_console()输出日志到串口/屏幕不阻塞最好xuan_host_alloc()分配内存块返回 NULL 时必须触发 OOM 流程xuan_host_free()释放内存必须和 alloc 配对xuan_host_load_module()返回模块字节码支持从 Flash 映射读取xuan_host_register_natives()注册设备原生 API是后期扩展能力最关键的入口// 移植层示例时间与调试串口 uint64_t xuan_host_clock_ms(void) { return (uint64_t)get_tick_counter_ms(); } void xuan_host_write_console(const char* s, uint32_t len) { uart_send_bytes(DEBUG_UART, (const uint8_t*)s, len); }这里有一个经验之谈移植层函数只做机械的接口映射不要在里面写任何业务判断。比如“什么级别日志不打印”“模块路径应该怎么路由”这类事情全部应该在 JS 层或运行时配置里处理。一旦工程师开始往移植层塞私有逻辑后期换板子的成本就会指数上升。4. 实测数据与一次真实的性能排查数字、结论、修复4.1 三个典型脚本的峰值内存实测没有实测数据一切架构上的自信都是空的。我在目标板子上跑了三个典型负载纯计算密集的fib(32)递归、大量创建和销毁临时对象的“对象冲击”、以及模拟异步串口读写的高频 IO 脚本。这些脚本分别代表了计算、内存、异步三类真实业务测试脚本峰值堆内存GC 次数执行时间说明fib(32)纯递归计算128KB0421ms压栈行为正常堆无压力1 万个临时对象创建/销毁2.1MB941.8s引用计数承接了绝大部分回收模拟异步串口收发 2000 次512KB212.3sasync 状态机保持稳定第一个脚本堆曲线非常平说明栈式虚拟机没有异常溢出第二个脚本的 94 次 GC 里绝大多数是标记清扫在清理环引用说明混合策略确实在起作用第三个脚本最接近生产场景峰值堆没有因为异步链路的堆积而失控。这些数据不是要证明 Xuan 快而是提供了一个可复现的评估基准后续任何优化都必须在这个基准上对比。4.2 一次持续十小时的性能排查字符串、属性和 async有一次现场反馈一个数据解析脚本跑完要 6.7 秒预期应该在 1 秒左右。最开始大家都以为是字符串拼接太慢因为代码里有个循环用拼了上万次在 JS 层 O(n^2) 是必然的。我把那个死循环改成数组 join 之后降到 4.2 秒但离预期还很远。这时不能凭直觉继续猜得做指令分布统计。我把虚拟机的 opcode 计数器打开发现OP_GET_PROP占了 36% 的指令数问题并不在字符串而在属性访问。修复方式是把属性访问的 intern id 缓存再往前推一层每个对象的属性槽直接关联一张小尺寸缓存最近访问的几个属性命中后发现直接拿 index不再做哈希查找。这个小改动让脚本从 4.2 秒降到 1.9 秒。剩下 0.8 秒还是嫌多继续 profile 发现 async/await 的实现里每次 await 恢复上下文都会做一整块协程栈复制。我把异步状态机改成纯 C 层结构协程栈不再整体复制只保存必要的局部寄存器索引最终整段脚本稳定在 1.2 秒。优化阶段耗时关键动作原始版本6.7s无修正字符串拼接4.2s循环改为数组 join属性访问缓存1.9sintern id 属性槽缓存异步状态机改造1.2s协程栈复制改为 C 层状态机这次排查给我最大的教训是不要相信第一直觉别急着骂代码风格先用指令计数器把瓶颈找出来。在嵌入式 JS 运行时的调优里最贵的往往是开发者拍脑袋猜方向的时间。4.3 和开源路线对比不鼓吹替换只讨论代价经常有人问我既然现成方案那么多为什么还要自己写我把三种路线做了个非常客观的对比。A 类是极小内存开源方案B 类是 XuanC 类出身桌面和服务器的重型方案。结论很直接如果你只要跑几十行脚本A 类方案体积更小、更省电根本没理由选我但你的业务脚本上千行、用了 async 和模块C 类进不了设备A 类又跑不动这时候 B 类的价值就非常明显了。对比维度A 类极简开源运行时B 类XuanC 类重型运行时运行时体积200500KB约 6MB几十 MB 起内存占用几十 KB 级可控按池分配数百 MB 级语法完整度严重子集化接近完整语言完整异步/模块支持弱或没有支持 async/await CommonJS完整但过重移植复杂度低中6 个函数高依赖系统环境我不会鼓吹用 Xuan 替换任何开源路线。技术选型本来就是代价交换6MB 换来的是业务开发效率和一个完全可控的调试环境。如果哪一天业务简单到只需要简单状态机我也一定会把这套运行时从项目里摘掉。5. 踩坑与劝告嵌入式 JS 运行时最折磨人的不是语言本身5.1 最难的坎宿主边界上的 C/JS 互操作语法、GC、字节码这些内部系统虽然复杂但它们都在我自己的掌控范围内。真正把士气打到崩溃的是 C 层和 JS 层之间的互操作。设备的原生能力全在 C 侧而业务逻辑在 JS 侧两边的边界一旦没处理干净崩溃就是瞬间的。我曾经遇到过一次事故某个传感器 API 的返回值在 C 层被直接强转成int但实际值是double溢出后成了一个很大的负数传给了底层库最后设备被看门狗咬住隔几秒复位一次。从那以后我立了一条铁律所有绑定函数入口都必须做严格类型检查外部值进入 C 前不过校验直接返回报错。// 绑定函数里最朴素的类型检查 static Value bind_uart_read(Frame* fr, uint32_t argc) { if (argc 1 || !is_number(fr-argv[0])) { return throw_type_error(fr, uart.read: length must be number); } int len to_int32(fr-argv[0]); if (len 0 || len 4096) { return throw_range_error(fr, uart.read: length out of range); } // ... }除了参数类型还有一个互操作的坑来自回调。JS 注册了一个回调函数给 C 层C 层在中断上下文或者持有锁的时候去调用它会出大事。我的原则是C 层尽量只把事件登记成“待处理”真正的回调推回事件循环主线程执行绝不跨线程调用 JS。5.2 调试工具链没有 DevTools 的世界要学会自救没有 DevTools也没有调试器协议这是自研运行时最痛的短板之一。普通 Node 或浏览器开发者根本不会意识到没有console.log的替代物时问题定位有多难。我做的第一件事是串口 REPL板子运行中随时可以用命令行输入一段 JS 并立即执行这对复现场景、查看内部状态非常有帮助。第二件事所有内置对象都实现了统一的toDebugString()无论什么对象都能以字符串形式输出内部内容。第三件事是把堆快照导出成 JSON通过串口分包拉回 PC 端分析。 xuan.gc() task.list() [main] staterunning heap1.2MB uptime12s sensor.read() { id: 3, value: 0.82, ts: 123456 }这套“基础设施”看起来不起眼但它决定了运行时能不能真正用于量产调试。开发板上我会把 debug hook 打开量产固件里再关掉节省那 120KB。如果你在做一个嵌入式 JS 运行时请一定在前期就把可观测性纳入设计后期补比前期做会痛苦十倍。5.3 给打算自造运行时的读者的几条实在建议说实话我不太建议所有人走自研运行时的路这是一条漫长的路。如果你评估后仍然决定要做我有四条血泪建议想分享。第一先写一个尽可能小的核心跑通全部门语言测试再慢慢往上加功能不要一开始就想做完整语言第二参考成熟方案的语法和文档非常必要但不要直接复制大段实现你需要的是彻底理解而不是假装理解第三特性裁剪和全量构建必须并行维护每个改动都要跑一遍特性组合矩阵否则你迟早会被某个关闭开关后的隐藏崩溃砸到头上第四原生绑定层和标准库的维护是长期开销不是做完解析器就结束了最好有专人持续跟。这四条每一条我都付出了实际代价才换来。尤其是第四点很多人以为写完运行时核心就大功告成实际上设备厂商要接入的传感器、屏、网络协议一个比一个怪绑定层的维护量会远超你最初的想象。在我动手写 Xuan 之前我预测最痛苦的部分会是语法分析器或字节码优化真正做完一遍才发现最难的反而是把模块系统、事件循环、GC 和互操作边界全部揉进一套内存预算里。6MB 不是拍脑袋定的目标它是所有功能相互妥协后的自然结果。如果你也打算做类似的事建议先想清楚三件事设备预算到底能承受多少团队有没有人长期维护绑定层业务脚本复杂到什么程度。这三个问题解决了自研运行时的收益会非常直接解决不了请冷静地回去用开源方案。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑