☰
嵌入式设备上跑JavaScript:自研轻量运行时设计与实战
2026/10/18 1:23:17 网站建设 项目流程

嵌入式设备上跑 JavaScript,这念头我当初看着也觉得有点“胡来”。直到我亲手把业务脚本从 C 代码里抽出来、让运营人员直接改脚本上线,才彻底明白了这件事的价值。这个代号叫 xuan 的运行时,最终固件体积 6MB 出头,这里面包含了解释器、核心语法分析器、基础标准库和硬件外设绑定层,占用资源不算小,但对于一颗带 8MB Flash、32MB PSRAM、主频 400MHz 的小型 SoC 来说,完全喘得过来。做这件事之前我用过的方案基本就是“业务一变、全量烧录”,一个简单的上报策略调整,都要走一遍编译、烧录、测试流程,慢则半天,快则两小时,怎么想都别扭。所以我把注意力放在了“能不能在设备端给出一层可解释的脚本能力”上,JS 又正好是你我他都会一点的语言,xuan 就这么被逼出来了。

这篇文章想把整个设计过程和踩坑记录完整写一遍。你会看到我为什么没直接用别人写好的迷你引擎、语言子集是怎么划的、字节码怎么设计、内存和 GC 怎么取舍,以及实测数据、常见问题、排查技巧。如果你正好在做一个资源没那么紧张但又没有 Linux 的嵌入式平台,也想给设备加一层脚本扩展能力,这篇文章应该能让你少走不少弯路。

1. 为什么要在嵌入式设备上跑 JavaScript

1.1 业务脚本化的刚需

很多时候我们说的“嵌入式开发”,其实大部分时间不是在写特别底层的驱动,而是在写业务逻辑。比如一个采集器,什么时候采样、采到什么值触发上报、上报完数据怎么处理,这些逻辑在产品迭代中变动的频率非常高。如果全部用 C 写成固件,每改一次都是一次烧录流程,特别是设备已经铺到现场的情况下,升级一次固件还要考虑传输失败、断电变砖的可能性,工作量很大。

我在某个边缘控制器项目里遇到的就是这个问题。硬件已经锁死了,我在上面既要跑各类采集控制逻辑,又要给上层提供策略接口。后来跟团队商量,干脆把策略脚本化:C 只做驱动、内存管理、网络协议这些跑得快且跟硬件强相关的事情,业务脚本则用 JS 来写,运行时负责解析和执行。运维人员远程下发一段脚本,不用改固件,不用重启设备,业务就能更新。这体验比重新烧录强了不知道多少倍。

这个思路并不新鲜,很多带屏设备、路由器、智能音箱都在做类似的事情。但真正到了资源受限的 MCU 级别,能做好的不多。关键是两个问题没解决:一是运行时体积能不能塞进 Flash,二是内存能不能撑起一个解释器。xuan 的做法是把体积压到 6MB 以下,把运行时内存控制在 1.5MB 以内,总算在性能和可用性之间找到了一个相对舒服的点。

1.2 为什么不用现成的引擎

刚开始我也想过直接移植别人写的开源迷你引擎。市面上的确有很多内存占用比较小的 JS 引擎,有的编译后缩到一两百 KB,甚至更小,语法支持也不错。但真到具体项目里试用,问题就出来了。

第一是裁剪成本。那些引擎虽然小,但他们的代码结构、内存模型、平台抽象层都是围绕“通用”设计的,你要嵌入到一个特定硬件平台,得自己适配串口、SPI、I2C、GPIO、网络栈,还要把引擎内部的平台相关代码抠出来重写。这个过程一点都不比从零开始写一个解释器省事,甚至更费劲。

第二是依赖关系。有的引擎虽然顶层 API 看着简洁,底下却依赖了不少标准 C 库特性,还有些用了比较重的编译器和 linker 特性。在裸机环境下,这些隐藏依赖一个个冒出来,最痛苦的是你根本不知道它哪个模块偷偷 malloc 了一大块堆。

