kodeOS internals
Memory budget
Who lives in the 640 KB of internal RAM of the Kode Dot, the build lines that keep everything else in PSRAM, and how a build and a board are checked.
What it is
The Kode Dot has two memories. The internal RAM is inside the ESP32-P4 and is 640 KB (655,360 bytes). The PSRAM is 32 MB. Some things can only live in the internal RAM, so it is the memory that runs out first, and it runs out without a warning: a build that takes too much still links and still passes the host tests.
Code gets internal RAM only when it asks for internal RAM by name. Code that asks for memory in general gets PSRAM, even while internal RAM is free. Read this page before you add a buffer, a task or a library to kodeOS, to the SDK or to an app.
How it is connected
flowchart LR subgraph build["At build time"] board["kbsp/sdkconfig.board<br/>4 lines"] elf["kodeos.elf"] --> chk["check_ram.sh<br/>+ ram_budget.txt"] end subgraph run["On the Kode Dot"] malloc["malloc() takes PSRAM"] --> psram["PSRAM, 32 MB"] malloc --> ps["ps, test component=mem"] end board --> malloc chk --> ci["before a merge"] class board,elf store class psram chip class ci ext
The four lines live in components/kbsp/sdkconfig.board, which every project reads first. khal_system_esp.c, kdrv_radio.c, kdrv_ble.c and kdrv_net.c stop the build with an #error that names the file when a project does not carry them. The check of a build is tools/check_ram.sh with a budget file the project keeps (tools/ram_budget.txt in kodeOS). The numbers of a running board come from the khal_heap_* functions and the console prints them with ps.
A buffer comes from khal_alloc_psram(). khal has no allocator that tries internal RAM first and falls back to PSRAM. The call takes an alignment and a size, returns zeroed memory and never falls back. khal_free() releases it. LVGL never falls back either: the hooks in kui_mem.c take PSRAM and answer NULL when PSRAM cannot serve.
Why mbedTLS is in PSRAM. A TLS session that stays up holds about 30 KB of mbedTLS buffers, and the Kode channel (internals/kode-channel) keeps one up for as long as the board has Wi-Fi. A renewal adds two handshakes while it is up.
In the internal mode, one HTTPS request with the session up took the pool to about 60 KB, under the floor of 64 KB. The TLS code runs from flash in ordinary tasks, so it never runs with the cache off and PSRAM is always in reach for it.
A refusal is printed at once. For the first four refusals the board prints kodeOS: heap refused <n> bytes, caps 0x<caps>, task '<name>'. caps 0x80c is MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT, a request only internal RAM can serve. The caller is often a vendor library that calls abort() next, so the line goes out without a lock, by the same path as a QP assert.
Specifications
What must be in internal RAM
| What | Why it cannot be in PSRAM |
|---|---|
Every global and every static | The linker puts .data and .bss in internal RAM |
| Code in IRAM | It runs while the flash cache is off |
| The stack of a task | FreeRTOS takes it from internal RAM. A task that writes flash cannot have its stack in PSRAM, because a flash write takes PSRAM out of reach |
| Queues, semaphores and the control block of a task | FreeRTOS takes them from internal RAM |
| DMA descriptors | The DMA engines read them from internal RAM |
| Data an interrupt reads while the cache is off | PSRAM is out of reach then |
A DMA buffer is not in this table. On the P4 the DMA engines and the SDMMC controller reach PSRAM, so a buffer can be there.
The four lines
| Line | What it keeps out of internal RAM |
|---|---|
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=0 | Every plain malloc(), whatever its size. The ESP-IDF default is 16384, which fills internal RAM with small blocks and fails later, in code that cannot use PSRAM |
CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM=y | The two transport pools of esp-hosted, 88 KB |
CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=y | The pools and tables of the NimBLE host, 32 KB |
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y | The buffers of every TLS session and handshake, about 30 KB for a session that stays up |
CONFIG_SPIRAM_MEMTEST=n skips the walk over the 32 MB at boot, which costs about a second. test component=psram tests it instead.
The budget file
tools/check_ram.sh <firmware.elf> <budget file> reads the ELF of a build and makes two checks. The static image in internal RAM (IRAM code, .data, .bss) must not exceed static_max. Every data symbol of 2048 bytes or more in internal RAM must have a line in the file, and must not be larger than that line says.
The script needs riscv32-esp-elf-size and riscv32-esp-elf-nm on the path, which export.sh provides, and exits 1 past the budget. A new static buffer of 4 KB fails the check until its author moves it to PSRAM or adds a line in the same change.
| Budget | static_max | Meaning |
|---|---|---|
kodeOS (tools/ram_budget.txt) | 417000 bytes | A few kilobytes above what kodeOS takes, with CONFIG_KODE_LOWPOWER and CONFIG_KODE_SCHEDULER on |
SDK (tests/target/ram_budget.txt) | 364000 bytes | The test project links every component with every option on, so this is what the SDK costs an app before it adds a line |
The two optional Active Objects of the Kode Dot's rest and schedule cost internal RAM too. The figures are those that tools/ram_budget.txt records.
| Option | What it adds to the static image |
|---|---|
CONFIG_KODE_LOWPOWER | 19,016 bytes: 391,232 with the option off and 410,248 with it on. 13,030 of them are IRAM, the power-management and sleep code that light sleep runs with the flash cache off. The rest, about 6 KB, is data: the 3072-byte stack of AO_LowPower, the radio's sleep sentinel and the sleep tables. An app that turns the option on pays the same |
CONFIG_KODE_SCHEDULER | The 3072-byte stack of AO_Scheduler, its queue of 24 events, and a wider large event pool: a block of 344 bytes for SchedEntryEvt instead of the size of MqttMsgEvt, which makes the pool 16,512 bytes. The static image reads 414,424 with both on. The table of 16 entries of about 350 bytes comes from PSRAM, once |
The large statics kodeOS lists, by group:
| Group | Bytes | Why it is internal |
|---|---|---|
| Stacks of the 23 Active Objects | 122,880 | An AO may write flash. AO_Gui's is 24,576 because ThorVG rasterises Kody on it; the others are 3072 to 8192. AO_LowPower and AO_Scheduler have 3072 each, listed as lowpower_stack and scheduler_stack |
The Active Object supervisor's stack (s_watch_stack) | 3,072 | It must keep feeding the task watchdog while a flash write holds PSRAM out of reach |
QP/C event pools (s_jumbo_pool, s_medium_pool, s_large_pool) | 49,200 | Events are posted from interrupts |
AO objects (l_gui 12,288, l_console 4,096) | 16,384 | They grow with the code, so each line leaves room |
kodeOS buffers (s_recv_buf 4,096, s_adopt_buf 4,096, s_fs_buf 4,096) | 12,288 | Flash-path and interrupt-path buffers |
Libraries (lv_global 17,408, _cdcd_itf 8,704, xIsrStack 4,192, s_retention 2,816, gNfcDev 2,560) | 35,680 | Declared by the library. Listed so a version bump that grows one is seen |
Stacks of workers (the tasks an Active Object owns for waiting jobs) are in PSRAM, except the radio worker's, which uses NVS. The other tasks keep internal stacks:
- the six tasks of esp-hosted (3072 or 4096 bytes each);
- the console reader, the NFC task and the NimBLE host task (4096 each).
The board
ps prints four numbers of the internal RAM in its last record:
>|heap=<n> int_free=<n> int_max=<n> int_low=<n> alloc_fail=<n> log_dropped=<n> rx_dropped=<n>
| Field | Meaning |
|---|---|
int_free | Free internal bytes now (khal_heap_internal_free()) |
int_max | Largest free block now (khal_heap_internal_largest()) |
int_low | The lowest int_free has been since boot (khal_heap_internal_low()) |
alloc_fail | Allocations the heap refused since boot (khal_heap_alloc_failures()) |
int_low is the number that judges a build, because internal RAM that came close to zero looks healthy a second later. Read it after the heaviest work the console can drive: a Wi-Fi scan, a download, test component=rfid. test component=mem passes when int_low is at least 64 KB (KODE_MEM_TEST_FLOOR_BYTES) and alloc_fail is 0. It is the last row of test component=all.
Measured on a Kode Dot running kodeOS with the radio link up:
| State | int_free (bytes) | int_low (bytes) |
|---|---|---|
| On the face, after boot | 134,651 | 133,504 |
| After a Wi-Fi scan | 130,583 | 130,312 |
After test component=rfid | 134,327 | 112,156 |
With Wi-Fi up and the channel in use, alloc_fail stays 0 and the internal RAM reads, in KB:
| State | Free | Lowest |
|---|---|---|
| Wi-Fi up, no TLS session | 129.3 | 128.9 |
| Channel up (an MQTT session over TLS) | 121.8 | 121.1 |
| Channel up, ring and work area taken | 113.9 | 113.3 |
Gotchas
Example code
An app takes a 64 KB buffer from PSRAM and logs the internal RAM before and after. Two log lines appear on the console; their free figures are close, because the buffer went to PSRAM.
/*
* memory_check.c - 2026-10-28 - from Kode with love
*
* Takes a 64 KB work buffer from PSRAM, the way the memory budget asks, and
* logs how much internal RAM the Kode Dot has left before and after. Run it
* as an app. The log shows two lines with the free internal bytes, the lowest
* they have been since boot and the number of refused allocations, which is 0
* on a healthy board. The two free figures are close: the buffer went to PSRAM.
*/
#include "kode.h"
#include "khal_system.h"
#define TAG "memory_check"
#define WORK_BUFFER_BYTES (64U * 1024U)
#define WORK_BUFFER_ALIGN 16U
/* Log the three numbers that judge a build. */
static void log_internal_ram(const char *when)
{
KLOG_I(TAG, "%s: internal free %u, lowest %u, refused %lu", when,
(unsigned)khal_heap_internal_free(),
(unsigned)khal_heap_internal_low(),
(unsigned long)khal_heap_alloc_failures());
}
/* Start the system, take the buffer from PSRAM, and compare the two readings. */
void app_main(void)
{
kode_init();
log_internal_ram("before");
uint8_t *work = khal_alloc_psram(WORK_BUFFER_ALIGN, WORK_BUFFER_BYTES);
if (work == NULL) {
KLOG_E(TAG, "PSRAM refused %u bytes", (unsigned)WORK_BUFFER_BYTES);
return;
}
log_internal_ram("after");
khal_free(work);
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
tools/ram_budget.txt: the kodeOS budget.tools/check_ram.sh: the gate that reads an ELF against a budget file.components/kbsp/sdkconfig.board: the four memory lines.