micro_sd_fat32_fs.spin2
The SD card driver provides full FAT32 filesystem access for the Parallax Propeller 2 (P2). It runs a dedicated worker cog that owns the SPI hardware, accepts commands from any calling cog via a shared mailbox, and serializes all card access through a hardware lock.
Key architectural features:
- Smart pin SPI with streamer DMA for hardware-accelerated sector transfers
- Multi-file handle system supporting simultaneous file and directory handles (default 6, user-configurable)
- Per-cog current working directory for safe multi-cog filesystem navigation
- Single-writer policy preventing concurrent write corruption
- Hardware-accelerated CRC-16 using the P2's
GETCRCinstruction - Exported STRUCT types for named access to SD card registers and FAT32 on-disk structures
- Conditional compilation with 8 feature flags for minimal or full builds
- Next-fit cluster allocation with wrap-around for efficient free-space reuse
- Auto-flush on idle -- worker cog flushes dirty handles after 200ms without commands
- Defragmentation -- query fragmentation, compact existing files, create contiguous files
- Non-blocking file I/O -- async read/write with poll-based completion
┌──────────────────────────────────────────────────────────┐
│ Calling Cog(s) (up to 8) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Cog 0 │ │ Cog 1 │ │ Cog N │ │
│ │ CWD: / │ │ CWD: /A │ │ CWD: /B │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └────────────┼────────────┘ │
│ │ send_command() │
│ ┌─────▼─────┐ │
│ │ Mailbox │ pb_cmd, pb_param0..3 │
│ │ (Hub RAM) │ pb_status, pb_data0..1 │
│ └─────┬─────┘ │
│ │ │
│ ┌─────▼─────────────────┐ │
│ │ Worker Cog (fs_worker) │ │
│ │ - Owns SPI pins │ │
│ │ - Command dispatch │ │
│ │ - FAT32 operations │ │
│ └─────┬─────────────────┘ │
│ │ │
│ ┌─────▼─────┐ │
│ │ Smart Pin │ P_TRANSITION (SCK) │
│ │ SPI + DMA │ P_SYNC_TX (MOSI) │
│ │ │ P_SYNC_RX (MISO) │
│ └─────┬─────┘ │
│ │ │
└────────────────────┼──────────────────────────────────────┘
│
┌─────▼─────┐
│ SD Card │
└───────────┘
The driver operates in three modes:
| Mode | Value | Set By | Allowed Operations |
|---|---|---|---|
MODE_NONE |
0 | Initial state | Only mount() or initCardOnly() |
MODE_RAW |
1 | initCardOnly() |
Raw sector read/write only |
MODE_FILESYSTEM |
2 | mount() |
Full filesystem + raw access |
Filesystem commands are rejected with E_NOT_MOUNTED when not in MODE_FILESYSTEM.
mount() validates the volume's structure before accepting it, first failure wins: the MBR partition-type byte must name a FAT32 partition (E_NOT_FAT32), the VBR's bytes-per-sector must be 512 (E_BAD_SECTOR_SIZE), the FAT count must be 2 (E_NOT_FAT32), and sectors-per-cluster must be a power of two and nonzero (E_NOT_FAT32 -- zero is rejected rather than dividing by zero in the worker). A structurally corrupt card therefore fails the mount with a specific code instead of hanging. A bad FSInfo sector is deliberately tolerated at mount -- the free-count hint is rebuilt lazily -- and surfaces as E_BAD_FSINFO only if a later update to it fails at sync/unmount time.
The P2 Edge Module microSD socket has no card-detect pin, and the SD specification defines no software-only detection method for SPI mode. The driver uses a behavioral approach: probe the card with CMD0 and analyze the MISO line response.
Before the CMD0 probe, the driver enables a P2 internal 15K pull-up resistor on the MISO pin:
wrpin(miso, P_HIGH_15K) ' Enable 15K pull-up on MISO
pinf(miso) ' Float pin (input with pull-up active)
waitus(10) ' Let pull-up settle
This creates a definitive electrical signal:
| Scenario | MISO Behavior | cmd() Result |
|---|---|---|
| Card present | Card drives MISO, responds within 0-8 bytes | Non-zero (typically $01) |
| No card | Pull-up holds MISO high, every byte is $FF | 0 (timeout) |
The driver sends CMD0 up to 5 times, tracking whether any attempt received a non-timeout response. After the retry loop:
- All timeouts (MISO never driven):
E_NO_CARD-- no card is physically present - At least one response but not $01:
E_BAD_RESPONSE-- card present but not initializing - Got $01: card is present and idle, initialization continues
The pull-up is automatically cleared when the SPI smart pins are configured for normal operation.
Every P2 I/O pin has configurable internal pull resistors. The 15K pull-up (P_HIGH_15K) is ideal: strong enough for reliable $FF reads with no card, weak enough that any SD card (output impedance under 100 ohms) easily overpowers it. This makes detection self-contained -- no external pull-up resistors are needed on any board design.
For full electrical analysis and SD specification research, see DOCs/Reference/CARD-PRESENCE-DETECTION.md.
The driver uses #ifdef / #endif blocks to exclude optional features from minimal builds. The core driver compiles to ~24 KB with no flags defined.
| Flag | Features Included |
|---|---|
SD_INCLUDE_ASYNC |
Non-blocking file I/O: startReadHandle(), startWriteHandle(), isComplete(), getResult() |
SD_INCLUDE_DEFRAG |
Defragmentation: fileFragments(), compactFile(), createFileContiguous() |
SD_INCLUDE_RAW |
Raw sector read/write, initCardOnly(), multi-block (CMD18/CMD25) |
SD_INCLUDE_REGISTERS |
CID, CSD, SCR, SD Status register access, OCR, VBR read |
SD_INCLUDE_SPEED |
CMD6 high-speed mode query and switch (50 MHz) |
SD_INCLUDE_DEBUG |
Debug getters, CRC diagnostic methods, display utilities |
SD_INCLUDE_TEST_HOOKS |
Fault injection: setTestFailSector(), setTestFailWriteAfter(), the CRC error-injection hooks, clearTestErrors() |
SD_INCLUDE_ALL |
Enables all of the above (not STACK_CHECK) |
The methods behind SD_INCLUDE_DEBUG are diagnostic instruments, documented
NOT FOR PRODUCTION USE, and they sit outside this project's semantic-versioning
compatibility promise. They may be renamed, changed, or removed in a patch
release when their name or documentation is found to misdescribe what they do —
correcting a misleading diagnostic is worth more than preserving its spelling.
Every other public method is covered by the usual promise: a rename or signature change is a breaking change and forces at least a minor bump.
Established 2026-07-26 (v1.6.1), when debugClearRootDir() was renamed
debugZeroRootSector() because the old name asserted the opposite of the code's
actual behavior. That rename shipped in a patch release under this policy.
SD_PINS_EXTERNAL is a build define (-D SD_PINS_EXTERNAL), not an
exportdef feature flag. It selects an external SD header (base pins 16–21)
instead of the P2 Edge onboard slot. tools/run_test.sh and
tools/run_regression.sh pass it through with --external.
Flags are exported from the top-level file using #pragma exportdef before the OBJ declaration:
#pragma exportdef SD_INCLUDE_RAW
#pragma exportdef SD_INCLUDE_REGISTERS
OBJ
sd : "micro_sd_fat32_fs"
Or enable everything:
#pragma exportdef SD_INCLUDE_ALL
OBJ
sd : "micro_sd_fat32_fs"
| Configuration | Size |
|---|---|
| Core (no flags) | ~21 KB |
SD_INCLUDE_ALL |
~26 KB |
All cog-to-worker communication flows through shared hub RAM variables:
| Register | Direction | Purpose |
|---|---|---|
pb_cmd |
Caller -> Worker | Command opcode (0 = idle) |
pb_param0..3 |
Caller -> Worker | Command parameters |
pb_caller |
Caller -> Worker | Calling cog's ID (for COGATN wakeup) |
pb_status |
Worker -> Caller | Result status code |
pb_data0..1 |
Worker -> Caller | Result data (handle, count, pointer) |
- Caller acquires hardware lock (prevents other cogs from sending commands)
- Caller writes
pb_param0..3,pb_caller := COGID(), thenpb_cmd := command - Caller sleeps via
WAITATN()(efficient hardware sleep, not polling) - Worker completes command, sets
pb_cmd := CMD_NONE, wakes caller viaCOGATN(1 << pb_caller) - Caller reads
pb_statusandpb_data0for results - Caller releases hardware lock
Core Filesystem Commands:
| Command | Value | Parameters | Returns |
|---|---|---|---|
CMD_MOUNT |
1 | (pins via DAT) | status |
CMD_UNMOUNT |
2 | -- | status |
| (3–8 unused) | 3–8 | (reserved gaps from removed V1 API) | -- |
CMD_NEWDIR |
9 | param0=dirname | status |
CMD_DELETE |
10 | param0=filename | status |
CMD_RENAME |
11 | param0=old, param1=new | status |
CMD_CHDIR |
12 | param0=path | status |
CMD_READDIR |
13 | param0=entry index | data0=entry ptr |
CMD_FILESIZE |
14 | -- | data0=file size (unused -- no public caller after V1 removal) |
CMD_FREESPACE |
15 | -- | data0=free sectors |
CMD_SYNC |
16 | -- | status |
CMD_MOVEFILE |
17 | param0=name, param1=dest | status |
Handle-Based File Commands:
| Command | Value | Parameters | Returns |
|---|---|---|---|
CMD_OPEN_READ |
30 | param0=path | data0=handle |
CMD_OPEN_WRITE |
31 | param0=path | data0=handle |
CMD_CREATE |
32 | param0=path | data0=handle |
CMD_CLOSE_H |
33 | param0=handle | status |
CMD_READ_H |
34 | param0=handle, param1=buf, param2=count | data0=bytes |
CMD_WRITE_H |
35 | param0=handle, param1=buf, param2=count | data0=bytes |
CMD_SEEK_H |
36 | param0=handle, param1=position | status |
CMD_TELL_H |
37 | param0=handle | data0=position |
CMD_FILESIZE_H |
38 | param0=handle | data0=size |
CMD_SYNC_H |
39 | param0=handle | status |
CMD_SYNC_ALL |
40 | -- | status |
CMD_EOF_H |
41 | param0=handle | data0=bool |
Directory Handle Commands:
| Command | Value | Parameters | Returns |
|---|---|---|---|
CMD_OPEN_DIR |
43 | param0=path | data0=handle |
CMD_READ_DIR_H |
44 | param0=handle | data0=entry ptr |
CMD_CLOSE_DIR_H |
45 | param0=handle | status |
Other Core Commands:
| Command | Value | Parameters | Returns |
|---|---|---|---|
CMD_SET_VOL_LABEL |
46 | param0=label string | status |
Conditional Commands:
| Command | Value | Guard | Purpose |
|---|---|---|---|
CMD_READ_SECTORS |
18 | SD_INCLUDE_RAW |
Multi-block read (CMD18) |
CMD_WRITE_SECTORS |
19 | SD_INCLUDE_RAW |
Multi-block write (CMD25) |
CMD_READ_SECTOR_RAW |
20 | SD_INCLUDE_RAW |
Single sector read |
CMD_WRITE_SECTOR_RAW |
21 | SD_INCLUDE_RAW |
Single sector write |
CMD_INIT_CARD_ONLY |
22 | SD_INCLUDE_RAW |
Raw mode init (no FS) |
CMD_GET_CARD_SIZE |
23 | SD_INCLUDE_RAW |
Card capacity in sectors |
CMD_READ_SCR |
24 | SD_INCLUDE_REGISTERS |
Read SCR register |
CMD_DEBUG_SLOW_READ |
25 | SD_INCLUDE_DEBUG |
Byte-by-byte sector read |
CMD_DEBUG_ZERO_ROOT_SEC |
26 | SD_INCLUDE_DEBUG |
Zero the first root-directory sector only |
CMD_READ_CID |
27 | SD_INCLUDE_REGISTERS |
Read CID register |
CMD_READ_CSD |
28 | SD_INCLUDE_REGISTERS |
Read CSD register |
CMD_READ_SD_STATUS |
29 | SD_INCLUDE_REGISTERS |
Read SD Status (ACMD13) |
CMD_CREATE_CONTIGUOUS |
47 | SD_INCLUDE_DEFRAG |
Create file with contiguous chain |
CMD_COMPACT_FILE |
48 | SD_INCLUDE_DEFRAG |
Defragment existing file |
CMD_FILE_FRAGMENTS |
49 | SD_INCLUDE_DEFRAG |
Count file fragmentation |
File handles and directory handles share a single pool of MAX_OPEN_FILES slots (default 6, user-configurable). Each slot can hold either a file or a directory enumeration handle.
| Flag | Value | Meaning |
|---|---|---|
HF_FREE |
0 | Slot available |
HF_READ |
1 | Open for reading |
HF_WRITE |
2 | Open for writing |
HF_DIR |
4 | Directory enumeration handle |
HF_DIRTY |
$80 | Buffer has pending writes (OR'd with mode) |
Each handle slot contains:
| Array | Type | File Use | Directory Use |
|---|---|---|---|
h_flags |
BYTE | HF_READ or HF_WRITE | HF_DIR |
h_attr |
BYTE | FAT attributes byte | Directory attributes |
h_start_clus |
LONG | First cluster of file | First cluster of directory |
h_cluster |
LONG | Current cluster in chain | Current cluster during enum |
h_sector |
LONG | Current data sector | Current directory sector |
h_position |
LONG | Byte offset in file | Entry index (0-based) |
h_size |
LONG | File size in bytes | 0 (unused) |
h_dir_sector |
LONG | Sector of directory entry | 0 (unused) |
h_dir_offset |
WORD | Offset within dir sector | 0 (unused) |
h_buf[512] |
BYTE | Per-handle data buffer | Per-handle sector cache |
h_buf_sector |
LONG | Sector in buffer (-1=none) | Sector in buffer (-1=none) |
h_prealloc_end |
LONG | Last pre-allocated cluster (defrag only, 0=normal) | 0 (unused) |
allocateHandle() Find free slot (h_flags == HF_FREE)
|
v
Populate state Caller sets h_flags, h_start_clus, h_sector, etc.
|
v
Use handle readHandle / writeHandle / readDirectoryHandle
|
v
closeFileHandle() Flush dirty buffer, update dir entry, freeHandle()
or closeDirectoryHandle()
|
v
freeHandle() Clear all state, h_flags := HF_FREE
File operations reject directory handles and vice versa:
readHandle(),writeHandle(),seekHandle(), etc. checkh_flags[handle] & HF_DIRand returnE_NOT_A_DIR_HANDLEif setreadDirectoryHandle()checksh_flags[handle] <> HF_DIRand returnsE_INVALID_HANDLEif not a directory handle
The driver prevents two handles from writing the same file simultaneously:
- Each file is uniquely identified by its
(dir_sector, dir_offset)pair isFileOpenForWrite()scans all handles for a matching pair withHF_WRITEsetopenFileWrite()and write operations call this check before proceeding- Multiple read handles to the same file are allowed
- Violation returns
E_FILE_ALREADY_OPEN(-92)
Each P2 cog maintains its own current working directory:
DAT
cog_dir_sec LONG 0[8] ' Per-cog CWD sector (one per P2 cog)
- Indexed by
pb_caller(the calling cog's ID) - Initialized to
root_secfor all 8 cogs at mount time changeDirectory()only modifiescog_dir_sec[pb_caller]searchDirectory()readscog_dir_sec[pb_caller]as its starting pointreadDirectory()enumerates fromcog_dir_sec[pb_caller]
This ensures Cog A can cd /FOLDER1 while Cog B does cd /FOLDER2 without interference.
Three 512-byte shared buffers serve the worker cog's internal operations:
| Buffer | Cache Variable | Purpose |
|---|---|---|
buf (512 bytes) |
sec_in_buf |
General data sector I/O |
dir_buf (512 bytes) |
dir_sec_in_buf |
Directory sector reads |
fat_buf (512 bytes) |
fat_sec_in_buf |
FAT table reads/writes |
Additionally, entry_buffer (32 bytes) holds the most recently read directory entry.
Each handle has its own 512-byte sector buffer:
h_buf BYTE 0[512 * MAX_OPEN_FILES] ' 512 bytes per handle
h_buf_sector LONG 0[MAX_OPEN_FILES] ' Which sector is cached (-1 = none)
Per-handle buffers eliminate thrashing when alternating between multiple open files. The pointer to a handle's buffer is @h_buf + (handle * 512).
| Component | Size |
|---|---|
| Shared buffers (buf + dir_buf + fat_buf) | 1,536 bytes |
| Entry buffer | 32 bytes |
| Per-handle state (32 bytes x 6) | 192 bytes |
| Per-handle buffers (512 bytes x 6) | 3,072 bytes |
| Per-cog CWD (8 LONGs) | 32 bytes |
| Worker cog stack | ~512 bytes |
| Total (6 handles, default) | ~5,376 bytes |
The default MAX_OPEN_FILES = 6 was established after directory handles were unified into the file handle pool. With the unified pool now serving both file and directory operations, the right count depends on how many cogs will perform file I/O concurrently and what each cog does.
Per-cog handle demand. A cog consumes one handle for each file or directory it has open at the same time. Handles represent reserved state (position, buffer, cluster chain) — the worker cog still serializes actual I/O, so handles don't create parallelism, they create concurrency of open resources.
| Usage Pattern | Handles Needed |
|---|---|
| Read or write a single file | 1 |
| Copy operation (read source + write destination) | 2 |
| Single file + directory enumeration | 2 |
| File copy + directory browsing | 3 |
Sizing formula. Count the cogs that will have files or directories open simultaneously (N), estimate each cog's peak handle demand, and add headroom for transient opens (e.g., a cog briefly opening a config file):
MAX_OPEN_FILES = (N × peak_handles_per_cog) + headroom
Example scenarios for 2–3 active cogs in an 8-cog system (1 cog reserved for the SD worker):
| Scenario | Cogs | Per-Cog | Headroom | Total |
|---|---|---|---|---|
| 2 cogs, each reads one file | 2 | 1 | +1 | 3 |
| 2 cogs, each does file copy | 2 | 2 | +1 | 5 |
| 2 cogs copying + dir scan | 2 | 3 | +1 | 7 |
| 3 cogs, single file + dir each | 3 | 2 | +1 | 7 |
| 3 cogs, each does file copy | 3 | 2 | +2 | 8 |
Incremental memory cost for additional handles (544 bytes each: 32 bytes state + 512 bytes buffer):
| MAX_OPEN_FILES | Handle Memory | Delta from 4 |
|---|---|---|
| 4 (old default) | 2,176 bytes | — |
| 6 (recommended) | 3,264 bytes | +1,088 |
| 8 | 4,352 bytes | +2,176 |
| 10 | 5,440 bytes | +3,264 |
Recommended default: 6. This covers the common case of 2–3 cogs with mixed file and directory operations, providing 2 handles per active cog plus room for a directory enumeration or transient open. The cost is 1,088 bytes above the previous default — a modest investment from the P2's 512KB hub RAM that avoids E_TOO_MANY_FILES errors during normal multi-cog operation. Applications with a single file-using cog can reduce to 2 or 3; applications where every cog does file I/O should size to their actual peak demand.
To override, define MAX_OPEN_FILES in the top-level object's CON block before the OBJ declaration:
CON
MAX_OPEN_FILES = 8 ' 3 cogs doing file copy + headroom
OBJ
sd : "micro_sd_fat32_fs"
Some cards deviate from the SD spec in ways that would otherwise make them
unusable. The driver probes for these at init and records what it found in an
advisory bitmask readable via cardWarnings(). Nothing here is a workaround for
a driver defect — each is a documented property of the card in the socket.
| Flag | Meaning | Driver response |
|---|---|---|
CW_CMD13_UNRELIABLE ($01) |
CMD13 probe failed at init | Runtime status checks disabled for this card |
CW_CMD23_SUPPORTED ($02) |
CMD23 (SET_BLOCK_COUNT) confirmed working in SPI mode | Used for multi-block transfers |
CW_NO_DATA_CRC ($04) |
Card returns a placeholder data-block CRC | Read-side CRC validation disabled for this card only |
Dummy data CRCs. Some counterfeit and marginal SDSC units return a constant
placeholder instead of a real data-block CRC. Under strict validation every
streamer read fails. The driver detects this at init, sets CW_NO_DATA_CRC, and
skips read-side CRC validation for that card — cards with real CRCs keep strict
validation. Because such cards are also often timing-marginal, a per-card SCK
ceiling probe (probeSpiCeiling, PROBE_SCK_SAMPLES clean reads required per
candidate half-period, derating on the first mismatch) picks a safe clock rate.
SDSC block length (CMD16). SDHC/SDXC hardwire a 512-byte block; SDSC does
not, and a macOS-formatted SDSC card may arrive with a different block length.
Init step 7.5 issues CMD16 to pin SDSC cards to 512 bytes, distinguishing
accepted / stale-R1 / no-response / rejected outcomes rather than assuming
success. The audit and fsck utilities additionally accept macOS filesystem
conventions (removable-media byte, media-derived FAT[0], zero hidden sectors,
archive-bit volume label), so such cards mount and audit cleanly.
The driver uses three smart pin modes for SPI mode 0 (CPOL=0, CPHA=0):
| Pin | Smart Pin Mode | Purpose |
|---|---|---|
| SCK | `P_TRANSITION | P_OE` |
| MOSI | `P_SYNC_TX | P_OE |
| MISO | `P_SYNC_RX | P_PLUS3_B` |
The P_PLUS2_B and P_PLUS3_B selectors route the SCK signal to the TX and RX pins respectively, based on the pin assignments (MISO, MOSI, CS, SCK are consecutive pins).
setSPISpeed(freq) calculates the SCK half-period using ceiling division to ensure the actual frequency never exceeds the target:
half_period = ceil(clkfreq / (freq * 2))
= (clkfreq + (freq * 2) - 1) / (freq * 2)
The half-period is clamped to a minimum of 4 system clocks (the hardware minimum for reliable smart pin transitions). This means the maximum achievable SPI frequency depends on clkfreq:
- At 320 MHz: max SPI = 320/(4*2) = 40 MHz, but SD spec caps at 25 MHz
- At 200 MHz: half_period=4 yields exactly 25 MHz
- Below 200 MHz: SPI drops below 25 MHz (e.g., 160 MHz -> 20 MHz)
The driver starts at 400 kHz for card initialization (SD specification requirement), then setOptimalSpeed() switches to the card's maximum speed (up to 25 MHz) after init.
initCard() issues CMD12 (STOP_TRANSMISSION) before CMD0, unconditionally, on
every driver start.
Why: the driver is not the only bus master. On the P2 Edge the microSD socket shares P58-P61 with the boot flash, and the two pin roles do not line up:
| Pin | Flash role | microSD role |
|---|---|---|
| P58 | DO | MISO |
| P59 | DI | MOSI |
| P60 | CLK | DAT3/CS |
| P61 | CS | CLK |
CLK and CS are swapped. So while the boot sequence touches flash, the card sees its own chip-select toggling at flash clock rate with traffic on its data lines — on every reset, at RCFAST, before a single user instruction executes.
A card left mid-transfer by that is streaming, not listening. CMD0 sent into
the stream is data rather than a command, so initialisation sees five silent
retries and reports E_NO_CARD for a card that is present and healthy. Nothing
downstream recovers it: a multiple-block read continues until told to stop, so
additional clocking only feeds it, and waiting does not help — both measured, at
102,400 clocks in each CS polarity and at 120 seconds of powered idle. Only
removing power cleared it.
CMD12 is what the SD specification designates for this (Part 1 Physical Layer §4.3): "All data read commands can be aborted any time by the stop command (CMD12). The data transfer will terminate and the card will return to the Transfer State."
It is safe to run unconditionally. On an idle card CMD12 has nothing to stop and returns an error bit the driver ignores; there is no state to get wrong. The cost is one command plus a short settle delay, once per start.
Scope of the original defect: it required a card that had been used before the reset, and appeared most readily on marginal or counterfeit silicon. Mainstream cards were not observed to wedge — but the exposure is a property of the board's pin sharing, not of any particular card.
The production speed bound (v1.7.1). User setSPISpeed() requests are clamped
at the card's declared TRAN_SPEED, itself capped at the SD SPI-mode 25 MHz — and
lifted to 50 MHz only while verified CMD6 high-speed mode is active. Internal speed
changes (the 400 kHz init, setOptimalSpeed(), the high-speed path) are unaffected.
The bound exists because above-spec operation was measured to produce silent
whole-sector write corruption where the fixed write-path phase pad meets
above-spec frequencies (see SPI-PHASE-MARGIN-API.md). The returned actual_hz
reports the clamped truth rather than the request. Characterization tools lift the
bound through debugSetOverspeedAllowed().
High-speed mode does not reach 50 MHz. The SPI clock is clkfreq / (2 * hp)
with integer hp, so at 350 MHz sysclk a 50 MHz request resolves to hp=4 and an
actual 43.75 MHz. isHighSpeedActive() therefore reports the card's mode, not
a clock threshold — a frequency comparison would answer FALSE at this project's own
sysclk while high-speed mode was genuinely active. Use getSPIFrequency() for the
clock and isHighSpeedActive() for the mode; they answer different questions.
Leaving high-speed mode is explicit. The CMD6 switch is sticky at the card: a
host that drops its clock without switching the card back strands the link, and
every subsequent transfer fails until re-init. All four exits — the three
verification-failure paths, a user setSPISpeed(), and unmount() — route through
the same switch-back.
Sector transfers use the P2's streamer engine for hardware-accelerated bulk data movement. The streamer moves data between hub RAM and the SPI pins at the full SPI clock rate with no per-byte cog intervention, which is where the driver's 4–5x throughput advantage over a byte loop comes from.
Read (512 bytes from card) — readSector():
wrfast #0, p_buf ' Prime FIFO for hub writes (before DIR-rise)
setxfrq xfrq ' Streamer NCO rate (before DIR-rise)
fltl _sck ' Reset SCK smart pin
dirh _sck ' DIR=1 -- base-period counter starts cycling at hp
wypin clk_count, _sck ' Y written 2 sysclks after DIRH, inside the first hp
waitx align_delay ' Phase pad: sample point vs SCK edge
xinit stream_mode, init_phase ' Start RX streamer
waitxfi ' Wait for 512 bytes received
The read path's ordering constraint is not the same as the write path's. WYPIN must
execute before the first base-period boundary after DIR-rise, or the first SCK
transition slips by a full half-period when hp <= 6 — so WYPIN immediately follows
DIRH with no instruction between it and the DIR-rise, and the phase pad is applied
after. readSectors() uses DIRL+DRVL in place of FLTL+DIRH but keeps the same
WYPIN-then-WAITX-then-XINIT order.
Write (512 bytes to card):
setxfrq xfrq ' Streamer bit rate (before SCK reset)
rdfast #0, p_buf ' Prime FIFO -- variable hub wait lands HERE
dirl _sck ' Reset SCK base-period counter
drvl _sck ' Re-enable, counter restarts fresh
waitx tx_align_delay ' Phase pad: data bitstream vs SCK edge
xinit stream_mode, #0 ' Start TX streamer
wypin clk_count, _sck ' Start clock
waitxfi ' Wait for completion
The ordering of those two blocks is not stylistic. It is the subject of the next section.
This is the most important invariant in the driver's PASM2, and it was learned the hard way in v1.7.0.
The mechanism. DIRL/DRVL on the SCK pin resets the P_TRANSITION smart pin's base-period counter, and every SCK edge thereafter lands on a grid anchored at that DIR-rise. XINIT starts the streamer, whose NCO ramps from zero at the instant it executes. So the phase between the outgoing data bitstream and the clock edges that latch it is determined entirely by how many sysclks elapse between the DRVL and the XINIT.
Every instruction in that window must therefore take a fixed, known number of clocks. Cog ops like WAITX, XINIT and WYPIN do — two clocks each. RDFAST does not. It blocks while the FIFO fills, and P2KB documents that as WRFAST finish + 10...17 sysclks — a variable latency, not a fixed instruction count.
What drives that variability is not established. P2KB describes a hub operation as completing "in 2-9 clocks depending on when the COG's slot arrives" — a property of issue time — while its hub-slicing section can be read as making it a property of the address requested. The invariant below holds either way, and does not depend on resolving it.
The consequence, before the fix. With RDFAST sitting between the SCK reset and XINIT, the data-to-clock phase was not a compile-time constant. Phases that fell outside the passing window stored every sector one bit late at the card — silently. The failure tracked the build: enabling SD_INCLUDE_SPEED, which shifts the driver's DAT by 36 bytes, was sufficient to turn a passing configuration into a failing one. That correlation is measured; the causal path from a DAT shift to a phase change is not, and the invariant does not require it.
Why nothing caught it. Two independent mechanisms that look like they should have:
- The card's data-response token said the write was fine. In SPI mode, write-CRC checking is off unless the host enables it with CMD59, and this driver never sends CMD59. So
dresp = $05("data accepted") only ever proved the packet framing was well-formed — never that the payload bits were the ones the caller supplied. A spec-conforming card with CRC enforcement on would have rejected these writes outright. - Reading the sector back matched. The shifted bytes were genuinely what the card had stored, so a read returned them faithfully and a byte-compare against the card's contents agreed with itself. The comparison was tautological. The read path was hardened in an earlier phase and was never affected, which is exactly why the defect presented as "writes are fine, some builds just fail" rather than as a phase problem.
The fix. SETXFRQ and RDFAST (and WRFAST on the read side) are hoisted above the SCK reset, so the variable latency is spent before the phase-critical window opens. From DRVL onward every instruction is a fixed-two-clock cog op, XINIT sits at a compile-time-constant offset from the reset, and the phase becomes a build-independent constant.
Then, and only then, is a single tuned default meaningful. tx_align_delay pads the window to center that constant phase. Its value was measured on the bench: pad-to-phase is a sawtooth of period hp, so only hp distinct phases exist however far the pad is swept. At hp=7 exactly one loses — pad ≡ 1 (mod 7), observed failing at pads 8, 15, 22 and 29 across three runs — and the shipped default of 4 is the centre of the measured passing band 2..7.
Invariance is not merely argued — it was measured on 2026-08-13: sweeping the streamer's hub buffer through all eight long-aligned positions, on both the write and read paths, returned correct data at every point. See DOCs/DRIVER-EVOLUTION-v1.6.0-to-v1.7.0.md §4.7 and DOCs/SPI-PHASE-MARGIN-API.md.
The general lesson, which outlives this bug. When a smart pin's timing is anchored by an instruction you execute, every instruction between that anchor and the dependent event is part of the timing contract. A hub-touching instruction in that window silently converts a timing constant into something that is not one. What else it then depends on can be genuinely hard to pin down — in this driver's case it was never identified, and the fault was observed exactly once despite the ordering being present in eighteen releases. The remedy does not require knowing: move the variable-latency instruction out of the window and the question stops mattering.
The read path's align_delay (the WAITX phase pad in the streamer read block above, computed as hp plus an offset that defaults to +5 since v1.7.1) has the same character as tx_align_delay: it selects where, within the bit cell, the streamer samples MISO. A 2026-08-17 bench characterization campaign measured its passing band directly and produced three facts a reader of this driver should know.
The requirement rises with SPI frequency and with wiring delay. The passing band's lower edge follows align_delay ≥ hp + k, where k grows as the SPI clock rises, because the card's clock-to-output delay (t_ODLY) plus the round-trip wire time consumes a growing fraction of the shrinking bit cell. When align_delay falls below the band, the streamer captures each bit one position early and the entire 512-byte payload arrives as a one-bit right shift — confirmed byte-for-byte on the bench ($C1 stored, $E0 received, uniformly across a whole sector). Slow-launching silicon (measured on a counterfeit-class SDSC card) exits the band at lower frequencies than mainstream cards; every card measured to date is inside the band at the production 25 MHz default.
The default now sits at the band's centre. Through v1.7.0 the shipped
align_delay = hp + 0 sat at the band's lower edge. Five independent passing-band
sweeps — two card families, both sockets, three frequencies — all centred on +5,
and a later survey across four further controller families (SanDisk, Samsung,
Longsys, Phison) found every one of them centred there too. The offset default moved
to +5 in v1.7.1. One exception is carried: at hp=4 the positive offset is withheld,
because +5 exceeds the whole bit period there and that cell's band has not been
characterized in the card state where it actually occurs.
Two physical sockets on the same board differ measurably. On the reference bench (P2 Edge module), the module's onboard microSD socket and an external header-wired adapter socket were characterized against each other with cards swapped between them to separate card effects from socket effects. The adapter's extra wiring consumes one additional alignment tick at 350 MHz sysclk — as a physical quantity, between 0 and ~5.7 ns of additional round-trip delay, most likely ~3 ns (the method quantizes to one 2.857 ns sysclk tick; state this number in nanoseconds, since the tick count it costs depends on sysclk). The same extra delay lowers the adapter's command-response ceiling: R1 responses arrive bit-slipped above ~37 MHz on the adapter (out-of-spec territory — production 25 MHz carries wide margin), while the onboard socket is clean at every frequency the driver can generate at 350 MHz sysclk.
CRC does not referee this failure on every card. Cards in the dummy-data-CRC quirk class (CW_NO_DATA_CRC) return a constant in the CRC position, so a one-bit-shifted payload from such a card raises no CRC error — the corruption is silent. Detecting misalignment on these cards requires comparing a streamer read against a byte-by-byte smart-pin read of the same sector, which is how the characterization instruments (diagnostic-tests/SD_socket_shmoo.spin2, SD_phase_sweep_test.spin2) score them. This is why any alignment mitigation must be proactive (measure the band, sit inside it) rather than reactive to CRC errors.
Measurement conditions for all numbers above: 350 MHz sysclk except where noted, sector reads scored by CRC compare and byte-compare against a low-speed reference, ±1 sysclk tick resolution. The full account, including the method and its limits, is in Socket Timing Characterization.
After card initialization completes (CMD0/CMD8/ACMD41/CMD58), the driver reads two card registers to configure itself for the specific card inserted:
CID register (16 bytes, via CMD10): Contains manufacturer identity. The driver extracts the manufacturer ID byte (byte 0) to identify the card brand.
CSD register (16 bytes, via CMD9): Contains the card's electrical and timing characteristics. The driver extracts three fields:
| CSD Field | Location | What It Tells Us |
|---|---|---|
| TRAN_SPEED | Byte 3 | Maximum SPI clock frequency the card supports |
| TAAC + NSAC | Bytes 1-2 | Read access time (how long a read operation may take) |
| R2W_FACTOR | Byte 12, bits [4:2] | Write-to-read timeout ratio (writes take longer than reads) |
SPI clock speed (card_max_speed_hz): TRAN_SPEED is a two-part encoded field -- a time value multiplier (bits [6:3]) and a rate unit (bits [2:0]). The driver decodes this to get the card's maximum transfer rate in Hz. Most SDHC/SDXC cards report 25 MHz. The SPI clock is set to the lesser of the card's reported maximum and 25 MHz (the SD SPI mode ceiling).
Read timeouts (card_read_timeout_ms): Used by readSector() and waitDataToken() when waiting for the card to deliver data. If the card doesn't respond within this window, the operation returns E_TIMEOUT.
- SDHC/SDXC (CSD v2.0): Fixed at 100ms per specification
- SDSC (CSD v1.0): Calculated from TAAC and NSAC with a 100x safety factor, minimum 100ms
Write timeouts (card_write_timeout_ms): Used by writeSector() and waitBusyComplete() when waiting for the card to finish programming flash. Write timeouts are longer than read timeouts because flash programming is inherently slower than reading.
- SDHC/SDXC: Fixed at 500ms (2x the 250ms spec value, for margin on cold writes and sector 0)
- SDSC: Read timeout multiplied by 2^R2W_FACTOR, clamped to 250ms-1000ms
If CID or CSD reads fail (rare but possible on marginal cards), the driver uses safe defaults:
| Parameter | Default | Rationale |
|---|---|---|
card_max_speed_hz |
25 MHz | SD SPI mode maximum |
card_read_timeout_ms |
100 ms | SDHC spec value |
card_write_timeout_ms |
500 ms | Conservative margin |
readSectors(start_sector, count, p_buffer) reads consecutive sectors in a single command:
- Send CMD18 with starting sector address
- For each sector: wait for
$FEtoken, streamer-receive 512 bytes, validate CRC - Send CMD12 (STOP_TRANSMISSION) to end the transfer
- Verify card status with CMD13
writeSectors(start_sector, count, p_buffer) writes consecutive sectors:
- Send CMD25 with starting sector address
- For each sector: send
$FCtoken, streamer-transmit 512 bytes + CRC, wait for card busy - Send
$FDstop token, wait for final programming - Verify card status with CMD13
Multi-sector operations are significantly faster than single-sector loops because they eliminate per-sector command overhead and allow the card's internal controller to optimize flash writes for sequential access.
The driver validates data integrity using CRC-16-CCITT on every sector transfer:
PRI calcDataCRC(pData, len) : crc | raw
raw := GETCRC(pData, CRC_POLY_REFLECTED, len)
crc := ((raw ^ CRC_BASE_512) REV 31) >> 16
- Uses the P2's hardware
GETCRCinstruction (no lookup table needed) CRC_POLY_REFLECTED($8408) is the CRC-16-CCITT polynomial in LSB-first formCRC_BASE_512($2C68) compensates forGETCRCinitialization differences- The
REV 31+>> 16converts from reflected to standard bit order - CRC validation is enabled by default (
diag_crc_enabled= 1) - Reads: Card sends CRC after 512 data bytes; driver calculates CRC from received data and compares
- Writes: Driver calculates CRC from data and sends it after the 512 data bytes
Match/mismatch counters and the setCRCValidation() toggle are available via SD_INCLUDE_DEBUG (see Conditional Compilation).
The driver uses a next-fit allocator rather than first-fit. After each allocation, fsi_nxt_free records the next cluster number to try, so sequential file writes don't re-scan from the beginning of the FAT. This reduces FAT sector reads from O(N) to O(1) for typical sequential writes.
- Choose starting scan position:
- If extending an existing chain: start after the previous cluster
- If starting a new chain: use the
fsi_nxt_freehint from FSInfo - Fallback: cluster 2 (first data cluster)
- Scan forward through the FAT looking for a zero (free) entry
- If the scan reaches the end of the FAT without finding a free cluster, wrap to cluster 2 and continue
- If the scan returns to the starting position after wrapping, the disk is full (
E_DISK_FULL) - When a free cluster is found: mark it as EOC (
$0FFFFFFF), link the previous cluster if extending, write both FAT copies, updatefsi_nxt_freeandfsi_free_count
The driver maintains fsi_free_count and fsi_nxt_free incrementally during operation (rather than scanning the entire FAT). Both are persisted to the FSInfo sector at unmount.
All file data is written by do_write_h (the sole file-data write path; both writeHandle() and the async startWriteHandle() dispatch to it). It writes a sector at a time out of the per-handle buffer, advancing position as it goes. Three rules govern what happens at the edges — and each rule exists because getting it wrong corrupts data:
When a write fills the last sector of a cluster and more data remains, the handle must move to the next cluster. There are two distinct cases, and the driver must tell them apart:
- Overwrite (the cluster already links forward). When a file is reopened and written over existing content, the current cluster's FAT entry already points to the next cluster in the file's chain. The driver must follow that link.
- Growth (the cluster is end-of-chain). When the write extends past the file's current allocation, the current cluster's FAT entry is an EOC marker (
>= $0FFF_FFF8). Only then does the driver allocate a fresh cluster and link it on.
writeAdvanceCluster() decides between the two by reading the current cluster's FAT entry (mirroring the read path's chain-follow logic exactly): if the entry is +>= FAT32_EOC_MIN it grows via allocateCluster(), otherwise it follows the existing link. Unconditionally allocating here — the historical Bug A — overwrites a live FAT link during an in-place overwrite, orphaning the remainder of the chain and silently truncating the file on the next read. On a healthy card this loss is data-level and does not necessarily trip a structural FAT/VBR audit.
DEFRAG note. With
SD_INCLUDE_DEFRAG, a pre-allocated contiguous file (h_prealloc_end > 0) takes a fast path that advances toh_cluster + 1without reading the FAT. This is safe by construction:allocateContiguousChain()has already written the sequential FAT links for the whole reserved run, soh_cluster + 1is the chain's next cluster — the fast path follows the same linkwriteAdvanceClusterwould, just without the read. It never allocates across a boundary, so Bug A cannot occur on it.
Before modifying a sector's buffer, the driver decides whether to read the existing on-disk sector or start from a zero-filled buffer. The test is whether the sector's first byte lies within the file, not whether the write position does:
if (h_position & !SECTOR_OFFSET_MASK) < h_size ' sector-aligned base position
Appending at a position that is mid-sector (for example, a 100-byte file reopened and extended) leaves h_position == h_size, both inside the same sector. Testing h_position < h_size directly (the historical Bug B) reports "past end of file" and zero-fills the buffer, wiping the file's existing leading bytes. Sector-aligning the position first makes the test ask the correct question, so the existing bytes are loaded and preserved.
At the top of the write loop the driver refuses to write any sector below root_sec:
if h_sector[handle] < root_sec
' REFUSING metadata-region write -> report E_BAD_CHAIN
File data always lives at or after root_sec (the first data cluster maps to root_sec). A target below it means the chain walk has gone wrong and landed in the FAT, VBR, or reserved region. This is a correct-by-construction backstop: it must never fire during a passing run, and if it does it stops before corrupting filesystem metadata. It reports E_BAD_CHAIN — as the return value if nothing had been accepted yet, otherwise as the bytes written so far with the reason in handleError(). Its firing is treated as a real defect, not a recoverable condition.
Both do_read_h and do_write_h return a byte count, and every stopping point records its reason in the per-handle h_error slot. Once any byte has moved, a failure returns the partial count: the caller must be able to tell how much of the file is valid, and an error code would destroy that information. handleError() supplies the reason.
A failure that moves no bytes is the one case where that reasoning inverts. There is no partial count worth preserving, and zero is the value every read loop is written to stop on — repeat while n > 0, repeat until n == 0. Returning zero there makes a failing card indistinguishable from a finished file, and the driver's own dispatcher compounds it: pb_status := pb_data0 < 0 ? pb_data0 : SUCCESS means a zero return also drives ERROR() to report SUCCESS, and getResult() inherits the same value on the async path.
So every zero-byte failure exit returns its error code instead. readHandle() returning 0 therefore means a genuine end of file and nothing else, and both loop idioms stop for the right reason. do_read_h implements this as a single guard at its tail rather than at each stopping point, so a future early-exit cannot forget it.
The worker cog monitors idle time between commands. When no command arrives for 200ms (IDLE_FLUSH_MS), the worker scans all handles for dirty buffers and flushes them to disk:
- Worker polls
pb_cmdin its main loop - Between polls, a clock counter tracks elapsed idle time
- If
(getct() - idle_timer) >= idle_flush_clocksand no command pending:- Scan all handles for
HF_DIRTYflag - For each dirty handle: write the sector buffer to disk, update the directory entry with the current file size and timestamp, clear
HF_DIRTY
- Scan all handles for
- Reset the idle timer after flush or after any command completes
This ensures data reaches the card even if the application forgets to call syncHandle() or closeFileHandle(). The 200ms threshold is short enough for data safety but long enough that it doesn't interfere with normal write bursts.
The flush is started by the worker itself, so unlike every other write path there is no calling cog to return a status to — and no cog error slot that would be the right place to put one, since the failure belongs to no cog's operation. The worker therefore records the first failure in flush_error, which lastFlushError() exposes:
- Each
do_sync_h()that returns non-SUCCESSis recorded. updateFSInfo()'s outcome is read from itsfsinfo_errorcompanion, not from its boolean return — aFALSEthere also means "there was nothing to update".- A flush cut short by an arriving command records nothing. That abort is by design: the handles stay dirty and the next idle window retries.
The field is sticky on the first failure and is cleared only by clearFlushError(). Flushes run on a timer, so a later clean one would otherwise erase the report before the application ever polled for it. A failed flush leaves the handle HF_DIRTY, so the data is still in the driver's buffer and a later syncHandle() or closeFileHandle() can still land it.
Requires SD_INCLUDE_DEFRAG.
FAT32 allocates clusters on demand, so files written over time may end up with clusters scattered across the disk. The defragmentation API provides three operations: query fragmentation, compact existing files, and create pre-allocated contiguous files.
fileFragments(path) walks a file's FAT chain and counts transitions where next_cluster != current_cluster + 1. A contiguous file has fragment count 1. An empty file returns 0.
compactFile(path) relocates a fragmented file's clusters into a contiguous chain using a copy-then-free strategy. The file must be closed (no open handles). The process:
- Find the file and verify it is not open (
isFileOpenAny) - Skip if empty or already contiguous (fragment count = 1)
- Count total clusters by walking the FAT chain
- Find a contiguous run of N free clusters via
findContiguousRun()-- linear scan from cluster 2 - Copy each cluster from old location to new contiguous location (
copyClusterData) - Verify read-back of every copied cluster, byte-by-byte comparison (
verifyClusterCopy). If any mismatch is found, the operation aborts withE_VERIFY_FAILED-- the original data is still intact - Build new FAT chain: sequential links (3->4->5->...->EOC) via
allocateContiguousChain - Update directory entry to point to new first cluster
- Free old cluster chain
- Invalidate all sector caches
The copy-then-free ordering ensures the original data is always intact until verification succeeds. If power is lost during compaction, the original chain is still valid (the new chain is not linked from the directory until step 8).
createFileContiguous(path, file_size) creates a new empty file with a pre-allocated contiguous cluster chain. This is useful for data logging and streaming where fragmentation must be avoided:
- Calculate clusters needed:
ceil(file_size / bytes_per_cluster) - Find a contiguous run via
findContiguousRun() - Allocate the chain via
allocateContiguousChain() - Create the directory entry with the first cluster set
- Return a write handle with
h_prealloc_endset to the last pre-allocated cluster
When h_prealloc_end is non-zero, writeHandle() skips the normal allocateCluster() path at cluster boundaries. Instead, it simply advances to the next sequential cluster (which is already allocated). This eliminates FAT reads/writes during the write path, improving write throughput. If writes exceed the pre-allocated space, the write reports E_NO_CONTIGUOUS_SPACE — as the return value when the reservation ran out before any byte of that call was accepted, and via handleError() when it ran out part-way through and a partial count is returned instead.
compactFile and createFileContiguous share two low-level helpers:
findContiguousRun(count)-- linear scan from cluster 2 looking for N consecutive free FAT entries. Returns the first cluster of the run orE_NO_CONTIGUOUS_SPACE.allocateContiguousChain(first, count)-- marks a sequential run of clusters as a linked chain in both FAT copies, with the last entry set to EOC.
Requires SD_INCLUDE_ASYNC.
The standard readHandle() and writeHandle() block the caller via WAITATN until the worker completes. For applications where the calling cog must keep running (sensor polling, control loops, display updates), the async API provides non-blocking alternatives.
The async API reuses the existing command protocol but skips the WAITATN sleep:
startReadHandle()/startWriteHandle()-- acquires the API lock, writes mailbox parameters, setspb_cmd, then returns immediately withPENDING(1). The lock is held throughout the operation.isComplete()-- pollspb_cmd == CMD_NONE(the worker clearspb_cmdwhen done). Returns TRUE/FALSE without blocking.getResult()-- waits if needed, reads the result frompb_status/pb_data0, releases the lock, and drains any staleCOGATN. After this call, new commands can be issued. Runs the same worker stack-guard check as the blocking path: a violation reportsE_STACK_OVERFLOW, overriding even a successful result.cancelAsync()-- waits for the worker to finish (cannot interrupt SPI mid-transfer), discards the result, and releases the lock. Cancel discards the result, not the operation: the handle's position has moved by the bytes transferred, and a write's data landed. It runs the same stack-guard check asgetResult().
- Only one async operation can be in flight at a time, across the whole driver (
E_ASYNC_BUSYif a second is attempted -- promptly, even when two starts race) - The API lock is held for the entire duration, so no other cog can issue commands until
getResult()orcancelAsync()is called - A blocking API call (
readHandle(),closeFileHandle(),unmount(), ...) from the cog that owns the in-flight operation returnsE_ASYNC_BUSYinstead of deadlocking on the non-re-entrant lock; collect or cancel first - Only the cog that started the operation may collect or cancel it (
E_NO_ASYNC_OPfor any other cog) - The caller must not modify the data buffer while the async write is in progress
SD_INCLUDE_ASYNCis not part ofSD_INCLUDE_ALL-- it must be enabled separately
The driver defines and exports packed struct types (requires {Spin2_v45}) for named access to SD card registers and FAT32 on-disk structures. Consumer objects access them via the sd. prefix (e.g., sd.cid_t, sd.dir_entry_t).
| Struct | Size | Purpose |
|---|---|---|
cid_t |
16 bytes | CID register: manufacturer ID, product name, serial number, manufacturing date |
csd_t |
16 bytes | CSD register: card capacity, speed class, timing parameters |
scr_t |
8 bytes | SCR register: SD spec version, bus widths, security features |
| Struct | Size | Purpose |
|---|---|---|
dir_entry_t |
32 bytes | Directory entry: name, ext, attributes, timestamps, cluster, file size |
mbr_partition_t |
16 bytes | MBR partition table entry: boot flag, type, LBA start/size |
vbr_t |
512 bytes | Volume Boot Record (BPB): bytes/sector, clusters, FAT layout, volume label |
fsinfo_t |
512 bytes | FSInfo sector: free cluster count, next free hint, signatures |
Structs are overlaid onto buffers via typed pointers:
OBJ
sd : "micro_sd_fat32_fs"
PRI parseCID(p_buf)
' Overlay struct onto raw buffer
sd.cid_t pCid := @p_buf
debug("Manufacturer: ", uhex_byte_(pCid.mid))
debug("Product: ", lstr_(@pCid.pnm, 5))
All structs are packed (Spin2 default) with offsets matching their respective hardware or on-disk layouts exactly. SD card register structs are big-endian (as received from card); FAT32 structs are little-endian (native P2 byte order).
| Error | Value | Meaning |
|---|---|---|
E_TIMEOUT |
-1 | Card didn't respond in time |
E_NO_RESPONSE |
-2 | Card not responding |
E_BAD_RESPONSE |
-3 | Unexpected response from card |
E_CRC_ERROR |
-4 | Data CRC mismatch |
E_WRITE_REJECTED |
-5 | Card rejected write operation |
E_CARD_BUSY |
-6 | Card busy timeout |
E_IO_ERROR |
-7 | General I/O error |
E_NO_CARD |
-8 | No card detected in slot |
E_NOT_MOUNTED |
-20 | Filesystem not mounted |
E_INIT_FAILED |
-21 | Card initialization failed |
E_NOT_FAT32 |
-22 | Card not formatted as FAT32 |
E_BAD_SECTOR_SIZE |
-23 | Sector size not 512 bytes |
E_BAD_FSINFO |
-24 | FSInfo sector signatures invalid (free-space hint cannot be updated) |
E_BAD_CHAIN |
-25 | Cluster chain disagrees with the directory entry (chain walk went wrong) |
E_STACK_OVERFLOW |
-26 | Worker cog wrote past its stack -- nothing it reports can be trusted |
E_FILE_NOT_FOUND |
-40 | File doesn't exist |
E_FILE_EXISTS |
-41 | File already exists |
E_NOT_A_FILE |
-42 | Expected file, found directory |
E_NOT_A_DIR |
-43 | Expected directory, found file |
E_DIR_NOT_EMPTY |
-44 | Directory still contains entries; deleteFile() refused. Empty it first |
E_FILE_NOT_OPEN |
-45 | RESERVED -- never produced; a closed handle reports E_INVALID_HANDLE |
E_END_OF_FILE |
-46 | Read past end of file |
E_FILE_OPEN |
-47 | File has an open handle; deleteFile() refused. Close it first |
E_DISK_FULL |
-60 | No free clusters available |
E_NO_CONTIGUOUS_SPACE |
-61 | No contiguous run of sufficient length (defrag) |
E_FILE_OPEN_FOR_COMPACT |
-62 | File is open, cannot compact (defrag) |
E_VERIFY_FAILED |
-63 | Read-back verification failed after compact (defrag) |
E_NO_LOCK |
-64 | Could not acquire hardware lock |
E_NO_COG |
-65 | No free cog to run the worker (all eight in use) |
E_TOO_MANY_FILES |
-90 | All handle slots in use |
E_INVALID_HANDLE |
-91 | Handle out of range or not open |
E_FILE_ALREADY_OPEN |
-92 | File already open for writing |
E_NOT_A_DIR_HANDLE |
-93 | Wrong handle type for operation |
E_ASYNC_BUSY |
-95 | An async operation is already in flight -- returned by a second start*(), and by ANY blocking API call from the cog that owns the in-flight operation |
E_NO_ASYNC_OP |
-96 | No async operation to get result from |
Lifecycle:
| Method | Description |
|---|---|
mount(cs, mosi, miso, sck) |
Initialize card and mount filesystem |
unmount() |
Flush all handles, update FSInfo, unmount |
stop() |
Stop worker cog and release hardware lock; returns the final unmount's status |
error() |
Last error code for calling cog |
handleError(handle) |
Why the last read/write on this handle came up short |
lastFlushError() |
First failure of an automatic background flush (sticky) |
clearFlushError() |
Clear the automatic-flush error report |
checkStackGuard() : bIntact |
Verify worker cog stack guard is intact |
Handle-Based File Operations:
| Method | Description |
|---|---|
openFileRead(pPath) : handle |
Open existing file for reading |
openFileWrite(pPath) : handle |
Open existing file for append writing |
createFileNew(pPath) : handle |
Create new file for writing |
readHandle(handle, pBuf, count) : bytes |
Read bytes from file (short count: see handleError()) |
writeHandle(handle, pBuf, count) : bytes |
Write bytes to file (short count: see handleError()) |
seekHandle(handle, position) : result |
Seek to byte position |
tellHandle(handle) : position |
Get current byte position |
eofHandle(handle) : bool |
Check if at end of file (TRUE if the query failed too -- see error()) |
fileSizeHandle(handle) : size |
Get file size in bytes |
syncHandle(handle) : result |
Flush pending writes |
syncAllHandles() : result |
Flush all open handles |
closeFileHandle(handle) : result |
Close handle, flush writes |
File Management:
| Method | Description |
|---|---|
deleteFile(pName) : result |
Delete file or empty directory |
rename(pOld, pNew) : result |
Rename file or directory |
moveFile(pName, pDest) : result |
Move file to directory |
Directory Operations:
| Method | Description |
|---|---|
changeDirectory(pPath) : result |
Change calling cog's CWD |
newDirectory(pName) : result |
Create new directory |
readDirectory(entry) : pEntry |
Enumerate CWD by index |
openDirectory(pPath) : handle |
Open directory for handle-based enumeration |
readDirectoryHandle(handle) : pEntry |
Read next directory entry |
closeDirectoryHandle(handle) |
Close directory handle, returns status |
Information:
| Method | Description |
|---|---|
freeSpace() : sectors |
Free space in 512-byte sectors |
sectorsPerCluster() : count |
Sectors per cluster (power of 2: 1..128), 0 if not mounted |
volumeLabel() : pStr |
Pointer to volume label string |
setVolumeLabel(pLabel) : result |
Set volume label |
fileName() : pStr |
Name from last directory read |
fileSize() : size |
File size from last directory read |
attributes() : attr |
Attributes from last directory read |
setDate(y,m,d,h,mi,s) |
Set date/time for new files |
getDate() : y, m, d, h, mi, s |
Get current clock (auto-increments from last setDate()) |
getSPIFrequency() : hz |
Current SPI clock frequency |
getCardMaxSpeed() : hz |
Card's reported max speed from CSD |
getManufacturerID() : mid |
Card manufacturer ID byte |
getReadTimeout() : ms |
Read timeout from CSD |
getWriteTimeout() : ms |
Write timeout from CSD |
eraseBlockSectors() : sectors |
Erase block size from CSD (32=SDSC, 128=SDHC/SDXC typical) |
isHighSpeedActive() : bool |
True while the CARD is in CMD6 high-speed mode (a mode, not a clock threshold -- at 350 MHz sysclk high speed is 43.75 MHz) |
driverVersion() : major, minor, patch |
This driver's version as three numbers |
driverVersionString() : pStr |
This driver's version as printable text, e.g. "1.8.0" |
Utilities:
| Method | Description |
|---|---|
setSPISpeed(freq) |
Set SPI clock frequency in Hz |
syncDirCache() |
Invalidate directory sector cache |
sync() : result |
Flush all pending writes |
| Method | Description |
|---|---|
initCardOnly(cs, mosi, miso, sck) |
Initialize card without mounting filesystem |
cardSizeSectors() : sectors |
Total 512-byte sectors on card |
readSectorRaw(sector, pBuf) : result |
Read sector at absolute LBA |
writeSectorRaw(sector, pBuf) : result |
Write sector at absolute LBA |
readSectorsRaw(start, count, pBuf) : sectors |
Multi-block read (CMD18) |
writeSectorsRaw(start, count, pBuf) : sectors |
Multi-block write (CMD25) |
testCMD13() : r2 |
Send CMD13, return raw R2 response |
| Method | Description |
|---|---|
readCIDRaw(pBuf) : result |
Read 16-byte CID register |
readCSDRaw(pBuf) : result |
Read 16-byte CSD register |
readSCRRaw(pBuf) : result |
Read 8-byte SCR register |
readSDStatusRaw(pBuf) : result |
Read 64-byte SD Status register (ACMD13) |
getOCR() : ocr |
Get cached OCR value |
readVBRRaw(pBuf) : result |
Read 512-byte Volume Boot Record |
| Method | Description |
|---|---|
attemptHighSpeed() : bool |
Switch to 50 MHz, verified READ-ONLY (see below) |
checkCMD6Support() : bool |
Check if card supports CMD6 |
checkHighSpeedCapability() : bool |
Query high-speed capability |
How the 50 MHz switch is verified. attemptHighSpeed() captures a baseline
sector at the current known-good speed, performs the CMD6 switch, then re-reads
that same sector at 50 MHz and compares. Nothing is written to the card at the
unverified speed. An earlier implementation wrote a sector at 50 MHz and
compared the result against itself, which cannot detect the failure the check
exists to catch; it also risked a bad write to the root sector on a card that
could not sustain the higher rate. On any mismatch the driver falls back.
All three of these return an honest boolean, and FALSE is ambiguous on its
own: "the card does not offer high speed" and "the card could not be asked" are
both FALSE. ERROR() separates them — SUCCESS when the card simply declined,
a negative code when the query or the verification failed. Without that split a
card with SPI trouble reports as "high speed not supported" and sends you
diagnosing the wrong thing.
| Method | Description |
|---|---|
getLastCMD13() : r2 |
Last CMD13 R2 response word |
getLastCMD13Error() : r2 |
Last non-zero CMD13 result |
getLastReceivedCRC() : crc |
CRC-16 received from card |
getLastCalculatedCRC() : crc |
CRC-16 calculated from data |
getLastSentCRC() : crc |
CRC-16 sent with last write |
getCRCMatchCount() : count |
CRC match count |
getCRCMismatchCount() : count |
CRC mismatch count |
getCRCRetryCount() : count |
CRC retry count |
setCRCValidation(enabled) |
Enable/disable CRC checking |
getWriteDiag() : result_code, r1_resp, data_resp, sector_num |
Last write diagnostic data |
debugGetRootSec() : sector |
Root directory sector |
debugGetDirSec() : sector |
Calling cog's directory sector |
debugGetVbrSec() : sector |
VBR sector |
debugGetFatSec() : sector |
FAT start sector |
debugGetSecPerFat() : count |
Sectors per FAT |
debugDumpRootDir() |
Print root entries to debug |
debugZeroRootSector() : result |
Zero the FIRST root sector only; leaks the erased entries' chains (destructive) |
debugReadSectorSlow(sector, pBuf) : result |
Byte-by-byte read (no streamer) |
debugEraseBlock(start_sector) : result |
Erase one erase-block region via CMD32/33/38 |
debugGetReadSectorDiag(...) |
Last readSector diagnostic data |
debugGetReadSectorDiagExt(...) |
Extended diagnostic data |
displaySector() |
Hex dump of sector buffer |
displayEntry() |
Hex dump of directory entry |
displayFAT(cluster) |
Hex dump of FAT sector |
Fault injection, for the regression suites. Enabled by SD_INCLUDE_ALL and by
nothing else -- in particular not by SD_INCLUDE_DEBUG, which is a build you may
reasonably ship. An injected failure is indistinguishable to the caller from a
genuine one: same error code, same cache invalidation, same diagnostic counters.
| Method | Description |
|---|---|
setTestFailSector(sector, mode) |
Fail one named LBA. TF_READ, TF_WRITE or TF_BOTH, optionally OR'd with TF_STICKY |
setTestFailWriteAfter(writeIndex) |
Fail the nth subsequent writeSector() call (one-shot) |
getTestWriteCallCount() : count |
writeSector() calls counted since arming |
setTestForceReadError(count) |
Inject N forced CRC mismatches on reads of any sector |
setTestForceWriteError(enabled) |
Inject one-shot write CRC corruption on any sector |
setTestMaxClusters(max) |
Set artificial cluster limit (disk-full testing) |
getTestErrorCount() : count |
Count of injected test errors triggered |
clearTestErrors() |
Reset all injection state, leaving no residue |
| Method | Description |
|---|---|
fileFragments(pPath) : count |
Count non-contiguous fragments (1 = contiguous, 0 = empty) |
isFileContiguous(pPath) : bool |
TRUE only if the count was obtained AND equals 1 (see error()) |
createFileContiguous(pPath, size) : handle |
Create new file with pre-allocated contiguous chain |
compactFile(pPath) : result |
Relocate file clusters into contiguous chain (file must be closed) |
| Method | Description |
|---|---|
startReadHandle(handle, pBuf, count) : status |
Begin non-blocking read, returns PENDING |
startWriteHandle(handle, pBuf, count) : status |
Begin non-blocking write, returns PENDING |
isComplete() : bool |
Poll whether async operation has finished |
getResult() : result |
Get result and release lock (bytes read/written or error) |
cancelAsync() : result |
Wait for completion, discard result, release lock |
Part of the P2 microSD Filesystem project - Iron Sheep Productions