Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

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

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.

Dian Rijal Asyrof/August 22, 2026/7 min read
Illustration for Building Operating System from Assembly with Tumble Forth

Added internal links to article.


Modern operating system development is buried under toolchain bloat. If you want to write a simple kernel today, the standard advice is to configure a cross-compiler, write a linker script, set up GRUB, and compile hundreds of lines of C boilerplate just to get a screen cleared.

You can bypass this entire stack. By building a minimal Forth runtime directly in assembly-which we will call Tumble Forth-you get a self-hosting environment that runs on bare metal. From there, you can write a tiny, single-pass C compiler directly in Forth words. No GCC, no GNU binutils, and no linker scripts required. Just raw x86_64 assembly and a interactive terminal running over a serial port.

Here is how to build this system from the boot sector up.

The Boot Sequence

The PC boot process starts in 16-bit real mode. The BIOS loads the first 512-byte sector of our boot medium into physical memory address 0x7C00 and jumps to it. Our bootloader must transition the processor from 16-bit real mode, through 32-bit protected mode, and finally into 64-bit long mode.

First, we define the boot sector structure and initialize the segment registers.

[org 0x7c00]
[bits 16]
 
start:
    cli
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7c00
 
    ; Clear screen
    mov ax, 0x0003
    int 0x10
 
    ; Check for long mode compatibility
    call check_cpuid
    call check_long_mode
 
    ; Set up page tables
    call setup_page_tables
 
    ; Enter long mode
    call switch_to_long_mode
 
    jmp $ ; Hang if we return

To enter 64-bit long mode, we must configure page tables. We will map the first 1 gigabyte of physical memory using 2-megabyte pages. This identity mapping allows our kernel to access physical memory addresses directly. We allocate space for our page tables at address 0x1000.

setup_page_tables:
    ; Zero out the page table space (4 pages = 16KB)
    mov edi, 0x1000
    mov cr3, edi
    xor eax, eax
    mov ecx, 4096
    rep stosd
 
    ; Re-initialize EDI to start of PML4
    mov edi, 0x1000
 
    ; PML4 points to PDPT
    ; Set present and writable flags (0x3)
    mov dword [edi], 0x2003
    
    ; PDPT points to Page Directory
    mov dword [edi + 0x1000], 0x3003
 
    ; Page Directory points to 2MB pages
    ; Set present, writable, and page size flags (0x83)
    mov edi, 0x3000
    mov eax, 0x00000083
    mov ecx, 512
 
.map_pages:
    mov [edi], eax
    add eax, 0x200000 ; Move to next 2MB page
    add edi, 8
    loop .map_pages
    ret

Now we define a minimal Global Descriptor Table (GDT) to describe the 64-bit code and data segments.

align 8
gdt64:
    dq 0 ; Null descriptor
.code: equ $ - gdt64
    dq (1<<43) | (1<<44) | (1<<47) | (1<<53) ; 64-bit Code segment
.data: equ $ - gdt64
    dq (1<<41) | (1<<44) | (1<<47) ; 64-bit Data segment
.pointer:
    dw $ - gdt64 - 1
    dq gdt64

With paging configured and the GDT defined, we enable Physical Address Extension (PAE), load the GDT, enable long mode in the Extended Feature Enable Register (EFER), and toggle paging on.

switch_to_long_mode:
    ; Enable PAE
    mov eax, cr4
    or eax, 1 << 5
    mov cr4, eax
 
    ; Load GDT
    lgdt [gdt64.pointer]
 
    ; Enable Long Mode in EFER MSR
    mov ecx, 0xC0000080
    rdmsr
    or eax, 1 << 8
    wrmsr
 
    ; Enable Paging and Protected Mode
    mov eax, cr0
    or eax, 0x80000001
    mov cr0, eax
 
    ; Jump to 64-bit code segment
    jmp gdt64.code:long_mode_entry
 
[bits 64]
long_mode_entry:
    mov ax, gdt64.data
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
 
    ; Set stack pointers
    mov rsp, 0x90000  ; Return stack pointer
    mov r12, 0x80000  ; Data stack pointer
 
    ; Jump to Forth entry point
    jmp forth_cold

The Tumble Forth Runtime

Forth uses two stacks: a parameter (data) stack and a return stack. We map the hardware stack pointer RSP to the return stack, which handles function call return addresses. We map register R12 to the data stack pointer.

To optimize performance, we keep the top value of the data stack in register RAX. This register allocation scheme avoids memory access overhead for simple operations.

We use Direct Threaded Code (DTC). In DTC, a compiled word contains a list of addresses pointing directly to machine code or other compiled words. The instruction pointer RSI tracks our position in the compiled code.

The inner interpreter is a single assembly instruction that fetches the next address and jumps to it.

