デバッグ

クロスプラットフォームのEmscriptenコードをデバッグする主な利点の1つは、同じクロスプラットフォームのソースコードをネイティブプラットフォームでデバッグするか、デバッガ、プロファイラ、その他のツールを含む、ブラウザのますます強力になっているツールセットを使用してデバッグできることです。

Emscriptenは、デバッグを支援するための多くの機能とツールを提供します。

  • コンパイラのデバッグ情報フラグにより、コンパイル済みコードにデバッグ情報を保持し、ソースマップを作成して、ブラウザでデバッグする際にネイティブC++ソースコードをステップ実行できます。

  • デバッグモードは、デバッグログを出力し、分析のための中間ビルドファイルを保存します。

  • コンパイラ設定により、メモリアクセスと一般的な割り当てエラーの実行時チェックを有効にできます。

  • 手動によるprintデバッグもサポートされており、ネイティブプラットフォームよりも優れている点もあります。

  • AutoDebuggerは、メモリへの各ストアを書き出すようにLLVMビットコードを自動的にインストルメントします。

この記事では、Emscriptenが提供する主要なツールと設定について説明し、いくつかのEmscripten固有の問題のデバッグ方法を説明するセクションを含みます。

ブラウザでのデバッグ

emccは、DWARFシンボルまたはソースマップのいずれかの形式でデバッグ情報を出力できます。どちらも、ブラウザのデバッガでC/C++ソースコードを表示してデバッグできます。DWARFは、最も正確で詳細なデバッグエクスペリエンスを提供し、拡張機能 <https://goo.gle/wasm-debugging-extension> を使用してChrome 88で実験的にサポートされています。こちら <https://developer.chrome.com/blog/wasm-debugging-2020/> に詳しい使用方法ガイドがあります。ソースマップはFirefox、Chrome、Safariでより広くサポートされていますが、DWARFとは異なり、変数の検査などはできません。

emccは、デフォルトで最適化されたビルドからデバッグ情報のほとんどを削除します。DWARFは、emcc-gフラグで生成でき、ソースマップは-gsource-mapオプションで出力できます。-O1以上の最適化レベルでは、LLVMデバッグ情報がますます削除され、ランタイムASSERTIONチェックも無効化されることに注意してください。-gフラグを渡すと、生成されたJavaScriptコードにも影響し、空白、関数名、変数名が保持されます。

ヒント

中規模のプロジェクトでも、DWARFデバッグ情報はかなりのサイズになり、特にモジュールのコンパイルとロードのパフォーマンスに悪影響を与える可能性があります。デバッグ情報は、-gseparate-dwarfオプションを使用して、代わりに別のファイルに出力することもできます!デバッグ情報のサイズは、すべてのオブジェクトファイルのデバッグ情報をリンクする必要もあるため、リンク時間にも影響します。-gsplit-dwarfオプションを使用すると、clangがデバッグ情報をオブジェクトファイル全体に分散させるため、役立ちます。そのデバッグ情報は、次にemdwpツールを使用してDWARFパッケージファイル(.dwp)にリンクする必要がありますが、これはコンパイル済み出力のリンクと並行して行うことができます!リンク後に実行する場合は、emdwp -e foo.wasm -o foo.wasm.dwp、または-gseparate-dwarfと一緒に使用する場合(dwpファイルは、.dwp拡張子が追加されたメインシンボルファイルと同じファイル名にする必要があります)はemdwp -e foo.debug.wasm -o foo.debug.wasm.dwpと同じくらい簡単です。

-gフラグは、整数レベルでも指定できます。-g0-g1-g2-gsource-mapを使用する場合のデフォルト)、-g3-gを使用する場合のデフォルト)。各レベルは、最後のレベルに基づいて、コンパイル済み出力に徐々に多くのデバッグ情報を提供します。

注意

Binaryenの最適化によりDWARF情報の品質がさらに低下するため、-O1 -gは、他のオプションで必要とされない限り、Binaryenオプティマイザ(wasm-opt)の実行を完全にスキップします。デバッグ情報が保持されるようにするには、-sERROR_ON_WASM_CHANGES_AFTER_LINKオプションを追加することもできます。Binaryenのスキップの詳細については、こちらを参照してください。

注意

Binaryenオプティマイザ(実行する場合でも)とJavaScriptオプティマイザの両方で、デバッグフラグと組み合わせて使用​​する場合、一部の最適化が無効になる場合があります。たとえば、-O3 -gでコンパイルする場合、Binaryenオプティマイザは、有効なDWARF情報を生成しない最適化パスの一部をスキップし、要求されたデバッグ情報をより適切に提供するために、通常のJavaScript最適化の一部も無効になります。

