Modern operating systems are massive. A standard Linux kernel contains millions of lines of code, making it almost impossible for a single engineer to understand the entire stack. If you want to learn how software interacts with hardware at the lowest level, you have to strip away the bloat.
We can build a bootable, interactive operating system by combining C with a minimal, stack-based language paradigm: Forth. We will call this implementation Tumble Forth. It is a system that fits entirely in your head, boots from bare metal, and gives you an interactive command line directly connected to the CPU.
Why Forth on Bare Metal?
Forth is not just a programming language. It is an interactive environment, a compiler, and a virtual machine rolled into one. Unlike C, which requires a separate compiler, linker, and loader to run code (though C compilers now support tail-call optimization), Forth can compile new functions (called "words") directly into memory while the system is running.
By implementing the core Forth engine in C and bootstrapping the hardware with a tiny assembly stub, we get the best of both worlds. C gives us structured control flow and easy memory mapping—though without the safety of the Rust borrow checker—while Forth gives us an interactive shell on the target hardware without needing a complex user-space environment.
The architecture relies on two stacks:
- The Data Stack (
parameter_stack): Used to pass arguments between functions. - The Return Stack (
return_stack): Used to keep track of function return addresses and loop counters.
This dual-stack design simplifies code generation. You do not need a complex register allocation algorithm. Every operation pulls its inputs from the stack and pushes its outputs back onto it.
The Assembly Bootloader
To run on bare metal, we need to satisfy the Multiboot specification. This allows bootloaders like GRUB to load our kernel. We write a small assembly file, boot.S, to set up the environment, initialize the stack pointer, and jump to our C code.
# Declare constants for the Multiboot header
.set ALIGN, 1<<0 # align loaded modules on page boundaries
.set MEMINFO, 1<<1 # provide memory map
.set FLAGS, ALIGN | MEMINFO # this is the Multiboot 'flag' field
.set MAGIC, 0x1BADB002 # 'magic number' lets bootloader find the header
.set CHECKSUM, -(MAGIC + FLAGS) # checksum of above, to prove we are multiboot
.section .multiboot
.align 4
.long MAGIC
.long FLAGS
.long CHECKSUM
.section .bss
.align 16
stack_bottom:
.skip 16384 # 16 KiB of stack space
stack_top:
.section .text
.global _start
.type _start, @function
_start:
# Set up the stack pointer
mov $stack_top, %esp
# Clear interrupts
cli
# Call the global constructors
# Jump to our C kernel entry point
call kernel_main
# Hang if kernel returns
1: hlt
jmp 1bThis assembly code does the bare minimum. It sets up a 16 KB stack in the BSS segment, disables interrupts to prevent unhandled hardware events from crashing the CPU, and calls kernel_main.
Implementing the Stacks in C
With the assembly wrapper in place, we can write the core virtual machine in C. We define the stacks and their pointers, adhering to variable naming best practices for clarity. We use simple arrays with bounds checking to prevent overflows during development.
#define STACK_SIZE 256
int data_stack[STACK_SIZE];
int data_stack_ptr = -1;
int return_stack[STACK_SIZE];
int return_stack_ptr = -1;
void push(int val) {
if (data_stack_ptr >= STACK_SIZE - 1) {
// Handle stack overflow
return;
}
data_stack[++data_stack_ptr] = val;
}
int pop(void) {
if (data_stack_ptr < 0) {
// Handle stack underflow
return 0;
}
return data_stack[data_stack_ptr-];
}
void r_push(int val) {
if (return_stack_ptr >= STACK_SIZE - 1) {
return;
}
return_stack[++return_stack_ptr] = val;
}
int r_pop(void) {
if (return_stack_ptr < 0) {
return 0;
}
return return_stack[return_stack_ptr-];
}These stack operations are the foundation of our virtual machine. Every arithmetic operation, variable lookup, and control flow decision will interact with these functions.
The Dictionary Structure
The heart of Tumble Forth is the dictionary. The dictionary is a linked list of words. Each word contains:
- A link to the previous word in the dictionary.
- A name string (up to 31 characters).
- A flag byte (for immediate words like loop structures).
- A pointer to the executable code.
typedef void (*xt_t)(void); // Execution token type
typedef struct word_t {
struct word_t *link;
char name[32];
unsigned char flags;
xt_t code;
} word_t;
// Pointer to the latest defined word
word_t *latest = 0;When the user types a word, the interpreter searches this linked list starting from latest back to the very first word. If it finds a match, it executes the function pointed to by code. If it does not find a match, it tries to parse the word as a number. If that fails, it prints an error.
Core Forth Words in C
We need to populate the dictionary with primitive words. These are the building blocks written in C that allow us to write higher-level logic in Forth itself.
void add_word(const char *name, xt_t code, unsigned char flags) {
// In a real kernel, we would use a simple bump allocator in a dedicated heap
static word_t dictionary_pool[1024];
static int pool_index = 0;
if (pool_index >= 1024) return;
word_t *new_word = &dictionary_pool[pool_index++];
new_word->link = latest;
new_word->flags = flags;
// Copy name safely
int i = 0;
while (name[i] && i < 31) {
new_word->name[i] = name[i];
i++;
}
new_word->name[i] = '\0';
new_word->code = code;
latest = new_word;
}
// Primitive: ADD (+)
void forth_add(void) {
int b = pop();
int a = pop();
push(a + b);
}
// Primitive: SUB (-)
void forth_sub(void) {
int b = pop();
int a = pop();
push(a - b);
}
// Primitive: DUP (Duplicate top of stack)
void forth_dup(void) {
int val = pop();
push(val);
push(val);
}
// Primitive: DROP (Discard top of stack)
void forth_drop(void) {
pop();
}
// Primitive: EMIT (Print character on stack)
void forth_emit(void) {
char c = (char)pop();
serial_write(c);
}We initialize these words during system startup:
void init_forth(void) {
add_word("+", forth_add, 0);
add_word("-", forth_sub, 0);
add_word("dup", forth_dup, 0);
add_word("drop", forth_drop, 0);
add_word("emit", forth_emit, 0);
}Bare Metal I/O via Serial Port
To interact with our OS, we need a way to read and write characters. The easiest way on x86 bare metal is using the serial port (COM1). It avoids the complexity of writing a full keyboard controller driver and VGA text mode renderer.
Here is a minimal serial port driver:
#define PORT 0x3f8 /* COM1 */
static inline unsigned char inb(unsigned short port) {
unsigned char ret;
asm volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
static inline void outb(unsigned short port, unsigned char val) {
asm volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}
void init_serial(void) {
outb(PORT + 1, 0x00); // Disable all interrupts
outb(PORT + 3, 0x80); // Enable DLAB (set baud rate divisor)
outb(PORT + 0, 0x03); // Set divisor to 3 (lo byte) 38400 baud
outb(PORT + 1, 0x00); // (hi byte)
outb(PORT + 3, 0x03); // 8 bits, no parity, one stop bit
outb(PORT + 2, 0xC7); // Enable FIFO, clear them, with 14-byte threshold
outb(PORT + 4, 0x0B); // IRQs enabled, RTS/DSR set
}
int is_transmit_empty(void) {
return inb(PORT + 5) & 0x20;
}
void serial_write(char a) {
while (is_transmit_empty() == 0);
outb(PORT, a);
}
int serial_received(void) {
return inb(PORT + 5) & 1;
}
char serial_read(void) {
while (serial_received() == 0);
return inb(PORT);
}This code directly interacts with the CPU I/O space using inline assembly. The inb and outb instructions read and write from hardware ports. We configure COM1 for 38400 baud, which is compatible with modern virtual machines like QEMU.
The Read-Eval-Print Loop (REPL)
Now we need an interpreter loop that reads input from the serial port, splits it into words, and either executes them or compiles them.
#define BUFFER_SIZE 128
char line_buffer[BUFFER_SIZE];
int buffer_idx = 0;
void read_line(void) {
buffer_idx = 0;
while (1) {
char c = serial_read();
// Handle backspace
if (c == 127 || c == '\b') {
if (buffer_idx > 0) {
buffer_idx-;
serial_write('\b');
serial_write(' ');
serial_write('\b');
}
continue;
}
// Handle newline
if (c == '\r' || c == '\n') {
line_buffer[buffer_idx] = '\0';
serial_write('\r');
serial_write('\n');
break;
}
if (buffer_idx < BUFFER_SIZE - 1) {
line_buffer[buffer_idx++] = c;
serial_write(c);
}
}
}Once we have a line of text, we parse it word by word. Spaces are the only delimiters.
int parse_number(const char *str, int *result) {
int val = 0;
int sign = 1;
int i = 0;
if (str[0] == '-') {
sign = -1;
i++;
}
if (str[i] == '\0') return 0; // Not a number
while (str[i]) {
if (str[i] < '0' || str[i] > '9') return 0;
val = val * 10 + (str[i] - '0');
i++;
}
*result = val * sign;
return 1;
}
void interpret(char *line) {
char word[32];
int line_ptr = 0;
while (line[line_ptr]) {
// Skip whitespace
while (line[line_ptr] == ' ' || line[line_ptr] == '\t') {
line_ptr++;
}
if (line[line_ptr] == '\0') break;
// Extract word
int w_ptr = 0;
while (line[line_ptr] && line[line_ptr] != ' ' && line[line_ptr] != '\t') {
if (w_ptr < 31) {
word[w_ptr++] = line[line_ptr];
}
line_ptr++;
}
word[w_ptr] = '\0';
// Search dictionary
word_t *curr = latest;
int found = 0;
while (curr) {
// Simple string compare
int match = 1;
int i = 0;
while (word[i] || curr->name[i]) {
if (word[i] != curr->name[i]) {
match = 0;
break;
}
i++;
}
if (match) {
curr->code();
found = 1;
break;
}
curr = curr->link;
}
if (!found) {
// Try parsing as number
int num;
if (parse_number(word, &num)) {
push(num);
} else {
// Print error
serial_write('?');
serial_write(' ');
for (int i = 0; word[i]; i++) {
serial_write(word[i]);
}
serial_write('\r');
serial_write('\n');
return;
}
}
}
// Print OK prompt
serial_write(' ');
serial_write('o');
serial_write('k');
serial_write('\r');
serial_write('\n');
}This interpreter handles basic stack manipulation and arithmetic. If you type 5 10 + dup emit, the system pushes 5 and 10 to the stack, adds them, duplicates the result (15), and prints the ASCII character corresponding to 15 to the serial console.
Connecting Assembly, C, and Forth
To link everything together, our C entry point initializes the hardware, registers the core words, and starts the REPL loop.
void kernel_main(void) {
init_serial();
init_forth();
// Print welcome banner
const char *banner = "Tumble Forth Kernel v0.1 Booted\r\n";
for (int i = 0; banner[i]; i++) {
serial_write(banner[i]);
}
while (1) {
read_line();
interpret(line_buffer);
}
}We need a linker script to position our code correctly in the final binary. GRUB expects the Multiboot header to be within the first 8 KB of the file.
ENTRY(_start)
SECTIONS
{
. = 1M;
.text BLOCK(4K) : ALIGN(4K)
{
*(.multiboot)
*(.text)
}
.rodata BLOCK(4K) : ALIGN(4K)
{
*(.rodata)
}
.data BLOCK(4K) : ALIGN(4K)
{
*(.data)
}
.bss BLOCK(4K) : ALIGN(4K)
{
*(COMMON)
*(.bss)
}
}This linker script loads the kernel at the 1 MB mark, which is standard for x86 kernels. It ensures the Multiboot header is placed at the very beginning of the .text section.
Compiling and Running the Kernel
To build the kernel, we use GCC configured for bare-metal targets. We must disable standard library headers and target-specific optimizations that assume an operating system is already running.
# Compile assembly
i686-elf-as -32 boot.S -o boot.o
# Compile C files with strict bare-metal flags
i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
# Link the kernel binary
i686-elf-gcc -T linker.ld -o myos.bin -ffreestanding -O2 -nostdlib boot.o kernel.o -lgccWe can run the compiled binary using QEMU. We redirect the guest's serial port to our host terminal so we can type directly into the Forth REPL.
qemu-system-i386 -kernel myos.bin -nographic -serial mon:stdioThe -nographic flag combined with -serial mon:stdio multiplexes the QEMU monitor and the virtual machine's serial port into your terminal. You can interact with your custom kernel in real-time, executing stack operations directly on the bare-metal emulator.



