Kernel Project Map, Boot Flow, and Machine Contract

LESSON

Operating Systems Implementation

001 30 min intermediate

Kernel Project Map, Boot Flow, and Machine Contract

By the end of this lesson, you will be able to...

  • Define the machine assumptions a small kernel may rely on before its main code runs.

  • Trace a boot from machine entry to one observable kernel_ready state.

  • Review a boot design by checking ownership, evidence, failure boundaries, and portability costs.

Idea in one sentence: A kernel can start reliably only when the code and the machine agree on where execution begins, which state already exists, and who must create the rest.

Core Insight

Suppose you have compiled a small RISC-V kernel named kernel.elf. QEMU accepts the image. The terminal stays blank.

The kernel may have failed before reaching kernel_main. It may also be running correctly while writing to the wrong UART address. The CPU may have entered in a privilege mode your first instruction did not expect. The code may have called a function before creating a valid stack. Even a successful build says nothing about these boundaries.

The tempting plan is simple:

void kernel_main(void) {
    console_init();
    memory_init();
    scheduler_init();
}

But a normal C function already assumes a working execution environment. It expects a stack, a valid program counter, a matching instruction set, usable memory, and rules for returning or trapping. No operating system exists yet to provide those things.

The first design task is therefore not “write the scheduler.” It is “state the machine contract.”

The Promise We Need to Keep

For this lesson, the target is deliberately small:

Boot one RISC-V hart on QEMU's virt machine, establish the state required by C code, print one trustworthy readiness message, and stop in a known state.

A hart is a RISC-V hardware thread: one independent instruction-execution context. We will use one hart now. Multi-hart startup would introduce shared initialization and synchronization before we have built the tools to explain them.

Our chosen teaching contract is:

Boundary Decision for this kernel
Machine QEMU RISC-V virt platform
CPU One 64-bit RISC-V hart
Firmware No default firmware; the kernel owns the early handoff
Entry privilege Machine mode
Kernel load and link address Physical address 0x80000000
Early memory A fixed RAM range known to the build and launch command
Early device The virt UART for serial evidence
First symbol _start, written in assembly
First C function boot_main, called only after a stack exists
Success evidence A serial line plus a final known halt loop

These are not universal RISC-V facts. They are design decisions tied to one launch environment. If firmware enters the kernel in supervisor mode, or another board places RAM elsewhere, the contract changes.

That distinction is central:

Plain meaning:

The machine contract is the list of facts the kernel assumes are already true at entry.

In this scenario:

The kernel assumes one RISC-V hart starts at _start in machine mode, with the image linked where QEMU loaded it.

Technical name:

This is a boot protocol or machine handoff contract. It sits below the later user-kernel ABI. The machine contract gets the kernel running; the syscall ABI will later define how user programs ask the running kernel for work.

The Naive Design

A first project often jumps directly from the linker to C:

kernel.elf -> kernel_main() -> initialize everything

This picture hides every precondition.

Consider just one C call. The compiler may generate instructions that save a return address and local variables on the stack. If the stack pointer contains an arbitrary value, the first store can overwrite code, touch an unmapped address, or disappear into a device region.

Now consider a global variable:

static bool memory_ready;

C expects uninitialized static storage to begin as zero. That storage normally lives in the BSS section. If the boot path does not arrange or verify that zero state, memory_ready can appear true before memory initialization has happened.

The simple diagram also hides placement. A linked address is not a suggestion. If the linker emits references for code at 0x80000000 but the launcher places the image elsewhere, absolute addresses and early control flow can be wrong before the console works.

So the naive design fails because it treats hidden state as if it were guaranteed state.

Design Alternatives

There are several valid ways to divide boot responsibility.

Alternative A: the kernel enters directly in machine mode

The launch environment loads the image, then the kernel configures early privileged state itself.

This makes the teaching path short. The kernel can see the machine boundary directly. It also couples the kernel to more platform and privilege details.

Alternative B: firmware hands control to a supervisor-mode kernel

Firmware such as OpenSBI performs machine-mode setup and passes control to the kernel in supervisor mode. The handoff may include a hart identifier and a pointer to a device tree that describes memory and devices.

This separation is closer to many real RISC-V systems. It reduces what the kernel owns at the highest privilege level. It also means the kernel must obey a larger firmware contract before it can do anything useful.

Alternative C: use a richer boot environment

A bootloader or UEFI-like environment can load files, provide tables, and standardize parts of the handoff. That improves portability across machines that support the same protocol. It adds components, configuration, and failure surfaces.

For this small kernel, we choose Alternative A. The choice exposes the early mechanism with the fewest moving parts. It is a teaching trade-off, not a claim that production kernels should bypass firmware.

A Better Boundary

We can now split the project by responsibility:

linker script
  decides addresses and exports section boundaries

entry assembly: _start
  establishes state required before C is safe

boot code: boot_main
  validates the contract and prepares privileged execution

