Skip to content

OLED Framebuffer Driver

Time estimate: ~90 minutes (30 min quick path / 90 min full walkthrough) Prerequisites: MCP9808 Driver, OLED Display

Learning Objectives

By the end of this tutorial you will be able to:

  • Explain the Linux framebuffer subsystem (fbdev) and the fb_info structure
  • Implement fb_ops for a monochrome display
  • Use deferred I/O to periodically refresh display hardware
  • Build and load an I2C framebuffer kernel driver
  • Test the framebuffer with command-line tools, Python, and the Pong game
From Character Device to Framebuffer

In the MCP9808 Driver tutorial you built a character device — it exposes a single value (temperature) through read(). A framebuffer driver is different: it exposes a pixel buffer that applications can write to directly. The kernel provides the fbdev subsystem to standardise this — any application that knows how to draw to /dev/fbN will work with any framebuffer driver, whether it drives an OLED, an LED matrix, or a full LCD panel.

Both drivers sit on the same I2C bus. The MCP9808 driver uses the cdev subsystem; this OLED driver uses the fbdev subsystem. The I2C transport is the same — only the kernel subsystem they register with changes.


Quick Path: Build, Load, Play

If you want to get the OLED framebuffer running quickly and play Pong on it, follow these steps. The detailed walkthrough below explains how the driver works inside.

1. Clone and Build the Driver

git clone https://github.com/gsebik/ssd1306_fb_driver.git ~/ssd1306-driver
cd ~/ssd1306-driver
make
Warning

You need kernel headers installed: sudo apt install linux-headers-$(uname -r)

Build error on kernel 6.12+?

If you get 'const struct backlight_ops' has no member named 'check_fb', the backlight API changed in newer kernels. The fix: open ssd1306fb.c, find the ssd1307fb_bl_ops struct, and remove the .check_fb line:

static const struct backlight_ops ssd1307fb_bl_ops = {
    .update_status = ssd1307fb_update_bl,
    /* remove: .check_fb = ssd1307fb_check_fb, */
};
You can also delete the ssd1307fb_check_fb() function — it is no longer needed. The kernel's backlight core handles matching without it. Then run make again.

2. Install the Module

Copy the compiled module into the kernel's module directory so it loads automatically when the Device Tree overlay matches:

sudo mkdir -p /lib/modules/$(uname -r)/extra
sudo cp ssd1306_fb.ko /lib/modules/$(uname -r)/extra/
sudo depmod -a

3. Install the Device Tree Overlay

# Compile the overlay (the .dts file is included in the repo)
dtc -@ -I dts -O dtb -o ssd1306-i2c.dtbo ssd1306fb-overlay.dts

# Install and enable
sudo cp ssd1306-i2c.dtbo /boot/firmware/overlays/
echo "dtoverlay=ssd1306-i2c" | sudo tee -a /boot/firmware/config.txt

4. Reboot and Verify

sudo reboot

After reboot, the overlay describes the SSD1306 on I2C, the kernel auto-loads the matching module, and /dev/fb1 appears:

ls /dev/fb*              # should show /dev/fb0 and /dev/fb1
dmesg | grep ssd1306     # should show probe success
fbset -fb /dev/fb1       # should show 128x64, 1 bpp

5. Disable Console Cursor (Optional)

Info

Linux assigns a text console (fbcon) to every new framebuffer by default. On the OLED this shows as a blinking cursor block in the top-left corner. If you only use the OLED for custom graphics (Pong, sensor dashboard, etc.), you should disable this. If you want a text console on the OLED (e.g., for dmesg output), skip this step.

# Disable blinking cursor on all framebuffers
echo 0 | sudo tee /sys/class/graphics/fbcon/cursor_blink

# Redirect the console away from the OLED (keep it on fb0 / HDMI only)
sudo sh -c 'echo 0 > /sys/class/vtconsole/vtcon1/bind'

To make this permanent across reboots, create a systemd service:

sudo tee /etc/systemd/system/fb-nocursor.service <<'EOF'
[Unit]
Description=Disable fbcon cursor on OLED
After=multi-user.target

