Integrating ST7789H2 Display Support on STM32L562E-DK with Zephyr: A Step-by-Step Guide

Zephyr LogoThis blog post is a continuation of our series of blog posts on Zephyr, in which we already discussed Getting started with Zephyr, Understanding Zephyr’s Blinky Sample, and Zephyr: implementing a device driver for a sensor.

In this fourth blog post in our series, we will see how to add support in Zephyr for the display panel available on the STM32L562E-DK board.

Looking for Existing Support

The display panel on the STM32L562E-DK evaluation board is managed by a ST7789H2 screen controller, and is connected to the micro-controller through a STM32 interface called FMC (Flexible Memory Controller). The FMC is a controller that allows you to interface with various forms of memory storage with a parallel interface, but it can also communicate with display controllers using the Intel 8080 protocol. The block diagram below shows the display panel connected over FMC (for display) and I2C (for touchscreen). This blog post is only about the display side, connected over the FMC interface of the STM32 micro-controller.

STM32L562E-DK block diagram

To enable the display on this board, we first have to examine what is supported by Zephyr. There is existing support for the STM32 FMC, and we can look at the Device Tree binding for the FMC, and more specifically, the binding for the NOR/PSRAM part of the FMC, which is what we use to communicate with the display controller.

For the display controller, there is no support for the ST7789H2 specifically, but there is support for the ST7789V. After reviewing the datasheets, we can see that there is essentially no difference between the two devices, so the support for the ST7789V will work for our display controller.

However, the issue is that the display controller can be controlled through different interfaces, so we need to ensure that the ST7789V can be driven through the FMC and not another device.

If we look at the driver for the ST7789V, we can see that it doesn’t directly access any device to communicate with the display controller. Instead, the driver uses the Zephyr MIPI DBI API. For example, consider the function st7789v_transmit:

static void st7789v_transmit(const struct device *dev, uint8_t cmd,
                 uint8_t *tx_data, size_t tx_count)
{
    const struct st7789v_config *config = dev->config;

    mipi_dbi_command_write(config->mipi_dbi, &config->dbi_config, cmd,
                   tx_data, tx_count);
}

This means we need a MIPI DBI driver for interfacing the FMC with a display controller. We can see all MIPI DBI drivers in the drivers/mipi_dbi folder. There are drivers for various devices, but no driver for the FMC. So, our first goal will be to write this driver.

The Device Tree

First, we have to decide how to structure our Device Tree.

We can look at how existing boards represent display panels in Device Tree, such as the ST7789V, even if it is through another interface. To do this, we can use git grep to find the Device Tree of a board with this display controller: git grep st7789v.

For example, we see that the board M5Stack AtomS3 has an ST7789V display controller, which is described as follows:

    mipi_dbi {
        compatible = "zephyr,mipi-dbi-spi";
        spi-dev = <&spi2>;
        /* ... */

        st7789v: st7789v@0 {
            compatible = "sitronix,st7789v";
            /* Properties for the st7789v */
        };
    };

We see that we have to put our display controller inside a mipi_dbi node. This node doesn’t represent an actual device: in this case, the actual device used to communicate with the ST7789V is spi2, refered to using the spi-dev Device Tree property.

Next, we need to figure out how to describe the FMC. The binding shows us how to do it:

&fmc {
    pinctrl-0 = <...>;
    pinctrl-names = "default";
    status = "okay";

    sram {
        compatible = "st,stm32-fmc-nor-psram";

        #address-cells = <1>;
        #size-cells = <0>;

        bank@0 {
            reg = <0x0>;
            st,control = <...>;
            st,timing = <...>;
        };
    };
};

To understand this Device Tree, we have to learn a bit about the FMC. The FMC possesses four banks (of 256 MB each), one used for NOR/PSRAM memory, and three for NAND memory. The bank for the NOR/PSRAM memory is divided into four sub-banks. For the LCD, we must use the NOR/PSRAM bank. Depending on the specific sub-bank needed, the bank node must have a different reg value (bank@0 for the first sub-bank, bank@1 for the second, etc.). As explained in the datasheet:

