Lyx – Memory Management

Lyx hat keinen Garbage Collector. Das ist keine Einschränkung — es ist ein Designziel. GC-Pausen sind nicht-deterministisch: Sie können jederzeit einsetzen, beliebig lange dauern und Echtzeitgarantien zerstören. In eingebetteten Systemen, Flugsteuerungen und Hochlast-Servern ist vorhersagbares Speicherverhalten keine Option, sondern Pflicht.

 
Heap-Arrays tragen seit lyxc 1.0.17C (#1255, #1355). var a := new int64[n]; belegt zur Laufzeit und ist les- und schreibbar; var b: array<int64> := [1,2]; nimmt b.push(3) an, len(b) zählt mit. sizeof auf eine solche Variable liefert weiterhin 8 — sie ist ein Zeiger auf den Kopf, nicht die Nutzlast.

Bis 1.0.16x wies der Parser new int64[size] ab (expected (, got [), und ein Indexzugriff auf ein array<T> schrieb ohne Meldung auf nicht allozierten Speicher.

Weiterhin tragfähig für rohe Puffer: alloc mit poke/peek und free_mem:

var n: int64 := 4;
var b: int64 := alloc(n * 8);
poke64(b + 2*8, 42);
PrintLn(IntToStr(peek64(b + 2*8)));   // 42
free_mem(b);


Für feste Größen bleibt das Stack-Array: var a: [4]int64 := [1,2,3,4];.

Lyx gibt dem Entwickler vollständige Kontrolle über drei Speicherbereiche:

Bereich Allokation Freigabe Einsatz
Stack Automatisch bei Variablendeklaration Automatisch bei Scope-Ende Lokale Variablen, feste Arrays, Structs

Welche Speicherklasse (var, let, co, con) wo hingehört und wie lange eine Variable lebt, steht unter Variablen.

Heap Explizit mit new / malloc Explizit mit dispose / free_mem Daten die Scope-Grenzen überschreiten
Statischer Speicher Zur Compile-Zeit Nie Konstanten, String-Literale, globale Puffer

1. Stack-Allokation

Der Stack ist der schnellste und sicherste Speicherbereich. Jede lokale Variable, jeder feste Array und jeder Struct liegt automatisch auf dem Stack — kein Allokationsaufruf, keine Freigabe.

fn ProcessData(): void {
    var counter:  int64 := 0;          // 8 Byte auf dem Stack
    var ratio:    f64   := 1.5;        // 8 Byte auf dem Stack
    var name:     pchar := "Sensor A"; // Zeiger (8 Byte) auf statischen String

    var readings: f64[64];             // 64 × 8 = 512 Byte auf dem Stack
    var header:   PacketHeader;        // Struct — Größe zur Compile-Zeit bekannt

    // counter, ratio, readings, header werden automatisch freigegeben
    // wenn ProcessData() zurückkehrt
}

Vorteile des Stacks:

  • Allokation = Stapelzeiger-Verschiebung — eine einzige Assembler-Instruktion
  • Keine Fragmentierung
  • Deterministische Freigabe am Scope-Ende
  • WCET-berechenbar — der Speicherverbrauch ist statisch bekannt

Einschränkung: Die Größe muss zur Compile-Zeit bekannt sein. var buf: uint8[n] mit einem Laufzeit-Wert n ist nicht zulässig.

Stack-Tiefe und Scope

Lokale Variablen leben genau so lange wie ihr Scope. Beim Verlassen des inneren Blocks wird der Speicher sofort freigegeben:

fn Example(): int64 {
    var outer: int64 := 10;

    {
        var inner: int64 := 20;   // lebt nur in diesem Block
        outer := outer + inner;
    }
    // inner ist hier nicht mehr zugänglich — Speicher freigegeben

    return outer;   // 30
}


2. Stack-Limit und statische Stack-Analyse

Stack-Overflows sind in eingebetteten Systemen eine der häufigsten Fehlerquellen — und besonders heimtückisch, weil sie oft nur unter Last auftreten. @stack_limit(N) definiert das maximale Stack-Budget einer Funktion inklusive aller ihrer Aufrufe. Der Compiler analysiert den gesamten Aufrufgraphen statisch — bei jeder Übersetzung, ohne Flag. Ein –stack-check gibt es nicht.

@stack_limit(512)
fn FilterSensorData(raw: int64, n: int64): f64 {
    var sum: f64 := 0.0;
    var buf: f64[16];          // 128 Byte — passt ins Budget

    var i: int64 := 0;
    while (i < 16 && i < n) limit(16) {
        buf[i] := (raw + i * 8) as f64;
        sum := sum + buf[i];
        i := i + 1;
    }
    return sum / 16.0;
}

@stack_limit(1024)
fn RunControlCycle(sensors: int64, n: int64): void {
    var filtered: f64 := FilterSensorData(sensors, n);  // nested call
    var result: ControlOutput := ComputeOutput(filtered);
    ApplyControl(result);
}

lyxc flight_ctrl.lyx -o flight_ctrl

Beispielausgabe bei Grenzwert-Verletzung:

[stack-check] RunControlCycle
  direct locals:    320 bytes
  FilterSensorData: 192 bytes  (inkl. dessen Locals)
  ComputeOutput:    256 bytes
  ApplyControl:      88 bytes
  ─────────────────────────────
  worst-case total: 856 bytes / 1024 limit — OK

[stack-check] FilterSensorData
  direct locals:    192 bytes / 512 limit — OK

[stack-check] PASS — alle Limits eingehalten

Wenn ein Limit überschritten wird:

error: function 'RunControlCycle' exceeds @stack_limit:
  worst-case stack: 1280 bytes, limit: 1024 bytes
  call chain: RunControlCycle → ComputeOutput → FFTTransform (576 bytes)


3. Heap-Allokation mit new / dispose

Heap-Speicher überlebt den Scope seiner Allokation. Er wird mit new angefordert und muss mit dispose explizit freigegeben werden.

Einfache Allokation

fn main(): int64 {
    // Einzelnen Wert auf dem Heap: new braucht immer eine Elementzahl
    var ptr: int64 := new int64[1];
    poke64(ptr, 42);                  // Wert schreiben

    PrintLn(IntToStr(peek64(ptr)));   // Wert lesen  -> 42

    dispose ptr;                      // Freigabe — danach ist ptr ungültig
    return 0;
}

 
 
new liefert genullten Speicher — auf jedem Ziel (#1848, seit 1.1.12I). Ein frisch angelegtes Objekt hat in allen Feldern 0; nachgemessen mit 1.1.14A an einer Klasse mit zwei int64-Feldern. Zuvor galt das nur auf einem Teil der Ziele, was Code, der sich darauf verließ, auf dem anderen still falsch machte.

new belegt seit 1.1.12C die Objektgröße statt einer ganzen Seite (#1836). Wer viele kleine Objekte anlegt, merkt den Unterschied im Speicherbedarf.

Dieselbe Ursache hatte StrConcat (#1980, behoben in 1.2.4A): ein roher mmap je Aufruf, mindestens eine Seite, nie freigegeben — 100.000 Verkettungen kosteten 400 MB, jetzt 3 MB. → std.string
Zwei Schreibweisen, die es nicht gibt. new int64 ohne Klammern ist ein Parse-Fehler — für einen Einzelwert also new int64[1] (#1506). Und eine Dereferenz-Zuweisung wie (ptr as int64) := 42 existiert nicht: Lyx führt keinen Zeigertyp, gelesen und geschrieben wird mit peek64/poke64. Frühere Fassungen dieser Seite zeigten beides.

Array auf dem Heap

fn AllocBuffer(size: int64): int64 {
    var buf: int64 := new uint8[size];   // Heap-Array — Größe zur Laufzeit bestimmt
    return buf;
}

fn main(): int64 {
    var n: int64 := 4096;
    var buffer: int64 := AllocBuffer(n);

    // Buffer verwenden...
    FillBuffer(buffer, n, 0);

    dispose buffer;   // Pflicht — kein GC räumt auf
    return 0;
}

Struct auf dem Heap

type Connection = struct {
    fd:       int64;
    buf:      int64;   // Zeiger auf Heap-Puffer
    buf_size: int64;
    connected: bool;
};

fn NewConnection(fd: int64): int64 {
    var conn: int64 := new Connection;
    var c: Connection := conn as Connection;
    c.fd       := fd;
    c.buf      := new uint8[8192];
    c.buf_size := 8192;
    c.connected := true;
    return conn;
}

fn FreeConnection(conn: int64): void {
    var c: Connection := conn as Connection;
    dispose c.buf;   // erst den inneren Puffer freigeben
    dispose conn;    // dann die Struct selbst
}

Reihenfolge bei Freigabe: Immer von innen nach außen freigeben. Erst den Inhalt, dann den Container — sonst Memory Leak.

4. std.alloc — Direkte Heap-Kontrolle

std.alloc gibt direkten Zugriff auf die Heap-Allokations-Primitive — ohne den new/dispose-Syntaxzucker. Das ist die Grundlage aller dynamischen Datenstrukturen in der Standardbibliothek.

import std.alloc;

fn main(): int64 {
    // malloc: Speicher allokieren (unkinitialisiert)
    var ptr: int64 := malloc(1024);
    if (ptr == 0) {
        PrintLn("Out of Memory");
        return 1;
    }

    // calloc: Speicher allokieren und auf 0 setzen
    var zeroed: int64 := calloc(64, 8);   // 64 int64 = 512 Byte, alle 0

    // realloc: Puffer vergrößern
    var bigger: int64 := realloc_mem(zeroed, 2048);
    if (bigger == 0) {
        free_mem(zeroed);   // realloc fehlgeschlagen — Original noch gültig
        return 1;
    }
    zeroed := bigger;

    // Speicher freigeben
    free_mem(ptr);
    free_mem(zeroed);

    return 0;
}

malloc_safe vs. malloc_orpanic

import std.alloc;

fn ProcessLargeDataset(n: int64): void {
    // malloc_safe: gibt 0 zurück bei OOM — Aufrufer entscheidet
    var buf: int64 := malloc_safe(n * 8);
    if (buf == 0) {
        PrintLn("Kein Speicher — Datensatz übersprungen");
        return;
    }
    // ... verarbeiten ...
    free_mem(buf);
}

fn InitSystemBuffers(): void {
    // malloc_orpanic wiederholt den Versuch, bis er gelingt — es kehrt also
    // entweder mit gültigem Zeiger zurück oder gar nicht.
    var ring_buf: int64 := malloc_orpanic(65536);
    // ring_buf ist hier gültig
}

fn InitSystemBuffersMitAbbruch(): void {
    // Sauberer Abbruch statt Warteschleife: selbst prüfen und panic() rufen
    var ring_buf: int64 := malloc(65536);
    if (ring_buf == 0) { panic("kein Speicher fuer den Ringpuffer"); }
}

Funktion Verhalten bei OOM Einsatz
malloc Gibt 0 zurück (kein Abbruch) Wenn OOM behandelt werden soll
malloc_safe Gibt 0 zurück Explizit: macht Null-Prüfung sichtbar
malloc_orpanic Wiederholt den Versuch endlos — kehrt nie mit 0 zurück, hängt aber bei echter Speichernot nur wenn Warten sinnvoll ist; für Pflicht-Ressourcen besser malloc + panic()
calloc(n, size) 0 bei OOM Wenn Nullinitialisierung gewünscht

Alignment

std.alloc garantiert 8-Byte-Alignment für alle Allokationen — ausreichend für int64, f64 und Zeiger. Für SIMD-Operationen (16/32 Byte) muss manuell ausgerichtet werden:

import std.alloc;

fn AllocAligned(size: int64, alignment: int64): int64 {
    // Überschuss allokieren und auf Grenze ausrichten
    var raw: int64 := malloc(size + alignment);
    if (raw == 0) { return 0; }
    var aligned: int64 := (raw + alignment - 1) & -(alignment);
    return aligned;
    // Achtung: raw muss gespeichert werden, um es später korrekt freizugeben
}


5. Fat Pointers — Arrays mit Längeninformation

Dynamische Arrays in Lyx sind Fat Pointers: Ein 24-Byte-Struct aus Basisadresse, aktueller Länge (len) und Kapazität (cap). Der Zeiger zeigt auf den Heap-Puffer, aber Länge und Kapazität werden mitgeführt.

┌─────────────────────────────────────────────┐
│ Fat Pointer (24 Byte auf dem Stack)         │
│  base: int64 ──→ [Heap: Elemente]          │
│  len:  int64     [0][1][2][3]...[n-1]      │
│  cap:  int64                                │
└─────────────────────────────────────────────┘

Das macht Array-Zugriffe sicher: Lyx kann zur Laufzeit prüfen, ob ein Index in [0, len) liegt — ohne dass die Länge separat mitgegeben werden muss.

fn main(): int64 {
    // Statisches Array: kein Fat Pointer — liegt vollständig auf dem Stack
    var stack_arr: int64[8] := [1, 2, 3, 4, 5, 6, 7, 8];

    // Dynamisches Array: Fat Pointer — Heap-Puffer, Länge und Kapazität
    var dyn_arr: int64[];                     // len=0, waechst bei push

    // Elemente hinzufügen (wächst automatisch bei Überschreitung der Kapazität)
    dyn_arr.push(10);
    dyn_arr.push(20);
    dyn_arr.push(30);

    PrintLn(IntToStr(dyn_arr.len));  // 3
    PrintLn(IntToStr(dyn_arr.cap));  // 4

    dyn_arr.push(40);
    dyn_arr.push(50);        // cap überschritten → realloc auf cap=8

    PrintLn(IntToStr(dyn_arr.len));  // 5
    PrintLn(IntToStr(dyn_arr.cap));  // 8 (verdoppelt

    // Freigabe: gibt den Heap-Puffer frei, Fat Pointer selbst ist auf dem Stack
    dispose dyn_arr;

    return 0;
}

Bei @redundant werden die drei Felder des Fat Pointers (base, len, cap) dreifach im RAM abgelegt und bei jedem Zugriff per Mehrheitsentscheid ausgelesen:

@flight_crit
fn TrackBuffer(): void {
    @redundant
    var secure_buf: int64[] := new int64[](16);
    // base, len und cap liegen 3× im RAM
    // Ein Bit-Flip beeinflusst das Ergebnis nicht
    secure_buf.push(42);
    dispose secure_buf;
}


6. Lebensdauer und Dangling Pointers

Da Lyx keinen GC hat, muss der Entwickler Lebensdauer-Regeln einhalten. Der häufigste Fehler: Ein Zeiger auf Speicher, der bereits freigegeben wurde (Dangling Pointer).

fn GetBuffer(): int64 {
    var local_buf: uint8[256];
    return local_buf as int64;   // FEHLER: Zeiger auf Stack-Speicher
    // local_buf wird freigegeben wenn GetBuffer() zurückkehrt
    // Der zurückgegebene Zeiger ist danach ungültig
}

fn GetHeapBuffer(): int64 {
    var heap_buf: int64 := new uint8[256];
    return heap_buf;   // OK: Heap-Speicher überlebt den Scope
    // Aufrufer ist verantwortlich für dispose
}

Ownership-Konvention

Lyx hat kein eingebautes Ownership-System. Die Konvention ist:

  • Wer new aufruft, besitzt den Speicher
  • Wer Besitz übergibt (z.B. als Rückgabewert), kommentiert das klar
  • Funktionen, die einen Zeiger empfangen, geben ihn normalerweise nicht frei
  • Ausnahme: Funktionen mit Free/Close/Destroy im Namen

// Erzeugt Puffer — Aufrufer ist verantwortlich für dispose
fn CreatePacket(size: int64): int64 {
    return new uint8[size];
}

// Verarbeitet Puffer — gibt ihn NICHT frei
fn SendPacket(pkt: int64, len: int64): bool {
    return NetworkSend(pkt, len);
}

// Gibt Puffer frei — Aufrufer darf ihn danach nicht mehr nutzen
fn FreePacket(pkt: int64): void {
    dispose pkt;
}

fn main(): int64 {
    var pkt: int64 := CreatePacket(512);
    FillPacketHeader(pkt);
    SendPacket(pkt, 512);
    FreePacket(pkt);
    // pkt ist jetzt ungültig — kein weiterer Zugriff
    return 0;
}


7. Speicher in Safety-kritischem Code

DO-178C und verwandte Standards stellen klare Anforderungen an Speicherverhalten. Lyx erzwingt die wichtigste Regel mit einem Compiler-Fehler: @flight_crit (Attributes & Pragmas)-Funktionen dürfen kein new enthalten.

@flight_crit
fn ControlLoop(state: AircraftState): ControlOutput {
    var buf: f64[32];   // OK: Stack, Größe zur Compile-Zeit bekannt

    // var dyn: int64 := new uint8[64];   // Compiler-Fehler:
    // "heap allocation in @flight_crit function"

    // Alle Berechnungen auf Stack-Variablen
    var pitch: f64 := ComputePitch(state);
    var roll:  f64 := ComputeRoll(state);
    var _r: ControlOutput;
    _r.pitch := pitch;
    _r.roll := roll;
    return _r;
}

Initialisierungsphase vs. Regelzyklus

Das zulässige Muster für dynamischen Speicher in Safety-Code: Einmalige Allokation beim Start, danach ausschließlich Stack.

// Globale Zeiger auf vorallokierten Speicher
var g_sensor_buf: int64 := 0;
var g_log_buf:    int64 := 0;

con SENSOR_BUF_SIZE: int64 := 4096;
con LOG_BUF_SIZE:    int64 := 65536;

// Initialisierungsphase — new ist erlaubt
fn SystemInit(): bool {
    g_sensor_buf := new uint8[SENSOR_BUF_SIZE];
    if (g_sensor_buf == 0) { return false; }

    g_log_buf := new uint8[LOG_BUF_SIZE];
    if (g_log_buf == 0) {
        dispose g_sensor_buf;
        return false;
    }
    return true;
}

// Regelzyklus — kein new, nur vorallokierte Puffer
@flight_crit
@stack_limit(1024)
fn RunCycle(sensors: int64): void {   // Zeiger auf den Sensorblock
    // g_sensor_buf wurde in SystemInit allokiert — sicherer Zugriff
    CopySensorData(sensors, g_sensor_buf, SENSOR_BUF_SIZE);
    ProcessSensorData(g_sensor_buf, SENSOR_BUF_SIZE);
    LogData(g_log_buf, LOG_BUF_SIZE);
}

fn SystemShutdown(): void {
    dispose g_sensor_buf;
    dispose g_log_buf;
}

fn main(): int64 {
    if (!SystemInit()) {
        PrintLn("Initialisierung fehlgeschlagen");
        return 1;
    }

    var sensors: int64 := ReadSensors();   // Zeiger auf den Sensorblock
    while (true) limit(2147483647) {
        RunCycle(sensors);
        sensors := ReadSensors();
    }

    SystemShutdown();
    return 0;
}


8. Speicher-Diagnose und Analyse

Werkzeuge zur Speicheranalyse und -diagnose:

Stack-Verbrauch messen

# Statische Stack-Analyse über den gesamten Call-Graph
lyxc flight_ctrl.lyx -o flight_ctrl

# Detaillierter Report mit allen Funktionen und Worst-Case-Pfaden
lyxc flight_ctrl.lyx --static-analysis -o flight_ctrl

Heap-Nutzung zur Laufzeit prüfen

lyxc hat kein eingebautes Heap-Tracking für kompilierte Programme. Für Leak-Analyse empfiehlt sich valgrind auf Linux:

lyxc myprogram.lyx --debug -o myprogram_debug
valgrind --leak-check=full ./myprogram_debug

==3842== HEAP SUMMARY:
==3842==     in use at exit: 0 bytes in 0 blocks
==3842==   total heap usage: 142 allocs, 142 frees, 1,048,576 bytes allocated
==3842==
==3842== All heap blocks were freed -- no leaks are possible

Verbleibende Blöcke (definitely lost) zeigen vergessene free()-Aufrufe an. Das –debug-Flag ist für valgrind nicht zwingend, liefert aber Zeilennummern im Leak-Report.


9. Best Practices

Empfehlungen für den produktiven Einsatz:

Situation Empfehlung
Lokale Variablen Immer Stack — keine Ausnahme
Arrays fester Größe Stack: var buf: uint8[256]
Arrays variabler Größe Heap: new uint8[n] mit dispose
Daten über Scope-Grenzen Heap — mit klarer Ownership-Konvention
Pflicht-Ressourcen in Initialisierung malloc + Prüfung + panic()malloc_orpanic wartet stattdessen endlos
Optionale Ressourcen malloc_safe — Aufrufer prüft auf 0
Safety-Code (DAL-A/B) Nur Stack im Regelzyklus, Heap nur in Init
@flight_crit-Funktionen Kein new — Compiler erzwingt das
Stack-Budget nachweisen @stack_limit(N) — wird ohne Flag bei jeder Übersetzung geprüft
TMR für kritische Zustände @redundant auf Stack-Variablen
Speicherlecks aufspüren –debug + valgrind –leak-check=full

std.alloc — Vollständige Funktionsreferenz
Rohspeicher — alloc/free, peek/poke, Byte-Offset-Muster
Handles und Dateideskriptoren — Kernel-fd vs. Userspace-Handle
Aerospace & Safety — Stack-Limit und TMR im Detail

Codebeispiele geprüft: gegen lyxc 1.2.5C übersetzt (Prüflauf 2026-09-08 über die gesamte Doku: 574 Vollprogramme, 0 echte Fehler; zusätzlich 5159 Aufrufe gegen die pub fn-Signaturen in aurum/std gehalten, 0 Abweichungen).