第三是控制力。我需要把外设访问直接暴露成 JS 原生对象,需要在脚本执行到某个特定地方时能够精确地暂停、超时、监控内存水位,还需要在硬件中断上下文里安全地唤醒 JS 任务。这些能力在现成引擎里不是说没有,但做起来总觉得像隔着一层纱,不如自己写的那么顺手。

所以最终决定是自己写一个。xuan 并不追求把所有 ECMAScript 规范都实现,而是把设备项目里真正用到的语法和 API 实现好,同时留出足够的扩展能力。这个决定的直接收益是:我可以完全控制内存布局,可以把每个对象的布局都在脑海里画出来,出了问题基本看一眼代码就能定位。

1.3 6MB 这个数字意味着什么

先说体积。6MB 放在 PC 上就是一个小文件,但在嵌入式世界里,很多低端 MCU 的 Flash 总共才 128KB 到 512KB,连这 6MB 的零头都装不下。所以 xuan 对应的平台是那些带外部 Flash、带 MMU、主频在 300MHz 以上的小型 SoC,比如某些 WiFi/BLE 双模芯片、边缘网关主控等。这类芯片通常有 8MB 到 16MB 的 Flash 空间,6MB 的运行时加基础库,加上系统固件、协议栈和资源文件,勉强能压进 8MB 的 bin 分区里。

这 6MB 是怎么构成的?实际编译器产物大概分三块:引擎核心代码约 4.2MB,包括词法分析、语法解析、字节码编译器、虚拟机执行循环、垃圾回收、标准库;外设绑定层约 1.1MB,包含各种硬件模块注册和设备操作的桥接代码;剩下 0.7MB 是各种初始化数据、启动检查、调试冲印模块。这里面引擎核心的占比最大,因为几乎每条 JS 功能的背后都对应着几十到几百行 C 代码。

说实话,6MB 不是一个小体积。如果你追求极致的省 Flash,肯定不该选这个思路。但 xuan 的目标是在保证语言表达能力和开发效率的前提下做体积优化,而不是把自己变成一条“能用就行的极简命令解释器”。从实际效果看,业务脚本的复杂度和开发效率都明显提升了,这 6MB 花得值。

2. xuan 的整体设计与取舍

2.1 语言子集的划定

做自研运行时第一件大事就是决定支持哪些语法。ECMAScript 规范几百页,要全部实现的话这不是 6MB 规模的项目,而是几十万行起步的大工程。况且嵌入式场景里跑的脚本根本不需要浏览器那一整套东西,也不需要完整的模块系统、迭代器等高级特性。

xuan 选择的是“ES5.1 为底座,向上摘取一部分 ES6 甜点”的路线。基础语法支持 var、function、对象字面量、数组字面量、for/while/do-while、switch、try-catch、逻辑表达式、三目运算符、位运算等,这些是业务脚本里最常用的部分。ES6 方面我们支持了 let/const、箭头函数、模板字符串、解构赋值的基础场景,还做了 Promise 的精简实现,方便写异步回调链。

没有实现的东西也值得列一下:不打算支持完整的 class 继承体系(用函数式混入代替)、不打算支持生成器 Generator、不打算支持 Proxy/Reflect、不打算支持正则表达式的完整引擎,只支持一种简单的字符串匹配模式。这些决定在立项阶段就要跟业务团队对齐,否则他们写脚本时用到某个不存在的语法,报错信息又不够直观,就会很痛苦。

实际开发中,我专门写了一个“语法能力检测工具”,在 PC 上先对业务代码做一遍预检查,把不支持的语法点直接标记出来。这比在设备端运行时报错然后反查方便得多。另外,xuan 还支持一个“严格模式标记”,如果脚本开头写了 “use xuan-embedded”,运行时会直接拒绝某些高开销语法,例如动态 eval 和超长字符串拼接。

