Build a Bare-Metal x86 Bootloader
Write a 512-byte x86 bootloader in NASM assembly that boots in QEMU.
Introduction
30 Second Summary
Every time you press the power button on your computer, a tiny program runs before anything else. Before Windows, before macOS, before Linux, there is a small piece of code that wakes the hardware up and decides what happens next.
In this project, you will write that program from scratch. You'll build a bare-metal x86 bootloader in assembly language that boots in an emulator, prints messages to the screen, and loads additional code from disk.
What You'll Build
A bare-metal x86 bootloader written entirely in NASM assembly that runs without any operating system, booting directly on emulated hardware in QEMU.
By the end of this project, you'll have:
- A 512-byte boot sector that BIOS loads and executes when the computer powers on.
- A two-stage boot system that reads additional code from disk into memory and jumps to it.
- Colorful VGA text output written directly to video memory at 0xB8000 without any OS support.
- Secret Mission: Add keyboard input to create a primitive terminal that echoes typed characters to the screen.
Do I need to know assembly language?
No prior assembly experience is required. This project teaches you x86 assembly as you go. You only need a basic understanding of what binary and hexadecimal are.
Install NASM, QEMU, and Make
Before writing any assembly code, you need three tools working together on your system. NASM is the assembler that translates human-readable assembly into raw machine bytes the CPU can execute directly.
QEMU emulates a full x86 computer so you can test your bootloader without rebooting your actual machine. GNU Make automates the build process so you can assemble, create a disk image, and boot it in one command.
In this step, get ready to:
- Install NASM, QEMU, and GNU Make.
- Verify each tool is working with version checks.
- Create your project directory.
Install NASM, QEMU, and GNU Make
Each tool plays a specific role in the bootloader workflow. The install command depends on which Linux distribution you are using.
- Install all three tools by running the command for your distribution:
Debian / Ubuntu
sudo apt-get update && sudo apt-get install -y nasm qemu-system-x86 make
Fedora
sudo dnf install -y nasm qemu-system-x86 make
Arch
sudo pacman -S nasm qemu make
What does each tool do?
- NASM (Netwide Assembler) converts assembly source code into flat binary files. The -f bin flag produces raw machine code with no OS headers or metadata.
- QEMU emulates a complete x86 PC. The qemu-system-i386 variant boots in 16-bit real mode, just like real hardware.
- GNU Make reads a Makefile to automate assembly, disk image creation, and launching QEMU in a single command.
Verify each tool is installed
Running version checks confirms each tool is accessible from your terminal and ready to use.
- Check that NASM is installed by running this command:
nasm -v
✔️ I see a version number
NASM is ready. You should see output showing the NASM version and compile date.
ⓧ Command not found
NASM is not installed or not on your PATH. Go back to the install step above and run the command for your distribution.
- Check that QEMU is installed by running this command:
qemu-system-i386 --version
✔️ I see QEMU version info
QEMU is ready. The output shows the QEMU emulator version number.
ⓧ Command not found
QEMU is not installed or the x86 system package is missing. Go back to the install step above and ensure you installed qemu-system-x86 (not just the base qemu package).
- Check that GNU Make is installed by running this command:
make --version
✔️ I see GNU Make version info
GNU Make is ready. The output shows the Make version number.
ⓧ Command not found
Make is not installed. Go back to the install step above and run the command for your distribution.
Still seeing errors after installing?
- Close and reopen your terminal, then try the version check again.
- Make sure you ran the install command with sudo (it needs administrator permissions).
Help me troubleshoot my NASM, QEMU, or Make installation
Create the project directory
Your bootloader project needs a dedicated folder to hold the assembly source files, the Makefile, and the output disk image.
- Create your project directory and move into it by running these commands:
mkdir -p ~/osdev && cd ~/osdev
- Confirm you are in the correct directory by running:
pwd
- You should see /home/your-username/osdev (with your actual username) in the output.
Why ~/osdev?
This folder keeps your bootloader project self-contained. You will create three files here: boot.asm, stage2.asm, and a Makefile.
Directory not found or permission denied?
- The -p flag tells mkdir to create the directory only if it does not exist already, so running it twice is safe.
- If you see a permission error, make sure you are creating the folder in your home directory (the ~ shortcut) and not in a system directory.
Help me create my osdev project directory
Your toolchain is ready. Next up, you will write the first-stage bootloader: 512 bytes of code that the BIOS loads and executes when a computer powers on.
Write the First-Stage Bootloader
Your tools are installed and ready. Now it's time to write actual code that runs on bare metal.
When a computer powers on, the BIOS loads exactly 512 bytes from the first disk sector into memory at address 0x7C00. If those bytes end with the magic signature 0xAA55, the BIOS treats them as executable code and jumps to them. Your job in this step is to write those 512 bytes.
In this step, get ready to:
- Write a boot sector in NASM assembly that prints a message using BIOS interrupts.
- Create a Makefile that assembles the code into a bootable disk image.
- Boot your code in QEMU and see it run on emulated hardware.
Create the boot sector assembly file
A boot sector must fit in exactly 512 bytes. It runs in 16-bit real mode with no operating system support. Your code needs to set up its own stack, configure segment registers, and communicate with hardware through BIOS interrupts.
- Create a file called boot.asm in your osdev directory and paste this code:
[bits 16]
[org 0x7C00]
; Entry point - BIOS jumps here after loading boot sector
start:
; Initialize segment registers
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Stack grows downward from 0x7C00
; Save boot drive number (BIOS passes it in DL)
mov [boot_drive], dl
; Print boot message
mov si, msg_booting
call print_string
halt:
cli
hlt
jmp halt
; print_string: prints null-terminated string pointed to by SI
print_string:
pusha
.loop:
lodsb ; Load byte from [SI] into AL, increment SI
or al, al ; Check if null terminator
jz .done
mov ah, 0x0E ; BIOS teletype output
mov bh, 0 ; Page number
int 0x10 ; Print character
jmp .loop
.done:
popa
ret
; Data
msg_booting: db "Booting...", 13, 10, 0
boot_drive: db 0
; Padding and boot signature
times 510-($-$$) db 0
dw 0xAA55
What does this code do?
- [bits 16] and [org 0x7C00] tell NASM to generate 16-bit code and calculate addresses starting from 0x7C00, where the BIOS loads the boot sector.
- The segment registers (DS, ES, SS) are zeroed and the stack pointer is set to 0x7C00. This gives the program a working stack that grows downward.
- mov [boot_drive], dl saves the drive number the BIOS passed in the DL register at boot time. You will need this later for disk reads.
- print_string uses BIOS interrupt INT 0x10 with AH=0x0E to print characters one at a time until it hits a null byte (0).
- times 510-($-$$) db 0 pads the file to exactly 510 bytes. dw 0xAA55 places the two-byte boot signature at the very end, filling the sector to 512 bytes.
- Save boot.asm.
- Confirm the file exists by running:
ls
You should see boot.asm in the output.
Don't see boot.asm listed?
- Make sure you saved the file inside the ~/osdev directory. Run pwd to check your current location.
- Check for typos in the filename. It must be exactly boot.asm (all lowercase).
Help me figure out why my boot.asm file isn't showing up.
✔️ Awesome, I've got everything!
Great. Make sure you have saved boot.asm before moving on.
ⓧ I'd like to double check the full code
[bits 16]
[org 0x7C00]
; Entry point - BIOS jumps here after loading boot sector
start:
; Initialize segment registers
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Stack grows downward from 0x7C00
; Save boot drive number (BIOS passes it in DL)
mov [boot_drive], dl
; Print boot message
mov si, msg_booting
call print_string
halt:
cli
hlt
jmp halt
; print_string: prints null-terminated string pointed to by SI
print_string:
pusha
.loop:
lodsb ; Load byte from [SI] into AL, increment SI
or al, al ; Check if null terminator
jz .done
mov ah, 0x0E ; BIOS teletype output
mov bh, 0 ; Page number
int 0x10 ; Print character
jmp .loop
.done:
popa
ret
; Data
msg_booting: db "Booting...", 13, 10, 0
boot_drive: db 0
; Padding and boot signature
times 510-($-$$) db 0
dw 0xAA55
Write the Makefile
A Makefile automates the assembly and disk image creation. Instead of typing multiple commands every time you change your code, you run one command and GNU Make handles the rest.
- Create a file called Makefile (capital M, no file extension) in your osdev directory and paste this:
ASM = nasm
QEMU = qemu-system-i386
all: os.img
boot.bin: boot.asm
$(ASM) -f bin -o boot.bin boot.asm
os.img: boot.bin
dd if=/dev/zero of=os.img bs=512 count=2880
dd if=boot.bin of=os.img bs=512 count=1 conv=notrunc
run: os.img
$(QEMU) -fda os.img
clean:
rm -f boot.bin os.img
.PHONY: all run clean
What do these Makefile targets do?
- boot.bin assembles boot.asm into a flat binary using nasm -f bin. The -f bin flag produces raw machine code with no file headers.
- os.img first creates a blank 1.44 MB floppy image (2880 sectors of 512 bytes each). Then it overwrites the first sector with your boot binary, keeping the rest of the image intact.
- run launches QEMU with your disk image loaded as floppy drive A using the -fda flag.
- clean removes all generated files so you can rebuild from scratch.
Why tabs matter in Makefiles
Makefiles require real tab characters (not spaces) before each command line. If your editor converts tabs to spaces, Make will fail with a "missing separator" error. Check your editor settings to ensure tabs are preserved, or copy the file contents exactly.
- Save Makefile.
- Confirm both source files exist by running:
ls
You should see boot.asm and Makefile listed.
Seeing a 'missing separator' error later?
- Your editor likely converted tabs to spaces. Open Makefile and re-insert real tab characters before each command (the lines starting with $(ASM), dd, $(QEMU), and rm).
- In VS Code or Cursor, check the bottom status bar. It should say "Tabs" not "Spaces" for this file.
Help me fix a Makefile tabs vs spaces issue.
✔️ Awesome, I've got everything!
Great. Make sure Makefile is saved before moving on.
ⓧ I'd like to double check the full code
ASM = nasm
QEMU = qemu-system-i386
all: os.img
boot.bin: boot.asm
$(ASM) -f bin -o boot.bin boot.asm
os.img: boot.bin
dd if=/dev/zero of=os.img bs=512 count=2880
dd if=boot.bin of=os.img bs=512 count=1 conv=notrunc
run: os.img
$(QEMU) -fda os.img
clean:
rm -f boot.bin os.img
.PHONY: all run clean
Build and boot your code
Everything is in place. Time to assemble your boot sector into a disk image and launch it on emulated hardware.
- Build the disk image and start QEMU by running:
make os.img && make run
What happens when you run this?
Make first assembles boot.asm into boot.bin, then creates a blank floppy image and writes your boot sector to its first 512 bytes. QEMU then opens a new window and boots from that image, just like a real BIOS would boot from a floppy disk.
A QEMU window opens showing a black screen with Booting... printed in white text near the top. The system halts after printing the message.
Don't see the QEMU window?
- If you get No rule to make target, check that Makefile uses real tab characters (not spaces) before each command.
- If QEMU shows No bootable device, verify your boot.asm ends with dw 0xAA55. This signature tells the BIOS the sector is bootable.
- If QEMU launches but the screen is blank, check that your print_string routine uses 0x0E in AH (teletype output mode) and that msg_booting ends with , 0 (null terminator).
Help me debug my bootloader in QEMU.
Your boot sector is alive. Next, you will extend it to load additional code from disk, proving that 512 bytes is just the beginning.
Load a Second Stage from Disk
Your bootloader prints a message, but that is all 512 bytes can hold. Real operating systems solve this by using the bootloader as a launcher. It reads more code from disk into memory, then jumps to it.
In this step, you will extend boot.asm to read a second sector from disk using BIOS interrupt INT 0x13. Then you will create stage2.asm, a separate program that gets loaded and executed by your bootloader.
In this step, get ready to:
- Add disk read code to the bootloader using BIOS interrupt INT 0x13.
- Create a second-stage program that prints its own message.
- Update the Makefile to assemble and link both stages into one bootable image.
Add disk read code to boot.asm
Your bootloader currently halts right after printing its message. You need to insert code that asks the BIOS to read sector 2 from the floppy disk into memory at address 0x7E00 (immediately after the 512-byte boot sector). If the read succeeds, the bootloader jumps to that address to execute the loaded code.
- In boot.asm, find the call print_string line (after mov si, msg_booting).
- Find the halt: label directly below it.
- Add the following disk read code between call print_string and halt::
; Load Stage 2 from disk sector 2 into memory at 0x7E00
mov ah, 0x02 ; BIOS read sectors function
mov al, 1 ; Number of sectors to read
mov ch, 0 ; Cylinder 0
mov cl, 2 ; Sector 2 (1-indexed; sector 1 is boot sector)
mov dh, 0 ; Head 0
mov dl, [boot_drive] ; Drive number
mov bx, 0x7E00 ; ES:BX = destination address (ES=0 from above)
int 0x13 ; Call BIOS disk service
jc disk_error ; If carry flag set, disk read failed
; Jump to loaded Stage 2
jmp 0x7E00
disk_error:
mov si, msg_disk_err
call print_string
jmp halt
What does this code do?
INT 0x13 is the BIOS disk services interrupt. Setting AH=0x02 tells it to read sectors. The other registers specify exactly where to read from (cylinder 0, sector 2, head 0) and where to store the data in memory (ES:BX = 0x0000:0x7E00).
After the interrupt returns, the CPU's carry flag indicates success or failure. jc disk_error jumps to the error handler if the read failed. On success, jmp 0x7E00 hands control to whatever code was loaded there.
- Also add the error message string in the Data section of boot.asm. Find msg_booting and add this line directly after it:
msg_disk_err: db "Disk read error!", 13, 10, 0
Why a separate error message?
If the disk read fails (bad image, wrong sector number, corrupted file), you want visible feedback instead of a silent hang. The disk_error handler prints this message and then falls through to halt, stopping the CPU.
- Save boot.asm.
✔️ Awesome, I've got everything!
Great. Double check you have saved boot.asm before moving on.
ⓧ I'd like to double check the full code
[bits 16]
[org 0x7C00]
; Entry point - BIOS jumps here after loading boot sector
start:
; Initialize segment registers
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Stack grows downward from 0x7C00
; Save boot drive number (BIOS passes it in DL)
mov [boot_drive], dl
; Print boot message
mov si, msg_booting
call print_string
; Load Stage 2 from disk sector 2 into memory at 0x7E00
mov ah, 0x02 ; BIOS read sectors function
mov al, 1 ; Number of sectors to read
mov ch, 0 ; Cylinder 0
mov cl, 2 ; Sector 2 (1-indexed; sector 1 is boot sector)
mov dh, 0 ; Head 0
mov dl, [boot_drive] ; Drive number
mov bx, 0x7E00 ; ES:BX = destination address (ES=0 from above)
int 0x13 ; Call BIOS disk service
jc disk_error ; If carry flag set, disk read failed
; Jump to loaded Stage 2
jmp 0x7E00
disk_error:
mov si, msg_disk_err
call print_string
jmp halt
halt:
cli
hlt
jmp halt
; print_string: prints null-terminated string pointed to by SI
print_string:
pusha
.loop:
lodsb ; Load byte from [SI] into AL, increment SI
or al, al ; Check if null terminator
jz .done
mov ah, 0x0E ; BIOS teletype output
mov bh, 0 ; Page number
int 0x10 ; Print character
jmp .loop
.done:
popa
ret
; Data
msg_booting: db "Booting...", 13, 10, 0
msg_disk_err: db "Disk read error!", 13, 10, 0
boot_drive: db 0
; Padding and boot signature
times 510-($-$$) db 0
dw 0xAA55
Create stage2.asm
Now you need the code that lives in sector 2. This is a separate assembly file that gets loaded into memory at 0x7E00 and executes when the bootloader jumps to it.
- Create a new file called stage2.asm in your ~/osdev folder by running:
touch stage2.asm
- Add the following code to stage2.asm:
[bits 16]
[org 0x7E00]
stage2_start:
; Print Stage 2 message via BIOS
mov si, msg_stage2
call print_string_s2
; Halt cleanly
cli
hlt
jmp $
; print_string_s2: prints null-terminated string via BIOS INT 0x10
print_string_s2:
pusha
.loop:
lodsb
or al, al
jz .done
mov ah, 0x0E
mov bh, 0
int 0x10
jmp .loop
.done:
popa
ret
; Data
msg_stage2: db "Stage 2 loaded! Welcome to your OS.", 13, 10, 0
What does this code do?
[org 0x7E00] tells NASM to calculate all addresses relative to 0x7E00. This matches where the bootloader loads this code in memory.
The print_string_s2 routine is a duplicate of the boot sector's print function. Stage 2 cannot call functions in the boot sector because they are at different memory addresses. Each stage needs its own copy.
cli disables hardware interrupts and hlt stops the CPU. Together they form a clean halt. The jmp $ is a safety net in case the CPU wakes from hlt due to a non-maskable interrupt.
- Save stage2.asm.
- Confirm the file exists by running:
ls
You should see boot.asm, stage2.asm, and Makefile listed in the output.
Don't see stage2.asm?
Make sure you ran touch stage2.asm from inside your ~/osdev directory. If you are in a different directory, run cd ~/osdev first.
help me find my stage2.asm file
Update the Makefile and test both stages
The Makefile needs to know about stage2.asm. It must assemble it into a binary and write that binary to sector 2 of the floppy image so the bootloader can find it.
- Open Makefile and replace its entire contents with the following:
ASM = nasm
QEMU = qemu-system-i386
all: os.img
boot.bin: boot.asm
$(ASM) -f bin -o boot.bin boot.asm
stage2.bin: stage2.asm
$(ASM) -f bin -o stage2.bin stage2.asm
os.img: boot.bin stage2.bin
dd if=/dev/zero of=os.img bs=512 count=2880
dd if=boot.bin of=os.img bs=512 count=1 conv=notrunc
dd if=stage2.bin of=os.img bs=512 seek=1 count=1 conv=notrunc
run: os.img
$(QEMU) -fda os.img
clean:
rm -f boot.bin stage2.bin os.img
.PHONY: all run clean
What changed in the Makefile?
A new stage2.bin target assembles stage2.asm into a flat binary. The os.img target now depends on both boot.bin and stage2.bin.
The key line is dd if=stage2.bin of=os.img bs=512 seek=1 count=1 conv=notrunc. The seek=1 skips the first 512-byte block (sector 1, where the bootloader lives) and writes stage2.bin into sector 2. This is exactly where the bootloader's INT 0x13 call reads from.
- Save Makefile.
- Build the disk image and launch QEMU by running:
make os.img && make run
You should see two messages in the QEMU window: "Booting..." from your bootloader and "Stage 2 loaded! Welcome to your OS." from stage 2.
Seeing "Disk read error!" instead?
This means INT 0x13 failed to read sector 2. Check that stage2.bin exists by running ls. If it is missing, the Makefile's stage2.bin target may have an error. Verify the indentation uses a real tab character, not spaces.
Also confirm the dd command writing stage2.bin uses seek=1 (not seek=2). The dd tool counts from 0, while BIOS sectors count from 1. Sector 2 in BIOS terminology is offset 1 in dd terminology.
help me debug my disk read error
Only see "Booting..." with no error and no Stage 2 message?
The disk read succeeded but Stage 2 is not printing. Check that stage2.asm uses [org 0x7E00] (not 0x7C00). A wrong origin causes the msg_stage2 address to be calculated incorrectly, so lodsb reads garbage instead of your string.
help me debug my stage 2 not printing
You now have a two-stage boot system. Your 512-byte bootloader loads additional code from disk and executes it. Next up, you will make Stage 2 write colorful text directly to VGA memory, bypassing the BIOS entirely.
Add VGA Color Output in Stage 2
Your two-stage boot system is working. The bootloader loads and executes code from a second disk sector, and both messages appear on screen. Now you'll go a step further and bypass BIOS entirely.
In this step, you'll write directly to VGA text-mode memory. This is a fixed region in the x86 memory map where every byte you write immediately appears on screen. You get full control over character placement and color without calling any BIOS interrupts.
In this step, get ready to:
- Write a print_vga routine that outputs colored text to VGA memory.
- Clear a section of the screen and display a colorful banner.
- Test the colored output in QEMU.
Write a print_vga routine for direct VGA memory access
VGA text-mode memory starts at segment 0xB800 in real mode (physical address 0xB8000). Each character on screen occupies 2 bytes: one for the ASCII character and one for the color attribute.
What is the VGA attribute byte?
The attribute byte controls foreground and background color. The high nibble (4 bits) sets the background. The low nibble sets the foreground.
For example, 0x0A means black background (0) with bright green foreground (A). 0x0E means black background (0) with yellow foreground (E).
- In stage2.asm, find the print_string_s2 routine. Add the following print_vga routine directly below it (after its ret instruction):
; print_vga: prints null-terminated string at ES:DI with attribute in AH
; SI = string pointer, AH = color attribute, ES:DI = VGA position
print_vga:
pusha
.loop:
lodsb
or al, al
jz .done
mov [es:di], al ; Write character
mov [es:di+1], ah ; Write attribute
add di, 2
jmp .loop
.done:
popa
ret
What does this routine do?
This routine works like print_string_s2 but writes to VGA memory instead of calling BIOS. It expects three inputs: SI points to the string, AH holds the color attribute, and ES:DI points to the VGA memory position.
For each character, it writes the ASCII byte to [es:di] and the color attribute to [es:di+1], then advances DI by 2 to reach the next character cell.
You won't see a change yet. The next chunk uses this routine to actually print colored text.
Clear the screen and print a colored banner
To position text on screen using VGA memory, you calculate a byte offset. Each row has 80 characters and each character takes 2 bytes, so one row equals 160 bytes. Row 4, column 35 is at offset (4 * 80 + 35) * 2 = 710.
- In stage2.asm, find the line call print_string_s2. Add the following code between that line and the ; Halt cleanly comment:
; Now write directly to VGA text memory for colored output
mov ax, 0xB800
mov es, ax
; Clear a section of screen (row 3-5, all 80 columns)
mov di, 480 ; Row 3 = 3 * 160 = 480
mov cx, 240 ; 3 rows * 80 chars = 240 word pairs
mov ax, 0x0020 ; Black background, space character
.clear_loop:
mov [es:di], al ; Space char
mov [es:di+1], ah ; Attribute: black on black
add di, 2
loop .clear_loop
; Print "MyOS v0.1" at row 4, column 35 in bright green
mov di, 710 ; (4*80 + 35) * 2 = 710
mov si, msg_banner
mov ah, 0x0A ; Bright green on black
call print_vga
; Print subtitle at row 6, column 24 in yellow
mov di, 1008 ; (6*80 + 24) * 2 = 1008
mov si, msg_subtitle
mov ah, 0x0E ; Yellow on black
call print_vga
What does this code do?
First, it sets the ES segment register to 0xB800 so all memory writes through ES target the VGA text buffer.
The clear loop writes spaces with a black attribute across rows 3 through 5 (240 character cells = 3 rows of 80 columns). This creates a blank canvas for the banner.
Then it calls print_vga twice. The first call prints the banner at row 4, column 35 in bright green (0x0A). The second prints the subtitle at row 6, column 24 in yellow (0x0E).
- Now add the message strings that this code references. Find the line msg_stage2: db "Stage 2 loaded! Welcome to your OS.", 13, 10, 0 at the bottom of your file. Add these two lines directly below it:
msg_banner: db "MyOS v0.1", 0
msg_subtitle: db "Bootloader written from scratch!", 0
Why no 13, 10 in these strings?
The earlier BIOS-printed strings used 13, 10 (carriage return + line feed) because BIOS teletype output interprets them as newlines. VGA direct writes don't interpret control characters. You position text purely through memory offsets, so newline bytes are unnecessary.
- Save stage2.asm.
Seeing an assembler error?
Make sure the .clear_loop: label starts with a dot. Local labels in NASM must begin with a period.
Check that the VGA code sits between call print_string_s2 and the cli instruction. Code placed after hlt will never execute.
Still stuck? Help me debug my stage2.asm assembler error.
Build and test the colored output
- Rebuild the disk image and launch QEMU by running:
make os.img && make run
- Look at the QEMU window. You should see the BIOS-printed messages ("Booting..." and "Stage 2 loaded!") at the top, plus bright green text reading MyOS v0.1 and yellow text reading Bootloader written from scratch! below them.
Why two different printing methods?
The white text at the top uses BIOS interrupt INT 0x10. It's simple but you can't control color without extra calls. The green and yellow text below uses direct VGA memory writes. You calculated exact screen positions and chose precise colors, all without asking BIOS for help.
Real operating systems use direct memory access like this for performance. BIOS calls are slow because they go through firmware. Writing to VGA memory is instant.
Don't see colored text?
If you see only the BIOS messages but no green or yellow text, check that mov ax, 0xB800 and mov es, ax appear before the clear loop. Without setting ES, writes go to the wrong memory.
If the text appears but in the wrong position, double-check your offset values. Row 4, column 35 should be 710. Row 6, column 24 should be 1008.
If you see garbled characters, make sure msg_banner and msg_subtitle end with , 0 (null terminator). Without it, the print loop reads past the string into garbage memory.
Need more help? Help me debug my VGA color output in QEMU.
✔️ Awesome, I've got everything!
Great. Double check you've saved stage2.asm and that QEMU shows your colored banner.
ⓧ I'd like to double check the full code
Here is the complete stage2.asm at this point:
[bits 16]
[org 0x7E00]
stage2_start:
; Print Stage 2 message via BIOS
mov si, msg_stage2
call print_string_s2
; Now write directly to VGA text memory for colored output
mov ax, 0xB800
mov es, ax
; Clear a section of screen (row 3-5, all 80 columns)
mov di, 480 ; Row 3 = 3 * 160 = 480
mov cx, 240 ; 3 rows * 80 chars = 240 word pairs
mov ax, 0x0020 ; Black background, space character
.clear_loop:
mov [es:di], al ; Space char
mov [es:di+1], ah ; Attribute: black on black
add di, 2
loop .clear_loop
; Print "MyOS v0.1" at row 4, column 35 in bright green
mov di, 710 ; (4*80 + 35) * 2 = 710
mov si, msg_banner
mov ah, 0x0A ; Bright green on black
call print_vga
; Print subtitle at row 6, column 24 in yellow
mov di, 1008 ; (6*80 + 24) * 2 = 1008
mov si, msg_subtitle
mov ah, 0x0E ; Yellow on black
call print_vga
; Halt cleanly
cli
hlt
jmp $
; print_string_s2: prints null-terminated string via BIOS INT 0x10
print_string_s2:
pusha
.loop:
lodsb
or al, al
jz .done
mov ah, 0x0E
mov bh, 0
int 0x10
jmp .loop
.done:
popa
ret
; print_vga: prints null-terminated string at ES:DI with attribute in AH
; SI = string pointer, AH = color attribute, ES:DI = VGA position
print_vga:
pusha
.loop:
lodsb
or al, al
jz .done
mov [es:di], al ; Write character
mov [es:di+1], ah ; Write attribute
add di, 2
jmp .loop
.done:
popa
ret
; Data
msg_stage2: db "Stage 2 loaded! Welcome to your OS.", 13, 10, 0
msg_banner: db "MyOS v0.1", 0
msg_subtitle: db "Bootloader written from scratch!", 0
The boot.asm and Makefile are unchanged from the previous step.
Secret mission
Add Keyboard Input
Your bootloader can print messages, but it can't listen. Real operating systems respond to the user. In this secret mission, you'll add an interactive keyboard input loop that echoes typed characters to the screen, creating a primitive terminal right on bare metal.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources on disk, or delete them entirely.
Resources you used:
- The ~/osdev project directory (contains boot.asm, stage2.asm, Makefile, and generated binary files).
- Installed packages: NASM, QEMU, and GNU Make.
Keep everything running
No action needed. Choose this if you plan to extend your bootloader into a full kernel or want to experiment further with x86 assembly.
- Your ~/osdev directory stays on disk, ready for future development.
- NASM, QEMU, and Make remain installed and available for other projects.
Pause - I'll come back to this later
There is nothing to pause. This project has no running processes or cloud resources.
- Your files stay on disk at ~/osdev until you return.
- No memory or CPU is consumed while the project sits idle.
Delete - I don't want to use this again
Remove all project files and optionally uninstall the tools you installed in Step 1.
- Delete the project directory by running:
rm -rf ~/osdev
- Confirm the directory is gone by running:
ls ~/osdev
You should see a "No such file or directory" message.
- If you want to uninstall NASM, QEMU, and Make, use your package manager. On Debian/Ubuntu:
sudo apt-get remove -y nasm qemu-system-x86 make
- On Fedora:
sudo dnf remove -y nasm qemu-system-x86 make
- On Arch:
sudo pacman -R nasm qemu make
Keep the tools if you plan more OS dev
NASM, QEMU, and Make are common development tools used across many low-level and systems programming projects. If you plan to continue with OS development or embedded work, leave them installed.
Nice Work!
Nice Work!
You just built a bare-metal x86 bootloader from scratch. Your code runs without any operating system, directly on the CPU the moment the machine powers on.
You've learned how to:
- Build a 512-byte boot sector that BIOS loads to address 0x7C00, complete with segment initialization, a print routine using INT 0x10, and the 0xAA55 boot signature.
- Implement two-stage booting by reading a second sector from disk into memory using BIOS INT 0x13 disk services, then jumping to the loaded code.
- Write directly to VGA text-mode memory at segment 0xB800 for colored output, bypassing BIOS interrupts entirely.
- Secret Mission: Built a primitive terminal with an interactive keyboard input loop using INT 0x16, character echo, Enter/Backspace handling, and cursor position tracking.
Ready to quiz yourself?