💻 C und Assembler mit Raspberry Pi

Speicherwaltung MMU Teil 1 (PI 4, C)

Was ist die MMU und warum brauchen wir sie?

Die MMU (Memory Management Unit) ist eine Hardware-Einheit in der CPU, die als "Türsteher" zwischen dem Prozessor und dem physischen RAM sitzt. Sie übersetzt virtuelle Adressen (die Dein Programm sieht) in physische Adressen (wo die Daten im RAM-Chip wirklich liegen).

Viele Embedded-Entwickler fragen sich: "Warum den Overhead betreiben? Ich habe doch eh nur ein Programm, das auf dem Bare-Metal läuft!

Es gibt zwei zwingende Gründe, die MMU zu aktivieren, selbst wenn man keine virtuellen Adressen (Identity Mapping) nutzt:

  1. Performance-Boost durch Caches: Die ARM-Architektur erlaubt es nicht, die extrem schnellen L1- und L2-Caches der CPU für Speicherbereiche zu nutzen, wenn die MMU deaktiviert ist. Ohne MMU greift die CPU bei jedem Lesebefehl direkt auf den (langsamen) RAM zu. Erst durch das Aufsetzen von Page-Tables und das Setzen des "Cacheable"-Bits können wir die CPU-Caches aktivieren.
  2. Speicherschutz (Memory Protection): Die MMU erlaubt es uns, Speicherbereiche mit Attributen zu versehen (z. B. Read-Only, Execute-Never). Das verhindert, dass ein Bug im Code (z. B. ein wilder Zeiger) das Betriebssystem oder kritische Hardware-Register überschreibt.

Das ARM64 Page-Table System (64KB Granule)

AArch64 nutzt ein hierarchisches Tabellen-System. Anstatt eine riesige Tabelle für den gesamten Adressraum zu bauen (was Megabytes an RAM verschwenden würde), hangelt sich die MMU durch bis zu 4 Ebenen. Wir betrachten hier das Setup für 64KB Pages (Granule Size) bei einem typischen 48-Bit Adressraum.

Der Aufbau der Übersetzung

Die MMU zerlegt die 48-Bit virtuelle Adresse in Fragmente, um durch die Tabellen zu navigieren:

  • Page Offset (Bits 15:0): 16 Bit = 216 = 64 KB. Das ist der Index innerhalb der finalen Speicherseite.
  • Level 3 Table (Bits 28:16): 13 Bit = 8192 Einträge. Die unterste Ebene. Hier stehen die finalen Page Descriptors, die auf die physischen 64KB-Blöcke im RAM zeigen.
  • Level 2 Table (Bits 41:29): 13 Bit = 8192 Einträge. Diese Ebene zeigt entweder auf eine Level-3-Tabelle oder nutzt sogenannte Block Descriptors. Ein Block Descriptor auf Level 2 kann riesige Bereiche (z. B. 512 MB am Stück) auf einmal mappen, was der MMU viel Arbeit beim "Table Walk" erspart.
  • Level 1 Table (Bits 47:42): 6 Bit = 64 Einträge. Der Startpunkt der Übersetzung.
Virtuelle Adresse (48 Bit)
┌───────────┬──────────────┬──────────────┬──────────────┐
│ Bits 47:42│ Bits 41:29   │ Bits 28:16   │ Bits 15:0    │
│ (6 Bit)   │ (13 Bit)     │ (13 Bit)     │ (16 Bit)     │
└─────┬─────┴──────┬───────┴──────┬───────┴──────┬───────┘
      │            │              │              │
      ▼            ▼              ▼              ▼
  Level 1      Level 2        Level 3       Page Offset
  (64 Einträge) (8192 Einträge)(8192 Einträge) (64 KB)

Der "Table Walk":

  1. Die CPU liest das Register TTBR0_EL1 (Translation Table Base Register), um die physische Adresse der Level-1-Tabelle zu finden.
  2. Sie nutzt die Bits 47:42 der virtuellen Adresse, um den Eintrag in Level 1 zu finden.
  3. Dieser Eintrag enthält die physische Adresse der Level-2-Tabelle.
  4. Sie hangelt sich weiter bis zu Level 3, addiert den Page Offset und hat die finale physische Adresse.

