Inside V8:平平无奇mksnapshot

2023-09-01 18:41:31 浏览数 (2)

mksnapshot是v8编译过程中的一个中间产物,看名字平平无奇,也甚少文章着重介绍它,但实际上它并不是它名字表述那样只是生成个快照,而是内藏玄机:

  • builtin是搭建起V8运行时最重要的积木块(见本系列的上一篇《Inside V8:源码入门》),作为builtin的生成者,mksnapshot就是builtin之母了,是看懂v8源码的重要一环
  • mksnapshot也是理解交叉编译过程,进而实现v8移植的关键
  • mksnapshot本身足够复杂,比如为了实现交叉编译中目标平台snapshot的生成,它做了各种cpu(arm、mips、risc、ppc)的模拟器(Simulator)

Array.isArray在v8是怎么实现的?

我们以Array.isArray的实现来讲解下mksnapshot扮演的角色。

首先, Array.isArray是用一个叫torque的语言来写的,有点类似js的语法,只在v8中使用,Array.isArray的实现如下:

代码语言:javascript复制
namespace runtime {
extern runtime ArrayIsArray(implicit context: Context)(JSAny): JSAny;
}  // namespace runtime

namespace array {
// ES #sec-array.isarray
javascript builtin ArrayIsArray(js-implicit context: NativeContext)(arg: JSAny):
    JSAny {
  // 1. Return ? IsArray(arg).
  typeswitch (arg) {
    case (JSArray): {
      return True;
    }
    case (JSProxy): {
      // TODO(verwaest): Handle proxies in-place
      return runtime::ArrayIsArray(arg);
    }
    case (JSAny): {
      return False;
    }
  }
}
}  // namespace array

经过torque编译器后,会生成一段很复杂的C 代码,我截取一个片段

代码语言:javascript复制
TNode<JSProxy> Cast_JSProxy_1(compiler::CodeAssemblerState* state_, TNode<Context> p_context, TNode<Object> p_o, compiler::CodeAssemblerLabel* label_CastError) {
  // other code ...
  if (block0.is_used()) {
    ca_.Bind(&block0);
    ca_.SetSourcePosition("../../src/builtins/cast.tq", 162);
    compiler::CodeAssemblerLabel label1(&ca_);
    tmp0 = CodeStubAssembler(state_).TaggedToHeapObject(TNode<Object>{p_o}, &label1);
    ca_.Goto(&block3);
    if (label1.is_used()) {
      ca_.Bind(&label1);
      ca_.Goto(&block4);
    }
  }
  // other code ...
}

这是跑在运行时的Array.isArray的C 实现?

错了!这段代码只跑在mksnapshot里头,这段代码的产物是turbofan的IR。IR经过turbofan的优化编译后生成目标机器指令,然后dump到embedded.S汇编文件,如下才是跑在运行时的Array.isArray:

代码语言:javascript复制
Builtins_ArrayIsArray:
.type Builtins_ArrayIsArray, %function
.size Builtins_ArrayIsArray, 214
  .octa 0xd10043ff910043fda9017bfda9be6fe1,0x540003a9eb2263fff8560342f81e83a0
  .octa 0x7840b063f85ff04336000182f9401be2,0x14000007d2800003540000607110907f
  .octa 0x910043ffa8c17bfd910003bff85b8340,0x35000163d2800020d2800023d65f03c0
  .octa 0x540000e17102d47f7840b063f85ff043,0xf94da741f90003e2f90007ffd10043ff
  .octa 0x17ffffeef85c034017fffff097ffb480,0xaa1b03e2f9501f41d2800000f90003fb
  .octa 0x17ffffddf94003fb97ffb477aa0003e3,0x840000000100000002d503201f
  .octa 0xffffffff000000a8ffffffffffffffff
  .byte 0xff,0xff,0xff,0xff,0x0,0x1,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc

在这个过程中,jit编译器turbofan干的是AOT的活。可以类比llvm之类的编译器架构,turbofan就类似llvm后端(ps,llvm我记得也支持jit)。

交叉编译中的builtin生成

在一般的库,所谓交叉编译就是调用改目标平台指定的工具链直接编译源码生成目标平台的文件。比如一个C文件要给android用,调用ndk包的gcc、clang编译即可。

但我们前面说过,builtin实际用的是v8自己的工具链体系编译成目标平台的代码,所以并不能套用上面的方式,它是怎么实现呢?

首先,turbofan生成机器指令的部分是可以替换的,比如链接http://builtins-x64.cc生成的是x64指令,链接http://builtins-arm64.cc生成的arm64指令。

以在linux x64上交叉编译android arm64的builtin为例,步骤如下:

  • 调用本地编译器,编译一个linux版本mksnapshot程序
  • 上述mksnapshot链接的是http://builtins-arm64.cc(而不是http://builtins-x64.cc)
  • 调用上述mksnapshot生成arm64指令并dump到embedded.S
  • 调用ndk的工具链,编译embedded.S和v8运行时的其它代码,生成能在arm64上使用的v8库

builtin加载

在embedded.S里的builtins是怎么起作用的呢?

首先embedded.S声明了四个全局变量,分别是:

  • v8_Default_embedded_blob_code_:初始化为第一个builtin的起始位置(全部builtin紧凑的放在一个代码段里头)
  • v8_Default_embedded_blob_data_:指向一块数据,这块数据包含诸如各builtin相对v8_Default_embedded_blob_code_的偏移,builtin的长度等等信息
  • v8_Default_embedded_blob_code_size_:所有builtin的总长度
  • v8_Default_embedded_blob_data_size_:v8_Default_embedded_blob_data_数据的总长度

