Skip to content

viktor-nikolov/ILI9488-Xilinx

Repository files navigation

License

ILI9488 TFT SPI display library for Xilinx FPGA and SoC

This is the port of Jaret Burkett's ILI9488 Arduino library to AMD Xilinx SoC and FPGA.

I removed all Arduino-specific code, optimized SPI writes for the capabilities of Xilinx SPI libraries, and made other modifications.
The integral part was porting of the Adafruit_GFX library because the ILI9488 library is based on it.

I tested the library on AMD Xilinx Zynq-7000 SoC and Artix-7 FPGA (running on MicroBlaze soft CPU) with the following display: 3.5″ SPI Module ILI9488 SKU:MSP3520 480x320 pixels (which can be purchased on Amazon or on AliExpress; I'm not affiliated in any way).

This repository contains sample projects that run on Digilent boards Zybo Z7, Arty A7 and Cmod A7.
I also provided a detailed step-by-step tutorial on how to use the display on the Zynq board Cora Z7.

Note

The ILI9488 3.5″ TFT LCD module is a touch screen. However, the library presented here contains only graphics display capabilities. It does not read any user touch inputs from the display.

How to use the library

HW connection and setup

I'm describing here the connection of the 3.5″ TFT SPI Module ILI9488, which seems to be the most easily available display with the ILI9488 controller IC.

Interfaces

ILI9488 display controller IC has several interfaces. The 3.5″ TFT SPI Module module in question uses "DBI Type C Option 3", which is, in fact, a 4-line SPI.
The ILI9488 datasheet specifies that the shortest possible SPI clock cycle for write operations is 50 ns, i.e., 20 MHz (see page 332 in the datasheet). Nevertheless, my specimen of the display was able to run with the SPI clock increased to 20.83 MHz.

In addition to the SPI, the display needs to be connected to two GPIO pins (reset and Data/Command selection signals).

The library supports both Zynq Processing System SPI and AXI Quad SPI IP (see this short introduction to the two types of SPI).
The library also supports both Zynq Processing System EMIO GPIO and AXI GPIO IP.

Different kinds of SPI and GPIO can be combined (e.g., you can use AXI SPI with PS GPIO and vice versa). All four combinations are supported by the library.

PS SPI setup

The ILI9488 datasheet specifies that the minimum SPI clock cycle for write operations is 50 ns, i.e., 20 MHz (see page 332 in the datasheet).

The Zynq-7000 Processing System default SPI clock is 166.67 MHz. You can scale this frequency down by a power of two factors by calling XSpiPs_SetClkPrescaler.

For getting a setting closest to the ILI9488 rated 20 MHz, we can call XSpiPs_SetClkPrescaler(&SpiInstance,XSPIPS_CLK_PRESCALE_8), which gives us an SPI clock of 20.83 MHz (== 166,67 / 8).
20.83 MHz is higher than the 20 MHz from the datasheet. Nevertheless, my specimen of the display worked well at this frequency.

If you want to be on the safe side, you can set the SPI frequency to 150 MHz in the Zynq-7000 configuration in Vivado. Then, with the factor XSPIPS_CLK_PRESCALE_8, you get the SPI frequency of 18,75 MHz.

AXI SPI setup

The library expects that the AXI Quad SPI is configured as a Master, in the standard mode and with a Transaction Width of 8 bits.

For best performance, I highly recommend configuring the AXI SPI IP with a FIFO of 256 bytes. (The library will work with a FIFO of 16 bytes and without a FIFO, but the performance will be reduced.)

To achieve the 20 MHz SPI clock for the ILI9488, I recommend to drive AXI SPI input signal ext_spi_clk with 40 MHz and set the Frequency Ratio in the IP configuration to 2.

I tested the library with AXI Quad SPI configured in this way:

ILI9488 TFT display pins

Physical connection

For using the 3.5″ TFT SPI Module ILI9488 with the library, we need to connect the pins highlighted in the photo below.

  • Please note that we do not need to connect the "SDO (MISO)" pin of the display to SPI because we are not reading any data from the display.

Logic IO pins accept a 3.3 V voltage level (TTL).

ILI9488 TFT display pins

Pin Meaning Where to connect to
LED Display backlight control; level 3.3 V gives maximum brightness 3.3 V power source
SCK SPI bus clock signal,
rated at 20 MHz
(my specimen of the display worked at 20.83 MHz)
PS SPI: SPIx_SCLK_O signal (e.g., SPI0_SCLK_O)

AXI SPI: sck_o signal
SDI (MOSI) SPI bus write data signal (input into the display) PS SPI: SPIx_MOSI_O signal (e.g., SPI0_MOSI_O)

AXI SPI: iox_o signal (e.g. io0_o)
DC/RS Data/Command selection signal
high: command, low: data
A GPIO signal
RESET Display reset signal, low for reset A GPIO signal
CS SPI chip select signal, low level for enable PS SPI: SPIx_SS_O signal (e.g., SPI0_SS_O)

AXI SPI: ss_o[x:x] signal (e.g. ss_o[0:0])
GND Ground Ground
VCC 5 V or 3.3V power input 5 V or 3.3V power source

SW configuration and usage

Using the library in Vitis

To use the library, copy the whole content of the ILI9488-Xilinx_library folder to the src folder of your application project in Vitis.

The library is defined as the class ILI9488 in ILI9488_Xil.h and ILI9488_Xil.cpp.

The class ILI9488 extends the class Adafruit_GFX, which is defined (together with other needed classes) in the source files in the Adafruit_GFX folder.

Folder Adafruit_GFX/Fonts contains definitions of several fonts that came with the Adafruit_GFX library.
(See function testBigFont in the library demo app for an example of use.)

Warning

When using the library on the MicroBlaze, be aware of the fact that the default stack size on the MicroBlaze is only 1 kB. The method ILI9488::fillRect uses 768 B from the stack for a local array, which is used to prepare data to be sent to the display over SPI.
The demo application included in this repository works with a 1 kB stack size, but more complex applications may not.

I highly recommend you increase the stack size in lscript.ld on the MicroBlaze.

Important

The compiler optimization matters!
There is a code in the library (especially in ILI9488::fillRect) which is CPU intensive.

Do build the final application in the Release configuration.

I recommend changing the Properties|C/C++ Build|Setting|Optimization to "Optimize most (-O3)" (the default is -O2 optimization setting).
I measured that using -O3 increases the library's performance as compared to -O2.

Configuring the library

As described in the chapter Interfaces, the display can be connected to the system using PS SPI or AXI SPI and PS GPIO or AXI GPIO.
We need to tell the library which connection we use.
The library is configured by editing the header ILI9488_Xil_setup.h. (The ILI9488_Xil_setup.h is being included by the ILI9488_Xil.h.)

You must edit the following section of this header, uncommenting one of the macros for SPI and one of the macros for GPIO:

/**** select one of the SPI types used by given application ****/
//#define ILI9488_SPI_PS  //SPI of Zynq Processing Systems is used.
//#define ILI9488_SPI_AXI //AXI Quad SPI IP is used.

/**** select one of the GPIO types used by given application ****/
//#define ILI9488_GPIO_PS  //EMIO GPIO of Zynq Processing Systems is used.
//#define ILI9488_GPIO_AXI //AXI GPIO IP is used.

Initializing the library

The class ILI9488 has an empty constructor.
The initialization of the class and configuration of the display is done by the method ILI9488::init. During the execution of ILI9488::init, configuration commands are sent to the display over SPI.

Warning

You must call ILI9488::init before using any other method of the ILI9488 class.

The method will raise std::invalid_argument or std::logic_error exceptions if it detects an issue with the parameters passed into it.

Here is the declaration of ILI9488::init for the case that AXI SPI and AXI GPIO are used (there are another three versions of ILI9488::init covering other combinations of SPI and GPIO connection; see ILI9488_Xil.h):

void ILI9488::init( XSpi *spi, XGpio *gpio, u32 _RSTPin, u32 _DCPin, unsigned _GPIOChannel = 1 );
Parameter Meaning
spi Address of an XSpi instance representing an initialized SPI driver ready for use.
gpio Address of an XGpio instance representing an initialized GPIO driver ready for use.
_RSTPin Mask of the GPIO pin to which the RESET signal of the display is connected to.
This mask is a value the library will use when calling functions XGpio_DiscreteSet/XGpio_DiscreteClear. A GPIO pin represented by bit 0 has the mask 0x01.
_DCPin Mask of the GPIO pin to which the DC/RS signal of the display is connected to.
_GPIOChannel AXI GPIO IP can be configured to have two channels (i.e., two banks of GPIO ports.). The default channel is identified by value 1.
In case you connect RESET and DC/RS signals to channel 2 of an AXI GPIO IP, provide 2 as the value of the _GPIOChannel parameter. (Please note that RESET and DC/RS signals must be in the same channel.)

You must pass initialized and ready to use instances of SPI and GPIO drivers to the ILI9488::init.

Tip

Part of this repository is a demo application, which shows the usage of the library. The application is implemented to work with all combinations of PS/AXI SPI/GPIO connections.

I recommend that you use functions initialize_PS_SPI(), initialize_AXI_SPI(), initialize_PS_GPIO() and initialize_AXI_GPIO() from the demo application as templates for SPI/GPIO interfaces initialization.

SPI Slave selection

The code using the library is responsible for selecting the correct SPI Slave before calling any of the library's methods. The library doesn't do Slave selection.

On PS SPI, you can select, for example, Slave 0 by the call XSpiPs_SetSlaveSelect(&SpiInstance, 0);.

On AXI SPI, I recommend selecting a Slave by following commands:

XSpi SpiInstance;

...

/* Select Slave 0 in the SPI instance configuration.
 * Parameter value 1 means that bit 0 is set, and therefore, Slave 0 is active.
 * We call this in order to have the correct value in SpiInstance.SlaveSelectReg.
 */
XSpi_SetSlaveSelect(&SpiInstance, 1);

/* Set the slave select register to select the device on the SPI before starting the transfer
 * of data. This call actually drives the respective SS signal low to activate the SPI slave.
 */
XSpi_SetSlaveSelectReg(&SpiInstance, SpiInstance.SlaveSelectReg);

Warning

On AXI SPI, you must select the Slave by calling XSpi_SetSlaveSelectReg, which does the actual write to the AXI SPI IP register, thus driving the relevant ss_o[x:x] signal low.

This is because, on AXI SPI, the library uses low-level SPI function XSpi_WriteReg, which does not set the slave register automatically based on the setting in the XSpi instance.

Setting display rotation

The method void ILI9488::setRotation(uint8_t rotation) sets the position of the pixel [0,0] and the rotation of the graphics on the display.

The following image shows the effect of calling ILI9488::setRotation on the 3.5″ TFT SPI Module ILI9488.
The default setting is setRotation(0).

ILI9488 TFT display pins

Drawing graphics elements

You can refer to the Adafruit GFX library's reference and the user guide for information on drawing graphic elements. You just need to ignore Arduino-specific aspects of the Adafruit GFX library.

In my demo application, I strived to show the usage of the most common Adafruit GFX methods.

The Adafruit GFX library uses 16-bit color representation R:G:B 5b:6b:5b. The color values are passed to library methods as unsigned 16-bit integers.
The ILI9488 display has a 24-bit color representation R:G:B 8b:8b:8b. It means that you can't utilize the full color depth of ILI9488 by the Adafruit GFX library.

The only way to draw 24-bit color graphics by the ILI9488 library is to draw a 24-bit color bitmap using the method ILI9488::drawImage888 (see next chapter for details).

Drawing RGB bitmap images

The ILI9488 library contains the following two methods for drawing RGB bitmap images, which are not inherited from the Adafruit GFX library:

void ILI9488::drawImage888( const uint8_t* img, uint16_t x, uint16_t y, uint16_t w, uint16_t h );
void ILI9488::drawImage565( const uint8_t* img, uint16_t x, uint16_t y, uint16_t w, uint16_t h );

These methods take as argument img a byte array of consecutive image pixels starting with the top left corner pixel [0,0], going horizontally along the x-axis.

ILI9488::drawImage888 works with pixels in color coding R:G:B 8b:8b:8b (i.e., 3 bytes per pixel).
ILI9488::drawImage565 works with pixels in color coding R:G:B 5b:6b:5b (i.e., 2 bytes per pixel).

ILI9488::drawImage565 doesn't utilize the full 24-bit color depth of ILI9488. Nevertheless, the input image takes less space in memory as compared to 24-bit depth.

Tip

In this GitHub repository, I provided two Python scripts (for both color bit depths), which read an image file and write to the standard output definition of a constant in C++ (an array of bytes) usable as input to the ILI9488::drawImage888 and ILI9488::drawImage565. See details in image_to_source_code_conversion.

Tip

Using ILI9488::drawImage888 is actually the fastest way to draw anything (even a single pixel) to the ILI9488 because input graphic data are already in the format ready to be sent to the display over the SPI. No conversion is needed.

Consider, for example, that ILI9488::drawPixel(int16_t x, int16_t y, uint16_t color) must first convert 16-bit R:G:B 5b:6b:5b color to three bytes R:G:B 8b:8b:8b, which are then sent over the SPI. This puts a load on the CPU.

Please note that ILI9488::drawImage565 looks similar to Adafruit_GFX::drawRGBBitmap, but the methods use different image data formats.
For ILI9488::drawImage565, a pixel is represented as an array of two bytes. For Adafruit_GFX::drawRGBBitmap, a pixel is represented as a 16-bit unsigned integer (i.e., the bytes are in the reverse order due to little-endian data storage.)

Demo application and sample projects

I prepared a demo application, which shows how to initialize GPIO and SPI, how to initialize the library, and how to use its methods. It is a standalone application running on Zynq ARM core or MicroBlaze soft CPU.

This YouTube video shows what the demo application does:
Watch the video

To use the application, copy files from folder ILI9488-Xilinx_library_demo_app into the src folder of your application project in Vitis.

The application contains code for all four combinations of PS/AXI GPIO/SPI interfaces. The correct version of code will be enabled based on the definition library's configuration macros in ILI9488_Xil_setup.h (see chapter Configuring the library for details.)

The application assumes that GPIO and SPI device 0 is used and that the RST signal is connected to GPIO pin 0 and the DC/RS signal is connected to GPIO pin 1.
To change this to other GPIO/SPI devices or to other pins, you need to set accordingly values of macros ILI9488_SPI_DEVICE_ID, ILI9488_GPIO_DEVICE_ID, ILI9488_RST_PIN and ILI9488_DC_PIN, which are defined at the beginning of main.cpp.

I included in this repository several sample projects designed in Vivado 2023.1 and Vitis 2023.1, which show the use of the library on Zynq-7000 and MicroBlaze. Refer to the folder sample_project_files for details.

I also provided a detailed step-by-step tutorial on how to use the display on the Zynq board Cora Z7.

Performance

ILI9488 is not very fast.
It uses SPI with a 20 MHz clock and each pixel on the display is represented by 3 bytes.

So when you fill the whole 320x480 display with a color using ILI9488::fillRect, 450 kB of data needs to be transferred over the SPI (plus a few bytes of commands).

Drawing a single pixel using ILI9488::drawPixel requires a transfer of 13 bytes (10 bytes of commands and 3 bytes of data).
That is why drawing of "big fonts" (defined in the headers in the folder Adafruit_GFX/Fonts) is relatively slow because the Adafruit_GFX library draws these bitmaps pixel by pixel.

My performance measurements revealed that for AXI SPI, the use of the high-level function XSpi_Transfer significantly decreases the library's overall performance.

The library only writes over the SPI; we do not read any data back from the display. However, function XSpi_Transfer always reads the receive FIFO buffer from the AXI SPI IP (even when you provide NULL as the value of the receive buffer in the XSpi_Transfer parameters).
It means that when you send 100 B of data to the display, 200 B of data are transferred in total over a relatively slow AXI bus.

For AXI SPI, I, therefore, implemented the private method ILI9488::writeToSPI using low-level SPI functions (e.g., XSpi_WriteReg). The implementation doesn't read any data back from the receive FIFO buffer from the AXI SPI IP.
The fact that ILI9488::writeToSPI has similar performance on both slow 160 MHz MicroBlaze and fast 667 MHz Zynq-7000 tells me that it's efficient and the performance bottleneck is the 20 MHz SPI clock of the ILI9488 display controller IC.

For PS SPI on Zynq-7000, the method ILI9488::writeToSPI just calls the function XSpiPs_PolledTransfer. XSpiPs_PolledTransfer also always reads the content of the receive FIFO, but that is quite fast on Zynq-7000. I, therefore, didn't invest time into low-level SPI implementation for PS SPI.

For all tests listed below, the app was compiled with the highest gcc compiler optimization (flag -O3).

Fill display 320x480 performance

The durations listed in the table are the durations of the call display.fillRect( 0, 0, 480, 320, ILI9488_BLUE ); (measured using a GPIO pin and an oscilloscope).

HW SW implementation FIFO length Duration
MicroBlaze 160 MHz, AXI SPI low-level SPI functions used 256 B 185.1 ms
Zynq-7000 667 MHz, AXI SPI low-level SPI functions used 256 B 185.0 ms
Zynq-7000 667 MHz, PS SPI function XSpiPs_PolledTransfer used 128 B 203.3 ms
Zynq-7000 667 MHz, AXI SPI function XSpi_Transfer used
based on this measurement, I decided to use low-level SPI functions in the library for AXI SPI
256 B 462.7 ms

Fill rectangle 50x50 performance

The durations listed in the table are the durations of the call display.fillRect( 0, 0, 50, 50, ILI9488_BLUE ); (measured using a GPIO pin and an oscilloscope).

HW SW implementation FIFO length Duration
MicroBlaze 160 MHz, AXI SPI low-level SPI functions used 256 B 3.041 ms
Zynq-7000 667 MHz, AXI SPI low-level SPI functions used 256 B 3.019 ms
Zynq-7000 667 MHz, PS SPI function XSpiPs_PolledTransfer used 128 B 3.324 ms
Zynq-7000 667 MHz, AXI SPI function XSpi_Transfer used
based on this measurement, I decided to use low-level SPI functions in the library for AXI SPI
256 B 7.665 ms