Lyx – Low-Level: Pointer & Inlining

Systemprogrammierung – Treiber, Kernel-Module, Echtzeit-Regler, Hardware-Abstraktionen – erfordert direkten Zugriff auf Speicheradressen und präzise Kontrolle über den generierten Maschinencode. Lyx bietet dafür ein vollständiges Pointer-Modell nach Pascal-Vorbild sowie mehrere Performance-Pragmas (@inline, @no_opt, @parallel, @section), die das Backend gezielt steuern.

Sicherheits-Garantien wie Range-Checks und @flight_crit-Regeln bleiben dabei erhalten: Pointer-Arithmetik ist explizit in unsafe-Blöcke eingekapselt, und in zertifizierungspflichtigen Modulen erzeugt ungesicherter Pointer-Einsatz einen Compiler-Fehler.

1. Pointer – Grundlagen

Ein Pointer speichert die Adresse einer anderen Variable im Speicher. Intern wird jeder Pointer als vorzeichenloser int64 (8 Byte) dargestellt – auf allen unterstützten 64-Bit-Plattformen (x86_64, ARM64, RISC-V64).

Pointer-Operatoren

Operator Bedeutung Beispiel
^T Pointer-Typ auf T var p: ^int64
^x Adresse von x (Address-of) ^counter
p^ Wert an Adresse p (Dereferenz) p^ := 42
nil Null-Pointer (kein gültiges Ziel) var p: ^int64 := nil
 
Syntax-Merkregel:
Der Hut ^ zeigt vor dem Typ auf den Typ (^int64 = Pointer auf int64) und vor einer Variable auf deren Adresse (^x = Adresse von x). Nach dem Pointer-Namen dereferenziert er (p^ = Wert an p).

Lesen und Schreiben über einen Pointer

unit demo;
import std.io;

fn main(): int64 {
    var x: int64 := 10;
    var p: ^int64 := ^x;   // p zeigt auf x

    PrintInt(p^);           // Ausgabe: 10  (lesen)
    p^ := 99;               // schreiben über den Pointer
    PrintInt(x);            // Ausgabe: 99  (x wurde geändert)

    return 0;
}

p^ und x benennen denselben Speicherplatz: Eine Zuweisung über p^ verändert x direkt.

2. Pointer auf Structs

Struct-Felder sind über einen Pointer mit . erreichbar – Lyx erfordert keine gesonderte Pfeil-Syntax (). Der Dereferenz-Operator wirkt auf den gesamten Struct-Wert.

unit structs;
import std.io;

type Vec2 = struct {
    x: f64;
    y: f64;
};

fn Scale(p: ^Vec2, factor: f64) {
    p^.x := p^.x * factor;
    p^.y := p^.y * factor;
}

fn main(): int64 {
    var v := Vec2 { x: 3.0, y: 4.0 };
    Scale(^v, 2.0);
    PrintFloat(v.x);   // 6.0
    PrintFloat(v.y);   // 8.0
    return 0;
}

Das Übergeben eines Struct-Pointers (^Vec2) ist die Standard-Methode, um große Strukturen ohne Kopier-Overhead zu modifizieren.

3. Mehrfach-Pointer (Pointer auf Pointer)

Pointer können auf andere Pointer zeigen. Der Typ ergibt sich durch Verschachtelung mit Klammern.

var x: int64 := 42;
var p:  ^int64      := ^x;   // Pointer auf int64
var pp: ^(^int64)   := ^p;   // Pointer auf Pointer auf int64

PrintInt(p^);    // 42
PrintInt(pp^^);  // 42  (doppelte Dereferenz)

pp^^  := 99;     // schreibt 99 direkt in x
PrintInt(x);     // 99

Mehrfach-Pointer werden in der Praxis für Output-Parameter von Funktionen oder für dynamische Treiber-Dispatch-Tabellen verwendet.

4. nil – Der Null-Pointer

nil ist der Null-Pointer: ein Pointer ohne gültiges Ziel. Der Versuch, nil^ zu lesen oder zu schreiben, führt zu einem Laufzeit-Fehler (Segmentation Fault oder explizitem panic).

