SPI LCD Rendering on Nerves with Scenic

Scenic is one of the go-to options for putting a UI on Nerves hardware, but getting it to actually draw on a physical panel involves more than adding the dependency and writing a scene. Before Scenic renders a single pixel, the kernel needs to know your display exists, bring up a driver for it, and expose it as a framebuffer device — and that part is standard Linux display bring-up, not really a Scenic concern at all.

 

This post walks through that chain end to end on a BeagleBone-class board, using an SPI-connected ILI9488 LCD as the running example. On Nerves, Scenic doesn’t talk to a GPU or a windowing system — it renders by writing pixels straight into a Linux framebuffer device, /dev/fb0. That’s the whole trick and also the whole challenge: everything in this guide exists to get a valid, correctly-sized /dev/fb0 onto the board and then point Scenic at it.

 

Three things have to come together:
  1. A device tree overlay that brings up the SPI display and gives Linux a framebuffer for it.
  2. The kernel module for that framebuffer driver actually loaded at boot.
  3. A Scenic viewport configured with the driver, device path, and dimensions that match what the overlay declared.
 
If you haven’t worked with device tree overlays on Nerves before, it’s worth reading a general overlay/cape primer first — this post assumes you know why overlays exist and how to structure one, and jumps straight into the display-specific parts.

1. How SPI Framebuffer Displays Work on Linux

Before diving into the specific overlay, it helps to know the general shape of what’s happening under the hood, because it explains most of the pitfalls later on.
Small SPI-connected LCDs like the ILI9488 don’t have their own display controller memory that the CPU can just point a GPU at — there’s no HDMI, no DRM/KMS pipeline. Instead, a kernel driver (in the `fbtft`-style family of framebuffer drivers) keeps a copy of the screen contents in RAM and pushes it to the panel over SPI whenever it changes. Userspace never talks to the panel directly — it just writes bytes into `/dev/fb0`, and the driver handles translating that into SPI transactions the panel understands.
A few consequences fall out of that design, and they’re worth keeping in mind:
  • The framebuffer format is fixed by the driver, not negotiable at runtime — for the ILI9488 that’s 16-bit RGB565. If your rendering library assumes a different format, you’ll get color corruption rather than an error.
  • The refresh isn’t free. Every pixel written to `/dev/fb0` eventually has to cross the SPI bus, so SPI clock speed puts a real ceiling on frame rate (more on this in section 5).
  • Orientation is a driver/overlay concern, not a Scenic concern. Rotation is typically set via a DTS property, not in your scene code — if the picture is sideways, that’s a device tree fix, not an application fix.
With that model in mind, the rest of this guide is really just: get the driver to enumerate correctly, get `/dev/fb0` to appear with the right dimensions, and get Scenic writing to it.
 

2. The Device Tree Overlay: Bringing Up the Display

The overlay’s job here is threefold: configure pinmux for SPI and the display’s control GPIOs, declare the display node itself, and bring up the SPI bus it lives on. The relevant structure looks like this:
				
					/* Fragment 0: Disable conflicting pinmux */
fragment@0 {
    target = <&ocp>;
    __overlay__ {
        P8_08_pinmux { status = "disabled"; }; /* DC - gpio2[3] */
        P8_09_pinmux { status = "disabled"; }; /* RESET - gpio2[5] */
        P8_10_pinmux { status = "disabled"; }; /* LCD CS - gpio2[4] */
        P9_18_pinmux { status = "disabled"; }; /* SPI0_D1 MOSI */
        P9_21_pinmux { status = "disabled"; }; /* SPI0_D0 MISO */
        P9_22_pinmux { status = "disabled"; }; /* SPI0_SCLK */
    };
};

/* Fragment 3: Pinmux */
fragment@3 {
    target = <&am33xx_pinmux>;
    __overlay__ {
        bb_spi0_pins: pinmux_bb_spi0_pins {
            pinctrl-single,pins = <
                0x150 0x30   /* P9_22 SPI0_SCLK */
                0x154 0x30   /* P9_21 SPI0_MISO */
                0x158 0x10   /* P9_18 SPI0_MOSI */
            >;
        };
    };
};