Die Speicher-Kategorien (MAIR_EL1)

Die MMU muss wissen, wie sie auf den Speicher zugreifen darf. Darf sie cachen? Darf sie Lesezugriffe spekulativ ausführen?

Dafür gibt es das MAIR_EL1 (Memory Attribute Indirection Register). Es definiert bis zu 8 Profile (Attr0 bis Attr7). In unseren Page-Tables speichern wir dann nur noch einen 3-Bit-Index (AttrIndx), der auf das passende Profil im MAIR verweist.

ATTRINDX_NORMAL (z.B. 0xFF - Inner/Outer Write-Back Cacheable)

  • Zweck: Normaler RAM für Code (.text), Daten (.data), Heap und Stack.
  • Verhalten: Die CPU darf die L1- und L2-Caches voll ausnutzen. Schreibzugriffe werden im Cache gebündelt (Write-Back) und nur bei Bedarf in den RAM geschrieben.
  • Performance: Maximal. Spekulative Zugriffe und Out-of-Order Execution sind erlaubt.

ATTRINDX_DEVICE (z.B. 0x00 - Device-nGnRnE)

  • Zweck: Memory-Mapped I/O (MMIO). Also Hardware-Register wie der GIC (Interrupt Controller), UART, GPIO oder Timer.
  • Verhalten:
    • Uncached: Jeder Lese-/Schreibzugriff geht zwingend auf den Hardware-Bus.
    • Strongly Ordered (Strikt geordnet): Die CPU darf die Reihenfolge der Zugriffe nicht verändern. Ein Write an GPIO-Pin A muss physisch auf dem Bus ankommen, bevor der Write an Pin B gesendet wird.
    • No Speculative Reads: Die CPU darf nicht "auf Verdacht" lesen. Bei Hardware-Registern kann ein bloßer Lesebefehl Nebenwirkungen haben (z.B. das Löschen eines Interrupt-Status-Registers durch "Read-to-Clear").

ATTRINDX_COHERENT / Non-Cacheable (z.B. 0x44)

  • Zweck: Geteilter Speicher für DMA-Hardware (Mailbox, USB xHCI, SD-Card-Controller).
  • Das Problem: Wenn die CPU Daten für den DMA-Controller in ihren L1-Cache schreibt, landen diese nicht im physischen RAM. Der DMA-Controller (der nur den RAM sieht) liest veraltete Daten -> Datenkorruption!
  • Verhalten: Der Bereich wird als Non-Cacheable markiert. CPU-Zugriffe umgehen die Caches und schreiben direkt in den RAM.
  • Wichtig: Selbst mit diesem Attribut muss der Programmierer oft explizite Memory Barriers (DSB, DMB Instruktionen) setzen, um sicherzustellen, dass alle Writes den RAM verlassen haben, bevor man dem DMA-Controller den Start-Befehl gibt.
Aufgabe Ohne MMU Mit MMU
Adressübersetzung CPU greift direkt auf physische Adresse zu Virtuelle -> Physische Adresse
Cache-Aktivierung L1/L2-Caches bleiben deaktiviert -> extrem langsam Speicherbereiche als "cacheable" markiert -> voller Speed. 
Speicherschutz Jeder Zugriff möglich PXN, AP-Bits, Attribut-Kontrolle

Der Execute-Never-Schutz (PXN / UXN)

In unserem Linker-Script markiert das Symbol _etext das Ende des kompilierten Maschinencodes. Alles, was dahinter im Speicher liegt, sind Daten (.data, .bss, der Heap aus Ihrem letzten Kapitel, der Stack).

Hier greift ein fundamentales Sicherheitsprinzip der modernen Informatik: W^X (Write XOR Execute).

Speicher sollte entweder beschreibbar (Heap/Stack) ODER ausführbar (Code) sein, niemals beides gleichzeitig.

In einem gehärteten OS würde man .text zusätzlich auf Read-Only setzen

Was ist PXN?

In den ARM64 Page-Descriptors gibt es das Bit PXN (Privileged Execute Never).