2.2 字节码与虚拟机设计

JavaScript 代码不能直接在设备上异步解释执行每条语句,那样速度太慢,语法分析也会反复做。所以 xuan 的架构是先把 JS 源码编译成字节码,再交给虚拟机执行。这个编译步骤可以在设备端做,也可以在 PC 上预编译成字节码文件再下发,后者的启动速度要快很多。

字节码设计上我选了基于栈的虚拟机。为什么不用寄存器式虚拟机?寄存器式虚拟机指令数更少、执行更快,但指令格式复杂,编译器生成代码的难度也更高。栈式虚拟机的指令实现简单、代码清晰,在嵌入式设备上,栈多几个内存访问并没有那么致命,换来的是极高的可维护性。xuan 的每一条指令都是 16 位定长,操作数直接编码在指令中,比如 LOAD_CONST 指令用一个字节表示常量索引,另一个字节表示操作数类型。

整个指令集一共 128 条左右,分成了几类:

  • 常量与变量操作:LOAD_CONST、LOAD_VAR、STORE_VAR、LOAD_FIELD、STORE_FIELD
  • 运算指令:ADD_INT、SUB_INT、MUL_INT、DIV_INT、COMPARE、JUMP_IF_FALSE 等
  • 对象与数组:NEW_OBJ、NEW_ARR、ARR_GET、ARR_SET
  • 函数与调用:CALL_FUNC、RETURN、MAKE_CLOSURE
  • 控制流:JUMP、JUMP_IF_TRUE、JUMP_IF_FALSE、LOOP_BACK

这些指令不是拿来当摆设的。以 LOAD_FIELD 为例,它在读对象属性时实际上先查一下属性是否存在,不存在直接抛异常,避免在解释器里反复处理 undefined。指令集定好后,编译器的输出就很规整了,后面做字节码优化也简单很多。

2.3 内存模型与 GC 策略

嵌入式环境最难受的就是内存碎片和堆空间上限。xuan 没有直接依赖 C 库的 malloc/free 做所有分配,而是自己封装了一个内存池,分块管理。对象节点、字符串数据、数组缓冲区、函数闭包上下文各自有独立的池子,这样不同类型对象的分配不会互相污染,也方便做性能统计。

GC 用的是最经典的标记-清除算法,没有做分代,没有做移动对象。为什么不做得复杂一点?因为嵌入式平台上的对象引用关系已经足够简单,分代带来的收益不明显,而移动对象需要更新所有指针,对于手写解释器来说风险很大。标记-清除的代价是有时会暂停几十毫秒,这在业务脚本可接受范围内。不过我在实现时做了一点改进:标记阶段采用显式栈,不用递归,避免在对象图很深的时候把调用栈打爆。

分配策略上还有一个重要原则:能复用就复用。字符串拼接会频繁产生新字符串,xuan 内部做了短字符串驻留表,重复出现的小字符串直接返回同一个对象。数组如果缩到很小,也不会立即缩容,避免频繁 resize 导致的内存抖动。这些细节加起来,内存碎片率比直接用 malloc 低了不少。

由于只能使用固定大小的堆,xuan 在启动时就会确定好 GC 堆大小,比如 1MB。如果脚本内存占用超过阈值,GC 会先启动,多次 GC 后仍然不够就抛出“内存不足”异常。这个设计的副作用是:如果某段循环代码反复分配大数组,会频繁触发 GC,但至少不会出现进程崩溃后现场完全无法调试的情况。

2.4 标准库裁剪与外设绑定

标准库是所有运行时里最难取舍的部分。嵌入式脚本不需要文件系统、网络 HTTP 全套、定时器也跟桌面环境不一样。xuan 最初只做了四组 API:console 系列、定时器、事件注册表和硬件访问抽象层。后来随着需求增加又补了 JSON 解析和简单字符串处理。

