How-To-Tutorials · September 23, 2025

How to Implement 16-Bit RGB Pixel Rendering on TFT Display with STM32 SPI

how-to-implement-16-bit-rgb-pixel-rendering-on-tft-display-with-stm32-spi.png

RGB565 and Why It Matters for TFT Displays

Most small TFT displays (ILI9341, ST7735, ST7789—the usual suspects) operate in 16-bit color mode called RGB565. That’s 5 bits red, 6 bits green, 5 bits blue packed into a single uint16_t. Green gets the extra bit because human eyes are most sensitive to green. It’s a good tradeoff: 65,536 colors in just two bytes per pixel, which keeps memory and bandwidth manageable on a microcontroller.

The challenge is getting those pixels to the display fast enough. Pushing a 128×160 framebuffer over SPI one byte at a time with blocking calls is painfully slow. DMA fixes this—you hand the buffer to the SPI peripheral and it streams out automatically while your code does something else.

Prerequisites

  • STM32 programming experience with HAL and CubeMX
  • C programming skills
  • STM32 development board (F4, F7, or H7 series recommended for speed)
  • SPI-based TFT display (ST7735, ILI9341, or similar)
  • STM32CubeIDE v1.16+

Parts and Tools

  • STM32 dev board (Nucleo or Discovery)
  • TFT display module with SPI interface
  • Jumper wires
  • 3.3V-compatible wiring (most TFT modules are 3.3V; double-check yours)

Steps

  1. Wiring the Display

    Get the physical connections right first. Standard SPI TFT hookup:

    • SCK → SPI clock pin (e.g., PA5 on SPI1)
    • MOSI → SPI MOSI pin (e.g., PA7 on SPI1)
    • CS → Any GPIO pin (chip select, directly controlled by your code)
    • DC/RS → Any GPIO pin (data/command selector—low for commands, high for pixel data)
    • RST → Any GPIO pin (or tie to the board’s reset line)
    • MISO → Usually not needed for display-only operation, but connect it if you want to read the display ID for debugging

    Watch out: some cheap TFT modules label the pins differently. “SDA” usually means MOSI, “SCL” means SCK. Check the datasheet for your specific module.

  2. SPI and DMA Configuration in CubeMX

    Open the .ioc file and configure SPI1 (or whichever SPI bus you’re using):

    • Mode: Transmit Only Master (no need for full-duplex unless you’re reading back from the display)
    • Data Size: 8 bits (we’ll send 16-bit pixels as two bytes)
    • Baud Rate Prescaler: Start with something conservative like /4 or /8 and push faster once things work. Most ILI9341 displays handle up to ~40 MHz SPI clock.
    • CPOL/CPHA: Typically Low/1 Edge (Mode 0) for most TFT controllers. Check your display’s datasheet.

    Under DMA Settings, add SPI1_TX. Set direction to Memory to Peripheral, memory data width to Byte, and enable memory increment. Leave peripheral data width at Byte and peripheral increment disabled.

  3. The RGB565 Conversion Macro

    This packs 8-bit R, G, B values into a 16-bit RGB565 word:

    
    #define RGB565(r, g, b) ((uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | ((b & 0xF8) >> 3)))
    

    The bit masking drops the lower bits of each color channel. You lose some precision, but that’s the tradeoff at 16 bits. For convenience, define your common colors up front:

    
    #define COLOR_RED    RGB565(255, 0, 0)
    #define COLOR_GREEN  RGB565(0, 255, 0)
    #define COLOR_BLUE   RGB565(0, 0, 255)
    #define COLOR_WHITE  RGB565(255, 255, 255)
    #define COLOR_BLACK  RGB565(0, 0, 0)
    
  4. Display Command and Data Helpers

    You need low-level functions to send commands vs. pixel data. The DC pin tells the display which is which:

    
    static void LCD_SendCommand(uint8_t cmd)
    {
        HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_RESET); // DC low = command
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);  // CS low
        HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY);
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);    // CS high
    }
    
    static void LCD_SendData(uint8_t *data, size_t len)
    {
        HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET);    // DC high = data
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
        HAL_SPI_Transmit(&hspi1, data, len, HAL_MAX_DELAY);
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);
    }
    
  5. DMA-Accelerated Framebuffer Push

    Define your framebuffer and a function to blast it to the display over DMA:

    
    #define WIDTH  128
    #define HEIGHT 160
    uint16_t framebuffer[WIDTH * HEIGHT];
    
    volatile uint8_t dmaTransferComplete = 1;
    
    void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
    {
        if (hspi->Instance == SPI1)
        {
            HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);
            dmaTransferComplete = 1;
        }
    }
    
    void LCD_UpdateDisplay(void)
    {
        while (!dmaTransferComplete); // Wait for any previous transfer
        dmaTransferComplete = 0;
    
        // Set the address window to full screen (commands vary by display controller)
        LCD_SetAddressWindow(0, 0, WIDTH - 1, HEIGHT - 1);
    
        HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET);
        HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET);
        HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)framebuffer, WIDTH * HEIGHT * 2);
    }
    

    That * 2 is because each pixel is 2 bytes but we’re sending in byte mode. I prefer sending in byte mode with an 8-bit SPI config because it avoids endianness headaches—but you need to make sure your RGB565 values are stored in big-endian byte order (MSB first), which is what most TFT controllers expect. If your colors look wrong, try swapping the bytes of each pixel.

  6. Render Something

    Fill the framebuffer with a gradient to verify everything works:

    
    for (int y = 0; y < HEIGHT; y++)
    {
        for (int x = 0; x < WIDTH; x++)
        {
            uint8_t r = (x * 255) / WIDTH;
            uint8_t g = (y * 255) / HEIGHT;
            uint8_t b = 128;
            framebuffer[y * WIDTH + x] = RGB565(r, g, b);
        }
    }
    LCD_UpdateDisplay();
    

    You should see a smooth red-green gradient. If you see vertical or horizontal banding, your address window setup is probably off by one, or the display dimensions don’t match your WIDTH/HEIGHT constants.

Troubleshooting

  • Blank display, nothing at all

    Check power first (some modules need a backlight pin driven high). Then verify your reset sequence—most TFT controllers need a specific init sequence sent after power-on. Look up the initialization code for your exact controller IC (ILI9341, ST7735, etc.).

  • Garbled or shifted image

    SPI clock polarity/phase mismatch. Try all four CPOL/CPHA combinations. Also check that your SPI clock speed isn’t exceeding the display’s maximum—drop it down and test.

  • DMA transfer hangs or never completes

    Make sure the DMA interrupt is enabled in CubeMX (under NVIC settings). Without the interrupt, the HAL callback never fires and your code blocks forever on the next while (!dmaTransferComplete).

  • Wrong colors

    Byte order is the most common cause. RGB565 should be sent MSB first. If red looks blue or vice versa, swap the bytes in your framebuffer entries: pixel = (pixel << 8) | (pixel >> 8);

Going Further

Once basic rendering works, the door is open for double buffering (render to one buffer while DMA sends the other), partial screen updates to boost frame rates, and sprite/tile engines for simple games. The DMA approach scales well—on an STM32F4 at 84 MHz with SPI running at 42 MHz, you can push a full 320×240 ILI9341 screen at roughly 30+ FPS. Not bad for a microcontroller.