asm.js 和 Emscripten 入門教程

阮一峰發表於2017-09-07

Web 技術突飛猛進,但是有一個領域一直無法突破 ---- 遊戲。

遊戲的效能要求非常高,一些大型遊戲連 PC 跑起來都很吃力,更不要提在瀏覽器的沙盒模型裡跑了!但是,儘管很困難,許多開發者始終沒放棄,希望讓瀏覽器執行 3D 遊戲。

2012年,Mozilla 的工程師 Alon Zakai 在研究 LLVM 編譯器時突發奇想:許多 3D 遊戲都是用 C / C++ 語言寫的,如果能將 C / C++ 語言編譯成 JavaScript 程式碼,它們不就能在瀏覽器裡執行了嗎?眾所周知,JavaScript 的基本語法與 C 語言高度相似。

於是,他開始研究怎麼才能實現這個目標,為此專門做了一個編譯器專案 Emscripten。這個編譯器可以將 C / C++ 程式碼編譯成 JS 程式碼,但不是普通的 JS,而是一種叫做 asm.js 的 JavaScript 變體。

本文就將介紹 asm.js 和 Emscripten 的基本用法,介紹如何將 C / C++ 轉成 JS。

一、asm.js 的簡介

1.1 原理

C / C++ 編譯成 JS 有兩個最大的困難。

  • C / C++ 是靜態型別語言,而 JS 是動態型別語言。
  • C / C++ 是手動記憶體管理,而 JS 依靠垃圾回收機制。

asm.js 就是為了解決這兩個問題而設計的:它的變數一律都是靜態型別,並且取消垃圾回收機制。除了這兩點,它與 JavaScript 並無差異,也就是說,asm.js 是 JavaScript 的一個嚴格的子集,只能使用後者的一部分語法。

一旦 JavaScript 引擎發現執行的是 asm.js,就知道這是經過優化的程式碼,可以跳過語法分析這一步,直接轉成組合語言。另外,瀏覽器還會呼叫 WebGL 通過 GPU 執行 asm.js,即 asm.js 的執行引擎與普通的 JavaScript 指令碼不同。這些都是 asm.js 執行較快的原因。據稱,asm.js 在瀏覽器裡的執行速度,大約是原生程式碼的50%左右。

下面就依次介紹 asm.js 的兩大語法特點。

1.2 靜態型別的變數

asm.js 只提供兩種資料型別

  • 32位帶符號整數
  • 64位帶符號浮點數

其他資料型別,比如字串、布林值或者物件,asm.js 一概不提供。它們都是以數值的形式存在,儲存在記憶體中,通過 TypedArray 呼叫。

如果變數的型別要在執行時確定,asm.js 就要求事先宣告型別,並且不得改變,這樣就節省了型別判斷的時間。

asm.js 的型別宣告有固定寫法,變數 | 0表示整數,+變數表示浮點數。


var a = 1;

var x = a | 0;  // x 是32位整數
var y = +a;  // y 是64位浮點數

上面程式碼中,變數x宣告為整數,y宣告為浮點數。支援 asm.js 的引擎一看到x = a | 0,就知道x是整數,然後採用 asm.js 的機制處理。如果引擎不支援 asm.js 也沒關係,這段程式碼照樣可以執行,最後得到的還是同樣的結果。

再看下面的例子。


// 寫法一
var first = 5;
var second = first;

// 寫法二
var first = 5;
var second = first | 0;

上面程式碼中,寫法一是普通的 JavaScript,變數second只有在執行時才能知道型別,這樣就很慢了,寫法二是 asm.js,second在宣告時就知道是整數,速度就提高了。

函式的引數和返回值,都要用這種方式指定型別。


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

上面程式碼中,除了引數xy需要宣告型別,函式的返回值也需要宣告型別。

1.3 垃圾回收機制

asm.js 沒有垃圾回收機制,所有記憶體操作都由程式設計師自己控制。asm.js 通過 TypedArray 直接讀寫記憶體。

下面就是直接讀寫記憶體的例子。


var buffer = new ArrayBuffer(32768);
var HEAP8 = new Int8Array(buffer);
function compiledCode(ptr) {
  HEAP[ptr] = 12;
  return HEAP[ptr + 4];
}  

如果涉及到指標,也是一樣處理。


size_t strlen(char *ptr) {
  char *curr = ptr;
  while (*curr != 0) {
    curr++;
  }
  return (curr - ptr);
}

上面的程式碼編譯成 asm.js,就是下面這樣。


function strlen(ptr) {
  ptr = ptr|0;
  var curr = 0;
  curr = ptr;
  while (MEM8[curr]|0 != 0) {
    curr = (curr + 1)|0;
  }
  return (curr - ptr)|0;
}

1.4 asm.js 與 WebAssembly 的異同

如果你對 JS 比較瞭解,可能知道還有一種叫做 WebAssembly 的技術,也能將 C / C++ 轉成 JS 引擎可以執行的程式碼。那麼它與 asm.js 有何區別呢?

