Skip to content

Latest commit

 

History

History
1667 lines (1254 loc) · 55 KB

File metadata and controls

1667 lines (1254 loc) · 55 KB

SD Card Driver — Tutorial

micro_sd_fat32_fs.spin2

A practical guide to FAT32 file operations on the Parallax Propeller 2

This tutorial shows how to perform common filesystem operations using the SD card driver. If you're familiar with standard FAT32 APIs (FatFs, POSIX file I/O), this guide maps those concepts to our driver's interface.

Reference: For background on FAT32 internals and standard API concepts, see FAT32-API-CONCEPTS-REFERENCE.md


Table of Contents

  1. Quick Start
  2. Handle-Based File API
  3. Mounting the Card
  4. Working with Directories
  5. Searching for Files
  6. Reading Files
  7. Writing Files
  8. Seeking and Random Access
  9. File Information
  10. File Management
  11. Multi-Cog Access
  12. Non-Blocking (Async) File I/O
  13. File Defragmentation
  14. Error Handling
  15. Complete Examples
  16. Example Programs
  17. Architecture Notes
  18. API Quick Reference
  19. Conditional API Modules

Quick Start

OBJ
  sd : "micro_sd_fat32_fs"

CON
  ' SD card pins - offsets from 8-pin header base pin
  SD_BASE = 56                        ' P2 Edge Module default
  SD_SCK  = SD_BASE + 5              ' Serial Clock
  SD_CS   = SD_BASE + 4              ' Chip Select
  SD_MOSI = SD_BASE + 3              ' Master Out, Slave In
  SD_MISO = SD_BASE + 2              ' Master In, Slave Out

PUB main() | handle, buf[128], bytes_read
  ' Mount the card
  if not sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    debug("Mount failed!")
    return

  ' Open file for reading (handle-based API)
  handle := sd.openFileRead(string("TEST.TXT"))
  if handle >= 0
    bytes_read := sd.readHandle(handle, @buf, 512)
    sd.closeFileHandle(handle)
    debug("Read ", udec(bytes_read), " bytes")

  ' Clean shutdown
  sd.unmount()

Handle-Based File API

The driver uses a handle-based file API that supports multiple files and directories open simultaneously (default 6 handles, user-configurable via MAX_OPEN_FILES). This enables use cases like:

  • Reading a configuration file while writing a log
  • Copying data between files
  • Comparing file contents

Key Concepts

File Handles: Each open file returns a handle (0-5, with the default 6 handles). Use this handle for all subsequent operations on that file.

Single-Writer Policy: Only one write handle per file is allowed. Different files can be open for writing simultaneously.

Handle Type Enforcement: writeHandle() on a read-only handle returns E_INVALID_HANDLE — you must open the file with openFileWrite() or createFileNew() to write. Reading from a write handle is permitted (useful for verify-after-write patterns).

Singleton Architecture: The driver uses a singleton pattern - all OBJ instances share the same worker cog. Calling stop() from any instance affects all instances.

Filename Format

The driver uses 8.3 short filenames (up to 8 characters, a dot, and a 3-character extension). Names are case-insensitive — "DATA.TXT", "data.txt", and "Data.Txt" all refer to the same file. Long filenames (LFN) are not supported.

Opening Files

' Open for reading (returns handle or negative error code)
handle := sd.openFileRead(string("DATA.TXT"))
if handle < 0
  debug("Open failed, error: ", sdec(handle))

' Open for writing (existing file, appends — positions at end of file)
handle := sd.openFileWrite(string("OUTPUT.TXT"))

' Create new file for writing (fails if file already exists)
handle := sd.createFileNew(string("NEWFILE.TXT"))

Reading/Writing with Handles

' Read using handle
bytes_read := sd.readHandle(handle, @buffer, count)

' Write using handle
bytes_written := sd.writeHandle(handle, @buffer, count)

Closing Files

' Close specific handle
sd.closeFileHandle(handle)

' Sync all open handles without closing
sd.syncAllHandles()

File Operations with Handles

' Get file size
size := sd.fileSizeHandle(handle)

' Get current position
pos := sd.tellHandle(handle)

' Check for end of file
if sd.eofHandle(handle)
  debug("At end of file")

' Seek to position
sd.seekHandle(handle, position)

' Flush writes without closing
sd.syncHandle(handle)

Multi-File Example

PUB copyFile(src_name, dest_name) | src_h, dest_h, buf[128], bytes
  ' Open source for reading
  src_h := sd.openFileRead(src_name)
  if src_h < 0
    return false

  ' Create destination for writing
  dest_h := sd.createFileNew(dest_name)
  if dest_h < 0
    sd.closeFileHandle(src_h)
    return false

  ' Copy in chunks. `<= 0` stops on both endings: 0 is a clean end of file,
  ' negative is a failure that transferred nothing.
  repeat
    bytes := sd.readHandle(src_h, @buf, 512)
    if bytes <= 0
      quit
    sd.writeHandle(dest_h, @buf, bytes)

  ' Clean up both files
  sd.closeFileHandle(src_h)
  sd.closeFileHandle(dest_h)
  return true

Handle Error Codes

Code Constant Description
-90 E_TOO_MANY_FILES All file handles in use
-91 E_INVALID_HANDLE Handle not valid or not open
-92 E_FILE_ALREADY_OPEN File already open (same path)
-93 E_NOT_A_DIR_HANDLE Expected directory handle, got file handle

Mounting the Card

Concept

Before any filesystem operations, you must mount the card. This initializes the SPI interface, reads the boot sector, and locates the FAT and root directory.

API

PUB mount(_cs, _mosi, _miso, _sck) : result

Parameters:

Parameter Type Description
_cs pin number Chip Select (directly wired to SD card CS)
_mosi pin number Master Out, Slave In (data to card)
_miso pin number Master In, Slave Out (data from card)
_sck pin number Serial Clock

Returns: true on success, false on failure

Example:

CON
  SD_BASE = 56                        ' P2 Edge Module default
  SD_SCK  = SD_BASE + 5
  SD_CS   = SD_BASE + 4
  SD_MOSI = SD_BASE + 3
  SD_MISO = SD_BASE + 2

PUB setup()
  if sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    debug("Card mounted successfully")
    debug("Volume: ", zstr(sd.volumeLabel()))
  else
    debug("Mount failed, error: ", sdec(sd.error()))

Using a Different 8-Pin Header Group

