MicroQuickJS:嵌入式系统的轻量级JavaScript引擎

摘要

MicroQuickJS(简称MQuickJS)是一款专为嵌入式系统设计的JavaScript引擎,仅需10kB RAM即可运行,ROM占用约100kB(ARM Thumb-2代码),速度媲美QuickJS,支持ES5子集及严格模式,本文详解其特性、使用与技术细节。

一、初识MicroQuickJS:嵌入式场景的JavaScript解决方案

你是否在寻找一款能在资源受限的嵌入式设备上运行的JavaScript引擎?MicroQuickJS(简称MQuickJS)或许正是你需要的工具。它专为嵌入式系统打造,最大的特点就是极致的资源友好性——运行JavaScript程序仅需10kB的RAM,整个引擎包括C库在内也只需要约100kB的ROM(基于ARM Thumb-2代码),而运行速度却能与QuickJS相媲美。

和我们熟悉的JavaScript引擎不同,MQuickJS只支持JavaScript的一个子集(接近ES5),并且采用了“更严格的模式”。在这种模式下,一些容易出错或低效的JavaScript语法结构被禁止,这不仅让代码更安全,也更符合嵌入式系统对稳定性和效率的要求。

虽然MQuickJS与QuickJS共享不少代码,但为了降低内存消耗,它的内部机制做了很多调整。比如,它采用追踪垃圾回收器,虚拟机不使用CPU栈,字符串以UTF-8格式存储——这些设计都是为了在有限的嵌入式资源中发挥最大效能。

二、上手MQuickJS:REPL工具mqjs的使用指南

想要体验MQuickJS,最直接的方式就是使用它的REPL工具——mqjs。无论是运行脚本、执行表达式,还是生成字节码,mqjs都能轻松应对。

1. mqjs的基本用法

mqjs的使用方法很简单,在命令行输入mqjs并加上相应参数即可。它的主要选项如下:

  • -h--help:列出所有选项
  • -e--eval EXPR:执行表达式EXPR
  • -i--interactive:进入交互模式
  • -I--include file:包含额外的文件
  • -d--dump:输出内存使用统计信息
  • --memory-limit n:限制内存使用为n字节(支持k、m等单位)
  • --no-column:调试信息中不显示列号(只保留行号)
  • -o FILE:将字节码保存到FILE
  • -m32:强制生成32位字节码(与-o配合使用)

2. 运行JavaScript程序

如果你想运行一个JavaScript文件,同时限制内存使用(比如10kB),可以这样操作:

./mqjs --memory-limit 10k tests/mandelbrot.js

这个命令会用10kB的内存运行tests目录下的mandelbrot.js脚本,非常适合测试程序在资源受限环境下的表现。

3. 生成和运行字节码

MQuickJS的一大优势是可以将编译后的字节码保存到持久化存储(如文件或ROM)中,方便在嵌入式设备上复用。

生成字节码的命令如下:

./mqjs -o mandelbrot.bin tests/mandelbrot.js

这条命令会把mandelbrot.js编译后的字节码保存到mandelbrot.bin文件中。之后,你可以像运行普通脚本一样运行这个字节码文件:

./mqjs mandelbrot.bin

需要注意的是,字节码的格式依赖于CPU的字节序和字长(32位或64位)。如果你的电脑是64位CPU,想要生成能在32位嵌入式系统上运行的字节码,可以加上-m32选项:

./mqjs -o mandelbrot_32.bin -m32 tests/mandelbrot.js

另外,如果你想节省存储空间,可以用--no-column选项去掉调试信息中的列号(只保留行号),进一步减小字节码文件的大小。

三、MQuickJS的“严格模式”:更安全、更高效的代码约束

MQuickJS始终运行在“更严格的模式”下,这和我们在标准JavaScript中使用"use strict"有点类似,但限制更严格。这种模式的设计理念是:严格模式是JavaScript的一个子集,所以在MQuickJS中能运行的代码,在其他JavaScript引擎中也能正常工作,但反过来却不一定。

具体来说,严格模式有哪些约束呢?

1. 禁止非声明的全局变量和with语句

在严格模式下,所有全局变量必须用var声明,不能直接赋值给未声明的变量(否则会报错)。同时,with关键字被完全禁止,因为它会让代码的作用域变得模糊,容易出错,也会影响执行效率。

2. 数组不能有“空洞”

这是MQuickJS中非常有特点的一条约束。在标准JavaScript中,我们可能会写出这样的代码:

a = [];
a[0] = 1;
a[10] = 2; // 数组中间出现空洞

