A custom, bare-metal operating system for the Raspberry Pi 3, built from scratch.
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.
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
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.
Execution begins at the _start
label in boot.S
. The first tasks are critical low-level initializations performed in ARMv8 assembly.
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.sp
) is set to a valid memory region before any C functions can be called.__bss_start
and __bss_size
symbols for this purpose.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();
}
}
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;
}
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.
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;
}
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.