Skip to content

03 - VGA Text Output: Bringing Your OS to Life with Console Printing

This tutorial covers implementing VGA Text Output, a fundamental component in operating system development. Once you successfully boot to a black screen and confirm kernel control, adding text output becomes essential for debugging and displaying system information.

Understanding VGA Text Mode

VGA text mode (Mode 0x03) provides a display resolution of 80 characters by 25 lines. The system manages display output through a dedicated memory region located at address 0xB8000. Each character on screen requires two bytes in video memory:

  • Byte 1 (Character ASCII): Contains the ASCII value of the character to display
  • Byte 2 (Attribute Byte): Defines foreground color (lower 4 bits) and background color (upper 4 bits)

We'll define a TextColor enum in kernel.cpp to manage the 16 standard VGA colors systematically.

// Base address for video memory in text mode
#define VIDEO_MEMORY_ADDRESS 0xb8000

// Screen dimensions for text mode (80x25 characters)
#define SCREEN_WIDTH 80
#define SCREEN_HEIGHT 25

// Enum for text colors to represent foreground and background colors
typedef enum {
    BLACK = 0x0,
    BLUE = 0x1,
    GREEN = 0x2,
    CYAN = 0x3,
    RED = 0x4,
    MAGENTA = 0x5,
    BROWN = 0x6,
    LIGHT_GRAY = 0x7,
    DARK_GRAY = 0x8,
    LIGHT_BLUE = 0x9,
    LIGHT_GREEN = 0xA,
    LIGHT_CYAN = 0xB,
    LIGHT_RED = 0xC,
    LIGHT_MAGENTA = 0xD,
    YELLOW = 0xE,
    WHITE = 0xF
} TextColor;

Writing Characters Directly in kernel.cpp

We'll start by writing characters directly to video memory from kernel.cpp. This demonstrates the core mechanism before implementing more sophisticated methods.

#include <kernel.h>

#define VIDEO_MEMORY_ADDRESS 0xb8000
#define SCREEN_WIDTH 80
#define SCREEN_HEIGHT 25

// Enum for text colors to represent foreground and background colors
typedef enum {
    BLACK = 0x0,
    BLUE = 0x1,
    GREEN = 0x2,
    CYAN = 0x3,
    RED = 0x4,
    MAGENTA = 0x5,
    BROWN = 0x6,
    LIGHT_GRAY = 0x7,
    DARK_GRAY = 0x8,
    LIGHT_BLUE = 0x9,
    LIGHT_GREEN = 0xA,
    LIGHT_CYAN = 0xB,
    LIGHT_RED = 0xC,
    LIGHT_MAGENTA = 0xD,
    YELLOW = 0xE,
    WHITE = 0xF
} TextColor;

extern "C" void kernelMain(void* multiboot_structure, uint32_t magicnumber) {
    // Video memory is typically 16-bit, where the lower 8 bits are the ASCII character
    // and the upper 8 bits are the color attribute.
    unsigned short* VideoMemory = (unsigned short*)VIDEO_MEMORY_ADDRESS;

    VideoMemory[0] = (WHITE << 8) | 'A';
    VideoMemory[1] = (WHITE << 8) | 'B';
    VideoMemory[2] = (WHITE << 8) | 'C';
    VideoMemory[3] = (WHITE << 8) | 'D';

    while (1);
}

img_1

This code displays "ABCD" at the top-left corner of the screen. You might see remaining characters from the GRUB bootloader. To create a clean display, we'll clear the screen first, then iterate through a string for printing.

extern "C" void kernelMain(void* multiboot_structure, uint32_t magicnumber) {
    // Video memory is typically 16-bit, where the lower 8 bits are the ASCII character
    // and the upper 8 bits are the color attribute.
    unsigned short* VideoMemory = (unsigned short*)VIDEO_MEMORY_ADDRESS;

    // Clear the screen: Fill video memory with spaces (0x20) and a default color (e.g., white on black)
    for (int i = 0; i < SCREEN_WIDTH * SCREEN_HEIGHT; i++) {
        VideoMemory[i] = 0;
    }

    const char* message = "Hello World";
    // Loop through the message and place each character into video memory
    for (int i = 0; message[i] != '\0'; i++) {
        // Each character needs to be combined with the color attribute
        VideoMemory[i] = (unsigned short)(WHITE << 8) | message[i];
    }

    // Infinite loop to halt the kernel execution
    while (1);
}