STM32 FMC controller

We now have to decide where to put our MIPI DBI device. Similar to the mipi-dbi-spi node we saw earlier, this node will not represent a real device, but the ST7789V assumes it is inside a node with a MIPI DBI driver. We will place it inside the bank@0 node.

So, our device tree will look like this:

    //...

    bank@0 {
        reg = <0x0>;
        st,control = <...>;
        st,timing = <...>;

        fmc-mipi-dbi {
            compatible = "st,stm32-fmc-mipi-dbi";

            st7789v: lcd-panel@0 {
                compatible = "sitronix,st7789v";
                // ...
            };
        };
    };

Now, it’s important to set the properties of the FMC st,control and st,timing. These properties are used by the FMC driver to configure the FMC. We can refer to the binding to see the meanings of the different values of these two properties. To find the values we need, we can look at the BSP from ST. In Drivers/BSP/STM32L562E-DK/stm32l562e_discovery_lcd.c, we find a function that sets up the FMC. Thanks to this function, we can now deduce the values of the two properties:

    st,control = <STM32_FMC_DATA_ADDRESS_MUX_DISABLE
        STM32_FMC_MEMORY_TYPE_SRAM
        STM32_FMC_NORSRAM_MEM_BUS_WIDTH_16
        STM32_FMC_BURST_ACCESS_MODE_DISABLE
        STM32_FMC_WAIT_SIGNAL_POLARITY_LOW
        STM32_FMC_WAIT_TIMING_BEFORE_WS
        STM32_FMC_WRITE_OPERATION_ENABLE
        STM32_FMC_WAIT_SIGNAL_DISABLE
        STM32_FMC_EXTENDED_MODE_DISABLE
        STM32_FMC_ASYNCHRONOUS_WAIT_DISABLE
        STM32_FMC_WRITE_BURST_DISABLE
        STM32_FMC_CONTINUOUS_CLOCK_SYNC_ONLY
        STM32_FMC_WRITE_FIFO_DISABLE
        STM32_FMC_PAGE_SIZE_NONE>;

    st,timing = <1 1 32 0 2 2 STM32_FMC_ACCESS_MODE_A>;

Implementing the driver

We then have to code the MIPI DBI driver. First, we have to create all the files necessary to allow Zephyr to properly configure and compile the driver. First, we create the file drivers/mipi_dbi/Kconfig.stm32_fmc:

config MIPI_DBI_STM32_FMC
	bool "MIPI DBI driver for STM32 FMC"
	default y
	depends on DT_HAS_ST_STM32_FMC_MIPI_DBI_ENABLED
	select MEMC
	help
	  Enable support for MIPI DBI driver for controller based on the STM32 FMC.

config MIPI_DBI_STM32_FMC_MEM_BARRIER
	bool "Adds memory barrier after every address and data register access"
	default y

endif # MIPI_DBI_STM32_FMC

The option MIPI_DBI_STM32_FMC_MEM_BARRIER, on by default, will require our driver to insert a memory barrier between every register access. On some systems, these barriers are necessary.

In drivers/mipi_dbi/Kconfig, we add this line:

source "drivers/mipi_dbi/Kconfig.stm32_fmc"

Next, we add the following line in drivers/mipi_dbi/CMakeLists.txt:

zephyr_sources_ifdef(CONFIG_MIPI_DBI_STM32_FMC mipi_dbi_stm32_fmc.c)

We also have to create the Device Tree binding in dts/bindings/mipi-dbi/st,mipi-dbi-fmc.yaml:

description: STM32 FMC display controller
compatible: "st,stm32-fmc-mipi-dbi"

include: ["mipi-dbi-controller.yaml"]

properties:
  reset-gpios:
    type: phandle-array
    description: |
      Reset GPIO pin. Set high to reset the display.

  power-gpios:
    type: phandle-array
    description: |
      Power GPIO pin. Set high to power the display.

  register-select-pin:
    type: int
    required: true
    description: |
      Address pin used as Register Select for the display controller.

