← Back to list

Making an OS from scratch

Building a legacy x86 operating system with a VGA 80x25 display from the ground up

Ricarditomontserrat · 2026-03-12 15:35 · 0 claps · 8.8 min read
#os #scratch #legacy #vga #ms-dos
Open on Medium ↗

Making an OS from scratch

Building a legacy x86 operating system with a VGA 80x25 display from the ground up

If you’ve ever wondered how operating systems actually work under the hood, you’re not alone. Most of us use operating systems daily without ever thinking about what happens when we press the power button. In this article, I’ll walk you through building a simple operating system from scratch — one that runs on real x86 hardware (or emulators like QEMU), displays text on a classic VGA 80x25 screen, and gives you complete control over the machine.

This is reminiscent of how MS-DOS and FreeDOS worked — bare-metal programming where you directly manipulate hardware. Let’s dive in.

The Big Picture

Our OS follows a classic boot process:

  1. BIOS loads our boot sector (512 bytes) from disk
  2. Bootloader initializes the system and loads the kernel
  3. Kernel (written in C) takes over and provides the main functionality

The entire project uses x86 assembly for the low-level boot code, then switches to C for the kernel. But here’s the interesting part — I created a custom preprocessor called rasm (regex assembly) that lets me write assembly at a slightly higher level. We’ll see how this works throughout the article.

[embed]GitHub - Ronterox/TetoOS: An OS for Teto, written in Tetolang for Teto fans An OS for Teto, written in Tetolang for Teto fans. Contribute to Ronterox/TetoOS development by creating an account on…github.com

This github repo, contains the final result of this whole tutorial.

The Boot Sector

Every x86 system starts by executing code at memory address 0x7C00. This is where our bootloader lives. Let’s start with a simple string-printing function.

The High-Level View (rasm)

Here’s how we write printing in my custom rasm syntax:

print {

   loop {
     al = *bx ; Load character from bx pointer
     if al == 0 end ; Stop if null terminator

     ah = 0xE ; BIOS video interrupt function
     int 0x10 ; Call BIOS

     inc bx ; Next character
     jmp loop
   }

   end {
     popa ; Restore all registers
     ret ; Return to caller
   }

}

This looks almost like pseudocode, right? The *bx means “dereference the pointer in bx” (load the byte at that memory address). The if al == 0 end becomes a compare and conditional jump in real assembly.

The Real Assembly (asm)

Here’s what this actually compiles to:

print:
    pusha                     ; Save all registers

loop:
    mov al, [bx]             ; Load byte at bx
    cmp al, 0                ; Compare to null
    je end                   ; Jump if equal

    mov ah, 0xE              ; BIOS teletype function
    int 0x10                 ; Video interrupt

    inc bx                   ; Advance pointer
    jmp loop                 ; Repeat

end:
    popa                     ; Restore registers
    ret                      ; Return

Notice the pattern? Every “high-level” construct in rasm translates directly to assembly instructions. *bx becomes [bx], al = *bx becomes mov al, [bx], and if al == 0 end becomes cmp al, 0 / je end.

The Bootloader Entry Point

Our main bootloader file ties everything together:

[org 0x7c00]

KERNEL_OFFSET equ 0x1000

*BOOT_DRIVE = dl              ; Save boot drive number
bp = 0x8000                   ; Set up stack
sp = bp

print_hex(bx = *BOOT_DRIVE)   ; Print boot drive in hex
print(bx = MSG_REAL_MODE)     ; Display welcome message

load_kernel()                 ; Load kernel from disk
switch_to_pm()                ; Switch to 32-bit protected mode

jmp $                         ; Hang if something goes wrong

This sets up the stack, prints debug info, loads the kernel into memory at offset 0x1000, and switches to 32-bit protected mode. The jmp $ at the end is an infinite loop — “jump to current position” basically means “hang forever.”

Loading the Kernel from Disk

Reading from disk in 16-bit real mode requires calling BIOS interrupt 0x13. Here’s the rasm version:

disk_read {
    pusha                     ; Save registers
    clr cx                    ; cx = 0 (retry counter)
}

disk_loop {
    inc cx                    ; Increment attempt count

    ah = 0x2                  ; BIOS read sector function
    al = dh                   ; Number of sectors to read
    ch = 0                    ; Cylinder 0
    cl = 0x2                  ; Start from sector 2 (sector 1 is bootloader)
    dh = 0                    ; Head 0
    int 0x13                  ; Call disk BIOS

    if al != dh sectors_error ; Verify all sectors read
}

disk_done {
    popa
    ret
}

The compiled assembly:

disk_read:
    pusha
    xor cx, cx

disk_loop:
    inc cx

    mov ah, 0x2
    mov al, dh
    mov ch, 0
    mov cl, 0x2
    mov dh, 0

    int 0x13

    pop cx
    pop dx

    jc disk_error             ; Jump if carry flag set (error)
    cmp al, dh
    jne sectors_error

