Skip to content

03. VGA Text Mode Display

Jose edited this page Aug 18, 2021 · 12 revisions

We would better have a nice set of testing & debugging routines before starting to implement any complicated logic in the kernel. Yet, debugging by printing is impossible without a working video display (we will also setup GDB debugger with QEMU in chapter 5; despite of this, terminal display is so indispensable that almost all OS developers choose to do this in the early stage). In this chapter, we will enable our simple VGA text mode display functionality.

When tackling with the hardware, we will have to face some petty technical details and specifications.

Main References of This Chapter

Scan through them before going forth:

"Lower" Memory & VGA Specifications

The processor locates and communicates with other devices through addresses. The size of a 32-bit address space is limited to 2^32 = 4GiB. Yet, this address space is not one-on-one mapped onto free physical memory. Instead, there are certain standardized addresses reserved for BIOS data, the bootloader, video card memory, and other memory-mapped hardware devices. Memory map on x86 specifies a special region 0x00000000~0x00100000 (the first 1MiB) as the reserved "lower" memory. A detailed anatomy of this region is as follows (thanks Huan for this wonderful figure):

Within the "lower" memory region, 0x000A0000~0x000BFFFF is reserved for VGA display memory. For simplicity, we will be using the VGA text mode display, where the screen is indexed by cell entries (default to 80x25 window size) instead of pixels. The text mode buffer starts at address 0x000B8000. Each entry takes 2 bytes to describe a cell (table from wikipedia):

Note that on our default mode setup, the blinking bit is off and serves as the fourth background color bit. Thus, attribute bits 7-4 specify background color, attribute bits 3-0 specify foreground color, and character bits 7-0 specify the character symbol. VGA text mode display can be directly implemented by writing to the video memory text mode buffer. (Another option is to call video BIOS functions, but the previous one is often considered a better approach.)

Besides text content, we may also want to fire control signals to the hardware to do things like enabling and moving the cursor. There are many internal registers that we can control. Control signals are sent through I/O ports, by first sending "which internal register I'm going to set" through port 0x3D4, then sending "the value" through port 0x3D5. See "Text Mode Cursor". Example assembly:

outb 0x3D4, 0x0F    # Register 0x0F: cursor pos index lower 8 bits.
outb 0x3D5, 0       # Set to zero.

In short, displaying text on a 80x25 VGA window in chrome text mode is all about manipulating the first 2x80x25 bytes starting from 0x000B8000, plus sending a few control signals with inline assembly ✭.

Terminal Display Implementation

We will put display-related source code in src/display/. Notice that Hux is not mimicking existing UNIX & Linux design, so our repo structure diverges from UNIX tradition. We will not follow instructions on the "Meaty Skeleton" page.

Hardcoded Colorcodes

First we hardcode VGA colorcodes @ src/display/vga.h:

/** Hardcoded 4-bit color codes. */
enum vga_color {
    VGA_COLOR_BLACK         = 0,
    VGA_COLOR_BLUE          = 1,
    VGA_COLOR_GREEN         = 2,
    VGA_COLOR_CYAN          = 3,
    VGA_COLOR_RED           = 4,
    VGA_COLOR_MAGENTA       = 5,
    VGA_COLOR_BROWN         = 6,
    VGA_COLOR_LIGHT_GREY    = 7,
    VGA_COLOR_DARK_GREY     = 8,
    VGA_COLOR_LIGHT_BLUE    = 9,
    VGA_COLOR_LIGHT_GREEN   = 10,
    VGA_COLOR_LIGHT_CYAN    = 11,
    VGA_COLOR_LIGHT_RED     = 12,
    VGA_COLOR_LIGHT_MAGENTA = 13,
    VGA_COLOR_LIGHT_BROWN   = 14,
    VGA_COLOR_WHITE         = 15,
};
typedef enum vga_color vga_color_t;


/**
 * VGA entry composer.
 * A VGA entry = [4bits bg | 4bits fg | 8bits content].
 */
static inline uint16_t
vga_entry(vga_color_t bg, vga_color_t fg, unsigned char c)
{
    return (uint16_t) c | (uint16_t) fg << 8 | (uint16_t) bg << 12;
}

