C#の背後で何が起こるか:スタックの操作の基本

オブジェクトの初期化、メソッドの呼び出し、パラメーターの引き渡しという単純な行の背後にあるすべてのものを調べることをお勧めします。 もちろん、実際にこの情報を使用すると、呼び出し元のメソッドのスタックが減算されます。



免責事項



ストーリーを始める前に、 StructLayoutに関する最初の投稿を読むことを強くお勧めします。 この記事で使用する例があります。



上位レベルの背後にあるすべてのコードはデバッグモード用に提示されており、概念的な基礎を示しているのは彼です。 また、上記のすべては32ビットプラットフォームで考慮されます。 JIT最適化は、ここでは考慮されない別個の大きなトピックです。



また、この記事には実際のプロジェクトに適用する価値のある資料が含まれていないことを警告します。



理論から始める



最終的に、コードはマシンコマンドのセットになります。 最も理解しやすいのは、1つ(または複数の)機械命令に直接対応するアセンブリ言語命令の形式での表現です。









簡単な例に移る前に、ソフトウェアスタックとは何かを理解することをお勧めします。 ソフトウェアスタックは、主に、さまざまな種類のデータを保存するために原則として使用されるメモリの一部です(原則として、 一時データと呼ばれる場合があります )。 また、スタックが下位アドレスに向かって成長していることを覚えておく価値があります。 つまり、オブジェクトが後でスタックにプッシュされると、そのアドレスは小さくなります。



次に、アセンブラー言語の次のコードを見てみましょう(デバッグモードに固有の呼び出しの一部は省略しました)。



C#:



public class StubClass { public static int StubMethod(int fromEcx, int fromEdx, int fromStack) { int local = 5; return local + fromEcx + fromEdx + fromStack; } public static void CallingMethod() { int local1 = 7, local2 = 8, local3 = 9; int result = StubMethod(local1, local2, local3); } }
      
      





Asm:



 StubClass.StubMethod(Int32, Int32, Int32) 1: push ebp 2: mov ebp, esp 3: sub esp, 0x10 4: mov [ebp-0x4], ecx 5: mov [ebp-0x8], edx 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x10], edx 10: nop 11: mov dword [ebp-0xc], 0x5 12: mov eax, [ebp-0xc] 13: add eax, [ebp-0x4] 14: add eax, [ebp-0x8] 15: add eax, [ebp+0x8] 16: mov [ebp-0x10], eax 17: mov eax, [ebp-0x10] 18: mov esp, ebp 19: pop ebp 20: ret 0x4 StubClass.CallingMethod() 1: push ebp 2: mov ebp, esp 3: sub esp, 0x14 4: xor eax, eax 5: mov [ebp-0x14], eax 6: xor edx, edx 7: mov [ebp-0xc], edx 8: xor edx, edx 9: mov [ebp-0x8], edx 10: xor edx, edx 11: mov [ebp-0x4], edx 12: xor edx, edx 13: mov [ebp-0x10], edx 14: nop 15: mov dword [ebp-0x4], 0x7 16: mov dword [ebp-0x8], 0x8 17: mov dword [ebp-0xc], 0x9 18: push dword [ebp-0xc] 19: mov ecx, [ebp-0x4] 20: mov edx, [ebp-0x8] 21: call StubClass.StubMethod(Int32, Int32, Int32) 22: mov [ebp-0x14], eax 23: mov eax, [ebp-0x14] 24: mov [ebp-0x10], eax 25: nop 26: mov esp, ebp 27: pop ebp 28: ret
      
      





最初に注意する必要があるのは、 EBPおよびESPの登録とそれらの操作です。



私の友人の間での誤解は、 EBPレジスタがスタックのトップへのポインターに何らかの形で関連しているということです。 これはそうではないと言う必要があります。



ESPレジスタは、スタックの先頭へのポインタを担当します。 同様に、各PUSHコマンド (値をスタックの最上部に配置する)で、このレジスタの値は減少し(スタックは下位アドレスに向かって増加します)、各POP操作で増加します。 また、 CALLコマンドはリターンアドレスをスタックにプッシュするため、 ESPレジスタの値も減少します。 実際、 ESPレジスタの変更は、これらの命令が実行されるときだけでなく実行されます(たとえば、割り込み呼び出しが実行されるとき、 CALL命令が実行されるときに同じことが起こります)。



StubMethodを検討してください。



最初の行では、 EBPレジスタの内容が保存されます(スタックにプッシュされます)。 関数から戻る前に、この値が復元されます。



2行目には、スタックの最上部のアドレスの現在の値が格納されます( ESPレジスタの値はEBPに入力されます)。 この場合、 EBPレジスタは、現在の呼び出しのコンテキストではゼロの一種です。 それに関連してアドレス指定が実行されます。 次に、スタックの最上部を、ローカル変数とパラメーターを保存するのに必要な数の位置に移動します(3行目)。 すべてのローカルニーズにメモリを割り当てるようなものです。



上記のすべては、プロローグ関数と呼ばれます。



その後、格納されたEBPを介してスタック上の変数にアクセスします。これは、この特定のメソッドの変数が始まる場所を示します。

次はローカル変数の初期化です。



fastcallに関する注意:ネイティブ.netはfastcall呼び出し規約を使用します。

この合意は、関数に渡されるパラメーターの場所と順序を管理します。

fastcallでは、最初と2番目のパラメーターはそれぞれECXEDXレジスタを介して渡され、後続のパラメーターはスタックを介して渡されます。



非静的メソッドの場合、最初のパラメーターは暗黙的であり、メソッドが呼び出されるオブジェクトのアドレスが含まれます(これにアドレスします)。