回答是,兩者的功能基本一致,就是轉出來的程式碼不一樣:asm.js 是文字,WebAssembly 是二進位制位元組碼,因此執行速度更快、體積更小。從長遠來看,WebAssembly 的前景更光明。

但是,這並不意味著 asm.js 肯定會被淘汰,因為它有兩個優點:首先,它是文字,人類可讀,比較直觀;其次,所有瀏覽器都支援 asm.js,不會有相容性問題。

二、 Emscripten 編譯器

2.1 Emscripten 簡介

雖然 asm.js 可以手寫,但是它從來就是編譯器的目標語言,要通過編譯產生。目前,生成 asm.js 的主要工具是 Emscripten

Emscripten 的底層是 LLVM 編譯器,理論上任何可以生成 LLVM IR(Intermediate Representation)的語言,都可以編譯生成 asm.js。 但是實際上,Emscripten 幾乎只用於將 C / C++ 程式碼編譯生成 asm.js。


C/C++ ⇒ LLVM ==> LLVM IR ⇒ Emscripten ⇒ asm.js

2.2 Emscripten 的安裝

Emscripten 的安裝可以根據官方文件。由於依賴較多,安裝起來比較麻煩,我發現更方便的方法是安裝 SDK

你可以按照下面的步驟操作。


$ git clone https://github.com/juj/emsdk.git
$ cd emsdk
$ ./emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit
$ ./emsdk activate --build=Release sdk-incoming-64bit binaryen-master-64bit
$ source ./emsdk_env.sh

注意,最後一行非常重要。每次重新登陸或者新建 Shell 視窗,都要執行一次這行命令source ./emsdk_env.sh

2.3 Hello World

首先,新建一個最簡單的 C++ 程式hello.cc


#include <iostream>

int main() {
  std::cout << "Hello World!" << std::endl;
}

然後,將這個程式轉成 asm.js。


$ emcc hello.cc
$ node a.out.js
Hello World!

上面程式碼中,emcc命令用於編譯原始碼,預設生成a.out.js。使用 Node 執行a.out.js,就會在命令列輸出 Hello World。

注意,asm.js 預設自動執行main函式。

emcc是 Emscripten 的編譯命令。它的用法非常簡單。


# 生成 a.out.js
$ emcc hello.c

# 生成 hello.js
$ emcc hello.c -o hello.js

# 生成 hello.html 和 hello.js
$ emcc hello.c -o hello.html

三、Emscripten 語法

3.1 C/C++ 呼叫 JavaScript

Emscripten 允許 C / C++ 程式碼直接呼叫 JavaScript。

新建一個檔案example1.cc,寫入下面的程式碼。


#include <emscripten.h>

int main() {
  EM_ASM({ alert('Hello World!'); });
}

EM_ASM是一個巨集,會呼叫嵌入的 JavaScript 程式碼。注意,JavaScript 程式碼要寫在大括號裡面。

然後,將這個程式編譯成 asm.js。


$ emcc example1.cc -o example1.html

瀏覽器開啟example1.html,就會跳出對話方塊Hello World!

3.2 C/C++ 與 JavaScript 的通訊

Emscripten 允許 C / C++ 程式碼與 JavaScript 通訊。

新建一個檔案example2.cc,寫入下面的程式碼。


#include <emscripten.h>
#include <iostream>

int main() {
  int val1 = 21;
  int val2 = EM_ASM_INT({ return $0 * 2; }, val1);

  std::cout << "val2 == " << val2 << std::endl;
}

上面程式碼中,EM_ASM_INT表示 JavaScript 程式碼返回的是一個整數,它的引數裡面的$0表示第一個引數,$1表示第二個引數,以此類推。EM_ASM_INT的其他引數會按照順序,傳入 JavaScript 表示式。

然後,將這個程式編譯成 asm.js。


$ emcc example2.cc -o example2.html

瀏覽器開啟網頁example2.html,會顯示val2 == 42

3.3 EM_ASM 巨集系列

Emscripten 提供以下巨集。

  • EM_ASM:呼叫 JS 程式碼,沒有引數,也沒有返回值。
  • EMASMARGS:呼叫 JS 程式碼,可以有任意個引數,但是沒有返回值。
  • EMASMINT:呼叫 JS 程式碼,可以有任意個引數,返回一個整數。
  • EMASMDOUBLE:呼叫 JS 程式碼,可以有任意個引數,返回一個雙精度浮點數。
  • EMASMINT_V:呼叫 JS 程式碼,沒有引數,返回一個整數。
  • EMASMDOUBLE_V:呼叫 JS 程式碼,沒有引數,返回一個雙精度浮點數。

下面是一個EM_ASM_ARGS的例子。新建檔案example3.cc,寫入下面的程式碼。


#include <emscripten.h>
#include <string>

void Alert(const std::string & msg) {
  EM_ASM_ARGS({
    var msg = Pointer_stringify($0);
    alert(msg);
  }, msg.c_str());
}

int main() {
  Alert("Hello from C++!");
}

