Lyx – ABI & Calling Conventions

Die ABI (Application Binary Interface) legt fest, wie Funktionen auf Binärebene kommunizieren: welche Register Argumente tragen, wie der Stack aufgebaut ist, wer Register sichert und wie der Rückgabewert transportiert wird. Lyx folgt den nativen Konventionen der jeweiligen Zielplattform, um Kompatibilität mit Betriebssystem-Syscalls und C-Bibliotheken ohne Wrapper zu gewährleisten.

Dieses Wissen ist notwendig für:

  • FFI-Grenzen – C-Funktionen korrekt aufrufen und exportieren
  • Inline-Assembly – Register-Belegung vor und nach unsafe-Blöcken
  • Debugging – Stack-Traces und Disassemblies interpretieren
  • Safety-Nachweis–call-graph-Reports bei DO-178C-Zertifizierung

1. Datentyp-Größen & Alignment

Alle Typen folgen ihrer natürlichen Ausrichtung (Alignment = Größe, maximal 8 Byte). Das gilt auf allen drei unterstützten Architekturen.

Typ Größe (Byte) Alignment Registerklasse
bool 1 1 Integer (GPR), Wert 0 oder 1
int8 / uint8 1 1 Integer (GPR)
int16 / uint16 2 2 Integer (GPR)
int32 / uint32 4 4 Integer (GPR)
int64 / uint64 8 8 Integer (GPR)
isize / usize 8 8 Integer (GPR)
f32 4 4 Float (FPU/SIMD)
f64 8 8 Float (FPU/SIMD)
qbool 8 8 Float (FPU) – wie f64
pchar / ^T 8 8 Integer (GPR) – Pointer = int64
char 1 1 Integer (GPR)
 
qbool in der ABI:
Obwohl qbool logisch wie ein bool wirkt, wird er als f64 behandelt. Wahrscheinlichkeitswerte fließen direkt durch die Floating-Point-Einheiten – ohne GPR-zu-FPU-Konvertierung.

Struct-Alignment

Structs werden so ausgerichtet, dass jedes Feld sein natürliches Alignment erfüllt. Der Compiler fügt Padding-Bytes ein:

type Example = struct {
    a: uint8;    // Offset 0, 1 Byte
    // 7 Byte Padding
    b: int64;    // Offset 8, 8 Byte
    c: uint32;   // Offset 16, 4 Byte
    // 4 Byte Padding
};
// Gesamt: 24 Byte

Mit @packed entfällt das Padding (wichtig für Hardware-Register-Maps und Protokoll-Frames):

@packed
type PackedExample = struct {
    a: uint8;    // Offset 0
    b: int64;    // Offset 1
    c: uint32;   // Offset 9
};
// Gesamt: 13 Byte – kein Padding

→ Details: FFI – @packed und Struct-Layout

2. x86_64 – System V AMD64 (Linux / macOS)

Die Standard-Konvention auf Linux und macOS. Lyx verwendet sie für alle Nicht-Windows-Targets.

Integer-Register

Zuordnung der Integer-Register nach x86-64 System V ABI:

Zweck Register Gesichert von
Argument 1 RDI Aufrufer (Caller-saved)
Argument 2 RSI Aufrufer
Argument 3 RDX Aufrufer
Argument 4 RCX Aufrufer
Argument 5 R8 Aufrufer
Argument 6 R9 Aufrufer
Argument 7+ Stack Aufrufer
Rückgabewert (primär) RAX Aufrufer
Rückgabewert (sekundär) RDX Aufrufer
Scratch (temporär) R10, R11 Aufrufer
Callee-saved RBX, RBP, R12–R15 Aufgerufener

Float-Register (XMM / YMM)

Zuordnung der Floating-Point-Register:

Zweck Register
Float-Argumente 1–8 XMM0 – XMM7
Float-Rückgabewert XMM0
Caller-saved XMM0 – XMM15

Prolog / Epilog (x86_64)

Standard-Funktionsrahmen bei x86_64:

