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
| Value | Colour | Value | Colour |
|---|---|---|---|
| 0 | Black | 8 | Dark Gray |
| 1 | Blue | 9 | Light Blue |
| 2 | Green | 10 | Light Green |
| 3 | Cyan | 11 | Light Cyan |
| 4 | Red | 12 | Light Red |
| 5 | Magenta | 13 | Pink |
| 6 | Brown | 14 | Yellow |
| 7 | Light Gray | 15 | White |
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
&mutof 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 inno_stdkernels.
Memory-Mapped I/O vs Port I/O
Two different ways hardware communicates with the CPU:
| Memory-Mapped I/O | Port I/O | |
|---|---|---|
| Example | VGA buffer at 0xB8000 | Keyboard at port 0x60 |
| Access | Normal memory reads/writes | Special in/out CPU instructions |
| Address space | Shares physical memory space | Separate 16-bit address space (ports 0–65535) |
| Rust | Raw pointer access | Inline assembly |
The VGA buffer uses memory-mapped I/O. The VGA cursor control registers use port I/O. We end up using both.