The microSD add-on board works on any P2 8-pin header group — not just the default P2 Edge Module pins. Simply change SD_BASE to match your wiring:

Header Group SD_BASE CS MOSI MISO SCK
P0–P7 0 P4 P3 P2 P5
P8–P15 8 P12 P11 P10 P13
P16–P23 16 P20 P19 P18 P21
P24–P31 24 P28 P27 P26 P29
P32–P39 32 P36 P35 P34 P37
P40–P47 40 P44 P43 P42 P45
P48–P55 48 P52 P51 P50 P53

Example — External Header on P16–P23:

CON
  SD_BASE = 16                        ' External header (P16-P23 group)
  SD_SCK  = SD_BASE + 5              ' P21
  SD_CS   = SD_BASE + 4              ' P20
  SD_MOSI = SD_BASE + 3              ' P19
  SD_MISO = SD_BASE + 2              ' P18

Note: The P2 Edge Module's built-in SD slot is hard-wired to the P56–P63 group. External headers use longer PCB traces, which may limit maximum reliable SPI speed compared to the on-board slot. The driver defaults to 25 MHz SPI which works on all tested configurations.

Unmounting

Always unmount cleanly to ensure data integrity:

sd.unmount()

This flushes any pending writes and updates the FSInfo sector with the correct free cluster count.


Working with Directories

Concept

The driver maintains a "current working directory" (CWD) per cog -- each P2 cog has its own independent CWD. This means cog A can navigate to /LOGS while cog B works in /DATA without interference. You navigate the directory tree by changing directories, and can use absolute paths (starting with /) or relative paths.

Changing the Current Directory

PUB changeDirectory(name_ptr) : result

Examples:

' Navigate to a subdirectory
sd.changeDirectory(string("LOGS"))

' Navigate to root
sd.changeDirectory(string("/"))

' Navigate using absolute path
sd.changeDirectory(string("/DATA/2026/JAN"))

' Navigate up one level (to parent)
sd.changeDirectory(string(".."))

Creating a New Directory

PUB newDirectory(name_ptr) : result

Example:

' Create a new directory in current location
if sd.newDirectory(string("BACKUP"))
  debug("Directory created")
else
  debug("Failed - may already exist")

Enumerating Directory Contents

PUB readDirectory(entry) : result

This function iterates through entries in the current directory. Call it with entry index 0, 1, 2, etc. to get successive entries. Returns a pointer to the internal entry buffer, or 0 when no more entries exist.

Getting Entry Information:

After readDirectory() returns successfully, use these methods:

Method Returns Description
fileName() string pointer 8.3 formatted filename
fileSize() long Size in bytes (0 for directories)
attributes() byte Attribute flags (see below)

Important: The pointers returned by fileName() and the data from attributes() / fileSize() are only valid until the next call to readDirectory() or readDirectoryHandle(). If you need to keep a filename, copy it with bytemove() before reading the next entry.

Attribute Flags:

Value Meaning
$01 Read-only
$02 Hidden
$04 System
$08 Volume label
$10 Directory
$20 Archive

Example - List All Files in Current Directory:

PUB listDirectory() | entry_num, p_entry
  entry_num := 0
  repeat
    p_entry := sd.readDirectory(entry_num)
    if p_entry == 0
      quit                                    ' No more entries

    ' Check if it's a directory or file
    if sd.attributes() & $10
      debug("[DIR]  ", zstr(sd.fileName()))
    else
      debug("[FILE] ", zstr(sd.fileName()), " (", udec(sd.fileSize()), " bytes)")

    entry_num++

Handle-Based Directory Enumeration

For more advanced use cases, the driver provides handle-based directory enumeration. This lets you enumerate a specific directory without changing your CWD, and supports concurrent enumeration from multiple cogs since each handle has its own state.

PUB openDirectory(p_path) : handle
PUB readDirectoryHandle(handle) : p_entry
PUB closeDirectoryHandle(handle) : status

Directory handles share the same pool as file handles (MAX_OPEN_FILES total). Pass "." or "" to enumerate the calling cog's CWD, or any path to enumerate a specific directory.

Example - List a Specific Directory Without Changing CWD:

PUB listPath(p_path) | dh, p_entry
  dh := sd.openDirectory(p_path)
  if dh < 0
    debug("Cannot open directory: error ", sdec(dh))
    return

  repeat
    p_entry := sd.readDirectoryHandle(dh)
    if p_entry == 0
      quit                                    ' End of directory

    if sd.attributes() & $10
      debug("[DIR]  ", zstr(sd.fileName()))
    else
      debug("[FILE] ", zstr(sd.fileName()), " (", udec(sd.fileSize()), " bytes)")

  sd.closeDirectoryHandle(dh)

When to use which:

Approach Use when...
readDirectory(index) Simple enumeration of current directory
openDirectory() + readDirectoryHandle() Enumerating a different directory without changing CWD, or concurrent enumeration from multiple cogs

Searching for Files

Concept

Unlike some APIs that provide a separate search function, our driver combines searching with opening. When you call openFileRead() with a path, the driver walks the directory tree to find the file.

Search for a File in Current Directory

handle := sd.openFileRead(string("CONFIG.TXT"))
if handle >= 0
  ' File found and opened
  sd.closeFileHandle(handle)
else
  debug("File not found")

Search Using Full Path

The driver supports absolute paths starting with /:

' Search from root, regardless of current directory
handle := sd.openFileRead(string("/SETTINGS/USER.CFG"))
if handle >= 0
  ' Found it
  sd.closeFileHandle(handle)

Check if Directory Exists

if sd.changeDirectory(string("LOGS"))
  debug("LOGS directory exists")
  sd.changeDirectory(string(".."))          ' Go back up
else
  debug("LOGS directory not found")

Reading Files

Concept

File reading is byte-oriented. You provide a buffer and a count, and the driver returns however many bytes were actually read. The driver handles all the cluster chain following, sector boundary crossing, and buffering internally.

Opening a File for Reading

handle := sd.openFileRead(string("DATA.TXT"))
if handle < 0
  debug("Open failed")
  return

Reading Data

PUB readHandle(handle, p_buffer, count) : bytes_read

Parameters:

Parameter Description
handle File handle from openFileRead()
p_buffer Pointer to your receive buffer
count Maximum bytes to read

Returns: Actual number of bytes read. A negative value means nothing was transferred, so 0 means a genuine end of file and nothing else — both repeat while n > 0 and repeat until n == 0 stop for the right reason.

