A USB 1.1 full-speed host for the Parallax Propeller 2 that runs in one cog, with compile-time-selectable class drivers (HID keyboard/mouse, serial — CDC-ACM plus FTDI/CP210x/PL2303 USB-serial bridges, MSC mass storage, USB Audio Class 1 playback + capture over isochronous endpoints) and two filesystems layered over MSC: a greenfield exFAT driver (read/write, subdirectories) and an adapted FAT32 driver. Supports devices attached directly or behind a USB hub, including multiple devices at once.
⚠ Project status: living code, no promises. This project is in active flux and changing rapidly, with no fixed roadmap and no declared end state — my own instruments are the roadmap. I (Shannon Mackey) use this driver extensively in real projects and continue to flush out issues on real hardware; interfaces move, behavior gets re-derived whenever the silicon disagrees with the documentation, and anything here may change without notice. Extensive regression harnesses live in
harness/and everything claimed below was validated on the bench when written — but I make no claims and offer no warranties of any kind: the MIT license's AS-IS terms (LICENSE) are the entirety of the promise. If it breaks on your bench, a field report with reproduction steps (seedocs/feedback/) is the most valuable thing you can send.
Everything documented here was validated on real hardware; see Validation status.
Hardware requirements
| Requirement | Value |
|---|---|
| System clock | Keep above 180 MHz. Characterized full-stack floor is 165 MHz (160 MHz is marginal - real media start failing bring-up intermittently; ≤ 150 MHz fails). The TX path self-adjusts its smart-pin gap around a ~168 MHz breakpoint; 180 MHz gives margin over both. No runtime check. Developed at 200 MHz; characterized 165-300 MHz. |
| USB data pins | D− and D+ on an adjacent even/odd pin pair, D− on the even pin (P_USB_PAIR smart-pin requirement). |
| VBUS | 5 V to the device/hub. An active-high enable pin (load switch) is supported but optional. |
| Devices | USB 1.1 full-speed (12 Mbit/s). Low-speed devices are reached through a hub's transaction translation only if the hub presents them as FS; not otherwise supported. An LS leaf behind a hub is now named explicitly in the log (leaf is LOW-SPEED, ticket 1210) instead of failing silently; LS host support is tracked as ticket 1190 (design: docs/design/low-speed-host.md). |
The P2 Eval "USB serial host" accessory header maps as: base+0 = LED, base+1 = EN (VBUS enable), base+2 = D−, base+3 = D+.
Architecture
top-level object (your code)
│
usb_app.spin2 ───────────── facade: open/close, class binding, kbd_* /
│ ser_* / disk_* APIs, fs_* filesystem facade
│ (format dispatch + exFAT/FAT32 auto-detect)
├── usb_hub_host.spin2 hub enumeration: addresses, port reset,
│ config-descriptor parsing for class endpoints;
│ one level of NESTED hub (1205) - combo
│ hub+reader units enumerate their subtree
├── usb_class_hid/cdc/msc.spin2 class protocol (reports, ACM, BOT/SCSI)
├── usb_class_ftdi.spin2 FTDI USB-serial bridges (FT232R/FT-X plus
│ H-series FT2232H/FT4232H/FT232H, multi-channel
│ aware); shares the ser_* API
├── usb_class_pl2303.spin2 Prolific PL2303 bridges (HX/HXD/TA/TB);
│ same ser_* API
├── usb_class_cp210x.spin2 SiLabs CP210x bridges; same ser_* API
├── usb_class_uac.spin2 USB Audio Class 1: descriptor parse (speaker
│ + mic ISO endpoints), SET_CUR rate, Feature-Unit
│ volume/mute; streaming itself is the PHY cog
│ servicing hub-RAM rings on every SOF (aud_* API)
├── usb_hid_report.spin2 HID report-descriptor parser (2150): turns
│ any gamepad/joystick's descriptor into a
│ control map at bind time - axes, hats, button
│ ranges with offsets/ranges (joy_* API)
├── usb_class_midi.spin2 USB-MIDI 1.0 host (2140): 32-bit event
│ packets over bulk endpoints, poll-once IN,
│ pack/decode helpers (midi_* API)
├── usb_ser_expect.spin2 expect-style scripted serial exchange
│ (send/expect with glob patterns + timeouts,
│ tokenizer) for instruments; binds any transport
│ via method pointers
├── usb_fat32_fs.spin2 FAT32 (adapted from IronSheep P2 SD driver, MIT);
│ cogless since 2088, 6 file handles
├── usb_fs_job.spin2 OPT-IN async job service: whole file operations
│ (write/copy/...) on one worker cog with
│ progress/cancel while your loop keeps running
├── usb_exfat_fs.spin2 exFAT (written for this project); no extra cog,
│ 4 file handles
└── usb_host.spin2 ──── the ONE USB cog: PHY (P_USB_PAIR smart pins),
bus reset, SOF, control/bulk/interrupt
transfers, CRC5/16, error recovery.
Commanded through a lock-guarded hub-RAM mailbox.
Key structural facts:
- One cog does all USB. Classes and filesystems are libraries running on the caller's cog; they talk to the USB cog through a mailbox in hub RAM guarded by a hardware lock, so any cog may call the APIs.
- Filesystems see a block device, not USB. Each FS driver is bound to
storage through two Spin2 method pointers with signature
(lba, nblocks, p_buf) : ok(ok ≠ 0 on success) plus a block count.usb_app.fs_mount()binds them todisk_read/disk_write(MSC READ(10)/ WRITE(10), 512-byte blocks). The same interface would accept any other 512-byte block device. - Both filesystems are cogless. The exFAT driver was written that way;
the FAT32 port's worker cog (inherited from its SD original, which needed
cog-local SPI pins) was retired in ticket 2088 - commands now dispatch
directly on the caller's cog (build with
-D SD_USE_WORKER_COGto restore the old mode and its native per-call async API). - Long operations without blocking (ticket 2088): the opt-in
usb_fs_jobservice runs whole jobs - write/append/read a file, copy, mkdir, delete - on one worker cog through the samefs_*facade, with live progress, prompt cancel (~0.1 s), and clean failure on hot detach (in-flight I/O aborts in milliseconds). Measured with an Alicat control loop at 100 ms cadence during a 192 KB SSD copy: successful exchanges kept a 30 ms round trip; the same work done synchronously broke the cadence on every chunk (290 ms slips). Data movement details: docs/dataflow.md. - Format auto-detect. With both FS gates compiled in,
fs_mount()probes for exFAT first and falls back to FAT32 only when the media is genuinely not exFAT.fs_kind()reports which driver owns the mount (1 = FAT32, 2 = exFAT); allfs_*calls dispatch on it.
Resource usage
| Resource | Amount |
|---|---|
| Cogs | 1 (USB host — including all isochronous audio streaming, which the PHY cog services on every SOF). Both filesystems are cogless (FAT32's legacy worker retired in 2088; -D SD_USE_WORKER_COG restores it). The optional async job service (usb_fs_job) adds 1 while started; a dedicated DSP cog for audio is the recommended application pattern, not a driver cost. |
| Locks | 1 (host mailbox) + 1 per filesystem driver in use. Allocated with LOCKNEW, returned on stop. Audio ring operations are lock-free by design. |
| Hub RAM, host | UH_TOTAL_BYTES (from usb_hub_ram.spin2, layout v7) for the mailbox and transfer buffers: 12,112 bytes with HID+MSC+CDC; USB_AUDIO adds 6,208 (ISO OUT ring 4 KB + 8×256 B IN slots + control) for 18,320 all-on; shrinks when classes are compiled out. |
| Hub RAM, exFAT | ≈ 2.5 KB (three 512-byte sector caches, 608-byte entry-set buffer, handle table). |
| Hub RAM, FAT32 | ≈ 5 KB (sector buffers, handle table, 1 KB worker-cog stack). |
| Flash/RAM, code | See Memory footprint by build — 19 KB (host core alone) to 78 KB (everything), measured. |
| Smart pins | The D−/D+ pair. EN/LED are plain pins. |
Memory footprint by build
Total hub RAM occupied while running — code + all DAT (PHY engine, hub-RAM
workspace, filesystem sector buffers) plus VAR — measured with
pnut-ts -m (memory-map output) on a minimal one-object top
(harness/top_footprint.spin2; pnut-ts compiles every method of an
instantiated object, so nothing is stripped and these are honest maxima).
The P2 has 512 KB of hub RAM; even the everything-enabled build uses ~16 %.
| Build gates | Code+DAT | VAR | Total running |
|---|---|---|---|
| (none — host core, enumeration, hot-plug only) | 15,832 | 3,644 | 19,476 |
USB_HID |
17,212 | 4,956 | 22,168 |
USB_HID + USB_JOY (gamepad parse + mapping) |
19,484 | 5,352 | 24,836 |
USB_CDC (incl. FTDI/CP210x/PL2303 bridges) |
19,060 | 6,804 | 25,864 |
USB_MSC |
17,672 | 12,160 | 29,832 |
USB_AUDIO |
17,628 | 10,156 | 27,784 |
USB_MIDI |
16,532 | 3,948 | 20,480 |
USB_HID + USB_CDC + USB_MSC |
22,328 | 16,632 | 38,960 |
USB_MSC + USB_EXFAT |
27,304 | 12,168 | 39,472 |
USB_MSC + USB_FAT32 |
39,492 | 12,168 | 51,660 |
USB_MSC + USB_EXFAT + USB_FAT32 |
48,780 | 12,172 | 60,952 |
USB_HID + USB_CDC + USB_MSC + USB_EXFAT |
31,952 | 16,640 | 48,592 |
USB_HID + USB_CDC + USB_MSC + USB_AUDIO |
24,160 | 23,144 | 47,304 |
...+ USB_EXFAT |
33,784 | 23,152 | 56,936 |
| Everything (all 6 classes + both filesystems) | 58,240 | 23,856 | 82,096 |
Bytes, pnut-ts v1.55.0, 2026-08-12 (after 2140 MIDI / 2150 gamepad /
root hot-plug additions). Not included: your application code
and stacks; the opt-in usb_ser_expect (≈ 2 KB) and usb_fs_job (≈ 3 KB
incl. its 400-long worker stack) objects, which cost nothing unless
instantiated. Compiling with -d adds only ~0.4–0.6 KB of code, but at
runtime DEBUG reserves the top 16 KB of hub RAM for the debugger —
budget for it in debug builds. Reproduce any row:
pnut-ts -q -m -I src -D USB_MSC -D USB_EXFAT harness/top_footprint.spin2
grep "Total Size" harness/top_footprint.map
Measured throughput through a hub (whole-transfer engine, ticket 1170 — the PHY cog loops bulk packets in-cog instead of interpreted Spin): ≈ 810–890 KB/s on multi-block raw reads, ≈ 940 KB/s raw writes (300 MHz), ≈ 600–645 KB/s through the exFAT API with 16 KB calls — ~65–75% of the full-speed bulk ceiling, ~14x the pre-1170 figures. Single-block latency ≈ 1 ms. Creating a directory (one cluster zero-fill on 128 KB-cluster media) ≈ 0.3 s. Full measured profile: docs/perf-1tb-ssd.md.
Building with pnut-ts
Class drivers and filesystems are selected with -D gates at compile time;
code for disabled gates is not compiled in.
| Gate | Enables | Requires |
|---|---|---|
USB_HID |
keyboard/mouse APIs (kbd_*, mouse_*) |
— |
USB_JOY |
gamepad/joystick (joy_*): report-descriptor parse, control introspection, mapping wizard primitive (awaiting hardware validation, ticket 2150) |
USB_HID |
USB_CDC |
serial APIs (ser_*): CDC-ACM gadgets and FTDI / CP210x / PL2303 USB-serial bridges, plus the expect layer |
— |
USB_MSC |
mass-storage APIs (disk_*) |
— |
USB_AUDIO |
UAC1 audio (aud_*): isochronous playback + capture, volume/mute |
— |
USB_MIDI |
USB-MIDI 1.0 host (midi_*): event read/write + decode helpers (awaiting hardware validation, ticket 2140) |
— |
USB_EXFAT |
exFAT filesystem (fs_*) — the primary/default filesystem |
USB_MSC |
USB_FAT32 |
FAT32 filesystem (fs_*) |
USB_MSC |
A filesystem gate without USB_MSC fails the build deliberately, with an
undefined symbol whose name states the constraint (pnut-ts has no #error
directive).
# typical filesystem build (exFAT only)
pnut-ts -d -I <path-to>/src -D USB_MSC -D USB_EXFAT your_top.spin2
# everything, with FAT32 fallback auto-detect
pnut-ts -d -I <path-to>/src -D USB_HID -D USB_CDC -D USB_MSC -D USB_EXFAT -D USB_FAT32 your_top.spin2
# load and watch debug() output
pnut-term-ts --headless -r your_top.bin -p <PORT> -b 2000000 --end-marker "YOUR_MARKER"
-d compiles the debug() statements in; omit it for a silent build. Your
top file must re-export the gates to child objects:
#IFDEF USB_MSC
#PRAGMA EXPORTDEF USB_MSC
#ENDIF
' ...one block per gate you use
Do not default the gates on in your top file
A consumer of this driver lost a day to the obvious-looking version of that block:
' WRONG - this defeats every gate you have
#IFNDEF USB_MSC
#DEFINE USB_MSC
#ENDIF
#IFDEF USB_MSC
#PRAGMA EXPORTDEF USB_MSC
#ENDIF
The #IFNDEF/#DEFINE turns a gate the build left off back on, and the
EXPORTDEF then pushes it down here — so -D selection silently does nothing
and every combination produces a byte-identical image. It reads like a helpful
default, and it is invisible: the build succeeds, the driver works, and the
footprint never moves.
Two things make it hard to spot. It is easy to write the same block in more than one file, so removing it from one changes nothing and looks like proof that the gates are broken. And the symptom — a switch that has no effect — points at the driver rather than at the top file.
Export the gate, never define it. Let -D be the only source of truth, and
check with a byte comparison rather than by eye:
pnut-ts -q -I src -D USB_MSC -D USB_HID your_top.spin2 && md5sum your_top.bin
pnut-ts -q -I src -D USB_MSC your_top.spin2 && md5sum your_top.bin
# the two MUST differ
Integration: bring-up
One call does it (facade v2, ticket 0180):
OBJ
usb : "usb_app"
PUB main()
ifnot usb.begin(16) ' serial-host header base pin
debug(zstr_(usb.err_str()))
return
if usb.await_drive(15_000) ' storage there? (samples hub ports while waiting)
...usb.fs_open_read(...) ' filesystems mount on first use
if usb.await_serial(3000)
usb.ser_open(38400) ' CDC-ACM or any vendor bridge - same API
if usb.kbd_ready()
...usb.kbd_char()
begin() starts the host cog, runs a sampled reset→probe ladder (the
bus answering GET_DESCRIPTOR at address 0 is the readiness signal — no fixed
power dwells or boot allowances), keeps SOF running for the session,
enumerates the whole bus, and binds every compiled class: storage, serial
(first vendor bridge — FTDI, CP210x or PL2303 — if any, else first CDC-ACM), keyboard and mouse by HID
boot protocol. await_drive/serial/kbd(ms) sample hub port status while
waiting, so devices that assert connect seconds after power (SSD bridges:
6–9 s measured) still arrive; rescan() does one non-blocking pass for idle
loops. err_str() explains failures in words.
Hot-plug (insert / remove / rearrange) is first-class: put usb.poll()
in your idle loop (internally rate-limited — free between samples) and
optionally bind usb.on_change(@handler) to be told about every
EV_ATTACH/EV_DETACH after the driver has reacted — bindings dropped on
removal (a mounted filesystem is abandoned with zero I/O to the dead
device), new devices enumerated and bound on arrival. Coverage includes the
root device of each bus (2026-08-12 field report): a yanked hub tears
down its whole subtree, and a re-inserted root — hub or direct device — is
recovered by poll() alone, without begin() and without touching the
other bus. The full usage guide — handler rules, per-class detach/re-attach
semantics, waiting styles, troubleshooting — is
docs/hotplug.md;
harness/top_hotplug.spin2 validates the whole cycle live, including a
programmatic port-power detach of the mounted SSD and remount after restore;
harness/top_rootplug.spin2 validates root death/recovery via EN-gated hub
power.
Getting started, per class
One clean, copy-paste guide per feature — what to buy, the build line, the smallest complete program (every one compile-verified verbatim), what bring-up actually does step by step, and a troubleshooting table:
| Guide | You get | Status |
|---|---|---|
| Gamepad / joystick | any pad's buttons/sticks/hats discovered by descriptor, plus the press-the-control-you-want mapping wizard | awaiting hw (2150) |
| USB-MIDI | note/CC/bend events from any class-compliant controller, decoded; optional synth | awaiting hw (2140) |
| Audio | 48/44.1 kHz playback + microphone through a $10 UAC1 adapter, fed from a ring the USB cog services itself | validated |
| Keyboard & mouse | boot-protocol keys/ASCII and mouse deltas, composite receivers included | validated |
| Serial | one API over FTDI/PL2303/CP210x/CDC + the expect engine for instruments | validated |
| Storage & files | files on exFAT/FAT32 media, mount-on-first-use, hot-plug-safe | validated |
| Recovering a wedged drive | telling a hub-disabled port from a wedged device, the reset/re-bind ladder, opt-in auto-recovery, and proving a drive really came back | validated |
All of them share the same skeleton: usb.begin(16) → an await/poll for
the class → use it → usb.poll() in the idle loop for hot-plug.
How bytes actually move — the buffer inventory, the consumer-pull model, and where backpressure is absorbed at every layer — is documented in docs/dataflow.md.
Every timing constant behind this — what it paces, its spec citation or
measurement, and the former blind waits it replaced — is tabulated in the
timing constants ledger of
docs/api/usb-app-api.md, the full API manual.
The pre-v2 manual sequence (open() / probe loops / parse_classes /
bind_* / disk_open / fs_mount) remains available as the advanced layer
for exotic topologies and is documented there too.
Filesystem API (usb_app facade)
| Call | Notes |
|---|---|
fs_mount() : st |
0 on success. Auto-detects format. |
fs_unmount() : st |
Flushes and clears the volume-dirty flag. |
fs_kind() : k |
0 none, 1 FAT32, 2 exFAT. |
fs_vol_label() : p |
Zero-terminated string pointer. |
fs_open_read(path) : h fs_open_write(path) : h fs_create(path) : h |
Handle ≥ 0 or negative error. fs_open_write positions at end (append); seek to overwrite. fs_create errors if the file exists. |
fs_read(h, buf, n) : n fs_write(h, buf, n) : n |
Bytes moved (0 at EOF), negative on error. |
fs_seek(h, pos) : st fs_file_size(h) : n |
Seeking past EOF is refused (−115) on both filesystems — enforced at the facade (6170). |
fs_close(h) : st |
Flushes directory metadata for written files. |
fs_delete(path) : st |
Files, or empty directories (else −96). |
fs_mkdir(path) : st |
Parent must exist. |
fs_rename(old, new) : st |
Rename in place on both filesystems; the final component of new becomes the name in old's directory. |
fs_move(path, destdir) : st |
Move a file (or directory) into another directory, both filesystems. |
fs_max_handles() : n |
Concurrent open handles (files + directory iterations share the pool). Both drivers: 6. |
drive_bind_second(da) : ok fs_mount_second_exfat() : st fs_unmount_second() |
Two volumes at once (6140): bind a second MSC drive and mount the exFAT driver on it while the facade keeps serving its primary (typically FAT32) volume. Drive the second volume through the exFAT driver object (it's a DAT singleton). One FAT32 + one exFAT is the supported pair; a BOT-operation lock makes two-cog I/O to both volumes safe. |
fs_sync() : st |
Flush all open write handles. |
fs_dir_open(path) : h fs_dir_next(h) : p fs_dir_close(h) |
Iterate a directory; after each fs_dir_next, read fs_entry_name()/fs_entry_size()/fs_entry_attr() (attr bit 4 = directory). |
fs_time_source(mp) |
Bind an RTC; see below. |
Paths use / separators on both filesystems, absolute or relative to the
per-cog working directory (fs_chdir; root after mount). On exFAT the cwd
lives at the facade (the driver resolves full paths); on FAT32 it is the
adapted driver's own per-cog cwd, with 8.3 naming.
Serial instruments: the expect layer
Most lab instruments (mass-flow controllers, meters, pumps) speak short
line-oriented ASCII over a USB-serial cable — and most of those cables are
vendor-bridge silicon (FTDI, Prolific PL2303, SiLabs CP210x), not CDC-ACM.
This stack handles all of them behind one API, and
usb_ser_expect gives you tcl-expect-style scripted exchanges on top:
OBJ
usb : "usb_app"
exp : "usb_ser_expect"
usb.begin(16)
usb.await_serial(3000) ' any bridge or CDC - bound either way
usb.ser_open(38400)
exp.bind(@ser_wr, @ser_rd) ' one-line shims (manual §4)
exp.bind_baud(@ser_baud)
if exp.autobaud(string("A"), string("A*"), 0, 0) ' condition-driven rate lock
if exp.chat(string("A"), string("A*"), 0) ' 0 = default timeout
flow_milli := exp.token_milli(2) ' "+0.512" -> 512
gas := exp.token(6) ' "Air"
The engine is generic — it knows lines, glob patterns, timeouts and
tokens; your application supplies the commands, so SCPI instruments
(set_delims(string(",")), LF terminators), AT modems (expect_any over
OK/ERROR* branches), and Alicat controllers all script the same way.
Verbs: sendline / readline / expect / expect_any / chat /
drain() (ends on a sampled quiet line, never a fixed delay) / autobaud
(retries only on evidence of garbled traffic, never a blind repeat).
Dissection: ntokens(), token(i), is_num(i), token_int(i),
token_milli(i) (fixed-point → integer milli-units; no floats),
unparsed() for partial replies in error reports. Full contract, dialect
knobs and worked SCPI/AT shapes:
docs/api/usb-app-api.md.
harness/top_alicat_expect.spin2 is the worked example, validated against an
Alicat BASIS mass-flow controller: device discovery (FTDI vs CDC), autobaud
probing, frame tokenization, a multi-line info dump, and a setpoint
write/readback/zero cycle that identifies the setpoint column by diffing
frames rather than trusting a hardcoded layout. top_serial_discover.spin2
dumps every device's interfaces/endpoints when you need to identify a new
cable.
Bridge specifics handled for you — FTDI (usb_class_ftdi.spin2): vendor
bring-up (reset/purge, 8N1, no flow control, DTR/RTS), divisor baud encoding
with chip-generation detect from bcdDevice (48 MHz-base classic parts,
120 MHz-base H-series), 4 ms latency timer, the 2 status bytes FTDI prefixes
to every bulk-IN packet, and multi-channel parts (FT2232C/H, FT4232H):
channel A auto-binds, bind_ftdi_ch() reaches B..D. Prolific PL2303
(usb_class_pl2303.spin2): chip-type detect (HX/HXD/TA/TB, legacy type H,
HXN degrade), the undocumented vendor init dance, literal-baud line coding
with ser_device_baud() read-back, DTR/RTS. SiLabs CP210x
(usb_class_cp210x.spin2): IFC_ENABLE bring-up, literal 32-bit baud with
CP2101 divisor fallback, DTR/RTS, FIFO purge. Validated live: FT232R/FT-X,
FT4232H (all 4 channels), PL2303HXD, PL2303TA.
The RTC time-source contract
Without a time source, files are stamped with a fixed build-time constant. To get real timestamps, bind a method pointer before writing files:
app.fs_time_source(@rtc_now) ' from your top object
PRI rtc_now() : ts
' read your RTC here, then pack:
ts := ((year - 1980) << 25) | (month << 21) | (day << 16) | (hour << 11) | (minute << 5) | (second >> 1)
Requirements on the method you provide:
- Signature: no parameters, one return value — a single long in packed DOS/exFAT format: bits 31..25 year−1980 (0..127), 24..21 month (1..12), 20..16 day (1..31), 15..11 hour (0..23), 10..5 minute (0..59), 4..0 seconds÷2 (0..29). Values are written to the media as-is; no validation is performed.
- Calling context: it is called synchronously on whatever cog invoked
the filesystem write API (
fs_create,fs_close,fs_sync) — at file creation and at every entry-set flush. If several cogs use the filesystem, the method must be safe to call from any of them. - It must return promptly and must not re-enter this stack. It runs while
the filesystem's API lock is held. Do not call any
fs_*/disk_*/USB API from it, and do not block (a register read from an I²C/SPI RTC is fine; a wait-for-next-second loop is not). - The method pointer captures its object context (
@methodfrom any object works). Passing 0 reverts to the fixed constant. - Current wiring: the stamps land on exFAT create + last-modified fields. 10 ms-resolution fields and UTC-offset fields are written as zero.
Limits
Filesystem-independent:
- 512-byte logical blocks only. Media reporting another block size is
rejected at
disk_open. - MSC: bulk-only transport, LUN 0 only.
- One storage device mounted at a time.
- One writer per file; no file locking between cogs beyond the API lock.
Audio (USB_AUDIO, UAC1):
- One audio device bound at a time (first UAC1 device found).
- 16-bit little-endian interleaved PCM assumed;
bSubframeSizeis parsed but not enforced (commodity adapters are 16-bit). - First speaker and first mic ISO alt-setting only; no unit-topology walk. Volume/mute go to the Feature Units master channel, first-ACK-wins — which FU sits in the playback path is device luck (fanning every FU × channel combo wedged a CM108's EP0 on the bench, hence the restraint).
- No rate table:
aud_open(rate)asks the device via SET_CUR and the device is the authority — an unsupported rate STALLs and the open fails cleanly. 48,000 and 44,100 Hz validated (44.1 k exact via a 16.16 fractional accumulator). aud_readreturns whole frames only; a buffer smaller than one mic packet drains nothing (size it ≥ the mic max packet, ≤ 252 bytes).- Synchronous-mode ISO mics (e.g. CM108 capture) lock to SOF timing and degrade under heavy bulk-induced SOF jitter from a degraded bus peer (~99.3 % held with healthy peers); adaptive-mode playback is immune.
exFAT driver:
- MBR partition 1 or partitionless (superfloppy) volumes. No GPT.
- Boot-region checksum is verified at mount; a corrupt boot region fails the mount (code −83) rather than mounting best-effort.
- Names: ASCII only. Matching is case-insensitive via ASCII folding — the
on-disk up-case table is not consulted, so non-ASCII names cannot be matched
(they list with
?substituted) and case-folding of non-ASCII is wrong by spec. Name length ≤ 64 chars per component. - File sizes: 32-bit API. Files ≥ 2 GB read with size clamped to $7FFF_FFFF; writing cannot extend a file past that.
- 4 concurrent handles (files + directory iterators combined,
MAX_OPENinusb_exfat_fs.spin2). - A directory cannot grow past its initial cluster: with 128 KB clusters that is 4,096 directory entries — at 3 entries per short-named file, ≈ 1,300 files per directory. Exceeding it returns −94 rather than extending.
- No rename/move. Directory deletes require the directory to be empty.
- Timestamps: create and last-modified only; last-access is not updated.
- No TexFAT (transaction-safe exFAT); the second FAT, if present, is ignored (per spec, only FAT[0] is active without TexFAT).
FAT32 driver (adapted third-party code):
- 8.3 names only (no VFAT long names).
- 6 concurrent handles (
MAX_OPEN_FILES). - Expects exactly 2 FATs and 512-byte sectors; FAT32 only (no FAT12/16).
- Runs one worker cog while mounted; per-cog current-directory semantics come from the original driver.
Flash wear policy (both filesystems)
Both drivers allocate next-fit: a forward-marching hint that wraps at the end of the volume and is never rolled back on delete, so the whole volume cycles through before any freed cluster is rewritten — spreading erase load instead of hammering the low clusters under delete/create workloads (a rotating log is exactly that workload). Hardened 2026-08-12 after an SSD wear scare:
- FAT32: the hint lives in the on-media FSInfo sector — loaded at
mount, advanced on every allocation, and persisted on every
fs_sync()as well as unmount (the hot-plug path abandons a yanked volume with zero I/O, so unmount-only persistence lost the hint on every surprise removal). Media without a valid FSInfo get the hint derived at mount by a backward FAT scan instead of restarting at cluster 2. - exFAT: the spec has no on-media next-free field, so every mount derives the hint by scanning the allocation bitmap backward for the highest in-use cluster and resuming past it. The scan reads only the trailing all-free bitmap sectors (a well-filled volume terminates in a few reads; worst case — a freshly formatted volume — is one full bitmap pass: ~1.9 s on 1 TB at 128 KB clusters, ~0.1 s on a 64 GB card).
Managed media (SSD bridges, SD cards) also wear-level internally in their controllers; this policy keeps the filesystem from concentrating logical rewrites on top of that, and is what protects raw or cheaply-managed flash.
Validated live 2026-08-13 (harness/top_wearpolicy.spin2, 64 GB exFAT
card): write 2 clusters → delete → write again — the new file took the
next cluster (7), not the just-freed ones (5,6), and the remount scan
re-derived the allocation front (resume-at-8) across sessions. FAT32 side
validated the same day via the full acceptance suite.
Error codes (exFAT / facade)
| Code | Meaning |
|---|---|
| −80 | not mounted / no filesystem owns the mount |
| −81 | media is not exFAT (auto-detect falls through to FAT32 on this) |
| −82 | unsupported geometry (sector size ≠ 512) |
| −83 | boot-region checksum mismatch |
| −84 | block-device I/O error |
| −85 | not found |
| −86 | out of handles |
| −87 | bad handle |
| −88 | broken cluster chain |
| −89 | no block device bound |
| −92 | already exists (fs_create, fs_mkdir) |
| −93 | no free cluster (disk full) |
| −94 | no room in directory |
| −95 | empty or over-long name |
| −96 | directory not empty (fs_delete) |
| −100 | fs_mount called before disk_open succeeded |
| −110 | not a file |
| −111 | not a directory |
| −112 | file is in use by an open handle |
| −113 | file already open for writing |
| −114 | not a valid handle of that kind (e.g. file op on a dir handle) |
| −115 | invalid parameter |
| −116 | end of file |
| −117 | no contiguous space |
| −118 | media is not the filesystem asked for |
| −119 | a driver code with no facade meaning |
| −120 | operation not supported by the mounted filesystem's driver (currently unreachable — kept for future operations) |
The table above is the facade contract on both filesystems: exFAT's driver
codes are these values natively, and FAT32's raw codes (0 to −64 range in
usb_fat32_fs.spin2) are translated by the facade. The −110..−120 block covers
conditions FAT32 can report that the original exFAT-derived table had no word
for, placed outside both drivers' raw ranges so an untranslated leak is
recognisable.
Examples
All in examples/, all on the v2 session API, each compilable standalone
with just its own gate (build lines in each file's header):
| File | Shows |
|---|---|
top_fs_example.spin2 |
begin() + await_drive(), time source, directory listing, file create/write/readback (filesystem mounts itself on first use). |
top_msc_example.spin2 |
Raw block layer: capacity, sector reads, a throughput burst. Read-only by design. |
top_cdc_example.spin2 |
Serial plane: one ser_* API over CDC-ACM or any vendor bridge, write + polled read. |
top_hid_example.spin2 |
Keyboard + mouse (bound to separate boot-protocol interfaces automatically), report polling. |
top_hotplug_example.spin2 |
Hotplug-ready application skeleton — a resilient logger implementing the docs/hotplug.md patterns: poll() in the idle loop, a minimal on_change handler, ready-checks before every use, reopen-by-path logging that survives detach, re-autobaud on serial re-attach. |
top_tone_keyboard_example.spin2 |
Audio + HID together (USB_AUDIO + USB_HID): keys 1–9 on a USB keyboard play a C-major scale through a USB audio adapter — phase-accumulator synthesis fed to aud_write, poll() + stream-health stats on the side. The first-hardware audio demo (nine clean tones, 169k frames at a sustained 1000/s). |
top_audio_loop_example.spin2 |
Audio playback + full-duplex: 3 s of synthesized tone, then a dedicated DSP cog running mic → biquad low-pass → speaker live (coefficients derived in-file). |
top_audio_dsp_example.spin2 |
The cross-cog audio pattern: a DSP cog owns the capture stream (aud_mic_open + aud_read, per-10 ms RMS/peak + 1 kHz Goertzel detect) while the main cog keeps poll() and hot-plug. |
top_midi_example.spin2 |
USB-MIDI host (USB_MIDI, ticket 2140 — awaiting hardware): with USB_AUDIO, a monophonic velocity-sensitive synth with ±2-semitone pitch bend (exact equal-temperament note table, derivations in-file); without, a decoded MIDI event monitor. |
top_joy_example.spin2 |
Gamepad/joystick (USB_JOY, ticket 2150 — awaiting hardware): discover (every axis/button/hat introspected by HID usage, no hardcoded layout), map (interactive press-the-control-you-want wizard via joy_mark()/joy_changed()), play (mapped controls read back through joy_control()). |
The FS, hotplug, serial and HID examples were each run on the bench before
this README claimed they work — against the 1 TB exFAT SSD behind the hub
(arriving via the late-attach path) with the Alicat MFC answering on the
serial plane. On the audio plane, top_tone_keyboard_example is the path's
first-hardware run (nine clean tones by ear); the loop and DSP examples
encode the patterns the acceptance harness (harness/top_audio_accept.spin2)
validated — capture rates, Goertzel detection, the cross-cog ring contract —
as compile-verified worked examples.
Filesystem regression coverage
Two gates, both green on real hardware. Full detail — including what is not covered — in docs/regression-report.md.
| gate | what it covers | result |
|---|---|---|
tests/regression/run_gate.sh |
17 RT_ suites, FAT32 driver, one drive |
346 / 346, 0 recoveries |
tests/regression/run_fs_gate.sh |
11 FS_ suites through the fs_* facade, both filesystems, the XS_ exFAT structural + malformed-media synthetic suites, the MD_ multi-drive suite (dual mounts + two-cog concurrency), and the HP_ hot-plug suite (real detaches via hub port power, ring-audited abandon honesty, sticky re-bind, churn) |
FAT32 223/223, exFAT 219/219, structural 27/27, malformed 20/20, multi-drive 13/13, hot-plug 11/11 |
Both refuse to call a run green on a technicality: run_gate.sh fails on any
missing result and on any BOT recovery event — a suite that passed after a
recovery is not the same result as one that never needed one — and
run_fs_gate.sh fails on any deviation from expected counts in either
direction, so a new failure cannot hide behind a fixed one.
FAT32 geometry matrix
Three volumes formatted to different geometries, each pinned by serial:
| drive | cluster size | data-area alignment | 16 non-defrag suites | RT_defrag_tests |
|---|---|---|---|---|
| A | 32 KB | 0 | 332 pass, 0 fail, 0 recoveries | 14 pass |
| B | 4 KB | 0 | 332 pass, 0 fail, 0 recoveries | wedges the drive |
| C | 4 KB | 5 | 332 pass, 0 fail, 0 recoveries | wedges the drive |
All three produce the identical 332. Drive C's non-zero cluster alignment is the
only geometry reaching the entryOffsetInCluster sign fix, so that path is now
exercised on live media — and alignment is thereby exonerated in the defrag
wedge: B and C differ only in alignment and behave identically both ways, so the
variable they share is the 4 KB cluster size.
Both filesystems, through the application facade
The FS_ suites talk to usb_app's fs_* facade — the interface applications
use — so one source runs on both filesystems:
| suite | FAT32 | exFAT |
|---|---|---|
FS_seek_tests |
38 / 38 | 38 / 38 |
FS_cogcwd_tests |
5 / 5 | 5 / 5 |
FS_stress_tests |
4 / 4 | 4 / 4 |
FS_fatchain_tests |
2 / 2 | 2 / 2 |
FS_delete_guard_tests |
8 / 8 | 8 / 8 |
FS_multihandle_tests |
22 / 22 | 22 / 22 |
FS_read_write_tests |
49 / 49 | 49 / 49 |
FS_dirhandle_tests |
25 / 25 | 25 / 25 |
FS_subdir_ops_tests |
18 / 18 | 18 / 18 |
FS_error_handling_tests |
19 / 19 | 18 / 18 † |
FS_directory_tests |
33 / 33 | 30 / 30 ‡ |
| total | 223 / 223 | 219 / 219 |
† the white-box MBR-preservation test is FAT32-only by design and compiles out on exFAT (18 there). ‡ the cluster-zero raw-patch group likewise (30 there).
Each FAT32 column matches its RT_ original exactly — the control proving the
ports are faithful. The exFAT column is at FULL PARITY (ticket 6170,
closed 2026-08-28): the 27-item shortfall ledger the ladder accumulated —
seek-past-EOF clamping, chdir silent success, handle-limit differences,
error-code splits, missing rename/move, the error write-on-exit gap — cleared
by five facade/driver normalizations, a facade-level per-cog cwd for exFAT,
and real rename/moveFile in the exFAT driver (entry-set rewrite with
independent checksum/hash verification in the structural suite). The only
exFAT-absent tests are the white-box-by-design ones above.
Product build matrix
run_fs_gate.sh --build-only compiles every shippable configuration with no
hardware, so it belongs in a build pipeline:
| build | size |
|---|---|
USB_MSC USB_EXFAT |
58,700 B |
USB_MSC USB_FAT32 |
71,869 B |
USB_MSC USB_EXFAT USB_FAT32 |
81,876 B |
The three different footprints are the evidence the compile-time gating works — each build carries only what it asked for, and a disabled filesystem leaves no remnant.
What testing through the facade found
Pointing the same suites at both drivers surfaced defects neither driver-bound ladder could reach, since each only ever ran against its own driver:
- exFAT had no open-path table. It kept handles but nothing mapping a path to them, so it deleted open files, allowed two write handles on one file, and left state that failed nine further tests. Fixed — the identity it needed (parent-dir cluster + entry-set index) was already recorded per handle.
- The two drivers' error codes collided —
−93meant "not a dir handle" on FAT32 and "disk full" on exFAT. The facade now translates FAT32's codes into the contract the README has published all along. - Parity divergences that were silent success — all since resolved (6170):
fs_seekpast EOF was silently clamped by exFAT (now refused at the facade on both);fs_chdirreturned success on exFAT having done nothing (the facade now keeps a real per-cog cwd for it); the same misuse yielded different error codes per filesystem (now −111/−114 everywhere). Each was filed first, decided once, then fixed — the ledger is on ticket 08de1c7aaa. - The "populated-directory delete" defect was the suite's own fixture. It
built the directory via
fs_chdir+ bare names — a no-op on exFAT — so the child landed in root and the directory really was empty. Rebuilt with absolute paths and a child-enumerates-inside check, the guard fires on both filesystems (−96) and the suite is 8/8 on both. - The BOT wedge was the host's own timer arithmetic — the biggest find of
the whole port effort. Twelve sites in the USB cog compared absolute
getct()values against deadlines, which misfires when a wait straddles the 32-bit CNT wrap (a 21.47 s grid, phase fixed at reset) — spurious timeouts in the transaction engine and, worse, SOF anomalies injected between the packets of in-flight transfers. Cracked open by four consecutive runs wedging at mount + 10.66–10.70 s across a physical reseat; fixed with the wrap-safe subtract-then-sign-test form; verified by the reproducer running clean, the TX canary, and the full gate green on both filesystems (ticket1464ab6579). fs_eofleaked raw driver codes through its error leg — the normalisation detour bypassed the translation, so misuse returned FAT32's −93 where the contract says −114. Found by the ported suite's FAT32 control on its first run; fixed the same day. (fs_dir_openhad the same leak, also now fixed.)- exFAT lacks the
fs_error()write-on-exit contract: a successful operation never stores 0, so a stale error survives success. Predicted from source, confirmed on hardware; filed with the other parity gaps.
Known gaps, stated plainly
RT_defrag_tests has never passed on 4 KB media. Seven of the seventeen suites
still cannot run on exFAT — the old error-constant wall fell with the facade
error contract; what remains is the date API, driver lifecycle, and
moveFile/setVolumeLabel, plus a group that is white-box by design
(raw-sector and debugGet* access) and rightly stays FAT32-only. exFAT's
structural side (bitmap, entry sets, NoFatChain, VolumeDirty, boot region)
has its own 27-test synthetic-volume suite — which found and now guards a
real stray-FAT-write defect in the NoFatChain conversion path. Multi-drive
is covered (6140): dual FAT32+exFAT mounts, two-cog concurrent I/O with
per-volume content verification, selection churn, and the bystander invariant
asserted by witness hashes. Malformed media is covered (6190): hostile
synthetic volumes for both filesystems — FAT chain loops, cycles and off-end
chains, cross-linked files, lying geometry and FSInfo, truncated exFAT entry
sets, out-of-range system clusters — with fail-clean asserted mechanically
(bounded reads, defined codes, a shim auditing zero out-of-range writes).
Not exercised at all: hot-plug/surprise removal.
These numbers measure one drive at a time, well-formed media, no removals. A floor, not a finish line.
Validation status
All on real hardware (P2 Eval, serial-host header, 200 MHz): a 4-port hub (VID $05E3) carrying a composite CDC serial gadget ($303A:$1001), a keyboard/mouse receiver ($046D:$C52B), and a USB SD-card reader ($14CD:$1212) — enumerated and exercised concurrently.
- MSC: single and 8-block reads/writes with pattern verification.
- 1 TB exFAT SSD (ASMedia
$174C:$A89ASATA bridge, behind the hub with the three devices above): full-range LBA addressing (verified write-back at LBA 2,000,409,256), mount with boot-checksum verification (7.8 M × 128 KB clusters), small/large file write/read/verify, seek, remount persistence, cleanup — plus a raw and filesystem performance profile. Results table and writeup: docs/perf-1tb-ssd.md. This work also root-caused and fixed a content-dependent PHY TX corruption (bit-stuff geometry vs. the smart-pin byte-handoff gap) that no prior media had triggered. - FAT32 on the cogless driver (8 GB FAT32 card in the USB reader,
2026-08-10): auto-detect fallback (exFAT probe miss → FAT32 mount, 81 ms),
directory listing of a live volume, create/write/read/verify/seek/delete,
mkdir, persistence across remount, 256 KB copy verified (same
band as exFAT), small-chunk I/O — all via direct dispatch, no worker cog —
plus a 256 KB async-job copy on the same card, verified
(
harness/top_fat32_test.spin2, RESULT PASS, zero failures). - exFAT: mount with boot-checksum verification on a 64 GB card; file
create/write/readback (including a 147 KB file spanning a cluster
boundary), seek, delete; subdirectory create/list/delete; persistence
across remounts. After the write sessions the card was checked on a PC
(
fsck.exfatclean, files readable). - HID and CDC paths validated in the same multi-device sessions.
- Alicat BASIS mass-flow controller over its FTDI cable ($0403:$6015),
live through the hub: autobaud at 38,400, tokenized poll frames, setpoint
write/readback/zero with the valve-drive response observed across timed
polls — all through
usb_ser_expect+usb_class_ftdi(harness/top_alicat_expect.spin2, tickets 2055/0180). - First audio: USB Audio Class playback validated by ear (tickets
1180/2110): isochronous OUT streamed by the PHY on every SOF from a
hub-RAM ring — a C-Media CM108 adapter behind the hub played nine clean
synthesized tones driven live from a USB keyboard
(
examples/top_tone_keyboard_example.spin2), 169k frames at a sustained 1000/s. Then closed to full acceptance with a loopback jumper (headphone→mic): capture measured at exactly 48,000 and 44,100 Hz, a four-tone Goertzel matrix verified bin-by-bin, and 60 s of zero-underrun playback while the async job cog streamed file copies at 251 KB/s (harness/top_audio_accept.spin2). Bugs the campaign shook out: interrupt-IN polls carried the bulk NAK budget (~64 ms per idle keyboard poll — interrupt is poll-once, now ~1 ms); the ISO receiver lacked the own-EOP gate; the frame guards taxed both buses with the ISO reservation;aud_readclip-discard punched DSP-shredding discontinuities. - Hub-path ACK-window fix (ticket 0190, from a field report): the in-cog RX ACK now precedes all mailbox bookkeeping — USB-3 sticks in FS fallback behind hubs no longer go silent/wedged at 200 MHz. Full dual-bus suite (hostile canaries both buses, 20/20+20/20 storage/serial interleave, hot-plug) validated at 200 and 300 MHz; a co-discovered SOF-starvation bug (control-transfer bursts postponing SOF until devices legally suspended) fixed in the same pass.
- USB-serial bridge family validated live (ticket 2065): a UTEK FT4232H
quad adapter ($0403:$6011) — all 4 channels brought up through
bind_ftdi_ch()with the H-series 120 MHz-base divisor; PL2303HXD and PL2303TA ($067B:$2303) — full vendor init with the commanded baud read back via GET_LINE (38,400 then 115,200 echoed exactly by both chips); Alicat FT-X regression after the driver rework: 9/9 exchanges, PASS (harness/top_serialx.spin2). - Facade v2 session API validated live on both planes: the Alicat demo
(9/9 exchanges, ~20–28 ms round trips — faster than v1 because sampled
quiet-drain replaced fixed drains) and the FS example (
begin()+await_drive()binding the SSD via the late-attach path, mount-on-first- use, write/readback) (ticket 0180). - HID multi-interface auto-bind confirmed live (Unifying receiver $046D:$C52B): keyboard and mouse on separate boot-protocol interfaces, both endpoints answering, reports delivered (tickets 0180/0185).
- Hot-plug cycle validated live (
harness/top_hotplug.spin2): SSD attach as apoll()event; programmatic detach (hub per-port power) of the mounted volume — bindings dropped, filesystem abandoned I/O-free; re-attach, remount, file write/readback; post-cycle binding-integrity check. Also flushed out and fixed a roster-compaction bug via Spin2's auto-reversingrepeat(ticket 0185). - Nested hub validated live (2026-08-13, ticket 1205 from a field report): a dock-style combo unit (Realtek $0BDA root hub with a Prolific PL2586 hub inside) enumerated one level deep - the card reader, a Billboard device and an RTL8153 NIC behind the inner hub all rostered; the SD card behind the inner hub then passed the FULL FAT32 acceptance suite (write/verify/remount/async copy, RESULT PASS), the hostile TX pattern canary bit-exact, and 60 s of zero-underrun audio with the file-copy load running through the nested path on the same bus.
- Cross-bus multi-cog concurrency validated live (2026-08-13, ticket
1200 from field report #3): a worker cog streaming 3x64 KB file writes
through the nested hub on bus 1 while the main cog ran 84 serial
exchanges (through a 2160 serial handle) plus 84 HID polls on bus 0 -
zero serial failures, all jobs verified (
harness/top_xbus1200.spin2, RESULT PASS). This is the field-observed misrouting geometry at 20x the reported cadence, clean under the per-cog routing fix. - Root hot-plug validated live (2026-08-12, from a P2 Edge field
report;
harness/top_rootplug.spin2with EN-gated hub power as the programmatic "pull it out, put it back"): hub death → 3 leaf + 1 port-0 rootEV_DETACH, roster drained, zombie-leaf cleanup; 6 s dead-bus soak with zero phantom attaches (the bench's floating-J line caveat handled by evidence-based paced probing); EN restore → rootEV_ATTACH+ full subtree re-rostered bypoll()alone in 1.1 s; all addresses answering (RESULT PASS).
Development history, decisions, and per-feature evidence live in the Fossil
ticket ledger in this repository (fossil ticket show 1 from the checkout).
License
MIT — see LICENSE. © 2026 Shannon Mackey (Refaqtory, LLC). Third-party components keep their original MIT copyrights: the FAT32 driver derives from Chris Gadd's and Stephen M Moraco's P2 microSD driver, and the bench uses Jon McPhalen's I²C/DS3231 objects — each file carries its own notice.
This project stands on real shoulders — Chip Gracey's silicon, Ada Gottensträter's pioneering USBnew host, Iron Sheep's toolchain, and a community's worth of prior art. The full appreciation, by name and by debt, is in ACKNOWLEDGMENTS.md.