console 是调试的基础,但设备端没有屏幕也没有终端,我把 console.log 重定向成串口输出或者日志缓冲区。这样脚本里打日志,最终会通过硬件抽象层进入到上位机诊断工具。定时器在嵌入式 RTOS 环境下要跟系统 tick 对齐,xuan 的 setInterval、setTimeout 内部会注册到系统的时间轮上,回调时再进入 JS 执行环境。

硬件访问这块特别讲究。xuan 暴露给 JS 的不是一个个 GPIO 函数,而是一组“设备对象”,比如 board.led、board.sensor.temperature、board.uart0。内部实现时每个设备对象背后都关联了一个 C 结构体,操作函数直接绑定底层驱动。这样脚本里写 board.led.on(),效果等价于调用一个底层 C 函数来拉高引脚电平,几乎没有额外的中间开销。

外设对象的注册方式采用“原生模块注册表”,JS 代码里 importNative('gpio') 会拿到一个对象,对象上的每个属性都对应 C 侧的一个函数指针。这样做有几个好处:第一,不是所有原生函数都暴露给脚本,安全可控;第二,每个原生函数的参数检查在 C 侧完成,避免 JS 层误传类型导致硬件操作异常;第三,脚本层和 C 层的调用来回成本很低,因为参数直接通过 VM 栈传递。

3. 从零搭建运行时的核心实现

3.1 词法分析与语法解析

整个 xuan 的编译前端是我手写的,没有用工具生成。词法分析器是一个标准的状态机,负责把源码拆成 token。token 类型包括关键字、标识符、数字、字符串、运算符和分隔符。这部分看着简单,但坑很多。最典型的就是数字解析,JS 里允许十六进制、八进制、二进制的前缀,还有浮点数科学计数法,状态机会很复杂。我最后参考 ECMAScript 规范里数字字面量的产生式一点点改,才把测试用例全部跑过。

字符串那块也很容易出问题。xuan 内部统一用 UTF-8 保存字符串,所以词法分析时碰到转义字符 \uXXXX 就得正确处理。这里容易踩的坑是:中文字符和 emoji 在源码里是多字节的,如果你按 char 循环处理,就会把字符截断。我的做法是词法阶段就把源字符串按 UTF-8 解码后的码点边界对齐,后面所有字符串操作都按字符索引走。

语法解析采用的是最经典的递归下降解析器。JS 的表达式优先级有十几层,递归下降写出来很长,但每一层函数独立,调试起来真的舒服。我在解析表达式时用了 Pratt Parsing 中对二元运算符优先级处理的方式,用一个优先级表搞定加法、乘法、比较、逻辑运算等,避免了深层的 if/else 嵌套。

解析器产出的是一棵 AST。AST 节点在设计时尽量轻量,每个节点用 16 字节对齐,节点类型、源码位置和子节点索引打包在结构里。因为嵌入式设备内存有限,AST 不适合长期存在,所以编译成字节码后立刻释放。这里有个值得注意的点:xuan 并没有把 AST 序列化到 flash 做缓存,因为设备端源码更新频率不高,每次启动重新解析也就多花几毫秒,没必要为这个增加复杂度。

3.2 编译器与字节码生成过程

从 AST 到字节码的转换是编译器的核心。我采用的是单趟扫描,边遍历 AST 边生成字节码。每个函数都会生成一个独立的字节码块,函数入口先做参数检查,然后执行函数体里的语句序列。

变量作用域处理是比较麻烦的一块。xuan 支持函数闭包,也就是说内层函数能访问外层函数的局部变量。这里我的方案是:编译每个函数时维护一张“变量槽位表”,外层变量如果被内层捕获,就分配到一个“闭包上下文”对象里,普通变量则直接放在 VM 的栈帧上。从 VM 视角看,访问一个局部变量可能是一次栈访问,也可能是一次闭包上下文访问,取决于编译器标记。