A POSITIVE short count has two possible meanings. It is the normal end-of-file signal, and it is also what you get when the card fails part-way through a transfer — both are a positive number smaller than count, so the return value alone cannot tell them apart. handleError(handle) does: it reports SUCCESS after a clean end of file, and the specific error otherwise.

  bytes_this_read := sd.readHandle(handle, @chunk, CHUNK_SIZE)
  if bytes_this_read < CHUNK_SIZE
    if sd.handleError(handle) <> 0
      debug("Read failed part-way: ", sdec(sd.handleError(handle)))
      quit                                      ' A truncated file, not a complete one

Reading handleError() does not clear it — the next read or write on that handle overwrites it, the same rule error() follows. Read it before you close the handle; closing returns the slot to the pool and clears it.

Reading Examples

Read Entire Small File:

VAR
  byte buffer[512]

PUB readSmallFile() | handle, size
  handle := sd.openFileRead(string("MESSAGE.TXT"))
  if handle < 0
    return

  size := sd.fileSizeHandle(handle)
  sd.readHandle(handle, @buffer, size)
  buffer[size] := 0                           ' Null-terminate for string use
  debug(zstr(@buffer))
  sd.closeFileHandle(handle)

Read Large File in Chunks:

CON
  CHUNK_SIZE = 512

VAR
  byte chunk[CHUNK_SIZE]

PUB readLargeFile() | handle, total_read, bytes_this_read
  handle := sd.openFileRead(string("BIGFILE.DAT"))
  if handle < 0
    return

  total_read := 0
  repeat
    bytes_this_read := sd.readHandle(handle, @chunk, CHUNK_SIZE)
    if bytes_this_read <= 0
      quit                                    ' 0 = end of file, negative = read failed

    ' Process chunk here...
    processData(@chunk, bytes_this_read)

    total_read += bytes_this_read

  debug("Total bytes read: ", udec(total_read))
  sd.closeFileHandle(handle)

Writing Files

Concept

Writing works similarly to reading. For new files, use createFileNew() which creates the file and returns a handle for writing. For existing files, use openFileWrite().

Creating a New File

handle := sd.createFileNew(string("NEWFILE.TXT"))
if handle < 0
  debug("Could not create file")
  return

Writing Data

PUB writeHandle(handle, p_buffer, count) : bytes_written

Parameters:

Parameter Description
handle File handle from createFileNew() or openFileWrite()
p_buffer Pointer to data to write
count Number of bytes to write

Returns: Number of bytes written. A negative value means nothing was accepted. Once any byte has been accepted, a failure returns the partial count instead — so you always know how much of the file is valid — and handleError(handle) tells you why it stopped: E_IO_ERROR for a card failure, E_DISK_FULL when the volume filled up, E_BAD_CHAIN if the file's cluster chain is inconsistent. Check it whenever the count is short:

  written := sd.writeHandle(handle, @record, RECORD_SIZE)
  if written < RECORD_SIZE
    debug("Only ", udec(written), " bytes stored: ", sdec(sd.handleError(handle)))

Writing Examples

Create and Write a Small File:

PUB createTextFile() | handle
  handle := sd.createFileNew(string("HELLO.TXT"))
  if handle >= 0
    sd.writeHandle(handle, string("Hello, World!"), 13)
    sd.closeFileHandle(handle)
    debug("File created successfully")
  else
    debug("Could not create file")

Write Binary Data:

VAR
  long sensor_data[100]

PUB saveSensorData() | handle
  handle := sd.createFileNew(string("SENSOR.DAT"))
  if handle >= 0
    sd.writeHandle(handle, @sensor_data, 100 * 4)   ' 100 longs = 400 bytes
    sd.closeFileHandle(handle)

Write Large Data in Chunks:

PUB writeLargeData(p_data, total_bytes) | handle, offset, chunk_size
  handle := sd.createFileNew(string("DUMP.BIN"))
  if handle < 0
    return false

  offset := 0
  repeat while offset < total_bytes
    chunk_size := (total_bytes - offset) <# 512
    sd.writeHandle(handle, p_data + offset, chunk_size)
    offset += chunk_size

  sd.closeFileHandle(handle)
  return true

Flushing Data Without Closing

Use syncHandle() to flush pending writes without closing the file:

PUB longRunningWrite() | handle, idx
  handle := sd.createFileNew(string("DATA.LOG"))
  if handle < 0
    return

  repeat idx from 0 to 999
    sd.writeHandle(handle, @data_point, DATA_SIZE)

    ' Checkpoint every 100 entries
    if idx // 100 == 99
      sd.syncHandle(handle)

  sd.closeFileHandle(handle)

When the Driver Flushes For You

If you write and then simply stop, the driver notices the card has been idle for 200ms and flushes your dirty buffers on its own. For an application that writes without explicit syncs, this is the path the data actually takes.

That flush is started by the worker cog itself, so there is no call of yours for it to fail on. If it fails, nothing you call afterward will tell you: the data never reached the card, and every subsequent operation still reports success. lastFlushError() is how you find out, and a long-running writer should poll it:

  repeat idx from 0 to 999
    sd.writeHandle(handle, @data_point, DATA_SIZE)

    if idx // 100 == 99
      if sd.lastFlushError() <> 0
        debug("Background flush failed: ", sdec(sd.lastFlushError()))
        sd.clearFlushError()
        sd.syncHandle(handle)               ' The data is still buffered -- try again explicitly

It keeps the first failure, not the most recent — flushes run on a timer, so a later clean one would otherwise erase the report before you looked. Reading does not clear it; call clearFlushError() once you have handled it. And a failed flush leaves the handle dirty, so the data is still in the driver's buffer: syncHandle() or closeFileHandle() can still get it to the card once the cause is resolved.

A flush cut short because one of your commands arrived is not a failure and is not reported — the buffers stay dirty and the next idle window picks them up.


Seeking and Random Access

Concept

The driver maintains a file position that advances automatically with each read/write. You can change this position with seekHandle().

Seek to Position

PUB seekHandle(handle, position) : result

Parameters:

Parameter Description
handle File handle
pos Byte offset from start of file

Returns: SUCCESS (0) on success, or a negative error code if position is beyond end of file

Seeking Examples

Read from Middle of File:

VAR
  byte header[64]

PUB readFileHeader() | handle
  handle := sd.openFileRead(string("DATA.BIN"))
  if handle < 0
    return

  sd.seekHandle(handle, 256)                  ' Skip first 256 bytes
  sd.readHandle(handle, @header, 64)          ' Read 64-byte header
  sd.closeFileHandle(handle)

