WebAssembly 實踐:如何寫代碼

leon.wei 7年前發布 | 81K 次閱讀 JavaScript開發 WebAssembly JavaScript

本文不討論 WebAssembly 的發展,只是一步一步地教你怎么寫 WebAssembly 的各種 demo。文中給出的例子我都放在 GitHub 中了,包含了編譯腳本和編譯好的可執行文件,只需再有一個支持 WebAssembly 的瀏覽器就可以直接運行。

配置開發調試環境

安裝編譯工具

參考官方 Developer’s GuideAdvanced Tools ,需要安裝的工具有:

安裝過程挺繁瑣的,得本地 clone 代碼再編譯。

安裝瀏覽器

作為一個新技術,之所以說 WebAssembly 前途明媚,不僅是因為 W3C 成立了專門的 Webassembly Community Group ,被標準認可;也是因為這次各大主流瀏覽器廠商(難得的)達成了一致,共同參與規范的討論,在自家的瀏覽器里都實現了。

體驗新技術,建議使用激進版瀏覽器,最新版本中都已經支持了 WebAssembly。

除了上邊幾個激進的瀏覽器,在主流版本里開啟 flag 也是可以使用 WebAssembly 的:

  • Chrome: 打開 chrome://flags/#enable-webassembly ,選擇 enable 。

  • Firefox: 打開 about:config 將 javascript.options.wasm 設置為 true 。

快速體驗 WebAssembly

想快速體驗 WebAssembly ?最簡單的辦法就是找個支持 WebAssembly 的瀏覽器,打開控制臺,把下列代碼粘貼進去。

WebAssembly.compile(new Uint8Array(`
  00 61 73 6d  0d 00 00 00  01 0c 02 60  02 7f 7f 01
  7f 60 01 7f  01 7f 03 03  02 00 01 07  10 02 03 61
  64 64 00 00  06 73 71 75  61 72 65 00  01 0a 13 02
  08 00 20 00  20 01 6a 0f  0b 08 00 20  00 20 00 6c
  0f 0b`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)).then(module => {
  const instance = new WebAssembly.Instance(module)
  const { add, square } = instance.exports

  console.log('2 + 4 =', add(2, 4))
  console.log('3^2 =', square(3))
  console.log('(2 + 5)^2 =', square(add(2 + 5)))
})

里邊這一坨奇怪的數字,就是 WebAssembly 的二進制源碼。

運行結果

如果報錯,說明你的瀏覽器不支持 WebAssembly ;如果沒報錯,代碼的運行結果如下(還會返回一個 Promise):

2 + 4 = 6
3^2 = 9
(2 + 5)^2 = 49

其中 add 和 square 雖然做的事情很簡單,就是計算加法和平方,但那畢竟是由 WebAssembly 編譯出來的接口,是硬生生地用二進制寫出來的!

解釋代碼

上邊的二進制源碼一行 16 個數,有 4 行零兩個,一共有 66 個數;每個數都是 8 位無符號十六進制整數,一共占 66 Byte。

WebAssembly 提供了 JS API ,其中 WebAssembly.compile 可以用來編譯 wasm 的二進制源碼,它接受 BufferSource 格式的參數,返回一個 Promise。

那些代碼里的前幾行,目的就是把字符串轉成 ArrayBuffer。先將字符串分割成普通數組,然后將普通數組轉成 8 位無符號整數的數組;里的數字是十六進制的,所有用了 parseInt(str, 16) 。

如果瀏覽器支持了通過 <script type="module"> 的方式引入 wasm 文件,這些步驟都是多余的。

new Uint8Array(
  `...`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)

然后,如果 WebAssembly.compile 返回的 Promise fulfilled 了, resolve 方法的第一個參數就是 WebAssembly 的模塊對象,是 WebAssembly.Module 的實例。

然后使用 WebAssembly.Instance 將模塊對象轉成 WebAssembly 實例(第二個參數可以用來導入變量)。

