technique

Driving the CYD display

Putting pixels on the Cheap Yellow Display's ILI9341 screen: SPI commands, initialization, orientation, colors, the backlight, and touch.

Before this

This page assumes you are comfortable with:

Why you need this

The CYD (ESP32-2432S028, the Cheap Yellow Display) is an ESP32 with a 2.8 inch, 320 by 240 color screen bolted on, which makes it the cheapest way to give a gadget a face. The screen does nothing until your program wakes it, tells it which way is up, and streams it pixels over SPI. This page is stage 4 of the pipeline on the hub, "Talk to hardware", and it is the deep end of I2C and SPI peripherals.

The idea

The screen is driven by an ILI9341, a display controller chip with its own memory. That memory, the frame memory, holds one color value per pixel. The controller reads it over and over to refresh the glass. Your program never touches the glass; it writes into the controller's frame memory over SPI, and the picture changes.

The controller is command driven. Every transfer is either a command (a one-byte instruction number) or data (the bytes that go with it). One extra wire, the data/command pin (DC), tells the controller which one it is getting: DC low means "this byte is a command", DC high means "these bytes are data".

On the CYD the author's pinout and knowledge base give the wiring:

Signal GPIO Notes
SCK 14 SPI clock
MOSI 13 ESP32 to display
MISO 12 display to ESP32
CS 15 chip select
DC 2 data/command select
Backlight 21 active high: 1 turns the light on

The commands this page uses, with their numbers taken from the author's driver and checked against the ILI9341 datasheet:

Command Number Data that follows
Software reset 0x01 none
Sleep out 0x11 none
Pixel format (COLMOD) 0x3A 0x55 = 16 bits per pixel
Memory access control (MADCTL) 0x36 one byte of orientation flags
Display on 0x29 none
Column address set 0x2A start x, end x, two bytes each
Page (row) address set 0x2B start y, end y, two bytes each
Memory write 0x2C pixel colors, two bytes each

Drawing anything is three steps: set a window (a rectangle) with 0x2A and 0x2B, send 0x2C, then stream exactly width times height colors. The controller fills the window left to right, top to bottom.

Colors: RGB565

Each pixel is 16 bits in RGB565: 5 bits of red, 6 of green, 5 of blue, packed as RRRRRGGG GGGBBBBB. Green gets the extra bit because eyes are most sensitive to it. To convert a normal 24-bit color (8 bits per channel), keep the top bits of each channel:

RGB565=(r≫3)≪11  ∣  (g≫2)≪5  ∣  (b≫3)\text{RGB565} = (r \gg 3) \ll 11 \;|\; (g \gg 2) \ll 5 \;|\; (b \gg 3)

Here ≫\gg shifts right (dropping low bits), ≪\ll shifts left, and ∣| is bitwise OR. The controller wants the high byte first.

Orientation: MADCTL

The panel's natural shape is 240 wide by 320 tall. MADCTL is one byte of flags that tells the controller how to map your x and y onto it. The datasheet names the top six bits:

Bit Value Name Effect
7 0x80 MY flip row order
6 0x40 MX flip column order
5 0x20 MV swap rows and columns
4 0x10 ML vertical refresh order
3 0x08 BGR panel's color filter is blue-green-red
2 0x04 MH horizontal refresh order

The author's knowledge base says to use 0x28 on the CYD: 0b00101000, which is MV (landscape, 320 by 240) plus BGR (so red is red). The ESP_32_SAI dashboard uses 0x68 instead, 0b01101000, which adds MX to flip the column order; its comment says this setting suits the board with its USB-C port on the right. Whichever value you start from, draw a word of text and check that it reads correctly and that red is red before building anything else.

Worked example

The driver

This initialization is excerpted from TeachingMachineCYD's ili9341.py (author's project, hardware-proven; ESP_32_DAD and ESP_32_Keyboard carry close copies with the same init sequence).

def _cmd(self, cmd):
    self.dc.value(0)          # DC low: command
    self.cs.value(0)
    self.spi.write(bytes([cmd]))
    self.cs.value(1)

def _init(self):
    ...
    self._cmd(_SWRESET); time.sleep_ms(150)
    self._cmd(_SLPOUT);  time.sleep_ms(150)
    self._cmd(_COLMOD);  self._dat(0x55)   # 16-bit colour
    self._cmd(_MADCTL);  self._dat(0x28)   # landscape (MV+BGR)
    self._cmd(_DISPON);  time.sleep_ms(100)