但在MQuickJS中,这种“跳过索引赋值”的操作会抛出TypeError。因为数组不允许有空洞,只有在数组末尾扩展时赋值才是允许的(比如a[0] = 1会把数组长度扩展到1,这是合法的)。

如果你的场景确实需要类似“带空洞的数组”,可以用普通对象来代替:

a = {};
a[0] = 1;
a[10] = 2; // 合法,对象可以有不连续的属性

另外,new Array(len)仍然可以正常使用,只是数组元素会被初始化为undefined;而带有空洞的数组字面量(如[1, , 3])会直接报语法错误。

3. 仅支持全局eval,不支持直接eval

在JavaScript中,eval函数的行为很特殊:直接调用eval(如eval('1+2'))可以访问和修改局部变量,而间接调用(如(1, eval)('1+2'))则只能在全局作用域中执行。

MQuickJS为了避免eval带来的复杂性和安全问题,只支持间接的全局eval。所以,直接写eval('1+2')会被禁止,必须用间接调用的方式。

4. 不支持值装箱

在标准JavaScript中,我们可以用new Number(1)new String('hello')等方式创建“装箱”的原始值对象,但MQuickJS不支持这种用法。其实在大多数场景下,我们也不需要这么做——直接使用原始值(如1'hello')就足够了。

四、JavaScript子集参考:MQuickJS支持哪些特性?

MQuickJS支持的JavaScript特性以ES5为基础,并做了一些调整和扩展。如果你想在MQuickJS中编写代码,需要了解它具体支持哪些功能,又有哪些限制。

1. 数组(Array)对象

  • 无空洞:如前所述,数组不能有未赋值的索引位置。
  • 数字属性由数组直接处理:不会转发到原型链上,避免了原型污染的风险。
  • 越界赋值报错:只有在数组末尾扩展时的赋值是允许的(如数组长度为2时,赋值给索引2是合法的,会把长度变为3)。
  • length是 getter/setter:数组的length属性由原型链上的 getter 和 setter 控制,修改length会相应地调整数组元素。

2. 对象属性

所有对象的属性都是可写、可枚举、可配置的。这意味着你可以自由地修改、删除对象的属性,也能通过for...in循环遍历它们。

3. for…in循环

for...in循环只遍历对象自身的属性(不包括原型链上的)。但为了和标准JavaScript保持一致,建议使用以下模式:

for(var prop in obj) {
    if (obj.hasOwnProperty(prop)) {
        // 处理prop
    }
}

不过更推荐使用for...of循环结合Object.keys(obj),这种方式更清晰:

for(var prop of Object.keys(obj)) {
    // 处理prop
}

4. 函数对象

函数对象的prototypelengthname属性都是 getter/setter。另外,C函数不能有自己的属性,但C构造函数的行为是正常的。

5. 全局对象

全局对象是存在的,但不建议过度使用。它不能包含 getter/setter,而且直接在全局对象上创建的属性,不会作为全局变量在脚本中可见。

6. try…catch中的变量

catch关键字后面的变量是一个普通变量,和其他变量的作用域规则一致。

7. 正则表达式(Regexp)

  • 大小写折叠仅支持ASCII字符:非ASCII字符的大小写转换可能不符合预期。
  • 匹配基于Unicode码点:/./会匹配一个Unicode码点,而不是像带u标志的标准正则那样匹配UTF-16字符。

8. 字符串(String)

toLowerCase()toUpperCase()方法仅处理ASCII字符,非ASCII字符的转换可能不会生效。

9. 日期(Date)

目前只支持Date.now()方法,用于获取当前时间戳。

10. ES5扩展特性

MQuickJS还支持一些ES5之外的扩展,让开发更方便:

  • for...of循环:但目前只能用于数组,不支持自定义迭代器。
  • 类型化数组(Typed arrays):方便处理二进制数据。
  • 字符串中的\u{hex}语法:支持用Unicode码点表示字符。
  • 数学函数:包括imulclz32froundtrunclog2log10等。
  • 指数运算符(**):如2**3表示2的3次方。
  • 正则表达式标志:支持s(dotall)、y(sticky)、u(unicode),但unicode模式下不支持Unicode属性。
  • 字符串方法:codePointAtreplaceAlltrimStarttrimEnd
  • globalThis全局属性:统一访问全局对象。

五、C API:在嵌入式系统中集成MQuickJS

如果你想在C语言项目中集成MQuickJS,它的C API设计非常轻量,几乎不依赖C库(不使用malloc()free()printf()等函数),非常适合嵌入式场景。

