Level Display: SDL2 + DRM/KMS (Tear-Free)
Time estimate: ~45 minutes Prerequisites: Level Display: Python, DRM/KMS Test Pattern
Learning Objectives
By the end of this tutorial you will be able to:
- Build a tear-free display application using SDL2 with DRM/KMS backend
- Compare visual quality between Python/fbdev and C/DRM/KMS
- Implement a multi-threaded sensor+render architecture
- Add jitter timing instrumentation
Understanding VSync and Page Flipping
A display refreshes its image line by line at a fixed rate (typically 60 Hz). If the application
overwrites the framebuffer mid-refresh, the top half of the screen shows the old frame and the
bottom half shows the new one — this visible glitch is called tearing. Double buffering
solves this by using two buffers: the application renders into a back buffer while the
display reads from the front buffer. When the frame is complete, the GPU swaps the buffer
pointers during the vertical blanking interval (VBlank) — the brief pause between the
last scan line and the first. This atomic swap is called a page flip and it eliminates
tearing entirely. In SDL2, passing the SDL_RENDERER_PRESENTVSYNC flag enables this
behaviour automatically — SDL_RenderPresent() blocks until the next VBlank before swapping.
For a deeper look at DRM/KMS and the Linux graphics stack, see the Graphics Stack Reference. For frame pacing, render budgets, and real-time graphics concepts, see the Real-Time Graphics Reference.
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/apps/level-display/.
1. Verify Full KMS
DRM/KMS (Direct Rendering Manager / Kernel Mode Setting) provides hardware-accelerated page flipping with VSync. The Raspberry Pi must use the full KMS driver, not the legacy "fake KMS" driver.
Check the boot configuration:
You should see:
Warning
If you see vc4-fkms-v3d (note the f for fake), SDL2 will fall back to fbdev and you will not get tear-free rendering. Edit the config and reboot.
Verify the kernel module is loaded:
Expected output includes vc4, v3d, and drm_kms_helper.
Check that a DRM device exists:
| Driver | Overlay | Page Flip | VSync | Tearing |
|---|---|---|---|---|
| Full KMS | vc4-kms-v3d |
Hardware | Yes | No |
| Fake KMS | vc4-fkms-v3d |
Software | Limited | Possible |
| fbdev only | None | None | No | Yes |
Checkpoint
ls /dev/dri/card0 exists and lsmod | grep vc4 shows the module loaded.
2. Build the SDL2 App
The SDL2 application uses a two-thread architecture:
- Sensor thread — reads the BMI160 at high frequency, stores the latest roll/pitch
- Render thread (main) — draws the artificial horizon, calls
SDL_RenderPresent()which blocks until VSync
Key sections of src/embedded-linux/apps/level-display/level_sdl2.c:
Initialization:
SDL_Init(SDL_INIT_VIDEO);
SDL_Window *win = SDL_CreateWindow("Level",
SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
0, 0, /* fullscreen ignores these */
SDL_WINDOW_FULLSCREEN_DESKTOP);
SDL_Renderer *ren = SDL_CreateRenderer(win, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
The SDL_RENDERER_PRESENTVSYNC flag is critical — it makes SDL_RenderPresent() block until the next vertical blanking interval, which prevents tearing.
Sensor thread:
static _Atomic float g_roll = 0.0f;
static _Atomic float g_pitch = 0.0f;
void *sensor_thread(void *arg) {
int fd = open("/dev/bmi160", O_RDONLY);
struct bmi160_data data;
while (!quit) {
read(fd, &data, sizeof(data));
float roll = atan2f(data.ay, data.az) * 180.0f / M_PI;
float pitch = atan2f(-data.ax,
sqrtf(data.ay * data.ay + data.az * data.az))
* 180.0f / M_PI;
atomic_store(&g_roll, roll);
atomic_store(&g_pitch, pitch);
usleep(5000); /* ~200 Hz sensor rate */
}
close(fd);
return NULL;
}
World texture (pre-rendered once):
The sky, ground, and horizon are rendered onto a large off-screen texture (sized to the screen diagonal so rotation never exposes corners). Pitch reference lines at ±10°, ±20°, ±30° help gauge the pitch angle.
SDL_Texture *world_tex = SDL_CreateTexture(ren,
SDL_PIXELFORMAT_RGBA8888, SDL_TEXTUREACCESS_TARGET, diag, diag);
SDL_SetRenderTarget(ren, world_tex);
/* Sky top half, ground bottom half, horizon line at center */
SDL_SetRenderTarget(ren, NULL);
Render loop:
Each frame, the world texture is rotated by roll and shifted by pitch using SDL_RenderCopyEx, just like a real aircraft attitude indicator. The aircraft symbol (crosshair) is drawn on top, fixed at screen center.
while (!quit) {
float roll = atomic_load(&g_roll);
float pitch = atomic_load(&g_pitch);
/* Rotate and shift the sky/ground world */
int pitch_px = (int)(pitch * 3.0f);
SDL_Rect dst = { (W - diag) / 2, (H - diag) / 2 + pitch_px,
diag, diag };
SDL_Point center = { diag / 2, diag / 2 - pitch_px };
SDL_RenderCopyEx(ren, world_tex, NULL, &dst,
(double)roll, ¢er, SDL_FLIP_NONE);
/* Fixed aircraft symbol at screen center */
draw_aircraft_symbol(ren, W / 2, H / 2);
/* Compass heading bar at the bottom */
draw_compass_bar(ren, yaw, W, H);
SDL_RenderPresent(ren); /* blocks until VSync */
}
Tip
The sensor thread runs at ~200 Hz while the render loop is locked to 60 Hz by VSync. This decoupling means the display always has fresh sensor data without waiting for the next render frame.
Compass heading (yaw)
The compass bar at the bottom of the display shows heading by integrating the BMI160 gyroscope Z-axis. Cardinal directions (N/E/S/W) and 10° tick marks scroll horizontally as you rotate the sensor.
Since the BMI160 has no magnetometer, the heading is relative (starts at 0° on launch) and drifts over time due to gyro bias. For absolute heading you would need a magnetometer (e.g. BMM150) or GPS.
3. Compile
Install SDL2 development libraries if not already present:
Build with the Makefile:
Or compile directly:
gcc -Wall -O2 $(sdl2-config --cflags) \
-o level_sdl2 level_sdl2.c \
$(sdl2-config --libs) -lm -lpthread
Checkpoint
The binary level_sdl2 (or src/embedded-linux/apps/level-display/level_sdl2) is produced with no errors.
Stuck?
- If
sdl2-configis not found:sudo apt install libsdl2-dev - If linker errors about
atomic_store: add-latomicto the link flags - If
SDL.hnot found: check thatsdl2-config --cflagsoutputs a valid include path
4. Run
Since the Raspberry Pi typically does not run X11 or Wayland in an embedded setup, SDL2 needs to be told to use the DRM/KMS backend directly:
Warning
The SDL_VIDEODRIVER=kmsdrm environment variable is required. Without it, SDL2 will try X11 or Wayland first, and if neither is running, it will fall back to fbdev — losing VSync.
You should see the attitude indicator rendered fullscreen on the HDMI display. Tilt the sensor board — the sky and ground rotate and shift like a real aircraft instrument while the aircraft symbol stays fixed at screen center.
Checkpoint
The application renders fullscreen on HDMI. The sky/ground rotate with roll and shift with pitch. The aircraft symbol stays centred. There is no visible tearing.
To verify the sensor is actually responding, run with the -v (verbose) flag:
This prints roll, pitch, and raw accelerometer values to the terminal at ~1 Hz:
When using the SPI fallback (/dev/spidev0.0), the app initialises the BMI160 automatically — you should see this in verbose mode:
Axis remapping: if the sensor board is mounted rotated (e.g. 90° on the PCB, or chip facing down), use -R to remap the axes. The format is three axis letters (X, Y, Z) with optional - sign prefix, specifying which raw sensor axis maps to each body axis:
# Sensor rotated 90° clockwise (viewed from above): body X = raw Y, body Y = -raw X
sudo SDL_VIDEODRIVER=kmsdrm ./level_sdl2 -v -R Y-XZ
Common mappings:
-R value |
Mounting orientation |
|---|---|
XYZ |
Default — chip up, X forward |
Y-XZ |
90° CW around Z |
-X-YZ |
180° around Z |
-YXZ |
270° CW around Z |
X-Y-Z |
Chip down (flipped) |
X-ZY |
Chip forward (standing) |
Tip
Not sure which mapping to use? Run with -v and tilt the board slowly — watch which raw axis (ax, ay, az) changes. At rest, the axis pointing up should read ~16384 (1 g). Use -R to map that axis to Z, and arrange X/Y so roll and pitch respond correctly.
Zero-offset calibration: if the horizon is slightly off even after choosing the right mapping, use -c to auto-calibrate at startup (waits ~1 s for the filter to settle), or press c at any time to recalibrate:
# Remap axes AND calibrate — hold the board level at launch
sudo SDL_VIDEODRIVER=kmsdrm ./level_sdl2 -v -R Y-XZ -c
Calibrating... hold still
Calibrated: offset roll=+3.2° pitch=-1.5°
IMU: roll= 0.0° pitch= 0.0° (ax= -412 ay= 891 az= 16102)
If reads are failing, -v will tell you:
Stuck?
/dev/bmi160 not found:
You need the BMI160 kernel driver and device tree overlay from the BMI160 SPI Driver tutorial. Load it and reboot.
/dev/spidev0.0 not found:
SPI is not enabled. Add dtparam=spi=on to /boot/firmware/config.txt and reboot.
BMI160 init failed or unexpected chip ID:
The SPI bus works but the sensor is not responding correctly. Check wiring (MOSI, MISO, SCLK, CS on CE0) and that the sensor is powered.
IMU: read failed in verbose mode:
The device opened and init succeeded but reads are failing. Check wiring and dmesg | grep -i bmi.
All values are zero (ax=0 ay=0 az=0):
The sensor is not being woken from suspend mode. This should not happen with the current code — if you see this, check that you are running the latest build (make -C src/embedded-linux/apps/level-display/).
Sensor connected but values look wrong:
At rest, az should be close to ±16384 (1 g). If values are noisy or stuck at unexpected values, check wiring and try a lower SPI speed.
Quick check commands:
5. Visual Comparison
Compare the Python framebuffer version with the SDL2 DRM/KMS version. If your setup allows, run them sequentially and note the differences:
# Run Python version first
sudo python3 src/embedded-linux/apps/level-display/level_display.py
# (Ctrl+C to stop)
# Then run SDL2 version
sudo SDL_VIDEODRIVER=kmsdrm ./level_sdl2
# (Ctrl+C to stop)
Observe:
| Aspect | Python / fbdev | SDL2 / DRM/KMS |
|---|---|---|
| Tearing | Visible, especially during fast rotation | None (VSync-locked) |
| Frame pacing | Variable (depends on Python speed) | Consistent 16.6 ms (60 Hz) |
| Motion smoothness | Jerky under CPU load | Steady regardless of background tasks |
| CPU usage | Higher (Python + OpenCV + conversion) | Lower (native C + hardware renderer) |
Tip
To make tearing more obvious in the Python version, try rotating the board quickly back and forth. The tearing line moves with each frame and is easiest to spot on the horizon boundary between sky and ground.
6. Add Jitter Timestamps
The SDL2 application supports CSV logging to record timing data for later analysis. Enable it with the -l flag:
The logging uses clock_gettime(CLOCK_MONOTONIC_RAW) for high-resolution, hardware-based timestamps that are immune to NTP adjustments:
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
uint64_t now = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
Each CSV row records:
| Column | Description |
|---|---|
timestamp_ns |
Absolute time in nanoseconds |
sensor_dt_ns |
Time between sensor reads |
frame_dt_ns |
Time between SDL_RenderPresent returns |
latency_ns |
Time from sensor read to frame display |
roll_deg |
Current roll angle |
pitch_deg |
Current pitch angle |
7. Analyze Initial Results
Let the application run for about 30 seconds with logging enabled, then stop with Ctrl+C.
Take a quick look at the data:
# Check the file
head -5 jitter.csv
wc -l jitter.csv
# Quick statistics with awk
awk -F',' 'NR>1 {print $3/1e6}' jitter.csv | sort -n | tail -5
At 60 Hz, frame dt should cluster tightly around 16.6 ms (16,666,666 ns). Sensor dt should be consistent at whatever rate the sensor thread runs (~5 ms for 200 Hz).
Tip
If you see occasional frame dt values of ~33 ms (double), that indicates a dropped frame — the render loop missed one VSync deadline. This is normal under load but should be rare at idle.
What Just Happened?
DRM/KMS page flipping eliminates tearing. When you call SDL_RenderPresent() with VSync enabled, SDL2 submits the completed frame to the DRM subsystem. The kernel schedules a page flip — swapping the displayed buffer pointer — at the next VBlank interrupt. The display controller atomically switches to the new buffer between scan lines, so you never see a half-old half-new frame.
VSync locks frame rate to 60 Hz. The SDL_RenderPresent() call blocks until the page flip completes, which naturally throttles your render loop to the display refresh rate. This is why frame dt clusters around 16.6 ms.
The hardware schedules buffer swaps at VBlank — even if your render thread is slightly early or late. As long as the frame is ready before the deadline, it will be displayed cleanly. If it is late, the previous frame is simply shown again (a "dropped frame").
Challenges
Challenge 1: Magnetometer Fusion
The compass heading drifts because it relies on gyro integration alone. Research how a complementary filter or Madgwick filter can fuse accelerometer, gyroscope, and magnetometer data for a stable heading. If you have a BMM150 magnetometer, implement a basic compass calibration routine.
Challenge 2: Bank Angle Indicator
Real attitude indicators have bank angle markings (10°, 20°, 30°, 60°, 90°) on an arc above the horizon. Add a fixed arc with tick marks and a rolling pointer that shows the current bank angle.
Deliverable
- [ ] Running SDL2 application with tear-free fullscreen display
- [ ] Visual comparison notes: describe the difference between Python/fbdev and SDL2/DRM
- [ ] Initial jitter CSV log (
jitter.csv) with at least 30 seconds of data