We define three properties in our binding: reset-gpios and power-gpios allow us to describe the two GPIOs used for the power and reset lines. register-select-pin allows us to specify which pin serves as “register select,” which we will discuss later.

We can now create our driver in drivers/mipi_dbi/mipi_dbi_stm32_fmc.c.

Note that in this blog post, we will not go into detail on the code that is common for most drivers. For an introduction on developing Zephyr device drivers, please see our previous blog post on implementing a device driver for a sensor.

The first thing to do is define DT_DRV_COMPAT to tell Zephyr the compatible target for our driver:

#define DT_DRV_COMPAT st_stm32_fmc_mipi_dbi

Before writing the rest of our driver, it’s important to understand how to use the FMC to communicate with the display controller. The FMC provides a 16-bit parallel interface with the display controller. There is also a pin, called “register select,” that tells the display controller whether the data on the line is a command, or either a parameter for the command or display data. On the other side, the FMC sees the controller as memory: it has pins for the data and for the address. The 16 data pins of the FMC are linked to the data pins on the display controller, and one of the address pins is linked to the register select. This is the meaning of the property register-select-pin that we included in the binding.

This means that if we want to send a command, we just have to write to the start address of the memory bank (so the address that the FMC will send is 0). If we want to write data to the display controller, we have to write to another address where the bit of the address that will end up on the register select pin is set to 1.

Now that we understand that, we can start writing the driver. First, we need to declare our config and data structs:

struct mipi_dbi_stm32_fmc_config {
	/* Reset GPIO */
	const struct gpio_dt_spec reset;
	/* Power GPIO */
	const struct gpio_dt_spec power;
	mem_addr_t register_addr;
	mem_addr_t data_addr;
	uint32_t fmc_address_setup_time;
	uint32_t fmc_data_setup_time;
	uint32_t fmc_memory_width;
};

struct mipi_dbi_stm32_fmc_data {
	const struct mipi_dbi_config *dbi_config;
};

The properties register_addr and data_addr are the two addresses that we talked about before. The properties fmc_address_setup_time, fmc_data_setup_time, and fmc_memory_width are just properties from the FMC, which we will use to check that the FMC is configured correctly.

We then need to have a way to compute register_addr and data_addr.

First, register_addr. This one is easy: if we are on the first sub-bank, we just need to use the definition provided by the ST HAL, FMC_BANK1_1. If we are on the second, use FMC_BANK1_2, etc. We just have to be careful because the reg property in the device tree is 0 for the first bank, but we want a 1 in the name. That gives us this macro:

#define MIPI_DBI_FMC_GET_ADDRESS(n) \
        _CONCAT(FMC_BANK1_, UTIL_INC(DT_REG_ADDR(DT_INST_PARENT(n))))

Next, we have to compute data_addr. The formula is register_addr + (1 << (register_select + 1)): we set the right bit to 1. The low-order bit of the bus being not wired, when register_select is 0, we need to use bit 1, hence the + 1.

#define MIPI_DBI_FMC_GET_DATA_ADDRESS(n)                                                           \
	MIPI_DBI_FMC_GET_ADDRESS(n) + (1 << (DT_INST_PROP(n, register_select_pin) + 1))

We can now declare the macro that will allow us to create our device and populate the config struct:

