SDK
MicroSD and internal FAT
The microSD slot and the 2 MB internal FAT volume of a Kode Dot: wiring, the card's field table, the counterfeit-card trap and one file API over both.
What it is
A Kode Dot keeps files in two places. One is a microSD card in slot J2. The other is a 2 MB FAT volume in the storage partition of the board's own flash. The module kdrv/storage holds the three drivers that serve them:
| File | Job |
|---|---|
kdrv_sd.c | The card: power, bring-up, a table of everything it reported, format, raw sectors, lending |
kdrv_fs.c | The internal volume: mount, unmount, free space |
kdrv_storage.c | One file API over both: virtual paths, listing, reading, writing, copying |
The card comes and goes. The internal volume is always there and nobody can swap it for a different one. That difference decides the rule the whole layer follows: a read looks on the card first, a write goes to the internal volume first.
An app does not call these drivers. It uses the kode_fs_*() calls of sdk/kode, which ask AO_Storage. Only AO_Storage opens a file in the system (sdk/kaos/storage).
How it is connected
flowchart LR ao["AO_Storage"] --> st["kdrv_storage"] st --> sd["kdrv_sd"] st --> fs["kdrv_fs"] sd --> hal["khal_sdmmc"] hal --> slot["SDMMC slot 0<br/>J2, microSD"] fs --> part["storage partition<br/>0x1800000"] gpio["GPIO22 SD_PWR"] -- "gate of Q1" --> slot class ao ao class slot chip class part store
The owner
AO_Storage owns both volumes. It claims KBSP_RES_SDMMC and KBSP_RES_FATFS at start-up and runs at priority 5. Whatever waits on the card (a mount, an unmount, a lend, raw sectors, a format) runs in its worker task storage_sd, which holds one call at a time. A request that could touch the card waits in a queue of 8 while a call is out. The AO keeps every decision and the worker runs one driver call.
The card slot
The wiring of the card slot:
- Six lines, no bridge chip.
J2connects straight to the ESP32-P4 on the pins its SDMMC slot 0 fixes in the IO mux. Nothing in the code assigns them, andkbsp_pins.hlists them for reference. - The P4 powers the card.
SD1_VDDcomes through Q1, a P-MOSFET. Its gate isSD_PWRon GPIO22 (KBSP_PIN_SD_PWR), active low. No other chip has to be up for a card to mount. - The bus pull-ups share that switch. R4 to R8 and R12, 47 kΩ each, tie to
SD1_VDD. A card with no power also means a bus with no pull-ups. - The IO domain has its own supply. The P4's on-chip LDO channel 4 (
KBSP_SD_LDO_CHAN) feeds the SDMMC IO pins at 3.3 V. - Card detect goes to the C5.
SD1_CDis IO6 of the ESP32-C5.sdk/kdrv/c5reads it as the fieldsd_cd. The schematic does not confirm its polarity, so it is a reading and the driver does not use it.
One controller, two users
The P4 has one SDMMC controller. The link to the C5 uses its slot 1 and the card uses slot 0. A firmware that includes esp-hosted lets it take the controller in a constructor before app_main(), and a plain card mount then fails with "no available sd host controller".
So kdrv_sd has a hosted mode: the host init does nothing and the host deinit releases only slot 0. The mode is on by default in a build that has esp-hosted, and AO_Radio switches it with kdrv_sd_set_hosted() around the link's life. The mode decides what the next mount does. It does not move a card that is already mounted.
In hosted mode the controller exists only while the link's slot is on it. When AO_Radio takes the link down and brings it up again, a Wi-Fi or Bluetooth switch for example, there is a moment with no controller. A mount in that moment is refused with KODE_DENIED and a debug line, <n>-bit mount waits: the link's controller is not up. Nothing touches the bus, the card is not marked missing, and the next mount after the link is back tries it.
A mount runs these steps:
card_up()drivesSD_PWRlow. The first time since boot it waits 50 ms (KDRV_SD_POWER_SETTLE_MS) for the rail behind Q1 to rise. Later mounts drive the pin again and do not wait.khal_sdmmcstarts the IO-domain LDO once per boot and waits 10 ms.- The mount asks for
width_reqdata lines (4 by default) atfreq_req_khz(40000 by default). - If that fails and four lines were asked, one retry at one line.
- On success the card's registers are copied into a snapshot and the FAT volume is mounted at
/sdcardwith 5 files open at most and a 64 KB cluster. The mount never formats.
The retry in step 4 is the one decision in kdrv_sd.c. It exists for counterfeit cards (see Gotchas). What a one-line mount means is AO_Storage's judgement, and the production test fails on it.
The internal volume
kdrv_fs_mount() mounts the data partition storage (subtype fat, 2 MB at 0x1800000) at /storage through ESP-IDF's wear-levelling layer, with 4 files open at most. The first mount of a blank partition fails, and the driver mounts again with formatting on, because the partition belongs to the board. A card belongs to somebody else and a mount never formats it.
One file API
kdrv_storage names a file two ways. The flat calls take a bare name and a medium (kdrv_medium_t: KDRV_MEDIUM_SD is 0, KDRV_MEDIUM_FS is 1) and are thin wrappers over the path calls. The path calls take an absolute virtual path: /sdcard/..., /storage/... or / alone.
/is a directory with exactly two children,sdcardandstorage. Both appear in a listing whether or not their volume is mounted, so "is my file there" never gets a silently shorter answer.- A path must be canonical: it starts with
/, has no., no.., no//and no trailing/(except/). Anything else isKODE_DENIED. The console cleans the paths people type before they get here. kdrv_storage_stat()answersKODE_OKwithexistsfalse for a path that is not there. A malformed path or one that names no volume isKODE_DENIED. A volume that will not mount isKODE_ERROR.mkdir,rmdirandunlinkhave no recursive form.unlinkchecks first that the path is a file.renameacross/sdcardand/storageis refused withKODE_DENIED. FAT cannot rename across a mount, and a hidden copy would make one call mean two jobs.- There is one reader and one writer at a time, so a copy can hold both.
kdrv_storage_copy_open(),_step()and_end()are the only sequence that does. The caller supplies the buffer (AO_Storage lends one of its two 32 KB PSRAM buffers), and a copy onto its own source is refused before either file opens.
Probe or mount
kdrv_storage_mount() always tries, and the console's sd_mount uses it. kdrv_storage_probe() runs before every path call and tries the card once per boot.
A board with no card pays two bus attempts for each try, and a listing must not repeat that every time. After one failure only an explicit mount tries again.
An empty slot is told once per boot. The first mount that finds no card logs it in full. Every later mount that finds the slot still empty says still no card at debug level, holds ESP-IDF's own SDMMC and GPIO lines quiet, and leaves SD_PWR alone while it is still low. A card that mounts ends this, and the next empty slot is told in full again.
Lending
kdrv_sd_lend() unmounts the volume and keeps the card for raw sectors only. kdrv_sd_mount() then refuses and no /sdcard path opens. This is for an app that gives the card to a computer over USB mass storage, because a FAT volume with two masters gets corrupted. kdrv_sd_return() mounts the volume again, so the next listing reads what the computer wrote.
Specifications
The slot
| Thing | Value |
|---|---|
| Connector | J2 on the main board; mount point /sdcard |
| Bus | SDMMC slot 0, SD High Speed at 3.3 V; UHS-I needs 1.8 V, which this board does not switch |
| Data and clock pins | SD1_D0 GPIO39, SD1_D1 GPIO40, SD1_D2 GPIO41, SD1_D3 GPIO42, SD1_CLK GPIO43, SD1_CMD GPIO44 |
| Card supply | Q1 gate on GPIO22, active low; 47 kΩ pull-ups on the switched rail |
| IO supply | On-chip LDO channel 4 (KBSP_SD_LDO_CHAN), 10 ms settle once per boot |
| Default width | 4 lines (KBSP_SD_WIDTH) |
| Default clock | 40000 kHz (KBSP_SD_FREQ_KHZ), validated on this board. The bench can ask up to 50000 kHz with freq_req_khz |
| Files open at once | 5 on the card, 4 on the internal volume |
| Cluster size | 64 KB on the card. FAT cannot use a larger one, since the cluster field counts sectors in one byte |
| Internal volume | Partition storage, 2 MB at 0x1800000, about 2,016 KB usable after wear levelling; mount point /storage |
| Longest virtual path | 96 bytes, terminator included (KDRV_VPATH_MAX, twinned as KODE_PATH_MAX) |
Write speed depends on the block size far more than on the clock. Measured on a Kode Dot with a SanDisk SN128 at 50 MHz, a write goes from 2.09 MB/s with 4 KiB blocks to 17.04 MB/s with 128 KiB blocks, a factor of eight, where the faster clock buys about 15 %. kodeOS moves 32 KB at a time. Buffers in PSRAM cost about a third of the write speed (13.65 MB/s against 8.55 MB/s), which is the price of not spending 64 KB of internal RAM on idle buffers.
The time limits a caller waits by when it runs these calls in a task of its own:
| Constant | Value | What it bounds |
|---|---|---|
KDRV_SD_POWER_SETTLE_MS | 50 ms | The rail behind Q1, once per boot |
KDRV_SD_TRY_MAX_MS | 3000 ms | One try at the card: identification plus the volume mount |
KDRV_SD_MOUNT_MAX_MS | 6050 ms | Settle plus two tries (four lines, then one) |
KDRV_SD_LEND_MAX_MS | 12100 ms | The volume down, the card attached and, if that fails, the volume mounted again |
KDRV_SD_SECTORS_MAX_MS | 5000 ms | A transfer of 32 KB of sectors |
KDRV_SD_FORMAT_MAX_MS | 120000 ms | A format of the largest card the slot takes |
The card's fields
The card is a block device and has no register map to poke. The table is what it said at identification (the CID, CSD, SCR and SSR registers) plus what the link negotiated, which no datasheet can tell you. kdrv_sd_get() reads one field as an int32_t. The console's sd verb and sd_help print the same table.
Where a value comes from decides when it can be read. Card fields come from the snapshot taken at mount and are refused (KODE_DENIED) while nothing is mounted, a lent card included. FAT fields ask the volume each time. Self fields are the driver's own state and always answer.
Fields: identity
The identification registers, as the card printed them.
| Field | Source | Unit and range | What it is |
|---|---|---|---|
mfg_id | Card | code, 0 to 255 | CID manufacturer ID, for example 0x03 SanDisk, 0x1B Samsung |
oem_id | Card | code, 0 to 65535 | CID OEM or application ID, two ASCII characters as one number |
revision | Card | code, 0 to 255 | CID product revision in BCD: 0x30 is 3.0 |
serial_hi, serial_lo | Card | code, 0 to 65535 | The 32-bit serial number in two halves |
mfg_year | Card | year, 2000 to 2255 | CID manufacturing date, decoded |
mfg_month | Card | month, 0 to 15 | The month, one-based |
Fields: capacity and layout
How big the card is and how it is addressed.
| Field | Source | Unit and range | What it is |
|---|---|---|---|
csd_ver | Card | code, 0 to 3 | CSD structure: 0 is SDSC, 1 is SDHC or SDXC |
capacity_mb | Card | MB | Sectors times sector size, in units of 1,048,576 bytes |
sectors | Card | 512 B | Capacity in sectors: where the card ends |
sector_size | Card | B, 0 to 4096 | 512 on every SDHC |
read_block_len | Card | B, 1 to 32768 | Read block length, converted from the CSD's exponent |
high_capacity | Card | bool | OCR CCS: addressed by block, not by byte |
mmc | Card | bool | The part is an MMC, not an SD card |
Fields: speed and bus
What the card claims and what the link negotiated.
| Field | Source | Unit and range | What it is |
|---|---|---|---|
tr_speed_khz | Card | kHz | Rate the CSD declares. The real rate is freq_khz |
ccc | Card | mask, 0 to 4095 | Card command classes |
sd_spec | Card | code, 0 to 15 | Physical layer specification version (SCR) |
bus_widths | Card | mask, 0 to 15 | What the card claims: bit 0 is 1-bit, bit 2 is 4-bit |
bus_width | Card | bits, 0 to 8 | Data lines in use, 1 or 4. A healthy slot reads 4; 1 means three lines do not reach the card |
freq_khz | Card | kHz | Where the host clock ended up |
card_max_khz | Card | kHz | The fastest clock the card said it takes |
rca | Card | code, 0 to 65535 | Relative card address from identification |
uhs1 | Card | bool | UHS-I capable. This board cannot use it |
Fields: erase
How the card erases, from the SSR.
| Field | Source | Unit and range | What it is |
|---|---|---|---|
erase_state | Card | bool | What an erased block reads back as |
au_kb | Card | KB | Allocation unit, the size the card erases in (SSR) |
erase_size_au | Card | AU | Allocation units one erase covers |
erase_timeout_s | Card | s, 0 to 255 | Time asked to erase one allocation unit |
erase_offset_s | Card | s, 0 to 255 | Constant added to every erase timeout |
discard | Card | bool | The card supports DISCARD |
fule | Card | bool | The card supports Full User area Logical Erase |
Fields: volume and knobs
The driver's own state, the FAT volume and the two writable fields.
| Field | Source | Unit and range | What it is |
|---|---|---|---|
mounted | Self | bool | Whether a volume is mounted now. Reads 0 for a lent card |
hosted | Self | bool | Whether the next mount reuses esp-hosted's controller. Read-only |
total_mb, free_mb | FAT | MB | The volume's size and unused space |
width_req | Self, writable | bits, 1 or 4 | Lines the next mount asks for. 2 and 3 are refused. Default 4 |
freq_req_khz | Self, writable | kHz, 400 to 50000 | Clock ceiling the next mount asks for. Default 40000 |
The two writable fields touch no hardware. They are read by the next mount, so a bench can force one data line or a slower clock and then read bus_width and freq_khz to see what the card gave. They last until the board resets.
The driver
| Call | What it does |
|---|---|
kdrv_sd_mount() | Powers the card and mounts it, four lines then one. Returns /sdcard, or NULL with the reason logged. Idempotent. Refuses while the card is lent |
kdrv_sd_unmount() | Unmounts and clears the snapshot. Safe with nothing mounted |
kdrv_sd_get(f, &v), kdrv_sd_set(f, v) | One field. v is untouched unless the call returns KODE_OK. set returns KODE_DENIED for a read-only field or a value out of range |
kdrv_sd_field_info(f), kdrv_sd_field_by_name() | Name, unit, range and writability; the lookup the console uses |
kdrv_sd_name(buf, len) | The product name from the CID. The snapshot keeps 8 bytes with the terminator |
kdrv_sd_format() | A fresh empty filesystem. On failure no card is left mounted and the snapshot is cleared |
kdrv_sd_read_sectors(), kdrv_sd_write_sectors() | Whole sectors below the filesystem, on a mounted or a lent card |
kdrv_sd_lend(§ors), kdrv_sd_return(), kdrv_sd_lent() | Hand the card out for sectors only and take it back |
kdrv_sd_set_hosted(on) | Sets the mount path for the next mount |
kdrv_sd_usage(), kdrv_sd_dump() | FAT total and free bytes; the whole table as name=value log lines |
kdrv_fs_mount(), _unmount(), _mounted(), _mount_point(), _usage() | The internal volume. Mount returns /storage, formatting a blank partition first |
kdrv_storage_mount(m), _probe(m), _unmount(m), _mounted(m), _mount_point(m), _usage(m, ...), _dump(m) | Volume calls for either medium |
kdrv_storage_stat(), _list_path(), _mkdir(), _unlink(), _rmdir(), _rename() | Path calls. A listing hands out entries through a callback that returns false to stop |
kdrv_storage_open_path(), _create_path(path, append), _read(), _write(), _close(), _discard() | One file at a time. close() returns the status of fclose(), where a buffered write finally reaches the card |
kdrv_storage_open(name, ...), _create(name, first, ...), _list(m, ...), _remove(name, m) | Flat calls. open tries the card then the internal volume. create tries first, then the other |
kdrv_storage_copy_open(), _copy_step(), _copy_end(keep) | One copy at a time. copy_end(false) deletes the destination |
kdrv_storage_medium_of(path) | Which medium a virtual path names, or KDRV_MEDIUM_COUNT |
kdrv_storage_selftest(m) | Writes .kodeos_test, reads it back, compares and deletes it |
discard() depends on how the write was opened. After a truncating create it closes and deletes the file, so no stub is left with a name and half its bytes. After an append it closes and leaves the file alone, since good data may already be in it.
The flat listing hides names that start with ._, the AppleDouble files macOS leaves on a FAT card. The path listing shows them, because ls answers "what is here".
The files
| File | What it does |
|---|---|
include/kdrv_sd.h, src/kdrv_sd.c | The card's field table, bring-up with the width retry, format, raw sectors, lending |
include/kdrv_fs.h, src/kdrv_fs.c | The storage partition mounted through wear levelling |
include/kdrv_storage.h, src/kdrv_storage.c | Virtual paths, listing, the reader and writer pair, copy, the probe rule. The only file allowed to include dirent.h and sys/stat.h |
The card and the FAT calls underneath are in khal_sdmmc (sdk/khal), which keeps every ESP-IDF SDMMC call in one place.
Gotchas
Example code
Write a line to the internal volume and read it back. The log prints the file's size and its text. Run it twice: kode_fs_write() with append false replaces the file, so the answer is the same.
/*
* storage_note.c - 2026-10-28 - from Kode with love
*
* Writes one line to /storage/note.txt on the internal FAT, reads the file
* back and logs its size and text. The log shows: /storage/note.txt holds
* 19 bytes, then the line.
*/
#include <string.h>
#include "kode.h"
#define TAG "note"
#define NOTE_PATH "/storage/note.txt"
/* Write, read back, then idle so the console stays up. */
void app_main(void)
{
static const char line[] = "hello from the app\n";
char text[64];
uint32_t got = 0U;
kode_init();
if (kode_fs_write(NOTE_PATH, line, (uint32_t)strlen(line), false) != KODE_OK) {
KLOG_W(TAG, "could not write %s", NOTE_PATH);
} else if (kode_fs_read(NOTE_PATH, text, sizeof(text) - 1U, &got) == KODE_OK) {
text[got] = '\0';
KLOG_N(TAG, "%s holds %lu bytes:\n%s", NOTE_PATH, (unsigned long)got, text);
} else {
KLOG_W(TAG, "could not read %s", NOTE_PATH);
}
while (1) {
kode_delay_ms(1000);
}
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kdrv/storage: the three drivers,kdrv_sd.c,kdrv_fs.candkdrv_storage.c.kdrv_sd.h: the field enum and the card calls.kdrv_storage.h: the path and flat calls.components/kaos/storage: AO_Storage, the owner, with the worker and the card test (sdk/kaos/storage).