深入 WebAssembly 之直譯器實現篇

雲音樂大前端團隊發表於2021-09-26

圖片來源:https://unsplash.com/photos/N...

本文作者:伍六一

Wasm 直譯器專案地址:

https://github.com/mcuking/wasmc

背景

從去年年底開始筆者決定深入 WebAssembly(為了書寫方便,接下來簡稱為 Wasm)這門技術,在讀《WebAssembly 原理與核心技術》這本書的過程中(這本書詳細講解了 Wasm 的直譯器和虛擬機器的工作原理以及實現思路),萌生了實現一個 Wasm 直譯器的想法,於是就有了這個專案。接下來我們就直奔主題,看下到底如何實現一個 Wasm 直譯器。

Wasm 背景知識

在具體闡述直譯器實現過程之前,首先介紹下 Wasm 相關的背景知識。

Wasm 是什麼

Wasm 是一種底層類組合語言,能在 Web 平臺上以趨近原生應用的速度執行。C/C++/Rust 等語言將 Wasm 作為編譯目標語言,可以將已有的程式碼移植到 Web 平臺中執行,以提升程式碼複用度。

而 Wasm 官網給出的定義是 —— WebAssembly(縮寫為 Wasm)是一種基於棧式虛擬機器的二進位制指令格式。Wasm 被設計成為一種程式語言的可移植編譯目標,可以通過將其部署到 Web 平臺上,使其為客戶端和服務端應用程式提供服務。

其中將 Wasm 定義為一種虛擬指令集架構 V-ISA(Virtual-Instruction Set Architecture),關於這方面的解讀,請參考下面執行階段的內容。

接著來看下 Wasm 的一些特點:

  1. 層次必須低,儘量接近機器語言,這樣直譯器才更容易進行 AOT/JIT 編譯,以趨近原生應用的速度執行 Wasm 程式;
  2. 作為目的碼,由其他高階語言編譯器生成;
  3. 程式碼安全可控,不能像真正的組合語言那樣可以執行任意操作;
  4. 程式碼必須是平臺無關的(不能是平臺相關的機器碼),這樣才可以跨平臺執行,所以採用了虛擬機器/位元組碼技術。
Tip: 關於 Wasm 的更多詳細介紹可參考筆者翻譯的文章 《WebAssembly 的後 MVP 時代的未來:一棵卡通技能樹(譯)》

Wasm 能做什麼

Wasm 目前已經在瀏覽器端的影像處理、音視訊處理、遊戲、IDE、視覺化、科學計算等,以及非瀏覽器端的Serverless、區塊鏈、IoT 等領域有一定的應用。如果想要了解更多有關 Wasm 應用的內容,可以關注筆者的另一個 GitHub 倉庫:

https://github.com/mcuking/Awesome-WebAssembly-Applications

Wasm 規範

Wasm 技術目前有 4 份規範:

  • 核心規範 —— 定義了獨立於具體嵌入(即平臺無關)的 Wasm 模組的語義。
  • JavaScript API —— 定義用於從 JavaScript 內部訪問 Wasm 的 JavaScript 類和物件。
  • Web API —— 定義了專門在 Web 瀏覽器中可用的 JavaScript API 擴充套件。
  • WASI API —— 定義了一個模組化的系統介面來在 Web 之外執行 Wasm,例如訪問檔案、網路連結等能力。

本文主要介紹的 Wasm 直譯器主要是執行在非瀏覽器環境,因此無需關注 JavaScript APIWeb API 規範。

另外目前實現的版本並沒有涉及到 WASI(後續有計劃支援),所以目前只需要關注 核心規範 即可。

Wasm 模組