; Funktionsprolog (Lyx-generiert)
push  rbp              ; Sichert alten Frame-Pointer (Callee-saved)
mov   rbp, rsp         ; Neuer Frame-Pointer
sub   rsp, N           ; Platz für lokale Variablen (N = nächstes Vielfaches von 16)

; … Funktionskörper …

; Funktionsepilog
mov   rsp, rbp         ; Stack-Pointer wiederherstellen
pop   rbp              ; Frame-Pointer wiederherstellen
ret                    ; Springt zur Adresse auf dem Stack (von 'call' abgelegt)

Konkretes Beispiel

fn Add(a: int64, b: int64): int64 {
    return a + b;
}

Generierter Assembler:

Add:
    push  rbp
    mov   rbp, rsp
    mov   rax, rdi      ; a kommt in RDI → ins Ergebnis-Register RAX
    add   rax, rsi      ; b kommt in RSI → addieren
    pop   rbp
    ret

Rekursion auf x86_64

Bei rekursiven Aufrufen muss nichts extra gesichert werden – call legt die Rücksprungadresse automatisch auf den Stack. RBP wird im Prolog gesichert.

Factorial:
    push  rbp
    mov   rbp, rsp
    cmp   rdi, 1
    jle   .base
    push  rdi             ; RDI sichern (Caller-saved, wird durch rekursiven call überschrieben)
    dec   rdi
    call  Factorial       ; RDI = n-1
    pop   rdi             ; RDI (= n) wiederherstellen
    imul  rax, rdi        ; Ergebnis = n * Factorial(n-1)
    jmp   .done
.base:
    mov   rax, 1
.done:
    pop   rbp
    ret

3. x86_64 – Microsoft x64 (Windows)

Windows verwendet eine eigene Calling Convention. Der Hauptunterschied: nur vier Argument-Register, Shadow Space auf dem Stack.

Integer-Register

Zuordnung der Integer-Register nach x86-64 System V ABI:

Zweck Register Gesichert von
Argument 1 RCX Aufrufer
Argument 2 RDX Aufrufer
Argument 3 R8 Aufrufer
Argument 4 R9 Aufrufer
Argument 5+ Stack Aufrufer
Rückgabewert RAX Aufrufer
Callee-saved RBX, RBP, RDI, RSI, R12–R15 Aufgerufener

Float-Register

Zuordnung der Floating-Point-Register:

Zweck Register
Float-Argumente 1–4 XMM0 – XMM3
Float-Rückgabewert XMM0
Caller-saved XMM0 – XMM5
Callee-saved XMM6 – XMM15
 
Wichtiger Unterschied zu System V:
Auf Windows sind XMM6–XMM15 callee-saved. Wenn Lyx-Code diese Register nutzt (z. B. für f64-Operationen), muss der generierte Prolog sie sichern. Der Lyx-Compiler erledigt das automatisch beim Windows-Target.

Shadow Space

Vor jedem Funktionsaufruf reserviert der Aufrufer 32 Byte Shadow Space auf dem Stack – direkt unterhalb der Rücksprungadresse. Windows-API-Funktionen nutzen diesen Bereich intern zum Sichern von RCX, RDX, R8, R9.

; Windows-Aufruf: Funktion mit 2 Argumenten
sub   rsp, 40        ; 32 Byte Shadow Space + 8 Byte Alignment
mov   rcx, arg1      ; Argument 1
mov   rdx, arg2      ; Argument 2
call  SomeCFunction
add   rsp, 40        ; Shadow Space freigeben

Lyx generiert den Shadow Space automatisch bei allen @extern-Aufrufen unter Windows-Target.

@stdcall

@stdcall ist die Aufrufkonvention älterer Win32-APIs (vor Vista weit verbreitet). Der wesentliche Unterschied: Der Aufgerufene räumt den Stack auf (nicht der Aufrufer).

@extern @stdcall
fn MessageBoxA(hwnd: int64, text: pchar, caption: pchar, flags: uint32): int32;

