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
Where the rule is kept and checked

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

WhatWhy it cannot be in PSRAM
Every global and every staticThe linker puts .data and .bss in internal RAM
Code in IRAMIt runs while the flash cache is off
The stack of a taskFreeRTOS 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 taskFreeRTOS takes them from internal RAM
DMA descriptorsThe DMA engines read them from internal RAM
Data an interrupt reads while the cache is offPSRAM 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

LineWhat it keeps out of internal RAM
CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=0Every 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=yThe two transport pools of esp-hosted, 88 KB
CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_EXTERNAL=yThe pools and tables of the NimBLE host, 32 KB
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=yThe 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.

Budgetstatic_maxMeaning
kodeOS (tools/ram_budget.txt)417000 bytesA few kilobytes above what kodeOS takes, with CONFIG_KODE_LOWPOWER and CONFIG_KODE_SCHEDULER on
SDK (tests/target/ram_budget.txt)364000 bytesThe 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.

OptionWhat it adds to the static image
CONFIG_KODE_LOWPOWER19,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_SCHEDULERThe 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:

GroupBytesWhy it is internal
Stacks of the 23 Active Objects122,880An 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,072It 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,200Events are posted from interrupts
AO objects (l_gui 12,288, l_console 4,096)16,384They 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,288Flash-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,680Declared 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 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>

FieldMeaning
int_freeFree internal bytes now (khal_heap_internal_free())
int_maxLargest free block now (khal_heap_internal_largest())
int_lowThe lowest int_free has been since boot (khal_heap_internal_low())
alloc_failAllocations 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:

Stateint_free (bytes)int_low (bytes)
On the face, after boot134,651133,504
After a Wi-Fi scan130,583130,312
After test component=rfid134,327112,156

With Wi-Fi up and the channel in use, alloc_fail stays 0 and the internal RAM reads, in KB:

StateFreeLowest
Wi-Fi up, no TLS session129.3128.9
Channel up (an MQTT session over TLS)121.8121.1
Channel up, ring and work area taken113.9113.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