デバッグモード(EMCC_DEBUG)

EMCC_DEBUG環境変数を設定して、Emscriptenのデバッグモードを有効にできます。

# Linux or macOS
EMCC_DEBUG=1 emcc test/hello_world.cpp -o hello.html

# Windows
set EMCC_DEBUG=1
emcc test/hello_world.cpp -o hello.html
set EMCC_DEBUG=0

EMCC_DEBUG=1を設定すると、emccはデバッグ出力を出力し、コンパイラのさまざまな段階の中間ファイルを生成します。EMCC_DEBUG=2は、さらに各JavaScriptオプティマイザパスの中間ファイルを生成します。

デバッグログと中間ファイルは、TEMP_DIR/emscripten_tempに出力されます。ここでTEMP_DIRはOSのデフォルトの一時ディレクトリです(例:UNIXでは/tmp)。

デバッグログを分析して、各ステップで行われた変更をプロファイリングおよびレビューできます。

注意

より限定的なデバッグ情報を、詳細出力コンパイラフラグ(emcc -v)を指定することでも有効にできます。

コンパイラ設定

Emscriptenには、デバッグに役立つ多くのコンパイラ設定があります。これらはemcc -sオプションを使用して設定され、最適化フラグを上書きします。例えば

emcc -O1 -sASSERTIONS test/hello_world

重要な設定には以下があります。

  • ASSERTIONS=1は、一般的なメモリ割り当てエラー(例えば、割り当てられたメモリよりも多くのメモリへの書き込み)に対するランタイムチェックを有効にするために使用されます。また、Emscriptenがプログラムフローのエラーをどのように処理するかも定義します。より詳細なテストを実行するには、値をASSERTIONS=2に設定できます。

    ASSERTIONS=1はデフォルトで有効になっています。アサーションは、最適化されたコード(-O1以上)に対してはオフになります。

  • SAFE_HEAP=1は追加のメモリアクセスのチェックを追加し、0の参照外しやメモリアライメントの問題などの問題について明確なエラーを出力します。

    SAFE_HEAP操作をログに記録するには、SAFE_HEAP_LOGを設定することもできます。

  • STACK_OVERFLOW_CHECK=1リンカーフラグを渡すと、スタックの最後にランタイムマジックトークン値が追加されます。これは特定の場所でチェックされ、ユーザーコードがスタックの終端を超えて誤って書き込まれていないことを確認します。EmscriptenスタックのオーバーランはJavaScript(影響を受けない)にとってセキュリティ上の問題ではありませんが、スタックを超えて書き込むと、Emscripten HEAP内のグローバルデータと動的に割り当てられたメモリセクションでメモリ破損が発生し、アプリケーションが予期しない方法で失敗します。STACK_OVERFLOW_CHECK=2は、わずかに詳細なスタックガードチェックを有効にし、パフォーマンスを犠牲にしてより正確なコールスタックを提供できます。デフォルト値は、ASSERTIONS=1が設定されている場合は1、それ以外の場合は無効です。

その他の多くの便利なデバッグ設定は、src/settings.jsで定義されています。詳細については、そのファイルで「check」と「debug」というキーワードを検索してください。

サニタイザ

Emscriptenは、Undefined Behaviour SanitizerAddress Sanitizerなど、Clangの一部サニタイザもサポートしています。

emcc詳細出力

emcc -vでコンパイルすると、Emscriptenは実行するサブコマンドと、Clangに-vを渡すことを出力します。

手動プリントデバッグ

ソースコードにprintf()文を手動で挿入し、コードをコンパイルして実行して問題を調査することもできます。printf()はラインバッファリングされるため、コンソールに出力を見るには\nを追加してください。

問題のある行が分かっている場合は、JavaScriptにprint(new Error().stack)を追加して、その時点でのスタックトレースを取得できます。

デバッグプリントアウトは、任意のJavaScriptを実行することもできます。例えば

function _addAndPrint($left, $right) {
  $left = $left | 0;
  $right = $right | 0;
  //---
  if ($left < $right) console.log('l<r at ' + stackTrace());
  //---
  _printAnInteger($left + $right | 0);
}

Chrome DevToolsを使用したデバッグ

Chrome DevToolsは、DWARF情報を含むWebAssemblyファイルでのソースレベルデバッグをサポートしています。それを使用するには、次のWasmデバッグ拡張機能プラグインが必要です。https://goo.gle/wasm-debugging-extension

