ARM64 Assembler

Mit meinem ARM64 Windows 11 und Linux am Pinebook habe ich zwei Umgebungen, auf denen ich endlich auch mal ARM64 Assembler Codes testen kann.

Das Schlimme an dem Dialekt ist, dass er sich von ARM32 unterscheidet, was die Portierung von ARM32 Code erschwert.


Microsoft ARM Assembler (MARMASM)

Wenn die Einrichtung des ARM Assemblers erfolgt ist, kann man schon munter drauf losprogrammieren.

Wie schon erwähnt war die Konvention, dass Anweisungen eingerückt sein müssen und nur Labels und Prozedurnamen am Zeilenanfang starten dürfen, neu für mich. Doch sobald man sich daran gewöhnt hat, ist es auch kein Problem mehr.

Register

Die Register x0 bis x7 stellen 8 Integer Funktionsparameter dar, wobei in x0 auch gleich wieder der Returnvalue zurückgegeben wird. Ansonsten gelten x0 bis x15 als (volatile) Scratch-Register und können innerhalb jeder Funktion frei verändert werden.

x19 bis x28 sind nicht-volatile Scratch-Register. Will eine Funktion sie nutzen, müssen die Inhalte z.B.: auf dem Stack gesichert werden und am Ende der Funktion wieder auf den Ursprungswert zurückgesetzt werden. Somit kann eine Funktion, die eine andere aufruft, erwarten, dass diese Register nach dem Aufruf alle Werte “gehalten” haben.

Der Framepointer liegt in x29, kann aber auch als fp angesprochen werden und x30 ist das Link-Register, auch als lr bekannt.

Die Registern x16 und x17 sind volatile “Intra-Procedure-Call-Scratch-Register”, deren Inhalt sich während eines Funktionsaufrufes ändern kann, etwa durch Debugging oder Patches, innerhalb der Funktion kann es “normal” genutzt werden. Und x18 gehört dem OS, unter Windows verweist es z.B.: auf den Thread-Environment-Block im Usermode.

Funktions-Prolog und -Epilog

Ein Bild sagt mehr als 1000 Worte. Folgender Code zeigt eine Funktion, die in C als

1long long my_asm_func_add_2_parameters(long long a, long long b);

deklariert werden würde:

 1  AREA |.text|,CODE,READONLY
 2
 3  EXPORT my_asm_func_add_2_parameters
 4
 5my_asm_func_add_2_parameters PROC
 6  ;; prologue begins
 7
 8  pacibsp                     ; return address protection
 9
