Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Programming

Bare-Metal Operating System Design with Forth and C Assembly

Build minimal OS kernel from bare metal. Use tumble forth c compiler to link Forth paradigm with C code. Write bootable system architecture now.

Dian Rijal Asyrof/August 22, 2026/7 min read
Illustration for Bare-Metal Operating System Design with Forth and C Assembly

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:

  1. The Data Stack (parameter_stack): Used to pass arguments between functions.
  2. 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 1b

This 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 -lgcc

We 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:stdio

The -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.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleOptimizing Streaming Text-to-Speech Models for Sub-50 Millisecond LatencyNext articleBuilding Operating System from Assembly with Tumble Forth
ForthArchitectureBare MetalHardwareDeveloper Tools
On this page↓
  1. Why Forth on Bare Metal?
  2. The Assembly Bootloader
  3. Implementing the Stacks in C
  4. The Dictionary Structure
  5. Core Forth Words in C
  6. Bare Metal I/O via Serial Port
  7. The Read-Eval-Print Loop (REPL)
  8. Connecting Assembly, C, and Forth
  9. Compiling and Running the Kernel

On this page

  1. Why Forth on Bare Metal?
  2. The Assembly Bootloader
  3. Implementing the Stacks in C
  4. The Dictionary Structure
  5. Core Forth Words in C
  6. Bare Metal I/O via Serial Port
  7. The Read-Eval-Print Loop (REPL)
  8. Connecting Assembly, C, and Forth
  9. Compiling and Running the Kernel

See also

Illustration for Building Operating System from Assembly with Tumble Forth
Programming/Aug 22, 2026

Building Operating System from Assembly with Tumble Forth

Build OS from assembly. Boot sequence uses tumble forth c compiler on Forth runtime. Write low-level system code. Control hardware.

7 min read
AssemblyForth
Illustration for Rust Glancer Cuts Language Server Memory Overhead by 100x
Programming/Aug 22, 2026

Rust Glancer Cuts Language Server Memory Overhead by 100x

Reduce IDE overhead. New index structures cut rust glancer lsp ram usage 100x. Run fast language server features on low-spec hardware.

6 min read
RustGlancer
Illustration for Fast Codebase Inspection and Structural Analysis with Rust Glancer
Programming/Aug 22, 2026

Fast Codebase Inspection and Structural Analysis with Rust Glancer

Analyze codebase structure fast with the rust glancer tool. Parse AST and run syntax checks to speed up repository inspection.

6 min read
RustGlancer