理解 WebAssembly 文本格式

爲了能夠讓人類閱讀和編輯 WebAssembly,wasm 二進制格式提供了相應的文本表示。這是一種用來在文本編輯器、瀏覽器開發者工具等工具中顯示的中間形式。本文用基本語法的方式解釋了這種文本表示是如何工作的,以及它是如何與它表示的底層字節碼,及在 JavaScript 中表示 wasm 的封裝對象關聯起來的。
本質上,這種文本形式更類似於處理器的彙編指令。

:如果你是一個 Web 開發者並且只是想在頁面中加載 wasm 模塊然後在你的代碼中使用它(參考使用 WebAssembly 的 JavaScript API),那麼,本文可能有點兒強人所難了。但是,如果你想編寫 wasm 模塊從而優化你的 JavaScript 的性能或者構建你自己的 WebAssembly 編譯器,那麼,本文是很有用的。

不論是二進制還是文本格式,WebAssembly 代碼中的基本單元是一個模塊。在文本格式中,一個模塊被表示爲一個大的 S - 表達式。

S - 表達式是一個非常古老和非常簡單的用來表示樹的文本格式。因此,我們可以把一個模塊想象爲一棵由描述了模塊結構和代碼的節點組成的樹。不過,與一門編程語言的抽象語法樹不同的是,WebAssembly 的樹是相當平的,也就是大部分包含了指令列表。

首先,讓我們看下 S - 表達式長什麼樣。樹上的每個一個節點都有一對括號——(...)——包圍。括號內的第一個標籤告訴你該節點的類型,其後跟隨的是由空格分隔的屬性或孩子節點列表。

S - 表達式如下:

(module (memory 1) (func))

這條表達式,表示一棵根節點爲 “模塊(module)” 的樹,該樹有兩個孩子節點,分別是 屬性爲 1 的 “內存(memory)” 節點 和 一個 “函數(func)” 節點。我們一會兒就會看到這些節點的含義。

讓我們從最簡單最短的可能的 wasm 模塊開始。

(module)

這個模塊完全是空的,但是仍然是一個合法的模塊。

如果我們現在把該模塊轉換爲二進制(參考把 WebAssembly 文本格式轉換爲 wasm),我們將會看到在二進制格式中描述的 8 字節的模塊頭:

0000000: 0061 736d              ; WASM_BINARY_MAGIC
0000004: 0d00 0000              ; WASM_BINARY_VERSION

好了,那並不是很有趣,讓我們向模塊中增加一些可執行代碼。

WebAssembly 模塊中的所有代碼都是劃分到函數里面。函數具有下列的僞代碼結構:

( func <signature> <locals> <body> )

簽名是由一系列參數類型聲明,及其後面的返回值類型聲明列表組成。值得注意的是:

每一個參數都有一個顯式聲明的類型,wasm 當前有四個可用類型:

參數格式爲 **(param <類型>)**,返回值格式爲 (result <類型>)

因此,接受兩個 32 位整數,返回一個 64 位浮點數的函數應該這樣寫:

(func (param i32) (param i32) (result f64) ... )

在簽名的後面是帶有類型的局部變量,格式爲 **(local <類型>)**。函數調用可以通過參數實參值對局部變量進行初始化。

局部變量和參數能夠被函數體使用 get_local 和 set_local 指令進行讀寫。

get_local/set_local 指令使用數字索引來指向將被存取的條目:按照它們的聲明順序,參數在前,局部變量在後。因此,給定下面的函數:

(func (param i32) (param f32) (local f64)
  get_local 0
  get_local 1
  get_local 2)

由於使用數字索引來指向某個條目容易讓人混淆,因此,也可以通過別名的方式來訪問它們,方法就是在類型聲明的前面添加一個使用美元符號($)作爲前綴的名字。例如:

(func (param $p1 i32) (param $p2 f32) (local $loc i32))

這裏,使用 get_local $p1 就代替 get_local 0,訪問參數 i32 變量時,就可以通過 $p1 進行訪問。

