简介
Quick JS 是 Fabrice Bellard 今年发布的一款 JavaScript 引擎,具有以下特性:
- 轻量而且易于嵌入:只需几个C文件,没有外部依赖,一个x86下的简单的“hello world”程序只要180 KiB。
- 具有极低启动时间的快速解释器: 在一台单核的台式PC上,大约在100秒内运行ECMAScript 测试套件1 56000次。运行时实例的完整生命周期在不到300微秒的时间内完成。
- 几乎完整实现ES2019支持,包括: 模块,异步生成器和和完整Annex B支持 (传统的Web兼容性)。
- 通过100%的ECMAScript Test Suite测试。
- 可以将Javascript源编译为没有外部依赖的可执行文件。
- 使用引用计数(以减少内存使用并具有确定性行为)的垃圾收集与循环删除。
- 数学扩展:BigInt, BigFloat, 运算符重载, bigint模式, math模式.
- 在Javascript中实现的具有上下文着色和完成的命令行解释器。
- 采用C包装库构建的内置标准库。
本文主要记录我阅读 QuickJS 源码时记录的一些心得,主要用于学习用途。
编译
源码下载地址:https://bellard.org/quickjs/
编译 qjs 引擎本身
qjs 本身用于直接执行 JavaScript 代码:
$ make qjs
编译 qjsc 编译器
qjsc 编译器可以把 JavaScript 代码编译成 QuickJS 虚拟机的字节码(可直接通过 QuickJs 虚拟机执行)。 也可以把 JavaScript 代码编译成一个 C 语言的 .c 文件,这个文件包含了字节码:
但是编译 qjsc 编译器首先需要编译 libquickjs 库:
$ make libquickjs.a
$ make qjsc
使用 qjsc 编译器把 my_test.js
文件编译成 my_test.c
文件:
$ ./qjsc -e -o my_test.c my_test.js
使用 XCode 进行 Debug
如果你想很清楚地了解整个虚拟机的执行过程,可能你需要 XCode 来进行单步调试:
为了能在 XCode 下进行调试,你可能需要先在 Makefile 里面把优化给去掉。 打开 Makefile, 在里面找到这一句:
CFLAGS_OPT=$(CFLAGS) -O2
改成:
CFLAGS_OPT=$(CFLAGS) -O0
接下来就好办了,我们照着 这篇教程 走就可以在 XCode 里面 debug 了。
OPCode
QuickJs 的虚拟机使用栈式虚拟机。对于什么式栈虚拟机推荐阅读 这篇文章 了解。
QuickJS 的 OPCode 十分简洁和紧凑。所有 OP 码的定义都放在 quickjs-opcode.h
文件里面:
从上图可以知道 OPCode 定义分为这几个部分:
- id: OPCode 的名字
- size: OPCode 的字节大小。比如说
push_i32
指令的 size 是 5. 那么第一个字节用来存 OPCode 本身,
后面四个字节用来存 32 为的整型,也就是四个字节。
- n_pop: 从栈上面弹出的元素的数量。和下面的 n_push 一起用户统计函数需要分配的栈的大小。
- n_push: 从栈上面插入元素的数量。
- f: 字节码的格式。
Runtime
在虚拟机内部,运算的最基本单位是 JSValue,一个 JSValue 可以用以下 Tag 来标示:
enum {
/* all tags with a reference count are negative */
JS_TAG_FIRST = -10, /* first negative tag */
JS_TAG_BIG_INT = -10,
JS_TAG_BIG_FLOAT = -9,
JS_TAG_SYMBOL = -8,
JS_TAG_STRING = -7,
JS_TAG_SHAPE = -6, /* used internally during GC */
JS_TAG_ASYNC_FUNCTION = -5, /* used internally during GC */
JS_TAG_VAR_REF = -4, /* used internally during GC */
JS_TAG_MODULE = -3, /* used internally */
JS_TAG_FUNCTION_BYTECODE = -2, /* used internally */
JS_TAG_OBJECT = -1,
JS_TAG_INT = 0,
JS_TAG_BOOL = 1,
JS_TAG_NULL = 2,
JS_TAG_UNDEFINED = 3,
JS_TAG_UNINITIALIZED = 4,
JS_TAG_CATCH_OFFSET = 5,
JS_TAG_EXCEPTION = 6,
JS_TAG_FLOAT64 = 7,
/* any larger tag is FLOAT64 if JS_NAN_BOXING */
};
那么一个 JSValue 的表示是这样的:
typedef union JSValueUnion {
int32_t int32;
double float64;
void *ptr;
} JSValueUnion;
typedef struct JSValue {
JSValueUnion u;
int64_t tag;
} JSValue;
从这个结构体可以看出一个 JSValue 占用 16 字节的内存。tag 用来表示这个 Value 的类型。而 JSValueUnion
则是
用来储存真正的值。这个值可以是 int/float 或者一个指针指向一个真正的对象。
真正的对象在 QuickJS 里面则是使用引用计数来进行内存管理,所以都有一个引用计数器:
ypedef struct JSRefCountHeader {
int ref_count;
} JSRefCountHeader;
命令执行
知道了上述背景之后,我们就可以看看 QuickJS 虚拟机具体的执行过程了。需要知道的是,一个栈虚拟机,需要两个很重要的 指针,分别是 pc 和 sp。
pc 指的是程序计数器。指向正在读取的 OPCode,读取之后 pc 会 ++,指向下一个字节。
sp 是栈指针。指向栈机器栈顶的下一个元素,用来进行栈相关的操作。
入栈
入栈操作就是把读入的值变成一个 JSInt32,然后赋值到 sp 指针指向的内存,然后 sp++
。就完成了一个入栈操作。
我们可以看到 QuickJS 的字节码实际是十分冗余的,具体为什么这么设计我不太清楚。估计是 0-7 之间的数可以用一个
字节搞定,节省空间,但是我个人感觉也太省了,对实际运行应该帮助不大。
OP_push_i8
是一个两字节的 OP 码,第二个字节就是一个 8 位的整数,同理 OP_push_i16
就是一个三个字节
的指令,入栈一个 16 位的整数。
加法
栈虚拟机上的加法就是从栈弹出两个元素,相加,然后入栈。这里首先从栈顶取出 op1 和 op2。然后判断是不是 int。 这里的 int 类型是 32 位的。所以它先把他们切到 64 位进行相加。加完再切回 32 位的 int,看看数据是不是一样。 若不一样,则说明加法产生了溢出,这个时候就丢到 add_slow 进行处理。
若两个 op 都是 float64 则进行浮点数的加法。
If 分支跳转
If 分支跳转是程序里面很重要的一步,实现了分支跳转,for 和 if 语句都可以实现了。
QuickJs 有 goto 指令,比较简单,这里就不说了,这里介绍一下 OP_if_true
:
If 分支跳转主要是看栈顶元素是不是为 true。若为 true,则把 pc 加等于相应的 offset。
这个 offset 在 QuickJS 里面是 32 位的,这意味着 OP_if_true
是一个 5 个字节的
OPCode。这个 offset 在编译的过程中就可以被算出来。
图中有一个 tag 是否小于等于 JS_TAG_UNDEFINED
的判断。这个判断根据上面的 tag 的定义。
我们知道只有这几个类型是不符合这个判断的:
- JS_TAG_UNINITIALIZED = 4
- JS_TAG_CATCH_OFFSET = 5
- JS_TAG_EXCEPTION = 6
- JS_TAG_FLOAT64 = 7
以上几个类型需要用 JS_ToBoolFree
,进行判断,其他类型则可以从 JSValue 读出真正的 bool 值。
总结
QuickJS 源码实现非常简单易懂,是一个非常适合学习的 JS 引擎。有一点谈不上好坏的就是 像 Bellard 这样的 hacker 出来的代码非常的骚气。比如说通篇的 goto,不过幸好不是很难懂, 有些逻辑确实 goto 写起来比较爽这一点,可以理解。另一方面不太好的就是大部分代码都写尽了一个 .c 文件 里面了,读函数调用跳来跳去比较麻烦。
总的来说是比较适合阅读的源码了。