ARM64 Assembler
« | 03 Sep 2022 | »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 stp
s 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
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.