fn TryRead(p: ^int64): int64 {
    if (p = nil) {
        return -1;       // Fehlerfall explizit behandeln
    }
    return p^;
}

 
nil-Check-Pflicht:
Der Compiler warnt (–lint), wenn ein Pointer ohne vorherigen nil-Check dereferenziert wird und nicht aus einem Kontext stammt, der nil ausschließt (z. B. ^lokaleVariable).

Nullable Pointer-Typen

Mit dem ?-Suffix wird ein Pointer explizit als nullable deklariert. In Kombination mit dem Null-Coalescing-Operator ?? lassen sich Fallback-Werte definieren:

var name: pchar? := GetOptionalName();   // darf nil sein
var display: pchar := name ?? "Unbekannt";
PrintStr(display);

5. Pointer ↔ int64 – Typ-Casts

Da alle Pointer intern als int64 gespeichert sind, ist der explizite Cast in beide Richtungen möglich. Das ist für MMIO-Adressen und FFI-Aufrufe notwendig.

var addr: int64    := 0x40020000;
var reg:  ^uint32  := addr as ^uint32;   // Integer → Pointer

var p:   ^uint8   := get_buffer();
var raw: int64    := p as int64;         // Pointer → Integer (für Arithmetik)

 
Achtung:
as-Casts zwischen Pointer und Integer umgehen die Typsicherheit vollständig. Außerhalb von unsafe-Blöcken sind sie nur für MMIO-Adressen (@volatile) und nachgewiesene FFI-Szenarien gedacht.

6. @volatile – Memory-Mapped I/O (MMIO)

Auf Mikrocontrollern (ESP32, STM32, RISC-V) sind Hardware-Register direkt in den Adressraum eingeblendet. Ohne @volatile kann der Compiler Zugriffe auf dieselbe Adresse wegoptimieren, weil er keine Seiteneffekte erkennt.

GPIO-Beispiel (ESP32 / RISC-V)

unit gpio;

@volatile
var GPIO_OUT:    ^uint32 := 0x3FF44004 as ^uint32;   // Setze GPIOs HIGH

@volatile
var GPIO_OUT_W1TC: ^uint32 := 0x3FF4400C as ^uint32; // Lösche GPIOs (Write-1-to-Clear)

fn ToggleLed(pin: uint32) {
    GPIO_OUT^       := 1u32 << pin;   // LED an
    // … Wartezeit …
    GPIO_OUT_W1TC^  := 1u32 << pin;   // LED aus
}

UART Status-Register

@volatile
var UART_SR: ^uint8 := 0x40013800 as ^uint8;

con UART_TXE: uint8 := 0x80u8;   // Transmit Empty Bit

fn WaitTxReady() {
    while (UART_SR^ & UART_TXE = 0) { }  // Busy-Wait – darf nicht wegoptimiert werden
}

@volatile garantiert, dass jeder Lese- und Schreibzugriff exakt in der programmierten Reihenfolge im Maschinencode erscheint.

7. pchar – String als Zeiger

pchar ist ein Alias für ^uint8 (char* in C) und repräsentiert einen nullterminierten ASCII-String. String-Literale in Lyx haben den Typ pchar.

let msg: pchar := "Hallo Lyx";
PrintStr(msg);           // gibt "Hallo Lyx" aus

// Einzelne Zeichen lesen:
var first: uint8 := msg^;          // 'H' = 72
var second: uint8 := (msg + 1)^;   // 'a' = 97  (unsafe!)

Das Lesen einzelner Zeichen per Offset-Arithmetik erfordert einen unsafe-Block (→ Abschnitt 8). Für sicheres String-Handling stehen std.string und std.io zur Verfügung.

Situation Empfehlung
Read-only Ausgabe pchar-Literal direkt an PrintStr / FFI übergeben
Zeichen lesen std.string.CharAt(s, i) statt manueller Arithmetik
String manipulieren std.string-Funktionen; pchar nur für FFI-Grenzen
Ownership übergeben Kommentar, ob Aufrufer oder Aufgerufener free() trägt

