💻 C und Assembler mit Raspberry Pi

Interrupt Teil 2 – Asynchrone Hardware-Interrupts (GIC-400 & Timer) (PI4)

In diesem Kapitel wollen wir erstmals einen echten Hardware-Interrupt (IRQ) auf dem Raspberry Pi 4 verwenden. Während synchrone Exceptions (wie svc #0) direkt durch einen Befehl im Code ausgelöst werden, feuern Hardware-Interrupts völlig asynchron – also zu jedem beliebigen Zeitpunkt im Hintergrund.

Der Raspberry Pi 4 besitzt einen integrierten ARM Generic Timer, den wir so programmieren können, dass er in exakt definierten Zeitabständen einen Interrupt auslöst. Über diesen Timer lassen wir eine kleine Rotor-Animation flüssig auf dem Bildschirm rotieren, während das Hauptprogramm ungestört weiterläuft.

Den GIC-400 (Generic Interrupt Controller) initialisieren

Damit ein Hardware-Signal der Peripherie den CPU-Kern überhaupt erreicht, muss der Interrupt-Controller (beim Pi 4 der GIC-400) konfiguriert werden. Der GIC besteht aus zwei Hauptkomponenten:

  • GICD (Distributor): Nimmt die Interrupts aller Hardware-Einheiten entgegen, verteilt sie und schaltet sie global frei.
  • GICC (CPU Interface): Liegt direkt am jeweiligen CPU-Kern, filtert Interrupts nach Priorität und meldet sie an die CPU.

Dafür erstellen wir die Funktion Interrupt_Initialize():

void Interrupt_Initialize(void)
{
    // 1. VBAR_EL1 setzen (Sicherheitsnetz für den Vektortabellen-Puffer)
    asm volatile ("msr vbar_el1, %0" : : "r" (vectors));
    asm volatile ("isb" ::: "memory");

    // 2. GIC-Distributor & CPU-Interface aktivieren
    write32(GICD_CTLR_ENABLE, GICD_CTLR);
    write32(GICC_PMR_PRIORITY, GICC_PMR);
    write32(GICC_CTLR_ENABLE, GICC_CTLR);

    // 3. IRQ 27 (Virtual Timer / ARM_IRQLOCAL0_CNTPNS) aktivieren
    // (Der Virtual Timer liegt auf PPI 14 -> entspricht im GIC IRQ 27)
    write32((1 << 27), GICD_ISENABLER0);

    // 4. IRQs auf CPU-Ebene freischalten
    irq_enable();
}

Die Aufrufe in Interrupt_Initialize() im Detail:

  • msr vbar_el1, %0 & isb: Stellt sicher, dass das Systemregister VBAR_EL1 definitiv auf unsere Vektortabelle vectors zeigt. Das isb (Instruction Synchronization Barrier) zwingt die CPU, alle nachfolgenden Befehle erst nach dem Update des Registers auszuführen.
  • write32(GICD_CTLR_ENABLE, GICD_CTLR): Schaltet den Distributor global ein (Bit 0 = 1). Ab jetzt verarbeitet der GIC eingehende Hardware-Signale.
  • write32(GICC_PMR_PRIORITY, GICC_PMR): Konfiguriert die Priority Mask des CPU-Interfaces auf 0xF0. Nur Interrupts mit einer höheren (numerisch kleineren) Priorität als 0xF0 werden an die CPU durchgereicht.
  • write32(GICC_CTLR_ENABLE, GICC_CTLR): Aktiviert das CPU-Interface für Core 0 (Bit 0 = 1).
  • write32((1 << 27), GICD_ISENABLER0): Der ARM Virtual Timer ist eine private Interrupt-Quelle (PPI 14) und belegt im GIC-400 die IRQ-Nummer 27. Mit diesem Schreibbefehl setzen wir Bit 27 im Interrupt Set-Enable Register 0 (GICD_ISENABLER0) und erlauben dem GIC, das Timer-Signal durchzulassen.
  • irq_enable(): Dieser Inline-Assembler-Befehl führt msr daifclr, #2 aus. Er entfernt die PSTATE-Interrupt-Sperre der CPU. Erst ab diesem Moment reagiert der ARM-Kern physisch auf eingehende IRQ-Signale!

Vektortabelle anpassen (vector.S)

Unsere Vektortabelle gab bisher alle unerwarteten Ausnahmen an die Diagnose-Routine show_invalid_entry_message weiter und hielt das System an.

Wenn der GIC einen Timer-Interrupt auslöst, springt die CPU hardwareseitig in die Tabelle "Current EL with SPx" auf den Eintrag "IRQ" – das entspricht dem Vektor-Typ 5.

In unserer zentralen Assembler-Routine exception_common_handler bauen wir eine Weiche ein, die Typ 5 abfängt und direkt zu unserem Handler springt:

// --- B. NEON/SIMD Register auf den Stack sichern ---
    ...
    stp     q28, q29, [sp, #256 + 32 * 14]
    stp     q30, q31, [sp, #256 + 32 * 15]

    // --- C. Exception-Typ unterscheiden ---
    cmp     x0, #5                  // Prüfen: Ist der Vektor-Typ == 5 (IRQ)?
    beq     call_irq_handler        // Wenn JA: Direktsprung zum IRQ-Handler!

    // Falls NEIN: Es war eine tödliche Exception -> Diagnosedaten aufbauen & anzeigen
    sub     sp, sp, #64             
    mrs     x1, elr_el1
    ...
    bl      show_invalid_entry_message   // Kommt nicht mehr zurück!

call_irq_handler:
    bl      InterruptHandler        // C-Funktion für Interrupts aufrufen

    // --- D. Register wieder herstellen & eret ---
    ldp     q0,  q1,  [sp, #256 + 32 * 0]
    ...
    eret

Wichtig: Durch den Sprung zu call_irq_handler werden bei einem normalen Timer-Interrupt keine Diagnosedaten wie ESR oder FAR ausgewertet. Nach dem Abarbeiten von InterruptHandler stellt die CPU alle Register lautlos wieder her und setzt das Hauptprogramm mit eret an exakt der unterbrochenen Stelle fort!

Der C-Interrupt-Dispatcher (InterruptHandler)

Sobald die CPU bei Typ 5 in die Funktion InterruptHandler() springt, müssen wir den GIC befragen, wer den Interrupt ausgelöst hat, die passende Routine aufrufen und dem GIC die Bearbeitung bestätigen:

void InterruptHandler(void)
{
    // 1. Abfragen, welcher IRQ gefeuert hat (Acknowledge)
    u32 ack = read32(GICC_IAR);
    u32 irq = ack & GICC_IAR_INTERRUPT_ID__MASK;

    // 2. Routing zum passenden Treiber
    if (irq == 27) // Virtual Timer IRQ
    {
        RotorTimerHandler(NULL); // Rotor-Animation weiterdrehen
    }

    // 3. End of Interrupt (EOI) an den GIC melden
    write32(ack, GICC_EOIR);
}

Die Befehle in InterruptHandler() im Detail:

  • read32(GICC_IAR): Das Auslesen des Interrupt Acknowledge Registers (GICC_IAR) bewirkt zwei Dinge gleichzeitig: Der GIC liefert uns die ID des aktuell anstehenden Interrupts und schaltet diesen intern auf den Status Active.
  • ack & GICC_IAR_INTERRUPT_ID__MASK: Maskiert die untersten 10 Bits heraus, um die reine IRQ-Nummer zu erhalten (Maske 0x3FF).
  • write32(ack, GICC_EOIR): Schreibt den ausgelesenen Quittierungswert unberührt zurück in das End of Interrupt Register (GICC_EOIR). Das ist zwingend erforderlich: Wenn man dem GIC nicht mitteilt, dass die Bearbeitung abgeschlossen ist, blockiert er alle weiteren Interrupts derselben oder niedrigerer Priorität!

Den Timer initialisieren (InitRotorTimer)

Damit der ARM Virtual Timer überhaupt anfängt zu zählen und Impulse an den GIC schickt, konfigurieren wir ihn mit der Funktion InitRotorTimer():

void InitRotorTimer(void)
{
    u64 frq;
    // 1. Quarz-Frequenz der CPU auslesen
    asm volatile ("mrs %0, cntfrq_el0" : "=r" (frq));

    // 2. Intervall auf 100 ms (10 Hz) berechnen und im Timer-Vergleichsregister ablegen
    u64 ticks = frq / 10;
    asm volatile ("msr cntv_tval_el0, %0" :: "r" (ticks));

    // 3. Timer aktivieren & Entmaskieren
    asm volatile ("msr cntv_ctl_el0, %0" :: "r" (1));
}

Die Systemregister im Detail:

  • cntfrq_el0 (Counter Frequency Register): Liefert die Taktrate des Systemzählers in Hz (auf dem Pi 4 meist 54 MHz).
  • cntv_tval_el0 (Virtual Timer TimerValue Register): Ein Countdown-Register. Wir laden es mit frq / 10 (Ticks für 100ms. Sobald der Wert auf 0 heruntergezählt ist, feuert der Timer den IRQ 27.
  • cntv_ctl_el0 (Virtual Timer Control Register):
    • Bit 0 (ENABLE) = 1: Startet den Timer.
    • Bit 1 (IMASK) = 0: Entmaskiert das Interrupt-Signal (Signal wird an den GIC weitergeleitet).
    • Der Wert 1 setzt somit Enable=1 und IMASK=0.

Die Rotor-Animations-Routine (RotorTimerHandler)

Wird der IRQ 27 ausgelöst, ruft InterruptHandler() unsere Funktion RotorTimerHandler() auf:

static const char rotor_chars[] = {'|', '/', '-', '\\'};
static int rotor_index = 0;

void RotorTimerHandler(void *pParam)
{
    // 1. Zeichen für die Animation bestimmen und Index weiterschalten
    char c = rotor_chars[rotor_index];
    rotor_index = (rotor_index + 1) % 4;

    // 2. Zeichen oben rechts auf dem Bildschirm zeichnen (x=1800, y=20)
    DrawChar(c, 1800, 20);

    // 3. Timer für den nächsten Durchlauf neu laden (100 ms)
    u64 frq;
    asm volatile ("mrs %0, cntfrq_el0" : "=r" (frq));
    asm volatile ("msr cntv_tval_el0, %0" :: "r" (frq / 10));
}

Ganz wichtig beim ARM-Timer: Der Timer setzt seinen Interrupt nicht automatisch zurück! Wir müssen in Punkt 3 der Funktion das Register cntv_tval_el0 explizit neu laden. Dadurch wird der anstehende Interrupt gelöscht und der 100-ms-Countdown startet für den nächsten Rotor-Schritt von vorn.

Einbindung in die main()

In unserer main.c schalten wir die Subsysteme nacheinander frei:

void main(void)
{
    // ------------------------------------------------------------------------
    // 1. Hardware & Display Initialization
    // ------------------------------------------------------------------------
    LED_off();
    Init_Screen();
    InitTerminal();

    // Print Welcome Banner
    SetFrontColor(satyr);
    printf("*****************************************************************************************\n");
    printf("***                                                                                   ***\n");
    printf("***                                (c) by Satyria                                     ***\n");
    printf("***                                                                                   ***\n");
    printf("*****************************************************************************************\n");
    SetFrontColor(white);

    Interrupt_Initialize();
    InitRotorTimer();
    printf("Rotor-Animation läuft über Timer-Interrupts!\n");

    // Trigger LED error pattern (2 blinks) as a physical proof-of-life signal
    LED_Error(2);
}

Der optische Beweis:

Wenn du das Programm startest, siehst du das blinkende Error-Muster der Onboard-LED aus LED_Error(2). Völlig unbeeindruckt von dieser Endlosschleife im Hauptprogramm unterbricht der Timer die CPU alle 100ms, dreht den Rotor oben rechts auf dem Bildschirm ein Stück weiter und gibt die Kontrolle nahtlos an das Hauptprogramm zurück.

Das Fundament für ein reaktives Bare-Metal-Betriebssystem steht!