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.
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:
- Bits 0–7: ASCII code (using code page 437)
- Bits 8–11: foreground color
- Bits 12–14: background color
- Bit 15: blink flag
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))
}
}
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],
}
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);
}
}
}
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) },
});
}
Print Macros
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();
}
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 {}
}
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.