今回から数回に分けて、C# のコンパイル結果である CIL を扱います。
C# のコードは、ビルドするといきなり CPU が直接実行する機械語になるわけではありません。
まず .NET ランタイムが理解できる中間表現へ変換され、実行時に JIT コンパイラーによってネイティブコードへ変換されます。
この記事では、CIL を読む理由、基本的な構文、命令とスタックの関係を見ていきます。
CIL を学ぶ理由
普通のアプリケーション開発では、CIL を直接書くことはほとんどありません。
それでも CIL を読む力があると、C# と .NET の理解が一段深くなります。
たとえば、次のような場面で役立ちます。
- C# の構文が実行時にどう表現されるかを理解する
- 値型、参照型、ボックス化、仮想呼び出しなどの仕組みを確認する
- リフレクションや属性がどのようにメタデータへ保存されるかを理解する
- パフォーマンス上の違いを調べる
- 逆コンパイルツールの出力を読めるようにする
- 動的にコードを生成する仕組みを理解する
CIL は毎日書くものではありません。
ただし、読めるようになると「C# の裏側で何が起きているのか」を説明できるようになります。
C# から CIL への流れ
次のような C# コードを考えます。
public static int Add(int x, int y)
{
return x + y;
}
これをビルドすると、概念的には次のような CIL になります。
.method public hidebysig static int32 Add(int32 x, int32 y) cil managed
{
.maxstack 2
ldarg.0
ldarg.1
add
ret
}
細かい出力はビルド設定やコンパイラーによって変わります。
ただし、意味としては「引数をスタックへ積む」「加算する」「戻る」という流れです。
CIL の大まかな構成
CIL のテキスト表現には、主に次のような要素が出てきます。
ディレクティブ
. で始まる宣言です。
アセンブリ、クラス、メソッド、ローカル変数、スタックサイズなどを表します。
.assembly Sample {}
.class public auto ansi beforefieldinit Calculator
.method public hidebysig static int32 Add(int32 x, int32 y) cil managed
.maxstack 2
ディレクティブは「構造を宣言するもの」と考えると分かりやすいです。
属性
CIL 上にも、型やメソッドの性質を表す指定が現れます。
たとえば public、static、private、specialname などです。
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
C# のアクセス修飾子やメンバーの性質が、CIL 上ではこのような形で表現されます。
命令
実際に実行される操作です。
ldarg.0
ldarg.1
add
ret
ldarg.0 は 0 番目の引数を評価スタックへ積みます。
add はスタック上の値を取り出して加算し、結果を再びスタックへ積みます。
ret は戻り値を返してメソッドを終了します。
命令名と実際の命令コード
CIL では、命令を人間が読みやすい名前で表します。
ldc.i4.1
ldc.i4.2
add
ret
これらの名前は、実際のバイナリ上では数値の命令コードとして保存されます。
人間が読む .il では名前で表示され、アセンブリの中ではより低レベルな表現になっています。
通常の学習では、命令名を読めれば十分です。
数値の命令コードまで覚える必要はありません。
CIL はスタックベースで動く
CIL を読む上で最重要なのは、評価スタックです。
C# の式は、CIL では値をスタックへ積んだり、取り出したりしながら処理されます。
次の C# コードを考えます。
int result = 10 + 20;
概念的には、次のような CIL になります。
ldc.i4.s 10
ldc.i4.s 20
add
stloc.0
スタックの動きは次の通りです。
ldc.i4.s 10 -> 10 を積む
ldc.i4.s 20 -> 20 を積む
add -> 20 と 10 を取り出して加算し、30 を積む
stloc.0 -> 30 をローカル変数 0 番へ格納する
CIL では、多くの命令が「スタックから値を取る」「結果をスタックへ積む」という形で動きます。
スタック操作の小さな例
次の C# メソッドを見てみます。
public static int MultiplyAndAdd(int x, int y)
{
return (x * y) + 10;
}
概念的な CIL です。
.method public hidebysig static int32 MultiplyAndAdd(int32 x, int32 y) cil managed
{
.maxstack 2
ldarg.0
ldarg.1
mul
ldc.i4.s 10
add
ret
}
流れを追うと、こうなります。
ldarg.0 -> x を積む
ldarg.1 -> y を積む
mul -> x と y を取り出して掛け算し、結果を積む
ldc.i4.s 10 -> 10 を積む
add -> 掛け算の結果と 10 を加算する
ret -> 結果を返す
この「値を積む、使う、結果を積む」という見方ができると、CIL はかなり読みやすくなります。
往復的に理解する
CIL を学ぶときは、C# と CIL を往復しながら読むのが効果的です。
public static bool IsEven(int value)
{
return value % 2 == 0;
}
概念的な CIL です。
ldarg.0
ldc.i4.2
rem
ldc.i4.0
ceq
ret
流れは次の通りです。
引数 value を積む
2 を積む
剰余を計算する
0 を積む
等しいか比較する
結果を返す
このように、C# の構文を CIL に置き換えてみると、演算子や比較の実体が見えてきます。
CIL を確認する方法
ビルド済みアセンブリの中身を確認するには、逆コンパイルツールを使うのが手軽です。
代表的には ILSpy などがあります。
C# 風のコードへ戻して表示するだけでなく、CIL の表示もできます。
また、.il ファイルを作って ilasm で組み立てる方法もあります。
ただし、日常の .NET 開発では、CIL を手でコンパイルするよりも「読むため」に使うことが多いです。
まとめ
CIL は、C# と CLR の間にある中間表現です。
普段は直接書きませんが、読めるようになると、C# の構文が実行時にどう扱われるかを理解しやすくなります。
最初に押さえるべきポイントは、ディレクティブ、属性、命令、そして評価スタックです。
次回は、CIL 上でアセンブリ、名前空間、クラス、インターフェイス、構造体、列挙型、ジェネリック型がどのように定義されるかを見ていきます。