Wasm 模組主要有以下 4 種表現形式:

  • 二進位制格式 —— Wasm 的主要編碼格式,以 .wasm 字尾結尾。
  • 文字格式 —— 主要是為了方便開發者理解 Wasm 模組,或者編寫小型的測試程式碼,以 .wat 字尾結尾,相當於組合語言程式。
  • 記憶體格式 —— 模組載入到記憶體的表現,該表現形式與具體的 Wasm 虛擬機器的實現有關,不同 Wasm 虛擬機器的實現有不同的記憶體表示。
  • 模組例項 —— 如果將記憶體格式理解為面嚮物件語言中的類,那模組例項就相當於“物件”。

下圖就是使用 C 語言編寫的階乘函式,以及對應的 Wasm 文字格式和二進位制格式。

而記憶體格式和具體的 Wasm 直譯器的實現有關,例如本專案的記憶體格式大致如下(在後面執行階段部分會詳細講解):

各個格式之間的關聯如下:

  • 二進位制格式主要由高階程式語言編譯器生成,也可通過文字格式編譯生成。
  • 文字格式可以有開發者個直接編寫,也可由二進位制反編譯生成。
  • Wasm 直譯器通常會將二進位制模組解碼為內部形式,即記憶體格式(比如 C/C++ 結構體),然後再進行後續處理。

最後推薦一個名為 WebAssembly Code Explorer 的站點,可以更直觀地檢視 Wasm 二進位制格式和文字格式之間的關聯。

https://wasdk.github.io/wasmc...

直譯器實現原理

通過上面的介紹,相信大家對 Wasm 技術已經有了大致的瞭解。接下來我們從分析 Wasm 二進位制檔案的執行流程開始,探討直譯器的實現思路。

Wasm 二進位制檔案被執行主要分 3 個階段:解碼驗證執行

  1. 解碼階段:將二進位制格式解碼為記憶體格式。
  2. 驗證階段:對模組進行靜態分析,確保模組的結構滿足規範要求,且函式的位元組碼沒有不良行為(例如呼叫不存在的函式)。
  3. 執行階段:進一步分為例項化函式呼叫兩個階段。

Tip: 本專案實現的直譯器,並沒有一個單獨的驗證階段。而是將具體的驗證分佈在解碼階段執行階段中進行,例如在解碼階段驗證是否存在非法的段 ID,在執行階段驗證函式的引數或返回值的型別或數量是否和函式簽名匹配等。

另外例項化過程在解碼階段就完成了,執行階段僅需要進行函式呼叫即可。
所謂例項化,主要內容就是為記憶體段、表段等申請空間,記錄所有函式(自定義的函式和匯入的函式)的入口地址,然後將模組的所有資訊記錄到一個統一的資料結構 module 中。

接下來我們就分別對解碼階段執行階段的實現細節進行詳細闡述。

解碼階段

Wasm 二進位制檔案結構

和其他二進位制格式(例如 Java 類檔案)一樣,Wasm 二進位制格式也是以魔數和版本號開頭,之後就是模組的主體內容,這些內容根據不同用途被分別放在不同的段(Section) 中。一共定義了 12 種段,每種段分配了 ID(從 0 到 11)。除了自定義段之外,其他所有段都最多隻能出現一次,且須按照 ID 遞增的順序出現。ID 從 0 到 11 依次有如下 12 個段:

自定義段、型別段、匯入段、函式段、表段、記憶體段、全域性段、匯出段、起始段、元素段、程式碼段、資料段

Tip: 其中不同段之間的排序是有一定依據的,主要目的是為了進行流編譯 —— 即一邊下載 Wasm 模組一邊將其編譯到機器碼,詳細資訊可查閱文章 《Making WebAssembly even faster: Firefox’s new streaming and tiering compiler》