Random Access Read:

PUB readRecordAt(record_num) | handle, record[16]
  ' Assuming 64-byte records
  handle := sd.openFileRead(string("DATABASE.DAT"))
  if handle >= 0
    sd.seekHandle(handle, record_num * 64)
    sd.readHandle(handle, @record, 64)
    sd.closeFileHandle(handle)
  return @record

File Information

Getting File Size

size := sd.fileSizeHandle(handle)

Returns the size of the file in bytes.

Getting Current Position

pos := sd.tellHandle(handle)

Returns the current read/write position.

Checking for End of File

if sd.eofHandle(handle)
  debug("Reached end of file")

Volume Information

' Get volume label
debug("Volume: ", zstr(sd.volumeLabel()))

' Get free space (returns sectors, not bytes)
free_sectors := sd.freeSpace()
debug("Free space: ", udec(free_sectors >> 11), " MB")  ' sectors / 2048 = MB

Date and Time

The driver maintains an auto-incrementing clock for file timestamps. Call setDate() once to seed the clock — the worker cog then advances it automatically using a CT1-based 2-second tick. Files created or modified after setDate() receive accurate timestamps without further calls.

Setting the Clock:

PUB setDate(year, month, day, hour, minute, second) : result
Parameter Range Description
year 1980-2107 Year (FAT32 epoch starts at 1980)
month 1-12 Month
day 1-31 Day
hour 0-23 Hour (24-hour format)
minute 0-59 Minute
second 0-59 Second

Returns: SUCCESS (0) or E_INVALID_PARAM if any value is out of range.

' Seed the clock at startup — it auto-increments from here
sd.setDate(2026, 3, 18, 14, 30, 0)

' All files created after this get accurate timestamps
handle := sd.createFileNew(string("TIMESTAMPED.TXT"))
sd.closeFileHandle(handle)

Reading the Clock:

PUB getDate() : year, month, day, hour, minute, second

Returns the live clock value maintained by the worker cog. If setDate() was never called, returns the default timestamp. Seconds are even values only (FAT32 stores seconds / 2).

' Read current driver clock
year, month, day, hour, minute, second := sd.getDate()
debug("Date: ", udec(year), "-", udec(month), "-", udec(day))
debug("Time: ", udec(hour), ":", udec(minute), ":", udec(second))

File Management

Deleting Files

PUB deleteFile(name_ptr) : result

Deletes the named file from the current directory. Returns true on success, false on failure. The file must not be open. Use sd.error() to retrieve the error code on failure.

if sd.deleteFile(@"OLD_DATA.TXT")
    debug("File deleted")
else
    debug("Delete failed, error: ", sdec(sd.error()))

Renaming Files and Directories

PUB rename(old_name, new_name) : result

Renames a file or directory within the current directory. Both names are simple filenames (not paths). Returns true on success, false on failure. Fails with E_FILE_NOT_FOUND if the source doesn't exist, or E_FILE_EXISTS if the destination name is already in use.

if not sd.rename(@"DRAFT.TXT", @"FINAL.TXT")
  debug("Rename failed: ", sdec(sd.error()))

Moving Files Between Directories

PUB moveFile(name_ptr, dest_folder) : result

Moves a file from the current directory into a different directory. The destination must be an existing directory name or path.

' Move LOG.TXT from current directory into the ARCHIVE directory
sd.moveFile(@"LOG.TXT", @"ARCHIVE")

Multi-Cog Access

Concept

The P2 has 8 cogs, and the SD driver is designed for safe concurrent access from any of them. The driver runs a dedicated worker cog that serializes all SPI operations through a hardware lock — your application cogs never touch the SPI bus directly.

What Each Cog Gets

  • Its own current working directory (CWD) — cog A can navigate to /LOGS while cog B works in /DATA
  • Its own error sloterror() returns the last error for the calling cog only
  • Shared file handles — handles are allocated from a common pool and can be used from any cog

Singleton Pattern

All OBJ instances of the driver share the same worker cog. This means you don't need to pass a driver reference between cogs — just declare the OBJ in each cog's top-level object:

OBJ
    sd : "micro_sd_fat32_fs"

PUB readerTask() | handle, buf[128], bytes_read
    ' This cog can use sd.* immediately — it shares the already-mounted driver
    handle := sd.openFileRead(@"SENSOR.DAT")
    if handle >= 0
        repeat
            bytes_read := sd.readHandle(handle, @buf, 512)
            if bytes_read <= 0                  ' 0 = end of file, negative = read failed
                quit
            processData(@buf, bytes_read)
        sd.closeFileHandle(handle)

Rules for Multi-Cog Access

  1. Mount from one cog only. Call mount() before starting other cogs that use the driver.
  2. One writer per file. Only one write handle per file is allowed across all cogs. Different files can be open for writing simultaneously.
  3. Close handles when done. Handles are a shared resource (default 6 total).
  4. Don't call stop() or unmount() while other cogs are using the driver.

See also: SD_example_multicog.spin2 for a complete working example.


Non-Blocking (Async) File I/O

Concept

The standard readHandle() and writeHandle() calls are blocking — the calling cog halts (via WAITATN) until the SD card finishes. For many applications this is fine. But when your cog has real-time work — sensor polling, control loops, display updates — those 5-50ms SD transfers waste millions of clock cycles.

The async API lets you start a read or write, keep running while the worker cog handles the SD transfer, then collect the result when you're ready.

Enabling the Async API

The async methods require the SD_INCLUDE_ASYNC feature flag:

#pragma exportdef SD_INCLUDE_ASYNC

OBJ
  sd : "micro_sd_fat32_fs"

(SD_INCLUDE_ALL also enables it.)

The Three-Phase Pattern

1. START   — sd.startReadHandle() or sd.startWriteHandle()
             Returns immediately with PENDING (1)
2. DO WORK — Your cog runs freely while the SD transfer happens
             Poll sd.isComplete() when convenient
3. COLLECT — sd.getResult() returns bytes read/written
             Releases the lock so other cogs can use the driver

API Reference

Method Description
startReadHandle(handle, buffer, count) Begin async read. Returns PENDING (1) or negative error
startWriteHandle(handle, buffer, count) Begin async write. Returns PENDING (1) or negative error
isComplete() Returns TRUE if the operation has finished
getResult() Returns bytes read/written (or error). Releases the lock
cancelAsync() Waits for worker to finish, discards result, releases lock

