src/platforms/esp/32/drivers/UART_ISR_ARCHITECTURE.md
This document explains the real architecture of ESP-IDF UART at the driver/ISR/FreeRTOS boundary—not Arduino-level hand-waving.
The ESP32 UART driver sits at the boundary between:
When you call:
uart_driver_install(
port,
rx_buf_size, // 1️⃣ RX ring buffer
tx_buf_size, // 2️⃣ TX ring buffer
queue_size, // 3️⃣ Event queue
queue_handle, // 4️⃣ Queue handle
intr_flags // 5️⃣ Interrupt flags (THE CRITICAL ONE)
);
Two parameters control ISR behavior:
The event queue is a FreeRTOS queue that the UART ISR posts messages into.
Messages are of type uart_event_t:
typedef enum {
UART_DATA, // RX data available
UART_FIFO_OVF, // Hardware FIFO overflow
UART_BUFFER_FULL, // Driver ring buffer full
UART_BREAK, // Break detected
UART_PARITY_ERR, // Parity error
UART_FRAME_ERR // Frame error
} uart_event_type_t;
typedef struct {
uart_event_type_t type;
size_t size;
} uart_event_t;
UART HW Interrupt
↓
UART ISR (ESP-IDF)
↓
xQueueSendFromISR(uart_event_queue)
↓
Your FreeRTOS Task receives uart_event_t
↓
xQueueReceive(uart_queue, &evt, timeout)
0, NULL0, // No event queue
NULL, // No event queue handle
This means:
But importantly:
uart_read_bytes() still works (polling mode)You are saying:
"I will poll or block on reads myself. Don't notify me."
Use it when:
Typical pattern:
QueueHandle_t uart_queue;
uart_driver_install(UART_NUM_0, 256, 256, 10, &uart_queue, flags);
uart_event_t evt;
while (1) {
if (xQueueReceive(uart_queue, &evt, portMAX_DELAY)) {
switch (evt.type) {
case UART_DATA:
// Read evt.size bytes
break;
case UART_FIFO_OVF:
// Handle overflow
break;
}
}
}
Don't use it when:
For debug/console UART: Skipping the queue is correct.
The last parameter controls how the UART ISR is registered with the ESP32 interrupt controller:
intr_flags // How survivable your ISR is under load
This directly affects:
0 // Default = ESP_INTR_FLAG_LEVEL1
This means:
Result: Your debug prints vanish when you need them most.
ESP32 does:
Without proper ISR configuration:
ESP_INTR_FLAG_IRAM | ESP_INTR_FLAG_LEVEL3
Purpose: Place ISR in IRAM (internal RAM)
Effect:
Without IRAM:
[Your code triggers flash write]
↓
Flash cache disabled
↓
UART interrupt fires
↓
ISR code in flash → CANNOT EXECUTE
↓
UART FIFO overflows → BYTES LOST
With IRAM:
[Your code triggers flash write]
↓
Flash cache disabled
↓
UART interrupt fires
↓
ISR code in IRAM → EXECUTES NORMALLY
↓
Bytes safely moved to ring buffer
Purpose: Set ISR scheduling priority
| Level | Priority | Use Case |
|---|---|---|
| LEVEL1 | Lowest | ❌ Can be masked (fragile) |
| LEVEL2 | Low | Background tasks |
| LEVEL3 | Medium | ✅ Debug/console UART |
| LEVEL4 | High | Time-critical sensors |
| LEVEL5 | Highest | Safety-critical hardware |
LEVEL3 is ideal for debug UART:
// UartConfig defaults
intrPriority = UartIntrPriority::LEVEL3; // High priority
intrFlags = UartIntrFlags::IRAM; // ISR in IRAM
// Converts to:
ESP_INTR_FLAG_IRAM | ESP_INTR_FLAG_LEVEL3
This ensures:
| Configuration | Behavior |
|---|---|
| No queue + LEVEL1 | ❌ Fast but fragile |
| Queue + LEVEL1 | ❌ Event-driven but lossy |
| No queue + IRAM+LEVEL3 | ✅ Best for debug UART |
| Queue + IRAM+LEVEL3 | ✅ Best for diagnostics+safety |
UART Hardware
↓
Hardware FIFO (128 bytes)
↓
[UART ISR fires when FIFO threshold reached]
↓
ISR copies FIFO → RX Ring Buffer (256+ bytes)
↓
[ISR posts to event queue if configured]
↓
Application calls uart_read_bytes()
↓
Reads from RX Ring Buffer
Without ring buffer:
With ring buffer:
config.rxBufferSize = 256; // Default
How to calculate:
Bytes/second = baudRate / 10
Bytes/ms = baudRate / 10000
At 115200 baud:
= 11.52 bytes/ms
= ~11520 bytes/second
256 byte buffer = 22ms cushion
512 byte buffer = 44ms cushion
1024 byte buffer = 88ms cushion
Recommendation:
UartConfig config = UartConfig::console();
// Defaults to:
// intrPriority = LEVEL3 (high priority)
// intrFlags = IRAM (survives flash ops)
// eventQueueSize = 0 (polling mode)
// rxBufferSize = 256 (22ms cushion @ 115200)
UartEsp32 uart(config);
Result:
UartConfig config;
config.port = UartPort::UART1;
config.baudRate = 460800; // High speed
config.intrPriority = UartIntrPriority::LEVEL4; // Very high priority
config.intrFlags = UartIntrFlags::IRAM; // IRAM ISR
config.rxBufferSize = 2048; // Large buffer
config.eventQueueSize = 10; // Event-driven
config.flowControl = UartFlowControl::RTS_CTS; // Hardware flow control
UartEsp32 uart(config);
Result:
UartConfig config = UartConfig::lowOverhead();
// Defaults to:
// intrPriority = LEVEL1 (lowest)
// intrFlags = NONE (no IRAM)
Result:
✅ Enable IRAM ISR
config.intrFlags = UartIntrFlags::IRAM;
✅ Increase ISR priority
config.intrPriority = UartIntrPriority::LEVEL3;
✅ Increase buffer size
config.rxBufferSize = 512; // or larger
✅ Add hardware flow control
config.flowControl = UartFlowControl::RTS_CTS;
UartConfig config = UartConfig::console();
// Automatically sets:
// - LEVEL3 priority (high)
// - IRAM ISR (survives flash ops)
// - No event queue (polling)
// - 256 byte buffers
This configuration ensures debug output is rock-solid.
Event queue = ISR → FreeRTOS message channel
0, NULL = "don't notify me, I'll poll" ✅Interrupt flags = How survivable your ISR is under load
0 = Lowest priority, safest, but weakest ❌ESP_INTR_FLAG_IRAM | LEVEL3 = Essential for reliability ✅On ESP32, the default ISR configuration is dangerously fragile.
For debug UART, you MUST use:
Otherwise, your debug prints will vanish exactly when you need them most.