CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

ILPostProcessor 入門 第3回目「IL編集の基本」

この記事は ILPostProcessor 入門シリーズの第3回です。

第1回、第2回と前置きが長くなりましたが、今回からいよいよILについて説明していきます。

概要

簡単な ILPostProcessor であれば、第1回で紹介したように SharpLabコンパイルした IL を目コピすれば実装できます。しかし、ちょっと凝った実装を行ったりトラブルシューティングを行う上では、やはり IL の知識が必要です。

第1回で紹介した通りですが、C# は一旦 IL に変換され、IL を元に各実行環境用のネイティブコードが生成されます。こうした事情から、IL は人間よりも機械にとって読みやすいフォーマットになっています。

IL 自体はバイナリフォーマットですが、テキスト表現形式が定義されています。ひとつのメソッドのテキスト表現を抜き出すとこんな感じです。

    .method public hidebysig static 
        int32 M (
            int32 a,
            int32 b
        ) cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: add
        IL_0003: ret
    } // end of method C::M

IL_0000: の行からがIL命令の羅列です。IL_0000: の部分は命令のオフセットで、後に続く部分が命令の本体です。IL 命令は上から順番に実行され、ldarg.1 ldarg.2 add ret がそれぞれ IL 命令となっています。

命令の種類は仕様で定義されており、以下のページで全ての命令の情報を見ることができます。あらゆる C# コードはこれらの命令の組み合わせによって表現されています。

スタックマシン

はじめに紹介した IL コードですが、元の C# はこんなコードでした。

  public static void M(int a, int b) => a + b;

a と b を足して返すだけのシンプルなコードです。これが IL になると次のようなコードになります。

  IL_0000: ldarg.0
  IL_0001: ldarg.1
  IL_0002: add
  IL_0003: ret

IL では式を入れ子にすることができず、全ての命令が直列に記述されます。このコードでどうやって a + b を表現しているのかというと、評価スタック(evaluation stack)と呼ばれる場所に値を出し入れすることで実現しています。

例えば、ldarg.0 は、「引数0をスタックにプッシュする」命令です。このメソッドでは引数 0 が a 、引数 1 が b なので、二行目まで実行すると、スタックの状態はこんな感じになります。

次に、add 命令はスタックから2つの値をポップし、その和をプッシュします。スタックの状態はこうなります。

最後に、ret 命令はスタックから値を1つポップし、メソッドの戻り値とします。

戻り値が void のメソッドの場合は、ret は値をポップせずそのままメソッドを終了します。

このように、IL では全ての値をスタック上で出し入れすることで計算を行います。計算に限らず、大体の命令はスタック上から入力値を取り出し、出力値をスタックにプッシュします。すべての命令の入力値・出力値は仕様で定められているので、それらを順番に読んでいけば IL の流れを掴むことができます。

💡 評価スタックは IL 上の仮想的なデータ構造であり、実行時のメモリ上の値が実際にこのような構造をとっているとは限りません。

オペランドと命令の短縮形

先ほどの例で、引数 1 をスタックにロードする命令 ldarg.1 が登場しました。

この ldarg 命令には、以下のようなバリエーションがあります。

  • ldarg N
  • ldarg.0
  • ldarg.1
  • ldarg.2
  • ldarg.3
  • ldarg.s N

空白の後に N として示されているのはオペランドといい、IL 命令に必要な付加情報を与えるものです。ldarg N の場合は、オペランドとして対象の引数のインデックスを指定します。

ldarg.0 - ldarg.3 は、オペランドを持たず、命令自体に引数インデックスが定数として含まれています。例えば、ldarg.0ldarg 0 と書くのと全く同じ意味を持ちます。テキスト表現上はこれらを区別する意味がありませんが、IL のバイナリフォーマット上は ldarg.0 の方を使うことでコードサイズを節約することが意図されています。

  • ldarg 0 … FE 09 00 00
  • ldarg.0 … 02

ちなみに、ldarg は引数インデックスを16ビット整数で指定しますが、ldarg.s は8ビット整数で指定するため、255番目の引数までは ldarg.s の方がコードサイズを節約できます。

この種のバリエーションは他の命令でも頻出するので、覚えておきましょう。

スタック上の値の型

