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. 函数对象
函数对象的prototype、length和name属性都是 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码点表示字符。 -
数学函数:包括 imul、clz32、fround、trunc、log2、log10等。 -
指数运算符( **):如2**3表示2的3次方。 -
正则表达式标志:支持 s(dotall)、y(sticky)、u(unicode),但unicode模式下不支持Unicode属性。 -
字符串方法: codePointAt、replaceAll、trimStart、trimEnd。 -
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执行,为嵌入式领域的脚本化开发提供了新的可能。

