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.