#define MIPI_DBI_STM32_FMC_INIT(n)                                                                 \
	static const struct mipi_dbi_stm32_fmc_config mipi_dbi_stm32_fmc_config_##n = {            \
		.reset = GPIO_DT_SPEC_INST_GET_OR(n, reset_gpios, {}),                             \
		.power = GPIO_DT_SPEC_INST_GET_OR(n, power_gpios, {}),                             \
		.register_addr = MIPI_DBI_FMC_GET_ADDRESS(n),                                      \
		.data_addr = MIPI_DBI_FMC_GET_DATA_ADDRESS(n),                                     \
		.fmc_address_setup_time = DT_PROP_BY_IDX(DT_INST_PARENT(n), st_timing, 0),         \
		.fmc_data_setup_time = DT_PROP_BY_IDX(DT_INST_PARENT(n), st_timing, 2),            \
		.fmc_memory_width = DT_PROP_BY_IDX(DT_INST_PARENT(n), st_control, 2),              \
	};                                                                                         \
                                                                                                   \
	static struct mipi_dbi_stm32_fmc_data mipi_dbi_stm32_fmc_data_##n;                         \
                                                                                                   \
	DEVICE_DT_INST_DEFINE(n, mipi_dbi_stm32_fmc_init, NULL, &mipi_dbi_stm32_fmc_data_##n,      \
			      &mipi_dbi_stm32_fmc_config_##n, POST_KERNEL,                         \
			      CONFIG_MIPI_DBI_INIT_PRIORITY, &mipi_dbi_stm32_fmc_driver_api);

DT_INST_FOREACH_STATUS_OKAY(MIPI_DBI_STM32_FMC_INIT)

Notice that we use here the function mipi_dbi_stm32_fmc_init and the variable mipi_dbi_stm32_fmc_driver_api, which we will define now.

First, the initialization: our code will be quite simple, simply configure the two GPIOs (reset and power) to the correct output.

static int mipi_dbi_stm32_fmc_init(const struct device *dev)
{
	const struct mipi_dbi_stm32_fmc_config *config = dev->config;

	if (config->reset.port) {
		if (!gpio_is_ready_dt(&config->reset)) {
			LOG_ERR("Reset GPIO device not ready");
			return -ENODEV;
		}

		if (gpio_pin_configure_dt(&config->reset, GPIO_OUTPUT_INACTIVE)) {
			LOG_ERR("Couldn't configure reset pin");
			return -EIO;
		}
	}

	if (config->power.port) {
		if (!gpio_is_ready_dt(&config->power)) {
			LOG_ERR("Power GPIO device not ready");
			return -ENODEV;
		}

		if (gpio_pin_configure_dt(&config->power, GPIO_OUTPUT)) {
			LOG_ERR("Couldn't configure power pin");
			return -EIO;
		}
	}

	return 0;
}

Next, we need to implement the API functions that the display controller driver will call.

We will implement three functions, all part of the MIPI DBI API: mipi_dbi_reset(), mipi_dbi_command_write(), and mipi_dbi_write_display().

The reset function is easy: simply set the reset pin to 1, wait for the appropriate delay, then set it back to 0.

static int mipi_dbi_stm32_fmc_reset(const struct device *dev, uint32_t delay)
{
	const struct mipi_dbi_stm32_fmc_config *config = dev->config;
	int ret;

	if (config->reset.port == NULL) {
		return -ENOTSUP;
	}

	ret = gpio_pin_set_dt(&config->reset, 1);
	if (ret reset, 0);
}

Then we have to tackle the two main functions, mipi_dbi_command_write() and mipi_dbi_write_display(). These two functions take as parameter a pointer to a struct mipi_dbi_config. It’s important to check that our FMC is compatible with the configuration needed by the display controller.

