IL2CPPの内部:P/インボーク・ラッパー

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 分
IL2CPPの内部:P/インボーク・ラッパー
このウェブページは、お客様の便宜のために機械翻訳されたものです。翻訳されたコンテンツの正確性や信頼性は保証いたしかねます。翻訳されたコンテンツの正確性について疑問をお持ちの場合は、ウェブページの公式な英語版をご覧ください。
これはIL2CPP内部シリーズの6番目の投稿です。この投稿では、il2cpp.exeが、マネージドコードとネイティブコード間の相互運用に使用するラッパーメソッドや型をどのように生成するのかを探ります。具体的には、ブリッタブル型と非ブリッタブル型の違いを見て、文字列と配列のマーシャリングを理解し、マーシャリングのコストについて学ぶ。

私はこれまで、マネージドからネイティブへの相互運用コードをかなり書いてきたが、C#でp/invoke宣言を正しく行うのは、控えめに言ってもまだ難しい。ランタイムが私のオブジェクトをマーシャルするために何をしているのかを理解するのは、さらに謎だ。IL2CPPはそのマーシャリングのほとんどを生成されたC++コードで行うので、その動作を見ることができ(デバッグもできる!)、トラブルシューティングやパフォーマンス解析のためのより良い洞察を提供する。

この投稿は、マーシャリングとネイティブ・インタロップに関する一般的な情報を提供することを目的としていない。1つの記事にするには広すぎる話題だ。Unityのドキュメントでは、ネイティブプラグインがUnityとどのように相互作用するかについて説明しています。Monoも マイクロソフトも、p/invoke全般に関する素晴らしい情報をたくさん提供している。

このシリーズのすべての投稿と同様に、変更される可能性があり、実際にUnityの新しいバージョンで変更される可能性があるコードを調査します。しかし、コンセプトは変わらないはずだ。この連載で取り上げたことはすべて、実装の詳細として受け止めてほしい。私たちは、可能な限りこのような詳細を明らかにし、議論したいと思っています。

セットアップ

この投稿では、OSX上のUnity 5.0.2p4を使っています。iOSプラットフォーム用に、"Architecture "の値を "Universal "にしてビルドしてみる。この例のネイティブ・コードは、ARMv7とARM64の両方のスタティック・ライブラリーとしてXcode 6.3.2でビルドした。

ネイティブのコードは次のようになる:

#include <cstring>
#include <cmath>

extern "C" {
int Increment(int i) {
return i + 1;
}

bool StringsMatch(const char* l, const char* r) {
return strcmp(l, r) == 0;
}

struct Vector {
float x;
float y;
float z;
};

float ComputeLength(Vector v) {
return sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}

void SetX(Vector* v, float value) {
v->x = value;
}

struct Boss {
char* name;
int health;
};

bool IsBossDead(Boss b) {
return b.health == 0;
}

int SumArrayElements(int* elements, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += elements[i];
}
return sum;
}

int SumBossHealth(Boss* bosses, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += bosses[i].health;
}
return sum;
}

}

Unityのスクリプト・コードは、やはりHelloWorld.csファイルにあります。このようになります。

void Start () {
Debug.Log (string.Format ("Using a blittable argument: {0}", Increment (42)));
Debug.Log (string.Format ("Marshaling strings: {0}", StringsMatch ("Hello", "Goodbye")));

var vector = new Vector (1.0f, 2.0f, 3.0f);
Debug.Log (string.Format ("Marshaling a blittable struct: {0}", ComputeLength (vector)));
SetX (ref vector, 42.0f);
Debug.Log (string.Format ("Marshaling a blittable struct by reference: {0}", vector.x));

Debug.Log (string.Format ("Marshaling a non-blittable struct: {0}", IsBossDead (new Boss("Final Boss", 100))));

int[] values = {1, 2, 3, 4};
Debug.Log(string.Format("Marshaling an array: {0}", SumArrayElements(values, values.Length)));
Boss[] bosses = {new Boss("First Boss", 25), new Boss("Second Boss", 45)};
Debug.Log(string.Format("Marshaling an array by reference: {0}", SumBossHealth(bosses, bosses.Length)));
}