img_2


Folder structure

hashx86/
├── kernel.cpp
├── console.cpp
├── linker.ld
├── Makefile
├── include/
│   ├── core/
│   │   └── ports.h
│   ├── kernel.h
│   ├── stdint.h
│   ├── types.h
│   └── console.h
├── core/
│   └── ports.cpp
└── asm/
    └── loader.asm

Implementing Console and Port Abstraction

To build a more robust text output system, we'll create console.cpp and console.h for our printf function, plus a Port class in a new core directory to handle hardware interactions, particularly cursor control.

Understanding ports.cpp and ports.h

The ports.cpp and ports.h files handle hardware device communication through I/O ports. In OS development, devices like keyboards, mice, and VGA controllers communicate with the CPU by reading from and writing to specific I/O port addresses.

  • ports.h: Declares the Port base class and derived classes (Port8Bit, Port8BitSlow, Port16Bit, Port32Bit). These provide object-oriented interfaces for different port bit widths, making code more modular and readable.

  • ports.cpp: Implements the Port classes. The Read() and Write() methods use inline assembly instructions (inb, outb, inw, outw, inl, outl) for direct hardware communication. The Port8BitSlow class includes additional delay instructions for older or slower hardware that needs more processing time.

core/ports.cpp

#include <core/ports.h>

Port::Port(uint16_t portNumber) {
    this->portNumber = portNumber;
}

Port::~Port() {
}

// Port8Bit
Port8Bit::Port8Bit(uint16_t portNumber) : Port(portNumber) {}

Port8Bit::~Port8Bit() {}

void Port8Bit::Write(uint8_t data) {
    asm volatile ("outb %0, %1" : : "a"(data), "Nd"(portNumber));
}

uint8_t Port8Bit::Read() {
    uint8_t result;
    asm volatile ("inb %1, %0" : "=a"(result) : "Nd"(portNumber));
    return result;
}

// Port8BitSlow
Port8BitSlow::Port8BitSlow(uint16_t portNumber) : Port8Bit(portNumber) {}

Port8BitSlow::~Port8BitSlow() {}

void Port8BitSlow::Write(uint8_t data) {
    asm volatile ("outb %0, %1\n\tjmp 1f\n1:\tjmp 1f\n1:" : : "a"(data), "Nd"(portNumber));
}


// Port16Bit
Port16Bit::Port16Bit(uint16_t portNumber) : Port(portNumber) {}

Port16Bit::~Port16Bit() {}


void Port16Bit::Write(uint16_t data) {
    asm volatile ("outw %0, %1" : : "a"(data), "Nd"(portNumber));
}

uint16_t Port16Bit::Read() {
    uint16_t result;
    asm volatile ("inw %1, %0" : "=a"(result) : "Nd"(portNumber));
    return result;
}


// Port32Bit
Port32Bit::Port32Bit(uint16_t portNumber) : Port(portNumber) {}

Port32Bit::~Port32Bit() {}

void Port32Bit::Write(uint32_t data) {
    asm volatile ("outl %0, %1" : : "a"(data), "Nd"(portNumber));
}

uint32_t Port32Bit::Read() {
    uint32_t result;
    asm volatile ("inl %1, %0" : "=a"(result) : "Nd"(portNumber));
    return result;
}

core/ports.h

#ifndef PORT_H
#define PORT_H