macro NEXT {
    lodsq
    jmp [rax]
}

Every primitive word ends by executing this macro. Let's write the core data stack manipulation words in x86_64 assembly.

; DUP ( x - x x )
forth_dup:
    dq .code
.code:
    sub r12, 8
    mov [r12], rax
    NEXT
 
; DROP ( x - )
forth_drop:
    dq .code
.code:
    mov rax, [r12]
    add r12, 8
    NEXT
 
; SWAP ( x y - y x )
forth_swap:
    dq .code
.code:
    mov rdx, [r12]
    mov [r12], rax
    mov rax, rdx
    NEXT
 
; OVER ( x y - x y x )
forth_over:
    dq .code
.code:
    mov rdx, [r12]
    sub r12, 8
    mov [r12], rax
    mov rax, rdx
    NEXT
 
; + ( n1 n2 - sum )
forth_add:
    dq .code
.code:
    add rax, [r12]
    add r12, 8
    NEXT

We also need memory access primitives to read and write physical addresses. In Forth, these are @ (fetch) and ! (store).

; @ ( addr - val )
forth_fetch:
    dq .code
.code:
    mov rax, [rax]
    NEXT
 
; ! ( val addr - )
forth_store:
    dq .code
.code:
    mov rdx, [r12]
    add r12, 8
    mov [rax], rdx
    mov rax, [r12]
    add r12, 8
    NEXT

Hardware Communication

To make the system interactive, we need serial port communication. We write primitives to read and write bytes using x86 in and out instructions. The standard serial port COM1 maps to port 0x3F8.

; KEY ( - char )
forth_key:
    dq .code
.code:
    sub r12, 8
    mov [r12], rax
.wait_rx:
    mov dx, 0x3FD ; Line Status Register
    in al, dx
    test al, 1
    jz .wait_rx
    mov dx, 0x3F8 ; Data Register
    in al, dx
    movzx rax, al
    NEXT
 
; EMIT ( char - )
forth_emit:
    dq .code
.code:
    mov rbx, rax
.wait_tx:
    mov dx, 0x3FD
    in al, dx
    test al, 0x20
    jz .wait_tx
    mov dx, 0x3F8
    mov al, bl
    out dx, al
    mov rax, [r12]
    add r12, 8
    NEXT

The Dictionary and Interpreter

A Forth word header consists of:

  1. A link pointer to the previous word in the dictionary.
  2. A length byte plus the name characters.
  3. The code field pointer.

We define a macro in assembly to construct these headers.

%define link 0
 
%macro defword 3
    align 8
    %%link: dq link
    %define link %%link
    db %2
    db %1
    forth_%3:
%endmacro

We can now define our dictionary words in assembly, building up to the outer interpreter loop. The outer interpreter reads whitespace-delimited words from the serial port, searches the dictionary, and either executes them or parses them as numbers.

defword "KEY", 3, key
defword "EMIT", 4, emit
defword "+", 1, add
defword "DUP", 3, dup
defword "DROP", 4, drop

The lookup routine compares the input string against the dictionary links. If found, it returns the execution token (XT). If not, it parses the string as a base-10 or base-16 number.

; FIND ( addr len - xt | 0 )
forth_find:
    dq .code
.code:
    mov rcx, rax ; length
    mov rsi, [r12] ; string address
    mov rdx, link ; start at latest word
.loop:
    test rdx, rdx
    jz .not_found
    
    ; Compare length
    movzx rbx, byte [rdx + 8]
    cmp bl, cl
    jne .next_word
 
    ; Compare string bytes
    lea rdi, [rdx + 9]
    push rsi
    push rcx
    repe cmpsb
    pop rcx
    pop rsi
    je .found
 
.next_word:
    mov rdx, [rdx]
    jmp .loop
 
.found:
    pop rbx ; clean stack
    ; Calculate XT address (aligned to 8 bytes after name)
    add rdx, 9
    add rdx, rcx
    add rdx, 7
    and rdx, ~7
    mov rax, rdx
    NEXT
 
.not_found:
    add r12, 8
    xor rax, rax
    NEXT

With FIND and basic text parsing, we can write the main outer interpreter loop in Forth itself. We compile the loop into our dictionary.

Compiling C from Forth