4. ARM64 / AArch64 (AAPCS64)

ARM64 verwendet den AAPCS64-Standard (Procedure Call Standard for the Arm 64-bit Architecture). Dieser gilt auf Linux-ARM, Apple Silicon (macOS/iOS) und Embedded-ARM-Targets.

Integer-Register (X0–X30)

Zuordnung der Integer-Register nach x86-64 System V ABI:

Register Alias Zweck Gesichert von
X0–X7 Argumente 1–8 / Rückgabewert 1–2 Aufrufer
X8 XR Indirekter Ergebnis-Zeiger (große Structs) Aufrufer
X9–X15 Temporäre Register Aufrufer
X16–X17 IP0, IP1 Intra-Procedure-Call Scratch Aufrufer
X18 PR Platform-Register (OS-intern, nicht für Lyx)
X19–X28 Callee-saved Register Aufgerufener
X29 FP Frame-Pointer Aufgerufener
X30 LR Link-Register (Rücksprungadresse) Aufgerufener
SP Stack-Pointer (16-Byte-Aligned)

Float-/SIMD-Register (V0–V31)

Register Zweck Gesichert von
V0–V7 Float-Argumente / Float-Rückgabewert Aufrufer
V8–V15 Callee-saved Aufgerufener
V16–V31 Temporäre SIMD-Register Aufrufer

Prolog / Epilog (ARM64)