通過 instance.exports 可以拿到 wasm 代碼輸出的接口,剩下的代碼就和和普通 javascript 一樣了。

注意數據類型

WebAssembly 是有明確的數據類型的,我那個例子里用的都是 32 位整型數(是不是看不出來…… 二進制里那些 7f 表示 i32 指令,意思就是32位整數),所以用 WebAssembly 編譯出來的時候要注意數據類型。

如果你亂傳數據,WebAssembly 程序也不會報錯,因為在執行時會被動態轉換( dynamic_cast ),它支持傳遞 模糊類型的數據引用 。但是你如果給函數傳了個字符串或者超大的數,具體會被轉成什么就說不清了,通常是轉成 0。

console.log(square('Tom')) // 0
console.log(add(2e+66, 3e+66)) // 0
console.log(2e+66 + 3e+66) // 5e+66

把 C/C++ 編譯成 WebAssembly

二進制代碼簡直不是人寫的:joy:,還有其他方式能寫 WebAssembly 嗎?

有,那就是把其他語言編譯成 WebAssembly 的二進制。想實現這個效果,不得不用到各種編譯工具了。其中一個比較關鍵的工具是 Emscripten ,它基于 LLVM ,可以將 C/C++ 編譯成 asm.js,使用 WASM 標志也可以直接生成 WebAssembly 二進制文件(后綴是 .wasm )。

Emscripten
source.c   ----->  target.js

     Emscripten (with flag)
source.c   ----->  target.wasm

工具如何安裝就不講了,在此只提醒一點: emcc 在 1.37 以上版本才支持直接生成 wasm 文件。

編寫 C 代碼

首先新建一個 C 語言文件,假設叫 math.c 吧,在里邊實現 add 和 square 方法:

// math.c

int add (int x, int y) {
  return x + y;
}

int square (int x) {
  return x * x;
}

然后執行 emcc math.c -Os -s WASM=1 -s SIDE_MODULE=1 -o math.wasm 就可以生成 wasm 文件了。

代碼解釋

C 語言代碼一目了然,就是寫了兩個函數,由于 C 語言里的函數都是全局的,這兩個函數默認都會被模塊導出。

不知道你有沒有注意到,這個文件里沒寫 main 函數!沒寫入口函數,它自身什么也執行不了,但是可以把它當成一個庫文件使用,所以我在也是用模塊的方式編譯生成的 wasm 文件。

在 WebAssembly 官方給出的例子 中,是寫了 main 函數,而且是直接把 C 文件編譯生成了 html + js + wasm 文件,實際上是生成了一個可以運行 demo,簡單粗暴。生成的代碼體積比較大,很難看懂里邊具體做了什么。為了代碼簡潔,我這里只是生成 wasm 模塊,沒有其他多余文件,要想把它運行起來還需要自己寫 html 和 js 讀取并執行 wasm 文件。

如果你也想直接生成可用的 demo,你可以再寫個 main 函數,然后執行 emcc math.c -s WASM=1 -o math.html 就可以了。

如何運行 WebAssembly 二進制文件?

現在有了 wasm 文件,也有了支持 WebAssembly 的瀏覽器,怎么把它運行起來呢?

目前只有一種方式能調用 wasm 里的提供接口,那就是: 用 javascript !

WebAssembly 目前只設計也只實現了 javascript API,就像我剛開始提供的那個例子一樣,只有通過 js 代碼來編譯、實例化才可以調用其中的接口。這也很好的說明了 WebAssembly 并不是要替代 javascript ,而是用來增強 javascript 和 Web 平臺的能力的。我覺得 WebAssembly 更適合用于寫模塊,承接各種復雜的計算,如圖像處理、3D運算、語音識別、視音頻編碼解碼這種工作,主體程序還是要用 javascript 來寫的。

編寫加載函數 (loader)

在最開始的例子里,已經很簡化的將執行 WebAssembly 的步驟寫出來了,其實就是 【加載文件】->【轉成 buffer】->【編譯】->【實例化】。

