Documentation / Hardware

ESP32-P4 MIPI-DSI screen flickers when writing flash: the cause and the fix

On an ESP32-P4 with a MIPI-DSI panel, the screen blinks every time the program writes to flash: saving a setting, writing a file, an OTA update. This page explains why, gives a fix you can drop into any Arduino sketch, and says what LCD Wizard does about it.

The symptoms

You will see one or more of these on an ESP32-P4 board with a MIPI-DSI screen, built with the Arduino core (arduino-esp32 3.x, PlatformIO or the Arduino IDE):

It is the same whichever panel controller drives the screen (ST7701, JD9165, JD9365, EK79007, ILI9881C, HX8394): the cause is in the display driver, not the panel. We measured it on Waveshare’s ESP32-P4-WIFI6-Touch-LCD-4.3. Panels on the ESP32-S3’s RGB bus do not do it.

The cause

ESP-IDF’s DPI panel driver (esp_lcd_panel_dpi.c, in ESP-IDF 5.5 and 6.1) does not repeat the frame in hardware. It sends each frame from PSRAM to the DSI bridge as one DMA block, and the end-of-frame interrupt starts the next one.

While the flash is written or erased, the chip turns its cache off, and only interrupts marked IRAM-safe may run. The arduino-esp32 core’s prebuilt ESP32-P4 libraries have CONFIG_LCD_DSI_ISR_CACHE_SAFE (and with it CONFIG_DW_GDMA_ISR_IRAM_SAFE) switched off. So the end-of-frame interrupt has to wait: the DMA stops at the end of the frame, and the panel gets no pixels until the write is over.

A 4 KB sector erase took 14 to 20 ms on the board we measured (more on some flash chips): one or two frames of blank screen. A 256-byte page program takes about 0.6 ms, which fits in the gap between two frames, so a write into flash that is already erased usually shows nothing. LittleFS, NVS and OTA all erase as they go.

It is not PSRAM bandwidth, the usual explanation for DSI underruns: the ESP32-P4 has separate controllers for flash and PSRAM, and the DMA keeps reading the frame buffer while the flash is busy. It only needs something to start the next frame. The DSI bridge’s underrun flag does not catch it either. In our tests it stayed clear while the screen visibly blinked.

The fix: keep the frame running without the interrupt

The DMA controller on the ESP32-P4 (DW-GDMA) has an auto-reload mode, which sends the same block again and again in hardware with no interrupt. One frame buffer is one block, so in that mode the panel keeps being fed through any flash write.

lw_dsi_hold() switches the DSI channel over once, after the panel has started:

  1. It finds the DMA channel that feeds the DSI bridge.
  2. Partway through a frame, it stops the end-of-frame interrupt reaching the CPU, so the channel halts by itself at the end of that frame.
  3. In the gap before the next frame, it sets the channel to auto-reload and starts it again.
  4. It then checks the channel is still sending. If it is not, it hands the panel back to the driver as it was and returns an error.

It uses only the register header that ships with the core (hal/dw_gdma_ll.h). Nothing is rebuilt, and no sdkconfig change is needed. It is the C file below, which LCD Wizard writes into its exports. Run on a Waveshare ESP32-P4-WIFI6-Touch-LCD-4.3: the screen blinked through erases, LittleFS writes and NVS commits before it, and stayed solid through all of them after.

Using it in your own sketch

Save the two files below beside your sketch as lw_dsi.h and lw_dsi.c. Call it once, after the panel is running, with its frame buffer and that buffer’s size in bytes. With GFX Library for Arduino:

#include <Arduino_GFX_Library.h>
#include "lw_dsi.h"

void setup()
{
    gfx->begin();
    int r = lw_dsi_hold(gfx->getFramebuffer(), (size_t)LCD_H_RES * LCD_V_RES * 2);  // RGB565
    if (r != 0) Serial.printf("lw_dsi_hold: %d - the screen may still blink on flash writes\n", r);
}

With esp_lcd directly, take the frame buffer from esp_lcd_dpi_panel_get_frame_buffer(panel, 1, &fb) after esp_lcd_panel_init(). The size is width × height × bytes per pixel, and it must be one frame buffer (num_fbs 1).

What it returns: 0 when done; -1 no DSI DMA running yet; -2 or -3 the driver is not set up as expected (another driver version, or more than one frame buffer); -4 or -5 the frame could not be timed; -6 the switch did not hold and the driver’s own way was put back.

What it costs

The code

lw_dsi.h:

#pragma once
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

/* Keeps a MIPI-DSI panel's picture through flash writes (ESP32-P4).
 * Call once, after the panel is running, with its frame buffer.
 * Returns 0 when done, a negative number when the panel was left as it was. */
int lw_dsi_hold(const void *fb, size_t fb_bytes);

