Chapter 9: Kernel Entry Point
- What happens inside the kernel entry point before C code runs
- How to install the exception vector table
- How to enable the instruction and data caches
- How to configure the GIC (interrupt controller)
- The initialization sequence from _start to kernel_main
- How to structure kernel initialization for clarity and correctness
9.1 The Entry Point and Initialization Sequence
The kernel entry point is the first code that runs when the kernel
gains control of the CPU. In our kernel, this is the _start label in
start.S. The entry point must prepare the CPU so that C code can run
safely.
The full initialization sequence, from entry to running the first user-space process, follows a strict order:
- Early assembly setup (start.S): stack, BSS, EL drop, save boot params
- Early C init (kernel_main): UART, exception vectors, GIC, system timers
- Memory init: physical memory map, page tables, MMU enable
- Subsystem init: scheduler, heap allocator, device drivers
- First process: spawn init process, drop to EL0
In this chapter, we focus on steps 1 and 2: everything that happens inside
_start and kernel_main before memory management is set up.
9.2 Early Assembly Setup Recap
From Chapter 7, our start.S does these things before calling
kernel_main:
_start:
mov x20, x0 /* save device tree address */
mov x21, x1 /* save CPU ID */
mrs x0, CurrentEL /* check EL */
lsr x0, x0, #2
cmp x0, #2
b.ne 1f
/* Drop from EL2 to EL1 */
mov x0, #0x3C5
msr SPSR_EL2, x0
adr x0, 1f
msr ELR_EL2, x0
eret
1: ldr x0, =_stack_end /* set stack */
mov sp, x0
/* Clear BSS */
ldr x0, =_bss_start
ldr x1, =_bss_end
mov x2, xzr
2: cmp x0, x1
b.ge 3f
str x2, [x0], #8
b 2b
3: mov x0, x20 /* restore dtb address */
mov x1, x21 /* restore CPU ID */
bl kernel_main
After this code runs, the CPU is at EL1 with a valid stack pointer, zeroed BSS, and the device tree address and CPU ID in registers ready for the C function.
9.3 Exception Vector Installation
Before we can enable interrupts, we must install the exception vector table. This is a table of 16 entries (each 128 bytes) that tells the CPU where to jump when exceptions occur. Without this table, any exception (including the timer interrupt) would cause the CPU to jump to an undefined address and crash.
The exception vector table must be aligned to 2 KB (2048 bytes). We install it by
writing its address to the VBAR_EL1 register:
.section .text.vectors
.align 11 /* 2^11 = 2048 byte alignment */
.global exception_vectors
exception_vectors:
/* EL1t (SP_EL0), synchronous */
.align 7
b el1t_sync
.align 7
b el1t_irq
.align 7
b el1t_fiq
.align 7
b el1t_serror
/* EL1h (SP_EL1), synchronous */
.align 7
b el1h_sync
.align 7
b el1h_irq
.align 7
b el1h_fiq
.align 7
b el1h_serror
/* EL0 (AArch64), synchronous */
.align 7
b el0_sync_64
.align 7
b el0_irq_64
.align 7
b el0_fiq_64
.align 7
b el0_serror_64
/* EL0 (AArch32), synchronous */
.align 7
b el0_sync_32
.align 7
b el0_irq_32
.align 7
b el0_fiq_32
.align 7
b el0_serror_32
In our C code, we install the table early in kernel_main:
extern char exception_vectors[];
void install_exception_vectors(void) {
__asm__ volatile("msr VBAR_EL1, %0" : : "r" (exception_vectors));
__asm__ volatile("isb");
}
After this call, any exception that occurs will jump to one of these vectors. The
ISB (instruction synchronization barrier) ensures the new vector address
is used by subsequent instructions.
9.4 Enabling Caches
When the CPU starts, both the instruction cache (I-cache) and data cache (D-cache) are disabled. This means every memory access goes directly to main memory, which is very slow.
We can enable the caches before the MMU is turned on, but only the instruction cache is safe to enable without MMU. The data cache requires the MMU to be active because cacheable memory regions must be defined in the page tables.
enable_caches:
mrs x0, SCTLR_EL1
/* Enable instruction cache (bit 12) */
orr x0, x0, #(1 << 12)
/* Enable branch prediction (bit 11) */
orr x0, x0, #(1 << 11)
msr SCTLR_EL1, x0
isb
ret
The data cache and MMU are enabled together later, after we have set up page tables. Until then, all data accesses go directly to physical memory.
9.5 GIC Initialization
The Generic Interrupt Controller (GIC) is the hardware component that manages interrupts on ARM64 systems. It receives interrupt signals from devices, prioritizes them, and delivers them to the CPU.
On QEMU virt, the GIC is a GICv3 at physical address 0x08000000. It consists of two main interfaces:
- Distributor (GICD): global interrupt configuration, enable/disable, priority grouping
- Redistributor / CPU Interface (GICR / GICC): per-CPU interrupt handling, priority masking, interrupt acknowledgment
Minimal GIC initialization for the distributor:
#define GICD_BASE 0x08000000
#define GICR_BASE 0x080A0000
/* GIC distributor registers */
#define GICD_CTLR (GICD_BASE + 0x0000)
#define GICD_TYPER (GICD_BASE + 0x0004)
#define GICD_IGROUPR (GICD_BASE + 0x0080)
#define GICD_ISENABLER (GICD_BASE + 0x0100)
#define GICD_ICENABLER (GICD_BASE + 0x0180)
#define GICD_ICPEND (GICD_BASE + 0x0280)
#define GICD_ICFGR (GICD_BASE + 0x0C00)
/* GIC redistributor registers */
#define GICR_WAKER (GICR_BASE + 0x0014)
void gic_init(void) {
volatile uint32_t *gicd_ctlr = (uint32_t *)GICD_CTLR;
volatile uint32_t *gicd_icenabler = (uint32_t *)GICD_ICENABLER;
volatile uint32_t *gicd_icpend = (uint32_t *)GICD_ICPEND;
volatile uint32_t *gicd_igroupr = (uint32_t *)GICD_IGROUPR;
volatile uint32_t *gicd_icfgr = (uint32_t *)GICD_ICFGR;
volatile uint32_t *gicr_waker = (uint32_t *)GICR_WAKER;
/* Disable the distributor */
*gicd_ctlr = 0;
/* Disable all interrupts (set bits in ICENABLER) */
for (int i = 0; i < 32; i++) {
gicd_icenabler[i] = 0xFFFFFFFF;
}
/* Clear all pending interrupts */
for (int i = 0; i < 32; i++) {
gicd_icpend[i] = 0xFFFFFFFF;
}
/* Set all interrupts to group 1 (non-secure) */
for (int i = 0; i < 32; i++) {
gicd_igroupr[i] = 0xFFFFFFFF;
}
/* Set all interrupts to level-sensitive */
for (int i = 0; i < 32; i++) {
gicd_icfgr[i] = 0x00000000;
}
/* Wake up the redistributor (clear the "processor sleep" bit) */
*gicr_waker &= ~(1 << 1);
/* Enable the distributor */
*gicd_ctlr = 1;
}
After this initialization, the GIC is ready to receive interrupts. We enable specific interrupts (like the timer interrupt) later when we initialize each device.
9.6 System Timer Initialization
ARM64 provides a system timer that can generate periodic interrupts. This is essential for the scheduler, which needs a timer tick to preempt running processes.
The system timer has a counter (CNTPCT_EL0) that increments at a fixed frequency, and a timer (CNTP_TVAL_EL1) that generates an interrupt when the count reaches a certain value.
#define CNTP_TVAL_EL1 "S3_1_C14_C2_0" /* Timer value register */
#define CNTP_CTL_EL1 "S3_1_C14_C2_1" /* Timer control register */
#define CNTPCT_EL0 "S3_3_C14_C0_0" /* Counter register (read-only) */
/* Read the counter frequency */
uint64_t timer_get_freq(void) {
uint64_t freq;
__asm__("mrs %0, CNTFRQ_EL0" : "=r" (freq));
return freq;
}
/* Read the current counter value */
uint64_t timer_get_count(void) {
uint64_t count;
__asm__("mrs %0, " CNTPCT_EL0 : "=r" (count));
return count;
}
/* Set the timer to fire after 'us' microseconds */
void timer_set_us(uint64_t us) {
uint64_t freq = timer_get_freq();
uint64_t ticks = (freq * us) / 1000000;
/* Set the timer value (how many ticks from now) */
__asm__("msr " CNTP_TVAL_EL1 ", %0" : : "r" (ticks));
/* Enable the timer: bit 0 = enable, bit 1 = mask interrupt */
__asm__("msr " CNTP_CTL_EL1 ", %0" : : "r" (1UL));
/* Enable the timer interrupt in the GIC (PPI interrupt ID 30) */
volatile uint32_t *isenabler = (uint32_t *)(0x08000000 + 0x0100);
isenabler[0] = (1 << 30);
}
/* Handle the timer interrupt (called from exception handler) */
void timer_handler(void) {
/* Acknowledge the timer interrupt */
__asm__("msr " CNTP_CTL_EL1 ", %0" : : "r" (0));
/* Clear the interrupt in the GIC */
volatile uint32_t *eoir = (uint32_t *)(0x08000000 + 0x4000 + 0x0010);
*eoir = 30; /* interrupt ID 30 */
}
The timer frequency on QEMU virt is typically 62.5 MHz (62,500,000 Hz), so each tick is
16 nanoseconds. We use this to implement timer_set_us for microsecond
granularity.
9.7 The Complete Initialization Sequence
Here is the full kernel_main function with all the initialization steps we
have discussed:
#include
extern void install_exception_vectors(void);
extern void enable_caches(void);
extern void gic_init(void);
extern void timer_set_us(uint64_t us);
extern void uart_init(void);
extern void uart_puts(const char *s);
/* Print a hex number */
static void puthex(uint64_t n) {
const char *hex = "0123456789abcdef";
uart_puts("0x");
for (int i = 15; i >= 0; i--)
uart_putc(hex[(n >> (i * 4)) & 0xf]);
}
void kernel_main(uint64_t dtb_addr, uint64_t cpu_id) {
/* Step 1: Micro-architecture setup */
install_exception_vectors();
enable_caches();
/* Step 2: Interrupt controller setup */
if (cpu_id == 0) {
gic_init();
}
/* Step 3: Console setup (for debug output) */
uart_init();
uart_puts("\r\n[KERNEL] Boot CPU: ");
puthex(cpu_id);
uart_puts("\r\n");
/* Step 4: System timer setup */
uint64_t freq = timer_get_freq();
uart_puts("[KERNEL] Timer frequency: ");
puthex(freq);
uart_puts(" Hz\r\n");
/* Step 5: Memory subsystem (future chapters) */
uart_puts("[KERNEL] Device tree at: ");
puthex(dtb_addr);
uart_puts("\r\n");
/* Step 6: Enable interrupts for this CPU */
__asm__("msr DAIFClr, #2"); /* unmask IRQs */
/* Step 7: Set a periodic timer (every 10 ms for scheduler) */
timer_set_us(10000);
uart_puts("[KERNEL] Initialization complete. Waiting for interrupts...\r\n");
/* Main loop: wait for interrupts */
while (1) {
__asm__("wfi");
}
}
Each step builds on the previous one. The exception vectors must be installed before any interrupt can occur. The GIC must be initialized before device interrupts can be delivered. The timer must be configured before the scheduler can use it.
9.8 Startup Order and Dependencies
Initialization order matters. This table shows the dependencies between subsystems:
| # | Subsystem | Depends On | Why |
|---|---|---|---|
| 1 | Exception vectors | Nothing | Must be ready before any interrupt |
| 2 | Caches (I-cache) | Nothing | Safe without MMU |
| 3 | GIC | Exception vectors | GIC sends interrupts to CPU |
| 4 | UART | Nothing | For debug messages |
| 5 | System timer | GIC, exception vectors | Timer interrupt must be handled |
| 6 | Memory map | UART (for debug) | Must know physical memory layout |
| 7 | Page tables | Memory map | Must know which memory to map |
| 8 | MMU + D-cache | Page tables | MMU needs page tables |
| 9 | Heap allocator | MMU, memory map | Needs virtual memory |
| 10 | Scheduler | Timer, heap, MMU | Needs timer + dynamic memory |
We will implement each of these subsystems in the order shown. The next several chapters cover the memory management stack (steps 6-8), which is the most complex part of the kernel.
9.9 Our Implementation
The complete kernel entry point implementation for our kernel consists of these files:
| File | Contents |
|---|---|
start.S | Early assembly: stack, BSS, EL drop, jump to kernel_main |
vectors.S | Exception vector table with 16 entries |
kernel.c | kernel_main and all initialization functions |
uart.c | UART initialization and I/O |
gic.c | GIC initialization (distributor + redistributor) |
timer.c | System timer setup and handler |
The build process combines these into a single kernel ELF:
# Assemble
aarch64-none-elf-as start.S -o start.o
aarch64-none-elf-as vectors.S -o vectors.o
# Compile
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra kernel.c -o kernel.o
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra uart.c -o uart.o
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra gic.c -o gic.o
aarch64-none-elf-gcc -c -ffreestanding -O2 -Wall -Wextra timer.c -o timer.o
# Link
aarch64-none-elf-ld -T kernel.ld start.o vectors.o kernel.o uart.o gic.o timer.o -o kernel.elf
# Run
qemu-system-aarch64 -M virt -cpu cortex-a72 -nographic -kernel kernel.elf
9.10 Exercises
Exercise 1: Verify Exception Vectors
After installing the exception vectors, read back VBAR_EL1 and print its
value. Verify it matches the address of your exception_vectors symbol.
Use objdump -t kernel.elf | grep exception_vectors to find the address.
Exercise 2: Trigger a Synchronous Exception
Add an undefined instruction (.word 0xDEAD1234) somewhere in your code
and observe what happens. The CPU should jump to the el1h_sync vector.
Add code there that prints "Synchronous exception occurred" and then halts.
Exercise 3: Measure Cache Performance
Write a function that reads 1 MB of memory sequentially and measures the time using CNTPCT_EL0. Run it with the instruction cache disabled and then with it enabled. Compare the times and compute the speedup.
Exercise 4: Timer Accuracy
Set the system timer to fire after 1 second. In the timer handler, increment a counter. After 10 seconds (check using CNTPCT_EL0), print the counter value. It should be 10. If not, explain the discrepancy.
Exercise 5: GIC Interrupt Count
After GIC initialization, read the GICD_TYPER register and print the
number of interrupt lines supported. The TYPER register encodes this in bits 4:0
(ITLinesNumber). The number of interrupts is 32 * (ITLinesNumber + 1).
Exercise 6: Unexpected Interrupt Handler (Challenge)
Create a single "unexpected interrupt" handler that all unused exception vectors
point to. The handler should print the exception type (sync, IRQ, FIQ, SError), the
exception level (EL1 or EL0), and the value of ESR_EL1 (the exception
syndrome register). Then halt the CPU.
9.11 Summary
In this chapter, we covered the critical initialization that happens inside the kernel entry point before the main kernel subsystems are started.
The initialization sequence follows a strict order: exception vectors first (so interrupts can be handled), then caches (for performance), then the GIC (interrupt controller), then the system timer, and finally individual device drivers.
We installed the exception vector table by writing its address to VBAR_EL1.
We enabled the instruction cache and branch prediction via SCTLR_EL1. We
initialized the GIC distributor and redistributor to accept interrupts. We configured
the system timer to generate periodic interrupts for the scheduler.
The dependency chain between subsystems determines the initialization order. Getting this order wrong causes subtle bugs that are hard to debug. Always initialize from the CPU outward: CPU features first, then interrupt controller, then devices.
In the next chapter, we will dive deeper into exception levels and how the kernel manages transitions between EL1 (kernel) and EL0 (user space).