---
title: "x86 bootloader: 2D graphics in protected mode and 3D in 512 bytes"
url: https://michalasobczak.pl/posts/x86-bootloader-2d-and-3d-graphics-in-nasm.html
author: "Michał Sobczak"
description: "Two bare-metal x86 programs that boot from a floppy image without any operating system: an animated 2D demo in 32-bit protected mode with MMX, and a real-mode 3D projection that has to fit in a single 512-byte boot sector."
published: 2026-10-25T09:00:00.000Z
updated: 2026-10-25T09:00:00.000Z
language: en
categories: ["Hobby"]
tags: ["Programming", "Vintage Computing", "3D"]
license: CC BY-NC-ND 4.0 (https://creativecommons.org/licenses/by-nc-nd/4.0/)
---
# 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](https://github.com/michalasobczak/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](https://michalasobczak.pl/media/projects/x86-protected-mode-2d.png)

### 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](https://michalasobczak.pl/media/projects/x86-real-mode-3d.png)

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](https://michalasobczak.pl/posts/commodore-64-restoration-and-3d-in-basic-c-and-assembly.html)): 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](https://github.com/michalasobczak/x86-bootloader). See also the [overview of my hands-on projects](https://michalasobczak.pl/posts/hands-on-projects-on-github.html).
