Skip to the content.

Writing a “bare metal” operating system for Raspberry Pi 4 (Part 5)

< Go back to part4-miniuart

Working with the screen

As exciting as the UART is, in this tutorial we’re finally going to get “Hello world!” up on the screen! Now that we’re experts in MMIO, we’re ready to tackle mailboxes. This is how we communicate with the VideoCore multimedia processor. We can send it messages, and it can reply. Think of it just like email.

Let’s create mb.c:

#include "io.h"

// The buffer must be 16-byte aligned as only the upper 28 bits of the address can be passed via the mailbox
volatile unsigned int __attribute__((aligned(16))) mbox[36];

enum {
    VIDEOCORE_MBOX = (PERIPHERAL_BASE + 0x0000B880),
    MBOX_READ      = (VIDEOCORE_MBOX + 0x0),
    MBOX_POLL      = (VIDEOCORE_MBOX + 0x10),
    MBOX_SENDER    = (VIDEOCORE_MBOX + 0x14),
    MBOX_STATUS    = (VIDEOCORE_MBOX + 0x18),
    MBOX_CONFIG    = (VIDEOCORE_MBOX + 0x1C),
    MBOX_WRITE     = (VIDEOCORE_MBOX + 0x20),
    MBOX_RESPONSE  = 0x80000000,
    MBOX_FULL      = 0x80000000,
    MBOX_EMPTY     = 0x40000000
};

unsigned int mbox_call(unsigned char ch)
{
    // 28-bit address (MSB) and 4-bit value (LSB)
    unsigned int r = ((unsigned int)((long) &mbox) &~ 0xF) | (ch & 0xF);

    // Wait until we can write
    while (mmio_read(MBOX_STATUS) & MBOX_FULL);
    
    // Write the address of our buffer to the mailbox with the channel appended
    mmio_write(MBOX_WRITE, r);

    while (1) {
        // Is there a reply?
        while (mmio_read(MBOX_STATUS) & MBOX_EMPTY);

        // Is it a reply to our message?
        if (r == mmio_read(MBOX_READ)) return mbox[1]==MBOX_RESPONSE; // Is it successful?
           
    }
    return 0;
}

First we include io.h as we need access to the PERIPHERAL_BASE definition and also to make use of the mmio_read and mmio_write functions that io.c provides. Our previous MMIO experience is useful here, as sending/receiving mailbox request/responses is achieved using the same technique. We’ll just be addressing different offsets from PERIPHERAL_BASE, as you see in the code.

Importantly, our mailbox buffer (where messages will be stored) needs to be correctly aligned in memory. This is one example where we need to be strict with the compiler instead of letting it do its thing! By ensuring the buffer is “16-byte aligned”, we know that its memory address is a multiple of 16 i.e. the 4 least significant bits are not set. That’s good, because only the 28 most significant bits can be used as the address, leaving the 4 least significant bits to specify the channel.

I recommend reading up on the mailbox property interface. You’ll see that channel 8 is reserved for messages from the ARM for response by the VideoCore, so we’ll be using this channel.

mbox_call implements all the MMIO we need to send the message (assuming it’s been set up in the buffer) and await the reply. The VideoCore will write the reply directly to our original buffer.

The framebuffer

Now take a look at fb.c. The fb_init() routine makes our very first mailbox call, using some definitions from mb.h. Remember the email analogy? Well, since it’s possible to ask a person to do more than one thing by email, we can also ask a few things of the VideoCore at once. This message asks for two things:

You can read more about the message structure on the mailbox property interface page that I shared before.

A framebuffer is simply an area of memory that contains a bitmap which drives a video display. In other words, we can manipulate the pixels on the screen directly by writing to specific memory addresses. We will first need to understand how this memory is organised though.

In this example, we ask the VideoCore for:

So each pixel is made up of 8-bits for the Red value, 8-bits for Green, 8-bits for Blue and 8-bits for the Alpha channel (representing transparency/opacity). We’re asking that the pixels are ordered in memory with the Red byte coming first, then Green, then Blue - RGB. In actual fact, the Alpha byte always comes ahead of all of these, so it’s really ARGB.