注意,當文本轉換爲二進制後,二進制中只包含整數。

雖然瀏覽器把 wasm 編譯爲某種更高效的東西,但是,wasm 的執行是以棧式機器定義的。也就是說,其基本理念是每種類型的指令都是在棧上執行數值的入棧出棧操作。

例如,get_local 被定義爲把它讀到的局部變量值壓入到棧上,然後 i32.add 從棧上取出兩個 i32 類型值(它的含義是把前面壓入棧上的兩個值取出來)計算它們的和(以 2^32 求模),最後把結果壓入棧上。

當函數被調用的時候,它是從一個空棧開始的。隨着函數體指令的執行,棧會逐步填滿和清空。例如,在執行了下面的函數之後:

(func (param $p i32)
  get_local $p
  get_local $p
  i32.add)

棧上只包含一個 i32 類型值——表達式 ($p + $p) 的結果,該結果是由 i32.add 得到的。函數的返回值就是棧上留下的那個最終值。

WebAssembly 驗證規則確保棧準確匹配:如果你聲明瞭 (result f32),那麼,最終棧上必須包含一個 f32 類型值。如果沒有 result 類型,那麼棧必須是空的。

正如前面提到的,函數體就是函數被調用後執行的指令列表。把已經學到的放在一起,我們能夠定義一個包含我們的簡單函數的模塊:

(module
  (func (param $lhs i32) (param $rhs i32) (result i32)
    get_local $lhs
    get_local $rhs
    i32.add))

這個函數獲取兩個參數,然後相加,最後返回其結果。

有很多東西都可以放在函數體裏面,但是,現在我們從簡單的開始,然後隨着逐步前進,你會看到更多的例子。訪問 webassembly.org 語義手冊獲取可用操作碼的完整列表。

我們的函數自己不會做什麼——現在,我們需要調用它。我們該如何做呢?正如在一個 ES2015 模塊裏面一樣,wasm 函數必須通過模塊裏面的 export 語句顯式地導出。

像局部變量一樣,函數默認也是通過索引來區分的,但是爲了方便,可以給它們起個名字。讓我們由此開始——首先,在關鍵字 func 的後面增加一個美元符號開頭的名字:

(func $add)

現在,我們需要增加一個導出聲明——看起來像下面這樣:

(export "add" (func $add))

這裏的 add 是 JavaScript 中用來區別這個函數的名字,而 $add 則是指出模塊中的哪個 WebAssembly 函數將會被導出:

所以,我們最終的模塊(當前)看起來像下面這樣:

(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    get_local $lhs
    get_local $rhs
    i32.add)
  (export "add" (func $add))
)

如果你想繼續研究這個例子,那麼把我們上面的模塊保存到一個名叫 add.wat 的文件中,然後使用 wabt(參考把 WebAssembly 文本格式轉換爲 wasm)將其轉換爲名叫 add.wasm 的二進制文件。

接下來,我們把二進制文件加載到叫做 addCode 的帶類型數組(獲取 WebAssembly 字節碼 (en-US)),編譯並實例化它,然後在 JavaScript 中執行我們的 add 函數(現在,我們可以在實例的 exports (en-US) 屬性中找到 add())。

fetchAndInstantiate('add.wasm').then(function(instance) {
   console.log(instance.exports.add(1, 2));  // "3"
});

// fetchAndInstantiate() found in wasm-utils.js
function fetchAndInstantiate(url, importObject) {
  return fetch(url).then(response =>
    response.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
  ).then(results =>
    results.instance
  );
}

:你可以在 GitHub 上找到這個例子 add.html (實時運行)。另外,參考 WebAssembly.instantiate() 來獲取關於實例化函數的更多細節以及 wasm-utils.js 來獲取 fetchAndInstantiate() 的源代碼。

現在,我們已經討論了基本概念,讓我們繼續看看更高級的特性。

