PPA SRM: 90°/270° RGB565 rotation never completes when out.block_offset_x % 4 == 2 while the DPI frame buffer is being scanned out (ESP32-P4, IDF 5.5.4) (IDFGH-18292)
Answers checklist
- I have read the documentation ESP-IDF Programming Guide and the issue is not addressed there.
- I have updated my IDF branch (release/vX.Y) to the latest version and checked that the issue is present there.
- I have searched the issue tracker for a similar issue and not found a similar issue.
IDF version
v5.5.4
Espressif SoC revision
ESP32-P4 rev v1.3
Operating System used
Linux
How did you build your project?
Other (please specify in More Information) - PlatformIO with the pioarduino platform fork, which uses the stock ESP-IDF 5.5.4 component set. The same code builds with a plain idf.py build (the project has a normal top-level CMakeLists.txt).
If you are using Windows, please specify command line type.
None
Development Kit
Custom board with an ESP32-P4 module (32 MB HEX PSRAM, 16 MB flash) and a 720x1280 MIPI-DSI display. The reproducer brings the DSI/DPI interface up itself and needs no panel driver - it never sends a command to the panel, so the display stays dark. Only the DPI frame buffer read-out matters, so any ESP32-P4 board with a DSI panel should do; substitute your own lane bit rate and video timing.
Power Supply used.
USB
What is the expected behavior?
ppa_do_scale_rotate_mirror() with rotation_angle = PPA_SRM_ROTATION_ANGLE_90 (or 270) and PPA_SRM_COLOR_MODE_RGB565 on both sides should either
- complete the transaction for any
out.block_offset_xthat passes the driver's own argument checks, or - reject the configuration with
ESP_ERR_INVALID_ARGif the hardware cannot handle it.
What is the actual behavior?
The transaction never completes for certain values of out.block_offset_x, and every such value has out.block_offset_x % 4 == 2 - that is, the destination row of the target block does not start on an 8-byte boundary for RGB565.
on_trans_doneis never invoked - not after 2 s, not after 6 s.- In
PPA_TRANS_MODE_BLOCKINGthe call never returns at all (ppa_core.cwaits on the engine semaphore and on the transaction semaphore withportMAX_DELAY), so the calling task stops feeding the task watchdog and the device resets. - The PPA engine stays occupied for the rest of the boot:
ppa_unregister_client()returnsESP_ERR_INVALID_STATE("client still has unprocessed trans",ppa_core.c,trans_cnt != 0) - tried 12 times over 6 s, every time - and every further transaction from that client is rejected immediately. There is no public recovery path; only a reset gets the engine back.dma2d_force_end()fromesp_private/dma2d.hlooks like it would help, but it needs the driver-internalppa_trans_t, which the PPA driver does not expose.
out.block_offset_x values that are multiples of 4 complete normally in about 1.6 ms with the exact same geometry and buffers.
A second condition is needed to see this at all: the destination must be a MIPI-DSI DPI frame buffer that the DSI host is reading out at the same time. With the frame buffer allocated by the very same driver at the very same address but esp_lcd_panel_init() not called - so the read-out never starts - all 66 offsets of the sweep complete. That control run is one #define away in the reproducer below.
Steps to reproduce
A minimal, standalone reproducer (no UI library, no vendor panel driver; only esp_driver_ppa and esp_lcd) is pasted below in full.
- Build and flash for an ESP32-P4 target with PSRAM and a MIPI-DSI panel connected (
idf.py set-target esp32p4 && idf.py flash monitor, orpio run -t upload -t monitor). AdjustDSI_LANE_MBPS,DPI_CLK_MHZand the video timing to your panel if needed. - Press reset and watch the serial output for about two minutes. The program sweeps
out.block_offset_xfrom 200 to 330 in steps of 2 with a fixed 424x134 RGB565 source block, the 720x1280 RGB565 DPI frame buffer as destination,out.block_offset_y = 712, 90 degree rotation, scale 1.0 - one transaction per fresh boot, so no transaction can be influenced by a previous one. - It prints a summary at the end. On our board 10 to 11 of the 66 offsets print
HANG, and every one of them hasx % 4 == 2; no offset withx % 4 == 0has ever hung. - For the control: set
DPI_STARTto 0, rebuild, run again. 66 of 66 offsets complete.
The source block height is 134 on purpose: 132 and 134 expose the problem, while several other heights did not (see More Information).
The transaction is issued PPA_TRANS_MODE_NON_BLOCKING and awaited on a semaphore with a 2000 ms timeout - otherwise the hang could not be printed at all. Because the engine stays busy after a hang, the sweep position and the results are kept in RTC memory and the chip is restarted after each transaction, so one reset walks the whole list.
main.c:
/*
* ESP32-P4 / ESP-IDF 5.5.4 - PPA SRM reproducer
*
* A 90 degree RGB565 rotation through ppa_do_scale_rotate_mirror() never completes when
* (a) out.block_offset_x is not a multiple of 4 pixels (RGB565: the destination row does
* not start on an 8 byte boundary), and
* (b) the destination is a MIPI-DSI DPI frame buffer that the DSI host is reading out at
* the same time.
*
* (b) is required. With the very same destination address, the very same parameters and the
* DPI read-out simply not started (esp_lcd_panel_init() left out), all offsets complete
* normally. See README.md for that control measurement.
*
* Not every misaligned offset hangs, but every hang observed is at an offset with
* x % 4 == 2; an offset with x % 4 == 0 has never hung, neither in this sweep nor in a
* 400 transaction soak. A hanging offset hangs on its very first transaction, every time.
*
* The transaction is issued NON_BLOCKING and awaited on a semaphore with a 2000 ms
* timeout, so the failure can be printed at all - in PPA_TRANS_MODE_BLOCKING the call
* simply never returns (ppa_core.c waits with portMAX_DELAY).
*
* A stuck transaction keeps the PPA engine busy forever: ppa_unregister_client() then
* returns ESP_ERR_INVALID_STATE ("client still has unprocessed trans") and every further
* transaction is rejected. There is no public recovery path, so the sweep position and the
* results are kept in RTC memory and the chip is restarted after every transaction; one
* press of reset walks the whole list and prints a summary at the end.
*/
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/ppa.h"
#include "esp_attr.h"
#include "esp_heap_caps.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_ldo_regulator.h"
#include "esp_lcd_mipi_dsi.h"
#include "esp_lcd_panel_ops.h"
/* Set to 0 for the control run: the frame buffer is allocated exactly as before, but the
* DPI read-out is never started. Everything then completes - see README.md. */
#define DPI_START 1
#define SRC_W 424 /* rendered strip, as a UI library would hand it over */
#define SRC_H 134 /* one block height that exposes it - see README */
#define DST_W 720 /* portrait frame buffer of a 720x1280 panel */
#define DST_H 1280
#define DST_OFF_Y 712 /* fixed; only block_offset_x is varied */
#define TIMEOUT_MS 2000
#define BUF_ALIGN 128 /* >= every ESP32-P4 cache line size (L1 64 B, L2 64/128 B) */
/* out.block_offset_x sweep, one transaction per boot. */
#define X_FIRST 200
#define X_LAST 330
#define X_STEP 2
#define N_OFFSETS ((X_LAST - X_FIRST) / X_STEP + 1)
/* MIPI-DSI: 720x1280, 2 data lanes. No vendor panel driver is used - the DPI interface on
* its own produces the frame buffer read traffic, and that is what matters here. Nothing is
* sent to the panel over the command channel, so the picture stays dark; any other panel's
* timing and lane bit rate can be substituted without changing the result. */
#define DSI_LANES 2
#define DSI_LANE_MBPS 700
#define DPI_CLK_MHZ 58
#define DSI_PHY_LDO_CHAN 3
#define DSI_PHY_LDO_MV 2500
/* Sweep position and results, kept across the restart after every transaction. */
#define RTC_MAGIC 0x50504132u
#define RES_NONE 0
#define RES_OK 1
#define RES_HANG 2
RTC_NOINIT_ATTR static uint32_t s_magic;
RTC_NOINIT_ATTR static uint32_t s_step;
RTC_NOINIT_ATTR static uint8_t s_result[N_OFFSETS];
static bool IRAM_ATTR on_trans_done(ppa_client_handle_t client, ppa_event_data_t *data, void *user_data)
{
BaseType_t hp = pdFALSE;
xSemaphoreGiveFromISR((SemaphoreHandle_t)user_data, &hp);
return hp == pdTRUE;
}
/* Bring up the MIPI-DSI DPI interface and hand back its first frame buffer. After
* esp_lcd_panel_init() the DSI host reads that buffer out of PSRAM continuously. */
static void *dpi_frame_buffer(void)
{
esp_ldo_channel_handle_t ldo = NULL;
esp_ldo_channel_config_t ldo_cfg = { .chan_id = DSI_PHY_LDO_CHAN, .voltage_mv = DSI_PHY_LDO_MV };
ESP_ERROR_CHECK(esp_ldo_acquire_channel(&ldo_cfg, &ldo));
esp_lcd_dsi_bus_handle_t bus = NULL;
esp_lcd_dsi_bus_config_t bus_cfg = {
.bus_id = 0,
.num_data_lanes = DSI_LANES,
.phy_clk_src = 0,
.lane_bit_rate_mbps = DSI_LANE_MBPS,
};
ESP_ERROR_CHECK(esp_lcd_new_dsi_bus(&bus_cfg, &bus));
esp_lcd_dpi_panel_config_t dpi_cfg = {
.virtual_channel = 0,
.dpi_clk_src = MIPI_DSI_DPI_CLK_SRC_DEFAULT,
.dpi_clock_freq_mhz = DPI_CLK_MHZ,
.pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565,
.num_fbs = 2,
.video_timing = {
.h_size = DST_W, .v_size = DST_H,
.hsync_back_porch = 20, .hsync_pulse_width = 20, .hsync_front_porch = 40,
.vsync_back_porch = 10, .vsync_pulse_width = 4, .vsync_front_porch = 24,
},
};
esp_lcd_panel_handle_t panel = NULL;
ESP_ERROR_CHECK(esp_lcd_new_panel_dpi(bus, &dpi_cfg, &panel));
#if DPI_START
ESP_ERROR_CHECK(esp_lcd_panel_init(panel)); /* starts the frame buffer read-out */
#endif
void *fb0 = NULL;
ESP_ERROR_CHECK(esp_lcd_dpi_panel_get_frame_buffer(panel, 1, &fb0));
return fb0;
}
static void print_summary(void)
{
int hangs = 0, misaligned_hangs = 0;
printf("\n=== summary: out.block_offset_x, one transaction per fresh boot ===\n");
for (int i = 0; i < N_OFFSETS; i++) {
if (i % 10 == 0) printf("\n ");
printf("%3d:%-5s", X_FIRST + i * X_STEP,
s_result[i] == RES_HANG ? "HANG" : s_result[i] == RES_OK ? "ok" : "-");
}
printf("\n\n hanging offsets:");
for (int i = 0; i < N_OFFSETS; i++) {
if (s_result[i] != RES_HANG) continue;
const int x = X_FIRST + i * X_STEP;
hangs++;
if (x % 4 == 2) misaligned_hangs++;
printf(" %d", x);
}
printf("\n %d hang(s), %d of them with x %% 4 == 2\n", hangs, misaligned_hangs);
printf("=== sweep finished - press reset to run it again ===\n");
}
void app_main(void)
{
if (s_magic != RTC_MAGIC) { /* power-on / external reset: start over */
s_magic = RTC_MAGIC;
s_step = 0;
memset(s_result, RES_NONE, sizeof(s_result));
}
if (s_step >= N_OFFSETS) {
print_summary();
s_magic = 0;
return;
}
const size_t src_bytes = (size_t)SRC_W * SRC_H * 2;
const size_t dst_bytes = (size_t)DST_W * DST_H * 2;
uint16_t *src = heap_caps_aligned_alloc(BUF_ALIGN, (src_bytes + BUF_ALIGN - 1) & ~((size_t)BUF_ALIGN - 1),
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
uint16_t *dst = dpi_frame_buffer();
if (!src || !dst) {
printf("allocation failed (src=%p dst=%p)\n", (void *)src, (void *)dst);
return;
}
for (size_t i = 0; i < src_bytes / 2; i++) src[i] = (uint16_t)i; /* any content */
SemaphoreHandle_t done = xSemaphoreCreateBinary();
ppa_client_handle_t client = NULL;
ppa_client_config_t client_cfg = {
.oper_type = PPA_OPERATION_SRM,
.max_pending_trans_num = 1,
};
ESP_ERROR_CHECK(ppa_register_client(&client_cfg, &client));
ppa_event_callbacks_t cbs = { .on_trans_done = on_trans_done };
ESP_ERROR_CHECK(ppa_client_register_event_callbacks(client, &cbs));
if (s_step == 0) {
printf("\n=== PPA SRM block_offset_x alignment repro ===\n");
printf("src %dx%d RGB565 @%p (internal) dst %dx%d RGB565 @%p (%s, %u B)\n",
SRC_W, SRC_H, (void *)src, DST_W, DST_H, (void *)dst,
DPI_START ? "DPI frame buffer, being read out" : "DPI frame buffer, read-out NOT started",
(unsigned)dst_bytes);
printf("rotation 90 deg, out.block_offset_y=%d, timeout %d ms, x = %d..%d step %d\n",
DST_OFF_Y, TIMEOUT_MS, X_FIRST, X_LAST, X_STEP);
}
const uint32_t x = X_FIRST + s_step * X_STEP;
ppa_srm_oper_config_t op = {
.in = {
.buffer = src,
.pic_w = SRC_W, .pic_h = SRC_H,
.block_w = SRC_W, .block_h = SRC_H,
.srm_cm = PPA_SRM_COLOR_MODE_RGB565,
},
.out = {
.buffer = dst,
.buffer_size = dst_bytes,
.pic_w = DST_W, .pic_h = DST_H,
.block_offset_x = x,
.block_offset_y = DST_OFF_Y,
.srm_cm = PPA_SRM_COLOR_MODE_RGB565,
},
.rotation_angle = PPA_SRM_ROTATION_ANGLE_90,
.scale_x = 1.0f, .scale_y = 1.0f,
.mode = PPA_TRANS_MODE_NON_BLOCKING,
.user_data = done,
};
printf("x=%3u start\n", (unsigned)x); /* printed BEFORE the call - it may not return */
fflush(stdout);
const int64_t t0 = esp_timer_get_time();
const esp_err_t err = ppa_do_scale_rotate_mirror(client, &op);
if (err != ESP_OK) {
printf("x=%3u REJECTED (%s)\n", (unsigned)x, esp_err_to_name(err));
} else if (xSemaphoreTake(done, pdMS_TO_TICKS(TIMEOUT_MS)) == pdTRUE) {
printf("x=%3u ok %lld us\n", (unsigned)x, esp_timer_get_time() - t0);
s_result[s_step] = RES_OK;
} else {
printf("x=%3u HANG (no completion after %d ms)\n", (unsigned)x, TIMEOUT_MS);
printf(" ppa_unregister_client() -> %s\n", esp_err_to_name(ppa_unregister_client(client)));
s_result[s_step] = RES_HANG;
}
/* The PPA engine cannot be freed after a hang, so every step gets a fresh boot. */
s_step++;
fflush(stdout);
vTaskDelay(pdMS_TO_TICKS(30));
esp_restart();
}sdkconfig.defaults:
CONFIG_IDF_TARGET="esp32p4"
# Chip revision of the board this was measured on: ESP32-P4 v1.3 (rev < 3.0).
# Remove these two lines on an ESP32-P4 rev >= 3.0 board.
CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y
CONFIG_ESP32P4_REV_MIN_100=y
# PSRAM holds the destination framebuffer (as a real display framebuffer would).
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_HEX=y
CONFIG_SPIRAM_SPEED_200M=y
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# The stuck transaction is detected by a timeout, not by the task watchdog.
CONFIG_ESP_TASK_WDT_INIT=nBuild or installation Logs
No response
More Information
Measurements on the device
ESP32-P4 v1.3, IDF 5.5.4, 32 MB HEX PSRAM @ 200 MHz. 720x1280 RGB565 DPI frame buffer at 0x48000ac0 as destination, 424x134 RGB565 source block in internal DMA-capable memory at 0x4ff40700, 90 degree rotation, out.block_offset_y = 712, one SRM client with max_pending_trans_num = 1.
| Configuration | Result |
|---|---|
| sweep x = 200..330 step 2, one transaction per fresh boot, read-out running | 10 hangs: 202 206 218 222 226 262 278 286 290 326 - all with x % 4 == 2 |
| same sweep repeated | 11 hangs: 202 214 222 226 270 278 282 286 290 326 330 - all with x % 4 == 2 |
same sweep, read-out not started (esp_lcd_panel_init() left out, same frame buffer address) |
0 hangs in 66 offsets |
out.block_offset_x = 226 (fixed), read-out running |
hangs on the first transaction, 5 of 5 fresh boots |
out.block_offset_x = 230 (fixed), read-out running |
400 transactions in one boot, no hang |
destination an ordinary heap_caps_aligned_alloc() PSRAM buffer, no DSI bus at all, x = 224/226/228/230/232 |
30 of 30 transactions complete - the problem does not appear |
So the offset alignment alone is not sufficient: the concurrent DPI read-out of the destination buffer is part of the trigger. The two earlier runs also show that which misaligned offsets hang is not fully fixed - it varies a little between runs, and it shifted when the frame buffer happened to land at a different address (0x48000f00 instead of 0x48000ac0 in an earlier measurement). What is invariant across every measurement so far is the class: hangs occur only at x % 4 == 2.
Additional observations from the full application this was found in (a UI library rotating rendered strips into the DPI frame buffer, same geometry):
- 270 degrees behaves exactly like 90 degrees.
- Block height: with
in.block_h= 132 and 134 the hangs appear, again exactly at x % 4 == 2. Within.block_h= 128, 130, 136, 138, 140, 142, 144, 150, 152 and 166 the very same x sweep produced no hang at all. So the misaligned x offset is the condition for the hang, but only some block heights expose it.in.block_w= 376 behaves like 424. data_burst_length(128/64/32/16/8 bytes inppa_client_config_t) shifts the hangs but does not remove them: with 32 bytes, x = 230 once ran 4000 times, but in a second series x = 200 hung instead. Not a workaround.- Inserting a 7 ms delay before each transaction did not change which offsets hang, so it is not a simple phase relationship to the panel's VSYNC.
Our workaround is to round every destination x offset down to a multiple of 4 before calling the driver, and to run every transaction non-blocking with a timeout so a hang cannot take the calling task down with it. Since then the problem is gone (about 5400 rotations without a single hang).
Why this looks like a missing check rather than an application bug
ppa_do_scale_rotate_mirror() in components/esp_driver_ppa/src/ppa_srm.c (IDF 5.5.4) validates alignment only for YUV color modes:
// For YUV420 input/output: in desc, ha/hb/va/vb/x/y must be even number
// For YUV422 input/output: in desc, ha/hb/x must be even number
if (config->in.srm_cm == PPA_SRM_COLOR_MODE_YUV420) {
ESP_RETURN_ON_FALSE(config->in.pic_h % 2 == 0 && config->in.pic_w % 2 == 0 &&
config->in.block_h % 2 == 0 && config->in.block_w % 2 == 0 &&
config->in.block_offset_x % 2 == 0 && config->in.block_offset_y % 2 == 0,
ESP_ERR_INVALID_ARG, TAG, "YUV420 input does not support odd h/w/offset_x/offset_y");
} else if (PPA_IS_CM_YUV422(config->in.srm_cm)) {
ESP_RETURN_ON_FALSE(config->in.pic_w % 2 == 0 && config->in.block_w % 2 == 0 && config->in.block_offset_x % 2 == 0,
ESP_ERR_INVALID_ARG, TAG, "YUV422 input does not support odd w/offset_x");
}
if (config->out.srm_cm == PPA_SRM_COLOR_MODE_YUV420) {
ESP_RETURN_ON_FALSE(config->out.pic_h % 2 == 0 && config->out.pic_w % 2 == 0 &&
config->out.block_offset_x % 2 == 0 && config->out.block_offset_y % 2 == 0,
ESP_ERR_INVALID_ARG, TAG, "YUV420 output does not support odd h/w/offset_x/offset_y");
} else if (PPA_IS_CM_YUV422(config->out.srm_cm)) {
ESP_RETURN_ON_FALSE(config->out.pic_w % 2 == 0 && config->out.block_offset_x % 2 == 0,
ESP_ERR_INVALID_ARG, TAG, "YUV422 output does not support odd w/offset_x");
}For RGB565 there is no constraint on out.block_offset_x - neither in the driver nor in the PPA programming guide. The only other checks that touch the d
Source: espressif/esp-idf