Project Overview
Capstone Goal: Build a minimal OS that boots from BIOS, enters protected mode, handles keyboard interrupts, displays text on screen, and runs a simple shell. This combines everything from Parts 0-23!
Architecture
Mini OS Components
mini-os/
├── boot.asm # Stage 1 bootloader (MBR)
├── loader.asm # Stage 2 loader (protected mode)
├── kernel.asm # Kernel entry point
├── idt.asm # Interrupt Descriptor Table
├── keyboard.asm # PS/2 keyboard driver
├── vga.asm # VGA text mode output
├── shell.asm # Command shell
├── linker.ld # Linker script
└── Makefile # Build automation
Stage 1: Bootloader
; boot.asm - First stage bootloader (512 bytes)
[BITS 16]
[ORG 0x7C00]
start:
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
; Display boot message
mov si, msg_boot
call print_string
; Load stage 2 from disk
mov ah, 0x02 ; BIOS read sectors
mov al, 4 ; Read 4 sectors
mov ch, 0 ; Cylinder 0
mov cl, 2 ; Start at sector 2
mov dh, 0 ; Head 0
mov bx, 0x7E00 ; Load address
int 0x13
jc .disk_error
; Jump to stage 2
jmp 0x0000:0x7E00
.disk_error:
mov si, msg_error
call print_string
hlt
print_string:
mov ah, 0x0E
.loop:
lodsb
test al, al
jz .done
int 0x10
jmp .loop
.done:
ret
msg_boot: db "Mini OS Booting...", 13, 10, 0
msg_error: db "Disk Error!", 0
times 510 - ($ - $$) db 0
dw 0xAA55
Stage 2: Protected Mode Entry
Protected mode gives us 32-bit addressing, memory protection, and paging. The key is setting up a Global Descriptor Table (GDT).
GDT Layout for Mini OS:
┌─────────┬─────────┬─────────┬──────────┐
│ NULL │ Code │ Data │ Limit │
│ (0x00) │ (0x08) │ (0x10) │ │
└─────────┴─────────┴─────────┴──────────┘
Segment selectors: Code = 0x08, Data = 0x10
; loader.asm - Stage 2: Protected mode entry
[BITS 16]
[ORG 0x7E00]
loader_start:
; Print entering protected mode message
mov si, msg_pm
call print_string_16
; Enable A20 line (fast method)
in al, 0x92
or al, 2
out 0x92, al
; Load GDT
cli
lgdt [gdt_descriptor]
; Set PE (Protection Enable) bit in CR0
mov eax, cr0
or eax, 1
mov cr0, eax
; Far jump to flush pipeline and enter 32-bit code
jmp 0x08:protected_mode
print_string_16:
mov ah, 0x0E
.loop:
lodsb
test al, al
jz .done
int 0x10
jmp .loop
.done:
ret
msg_pm: db "Entering protected mode...", 13, 10, 0
; ===== GDT =====
align 8
gdt_start:
; NULL descriptor (required)
dq 0
; Code segment: base=0, limit=4GB, 32-bit, ring 0
gdt_code:
dw 0xFFFF ; Limit low (0-15)
dw 0x0000 ; Base low (0-15)
db 0x00 ; Base middle (16-23)
db 10011010b ; Access: Present, Ring 0, Code, Executable, Readable
db 11001111b ; Flags: 4KB granularity, 32-bit + Limit high (16-19)
db 0x00 ; Base high (24-31)
; Data segment: base=0, limit=4GB, 32-bit, ring 0
gdt_data:
dw 0xFFFF ; Limit low
dw 0x0000 ; Base low
db 0x00 ; Base middle
db 10010010b ; Access: Present, Ring 0, Data, Writable
db 11001111b ; Flags: 4KB granularity, 32-bit + Limit high
db 0x00 ; Base high
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; Size - 1
dd gdt_start ; Address
; ===== 32-bit Protected Mode =====
[BITS 32]
protected_mode:
; Setup segment registers
mov ax, 0x10 ; Data segment selector
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x90000 ; Stack below 1MB
; Clear screen
call clear_screen_32
; Print welcome message
mov esi, msg_welcome
mov edi, 0xB8000 ; VGA buffer
mov ah, 0x0F ; White on black
call print_string_32
; Jump to kernel
jmp 0x10000 ; Kernel loaded here
clear_screen_32:
mov edi, 0xB8000
mov ecx, 80*25
mov ax, 0x0720 ; Space, gray on black
rep stosw
ret
print_string_32:
.loop:
lodsb
test al, al
jz .done
stosw
jmp .loop
.done:
ret
msg_welcome: db "Welcome to Mini OS [32-bit Protected Mode]", 0
times 2048 - ($ - $$) db 0 ; Pad to 4 sectors
Stage 3: Interrupt Handling
The Interrupt Descriptor Table (IDT) tells the CPU where to jump when interrupts occur—hardware events (keyboard, timer) and software exceptions (divide by zero, page fault).
IDT Entry (8 bytes each):
┌──────────┬──────────┬────────┬────────┐
│ Offset │ Selector │ Type │ Offset │
│ 0-15 │ 16-31 │ Attr │ 16-31 │
└──────────┴──────────┴────────┴────────┘
Important Interrupts:
0: Divide Error 8: Double Fault
13: General Protection 14: Page Fault
32: Timer (IRQ0) 33: Keyboard (IRQ1)
; idt.asm - Interrupt Descriptor Table setup
[BITS 32]
section .data
; IDT - 256 entries * 8 bytes = 2048 bytes
align 8
idt_start:
times 256 dq 0 ; Initialize to zero
idt_end:
idt_descriptor:
dw idt_end - idt_start - 1
dd idt_start
; ISR stubs array for easy lookup
isr_stubs:
dd isr0, isr1, isr2, isr3, isr4, isr5, isr6, isr7
dd isr8, isr9, isr10, isr11, isr12, isr13, isr14, isr15
dd isr16, isr17, isr18, isr19, isr20, isr21, isr22, isr23
dd isr24, isr25, isr26, isr27, isr28, isr29, isr30, isr31
dd irq0, irq1 ; Hardware interrupts 32-33
section .text
; Setup one IDT entry
; EAX = interrupt number, EBX = handler address
set_idt_entry:
push edi
mov edi, idt_start
shl eax, 3 ; * 8 bytes per entry
add edi, eax
mov word [edi], bx ; Offset low
mov word [edi+2], 0x08 ; Code segment selector
mov byte [edi+4], 0x00 ; Reserved
mov byte [edi+5], 0x8E ; Type: 32-bit interrupt gate, ring 0, present
shr ebx, 16
mov word [edi+6], bx ; Offset high
pop edi
ret
; Initialize IDT with all ISR handlers
idt_init:
; Setup exception handlers (0-31)
xor eax, eax
.setup_loop:
push eax
mov ebx, [isr_stubs + eax*4]
call set_idt_entry
pop eax
inc eax
cmp eax, 34 ; 0-31 exceptions + IRQ 0-1
jl .setup_loop
; Remap PIC (8259) - move IRQs to 32-47
call pic_remap
; Load IDT
lidt [idt_descriptor]
sti ; Enable interrupts
ret
; Remap PIC to avoid conflicts with CPU exceptions
pic_remap:
; ICW1: Initialize
mov al, 0x11
out 0x20, al ; Master PIC
out 0xA0, al ; Slave PIC
; ICW2: Vector offset
mov al, 0x20 ; Master: IRQ 0-7 -> INT 32-39
out 0x21, al
mov al, 0x28 ; Slave: IRQ 8-15 -> INT 40-47
out 0xA1, al
; ICW3: Cascade
mov al, 0x04 ; Master: Slave on IRQ2
out 0x21, al
mov al, 0x02 ; Slave: Cascade identity
out 0xA1, al
; ICW4: 8086 mode
mov al, 0x01
out 0x21, al
out 0xA1, al
; Mask all interrupts except keyboard (IRQ1)
mov al, 0xFD ; 11111101 - only IRQ1 enabled
out 0x21, al
mov al, 0xFF ; Disable all slave IRQs
out 0xA1, al
ret
; Macro to generate ISR stub (no error code)
%macro ISR_NOERRCODE 1
isr%1:
push dword 0 ; Dummy error code
push dword %1 ; Interrupt number
jmp isr_common
%endmacro
; Macro for ISR with error code
%macro ISR_ERRCODE 1
isr%1:
push dword %1 ; CPU already pushed error code
jmp isr_common
%endmacro
; Generate ISR stubs
ISR_NOERRCODE 0 ; Divide Error
ISR_NOERRCODE 1 ; Debug
ISR_NOERRCODE 2 ; NMI
ISR_NOERRCODE 3 ; Breakpoint
ISR_NOERRCODE 4 ; Overflow
ISR_NOERRCODE 5 ; Bound Range
ISR_NOERRCODE 6 ; Invalid Opcode
ISR_NOERRCODE 7 ; Device Not Available
ISR_ERRCODE 8 ; Double Fault
ISR_NOERRCODE 9 ; Coprocessor Segment
ISR_ERRCODE 10 ; Invalid TSS
ISR_ERRCODE 11 ; Segment Not Present
ISR_ERRCODE 12 ; Stack Segment Fault
ISR_ERRCODE 13 ; General Protection Fault
ISR_ERRCODE 14 ; Page Fault
ISR_NOERRCODE 15 ; Reserved
ISR_NOERRCODE 16 ; x87 FPU Error
ISR_ERRCODE 17 ; Alignment Check
ISR_NOERRCODE 18 ; Machine Check
ISR_NOERRCODE 19 ; SIMD Exception
%assign i 20
%rep 12
ISR_NOERRCODE i
%assign i i+1
%endrep
; Common ISR handler
isr_common:
pusha ; Save all registers
push ds
push es
push fs
push gs
mov ax, 0x10 ; Kernel data segment
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
; Call C handler: void isr_handler(registers_t* regs)
push esp ; Pointer to register struct
extern isr_handler
call isr_handler
add esp, 4
pop gs
pop fs
pop es
pop ds
popa
add esp, 8 ; Pop error code and interrupt number
iret
; IRQ handlers
irq0: ; Timer
push dword 0
push dword 32
jmp irq_common
irq1: ; Keyboard
push dword 0
push dword 33
jmp irq_common
irq_common:
pusha
push ds
push es
push fs
push gs
mov ax, 0x10
mov ds, ax
mov es, ax
push esp
extern irq_handler
call irq_handler
add esp, 4
; Send EOI to PIC
mov al, 0x20
out 0x20, al
pop gs
pop fs
pop es
pop ds
popa
add esp, 8
iret
Stage 4: Keyboard Driver
The PS/2 keyboard sends scan codes to port 0x60. We need to convert these to ASCII characters for our shell.
; keyboard.asm - PS/2 keyboard driver
[BITS 32]
section .data
; Keyboard buffer (circular)
KEY_BUFFER_SIZE equ 256
key_buffer: times KEY_BUFFER_SIZE db 0
key_head: dd 0
key_tail: dd 0
; Scan code to ASCII lookup table (US QWERTY)
; Index = scan code, value = ASCII (0 = no mapping)
scancode_table:
; 0 1 2 3 4 5 6 7 8 9 A B C D E F
db 0, 27, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 8, 9 ; 0x00-0x0F
db 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', 13, 0, 'a', 's' ; 0x10-0x1F
db 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'", '`', 0, '\', 'z', 'x', 'c', 'v' ; 0x20-0x2F
db 'b', 'n', 'm', ',', '.', '/', 0, '*', 0, ' ', 0, 0, 0, 0, 0, 0 ; 0x30-0x3F
; Shift key scan code to ASCII
scancode_shift:
db 0, 27, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', 8, 9
db 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', 13, 0, 'A', 'S'
db 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"', '~', 0, '|', 'Z', 'X', 'C', 'V'
db 'B', 'N', 'M', '<', '>', '?', 0, '*', 0, ' ', 0, 0, 0, 0, 0, 0
shift_pressed: db 0
section .text
; Keyboard IRQ handler (IRQ1 = INT 33)
global keyboard_handler
keyboard_handler:
push eax
push ebx
push esi
; Read scan code from keyboard controller
in al, 0x60
; Check for key release (bit 7 set)
test al, 0x80
jnz .key_release
; Key press
; Check for shift key (scan codes 0x2A, 0x36)
cmp al, 0x2A
je .shift_press
cmp al, 0x36
je .shift_press
; Convert scan code to ASCII
movzx ebx, al
cmp byte [shift_pressed], 0
jnz .use_shift
mov al, [scancode_table + ebx]
jmp .check_valid
.use_shift:
mov al, [scancode_shift + ebx]
.check_valid:
test al, al ; Check if valid character
jz .done
; Add to circular buffer
mov esi, [key_head]
mov [key_buffer + esi], al
inc esi
and esi, KEY_BUFFER_SIZE - 1 ; Wrap around
mov [key_head], esi
jmp .done
.shift_press:
mov byte [shift_pressed], 1
jmp .done
.key_release:
; Check for shift release
and al, 0x7F ; Clear release bit
cmp al, 0x2A
je .shift_release
cmp al, 0x36
je .shift_release
jmp .done
.shift_release:
mov byte [shift_pressed], 0
.done:
pop esi
pop ebx
pop eax
ret
; Read character from keyboard buffer
; Returns: AL = character, or 0 if buffer empty
global kbd_getchar
kbd_getchar:
mov eax, [key_tail]
cmp eax, [key_head] ; Check if buffer empty
je .empty
movzx eax, byte [key_buffer + eax]
mov ebx, [key_tail]
inc ebx
and ebx, KEY_BUFFER_SIZE - 1
mov [key_tail], ebx
ret
.empty:
xor eax, eax
ret
; Wait for and return a character
global kbd_getchar_blocking
kbd_getchar_blocking:
call kbd_getchar
test al, al
jz kbd_getchar_blocking ; Busy wait
ret
Note: This is a simple polling/interrupt driver. Production drivers handle extended scan codes (0xE0 prefix), caps lock, and multiple keyboard layouts.
Stage 5: VGA Text Output
VGA text mode uses a memory-mapped buffer at 0xB8000. Each character is 2 bytes: ASCII + attribute (color).
VGA Text Mode Memory Layout (80x25):
0xB8000: ┌─────┬─────┬─────┬───┬─────┬─────┐
│Char │Attr │Char │...│Char │Attr │ Row 0 (160 bytes)
└─────┴─────┴─────┴───┴─────┴─────┘
0xB80A0: Row 1...
0xB8F00: Row 24 (last row)
Attribute Byte:
┌───┬───────┬────────┐
│ 7 │ 6-4 │ 3-0 │
├───┼───────┼────────┤
│Blk│BackRGB│ForeRGBI│
└───┴───────┴────────┘
Colors: 0=Black 1=Blue 2=Green 3=Cyan 4=Red 5=Magenta 6=Brown 7=LightGray
8=DarkGray 9=LightBlue A=LightGreen B=LightCyan C=LightRed
D=LightMagenta E=Yellow F=White
; vga.asm - VGA text mode driver
[BITS 32]
section .data
VGA_BUFFER equ 0xB8000
VGA_WIDTH equ 80
VGA_HEIGHT equ 25
cursor_x: dd 0
cursor_y: dd 0
vga_color: db 0x07 ; Default: white on black
section .text
; Set text color
; Input: AL = color attribute
global vga_set_color
vga_set_color:
mov [vga_color], al
ret
; Clear screen
global vga_clear
vga_clear:
push edi
push ecx
mov edi, VGA_BUFFER
mov ecx, VGA_WIDTH * VGA_HEIGHT
mov ah, [vga_color]
mov al, ' '
rep stosw
; Reset cursor
mov dword [cursor_x], 0
mov dword [cursor_y], 0
call update_hardware_cursor
pop ecx
pop edi
ret
; Print single character
; Input: AL = character
global vga_putchar
vga_putchar:
push ebx
push edi
; Handle special characters
cmp al, 10 ; Newline
je .newline
cmp al, 13 ; Carriage return
je .carriage_return
cmp al, 8 ; Backspace
je .backspace
cmp al, 9 ; Tab
je .tab
; Calculate buffer position: (y * 80 + x) * 2 + 0xB8000
mov ebx, [cursor_y]
imul ebx, VGA_WIDTH
add ebx, [cursor_x]
shl ebx, 1 ; * 2 bytes per character
add ebx, VGA_BUFFER
; Write character + attribute
mov ah, [vga_color]
mov [ebx], ax
; Advance cursor
inc dword [cursor_x]
cmp dword [cursor_x], VGA_WIDTH
jl .done
.newline:
mov dword [cursor_x], 0
inc dword [cursor_y]
jmp .check_scroll
.carriage_return:
mov dword [cursor_x], 0
jmp .done
.backspace:
cmp dword [cursor_x], 0
je .done
dec dword [cursor_x]
; Erase character at cursor
mov al, ' '
call vga_putchar
dec dword [cursor_x]
jmp .done
.tab:
mov eax, [cursor_x]
add eax, 8
and eax, ~7 ; Align to 8
mov [cursor_x], eax
cmp eax, VGA_WIDTH
jl .done
jmp .newline
.check_scroll:
cmp dword [cursor_y], VGA_HEIGHT
jl .done
call scroll_screen
.done:
call update_hardware_cursor
pop edi
pop ebx
ret
; Print null-terminated string
; Input: ESI = pointer to string
global vga_print
vga_print:
push esi
push eax
.loop:
lodsb
test al, al
jz .done
call vga_putchar
jmp .loop
.done:
pop eax
pop esi
ret
; Print string with newline
global vga_println
vga_println:
call vga_print
mov al, 10
call vga_putchar
ret
; Scroll screen up one line
scroll_screen:
push esi
push edi
push ecx
; Copy lines 1-24 to 0-23
mov edi, VGA_BUFFER
mov esi, VGA_BUFFER + VGA_WIDTH * 2
mov ecx, VGA_WIDTH * (VGA_HEIGHT - 1)
rep movsw
; Clear last line
mov ecx, VGA_WIDTH
mov ah, [vga_color]
mov al, ' '
rep stosw
; Move cursor up
dec dword [cursor_y]
pop ecx
pop edi
pop esi
ret
; Update hardware cursor position
update_hardware_cursor:
push eax
push ebx
push edx
mov ebx, [cursor_y]
imul ebx, VGA_WIDTH
add ebx, [cursor_x]
; Cursor low byte
mov dx, 0x3D4
mov al, 0x0F
out dx, al
mov dx, 0x3D5
mov al, bl
out dx, al
; Cursor high byte
mov dx, 0x3D4
mov al, 0x0E
out dx, al
mov dx, 0x3D5
mov al, bh
out dx, al
pop edx
pop ebx
pop eax
ret
Stage 6: Command Shell
A simple command-line shell that ties everything together: reading keyboard input, parsing commands, and executing actions.
; shell.asm - Simple command shell
[BITS 32]
section .data
prompt: db "MiniOS> ", 0
cmd_buffer: times 256 db 0
cmd_len: dd 0
; Built-in commands
cmd_help: db "help", 0
cmd_clear: db "clear", 0
cmd_echo: db "echo", 0
cmd_reboot: db "reboot", 0
; Help text
help_text:
db "Mini OS Shell Commands:", 10
db " help - Show this help", 10
db " clear - Clear screen", 10
db " echo - Echo text", 10
db " reboot - Restart system", 10, 0
unknown_cmd: db "Unknown command: ", 0
newline: db 10, 0
section .text
; Main shell loop
global shell_run
shell_run:
; Clear command buffer
call clear_cmd_buffer
; Print prompt
mov esi, prompt
call vga_print
.input_loop:
; Get character from keyboard (blocking)
call kbd_getchar_blocking
; Handle special keys
cmp al, 13 ; Enter
je .execute
cmp al, 8 ; Backspace
je .backspace
; Printable character - add to buffer
mov ebx, [cmd_len]
cmp ebx, 254 ; Buffer full?
jge .input_loop
mov [cmd_buffer + ebx], al
inc dword [cmd_len]
; Echo character
call vga_putchar
jmp .input_loop
.backspace:
cmp dword [cmd_len], 0
je .input_loop
dec dword [cmd_len]
mov al, 8 ; Backspace
call vga_putchar
jmp .input_loop
.execute:
; Null-terminate command
mov ebx, [cmd_len]
mov byte [cmd_buffer + ebx], 0
; Print newline
mov al, 10
call vga_putchar
; Empty command?
cmp dword [cmd_len], 0
je shell_run
; Parse and execute command
call parse_command
jmp shell_run
clear_cmd_buffer:
push edi
push ecx
mov edi, cmd_buffer
xor eax, eax
mov ecx, 256
rep stosb
mov dword [cmd_len], 0
pop ecx
pop edi
ret
; Parse and execute command
parse_command:
; Compare with built-in commands
mov esi, cmd_buffer
; Check "help"
mov edi, cmd_help
call strcmp
test eax, eax
jz .do_help
; Check "clear"
mov edi, cmd_clear
call strcmp
test eax, eax
jz .do_clear
; Check "echo " (with space)
mov esi, cmd_buffer
mov edi, cmd_echo
push esi
call strncmp_4 ; Compare first 4 chars
pop esi
test eax, eax
jz .do_echo
; Check "reboot"
mov esi, cmd_buffer
mov edi, cmd_reboot
call strcmp
test eax, eax
jz .do_reboot
; Unknown command
mov esi, unknown_cmd
call vga_print
mov esi, cmd_buffer
call vga_println
ret
.do_help:
mov esi, help_text
call vga_print
ret
.do_clear:
call vga_clear
ret
.do_echo:
; Print everything after "echo "
mov esi, cmd_buffer
add esi, 5 ; Skip "echo "
call vga_println
ret
.do_reboot:
; Triple fault reboot (crude but effective)
lidt [null_idt]
int 0 ; Triple fault -> reboot
ret
null_idt:
dw 0
dd 0
; String compare (returns 0 if equal)
; ESI = string1, EDI = string2
strcmp:
push esi
push edi
.loop:
lodsb
mov ah, [edi]
inc edi
cmp al, ah
jne .not_equal
test al, al
jnz .loop
xor eax, eax ; Equal
jmp .done
.not_equal:
mov eax, 1
.done:
pop edi
pop esi
ret
; Compare first 4 characters
strncmp_4:
mov eax, [esi]
cmp eax, [edi]
jne .not_equal
xor eax, eax
ret
.not_equal:
mov eax, 1
ret
Challenge
Extend the Shell
Add these features to make your Mini OS more useful:
- date - Read RTC and display current time
- mem - Show memory map and usage
- color <fg> <bg> - Change text colors
- uptime - Track time since boot using PIT timer
- history - Keep last 10 commands with up/down navigation
Building & Testing
# Makefile
all: os.img
boot.bin: boot.asm
nasm -f bin boot.asm -o boot.bin
kernel.bin: kernel.asm
nasm -f bin kernel.asm -o kernel.bin
os.img: boot.bin kernel.bin
cat boot.bin kernel.bin > os.img
run: os.img
qemu-system-i386 -drive format=raw,file=os.img
debug: os.img
qemu-system-i386 -drive format=raw,file=os.img -s -S &
gdb -ex "target remote :1234" -ex "set architecture i8086"
clean:
rm -f *.bin *.img
Series Complete! You've mastered x86 assembly from environment setup through building a mini operating system. Continue exploring: Linux kernel, game development, or reverse engineering!
Review Previous Parts
Part 23: Practical Projects
Build useful assembly utilities.
Read Article
Part 19: Bootloader Development
Deep dive into bootloader concepts.
Read Article
Part 0: Environment Setup
Start from the beginning of the series.
Read Article