Guides·02

Runtime & HAL

Port the C99 generated by Protok Studio to your MCU. The HAL is four weak functions and one receive hook — everything else runs on LVGL timers.

Protok Studio generates portable C99 that compiles on any C99 toolchain. The only platform-specific code you write lives in one file: pli_hal.c. It ships with weak stubs for every function, so your project compiles and links out of the box — you override only the hooks your hardware needs.

The HAL: pli_hal.h

The entire hardware abstraction is four functions and two optional macros:

c · pli_hal.h
uint32_t pli_get_tick(void);
void pli_can_send_message(uint32_t bus, uint32_t id,
                          const uint8_t *data, uint8_t dlc);
uint32_t pli_nvm_read(uint32_t addr, void* data, uint32_t size);
uint32_t pli_nvm_write(uint32_t addr, const void* data, uint32_t size);

/* DPL (PHMI exclusive) — only if DPL is enabled in Project Settings */
void pli_dpl_write(const uint8_t *data, uint32_t len);

/* Optional — default is empty (no-op) */
#define PLI_ENTER_CRITICAL()
#define PLI_EXIT_CRITICAL()
FunctionWhen it's calledWhat you do
pli_get_tickEvery LVGL timer tick — the generated logic uses it for timing.Return a 1 ms incrementing tick (e.g. from a hardware timer or HAL_GetTick).
pli_can_send_messageWhen a CAN_TX block or widget binding packs a frame for the bus.Push the frame to your CAN peripheral's TX FIFO. bus selects the controller if you have more than one.
pli_nvm_read / pli_nvm_writeOnly if your project uses NVM read/write nodes for persistent values.Read/write your flash or EEPROM. Return 0 on success. Skip if you have no NVM nodes — the weak stubs already return 0.
pli_dpl_writeOnly if DPL is enabled — the DPL module streams binary log records through this hook.Write the bytes to your log sink (UART, file, flash partition). PHMI exclusive. Skip if DPL is off — no stub is called.

Weak stubs = compiles immediately

pli_hal.c provides PLI_WEAK stubs for all four functions. The project links and runs with zero porting effort — widgets render, the simulator logic works. You then replace stubs one at a time as you bring up each piece of hardware.

CAN receive: the one hook you call

The generated code does not poll the bus. When a CAN frame arrives, your driver calls one function from pli_can.h:

c · pli_can.h
bool pli_can_sae1939_process_frame(uint32_t bus, uint32_t id,
                                      const uint8_t *data, uint8_t dlc);

This is ISR-safe: it just enqueues the frame into a ring buffer. A generated LVGL timer (pli_can_lvgl_worker, runs every 5 ms in the LVGL task context) drains the queue, decodes signals using the bit positions and scaling from your DBC, and updates the bound widgets and logic inputs. You never call LVGL from your ISR — the timer handles that.

c · your CAN RX path
/* From your CAN RX interrupt or polling loop: */
void my_can_rx_isr(uint32_t id, const uint8_t *data, uint8_t dlc) {
    pli_can_sae1939_process_frame(0, id, data, dlc);  /* bus 0 */
}

Execution model: it's all LVGL timers

There is no separate runtime loop or step() function. When you call pli_can_init() once at startup, it registers a set of lv_timers that do everything:

  • 5 ms — pli_can_lvgl_worker: drains the CAN RX queue, decodes signals, updates widgets.
  • 20 ms — pli_update_nodes: evaluates logic nodes (if your project has any).
  • 50 ms — logic comparisons, math/cscript/delay nodes, watchdogs, screen switches, animation triggers (only the ones your project uses).
  • 100 ms — pli_ui_update_bound_objects: refreshes bound widget properties.

All of these run inside lv_timer_handler(), so they are always in the LVGL thread context and safe to call LVGL APIs.

Your main loop

That makes the firmware main loop trivial:

c · main.c
int main(void) {
    /* your hardware init */
    board_init();
    lv_init();
    setup_display();     /* your LVGL display driver */
    setup_input();       /* your LVGL touch/encoder driver */

    ui_init();           /* generated: builds screens + widgets */
    pli_can_init();      /* generated: registers LVGL timers + CAN worker */

    while (1) {
        lv_tick_inc(1);          /* 1 ms tick — or call from a timer ISR */
        lv_timer_handler();      /* runs all pli_* timers + LVGL render */
        /* sleep / WFI if you want low power */
    }
}

Critical sections (optional)

The CAN RX queue is written from your ISR and read from the LVGL timer. If your platform can actually race them (preemptive RTOS, dual-core), define the two macros in your build to mask interrupts or take a mutex:

c · compile-time override
#define PLI_ENTER_CRITICAL()  __disable_irq()
#define PLI_EXIT_CRITICAL()   __enable_irq()

On bare-metal single-thread designs where pli_can_sae1939_process_frame runs from the same context as the timer, the default no-op macros are fine.

LVGL display & input drivers

The generated ui_screens.c uses standard LVGL 9.6 APIs, so you provide the same display and input drivers as any LVGL project: a lv_display_t with a flush callback that writes to your panel, and an lv_indev_t for touch or encoder. See Hardware targets for validated reference drivers.

LVGL version

Protok generates code for LVGL 9.6.0. Older LVGL releases lack APIs the generated UI calls — match your LVGL version.

Licensing of the output

  • Generated C99 is yours — 100% ownership, no runtime royalties, no vendor lock-in.
  • LVGL is MIT licensed — free for any use.
  • The pli_* runtime files are generated into your project and compile with your code — no separate runtime library to link.

What's next