作者:京東科技 于飛躍
一、背景
如圖所示,Roma架構是我們自主研發的動态化跨平台解決方案,已支援iOS,android,web三端。目前在京東金融APP已經有200+頁面,200+樂高樓層使用,為保證基于Roma架構開發的業務可以零成本、無縫運作到鴻蒙系統,需要将Roma架構适配到鴻蒙系統。
Roma架構是基于JS引擎運作的,在iOS系統使用系統内置的JavascriptCore,在Android系統使用V8,然而,鴻蒙系統卻沒有可以執行Roma架構的JS引擎,是以需要移植一個JS引擎到鴻蒙平台。
二、JS引擎選型
目前主流的JS引擎有以下這些:
引擎名稱 | 應用代表 | 公司 |
V8 | Chrome/Opera/Edge/Node.js/Electron | |
SpiderMonkey | firefox | Mozilla |
JavaScriptCore | Safari | Apple |
Chakra | IE | Microsoft |
Hermes | React Native | |
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引擎的工作原理
V8的主要任務是執行JavaScript代碼,并且能夠處理JavaScript源代碼、即時編譯(JIT)代碼以及執行代碼。v8是一個非常複雜的項目,有超過100萬行C++代碼。
下圖展示了它的基本工作流程:
如圖所示,它通過詞法分析、文法分析、位元組碼生成與執行、即時編譯與機器碼生成以及垃圾回收等步驟,實作了對JavaScript源代碼的高效執行。此外,V8引擎還通過監控代碼的執行情況,對熱點函數進行自動優化,進而進一步提高了代碼的執行性能。其中 Parser(解析器)、Ignition(解釋器)、TurboFan(編譯器) 、Orinoco(垃圾回收)是 V8 中四個核心工作子產品,對應的V8源碼目錄如下圖。
1、Parser:解析器
負責将JavaScript源碼轉換為Abstract Syntax Tree (AST)抽象文法樹,解析過程分為:詞法分析(Lexical Analysis)和文法分析(Syntax Analysis)兩個階段。
1.1、詞法分析
V8 引擎首先會掃描所有的源代碼,進行詞法分析(Tokenizing/Lexing)(詞法分析是通過 Scanner 子產品來完成的)。也稱為分詞,是将字元串形式的代碼轉換為标記(token)序列的過程。這裡的token是一個字元串,是構成源代碼的最小機關,類似于英語中單詞,例如,var a = 2; 經過詞法分析得到的tokens如下:
從上圖中可以看到,這句代碼最終被分解出了五個詞法單元:
var 關鍵字
a 辨別符
= 運算符
2 數值
;分号
一個可以線上檢視Tokens的網站: https://esprima.org/demo/parse.html
1.2、文法分析
文法分析是将詞法分析産生的token按照某種給定的形式文法(這裡是JavaScript語言的文法規則)轉換成抽象文法樹(AST)的過程。也就是把單詞組合成句子的過程。這個過程會分析文法錯誤:遇到錯誤的文法會抛出異常。AST是源代碼的文法結構的樹形表示。AST包含了源代碼中的所有文法結構資訊,但不包含代碼的執行邏輯。
例如, var a = 2; 經過文法分析後生成的AST如下:
可以看到這段程式的類型是 VariableDeclaration,也就是說這段代碼是用來聲明變量的。
一個可以線上檢視AST結構的網站:https://astexplorer.net/
2、Ignition:(interpreter)解釋器
負責将AST轉換成位元組碼(Bytecode)并逐行解釋執行位元組碼,提供快速的啟動和較低的記憶體使用,同時會标記熱點代碼,收集TurboFan優化編譯所需的資訊,比如函數參數的類型。
2.1、什麼是位元組碼?
位元組碼(Bytecode)是一種介于AST和機器碼之間的中間表示形式,它比AST更接近機器碼,它比機器碼更抽象,也更輕量,與特定機器代碼無關,需要解釋器轉譯後才能成為機器碼。位元組碼通常不像源碼一樣可以讓人閱讀,而是編碼後的數值常量、引用、指令等構成的序列。
2.2、位元組碼的優點
•不針對特定CPU架構
•比原始的進階語言轉換成機器語言更快
•位元組碼比機器碼占用記憶體更小
•利用位元組碼,可以實作Compile Once,Run anywhere(一次編譯到處運作)。
早期版本的 V8 ,并沒有生成中間位元組碼的過程,而是将所有源碼轉換為了機器代碼。機器代碼雖然執行速度更快,但是占用記憶體大。
2.3、檢視位元組碼
Node.js是基于V8引擎實作的,是以node指令提供了很多V8引擎的選項,我們可以通過這些選項,檢視V8引擎中各個階段的産物。使用node的--print-bytecode選項,可以列印出Ignition生成的Bytecode。
示例test.js如下
//test.js
function add(a, b){
return a + b;
}
add(1,2); //V8不會編譯沒有被調用的函數,是以需要在最後一行調用add函數
運作下面的node指令,列印出Ignition生成的位元組碼。
node --print-bytecode test.js
[generated bytecode for function: add (0x29e627015191 <SharedFunctionInfo add>)]
Bytecode length: 6
Parameter count 3
Register count 0
Frame size 0
OSR urgency: 0
Bytecode age: 0
33 S> 0x29e627015bb8 @ 0 : 0b 04 Ldar a1
41 E> 0x29e627015bba @ 2 : 39 03 00 Add a0, [0]
44 S> 0x29e627015bbd @ 5 : a9 Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)
0x29e627015bc1 <ByteArray[8]>
控制台輸出的内容非常多,最後一部分是add函數的Bytecode。
位元組碼的詳細資訊如下:
•[generated bytecode for function: add (0x29e627015191 <SharedFunctionInfo add>)]: 這行告訴我們,接下來的位元組碼是為 add 函數生成的。0x29e627015191 是這個函數在記憶體中的位址。
•Bytecode length: 6: 整個位元組碼的長度是 6 位元組。
•Parameter count 3: 該函數有 3 個參數。包括傳入的 a,b 以及 this。
•Register count 0: 該函數沒有使用任何寄存器。
•Frame size 0: 該函數的幀大小是 0。幀大小是指在調用棧上配置設定給這個函數的空間大小,用于存儲局部變量、函數參數等。
•OSR urgency: 0: On-Stack Replacement(OSR)優化的緊急程度是 0。OSR 是一種在運作時将解釋執行的函數替換為編譯執行的函數的技術,用于提高性能。
•Bytecode age: 0: 位元組碼的年齡是 0。位元組碼的年齡是指它被執行的次數,年齡越高,說明這個位元組碼被執行的越頻繁,可能會被 V8 引擎優化。
•Ldar a1 表示将寄存器中的值加載到累加器中 ,這行是位元組碼的第一條指令
•Add a0, [0] 從 a0 寄存器加載值并且将其與累加器中的值相加,然後将結果再次放入累加器 。
•Return 結束目前函數的執行,并把控制權傳給調用方,将累加器中的值作為傳回值
•S> 表示這是一個“Safepoint”指令,V8 引擎可以在執行這條指令時進行垃圾回收等操作。
•E> 表示這是一個“Effect”指令,可能會改變程式的狀态。
•Constant pool (size = 0): 常量池的大小是 0。常量池是用來存儲函數中使用的常量值的。
•Handler Table (size = 0): 異常處理表的大小是 0。異常處理表是用來處理函數中可能出現的異常的。
•Source Position Table (size = 8): 源代碼位置表的大小是 8。源代碼位置表是用來将位元組碼指令與源代碼行号關聯起來的,友善調試。
•0x29e627015bc1 <ByteArray[8]>: 這行是源代碼位置表的具體内容,顯示了每個位元組碼指令對應的源代碼行号和列号。
可以看到,Bytecode某種程度上就是彙編語言,隻是它沒有對應特定的CPU,或者說它對應的是虛拟的CPU。這樣的話,生成Bytecode時簡單很多,無需為不同的CPU生産不同的代碼。要知道,V8支援9種不同的CPU,引入一個中間層Bytecode,可以簡化V8的編譯流程,提高可擴充性。如果我們在不同硬體上去生成Bytecode,生成代碼的指令是一樣的.
3、TurboFan:(compiler)編譯器
V8 的優化編譯器也是v8實作即時編譯(JIT)的核心,負責将熱點函數的位元組碼編譯成高效的機器碼。
3.1、什麼是JIT?
我們需要先了解一下JIT(Just in Time)即時編譯。
在運作C、C++以及Java等程式之前,需要進行編譯,不能直接執行源碼;但對于JavaScript來說,我們可以直接執行源碼(比如:node server.js),它是在運作的時候先編譯再執行,這種方式被稱為即時編譯(Just-in-time compilation),簡稱為JIT。是以,V8也屬于JIT編譯器。
實作JIT編譯器的系統通常會不斷地分析正在執行的代碼,并确定代碼的某些部分,在這些部分中,編譯或重新編譯所獲得的加速将超過編譯該代碼的開銷。 JIT編譯是兩種傳統的機器代碼翻譯方法——提前編譯(AOT)和解釋——的結合,它結合了兩者的優點和缺點。大緻來說,JIT編譯将編譯代碼的速度與解釋的靈活性、解釋器的開銷以及額外的編譯開銷(而不僅僅是解釋)結合起來。
除了V8引擎,Java虛拟機、PHP 8也用到了JIT。
3.2、V8引擎的JIT
V8的JIT編譯包括多個階段,從生成位元組碼到生成高度優化的機器碼,根據JavaScript代碼的執行特性動态地優化代碼,以實作高性能的JavaScript執行。看下圖Ignition 和 TurboFan 的互動:
當 Ignition 開始執行 JavaScript 代碼後,V8 會一直覺察 JavaScript 代碼的執行情況,并記錄執行資訊,如每個函數的執行次數、每次調用函數時,傳遞的參數類型等。如果一個函數被調用的次數超過了内設的門檻值,螢幕就會将目前函數标記為熱點函數(Hot Function),并将該函數的位元組碼以及執行的相關資訊發送給 TurboFan。TurboFan 會根據執行資訊做出一些進一步優化此代碼的假設,在假設的基礎上将位元組碼編譯為優化的機器代碼。如果假設成立,那麼當下一次調用該函數時,就會執行優化編譯後的機器代碼,以提高代碼的執行性能。
如果假設不成立,上圖中,綠色的線,是“去優化(Deoptimize)”的過程,如果TurboFan生成的優化機器碼,對需要執行的代碼不适用,會把優化的機器碼,重新轉換成位元組碼來執行。這是因為Ignition收集的資訊可能是錯誤的。
例如:
function add(a, b) {
return a + b;
}
add(1, 2);
add(2, 2);
add("1", "2");
add函數的參數之前是整數,後來又變成了字元串。生成的優化機器碼已經假定add函數的參數是整數,那當然是錯誤的,于是需要進行去優化,Deoptimize為Bytecode來執行。
TurboFan除了上面基于類型做優化和反優化,還有包括内聯(inlining)和逃逸分析(Escape Analysis)等,内聯就是将相關聯的函數進行合并。例如:
function add(a, b) {
return a + b
}
function foo() {
return add(2, 4)
}
内聯優化後:
function fooAddInlined() {
var a = 2
var b = 4
var addReturnValue = a + b
return addReturnValue
}
// 因為 fooAddInlined 中 a 和 b 的值都是确定的,是以可以進一步優化
function fooAddInlined() {
return 6
}
使用node指令的--print-code以及--print-opt-code選項,可以列印出TurboFan生成的彙編代碼。
node --print-code --print-opt-code test.js
4、Orinoco:垃圾回收
一個高效的垃圾回收器,用于自動管理記憶體,回收不再使用的對象記憶體;它使用多種垃圾回收政策,如分代回收、标記-清除、增量标記等,以實作高效記憶體管理。
Orinoco的主要特點包括:
•并發标記: Orinoco使用并發标記技術來減少垃圾回收的停頓時間(Pause Time)。這意味着在應用程式繼續執行的同時,垃圾回收器可以在背景進行标記操作。
•增量式垃圾回收: Orinoco支援增量式垃圾回收,這允許垃圾回收器在小的時間片内執行部分垃圾回收工作,而不是一次性處理所有的垃圾。
•更高效的記憶體管理: Orinoco引入了一些新的記憶體管理政策和資料結構,旨在減少記憶體碎片和提高記憶體使用率。
•可擴充性: Orinoco的設計考慮了可擴充性,使得它可以适應不同的工作負載和硬體配置。
•多線程支援: Orinoco支援多線程環境,可以利用多核CPU來加速垃圾回收過程。
四、V8移植工具選型
我們的開發環境各式各樣可能系統是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))。
完整CMakeLists.txt檔案的主要配置樣例:
# 1. 聲明要求的cmake最低版本
cmake_minimum_required( VERSION 2.8 )
# 2. 添加c++11标準支援
#set( CMAKE_CXX_FLAGS "-std=c++11" )
# 3. 聲明一個cmake工程
PROJECT(camke_demo)
MESSAGE(STATUS "Project: SERVER") #列印相關消息
# 4. 頭檔案
include_directories(
${PROJECT_SOURCE_DIR}/../include/mq
${PROJECT_SOURCE_DIR}/../include/incl
${PROJECT_SOURCE_DIR}/../include/rapidjson
)
# 5. 通過設定SRC變量,将源代碼路徑都給SRC,如果有多個,可以直接在後面繼續添加
set(SRC
${PROJECT_SOURCE_DIR}/../include/incl/tfc_base_config_file.cpp
${PROJECT_SOURCE_DIR}/../include/mq/tfc_ipc_sv.cpp
${PROJECT_SOURCE_DIR}/../include/mq/tfc_net_ipc_mq.cpp
${PROJECT_SOURCE_DIR}/../include/mq/tfc_net_open_mq.cpp
)
# 6. 建立共享庫/靜态庫
# 設定路徑(下面生成共享庫的路徑)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/lib)
# 即生成的共享庫在工程檔案夾下的lib檔案夾中
set(LIB_NAME camke_demo_lib)
# 建立共享庫(把工程内的cpp檔案都建立成共享庫檔案,友善通過頭檔案來調用)
# 這時候隻需要cpp,不需要有主函數
# ${PROJECT_NAME}是生成的庫名 表示生成的共享庫檔案就叫做 lib工程名.so
# 也可以專門寫cmakelists來編譯一個沒有主函數的程式來生成共享庫,供其它程式使用
# SHARED為生成動态庫,STATIC為生成靜态庫
add_library(${LIB_NAME} STATIC ${SRC})
# 7. 連結庫檔案
# 把剛剛生成的${LIB_NAME}庫和所需的其它庫連結起來
# 如果需要連結其他的動态庫,-l後接去除lib字首和.so字尾的名稱,以連結
# libpthread.so 為例,-lpthread
target_link_libraries(${LIB_NAME} pthread dl)
# 8. 編譯主函數,生成可執行檔案
# 先設定路徑
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/bin)
# 可執行檔案生成
add_executable(${PROJECT_NAME} ${SRC})
# 連結這個可執行檔案所需的庫
target_link_libraries(${PROJECT_NAME} pthread dl ${LIB_NAME})
一般把CMakeLists.txt檔案放在工程目錄下,使用時先建立一個叫build的檔案夾(這個并非必須,因為cmake指令指向CMakeLists.txt所在的目錄,例如cmake .. 表示CMakeLists.txt在目前目錄的上一級目錄。cmake執行後會生成很多編譯的中間檔案,是以一般建議建立一個新的目錄,專門用來編譯),通常建構步驟如下:
1.mkdir build
2.cd build
3.cmake .. 或者 cmake -G Ninja ..
4.make 或者 ninja
其中cmake .. 在build檔案夾下生成Makefile。make指令在Makefile所在的目錄下執行,根據Makefile進行編譯。
或者cmake -G Ninja .. 在build檔案夾下生成build.ninja。ninja指令在build.ninja所在的目錄下執行,根據build.ninja進行編譯。
2、CMake中的交叉編譯設定
配置方式一:
直接在CMakeLists.txt檔案中,使用CMAKE_C_COMPILER和CMAKE_CXX_COMPILER這兩個變量來指定C和C++的編譯器路徑。使用CMAKE_LINKER變量來指定項目的連結器。這樣,當CMake生成建構檔案時,就會使用指定的編譯器來編譯源代碼。使用指定的連結器進行項目的連結操作。
以下是一個簡單的設定交叉編譯器和連結器的CMakeLists.txt檔案示例:
# 指定CMake的最低版本要求
cmake_minimum_required(VERSION 3.10)
# 項目名稱
project(CrossCompileExample)
# 設定C編譯器和C++編譯器
set(CMAKE_C_COMPILER "/path/to/c/compiler")
set(CMAKE_CXX_COMPILER "/path/to/cxx/compiler")
# 設定連結器
set(CMAKE_LINKER "/path/to/linker")
# 添加可執行檔案
add_executable(myapp main.cpp)
另外我們還可以使用單獨工具鍊檔案配置交叉編譯環境。
配置方式二: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就會使用工具鍊檔案中指定的編譯器和設定來為目标平台生成代碼。
五、V8和正常C++庫移植的重大差異
正常C++項目按照上述交叉編譯介紹的配置即可完成交叉編譯過程,但是V8的移植必須充分了解builtin和snapshot才能完成!一般的庫,所謂交叉編譯就是調用目标平台指定的工具鍊直接編譯源碼生成目标平台的檔案。比如一個C檔案要給android用,調用ndk包的gcc、clang編譯即可。但由于v8的builtin實際用的是v8自己的工具鍊體系編譯成目标平台的代碼,是以并不能套用上面的方式。
1、builtin
1.1、builtin是什麼
在V8引擎中,builtin即内置函數或子產品。V8的内置函數和子產品是JavaScript語言的一部分,提供了一些基本的功能,例如數學運算、字元串操作、日期處理等。另外ignition解析器每一條位元組碼指令實作也是一個builtin。
V8的内置函數和子產品是通過C++代碼實作的,并在編譯時直接內建到V8引擎中。這些内置函數和子產品不需要在JavaScript代碼中顯式地導入或引用,就可以直接使用。
以下是一些V8的内置函數和子產品的例子:
•Math對象:提供了各種數學運算的函數,例如Math.sin()、Math.cos()等。
•String對象:提供了字元串操作的函數,例如String.prototype.split()、String.prototype.replace()等。
•Date對象:提供了日期和時間處理的函數,例如Date.now()、Date.parse()等。
•JSON對象:提供了JSON資料的解析和生成的函數,例如JSON.parse()、JSON.stringify()等。
•ArrayBuffer對象:提供了對二進制資料的操作的函數,例如ArrayBuffer.prototype.slice()、ArrayBuffer.prototype.byteLength等。
•WebAssembly子產品:提供了對WebAssembly子產品的加載和執行個體化的函數,例如WebAssembly.compile()、WebAssembly.instantiate()等。
這些内置函數和子產品都是V8引擎的重要組成部分,提供了基礎的JavaScript功能。它們是V8運作時最重要的“積木塊”;
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<Object> value = LoadRegisterAtOperandIndex(0);
SetAccumulator(value);
Dispatch();
}
上述代碼隻在V8的編譯階段由mksnapshot程式執行,執行後會産出機器碼(JIT),然後mksnapshot程式把生成的機器碼dump下來放到彙編檔案embedded.S裡,編譯進V8運作時(相當于用JIT編譯器去AOT)。
builtin被dump到embedded.S的對應v8源碼在v8/src/snapshot/embedded-file-writer.h
void WriteFilePrologue(PlatformEmbeddedFileWriterBase* w) const {
w->Comment("Autogenerated file. Do not edit.");
w->Newline(); w->FilePrologue();
}
上述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
.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
void SetupIsolateDelegate::SetupBuiltinsInternal(Isolate* isolate) {
Builtins* builtins = isolate->builtins();
DCHECK(!builtins->initialized_);
PopulateWithPlaceholders(isolate);
// Create a scope for the handles in the builtins.
HandleScope scope(isolate);
int index = 0;
Code code;
#define BUILD_CPP(Name) \
code = BuildAdaptor(isolate, Builtin::k##Name, \
FUNCTION_ADDR(Builtin_##Name), #Name); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
#define BUILD_TFJ(Name, Argc, ...) \
code = BuildWithCodeStubAssemblerJS( \
isolate, Builtin::k##Name, &Builtins::Generate_##Name, Argc, #Name); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
#define BUILD_TFC(Name, InterfaceDescriptor) \
/* Return size is from the provided CallInterfaceDescriptor. */ \
code = BuildWithCodeStubAssemblerCS( \
isolate, Builtin::k##Name, &Builtins::Generate_##Name, \
CallDescriptors::InterfaceDescriptor, #Name); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
#define BUILD_TFS(Name, ...) \
/* Return size for generic TF builtins (stub linkage) is always 1. */ \
code = BuildWithCodeStubAssemblerCS(isolate, Builtin::k##Name, \
&Builtins::Generate_##Name, \
CallDescriptors::Name, #Name); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
#define BUILD_TFH(Name, InterfaceDescriptor) \
/* Return size for IC builtins/handlers is always 1. */ \
code = BuildWithCodeStubAssemblerCS( \
isolate, Builtin::k##Name, &Builtins::Generate_##Name, \
CallDescriptors::InterfaceDescriptor, #Name); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
#define BUILD_BCH(Name, OperandScale, Bytecode) \
code = GenerateBytecodeHandler(isolate, Builtin::k##Name, OperandScale, \
Bytecode); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
#define BUILD_ASM(Name, InterfaceDescriptor) \
code = BuildWithMacroAssembler(isolate, Builtin::k##Name, \
Builtins::Generate_##Name, #Name); \
AddBuiltin(builtins, Builtin::k##Name, code); \
index++;
BUILTIN_LIST(BUILD_CPP, BUILD_TFJ, BUILD_TFC, BUILD_TFS, BUILD_TFH, BUILD_BCH,
BUILD_ASM);
#undef BUILD_CPP
#undef BUILD_TFJ
#undef BUILD_TFC
#undef BUILD_TFS
#undef BUILD_TFH
#undef BUILD_BCH
#undef BUILD_ASM
// ...
}
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檔案。
x64的實作如下:
void Builtins::Generate_DoubleToI(MacroAssembler* masm) {
Label check_negative, process_64_bits, done;
// Account for return address and saved regs.
const int kArgumentOffset = 4 * kSystemPointerSize;
MemOperand mantissa_operand(MemOperand(rsp, kArgumentOffset));
MemOperand exponent_operand(
MemOperand(rsp, kArgumentOffset + kDoubleSize / 2));
// The result is returned on the stack.
MemOperand return_operand = mantissa_operand;
Register scratch1 = rbx;
// Since we must use rcx for shifts below, use some other register (rax)
// to calculate the result if ecx is the requested return register.
Register result_reg = rax;
// Save ecx if it isn't the return register and therefore volatile, or if it
// is the return register, then save the temp register we use in its stead
// for the result.
Register save_reg = rax;
__ pushq(rcx);
__ pushq(scratch1);
__ pushq(save_reg);
__ movl(scratch1, mantissa_operand);
__ Movsd(kScratchDoubleReg, mantissa_operand);
__ movl(rcx, exponent_operand);
__ andl(rcx, Immediate(HeapNumber::kExponentMask));
__ shrl(rcx, Immediate(HeapNumber::kExponentShift));
__ leal(result_reg, MemOperand(rcx, -HeapNumber::kExponentBias));
__ cmpl(result_reg, Immediate(HeapNumber::kMantissaBits));
__ j(below, &process_64_bits, Label::kNear);
// Result is entirely in lower 32-bits of mantissa
int delta =
HeapNumber::kExponentBias + base::Double::kPhysicalSignificandSize;
__ subl(rcx, Immediate(delta));
__ xorl(result_reg, result_reg);
__ cmpl(rcx, Immediate(31));
__ j(above, &done, Label::kNear);
__ shll_cl(scratch1);
__ jmp(&check_negative, Label::kNear);
__ bind(&process_64_bits);
__ Cvttsd2siq(result_reg, kScratchDoubleReg);
__ jmp(&done, Label::kNear);
// If the double was negative, negate the integer result.
__ bind(&check_negative);
__ movl(result_reg, scratch1);
__ negl(result_reg);
__ cmpl(exponent_operand, Immediate(0));
__ cmovl(greater, result_reg, scratch1);
// Restore registers
__ bind(&done);
__ movl(return_operand, result_reg);
__ popq(save_reg);
__ popq(scratch1);
__ popq(rcx);
__ ret(0);
}
看上去很像彙編(程式設計的思考方式按彙編來),實際上是c++函數,比如這行movl
__ movl(scratch1, mantissa_operand);
__是個宏,實際上是調用masm變量的函數(movl)
#define __ ACCESS_MASM(masm)
#define ACCESS_MASM(masm) masm->
而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語言實作如下:
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編譯器編譯後,會生成一段複雜的CSA的C++代碼,下面截取一個片段
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 ...
}
和上面講的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的活。
1.3、builtin是怎麼加載使用的
mksnapshot生成的包含所有builtin的産物embedded.S會和其他v8源碼一起編譯成最終的v8庫,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_資料的總長度
在v8/src/execution/isolate.cc中聲明了幾個extern變量,連結embedded.S後v8/src/execution/isolate.cc就能引用到那幾個變量:
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通過枚舉值擷取起始位址調用。
2、snapshot
在V8引擎中,snapshot是指在啟動時将部分或全部JavaScript堆記憶體的狀态儲存到一個檔案中,以便在後續的啟動中可以快速恢複到這個狀态。這個技術可以顯著減少V8引擎的啟動時間,特别是在大型應用程式中。
snapshot檔案包含了以下幾個部分:
•JavaScript堆的記憶體布局:包括了所有對象的位址、大小和類型等資訊。
•JavaScript代碼的位元組碼:包括了所有已經編譯的JavaScript函數的位元組碼。
•全局對象的狀态:包括了全局對象的屬性值、函數指針等資訊。
•其他必要的狀态:例如,垃圾回收器的狀态、Just-In-Time (JIT) 編譯器的緩存等。
當V8引擎啟動時,如果存在有效的Snapshot檔案,V8會直接從這個檔案中讀取JavaScript堆的狀态和位元組碼,而不需要重新解析和編譯所有的JavaScript代碼。這可以大幅度縮短V8引擎的啟動時間。V8的Snapshot技術有以下幾個優點:
•快速啟動:可以顯著減少V8引擎的啟動時間,特别是在大型應用程式中。
•低記憶體占用:由于部分或全部JavaScript堆的狀态已經被儲存到檔案中,是以在啟動時可以節省記憶體。
•穩定性:Snapshot檔案是由V8引擎生成的,保證了與引擎的相容性和穩定性。
如果不是交叉編譯,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接口恢複。
//儲存
size_t context_index = snapshot_creator.AddContext(context, si_cb);
//恢複
v8::Local<v8::Context> context = v8::Context::FromSnapshot(isolate, context_index, di_cb).ToLocalChecked();
結合交叉編譯時就會有個很費解的地方:我們前面提到mksnapshot在交叉編譯時,JIT生成的builtin是目标機器指令,而js的運作得通過跑builtin來實作(Ignition解析器每個指令就是一個builtin),這目标機器指令(比如arm64)怎麼在本地(比如linux 的x64)跑起來呢?mksnapshot為了實作交叉編譯中目标平台snapshot的生成,它做了各種cpu(arm、mips、risc、ppc)的模拟器(Simulator)
通過檢視源碼交叉編譯時,mksnapshot會用一個目标機器的模拟器來跑這些builtin:
//src\common\globals.h
#if !defined(USE_SIMULATOR)
#if (V8_TARGET_ARCH_ARM64 && !V8_HOST_ARCH_ARM64)
#define USE_SIMULATOR 1
#endif
// ...
#endif
//src\execution\simulator.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将會調用到v8/src/execution/simulator-arm64.h, v8/src/execution/simulator-arm64.cc實作的模拟器。上面Call的處理是把指令首位址指派到模拟器的_pc寄存器,參數放寄存器,執行完指令從寄存器擷取傳回值。
六、V8移植的具體步驟
一般我們将負責編譯的機器稱為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的主要輸入檔案,包含了項目的所有建構規則和依賴關系。
這個檔案的内容是Ninja的文法,描述了如何從源檔案生成目标檔案。它包括了以下幾個部分:
•規則:定義了如何從源檔案生成目标檔案的規則。例如,編譯C++檔案、連結庫等。
•建構目标:列出了項目中所有需要建構的目标,包括可執行檔案、靜态庫、動态庫等。
•依賴關系:描述了各個建構目标之間的依賴關系。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。
七、鴻蒙工程中使用v8庫
1.建立native c++工程
2.導入v8庫
将v8源碼中的include目錄和上面編譯生成的.a檔案放入cpp檔案夾下
3.修改cpp目錄下CMakeList.txt檔案
設定c++标準17,連結v8靜态庫
4.添加napi方法測試使用v8
下面是簡單的demo
導出c++方法
、
arkts側調用c++方法
運作檢視結果:
八、JS引擎的發展趨勢
随着物聯網的發展,人們對IOT裝置(如智能手表)的使用越來越多。如果希望把JS應用到IOT領域,必然需要從JS引擎角度去進行優化,隻是去做上層的架構收效甚微。因為對于IOT硬體來說,CPU、記憶體、電量都是需要省着點用的,不是每一個智能家電都需要裝一個骁龍855。那怎麼可以基于V8引擎進行改造來進一步提升JS的執行性能呢?
•使用TypeScript程式設計,遵循嚴格的類型化程式設計規則;
•建構的時候将TypeScript直接編譯為Bytecode,而不是生成JS檔案,這樣運作的時候就省去了Parse以及生成Bytecode的過程;
•運作的時候,需要先将Bytecode編譯為對應CPU的彙編代碼;
•由于采用了類型化的程式設計方式,有利于編譯器優化所生成的彙編代碼,省去了很多額外的操作;
基于V8引擎來實作,技術上應該是可行的:
•将Parser以及Ignition拆分出來,用于建構階段;
•删掉TurboFan處理JS動态特性的相關代碼;
這樣可以将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等衆多領域的綜合解決方案,我們有各個領域優秀的小夥伴共同前行,大家如果想深入了解某個領域的具體實作,可以随時留言交流~