Important: Lock Behavior

While an async operation is in flight, the API lock is held. No other cog can issue SD commands until you call getResult() or cancelAsync(). This means:

  • Don't start an async op and forget about it — other cogs will block
  • Call getResult() as soon as you're ready for the data
  • If you need to bail out, call cancelAsync() (it still waits for the SPI transfer to finish safely)
  • Your own cog's blocking calls (readHandle(), closeFileHandle(), unmount(), ...) return E_ASYNC_BUSY while your async op is in flight — collect or cancel first. The lock is not re-entrant, so without this guard such a call could never return.

Important: One Async Operation, and It Belongs to One Cog

There is a single in-flight slot for the whole driver, not one per cog. A second cog that starts an async operation while one is pending gets E_ASYNC_BUSY.

The operation also belongs to the cog that started it. Only that cog may collect it with getResult() or drop it with cancelAsync(); another cog calling either gets E_NO_ASYNC_OP and changes nothing. isComplete() likewise reports FALSE in a cog that does not own the operation — that cog has none.

This matters more than it looks. The async calls release the API lock as their last act, so a cog collecting a result it does not own would release a lock it never held, freeing the driver out from under the cog that did.

Error Codes

Code Constant Description
1 PENDING Operation launched successfully
-95 E_ASYNC_BUSY Another async operation is already in flight
-96 E_NO_ASYNC_OP No async operation to get result from, or it belongs to another cog

Example: Async Read with Sensor Polling

#pragma exportdef SD_INCLUDE_ASYNC

OBJ
  sd : "micro_sd_fat32_fs"

VAR
  byte file_buf[512]

PUB dataAcquisition() | handle, status, bytes_read
  handle := sd.openFileRead(string("CONFIG.DAT"))
  if handle < 0
    return

  ' Phase 1: Start the read
  status := sd.startReadHandle(handle, @file_buf, 512)
  if status <> sd.PENDING
    debug("Async start failed: ", sdec(status))
    sd.closeFileHandle(handle)
    return

  ' Phase 2: Do useful work while SD card transfers data
  repeat until sd.isComplete()
    pollSensors()                                ' Real-time work continues!
    updateControlLoop()

  ' Phase 3: Collect the result
  bytes_read := sd.getResult()
  if bytes_read > 0
    processConfig(@file_buf, bytes_read)

  sd.closeFileHandle(handle)

Example: Async Write for Data Logging

PUB logWithAsync(handle, p_data, count) | status, result
  ' Start the write — returns immediately
  status := sd.startWriteHandle(handle, p_data, count)
  if status <> sd.PENDING
    return status

  ' Cog continues running while the write happens
  repeat until sd.isComplete()
    readNextSensorSample()

  ' Collect result
  result := sd.getResult()
  if result < 0
    debug("Write error: ", sdec(result))
  return result

When to Use Async vs Blocking

Situation Use
Simple file operations, no time pressure readHandle() / writeHandle() (blocking)
Cog has real-time work during SD transfers startReadHandle() / startWriteHandle() (async)
Multiple cogs issuing frequent SD commands Blocking (async holds the lock longer)
Single cog with control loop + logging Async (keeps control loop responsive)

File Defragmentation

Concept

FAT32 fragmentation occurs when a file's clusters are scattered across the disk instead of stored contiguously. This hurts read/write throughput (multi-block CMD18/CMD25 transfers only work on contiguous sectors) and is critical for the P2 boot file which must be contiguous.

The defrag API lets you query fragmentation, compact individual files, and pre-allocate contiguous space for new files. Enable it with SD_INCLUDE_DEFRAG.

Enabling the Defrag API

#pragma exportdef SD_INCLUDE_DEFRAG

OBJ
    sd : "micro_sd_fat32_fs"

Checking if a File is Contiguous

PUB check_boot_file() | frags
    ' Quick boolean check
    if not sd.isFileContiguous(@"_BOOT_P2.BIX")
        debug("Boot file is fragmented!")

    ' Detailed fragment count
    frags := sd.fileFragments(@"_BOOT_P2.BIX")
    if frags > 1
        debug("Boot file has ", udec_(frags), " fragments")

fileFragments() returns the number of non-contiguous runs in the cluster chain (1 = fully contiguous). isFileContiguous() is a convenience wrapper that returns TRUE when the fragment count is 1.

Both of these return a plain boolean, never an error code mixed in. A query that fails reports FALSE — "not contiguous, or not known to be" — and error() says which. That is the safe reading: an error that reported TRUE would let you skip a compaction the file actually needed.

Compacting a Fragmented File

PUB ensure_boot_contiguous() | result
    if not sd.isFileContiguous(@"_BOOT_P2.BIX")
        result := sd.compactFile(@"_BOOT_P2.BIX")
        if result == 0
            debug("Boot file compacted successfully")
        else
            debug("Compaction failed: ", sdec_(result))

compactFile() relocates the file's clusters into a contiguous chain. It performs a full read-back verification after every cluster copy to ensure data integrity — a corrupted boot file would brick the device.

Requirements:

  • The file must be closed (no active read or write handles). Returns E_FILE_OPEN_FOR_COMPACT (-62) otherwise.
  • Sufficient contiguous free space must exist on the card. Returns E_NO_CONTIGUOUS_SPACE (-61) if not.
  • This is a maintenance operation, not a hot path — it may take several seconds for large files.

Safety: The copy-then-free strategy means the file is always readable during compaction. Worst case on power loss is orphaned clusters (wasted space, not data loss), recoverable by FSCK.

Creating a Pre-Allocated Contiguous File

When you know the file size in advance (firmware images, fixed-size logs, configuration blocks), you can pre-allocate contiguous space at creation time:

PUB write_firmware_image(p_data, data_size) | handle, written
    handle := sd.createFileContiguous(@"FIRMWARE.BIN", data_size)
    if handle < 0
        debug("Failed to create contiguous file: ", sdec_(handle))
        return handle

    written := sd.writeHandle(handle, p_data, data_size)
    sd.closeFileHandle(handle)

    ' Verify contiguity (should always be true)
    if sd.isFileContiguous(@"FIRMWARE.BIN")
        debug("Firmware written contiguously: ", udec_(written), " bytes")

