IL2CPPの内部:生成コードのデバッグのヒント

これはIL2CPP内部シリーズの3番目のブログ記事です。この投稿では、IL2CPPで生成されたC++コードのデバッグを少し簡単にするためのヒントを探ります。ブレークポイントを設定し、文字列やユーザー定義型の内容を表示し、例外が発生する場所を特定する方法を紹介する。
ここでは、.NETのILコードから生成されたC++コードをデバッグしていると考えてください。そのため、デバッグは快適な経験ではないだろう。しかし、これらのヒントをいくつか使えば、Unityプロジェクトのコードが実際のターゲットデバイス上でどのように実行されるかについて、意味のある洞察を得ることが可能です(マネージドコードのデバッグについては、記事の最後で少しお話しします)。
また、あなたのプロジェクトで生成されるコードは、このコードとは異なることを覚悟してください。Unityの新しいバージョンが出るたびに、生成されるコードをより良く、より速く、より小さくする方法を模索しています。
セットアップ
この投稿では、OSX上のUnity 5.0.1p3を使用しています。生成されたコードについての投稿と同じサンプル・プロジェクトを使うが、今回はIL2CPPスクリプト・バックエンドを使ってiOSターゲット用にビルドする。前の記事でやったように、「開発プレイヤー」オプションを選択してビルドすると、il2cpp.exeはILコードの名前に基づいて型名とメソッド名を持つC++コードを生成する。
UnityがXcodeプロジェクトを生成し終わったら、それをXcodeで開き(私はバージョン6.3.1を持っているが、最近のバージョンなら何でも動くはずだ)、ターゲット・デバイス(iPad Mini 3だが、iOSデバイスなら何でも動くはずだ)を選択し、Xcodeでプロジェクトをビルドすることができる。
ブレークポイントの設定
プロジェクトを実行する前に、まずHelloWorldクラスのStartメソッドの先頭にブレークポイントを設定する。前の記事で見たように、生成されたC++コードにおけるこのメソッドの名前はHelloWorld_Start_m3である。Cmd+Shift+Oを使い、このメソッド名をXcodeに入力し、ブレークポイントを設定する。

また、XCodeでDebug > Breakpoints > Create Symbolic Breakpointsを選択し、このメソッドでブレークするように設定することもできる。

今Xcodeプロジェクトを実行すると、メソッドの開始ですぐにブレークするのがわかる。
メソッド名がわかれば、このように生成されたコード内の他のメソッドにブレークポイントを設定できる。Xcodeでは、生成されたコードファイルの特定の行にブレークポイントを設定することもできる。実際、生成されたファイルはすべてXcodeプロジェクトの一部である。プロジェクト・ナビゲーターのClasses/Nativeディレクトリにあります。

