Case Study 38-2: MinOS on ARM64 — Porting to QEMU aarch64
Introduction
The skills you built writing MinOS for x86-64 transfer directly to other architectures — with important differences. Porting MinOS to ARM64 running in qemu-system-aarch64 illuminates those differences concretely: UEFI boot vs. BIOS MBR, exception levels vs. privilege rings, ARM64's GIC interrupt controller vs. the x86-64 PIC/APIC, and the ARM64 MMU with its TTBR registers.
This case study is a design exercise and architectural comparison. The goal is not a complete ARM64 MinOS implementation (that is an extension project) but a precise understanding of what changes and what stays the same.
Boot: UEFI vs. BIOS
x86-64 BIOS Boot
The x86-64 MinOS bootloader exploits the BIOS convention: load the 512-byte MBR into 0x7C00, verify 0x55AA signature, execute. This is the IBM PC architecture from 1981. It works, but:
- Must fit in 512 bytes
- Starts in 16-bit real mode
- Requires A20 line management
- No built-in disk abstraction beyond INT 13h
ARM64 UEFI Boot
Modern ARM64 systems use UEFI (Unified Extensible Firmware Interface). QEMU's qemu-system-aarch64 emulates UEFI via the AAVMF firmware (ARM64 OVMF).
UEFI differences:
- Boots to a 64-bit environment (EL1 or EL2 on ARM64) — no mode transition needed
- Provides standard services: memory map via GetMemoryMap(), file access via EFI file protocols
- Applications are PE/COFF format ELF-like files with a specific entry point
- No 512-byte constraint
A minimal UEFI application for MinOS ARM64:
/* efi_main.c — UEFI entry point for ARM64 MinOS */
#include <efi.h>
#include <efilib.h>
EFI_STATUS EFIAPI efi_main(EFI_HANDLE ImageHandle,
EFI_SYSTEM_TABLE *SystemTable)
{
InitializeLib(ImageHandle, SystemTable);
Print(L"MinOS ARM64 bootloader\n");
/* Get memory map */
EFI_MEMORY_DESCRIPTOR *MemMap;
UINTN MapSize, MapKey, DescSize;
UINT32 DescVer;
GetMemoryMap(&MapSize, MemMap, &MapKey, &DescSize, &DescVer);
/* Load kernel from disk */
/* ... EFI file protocol calls ... */
/* Exit boot services (hand over to kernel) */
SystemTable->BootServices->ExitBootServices(ImageHandle, MapKey);
/* Jump to kernel */
void (*kernel_entry)(void) = (void *)KERNEL_LOAD_ADDRESS;
kernel_entry();
return EFI_SUCCESS; /* unreachable */
}
The UEFI environment provides much more than BIOS: available memory map, ACPI tables, GOP (Graphics Output Protocol) for framebuffer, and more — at the cost of a more complex boot process.
Exception Levels vs. Privilege Rings
x86-64 Privilege Rings
x86-64 uses 4 privilege levels (rings 0-3), though only 0 and 3 are used in practice: - Ring 0: kernel, full privilege - Ring 3: user space, restricted - Transitions via: SYSCALL/SYSRET, INT/IRET, exceptions
ARM64 Exception Levels
ARM64 uses a different hierarchy of exception levels (EL0-EL3):
EL3: Secure Monitor (secure world) — firmware/TrustZone
EL2: Hypervisor — virtualization
EL1: OS kernel — equivalent to ring 0
EL0: User space — equivalent to ring 3
For MinOS ARM64: - Kernel runs at EL1 - User processes run at EL0 - QEMU typically starts execution at EL1 (or EL2 if emulating a hypervisor)
The UEFI firmware (AAVMF) starts at EL2, drops to EL1 before calling the OS loader.
Exception Level Transitions
ARM64 enters EL1 from EL0 via:
- SVC (Supervisor Call) — equivalent to x86-64 SYSCALL
- Exceptions (data abort, undefined instruction)
- IRQ/FIQ from EL0
The return path uses ERET (Exception Return) — equivalent to x86-64 IRETQ.
; ARM64: entering EL1 via SVC (system call)
; At EL0:
mov x8, #1 ; syscall number
mov x0, #1 ; arg1 (fd)
adr x1, msg ; arg2 (buffer)
mov x2, #13 ; arg3 (length)
svc #0 ; jump to EL1
; At EL1 (in exception handler):
; x0-x5: arguments preserved
; ELR_EL1: address of instruction after SVC
; SPSR_EL1: PSTATE at time of SVC (including EL and DAIF bits)
; Return to EL0:
eret ; restores PC from ELR_EL1, PSTATE from SPSR_EL1
Compare to x86-64:
; x86-64: entering ring 0 via SYSCALL
mov rax, 1 ; syscall number (write)
mov rdi, 1 ; arg1
lea rsi, [msg] ; arg2
mov rdx, 13 ; arg3
syscall ; jump to kernel
; At ring 0:
; Arguments in rdi, rsi, rdx, r10, r8, r9
; RCX: return address (saved by CPU)
; R11: saved RFLAGS
; Return via sysret
The symmetry is clear: SVC/ERET on ARM64 parallels SYSCALL/SYSRET on x86-64.
ARM64 Interrupt Controller: GIC
x86-64: PIC/APIC
MinOS x86-64 uses the 8259A PIC (Programmable Interrupt Controller). IRQs 0-15 map to IDT vectors 0x20-0x2F. The APIC is more powerful but requires more setup.
ARM64: GIC (Generic Interrupt Controller)
ARM64 systems use the GIC (ARM Generic Interrupt Controller). QEMU's virt machine emulates a GICv3.
Key differences from the PIC: - 32+ interrupt IDs (vs. 16 in PIC) - Distributor (GICD) and CPU interface (GICC): separate registers - Supports SPI (Shared Peripheral Interrupts, like the PIC's IRQs), PPI (Per-Processor Interrupts, like timers), and SGI (Software-Generated Interrupts, for IPI) - No "EOI" to a separate port — write to GICC_EOIR register
/* ARM64 MinOS: GIC initialization */
#define GICD_BASE 0x08000000 /* GIC Distributor (QEMU virt) */
#define GICC_BASE 0x08010000 /* GIC CPU Interface */
#define GICD_CTLR (GICD_BASE + 0x000)
#define GICD_ISENABLER (GICD_BASE + 0x100)
#define GICC_CTLR (GICC_BASE + 0x000)
#define GICC_PMR (GICC_BASE + 0x004) /* priority mask */
#define GICC_IAR (GICC_BASE + 0x00C) /* interrupt acknowledge */
#define GICC_EOIR (GICC_BASE + 0x010) /* end of interrupt */
void gic_init(void) {
/* Enable distributor */
*(volatile uint32_t *)GICD_CTLR = 1;
/* Enable CPU interface, all priorities */
*(volatile uint32_t *)GICC_PMR = 0xFF; /* allow all priorities */
*(volatile uint32_t *)GICC_CTLR = 1;
/* Enable timer interrupt (SPI 27 on QEMU virt for ARM timer) */
*(volatile uint32_t *)(GICD_ISENABLER + (27/32)*4) |= (1 << (27 % 32));
}
void irq_handler(void) {
uint32_t iar = *(volatile uint32_t *)GICC_IAR;
uint32_t intid = iar & 0x3FF;
if (intid == 27) { /* ARM timer */
timer_handler();
} else if (intid == 33) { /* PL011 UART receive (keyboard equivalent) */
uart_handler();
}
*(volatile uint32_t *)GICC_EOIR = iar; /* acknowledge */
}
ARM64 Timer: Generic Timer vs. PIT
x86-64 MinOS uses the PIT (Intel 8253) at I/O ports 0x40-0x43. ARM64 has no I/O ports — it uses Memory-Mapped I/O exclusively.
The ARM64 Generic Timer is a core architectural feature (not a platform peripheral):
/* ARM64 timer using the architectural timer */
void timer_init(void) {
uint64_t freq;
/* Read timer frequency from system register */
__asm__ volatile("mrs %0, cntfrq_el0" : "=r"(freq));
/* Typically 62.5 MHz on QEMU */
/* Set timer to fire in (freq / HZ) ticks */
uint64_t interval = freq / 100; /* 100 Hz */
__asm__ volatile("msr cntp_tval_el0, %0" :: "r"(interval));
/* Enable the timer */
__asm__ volatile("msr cntp_ctl_el0, %0" :: "r"(1ULL));
}
The ARM64 system registers (mrs/msr) replace x86-64 I/O port instructions (in/out).
ARM64 MMU and Page Tables
x86-64 uses CR3 to point to PML4. ARM64 uses two separate translation table registers:
- TTBR0_EL1: user-space page tables (low virtual addresses)
- TTBR1_EL1: kernel page tables (high virtual addresses)
ARM64 page tables are conceptually similar to x86-64 but with different field layouts and different names (PGD, PUD, PMD, PTE in Linux terminology).
/* ARM64 MMU initialization */
void mmu_init(void) {
/* Set Memory Attribute Indirection Register */
uint64_t mair = (0xFF << 0) /* Attr0: Normal memory (WB, WA) */
| (0x00 << 8); /* Attr1: Device memory (nGnRnE) */
__asm__ volatile("msr mair_el1, %0" :: "r"(mair));
/* Set Translation Control Register */
uint64_t tcr = (16 << 0) /* T0SZ: 48-bit VA for TTBR0 */
| (16 << 16) /* T1SZ: 48-bit VA for TTBR1 */
| (0 << 14) /* TG0: 4KB pages for TTBR0 */
| (2 << 30); /* TG1: 4KB pages for TTBR1 */
__asm__ volatile("msr tcr_el1, %0" :: "r"(tcr));
/* Set kernel page table base */
__asm__ volatile("msr ttbr1_el1, %0" :: "r"(kernel_pgtable));
/* Enable MMU */
uint64_t sctlr;
__asm__ volatile("mrs %0, sctlr_el1" : "=r"(sctlr));
sctlr |= 1; /* M: MMU enable */
__asm__ volatile("msr sctlr_el1, %0" :: "r"(sctlr));
__asm__ volatile("isb"); /* instruction synchronization barrier */
}
What Stays the Same
Despite the architectural differences, MinOS's fundamental design is portable:
| Component | x86-64 MinOS | ARM64 MinOS |
|---|---|---|
| Bootloader | 512B MBR, real mode → long mode | UEFI EFI application, already 64-bit |
| Kernel entry | entry.asm, load GDT/IDT |
Entry with no GDT (ARM uses page tables), setup VBAR_EL1 for vectors |
| VGA output | PIO to 0xB8000 | ARM PL011 UART or ARM framebuffer (MMIO) |
| Interrupts | IDT + PIC | Exception vector table (VBAR_EL1) + GIC |
| Timer | PIT via I/O ports | ARM Generic Timer via system registers |
| Memory | 4-level paging, CR3, CR4.PAE | 4-level paging, TTBR0/TTBR1, MAIR |
| Syscalls | SYSCALL/SYSRET MSRs | SVC/ERET, exception level transition |
| Context switch | Push/pop registers, iretq | Push/pop registers, eret |
| Shell, scheduler, PMM | Identical C code | Identical C code |
The C kernel logic — scheduler, memory allocator, shell, drivers (at a higher level of abstraction) — ports with minimal changes. Only the hardware-interface layer needs ARM64-specific rewriting.
Running ARM64 MinOS in QEMU
# Required QEMU packages:
# qemu-system-arm (or qemu-system-aarch64)
# AAVMF firmware: ovmf-aarch64 package
# Build command:
qemu-system-aarch64 \
-machine virt \
-cpu cortex-a72 \
-m 128M \
-bios /usr/share/qemu-efi-aarch64/QEMU_EFI.fd \
-drive file=minOS-arm64.img,format=raw \
-serial stdio \
-nographic
The -machine virt flag uses QEMU's generic ARM64 virtual machine, which has the GICv3, ARM Generic Timer, PL011 UART, and VirtIO bus — all well-documented and suitable for educational OS development.
Lessons from the Comparison
Porting MinOS ARM64 teaches the most important lesson about systems programming: the concepts are universal; the specific registers and I/O mechanisms are architecture-specific.
Every operating system — Linux, Windows, macOS, FreeBSD — solves these same problems: 1. Boot: get from firmware to a stable execution environment 2. Privilege separation: kernel code protected from user code 3. Interrupts: hardware events interrupt normal execution flow 4. Memory management: virtual address spaces, page tables 5. Scheduling: multiple execution contexts sharing one processor
The names differ. The register numbers differ. The MMIO addresses differ. But the problems and their solutions are the same. Understanding them at the assembly level on x86-64 gives you the vocabulary to understand any architecture quickly — and that vocabulary is what makes the second architecture significantly easier than the first.