Assembler: Stack-Management und Aufrufkonventionen
In höheren Sprachen wie C oder C++ regelt der Compiler automatisch, wie Parameter und Rückgabewerte zwischen Funktionen ausgetauscht werden. In der Assemblersprache erfolgt dies hingegen explizit durch Registerzuweisungen, Speicherzugriffe oder Push-/Pop-Operationen auf dem Stack. Dabei entscheidet die Aufrufkonvention über das genaue Vorgehen zum Ablegen von Parametern und zum Speichern des Rückgabewerts.
Grundlegendes zum Stack
Der Stack dient in vielen Architekturmodellen als Hauptwerkzeug, um Funktionsparameter, lokale Variablen und Rücksprungadressen zu verwalten. Er wird üblicherweise über einen speziellen Zeiger wie ESP (x86) oder RSP (x86-64) adressiert. Ein typischer Funktionsaufruf umfasst:
- Speichern von benötigten Registern auf dem Stack.
- Ablegen von Parametern auf dem Stack (je nach Aufrufkonvention).
- Aufruf der Funktion mit
CALLoderBL(je nach Architektur). - Platzieren der Rücksprungadresse automatisch durch die CPU.
Beispiel: x86 cdecl-Konvention
In der x86-Architektur legt die cdecl-Konvention fest, dass Parameter auf dem Stack in umgekehrter Reihenfolge abgelegt werden. Der Aufrufer ist außerdem dafür verantwortlich, den Stack nach dem Funktionsaufruf wieder aufzuräumen. Ein vereinfachter Ablauf sieht so aus:
; Beispiel: int sum(int a, int b) ; Aufrufer bereitet den Stack vor, legt Parameter ab ; --- call site --- push dword 7 ; b push dword 3 ; a call sum add esp, 8 ; Stack wieder aufräumen
Aufgerufene Funktion (sum):
sum:
push ebp ; Basiszeiger speichern
mov ebp, esp ; Basiszeiger setzen
mov eax, [ebp+8] ; a in eax
add eax, [ebp+12] ; eax ← eax + b
pop ebp
ret
Hier wird a bei Adresse [ebp+8] und b bei [ebp+12] gefunden, da der Stack an dieser Stelle die Parameter enthält. Der aufgerufene Code rettet und restauriert den ebp-Registerinhalt, damit bei Rückkehr der Zustand wiederhergestellt ist.
x86-64 System V AMD64 ABI
In der 64-Bit-Welt werden die ersten Integer- bzw. Zeiger-Parameter meist über Register übergeben. So verwendet die System V AMD64 ABI die Register RDI, RSI, RDX, RCX, R8 und R9 für bis zu sechs Parameter. Weitere Parameter kommen auf den Stack. Ein Funktionsaufruf könnte folgendermaßen aussehen:
; Beispiel: sum(rdi, rsi)
; sum-Funktion erwartet erste beiden Parameter in rdi, rsi
global sum
section .text
sum:
mov rax, rdi ; sichere ersten Parameter
add rax, rsi ; rax ← rax + rsi
ret
Wird diese Funktion aus C heraus aufgerufen, legt der Compiler den ersten Parameter in rdi und den zweiten in rsi ab. Das Ergebnis wird in rax erwartet.
Stack-Frames und lokale Variablen
Zusätzliche lokale Variablen werden im Stack-Bereich unterhalb des Basiszeigers abgelegt. Dabei reserviert die Funktion Speicher, wenn sie ihren Stackrahmen (Stack Frame) einrichtet:
; Beispiel: lokale Variable sichern
someFunc:
push rbp ; Basiszeiger sichern
mov rbp, rsp ; neuen Basiszeiger setzen
sub rsp, 32 ; 32 Bytes für lokale Variablen reservieren
; Zugriff auf lokale Variablen über [rbp - offset]
leave ; Stack wieder freigeben (mov rsp, rbp; pop rbp)
ret
Die genaue Verwendung variiert je nach Optimierungsgrad des Compilers. Mitunter werden lokale Variablen gar nicht im Stack, sondern in Registern gehalten, falls dies effizienter ist.
Weitere Aufrufkonventionen
Neben cdecl und der System V ABI existieren weitere Konventionen innerhalb derselben Plattform oder auf anderen Architekturen. Beispiele sind stdcall (Windows), fastcall, Microsoft x64 oder ARM EABI. Die Hauptunterschiede liegen darin, welche Register für Parameter und Rückgabewerte verwendet werden und wer nach dem Aufruf den Stack aufräumt.
Praxisbeispiel: Verketten von Strings in x86-64
section .data
str1 db "Hello, ", 0
str2 db "World!", 0
section .bss
buffer resb 64
section .text
global _start
; char* concat(char* dst, const char* src)
concat:
push rbp
mov rbp, rsp
.loop:
mov al, [rsi]
cmp al, 0
je .done
mov [rdi], al
inc rdi
inc rsi
jmp .loop
.done:
mov byte [rdi], 0
pop rbp
ret
_start:
; Parameterübergabe an concat, rdi=Ziel, rsi=Quelle
mov rdi, buffer
mov rsi, str1
call concat
; zweiter Aufruf
mov rdi, buffer
mov rsi, str2
call concat
; Ausgabe des Ergebnisses über Linux syscall write(1, buffer, length)
mov rax, 1 ; write
mov rdi, 1 ; stdout
mov rsi, buffer ; Adresse der Zeichenkette
mov rdx, 13 ; 13 Zeichen (einschließlich Nullbyte)
syscall
; Programm beenden
mov rax, 60 ; exit
xor rdi, rdi
syscall
Dieser Code zeigt das Zusammenspiel von Parametern, Registern und dem Stack. Die Funktion concat erhält ihre Argumente in rdi (Zielpuffer) und rsi (Quell-String). Mithilfe von mov, inc und einem Schleifenkonstrukt wird Zeichen für Zeichen kopiert, bis das Nullterminator-Byte erreicht wird. Anschließend wird der Buffer mit einem zusätzlichen Nullterminator abgeschlossen.
Stack-Management in anderen Architekturen
In ARM- oder RISC-V-Umgebungen existieren ähnliche Ansätze, jedoch unterscheiden sich die Registerbezeichnungen und die Konventionen zum Ablegen von Parametern. Bei ARM werden zum Beispiel bis zu vier Parameter in R0–R3 übergeben. Der Rest wandert auf den Stack. Auch hier ist die korrekte Erzeugung von Stack-Frames für lokale Variablen oder verschachtelte Aufrufe verantwortlich.