上面程式碼中,我們將一個字串傳入 JS 程式碼。由於沒有返回值,所以使用EM_ASM_ARGS。另外,我們都知道,在 C / C++ 裡面,字串是一個字元陣列,所以要呼叫Pointer_stringify()方法將字元陣列轉成 JS 的字串。

接著,將這個程式轉成 asm.js。


$ emcc example3.cc -o example3.html

瀏覽器開啟example3.html,會跳出對話方塊"Hello from C++!"。

3.4 JavaScript 呼叫 C / C++ 程式碼

JS 程式碼也可以呼叫 C / C++ 程式碼。新建一個檔案example4.cc,寫入下面的程式碼。


#include <emscripten.h>

extern "C" {
  double SquareVal(double val) {
    return val * val;
  }
}

int main() {
  EM_ASM({
    SquareVal = Module.cwrap('SquareVal', 'number', ['number']);
    var x = 12.5;
    alert('Computing: ' + x + ' * ' + x + ' = ' + SquareVal(x));
  });
}

上面程式碼中,EM_ASM執行 JS 程式碼,裡面有一個 C 語言函式SquareVal。這個函式必須放在extern "C"程式碼塊之中定義,而且 JS 程式碼還要用Module.cwrap()方法引入這個函式。

Module.cwrap()接受三個引數,含義如下。

  • C 函式的名稱,放在引號之中。
  • C 函式返回值的型別。如果沒有返回值,可以把型別寫成null
  • 函式引數型別的陣列。

除了Module.cwrap(),還有一個Module.ccall()方法,可以在 JS 程式碼之中呼叫 C 函式。


var result = Module.ccall('int_sqrt', // C 函式的名稱
  'number', // 返回值的型別
  ['number'], // 引數型別的陣列
  [28] // 引數陣列
); 

回到前面的示例,現在將example4.cc編譯成 asm.js。


$  emcc -s EXPORTED_FUNCTIONS="['_SquareVal', '_main']" example4.cc -o example4.html

注意,編譯命令裡面要用-s EXPORTED_FUNCTIONS引數給出輸出的函式名陣列,而且函式名前面加下劃線。本例只輸出兩個 C 函式,所以要寫成['_SquareVal', '_main']

瀏覽器開啟example4.html,就會看到彈出的對話方塊裡面顯示下面的內容。


Computing: 12.5 * 12.5 = 156.25 

3.5 C 函式輸出為 JavaScript 模組

另一種情況是輸出 C 函式,供網頁裡面的 JavaScript 指令碼呼叫。 新建一個檔案example5.cc,寫入下面的程式碼。


extern "C" {
  double SquareVal(double val) {
    return val * val;
  }
}

上面程式碼中,SquareVal是一個 C 函式,放在extern "C"程式碼塊裡面,就可以對外輸出。

然後,編譯這個函式。


$ emcc -s EXPORTED_FUNCTIONS="['_SquareVal']" example5.cc -o example5.js

上面程式碼中,-s EXPORTED_FUNCTIONS引數告訴編譯器,程式碼裡面需要輸出的函式名。函式名前面要加下劃線。

接著,寫一個網頁,載入剛剛生成的example5.js


<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML//EN">
<body>
<h1>Test File</h1>
<script type="text/javascript" src="example5.js"></script>
<script>
  SquareVal = Module.cwrap('SquareVal', 'number', ['number']);
  document.write("result == " + SquareVal(10));
</script>
</body>

瀏覽器開啟這個網頁,就可以看到result == 100了。

3.6 Node 呼叫 C 函式

如果執行環境不是瀏覽器,而是 Node,那麼呼叫 C 函式就更方便了。新建一個檔案example6.c,寫入下面的程式碼。


#include <stdio.h>
#include <emscripten.h>

void sayHi() {
  printf("Hi!\n");
}

int daysInWeek() {
  return 7;
}

然後,將這個指令碼編譯成 asm.js。


$ emcc -s EXPORTED_FUNCTIONS="['_sayHi', '_daysInWeek']" example6.c -o example6.js

接著,寫一個 Node 指令碼test.js


var em_module = require('./api_example.js');

em_module._sayHi();
em_module.ccall("sayHi");
console.log(em_module._daysInWeek());

上面程式碼中,Node 指令碼呼叫 C 函式有兩種方法,一種是使用下劃線函式名呼叫em_module._sayHi(),另一種使用ccall方法呼叫em_module.ccall("sayHi")

執行這個指令碼,就可以看到命令列的輸出。


$ node test.js
Hi!
Hi!
7

四、用途

asm.js 不僅能讓瀏覽器執行 3D 遊戲,還可以執行各種伺服器軟體,比如 LuaRubySQLite。 這意味著很多工具和演算法,都可以使用現成的程式碼,不用重新寫一遍。

另外,由於 asm.js 的執行速度較快,所以一些計算密集型的操作(比如計算 Hash)可以使用 C / C++ 實現,再在 JS 中呼叫它們。

真實的轉碼例項可以看一下 gzlib 的編譯,參考它的 Makefile 怎麼寫。

五、參考連結

(完)