Making a String Library

Since we cannot plug-and-use a pre-written C standard library, we have to implement our own. All common functions ("common" here means used by many other kernel parts) shall be put under src/common/. For terminal display, we will first make a string library.

Code @ src/common/string.h:

size_t strlen(const char *str);

Code @ src/common/string.c:

/** Length of the string (excluding the terminating '\0'). */
size_t
strlen(const char *str)
{
    size_t len = 0;
    while (str[len])
        len++;
    return len;
}

Functions other than strlen() are not needed by the display part. We will come back and add them when we need them.

Printing to Terminal

Declare the terminal display function headers @ src/display/terminal.h:

/**
 * Default to black background + light grey foreground.
 * Foreground color can be customized with '*_color' functions.
 */
extern const vga_color_t TERMINAL_DEFAULT_COLOR_BG;
extern const vga_color_t TERMINAL_DEFAULT_COLOR_FG;


void terminal_init();

void terminal_write(const char *data, size_t size);
void terminal_write_color(const char *data, size_t size, vga_color_t fg);

void terminal_clear();

Implement these functions @ src/display/terminal.c:

static uint16_t * const VGA_MEMORY = (uint16_t *) 0xB8000;
static const size_t VGA_WIDTH  = 80;
static const size_t VGA_HEIGHT = 25;


/**
 * Default to black background + light grey foreground.
 * Foreground color can be customized with '*_color' functions.
 */
const vga_color_t TERMINAL_DEFAULT_COLOR_BG = VGA_COLOR_BLACK;
const vga_color_t TERMINAL_DEFAULT_COLOR_FG = VGA_COLOR_LIGHT_GREY;


static uint16_t *terminal_buf;
static size_t terminal_row;     /** Records current logical cursor pos. */
static size_t terminal_col;


/**
 * Put a character at current cursor position with specified foreground color,
 * then update the logical cursor position.
 */
static void
putchar_color(char c, vga_color_t fg)
{
    size_t idx = terminal_row * VGA_WIDTH + terminal_col;
    terminal_buf[idx] = vga_entry(TERMINAL_DEFAULT_COLOR_BG, fg, c);

    if (++terminal_col == VGA_WIDTH) {
        terminal_col = 0;
        if (++terminal_row == VGA_HEIGHT) {
            terminal_row = 0;
        }
    }
}


/** Initialize terminal display. */
void
terminal_init(void)
{
    terminal_buf = VGA_MEMORY;
    terminal_row = 0;
    terminal_col = 0;
    
    _enable_cursor();   /** Will come to this later. */
    terminal_clear();
}


/** Write a sequence of data. */
void
terminal_write(const char *data, size_t size)
{
    terminal_write_color(data, size, TERMINAL_DEFAULT_COLOR_FG);
}

/** Write a sequence of data with specified foreground color. */
void
terminal_write_color(const char *data, size_t size, vga_color_t fg)
{
    for (size_t i = 0; i < size; ++i)
        _putchar_color(data[i], fg);
    _update_cursor();   /** Will come to this later. */
}


/** Erase (backspace) a character. */
void
terminal_erase(void)
{
    if (terminal_col > 0)
        terminal_col--;
    else if (terminal_row > 0) {
        terminal_row--;
        terminal_col = VGA_WIDTH - 1;
    }

    size_t idx = terminal_row * VGA_WIDTH + terminal_col;
    terminal_buf[idx] = vga_entry(TERMINAL_DEFAULT_COLOR_BG,
                                  TERMINAL_DEFAULT_COLOR_FG, ' ');
    _update_cursor();
}

/** Clear the terminal window by flushing spaces. */
void
terminal_clear(void)
{
    for (size_t y = 0; y < VGA_HEIGHT; ++y) {
        for (size_t x = 0; x < VGA_WIDTH; ++x) {
            size_t idx = y * VGA_WIDTH + x;
            terminal_buf[idx] = vga_entry(TERMINAL_DEFAULT_COLOR_BG,
                                          TERMINAL_DEFAULT_COLOR_FG, ' ');
        }
    }

    terminal_row = 0;
    terminal_col = 0;
    _update_cursor();
}