#include <../include/types.h>

    class Port {
        protected:
            uint16_t portNumber;
            Port(uint16_t portNumber);
            ~Port();
        };

    class Port8Bit : public Port {
        public:
            Port8Bit(uint16_t portNumber);
            ~Port8Bit();
            virtual void Write(uint8_t data);
            virtual uint8_t Read();
    };

    class Port8BitSlow : public Port8Bit {
        public:
            Port8BitSlow(uint16_t portNumber);
            ~Port8BitSlow();
            virtual void Write(uint8_t data);
    };

    class Port16Bit : public Port {
        public:
            Port16Bit(uint16_t portNumber);
            ~Port16Bit();
            virtual void Write(uint16_t data);
            virtual uint16_t Read();
    };

    class Port32Bit : public Port {
        public:
            Port32Bit(uint16_t portNumber);
            ~Port32Bit();
            virtual void Write(uint32_t data);
            virtual uint32_t Read();
    };

#endif

Building the Console System

These enhanced console files provide comprehensive functionality including printf with color support, screen clearing, update cursor position and scrolling.

console.h

#ifndef CONSOLE_H
#define CONSOLE_H

#include <core/ports.h>
#include <stdarg.h>

#define VIDEO_MEMORY_ADDRESS 0xb8000
#define SCREEN_WIDTH 80
#define SCREEN_HEIGHT 25

// Define text colors as an enum
typedef enum {
    BLACK = 0x0,
    BLUE = 0x1,
    GREEN = 0x2,
    CYAN = 0x3,
    RED = 0x4,
    MAGENTA = 0x5,
    BROWN = 0x6,
    LIGHT_GRAY = 0x7,
    DARK_GRAY = 0x8,
    LIGHT_BLUE = 0x9,
    LIGHT_GREEN = 0xA,
    LIGHT_CYAN = 0xB,
    LIGHT_RED = 0xC,
    LIGHT_MAGENTA = 0xD,
    YELLOW = 0xE,
    WHITE = 0xF
} TextColor;

void printf(TextColor color, const char* format, ...);
void clearScreen();
void scrollScreen();
TextColor combineColors(TextColor foreground, TextColor background);

#define DEBUG_LOG(format, ...) DebugPrintf("[DEBUG]", format, ##__VA_ARGS__)
#define PRINT(tag, format, ...) MSGPrintf(LIGHT_BLUE, tag, format, ##__VA_ARGS__)

void DebugPrintf(const char* tag, const char* format, ...);
void MSGPrintf(TextColor cTag, const char* tag, const char* format, ...);


#endif // CONSOLE_H

console.cpp

#include <console.h>

int cursorRow = 0;
int cursorCol = 0;

Port8Bit port_1(0x3D4);
Port8Bit port_2(0x3D5);

TextColor combineColors(TextColor foreground, TextColor background) {
    return (TextColor)((background << 4) | foreground);
}


// Simple Debug Wrapper Function
void DebugPrintf(const char* tag, const char* format, ...) {
    printf(LIGHT_RED, "%s", tag);    // Print the tag
    printf(LIGHT_GRAY, ":");

    va_list args;
    va_start(args, format);
    printf(LIGHT_GRAY, format, args); // Use the core printf
    va_end(args);

    printf(DARK_GRAY, "\n");          // Add newline for better output separation
}

// Simple Wrapper Function
void MSGPrintf(TextColor cTag, const char* tag, const char* format, ...) {
    printf(cTag, "[%s]", tag); // Print the tag
    printf(LIGHT_GRAY, ":");
    if (!format || !*format) {
        return; // Do nothing if the format is null or empty
    }

    // Check if the format string contains placeholders
    bool hasPlaceholders = false;
    for (const char* p = format; *p != '\0'; p++) {
        if (*p == '%') {
            hasPlaceholders = true;
            break;
        }
    }

    // If there are no placeholders, print the format string as is
    if (!hasPlaceholders) {
        printf(LIGHT_GRAY, format);
        return;
    }

    // Process the format string with arguments
    va_list args;
    va_start(args, format);

    for (const char* p = format; *p != '\0'; p++) {
        if (*p == '%') {
            p++;
            switch (*p) {
                case 's': { // String
                    const char* str = va_arg(args, const char*);
                    printf(LIGHT_GRAY, str);
                    break;
                }
                case 'd': { // Decimal integer
                    int num = va_arg(args, int);
                    printf(LIGHT_GRAY, "%d", num);
                    break;
                }
                case 'x': { // Hexadecimal
                    int num = va_arg(args, int);
                    printf(LIGHT_GRAY, "%x", num);
                    break;
                }
                default:
                    printf(LIGHT_GRAY, "%%");
                    printf(LIGHT_GRAY, "%c", *p);
                    break;
            }
        } else {
            printf(LIGHT_GRAY, "%c", *p);
        }
    }

    va_end(args);
}


