|
史无前例!移植V8虚拟机到纯血鸿蒙系统
京东科技 于飞跃
京东技术
京东技术 北京京东尚科信息技术有限公司 京东官方技术分享平台。你想知道的京东前沿技术、创新思考、开源方案...这里应有尽有! 266篇内容
2024年09月26日 09:30
北京
01背景理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将如图所示,Roma框架是我们自主研发的动态化跨平台解决方案,已支持iOS,android,web三端。目前在京东金融APP已经有200+页面,200+乐高楼层使用,为保证基于Roma框架开发的业务可以零成本、无缝运行到鸿蒙系统,需要将Roma框架适配到鸿蒙系统。Roma框架是基于JS引擎运行的,在iOS系统使用系统内置的JavascriptCore,在Android系统使用V8,然而,鸿蒙系统当时却没有可以执行Roma框架的JS引擎,因此需要移植一个JS引擎到鸿蒙平台。02JS引擎选型理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将目前主流的JS引擎有以下这些:引擎名称应用代表公司V8Chrome/Opera/Edge/Node.js/ElectronGoogleSpiderMonkeyfirefoxMozillaJavaScriptCoreSafariAppleChakraIEMicrosoftHermesReact NativeFacebookJerryScript/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等各个领域。03V8移植工具选型理解,首先 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系统进行交叉编译# 设置C和C++编译器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就会使用工具链文件中指定的编译器和设置来为目标平台生成代码。04 V8和常规C++库移植的重大差异理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将一般的库,所谓交叉编译就是调用目标平台指定的工具链直接编译源码生成目标平台的文件。比如一个C文件要给android用,调用ndk包的gcc、clang编译即可。但由于v8的builtin和snapshot用的是v8自己的工具链体系编译成目标平台的代码,所以并不能直接套用这种方式。1. builtin1.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 0x72ba0b74d93b48fffffff91d8d48,0xec83481c6ae5894855ccffa9104ae800 .octa 0x2454894cf0e4834828ec8348e2894920,0x458948e04d894ce87d894cf065894c20 .octa 0x4d0000494f808b4500001410858b4dd8,0x1640858b49e1894c00000024bac603 .octa 0x4d00000000158d4ccc01740fc4f64000,0x2045c749d0ff206d8949285589 .octa 0xe4834828ec8348e289492024648b4800,0x808b4500001410858b4d202454894cf0 .octa 0x858b49d84d8b48d233c6034d00004953,0x158d4ccc01740fc4f64000001640 .octa 0x2045c749d0ff206d89492855894d0000,0x5d8b48f0658b4c2024648b4800000000 .octa 0x4cf7348b48007d8b48011c74be0f49e0,0x100000000ba49211cb60f43024b8d .octa 0xa90f4fe800000002ba0b77d33b4c0000,0x8b48006d8b48df0c8b49e87d8b4cccff .octa 0xcccccccccccccccc90e1ff30c48348c6.byte0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xcc,0xccbuiltin在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的方式有两种:直接生成机器码,ASM和CPP类型builtin使用这种方式(CPP类型只是生成适配器)先生成turbofan的graph(IR),然后由turbofan编译器编译成机器码,除ASM和CPP之外其它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 runtimenamespace array {// ES #sec-array.isarrayjavascript 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编译器编译后,会生成一段复杂的CSA的C++代码,下面截取一个片段TNode Cast_JSProxy_1(compiler::CodeAssemblerState* state_, TNode p_context, TNode 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{p_o}, &label1); ca_.Goto(&block3); if (label1.is_used()) { ca_.Bind(&label1); ca_.Goto(&block4); } } // other code ...}和上面讲的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 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的活。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文件中。05V8移植的具体步骤理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将一般我们将负责编译的机器称为host,编译产物运行的目标机器称为target。本文使用的host机器是Mac M1 ,Xcode版本Version 14.2 (14C18)鸿蒙IDE版本:DevEco Studio NEXT Developer Beta5鸿蒙SDK版本是HarmonyOS-NEXT-DB5目标机器架构:arm64-v8a如果要在Mac M1上交叉编译鸿蒙arm64的builtin,步骤如下:调用本地编译器,编译一个Mac M1版本mksnapshot可执行程序执行上述mksnapshot生成鸿蒙平台arm64指令并dump到embedded.S调用鸿蒙sdk的工具链,编译链接embedded.S和v8的其它代码,生成能在鸿蒙arm64上使用的v8库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 ..生成针对ninja编译需要的文件。下面是控制台打印的工具链配置信息,使用的是Mac本地xcode的工具链:build文件夹下生成以下文件:其中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快照。$ cp bytecode_builtins_list_generator torque mksnapshot /usr/local/bin$ mkdir ohosbuild #创建新的鸿蒙v8的编译目录$ cd ohosbuild#使用鸿蒙提供的工具链文件$ cmake -DOHOS_STL=c++_shared -DOHOS_ARCH=arm64-v8a -DOHOS_PLATFORM=OHOS -DCMAKE_TOOLCHAIN_FILE=/Applications/DevEco-Studio.app/Contents/sdk/HarmonyOS-NEXT-DB5/openharmony/native/build/cmake/ohos.toolchain.cmake -G Ninja ..$ ninja 或者 cmake --build .执行第一步cmake配置后控制台的信息可以看到,使用了鸿蒙的工具链执行完成后ohosbuild文件夹下生成了鸿蒙平台的v8静态库,可以修改CMakeList.txt配置合成一个.a或者生成.so。06鸿蒙工程中使用V8库理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将1. 新建native c++工程2. 导入v8库将v8源码中的include目录和上面编译生成的.a文件放入cpp文件夹下3. 修改cpp目录下CMakeList.txt文件设置c++标准17,链接v8静态库4. 添加napi方法测试使用v8下面是简单的demo导出c++方法arkts侧调用c++方法运行查看结果:07 JS引擎的发展趋势理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将随着物联网的发展,人们对IOT设备(如智能手表)的使用越来越多。如果希望把JS应用到IOT领域,必然需要从JS引擎角度去进行优化,只是去做上层的框架收效甚微。因为对于IOT硬件来说,CPU、内存、电量都是稀缺资源。那怎么可以基于V8引擎进行改造来进一步提升JS的执行性能呢?使用TypeScript编程,遵循严格的类型化编程规则;构建的时候将TypeScript直接编译为Bytecode,而不是生成JS文件,这样运行的时候就省去了Parse以及生成Bytecode的过程;运行的时候,需要先将Bytecode编译为对应CPU的汇编代码;由于采用了类型化的编程方式,有利于编译器优化所生成的汇编代码,省去了很多额外的操作。如果用V8引擎来实现可以将Parser以及Ignition拆分出来,用于构建阶段,删除TurboFan处理JS动态特性的相关代码,这样可以将JS引擎简化很多,一方面不再需要parse以及生成bytecode,另一方面编译器不再需要因为JavaScript动态特性做很多额外的工作。因此可以减少CPU、内存以及电量的使用,优化性能。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等众多领域的综合解决方案,我们这里有各个领域优秀的小伙伴共同前行,大家如果想深入了解某个领域的具体实现,可以随时留言交流~推荐阅读整洁架构演进之路——京东广告投放平台实战鸿蒙跨端实践-布局方案介绍【黄金圆环】在研发领域的实践分享京东鸿蒙上线前瞻——使用 Taro 打造高性能原生应用关注【京东技术】后台回复【加入京东】获取专属社招和校招内推码!底层能力:维护用户基础数据、行为数据建模、用户画像分析、精准营销策略的制定功能支撑:会员成长体系、等级计算策略、权益体系、营销底层能力支持用户活跃:会员关怀、用户触达、活跃活动、业务线交叉获客、拉新促活
|
|