Tasks and Memory
Everything hard about this firmware is ultimately a memory placement or scheduling question. This chapter gives you the mental model the rest of the masterclass assumes.
FreeRTOS in two minutes
FreeRTOS schedules tasks (threads) strictly by priority: the highest-priority ready task runs, always and immediately. Equal priorities round-robin. On the ESP32-S3 there are two cores; a task can be pinned to one core or float. Blocking calls (queue receive, delay, DMA wait) take a task out of the ready set, which is how lower-priority work ever runs.
The consequence that shapes Safi: priority is for deadlines, not importance. The audio decoder is not more important than the web server, but it has a hard deadline (the DAC consumes samples at 44,100 per second whether or not new ones arrived), so it sits above everything that merely wants CPU.
Safi's task map
| Task | Prio | Core | Stack | Why these numbers |
|---|---|---|---|---|
lwIP tiT | 18 | 0 | IDF default | The TCP/IP stack, above all app tasks so packets never rot |
audio_task | 10 | 0 | 24 KB | The realtime deadline. Pinned so its worst case never migrates |
stream_worker | 9 | any | 6 KB | Just below audio: it feeds audio and must never starve it |
encoder_task | 10 | any | small | Input latency is human-visible |
display_task | 5 | any | 4 KB | UI bridge; a late repaint is invisible |
event_task | 5 | any | 3 KB | Async event delivery |
httpd | 5 | any | 10 KB | TLS handshakes are heavy but deadline-free |
taskLVGL | 4 | 1 | 8 KB | Rendering, deliberately on the other core from audio |
battery_mon | 3 | any | 2.5 KB | Samples every few seconds |
Two placements do the heavy lifting: audio pinned to core 0 alongside the network stack (its data source), LVGL pinned to core 1 so a full-screen Arabic re-shape and repaint cannot delay a DMA refill. Everything else floats and lets the scheduler balance.
The three memories
| Memory | Size | Speed | Who can use it |
|---|---|---|---|
| Internal SRAM | ~400 KB total, far less free | Fast | Everyone: CPU, DMA, ISRs, FreeRTOS |
| PSRAM (external, octal SPI) | 8 MB | Several times slower, cache-mediated | CPU data only |
| Flash (memory-mapped) | 16 MB | Slow, cached | Code and constants |
The placement rules Safi enforces, each learned or confirmed the hard way:
- DMA buffers must be internal. The SPI, I2S, and SD DMA engines cannot reach PSRAM. This single rule dictated the display architecture: an early revision kept the framebuffer in PSRAM and produced the corrupted, "dislocated" screens that LVGL migration fixed; the full story is in the display chapter.
- FreeRTOS objects and task stacks must be internal. Queues, semaphores, and ring buffer control blocks contain spinlocks the kernel manipulates with interrupts off; PSRAM's atomic-access emulation is not safe there.
- Big cold data goes to PSRAM, explicitly. Static buffers get
EXT_RAM_BSS_ATTR(the SD library index, ~19 KB per reciter slot; codec state; TLS session buffers viaCONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC). Heap callers ask for it withheap_caps_malloc(size, MALLOC_CAP_SPIRAM). - Audit the internal heap at milestones. Boot logs print
internal freeafter each init stage, because the difference between "works" andESP_ERR_NO_MEMat 2 a.m. is roughly 30 KB. When TLS handshakes began failing after the LVGL migration, these milestones located the squeeze in minutes.
Where the internal RAM actually goes in a running Safi: WiFi driver buffers (~50 KB), LVGL's draw buffer (10 KB, DMA), the stream ring buffer (16 KB, rule 2), the audio task stack (24 KB), lwIP, and the kernel. The margin is real but thin; every chapter that allocates explains where its memory lives.
The stack overflow that renamed this chapter
The audio task originally had a 4 KB stack, and later 16 KB. Both numbers produced the strangest bug of the project's bring-up: crashes whose corpse was a different subsystem every time. One boot died spinning on a corrupted queue lock, the next inside the system event loop, a third decoded garbage audio. Heap poisoning found nothing wrong with the heap.
The mechanism, once cornered: minimp3 allocates about 7 KB of scratch on the stack per decoded frame, on top of the decode loop's own locals and whatever interrupt frame lands on the running task's stack. FreeRTOS stacks grow downward into whatever the allocator placed just below, and the canary check only guards the very end, only at context switches. A deep-but-brief excursion punched through the canary region into the neighboring heap block (the audio command queue), corrupted its spinlock, and was gone by the time anything checked. The corpse was whoever owned the adjacent allocation that boot.
The fix was 24 KB of stack; the lessons were permanent:
- Size stacks from the deepest callee's documented appetite, not from what looks reasonable in your own code.
- Polymorphic, corpse-hopping crashes mean memory corruption near a task stack until proven otherwise.
uxTaskGetStackHighWaterMarkin the field beats guessing; the boot report and console expose it.
The forensic path that cornered it (a linker-wrapped xPortEnterCriticalTimeout that printed the lock owner, then finding minimp3's own stack frames inside the queue struct) is written up in observability as the house method for impossible bugs.
Interrupts in one paragraph
ISRs run outside any task, may only call FromISR kernel APIs, and must touch only internal RAM (a PSRAM cache miss inside an ISR can deadlock against the cache's own interrupt). Safi keeps ISRs nearly empty: the encoder ISR timestamps and queues, the I2S and SPI drivers' ISRs belong to ESP-IDF, and everything else is polled from tasks. Boring interrupts are correct interrupts.