/* Fragment 3: SPI0 */
fragment@3 {
    target = <&spi0>;
    __overlay__ {
        status = "okay";
        #address-cells = <1>;
        #size-cells = <0>;
        pinctrl-names = "default";
        pinctrl-0 = <&bb_spi0_pins>;

        display@0 {
            compatible = "ilitek,ili9488";
            reg = <0>;
            spi-max-frequency = <32000000>;
            dc-gpios    = <&gpio2 3 0>;
            reset-gpios = <&gpio2 5 0>;
            backlight   = <&backlight_fixed>;
            rotation = <180>;
            width = <480>;
            height = <320>;
        };
    };
};
				
			
The properties that actually matter day-to-day:
Property Why it matters
width / height
Must match your Scenic viewport `size` exactly — a mismatch is the single most common cause of a garbled or partial image.
spi-max-frequency
The panel’s maximum supported SPI clock. Pushing past it causes visible artifacts, not a clean failure.
dc-gpios / reset-gpios
The data/command and reset lines your specific panel is wired to — these are board-specific, not ILI9488-specific.
rotation
Fixes physical mounting orientation at the driver level, so your scene code can always assume landscape (or whatever orientation you designed for).
Once this is in place and the firmware is rebuilt, the kernel should enumerate the display on boot:
				
					[    8.246315] ili9488 spi0.0: ILI9488 rev 0
[    8.251921] ili9488 spi0.0: fb0: ili9488 frame buffer device
				
			
and the framebuffer device node should exist:
				
					ls -l /dev/fb0
# crwxrwxrwx 1 root root 10, 32768 Sep 10 09:22 /dev/fb0
				
			
If you don’t see either of those, the overlay isn’t applying or isn’t matching your board — check the `compatible` string before anything else.
 

3. Loading the Framebuffer Kernel Module

Normally, a device tree `compatible` string match is enough for Linux to auto-load the right driver — you don’t run `modprobe` by hand. But in practice, some Nerves system builds compile a driver like `ili9488` as a module without including it in the default auto-load list, which means the framebuffer never appears even though the overlay applied cleanly.

 

The reliable fix is to `modprobe` it explicitly, early in your application’s boot sequence, and give the driver a moment to finish probing before anything tries to open `/dev/fb0`:
				
					defp load_framebuffer_driver do
  PpScenic.BootTrace.timed("modprobe ili9488", fn ->
    run_modprobe("ili9488", "framebuffer")
  end)

  PpScenic.BootTrace.timed("Process.sleep(200) after modprobe", fn ->
    Process.sleep(200)
  end)
end
				
			
This runs during `Application.start/2`, guarded to target hardware only (`target != :host`), so it’s a no-op when you’re iterating in the host-based Scenic simulator. The short sleep afterward isn’t cosmetic — without it, it’s easy to race the viewport’s attempt to open `/dev/fb0` against the driver still finishing its probe.
 

4. The Framebuffer Driver Scenic Uses

Scenic ships a standard Nerves framebuffer driver, but it assumes a fairly generic pixel format. The ILI9488 here needed a **custom driver** (`Scenic.Driver.FbScenic`) instead, to correctly handle its native 16-bit RGB565 layout and write to `/dev/fb0` through a dedicated `FbWriter` process rather than the default write path.

 

Whichever driver you end up using — stock or custom — it gets registered the same way, in the viewport’s driver list:
 
				
					[[module: Scenic.Driver.FbScenic, fbdev: "/dev/fb0"]]
				
			

4.1 What `Scenic.Driver` Requires

To plug a custom driver into Scenic, you implement the `Scenic.Driver` behavior. The behavior declares a small set of callbacks your module must provide:
  • `validate_opts/1` — check the driver options at compile/config time
  • `init/2` — called once at viewport startup; open devices, read dimensions, return `{:ok, driver}`
  • `update_scene/2` — called whenever the graph changes; produce and push pixels to the hardware
  • `handle_info/2` — for async messages (e.g. deferred work after `init` returns)
  • `clear_color/2` — fill the screen with a solid color
  • `terminate/2` — clean up at shutdown

The `validate_opts/1` callback is the simplest — since our driver accepts standard keyword options (`fbdev`, stride, etc.), it passes everything through unchanged:

				
					# pp_nerves_app/apps/pp_scenic/lib/driver/FbScenic.ex
@impl true
def validate_opts(opts) when is_list(opts), do: {:ok, opts}
				
			
The `init/2` callback is where the driver comes to life. It reads the framebuffer’s actual geometry from sysfs — stride, bits-per-pixel, and the real pixel dimensions — so the driver can adapt to whatever the kernel reports rather than hardcoding values:
				
					@impl true
def init(%Scenic.Driver{} = driver, opts) do
  fbdev = Keyword.get(opts, :fbdev, @default_fbdev)

  {stride, bpp, fb_w, fb_h} = read_fb_sysfs(fbdev)

  # ... load fonts, start FbWriter ...
  {:ok, driver}
end
				
			

4.2 Reading Framebuffer Geometry from sysfs

The three helper functions our driver uses to interrogate the framebuffer device are worth calling out, because they illustrate the general pattern for any custom framebuffer driver on Linux:
				
					# From pp_nerves_app/apps/pp_scenic/lib/driver/FbScenic.ex
defp read_stride(fbdev) do
  sys = "/sys/class/graphics/#{Path.basename(fbdev)}/stride"
  case File.read(sys) do
    {:ok, content} -> Integer.parse(String.trim(content)) |> elem(0)
    _ -> 960
  end
end

defp read_bits_per_pixel(fbdev) do
  sys = "/sys/class/graphics/#{Path.basename(fbdev)}/bits_per_pixel"
  case File.read(sys) do
    {:ok, content} -> Integer.parse(String.trim(content)) |> elem(0)
    _ -> 16
  end
end

defp read_fb_dimensions(fbdev, stride, bpp) do
  fb_w = div(stride * 8, max(bpp, 1))
  sys = "/sys/class/graphics/#{Path.basename(fbdev)}"
  fb_h = case File.read(Path.join(sys, "virtual_size")) do
           {:ok, raw} -> parse_virtual_size(raw)
           _ -> @default_height
         end
  {fb_w, fb_h}
end

				
			
This is general-purpose Linux framebuffer introspection — the same paths exist on any embedded Linux system with a `/dev/fb0` device, not just Nerves. A custom driver that needs to know the real pixel format or line stride should always read these rather than assume defaults.
 

4.3 Why We Needed a Custom Driver

The general lesson worth taking away, independent of this specific panel: **check your panel’s native color format against what your Scenic driver expects before assuming a rendering bug is a scene code problem.** A color-channel swap or a washed-out image is very often a pixel-format mismatch between the driver and the panel, not a bug in your graph.
For the ILI9488 specifically, the mismatch is RGB565. Scenic’s built-in Nerves framebuffer driver renders into an RGBA buffer that it then copies to the framebuffer — but if the framebuffer reports 16-bit RGB565 (as the ILI9488 does), that copy path needs an explicit RGBA-to-RGB565 pass or the colors come out wrong. Our `FbWriter` handles that in the `rgba_to_rgb565/1` function.
 
 

5. Performance: SPI Clock Speed vs. Frame Rate

This is worth calling out on its own, since it’s easy to hit the ceiling without realizing why. Every full-screen redraw on a panel like this has to move `width × height × bytes-per-pixel` over SPI — for a 480×320 RGB565 display, that’s roughly 300 KB per frame. At `spi-max-frequency = 32000000` (32 MHz), the theoretical best case is a few frames per second for full-screen updates, and real-world throughput is lower once driver and bus overhead are accounted for.
Practical implications:
  • Static or mostly-static UIs (dashboards, status screens, calculator-style apps) are a good fit for this class of display — you’re rarely redrawing the whole panel.
  • Animation-heavy scenes will visibly struggle; if you need smoother motion, minimize the redrawn region rather than pushing full-frame updates, or budget your expectations accordingly.
  • Raising `spi-max-frequency` past the panel’s rated maximum sometimes “works” but risks visible corruption under thermal or cable-length stress — treat the datasheet number as a real ceiling, not a suggestion.

6. Configuring the Scenic Viewport