換句話說,每一個不同的段都描述了這個 Wasm 模組的一部分資訊。而模組內的所有段放在一起,便描述了這個 Wasm 模組的全部資訊:

  • 記憶體段和資料段:記憶體段用於儲存程式的執行時動態資料。資料段用於儲存初始化記憶體的靜態資料。記憶體可以從外部宿主匯入,同時記憶體物件也可以匯出到外部宿主環境。
  • 表段和元素段:表段用於儲存物件引用,目前物件只能是函式,因此可以通過表段實現函式指標的功能。元素段用於儲存初始化表段的資料。表物件可以從外部宿主匯入,同時表物件也可以匯出到外部宿主環境。
  • 起始段:起始段用於儲存起始函式的索引,即指定了一個在載入時自動執行的函式。起始函式主要作用:1. 在模組載入後進行初始化工作; 2. 將模組變成可執行檔案。
  • 全域性段:全域性段用於儲存全域性變數的資訊(全域性變數的值型別、可變性、初始化表示式等)。
  • 函式段、程式碼段和型別段:這三個段均是用於儲存表達函式的資料。其中
    型別段:型別段用於儲存模組內所有的函式簽名(函式簽名記錄了函式的引數和返回值的型別和數量),注意若存在多個函式的函式簽名相同,則儲存一份即可。
    函式段:函式段用於儲存函式對應的函式簽名索引,注意是函式簽名的索引,而不是函式索引。
    程式碼段:程式碼段用於儲存函式的位元組碼和區域性變數,也就是函式體內的區域性變數和程式碼所對應的位元組碼。
  • 匯入段和匯出段:匯出段用於儲存匯出項資訊(匯出項的成員名、型別,以及在對應段中的索引等)。匯入段用於儲存匯入項資訊(匯入項的成員名、型別,以及從哪個模組匯入等)。匯出/匯入項型別有 4 種:函式、表、記憶體、全域性變數。
  • 自定義段:自定義段主要用於儲存除錯符號等和執行無關的資訊。
Tip: 在上面的 Wasm 二進位制格式的段中,表段應該比會較難以理解,這裡特地對其說明下。
在 Wasm 設計思想中,與執行過程相關的程式碼段/棧等元素和記憶體是完全分離的,這與通常的體系結構中程式碼段/資料段/堆/棧全都處在統一編址記憶體空間情況完全不同,函式地址對 Wasm 程式來說是不可見的,更不要說將函式當作變數一樣傳遞、修改和呼叫。
表是實現這一機制的關鍵,表用於儲存物件引用,目前物件只能是函式,也就是說目前表中只是用來儲存函式索引值。Wasm 程式只能通過表中的索引,找到對應函式索引值來呼叫函式,並且執行時的棧資料也不儲存在記憶體物件中。由此徹底杜絕了 Wasm 程式碼越界執行的可能,最糟糕情況不過是在記憶體物件中產生一堆錯誤資料而已。

知道了每個段對應的用途以及每個段的具體編碼格式(詳細的編碼格式可檢視 module.c 中的 load_module 函式中的註釋),我們就可以對 Wasm 二進位制檔案進行解碼,將其“翻譯”成記憶體格式,也就是將模組的所有資訊記錄到一個統一的資料結構中 —— modulemodule 結構如下圖所示:

Tip: 為了節約空間,讓二進位制檔案更加緊湊,Wasm 二進位制格式採用 LEB128(Little Endian Base 128) 來編碼列表長度、索引等整數值。LEB128 是一種變長編碼格式,32 位整數編碼後會佔 1 到 5 個位元組,64 位整數編碼後會佔 1 到 10 個位元組。越小的整數編碼後佔用的位元組數越少。由於像列表長度、索引這樣的整數通常都比較小,所以採用 LEB128 編碼就可以起到節約空間的作用。
LEB128 有兩個特點:1. 採用小端序表示,即低位位元組在前,高位位元組在後;2. 採用 128 進位制,即每 7 位為一組(一個位元組的後 7 位),空出來的最高位是標識位,1 表示還有後續位元組,0 表示沒有。
LEB128 有兩個變體,分別用來編碼無符號整數和有符號整數,具體實現可查閱 https://github.com/mcuking/wasmc/blob/master/source/utils.c 中的 read_LEB 函式。

最後展示下解碼階段對應的部分實際程式碼截圖如下:

更多細節建議查閱 https://github.com/mcuking/wasmc/blob/master/source/module.c 中的 load_module 函式,其中有豐富的註釋講解。

執行階段

經過了上面的解碼階段,我們可以從 Wasm 二進位制檔案中得到涵蓋執行階段所需要的全部資訊的記憶體格式,接下來我們來一起探索如何基於上面的記憶體格式實現執行階段。在正式開始之前,首先需要介紹下棧式虛擬機器的相關知識作為鋪墊。

官網對 Wasm 的定義 —— Wasm 是基於棧式虛擬機器的二進位制指令格式。也就是說 Wasm 不僅僅是一門程式語言,也是一套虛擬機器體系結構規範。那麼什麼是虛擬機器,什麼又是棧式虛擬機器呢?

虛擬機器概念

虛擬機器是軟體對硬體的模擬,藉助作業系統和編譯器提供的功能模擬硬體的工作,這裡主要指對硬體 CPU 的模擬。虛擬機器執行指令主要有以下 3 個步驟:

  1. 取指—從程式計數器 PC 指向指令流中的地址獲取指令
  2. 譯碼—判斷指令的型別,進入相應的處理流程
  3. 執行—按照指令的含義執行相應的函式

執行指令流中的一條條指令,就是不斷迴圈執行上面的三個步驟。迴圈執行的過程中需要有一個標誌來記錄當前已經執行到哪一條指令,也就是程式計數器 PC (Program Count) —— 用於儲存下一條待執行指令的地址。

Tip: 提供給 Wasm 虛擬機器解釋執行的不是平臺相關的機器碼,而是由 Wasm 自定義的一套指令集所構成的位元組碼,主要是為了實現跨平臺的目的 —— 用軟體去模擬 CPU,並定義一套類似 CPU 指令集的自定義指令集,這樣只需要虛擬機器本身的程式針對不同平臺適配即可,而執行在虛擬機器上的程式則無需關心跑在哪個平臺上。

Wasm 指令集

Wasm 指令主要分為 5 大類:

  1. 控制指令—函式呼叫/跳轉/迴圈等
  2. 引數指令—丟棄棧頂等
  3. 變數指令—讀寫全域性/區域性變數
  4. 記憶體指令—記憶體載入/儲存
  5. 數值指令—數值計算

每條指令包含兩部分資訊:操作碼和運算元。

  • 操作碼(Opcode):是指令的 ID,決定指令將執行的操作,固定為 1 個位元組,因此指令集最多包含 256 種指令,這種程式碼又被稱為位元組碼。Wasm 規範共定義了 178 種指令。由於操作碼是一個整數,便於機器處理但對人不友好,因此 Wasm 規範給每個操作碼定義了助記符。

下圖是 Wasm 部分指令的操作碼助記符的列舉,完成版請查閱 https://github.com/mcuking/wasmc/blob/master/source/opcode.h

另外 GitHub 上有一個視覺化表格比較直觀地展示了 Wasm 所有的操作碼,感興趣的同學可以點選檢視下。

https://pengowray.github.io/w...

關於運算元的內容會在下面的棧式虛擬機器部分介紹。

棧式虛擬機器

虛擬機器又大致分為兩種:暫存器虛擬機器和棧式虛擬機器。

  • 暫存器式虛擬機器:完全按照硬體 CPU 實現思路,虛擬機器內部也模擬了暫存器,運算元和指令執行的結果均可存放在暫存器中。實際案例有 V8 / Lua 虛擬機器。
    因為暫存器個數是有限的,如何將無限的變數分配到有限的暫存器中而不衝突,需要暫存器分配演算法,例如經典的圖著色演算法。所以暫存器式虛擬機器實現難度略大,但優化潛力更大。
  • 棧式虛擬機器:指令的結果儲存在模擬的運算元棧(Operand Stack)中,和暫存器式虛擬機器相比實現更簡單。實際案例有 JVM / QuickJs / Wasmer。