いろいろな IL 命令を見ていく上で重要な概念として、スタック上の値には型の区別があります。各命令がスタックからプッシュ・ポップする値の型は仕様で定義されており、許可されていない型の値を入力すると、実行に失敗したり、未定義動作になったりします。

  • int32
  • int64
  • native int
    • 実行環境におけるポインタサイズの整数
  • F (浮動小数点数)
  • & (マネージドポインタ)
    • GCによるオブジェクトの移動に追従する
  • O (オブジェクト参照)
  • そのほか、任意のstruct

数値型 (int32, int64, F)

ldc 命令で数値型の定数をスタックにプッシュすることができます。

// i4はint32としてスタックにプッシュ
ldc.i4.s 200 // オペランドは16bit
ldc.i4 300   // オペランドは32bit

ldc.i4.m1    // -1をロード
ldc.i4.0
ldc.i4.1
ldc.i4.2
ldc.i4.3

// i8はint64としてスタックにプッシュ
ldc.i8 5000000000

// r4はfloatをFとしてスタックにプッシュ
ldc.r4 1.0

// r8はdoubleをFとしてスタックにプッシュ
ldc.r8 1.0

注意が必要な点として、C# とは異なり、スタック上の整数型には符号の区別はありません。スタックから各命令に入力される整数値は、各命令によって符号の解釈が異なります。入力値を unsigned として解釈する命令には、.u.un といったサフィックスがついていることが多いです。

また、浮動小数点型をロードする命令は ldc.r4ldc.r8 がありますが、スタック上は精度の区別がなくどちらも F という型として扱われます。

⚠️ これはIL上の概念であり、ネイティブコード上でも精度の区別がないわけではありません。

native int

数値型の一つである native int は、実行環境のアーキテクチャにおけるポインタサイズの整数で、64bit 環境なら8バイトになります。C# では IntPtr にあたり、ポインタを操作する際に使用します。

.NET は単一の DLL ファイルで 32bit も 64bit もサポートすることができますが、これは IL 上では native int のように特定のアーキテクチャに依存しない抽象的な型として表現しておき、実行時にアーキテクチャに応じたネイティブコードを生成することで実現されています。

O (オブジェクト参照)

参照型オブジェクトに対する参照で、実態はポインタです。ldnull という命令によって null 参照をスタックにプッシュすることができます。また、newobj 命令によって新しいインスタンスを生成すると、その参照がOとしてプッシュされます。

& (マネージドポインタ)

IL では、ローカル変数や引数、フィールド、配列要素に対する参照を値として取得し、スタック上に置くことができます。この値は C#ref に相当し、IL的にはマネージドポインタと呼ばれます。これは O と似ていますが、O は参照型オブジェクトそのものを指し示すのに対し、& は参照型オブジェクトのインスタンスフィールドや、スタック上の値を指し示すことができます。

ポインタと GC について

スタック上の値で特に重要なのは、&O の扱いです。これらはヒープ上の特定の位置を参照することができますが、.NET においてヒープ上のオブジェクトは GC によって移動される可能性があります。&O は、GC によって参照先のオブジェクトが移動されると、ランタイムが自動的にアドレスを更新します。

これは、スタック上の値には実行時型情報が存在するということを意味しています。ランタイムはスタック上のどの位置に &O が存在するのかを把握していて、それによってGCが走った際の振る舞いが変わります。native int, &, O はどれもメモリ上の表現は単なるポインタに過ぎませんが、うっかり &Onative int に変換してしまうと、GC が走ったタイミングでダングリングポインタとなる危険性があります。

📝 この辺りは、C#fixedGCHandleUnsafe.As() を使うときも意識するポイントです。

💡 実際のところ、現在の Unity で使われている Mono / IL2CPP ランタイムでは Non-Moving である Boehm GC を採用しているため、マネージドオブジェクトのアドレスは変わりません。

ただし、現在Unityでは本家 .NET と同じランタイム(CoreCLR)の導入が予定されており、こちらは Moving GC を採用しています。Moving GC でのオブジェクトの移動(コンパクション)は、主にメモリの断片化を防ぎ、新たなメモリ確保時のパフォーマンス低下を回避するために行われます。

Mono.CecilでIL命令を編集する

第1回のコードで少しだけ登場しましたが、Mono.Cecil で IL 命令を編集するには ILProcessor クラスを使用します。