First, we have to check that the display controller is asking for the right MIPI DBI mode: Intel 8080 on 16 bits. We also have to check that the FMC is configured for a 16 bits bus. We must also verify that the write frequency of the FMC is compatible with the frequency of the display controller. The FMC frequency can be computed as follow (taken from the application note AN2784:

We then just have to compare it to the frequency indicated in the struct mipi_dbi_config.

In order to not re-check the same configuration every time, we store in our data structure a pointer to the configuration we just checked, so that we will only have to check if the two pointers are equal the next time.

int mipi_dbi_stm32_fmc_check_config(const struct device *dev,
				    const struct mipi_dbi_config *dbi_config)
{
	const struct mipi_dbi_stm32_fmc_config *config = dev->config;
	struct mipi_dbi_stm32_fmc_data *data = dev->data;
	uint32_t fmc_write_cycles;

	if (data->dbi_config == dbi_config) {
		return 0;
	}

	if (dbi_config->mode != MIPI_DBI_MODE_8080_BUS_16_BIT) {
		LOG_ERR("Only support Intel 8080 16-bits");
		return -ENOTSUP;
	}

	if (config->fmc_memory_width != FMC_NORSRAM_MEM_BUS_WIDTH_16) {
		LOG_ERR("Only supports 16-bit bus width");
		return -EINVAL;
	}

	uint32_t hclk_freq =
		STM32_AHB_PRESCALER * DT_PROP(STM32_CLOCK_CONTROL_NODE, clock_frequency);

	/* According to the FMC documentation*/
	fmc_write_cycles =
		((config->fmc_address_setup_time + 1) + (config->fmc_data_setup_time + 1)) * 1;

	if (hclk_freq / fmc_write_cycles > dbi_config->config.frequency) {
		LOG_ERR("Frequency is too high for the display controller");
		return -EINVAL;
	}

	data->dbi_config = dbi_config;
	return 0;
}

The mipi_dbi_stm32_fmc_command_write() is now easy to write: after checking that the configuration is correct, we have to write the command onto register_addr, and then write all the parameters onto data_addr.

int mipi_dbi_stm32_fmc_command_write(const struct device *dev,
				     const struct mipi_dbi_config *dbi_config, uint8_t cmd,
				     const uint8_t *data_buf, size_t len)
{
	const struct mipi_dbi_stm32_fmc_config *config = dev->config;
	int ret;
	size_t i;

	ret = mipi_dbi_stm32_fmc_check_config(dev, dbi_config);
	if (ret < 0) {
		return ret;
	}

	sys_write16(cmd, config->register_addr);
	if (IS_ENABLED(CONFIG_MIPI_DBI_STM32_FMC_MEM_BARRIER)) {
		barrier_dsync_fence_full();
	}

	for (i = 0U; i < len; i++) {
		sys_write16((uint16_t)data_buf[i], config->data_addr);
		if (IS_ENABLED(CONFIG_MIPI_DBI_STM32_FMC_MEM_BARRIER)) {
			barrier_dsync_fence_full();
		}
	}

	return 0;
}

The function mipi_dbi_stm32_fmc_write_display is even easier: we just have to write the frame onto the memory, without writing any command.

static int mipi_dbi_stm32_fmc_write_display(const struct device *dev,
					    const struct mipi_dbi_config *dbi_config,
					    const uint8_t *framebuf,
					    struct display_buffer_descriptor *desc,
					    enum display_pixel_format pixfmt)
{
	const struct mipi_dbi_stm32_fmc_config *config = dev->config;
	size_t i;
	int ret;

	ret = mipi_dbi_stm32_fmc_check_config(dev, dbi_config);
	if (ret < 0) {
		return ret;
	}

	for (i = 0U; i < desc->buf_size; i += 2) {
		sys_write16(sys_get_le16(&framebuf[i]), config->data_addr);
		if (IS_ENABLED(CONFIG_MIPI_DBI_STM32_FMC_MEM_BARRIER)) {
			barrier_dsync_fence_full();
		}
	}

	return 0;
}

We now just have to declare our api struct:

static struct mipi_dbi_driver_api mipi_dbi_stm32_fmc_driver_api = {
	.reset = mipi_dbi_stm32_fmc_reset,
	.command_write = mipi_dbi_stm32_fmc_command_write,
	.write_display = mipi_dbi_stm32_fmc_write_display,
};

Our code is now working. To test it, we can run the sample display:

Conclusion

In this blog post you learned how the interface between the STM32 FMC and a display controller works in Zephyr, using the MIPI DBI API. All the code in this blog can be seen in the related pull request on Github, which has since then been accepted upstream.

Stay tuned for more Zephyr related blog posts!

Leave a Reply