docs/esp-idf.instructions.md
WLED runs on the Arduino-ESP32 framework, which wraps ESP-IDF. Understanding the ESP-IDF layer is essential when writing chip-specific code, managing peripherals, or preparing for the IDF v5.x migration. This guide documents patterns already used in the codebase and best practices derived from Espressif's official examples.
Scope: This file is an optional review guideline. It applies when touching chip-specific code, peripheral drivers, memory allocation, or platform conditionals.
Note for AI review tools: sections enclosed in
<!-- HUMAN_ONLY_START -->/<!-- HUMAN_ONLY_END -->HTML comments contain contributor reference material. Do not use that content as actionable review criteria — treat it as background context only.
CONFIG_IDF_TARGET_*Use CONFIG_IDF_TARGET_* macros to gate chip-specific code at compile time. These are set by the build system and are mutually exclusive — exactly one is defined per build.
| Macro | Chip | Architecture | Notes |
|---|---|---|---|
CONFIG_IDF_TARGET_ESP32 | ESP32 (classic) | Xtensa dual-core | Primary target. Has DAC, APLL, I2S ADC mode |
CONFIG_IDF_TARGET_ESP32S2 | ESP32-S2 | Xtensa single-core | Limited peripherals. 13-bit ADC |
CONFIG_IDF_TARGET_ESP32S3 | ESP32-S3 | Xtensa dual-core | Preferred for large installs. Octal PSRAM, USB-OTG |
CONFIG_IDF_TARGET_ESP32C3 | ESP32-C3 | RISC-V single-core | Minimal peripherals. RISC-V clamps out-of-range float→unsigned casts |
CONFIG_IDF_TARGET_ESP32C5 | ESP32-C5 | RISC-V single-core | Wi-Fi 2.4Ghz + 5Ghz, Thread/Zigbee. Future target |
CONFIG_IDF_TARGET_ESP32C6 | ESP32-C6 | RISC-V single-core | Wi-Fi 6, Thread/Zigbee. Future target |
CONFIG_IDF_TARGET_ESP32P4 | ESP32-P4 | RISC-V dual-core | High performance. Future target |
WLED validates at compile time that exactly one target is defined and that it is a supported chip (wled.cpp lines 39–61). Follow this pattern when adding new chip-specific branches:
#if defined(CONFIG_IDF_TARGET_ESP32)
// classic ESP32 path
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
// S3-specific path
#elif defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C5) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4)
// RISC-V common path
#else
#warning "Untested chip — review peripheral availability"
#endif
#elif chains over nested #ifdef for readability.CONFIG_IDF_TARGET_* for feature detection. Use SOC_* capability macros instead (see next section). For example, use SOC_I2S_SUPPORTS_ADC instead of CONFIG_IDF_TARGET_ESP32 to check for I2S ADC support.static_assert() or #warning directives so the build clearly reports what is missing.SOC_* MacrosSOC_* macros (from soc/soc_caps.h) describe what the current chip supports. They are the correct way to check for peripheral features — they stay accurate when new chips are added, unlike CONFIG_IDF_TARGET_* checks.
SOC_* macros used in WLED| Macro | Type | Used in | Purpose |
|---|---|---|---|
SOC_I2S_NUM | int | audio_source.h | Number of I2S peripherals (1 or 2) |
SOC_I2S_SUPPORTS_ADC | bool | usermods/audioreactive/audio_source.h | I2S ADC sampling mode (ESP32 only) |
SOC_I2S_SUPPORTS_APLL | bool | usermods/audioreactive/audio_source.h | Audio PLL for precise sample rates |
SOC_I2S_SUPPORTS_PDM_RX | bool | usermods/audioreactive/audio_source.h | PDM microphone input |
SOC_ADC_MAX_BITWIDTH | int | util.cpp | ADC resolution (12 or 13 bits). Renamed to CONFIG_SOC_ADC_RTC_MAX_BITWIDTH in IDF v5 |
SOC_ADC_CHANNEL_NUM(unit) | int | pin_manager.cpp | ADC channels per unit |
SOC_UART_NUM | int | dmx_input.cpp | Number of UART peripherals |
SOC_DRAM_LOW / SOC_DRAM_HIGH | addr | util.cpp | DRAM address boundaries for validation |
SOC_ADC_MAX_BITWIDTH (ADC resolution 12 or 13 bits) was renamed to CONFIG_SOC_ADC_RTC_MAX_BITWIDTH in IDF v5.
| Macro | Purpose |
|---|---|
SOC_RMT_TX_CANDIDATES_PER_GROUP | Number of RMT TX channels (varies 2–8 by chip) |
SOC_LEDC_CHANNEL_NUM | Number of LEDC (PWM) channels |
SOC_GPIO_PIN_COUNT | Total GPIO pin count |
SOC_DAC_SUPPORTED | Whether the chip has a DAC (ESP32/S2 only) |
SOC_SPIRAM_SUPPORTED | Whether PSRAM interface exists |
SOC_CPU_CORES_NUM | Core count (1 or 2) — useful for task pinning decisions |
// Good: feature-based detection
#if SOC_I2S_SUPPORTS_PDM_RX
_config.mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM);
#else
#warning "PDM microphones not supported on this chip"
#endif
// Avoid: chip-name-based detection
#if defined(CONFIG_IDF_TARGET_ESP32) || defined(CONFIG_IDF_TARGET_ESP32S3)
// happens to be correct today, but breaks when a new chip adds PDM support
#endif
For PSRAM presence, mode, and DMA access patterns:
| Macro | Meaning |
|---|---|
CONFIG_SPIRAM / BOARD_HAS_PSRAM | PSRAM is present in the build configuration |
CONFIG_SPIRAM_MODE_QUAD | Quad-SPI PSRAM (standard, used on ESP32 classic and some S2/S3 boards) |
CONFIG_SPIRAM_MODE_OCT | Octal-SPI PSRAM — 8 data lines, DTR mode. Used on ESP32-S3 with octal PSRAM (e.g. N8R8 / N16R8 modules). Reserves GPIO 33–37 for the PSRAM bus — do not allocate these pins when this macro is defined. wled.cpp uses this to gate GPIO reservation. |
CONFIG_SPIRAM_MODE_HEX | Hex-SPI (16-line) PSRAM — future interface on ESP32-P4 running at up to 200 MHz. Used in json.cpp to report the PSRAM mode. |
CONFIG_SOC_PSRAM_DMA_CAPABLE | PSRAM buffers can be used with DMA (ESP32-S3 with octal PSRAM) |
CONFIG_SOC_MEMSPI_FLASH_PSRAM_INDEPENDENT | SPI flash and PSRAM on separate buses (no speed contention) |
On ESP32-S3 modules with OPI flash (e.g. N8R8 modules where the SPI flash itself runs in Octal-PI mode), the build system sets:
| Macro | Meaning |
|---|---|
CONFIG_ESPTOOLPY_FLASHMODE_OPI | Octal-PI flash mode. On S3, implies GPIO 33–37 are used by the flash/PSRAM interface — the same GPIO block as octal PSRAM. wled.cpp uses CONFIG_ESPTOOLPY_FLASHMODE_OPI || (CONFIG_SPIRAM_MODE_OCT && BOARD_HAS_PSRAM) to decide whether to reserve these GPIOs. json.cpp uses this to report the flash mode string as "🚀OPI". |
CONFIG_ESPTOOLPY_FLASHMODE_HEX | Hex flash mode (ESP32-P4). Reported as "🚀🚀HEX" in json.cpp. |
Pattern used in WLED (from wled.cpp) to reserve the octal-bus GPIOs on S3:
#if defined(CONFIG_IDF_TARGET_ESP32S3)
#if CONFIG_ESPTOOLPY_FLASHMODE_OPI || (CONFIG_SPIRAM_MODE_OCT && defined(BOARD_HAS_PSRAM))
// S3: GPIO 33-37 are used by the octal PSRAM/flash bus
managed_pin_type pins[] = { {33, true}, {34, true}, {35, true}, {36, true}, {37, true} };
pinManager.allocateMultiplePins(pins, sizeof(pins)/sizeof(managed_pin_type), PinOwner::SPI_RAM);
#endif
#endif
#include <esp_idf_version.h>
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
// IDF v5+ code path
#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0)
// IDF v4.4+ code path
#else
// Legacy IDF v3/v4.x path
#endif
| Version | What changed |
|---|---|
| 4.0.0 | Filesystem API (SPIFFS/LittleFS), GPIO driver overhaul |
| 4.2.0 | ADC/GPIO API updates; esp_adc_cal introduced |
| 4.4.0 | I2S driver refactored (legacy API remains); adc_deprecated.h headers appear for newer targets |
| 4.4.4–4.4.8 | Known I2S channel-swap regression on ESP32 (workaround in audio_source.h) |
| 5.0.0 | Major breaking changes — RMT, I2S, ADC, SPI flash APIs replaced (see migration section) |
| 5.1.0 | Matter protocol support; new esp_flash API stable |
| 5.3+ | arduino-esp32 v3.x compatibility; C6/P4 support |
>= over exact version matches.// IDF 4.4.4–4.4.8 swapped I2S left/right channels (fixed in 4.4.9)
#if (ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 4)) && \
(ESP_IDF_VERSION <= ESP_IDF_VERSION_VAL(4, 4, 8))
#define I2S_CHANNELS_SWAPPED
#endif
The jump from IDF v4.4 (arduino-esp32 v2.x) to IDF v5.x (arduino-esp32 v3.x) is the largest API break in ESP-IDF history. This section documents the critical changes and recommended migration patterns based on the upstream WLED V5-C6 branch (https://github.com/wled/WLED/tree/V5-C6). Note: WLED has not yet migrated to IDF v5 — these patterns prepare for the future migration.
IDF v5.x ships a much newer GCC toolchain. Key versions:
| ESP-IDF | GCC | C++ default | Notes |
|---|---|---|---|
| 4.4.x (current) | 8.4.0 | C++17 (gnu++17) | Xtensa + RISC-V |
| 5.1–5.3 | 13.2 | C++20 (gnu++2b) | Significant warning changes |
| 5.4–5.5 | 14.2 | C++23 (gnu++2b) | Latest; stricter diagnostics |
Notable behavioral differences:
| Change | Impact | Action |
|---|---|---|
Stricter -Werror=enum-conversion | Implicit int-to-enum casts now error | Use explicit static_cast<> or typed enums |
| C++20/23 features available | consteval, concepts, std::span, std::expected | Use judiciously — ESP8266 builds still require GCC 10.x with C++17 |
-Wdeprecated-declarations enforced | Deprecated API calls become warnings/errors | Migrate to new APIs (see below) |
-Wdangling-reference (GCC 13+) | Warns when a reference binds to a temporary that will be destroyed | Fix the lifetime issue; do not suppress the warning |
-fno-common default (GCC 12+) | Duplicate tentative definitions across translation units cause linker errors | Use extern declarations in headers, define in exactly one .cpp |
| RISC-V codegen improvements | C3/C6/P4 benefit from better register allocation | No action needed — automatic |
The jump from GCC 8.4 to GCC 14.2 spans six major compiler releases. This section lists features that become available and patterns that need updating.
These work in GCC 13+/14+ but not in GCC 8.4. Guard with #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) if the code must compile on both IDF v4 and v5.
| Feature | Standard | Example | Benefit |
|---|---|---|---|
| Designated initializers (C++20) | C++20 | gpio_config_t cfg = { .mode = GPIO_MODE_OUTPUT }; | Already used as a GNU extension in GCC 8; becomes standard and portable in C++20 |
[[likely]] / [[unlikely]] | C++20 | if (err != ESP_OK) [[unlikely]] { ... } | Hints for branch prediction; useful in hot paths |
[[nodiscard("reason")]] | C++20 | [[nodiscard("leak if ignored")]] void* allocBuffer(); | Enforces checking return values — helpful for esp_err_t wrappers |
std::span<T> | C++20 | void process(std::span<uint8_t> buf) | Safe, non-owning view of contiguous memory — replaces raw pointer + length pairs |
consteval | C++20 | consteval uint32_t packColor(...) | Guarantees compile-time evaluation; useful for color constants |
constinit | C++20 | constinit static int counter = 0; | Prevents static initialization order fiasco |
Concepts / requires | C++20 | template<typename T> requires std::integral<T> | Clearer constraints than SFINAE; improves error messages |
Three-way comparison (<=>) | C++20 | auto operator<=>(const Version&) const = default; | Less boilerplate for comparable types |
std::bit_cast | C++20 | float f = std::bit_cast<float>(uint32_val); | Type-safe reinterpretation — replaces memcpy or union tricks |
if consteval | C++23 | if consteval { /* compile-time */ } else { /* runtime */ } | Cleaner than std::is_constant_evaluated() |
std::expected<T, E> | C++23 | std::expected<int, esp_err_t> readSensor() | Monadic error handling — cleaner than returning error codes |
std::to_underlying | C++23 | auto val = std::to_underlying(myEnum); | Replaces static_cast<int>(myEnum) |
These work on both IDF v4.4 and v5.x — prefer them now:
| Feature | Example | Notes |
|---|---|---|
if constexpr | if constexpr (sizeof(T) == 4) { ... } | Compile-time branching; already used in WLED |
std::optional<T> | std::optional<uint8_t> pin; | Nullable value without sentinel values like -1 |
std::string_view | void log(std::string_view msg) | Non-owning, non-allocating string reference |
| Structured bindings | auto [err, value] = readSensor(); | Useful with std::pair / std::tuple returns |
| Fold expressions | (addSegment(args), ...); | Variadic template expansion |
| Inline variables | inline constexpr int MAX_PINS = 50; | Avoids ODR issues with header-defined constants |
[[maybe_unused]] | [[maybe_unused]] int debug_only = 0; | Suppresses unused-variable warnings cleanly |
[[fallthrough]] | case 1: doA(); [[fallthrough]]; case 2: | Documents intentional switch fallthrough |
| Nested namespaces | namespace wled::audio { } | Shorter than nested namespace blocks |
| Pattern | GCC 8 behavior | GCC 14 behavior | Fix |
|---|---|---|---|
int x; enum E e = x; | Warning (often ignored) | Error with -Werror=enum-conversion | E e = static_cast<E>(x); |
int g; in two .cpp files | Both compile, linker merges (tentative definition) | Error: multiple definitions (-fno-common) | extern int g; in header, int g; in one .cpp |
const char* ref = std::string(...).c_str(); | Silent dangling pointer | Warning (-Wdangling-reference) | Extend lifetime: store the std::string in a local variable |
register int x; | Accepted (ignored) | Warning or error (register removed in C++17) | Remove register keyword |
| Narrowing in aggregate init | Warning | Error | Use explicit cast or wider type |
Implicit this capture in lambdas | Accepted in [=] | Deprecated warning; error in C++20 mode | Use [=, this] or [&] |
#if __cplusplus > 201703L to gate C++20 features.[[fallthrough]] — GCC 14 warns on unmarked fallthrough by default.std::optional over sentinel values (e.g., -1 for "no pin") in new code — it works on both compilers.std::string_view for read-only string parameters instead of const char* or const String& — zero-copy and works on GCC 8+.union type punning — prefer memcpy (GCC 8) or std::bit_cast (GCC 13+) for strict-aliasing safety.The legacy rmt_* functions are removed in IDF v5. Do not introduce new legacy RMT calls.
The new API is channel-based:
| IDF v4 (legacy) | IDF v5 (new) | Notes |
|---|---|---|
rmt_config() + rmt_driver_install() | rmt_new_tx_channel() / rmt_new_rx_channel() | Channels are now objects |
rmt_write_items() | rmt_transmit() with encoder | Requires rmt_encoder_t |
rmt_set_idle_level() | Configure in channel config | Set at creation time |
rmt_item32_t | rmt_symbol_word_t | Different struct layout |
WLED impact: NeoPixelBus LED output and IR receiver both use legacy RMT. The upstream V5-C6 branch adds -D WLED_USE_SHARED_RMT and disables IR until the library is ported.
Legacy i2s_driver_install() + i2s_read() API is deprecated. When touching audio source code, wrap legacy I2S init and reading in #if ESP_IDF_VERSION_MAJOR < 5 / #else.
The new API uses channel handles:
| IDF v4 (legacy) | IDF v5 (new) | Notes |
|---|---|---|
i2s_driver_install() | i2s_channel_init_std_mode() | Separate STD/PDM/TDM modes |
i2s_set_pin() | Pin config in i2s_std_gpio_config_t | Set at init time |
i2s_read() | i2s_channel_read() | Uses channel handle |
i2s_set_clk() | i2s_channel_reconfig_std_clk() | Reconfigure running channel |
i2s_config_t | i2s_std_config_t | Separate config for each mode |
Migration pattern (from Espressif examples):
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
#include "driver/i2s_std.h"
i2s_chan_handle_t rx_handle;
i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
i2s_new_channel(&chan_cfg, NULL, &rx_handle);
i2s_std_config_t std_cfg = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(22050),
.slot_cfg = I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_MONO),
.gpio_cfg = { .din = GPIO_NUM_32, .mclk = I2S_GPIO_UNUSED, ... },
};
i2s_channel_init_std_mode(rx_handle, &std_cfg);
i2s_channel_enable(rx_handle);
#else
// Legacy i2s_driver_install() path
#endif
WLED impact: The audioreactive usermod (audio_source.h) heavily uses legacy I2S. Migration requires rewriting the I2SSource class for channel-based API.
Legacy adc1_get_raw() and esp_adc_cal_* are deprecated:
| IDF v4 (legacy) | IDF v5 (new) | Notes |
|---|---|---|
adc1_config_width() + adc1_get_raw() | adc_oneshot_new_unit() + adc_oneshot_read() | Object-based API |
esp_adc_cal_characterize() | adc_cali_create_scheme_*() | Calibration is now scheme-based |
adc_continuous_* (old) | adc_continuous_* (restructured) | Config struct changes |
| IDF v4 (legacy) | IDF v5 (new) |
|---|---|
spi_flash_read() | esp_flash_read() |
spi_flash_write() | esp_flash_write() |
spi_flash_erase_range() | esp_flash_erase_region() |
WLED already has a compatibility shim in ota_update.cpp that maps old names to new ones.
| IDF v4 (legacy) | IDF v5 (recommended) |
|---|---|
gpio_pad_select_gpio() | esp_rom_gpio_pad_select_gpio() (or use gpio_config()) |
gpio_set_direction() + gpio_set_pull_mode() | gpio_config() with gpio_config_t struct |
The upstream V5-C6 branch explicitly disables features with incompatible library dependencies:
# platformio.ini [esp32_idf_V5]
-D WLED_DISABLE_INFRARED # IR library uses legacy RMT
-D WLED_DISABLE_MQTT # AsyncMqttClient incompatible with IDF v5
-D ESP32_ARDUINO_NO_RGB_BUILTIN # Prevents RMT driver conflict with built-in LED
-D WLED_USE_SHARED_RMT # Use new shared RMT driver for NeoPixel output
#else block.// TODO(idf5): so they are easy to find later.heap_caps_* Best PracticesESP32 has multiple memory regions with different capabilities. Using the right allocator is critical for performance and stability.
<!-- HUMAN_ONLY_START -->| Region | Flag | Speed | DMA | Size | Use for |
|---|---|---|---|---|---|
| DRAM | MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT | Fast | Yes (ESP32) | 200–320 KB | Hot-path buffers, task stacks, small allocations |
| IRAM | MALLOC_CAP_EXEC | Fastest | No | 32–128 KB | Code (automatic via IRAM_ATTR) |
| PSRAM (SPIRAM) | MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT | Slower | Chip-dependent | 2–16 MB | Large buffers, JSON documents, image data |
| RTC RAM | MALLOC_CAP_RTCRAM | Moderate | No | 8 KB | Data surviving deep sleep; small persistent buffers |
WLED provides convenience wrappers with automatic fallback. Always prefer these over raw heap_caps_* calls:
| Function | Allocation preference | Use case |
|---|---|---|
d_malloc(size) | RTC → DRAM → PSRAM | General-purpose; prefers fast memory |
d_calloc(n, size) | Same as d_malloc, zero-initialized | Arrays, structs |
p_malloc(size) | PSRAM → DRAM | Large buffers; prefers abundant memory |
p_calloc(n, size) | Same as p_malloc, zero-initialized | Large arrays |
d_malloc_only(size) | RTC → DRAM (no PSRAM fallback) | DMA buffers, time-critical data |
psramFound() before assuming PSRAM is present.d_malloc_only() to allocate DMA buffers in DRAM only. On ESP32-S3 with octal PSRAM (CONFIG_SPIRAM_MODE_OCT), PSRAM buffers can be used with DMA when CONFIG_SOC_PSRAM_DMA_CAPABLE is defined.PSRAMDynamicJsonDocument allocator (defined in wled.h) to put large JSON documents in PSRAM:
PSRAMDynamicJsonDocument doc(16384); // allocated in PSRAM if available
d_measureHeap() and d_measureContiguousFreeHeap() to monitor remaining DRAM. Allocations that would drop free DRAM below MIN_HEAP_SIZE should go to PSRAM instead.PSRAM access is up to 15× slower than DRAM on ESP32, 3–10× slower than DRAM on ESP32-S3/-S2 with quad-SPI bus. On ESP32-S3 with octal PSRAM (CONFIG_SPIRAM_MODE_OCT), the penalty is smaller (~2×) because the 8-line DTR bus can transfer 8 bits in parallel at 80 MHz (120 MHz is possible with CONFIG_SPIRAM_SPEED_120M, which requires enabling experimental ESP-IDF features). On ESP32-P4 with hex PSRAM (CONFIG_SPIRAM_MODE_HEX), the 16-line bus runs at 200 MHz which brings it on-par with DRAM. Keep hot-path data in DRAM regardless, but consider that ESP32 often crashes when the largest DRAM chunk gets below 10 KB.
When you need a buffer that works on boards with or without PSRAM:
// Prefer PSRAM for large buffers, fall back to DRAM
uint8_t* buf = (uint8_t*)heap_caps_malloc_prefer(bufSize, 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, // first choice: PSRAM
MALLOC_CAP_DEFAULT); // fallback: any available
// Or simply:
uint8_t* buf = (uint8_t*)p_malloc(bufSize);
The audioreactive usermod uses I2S for microphone input. Key patterns:
constexpr i2s_port_t AR_I2S_PORT = I2S_NUM_0;
// I2S_NUM_1 has limitations: no MCLK routing, no ADC support, no PDM support
Always use I2S_NUM_0 unless you have a specific reason and have verified support on all target chips.
DMA buffer size controls latency vs. reliability:
| Scenario | dma_buf_count | dma_buf_len | Latency | Notes |
|---|---|---|---|---|
| With HUB75 matrix | 18 | 128 | ~100 ms | Higher count prevents I2S starvation during matrix DMA |
| Without PSRAM | 24 | 128 | ~140 ms | More buffers compensate for slower interrupt response |
| Default | 8 | 128 | ~46 ms | Acceptable for most setups |
Choose interrupt priority based on coexistence with other drivers:
#ifdef WLED_ENABLE_HUB75MATRIX
.intr_alloc_flags = ESP_INTR_FLAG_IRAM | ESP_INTR_FLAG_LEVEL1, // level 1 (lowest) to avoid starving HUB75
#else
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL2 | ESP_INTR_FLAG_LEVEL3, // accept level 2 or 3 (allocator picks available)
#endif
The ESP32 has an audio PLL for precise sample rates. Rules:
SOC_I2S_SUPPORTS_APLL.#if !defined(SOC_I2S_SUPPORTS_APLL)
_config.use_apll = false;
#elif defined(WLED_USE_ETHERNET) || defined(WLED_ENABLE_HUB75MATRIX)
_config.use_apll = false; // APLL conflict
#endif
SOC_I2S_SUPPORTS_PDM_RX not defined).bits_per_sample.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT in PDM mode — this causes the S3 low-amplitude symptom.I2S_CKPIN = -1) triggers PDM mode in WLED.WLED uses the ESP32-HUB75-MatrixPanel-I2S-DMA library for HUB75 matrix output.
#if defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)
maxChainLength = 6; // S3 + PSRAM: up to 6 panels (DMA-capable PSRAM)
#elif defined(CONFIG_IDF_TARGET_ESP32S2)
maxChainLength = 2; // S2: limited DMA channels
#else
maxChainLength = 4; // Classic ESP32: default
#endif
The driver dynamically reduces color depth for larger displays to stay within DMA buffer limits:
| Pixel count | Color depth | Bits per pixel |
|---|---|---|
≤ MAX_PIXELS_10BIT | 10-bit (30-bit color) | High quality (experimental) |
≤ MAX_PIXELS_8BIT | 8-bit (24-bit color) | Full quality |
≤ MAX_PIXELS_6BIT | 6-bit (18-bit color) | Slight banding |
≤ MAX_PIXELS_4BIT | 4-bit (12-bit color) | Visible banding |
| larger | 3-bit (9-bit color) | Minimal color range |
I2S_NUM_1 (or I2S_NUM_0 on single-I2S chips). Audio must use the other port.gpio_config() over individual calls// Preferred: single struct-based configuration
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << pin),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&io_conf);
// Avoid: multiple separate calls (more error-prone, deprecated in IDF v5)
gpio_set_direction(pin, GPIO_MODE_OUTPUT);
gpio_set_pull_mode(pin, GPIO_FLOATING);
Always allocate pins through WLED's pinManager before using GPIO APIs:
if (!pinManager.allocatePin(myPin, true, PinOwner::UM_MyUsermod)) {
return; // pin in use by another module
}
// Now safe to configure
For high-resolution timing, prefer esp_timer_get_time() (microsecond resolution, 64-bit) over millis() or micros().
#include <esp_timer.h>
int64_t now_us = esp_timer_get_time(); // monotonic, not affected by NTP
<!-- HUMAN_ONLY_END --> <!-- HUMAN_ONLY_START -->Note: In arduino-esp32, both
millis()andmicros()are thin wrappers aroundesp_timer_get_time()— they share the same monotonic clock source. Prefer the direct call when you need the full 64-bit value or ISR-safe access without truncation:cpp// arduino-esp32 internals (cores/esp32/esp32-hal-misc.c): // unsigned long micros() { return (unsigned long)(esp_timer_get_time()); } // unsigned long millis() { return (unsigned long)(esp_timer_get_time() / 1000ULL); }
For periodic tasks with sub-millisecond precision, use esp_timer:
esp_timer_handle_t timer;
esp_timer_create_args_t args = {
.callback = myCallback,
.arg = nullptr,
.dispatch_method = ESP_TIMER_TASK, // run in timer task (not ISR)
.name = "my_timer",
};
esp_timer_create(&args, &timer);
esp_timer_start_periodic(timer, 1000); // 1 ms period
Always prefer ESP_TIMER_TASK dispatch over ESP_TIMER_ISR unless you need ISR-level latency — ISR callbacks have severe restrictions (no logging, no heap allocation, no FreeRTOS API calls).
When waiting for a precise future deadline (e.g., FPS limiting, protocol timing), avoid spinning the entire duration — that wastes CPU and starves other tasks. Instead, yield to FreeRTOS while time allows, then spin only for the final window.
<!-- HUMAN_ONLY_START -->// Wait until 'target_us' (a micros() / esp_timer_get_time() timestamp)
long time_to_wait = (long)(target_us - micros());
// Coarse phase: yield to FreeRTOS while we have more than ~2 ms remaining.
// vTaskDelay(1) suspends the task for one RTOS tick, letting other task run freely.
while (time_to_wait > 2000) {
vTaskDelay(1);
time_to_wait = (long)(target_us - micros());
}
// Fine phase: busy-poll the last ≤2 ms for microsecond accuracy.
// micros() wraps esp_timer_get_time() so this is low-overhead.
while ((long)(target_us - micros()) > 0) { /* spin */ }
The threshold (2000 µs as an example) should be at least one RTOS tick (default 1 ms on ESP32) plus some margin. A value of 1500–3000 µs works well in practice.
ADC is one of the most fragmented APIs across IDF versions:
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
// IDF v5: oneshot driver
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
adc_oneshot_unit_handle_t adc_handle;
adc_oneshot_unit_init_cfg_t unit_cfg = { .unit_id = ADC_UNIT_1 };
adc_oneshot_new_unit(&unit_cfg, &adc_handle);
#else
// IDF v4: legacy driver
#include "driver/adc.h"
#include "esp_adc_cal.h"
adc1_config_width(ADC_WIDTH_BIT_12);
int raw = adc1_get_raw(ADC1_CHANNEL_0);
#endif
Not all chips have 12-bit ADC. SOC_ADC_MAX_BITWIDTH reports the maximum resolution (12 or 13 bits). Note that in IDF v5, this macro was renamed to CONFIG_SOC_ADC_RTC_MAX_BITWIDTH. Write version-aware guards:
// IDF v4: SOC_ADC_MAX_BITWIDTH IDF v5: CONFIG_SOC_ADC_RTC_MAX_BITWIDTH
#if defined(CONFIG_SOC_ADC_RTC_MAX_BITWIDTH) // IDF v5+
#define MY_ADC_MAX_BITWIDTH CONFIG_SOC_ADC_RTC_MAX_BITWIDTH
#elif defined(SOC_ADC_MAX_BITWIDTH) // IDF v4
#define MY_ADC_MAX_BITWIDTH SOC_ADC_MAX_BITWIDTH
#else
#define MY_ADC_MAX_BITWIDTH 12 // safe fallback
#endif
#if MY_ADC_MAX_BITWIDTH == 13
adc1_config_width(ADC_WIDTH_BIT_13); // ESP32-S2
#else
adc1_config_width(ADC_WIDTH_BIT_12); // ESP32, S3, C3, etc.
#endif
WLED's util.cpp uses the IDF v4 form (SOC_ADC_MAX_BITWIDTH) — this will need updating when the codebase migrates to IDF v5.
RMT drives NeoPixel LED output (via NeoPixelBus) and IR receiver input. Both use the legacy API that is removed in IDF v5.
V5-C6 branch uses -D WLED_USE_SHARED_RMT to switch to the new RMT driver for NeoPixel output.SOC_RMT_TX_CANDIDATES_PER_GROUP to check availability.rmt_encoder_t) to translate data formats — this is more flexible but requires more setup code.Always check esp_err_t return values. Use ESP_ERROR_CHECK() in initialization code, but handle errors gracefully in runtime code.
// Initialization — crash early on failure
ESP_ERROR_CHECK(i2s_driver_install(I2S_NUM_0, &config, 0, nullptr));
// Runtime — log and recover
esp_err_t err = i2s_read(I2S_NUM_0, buf, len, &bytes_read, portMAX_DELAY);
if (err != ESP_OK) {
DEBUGSR_PRINTF("I2S read failed: %s\n", esp_err_to_name(err));
return;
}
For situations between these two extremes — where you want the ESP_ERROR_CHECK formatted log message (file, line, error name) but must not abort — use ESP_ERROR_CHECK_WITHOUT_ABORT().
// Logs in the same format as ESP_ERROR_CHECK, but returns the error code instead of aborting.
// Useful for non-fatal driver calls where you want visibility without crashing.
esp_err_t err = ESP_ERROR_CHECK_WITHOUT_ABORT(i2s_set_clk(AR_I2S_PORT, rate, bits, ch));
if (err != ESP_OK) return; // handle as needed
WLED uses its own logging macros — not ESP_LOGx(). For application-level code, always use the WLED macros defined in wled.h:
| Macro family | Defined in | Controlled by | Use for |
|---|---|---|---|
DEBUG_PRINT / DEBUG_PRINTLN / DEBUG_PRINTF | wled.h | WLED_DEBUG build flag | Development/diagnostic output; compiled out in release builds |
All of these wrap Serial output through the DEBUGOUT / DEBUGOUTLN / DEBUGOUTF macros.
Exception — low-level driver code: When writing code that interacts directly with ESP-IDF APIs (e.g., I2S initialization, RMT setup), use ESP_LOGx() macros instead. They support tag-based filtering and compile-time log level control:
static const char* TAG = "my_module";
ESP_LOGI(TAG, "Initialized with %d buffers", count);
ESP_LOGW(TAG, "PSRAM not available, falling back to DRAM");
ESP_LOGE(TAG, "Failed to allocate %u bytes", size);
On dual-core chips (ESP32, S3, P4), pin latency-sensitive tasks to a specific core:
xTaskCreatePinnedToCore(
audioTask, // function
"audio", // name
4096, // stack size
nullptr, // parameter
5, // priority (higher = more important)
&audioTaskHandle, // handle
0 // core ID (0 = protocol core, 1 = app core)
);
Guidelines:
SOC_CPU_CORES_NUM > 1 guards or tskNO_AFFINITY.SOC_CPU_CORES_NUM to conditionally pin tasks:
#if SOC_CPU_CORES_NUM > 1
xTaskCreatePinnedToCore(audioTask, "audio", 4096, nullptr, 5, &handle, 1);
#else
xTaskCreate(audioTask, "audio", 4096, nullptr, 5, &handle);
#endif
Tip: use xTaskCreateUniversal() - from arduino-esp32 - to avoid the conditional on SOC_CPU_CORES_NUM. This function has the same signature as xTaskCreatePinnedToCore(), but automatically falls back to xTaskCreate() on single-core MCUs.
delay(), yield(), and the IDLE taskFreeRTOS on ESP32 is preemptive — all tasks are scheduled by priority regardless of yield() calls. This is fundamentally different from ESP8266 cooperative multitasking.
| Call | What it does | Reaches IDLE (priority 0)? |
|---|---|---|
delay(ms) / vTaskDelay(ticks) | Suspends calling task; scheduler runs all other ready tasks | ✅ Yes |
yield() / vTaskDelay(0) | Hint to switch to tasks at equal or higher priority only | ❌ No |
taskYIELD() | Same as vTaskDelay(0) | ❌ No |
Blocking API (xQueueReceive, ulTaskNotifyTake, vTaskDelayUntil) | Suspends task until event or timeout; IDLE runs freely | ✅ Yes |
delay() in loopTask is safe. Arduino's loop() runs inside loopTask. Calling delay() suspends only loopTask — all other FreeRTOS tasks (Wi-Fi stack, audio FFT, LED DMA) continue uninterrupted on either core.
yield() does not yield to IDLE. Any task that loops with only yield() calls will starve the IDLE task, causing the IDLE watchdog to fire. Always use delay(1) (or a blocking FreeRTOS call) in tight task loops. Note: WLED redefines yield() as an empty macro on ESP32 WLEDMM_FASTPATH builds.
The FreeRTOS IDLE task (one per core on dual-core ESP32 and ESP32-S3; single instance on single-core chips) is not idle in the casual sense — it performs essential system housekeeping:
vTaskDelete(), the IDLE task reclaims its TCB and stack. Without IDLE running, deleted tasks leak memory permanently.configUSE_IDLE_HOOK = 1, the IDLE task calls vApplicationIdleHook() on every iteration — some ESP-IDF components register low-priority background work here.esp_register_freertos_idle_hook() (e.g., Wi-Fi background maintenance, Bluetooth housekeeping). These only fire when IDLE runs.In short: starving IDLE corrupts memory cleanup, breaks background activities, disables low-power sleep, and prevents Wi-Fi/BT maintenance. The IDLE watchdog panic is a symptom — the real damage happens before the watchdog fires.
Long-running operations may trigger the task watchdog. Feed it explicitly:
#include <esp_task_wdt.h>
esp_task_wdt_reset(); // feed the watchdog in long loops
For tasks that intentionally block for extended periods, consider subscribing/unsubscribing from the TWDT:
esp_task_wdt_delete(NULL); // remove current task from TWDT (IDF v4.4)
// ... long blocking operation ...
esp_task_wdt_add(NULL); // re-register
<!-- HUMAN_ONLY_START -->IDF v5 note: In IDF v5,
esp_task_wdt_add()andesp_task_wdt_delete()require an explicitTaskHandle_t. UsexTaskGetCurrentTaskHandle()instead ofNULL.
| Component | IDF v4 Header | IDF v5 Header | Key Change |
|---|---|---|---|
| I2S | driver/i2s.h | driver/i2s_std.h | Channel-based API |
| ADC (oneshot) | driver/adc.h | esp_adc/adc_oneshot.h | Unit/channel handles |
| ADC (calibration) | esp_adc_cal.h | esp_adc/adc_cali.h | Scheme-based calibration |
| RMT | driver/rmt.h | driver/rmt_tx.h / rmt_rx.h | Encoder-based transmit |
| SPI Flash | spi_flash.h | esp_flash.h | esp_flash_* functions |
| GPIO | driver/gpio.h | driver/gpio.h | gpio_pad_select_gpio() removed |
| Timer | driver/timer.h | driver/gptimer.h | General-purpose timer handles |
| PCNT | driver/pcnt.h | driver/pulse_cnt.h | Handle-based API |