Skip to content

vga text mode

Mohiuddin Khan Inamdar edited this page Jun 21, 2026 · 3 revisions

← Home

VGA Text Mode

How a freestanding kernel paints characters on screen by writing bytes straight into memory — no driver, no syscall, no BIOS.

When the BIOS hands control to your boot sector, the display is already in VGA text mode 3: an 80-column by 25-row grid of character cells, 16 colors. The screen is not something you "draw" pixel by pixel — instead, a region of physical memory is the screen. Write a byte there and a glyph appears. This is the simplest possible output device, and it is why MyOS-Simple can print "Hello, World!" from a kernel that contains no libc, no printf, and no graphics code at all.

The memory-mapped framebuffer

The text-mode framebuffer lives at physical address 0xB8000. It is memory-mapped, not an I/O port — you reach it with an ordinary pointer dereference, not with inb/outb. (Contrast this with the PS/2 keyboard, which is read through I/O ports.)

#define VIDEO_MEMORY 0xb8000

kernel.c:12, and identically shell.c:13.

Because the project runs in flat 32-bit protected mode with an identity-mapped GDT, the linear address 0xB8000 maps directly to that physical address, so the pointer "just works."

Cell layout: two bytes per character

Each of the 80×25 = 2000 cells is two bytes:

Byte Meaning
0 (even) The ASCII character code
1 (odd) The attribute (color) byte

So the whole screen is 80 * 25 * 2 = 4000 bytes. The offset of cell (x, y) is:

int offset = (y * 80 + x) * 2;
video[offset]     = c;     // character
video[offset + 1] = attr;  // color

putchar_at, kernel.c:51-56 (and shell.c:44-49).

💡 Tidbit: The (y * 80 + x) * 2 formula is the text-mode equivalent of a stride calculation in a pixel framebuffer — 80 is the row stride in cells and 2 is the bytes-per-cell. Every framebuffer you ever touch, graphical or textual, computes addresses this same way.

The pointer is declared volatile so the compiler never caches or reorders the writes — every store must actually reach video memory:

volatile char* video = (volatile char*)VIDEO_MEMORY;

kernel.c:52.

The attribute byte

The attribute byte packs a background color in the high nibble and a foreground color in the low nibble:

  bit:  7   6 5 4   3 2 1 0
       [B] [ BG  ] [  FG   ]

make_color builds one by hand:

char make_color(char bg, char fg) {
    return (bg << 4) | fg;
}

kernel.c:46-48. Equivalently, attribute = (background << 4) | foreground.

The 16 colors

The low nibble (foreground) can be any of 16 colors 0x00xF:

Code Color Code Color
0x0 BLACK 0x8 DARK_GRAY
0x1 BLUE 0x9 LIGHT_BLUE
0x2 GREEN 0xA LIGHT_GREEN
0x3 CYAN 0xB LIGHT_CYAN
0x4 RED 0xC LIGHT_RED
0x5 MAGENTA 0xD LIGHT_MAGENTA
0x6 BROWN 0xE YELLOW
0x7 LIGHT_GRAY 0xF WHITE

These are defined as macros in kernel.c:15-30. A combined constant like WHITE_ON_BLACK is simply 0x0F (kernel.c:33) — black background nibble 0, white foreground nibble F.

The kernel even renders a live swatch of all 16 by drawing space characters whose background equals their foreground:

for (int i = 0; i < 16; i++) {
    putchar_at(' ', 13 + i*2, 23, make_color(i, i));
    putchar_at(' ', 13 + i*2 + 1, 23, make_color(i, i));
}

kernel.c:293-296.

⚠️ Caveat: In standard VGA text mode, bit 7 of the attribute byte (the top bit of the background nibble) is the blink attribute by default, not an extra background-color bit. That is why the background is effectively limited to 8 colors (0x00x7) while the foreground gets all 16: setting a "background" of 0x80xF instead makes the cell blink. Blink can be disabled via a VGA register to free that bit for backgrounds, but MyOS-Simple never touches it, so high background codes will blink.

Clearing the screen

There is no "clear" instruction — you blank the screen by writing a space into every cell with a chosen attribute:

void clear_screen_color(char bg_color) {
    volatile char* video = (volatile char*)VIDEO_MEMORY;
    char attr = make_color(bg_color, WHITE);
    for (int i = 0; i < 80 * 25 * 2; i += 2) {
        video[i] = ' ';
        video[i + 1] = attr;
    }
}

kernel.c:73-80. The shell's clear_screen is the same loop with a fixed WHITE_ON_BLACK attribute (shell.c:51-59), and it also resets the software cursor to (0, 0).

Scrolling

VGA text mode has no scroll hardware that the project uses, so the shell scrolls by hand: when the cursor passes the bottom row it copies rows 1–24 up into rows 0–23, then blanks the last row.

void scroll_screen() {
    volatile char* video = (volatile char*)VIDEO_MEMORY;
    // Move all lines up by one
    for (int y = 0; y < 24; y++) {
        for (int x = 0; x < 80; x++) {
            int src_offset = ((y + 1) * 80 + x) * 2;
            int dst_offset = (y * 80 + x) * 2;
            video[dst_offset]     = video[src_offset];
            video[dst_offset + 1] = video[src_offset + 1];
        }
    }
    // Clear the last line
    for (int x = 0; x < 80; x++) {
        int offset = (24 * 80 + x) * 2;
        video[offset]     = ' ';
        video[offset + 1] = WHITE_ON_BLACK;
    }
}

shell.c:61-80. It is triggered from putchar when cursor_y >= 25 (shell.c:101-104).

The cursor is software-only

There is no hardware text cursor management in this project. The shell tracks a software cursor with two integers (cursor_x, cursor_yshell.c:33-34) and advances them inside putchar (shell.c:82-105). The color demo draws a literal underscore glyph to look like a cursor and erases it by overwriting with a space:

// Show cursor
putchar_at('_', cursor_x, cursor_y, WHITE_ON_BLACK);
...
// Clear cursor
putchar_at(' ', cursor_x, cursor_y, WHITE_ON_BLACK);

kernel.c:304 and kernel.c:311. The real blinking hardware cursor (controlled via CRTC registers on ports 0x3D4/0x3D5) is never programmed.

💡 Tidbit: Because output is just memory writes, the kernel can update any cell at any time without "moving" a cursor first — the live keyboard-modifier status line (show_keyboard_status, kernel.c:205-250) and the cursor underscore are both drawn directly at fixed coordinates while the input loop runs.

See also

Clone this wiki locally