1. 引擎初始化

初始化MQuickJS时,需要提供一块内存缓冲区——引擎只会在这块缓冲区中分配内存,不会占用系统其他内存。示例代码如下:

JSContext *ctx;
uint8_t mem_buf[8192]; // 8192字节的内存缓冲区
ctx = JS_NewContext(mem_buf, sizeof(mem_buf), &js_stdlib);
// 使用引擎...
JS_FreeContext(ctx); // 释放上下文(主要是调用用户对象的析构函数)

JS_FreeContext并不是必须的,因为引擎没有分配系统内存,它的主要作用是调用用户对象的析构函数。

2. 内存处理:注意垃圾回收的特殊性

MQuickJS使用的是压缩型垃圾回收器,这和很多其他引擎的引用计数机制不同,所以在使用C API时需要注意以下几点:

  • 不需要显式释放值:没有JS_FreeValue()这样的函数,垃圾回收器会自动处理。
  • 对象地址可能变动:每次JS分配内存时,对象的地址都可能改变。所以,C代码中应尽量避免直接使用JSValue类型的变量,除非是API调用之间的临时变量。
  • 使用JSGCRef管理引用:对于需要长期保存的JSValue,应该用JS_PushGCRef()获取一个JSGCRef指针,它会在对象移动时自动更新。使用完毕后,必须用JS_PopGCRef()释放。

示例代码:

JSValue my_js_func(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv)
{
    JSGCRef obj1_ref, obj2_ref;
    JSValue *obj1, *obj2, ret;

    ret = JS_EXCEPTION;
    // 获取两个GCRef指针
    obj1 = JS_PushGCRef(ctx, &obj1_ref);
    obj2 = JS_PushGCRef(ctx, &obj2_ref);
    // 创建第一个对象
    *obj1 = JS_NewObject(ctx);
    if (JS_IsException(*obj1))
        goto fail;
    // 创建第二个对象(此时obj1的地址可能已改变,但通过obj1_ref访问是安全的)
    *obj2 = JS_NewObject(ctx);
    if (JS_IsException(*obj2))
        goto fail;
    // 给obj1设置属性(obj1和obj2的地址可能再次改变,但引用安全)
    JS_SetPropertyStr(ctx, *obj1, "x", *obj2);
    ret = *obj1;
fail:
    // 释放GCRef
    JS_PopGCRef(ctx, &obj2_ref);
    JS_PopGCRef(ctx, &obj1_ref);
    return ret;
}

在PC上调试时,可以定义DEBUG_GC宏,强制JS分配器在每次分配时移动对象,这样能快速检测出无效的JSValue使用。

3. 标准库:ROM中的高效实现

MQuickJS的标准库通过一个自定义工具(mquickjs_build.c)编译成C结构,可以直接存放在ROM中。这使得标准库的初始化速度极快,几乎不占用RAM。

mqjs_stdlib.c提供了一个mqjs工具使用的标准库示例,编译后会生成mqjs_stdlib.h。如果你想自定义标准库,可以参考这个示例。

example.c是一个完整的使用MQuickJS C API的示例程序,适合作为集成时的参考。

4. 持久化字节码:从ROM中运行

mqjs生成的字节码可以直接在ROM中运行,但需要先进行重定位处理(使用JS_RelocateBytecode())。重定位后的字节码可以通过JS_LoadBytecode()加载,然后用JS_Run()执行(具体可参考mqjs.c的实现)。

需要注意的是,字节码格式不保证向后兼容,而且执行前不会进行验证,所以只能运行来自可信来源的字节码。

5. 数学库与浮点仿真

MQuickJS内置了一个轻量的数学库(libm.c)。如果目标CPU没有浮点支持,它还提供了一个比GCC工具链更小的浮点仿真器,确保数学运算能正常进行。

六、内部机制:MQuickJS与QuickJS的差异

虽然MQuickJS和QuickJS共享部分代码,但为了适应嵌入式场景,它的内部机制做了很多优化,两者的差异主要体现在以下几个方面:

1. 垃圾回收

MQuickJS使用追踪式压缩垃圾回收器,而不是QuickJS的引用计数机制。这种设计的好处是:

  • 对象体积更小:每个分配的内存块只需要额外几个比特存储GC信息。
  • 避免内存碎片:压缩式回收会整理内存,减少碎片。
  • 不依赖系统malloc:引擎有自己的内存分配器,完全在用户提供的缓冲区中操作。

2. 值与对象的表示