createFileContiguous() finds a contiguous run of free clusters large enough for the specified size, allocates the entire chain upfront, and returns a write handle. Subsequent writeHandle() calls fill the pre-allocated space without ever calling the allocator — guaranteed no fragmentation.

If the pre-allocated space is exhausted (caller writes more than file_size bytes), writeHandle() returns 0 bytes written, similar to disk-full behavior.

API Reference

Method Description
fileFragments(path) Count non-contiguous runs (1 = contiguous, 0 = empty file)
isFileContiguous(path) TRUE only if the count was obtained AND equals 1 (see error())
compactFile(path) Relocate to contiguous clusters with read-back verify
createFileContiguous(path, size) Create file with pre-allocated contiguous chain

Error Codes

Code Constant Meaning
-61 E_NO_CONTIGUOUS_SPACE No contiguous free cluster run large enough
-62 E_FILE_OPEN_FOR_COMPACT File has active handles (close first)
-63 E_VERIFY_FAILED Read-back verification failed after copy

Fragmentation Prevention

The driver also uses next-fit allocation (always enabled, no flag needed) which starts each cluster search from where the previous allocation left off instead of always starting at cluster 2. This keeps sequential writes contiguous automatically and reduces fragmentation on normal file operations.


Error Handling

Checking for Errors

PUB error() : status

Returns the error code from the most recent operation. Thread-safe (each cog has its own error slot).

Error Codes

Code Constant Description
0 SUCCESS No error
-1 E_TIMEOUT Card didn't respond in time
-2 E_NO_RESPONSE Card not responding
-3 E_BAD_RESPONSE Unexpected response from card
-4 E_CRC_ERROR Data CRC mismatch
-5 E_WRITE_REJECTED Card rejected write operation
-6 E_CARD_BUSY Card busy
-20 E_NOT_MOUNTED Filesystem not mounted
-21 E_INIT_FAILED Card initialization failed
-22 E_NOT_FAT32 Card not formatted as FAT32
-23 E_BAD_SECTOR_SIZE Sector size not 512 bytes
-24 E_BAD_FSINFO FSInfo sector signatures invalid (free-space hint cannot be updated)
-25 E_BAD_CHAIN Cluster chain disagrees with the directory entry (chain walk went wrong)
-26 E_STACK_OVERFLOW Worker cog wrote past its stack -- nothing it reports can be trusted
-40 E_FILE_NOT_FOUND File doesn't exist
-41 E_FILE_EXISTS File already exists
-42 E_NOT_A_FILE Expected file, found directory
-43 E_NOT_A_DIR Expected directory, found file
-45 E_FILE_NOT_OPEN RESERVED -- never produced; a closed handle reports E_INVALID_HANDLE
-46 E_END_OF_FILE Read past end of file
-60 E_DISK_FULL No free clusters
-64 E_NO_LOCK Couldn't allocate hardware lock
-65 E_NO_COG No free cog to run the worker (all eight in use)
-7 E_IO_ERROR I/O error during read or write
-90 E_TOO_MANY_FILES All file handles in use
-91 E_INVALID_HANDLE Handle not valid or not open
-92 E_FILE_ALREADY_OPEN File already open (same path)
-93 E_NOT_A_DIR_HANDLE Expected directory handle, got file handle

Error Handling Pattern

PUB safeFileOperation() | handle
  if not sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    case sd.error()
      sd.E_TIMEOUT:
        debug("Card not inserted or not responding")
      sd.E_NOT_FAT32:
        debug("Card not formatted as FAT32")
      other:
        debug("Mount failed, error: ", sdec(sd.error()))
    return

  handle := sd.openFileRead(string("DATA.TXT"))
  if handle < 0
    if sd.error() == sd.E_FILE_NOT_FOUND
      ' Create the file
      handle := sd.createFileNew(string("DATA.TXT"))

  if handle >= 0
    ' ... perform operations ...
    sd.closeFileHandle(handle)

  sd.unmount()

Complete Examples

Example 1: Configuration File Reader

CON
  SD_BASE = 56, SD_SCK = SD_BASE + 5, SD_CS = SD_BASE + 4, SD_MOSI = SD_BASE + 3, SD_MISO = SD_BASE + 2
  MAX_LINE = 80

OBJ
  sd : "micro_sd_fat32_fs"

VAR
  byte line_buffer[MAX_LINE]

PUB readConfig() | handle, charIdx, ch
  if not sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    return false

  handle := sd.openFileRead(string("CONFIG.INI"))
  if handle < 0
    sd.unmount()
    return false

  ' Read file line by line
  repeat
    charIdx := 0
    repeat
      if sd.readHandle(handle, @ch, 1) <= 0   ' 0 = end of file, negative = read failed
        quit
      if ch == 10                             ' Newline
        quit
      if ch <> 13 and charIdx < MAX_LINE - 1  ' Skip CR, check buffer
        line_buffer[charIdx++] := ch

    line_buffer[charIdx] := 0                 ' Null terminate

    if charIdx > 0
      processConfigLine(@line_buffer)

    if sd.eofHandle(handle)
      quit

  sd.closeFileHandle(handle)
  sd.unmount()
  return true

Example 2: Data Logger

CON
  SD_BASE = 56, SD_SCK = SD_BASE + 5, SD_CS = SD_BASE + 4, SD_MOSI = SD_BASE + 3, SD_MISO = SD_BASE + 2

OBJ
  sd : "micro_sd_fat32_fs"

VAR
  byte log_name[13]
  long log_handle

PUB startLogging() | handle
  if not sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    return false

  ' Set timestamp
  sd.setDate(2026, 2, 3, 12, 0, 0)

  ' Create logs directory if needed
  if not sd.changeDirectory(string("LOGS"))
    sd.newDirectory(string("LOGS"))
    sd.changeDirectory(string("LOGS"))

  ' Find next available log number
  findNextLogName()

  ' Create the new log file
  log_handle := sd.createFileNew(@log_name)
  if log_handle < 0
    sd.unmount()
    return false

  sd.writeHandle(log_handle, string("=== Log Started ===", 13, 10), 21)
  return true

PUB logEntry(message) | len
  len := strsize(message)
  sd.writeHandle(log_handle, message, len)
  sd.writeHandle(log_handle, string(13, 10), 2)
  sd.syncHandle(log_handle)                   ' Ensure data is saved

PUB stopLogging()
  sd.writeHandle(log_handle, string("=== Log Ended ===", 13, 10), 19)
  sd.closeFileHandle(log_handle)
  sd.changeDirectory(string("/"))
  sd.unmount()