kernel initialization: kernel_main
  constructs kernel subsystems in dependency order

The linker script might express the placement boundary like this:

ENTRY(_start)

SECTIONS {
    . = 0x80000000;
    .text : { *(.text.entry) *(.text .text.*) }
    .rodata : { *(.rodata .rodata.*) }
    .data : { *(.data .data.*) }
    __bss_start = .;
    .bss : { *(.bss .bss.*) *(COMMON) }
    __bss_end = .;
}

The exact syntax matters less than the promises it makes:

The entry code then creates the minimum safe environment:

_start:
  disable interrupts
  read hart id
  park any hart other than hart 0
  set stack pointer to boot_stack_top
  clear memory from __bss_start to __bss_end
  call boot_main
  enter a known halt loop if boot_main returns

Notice the ownership rule. Assembly owns the state needed to call C. C must not quietly assume that assembly completed work that is missing from the contract.

Worked Boot Ledger

A boot ledger makes the handoff inspectable. Each row records who owns a transition, which state it consumes, what it creates, and how we can observe failure.

Stage Owner Input state State created Evidence or failure signal
0 QEMU virt launch arguments and kernel.elf CPU reset path, RAM, emulated devices, loaded image QEMU starts; debugger can inspect the reset vector
1 machine handoff reset state control reaches _start at the linked entry breakpoint at _start; wrong placement gives no valid entry
2 _start hart id and writable RAM one selected boot hart and a valid stack pointer stack address falls inside reserved boot-stack memory
3 _start linker-exported BSS bounds zeroed static storage sentinel globals have their specified zero value
4 boot_main safe C environment early serial output and checked machine assumptions boot: entry contract accepted appears once
5 privilege setup machine-mode control defined interrupt state and a deliberate next privilege boundary CSR inspection matches the design
6 kernel_main validated handoff allocator metadata and later subsystem state ordered log markers identify the last completed step
7 final boot check initialized minimum kernel kernel_ready plus a known idle or halt state output is stable and does not return through an invalid call path

Here is a compact implementation sketch:

void boot_main(void) {
    early_uart_init();
    early_puts("boot: entry contract accepted\n");

    require(current_hart_id() == 0);
    require((uintptr_t)_start == KERNEL_LINK_ADDRESS);
    require(bss_is_zero());

    prepare_privilege_transition();
    kernel_main();

    halt_with_reason("kernel_main returned");
}

The require checks do not prove the whole machine is correct. They convert some silent assumptions into visible failures. That is already a major improvement.

Now contrast two runs.

In the good run, the debugger reaches _start, the stack points into reserved memory, the BSS sentinel is zero, and the UART prints each marker once. The ledger reaches stage 7.

In a bad run, the debugger reaches _start, but no serial text appears. That evidence rules out “the CPU never executed the image.” It does not prove boot_main failed. The UART address, clock assumptions, or output code may be wrong. The next probe should inspect the program counter after early_uart_init or write a marker into known RAM.

So far, we have replaced “the screen is blank” with a sequence of owned transitions and testable claims. This matters because early boot has almost no services. The design must create its own evidence.

Check Your Understanding

Check: Why must _start set the stack before calling an ordinary C function?

Think first, then reveal.

Answer: The compiled function may immediately save registers or local data through the stack pointer. Without a valid reserved stack, the first memory access can corrupt another region or fault before any diagnostic path exists.

Check: A debugger stops at _start, but the serial console is blank. Which claim is supported?

Think first, then reveal.

Answer: The image is executing at least as far as _start. The evidence does not show that console initialization is correct or that boot_main was reached. Inspect the next transition instead of restarting the whole diagnosis.

Trade-offs and Limits

An explicit machine contract improves repeatability. It also creates coupling.

The boundary is visible when a new platform forces many unrelated boot-code changes. That signal suggests the contract is too implicit or too mixed with platform-specific code. A small platform adapter or a firmware handoff may then be worth the extra layer.

Common Confusions

Confusion: the emulator removes hardware assumptions

Why it is tempting:

QEMU feels like a controlled software process, so the guest machine can seem generic.

Better model:

QEMU emulates a specific board model. CPU features, RAM placement, UART addresses, interrupt controllers, and firmware options still form a machine contract.

Confusion: the linker decides where the image is loaded

Why it is tempting:

The linker script contains an address, so it appears to control placement.

Better model:

The linker assigns addresses inside the image. The launcher or firmware places the image. Both sides must agree.

Confusion: the boot contract is the syscall ABI

Why it is tempting:

Both are low-level interfaces involving registers and control transfer.

Better model:

The boot contract is between the machine or firmware and the kernel. The syscall ABI is between a running user program and a running kernel. Lesson 008 will build that later boundary.

Practice: Change the Handoff

Review this modified design:

Rewrite stages 1–5 of the boot ledger for this handoff.

A good answer should mention:

Resources

Key Takeaways

NEXT Privilege, Address Spaces, and Kernel Boundaries