字节码生成时还有一层优化:常量合并。比如表达式 1 + 2,编译器会直接算成 3,不生成两条指令。再比如字符串模板abc${x}def,在语法解析阶段就拆成字符串拼接调用。这些优化看着小儿科,但在嵌入式脚本里很常见,累积下来能省不少执行时间。

生成了字节码之后,会做一次简单的寄存器分配器式调整吗?不会,栈式虚拟机不需要太复杂的分配。但我会做一次跳转地址回填,把所有 JUMP 指令的目标地址从符号位置改成真实偏移。这一步做完,字节码就能直接交给虚拟机跑了。

下面是 xuan 里一个极简函数的编译结果示意,仅供参考:

function add(a, b) { return a + b; }
[0] LOAD_CONST 0 // 常量 a [1] LOAD_VAR 0 // 压入参数 a [2] LOAD_VAR 1 // 压入参数 b [3] ADD_INT [4] RETURN

3.3 解释器的执行循环

VM 主循环是我花了最多时间打磨的地方。xuan 使用经典的大 switch-case 指令分派,没有做间接跳转表,虽然间接跳转理论上更快,但在某些编译器的优化下表现不一定稳定,我宁愿选一个所有平台都表现一致的写法。

每条指令执行完成后更新程序计数器 PC,然后进入下一轮循环。这个循环最关键的优化点是减少边界检查。比如访问当前栈帧变量时,会直接按索引从栈顶数组读取,而不是逐个校验索引范围。这个操作是建立在编译器已经保证变量索引合法的前提上的,VM 只需要信任字节码。

函数调用在 VM 里是最昂贵的操作。每次 CALL_FUNC 需要切换栈帧、准备参数区、可能创建闭包上下文。我在设计时特意把“参数个数不匹配”的检查提前到字节码校验阶段,而不是每次调用时做完整检查,这样正常路径少了几个分支。

异常处理算是解释器里另一个大难点。JS 的 try-catch 需要 VM 能在执行中任意位置把控制流跳转到 catch 块。我的做法是在编译时生成异常处理器表,每条可能抛异常的指令如果位于 try 块内,就在表里登记。异常抛出的瞬间,VM 会沿当前调用栈向上查找最近的异常处理器,找到后重设 PC 并压入异常对象。这个机制实现后,业务脚本里的 try-catch 终于像那么回事了。

3.4 原生函数注册与调用

没有原生函数绑定,xuan 就只是一个玩具。我在 C 侧定义了统一的桥接接口,原生函数长这样:

static xu_value_t native_gpio_write(xu_vm_t *vm, xu_value_t args[], int argc);

每个原生函数接收一个 VM 句柄、参数数组和参数个数,返回一个 JS 值。参数数组里的 xu_value_t 是一个 union 类型,可以是 int、double、对象指针、字符串指针、布尔值等。JS 层调用原生函数时,VM 会自动把 JS 值转换成 xu_value_t,C 函数内部再按类型取用。

注册的方式很简单,把 C 函数指针绑定到某个 JS 对象属性上:

xu_module_register(vm, "gpio", "write", native_gpio_write);

这个调用执行后,JS 里的 gpio.write 就会被解释器识别成原生调用,走一条快路径直接执行 C 函数,而不是再通过字节码解释一层。整个调用开销大概只有普通 JS 函数调用的 1.5 倍,这个数字对嵌入式脚本来说非常能接受。

这里容易疏忽的是参数生命周期。原生函数收到的字符串和对象,必须保证执行期间不被 GC 回收。xuan 的处理方式是:在调用原生函数前,把传入参数对象全部标记为临时根引用,函数返回后再清除标记。这样即使原生函数内部执行了任何触发 GC 的操作,对象也不会突然从底下消失。

3.5 构建与体积优化

xuan 整个项目用 C 语言编写,构建系统用的是内部统一的多平台构建脚本。交叉编译到目标板时,几个关键的编译选项直接影响最终体积。