Mono.Cecil でロードしたアセンブリAssemblyDefinition > TypeDefinition > MethodDefinition という包含関係になっているので、まずは MethodDefinition を取得します。

foreach (var module in assembly.Modules)
{
    foreach (var type in module.GetTypes())
    {
        foreach (var method in type.GetMethods())
        {
            ProcessMethod(method);
        }
    }
}

private void ProcessMethod(MethodDefinition method)
{
    /* */
}

MethodDefinition が取得できたら、そこから ILProcessor を取得します。

    var processor = method.Body.GetILProcessor();
    processor.InsertBefore(method.Body.Instructions[0], Instruction.Create(OpCodes.Ldarg_0));

ILProcessor には InsertBefore()InsertAfter()Remove() などのメソッドが用意されています。これらを使って命令を増やしたり減らしたりできるわけです。

また、method.Body.Instructions を使うと命令のリストにアクセスすることができます。

命令を表す InstructionインスタンスInstruction.Create() で作成することができます。

var instruction = Instruction.Create(OpCodes.Ldarg_0);

基本的に、メソッド内に追加する Instruction は毎度 Create() する必要があります。

// これはダメ
var instruction = Instruction.Create(OpCodes.Ldarg_0);
processor.InsertBefore(method.Body.Instructions[0], instruction);
processor.InsertBefore(method.Body.Instructions[0], instruction);

// こうする
processor.InsertBefore(method.Body.Instructions[0], Instruction.Create(OpCodes.Ldarg_0));
processor.InsertBefore(method.Body.Instructions[0], Instruction.Create(OpCodes.Ldarg_0));

オペランドが必要な場合は、Instruction.Create() の第二引数に指定します。

例えば ldarg の場合、オペランドとして ParameterDefinitionインスタンスを指定します。

Collection<ParameterDefinition> parameters = method.Parameters;
var instruction = Instruction.Create(OpCodes.Ldarg, parameters[0]);

-

💡 ldargオペランドは、バイナリ上は引数のインデックスですが、Mono.Cecil では ParameterDefinition への参照として表現されます。実際のインデックスとの変換は Mono.Cecil が自動的に行ってくれます。これによって ParameterDefinition を追加・削除しても正しくインデックスをトラックすることができます。

各命令のオペランドが Mono.Cecil 上でどう表現されるかについては特にドキュメントがないので、実際の DLL を読み込ませて Instruction.Operand プロパティの型を確認するのがおすすめです。あるいは、CodeReader.ReadOperand() の実装も参考になるでしょう。

いろいろなIL命令

ここからは、さまざまな C# コードが IL でどう表現されるかをみていきます。

ここで全ての IL 命令を解説するには種類が多すぎるので、今回は最も基本的な命令のみに絞っています。ここで紹介していない命令や、各命令のより詳しい情報については、冒頭でも紹介したこれらのページを参照してください。

ローカル変数の読み書き

IL 上も、C# と同様にローカル変数の概念があり、ldlocstloc の二つの命令で読み書きできます。

  • ldlocオペランドに指定したローカル変数から値を読み出し、スタックにプッシュする
  • stloc ... スタックから値をポップして、オペランドに指定したローカル変数に書き込む
ldc.i4.1 // 定数1
stloc.0 // ローカル変数0に1をストア
// ...
ldloc.0 // ローカル変数0から値をロード

Mono.Cecil では、オペランドの型が VariableDefinition となっています。MethodBody.Varibles からローカル変数のリストを取得・操作できます。

Collection<VariableDefinition> variables = method.Body.Variables;
variables.Add(new VariableDefinition(/* */));

メソッドの呼び出し

IL では、メソッドの呼び出しは次のように表現されます。

call int32 C::M() // int32は戻り値の型、C::M()は対象のメソッド

Mono.Cecil では、call 命令のオペランドの型は MethodReference になります。MethodReference は名前の通り任意のメソッドを参照するための型です。

MethodReference については第4回で詳しく説明します。

引数の渡し方 (staticの場合)

メソッド呼び出し命令では、引数の数だけスタックから値がポップされ、メソッドに渡されます。

// ldc.i4.0
// ldc.i4.1
// call void C::M_Static(int32, int32)
C.M_Static(0, 1);

public class C
{   
    public static void M_Static(int a, int b)
    {
    }
}

戻り値の受け取り方