8. unsafe – Pointer-Arithmetik

Pointer-Arithmetik (Offset-Addition/Subtraktion) ist in Lyx standardmäßig verboten. Innerhalb eines unsafe-Blocks hebt der Compiler diese Einschränkung auf und erlaubt skalierte Adress-Berechnung.

unit buffer_ops;
import std.io;

fn ReadWord(base: ^uint8, offset: int64): uint32 {
    var result: uint32;
    unsafe {
        var ptr: ^uint8 := base + offset;          // Pointer-Arithmetik
        var p32: ^uint32 := ptr as ^uint32;        // Reinterpret-Cast
        result := p32^;                             // Lese 4 Bytes
    }
    return result;
}

Was ist in unsafe erlaubt?

Operation Beschreibung
ptr + n / ptr - n Addition/Subtraktion um n Bytes (skaliert nach Elementgröße)
ptr as ^OtherType Reinterpret-Cast: gleiche Adresse, anderer Typ
(ptr)^ Dereferenz eines berechneten Zeigers
addr as ^T Integer → Pointer (MMIO, FFI)

Typisches Muster: Byte-Buffer

fn CopyBytes(dst: ^uint8, src: ^uint8, count: int64) {
    var i: int64 := 0;
    while (i < count) {
        unsafe {
            (dst + i)^ := (src + i)^;
        }
        i++;
    }
}

 
Sicherheits-Warnung:
unsafe-Blöcke erscheinen im Compiler-Report (–call-graph) und in der Provenance-Ausgabe (–provenance). In Modulen mit @dal(A) oder @dal(B) erzeugt unsafe standardmäßig einen Compiler-Fehler, sofern keine explizite Ausnahme mit @allow_unsafe dokumentiert ist.

9. Pointer in Safety-Code

Lyx unterscheidet klar zwischen zulässigen und verbotenen Pointer-Operationen in sicherheitskritischen Modulen.

Operation @flight_crit @dal(A) @dal(B/C)
Pointer auf Stack-Variable (^x) ✅ Erlaubt ✅ Erlaubt ✅ Erlaubt
Pointer als Funktionsparameter (^T) ✅ Erlaubt ✅ Erlaubt ✅ Erlaubt
@volatile MMIO-Zugriff ✅ Erlaubt ✅ Erlaubt ✅ Erlaubt
nil-Pointer (mit Prüfung) ✅ Erlaubt ✅ Erlaubt ✅ Erlaubt
new / dispose (Heap) ❌ Verboten ❌ Verboten ⚠️ Warnung
unsafe Pointer-Arithmetik ❌ Compiler-Fehler ❌ Compiler-Fehler ⚠️ Warnung
as-Cast Pointer ↔ int64 ⚠️ Warnung ❌ Compiler-Fehler ⚠️ Warnung

Empfohlenes Muster für @flight_crit

Alle Puffer und Arbeitsspeicher werden in der Init-Phase als Stack-Arrays angelegt und als Pointer weitergereicht. Innerhalb des Regel-Zyklus entstehen keine neuen Pointer und keine Arithmetik.

@flight_crit
@dal(B)
@stack_limit(4096)
unit sensor_ctrl;

fn main(): int64 {
    // Init-Phase: Puffer als Stack-Array
    var samples: [256]f64;
    var buf_ptr: ^f64 := ^samples[0];   // Pointer auf erstes Element – erlaubt

    // Regel-Zyklus: nur sicherer Zugriff
    while (true) {
        var val := ReadSensor();
        buf_ptr^ := val;              // Schreiben via Pointer – erlaubt
        buf_ptr^ := ProcessSample(buf_ptr^);
    }

    return 0;
}

10. @inline – Inlining erzwingen

@inline weist den Compiler an, den Funktionsaufruf durch den Funktionskörper zu ersetzen. Damit entfällt der Aufruf-Overhead (Register-Sicherung, Stack-Frame-Aufbau, Branch).

