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:
- 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.
- 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) |
+-----+-----+------+-------+------+-------+------+-------+
| | | |
v v v v
Level 1 Level 2 Level 3 Page Offset
(64 Einträge) (8192 Einträge)(8192 Einträge) (64 KB)
Der "Table Walk":
- Die CPU liest das Register
TTBR0_EL1(Translation Table Base Register), um die physische Adresse der Level-1-Tabelle zu finden. - Sie nutzt die Bits 47:42 der virtuellen Adresse, um den Eintrag in Level 1 zu finden.
- Dieser Eintrag enthält die physische Adresse der Level-2-Tabelle.
- 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.
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 liest veraltete Daten → Datenkorruption!
- Verhalten: Der Bereich wird als Non-Cacheable markiert. CPU-Zugriffe umgehen die Caches und schreiben direkt in den RAM.
| 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 und 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.
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. Zusätzlich setzen wir UXN = 1 (User Execute Never), 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.
Von der Theorie zur Praxis: Die Page-Tables im RAM aufbauen
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)
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
Reserved0_1 : 4, // Muss 0 sein
OutputAddress : 32, // Physische Adresse [47:16]
Reserved0_2 : 4, // Muss 0 sein
Contiguous : 1, // Contiguous-Hint
PXN : 1, // Privileged Execute Never
UXN : 1, // User Execute Never
Ignored : 9; // Wird von der Hardware ignoriert
} TARMV8MMU_LEVEL3_PAGE_DESCRIPTOR;
#define ARMV8MMUL3PAGEADDR(addr) (((addr) >> 16) & 0xFFFFFFFF)
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;
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;
static TARMV8MMU_LEVEL3_DESCRIPTOR* TranslationTable_CreateLevel3Table(TranslationTable* pMasterTable, uintptr nBaseAddress)
{
TARMV8MMU_LEVEL3_DESCRIPTOR* pTable = (TARMV8MMU_LEVEL3_DESCRIPTOR*)palloc();
uintptr coherent_start = (uintptr)&__coherent_start;
uintptr coherent_end = (uintptr)&__coherent_end;
for (unsigned nPage = 0; nPage < ARMV8MMU_TABLE_ENTRIES; nPage++)
{
TARMV8MMU_LEVEL3_PAGE_DESCRIPTOR *pDesc = &pTable[nPage].Page;
pDesc->Value11 = 3;
pDesc->AttrIndx = ATTRINDX_NORMAL;
pDesc->NS = 0;
pDesc->AP = ATTRIB_AP_RW_EL1;
pDesc->SH = ATTRIB_SH_INNER_SHAREABLE;
pDesc->AF = 1;
pDesc->nG = 0;
pDesc->Reserved0_1 = 0;
pDesc->OutputAddress = ARMV8MMUL3PAGEADDR(nBaseAddress);
pDesc->Reserved0_2 = 0;
pDesc->Contiguous = 0;
pDesc->PXN = 0;
pDesc->UXN = 1;
pDesc->Ignored = 0;
if (nBaseAddress >= (u64)&_etext)
{
pDesc->PXN = 1;
if ((nBaseAddress >= pMasterTable->nMemSize && nBaseAddress < HIGHMEM_START) || nBaseAddress > HIGHMEM_END)
{
pDesc->AttrIndx = ATTRINDX_DEVICE;
pDesc->SH = ATTRIB_SH_OUTER_SHAREABLE;
}
else if (nBaseAddress >= coherent_start && nBaseAddress < coherent_end)
{
pDesc->AttrIndx = ATTRINDX_COHERENT;
pDesc->SH = ATTRIB_SH_OUTER_SHAREABLE;
}
}
nBaseAddress += ARMV8MMU_LEVEL3_PAGE_SIZE;
}
return pTable;
}
Die Entscheidungslogik als Diagramm
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)
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++)
{
u64 nBaseAddress = (u64) nEntry * ARMV8MMU_TABLE_ENTRIES * ARMV8MMU_LEVEL3_PAGE_SIZE;
if (nBaseAddress >= 4*GIGABYTE && !(MEM_PCIE_RANGE_START_VIRTUAL <= nBaseAddress && nBaseAddress <= MEM_PCIE_RANGE_END_VIRTUAL))
{
continue;
}
TARMV8MMU_LEVEL3_DESCRIPTOR* pTable = TranslationTable_CreateLevel3Table(pMasterTable, nBaseAddress);
TARMV8MMU_LEVEL2_TABLE_DESCRIPTOR *pDesc = &pMasterTable->pTable[nEntry].Table;
pDesc->Value11 = 3;
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 ();
}
Den Quellcode kannst du hier herunterladen: https://www.satyria.de/arm/sources/RPI4/C/speicher2.zip