[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo 0 > /sys/class/graphics/fbcon/cursor_blink; echo 0 > /sys/class/vtconsole/vtcon1/bind'

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable fb-nocursor.service

6. Test It

# Random noise on the OLED
sudo cat /dev/urandom > /dev/fb0

# Clear the display
sudo dd if=/dev/zero of=/dev/fb0 bs=1024 count=1

7. Play Pong

Build and run the Pong game from the embedded-linux repository:

cd ~/embedded-linux/apps/pong-fb
make pong_fb
sudo ./pong_fb /dev/fb0

Controls: W/S or Arrow keys to move, Q to quit. The game auto-detects the 128×64 resolution from the driver.

Checkpoint — Pong Running on OLED

You should see the Pong game running on the SSD1306 OLED display at ~20 fps. The ball bounces, the AI paddle tracks, and score is kept. The driver survives reboots — no manual insmod needed.

Now read on to understand how the driver works — or skip to the challenges at the end.


What is a Framebuffer?

A framebuffer (/dev/fbN) is a memory-mapped pixel buffer. The kernel driver allocates a chunk of memory to represent the screen, and applications write pixel data into it. The driver then pushes that data to the physical display.

Key structures:

Structure Purpose
struct fb_info Main framebuffer descriptor — resolution, pixel format, pointer to screen memory, function pointers
struct fb_ops Operations the driver implements: fb_read, fb_write, fb_fillrect, fb_copyarea, fb_imageblit
struct fb_deferred_io Deferred I/O — the kernel tracks which pages of the framebuffer have been written and calls your refresh function periodically
fb_var_screeninfo Variable screen parameters: resolution, bits per pixel, colour format
fb_fix_screeninfo Fixed parameters: physical address, line length, memory size

Deferred I/O

Displays without built-in frame memory (or slow buses like I2C) cannot be updated on every pixel write. Instead, fb_deferred_io tracks dirty pages and calls the driver's refresh function at a configurable interval (e.g., every 50 ms). This lets applications write freely while the driver batches updates.

Note

For deeper coverage of the Linux graphics stack (fbdev vs DRM/KMS), see Graphics Stack.


SSD1306 Display Protocol

The SSD1306 is a 128×64 (or 128×32) monochrome OLED controller connected via I2C at address 0x3C.

I2C Communication

Each I2C message starts with a control byte:

Control byte Meaning
0x00 Next byte is a command
0x40 Following bytes are display data (GDDRAM)

GDDRAM Layout

The display memory is organised as pages of 128 bytes. Each byte represents 8 vertical pixels in one column:

Page 0:  rows 0–7    (128 bytes, each byte = 8 vertical pixels)
Page 1:  rows 8–15
...
Page 7:  rows 56–63  (for 128×64 display)

Bit 0 of each byte is the top pixel, bit 7 is the bottom pixel within that page.

Key Commands

Command Hex Purpose
Display OFF 0xAE Turn display off
Display ON 0xAF Turn display on
Set addressing mode 0x20, 0x00 Horizontal addressing (auto-increment column and page)
Set column address 0x21, start, end Set column range for data writes
Set page address 0x22, start, end Set page range for data writes
Tip

The full SSD1306 command set is in the datasheet.


Step-by-step Driver Implementation

Note

If you followed the Quick Path above, you already have the driver source in ~/ssd1306-driver. The steps below walk through how it is built, explaining each piece. You can read the source alongside this walkthrough.

If you skipped the quick path: git clone https://github.com/gsebik/ssd1306_fb_driver.git ~/ssd1306-driver

Prepare the Build Environment

cd ~/ssd1306-driver

The repository includes a Makefile:

obj-m += ssd1306_fb.o

KDIR ?= /lib/modules/$(shell uname -r)/build

all:
    make -C $(KDIR) M=$(PWD) modules

clean:
    make -C $(KDIR) M=$(PWD) clean

Step 1: Module Skeleton + I2C Driver

Start with the same I2C driver pattern you used for the MCP9808, but with a different compatible string:

#include <linux/module.h>
#include <linux/i2c.h>
#include <linux/of.h>
#include <linux/fb.h>
#include <linux/delay.h>
#include <linux/slab.h>

#define DRIVER_NAME  "ssd1306fb"

struct ssd1306_par {
    struct i2c_client *client;
    u32 width;
    u32 height;
    u8 *buffer;  /* hardware-format buffer (page-oriented) */
};

static int ssd1306_probe(struct i2c_client *client)
{
    dev_info(&client->dev, "ssd1306fb: probe called (addr=0x%02x)\n",
         client->addr);
    return 0;
}

static void ssd1306_remove(struct i2c_client *client)
{
    dev_info(&client->dev, "ssd1306fb: removed\n");
}

static const struct i2c_device_id ssd1306_id[] = {
    { "ssd1306fb", 0 },
    { }
};
MODULE_DEVICE_TABLE(i2c, ssd1306_id);

static const struct of_device_id ssd1306_of_match[] = {
    { .compatible = "ssd1306fb" },
    { }
};
MODULE_DEVICE_TABLE(of, ssd1306_of_match);

static struct i2c_driver ssd1306_i2c_driver = {
    .driver = {
        .name = DRIVER_NAME,
        .of_match_table = ssd1306_of_match,
    },
    .probe    = ssd1306_probe,
    .remove   = ssd1306_remove,
    .id_table = ssd1306_id,
};

module_i2c_driver(ssd1306_i2c_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("SSD1306 I2C OLED Framebuffer Driver");

Build and load to verify the skeleton works:

make
sudo insmod ssd1306_fb.ko
dmesg | tail
sudo rmmod ssd1306_fb
Checkpoint — Skeleton Loads

dmesg shows the module loading. Probe will run once we add the Device Tree overlay.


Step 2: Display Initialization

Add helper functions to send commands and data over I2C, and an init sequence in probe():

/* I2C control bytes */
#define SSD1306_CMD   0x00
#define SSD1306_DATA  0x40

static int ssd1306_write_cmd(struct i2c_client *client, u8 cmd)
{
    return i2c_smbus_write_byte_data(client, SSD1306_CMD, cmd);
}

static int ssd1306_write_data(struct i2c_client *client,
                              const u8 *data, size_t len)
{
    u8 *buf;
    int ret;

    buf = kmalloc(len + 1, GFP_KERNEL);
    if (!buf)
        return -ENOMEM;

    buf[0] = SSD1306_DATA;
    memcpy(buf + 1, data, len);

    ret = i2c_master_send(client, buf, len + 1);
    kfree(buf);

    return ret < 0 ? ret : 0;
}

The init sequence configures the display controller — clock, multiplex ratio, charge pump, addressing mode, and turns the display on:

static const u8 ssd1306_init_cmds[] = {
    0xAE,        /* Display OFF */
    0xD5, 0x80,  /* Set clock divide ratio */
    0xA8, 0x3F,  /* Set multiplex ratio (63 for 64 rows) */
    0xD3, 0x00,  /* Set display offset: none */
    0x40,        /* Set display start line to 0 */
    0x8D, 0x14,  /* Enable charge pump */
    0x20, 0x00,  /* Horizontal addressing mode */
    0xA1,        /* Segment re-map: col 127 → SEG0 */
    0xC8,        /* COM output scan direction: remapped */
    0xDA, 0x12,  /* COM pins configuration */
    0x81, 0xCF,  /* Set contrast */
    0xD9, 0xF1,  /* Set pre-charge period */
    0xDB, 0x40,  /* Set VCOMH deselect level */
    0xA4,        /* Display ON (resume from GDDRAM) */
    0xA6,        /* Normal display (not inverted) */
    0xAF,        /* Display ON */
};

Call this in probe() — after it runs, the OLED should turn on (showing whatever is in GDDRAM, likely random noise or blank).

Checkpoint — Display Turns On

After loading the module with the DT overlay, the OLED display should light up.


Step 3: Register Framebuffer

In probe(), allocate a fb_info structure, fill in the screen parameters, and register it:

static const struct fb_ops ssd1306_fbops = {
    .owner      = THIS_MODULE,
    .fb_read    = fb_sys_read,
    .fb_write   = fb_sys_write,
    .fb_fillrect  = sys_fillrect,
    .fb_copyarea  = sys_copyarea,
    .fb_imageblit = sys_imageblit,
};

In probe():

int vmem_size = width * height / 8;  /* 1 bit per pixel */

info = framebuffer_alloc(sizeof(struct ssd1306_par), &client->dev);
/* ... fill par ... */

info->fbops = &ssd1306_fbops;
info->screen_base = vmem;          /* kernel-allocated pixel buffer */
info->screen_size = vmem_size;

/* Fixed screen info */
info->fix.type       = FB_TYPE_PACKED_PIXELS;
info->fix.visual     = FB_VISUAL_MONO10;
info->fix.line_length = width / 8;
info->fix.smem_len   = vmem_size;

/* Variable screen info */
info->var.xres = info->var.xres_virtual = width;
info->var.yres = info->var.yres_virtual = height;
info->var.bits_per_pixel = 1;

register_framebuffer(info);

The fb_sys_* and sys_* functions are kernel-provided implementations for system-memory framebuffers (as opposed to video RAM).

Checkpoint — /dev/fbN Exists

ls /dev/fb* should show a new framebuffer device. fbset -fb /dev/fb1 should display the resolution.


Step 4: Deferred I/O and Refresh

This is the key piece — connecting framebuffer writes to actual display updates:

static void ssd1306_update_display(struct ssd1306_par *par)
{
    struct fb_info *info = dev_get_drvdata(&par->client->dev);
    u8 *vmem = info->screen_base;
    int pages = par->height / 8;

    memset(par->buffer, 0, par->width * pages);

    /* Convert linear 1bpp (row-major) → SSD1306 page format */
    for (int y = 0; y < par->height; y++) {
        int page = y / 8;
        for (int x = 0; x < par->width; x++) {
            int src_byte = (y * par->width + x) / 8;
            int src_bit  = x % 8;  /* LSB = leftmost pixel */
            if (vmem[src_byte] & (1 << src_bit))
                par->buffer[page * par->width + x] |=
                    (1 << (y % 8));
        }
    }

    /* Set address range and send data */
    ssd1306_write_cmd(par->client, 0x21);  /* Column address */
    ssd1306_write_cmd(par->client, 0);
    ssd1306_write_cmd(par->client, par->width - 1);
    ssd1306_write_cmd(par->client, 0x22);  /* Page address */
    ssd1306_write_cmd(par->client, 0);
    ssd1306_write_cmd(par->client, pages - 1);

    ssd1306_write_data(par->client, par->buffer, par->width * pages);
}

static void ssd1306_deferred_io(struct fb_info *info,
                                struct list_head *pagelist)
{
    ssd1306_update_display(info->par);
}

static struct fb_deferred_io ssd1306_defio = {
    .delay       = HZ / 20,  /* 50 ms → 20 fps */
    .deferred_io = ssd1306_deferred_io,
};

In probe(), before register_framebuffer():

info->fbdefio = &ssd1306_defio;
fb_deferred_io_init(info);

The format conversion is necessary because the Linux framebuffer uses row-major bit layout (each byte = 8 horizontal pixels) while the SSD1306 uses page-oriented layout (each byte = 8 vertical pixels in a column).

Checkpoint — Display Shows Content

cat /dev/urandom > /dev/fb1 should show random noise on the OLED. The display updates every 50 ms.


Step 5: Cleanup

In remove(), tear down in reverse order:

static void ssd1306_remove(struct i2c_client *client)
{
    struct fb_info *info = dev_get_drvdata(&client->dev);

    unregister_framebuffer(info);
    fb_deferred_io_cleanup(info);
    ssd1306_write_cmd(client, 0xAE);  /* Display OFF */
    framebuffer_release(info);
}
Checkpoint — Clean Unload

sudo rmmod ssd1306_fb should turn the display off and remove /dev/fbN.


Device Tree Overlay

Create ssd1306-i2c-overlay.dts:

/dts-v1/;
/plugin/;

/ {
    compatible = "brcm,bcm2835";

    fragment@0 {
        target = <&i2c1>;

        __overlay__ {
            #address-cells = <1>;
            #size-cells = <0>;

            ssd1306fb@3c {
                compatible = "ssd1306fb";
                reg = <0x3c>;
                width = <128>;
                height = <64>;
            };
        };
    };
};
Tip

If you have a 128×32 display, change height = <32>. The driver reads these values from the Device Tree and adjusts automatically.

Compile, install, and enable:

dtc -@ -I dts -O dtb -o ssd1306-i2c.dtbo ssd1306-i2c-overlay.dts
sudo cp ssd1306-i2c.dtbo /boot/firmware/overlays/
# Add to /boot/firmware/config.txt:
# dtoverlay=ssd1306-i2c
sudo reboot

Test the Framebuffer

After reboot with the overlay and module loaded:

# Check framebuffer exists
ls /dev/fb*
fbset -fb /dev/fb1

# Random noise on the display
cat /dev/urandom > /dev/fb1

# Clear the display
dd if=/dev/zero of=/dev/fb1 bs=1024

Python Test Script

Write pixels directly to the framebuffer:

import struct

WIDTH = 128
HEIGHT = 64
BPP = 1  # 1 bit per pixel

fb_size = WIDTH * HEIGHT // 8

# Create a buffer with a border rectangle
buf = bytearray(fb_size)

def set_pixel(buf, x, y, on=True):
    byte_idx = y * (WIDTH // 8) + x // 8
    bit_idx = x % 8  # LSB = leftmost pixel (mainline ssd1307fb convention)
    if on:
        buf[byte_idx] |= (1 << bit_idx)
    else:
        buf[byte_idx] &= ~(1 << bit_idx)

# Draw border
for x in range(WIDTH):
    set_pixel(buf, x, 0)
    set_pixel(buf, x, HEIGHT - 1)
for y in range(HEIGHT):
    set_pixel(buf, 0, y)
    set_pixel(buf, WIDTH - 1, y)

with open('/dev/fb1', 'wb') as fb:
    fb.write(buf)

print("Rectangle drawn on OLED")

Working with Multiple Framebuffers

A Raspberry Pi with HDMI output and an SSD1306 OLED has two framebuffers: fb0 (HDMI) and fb1 (OLED). Understanding how to list, identify, and select them is essential when you have multiple displays.

List All Framebuffers

cat /proc/fb

Example output:

0 vc4drmfb
1 ssd1306fb

The number is the framebuffer index, the name is the driver. vc4drmfb is the Raspberry Pi's GPU (HDMI/DSI), ssd1306fb is the OLED.

Query Framebuffer Details

Use fbset to show resolution, pixel format, and timing for a specific framebuffer:

fbset -fb /dev/fb0    # HDMI — likely 1920x1080, 32 bpp
fbset -fb /dev/fb1    # OLED — 128x64, 1 bpp

For more detail, read the sysfs attributes:

# List all available attributes
ls /sys/class/graphics/fb1/

# Key attributes:
cat /sys/class/graphics/fb1/name          # driver name (ssd1306fb)
cat /sys/class/graphics/fb1/virtual_size  # width,height
cat /sys/class/graphics/fb1/bits_per_pixel
cat /sys/class/graphics/fb1/stride        # line_length in bytes

How the Kernel Assigns Numbers

Framebuffer numbers (fb0, fb1, ...) are assigned in the order drivers call register_framebuffer(). The GPU driver loads early during boot and gets fb0. The SSD1306 module loads later (after Device Tree matching) and gets fb1. The numbers are not fixed — if you have multiple OLED displays or load modules in a different order, the numbers may shift.

Identify Which Display is Which

When numbers can change, use the driver name to find the right device:

# Find the OLED framebuffer by driver name
for fb in /sys/class/graphics/fb*; do
    if [ "$(cat $fb/name 2>/dev/null)" = "ssd1306fb" ]; then
        echo "OLED is at /dev/$(basename $fb)"
    fi
done

In C, you can query the driver name programmatically:

struct fb_fix_screeninfo finfo;
ioctl(fd, FBIOGET_FSCREENINFO, &finfo);
printf("Driver: %s\n", finfo.id);  /* "ssd1306fb" */

This is how pong_fb.c could auto-detect the correct device — open each /dev/fbN, query finfo.id, and use the one matching the expected driver.

Select a Framebuffer in Applications

Most framebuffer applications accept the device path as an argument:

./pong_fb /dev/fb1              # explicit path
sudo cat /dev/urandom > /dev/fb1  # redirect to specific fb

Environment variables work for some SDL1/fbdev apps:

export FRAMEBUFFER=/dev/fb1

The ioctl Interface

User-space applications query framebuffers through two ioctl calls:

ioctl Structure Returns
FBIOGET_VSCREENINFO fb_var_screeninfo Resolution (xres, yres), bits per pixel, colour offsets
FBIOGET_FSCREENINFO fb_fix_screeninfo Driver name (id), line_length (bytes per row), total memory size, memory type

The line_length field from fb_fix_screeninfo is particularly important — it tells you how many bytes each row occupies in memory. For 1bpp displays:

  • line_length == width / 8linear row-major (each byte = 8 horizontal pixels)
  • line_length == widthpage-oriented (each byte = 8 vertical pixels, SSD1306 native format)

Applications like pong_fb.c use this to auto-detect the pixel layout and work on any monochrome framebuffer without hardcoding the format.

Tip

You can also modify framebuffer parameters at runtime with FBIOPUT_VSCREENINFO — for example, changing the virtual resolution for double-buffering. However, most simple framebuffer drivers (including this SSD1306 driver) only support a fixed configuration, so the PUT ioctl will return an error if you try to change the resolution.


What Just Happened?

Compare the two I2C drivers you have built:

MCP9808 (char device) SSD1306 (framebuffer)
Kernel subsystem cdev (character device) fbdev (framebuffer)
User interface /dev/mcp9808read() returns a string /dev/fbNwrite()/mmap() pixel data
Data flow Kernel → user (sensor value) User → kernel → hardware (pixel buffer)
Update mechanism On-demand (each read() triggers I2C) Deferred I/O (periodic bulk transfer)
Data size 2 bytes per read 1024 bytes per frame (128×64/8)
I2C bus Same bus Same bus

Both drivers follow the same pattern: I2C driver registration, Device Tree matching, probe()/remove(). The difference is which kernel subsystem they register with — cdev for single values, fbdev for pixel buffers.


Troubleshooting

Display stays blank after I2C recovery

If the OLED stops working (e.g., loose wire, I2C bus glitch) and you reconnect it, the display often stays blank even though i2cdetect shows the device at 0x3c again. This is normal — here is why:

The SSD1306 needs a full initialisation sequence (charge pump, addressing mode, display ON, etc.) before it shows anything. The driver only sends this sequence in probe(), which runs once — when the kernel first matches the Device Tree overlay to the driver. Reconnecting the I2C bus electrically recovers the bus, but it does not re-run probe(). The display controller has lost its configuration and waits for commands that never come.

Quick recovery without reboot — unbind and rebind the driver:

# Check the device address (should show 3c)
sudo i2cdetect -y 1

# Unbind the driver from the device
echo "1-003c" | sudo tee /sys/bus/i2c/drivers/ssd1306fb/unbind

# Rebind — this re-runs probe() and reinitialises the display
echo "1-003c" | sudo tee /sys/bus/i2c/drivers/ssd1306fb/bind

The 1-003c format means I2C bus 1, address 0x3C. If your device is on a different bus or address, adjust accordingly.

Tip

If unbind/bind does not work (e.g., the I2C bus itself is still stuck), a full reboot is the safest recovery. The kernel re-scans the bus, re-runs probe(), and the display initialises from scratch.

No /dev/fb1 after reboot

  1. Check the overlay loaded: sudo vcdbg log msg |& grep ssd1306
  2. Check the module loaded: lsmod | grep ssd1306
  3. Check dmesg | grep ssd1306 for probe errors
  4. Verify I2C device is visible: sudo i2cdetect -y 1 (should show 3c)
  5. If the module is not loaded, check depmod ran: modinfo ssd1306_fb should show the module path

Display shows garbled pixels

If the image looks scrambled (panels swapped, pixels reversed), the pixel format may be mismatched between your application and the driver. The driver uses linear row-major 1bpp with LSB-first bit ordering (bit 0 = leftmost pixel, matching the mainline ssd1307fb convention). Make sure your application uses the same format — see Pong on Framebuffer for an example that auto-detects the format.


Challenges

Tip

Try extending the driver and applications:

  • Adjustable refresh rate — change the deferred I/O delay from HZ/20 to a module parameter so you can tune FPS without recompiling
  • Display MCP9808 temperature — write a program that reads /sys/class/hwmon/hwmon0/temp1_input and draws the value as large digits on /dev/fb1
  • Contrast control via sysfs — add a sysfs attribute that lets you change the SSD1306 contrast command (0x81) from user space
  • 128×32 support — test with a 128×32 OLED (change height in the DT overlay) and verify Pong adapts correctly

Course Overview | Next: Pong on Framebuffer →