爲函數給定一個索引或名字,call指令可以調用它。例如,下面的模塊包含兩個函數——一個返回值 42,另一個返回,第一個函數結果加 1。

(module
  (func $getAnswer (result i32)
    i32.const 42)
  (func (export "getAnswerPlus1") (result i32)
    call $getAnswer
    i32.const 1
    i32.add))

:i32.const 只是定義一個 32 位整數並把它壓入棧。你可以把 i32 替換爲任何其他可用的類型,並把 const 值修改爲你想要的任何值(這裏,我們把這個值設置爲 42)。

在這個例子中,你注意到一個 (export "getAnswerPlus1") 代碼段,並且它聲明在第二個函數的 func 語句之後——這聲明我們想導出這個函數,以及定義導出的名字的簡便方法。

從功能上來說,這與我們前面做過的那樣,在函數外面,即模塊的其他地方,包括一個獨立的函數語句是等價的。例如:

(export "getAnswerPlus1" (func $functionName))

調用我們前面模塊的 JavaScript 看起來像這樣:

fetchAndInstantiate('call.wasm').then(function(instance) {
  console.log(instance.exports.getAnswerPlus1());  // "43"
});

注:你可以在 GitHub 上找到這個例子 call.wasm (或實時運行)。再提一次,查看 wasm-utils.js 來了解 fetchAndInstantiate() 的源代碼。

我們已經見過 JavaScript 調用 WebAssembly 函數,但是 WebAssembly 如何調用 JavaScript 函數呢?事實上,WebAssembly 對 JavaScript 沒有任何瞭解,但是,它有一個可以導入 JavaScript 或 wasm 函數的通用方法。讓我們看一個例子:

(module
  (import "console" "log" (func $log (param i32)))
  (func (export "logIt")
    i32.const 13
    call $log))

WebAssembly 使用了兩級命名空間,所以,這裏的導入語句是說我們要求從 console 模塊導入 log 函數。另外,你可以看到在 logIt 函數中,通過 call 指令調用了 JavaScrpit 導入的函數 log。

導入的函數就像普通函數一樣:它們擁有一個 WebAssembly 驗證機制,會靜態檢查的簽名,可以被設置一個索引,能夠被命名和被調用。

JavaScript 函數沒有簽名的概念,因此,無論導入的聲明簽名是什麼,任何 JavaScript 函數都可以被傳遞過來。一旦一個模塊聲明瞭一個導入, WebAssembly.instantiate() 的調用者必須傳遞一個擁有相應屬性的導入對象。

就上面而言,我們需要一個(讓我們稱之爲 importObject 的)對象,並且 importObject.console.log 是一個 JavaScript 函數。

這看起來像下面這樣:

var importObject = {
  console: {
    log: function(arg) {
      console.log(arg);
    }
  }
};

fetchAndInstantiate('logger.wasm', importObject).then(function(instance) {
  instance.exports.logIt();
});

:你可以在 GitHub 上找到這個例子 logger.html (實時運行)。

上面的例子是一個相當簡單的日誌函數:它只是打印一個整數!要是我們想輸出一個文本字符串呢?爲了處理字符串及其他複雜數據類型,WebAssembly 提供了內存。

按照 WebAssembly 的定義,內存就是一個隨着時間增長的字節數組。WebAssembly 包含諸如 i32.load 和 i32.store 指令來實現對線性內存的讀寫。

從 JavaScript 的角度來看,內存就是一個 ArrayBuffer,並且它是可變大小的。從字面上來說,這也是 asm.js 所做的(除了它不能改變大小;參考 asm.js 編程模型)。

因此,一個字符串就是位於這個線性內存某處的字節序列。

讓我們假設我們已經把一個合適的字符串字節寫入到了內存中;那麼,我們該如何把那個字符串傳遞給 JavaScript 呢?

關鍵在於 JavaScript 能夠通過 WebAssembly.Memory()接口創建 WebAssembly 線性內存實例,並且能夠通過相關的實例方法獲取已經存在的內存實例(當前每一個模塊實例只能有一個內存實例)。內存實例擁有一個 buffer (en-US) 獲取器,它返回一個指向整個線性內存的 ArrayBuffer。