function loadWebAssembly (path) {
  return fetch(path)                   // 加載文件        
    .then(res => res.arrayBuffer())    // 轉成 ArrayBuffer
    .then(WebAssembly.instantiate)     // 編譯 + 實例化
    .then(mod => mod.instance)         // 提取生成都模塊
}

代碼其實很簡單,使用了 Fetch API 來獲取 wasm 文件,然后將其轉換成 ArrayBuffer,然后使用 WebAssembly.instantiate 這個一步到位的方法來編譯并初始化一個 WebAssembly 的實例。最后一步是從生成的模塊中提取出真正的實例對象。

完成了上邊的操作,就可以直接使用 loadWebAssembly 這個方法加載 wasm 文件了,它相當于是一個 wasm-loader ;返回值是一個 Promise,使用起來和普通的 js 函數沒什么區別。從 instance.exports 中可以找到 wasm 文件輸出的接口。

loadWebAssembly('path/to/math.wasm')
  .then(instance => {
    const { add, square } = instance.exports
    // ...
  })

返回 Promise 不只是因為 fetch 函數,即使像最開始的例子那樣把二進制硬編碼,也必須要用 Promise 。因為 WebAssembly.compile 和 WebAssembly.instantiate 這些接口都是異步的,本身就返回 Promise 。

更完整的加載函數

如果你直接使用上邊那個 loadWebAssembly 函數,有可能會執行失敗,因為在 wasm 文件里,可能還會引入一些環境變量,在實例化的同時還需要初始化內存空間和變量映射表,也就是 WebAssembly.Memory 和 WebAssembly.Table 。

/**
 * @param {String} path wasm 文件路徑
 * @param {Object} imports 傳遞到 wasm 代碼中的變量
 */
function loadWebAssembly (path, imports = {}) {
  return fetch(path)
    .then(response => response.arrayBuffer())
    .then(buffer => WebAssembly.compile(buffer))
    .then(module => {
      imports.env = imports.env || {}

      // 開辟內存空間
      imports.env.memoryBase = imports.env.memoryBase || 0
      if (!imports.env.memory) {
        imports.env.memory = new WebAssembly.Memory({ initial: 256 })
      }

      // 創建變量映射表
      imports.env.tableBase = imports.env.tableBase || 0
      if (!imports.env.table) {
        // 在 MVP 版本中 element 只能是 "anyfunc"
        imports.env.table = new WebAssembly.Table({ initial: 0, element: 'anyfunc' })
      }

      // 創建 WebAssembly 實例
      return new WebAssembly.Instance(module, imports)
    })
}

這個 loadWebAssembly 函數還接受第二個參數,表示要傳遞給 wasm 的變量,在初始化 WebAssembly 實例的時候,可以把一些接口傳遞給 wasm 代碼。

調用 wasm 導出的接口

有了 loadWebAssembly 就可以調用 wasm 代碼導出的接口了。

loadWebAssembly('./math.wasm')
  .then(instance => {
    const add = instance.exports._add
    const square = instance.exports._square

    console.log('2 + 4 =', add(2, 4))
    console.log('3^2 =', square(3))
    console.log('(2 + 5)^2 =', square(add(2 + 5)))
  })

比較奇怪的一點是,用 C/C++ 導出的模塊,屬性名上默認都帶了 _ 前綴,asm.js 轉成了 wasm 模塊就不帶。

在瀏覽器中的運行效果

參考剛才用 C 語言寫出來的項目,直接用瀏覽器打開 index.html 即可。能看到這樣的輸出(我使用的是 Chrome Canany 瀏覽器):

如果你打開開發者工具的 Source 面板,能夠看到 wasm 的源代碼,瀏覽器已經將二進制轉換成了對等的 文本指令 )。