Writing a full ANSI C compiler on bare metal is a massive task. However, we can write a compiler for a subset of C (let's call it "Sub-C") in about 200 lines of Forth.

Our C compiler will compile code on the fly. It reads C tokens and emits x86_64 machine code directly into the dictionary memory space. We will support:

  • Local and global variables.
  • Basic arithmetic (+, -, *, /).
  • Control flow (if, else, while).
  • Function declarations and calls.

Since Forth has direct access to the compiling pointer HERE (which tracks the current free memory location), we can write machine code bytes directly to memory. We define a helper word C, to write a byte and increment HERE.

: C, ( char - ) HERE C! 1 HERE +! ;
: , ( val - ) HERE ! 8 HERE +! ;

Parsing C Expressions

We parse expressions using a simple top-down parser. Because we want to emit code immediately, we use a single-pass design.

When the compiler encounters a number, it emits an x86 push instruction. When it encounters a variable (ideally named using variable naming best practices), it calculates the offset and emits a load instruction.

Let's define the machine code patterns we need:

\ Push constant to CPU stack: push imm32 (0x68 followed by 4 bytes)
: emit-push-imm ( n - )
    104 C, \ 0x68
    dup C, 8 rshift dup C, 8 rshift dup C, 8 rshift C, ;
 
\ Add top two elements on CPU stack: pop rax; add [rsp], rax
: emit-add ( - )
    90 C, \ pop rax (0x58)
    72 C, 1 C, 36 C, \ add [rsp], rax (0x01 0x04 0x24)
    ;

We can build a parser loop that reads tokens and matches operators.

: parse-expression ( - )
    next-token
    is-number? IF
        token-value emit-push-imm
    ELSE
        find-variable IF
            emit-load-variable
        THEN
    THEN
    next-token
    dup [char] + = IF
        parse-expression emit-add
    THEN
    ;

Control Flow and Patches

Control flow requires backpatching jump destinations. While we use standard conditional jumps here, modern processors often benefit from eliminating them entirely, a concept explored in branchless Rust optimization. When we compile a while loop, we record the start address. When we parse the closing brace }, we emit a jump instruction back to the start, and patch the conditional exit jump to point to the address after the loop.

We define a structure to track these patch locations on the Forth parameter stack.

: c-if ( - patch-addr )
    \ Emit comparison and jump-if-zero (JZ) with dummy offset
    131 C, 248 C, 0 C, \ cmp eax, 0
    15 C, 132 C, \ JZ (0x0F 0x84)
    HERE 0 , \ Save address for patch and emit 8-byte dummy
    ;
 
: c-else ( patch-addr1 - patch-addr2 )
    \ Emit unconditional jump (JMP)
    233 C, HERE 0 , \ JMP with dummy offset
    swap
    HERE swap ! \ Patch first jump to point here
    ;
 
: c-then ( patch-addr - )
    HERE swap ! \ Patch jump to point to current location
    ;

Parsing Function Declarations

When the compiler sees void name() { ... }, it adds name to the Forth dictionary. The definition of name will point to the current code generation address (HERE).

: parse-function ( - )
    expect-token void
    get-name-token
    create-dictionary-entry
    expect-token {
    BEGIN
        parse-statement
        current-token [char] } =
    UNTIL
    \ Emit ret instruction (0xC3)
    195 C,
    ;

Running the OS

To build the bootloader and kernel, we assemble the boot sector and the assembly primitives using nasm, then write them to a raw disk image.

nasm -f bin boot.asm -o boot.bin
nasm -f bin kernel.asm -o kernel.bin
cat boot.bin kernel.bin > os.img

We run the OS using QEMU, redirecting the virtual serial port to our host terminal.

qemu-system-x86_64 -drive format=raw,file=os.img -serial stdio

When the system boots, you receive a prompt over the serial line. You are now inside the Tumble Forth environment.

You can interactively define new hardware drivers:

: clear-screen ( - )
    0xB8000 ( VGA video memory )
    80 25 * 0 DO
        32 over I 2 * + C! ( Space character )
        15 over I 2 * + 1+ C! ( White text on black )
    LOOP
    drop ;

You can then load your C compiler code and compile C functions directly into the same memory space:

void hello() {
    print_string("Hello from compiled C code on bare metal!");
}

The transition from assembly to Forth, and from Forth to C, happens entirely in memory without a linker or loader. The system is live, customizable, and completely self-contained.

DR

Dian Rijal Asyrof

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

Previous articleBare-Metal Operating System Design with Forth and C Assembly
AssemblyForthBootloaderDeveloper ToolsHardware
On this page↓
  1. The Boot Sequence
  2. The Tumble Forth Runtime
  3. Hardware Communication
  4. The Dictionary and Interpreter
  5. Compiling C from Forth
  6. Parsing C Expressions
  7. Control Flow and Patches
  8. Parsing Function Declarations
  9. Running the OS

On this page

  1. The Boot Sequence
  2. The Tumble Forth Runtime
  3. Hardware Communication
  4. The Dictionary and Interpreter
  5. Compiling C from Forth
  6. Parsing C Expressions
  7. Control Flow and Patches
  8. Parsing Function Declarations
  9. Running the OS

See also

Illustration for Bare-Metal Operating System Design with Forth and C Assembly
Programming/Aug 22, 2026

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.

7 min read
ForthArchitecture
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