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.
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 |
| 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. Mit –stack-check analysiert der Compiler den gesamten Call-Graph statisch.
@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 --stack-check -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
var ptr: int64 := new int64;
(ptr as int64) := 42; // Wert schreiben
PrintInt(ptr as int64); // Wert lesen
PrintStr("\n");
dispose ptr; // Freigabe — danach ist ptr ungültig
return 0;
}
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) {
PrintStr("Out of Memory\n");
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) {
PrintStr("Kein Speicher — Datensatz übersprungen\n");
return;
}
// ... verarbeiten ...
free_mem(buf);
}
fn InitSystemBuffers(): void {
// malloc_orpanic: bricht das Programm ab bei OOM — für Pflicht-Ressourcen
var ring_buf: int64 := malloc_orpanic(65536);
// Falls OOM: Programm endet mit Fehlercode — kein weiterer Code wird ausgeführt
// ring_buf ist hier garantiert gültig
}
| 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 | Beendet Programm mit Fehlercode | Pflicht-Ressourcen in der Initialisierung |
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[] := new int64[](4); // len=0, cap=4, Heap allokiert
// Elemente hinzufügen (wächst automatisch bei Überschreitung der Kapazität)
dyn_arr.push(10);
dyn_arr.push(20);
dyn_arr.push(30);
PrintInt(dyn_arr.len); // 3
PrintInt(dyn_arr.cap); // 4
dyn_arr.push(40);
dyn_arr.push(50); // cap überschritten → realloc auf cap=8
PrintInt(dyn_arr.len); // 5
PrintInt(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
newaufruft, 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/Destroyim 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-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);
return ControlOutput { pitch: pitch, roll: roll };
}
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: SensorArray): void {
// 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()) {
PrintStr("Initialisierung fehlgeschlagen\n");
return 1;
}
var sensors: SensorArray := ReadSensors();
while (true) limit(2147483647) {
RunCycle(sensors);
sensors := ReadSensors();
}
SystemShutdown();
return 0;
}
8. Speicher-Diagnose und Analyse
Stack-Verbrauch messen
# Statische Stack-Analyse über den gesamten Call-Graph
lyxc flight_ctrl.lyx --stack-check
# Detaillierter Report mit allen Funktionen und Worst-Case-Pfaden
lyxc flight_ctrl.lyx --stack-check --verbose
Heap-Nutzung zur Laufzeit prüfen
Der Debug-Build des Compilers (mit -gh) verfolgt alle Heap-Allokationen und gibt am Programmende eine Zusammenfassung aus:
lyxc-debug myprogram.lyx -o myprogram_debug
./myprogram_debug
=== Heap Summary ===
Total allocated: 1 048 576 bytes
Total freed: 1 048 576 bytes
Peak usage: 131 072 bytes
Remaining (leaks): 0 bytes
Allocation count: 142
Ein verbleibender Restbetrag (Leaks > 0) zeigt nicht freigegebenen Speicher an.
9. Best Practices
| 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_orpanic — Programm endet bei OOM |
| 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) + –stack-check |
| TMR für kritische Zustände | @redundant auf Stack-Variablen |
| Speicherlecks aufspüren | Debug-Build mit -gh |
→ std.alloc — Vollständige Funktionsreferenz
→ Aerospace & Safety — Stack-Limit und TMR im Detail