4行目と5行目では、レジスター(最初の2つ)を介して送信されたパラメーターがスタックに保管されます。



次に、ローカル変数のスタックスペースをクリーニングし、ローカル変数を初期化します。



関数の結果がEAXレジスターにあることを思い出してください。



12〜16行目に、必要な変数が追加されます。 15行目に注目します。アドレスの呼び出しは、スタックの先頭以上、つまり前のメソッドのスタックを呼び出しています。 呼び出す前に、呼び出し元のメソッドはパラメーターをスタックの一番上にプッシュします。 ここで読みます。 追加の結果はEAXレジスタから取得され、スタックにプッシュされます。 これはStubMethodの戻り値であるため、再びEAXに配置されます 。 もちろん、このような不条理な命令セットはデバッグモードにのみ固有のものですが、大部分の作業を行うスマートオプティマイザーなしでコードがどのように見えるかを示しています。



18行目と19行目は、前のEBP (メソッドの呼び出し)とスタックの最上部へのポインター(メソッドが呼び出された時点)を復元します。



最後の行が戻ります。 値0x4についてはもう少し低くします。

この一連のコマンドは、関数エピローグと呼ばれます。



それでは、CallingMethodを見てみましょう。 18行目に進みましょう。ここでは、3番目のパラメーターをスタックの先頭に配置します。 PUSH命令を使用してこれを行うことに注意してください。つまり、 ESP値は減少します。 他の2つのパラメーターはレジスター( fastcall )に配置されます。 次は、StubMethodメソッドの呼び出しです。 ここで、 RET 0x4命令を思い出してください。 ここで次の質問が可能です:0x4とは何ですか? 上で述べたように、呼び出された関数のパラメーターをスタックにプッシュしました。 しかし、今ではそれらは必要ありません。 0x4は、関数を呼び出した後、バイトをスタックからクリアする必要があることを示します。 パラメーターが1つだったため、4バイトをクリアする必要があります。



サンプルスタックイメージを次に示します。







したがって、メソッドを呼び出した直後に向きを変えてスタックの後ろにあるものを見ると、最初に目にするのはEBPがスタックにプッシュされることです(実際、これは現在のメソッドの最初の行で発生しました)。 次に、実行を続行する場所を示す戻りアドレスがあります( RET命令で使用)。 そして、これらのフィールドを介して、現在の関数のパラメーター自体が表示されます(3番目から開始して、パラメーターは以前にレジスターを介して送信されます)。 そしてその背後には、呼び出しメソッド自体のスタックがあります!

上記の最初と2番目のフィールドは、パラメーターを参照する際の+ 0x8のオフセットを説明しています。

したがって、関数が呼び出されるとき、パラメーターは厳密に定義された順序でスタックの最上部になければなりません。 したがって、メソッドを呼び出す前に、各パラメーターがスタックにプッシュされます。

しかし、それらをプッシュしなくても、関数はそれらを受け入れますか?



小さな例



それで、上記のすべての事実は、私の関数を呼び出すメソッドのスタックを読みたくてたまらない欲望を引き起こしました。 文字通り、3番目の引数から1つの位置にある(呼び出しメソッドのスタックに最も近くなる)という考えは、私が非常に多く取得したい大切なデータであり、眠らせませんでした。



したがって、呼び出し元のメソッドのスタックを読み取るには、パラメーターよりも少し先を取得する必要があります。



パラメーターを参照する場合、パラメーターのアドレスの計算は、呼び出しメソッドがすべてをスタックにプッシュしたという事実にのみ基づいています。



しかし、 EDXパラメーターを通過する暗黙の(誰が気にしている- 最後の記事 )は、場合によってはコンパイラーを裏切ることができることを示唆しています。



これを行ったツールはStructLayoutAttributeと呼ばれます( 最初の記事の機能)。 //いつかこの属性以外のことを学ぶと約束します。



参照型では、同じお気に入りの手法をすべて使用します。



同時に、オーバーラップするメソッドのパラメーターの数が異なる場合、コンパイラーは必要なものをスタックにプッシュしません(想像上のように、どのパラメーターを知らないため)。

ただし、実際に呼び出されるメソッド(別の型からの同じオフセットで)は、スタックに関連するプラスアドレス、つまり、パラメーターを見つける予定のアドレスをアドレス指定します。



しかし、彼はそれらを見つけず、呼び出し元メソッドのスタックの読み取りを開始します。



ネタバレコード
 using System; using System.Runtime.InteropServices; namespace Magic { public class StubClass { public StubClass(int id) { Id = id; } public int Id; } [StructLayout(LayoutKind.Explicit)] public class CustomStructWithLayout { [FieldOffset(0)] public Test1 Test1; [FieldOffset(0)] public Test2 Test2; } public class Test1 { public virtual void Useless(int skipFastcall1, int skipFastcall2, StubClass adressOnStack) { adressOnStack.Id = 189; } } public class Test2 { public virtual int Useless() { return 888; } } class Program { static void Main() { Test2 objectWithLayout = new CustomStructWithLayout { Test2 = new Test2(), Test1 = new Test1() }.Test2; StubClass adressOnStack = new StubClass(3); objectWithLayout.Useless(); Console.WriteLine($"MAGIC - {adressOnStack.Id}"); // MAGIC - 189 } } }
      
      







アセンブラーの言語コードを提供するつもりはありません。そこではすべてが明確になっていますが、質問がある場合はコメントで答えようとします



この例は実際には使用できないことを完全に理解していますが、私の意見では、一般的な作業スキームを理解するのに非常に役立ちます。



All Articles