理解 WebAssembly 文字格式
為了使 WebAssembly 能夠被人類讀取和編輯,Wasm 二進位制格式有一個文字表示形式。這是一種中間形式,旨在在文字編輯器、瀏覽器開發者工具等中公開。本文解釋了該文字格式的工作原理,包括原始語法以及它與表示的底層位元組碼之間的關係——以及在 JavaScript 中表示 Wasm 的包裝器物件。
注意:如果您只是希望將 Wasm 模組載入到頁面中並在程式碼中使用它(請參閱使用 WebAssembly JavaScript API),那麼這可能是多餘的,但如果您想要編寫 Wasm 模組以最佳化 JavaScript 庫的效能,或者構建自己的 WebAssembly 編譯器,那麼它會更有用。
S 表示式
在二進位制和文字格式中,WebAssembly 中程式碼的基本單元是模組。在文字格式中,模組表示為一個大的 S 表示式。S 表示式是一種非常古老且非常簡單的文字格式,用於表示樹,因此我們可以將模組視為描述模組結構及其程式碼的節點樹。但是,與程式語言的抽象語法樹不同,WebAssembly 的樹相當扁平,主要由指令列表組成。
首先,讓我們看看 S 表示式是什麼樣子。樹中的每個節點都位於一對括號內——( ... )。括號內的第一個標籤告訴您它是哪種型別的節點,之後是空格分隔的屬性或子節點列表。這意味著 WebAssembly S 表示式
(module (memory 1) (func))
表示一棵樹,其根節點為“module”,有兩個子節點,一個具有屬性“1”的“memory”節點和一個“func”節點。我們很快就會看到這些節點的實際含義。
最簡單的模組
讓我們從最簡單、最短的 Wasm 模組開始。
(module)
此模組完全為空,但仍然是有效的模組。
如果我們現在將模組轉換為二進位制格式(請參閱將 WebAssembly 文字格式轉換為 Wasm),我們將看到二進位制格式中描述的 8 位元組模組頭。
0000000: 0061 736d ; WASM_BINARY_MAGIC
0000004: 0100 0000 ; WASM_BINARY_VERSION
向模組新增功能
好的,這沒有太多趣味,讓我們向此模組新增一些可執行程式碼。
Webassembly 模組中的所有程式碼都分組到函式中,這些函式具有以下虛擬碼結構
( func <signature> <locals> <body> )
- **簽名**宣告函式接受什麼(引數)並返回什麼(返回值)。
- **區域性變數**類似於 JavaScript 中的 var,但聲明瞭顯式型別。
- **函式體**只是一個低階指令的線性列表。
因此,這類似於其他語言中的函式,即使它看起來不同,因為它是一個 S 表示式。
簽名和引數
簽名是一系列引數型別宣告,後跟返回值型別宣告列表。這裡值得注意的是
- 缺少
(result)意味著函式不返回任何內容。 - 在當前迭代中,最多隻能有一個返回值型別,但稍後將放寬到任意數量。
每個引數都明確聲明瞭型別;Wasm數字型別、引用型別、向量型別。數字型別為
i32:32 位整數i64:64 位整數f32:32 位浮點數f64:64 位浮點數
單個引數寫為(param i32),返回值型別寫為(result i32),因此一個接收兩個 32 位整數並返回 64 位浮點數的二元函式將這樣寫
(func (param i32) (param i32) (result f64) ...)
在簽名之後,區域性變數及其型別一起列出,例如(local i32)。引數基本上只是由呼叫方傳遞的相應引數的值初始化的區域性變數。
獲取和設定區域性變數和引數
區域性變數/引數可以透過函式體的local.get和local.set指令讀取和寫入。
local.get/local.set命令透過其數字索引引用要獲取/設定的專案:首先按宣告順序引用引數,然後按宣告順序引用區域性變數。因此,給定以下函式
(func (param i32) (param f32) (local f64)
local.get 0
local.get 1
local.get 2)
指令local.get 0將獲取 i32 引數,local.get 1將獲取 f32 引數,而local.get 2將獲取 f64 區域性變數。
這裡還有另一個問題——使用數字索引引用專案可能會令人困惑且煩人,因此文字格式允許您透過在型別宣告之前包含以美元符號 ($) 為字首的名稱來命名引數、區域性變數和大多數其他專案。
因此,您可以像這樣重寫我們之前的簽名
(func (param $p1 i32) (param $p2 f32) (local $loc f64) …)
然後可以編寫local.get $p1而不是local.get 0等。(請注意,當此文字轉換為二進位制時,二進位制檔案將僅包含整數。)
棧機器
在我們可以編寫函式體之前,我們必須討論另一件事:**棧機器**。儘管瀏覽器將其編譯成更有效的東西,但 Wasm 執行是根據棧機器定義的,棧機器的基本思想是每種型別的指令都會將一定數量的i32/i64/f32/f64值推入或彈出棧。
例如,local.get定義為將其讀取的區域性變數的值推入棧中,而i32.add彈出兩個i32值(它隱式地獲取之前推入棧中的前兩個值),計算它們的和(模 2^32)並推入結果 i32 值。
當呼叫函式時,它從一個空棧開始,隨著函式體指令的執行,棧逐漸被填充和清空。例如,執行以下函式後
(func (param $p i32)
(result i32)
local.get $p
local.get $p
i32.add)
棧中恰好包含一個i32值——表示式 ($p + $p) 的結果,該結果由i32.add處理。函式的返回值只是棧上剩下的最後一個值。
WebAssembly 驗證規則確保棧完全匹配:如果您宣告(result f32),則棧必須在最後包含恰好一個f32。如果沒有結果型別,則棧必須為空。
我們的第一個函式體
如前所述,函式體是呼叫函式時按順序執行的指令列表。將此與我們已經學到的內容結合起來,我們終於可以定義一個包含我們自己的簡單函式的模組了
(module
(func (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add))
此函式獲取兩個引數,將它們加在一起,並返回結果。
函式體中還可以包含很多其他內容,但我們現在先從簡單的內容開始,隨著學習的深入,您會看到更多示例。有關可用操作碼的完整列表,請參閱webassembly.org 語義參考。
呼叫函式
我們的函式本身不會做太多事情——現在我們需要呼叫它。我們該怎麼做呢?與 ES 模組一樣,Wasm 函式必須透過模組內的export語句顯式匯出。
與區域性變數一樣,函式預設情況下由索引標識,但為了方便起見,可以為其命名。讓我們從這裡開始——首先,我們將在func關鍵字之後新增一個以美元符號為字首的名稱
(func $add …)
現在我們需要新增一個匯出宣告——如下所示
(export "add" (func $add))
這裡,add是函式在 JavaScript 中的標識名稱,而$add則選擇要匯出的模組內部的哪個 WebAssembly 函式。
因此,我們最終的模組(目前)如下所示
(module
(func $add (param $lhs i32) (param $rhs i32) (result i32)
local.get $lhs
local.get $rhs
i32.add)
(export "add" (func $add))
)
如果您想跟隨示例進行操作,請將上述模組儲存到名為add.wat的檔案中,然後使用 wabt 將其轉換為名為add.wasm的二進位制檔案(有關詳細資訊,請參閱將 WebAssembly 文字格式轉換為 Wasm)。
接下來,我們將非同步例項化我們的二進位制檔案(請參閱載入和執行 WebAssembly 程式碼)並在 JavaScript 中執行我們的add函式(我們現在可以在例項的exports屬性中找到add())。
WebAssembly.instantiateStreaming(fetch("add.wasm")).then((obj) => {
console.log(obj.instance.exports.add(1, 2)); // "3"
});
注意:您可以在 GitHub 上找到此示例,網址為add.html(也可以檢視其線上版本)。另請參閱WebAssembly.instantiateStreaming(),以獲取有關例項化函式的更多詳細資訊。
探索基礎知識
現在我們已經介紹了真正基礎的內容,讓我們繼續瞭解一些更高階的功能。
從同一模組中的其他函式呼叫函式
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 替換為任何其他可用型別,並將常量的值更改為您想要的任何值(這裡我們將值設定為 42)。
在這個例子中,您會注意到一個 (export "getAnswerPlus1") 部分,它在第二個函式的 func 語句之後宣告——這是一種簡寫方式,用於宣告我們希望匯出此函式,並定義我們希望匯出的名稱。
這在功能上等同於在模組中的其他地方,在函式外部包含一個單獨的函式語句,就像我們之前做的那樣,例如:
(export "getAnswerPlus1" (func $functionName))
呼叫我們上面模組的 JavaScript 程式碼如下所示:
WebAssembly.instantiateStreaming(fetch("call.wasm")).then((obj) => {
console.log(obj.instance.exports.getAnswerPlus1()); // "43"
});
從 JavaScript 匯入函式
我們已經看到了 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 指令呼叫匯入的函式。
匯入的函式就像普通函式一樣:它們具有 WebAssembly 驗證靜態檢查的簽名,並且被賦予索引,可以命名和呼叫。
JavaScript 函式沒有簽名的概念,因此可以傳遞任何 JavaScript 函式,無論匯入宣告的簽名是什麼。一旦模組聲明瞭匯入,WebAssembly.instantiate() 的呼叫者必須傳入一個具有對應屬性的匯入物件。
對於上述情況,我們需要一個物件(我們稱之為 importObject),使得 importObject.console.log 是一個 JavaScript 函式。
這將如下所示:
const importObject = {
console: {
log(arg) {
console.log(arg);
},
},
};
WebAssembly.instantiateStreaming(fetch("logger.wasm"), importObject).then(
(obj) => {
obj.instance.exports.logIt();
},
);
注意:您可以在 GitHub 上找到此示例,網址為 logger.html(也可以線上檢視)。
在 WebAssembly 中宣告全域性變數
WebAssembly 能夠建立全域性變數例項,這些例項可從 JavaScript 訪問,並且可以在一個或多個 WebAssembly.Module 例項之間匯入/匯出。這非常有用,因為它允許多個模組的動態連結。
在 WebAssembly 文字格式中,它看起來像這樣(請參閱我們 GitHub 儲存庫中的 global.wat;也可以參閱 global.html 以獲取即時 JavaScript 示例)。
(module
(global $g (import "js" "global") (mut i32))
(func (export "getGlobal") (result i32)
(global.get $g))
(func (export "incGlobal")
(global.set $g
(i32.add (global.get $g) (i32.const 1))))
)
這看起來與我們之前看到的類似,除了我們使用關鍵字 global 指定全域性值,並且如果我們希望它可變,我們還會在值的資料型別旁邊指定關鍵字 mut。
要使用 JavaScript 建立等效的值,您可以使用 WebAssembly.Global() 建構函式。
const global = new WebAssembly.Global({ value: "i32", mutable: true }, 0);
WebAssembly 記憶體
以上示例展示瞭如何在彙編程式碼中處理數字,將它們新增到 棧 中,對它們執行操作,然後透過呼叫 JavaScript 中的方法記錄結果。
對於處理字串和其他更復雜的資料型別,我們使用 memory,它可以在 WebAssembly 或 JavaScript 中建立,並在環境之間共享(更新版本的 WebAssembly 也可以使用 引用型別)。
在 WebAssembly 中,memory 只是一個大的連續的可變原始位元組陣列,它可以隨著時間的推移而增長(請參閱規範中的 線性記憶體)。WebAssembly 包含 記憶體指令,如 i32.load 和 i32.store,用於在棧和記憶體中的任何位置之間讀寫位元組。
從 JavaScript 的角度來看,記憶體就像都在一個可增長的 ArrayBuffer 中。JavaScript 可以透過 WebAssembly.Memory() 介面建立 WebAssembly 線性記憶體例項並將其匯出到記憶體例項,或者訪問在 WebAssembly 程式碼中建立並匯出的記憶體例項。JavaScript Memory 例項具有 buffer 獲取器,它返回一個指向整個線性記憶體的 ArrayBuffer。
記憶體例項也可以增長,例如透過 JavaScript 中的 Memory.grow() 方法或 WebAssembly 中的 memory.grow 方法。由於 ArrayBuffer 物件的大小不能改變,因此當前的 ArrayBuffer 將分離,並建立一個新的 ArrayBuffer 來指向更新、更大的記憶體。
請注意,當您建立記憶體時,您需要定義初始大小,並且可以可選地指定記憶體可以增長到的最大大小。WebAssembly 將嘗試保留最大大小(如果已指定),如果能夠這樣做,它可以在將來更有效地增長緩衝區。即使現在無法分配最大大小,它以後也可能仍然能夠增長。只有當無法分配初始大小時,該方法才會失敗。
注意:最初,WebAssembly 每個模組例項只允許一個記憶體。現在,在瀏覽器支援的情況下,您可以擁有 多個記憶體。不使用多個記憶體的程式碼不需要更改!
為了演示其中一些行為,讓我們考慮一下我們希望在 WebAssembly 程式碼中使用字串的情況。字串只是線性記憶體中某個位置的位元組序列。假設我們已將合適的位元組字串寫入 WebAssembly 記憶體,我們可以透過共享記憶體、記憶體中字串的偏移量以及某種指示長度的方法將該字串傳遞給 JavaScript。
首先,讓我們建立一些記憶體並在 WebAssembly 和 JavaScript 之間共享它。WebAssembly 在這裡給了我們很大的靈活性:我們可以在 JavaScript 中建立 Memory 物件並讓 WebAssembly 模組匯入該記憶體,或者讓 WebAssembly 模組建立該記憶體並將其匯出到 JavaScript。
對於此示例,我們將建立 JavaScript 中的記憶體,然後將其匯入到 WebAssembly 中。首先,我們使用 1 頁建立一個 Memory 物件,並將其新增到 importObject 中的鍵 js.mem 下。然後,我們使用 WebAssembly.instantiateStreaming() 方法例項化我們的 WebAssembly 模組(在本例中為“the_wasm_to_import.wasm”),並傳遞匯入物件。
const memory = new WebAssembly.Memory({ initial: 1 });
const importObject = {
js: { mem: memory },
};
WebAssembly.instantiateStreaming(
fetch("the_wasm_to_import.wasm"),
importObject,
).then((obj) => {
// Call exported functions ...
});
在我們的 WebAssembly 檔案中,我們匯入此記憶體。使用 WebAssembly 文字格式,import 語句寫成如下所示:
(import "js" "mem" (memory 1))
必須使用 importObject 中指定的相同兩級鍵(js.mem)匯入記憶體。1 表示匯入的記憶體必須至少具有 1 頁記憶體(WebAssembly 目前將頁面定義為 64KB)。
注意:由於這是匯入到 WebAssembly 模組中的第一個記憶體,因此它的記憶體索引為“0”。您可以使用索引在 記憶體指令 中引用此特定記憶體,但是由於 0 是預設索引,因此在單記憶體應用程式中,您不需要指定。
現在我們有了共享的記憶體例項,下一步是將字串資料寫入其中。然後,我們將有關字串位置和長度的資訊傳遞給 JavaScript(我們也可以在字串本身中編碼字串的長度,但對於我們來說,傳遞長度更容易實現)。
首先,讓我們將字串資料新增到我們的記憶體中,在本例中為“Hi”。由於我們擁有整個線性記憶體,因此我們只需使用 data 部分將字串內容寫入全域性記憶體即可。資料部分允許在例項化時將位元組字串寫入給定偏移量,並且類似於本機可執行檔案格式中的 .data 部分。這裡我們將資料寫入預設記憶體(我們不需要指定)的偏移量 0。
(module
(import "js" "mem" (memory 1))
;; ...
(data (i32.const 0) "Hi")
;;
)
注意:上面的雙分號語法 (;;) 用於指示 WebAssembly 檔案中的註釋。在本例中,我們只是使用它們來指示其他程式碼的佔位符。
為了與 JavaScript 共享此資料,我們將定義兩個函式。首先,我們從 JavaScript 匯入一個函式,我們將使用它來將字串記錄到控制檯。這需要在用於例項化 WebAssembly 模組的 importObject 中對映到 console.log。該函式在 WebAssembly 中命名為 $log,併為記憶體中的字串偏移量和長度採用 i32 引數。
第二個 WebAssembly 函式 writeHi() 使用記憶體中字串的偏移量和長度(0 和 2)呼叫匯入的 $log 函式。這從模組匯出,以便可以從 JavaScript 呼叫它。
我們最終的 WebAssembly 模組(文字格式)如下所示。
(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
)
)
在 JavaScript 端,我們需要定義日誌記錄函式,將其傳遞給 WebAssembly,然後呼叫匯出的 writeHi() 方法。完整的程式碼如下所示:
const memory = new WebAssembly.Memory({ initial: 1 });
// Logging function ($log) called from WebAssembly
function consoleLogString(offset, length) {
const bytes = new Uint8Array(memory.buffer, offset, length);
const string = new TextDecoder("utf8").decode(bytes);
console.log(string);
}
const importObject = {
console: { log: consoleLogString },
js: { mem: memory },
};
WebAssembly.instantiateStreaming(fetch("logger2.wasm"), importObject).then(
(obj) => {
// Call the function exported from logger2.wasm
obj.instance.exports.writeHi();
},
);
請注意,日誌記錄函式 consoleLogString() 被傳遞到 importObject 中的屬性 console.log,並由 WebAssembly 模組匯入。該函式使用傳遞的偏移量和給定長度在共享記憶體中的字串上建立一個 Uint8Array 檢視。然後使用 TextDecoder API 將位元組從 UTF-8 解碼為字串(我們在這裡指定 utf8,但支援許多其他編碼)。然後使用 console.log() 將字串記錄到控制檯。
最後一步是呼叫匯出的 writeHi() 函式,這在物件例項化後完成。當您執行程式碼時,控制檯將顯示文字“Hi”。
注意:您可以在 GitHub 上找到完整的原始碼,網址為 logger2.html(也可以線上檢視)。
多個記憶體
更新的實現允許您在 WebAssembly 和 JavaScript 中使用多個記憶體物件,這種方式與為僅支援單個記憶體的實現編寫的程式碼相容。多個記憶體對於分離應與其他應用程式資料以不同方式處理的資料(例如公共資料與私有資料、需要持久化的資料以及需要線上程之間共享的資料)非常有用。對於需要擴充套件到 Wasm 32 位地址空間的非常大的應用程式以及其他目的,它也可能很有用。
提供給 WebAssembly 程式碼的記憶體(無論直接宣告還是匯入)都會獲得一個從零開始的順序分配的記憶體索引號。所有 記憶體指令(例如 load 或 store)都可以透過其索引引用任何特定記憶體,因此您可以控制正在使用的記憶體。
記憶體指令的預設索引為 0,即新增到 WebAssembly 例項的第一個記憶體的索引。因此,如果您只新增一個記憶體,則您的程式碼不需要指定索引。
為了更詳細地說明其工作原理,我們將擴充套件之前的示例,將字串寫入三個不同的記憶體並記錄結果。下面的程式碼展示了我們如何首先匯入兩個記憶體例項,使用與前一個示例相同的方法。為了展示如何在 WebAssembly 模組中建立記憶體,我們在模組中建立了一個名為$mem2的第三個記憶體例項並將其匯出。
(module
;; ...
(import "js" "mem0" (memory 1))
(import "js" "mem1" (memory 1))
;; Create and export a third memory
(memory $mem2 1)
(export "memory2" (memory $mem2))
;; ...
)
這三個記憶體例項會根據其建立順序自動分配例項。下面的程式碼展示了我們如何在data指令中指定此索引(例如(memory 1))以選擇要將字串寫入的記憶體(您可以對所有其他記憶體指令使用相同的方法,例如load和grow)。在這裡,我們寫入一個指示每種記憶體型別的字串。
(data (memory 0) (i32.const 0) "Memory 0 data")
(data (memory 1) (i32.const 0) "Memory 1 data")
(data (memory 2) (i32.const 0) "Memory 2 data")
;; Add text to default (0-index) memory
(data (i32.const 13) " (Default)")
請注意,(memory 0)是預設值,因此是可選的。為了證明這一點,我們在不指定記憶體索引的情況下寫入文字" (Default)",當記錄記憶體內容時,此文字應附加在"Memory 0 data"之後。
WebAssembly 記錄程式碼與前一個示例幾乎完全相同,只是除了字串偏移量和長度之外,我們還需要傳遞包含字串的記憶體的索引。我們還記錄了所有三個記憶體例項。
完整的模組如下所示
(module
(import "console" "log" (func $log (param i32 i32 i32)))
(import "js" "mem0" (memory 1))
(import "js" "mem1" (memory 1))
;; Create and export a third memory
(memory $mem2 1)
(export "memory2" (memory $mem2))
(data (memory 0) (i32.const 0) "Memory 0 data")
(data (memory 1) (i32.const 0) "Memory 1 data")
(data (memory 2) (i32.const 0) "Memory 2 data")
;; Add text to default (0-index) memory
(data (i32.const 13) " (Default)")
(func $logMemory (param $memIndex i32) (param $memOffSet i32) (param $stringLength i32)
local.get $memIndex
local.get $memOffSet
local.get $stringLength
call $log
)
(func (export "logAllMemory")
;; Log memory index 0, offset 0
(i32.const 0) ;; memory index 0
(i32.const 0) ;; memory offset 0
(i32.const 23) ;; string length 23
(call $logMemory)
;; Log memory index 1, offset 0
i32.const 1 ;; memory index 1
i32.const 0 ;; memory offset 0
i32.const 20 ;; string length 20
call $logMemory
;; Log memory index 2, offset 0
i32.const 2 ;; memory index 2
i32.const 0 ;; memory offset 0
i32.const 12 ;; string length 13
call $logMemory
)
)
JavaScript 程式碼也與前一個示例非常相似,除了我們建立並將兩個記憶體例項傳遞給importObject(),並且模組例項匯出的記憶體在例項化後使用已解析的 Promise(obj.instance.exports)進行訪問。記錄每個字串的程式碼也稍微複雜一些,因為我們需要將 WebAssembly 中的記憶體例項編號與特定的Memory物件匹配。
const memory0 = new WebAssembly.Memory({ initial: 1 });
const memory1 = new WebAssembly.Memory({ initial: 1 });
let memory2; // Created by module
function consoleLogString(memoryInstance, offset, length) {
let memory;
switch (memoryInstance) {
case 0:
memory = memory0;
break;
case 1:
memory = memory1;
break;
case 2:
memory = memory2;
break;
// code block
}
const bytes = new Uint8Array(memory.buffer, offset, length);
const string = new TextDecoder("utf8").decode(bytes);
log(string); // implementation not shown - could call console.log()
}
const importObject = {
console: { log: consoleLogString },
js: { mem0: memory0, mem1: memory1 },
};
WebAssembly.instantiateStreaming(fetch("multi-memory.wasm"), importObject).then(
(obj) => {
//Get exported memory
memory2 = obj.instance.exports.memory2;
//Log memory
obj.instance.exports.logAllMemory();
},
);
該示例的輸出應類似於以下文字,但“Memory 1 data”可能有一些尾隨的“垃圾字元”,因為文字解碼器傳遞的位元組數超過了用於編碼字串的位元組數。
Memory 0 data (Default) Memory 1 data Memory 2 data
您可以在 GitHub 上找到完整的原始碼,網址為 multi-memory.html(也可檢視其線上版本)
注意:有關此功能的瀏覽器相容性資訊,請參閱主頁上的webassembly.multimemory。
WebAssembly 表格
為了完成本次 WebAssembly 文字格式的巡禮,讓我們看看 WebAssembly 最複雜且經常令人困惑的部分:表格。表格基本上是可調整大小的引用陣列,可以透過 WebAssembly 程式碼中的索引訪問。
要了解為什麼需要表格,我們首先需要觀察到我們之前看到的call指令(請參閱在同一模組中呼叫其他函式的函式)採用靜態函式索引,因此只能呼叫一個函式——但如果被呼叫者是執行時值呢?
- 在 JavaScript 中,我們經常看到這種情況:函式是一等值。
- 在 C/C++ 中,我們使用函式指標可以看到這一點。
- 在 C++ 中,我們使用虛擬函式可以看到這一點。
WebAssembly 需要一種型別的呼叫指令來實現這一點,因此我們提供了call_indirect,它採用動態函式運算元。問題在於,我們目前在 WebAssembly 中提供的運算元型別僅為i32/i64/f32/f64。
WebAssembly 可以新增anyfunc型別(“any”因為該型別可以儲存任何簽名的函式),但不幸的是,出於安全原因,此anyfunc型別不能儲存線上性記憶體中。線性記憶體將儲存值的原始內容作為位元組公開,這將允許 Wasm 內容任意觀察和破壞原始函式地址,這在網路上是不允許的。
解決方案是將函式引用儲存在表格中,並傳遞表格索引,這些索引只是 i32 值。因此,call_indirect的運算元可以是 i32 索引值。
在 Wasm 中定義表格
那麼我們如何將 Wasm 函式放入我們的表格中呢?就像data部分可用於使用位元組初始化線性記憶體區域一樣,elem部分可用於使用函式初始化表格區域
(module
(table 2 funcref)
(elem (i32.const 0) $f1 $f2)
(func $f1 (result i32)
i32.const 42)
(func $f2 (result i32)
i32.const 13)
...
)
- 在
(table 2 funcref)中,2 是表格的初始大小(表示它將儲存兩個引用),funcref宣告這些引用的元素型別是函式引用。 func部分就像任何其他宣告的 Wasm 函式一樣。這些是我們將在表格中引用的函式(例如,每個函式都只返回一個常量值)。請注意,此處宣告部分的順序無關緊要——您可以在任何位置宣告函式,並在elem部分中引用它們。elem部分可以按任何順序列出模組中任何子集的函式,並允許重複。這是要由表格引用的函式列表,按其引用順序排列。elem部分中的(i32.const 0)值是一個偏移量——這需要在部分開頭宣告,並指定在表格中函式引用開始填充的哪個索引處。這裡我們指定了 0,大小為 2(見上文),因此我們可以在索引 0 和 1 處填充兩個引用。如果我們想從偏移量 1 開始寫入引用,則必須寫入(i32.const 1),並且表格大小必須為 3。
注意:未初始化的元素將獲得預設的丟擲呼叫值。
在 JavaScript 中,建立此類表格例項的等效呼叫如下所示
function () {
// table section
const tbl = new WebAssembly.Table({initial: 2, element: "anyfunc"});
// function sections:
const f1 = ... /* some imported WebAssembly function */
const f2 = ... /* some imported WebAssembly 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)
local.get $i
call_indirect (type $return_i32))
(type $return_i32 (func (result i32)))塊指定了一種型別,並帶有一個引用名稱。在稍後執行表格函式引用呼叫的型別檢查時,將使用此型別。這裡我們說引用需要是返回i32作為結果的函式。- 接下來,我們定義一個函式,該函式將以
callByIndex名稱匯出。這將以i32作為引數,該引數被賦予引數名稱$i。 - 在函式內部,我們在堆疊中新增一個值——作為引數
$i傳入的任何值。 - 最後,我們使用
call_indirect從表格中呼叫一個函式——它隱式地從堆疊中彈出$i的值。這樣做的最終結果是,callByIndex函式呼叫表格中第$i個函式。
您也可以在命令呼叫期間顯式宣告call_indirect引數,而不是在它之前宣告,如下所示
(call_indirect (type $return_i32) (local.get $i))
在像 JavaScript 這樣更高階、更具表現力的語言中,您可以想象使用包含函式的陣列(或者可能更可能是物件)來做同樣的事情。虛擬碼看起來像tbl[i]()。
因此,回到型別檢查。由於 WebAssembly 是型別檢查的,並且funcref可能具有任何函式簽名,因此我們必須在呼叫站點提供被呼叫者的假定簽名,因此我們包含$return_i32型別,以告訴程式期望返回i32的函式。如果被呼叫者沒有匹配的簽名(例如,返回f32),則會丟擲WebAssembly.RuntimeError。
那麼是什麼將call_indirect與我們呼叫的表格聯絡起來呢?答案是每個模組例項目前只允許一個表格,這就是call_indirect隱式呼叫的表格。將來,當允許多個表格時,我們還需要指定某種表格識別符號,例如
call_indirect $my_spicy_table (type $i32_to_void)
整個模組如下所示,您可以在我們的wasm-table.wat示例檔案中找到它
(module
(table 2 funcref)
(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)
local.get $i
call_indirect (type $return_i32))
)
我們使用以下 JavaScript 將其載入到網頁中
WebAssembly.instantiateStreaming(fetch("wasm-table.wasm")).then((obj) => {
console.log(obj.instance.exports.callByIndex(0)); // returns 42
console.log(obj.instance.exports.callByIndex(1)); // returns 13
console.log(obj.instance.exports.callByIndex(2)); // returns an error, because there is no index position 2 in the table
});
注意:您可以在 GitHub 上找到此示例,網址為 wasm-table.html(也可檢視其線上版本)。
注意:與記憶體一樣,表格也可以從 JavaScript 中建立(請參閱WebAssembly.Table()),以及匯入/匯出到另一個 Wasm 模組。
更改表格和動態連結
由於 JavaScript 完全訪問函式引用,因此可以使用grow()、get()和set()方法從 JavaScript 更改 Table 物件。WebAssembly 程式碼本身能夠使用作為引用型別一部分新增的指令(例如table.get和table.set)來操作表格。
由於表格是可變的,因此它們可用於實現複雜的載入時和執行時動態連結方案。當程式動態連結時,多個例項共享相同的記憶體和表格。這與本機應用程式對稱,在該應用程式中,多個已編譯的.dll共享單個程序的地址空間。
為了實際演示這一點,我們將建立一個包含 Memory 物件和 Table 物件的單個匯入物件,並將此相同的匯入物件傳遞給多個instantiate()呼叫。
我們的.wat示例如下所示
shared0.wat:
(module
(import "js" "memory" (memory 1))
(import "js" "table" (table 1 funcref))
(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 funcref))
(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 (type $void_to_i32))
)
它們的工作原理如下
- 函式
shared0func在shared0.wat中定義,並存儲在我們匯入的表格中。 - 此函式建立一個包含值
0的常量,然後使用i32.load命令載入提供的記憶體索引中包含的值。提供的索引為0——同樣,它隱式地從堆疊中彈出先前的值。因此,shared0func載入並返回儲存在記憶體索引0處的值。 - 在
shared1.wat中,我們匯出一個名為doIt的函式——此函式建立兩個包含值0和42的常量,然後呼叫i32.store將提供的值儲存在匯入記憶體的提供索引處。同樣,它隱式地從堆疊中彈出這些值,因此結果是它將值42儲存在記憶體索引0處, - 在函式的最後一部分,我們建立一個值為
0的常量,然後呼叫表格中此索引 0 處的函式,即shared0func,它之前由shared0.wat中的elem塊儲存在那裡。 - 當被呼叫時,
shared0func載入我們使用shared1.wat中的i32.store命令儲存在記憶體中的42。
注意:上述表示式再次隱式地從堆疊中彈出值,但您可以在命令呼叫內部顯式宣告這些值,例如
(i32.store (i32.const 0) (i32.const 42))
(call_indirect (type $void_to_i32) (i32.const 0))
轉換為彙編後,我們隨後透過以下程式碼在 JavaScript 中使用shared0.wasm和shared1.wasm
const importObj = {
js: {
memory: new WebAssembly.Memory({ initial: 1 }),
table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }),
},
};
Promise.all([
WebAssembly.instantiateStreaming(fetch("shared0.wasm"), importObj),
WebAssembly.instantiateStreaming(fetch("shared1.wasm"), importObj),
]).then((results) => {
console.log(results[1].instance.exports.doIt()); // prints 42
});
正在編譯的每個模組都可以匯入相同的記憶體和表格物件,從而共享相同的線性記憶體和表格“地址空間”。
注意:您可以在 GitHub 上找到此示例,網址為 shared-address-space.html(也可檢視其線上版本)。
批次記憶體操作
批次記憶體操作是語言中較新的補充——提供了七個新的內建操作用於批次記憶體操作(如複製和初始化),以允許 WebAssembly 以更有效、更高效的方式模擬本機函式(如memcpy和memmove)。
注意:有關瀏覽器相容性資訊,請參閱主頁上的webassembly.bulk-memory-operations。
新的操作如下所示:
data.drop:丟棄資料段中的資料。elem.drop:丟棄元素段中的資料。memory.copy:將線性記憶體的一個區域複製到另一個區域。memory.fill:用給定的位元組值填充線性記憶體的某個區域。memory.init:從資料段複製一個區域。table.copy:將表的一個區域複製到另一個區域。table.init:從元素段複製一個區域。
注意: 你可以在 批次記憶體操作和條件段初始化 提議中找到更多資訊。
型別
數字型別
WebAssembly 目前有四種可用的數字型別。
i32:32 位整數i64:64 位整數f32:32 位浮點數f64:64 位浮點數
向量型別
v128:128 位向量,包含打包的整數、浮點數資料或單個 128 位型別。
引用型別
引用型別提議 提供了兩個主要功能。
- 一種新的型別
externref,它可以儲存任何 JavaScript 值,例如字串、DOM 引用、物件等。從 WebAssembly 的角度來看,externref是不透明的——Wasm 模組無法訪問和操作這些值,而只能接收它們並將它們傳遞出去。但這對於允許 Wasm 模組呼叫 JavaScript 函式、DOM API 等以及通常為與主機環境更容易互動鋪平道路非常有用。externref可用於值型別和表元素。 - 許多新的指令,允許 Wasm 模組直接操作 WebAssembly 表格,而不是必須透過 JavaScript API 來操作。
注意: wasm-bindgen 文件包含一些關於如何從 Rust 利用 externref 的有用資訊。
注意: 請參閱 主頁上的 webassembly.reference-types,瞭解瀏覽器相容性資訊。
多返回值 WebAssembly
語言的另一個較新的補充是 WebAssembly 多值,這意味著 WebAssembly 函式現在可以返回多個值,並且指令序列可以消耗和產生多個堆疊值。
注意: 請參閱 主頁上的 webassembly.multi-value,瞭解瀏覽器相容性資訊。
在撰寫本文時(2020 年 6 月),這還處於早期階段,唯一可用的多值指令是呼叫自身返回多個值的函式。例如
(module
(func $get_two_numbers (result i32 i32)
i32.const 1
i32.const 2
)
(func (export "add_two_numbers") (result i32)
call $get_two_numbers
i32.add
)
)
但這將為更有用的指令型別和其他事物鋪平道路。有關迄今為止進展情況和工作原理的有用說明,請參閱 Nick Fitzgerald 撰寫的 Multi-Value All The Wasm!。
WebAssembly 執行緒
WebAssembly 執行緒允許 WebAssembly 記憶體物件在執行在單獨 Web Worker 中的多個 WebAssembly 例項之間共享,與 JavaScript 中的 SharedArrayBuffer 方式相同。這允許 Worker 之間進行非常快速的通訊,並在 Web 應用程式中獲得顯著的效能提升。
執行緒提議有兩個部分:共享記憶體和原子記憶體訪問。
注意: 請參閱 主頁上的 webassembly.threads-and-atomics,瞭解瀏覽器相容性資訊。
共享記憶體
如上所述,您可以建立共享的 WebAssembly Memory 物件,可以使用 postMessage() 在 Window 和 Worker 上下文之間傳輸,與 SharedArrayBuffer 的方式相同。
在 JavaScript API 端,WebAssembly.Memory() 建構函式的初始化物件現在具有一個 shared 屬性,將其設定為 true 將建立共享記憶體。
const memory = new WebAssembly.Memory({
initial: 10,
maximum: 100,
shared: true,
});
記憶體的 buffer 屬性現在將返回 SharedArrayBuffer,而不是通常的 ArrayBuffer。
memory.buffer; // returns SharedArrayBuffer
在文字格式中,您可以使用 shared 關鍵字建立共享記憶體,如下所示:
(memory 1 2 shared)
與非共享記憶體不同,共享記憶體必須在 JavaScript API 建構函式和 Wasm 文字格式中都指定“最大”大小。
注意: 你可以在 WebAssembly 的執行緒提議 中找到更多詳細資訊。
原子記憶體訪問
添加了一些新的 Wasm 指令,可用於實現更高級別的功能,例如互斥鎖、條件變數等。你可以在 此處找到它們的列表。
注意: Emscripten Pthreads 支援頁面 顯示瞭如何從 Emscripten 利用此新功能。
總結
這完成了我們對 WebAssembly 文字格式主要元件及其如何在 WebAssembly JS API 中反映的高階概述。
另請參閱
- 未包含的主要內容是所有可能出現在函式體中的指令的完整列表。請參閱 WebAssembly 語義,瞭解每條指令的處理方式。
- 另請參閱規範直譯器實現的 文字格式的語法。