PRI findNextLogName() | num, handle
  num := 0
  repeat
    formatLogName(num)
    handle := sd.openFileRead(@log_name)
    if handle < 0
      quit                                    ' Found unused name
    sd.closeFileHandle(handle)
    num++

PRI formatLogName(num) | d10, d1
  ' Format as "LOG00.TXT" through "LOG99.TXT"
  d10 := num / 10
  d1 := num // 10
  bytemove(@log_name, string("LOG00.TXT"), 10)
  log_name[3] := "0" + d10
  log_name[4] := "0" + d1

Example 3: Binary Record File

CON
  SD_BASE = 56, SD_SCK = SD_BASE + 5, SD_CS = SD_BASE + 4, SD_MOSI = SD_BASE + 3, SD_MISO = SD_BASE + 2
  RECORD_SIZE = 32

OBJ
  sd : "micro_sd_fat32_fs"

PUB readRecord(filename, record_num, p_dest) : success | handle
  if not sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    return false

  handle := sd.openFileRead(filename)
  if handle < 0
    sd.unmount()
    return false

  ' Check if record exists
  if (record_num + 1) * RECORD_SIZE > sd.fileSizeHandle(handle)
    sd.closeFileHandle(handle)
    sd.unmount()
    return false

  ' Seek to record position and read
  sd.seekHandle(handle, record_num * RECORD_SIZE)
  sd.readHandle(handle, p_dest, RECORD_SIZE)

  sd.closeFileHandle(handle)
  sd.unmount()
  return true

PUB appendRecord(filename, p_src) : success | handle
  if not sd.mount(SD_CS, SD_MOSI, SD_MISO, SD_SCK)
    return false

  ' Try to open existing (appends), or create new
  handle := sd.openFileWrite(filename)
  if handle < 0
    handle := sd.createFileNew(filename)
    if handle < 0
      sd.unmount()
      return false

  sd.writeHandle(handle, p_src, RECORD_SIZE)

  sd.closeFileHandle(handle)
  sd.unmount()
  return true

Example Programs

The src/EXAMPLES/ directory contains compilable, self-contained programs you can run directly on P2 hardware. Each demonstrates a common usage pattern:

Program What It Teaches
SD_example_read_write.spin2 Complete file lifecycle: create, write, close, re-open, read back
SD_example_data_logger.spin2 Append-mode logging with syncHandle() for power-fail safety
SD_example_directory_walk.spin2 Directory listing (both APIs), file delete, rename, subdirectory creation
SD_example_multicog.spin2 Two cogs accessing different files concurrently

See the Examples README for build instructions and detailed descriptions.


Architecture Notes

Singleton Pattern

The driver uses a singleton pattern: all object instances share the same worker cog and state. This has important implications:

OBJ
  sd1 : "micro_sd_fat32_fs"    ' First instance
  sd2 : "micro_sd_fat32_fs"    ' Second instance - shares same driver!

PUB example()
  sd1.mount(CS, MOSI, MISO, SCK)
  ' sd2 can now use the mounted card too - same driver instance
  handle := sd2.openFileRead(string("TEST.TXT"))

  ' WARNING: stop() from ANY instance affects ALL instances
  sd1.stop()   ' This will also stop sd2's access!

Worker Cog

All SPI operations are performed by a dedicated worker cog. This:

  • Isolates timing-sensitive SPI operations from your application code
  • Provides multi-cog safety through a hardware lock
  • Allows the main cog to continue other work during SD operations

Memory Usage

Resource Usage
Cogs 1 (worker)
Locks 1
Hub RAM ~6KB (handles + worker stack + sector buffers)

API Quick Reference

These methods are always available in the core driver (no feature flags required).

Lifecycle

Method Description
mount(cs, mosi, miso, sck) Mount card, returns true/false
unmount() Unmount card cleanly
stop() Stop worker cog; 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() Verify worker cog stack integrity (always compiled)

Handle-Based File Operations

Method Description
openFileRead(path) Open for reading, returns handle
openFileWrite(path) Open for append writing, returns handle
createFileNew(path) Create new file for writing, returns handle
closeFileHandle(handle) Close file handle, flush writes
readHandle(handle, buffer, count) Read bytes; count read, 0 only at EOF, negative if nothing moved (short count: see handleError())
writeHandle(handle, buffer, count) Write bytes; count written, negative if nothing accepted (short count: see handleError())
seekHandle(handle, position) Set file position, returns SUCCESS (0) or error
tellHandle(handle) Get current position
eofHandle(handle) Check if at end of file (TRUE if the query failed too -- see error())
fileSizeHandle(handle) Get file size by handle
syncHandle(handle) Flush writes on handle
syncAllHandles() Flush all open handles

File Management

Method Description
deleteFile(name) Delete file
rename(old, new) Rename file or directory
moveFile(name, dest) Move file to directory

Directories

Method Description
changeDirectory(name) Change current directory (per-cog CWD)
newDirectory(name) Create new directory
readDirectory(index) Get CWD entry at index
openDirectory(path) Open directory for enumeration, returns handle
readDirectoryHandle(handle) Read next entry from directory handle
closeDirectoryHandle(handle) Close directory handle, returns status

Information

Method Description
fileName() Name of last directory entry
fileSize() Size of last directory entry in bytes
attributes() Attributes of last directory entry
volumeLabel() Card volume label
setVolumeLabel(label) Set volume label
freeSpace() Free sectors on card
sectorsPerCluster() Sectors per cluster (power of 2: 1..128)
setDate(y,m,d,h,mi,s) Set date/time, starts auto-incrementing clock
getDate() Get current clock (returns year, month, day, hour, minute, second)
getSPIFrequency() Current SPI clock in Hz
getCardMaxSpeed() Card's reported max speed in Hz
getManufacturerID() Card manufacturer ID byte
getReadTimeout() Read timeout in ms
getWriteTimeout() Write timeout in ms
isHighSpeedActive() True while the card is in CMD6 high-speed mode (a mode, not a clock threshold)
driverVersion() Driver version as three numbers (major, minor, patch)
driverVersionString() Driver version as printable text, e.g. "1.8.0"

Utilities

Method Description
setSPISpeed(freq) Set SPI clock frequency in Hz
syncDirCache() Force directory cache re-read
sync() Flush all pending writes

Conditional API Modules

The driver supports optional feature modules enabled via #pragma exportdef in your top-level file. This keeps the core driver small (24 KB) for applications that only need standard file operations.