Wenn wir für alle Speicherbereiche hinter _etext das Bit PXN = 1 setzen, verbieten wir der CPU, dort Code auszuführen. Da in cpacr_el1 (Bits FPEN) Floating-Point/SIMD freigeschaltet wurden und dein C-Compiler für Optimierungen NEON-Register nutzt, sollte kurz erwähnt werden, dass UXN = 1 (User Execute Never) zusätzlich gesetzt wird, um EL0-Zugriffe komplett zu unterbinden.

Der Effekt: Versucht ein Programm (oder ein Angreifer), Befehle vom Heap oder Stack auszuführen, blockiert die MMU den Zugriff und wirft sofort eine Permission Fault Exception.

Warum ist das so wichtig?

Stellen Dir vor, Unser Heap (aus dem alloc-Code) wäre als "ausführbar" markiert.

Ein Angreifer findet einen Buffer-Overflow in Ihrem Code.

Er schreibt eigenen Schadcode (Shellcode) auf den Heap.

Er manipuliert den Return-Address auf dem Stack so, dass er auf den Heap zeigt.

Die CPU springt auf den Heap und führt den Schadcode mit vollen Kernel-Rechten (EL1) aus.

Durch das Setzen von PXN=1 im Page-Table-Descriptor für den Heap wird dieser Angriffsvektor (Code Injection) auf Hardware-Ebene komplett unmöglich gemacht. Die MMU fängt den Versuch ab, noch bevor die CPU den ersten bösartigen Befehl decodieren kann.

Von der Theorie zur Praxis: Die Page-Tables im RAM aufbauen

Genug Theorie – jetzt bauen wir die Tabellen tatsächlich im Speicher auf. Bevor wir die MMU in Teil 3 einschalten, müssen die Page-Tables vollständig und korrekt im RAM liegen. In diesem Kapitel erstellen wir die Datenstrukturen, befüllen sie mit den richtigen Attributen und verifizieren das Ergebnis per printf().

Die MMU ist dabei noch "ausgeschaltet". Wir arbeiten also weiterhin mit physischen Adressen – aber die Tabellen selbst sind bereits für den späteren Identity-Mapping-Betrieb vorbereitet (VA == PA).

Die Descriptor-Strukturen: Bits statt Bytes

Jeder Eintrag in einer Page-Table ist exakt 64 Bit (8 Bytes) groß. Die ARM-Architektur definiert genau, welches Bit welche Bedeutung hat. In C bilden wir das mit sogenannten "Bitfield-Structs" ab.

Level 3 Page Descriptor – der wichtigste Baustein

Dieser Descriptor beschreibt eine einzelne 64KB-Seite:

typedef struct PACKED TARMV8MMU_LEVEL3_PAGE_DESCRIPTOR
{
    u64 Value11       : 2,   // Muss 0b11 sein → gültiger Page-Descriptor
        AttrIndx      : 3,   // Index ins MAIR_EL1 (0=NORMAL, 1=DEVICE, 2=COHERENT)
        NS            : 1,   // Non-Secure (0 = Secure, wir bleiben bei 0)
        AP            : 2,   // Access Permission (0 = Read/Write nur EL1)
        SH            : 2,   // Shareability (3=Inner, 2=Outer, 0=Non)
        AF            : 1,   // Access Flag (MUSS 1 sein, sonst Fault!)
        nG            : 1,   // not-Global (0 = global, für TLB relevant)
        Reserved0_1   : 4,   // Muss 0 sein
        OutputAddress : 32,  // Physische Adresse [47:16]
        Reserved0_2   : 4,   // Muss 0 sein
        Contiguous    : 1,   // Contiguous-Hint (0 = nein)
        PXN           : 1,   // Privileged Execute Never
        UXN           : 1,   // User Execute Never
        Ignored       : 9;   // Wird von der Hardware ignoriert
} TARMV8MMU_LEVEL3_PAGE_DESCRIPTOR;

Wichtig: Das Feld OutputAddress enthält nur die Bits [47:16] der physischen Adresse. Die unteren 16 Bits sind immer Null, weil
eine 64KB-Seite per Definition an einer 64KB-Grenze beginnt. Das Makro ARMV8MMUL3PAGEADDR(addr) erledigt die Umrechnung:

#define ARMV8MMUL3PAGEADDR(addr) (((addr) >> 16) & 0xFFFFFFFF)

Level 2 Table Descriptor – der Verweis nach unten