void scrollScreen() {
    unsigned short* VideoMemory = (unsigned short*)VIDEO_MEMORY_ADDRESS;

    for (int row = 1; row < SCREEN_HEIGHT; row++) {
        for (int col = 0; col < SCREEN_WIDTH; col++) {
            VideoMemory[(row - 1) * SCREEN_WIDTH + col] = VideoMemory[row * SCREEN_WIDTH + col];
        }
    }

    unsigned short blank = 0x20 | (WHITE << 8); // Space with white on black
    for (int col = 0; col < SCREEN_WIDTH; col++) {
        VideoMemory[(SCREEN_HEIGHT - 1) * SCREEN_WIDTH + col] = blank;
    }
}

void updateCursor(int row, int col) {
    unsigned short position = row * SCREEN_WIDTH + col;

    port_1.Write(14);
    port_2.Write(position >> 8);
    port_1.Write(15);
    port_2.Write(position & 0xFF);
}

void clearScreen() {
    unsigned short* VideoMemory = (unsigned short*)VIDEO_MEMORY_ADDRESS;
    unsigned short blank = 0x20 | (WHITE << 8); // Space with white text on black background

    for (int i = 0; i < SCREEN_WIDTH * SCREEN_HEIGHT; i++) {
        VideoMemory[i] = blank;
    }

    cursorRow = 0;
    cursorCol = 0;
    updateCursor(0, 0);
}

