間が随分と空いてしまいましたが、低水準言語とasm.jsとWebAssembly詳解の第3回目です。
前回は、型システムを中心にasm.jsを解説しました。asm.jsはプログラムの中に型が明示されるため、事前コンパイルをして高速に動作させられる点がasm.jsの大きな特徴でした。
しかし数値演算しか行えなえず、また式にすべて型アノテーションを行わなくてはならないため、通常のJavaScriptのプログラムのように手で書くのはなかなかに辛いものがあることもわかりました。
これはasm.jsで書かれたプログラムは、JavaScriptとしても実行可能であることが求められたためでもありました。そこでasm.jsよりも書きやすい言語で書かれたプログラムを、asm.jsに変換するツールが提案されています。
そのもっともよく使われている例であるEmscriptenについて、インストール方法や簡単な使い方、ファイルアクセス、そしてJavaScriptとの相互連携までを解説します。
トランスコンパイラ Emscripten
Emscriptenはオープンソースのトランスコンパイラで、コンパイラフレームワークであるLLVMを利用して実装されています。これを利用することで、C言語やC++で書かれたプログラムをasm.jsに変換できます。
たとえば次のCで書かれたHello,worldもEmscriptenを使うと、asm.jsに変換され、Node.jsやWebブラウザで実行できます。
1 2 3 4 5 |
#include <stdio.h> int main(int argc, char** argv){ printf("Hello, world!\n"); return 0; } |
変換に利用するのがEmscriptenのフロントエンドであるemcc
コマンドです。これをgccのように使って、.cファイルをコンパイルします。次の例では、上記のHello,worldをasm.jsに変換し、Node.jsで実行しています。
3行目に出力されている”Hello,world!”は、Node.jsが出力しています。Cで書かれたプログラムが、Node.jsの上で動作する、というのはなかなか味わい深いものがありますね。
1 2 3 |
$ emcc -o hello.js hello.c $ node hello.js Hello, world! |
Emscriptenは、自分の書いたプログラムだけでなく、libcやlibc++といった標準ライブラリも一緒に変換します。
上記の例ではCの標準関数も一緒に変換が行われているため、printf
が正しく実行されています。
またOpenGLの関数呼び出しはWebGLへ変換されます。SDLの関数も利用できるため、次のようなSDLを用いたプログラムも変換できます。
これらはCanvas要素をディスプレイ代わりに利用するため、変換時にJavaScriptだけでなくHTMLも合わせて出力させることで実行できます。
1 |
$ emcc -o hello-world-sdl.html hello-world-sdl.cpp |
Emscriptenのインストール
Emscriptenはemsdkというツールを利用してインストールします。このツールは単なるインストーラではなく、nvmやrustup、rvmのようなツールで、使用するEmscriptenのバージョン指定や更新を行えます。
emsdkとEmscriptenのインストールは配布元のサイトで確認いただけますが、概要を述べると次のようになります。
- 依存するツールのインストール:GCC/Git/Cmake/Python 2.7.x 2.emsdkのクローンと更新
- 最新版のEmscriptenのインストール
- インストールしたEmscriptenの有効化
emsdkはGitHubで配布されています。また内部の処理にGitを利用します。またEmscriptenのインストールや更新時にclangのコンパイルを行います。cmakeやgccはそのコンパイルに利用されます。
環境によってインストール方法は異なります。それぞれの環境にあった方法でインストールしてください。Homebrewを利用できるようになっているmacOSの場合、GCC/Git/Pythonはすでにインストールされています。CMakeは次のようにbrew
コマンドを使ってインストールできます。
1 |
$ brew install cmake # cmake のインストール |
依存するツールをインストールしたらemsdkをクローンします。
1 2 |
$ git clone https://github.com/juj/emsdk.git $ cd emsdk |
emsdkフォルダ内にある、emsdkというファイルを使って、Emscriptenの管理を行います。
まずupdate
コマンドを実行して、emsdkそのものと、インストール可能なEmscriptenのバージョンリストを最新のものへと更新します。次にinstall
コマンドを実行して、最新の安定板をインストールします。
1 2 |
$ ./emsdk update $ ./emsdk install latest |
インストールしただけでは、Emscriptenを利用できるようにはなりません。activate
コマンドで利用するEmscriptenのバージョンを指定し、環境変数を設定することで、インストールした最新バージョンのEmscriptenが利用できるようになります。
1 2 |
$ ./emsdk activate latest $ source ./emsdk_env.sh |
Hello, world!
asm.jsへのコンパイルはemccコマンドを利用して行います。まずは正しくインストールできたかどうかを確認するために、バージョン番号を表示させてみましょう。
1 |
$ emcc -v |
バージョン番号が表示されれば正しくインストールできています。次はHello,worldでしょう。次のようなプログラムを作成し、hello.cという名前で保存します。
1 2 3 4 5 6 |
#include <stdio.h> int main(int argc, char** argv){ printf("Hello, world!\n"); return 0; } |
プログラムを作成したら、つぎのコマンドを実行します。
1 |
$ emcc hello.c |
a.out.jsというファイルが作成されます。これが上記のプログラムがEmscriptenによって変換された結果です。nodeで実行させてみましょう。
ターミナルに”Hello,world!”と出力されるはずです。
1 |
$ node a.out.js |
emcc コマンド
emccコマンドには、いくつものオプションがあります。それらのうち、よく使うものをまとめると次の表になります。
オプション | 効果 |
---|---|
-o <file> |
出力するファイル名を指定する |
-O0 |
最適化を全く行わない |
-O1 |
LLVMの-O0 最適化や、アサーションの削除といった最適化が行われる |
-O2 |
-O1 に加えて、出力するJavaScriptの最適化も行う |
-O3 |
-O2 に加えて、さらなる最適化をJavaScriptに対して行う。遅い |
-g |
デバッグ情報を保持する |
--preload-file <path> |
プログラム中で読み書きするファイルの場所を指定する |
--embed-file <path> |
プログラムに埋め込むファイルの場所を指定する |
-o
オプションを使うと出力するファイル名を指定だけでなく、ターゲットの変更もできます。
例えば.htmlで終わるファイル名を指定することで、HTMLを出力できます。
1 2 3 |
$ emcc -o hello.html hello.c $ ls hello.c hello.html hello.js |
出力されたHTMLファイルをブラウザで表示させると、次のように”Hello,world!”と出力されていることが確認できます。
ファイルも扱えます
Node.js同様、C/C++のプログラムはファイルに対する操作が行えます。C/C++の標準的なライブラリで提供されている機能を利用してreadme.txtというファイルを読み込むプログラム実装すると、次のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include int main(int argc, char** argv){ FILE *file = fopen("readme.txt", "r"); while(!feof(file)){ char c = fgetc(file); if(c == EOF){ break; } putchar(c); } fclose(file); return 0; } |
このコードの中のfopen,feof,fgetc,fcloseがファイルアクセスに関する関数になります。これらの関数の呼び出しは同期的に行われます。このコードもEmscriptenで変換できますし、正しく動作します。
そもそもブラウザ内で動作するJSはファイルにアクセスできません。
また、このコードはファイルアクセスが同期的に処理されているため、そのまま変換を行うとアクセスが終わるまで、ブラウザの処理が止まってしまいます。下図のような仮想的なファイルシステムを提供することで、Emscriptenはこれらの問題を解決しています。
まずSynchronous File System APIによって、同期的な関数呼び出しを非同期的なものに変換しています。
このAPIはファイルシステムの実装も隠蔽しています。通常はメモリ上に構築されるファイルシステムを利用しますが、コンパイル時のオプションによってNode.jsのファイルシステムや、IndexedDBを利用したものを利用できます。この切り替えを行ったとしても、コードを変更する必要はありません。
Emscriptenの仮想ファイルシステムはPOSIXのファイルシステムと同様に、デバイスファイルの作成や、ファイルシステムのマウントなどもサポートしています。
これらの機能はFileSystem API 経由で利用できます。標準入出力も自作のオブジェクトに置き換えられますので、なかなかに工夫しがいのあるかと思います。
アセットを追加するには
上記の例で利用したreadme.txtのようなアセットを利用するためには、Emscriptenによって提供される仮想ファイルシステムにアセットのファイルを追加する必要があります。
追加方法には、次の2つがあります。
ファイルサイズの大きいものや更新が頻繁なものは後者で、そうでないものは前者の方法を利用するとよいでしょう。
- 利用するファイルをJSに埋め込む方法
- 利用されるファイルを1つにまとめ、それをXHRで読み込む方法
前者は--embed-file
オプションを、後者は--preload-file
オプションをつけてemcc
を実行することで、利用できます。
例えば先ほど紹介したプログラムはreadme.txtというファイルを読み込み、その内容を標準出力へと出力します。これを後者の方法で変換するには、次のようにemcc
コマンドを実行します。
1 |
$ emcc -o read-file.html --preload-file readme.txt read-file.c |
実行すると、HTMLファイルやJSファイル以外に.data
ファイルが作成されます。
これはアセットを一つにまとめたものです。このファイルを非同期に読み込み、メモリ上に配置することで、Emscriptenはアセットのロードを実現させています。
インライン JavaScript
C/C++には、インラインアセンブラという機能があります。これはアセンブラで書かれたコードをソースコードの中に埋め込んでしまえる、というもので、高速化やシステムコールの実現などのために利用されます。
同様にEmscriptenでは、ソースコードの中にJavaScriptを埋め込めます。JavaScriptを埋め込むことで、C/C++のコードからブラウザの機能を利用できるようになります。
埋め込むためにはEM_ASM()
と呼ばれるマクロを使います。
alert
関数を呼び出すコードは、次のように書けます。
1 2 3 4 5 6 7 8 |
#include int main(){ EM_ASM( alert("Hello, world!"); ); return 0; } |
これをemcc
でコンパイルして、実行した結果は次のようになります。
EM_ASM_
を利用すれば、パラメーターを与えることもできます。EM_ASM
との違いは、埋め込むJSのコードがブロックになっている点と、マクロの名前です。パラメータを与えるマクロは、末尾に_
がついています。間違えがちなので注意が必要です。
1 2 3 4 5 6 7 8 |
#include int main(){ EM_ASM_({ alert(`Hello, world: ${$0}`); }, 100); return 0; } |
与えられたパラメータは、$0
,$1
のように参照できます。上記の例では、与えられたパラメータをテンプレート文字列内で利用しています。
これらのマクロはemscripten.h
に定義されています。詳しくはドキュメントを参照してください。
外部関数としてJavaScript関数を呼び出す
マクロとしてJavaScriptを埋め込む以外に、C/C++からJavaScriptの関数を呼び出せます。それは外部関数として関数を宣言しておき、Emscriptenの--js-library
オプションで実装を与える方法です。
次のコードでは、zero
という関数を呼び出しています。この関数は外部の関数として宣言されています。、コンパイラは名前、引数の数と型、返り値の型以外わかっていません。
1 2 3 4 5 |
extern int zero(); int one(){ return zero() + 1; } |
通常のC/C++では、C/C++のコード(時にはコンパイル済みのバイナリ)として、この関数の実装は与えられますが、Emscriptenでは、これらに加えてJavaScriptでその実装を与えることもできます。
次のようにJavaScriptでzero
を実装し、LibraryManager
オブジェクトのlibrary
属性にマージしてやることで、C/C++のコードから呼び出せるようになります。
1 2 3 4 5 6 7 |
function zero(){ return 0; } mergeInto(LibraryManager.library, { zero: zero }); |
JavaScriptから変換されたコード利用するには
基本的な機能はC/C++で実装済みで、それを操作するUIをWebで提供したい。そんな場合は、Emscriptenで変換されたC/C++のコードを、JSから呼び出すかたちで実装するとよいでしょう。
まず基本であるCで定義された関数をJSから呼び出してみましょう。次のような足し算を行う関数が、add.cというファイルに定義されていたとしましょう。
1 2 3 |
int add(int a, int b){ return a + b; } |
これをemcc
コマンドでJSに変換します。
1 |
% emcc -o add.js -s "EXPORTED_FUNCTIONS=['_add']" add.c |
-s
オプションを使うと、コマンドラインオプションでは指定できない内部的なオプションを指定できます。
これを利用してJSへエキスポートする関数のリストであるEXPORTED_FUNCTIONS
を設定しています。このリストに載せる関数は、その名前の先頭に_
をつけなければならないことに注意してください。
またC++の場合は、関数がマングリングされるの防ぐために、次のようにCのプログラムとして記述する必要があります。
1 2 3 4 5 |
extern "C"{ int add(int a, int b){ return a + b; } } |
生成されたadd.jsをロードした後、次のようにJS側でasm.jsの関数をラップしたJavaScriptの関数を作成します。
1 |
const add = Module.cwrap("add", "number", ["number"]); |
Module
オブジェクトは、Emscriptenによって自動的に定義されるオブジェクトで、生成されたコードが実行されるときに、さまざまな形で利用されます。そのcwrapメソッドを使って、asm.jsの関数をJavaScriptから利用できるようにします。そのメソッドの引数はそれぞれ、ラップする関数の名前、返り値の型、引数の型を表します。
一度ラップされてしまえば、JavaScriptの関数と区別することなく利用できます。
1 2 |
const sum = add(1, 2); // 3 console.log(sum); |
C++で書かれたコードに対しては、上記の方法に加えて、C++で定義されたクラスをJavaScriptから利用できるようになります。これの詳細については述べませんが、手順を概観すると次のようになります:
- C++で定義されたクラスのためのWebIDLを作成する
- 作成したWebIDLを
tools/webidl_binder.py
で処理して、グルーコードを作成する - グルーコードとともに、cppファイルを
emcc
コマンドでコンパイルする
上記の手順で処理することによって、C++で定義されたクラスを作成するコンストラクタが、Module
オブジェクトの属性として参照できるようになります。
詳しくはこちらのドキュメントを参照してください。
まとめ
手で書くのが大変なasm.jsも、CやC++で書いたコードをEmscriptenで変換することで簡単に生成できます。このとき標準ライブラリの変換も合わせて行われます。
またファイルシステムもEmscriptenによってエミュレートされるため、asm.jsへの変換を行う際にソースコードを変更する必要はほとんどありません。C/C++のコードをほとんど変えなくても、asm.jsへと変換できます。
またJavaScriptと変換されたコードの相互呼び出しも可能な上に、標準入出力もJSでエミューレートできます。そのおかげで、Emscriptenで作られたasm.jsコードと、JavaScriptで作られたコードとを柔軟に組み合わせてサイトやアプリを作ることができます。
これでめでたしめでたし、とならないのが技術の世界のつらいところです。Emscriptenによって作られたコードにも弱点があります。それはロードにかかる時間です。単なるHello Worldであっても、変換されたコードは300Kバイト程度になります。これは合わせて変換された標準ライブラリが、コードに含まれているためです。
コードのサイズは、Hello Worldなので300K程度で済んでいるともいえます。これがゲームともなれば、そのサイズはさらに増大します。コードサイズの大きさは、ダウンロードにかかる時間が大きいことも意味しますし、ダウンロード後、起動までの時間が長くなることも意味します。
プログラム | コードのサイズ(asm.js) | コードのサイズ(WebAssembly) |
---|---|---|
Hello World | 301KiB | 204KiB |
AngryBots | 19MiB | 6MiB |
StandardAssets | 25.7MiB | 13.4MiB |
UnityChan-CRS | 21.3MiB | 11.4MiB |
この問題を解決するために、新しいファイルフォーマットの議論が始まりました。それがWebAssemblyです。
上の表をご覧いただければ、WebAssemblyを利用することでファイルサイズがぐっと小さくなっていることがわかるかと思います。最終回となる次回はWebAssemblyの役割と、その特徴について解説します。