To enable a module, add the pragma before the OBJ declaration:

#pragma exportdef SD_INCLUDE_RAW
#pragma exportdef SD_INCLUDE_REGISTERS

OBJ
  sd : "micro_sd_fat32_fs"

To enable all modules at once:

#pragma exportdef SD_INCLUDE_ALL

OBJ
  sd : "micro_sd_fat32_fs"

SD_INCLUDE_ASYNC - Non-Blocking File I/O

For applications where the calling cog must keep running during SD transfers. See Non-Blocking (Async) File I/O for detailed usage.

Method Description
startReadHandle(handle, buffer, count) Begin async read, returns PENDING (1)
startWriteHandle(handle, buffer, count) Begin async write, returns PENDING (1)
isComplete() Check if async operation has finished
getResult() Get bytes read/written, release lock
cancelAsync() Discard result, release lock

SD_INCLUDE_DEFRAG - File Defragmentation

For applications that need contiguous file storage (boot files, firmware images) or want to query and repair fragmentation. See File Defragmentation for detailed usage.

Method Description
fileFragments(path) Count non-contiguous cluster chain runs (1 = contiguous)
isFileContiguous(path) TRUE only if the count was obtained AND equals 1 (see error())
compactFile(path) Relocate file to contiguous clusters with read-back verify
createFileContiguous(path, size) Create file with pre-allocated contiguous chain

SD_INCLUDE_RAW - Raw Sector Access

For low-level operations like formatting, partitioning, or direct sector manipulation. Bypasses the filesystem layer entirely.

Method Description
initCardOnly(cs, mosi, miso, sck) Initialize card without mounting filesystem
cardSizeSectors() Total 512-byte sectors on card
readSectorRaw(sector, buffer) Read sector at absolute LBA
writeSectorRaw(sector, buffer) Write sector at absolute LBA
readSectorsRaw(start, count, buffer) Multi-block read (CMD18)
writeSectorsRaw(start, count, buffer) Multi-block write (CMD25)
testCMD13() Send CMD13, return raw R2 response

SD_INCLUDE_REGISTERS - Card Register Access

For card characterization and identification. Provides raw access to CID, CSD, SCR, and OCR registers.

Method Description
readCIDRaw(buffer) Read 16-byte CID register (manufacturer, serial, etc.)
readCSDRaw(buffer) Read 16-byte CSD register (capacity, speed, features)
readSCRRaw(buffer) Read 8-byte SCR register (SD spec version, bus widths)
getOCR() Get cached OCR value (voltage range, capacity status)
readSDStatusRaw(buffer) Read 64-byte SD Status register (ACMD13)
readVBRRaw(buffer) Read 512-byte Volume Boot Record

SD_INCLUDE_SPEED - High-Speed Mode Control

For testing and enabling 50 MHz high-speed mode via CMD6.

Method Description
attemptHighSpeed() Switch to 50 MHz with verification, falls back on failure
checkCMD6Support() Check if card supports CMD6 (SD 2.0+)
checkHighSpeedCapability() Query if card reports high-speed capability

SD_INCLUDE_DEBUG - Diagnostic Methods

For driver development, debugging, and regression testing. Includes CRC diagnostic getters, internal state inspection, and display utilities.

Method Description
getLastCMD13() Last CMD13 R2 response word
getLastCMD13Error() Last non-zero CMD13 result
getLastReceivedCRC() CRC-16 received from card on last read
getLastCalculatedCRC() CRC-16 calculated from received data
getLastSentCRC() CRC-16 sent with last write
getCRCMatchCount() Count of reads where CRC matched
getCRCMismatchCount() Count of reads where CRC did not match
getCRCRetryCount() Count of CRC retries on reads
setCRCValidation(enabled) Enable/disable CRC checking
debugGetRootSec() Root directory sector number
debugGetDirSec() Current directory sector for calling cog
debugGetVbrSec() VBR sector number
debugGetFatSec() FAT start sector number
debugGetSecPerFat() Sectors per FAT
debugDumpRootDir() Print root directory entries to debug
debugZeroRootSector() Zero the FIRST root sector only; entries in later root sectors survive and the erased entries' clusters are leaked. Run SD_FAT32_fsck after, or SD_format_card for a clean card.
debugReadSectorSlow(sector, buffer) Byte-by-byte read without streamer
getWriteDiag() Last writeSector diagnostic (returns 4 values: result_code, r1_resp, data_resp, sector_num)
debugGetReadSectorDiag(...) Last readSector diagnostic data (8 params)
debugGetReadSectorDiagExt(...) Extended diagnostic data (5 params)
displaySector() Hex dump of sector buffer
displayEntry() Hex dump of directory entry buffer
displayFAT(cluster) Hex dump of FAT sector for cluster

SD_INCLUDE_TEST_HOOKS - Fault Injection

These make sector I/O fail on purpose, so that error-handling code can be tested against failures a healthy card will never produce. They are for the regression suites. SD_INCLUDE_ALL enables them; SD_INCLUDE_DEBUG deliberately does not, so turning on field diagnostics never turns on a method that corrupts your writes.

An injected failure is indistinguishable from a real 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 (default is one-shot)
setTestFailWriteAfter(writeIndex) Fail the nth subsequent writeSector() call -- names a write by its position in a sequence rather than by LBA
getTestWriteCallCount() How many writeSector() calls have happened 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) Artificial cluster limit, for disk-full testing
getTestErrorCount() Count of injected test errors triggered
clearTestErrors() Reset all injection state, leaving no residue for the next test

A read failure fires only when the driver actually reads the sector -- a cache hit is not a card access, so there is nothing to fail. Only the single-block paths are injected; raw multi-block transfers (CMD18/CMD25) are not.


What The Driver Handles For You

The driver abstracts away all FAT32 complexity:

  • Cluster chains: Following and allocating clusters automatically
  • FAT updates: Maintaining both FAT copies
  • Sector buffering: Managing the 512-byte sector buffer
  • Directory parsing: Converting 8.3 names and navigating entries
  • Path resolution: Walking the directory tree for absolute paths
  • Multi-cog safety: Serializing access through a worker cog
  • Multiple file handles: Track up to 6 open files/directories simultaneously (configurable)
  • Single-writer enforcement: Prevent data corruption from concurrent writes
  • CRC validation: Hardware-accelerated CRC-16 on all data transfers

You just work with files and bytes; the driver handles the rest.