Nebenläufigkeit auf Embedded-Targets & RTOS
Auf Linux und macOS stellt std.thread POSIX-Threads bereit. Auf Bare-Metal-Targets (ARM Cortex-M, RISC-V ohne Betriebssystem) und RTOS-Umgebungen (FreeRTOS auf ESP32) gelten andere Regeln. Dieses Dokument beschreibt die Muster und Werkzeuge, die Lyx für diese Umgebungen bereitstellt.
→ POSIX-Threads (Linux/macOS): Threads & Nebenläufigkeit
→ Hardware-Pointer und @volatile: Pointer & Inlining
→ Safety-Pragmas: Attribute & Pragmas · DO-178C Compliance
1. POSIX-Threads vs. Bare-Metal
| Kriterium | std.thread (POSIX) | Bare-Metal ohne OS | RTOS (FreeRTOS) |
|---|---|---|---|
| Zielplattform | Linux, macOS, BSD | ARM Cortex-M, RISC-V | ESP32, STM32 mit FreeRTOS |
| Lyx-Target | x86_64, arm64 | arm_cm, riscv | xtensa |
| Scheduler | Betriebssystem | Kein Scheduler — manuell | FreeRTOS-Scheduler (preemptiv) |
| Kontextwechsel | OS-Kernel | SysTick-ISR oder kooperativ | FreeRTOS-Tick |
| Mutex / Sync | MutexLock, CondWait | Interrupts sperren | FreeRTOS-Queue, Semaphore |
| Heap-Allokation in ISR | Erlaubt (aber riskant) | Verboten | Verboten |
std.thread nutzbar | ✅ | ✗ (kein pthread) | ✗ (andere ABI) |
Faustregel:std.threadsetzt einen POSIX-kompatiblen Kernel voraus. Auf Bare-Metal und in RTOS-Tasks wird Nebenläufigkeit durch Interrupt-Service-Routinen, kooperative Zustandsautomaten und RTOS-Primitiven umgesetzt.
2. Superloop — kooperatives Multitasking
Das einfachste Nebenläufigkeitsmodell für eingebettete Systeme ohne RTOS ist der Superloop: eine Endlosschleife, die mehrere Aufgaben reihum aufruft. Jede Aufgabe muss schnell enden (nicht blockieren), damit die anderen nicht verhungern.
unit superloop;
import std.io;
// Zustandsvariablen für jede Task
var sensor_ready: bool := false;
var display_dirty: bool := false;
var last_tick: int64 := 0;
// Task 1: Sensor lesen (alle 10 ms)
fn TaskSensor(now_ms: int64) {
if (now_ms - last_tick < 10) { return; }
last_tick := now_ms;
// ... Sensor abfragen ...
sensor_ready := true;
}
// Task 2: Display aktualisieren (wenn Daten neu)
fn TaskDisplay() {
if (sensor_ready = false) { return; }
// ... Display schreiben ...
sensor_ready := false;
display_dirty := false;
}
// Task 3: Watchdog zurücksetzen
@volatile
fn TaskWatchdog(wdt_base: int64) {
var feed: ^uint32 := (wdt_base + 0x04) as ^uint32;
feed^ := 0xFEED0000u32; // WDT-Feed-Sequenz
}
fn main(): int64 {
con WDT_BASE: int64 := 0x6001_5000;
var now: int64 := 0;
while (true) {
now := now + 1; // Vereinfacht — in Praxis: GetTick()
TaskSensor(now);
TaskDisplay();
TaskWatchdog(WDT_BASE);
}
return 0;
}
Tick-Zähler aus dem Hardware-Timer
Für genaues Timing liest der Superloop einen Hardware-Tick-Zähler:
@volatile
fn GetSysTickMs(): int64 {
// ARM Cortex-M: SysTick Current Value Register
con SYST_CVR: ^uint32 := 0xE000E018 as ^uint32;
return SYST_CVR^ as int64;
}
3. ISR — Interrupt Service Routinen
Eine ISR ist eine Funktion, die der Hardware-Interrupt-Controller aufruft, wenn ein Ereignis eintritt (Timer-Tick, UART-Byte empfangen, GPIO-Flanke). Sie läuft außerhalb des normalen Kontrollflusses — asynchron zur Hauptschleife.
ISR in Lyx deklarieren
ISRs werden mit @section in den Interrupt-Vektor platziert. Der Name muss mit dem Linker-Script übereinstimmen:
// ARM Cortex-M: SysTick-Handler
@section(".isr_vector")
@no_opt
fn SysTick_Handler(): void {
// Tick-Zähler erhöhen — atomar durch @no_opt und Single-Writer-Konvention
tick_counter := tick_counter + 1;
}
// ARM Cortex-M: USART2-Empfangs-IRQ
@section(".isr_vector")
@no_opt
fn USART2_IRQHandler(): void {
con USART2_RDR: ^uint8 := 0x4000_4424 as ^uint8;
var byte: uint8 := USART2_RDR^;
RingBufferPush(byte); // Lock-freier Push (ISR schreibt, Main liest)
}
Regeln für ISRs
| Regel | Begründung |
|---|---|
Kein MutexLock / pthread | Mutex kann blockieren — ISR darf nicht blockieren |
Kein dynamischer Speicher (alloc) | Heap-Allokation ist nicht re-entrant ohne OS-Schutz |
Kein PrintStr / I/O | I/O-Funktionen können intern sperren oder langsam sein |
Alle geteilten Variablen als @volatile | Compiler darf Zugriffe sonst wegoptimieren |
| Minimale Logik | ISR soll schnell enden — aufwändige Arbeit in Hauptschleife auslagern |
@no_opt auf die ISR-Funktion | Verhindert, dass Compiler Register-State wegoptimiert |
@volatile auf gemeinsame Variablen
Variablen, die sowohl im Hauptprogramm als auch in einer ISR benutzt werden, müssen @volatile sein — sonst darf der Compiler Caching-Optimierungen anwenden, die den ISR-Schreibzugriff unsichtbar machen:
@volatile var tick_counter: int64 := 0;
@volatile var uart_rx_byte: uint8 := 0;
@volatile var uart_rx_ready: bool := false;
4. ISR ↔ Hauptschleife: Lock-freier Ring Buffer
Der häufigste Weg, Daten von einer ISR an die Hauptschleife zu übergeben, ist ein SPSC-Ring-Buffer (Single Producer, Single Consumer). Die ISR schreibt (Producer), die Hauptschleife liest (Consumer). Weil es genau einen Schreiber und einen Leser gibt, sind keine Mutexe nötig — nur korrekte Speicherreihenfolge.
unit spsc_ring;
// Ring Buffer: ISR schreibt, Hauptschleife liest
con RING_SIZE: int64 := 256;
@volatile var ring_buf: [256]uint8 := [];
@volatile var ring_head: int64 := 0; // Schreibzeiger (ISR)
@volatile var ring_tail: int64 := 0; // Lesezeiger (Hauptschleife)
// Aufruf NUR aus ISR — kein Mutex nötig (Single Writer)
fn RingBufferPush(byte: uint8): bool {
var next: int64 := (ring_head + 1) mod RING_SIZE;
if (next = ring_tail) { return false; } // Voll — Byte verwerfen
ring_buf[ring_head] := byte;
ring_head := next;
return true;
}
// Aufruf NUR aus Hauptschleife — kein Mutex nötig (Single Reader)
fn RingBufferPop(): (uint8, bool) {
if (ring_tail = ring_head) { return (0u8, false); } // Leer
var byte: uint8 := ring_buf[ring_tail];
ring_tail := (ring_tail + 1) mod RING_SIZE;
return (byte, true);
}
// Hauptschleife: verarbeitet empfangene UART-Bytes
fn ProcessIncoming() {
var (byte, ok): (uint8, bool) := RingBufferPop();
while (ok) limit(RING_SIZE) {
// ... Byte verarbeiten ...
var (b2, ok2) := RingBufferPop();
byte := b2;
ok := ok2;
}
}
Invariante:ring_headwird nur von der ISR geschrieben,ring_tailnur von der Hauptschleife. Solange diese Konvention eingehalten wird, ist kein Lock nötig. Würden mehrere ISRs schreiben oder mehrere Consumer lesen, wäre ein kritischer Abschnitt notwendig.
5. Kritische Abschnitte auf Bare-Metal
Wenn eine Race Condition zwischen ISR und Hauptschleife unvermeidbar ist (z.B. mehrteilige Struktur atomar lesen), müssen Interrupts kurzzeitig gesperrt werden. Das ist das Bare-Metal-Äquivalent zum Mutex.
ARM Cortex-M: PRIMASK
Auf ARM Cortex-M sperrt die CPSID i-Instruktion alle maskierbaren Interrupts. Da Lyx keinen eingebauten Assembler-Aufruf für Special-Register bietet, werden kurze Assembler-Stubs als @extern eingebunden:
// Assembler-Stubs (z.B. irq_stubs.s):
// DisableIRQ: CPSID i / BX lr
// EnableIRQ: CPSIE i / BX lr
// GetIPSR: MRS r0, IPSR / BX lr (0 = kein IRQ aktiv)
@extern fn DisableIRQ(): void;
@extern fn EnableIRQ(): void;
@extern fn GetIPSR(): int64; // > 0 wenn in ISR
// Atomares Lesen einer mehrteiligen Struktur
fn ReadSensorAtomic(out_val: ^int64, out_ts: ^int64) {
DisableIRQ();
out_val^ := sensor_value;
out_ts^ := sensor_timestamp;
EnableIRQ();
}
Achtung: Interrupts so kurz wie möglich sperren. Lange kritische Abschnitte erhöhen die Interrupt-Latenz und verletzen WCET-Garantien.
RISC-V: mstatus.MIE
Auf RISC-V (Machine Mode) wird das MIE-Bit im mstatus-Register gelöscht, um Interrupts zu sperren:
// Assembler-Stubs:
// DisableIRQ: CSRC mstatus, 8 / RET (MIE-Bit löschen)
// EnableIRQ: CSRS mstatus, 8 / RET (MIE-Bit setzen)
@extern fn DisableIRQ(): void;
@extern fn EnableIRQ(): void;
6. ARM Cortex-M: SysTick & NVIC
Der SysTick-Timer erzeugt den periodischen System-Tick (typisch 1 ms) — die Zeitbasis für kooperative Scheduler und Timeouts.
SysTick initialisieren
unit arm_cortexm;
// SysTick-Register (ARM Cortex-M, adressiert ab 0xE000E010)
con SYST_CSR: ^uint32 := 0xE000E010 as ^uint32; // Control & Status
con SYST_RVR: ^uint32 := 0xE000E014 as ^uint32; // Reload Value
con SYST_CVR: ^uint32 := 0xE000E018 as ^uint32; // Current Value
// NVIC: Interrupt Set Enable Register (für IRQ 0..31)
con NVIC_ISER0: ^uint32 := 0xE000E100 as ^uint32;
@volatile var sys_tick_ms: int64 := 0;
// SysTick auf 1 ms konfigurieren (72 MHz Takt als Beispiel)
fn SysTickInit(cpu_hz: int64) {
var reload: uint32 := (cpu_hz / 1000 - 1) as uint32;
SYST_RVR^ := reload;
SYST_CVR^ := 0u32; // Zähler zurücksetzen
SYST_CSR^ := 0x07u32; // Enable | TickInt | AHB-Takt
}
// SysTick-Handler (im Interrupt-Vektor)
@section(".isr_vector")
@no_opt
fn SysTick_Handler(): void {
sys_tick_ms := sys_tick_ms + 1;
}
// Aktuellen Tick-Wert lesen (Hauptschleife)
fn GetTickMs(): int64 {
return sys_tick_ms;
}
// Busy-Wait für N Millisekunden
fn DelayMs(n: int64) {
var start: int64 := GetTickMs();
while (GetTickMs() - start < n) limit(100000) { }
}
NVIC-Priorität setzen
// NVIC Interrupt Priority Register (IPR) — 8-Bit-Priorität pro IRQ
// IPR[n] liegt bei 0xE000E400 + n
fn NvicSetPriority(irq: int64, prio: uint8) {
var ipr: ^uint8 := (0xE000E400 + irq) as ^uint8;
ipr^ := prio;
}
// IRQ 37 (USART2 auf STM32F4) aktivieren und Priorität setzen
fn EnableUSART2IRQ() {
NvicSetPriority(37, 0xA0u8); // Priorität 10 (0xA0 bei 4-Bit-Prio)
NVIC_ISER0^ := NVIC_ISER0^ | (1u32 << 5u32); // ISER[37/32] Bit 5
}
7. RISC-V: CLINT Timer-Interrupt
Der CLINT (Core Local Interruptor) auf RISC-V-Mikrocontrollern erzeugt den Machine-Timer-Interrupt (MTI), wenn mtime >= mtimecmp. Er ist das RISC-V-Äquivalent zum SysTick.
unit riscv_timer;
// CLINT-Register-Basis (SiFive E-Series, GD32VF103)
con CLINT_BASE: int64 := 0x0200_0000;
con MTIME: ^int64 := (CLINT_BASE + 0xBFF8) as ^int64;
con MTIMECMP: ^int64 := (CLINT_BASE + 0x4000) as ^int64;
con TICKS_PER_MS: int64 := 32768; // 32.768 kHz CLINT-Takt (typisch)
@volatile var systick_ms: int64 := 0;
// Timer-Interrupt initialisieren: alle 1 ms auslösen
fn TimerInit() {
MTIMECMP^ := MTIME^ + TICKS_PER_MS;
}
// Machine-Timer-Interrupt-Handler
// (Eintrag im Machine-Trap-Vector, je nach Linker-Script)
@section(".mtvec")
@no_opt
fn MachineTimerHandler(): void {
systick_ms := systick_ms + 1;
// Nächsten Interrupt planen
MTIMECMP^ := MTIMECMP^ + TICKS_PER_MS;
}
fn GetTickMs(): int64 {
return systick_ms;
}
8. ESP32 (Xtensa): FreeRTOS via FFI
Der ESP32 läuft mit FreeRTOS und bietet zwei Xtensa LX6-Kerne. Lyx bindet FreeRTOS über @extern-Deklarationen ein.
FreeRTOS-Typen und Tasks
unit esp32_rtos;
import std.io;
// FreeRTOS-Handle-Typen (intern: Pointer auf OS-Strukturen)
type TaskHandle = int64;
type QueueHandle = int64;
type SemHandle = int64;
// FreeRTOS Task-API
@extern fn xTaskCreate(
pvTaskCode: int64, // Funktionszeiger (als int64)
pcName: pchar,
usStackDepth: int64, // Stack-Größe in Words
pvParameters: int64, // Argument (beliebiger Pointer)
uxPriority: int64, // Priorität (0 = niedrigst)
pxCreatedTask: int64 // Rückgabe-Handle (^TaskHandle oder 0)
): int64; // pdPASS = 1 bei Erfolg
@extern fn vTaskDelay(xTicksToDelay: int64): void;
@extern fn vTaskStartScheduler(): void;
@extern fn vTaskDelete(task: TaskHandle): void;
// FreeRTOS Queue-API
@extern fn xQueueCreate(uxQueueLength: int64, uxItemSize: int64): QueueHandle;
@extern fn xQueueSend(queue: QueueHandle, pvItemToQueue: int64, xTicksToWait: int64): int64;
@extern fn xQueueReceive(queue: QueueHandle, pvBuffer: int64, xTicksToWait: int64): int64;
con pdPASS: int64 := 1;
con portMAX_DELAY: int64 := 0xFFFF_FFFF;
Zwei Tasks + Queue
var g_queue: QueueHandle := 0;
// Producer-Task (Kern 0)
fn ProducerTask(arg: int64): void {
var value: int64 := 0;
while (true) {
value := value + 1;
xQueueSend(g_queue, ^value as int64, portMAX_DELAY);
vTaskDelay(100); // 100 Ticks ≈ 100 ms warten
}
}
// Consumer-Task (Kern 1)
fn ConsumerTask(arg: int64): void {
var received: int64 := 0;
while (true) {
var ok: int64 := xQueueReceive(g_queue, ^received as int64, portMAX_DELAY);
if (ok = pdPASS) {
PrintStr("Empfangen: ");
PrintInt(received);
PrintStr("\n");
}
}
}
fn main(): int64 {
g_queue := xQueueCreate(16, 8); // 16 Elemente à 8 Byte
xTaskCreate(ProducerTask as int64, "producer", 2048, 0, 5, 0);
xTaskCreate(ConsumerTask as int64, "consumer", 2048, 0, 5, 0);
vTaskStartScheduler(); // gibt nie zurück
return 0;
}
ESP32 Dual-Core: Pinning
Auf dem ESP32 können Tasks an einen bestimmten Kern gepinnt werden:
// xTaskCreatePinnedToCore: wie xTaskCreate, letztes Argument ist Core-ID (0 oder 1)
@extern fn xTaskCreatePinnedToCore(
pvTaskCode: int64,
pcName: pchar,
usStackDepth: int64,
pvParameters: int64,
uxPriority: int64,
pxCreatedTask: int64,
xCoreID: int64 // 0 = PRO_CPU, 1 = APP_CPU
): int64;
fn main(): int64 {
g_queue := xQueueCreate(32, 8);
xTaskCreatePinnedToCore(ProducerTask as int64, "prod", 2048, 0, 5, 0, 0);
xTaskCreatePinnedToCore(ConsumerTask as int64, "cons", 2048, 0, 5, 0, 1);
vTaskStartScheduler();
return 0;
}
9. Allgemeines RTOS-Muster: Semaphor
Binäre Semaphore sind das RTOS-Äquivalent zur Bedingungsvariable: Eine ISR signalisiert, der Task wartet.
// FreeRTOS Binary Semaphore
@extern fn xSemaphoreCreateBinary(): SemHandle;
@extern fn xSemaphoreGive(sem: SemHandle): int64;
@extern fn xSemaphoreTake(sem: SemHandle, timeout: int64): int64;
// Aus ISR heraus signalisieren (spezielle ISR-Variante!)
@extern fn xSemaphoreGiveFromISR(sem: SemHandle, pxHigherPriorityTaskWoken: int64): int64;
@extern fn portYIELD_FROM_ISR(xHigherPriorityTaskWoken: int64): void;
var g_sem: SemHandle := 0;
// UART-ISR signalisiert Semaphor
@section(".isr_vector")
@no_opt
fn UART_RxISR(): void {
var woken: int64 := 0;
xSemaphoreGiveFromISR(g_sem, ^woken as int64);
portYIELD_FROM_ISR(woken);
}
// Verarbeitungs-Task wartet auf Semaphor
fn UartProcessTask(arg: int64): void {
while (true) {
xSemaphoreTake(g_sem, portMAX_DELAY);
// ... UART-Daten auslesen und verarbeiten ...
}
}
10. Safety & DO-178C auf Embedded-Targets
Stack-Limit pro Task
Jede ISR und jede RTOS-Task hat ihren eigenen Stack. @stack_limit begrenzt den Stack-Verbrauch zur Compile-Zeit:
@stack_limit(512)
@section(".isr_vector")
@no_opt
fn SysTick_Handler(): void {
sys_tick_ms := sys_tick_ms + 1;
}
@stack_limit(2048)
fn ControlTask(arg: int64): void {
// ... max. 2048 Byte Stack ...
}
WCET-Annotation für ISRs
@wcet(10) // Maximale Ausführungszeit: 10 µs
@stack_limit(128)
@section(".isr_vector")
@no_opt
fn ADC_IRQHandler(): void {
// Liest einen Messwert und legt ihn in den Ring Buffer
RingBufferPush(AdcRead());
}
DAL-Tabelle für Embedded-Nebenläufigkeit
| Konstrukt | DAL-D (Minor) | DAL-C (Major) | DAL-B (Gefährlich) | DAL-A (Katastrophal) |
|---|---|---|---|---|
| Superloop | ✅ | ✅ | Timing-Nachweis | Formaler WCET-Beweis |
ISR ohne @wcet | ✅ | Dokumentation | ✗ | ✗ |
ISR mit @wcet | ✅ | ✅ | ✅ | + MC/DC + Lockstep |
| Kritischer Abschnitt | ✅ | + Max-Dauer dok. | + Latenz-Nachweis | + Formal verifiziert |
| FreeRTOS-Task | ✅ | + Stack-Nachweis | @stack_limit zwingend | RTOS selbst DAL-A |
| Shared Memory ISR↔Main | SPSC-Ring | SPSC + @volatile | + Timing-Invarianten | + TMR auf Buffer |
Compile-Flags für Embedded-Safety
// DO-178C DAL-B Build für ARM Cortex-M:
// lyxc main.lyx \
// --target=arm_cm \
// --stack-check \
// --wcet \
// --call-graph \
// --mcdc-instrument \
// --coverage-report \
// -O2
→ Vollständige Flag-Referenz: CLI-Referenz
11. Checkliste: Embedded-Nebenläufigkeit
@volatileauf alle Variablen, die ISR und Hauptschleife teilen@no_optauf jede ISR-Funktion@section(„.isr_vector“)korrekt gesetzt — mit Linker-Script abgeglichen- SPSC-Ring-Buffer: sicherstellen, dass nur ein Schreiber und ein Leser existieren
- Kein
malloc/std.allocin ISRs - Kritische Abschnitte so kurz wie möglich halten
@stack_limitfür alle ISRs und RTOS-Tasks gesetzt@wcetfür zeitkritische ISRs gesetzt und vom Compiler verifiziert- FreeRTOS-Task-Stack-Größe großzügig bemessen (Überläufe sind schwer zu debuggen)
- Watchdog-Reset in der Hauptschleife — nie vergessen
Zusammenfassung
| Szenario | Empfohlenes Muster |
|---|---|
| Linux / macOS, mehrere Threads | std.thread — Threads & Nebenläufigkeit |
| Bare-Metal, eine Aufgabe | Superloop mit Hardware-Tick |
| Bare-Metal, ISR → Hauptschleife | SPSC-Ring-Buffer + @volatile |
| Bare-Metal, kritische Sektion | Interrupts sperren (DisableIRQ/EnableIRQ als @extern-Stubs) |
| ARM Cortex-M, Zeitbasis | SysTick-ISR + sys_tick_ms |
| RISC-V, Zeitbasis | CLINT MTIMECMP + Machine-Timer-Handler |
| ESP32, zwei Tasks | FreeRTOS via FFI + xTaskCreatePinnedToCore |
| ESP32, ISR → Task | FreeRTOS Binary Semaphor + xSemaphoreGiveFromISR |
| Safety-kritisch (DAL-B/A) | @stack_limit + @wcet + @section + SPSC |
Letzte Aktualisierung: 2026-05-22