文字列の表示
XcodeでIL2CPP文字列の表現を表示するには、2つの方法があります。文字列のメモリを直接表示することもできるし、libil2cpp の文字列ユーティリティを呼び出して文字列を std::string に変換し、Xcode が表示できるようにすることもできる。stringLiteral1という文字列の値を見てみよう(ネタバレ注意:その内容は "Hello, IL2CPP!")。
Ctagsをビルドして(またはXcodeでCmd+Ctrl+Jを使って)生成されたコードでは、_stringLiteral1の定義にジャンプして、その型がIl2CppString_14であることを確認できる:
serializers.types`プロップでシリアライザーを指定してください。
実際、IL2CPPの文字列はすべてこのように表現される。Il2CppStringの定義はobject-internals.hヘッダーファイルにある。これらの文字列には、IL2CPP の管理型である Il2CppObject (Il2CppDataSegmentString typedef でアクセスされる) の標準的なヘッダ部分、4 バイト長、2 バイト文字の配列が含まれる。stringLiteral1のようにコンパイル時に定義された文字列は固定長の文字配列で終わるが、実行時に作成された文字列は割り当てられた配列になる。文字列の文字はUTF-16としてエンコードされる。
Xcodeのウォッチ・ウィンドウに_stringLiteral1を追加すれば、View Memory of "_stringLiteral1 "オプションを選択し、メモリ上の文字列のレイアウトを見ることができる。

メモリ・ビューアでは、このように表示される:

文字列のヘッダー・メンバーは16バイトなので、それをスキップして、サイズの4バイトの値が0x000E(14)であることがわかる。長さの次のバイトは、文字列の最初の文字、0x0048('H')である。各文字は2バイト幅だが、この文字列ではすべての文字が1バイトに収まるので、Xcodeは各文字の間に点を入れて右側に表示する。それでも、文字列の内容ははっきりと見える。この方法で文字列を見ることはできるが、より複雑な文字列を見るには少々難しい。
Xcodeのlldbプロンプトから文字列の内容を見ることもできる。utils/StringUtils.hヘッダは、libil2cppのいくつかの文字列ユーティリティのインターフェイスを提供する。具体的には、lldbプロンプトからUtf16ToUtf8メソッドを呼び出してみよう。インターフェイスはこんな感じだ:
serializers.types`プロップでシリアライザーを指定してください。
このメソッドにC++構造体のcharsメンバを渡すと、UTF-8にエンコードされたstd::stringが返される。そして、lldbプロンプトでpコマンドを使えば、文字列の内容を表示することができる。
serializers.types`プロップでシリアライザーを指定してください。
ユーザー定義タイプの表示
また、ユーザー定義のタイプの内容を見ることもできる。このプロジェクトの簡単なスクリプト・コードでは、InstanceIdentifierというフィールドを持つImportantというC#型を作成した。スクリプトでImportant型の2つ目のインスタンスを作成した直後にブレークポイントを設定すると、生成されたコードが予想通りInstanceIdentifierの値を1に設定していることがわかる。

そのため、生成されたコードでユーザー定義型の内容を見ることは、XcodeのC++コードで通常行うのと同じ方法で行われる。
生成されたコードにおける例外のブレーク
バグの原因を突き止めようと、生成されたコードをデバッグしている自分に気づくことがよくある。多くの場合、これらのバグは管理された例外として現れる。前回の投稿で説明したように、IL2CPPはC++の例外を使用して管理例外を実装しているので、Xcodeで管理例外が発生した場合、いくつかの方法でブレークすることができる。
管理例外がスローされたときにブレークする最も簡単な方法は、 il2cpp.exeで管理例外が明示的にスローされたときに使われる il2cpp_codegen_raise_exception関数にブレークポイントを設定することである。

プロジェクトを実行させると、StartのコードがInvalidOperationException例外をスローしてXcodeが壊れてしまう。これは、文字列のコンテンツを見ることが非常に役立つ場所である。ex引数のメンバを調べてみると、___message_2メンバがあり、これは例外のメッセージを表す文字列であることがわかる。

少しいじれば、この文字列の値を表示して、何が問題なのかを確認することができる:
serializers.types`プロップでシリアライザーを指定してください。
ここでの文字列は上記と同じレイアウトだが、生成されるフィールドの名前は若干異なることに注意。charsフィールドは___start_char_1という名前で、型はuint16_t[]ではなくuint16_tである。しかし、これはまだ配列の最初の文字なので、そのアドレスを変換関数に渡すことができる。
しかし、すべてのマネージド例外が、生成されたコードによって明示的にスローされるわけではない。libil2cpp のランタイムコードは、場合によってはマネージド例外を投げるが、 そのために il2cpp_codegen_raise_exception を呼ぶことはない。このような例外をどのようにキャッチすればよいのだろうか?
XcodeでDebug > Breakpoints > Create Exception Breakpointを使い、ブレークポイントを編集すれば、C++例外を選択し、Il2CppExceptionWrapper型の例外がスローされたときにブレークできる。このC++型は、すべての管理例外をラップするために使われるので、すべての管理例外をキャッチすることができる。

スクリプトのStartメソッドの先頭に次の2行のコードを追加して、これが機能することを証明しよう:
serializers.types`プロップでシリアライザーを指定してください。
この2行目でNullReferenceExceptionがスローされる。例外ブレークポイントを設定したXcodeでこのコードを実行すると、例外がスローされたときにXcodeが確かにブレークすることがわかります。しかし、ブレークポイントはlibil2cppのコード内にあるので、見えるのはアセンブリコードだけだ。コールスタックを見てみると、数フレーム上のNullCheckメソッドに移動する必要があることがわかる。NullCheckメソッドはil2cpp.exeによって生成されたコードに注入される。

そこからもう1フレーム上に戻り、Important型のインスタンスが確かにNULLの値を持っていることを確認できる。

結論
生成されたコードをデバッグするためのいくつかのヒントを説明した後、IL2CPPによって生成されたC++コードを使用して、起こりうる問題を追跡する方法について理解を深めていただければと思います。IL2CPPが使用する他の型のレイアウトを調査して、生成されたコードのデバッグ方法についてもっと学ぶことをお勧めする。
IL2CPPのマネージドコード・デバッガーはどこにある?デバイス上でIL2CPPスクリプトのバックエンドを経由して実行されているマネージドコードをデバッグすべきではないのか?実際、これは可能だ。現在、IL2CPPの内部でアルファ品質のマネージドコードデバッガを持っている。まだリリースの準備はできていないが、我々のロードマップには載っているので、期待していてほしい。
このシリーズの次の投稿では、IL2CPPスクリプト・バックエンドがマネージド・コードに存在するさまざまなタイプのメソッド呼び出しを実装するさまざまな方法を調査する。それぞれのメソッド呼び出しの実行時コストを見てみよう。
