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

Ich schlage vor, die Interna zu betrachten, die hinter den einfachen Linien des Initialisierens der Objekte, Aufrufen von Methoden und Übergeben von Parametern stehen. Und natürlich werden wir diese Informationen in der Praxis verwenden - wir werden den Stapel der aufrufenden Methode subtrahieren.

Haftungsausschluss


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

Der gesamte Code hinter dem übergeordneten Code wird für den Debug- Modus angezeigt , da er die konzeptionelle Basis zeigt. Die JIT-Optimierung ist ein separates großes Thema, das hier nicht behandelt wird.

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

Erste - Theorie


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


Bevor ich mich einem einfachen Beispiel zuwende, schlage ich vor, mich mit Stack vertraut zu machen. Der Stapel ist in erster Linie ein Speicherblock, der in der Regel zum Speichern verschiedener Arten von Daten verwendet wird (normalerweise können sie als zeitliche Daten bezeichnet werden ). Es ist auch zu beachten, dass der Stapel in Richtung kleinerer Adressen wächst. Das heißt, je später ein Objekt auf dem Stapel platziert wird, desto weniger Adresse hat es.

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

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, was zu bemerken ist, sind die EBP- und ESP- Register und Operationen mit ihnen.

Ein Missverständnis, dass das EBP- Register irgendwie mit dem Zeiger auf den oberen Rand des Stapels zusammenhängt, ist bei meinen Freunden weit verbreitet. Ich muss sagen, dass es nicht ist.

Das ESP- Register ist dafür verantwortlich, auf die Oberseite des Stapels zu zeigen. Entsprechend wird mit jedem PUSH- Befehl (Platzieren eines Wertes oben auf dem Stapel) der Wert des ESP- Registers dekrementiert (der Stapel wächst zu kleineren Adressen hin) und mit jedem POP- Befehl wird er inkrementiert. Außerdem drückt der Befehl CALL die Rücksprungadresse auf den Stapel, wodurch der Wert des ESP- Registers verringert wird. Tatsächlich wird die Änderung des ESP- Registers nicht nur ausgeführt, wenn diese Anweisungen ausgeführt werden (wenn beispielsweise Interrupt-Aufrufe ausgeführt werden, geschieht dasselbe mit den CALL- Anweisungen).

Berücksichtigt StubMethod () .

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

In der zweiten Zeile wird der aktuelle Wert der Adresse oben im Stapel gespeichert (der Wert des Registers ESP wird nach EBP verschoben). 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 Speicherzuweisung für alle lokalen Anforderungen - Stack-Frame . Gleichzeitig ist das EBP- Register ein Ausgangspunkt im Kontext des aktuellen Aufrufs. Die Adressierung basiert auf diesem Wert.

All dies wird als Funktionsprolog bezeichnet .

Danach wird auf Variablen auf dem Stapel über das gespeicherte EBP- Register zugegriffen, das auf die Stelle zeigt, an der die Variablen dieser Methode beginnen. Als nächstes folgt die Initialisierung lokaler Variablen.

Fastcall- Erinnerung: In .net wird die Fastcall- Aufrufkonvention verwendet.
Die aufrufende Konvention regelt den Ort und die Reihenfolge der an die Funktion übergebenen Parameter.
Der erste und der zweite Parameter werden über die ECX- bzw. EDX- Register übergeben, die nachfolgenden Parameter werden über den Stapel übertragen. (Dies gilt wie immer für 32-Bit-Systeme. In 64-Bit-Systemen werden vier Parameter durch Register geleitet ( RCX , RDX , R8 , R9 ).)

Bei nicht statischen Methoden ist der erste Parameter implizit und enthält die Adresse der Instanz, auf der die Methode aufgerufen wird (diese Adresse).

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

Als nächstes wird der Speicherplatz auf dem Stapel für lokale Variablen ( Stapelrahmen ) bereinigt und lokale Variablen initialisiert.

Es ist erwähnenswert, dass sich das Ergebnis der Funktion im Register EAX befindet .

In den Zeilen 12-16 werden die gewünschten Variablen hinzugefügt. Ich mache Sie auf Zeile 15 aufmerksam. Es gibt einen Zugriffswert für die Adresse, der größer als der Anfang des Stapels ist, dh für den Stapel der vorherigen Methode. Vor dem Aufruf schiebt der Aufrufer einen Parameter an den Anfang des Stapels. Hier lesen wir es. Das Ergebnis der Addition wird aus dem Register EAX erhalten und auf den Stapel gelegt. Da dies der Rückgabewert von StubMethod () ist , wird er erneut in EAX platziert . Natürlich sind solche absurden Befehlssätze nur im Debug-Modus enthalten, aber sie zeigen genau, wie unser Code ohne einen intelligenten Optimierer aussieht, der den Löwenanteil der Arbeit leistet.