在http://isolate.cc声明几个extern变量,于是链接后http://isolate.cc就能引用到那几个变量:

代码语言:javascript复制
extern "C" const uint8_t* v8_Default_embedded_blob_code_;
extern "C" uint32_t v8_Default_embedded_blob_code_size_;
extern "C" const uint8_t* v8_Default_embedded_blob_data_;
extern "C" uint32_t v8_Default_embedded_blob_data_size_;

前面也说过,v8_Default_embedded_blob_data_包含了各builtin的偏移,这些偏移组成一个数组,放在isolate的builtin_entry_table,数组下标是该builtin的枚举值。调用某builtin就是builtin_entry_table通过枚举值获取起始地址调用。

交叉编译中的snapshot生成

如果不是交叉编译,snapshot生成还是挺容易理解的:v8对各种对象有做了序列化和反序列化的支持,所谓生成snapshot,就是序列化,通常会以context作为根来序列化。

生成snapshot前允许执行一段代码(以及其warnup代码),这段代码调用到的函数的编译结果也会序列化下来,后续反序列化后,就免去了编译过程,大大加快的启动的速度,以nodejs为例,采用snapshot能数倍加快其启动速度。

结合交叉编译时就会有个很费解的地方:我们前面提到mksnapshot在交叉编译时,jit生成的builtin是目标机器指令,而js的运行得通过跑builtin来实现(Ignition解析器每个指令就是一个builtin),这目标机器指令(比如arm64)怎么在本地(linux 的x64)跑起来呢?

通过调试才知道交叉编译时,mksnapshot会用一个目标机器的模拟器来跑这些builtin:

代码语言:javascript复制
//srccommonglobals.h
#if !defined(USE_SIMULATOR)
#if (V8_TARGET_ARCH_ARM64 && !V8_HOST_ARCH_ARM64)
#define USE_SIMULATOR 1
#endif
// ...
#endif

//srcexecutionsimulator.h
#ifdef USE_SIMULATOR
  Return Call(Args... args) {
    // other code ...
    return Simulator::current(isolate_)->template Call<Return>(
        reinterpret_cast<Address>(fn_ptr_), args...);
  }
#else

  DISABLE_CFI_ICALL Return Call(Args... args) {
    // other code ...
  }
#endif  // USE_SIMULATOR

如果交叉编译,将会走USE_SIMULATOR分支。arm64将会调用到simulator-arm64.h, http://simulator-arm64.cc实现的模拟器里头。上面Call的处理是把指令首地址赋值到模拟器的_pc寄存器,参数放寄存器,执行完指令从寄存器获取返回值。

更深入了解Snapshot

mksnapshot制作快照可以输入一个额外的脚本,加载这快照后等同于执行过了这脚本了,只可惜mksnapshot工具提供的是一个纯净的v8环境,于是你输入的脚本也必需是一个纯es规范的js。

但我们js虚拟机往往是嵌入到一个程序中使用,会有很多宿主的扩展api(比如nodejs的文件、网络api,puerts导出的引擎api等等),如果需要包含这些扩展的初始化,mksnapshot是不可用的,所以mksnapshot也只能作为v8编译的一个中间环节。没法作为一个通用工具使用。

mksnapshot的快照制作是调用v8::SnapshotCreator完成,而v8::SnapshotCreator是提供了我们输入这些外部数据的机会。

external_references

一个原生扩展方法的函数指针,或者v8::External,都是外部数据,需要在SnapshotCreator的构造时输入,构造函数如下:

代码语言:javascript复制
SnapshotCreator(Isolate* isolate,
                const intptr_t* external_references = nullptr,
                StartupData* existing_blob = nullptr);
  • isolate:要谨记传入的isolate是仅分配未初始化的(可以用v8::Isolate::Allocate,不可以用v8::Isolate::New)
  • external_references:nullptr结尾的数组,要注意制作快照和加载快照的外部指针排序必须严格一致,实际上快照是通过查表找到一个非nullptr指针对应的index,把index保存到快照,加载快照通过index恢复指针
  • existing_blob:可以通过这个继承另一个快照

InternalField

还有另外一种用户自定义数据是 InternalField,如果有使用到这种数据,需要通过提供序列化(v8::SerializeInternalFieldsCallback)和反序列化(v8::DeserializeInternalFieldsCallback)逻辑帮助v8生成快照时保存/恢复你所需数据,对于上述的两个Callback,可以看snapshots里的介绍。

Snapshot保存数据

如果你只有一个Context需要保存,用SnapshotCreator::SetDefaultContext就可以了,恢复时直接v8::Context::New即可。

如果有多于一个Context,还可以通过SnapshotCreator::AddContext添加,它会返回一个索引,恢复时输入索引即可恢复到指定的存档

代码语言:javascript复制
//保存
size_t context_index = snapshot_creator.AddContext(context, si_cb);
//恢复
v8::Local<v8::Context> context = v8::Context::FromSnapshot(isolate, context_index, di_cb).ToLocalChecked();

如果保存Context之外的数据,可以调用SnapshotCreator::AddData,然后通过Isolate或者Context的GetDataFromSnapshot接口恢复。

0 人点赞