第一个是优化等级。我用了偏向尺寸优化的 O2 级别,同时打开函数节(function-sections)和数据节(data-sections),配合链接器的垃圾回收,可以把未引用的代码段全部剥掉。这个操作效果非常明显,光是把所有调试符号去掉再加上节裁剪,体积就能减少三分之一。

第二个是禁用了 C++ 异常和标准模板库。你可能会问,C 项目怎么禁用这些?因为有的时候会不小心链接进来一些 C++ 运行库,特别是从第三方芯片 SDK 里带过来的。我特意检查了最终的链接 map 文件,把那些因为弱符号被拉进来的 C++ 依赖都清掉了。

第三个是静态库的提炼。xuan 把不常用的标准库函数做成了可裁剪模块,比如正则匹配、浮点数格式化输出、复杂 JSON 解析等。不需要的话,构建脚本里加一个宏定义就能从编译中剔除。最终交付时我做了两组配置:开发版完整镜像 6MB,发布版裁剪后只有 4.7MB。

4. 实际跑起来的性能与稳定性

4.1 启动流程与内存占用实测

xuan 的设备端启动流程大致分四步:初始化内存池、注册原生模块、加载主脚本源码、执行字节码。

启动第一步不是加载 JS,而是先把内存池的 Header 和块管理结构初始化好。这块代码在板子上运行非常快,大概 200 微秒。然后是原生模块注册,所有绑定的 C 函数都会挂到一个全局注册表上,耗时约 1.5 毫秒。真正耗时的是源码加载和解析。如果源码是一份 200KB 的文本,词法和语法解析大概要花 20 到 30 毫秒,字节码生成再加 10 毫秒左右。整个启动到主脚本第一行执行,实测在 40 毫秒到 50 毫秒之间,比我预期要好。

内存占用方面,如果给 xuan 分配了 1.5MB 的堆空间,冷启动后 GC 堆几乎没怎么用,系统堆会吃几百 KB。等业务脚本运行一段时间、创建了一些对象后,内存水位通常在 40% 到 60% 之间。峰值内存出现在大量字符串拼接或者数组增长时,最多可以摸到 80%,但因为有 GC,一般不会直接打满。

这块要特别说明:我不建议给 xuan 分配超过 2MB 的堆。因为标记-清除算法下,堆越大,GC 暂停时间越长。实测 2MB 堆做一次全量 GC 大约 40 毫秒,1MB 堆只需要 15 毫秒左右。对业务来说,15 毫秒的停顿已经勉强可接受,40 毫秒就有点卡了。

4.2 简单基准测试数据

我不打算做完整的 benchmark,只测了三个跟业务直接相关的场景,你也可以把这当成一个参考。

第一个是空函数调用一百万次,xuan 大约需要 220ms,也就是说一次简单 JS 函数调用大概是 220ns 级别。第二个是循环里做整数累加,从 1 加到 100 万,耗时约 80ms。第三个是创建一万个对象并给属性赋值,执行时间 350ms,期间触发了两次 GC。

作为参考,我用同样的逻辑在 PC 上的完整 JS 引擎跑,速度可能快 10 到 20 倍,但 xuan 的性能对于设备脚本来说足够用了。真正的瓶颈不在 JS 执行速度,而在于外设读取、网络等待这类异步操作。业务逻辑里 99% 的时间都在等待硬件,JS 解释器怎么都比硬件快。

有一个数字需要警惕:浮点运算。开发板若不带硬件 FPU,xuan 的浮点运算直接走 C 库软浮点,性能会掉到整数运算的二十分之一。如果你写脚本大量使用浮点,就必须在脚本层用整数刻度来规避。通常我会建议业务方,能转成 mM、mV 这种整数单位的,绝不用浮点。实在要浮点时,就用 xuan 提供的 Decimal 模式,内部按定点数计算。

4.3 防死循环和异常处理机制