10  ; save non-volatile registers
11  stp fp, lr, [sp, #-0x60]!   ; sp -= 0x60
12                              ; sp[0x00] = fp; sp[0x08] = lr
13  stp x19, x20, [sp, #0x10]   ; sp[0x10] = x19; sp[0x18] = x20
14  stp x21, x22, [sp, #0x20]   ; sp[0x20] = x21; sp[0x28] = x22
15  stp x23, x24, [sp, #0x30]   ; sp[0x30] = x23; sp[0x38] = x24
16  stp x25, x26, [sp, #0x40]   ; sp[0x40] = x25; sp[0x48] = x26
17  stp x27, x28, [sp, #0x50]   ; sp[0x50] = x27; sp[0x58] = x28
18
19  ; setup stack-frame
20  mov fp, sp                  ; fp = sp
21
22  ; allocate stack for local variables
23  sub sp, sp, #0x10           ; 2 local variables
24
25  ;; prologue completed
26
27  ; parameters a+b (x0=a, x1=b)
28  add x0, x0, x1              ; x0 += x1        
29  ; return value in x0
30
31  ;; epilogue begins
32
33  ; deallocate stack for local variables
34  add sp, sp, #0x10           ; free 2 local variables
35
36  ; restore non-volatile registers
37  ldp x27, x28, [sp, #0x50]   ; x27 = sp[0x50]; x28 = sp[0x58]
38  ldp x25, x26, [sp, #0x40]   ; x25 = sp[0x40]; x26 = sp[0x48]
39  ldp x23, x24, [sp, #0x30]   ; x23 = sp[0x30]; x24 = sp[0x38]
40  ldp x21, x22, [sp, #0x20]   ; x21 = sp[0x20]; x22 = sp[0x28]
41  ldp x19, x20, [sp, #0x10]   ; x19 = sp[0x10]; x20 = sp[0x18]
42  ldp fp, lr, [sp], #0x060    ; fp = sp[0x00]; lr = sp[0x08]
43                              ; sp += 0x60
44
45  autibsp                     ; return address check
46  ;; epilogue ends
47  ret
48  ENDP
49  END

Und jetzt zu den Details: pacibsp und autibsp dienen dem Schutz gegen Fehler und Malware, aber das hat schon Raymond Chen super erklärt.

Der Stack ist immer auf 16 Bytes ausgerichtet, weshalb man häufig immer gleich zwei Register gleichzeitig auf den Stack speichert bzw. lädt. stp (store pair) und ldp (load pair) erledigen das, man könnte aber auch einzelne Register ansprechen mit str (store register) und ldr (load register).

Gemäß ABI werden zuerst Framepointer und Linkregister auf den Stack gesichert, gefolgt von allen nicht-volatilen Registern x19 - x28, die man in der Funktion verändern möchte. (Im Beispiel wird gar nichts verändert, aber zwecks Demo werden trotzdem alle herangezogen).

Die erste stp Anweisung mit dem Rufzeichen am Ende führt zwei Änderungen durch: Sie subtrahiert zuerst vom Stackpointer die Puffergröße für alle zu speichernden Register (12 x 8 = 0x60) und danach werden fp und lr in den Stack gespeichert. Die Nachfolgenden stps sichern die restlichen Register “über” dem aktuellen Stackpointer.

Danach wird noch der Framepointer fp auf den Stackpointer sp gesetzt und es werden genau so viele Bytes vom Stackpointer subtrahiert, wie man für lokale Variablen braucht, die man dann über den Framepointer adressieren kann.

Der Framepointer zeigt somit genau auf die Stack-Adresse, an der der alte Framepointer und danach die Rücksprungadresse gesichert wurde, und so kann der Debugger den Callstack ermitteln.

Lokale Variablen werden dann über fp[-offset] oder sp[+offset] angesprochen. Nach dem Prolog sieht das Stack-Layout so aus:

Frame ptr Stack ptr Bedeutung
fp[0x60] sp[0x70] Aufrufer-Stack, weitere Parameter
    Beginn des lokalen Stacks
fp[0x58] sp[0x68] gesichertes NV Register 10 (x28)
fp[0x50] sp[0x60] gesichertes NV Register 9 (x27)
fp[0x18] sp[0x28] gesichertes NV Register 2 (x20)
fp[0x10] sp[0x20] gesichertes NV Register 1 (x19)
fp[0x08] sp[0x18] Rücksprungadresse zum Aufrufer
fp[0x00] sp[0x10] Framepointer des Aufrufers
fp[-0x08] sp[0x08] Erste 64-bit Stack Variable
fp[-0x10] sp[0x00] Zweite 64-bit Stack Variable
-   Ende des lokalen Stacks

Nachdem auf ARM64 die ersten 8 Funktionsparameter per Register übergeben werden, kann mit diesen sofort gerechnet werden. Im Beispiel werden schließlich nur zwei Parameter (x0, x1) addiert und das Ergebnis in x0 zurückgegeben.

Der Epilog ist die Umkehrung des Prologs. Zuerst wird der Speicher für lokale Variablen vom Stackpointer “wegaddiert”, dann werden alle nicht-volatilen Register vom Stack zurückgelesen und am Ende der Stackpointer wieder erhöht, damit er genau den Wert hat, den er am Anfang der Funktion hatte.

ret springt dann zur Adresse des wiederhergestellten Linkregisters.

Funktionsaufruf

Die obige Beispielfunktion my_asm_func_add_2_parameters wird ganz einfach per

1  mov x0, #21
2  mov x1, #12
3  bl my_asm_func_add_2_parameters

aufgerufen.
Es werden die Parameter-Register gesetzt, und danach springt bl die Adresse an und sichert gleichzeitig die Rücksprungadresse im Linkregister lr.

Fazit

Mit dem Wissen kann man jetzt auch schon fleißig Assembler-Routinen in ARM64 Programmen schreiben.

Natürlich fehlt noch der Hinweis, dass bei mehr als 8 Parametern, die anderen vor dem Funktionsaufruf auf den Stack gepusht werden, und dass bei Variadic-Functions die Parameter Register ebenso zuerst auf den Stack gepusht werden, damit alle Parameter hintereinander auf dem Stack liegen … aber solche Details hat Raymond schon super beschrieben.

Nicht vergessen darf man auch, dass Floating-Point Argumente in eigenen Register übergeben werden und deren Nummerierung ist leider kompliziert, wenn man sie mit Integer Parametern mischt.

Am Ende bleibt zu sagen, dass ich nun meine Callstack-Switching Routinen im GATE Framework in ARM64 Assembler nachziehen kann, damit auch diese Plattformen abgedeckt sind.

Von X86 kommend wirkte ARM für mich anfangs sehr ungewohnt … doch nach ein paar Fingerübungen wirkt das ganze vertrauter und von den Instruktionen recht genial umgesetzt.