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:

FileJob
kdrv_sd.cThe card: power, bring-up, a table of everything it reported, format, raw sectors, lending
kdrv_fs.cThe internal volume: mount, unmount, free space
kdrv_storage.cOne 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 two volumes

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:

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:

    1. card_up() drives SD_PWR low. 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.
    2. khal_sdmmc starts the IO-domain LDO once per boot and waits 10 ms.
    3. The mount asks for width_req data lines (4 by default) at freq_req_khz (40000 by default).
    4. If that fails and four lines were asked, one retry at one line.
    5. On success the card's registers are copied into a snapshot and the FAT volume is mounted at /sdcard with 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.

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

ThingValue
ConnectorJ2 on the main board; mount point /sdcard
BusSDMMC slot 0, SD High Speed at 3.3 V; UHS-I needs 1.8 V, which this board does not switch
Data and clock pinsSD1_D0 GPIO39, SD1_D1 GPIO40, SD1_D2 GPIO41, SD1_D3 GPIO42, SD1_CLK GPIO43, SD1_CMD GPIO44
Card supplyQ1 gate on GPIO22, active low; 47 kΩ pull-ups on the switched rail
IO supplyOn-chip LDO channel 4 (KBSP_SD_LDO_CHAN), 10 ms settle once per boot
Default width4 lines (KBSP_SD_WIDTH)
Default clock40000 kHz (KBSP_SD_FREQ_KHZ), validated on this board. The bench can ask up to 50000 kHz with freq_req_khz
Files open at once5 on the card, 4 on the internal volume
Cluster size64 KB on the card. FAT cannot use a larger one, since the cluster field counts sectors in one byte
Internal volumePartition storage, 2 MB at 0x1800000, about 2,016 KB usable after wear levelling; mount point /storage
Longest virtual path96 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:

ConstantValueWhat it bounds
KDRV_SD_POWER_SETTLE_MS50 msThe rail behind Q1, once per boot
KDRV_SD_TRY_MAX_MS3000 msOne try at the card: identification plus the volume mount
KDRV_SD_MOUNT_MAX_MS6050 msSettle plus two tries (four lines, then one)
KDRV_SD_LEND_MAX_MS12100 msThe volume down, the card attached and, if that fails, the volume mounted again
KDRV_SD_SECTORS_MAX_MS5000 msA transfer of 32 KB of sectors
KDRV_SD_FORMAT_MAX_MS120000 msA 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.

FieldSourceUnit and rangeWhat it is
mfg_idCardcode, 0 to 255CID manufacturer ID, for example 0x03 SanDisk, 0x1B Samsung
oem_idCardcode, 0 to 65535CID OEM or application ID, two ASCII characters as one number
revisionCardcode, 0 to 255CID product revision in BCD: 0x30 is 3.0
serial_hi, serial_loCardcode, 0 to 65535The 32-bit serial number in two halves
mfg_yearCardyear, 2000 to 2255CID manufacturing date, decoded
mfg_monthCardmonth, 0 to 15The month, one-based

Fields: capacity and layout

How big the card is and how it is addressed.

FieldSourceUnit and rangeWhat it is
csd_verCardcode, 0 to 3CSD structure: 0 is SDSC, 1 is SDHC or SDXC
capacity_mbCardMBSectors times sector size, in units of 1,048,576 bytes
sectorsCard512 BCapacity in sectors: where the card ends
sector_sizeCardB, 0 to 4096512 on every SDHC
read_block_lenCardB, 1 to 32768Read block length, converted from the CSD's exponent
high_capacityCardboolOCR CCS: addressed by block, not by byte
mmcCardboolThe part is an MMC, not an SD card

Fields: speed and bus

What the card claims and what the link negotiated.

FieldSourceUnit and rangeWhat it is
tr_speed_khzCardkHzRate the CSD declares. The real rate is freq_khz
cccCardmask, 0 to 4095Card command classes
sd_specCardcode, 0 to 15Physical layer specification version (SCR)
bus_widthsCardmask, 0 to 15What the card claims: bit 0 is 1-bit, bit 2 is 4-bit
bus_widthCardbits, 0 to 8Data lines in use, 1 or 4. A healthy slot reads 4; 1 means three lines do not reach the card
freq_khzCardkHzWhere the host clock ended up
card_max_khzCardkHzThe fastest clock the card said it takes
rcaCardcode, 0 to 65535Relative card address from identification
uhs1CardboolUHS-I capable. This board cannot use it

Fields: erase

How the card erases, from the SSR.

FieldSourceUnit and rangeWhat it is
erase_stateCardboolWhat an erased block reads back as
au_kbCardKBAllocation unit, the size the card erases in (SSR)
erase_size_auCardAUAllocation units one erase covers
erase_timeout_sCards, 0 to 255Time asked to erase one allocation unit
erase_offset_sCards, 0 to 255Constant added to every erase timeout
discardCardboolThe card supports DISCARD
fuleCardboolThe card supports Full User area Logical Erase

Fields: volume and knobs

The driver's own state, the FAT volume and the two writable fields.

FieldSourceUnit and rangeWhat it is
mountedSelfboolWhether a volume is mounted now. Reads 0 for a lent card
hostedSelfboolWhether the next mount reuses esp-hosted's controller. Read-only
total_mb, free_mbFATMBThe volume's size and unused space
width_reqSelf, writablebits, 1 or 4Lines the next mount asks for. 2 and 3 are refused. Default 4
freq_req_khzSelf, writablekHz, 400 to 50000Clock 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

CallWhat 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(&sectors), 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

FileWhat it does
include/kdrv_sd.h, src/kdrv_sd.cThe card's field table, bring-up with the width retry, format, raw sectors, lending
include/kdrv_fs.h, src/kdrv_fs.cThe storage partition mounted through wear levelling
include/kdrv_storage.h, src/kdrv_storage.cVirtual 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