1. 6MB 的额度是项目约束逼出来的,不是炫技
1.1 这个故事最开始是一个“不太可能”的活
标题里那个 6MB,在不少人看来可能是个卖点或者噱头。但它在我这边是一个非常具体的硬件约束倒推出来的结果:某客户做的是工业控制类的联网设备,主控芯片只有 8MB 内置 Flash、1MB RAM,主频两百兆左右。设备上已经有完整的 UI 框架、字库、通信协议栈、外设驱动,这些挤完之后,能留给“动态业务逻辑”的空间差不多就是 6MB 左右。
客户的需求也很直白:设备已经卖到现场了,固件不能隔三差五整包升级,又要能持续调整业务逻辑。换句话说,他们想要在设备里留一块“可以热更新的应用区”,让业务脚本可以单独替换。一开始我想过用轻量级脚本语言,比如 Lua、或者自己的 DSL。但客户那边的应用开发团队跟我说,他们最熟的就是 JavaScript,连工厂产线上的调试脚本都是 JS 写的。我思考了两天,最后决定:那就做一个 JavaScript 运行时,命名就叫 xuan。
这个“xuan”没有太复杂的含义,旋钮的“旋”——嵌入式设备里很多配置逻辑就藏在旋钮和按钮后面,意思是让 JS 脚本能在小型设备上转起来。它的定位从一开始就不是“完整复刻浏览器里的 JavaScript”,而是在 6MB 预算内实现一个足够实用、可嵌入、可调试的 JS 运行时。
1.2 从硬件规格倒推出来的内存预算
在写第一行代码前,我先把 6MB 的账算了一遍。不要小看这一步,很多自研运行时的项目死在半路,就是因为在架构阶段没想清楚内存账。
这 6MB 不是全部给引擎代码用的,它包含三块:引擎本身的机器码、预编译的运行时库(就是那些内建函数和对象)、以及最后落地的用户脚本字节码和常量数据。我的初步预算是这样拆的:
| 项目 | 预算 | 说明 |
|---|---|---|
| 引擎代码 | 约 2.4MB | 词法分析、语法分析、字节码编译器、虚拟机循环、GC |
| 运行时库 | 约 1.6MB | 内建对象、字符串方法、Promise 相关模块、宿主绑定层 |
| 用户脚本区 | 约 2MB | 存放编译后的字节码、常量池、模块加载所需数据 |
RAM 那边也有一条硬线:整个引擎的动态堆不能超过 256KB,而且这个堆还要和 UI 系统、协议栈共享剩余的内存。也就是说,我要在一只脚被捆住的情况下做 JavaScript 运行时。
1.3 为什么不直接移植现成的开源 JS 引擎
肯定会有人问:市面上不是有现成的 JavaScript 引擎吗,为什么还要自己造一个?
我当时的考量是:大而全的引擎塞不进这个 Flash 预算,而且它的 API 设计、内存模型、编译依赖都不适合我这种“只需要 ES5 子集 + 少量异步能力”的场景。另一个选择是那些专门面向小内存的 JS 引擎,但它们的语义裁剪很厉害,有些连闭包和 try/catch 都做不完整。如果我要花大量时间适配和裁剪,还不如直接按照自己的需求设计一个。
更关键的一点是:客户需要的不是“能跑 JavaScript 的芯片”,而是“能跑他们现有业务脚本的芯片”。既然业务脚本是我来帮助他们迁移的,那么我完全可以主导语言子集的设计,只要语义清晰、文档明确,脚本怎么写我说了算。
2. 语言特性筛选:删掉 eval、正则和 Generator 之后,JS 还是 JS
2.1 保留和删除的边界怎么划
JavaScript 标准库非常庞大,完整塞进去根本不可能。我的原则是:只保留“业务脚本高频使用、且语义可以用少量 C 代码实现”的特性。
首批保留的包括:函数、闭包、对象字面量、数组、字符串常用方法、数字运算、布尔值、null/undefined、try/catch、typeof、instanceof、setInterval、Promise、async/await的简版实现。这些都是 IoT 逻辑里常年命中的能力:延时、循环、状态判断、异常上报、读写外设。
第一批砍掉的有:eval、Function构造器、with、Generator、Proxy、Reflect、Symbol、完整正则表达式、Date的复杂格式化能力。
我专门把eval从语法层面直接拒绝掉。这样词法分析和代码缓存都大大简化。还有一个意外的好处:脚本安全性变高了,因为没有动态执行代码的入口,恶意脚本能做的事情少很多。后面在做设备远程升级脚本时,这个选择帮了大忙。
2.2 裁剪之后,脚本写起来是什么感觉
下面是客户现有脚本迁移后的一个示例节选,用来说明 xuan 的语言形态:
// 设备温控脚本示例 const target = 26; let current = 0; function readSensor() { return dev.temp.read(0); } async function regulate() { while (true) { current = readSensor(); if (current > target + 2) { dev.relay.write(1, true); } else if (current < target - 2) { dev.relay.write(1, false); } await delay(1000); } } regulate().catch((err) => { dev.report.error(err.stack); });你们可能发现了,我保留了const和let。在 JS 严谨模式下,块级作用域语义很明确,编译期就能确定变量槽位,这比 var 的隐式变量提升要容易处理得多。所以我干脆把 xuan 所有脚本默认置于严格模式,而且只支持显式声明变量,不允许意外创建全局变量。
2.3 为了小体积做的一次语义妥协
要说完全没妥协也不现实。比如我保留了async/await,但内部实现不是完整的协程,而是把它转换为状态机式的字节码。这样省下了大量运行时栈和生成器状态存储的开销。
Date只保留时间戳和基本的 UTC 获取方法。业务脚本最常见的需求是“记录当前时间并上传”,而不是做复杂的时区换算。因此Date.now()是完整支持的,但toLocaleString()这类方法会返回格式固定的字符串,不再跟随运行时环境的区域设置。
正则表达式我原本想完整支持,但完整引擎体积至少要增加 800KB 以上。后来客户脚本我看了下,真正用到高级正则的地方几乎没有。最后只实现了一个简化版本:支持字面量匹配、字符集、重复量词和分组。这个简化版能应付大部分协议字段提取。
3. 从 AST 解释器改成字节码 VM:这个转折决定了项目生死
3.1 第一版原型:AST 直接递归求值的教训
项目最开始三周,我采用了最快的方案:词法分析后生成 AST,然后直接对这个语法树做递归求值。这个原型跑通得非常快,第三天就能执行基本的函数调用和对象读写。
但两周后问题来了。第一,每个 AST 节点都是独立的堆对象,一个稍微复杂的脚本就会有几千个节点,内存消耗直接顶到上限。第二,递归求值时每次访问对象属性都要查找属性表,性能完全没法看。第三是深浅问题:脚本里循环调用的函数如果递归层数深一点,C 栈就爆了,设备直接死机。
这个原型让我意识到一件事:在嵌入式平台上做 JS 运行时,“先把功能跑通”远远不够,内存模型和调用机制从一开始就要设计正确。于是我推翻了 AST 解释器的架构,开始做字节码虚拟机。
3.2 为什么选择基于栈的字节码,而不是寄存器字节码
当时在基于栈的虚拟机和基于寄存器的虚拟机之间犹豫。像 Lua 的寄存器虚拟机性能好,但编译器和指令编码都比栈机复杂不少。考虑到开发周期和调试难度,我最后选了基于栈的字节码。
基于栈的 VM 优点很直接:每条指令不需要关心操作数落在哪个寄存器,编译逻辑简单,指令编码也短。缺点是执行时会有大量的压栈和弹栈操作。不过 xuan 的定位不是跑大型 Web 应用,脚本通常只有几十到几百行,这点开销完全可接受。
指令宽度我统一设计为 32 位。低 16 位是操作码,高 16 位是立即数或地址。32 位宽度换来的是极高的对齐效率,而且对于 6MB 这个规模,2 字节的跳转偏移完全不够用,16 位刚刚好。
| 操作码 | 立即数/操作数 | 说明 |
|---|---|---|
| LOAD_CONST | 常量索引 | 从常量池加载到栈顶 |
| LOAD_LOCAL | 本地变量槽位 | 读取栈帧本地变量 |
| STORE_LOCAL | 本地变量槽位 | 存入栈帧本地变量 |
| LOAD_PROP | 属性索引 | 从栈顶对象读属性 |
| CALL | 参数个数 | 调用栈顶函数 |
| JMP | 目标地址 | 无条件跳转 |
| JMPF | 目标地址 | 栈顶为假时跳转 |
| RET | 无 | 返回当前函数 |
| MAKE_CLOSURE | 捕获单元数量 | 创建闭包 |
3.3 闭包和函数调用:最容易踩坑的部分
闭包的实现是整个字节码设计中风险最高的地方。JavaScript 的闭包会捕获外部函数的变量,而且这些变量可能在外部函数返回后依然存活。我采用了一种类似 Lua upvalue 的方案:编译器在编译外层函数时,确定哪些变量必须被捕获,然后在内层函数中建立一条捕获链。
当内层函数引用外层变量时,它不是直接访问当前栈帧,而是经过一层 upvalue 指针。第一次访问时把栈上的变量值“提升”到堆上,之后所有修改都通过这个堆上的槽位完成。闭包因此变成“函数对象 + 指针数组”,尺寸可控,语义也正确。
函数调用帧的设计也做了特殊处理。每个栈帧保存返回地址、调用者帧指针、栈基址、以及参数区。为了控制栈帧分配开销,我没有对每个帧单独申请堆内存,而是在一个预分配的执行栈上按数组方式压帧。整个执行栈上限是 64KB,超过就抛 StackOverflow 异常,不会再让 C 栈跟着爆掉。
4. 对象、字符串和数值:三种核心数据在内存里怎么安家
4.1 数值表示:能整型就不要浮点
JS 的规范里只有 Number 类型,底层统一是 IEEE 754 双精度浮点。但嵌入式设备上如果所有数值都按 8 字节存储,那么数组、循环计数器、GPIO 电平这些最常见的场景会白白浪费一半到一倍内存。
xuan 内部给数值设计了一个 64 位带标记的联合结构:低 8 位存类型标签,高 56 位存数据。当数据可以被判定为 32 位整数时,把它存成有符号整数;只有当赋值结果明显超出整型范围,或者出现除不尽的小数时,才转换成 double。所有算术运算开始前,先检查两个操作数的类型,如果都是整数,直接用整数运算,只有遇到溢出才升级为浮点。
这个优化对设备脚本极其关键。一对解析器处理类似reg = (addr << 2) | offset的算法时,如果用纯浮点,光是类型转换和无条件转换就够让 CPU 浪费几万个周期。现在常见指令序列完全可以全程走整数流水线。
4.2 对象模型:从哈希表优化成 shape + 属性槽
最开始的实现里,每个 JS 对象都是一个动态哈希表。后来我观察脚本特征,发现大多数对象都是同构的:比如传感器数据对象永远只有{temperature, humidity, timestamp}这三个属性。
我引入了 shape 机制。每个 shape 描述一组固定的属性名和顺序,对象本身只存储与 shape 对应的属性值数组。打个比方:shape 像是职位的职位说明书,对象像填写好的表格,表格只存数据,不存每个单元格叫什么名字。
shape obj_shape { prop_keys: [temperature, humidity, timestamp] prop_count: 3 } 实例对象 { shape_ptr: &obj_shape, values[0] = 25.5 values[1] = 60.2 values[2] = 1690000000 }当对象属性完全匹配 shape 时,属性读写被优化成数组索引操作,比哈希查找快几个量级。只有出现新属性名时才切换到更通用的哈希模式。我还顺着这个思路做了单态内联缓存,在虚拟机里缓存最近一次属性访问的 shape 和槽位,命中就直接读取。
4.3 字符串优化:短串内嵌和驻留池
字符串在 JS 里是重度使用的类型,尽管脚本长字符串不多,短键名却到处都是。为了省内存,我把字符串设计了三种形态:
第一种是短驻留字符串。长度不超过 16 字节的字符串会被全局唯一驻留,每次创建时先去驻留池查找,存在就复用同一个内存地址。这样属性名、枚举值、状态字符串几乎不产生额外拷贝。
第二种是内嵌字符串。对象头中预留了一个小缓冲区,较短的字符串值直接存在里面,不额外分配内存。这对状态值、错误消息这类常见取值非常友好。
第三种是长引用字符串。真正的长字符串才使用堆分配,并在尾部保存 32 位长度和 32 位哈希值,便于快速比较。
字符串拼接是另一个重灾区。xuan 内部对两个字符串的拼接做了特殊判断:如果左侧字符串后续没有被其他变量引用,拼接操作可以原地扩展缓冲区,避免反复拷贝。这个优化让客户脚本里大量日志拼接操作的耗时下降了大概一半。
5. C 与 JS 的双向桥:让脚本能点灯、能定时、能上报
5.1 宿主函数注册与参数校验
从 JS 里调用 C 函数是运行时最关键的能力。xuan 提供了一套注册接口,让 C 代码可以注册任意数量的内建函数。每个宿主函数收到的参数是一个数组指针,不直接暴露内部值,而是通过一组取值 API 解析。
static xuan_value dev_gpio_write(xuan_vm *vm, xuan_args args) { xuan_int pin, level; if (xuan_arg_int(args, 0, &pin) != XUAN_OK || xuan_arg_int(args, 1, &level) != XUAN_OK) { xuan_throw(vm, "invalid gpio args"); return xuan_undefined(); } // 实际驱动调用,由平台层实现 platform_gpio_write((int)pin, (int)level); return xuan_undefined(); }注册则是一行代码:
xuan_register_native(vm, "dev.gpio.write", dev_gpio_write);这段最常见的工程细节是:注册的模块名和函数名字符串,会作为驻留字符串存在。这样以后每次 JS 调用dev.gpio.write都省去了字符串查找的损耗。
5.2 事件循环与异步任务调度
xuan 的async/await底层是一个轻量事件循环。当我执行到await delay(1000),会把当前函数的状态挂起并记录结束时间,然后立即返回到 C 调用者那里。C 层的主循环负责周期性地调用xuan_poll_events(),检查哪些定时器到期,到期后把等待中的任务推进执行队列。
这样设计有个实际好处:JS 脚本中再也没有“阻塞睡眠”的写法。所有耗时操作都是异步的,设备看门狗不会被一个瞎写的死循环拖死。为了让客户团队适应,我在 API 文档里加了一条基本原则:脚本只能通过事件等待时间,不能直接占用 CPU 忙等。
// 错误示例:会卡死事件循环 while (dev.temp.read(0) > 30) {} // 推荐用法:等待下一次检查 while (true) { if (dev.temp.read(0) > 30) { dev.relay.write(1, true); } await delay(500); }5.3 外部回调与重入保护:一个必须单独设计的边界
桥接层里最伤筋动骨的问题,是外部中断、串口接收、网络事件里触发 JS 回调时的重入。如果外部中断直接调用 VM 执行函数,VM 的状态机可能正执行到一半,数据结构和栈状态都会被破坏,轻则脚本行为异常,重则彻底死机。
我的解决方案是外部事件一律不直接执行 JS。中断或回调线程只负责往一个无锁环形队列里塞一条事件记录,真正执行动作的是主循环里xuan_poll_events()。这个延迟执行策略,把“事件发生”和“JS 执行”剥离开,代价是回调会有毫秒级延迟,但稳定性大幅提升。
6. 实测和优化:性能从每秒 200 条到 4 万条背后的三个动作
6.1 第一版基准:惨到不想给客户看
功能全部跑通后,我拿客户一段约 200 行的温控逻辑做基准。脚本内容是状态判断、定时器、少量数组遍历。刚开始的结果是每秒大约能执行 200 条 JS 语句,启动一次引擎加执行一个 100 行脚本,需要将近 500 毫秒。这个数字在客户那边肯定交不了差。
我用调试器逐步追踪热点,发现三个主要瓶颈:属性访问没有缓存,函数调用时参数按哈希命名匹配,还有操作码分发用了标准的 switch-case,分支预测效果很差。
6.2 单态内联缓存:属性访问提速的关键
第一个优化动作是给属性访问加上一层缓存。每次执行 LOAD_PROP 指令时,拿对象的 shape 去和一个 64 位缓存槽比较,相同就直接用缓存的槽位索引。如果不同,就回退到哈希查找,并重新填充缓存。
之所以叫单态缓存,是因为它只缓存一种 shape。实际脚本里同一段代码反复访问的几乎都是同一类对象,所以命中率能到百分之九十以上。这个改动让温控脚本的吞吐量翻了三倍。
6.3 参数直接按槽位访问,不再按名匹配
函数调用参数处理最初是创建一个局部对象,参数通过名字查表,这个设计非常慢。我改成编译期给每个函数参数分配固定的本地变量槽位,调用方按顺序压入,被调方直接按索引读取。
这个改动同时对闭包捕获有影响,因为闭包里的自由变量也需要按捕获索引定位。统一之后,函数的入口耗时从原来的微秒级降到纳秒级。
6.4 操作码分发换成大块跳转表
最后是 VM 主循环。我把 switch-case 改为跳转表,并在每个操作码结束后直接从表中取下一个处理地址。这本质上是用空间换时间:代码体积增加了不到 50KB,但操作码分发效率显著提升。
实际测试中,温控脚本的执行速度从每秒约 200 条提升到每秒 4 万条左右,启动到执行完成的时间从 500 毫秒降到 80 毫秒以内。内存峰值也稳定在 128KB 以内,留有约一半的动态堆冗余给异常场景。这个结果客户那边总算满意了。
7. 想自研 JS 运行时的朋友,我的建议是这些
7.1 设计语言子集前,先去统计业务脚本
第一件事一定是把真实业务脚本的语法和 API 使用频率统计出来,而不是对着 ECMAScript 规范一个一个比划。我当初把正则、Proxy、Generator 全套保留的冲动,被统计结果一票否决。没有统计,就会为了用不到的重量级特性浪费不可再生的体积和精力。
可以先跑一个小工具,把目标场景里的脚本解析成 AST,统计节点类型、方法调用、字符串长度、对象形状。这份数据会一针见血地告诉你真正要实现的特性清单。
7.2 内存模型要在写语法分析器之前定好
第二件事是把值表示、对象模型、栈帧布局、GC 策略放在词法分析之前设计。这些底层的决定一旦写进大量代码,后期修改的成本极高。我经历过 AST 解释器推翻重来,也经历过值表示修改导致全量测试通宵更新,这些痛都是因为前期架构设计不够果断。
如果你也想做类似事情,建议按照“值表示→对象模型→字节码指令→编译→执行循环→宿主绑定→GC 调优”的顺序推进。每一步都先用最小用例验证,别急着堆功能。
7.3 一定要把“错误信息可读性”当一等公民
嵌入式里调试条件差,printf 都要省着用,所以运行时报错信息必须尽量详细。xuan 的每一个异常都能给出脚本文件名、字节码偏移、以及当前函数的调用栈。当try/catch命中时,err.stack可以还原出大致的源码位置。
要做到这个,编译器在生成字节码时必须保留“指令地址→源码行号”的映射。这个表会占用一些空间,但实战中它省下的是调试人员几十个小时的排查时间。绝对不能为了省空间丢弃它。
……
项目做到现在已经有几个月了。说实话,自研 JS 运行时比我预想的要复杂很多,但也正因为是自用,可以对各种不合理的设计直接推翻重来。如果你也想在嵌入式设备上做脚本能力,我的经验是:先确定你能接受的舍弃范围,再沉下心来设计底层内存模型,最后用真实业务脚本持续磨优化。这条路不轻松,但走通之后,你会对 JavaScript 这门语言多出完全不同的理解。