# 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

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

- **Six lines, no bridge chip.** `J2` connects straight to the ESP32-P4 on the pins its SDMMC slot 0 fixes in the IO mux. Nothing in the code assigns them, and `kbsp_pins.h` lists them for reference.
- **The P4 powers the card.** `SD1_VDD` comes through Q1, a P-MOSFET. Its gate is `SD_PWR` on 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_CD` is IO6 of the ESP32-C5. `sdk/kdrv/c5` reads it as the field `sd_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:

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.

- `/` is a directory with exactly two children, `sdcard` and `storage`. 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 is `KODE_DENIED`. The console cleans the paths people type before they get here.
- `kdrv_storage_stat()` answers `KODE_OK` with `exists` false for a path that is not there. A malformed path or one that names no volume is `KODE_DENIED`. A volume that will not mount is `KODE_ERROR`.
- `mkdir`, `rmdir` and `unlink` have no recursive form. `unlink` checks first that the path is a file.
- `rename` across `/sdcard` and `/storage` is refused with `KODE_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(&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

| 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

> **WARNING**
>
> **Some cards shipped with a Kode Dot are counterfeit, and writing to them fails.** The tell is in the CID: manufacturer `0x05`, an empty OEM field, the product name `asdfg`, serial numbers that run in sequence across a batch, and a capacity of exactly 30000 MB. The card claims 4-bit in its SCR and then fails writes on a 4-bit bus at every clock from 400 kHz to 40 MHz, while a SanDisk card in the same slot writes at all of them. Reads work on every width. The capacity is real, since a full surface check marked 64 of 64.
>
> Measured on a Kode Dot, 4-bit runs at 12.3 MB/s write and 15.5 MB/s read, and the 1-bit fallback at 4.8 MB/s and 4.9 MB/s. Which width a counterfeit card comes up on varies from boot to boot.

> **DANGER**
>
> **One failed write poisons the volume until it is mounted again.** FATFS is left holding sectors it can never flush, and every later open on that card returns length 0, so every file looks empty. `kdrv_storage_open_path()` answers `KODE_DENIED` for a file that opens with length 0 and logs `'<path>' is empty`. That is why `kdrv_storage_create()` tries the internal volume first. Mount the card again to recover. A card that lies about its size is a different fake: only a write to the last megabyte shows it, which is what the `sd_verify` verb does.

> **WARNING**
>
> **A mount or a format can fail and leave no card.**
>
> - **A mount failure is usually a power problem.** The card's supply is `SD_PWR` on GPIO22 through Q1, and the pull-ups share that rail. The error log names both. Check the card is inserted, then Q1.
> - **A failed format leaves no card.** ESP-IDF's format can free the card and still report success, so `khal_sdmmc` checks and `kdrv_sd_format()` drops the snapshot. After `KODE_ERROR`, mount again before anything else.

> **NOTE**
>
> **Smaller points about the calls.**
>
> - **A raw write is invisible to the filesystem.** `kdrv_sd_write_sectors()` on a mounted card writes over whatever FAT had there and nothing reconciles it. Use it only on a card about to be formatted with no file open. The card row of the production test only reads. `sd_verify` writes the last megabyte and then formats, in that order.
> - **`width_req` is a request and `bus_width` is the answer.** The table has both so that a difference shows. The knobs are static variables that a boot resets. The card test and `sd_verify` set `width_req` back to the board's width when they start and end, and they leave `freq_req_khz` as it was.
> - **The probe gives up after one failure.** If the card failed once this boot, `kdrv_storage_probe()` keeps returning `NULL` and a working card inserted later stays unseen until an explicit `kdrv_storage_mount()` (the console's `sd_mount`).
> - **`kdrv_storage_create()` may ignore `first`.** If the preferred medium refuses, it uses the other without saying so. Read the `medium` it returns. A caller that must test the card, as the card row does, checks that value and discards the file when it landed on the internal volume.
> - **`discard()` after a clean `close()` deletes nothing.** `close()` forgets the write path, so the sequence create, close, open, discard cannot remove the first file by accident.

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

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/storage): the three drivers, `kdrv_sd.c`, `kdrv_fs.c` and `kdrv_storage.c`.
- [`kdrv_sd.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/storage/include/kdrv_sd.h): the field enum and the card calls. [`kdrv_storage.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/storage/include/kdrv_storage.h): the path and flat calls.
- [`components/kaos/storage`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/storage): AO_Storage, the owner, with the worker and the card test (`sdk/kaos/storage`).
