Skip to main content

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

TaskPrioCoreStackWhy these numbers
lwIP tiT180IDF defaultThe TCP/IP stack, above all app tasks so packets never rot
audio_task10024 KBThe realtime deadline. Pinned so its worst case never migrates
stream_worker9any6 KBJust below audio: it feeds audio and must never starve it
encoder_task10anysmallInput latency is human-visible
display_task5any4 KBUI bridge; a late repaint is invisible
event_task5any3 KBAsync event delivery
httpd5any10 KBTLS handshakes are heavy but deadline-free
taskLVGL418 KBRendering, deliberately on the other core from audio
battery_mon3any2.5 KBSamples 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

MemorySizeSpeedWho can use it
Internal SRAM~400 KB total, far less freeFastEveryone: CPU, DMA, ISRs, FreeRTOS
PSRAM (external, octal SPI)8 MBSeveral times slower, cache-mediatedCPU data only
Flash (memory-mapped)16 MBSlow, cachedCode and constants

The placement rules Safi enforces, each learned or confirmed the hard way:

  1. 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.
  2. 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.
  3. 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 via CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC). Heap callers ask for it with heap_caps_malloc(size, MALLOC_CAP_SPIRAM).
  4. Audit the internal heap at milestones. Boot logs print internal free after each init stage, because the difference between "works" and ESP_ERR_NO_MEM at 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.
  • uxTaskGetStackHighWaterMark in 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.