Ein Level-2-Eintrag zeigt entweder auf eine Level-3-Tabelle (Table Descriptor) oder mappt einen großen Block direkt
(Block Descriptor). Für unser Tutorial nutzen wir Table Descriptors:

typedef struct PACKED TARMV8MMU_LEVEL2_TABLE_DESCRIPTOR
{
    u64 Value11       : 2,   // Muss 0b11 sein → Table-Descriptor
        Ignored1      : 14,  // Auf 0 setzen
        TableAddress  : 32,  // Physische Adresse der L3-Tabelle [47:16]
        Reserved0     : 4,   // Muss 0 sein
        Ignored2      : 7,   // Auf 0 setzen
        PXNTable      : 1,   // PXN für gesamte Sub-Tabelle
        UXNTable      : 1,   // UXN für gesamte Sub-Tabelle
        APTable       : 2,   // Zugriffsrechte für Sub-Tabelle
        NSTable       : 1;   // Non-Secure für Sub-Tabelle
} TARMV8MMU_LEVEL2_TABLE_DESCRIPTOR;

Die Union: Ein Descriptor kann alles sein

Die MMU schaut sich nur die untersten 2 Bits an, um den Typ zu erkennen. Eine Union erlaubt uns den typsicheren Zugriff:

typedef union PACKED TARMV8MMU_LEVEL3_DESCRIPTOR
{
    TARMV8MMU_LEVEL3_PAGE_DESCRIPTOR    Page;
    TARMV8MMU_LEVEL3_INVALID_DESCRIPTOR Invalid;
} TARMV8MMU_LEVEL3_DESCRIPTOR;

typedef union PACKED TARMV8MMU_LEVEL2_DESCRIPTOR
{
    TARMV8MMU_LEVEL2_TABLE_DESCRIPTOR   Table;
    TARMV8MMU_LEVEL2_BLOCK_DESCRIPTOR   Block;
    TARMV8MMU_LEVEL2_INVALID_DESCRIPTOR Invalid;
} TARMV8MMU_LEVEL2_DESCRIPTOR;
Bits [1:0] Bedeutung
0b00 oder 0b10 Invalid – Zugriff löst Fault aus
0b01 Block Descriptor (nur Level 1/2)
0b11 Table Descriptor (L1/L2) oder Page Descriptor (L3)

TranslationTable_CreateLevel3Table():

8192 Seiten konfigurieren:

Diese Funktion ist das Herzstück. Sie alloziert eine komplette 64KB-Seite (Platz für 8192 Descriptoren à 8 Bytes) und befüllt
jeden Eintrag basierend auf der Adresse, die er beschreibt.

static TARMV8MMU_LEVEL3_DESCRIPTOR* TranslationTable_CreateLevel3Table(TranslationTable* pMasterTable, uintptr nBaseAddress)
{
    TARMV8MMU_LEVEL3_DESCRIPTOR* pTable = (TARMV8MMU_LEVEL3_DESCRIPTOR*)palloc();

    // Dynamische Adressen für den Coherent-Bereich
    uintptr coherent_start = (uintptr)&__coherent_start;
    uintptr coherent_end   = (uintptr)&__coherent_end;

    for (unsigned nPage = 0; nPage < ARMV8MMU_TABLE_ENTRIES; nPage++)    // 8192 entries à 64KB
    {
        TARMV8MMU_LEVEL3_PAGE_DESCRIPTOR *pDesc = &pTable[nPage].Page;

        // Default: Normaler, gecachter RAM
        pDesc->Value11       = 3;                          // 0b11 = Gültige Page
        pDesc->AttrIndx      = ATTRINDX_NORMAL;            // Cacheable
        pDesc->NS            = 0;                          
        pDesc->AP            = ATTRIB_AP_RW_EL1;           // Read/Write nur EL1
        pDesc->SH            = ATTRIB_SH_INNER_SHAREABLE;  
        pDesc->AF            = 1;                          // Access Flag MUSS 1 sein!
        pDesc->nG            = 0;                          
        pDesc->Reserved0_1   = 0;
        pDesc->OutputAddress = ARMV8MMUL3PAGEADDR(nBaseAddress);
        pDesc->Reserved0_2   = 0;
        pDesc->Contiguous    = 0;
        pDesc->PXN           = 0;                          // Code ausführbar
        pDesc->UXN           = 1;                          
        pDesc->Ignored       = 0;

        // Sonderfälle ab _etext
        if (nBaseAddress >= (u64)&_etext)
        {
            pDesc->PXN = 1; // Kein Code-Ausführung im Daten/Heap/MMIO-Bereich!

            if (   (   nBaseAddress >= pMasterTable->nMemSize
                    && nBaseAddress < HIGHMEM_START)
                || nBaseAddress > HIGHMEM_END)
            {
                // MMIO / Peripherie (GIC, GPIO etc.)
                pDesc->AttrIndx = ATTRINDX_DEVICE;
                pDesc->SH    = ATTRIB_SH_OUTER_SHAREABLE;
            }
            else if (   nBaseAddress >= coherent_start
                     && nBaseAddress <  coherent_end)
            {
                // DMA-Bereich
                pDesc->AttrIndx = ATTRINDX_COHERENT;
                pDesc->SH    = ATTRIB_SH_OUTER_SHAREABLE;
            }
        }

        nBaseAddress += ARMV8MMU_LEVEL3_PAGE_SIZE;
    }

    return pTable;
}

