文章发布于:2024年2月4日

最近对 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 文件内,定义了所有的字节码,通过名字,大概可以了解一些信息。


每个字节码分为五部分:

  1. id,也就是字节码的名字。
  2. size,也就是字节码占用的内存空间。
  3. pop,数据出栈操作
  4. push,数据入栈操作
  5. 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个字节的字节码。

下边一张图更能说明什么是字节码(图片来自网络,忘了是什么工具生成的字节码)


关于词法解析

我对编译原理不了解,但听说过一些名词,比如代码会被转换成语法树之类的。但在 QuickJS 里面,似乎没有这些,作者就是直接字符串解析,比如在 next_token 这函数里。



可以看出,这就是简单的字符解析:case 匹配到 “=” 号,如果接下来连续出现两个 “=” 号 (p[1], p[2]),则匹配严格等于模式(“===”),如果只有一个,则只是普通等于模式(“==”),如果 case 后是 “>” 号,则匹配箭头函数(“=>”)。