In den Zeilen 18 und 19 werden sowohl das vorherige EBP (aufrufende Methode) als auch der Zeiger auf den oberen Rand des Stapels wiederhergestellt (zum Zeitpunkt des Aufrufs der Methode). Die letzte Zeile ist die Rückkehr von der Funktion. Über den Wert 0x4 werde ich etwas später berichten.

Eine solche Folge von Befehlen 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 Register eingetragen ( Fastcall ). Als nächstes folgt der Methodenaufruf StubMethod () . Erinnern wir uns nun an die Anweisung RET 0x4 . Hier ist folgende Frage 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, wie viele Bytes nach dem Funktionsaufruf aus dem Stapel gelöscht werden müssen. Da der Parameter eins war, müssen Sie 4 Bytes löschen.

Hier ist ein grobes Bild des Stapels:



Wenn wir uns also umdrehen und sehen, was direkt nach dem Methodenaufruf auf dem Stapel liegt, sehen wir als erstes EBP , das auf den Stapel verschoben wurde (tatsächlich geschah dies in der ersten Zeile der aktuellen Methode). Das nächste ist die Absenderadresse. Es bestimmt den Ort, an dem die Ausführung fortgesetzt werden soll, nachdem unsere Funktion beendet wurde (von RET verwendet ). Und direkt nach diesen Feldern sehen wir die Parameter der aktuellen Funktion (ab dem 3. werden die ersten beiden Parameter durch die Register geleitet). Und hinter ihnen verbirgt sich der Stapel der aufrufenden Methode!

Das zuvor erwähnte erste und zweite Feld ( EBP und Rücksprungadresse) erklären den Offset in + 0x8, wenn wir auf Parameter zugreifen.

Dementsprechend müssen sich die Parameter vor dem Funktionsaufruf in einer genau definierten Reihenfolge oben im Stapel befinden. Daher wird vor dem Aufrufen der Methode jeder Parameter auf den Stapel verschoben.
Aber was ist, wenn sie nicht drücken und die Funktion sie trotzdem übernimmt?

Kleines Beispiel


Alle oben genannten Fakten haben mich zu dem überwältigenden Wunsch veranlasst, den Stapel der Methode zu lesen, die meine Methode aufruft. Die Idee, dass ich mich nur an einer Position vom dritten Argument befinde (es wird dem Stapel der aufrufenden Methode am nächsten kommen), sind die geschätzten Daten, die ich so sehr erhalten möchte, dass ich nicht schlafen durfte.

Um den Stapel der aufrufenden Methode zu lesen, muss ich etwas weiter als die Parameter klettern.

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

Das implizite Durchlaufen des EDX- Parameters (wer interessiert ist - vorheriger Artikel ) lässt mich jedoch denken, dass wir den Compiler in einigen Fällen überlisten können.

Das Tool, mit dem ich dies getan habe, heißt StructLayoutAttribute (alle Funktionen sind im ersten Artikel enthalten ). // Eines Tages werde ich ein bisschen mehr als nur dieses Attribut lernen, das verspreche ich

Wir verwenden dieselbe bevorzugte Methode mit überlappenden Referenztypen.

Wenn überlappende Methoden eine andere Anzahl von Parametern haben, schiebt der Compiler gleichzeitig die erforderlichen nicht auf den Stapel (zumindest weil er nicht weiß, welche).
Die tatsächlich aufgerufene Methode (mit demselben Versatz von einem anderen Typ) wird jedoch zu positiven Adressen relativ zu ihrem Stapel, dh zu denen, bei denen die Parameter gefunden werden sollen.

Aber niemand übergibt Parameter und die Methode beginnt, den Stapel der aufrufenden Methode zu lesen. Die Adresse des Objekts (mit der Id-Eigenschaft, die in WriteLine () verwendet wird ) befindet sich an der Stelle, an der der dritte Parameter erwartet wird.

Code ist im Spoiler
 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-Code nicht geben, dort ist alles ziemlich klar, aber wenn es irgendwelche Fragen gibt, werde ich versuchen, sie 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/de447274/


All Articles