Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

VGA Text Mode

What is VGA Text Mode?

VGA (Video Graphics Array) text mode is a simple way to display text on screen. When a computer boots, it starts in VGA text mode by default — so the screen is already working before we do anything. We just need to write to the right memory address.

Key properties:

  • 80 columns × 25 rows = 2000 character positions
  • 16 foreground colours, 16 background colours
  • Memory-mapped at physical address 0xB8000

The VGA Buffer

The VGA text buffer lives at 0xB8000 in physical memory. Writing to this address writes to the screen.

Each character takes 2 bytes:

[Byte 0]: ASCII character code
[Byte 1]: Colour attribute (4 bits background | 4 bits foreground)

Buffer layout:

Screen position (row 0, col 0) → Address 0xB8000
Screen position (row 0, col 1) → Address 0xB8002
Screen position (row 1, col 0) → Address 0xB80A0  (row * 80 * 2 = 160 bytes per row)

Formula for any position:

#![allow(unused)]
fn main() {
let offset = (row * VGA_WIDTH + col) * 2;
// Character byte at: 0xB8000 + offset
// Colour byte at:    0xB8000 + offset + 1
}

Colour System

The colour byte packs foreground and background into 8 bits:

Bit: 7 6 5 4 | 3 2 1 0
     ────────┼────────
     Background│Foreground
#![allow(unused)]
fn main() {
pub const fn color_byte(fg: Color, bg: Color) -> u8 {
    (bg as u8) << 4 | (fg as u8)
}
}

Colour Values

ValueColourValueColour
0Black8Dark Gray
1Blue9Light Blue
2Green10Light Green
3Cyan11Light Cyan
4Red12Light Red
5Magenta13Pink
6Brown14Yellow
7Light Gray15White

The Writer

The Writer struct tracks cursor position and current colour, and exposes methods to write to the buffer:

#![allow(unused)]
fn main() {
pub struct Writer {
    column: usize,
    row: usize,
    color: u8,
}
}

Writing a Byte

#![allow(unused)]
fn main() {
pub fn write_byte(&mut self, byte: u8) {
    match byte {
        b'\n' => self.new_line(),
        byte => {
            if self.column >= VGA_WIDTH {
                self.new_line();
            }

            let offset = (self.row * VGA_WIDTH + self.column) * 2;

            unsafe {
                *VGA_BUFFER.add(offset) = byte;
                *VGA_BUFFER.add(offset + 1) = self.color;
            }

            self.column += 1;
        }
    }
    self.update_cursor();
}
}

The unsafe block is required because we’re writing to a raw pointer — we’re directly manipulating hardware memory. Rust can’t verify this is safe; we know it is because we control the address space.

Scrolling

When the cursor reaches the last row, we scroll the entire screen up by one row:

#![allow(unused)]
fn main() {
fn scroll(&mut self) {
    unsafe {
        // Move each row up by one
        for row in 1..VGA_HEIGHT {
            for col in 0..VGA_WIDTH {
                let src = (row * VGA_WIDTH + col) * 2;
                let dst = ((row - 1) * VGA_WIDTH + col) * 2;

                *VGA_BUFFER.add(dst)     = *VGA_BUFFER.add(src);
                *VGA_BUFFER.add(dst + 1) = *VGA_BUFFER.add(src + 1);
            }
        }

        // Clear the last row
        for col in 0..VGA_WIDTH {
            let offset = ((VGA_HEIGHT - 1) * VGA_WIDTH + col) * 2;
            *VGA_BUFFER.add(offset)     = b' ';
            *VGA_BUFFER.add(offset + 1) = self.color;
        }
    }

    self.row = VGA_HEIGHT - 1;
    self.column = 0;
}
}

Hardware Cursor

The VGA hardware has its own blinking cursor. We control it through port I/O — a separate address space accessed with special CPU instructions.

  • Port 0x3D4 — Control register (select which cursor setting to change)
  • Port 0x3D5 — Data register (write the value)
#![allow(unused)]
fn main() {
fn update_cursor(&self) {
    let pos = self.row * VGA_WIDTH + self.column;

    unsafe {
        // Send high byte of position
        outb(VGA_CTRL_PORT, 0x0E);
        outb(VGA_DATA_PORT, (pos >> 8) as u8);

        // Send low byte of position
        outb(VGA_CTRL_PORT, 0x0F);
        outb(VGA_DATA_PORT, pos as u8);
    }
}
}

Port I/O uses special CPU instructions — out to write, in to read — accessed from Rust via inline assembly:

#![allow(unused)]
fn main() {
unsafe fn outb(port: u16, value: u8) {
    core::arch::asm!(
        "out dx, al",
        in("dx") port,
        in("al") value,
        options(nomem, nostack, preserves_flags)
    );
}
}

Global Writer

The writer needs to be accessible from anywhere in the kernel (including interrupt handlers). We use a static global with addr_of_mut! to safely get a mutable reference without triggering Rust’s stacking-borrow rules:

#![allow(unused)]
fn main() {
static mut WRITER: Writer = Writer::new();

pub fn writer() -> &'static mut Writer {
    unsafe { &mut *core::ptr::addr_of_mut!(WRITER) }
}
}

Why addr_of_mut! instead of &mut WRITER?

Taking &mut of a static directly can create undefined behaviour under Rust’s aliasing rules. addr_of_mut! creates a raw pointer to the static without creating a reference, which is then safely cast. This is the correct pattern for mutable statics in no_std kernels.

Memory-Mapped I/O vs Port I/O

Two different ways hardware communicates with the CPU:

Memory-Mapped I/OPort I/O
ExampleVGA buffer at 0xB8000Keyboard at port 0x60
AccessNormal memory reads/writesSpecial in/out CPU instructions
Address spaceShares physical memory spaceSeparate 16-bit address space (ports 0–65535)
RustRaw pointer accessInline assembly

The VGA buffer uses memory-mapped I/O. The VGA cursor control registers use port I/O. We end up using both.