#ifdef __cplusplus
}
#endif

lw_dsi.c:

/* Keeps a MIPI-DSI panel's picture through flash writes, on an ESP32-P4.
 *
 * ESP-IDF's DPI panel (esp_lcd_panel_dpi.c, 5.5) sends each frame with one
 * DW-GDMA block, and its end-of-frame interrupt starts the next one. While
 * the flash is written or erased the cache is off, and an interrupt not
 * marked IRAM-safe waits: the Arduino core is built without
 * CONFIG_LCD_DSI_ISR_CACHE_SAFE, so the DMA stops at the end of the frame
 * and the panel runs dry (an underrun) until the write is over. A 4 KB
 * erase takes tens of milliseconds - a few frames of blank screen.
 *
 * The cure here needs no interrupt: the channel is switched to the
 * DW-GDMA's auto-reload mode, which sends the same block - the whole frame
 * buffer - again and again in hardware. The switch is made at a frame's
 * end: the end-of-frame interrupt is kept from the CPU while the frame is
 * part sent, so the channel stops by itself at its end, and it is set up
 * again and started within the vertical blanking. The driver's interrupt
 * never runs again; nothing in the generated code uses its callbacks. */
#include <stdint.h>
#include <stdbool.h>
#include "sdkconfig.h"
#include "freertos/FreeRTOS.h"
#include "esp_rom_sys.h"
#include "esp_timer.h"
#include "hal/dw_gdma_ll.h"
#include "hal/cache_ll.h"
#include "lw_dsi.h"

static portMUX_TYPE lw_dsi_lock = portMUX_INITIALIZER_UNLOCKED;

/* The channel the DPI driver feeds the DSI bridge with: running, writing
 * to the bridge, each block a linked-list one. */
static int lw_dsi_channel(void)
{
    for (int i = 0; i < DW_GDMA_LL_CHANNELS_PER_GROUP; i++) {
        if (!(DW_GDMA.chen0.val & (1u << i))) continue;
        if (DW_GDMA.ch[i].dar0.dar0 != MIPI_DSI_BRG_MEM_BASE) continue;
        return i;
    }
    return -1;
}