MQuickJS中的值(Value)大小与CPU字长相同(32位CPU上是32位,64位CPU上是64位),可以存储以下内容:

  • 31位整数(用1位作为标签)。
  • 单个Unicode码点(可以是1个或2个16位代码单元组成的字符串)。
  • 64位浮点数(在64位CPU上,支持较小的指数范围)。
  • 内存块指针(内存块本身存储类型标签)。

JavaScript对象至少需要3个CPU字长(32位CPU上是12字节),具体大小取决于对象类型。属性存储在哈希表中,每个属性至少需要3个CPU字长。标准库对象的属性可以直接存放在ROM中,节省RAM。

属性键是JSValue类型(而不是QuickJS中的特定类型),只能是字符串或31位正整数。字符串键会被intern(全局唯一),减少内存占用。

字符串内部以UTF-8格式存储(QuickJS使用8位或16位数组),虽然不直接存储代理对(surrogate pairs),但在JavaScript中迭代16位代码单元时仍能正确显示,兼顾了兼容性和内存效率。

C函数可以作为单个值存储(减少开销),但这种情况下不能添加额外属性。标准库中的大部分函数都采用这种方式。

3. 标准库

整个标准库都存放在ROM中,编译时生成,只有少数对象需要在RAM中创建,因此引擎初始化速度极快。

4. 字节码

字节码基于栈结构(类似QuickJS),但通过间接表引用原子(atoms)。行号和列号信息使用指数哥伦布编码(exponential-Golomb codes)压缩,减少存储开销。

5. 编译过程

解析器基于QuickJS的版本改造,避免递归调用,确保C栈使用量可控。编译过程不生成抽象语法树(AST),而是一次性生成字节码,并通过多种技巧优化,省去了QuickJS的多轮优化步骤,更适合资源受限的场景。

七、测试与基准测试:验证MQuickJS的性能

想要确认MQuickJS的功能和性能,可以通过以下测试和基准测试来验证:

1. 基本测试

运行基本测试套件的命令很简单:

make test

这个命令会执行一系列基础测试,验证引擎的核心功能是否正常。

2. QuickJS微基准测试

如果想了解MQuickJS的运行速度,可以运行QuickJS的微基准测试:

make microbench

通过这个测试,你可以直观地比较MQuickJS与QuickJS在不同操作上的性能差异。

3. Octane基准测试

MQuickJS还支持V8的Octane基准测试(需要使用修改过的版本,适配严格模式)。相关的测试文件和补丁可以从这里下载。

运行Octane测试的命令是:

make octane

这个测试能更全面地评估引擎在复杂应用场景下的表现。

八、许可证信息

MQuickJS采用MIT许可证发布,除非另有说明,其源代码的版权归Fabrice Bellard和Charlie Gordon所有。这意味着你可以自由地使用、修改和分发MQuickJS,无论是商业项目还是开源项目,只要保留原始版权信息即可。

九、FAQ:关于MQuickJS的常见问题

1. MQuickJS适合哪些场景?

MQuickJS最适合资源受限的嵌入式系统,比如需要运行JavaScript脚本但RAM和ROM空间有限的设备(如物联网传感器、微控制器等)。

2. 为什么MQuickJS不支持某些JavaScript特性?

为了降低内存占用和提高执行效率,MQuickJS只保留了ES5的核心子集和部分扩展特性,移除了容易出错或低效的功能(如with语句、数组空洞等)。

3. 如何将MQuickJS集成到我的嵌入式项目中?

首先,参考example.c了解基本用法;然后,根据项目需求准备内存缓冲区,初始化JS上下文;最后,通过C API加载脚本或字节码并执行。如果需要自定义标准库,可以参考mqjs_stdlib.c的实现。

4. 字节码可以在不同架构的设备上通用吗?

不可以。字节码格式依赖于CPU的字节序和字长(32位或64位),但64位设备可以通过-m32选项生成32位字节码,供32位嵌入式系统使用。

5. MQuickJS的垃圾回收会影响实时性吗?

追踪式垃圾回收在执行时会暂停程序,可能对实时性有一定影响。但由于MQuickJS的内存使用量小,垃圾回收的频率和耗时通常可控,适合对实时性要求不极端的场景。

通过本文的介绍,相信你对MicroQuickJS有了全面的了解。无论是在嵌入式设备上运行JavaScript,还是想研究轻量级引擎的实现原理,MQuickJS都是一个值得深入探索的工具。它的设计理念——在有限资源中实现高效的JavaScript执行,为嵌入式领域的脚本化开发提供了新的可能。