脚本语言跑在设备上,最怕的就是死循环。一旦脚本进入 while(1),设备侧控制逻辑就全部停摆,这是绝对不能接受的。xuan 提供了两套防护。

第一套是执行指令预算。每执行 10 万条指令,VM 就会检查一次当前是否超时。如果超时,会抛出一个特殊异常“脚本执行超时”,默认情况下脚本会在该处中断。这个预算可以通过 API 动态调整,比如某些长时间计算的任务可以放大预算,一般业务逻辑保持在 100 万条以内。

第二套是看门狗协作。xuan 在每次 VM 回到事件循环入口时,会喂一次系统看门狗。如果脚本长时间不结束导致看门狗被饿死,整个设备会重启。重启虽然暴力,但总比设备完全变成砖头好。后来我在日志里看到过两次看门狗触发,查下来都是业务方在脚本里写了 expect = 0 的条件死循环,这种低级错误有了防护才能兜底。

异常处理上,xuan 允许脚本用 try-catch 捕获运行时错误,比如类型错误、属性不存在、内存不足。未捕获的异常会进入全局错误钩子,钩子默认把错误信息写到日志缓冲区,同时通过串口上报。实际排查问题时,这个错误钩子比什么都管用,因为你能直接在设备端拿到出错的文件和行号。

5. 常见问题与排查实录

5.1 内存碎片与堆耗尽

xuan 用内存池虽然减少了很多碎片,但业务脚本如果频繁创建不同大小的数组、字符串,仍然可能出现碎片化。典型症状是:GC 明明回收了一堆对象,但马上分配一个大数组时,仍然报内存不足。

排查方法很简单,我加了一个内存状态查询函数,能打印出每个内存池的已用块数、空闲块数和最大连续空闲块。如果最大连续空闲块远小于总空闲空间,基本就说明碎片很严重了。应对手段有两个:一是调整池的分配粒度,让大对象固定走单独的分配器;二是清理脚本里反复创建后丢弃的大字符串。

说实话,这些问题的根源大多是脚本写得比较随意,比如在循环体内做字符串拼接,每轮循环都生成新字符串。我会在代码评审时重点提醒团队养成“能复用的对象不复用,能预分配的不动态增长”的习惯。

5.2 GC 标记阶段栈溢出

早期版本我用递归做标记,对象图一旦很深,C 调用栈就爆了。当时遇到一个问题,脚本创建一个很深的链表,然后这个链表变成垃圾,触发 GC 时,系统直接 hardfault,查了半天才在日志里发现调用栈指针异常。

后来我把标记阶段改成了显式栈迭代,用一个动态增长的数组保存待扫描对象。这个修复让 GC 可以处理非常深的对象图,递归深度受限问题彻底消失。这个经验也适用于所有嵌入式场景:凡是可能深度遍历的地方,尽量不要用 C 递归,除非你能明确控制最大深度。

5.3 回调上下文丢失与闭包语义

业务脚本里经常出现 setTimeout 回调,如果回调里依赖 this 或者外部变量,很容易出现“上下文丢失”。我遇到过最典型的问题是:脚本里从设备对象取了某个临时值,然后放进定时器回调里,等回调真正执行时,临时值已经变成了 undefined。

这个问题的本质是闭包上下文生命周期没管理好。xuan 的闭包上下文是在创建函数对象时生成的,如果只是把匿名函数作为回调参数传进去,按正常 JS 语义其作用域链应该是保留的。但我的第一版实现里,setTimeout 内部没有把传入的闭包对象标记为根引用,导致该回调函数和它的上下文在定时器等待期间被 GC 回收了。修复方式很简单:所有已注册的定时器回调都加入全局根集合,回调执行完或者定时器取消时再移除。

从这里我也学到一个教训:嵌入式 JS 引擎的 GC 根集合不仅包含调用栈和全局对象,还必须包含所有“未来可能唤醒”的挂起任务。凡是跟事件循环挂钩的对象,都需要单独管理生命周期,否则很容易踩到 GC 的坑。