void printf(TextColor color, const char* format, ...) {
    unsigned short* VideoMemory = (unsigned short*)VIDEO_MEMORY_ADDRESS;
    va_list args;
    va_start(args, format);

    for (int i = 0; format[i] != '\0'; i++) {
        if (format[i] == '%') {
            i++;
            switch (format[i]) {
                case 'd': { // Signed Integer
                    int num = va_arg(args, int);
                    char buffer[12]; // Enough for -2147483648 + '\0'
                    int index = 11;  // Start filling from the end
                    buffer[index] = '\0'; // Null-terminate

                    if (num == 0) {
                        buffer[--index] = '0';
                    } else if (num == -2147483648) { // Special case for INT_MIN
                        const char* minStr = "-2147483648";
                        for (int j = 0; minStr[j] != '\0'; j++) {
                            int position = cursorRow * SCREEN_WIDTH + cursorCol;
                            VideoMemory[position] = (color << 8) | minStr[j];
                            cursorCol++;
                            if (cursorCol >= SCREEN_WIDTH) { cursorCol = 0; cursorRow++; }
                            if (cursorRow >= SCREEN_HEIGHT) { scrollScreen(); cursorRow = SCREEN_HEIGHT - 1; }
                        }
                        break; // Exit the case here
                    } else {
                        bool isNegative = (num < 0);
                        if (isNegative) num = -num;

                        while (num > 0) {
                            buffer[--index] = (num % 10) + '0';
                            num /= 10;
                        }

                        if (isNegative) buffer[--index] = '-';
                    }

                    for (int j = index; buffer[j] != '\0'; j++) {
                        int position = cursorRow * SCREEN_WIDTH + cursorCol;
                        VideoMemory[position] = (color << 8) | buffer[j];
                        cursorCol++;
                        if (cursorCol >= SCREEN_WIDTH) { cursorCol = 0; cursorRow++; }
                        if (cursorRow >= SCREEN_HEIGHT) { scrollScreen(); cursorRow = SCREEN_HEIGHT - 1; }
                    }
                    break;
                }

                case 'u': { // Unsigned Integer
                    uint32_t num = va_arg(args, uint32_t);
                    char buffer[11];        // Enough for 0 to 4294967295
                    int index = 10;         // Start filling from the end
                    buffer[index] = '\0';   // Null-terminate

                    if (num == 0) {
                        buffer[--index] = '0';
                    } else {
                        while (num > 0) {
                            buffer[--index] = (num % 10) + '0';
                            num /= 10;
                        }
                    }

                    for (int j = index; buffer[j] != '\0'; j++) {
                        int position = cursorRow * SCREEN_WIDTH + cursorCol;
                        VideoMemory[position] = (color << 8) | buffer[j];
                        cursorCol++;
                        if (cursorCol >= SCREEN_WIDTH) { cursorCol = 0; cursorRow++; }
                        if (cursorRow >= SCREEN_HEIGHT) { scrollScreen(); cursorRow = SCREEN_HEIGHT - 1; }
                    }
                    break;
                }

                case 'x': { // Hexadecimal
                    uint32_t num = va_arg(args, uint32_t);
                    char buffer[9];        // Enough for 8 hex digits + '\0'
                    int index = 8;         // Start filling from the end
                    buffer[index] = '\0';  // Null-terminate
                    const char* hexDigits = "0123456789ABCDEF";

                    if (num == 0) {
                        buffer[--index] = '0';
                    } else {
                        while (num > 0) {
                            buffer[--index] = hexDigits[num % 16];
                            num /= 16;
                        }
                    }

                    for (int j = index; buffer[j] != '\0'; j++) {
                        int position = cursorRow * SCREEN_WIDTH + cursorCol;
                        VideoMemory[position] = (color << 8) | buffer[j];
                        cursorCol++;
                        if (cursorCol >= SCREEN_WIDTH) { cursorCol = 0; cursorRow++; }
                        if (cursorRow >= SCREEN_HEIGHT) { scrollScreen(); cursorRow = SCREEN_HEIGHT - 1; }
                    }
                    break;
                }

                case 's': { // String
                    const char* str = va_arg(args, const char*);
                    for (int j = 0; str[j] != '\0'; j++) {
                        int position = cursorRow * SCREEN_WIDTH + cursorCol;
                        VideoMemory[position] = (color << 8) | str[j];
                        cursorCol++;

                        if (cursorCol >= SCREEN_WIDTH) {
                            cursorCol = 0;
                            cursorRow++;
                        }

                        if (cursorRow >= SCREEN_HEIGHT) {
                            scrollScreen();
                            cursorRow = SCREEN_HEIGHT - 1;
                        }
                    }
                    break;
                }

                default:
                    break;
            }
        } else if (format[i] == '\n') {
            cursorRow++;
            cursorCol = 0; // Reset column to the beginning

            // Handle scrolling if we exceed the screen height
            if (cursorRow >= SCREEN_HEIGHT) {
                scrollScreen();
                cursorRow = SCREEN_HEIGHT - 1; // Move cursor to the last row
                cursorCol = 0; // Ensure cursor starts at the beginning of the row
            }

        } else {
            int position = cursorRow * SCREEN_WIDTH + cursorCol;
            VideoMemory[position] = (color << 8) | format[i];
            cursorCol++;

            if (cursorCol >= SCREEN_WIDTH) {
                cursorCol = 0;
                cursorRow++;
            }

            if (cursorRow >= SCREEN_HEIGHT) {
                scrollScreen();
                cursorRow = SCREEN_HEIGHT - 1;
            }
        }
    }
    va_end(args);
    updateCursor(cursorRow, cursorCol);
}

Integrating Console into kernel.cpp

Now we can include our console functionality in kernel.h and use the printf function for text display.

Add this include to the top of kernel.h:

#include <console.h>

Here's the updated kernel.cpp:

#include <kernel.h>