Wann sinnvoll?

  • Kleine mathematische Hilfsfunktionen (clamp, min, max, Bit-Operationen)
  • Getter/Setter für Felder (Zugriff mit Null-Overhead)
  • Heiße Pfade in Schleifen, die weniger als ~10 Instruktionen erzeugen

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

@inline
fn abs_f64(x: f64): f64 {
    if (x < 0.0) { return -x; }
    return x;
}

fn main(): int64 {
    var speed: int64 := clamp(ReadSensor(), 0, 300);   // kein Funktionsaufruf im Asm
    return 0;
}

Trade-off: Geschwindigkeit vs. Code-Größe

Aspekt Ohne @inline Mit @inline
Aufruf-Overhead Ja (push/pop, call/ret) Nein
Code-Größe Klein (eine Kopie) Wächst pro Aufrufstelle
Instruction-Cache Gut (dichte Loop) Schlechter bei vielen Aufrufstellen
Stack-Tiefe Tiefer (Stack-Frame) Flacher
 
Energy-Level-Interaktion:
Das Energy-Aware-Backend inlinet bei Level 4–5 automatisch kleine Funktionen auch ohne @inline. Bei Level 1–2 wird Inlining bewusst reduziert, um den Instruction-Cache zu schonen. @inline erzwingt Inlining unabhängig vom Energy-Level.

11. @no_opt – Optimierung deaktivieren

@no_opt verhindert jede Optimierung für eine einzelne Funktion. Das ist notwendig, wenn:

  • Timing exakt eingehalten werden muss (Busy-Wait-Schleifen)
  • Der Debugger den Code 1:1 nachvollziehen muss
  • Eine Hardware-Sequenz in genau dieser Reihenfolge ablaufen muss

@no_opt
fn WaitMicros(us: int64) {
    var i: int64 := 0;
    while (i < us * 100) {   // Schleife bleibt erhalten – kein "dead loop" Entfernen
        i++;
    }
}

@no_opt
fn SendByte(b: uint8) {
    while (TX_SR^ & 0x80u8 = 0) { }   // Warte auf "TX empty"
    TX_DATA^ := b;                     // Sende Byte
}

Im Gegensatz zu @volatile (das nur Lade-/Store-Reihenfolge schützt) verhindert @no_opt auch das Umordnen, Fusionieren oder Entfernen beliebiger Operationen.

12. @parallel – SIMD-Vektorisierung

@parallel signalisiert dem Compiler, dass eine Schleife oder ein Array-Block keine Datenabhängigkeiten zwischen den Iterationen besitzt und für SIMD-Vektorisierung geeignet ist (AVX2 auf x86_64, NEON auf ARM64).

unit dsp;

fn NormalizeSignal(samples: [1024]f32, gain: f32) {
    @parallel
    for i := 0 to 1023 do {
        samples[i] := samples[i] * gain;
    }
}

fn DotProduct(a: [512]f64, b: [512]f64): f64 {
    var sum: f64 := 0.0;
    @parallel
    for i := 0 to 511 do {
        sum := sum + a[i] * b[i];
    }
    return sum;
}

Plattform SIMD-Erweiterung Vektorgröße
x86_64 (AVX2) 256-Bit-Register 8× f32 oder 4× f64
ARM64 (NEON) 128-Bit-Register 4× f32 oder 2× f64
RISC-V (V-Extension) Variabel (>=128 Bit) Abhängig vom Hart
 
Voraussetzung:
Der Compiler prüft, ob Schreibzugriffe innerhalb der Schleife unabhängig voneinander sind. Falls Aliasing möglich ist (z. B. src und dst zeigen auf überlappende Bereiche), warnt –lint und verzichtet auf Vektorisierung.

13. @section – ELF-Sektion steuern

@section platziert den Maschinencode einer Funktion oder die Daten einer Variablen in einer benannten ELF-Sektion. Das ist entscheidend für:

  • Embedded-Systeme: Code in schnellem SRAM statt langsamem Flash
  • Bootloader: Init-Code in einer separaten Sektion („.boot“)
  • Interrupt-Handler: ISR-Code muss im direkt erreichbaren Speicher liegen

