教你手寫WASM

教你手寫WASM

來自專欄 JS、chromium、electron

原始文章信息

  • 原標題:Writing WebAssembly By Hand
  • 原作者: Colin Eberhardt twitter.com/ColinEberha
  • 原鏈接: blog.scottlogic.com/201

WebAssembly是一種全新的Web編程語言,但是與JavaScript不同,它不是一種讓你直接手動編寫的語言,而是C / C ++,Rust,C#和TypeScript等不斷增加的上層語言的編譯目標。但是,實際上可以直接手動編寫WebAssembly。事實證明這是一個很有學習型而且愉快的過程,我將在這篇博文中探討。

WebAssembly是一種非常低級的語言,因此用它創建複雜的應用程序既困難又耗時。那麼為什麼有人想要這樣做呢?

在創建自己的項目webassemblyjs(WebAssembly的一個工具鏈)的過程中,我開始了解WebAssembly語言和運行時(runtime)的底層細節。該項目仍處於早期階段,但我們希望它將成為未來JavaScript / WebAssembly混合開發工具的有用一部分。

我個人認為這種底層細節的探索很有趣。此外,手動編寫WebAssembly實際上非常簡單,我當然鼓勵你試一試!

這篇文章描述了實現Conway』s Game of Life的過程,Conway』s Game of Life是一個經典之作。您可能還想看看這個非常棒的使用Rust實現(更為實用)的教程。

如果你想看到代碼的運行情況,它可以通過WebAssembly Studio - 只需點擊Build然後Run即可。

編譯WebAssembly

WebAssembly規範描述了兩種格式,第一種是.wasm文件的緊湊二進位格式,這是WebAssembly代碼的常規輸出格式。第二種是文本格式(WAT-WebAssembly文本格式),它與二進位格式非常相似,但設計為人類可讀。

以下是一個非常簡單的"hello world" WebAssembly模塊(WAT格式):

(module (func (result i32) (i32.const 42) ) (export "helloWorld" (func 0)))

上面代碼導出的單個函數並允許從JavaScript調用它,返回一個常量值42。

另外,我首選的編輯器是VSCode,它具有wat 文件的語法高亮,這在手寫WebAssembly時非常有用。

WAT文件需要先編譯成二進位格式,然後才能在瀏覽器或Node中運行。 WebAssembly二進位工具包具有支持底層WebAssembly開發的各種工具,包括wat2wasm命令行工具。但是,這個工具是用C ++編寫的,作為一個JavaScript開發人員,我對這種對原生代碼的依賴有點敏感!

幸運的是,WebAssembly旨在消除這種情況,你會在npm上找到一個wabt的WebAssembly構建工具。

wabt添加到項目中,你可以將wat文件編譯為wasm,如下所示:

const { readFileSync, writeFileSync } = require("fs");const wabt = require("wabt");const path = require("path");const inputWat = "main.wat";const outputWasm = "main.wasm";const wasmModule = wabt.parseWat(inputWat, readFileSync(inputWat, "utf8"));const { buffer } = wasmModule.toBinary({});writeFileSync(outputWasm, new Buffer(buffer));

編譯完成後,通過載入,編譯和實例化此WebAssembly模塊來完成"hello world"示例:

const { readFileSync } = require("fs");const run = async () => { const buffer = readFileSync("./main.wasm"); const module = await WebAssembly.compile(buffer); const instance = await WebAssembly.instantiate(module); console.log(instance.exports.helloWorld());};run();

運行上述代碼,預期獲得如下結果:

$ node index.js42

我不打算詳細介紹上面的代碼中WebAssembly介面的任何細節。如果您想了解有關JavaScript API以及載入/編譯/實例化生命周期的更多信息,我強烈推薦MDN文檔。這篇博文是關於WAT的!

測試代碼開發

編寫WebAssembly可能有點容易出錯,這就是為什麼我發現擁有一個提供快速反饋的JavaScript測試環境常有用。我決定使用Jest作為我的測試Runner,主要是因為我聽說過Jest是個好東西並且之前沒有使用它 - 這似乎是一個很好的實驗借口!

理想情況下,每當我更改項目中的任何JavaScript或WebAssembly時,我都希望運行測試。我還想在運行測試之前編譯wat到wasm。

我將Jest添加到我的項目中,並將其配置為包含js和wat文件擴展名:

// jest.config.jsmodule.exports = { moduleFileExtensions: ["js", "wat"]};

更新npm script,讓Jest運行在Watch Mode:

