理解 WebAssembly 文字格式
為了使 WebAssembly 能夠被人類閱讀和編輯,Wasm 二進位制格式有一種文字表示。這是一種中間形式,旨在顯示在文字編輯器、瀏覽器開發者工具和其他類似環境中。本文解釋了文字格式的原始語法如何工作,以及它與它所代表的底層位元組碼以及在 JavaScript 中表示 Wasm 的包裝物件之間的關係。
注意: 如果您是一名 Web 開發者,想要將 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,但聲明瞭顯式型別。
- 函式體只是低階指令的線性列表。
這與其它語言中的函式相似,儘管它看起來有些不同。
簽名和引數
簽名是引數型別宣告的序列,後跟返回型別宣告的列表。值得注意的是
- 缺少
(result)意味著函式不返回任何內容。 - 在當前迭代中,最多可以有 1 個返回型別,但稍後會放寬到任意數量。
每個引數都顯式聲明瞭型別;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,另一個返回呼叫第一個函式加一的結果
(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 具有兩級名稱空間,因此這裡的 import 語句從 console 模組匯入 log 函式。您還可以看到匯出的 logIt 函式使用我們上面介紹的 call 指令呼叫匯入的函式。
匯入的函式就像普通函式一樣:它們有一個 WebAssembly 靜態檢查的簽名,並且它們被賦予一個索引,可以命名和呼叫。
JavaScript 函式沒有簽名的概念,因此可以傳遞任何 JavaScript 函式,無論匯入宣告的簽名如何。一旦模組聲明瞭匯入,WebAssembly.instantiate() 的呼叫者必須傳入具有相應屬性的匯入物件。
上述匯入需要一個物件(我們稱之為 importObject),使得 importObject.console.log 是一個 JavaScript 函式。
這在 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 getter,它返回一個指向整個線性記憶體的 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(),呼叫匯入的 $log 函式,並傳入字串在記憶體中的偏移量和長度 (0 和 2)。此函式從模組中匯出,以便可以從 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() 以 console.log 屬性傳遞給 importObject,並由 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,並將其匯出。
注意: 如果您正在使用 wabt (例如 wat2wasm) 將文字格式轉換為 Wasm,您可能需要傳遞 --enable-multi-memory,因為多記憶體支援仍然是可選的。
(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 - overruns the length of the data for illustration
call $logMemory
;; Log memory index 2, offset 0
i32.const 2 ;; memory index 2
i32.const 0 ;; memory offset 0
i32.const 13 ;; 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 內容可以任意觀察和損壞原始函式地址,這是 Web 上不允許的。
解決方案是將函式引用儲存在表中,並傳遞表索引,這些索引只是 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 module() {
// table section
const tbl = new WebAssembly.Table({ initial: 2, element: "anyfunc" });
// function sections:
const f1 = () => 42; /* some imported WebAssembly function */
const f2 = () => 13; /* 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))
轉換為 WebAssembly 二進位制 (Wasm) 後,我們透過以下程式碼在 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 的多值 WebAssembly!。
WebAssembly 執行緒
WebAssembly 執行緒允許 WebAssembly 記憶體物件在單獨的 Web Workers 中執行的多個 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 語義。
- 另請參閱 文字格式的語法,該語法由規範直譯器實現。