int lw_dsi_hold(const void *fb, size_t fb_bytes)
{
    const uint32_t base = (uint32_t)fb;
    int ch = lw_dsi_channel();
    if (ch < 0) return -1;                                     /* no DSI DMA running */
    volatile dmac_channel_reg_t *c = &DW_GDMA.ch[ch];
    if (c->cfg0.src_multblk_type != DW_GDMA_BLOCK_TRANSFER_LIST ||
        c->cfg0.dst_multblk_type != DW_GDMA_BLOCK_TRANSFER_LIST) return -2;  /* not the driver's set-up */
    if (((size_t)c->block_ts0.block_ts + 1) * 8 != fb_bytes) return -3;     /* a block is not one frame */

    const uint32_t sig = c->int_sig_ena0.val;
    const uint32_t blk = c->block_ts0.val, ctl0 = c->ctl0.val, ctl1 = c->ctl1.val;
    int64_t t0 = esp_timer_get_time();
    int ret = 0;

    portENTER_CRITICAL(&lw_dsi_lock);
    /* 1. Wait until the frame is a fifth to two-thirds sent: the last
     *    frame's interrupt is long over and this one's end is milliseconds away. */
    for (;;) {
        uint32_t off = c->sar0.sar0 - base;
        if (off >= fb_bytes / 5 && off <= fb_bytes * 2 / 3) break;
        if (esp_timer_get_time() - t0 > 100000) { ret = -4; break; }   /* the address does not move */
    }
    if (ret == 0) {
        /* 2. The end of this frame is not told to the CPU, so the channel stops there. */
        dw_gdma_ll_channel_enable_intr_propagation(&DW_GDMA, ch, UINT32_MAX, false);
        int64_t t1 = esp_timer_get_time();
        while (DW_GDMA.chen0.val & (1u << ch)) {
            if (esp_timer_get_time() - t1 > 50000) { ret = -5; break; }
        }
    }
    if (ret == 0) {
        /* 3. In the blanking: the same frame, sent again and again by the DMA. */
        dw_gdma_ll_channel_clear_intr(&DW_GDMA, ch, UINT32_MAX);
        dw_gdma_ll_channel_set_src_multi_block_type(&DW_GDMA, ch, DW_GDMA_BLOCK_TRANSFER_RELOAD);
        dw_gdma_ll_channel_set_dst_multi_block_type(&DW_GDMA, ch, DW_GDMA_BLOCK_TRANSFER_RELOAD);
        dw_gdma_ll_channel_set_src_addr(&DW_GDMA, ch, base);
        dw_gdma_ll_channel_set_dst_addr(&DW_GDMA, ch, MIPI_DSI_BRG_MEM_BASE);
        dw_gdma_ll_channel_set_block_markers(&DW_GDMA, ch, false, false, false);
        dw_gdma_ll_channel_enable(&DW_GDMA, ch, true);
    } else if (ret == -5) {
        c->int_sig_ena0.val = sig;
    }
    portEXIT_CRITICAL(&lw_dsi_lock);
    if (ret) return ret;

    /* 4. Still sending after three frames? If not, back to the driver's way. */
    esp_rom_delay_us(50000);
    uint32_t a = c->sar0.sar0;
    esp_rom_delay_us(3000);
    uint32_t b = c->sar0.sar0;
    if ((DW_GDMA.chen0.val & (1u << ch)) && a != b) return 0;

    /* One frame from an item of our own, as the driver sends it; at its end
     * the driver's interrupt carries on with its own list, as before. */
    static dw_gdma_link_list_item_t lw_dsi_lli;
    dw_gdma_link_list_item_t *lli = (dw_gdma_link_list_item_t *)CACHE_LL_L2MEM_NON_CACHE_ADDR(&lw_dsi_lli);
    portENTER_CRITICAL(&lw_dsi_lock);
    dw_gdma_ll_channel_enable(&DW_GDMA, ch, false);
    while (DW_GDMA.chen0.val & (1u << ch)) { }
    dw_gdma_ll_channel_clear_intr(&DW_GDMA, ch, UINT32_MAX);
    lli->sar_lo.val = base;
    lli->sar_hi.val = 0;
    lli->dar_lo.val = MIPI_DSI_BRG_MEM_BASE;
    lli->dar_hi.val = 0;
    lli->block_ts_lo.val = blk;
    lli->llp_lo.val = 0;
    lli->llp_hi.val = 0;
    lli->ctrl_lo.val = ctl0;
    lli->ctrl_hi.val = ctl1;
    dw_gdma_ll_lli_set_block_markers(lli, false, true, true);
    dw_gdma_ll_channel_set_src_multi_block_type(&DW_GDMA, ch, DW_GDMA_BLOCK_TRANSFER_LIST);
    dw_gdma_ll_channel_set_dst_multi_block_type(&DW_GDMA, ch, DW_GDMA_BLOCK_TRANSFER_LIST);
    dw_gdma_ll_channel_set_link_list_master_port(&DW_GDMA, ch, DW_GDMA_LL_MASTER_PORT_MEMORY);
    dw_gdma_ll_channel_set_link_list_head_addr(&DW_GDMA, ch, (uint32_t)&lw_dsi_lli);
    c->int_sig_ena0.val = sig;
    dw_gdma_ll_channel_enable(&DW_GDMA, ch, true);
    portEXIT_CRITICAL(&lw_dsi_lock);
    return -6;
}

The fix in an ESP-IDF project

ESP-IDF builds its libraries from your project’s settings, so there you can use Espressif’s own option instead of the workaround. It is off by default in ESP-IDF 5.5 and 6.1. Put it in sdkconfig.defaults (or Component config > LCD and Touch Panel in idf.py menuconfig):

CONFIG_LCD_DSI_ISR_CACHE_SAFE=y

Run on a Waveshare ESP32-P4-WIFI6-Touch-LCD-4.3 with ESP-IDF 6.1: the same test program blinked through every erase with the option off, and held a steady picture with it on.

It puts the DSI interrupt and its DMA code in IRAM and lets them run while the cache is off (it selects CONFIG_DW_GDMA_ISR_IRAM_SAFE). Two things to know:

ESPHome

ESPHome’s mipi_dsi display (read in 2026.8) uses the same DPI driver and does not set the option, so the same blink can be expected on flash writes. Turning it on through sdkconfig_options is not enough on its own: the component registers an on_color_trans_done callback that is not in IRAM, which the option refuses.

Other ways round it

We have asked the arduino-esp32 maintainers to build the ESP32-P4 libraries with CONFIG_LCD_DSI_ISR_CACHE_SAFE on: espressif/arduino-esp32#13026. If a release does, this workaround is no longer needed with it.

In LCD Wizard

Each export of an ESP32-P4 design with a MIPI-DSI panel carries the fix that suits it:

ExportThe fix
Arduinolw_dsi.c beside the sketch, and lw_dsi_hold() straight after the panel starts. If the switch fails, the sketch says so on the serial port.
ESP-IDFCONFIG_LCD_DSI_ISR_CACHE_SAFE=y in sdkconfig.defaults. Not in a design with a camera on the CSI connector, for the reason above: there sdkconfig.defaults says it is left off.
ESPHomeThe export does not write ESP32-P4 or MIPI-DSI designs yet.

A design that saves settings, keeps files or updates itself over the air does not blink. The board pages for the ESP32-P4 boards say which panel each one has.