The viewport is where the pieces from sections 2–4 come together. It needs to agree with the device tree on size, and reference the driver and framebuffer path from section 4:
				
					defp main_viewport_config do
  default_scene = root_scene_module()
  %{
    name: :main_viewport,
    size: {480, 320},
    default_scene: default_scene,
    theme: :dark,
    drivers: viewport_drivers(default_scene)
  }
end

defp viewport_drivers(_) do
  [
    [module: Scenic.Driver.FbScenic, fbdev: "/dev/fb0"]
  ]
end
				
			
`size: {480, 320}` isn’t just a viewport setting — it must match the `width`/`height` your overlay declared in section 2. If they disagree, you’ll typically see a partial or wrapped image rather than a clean error, which makes this mismatch one of the more confusing bugs to track down if you don’t know to check it first.
 
 

7. Fonts on an Embedded Framebuffer Target

Scenic renders text via TTF fonts, and on a stripped-down Nerves target it’s easy for the font assets a scene expects to simply not be present at build time. If your font-loading step is skipped, Scenic falls back to a built-in font that may not render cleanly on a small hardware framebuffer — text can come out at the wrong size or with visible artifacts.
The fix is to make sure your font files are actually copied into place before Scenic’s asset compiler runs, as part of your build pipeline (a custom Mix compiler step is a reasonable way to do this). If you’re seeing garbled or oddly-sized text on real hardware but not in the host simulator, missing fonts at compile time — not a scene code issue — is the first thing worth checking.
 

8. Build, Flash, and Verify

  1. Rebuild firmware — this recompiles the DTS and regenerates the `.dtbo` using

    mix firmware

  2. Flash to the board

    mix burn

  3. Watch boot logs for the framebuffer probe
    ili9488 spi0.0: fb0: ili9488 frame buffer device
  4. Confirm `/dev/fb0` exists, from an IEx session on the target:
    File.exists?(“/dev/fb0”)
    #=> true
  5. Check the framebuffer’s reported dimensions
    dims = File.read!(“/sys/class/graphics/fb0/virtual_size”) |> String.trim()
    IO.puts(“Framebuffer: #{dims}”)
    # Expected: “480,320” for a landscape 480×320 display
  6. Watch for the driver attaching and writing its first frame
    [boot +1234ms] modprobe ili9488 (framebuffer) ok: …
    [boot +1456ms] FbScenic.init fbdev=/dev/fb0 stride=960 bpp=16 fb_pixels=480×320 viewport=480×320
    [boot +1589ms] FbScenic.init done
    [boot +1720ms] FbWriter FIRST frame written to /dev/fb0 
If you get through step 6 and still see a blank or garbled screen, the pipeline up to the framebuffer is working — at that point the bug is almost always in scene rendering or the pixel-format handling from section 4, not in the device tree or module loading.
 

9. Common Pitfalls

  • Overlay `compatible` string missing your board variant — the overlay silently won’t load. List every board you actually deploy to (e.g. `ti,beaglebone-black`, `ti,beaglebone-green`, `ti,beaglebone-green-wireless`), not just the one on your desk.
  • Framebuffer dimensions mismatch — if `/sys/class/graphics/fb0/virtual_size` reports `320,480` (portrait) but your viewport expects `{480, 320}` (landscape), rendering breaks. Fix `rotation` in the DTS, or swap the viewport size to match — don’t try to fix it in scene code.
  • Missing fonts at build time — see section 7. This shows up as bad text rendering, not a crash, so it’s easy to misattribute.
  • Module not auto-loaded — some Nerves system builds don’t include the panel driver in the default module list; an explicit `modprobe` at boot (section 3) is the dependable fix.
  • Pushing SPI clock past the panel’s rated maximum — tempting for frame rate, but trades correctness for speed in a way that doesn’t always fail cleanly.
This post focuses on getting a framebuffer-backed LCD rendering under Scenic. For the device tree fundamentals behind the overlay in section 2 — why overlays diverge across board variants, and how to debug a pin conflict generally — see the companion post on building custom device tree overlays for Nerves. Touch input on top of this same display is covered separately
Facebook
Twitter
LinkedIn
Email

Recent Posts

5 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *

Table Header Table Header Table Header Table Header
Content
Content
Content
Content