博客 / 詳情

返回

編譯wasm Web應用

剛學完WebAssembly的入門課,賣弄一點入門知識。

首先我們知道wasm是目標語言,是一種新的V-ISA標準,所以編寫wasm應用,正常來説不會直接使用WAT可讀文本格式,更不會用wasm字節碼;而是使用其他高級語言編寫源代碼,經過編譯後得到wasm應用。課程中使用了C++來編寫源代碼,所以這裏我也用C++來編寫demo。

wasm的運行環境主要分為兩類,一類是Web瀏覽器,另一類就是out-of-web環境,運行於Web瀏覽器的wasm應用主要使用Emscripten來編譯得到,因為它會在編譯過程中,為所編譯代碼在Web平台的功能適配性進行一定的調整。

針對Web平台的編譯

對於功能適配性的調整,可以從下面這個例子中得到體現。

編碼

首先我們編寫一段功能簡單的C++源代碼:

#include <iostream>

extern "C" {
  // 防止Name Mangling
  int add(int x, int y) {
    return x + y;
  }
}

int main(int argc, char **argv) {
  std::cout << add(10, 20) << std::endl;
  return 0;
}

這段代碼裏,聲明瞭一個函數“add”,它的定義被放置在“extern "C" {}”結構中,以防止函數名被C++的Name Mangling機制更改,從而確保在宿主環境中調用該函數時,可以用與C++源碼中保持一致的函數名,來直接調用這個函數。

這段代碼中還定義了主函數main,其內部調用了add函數,並且通過std::cout 來將該函數的調用結果輸出到stdout

編譯

現在我們可以用Emscripten這個工具集中最為重要的編譯器組件emcc,來編譯這段源代碼。命令如下所示:

emcc main.cc -s WASM=1 -O3 -o main.html

通過“-s”參數,為emcc指定了編譯時選項“WASM=1”,這樣emcc就會將輸入的源代碼編譯為wasm格式目標代碼,“-o”參數則指定了產出文件的格式為“.html”,這樣Emscripten就會生成一個可以直接在瀏覽器中使用的Web應用。

這個自動生成的應用中,包含了wasm模塊代碼、JavaScript代碼以及HTML代碼。

運行

現在我們可以嘗試在本地運行這個簡單的Web應用。首先自行準備一個簡單的Web服務器:

const http = require('http');
const url = require('url');
const fs = require('fs');
const path = require('path');

const PORT = 8888;
const mime = {
  "html": "text/html;charset=UTF-8",
  "wasm": "application/wasm" // 遇到".wasm"格式文件的請求時,返回特定的MIME
}

http.createServer((req, res) => {
  let realPath = path.join(__dirname, `.${url.parse(req.url).pathname}`);
  // 檢查所訪問文件是否存在並且可讀
  fs.access(realPath, fs.constants.R_OK, err => {
    if (err) {
      res.writeHead(404, { 'Content-Type': 'text/plain' });
      res.end();
    } else {
      fs.readFile(realPath, "binary", (err, file) => {
        if (err) {
          // 文件讀取失敗時返回500
          res.writeHead(500, { 'Content-Type': 'text/plain' });
          end();
        } else {
          // 根據請求的文件返回相應的文件內容
          let ext = path.extname(realPath);
          ext = ext ? ext.slice(1) : 'unknow';
          let contentType = mime[ext] || 'text/plain';
          res.writeHead(200, { 'Content-Type', contentType });
          res.write(file, "binary");
          res.end();
        }
      });
    }
  });
}).listen(PORT);
console.log("Server is running at port: " + PORT + ".");

這段代碼中最為重要的一個地方,就是對wasm格式文件請求的處理。

通過返回特殊的MIME類型“application/wasm”,我們明確告訴瀏覽器,這是一個wasm格式的文件,這樣瀏覽器就可以允許應用使用針對wasm文件的“流式編譯”方式,來加載和解析該文件。

現在我們通過8888端口來訪問剛剛編譯生成的main.html文件。

Emscripten demo1

可以看到,Emscripten將C++源碼中使用std::cout將數據輸出到stdout,模擬為輸出到頁面上指定的textarea區域。這就是Emscripten針對Web平台的功能適配性調整。

再繼續看,Emscripten自動生成的完整wasm Web應用,不管是js文件還是html文件,體積都偏大,這是因為Emscripten自動生成的“膠水代碼”中,包含有通過JavaScript模擬出的POSIX運行時環境的完整代碼,而大多數情況下,我們不需要這些。

僅生成wasm模塊

那怎樣可以使得Emscripten僅生成wasm模塊,而js膠水代碼和Web API這兩部分的代碼由我們自己編寫呢?

答案就是調整編譯時的命令行參數。那麼我們要如何去編寫JS來調用wasm模塊導出的函數呢?

課程裏有個圖像處理的例子,這裏就來整個小例子。

首先編寫我們的HTML頁面:

<!-- index.html -->
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>DEMO</title>
    </head>
    <body>
        <div>
            <h1>Counter: </h1>
            <span>0</span>
            <button id="increaseButton">點我+1</button>
        </div>
        <script src="index.js"></script>
    </body>