詳細はDebugging WebAssembly with modern toolsを参照してください。

JavaScriptからのC++例外の処理

JavaScriptからのC++例外の処理を参照してください。

Emscripten固有の問題

メモリアライメントの問題

Emscriptenメモリ表現はCとC++と互換性があります。ただし、未定義の動作が関係している場合、ネイティブアーキテクチャとの違い、そしてasm.jsとWebAssemblyに対するEmscriptenの出力の違いが見られる場合があります。

  • asm.jsでは、ロードとストアはアライメントされている必要があり、アライメントされていないアドレスで通常のロードまたはストアを実行すると、サイレントに失敗する可能性があります(間違ったアドレスにアクセスします)。コンパイラがロードまたはストアがアライメントされていないことを認識している場合、動作しますが遅い方法でエミュレートできます。

  • WebAssemblyでは、アライメントされていないロードとストアは動作します。それぞれに期待されるアライメントが注釈として付けられます。実際の配置が一致しない場合でも機能しますが、一部のCPUアーキテクチャでは遅くなる可能性があります。

ヒント

SAFE_HEAPを使用して、メモリアライメントの問題を明らかにすることができます。

一般的に、アライメントされていない読み取りと書き込みは避けるのが最善です。上記のように、多くの場合、それらは未定義の動作の結果として発生します。しかし、場合によっては避けられないこともあります。例えば、移植するコードが、既存のデータ形式でパックされた構造体からintを読み取る場合です。その場合、asm.jsで正しく動作し、WebAssemblyで高速にするには、コンパイラがロードまたはストアがアライメントされていないことを認識していることを確認する必要があります。そのためには

  • 個々のバイトを手動で読み取り、完全な値を再構築します。

  • emscripten_align*typedefを使用します。これらは、基本型(shortintfloatdouble)のアライメントされていないバージョンを定義します。これらの型に対するすべての操作は完全にアライメントされていません(ほとんどの場合、1のバリアントを使用します。これは、アライメントがまったくないことを意味します)。

関数ポインタの問題

関数ポインタ呼び出しからnullFuncまたはb0またはb1へのabort()が発生した場合(「不正な関数ポインタ」というエラーメッセージが表示される場合もあります)、問題は、呼び出されたときに関数ポインタが予期される関数ポインタテーブルに見つからなかったことです。

注意

nullFuncは、関数ポインタテーブル(b0b1は、より最適化されたビルドでnullFuncに使用されるより短い名前です)の空のインデックスエントリを埋めるために使用される関数です。無効なインデックスへの関数ポインタはこの関数を呼び出し、これは単にabort()を呼び出します。

考えられる原因はいくつかあります。

  • コードは、別の型からキャストされた関数ポインタを呼び出しています(これは未定義の動作ですが、現実世界のコードで発生します)。最適化されたEmscripten出力では、各関数ポインタ型は元のシグネチャに基づいて個別のテーブルに格納されるため、正しい動作を得るには、同じシグネチャを使用して関数ポインタを呼び出す必要があります(詳細については、コード移植性のセクションの関数ポインタの問題を参照してください)。

  • コードはNULLポインタでメソッドを呼び出しているか、0を参照解除しています。この種のバグは、あらゆる種類のコーディングエラーによって発生する可能性がありますが、ランタイム時に関数テーブルで関数が検出できないため、関数ポインタエラーとして現れます。

これらの問題をデバッグするには

  • -Werrorでコンパイルします。これにより、警告がエラーに変換されます。これは、一部の未定義の動作の場合、警告が表示されないため、役立つ場合があります。

  • 呼び出されている関数ポインタとその型に関する有用な情報を取得するには、-sASSERTIONS=2を使用します。

  • ブラウザのスタックトレースを見て、エラーが発生した場所と、どの関数を呼び出すべきだったかを確認します。

  • -Wcast-function-typeを使用して、危険な関数ポインタキャストに関するclangの警告を有効にします。

  • SAFE_HEAP=1でビルドします。

  • サニタイザを使用したデバッグは、特にUBSanにおいて役立ちます。

別の関数ポインタの問題は、間違った関数が呼び出された場合です。SAFE_HEAP=1は、関数テーブルへのアクセスに関連する可能性のあるエラーを検出するため、これにも役立ちます。

無限ループ

無限ループにより、ページがハングします。しばらくすると、ブラウザはユーザーにページがハングしていることを通知し、停止または閉鎖するよう促します。