We then send the message using channel 8 (MBOX_CH_PROP) and check that what the VideoCore sends back is what we asked for. It should also tell us the missing piece of the framebuffer organisation puzzle - the number of bytes per line or pitch.

If everything comes back as expected, then we’re ready to write to the screen!

Drawing a pixel

To save us remembering RGB colour combinations, let’s set up a simple 16-colour palette. Anyone remember the old EGA/VGA palette? If you take a look in terminal.h, you’ll see the vgapal array sets up that same palette, with Black as item 0 and White as item 15, and many shades in between!

Our drawPixel routine can then take an (x, y) coordinate and a colour. We use an unsigned char (8 bits) to represent two palette indexes at once, with the 4 most significant bits representing the background colour and the 4 least significant bits, the foreground colour. You may see why it’s helpful to have only a 16-colour palette for now!

void drawPixel(int x, int y, unsigned char attr)
{
    int offs = (y * pitch) + (x * 4);
    *((unsigned int*)(fb + offs)) = vgapal[attr & 0x0f];
}

We first calculate the framebuffer offset in bytes. (y * pitch) gets us to coordinate (0, y) - pitch is the number of bytes per line. We then add (x * 4) to get to (x, y) - there are 4 bytes (or 32 bits!) per pixel (ARGB). We can then set that byte in the framebuffer to our foreground colour (we don’t need a background colour here).

Drawing lines, rectangles and circles

Examine and understand our drawRect, drawLine and drawCircle routines now. Where a polygon is filled, we use the background colour for the fill and the foreground colour for the outline.

I recommend reading Bresenham on drawing graphics primitives. My line and circle drawing algorithms come from him, and they’re designed to use only simple mathematics. He makes for very interesting reading and his algorithms are still very important today.

Writing characters to the screen

I told you that nothing is for free in bare metal programming, right? Well, if we want to write a message to the screen then we need a font. So, just like we built our palette, we now need to build up a font for our code to use. Luckily, fonts are just arrays of simple bitmaps - 1’s and 0’s used to describe a picture. We’re going to define an 8x8 font similar to the one MS-DOS used.

Imagine an 8x8 “A”:

0 0 0 0 1 1 0 0 = 0x0C
0 0 0 1 1 1 1 0 = 0x1E
0 0 1 1 0 0 1 1 = 0x33
0 0 1 1 0 0 1 1 = 0x33
0 0 1 1 1 1 1 1 = 0x3F
0 0 1 1 0 0 1 1 = 0x33
0 0 1 1 0 0 1 1 = 0x33
0 0 0 0 0 0 0 0 = 0x00

This bitmap can be represented by just 8 bytes (the hexadecimal numbers after the = signs). When you look in terminal.h, you’ll see that we’ve done this for many of the useful characters found in code page 437.

drawChar should now be fairly self-explanatory.

drawString unsurprisingly uses drawChar to print a whole string.

Updating our kernel to be more artistic

Finally, we can create a work of art on-screen! Our updated kernel.c exercises all these graphics routines to draw the picture below.

Build the kernel, copy it to your SD card. You may need to update your config.txt once more. If you previously set the hdmi_safe parameter to get Raspbian going, you probably won’t need it now. You might, however, need to set hdmi_mode and hdmi_group specifically to ensure we get into 1080p mode.

It’s a good time to gain an understanding of the screen resolution settings for the RPi4. Because I’m using a regular TV, my config.txt file now contains three lines (including the one we already added for the UART):

core_freq_min=500
hdmi_group=1
hdmi_mode=16

Now fire up the RPi4!

We’ve done so much more than a basic “Hello world!” on-screen already! Sit back, relax and enjoy your artwork. In the next tutorial, we’ll be combining graphics with keyboard input from the UART to create our first game.

Our first on-screen artwork

Go to part6-breakout >