disk_done:
    popa
    ret

Switching to 32-Bit Protected Mode

This is where things get interesting. The x86 processor starts in real mode (16-bit, can only access 1MB of memory). To use all our memory and get more powerful instructions, we switch to 32-bit protected mode.

The GDT (Global Descriptor Table)

Flags from Wiki OsDev

Flags from Wiki OsDev

Flags from Wiki OsDev

Flags from Wiki OsDev

First, we need to set up a GDT — essentially a table that defines memory segments:

gdt_start:
    dd 0x0                    ; Null descriptor
    dd 0x0

; Code segment descriptor
gdt_code:
    dw 0xffff                 ; Limit (4GB)
    dw 0x0                    ; Base (low 16 bits)
    db 0x0                    ; Base (middle 8 bits)
    db 10011010b              ; Flags: present, ring 0, code
    db 11001111b              ; Flags: 4KB granularity, 32-bit
    db 0x0                    ; Base (high 8 bits)

; Data segment descriptor
gdt_data:
    dw 0xffff
    dw 0x0
    db 0x0
    db 10010010b              ; Flags: present, ring 0, data
    db 11001111b
    db 0x0

gdt_end:

gdt_descriptor:
    dw gdt_end - gdt_start - 1 ; GDT size
    dd gdt_start               ; GDT address

CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start

The Switch

Now we switch modes:

[bits 16]
switch_to_pm {
    cli                       ; Disable interrupts
    lgdt *gdt_descriptor      ; Load GDT

    eax = cr0                 ; Get control register 0
    eax |= 0x1                ; Set protected mode bit
    cr0 = eax                 ; Enable protected mode

    jmp CODE_SEG:init_pm      ; Far jump to set code segment
}

[bits 32]
init_pm {
    ax = DATA_SEG             ; Set up all segment registers
    ds = ax, ss = ax, es = ax, fs = ax, gs = ax
    begin_pm(ebp = 0x90000, esp = ebp)
}

Compiled:

[bits 16]
switch_to_pm:
    cli
    lgdt [gdt_descriptor]

    mov eax, cr0
    or eax, 0x1
    mov cr0, eax

    jmp CODE_SEG:init_pm

[bits 32]
init_pm:
    mov ax, DATA_SEG
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ebp, 0x90000
    mov esp, ebp
    call begin_pm

Notice how eax |= 0x1 becomes or eax, 0x1, and multiple assignments like ds = ax, ss = ax, es = ax expand to multiple mov instructions.

The Kernel

Once we’re in 32-bit mode, we can run C code! Here’s where the real fun begins. The kernel_entry.s file is just a tiny stub that calls our C main() function:

[bits 32]
[extern main]
call main
jmp $

Now let’s look at the C kernel. This is where we get VGA graphics working properly.

VGA Memory

The VGA text buffer lives at memory address 0xB8000. Each character takes 2 bytes: the character itself and its attributes (color):

#define VGA_MEMORY 0xB8000
#define MAX_COLS 80
#define MAX_ROWS 25
#define WHITE_ON_BLACK 0x0f

Writing to the Screen

Here’s how we print a character at a specific position:

void print_char(const char c, const uint16_t offset, const uint16_t color) {
    char *vga = (char *)VGA_MEMORY;
    vga[offset] = c;           // Character byte
    vga[offset + 1] = color;   // Attribute byte
}

Each character cell is 2 bytes — one for the ASCII character, one for the color. The color byte is split: lower 4 bits are the foreground color, upper 4 bits are the background.

Getting and Setting the Cursor

To write at the cursor position (or any position), we need to interact with the VGA hardware:

void outb(const uint16_t port, const uint16_t data) {
    __asm__("outb dx, al" : : "d"(port), "a"(data));
}

uint16_t get_cursor() {
    uint16_t position;
    outb(0x3d4, 14);              // VGA cursor high byte
    position = inb(0x3d5) << 8;
    outb(0x3d4, 15);              // VGA cursor low byte
    position += inb(0x3d5);
    return position;
}

The VGA controller has I/O ports at 0x3d4 and 0x3d5. We write command numbers (14 for high byte, 15 for low byte), then read/write the data.

Scrolling

When we reach the bottom of the screen, we need to scroll up:

void memcopy(const char *source, char *dest, const int nbytes) {
    for (size_t i = 0; i < nbytes; ++i)
        dest[i] = source[i];
}

// Inside kernel_print_at():
if (char_pos > MAX_VGA) {
    // Scroll each line up
    for (size_t i = 0; i < MAX_ROWS + 1; i++) {
        const char *source = (char *)VGA_MEMORY + i * MAX_COLS * 2;
        char *destination = (char *)VGA_MEMORY + (i - 1) * MAX_COLS * 2;
        memcopy(source, destination, MAX_COLS * 2);
    }
    offset -= MAX_COLS * 2;
}

