简要学习V8 Bytecode的组成以及执行方式。
纵览各个JavaScript引擎的实现,我们发现基于字节码的实现是主流。
- 苹果公司的JavaScriptCore(JSC) 引擎实现了一个寄存器机(Register Machine)
- Mozilla 公司的SpiderMonkey引擎实现了堆栈机(Stack Machine)
- 微软的 Chakra 实现的是寄存器机(Register Machine)
字节码是机器代码的抽象。如果字节码采用和物理 CPU 相同的计算模型进行设计,则将字节码编译为机器代码更容易。这就是为什么解释器(Interpreter)常常是寄存器或堆栈。
但在早些年前,V8引擎选择了直接将 JS 代码编译到机器代码执行。虽然使其执行性能上登峰造极,但却带来了内存占用过大的问题。
2017年,V8引擎为解决由此引发的问题,引入了Ignition。
Ignition是一个具有累加器的寄存器机(Register Machine)。
从源码到解释器
v8引擎拥有众多子模块,这里列出其四大重要组成:
- Parser:将JavaScript源码转换为Abstract Syntax Tree (AST)
- Ignition:Interpreter,即解释器,将AST转换为Bytecode,解释执行Bytecode;同时收集TurboFan优化编译所需的信息
- TurboFan:Compiler,即编译器,TurboFan利用Ignition所收集的类型信息,将Bytecode转换为优化的汇编代码
- Orinoco:Garbage Collector,垃圾回收模块,负责将程序不再需要的内存空间回收;
Parser将JavaScript源码解析为AST,而后Ignition将AST转换为Bytecode,最后经TurboFan将Bytecode转换为经其优化的Machine Code。
其流水线图清晰的展示了引擎执行流程。
Ignition Bytecode
最直观的,我们希望能够通过一个简单的方式来观察语句对应的字节码。
下面是一个极其简单的代码块。
undefined;
我们可以通过V8提供的d8添加--print-bytecode
来打印出对应字节码。
编译d8
下载编译工具链
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
导入环境变量
export PATH=`pwd`/depot_tools:"$PATH"
拉取v8源码,这里使用版本为10.8.79
gclient sync fetch v8 git pull origin git checkout 10.8.79
为方便查看字节码,编译时开启反汇编以及常量显示
tools/dev/v8gen.py x64.release -- \ > v8_enable_disassembler=true \ > v8_enable_object_print=true ninja -C out.gn/x64.release d8
简单测试
out.gn/x64.release/d8 --print-bytecode -e "1;"
得到字节码输出
[generated bytecode for function: (0x1bf600199c39 <SharedFunctionInfo>)] Bytecode length: 4 Parameter count 1 Register count 1 Frame size 8 Bytecode age: 0 0x1bf600199c9e @ 0 : 0d 01 LdaSmi [1] 0x1bf600199ca0 @ 2 : c5 Star0 0x1bf600199ca1 @ 3 : aa Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0)
Accumulator Load Instruction
上文提到,Ignition是一个具有累加器的寄存器机,这意味Ignition中必然存在特殊的指令来操纵累加器。
Ignition拥有16个通用寄存器,这里将其称为r0
–r15
我们可以在https://github.com/v8/v8/blob/master/src/interpreter/bytecodes.h看到相关定义。
// The list of single-byte Star variants, in the format of BYTECODE_LIST. #define SHORT_STAR_BYTECODE_LIST(V) \ V(Star15, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star14, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star13, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star12, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star11, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star10, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star9, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star8, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star7, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star6, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star5, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star4, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star3, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star2, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star1, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \ V(Star0, ImplicitRegisterUse::kReadAccumulatorWriteShortStar)
以及一个累加器寄存器,这里简称为acc
(Accumulator Rigister)。
从上面的测试中,我们就能发现三条指令,这里先给出指令解析。
Lda*
我们可以将其理解为Load * into accumulator register
例如:
LdaUndefined
,即将undefined
放入Accumulator RigisterLdaSmi [10]
,即将10
放入Accumulator Rigister
Star*
我们可以将此指令理解为Store accumulator register’s value into *
例如:
Star0
,即将Accumulator Rigister上的值放入r0
寄存器。Star3
,即将Accumulator Rigister上的值放入r3
寄存器。
Return
Return返回acc中的内容。
下面详述部分累加器操作指令
Oddball
Oddball类型包含以下
- null
- undefined
- true
- false
我们从一个最小语句开始:
out.gn/x64.release/d8 --print-bytecode -e "undefined;"
得到以下输出:
0x102300199c9e @ 0 : 0e LdaUndefined 0x102300199c9f @ 1 : c5 Star0 0x102300199ca0 @ 2 : aa Return
在这样一条语句中,字节码表示的伪代码如下
acc = undefined; r0 = acc; return acc;
明显的,其返回值即undefined
null
out.gn/x64.release/d8 --print-bytecode -e "null;"
对应的字节码如下:
0x1a3900199c9e @ 0 : 0f LdaNull
布尔类型
键入true
out.gn/x64.release/d8 --print-bytecode -e "true;"
得到以下输出:
0x3c100199c9e @ 0 : 11 LdaTrue 0x3c100199c9f @ 1 : c5 Star0 0x3c100199ca0 @ 2 : aa Return
键入false
out.gn/x64.release/d8 --print-bytecode -e "false;"
得到以下输出:
0x146f00199c9e @ 0 : 12 LdaFalse 0x146f00199c9f @ 1 : c5 Star0 0x146f00199ca0 @ 2 : aa Return
关键指令分别为LdaTrue
和LdaFalse
,其返回值为true
和false
Small integers
首先尝试0
out.gn/x64.release/d8 --print-bytecode -e "0;"
得到以下输出:
0x2c8f00199c9e @ 0 : 0c LdaZero 0x2c8f00199c9f @ 1 : c5 Star0 0x2c8f00199ca0 @ 2 : aa Return
与Oddball类型相似,关键指令为LdaZero
当我们输入单字节大小以内且大于1的数字时
out.gn/x64.release/d8 --print-bytecode -e "10;"
得到以下输出:
0x1f200199c9e @ 0 : 0d 0a LdaSmi [10] 0x1f200199ca0 @ 2 : c5 Star0 0x1f200199ca1 @ 3 : aa Return
不同于0,关键指令变为LdaSmi
,显然[10]
中的10
为立即数。
即LdaSmi
指令占用两字节,0d
为操作码,0a
为立即数。
当我们输入双字节大小的整数时
out.gn/x64.release/d8 --print-bytecode -e "1000;"
得到以下输出:
0xd3b00199c9e @ 0 : 00 0d e8 03 LdaSmi.Wide [1000] 0xd3b00199ca2 @ 4 : c5 Star0 0xd3b00199ca3 @ 5 : aa Return
指令由LdaSmi
变为了LdaSmi.Wide
,占用了4字节,00 0d
为操作码,e8 03
为立即数。
同样的,当我们输入四字节大小的整数时
out.gn/x64.release/d8 --print-bytecode -e "100000;"
得到以下输出:
0x16cc00199c9e @ 0 : 01 0d 40 42 0f 00 LdaSmi.ExtraWide [1000000] 0x16cc00199ca4 @ 6 : c5 Star0 0x16cc00199ca5 @ 7 : aa Return
指令为LdaSmi.ExtraWide
,占用了6字节,01 0d
为操作码,40 42 0f 00
为立即数。
可以看到,Ignition字节码的操作数宽度能够自由伸缩,下图展示了其指令内存排布。
HeapNumber
浮点数如何表示呢?
out.gn/x64.release/d8 --print-bytecode -e "10.03;"
得到以下输出:
[generated bytecode for function: (0x2ea900199c39 <SharedFunctionInfo>)] Bytecode length: 4 Parameter count 1 Register count 1 Frame size 8 Bytecode age: 0 0x2ea900199cb6 @ 0 : 13 00 LdaConstant [0] 0x2ea900199cb8 @ 2 : c5 Star0 0x2ea900199cb9 @ 3 : aa Return Constant pool (size = 1) 0x2ea900199c7d: [FixedArray] in OldSpace - map: 0x2ea900002229 <Map(FIXED_ARRAY_TYPE)> - length: 1 0: 0x2ea900199c89 <HeapNumber 10.03> Handler Table (size = 0) Source Position Table (size = 0)
不同于整数类型,在Ignition字节码中,浮点数被放置在堆(Heap)上,指令为LdaConstant
。
LdaConstant [0]
即意味着从Constant pool读取索引为0的元素,即<HeapNumber 10.03>
。
下图展示了LdaConstant
指令的取指过程
简单的总结一下:
- String以及HeapNumber总是存储在Constant pool中
- Small integers和Oddball由字节码直接加载
Closure
关于闭包的构成,如下所示:
+-------------+ | Closure |-------+-------------------+--------------------+ +-------------+ | | | ↓ ↓ ↓ +-------------+ +--------------------+ +-----------------+ | Context | | SharedFunctionInfo | | Feedback Vector | +-------------+ +--------------------+ +-----------------+ | | Invocation Count| | +-----------------+ | | Optimized Code | | +-----------------+ | | Binary Op | | +-----------------+ | | +-----------------+ +-----------> | Byte Code | +-----------------+
- Closure:函数闭包包含 Context, SharedFunctionInfo 和FeedbackVector
- Context:包含函数的自由变量,并提供对全局对象的访问
自由变量是既不是函数的局部变量也不是函数的参数变量,即它们在函数的作用域中,但在函数之外声明。
- SharedFunctionInfo:存储函数的一般信息,如源位置和字节码
- FeedbackVector: 存储编译器优化所需的信息
Variable Definition
let
我们首先在代码块内定义一个变量
out.gn/x64.release/d8 --print-bytecode -e "{let a=1;}"
有下列输出:
0x202a00199c9e @ 0 : 0d 01 LdaSmi [1] 0x202a00199ca0 @ 2 : c4 Star1 0x202a00199ca1 @ 3 : 0e LdaUndefined 0x202a00199ca2 @ 4 : aa Return
其伪代码表示为:
acc = 1; r1 = acc; acc = undefined; return acc;
可以看到在代码块内,使用let
定义变量,其值存储在寄存器中。
但上文提到,通用寄存器仅16个,当变量定义超过16个时,其字节码又如何表示呢?
out.gn/x64.release/d8 --print-bytecode -e "{let a=1,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q;}"
有下列输出:
0x2aa500199c9e @ 0 : 0d 01 LdaSmi [1] 0x2aa500199ca0 @ 2 : c4 Star1 0x2aa500199ca1 @ 3 : 0e LdaUndefined 0x2aa500199ca2 @ 4 : c3 Star2 ··· 0x2aa500199cbb @ 29 : 0e LdaUndefined 0x2aa500199cbc @ 30 : b6 Star15 0x2aa500199cbd @ 31 : 0e LdaUndefined 0x2aa500199cbe @ 32 : 18 ea Star r16 0x2aa500199cc0 @ 34 : 0e LdaUndefined 0x2aa500199cc1 @ 35 : 18 e9 Star r17 0x2aa500199cc3 @ 37 : 0e LdaUndefined 0x2aa500199cc4 @ 38 : aa Return
可以看到,当变量数超出通用寄存器的数量时,Ignition转而申请一个临时寄存器r16
,并使用Star
指令将值放置在临时寄存器中。
如果我们在全局环境下let
定义变量,其表现又与闭包不同。
out.gn/x64.release/d8 --print-bytecode -e "let a=1, b=2;"
输出有以下:
0x39b300199cae @ 0 : 0d 01 LdaSmi [1] 0x39b300199cb0 @ 2 : 25 02 StaCurrentContextSlot [2] 0x39b300199cb2 @ 4 : 0d 02 LdaSmi [2] 0x39b300199cb4 @ 6 : 25 03 StaCurrentContextSlot [3] 0x39b300199cb6 @ 8 : 0e LdaUndefined 0x39b300199cb7 @ 9 : aa Return
这里出现了一条新的指令StaCurrentContextSlot
,意为将变量定义放置在当前上下文的作用域内。
var
首先来观察下列
out.gn/x64.release/d8 --print-bytecode -e "var a=2;"
得到以下:
[generated bytecode for function: (0x06d600199c39 <SharedFunctionInfo>)] Bytecode length: 18 Parameter count 1 Register count 3 Frame size 24 Bytecode age: 0 0x6d600199cba @ 0 : 13 00 LdaConstant [0] 0x6d600199cbc @ 2 : c4 Star1 0x6d600199cbd @ 3 : 19 fe f8 Mov <closure>, r2 0x6d600199cc0 @ 6 : 66 5e 01 f9 02 CallRuntime [DeclareGlobals], r1-r2 0x6d600199cc5 @ 11 : 0d 02 LdaSmi [2] 0x6d600199cc7 @ 13 : 23 01 00 StaGlobal [1], [0] 0x6d600199cca @ 16 : 0e LdaUndefined 0x6d600199ccb @ 17 : aa Return Constant pool (size = 2) 0x6d600199c89: [FixedArray] in OldSpace - map: 0x06d600002229 <Map(FIXED_ARRAY_TYPE)> - length: 2 0: 0x06d600199c7d <FixedArray[1]> 1: 0x06d600004075 <String[1]: #a> Handler Table (size = 0) Source Position Table (size = 0)
其伪代码可以表示为:
acc = Constant[0]; r1 = acc; r2 = <closure>; DeclareGlobals(r1, r2); acc = 2; global[1] = acc; acc = undefined; return acc;
LdaConstant [0]
:从Constant pool取出下标为0的常量放入累加器中Star1
:将累加器中的内容存放到r1寄存器中Mov <closure>, r2
:将<closure>
的上下文内容放到r2
寄存器中CallRuntime [DeclareGlobals], r1-r2
:调用Runtime函数DeclareGlobals,参数为r1
和r2
寄存器StaGlobal [1], [0]
:将acc中的内容放到全局定义中,这里需要注意[0]
代表反馈向量槽(FeedbackVector Slot)中相应的位置
当我们在代码块中使用var
定义时,其字节码与上述无异。
out.gn/x64.release/d8 --print-bytecode -e "{var a=1}"
有以下输出:
[generated bytecode for function: (0x269900199c39 <SharedFunctionInfo>)] Bytecode length: 18 Parameter count 1 Register count 3 Frame size 24 Bytecode age: 0 0x269900199cba @ 0 : 13 00 LdaConstant [0] 0x269900199cbc @ 2 : c4 Star1 0x269900199cbd @ 3 : 19 fe f8 Mov <closure>, r2 0x269900199cc0 @ 6 : 66 5e 01 f9 02 CallRuntime [DeclareGlobals], r1-r2 0x269900199cc5 @ 11 : 0d 01 LdaSmi [1] 0x269900199cc7 @ 13 : 23 01 00 StaGlobal [1], [0] 0x269900199cca @ 16 : 0e LdaUndefined 0x269900199ccb @ 17 : aa Return Constant pool (size = 2) 0x269900199c89: [FixedArray] in OldSpace - map: 0x269900002229 <Map(FIXED_ARRAY_TYPE)> - length: 2 0: 0x269900199c7d <FixedArray[1]> 1: 0x269900004075 <String[1]: #a> Handler Table (size = 0) Source Position Table (size = 0)
但当我们在闭包内定义,就有所差异了。
这里要注意v8采用了懒加载技术(lazy loading),简单的来讲当我们定义一个函数而不去调用它,那么这个函数并不会加载。
因此为达到我们的实验目的,我们需要主动调用它,使其正常加载:
out.gn/x64.release/d8 --print-bytecode -e "function test(){var a=1} test();"
省略外部调用字节码,函数内部字节码有以下:
··· [generated bytecode for function: test (0x15a600199c91 <SharedFunctionInfo test>)] Bytecode length: 5 Parameter count 1 Register count 1 Frame size 8 Bytecode age: 0 0x15a600199e12 @ 0 : 0d 01 LdaSmi [1] 0x15a600199e14 @ 2 : c5 Star0 0x15a600199e15 @ 3 : 0e LdaUndefined 0x15a600199e16 @ 4 : aa Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0)
可以发现在闭包内,var
定义等同于let
在闭包内的定义。
这恰恰证明了在ES6标准中新增的let
与var
的区别。
var
声明限定于函数作用域和全局作用域let
在var
的基础上添加了块级作用域声明
const
当然,const
也是ES6标准中新增的内容,用于常量定义。和let
一样,const
也能声明在块级作用域。
在块级作用域中声明:
out.gn/x64.release/d8 --print-bytecode -e "{const a=1}"
有以下输出:
[generated bytecode for function: (0x327f00199c39 <SharedFunctionInfo>)] Bytecode length: 5 Parameter count 1 Register count 2 Frame size 16 Bytecode age: 0 0x327f00199c9e @ 0 : 0d 01 LdaSmi [1] 0x327f00199ca0 @ 2 : c4 Star1 0x327f00199ca1 @ 3 : 0e LdaUndefined 0x327f00199ca2 @ 4 : aa Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0)
其字节码与let
定义无异;在全局作用域中,与let
仍旧无异,这里不再赘述。
既然字节码无异,继而产生了一个问题:如何区分常量与变量?
首先来观察变量:
out.gn/x64.release/d8 --print-bytecode -e "let a=1; a=2"
有以下输出:
[generated bytecode for function: (0x355b00199c39 <SharedFunctionInfo>)] Bytecode length: 10 Parameter count 1 Register count 1 Frame size 8 Bytecode age: 0 0x355b00199ca6 @ 0 : 0d 01 LdaSmi [1] 0x355b00199ca8 @ 2 : 25 02 StaCurrentContextSlot [2] 0x355b00199caa @ 4 : 0d 02 LdaSmi [2] 0x355b00199cac @ 6 : 25 02 StaCurrentContextSlot [2] 0x355b00199cae @ 8 : c5 Star0 0x355b00199caf @ 9 : aa Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0)
再来观察常量:
out.gn/x64.release/d8 --print-bytecode -e "const a=1; a=2"
有以下输出:
[generated bytecode for function: (0x08ae00199c39 <SharedFunctionInfo>)] Bytecode length: 13 Parameter count 1 Register count 1 Frame size 8 Bytecode age: 0 0x8ae00199ca6 @ 0 : 0d 01 LdaSmi [1] 0x8ae00199ca8 @ 2 : 25 02 StaCurrentContextSlot [2] 0x8ae00199caa @ 4 : 0d 02 LdaSmi [2] 0x8ae00199cac @ 6 : 66 70 01 fa 00 CallRuntime [ThrowConstAssignError], r0-r0 0x8ae00199cb1 @ 11 : c5 Star0 0x8ae00199cb2 @ 12 : aa Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0)
可以发现为常量再次赋值时,Ignition将会调用运行时方法ThrowConstAssignError
抛出错误。
显而易见,在Js代码转换为字节码的过程中,其对常量便做了预处理。
Arithmetic Instruction
Unary Operators
指令 | 实例 |
---|---|
Inc | a++ |
Dec | a-- |
Negate | -a |
BitwiseNot | ~a |
ToBooleanLogicalNot | !a |
LogicalNot | !a |
TypeOf | typeof a |
DeletePropertyStrict | delete a[0] (严格模式) |
DeletePropertySloppy | delete a[0] |
Binary Operators
指令 | 实例 |
---|---|
Add | a + b |
Sub | a - b |
Mul | a * b |
Div | a / b |
Mod | a % b |
BitwiseOr | a | b |
BitwiseXor | a ^ b |
BitwiseAnd | a & b |
ShiftLeft | a << b |
ShiftRight | a >> b |
ShiftRightLogical | a >>> b |
More
- Branch Jump Instruction
- Effectful Test Operators
- Control Flow
- Creation and Invocation of Closures
······
更多关于字节码可以参考https://github.com/v8/v8/blob/master/src/interpreter/bytecodes.h,其定义给出了较为详细的字节码operand以及operandType
Simple v8 Bytecode Interpreter
这是一个简易的v8字节码的执行器(非反序列化),有助于我们理解字节码简要的执行流程。
发表回复