"scripts": { "test": "jest *.wat *.js --watch"}

通過調整上面基於wabt的構建腳本,可以重用代碼,允許Jest在測試用例執行之前將wat編譯為wasm。這是完整的測試套件,用於驗證導出的helloWorld函數的預期行為:

// 原文代碼有誤,譯者有修正const { readFileSync } = require("fs");const build = require("../build");const instantiate = async () => { const buffer = readFileSync("./main.wasm"); const module = await WebAssembly.compile(buffer); const instance = await WebAssembly.instantiate(module); return instance.exports;};let wasmbeforeAll(() => { build();});beforeEach(async done => { wasm = await instantiate(); done();});test("hello world returns 42", async done => { expect(wasm.helloWorld()).toBe(42); done();});

有了這個,我可以快速迭代,一旦我保存對測試文件或我的WebAssembly代碼的更改,我的測試就會運行。

遊戲狀態

The Game of life是在 n × n 的平面方塊上運行。在大多數語言中,您希望將遊戲狀態存儲在二維數組中。不幸的是,WebAssembly沒有二維數組 - 事實上,它根本沒有數組!您可以使用的唯一類型是四種數字類型(兩個浮點,兩個整數)。為了處理其他更複雜的類型,WebAssembly為您提供線性內存空間(linear memory)。

為簡單起見,我們將使用i32類型將每個單元的狀態存儲為整數值。使用線性內存空間,給定單元的存儲地址的偏移量(假設為50 x 50平面)為(x + y * 50) * 4

編寫一個從屏幕坐標轉換為內存偏移的函數是很有意義的。這是一個最小的測試用例:

test("offsetFromCoordinate", () => { expect(wasm.offsetFromCoordinate(0, 0)).toBe(0); expect(wasm.offsetFromCoordinate(49, 0)).toBe(49 * 4); expect(wasm.offsetFromCoordinate(10, 2)).toBe((10 + 2 * 50) * 4);});

下面是函數實現與導出:

(func $offsetFromCoordinate (param $x i32) (param $y i32) (result i32) get_local $y i32.const 50 i32.mul get_local $x i32.add i32.const 4 i32.mul)(export "offsetFromCoordinate" (func $offsetFromCoordinate))

WebAssembly函數與其他語言的函數非常相似,它們具有聲明無,一個或多個類型化參數和可選返回值的簽名。上述函數採用兩個i32入數(坐標)並返回單個i32結果(存儲偏移量)。函數體包含許多指令(WebAssembly有大約50條不同的指令),這些指令是按順序執行的。

WebAssembly指令在堆棧上運行,考慮到上述函數中的每一步,它解釋如下:

  1. get_local $y - 將 $y 參數的值推入堆棧。
  2. i32.const 50 - 推入常數值50
  3. i32.mul - 從堆棧中彈出兩個值,將它們相乘,然後將結果推入堆棧
  4. get_local $x - 將 $x 參數的值推入堆棧。
  5. etc

當函數執行完成時,堆棧上只剩下一個值,它將成為函數的返回值。

載入並編譯WebAssembly模塊時,將驗證其內容。其中之一是確保代碼中任何點的堆棧深度與後續指令或當前函數返回的值兼容。您可以閱讀Mauro Bringolf在本文中有關驗證和類型檢查的更多信息。驗證是語言和運行時的許多安全/安全功能之一。

這種基於堆棧的邏輯可能有點難以閱讀,特定操作的輸入值可能取決於功能邏輯中非常"遠"的指令。幸運的是,WAT格式還允許您以更易讀,嵌套的形式表達您的應用程序。

例如簡單的乘法:

get_local $yi32.const 50i32.mul

等價於

(i32.mul (get_local $y) (i32.const 50))

這種寫法看起來像一個更傳統的函數寫法。將此應用於之前寫的代碼:

(func $offsetFromCoordinate (param $x i32) (param $y i32) (result i32) (i32.mul (i32.add (i32.mul (i32.const 50) (get_local $y) ) (get_local $x) ) (i32.const 4) ))

在這種情況下,我認為代碼更具可讀性。但是,值得注意的是,這種嵌套都是"語法糖",二進位文件格式中的指令順序是順序的。此外,如果您在瀏覽器DevTools中檢查wasm函數,您將看到順序執行的代碼版本。

有了這個實用功能,現在可以編寫在給定位置獲取/設置單元狀態的功能。

先寫一個簡單的測試用例:

