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.
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
Pointyou pass toTextis the baseline, not the top-left corner. ForFONT_6X10the 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::Writewithout an alias if you wantwrite!()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:
Writetrait conflict —core::fmt::Write(needed forwrite!()) andembedded_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()
-
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. -
Wrong colour order — if reds look blue, add
.color_order(ColorOrder::Bgr)to theBuilderchain. -
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.
-
delaymoved intoExclusiveDevice—ExclusiveDevice::new(spi, cs, delay)consumesdelay. Create a newDelay::new()wherever you need one after that point —Delayis zero-sized so multiple instances are fine. - Blocking reads with
while let Ok(len)—embedded_io::Read::readon a blocking socket blocks until data arrives. Usematchand break onOk(0)(EOF) explicitly, and callsocket.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
embedded-graphicsdocs — full API reference including all primitives and text stylesmipidsidocs — list of supported display controllers and builder optionsesp-haldocs — SPI, GPIO, timers, and all other ESP32 peripherals- The Embedded Rust Book — foundational
guide to
no_stdRust on microcontrollers