@section(".fast_ram")
fn IsrUart() {
    // Wird in SRAM geladen – kein Flash-Read-Wait-State
    var byte := UART_DATA^;
    RingBufferPush(byte);
}

@section(".slow_mem")
fn PrintDiagnostics() {
    // Seltener Aufruf – darf im langsamen Flash liegen
    PrintStr("Diagnostics OK");
}

@section(".boot")
fn SystemInit() {
    ClockSetup();
    MemoryInit();
}

Die Sektions-Namen müssen im Linker-Script (*.ld) definiert sein und einer physikalischen Speicherregion zugeordnet werden.

14. Vollständiges Beispiel: Hardware-Treiber mit Pointer & Pragmas

Ein UART-Treiber kombiniert alle beschriebenen Mechanismen:

unit uart_driver;
import std.io;

// ── Hardware-Register (MMIO) ──────────────────────────────────────────────────
@volatile var UART_DR:   ^uint8  := 0x40013800 as ^uint8;   // Data Register
@volatile var UART_SR:   ^uint32 := 0x40013804 as ^uint32;  // Status Register
@volatile var UART_BRR:  ^uint32 := 0x40013808 as ^uint32;  // Baud Rate

con UART_SR_TXE:  uint32 := 0x0080u32;   // Transmit Empty
con UART_SR_RXNE: uint32 := 0x0020u32;   // Receive Not Empty

// ── Inline-Hilfsfunktionen ────────────────────────────────────────────────────
@inline
fn TxReady(): bool {
    return UART_SR^ & UART_SR_TXE != 0;
}

@inline
fn RxReady(): bool {
    return UART_SR^ & UART_SR_RXNE != 0;
}

// ── Busy-Wait (darf nicht wegoptimiert werden) ────────────────────────────────
@no_opt
fn WaitTx() {
    while (!TxReady()) { }
}

// ── Byte senden ───────────────────────────────────────────────────────────────
@section(".fast_ram")
@flight_crit
fn SendByte(b: uint8) {
    WaitTx();
    UART_DR^ := b;
}

// ── String senden ─────────────────────────────────────────────────────────────
fn SendStr(s: pchar) {
    var p: ^uint8 := s as ^uint8;
    while (p^ != 0u8) {
        SendByte(p^);
        unsafe { p := p + 1; }
    }
}

// ── Buffer empfangen ─────────────────────────────────────────────────────────
fn RecvBytes(dst: ^uint8, count: int64): int64 {
    var received: int64 := 0;
    while (received < count) {
        if (RxReady()) {
            unsafe {
                (dst + received)^ := UART_DR^ as uint8;
            }
            received++;
        }
    }
    return received;
}

fn main(): int64 {
    UART_BRR^ := 0x683u32;   // 9600 Baud @ 16 MHz
    SendStr("UART bereit\n");
    return 0;
}

15. Zusammenfassung: Wann was nutzen?

Feature Einsatzgebiet Risiko / Einschränkung
^T Stack-Pointer Struct-Parameter, Output-Werte, Treiber-Register nil-Check notwendig
@volatile Hardware-Register (MMIO), Shared Memory Multicore Keines – verhindert Fehloptimierung
nil-Check Pointer aus externen Quellen, optionale Werte Pflicht vor Dereferenz
unsafe + Arithmetik Byte-Buffer, pchar-Iteration, FFI-Wrapper Verboten in @dal(A/B)
pchar String-Literale, C-FFI-Strings Keine Bounds-Checks – std.string bevorzugen
@inline Kleine Helferfunktionen, heiße Schleifen Code-Größe steigt
@no_opt Busy-Wait, Hardware-Sequenzen, Timing-kritisch Kein Optimierungs-Benefit
@parallel DSP/ML-Schleifen ohne Datenabhängigkeit Aliasing-Check notwendig
@section ISR, Bootloader, SRAM-Placement Linker-Script muss passen

Weiterführende Seiten: