SDL2 Dashboard (No Compositor)
Time estimate: ~60 minutes (including host development) Prerequisites: DRM/KMS Test Pattern, Single-App Fullscreen UI
Learning Objectives
By the end of this tutorial you will be able to:
- Build a multi-gauge fullscreen dashboard using SDL2 with DRM/KMS backend
- Read sensor data from sysfs (temperature) and a character device (IMU)
- Render custom gauge elements (arc, horizon, bar) with SDL2 line drawing and SDL2_ttf
- Measure binary size, RAM usage, and startup time of an SDL2 application
SDL2 + DRM/KMS: Lightweight Embedded Display
SDL2 provides a thin, cross-platform layer over the GPU and input subsystems. On embedded Linux without a desktop, the kmsdrm video backend opens /dev/dri/card0, creates a GBM surface, and uses DRM atomic commits for VSync-synchronised page flips. This gives you GPU-accelerated rendering with no compositor overhead. SDL2 has no built-in widgets — you draw every arc, rectangle, and text string yourself from primitives (line segments, filled rects, SDL2_ttf for fonts). The result is tiny (5-8 MB RAM, <0.5 s startup) and gives you full control, but every new UI element means more C drawing code. This makes SDL2 ideal for resource-constrained devices with simple UIs (industrial sensor displays, boot splashes), while complex UIs with touch, animations, and rapid iteration favour Qt.
See also: Graphics Stack reference
Overview
The Level Display: SDL2 tutorial draws a single artificial horizon. Real embedded HMI products — industrial panels, vehicle dashboards, medical monitors — display multiple data sources simultaneously.
This tutorial builds a three-panel dashboard:
┌──────────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ ┌──────┐ │
│ │ TEMP │ │ HORIZON │ │ CPU │ │
│ │ gauge │ │ strip │ │ bar │ │
│ │ (arc) │ │ │ │ │ │
│ │ 23.5°C │ │ ═══╲═══ │ │ ▓▓░░ │ │
│ └──────────┘ └──────────┘ └──────┘ │
└──────────────────────────────────────┘
Data sources:
| Panel | Source | Fallback |
|---|---|---|
| Temperature | MCP9808 via sysfs (/sys/bus/i2c/.../temp1_input) |
Arrow keys Up/Down |
| Horizon | BMI160 via /dev/bmi160 |
Arrow keys Left/Right |
| CPU usage | /proc/stat |
Always available |
The keyboard fallback means this tutorial works on any machine with a display — your laptop, a VM, or the Pi. Develop on the host first, then deploy to the target.
1. Develop on Host
Before touching the Pi, build and iterate on your development machine. SDL2 is cross-platform — the same source compiles on your desktop and renders in a normal window via X11 or Wayland. Sensors are absent, so the keyboard fallback activates automatically.
Install Host Dependencies
On Debian/Ubuntu:
On Arch:
On Fedora:
Tip
The font file (DejaVuSans-Bold.ttf) lives in different directories on each distro. The source code searches multiple paths automatically — no #define to edit.
Build and Run Locally
gcc -Wall -O2 $(sdl2-config --cflags) \
-o sdl2_dashboard src/embedded-linux/apps/sdl2-dashboard/sdl2_dashboard.c \
$(sdl2-config --libs) -lSDL2_ttf -lm
./sdl2_dashboard
No sudo, no SDL_VIDEODRIVER — SDL2 auto-detects your desktop display server (X11 or Wayland) and opens a regular window. The application prints:
MCP9808 not found — using keyboard fallback (Up/Down)
BMI160 not found — using keyboard fallback (Left/Right)
Dashboard running (800x480). Press Escape or Ctrl+C to quit.
Use arrow keys to drive the gauges. This is your UI development loop — edit code, recompile, test in seconds.
Checkpoint
The dashboard opens in a desktop window. Arrow keys adjust temperature and roll. CPU bar shows your host's actual CPU usage (it reads /proc/stat, which works everywhere on Linux).
Tip
This is the standard embedded workflow: develop on host, deploy to target. The same pattern applies to SDL2, Qt, GTK, or any cross-platform toolkit. The code is identical — only the display backend changes (X11 on the desktop, KMS/DRM on the Pi).
Optional: Test in QEMU
If you want to verify the ARM binary before deploying to hardware, you can run the dashboard in a QEMU virtual machine with a virtual display.
Option A — Build inside a QEMU system VM:
Boot a Raspberry Pi OS image in QEMU (if you built one in Buildroot: Minimal Linux, you can reuse it):
# Download a Pi OS arm64 image or use your Buildroot image
qemu-system-aarch64 \
-M virt -cpu cortex-a72 -m 2G -smp 4 \
-device virtio-gpu-pci \
-display gtk,gl=on \
-device qemu-xhci -device usb-kbd \
-drive file=pi-image.qcow2,if=virtio \
-nic user,hostfwd=tcp::2222-:22 \
-bios /usr/share/qemu-efi-aarch64/QEMU_EFI.fd
SSH in, copy the source, install SDL2 inside the VM, build and run. The QEMU window shows the graphical output via virtio-gpu.
Option B — Cross-compile + QEMU user-mode:
# Install cross-compiler and ARM64 SDL2 libraries
sudo dpkg --add-architecture arm64
sudo apt install gcc-aarch64-linux-gnu \
libsdl2-dev:arm64 libsdl2-ttf-dev:arm64
# Cross-compile
aarch64-linux-gnu-gcc -Wall -O2 \
$(aarch64-linux-gnu-pkg-config --cflags sdl2) \
-o sdl2_dashboard_arm src/embedded-linux/apps/sdl2-dashboard/sdl2_dashboard.c \
$(aarch64-linux-gnu-pkg-config --libs sdl2) -lSDL2_ttf -lm
# Run the ARM binary on x86 via QEMU user-mode
qemu-aarch64-static ./sdl2_dashboard_arm
QEMU user-mode translates ARM system calls to x86 — the binary uses your host's display server, so it opens a normal window.
Tip
QEMU is optional. Host-native development covers 95% of UI work. Use QEMU when you need to verify the ARM binary specifically (e.g., testing cross-compilation, checking for architecture-specific bugs, or validating a Buildroot image).
2. Install Dependencies (Target)
On the Raspberry Pi, install the same SDL2 packages. The base SDL2 library was installed in Level Display: SDL2.
Checkpoint
sdl2-config --version prints a version number and fc-match DejaVuSans-Bold returns a path.
3. Understand the Architecture
Unlike the level display which uses a separate sensor thread at 200 Hz, the dashboard polls sensors at just 10 Hz in the main render loop. Why?
- Temperature changes slowly (~0.1 °C/s) — 10 Hz is more than enough
- CPU usage is computed from
/proc/statdeltas — high-frequency reads add noise - The IMU is secondary here (it was the primary sensor in the level display)
Main loop (60 Hz, VSync-locked):
├── SDL_PollEvent() — handle quit, keyboard fallback
├── Poll sensors (10 Hz) — read sysfs, /dev/bmi160, /proc/stat
├── Render temperature arc — SDL2 line segments (draw_arc)
├── Render horizon strip — filled rectangles + rotated line
├── Render CPU bar — filled rectangle (proportional height)
├── Render text overlays — SDL2_ttf for labels and values
└── SDL_RenderPresent() — blocks until VSync
Tip
The SDL_RENDERER_PRESENTVSYNC flag makes SDL_RenderPresent() block until VSync. This means the render loop naturally runs at 60 Hz (matching the display) without needing SDL_Delay() or manual timing.
4. Build and Run on Target
Copy the source to the Pi (or build directly if you cloned the repo there). Compile:
gcc -Wall -O2 $(sdl2-config --cflags) \
-o sdl2_dashboard src/embedded-linux/apps/sdl2-dashboard/sdl2_dashboard.c \
$(sdl2-config --libs) -lSDL2_ttf -lm
Run with the KMS/DRM backend (required on the Pi without a desktop environment):
Warning
SDL_VIDEODRIVER=kmsdrm is required for direct rendering without a compositor. Without it, SDL2 will try X11/Wayland first. On your development host you didn't need this because X11/Wayland was available — on the Pi there is no compositor.
You should see three panels: a temperature arc gauge on the left, an artificial horizon in the center, and a CPU usage bar on the right.
If sensors are connected (MCP9808, BMI160), the gauges show real data. Otherwise, arrow keys provide the same fallback as on the host:
- Up/Down — adjust simulated temperature (±0.5 °C per press)
- Left/Right — adjust simulated roll angle (±2° per press)
- Escape — quit
Checkpoint
The dashboard renders fullscreen on HDMI with all three panels visible. If sensors are present, the temperature gauge shows real temperature and the horizon responds to tilting.
5. Read the Code
Open src/embedded-linux/apps/sdl2-dashboard/sdl2_dashboard.c and walk through these key sections:
Sensor Reading
The temperature sensor is read via sysfs — the kernel's MCP9808 driver exposes temperature as a file:
static float read_temperature(void)
{
/* Glob resolves /sys/bus/i2c/devices/1-0018/hwmon/hwmon*/temp1_input */
FILE *f = fopen(path, "r");
int millideg;
fscanf(f, "%d", &millideg);
fclose(f);
return millideg / 1000.0f;
}
This is the standard Linux sensor interface: a driver in the kernel does the I2C transaction, and user space just reads a file. Compare this to the raw I2C approach from Enable I2C.
Arc Gauge Drawing
SDL2 has no built-in arc primitive. The gauge is drawn as a series of short line segments:
static void draw_arc(SDL_Renderer *ren, int cx, int cy, int radius,
float start_deg, float end_deg, int segments, ...)
{
float step = (end_deg - start_deg) / (float)segments;
for (int i = 0; i < segments; i++) {
float a1 = (start_deg + step * i) * M_PI / 180.0f;
float a2 = (start_deg + step * (i + 1)) * M_PI / 180.0f;
SDL_RenderDrawLine(ren, cx + radius*cos(a1), cy - radius*sin(a1),
cx + radius*cos(a2), cy - radius*sin(a2));
}
}
Tip
This is the "bring your own" nature of SDL2 — it provides a thin layer over the GPU but no widgets. Every visual element (arcs, gauges, text) must be built from primitives. Compare this to the Qt approach in Qt + EGLFS Dashboard.
Text Rendering
SDL2 itself cannot render text. The SDL2_ttf library loads a TrueType font and creates a texture for each string:
SDL_Surface *surf = TTF_RenderText_Blended(font, text, color);
SDL_Texture *tex = SDL_CreateTextureFromSurface(ren, surf);
SDL_RenderCopy(ren, tex, NULL, &dst_rect);
SDL_DestroyTexture(tex);
SDL_FreeSurface(surf);
Each text render is a surface → texture → copy → destroy cycle. For static labels, a production application would cache the textures.
Keyboard Fallback
On startup, the code probes whether each sensor exists:
If a sensor is absent, SDL_PollEvent handles arrow key presses to adjust simulated values. This pattern is useful for development — you can work on the UI at your desk without the sensor board.
Shared State and Signal Safety
The dashboard must shut down cleanly when the user presses Ctrl+C. A signal handler catches SIGINT and sets a flag that the render loop checks:
static volatile sig_atomic_t g_running = 1;
static void handle_signal(int sig)
{
(void)sig;
g_running = 0; /* signal handler writes */
}
/* in main(): */
while (g_running) { /* render loop reads */
/* ... */
}
Two keywords protect this shared variable:
volatile— tells the compiler "don't optimize away reads ofg_running; always reload it from memory." Withoutvolatile, the compiler might cacheg_runningin a register and the loop would never see the signal handler's write.sig_atomic_t— a type guaranteed to be read/written atomically with respect to signals. On most architectures this is justint, but using the correct type makes the intent explicit and portable.
This works for signals because there is only one writer (the signal handler) and one reader (the main loop), operating on a single variable.
Atomics vs Mutexes: When to Use Which
C11 introduced <stdatomic.h> with atomic_store, atomic_load, and other operations that guarantee thread-safe access to single variables — without locks.
#include <stdatomic.h>
atomic_int ready = 0;
/* Thread A (writer): */
atomic_store(&ready, 1); /* single atomic write, never blocks */
/* Thread B (reader): */
while (!atomic_load(&ready)) /* single atomic read */
; /* spin until ready */
How does this compare to a mutex?
atomic_store / atomic_load |
pthread_mutex_lock |
|
|---|---|---|
| Locking | No lock | Acquires a lock |
| Blocking | Never blocks | May block (thread waits) |
| Overhead | Very low (single CPU instruction) | Higher (system call if contended) |
| Protects | One variable, one operation | Arbitrary critical section |
Use atomics when you need to safely read or write a single shared flag or counter between threads (or between a signal handler and the main thread, as g_running does here).
Use a mutex when you need to protect multiple related variables or a read-modify-write sequence:
/* This NEEDS a mutex — two variables must stay consistent */
pthread_mutex_lock(&lock);
balance -= amount;
transactions++;
pthread_mutex_unlock(&lock);
/* atomic_store CANNOT protect this — the two writes could be
interleaved with another thread reading between them */
On embedded hardware (ARM Cortex-A on the Pi), atomic_store compiles to a single stlr (store-release) instruction — no OS involvement, no context switch, no syscall. This makes atomics ideal for the kind of lightweight thread signaling that embedded real-time applications need.
The dashboard uses volatile sig_atomic_t instead of atomic_int because it communicates with a signal handler, not a thread. Signal handlers have stricter rules than threads (they can only call async-signal-safe functions), and sig_atomic_t predates C11. For threaded code (like the sensor thread in Level Display: SDL2), prefer <stdatomic.h>.
Checkpoint
You can identify the five main sections in the code: sensor reading, arc drawing, text rendering, keyboard fallback, and signal-safe shutdown flag.
6. Measure (on Target)
Record these metrics on the Pi — you'll compare them against the Qt dashboard in the next tutorial.
Binary Size
Expected: ~50 KB (the binary itself; SDL2 and SDL2_ttf are shared libraries).
Runtime RAM
Run the dashboard, then from another terminal:
Expected: ~5-8 MB RSS.
Startup Time
time sudo SDL_VIDEODRIVER=kmsdrm ./sdl2_dashboard &
# Wait for "Dashboard running" message, then kill
Expected: < 0.5 seconds to first frame.
Checkpoint
Record your measurements in this table:
| Metric | Value |
|---|---|
| Binary size | |
| Runtime RAM (RSS) | |
| Startup time | |
| Shared libraries |
What Just Happened?
You built a multi-element dashboard using SDL2's low-level primitives: line segments for arcs, filled rectangles for the horizon and CPU bar, and SDL2_ttf for text. The application renders directly to the display via DRM/KMS — no window manager, no compositor, no desktop environment.
The key trade-off: SDL2 gives you full control and minimal overhead, but every visual element must be hand-coded. Adding a new gauge means writing more C drawing code. The Qt + EGLFS Dashboard tutorial shows the alternative — a declarative approach where you describe what to display and the framework handles how.
Challenges
Challenge 1: Network Throughput Gauge
Add a fourth panel that shows network throughput (bytes/sec). Read from /proc/net/dev, compute the delta between polls, and render it as a second vertical bar next to the CPU bar.
Challenge 2: Touch Input
Add touch input support using SDL2's touch events (SDL_FINGERDOWN, SDL_FINGERMOTION). When the user taps a gauge panel, highlight it with a border. This demonstrates SDL2's manual input handling compared to Qt's built-in MouseArea.
Deliverable
- [ ] Running SDL2 dashboard with all three panels (temperature, horizon, CPU)
- [ ] Measurement table completed (binary size, RAM, startup time)
- [ ] Code walkthrough notes: identify the arc drawing loop, sensor reading, and text rendering sections