AI-assisted ESP-IDF + LVGL firmware for the JC3636K518C round knob display
The reference firmware (a production recorder on this exact board) hit every limit below at least once. The numbers are from its logs, not from datasheets.
| Pool | Size | Who needs it |
|---|---|---|
| Internal SRAM | ~512 KB total, ~300 KB usable heap after Wi-Fi + Bluetooth stacks and task stacks | DMA buffers, lwIP, mbedTLS, FreeRTOS stacks, anything latency-critical |
| Octal PSRAM | 8 MB at 80 MHz | Everything that tolerates ~2× latency: LVGL object pool, big BSS, file chunks, audio rings |
| Flash | 16 MB | App 9 MB + spiffs 4 MB in the starter partition table |
Rules:
heap_caps_malloc(n, MALLOC_CAP_SPIRAM) for buffers over a
few KB. Let large static arrays go there too: CONFIG_SPIRAM_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y
plus EXT_RAM_BSS_ATTR (or LVGL’s LV_ATTRIBUTE_LARGE_RAM_ARRAY).buff_spiram = false,
buff_dma = true), SD/I2S DMA descriptors, and anything touched every millisecond.esp_http_client, more if it printfs floats). Check with
uxTaskGetStackHighWaterMark().360×360×2 B = 253 KB per frame over a QSPI bus at 80 MHz. A full-frame redraw lands around 25–40 fps on a good day; with other SPI traffic it drops.
-O2 (CONFIG_COMPILER_OPTIMIZATION_PERF=y) and run at 240 MHz. Both are
measurable on software-rendered LVGL.esp_lvgl_port owns the LVGL tick, timer handler and flush, and a mutex. Everything that
touches LVGL objects runs either inside that task (timers, event callbacks) or under
ui_lock(timeout) / ui_unlock() from another task.
vTaskDelay while holding the LVGL lock. The reference firmware
hit the task watchdog when a task spun in lv_malloc while the port task held the lock
during an upload.esp_timer and decoded by pure code; steps accumulate in an
atomic and the LVGL task drains them. No per-detent interrupts.vTaskDelay(pdMS_TO_TICKS(3)) means 3 ms.ESP_LOGI(TAG, "heap[%s]: free-int=%u min-int=%u free-psram=%u", phase,
heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL),
heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
Log that at named phases: after init, before the first TLS session, after each transfer. The minimum-free-internal number is the one that decides whether a feature fits.
idf.py size-components for flash and static RAM per component.heap_caps_print_heap_info(MALLOC_CAP_INTERNAL) when fragmentation is suspected (small
“free” but tiny “largest block” is fragmentation).esp_timer_get_time() around a suspect section; log milliseconds.lv_obj_get_child_count and the LVGL memory monitor when the object pool grows.These are the mistakes generated code made on real projects here. Check for them explicitly.
| Pitfall | What it looked like | Guard |
|---|---|---|
| Invented pin numbers | Pins from the K718C, from a Waveshare W-series board, from an ESPHome post for a different PCB | Pins exist only as BOARD_* macros in one header. Run scripts/check-board-pins.sh; it fails the build on any GPIO literal elsewhere. |
| Invented components | espressif/knob for the rotary (reads zero: not quadrature), esp_lcd_gc9a01 for an ST77916 panel, esp_lcd_touch_cst816d (the driver is cst816s, it covers the D) |
Only add managed components that appear in idf_component.yml; look the name up on components.espressif.com before adding one. |
| LVGL 9.2 idioms on LVGL 9.6 | lv_obj_add_flag/remove_flag (deprecated), CONFIG_LV_MEM_SIZE_KILOBYTES (ignored; the pool silently falls back to 64 KB) |
The lock pins lvgl 9.6.0. Use lv_obj_set_hidden/clickable/scrollable and CONFIG_LV_MEM_SIZE=131072. Treat every deprecation warning as a bug. |
| LVGL 8 API in an LVGL 9 project | lv_disp_drv_t, lv_obj_set_style_local_*, lv_btn_*, LV_STATE_PRESSED as a style selector arg |
The template pins LVGL ^9.2. Check the symbol exists in managed_components/lvgl__lvgl/src/ before using it. |
| Guessed panel init sequences | A plausible ST7789 table, or an extra esp_lcd_panel_invert_color(true) on top of the table’s 0x21 |
Use ui_panel_init_cmds.c unchanged. Invert once. |
Non-existent CONFIG_ symbols |
CONFIG_LVGL_... (the real prefix is CONFIG_LV_), CONFIG_SPIRAM_USE_MALLOC variations from other IDF versions |
Grep sdkconfig after a build; an unknown symbol in sdkconfig.defaults is silently ignored. |
| Quadrature assumptions | PCNT setup, rotary_encoder component |
The knob is two switches. Use knob_decode.c. |
| Driving GPIO0 | “Set the I2S mux to the S3 side” | Never. The on-board pull-up already selects the S3. |
| Blocking inside LVGL callbacks | HTTP GET inside a button event | Post to the network task; update the UI from a callback under the lock. |
| Whole-file buffers | malloc(file_size) before upload |
Stream through one 16 KB chunk; see networking-under-memory-constraints. |
| Wrong IDF version idioms | Legacy driver/i2c.h instead of driver/i2c_master.h; esp_netif_sntp missing on old IDF |
The template is IDF 5.3; use the new I2C master driver and LEDC APIs as written there. |
scripts/check-board-pins.sh — no GPIO literals outside components/board.grep -rn "GPIO_NUM_0\b\|gpio_num = 0\b" components main — nothing drives GPIO0.idf.py build in Docker — zero warnings in your own components.scripts/test.sh — host tests pass for every pure module you touched.E ( lines, I2C scan shows 0x15 and 0x5A, heap log shows internal
minimum-free above your floor.dependencies.lock with a real version.| Thing | Value |
|---|---|
| CPU | 2× Xtensa LX7, 240 MHz |
| Internal heap after Wi-Fi init | roughly 250–300 KB; log it |
| PSRAM | 8 MB octal, 80 MHz |
| LVGL draw buffer | 2 × 28.8 KB internal (40 rows) |
| LVGL object pool | 128 KB, in PSRAM |
| Panel clock | 80 MHz QSPI |
| Knob poll | 3 ms, debounce 2 samples (~6 ms) |
| TCP send/receive window | 16 KB each (CONFIG_LWIP_TCP_SND_BUF_DEFAULT, _WND_DEFAULT) |
| Streaming chunk | 16 KB in PSRAM |
| FreeRTOS tick | 1000 Hz |