戻り値のあるメソッドの場合は、メソッド終了後に戻り値がスタックにプッシュされます。

public class C 
{
    public static int M1()
    {
        // call int32 C::M2() // 戻り値がプッシュされる
        // ret                // プッシュされた戻り値をそのままreturnする
        return M2();
    }
    
    public static int M2() => 1;
}

インスタンスメソッドにおける this の扱い

インスタンスメソッドの場合、this は暗黙的な 0 番目の引数として扱われます。(残りの引数はインデックス 1 から始まります。)

呼び出されたインスタンスメソッド内では、ldarg.0this にアクセスすることができます。

参照型のインスタンスメソッドではthisO として渡され、値型のインスタンスメソッドでは & として渡されます。

public class C
{   
    public void M1()
    {
        // ldarg.0   // thisをロード
        // ldc.i4.0
        // ldc.i4.1
        // call instance void C::M2(int32, int32)
        M2(0, 1);
    }
    
    public void M2(int a, int b)
    {
    }
}

オブジェクトの作成

コンストラクタを使ってインスタンスを初期化する場合は newobj を使用します。

// ldc.i4.0
// newobj instance void C::.ctor(int32)
new C(0);

// ldc.i4.0
// newobj instance void S::.ctor(int32)
new S(0);

public class C 
{
    public C(int a) {}
}

public struct S
{
    public S(int a) {}
}

値型の場合、initobj を使用するとコンストラクタを使わないゼロクリア初期化(default の代入と同じ)が可能です。

// ldloca.s 0     // sのアドレスをマネージドポインタ(&)として取得
// initobj S      // アドレスをポップして、内容を初期化(ゼロクリア)
S s = default(S);

// 後から普通にコンストラクタを呼び出して初期化することもできる
// ldloca.s 0
// ldc.i4.0
// call instance void S::.ctor(int32)
s = new S(0);

public struct S
{
    public S(int a) {}
}

仮想メソッド呼び出し

virtualoverrideabstract なメソッドや、インターフェースを介したメソッドの呼び出しを行う場合、call ではなく callvirt という命令を使用する必要があります。使い方は call と同じです。

callvirt では、ランタイムがインスタンスの型を見て実際に呼び出されるメソッドを振り分けます。

// newobj instance void C::.ctor()
// callvirt instance string [netstandard]System.Object::ToString();
new C().ToString(); // CにはToString()のオーバーライドがないので、基底クラス (System.Object) の実装が呼ばれる

public class C
{
}

分岐

分岐を行うための命令もあります。

// IL_0000: ldarg.0
// IL_0001: brtrue.s IL_000e // スタックから値をポップし、0でなければIL_000eにジャンプ
if(i == 0)
{
    // IL_0003: ldstr "ZERO"
    // IL_0008: call void [System.Console]System.Console::WriteLine(string)
    // IL_000d: br.s IL_0019 // 無条件でIL_0019にジャンプ
    Console.WriteLine("ZERO");
}
else
{
    // IL_000f: ldstr "NOT ZERO"
    // IL_0014: call void [System.Console]System.Console::WriteLine(string)
    Console.WriteLine("NOT ZERO");
}

// IL_0019: ret

2つの分岐命令が登場しました。

1つ目に登場したのは brtrue.s です。これはスタックから値をひとつポップし、それが 0 でなければ、オペランドで指定した命令にジャンプします。

// IL_0001: brtrue.s IL_000e // スタックから値をポップし、0でなければIL_000eにジャンプ

2つ目に登場したのは br.s です。こちらはスタックの状態に関わらず、オペランドで指定した命令にジャンプします。

// IL_000d: br.s IL_0019 // 無条件でIL_0019にジャンプ

Mono.Cecil では、ジャンプ先を指定するオペランドの型は Instruction になります。

このように、C# におけるifなどの条件分岐は、IL では条件によって分岐する命令になります。ブロックのような構造ではなく、フラット化された命令によって分岐を表現します。

今回紹介したほかにも、2つの数値を比較して分岐する命令や、テーブルを使用して3つ以上の枝に分岐できる switch 命令など、さまざまなバリエーションがあります。

分岐命令の短縮形に関する注意点

先ほど紹介した分岐命令には .sサフィックスがついていましたが、これはオペランドと命令の短縮形 で紹介した短縮形です。

