Display and touch screen modules (ST7789 and XPT2046)
This short write up is regarding a display module I purchased off AliExpress. In the world of hobbyists, the ST7789 display controllers are very common. I purchased one recently, and was trying out various designs with it. I completed one of the projects and decided to design an enclosure as well. Some of you might be able to reuse the artifacts this project generated.
A firmware to test the display.
One of the main challenge I faced after receiving the display module is confirming its operation. I could not easily find a reliable firmware that is tailor made for this purpose. Therefore, after I completed a project I decided to come up with one.
This firmware is written for a Raspberry Pi PICO W module, and can be used as a reference for any other platform. This simply displays the cordinates of the touch point in the screen.
Hardware setup
The module I purchased has a display, touch screen, and an SD card connector. I didn’t use the SD CARD. The display module has the following connections.
- Four SPI lines. (MOSI, MISO, CSn, CLK)
- Data or Command selection pin.
- LCD backlight control pin.
- LCD resest control pin.
The touch screen has the following connections.
- Four SPI lines. (MOSI, MISO, CSn, CLK)
- Touch interrup line.
The module also has two pins for power and ground.
Connecting these 14 pins into a controller board is not that complex. Easiest is to find a controller that has 2 SPI modules, and another 4 GPIO pins. Since I used the Raspberry Pi PICO W board, I connected the display module with the controller as follows.
| Display Module Pin | Raspberry Pi PICO W pin |
|---|---|
| Display MISO | 16 |
| Display CSn | 17 |
| Display CLK | 18 |
| Display MOSI | 19 |
| Display backlight | 2 |
| Display Data/Cmd | 3 |
| Display reset | 4 |
| Touch interrupt | 11 |
| Touch MISO | 12 |
| Touch CSn | 13 |
| Touch CLK | 14 |
| Touch MOSI | 15 |
The power pin must be connected to 3.3 V.
Firmware usage
The github repository
contains the project along with the binaries that can be used to test the display module.
You can simply build and flash this project to test the module. The build instructions
are in the README.md file inside the project folder.
Step-by-step development guide
Please note that while the following steps are verified, I did not attempt to explain all the steps in detail.
First let’s setup the project and configure the pins:
- Install the
Raspberry Pi Picoextension in VSCode.
You can setup all the tools manually, but this is the fastest way to get everything setup.
Create a
New C/C++ Projectusing the extension. Select thePico Wboard and useConsole over UART. I like to have console access as it makes debugging much simpler.Please refer the
Getting started with Raspberry Pi Pico-series - Appendix Awhich shows how to setup another Pico board as the debug board.Once the connections are made, you should be able to compile and flash the project using VSCode. (Run Task -> Compile Project, Run Task -> Flash)
Use
puttyto verify the console over UART. The default project usually prints Hello, World in a loop.Configure all the neccessary pins.
#include "hardware/spi.h"
static const bool LCD_CMD = false;
static const bool LCD_DATA = true;
const uint GPIO_LCD_BACKLIGHT_PIN = 2;
const uint GPIO_LCD_DCX = 3;
const uint GPIO_LCD_RESETn = 4;
const uint GPIO_SPI0_RX = 16;
const uint GPIO_SPI0_CSn = 17;
const uint GPIO_SPI0_SCK = 18;
const uint GPIO_SPI0_TX = 19;
static void initialise_lcd_hw()
{
// Backlight
gpio_init(GPIO_LCD_BACKLIGHT_PIN);
gpio_set_dir(GPIO_LCD_BACKLIGHT_PIN, GPIO_OUT);
// LCD reset pin
gpio_init(GPIO_LCD_RESETn);
gpio_set_dir(GPIO_LCD_RESETn, GPIO_OUT);
// DCX pin
gpio_init(GPIO_LCD_DCX);
gpio_set_dir(GPIO_LCD_DCX, GPIO_OUT);
// SPI initialisation
gpio_init(GPIO_SPI0_CSn); // Software controlled
gpio_set_dir(GPIO_SPI0_CSn, GPIO_OUT);
gpio_init(GPIO_SPI0_RX);
gpio_init(GPIO_SPI0_SCK);
gpio_init(GPIO_SPI0_TX);
gpio_set_function(GPIO_SPI0_RX, GPIO_FUNC_SPI);
gpio_set_function(GPIO_SPI0_SCK, GPIO_FUNC_SPI);
gpio_set_function(GPIO_SPI0_TX, GPIO_FUNC_SPI);
const uint baud = spi_init(spi0, 50000000); // Maximum supported is 62.5 MHz
printf("SPI initialised with: %d baudrate\n\r", baud);
// Switch on backlight
gpio_put(GPIO_LCD_BACKLIGHT_PIN, 1);
// Reset the LCD
gpio_put(GPIO_LCD_RESETn, false);
sleep_ms(25); // This needs to be more than 10 us
gpio_put(GPIO_LCD_RESETn, true);
sleep_ms(125); // The worst case time is 120 ms
gpio_put(GPIO_LCD_DCX, LCD_DATA);
gpio_put(GPIO_SPI0_CSn, true);
}
Make sure to include the neccessary cmake libraries in the CMakeLists.txt file.
target_link_libraries(Application PUBLIC hardware_spi)
- If you flash a firmware that has this code. It should switch on the backlight of the display.
Next we’ll setup the graphics library for the example. This is what we’ll be using to render the
UI widgets. I will be using LVGL as the graphics library.
- Add
LVGLas a submodule
git submodule add https://github.com/lvgl/lvgl
- Copy the
lv_conf_template.haslv_conf.hinto the project root folder, and do the following changes. The project root folder is in the include path. This is why I copied the header to the root. It can be any other place, but we have to setup some build variables to tell the compiler where the file is.
- Enable the contents by removing the
#if 0directive. - Disable all colour formats except the RGB565.
#define LV_DRAW_SW_SUPPORT_RGB565 1
#define LV_DRAW_SW_SUPPORT_RGB565A8 0
#define LV_DRAW_SW_SUPPORT_RGB888 0
#define LV_DRAW_SW_SUPPORT_XRGB8888 0
#define LV_DRAW_SW_SUPPORT_ARGB8888 0
#define LV_DRAW_SW_SUPPORT_ARGB8888_PREMULTIPLIED 0
#define LV_DRAW_SW_SUPPORT_L8 0
#define LV_DRAW_SW_SUPPORT_AL88 0
#define LV_DRAW_SW_SUPPORT_A8 0
#define LV_DRAW_SW_SUPPORT_I1 0
- Select the LCD device.
#define LV_USE_ST7789 1
- Add the LVGL as a cmake sub directory and setup the library build configurations in the
CMakeLists.txt
set(LV_CONF_INCLUDE_SIMPLE ON)
set(LV_CONF_BUILD_DISABLE_EXAMPLES ON)
set(LV_CONF_BUILD_DISABLE_DEMOS ON)
add_subdirectory(lvgl)
target_link_libraries(Application PUBLIC hardware_spi lvgl)
- Make sure you can build the project.
The LVGL library needs the following capabilities from the host. Please refer the documentation for the most upto-date information.
- A function that is a millisecond-based tick source.
- A function that can be used to send commands to the LCD.
- A function that can be used to send data to the LCD.
- Create the millisecond based timer using the systick.
The PICO default clock rate is 125 MHz. We can use the systick interrupt to generate a one millisecond timer as follows.
#include "hardware/structs/systick.h"
static uint32_t tick_count = 0;
// Call this function to setup the isr
void setup_isr()
{
systick_hw_t* systick = systick_hw;
systick->cvr = 0x00;
// ENABLE systick timer
// ENABLE systick interrupt
// Use core clock for the timer
// Please refer the Arm reference manual for more information
systick->csr = 0x07;
systick->rvr = 124999;
}
// Call this function to get the tick count
uint32_t get_tick_count()
{
return tick_count;
}
extern void isr_systick()
{
tick_count++;
}
- Creating a function to send a command to the LCD display over SPI. These commands setup LCD display parameters. There are many ways to improve this blocking function, but I’m going to just explain the simplest form.
#include "lvgl.h"
static void send_lcd_cmd(lv_display_t *disp, const uint8_t *cmd, size_t cmd_size, const uint8_t *param, size_t param_size)
{
// Verify parameters
if (!disp || !cmd) {
return;
}
// Select LCD command as the next command. This is a GPIO set.
gpio_put(GPIO_LCD_DCX, LCD_CMD);
// Select the chip
gpio_put(GPIO_SPI0_CSn, false);
// Set the SPI data format, this is needed because display data send format is different.
spi_set_format(spi0, 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST);
// Write the command
spi_write_blocking(spi0, cmd, cmd_size);
// Write any params
if (param) {
// Params are DATA, therefore, select DATA.
gpio_put(GPIO_LCD_DCX, LCD_DATA);
spi_write_blocking(spi0, param, param_size);
}
// Deselect the chip
gpio_put(GPIO_SPI0_CSn, true);
}
- Create a function to send data to the LCD over SPI. This function is used by LVGL to transmit display data to the LCD.
static void send_lcd_data(lv_display_t *disp, const uint8_t *cmd, size_t cmd_size, uint8_t *param, size_t param_size)
{
if (!disp || !cmd) {
return;
}
// Send the LCD command first
gpio_put(GPIO_LCD_DCX, LCD_CMD);
gpio_put(GPIO_SPI0_CSn, false);
spi_set_format(spi0, 8, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST);
spi_write_blocking(spi0, cmd, cmd_size);
// Send LCD data if available
if (param) {
// The data write is alwways 16 bits.
// The data transfer is MSB first. Refer 8.8.42 in ST7789 datasheet
uint16_t* data = (uint16_t*)param;
size_t data_size = param_size / 2;
spi_set_format(spi0, 16, SPI_CPOL_0, SPI_CPHA_0, SPI_MSB_FIRST);
gpio_put(GPIO_LCD_DCX, LCD_DATA);
spi_write16_blocking(spi0, data, data_size);
}
gpio_put(GPIO_SPI0_CSn, true);
// Tell LVGL that the transfer is complete and we are ready for the next set of data.
lv_display_flush_ready(disp);
}
- Now that we have all the required infrastructure functions, next lest initialise LVGL.
static const uint32_t LCD_H_RES = 240;
static const uint32_t LCD_V_RES = 320;
static lv_display_t *lcd_disp = NULL;
static void initialise_lvgl_framework()
{
// Initialise the library
lv_init();
// Set the tick count callbcak
lv_tick_set_cb(get_tick_count);
// Setup the display
lcd_disp = lv_st7789_create(LCD_H_RES, LCD_V_RES, LV_LCD_FLAG_NONE, send_lcd_cmd, send_lcd_data);
// Colour setting is governed by LV_COLOR_DEPTH. It's set to 16 which leads to RGB565 color format
// being native.
lv_disp_set_rotation(lcd_disp, LV_DISP_ROTATION_0);
lv_color_t *buf1 = NULL;
lv_color_t *buf2 = NULL;
// For partial rendering the buffer is set to 1/10th of display size
// Please read LVGL docs for more information related to the RENDER_MODE_PARTIAL
const uint32_t buf_size = LCD_H_RES * LCD_V_RES * lv_color_format_get_size(lv_display_get_color_format(lcd_disp)) / 10;
buf1 = lv_malloc(buf_size);
if (buf1 == NULL) {
printf("Buffer1 allocation failed!\n\r");
return;
}
buf2 = lv_malloc(buf_size);
if (buf2 == NULL) {
printf("Buffer2 allocation failed!\n\r");
lv_free(buf1);
return;
}
lv_display_set_buffers(lcd_disp, buf1, buf2, buf_size, LV_DISPLAY_RENDER_MODE_PARTIAL);
}
- Now that we have both the hardware and the framework configured, let’s add some code to paint the background in some colour. I choose gray as the colour.
static void ui_init(lv_display_t *disp)
{
/* set screen background to bright yellow */
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, lv_color_hex(0xFFFB00), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_100, 0);
}
Call this function after the initialise_lvgl_framework().
- Then, we must call the
lv_timer_handler()function in the main loop.
int main()
{
stdio_init_all();
setup_isr();
initialise_lcd_hw();
initialise_lvgl_framework();
ui_init(lcd_disp);
printf("Hello, world!\n");
while (true) {
lv_timer_handler();
}
}
Flash the firmware now and you should see the LCD colour change to yellow.
Next, we’ll focus on getting the touch screen working. The idea is to read the touch cordinates and print them to the terminal.
- Let’s configure the SPI lines and the touch interrupt line for the touch screen. It’s important to understand that the touch screen and the LCD are independent hardware systems. They never directly communicate with each other. Instead, the firmware acts as the intermediary that combines these systems together. We will not focus on how to connect these two systems.
#define TOUCH_SCREEN_IRQ (11)
#define GPIO_SPI1_CSn (13)
#define GPIO_SPI1_RX (12)
#define GPIO_SPI1_SCK (14)
#define GPIO_SPI1_TX (15)
static bool touch_detected = false;
void touch_irq()
{
gpio_acknowledge_irq(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL);
touch_detected = true;
}
void init_touch_screen()
{
// Configure the GPIO that has the interrupt
gpio_init(TOUCH_SCREEN_IRQ);
gpio_set_dir(TOUCH_SCREEN_IRQ, false);
// Enable the interrupt and set the callback
irq_set_enabled(IO_IRQ_BANK0, true);
gpio_add_raw_irq_handler(TOUCH_SCREEN_IRQ, touch_irq);
// Configure GPIOs used by SPI
gpio_init(GPIO_SPI1_CSn); // Software controlled
gpio_init(GPIO_SPI1_RX);
gpio_init(GPIO_SPI1_SCK);
gpio_init(GPIO_SPI1_TX);
// Configure SPI
gpio_set_dir(GPIO_SPI1_CSn, GPIO_OUT);
gpio_set_function(GPIO_SPI1_RX, GPIO_FUNC_SPI);
gpio_set_function(GPIO_SPI1_SCK, GPIO_FUNC_SPI);
gpio_set_function(GPIO_SPI1_TX, GPIO_FUNC_SPI);
const uint baud = spi_init(spi1, 100000);
printf("SPI initialised with: %d baudrate\n\r", baud);
// Set interrupt edge trigger
gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, true);
// De-select the touch controller chip
gpio_put(GPIO_SPI1_CSn, true);
}
Once this is done I encourage you to test this on target by placing a breakpoint in touch_irq()
function and touching the screen. You should also be able to probe the interrupt pin if you have
an oscilloscope.
- Let’s read the touch cordinates.
The way the touch screens (at least the one I use) work is quite fascinating. For this example, I use a touch screen with the controller XPT2046. I used a well written datasheet of a comparable touch screen controller ADS7846 to understand how everything works. I wouldn’t go through the theory of operation here as it needs a separate post altogether.
Touch controller is connected to the MCU through SPI. We have to first write a control byte into the controller, and then read 16-bits out from the controller. The first 12-bits (MSb) contain the reading. This needs to be done for both x and y axis to read a complete touch cordinate.
The main concern in reading the touch cordinates is not related to the communication aspect, instead it is to do with how we manage the interrupts. The interrupt line goes LOW on touch, and stays low for the duration of touch. It returns to HIGH on release.
However, if when we start reading the cordinates, the conversion interacts with the touch interrupt and makes the interrupt generation much convoluted. Depending on the power down modes of the touch controller, I see totally different touch interrupt behaviour. The recommendation is to mask the touch interrupt until we finish the reading. For the first cut, lets disable the interrupts for 1 second.
Therefore, first let’s change the touch_irq function as follows,
void touch_irq()
{
// We don't enable interrupts until we complete the reading.
gpio_acknowledge_irq(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL);
gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, false);
touch_detected = true;
}
The following code reads the touch cordinate. The main() function is modified to call the
read_touch_point() function on touch detection.
typedef struct {
bool valid;
uint16_t x;
uint16_t y;
} touch_point_t;
static uint16_t get_reading(const uint8_t* buffer)
{
// Only the first 12 bits have valid data.
// Data is in the buffer MSB first.
uint16_t reading = (buffer[0] << 8) | buffer[1];
reading = reading >> 4;
return reading;
}
static touch_point_t read_touch_point()
{
// Select chip
gpio_put(GPIO_SPI1_CSn, false);
const uint8_t dummy = 0xff;
// START(1) + CHANNEL(001) + MODE(0) + DFR(0) + POWER_DOWN(00)
// The following reading enables, channel 001.
// This energises Y plane (Yp and Yn). Then connects Xp to ADC.
// Xp voltage corresponds to Y reading.
const uint8_t read_y = 0b10010000;
// START(1) + CHANNEL(101) + MODE(0) + DFR(0) + POWER_DOWN(00)
// The following reading enables, channel 101.
// This energises Y plane (Yp and Yn). Then connects Xp to ADC.
// Yp voltage corresponds to X reading.
const uint8_t read_x = 0b11010000;
uint8_t buffer[2];
spi_write_blocking(spi1, &read_y, sizeof(read_y));
spi_read_blocking(spi1, dummy, buffer, sizeof(buffer));
uint16_t reading_y = get_reading(buffer);
spi_write_blocking(spi1, &read_x, sizeof(read_x));
spi_read_blocking(spi1, dummy, buffer, sizeof(buffer));
uint16_t reading_x = get_reading(buffer);
printf("(X, Y) -> (%d, %d)\n", reading_x, reading_y);
touch_point_t tp = {.x = reading_x, .y = reading_y};
gpio_put(GPIO_SPI1_CSn, true);
return tp;
}
int main()
{
stdio_init_all();
setup_isr();
initialise_lcd_hw();
initialise_lvgl_framework();
ui_init(lcd_disp);
init_touch_screen();
printf("Hello, world!\n");
while (true) {
lv_timer_handler();
if (touch_detected) {
read_touch_point();
// Enable IRQ after 1000 ms
sleep_ms(1000);
gpio_set_irq_enabled(TOUCH_SCREEN_IRQ, GPIO_IRQ_EDGE_FALL, true);
touch_detected = false;
}
}
}
With this code, you should be able to see the touch cordinates printed out through the terminal. This is definitely not perfect as we have a 1 second delay affecting the main loop. However, this is almost enough for a proof of concept.
- Enabling the touch screen controller.
The solution works even without the following code, however it is better to add the following code to improve the stability of the solution.
The init touch screen shall have the following code at the end of the function:
void init_touch_screen()
{
...
gpio_put(GPIO_SPI1_CSn, false);
const uint8_t dummy = 0x00;
const uint8_t idle = 0b10000000;
uint8_t buffer[2];
spi_write_blocking(spi1, &idle, sizeof(idle));
spi_read_blocking(spi1, dummy, buffer, sizeof(buffer));
// De-select the touch controller chip
gpio_put(GPIO_SPI1_CSn, true);
}
This initialises the touch screen controller such that it controls the interrupt line when a touch occurs.
Now you have a firmware that can be used to test the display and the touch screen of any new ST7789 modules you purchase. Just remember to wait one second before touching the screen to ensure smooth operation.