最近对 QuickJS 感兴趣,特别是那个实现 js引擎的五万多行的 quickjs.c 文件,之前学习C语言语法,也是为了想窥探一下这些代码的实现细节。
QuickJS的功能主要有两个:第一是运行js代码,第二是可以将js代码编译成电脑可以直接执行的exe程序。
QuickJS先通过词法分析将js代码转换成相应的字节码,然后再将这些字节码带入到一个执行函数里做最后的执行。最终的执行函数是 JS_CallInternal,这个函数内部有一个巨大的 switch 语句,然后所有的字节码类型在每个 case里,通过依次取出字节码,放入到 switch 语句里来做最终的执行。
我虽然下载了Quickjs的代码,但并没有真的去编译,因为我的系统是win,听说想要编译成功,还需要做其它一些工作,而我对C不太了解,就直接放弃了。好在作者在他的另一个神作 JSLinux 里放了一个Quickjs,这样通过浏览器运行 linux,就能直接运行和编译js代码了。
通过qjs直接运行
进入 JSLinux,通过 touch 123.js 指令创建一个js文件,然后通过 vi 123.js 编辑文件,将以一下内容输入文件,保存并退出。
function add(a, b) {
return a + b;
}
console.log('6 + 6 = ' + add(6, 6));
通过 qjs 123.js 就能直接运行 js 代码
转换字节码
输入 qjsc -e 123.js 就能得到一个包含字节码的的文件,文明名是 out.c。下边也展示了 qjsc 指令的具体参数。
用 vi out.c 打开文件,可以看到文件内容。
文件里上面一个大数组存储的就是 js 代码生成的字节码,将这些字节码,连同后边要对字节码所做的操作,全部放在一个C程序文件里,再将这个C文件编译成可执行程序,从而完成由js到exe。
在 main 函数里,先通过 JS_NewRuntime 生成一个运行时,然后基于这个运行时,用JS_NewContextRaw 创建上下文 context,再在 context 上挂载各种类型对象。context 和字节码作为参数,代入到 js_std_eval_binary 函数里做最终的执行。js_std_loop 函数则是实现 EventLoop功能。
/* File generated automatically by the QuickJS compiler. */
#include "quickjs-libc.h"
const uint32_t qjsc_123_size = 137;
const uint8_t qjsc_123[137] = {
0x02, 0x06, 0x0e, 0x63, 0x6f, 0x6e, 0x73, 0x6f,
0x6c, 0x65, 0x06, 0x6c, 0x6f, 0x67, 0x10, 0x36,
0x20, 0x2b, 0x20, 0x36, 0x20, 0x3d, 0x20, 0x0c,
0x31, 0x32, 0x33, 0x2e, 0x6a, 0x73, 0x02, 0x61,
0x02, 0x62, 0x0e, 0x00, 0x06, 0x00, 0xa0, 0x01,
0x00, 0x01, 0x00, 0x06, 0x00, 0x01, 0x2b, 0x01,
0xa2, 0x01, 0x00, 0x00, 0x00, 0x3f, 0x67, 0x00,
0x00, 0x00, 0x40, 0xc1, 0x00, 0x40, 0x67, 0x00,
0x00, 0x00, 0x00, 0x38, 0xe0, 0x00, 0x00, 0x00,
0x42, 0xe1, 0x00, 0x00, 0x00, 0x04, 0xe2, 0x00,
0x00, 0x00, 0x38, 0x67, 0x00, 0x00, 0x00, 0xbc,
0xbc, 0xf1, 0x9e, 0x24, 0x01, 0x00, 0xce, 0x28,
0xc6, 0x03, 0x01, 0x04, 0x1f, 0x00, 0x08, 0x0c,
0x0e, 0x43, 0x06, 0x00, 0xce, 0x01, 0x02, 0x00,
0x02, 0x02, 0x00, 0x00, 0x04, 0x02, 0xc8, 0x03,
0x00, 0x01, 0x00, 0xca, 0x03, 0x00, 0x01, 0x00,
0xd2, 0xd3, 0x9e, 0x28, 0xc6, 0x03, 0x02, 0x01,
0x03,
}; int main(int argc, char **argv)
{
JSRuntime *rt;
JSContext *ctx;
rt = JS_NewRuntime();
js_std_init_handlers(rt);
ctx = JS_NewContextRaw(rt);
JS_SetModuleLoaderFunc(rt, NULL, js_module_loader, NULL);
JS_AddIntrinsicBaseObjects(ctx);
JS_AddIntrinsicDate(ctx);
JS_AddIntrinsicEval(ctx);
JS_AddIntrinsicStringNormalize(ctx);
JS_AddIntrinsicRegExp(ctx);
JS_AddIntrinsicJSON(ctx);
JS_AddIntrinsicProxy(ctx);
JS_AddIntrinsicMapSet(ctx);
JS_AddIntrinsicTypedArrays(ctx);
JS_AddIntrinsicPromise(ctx);
JS_AddIntrinsicBigInt(ctx);
js_std_add_helpers(ctx, argc, argv);
js_std_eval_binary(ctx, qjsc_123, qjsc_123_size, 0);
js_std_loop(ctx);
JS_FreeContext(ctx);
JS_FreeRuntime(rt);
return 0;
}
将js代码编译成可以行程序
输入 qjsc -o 123 123.js 得到可执行程序,通过 ls 指令可以看到,得到了一个绿色文件名的可执行程序。
输入 ./123 运行程序,输出结果。通过 stat 指令查看可执行程序的信息。
关于字节码
在 quickjs-opcode.h 文件内,定义了所有的字节码,通过名字,大概可以了解一些信息。
每个字节码分为五部分:
- id,也就是字节码的名字。
- size,也就是字节码占用的内存空间。
- pop,数据出栈操作
- push,数据入栈操作
- flag,标记
在执行字节码的函数 JS_CallInternal 内,有两个指针很重要:字节码指针 pc 和 执行栈指针 sp。比如加法操作在文件的定义如下:
DEF( add, 1, 2, 1, none)
具体的操作代码如下:
从文件定义中可以看出,需要从栈sp中pop两个数据,分别对应代码的op1 = sp[-2] 和 op2 = sp[-1],然后执行完成后又将结果push进去,对应代码的 sp[-2] = JS_NewInt32(ctx, r)。
Quickjs将一个js文件内所有的代码全部包裹在一个函数内执行,函数内可以无限套函数,也就能创建无数个临时栈,所有函数的执行数据都存储在栈结构里,sp就是指向栈的指针。也就是说,就算你在一个文件内没有定义任何函数,实际执行时,这些代码仍然会被放在一个全局函数里执行。所以对于字节码指令来说,最重要的信息就是对栈数据做了哪些pop和push操作。
第二个指针是指向字节码列表的 pc 指针,通过移动这个指针,来不断的执行字节码指令。OP_add 操作里没有将 pc 指向下一个地址,不过他这个 CASE 指令也不是原装的 case,通过前面的 SWITCH 指令的宏定义可以看出,它似乎能自动对 pc 加 1。
SWITCH 定义:
#define SWITCH(pc) goto *dispatch_table[opcode = *pc++];
也就是说,这个SWITCH 是通过 goto 语句跳转实现的。
下面是一个关于 OP_array_from 字节码对应的操作,主要是用来创建数组。
在 quickjs-opcode.h 文件中,OP_array_from 的定义如下:
DEF( array_from, 3, 0, 1, npop)
size长度是3,而这里 pc += 2,这说明,每个字节码的长度占1,如果字节码的长度刚好是1,说明字节码仅仅是一个指令,不包含数据,而如果字节码指令大于1,多出的部分,就是数据信息。get_u16的参数是pc,说明它获取了2个字节的字节码。
下边一张图更能说明什么是字节码(图片来自网络,忘了是什么工具生成的字节码)