There’s a persistent myth in the software engineering world that operating system kernels are written entirely in C or C++. “Why would you need assembly?” the thinking goes. “Modern compilers generate great code. Just write everything in a high-level language and let the compiler do the work.”
If only things were that simple.
The reality is that every production operating system kernel—Linux, Windows, macOS, FreeBSD, you name it—has significant amounts of assembly language code. Not just a few lines here and there for “performance-critical sections,” but thousands upon thousands of lines of carefully crafted assembly that handles the tasks that C simply can’t do.
In this article, we’re going to dive deep into the world of OS kernel development with assembly language. We’ll look at exactly why kernels still need assembly, which parts of the kernel have to be written in assembly, and how to write those parts correctly. We’ll cover context switching (the crown jewel of OS assembly code), interrupt handling, system call dispatch, SMP initialization, and a whole lot more.
By the time we’re done, you’ll understand not just that kernels use assembly, but exactly where and exactly why. And you’ll have the knowledge you need to write your own OS kernel assembly code—whether for a hobbyist project or just to understand how your computer really works.
Let’s get started.
Why OS Kernels Still Have Assembly: Context Switching, Interrupt Handling, CPU-Specific Initialization
Let me start with the obvious question: Why? Why, in 2024, with compilers that can optimize the hell out of C code, do operating system kernels still need assembly language?
The answer comes down to three fundamental facts about modern CPUs and operating systems:
- There are things that C can’t do. C gives you a portable, high-level abstraction over the hardware. But the hardware can do things that C simply can’t represent. Switching the CPU from user mode to kernel mode. Loading a new GDT (Global Descriptor Table). Executing a “HLT” instruction to put the CPU to sleep. These are things that only assembly can do, because they require executing specific CPU instructions that have no C equivalent.
- Someone has to set up the execution environment that C expects. C assumes that there’s a stack, that memory is set up, that interrupts are configured, that there’s a
main()function to call. But something has to set all of that up beforemain()can run. That “something” is assembly code—the very first code that runs when the kernel is loaded. - Performance matters in the kernel. The kernel is the most performance-critical code on the system. A slow syscall dispatch means every system call is slow. A slow context switch means every thread switch is slow. A slow interrupt handler means every I/O operation is slow. And while compilers are good, they’re not perfect—there are still places where hand-written assembly can outperform even the best compiler output.
Let’s break this down into the specific places where assembly is absolutely required in an OS kernel.
Context Switching
A context switch is what happens when the operating system switches the CPU from running one thread to running another. The kernel has to:
- Save the entire state (all registers) of the currently running thread
- Choose the next thread to run (the scheduler)
- Restore the entire state of the next thread
- Jump to where that thread was last executing
This is fundamentally an assembly-language operation. Why? Because you can’t save the CPU registers in C! Think about it: if you’re writing a C function to save registers, by the time your function starts executing, the C compiler has already clobbered some registers (the function call ABI specifies which registers are callee-saved and which are caller-saved). You can’t save the complete CPU state from C, because C itself uses the CPU state.
Here’s what a context switch function looks like in x86-64 assembly (Linux kernel style):
; Context switch: switch from thread A to thread B
; Input: RDI = pointer to thread A's saved state (thread_struct)
; RSI = pointer to thread B's saved state
; Output: None (doesn't return in the traditional sense—returns as thread B)
; C prototype: void switch_to(struct thread_struct *prev, struct thread_struct *next)
global switch_to
switch_to:
; Save callee-saved registers of thread A
mov [rdi + 0x00], rbx ; Save RBX
mov [rdi + 0x08], rbp ; Save RBP
mov [rdi + 0x10], r12 ; Save R12
mov [rdi + 0x18], r13 ; Save R13
mov [rdi + 0x20], r14 ; Save R14
mov [rdi + 0x28], r15 ; Save R15
mov [rdi + 0x30], rsp ; Save RSP (stack pointer)
; Save the return address (RIP) so thread A can resume later
; The return address is at [RSP] (pushed by the CALL instruction)
mov rax, [rsp]
mov [rdi + 0x38], rax ; Save RIP
; Restore callee-saved registers of thread B
mov rbx, [rsi + 0x00] ; Restore RBX
mov rbp, [rsi + 0x08] ; Restore RBP
mov r12, [rsi + 0x10] ; Restore R12
mov r13, [rsi + 0x18] ; Restore R13
mov r14, [rsi + 0x20] ; Restore R14
mov r15, [rsi + 0x28] ; Restore R15
mov rsp, [rsi + 0x30] ; Restore RSP (switch to thread B's stack!)
; "Return" to thread B's saved RIP
; Thread B's saved RIP is stored at offset 0x38 in its thread_struct
push qword [rsi + 0x38] ; Push RIP onto thread B's stack
ret ; RET pops RIP from stack and jumps there
This is the heart of the operating system. Every time the OS switches threads, this code runs. And it has to be written in assembly, because only assembly can save and restore the complete CPU state.
Interrupt Handling
Interrupts are the way that hardware devices (keyboard, disk, network card, timer, etc.) get the CPU’s attention. When a hardware device needs service, it asserts an interrupt request line, and the CPU stops what it’s doing, saves its current state, and jumps to an interrupt service routine (ISR) that the OS has registered for that interrupt.
Interrupt handling is another area where assembly is mandatory. When an interrupt fires, the CPU automatically:
- Saves the current RFLAGS, CS, and RIP on the stack (for x86-64)
- Jumps to the interrupt handler (the address is looked up in the IDT—Interrupt Descriptor Table)
- The handler has to save the rest of the CPU state (all the other registers)
- Do the actual work of handling the interrupt
- Restore the CPU state
- Execute
IRETQto return from the interrupt
The CPU’s interrupt mechanism is fundamentally an assembly-level construct. The IDT entries are data structures that contain segment selectors and offsets (assembly-level concepts). The IRETQ instruction that returns from an interrupt is an assembly instruction. And the prologue/epilogue code that saves and restores registers has to be written in assembly.
Here’s what a basic interrupt handler looks like in x86-64 assembly:
; Interrupt handler template (x86-64)
; This saves the CPU state, calls a C handler, and restores state
global timer_interrupt_handler
extern timer_handler_c ; C function that does the real work
timer_interrupt_handler:
; The CPU has already pushed: SS, RSP, RFLAGS, CS, RIP
; (This is the hardware interrupt frame)
; Save remaining registers
push rax
push rbx
push rcx
push rdx
push rsi
push rdi
push rbp
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
; Call the C handler
call timer_handler_c
; Restore registers (in reverse order)
pop r15
pop r14
pop r13
pop r12
pop r11
pop r10
pop r9
pop r8
pop rbp
pop rdi
pop rsi
pop rdx
pop rcx
pop rbx
pop rax
; Return from interrupt (pops RIP, CS, RFLAGS, RSP, SS)
iretq
(Modern kernels use a slightly more sophisticated approach—they save the registers to a struct pt_regs on the stack and pass a pointer to that struct to the C handler. But the basic idea is the same: the interrupt entry/exit code is assembly.)
CPU-Specific Initialization
Every CPU has features that can only be configured via assembly instructions. Setting up the GDT (Global Descriptor Table). Loading the IDT (Interrupt Descriptor Table). Enabling the floating-point unit (FPU). Enabling SSE/AVX instructions. Configuring the control registers (CR0, CR2, CR3, CR4, EFER). Setting up the syscall/MSR registers for fast system call dispatch.
All of these are privileged operations that can only be done from kernel mode (ring 0), and they all require specific assembly instructions (LGDT, LIDT, LTR, MOV to CRx, WRMSR, etc.) that have no C equivalent.
Here’s what the early kernel initialization code looks like (setting up the GDT and IDT):
; Early kernel initialization (x86-64)
; Sets up GDT, IDT, and enters the kernel's proper execution environment
global early_init
extern main ; The C kernel entry point
early_init:
; Set up the stack (we're running in assembly, need a stack)
mov rsp, early_stack_top
; Load the GDT (Global Descriptor Table)
lgdt [gdt_descriptor]
; Load the IDT (Interrupt Descriptor Table)
lidt [idt_descriptor]
; Set up the TSS (Task State Segment) - needed for stack switching on syscalls
mov ax, 0x18 ; TSS selector (from GDT)
ltr ax ; Load task register
; Enable SSE/AVX (needed for floating-point in the kernel)
mov rax, cr0
and ax, 0xFFFB ; Clear CR0.EM (emulation bit)
or ax, 0x0002 ; Set CR0.MP (monitor coprocessor)
mov cr0, rax
mov rax, cr4
or ax, 0x200 ; Set CR4.OSFXSR (enable FXSAVE/FXRSTOR)
or ax, 0x400 ; Set CR4.OSXMMEXCPT (enable SIMD exceptions)
mov cr4, rax
; Enable syscall/sysret (fast system call mechanism)
mov ecx, 0xC0000080 ; EFER MSR
rdmsr
or ax, 0x0001 ; Set EFER.SCE (syscall enable)
wrmsr
; Set up syscall targets
mov ecx, 0xC0000081 ; STAR MSR (syscall target)
rdmsr
mov edx, 0x00180008 ; Kernel CS=0x08, User CS=0x18 (adjust for your GDT)
wrmsr
; Jump to the C kernel entry point
call main
; If main() returns, halt the CPU
.hang:
hlt
jmp .hang
; =============================================================================
; GDT (Global Descriptor Table)
; =============================================================================
section .data
align 16
gdt_start:
; Null descriptor (required)
dq 0x0000000000000000
; Kernel code segment (selector = 0x08)
dq 0x00AF9A000000FFFF
; Kernel data segment (selector = 0x10)
dq 0x00AF92000000FFFF
; User code segment (selector = 0x18)
dq 0x00AFFA000000FFFF
; User data segment (selector = 0x20)
dq 0x00AFF2000000FFFF
; TSS descriptor (selector = 0x28)
; (TSS descriptor is 16 bytes in x86-64, special format)
dq 0x0000000000000000 ; Placeholder — set up at runtime
dq 0x0000000000000000
gdt_descriptor:
dw gdt_end - gdt_start - 1
dq gdt_start
gdt_end:
; =============================================================================
; Stack for early initialization
; =============================================================================
section .bss
align 16
early_stack_bottom:
resb 16384 ; 16KB stack
early_stack_top:
; =============================================================================
; IDT (Interrupt Descriptor Table) — set up by C code, loaded here
; =============================================================================
; The actual IDT is typically set up by C code (easier to manage 256 entries)
; We just need a minimal IDT for early init, then the C code replaces it
align 16
idt_descriptor:
dw 0x0FFF ; Limit = 256 entries × 8 bytes - 1
dq idt_start
idt_start:
times 256 dq 0 ; 256 null IDT entries (filled in by C code)
This is the “C runtime startup” code for an OS kernel. It’s the assembly code that runs before main() (or kmain()), setting up the CPU and the execution environment so that C code can run.
Without this assembly code, you can’t run C code. There’s no C function that can load the GDT, or set up the IDT, or enable the FPU. These are fundamentally assembly-level operations, and they have to be done in assembly.
Summary: Where Assembly is Required in an OS Kernel
To summarize, here are the places in an OS kernel where assembly is absolutely required:
- Context switching (saving/restoring thread state)
- Interrupt handling (ISR entry/exit code, IDT setup)
- System call dispatch (the gateway between user mode and kernel mode)
- CPU initialization (GDT, IDT, TSS, control registers, MSRs)
- Memory management (setting CR3 for page table switches, TLB invalidation with
INVLPG) - SMP initialization (starting application processors with SIPI)
- Power management (
HLTinstruction for idle loop,ACPImethods) - Atomic operations (
LOCKprefix,CMPXCHG,XCHGfor locks) - User-mode transitions (setting up the initial user stack and jumping to user code)
- Signal delivery (modifying the user stack to “inject” a signal handler)
In the sections that follow, we’re going to dive deep into each of these areas. We’ll look at the assembly code that implements them, the design decisions behind that code, and the subtle bugs that can bite you if you get it wrong.
But first, let’s talk about the “crown jewel” of OS assembly code: the context switch.



