Before studying processes, system calls, memory management, or filesystems inside an operating system, it is useful to understand one fundamental question:
When a computer is powered on, how does the CPU eventually start executing the operating system kernel?
There are several answers depending on the boot architecture. The three models we will study are:
Traditional Legacy BIOS + MBR boot
Modern UEFI + GPT boot
xv6 + QEMU boot
The xv6 model is deliberately simplified, but it allows us to see the kernel startup process much more clearly.
The traditional PC boot process uses BIOS firmware and commonly an MBR-partitioned disk.
A simplified sequence is:
Power On
↓
BIOS firmware
↓
Read sector 0
↓
MBR boot code
↓
Bootloader
↓
Linux kernel
↓
Operating system starts
In the traditional MBR layout, the first sector of the disk contains the Master Boot Record (MBR).
A traditional 512-byte MBR contains:
Sector 0
┌─────────────────────────────┐
│ Boot code │
│ │
│ │
├─────────────────────────────┤
│ Partition table │
├─────────────────────────────┤
│ Boot signature 0x55AA │
└─────────────────────────────┘
The MBR contains both executable boot code and partition information.
The BIOS does not understand Linux, ext4, or the Linux kernel. Its initial job is very simple:
Find the boot device.
Read its first sector.
Place that sector into memory.
Transfer CPU execution to the boot code.
Historically, the boot sector was loaded at the conventional address:
0x7C00
The CPU then begins executing the boot code located there.
No.
This distinction is important.
The partition table is data, not the program that launches the kernel.
It tells software things such as:
Partition 1 → starts here, ends here
Partition 2 → starts here, ends here
The boot code or bootloader can use this information to locate the appropriate partition.
The overall process is therefore:
BIOS
↓
MBR boot code
↓
find/load bootloader
↓
bootloader understands filesystem
↓
find kernel
↓
load kernel into RAM
↓
jump to kernel
The tiny MBR boot code historically could not contain a sophisticated filesystem and kernel loader. Therefore, systems such as Linux commonly used a multi-stage bootloader such as GRUB.
A simplified Linux BIOS boot might therefore look like:
BIOS
↓
MBR
↓
GRUB
↓
/boot/vmlinuz
↓
Linux kernel
A useful concept here is that a filesystem is fundamentally an organization of data on a storage device.
For example, an ext4 filesystem contains information describing:
superblock
inode information
directories
file metadata
data blocks
The Linux kernel contains code capable of understanding ext4.
But the kernel is not the only software that can understand a filesystem.
A bootloader can have its own filesystem drivers.
This gives us:
Filesystem on disk
↑
│
┌──────┼─────────┐
│ │ │
UEFI GRUB Linux
│ │ │
FAT ext4 ext4
code code code
The same filesystem format can therefore be understood by different pieces of software.
This becomes particularly important with UEFI.
Modern computers generally use UEFI firmware instead of the traditional BIOS boot mechanism.
A typical modern Linux installation looks approximately like:
Disk
├── EFI System Partition
│ └── EFI
│ └── ubuntu
│ └── grubx64.efi
│
└── Linux filesystem
└── boot
└── vmlinuz-...
The EFI System Partition (ESP) is normally formatted using FAT32.
The important difference is that UEFI does not normally say:
"Read sector 0 and execute the code stored there."
Instead, UEFI has support for locating and loading EFI executable files.
The physical disk blocks containing grubx64.efi are not permanently fixed.
Instead, the system has a logical path such as:
\EFI\ubuntu\grubx64.efi
The UEFI firmware knows which partition contains the EFI System Partition and can use its FAT filesystem support to locate the file.
Conceptually:
UEFI
↓
EFI System Partition
↓
FAT32 filesystem
↓
\EFI\ubuntu\grubx64.efi
↓
physical disk blocks
↓
load file into RAM
↓
execute GRUB
The filesystem translates the logical filename into the actual physical blocks containing the file.
Therefore, if the physical location of the file changes, the UEFI boot entry normally does not have to be changed.
For example:
Logical location:
\EFI\ubuntu\grubx64.efi
might currently correspond to:
Disk blocks:
1000, 1001, 1050, 1051...
and later:
Disk blocks:
5000, 5001, 5070, 5071...
The FAT filesystem maintains the mapping.
At this stage, Linux has not started yet.
Therefore, the Linux kernel's filesystem code cannot be responsible for reading the EFI System Partition.
Instead:
UEFI firmware
↓
UEFI's own FAT filesystem support
↓
grubx64.efi
Once GRUB starts, GRUB has its own filesystem support.
For example, if /boot is on an ext4 filesystem:
GRUB
↓
GRUB ext4 filesystem driver
↓
/boot/vmlinuz
↓
ext4 metadata
↓
physical disk blocks
↓
Linux kernel loaded into RAM
So there can be multiple independent implementations of a filesystem:
UEFI → FAT implementation
GRUB → FAT/ext4/etc. implementations
Linux → FAT/ext4/etc. implementations
They all understand the same on-disk formats.
On a typical Linux installation, you will see files such as:
/boot/vmlinuz-6.x.x
vmlinuz is the Linux kernel image used for booting.
GRUB locates the appropriate kernel and loads it into RAM.
A simplified modern Linux boot process is therefore:
Power On
↓
UEFI firmware
↓
EFI System Partition
↓
FAT32
↓
grubx64.efi
↓
GRUB
↓
/boot/vmlinuz
↓
Linux kernel
↓
Linux starts
GRUB may also load an initramfs/initrd containing files needed during early Linux startup.
Modern systems commonly use GPT (GUID Partition Table) instead of MBR partitioning.
A simplified disk might look like:
Disk
┌────────────────────────────┐
│ GPT information │
├────────────────────────────┤
│ EFI System Partition │
│ FAT32 │
│ grubx64.efi │
├────────────────────────────┤
│ Linux filesystem │
│ ext4 │
│ /boot/vmlinuz │
├────────────────────────────┤
│ Other partitions │
└────────────────────────────┘
It is useful to distinguish:
BIOS / UEFI
These describe the firmware/boot mechanism.
MBR / GPT
These describe disk partitioning schemes.
They are historically associated:
Traditional:
BIOS + MBR
Modern:
UEFI + GPT
but they are not fundamentally the same concept.
For example, Linux can be installed using different combinations depending on the hardware and configuration.
The two boot processes can be summarized as:
LEGACY BIOS
────────────────────────
BIOS
↓
sector 0
↓
MBR boot code
↓
bootloader
↓
filesystem
↓
vmlinuz
↓
Linux
versus:
UEFI
────────────────────────
UEFI
↓
EFI System Partition
↓
FAT32
↓
grubx64.efi
↓
GRUB
↓
filesystem
↓
vmlinuz
↓
Linux
The major conceptual difference is:
Legacy BIOS begins by executing boot code from the first disk sector, whereas UEFI can locate and execute an EFI program from a filesystem.
xv6 intentionally removes much of the complexity of a real PC boot process.
Instead of requiring you to understand:
UEFI
↓
EFI System Partition
↓
GRUB
↓
filesystem
↓
Linux
xv6 allows us to focus directly on the kernel.
QEMU is configured to load the xv6 kernel directly.
A typical xv6 configuration contains:
QEMU = qemu-system-riscv64
and:
QEMUOPTS = -machine virt \
-bios none \
-kernel $K/kernel \
-m 128M \
-smp $(CPUS) \
-nographic
There is also a virtual disk:
QEMUOPTS += -drive file=fs.img,if=none,format=raw,id=x0
QEMUOPTS += -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
When you modify an xv6 source file and run:
make qemu
the Makefile determines which source files need to be recompiled and linked.
Conceptually:
xv6 source files
↓
compiler
↓
object files
↓
linker
↓
kernel/kernel
↓
QEMU
↓
xv6 starts
The important distinction is:
make
builds the kernel and other required files.
qemu
runs the resulting kernel inside QEMU.
make qemu effectively combines these operations.
QEMU is a machine emulator/virtualizer capable of providing a virtual RISC-V machine.
The xv6 configuration tells QEMU:
-machine virt
Use the RISC-V virtual machine.
-bios none
Do not use a normal firmware/BIOS layer.
-kernel $K/kernel
Load the xv6 kernel directly.
-m 128M
Give the virtual machine 128 MB of memory.
-smp $(CPUS)
Create the requested number of virtual CPUs.
-nographic
Run without a graphical display and use the terminal.
Thus, unlike a normal modern PC:
UEFI → GRUB → Linux
xv6 uses:
QEMU
↓
directly load xv6 kernel
↓
kernel entry point
This simplification is intentional.
The xv6 linker script contains the kernel's memory layout.
The linker script is:
kernel/kernel.ld
It contains information that places the kernel at the appropriate address in the RISC-V virtual machine, including the familiar:
0x80000000
region.
The important point is that the linker script does not itself execute.
Instead:
kernel.ld
↓
used by linker
↓
determines kernel layout/addresses
↓
produces kernel/kernel
↓
QEMU loads kernel/kernel
So it is more accurate to say:
kernel.ld tells the linker how the kernel should be laid out in memory; QEMU then loads the resulting kernel image into the virtual machine according to the xv6/QEMU setup.
The kernel contains an assembly startup file:
kernel/entry.S
The entry symbol is:
_entry
The kernel's entry point is therefore the beginning of the low-level startup sequence.
Conceptually:
QEMU
↓
loads kernel
↓
CPU begins at kernel entry point
↓
_entry in entry.S
↓
start()
↓
main()
This is one of the most important chains to understand in xv6.
A simplified portion of xv6's startup code looks like:
la sp, stack0
li a0, 1024*4
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
call start
spin:
j spin
The first important job is to establish a stack.
The stack is necessary because the C code that follows needs a valid stack.
For multiple RISC-V CPUs (harts), xv6 gives each CPU its own stack region.
Conceptually:
stack0
├── CPU/hart 0 stack
├── CPU/hart 1 stack
├── CPU/hart 2 stack
└── ...
The mhartid CSR identifies the current CPU.
The calculation therefore selects the appropriate stack for that CPU.
Then:
call start
transfers execution into C code.
The early xv6 startup path can be viewed as:
QEMU
│
↓
kernel/kernel
│
↓
_entry
entry.S
│
│ set up stack
↓
start()
start.c
│
│ low-level CPU setup
↓
main()
main.c
│
│ kernel initialization
↓
operating kernel
This is the point where xv6 begins to resemble the architecture of a real operating system.
main() performs the major kernel initialization steps.
It initializes subsystems such as:
console
memory allocator
kernel page tables
process table
trap handling
interrupt handling
disk
filesystem
file table
process subsystem
The exact initialization order can be followed directly through:
kernel/main.c
This is an excellent file to study immediately after understanding entry.S and start.c.
The xv6 boot path is much smaller than a modern Linux boot path.
Compare:
REAL PC
────────────────────────────
UEFI
↓
EFI System Partition
↓
FAT32
↓
grubx64.efi
↓
GRUB
↓
filesystem
↓
vmlinuz
↓
Linux entry point
↓
Linux initialization
with:
XV6
────────────────────────────
QEMU
↓
kernel/kernel
↓
_entry
↓
start()
↓
main()
↓
kernel initialization
xv6 deliberately removes the firmware and sophisticated bootloader layers so that the kernel itself can be studied.
xv6 also has:
fs.img
This is a filesystem image stored as a file on the host machine's disk.
QEMU presents it to xv6 as a virtual disk:
Host machine
─────────────────────
fs.img
│
│ QEMU
↓
Virtual disk
│
↓
xv6 disk driver
│
↓
xv6 filesystem
Thus fs.img is not the kernel.
It contains the filesystem data that xv6 accesses as if it were a disk.
This is analogous to a physical disk containing a filesystem, except the disk is represented by a file on the host system.
At this point, the complete picture is:
HOST
│
make qemu
│
┌───────────┴───────────┐
↓ ↓
compile/link fs.img
│ │
↓ │
kernel/kernel │
│ │
└───────────┬───────────┘
↓
QEMU
│
┌───────────┴───────────┐
↓ ↓
load kernel virtual disk
│ │
↓ ↓
_entry fs.img
│
↓
start()
│
↓
main()
│
↓
kernel initialization
│
↓
running xv6
This is the foundation for everything that follows.
Once you understand:
QEMU
↓
_entry
↓
start()
↓
main()
you should follow the kernel initialization in main.c.
A useful progression is:
Study:
kernel/entry.S
kernel/start.c
kernel/main.c
kernel/kernel.ld
Makefile
Understand:
How QEMU starts xv6
Kernel entry point
Stack initialization
CPU/hart initialization
Kernel memory layout
Transition from assembly to C
Then study:
kalloc.c
vm.c
Understand:
physical memory
↓
allocator
↓
page tables
↓
virtual memory
Then study:
proc.c
proc.h
Understand:
process structure
↓
process table
↓
kernel stack
↓
address space
↓
context
↓
scheduler
Study:
trampoline.S
trap.c
syscall.c
sysproc.c
Understand:
User program
↓
system call
↓
CPU trap
↓
kernel
↓
syscall dispatcher
↓
specific sys_* function
Study:
fs.c
file.c
sysfile.c
bio.c
virtio_disk.c
fs.h
mkfs.c
Understand:
system call
↓
file layer
↓
inode
↓
filesystem
↓
buffer cache
↓
VirtIO disk
↓
fs.img
Finally, implement your own system call.
You will see that adding a system call requires connecting several layers:
User program
↓
user declaration
↓
system-call number
↓
syscall dispatcher
↓
sys_<your_call>()
↓
kernel functionality
This is where the earlier concepts start coming together.
The most useful mental model is:
BOOT
│
↓
CPU starts executing firmware/boot code
│
↓
BOOTLOADER
│
↓
KERNEL LOADED
│
↓
KERNEL ENTRY
│
↓
MEMORY INITIALIZATION
│
↓
PROCESS INITIALIZATION
│
↓
INTERRUPTS/TRAPS
│
↓
FILESYSTEM / DEVICES
│
↓
SCHEDULER
│
↓
USER PROCESSES
│
↓
SYSTEM CALLS
│
↓
RUNNING OPERATING SYSTEM
The real Linux boot process has many more layers:
UEFI
↓
grubx64.efi
↓
GRUB
↓
vmlinuz
↓
Linux entry
↓
Linux initialization
while xv6 intentionally compresses the early part:
QEMU
↓
kernel/kernel
↓
_entry
↓
start()
↓
main()
The goal of studying xv6 is not to memorize its source code. The goal is to understand how each layer of an operating system is constructed and how control moves from hardware, to kernel initialization, to processes, to system calls and finally to user programs.