Emscriptenの紹介ページを見ると、巨大な実行用HTMLやらグルーJSやらがくっついているhello worldのサンプルが載っている。 しかし、WebAssembly(以下面倒なのでwasm)を使いたい場合というのは、時間のかかるデータ処理などをC言語でやらせて、結果をJavaScriptで表示するという用途が圧倒的に多いはずで、C言語でブラウザにhello Worldを表示したいわけではない。 もっと気の利いた小さいサンプルが欲しい、というのがこのページの趣旨。
そう考えると、取り合えず何かデータをC言語側に渡し、戻り値を受け取ってconsole.log
で表示できればそれで十分なわけだ。
最近のブラウザでは驚くほど短く書ける。
C言語のソースはこんなの。
#ifdef EMSCRIPTEN #include#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
という16MBのオブジェクトがあるのが分かるだろう。
…マテ。 今16MBって言った? 1KBしか使わなくても16MB持っていかれるの?
これはemcc
でリンクするときに-sTOTAL_MEMORY=64kb -sTOTAL_STACK=32kb
などと指定してやれば減らすことができる。
-sALLOW_MEMORY_GROWTH=1
とすると、足りなくなった時に自動で増やしてくれる。
C言語上でのポインタは、JavaScriptではこのArrayBuffer
のバイトオフセットとして、つまり単純な整数値として得られる。
したがって、Uint8Array
のような型付き配列か、DataView
を使えばJavaScript側からデータを読み書きできる。
エンディアンはリトルエンディアンらしい。「らしい」というのはここにそう書いてあるけど、確認するすべを持たないからである。
したがって、JavaScriptから読み書きする場合は型付き配列よりDataView
を使った方が安全である。
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年近く考えたことがなかったのだが、構造体はその中身によってアライメントが変わる。 いや、配列も変わるのだが、配列の場合は要素型のアライメントと同じになるから気にしなくても問題ないというだけである。 コンパイラにもよるが、構造体のアライメントはメンバのうち、アライメントが一番大きいものと同じになるのが普通である。
例えば、構造体の中身がchar
5個ならば、アライメントは1バイトで構造体のサイズは5バイトである。
この構造体の配列を作ると、配列の要素が3個ならば配列のサイズは15バイトになる。
詰め物はまったく入らないわけだ。
ところが、char
3個とint16_t
1個だと、アライメントは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