; ARM64 Funktionsprolog
stp   x29, x30, [sp, #-16]!   ; Frame-Pointer (x29) und Link-Register (x30) sichern
                                ; '!' = Pre-Decrement: SP wird zuerst um 16 verringert
mov   x29, sp                   ; Neuen Frame-Pointer setzen

; … Funktionskörper …

; ARM64 Funktionsepilog
ldp   x29, x30, [sp], #16      ; FP und LR wiederherstellen; SP += 16
ret                              ; Springt zu Adresse in X30

 
Häufigster ARM64-Bug bei Rekursion:
Das Link-Register X30 wird beim bl-Befehl (Branch with Link) mit der Rücksprungadresse des aktuellen Aufrufs überschrieben. Ohne stp x29, x30 am Anfang verliert die Funktion ihre eigene Rücksprungadresse, sobald sie eine andere Funktion aufruft. Das führt zu einem Absturz beim ret.

Konkretes Beispiel: Drei-Argument-Funktion

fn Clamp(val: int64, lo: int64, hi: int64): int64 {
    if (val < lo) { return lo; }
    if (val > hi) { return hi; }
    return val;
}

Clamp:
    ; val → X0, lo → X1, hi → X2
    stp   x29, x30, [sp, #-16]!
    mov   x29, sp
    cmp   x0, x1
    bge   .check_hi
    mov   x0, x1           ; return lo
    b     .done
.check_hi:
    cmp   x0, x2
    ble   .done
    mov   x0, x2           ; return hi
.done:
    ldp   x29, x30, [sp], #16
    ret

Ergebnis-Register bei großen Structs

Structs bis 16 Byte werden in X0–X1 zurückgegeben (zwei 8-Byte-Hälften). Größere Structs: Der Aufrufer reserviert Platz und übergibt den Zeiger in X8 (Indirect Result Register). Der Aufgerufene schreibt das Ergebnis dorthin.

type BigResult = struct { a: int64; b: int64; c: int64; };   // 24 Byte

fn GetResult(): BigResult {
    return BigResult { a: 1, b: 2, c: 3 };
    // Compiler: Aufrufer reserviert 24 Byte, Adresse in X8
    //           Aufgerufener schreibt nach [X8]
}

5. RISC-V64 (RV64GC)

Für RISC-V nutzt Lyx die offizielle RISC-V calling convention (psABI, Integer + FP extension).

Register-Übersicht

ABI-Name Reg.-Nr. Zweck Gesichert von
zero x0 Konstante 0 (hardwired)
ra x1 Return Address (Rücksprungadresse) Aufgerufener
sp x2 Stack Pointer Aufgerufener
gp x3 Global Pointer (GOT-Zugriff)
tp x4 Thread Pointer (TLS)
t0–t2 x5–x7 Temporäre Register Aufrufer
s0 / fp x8 Saved Register / Frame Pointer Aufgerufener
s1 x9 Saved Register Aufgerufener
a0–a7 x10–x17 Argumente / Rückgabewerte (a0–a1) Aufrufer
s2–s11 x18–x27 Saved Register Aufgerufener
t3–t6 x28–x31 Temporäre Register Aufrufer

Float-Register (F-Extension)

ABI-Name Zweck Gesichert von
fa0–fa7 Float-Argumente / Rückgabe Aufrufer
fs0–fs11 Saved Float-Register Aufgerufener
ft0–ft11 Temporäre Float-Register Aufrufer

Prolog / Epilog (RISC-V64)

; RISC-V64 Funktionsprolog
addi  sp, sp, -16      ; Stack-Pointer verringern
sd    ra, 8(sp)        ; Rücksprungadresse sichern
sd    s0, 0(sp)        ; Frame-Pointer sichern
addi  s0, sp, 16       ; Neuen Frame-Pointer setzen

; … Funktionskörper …

; RISC-V64 Funktionsepilog
ld    ra, 8(sp)        ; Rücksprungadresse wiederherstellen
ld    s0, 0(sp)        ; Frame-Pointer wiederherstellen
addi  sp, sp, 16       ; Stack freigeben
ret                     ; = jalr x0, ra, 0

Ähnlich wie ARM64: ra muss im Prolog gesichert werden, bevor eine andere Funktion aufgerufen wird.

6. Stack-Layout & Alignment

Alle drei Architekturen verlangen ein 16-Byte-Alignment des Stack-Pointers zum Zeitpunkt eines Funktionsaufrufs. Der Lyx-Compiler stellt dies im Prolog sicher.

Stack-Frame-Struktur (x86_64, System V)

Höhere Adressen (Richtung Basis)
┌──────────────────────────────────┐
│ Argument 8+ (vom Aufrufer)       │ ← RSP vor dem 'call'
├──────────────────────────────────┤
│ Rücksprungadresse (von 'call')   │
├──────────────────────────────────┤  ← RBP zeigt hier
│ Gesicherter alter RBP            │
├──────────────────────────────────┤
│ Lokale Variable 1                │
│ Lokale Variable 2                │
│ …                                │
├──────────────────────────────────┤
│ Padding (für 16-Byte-Alignment)  │
└──────────────────────────────────┘  ← RSP (aktuelle Position)
Niedrigere Adressen (Stack wächst ↓)

@integrity – Canary-Bytes

In Modulen mit @integrity oder @redundant fügt der Lyx-Compiler zusätzliche Canary-Bytes zwischen Stack-Frames ein. Diese werden beim Epilog geprüft; eine Änderung signalisiert einen Pufferüberlauf oder Bit-Flip.

@integrity(mode: scrubbed)
@dal(B)
fn ProcessFrame(data: ^uint8) {
    var buf: [128]uint8;
    // Compiler: legt Canary-Wert oberhalb von 'buf', prüft ihn im Epilog
}

7. Rückgabewerte

Konventionen für Rückgabewerte nach ABI:

Primitive Typen

Typ x86_64 ARM64 RISC-V64
int8int64, Pointer RAX X0 a0
f32, f64, qbool XMM0 V0 fa0
Zweiter Rückgabewert RDX X1 a1

Tupel-Rückgabe

Lyx unterstützt Tupel-Rückgaben: (int64, bool). Beide Werte werden in den zwei primären Rückgabe-Registern transportiert:

fn Divide(a: int64, b: int64): (int64, bool) {
    if (b = 0) { return (0, false); }
    return (a / b, true);
}

; x86_64: RAX = Quotient, RDX = ok (0 oder 1)
; ARM64:  X0  = Quotient, X1  = ok
; RISC-V: a0  = Quotient, a1  = ok

Große Structs (> 16 Byte)

Structs über 16 Byte werden nicht in Registern zurückgegeben. Der Aufrufer alloziert den Speicher und übergibt einen versteckten Zeiger:

Architektur Zeiger-Register
x86_64 (System V) RDI (erstes Argument – verschiebt alle anderen um +1)
ARM64 X8 (Indirect Result Register – kein Argument verschoben)
RISC-V64 a0 (erstes Argument – verschiebt alle anderen um +1)

8. Variadic Functions (@variadic)

Variadische Funktionen (C-printf-Stil) erfordern in System V, dass AL (lower byte of RAX) die Anzahl der genutzten XMM-Register enthält, bevor die Funktion aufgerufen wird.

@extern @variadic
fn printf(fmt: pchar): int32;

; x86_64 System V: printf("Wert: %d\n", 42)
mov   rdi, fmt_str     ; Format-String
mov   rsi, 42          ; Erstes variadisches Argument
xor   eax, eax         ; AL = 0 (keine XMM-Register genutzt)
call  printf

Auf Windows (Microsoft x64) entfällt die AL-Konvention; variadische Argumente folgen den normalen Argument-Registern (RCX, RDX, R8, R9, dann Stack).

9. Systemaufruf-Konvention (Syscalls)

Syscalls nutzen eine eigene Konvention und umgehen die normale Calling Convention. std.os und std.fs kapseln diese Aufrufe.

Aspekt x86_64 (Linux) ARM64 (Linux) RISC-V64 (Linux)
Syscall-Nummer RAX X8 a7
Argument 1 RDI X0 a0
Argument 2 RSI X1 a1
Argument 3 RDX X2 a2
Argument 4 R10 X3 a3
Argument 5 R8 X4 a4
Argument 6 R9 X5 a5
Rückgabewert RAX X0 a0
Instruktion syscall svc #0 ecall

// std.os kapselt Syscalls – direkter Einsatz nur in unsafe + FFI:
unsafe {
    // write(1, msg, len) → Syscall Nr. 1 auf x86_64
    var result: int64;
    // ... inline asm oder std.os.Write() verwenden
}

10. Plattform-Vergleich: Wichtigste Unterschiede

Merkmal System V (Linux/macOS) Microsoft x64 ARM64 (AAPCS64) RISC-V64
Argument-Register (Int) 6 (RDI,RSI,RDX,RCX,R8,R9) 4 (RCX,RDX,R8,R9) 8 (X0–X7) 8 (a0–a7)
Argument-Register (Float) 8 (XMM0–XMM7) 4 (XMM0–XMM3) 8 (V0–V7) 8 (fa0–fa7)
Rückgabe-Register RAX (+RDX) RAX X0 (+X1) a0 (+a1)
Float-Rückgabe XMM0 XMM0 V0 fa0
Shadow Space Nein 32 Byte Nein Nein
Callee-saved XMM Keine XMM6–XMM15 V8–V15 fs0–fs11
Rücksprung-Adresse Stack (call) Stack (call) X30 (LR) x1 (ra)
Stack-Alignment 16 Byte 16 Byte 16 Byte 16 Byte
Struct > 16 Byte RDI (arg 0) Stack X8 (IR) a0 (arg 0)

11. Auswirkung auf Lyx-Code

Praktische Auswirkungen der ABI-Regeln auf Lyx-Programmcode:

Funktionen mit vielen Parametern

Mehr als 6 (System V) / 4 (Windows) / 8 (ARM64/RISC-V) Integer-Argumente gehen auf den Stack – mit Aufruf-Overhead. Die Lyx-Empfehlung: Struct-Pointer übergeben.

// Schlecht: viele Parameter → Stack-Overflow-Risiko bei @flight_crit
fn ProcessData(a: int64, b: int64, c: int64, d: int64, e: int64, f: int64, g: int64) { ... }

// Besser: Struct-Pointer – ein Register, kein Stack-Argument
type DataParams = struct { a: int64; b: int64; c: int64; d: int64; e: int64; f: int64; g: int64; };
fn ProcessData(p: ^DataParams) { ... }

float vs. int in Argumentposition

Werden Float- und Integer-Argumente gemischt, belegen sie separate Registerbänke und blockieren sich nicht gegenseitig:

fn Mix(i: int64, f: f64, j: int64): f64 {
    // x86_64 System V: i→RDI, f→XMM0, j→RSI
    // ARM64:            i→X0,  f→V0,   j→X1
    return f * (i as f64) + (j as f64);
}

@export und C-Kompatibilität

Mit @export erzeugt der Lyx-Compiler ein C-kompatibles Symbol ohne Name-Mangling. Die Konvention folgt der Plattform-ABI:

@export
pub fn lyx_add(a: int64, b: int64): int64 {
    return a + b;
}

// Verwendung aus C:
extern long lyx_add(long a, long b);
long result = lyx_add(3, 4);   // 7

12. ABI in Safety-Code

Bei DO-178C-Zertifizierung muss die ABI-Konformität nachgewiesen werden. Der –call-graph-Pass erzeugt einen Bericht über alle Funktionsaufrufe und deren Register-Belegung:

lyxc --call-graph --provenance --target arm64 main.lyx

Call-Graph-Auszug:
  ProcessFlightData(val: f64)
    Plattform: ARM64 (AAPCS64)
    Argumente: val → V0 (f64, Float-Register)
    Rückgabe:  X0  (int64)
    Callee-saved genutzt: X19, X20
    Sichere Register: X19, X20 korrekt gesichert (stp/ldp im Prolog/Epilog)
    unsafe-Blöcke: keine
    @redundant-Variablen: heading (TMR-gesichert)
    ✅ ABI-konform

13. macOS x86_64 – Mach-O Backend-Besonderheiten

Der –target=macosx64-Backend erzeugt Mach-O-Binaries statt ELF. Die Calling Convention ist identisch mit System V AMD64 (Abschnitt 2), aber es gibt plattformspezifische Unterschiede im generierten Maschinencode und in den Syscall-Nummern.

_start-Stub und Helper-Offsets

Der macOS-_start-Stub ist 79 Bytes lang (Linux: 65 Bytes). Die drei eingebetteten Helper-Symbole liegen an exakt denselben Offset-Positionen wie auf Linux:

Symbol Offset im Binary
strlen Byte 79
printstr Byte 94
printint Byte 120

Der rel32-Patch für den call main-Sprung verwendet Bytes 63–66 (nicht 49–52 wie bei CG_ARGC — ein kritischer Bug in der ersten Implementierung, der falsche Sprungziele erzeugte).

Mach-O Data-Section

cg.data (Strings und VMT-Tabellen) wird nach dem Code-Segment in die Mach-O-Datei geschrieben. VMT-Zeiger, die für Linux-Basisadressen berechnet wurden, werden beim Schreiben auf die macOS-Ladeadresse umgerechnet (VMT-Patching: Linux-Base → macOS-Base).

Syscall-Nummern: macOS vs. Linux

macOS x86_64 verwendet andere Syscall-Nummern als Linux. Die folgende Tabelle zeigt die 9 Socket-Syscalls, die der Backend-Code anpasst:

Syscall Linux x86_64 macOS x86_64
socket 41 97
connect 42 98
bind 49 104
listen 50 106
accept 43 30
sendto 44 133
recvfrom 45 29
setsockopt 54 105
shutdown 48 134

Plattformkonstanten

Konstante Linux macOS
MAP_ANON 0x20 0x1002

Der Unterschied betrifft alle mmap-Aufrufe für anonyme Speicherzuordnungen (alloc, Heap, Stack-Extension). Ein falscher MAP_ANON-Wert führt zu EINVAL beim Systemaufruf.


Weiterführende Seiten: