Skip to content

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:

cd ~
git clone https://github.com/OE-KVK-H2IoT/embedded-linux.git

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:

grep -E "dtoverlay.*vc4" /boot/firmware/config.txt

You should see:

dtoverlay=vc4-kms-v3d
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:

lsmod | grep vc4

Expected output includes vc4, v3d, and drm_kms_helper.

Check that a DRM device exists:

ls -la /dev/dri/card*
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, &center, 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:

sudo apt install libsdl2-dev

Build with the Makefile:

make -C src/embedded-linux/apps/level-display/

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-config is not found: sudo apt install libsdl2-dev
  • If linker errors about atomic_store: add -latomic to the link flags
  • If SDL.h not found: check that sdl2-config --cflags outputs 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:

sudo SDL_VIDEODRIVER=kmsdrm ./level_sdl2
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:

sudo SDL_VIDEODRIVER=kmsdrm ./level_sdl2 -v

This prints roll, pitch, and raw accelerometer values to the terminal at ~1 Hz:

IMU: roll=  3.2°  pitch= -1.5°  (ax=  -412  ay=   891  az= 16102)

When using the SPI fallback (/dev/spidev0.0), the app initialises the BMI160 automatically — you should see this in verbose mode:

BMI160: chip ID OK, sensor active
IMU: roll=  3.2°  pitch= -1.5°  (ax=  -412  ay=   891  az= 16102)

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:

IMU: read failed (Remote I/O error)
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:

ls /dev/bmi160        # character device exists?
ls /dev/spidev*       # SPI device exists?
dmesg | grep -i bmi   # driver loaded?


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:

sudo SDL_VIDEODRIVER=kmsdrm ./level_sdl2 -l jitter.csv

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

Course Overview | Next: Jitter Measurement →