跳转至

V8 Ignition 解释器的内部实现探究

背景

V8 是一个很常见的 JavaScript 引擎,运行在很多的设备上,因此想探究一下它内部的部分实现。本博客在 ARM64 Ubuntu 24.04 平台上针对 V8 12.8.374.31 版本进行分析。本博客主要分析了 V8 的 Ignition 解释器的解释执行部分。

编译 V8

首先简单过一下 v8 的源码获取以及编译流程,主要参考了 Checking out the V8 source codeCompiling 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 一共有这些解释器或编译器,按照其优化等级从小到大的顺序:

  1. Ignition: 解释器
  2. SparkPlug: 不优化的快速编译器,追求快的编译速度
  3. Maglev:做优化的编译器,寻求编译速度和编译质量的平衡
  4. TurboFan:做优化的编译器,寻求更好的编译质量

在 JS 的使用场景,不同代码被调用的次数以及对及时性的需求差别很大,为了适应不同的场景,V8 设计了这些解释器和编译器来提升整体的性能:执行次数少的代码,倾向于用更低优化等级的解释器或编译器,追求更低的优化开销;执行次数多的代码,当编译优化时间不再成为瓶颈,则倾向于用更高优化等级的编译器,追求更高的执行性能。

Ignition 解释器

分析样例 JS 代码

首先来观察一下 Ignition 解释器的工作流程。写一段简单的 JS 代码:

function add(a, b) {
    return a + b;
}

add(1, 2);

保存为 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 函数,然后以 12 两个参数调用 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 函数:

  1. LdaConstant [0]: 把 Constant Pool 的第 0 项也就是 FixedArray[2] 写入 accumulator 寄存器当中
  2. Star1: 把 accumulator 寄存器的值拷贝到 r1 寄存器,结合上一条字节码,就是设置 r1 = FixedArray[2]
  3. Mov <closure>, r2: 把 <closure> 拷贝到 r2 寄存器,猜测这里的 <closure> 对应的是 add 函数
  4. CallRuntime [DeclareGlobals], r1-r2: 调用运行时的 DeclareGlobals 函数,并传递两个参数,分别是 r1r2;有意思的是,CallRuntime 的参数必须保存在连续的寄存器当中,猜测是为了节省编码空间

至此,add 函数就声明完成了。接下来,就要实现 add(1, 2) 的调用:

  1. LdaGlobal [1], [0]: 把 Constant Pool 的第 1 项也就是 "add" 这个字符串写入 accumulator,最后的 [0] 和 FeedBackVector 有关,目前先忽略
  2. Star1: 把 accumulator 寄存器的值拷贝到 r1 寄存器,结合上一条字节码,就是设置 r1 = "add"
  3. LdaSmi [1]: 把小整数(Small integer, Smi)1 写入 accumulator
  4. Star2: 把 accumulator 寄存器的值拷贝到 r2 寄存器,结合上一条字节码,就是设置 r2 = 1
  5. LdaSmi [2]: 把小整数(Small integer, Smi)2 写入 accumulator
  6. Star3: 把 accumulator 寄存器的值拷贝到 r3 寄存器,结合上一条字节码,就是设置 r3 = 2
  7. 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)
  1. Ldar a1: 把第二个参数 a1 也就是 b 写入 accumulator 寄存器
  2. Add a0, [0]: 求第一个参数 a0 也就是 aaccumulator 寄存器的和,写入到 accumulator 寄存器当中,结合上一条 Bytecode,就是 accumulator = a0 + a1[0] 和 FeedBackVector 有关
  3. Return: 把 accumulator 中的值作为返回值,结束函数调用

简单小结一下 V8 的字节码:

  1. 有若干个局部的寄存器,在操作数中以 rn 的形式出现,n 是寄存器编号
  2. accumulator 局部寄存器,作为部分字节码的隐含输入或输出(Add
  3. 有若干个参数,在操作数中以 an 的形式出现,n 是参数编号
  4. 操作数还可以出现立即数参数 [imm],可能是整数字面量(LdaSmi),可能是下标(LdaConstant),也可能是 FeedBackVector 的 slot

有了字节码以后,接下来观察 Ignition 具体是怎么解释执行这些字节码的。

解释执行

为了实际执行这些字节码,Ignition 的做法是:

  1. 给每种可能的 Opcode 生成一段二进制代码,这段代码会实现这个 Opcode 的功能
  2. 在运行时维护一个 dispatch table,维护了 Opcode 到二进制代码地址的映射关系
  3. 在每段代码的结尾,找到下一个 Opcode 对应的代码的地址,然后跳转过去
  4. 调用函数时,先做一系列的准备,找到函数第一个字节码的 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

小结一下:

  1. Ignition 给每种可能的 Opcode 类型生成一段代码
  2. 这段代码会进行一些检查(仅 Debug 模式下),然后在汇编里实现这个字节码的功能
  3. 执行完字节码后,进入 Dispatch 逻辑,寻找下一个字节码对应的代码的地址
  4. 特别地,如果下一个字节码是 Short Star (Star0-Star15),因为它比较简单和常见,就直接执行它,执行完再重新寻找再下一个字节码对应的代码的地址
  5. 这些 Opcode 对应的代码会在 v8 编译过程中通过 mksnapshot 命令一次性生成好,运行时直接复用,不用重新生成
  6. V8 的值的最低位标识了它的类型:0 表示 Smi,1 表示指针,因此在存储 Smi 的时候,寄存器里保存的是实际值的两倍,这样最低位就是 0

参考

评论