Was passiert hinter den Kulissen in C #: die Grundlagen der Arbeit mit dem Stack

Ich schlage vor, alles zu betrachten, was hinter einfachen Zeilen zum Initialisieren von Objekten, Aufrufen von Methoden und Übergeben von Parametern steht. Die Verwendung dieser Informationen in der Praxis subtrahiert natürlich den Stapel der aufrufenden Methode.

Haftungsausschluss


Bevor Sie mit der Geschichte beginnen, empfehle ich dringend, dass Sie den ersten Beitrag über StructLayout lesen , weil In diesem Artikel wird ein Beispiel verwendet.

Der gesamte Code hinter der höheren Ebene wird für den Debugging- Modus dargestellt. Er ist es, der die konzeptionelle Basis zeigt. All dies wird auch für eine 32-Bit-Plattform berücksichtigt. Die JIT-Optimierung ist ein separates und großes Thema, das hier nicht berücksichtigt wird.

Ich möchte auch warnen, dass dieser Artikel kein Material enthält, das in realen Projekten verwendet werden sollte.

Beginnen Sie mit der Theorie


Jeder Code wird letztendlich zu einer Reihe von Maschinenbefehlen. Am verständlichsten ist ihre Darstellung in Form von Assembler-Anweisungen, die direkt einer (oder mehreren) Maschinenanweisungen entsprechen.


Bevor Sie zu einem einfachen Beispiel übergehen, empfehlen wir Ihnen, sich mit einem Software-Stack vertraut zu machen. Der Software-Stack ist in erster Linie ein Speicher, der in der Regel zum Speichern verschiedener Arten von Daten verwendet wird (in der Regel können sie als temporäre Daten bezeichnet werden ). Es ist auch zu beachten, dass der Stapel in Richtung niedrigerer Adressen wächst. Das heißt, je später das Objekt auf den Stapel geschoben wird, desto geringer ist seine Adresse.

Schauen wir uns nun den nächsten Code in Assembler-Sprache an (ich habe einige der Aufrufe weggelassen, die dem Debug-Modus inhärent sind).

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 

Das erste, worauf Sie achten sollten, sind die EBP- und ESP- Register und Operationen mit ihnen.

Ein Missverständnis unter meinen Freunden ist, dass das EBP- Register irgendwie mit einem Zeiger auf den oberen Rand des Stapels zusammenhängt. Ich muss sagen, dass das nicht so ist.

Das ESP- Register ist für den Zeiger auf die Oberseite des Stapels verantwortlich. Dementsprechend wird mit jedem PUSH-Befehl (der den Wert oben auf dem Stapel platziert) der Wert dieses Registers dekrementiert (der Stapel wächst zu niedrigeren Adressen hin) und mit jeder POP- Operation erhöht er sich. Der Befehl CALL schiebt auch die Rücksprungadresse auf den Stapel, wodurch auch der Wert des ESP- Registers verringert wird. Tatsächlich wird das Ändern des ESP- Registers nicht nur ausgeführt, wenn diese Anweisungen ausgeführt werden (wenn beispielsweise Interrupt-Aufrufe ausgeführt werden, geschieht dasselbe, wenn die CALL- Anweisungen ausgeführt werden).

Betrachten Sie StubMethod.

In der ersten Zeile wird der Inhalt des EBP- Registers gespeichert (auf den Stapel geschoben). Vor der Rückkehr von der Funktion wird dieser Wert wiederhergestellt.

In der zweiten Zeile wird der aktuelle Wert oben in der Stapeladresse gespeichert (der Wert des ESP- Registers wird in EBP eingegeben). In diesem Fall ist das EBP- Register im Kontext des aktuellen Aufrufs eine Art Null. Die Adressierung erfolgt relativ dazu. Als nächstes verschieben wir den oberen Rand des Stapels an so viele Positionen, wie wir zum Speichern lokaler Variablen und Parameter benötigen (dritte Zeile). So etwas wie das Zuweisen von Speicher für alle lokalen Anforderungen.

All dies wird als Prologfunktion bezeichnet.

Danach erfolgt der Zugriff auf die Variablen auf dem Stapel über das gespeicherte EBP , das den Ort angibt, an dem die Variablen dieser bestimmten Methode beginnen.
Als nächstes folgt die Initialisierung lokaler Variablen.

Erinnerung an Fastcall : Das native .net verwendet die Fastcall- Aufrufkonvention.
Die Vereinbarung regelt den Ort und die Reihenfolge der an die Funktion übergebenen Parameter.
Beim Fastcall werden der erste und der zweite Parameter durch die ECX- bzw. EDX- Register geleitet, und die nachfolgenden Parameter werden durch den Stapel geleitet.

Bei nicht statischen Methoden ist der erste Parameter implizit und enthält die Adresse des Objekts, für das die Methode aufgerufen wird (Adresse this).

In den Zeilen 4 und 5 werden die Parameter, die über die Register (die ersten 2) übertragen wurden, auf dem Stapel gespeichert.

Als Nächstes wird der Stapelspeicher für lokale Variablen bereinigt und lokale Variablen initialisiert.

Es sei daran erinnert, dass sich das Ergebnis der Funktion im EAX- Register befindet.

