Post

Driving a TFT Display with Rust on the ESP32

A practical guide to wiring up a 128×128 ST7735s TFT display to an ESP32, initialising it from bare-metal Rust with no_std, and building a live dashboard with embedded-graphics.

Driving a TFT Display with Rust on the ESP32

Rust on embedded hardware used to mean C with extra steps. In 2025 that story has changed dramatically — the esp-hal ecosystem gives you type-safe peripheral access, zero-cost abstractions, and the full power of the Rust type system on a £3 microcontroller. This post walks through everything needed to get pixels on a 128×128 ST7735s TFT display from scratch.

What We Are Building

A bare-metal WiFi dashboard that runs on the ESP32 with no operating system. It connects to WiFi, makes periodic HTTP requests, and shows live stats on a small colour TFT display — IP address, uptime, request counters, and the last HTTP status code.

The full source is a single main.rs with no heap-allocated display buffers, no threading, and no unsafe beyond what the HAL macros require.


Hardware You Need

Part Notes
ESP32-WROOM-32 dev board Any 38-pin or 30-pin variant works
128×128 ST7735s TFT module Make sure it is the S variant (BGR colour order)
Jumper wires 6 connections needed

Wiring

TFT Pin ESP32 GPIO Purpose
DC GPIO 2 Data / Command select
RST GPIO 4 Hardware reset (active low)
CS GPIO 5 SPI chip select (active low)
SCK GPIO 18 SPI clock
SDA/MOSI GPIO 23 SPI data out
VCC 3.3 V Power
GND GND Ground

The ST7735s runs on 3.3 V logic. Do not connect it to 5 V — you will destroy the display controller.

Some cheap modules label the MOSI pin SDA. This is normal — it is an SPI data line, not I²C.


Project Setup

Install the ESP Rust toolchain if you have not already:

1
2
3
cargo install espup
espup install
source ~/export-esp.sh

Create the project:

1
2
cargo new wifi-display --bin
cd wifi-display

Your Cargo.toml dependencies section needs at minimum:

1
2
3
4
5
6
7
8
9
10
[dependencies]
esp-hal          = { version = "1.0", features = ["esp32"] }
esp-alloc        = "0.6"
esp-println      = { version = "0.13", features = ["esp32"] }
esp-bootloader-esp-idf = "0.1"
mipidsi          = "0.9"
embedded-graphics = "0.8"
embedded-hal-bus  = "0.2"
embedded-io      = "0.6"
heapless         = "0.8"

And at the top of main.rs:

1
2
#![no_std]
#![no_main]

Initialising the Display

The SPI Bus

The ST7735s communicates over SPI. On the ESP32 we use the SPI2 (HSPI) peripheral. esp-hal requires you to construct the peripheral with an explicit clock frequency and mode before attaching pins:

1
2
3
4
5
6
7
8
9
let spi = Spi::new(
    peripherals.SPI2,
    Config::default()
        .with_frequency(Rate::from_mhz(40))
        .with_mode(Mode::_0),   // CPOL=0, CPHA=0
)
.unwrap()
.with_sck(peripherals.GPIO18)
.with_mosi(peripherals.GPIO23);

The display is write-only so there is no MISO pin.

40 MHz is the maximum SPI speed for this display controller. Some cheap clone modules only run reliably at 16–20 MHz. If you see corrupted pixels, drop the frequency first.

ExclusiveDevice and the SPI Interface

mipidsi does not take a raw SPI peripheral — it wants an SpiDevice from embedded-hal. We wrap ours using embedded-hal-bus:

1
2
3
4
5
6
let spi_device = embedded_hal_bus::spi::ExclusiveDevice::new(
    spi, cs, delay
).unwrap();

let mut buffer = [0u8; 512];
let di = SpiInterface::new(spi_device, dc, &mut buffer);

The buffer is a scratch area that mipidsi uses to stage pixel data before flushing it to the SPI FIFO. 512 bytes is enough for this display size.

Builder Initialisation

1
2
3
4
5
6
7
8
let mut display = Builder::new(ST7735s, di)
    .reset_pin(rst)
    .display_size(128, 128)
    .color_order(ColorOrder::Bgr)   // fixes red/blue swap on this panel
    .init(&mut delay)
    .unwrap();

display.clear(Rgb565::BLACK).unwrap();

The ColorOrder::Bgr line is the most common gotcha with cheap ST7735s modules. Without it, red and blue channels are swapped — your reds look blue and vice versa.


Drawing with embedded-graphics

embedded-graphics is a no_std-compatible 2D drawing library. It works with any display that implements DrawTarget — which mipidsi does automatically.

Colour

The ST7735s uses RGB565: 5 bits red, 6 bits green, 5 bits blue, packed into a 16-bit word. Rgb565::new(r, g, b) takes values in those ranges:

1
2
3
4
5
// Some useful colours
const BG:          Rgb565 = Rgb565::BLACK;
const ACCENT_CYAN: Rgb565 = Rgb565::new(0, 63, 31);   // max green + blue
const ACCENT_LIME: Rgb565 = Rgb565::new(4, 50, 4);
const ACCENT_RED:  Rgb565 = Rgb565::new(28, 10, 4);

Filled Rectangles

Rectangles are the workhorse primitive. Use them for backgrounds, dividers, and clearing old text before redrawing:

