Emscriptenの紹介ページを見ると、巨大な実行用HTMLやらグルーJSやらがくっついているhello worldのサンプルが載っている。 しかし、WebAssembly(以下面倒なのでwasm)を使いたい場合というのは、時間のかかるデータ処理などをC言語でやらせて、結果をJavaScriptで表示するという用途が圧倒的に多いはずで、C言語でブラウザにhello Worldを表示したいわけではない。 もっと気の利いた小さいサンプルが欲しい、というのがこのページの趣旨。
そう考えると、取り合えず何かデータをC言語側に渡し、戻り値を受け取ってconsole.logで表示できればそれで十分なわけだ。
最近のブラウザでは驚くほど短く書ける。
C言語のソースはこんなの。
#ifdef EMSCRIPTEN
#include <emscripten.h>
#else
#define EMSCRIPTEN_KEEPALIVE
#endif
EMSCRIPTEN_KEEPALIVE
int foo(int x) { return x * 2; }
emccは基本的にはgccなんかと同じコンパイラドライバで、放っておくとリンクまで走る。
この時にどこからも参照されていない関数があるとリンク結果から取り除かれてしまう。
これを防ぐためにEMSCRIPTEN_KEEPALIVEというマクロを指定するが、このマクロはemscripten.hで定義されていて、このヘッダは普通のGCCやClangの環境にはないわけだ。
そこでEMSCRIPTENマクロで切り替えている。
このマクロはどこで知ったかというと、emccもLLVMの仲間なので、clangと同じようなコマンドラインオプションが使え、マクロ一覧の調べかたもほぼ同じである。
emcc -dM -E -x c /dev/nullと打ってgrepすれば、EMSCRIPTENというマクロがすぐに見つかるだろう。
もし将来このマクロがなくなったとしても、emccを実行するときに-DEMSCRIPTENを指定すればいいだけだ。
EMSCRIPTENマクロがなければ、EMSCRIPTEN_KEEPALIVEが空になり、他の環境でもそのままコンパイルできる。
何度も打つのは面倒なので、実際に使う際にはヘッダにまとめておくといいだろう。
これをfoo.cという名前で保存して、emcc -o foo.wasm --no-entry foo.cとするとfoo.wasmファイルができる。
手元の環境ではたったの630バイトである。
グルーJSも実行用HTMLも生成されない。wasmだけができる。
--no-entryというのはmain関数を呼び出すコードを生成しない、という意味で、要するにライブラリっぽいバイナリを生成するためのオプションである。
このオプションを指定しない場合はmain関数が必要で、main関数から参照されているものは全て残るが、コードが巨大になる。
このオプションを指定するとmainは不要になるが、放っておくと全部の関数が削除されてしまう。
そこでEMSCRIPTEN_KEEPALIVEを指定したわけだ。
これをHTMLからJavaScriptで読み込む。
<!doctype html>
<meta charset="utf-8">
<title>foo</title>
<script>
WebAssembly.instantiateStreaming(fetch("foo.wasm")).then(wasm => {
console.log(wasm.instance.exports.foo(10));
});
</script>
これを開発コンソールを開いた状態のブラウザに読み込ませると、コンソールに20と表示されるだろう。
PHPのビルトインサーバーでもなんでもいいのでサーバー経由で読み込ませた方が問題が少ない。
PHPのビルトインサーバーはwasmファイルをちゃんとapplication/wasmで返してくれる。
ローカルファイルを直接指定する場合、fetchにno-corsを指定しないとダメなことが多い。
余談だが、このファイルはHTML5としても正しい文法になっているはずである。
必須なのはhtml・head・meta charset・title・bodyだが、html・head・bodyは開始タグ・終了タグとも省略できるので、手書きするときはこれが正しいHTML5でなおかつ一番短い状態のはずである(よい子は真似をしないように)。
そして、書かなければならないソースはこのふたつで全部である。
…つか、このHTMLファイル、bodyが空だな。
我ながらヒドいw
とりあえずサンプルはこれ。 まずはJavaScriptから。
いわゆる2次遅れ系というやつで、時系列データのフィルタにも使えるし、UI要素を動かすときもQ(あるいは減衰係数)を調整すれば減衰振動してくれる、何かと使い道が多い処理である。
本当は双一次変換とかプリワーピングとか色々あるのだが、ここではLCRフィルタを数値演算で積算しているだけである。
buildClassとかはなんかクラス作ってるんだなー、程度の知識で大丈夫。
詳しくはプロトタイプによる継承などを見てもらえば。
実行すると黒い縦棒がびよんびよん動くが、だんだん勢いがなくなっていく。
これが減衰振動というやつだが、動いているのは背景を真っ黒に塗られ、ただ一文字[が書いてあるspan要素である(ダークモードだと[の勇姿が見られるだろう)。
インラインスタイルでpositionがabsoluteにしてあって、JavaScript側からe.style.leftを設定すると希望の位置に動かせる、というシカケになっている。
単位としてvwを付けているので、ウィンドウの大きさが変わっても常にウィンドウの真ん中を中心にして振動する。
SVGでグラフを書いてもいいが、動きがあるものの確認にはこれが一番手っ取り早いのではないだろうか。
最近のブラウザはプロトタイピングツールとしても優秀である。
$RAFはrequestAnimationFrame、$IDはdocument.getDocumentByIdのことだったりとかなり酷いことをやってるけど。
そこら辺はcommon.jsを見ていただければ。
これをC++で書くとこうなる。
このソースは大雑把に上半分と下半分に分かれている。
上半分はJavaScriptをC++のコードとして実装した部分である。
下半分はC++のクラスをCから使えるようにラッパーを被せている部分である。
Emscriptenではクラス丸ごとのエクスポートは(今のところ)できないので、メンバ関数ごとにエクスポートすることになる。
そうすると、C++の名前変換(name mangling)がかかってしまい、JavaScriptからインポートするのに苦労するので、extern "C"を付けて名前変換が起きないようにしている。
IIRLPF2_createのような関数を作り、IIRLPF2クラスをnewしてそのポインタを返すのも、C言語の場合と同じである。
ポインタはヒープ先頭からのオフセットを示す整数としてJavaScript側に返され、wasm側がそれをポインタとして受け取れば、元と同じメモリブロックを参照できる、という寸法である。
EMSCRIPTEN_KEEPALIVEはCの関数にだけ付けておけばよい。
他の関数はCの関数から参照されているので、すべて自動的に残る。
これをemcc -o iir-lpf2.wasm --no-entry iir-lpf2.cppとするとiir-lpf2.wasmができるので、それを読み込むHTMLがこれ。
プロミスを待つ部分と、C言語ラッパーを呼ぶ部分が違うくらいで、残りはJavaScriptの場合とほとんど同じである。
今はサンプルということで必要最低限のコードしか書いてないが、実用的にはデストラクタやdeleteの呼び出しが必要であることに注意。
JavaScriptにはデストラクタがないので、注意しないとメモリリークし放題になる。
なお、newを使った途端にHTML側のwasmのロードでエラーが出る場合はEmscriptenのバージョンが古い。
今のところ、Debianのものは古い。
したがってUbuntuのものも古い。
MacのHomebrewは大丈夫である。
ちょっと追いかけたら3.1.59から仕様の変更が入ったようだ。
emsdkを使って最新のEmscriptenをインストールするか、wasm側がJavaScript側の関数をインポートしているために出るエラーなので、wasm-disあたりでインポートしているシンボルを調べて、JavaScript側で空の関数を定義しておけばよい。
instantiateStreamingの第二引数にオプションとして指定することになるが、個人的にはもう二度とやらない気がするので詳細は割愛。
これをJavaScript側でもクラスとして扱えるようにするとこうなる。
wasm読み込みのプロミス待ちが必要になるのと、initにwasmのインスタンスを渡す必要がある以外は、JavaScriptとまったく同じになる。
wasmを読み込んでエクスポートされた関数を持ってくるところは、将来的にクラスが増えたとしても共通になるので、使いまわせるようにまとめてしまおう。
WasmLoaderはちょっとしたメタクラスっぽくなっており、loadを呼ぶと読み込んだwasmオブジェクトを保持するためのクラスを返す。
このクラスを基底クラスとして派生クラスを作り、派生クラスから基底クラスのコンストラクタをきちんと呼び出せば、this.wasmにエクスポートオブジェクトが入る仕掛けである。
このオブジェクトは静的メンバで構わないのだが、this.wasmとアクセスした方が楽なのでインスタンスメンバにしてある。
load関数では先にクラスを作ってローカル変数clsに保持しておき、instantiateStreamingのプロミスが解決するとwasmオブジェクトが得られるので、それをクラスclsの静的メンバとして設定している。
load関数自身はclsをそのまま返している。
cls.wasmを設定するのはプロミスの中だから、この時点ではまだcls.wasmは設定されていないわけだ。
このプロミスが解決するまではinitの中でthis.wasmが設定できないので別途プロミスを待つ必要があり、そのための関数がreadyである。
複数のwasmを読み込んだ場合はプロミスも複数になるため、Promise.allで全部のプロミスが解決するまで待つようになっている。
逆に、ひとつのwasmに複数のクラスが詰め込まれている場合はload関数の戻り値を適当な変数にとっておいて使いまわせばよい。
残りのコードは(字下げを除いて)純粋なJavaScript版とまったく同じになる。
なお、今回は独自のbuildClassという変な関数を使ってクラスを作っているが、最近のJavaScriptのclass構文でも匿名クラスを作って変数に代入することができるので、同様に実装できるはずである。
こうやってグルーコードって増えていくんだなー、と実感した今日この頃。
メモリーはJavaScript上ではArrayBufferで確保されている。
インスタンスの中にmemory.bufferという16MBのオブジェクトがあるのが分かるだろう。
…マテ。 今16MBって言った? 1KBしか使わなくても16MB持っていかれるの?
これはemccでリンクするときに-sTOTAL_MEMORY=64kb -sTOTAL_STACK=32kbなどと指定してやれば減らすことができる。
-sALLOW_MEMORY_GROWTH=1とすると、足りなくなった時に自動で増やしてくれる。
C言語上でのポインタは、JavaScriptではこのArrayBufferのバイトオフセットとして、つまり単純な整数値として得られる。
したがって、Uint8Arrayのような型付き配列か、DataViewを使えばJavaScript側からデータを読み書きできる。
エンディアンはリトルエンディアンらしい。「らしい」というのはここにそう書いてあるけど、確認するすべを持たないからである。
したがって、JavaScriptから読み書きする場合は型付き配列よりDataViewを使った方が安全である。
ちょっと試してみると、NULL(nullptr)は0が返ってくるようだ。
C言語側からreturnで構造体を返すとJavaScript側では結果を得られない。
mallocやnewで構造体を確保し、そのポインタを返すのが楽だろう。
JavaScript側で直接mallocできるわけではないので、構造体を引数として渡す場合も結局C言語側で構造体を確保する必要がある。
そして確保したメモリーはfreeやdeleteしないとメモリリークするので、そのための関数も必要である。
Cの場合は単にfreeする関数ひとつでいいが、C++の場合はデストラクタの都合があるので構造体ごとに用意する必要が出てくるだろう。
JavaScriptでwasmの構造体から値を引き抜く場合、一番簡単確実なのは関数でいっこいっこ抜くことである。
しかし、そこそこフィールドの数がある構造体からデータを引き抜くのに、フィールドごとに関数を作っているのはダルい。
配列ならばthis.wasm.memory.bufferから型付き配列さえ作ってしまえば簡単に読めるので、C側とJavaScript側で申し合わせて配列に詰めてしまうのが一番簡単である。
古き良きアセンブラの時代を思い出す。
まぁ、WebAssemblyだもんな。
でも可読性を上げるには構造体がいい。 ということでやめておけばいいのに作ってしまった。
JavaScriptでC言語の構造体にアクセスする場合、生のオフセットを用いてアクセスする必要があるため、構造体メンバのアライメント(アラインメント)が正確に合っている必要がある。
これはemccのコマンドラインオプションで-fpack-struct=4のように指定できる。
手元の環境ではデフォルトでは8バイトのようだ。
オプション名だけで数値を指定しないと1バイト(詰め物なし)になる。
あと、wasm64ではポインタサイズが8バイトになる。 このふたつの情報がないと構造体のメンバ配置が決まらない。 これはC側にこんな関数を作って対処する。
#include <stddef.h>
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int MEMORY_ALIGN(void) {
return offsetof(struct { char c; double d; }, d);
}
EMSCRIPTEN_KEEPALIVE
int POINTER_SIZE(void) {
return sizeof(void *);
}
MEMORY_ALIGNの方は「なんじゃこりゃぁ!?」感全開だが、構造体の先頭に一番小さい型を置き、2番目に一番大きい型を置くと、一番大きい型のオフセットでアライメントが分かる。
doubleは8バイトあるから、アライメントが8バイトなら構造体先頭から8バイト目に来るが、アライメントが4バイトの場合は4バイト目に来るので区別がつく。
なお、C11・C++11からはalignofという演算子で簡単に知ることができる。
POINTER_SIZEの方は見たまんまである。
構造体はメンバだけでなく、構造体自身のアライメントも考えなければならない。 実は今まで40年近く考えたことがなかったのだが、構造体はその中身によってアライメントが変わる。 いや、配列も変わるのだが、配列の場合は要素型のアライメントと同じになるから気にしなくても問題ないというだけである。 コンパイラにもよるが、構造体のアライメントはメンバのうち、アライメントが一番大きいものと同じになるのが普通である。
例えば、構造体の中身がchar5個ならば、アライメントは1バイトで構造体のサイズは5バイトである。
この構造体の配列を作ると、配列の要素が3個ならば配列のサイズは15バイトになる。
詰め物はまったく入らないわけだ。
ところが、char3個とint16_t1個だと、アライメントは2バイトになり、構造体のサイズは詰め物が入って6バイトになる。
これは実際にsizeof演算子が6バイトという数字を返すはずだ。
じゃないとmallocで配列を確保するときに困ってしまう。
たとえint16_tの前にcharが2個、後ろに1個あるような配置でも、だ。
この場合、構造体の後ろに1バイトの詰め物ができる。
配列にした場合、次の要素となる構造体の先頭もcharだが、この構造体が前に詰められたりはせず、詰め物はそのまま残る。
そりゃそうだろう、詰めてしまったらせっかくのアライメントが台無しである。
したがって、配列のサイズは18バイトになる。
いわゆるプリミティブ型の場合、サイズが分かればMEMORY_ALIGNと比べて小さい方がアライメントサイズになる。
読み込む前にアライメントに合わせてポインタを移動し、読み込んだ後に読んだバイト数だけ移動すれば、その型のアラインメトに合った位置に移動している。
構造体の場合はサイズを調べても無駄で、メンバをすべてスキャンして、その最大のアライメントを求める必要がある。
読み込む前にアライメントに合わせてポインタを移動し、読み込んだ後に読んだバイト数だけ移動するところまではプリミティブ型と同じである。
しかし、構造体の場合、アライメントが2バイトなのに最後のメンバがcharだったりすると中途半端な位置になってしまう。
次に読む値がint16_tならば読む前にアライメント調整が入るが、charだとアライメント調整が入らないから変なところを読んでしまう。
したがって、構造体の場合は読む前と読んだ後にアライメント調整が必要である。
なんかJavaScriptの重箱の隅をくまなくつついたようなコードになってしまった。
まず、$だが、これは例のヤツのfactoryに対応する。
JavaScriptでは$を識別子として使える。
jQueryなんかでおなじみだろう。
というか、もう$IDとかで使いまくっている。
$1文字でも識別子として有効で、staticのSに見えないこともないのでfactoryの代わりに使うことにした。
次にsizeOfValuesオブジェクトのキーがおかしい。
JavaScriptのオブジェクトリテラルはキーに数値を使うことができる。
実際のキーは文字列に変換される。
つまり、sizeOfValues[-8]はsizeOfValues["-8"]と同じ扱いになるということだ。
roundupも変なコードである。
これはszをa単位に切り上げている。
aは2の整数乗でなければならない。
つまり、アライメント調整をしている。
&がなければ結果が0になるのが分かる。
その、引く数の方を下位ビットだけ残しているので、下位ビットが0になり、上位ビットは残る。
&(ビットごと論理積)を取ると無符号整数扱いになるので、実際には値が大きくなって下位ビットが0になるのだから、上位に桁上げが生じる。
これで切り上げになるわけだが、下位ビットが0だった場合は0+0で桁上げが生じず、元の値のままである。
prepareStructで構造体のアライメントを計算し、型情報を保持しているkindというオブジェクトに設定している。
この情報を元にextractStructでmemoryから構造体の値を抜いてくるわけだが、ループにfor inを使っている。
kindに余計な情報を後から設定してしまったが、これも列挙されてしまうのではないか?
結論から言えば列挙されない。
なぜなら、後から付け加えた情報はキーがSymbolだからだ。
キーがSymbolだとfor inでは列挙されない。
また、ユーザーが指定したキーと被らないというメリットもある。
あと、Cの構造体はメンバーの順序が保持されていることになっており、JavaScript側で列挙の順序が変わってしまうと正しく読み出せない。 これも、ES2020あたりから「プロパティの列挙順は、オブジェクトにプロパティを追加した順」と明記されたので、構造体に書いてある順に型情報を書くだけでいい。
現在読んでいるアドレスはthis.pに保持している。
alignは指定されたプリミティブ型のサイズからアライメントを求め、this.pをこれから読む値のアライメントに合わせて、これをローカル変数pに取っておき、this.pは次の読み込み位置まで進めてしまう。
戻り値はローカル変数に取っておいたp、つまり、読み込み開始位置になる。
プリミティブ型はalignを呼び出してその結果を次々とDataViewのgetUint32などの関数に渡していくだけでいい。
ポインタはポインタサイズで示される整数値として読み込む。
あと面倒臭いのは文字列である。
C言語側はUTF-8のゼロ終端文字列と仮定している。
uint8_tとして値を検査していき、文字列のバイト数を求める。
つまり、strlenを計算する。
できたら、デコードする範囲のDataViewを作り、TextDecoderで一気にデコードしてJavaScriptの文字列にする。
配列はgetChildrenを次々と呼び出せばアライメントまできちんと合う。
構造体はあらかじめ計算しておいた値を使ってアライメントを調整し、先ほど説明したとおり、読み込みが終わったらもう一度アライメントを調整する。
使い方は割と簡単で、例えばC言語側が
#include <stdint.h>
struct S {
uint16_t a[5];
uint8_t b;
int8_t c;
double d;
int64_t e;
float f;
struct T {
void *p;
const char *s;
} t;
} s = { ... };
struct S *foo() { return s; }
こんなだったとしたら、以下のように読み込める。
const vw = WasmValueWrapper.create(wasm.instance.exports, {
a: [ 5, 16 ],
b: 8, c: -8, d: "d", e: -1, f: "f",
t: { p: "p", s: "s" }
});
const val = vw.get(wasm.instance.exports.foo());
// val => {
// a: [ #, #, #, #, # ],
// b: #, c: #, d, #.##, e: #, f: #.##,
// t: { p: #, s: "abcdef" }
// }
WasmValueWrapperを作るときにwasmのインスタンスと型定義を渡すと、先ほど説明した構造体アライメントの計算を済ませるようになっている。
構造体のアライメントを決めるためにはすべてのメンバをスキャンしなければならないので、実際に値を読むときにいちいちアライメントを計算するのは不利だ。
こういうのはあらかじめ計算しておいて使い回すに限る。
一度計算してしまえば、同じ構造体ならばその情報を使って何回でも値を読むことができる。
型情報は単純な型はビット数を指定していて、プラスは符号なし、マイナスは符号付きである。
1と-1はJavaScriptのNumberに相当し、53ビットあるいは54ビットを超える値(Number.MAX_SAFE_INTEGERに収まらない値)に対して例外が発生する。
64と-64はBigIntが返ってくる。
"f"はfloat、"d"はdouble、"p"はC言語のポインタだが、JavaScript的にはすべてNumberである。
"s"はchar *のことで、'\0'終端のUTF-8文字列とみなして、JavaScriptのStringに変換される。
配列は最初の要素が要素数、次の要素が型情報で、配列の配列や構造体の配列も可能である。
構造体はオブジェクトとして表現している。
なんとなく作っているうちに、ほとんど共通なコードで書き込みもできることに気がついてしまったのでputも作ってしまった。
文字列はC言語側のmallocを呼ぶ必要があるため、wasm側でMALLOCという名前でエクスポートしておく必要がある。
また、不要になったらどこかでfreeする必要がある。
25 Jan 2025: 「メモリーと構造体」を追加
20 Jan 2025: 「C++の場合」を追加
14 Jan 2025: 新規作成
ご意見・ご要望の送り先は あかもず仮店舗 の末尾をご覧ください。
Copyright (C) 2024-2025 akamoz.jp