このコードの各メソッド・コールは、上に示したネイティブ・コードに組み込まれている。各メソッドのマネージド・メソッド宣言については、後ほど見ていくことにしよう。

なぜマーシャリングが必要なのか?

IL2CPPはすでにC++コードを生成しているのだから、なぜC#からC++コードへのマーシャリングが必要なのか?生成されるC++コードはネイティブコードであるが、C#の型の表現はC++と異なる場合が多いため、IL2CPPランタイムは双方の表現を相互に変換できなければならない。il2cpp.exeユーティリティは、型とメソッドの両方でこれを行う。

マネージドコードでは、すべての型は次のいずれかに分類されます。 ブリッタブルまたは非ブリッタブル.Blittable型は、マネージドコードでもネイティブコードでも同じ表現になります(byte、int、floatなど)。非ブリッタブル型は、マネージドコードとネイティブコードで表現が異なる(bool型、文字列型、配列型など)。そのため、ブリッタブル型はネイティブ・コードに直接渡すことができるが、非ブリッタブル型はネイティブ・コードに渡す前に何らかの変換が必要になる。多くの場合、この変換には新たなメモリ割り当てが伴う。

あるメソッドがネイティブ・コードで実装されていることをマネージド・コード・コンパイラに伝えるために、C#ではexternキーワードが使われる。このキーワードとDllImport属性によって、マネージド・コードのランタイムはネイティブ・メソッド定義を見つけ、それを呼び出すことができる。il2cpp.exeユーティリティは、各外部メソッドに対してラッパーC++メソッドを生成する。このラッパーはいくつかの重要なタスクを実行する:

- これは、関数ポインタを介してメソッドを呼び出すために使われる、ネイティブメソッドの型定義である。

- ネイティブ・メソッドを名前で解決し、そのメソッドへの関数ポインタを取得する。

- これは、(必要であれば)引数を管理された表現からネイティブな表現に変換する。

- ネイティブ・メソッドを呼び出す。

- これは、メソッドの戻り値をネイティブ表現から(必要であれば)マネージド表現に変換する。

- Inは、outまたはref引数をネイティブ表現から(必要であれば)マネージド表現に変換する。

次に、いくつかのexternメソッド宣言に対して生成されるラッパー・メソッドを見てみよう。

ブリッタブル型のマーシャリング

最も単純なexternラッパーは、blittable型のみを扱う。

[DllImport("__Internal")]
private extern static int Increment(int value);



In the Bulk_Assembly-CSharp_0.cpp file, search for the string “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
extern "C" int32_t HelloWorld_Increment_m3 (Object_t * __this /* static, unused */, int32_t ___value, const MethodInfo* method)
{
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
static PInvokeFunc _il2cpp_pinvoke_func;
if (!_il2cpp_pinvoke_func)
{
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
if (_il2cpp_pinvoke_func == NULL)
{
il2cpp_codegen_raise_exception(il2cpp_codegen_get_not_supported_exception("Unable to find method for p/invoke: 'Increment'"));
}
}

int32_t _return_value = _il2cpp_pinvoke_func(___value);

return _return_value;
}

まず、ネイティブ関数シグネチャの型定義に注目してほしい:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

似たようなことが、それぞれのラッパー関数に現れるだろう。このネイティブ関数はint32_tを1つ受け取り、int32_tを返す。

次に、ラッパーは適切な関数ポインターを見つけ、それを静的変数に格納する:

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

ここでIncrement関数は、実際には(C++コードの)externステートメントから来ている:

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}

