背景
理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将
如图所示,
Roma框架
是我们自主研发的
动态化跨平台解决方案
,已支持
iOS,android,web
三端。目前在京东金融APP已经有200+页面,200+乐高楼层使用,为保证基于Roma框架开发的业务可以零成本、无缝运行到鸿蒙系统,需要将Roma框架适配到鸿蒙系统。
Roma框架是基于JS引擎运行的,在iOS系统使用系统内置的JavascriptCore,在Android系统使用V8,然而,
鸿蒙系统当时却没有可以执行Roma框架的JS引擎
,因此
需要移植一个JS引擎到鸿蒙平台
。
JS引擎选型
理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将
引擎名称
应用代表
公司
V8
Chrome/Opera/Edge/Node.js/Electron
Google
SpiderMonkey
firefox
Mozilla
JavaScriptCore
Safari
Apple
Chakra
IE
Microsoft
Hermes
React Native
Facebook
JerryScript/duktape/QuickJS
小型并且可嵌入的Javascript引擎/主要应用于IOT设备
-
其中最流行的是
Google开源的V8引擎
,除了
Chrome
等浏览器,
Node.js
也是用的V8引擎。Chrome的市场占有率高达60%,而Node.js是JS后端编程的事实标准。另外,
Electron(桌面应用框架)
是基于Node.js与Chromium开发桌面应用,也是基于V8的。国内的众多浏览器,其实也都是基于
Chromium
浏览器开发,而Chromium相当于开源版本的Chrome,自然也是基于V8引擎的。甚至连浏览器界独树一帜的
Microsoft
也投靠了Chromium阵营。V8引擎使得JS可以应用在
Web、APP、桌面端、服务端以及IOT
等各个领域。
V8移植工具选型
理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将
我们的开发环境各式各样可能系统是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的构建配置文件。
1. 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))。
2. CMake中的交叉编译设置
CMake中可以使用工具链文件进行交叉编译设置,
工具链文件(toolchain file)
是将配置信息提取到一个单独的文件中,以便于在多个项目中复用。包含一系列CMake变量定义,这些变量指定了编译器、链接器和其他工具的位置,以及其他与目标平台相关的设置,以确保它能够正确地为目标平台生成代码。
创建一个名为
toolchain.cmake
的文件,并在其中定义工具链的路径和设置:
该项目需要为ARM架构的Linux系统进行交叉编译
set (CMAKE_C_COMPILER "/path/to/c/compiler" )
set (CMAKE_CXX_COMPILER "/path/to/cxx/compiler" )
set (CMAKE_LINKER "/path/to/linker" )
set (CMAKE_SYSTEM_NAME Linux)
set (CMAKE_SYSTEM_PROCESSOR arm)
在执行
cmake
命令构建时,使用
-DCMAKE_TOOLCHAIN_FILE
参数指定工具链文件的路径:
cmake -DCMAKE_TOOLCHAIN_FILE=/path/to /toolchain.cmake /path /to/source
这样,CMake就会使用工具链文件中指定的编译器和设置来为目标平台生成代码。
V8和常规C++库移植的重大差异
理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将
一般的库,所谓交叉编译就是调用目标平台指定的工具链直接编译源码生成目标平台的文件。比如一个C文件要给android用,调用ndk包的gcc、clang编译即可。但由于v8的builtin和snapshot用的是v8自己的工具链体系编译成目标平台的代码,所以并不能直接套用这种方式。
1. builtin
1.1 builtin是什么
在V8引擎中,builtin即内置函数或模块。V8的内置函数和模块是JavaScript语言的一部分,提供了一些基本的功能,例如数学运算、字符串操作、日期处理等,这些内置函数和模块是通过C++代码实现的,并在编译时直接集成到V8引擎中,不需要在JavaScript代码中显式地导入或引用,就可以直接使用。另外ignition解析器每一条字节码指令实现也是一个builtin。
1.2 builtin是如何生成的
v8源码中
builtin
的编译比较绕,因为v8中大多数
builtin
的“源码”,其实是
builtin
的生成逻辑,这也是理解V8源码的关键。
builtin
和
snapshot
都是通过
mksnapshot
工具运行生成的。
mksnapshot
是v8编译过程中的一个中间产物,也就是说v8编译过程中会生成一个
mksnapshot
可执行程序并且会执行它生成v8后续编译需要的builtin和snapshot,就像套娃一样。
例如v8源码中
字节码Ldar
指令的实现如下:
IGNITION_HANDLER(Ldar, InterpreterAssembler) {
TNode value = LoadRegisterAtOperandIndex(0 );
SetAccumulator(value );
Dispatch();
}
上述代码只在V8的
编译阶段
由
mksnapshot
程序执行,执行后会产出机器码(
JIT
),然后
mksnapshot
程序把生成的机器码dump下来放到汇编文件
embedded.S
里,编译进V8运行时(相当于用
JIT
编译器去
AOT
)。
上述
Ldar
指令dump到
embedded.S
后汇编代码如下:
Builtins_LdarHandler:
.def Builtins_LdarHandler; .scl 2 ; .type 32 ; .endef;
.octa 0x72ba0b74d93b48fffffff91d8d 48,0xec83481c6ae5894855ccff a9104ae800
.octa 0x2454894cf 0e4834828ec8348e2894920,0x458948e04d894ce87d894cf 065894c20
.octa 0x4d0000494f808b4500001410858b4dd 8,0x1640858b49e1894c00000024bac603
.octa 0x4d00000000158d4ccc01740fc4f 64000,0x2045c749d0ff206d 8949285589
.octa 0xe4834828ec8348e289492024648b4800 ,0x808b4500001410858b4d202454894cf 0
.octa 0x858b49d84d8b48d233c6034d 00004953,0x158d4ccc01740fc4f 64000001640
.octa 0x2045c749d0ff206d89492855894d 0000,0x5d8b48f 0658b4c2024648b4800000000
.octa 0x4cf7348b48007d8b48011c74be0f 49e0,0x100000000ba49211cb60f43024b8d
.octa 0xa90f4fe800000002ba0b77d 33b4c0000,0x8b48006d8b48df0c8b49e87d8b4cccff
.octa 0xcccccccccccccccc90e1ff 30c48348c6
.byte 0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc
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
文件。
除了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
语言实现如下:
namespace runtime {
extern runtime ArrayIsArray(implicit context: Context)(JSAny): JSAny;
}
namespace array {
javascript builtin ArrayIsArray(js-implicit context: NativeContext)(arg: JSAny):
JSAny {
typeswitch (arg) {
case (JSArray): {
return True;
}
case (JSProxy): {
return runtime::ArrayIsArray(arg);
}
case (JSAny): {
return False;
}
}
}
}
经过
torque编译器
编译后,会生成一段复杂的
CSA
的C++代码,下面截取一个片段
TNode Cast_JSProxy_1(compiler::CodeAssemblerState* state_, TNode p_context, TNode p_o, compiler::CodeAssemblerLabel* label_CastError) {
if (block0.is_used()) {
ca_.Bind(&block0);
ca_.SetSourcePosition("../../src/builtins/cast.tq" , 162 );
compiler::CodeAssemblerLabel label1(&ca_);
tmp0 = CodeStubAssembler(state_).TaggedToHeapObject(TNode{p_o}, &label1);
ca_.Goto (&block3);
if (label1.is_used()) {
ca_.Bind(&label1);
ca_.Goto (&block4);
}
}
}
和上面讲的
Ldar字节码
一样,这并不是跑在v8运行时的Array.isArray实现。这段代码只运行在
mksnapshot
中,这段代码的产物是turbofan的IR。IR经过turbofan的优化编译后生成目标机器指令,然后dump到
embedded.S
汇编文件,下面才是真正跑在v8运行时的Array.isArray:
Builtins_ArrayIsArray:
.type Builtins_ArrayIsArray, %function
.size Builtins_ArrayIsArray, 214
.octa 0xd10043ff910043fda9017bfda9be6f e1,0x540003a9eb2263fff8560342f 81e83a0
.octa 0x7840b063f85ff04336000182f 9401be2,0x14000007d2800003540000607110907f
.octa 0x910043ffa8c17bfd910003bff 85b8340,0x35000163d2800020d2800023d65f 03c0
.octa 0x540000e17102d47f7840b063f85ff 043,0xf94da741f90003e2f90007ffd10043ff
.octa 0x17ffffeef85c034017fffff097ff b480,0xaa1b03e2f9501f41d2800000f90003f b
.octa 0x17ffffddf94003fb97ff b477aa0003e3,0x840000000100000002d503201f
.octa 0xffffffff000000a8ffffffffffffffff
.byte 0xff ,0xff ,0xff ,0xff ,0x0 ,0x1 ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc ,0xcc
在这个过程中,JIT编译器turbofan同样干的是AOT的活。
2. snapshot
在V8引擎中,snapshot是指将部分或全部JavaScript堆内存的状态保存到一个文件中,以便在后续的启动中可以快速恢复到这个状态。当V8引擎启动时,如果存在有效的Snapshot文件,V8会直接从这个文件中读取JavaScript堆的状态和字节码,而不需要重新解析和编译所有的JavaScript代码。这可以大幅度缩短V8引擎的启动时间,特别是在大型应用程序中。
如果不是交叉编译,snapshot生成还是挺容易理解的:v8对各种对象有做了
序列化和反序列化
的支持,所谓生成
snapshot
,就是
序列化
,通常会以context作为根来序列化。
在交叉编译时,JIT生成的builtin是目标机器指令,而js的运行得通过跑builtin来实现(Ignition解析器每个指令就是一个builtin),这目标机器指令(比如arm64)怎么在本地(比如linux 的x64)跑起来呢?是因为
mksnapshot
为了实现交叉编译中目标平台snapshot的生成,做了
各种cpu(arm、mips、risc、ppc)的模拟器(Simulator),相关模拟器的实现在v8/src/execution/simulator-ArchName.h,v8/src/execution/simulator-ArchName.cc文件中
。
V8移植的具体步骤
理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将
一般我们将负责编译的机器称为
host
,编译产物运行的目标机器称为
target
。
本文使用的host机器是
Mac M1 ,Xcode版本Version 14.2 (14C18)
鸿蒙IDE版本:
DevEco Studio NEXT Developer Beta5
鸿蒙SDK版本是
HarmonyOS-NEXT-DB5
如果要在
Mac M1
上交叉编译鸿蒙
arm64的builtin
,步骤如下:
1. 首先安装cmake及ninja构建工具
鸿蒙sdk自带构建工具我们可以将它们加入环境变量中使用
2. 编写交叉编译V8到鸿蒙的CMakeList.txt
总共有1千多行,部分
CMakeList.txt
片段:
3. 使用host本机的编译工具链编译
$ mkdir build
$ cd build
$ cmake -G Ninja ..
$ ninja 或者 cmake --build .
首先创建一个编译目录
build
,打开
build
执行
cmake -G Ninja ..
生成针对n
inja
编译需要的文件。
下面是控制台打印的工具链配置信息,使用的是Mac本地xcode的工具链:
其中
CMakeCache.txt
是一个由CMake生成的缓存文件,用于存储CMake在配置过程中所做的选择和决策。它是根据你的项目的
CMakeLists.txt
文件和系统环境来生成一个初始的
CMakeCache.txt
文件。这个文件包含了所有可配置的选项及其默认值。
build.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
中的配置代码如下:
4. 使用鸿蒙SDK的编译工具链编译
因为在编译target平台的v8时中间生成的
bytecode_builtins_list_generator
,
torque
,
mksnapshot
可执行文件是针对target架构的无法在host机器上执行。所以首先需要把上面在host平台生成的可执行文件拷贝到
/usr/local/bin
,这样在编译target平台的v8过程中执行这些中间程序时会找到
/usr/local/bin
下的可执行文件正确的执行生成针对target的builtin和snapshot快照。