More Advanced Tweaks

Updating Cursor Position

In the code above, we update the logical cursor position terminal_row/col after putting a char. However, terminal_row/col are just C variables we maintain. The actual cursor on screen is controlled by explicitly triggering I/O port signals, as stated in the first section of this chapter.

First, let us make an I/O port library @ src/common/port.h:

void outb(uint16_t port, uint8_t  val);
void outw(uint16_t port, uint16_t val);
void outl(uint16_t port, uint32_t val);

uint8_t  inb(uint16_t port);
uint16_t inw(uint16_t port);
uint32_t inl(uint16_t port);

Receiving from & Sending to I/O ports are done with assembly instructions in[b|l|w] & out[b|l|w]. Here we will use inline assembly in C (here's also a collection of examples; also see GNU official doc on inline assembly) ✭.

Code @ src/common/port.c:

/** Output 8 bits to an I/O port. */
inline void
outb(uint16_t port, uint8_t val)
{
    asm volatile ( "outb %0, %1" : : "a" (val), "d" (port) );
}

/** Output 16 bits to an I/O port. */
inline void
outw(uint16_t port, uint16_t val)
{
    asm volatile ( "outw %0, %1" : : "a" (val), "d" (port) );
}

/** Output 32 bits to an I/O port. */
inline void
outl(uint16_t port, uint32_t val)
{
    asm volatile ( "outl %0, %1" : : "a" (val), "d" (port) );
}

/** Output CNT 32-bit dwords from the buffer at ADDR to an I/O port. */
inline void
outsl(uint16_t port, const void *addr, uint32_t cnt)
{
    asm volatile ( "cld; rep outsl"
                   : "+S" (addr), "+c" (cnt)
                   : "d" (port) );
}


/** Input 8 bits from an I/O port. */
inline uint8_t
inb(uint16_t port)
{
    uint8_t ret;
    asm volatile ( "inb %1, %0" : "=a" (ret) : "d" (port) );
    return ret;
}

/** Input 16 bits from an I/O port. */
inline uint16_t
inw(uint16_t port)
{
    uint16_t ret;
    asm volatile ( "inw %1, %0" : "=a" (ret) : "d" (port) );
    return ret;
}

/** Input 32 bits from an I/O port. */
inline uint32_t
inl(uint16_t port)
{
    uint32_t ret;
    asm volatile ( "inl %1, %0" : "=a" (ret) : "d" (port) );
    return ret;
}

/** Input CNT 32-bit dwords into the buffer at ADDR. */
inline void
insl(uint16_t port, void *addr, uint32_t cnt)
{
    asm volatile ( "cld; rep insl"
                   : "+D" (addr), "+c" (cnt)
                   : "d" (port)
                   : "memory" );
}

Then add the following @ src/display/terminal.c:

/** Enable physical cursor and set thickness to 2. */
static void
_enable_cursor()
{
    outb(0x3D4, 0x0A);
    outb(0x3D5, (inb(0x3D5) & 0xC0) | 14);  /** Start at scanline 14. */
    outb(0x3D4, 0x0B);
    outb(0x3D5, (inb(0x3D5) & 0xE0) | 15);  /** End at scanline 15. */
}

/** Update the actual cursor position on screen. */
static void
_update_cursor()
{
    size_t idx = terminal_row * VGA_WIDTH + terminal_col;
    outb(0x3D4, 0x0F);
    outb(0x3D5, (uint8_t) (idx & 0xFF));
    outb(0x3D4, 0x0E);
    outb(0x3D5, (uint8_t) ((idx >> 8) & 0xFF));
}

An entry in VGA text mode is by default 9x15 pixels in size, where 15 = 14 scanlines for the character + 1 empty scanline underneath. The scanline settings in enable_cursor above means that the cursor shape starts from scanline 14 and ends at scanline 15, i.e., occupying the bottom two scanlines.

Handling Special Symbols

Special symbols, such as \r (carriage return) and \n (newline), move the cursor around without printing anything. They should be treated differently.

Update the _putchar_color function @ src/display/terminal.c with a switch-case statement:

/**
 * Put a character at current cursor position with specified foreground color,
 * then update the logical cursor position. Should consider special symbols.
 */
static void
_putchar_color(char c, vga_color_t fg)
{
    switch (c) {

    case '\b':  /** Backspace. */
        if (terminal_col > 0)
            terminal_col--;
        break;

    case '\t':  /** Horizontal tab. */
        terminal_col += 4;
        terminal_col -= terminal_col % 4;
        if (terminal_col == VGA_WIDTH)
            terminal_col -= 4;
        break;

    case '\n':  /** Newline (w/ carriage return). */
        terminal_row++;
        terminal_col = 0;
        break;

    case '\r':  /** Carriage return. */
        terminal_col = 0;
        break;

    default: ;  /** Displayable character. */
        size_t idx = terminal_row * VGA_WIDTH + terminal_col;
        terminal_buf[idx] = vga_entry(TERMINAL_DEFAULT_COLOR_BG, fg, c);

        if (++terminal_col == VGA_WIDTH) {
            terminal_row++;
            terminal_col = 0;
        }
    }

    /** When going beyond the bottom line, scroll up one line. */
    if (terminal_row == VGA_HEIGHT) {
        _scroll_line();     /** Will come to this later. */
        terminal_row--;
    }
}

Bottom Line Scrolling

When the cursor goes beyond the bottom line, a clever terminal would automatically scroll up one line. This can be done by copying line 1-24 to line 0-23 and clear the bottom line.

Add a _scroll_line function @ src/display/terminal.c:

/**
 * Scroll one line up, by replacing line 0-23 with the line below, and clear
 * the bottom line.
 */
static void
_scroll_line()
{
    for (size_t y = 0; y < VGA_HEIGHT; ++y) {
        for (size_t x = 0; x < VGA_WIDTH; ++x) {
            size_t idx = y * VGA_WIDTH + x;
            if (y < VGA_HEIGHT - 1)
                terminal_buf[idx] = terminal_buf[idx + VGA_WIDTH];
            else    /** Clear the last line. */
                terminal_buf[idx] = vga_entry(TERMINAL_DEFAULT_COLOR_BG,
                                              TERMINAL_DEFAULT_COLOR_FG, ' ');
        }
    }
}

Progress So Far

Let's try it out! Clean up src/kernel.c and add in the following test code:

/** The main function that `boot.s` jumps to. */
void
kernel_main(void)
{
    terminal_init();

    char *hello_str_1 = "Hello, world! ";
    for (int i = 1; i < 16; ++i)
        terminal_write_color(hello_str_1, strlen(hello_str_1), i);

    char *hello_str_2 = "Bello, del me\b\b\b\b\b\bkernel\tworld!\rH\n";
    terminal_write("\n", 1);
    for (int i = 1; i < 16; ++i)
        terminal_write_color(hello_str_2, strlen(hello_str_2), i);

    char *hello_str_3 = "Hello from Hux ;)\n";
    for (int i = 1; i < 8; ++i)
        terminal_write(hello_str_3, strlen(hello_str_3));
}

Build, launch QEMU, and select "Hux" from GRUB:

Notice that our current terminal is very primitive. We will add in more functionalities in later chapters:

  • A kernel printf: current printing interface is tedious. Where is our good old friend printf? In hosted C programming, we simply #include <stdio.h>. In freestanding kernel programming, we will have to make our own printf (and possibly scanf).
  • Keyboard input & control: an operating system must provide an interface to accept user inputs. This is tightly related to interrupts and is not an easy part to implement. We will come back to it later.

Current repo structure:

hux-kernel
├── Makefile
├── scripts
│   ├── grub.cfg
│   └── kernel.ld
├── src
│   ├── boot
│   │   └── boot.s
│   ├── common
│   │   ├── port.c
│   │   ├── port.h
│   │   ├── string.c
│   │   └── string.h
│   ├── display
│   │   ├── terminal.c
│   │   ├── terminal.h
│   │   └── vga.h
│   └── kernel.c