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:
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()| Function | When it's called | What you do |
|---|---|---|
pli_get_tick | Every 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_message | When 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_write | Only 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_write | Only 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:
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.
/* 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:
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:
#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
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
- What exactly is in the generated file set? Generated code.
- Pick a validated target: Hardware targets.