1. 程式人生 > >WebAssembly 系列(四):WebAssembly 工作原理

WebAssembly 系列(四):WebAssembly 工作原理

WebAssembly 是除了 JavaScript 以外,另一種可以在網頁中執行的程式語言。過去如果你想在瀏覽器中執行程式碼來對網頁中各種元素進行控制,只有 JavaScript 這一種選擇。

所以當人們談論 WebAssembly 的時候,往往會拿 JavaScript 來進行比較。但是它們其實並不是“二選一”的關係——並不是只能用 WebAssembly 或者 JavaScript。

實際上,我們鼓勵開發者將這兩種語言一起使用,即使你不親自實現 WebAssembly 模組,你也可以學習它現有的模組,並它的優勢來實現你的功能。

WebAssembly 模組定義的一些功能可以通過 JavaScript 來呼叫。所以就像你通過 npm 下載 lodash 模組並通過 API 使用它一樣,未來你也可以下載 WebAssembly 模組並且使用其提供的功能。

那麼就讓我們來看一下如何開發 WebAssembly 模組,以及如何通過 JavaScript 使用他們。

WebAssembly 處於哪個環節?

在上一篇關於彙編的文章中,我介紹了編譯器是如何從高階語言翻譯到機器碼的。

圖片描述

那麼在上圖中,WebAssembly 在什麼位置呢?實際上,你可以把它看成另一種“目標組合語言”。

每一種目標組合語言(x86、ARM)都依賴於特定的機器結構。當你想要把你的程式碼放到使用者的機器上執行的時候,你並不知道目標機器結構是什麼樣的。

而 WebAssembly 與其他的組合語言不一樣,它不依賴於具體的物理機器。可以抽象地理解成它是概念機器的機器語言,而不是實際的物理機器的機器語言

正因為如此,WebAssembly 指令有時也被稱為虛擬指令。它比 JavaScript 程式碼更直接地對映到機器碼,它也代表了“如何能在通用的硬體上更有效地執行程式碼”的一種理念。所以它並不直接對映成特定硬體的機器碼。

圖片描述

瀏覽器把 WebAssembly 下載下來後,可以迅速地將其轉換成機器彙編程式碼。

編譯到 .wasm 檔案

目前對於 WebAssembly 支援情況最好的編譯器工具鏈是 LLVM。有很多不同的前端和後端外掛可以用在 LLVM 上。

提示:很多 WebAssembly 開發者用 C 語言或者 Rust 開發,再編譯成 WebAssembly。其實還有其他的方式來開發 WebAssembly 模組。例如

利用 TypeScript 開發 WebAssembly 模組,或者直接用文字格式的 WebAssembly 也可以。

假設想從 C 語言到 WebAssembly,我們就需要 clang 前端來把 C 程式碼變成 LLVM 中間程式碼。當變換成了 LLVM IR 時,說明 LLVM 已經理解了程式碼,它會對程式碼自動地做一些優化。

為了從 LLVM IR 生成 WebAssembly,還需要後端編譯器。在 LLVM 的工程中有正在開發中的後端,而且應該很快就開發完成了,現在這個時間節點,暫時還看不到它是如何起作用的。

還有一個易用的工具,叫做 Emscripten。它通過自己的後端先把程式碼轉換成自己的中間程式碼(叫做 asm.js),然後再轉化成 WebAssembly。實際上它背後也是使用的 LLVM。

圖片描述

Emscripten 還包含了許多額外的工具和庫來包容整個 C/C++ 程式碼庫,所以它更像是一個軟體開發者工具包(SDK)而不是編譯器。例如系統開發者需要檔案系統以對檔案進行讀寫,Emscripten 就有一個 IndexedDB 來模擬檔案系統。

不考慮太多的這些工具鏈,只要知道最終生成了 .wasm 檔案就可以了。後面我會介紹 .wasm 檔案的結構,在這之前先一起了解一下在 JS 中如何使用它。

載入一個 .wasm 模組到 JavaScript

.wasm 檔案是 WebAssembly 模組,它可以載入到 JavaScript 中使用,現階段載入的過程稍微有點複雜。

function fetchAndInstantiate(url, importObject) { 

  return fetch(url).then(response => 

    response.arrayBuffer() 

  ).then(bytes => 

    WebAssembly.instantiate(bytes, importObject) 

  ).then(results => 

    results.instance 

  ); 

}
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

如果想深入瞭解,可以在 MDN 文件中瞭解更多。

我們一直在致力於把這一過程變得簡單,對工具鏈進行優化。希望能夠把它整合到現有的模組打包工具中,比如 webpack 中,或者整合到載入器中,比如 SystemJS 中。我們相信載入 WebAssembly 模組也可以像載入 JavaScript 一樣簡單。

這裡介紹 WebAssembly 模組和 JavaScript 模組的主要區別。當前的 WebAssembly 只能使用數字(整型或者浮點型)作為引數或者返回值。

圖片描述

