Learning goal
The GFX task owns everything; IPC comes up before the display; and there is one clean proof that the first frame was flushed. By the end you can name the step at which a CM55 boot stopped from the LED alone, and you know why a display failure does not take the IPC link with it.
The real firmware sequence
Two files are readable in the template — proj_cm55/main.c and the consumer code it hands off to. The display controller between them is compiled into lib/cm55_core/…/libbento_cm55.a and ships without source; its public surface is tesaiot_display.h. The order inside the archive described below is the contract the archive keeps, also stated in lib/cm55_core/consumer_must_provide.txt:64-65 and bento_libs/claw/APIs/08_IPC_API.md:714-716 — state this plainly: a template consumer cannot read the code behind it.
- main() — cybsp_init(), __enable_irq(); failure is LED1 at 50 ms (proj_cm55/main.c:180-188).
- tesaiot_display_init() — it creates the GFX task and nothing else; it is not an init. The task runs tesaiot_display_task() with the name, stack size and priority the header defines (GFX_TASK_NAME = "TESAIoT Gfx Task", GFX_TASK_PRIORITY = configMAX_PRIORITIES - 1, tesaiot_display.h:42-44), and the handle is stored in rtos_cm55_gfx_task_handle. The return value is the xTaskCreate result, so compare against pdPASS, not CY_RSLT_SUCCESS; failure is LED2 at 50 ms:
int main(void)
{
cy_rslt_t result;
#if TESAIOT_ENABLE_FACE_RUNTIME && TESAIOT_ENABLE_FACE_RUNTIME_BOOT
if (face_mode_runtime_requested()) {
face_mode_launch_runtime();
}
#endif
result = cybsp_init();
if (CY_RSLT_SUCCESS != result)
{
for (;;) {
Cy_GPIO_Inv(CYBSP_USER_LED1_PORT, CYBSP_USER_LED1_PIN);
Cy_SysLib_Delay(50);
}
}
__enable_irq();
s_scb5_boot_held = ipc_scb5_lock_wait_us(SCB5_BOOT_LOCK_WAIT_US);
if (pdPASS != xTaskCreate(scb5_boot_release_task, "scb5_boot",
configMINIMAL_STACK_SIZE * 2U, NULL,
tskIDLE_PRIORITY + 1U, NULL)) {
if (s_scb5_boot_held) {
ipc_scb5_unlock();
s_scb5_boot_held = false;
}
}
if (s_scb5_boot_held) {
disp_i2c_bus_clear();
}
if (pdPASS != xResult) {
for (;;) {
Cy_GPIO_Inv(CYBSP_USER_LED2_PORT, CYBSP_USER_LED2_PIN);
Cy_SysLib_Delay(50);
}
}
Before the scheduler starts, main() also clears a display I2C bus held from before a warm reset (disp_i2c_bus_clear()), takes the cross-core SCB5 lock for the display bring-up (ipc_scb5_lock.h; the scb5_boot task gives it back once the display is ready) and creates app_task.
- GFX task entry: the IPC pipe FIRST — the first thing the GFX task does is call cm55_ipc_communication_setup(), which runs Cy_IPC_Pipe_Init(), so IPC works even if every display step after it fails. The implementation of that function ships as source:
void cm55_ipc_communication_setup(void)
{
static const cy_stc_ipc_pipe_config_t cm55_ipc_pipe_config =
{
{
.ipcNotifierNumber = CY_IPC_INTR_CYPIPE_EP2,
.ipcNotifierPriority = CY_IPC_INTR_CYPIPE_PRIOR_EP2,
.ipcNotifierMuxNumber = CY_IPC_INTR_CYPIPE_MUX_EP2,
.epAddress = CM55_IPC_PIPE_EP_ADDR,
{
.epChannel = CY_IPC_CHAN_CYPIPE_EP2,
.epIntr = CY_IPC_INTR_CYPIPE_EP2,
.epIntrmask = CY_IPC_CYPIPE_INTR_MASK
}
},
{
.ipcNotifierNumber = CY_IPC_INTR_CYPIPE_EP1,
.ipcNotifierPriority = CY_IPC_INTR_CYPIPE_PRIOR_EP1,
.ipcNotifierMuxNumber = CY_IPC_INTR_CYPIPE_MUX_EP1,
.epAddress = CM33_IPC_PIPE_EP_ADDR,
{
.epChannel = CY_IPC_CHAN_CYPIPE_EP1,
.epIntr = CY_IPC_INTR_CYPIPE_EP1,
.epIntrmask = CY_IPC_CYPIPE_INTR_MASK
}
},
.endpointClientsCount = CY_IPC_CYPIPE_CLIENT_CNT,
.endpointsCallbacksArray = ep2_cb_array,
.userPipeIsrHandler = &Cy_SysIpcPipeIsrCm55
};
Cy_IPC_Pipe_Config(cm55_ipc_pipe_array);
Cy_IPC_Pipe_Init(&cm55_ipc_pipe_config);
{
cy_stc_sysint_t ep2_intr_cfg = {
.intrSrc = (IRQn_Type)CY_IPC_INTR_CYPIPE_MUX_EP2,
.intrPriority = (uint32_t)CY_IPC_INTR_CYPIPE_PRIOR_EP2
};
(void)Cy_SysInt_Init(&ep2_intr_cfg, &Cy_SysIpcPipeIsrCm55);
NVIC_EnableIRQ((IRQn_Type)CY_IPC_INTR_CYPIPE_MUX_EP2);
The rule, verbatim from 08_IPC_API.md:708: "cm55_ipc_communication_setup()
must be called before any Cy_IPC_Pipe_RegisterCallback(). Without this,
RegisterCallback accesses uninitialized pipe endpoints, causing a HardFault
at boot." The fault table at :765-771 gives the symptom and the fix: "Always call cm55_ipc_communication_setup() first in GFX task."* This function is consumer-provided (consumer_must_provide.txt) — you own the call. Never call it twice (wifi_manager.c:65).
- IPC clients register — ipc_sensorhub_init() (header contract ipc_sensorhub.h:58: "Must be called AFTER
cm55_ipc_communication_setup()."), ipc_service_init() (ipc_service.h:19), then deepcraft_task_init() under BENTO_HAS_MODEL_LINK — the model-link peer, which also registers after the pipe setup (deepcraft_task.h:24). 5-10. Six LED-coded display steps, in order: 1 GFXSS, 2 DC interrupt, 3 GPU interrupt, 4 display I2C controller, 5 panel controller, 6 VGLite GPU. If display step N fails, the task records N, skips the rest of the display work and goes straight to the IPC-only path (step 17); LED2 then repeats N quick blinks (see Step 1 below). Display step 5 (panel) comes after display step 1 (GFXSS) because the panel controller at I2C address 0x45 answers only once GFXSS/DSI has powered the display.
- LVGL init — lv_init(), then the display port lv_port_disp_init() and the touch input port lv_port_indev_init(). lv_port_disp_init is template source (proj_cm55/modules/lvgl_display/core/lv_port_disp.c:267-285): one display, full render mode, disp_flush as the flush callback. The LVGL tick comes from the FreeRTOS tick hook (proj_cm55/main.c:53-62 → tesaiot_display_tick() → lv_tick_inc(1)).
- Audio, then the deterministic backlight — bento_audio_init() (bento_audio.h), a 1000 ms codec PLL settle, then one panel power-up through ws_panel_power_up() (ws_panel_power.h). A NAKed panel-MCU write is 4 LED2 blinks.
- UI creation — the handoff to consumer code: the active screen is filled dark blue 0x003366 and passed to sensorhub_ui_init().
- Page manager init + registration — template source, proj_cm55/modules/page-components/_core/sensorhub_ui.c: tesaiot_ui_styles_init(); pm_init(&s_pm); pm_set_instance(&s_pm); then the pm_register calls, one per page. The Edge AI registration is the exemplar (guarded BENTO_HAS_EDGE_AI; the page manager owns the lifecycle):
#if defined(BENTO_HAS_EDGE_AI) && (BENTO_HAS_EDGE_AI == 1)
{
page_def_t def = {
.name = "Edge AI",
.subtitle = "On-device inference",
.accent_color = UI_COLOR_ACCENT_PURPLE,
};
pm_register(&s_pm, PAGE_ID_EDGE_AI, &def);
}
- Home page created and loaded — page_home_create() then lv_screen_load(home_scr) (sensorhub_ui.c, end of sensorhub_ui_init); each card is bound to its page_id_t at page_home.c:622-625.
- 33 ms render timer — lv_timer_create(sensorhub_timer_cb, 33, NULL); the callback takes one snapshot and fans it out (Chapter B3).
- IPC-only path, deferred binding, ready flag — every path ends here, including a display step failure: ipc_lcd_init(NULL) unconditionally, ipc_ui_init(NULL) only when the display came up, then tesaiot_display_ready is set to 1. Both take NULL — the Playground page binds the real containers later with ipc_lcd_set_container() / ipc_ui_set_container() (ipc_lcd.h, ipc_ui.h; Chapter B3). app_task waits on that flag, bounded:
static void app_task(void *arg)
{
CY_UNUSED_PARAMETER(arg);
uint32_t wait_count = 0;
{
vTaskDelay(pdMS_TO_TICKS(100));
wait_count++;
if (wait_count > 150) {
break;
}
}
- First frame — the GFX loop calls lv_timer_handler(), which invokes disp_flush; the DC interrupt completes the frame and notifies the GFX task:
static void LV_ATTRIBUTE_FAST_MEM disp_flush(lv_display_t *disp_drv,
const lv_area_t *area,
uint8_t *color_p)
{
CY_UNUSED_PARAMETER(area);
s_flush_disp = disp_drv;
s_flush_start_tick = xTaskGetTickCount();
s_flush_start_count++;
Cy_GFXSS_Set_FrameBuffer((GFXSS_Type*) GFXSS, (uint32_t*) color_p,
&gfx_context);
}
void lv_port_disp_flush_ready(void)
{
lv_display_t *d = (lv_display_t *)s_flush_disp;
s_flush_disp = NULL;
if (d != NULL) {
s_flush_ready_count++;
lv_display_flush_ready(d);
with a 500 ms missed-IRQ safety net that force-completes a flush:
void lv_port_disp_check_flush_timeout(void)
{
lv_display_t *d = (lv_display_t *)s_flush_disp;
if (d != NULL) {
TickType_t elapsed = xTaskGetTickCount() - s_flush_start_tick;
if (elapsed >= FLUSH_TIMEOUT_TICKS) {
s_flush_disp = NULL;
lv_display_flush_ready(d);
Step-by-step
Step 1 — Watch LED2 through bring-up
Power-cycle and watch LED2 only.
- What you should observe
- One blink then solid ON while Cy_GFXSS_Init runs (display step 1), OFF when it returns; then OFF for good once display init has fully succeeded. If instead LED2 settles into N quick blinks every 2 s, bring-up stopped at step N and the task is in the IPC-only loop, still serving IPC. Four blinks is the backlight NAK (step 12).
Step 2 — Watch the screen
- What you should observe
- A solid dark-blue 0x003366 fill (step 13) replaced within a frame or two by the Home card grid on its dark shell (page_home.c:366-373), with the version badge. On a board that stops at step 13 you would see only the blue fill; that state has not been observed on shipped firmware and has no recipe.
Step 3 — Prove the first frame (per variant)
- What you should observe — both variants
- LED2 OFF and the Home grid visible. That is the human-eye proof.
- What you should observe — mtb-mpy only
- At the REPL, ui._diag() (modui.c:1470) returns the GFX task's own counters via ipc_ui_platform_diag(), which the display controller in libbento_cm55.a implements: DC IRQ status, flush_start_count, flush_ready_count, flush_timeout_count, GFX stack high-water mark, idle percent. flush_start_count incrementing between two calls is the cleanest "first frame worked" assertion on this variant — it is disp_flush being entered (lv_port_disp.c:157). A non-zero flush_timeout_count means the 500 ms safety net fired: the DC interrupt was missed at least once.
- What you should observe — mtb-only
- There is no REPL, so no ui._diag(). The programmatic equivalent is a C-side call to ipc_ui_platform_diag(out, max_words) with max_words >= 10 from your own CM55 code (it returns the same ten words). Otherwise: LED2 OFF plus the Home grid.
Step 4 — Prove IPC survives a display failure
Do not induce one. Reason from the sequence above instead: ipc_lcd_init(NULL) is on the IPC-only path and is not conditional on the display having come up; ipc_ui_init(NULL) is, because it creates an LVGL timer (ipc_ui.h:20-21). tesaiot_display_ready therefore becomes 1 even when the panel never came up, and app_task proceeds.
- What you should observe
- On mtb-mpy, on a board with a display step failure (LED2 N-blinks), the console still works and import ui still succeeds — the pipe was set up at step 3, before anything display-related. tesaiot_display_ready's tri-state (proj_cm55/main.c:138: 0=pending, 1=display+IPC, 2=IPC-only
(headless)) is why consumers test == 0, never == 1.
Traps
- HardFault if step 3 is reversed. RegisterCallback before cm55_ipc_communication_setup() — 08_IPC_API.md:708, :765-771. The observable is LED1+LED2 in groups of three (HardFault, proj_cm55/main.c:246-254) and 0xDEAD0003 at 0x28000000.
- 08_IPC_API.md:711-717 prescribes an order the code does not follow. It lists ipc_lcd_init(parent) third and ipc_service_init() fourth with real parents; the shipped task calls ipc_service_init() at step 4 and both ipc_lcd_init(NULL)/ipc_ui_init(NULL) at step 17 with NULL — deliberate deferred binding. Only rule #1 (setup first) is load-bearing.
- Appendix X #1 — CM55 printf is a no-op by default (weak, in proj_cm55/bento_cm55_stdio.c). Do not add prints to trace this sequence; use LED2 or ui._diag().
- Appendix X #13 — unbounded I2C timeouts on the shared display bus. The GFX task runs at configMAX_PRIORITIES - 1; a wedged clock-stretching device on the touch/panel bus with a block-forever timeout starves every lower task (cm55_sensor_poll.c:49-53).
- Appendix X #14 — PAGE_ID ordinals are ABI. Step 14's registrations use page_id_t values baked into libbento_ipc.a; renumbering compiles and misbehaves (page_id_ordinal_assert.c:22-25).
- Console ban. CM33_NS: Booting CM55... is BOOT_VERBOSE. Do not use it as the "CM55 was released" marker; the Home screen is.
- ui._diag() on mtb-only. It does not exist there. A tutorial step that requires it is an mtb-mpy step.
Variant applicability
- Variant
- mtb-mpy and mtb-only. CM55 is variant-agnostic — steps 1-18 are the same image. The Verify split is in Step 3: ui._diag() is a MicroPython REPL call and exists only on mtb-mpy; mtb-only verifies with LED2 OFF, the Home grid, or a C-side ipc_ui_platform_diag() call.