Framebuffer Basics (No Window Manager)
Time: 30 min | Prerequisites: SSH Login, Display Setup
This tutorial shows how to draw directly to the framebuffer on an embedded Linux system without X11 or Wayland.
Learning Objectives
By the end of this tutorial you will be able to:
- Query framebuffer resolution, pixel format, and stride from sysfs
- Display an image on a bare framebuffer using
fbi - Write pixels directly to
/dev/fb0using Python andmmap - Understand RGB565 vs XRGB8888 pixel formats
The Linux Graphics Stack -- Level 1: Framebuffer
Linux provides three levels of graphics, each trading simplicity for capability:
- Level 1: fbdev (framebuffer) -- Write pixels directly to
/dev/fb0. The display controller reads this memory at 60 Hz and sends the pixels to the monitor. No window manager, no GPU, no synchronisation. This is the simplest path and the one used in this tutorial. - Level 2: DRM/KMS -- The modern replacement for fbdev. Provides hardware page flipping synchronised to VBlank (no tearing), mode enumeration from EDID, and hardware overlay planes. Every active display driver in the kernel supports DRM.
- Level 3: Wayland or X11 -- A full compositor that manages multiple windows, input routing, and GPU-accelerated rendering. Powerful but heavy -- adds seconds to boot time and hundreds of MB of RAM. Unnecessary for single-app embedded products.
A framebuffer is a contiguous block of memory whose size is stride x height bytes. The stride (line length) may be larger than width x bytes_per_pixel due to hardware alignment. The kernel exposes this memory as /dev/fb0 -- every byte you write directly changes a pixel on the display.
For deeper reading see the Graphics Stack reference page.
Course Source Repository
This tutorial references source files from the course repository. If you haven't cloned it yet on your Pi:
Source files for this tutorial are in ~/embedded-linux/solutions/framebuffer-basics/.
How Does This Reach the Display?
When you SSH into a Raspberry Pi and run these scripts, pixels appear on the HDMI monitor — even though you never "selected" that screen or opened a window. This is confusing if you are used to desktop environments where you must open a window first. Here is why it works:
┌─────────────────────────────────────────────────────────┐
│ RAM │
│ ┌──────────────────────────┐ │
│ │ Framebuffer memory │◄── your program writes │
│ │ (e.g., 1920×1080×4 B) │ pixels here via mmap │
│ └──────────┬───────────────┘ │
│ │ DMA (Direct Memory Access) │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ Display controller │ Reads FB memory 60×/sec │
│ │ (part of the SoC) │ line by line, left→right │
│ └──────────┬───────────────┘ │
│ │ HDMI / DSI signals │
└─────────────┼───────────────────────────────────────────┘
▼
┌──────────────┐
│ HDMI Monitor │ Receives pixel stream
└──────────────┘
Key points:
- The display controller is a hardware block inside the SoC that continuously scans framebuffer memory at the refresh rate (typically 60 Hz) and converts each pixel value into electrical signals for HDMI (or DSI for ribbon-cable displays).
- There is no window manager involved. The framebuffer is the entire screen — pixel (0,0) is the top-left corner of the monitor. When the console shows a blinking cursor, that cursor is also being drawn into the framebuffer by a kernel component (
fbcon). - When you run
fb_draw.py, your pixels overwrite whatever was in framebuffer memory — including the text console. The console text is gone because you wrote black pixels over it. When the script exits, the console does not automatically redraw (runclearor switch TTYs withAlt+F1to get it back). - You do not "select" a display —
/dev/fb0is wired to whichever connector the bootloader configured at startup (usually HDMI-1). There is exactly one framebuffer device per display output.
Note
"Where did the terminal go?" — The Linux text console (fbcon) draws characters into the same framebuffer memory your scripts write to. When you clear the framebuffer to black, the terminal text disappears. It is not a different layer — it is the same pixel buffer. To restore the console, press Alt+F1 (on a local keyboard) or reboot. Over SSH, run: sudo chvt 1 to switch back to TTY 1.
1. Check Framebuffer Device
If you see /dev/fb0, framebuffer is available. The device belongs to the video group — you need sudo or membership in that group to write to it.
2. Query Resolution and Format
Before writing any pixels, you must know the framebuffer's resolution, pixel format, and stride. Getting the format wrong produces garbled colors; getting the stride wrong produces a diagonal smear.
You can also read these values programmatically from sysfs:
cat /sys/class/graphics/fb0/virtual_size # e.g., 1920,1080
cat /sys/class/graphics/fb0/bits_per_pixel # e.g., 16 or 32
cat /sys/class/graphics/fb0/stride # bytes per row
Note
Stride vs width: A 1920-pixel-wide display at 16 bpp uses 1920 × 2 = 3840 bytes per row. But the hardware may round up to 4096 for alignment. Always use stride (from sysfs or fbset), never width × bpp, when calculating pixel offsets.
Checkpoint
fbset -i shows a valid resolution and the LineLength field is non-zero. Note your resolution and bits_per_pixel — you'll need them below.
3. Display a Test Image
Install tools:
Warning
Use sudo apt-get install python3-pil — not pip3 install pillow. Recent Raspberry Pi OS (Debian Bookworm+) blocks system-wide pip installs with PEP 668. The python3-pil apt package is the correct way to install Pillow system-wide.
Create a test image and display it. First, create the script file:
cat > fb_test_image.py << 'EOF'
#!/usr/bin/env python3
"""Generate a test image matching the framebuffer resolution."""
import sys
# Read actual framebuffer resolution from sysfs
with open("/sys/class/graphics/fb0/virtual_size") as f:
width, height = [int(x) for x in f.read().strip().split(",")]
print(f"Framebuffer: {width}x{height}")
from PIL import Image, ImageDraw
img = Image.new("RGB", (width, height), (0, 0, 0))
draw = ImageDraw.Draw(img)
# Draw colored bars
bar_h = height // 4
colors = [(255, 0, 0), (0, 255, 0), (0, 128, 255), (255, 255, 0)]
for i, color in enumerate(colors):
draw.rectangle([0, i * bar_h, width, (i + 1) * bar_h], fill=color)
# Draw a white crosshair at center
cx, cy = width // 2, height // 2
draw.line([(cx, 0), (cx, height)], fill=(255, 255, 255), width=2)
draw.line([(0, cy), (width, cy)], fill=(255, 255, 255), width=2)
img.save("fb_test.png")
print(f"Saved fb_test.png ({width}x{height})")
EOF
Run it:
You should see four colored horizontal bars with a white crosshair at the center. Press q or Esc to exit fbi.
Stuck?
- Permission denied: Make sure you use
sudoforfbi. The framebuffer device requires root orvideogroup membership. fbishows nothing over SSH:fbineeds a real console (-T 1selects TTY 1). If the desktop is running, stop it first:sudo systemctl stop lightdm 2>/dev/null- "no such file" for fb0: Your display may use DRM/KMS without a legacy framebuffer. Check
ls /dev/dri/— if you seecard0, skip to the DRM/KMS tutorial.
Checkpoint
Colored bars are visible on the display. The image fills the screen at the correct resolution.
4. Write Pixels Directly with mmap
Now write pixels directly to framebuffer memory — no image library, no viewer. This is how embedded single-screen UIs work under the hood.
Memory Mapping (mmap)
The mmap system call maps the framebuffer device into your process's address space. Once mapped, you can read and write pixels as if they were entries in an array — no system call per pixel, no copy overhead. The kernel translates your writes directly into display memory updates.
Create the script:
cat > fb_draw.py << 'EOF'
#!/usr/bin/env python3
"""Draw directly to /dev/fb0 using mmap.
Reads resolution, stride, and pixel format from sysfs automatically.
"""
import mmap, struct, sys
# ── Read framebuffer parameters from sysfs ────────────────────
def read_sysfs(name):
with open(f"/sys/class/graphics/fb0/{name}") as f:
return f.read().strip()
width, height = [int(x) for x in read_sysfs("virtual_size").split(",")]
bpp = int(read_sysfs("bits_per_pixel"))
stride = int(read_sysfs("stride"))
fb_size = stride * height
print(f"Framebuffer: {width}x{height}, {bpp} bpp, stride={stride}, size={fb_size}")
# ── Pixel packing functions ───────────────────────────────────
def rgb565(r, g, b):
"""Pack RGB into 16-bit RGB565 (2 bytes)."""
return struct.pack("<H", ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))
def xrgb8888(r, g, b):
"""Pack RGB into 32-bit XRGB8888 (4 bytes)."""
return struct.pack("<I", (r << 16) | (g << 8) | b)
# Select packer based on detected format
if bpp == 16:
pack_pixel = rgb565
pixel_bytes = 2
elif bpp == 32:
pack_pixel = xrgb8888
pixel_bytes = 4
else:
print(f"Unsupported bpp: {bpp}")
sys.exit(1)
# ── Open and mmap the framebuffer ─────────────────────────────
with open("/dev/fb0", "r+b") as f:
mm = mmap.mmap(f.fileno(), fb_size)
# Clear to black
mm[:fb_size] = b'\x00' * fb_size
# Draw a red rectangle (100x100) at center
rx, ry, rw, rh = width // 2 - 50, height // 2 - 50, 100, 100
red = pack_pixel(255, 0, 0)
for y in range(ry, ry + rh):
for x in range(rx, rx + rw):
offset = y * stride + x * pixel_bytes
mm[offset:offset + pixel_bytes] = red
# Draw a green horizontal line across the middle
green = pack_pixel(0, 255, 0)
y = height // 2
for x in range(width):
offset = y * stride + x * pixel_bytes
mm[offset:offset + pixel_bytes] = green
mm.close()
print("Done — check the display!")
EOF
Run it:
You should see a red square at the center of a black screen, with a green horizontal line through the middle.
What's Happening
Your Python program
│
▼
mmap(/dev/fb0) → maps framebuffer memory into your address space
│
▼
mm[offset] = pixel_bytes → write goes directly to display memory
│
▼
Display controller reads memory continuously at refresh rate (60 Hz)
│
▼
Pixels appear on screen — no window manager, no compositor, no GPU
Each pixel's position in memory is: offset = y * stride + x * pixel_bytes
The stride (not width * pixel_bytes) accounts for any padding the hardware adds at the end of each row.
Checkpoint
A red rectangle and green line are visible on the display. fb_draw.py detected the correct resolution and pixel format automatically.
5. Animate: Bouncing Square
Create a simple animation to see the framebuffer update in real time:
cat > fb_bounce.py << 'EOF'
#!/usr/bin/env python3
"""Bouncing square on the framebuffer."""
import mmap, struct, time, signal, sys
# ── Read FB params ────────────────────────────────────────────
def read_sysfs(name):
with open(f"/sys/class/graphics/fb0/{name}") as f:
return f.read().strip()
width, height = [int(x) for x in read_sysfs("virtual_size").split(",")]
bpp = int(read_sysfs("bits_per_pixel"))
stride = int(read_sysfs("stride"))
fb_size = stride * height
if bpp == 16:
def pack(r, g, b):
return struct.pack("<H", ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))
pixel_bytes = 2
else:
def pack(r, g, b):
return struct.pack("<I", (r << 16) | (g << 8) | b)
pixel_bytes = 4
BLACK = pack(0, 0, 0)
WHITE = pack(255, 255, 255)
running = True
def stop(sig, frame): global running; running = False
signal.signal(signal.SIGINT, stop)
# ── Animation loop ────────────────────────────────────────────
SQ = 60 # square size
x, y = width // 4, height // 4
dx, dy = 3, 2
with open("/dev/fb0", "r+b") as f:
mm = mmap.mmap(f.fileno(), fb_size)
mm[:fb_size] = b'\x00' * fb_size # clear
print(f"Bouncing {SQ}x{SQ} square on {width}x{height} — Ctrl+C to stop")
while running:
# Erase old position
for row in range(y, min(y + SQ, height)):
off = row * stride + x * pixel_bytes
mm[off:off + SQ * pixel_bytes] = BLACK * SQ
# Move
x += dx
y += dy
if x <= 0 or x + SQ >= width: dx = -dx
if y <= 0 or y + SQ >= height: dy = -dy
# Draw new position
for row in range(y, min(y + SQ, height)):
off = row * stride + x * pixel_bytes
mm[off:off + SQ * pixel_bytes] = WHITE * SQ
time.sleep(0.016) # ~60 fps
mm[:fb_size] = b'\x00' * fb_size # clear on exit
mm.close()
print("Stopped.")
EOF
A white square bounces around the screen. Press Ctrl+C to stop. This is tearing-prone (no VSync) — that's by design. The DRM/KMS tutorial shows how page flipping solves tearing.
Pixel Format Reference
| Format | Bytes/Pixel | Channel Packing | Notes |
|---|---|---|---|
| RGB565 | 2 | RRRRRGGGGGGBBBBB (16 bits) |
Common on small embedded LCDs |
| RGB888 | 3 | RRRRRRRRGGGGGGGGBBBBBBBB (24 bits) |
No alpha, tight packing, rare |
| XRGB8888 | 4 | xxxxxxxxRRRRRRRRGGGGGGGGBBBBBBBB (32 bits) |
DRM default; x = padding |
The scripts above auto-detect the format from /sys/class/graphics/fb0/bits_per_pixel and select the correct packing function.
6. Your Turn: Investigate and Modify
These tasks require you to read sysfs, calculate values, and modify code — not just copy-paste.
Task A: Framebuffer Math
Answer these questions by reading sysfs values and calculating by hand. Write your answers in a comment at the top of your script.
- What is the total size of your framebuffer in bytes? Calculate
stride × height, then verify it matches reality. - How many wasted bytes per row does your display have? Calculate:
stride - (width × bytes_per_pixel). If non-zero, explain why the hardware pads rows. - If your display is 32 bpp, how much memory would you save switching to 16 bpp? What color precision do you lose?
How to read the values:
cat /sys/class/graphics/fb0/virtual_size # width,height
cat /sys/class/graphics/fb0/bits_per_pixel # 16 or 32
cat /sys/class/graphics/fb0/stride # bytes per row
How to validate your calculation — compare your stride × height result against the actual memory size:
# Method 1: read the framebuffer size from the kernel
ls -l /dev/fb0 # may show 0 (device files often do)
# Method 2: more reliable — use Python to check the mapped size
python3 -c "
import os
with open('/sys/class/graphics/fb0/stride') as f: stride = int(f.read())
with open('/sys/class/graphics/fb0/virtual_size') as f: w, h = f.read().split(',')
print(f'Calculated: {stride} × {int(h)} = {stride * int(h)} bytes')
print(f'That is {stride * int(h) / 1024 / 1024:.2f} MB')
"
Example Answer (1920×1080, 32 bpp)
stride = 7680(= 1920 × 4, no padding in this case)total = 7680 × 1080 = 8,294,400 bytes ≈ 7.9 MBwasted per row = 7680 - (1920 × 4) = 0(no alignment padding)- Switching to 16 bpp:
1920 × 2 × 1080 = 4,147,200 bytes ≈ 3.9 MB— saves ~50%, but blue goes from 256 levels (8 bit) to 32 levels (5 bit)
Task B: Draw a Gradient
Modify fb_draw.py to fill the entire screen with a horizontal gradient — black on the left edge, bright blue on the right edge. Each column x should have a blue intensity proportional to its position:
You need to figure out: which packing function argument controls blue? What happens to red and green values?
Step-by-step approach:
- Start from
fb_draw.py— you already have the sysfs reading and pixel packing functions - Replace the rectangle-drawing loop with a nested loop over all pixels:
for y in range(height): for x in range(width): - For each pixel, compute
blue_valuefromx, then pack withpack_pixel(0, 0, blue_value) - Run it — if you see vertical banding (staircase steps instead of smooth gradient), count how many distinct bands you see
Why the Banding?
The gradient looks striped because of quantization — your 8-bit blue value (0–255) gets truncated when packed into the pixel format. At 16 bpp (RGB565), blue has only 5 bits = 32 levels. Across 1920 pixels, each level spans ~60 pixels — a visible band. Even at 32 bpp (8-bit blue = 256 levels), you may see faint bands because 256 steps across 1920 pixels means ~7.5 pixels per step.
The fix is dithering — adding a tiny random offset to each pixel's value so neighboring pixels alternate between two adjacent levels, and the eye perceives a smooth average. Replace the blue calculation with:
Try it with and without dithering on the same screen (e.g., top half dithered, bottom half not) to see the difference. This is the same technique GPUs use internally when converting between color depths.Hint: Speeding It Up
Approach 1 — row at a time (slow but obvious):
for y in range(height):
row = b''.join(pack_pixel(0, 0, int(255 * x / (width - 1))) for x in range(width))
off = y * stride
mm[off:off + width * pixel_bytes] = row
Approach 2 — precompute once (fast):
A horizontal gradient is the same row repeated for every y. Build it once, write it many times:
row = b''.join(pack_pixel(0, 0, int(255 * x / (width - 1))) for x in range(width))
row += b'\x00' * (stride - width * pixel_bytes) # pad to stride
for y in range(height):
off = y * stride
mm[off:off + stride] = row
Approach 3 — one single write (fastest):
Build the entire framebuffer as one bytes object and write it in a single operation:
row = b''.join(pack_pixel(0, 0, int(255 * x / (width - 1))) for x in range(width))
row += b'\x00' * (stride - width * pixel_bytes)
mm[:fb_size] = row * height
Compare all three with time.monotonic() — the difference is dramatic and shows why minimizing Python loop iterations matters in embedded.
Task C: Measure Frame Rate
Modify fb_bounce.py to measure and print the actual frame rate every second. You will need to:
- Count frames in a 1-second window
- Print the FPS value
- Try removing the
time.sleep(0.016)line — what happens to FPS? Is the CPU usage acceptable? (Check withhtopin another SSH session.)
Hint
Add these variables before the while loop:
What You Will See in htop
With time.sleep(0.016), the process uses ~1–3% CPU — it sleeps most of the time and only wakes up to draw.
Without sleep, the process becomes a busy loop — it consumes 100% of one CPU core. In htop you will see one core pinned at 100%. You may also notice the process jumping between CPU cores — the Linux scheduler migrates threads to balance load across cores, so the green bar flickers between CPU 0, 1, 2, 3. This is normal behavior and shows that your single-threaded program has no CPU affinity set.
This matters in embedded: a busy loop on a single-core system (like Pi Zero) leaves zero CPU for anything else — SSH becomes laggy, other services stall. Even on a quad-core Pi 4, wasting an entire core on drawing a 60 Hz animation that the display can only show 60 times per second is pointless work. The display controller reads framebuffer memory at a fixed 60 Hz regardless of how fast you write — frames between refreshes are never seen.
Rule of thumb for embedded animation loops: sleep to yield CPU, and never render faster than the display refresh rate. On framebuffer (no VSync signal available), time.sleep(1/60) is a reasonable approximation. The DRM/KMS tutorial shows how to use page-flip events to sleep until the exact VBlank moment.
Task D: Understand VTs and the Display
After running fb_draw.py, the terminal text is gone. Without rebooting, investigate what controls the screen:
- Find out which virtual terminal (VT) is active:
cat /sys/class/tty/tty0/active - Check if
fbiis still running:ps aux | grep fbi - Try switching VTs and observe the HDMI display:
sudo chvt 2 # switch to VT 2 — what do you see?
sudo chvt 1 # switch back to VT 1 — what do you see now?
Answer these questions:
- Did
chvt 1show the text console or the colored bars fromfbi? Why? - What happens if you kill
fbifirst (sudo killall fbi) and thenchvt 2+chvt 1?
Hint: What's Actually Happening
Try this sequence and watch the HDMI display after each command:
sudo python3 fb_draw.py # red rectangle — terminal text overwritten
sudo chvt 2 # VT 2: kernel tells fbcon to redraw → login prompt appears
sudo chvt 1 # VT 1: but what redraws here?
If fbi is still running on VT 1 (from Section 3), it hooks into VT switch events. When VT 1 gets focus, fbi redraws its image — you see the colored bars again, not the text console. This is why it looks like chvt 1 "brought back the squares." The fbi process is a VT-aware application — it registered itself to receive focus events and redraws when activated.
If you kill fbi first (sudo killall fbi), then chvt 1 gives control back to fbcon, which redraws VT 1's text buffer — you see the terminal again.
This reveals an important difference:
| Who redraws? | On VT switch? | |
|---|---|---|
fb_draw.py |
Nobody — the script exited, pixels are "fire and forget" | Content is gone (overwritten by whatever redraws next) |
fbi |
fbi process stays running, listens for VT events |
Redraws its image when VT gets focus |
fbcon |
Kernel console driver | Redraws text when VT gets focus (if no app claimed the VT) |
Takeaway: raw mmap writes have no persistence — once your process exits, nothing will restore those pixels. A production framebuffer app must keep running and handle redraw events, just like fbi does.
Task E: Wrong Stride Experiment
Deliberately break fb_draw.py by replacing the line:
with a hardcoded wrong value:
Run it and observe the output. If your display has alignment padding (stride > width × pixel_bytes), you will see a diagonal smear instead of a rectangle — each row starts a few pixels too early because you are not accounting for the padding bytes at the end of each row.
What If It Looks Normal?
If your display has no padding (stride == width × pixel_bytes), the output will look correct. In that case, try a different wrong value to see the effect:
Checkpoint
You have at least completed tasks A, B, and D. Your gradient fills the full screen, and you can explain why the terminal disappeared and how to get it back.
Solution Code
If you are stuck, reference solutions are available in the course repository under src/embedded-linux/solutions/framebuffer-basics/ — contains task_a_fb_math.py, task_b_gradient.py, task_c_fps_counter.py, and task_e_wrong_stride.py. Try to solve each task yourself first — the learning happens in the debugging, not in reading the answer.
What Just Happened?
You wrote pixels directly to video memory — the same mechanism used by embedded kiosks, factory HMIs, and boot splash screens. No window manager, no compositor, no GPU driver needed.
| Concept | What You Did |
|---|---|
| sysfs | Read resolution, bpp, and stride from /sys/class/graphics/fb0/ |
| mmap | Mapped device memory into user space — zero-copy pixel access |
| stride | Used y * stride (not y * width * bpp) for correct row addressing |
| pixel format | Packed RGB values into RGB565 or XRGB8888 depending on hardware |
| fbcon | Understood that console text and your pixels share the same buffer |
Why DRM/KMS Next?
The framebuffer API is simple and effective for static images, but it hits real limitations once you need smooth animation or production-quality output:
| Problem | Framebuffer (fbdev) |
DRM/KMS |
|---|---|---|
| Tearing | You write while the display reads — no synchronization. The bouncing square tears visibly. | Page flipping: you prepare a new frame in a back buffer, then atomically swap it during VBlank. The display never reads a half-written frame. |
| Resolution changes | Changing mode requires fbset or rebooting. No standard way to enumerate supported modes. |
You query connectors, modes, and timing from the kernel. The hardware tells you exactly what it supports. |
| Multiple outputs | One /dev/fb0 per display, no coordination. |
One /dev/dri/card0 manages all outputs (HDMI + DSI) with shared resources. |
| Hardware planes | Not exposed — everything is one flat buffer. | Hardware overlay planes can composite layers (e.g., video + UI) without CPU cost. |
| Future-proof | Deprecated by the kernel — no new fbdev drivers are accepted. Existing drivers are maintained but receive no new features. | Active development. All new display drivers are DRM-only. |
The framebuffer is the right place to understand how pixel memory works. DRM/KMS is where you build products.
The next tutorial uses modetest and DRM dumb buffers to display tear-free graphics — same pixel math you learned here, but with proper synchronization.
Deliverable
- [ ]
fbset -ioutput shows your resolution and pixel format - [ ]
fb_test.pngdisplayed viafbi— colored bars visible on screen - [ ]
fb_draw.py— red rectangle and green line drawn directly to/dev/fb0 - [ ]
fb_bounce.py— bouncing square animation runs at ~60 fps - [ ] Task A — framebuffer math answers (total size, wasted bytes, bpp comparison)
- [ ] Task B — horizontal gradient fills the screen, smooth color transition
- [ ] Task D — restored the console after framebuffer overwrite, explanation of why
chvtworks