初めに
私はペアリング暗号と呼ばれる暗号のライブラリmclを開発しています。
ペアリング暗号は秘密分散機能を持ったBLS署名(bls, 解説記事)や暗号化したまま内積演算が可能なライブラリなどに利用されます。
この記事では、私がC++/asmで記述されたmclをライブラリをWebAssembly(以下wasm)で使えるようにしたときに悩んだいくつかのことを紹介します。ブラウザで実際に動くデモは(demo)からたどれます。
ツールとコンパイル
ツールは日々改良、更新されてすぐ情報が古くなってしまいます。
現時点(2017/12/3)でC++でwasm開発をするにはEmscripten SDKにしたがってemccをインストールするのがよいようです。様々な拡張子や変換ツールがあって混乱しやすいのですが、全体像を把握するには@chikoskiさんのWASM1.0リリース記念(?)最近の状況アップデートの10ページの図がよいでしょう。
関数のexport
C++で作った関数をexportする方法は2種類あります。
どちらもexportされた関数は関数名の最初に_
がつきます。
コンパイラオプションで指定する
一つはemccの-s "EXPORTED_FUNCTIONS=[関数名1,関数名2, ...]"
オプションを使ってexportしたい関数を明示する方法です。
当初、私はヘッダファイルから関数名を抽出してMakefileにオプションを設定するスクリプトファイルを作って対応していました。しかしファイルが複数必要になるので少々面倒でした。
ヘッダで指定する
もう一つはC++のヘッダファイルに__attribute__((used))
をつける方法です。これはemscripten.h
でEMSCRIPTEN_KEEPALIVE
というマクロ名で定義されているのでそれをincludeして使うのもありです。
コンパイラに何も指定しなくてよくなるので便利だと思います。今は
#ifdef __EMSCRIPTEN__
#define MCLSHE_DLL_API __attribute__((used))
#else
#define MCLSHE_DLL_API
#endif
MCLSHE_DLL_API int sheSecretKeySetByCSPRNG(sheSecretKey *sec);
のようにして使っています。
バイト列のやりとり
wasmで作られた関数にJSの配列は直接渡せません。一度mod._mallocした領域を渡す必要があります。
たとえばa = Uint8Array(...)を関数funcに渡すには
const p = mod._malloc(a.length)
mod.HEAP8.set(a, p)
func(p)
と、mod._mallocした領域にaの内容をコピーしてから関数に渡します。確保したp
は不要になったらmod._free(p)
しなければなりません。
p
は配列ではなく配列のoffsetです。p[i]
でアクセスできないので要注意です。
確保した領域を一時的に使いたいだけなら、mallocではなくスタックを使う方法もあります。
stackAlloc
で確保してstackRestore
で解放します。
const stack = mod.Runtime.stackSave()
const p1 = mod.Runtime.stackAlloc(buf1.length)
const p2 = mod.Runtime.stackAlloc(buf2.length)
mod.HEAP8.set(buf1, p1)
mod.HEAP8.set(buf2, p2)
func(p1, p2)
mod.Runtime.stackRestore(stack)
p1
, p2
まとめて解放されます。
JSの文字列をC++側に渡す
JSの文字列も一度コピーしなくてはなりません。具体的には文字列s
がASCII文字列と分かっているなら上記方法で確保した領域にs.charCodeAt(i)
をコピーすればよいです。
const AsciiStrToMem = function(p, s) {
for (let i = 0; i < s.length; i++) {
mod.HEAP8[p + i] = s.charCodeAt(i)
}
}
一般のUTF-16文字列をUTF-8に変換して渡したい場合はmod.StringToUTF8Arrayなどを使うとよさそうです。
余談ですがpreamble.jsやruntime.jsにはいろいろ便利そうな関数や実装方法の理解の助けになるものがあります。
wasmファイルの位置指定(Node.js用)
Node.jsで利用するときに、インストールしたときに同じディレクトリにあるwasmファイルを呼ぶにはmod.wasmBinaryFile
を設定しなければなりません。たとえばshe_c.wasm
というファイルを読み込みたい場合は
if (typeof __dirname === 'string') {
Module = {}
Module.wasmBinaryFile = __dirname + '/she_c.wasm'
}
というpre.js
ファイルを作り、emccのオプションに--pre-js ffi/js/pre.js
を追加します。
Node.jsから呼ばれたときのみModule
を設定するためにif (typeof ...)
で判定しています。
64bit整数の扱いに注意
たとえばexportする関数が
void func(long x);
でJSからmod._func(x);
と呼び出したとします。
Cの関数を呼ぶ前にx | 0
の処理が行われるため0xffffffffを超える値は切り捨てられます。また-1を渡すと0x00000000ffffffffになりint64_t(-1)
とは異なってしまいます。
exportする関数の引数や戻り値の型はlong(この場合int64_t)を使わず、intかuint32_tなどにするほうがよいでしょう。
C++からJSの関数を呼ぶ
これも大きく二つの方法があります。
emscripten.h
のEM_ASM
マクロを使う
このマクロの実体は
template <typename... Args> int emscripten_asm_const_int(const char* code, Args...);
という関数で第1引数にJSのコードの文字列、残りの引数にその文字列に渡す変数が入ります。
たとえばx + yを返すJSの関数をCの中で呼ぶには
#include <emscripten.h>
int callJS(int x, int y)
{
return EM_ASM_INT((return $0 + $1), x, y);
}
とします。複雑なコードを書くとエラーになるのでJS側に処理を書いてC側からは呼び出すだけにした方が無難かもしれません。
mod.callAdd1 = function(x, y) {
return x + y
}
void fill2(int x, int y)
{
EM_ASM((mod.callAdd($0, $1)), x, y);
}
整数を返す関数を使いたい場合はEM_ASM_INT
を使います。
JSで書いたコードをextern関数として呼び出す
JSで書いたコードを前述の--pre-js
オプションでemccの生成コードの中に埋め込み、それをCから呼びます。
var _call2JS = function(x, y) { /* _が必要 */
return x + y
}
extern int call2JS(int x, int y);
int call2(int x, int y)
{
return call2JS(x, y);
}
ここで一つ注意点があります。
どうやら現時点(emcc 1.37.22)でpre.jsにはECMAScript 2015は記述できないようで、たとえばlet
を使うとパーサーがエラーを出します。気を付けましょう。
LLVMフルサポートではない
こちらも徐々に対応されると思いますが、整数演算命令に限ってもLLVMの機能を全て使えるわけではありません。
たとえばLLVMでは256bit整数レジスタ(i256)などがあるのですが、数カ月前にadd i256
を試したときは
LLVM ERROR: Binary operator type not yet supported
for integer types larger than 64 bits
というエラーになりました。
この命令については1カ月ほど前に対応されましたが、それ以外にも対応していない機能がいろいろあるようです。
mclではARM64などのCPU向けにLLVMで記述した多倍長演算ライブラリを提供しているのですが、wasmでは動きません(今はwasm版はpure C++で対応しています)。
現在は対応していない部分を少しずつ書き換えてwasm版を作ろうとしているところですが、コストを考えるとツールが対応するのを待つか悩ましいところです。
また現時点でのwasmアーキテクチャには2個の64bit整数の乗算結果を128bitで返す命令がありません。そのためそれに対応するLLVMコードもエラーになります。こちらはwasmが対応しないとLLVMが対応する望みは薄いでしょう。
まとめ
C/C++がwasmを使うときにはまりそうなポイントをいくつか紹介しました。
ツールの改善、変更が多く、(この文章を含む)ドキュメントがすぐ古くなるのが難ですが、メモリまわりと呼び出し方を押さえておけば、DLL作成に慣れている人は手を出しやすいと思います。