Windows X64 Calling Convention

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, und stdarg.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 PUSHt 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 dem CALL.

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:

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.