Windows X64 Calling Convention
« | 26 Dec 2021 | »Nach vielen Jahren komme ich endlich dazu: X64 Assembler.
Sich selbst ein paar “Features” hinzucoden ist ja kein Problem, doch wenn ich mit C und C mit mir reden will, wird die Einhaltung der X64 Calling Convention essentiell.
Vorgeschichte: x86-32
Im 32-Bit Modus war die Welt theoretisch kompliziert, doch praktisch dann
wieder einfach. Es gab unter Windows
2 Aufruf-Konventionen, nämlich cdecl
und stdcall
und
Linux gab sich generell nur mit
cdecl
zufrieden.
Alle Parameter einer Funktion wurden auf den Stack gepusht und innerhalb der
Funktion wurde dann der Frame-Pointer (Base-Pointer, bzw. BP
oder EBP
)
auf den Stack-Pointer (SP
oder ESP
) gesetzt und über diesen konnte man
dann mit [EBP + offset]
auf übergebene Parameter und [EBP - offset]
auf
lokale Variablen am Stack zugreifen.
Ein paar Register mussten zusätzlich auf den Stack gepusht werden, weil sie
als non-volatile
gelten und aus der Sicht von außen nicht verändert werden
durften: EBX
, ESI
und EDI
.
Und am Ende wird ESP
wieder auf den EBP
zurückgesetzt und von dort
kann wieder das alte Stackframe zurück ge-POP-t werden.
In einer cdecl
x86-32 Funktion sieht der Maschinen-Code ohne Optimierungen
so etwa wie folgt aus:
1int32_t foo(int32_t a, int32_t b, int32_t c) 2{ 3 // PROLOG: 4 // asm PUSH EBP ; save old stack frame 5 // asm MOV EBP, ESP ; base for new stack frame 6 int32_t x; 7 int32_t y; 8 // asm SUB ESP, 8 ; alloc local stack for 2 ints (x, y) 9 10 x = a + b; 11 // asm MOV EAX, [EBP + 8] ; load a 12 // asm ADD EAX, [EBP + 12] ; add b 13 // asm MOV [EBP - 4], EAX ; store x 14 15 y = x + c; 16 // asm MOV EAX, [EBP - 4] ; load x 17 // asm ADD EAX, [EBP + 16] ; add c 18 // asm MOV [EBP - 8], EAX ; save y 19 20 return y; 21 // asm MOV EAX, [EBP - 8] ; load y as return value 22 23 // EPILOG: 24 // asm MOV ESP, EBP ; dealloc local stack 25 // asm POP EBP ; restore old stack frame 26 // asm RET 27} 28 29int main() 30{ 31 /*...*/ 32 33 int32_t result = foo(1, 2, 3); 34 // asm PUSH 3 ; add c to stack 35 // asm PUSH 2 ; add b to stack 36 // asm PUSH 1 ; add a to stack 37 // asm CALL foo ; call __cdecl function 38 // asm ADD ESP, 12 ; remove a,b,c from stack 39 // asm MOV [result], EAX 40 41 /*...*/ 42}
Innerhalb von foo
kann man über EBP
wie folgt adressieren:
EBP Offset | Element | Beschreibung |
---|---|---|
EBP + 16 | c | 3. Parameter (zuerst gepusht) |
EBP + 12 | b | 2. Parameter |
EBP + 8 | a | 1. Parameter (zuletzt gepusht) |
Stack-Frame Grenze | ||
EBP + 4 | RET Adresse | Rücksprung Adresse zum Aufrufer |
EBP + 0 | EBP Backup | Gesicherter EBP des Aufrufers |
EBP - 4 | x | 1. lokale Variable |
EBP - 8 | y | 2. lokale Variable |
Vereinfacht lautet die Regel:
EBP + 8 + arg_index * 4
greift auf Funktionsparameter zu.EBP - 4 - var_index * 4
greift auf lokale Variablen zu.
Andere Ausrichtungen wie für double
oder int64
verändern die Formel zwar,
aber hier geht es ja nur um die abstrakte Theorie.
Windows-X64 mit Home-Space, Shadow-Space …
Obwohl möglich, sieht die X64 Aufrufkonvention anders aus, als für X86-32, denn hier wollen wir für den schnelleren Datenaustausch einige Parameter über Register laufen lassen.
Alle Parameter kleiner 64 Bits werden auf 64 Bits ausgedehnt, und alles was größer ist, wird per Pointer übergeben.
Unter Windows werden daher die ersten 4 Parameter in RCX
, RDX
, R8
und
R9
übergeben und der Rest auf den Stack ge-PUSH-t.
Ein Spezialfall sind float
und double
Werte, die in den xmm
Registern
übergeben werden (was ich jetzt hier mal still und heimlich weglasse).
Für variadic/vararg
Funktionen wäre diese Konvention schlecht, weil die
über zusammenhängende Speicher funktionieren und keine Register kennen.
Und dafür gibt es einen interessanten Kompromiss:
Den Home-Space, auch Shadow-Space genannt.
Man allokiert (== reserviert == überspringt) am Stack genau den Speicher, den die Register der Parameter brauchen würden und überlässt es der aufgerufenen Funktion, diesen Speicher zu nutzen.
- Die Funktion könnte die 4 Parameter-Register in den Home-Space schreiben
und hätte dann ein Layout-Äquivalent zu 32-Bit
cdecl
, undstdarg.h
Makros würden 1:1 damit funktionieren. - Die Funktion kann den Home-Space aber auch ignorieren und nur mit den Registern arbeiten.
- Und die Funktion kann die vier 64 Bit Slots des Home-Space auch nutzen um andere Daten dort abzulegen, quasi lokale Variablen, die aber außerhalb des Stackframes liegen.
Kein formaler Frame-Pointer
In der 32-Bit Welt zeigt das EBP
Register immer genau auf die Adresse, wo
das EBP
Register des Aufrufers gesichert wurde und 4 Bytes darüber liegt die
Rücksprungadresse zum Aufrufercode.
So kann man “standardisiert” über EBP
die Aufrufkette rückverfolgen
(so lange nicht das Feature “Omit Frame Pointers” (/Oy
) aktiviert ist).
Das 64 Bit ABI kennt keinen fixen Frame-Pointer. Es ist nicht verboten RBP
weiter so zu benutzen, aber der C/C++ Compiler generiert Parameter und lokale
Variablen-Zugriffe häufig mit [RSP + Offset]
.
Das ist zwar sehr effizient und kann vom Compiler hoch optimiert werden,
aber macht es verdammt schwer nachzuvollziehen, was wirklich abgeht, da alles
relativ zum aktuellen Stack-Pointer passiert.
Will man RBP
statt RSP
nutzen, so wird empfohlen, RBP
zu sichern und
auf RSP zeigen zu lassen. Letztendlich ist es dann aber auch schon egal, ob
man RBP + offset
oder RSP + offset
schreibt.
Sicherung von “nicht-flüchtigen” Registern
Folgende Register gelten unter x64 als “non-volatile” (also “nicht-flüchtig) und müssen gesichert werden, bevor man sie verändern darf:
RBP, RBX, RSI, RDI, R12, R13, R14, R15
Man PUSH
t sie daher am Beginn der Funktion auf den Stack und holt sie
am Ende vor dem RET
mit POP
wieder zurück.
Wenn man die Register nicht anfasst, braucht man sie natürlich auch nicht
sichern.
Verflixtes 16-Byte Alignment
Um übelsten finde ich die 16-Byte Ausrichtung des Stacks, die notwendig ist,
damit 128-Bit Operationen performant funktionieren.
Je nach Anzahl der Parameter, Variablen und gesicherten Registern, müssen
im Stack Lücken gelassen werden, damit die RSP
Adresse vor einem
CALL
immer ein genaues Vielfaches von 16 Byte ist.
Beim Funktionsaufruf mit kleiner-gleich 4 Parametern, müssten wir 32 Bytes für den Home-Space vom Stack abziehen (was automatisch 16-Byte ausgerichtet ist). Bei 5 Parametern wird zuerst eine 8-Byte Lücke gelassen, dann der letzte Parameter gePUSHt und am Ende werden 32 Bytes Home-Space abgezogen. Bei 6 Parametern werden die letzten 2 Parameter gePUSHT und 32 Bytes Home-Space abgezogen … usw.
Fazit: Eine ungerade Parameteranzahl
>= 5
erfordert eine Ausrichtung vor demCALL
.
Innerhalb der Funktion hat CALL
die Rücksprungadresse auf den Stack gelegt,
womit dieser nicht ausgerichtet ist.
Entweder lässt man eine ungerade Summe von Register-PUSH
+ lokalen Variablen
den Stack ausrichten, oder man addiert 8 Bytes zu RSP
, damit der Stack
ausgerichtet ist und arbeitet dann mit einer geraden Anzahl an
PUSH
+ lokalen Variablen weiter.
Am Ende muss RSP
wieder durch 16 ohne Rest teilbar sein, damit ein
weiterer Funktions-CALL
problemlos stattfinden kann.
Fazit: Im Prolog braucht also eine gerade Anzahl von
PUSH
+ lokalen 64-bit Variablen eine Ausrichtung, weil die Rücksprung-Adresse die Stack-Ausrichtung verschiebt.
Und genau das macht es dann so f*cking kompliziert, die Adressen der Variablen und der Parameter zu bestimmen. Was für ein Glück, dass in C/C++ der Compiler diesen Job übernimmt.
x64 Beispiel
1int64_t foo(int64_t a, int64_t b, int64_t c, 2 int64_t d, int64_t e) 3{ 4 // PROLOG: 5 int64_t x; 6 int64_t y; 7 // asm SUB RSP, 24 ; alloc local stack frame 8 // ; for 2 int64 (x, y) + alignment 9 10 x = a + b + c + d; 11 // asm MOV RAX, RCX ; load a 12 // asm ADD RAX, RDX ; add b 13 // asm ADD RAX, R8 ; add c 14 // asm ADD RAX, R9 ; add d 15 // asm MOV [RSP + 8], RAX ; store x 16 17 y = x + e; 18 // asm MOV RAX, [RSP + 8] ; load x 19 // asm ADD RAX, [RSP + 24 + 8 + 32] ; add e 20 // asm MOV [RSP + 0], RAX ; save y 21 22 return y; 23 // asm MOV RAX, [RSP + 0] ; load y as return value 24 25 // EPILOG: 26 // asm SUB RSP, 24 ; dealloc local stack frame 27 // asm RET 28} 29 30int main() 31{ 32 /*...*/ 33 34 int64_t result = foo(1, 2, 3, 4, 5); 35 // asm SUB RSP, 8 ; prepare CALL-alignment 36 // asm PUSH 5 ; add e to stack 37 // asm MOV R9, 4 ; put d in register 38 // asm MOV R8, 3 ; put c in register 39 // asm MOV RDX, 2 ; put b in register 40 // asm MOV RCX, 1 ; put a in register 41 // asm SUB RSP, 32 ; alloc home space (4 * 64-Bits) 42 // asm CALL foo ; call function 43 // asm ADD RSP, 48 ; dealloc (home space + arg 5 + alignment) 44 // asm MOV [result], EAX 45 46 /*...*/ 47}
In main
brauchen 5 Parameter zusätzliche 8-Bytes um das 16-Byte Alignment
einzuhalten. Das gleiche passiert in foo
, wo der RET-Adresse + 2 Variablen
ebenfalls noch 8-Bytes fehlen, damit RSP
am Ende wieder korrekt
ausgerichtet ist.
Innerhalb von foo
kann man dann so adressieren:
RSP Offset | Element | Beschreibung |
---|---|---|
RSP + 72 | Lücke für Stack Ausrichtung | |
RSP + 64 | e | 5. Parameter |
RSP + 56 | d / R9 | Home für 4. Parameter |
RSP + 48 | c / R8 | Home für 3. Parameter |
RSP + 40 | b / RDX | Home für 2. Parameter |
RSP + 32 | a / RCX | Home für 1. Parameter |
Stack-Frame Grenze | ||
RSP + 24 | RET Adresse | Rücksprung Adresse zum Aufrufer |
RSP + 16 | Lücke für Stack Ausrichtung | |
RSP + 8 | x | 1. lokale Variable |
RSP + 0 | y | 2. lokale Variable |
Fazit
X86-32 war nie “schön”, aber es war User-freundlich. Wir konnten mit EBP
manuell ein paar ASM-Zeilen schreiben, die den OS-Standards entsprachen.
Bei X86-64 merkt man deutlich, dass die Calling Convention nicht mehr
auf “Handarbeit” ausgelegt ist.
Ein Compiler kann die nötigen Offsets schnell berechnen, wenn sich ein
Register oder eine Variable einschleicht.
Doch wenn man das selbst im Kopf machen muss, wird einem leicht schwindelig.
Andere ASM-Beispiele liegen im
BLOG-Classroom.
Und ein paar andere “coole” Blogger haben auch schöne Artikel dazu geschrieben:
- The Old New Thing: Home Space
- The Old New Thing: Stack Tracing
- The Old New Thing: Red Zone
- ASM-Tutorial von sonictk
In Linux (und BSD
und SysV) sieht die Sache ein bisschen anders aus. Man nutzt 6 Register für
Parameter, keinen Home-Space, dafür müssen vararg
Funktionen ihre Parameter
in einen Zwischenspeicher umkopieren, aber das verflixte Alignment ist auch
dort notwendig.
… aber das ist eine andere Geschichte.