在详细介绍和剖析QuickJS之前,我先跟你聊聊JavaScript的背景和QuickJS作者的背景吧。我觉得这样更有助于理解QuickJS。
先说说JavaScript。
第一个图文浏览器是1993年的Mosaic,由MarcAndreessen开发。后替代Mosaic的是Netscape的NetscapeNavigator浏览器。BrendanEich给Netscape开发Java辅助语言Mocha(后更名为JavaScript),耗时10天出原型(包含了eval函数),集成到Netscape2预览版里,Mocha基于对象而非Java那样基于类。Mocha采用源码解析生成字节码解释执行方式而非直接使用字节码的原因是Netscape公司希望Mocha代码简单易用,能够直接在网页代码中编写。
下面回到90年代,接着说代号是Mocha的JavaScript发展历程。
Mocha的动态的对象模型,使用原型链的机制能够更好实现,对象有属性键和对应的值,属性的值可以是多种类型包括函数、对象和基本数据类型等,找不到属性和未初始化的变量都会返回undefined,对象本身找不到时返回null,null本身也是对象,表示没有对象的对象,因此typeofnull会返回object。基础类型中字符串还没支持Unicode,有8位字符编码不可变序列组成,数字类型由IEEE754双精度二进制64位浮点值组成。新的属性也可以动态的创建,使用键值赋值方式。
ES3标准坚持了10年。
BrendanEich代表Mozilla在2004年开始参与ES4的规划,2006年Opera的LarsThomasHansen也加入进来。Flash将AVM2给了Mozilla,命名为Tamarin。起初微软没怎么参与ES4的工作,后来有20多年Smalltalk经验的AllenWirfs-Brock加入微软后,发现TC39正在设计中ES4是基于静态类型的ActionScript3.0,于是提出动态语言加静态类型不容易成,而且会有很大的兼容问题,还有个因素是担心Flash会影响到微软的产品,希望能够夺回标准主动权。AllenWirfs-Brock的想法得到在雅虎的DouglasCrockford的支持,他们一起提出新的标准提案,提案偏保守,只是对ES3做补丁。
2006年Google的LarsBak开始了V8引擎的开发,2008基于V8的Chrome浏览器发布,性能比SpiderMonkey快了10倍。V8出来后,各大浏览器公司开始专门组建团队投入js引擎的性能角逐中。当时js引擎有苹果公司的SquirrelFishExtreme、微软IE9的Chakra和Mozilla的TraceMonkey(解释器用的是SpiderMonkey)。
ECMAScript2016开始js进入了框架百家争鸣的时代。
React框架对应移动端开发的是ReactNative。
2019年出现的Svelte。Svelte的特点是构建出的代码小,使用时可以直接使用构建出带有少量仅会用到逻辑的运行时的组件,不需要专门的框架代码作为运行时使用,不会浪费。Svelte没有diff和patch操作,也能够减少代码,减少内存占用,性能会有提升。当然Svelte的优点在项目大了后可能不会像小项目那么明显。
在浏览器之外领域最成功的框架要数Node.js了。
RyanDahl基于V8开发了Node.js,提供标准库让js能够建立HTTP服务端应用。比如下面的js代码:
Node.js作者RyanDahl后来弄了Deno,针对Node.js做了改进。基于Node.js开发应用服务的框架还有Next.js,Next.js是一个React框架,包括了各种服务应用的功能,比如SSR、路由和打包构建,Netflix也在用Next.js。最近前端流行全栈无服务器Web应用框架,包含React、GraphQL、Prisma、Babel、Webpack的Redwood框架表现特别突出,能够给开发的人提供开发Web应用程序的完整体验。
2020年StackOverflow开发者调查显示超过半数的开发者会使用js,这得益于多年来不断更新累积的实用框架和库,还有生态社区的繁荣,还有由各大知名公司大量语言大师专门为js组成的技术委员会TC39,不断打磨js。
js背景说完了,接下来咱们来聊聊QuickJS的作者吧。
TC39这些年来一直在考虑加入高精度的小数类型Decimal。
这些都是什么数字呢?
Int的最大值是2的31次方减1,十进制就是2147483647,共31位,如果需要更大位数就需要用于科学计算的Decimal。Decimal128是128位的高精度精确小数类型。
为什么要使用Decimal这种类型呢?
当需要更高精度时,浮点运算可以用可变长度的比如指数来实现,大小根据需要来,这就是任意精度浮点运算。更高精度就需要浮点硬件,叫浮点扩展,比如double-doublearithmetric,用在C的longdouble类型。就算精度再高吧,有些有理数比如1/3,也没法全用二进制浮点数表示,如果要精确表现这些有理数,需要有理算术软件包,软件包对每个整数使用bignumber运算。在计算机的代数系统(比如Maple)里,可以评估无理数比如π直接处理底层数学,而不需要给每个中间计算使用近似值。
再看看对应的标准和各编程语言的实现情况。
那什么是IEEE754-2008标准?为什么要有这个标准?
以前很早的时候大概在七八十年代,计算机制造商的浮点标准比如字大小、表示方法还有四舍五入等都不一样,不同系统浮点的兼容就是个很大的问题,因此当时英特尔和摩托罗拉都提出了浮点标准的诉求。
85年IEEE754-2008decimal浮点标准出来,IBM大型机进行了支持。标准在计算机硬件和编程语言中应用非常广,其基本格式有单精度,双精度和扩展精度,单精度在C里是float类型,7位小数。双精度在C里是double类型,占8个字节,64位双精度的范围是2×10的负308次方到2×10的308次方。扩展精度在C99和C11标准的附件IEC60559浮点运算里定义了longdouble类型。四精度,34位小数。decimal32、decimal64和decimal128都是用于执行十进制进位。
IEEE754-2008标准还定义了32位、64位和128位decimal浮点表示法。规定了一些特殊值的表示方法,比如正无穷大+∞,负无穷大-∞,不是一个数字表示为NaNs等。定义了浮点单元(FPU),也可以叫数学协处理器,专门用来执行浮点运算。
IBM在1998年在大型机里引入了IEEE兼容的二进制浮点运算,05年加了IEEE兼容的decimal浮点类型。
除了IEEE754-2008标准外,还有其他浮点格式标准吗?
现在对机器学习模型训练而言,范围比精度更有用,因此有了Bfloat16标准,Bfloat16标准和IEEE754半精度格式的内存量一样,而指数要更多,IEEE754是5位,Bfloat16是8位。很多机器学习硬件加速器都提供了支持Bfloat16支持,Nvidia甚至还支持了TensorFloat-32格式标准,指数位数更多,达到10位。
QuickJS只有210KB,体积小,启动快,解释执行速度快,支持最新ECMAScript标准(ECMA-262)。
下面我们通过安装QuickJS来小试下吧。
QuickJS的编译和咱们通过Xcode工程配置编译的方式不同,使用的是makefile来配置编译和安装的,和一些开源C/C++工程编译使用cmake方式也有些不同,以前我们写些简单c/c++的demo后,会简单的通过clang命令加参数进行编译和链接,但如果需要编译和链接的文件多了,编译配置复杂了,每次手工编写就太过复杂,因此就会用到makefile或者cmake来帮助减少复杂的操作提高效率。那什么是makefile?和cmake有什么关系呢?
我先介绍下什么是makefile吧。
makefile是在目录下叫Makefile文件,由make这个命令工具进行解释执行。把源代码编译生成的中间目标文件.o文件,这个阶段只检测语法,如果源文件比较多,ObjectFile也就会多,再明确的把这些ObjectFile指出来,链接合成一个执行文件就会比较繁琐,期间还会检查寻找函数是否有实现。为了能够提高编译速度,需要对没有编译过的或者更新了的源文件进行编译,其他的直接链接中间目标文件。而且当头文件更改了,需要重新编译引用了更改的头文件的文件。上面所说的过程只需要make命令和编写的makefile就能完成。
简单说,makefile就是一个纯手动的IDE,通过手动编写编译规则和依赖来配合make命令来提高编译工作效率。make会先读入所有include的makefile,将各文件中的变量做初始化,分析语法规则,创建依赖关系链,依据此关系链来定所需要生成的文件。
那么makefile的语法规则是怎样的呢?
makefile的语法规则如下:
target...:prerequisites...command......其中的target可以是一个目标文件,也可以是一个可执行的文件,还可以是一个label。prerequisites表示是target所依赖的文件或者是target。prerequisites的文件或target只要有一个更新了,对应的后面的command就会执行。command就是这个target要执行的shell命令。
举个例子,我们先写个main.c
#include
#include"foo.h"voidsayHey(){printf("Hey!\n");}再写个makefile
hi:main.ofoo.occ-ohimain.ofoo.omain.o:main.cfoo.hcc-cmain.cfoo.o:foo.cfoo.hcc-cfoo.cclean:rmhimain.ofoo.o在该目录下直接输make就能生成hi可执行文件,如果想要清掉生成的可执行文件和中间目标文件,只要执行makeclean就可以了。
上面代码中冒号后的.c和.h文件就是表示依赖的prerequisites。你会发现.o文件的字符串重复了两次,如果是这种重复多次的应该如何简化呢,类似C语言中的变量,实际上在makefile里是可以有类似变量的语法,在文件开始使用=号来定义就行。写法如下:
objects=main.ofoo.o使用这个变量的语法是$(objects),使用变量语法后makefile就变成下面的样子:
objects=main.ofoo.ohi:$(objects)cc-ohi$(objects)main.o:main.cfoo.hcc-cmain.cfoo.o:foo.cfoo.hcc-cfoo.cclean:rmhi$(objects)makefile具有自动推导的能力,比如target如果是一个.o文件,那么makefile就会自动将.c加入prerequisites,而不用手动写,并且cc-cxxx.c也会被推导出,利用了自动推导的makefile如下:
objects=main.ofoo.ohi:$(objects)cc-ohi$(objects)main.o:foo.hfoo.o:clean:rmhi$(objects)make中通配符和shell一样,~/js表示是$HOME目录下的js目录,*.c表示所有后缀是c的文件,比如QuickJS的makefile里为了能够随时保持纯净源码环境会使用makeclean清理中间目标文件和生成文件,其中makefile的clean部分代码如下:
clean:rm-frepl.cqjscalc.cout.crm-f*.a*.o*.d*~unicode_genregexp_test$(PROGS)rm-fhello.ctest_fib.crm-fexamples/*.sotests/*.sorm-rf$(OBJDIR)/*.dSYM/qjs-debugrm-rfrun-test262-debugrun-test262-32上面的repl.c、qjscalc.c和out.c是生成的QuickJS字节码文件,.a、.o、*.d表示所有后缀是a、o、d的文件。
如果要简化到编译并链接所有的.c和.o文件,可以按照下面的写法来写:
objects:=$(patsubst%.c,%.o,$(wildcard*.c))foo:$(objects)cc-ofoo$(objects)上面代码中的patsubst是模式字符串替换函数,%表示任意长度字符串,$加括号表示要执行makefile的函数,wildcard的作用是扩展通配符,因为在变量定义和函数引用时,通配符会失效,因此这里wildcard的作用是获取目录下所有后缀是.c的文件。patsubst的语法如下:
$(patsubst
ifdefCONFIG_LTOlibquickjs.a:$(patsubst%.o,%.nolto.o,$(QJS_LIB_OBJS))$(AR)rcs$@$^endif#CONFIG_LTO上面这段表示在配置打开lto后,会把QJS_LIB_OBJS这个变量定义的那些中间目标.o文件后缀缓存.nolto.o后缀。
QuickJS的makefile中使用的函数除了patsubst和wildcard还有shell。shell的作用就是可以直接调用系统的shell函数,比如QuickJS里的$(shelluname-s),如果在macOS上运行会返回Darwin,使用此方法可以判断当前用户使用的操作系统,从而进行不同的后续操作。比如QuickJS的makefile是这么做的:
ifeq($(shelluname-s),Darwin)CONFIG_DARWIN=yendif上面代码可以看出,通过判断shell函数返回值来确定是否是Darwin内核,将结果记录在变量CONFIG_DARWIN变量中。通过这个结果后续配置编译器为clang。
$(OBJDIR)/%.o:%.c|$(OBJDIR)$(CC)$(CFLAGS_OPT)-c-o$@$<上面这段代码的作用是当.o文件依赖的编译中间产物或c源文件有更新时,会重新编译生成.o文件$@表示的是$(OBJDIR)/%.o,$<表示的是%.c|$(OBJDIR),简化了代码,$@就像数组那样会依次取出target,然后执行。
依赖关系里会有.h头文件,你一定奇怪在QuickJS的makefile里那些头文件为什么就没有出现在prerequisites中了,这是为什么呢?
这是因为有办法让makefile自动生成依赖关系。如果没有这办法自动生成依赖关系的话,在大型工程中,你就需要对每个c文件包含了那些头文件了解清楚,并在makefile里写好,当修改c文件时还需要手动的维护makefile,因此这种工作不光重复而且一不小心还会错。
那有办法能解决重复易错的问题么?
生成完.d文件后,需要用include命令把这些规则加到makefile里,看下QuickJS的做法:
-include$(wildcard$(OBJDIR)/*.d)makefile还有些隐含的规则,比如把源文件编译成中间目标文件这一步可以省略不写,make会自动的推导生成中间目标文件,对应命令是$(CC)–c$(CPPFLAGS)$(CFLAGS),链接目标文件是通过运行编译器的ld来生成,也可以省略,对应的命令是$(CC)$(LDFLAGS)
ifdefCONFIG_DEFAULT_ARAR=$(CROSS_PREFIX)arelseifdefCONFIG_LTOAR=$(CROSS_PREFIX)llvm-arelseAR=$(CROSS_PREFIX)arendifendif上面CROSS_PREFIX变量实际上已经没用了,以前是因为要兼容在Linux下运行Windows所需要添加mingw32的前缀,目前这段变量定义已经被注释掉了。隐含规则命令参数有编译器参数CFLAGS和链接器参数LDFLAGS等,这些变量可以根据条件判断或者平台区分,配置不同参数。
由于GNU的make和其他工具,比如微软的nmake还有BSD的pmake的makefile语法规则标准有不同,因此如果想为多个平台和工具编写可编译的makefile需要写多份makefile文件。
为了应对这样重复繁琐的工作,cmake出现了。
使用qjsc-e生成的C代码,通过编写如下的CMakeLists.txt配置:
cmake_minimum_required(VERSION3.10)project(runtime)add_executable(runtime#如果有多个C文件,在这里加src/main.c)#头文件和库文件include_directories(/usr/local/include)add_library(quickjsSTATICIMPORTED)set_target_properties(quickjsPROPERTIESIMPORTED_LOCATION"/usr/local/lib/quickjs/libquickjs.a")#链接到runtimetarget_link_libraries(runtimequickjs)按照上面代码编写,可以编译出可执行的文件了。
qjsc还可以把js文件编译成QuickJS虚拟机的字节码,比如编写下面的一段javascript代码,保存为helloworld.js
letmyString1="Hello";letmyString2="World";console.log(myString1+""+myString2+"!");使用
qjsc-ohellohelloworld.js就能够输出一个可执行文件hello可执行文件,运行后输出helloworld!。把参数改成-e可以输出.c文件。
qjsc-e-ohelloworld.chelloworld.js文件内容如下:
/*mainloopwhichcallstheuserJScallbacks*/voidjs_std_loop(JSContext*ctx){JSContext*ctx1;interr;for(;;){/*executethependingjobs*/for(;;){err=JS_ExecutePendingJob(JS_GetRuntime(ctx),&ctx1);if(err<=0){if(err<0){js_std_dump_error(ctx1);}break;}}if(!os_poll_func||os_poll_func(ctx))break;}}上面代码中的os_poll_func就是js_os_poll函数的调用,js_os_poll函数在quickjs-libc.c里定义,会在主线程检查有没有需要执行的任务,没有的话会在后台等待事件执行。
简单说QuickJS集成使用过程是先将QuickJS源码编译成静态或动态库。makefile会把头文件、库文件和可执行文件copy到标准目录下。然后C源码调用QuickJS提供的API头文件。最后编译生成可执行文件。
那么js和原生c的交互如何做呢?
在QuickJS的js代码里可以通过import导入一个c的库,调用库里的函数。比如QuickJS中的fib.c文件,查看其函数js_fib就是对js里调用的fib函数的实现,使用的是JS_CFUNC_DEF宏来做js方法和对应c函数映射。映射代码如下:
staticconstJSCFunctionListEntryjs_fib_funcs[]={JS_CFUNC_DEF("fib",1,js_fib),};在js中使用起来也很简单,代码如下:
import{fib}from"./fib.so";varf=fib(10);quickjs-libc内置了些std和os原生函数可以直接供js使用,比如std.out.printf函数,先看在js_std_file_proto_funcs里的映射代码:
JS_CFUNC_DEF("printf",1,js_std_printf),可以看到对应的是js_std_printf函数,JS_CFUNC_DEF宏的第二个参数为1表示out。在js里使用的代码如下:
import*asstdfrom'std'consthi='hi'std.out.printf('%s',hi)怎么新建一个自己的库呢?
QuickJS里的fib就是个例子,通过生成的test_fib.c可以看到下面的代码:
#ifdefJS_SHARED_LIBRARY#defineJS_INIT_MODULEjs_init_module#else#defineJS_INIT_MODULEjs_init_module_fib#endifJSModuleDef*JS_INIT_MODULE(JSContext*ctx,constchar*module_name){JSModuleDef*m;m=JS_NewCModule(ctx,module_name,js_fib_init);if(!m)returnNULL;JS_AddModuleExportList(ctx,m,js_fib_funcs,countof(js_fib_funcs));returnm;}从上面代码可以看到,创建模块使用的是JS_NewCModule函数,接着通过JS_AddModuleExportList将模块的函数加到模块导出列表里。JS_SetModuleLoaderFunc会设置读取库的函数为js_module_loader,js_module_loader会判断路径后缀,如果是.so会调用js_module_loader_so函数。js_module_loader_so会使用dlopen和dlsym来调用so库。
接下来,我们深入QuickJS内部,看看QuickJS的源代码结构以及说说源码的原理。
我先对QuickJS源码文件做个介绍,主要文件如下:
文件夹:
其中qjsc.c会根据参数输入,挨个调用compile_file函数进行编译。参数说明如下:
ming@mingdeMacBook-Pro~%qjsc-hQuickJSCompilerversion2020-11-08usage:qjsc[options][files]optionsare:-c只输出字节码C文件,默认是输出可执行文件-e输出带main函数的字节码C文件-ooutput设置输出文件名,默认是a.out或者out.c-Ncname设置输出c文件的字节码数组的变量名-m编译成JavaScript模块-Dmodule_name编译动态加载模块或worker,也就是用了import或os.Worker的情况-Mmodule_name[,cname]给外部C模块添加初始化代码-xbigendian和littleendian翻转,用于交叉编译-pprefix设置C变量名的前缀-Sn设置最大栈大小,默认是262144-flto编译成可执行文件使用clang的链接优化-fbignumbignumber支持的开启-fno-[date|eval|string-normalize|regexp|json|proxy|map|typedarray|promise|module-loader|bigint]让一些语言特性不可用,从而能够让生成的代码更小编译的过程是先使用js_load_file读取js文件内容,使用JS_Eval函数生成JSValue的对象,JSValue是基本的值或指针地址,比如函数、数组、字符串都是值,然后使用output_object_code函数把对象的字节码写到文件里,也就是dump成二进制。生成可执行文件使用的函数是output_executable。
qjs的Options-e表示执行表达式,-i表示进入交互模式,-I表示加载文件,-d会统计内存使用情况。
底层是基础,JS_RunGC使用引用计数来管理对象的释放。JS_Exception是会把JSValue返回的异常对象存在JSContext里,通过JS_GetException函数取出异常对象。内存管理控制js运行时全局内存分配上限使用的是JS_SetMemoryLimit函数,自定义分配内存用的是JS_NewRuntime2函数,堆栈的大小使用的是JS_SetMaxStackSize函数来设置。
quickjs.c有5万多行代码。分析QuickJS代码可以从他解析执行JS代码的过程一步一步的分析。调用QuickJS执行一段JS代码的代码如下:
JSRuntime*rt=JS_NewRuntime();JSContext*ctx=JS_NewContext(rt);js_std_add_helpers(ctx,0,NULL);constchar*scripts="console.log('helloquickjs')";JS_Eval(ctx,scripts,strlen(scripts),"main",0);上面代码中rt和ctx是先构造一个JS运行时和上下文环境,js_std_add_helpers是调用C的std方法帮助在控制台输出调试信息。
JS_NewRuntime函数会新建一个JSRuntimert,Runtime的结构体JSRuntime包含了内存分配函数和状态,原子大小atom_size和原子结构数组指针atom_array,记录类的数组class_array,用于GC的一些链表head,栈头stack_top,栈空间大小(bytes)stack_size,当前栈帧current_stack_frame,避免重复出现内存超出错误的in_out_of_memory布尔值,中断处理interrupt_handler,module读取函数module_loader_func,用于分配、释放和克隆SharedArrayBuffers的sab_funcs,Shape的哈希表shape_hash,创建一般函数对象外,还有种避开繁琐字节码处理更快创建函数对象的方法,也就是Shape,创建Shape的调用链是JS_NewCFunctionData->JS_NewObjectProtoClass->js_new_shape。创建shape的函数是js_new_shape2,创建对象调用的函数是JS_NewObjectFromShape。
创建的对象是JSObject结构体,JSObject是js的对象,JSObject的字段会使用union,结构体和union的区别是结构体的字段之间会有自己的内存,而union里的字段会使用相同的内存,union内存就是里面占用最多内存字段的内存。union使用的内存覆盖方式,只能有一个字段的值,每次有新字段赋值都会覆盖先前的字段值。JSObject里第一个union是用到引用计数的,__gc_ref_count用来计数,__gc_mark用来描述当前GC的信息,值为JSGCObjectTypeEnum枚举。extensible是表示对象能否扩展。is_exotic记录对象是否是exotic对象,es规范里定义只要不是普通的对象都是exotic对象,比如数组创建的实例就是exotic对象。fast_array为true用于JS_CLASS_ARRAY、JS_CLASS_ARGUMENTS和类型化数组这样只会用到get和put基本操作的数组。如果对象是构造函数is_constructor为true。当is_uncatchable_error字段为true时表示对象的错误不可捕获。class_id对应的是JS_CLASS打头的枚举值,这些枚举值定义了类的类型。原型和属性的名字还有flag记在shape字段,存属性的数组记录在prop字段。
first_weak_ref指向第一次使用这个对象做键的WeakMap地址。在js中WeakMap的键必须要是对象,Map的键可以是对象也可以是其他类型,当Map的键是对象时会多一次对对象引用的计数,而WeakMap则不会,WeakMap没法获取所有键和所有值。键使用对象主要是为了给实例存储一些额外的数据,如果使用Map的话释放对象时还需要考虑Map对应键和值的删除,维护起来不方便,而使用WeakMap,当对象在其他地方释放完后对应的WeakMap键值就会被自动清除掉。
JS_ClASS开头定义的类对象使用的是union,因为一个实例对象只可能属于一种类型。其中JS_CLASS_BOUND_FUNCTION类型对应的结构体是JSBoundFunction,JS_CLASS_BOUND_FUNCTION类型是使用bind()方法创建的函数,创建的函数的this被指定是bind()的第一个参数,bind的其他参数会给新创建的函数使用。JS_CLASS_C_FUNCTION_DATA这种类型的对象是QuickJS的扩展函数,对应结构体是JSCFunctionDataRecord。JS_CLASS_FOR_IN_ITERATOR类型对象是for...in创建的迭代器函数,对应的结构体是JSForInIterator。
JS_CLASS_ARRAY_BUFFER表示当前对象是ArrayBuffer对象,ArrayBuffer是用来访问二进制数据,比如加快数组操作,还有媒体和网络Socket的二进制数据,ArrayBuffer对应swift里的bytearray,swift的字符串类型是基于Unicodescalar值构建的,一个Unicodescalar是一个21位数字,用来代表一个字符或修饰符,比如U+1F600对应的修饰符是,U+004D对应的字符是M。因此Unicodescalar是可以由bytearray来构建的。bytearray和字符之间也可以相互转换,如下面的swift代码:
//字符转bytearrayletstr="starming"letbyteArray:[UInt8]=str.utf8.map{UInt8($0)}//bytearray转字符letbyteArray:[UInt8]=[115,116,97,114,109,105,110,103]letstr=String(data:Data(bytes:byteArray,count:8),encoding:.utf8)上面代码中,将starming字符串转换成了[115,116,97,114,109,105,110,103]bytearray,逆向过来也没有问题。
typed_array字段包含如下类型:
JS_CLASS_UINT8C_ARRAY,/*u.array(typed_array)*/JS_CLASS_INT8_ARRAY,/*u.array(typed_array)*/JS_CLASS_UINT8_ARRAY,/*u.array(typed_array)*/JS_CLASS_INT16_ARRAY,/*u.array(typed_array)*/JS_CLASS_UINT16_ARRAY,/*u.array(typed_array)*/JS_CLASS_INT32_ARRAY,/*u.array(typed_array)*/JS_CLASS_UINT32_ARRAY,/*u.array(typed_array)*/#ifdefCONFIG_BIGNUMJS_CLASS_BIG_INT64_ARRAY,/*u.array(typed_array)*/JS_CLASS_BIG_UINT64_ARRAY,/*u.array(typed_array)*/#endifJS_CLASS_FLOAT32_ARRAY,/*u.array(typed_array)*/JS_CLASS_FLOAT64_ARRAY,/*u.array(typed_array)*/JS_CLASS_DATAVIEW,/*u.typed_array*/typed_array也就是类型化数组,其结构体是JSTypedArray。类型化数组把实现分为ArrayBuffer对象缓冲,使用视图读写缓冲对象中的内容,视图会将数据转换成有类型的数组。
map_state字段的结构体是JSMapState,用来存放以下对象类型的状态:
JS_CLASS_MAP,/*u.map_state*/JS_CLASS_SET,/*u.map_state*/JS_CLASS_WEAKMAP,/*u.map_state*/JS_CLASS_WEAKSET,/*u.map_state*/上面对象类型的状态,状态包括是否是weak的Map、Map记录链接头、记录数量、链接头哈希表、哈希大小、调整哈希表大小的次数。
Map的迭代器类型是JS_CLASS_MAP_ITERATOR和JS_CLASS_SET_ITERATOR,对应的结构体是JSMapIteratorData。数组迭代器的类型是JS_CLASS_ARRAY_ITERATOR和JS_CLASS_STRING_ITERATOR,对应结构体是JSArrayIteratorData。正则表达式迭代器的类型是JS_CLASS_REGEXP_STRING_ITERATOR,对应结构体是JSRegExpStringIteratorData。function后带一个星号这样表示的函数是生成器函数,类型是JS_CLASS_GENERATOR,结构体是JSGeneratorData,生成器函数会返回一个generator对象。生成器函数用于coroutine,函数内通过yield语句可以将函数执行暂停,让其他函数可以执行,再次执行可以从暂停处继续执行。
最后就是bignumber的数值运算,包括bigint_ops、bigfloat_ops、bigdecimal_ops。
JS_NewRuntime函数对JSRuntime和JSMallocState初始化使用的是memset函数,这个函数一般是对比较大些的结构体进行初始化,因为是直接操作内存,所以很快。JS_NewRuntime函数使用的JSMallocFunctions结构体来记录JSMallocState分配内存状态。JSRuntime里用context_list记录所有的上下文,gc_obj_list记录分配的GC对象,在调用JS_FreeValueRT函数时如果JSValue的tag是字节码,同时不在GC清理周期内时,会将GC对象加到gc_zero_ref_count_list里。
接着JS_NewRuntime函数会调用JS_InitAtoms函数去初始化Atom,包括那些js的关键字和symbol等。QuickJS使用includequickjs-atom.h文件方式导入的atom,代码如下:
enum{__JS_ATOM_NULL=JS_ATOM_NULL,#defineDEF(name,str)JS_ATOM_##name,#include"quickjs-atom.h"#undefDEFJS_ATOM_END,};上面代码中的JS_ATOM_END作为枚举的最后一个值,同时能够表示atom的数量,atom还会记录在js_atom_init[]里。代码如下:
staticconstcharjs_atom_init[]=#defineDEF(name,str)str"\0"#include"quickjs-atom.h"#undefDEF;定义枚举的作用就是能够在编译时确定atom的数量,比在运行时通过计算数组数量性能消耗少。在JS_InitAtoms函数里通过JS_ATOM_END将遍历js_atom_init函数初始化每个atom,js_atom_init函数里会先使用js_alloc_string_rt为字符串分配内存将字符串拷贝到空间内,最后调用__JS_NewAtom函数新建atom。
JS_NewRuntime函数最后会用init_class_range函数创建对象、数组和函数类,每个类新建用的是JS_NewClass1函数,这里会完成JSClass结构体的初始化,重置上下文类原型数组和类数组。通过JSClassCall和JSClassExoticMethods来设置JSClass。JSClassCall会调用js_call_c_function、js_c_function_data_call、js_call_bound_function、js_generator_function_call。JSClassExoticMethodsexotic是外来行为的指针,没有可以为NULL,JS_NewRuntime函数会设置js_arguments_exotic_methods、js_string_exotic_methods、js_module_ns_exotic_methods三个行为。
使用JS_AddIntrinsicBasicObjects函数对上下文初始化内置构造函数的原型对象。JS_AddIntrinsicBaseObjects函数会往全局对象ctx->global_obj里添加标准库的对象Object、函数Function、错误Error、迭代器原型、数组Array、Number、布尔值Boolean、字符串String、Math、ES6反射、ES6symbol、ES6Generator等,然后定义全局属性和初始化参数。JS_AddIntrinsicDate函数会添加Date,JS_AddIntrinsicEval会设置内部eval函数为__JS_EvalInternal。JS_AddIntrinsicStringNormalize函数会设置属性normalize。
JS_AddIntrinsicRegExp函数会添加正则表达式库RegExp。JS_AddIntrinsicJSON函数会在全局对象中添加JSON标准库。JS_AddIntrinsicProxy函数用来添加为js提供元编程能力的Proxy,用来进行目标对象拦截、运算符重载、对象模拟等。
如果开启了bignumberJS_NewContext函数会调用JS_AddIntrinsicBigInt添加BigInt来支持科学计算。
JS_Eval方法就是执行JS脚本的入口方法。JS_Eval里调用情况如下:
js_parse_init函数执行完后会过滤Shebang,Shebang一般是会在类Unix脚本的第一行,规则是开头两个字符是#!,作用是告诉系统希望用什么解释器执行脚本,比如#!/bin/bash表示希望用bash来执行脚本。在QuickJS里显然Shebang起不了什么用,因此通过skip_shebang函数过滤掉。
eval_type获取eval的类型,eval的type有四种,JS_EVAL_TYPE_GLOBAL00表示默认全局代码,JS_EVAL_TYPE_MODULE01表示模块代码,JS_EVAL_TYPE_DIRECT10表示在internal直接调用,JS_EVAL_TYPE_INDIRECT11表示在internal非直接调用。eval的flag有四种,JS_EVAL_FLAG_STRICT10xx表强行strict模式,JS_EVAL_FLAG_STRIP100xx表强行strip模式,JS_EVAL_FLAG_COMPILE_ONLY1000xx表示仅编译但不运行,返回是一个有JS_TAG_FUNCTION_BYTECODE或JS_TAG_MODULEtag的对象,通过JS_EvalFunction()函数执行。JS_EVAL_FLAG_BACKTRACE_BARRIER10000xx表示在Error回溯中不需要之前的堆栈帧。
__JS_EvalInternal函数使用js_new_function_def函数创建一个顶层的函数定义节点,其第二个参数为父函数设置为NULL,后面再解析出来的函数都会成为他的子函数,js_new_function_def会返回一个初始化的JSFunctionDef。JSFunctionDef的字段parent_scope_level是当前函数在父层作用域的层级。parent_cpool_idx是当前函数在父层的常量池子里索引。child_list是子函数列表。is_eval如果为true表示当前函数代码是eval函数调用的。is_global_var表示当前函数是否不是局部的,比如是全局的,在module中或非strict。
has_home_object表示当前函数是否是homeobject。每个函数都有一个slot用来存函数的homeobject,这个slot可以通过[[HomeObject]]访问到,homeobject就是函数的初始定义。slot只有在函数定义为class或objectliterals才会被设置,其他情况会返回undefined。如下的函数定义:
letol={foo(){}}classc{foo(){}}functionf(){}上面的f函数会直接返回undefined,c是class,ol是objectliterals,当super方法被调用时会查看当前函数的[[HomeObject]],从而获取原型,通过原型调用到super方法。
has_prototype是指当前函数是否有prototype,class都有prototype。has_parameter_expressions指是否有参数表达式,如果有就会创建参数作用域。has_use_strict表示是否是强制模式。has_eval_call表示函数里是否有调用eval函数。
has_arguments_binding指函数是否有参数绑定,如果有就是箭头函数。箭头函数是使用=>定义函数,如下面代码:
//带参数varfoo=v=>v//对应如下非箭头函数varfoo=function(v){returnv}//无参数varfoo=()=>0//对应如下非箭头函数varfoo=function(){return0}箭头函数内的this是指定义时的对象,而不是运行时的对象,使this从动态变成静态。不能使用new命令,不可以用arguments对象,使用rest参数代替,不可以使用yield。
箭头函数的意图主要是可以让表达更简洁,如下代码:
//不用箭头函数varsortedGroup=group.sort(function(a,b){returna-b;});//使用箭头函数varsortedGroup=group.sort((a,b)=>a-b);箭头符号前面的小括号内是参数,代码整体看起来简洁了很多。
fix=λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))上面的fix函数可以使用箭头函数,代码如下:
varfix=f=>(x=>f(v=>x(x)(v)))(x=>f(v=>x(x)(v)));上面代码看起来更加简洁和清晰。
js_new_function_def会通过运行JS_NewAtom函数,来返回一个文件名JSAtom。JS_NewAtom函数调用链如下:
JS_NewAtom->JSNewAtomLen->JS_NewAtomStr->__JS_NewAtomJSAtom是uint32_t类型整数,用来记录关键字等字符串,作用是提高字符串内存占用和使用的效率。JSAtom的类型有JS_ATOM_TYPE_STRING、JS_ATOM_TYPE_GLOBAL_SYMBOL、JS_ATOM_TYPE_SYMBOL、JS_ATOM_TYPE_PRIVATE。JS_NewRuntime函数执行时会把quickjs-atom.h里定义的JS关键字比如if、new、try等,保留字比如class、enum、import等,标识符name、get、string等加到内存中,解析中加的会按需使用。字符串转JSAtom是通过__JS_NewAtom函数来做的,__JS_NewAtom函数会先尝试看已注册的atom里是否已经有对应的字符串,没有就根据定义的规则算出字符串的hash值i,然后把字符串指针加到JSAtomStruct类型的atom_array里。
如果已经有相同的atom在atom_array里,goto到donelabel里,C语言中的goto语句是一种把控制无条件转移到同函数内label处的语句。我觉得使用goto主要是为了减少多个return的情况,特别是逻辑多,最后需要进行统一处理时,代码上看起来会更顺,按顺序读,可读性会好。__JS_NewAtom函数出现异常就会goto到faillabel中,将i设置为JS_ATOM_NULL类型。donelabel会执行js_free_string函数,调用的是JS_FreeAtomStruct函数,目的是从atom_array里移出atom,过程是先从链表中移出atom然后在atom_array里将其标记为空闲,这样新加的atom就可以放置到这个位置。
字符串存储的结构是JSString,ascii会存放在JSString的union中str8中。在JS_NewAtomLen函数里会先将字符串通过JS_NewStringLen转成JSValue,然后通过宏JS_VALUE_GET_STRING将其转成JSString。
最后__JS_NewAtom函数会对atom_array做些边界处理。
js_new_function_def函数执行完创建了顶级函数定义后,会做个判断,如果创建失败会直接使用goto语法跳转到fail1label处,fail1label会释放所有JSModuleDef。
如果没有失败__JS_EvalInternal函数会先将JSParseState的当前函数设置为js_new_function_def创建的函数fd。然后对fd进行字段的设置。使用push_scope生成一个作用域,后面解析的内容会放到这个作用域内。接下来就开始执行解析函数js_parse_program,入参是JSParseState类型的s,s记录着fd,用来作为解析内容的输入。
js_parse_export函数和js_parse_import函数解析模块的两个主要功能,其中export主要是负责定义模块对外部的接口,import主要是负责接受外部模块的接口以供调用。JS的一个模块如果想让其他模块使用其内部的变量、函数和类就需要使用export命令,比如在某个js文件中定义可被其他模块访问的数据的代码如下:
接下来看下第三种,函数定义解析函数js_parse_function_decl。js_parse_function_decl内是执行的js_parse_function_decl2函数。js_parse_function_decl2函数会依据JSParseFunctionEnum枚举值func_type进行不同的解析和字节码生成,生成的字节码的函数是前缀为emit的函数,字节码会存在JSParseState的当前函数cur_func的byte_code字段中。func_type有statement、var、expr、arrow、getter、setter、method、类的构造函数、派生类的构造函数。其中statement、var、expr、arrow类型都是匿名函数。
创建一个新的函数,将s->cur_func作为其父函数,通过s->cur_func=fd;将JSParseState的当前函数设置为新创建的JSFunctionDef,以此能够生成函数定义树结构。然后开始解析参数,参数通过add_arg函数添加到fd的args数组中。小括号内的参数通过js_parse_expect(s,'(')函数起始,在while(s->token.val!=')')闭包内解析。小括号内如有中括号和大括号,也就是有数组和对象的情况就使用js_parse_destructuring_element函数进行解析,由于对象和数组会出现在各种js的语法中,所以将其处理包装成了一个通用的递归函数js_parse_destructuring_element用来简化写法。
设置完参数变量作用域后,就开始函数body的解析。如果是箭头函数,QuickJS会特殊处理,并在处理完直接goto到donelabel。js_parse_function_decl2函数对于箭头函数会用两个函数先做检查,一个是检查函数名函数js_parse_function_check_names,如果有函数名就直接goto到faillabel。另一个检查函数是js_parse_assign_expr赋值表达式解析函数,比如=或+=这样的符号,如果箭头函数后面是赋值表达式,那么也会直接goto到fail。如箭头函数规则符合,会使用emit_op生成OP_return字节码操作符,并用将源码保存在fd->source里。
不是箭头函数就在大括号内执行递归函数js_parse_source_element解析表达式和子函数,形成完整的函数定义树结构。递归调用代码如下:
while(s->token.val!='}'){if(js_parse_source_element(s))gotofail;}完成后也会保存源码到JSFunctionDef的source字段里。eat}符号后js_parse_function_decl2函数会goto到fail。无论是donelabel还是faillabel都会将cur_func设置为父函数,回到父函数里继续解析后面的代码。
js_parse_statement_or_decl函数主要依据token的值类型来分别调用对应的解析函数。token的值是大括号时会调用js_parse_block解析。return会看下一个token的值,如果不是;、},那么就会调用js_parse_expr函数。throw也会调用js_parse_expr,如果没有表达式就添加OP_throw字节码操作符。
if会在开始和结束分别调用作用域的进栈push_scope和出栈pop_scops函数,if内会递归调用js_parse_statement_or_decl函数。while会用new_label函数创建条件和退出的label,使用push_break_entry加到BlockEnv结构体中记录。while解析完会调用pop_break_entry函数将fd->top_break设置为BlockEnv的前一个break。while内也是递归调用js_parse_statement_or_decl函数。相比较于while,for会多调用js_parse_for_in_of去解析for/in或者for/of。break和continue都会直接goto到fail。
__JS_EvalInternal函数执行完解析函数js_parse_program后开始调用js_create_function函数来创建函数对象和所有包含的子函数。
js_create_function函数会从JSFunctionDef中创建一个函数对象和子函数,然后释放JSFunctionDef。开始会重新计算作用域的关联,通过四步将作用域和变量关联起来,方便在作用域和父作用域中查找变量。第一步遍历函数定义里的作用域,设置链表头。第二步遍历变量列表,将变量对应作用域层级链表头放到变量的scope_next里,并将变量所在列表索引放到变量对应作用域层级链表头里。第三步再遍历作用域,将没有变量的作用域指向父作用域的链表。第四步将当前作用域和父作用域变量链表连起来。通过fd->has_eval_call来看是否有调用eval,如果有,通过add_eval_variables来添加eval变量。如果函数里有eval调用,add_eval_variables函数会将eval里的闭合变量按照作用域排序。add_eval_variables函数会为eval定义一个给参数作用域用的额外的变量对象,还有需定义可能会使用的arguments,还在参数作用域加个argumentsbinding,另外,eval可以使用enclosing函数的所有变量,因此需要都加到闭包里。
js_create_function函数还会使用add_module_variables函数添加模块闭包中的全局变量。import其他模块的全局会通过js_parse_import函数作为闭包变量添加进来,add_module_variables函数是添加module里的全局变量,将模块的全局变量通过add_closure_var函数加到当前函数定义里,并处理类型是JS_EXPORT_TYPE_LOCAL的exports的变量。add_module_variables函数会先添加通过js_parse_import函数导入作为闭包变量的全局变量,添加闭包变量调用的是add_closure_var函数。遍历fd->child_list递归使用js_create_function函数创建所有子函数,将创建的子函数对象保存在fd->cpool里,cpool是一个JSValue结构体,JSValue是最基本的单位,这个结构体会有一个tag来标示JSValue的类型,值是保存在JSValueUnion里,值可以是整型和浮点,也可以是一个对象的指针,指针指向的对象是由引用计数来进行管理的,引用计数结构体是JSRefCountHeader。这里JSValue的值是个数组。
如果打开了DUMP_BYTECODE(将#defineDUMP_BYTECODE(1)这段注释打开),js_create_function函数会先进行第一次dump_byte_code函数调用,dump_byte_code函数入参tab是字节码,函数主要通过get前缀的函数,比如get_u32和get_u16来取字节码的值,并打印出来。
前面js_create_function函数dump的字节码可以称为pass1,如果只dump这个阶段字节码,将DUMP_BYTECODE设置为4,dumppass2阶段字节码需要调用resolve_variables函数。
解析当前函数的字节码的操作符,op就是当前的操作符。add_scope_var函数用来添加变量,add_scope_var函数会调用add_var函数将变量添加到函数定义JSFunctionDef的vars字段里,add_var调用完,add_scope_var函数会继续设置变量定义结构体JSVarDef的scope_level、scope_next和scope_first,其中scope的作用是在进入新作用域时能找到最临近的变量。QuickJS在解析时会生成使用变量的字节码,比如OP_scope_get_var字节码操作符。
resolve_variables函数会在需要的时候将全局变量访问转换成局部变量或闭包变量。resolve_variables函数会先做运行时检查,然后遍历所有字节码,字节码操作符是OP_eval和OP_apply_eval时将作用域索引转换成调整后的变量索引。当字节码是OP_scope_get_var_undef、OP_scope_get_var、OP_scope_put_var、OP_scope_delete_var、OP_scope_get_ref、OP_scope_put_var_init、OP_scope_make_ref时都会调用resolve_scope_var函数,在作用域里查找变量,对字节码操作符进行优化。对应QuickJS代码如下:
caseOP_scope_get_var_undef:caseOP_scope_get_var:caseOP_scope_put_var:caseOP_scope_delete_var:caseOP_scope_get_ref:caseOP_scope_put_var_init:var_name=get_u32(bc_buf+pos+1);scope=get_u16(bc_buf+pos+5);pos_next=resolve_scope_var(ctx,s,var_name,scope,op,&bc_out,NULL,NULL,pos_next);JS_FreeAtom(ctx,var_name);break;resolve_variables函数就是使用前面添加的变量。resolve_variables函数先遍历当前函数定义的作用域,如果找到对应的符号就生成对应优化的字节码操作符,没有找到会看函数内是否存在eval的调用,依然没有会到父作用域查找,还没有的话,就会在全局作用域查找,任何环节找到的处理方式是一致的。
varsay="hi!";(function(){console.log(say);})()按照变量提升规范定义,上面代码会输出hi,和你想的一样吧。如果换成下面的代码B:
varsay="hi!";(function(){console.log(say);varsay="seeuagain!"})()你先想想上面的代码会输出什么。QuickJS处理Hoisting使用的是instantiate_hoisted_definitions函数,instantiate_hoisted_definitions函数会先将函数参数和变量加到Hoisting里,闭包里会包含所有enclosing变量,如果外部有变量环境,在外部为变量创建一个属性,对应的QuickJS的instantiate_hoisted_definitions函数处理代码如下:
for(i=0;i
执行完resolve_variables后,将DUMP_BYTECODE设置为2,可以看到resolve_variables函数执行完后的字节码。下一步是执行resolve_labels函数。这个阶段是pass3
下面例子是消除冗余的loadstores。比如下面的代码:
a=b+c;d=a+e;转为下面的指令:
MOVb,R0;把b拷到寄存器ADDc,R0;添加c到寄存器,寄存器现在是b+cMOVR0,a;把R0寄存器拷给aMOVa,R0;将a拷到寄存器ADDe,R0;Addetotheregister,theregisterisnowa+e[(b+c)+e]将e添加到寄存器,现在寄存器是a+e,a是b+c,因此此时寄存器是b+c+eMOVR0,d;把寄存器拷给d上面的MOVa,RO是多余的,也就是说将a拷给寄存器是冗余步骤。
本质上peephole优化还是编译阶段的优化,只是处于一般编译后端的优化,由于QuickJS解析后是直接生成了字节码,并直接解释执行生成的字节码,所以很多编译的优化技术是用不上的,其他的优化包括有循环优化的诱导变量、强度降低、循环融合、循环反转、循环互换、循环不变的代码运动、循环嵌套优化、循环展开、循环拆分、循环解除切换、软件流水线、自动并行化等;数据流分析的常见的子表达消除、常量折叠、诱导变量识别和消除、死库消除、使用定义链、活变量分析、可用表达方式;SSA-based的全局值编号,稀疏条件常数传播;Codegeneration的寄存器分配、指令选择、指令调度、Rematerialization;函数的尾调用消除、消除中间数据结构的Deforestation;全局有过程间优化。
静态分析的优化有别名分析、指针分析、形状分析、转义分析、阵列访问分析、依赖性分析、控制流分析、数据流分析等。其他的优化包括消除边界检查、编译时函数执行、消除死代码、内联扩展、跳线程、按配置优化等。peephole优化属于baseblock级别的优化,llvm会在SSA优化时做module-scheduling和peephole优化。
下面我们来看看resolve_labels函数是怎么做peephole优化的。
resolve_labels函数先初始化伪变量,比如前面提到的new.targe和this,还有home_object、this.active_func变量,其中this变量在派生类的构造函数中是没有初始化的。随后会初始化参数变量、当前函数引用、变量环境对象。这些都会生成OP_special_object字节码操作符,并根据不同变量类型生成对应的值,类型定义枚举是OPSpecialObjectEnum。
接下来就是遍历字节码,针对不同字节码指令进行优化。
OP_line_num是用于调试的行号,记在line_num整型变量里。使用JSOpCode结构体计算栈的大小,遍历指令集时,堆栈大小通过oi->n_push-n_pop获取。OP_label操作符最终生成字节码的偏移量会被确定。OP_goto、OP_gosub、OP_if_true、OP_if_false和OP_label一样会确定最终偏移量,通过find_jump_target函数找到目标label,通过update_label函数移除jump指令。
遇到OP_null,通过code_match函数来看前一个字节码操作符如果是OP_strict_eq,那么就可以优化成OP_is_null。对于OP_push_i32,如果前字节码操作符是OP_neg,会用push_short_int函数在val前加上-符号来精简指令。OP_push_i32、OP_push_atom_value和OP_drop指令紧相邻的话可以都移出。
另外OP_to_propkey、OP_to_propkey2、OP_undefined、OP_insert2、OP_dup、OP_get_loc、OP_get_arg、OP_get_var_ref、OP_put_loc、OP_put_arg、OP_put_var_ref、OP_post_inc、OP_post_dec、OP_typeof等字节码操作符进行优化,相同的处理通过goto跳到label标签的方式来处理。有shrink、has_label、has_goto、has_constant_test等label来统一优化逻辑。主要是将长的操作数转成短操作数,合并指令,还有将操作数移到操作符里把长的字节码指令转换成短指令。
resolve_labels函数最后还会遍历一遍s->jump_slots做更多的跳转指令的优化。
resolve_labels函数执行完后,pass3阶段js_create_function还需要调用compute_stack_size函数广度优先方式来计算堆栈大小。compute_stack_size函数对会对栈有影响的指令使用ss_check检查字节码的buffer是否有字节码缓冲区溢出、堆栈溢出或堆栈大小不一致问题,并在s->stack_level_tab里记录每个位置的栈大小,然后用js_resize_array更新s->pc_stack数组。
__JS_EvalInternal函数执行完js_create_function,还会装载使用js_resolve_module函数处理当前模块所需所有模块。最后就是要开始解释执行函数对象fun_obj了。
函数对象字节码信息结构体是JSFunctionBytecode,js函数在运行时的数据结构是JSFunctionBytecode,创建函数就是初始化JSFunctionBytecode结构体,并设置里面所需的字段,这个过程就是将扫描代码生成的临时JSFunctionDef对应到JSFunctionBytecode中,由js_create_function函数负责处理。JSFunctionBytecode结构体里的byte_code_buf字段是函数对象自己字节码的指针,vardefs里是函数的参数和局部变量;closure_var是用于存放外部可见的闭包变量,closure_var通过add_closure_var函数进行添加,add_closure_var函数会把要用到的变量添加成闭包变量,通过get_closure_var2函数往上层级递归给每层级函数添加闭包变量,直到找到目标函数;stack_size是指堆栈的大小,stack_size主要作用是为初始化栈时能够减少内存占用;cpool是函数内常量池。
QuickJS解释执行的方式有五种,flag定义了JS_EVAL_FLAG_STRICT表示按照strict模式执行,JS_EVAL_FLAG_STRIP表示会按strip模式执行,JS_EVAL_FLAG_COMPILE_ONLY指只生成函数对象,不解释执行,但返回的结果的结果是带有字节码或JS_TAG_MODULE的函数对象,这个函数对象也是可以使用JS_EvalFunctionInternal来解释执行的。JS_EVAL_FLAG_BACKTRACE_BARRIER表示出现问题进行回溯时不需要之前堆栈帧。
QuickJS解释执行的函数是JS_EvalFunctionInternal,JS_EvalFunctionInteral函数会用JS_VALUE_GET_TAG来看函数对象是带字节码的JS_TAG_FUNCTION_BYTECODE还是JS_TAG_MODULE。
如果是JS_VALUE_GET_TAG是JS_TAG_MODULE表示当前函数对象是一个模块,因此需要按照模块的方式处理,JS_EvalFunctionInternal函数会先调用js_create_module_function找出模块中导出的变量,js_create_module_function函数调用链如下:
js_create_module_function->js_create_module_bytecode_function->js_create_module_var执行完js_create_module_function,会调用js_link_module函数,js_link_module函数会处理所有要导入的变量,将其保存在JSModuleDef的req_module_entries数组里供解释执行时使用。然后js_link_module还会检查间接的导入。最后将导出的变量保存在模块JSExportEntry的export_entries数组里,然后使用JS_Call函数执行导出的这些全局变量的初始化。
JS_EvalFunctionInteral函数对于tag是JS_TAG_FUNCTION_BYTECODE的函数对象会调用js_closure和JS_CallFree两个函数进行字节码的解释执行。
JS_CallInternal会对字节码的每条指令都进行解释执行。会先为变量、存放数据的栈还有参数等进行内存的分配。然后再对每条字节码指令一个一个进行解释执行,指令类型有push开头的入栈指令。goto、if打头的跳转指令。call开头的调用指令。交换类的指令有swap、nip、dup、perm、drop等。用于计算的指令有加减乘除、一元计算、逻辑计算等。处理指令时生成JSValue用的是JS_MKVAL和JS_MKPTR这两个宏。新建字符串类型用的是JS_NewString,对象是JS_NewObject,数组是JS_NewArray等。类型转换使用的函数有JS_toString来转换成字符串类型,JS_ToNumeric转换成数字,JS_ToObject转换成对象等。
force_gc=((rt->malloc_state.malloc_size+size)>rt->malloc_gc_threshold);如果定义了FORCE_GC_AT_MALLOC这个宏,每次都会调用JS_RunGC函数。JS_RunGC函数会调用三个函数,代码如下:
总的说来,GC是通过add_gc_object函数调用开始的,调用了add_gc_object函数的函数都是会涉及到GC,比如JS_CallInternal、js_create_function、JS_NewObjectFromShape等函数。其中JS_DupValueRT会对引用计数做加1操作。free_var_ref和JS_FreeValueRT函数会对引用计数减1,当减到0时会调用JS_FreeValueRT释放移除引用对象。
下面详细讲解不同类型指令怎么解释执行的。
pc指向当前正在执行的字节码地址。sp指向堆栈,堆栈存着运行时的数据,通过pc指向的操作指令进行堆栈的入栈和出栈操作。
DEF(push_i32,5,0,1,i32)上面OPCode定义中第一个参数id表示OPCode的名字,第二个size表示OPCode的字节大小,第三个n_pop表示出栈元素的数量,第四个参数n_push表示入栈元素的数量,第五个参数f表示字节码的类型。第二个参数可以看到OP_push_i32的大小是5字节,那么下个字节码需要pc指针增加4加上通过栈操作得到的偏移值1加在一起是5,因此可以移到下一个指令。
对于8位以下整数,使用的是OP_push加位数的字节码,大小是1个字节。使用JS_NewInt32进行类型转换然后入栈。8位整数使用的是OP_push_i8,8位整数放在第2个字节里,16位整数需要2个字节,所以对应的字节码是OP_push_i16。OP_push_empty_string会将JS_ATOM_empty_string这个atom使用JS_AtomToString转成JSValue入栈。OP_push_atom_value会将atom的值转成对应字符串JSValue进行入栈。当遇到OP_undefined、OP_null、OP_push_false、OP_push_true时将对应的特殊值定义,比如JS_UNDEFINED和JS_NULL入栈。
当字节码操作符是OP_fclosure或OP_fclosure8闭包时,会调用js_closure去创建闭包解释环境,解释执行包内指令。闭包就是函数和周边状态的引用合在一起的组合,内层可以访问外层作用域。比如下面这个例子:
functionadd(a){returnfunction(b){returna+b;}}上面代码可以看出,add函数内执行加法的函数能够访问到外层的入参变量a,从而和返回函数的入参变量b进行加法运算,返回相加后的值。解析时,会生成scope_get_var和scope_put_var,在执行resolve_variable函数时会将解析后的指令转换成局部变量get_loc和put_loc,自由变量get_var_ref和put_var_ref。获取局部和外层变量使用的是get_closure_var函数,get_closure_var函数会将当前函数所要用的变量记录在函数定义对象JSFunctionDef的closure_var里。变量结构是JSClosureVar,表示闭包变量信息,记录闭包里变量的各种信息,比如变量的索引、名字等,其字段is_local如果是true表示变量是局部变量,false表示是上层变量。在执行OP_fclosure字节码操作符时,会根据JSFunctionDef的closure_var生成var_refs,也就是自由变量的引用,获取这些自由变量的字节码操作符是OP_get_var_ref,是通过var_refs里的索引获取。
字节码操作符操作OP_push_this分为普通处理和严格模式处理,普通处理是直接将this指向指针入栈,再对引用计数加1。严格模式对于NULL和未定义的this会将全局对象指针入栈,如果this是一个对象,就按照普通模式处理,其他类型会尝试使用JS_ToObject转成对象,如果有异常则会goto到exceptionlabel。
drop、nip和dup开头的字节码是对sp堆栈里的顶层值进行删除和复制。insert开头的字节码是加入对象指针,perm是交换对象和值的栈层级。swap开头的是交换值的栈层级,rot是整体翻转。
call开头的字节码操作符和OP_eval会递归调用JS_CallInternal函数进行对应位置的执行。OP_call_constructor会调用JS_CallConstructorInternal函数执行构造函数指令,最终函数的指令也是通过JS_CallInternal解释执行。
创建数组,使用的是OP_array_from,对应函数是JS_NewArray,JS_NewArray函数会执行JS_NewObjectFromShape函数,参数设置类型为JS_CLASS_ARRAY,JS_NewObjectFromShape会创建一个数组对象Shape。
OP_apply会调用js_function_apply函数,如果没有参数就会直接调用JS_Call解释执行,有参数的话使用build_arg_list函数获取参数列表,对于构造函数js_function_apply会使用JS_CallConstructor2函数执行,其他的直接使用JS_Call执行。
OP_eval和OP_apply_eval会检查eval执行参数,如果没有会调用JS_EvalObject函数,JS_EvalObject函数会依据val参数是否是字符串决定是直接返回val地址还是调用JS_EvalInternal解析字符串。
OP_regexp会使用js_regexp_constructor_internal函数创建一个正则函数对象放到堆栈中。OP_get_super会使用JS_GetPrototype函数来获取原型对象放到堆栈中。OP_import是动态获取import内容,使用js_new_promise_capability函数检查是否是promise,然后调用JS_CallConstructor函数执行。
对于全局变量存取字节码,是调用的JS_GetGlobalVar和JS_SetGlobalVar函数。全局变量和函数的定义使用的是JS_DefineGlobalVar和JS_DefineGlobalFunction函数。局部变量和参数put使用的是JS_DupValue函数入栈,set使用的是set_value函数。对全局变量、局部变量和参数后加check的指令是在入栈或设置值前会用JS_IsUninitialized函数来判断他们是否是JS_TAG_UNINITIALIZED,在他们之前加make表示在运行时添加对象,会先用JS_NewObjectProto创建含JSShape的JSValue入栈,在栈里再取出JSObject设置变量引用,再入栈变量的atom。OP_close_loc用的是close_lexical_var来取消局部变量的引用。
goto这样的字节码指令在quickjs-opcode.h里定义的值类型都是label,通过取出指令对应label的偏移量,让pc加上偏移量直接跳转到对应字节码。OP_if_true有五个字节,因此pc+=4为下个指令指针地址。从sp栈里取出栈顶op,使用JS_VALUE_GET_TAG取出tag值,判断小于JS_TAG_UNDEFINED就执行下个指令。这里JS_TAG_UNDEFINED和其他tag类型都定义在一个匿名枚举里,有引用计数的类型都是负数,比如JS_TAG_BIG_INT-10、JS_TAG_STRING-7、JS_TAG_MODULE-3、JS_TAG_FUNCTION_BYTECODE-2、JS_TAG_OBJECT-1。整数、布尔值、NULL类型也满足小于JS_TAG_UNDEFINED条件。OP_if_false执行下条指令逻辑和OP_if_true相反。
DEF(for_in_start,1,1,1,none)DEF(for_of_start,1,1,3,none)DEF(for_await_of_start,1,1,3,none)DEF(for_in_next,1,1,3,none)DEF(for_of_next,2,3,5,u8)从上述字节码定义里可以看到,定义的字节码对应的是JavaScript的for...in和for...of语法。for...in是用在对象的遍历,会遍历所有对象以字符串为键的枚举属性,也包括继承的属性。for...of语句是在可迭代对象上创建迭代的循环,这样的对象有字符串、数组、参数、NodeList、TypedArray、Map、Set等。for...in会通过build_for_in_iterator函数来处理,for...of会用JS_GetIterator函数里的JS_CreateAsyncFromSyncIterator函数创建所需的迭代的循环。
获取属性的字节码操作符是OP_get_field,JS_CallInternal函数会调用的是JS_GetProperty。OP_put_field是设置类属性,JS_CallInternal函数会调用JS_SetPropertyInternal函数。创建一个新属性使用的是OP_define_field字节码操作符。私有属性的获取、设置、定义字节码调用的函数分别是JS_GetPrivateField、JS_SetPrivateField、JS_DefinePrivateField。属性获取会调用的是find_own_property函数,设置属性会在调用属性基础上多调用set_value函数对属性值进行设置,定义会多调用add_property函数,add_property函数会先检查是否先前已经定义过,如果没有就会调用add_shape_property函数新建属性。删除属性会调用delete_property函数。
设置对象操作符是OP_set_name和OP_set_name_computed。其中OP_set_name_computed用来设置计算的属性名,计算的属性名语法是在ECMAScript2015标准里开始支持的。该语法允许在[]中加入表达式,这个表达式会将计算结果作为属性名。比如下面js代码:
constnum=1consto={['k'+num]:1}这样就可以直接使用o.k1来获取k1键对应的值1。计算的属性名也可以用模板字元,代码如下:
constnum=1consto={[`k${num}`]:1}QuickJS对对象设置和计算的属性名对象设置的函数分别是JS_DefineObjectName和JS_DefineObjectNameComputed,他们都会通过JS_isObject函数检查是否是对象类型,js_object_has_name函数判断是否是有键名对象,JS_DefinePropertyValue函数设置属性值。不同的是计算的属性名会调用js_get_function_name来处理属性名计算问题。
定义方法字节码操作符是OP_define_method,先从堆栈取出持有方法的对象,然后再调用js_method_set_properties函数设置方法为对象的属性,然后通过JS_DefineProperty来定义设置的属性。
定义类使用的是js_op_define_class函数,js_op_define_class会先处理有父类和无父类的情况,调用JS_NewObjectProtoClass函数创建类对象,返回JSValue,将这个值传入js_closure2获取类的详细信息,包括类对应的字节码、home_object、变量等。js_method_set_home_object函数设置home_object,使用JS_DefinePropertyValue来定义属性值,计算的属性用JS_DefineObjectNameComputed函数,构造函数属性必须是第一个,构造函数属性可以被计算的属性覆盖。
定义数组元素使用字节码操作符是OP_define_array_el,调用JS_DefinePropertyValueValue函数,插入数组元素用的是OP_append,调用js_append_enumerate函数。获取对象的属性和设置对象的属性函数分别是JS_GetPropertyValue和JS_SetPropertyValue函数。对象的属性其实在js语法中被包装成不同的概念,比如数组的属性其实就是数组的元素,因此下面这些字节码都会用到获取对象属性和设置对象属性的函数:
OP_to_object字节码操作符会根据栈中定义对象类型来将一些非对象类型转为对象,调用的是JS_ToObject函数,内主要是调用JS_NewObjectFromShape函数来处理,类型class_id来区分不同的数据类型。OP_to_propkey和OP_to_propkey2是将键值的类型转为键可支持的字节码操作符,其主要处理就是除了整型、字符串和symbol外类型通过JS_ToStringInternal函数转化为字符JSValue类型。