Making a hello-world program in kernel space

Published: (April 27, 2026 at 03:26 PM EDT)
3 min read
Source: Dev.to

Source: Dev.to

Assumptions

  • Target: single‑core RV64I RISC‑V CPU with OpenSBI firmware.
  • OpenSBI is typically loaded at 0x80000000; everything below is reserved for MMIO devices.
  • To avoid overlapping OpenSBI code, load the kernel at an address such as 0x80200000.
  • The kernel is 2 MiB‑aligned, allowing the use of large (2 MiB) pages to minimise TLB misses.

MMIO (Memory‑mapped I/O) is the mechanism for communicating with external devices by reading/writing memory locations. RISC‑V prefers MMIO over port I/O.

Typical page sizes are 4 KiB, 2 MiB, and 1 GiB, enforced by the MMU. Large (2 MiB) pages are useful for reducing TLB pressure.

Preparing to jump into C code

We need a small assembly stub to set up the environment before calling C code.

/* entry.S */
.section .stack
.global stack_top
.balign 16                # 128‑bit alignment (required)
.space 16384             # 16 KiB stack
stack_top:

.section .text
.global _start
.extern stack_top
.extern kernel_main

.section .text.entry     # Place this function at the start of kernel memory
.balign 8                 # 64‑bit alignment
_start:
    # Zero out the .bss section, 32 bits at a time
    la t0, __bss_start
    la t1, __bss_end
bss_loop:
    bge t0, t1, bss_done
    sw zero, 0(t0)
    addi t0, t0, 4
    j bss_loop
bss_done:

    # Load the stack pointer
    la sp, stack_top

    call kernel_main      # Jump to C code

# If kernel_main returns, spin forever
spin:
    j spin

Linker script

/* linker.ld */
OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY
{
    RAM (rwxa)      : ORIGIN = 0x80200000, LENGTH = 1G   /* Usable memory */
    STACKRAM (rw)  : ORIGIN = 0xc0200000, LENGTH = 16K  /* Separate stack region */
}

SECTIONS
{
    . = 0x80200000;                     /* Load address */

    .text : {
        *(.text.entry)                  /* Ensure _start is first */
        *(.text .text.*)
    } > RAM

    .rodata : {
        . = ALIGN(16);
        *(.rodata .rodata.*)
    } > RAM

    .data : {
        . = ALIGN(16);
        *(.data .data.*)
    } > RAM

    .bss : {
        . = ALIGN(16);
        __bss_start = .;
        *(.bss .bss.*)
        *(COMMON)
        . = ALIGN(4);
        __bss_end = .;
    } > RAM

    .stack (NOLOAD) : {                /* Not loaded from the binary */
        *(.stack)
    } > STACKRAM
}

UART driver

/* uart.h */
typedef unsigned char uchar;

void uart_putc(uchar c);
void uart_puts(const uchar *s);
/* uart.c */
#include 
#include "uart.h"

#define UART_BASE   0x10000000   /* May vary between boards */
#define UART_THR    0x00         /* Transmit Holding Register (write) */
#define UART_LSR    0x05         /* Line Status Register (read) */
#define LSR_TX_IDLE 0x20

#define UART_REG(reg) ((volatile uint8_t *)(UART_BASE + (reg)))

void uart_putc(uchar c) {
    /* Wait until UART is ready */
    while ((*UART_REG(UART_LSR) & LSR_TX_IDLE) == 0)
        ;

    __asm__ volatile ("fence o, o" ::: "memory");
    *UART_REG(UART_THR) = c;
}

void uart_puts(const uchar *s) {
    while (*s) {
        uart_putc(*s);
        s++;
    }
}

Kernel entry point

/* main.c */
#include "uart.h"

void kernel_main(void) {
    uart_puts("Hello from Kernel World!");
}

Building and running

# Build
riscv64-unknown-elf-gcc main.c uart.c entry.S stack.S \
    -T linker.ld -o kernel.elf \
    -mcmodel=medany -ffreestanding -nostdlib -nostartfiles

# Run in QEMU
qemu-system-riscv64 -machine virt -cpu rv64 -m 2G \
    -nographic -bios default -kernel kernel.elf

Challenges for the reader

  • Implement a printf()‑style function for formatted output.
  • Add support for paging (set up page tables and enable virtual memory).
  • Write a basic fault handler to catch and report exceptions.

MIT License – feel free to use and modify this code.

0 views
Back to Blog

Related posts

Read more »