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
- Quick Start
- Handle-Based File API
- Mounting the Card
- Working with Directories
- Searching for Files
- Reading Files
- Writing Files
- Seeking and Random Access
- File Information
- File Management
- Multi-Cog Access
- Non-Blocking (Async) File I/O
- File Defragmentation
- Error Handling
- Complete Examples
- Example Programs
- Architecture Notes
- API Quick Reference
- Conditional API Modules
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()
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
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.
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.
' 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"))
' Read using handle
bytes_read := sd.readHandle(handle, @buffer, count)
' Write using handle
bytes_written := sd.writeHandle(handle, @buffer, count)
' Close specific handle
sd.closeFileHandle(handle)
' Sync all open handles without closing
sd.syncAllHandles()
' 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)
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
| 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 |
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.
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()))
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.
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.
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.
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(".."))
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")
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 fromattributes()/fileSize()are only valid until the next call toreadDirectory()orreadDirectoryHandle(). If you need to keep a filename, copy it withbytemove()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++
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 |
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.
handle := sd.openFileRead(string("CONFIG.TXT"))
if handle >= 0
' File found and opened
sd.closeFileHandle(handle)
else
debug("File not found")
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)
if sd.changeDirectory(string("LOGS"))
debug("LOGS directory exists")
sd.changeDirectory(string("..")) ' Go back up
else
debug("LOGS directory not found")
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.
handle := sd.openFileRead(string("DATA.TXT"))
if handle < 0
debug("Open failed")
return
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.
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 works similarly to reading. For new files, use createFileNew() which creates the file and returns a handle for writing. For existing files, use openFileWrite().
handle := sd.createFileNew(string("NEWFILE.TXT"))
if handle < 0
debug("Could not create file")
return
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)))
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
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)
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.
The driver maintains a file position that advances automatically with each read/write. You can change this position with seekHandle().
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
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
size := sd.fileSizeHandle(handle)
Returns the size of the file in bytes.
pos := sd.tellHandle(handle)
Returns the current read/write position.
if sd.eofHandle(handle)
debug("Reached end of file")
' 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
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))
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()))
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()))
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")
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.
- Its own current working directory (CWD) — cog A can navigate to
/LOGSwhile cog B works in/DATA - Its own error slot —
error()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
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)
- Mount from one cog only. Call
mount()before starting other cogs that use the driver. - One writer per file. Only one write handle per file is allowed across all cogs. Different files can be open for writing simultaneously.
- Close handles when done. Handles are a shared resource (default 6 total).
- Don't call
stop()orunmount()while other cogs are using the driver.
See also: SD_example_multicog.spin2 for a complete working example.
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.
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.)
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
| 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 |
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(), ...) returnE_ASYNC_BUSYwhile 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.
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.
| 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 |
#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)
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
| 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) |
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.
#pragma exportdef SD_INCLUDE_DEFRAG
OBJ
sd : "micro_sd_fat32_fs"
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.
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.
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.
| 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 |
| 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 |
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.
PUB error() : status
Returns the error code from the most recent operation. Thread-safe (each cog has its own error slot).
| 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 |
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()
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
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
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
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.
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!
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
| Resource | Usage |
|---|---|
| Cogs | 1 (worker) |
| Locks | 1 |
| Hub RAM | ~6KB (handles + worker stack + sector buffers) |
These methods are always available in the core driver (no feature flags required).
| 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) |
| 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 |
| Method | Description |
|---|---|
deleteFile(name) |
Delete file |
rename(old, new) |
Rename file or directory |
moveFile(name, dest) |
Move file to directory |
| 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 |
| 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" |
| Method | Description |
|---|---|
setSPISpeed(freq) |
Set SPI clock frequency in Hz |
syncDirCache() |
Force directory cache re-read |
sync() |
Flush all pending writes |
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"
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 |
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 |
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 |
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 |
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 |
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 |
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.
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.