</html>

這裏想要實現一個功能,點擊按鈕後,span內的數字加1,當然這個功能JavaScript也能做,但現在作為練習,我們要通過調用wasm函數來實現。

然後就是重要的JavaScript代碼,如下:

// index.js
document.addEventListener('DOMContentLoaded',  async () => {
    let response = await fetch('./index.wasm');
    let bytes = await response.arrayBuffer();
    let {instance} = await WebAssembly.instantiate(bytes);
    let {
        increase
    } = instance.exports;

    const span = document.querySelector('span');
    const button = document.querySelector('#increaseButton');
    let count = 0;
    button.addEventListener('click', () => {
        count = increase(count);
        span.innerText = count;
    });
});

首先,通過fetch獲取wasm模塊,並獲取fetch方法返回的Response對象;

然後,調用response對象上的arrayBuffer()方法,將內容解析為ArrayBuffer的形式,這個ArrayBuffer將作為WebAssembly.instantiate方法的實際調用參數;這是一個用於實例化wasm模塊的方法。

接着,WebAssembly.instantiate將實例化對應的wasm模塊,我們就可以獲得模塊的實例對象,在instance變量中,可以獲得從wasm模塊導出的所有方法。

此時,我們就可以調用wasm模塊的方法了,假設instance上有個increase方法,就可以這樣調用。

現在,我們編寫對應的C++代碼並進行編譯。

// index.cc
#include <emscripten.h>

extern "C" {
    EMSCRIPTEN_KEEPALIVE int increase(int x) {
        return x+1;
    }
}

此處我們需要引入<emscripten.h>,因為需要使用其中定義的宏EMSCRIPTEN_KEEPALIVE,因為這個文件中我們不聲明主函數main,也不在文件內部調用這個increase函數,為了防止在編譯過程中被DCE(Dead Code Elimination)處理掉,需要使用這個宏來標記函數。

現在我們來編譯這個文件。

$ emcc index.cc -s WASM=1 -O3 --no-entry -o index.wasm

僅生成wasm模塊文件的編譯方式,通常稱為”standalone模式”。

“-o”參數為我們指定了輸出的文件格式為“.wasm”,這就是告訴Emscripten以“standalone”的方式來編譯C++源碼。

“--no-entry”參數則告訴編譯器,這個wasm模塊沒有聲明“main”函數。

上述命令執行完畢後,就會得到一個名為“index.wasm”的二進制模塊文件。

此時我們就可以嘗試去運行這個Web應用,可以看到和期待的效果一致。

emcc demo

當然這個demo很簡單,目前要發揮wasm的優勢,更適合將其應用在計算密集的功能。

調試應用

當我們編寫完應用時,少不了要調試。那麼如何針對wasm應用進行調試呢,Emscripten也提供了一些方式。

編譯階段

首先是針對編譯階段,當使用emcc編譯項目時,可以通過為命令添加“EMCC_DEBUG”環境變量的方式,來讓emcc以“調試模式”來編譯項目。

$ EMCC_DEBUG=1 emcc index.cc \
> -s WASM=1 \
> -O3 \
> --no-entry -o index.wasm

可以看到編譯時輸出了很多的信息,這是因為我們將EMCC_DEBUG這個環境變量的值設置為1,EMCC_DEBUG的值可以設置為3個值,分別是0、1、2。

0表示關閉調試模式,這和不加這個環境變量是一樣的效果;1表示輸出編譯時的調試性信息,同時生成包含有編譯器各個階段運行信息的中間文件;可用於編譯流程的調試。

可以通過ls命令查看生成了哪些文件;調試性信息中包含了各個編譯階段所實際調用的命令行信息,通過對這些信息分析,能夠輔助開發者查找編譯失敗的原因。

當EMCC_DEBUG的值設置為2時,可以得到更多的調試性信息。

運行階段

當我們成功地編譯了wasm應用,但在實際運行時發生了錯誤,就需要在運行時進行調試。Emscripten也提供了一定的支持,我們可以在編譯時設定參數“-g“以保留與調試相關的信息。

當設置為”-gsource-map“時,emcc會生成可用於在Web瀏覽器中進行“源碼級”調試的特殊DWARF信息;通過這些特殊格式的信息,使我們可以直接在瀏覽器中對wasm模塊編譯之前的源代碼進行諸如“設置斷點”、“單步跟蹤”等調試手段。

這裏我們嘗試調試之前編寫的index.cc。

$ emcc index.cc -gsource-map -s WASM=1 -O3 --no-entry -o index.wasm

此時重新加載Web應用並打開“開發者面板”的“sources”Tab,就可以通過“操作”C++源代碼的方式,來為應用所使用的wasm模塊設置斷點。(wasm模塊的加載方式需要改為“流式編譯”)。

wasm debug

通過這種方式,開發者就可以方便地在wasm Web應用的運行過程中,調試發生在wasm模塊內部的“源碼級”錯誤。

WebAssembly作為一種相對較新的技術,可以先保持一點了解。

user avatar mrqueue 頭像 ipromise 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.