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 CALL oder BL (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.

Ähnliche Beiträge

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert