拙い英語力は技術力でカバー!・・・とか言ってみたいところですが、そうも行かず。かと言って、先週末受けた TOEIC は悲壮感が漂う始末...( = =)
CLI が世に出て 11 年が経とうというのに、この辺りの情報は相変わらず日本語の情報が絶望的な状態。まあ嘆いても仕方がないので、前回に引き続きアンマネージ API です。まずはまずは "Hello World!" をををををと調べ続けた結果をご報告いたします。
ECMA C# and Common Language Infrastructure Standards にある CLI の仕様書、Common Language Infrastructure (CLI) Partition II: Metadata Definition and Semantics を読み解き、バイナリを解析し、API の助けを借りて、一番基本的な CLI アプリケーションを作ってみようという試み。拙い英語力のため、時折変な訳が混じっているかもですが、ご容赦くださいませ。
なお、文中にある "Hello World!" バイナリと、それを作成するプログラムのソリューションは以下のリンクからダウンロードできます。プログラムは Visual Studio 2008 にて Boost C++ Libraries Ver.1.47.0 を利用して作成していますので、ビルドの際はいくらか環境を整える必要があります。
- バイナリ(Google Docs):hello.zip
- ソリューション(GitHub):urasandesu/CppTroll - GitHub
こちらのページ/ソフトウェアを参考に/使用させていただきました。いつも本当にお世話になっております <(_ _)>
Download Details - Microsoft Download Center - Shared Source Common Language Infrastructure 2.0 Release
Cecil - Mono
ECMA C# and Common Language Infrastructure Standards
Boost C++ Libraries
Windows実行ファイルのバイナリ概要(1/2):CodeZine
EXEファイルの内部構造(PEヘッダ)(1/3):CodeZine
EXEファイルの内部構造(セクション)(1/3):CodeZine
プログラムからEXEファイルを生成してみよう(1/3):CodeZine
Peering Inside the PE: A Tour of the Win32 Portable Executable File Format
Inside Windows: An In-Depth Look into the Win32 Portable Executable File Format
Inside Windows: An In-Depth Look into the Win32 Portable Executable File Format, Part 2
逆アセのスス乂
[x86 asm] help understanding JMP opcodes - GameDev.net
Understanding the Import Address Table
Can't find a way to get the ICeeGen interface - .NET Framework
re-assembling .net assemblies with ICeeFileGen
Stirlingの詳細情報 : Vector ソフトを探す!
目次
Hello PE file format!
Microsoft Windows での実行形式ファイルといえば、Portable Executable(PE) フォーマット。CLI のためのファイルフォーマットも、例に漏れずこの形式に沿ったものとなっています。CLI での仕様は、Common Language Infrastructure (CLI) Partition II: Metadata Definition and Semantics の [25 File format extensions to PE] からまとめられており、ここを見れば一通りの情報は得られる感じ。
PE ファイルフォーマット自体の入門には、山本 大祐さんが CodeZine で書かれている、『Windows実行ファイルのバイナリ概要(1/2):CodeZine』や『EXEファイルの内部構造(PEヘッダ)(1/3):CodeZine』、『EXEファイルの内部構造(セクション)(1/3):CodeZine』がとても参考になりました。
仕様を追っていくときの参考例として、以下のような "Hello World!" プログラムをコンパイルし、見比べながら追っていくことにします。
コンパイルした結果できあがった hello.exe を、お気に入りのバイナリエディタで開き、各構成要素をわかりやすく色分けしたのが以下の図です。構成要素は大きく分けて以下の通り:
using System;
class MainApp {
public static void Main() {
Console.WriteLine("Hello World!");
}
}
1. MS-DOS Header、MS-DOS 用スタブプログラム
2. ヘッダ
2-1. PE ヘッダ
2-2. .text セクションヘッダ
2-3. .reloc セクションヘッダ
3. .text セクション
3-1. Import Address Table(IAT) RVAs
3-2. CLI ヘッダ
3-3. IL メソッドボディ
3-4. メタデータ
3-5. Import Table Import Directory エントリ
3-6. Import Lookup Table RVAs
3-7. Import Method Hint Name
3-8. Import DLL Name
3-9. x86 エントリポイントスタブ
4. .reloc セクション
4-1. Fix-Up Table
先に共通のルールを 3 つほど。1 つ目は「常に~」のようなフィールドがあります。これは[24.1 Fixed fields] で "When writing these fields it is best that they be set to the value indicated, on reading they should be ignored.(そういう値は、書き込む時は指定された値を入れるのが良くて、読み込むときは無視するべき)" と説明されています。コンパイラによっては、仕様とは微妙に違う値が入っていると思いますが、特に気にしなくて良いでしょう。
次は、RVA(Relative Virtual Address)。聞きなれない言葉、というかまず普通は出会わない言葉でしょう。Meta Data API のリファレンス読むと、まずこれがわけわかめです。項目がメモリに読み込まれたアドレスから、イメージファイルのベースアドレスを減算したもので、ファイルが読み込まれたベースアドレスからのオフセットという言い方もできる、みたいな。あうあう・・・(ToT)
実際にどのように使われるかはさておき、ある項目のファイル上でのアドレスを p、RVA を r、それが属すセクションのファイル上でのアドレスを l、RVA を s とすると、p == r - l + s みたいな式が成り立ちます。例えば、hello.exe の例で言うと、Entry Point RVA(x86 エントリポイントスタブの RVA)として 0x0000232E って指定があります。これは、同じく Section Alignment が 0x2000 という指定から、各セクションが 0x2000 の倍数の位置の RVA に対応することになることがわかり、これが属すセクション(.text セクション)のファイル上のアドレスは 0x00000200 ですので、この項目の RVA が指すものの実際のファイル上のアドレスは、0x0000232E - 0x2000 + 0x00000200 で 0x0000052E となります。実際に確認すると、{ 0xFF, 0x25, 0x00, 0x20, 0x40, 0x00, ・・・ } とあり、これは仕様通り、無条件ジャンプ命令(JMP)ですね、と。
最後はバイナリ値の格納方法。リトルエンディアンです。これは x86 系 CPU が主流な Windows の場合は、標準的なことなのかもしれませんね。
さて、各要素について順番に見ていくことにしましょう。
1. MS-DOS Header、MS-DOS 用スタブプログラム
私もちゃんと使ったことはないのですが、昔 MS-DOS という OS があった頃の名残とのこと。Windows 環境では特に意味はありません。もし MS-DOS 環境で実行された場合に、"This program cannot be run in DOS mode." と表示し、すぐ終了するような処理が書かれています。CLI でも同じ感じで、前述の仕様書の [25.2.1 MS-DOS header] には以下の定義が載っています。
0x4D | 0x5A | 0x90 | 0x00 | 0x03 | 0x00 | 0x00 | 0x00 | 0x04 | 0x00 | 0x00 | 0x00 | 0xFF | 0xFF | 0x00 | 0x00 |
0xB8 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x40 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 |
0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 |
0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | lfanew | |||
0x0E | 0x1F | 0xBA | 0x0E | 0x00 | 0xB4 | 0x09 | 0xCD | 0x21 | 0xB8 | 0x01 | 0x4C | 0xCD | 0x21 | 0x54 | 0x68 |
0x69 | 0x73 | 0x20 | 0x70 | 0x72 | 0x6F | 0x67 | 0x72 | 0x61 | 0x6D | 0x20 | 0x63 | 0x61 | 0x6E | 0x6E | 0x6F |
0x74 | 0x20 | 0x62 | 0x65 | 0x20 | 0x72 | 0x75 | 0x6E | 0x20 | 0x69 | 0x6E | 0x20 | 0x44 | 0x4F | 0x53 | 0x20 |
0x6D | 0x6F | 0x64 | 0x65 | 0x2E | 0x0D | 0x0D | 0x0A | 0x24 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 |
hello.exe では以下の図の通り、0x00000000 ~ 0x0000007F の内容がこれに該当します。
2. ヘッダ
PE フォーマットファイルのヘッダは、PE ヘッダ、PE オプショナルヘッダとそれに続くセクションヘッダで構成されます。特にあれ?な部分はないですのでサクサク進みましょう。
2-1. PE ヘッダ
PE ヘッダは [25.2.2 PE file header]、[25.2.3 PE optional header] に説明があります。PE シグネチャへの言及について、なぜか上述の [25.2.1 MS-DOS header] に記述があるので、最初はハマりました (-_-;) 全てがっちゃんこした定義は以下のようになります。ちなみに、IMAGE_NT_HEADERS 構造体として、%INCLUDE%\WinNT.h にも定義されてます。
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | Signature | シグネチャ。''P', 'E', '\0', '\0'。 |
4 | 2 | Machine | マシン種別。0x14C(Intel 386 を表す)。 |
6 | 2 | Number of Sections | セクションの数。 |
8 | 4 | Time/Date Stamp | ファイルが作成された時刻と日付を、1970/01/01 00:00:00 から秒刻みで指定。もしくは 0。 |
12 | 4 | Pointer to Symbol Table | 常に 0。 |
16 | 4 | Number of Symbols | 常に 0。 |
20 | 2 | Optional Header Size | PE オプショナルヘッダのサイズ。sizeof(IMAGE_OPTIONAL_HEADER)。 |
22 | 2 | Characteristics | ファイルの属性。CLI の場合、0x0002(実行可能かどうか)、0x0004(ファイルから行番号が除去されているかどうか)、0x0008(ファイルからローカルシンボルが除去されているかどうか)、0x0100(32bit ワードマシンかどうか)、0x2000(DLL かどうか)が指定される。 |
24 | 2 | Magic | シグネチャ。0x10B。 |
26 | 1 | LMajor | 常に 6。 |
27 | 1 | LMinor | 常に 0。 |
28 | 4 | Code Size | コード(.text)セクションのサイズ。もしそれらが複数のセクションに渡って存在するのであれば、それら全ての合計。 |
32 | 4 | Initialized Data Size | 初期化データを持つセクションのサイズ。もしそれらが複数のセクションに渡って存在するのであれば、それら全ての合計。 |
36 | 4 | Uninitialized Data Size | 未初期化データを持つセクションのサイズ。もしそれらが複数のセクションに渡って存在するのであれば、それら全ての合計。 |
40 | 4 | Entry Point RVA | x86 エントリポイントスタブの RVA。 |
44 | 4 | Base Of Code | コードセクション(.text セクション)の RVA(ローダーへのヒント)。 |
48 | 4 | Base Of Data | データセクション(.reloc セクション)の RVA(ローダーへのヒント)。 |
52 | 4 | Image Base | 常に 0x400000。 |
56 | 4 | Section Alignment | 常に 0x2000。 |
60 | 4 | File Alignment | 0x200 か 0x1000 のいずれか。 |
64 | 2 | OS Major | 常に 4。 |
66 | 2 | OS Minor | 常に 0。 |
68 | 2 | User Major | 常に 0。 |
70 | 2 | User Minor | 常に 0。 |
72 | 2 | SubSys Major | 常に 4。 |
74 | 2 | SubSys Minor | 常に 0。 |
76 | 4 | Reserved | 常に 0。 |
80 | 4 | Image Size | 全てのヘッダ、パディングを含めたサイズ。Section Alignment の倍数で指定。 |
84 | 4 | Header Size | MS-DOS Header、MS-DOS 用スタブプログラム、ヘッダを合わせたサイズ。パディングも含める。File Alignment の倍数で指定。 |
88 | 4 | File Checksum | 常に 0。 |
92 | 2 | SubSystem | MAGE_SUBSYSTEM_WINDOWS_CE_GUI (0x3) か IMAGE_SUBSYSTEM_WINDOWS_GUI (0x2) のいずれか |
94 | 2 | DLL Flags | 常に 0。 |
96 | 4 | Stack Reserve Size | 常に 0x100000 (1Mb)。 |
100 | 4 | Stack Commit Size | 常に 0x1000 (4Kb)。 |
104 | 4 | Heap Reserve Size | 常に 0x100000 (1Mb)。 |
108 | 4 | Heap Commit Size | 常に 0x1000 (4Kb)。 |
112 | 4 | Loader Flags | 常に 0。 |
116 | 4 | Number of Data Directories | 常に 0x10。 |
120 | 8 | Export Table | 常に 0。 |
128 | 8 | Import Table | Import Table Import Directory エントリへの RVA とサイズ。 |
136 | 8 | Resource Table | 常に 0。 |
144 | 8 | Exception Table | 常に 0。 |
152 | 8 | Certificate Table | 常に 0。 |
160 | 8 | Base Relocation Table | Relocation Table への RVA とサイズ。無ければ 0。 |
168 | 8 | Debug | 常に 0。 |
176 | 8 | Copyright | 常に 0。 |
184 | 8 | Global Ptr | 常に 0。 |
192 | 8 | TLS Table | 常に 0。 |
200 | 8 | Load Config Table | 常に 0。 |
208 | 8 | Bound Import | 常に 0。 |
216 | 8 | IAT | Import Address Table(IAT) RVAs への RVA とサイズ。 |
224 | 8 | Delay Import Descriptor | 常に 0。 |
232 | 8 | CLI Header | CLI ヘッダ への RVA とサイズ。 |
240 | 8 | Reserved | 常に 0。 |
hello.exe では以下の図の通り、0x00000080 ~ 0x00000177 の内容がこれに該当します。
2-2. .text セクションヘッダ
PE ヘッダに続いて、各セクションのヘッダが配置されます。最初は .text セクション(コードセクション)ヘッダ。ヘッダの定義は全て同じですので、ここで紹介するのみとします。ちなみに、IMAGE_SECTION_HEADER 構造体として、%INCLUDE%\WinNT.h にも定義されてます。
Offset | Size | Field | Description |
---|---|---|---|
0 | 8 | Name | 8バイト長、NULL パディングされた ASCII 文字列。 |
8 | 4 | VirtualSize | セクションの長さ。SizeOfRawData との差分は 0 パディングされる。 |
12 | 4 | VirtualAddress | メモリにロードされた際の、実行イメージのためのセクションの先頭アドレス。 |
16 | 4 | SizeOfRawData | ファイル上のセクションサイズ。PE ヘッダで指定した File Alignment の倍数で指定。 |
20 | 4 | PointerToRawData | ファイル上のセクションオフセット。PE ヘッダで指定した File Alignment の倍数で指定。 |
24 | 4 | PointerToRelocations | Relocation セクションへの RVA。 |
28 | 4 | PointerToLinenumbers | 常に 0。 |
32 | 2 | NumberOfRelocations | Relocation の数。無ければ 0。 |
34 | 2 | NumberOfLinenumbers | 常に 0。 |
36 | 4 | Characteristics | セクションの属性。0x00000020(実行可能コードを含む)、0x00000040(初期化データを含む)、0x00000080(未初期化データを含む)、0x20000000(実行可)、0x40000000(読み込み可)、0x80000000(書き込み可)を指定する。 |
2-3. .reloc セクションヘッダ
続いて .reloc セクションヘッダ。定義は省略。何に使われるかまだよくわかっていないです。近々ではまだ必要なさそうですが、追々調べないとだめですね・・・ (^_^;)
hello.exe では以下の図の通り、0x000001A0 ~ 0x000001C7 の内容がこれに該当します。
3. .text セクション
コードセクションと呼ばれ、実際の実行コードはここに入ります。色々入り混じっているためか、概観を自分で描いてみるまでは、構造が全くイメージできませんでした。仕様書で言うと、[25.3.1 Import Table and Import Address Table (IAT)]、[25.3.3 CLI header]、[25.4 Common Intermediate Language physical layout]、[24 Metadata physical layout] 辺りに記述があります。バイナリ上に登場する順に説明していきます。
3-1. Import Address Table(IAT) RVAs
インポートするメソッドを表す Hint/Name Table への RVAs が格納されます。PE ヘッダにある IAT の指す先がここ。NULL 終端の配列になっていますが、マネージドアセンブリでは、通常 mscoree.dll の _CorExeMain(exe 向け) か _CorDllMain(dll 向け)しかインポートしませんので、1 要素のみがある状態になります。似たようなテーブルに Import Lookup Table がありますが、IAT のほうは実行時に実際のメソッドへのアドレスに書き換えられます。DWORD の配列ですが、慣習的に %INCLUDE%\WinNT.h に定義されてる IMAGE_THUNK_DATA 構造体の配列として表されるみたいですね。
hello.exe では以下の図の通り、0x00000200 ~ 0x00000207 の内容がこれに該当します。
3-2. CLI ヘッダ
CLR のためのヘッダーです。定義は以下の通り。IMAGE_COR20_HEADER 構造体として、%INCLUDE%\WinNT.h にも定義を見つけることができます。ここに定義があるってことは、完全に Windows の機能として取り込まれてるのかな?
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | Cb | ヘッダのサイズ。sizeof(IMAGE_COR20_HEADER)。 |
4 | 2 | MajorRuntimeVersion | このプログラムを実行するのに必要な CLR の最低メジャーバージョン。現在は 2。 |
6 | 2 | MinorRuntimeVersion | 同じくマイナーバージョン。現在は 0。 |
8 | 8 | MetaData | メタデータへの RVA とサイズ。 |
16 | 4 | Flags | ラインタイムイメージの属性。0x00000001(常にセット)、0x00000002(32bit プロセスにのみ読み込まれるかどうか)、0x00000008(厳密名を持っているかどうか)、0x00010000(常にリセット)を必要に応じて指定する。 |
20 | 4 | EntryPointToken | ランタイムイメージのエントリポイント(MethodDef トークン)を指定。 |
24 | 8 | Resources | リソースの RVA とサイズ。 |
32 | 8 | StrongNameSignature | ハッシュデータの RVA。バインディングとバージョニングのために、CLI ローダーによって利用される。 |
40 | 8 | CodeManagerTable | 常に 0。 |
48 | 8 | VTableFixups | 関数ポインタの配列(vtable スロットなど)への RVA。 |
56 | 8 | ExportAddressTableJumps | 常に 0。 |
64 | 8 | ManagedNativeHeader | 常に 0。 |
hello.exe では以下の図の通り、0x00000208 ~ 0x0000024F の内容がこれに該当します。
3-3. IL メソッドボディ
IL については、今回の説明に使っている仕様書とは別に一本仕様書があるぐらいなので、ちょっとボリュームががが (^_^;) 今回は配置する際に必要になるメソッドヘッダの定義と、今回使用した部分だけ紹介させていただきますね。
CLI には 2 種類のメソッドヘッダが用意されていおり、それぞれ Tiny フォーマット、Fat フォーマットと呼ばれてます。今後触っていく予定の Profiling API で IL メソッドボディを取り替えるような場合には、これらの判定が必要になるでしょう。以下のような違いがあります。
・Tiny フォーマット
以下の条件の全てに当てはまる。
- ローカル変数なし。
- 例外なし。
- 拡張データセクションなし。
- IL のオペランドスタックが 8 以下。
以下の条件のいずれかに当てはまる。
- エンコードサイズが大きすぎる。少なくとも 64 バイト。
- 例外あり。
- 拡張データセクションあり。
- ローカル変数あり。
- IL のオペランドスタックが 8 より大きい。
Start Bit | Count of Bits | Description |
---|---|---|
0 | 2 | Tiny フォーマットを表すビットフラグ(0x2)。 |
2 | 6 | メソッドボディのサイズ。 |
hello.exe では以下の図の通り、0x00000250 ~ 0x00000267 の内容がこれに該当します。Tiny メソッドヘッダを持つ 2 つの IL メソッドボディが見つけられるかな?(0x00000250 ~ 0x0000025D, 0x0000025E ~ 0x00000265。残りは 0 パディング)
3-4. メタデータ
メタデータについては後で詳細を説明するので、ここでは省略。
hello.exe では以下の図の通り、0x00000268 ~ 0x000004DB の内容がこれに該当します。
3-5. Import Table Import Directory エントリ
インポートするメソッドを含む DLL の Name への RVA や、インポートするメソッドを表す Hint/Name Table への RVA が格納されます。PE ヘッダにある Import Table の指す先がここ。%INCLUDE%\WinNT.h に定義されてる IMAGE_IMPORT_DESCRIPTOR 構造体の NULL 終端配列になってます。ただ、やはりマネージドアセンブリでは、通常 mscoree.dll の _CorExeMain(exe 向け) か _CorDllMain(dll 向け)しかインポートしませんので、1 要素のみがある状態に。仕様書にある 20 バイトの 0 パディング領域は、これを明示的に示したものとなります。定義は以下の通り。
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | ImportLookupTable | Import Lookup Table RVAs への RVA。 |
4 | 4 | DateTimeStamp | 常に 0。 |
8 | 4 | ForwarderChain | 常に 0。 |
12 | 4 | Name | NULL 終端の ASCII 文字列、"mscoree.dll" への RVA。 |
16 | 4 | ImportAddressTable | Import Address Table(IAT) RVAs への RVA。 |
20 | 20 | 0 パディング |
3-6. Import Lookup Table RVAs
インポートするメソッドを表す Hint/Name Table への RVAs が格納されます。Import Table Import Directory エントリにある ImportLookupTable の指す先がここ。NULL 終端の配列になっていますが、マネージドアセンブリでは、通常 mscoree.dll の _CorExeMain(exe 向け) か _CorDllMain(dll 向け)しかインポートしませんので、1 要素のみがある状態になります。似たようなテーブルに IAT がありますが、Import Lookup Table のほうは実行時も書き換えられることはありません。DWORD の配列ですが、慣習的に %INCLUDE%\WinNT.h に定義されてる IMAGE_THUNK_DATA 構造体の配列として表されるのは同じですね。
hello.exe では以下の図の通り、0x00000504 ~ 0x0000050B の内容がこれに該当します。
3-7. Import Method Hint Name
インポートするメソッドを表す Hint/Name が格納されます。Import Address Table(IAT) RVAs および Import Lookup Table RVAs が指す先がここ。定義は以下の通りです。
Offset | Size | Field | Description |
---|---|---|---|
0 | 2 | Hint | 0 初期化。 |
2 | variable | Name | 大文字・小文字を識別する、NULL 終端 ASCII 文字列。exe 向けには"_CorExeMain"、dll 向けには"_CorDllMain"が入る。 |
3-8. Import DLL Name
インポートする DLL を表す Name が格納されます。Import Table Import Directory エントリにある Name の指す先がここ。NULL 終端の ASCII 文字列になっています。マネージドアセンブリでは、"mscoree.dll"が入ることになります。
hello.exe では以下の図の通り、0x0000051E ~ 0x0000052D の内容がこれに該当します。
3-9. x86 エントリポイントスタブ
0xFF, 0x25 は無条件ジャンプ命令(JMP)、オペランドには Import Address Table RVAs の先頭アドレスを指定することになってます。PE オプショナルヘッダの Entry Point RVA の指す先がここ。これで、mscoree.dll の _CorExeMain(exe 向け) か _CorDllMain(dll 向け)へジャンプすることになります。
hello.exe では以下の図の通り、0x0000052E ~ 0x00000533 の内容がこれに該当します。
4. .reloc セクション
これがまだなんのためにあるのかわかっていないというのが今後の課題です (^_^;) x86 エントリポイントスタブのオペランドが配置される RVA について、0xFFFFF000 のマスクをかけた情報を Fix-Up Table に持っているようなのですが、これがなんに使われるのか・・・。
4-1. Fix-Up Table
上にも書いた通り、入っているものはわかるのですが、いまいち用途がわかってません。仕様書では、[25.3.2 Relocations] に記載があります。CIL のみで構成されたイメージの場合、最低限 IMAGE_REL_BASED_HIGHLOW (0x3) 型の Fixup が x86 エントリポイントスタブのために必要となる、みたいなことが書いてあります。定義は以下の通り。IMAGE_BASE_RELOCATION 構造体として、%INCLUDE%\WinNT.h にも定義されてます。
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | PageRVA | Fixup の適用を必要とする RVA が入る。下位 12 ビットが 0 になるよう、0xFFFFF000 でマスクされたものが入る。 |
4 | 4 | Block Size | Fixup ブロックのトータルサイズ。PageRVA と Block Size フィールドも含む。4 の倍数になるよう調整。 |
8 | 4 bits | Type | 適用する Fixup 種別が入る。 |
8 | 12 bits | Offset | PageRVA で指定した RVA の残り 12 ビットを Offset として格納。 |
ここまでで PE ファイルフォーマットに関する仕様は終わりです。お次は積み残してあったメタデータの内容に切り込んで行きましょう。
Hello Meta Data!
最初はここまで中身を知ろうとは全くもって考えていなかったんですが、後から紹介する Meta Data API や他のアンマネージ API を使おうとすると、結局ここの知識が必要になるという・・・。System.Reflection.Emit 名前空間の各クラスや、Mono.Cecil がいかにユーザーフレンドリーに作られているかが身にしみてわかります。
ただ、PE ファイルフォーマットにあるような、あっちに行ったりこっちに行ったりということが少ないと思いますので、一度雰囲気が分かれば、目リフレクションもできないわけでもないかも。
仕様書では、[24 Metadata physical layout] というセクションで説明がされています。PE ファイルフォーマットで位置だけ触れた 3-4. メタデータの中身について、さらに細かく色分けしてみたのが以下の図です。構成要素は以下の通り:
1. メタデータルート
2. #~ ストリームヘッダ
3. #Strings ヒープヘッダ
4. #US ヒープヘッダ
5. #GUID ヒープヘッダ
6. #Blob ヒープヘッダ
7. #~ ストリーム
8. #Strings ヒープ
9. #US ヒープ
10.#GUID ヒープ
11.#Blob ヒープ
PE ファイルフォーマットの時にあった共通のルールはここでも有効です。では、各要素について早速見て行きたいと思います。
1. メタデータルート
PE ファイルフォーマット上では、IL メソッドボディに続いて、なにやら意味ありげな 'B', 'S', 'J', 'B', ・・・の Magic Signature が、メタデータルートの開始を教えてくれます。1998 年当時、CLR の開発チームの主要メンバーだった、Brian Harry さん、Susan Radke-Sproull さん、Jason Zander さん、そして Bill Evans さんらの頭文字を取ったものらしいですが、あらゆる .NET アプリケーションに、自身のイニシャルが刻み込まれるというのはすごいですよね。
偉大な先人に思いを馳せつつ、定義を見ていくことにします。仕様書では [24.2.1 Metadata root] で説明されています。
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | Signature | Magic Signature:0x424A5342。 |
4 | 2 | MajorVersion | メジャーバージョン。1 で固定。 |
6 | 2 | MinorVersion | マイナーバージョン。1 で固定。 |
8 | 4 | Reserved | 常に 0。予約されてる。 |
12 | 4 | Length | Version 文字列に割り当てられるバイト長(NULL 終端含む)。4 の倍数で調整される。 |
16 | m | Version | NULL 終端、UTF8 文字列。長さは Length で示される。 |
16+m | x-m | 4 の倍数で調整。余りは 0 パディング。 | |
16+x | 2 | Flags | 常に 0。予約されてる。 |
16+x+2 | 2 | Streams | ストリーム数。 |
hello.exe では以下の図の通り、0x00000268 ~ 0x00000287 の内容がこれに該当します。
2. #~ ストリームヘッダ
「ちるだ すとりーむ へっだ」と読めばいいのかな?#~ ストリームはメタデータの論理構造であるメタデータテーブルの物理的な表現ですが、これへのオフセット/長さを示します。なお、これ以降、メタデータルートに示されたストリーム数分ヘッダが続きますが、ヘッダの定義は全て同じですのでここで紹介するのみとしましょう。
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | Offset | このヘッダが示すストリーム開始位置のオフセット。メタデータルートの先頭からのバイト数で表す。 |
4 | 4 | Size | このヘッダが示すストリームのサイズ。4 の倍数で調整。 |
8 | Name | このヘッダが示すストリームの名前。NULL 終端、ASCII 文字列。4 の倍数で長さが調整され、残りは 0 パディング。32 文字以内に制限される。 |
3. #Strings ヒープヘッダ
#Strings ヒープへのオフセット/長さを提示します。
hello.exe では以下の図の通り、0x00000294 ~ 0x000002A7 の内容がこれに該当します。
4. #US ヒープヘッダ
#US ヒープへのオフセット/長さを提示します。
hello.exe では以下の図の通り、0x000002A8 ~ 0x000002B3 の内容がこれに該当します。
5. #GUID ヒープヘッダ
#GUID ヒープへのオフセット/長さを提示します。
hello.exe では以下の図の通り、0x000002B4 ~ 0x000002C3 の内容がこれに該当します。
6. #Blob ヒープヘッダ
#Blob ヒープへのオフセット/長さを提示します。
hello.exe では以下の図の通り、0x000002C4 ~ 0x000002D3 の内容がこれに該当します。
7. #~ ストリーム
メタデータの論理構造であるメタデータテーブルの物理的な表現です。仕様書では、[24.2.6 #~ stream] に説明があります。以下のような定義になってます。
Offset | Size | Field | Description |
---|---|---|---|
0 | 4 | Reserved | 常に 0。予約されてる。 |
4 | 1 | MajorVersion | メタデータテーブルスキーマのメジャーバージョン。2 で固定。 |
5 | 1 | MinorVersion | メタデータテーブルスキーマのマイナーバージョン。0 で固定。 |
6 | 1 | HeapSizes | ヒープサイズを表すフラグ。0x01(#String ヒープサイズ >= 2^16)、0x02(#GUID ヒープサイズ >= 2^16)、0x04(#Blob ヒープサイズ >= 2^16)を指定。 |
7 | 1 | Reserved | 常に 1。予約されてる。 |
8 | 8 | Valid | どのメタデータテーブルが存在するかを表すビット列。ビット位置が各メタデータテーブルの識別子に対応する。 |
16 | 8 | Sorted | どのメタデータテーブルがソートされているかを表すビット列。ビット位置が各メタデータテーブルの識別子に対応する。 |
24 | 4*n | Rows | 各メタデータテーブルの行数を unsigned int の配列で保持。 |
24+4*n | Tables | メタデータテーブルが列挙される。 |
見方は両方とも同じなので、Valid について hello.exe を例にとって説明します。そこには { 0x47, 0x14, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, ・・・ } という値が設定されてるはずですが、これはメモリ上の表現で最下位ビットから順に書き出すと、1110001000101000000000000000000010010000000000000000000000000000・・・ になります(リトルエンディアン!リトルエンディアン!)。この順にメタデータテーブルの識別子と対応を取っていくことになります。0 ビット目セット → Module テーブル(0x00)あり、1 ビット目セット → TypeRef テーブル(0x01)あり、2 ビット目セット → TypeDef テーブル(0x02)あり、3 ビット目は該当するメタデータテーブルないので飛ばし、4 ビット目リセット → Field テーブル(0x04)なし・・・みたいな感じで。しかしここは Mono.Cecil のソースコード読めなかったらホント理解できずに終わってました。危ない危ない。
各テーブルのレコード内容については、Meta Data API を使ったソースコード解説の時にも見ますので、ここでは割愛です。 hello.exe では以下の図の通り、0x000002D4 ~ 0x000003B7 の内容がこれに該当します。
8. #Strings ヒープ
各メタデータテーブルで扱う文字列がここに収められます。アセンブリ名とかメソッド名とかですね。可変長の NULL 終端 UTF-8 文字列が 1 つ 1 つのレコードになっています。各テーブルからはインデックスで参照されるので、効率が気になるところ。必要になるまでは名前を取得しない(メタデータトークンのまま引き回す)、1 回目でうまくキャッシュしておく、などの工夫が必要そうです。ちなみに 1 レコード目は必ず空文字になります。仕様書では、[24.2.3 #Strings heap] に説明があります。
hello.exe では以下の図の通り、0x000003B8 ~ 0x0000046B の内容がこれに該当します。
9. #US ヒープ
プログラム中で扱う固定文字列がここに収められます。hello.exe の例ですと"Hello World!"。
後述の #Blob ヒープと同様、各レコードは先頭にバイト長を持ち、その後その中身が続くという構造になってます。例によって先頭レコードは必ず "\0" です。hello.exe の例ですと、先頭の 1 バイトがバイト長を表す情報として扱われていまs・・・あれ?それだと 127 文字(#US ヒープでは NULL 終端 Unicode で文字列が取り扱われる)しか使えなくない?と思ってしまうのですが、以下のルールで無駄な領域を圧縮してるだけですのでご安心ください (^_^;)
- 最初の 4 バイトが 110bbbbb(2) + x + y + z(最上位ビットが 1、次のビットも 1、その次のビットが 0)だったら、bbbbb(2) << 24 + x << 16 + y << 8 + z(上位ワードの上位バイト残りの 5 ビットを 24 ビット左シフトして、上位ワードの下位バイトを 16 ビット左シフトしたものと、下位ワードの上位バイトを 8 ビット左シフトしたものと、下位ワードの下位バイトと足す)を実際の数値として扱う。
- 最初の 2 バイトが 10bbbbbb(2) + x(最上位ビットが 1、次のビットが 0)だったら、bbbbbb(2) << 8 + x(上位バイト残りの 6 ビットを 8 ビット左シフトして、下位バイトと足す)を実際の数値として扱う。
- 最初の 1 バイトが 0bbbbbbb(2)(最上位ビットが 0)だったら、bbbbbbb(2)(残りの 7 ビット)を実際の数値として扱う。
10.#GUID ヒープ
Module と紐付く GUID がここに収められます。仕様書では [24.2.5 #GUID heap] に記述があります。ここもコンパイルの度に変化する部分なので、DIFF を取る場合はうまく避けなければいけないですね。データ自体は特に取り立ててエンコーディングもされず、そのまま格納されます。
hello.exe では以下の図の通り、0x00000488 ~ 0x00000497 の内容がこれに該当します。
11.#Blob ヒープ
メソッドのシグネチャ、フィールドのシグネチャ、プロパティのシグネチャ、ローカル変数のシグネチャ、カスタム属性の情報、引数のシグネチャ、戻り値のシグネチャ、ジェネリクス等々、ありとあらゆる情報がごった煮になっている領域です。まあ、名前の通りといえばそうなのですが・・・。
#US ヒープと同様、各レコードは先頭にバイト長を持ち、その後その中身が続くという構造になってます。例によって先頭レコードは必ず "\0" です。仕様書では、[23.2 Blobs and signatures] や [24.2.4 #US and #Blob heaps] に記載があります。シグネチャも膨大な仕様がありますので、とりあえずは今回使用するものだけ、後ほど Meta Data API を使ったソースコードの説明をする際にみていただければと。
hello.exe では以下の図の通り、0x00000498 ~ 0x000004DB の内容がこれに該当します。
さあ、準備が整いました!意図しないバイナリが作られたとしても、もう怖くないでしょう。ソースコードを使った Meta Data API 解説へ進みます!
Hello Meta Data API!
ここまでの説明で、大体のアセンブリのバイナリについて、ざっくりは読めるようにはなったかと思います。そうすると、今度は実際に作って見ようという話になると思いますが、バイト列をごりごり書き込むのはさすがに大変なので、用意されている API に少しは負担をしてもらいましょう。前のセクションで概要だけ説明したメタデータテーブルの詳細な情報が必要になりますので、仕様書の [22 Metadata logical format: tables] や [23 Metadata logical format: other structures]、[25 File format extensions to PE] を参照しつつ書いていきます。
なお、申し訳ないことに、次回に続く Profiling API の調査につなげる意味もあり、今回のサンプルは COM サーバーとしてしか動かないです。冒頭で紹介させていただいたソースコードを動かす場合は、CppTroll/MetaDataApiSample01Test/MetaDataApiSample01Test.cpp で書いているようなドライバ(COM クライアント)が必要ですのでご注意を。メインの処理はできるだけ平易になるよう、CppTroll/MetaDataApiSample01/MetaDataApiSample01.cpp(48) にある HRESULT CExeCreator::Create(BSTR) にまとめました。"Hello World!" をコンソール出力するだけのプログラムを出力するという簡単な処理ですが、全体で 2000 loc ほどあるという・・・ (-_-;) まあ、のんびり見ていきましょう。
処理の概観は以下の通りです:
1. IMetaDataImport を使ってメタデータをインポート
1-1. インポートするメンバ、型、そしてアセンブリを取得するために mscorlib.dll をオープン
1-2. インポートする型を探す
1-3. インポートするメンバを探す
1-4. インポートするアセンブリの準備
2. IMetaDataEmit を使ってメタデータをエミット
2-1. エミットするメンバ、型、そしてアセンブリを格納するために hello.exe を定義
2-2. エミットするアセンブリの準備
2-3. AssemblyRef テーブルの作成
2-4. TypeRef テーブルの作成
2-5. MemberRef テーブルの作成
2-6. Assembly テーブルの作成
2-7. CustomAttribute テーブルの作成
2-8. TypeDef テーブルの作成
2-9. MethodDef テーブルの作成
2-10.#US ヒープの作成
2-11.Module の名前の設定
3. IL メソッドボディのエミット
4. ICeeFileGen を使って PE フォーマットファイルを生成
4-1. ICeeFileGen のための生成/破棄メソッドを抽出
4-2. PE フォーマットファイル生成のための準備
4-3. 上で作成したメタデータと PE フォーマットファイルのマージ
4-4. PE フォーマットファイルに全てのデータを書き込み
Meta Data API にも共通の決め事は適用されます・・・ってことは、RVA とかシグネチャが生のまま出てくるってことですね (T_T)
1. IMetaDataImport を使ってメタデータをインポート
Meta Data API でメタデータをインポートするには IMetaDataImport を使います。この API を使うことで、読み込みの際に PE ファイルフォーマットを意識しなくて済むようになるのはいいですね。CppTroll/MetaDataApiSample01/MetaDataApiSample01.cpp(59) 辺りからがこの処理。順に見ていきます。
1-1. インポートするメンバ、型、そしてアセンブリを取得するために mscorlib.dll をオープン
////////////////////////////////////////////////////////////////////////////////////// // Open mscorlib.dll to get importing members, types and the assembly // path corSystemDirectoryPath; path mscorlibPath; { WCHAR buffer[MAX_PATH] = { 0 }; DWORD length = 0; hr = ::GetCORSystemDirectory(buffer, MAX_PATH, &length); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); corSystemDirectoryPath = buffer; mscorlibPath = buffer; mscorlibPath /= L"mscorlib.dll"; } CComPtr<IMetaDataDispenserEx> pDispMSCorLib; hr = ::CoCreateInstance(CLSID_CorMetaDataDispenser, NULL, CLSCTX_INPROC_SERVER, IID_IMetaDataDispenserEx, reinterpret_cast<void**>(&pDispMSCorLib)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); CComPtr<IMetaDataImport2> pImpMSCorLib; hr = pDispMSCorLib->OpenScope(mscorlibPath.wstring().c_str(), ofRead, IID_IMetaDataImport2, reinterpret_cast<IUnknown**>(&pImpMSCorLib)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);Meta Data API では、1 つ 1 つのアセンブリを扱う領域をスコープと呼んでます。今回利用するのは mscorlib.dll にある型だけですので、これを新しいスコープに読み込みます。69 行目~77 行目で CLR のシステムディレクトリを取得し、mscorlib.dll へのフルパスを生成。80 行目~85 行目でスコープを扱うための IMetaDataDispenserEx オブジェクトを生成。88 行目~93 行目で mscorlib.dll をスコープに読み込み、IMetaDataImport オブジェクトを生成します。
1-2. インポートする型を探す
////////////////////////////////////////////////////////////////////////////////////// // Find importing types // mdTypeDef mdtdObject = mdTypeDefNil; hr = pImpMSCorLib->FindTypeDefByName(L"System.Object", NULL, &mdtdObject); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); mdTypeDef mdtdCompilationRelaxationsAttribute = mdTypeDefNil; hr = pImpMSCorLib->FindTypeDefByName( L"System.Runtime.CompilerServices.CompilationRelaxationsAttribute", NULL, &mdtdCompilationRelaxationsAttribute); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); mdTypeDef mdtdRuntimeCompatibilityAttribute = mdTypeDefNil; hr = pImpMSCorLib->FindTypeDefByName( L"System.Runtime.CompilerServices.RuntimeCompatibilityAttribute", NULL, &mdtdRuntimeCompatibilityAttribute); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); mdTypeDef mdtdConsole = mdTypeDefNil; hr = pImpMSCorLib->FindTypeDefByName(L"System.Console", NULL, &mdtdConsole); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);100 行目に mdTypeDef って型が現れました。Meta Data API では、前述のメタデータテーブルと 1 対 1 に対応するメタデータトークンを使ってメタデータを操作します。mdTypeDef もこのメタデータトークンの 1 つで TypeDef テーブル(0x02)に対応します。型と言っても、内部的には単なる unsigned int 型で、最上位バイトが種別、下位 3 バイトがレコード番号(RID)を表すことになってます。例えば、101 行目で "System.Object" を対象のスコープから探していますが、"System.Object" は mscorlib.dll の TypeDef テーブル(0x02)に 2 番目のレコードとして登録されていますので、mdtdObject には、0x02000002 が入ってくることになります。105 行目~122 行目も同様に、必要な型のメタデータトークンを取得しておきます。
1-3. インポートするメンバを探す
////////////////////////////////////////////////////////////////////////////////////// // Find importing members // mdMethodDef mdmdCompilationRelaxationsAttributeCtor = mdMethodDefNil; { COR_SIGNATURE pSigBlob[] = { IMAGE_CEE_CS_CALLCONV_HASTHIS, // HASTHIS 1, // ParamCount ELEMENT_TYPE_VOID, // RetType ELEMENT_TYPE_I4 // ParamType }; ULONG sigBlobSize = sizeof(pSigBlob) / sizeof(COR_SIGNATURE); hr = pImpMSCorLib->FindMethod(mdtdCompilationRelaxationsAttribute, L".ctor", pSigBlob, sigBlobSize, &mdmdCompilationRelaxationsAttributeCtor); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); } mdMethodDef mdmdRuntimeCompatibilityAttributeCtor = mdMethodDefNil; { COR_SIGNATURE pSigBlob[] = { IMAGE_CEE_CS_CALLCONV_HASTHIS, // HASTHIS 0, // ParamCount ELEMENT_TYPE_VOID // RetType }; ULONG sigBlobSize = sizeof(pSigBlob) / sizeof(COR_SIGNATURE); hr = pImpMSCorLib->FindMethod(mdtdRuntimeCompatibilityAttribute, L".ctor", pSigBlob, sigBlobSize, &mdmdRuntimeCompatibilityAttributeCtor); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); } // ・・・ 同様に、System.Console.WriteLine、System.Object..ctor を探す。型が見つかったら、次はインポートするメンバを探します。シグネチャはここで必要になります。ぱっと見、なるほど、リフレクションと同じで、引数の型や数を問い合わせに含めればいいだけね、と思うのですが、バイトの並びが決まっているという大きな罠ががが・・・これはめんどい・・・('A`) 使いやすいライブラリを作るときは、ここのラップは必須ですね。シグネチャの定義は、[23.2.1 MethodDefSig]、[23.2.11 RetType]、[23.2.10 Param]、[23.2.12 Type] を参照しながら、当てはまる項目を並べていきましょう。
1-4. インポートするアセンブリの準備
////////////////////////////////////////////////////////////////////////////////////// // Prepare importing assembly // CComPtr<IMetaDataAssemblyImport> pAsmImpMSCorLib; hr = pImpMSCorLib->QueryInterface(IID_IMetaDataAssemblyImport, reinterpret_cast<void**>(&pAsmImpMSCorLib)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); mdAssembly mdaMSCorLib = mdAssemblyNil; hr = pAsmImpMSCorLib->GetAssemblyFromScope(&mdaMSCorLib); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); auto_ptr<PublicKeyBlob> pMSCorLibPubKey; DWORD msCorLibPubKeySize = 0; auto_ptr<WCHAR> msCorLibName; ASSEMBLYMETADATA amdMSCorLib; ::ZeroMemory(&amdMSCorLib, sizeof(ASSEMBLYMETADATA)); DWORD msCorLibAsmFlags = 0; { ULONG nameSize = 0; DWORD asmFlags = 0; hr = pAsmImpMSCorLib->GetAssemblyProps(mdaMSCorLib, NULL, NULL, NULL, NULL, 0, &nameSize, &amdMSCorLib, &asmFlags); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); msCorLibAsmFlags |= (asmFlags & ~afPublicKey); msCorLibName = auto_ptr<WCHAR>(new WCHAR[nameSize]); amdMSCorLib.szLocale = amdMSCorLib.cbLocale ? new WCHAR[amdMSCorLib.cbLocale] : NULL; amdMSCorLib.rOS = amdMSCorLib.ulOS ? new OSINFO[amdMSCorLib.ulOS] : NULL; amdMSCorLib.rProcessor = amdMSCorLib.ulProcessor ? new ULONG[amdMSCorLib.ulProcessor] : NULL; void *pPubKey = NULL; hr = pAsmImpMSCorLib->GetAssemblyProps(mdaMSCorLib, const_cast<const void**>(&pPubKey), &msCorLibPubKeySize, NULL, msCorLibName.get(), nameSize, NULL, &amdMSCorLib, NULL); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); if (msCorLibPubKeySize) if (!::StrongNameTokenFromPublicKey(reinterpret_cast<BYTE*>(pPubKey), msCorLibPubKeySize, reinterpret_cast<BYTE**>(&pPubKey), &msCorLibPubKeySize)) return COMError(::StrongNameErrorInfo(), __FILE__, __LINE__); pMSCorLibPubKey = auto_ptr<PublicKeyBlob>( reinterpret_cast<PublicKeyBlob*>(new BYTE[msCorLibPubKeySize])); ::memcpy_s(pMSCorLibPubKey.get(), msCorLibPubKeySize, pPubKey, msCorLibPubKeySize); if (msCorLibPubKeySize) ::StrongNameFreeBuffer(reinterpret_cast<BYTE*>(pPubKey)); }IMetaDataImport として扱っていたオブジェクトを一旦 IMetaDataAssemblyImport に扱い直し(196 行目~)、アセンブリを最終的にインポートする際に必要になる情報の準備をしておきます。mscorlib.dll は厳密名を持っていますので、アセンブリを参照するには公開キーが必要となります。領域の節約のため、通常は公開キーそのものでなく、公開キーを表すトークン(PublicKeyToken)を扱うので、その変換も 239 行目~252 行目で行っています。
2. IMetaDataEmit を使ってメタデータをエミット
Meta Data API でメタデータをエミットするには IMetaDataEmit を使います。この API を使えば、最終的に PE ファイルフォーマットを意識せずに書き込める、なんてことはありません(えー。IMetaDataEmit がやってくれることは、前述のメタデータ部分の作成を手伝ってくれることで、IL メソッドボディや、PE ファイルフォーマットは別に扱うことになるんですよね・・・。
さて、メタデータテーブルの作成でしたら、IMetaDataEmit にはほぼ 1 対 1 に対応したインターフェースがありますので、これを使っていきます。CppTroll/MetaDataApiSample01/MetaDataApiSample01.cpp(260) 辺りからがこの処理になります。
あと、「エミット」って日本語になりきれてないとは思うのですが、いい翻訳が見つからない・・・ (T_T) スミマセン。
2-1. エミットするメンバ、型、そしてアセンブリを格納するために hello.exe を定義
////////////////////////////////////////////////////////////////////////////////////// // Define hello.exe to store emitting members, types and the assembly // CComPtr<IMetaDataDispenserEx> pDispHello; hr = ::CoCreateInstance(CLSID_CorMetaDataDispenser, NULL, CLSCTX_INPROC_SERVER, IID_IMetaDataDispenserEx, reinterpret_cast<void**>(&pDispHello)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); CComPtr<IMetaDataEmit2> pEmtHello; hr = pDispHello->DefineScope(CLSID_CorMetaDataRuntime, 0, IID_IMetaDataEmit2, reinterpret_cast<IUnknown**>(&pEmtHello)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);hello.exe を作成する新しいアセンブリを扱うことになるので、やはり新しいスコープを生成し、IMetaDataEmit に割り当てます。特に何か特殊なことをやっているわけではないので、次へ進みます。
2-2. エミットするアセンブリの準備
////////////////////////////////////////////////////////////////////////////////////// // Prepare emitting assembly // CComPtr<IMetaDataAssemblyEmit> pAsmEmtHello; hr = pEmtHello->QueryInterface(IID_IMetaDataAssemblyEmit, reinterpret_cast<void**>(&pAsmEmtHello)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);アセンブリの情報を扱うために、IMetaDataEmit を IMetaDataAssemblyEmit としても扱えるようにしておきます。さくさく進みます。
2-3. AssemblyRef テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create AssemblyRef table // mdAssemblyRef mdarMSCorLib = mdAssemblyRefNil; hr = pAsmEmtHello->DefineAssemblyRef(pMSCorLibPubKey.get(), msCorLibPubKeySize, msCorLibName.get(), &amdMSCorLib, NULL, 0, msCorLibAsmFlags, &mdarMSCorLib); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);1-4. インポートするアセンブリの準備で作成しておいた公開キーを表すトークンと名前を使い、AssemblyRef テーブルを作成します。仕様書では、[22.5 AssemblyRef : 0x23] に詳細な情報が記述されています。#Strings ヒープや #Blob ヒープへの書き込みは、意識しなくていいようになってます。
2-4. TypeRef テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create TypeRef table // mdTypeRef mdtrObject = mdTypeRefNil; hr = pEmtHello->DefineTypeRefByName(mdarMSCorLib, L"System.Object", &mdtrObject); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); mdTypeRef mdtrCompilationRelaxationsAttribute = mdTypeRefNil; hr = pEmtHello->DefineTypeRefByName(mdarMSCorLib, L"System.Runtime.CompilerServices.CompilationRelaxationsAttribute", &mdtrCompilationRelaxationsAttribute); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); // ・・・ 同様に System.Runtime.CompilerServices.RuntimeCompatibilityAttribute、System.Console への TypeRef レコードを追加TypeRef テーブルの作成には DefineTypeRefByName メソッドを使います。もう一つそれっぽい DefineImportType メソッドっていうのがあるのですが、こちらはどうも上手く動きません。頼りの SSCLI 2.0 の csc でも、利用箇所が非常に限定されており、デバッグブレークできる状態に持っていけなかったり。TypeRef テーブルについては、仕様書 [22.38 TypeRef : 0x01] にある通り、DefineTypeRefByName に渡している情報で事足りるようなのでこれでいいのかもしれませんが、次に出てくる DefineImportMember と対象性がないせいで心配になります。また一段絡したら調べてみたいですね。
2-5. MemberRef テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create MemberRef table // mdMemberRef mdmrCompilationRelaxationsAttributeCtor = mdMemberRefNil; hr = pEmtHello->DefineImportMember(pAsmImpMSCorLib, NULL, 0, pImpMSCorLib, mdmdCompilationRelaxationsAttributeCtor, pAsmEmtHello, mdtrCompilationRelaxationsAttribute, &mdmrCompilationRelaxationsAttributeCtor); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); mdMemberRef mdmrRuntimeCompatibilityAttributeCtor = mdMemberRefNil; hr = pEmtHello->DefineImportMember(pAsmImpMSCorLib, NULL, 0, pImpMSCorLib, mdmdRuntimeCompatibilityAttributeCtor, pAsmEmtHello, mdtrRuntimeCompatibilityAttribute, &mdmrRuntimeCompatibilityAttributeCtor); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); // ・・・ 同様に System.Console.WriteLine、System.Object..ctor への MemberRef レコードを追加MemberRef テーブルの作成です。仕様書では、[22.25 MemberRef : 0x0A] に言及があります。仰々しい数の引数を要求されますが、今まで使ったものを指定するだけです。ただ、前述の通り、TypeRef テーブルの作成時に使うメソッドと対象性がなく、また要求される情報が仕様書に比べ極端に多いので不安が残るのですが・・・。とりあえず今回は、次に進みましょう。あ、IL メソッドボディの作成に必要になるので、各メソッドの mdMemberRef トークンには、後からでもアクセスできるようにしておく必要があります。
2-6. Assembly テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create Assembly table // mdAssembly mdaHello = mdAssemblyNil; ASSEMBLYMETADATA amdHello; ::ZeroMemory(&amdHello, sizeof(ASSEMBLYMETADATA)); hr = pAsmEmtHello->DefineAssembly(NULL, 0, CALG_SHA1, L"hello", &amdHello, afPA_None, &mdaHello); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);Assembly テーブルの作成です。Assembly テーブルの作成は、IMetaDataEmit ではなく IMetaDataAssemblyEmit を使うので、ちょっと注意が必要かもです。仕様書では、[22.2 Assembly : 0x20] として記載があります。
2-7. CustomAttribute テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create CustomAttribute table // WORD const CustomAttributeProlog = 0x0001; mdCustomAttribute mdcaCompilationRelaxationsAttribute = mdCustomAttributeNil; { SimpleBlob sb; sb.Put<WORD>(CustomAttributeProlog); // Prolog sb.Put<DWORD>(8); // FixedArg, int32: 8 sb.Put<WORD>(0); // NumNamed, Count: 0 hr = pEmtHello->DefineCustomAttribute(mdaHello, mdmrCompilationRelaxationsAttributeCtor, sb.Ptr(), sb.Size(), &mdcaCompilationRelaxationsAttribute); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); } mdCustomAttribute mdcaRuntimeCompatibilityAttribute = mdCustomAttributeNil; { SimpleBlob sb; sb.Put<WORD>(CustomAttributeProlog); // Prolog sb.Put<WORD>(1); // NumNamed, Count: 1 sb.Put<BYTE>(SERIALIZATION_TYPE_PROPERTY); // PROPERTY sb.Put<BYTE>(ELEMENT_TYPE_BOOLEAN); // FieldOrPropType: bool { string name("WrapNonExceptionThrows"); sb.Put<BYTE>(name.size()); // Name Length: 22 sb.Put(name.c_str(), name.size()); // Name: "WrapNonExceptionThrows" } sb.Put<BYTE>(1); // FixedArg, bool: 1 hr = pEmtHello->DefineCustomAttribute(mdaHello, mdmrRuntimeCompatibilityAttributeCtor, sb.Ptr(), sb.Size(), &mdcaRuntimeCompatibilityAttribute); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); }CustomAttribute テーブルの作成も非常に骨が折れるものの 1 つですね。仕様書にある [23.3 Custom attributes] を参照しながら、注意深く #Blob ヒープに入れ込む情報を構成する必要があります。395 行目に現れる SimpleBlob クラスは型に応じて自動的にメモリ領域を拡張しながらバイト列を生成していくクラスです。中ではパフォーマンスのため、ある程度のサイズのメモリを最初に確保し、そこから切り崩していくような形でメモリの割り当てができる、MS 謹製の CQuickArray<T> というクラスを利用してます。%INCLUDE%\corhlpr.h、%INCLUDE%\corhlpr.cpp 辺りにはこのような CLI 向けのヘルパーがいくつも定義されてますので、参考にしてみるのも良いかもしれません。
2-8. TypeDef テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create TypeDef table // mdTypeDef mdtdMainApp = mdTypeDefNil; hr = pEmtHello->DefineTypeDef(L"MainApp", tdNotPublic | tdBeforeFieldInit, mdtrObject, NULL, &mdtdMainApp); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);TypeDef テーブルの作成は、仕様書 [22.37 TypeDef : 0x02] のごちゃごちゃした定義の割りには、シンプルなメソッドを呼び出すだけで済むようになってます。ここではガワだけ作成し、FieldList や MethodList、他のクラスの継承やインターフェースの実装などは後付設定が可能なようですね。が、とりあえず複雑なクラス定義を作成するときまで、それらの使用方法は先延ばし。先に進みます。
2-9. MethodDef テーブルの作成
////////////////////////////////////////////////////////////////////////////////////// // Create MethodDef table // mdMethodDef mdmdMainAppMain = mdMethodDefNil; { COR_SIGNATURE pSigBlob[] = { IMAGE_CEE_CS_CALLCONV_DEFAULT, // DEFAULT 0, // ParamCount ELEMENT_TYPE_VOID // RetType }; ULONG sigBlobSize = sizeof(pSigBlob) / sizeof(COR_SIGNATURE); hr = pEmtHello->DefineMethod(mdtdMainApp, L"Main", fdPublic | mdHideBySig | mdStatic, pSigBlob, sigBlobSize, 0, 0, &mdmdMainAppMain); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); } mdMethodDef mdmdMainAppCtor = mdMethodDefNil; { COR_SIGNATURE pSigBlob[] = { IMAGE_CEE_CS_CALLCONV_HASTHIS, // HASTHIS 0, // ParamCount ELEMENT_TYPE_VOID // RetType }; ULONG sigBlobSize = sizeof(pSigBlob) / sizeof(COR_SIGNATURE); hr = pEmtHello->DefineMethod(mdtdMainApp, L".ctor", fdPublic | mdHideBySig | mdSpecialName, pSigBlob, sigBlobSize, 0, 0, &mdmdMainAppCtor); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); }MethodDef テーブルの作成では、1-3. インポートするメンバを探すと同様、シグネチャの設定が必要です。[23.2.1 MethodDefSig]、[23.2.11 RetType]、[23.2.10 Param]、[23.2.12 Type] を参照しながら、当てはまる項目を並べていきましょう。
2-10.#US ヒープの作成
////////////////////////////////////////////////////////////////////////////////////// // Create #US stream // mdString mdsHelloWorld = mdStringNil; { wstring text(L"Hello World!"); hr = pEmtHello->DefineUserString(text.c_str(), text.size(), &mdsHelloWorld); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); }コンソールに表示する "Hello World!" は #US ヒープに作成します。DefineUserString メソッドは特に複雑なことをやる必要はないですね。この後、IL メソッドボディで使うので、mdString トークンは取っておきます。
2-11.Module の名前の設定
////////////////////////////////////////////////////////////////////////////////////// // Set Module name hr = pEmtHello->SetModuleProps(L"hello.exe"); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);最後に Module の名前を設定してエミットは一通り終わり。あれ?Module テーブルって作ってなくね?と感じられる方もいらっしゃるかと思いますが、どうも DefineScope とかの際に自動的に生成されてるみたいです。ちなみに、SetModuleProps をやらなくてもアプリは動きます。Reflector では逆アセンブルができなくなりますががが。
3. IL メソッドボディのエミット
////////////////////////////////////////////////////////////////////////////////////// // Emit Method body // SimpleBlob mbMainAppMain; mbMainAppMain.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_NOP].byte2); mbMainAppMain.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_LDSTR].byte2); mbMainAppMain.Put<DWORD>(mdsHelloWorld); mbMainAppMain.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_CALL].byte2); mbMainAppMain.Put<DWORD>(mdmrConsoleWriteLine); mbMainAppMain.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_NOP].byte2); mbMainAppMain.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_RET].byte2); SimpleBlob mbMainAppCtor; mbMainAppCtor.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_LDARG_0].byte2); mbMainAppCtor.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_CALL].byte2); mbMainAppCtor.Put<DWORD>(mdmrObjectCtor); mbMainAppCtor.Put<BYTE>(OpCodes::Encodings[OpCodes::CEE_RET].byte2);手元に各メタデータテーブル要素のトークンが揃っていれば、IL メソッドボディのエミットは驚くほど簡単です。本当にちゃんと作っていくためには、SimpleBlob では機能が足りないですが、結構行けるものですね。ちなみに、nop 命令が入っているのは、動かなかったときに、SSCLI 2.0 の csc で出力した場合の IL メソッドボディに、なるべく近づくようにしたつもりなのですがなくても良かったかも (^_^;)
ldstr 命令のオペランドには、#US ヒープに格納した "Hello World!" を表すトークンを(508 行目)、call 命令のオペランドには、MemberRef テーブルに格納した System.Console.WriteLine を表すトークンを(510 行目)指定します。コンストラクタでは、基底クラスのコンストラクタを呼ぶようにするのをお忘れなく(517 行目)。
ここで使用している OpCodes ですが、SMC や SSCLI 2.0 の csc をみると、%INCLUDE%\opcode.def をうまいこと列挙型や const な配列内に #include していることがわかります。マクロがある言語ならではの技ですね。今回のもそれを参考にしてみました。
4. ICeeFileGen を使って PE フォーマットファイルを生成
IMetaDataEmit が PE ファイルフォーマットの面倒まで見てはくれないと知ってから PE フォーマットの勉強を始めたのですが、最終的にはそれ用の API があったというオチ。その名も ICeeFileGen です。アンマネージ API リファレンスを見ると、Host API に属すこのクラス(COM インターフェースではなく、クラスです!)、MSDN 的には詳細な説明もなく、使って欲しくない感全開なのですが、リファレンス実装である SSCLI 2.0 の csc を参考にすることで使い方はバッチリわかるのでしれっと利用します。ちなみに、以前の記事で使い方を探していた ICeeGen は罠なので気をつけましょう(インスタンスを生成する方法がない。加えて .NET 4 になってからは Obsolete とされてしまったので、今後これが使えるようになる日はないと思われ)。
4-1. ICeeFileGen のための生成/破棄メソッドを抽出
////////////////////////////////////////////////////////////////////////////////// // Extract the creating/destroying methods for ICeeFileGen // typedef HRESULT (__stdcall *CreateCeeFileGenPtr)(ICeeFileGen **ceeFileGen); typedef HRESULT (__stdcall *DestroyCeeFileGenPtr)(ICeeFileGen **ceeFileGen); CreateCeeFileGenPtr pfnCreateCeeFileGen = NULL; DestroyCeeFileGenPtr pfnDestroyCeeFileGen = NULL; pfnCreateCeeFileGen = reinterpret_cast<CreateCeeFileGenPtr>( ::GetProcAddress(hmodCorPE, "CreateICeeFileGen")); if (!pfnCreateCeeFileGen) return SystemError(::GetLastError(), __FILE__, __LINE__); pfnDestroyCeeFileGen = reinterpret_cast<DestroyCeeFileGenPtr>( ::GetProcAddress(hmodCorPE, "DestroyICeeFileGen")); if (!pfnDestroyCeeFileGen) return SystemError(::GetLastError(), __FILE__, __LINE__); ICeeFileGen* pCeeFileGen = NULL; hr = pfnCreateCeeFileGen(&pCeeFileGen); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); BOOST_SCOPE_EXIT((pCeeFileGen)(pfnDestroyCeeFileGen)) { pfnDestroyCeeFileGen(&pCeeFileGen); } BOOST_SCOPE_EXIT_END使い方がバッチリ、とは言っても元々 .NET インフラ向けのクラスですので、生成や破棄は一手間かかります。mscoree.dll から ICeeFileGen 生成用のメソッドである CreateCeeFileGen の取得(549 行目)および 破棄用のメソッドである DestroyCeeFileGen を取得(554 行目)します。生成と破棄処理の事前登録もここで行っておきましょう(560 行目~567 行目)。
4-2. PE フォーマットファイル生成のための準備
////////////////////////////////////////////////////////////////////////////////// // Prepare to generate the PE format file // HCEEFILE ceeFile = NULL; hr = pCeeFileGen->CreateCeeFileEx(&ceeFile, ICEE_CREATE_FILE_PURE_IL); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); BOOST_SCOPE_EXIT((ceeFile)(pCeeFileGen)) { pCeeFileGen->DestroyCeeFile(&ceeFile); } BOOST_SCOPE_EXIT_END hr = pCeeFileGen->SetOutputFileName(ceeFile, L"hello.exe"); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); hr = pCeeFileGen->SetComImageFlags(ceeFile, COMIMAGE_FLAGS_ILONLY); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); hr = pCeeFileGen->SetSubsystem(ceeFile, IMAGE_SUBSYSTEM_WINDOWS_CUI, 4, 0); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);ICeeFileGen を利用し、PE フォーマットファイル出力のための事前準備をしてしまいます。情報の操作に利用する共通のハンドルを生成し(575 行目)、ファイル名の設定(584 行目)、イメージのフラグ設定(588 行目)、イメージのサブシステムの設定(592 行目)を行っておきます。
4-3. 上で作成したメタデータと PE フォーマットファイルのマージ
////////////////////////////////////////////////////////////////////////////////// // Merge the meta data created above and the PE format file // HCEESECTION textSection = NULL; hr = pCeeFileGen->GetIlSection(ceeFile, &textSection); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); { COR_ILMETHOD_FAT fatHeader; ::ZeroMemory(&fatHeader, sizeof(COR_ILMETHOD_FAT)); fatHeader.SetMaxStack(1); fatHeader.SetCodeSize(mbMainAppMain.Size()); fatHeader.SetLocalVarSigTok(mdTokenNil); fatHeader.SetFlags(0); unsigned headerSize = COR_ILMETHOD::Size(&fatHeader, false); unsigned totalSize = headerSize + mbMainAppMain.Size(); BYTE *pBuffer = NULL; hr = pCeeFileGen->GetSectionBlock(textSection, totalSize, 1, reinterpret_cast<void**>(&pBuffer)); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); ULONG offset = 0; hr = pCeeFileGen->GetSectionDataLen(textSection, &offset); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); offset -= totalSize; ULONG codeRVA = 0; hr = pCeeFileGen->GetMethodRVA(ceeFile, offset, &codeRVA); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); hr = pEmtHello->SetMethodProps(mdmdMainAppMain, -1, codeRVA, 0); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); pBuffer += COR_ILMETHOD::Emit(headerSize, &fatHeader, false, pBuffer); ::memcpy_s(pBuffer, totalSize - headerSize, mbMainAppMain.Ptr(), mbMainAppMain.Size()); } // ・・・ 同様に MainApp..ctor の IL メソッドボディも作成
pCeeFileGen->SetEntryPoint(ceeFile, mdmdMainAppMain); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__); hr = pCeeFileGen->EmitMetaDataEx(ceeFile, pEmtHello); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);ここまで来ればもう一息!今まで作成した IL メソッドボディ、メタデータを順にマージします。PE フォーマットのところで書いた通り、IL メソッドボディはメタデータとは別に領域を確保し、RVA の計算などをする必要がありますが、ICeeFileGen のメソッドを使うことで、GetSectionBlock(領域確保)→GetSectionDataLen(確保後の.text セクションサイズ取得)→GetMethodRVA(オフセットを RVA に変換)のわずか 3 ステップでできちゃいます(617 行目~632 行目)。出来上がった RVA は、IMetaDataEmit::SetMethodProps で対応する MethodDef テーブルに記録しておきます。また、今回は FAT メソッドヘッダは使わないと言いつつ COR_ILMETHOD_FAT 構造体が出てきてますが(607 行目)、SSCLI 2.0 の csc のソースを見る限り兼用してるみたい。COR_ILMETHOD::Emit でよしなにしてくれます(638 行目)。
あとはこれ以外のメタデータ。ICeeFileGen::EmitMetaDataEx に IMetaDataEmit を与えるだけというお手軽さです。なぜ最後だけがんばったし (^_^;)
4-4. PE フォーマットファイルに全てのデータを書き込み
////////////////////////////////////////////////////////////////////////////////// // Write all data to the PE format file // hr = pCeeFileGen->GenerateCeeFile(ceeFile); if (FAILED(hr)) return COMError(hr, __FILE__, __LINE__);書き込みもこれだけです。なぜ最後だけがんばったし (^_^;)
さて、実行すれば、晴れて hello.exe が出力されるはずです。お気に入りのバイナリエディタで中身を見て、また最初から見直すと理解が深まるでしょう。
おわった~
長すぎです (ToT) 駆け足でやってもこのボリュームとは・・・。11 年目に始める CLI の基礎。仕事ではなかなかこういう低レイヤな部分をいじることはできないですが、いかがでしたか?これを機に開発者向けツール開発に興味を持っていただける方が、少しでも増えるとうれしいですね。
私はこれでやっと次に進めます。次は Profiling API ですっ!
0 件のコメント:
コメントを投稿