Die Entscheidungslogik als Flussdiagramm

Für jede 64KB-Seite (nBaseAddress):
│
├─ Adresse < _etext?
│  └─ JA → NORMAL, PXN=0 (Code darf ausgeführt werden)
│
└─ Adresse >= _etext?
   ├─ PXN = 1 (Execute-Never: kein Code hier!)
   │
   ├─ Adresse außerhalb des gültigen RAM?
   │  (< nMemSize oder > HIGHMEM_END)
   │  └─ JA → DEVICE (MMIO-Register)
   │
   ├─ Adresse in [__coherent_start .. __coherent_end)?
   │  └─ JA → COHERENT (DMA-Bereich)
   │
   └─ Sonst → NORMAL (Heap, Stack, Daten)

Beim Raspberry Pi 4 liegen die MMIO-Peripherie-Register (GPIO, GIC, PCIe) physisch oberhalb der echten RAM-Grenze (also ab 1 GB aufwärts bei Modellen mit 1 GB RAM, bzw. ab 0xFE000000). Deshalb werden alle Adressen oberhalb der physischen RAM-Größe automatisch als ATTRINDX_DEVICE eingestuft!

Warum AF = 1? Das Access-Flag-Bit muss zwingend gesetzt werden. Vergisst man das, wirft die MMU beim ersten Zugriff einen Access-Flag-Fault – ein klassischer Anfängerfehler, der schwer zu debuggen ist, weil die Fehlermeldung nicht intuitiv auf ein fehlendes Bit hinweist.

TranslationTable_Create(): Die Master-Tabelle aufbauen

Die Level-2-Tabelle ist der Einstiegspunkt für die MMU. Jeder ihrer Einträge verweist auf eine Level-3-Tabelle, die dann die eigentlichen 64KB-Seiten beschreibt.

void TranslationTable_Create(TranslationTable* pMasterTable, size_t nMemSize)
{
    pMasterTable->nMemSize = nMemSize;
    pMasterTable->pTable = (TARMV8MMU_LEVEL2_DESCRIPTOR *) palloc ();

    memset (pMasterTable->pTable, 0, PAGE_SIZE);

    for (unsigned nEntry = 0; nEntry < LEVEL2_TABLE_ENTRIES; nEntry++)    // 128 entries à 512MB = 64 GB
    {
        u64 nBaseAddress = (u64) nEntry * ARMV8MMU_TABLE_ENTRIES * ARMV8MMU_LEVEL3_PAGE_SIZE;

        // WICHTIG: Auch der Bereich um 0xFC000000 bis 0xFFFFFFFF (BCM2711 Peripherie/GPIO/GIC) 
        // muss gemappt werden! Wir überspringen erst jenseits von 4 GB (außer PCIe).
        if (   nBaseAddress >= 4*GIGABYTE
            && !(   MEM_PCIE_RANGE_START_VIRTUAL <= nBaseAddress
             && nBaseAddress <= MEM_PCIE_RANGE_END_VIRTUAL))
        {
            continue;  
        }

        // Level-3-Tabelle für diesen 512-MB-Block erzeugen
        TARMV8MMU_LEVEL3_DESCRIPTOR* pTable = TranslationTable_CreateLevel3Table(pMasterTable, nBaseAddress);

        // Level-2-Eintrag konfigurieren
        TARMV8MMU_LEVEL2_TABLE_DESCRIPTOR *pDesc = &pMasterTable->pTable[nEntry].Table;

        pDesc->Value11        = 3; // 0b11 = Table Descriptor
        pDesc->Ignored1        = 0;
        pDesc->TableAddress = ARMV8MMUL2TABLEADDR ((u64) pTable);
        pDesc->Reserved0    = 0;
        pDesc->Ignored2        = 0;
        pDesc->PXNTable        = 0;
        pDesc->UXNTable        = 0;
        pDesc->APTable        = AP_TABLE_ALL_ACCESS;
        pDesc->NSTable        = 0;
    }

    DataSyncBarrier ();  
}

