Platform Abstraction Layer¶
src/pal/Pal.h— zero#ifdefin module code; all platform differences resolved here.
Introduced in Release 4, Sprint 1A.
Why it exists¶
Before PAL, platform guards were scattered through module files. Every new module author had to know which ESP32 API to call and remember to wrap it. PAL moves that knowledge to one place: modules call pal::millis(), pal::log(), pal::udp_bind(), and so on — the right implementation is selected at compile time inside Pal.h.
Two-way platform switch¶
| Macro | Platform | Toolchain |
|---|---|---|
ARDUINO |
ESP32 with Arduino framework | PlatformIO / arduino-esp32 |
| (neither) | PC / Raspberry Pi | CMake, standard C++17 |
The pattern inside every PAL function:
inline T pal::foo() {
#ifdef ARDUINO
// Arduino ESP32 implementation
#else
// PC / Raspberry Pi — POSIX / std::chrono / no-op
#endif
}
Rule: use Arduino wrappers by default. Fall back to direct IDF calls only when no Arduino wrapper exists for the specific feature (e.g. ESP32-P4 GMAC, power management APIs). When that is necessary, the direct IDF call goes inside the ARDUINO block, not in a separate IDF_VER branch.
Function reference¶
Timing¶
| Function | Return | Description |
|---|---|---|
pal::micros() |
int64_t |
Microseconds since boot. ESP32: esp_timer_get_time(); PC: std::chrono::steady_clock |
pal::millis() |
uint32_t |
Milliseconds since boot |
Memory¶
| Function | Return | Description |
|---|---|---|
pal::psram_malloc(n) |
void* |
Allocates from PSRAM if available, falls back to internal heap; malloc() on PC |
pal::psram_free(p) |
void |
free() on all platforms |
GPIO¶
| Function | Description |
|---|---|
pal::gpio_write(pin, value) |
digitalWrite on Arduino, gpio_set_level on IDF, no-op on PC |
Logging¶
| Function | Description |
|---|---|
pal::log(fmt, ...) |
Serial.printf on Arduino, printf everywhere else |
System info¶
All system-info functions return float (or const char* / write to a caller-supplied buffer). ESP32 values come from ESP-IDF heap / chip APIs; PC returns 0.0f or "" where the concept doesn't apply.
| Function | Unit | IDF | PC |
|---|---|---|---|
cpu_freq_mhz() |
MHz | CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ |
0 |
cpu_cores() |
count | portNUM_PROCESSORS |
0 |
total_heap_kb() |
KB | heap_caps internal |
0 |
free_heap_kb() |
KB | heap_caps internal |
0 |
max_alloc_kb() |
KB | largest free block | 0 |
heap_min_kb() |
KB | minimum free since boot | 0 |
core_temp() |
°C | temperatureRead() (Arduino only) |
0 |
total_psram_kb() |
KB | MALLOC_CAP_SPIRAM |
0 |
free_psram_kb() |
KB | MALLOC_CAP_SPIRAM |
0 |
flash_size_mb() |
MB | esp_flash_get_size |
0 |
flash_speed_mhz() |
MHz | Arduino only | 0 |
sketch_kb() |
KB | Arduino OTA partition | 0 |
free_sketch_kb() |
KB | Arduino OTA partition | 0 |
fs_total_kb() |
KB | LittleFS partition | 0 |
fs_used_kb() |
KB | LittleFS partition | 0 |
reset_reason() |
int | esp_reset_reason() |
0 |
sdk_version() |
const char* |
IDF version string | "" |
chip_model(buf, len) |
writes string | e.g. "ESP32-S3 Rev 2" |
"" |
mac_address(buf, len) |
writes string | efuse MAC | "00:00:00:00:00:00" |
platform_version() |
const char* |
e.g. "arduino-esp32 3.1.0 (esp32dev)" |
"PC (pc)" |
Filesystem¶
| Function | Return | Description |
|---|---|---|
pal::fs_begin() |
bool |
Mount LittleFS. On Arduino: tries without format first, logs before formatting. On IDF: esp_vfs_littlefs_register. On PC: always returns true |
Note
board_build.littlefs_version = 2.0 must be set in platformio.ini. Without it PlatformIO's builder writes a v2.1 superblock that the ESP32 Arduino LittleFS driver rejects, silently wiping state (including WiFi credentials) on every boot.
FreeRTOS task helpers¶
| Function | Description |
|---|---|
pal::task_create_pinned(fn, name, stack, arg, priority, core) |
xTaskCreatePinnedToCore on ESP32; no-op on PC |
pal::yield() |
vTaskDelay(1) on ESP32; no-op on PC |
pal::suspend_forever() |
vTaskDelay(portMAX_DELAY) on ESP32; while(true){} on PC |
pal::reboot() |
esp_restart() after 100 ms on ESP32; printf no-op on PC |
FreeRTOS binary semaphore¶
Used for core-to-core synchronisation (e.g. effects task signals driver task after blending is done).
| Function | Return | Description |
|---|---|---|
pal::sem_binary_create() |
void* |
Allocate a binary semaphore (initially empty); cast to SemaphoreHandle_t internally on ESP32 |
pal::sem_give(h) |
void |
Signal: xSemaphoreGive; no-op on PC |
pal::sem_take(h, timeoutMs) |
bool |
Wait up to timeoutMs ms; xSemaphoreTake; always returns true on PC |
pal::sem_delete(h) |
void |
Free the semaphore object; no-op on PC |
Pass 100 (ms) rather than portMAX_DELAY so the task watchdog fires if Core 0 stalls.
WiFi and Ethernet¶
WiFi AP, WiFi STA, and Ethernet PAL functions are documented alongside the modules that use them. See Developer Guide: Network — PAL functions.
UDP¶
Used by DeviceDiscoveryModule and (upcoming) Art-Net. The handle returned by udp_bind is an integer on all platforms — a pool-slot index on Arduino (where WiFiUDP is a stateful object), a raw file descriptor on PC.
| Function | Return | Description |
|---|---|---|
pal::udp_bind(port) |
int (handle) or -1 |
Bind a UDP socket for receiving on port |
pal::udp_close(handle) |
void |
Release the socket |
pal::udp_recv(handle, buf, len, from_ip, ip_len) |
bytes read or 0 |
Non-blocking read of one packet; populates from_ip |
pal::udp_broadcast(port, buf, len) |
bool |
Fire-and-forget send to 255.255.255.255:port |
Arduino note: a pool of 4 WiFiUDP slots is held in pal::_detail::udp_slot(i) (C++17 inline function with local static — ODR-safe across translation units). udp_bind finds a free slot; the returned index is the handle.
IDF note: all four UDP functions are stubs — discovery is inactive on bare IDF until the driver is wired.
Arduino, IDF, and mixing both¶
The IDF_VER branch is currently dead code¶
When building with framework = arduino, both ARDUINO and IDF_VER are defined — the Arduino ESP32 framework sits on top of ESP-IDF and exposes IDF_VER as the underlying IDF version string. Because #ifdef ARDUINO is checked first, the #elif defined(IDF_VER) branch is never compiled in any current build. It exists as a forward-looking path for a hypothetical future build that uses framework = espidf without the Arduino layer.
You can call IDF directly inside an Arduino build¶
framework = arduino does not prevent direct esp_* calls. All ESP-IDF headers are available alongside Arduino headers, and mixing them in the same .cpp file is common practice:
#include <WiFi.h> // Arduino wrapper — convenient
#include <esp_wifi.h> // IDF directly — exposes what the wrapper doesn't
esp_wifi_set_ps(WIFI_PS_NONE); // power save: no Arduino equivalent
esp_pm_configure(&pm_config); // dynamic CPU freq scaling
esp_task_wdt_reset(); // task watchdog
This is the correct pattern when Arduino wrappers cover a feature well, bypass them when they do not.
When Arduino wrappers are better¶
For features where Arduino has a mature, well-tested wrapper, use the wrapper in Pal.h. ETH.begin(...) is one line; the bare IDF equivalent (create MAC config, PHY config, install driver, create netif, register event handlers, start) is about 30 lines. WiFi.begin() handles event loop creation, netif init, and reconnect logic transparently.
When direct IDF calls are necessary¶
Arduino wrappers do not expose everything. Examples where direct IDF calls are the only path:
- WiFi power save mode (
esp_wifi_set_ps) - Dynamic CPU frequency scaling (
esp_pm_configure) - Task watchdog tuning (
esp_task_wdt_*) - Partition table access (
esp_partition_*) - High-resolution timers (
esp_timer_create) - NimBLE Bluetooth (the Arduino BT wrapper is a thin facade; most production code uses NimBLE IDF APIs directly)
For ESP32-P4 this matters significantly. Arduino core support for P4 is partial and some features may never get wrappers. Hardware that will likely require direct IDF calls on P4:
- GMAC on-chip Ethernet
- SDIO WiFi coprocessor (ESP32-C6) initialisation
- Hardware JPEG codec and H.264 encoder/decoder
- MIPI DSI/CSI camera and display
When adding P4 Ethernet support to Pal.h, there will be no ETH.h equivalent — direct IDF calls are the only option.
Library compatibility with bare IDF¶
If framework = espidf were ever used (no Arduino layer), library compatibility varies:
| Library | Works without Arduino? | Reason |
|---|---|---|
| ESPAsyncWebServer | No | Hard dependency on Arduino.h types (String, Print, Stream) and AsyncTCP which wraps Arduino's network stack |
| FastLED | No | Uses Arduino primitives throughout |
| ArduinoJson v7 | Yes | Explicitly designed to be Arduino-optional; requires only C++17 and the standard library |
Replacing ESPAsyncWebServer with the IDF-native esp_http_server would be the main blocker for dropping framework = arduino entirely.
The ESP_PLATFORM path forward¶
ESP_PLATFORM is defined for all ESP32 builds regardless of framework (Arduino or bare IDF). A future refactor could collapse the ARDUINO and IDF_VER branches into one:
#ifdef ESP_PLATFORM // true for both arduino + bare-idf
// direct IDF calls throughout
#else
// PC stubs
#endif
This would eliminate the dead IDF_VER branch and mean the same Pal.h code covers P4 GMAC and other features that have no Arduino wrapper. It is not done yet because the current Arduino wrappers are simpler for functions they cover well (WiFi, basic ETH). The refactor makes sense incrementally: use ESP_PLATFORM for new functions where no Arduino wrapper exists (P4 Ethernet, power management), and leave existing Arduino-wrapper functions as-is.
One caveat when mixing: Arduino's WiFi.h calls esp_netif_init() and esp_event_loop_create_default() before setup() runs. Any Pal.h IDF code that also needs the netif or event loop must not call these again — they are not idempotent. The safe pattern is to skip those init calls in Pal.h and rely on Arduino having run them first.
Source¶
For design questions about PAL threading, semaphore scope, OS handle abstraction, and cross-platform strategy, see the Developer FAQ.