Skip to content

08. Text Rendering - Writing to the Screen

We can draw pixels, but for an OS, we need text. Let's implement a system to render characters and strings on our framebuffer.

How Text Rendering Works

Since we are in graphics mode, "text" is just a collection of pixels. To draw a letter 'A', we need to know which pixels to turn on.

Bitmap Fonts

A bitmap font stores each character as a grid of bits. - 1: Pixel on (foreground color) - 0: Pixel off (background color)

We'll use a simple 8x8 font. Each character is 8 pixels wide and 8 pixels high.

Example: The Letter 'A'

1
2
3
4
5
6
7
8
00011000  (0x18)   ..##..
00111100  (0x3C)   .####.
01100110  (0x66)   ##..##
01100110  (0x66)   ##..##
01111110  (0x7E)   ######
01100110  (0x66)   ##..##
01100110  (0x66)   ##..##
00000000  (0x00)   ......

In C, this is an array of 8 bytes: {0x18, 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x00}.

Implementing the Font Driver

Font Data: drivers/font.c

We define a large array containing the bitmap data for ASCII characters (32-127).

static const uint8_t font_8x8[128][8] = {
    // ... (data for space, !, ", #, etc.)
    {0x18, 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x00}, // 'A'
    // ...
};

const uint8_t* font_get_char(char c) {
    if (c < 32 || c > 127) return font_8x8[0];
    return font_8x8[c - 32];
}

Implementing the Console Driver

To make it useful, we need a console abstraction that handles: - Cursor position (x, y) - Newlines (\n) - Scrolling when the screen is full

Console State

1
2
3
4
static uint32_t cursor_x = 0;
static uint32_t cursor_y = 0;
static uint32_t max_cols = 0;
static uint32_t max_rows = 0;

Drawing a Character

void console_putc(char c) {
    if (c == '\n') {
        cursor_x = 0;
        cursor_y++;
        // Handle scrolling...
        return;
    }

    const uint8_t* glyph = font_get_char(c);

    // Draw 8x8 grid
    for (int y = 0; y < 8; y++) {
        for (int x = 0; x < 8; x++) {
            if (glyph[y] & (1 << (7-x))) {
                fb_set_pixel(cursor_x*8 + x, cursor_y*8 + y, WHITE);
            } else {
                fb_set_pixel(cursor_x*8 + x, cursor_y*8 + y, BLACK);
            }
        }
    }

    // Advance cursor
    cursor_x++;
    if (cursor_x >= max_cols) {
        cursor_x = 0;
        cursor_y++;
    }
}

Scrolling

When cursor_y reaches the bottom, we need to scroll. To implement proper line-by-line scrolling, we need memmove to shift the framebuffer contents up. But since we compile with -nostdinc -nostdlib, we must implement it ourselves.

Kernel String/Memory Functions

Before implementing scrolling, we need basic memory manipulation functions. These are essential building blocks for any OS kernel.

Why Implement Our Own?

When compiling a bare-metal kernel with:

-nostdinc -nostdlib -ffreestanding

We have no access to the standard C library. Functions like memcpy, memset, and memmove simply don't exist. We must implement them ourselves.

include/string.h

#ifndef STRING_H
#define STRING_H

#include "types.h"

void *memcpy(void *dest, const void *src, uint32_t n);
void *memset(void *s, int c, uint32_t n);
void *memmove(void *dest, const void *src, uint32_t n);
int memcmp(const void *s1, const void *s2, uint32_t n);

uint32_t strlen(const char *s);
int strcmp(const char *s1, const char *s2);
int strncmp(const char *s1, const char *s2, uint32_t n);
char *strcpy(char *dest, const char *src);
char *strncpy(char *dest, const char *src, uint32_t n);

#endif

kernel/string.c

#include "string.h"

void *memcpy(void *dest, const void *src, uint32_t n) {
    uint8_t *d = (uint8_t *)dest;
    const uint8_t *s = (const uint8_t *)src;
    while (n--) {
        *d++ = *s++;
    }
    return dest;
}

void *memset(void *s, int c, uint32_t n) {
    uint8_t *p = (uint8_t *)s;
    while (n--) {
        *p++ = (uint8_t)c;
    }
    return s;
}

void *memmove(void *dest, const void *src, uint32_t n) {
    uint8_t *d = (uint8_t *)dest;
    const uint8_t *s = (const uint8_t *)src;

    if (d < s) {
        // Copy forward
        while (n--) {
            *d++ = *s++;
        }
    } else if (d > s) {
        // Copy backward (handles overlapping regions)
        d += n;
        s += n;
        while (n--) {
            *--d = *--s;
        }
    }
    return dest;
}