Warum nur 128 Level-2-Einträge?

Rein rechnerisch könnte eine Level-2-Tabelle 8192 Einträge haben und damit 8192 × 512 MB = 4 TB abdecken. Der Raspberry Pi 4 hat aber maximal 8 GB RAM. Mit LEVEL2_TABLE_ENTRIES = 128 decken wir 128 × 512 MB = 64 GB ab – mehr als genug, und wir sparen uns das Allozieren von Level-3-Tabellen für Bereiche, die nie genutzt werden.

LEVEL2_TABLE_ENTRIES = 128
× 8192 Level-3-Einträge pro L2-Eintrag
× 64 KB pro Level-3-Eintrag
= 128 × 512 MB = 64 GB abgedeckter Adressraum

Der DataSyncBarrier am Ende

DataSyncBarrier();  // → asm volatile ("dsb sy" ::: "memory")

Diese Barriere stellt sicher, dass alle vorherigen Schreibzugriffe die CPU-Caches verlassen haben und im physischen RAM angekommen sind, bevor wir in Teil 3 die MMU einschalten. Ohne diese Barriere könnte die MMU beim ersten Table Walk auf veraltete (noch im Cache liegende) Daten zugreifen – mit fatalem Ergebnis.

Erfolgserlebnis: Die Page-Tables verifizieren

Die Tabellen stehen jetzt im RAM. Die MMU ist noch ausgeschaltet. Bevor wir in Teil 3 den „Schalter umlegen", prüfen wir per printf(), ob alles korrekt aufgebaut wurde.