def _window(self, x0, y0, x1, y1):
    self._cmd(_CASET)
    self._dat(bytes([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
    self._cmd(_PASET)
    self._dat(bytes([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
    self._cmd(_RAMWR)

(_dat is the same as _cmd with DC high.) The same project's main.py sets up the bus at 40 MHz, SPI mode 0:

backlight = Pin(21, Pin.OUT, value=1)
spi = SPI(1, baudrate=40_000_000, polarity=0, phase=0,
          sck=Pin(14), mosi=Pin(13), miso=Pin(12))
lcd = ILI9341(spi, cs=15, dc=2)

Converting a color

Take orange, #ffa500: red 255, green 165, blue 0.

Channel 8-bit value Shift Field value Binary field
Red 255 255≫3255 \gg 3 31 11111
Green 165 165≫2165 \gg 2 41 101001
Blue 0 0≫30 \gg 3 0 00000

Packed: 11111 101001 00000 = 0b1111110100100000 = 0xFD20. That is exactly the ORANGE constant in ESP_32_SAI's driver. On the wire the bytes go 0xFD then 0x20.

Filling the whole screen

The window is 320 by 240 pixels at 2 bytes each:

320×240×2=153,600 bytes=1,228,800 bits320 \times 240 \times 2 = 153{,}600 \text{ bytes} = 1{,}228{,}800 \text{ bits}

At the author's SPI clock of 40 MHz, one bit per clock tick:

1,228,800/40,000,000=0.0307 s≈30.7 ms1{,}228{,}800 / 40{,}000{,}000 = 0.0307 \text{ s} \approx 30.7 \text{ ms}

So the bus alone caps full-screen redraws at about 1000/30.7≈321000 / 30.7 \approx 32 per second, before MicroPython spends any time building the bytes. The TeachingMachineCYD fill() sends a 128-byte chunk (64 pixels) 320×240/64=1200320 \times 240 / 64 = 1200 times, which is 153,600 bytes, matching the calculation.

A side note on that 40 MHz: the ILI9341 datasheet lists a minimum serial write clock cycle of 100 ns, which is 10 MHz. The author's CYD boards run the display at four times that and it works, but it is outside the datasheet. At 10 MHz the same fill takes about 123 ms.

In an ESP32 project

Every CYD project in the author's set uses this driver: the ESP_32_SAI sensor dashboard plots temperature and humidity graphs, ESP_32_Keyboard shows each key pressed on a USB keyboard elsewhere, and TeachingMachineCYD runs a snake game and a math drill. The patterns they share:

  • Redraw only what changed. The keyboard display's comment says it redraws "only the changing text regions (no full-screen clear)". It pads each string to a fixed width so new text overwrites old text, rather than clearing the screen.
  • Text through framebuf. MicroPython's framebuf module has a built-in 8 by 8 pixel font. The driver draws a string into a small framebuffer in RAM, scales it up, then sends it as one window.
  • Backlight held in a variable. The author's knowledge base warns that a throwaway Pin(21, Pin.OUT).value(1) can be garbage collected, and the backlight then turns off at random. Notice the driver's own Pin(bl, Pin.OUT).value(1) line does exactly that, which is why the project's main.py makes and keeps its own backlight object instead of passing bl in.
  • Touch is a separate bus. The XPT2046 resistive touch controller is not on the display's SPI bus on this board, despite what many guides say. The author's notes give its own pins: SCK GPIO 25, MOSI GPIO 32, MISO GPIO 39, CS GPIO 33, and a pen-down interrupt on GPIO 36 that reads low while the screen is pressed. "Tap anywhere" needs only that interrupt pin, with no SPI at all.

Common mistakes

  • Screen stays black. The backlight on GPIO 21 was never set high, or its Pin object was collected. The display may be working perfectly in the dark.
  • Colors swapped (red looks blue). Either BGR is missing from MADCTL, or the bytes are in the wrong order. The author's touch notes record that framebuf.RGB565 stores pixels low byte first while the ILI9341 wants high byte first, so text drawn through framebuf comes out with swapped colors unless the driver swaps the bytes.
  • Picture mirrored or sideways. Wrong MADCTL. Try 0x28 first on a CYD.
  • Garbled text near the right edge. A string whose width runs past 320 asks for a window off the screen. The author's notes say the bytes then land in the wrong places; clip text to the screen.
  • Touch reads 0xFF forever while the pen-down pin works. You are reading touch on the display bus. Use the touch bus pins above.
  • Very slow text. Drawing one pixel at a time sets a fresh window for each: 11 bytes of commands and coordinates plus 2 bytes of color, 13 bytes per pixel, versus 2 bytes when streamed in one window (6.5 times the traffic, before counting the extra Python calls). Draw whole rows or whole glyphs as one window.

Cost

A full 320 by 240 frame in RGB565 is 153,600 bytes, which is 150 KB (1 KB = 1024 bytes). The author's drivers never hold a whole frame in RAM: the controller's own frame memory is the frame buffer, and the ESP32 streams small chunks into it (the fill() above reuses one 128-byte chunk 1200 times). Time is the other cost: about 31 ms of bus time per full screen at 40 MHz, so a few full redraws per second is fine and animation needs small regions. Per-pixel drawing in MicroPython is far slower than the bus, because each pixel costs several Python calls. The driver itself is about 100 lines and has no flash cost worth counting. The maker's time goes into orientation and color order, which take minutes once you know MADCTL and hours if you guess.

Going further

  • I2C and SPI peripherals, for the bus underneath.
  • The ILI9341 datasheet's command list, especially MADCTL, COLMOD, and the column and page address commands.
  • The XPT2046 touch controller datasheet, and calibrating raw touch readings to screen pixels.
  • MicroPython on the ESP32, for garbage collection and why held references matter.
  • Debugging resets and crashes, for the CYD's power limits when a screen and radio run together.

Back to ESP32 development: assembly, C, MicroPython, and CircuitPython