雖然是一個 wasm 文件,瀏覽器將它解析成了兩個(也有可能更多),是因為我們輸出了兩個接口,每個文件都對應了一個接口的定義。可以理解為 Canary 瀏覽器為了方便看源碼實現的 sourcemap 功能。

把 asm.js 編譯成 WebAssembly

剛才也介紹了 Emscripten 可以將 C/C++ 編譯成 asm.js ,這是它的默認功能,加上 flag 才能生成 wasm 。

asm.js 是 javascript 的子集,是一種語法(不是一個前端工具庫!),用了很多底層語法來標注數據類型,目的是提高 javascript 的運行效率,本身就是作為 C/C++ 編譯的目標設計的(也不是給人寫的),是一種中間表示層語法 (IR, Intermediate Representation)。asm.js 出生于 WebAssembly 之前, WebAssembly 借鑒了這個思路,做的更徹底一些,直接跳過 javascript ,設計了一套新的平臺指令。

編寫 asm.js 代碼

// math.js

function () {
  "use asm";

  function add (x, y) {
    x = x | 0;
    y = y | 0;
    return x + y | 0;
  }

  function square (x) {
    x = x | 0;
    return x * x | 0;
  }

  return {
    add: add,
    square: square
  };
}

上邊定義了一個函數,并且聲明了 "use asm" ,這樣一來,這個函數就會被視為 asm.js 的模塊,里邊可以添加方法,通過 return 暴露給外部使用。

不過, 只有 asm.js 才能轉成 wasm,普通 javascript 是不行的! 因為 javascript 是弱類型語言,用法也比較靈活,本身就很難編譯成強類型的指令。這類腳本語言本身的設計就是 JIT (Just-in-time) 的,也就是在運行時才編譯代碼。而 wasm 是一個二進制格式,需要提前編譯,比較接近于 AOT (Ahead-of-time) 的概念。

使用 Binaryen 和 WABT

雖然 Emscripten 能生成 asm.js 和 wasm ,但是卻不能把 asm.js 轉成 wasm 。因為它是基于 LLVM 的,然而 asm.js 沒法編譯成 LLVM IR (Intermediate Representation)。想要把 asm.js 編譯成 WebAssembly,就要用到他們官方提供的 Binaryen 和 WABT (WebAssembly Binary Toolkit) 工具了。

原理和編譯方法參考官方文檔,整個過程大概是這樣的:

Binaryen             WABT
math.js   --->   math.wast   --->   math.wasm

用腳本描述大概是這樣:

asm2wasm math.js > math.wast
wast2wasm math.wast -o math.wasm

wast 是什么格式?

WebAssembly 除了定義了二進制格式以外,還定義了一份對等的 文本描述 。官方給出的是線性表示的例子,而 wast 是用 S-表達式( s-expressions ) 描述的另一種文本格式。

上邊的 asm.js 代碼編譯生成的 wast 文件是這樣的:

(module
  (export "add" (func $add))
  (export "square" (func $square))
  (func $add (param $x i32) (param $y i32) (result i32)
    (return
      (i32.add
        (get_local $x)
        (get_local $y)
      )
    )
  )
  (func $square (param $x i32) (result i32)
    (return
      (i32.mul
        (get_local $x)
        (get_local $x)
      )
    )
  )
)

和 lisp 挺像的,反正比二進制宜讀多了:joy:。能看出來最外層聲明了是一個模塊,然后導出了兩個函數,下邊緊接著是兩個函數的定義,包含了參數列表和返回值的類型聲明。如果對這種類似 lisp 的語法比較熟悉,完全可以手寫 wast 嘛,只要裝個 wast2wasm 小工具就可以生成 wasm 了。或者在這個 在線 wast -> wasm 轉換工具 里寫 wast 代碼,可以實時預覽編譯的結果,也可以下載生成的 wasm 文件。

在 WebAssembly 中調用 Web API

在 js 里能調用 wasm 里定義的方法,反過來,wasm 里能不能調用 javascript 寫的方法呢?能不能調用平臺提供的方法(Web API)呢?