對於任何其他的複雜型別,比如 string,就必須得用 WebAssembly 模組的記憶體操作了。如果是經常使用 JavaScript,對直接操作記憶體不是很熟悉的話,可以回想一下 C、C++ 和 Rust 這些語言,它們都是手動操作記憶體。WebAssembly 的記憶體操作和這些語言的記憶體操作很像。

為了實現這個功能,它使用了 JavaScript 中稱為 ArrayBuffer 的資料結構。ArrayBuffer 是一個位元組陣列,所以它的索引(index)就相當於記憶體地址了。

如果你想在 JavaScript 和 WebAssembly 之間傳遞字串,可以利用 ArrayBuffer 將其寫入記憶體中,這時候 ArrayBuffer 的索引就是整型了,可以把它傳遞給 WebAssembly 函式。此時,第一個字元的索引就可以當做指標來使用。

圖片描述

這就好像一個 web 開發者在開發 WebAssembly 模組時,把這個模組包裝了一層外衣。這樣其他使用者在使用這個模組的時候,就不用關心記憶體管理的細節。

如果你想了解更多的記憶體管理,看一下我們寫的 WebAssembly 的記憶體操作

.wasm 檔案結構

如果你是寫高階語言的開發者,並且通過編譯器編譯成 WebAssembly,那你不用關心 WebAssembly 模組的結構。但是瞭解它的結構有助於你理解一些基本問題。

如果你對編譯器還不瞭解,建議先讀一下WebAssembly 系列(三)編譯器如何生成彙編這篇文章。

這段程式碼是即將生成 WebAssembly 的 C 程式碼:

int add42(int num) { 

    return num + 42; 

}
  
  • 1
  • 2
  • 3

你可以使用 WASM Explorer 來編譯這個函式。

開啟 .wasm 檔案(假設你的編輯器支援的話),可以看到下面程式碼:

00 61 73 6D 0D 00 00 00 01 86 80 80 80 00 01 60 

01 7F 01 7F 03 82 80 80 80 00 01 00 04 84 80 80 

80 00 01 70 00 00 05 83 80 80 80 00 01 00 01 06 

81 80 80 80 00 00 07 96 80 80 80 00 02 06 6D 65 

6D 6F 72 79 02 00 09 5F 5A 35 61 64 64 34 32 69 

00 00 0A 8D 80 80 80 00 01 87 80 80 80 00 00 20 

00 41 2A 6A 0B
  
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

這是模組的“二進位制”表示。之所以用引號把“二進位制”引起來,是因為上面其實是用十六進位制表示的,不過把它變成二進位制或者人們能看懂的十進位制表示也很容易。

例如,下面是 num + 42 的各種表示方法。

圖片描述

程式碼是如何工作的:基於棧的虛擬機器

如果你對具體的操作過程很好奇,那麼這幅圖可以告訴你指令都做了什麼。

圖片描述

從圖中我們可以注意到 加 操作並沒有指定哪兩個數字進行加。這是因為 WebAssembly 是採用“基於棧的虛擬機器”的機制。即一個操作符所需要的所有值,在操作進行之前都已經存放在堆疊中。

所有的操作符,比如加法,都知道自己需要多少個值。加需要兩個值,所以它從堆疊頂部取兩個值就可以了。那麼加指令就可以變的更短(單位元組),因為指令不需要指定源暫存器和目的暫存器。這也使得 .wasm 檔案變得更小,進而使得載入 .wasm 檔案更快。

儘管 WebAssembly 使用基於棧的虛擬機器,但是並不是說在實際的物理機器上它就是這麼生效的。當瀏覽器翻譯 WebAssembly 到機器碼時,瀏覽器會使用暫存器,而 WebAssembly 程式碼並不指定用哪些暫存器,這樣做的好處是給瀏覽器最大的自由度,讓其自己來進行暫存器的最佳分配。

WebAssembly 模組的組成部分

除了上面介紹的,.wasm 檔案還有其他部分。一些組成部分對於模組來講是必須的,一些是可選的。

必須部分:

  • Type。在模組中定義的函式的函式宣告和所有引入函式的函式宣告。
  • Function。給出模組中每個函式一個索引。
  • Code。模組中每個函式的實際函式體。

可選部分:

  1. Export。使函式、記憶體、表(tables)、全域性變數等對其他 WebAssembly 或 JavaScript 可見,允許動態連結一些分開編譯的元件,即 .dll 的WebAssembly 版本。
  2. Import。允許從其他 WebAssembly 或者 JavaScript 中匯入指定的函式、記憶體、表或者全域性變數。
  3. Start。當 WebAssembly 模組載入進來的時候,可以自動執行的函式(類似於 main 函式)。
  4. Global。宣告模組的全域性變數。
  5. Memory。定義模組用到的記憶體。
  6. Table。使得可以對映到 WebAssembly 模組以外的值,如對映到 JavaScript 的物件。這在間接函式呼叫時很有用。
  7. Data。初始化匯入的或者區域性記憶體。
  8. Element。初始化匯入的或者區域性的表。

如果你想了解關於這些組成部分的更深入的內容,可以閱讀這些組成部分的工作原理