Skip to content

Display Drivers

Phil Schatzmann edited this page Aug 30, 2026 · 1 revision

Overview

TinyGPU keeps rendering (Surface/FrameBuffer/Sprite, in RGB565/RGB666/RGB888/monochrome memory) completely separate from getting the finished pixels onto real hardware. Display drivers are the second half: a small, bus-specific class that takes a rendered ISurface<RGB_T> and pushes it to a physical panel controller.

Every driver derives from the same abstract base, DisplayDriver<RGB_T>:

template <typename RGB_T = RGB565>
class DisplayDriver {
 public:
  virtual bool begin() = 0;
  virtual void end() {}
  virtual bool writeData(ISurface<RGB_T>& surface) = 0;
  virtual bool writeData(ISurface<RGB_T>& surface, size_t x, size_t y) = 0;
  virtual void setRotation(DisplayRotation rotation) {}
  virtual size_t width() const = 0;
  virtual size_t height() const = 0;
  void writeColor(size_t width, size_t height, RGB_T color);

 protected:
  virtual bool setAddressWindow(size_t x, size_t y, size_t w, size_t h) = 0;
};

Because every concrete driver implements this same interface, application code (and TinyGPU's own DeviceOutput wrapper, LCDBoard setups, and the LVGLDriver integration) can hold a DisplayDriver<RGB_T>&/DisplayDriver<RGB_T>* and work with any panel/bus combination without caring which one it actually is:

#include <TinyGPU.h>
#include <TinyGPU/Drivers/DisplayDriverSPI.h>

using namespace tinygpu;

ILI9341Driver<RGB565> displayDriver(SPI, /*cs=*/5, /*dc=*/2, /*rst=*/4);
DeviceOutput<RGB565> out(displayDriver);

void setup() {
  SPI.begin();
  out.begin();
}

void loop() {
  FrameBufferRGB565 fb(240, 320, FontRGB565);
  fb.begin();
  fb.fillRect(0, 0, 240, 320, RGB565(255, 0, 0));
  out.writeData(fb);
}

Driver files at a glance

Each bus family lives in its own header under src/TinyGPU/Drivers/ - include the one(s) you actually need rather than pulling in every bus's dependencies.

File Bus Platform Panel drivers included
DisplayDriverSPI.h 4-wire SPI Any Arduino core (SPIClass) ST7735Driver, ST7789Driver, ILI9341Driver, ILI9342Driver, HX8357Driver, ST7796Driver
DisplayDriverQSPI.h Quad-SPI (1 clk, 1 cs, 4 data, no DC pin) ESP32 (esp_lcd + spi_master) NV3041ADriver
DisplayDriverParallel8.h 8-bit parallel ("8080"), bit-banged Any Arduino core ST7735Driver8080, ST7789Driver8080, ILI9341Driver8080, HX8357Driver8080, ST7796Driver8080
DisplayDriverParallel8ESP32.h 8-bit parallel, hardware-accelerated (LCD_CAM/I80 or PARLIO) ESP32 family (esp_lcd) ILI9341Driver8080ESP32, ST7789Driver8080ESP32
DisplayDriverParallel8RP2040.h 8-bit parallel, PIO/DMA-accelerated RP2040 (pico-sdk hardware/pio.h) ILI9341Driver8080RP2040, ST7789Driver8080RP2040
DisplayDriverDSI.h MIPI-DSI ESP32-P4 only ST7701Driver
DisplayDriverSDL.h none - desktop window Desktop (SDL2) DisplayDriverSDL (generic, no controller)

All panel drivers are class templates parameterized on RGB_T (default RGB565); every bus's bulk writeData() currently assumes a 16bpp RGB_T (static_assert(sizeof(RGB_T) == 2, ...)), so use RGB565 unless a specific driver documents otherwise.

Choosing a driver

  • You have a real controller chip on SPI (ILI9341, ST7789, ...): use DisplayDriverSPI.h. This is the default choice - works on every Arduino core, needs the fewest pins (CS/DC/RST + the SPI bus), and is what most breakout-board TFTs use.
  • The panel is wired with 8 parallel data pins (D0-D7) instead of SPI: use one of the DisplayDriverParallel8* files (see below for which one).
  • The panel/board documents QSPI (1 clock + 1 CS + 4 data lines, no separate D/C pin) on an ESP32: use DisplayDriverQSPI.h.
  • The panel is MIPI-DSI (typically a P4 board driving a large panel at high resolution): use DisplayDriverDSI.h.
  • You're developing/testing on a desktop, no hardware attached: use DisplayDriverSDL.h, paired with TouchDriverSDL for input.
  • Your exact panel isn't in the list: pick the file matching your bus, subclass the relevant base class (DisplayDriverSPI/DisplayDriverParallel8/...), and supply your panel's own init register sequence via that base's writeCommand()/writeDataN() helpers - every concrete driver in this library is a fairly small class built the same way, so an existing one is the best starting template.

SPI (DisplayDriverSPI.h)

The most portable option - built on the Arduino SPIClass API, so it works unmodified on AVR, SAMD, ESP32, RP2040, or any other core that provides one.

DisplayDriverSPI<RGB_T> is the shared base: it owns the SPI bus reference, CS/DC/RST pins, and X/Y offsets, and implements the common MIPI-DCS wire protocol (0x2A/0x2B column/row address set, 0x2C RAM write, plus writeCommand()/writeData8()/writeData16()/writeDataN() helpers for init sequences). Concrete panel classes only need to supply their chip's specific init register sequence and any per-chip quirks (rotation/MADCTL table, gamma, etc.).

#include <TinyGPU/Drivers/DisplayDriverSPI.h>

using namespace tinygpu;

ILI9341Driver<RGB565> driver(SPI, /*cs=*/5, /*dc=*/2, /*rst=*/4,
                             ILI9341Driver<RGB565>::Rotation::kLandscape);
Class Controller Notes
ST7735Driver ST7735 Small (typically 128x160) panels
ST7789Driver ST7789 Common 240x240/240x320 panels
ILI9341Driver ILI9341 Full power/gamma/VCOM init sequence (ported from Bodmer/TFT_eSPI); supports setRotation() (DisplayRotation) and setInvertColor() for clone panels whose color filter needs display inversion
ILI9342Driver ILI9342 Structurally identical to ILI9341Driver, with additional power/VCOM/gamma programming some ILI9341-labelled clone panels actually need
HX8357Driver HX8357 Minimal reset/sleep-out/pixel-format/display-on init
ST7796Driver ST7796 Minimal init + MADCTL

ILI9341Driver/ILI9342Driver read a nativeWidth/nativeHeight (native/portrait orientation) at construction and report width()/height() swapped or not depending on the current DisplayRotation (see isLandscapeFamily() in DisplayDriver.h) - so layout code that reads width()/height() fresh (rather than caching its own copy) automatically follows a later setRotation() call.

QSPI (DisplayDriverQSPI.h)

For compact panel controllers that speak the increasingly common "4-wire quad-SPI" protocol - 1 clock, 1 chip-select, 4 data lines, no separate D/C pin, with every command wrapped in a fixed 32-bit frame. ESP32-only: built on ESP-IDF's esp_lcd_new_panel_io_spi() with quad_mode=1.

Class Controller Notes
NV3041ADriver New Vision NV3041A 480x272 panels on Guition/Sunton JC4827W543-class ESP32-S3 boards; full vendor gate/source-timing/gamma init sequence, confirmed on real hardware; needs display-inversion on for correct colors on this IPS panel
#include <TinyGPU/Drivers/DisplayDriverQSPI.h>

using namespace tinygpu;

NV3041ADriver<RGB565> driver(/*cs=*/45, /*sclk=*/47, /*d0=*/21, /*d1=*/48,
                             /*d2=*/40, /*d3=*/39);

8-bit parallel (DisplayDriverParallel8.h, DisplayDriverParallel8ESP32.h, DisplayDriverParallel8RP2040.h)

Three implementations of the same wire protocol - 8 data lines (D0-D7) + WR/RS(DC)/CS(/RD) - trading portability against throughput:

File How it drives D0-D7 Platform When to use it
DisplayDriverParallel8.h digitalWrite() per bit, pinMode/digitalWrite for WR/DC/CS Any Arduino core Any board; simplest option, and the only one that works outside ESP32/RP2040. Throughput-limited (one digitalWrite() call per bit, per byte)
DisplayDriverParallel8ESP32.h DMA through a real peripheral: LCD_CAM/I80 (esp_lcd_new_panel_io_i80) on ESP32/S2/S3, or PARLIO (esp_lcd_new_panel_io_parl) on newer targets without LCD_CAM (C6/H2/P4/...) ESP32 family Much faster bulk transfers on ESP32; backend is picked automatically at compile time from the target's soc_caps.h
DisplayDriverParallel8RP2040.h One PIO state machine (assembled program, see below) + a DMA channel feeding its TX FIFO RP2040 Much faster bulk transfers on RP2040; requires D0-D7 to be 8 consecutive GPIOs (a PIO addressing constraint) and a separate GPIO for WR

All three expose the same set of panel classes and constructor shape (d0..d7, wr, dc, cs, rst, ...), so switching between them for the same wiring is largely a find-and-replace of the class name:

Panel Bit-banged ESP32 HW RP2040 PIO
ILI9341 ILI9341Driver8080 ILI9341Driver8080ESP32 ILI9341Driver8080RP2040
ST7789 ST7789Driver8080 ST7789Driver8080ESP32 ST7789Driver8080RP2040
ST7735 ST7735Driver8080 - -
HX8357 HX8357Driver8080 - -
ST7796 ST7796Driver8080 - -
// Portable, any board:
#include <TinyGPU/Drivers/DisplayDriverParallel8.h>
using namespace tinygpu;
ILI9341Driver8080<RGB565> driver(/*d0..d7=*/2, 3, 4, 5, 6, 7, 8, 9,
                                 /*wr=*/10, /*dc=*/11, /*cs=*/12);

// ESP32, hardware-accelerated:
#include <TinyGPU/Drivers/DisplayDriverParallel8ESP32.h>
ILI9341Driver8080ESP32<RGB565> driver(2, 3, 4, 5, 6, 7, 8, 9,
                                      /*wr=*/10, /*dc=*/11, /*cs=*/12);

// RP2040, PIO/DMA-accelerated - D0..D7 must be consecutive GPIOs:
#include <TinyGPU/Drivers/DisplayDriverParallel8RP2040.h>
ILI9341Driver8080RP2040<RGB565> driver(/*d0=*/2, /*wr=*/10, /*dc=*/11, /*cs=*/12);

RP2040 PIO program

DisplayDriverParallel8RP2040.h embeds a 2-instruction PIO program (assembled with pico-sdk's pioasm, not hand-derived):

.program lcd8080_write
.side_set 1
.wrap_target
    out pins, 8    side 0   ; drive the byte onto D0-D7, WR low
    nop            side 1   ; WR high - panel latches on this edge
.wrap

It runs at 2 PIO-clock cycles/byte; the state machine's clock divider (clockDiv, default 8.0) controls the resulting WR toggle rate - lower it for faster panels/wiring, raise it if data looks corrupted.

MIPI-DSI (DisplayDriverDSI.h)

For panels driven over MIPI-DSI - ESP32-P4 only (esp_lcd_mipi_dsi.h only exists in that variant of arduino-esp32's bundled ESP-IDF; the header compiles to an empty no-op on every other target rather than hard-erroring). Unlike the bus-oriented drivers above, a DSI panel has no address-window/RAM-write concept: esp_lcd_new_panel_dpi() allocates a screen-sized, DMA-scanned framebuffer up front, and writeData() writes directly into a rect of it via esp_lcd_panel_draw_bitmap() - the continuously-running DPI bridge just scans out whatever is currently there. Only a single frame buffer is used, so a write landing mid-scan can tear (the same tradeoff the SPI/QSPI drivers already accept for a torn transfer landing mid-refresh).

Class Controller Notes
ST7701Driver Sitronix ST7701S 480x800 portrait, 4.3" - as wired on the Guition JC4880P443C_I_W (ESP32-P4). Init sequence, DSI lane config (2 lanes @ 500Mbps) and video timing transcribed verbatim from a hardware-verified vendor BSP
#include <TinyGPU/Drivers/DisplayDriverDSI.h>

using namespace tinygpu;

ST7701Driver<RGB565> driver(/*rst=*/-1, /*backlight=*/-1);

Desktop / SDL (DisplayDriverSDL.h)

DisplayDriverSDL<RGB_T> is a generic (not chip-specific) driver that renders a Surface into an SDL2 window - useful for developing and testing UI/rendering code without any hardware attached. writeData() also drains the SDL event queue and exits the process on the window's close button, since a sketch that never touches TouchDriverSDL (the library's other SDL-based piece, mapping the desktop mouse to a TouchDriver) would otherwise leave the window unresponsive to being closed.

#include <TinyGPU/Drivers/DisplayDriverSDL.h>

using namespace tinygpu;

DisplayDriverSDL<RGB565> driver(240, 320);

Color/byte order quirks

Two independent, panel-wiring-dependent issues show up as "wrong colors," and are fixed in different places - don't confuse one for the other:

  • Red/blue swapped: the panel's subpixel order is BGR rather than RGB. This is handled per-driver via the controller's own MADCTL register (see e.g. DisplayDriverSPI.h's ILI9341Driver::madctlForRotation(), which always sets the BGR bit) - not a setting you toggle yourself.
  • Colors scrambled/inverted-looking across everything drawn (not just images): some panel/wiring combinations expect the opposite byte order from the one RGB565 stores pixels in by default. DisplayDriverSPI<RGB_T>::setSwapOutputBytes(bool) swaps each pixel's two bytes right before they go out over the wire - the one place all pixel data (fills/text/images alike) funnels through before hitting the bus, so it's the right fix for this symptom (as opposed to e.g. JPEGParser::setSwapBytes(), which only affects decoded images).

Verification status

Every driver's source here was written by hand and reviewed; where this repository's own tooling could build-verify a driver (real Arduino-Emulator/desktop build for the portable/SPI drivers, arduino-cli cross-compiles for the ESP32 and RP2040 hardware-accelerated drivers), it has been - a real compile (and, where applicable, link) against the platform's actual toolchain, not just a syntax read-through. The one known exception is DisplayDriverParallel8ESP32.h's PARLIO backend (ESP32-C6/H2/P4/...): its source compiles cleanly, but at least one arduino-esp32 core version (3.3.10) is missing esp_lcd_new_panel_io_parl from its prebuilt libraries for those targets, so it fails to link there - see that file's class comment. None of this replaces testing against your own exact board/panel/wiring before shipping.