test("get / set cell", () => { // check that linear memory initialises to zero expect(wasm.getCell(2, 2)).toBe(0); // set and expect wasm.setCell(2, 2, 1); expect(wasm.getCell(2, 2)).toBe(1);});

然後是實現代碼:

(memory 1)(func $setCell (param $x i32) (param $y i32) (param $value i32) (i32.store (call $offsetFromCoordinate (get_local $x) (get_local $y) ) (get_local $value) ))(func $getCell (param $x i32) (param $y i32) (result i32) (i32.load (call $offsetFromCoordinate (get_local $x) (get_local $y) ) ))

store指令將堆棧值存儲在存儲器中的給定位置,而載入則相反。每個模塊可以具有一個線性內存空間實例,其可以是導入的(即由主機環境提供),也可以在模塊本身內定義(並且可選地導出)。在上文中,模塊存儲器在模塊(memory 1)內定義,其中"1"表示該模塊需要至少1頁(64KiB)。

直接訪問內存

使用上面的代碼,可以讀/寫單元格值,這意味著當前遊戲已經可以用圖形表示狀態。但是,跨越JavaScript / WebAssembly邊界會有一定開銷。使用目前介面需要2,500次調用getCell函數。更有效的方法是允許JavaScript直接從線性內存空間中讀取遊戲狀態。

導出模塊內存空間很簡單:

(memory 1)(export "memory" (memory 0))

上面的代碼定義了模塊內存,然後通過索引引用內存來導出它(將來可能支持多個內存空間),並定義了部名稱memory。也可以不通過索引而是通過名稱來引用:

(memory $mem 1)(export "memory" (memory $mem))

可以使用相同的方法來導出函數(和全局變數)。這些名稱不包含在二進位格式(.wasm)中,它們僅僅是為了方便,並且增強了WAT格式的可讀性。

最後也可以通過簡寫來實現內聯定義導出,避免需要引用它們所涉及的函數或內存:

(memory (export "memory") 1)

同樣,這是語法糖,在二進位格式(.wasm)中有一個單獨一塊區塊是用於描述導出,上面的所有三個例子都是等價的。

通過此導出,模塊內存可用作 WebAssembly.Memory 實例(ArrayBuffer類型)。可以通過類型化數組(typed array)訪問此內存的內容,例如使用Uint32Array對應於WebAssembly模塊存儲的32位i32值:

test("read memory directly", () => { const memory = new Uint32Array(wasm.memory.buffer, 0, 50 * 50); wasm.setCell(2, 2, 10); expect(memory[2 + 2 * 50]).toBe(10); expect(memory[3 + 2 * 50]).toBe(0);});

邊界值

The Game of Life是在有限大小的空間中進行的。這可以通過假設50 x 50空間之外的任何單元格都是死亡來實現。測試單元格是否在空間中是一個簡單的範圍測試,0 <= x <50。實現和使用此檢查需要控制流邏輯。

和其他堆棧語言使用簡單的跳轉相比,WebAssembly控制項流相對較豐富與。它具有在高級語言中很常見的結構化的控制流結構,包括循環和if / then / else。WebAssembly目前沒有異常處理,但有一個活躍的提案正在研究這個問題。

這是一個簡單的例子,判斷本地 $x是否小於$y,if操作將根據堆棧頂部的值執行then或else塊中的指令:

(i32.lt_s (get_local $x) (get_local $y))(if (then ... ) (else ... ))

與其他WebAssembly操作一樣,if塊之前的指令可以嵌套以提高可讀性:

(if (i32.lt_s (get_local $x) (get_local $y) ) (then ... ) (else ... ))

if塊也可以返回一個結果:

(if (result i32) (i32.lt_s (get_local $x) (get_local $y) ) (then (i32.const 10) ) (else (i32.const 20) ))

考慮到這一點,可以更新getCell函數, 判斷給定坐標是否在我們的空間範圍內,否則返回零:

(func $inRange (param $low i32) (param $high i32) (param $value i32) (result i32) (i32.and (i32.ge_s (get_local $value) (get_local $low)) (i32.lt_s (get_local $value) (get_local $high)) ) )(func $getCell (param $x i32) (param $y i32) (result i32) (if (result i32) (block (result i32) ;; 確保 x 和 y 都在範圍中 (i32.and (call $inRange (i32.const 0) (i32.const 50) (get_local $x) ) (call $inRange (i32.const 0) (i32.const 50) (get_local $y) ) ) ) (then (i32.load (call $offsetFromCoordinate (get_local $x) (get_local $y)) ) ) (else (i32.const 0) ) ))

有了上面內容,構造一個計算給定單元格的存活鄰居的函數就非常簡單了。

遊戲規則

The Game of Life 基於以下簡單規則:

  1. 任何有少於兩個活鄰居的單元格會死亡,就好像是由人口不足而死。
  2. 任何有兩個或者三個活鄰居的活單元格都會保持存活。
  3. 任何有三個以上活鄰居的活單元格就會死亡,就好像是因為人口過剩。
  4. 具有正好三個活鄰居的任何死單元格變成活單元格,就好像通過繁殖一樣。 (譯者補充規則:1.每回合計算一次。 2. 鄰居包括對角線,和掃雷類似,地圖非邊緣的單元格都有8個鄰居。)

這些規則可以使用上面介紹的嵌套if控制流程指令來實現 - 但這裡有更加具有創意的寫法!

WebAssembly允許您創建一個帶有函數引用的表,該表可以通過索引來實現動態調用。

(table 16 anyfunc)(elem (i32.const 0) ;; 針對當前死亡的單元格, 3個活鄰居則alive $dead $dead $dead $alive $dead $dead $dead $dead $dead ;; 針對當前存活的單元格,2-3個活鄰居則alive $dead $dead $alive $alive $dead $dead $dead $dead $dead)(func $alive (result i32) i32.const 1)(func $dead (result i32) i32.const 0)

(譯者註:針對死亡/存活細胞,根據鄰居存活數量,用查表法,來判斷下一回合單元格狀態)

在上面的代碼中,table指令聲明了一個包含16個元素的表,這些元素是anyfunc類型,它是對任何簽名的函數的引用。 elem指令定義表本身,而且偏移量為(i32.const 0),它給出了表的起始索引,後面跟16個函數引用。

以下代碼顯示了此表在實際中的使用方式:

(call_indirect (result i32) (i32.add (i32.mul (i32.const 8) (call $isCellAlive (get_local $x) (get_local $y) ) ) (call $liveNeighbourCount (get_local $x) (get_local $y) ) ))

`call_indirect的索引由一個單元具有的活鄰居數量加上8,則返回結果基於表中的 $alive / $dead 函數。這可能不是實現這種邏輯的最有效方法,但卻是說明WebAssembly的另一個特性的一個很好的方法。

上面的代碼可以包含在函數$evolveCellToNextGeneration中,我們差不多已經實現了,最後一步是應用此邏輯迭代我們空間中的所有單元格。

WebAssembly有一個循環指令,可用於迭代一組指令。這與終端指令br和條件終端br_if指令一起使用,兩者都可以用於終止給定的控制流堆棧。

這是完整的代碼:

(func $evolveAllCells (local $x i32) (local $y i32) (set_local $y (i32.const 0)) (block (loop (set_local $x (i32.const 0)) (block (loop (call $evolveCellToNextGeneration (get_local $x) (get_local $y) ) (set_local $x (call $increment (get_local $x))) (br_if 1 (i32.eq (get_local $x) (i32.const 50))) (br 0) ) ) (set_local $y (call $increment (get_local $y))) (br_if 1 (i32.eq (get_local $y) (i32.const 50))) (br 0) ) ))

正如你所看到的,循環指令本身不會導致迭代開始,而是br 0指令(中斷到控制流堆棧深度為零),導致循環重複,使用break-if指令使其終止。

完成全部實現需要一些額外的細節,但是,如果您對缺失代碼感興趣,請查看完整的源代碼。

遊戲窗口

WebAssembly 模塊實現了 The Game of Life 的核心邏輯,但是,為了查看結果,需要某種形式的UI界面。 WebAssembly沒有任何 DOM 或任何其他形式的IO API,這是您需要在宿主環境中處理的內容。

我決定使用axel控制台圖形庫,而不是擺弄HTML,給輸出帶來漂亮的復古感。

(此處應有圖,知乎MD不支持導入本地圖就算了)

總結

WebAssembly是一種有趣且有不尋常的感覺的語言,雖然你可能永遠不會直接編寫它,但我相信您會發現你的開發工具會在不久的將來使用或編譯它。

如果你想看到這個項目的完整源代碼,你可以在GitHub上找到它,而懶得下的,它可以通過WebAssembly Studio獲得(項目地址),只需點擊"Build"然後"Run"即可。

撒花!

推薦閱讀:

官方:我們對Rust和WebAssembly的願景
從 Haskell 到 WebAssembly(1)
2018 年,WebAssembly(技術周刊 2018-04-20)

TAG:WebAssembly | 前端開發 |