# 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

```mermaid
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

| 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

> **WARNING**
>
> **The build is green and the board never reaches its banner.** A large new `static` landed in internal RAM and the heap could not reserve its pools (`Could not reserve internal/DMA pool`). Run `check_ram.sh` on every change, and put buffers in PSRAM with `khal_alloc_psram()`.

> **WARNING**
>
> **A buffer in PSRAM breaks code that runs during a flash write.** PSRAM is out of reach while the cache is off. Code that runs then touches only static or internal memory, which is why the stack of a task that writes flash stays internal. A worker whose stack is in PSRAM must not call NVS or flash functions.

> **NOTE**
>
> **A vendor option that says "internal" was written for boards with no PSRAM.** Look for the option that sends the stack to PSRAM before you accept its cost.

> **NOTE**
>
> **`CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL` makes no memory.** It only keeps 32 KB away from plain `malloc()`. A request with `MALLOC_CAP_INTERNAL` takes from the same pool as before.

## 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.

```c
/*
 * 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`](https://github.com/kodediy/KodeOS/blob/main/tools/ram_budget.txt): the kodeOS budget.
- [`tools/check_ram.sh`](https://github.com/kodediy/kode-sdk/blob/main/tools/check_ram.sh): the gate that reads an ELF against a budget file.
- [`components/kbsp/sdkconfig.board`](https://github.com/kodediy/kode-sdk/blob/main/components/kbsp/sdkconfig.board): the four memory lines.