extern "C" void kernelMain(void* multiboot_structure, uint32_t magicnumber) {
    clearScreen();

    printf(combineColors(YELLOW, BLACK), "Welcome to My OS!\n");
    printf(WHITE, "HELLO FROM ME:\n");

    for (int i = 0; i < 23; i++) {
        printf(LIGHT_GREEN, "Line %d: Scrolling example\n", i + 1);
    }

    while (1) {
        asm volatile("hlt");
    }
}

Updating the Makefile

To compile our new console.cpp and core/ports.cpp files correctly, update the Makefile:

GPP_PARAMS = -m32 -g -ffreestanding -Iinclude -fno-use-cxa-atexit -nostdlib -fno-builtin -fno-rtti -fno-exceptions -fno-common
ASM_PARAMS = --32 -g
ASM_NASM_PARAMS = -f elf32
objects = asm/loader.o \
          kernel.o \
          console.o \
          core/ports.o

LD_PARAMS = -melf_i386

# Compiling C++ files inside the main directory
%.o: %.cpp
    g++ $(GPP_PARAMS) -o $@ -c $<

# Compiling C++ files inside the core directory
core/%.o: core/%.cpp
    g++ $(GPP_PARAMS) -o $@ -c $<

# Compiling NASM assembly files
asm/%.o: asm/%.asm
    nasm $(ASM_NASM_PARAMS) -o $@ $<

# Linking the kernel binary
kernel.bin: linker.ld $(objects)
    ld $(LD_PARAMS) -T $< -o $@ $(objects)

# Install the kernel binary
install: kernel.bin
    sudo cp kernel.bin /boot/kernel.bin

# Clean rule: removes object files and the final binary
clean:
    rm -f $(objects) kernel.bin

runq:
    qemu-system-i386 -cdrom kernel.iso -boot d  -vga std -serial stdio -m 1G -d int,cpu_reset -D ./log.txt

run:
    make clean
    make
    make iso
    qemu-system-i386 -cdrom kernel.iso -boot d  -vga std -serial stdio -m 1G -d int,cpu_reset -D ./log.txt

runvb: kernel.iso
    (killall VirtualBox && sleep 1) || true
    VirtualBox --startvm 'My Operating System' &

iso: kernel.bin
    mkdir iso
    mkdir iso/boot
    mkdir iso/boot/grub
    mkdir iso/boot/font
    cp kernel.bin iso/boot/kernel.bin
    echo 'set timeout=0'                      > iso/boot/grub/grub.cfg
    echo 'set default=0'                     >> iso/boot/grub/grub.cfg
    echo 'terminal_output gfxterm'         >> iso/boot/grub/grub.cfg
    echo ''               >> iso/boot/grub/grub.cfg
    echo 'menuentry "My Operating System" {' >> iso/boot/grub/grub.cfg
    echo '  multiboot /boot/kernel.bin'      >> iso/boot/grub/grub.cfg
    echo '  boot'      >> iso/boot/grub/grub.cfg
    echo '}'                                 >> iso/boot/grub/grub.cfg
    grub-mkrescue --output=kernel.iso --modules="video gfxterm video_bochs video_cirrus" iso
    rm -rf iso

Expected Output

After compiling and running your OS with these changes, you will see:

  1. Screen Clear: The clearScreen() function removes all previous GRUB output, presenting a clean black screen.

  2. "Welcome to My OS!": This message appears at the top in yellow text on black background. The \n moves the cursor to the next line.

  3. "HELLO FROM ME:": Displays on the next line in white text on black background.

  4. Scrolling Example: The for loop prints ten lines starting with "Line X: Scrolling example" in light green text. When the cursor reaches the bottom of the screen, the scrollScreen() function automatically shifts all existing lines up by one, with new lines appearing at the bottom.

img_3

img_4

This output confirms that your VGA text output system works correctly, including color support, newlines, and automatic scrolling. This foundation enables more complex console interactions in your operating system.

In the next tutorial, we'll implement Global Descriptor Table (GDT).

Download Project Files

03 - VGA Text Output.zip