Part 3 - Easy Text

VGA Text Mode (Part 3)

In this section, I dive into VGA text mode by building a small Rust module that lets us safely write text to the screen. Along the way, I’ll wrap all the unsafe bits, add user-friendly formatting, and sprinkle in some personal notes and tips.

Tip

If you haven’t read the previous parts, make sure you understand memory mapping and basic Rust modules first.

Understanding the VGA Text Buffer

The VGA text buffer is basically a 25×80 grid of characters and color codes living at memory address 0xb8000. Each character cell is two bytes:

Warning

Not all hardware supports full RAM operations at 0xb8000. On some VMs or old machines, reads can be slow or behave unexpectedly.

Standard VGA colors

I like to keep a handy enum in Rust for colors. Here’s my version (I removed the default DarkGray alias to keep it lean):

#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
    Black   = 0,
    Blue    = 1,
    Green   = 2,
    Cyan    = 3,
    Red     = 4,
    Magenta = 5,
    Brown   = 6,
    LightGray = 7,
    LightBlue = 9,
    LightGreen = 10,
    LightCyan  = 11,
    LightRed   = 12,
    Pink       = 13,
    Yellow     = 14,
    White      = 15,
}

Next, I wrap foreground and background into a ColorCode:

#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ColorCode(u8);

impl ColorCode {
    pub fn new(fg: Color, bg: Color) -> Self {
        // background in high nibble, foreground in low nibble
        ColorCode((bg as u8) << 4 | (fg as u8))
    }
}
Note

The repr(transparent) on ColorCode ensures our struct layout is exactly one byte under the hood.

Modeling the Screen Buffer

Each screen cell is a struct of an ASCII byte plus a color code. I also use the volatile crate to force every write:

# Cargo.toml dependencies
volatile = "0.2"
use volatile::Volatile;

#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ScreenChar {
    ascii_character: u8,
    color_code: ColorCode,
}

const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;

#[repr(transparent)]
struct Buffer {
    chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}
Tip

Always check your VGA buffer layout with a debugger if you see strange characters!

The Writer Struct

My Writer struct holds position state and a pointer to the buffer. Here’s how I implement writing single bytes, strings, and automatic newlines:

pub struct Writer {
    column_position: usize,
    color_code: ColorCode,
    buffer: &'static mut Buffer,
}

impl Writer {
    pub fn write_byte(&mut self, byte: u8) {
        match byte {
            b'\n' => self.new_line(),
            byte => {
                if self.column_position >= BUFFER_WIDTH {
                    self.new_line();
                }
                let row = BUFFER_HEIGHT - 1;
                let col = self.column_position;
                self.buffer.chars[row][col].write(ScreenChar { ascii_character: byte, color_code: self.color_code });
                self.column_position += 1;
            }
        }
    }

    pub fn write_string(&mut self, s: &str) {
        for &b in s.as_bytes() {
            match b {
                0x20..=0x7e | b'\n' => self.write_byte(b),
                _ => self.write_byte(0xfe), // ■ for non-printable
            }
        }
    }

    fn new_line(&mut self) {
        for row in 1..BUFFER_HEIGHT {
            for col in 0..BUFFER_WIDTH {
                let ch = self.buffer.chars[row][col].read();
                self.buffer.chars[row - 1][col].write(ch);
            }
        }
        self.clear_row(BUFFER_HEIGHT - 1);
        self.column_position = 0;
    }

    fn clear_row(&mut self, row: usize) {
        let blank = ScreenChar { ascii_character: b' ', color_code: self.color_code };
        for col in 0..BUFFER_WIDTH {
            self.buffer.chars[row][col].write(blank);
        }
    }
}
Warning

Accessing buffer is unsafe under the hood. Make sure you never alias this mutable reference elsewhere.

Formatting Support

To use write! and writeln!, implement core::fmt::Write:

use core::fmt;

impl fmt::Write for Writer {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        self.write_string(s);
        Ok(())
    }
}

Global Writer

For convenience, I wrap the writer in a spin::Mutex behind lazy_static:

# Cargo.toml additions
lazy_static = { version = "1.0", features = ["spin_no_std"] }
spin = "0.5"
use lazy_static::lazy_static;
use spin::Mutex;

lazy_static! {
    pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
        column_position: 0,
        color_code: ColorCode::newYellow, Color::Black, // my favorite combo
        buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
    });
}

I define print! and println! to call my internal _print function:

#[macro_export]
macro_rules! print {
    ($($arg:tt)*) => _print(format_args!($($arg)*));
}

#[macro_export]
macro_rules! println {
    () => print!("\n");
    ($($arg:tt)*) => print!("{}\n", format_args!($($arg)*));
}

#[doc(hidden)]
pub fn _print(args: core::fmt::Arguments) {
    use core::fmt::Write;
    WRITER.lock().write_fmt(args).unwrap();
}
Note

If you see a panic inside _print, check for deadlocks or double locks on WRITER.

Putting It to Work

In src/main.rs, I call println! from _start:

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello from VGA! -> {}", 2025);
    loop {}
}

And to catch panics:

use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    println!("Panic: {}", info);
    loop {}
}
Tip

You can customize panic output with colors by changing the color_code in WRITER.


I hope you enjoyed my take on VGA text mode! In the next part, I’ll show you how I test this module without relying on hardware. Stay tuned.