This copies each line of video memory one row up, effectively scrolling the screen.

Integer to ASCII

Since we don’t have the C standard library, we need to implement our own conversion:

int int_to_ascii(const int n, char *str, const size_t i) {
    if (n < 10) {
        str[i] = n + '0';
        str[i + 1] = '\0';
        return i;
    }
    str[i] = (n % 10) + '0';
    return int_to_ascii(n / 10, str, i + 1);
}

void itoa(int n, char *str) {
    const size_t len = int_to_ascii(n, str, 0);
    // Reverse the string (recursive approach builds it backwards)
    for (size_t i = 0; i < len; ++i) {
        const char tmp = str[i];
        str[i] = str[len];
        str[len] = tmp;
    }
}

Main Kernel Function

Putting it all together:

int main() {
    clear_screen();                    // Clear to black

    for (size_t i = 0; i < 25; ++i) {  // Print numbers 0-24
        char str[255];
        itoa(i, str);    
        kprint_at("C://Home/User/ ", 0, i); // Print at column 0, row i
        kprint_at(str, 16, i);
    }

    // Force scrolling!
    kprint_at("This text forces scrolling. Row 0 will disappear. ", 60, 24);
    kprint("And with this text, row 1 disappears too!");
}

Building and Running

The Makefile ties everything together:

C_SOURCES = $(wildcard kernel/*.c)
OBJS = ${C_SOURCES:.c=.o}

CC = gcc
ASM = nasm
LINKER = ld -m elf_i386
QEMU = qemu-system-i386

CFLAGS = -masm=intel -g -m32 -ffreestanding -fno-pic

# $^ prerequisites
# $< first dependency
# $@ target

# first rule runs by default
os-image.bin: boot/bootloader.bin kernel.bin
 cat $^ > $@

run: os-image.bin
 ${QEMU} $<

kernel.bin: boot/kernel_entry.o ${OBJS}
 # kernel main at 0x1000 written on the linker.ld file
 ${LINKER} -o $@ -T linker.ld $^ --oformat binary

%.o: %.c
 ${CC} ${CFLAGS} -c $^ -o $@

%.o: %.asm
 ${ASM} $^ -f elf -l $<.lst -o $@ || (cat $<.lst | rg error -B 5; exit 1)

%.bin: %.asm
 ${ASM} $^ -f bin -l $<.lst -o $@ || (cat $<.lst | rg error -B 5; exit 1)

run: os-image.bin
    qemu-system-i386 $<

The bootloader compiles to a 512-byte binary (the boot sector), and the kernel compiles to a flat binary. We concatenate them together to get our OS image.

The linker file is pretty simple, notice the 0x1000 as the position of the kernel in memory:

ENTRY(main)
SECTIONS
{
    . = 0x1000; 
    .text : { *(.text) }
    .rodata : { *(.rodata*) }
    .data : { *(.data) }
    .bss  : { *(.bss) }
}

Run it with:

make run

And you’ll see rows 0–24 filled with numbers, then watch them scroll as more text is printed — exactly like the old DOS days!

Conclusion

We’ve built a complete, bootable operating system from scratch:

  • Bootloader (assembly): Sets up the system, loads kernel from disk
  • GDT & Mode Switch: Transitions from 16-bit real mode to 32-bit protected mode
  • Kernel: Full VGA text output with cursor management and scrolling

The rasm preprocessor demonstrates how high-level constructs map to low-level assembly — every line of “fancy” syntax becomes straightforward x86 instructions. This is what compilers do, just at a smaller scale.

This OS gives you a taste of what it was like to write MS-DOS or FreeDOS. From here, you could add:

  • Keyboard input
  • File system support
  • Memory management
  • Interrupts and timers

The journey to understanding operating systems starts with a single boot sector. Happy hacking!

References

[embed]GitHub - cfenollosa/os-tutorial: How to create an OS from scratch How to create an OS from scratch. Contribute to cfenollosa/os-tutorial development by creating an account on GitHub.github.com

[embed]VGA Hardware Even though VGA is old, many modern graphics cards are compatible with it, including NVidia and AMD (ATI) cards. This…wiki.osdev.org

[embed]Video Graphics Array - Wikipedia Video Graphics Array ( VGA) is a video display controller and accompanying de facto graphics standard, first introduced…en.wikipedia.org

https://web.archive.org/web/20211008041419/http://www.cs.bham.ac.uk/~exr/lectures/opsys/10_11/lectures/os-dev.pdf


메타데이터
post_id
c8755be9f4cc
slug
making-an-os-from-scratch-c8755be9f4cc
url
https://medium.com/@ricarditomontserrat/making-an-os-from-scratch-c8755be9f4cc
canonical_url
https://medium.com/@ricarditomontserrat/making-an-os-from-scratch-c8755be9f4cc
author_url
https://medium.com/@ricarditomontserrat
status
ok
fetched_at
2026-07-12 01:29:19