void TranslationTable_Verify(TranslationTable* pTable)
{
    printf("=== Page-Table Verifikation ===\n\n");

    uintptr coherent_start = (uintptr)&__coherent_start;
    uintptr heap_start     = (uintptr)&__heap_start;

    struct {
        uintptr addr;
        const char* name;
        u8 expected_attr;
        u8 expected_pxn;
    } tests[] = {
        { 0x80000,                    "Code (.text)     ", ATTRINDX_NORMAL,   0 },
        { heap_start,                 "Daten (Heap)     ", ATTRINDX_NORMAL,   1 },
        { coherent_start,             "Coherent (DMA)   ", ATTRINDX_COHERENT, 1 },
        { GPIO_BASE,                  "GPIO (MMIO)      ", ATTRINDX_DEVICE,   1 },
        { GICD_BASE,                  "GIC Distributor  ", ATTRINDX_DEVICE,   1 },
    };

    for (int i = 0; i < 5; i++)
    {
        // 1. Level-2-Index berechnen (Bits 41:29 → 512 MB Blöcke)
        unsigned nL2 = (tests[i].addr >> 29) & 0x1FFF;

        u64* pL2RawArray = (u64*)pTable->pTable;
        u64 l2_entry_raw = pL2RawArray[nL2];

        // Prüfen, ob L2-Eintrag gültig ist (Bits [1:0] müssen 3 sein)
        if ((l2_entry_raw & 0x3) != 3)
        {
            printf("  [FEHLER] %s: L2-Eintrag %d fuer Addr=0x%lX ist INVALID! (raw: 0x%lX)\n",
                   tests[i].name, nL2, tests[i].addr, l2_entry_raw);
            continue;
        }

        // 2. Level-3-Basisadresse aus den Bits [47:16] des L2-Descriptors extrahieren
        uintptr l3_addr = (uintptr)(l2_entry_raw & 0x0000FFFFFFFF0000ULL);

        u64* pL3RawArray = (u64*)l3_addr;

        // 3. Level-3-Index berechnen (Bits 28:16 → 64 KB Seiten)
        unsigned nL3 = (tests[i].addr >> 16) & 0x1FFF;

        u64 l3_entry_raw = pL3RawArray[nL3];

        // Validitäts-Check (Bits [1:0] müssen 3 sein)
        if ((l3_entry_raw & 0x3) != 3)
        {
            printf("  [FEHLER] %s: L3-Page-Eintrag %d ist INVALID!\n", tests[i].name, nL3);
            continue;
        }

        // 4. Reine Bit-Operationen auf 64-Bit Raw-Wert (verhindert Alignment Faults!):
        // AttrIndx = Bits [4:2]
        u8 actual_attr = (u8)((l3_entry_raw >> 2) & 0x7);
        
        // PXN = Bit 53
        u8 actual_pxn  = (u8)((l3_entry_raw >> 53) & 0x1);
        
        // OutputAddress = Bits [47:16]
        u64 actual_out_addr = l3_entry_raw & 0x0000FFFFFFFF0000ULL;
        u64 expected_out_addr = tests[i].addr & ~0xFFFFULL;

        boolean ok_attr = (actual_attr == tests[i].expected_attr);
        boolean ok_pxn  = (actual_pxn == tests[i].expected_pxn);
        boolean ok_addr = (actual_out_addr == expected_out_addr);

        printf("  %s: Attr=%d PXN=%d Addr=0x%012lX -> %s\n",
               tests[i].name,
               actual_attr,
               actual_pxn,
               actual_out_addr,
               (ok_attr && ok_pxn && ok_addr) ? "OK" : "FEHLER!");
    }

    printf("\n=== Verifikation abgeschlossen ===\n");
}

Erwartete Ausgabe

=== Page-Table Verifikation ===

   Code (.text)    : Attr=0 PXN=0 Addr=0x000000080000 → OK
   Daten (.rodata) : Attr=0 PXN=1 Addr=0x000000490000 → OK
   Coherent (DMA)  : Attr=2 PXN=1 Addr=0x000000090000 → OK
   GPIO (MMIO)     : Attr=1 PXN=1 Addr=0x0000FE200000 → OK
   GIC Distributor : Attr=1 PXN=1 Addr=0x0000FF841000 → OK

=== Verifikation abgeschlossen ===

Der Aha-Moment: Die Tabellen existieren im RAM, die Adressen stimmen, die Attribute sind korrekt – aber die MMU ist noch aus. Wir haben die Landkarte gezeichnet. Im nächsten Kapitel legen wir den Schalter um.

Den Sourcecode bekommst du hier: https://www.satyria.de/arm/sources/RPI4/C/speicher2.zip

Zusammenfassung: Was wir in Teil 2 erreicht haben

  • MMU-Aufgabe verstanden (Cache, Schutz, Übersetzung)
  • 48-Bit-Adresse in Tabellen-Indizes aufgeteilt
  • AttrIndx, PXN, AP, SH, AF erklärt
  • Descriptor-Bitfields in C definiert
  • Level-3-Tabelle mit 8192 Einträgen erzeugt
  • Level-2-Tabelle mit 128 Einträgen erzeugt
  • Linker-Symbole (_etext, __coherent_start) genutzt
  • Ergebnis per printf() verifiziert

Ausblick: Teil 3 – Die MMU einschalten

Im nächsten Kapitel konfigurieren wir die vier entscheidenden
Systemregister und schalten die MMU ein:

Register Aufgabe
MAIR_EL1 Speicherattribute definieren (Normal, Device, Coherent)
TCR_EL1 Übersetzungsparameter festlegen (Granule, Adressbreite)
TTBR0_EL1 Basisadresse der Level-2-Tabelle setzen
SCTLR_EL1 MMU aktivieren (M-Bit), Caches einschalten (C/I-Bits)

Nach Teil 3 läuft unser Kernel im gecachten MMU-Modus –
mit vollem Speicherschutz und maximaler Performance.