內存實例也能夠增長。舉例來說,在 JavaScript 中可以調用 Memory.grow() (en-US) 方法。由於 ArrayBuffer 不能改變大小,所以,當增長產生的時候,當前的 ArrayBuffer 會被移除, 並且一個新的 ArrayBuffer 會被創建並指向新的、更大的內存。這意味着爲了向 JavaScript 傳遞一個字符串,我們所需要做的就是把字符串在線性內存中的偏移量,以及表示其長度的方法傳遞出去。

雖然有許多不同的方法在字符串自身當中保存字符串的長度(例如,C 字符串);但是,這裏爲了簡單起見,我們僅僅把偏移量和長度都作爲參數:

(import "console" "log" (func $log (param i32) (param i32)))

在 JavaScript 端,我們可以使用文本解碼器 API,輕鬆地把我們的字節解碼轉化爲一個 JavaScript 字符串。(這裏,我們使用 utf8,不過,許多其他編碼也是支持的。)

consoleLogString(offset, length) {
  var bytes = new Uint8Array(memory.buffer, offset, length);
  var string = new TextDecoder('utf8').decode(bytes);
  console.log(string);
}

這個謎題的最後一部分就是 consoleLogString 從哪裏獲得?WebAssembly 的內存(memory)實例。這裏,WebAssembly 給我們很大靈活性:我們既可以使用 JavaScript 創建一個內存對象,讓 WebAssembly 模塊導入這個內存,或者我們讓 WebAssembly 模塊創建這個內存並把它導出給 JavaScript。

爲了簡單起見,讓我們用 JavaScript 創建它,然後把它導入到 WebAssembly。我們的導入語句編寫如下:

(import "js" "mem" (memory 1))

1 表示導入的內存必須至少有 1 頁內存。(WebAssembly 定義一頁爲 64KB。)

因此,讓我們看一個完整的打印字符串 “Hi” 的模塊。在一個常規的已編譯的 C 程序,你會調用一個函數來爲字符串分配一段內存。但是,因爲我們正在編寫自己的彙編,並且我們擁有整個線性內存,所以,我們可以使用數據(data)段把字符串內容寫入到一個全局內存中。數據段允許字符串字節在實例化時被寫在一個指定的偏移量。而且,它與原生的可執行格式中的數據(.data)段是類似的。

我們最終的 wasm 模塊看起來像這樣:

(module
  (import "console" "log" (func $log (param i32 i32)))
  (import "js" "mem" (memory 1))
  (data (i32.const 0) "Hi")
  (func (export "writeHi")
    i32.const 0  ;; pass offset 0 to log
    i32.const 2  ;; pass length 2 to log
    call $log))

: 注意上面的雙分號語法,它允許在 WebAssembly 文件中添加註釋。

現在,我們可以從 JavaScript 中創建一個 1 頁的內存(Memory )然後把它傳遞進去。這會在控制檯輸出 "Hi"。

var memory = new WebAssembly.Memory({initial:1});

var importObj = { console: { log: consoleLogString }, js: { mem: memory } };

fetchAndInstantiate('logger2.wasm', importObject).then(function(instance) {
  instance.exports.writeHi();
});

:你可以在 GitHub 上找到完整源代碼 logger2.html (或者實時運行)。

爲了結束 WebAssembly 文本格式之旅,讓我們看看最難理解的、常常令人迷惑的 WebAssembly 部分:表格。

總的來說,表格是從 WebAssembly 代碼中通過索引獲取的可變大小的引用數組。

爲了瞭解爲什麼表格是必須的,我們首先需要觀察前面看到的 call 指令,它接受一個靜態函數索引,並且只調用了一個函數——但是,如果被調用者是一個運行時值呢?