コードが無限ループに陥った場合、問題のあるコードを見つける簡単な方法の1つは、*JavaScriptプロファイラ*を使用することです。Firefoxプロファイラでは、コードが無限ループに入ると、プロファイルの最後に、同じことを繰り返し行っているコードブロックが表示されます。

注意

アプリケーションが無限のメインループを使用している場合、ブラウザのメインループを再コーディングする必要がある場合があります。

プロファイリング

速度

コードの速度をプロファイリングするには、プロファイリング情報を使用してビルドし、ブラウザのDevToolsプロファイラでコードを実行します。その後、最も多くの時間が費やされている関数を表示できるはずです。

メモリ

ブラウザのメモリプロファイリングツールは、一般的にJavaScriptレベルでの割り当てしか理解しません。その観点から、emscriptenでコンパイルされたアプリケーションが使用する線形メモリ全体は、単一の大きな割り当て(WebAssembly.Memory)です。DevToolsは、そのオブジェクト内の使用状況に関する情報は表示されません。そのため、他のツールが必要になります。次に説明します。

Emscriptenはmallinfo()をサポートしており、現在のメモリ割り当てに関する情報をdlmallocから取得できます。使用例については、このテストを参照してください。

Emscriptenには、メモリ使用状況を視覚的に表示する--memoryprofilerオプションもあります。これにより、メモリ断片化の程度などを確認できます。使用方法の例は以下の通りです。

emcc test/hello_world.c --memoryprofiler -o page.html

メモリプロファイラの出力はページ上に表示されるため、上記例のようにHTMLを出力する必要があることに注意してください。page.htmlをブラウザで読み込むことで確認できます(ローカルWebサーバーを使用することを忘れないでください)。表示は自動更新されるため、開発者ツールコンソールを開いて_malloc(1024 * 1024)のようなコマンドを実行できます。これにより1MBのメモリが割り当てられ、メモリプロファイラの表示に反映されます。

AutoDebugger

AutoDebuggerは、Emscriptenコードをデバッグするための「最終手段」です。

警告

このオプションは主にEmscriptenコア開発者向けです。

AutoDebuggerは出力を書き換え、メモリへの各ストアを出力します。これは、異なるコンパイラ設定の出力を比較して回帰を検出するために役立ちます。

AutoDebuggerは生成されたコード内の**あらゆる**問題を潜在的に見つけることができるため、CHECK_*設定やSAFE_HEAPよりも厳密に強力です。AutoDebuggerの用途の1つは、大量のログ出力を迅速に生成することであり、これにより異常な動作を確認できます。AutoDebugger回帰のデバッグにも特に役立ちます。

AutoDebuggerにはいくつかの制限があります。

  • 大量の出力が生成されます。変更点を特定するには、diffツールが非常に役立ちます。

  • ポインタアドレスではなく、単純な数値を出力します(ポインタアドレスは実行ごとに変化するため、比較できません)。これは、アドレスの検査によってポインタアドレスが0または非常に大きな値であることがわかるエラーがある場合に制限となります。tools/autodebugger.pyで、アドレスを整数として出力するようにツールを変更できます。

AutoDebuggerを実行するには、環境変数EMCC_AUTODEBUG=1を設定してコンパイルします。例:

# Linux or macOS
EMCC_AUTODEBUG=1 emcc test/hello_world.cpp -o hello.html

# Windows
set EMCC_AUTODEBUG=1
emcc test/hello_world.cpp -o hello.html
set EMCC_AUTODEBUG=0

AutoDebugger回帰ワークフロー

AutoDebuggerを使用して回帰を見つけるには、次のワークフローを使用してください。

  • 環境変数EMCC_AUTODEBUG=1を設定して、動作するコードをコンパイルします。

  • 環境変数EMCC_AUTODEBUG=1を設定して、再度コードをコンパイルします。ただし、今回は回帰を引き起こす設定を使用します。この手順の後、回帰前と回帰後の2つのビルドが得られます。

  • コンパイルされたコードの両バージョンを実行し、その出力を保存します。

  • diffツールを使用して出力を比較します。

出力の差異は、バグによって引き起こされる可能性が高いです。

注意

-sDETERMINISTICを使用すると、タイミングなどの問題によって偽陽性が出ないようにできます。

ヘルプが必要ですか?

Emscriptenテストスイートには、Emscriptenが提供するほぼすべての機能の良い例が含まれています。問題が発生した場合は、スイートを検索して、同様の動作を持つテストコードが実行できるかどうかを確認することをお勧めします。

ここで説明したアイデアを試してもさらにヘルプが必要な場合は、お問い合わせください