Making a hello-world program in kernel space
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.