(Ich habe einen gemacht Kern aller Code in dieser Antwort , falls Sie mit ihm spielen wollen)
Ich habe während meines CS101-Kurses im Jahr 2003 immer nur die grundlegendsten Dinge in asm gemacht. Und ich hatte nie wirklich "verstanden", wie asm und Stack funktionieren, bis mir klar wurde, dass alles grundlegend wie das Programmieren in C oder C ++ ist ... aber ohne lokale Variablen, Parameter und Funktionen. Klingt wahrscheinlich noch nicht einfach :) Lassen Sie sich von mir zeigen (für x86 asm mit Intel-Syntax ).
1. Was ist der Stapel?
Der Stapel ist normalerweise ein zusammenhängender Speicherblock, der jedem Thread zugewiesen wird, bevor er gestartet wird. Sie können dort speichern, was Sie wollen. In C ++ - Begriffen ( Code-Snippet Nr. 1 ):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2. Stapel oben und unten
Im Prinzip können Sie Werte in zufälligen Zellen des stack
Arrays speichern ( Snippet Nr. 2.1 ):
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
Aber stellen Sie sich vor, wie schwer es wäre, sich daran zu erinnern, welche Zellen stack
bereits verwendet werden und welche "frei" sind. Deshalb speichern wir neue Werte nebeneinander auf dem Stapel.
Eine seltsame Sache am Stapel von (x86) asm ist, dass Sie dort Dinge hinzufügen, die mit dem letzten Index beginnen, und zu niedrigeren Indizes wechseln: Stapel [999], dann Stapel [998] und so weiter ( Snippet # 2.2 ):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
Und nach wie vor (Vorsicht, du bist jetzt verwirrt Gonna werden) , um den „offiziellen“ Namen stack[999]
ist der Stapelboden .
Die zuletzt verwendete Zelle ( stack[997]
im obigen Beispiel) wird als Oberseite des Stapels bezeichnet (siehe Wo sich die Oberseite des Stapels auf x86 befindet ).
3. Stapelzeiger (SP)
Für die Zwecke dieser Diskussion nehmen wir an, dass CPU-Register als globale Variablen dargestellt werden (siehe Allzweckregister ).
int AX, BX, SP, BP, ...;
int main(){...}
Es gibt ein spezielles CPU-Register (SP), das die Oberseite des Stapels verfolgt. SP ist ein Zeiger (enthält eine Speicheradresse wie 0xAAAABBCC). Aber für die Zwecke dieses Beitrags werde ich ihn als Array-Index verwenden (0, 1, 2, ...).
Wenn ein Thread SP == STACK_CAPACITY
gestartet wird und das Programm und das Betriebssystem ihn nach Bedarf ändern. Die Regel ist, dass Sie nicht in Stapelzellen schreiben können, die über den Stapel hinausgehen, und dass ein Index, der kleiner als SP ist, ungültig und unsicher ist (aufgrund von Systemunterbrechungen ). Sie dekrementieren also
zuerst SP und schreiben dann einen Wert in die neu zugewiesene Zelle.
Wenn Sie mehrere Werte hintereinander in den Stapel verschieben möchten, können Sie im Voraus Speicherplatz für alle Werte reservieren ( Snippet 3 ):
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
Hinweis. Jetzt können Sie sehen, warum die Zuordnung auf dem Stapel so schnell ist - es ist nur eine einzelne Registerdekrementierung.
4. Lokale Variablen
Werfen wir einen Blick auf diese vereinfachende Funktion ( Snippet # 4.1 ):
int triple(int a) {
int result = a * 3;
return result;
}
und schreiben Sie es neu, ohne die lokale Variable zu verwenden ( Snippet # 4.2 ):
int triple_noLocals(int a) {
SP -= 1;
stack[SP] = a * 3;
return stack[SP];
}
und sehen, wie es aufgerufen wird ( Snippet # 4.3 ):
someVar = triple_noLocals(11);
SP += 1;
5. Push / Pop
Das Hinzufügen eines neuen Elements oben auf dem Stapel ist eine so häufige Operation, dass CPUs eine spezielle Anweisung dafür haben push
. Wir werden es so implementieren ( Snippet 5.1 ):
void push(int value) {
--SP;
stack[SP] = value;
}
Ebenso das oberste Element des Stapels ( Snippet 5.2 ):
void pop(int& result) {
result = stack[SP];
++SP;
}
Das übliche Verwendungsmuster für Push / Pop spart vorübergehend etwas Wert. Angenommen, wir haben etwas Nützliches in Variablen myVar
und aus irgendeinem Grund müssen wir Berechnungen durchführen, die es überschreiben ( Snippet 5.3 ):
int myVar = ...;
push(myVar);
myVar += 10;
...
pop(myVar);
6. Funktionsparameter
Übergeben wir nun die Parameter mit dem Stack ( Snippet Nr. 6 ):
int triple_noL_noParams() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11);
assert(triple(11) == triple_noL_noParams());
SP += 2;
}
7. return
Aussage
Lassen Sie uns den Wert im AX-Register zurückgeben ( Snippet # 7 ):
void triple_noL_noP_noReturn() {
SP -= 1;
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1;
}
void main(){
...
push(AX);
push(11);
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1;
pop(AX);
...
}
8. Stapelbasiszeiger (BP) (auch als Rahmenzeiger bezeichnet ) und Stapelrahmen
Nehmen wir die "erweiterte" Funktion und schreiben sie in unserem asm-ähnlichen C ++ ( Snippet # 8.1 ) neu:
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() {
SP -= 2;
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2;
}
int main(){
push(AX);
push(22);
push(11);
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
Stellen Sie sich nun vor, wir haben beschlossen, eine neue lokale Variable einzuführen, um das Ergebnis dort zu speichern, bevor wir zurückkehren, wie in tripple
(Snippet # 4.1). Der Hauptteil der Funktion ist ( Snippet # 8.2 ):
SP -= 3;
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
Sie sehen, wir mussten jeden einzelnen Verweis auf Funktionsparameter und lokale Variablen aktualisieren. Um dies zu vermeiden, benötigen wir einen Ankerindex, der sich nicht ändert, wenn der Stapel wächst.
Wir erstellen den Anker direkt bei der Funktionseingabe (bevor wir Platz für Einheimische zuweisen), indem wir die aktuelle Spitze (Wert von SP) im BP-Register speichern. Snippet # 8.3 :
void myAlgo_noLPR_withAnchor() {
push(BP);
BP = SP;
SP -= 2;
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
Die Scheibe des Stapels, Weichen gehört und ist in der vollen Kontrolle über die Funktion aufgerufen Funktion des Stapelrahmen . ZB myAlgo_noLPR_withAnchor
ist der stack[996 .. 994]
Stapelrahmen (beide Ideen inklusive).
Der Frame beginnt beim BP der Funktion (nachdem wir ihn innerhalb der Funktion aktualisiert haben) und dauert bis zum nächsten Stack-Frame. Die Parameter auf dem Stapel sind also Teil des Stapelrahmens des Aufrufers (siehe Anmerkung 8a).
Anmerkungen:
8a. Wikipedia sagt etwas anderes über Parameter, aber hier halte ich mich an das Handbuch für Intel-Softwareentwickler , siehe Bd. 1, Abschnitt 6.2.4.1 Stack-Frame Base Pointer und Abbildung 6-2 in Abschnitt 6.3.2 Far CALL and RET Operation . Die Funktionsparameter und der Stapelrahmen sind Teil des Aktivierungsdatensatzes der Funktion (siehe Die Funktionsperiloge ).
8b. Positive Offsets vom BP-Punkt zu Funktionsparametern und negative Offsets zeigen zu lokalen Variablen. Das ist ziemlich praktisch zum Debuggen von
8c. stack[BP]
speichert die Adresse des vorherigen Stapelrahmens,stack[stack[BP]]
speichert den vorherigen Stapelrahmen und so weiter. Nach dieser Kette können Sie Frames aller Funktionen im Programm erkennen, die noch nicht zurückgegeben wurden. So zeigen Debugger, dass Sie Stack
8d aufrufen . Die ersten drei Anweisungen myAlgo_noLPR_withAnchor
, in denen wir den Frame einrichten (alten BP speichern, BP aktualisieren, Speicherplatz für Einheimische reservieren), werden als Funktionsprolog bezeichnet
9. Aufrufen von Konventionen
In Snippet 8.1 haben wir die Parameter für myAlgo
von rechts nach links verschoben und das Ergebnis zurückgegeben AX
. Wir könnten genauso gut die Parameter von links nach rechts übergeben und zurückkehren BX
. Oder übergeben Sie Parameter in BX und CX und kehren Sie in AX zurück. Offensichtlich müssen caller ( main()
) und die aufgerufene Funktion übereinstimmen, wo und in welcher Reihenfolge all diese Dinge gespeichert sind.
Die Aufrufkonvention besteht aus einer Reihe von Regeln für die Übergabe von Parametern und die Rückgabe des Ergebnisses.
Im obigen Code haben wir die cdecl-Aufrufkonvention verwendet :
- Parameter werden auf dem Stapel übergeben, wobei sich das erste Argument zum Zeitpunkt des Aufrufs an der niedrigsten Adresse auf dem Stapel befindet (zuletzt gedrückt <...>). Der Aufrufer ist dafür verantwortlich, dass die Parameter nach dem Aufruf wieder vom Stapel entfernt werden.
- Der Rückgabewert wird in AX platziert
- EBP und ESP müssen vom Angerufenen (
myAlgo_noLPR_withAnchor
in unserem Fall main
Funktion) beibehalten werden , damit sich der Anrufer ( Funktion) darauf verlassen kann , dass diese Register nicht durch einen Anruf geändert wurden.
- Alle anderen Register (EAX, <...>) können vom Angerufenen frei geändert werden. Wenn ein Aufrufer einen Wert vor und nach dem Funktionsaufruf beibehalten möchte, muss er den Wert an einer anderen Stelle speichern (dies geschieht mit AX).
(Quelle: Beispiel "32-Bit-Cdecl" aus der Stack Overflow-Dokumentation; Copyright 2016 von icktoofay und Peter Cordes ; lizenziert unter CC BY-SA 3.0. Ein Archiv des vollständigen Inhalts der Stack Overflow-Dokumentation finden Sie unter archive.org Dieses Beispiel ist nach Themen-ID 3261 und Beispiel-ID 11196 indiziert.)
10. Funktionsaufrufe
Nun der interessanteste Teil. Genau wie Daten wird auch ausführbarer Code im Speicher gespeichert (völlig unabhängig vom Speicher für den Stapel), und jeder Befehl hat eine Adresse.
Wenn nicht anders befohlen, führt die CPU die Anweisungen nacheinander in der Reihenfolge aus, in der sie im Speicher gespeichert sind. Wir können der CPU jedoch befehlen, an eine andere Stelle im Speicher zu "springen" und von dort aus Anweisungen auszuführen. In asm kann es sich um eine beliebige Adresse handeln, und in höheren Sprachen wie C ++ können Sie nur zu Adressen springen, die durch Beschriftungen gekennzeichnet sind ( es gibt Problemumgehungen, aber sie sind, gelinde gesagt, nicht hübsch).
Nehmen wir diese Funktion ( Snippet # 10.1 ):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
Und anstatt tripple
C ++ so aufzurufen , gehen Sie wie folgt vor:
- Kopieren Sie
tripple
den Code an den Anfang des myAlgo
Körpers
- bei der
myAlgo
Eingabe über tripple
den Code mit springengoto
- Wenn wir den
tripple
Code ausführen müssen, speichern Sie die Stapeladresse der Codezeile direkt nach dem tripple
Aufruf, damit wir später hierher zurückkehren und die Ausführung fortsetzen können ( PUSH_ADDRESS
Makro unten).
- Springe zur Adresse der 1. Zeile (
tripple
Funktion) und führe sie bis zum Ende aus (3. und 4. sind zusammen CALL
Makro)
tripple
Nehmen Sie am Ende von (nachdem wir die Einheimischen aufgeräumt haben) die Absenderadresse vom oberen Rand des Stapels und springen Sie dorthin ( RET
Makro).
Da es in C ++ keine einfache Möglichkeit gibt, zu einer bestimmten Codeadresse zu springen, verwenden wir Beschriftungen, um Sprungstellen zu markieren. Ich werde nicht ins Detail gehen, wie die folgenden Makros funktionieren. Glauben Sie mir einfach, dass sie das tun, was ich sage ( Ausschnitt Nr. 10.2 ):
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP);
BP = SP;
SP -= 2;
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP;
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777);
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1;
SP += 2;
pop(AX);
}
Anmerkungen:
10a. Da die Absenderadresse auf dem Stapel gespeichert ist, können wir sie im Prinzip ändern. Dies ist , wie Stack Smashing Angriff funktioniert
10b. Die letzten 3 Anweisungen am "Ende" von triple_label
(Lokale bereinigen, alten BP wiederherstellen, zurückgeben) werden als Epilog der Funktion bezeichnet
11. Montage
Nun schauen wir uns real asm an myAlgo_withCalls
. So machen Sie das in Visual Studio:
- setze Build-Plattform auf x86 ( nicht x86_64)
- Build-Typ: Debug
- Setze den Haltepunkt irgendwo in myAlgo_withCalls
- Ausführen und wenn die Ausführung am Haltepunkt stoppt, drücken Sie Strg + Alt + D.
Ein Unterschied zu unserem asm-ähnlichen C ++ besteht darin, dass der Stapel von asm mit Bytes statt mit Ints arbeitet. Um Platz für einen zu reservieren int
, wird SP um 4 Bytes dekrementiert.
Los geht's ( Snippet # 11.1 , Zeilennummern in Kommentaren stammen aus dem Kern ):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
Und asm für tripple
( Snippet # 11.2 ):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
Hoffe, nach dem Lesen dieses Beitrags sieht die Montage nicht mehr so kryptisch aus wie zuvor :)
Hier sind Links aus dem Beitrag und einige weitere Informationen:
- Eli Bendersky , Wo sich die Oberseite des Stapels auf x86 befindet - oben / unten, Push / Pop, SP, Stapelrahmen, Aufrufkonventionen
- Eli Bendersky , Stapelrahmenlayout auf x86-64 - Argumente, die x64 weitergeben, Stapelrahmen, rote Zone
- University of Mariland, Understanding the Stack - eine wirklich gut geschriebene Einführung in Stack-Konzepte. (Es ist für MIPS (nicht x86) und in GAS-Syntax, aber dies ist für das Thema unbedeutend). Weitere Informationen zur MIPS ISA-Programmierung finden Sie bei Interesse.
- x86 Asm Wikibook, Allzweckregister
- x86 Demontage Wikibook, The Stack
- x86 Demontage-Wikibook, Funktionen und Stapelrahmen
- Handbücher für Intel-Softwareentwickler - Ich habe erwartet, dass es wirklich Hardcore ist, aber überraschenderweise ist es ziemlich einfach zu lesen (obwohl die Menge an Informationen überwältigend ist).
- Jonathan de Boyne Pollard, Die Gen-on-Funktions-Periloge - Prolog / Epilog, Stapelrahmen / Aktivierungsdatensatz, rote Zone