x86 bootloader: 2D graphics in protected mode and 3D in 512 bytes

Disclaimer: the projects described here are the result of my own independent work, done without the use of AI. This article itself was written with the help of AI.
The x86-bootloader repository holds two programs that run directly on the CPU after the BIOS hands over control. There is no operating system, no libc and no driver: just NASM assembly, BIOS interrupts and video memory at 0xA0000. Both run in the Bochs emulator or on real hardware booted from a floppy.
| Part | Mode | Size | What it does |
|---|---|---|---|
protected-mode-2d |
32-bit protected mode | 4555 bytes (512 B boot sector + 4043 B program) | animated 2D shapes, lines, logo, PC speaker sound, MMX blitting |
real-mode-3d |
16-bit real mode | 512 bytes | Bresenham lines and a 3D-to-2D projection, all inside the boot sector |
protected-mode-2d: code from 2005 #
This part is older than the repository. I wrote it around 2005 and committed version 11 of it in 2018. It is split into small files, bootsector.asm, main.asm, point.asm, line.asm, rect.asm, logo.asm and sound.asm, assembled separately and merged into a single floppy image.

From real mode to protected mode #
The boot sector is loaded by the BIOS at 0x7C00 and still runs in 16-bit real mode. It has to do everything that later becomes impossible without the BIOS:
- reset the drive and read the next 10 sectors (the actual program) to
0000:1000withint 13h, - switch to video mode 13h (320×200, 256 colours) with
int 10h, - disable interrupts, load the Global Descriptor Table with
lgdt, - set bit 0 of
CR0and do a far jump to flush the prefetch queue, - in 32-bit code: set the data and stack segments and jump to the program at
08h:01000h.
cli ; Disable interrupts, we want to be alone
xor ax, ax
mov ds, ax ; Set DS-register to 0 - used by lgdt
lgdt [gdt_desc] ; Load the GDT descriptor
mov eax, cr0
or eax, 1 ; Set bit 0
mov cr0, eax
jmp 08h:clear_pipe ; Jump to code segment, offset clear_pipe
[BITS 32]
clear_pipe:
mov ax, 10h ; Data segment selector
mov ds, ax
mov ss, ax
mov esp, 090000h
jmp 08h:01000h ; Jump to the program loaded from disk
The GDT is the minimum that works: a null descriptor, one code segment (read/execute) and one data segment (read/write), both flat over the whole 4 GB address space.
Double buffering with MMX #
Drawing straight into 0xA0000 flickers. The program draws each frame into a buffer at 0x10000 and then copies the 64 000 bytes to video memory. The copy uses the eight 64-bit MMX registers, so one loop iteration moves 64 bytes and 1000 iterations move the whole screen:
%macro draw_screen 0
mov esi, 010000h
mov edi, 0A0000h
mov cx, 1000
%%copy_loop:
movq mm0, [esi]
movq mm1, [esi+8]
; ... mm2 - mm7
movq [edi], mm0
movq [edi+8], mm1
; ... mm2 - mm7
add esi, 64
add edi, 64
loop %%copy_loop
%endmacro
MMX came out in 1996, so for a 2005 hobby project it was the cheapest "SIMD" I could get on any PC.
real-mode-3d: everything in one sector #
The second program, written in 2018, sets a harder limit: the whole thing must fit in the 512-byte boot sector that the BIOS loads. No second stage, no extra sectors.

It stays in real mode, enters mode 13h through int 10h, points ES at segment 0xA000 and writes pixels with [es:di]. Pixel and line drawing are NASM macros, not procedures, which saves the call/ret overhead and lets the assembler drop what is not used:
%macro PutPixelAt 3
mov bx, %2
imul bx,320
add bx, %1
mov di, bx
mov al, %3
mov [es:di], al
%endmacro
Bresenham first in Ruby, then in assembly #
Lines use Bresenham's integer-only algorithm. I first wrote it in Ruby (bresenham_integer.rb) to have a readable reference, and then translated it line by line into the LineFromTo macro. The comments in the assembly are the Ruby code:
mov ax, [vdy] ; d = 2*dy - dx
imul ax, 2
sub ax, [vdx]
mov [vd], ax
...
cmp word [vd], 0 ; if d > 0
jg %%.True
The 3D part is the same simplified projection I used on the Commodore 64 (see 3D on the Commodore 64): the depth is divided by constants and added to x and y, which gives an oblique, almost orthographic view with only integer div. No floating point, no FPU, no lookup tables.
Building and running #
nasm main.asm -f bin -o boot.bin
dd if=/dev/zero of=disk.img bs=1024 count=1440
dd if=boot.bin of=disk.img conv=notrunc
Bochs boots it with a minimal bochsrc.bxrc:
megs: 32
floppya: 1_44=disk.img, status=inserted
boot: a
log: bochsout.txt
cpu: ips=15000000
What I took from it #
- Real mode → protected mode is about twenty instructions, but every one of them has to be in the right order. Bochs's log (
bochsout.txt) is the only debugger you have, and it is enough. - 512 bytes is a very good teacher. Macros vs. procedures, register reuse and instruction size suddenly matter.
- Writing an algorithm first in a high-level language (here Ruby) and then hand-translating it is the fastest way I know to get correct assembly.
Source: github.com/michalasobczak/x86-bootloader. See also the overview of my hands-on projects.