接下來我們就詳細介紹下棧式虛擬機器的工作機制。

運算元

棧式虛擬機器主要特點是擁有一個運算元棧,Wasm 絕大部分指令都是在運算元棧上執行某種操作,例如下面的指令:

f32.sub:表示從運算元棧彈出 2 個 32 位浮點數,計算它們的差並將結果壓入到運算元棧頂。

其中從運算元棧彈出的 2 個 32 位浮點數就是運算元,下面是具體定義:

運算元,也稱動態運算元,是指在執行時位於運算元棧頂並被指令操縱的數。

立即數

我們再看另一個指令的例子:

i32.const 3:表示壓入索引為 3 的 32 位整數型別的區域性變數到運算元棧頂。

而這個數值 3 就是立即數,下面是具體定義:

立即數,也稱靜態立即引數 / 靜態運算元,立即數是直接硬編碼在指令裡的(也就是位元組碼裡),緊跟在操作碼後面。大部分 Wasm 指令是沒有立即數的,欲知 Wasm 指令中具體哪些指令是帶有立即數的,請查閱 https://github.com/mcuking/wasmc/blob/master/source/module.c 中的 skip_immediate 函式。

上面討論的僅僅是一條指令的執行,下面我們在看下一個函式在棧式虛擬機器上是如何被執行的:

  1. 呼叫方將引數壓入到運算元棧中
  2. 進入函式後,初始化引數
  3. 執行函式體中的指令
  4. 將函式的執行結果壓入到運算元棧頂並返回
  5. 呼叫方從運算元棧上獲取函式的返回值

如下圖所示:

由此可見,函式呼叫時引數傳遞和返回值獲取,以及函式體中的指令執行,都是通過運算元棧來完成的。

呼叫棧和棧幀

從上面的描述中可以看出,函式呼叫經常是巢狀的,例如函式 A 呼叫函式 B,函式 B 呼叫函式 C。因此需要另外一個棧來維護函式之間的呼叫關係資訊 —— 呼叫棧(Call Stack)

呼叫棧是由一個個獨立的棧幀組成,每次函式呼叫,都會向呼叫棧壓入一個棧幀(注意:為了闡述的簡潔明瞭,僅討論函式情況,其他例如 If / Loop 等控制塊暫不在本文討論中)。每次函式執行結束,都會從呼叫棧彈出對應棧幀並銷燬。一連串的函式呼叫,就是不停建立和銷燬棧幀的過程。但在任一時刻,只有位於呼叫棧頂的棧幀是活躍的,也就是所謂的當前棧幀

每個棧幀包括以下內容:

  1. 棧幀關聯的函式結構體變數,用於儲存該函式的所有資訊。
  2. 運算元棧,用於儲存引數、區域性變數、以及函式體指令執行過程中的運算元。
    需要提醒的是,所有函式關聯的棧幀是共用一個完整的運算元棧,每個棧幀會佔用這個運算元棧中的某一部分,每個棧幀只需要一個指標儲存自己那部分運算元棧棧底地址,用以和其他棧幀的運算元棧部分做區分即可。
    這樣做的好處是:呼叫方函式和被呼叫函式所關聯的棧幀的運算元棧部分在整個運算元棧中是相鄰的,便於呼叫方函式將引數傳遞給被呼叫函式,也便於被呼叫函式執行完成後將返回值傳遞給呼叫函式。
  3. 函式返回地址,用於儲存該棧幀呼叫指令的下一條指令的地址,當該棧幀從呼叫棧彈出時,會返回到該棧幀呼叫指令的下一條指令繼續執行,換句話說就是當前棧幀對應的函式執行完退出後,返回到呼叫該函式的地方繼續執行後面的指令。

