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), plus blinking and intensity 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);
}
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);
}
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/port.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
#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);
#endif // CONSOLE_H
console.cpp
#include "console.h"
#include "core/port.h"
#include <stdarg.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);
}
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': { // Integer
int num = va_arg(args, int);
char buffer[12]; // Increased size to accommodate INT_MIN and null terminator
int index = 0;
bool isNegative = false;
if (num == 0) {
buffer[index++] = '0';
} else {
if (num < 0) {
isNegative = true;
num = -num; // Make num positive for digit extraction
}
// Store digits in reverse order
int startDigitIndex = index; // Mark where digits start
int temp = num;
do {
buffer[index++] = (temp % 10) + '0';
temp /= 10;
} while (temp > 0);
if (isNegative) {
buffer[index++] = '-'; // Add the negative sign
}
// Reverse the digits in the buffer
int left = startDigitIndex;
int right = index - 1;
if (isNegative) { // If negative, don't include the '-' in the reversal
right--;
}
while (left < right) {
char c = buffer[left];
buffer[left] = buffer[right];
buffer[right] = c;
left++;
right--;
}
}
buffer[index] = '\0'; // Null-terminate the string
for (int j = 0; 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:
// Handle unsupported format specifiers or print '%' literally
// For simplicity, we'll just break here.
break;
}
} else if (format[i] == '\n') {
cursorRow++;
cursorCol = 0;
if (cursorRow >= SCREEN_HEIGHT) {
scrollScreen();
cursorRow = SCREEN_HEIGHT - 1;
}
} 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 <core/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/port.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:
-
Screen Clear: The
clearScreen()
function removes all previous GRUB output, presenting a clean black screen. -
"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. -
"HELLO FROM ME:": Displays on the next line in white text on black background.
-
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.
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).