5.4 浮点性能的坑

xuan 在没有 FPU 的平台上跑浮点计算,速度慢到让人怀疑人生。我做过一个测距脚本,需要通过三角函数和浮点运算来算一个角度值,完整执行一次要花 180ms,而同样是这段逻辑,用整数查表法加定点数乘法,只需要 12ms。这个差距让所有依赖浮点的算法都变得不现实。

我的建议很简单:设备端脚本里尽量避免浮点运算。如果一定要用,先检查平台是否支持硬件浮点,不支持就把所有浮点系数转换为整数倍率,或者用 xuan 提供的定点数对象。定点数的加减乘除是整数运算,只有乘法可能涉及到溢出,成本比软浮点低几个数量级。

如果实在绕不开浮点,另一个方案是将这部分算法下沉到 C 原生函数里,JS 只负责传参和读取结果。这样脚本层的表达力没有降低,实际执行却跑在 C 函数里,又快又稳。这也是我设计原生模块注册机制的初衷:性能敏感的代码永远不应该用解释执行来扛。

5.5 字符串编码与中文脚本

xuan 的字符串统一是 UTF-8,这本来没什么问题,但业务脚本有时候会从串口或文件读入一段二进制数据,里面夹着非 UTF-8 字节,然后又把它当字符串做处理,结果就是各种乱码和解析错乱。

后来我做了严格检测:字符串创建时如果发现非法 UTF-8 序列,直接抛异常,不会静默生成一个脏字符串。同时在 console.log 输出时,会按字节打印,保证调试信息不丢内容。开发过程中踩到的另一个点是 JS 的字符串长度,ECMAScript 规范里 length 代表 UTF-16 码元数,而 xuan 为了节省内存用的是 UTF-8 长度,这就导致同样一个中文字符串,在标准 JS 里和 xuan 里 length 值不一样。为了兼容,我实现字符串 length 时先进行编码转换计算,保持 length 语义跟标准一致,虽然每次获取 length 会多一点开销,但至少脚本不会因为中文就出错。

这个问题也提醒我,做一个“看起来兼容”的运行时很容易,但要真正让脚本在设备端和 PC 端行为一致,必须把字符编码这类底层细节全部对齐,否则调试时就只能靠逐个字节抠。

6. 一些实际操作中的体会

xuan 从立项到跑稳,整个过程大概花了一个季度,中间大部分时间都不是在写功能,而是在处理上面这些小坑。回顾下来,我最深的体会是:做嵌入式运行时,不是代码越多越牛,而是你要清楚自己到底解决什么问题。如果你的业务脚本只是几十行逻辑,外设操作也极简单,那真的没必要自己写一个运行时,用一个现成的极简解释器足够了。但如果你的业务场景复杂度上来了,需要对象、回调、闭包、异常处理,而且你还想对内存、体积、异常行为有细颗粒度的掌控,那自己做一个反而是省心的选择。

后来我把 xuan 的裁剪配置做成了几个模板,开发版、发布版、最小版,分别对应不同的 Flash 预算。团队里的嵌入式工程师不再为改脚本而头疼,业务方也能更快地验证新策略。一个很有意思的结果是,以前需要编译烧录两三天的需求,现在改成远程发脚本后,平均半天就能上线,设备的故障率也没有明显上升。

如果你也想在类似项目上试试,我建议你一定要在一开始就写一个“内存和异常可视化工具”,把设备端的内存占用、GC 用时、未捕获异常都打印出来。这会在后面调优时省下无数时间。还有就是要给脚本加执行预算和看门狗协作,没有这套保命机制,业务脚本一旦跑飞,再好的架构也白搭。最后再掏个底,跑这种运行时,稳定性和可观察性的优先级永远要排在掌握新语言特性的前面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询