1
2
3
4
5
6
7
8
9
10
11
// Draw a navy header bar
Rectangle::new(Point::new(0, 0), Size::new(128, 16))
    .into_styled(PrimitiveStyle::with_fill(HEADER_BG))
    .draw(&mut display)
    .unwrap();

// Erase a row before updating its text (prevents ghost characters)
Rectangle::new(Point::new(0, 18), Size::new(128, 13))
    .into_styled(PrimitiveStyle::with_fill(BG))
    .draw(&mut display)
    .unwrap();

Text Rendering

embedded-graphics ships with a set of monospaced bitmap fonts. We use two: FONT_6X10 for labels and FONT_10X20 for the large status code display:

1
2
3
4
5
6
7
8
9
10
11
12
let centered = TextStyleBuilder::new()
    .alignment(Alignment::Center)
    .build();

Text::with_text_style(
    "ESP32  DASHBOARD",
    Point::new(64, 12),          // baseline position
    MonoTextStyle::new(&FONT_6X10, ACCENT_CYAN),
    centered,
)
.draw(&mut display)
.unwrap();

The Point you pass to Text is the baseline, not the top-left corner. For FONT_6X10 the ascender is approximately 8 px above the baseline. If your text appears lower than expected, subtract ~8 from the y value.

Formatting Dynamic Values

Since we have no std::fmt, we use heapless::String — a stack-allocated string with a fixed capacity — combined with write!():

1
2
3
4
5
6
7
8
9
10
11
12
13
use core::fmt::Write;   // must be imported by this exact name for write!()

let mut uptime_str: heapless::String<12> = heapless::String::new();
write!(uptime_str, "{:02}:{:02}:{:02}", h, m, s).ok();

Text::with_text_style(
    uptime_str.as_str(),
    Point::new(124, 42),
    MonoTextStyle::new(&FONT_6X10, TEXT_WHITE),
    right,
)
.draw(&mut display)
.unwrap();

Always import core::fmt::Write without an alias if you want write!() to work. The macro resolves the trait by name. Aliasing it as anything else (e.g. use core::fmt::Write as FmtWrite) will cause a confusing compile error.


Keeping the UI Responsive

The most important architectural decision is separating static chrome from dynamic content.

Static chrome — header, dividers, section labels — is drawn once at startup:

1
2
3
fn draw_static_ui(display: &mut ...) {
    // header, dividers, labels — never changes
}

Dynamic values — IP, uptime, counts, status — are redrawn on every update. Each row is erased first with a black rectangle, then the new text is drawn on top:

1
2
3
fn draw_dynamic_ui(display: &mut ..., state: &DashState) {
    // clear row → draw label → draw value
}

This keeps SPI traffic minimal (only changed pixels are sent) and prevents flicker from full-screen redraws.


Common Pitfalls

Here are the mistakes most people hit when first working with this stack:

  1. Write trait conflictcore::fmt::Write (needed for write!()) and embedded_io::Write (needed for socket operations) have the same name. Import them with different aliases:
    1
    2
    
    use core::fmt::Write;              // for write!() into heapless::String
    use embedded_io::{Read, Write as IoWrite};  // for socket.write()
    
  2. Ghost text — when a shorter string replaces a longer one (e.g. "200" replacing "404") the leftover pixels remain. Always clear the row with a filled black rectangle before redrawing.

  3. Wrong colour order — if reds look blue, add .color_order(ColorOrder::Bgr) to the Builder chain.

  4. SPI speed too high — if the display shows corrupted or missing pixels, reduce from 40 MHz to 16 MHz. Some clone modules use lower-speed controllers.

  5. delay moved into ExclusiveDeviceExclusiveDevice::new(spi, cs, delay) consumes delay. Create a new Delay::new() wherever you need one after that point — Delay is zero-sized so multiple instances are fine.

  6. Blocking reads with while let Ok(len)embedded_io::Read::read on a blocking socket blocks until data arrives. Use match and break on Ok(0) (EOF) explicitly, and call socket.work() inside the loop to pump the network stack:
    1
    2
    3
    4
    5
    6
    7
    8
    
    loop {
        socket.work();
        match socket.read(&mut buf) {
            Ok(0)    => break,           // server closed connection
            Ok(len)  => { /* process */ }
            Err(_)   => break,           // connection error
        }
    }
    

Display Layout Reference

The full 128×128 dashboard is laid out as follows. All y values are pixel offsets from the top edge; text positions are baselines.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌────────────────────────┐  y=0
│  ESP32  DASHBOARD   ●  │  y=0–15   header (navy bg, cyan text, wifi dot)
├════════════════════════┤  y=16     1px cyan accent rule
│ IP          10.0.0.42  │  y=18–30  cyan label / white value
│ UP          00:04:32   │  y=32–43  lime label / white value
├────────────────────────┤  y=44     dim section divider
│ REQUESTS               │  y=46–56  dim section label
│ OK                  42 │  y=58–69  lime / white
│ ERR                  3 │  y=70–81  red  / white
├────────────────────────┤  y=82     dim section divider
│ LAST RESPONSE          │  y=84–94  dim section label
│                        │
│          200           │  y=96–119 FONT_10X20, color-coded by status class
│       1842 bytes       │  y=120–127 dim centered
└────────────────────────┘  y=128

Further Reading


Footnotes

This post is licensed under CC BY 4.0 by the author.