當然是可以的。不過在 MVP (Minimum Viable Product) 版本里實現的功能有限。要想在 wasm 里調用 Web API,需要在創建 WebAssembly 實例的時候把 Web API 傳遞過去才可以。具體做法可以參考上邊寫的那個比較復雜的 loader 。

向 wasm 中傳遞 js 變量

在有了 loadWebAssembly 這個方法之后,就可以給 wasm 代碼傳遞 js 變量和函數了。

const imports = {
  Math,
  objects: {
    count: 2333
  },
  methods: {
    output (message) {
      console.log(`-----> ${message} <-----`)
      return message
    }
  }
}

loadWebAssembly('path/to/source.wasm', imports)
  .then(instance => {
    // ...
  })

上邊的代碼里給 wasm 模塊傳遞了三個對象: Math 、 objects 、 methods ,分別對應了 Web API 、普通 js 對象、使用了 Web API 的 js 函數。屬性名和變量名都并沒什么限制,是可以隨便起的,把它傳遞給 loadWebAssembly 方法的第二個參數就可以傳遞到 wasm 模塊中了。

真正實現傳遞的是 loadWebAssembly 的這行代碼:

new WebAssembly.Instance(module, imports)

獲取并使用從 js 傳遞的變量

既然 wasm 的代碼最外層聲明的是一個模塊,我們能向外 export 接口,當然也可以 import 接口。

(module
  (import "objects" "count" (global $count f32))
  (import "methods" "output" (func $output (param f32)))
  (import "Math" "sin" (func $sin (param f32) (result f32)))
  (export "test" (func $test))
  (func $test (param $x f32)
    (call $output (f32.const 42))
    (call $output (get_global $count))
    (call $output (get_local $x))
    (call $output
      (call $sin
        (get_local $x)
      )
    )
  )
)

這段代碼也是在最外層聲明了一個 module ,然后前三行是 import 語句。首先從 objects 中導入 count 屬性,并且在代碼里聲明為全局的 $count 變量,格式是 32 位浮點數;然后從 methods 中導入 output 方法,聲明為一個接受 32 位浮點數作為參數的函數 $output ;最后從 Math 中導入 sin 方法,聲明為一個接受 32 位浮點數作為參數的函數 $sin ,返回值也是 32 位浮點數。這樣一來就把 js 傳遞的對象轉成了自身模塊中可以使用變量。

接下來是定義并且導出了一個 test 函數,接受一個 32 位浮點數作為參數。在 wast 的語法里 call 指令用來調用函數, get_global 用來獲取全局變量的值, get_local 用來獲取局部變量的值,只能在函數定義中使用。這樣來看, test 函數 里執行了四條命令,首先調用 $output 輸出了一個常量 42;然后調用 $output 輸出全局變量 $count ,這個值是通過 import 獲取來的;接著又輸出了函數的參數 $x ;最后輸出了函數參數 $x 調用 Web API $sin 計算后的結果。

編譯執行

通過 west2wasm source.wast -o source.wasm 可以生成 wasm 文件,然后使用 loadWebAssembly 編譯 wasm 文件。

loadWebAssembly('path/to/source.wasm', imports)
  .then(instance => {
    const { test } = instance.exports
    test(2333)
  })

會得到如下結果:

-----> 42 <-----
-----> 666 <-----
-----> 2333 <-----
-----> 0.9332447648048401 <-----

代碼雖然簡單,但是實現了向 wasm 中傳遞變量,并且能在 wasm 中調用 Math 和 console 這種平臺接口。如果想要繞過 javascript 直接給 wasm 傳參,或者在 wasm 里直接引用 DOM API,就得看他們下一步的計劃了。

結語

根據這篇《如何畫馬》的教程,相信你很快就能用 WebAssembly 寫出來 Angry Bots 這樣的游戲啦~ :muscle:

 

來自:https://segmentfault.com/a/1190000008402872

 

 本文由用戶 leon.wei 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!