.s がついていない分岐命令では、分岐先のオフセットを32ビット整数で表現するのに対し、.s の方は8ビット整数で表現します。他の命令と同様に、Mono.Cecil ではこのオペランドInstruction への参照によって表現され、 Mono.Cecil が自動的にオフセットに変換してくれます。

ひとつ問題として、IL ポストプロセッシングによって命令列を編集した際、このオフセットが8ビットを超えてしまう場合があります。こうした場合、Mono.Cecil.s 命令のまま8ビット以上のオフセットを書き込もうとしてしまうので、オーバーフローによって不正な IL を出力します。

これに対処するには、あらかじめすべての短縮形命令を通常のものに置き換えておく方法が有効です。詳しくは以下の記事で解説しています。

try-catch

最後に、例外ハンドラについて説明します。

ここまで、あらゆる C# コードは IL 命令の羅列で表現されてきましたが、例外処理については少し特殊です。

try
{
    // IL_0000: call void C::DoSomething()
    // IL_0005: leave.s IL_0014
    C.DoSomething();
}
catch(Exception ex)
{
    // catchでは、スタックのトップに例外オブジェクト(O)がプッシュされる
    // IL_0007: stloc.0
    // IL_0008: ldstr "Wrapper Exception"
    // IL_000d: ldloc.0
    // IL_000e: newobj instance void [System.Runtime]System.Exception::.ctor(string, class [System.Runtime]System.Exception)
    // IL_0013: throw
    throw new Exception("Wrapper Exception", ex);
}

// IL_0014: ret

trycatch の情報は、IL 命令として表現されません。

例外処理の情報は MethodBody.ExceptionHandlers に別途保持されます。

Collection<ExceptionHandler> exceptionHandlers = method.Body.ExceptionHandlers;

foreach (var exceptionHandler in exceptionHandlers)
{
    ExceptionHandlerType handlerType = exceptionHandler.HandlerType; // Catch, Finally, Filter, Faultのいずれか

    Instruction tryStart = exceptionHandler.TryStart; // tryブロックの開始位置

    Instruction tryEnd = exceptionHandler.TryEnd; // tryブロックの終了位置

    Instruction filterStart = exceptionHandler.FilterStart; // filterブロックの開始位置(filterブロックはendfilter命令で終了)

    Instruction handlerStart = exceptionHandler.HandlerStart; // catch, finally, faultブロックの開始位置

    Instruction handlerEnd = exceptionHandler.HandlerEnd; // catch, finally, faultブロックの終了位置

    TypeReference catchType = exceptionHandler.CatchType; // catchする例外の型
}

このように、例外ハンドラの範囲は命令の位置によって指定します。

先ほどのコードに乗せてみると、それぞれこんな感じになります。

try // HandlerType: ExceptionHandlerType.Catch
{
    // IL_0000: call void C::DoSomething() <=== TryStart
    // IL_0005: leave.s IL_0014 // <=== TryEnd leave命令で例外ハンドラを脱出する
    C.DoSomething();
}
catch(Exception ex) // CatchType: System.Exception
{
    // catchでは、スタックのトップに例外オブジェクト(O)がプッシュされる
    // IL_0007: stloc.0 // <== HandlerStart
    // IL_0008: ldstr "Wrapper Exception"
    // IL_000d: ldloc.0
    // IL_000e: newobj instance void [System.Runtime]System.Exception::.ctor(string, class [System.Runtime]System.Exception)
    // IL_0013: throw // <== HandlerEnd
    throw new Exception("Wrapper Exception", ex);
}

// IL_0014: ret

例外ハンドラの範囲に関する注意点

ILProcessor クラスによる命令の追加や削除では、例外ハンドラの存在を考慮してくれません。

例えば try ブロックの先頭に命令を挿入したい場合には、ILProcessor で命令を挿入した後に、対応する例外ハンドラの TryStart を更新する必要があります。忘れがちなところなので気をつけましょう。

var injectionPoint = exceptionHandler[0].TrySrart;
var newInstruction = Instruction.Create(/* */);

processor.InsertBefore(injectionPoint, newInstruction);

exceptionHandler[0].TryStart = newInstruction;

IL の基本的な構造についての解説は以上です。あとは個別の命令の仕様を読んで応用できると思います。

第4回では、型、メソッド、アセンブリ間の参照など、IL コードの外側の部分について解説していきます。