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

Animated 2D graphics drawn by the protected-mode bootloader in Bochs

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.

Animated 2D graphics drawn by the protected-mode bootloader in Bochs

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:

  1. reset the drive and read the next 10 sectors (the actual program) to 0000:1000 with int 13h,
  2. switch to video mode 13h (320×200, 256 colours) with int 10h,
  3. disable interrupts, load the Global Descriptor Table with lgdt,
  4. set bit 0 of CR0 and do a far jump to flush the prefetch queue,
  5. in 32-bit code: set the data and stack segments and jump to the program at 08h:01000h.
x86asm
  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:

x86asm
%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.

Real-mode 3D projection rendered from a 512-byte boot sector

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:

x86asm
%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:

x86asm
  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 #

Bash
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:

Text
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.

View as Markdown

All the content on this website is licensed under Attribution-NonCommercial-NoDerivatives 4.0 International (CC BY-NC-ND 4.0). Cite as: Michał Sobczak, “x86 bootloader: 2D graphics in protected mode and 3D in 512 bytes”, 25 October 2026, https://michalasobczak.pl/posts/x86-bootloader-2d-and-3d-graphics-in-nasm.html