作者:京东科技 于飞跃 如图所示,虚血鸿 Roma框架是拟机我们自主研发的 动态化跨平台解决方案,已支持 iOS,到纯android,蒙系web三端。移植目前在京东金融APP已经有200+页面,虚血鸿200+乐高楼层使用,拟机为保证基于Roma框架开发的到纯业务可以零成本、无缝运行到鸿蒙系统,蒙系需要将Roma框架适配到鸿蒙系统。移植 Roma框架是虚血鸿基于JS引擎运行的,在iOS系统使用系统内置的拟机JavascriptCore,在Android系统使用V8,到纯然而, 鸿蒙系统却没有可以执行Roma框架的蒙系JS引擎,因此 需要移植一个JS引擎到鸿蒙平台。 目前主流的JS引擎有以下这些: 其中的是 Google开源的V8引擎,除了 Chrome等浏览器, Node.js也是用的V8引擎。云南idc服务商Chrome的市场占有率高达60%,而Node.js是JS后端编程的事实标准。另外, Electron(桌面应用框架)是基于Node.js与Chromium开发桌面应用,也是基于V8的。国内的众多浏览器,其实也都是基于 Chromium浏览器开发,而Chromium相当于开源版本的Chrome,自然也是基于V8引擎的。甚至连浏览器界独树一帜的 Microsoft也投靠了Chromium阵营。V8引擎使得JS可以应用在 Web、APP、桌面端、服务端以及IOT等各个领域。 V8的主要任务是执行JavaScript代码,并且能够 处理JavaScript源代码、 即时编译(JIT)代码以及 执行代码。服务器租用v8是一个非常复杂的项目,有超过 100万行C++代码。 下图展示了它的基本工作流程: 如图所示,它通过词法分析、语法分析、字节码生成与执行、即时编译与机器码生成以及垃圾回收等步骤,实现了对JavaScript源代码的高效执行。此外,V8引擎还通过监控代码的执行情况,对热点函数进行自动优化,从而进一步提高了代码的执行性能。其中 Parser(解析器)、 Ignition(解释器)、 TurboFan(编译器)、 Orinoco(垃圾回收)是 V8 中四个核心工作模块,对应的V8源码目录如下图。 负责将JavaScript源码转换为Abstract Syntax Tree (AST)抽象语法树,解析过程分为: 词法分析(Lexical Analysis)和 语法分析(Syntax Analysis)两个阶段。 V8 引擎首先会扫描所有的源代码,进行词法分析(Tokenizing/Lexing)(词法分析是通过 Scanner 模块来完成的云服务器提供商)。也称为分词,是将字符串形式的代码转换为标记(token)序列的过程。这里的token是一个字符串,是构成源代码的最小单位,类似于英语中单词,例如, var a = 2;经过词法分析得到的tokens如下: 从上图中可以看到,这句代码最终被分解出了五个词法单元: var 关键字 a 标识符 = 运算符 2 数值 ;分号 语法分析是将词法分析产生的 token按照某种给定的形式文法(这里是JavaScript语言的语法规则)转换成 抽象语法树(AST)的过程。也就是把单词组合成句子的过程。这个过程会分析语法错误:遇到错误的语法会抛出异常。 AST是源代码的语法结构的树形表示。 AST包含了源代码中的所有语法结构信息,但不包含代码的执行逻辑。 例如, var a = 2; 经过语法分析后生成的AST如下: 可以看到这段程序的类型是 VariableDeclaration,也就是说这段代码是用来声明变量的。 负责将 AST转换成 字节码(Bytecode)并逐行解释执行字节码,提供快速的启动和较低的内存使用,同时会标记热点代码,收集 TurboFan优化编译所需的信息,比如函数参数的类型。 早期版本的 V8 ,并没有生成中间字节码的过程,而是将所有源码转换为了机器代码。机器代码虽然执行速度更快,但是占用内存大。 Node.js是基于V8引擎实现的,因此node命令提供了很多V8引擎的选项,我们可以通过这些选项,查看V8引擎中各个阶段的产物。使用node的 --print-bytecode选项,可以打印出Ignition生成的Bytecode。 示例test.js如下 运行下面的node命令,打印出Ignition生成的字节码。 控制台输出的内容非常多,最后一部分是add函数的Bytecode。 字节码的详细信息如下: 可以看到,Bytecode某种程度上就是汇编语言,只是它没有对应特定的CPU,或者说它对应的是虚拟的CPU。这样的话,生成Bytecode时简单很多,无需为不同的CPU生产不同的代码。要知道,V8支持9种不同的CPU,引入一个中间层Bytecode,可以简化V8的编译流程,提高可扩展性。如果我们在不同硬件上去生成Bytecode,生成代码的指令是一样的. V8 的优化编译器也是v8实现即时编译(JIT)的核心,负责将热点函数的字节码编译成高效的机器码。 我们需要先了解一下JIT (Just in Time)即时编译。 在运行C、C++以及Java等程序之前,需要进行编译,不能直接执行源码;但对于JavaScript来说,我们可以直接执行源码(比如:node server.js),它是在运行的时候先编译再执行,这种方式被称为即时编译(Just-in-time compilation),简称为JIT。因此,V8也属于JIT编译器。 除了V8引擎,Java虚拟机、PHP 8也用到了JIT。 V8的JIT编译包括多个阶段,从生成字节码到生成高度优化的机器码,根据JavaScript代码的执行特性动态地优化代码,以实现高性能的JavaScript执行。看下图 Ignition和 TurboFan的交互: 当 Ignition 开始执行 JavaScript 代码后,V8 会一直观察 JavaScript 代码的执行情况,并记录执行信息,如每个函数的执行次数、每次调用函数时,传递的参数类型等。如果一个函数被调用的次数超过了内设的阈值,监视器就会将当前函数标记为 热点函数(Hot Function),并将该函数的字节码以及执行的相关信息发送给 TurboFan。TurboFan 会根据执行信息做出一些进一步优化此代码的假设,在假设的基础上将字节码编译为优化的机器代码。如果假设成立,那么当下一次调用该函数时,就会执行优化编译后的机器代码,以提高代码的执行性能。 如果假设不成立,上图中,绿色的线,是“ 去优化(Deoptimize)”的过程,如果TurboFan生成的优化机器码,对需要执行的代码不适用,会把优化的机器码,重新转换成字节码来执行。这是因为Ignition收集的信息可能是错误的。 例如: add函数的参数之前是整数,后来又变成了字符串。生成的优化机器码已经假定add函数的参数是整数,那当然是错误的,于是需要进行去优化 ,Deoptimize为Bytecode来执行。 TurboFan除了上面基于类型做优化和反优化,还有包括 内联(inlining)和 逃逸分析(Escape Analysis)等,内联就是将相关联的函数进行合并。例如: 内联优化后: 使用node命令的 --print-code以及 --print-opt-code选项,可以打印出 TurboFan生成的汇编代码。 一个高效的垃圾回收器,用于自动管理内存,回收不再使用的对象内存;它使用多种垃圾回收策略,如分代回收、标记-清除、增量标记等,以实现高效内存管理。 Orinoco的主要特点包括: 我们的开发环境各式各样可能系统是Mac,Linux或者Windows,架构是x86或者arm,所以要想编译出可以跑在鸿蒙系统上的v8库我们需要使用交叉编译,它是在一个平台上为另一个平台编译代码的过程,允许我们在一个平台上为另一个平台生成可执行文件。这在嵌入式系统开发中尤为常见,因为许多嵌入式设备的硬件资源有限,不适合直接在上面编译代码。 交叉编译需要一个特定的编译器、链接器和库,这些都是为目标平台设计的。此外,开发者还需要确保代码没有平台相关的依赖,否则编译可能会失败。 v8官网上关于交叉编译Android和iOS平台的V8已经有详细的介绍。尚无关于鸿蒙OHOS平台的文档。V8官方使用的构建系统是 gn + ninja。gn是一个 元构建系统,最初由 Google开发,用于生成 Ninja文件。它提供了一个声明式的方式来定义项目的依赖关系、编译选项和其他构建参数。通过运行 gn gen命令,可以生成一个 Ninja文件。类似于 camke + make构建系统。 gn + ninja的构建流程如下: 通过查看 鸿蒙sdk,我们发现鸿蒙提供给开发者的native构建系统是 cmake + ninja,所以我们决定将v8官方采用的 gn + ninja转成 cmake + ninja。这就需要将gn语法的构建配置文件转成cmake的构建配置文件。 CMake是一个开源的、跨平台的构建系统。它不仅可以生成标准的 Unix Makefile配合 make命令使用,还能够生成 build.ninja文件配合 ninja使用,还可以为多种 IDE生成项目文件,如 Visual Studio、Eclipse、Xcode等。这种跨平台性使得 CMake在多种操作系统和开发环境中都能够无缝工作。 cmake的构建流程如下: CMake构建主要过程是编写 CMakeLists.txt文件,然后用cmake命令将CMakeLists.txt文件转化为make所需要的Makefile文件或者ninja需要的build.ninja文件,最后用 make命令或者 ninja命令执行编译任务生成可执行程序或共享库(so(shared object))。 完整 CMakeLists.txt文件的主要配置样例: 一般把 CMakeLists.txt文件放在工程目录下,使用时先创建一个叫 build的文件夹(这个并非必须,因为cmake命令指向 CMakeLists.txt所在的目录,例如 cmake .. 表示 CMakeLists.txt在当前目录的上一级目录。cmake执行后会生成很多编译的中间文件,所以一般建议新建一个新的目录,专门用来编译),通常构建步骤如下: 其中 cmake ..在 build文件夹下生成 Makefile。 make命令在 Makefile所在的目录下执行,根据 Makefile进行编译。 或者 cmake -G Ninja .. 在 build文件夹下生成 build.ninja。 ninja命令在 build.ninja所在的目录下执行,根据 build.ninja进行编译。 直接在 CMakeLists.txt文件中,使用 CMAKE_C_COMPILER和 CMAKE_CXX_COMPILER这两个变量来指定C和C++的编译器路径。使用 CMAKE_LINKER变量来指定项目的链接器。这样,当CMake生成构建文件时,就会使用指定的编译器来编译源代码。使用指定的链接器进行项目的链接操作。 以下是一个简单的设置交叉编译器和链接器的 CMakeLists.txt文件示例: 另外我们还可以使用单独工具链文件配置交叉编译环境。 工具链文件(toolchain file)是将配置信息提取到一个单独的文件中,以便于在多个项目中复用。包含一系列CMake变量定义,这些变量指定了编译器、链接器和其他工具的位置,以及其他与目标平台相关的设置,以确保它能够正确地为目标平台生成代码。它让我们可以专注于解决实际的问题,而不是每次都要手动配置编译器和工具。 一个基本的工具链文件示例如下: 创建一个名为 toolchain.cmake的文件,并在其中定义工具链的路径和设置: 该项目需要为ARM架构的Linux系统进行交叉编译 在执行 cmake命令构建时,使用 -DCMAKE_TOOLCHAIN_FILE参数指定工具链文件的路径: 这样,CMake就会使用工具链文件中指定的编译器和设置来为目标平台生成代码。 常规C++项目按照上述交叉编译介绍的配置即可完成交叉编译过程,但是V8的移植必须充分理解builtin和snapshot才能完成!一般的库,所谓交叉编译就是调用目标平台指定的工具链直接编译源码生成目标平台的文件。比如一个C文件要给android用,调用ndk包的gcc、clang编译即可。但由于v8的builtin实际用的是v8自己的工具链体系编译成目标平台的代码,所以并不能套用上面的方式。 在V8引擎中,builtin即内置函数或模块。V8的内置函数和模块是JavaScript语言的一部分,提供了一些基本的功能,例如数学运算、字符串操作、日期处理等。另外ignition解析器每一条字节码指令实现也是一个builtin。 V8的内置函数和模块是通过C++代码实现的,并在编译时直接集成到V8引擎中。这些内置函数和模块不需要在JavaScript代码中显式地导入或引用,就可以直接使用。 以下是一些V8的内置函数和模块的例子: 这些内置函数和模块都是V8引擎的重要组成部分,提供了基础的JavaScript功能。它们是V8运行时最重要的“积木块”; v8源码中 builtin的编译比较绕,因为v8中大多数 builtin的“源码”,其实是 builtin的生成逻辑,这也是理解V8源码的关键。 builtin和 snapshot都是通过 mksnapshot工具运行生成的。 mksnapshot是v8编译过程中的一个中间产物,也就是说v8编译过程中会生成一个 mksnapshot可执行程序并且会执行它生成v8后续编译需要的builtin和snapshot,就像套娃一样。 例如v8源码中 字节码Ldar指令的实现如下: 上述代码只在V8的 编译阶段由 mksnapshot程序执行,执行后会产出机器码( JIT),然后 mksnapshot程序把生成的机器码dump下来放到汇编文件 embedded.S里,编译进V8运行时(相当于用 JIT编译器去 AOT)。 builtin被dump到 embedded.S的对应v8源码在 v8/src/snapshot/embedded-file-writer.h 上述 Ldar指令dump到 embedded.S后汇编代码如下: builtin在v8源代码 v8\src\builtins\builtins-definitions.h中定义,这个文件还include一个根据 ignition指令生成的builtin列表以及 torque编译器生成的builtin定义,一共 1700+个builtin。每个builtin,都会在 embedded.S中生成一段代码。 builtin生成的v8源代码在: v8\src\builtins\setup-builtins-internal.cc BUILTIN_LIST宏内定义了所有的builtin,并根据其类型去调用不同的参数,在这里参数是BUILD_CPP, BUILD_TFJ...这些,定义了不同的生成策略,这些参数去掉前缀代表不同的builtin类型( CPP, TFJ, TFC, TFS, TFH, BCH, ASM) mksnapshot执行时生成builtin的方式有两种: 例如: DoubleToI是一个 ASM类型builtin,功能是把double转成整数,该builtin的JIT生成逻辑位于 Builtins::Generate_DoubleToI,如果是x64的window,该函数放在 v8/src/builtins/x64/builtins-x64.cc文件。由于每个CPU架构的指令都不一样,所以每个CPU架构都有一个实现,放在各自的 builtins-ArchName.cc文件。 x64的实现如下: 看上去很像汇编(编程的思考方式按汇编来),实际上是c++函数,比如这行 movl __是个宏,实际上是调用 masm变量的函数( movl) 而 movl的实现是往 pc_指针指向的内存写入mov指令及其操作数,并把 pc_指针前进指令长度。 ps:一条条指令写下来,然后把 内存权限改为可执行,这就是JIT的基本原理。 除了ASM和CPP的其它类型builtin都通过调用 CodeStubAssembler API(下称 CSA)编写,这套API和之前介绍ASM类型builtin时提到的“类汇编API”类似,不同的是“类汇编API”直接产出原生代码,CSA产出的是 turbofan的 graph(IR)。CSA比起“类汇编API”的好处是不用每个平台各写一次。 但是类汇编的CSA写起来还是太费劲了,于是V8提供了一个 类javascript的语言: torque ,这语言最终会编译成 CSA形式的c++代码和V8其它C++代码一起编译。 例如 Array.isArray使用 torque语言实现如下: 经过 torque编译器编译后,会生成一段复杂的 CSA的C++代码,下面截取一个片段 和上面讲的 Ldar字节码一样,这并不是跑在v8运行时的Array.isArray实现。这段代码只运行在 mksnapshot中,这段代码的产物是turbofan的IR。IR经过turbofan的优化编译后生成目标机器指令,然后dump到 embedded.S汇编文件,下面才是真正跑在v8运行时的Array.isArray: 在这个过程中,JIT编译器turbofan同样干的是AOT的活。 mksnapshot生成的包含所有builtin的产物 embedded.S会和其他v8源码一起编译成最终的v8库, embedded.S中声明了四个全局变量,分别是: 在v8/src/execution/isolate.cc中声明了几个extern变量,链接embedded.S后v8/src/execution/isolate.cc就能引用到那几个变量: v8_Default_embedded_blob_data_中包含了各builtin的偏移,这些偏移组成一个数组,放在isolate的 builtin_entry_table,数组下标是该builtin的枚举值。调用某builtin就是 builtin_entry_table通过枚举值获取起始地址调用。 在V8引擎中,snapshot是指在启动时将部分或全部JavaScript堆内存的状态保存到一个文件中,以便在后续的启动中可以快速恢复到这个状态。这个技术可以显著减少V8引擎的启动时间,特别是在大型应用程序中。 snapshot文件包含了以下几个部分: 当V8引擎启动时,如果存在有效的Snapshot文件,V8会直接从这个文件中读取JavaScript堆的状态和字节码,而不需要重新解析和编译所有的JavaScript代码。这可以大幅度缩短V8引擎的启动时间。V8的Snapshot技术有以下几个优点: 如果不是交叉编译,snapshot生成还是挺容易理解的:v8对各种对象有做了 序列化和反序列化的支持,所谓生成 snapshot,就是 序列化,通常会以context作为根来序列化。 mksnapshot制作快照可以输入一个额外的脚本,也就是生成snapshot前允许执行一段代码,这段代码调用到的函数的编译结果也会序列化下来,后续加载快照反序列化后等同于执行过了这脚本,就免去了编译过程,大大加快的启动的速度。 mksnapshot制作快照是通过调用v8::SnapshotCreator完成,而v8::SnapshotCreator提供了我们输入外部数据的机会。如果只有一个Context需要保存,用SnapshotCreator::SetDefaultContext就可以了,恢复时直接v8::Context::New即可。如果有多于一个Context,可以通过SnapshotCreator::AddContext添加,它会返回一个索引,恢复时输入索引即可恢复到指定的存档。如果保存Context之外的数据,可以调用SnapshotCreator::AddData,然后通过Isolate或者Context的GetDataFromSnapshot接口恢复。 结合交叉编译时就会有个很费解的地方:我们前面提到 mksnapshot在交叉编译时,JIT生成的builtin是目标机器指令,而js的运行得通过跑builtin来实现(Ignition解析器每个指令就是一个builtin),这目标机器指令(比如arm64)怎么在本地(比如linux 的x64)跑起来呢? mksnapshot为了实现交叉编译中目标平台snapshot的生成,它做了 各种cpu(arm、mips、risc、ppc)的模拟器(Simulator) 通过查看源码交叉编译时, mksnapshot会用一个目标机器的模拟器来跑这些builtin: 如果交叉编译,将会走 USE_SIMULATOR分支。 arm64将会调用到 v8/src/execution/simulator-arm64.h, v8/src/execution/simulator-arm64.cc实现的模拟器。上面Call的处理是把指令首地址赋值到模拟器的_pc寄存器,参数放寄存器,执行完指令从寄存器获取返回值。 一般我们将负责编译的机器称为 host,编译产物运行的目标机器称为 target。 如果要在 Mac M1上交叉编译鸿蒙 arm64的builtin,步骤如下: 鸿蒙sdk自带构建工具我们可以将它们加入环境变量中使用 总共有1千多行,部分 CMakeList.txt片段: 首先创建一个编译目录 build,打开 build执行 cmake -G Ninja .. 生成针对 ninja编译需要的文件。 下面是控制台打印的工具链配置信息,使用的是Mac本地xcode的工具链: build文件夹下生成以下文件: 其中 CMakeCache.txt是一个由CMake生成的缓存文件,用于存储CMake在配置过程中所做的选择和决策。它是根据你的项目的 CMakeLists.txt文件和系统环境来生成一个初始的 CMakeCache.txt文件。这个文件包含了所有可配置的选项及其默认值。 build.ninja文件是 Ninja的主要输入文件,包含了项目的所有构建规则和依赖关系。 这个文件的内容是Ninja的语法,描述了如何从源文件生成目标文件。它包括了以下几个部分: 然后执行 cmake --build . 或者 ninja 查看 build文件夹下生成的产物: 其中红框中的三个可执行文件是在编译过程中生成,同时还会在编译过程中执行。 bytecode_builtins_list_generator主要生成是字节码对应builtin的生成代码。 torque负责将. tq后缀的文件(使用 torque语言编写的builtin)编译成 CSA类型builtin的c++源码文件。 torque编译 .tq文件生成的c++代码在 torque-generated目录中: bytecode_builtins_list_generator执行生成字节码函数列表在下面目录中: mksnapshot则链接这些代码并执行,执行期间会在内置的对应架构模拟器中运行v8,最终生成host平台的 buildin汇编代码——embedded.S和snapshot(context的序列化对象)——snapshot.cc。它们跟随其他v8源代码一起编译生成最终的v8静态库 libv8_snapshot.a。目前build目录中已经编译出host平台的完整v8静态库及命令行调试工具 d8。 mksnapshot程序自身的编译生成及执行在 CMakeList.txt中的配置代码如下: 因为在编译target平台的v8时中间生成的 bytecode_builtins_list_generator,torque,mksnapshot可执行文件是针对target架构的无法在host机器上执行。所以首先需要把上面在host平台生成的可执行文件拷贝到 /usr/local/bin,这样在编译target平台的v8过程中执行这些中间程序时会找到 /usr/local/bin下的可执行文件正确的执行生成针对target的builtin和snapshot快照。 执行第一步cmake配置后控制台的信息可以看到,使用了鸿蒙的工具链 执行完成后 ohosbuild文件夹下生成了鸿蒙平台的v8静态库,可以修改 CMakeList.txt配置合成一个.a或者生成.so。 将v8源码中的include目录和上面编译生成的.a文件放入cpp文件夹下 设置c++标准17,链接v8静态库 下面是简单的demo 导出c++方法 、 arkts侧调用c++方法 运行查看结果: 随着物联网的发展,人们对IOT设备(如智能手表)的使用越来越多。如果希望把JS应用到IOT领域,必然需要从JS引擎角度去进行优化,只是去做上层的框架收效甚微。因为对于IOT硬件来说,CPU、内存、电量都是需要省着点用的,不是每一个智能家电都需要装一个骁龙855。那怎么可以基于V8引擎进行改造来进一步提升JS的执行性能呢? 基于V8引擎来实现,技术上应该是可行的: 这样可以将JS引擎简化很多,一方面不再需要parse以及生成bytecode,另一方面编译器不再需要因为JavaScript动态特性做很多额外的工作。因此可以减少CPU、内存以及电量的使用,优化性能,唯一的问题是必须使用严格的TS语法进行编程。 Facebook的 Hermes差不多就是这么干的,只是它没有要求用TS编程。 如今 鸿蒙原生的ETS引擎 Panda也是这么干的,它要求使用ets语法,其实是基于TS只不过做了更加严格的类型及语法限制(舍弃了更多的动态特性),进一步提升js的执行性能。 将V8移植到鸿蒙系统是一个巨大的 嵌入式范畴工作,涉及交叉编译、CMake、CLang、Ninja、C++、torque等各种知识,虽然我们经历了巨大挑战并掌握了V8移植技术,但出于应用包大小、稳定性、兼容性、维护成本等维度综合考虑,如果华为系统能内置V8,对Roma框架及业界所有依赖JS虚拟机的跨端框架都是一件意义深远的事情,通过和华为持续沟通,鸿蒙从API11版本提供了一个内置的JS引擎,它实际上是基于v8的封装,并提供了一套c-api接口。 如果不想用c-api并且不考虑包大小的问题仍然可以自己编译一个独立的v8引擎嵌入APP,直接使用v8面向对象的C++ API。 Roma框架是一个涉及JavaScript、C&C++、Harmony、iOS、Android、Java、Vue、Node、Webpack等众多领域的综合解决方案,我们有各个领域优秀的小伙伴共同前行,大家如果想深入了解某个领域的具体实现,可以随时留言交流~一、移植背景
二、JS引擎选型
三、V8引擎的工作原理
1、Parser:解析器
2、Ignition:(interpreter)解释器
3、TurboFan:(compiler)编译器
4、Orinoco:垃圾回收
四、V8移植工具选型
1、CMake简介
2、CMake中的交叉编译设置
配置方式一:
配置方式二:CMake中使用工具链文件配置
五、V8和常规C++库移植的重大差异
1、builtin
1.1、builtin是什么
1.2、builtin是如何生成的
1.3、builtin是怎么加载使用的
2、snapshot
六、V8移植的具体步骤
1.首先安装 cmake及 ninja构建工具
2.编写交叉编译V8到鸿蒙的CMakeList.txt
3.使用 host本机的编译工具链编译
$ mkdir build $ cd build $ cmake -G Ninja .. $ ninja 或者 cmake --build . 4.使用鸿蒙SDK的编译工具链编译
七、鸿蒙工程中使用v8库
1.新建native c++工程
2.导入v8库
3.修改cpp目录下CMakeList.txt文件
4.添加napi方法测试使用v8
八、JS引擎的发展趋势