V8 Ignition 解释器的内部实现探究¶
背景¶
V8 是一个很常见的 JavaScript 引擎,运行在很多的设备上,因此想探究一下它内部的部分实现。本博客在 ARM64 Ubuntu 24.04 平台上针对 V8 12.8.374.31 版本进行分析。本博客主要分析了 V8 的 Ignition 解释器的解释执行部分。
编译 V8¶
首先简单过一下 v8 的源码获取以及编译流程,主要参考了 Checking out the V8 source code 和 Compiling on Arm64 Linux:
# setup depot_tools
cd ~
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=$PWD/depot_tools:$PATH
# clone v8 repos
mkdir ~/v8
cd ~/v8
fetch v8
cd v8
# switch to specified tag
git checkout 12.8.374.31
gclient sync --verbose
# install dependencies
./build/install-build-deps.sh
# install llvm 19
wget https://mirrors.tuna.tsinghua.edu.cn/llvm-apt/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 19 -m https://mirrors.tuna.tsinghua.edu.cn/llvm-apt
rm llvm.sh
sudo apt install -y lld clang-19
# fix incompatibilities with system clang 19
sed -i "s/-Wno-missing-template-arg-list-after-template-kw//" build/config/compiler/BUILD.gn
# compile v8 using system clang 19
# for amd64: use x64.optdebug instead of arm64.optdebug
tools/dev/gm.py arm64.optdebug --progress=verbose
# d8 is compiled successfully at
./out/arm64.optdebug/d8
如果不想编译 V8,也可以直接用 Node.JS 来代替 d8
。不过 Node.JS 会加载很多 JS 代码,使得输出更加复杂,此时就需要手动过滤一些输出,或者通过命令行设置一些打印日志的过滤器。另外,后面有一些深入的调试信息,需要手动编译 V8 才能打开,因此还是建议读者上手自己编译一个 V8。
在 AMD64 上,默认会使用 V8 自带的 LLVM 版本来编译,此时就不需要额外安装 LLVM 19,也不需要修改 v8/build/config/compiler/BUILD.gn
。
解释器和编译器¶
通过 V8 的文档可以看到,V8 一共有这些解释器或编译器,按照其优化等级从小到大的顺序:
- Ignition: 解释器
- SparkPlug: 不优化的快速编译器,追求快的编译速度
- Maglev:做优化的编译器,寻求编译速度和编译质量的平衡
- TurboFan:做优化的编译器,寻求更好的编译质量
在 JS 的使用场景,不同代码被调用的次数以及对及时性的需求差别很大,为了适应不同的场景,V8 设计了这些解释器和编译器来提升整体的性能:执行次数少的代码,倾向于用更低优化等级的解释器或编译器,追求更低的优化开销;执行次数多的代码,当编译优化时间不再成为瓶颈,则倾向于用更高优化等级的编译器,追求更高的执行性能。
Ignition 解释器¶
分析样例 JS 代码¶
首先来观察一下 Ignition 解释器的工作流程。写一段简单的 JS 代码:
保存为 test.js
,运行 ./out/arm64.optdebug/d8 --print-ast --print-bytecode test.js
以打印它的 AST 以及 Bytecode:
首先开始的是 top level 的 AST 以及 Bytecode,它做的事情就是:声明一个函数 add,然后以参数 (1, 2)
来调用它。
top level AST:
[generating bytecode for function: ]
--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . FUNCTION "add" = function add
. EXPRESSION STATEMENT at 42
. . kAssign at -1
. . . VAR PROXY local[0] (0xc28698556308) (mode = TEMPORARY, assigned = true) ".result"
. . . CALL
. . . . VAR PROXY unallocated (0xc28698556200) (mode = VAR, assigned = true) "add"
. . . . LITERAL 1
. . . . LITERAL 2
. RETURN at -1
. . VAR PROXY local[0] (0xc28698556308) (mode = TEMPORARY, assigned = true) ".result"
首先声明了一个 add
函数,然后以 1
和 2
两个参数调用 add
函数,把结果绑定给局部变量 .result
,最后以 .result
为结果返回。接下来看它会被翻译成什么字节码:
[generated bytecode for function: (0x1f8e002988f5 <SharedFunctionInfo>)]
Bytecode length: 28
Parameter count 1
Register count 4
Frame size 32
0x304700040048 @ 0 : 13 00 LdaConstant [0]
0x30470004004a @ 2 : c9 Star1
0x30470004004b @ 3 : 19 fe f7 Mov <closure>, r2
0x30470004004e @ 6 : 68 63 01 f8 02 CallRuntime [DeclareGlobals], r1-r2
0x304700040053 @ 11 : 21 01 00 LdaGlobal [1], [0]
0x304700040056 @ 14 : c9 Star1
0x304700040057 @ 15 : 0d 01 LdaSmi [1]
0x304700040059 @ 17 : c8 Star2
0x30470004005a @ 18 : 0d 02 LdaSmi [2]
0x30470004005c @ 20 : c7 Star3
0x30470004005d @ 21 : 66 f8 f7 f6 02 CallUndefinedReceiver2 r1, r2, r3, [2]
0x304700040062 @ 26 : ca Star0
0x304700040063 @ 27 : af Return
Constant pool (size = 2)
0x304700040011: [TrustedFixedArray]
- map: 0x1f8e00000595 <Map(TRUSTED_FIXED_ARRAY_TYPE)>
- length: 2
0: 0x1f8e00298945 <FixedArray[2]>
1: 0x1f8e000041dd <String[3]: #add>
Handler Table (size = 0)
Source Position Table (size = 0)
V8 的字节码采用的是基于寄存器的执行模型,而非其他很多字节码会采用的栈式。换句话说,每个函数有自己的若干个寄存器可供操作。每条字节码分为 Opcode(表示这条字节码要进行的操作)和操作数两部分。函数开头的 Register count 4
表明该函数有四个寄存器:r0-r3
,此外还有一个特殊的 accumulator
寄存器,它一般不会出现在操作数列表中,而是隐含在 Opcode 内(Lda/Sta
)。
完整的 Opcode 列表可以在 v8/src/interpreter/bytecodes.h
中找到,对应的实现可以在 v8/src/interpreter/interpreter-generator.cc
中找到。
上述字节码分为两部分,第一部分是声明 add
函数:
LdaConstant [0]
: 把 Constant Pool 的第 0 项也就是FixedArray[2]
写入accumulator
寄存器当中Star1
: 把accumulator
寄存器的值拷贝到r1
寄存器,结合上一条字节码,就是设置r1 = FixedArray[2]
Mov <closure>, r2
: 把<closure>
拷贝到r2
寄存器,猜测这里的<closure>
对应的是add
函数CallRuntime [DeclareGlobals], r1-r2
: 调用运行时的DeclareGlobals
函数,并传递两个参数,分别是r1
和r2
;有意思的是,CallRuntime
的参数必须保存在连续的寄存器当中,猜测是为了节省编码空间
至此,add
函数就声明完成了。接下来,就要实现 add(1, 2)
的调用:
LdaGlobal [1], [0]
: 把 Constant Pool 的第 1 项也就是"add"
这个字符串写入accumulator
,最后的[0]
和 FeedBackVector 有关,目前先忽略Star1
: 把accumulator
寄存器的值拷贝到r1
寄存器,结合上一条字节码,就是设置r1 = "add"
LdaSmi [1]
: 把小整数(Small integer, Smi)1
写入accumulator
Star2
: 把accumulator
寄存器的值拷贝到r2
寄存器,结合上一条字节码,就是设置r2 = 1
LdaSmi [2]
: 把小整数(Small integer, Smi)2
写入accumulator
Star3
: 把accumulator
寄存器的值拷贝到r3
寄存器,结合上一条字节码,就是设置r3 = 2
CallUndefinedReceiver2 r1, r2, r3, [2]
: 根据r1
调用一个函数,并传递两个参数r2, r3
(函数名称最后的2
表示有两个参数),最后的[2]
也和 FeedBackVector 有关
这样就完成了函数调用。
接下来观察 add
函数的 AST:
[generating bytecode for function: add]
--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. INFERRED NAME ""
. PARAMS
. . VAR (0xc50512445280) (mode = VAR, assigned = false) "a"
. . VAR (0xc50512445300) (mode = VAR, assigned = false) "b"
. DECLS
. . VARIABLE (0xc50512445280) (mode = VAR, assigned = false) "a"
. . VARIABLE (0xc50512445300) (mode = VAR, assigned = false) "b"
. RETURN at 25
. . kAdd at 34
. . . VAR PROXY parameter[0] (0xc50512445280) (mode = VAR, assigned = false) "a"
. . . VAR PROXY parameter[1] (0xc50512445300) (mode = VAR, assigned = false) "b"
add
函数的 AST 比较直接,a + b
直接对应了 kAdd
结点,直接作为返回值。
接下来观察 add
的 Bytecode:
[generated bytecode for function: add (0x10c700298955 <SharedFunctionInfo add>)]
Bytecode length: 6
Parameter count 3
Register count 0
Frame size 0
0x6ed0004008c @ 0 : 0b 04 Ldar a1
0x6ed0004008e @ 2 : 3b 03 00 Add a0, [0]
0x6ed00040091 @ 5 : af Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)
Ldar a1
: 把第二个参数a1
也就是b
写入accumulator
寄存器Add a0, [0]
: 求第一个参数a0
也就是a
与accumulator
寄存器的和,写入到accumulator
寄存器当中,结合上一条 Bytecode,就是accumulator = a0 + a1
;[0]
和 FeedBackVector 有关Return
: 把accumulator
中的值作为返回值,结束函数调用
简单小结一下 V8 的字节码:
- 有若干个局部的寄存器,在操作数中以
rn
的形式出现,n
是寄存器编号 - 有
accumulator
局部寄存器,作为部分字节码的隐含输入或输出(Add
) - 有若干个参数,在操作数中以
an
的形式出现,n
是参数编号 - 操作数还可以出现立即数参数
[imm]
,可能是整数字面量(LdaSmi
),可能是下标(LdaConstant
),也可能是 FeedBackVector 的 slot
有了字节码以后,接下来观察 Ignition 具体是怎么解释执行这些字节码的。
解释执行¶
为了实际执行这些字节码,Ignition 的做法是:
- 给每种可能的 Opcode 生成一段二进制代码,这段代码会实现这个 Opcode 的功能
- 在运行时维护一个 dispatch table,维护了 Opcode 到二进制代码地址的映射关系
- 在每段代码的结尾,找到下一个 Opcode 对应的代码的地址,然后跳转过去
- 调用函数时,先做一系列的准备,找到函数第一个字节码的 Opcode 对应的代码的地址,跳转过去
由于 Opcode 的种类是固定的,所以实际运行 V8 的时候,这些代码已经编译好了,只需要在运行时初始化对应的数据结构即可。这个代码的生成和编译过程,也不是由 C++ 编译器做的,而是有一个 mksnapshot
命令来完成初始化,你可以认为它把这些 Opcode 对应的汇编指令都预先生成好,运行时直接加载即可。
首先来看 Ignition 的怎么实现各种 Opcode 的,以 LdaSmi
为例,它的作用是小的把立即数(Smi=Small integer)写入到 accumulator
当中,这段在 v8/src/interpreter/interpreter-generator.cc
的代码实现了这个逻辑:
// LdaSmi <imm>
//
// Load an integer literal into the accumulator as a Smi.
IGNITION_HANDLER(LdaSmi, InterpreterAssembler) {
TNode<Smi> smi_int = BytecodeOperandImmSmi(0);
SetAccumulator(smi_int);
Dispatch();
}
可以看到,逻辑并不复杂,就是取了第一个立即数操作数,设置到了 accumulator
,最后调用 Dispatch
,也就是读取下一个 Opcode 对应的汇编指令然后跳转。接下来看这几个步骤在汇编上是怎么实现的。
为了查看 Ignition 对各种 Opcode 具体生成了什么样的汇编指令,可以用 ./out/arm64.optdebug/mksnapshot --trace-ignition-codegen --code-comments
命令查看,下面列出了 LdaSmi
这个 Opcode 对应的汇编,由于这段汇编有点长,具体做的事情和对应的源码已经通过注释标注出来:
kind = BYTECODE_HANDLER
name = LdaSmi
compiler = turbofan
address = 0x31a000906fd
Instructions (size = 324)
# 在代码的开头,检查寄存器是否正确,即 x2 是否保存了当前代码段的开始地址,对应的源码:
# v8/src/compiler/backend/arm64/code-generator-arm64.cc CodeGenerator::AssembleCodeStartRegisterCheck():
# UseScratchRegisterScope temps(masm());
# Register scratch = temps.AcquireX();
# __ ComputeCodeStartAddress(scratch); // becomes x16 in the following code
# __ cmp(scratch, kJavaScriptCallCodeStartRegister);
# __ Assert(eq, AbortReason::kWrongFunctionCodeStart);
# 其中 kJavaScriptCallCodeStartRegister 定义在 v8/src/codegen/arm64/register-arm64.h:
# constexpr Register kJavaScriptCallCodeStartRegister = x2;
[ Frame: MANUAL
-- Prologue: check code start register -- - AssembleCode@../../src/compiler/backend/code-generator.cc:232
0xc6ccfe4a8b00 0 10000010 adr x16, #+0x0 (addr 0xc6ccfe4a8b00)
0xc6ccfe4a8b04 4 eb02021f cmp x16, x2
0xc6ccfe4a8b08 8 54000080 b.eq #+0x10 (addr 0xc6ccfe4a8b18)
[ - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4008
Abort message: - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4010
Wrong value in code start register passed - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4011
0xc6ccfe4a8b0c c d2801081 movz x1, #0x84
[ Frame: NO_FRAME_TYPE
[ - EntryFromBuiltinAsOperand@../../src/codegen/arm64/macro-assembler-arm64.cc:2377
]
0xc6ccfe4a8b10 10 f96a3750 ldr x16, [x26, #21608]
0xc6ccfe4a8b14 14 d63f0200 blr x16
]
]
# 栈对齐检查,定义在:
# v8/src/codegen/arm64/macro-assembler-arm64.cc MacroAssembler::AssertSpAligned():
# if (!v8_flags.debug_code) return;
# ASM_CODE_COMMENT(this);
# HardAbortScope hard_abort(this); // Avoid calls to Abort.
# // Arm64 requires the stack pointer to be 16-byte aligned prior to address
# // calculation.
# UseScratchRegisterScope scope(this);
# Register temp = scope.AcquireX(); // becomes x16 in the following code
# Mov(temp, sp);
# Tst(temp, 15);
# Check(eq, AbortReason::kUnexpectedStackPointer);
-- B0 start (construct frame) --
[ - AssertSpAligned@../../src/codegen/arm64/macro-assembler-arm64.cc:1590
0xc6ccfe4a8b18 18 910003f0 mov x16, sp
[ - LogicalMacro@../../src/codegen/arm64/macro-assembler-arm64.cc:197
0xc6ccfe4a8b1c 1c f2400e1f tst x16, #0xf
]
0xc6ccfe4a8b20 20 54000080 b.eq #+0x10 (addr 0xc6ccfe4a8b30)
[ - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4008
Abort message: - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4010
The stack pointer is not the expected value - Abort@../../src/codegen/arm64/macro-assembler-arm64.cc:4011
[ Frame: NO_FRAME_TYPE
0xc6ccfe4a8b24 24 52800780 movz w0, #0x3c
0xc6ccfe4a8b28 28 f94e7750 ldr x16, [x26, #7400]
0xc6ccfe4a8b2c 2c d63f0200 blr x16
]
]
]
# 构建栈帧
# 构建完成后会得到:sp = prev sp - 64, fp = sp + 48
#
# 栈帧的示意图, 每一个方框表示 8 字节的内存:
# sp + 64 +------------+
# | lr | <= lr 是 link register 的缩写,表示返回地址
# sp + 56 +------------+
# | prev fp | <= 保存了调用前的 fp (frame pointer)
# sp + 48 +------------+ <= 新的 fp (frame pointer) 指向这里
# | x16 (0x22) |
# sp + 40 +------------+
# | x20 | <= bytecode array register
# sp + 32 +------------+
# | x21 | <= dispatch table register
# sp + 24 +------------+
# | x19 | <= bytecode offset register,记录当前正在执行的 bytecode 在 bytecode array 中的偏移
# sp + 16 +------------+
# | x0 | <= accumulator register
# sp + 8 +------------+
# | |
# sp +------------+ <= 新的 sp (stack pointer) 指向这里
#
# 这些寄存器定义在 v8/src/codegen/arm64/register-arm64.h 当中:
# constexpr Register kInterpreterAccumulatorRegister = x0;
# constexpr Register kInterpreterBytecodeOffsetRegister = x19;
# constexpr Register kInterpreterBytecodeArrayRegister = x20;
# constexpr Register kInterpreterDispatchTableRegister = x21;
0xc6ccfe4a8b30 30 d2800450 movz x16, #0x22
0xc6ccfe4a8b34 34 a9be43ff stp xzr, x16, [sp, #-32]!
0xc6ccfe4a8b38 38 a9017bfd stp fp, lr, [sp, #16]
0xc6ccfe4a8b3c 3c 910043fd add fp, sp, #0x10 (16)
0xc6ccfe4a8b40 40 d10083ff sub sp, sp, #0x20 (32)
0xc6ccfe4a8b44 44 f90013f4 str x20, [sp, #32]
0xc6ccfe4a8b48 48 f9000ff5 str x21, [sp, #24]
0xc6ccfe4a8b4c 4c f90007e0 str x0, [sp, #8]
0xc6ccfe4a8b50 50 f9000bf3 str x19, [sp, #16]
# 调用了未知的 C 函数
0xc6ccfe4a8b54 54 d28042c1 movz x1, #0x216
[ - LoadFromConstantsTable@../../src/codegen/arm64/macro-assembler-arm64.cc:2166
[ - LoadRoot@../../src/codegen/arm64/macro-assembler-arm64.cc:1954
0xc6ccfe4a8b58 58 f94d5f42 ldr x2, [x26, #6840]
]
[ - DecompressTagged@../../src/codegen/arm64/macro-assembler-arm64.cc:3448
0xc6ccfe4a8b5c 5c d28bcef0 movz x16, #0x5e77
0xc6ccfe4a8b60 60 b8706842 ldr w2, [x2, x16]
0xc6ccfe4a8b64 64 8b020382 add x2, x28, x2
]
]
0xc6ccfe4a8b68 68 aa1403e0 mov x0, x20
0xc6ccfe4a8b6c 6c f94ecf50 ldr x16, [x26, #7576]
[ - CallCFunction@../../src/codegen/arm64/macro-assembler-arm64.cc:2106
0xc6ccfe4a8b70 70 10000068 adr x8, #+0xc (addr 0xc6ccfe4a8b7c)
0xc6ccfe4a8b74 74 a93f235d stp fp, x8, [x26, #-16]
0xc6ccfe4a8b78 78 d63f0200 blr x16
0xc6ccfe4a8b7c 7c f81f035f stur xzr, [x26, #-16]
]
0xc6ccfe4a8b80 80 d2800001 movz x1, #0x0
[ - LoadFromConstantsTable@../../src/codegen/arm64/macro-assembler-arm64.cc:2166
[ - LoadRoot@../../src/codegen/arm64/macro-assembler-arm64.cc:1954
0xc6ccfe4a8b84 84 f94d5f42 ldr x2, [x26, #6840]
]
[ - DecompressTagged@../../src/codegen/arm64/macro-assembler-arm64.cc:3448
0xc6ccfe4a8b88 88 d28bcf70 movz x16, #0x5e7b
0xc6ccfe4a8b8c 8c b8706842 ldr w2, [x2, x16]
0xc6ccfe4a8b90 90 8b020382 add x2, x28, x2
]
]
0xc6ccfe4a8b94 94 f94007e0 ldr x0, [sp, #8]
0xc6ccfe4a8b98 98 f94ecf50 ldr x16, [x26, #7576]
[ - CallCFunction@../../src/codegen/arm64/macro-assembler-arm64.cc:2106
0xc6ccfe4a8b9c 9c 10000068 adr x8, #+0xc (addr 0xc6ccfe4a8ba8)
0xc6ccfe4a8ba0 a0 a93f235d stp fp, x8, [x26, #-16]
0xc6ccfe4a8ba4 a4 d63f0200 blr x16
0xc6ccfe4a8ba8 a8 f81f035f stur xzr, [x26, #-16]
]
# 从这里开始实现 LdaSmi 的语义
# 从前面的分析可以看到 LdaSmi 由两个字节组成:
# 1. 第一个字节是 0x0d,表示这是一条 LdaSmi
# 2. 第二个字节就是要加载到 `accumulator` 的小整数
# 如:0d 01 对应 LdaSmi [1],0d 02 对应 LdaSmi [2]
# 所以,为了实现 LdaSmi,需要从 bytecode array 中读取 LdaSmi 字节码的第二个字节,
# 保存到 `accumulator` 寄存器当中
# 下面一条一条地分析指令在做的事情:
# 1. 从 sp + 16 地址读取 bytecode offset 寄存器的值到 x3,
# 它记录了 LdaSmi 相对 bytecode array 的偏移
0xc6ccfe4a8bac ac f9400be3 ldr x3, [sp, #16]
# 2. 计算 x3 + 1 的值并写入 x4,得到 LdaSmi 的第二个字节相对 bytecode array 的偏移
0xc6ccfe4a8bb0 b0 91000464 add x4, x3, #0x1 (1)
# 3. 从 sp + 32 地址读取 bytecode array 寄存器的值到 x20
0xc6ccfe4a8bb4 b4 f94013f4 ldr x20, [sp, #32]
# 4. 从 x20 + x4 地址读取 LdaSmi 的第二个字节到 x4,也就是要加载到 `accumulator` 的值,
# 之后 x4 的值会写入到 x0,也就是 `accumulator` 对应的寄存器
0xc6ccfe4a8bb8 b8 38e46a84 ldrsb w4, [x20, x4]
# Dispatch: 找到下一个 Opcode 对应的代码的入口,然后跳转过去
========= Dispatch - Dispatch@../../src/interpreter/interpreter-assembler.cc:1278 - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978
# x3 是 LdaSmi 当前所在的 bytecode offset,加 2 是因为 LdaSmi 占用了两个字节
# x19 = x3 + 2,就是 bytecode offset 前进两个字节,指向下一个字节码
0xc6ccfe4a8bbc bc 91000873 add x19, x3, #0x2 (2)
# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器
0xc6ccfe4a8bc0 c0 38736a83 ldrb w3, [x20, x19]
# 接下来检查在 x3 寄存器当中的字节码的第一个字节(Opcode),如果它:
# 1. 小于 187(kFirstShortStar),说明它不是特殊的 Short Star (Star0-Star15) 字节码
# 2. 介于 187(kFirstShortStar) 和 202(kLastShortStar) 之间,说明它是特殊的 Short Star (Star0-Star15) 字节码
# 3. 如果大于 202(kLastShortStar),说明它是非法的字节码
# 如果 x3 寄存器大于或等于 187,说明这个字节码可能是 Short Star 字节码,就跳转到后面的 B2
0xc6ccfe4a8bc4 c4 7102ec7f cmp w3, #0xbb (187)
0xc6ccfe4a8bc8 c8 54000102 b.hs #+0x20 (addr 0xc6ccfe4a8be8)
-- B1 start --
# 此时 x3 小于 187
# 从栈上读取 x21 即 dispatch table register
0xc6ccfe4a8bcc cc f9400ff5 ldr x21, [sp, #24]
# 从 dispatch table,以 x3 为下标,读取下一个字节码对应的代码的地址
0xc6ccfe4a8bd0 d0 f8637aa2 ldr x2, [x21, x3, lsl #3]
# 把之前 LdaSmi 计算得到的 x4 寄存器写到 `accumulator` 即 x0 寄存器当中
# 这里 x0 = 2 * x4,是因为 v8 用最低位表示这是一个 Smi(用 0 表示)还是一个指针(用 1 表示)
0xc6ccfe4a8bd4 d4 0b040080 add w0, w4, w4
# 恢复调用函数前的旧 fp 和 lr
0xc6ccfe4a8bd8 d8 a9407bbd ldp fp, lr, [fp]
# 恢复调用函数前的旧 sp
0xc6ccfe4a8bdc dc 910103ff add sp, sp, #0x40 (64)
# 下一个字节码对应的代码的地址已经保存在 x2 寄存器当中,跳转过去
0xc6ccfe4a8be0 e0 aa0203f1 mov x17, x2
0xc6ccfe4a8be4 e4 d61f0220 br x17
-- B2 start --
[ Assert: UintPtrGreaterThanOrEqual(opcode, UintPtrConstant(static_cast<int>( Bytecode::kFirstShortStar))) - StoreRegisterForShortStar@../../src/interpreter/interpreter-assembler.cc:310 - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978
] Assert - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978
[ Assert: UintPtrLessThanOrEqual( opcode, UintPtrConstant(static_cast<int>(Bytecode::kLastShortStar))) - StoreRegisterForShortStar@../../src/interpreter/interpreter-assembler.cc:314 - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978
# 此时 x3 大于或等于 187
# 进一步判断 x3 是否大于 202,如果大于,则跳转到后面的 B3
0xc6ccfe4a8be8 e8 7103287f cmp w3, #0xca (202)
0xc6ccfe4a8bec ec 540001c8 b.hi #+0x38 (addr 0xc6ccfe4a8c24)
-- B4 start --
# 此时 x3 介于 187 和 202 之间,是一个 Short Star
# Short Star 做的事情就是把 `accumulator` 寄存器的值复制到 r0-r15 当中指定的寄存器
# 所以直接在这里实现了 Short Star 的语义,而不是单独跑一段代码去执行它
# 由于 r0-r15 寄存器保存在栈上,所以通过 x3 计算出 Short Star 要写到哪个寄存器
# 进而直接计算要写到的栈的地址的偏移
# 寻找一个通项公式,找到 Star0-Star15 要写入的地址:
# Star0(202): r0 的位置在栈顶再往下的 8 字节,即 fp 减去 56
# Star0(187): r15 的位置在栈顶再往下的 8*16 字节,即 fp 减去 176
# 相对 fp 的偏移量就等于 x3 * 8 - 1672
# 从而得到下面的代码:
# 计算 x3 = x3 * 8
0xc6ccfe4a8bf0 f0 d37df063 lsl x3, x3, #3
# 把之前 LdaSmi 计算得到的 x4 寄存器写到 `accumulator` 即 x0 寄存器当中
# 这里 x0 = 2 * x4,是因为 v8 用最低位表示这是一个 Smi(用 0 表示)还是一个指针(用 1 表示)
0xc6ccfe4a8bf4 f4 0b040080 add w0, w4, w4
] Assert - AssembleArchInstruction@../../src/compiler/backend/arm64/code-generator-arm64.cc:978
# 计算 x3 = x3 - 1672,就得到了相对 fp 的偏移量
0xc6ccfe4a8bf8 f8 d11a2063 sub x3, x3, #0x688 (1672)
# 从 fp 的地址读取函数调用前的 fp
0xc6ccfe4a8bfc fc f94003a4 ldr x4, [fp]
# 把 `accumulator` 写入到相对函数调用前的 fp 的对应位置
0xc6ccfe4a8c00 100 f8236880 str x0, [x4, x3]
# 下面就是 Dispatch 逻辑,只不过这次是执行完 Short Star 字节码后的 Dispatch
# x19 = x3 + 1,就是 bytecode offset 前进一个字节,指向下一个字节码
0xc6ccfe4a8c04 104 91000673 add x19, x19, #0x1 (1)
# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器
0xc6ccfe4a8c08 108 38736a83 ldrb w3, [x20, x19]
# 从栈上读取 x21 即 dispatch table register
0xc6ccfe4a8c0c 10c f9400ff5 ldr x21, [sp, #24]
# 从 dispatch table,以 x3 为下标,读取下一个字节码对应的代码的地址
0xc6ccfe4a8c10 110 f8637aa2 ldr x2, [x21, x3, lsl #3]
# 恢复调用函数前的旧 fp 和 lr
0xc6ccfe4a8c14 114 a9407bbd ldp fp, lr, [fp]
# 恢复调用函数前的旧 sp
0xc6ccfe4a8c18 118 910103ff add sp, sp, #0x40 (64)
# 下一个字节码对应的代码的地址已经保存在 x2 寄存器当中,跳转过去
0xc6ccfe4a8c1c 11c aa0203f1 mov x17, x2
0xc6ccfe4a8c20 120 d61f0220 br x17
-- B5 start (no frame) --
-- B3 start (deferred) --
# 此时 x3 大于 202,为非法字节码,跳转到错误处理的逻辑
[ - LoadFromConstantsTable@../../src/codegen/arm64/macro-assembler-arm64.cc:2166
[ - LoadRoot@../../src/codegen/arm64/macro-assembler-arm64.cc:1954
0xc6ccfe4a8c24 124 f94d5f41 ldr x1, [x26, #6840]
]
[ - DecompressTagged@../../src/codegen/arm64/macro-assembler-arm64.cc:3448
0xc6ccfe4a8c28 128 d28bd170 movz x16, #0x5e8b
0xc6ccfe4a8c2c 12c b8706821 ldr w1, [x1, x16]
0xc6ccfe4a8c30 130 8b010381 add x1, x28, x1
]
]
[ Frame: NO_FRAME_TYPE
[ Inlined Trampoline for call to AbortCSADcheck - CallBuiltin@../../src/codegen/arm64/macro-assembler-arm64.cc:2391
0xc6ccfe4a8c34 134 96a4e10b bl #-0x56c7bd4 (addr 0xc6ccf8de1060) ;; code: Builtin::AbortCSADcheck
]
]
0xc6ccfe4a8c38 138 d4200000 brk #0x0
0xc6ccfe4a8c3c 13c d4200000 brk #0x0
0xc6ccfe4a8c40 140 d503201f nop
;;; Safepoint table. - Emit@../../src/codegen/safepoint-table.cc:187
]
External Source positions:
pc offset fileid line
b4 380 72
Safepoints (entries = 2, byte size = 12)
0xc6ccfe4a8b7c 7c slots (sp->fp): 01001000
0xc6ccfe4a8ba8 a8 slots (sp->fp): 00001000
RelocInfo (size = 3)
0xc6ccfe4a8c34 code target (BUILTIN AbortCSADcheck) (0xc6ccf8de1060)
为了简化代码,关闭了 control flow integrity 相关的代码生成,具体方法是运行 gn args out/arm64.optdebug
,追加一行 v8_control_flow_integrity = false
,再重新 autoninja -C out/arm64.optdebug d8
。
以上是 debug 模式下生成的代码,多了很多检查;如果在 release 模式下,可以观察到更优的指令:
kind = BYTECODE_HANDLER
name = LdaSmi
compiler = turbofan
address = 0x31a000462bd
Instructions (size = 80)
# 从这里开始实现 LdaSmi 的语义
# 计算 x19 + 1 的值并写入 x1,得到 LdaSmi 的第二个字节相对 bytecode array 的偏移
0xc903f8193400 0 91000661 add x1, x19, #0x1 (1)
# 从 x20 + x1 地址读取 LdaSmi 的第二个字节到 x1,也就是要加载到 `accumulator` 的值,
# 之后 x1 的值会写入到 x0,也就是 `accumulator` 对应的寄存器
0xc903f8193404 4 38e16a81 ldrsb w1, [x20, x1]
# Dispatch: 找到下一个 Opcode 对应的代码的入口,然后跳转过去
# x19 = x19 + 2,就是 bytecode offset 前进两个字节,指向下一个字节码
0xc903f8193408 8 91000a73 add x19, x19, #0x2 (2)
# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器
0xc903f819340c c 38736a83 ldrb w3, [x20, x19]
# 计算 x4 = x3 * 8,也就是 dispatch table 中下一个字节码对应的代码地址的字节偏移
0xc903f8193410 10 d37df064 lsl x4, x3, #3
# 把之前 LdaSmi 计算得到的 x1 寄存器写到 `accumulator` 即 x0 寄存器当中
# 这里 x0 = 2 * x1,是因为 v8 用最低位表示这是一个 Smi(用 0 表示)还是一个指针(用 1 表示)
0xc903f8193414 14 0b010020 add w0, w1, w1
# 如果 x3 寄存器大于或等于 187,说明这个字节码可能是 Short Star 字节码,就跳转到后面的 0xc903f819342c 地址
0xc903f8193418 18 7102ec7f cmp w3, #0xbb (187)
0xc903f819341c 1c 54000082 b.hs #+0x10 (addr 0xc903f819342c)
# 如果没有跳转,此时 x3 寄存器小于 187
# 从 dispatch table,以 x3 为下标(x4 = x3 * 8),读取下一个字节码对应的代码的地址
0xc903f8193420 20 f8646aa2 ldr x2, [x21, x4]
# 跳转到下一个字节码对应的代码的地址
0xc903f8193424 24 aa0203f1 mov x17, x2
0xc903f8193428 28 d61f0220 br x17
# 实现 Short Star 字节码
# 计算出要写入的 r0-r15 寄存器相对 fp 的偏移量 x3 * 8 - 1672
# 这个偏移量的计算公式在前面推导过,此时 x4 等于 x3 * 8
0xc903f819342c 2c d11a2081 sub x1, x4, #0x688 (1672)
0xc903f8193430 30 aa1d03e3 mov x3, fp
# 把 `accumulator` 写入到相对 fp 的对应位置
0xc903f8193434 34 f8216860 str x0, [x3, x1]
# 下面就是 Dispatch 逻辑,只不过这次是执行完 Short Star 字节码后的 Dispatch
# x19 = x19 + 1,就是 bytecode offset 前进一个字节,指向下一个字节码
0xc903f8193438 38 91000673 add x19, x19, #0x1 (1)
# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x1 寄存器
0xc903f819343c 3c 38736a81 ldrb w1, [x20, x19]
# 从 dispatch table,以 x1 为下标,读取下一个字节码对应的代码的地址
0xc903f8193440 40 f8617aa2 ldr x2, [x21, x1, lsl #3]
# 跳转到下一个字节码对应的代码的地址
0xc903f8193444 44 aa0203f1 mov x17, x2
0xc903f8193448 48 d61f0220 br x17
0xc903f819344c 4c d503201f nop
可见 release 模式下的代码还是简单了许多,保证了性能。
有的 Opcode 后面不会紧接着出现 Short Star,此时 Dispatch 会减少一次特判,代码更加简单,以 Ldar
为例:
kind = BYTECODE_HANDLER
name = Ldar
compiler = turbofan
address = 0x31a00046245
Instructions (size = 44)
# Ldar 的语义是,把指定参数寄存器的值写入到 `accumulator` 当中
# 参数寄存器的位置记录在 Ldar 字节码的第二个字节中
# 如:0b 04 对应 Ldar a1
# 计算 x19 + 1 的值并写入 x1,得到 Ldar 的第二个字节相对 bytecode array 的偏移
0xc903f8193320 0 91000661 add x1, x19, #0x1 (1)
# 从 x20 + x1 地址读取 Ldar 的第二个字节到 x1,也就是参数寄存器相对 fp 的下标
0xc903f8193324 4 38a16a81 ldrsb x1, [x20, x1]
# 相对 fp 以 x1 为下标,读取参数寄存器的值到 x1 寄存器
0xc903f8193328 8 aa1d03e3 mov x3, fp
0xc903f819332c c f8617861 ldr x1, [x3, x1, lsl #3]
# Dispatch: 找到下一个 Opcode 对应的代码的入口,然后跳转过去
# x19 = x19 + 2,就是 bytecode offset 前进两个字节,指向下一个字节码
0xc903f8193330 10 91000a73 add x19, x19, #0x2 (2)
# x20 是 bytecode array,从 bytecode array 读取下一个字节码的第一个字节到 x3 寄存器
0xc903f8193334 14 38736a83 ldrb w3, [x20, x19]
# 从 dispatch table,以 x3 为下标,读取下一个字节码对应的代码的地址
0xc903f8193338 18 f8637aa2 ldr x2, [x21, x3, lsl #3]
# 把参数寄存器的值写入到 `accumulator` 也就是 x0 当中
0xc903f819333c 1c aa0103e0 mov x0, x1
# 跳转到下一个字节码对应的代码的地址
0xc903f8193340 20 aa0203f1 mov x17, x2
0xc903f8193344 24 d61f0220 br x17
0xc903f8193348 28 d503201f nop
小结一下:
- Ignition 给每种可能的 Opcode 类型生成一段代码
- 这段代码会进行一些检查(仅 Debug 模式下),然后在汇编里实现这个字节码的功能
- 执行完字节码后,进入 Dispatch 逻辑,寻找下一个字节码对应的代码的地址
- 特别地,如果下一个字节码是 Short Star (Star0-Star15),因为它比较简单和常见,就直接执行它,执行完再重新寻找再下一个字节码对应的代码的地址
- 这些 Opcode 对应的代码会在 v8 编译过程中通过
mksnapshot
命令一次性生成好,运行时直接复用,不用重新生成 - V8 的值的最低位标识了它的类型:0 表示 Smi,1 表示指针,因此在存储 Smi 的时候,寄存器里保存的是实际值的两倍,这样最低位就是 0
参考¶
- Firing up the Ignition interpreter
- How to get Node.js to trace ignition within v8? with --trace-ignition
- Ignition: Jump-starting an Interpreter for V8
- Ignition: V8 Interpreter
- Introduction to TurboFan
- JavaScript Bytecode – v8 Ignition Instructions
- Understanding V8’s Bytecode
- V8 Documentation
- V8 Ignition
- V8 TurboFan
- V8 Turbolizer v13.4
- V8: Behind the Scenes (February Edition feat. A tale of TurboFan)
- danbev/learning-v8
- V8 Internals: How Small is a “Small Integer?”