The make qemu builds two main things:

kernel/kernel   = xv6 kernel binary
fs.img          = disk image containing /init, /sh, /ls, etc.

Then QEMU starts a fake RISC-V machine:

64-bit RISC-V
128 MB RAM
3 CPUs by default
UART console
virtio disk backed by fs.img

QEMU loads the kernel at:

0x80000000

and jumps to the first kernel instruction.

Boot path:

kernel/entry.S

Sets up a stack, then calls:

kernel/start.c

start.c prepares the RISC-V CPU: privilege mode, interrupts, timer, trap delegation. Then it jumps into:

kernel/main.c

main.c initializes the kernel:

console
printf
physical memory allocator
kernel page table
process table
trap handling
interrupt controller
buffer cache
inode table
file table
virtio disk
first user process

Then xv6 starts the scheduler.

The first process is not /init yet. It is tiny built-in code called initcode.

That tiny code runs in user mode and does:

exec("/init")

Now the kernel loads /init from fs.img.

That /init file exists because mkfs put user/_init into the disk image before boot.

Then:

user/init.c

runs. It opens the console and starts the shell:

exec("sh")

Then:

user/sh.c

runs, and you see the xv6 shell.

Compact flow:

make qemu

build kernel/kernel and fs.img

QEMU loads kernel at 0x80000000

entry.S sets stack

start.c prepares RISC-V supervisor mode

main.c initializes kernel subsystems

proc.c creates first process

scheduler runs initcode

initcode execs /init from fs.img

init starts sh

shell runs commands

That’s the boot sequence.

entry.S

  • Qemu puts this code into the kernel’s executable code section
  • The linker scripts puts this code at the beginning of the kernel image, at 0x80000000
  • the _entry code gives each CPU/hart its own stack, then call start()
  • Qemu does not know the name _entry. QEMU jumps to address 0x80000000; the linker made sure _entry lives there
.section .text
.global _entry
_entry:
        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

start.c

start is the bridge between machine mode (M-mode) and supervisor model (S-mode). When entry.S calls it, the CPU is in M-mode - the highest privilege on RISC-V, with unrestricted access to everything. The job of start() is to configure the hardware so that the kernel can run safely in S-model, then drop down to it.

#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "defs.h"

void main();
void timerinit();

__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

void start()
{
  // set M Previous Privilege mode to Supervisor, for mret.
  unsigned long x = r_mstatus();
  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;
  w_mstatus(x);

  // set M Exception Program Counter to main, for mret.
  // requires gcc -mcmodel=medany
  w_mepc((uint64)main);

  // disable paging for now.
  w_satp(0);

  // delegate all interrupts and exceptions to supervisor mode.
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE);

  // configure Physical Memory Protection to give supervisor mode
  // access to all of physical memory.
  w_pmpaddr0(0x3fffffffffffffull);
  w_pmpcfg0(0xf);

  // ask for clock interrupts.
  timerinit();

  // keep each CPU's hartid in its tp register, for cpuid().
  int id = r_mhartid();
  w_tp(id);

  // switch to supervisor mode and jump to main().
  asm volatile("mret");
}

// ask each hart to generate timer interrupts.
void
timerinit()
{
  // enable supervisor-mode timer interrupts.
  w_mie(r_mie() | MIE_STIE);

  // enable the sstc extension (i.e. stimecmp).
  w_menvcfg(r_menvcfg() | (1L << 63));

  // allow supervisor to use stimecmp and time.
  w_mcounteren(r_mcounteren() | 2);

  // ask for the very first timer interrupt.
  w_stimecmp(r_time() + 1000000);
}