Tip: 目前這個直譯器定義的棧幀中比沒有類似 JVM 虛擬機器棧幀中的區域性變數表,而是將引數、區域性變數和運算元都放在了運算元棧上,主要目的有兩個:

  1. 實現簡單,不需要額外定義區域性變數表,可以很大程度簡化程式碼。
  2. 讓引數傳遞變成無操作 NOP,可以讓兩個棧幀的運算元棧有一部分資料是重疊的,這部分資料就是引數,這樣自然就起到了引數在不同函式之間傳遞的作用。

實際示例

經過上面的鋪墊,相信大家對棧式虛擬機器有了一定的認識。最後我們用一個實際示例來闡述下整個執行過程:

下面這個 Wasm 文字格式中的有兩個函式:compute 函式和 add 函式,其中 add 函式主要是接收兩個數(型別分別是 32 位整數和 32 位浮點數),計算兩數之和。compute 函式中呼叫了兩次 add 函式,注意第二次呼叫 add 函式時,運算元棧上已經儲存了上次呼叫 add 函式時的返回結果(再一次印證了兩個函式關聯的棧幀是共用同一個完整的運算元棧的,可以很便捷地實現函式之間引數的傳遞),所以這次僅需要傳入第二個引數即可。

(module
    (func $compute (result i32)
        i32.const 13    ;; 向運算元棧壓入 13
        f32.const 42.0  ;; 向運算元棧壓入 42.0
        call $add       ;; 呼叫 $add 函式得到 55
        f32.const 10.0  ;; 向運算元棧壓入 10.0
        call $add       ;; 再呼叫 $add 函式得到 65
    )
    (func $add(param $a i32) (param $b f32) (result i32)
        i32.get_local $a  ;; 將型別為 32 位整數的區域性變數 $a 壓入到運算元棧
        f32.get_local $b  ;; 將型別為 32 位浮點數的區域性變數 $b 壓入到運算元棧
        i32.trunc_f32_s   ;; 將當前運算元棧頂的 32 位浮點數 $b 截斷為 32 有符號位整數(截掉小數部分)
        i32.add           ;; 將運算元棧頂和次棧頂的 32 位整數從運算元棧彈出,並計算兩者之和然後將和壓入運算元棧
    )
    (export "compute" (func $compute))
    (export "add" (func $add))
)

對應的就是其執行過程的示意圖如下:

最後展示下執行階段對應的部分實際程式碼截圖如下:

可以看到虛擬機器的取指、譯碼、執行三個階段,可以使用 while 迴圈和 switch-case 語句來簡單地實現。更多細節建議查閱 https://github.com/mcuking/wasmc/blob/master/source/interpreter.c 中的 interpreter 函式,其中有豐富的註釋講解。

結束語

以上就是 Wasm 直譯器實現中的核心內容,當然這僅僅是 Wasm 直譯器的最基本的功能 —— 簡單地逐條解析並執行指令,沒有像其他專業的直譯器那樣提供 JIT 功能 —— 即先解釋執行位元組碼來快速啟動,然後再通過 JIT 將其編譯成平臺相關的機器碼,以提升後面程式碼執行的速度(注:JIT 的具體實現過程因直譯器而異)。

所以用本專案的直譯器解釋執行 Wasm 檔案,速度上並沒有太多優勢。但也正是由於其實現比較簡單,所以原始碼更易讀,並且其中有豐富的註釋,所以非常適合對 Wasm 有興趣的讀者快速瞭解該技術的核心原理。

需要指出的是,本篇文章並沒有涉及到如何使用 Wasm 技術。而恰好筆者正在基於 Wasm 和 FFmpeg 開發支援 H256 編碼的視訊播放器,相關文章連結如下:

《深入 WebAssembly 之視訊播放器應用篇》

預計在視訊播放器投入到實際生產環境後,逐步完善文章內容 —— 重點闡述如何在前端專案中更好地應用 Wasm 技術,敬請期待~

https://github.com/mcuking/blog

參考資料

本文釋出自 網易雲音樂大前端團隊,文章未經授權禁止任何形式的轉載。我們常年招收前端、iOS、Android,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章