In den Zeilen 12-16 werden die erforderlichen Variablen hinzugefügt. Ich mache Sie auf Zeile 15 aufmerksam. Es wird die Adresse aufgerufen, mehr als der Anfang des Stapels, dh der Stapel der vorherigen Methode. Vor dem Aufruf verschiebt die aufrufende Methode den Parameter an den Anfang des Stapels. Hier lesen wir es. Das Ergebnis der Addition wird aus dem EAX- Register abgerufen und auf den Stapel geschoben. Da dies der Rückgabewert von StubMethod ist, wird er erneut in EAX platziert . Natürlich sind solche absurden Anweisungen nur im Debug-Modus enthalten, aber sie zeigen, wie unser Code ohne einen intelligenten Optimierer aussieht, der den Löwenanteil der Arbeit leistet.

Die Zeilen 18 und 19 stellen das vorherige EBP (die aufrufende Methode) und den Zeiger auf den oberen Rand des Stapels (zum Zeitpunkt des Aufrufs der Methode) wieder her.

Die letzte Zeile kehrt zurück. Über den Wert 0x4 werde ich etwas niedriger berichten.
Diese Befehlsfolge wird als Funktionsepilog bezeichnet.

Schauen wir uns nun CallingMethod an. Gehen wir direkt zu Zeile 18. Hier setzen wir den dritten Parameter oben auf den Stapel. Bitte beachten Sie, dass wir dies mit der PUSH- Anweisung tun, dh der ESP- Wert wird dekrementiert. Die anderen 2 Parameter werden in Registern abgelegt ( Fastcall ). Als nächstes wird die StubMethod-Methode aufgerufen. Rufen Sie nun den Befehl RET 0x4 auf . Folgende Frage ist hier möglich: Was ist 0x4? Wie oben erwähnt, haben wir die Parameter der aufgerufenen Funktion auf den Stapel verschoben. Aber jetzt brauchen wir sie nicht. 0x4 gibt an, dass das Byte nach dem Funktionsaufruf aus dem Stapel gelöscht werden muss. Da es einen Parameter gab, müssen Sie 4 Bytes löschen.

Hier ist ein Beispiel für ein Stapelbild:



Wenn wir uns also unmittelbar nach dem Aufrufen der Methode umdrehen und sehen, was sich auf der Rückseite des Stapels befindet, sehen wir als erstes das EBP, das auf den Stapel geschoben wird (tatsächlich geschah dies in der ersten Zeile der aktuellen Methode). Als nächstes gibt es eine Rücksprungadresse, die angibt, wo die Ausführung fortgesetzt wird (wird von der RET- Anweisung verwendet). Und durch diese Felder sehen wir die Parameter selbst der aktuellen Funktion (ab dem 3. werden die Parameter vorher durch die Register übertragen). Und dahinter steht der Stapel der aufrufenden Methode selbst!
Das erwähnte erste und zweite Feld erläutern den Versatz bei + 0x8 unter Bezugnahme auf die Parameter.
Dementsprechend müssen die Parameter beim Aufrufen der Funktion in einer genau definierten Reihenfolge oben im Stapel liegen. Daher wird vor dem Aufrufen der Methode jeder Parameter auf den Stapel verschoben.
Aber was ist, wenn Sie sie nicht drücken und die Funktion sie weiterhin akzeptiert?

Ein kleines Beispiel


Alle oben genannten Fakten haben mich zu dem unwiderstehlichen Wunsch gemacht, den Stapel einer Methode zu lesen, die meine Funktion aufruft. Der Gedanke, dass buchstäblich an einer Stelle aus dem dritten Argument (es wird dem Stapel der aufrufenden Methode am nächsten sein) die geschätzten Daten sind, die ich so sehr erhalten möchte, ließ mich nicht schlafen.

Um den Stapel der aufrufenden Methode zu lesen, muss ich also etwas weiter als bis zu den Parametern gehen.

Bei der Bezugnahme auf Parameter basiert die Berechnung der Adresse eines Parameters nur auf der Tatsache, dass die aufrufende Methode sie alle auf den Stapel verschoben hat.

Das implizite Durchlaufen des EDX- Parameters (wen interessiert das - der letzte Artikel ) legt jedoch nahe, dass wir den Compiler in einigen Fällen überlisten können.

Das Tool, mit dem ich das gemacht habe, heißt StructLayoutAttribute (Funktionen im ersten Artikel ). // Eines Tages werde ich etwas anderes als dieses Attribut lernen, das verspreche ich.

Wir verwenden alle die gleiche Lieblingstechnik mit Referenztypen.

Wenn die überlappenden Methoden eine andere Anzahl von Parametern haben, wird gleichzeitig festgestellt, dass der Compiler die benötigten nicht auf den Stapel schiebt (wie der imaginäre, weil er nicht weiß, welche).
Die tatsächlich aufgerufene Methode (mit demselben Versatz von einem anderen Typ) adressiert jedoch die Plusadressen relativ zu ihrem Stapel, dh diejenigen, bei denen die Parameter gesucht werden sollen.

Aber dort findet er sie nicht und beginnt, den Stapel der aufrufenden Methode zu lesen.

Spoiler Code
 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 } } } 


Ich werde den Assembler-Sprachcode nicht angeben, da ist alles ziemlich klar, aber wenn Sie Fragen haben, werde ich versuchen, diese in den Kommentaren zu beantworten

Ich verstehe vollkommen, dass dieses Beispiel in der Praxis nicht verwendet werden kann, aber meiner Meinung nach kann es sehr nützlich sein, um das allgemeine Arbeitsschema zu verstehen.

Source: https://habr.com/ru/post/de427465/


All Articles