WebAssembly 需要一種做到這一點的調用指令,因此,我們有了接受一個動態函數操作數的 call_indirect 指令。問題是,在 WebAssembly 中,當前操作數的僅有的類型是 i32/i64/f32/f64。

WebAssembly 可以增加一個 anyfunc 類型("any" 的含義是該類型能夠持有任何簽名的函數),但是,不幸的是,由於安全原因,這個 anyfunc 類型不能存儲在線性內存中。線性內存會把存儲的原始內容作爲字節暴露出去,並且這會使得wasm內容能夠任意的查看和修改原始函數地址,而這在網絡上是不被允許的。

解決方案是在一個表格中存儲函數引用,然後作爲 代替,傳遞表格索引——它們只是 i32 類型值。因此,call_indirect 的操作數可以是一個 i32 類型索引值。

在 wasm 中定義一個表格

那麼,我們該如何在表格中放置 wasm 函數呢?就像數據段能夠用來通過字節初始化線性內存區域一樣,元素(elem)段能夠用來通過函數初始化表格區域:

(module
  (table 2 anyfunc)
  (elem (i32.const 0) $f1 $f2)
  (func $f1 (result i32)
    i32.const 42)
  (func $f2 (result i32)
    i32.const 13)
  ...
)

: 未初始化的元素會被設定一個默認的調用即拋出(throw-on-call)值。

在 JavaScript 中,可以創建這樣一個表格實例的等價的函數調用看起來如下所示:

function() {
  // table section
  var tbl = new WebAssembly.Table({initial:2, element:"anyfunc"});

  // function sections:
  var f1 = function() {}
  var f2 = function() {}

  // elem section
  tbl.set(0, f1);
  tbl.set(1, f2);
};

使用表格

接着繼續。現在,表格已經定義好了,我們需要用某種方法使用它。讓我們使用下面的代碼段來做到這一點:

(type $return_i32 (func (result i32))) ;; if this was f32, type checking would fail
(func (export "callByIndex") (param $i i32) (result i32)
  get_local $i
  call_indirect $return_i32)

你也可以在命令調用的時候顯式地聲明 call_indirect 的參數,就像下面這樣:

(call_indirect $return_i32 (get_local $i))

在更高層面,像 JavaScript 這樣更具表達力的語言,你可以設想使用一個數組(或者更有可能的是對象)來完成相同的事情。僞代碼看起來像這樣:tbli

回到類型檢查。因爲 WebAssembly 是帶有類型檢查的,並且 anyfunc 的含義是任何函數簽名,所以,我們必須在調用點提供假定的被調用函數簽名。這裏,我們包含了一個 $return_i32 類型來告訴程序期望的是一個返回值爲 i32 類型的函數。如果被調用函數沒有一個匹配的簽名(比如說返回值是 f32 類型的),那麼,程序會拋出 WebAssembly.RuntimeError 異常。

那麼,是什麼把 call_indirect 指令和我們要是用的表格聯繫起來的呢?答案是,現在每一個模塊實例只允許唯一一個表格存在,這也就是 call_indirect 指令隱式地使用的表格。在將來,當多表格被允許了,我們需要在代碼行中指明一個某種形式的表格標識符:

call_indirect $my_spicy_table $i32_to_void

完整的模塊看起來如下所示並且能夠在我們的 wasm-table.wat 示例文件中找到:

(module
  (table 2 anyfunc)
  (func $f1 (result i32)
    i32.const 42)
  (func $f2 (result i32)
    i32.const 13)
  (elem (i32.const 0) $f1 $f2)
  (type $return_i32 (func (result i32)))
  (func (export "callByIndex") (param $i i32) (result i32)
    get_local $i
    call_indirect $return_i32)
)

我們使用下面的 JavaScript 把它加載到一個網頁中:

fetchAndInstantiate('wasm-table.wasm').then(function(instance) {
  console.log(instance.exports.callByIndex(0)); // 返回42
  console.log(instance.exports.callByIndex(1)); // 返回13
  console.log(instance.exports.callByIndex(2));
  // 返回一個錯誤,因爲在表格中沒有索引值2
});