iOSでは、ネイティブ・メソッドは1つのバイナリに静的にリンクされている(DllImport属性の"__Internal "文字列で示される)ので、IL2CPPのランタイムは関数ポインタを検索するために何もしない。その代わり、このextern文は、リンク時に適切な関数を見つけるようリンカーに通知する。他のプラットフォームでは、IL2CPPランタイムは、この関数ポインタを取得するために、プラットフォーム固有のAPIメソッドを使用してルックアップを実行するかもしれない(必要であれば)。

実際、これはiOS上では、マネージド・コード内の誤ったp/invokeシグネチャが、生成されたコード内のリンカー・エラーとして表示されることを意味する。実行時にエラーが発生することはない。したがって、p/invokeシグネチャーは、たとえ実行時に使用されなくても、すべて正しくなければならない。

最後に、関数ポインターを介してネイティブ・メソッドが呼び出され、戻り値が返される。引数は値としてネイティブ関数に渡されるため、ネイティブコードでその値を変更しても、期待通りマネージドコードでは利用できないことに注意しよう。

非ブリッタブル型のマーシャリング

文字列のような非ブリッタブル型では、もう少しエキサイティングになる。以前の投稿で、IL2CPPの文字列はUTF-16でエンコードされた2バイト文字の配列として表現され、その前に4バイトの長さ値が付加されていることを思い出してほしい。この表現は、iOSのC言語における文字列のchar*表現ともwchar_t*表現とも一致しないので、何らかの変換をしなければならない。StringsMatchメソッド(生成されたコードではHelloWorld_StringsMatch_m4)を見てみよう:

DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);

各文字列引数はchar*に変換されることがわかる(UnmangedType.LPStrディレクティブによる)。

typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);

変換は次のようになる(第1引数の場合):

char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);

適切な長さの新しい文字バッファが確保され、文字列の内容が新しいバッファにコピーされる。もちろん、ネイティブ・メソッドが呼ばれた後は、割り当てられたバッファをクリーンアップする必要がある:

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

そのため、文字列のような非ブリッタブル型のマーシャリングにはコストがかかる。

ユーザー定義型のマーシャリング

intやstringのような単純な型もいいが、もっと複雑なユーザー定義型はどうだろう?3つの浮動小数点値を含む上記のVector構造体をマーシャリングしたいとしよう。ユーザー定義型は、そのすべてのフィールドがブリット可能である場合に限り、ブリット可能であることが判明した。そのため、引数を変換することなくComputeLength(生成されたコードではHelloWorld_ComputeLength_m5)を呼び出すことができる:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;

引数の型がintであった最初の例と同じように、引数は値で渡されることに注意。Vectorのインスタンスを変更し、その変更をマネージド・コードで見たい場合は、SetXメソッド(HelloWorld_SetX_m6)のように参照渡しをする必要がある:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 *, float);

Vector_t1 * ____v_marshaled = { 0 };
Vector_t1  ____v_marshaled_dereferenced = { 0 };
____v_marshaled_dereferenced = *___v;
____v_marshaled = &____v_marshaled_dereferenced;

float _return_value = _il2cpp_pinvoke_func(____v_marshaled, ___value);

Vector_t1  ____v_result_dereferenced = { 0 };
Vector_t1 * ____v_result = &____v_result_dereferenced;
*____v_result = *____v_marshaled;
*___v = *____v_result;

return _return_value;

ここでは、Vector引数はネイティブコードへのポインタとして渡される。生成されたコードは少し複雑だが、基本的には同じ型のローカル変数を作成し、引数の値をローカルにコピーし、そのローカル変数へのポインタを持つネイティブメソッドを呼び出している。ネイティブ関数が戻った後、ローカル変数の値は引数にコピーバックされ、その値はマネージドコードで利用できるようになる。

上記で定義したBoss型のように、非ブリッタブルなユーザー定義型もマーシャリングできるが、もう少し手間がかかる。この型の各フィールドは、そのネイティブ表現にマーシャリングされなければならない。また、生成されるC++コードは、ネイティブ・コードの表現と一致する管理型の表現を必要とする。

