OhneBS

A custom, bare-metal operating system for the Raspberry Pi 3, built from scratch.

Operating System
C
ARM Assembly
Bare Metal

The "Bare Metal" Philosophy

To truly understand a system, you have to build it. OhneBS was born from a desire to strip away all abstractions and interact directly with the hardware. It's a journey to the first principles of computing, where every clock cycle and every byte of memory matters. This project is my proof of work for the belief that the deepest understanding comes from building, not just using.

Architecture & Project Structure

OhneBS adheres to the principle of modularization. The architecture is divided into distinct layers to ensure a clean separation of responsibilities, which is reflected in the physical directory structure.

Directory Tree

OhneBS/
├── build/
├── include/
│   ├── console.h, fb.h, gpio.h, mb.h, shell.h, ...
├── src/
│   ├── boot.S          # ARMv8 boot code
│   ├── kernel.c        # Main kernel entry and loop
│   ├── console.c       # Console abstraction layer
│   ├── fb.c            # Framebuffer driver
│   ├── gpio.c          # GPIO driver
│   ├── mb.c            # Mailbox driver for GPU communication
│   ├── shell.c         # Interactive user shell
│   └── uart.c          # UART serial driver
├── link.ld           # Linker script
└── Makefile.gcc      # Build system

Boot Process & Kernel Initialization

The Firmware Stage: The Black Box

The boot process begins with proprietary firmware on the Raspberry Pi's on-chip ROM. The VideoCore IV GPU starts first, loading bootcode.bin and then start.elf from the SD card. The start.elf firmware reads config.txt, initializes SDRAM, and finally loads our kernel image (kernel8.img) to physical address 0x80000. It then wakes up ARM Core 0 and sets its program counter to that address. This is the handoff point where OhneBS takes full control.

The Kernel Stage: From Assembly to C

Execution begins at the _start label in boot.S. The first tasks are critical low-level initializations performed in ARMv8 assembly.

  • Core Verification: The Multiprocessor Affinity Register (MPIDR_EL1) is read to ensure only the primary core (Core 0) proceeds with initialization. Other cores are put into a low-power wfe (Wait For Event) state.
  • Stack Initialization: The stack pointer (sp) is set to a valid memory region before any C functions can be called.
  • Clearing the BSS Section: The BSS section, containing uninitialized global and static variables, is manually zeroed out. The linker provides __bss_start and __bss_size symbols for this purpose.
  • Jump to C: With the environment prepared, the assembly code makes its final call using bl (Branch with Link) to the kernel_main function in our C code.

boot.S

// In boot.S
// Step 1: Core Verification
mrs     x1, mpidr_el1
and     x1, x1, #3        // Isolate core ID
cbz     x1, 2f            // If Core 0, continue
1:  wfe                   // Hang other cores
    b   1b
2:
// Step 2: Stack Initialization
ldr     x1, =_start
mov     sp, x1

// Step 3: Clearing the BSS Section
ldr     x1, =__bss_start
ldr     w2, =__bss_size
3:  cbz     w2, 4f
    str     xzr, [x1], #8   // Store 64-bit zero and increment
    sub     w2, w2, #1
    cbnz    w2, 3b

// Step 4: Jump to C
4:  bl      kernel_main

kernel.c

// In kernel.c
void kernel_main() {
    // 1. Initialize serial communication for debugging
    uart_init();

    // 2. Initialize the shell’s internal state
    shell_init();

    // 3. Initialize the framebuffer via mailbox calls to the GPU
    fb_init();

    // 4. Print a welcome message to the console (now both screen and UART)
    console_puts("Welcome to OhneBS! v0.2.0\n");

    // 5. Enter the main kernel loop
    while(1) {
        // Continuously poll the shell for updates (e.g., new user input)
        shell_update();
    }
}

Key Module Deep Dive

Mailbox Module for GPU Communication

To perform tasks like initializing the framebuffer, the ARM CPU must communicate with the VideoCore IV GPU. This is achieved via a mailbox message-passing system. The process involves preparing a 16-byte aligned message buffer in RAM, writing its memory address (combined with a channel number) to the MBOX_WRITE register, and then polling the MBOX_STATUS and MBOX_READ registers to wait for and verify the GPU's response.

mb.c - Core mailbox call

unsigned int mbox_call(unsigned char ch) {
    // Combine 28-bit address of our buffer with 4-bit channel
    unsigned int r = ((unsigned int)((long)&mbox) & ~0xF) | (ch & 0xF);

    // Wait until the mailbox is not full
    while (mmio_read(MBOX_STATUS) & MBOX_FULL);

    // Write our message address to the mailbox
    mmio_write(MBOX_WRITE, r);

    while (1) {
        // Wait until the mailbox has a reply
        while (mmio_read(MBOX_STATUS) & MBOX_EMPTY);

        // Check if the reply is for our message
        if (r == mmio_read(MBOX_READ)) {
            // Return success if the GPU response code is positive
            return mbox[1] == MBOX_RESPONSE;
        }
    }
    return 0;
}

Framebuffer and Console Abstraction

The framebuffer driver uses the mailbox interface to request a linear framebuffer from the GPU. The fb_init function sends a message with tags like MBOX_TAG_SETPHYWH (set resolution) and MBOX_TAG_GETFB (get framebuffer address/size). To support both graphical output and serial debugging, a console.c abstraction layer was introduced. It multiplexes all text output, sending each character to both the UART driver and the framebuffer driver, which renders it graphically.

GPIO Driver: Generic Register Access

The GPIO driver provides direct control over the Raspberry Pi's header pins. Instead of separate logic for each operation (set function, set pin, etc.), it uses a single, powerful helper function: gpio_call(). This function abstracts the complex math needed to find the correct memory-mapped register and bit position for any given pin and operation, performing a read-modify-write cycle. Public API functions like gpio_function() and gpio_set() are clean wrappers around this generic core.

gpio.c - Generic GPIO call function

unsigned int gpio_call(unsigned int pin_number, unsigned int value,
                         unsigned int base, unsigned int field_size) {
    unsigned int field_mask = (1 << field_size) - 1;
    
    unsigned int num_fields = 32 / field_size;
    unsigned int reg = base + ((pin_number / num_fields) * 4);
    unsigned int shift = (pin_number % num_fields) * field_size;

    // Read-modify-write operation
    unsigned int curval = mmio_read(reg);
    curval &= ~(field_mask << shift); // Clear the relevant bits
    curval |= value << shift;       // Set the new value
    mmio_write(reg, curval);
    
    return 1;
}

Interactive Shell

The shell is the primary user interface, driven by a polling-based architecture. The main kernel loop continuously calls shell_update(), which checks for new characters from UART. Input is buffered, echoed to the console, and processed upon receiving a carriage return. The command parser supports commands like help, version, set for variable management, and print for simple expression evaluation.

Curious about the source code?

View on GitHub