:你可以在 GitHub 上找到這個例子 wasm-table.html (實時查看)。

:就像內存一樣,表格也能夠從 JavaScript 中創建 (參考 WebAssembly.Table()) 並且能夠導入和導出到其他 wasm 模塊。

因爲 JavaScript 對於函數引用有完全的存取權限,所以,從 JavaScript 中通過 grow() (en-US)get() (en-US)set() (en-US) 方法能夠改變表格對象。

因爲表格是可變的,所以,它們能夠用來實現複雜的加載時和運行時動態鏈接。當程序被動態地鏈接,多個實例共享相同的內存和表格。這與原生應用程序的多個. dll 共享一個進程地址空間是等價的。

爲了看看實際情況,我們會創建一個包含一個內存對象和一個表格對象的導入對象,並且把這個導入對象傳遞到多個 instantiate() 調用中去。

我們的. wat 看起來像這樣:

shared0.wat:

(module
  (import "js" "memory" (memory 1))
  (import "js" "table" (table 1 anyfunc))
  (elem (i32.const 0) $shared0func)
  (func $shared0func (result i32)
   i32.const 0
   i32.load)
)

shared1.wat:

(module
  (import "js" "memory" (memory 1))
  (import "js" "table" (table 1 anyfunc))
  (type $void_to_i32 (func (result i32)))
  (func (export “doIt”) (result i32)
   i32.const 0
   i32.const 42
   i32.store  ;; store 42 at address 0
   i32.const 0
   call_indirect $void_to_i32)
)

運行邏輯如下:

  1. 函數 shared0func 在 shared0.wat 中定義並存儲在我們的導出表格對象 (table) 中。
  2. 該函數先創建一個常量值爲 0,然後執行 i32.load 指令。用給定的內存索引,去加載存儲到內存對象中的值,給定的索引值爲 0。—— 這樣,會隱式地將之前的值出棧。所以,shared0func 加載並返回了存儲在內存對象索引 0 處的值。
  3. 在 shared1.wat 中,我們導出了一個名爲 doIt 的函數——這個函數創建了兩個常量值,分別爲 0 和 42,然後使用 i32.store 指令把給定的值存儲在指定索引位置的內存對象中。同樣的,該指令會把這些值出棧,所以,結果就是把 42 存儲在內存索引 0 處。
  4. 在這個函數的最後一部分,我們創建了常量值 0,然後調用表格中索引 0 處的函數,該函數正是我們之前在 shared0.wat 中的使用元素代碼段(elem block)存儲的 shared0func。
  5. shared0func 在被調用之後會加載我們在 shared1.wat 中使用 i32.store 指令存儲在內存中的 42。

:上面的表達式會隱式地把這些值出棧,但是,你可以在使用指令的時候進行顯式地聲明。例如:

(i32.store (i32.const 0) (i32.const 42))
(call_indirect $void_to_i32 (i32.const 0))

在轉換爲彙編之後,我們可以在 JavaScript 中通過下面的代碼使用 shared0.wasm 和 shared1.wasm:

var importObj = {
  js: {
    memory : new WebAssembly.Memory({ initial: 1 }),
    table : new WebAssembly.Table({ initial: 1, element: "anyfunc" })
  }
};

Promise.all([
  fetchAndInstantiate('shared0.wasm', importObj),
  fetchAndInstantiate('shared1.wasm', importObj)
]).then(function(results) {
  console.log(results[1].exports.doIt());  // prints 42
});

每一個將被編譯的模塊都可以導入相同的內存和表格對象,這也就是共享相同的線性內存和表格的 “地址空間”。

:你可以在 GitHub 上找到這個例子 shared-address-space.html (或者實時運行)。

以上我們概括瀏覽了,關於 WebAssembly 文本格式的主要部分,以及它們是如何映射到 WebAssembly JS API 中的。 

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://developer.mozilla.org/zh-CN/docs/WebAssembly/Understanding_the_text_format