IsBossDeadエクスターン宣言を見てみよう:

[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);

このメソッドのラッパーの名前はHelloWorld_IsBossDead_m7である:

extern "C" bool HelloWorld_IsBossDead_m7 (Object_t * __this /* static, unused */, Boss_t2  ___b, const MethodInfo* method)
{
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (Boss_t2_marshaled);

Boss_t2_marshaled ____b_marshaled = { 0 };
Boss_t2_marshal(___b, ____b_marshaled);
uint8_t _return_value = _il2cpp_pinvoke_func(____b_marshaled);
Boss_t2_marshal_cleanup(____b_marshaled);

return _return_value;
}

引数はBoss_t2型としてラッパー関数に渡されるが、これはBoss構造体の生成型である。ネイティブ関数には別の型が渡される:Boss_t2_marshaled.この型の定義にジャンプしてみると、C++の静的ライブラリ・コードのBoss構造体の定義と一致していることがわかる:

struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};

ここでもC#のUnmanagedType.LPStrディレクティブを使い、文字列フィールドがchar*としてマーシャリングされることを示した。もし、ブリッタブルでないユーザー定義型の問題をデバッグすることになったら、次のようにすると非常に便利である。 この 構造体を見るのは非常に役に立つ。フィールドレイアウトがネイティブ側と一致しない場合、マネージドコードのマーシャリング指令が正しくない可能性がある。

Boss_t2_marshal関数は各フィールドをマーシャルする生成関数で、 Boss_t2_marshal_cleanup関数はマーシャル処理中に割り当てられたメモリを解放します。

非ブリッタブルなユーザー定義型のマーシャリング
配列のマーシャリング

最後に、ブリッタブル型と非ブリッタブル型の配列がどのようにマーシャリングされるかを探ります。SumArrayElementsメソッドには整数の配列が渡される:

[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);

この配列はマーシャルされるが、配列の要素型(int)はブリッタブルなので、マーシャルのコストは非常に小さい:

int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((Il2CppCodeGenArray*)___elements);

il2cpp_codegen_marshal_array関数は、単に既存の管理配列メモリへのポインタを返します!

しかし、非ブリッタブル型の配列をマーシャリングするのは、はるかにコストがかかる。SumBossHealth メソッドは Boss インスタンスの配列を渡します:

[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);

ラッパーは新しい配列を確保し、各要素を個別にマーシャルしなければならない:

Boss_t2_marshaled* ____bosses_marshaled = { 0 };
size_t ____bosses_Length = 0;
if (___bosses != NULL)
{
____bosses_Length = ((Il2CppCodeGenArray*)___bosses)->max_length;
____bosses_marshaled = il2cpp_codegen_marshal_allocate_array<Boss_t2_marshaled>(____bosses_Length);
}

for (int i = 0; i < ____bosses_Length; i++)
{
Boss_t2  const& item = *reinterpret_cast<Boss_t2 *>(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));
Boss_t2_marshal(item, (____bosses_marshaled)[i]);
}

もちろん、ネイティブ・メソッドの呼び出しが完了した後、これらのすべての割り当てがクリーンアップされる。

結論

IL2CPPスクリプト・バックエンドは、Monoスクリプト・バックエンドと同じマーシャリング動作をサポートする。IL2CPPは外部メソッドと型のためのラッパーを生成するので、マネージドからネイティブへのインターオプ呼び出しのコストを見ることができる。ブリット可能な型であれば、このコストはさほど気にならないことが多いが、ブリット不可能な型では、インターオプが非常に高価になることがある。いつものように、この記事ではマーシャリングの表面しか見ていない。生成されたコードをもっと調べて、戻り値やアウト・パラメーター、ネイティブ関数ポインターやマネージド・デリゲート、ユーザー定義参照型に対してどのようにマーシャリングが行われるかを確認してください。

次回は、IL2CPPがガベージ・コレクタとどのように統合されるかを探る。