uint32_t strlen(const char *s) {
    uint32_t len = 0;
    while (*s++) len++;
    return len;
}

int strcmp(const char *s1, const char *s2) {
    while (*s1 && (*s1 == *s2)) {
        s1++;
        s2++;
    }
    return *(const uint8_t *)s1 - *(const uint8_t *)s2;
}

Why memmove Instead of memcpy?

When scrolling the framebuffer, the source and destination regions overlap:

1
2
3
4
5
6
7
Before:          After:
+--------+       +--------+
| Line 0 | ←──── | Line 1 |  (Line 0 overwritten)
| Line 1 |       | Line 2 |
| Line 2 |       | Line 3 |
| Line 3 |       | (clear)|  (Last line cleared)
+--------+       +--------+

memcpy has undefined behavior when regions overlap. memmove handles this correctly by detecting the overlap direction and copying in the appropriate order.

Proper Scrolling Implementation

Now we can implement real scrolling in console.c:

#include "string.h"

static void console_scroll(void) {
    framebuffer_t *fb = framebuffer_get_info();
    if (!fb || !fb->buffer) return;

    uint32_t line_size = fb->pitch * FONT_HEIGHT;
    uint32_t total_size = fb->pitch * fb->height;

    // Move all lines up by one row
    memmove(fb->buffer, 
            (uint8_t *)fb->buffer + line_size,
            total_size - line_size);

    // Clear the last line
    memset((uint8_t *)fb->buffer + total_size - line_size, 
           0, 
           line_size);

    cursor_y = max_rows - 1;
}

This provides smooth scrolling behavior instead of the jarring screen clear.

Building and Testing

1. Build

1
2
3
cd simpian-os/build
cmake -DBUILD_EXAMPLES=ON ..
make

2. Deploy

Copy text_demo.img to SD card (rename to kernel8.img).

3. Expected Output

You should see: - "Raspberry Pi OS - Text Mode" - A list of all ASCII characters - Any text you type in the serial terminal will appear on the screen!

Integrating Console with the Kernel

When building a full-featured kernel, we face a timing challenge: the framebuffer and console are not available during early boot. The UART is initialized first and can output messages immediately, but the framebuffer requires mailbox communication with the GPU, which takes time.

The Problem

If we try to write to the console before it's initialized, the system will crash. But we want boot messages to appear on both UART (for debugging) and the screen (for users without serial cables).

Solution: Dual Output with kprint

We introduce a kprint function that outputs to both UART and console (when available):

static int is_console_initialized = 0;

void kprint(const char *s) {
    uart_puts(s);  // Always works
#ifdef ENABLE_TEXT_RENDERING
    if (is_console_initialized) {
        console_puts(s);  // Only after console is ready
    }
#endif
}

Replaying Boot Messages

After the console is initialized, we replay the boot messages so they appear on screen:

if (framebuffer_init(1920, 1080, 32) == 0) {
    console_init();
    is_console_initialized = 1;

    // Replay boot messages to screen
    console_puts("========================================\n");
    console_puts("  Raspberry Pi OS - Bare Metal Kernel  \n");
    console_puts("========================================\n");
    console_puts("[OK] Timer initialized\n");
    console_puts("[OK] GPIO initialized\n");
    // ... etc
}

Preserving Boot Messages

In console_init(), we avoid clearing the screen so any early graphics are preserved:

void console_init(void) {
    framebuffer_t *fb = framebuffer_get_info();
    if (!fb || !fb->buffer) return;

    max_cols = fb->width / FONT_WIDTH;
    max_rows = fb->height / FONT_HEIGHT;
    cursor_x = 0;
    cursor_y = 0;

    // Don't clear screen - preserve boot messages
    // console_clear();
}

This pattern is common in real operating systems. Linux, for example, uses an "early console" for boot messages and switches to a proper framebuffer console later.

Performance Note

Drawing pixel-by-pixel is slow. Real OSes use: - Hardware Acceleration: GPU blitting - Optimized Assembly: Copying 64 bits at a time - Double Buffering: To prevent flickering

Complete Source Code

What's Next?

We have a working display! Now we can start building the core OS features: - Memory Management: Allocating memory dynamically (malloc) - Virtual Memory: Using the MMU for process isolation

The next article covers Memory Allocation.