Back to Technology

USB Part 8: Mass Storage Class (MSC)

March 31, 2026 Wasil Zafar 28 min read

Implement USB Mass Storage Class (MSC) with TinyUSB — understand BBB protocol, SCSI transparent command set, LUN design, FATFS integration, SD card backend, read10/write10 callbacks, and media-change notification.

Table of Contents

  1. MSC Architecture & BBB Protocol
  2. SCSI Command Set
  3. TinyUSB MSC Configuration
  4. MSC Callbacks
  5. SD Card Backend via FATFS
  6. Write Protection & Ejection
  7. Performance Optimization
  8. FATFS on Device Side
  9. Internal Flash as MSC Storage
  10. Practical Exercises
  11. MSC Design Tool
  12. Conclusion & Next Steps
Series Context: This is Part 8 of the 17-part USB Development Mastery series. Parts 1–7 covered USB fundamentals, electrical layer, protocol and enumeration, device classes, TinyUSB architecture, CDC virtual COM port, and HID devices. Here we implement USB Mass Storage — turning your embedded device into a flash drive the host OS mounts automatically.

USB Development Mastery

Your 17-step learning path • Currently on Step 8
1
USB Fundamentals
USB system architecture, transfer types, host/device model, protocol stack
Completed
2
Electrical & Hardware Layer
D+/D- signalling, pull-ups, connectors, USB-C, STM32 USB peripherals
Completed
3
Protocol & Enumeration
Enumeration sequence, USB packets, descriptors, endpoint concepts
Completed
4
USB Device Classes
HID, CDC, MSC, MIDI, Audio, composite devices, vendor class
Completed
5
TinyUSB Deep Dive
Stack architecture, execution model, STM32 integration, descriptor callbacks
Completed
6
CDC Virtual COM Port
CDC class, bulk transfers, printf over USB, baud rate handling
Completed
7
HID Keyboard & Mouse
HID descriptors, report format, keyboard/mouse/gamepad implementation
Completed
8
USB Mass Storage
MSC class, SCSI commands, FATFS integration, SD card, RAM disk
You Are Here
9
Composite Devices
Multiple classes, IAD descriptor, CDC+HID, CDC+MSC
10
Debugging USB
Wireshark capture, protocol analyser, enumeration debugging, common failures
11
RTOS + USB Integration
FreeRTOS + TinyUSB, task priorities, thread-safe communication
12
Advanced USB Topics
Host mode, OTG, isochronous, USB audio, USB video
13
Performance & Optimisation
DMA, zero-copy buffers, throughput maximisation, latency tuning
14
Custom USB Class Drivers
Vendor class, writing descriptors, OS driver interaction
15
Bare-Metal USB
Direct register programming, writing USB stack from scratch, PHY timing
16
Security in USB
BadUSB attacks, device authentication, secure firmware, USB firewall
17
USB Hardware Design
PCB layout, differential pairs, impedance matching, EMI, USB-C PD

MSC Architecture & BBB Protocol

USB Mass Storage Class (MSC) is one of the most complex USB device classes precisely because it layers two independent protocols: the USB MSC class protocol on top, and the SCSI transparent command set underneath. Every time you plug in a USB flash drive, your OS is sending SCSI commands wrapped in USB bulk transfers. Understanding this layering is essential before writing a single callback.

Bulk-Only Transport (BBB)

The USB MSC specification defines two transport protocols: Control/Bulk/Interrupt (CBI) and Bulk-Only Transport (BBB). CBI is legacy — used by older floppy drives. All modern USB storage uses Bulk-Only Transport, so named because it uses only bulk endpoints for data and command transfer (no interrupt or control endpoints beyond the mandatory EP0).

BBB uses exactly two bulk endpoints:

  • Bulk OUT — host sends CBW (command) and write data to device
  • Bulk IN — device sends read data and CSW (status) back to host

Command Block Wrapper (CBW)

Every MSC transaction begins with the host sending a 31-byte Command Block Wrapper over the Bulk OUT endpoint. The CBW wraps a SCSI command and provides the framing that lets the USB layer understand the transaction:

Field Size (bytes) Value / Description
dCBWSignature40x43425355 ("USBC") — mandatory magic number
dCBWTag4Host-assigned tag; device echoes it in CSW so host matches responses
dCBWDataTransferLength4Number of bytes host expects to transfer in data phase (0 = no data)
bmCBWFlags1Bit 7: direction — 0=OUT (host-to-device), 1=IN (device-to-host)
bCBWLUN1Logical Unit Number (lower nibble); selects which storage volume to address
bCBWCBLength1Length of the CBWCB field (1–16 bytes); SCSI-10 commands use 10 bytes
CBWCB16The actual SCSI command block — padded to 16 bytes with zeros

Command Status Wrapper (CSW)

After the data phase (or immediately after the CBW if dCBWDataTransferLength is zero), the device sends a 13-byte Command Status Wrapper over the Bulk IN endpoint:

FieldSizeDescription
dCSWSignature40x53425355 ("USBS") — mandatory magic
dCSWTag4Echoed from dCBWTag — host uses this to match CBW to CSW
dCSWDataResidue4Difference between requested and actual bytes transferred
bCSWStatus10=Command Passed, 1=Command Failed, 2=Phase Error

BBB Transaction Sequence (ASCII Diagram)

HOST                                    DEVICE
 |                                          |
 |------ CBW (31 bytes, Bulk OUT) -------->|  Command Block Wrapper
 |       dCBWSignature = 0x43425355        |  contains SCSI command
 |       dCBWTag = 0xABCD1234             |
 |       dCBWDataTransferLength = 512      |
 |       bmCBWFlags = 0x80 (IN)           |
 |       bCBWLUN = 0                       |
 |       CBWCB = READ(10) command          |
 |                                          |
 |                              [device executes SCSI READ(10)]
 |                              [reads 512 bytes from storage]
 |                                          |
 |<----- Data (512 bytes, Bulk IN) --------|  data phase
 |                                          |
 |<----- CSW (13 bytes, Bulk IN) ----------|  Command Status Wrapper
 |       dCSWSignature = 0x53425355        |
 |       dCSWTag = 0xABCD1234             |  echoed tag
 |       dCSWDataResidue = 0               |
 |       bCSWStatus = 0 (PASSED)           |
 |                                          |

For a WRITE operation the data phase direction reverses:
 |------ CBW (bmCBWFlags = 0x00, OUT) --->|
 |------ Data (512 bytes, Bulk OUT) ------>|
 |<----- CSW (13 bytes, Bulk IN) ----------|

Why MSC Uses Bulk, Not Control

You might wonder why MSC does not use control transfers for commands, as most other USB class operations do. The answer is throughput. Control transfers have strict timing constraints — the device must respond within 50 ms, and the host must start the next stage within 5 ms of the previous stage completing. For sequential reads and writes of megabytes of data, these constraints would create severe bottlenecks. Bulk transfers have no timing guarantee but can utilise up to 100% of the remaining USB bandwidth in a frame — exactly what high-throughput storage needs.

Key Insight: TinyUSB handles all the BBB framing for you. You never manually construct CBWs or CSWs. TinyUSB parses the incoming CBW, calls your callback with the decoded SCSI command parameters, and assembles the CSW from your callback's return value. Your job is purely to service SCSI commands — fetch data from storage, write data to storage, respond to INQUIRY with your device identity.

SCSI Command Set for MSC

USB MSC uses the SCSI Transparent Command Set — a subset of the full SCSI protocol standardised by the T10 committee. The word "transparent" means the USB layer merely transports SCSI commands without interpreting them; the device firmware must parse and respond to SCSI commands directly.

For a typical USB flash drive or data logger, you need to implement the following mandatory and commonly required SCSI commands:

SCSI Command Opcode Purpose When Called
INQUIRY 0x12 Returns vendor ID, product ID, revision, device type First command after enumeration — OS identification
TEST UNIT READY 0x00 Reports whether storage medium is present and ready Repeatedly polled by OS; return error if SD not inserted
READ CAPACITY (10) 0x25 Returns total block count and block size (always 512 bytes) OS uses this to size the volume before mounting
READ (10) 0x28 Read N blocks starting at logical block address (LBA) Every file read from the host
WRITE (10) 0x2A Write N blocks starting at LBA from host data Every file write from the host
REQUEST SENSE 0x03 Returns error information after a failed command OS sends after any non-PASSED CSW status
MODE SENSE (6) 0x1A Reports device parameters including write-protect status OS queries this during mount to check write protection
PREVENT ALLOW MEDIUM REMOVAL 0x1E Requests device lock/unlock the medium (usually ignored) Sent when OS mounts and unmounts the volume
START STOP UNIT 0x1B Eject command — bit 0 (START) = 0 means eject Sent by OS when user clicks "Safely Remove Hardware"

READ(10) Command Block Layout

The SCSI READ(10) command occupies 10 bytes of the CBWCB field. Understanding the byte layout is useful when debugging with a USB analyser:

/* SCSI READ(10) — 10-byte command descriptor block */
/* Byte 0: Operation Code = 0x28 */
/* Byte 1: DPO/FUA/RARC flags (usually 0x00) */
/* Bytes 2-5: Logical Block Address (big-endian 32-bit) */
/* Byte 6: Group Number (usually 0x00) */
/* Bytes 7-8: Transfer Length in blocks (big-endian 16-bit) */
/* Byte 9: Control (usually 0x00) */

/* Example: READ 8 blocks starting at LBA 0x00001000 */
uint8_t read10_cdb[10] = {
    0x28,                         /* READ(10) opcode */
    0x00,                         /* flags */
    0x00, 0x00, 0x10, 0x00,       /* LBA = 0x00001000 (big-endian) */
    0x00,                         /* group number */
    0x00, 0x08,                   /* transfer length = 8 blocks */
    0x00                          /* control */
};

INQUIRY Response Data Structure

The SCSI INQUIRY response is 36 bytes of device identity information. The host OS uses this to identify the vendor and product for display in the file manager and for driver selection:

/* SCSI Standard Inquiry Response — 36 bytes */
typedef struct {
    uint8_t  peripheral_device_type;  /* 0x00 = Direct Access (disk) */
    uint8_t  removable_medium;        /* 0x80 = removable, 0x00 = fixed */
    uint8_t  version;                 /* 0x04 = SPC-2 compliance */
    uint8_t  response_data_format;    /* 0x02 = standard */
    uint8_t  additional_length;       /* 31 (total length - 5) */
    uint8_t  reserved[3];
    char     vendor_id[8];            /* left-aligned, space-padded: "TinyUSB " */
    char     product_id[16];          /* left-aligned, space-padded: "Mass Storage    " */
    char     product_rev[4];          /* "1.0 " */
} scsi_inquiry_resp_t;

TinyUSB MSC Configuration

Configuring TinyUSB for MSC involves three files: tusb_config.h for feature flags, the descriptor source file for USB descriptors, and the callback implementation file for SCSI handling.

tusb_config.h Settings

/* tusb_config.h — MSC-specific configuration */

/* Enable MSC device class — must be 1 to use MSC */
#define CFG_TUD_MSC           1

/* EP buffer size for MSC bulk endpoints.
 * Must be a multiple of the endpoint wMaxPacketSize.
 * Full Speed: 64 bytes max per packet, set buffer to 512 bytes
 * High Speed: 512 bytes max per packet, set buffer to 4096 bytes
 * Larger buffers reduce callback frequency and improve throughput. */
#define CFG_TUD_MSC_EP_BUFSIZE  512

/* Number of Logical Units (LUNs).
 * LUN 0 = first storage volume (e.g., SD card partition 1)
 * LUN 1 = second storage volume (e.g., internal flash)
 * Most designs use a single LUN (1). */
/* LUN count is set in the descriptor via bNumLUNs, not here */

/* Common settings also required */
#define CFG_TUSB_MCU          OPT_MCU_STM32F4  /* adjust for your MCU */
#define CFG_TUSB_OS           OPT_OS_NONE       /* bare-metal */
#define CFG_TUSB_DEBUG        0

/* Endpoint numbers — ensure no conflicts with other classes */
#define EPNUM_MSC_OUT   0x02   /* Bulk OUT endpoint */
#define EPNUM_MSC_IN    0x82   /* Bulk IN endpoint (bit 7 set = IN) */

MSC USB Descriptor

TinyUSB provides the TUD_MSC_DESCRIPTOR macro to generate the correct interface and endpoint descriptors for MSC. The macro takes the interface number, string descriptor index, OUT endpoint number, IN endpoint number, and maximum packet size:

/* usb_descriptors.c — Configuration descriptor with MSC */

#define CONFIG_TOTAL_LEN  (TUD_CONFIG_DESC_LEN + TUD_MSC_DESC_LEN)

/* Configuration descriptor array */
uint8_t const desc_configuration[] = {

    /* Configuration Descriptor */
    TUD_CONFIG_DESCRIPTOR(
        1,               /* bConfigurationValue */
        1,               /* bNumInterfaces — MSC uses exactly 1 interface */
        0,               /* iConfiguration string index (0 = none) */
        CONFIG_TOTAL_LEN,
        0x00,            /* bmAttributes — bus-powered, no remote wakeup */
        500              /* bMaxPower — 500 mA (250 in 2 mA units) */
    ),

    /* MSC Interface + Endpoint Descriptors */
    /* TUD_MSC_DESCRIPTOR(itfnum, stridx, epout, epin, epsize) */
    TUD_MSC_DESCRIPTOR(
        0,               /* itfnum — interface number 0 */
        4,               /* stridx — index into string descriptor array */
        EPNUM_MSC_OUT,   /* bulk OUT endpoint = 0x02 */
        EPNUM_MSC_IN,    /* bulk IN  endpoint = 0x82 */
        64               /* epsize — 64 for Full Speed, 512 for High Speed */
    )
};

/* Device Descriptor */
tusb_desc_device_t const desc_device = {
    .bLength            = sizeof(tusb_desc_device_t),
    .bDescriptorType    = TUSB_DESC_DEVICE,
    .bcdUSB             = 0x0200,
    .bDeviceClass       = 0x00,  /* class defined at interface level */
    .bDeviceSubClass    = 0x00,
    .bDeviceProtocol    = 0x00,
    .bMaxPacketSize0    = CFG_TUD_ENDPOINT0_SIZE,
    .idVendor           = 0xCafe,
    .idProduct          = 0x4008,
    .bcdDevice          = 0x0100,
    .iManufacturer      = 0x01,
    .iProduct           = 0x02,
    .iSerialNumber      = 0x03,
    .bNumConfigurations = 0x01
};

/* String Descriptors */
char const *string_desc_arr[] = {
    (const char[]) { 0x09, 0x04 },  /* 0: English (0x0409) */
    "Wasil Zafar",                   /* 1: Manufacturer */
    "USB MSC Device",                /* 2: Product */
    "123456",                        /* 3: Serial Number */
    "TinyUSB MSC"                    /* 4: MSC Interface */
};

Descriptor Callback Functions

/* Required TinyUSB descriptor callbacks */

uint8_t const * tud_descriptor_device_cb(void) {
    return (uint8_t const *) &desc_device;
}

uint8_t const * tud_descriptor_configuration_cb(uint8_t index) {
    (void) index;
    return desc_configuration;
}

uint16_t const * tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
    (void) langid;
    static uint16_t _desc_str[32];
    uint8_t chr_count;

    if (index == 0) {
        memcpy(&_desc_str[1], string_desc_arr[0], 2);
        chr_count = 1;
    } else {
        if (index >= sizeof(string_desc_arr) / sizeof(string_desc_arr[0]))
            return NULL;
        const char *str = string_desc_arr[index];
        chr_count = (uint8_t) strlen(str);
        if (chr_count > 31) chr_count = 31;
        for (uint8_t i = 0; i < chr_count; i++)
            _desc_str[1 + i] = str[i];
    }

    _desc_str[0] = (TUSB_DESC_STRING << 8) | (2 * chr_count + 2);
    return _desc_str;
}

MSC Callbacks

TinyUSB MSC requires you to implement several callback functions. TinyUSB parses each incoming SCSI command from the CBW and dispatches to the appropriate callback. You never touch raw CBW/CSW bytes — TinyUSB handles all that framing.

Complete RAM Disk Implementation

The simplest possible MSC backend is a RAM disk: a static array in MCU memory that acts as the storage medium. This is ideal for testing before integrating a real storage backend:

/* msc_disk.c — Complete RAM disk MSC implementation */

#include "tusb.h"
#include <string.h>

/* ── RAM disk configuration ── */
#define DISK_BLOCK_NUM   16      /* 16 blocks x 512 bytes = 8 KB */
#define DISK_BLOCK_SIZE  512     /* Standard sector size */

/* The actual RAM disk storage */
static uint8_t msc_disk[DISK_BLOCK_NUM][DISK_BLOCK_SIZE];

/* Pre-formatted FAT12 boot sector for a tiny 8 KB volume.
 * In production, this would be the SD card's actual sectors. */
static bool disk_initialized = false;

static void init_ram_disk(void) {
    if (disk_initialized) return;
    memset(msc_disk, 0, sizeof(msc_disk));

    /* Minimal FAT12 boot sector (sector 0) */
    uint8_t boot_sector[] = {
        0xEB, 0x3C, 0x90,              /* jmp short + NOP */
        'M','S','W','I','N','4','.','1',/* OEM name */
        0x00, 0x02,                     /* bytes per sector = 512 */
        0x01,                           /* sectors per cluster = 1 */
        0x01, 0x00,                     /* reserved sectors = 1 */
        0x01,                           /* number of FATs = 1 */
        0x10, 0x00,                     /* root entry count = 16 */
        0x10, 0x00,                     /* total sectors 16 = 16 */
        0xF8,                           /* media type = fixed disk */
        0x01, 0x00,                     /* sectors per FAT = 1 */
    };
    memcpy(msc_disk[0], boot_sector, sizeof(boot_sector));
    msc_disk[0][510] = 0x55;
    msc_disk[0][511] = 0xAA;

    disk_initialized = true;
}

/* ───────────────────────────────────────────────────────────────
   INQUIRY — return vendor/product/revision strings
   Called once after enumeration; OS uses this for device display.
   ─────────────────────────────────────────────────────────────── */
void tud_msc_inquiry_cb(uint8_t lun, uint8_t vendor_id[8],
                        uint8_t product_id[16], uint8_t product_rev[4])
{
    (void) lun;
    /* Left-aligned, space-padded as per SCSI standard */
    const char vid[]  = "TinyUSB ";
    const char pid[]  = "Mass Storage    ";
    const char rev[]  = "1.0 ";
    memcpy(vendor_id,  vid,  8);
    memcpy(product_id, pid, 16);
    memcpy(product_rev, rev, 4);
}

/* ───────────────────────────────────────────────────────────────
   TEST UNIT READY — report whether medium is present and ready
   Return true = ready. Return false = not ready (OS will retry).
   For SD card: check if SD is inserted and initialized.
   ─────────────────────────────────────────────────────────────── */
bool tud_msc_test_unit_ready_cb(uint8_t lun)
{
    (void) lun;
    init_ram_disk();
    return true;  /* RAM disk is always ready */
}

/* ───────────────────────────────────────────────────────────────
   CAPACITY — return block count and block size
   OS uses this to know the volume size before mounting.
   ─────────────────────────────────────────────────────────────── */
void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count,
                         uint16_t *block_size)
{
    (void) lun;
    *block_count = DISK_BLOCK_NUM;
    *block_size  = DISK_BLOCK_SIZE;
}

/* ───────────────────────────────────────────────────────────────
   READ10 — read blocks from storage
   Called by TinyUSB when host sends SCSI READ(10).
   lba  = logical block address (first block to read)
   offset = byte offset within the first block (for partial reads)
   buffer = destination buffer allocated by TinyUSB
   bufsize = number of bytes TinyUSB is requesting this call
             (may be less than total transfer — TinyUSB calls multiple times)
   Return: number of bytes copied into buffer, or -1 on error.
   ─────────────────────────────────────────────────────────────── */
int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                          void *buffer, uint32_t bufsize)
{
    (void) lun;

    /* Bounds check */
    if ((lba + (bufsize / DISK_BLOCK_SIZE)) > DISK_BLOCK_NUM) return -1;

    /* Copy from RAM disk into TinyUSB's buffer */
    uint8_t *src = (uint8_t *) msc_disk[lba] + offset;
    memcpy(buffer, src, bufsize);

    return (int32_t) bufsize;
}

/* ───────────────────────────────────────────────────────────────
   WRITE10 — write blocks to storage
   Called by TinyUSB when host sends SCSI WRITE(10).
   Return: number of bytes written, or -1 on error.
   ─────────────────────────────────────────────────────────────── */
int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                           uint8_t *buffer, uint32_t bufsize)
{
    (void) lun;

    if ((lba + (bufsize / DISK_BLOCK_SIZE)) > DISK_BLOCK_NUM) return -1;

    uint8_t *dst = (uint8_t *) msc_disk[lba] + offset;
    memcpy(dst, buffer, bufsize);

    return (int32_t) bufsize;
}

/* ───────────────────────────────────────────────────────────────
   SCSI_CB — handle all other SCSI commands not covered above
   TinyUSB calls this for any SCSI command it doesn't have a
   dedicated callback for. Most commands can return PASSED with
   zero data; a few require specific responses.
   ─────────────────────────────────────────────────────────────── */
int32_t tud_msc_scsi_cb(uint8_t lun, uint8_t const scsi_cmd[16],
                        void *buffer, uint16_t bufsize)
{
    void const *response = NULL;
    int32_t     resplen  = 0;

    bool in_xfer = true;

    switch (scsi_cmd[0]) {

        case SCSI_CMD_PREVENT_ALLOW_MEDIUM_REMOVAL:
            /* Ignore — we don't lock the medium */
            resplen = 0;
            break;

        case SCSI_CMD_START_STOP_UNIT:
            /* Byte 4 bit 0: START=1 start, START=0 stop/eject */
            resplen = 0;
            break;

        case SCSI_CMD_MODE_SENSE_6: {
            /* Return 4-byte mode parameter header.
             * Byte 2 bit 7 (WP) = 0 means writable. */
            static uint8_t mode_sense_resp[4] = {
                0x03,  /* mode data length */
                0x00,  /* medium type = 0 */
                0x00,  /* device-specific: WP bit is bit 7 — 0 = writable */
                0x00   /* block descriptor length = 0 */
            };
            response = mode_sense_resp;
            resplen  = sizeof(mode_sense_resp);
            break;
        }

        default:
            /* Unsupported command — signal CHECK CONDITION */
            tud_msc_set_sense(lun, SCSI_SENSE_ILLEGAL_REQUEST,
                              0x20, 0x00);
            resplen = -1;
            break;
    }

    if (resplen > bufsize) resplen = bufsize;
    if (response && resplen > 0) memcpy(buffer, response, (size_t) resplen);

    return resplen;
}
Critical: The tud_msc_read10_cb and tud_msc_write10_cb callbacks may be called multiple times per SCSI READ(10)/WRITE(10) command, with bufsize equal to CFG_TUD_MSC_EP_BUFSIZE each time, until the full dCBWDataTransferLength has been transferred. Your callbacks must correctly advance the storage pointer using the lba and offset parameters provided.

SD Card Backend via FATFS

Replacing the RAM disk with a real SD card requires bridging the TinyUSB MSC callbacks to the SDMMC HAL (or SPI SD driver). The fundamental unit of both SD cards and USB MSC is the 512-byte sector, so the mapping is direct: LBA in the SCSI READ(10)/WRITE(10) maps 1:1 to SD card block address.

SD Card Initialization

/* sd_msc_bridge.c — Bridge between TinyUSB MSC and SDMMC HAL */

#include "tusb.h"
#include "stm32f4xx_hal.h"
#include "stm32f4xx_hal_sd.h"
#include <string.h>

extern SD_HandleTypeDef hsd;  /* SD handle from CubeMX */

#define SD_BLOCK_SIZE       512
#define SD_TIMEOUT_MS       1000

static bool     sd_present     = false;
static uint32_t sd_block_count = 0;

/* Call this during system init to detect and initialize the SD card */
void sd_msc_init(void) {
    HAL_SD_CardInfoTypeDef card_info;

    if (HAL_SD_Init(&hsd) != HAL_OK) {
        sd_present = false;
        return;
    }

    if (HAL_SD_GetCardInfo(&hsd, &card_info) != HAL_OK) {
        sd_present = false;
        return;
    }

    sd_block_count = card_info.BlockNbr;
    sd_present     = true;
}

/* ── MSC Callbacks delegating to SDMMC HAL ── */

void tud_msc_inquiry_cb(uint8_t lun, uint8_t vendor_id[8],
                        uint8_t product_id[16], uint8_t product_rev[4])
{
    (void) lun;
    memcpy(vendor_id,  "STM32   ", 8);
    memcpy(product_id, "SD Card Drive   ", 16);
    memcpy(product_rev, "1.0 ", 4);
}

bool tud_msc_test_unit_ready_cb(uint8_t lun) {
    (void) lun;
    /* Re-check SD card presence on each call.
     * For GPIO card-detect pin, read the pin here. */
    return sd_present;
}

void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count,
                         uint16_t *block_size)
{
    (void) lun;
    *block_count = sd_block_count;
    *block_size  = SD_BLOCK_SIZE;
}

int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                          void *buffer, uint32_t bufsize)
{
    (void) lun;
    if (!sd_present) return -1;

    /* TinyUSB guarantees offset is 0 and bufsize is a multiple of
     * SD_BLOCK_SIZE when the endpoint buffer is SD_BLOCK_SIZE-aligned.
     * The number of blocks to read: */
    uint32_t num_blocks = bufsize / SD_BLOCK_SIZE;

    if (HAL_SD_ReadBlocks(&hsd, (uint8_t *)buffer,
                          lba, num_blocks, SD_TIMEOUT_MS) != HAL_OK) {
        return -1;
    }

    /* Wait for DMA transfer to complete if using DMA mode */
    uint32_t tickstart = HAL_GetTick();
    while (HAL_SD_GetCardState(&hsd) != HAL_SD_CARD_TRANSFER) {
        if ((HAL_GetTick() - tickstart) >= SD_TIMEOUT_MS) return -1;
    }

    return (int32_t) bufsize;
}

int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                           uint8_t *buffer, uint32_t bufsize)
{
    (void) lun;
    if (!sd_present) return -1;

    uint32_t num_blocks = bufsize / SD_BLOCK_SIZE;

    if (HAL_SD_WriteBlocks(&hsd, buffer, lba,
                           num_blocks, SD_TIMEOUT_MS) != HAL_OK) {
        return -1;
    }

    uint32_t tickstart = HAL_GetTick();
    while (HAL_SD_GetCardState(&hsd) != HAL_SD_CARD_TRANSFER) {
        if ((HAL_GetTick() - tickstart) >= SD_TIMEOUT_MS) return -1;
    }

    return (int32_t) bufsize;
}

int32_t tud_msc_scsi_cb(uint8_t lun, uint8_t const scsi_cmd[16],
                        void *buffer, uint16_t bufsize)
{
    /* Same implementation as RAM disk version above */
    (void) lun; (void) buffer; (void) bufsize;

    switch (scsi_cmd[0]) {
        case SCSI_CMD_PREVENT_ALLOW_MEDIUM_REMOVAL:
        case SCSI_CMD_START_STOP_UNIT:
            return 0;  /* acknowledge, take no action */

        case SCSI_CMD_MODE_SENSE_6: {
            static uint8_t resp[4] = { 0x03, 0x00, 0x00, 0x00 };
            uint16_t len = (bufsize < 4) ? bufsize : 4;
            memcpy(buffer, resp, len);
            return len;
        }

        default:
            tud_msc_set_sense(lun, SCSI_SENSE_ILLEGAL_REQUEST, 0x20, 0x00);
            return -1;
    }
}
DMA Alignment: When using SDMMC with DMA on STM32, the destination buffer for HAL_SD_ReadBlocks must be 4-byte aligned and located in a DMA-accessible memory region. TinyUSB's internal buffer (passed to tud_msc_read10_cb) is typically allocated in SRAM1 and is already aligned. If you see corrupted data, check that the buffer address is word-aligned and not in DTCM RAM (which is not accessible by DMA2 on STM32F7).

Write Protection & Ejection

Write Protection Callback

TinyUSB provides an optional callback tud_msc_is_writable_cb() that you can implement to make the storage appear read-only to the host. If this function returns false, TinyUSB responds to WRITE(10) with a CHECK CONDITION / WRITE PROTECTED sense code, and the OS will not attempt writes:

/* Optional write protection callback.
 * Return false to make the volume read-only.
 * Useful for internal flash volumes or when logging must not be
 * interrupted by host writes. */
bool tud_msc_is_writable_cb(uint8_t lun) {
    (void) lun;

#ifdef USB_MSC_READ_ONLY
    return false;   /* compile-time read-only */
#else
    /* Runtime check: GPIO pin (e.g., SD card's physical write-protect tab) */
    return (HAL_GPIO_ReadPin(SD_WP_GPIO_Port, SD_WP_Pin) == GPIO_PIN_RESET);
#endif
}

Ejection and START STOP UNIT

When the user clicks "Safely Remove Hardware" on Windows or "Eject" on macOS, the OS sends a SCSI START STOP UNIT command with the START bit clear and the LOEJ (Load/Eject) bit set. Your tud_msc_scsi_cb receives this and can respond by signalling the application to unmount FATFS and stop SD card access:

/* Ejection state flag — shared with application code */
volatile bool usb_eject_requested = false;

/* In tud_msc_scsi_cb: */
case SCSI_CMD_START_STOP_UNIT: {
    /* Byte 4: bit 0 = START, bit 1 = LOEJ */
    uint8_t loej  = (scsi_cmd[4] >> 1) & 0x01;
    uint8_t start = (scsi_cmd[4]     ) & 0x01;

    if (loej && !start) {
        /* Eject requested */
        usb_eject_requested = true;
    }
    return 0;  /* acknowledge */
}

Media Changed Notification

When an SD card is hot-plugged (removed and reinserted), the host must be told that the medium has changed, or it will use stale cached metadata. TinyUSB provides tud_msc_set_sense() to send a UNIT ATTENTION sense code on the next TEST UNIT READY command:

/* Called from SD card detect interrupt or GPIO polling */
void on_sd_card_inserted(uint8_t lun) {
    sd_msc_init();  /* re-initialize SD card */

    /* Signal UNIT ATTENTION: Not Ready to Ready transition,
     * Medium may have changed (ASC=0x28, ASCQ=0x00) */
    tud_msc_set_sense(lun,
                      SCSI_SENSE_UNIT_ATTENTION,
                      0x28,   /* ASC: NOT READY TO READY CHANGE */
                      0x00);  /* ASCQ */
}

void on_sd_card_removed(uint8_t lun) {
    sd_present = false;

    /* Signal NOT READY: Medium Not Present (ASC=0x3A) */
    tud_msc_set_sense(lun,
                      SCSI_SENSE_NOT_READY,
                      0x3A,   /* ASC: MEDIUM NOT PRESENT */
                      0x00);  /* ASCQ */
}
OS Behaviour: Windows caches filesystem metadata aggressively. If you swap an SD card without sending UNIT ATTENTION, Windows may read from a stale directory listing and show the old card's files. Always issue UNIT ATTENTION on media change. macOS is more conservative and re-reads the volume on every mount, but Windows requires the explicit signal.

Performance Optimization

Endpoint Buffer Size Impact

The single most impactful performance parameter for USB MSC is CFG_TUD_MSC_EP_BUFSIZE. This determines how many bytes TinyUSB requests per callback invocation:

Buffer SizeCallbacks per 4 KB ReadSD Read CallsApproximate FS Throughput
64 bytes6464~100 KB/s
512 bytes88~400 KB/s
4096 bytes18 (per call)~900 KB/s

For Full Speed USB, 512 bytes is a good balance. For High Speed USB with an HS-capable MCU (STM32F4 OTG_HS + external PHY), set CFG_TUD_MSC_EP_BUFSIZE to 4096 bytes to saturate the 480 Mbit/s bus.

Double-Buffering SDMMC DMA

The primary bottleneck for USB MSC on STM32 is typically the SD card read/write latency. Typical SD cards using SDMMC 4-bit wide bus at 25 MHz achieve about 10–12 MB/s raw throughput, far exceeding what USB Full Speed can deliver. However, the round-trip latency for a single HAL_SD_ReadBlocks call (including the DMA setup, transfer, and polling for completion) can be 200–500 µs even for a single sector — long enough to stall the USB pipeline.

To hide this latency, implement a double-buffer scheme where you start the next SD read while the previous one is being transmitted over USB:

/* Double-buffer scheme for SD read with DMA */

#define PING  0
#define PONG  1

/* Two 512-byte buffers in DMA-accessible SRAM */
__attribute__((aligned(4))) static uint8_t dma_buf[2][512];
static volatile int  ready_buf = -1;  /* which buffer has valid data */
static volatile bool dma_busy  = false;

/* DMA complete callback (called from ISR) */
void HAL_SD_RxCpltCallback(SD_HandleTypeDef *hsd) {
    dma_busy  = false;
    ready_buf ^= 1;  /* swap ping/pong */
}

int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                          void *buffer, uint32_t bufsize)
{
    (void) lun; (void) offset;

    /* Start next sector DMA read immediately */
    int next_buf = ready_buf ^ 1;
    if (!dma_busy) {
        dma_busy = true;
        HAL_SD_ReadBlocks_DMA(&hsd, dma_buf[next_buf], lba + 1, 1);
    }

    /* Copy already-ready data to TinyUSB buffer */
    memcpy(buffer, dma_buf[ready_buf], bufsize);
    return (int32_t) bufsize;
}

Achievable Throughput

ConfigurationRead SpeedWrite SpeedNotes
FS, polling, 512-byte buf~400 KB/s~300 KB/sTypical for most designs
FS, DMA, 512-byte buf~700 KB/s~500 KB/sDMA avoids CPU stalls
FS, double-buffer DMA~900 KB/s~700 KB/sNear FS theoretical max
HS + ULPI, 4096-byte buf~20 MB/s~15 MB/sRequires HS PHY + fast SD
HS + ULPI, UHS-I SD~25 MB/s~20 MB/sBest achievable with STM32H7
Measuring with a USB Analyser: To measure actual USB MSC throughput, use a hardware USB protocol analyser (Total Phase Beagle, Ellisys, or LeCroy). The analyser shows the inter-packet timing, allowing you to identify gaps between the DATA payload packets — gaps indicate the device is waiting for storage and wasting bus bandwidth. A gap of more than one USB frame (1 ms for FS) indicates the SD read/write is too slow.

FATFS on Device Side

Many embedded data-logger designs want the MCU to write data files to the SD card and expose that SD card over USB MSC simultaneously. This is a critically important scenario — and one that frequently causes filesystem corruption.

Do Not Do This: You cannot safely mount FATFS on the MCU and expose the same storage via USB MSC simultaneously. The host OS caches directory entries, FAT tables, and file data. If the MCU modifies the filesystem while the host has it mounted, the host's cache becomes inconsistent with the actual disk state, leading to filesystem corruption and data loss. This is not a TinyUSB limitation — it is a fundamental property of FAT filesystem design.

Safe Exclusive Access Pattern

The correct design uses a state machine that grants exclusive access to either the MCU or the USB host, never both simultaneously:

/* usb_msc_fatfs_manager.c — Exclusive access state machine */

#include "tusb.h"
#include "ff.h"    /* FATFS */

typedef enum {
    STATE_MCU_OWNS_DISK,    /* MCU writing via FATFS */
    STATE_TRANSITION_TO_USB,/* unmounting FATFS, yielding to USB */
    STATE_USB_OWNS_DISK,    /* USB host has mounted the volume */
    STATE_TRANSITION_TO_MCU,/* USB disconnected, remounting FATFS */
} disk_state_t;

static disk_state_t disk_state = STATE_MCU_OWNS_DISK;
static FATFS        fs;
static bool         fs_mounted = false;

/* Call this from your main loop or a task */
void disk_state_machine(void) {
    switch (disk_state) {

        case STATE_MCU_OWNS_DISK:
            /* Normal MCU operation — FATFS is mounted, MCU can write files */
            if (tud_connected() && tud_mounted()) {
                /* USB host has mounted our MSC volume — yield the disk */
                disk_state = STATE_TRANSITION_TO_USB;
            }
            break;

        case STATE_TRANSITION_TO_USB:
            /* Flush all pending writes and unmount FATFS */
            if (fs_mounted) {
                f_sync(NULL);    /* flush all open files */
                f_unmount("");   /* unmount the volume */
                fs_mounted = false;
            }
            disk_state = STATE_USB_OWNS_DISK;
            break;

        case STATE_USB_OWNS_DISK:
            /* USB host owns the disk — MCU must not touch FATFS */
            /* Still service tud_task() so USB remains responsive */
            if (!tud_connected() || !tud_mounted()) {
                /* USB disconnected or host unmounted */
                disk_state = STATE_TRANSITION_TO_MCU;
            }
            break;

        case STATE_TRANSITION_TO_MCU:
            /* Wait a moment for host to finish writing, then remount */
            /* Add a 500 ms delay here if the SD card was written by host */
            if (f_mount(&fs, "", 1) == FR_OK) {
                fs_mounted = true;
            }
            disk_state = STATE_MCU_OWNS_DISK;
            break;
    }
}

/* Override tud_msc_test_unit_ready_cb to report not-ready
 * while the MCU is writing — prevents host from mounting during transition */
bool tud_msc_test_unit_ready_cb(uint8_t lun) {
    (void) lun;
    /* Only report ready when disk is in USB-owned state */
    return (disk_state == STATE_USB_OWNS_DISK);
}

This state machine enforces that FATFS is only mounted when the USB host has not claimed the volume, and the USB host can only access the disk when FATFS is unmounted. The key transitions are:

  1. USB connects → MCU yields: f_unmount() is called before the host's first SCSI READ. The tud_msc_test_unit_ready_cb returns false during the transition to prevent the host from reading stale data.
  2. USB disconnects → MCU reclaims: A short delay allows the host to complete any pending write operations that may still be in flight, then f_mount() re-mounts the volume for MCU use.

Internal Flash as MSC Storage

For applications requiring a simple configuration drive or firmware-update mechanism without an SD card, MCU internal flash can serve as USB MSC storage. The host sees a small read-only (or writable) USB flash drive backed by the MCU's program memory.

Key Constraints for Flash-Backed MSC

  • Flash sector size alignment: STM32F4 flash sectors range from 16 KB to 128 KB. You must erase an entire sector before writing, even for a 512-byte USB write. This means a single sector erase may destroy several surrounding USB blocks.
  • Erase before write: Flash cannot be written without erasing first. A write strategy that buffers a full flash sector in RAM, erases the sector, then reprograms it is mandatory.
  • Wear levelling: Internal flash has a typical endurance of 10,000–100,000 erase cycles. For a configuration file that changes rarely, this is acceptable. For frequent logging, use QSPI flash with a wear-levelling library.
  • Read-only is simplest: Making the flash-backed MSC read-only (via tud_msc_is_writable_cb returning false) avoids all erase-before-write complexity. The host can read configuration files but not modify them — perfect for DFU-style firmware update flows where the device presents release notes and a firmware binary.

Read-Only Flash Drive Example

/* flash_msc.c — Read-only internal flash as USB MSC */

#include "tusb.h"
#include <string.h>

/* Place this section in a dedicated flash region via linker script.
 * The region must start at a FLASH_SECTOR_SIZE boundary. */
#define FLASH_MSC_BASE    0x08060000  /* Example: Sector 7 of STM32F407 */
#define FLASH_MSC_SIZE    (128 * 1024) /* 128 KB = Sector 7 */
#define DISK_BLOCK_SIZE   512
#define DISK_BLOCK_NUM    (FLASH_MSC_SIZE / DISK_BLOCK_SIZE) /* 256 blocks */

void tud_msc_inquiry_cb(uint8_t lun, uint8_t vendor_id[8],
                        uint8_t product_id[16], uint8_t product_rev[4])
{
    (void) lun;
    memcpy(vendor_id,  "STM32   ", 8);
    memcpy(product_id, "Config Drive    ", 16);
    memcpy(product_rev, "1.0 ", 4);
}

bool tud_msc_test_unit_ready_cb(uint8_t lun) {
    (void) lun;
    return true;  /* always ready — flash never disappears */
}

void tud_msc_capacity_cb(uint8_t lun, uint32_t *block_count,
                         uint16_t *block_size)
{
    (void) lun;
    *block_count = DISK_BLOCK_NUM;
    *block_size  = DISK_BLOCK_SIZE;
}

/* Read-only: return false to prevent any writes */
bool tud_msc_is_writable_cb(uint8_t lun) {
    (void) lun;
    return false;
}

int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                          void *buffer, uint32_t bufsize)
{
    (void) lun;
    if (lba >= DISK_BLOCK_NUM) return -1;

    /* Direct memory copy from flash — no DMA needed for reads */
    const uint8_t *src = (const uint8_t *)(FLASH_MSC_BASE
                         + lba * DISK_BLOCK_SIZE + offset);
    memcpy(buffer, src, bufsize);
    return (int32_t) bufsize;
}

int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset,
                           uint8_t *buffer, uint32_t bufsize)
{
    /* Should never be called since is_writable_cb returns false,
     * but handle defensively: */
    (void) lun; (void) lba; (void) offset; (void) buffer; (void) bufsize;
    tud_msc_set_sense(lun, SCSI_SENSE_DATA_PROTECT, 0x27, 0x00);
    return -1;
}

int32_t tud_msc_scsi_cb(uint8_t lun, uint8_t const scsi_cmd[16],
                        void *buffer, uint16_t bufsize)
{
    switch (scsi_cmd[0]) {
        case SCSI_CMD_PREVENT_ALLOW_MEDIUM_REMOVAL:
        case SCSI_CMD_START_STOP_UNIT:
            return 0;
        case SCSI_CMD_MODE_SENSE_6: {
            /* Set WP bit (bit 7 of byte 2) to signal write-protected */
            static uint8_t resp[4] = { 0x03, 0x00, 0x80, 0x00 };
            uint16_t len = (bufsize < 4) ? bufsize : 4;
            memcpy(buffer, resp, len);
            return len;
        }
        default:
            tud_msc_set_sense(lun, SCSI_SENSE_ILLEGAL_REQUEST, 0x20, 0x00);
            return -1;
    }
}
Pre-Formatted Flash: For the host to mount the flash-backed MSC volume and see files, the flash region must contain a pre-formatted FAT12 or FAT16 filesystem. You can generate this binary offline using a FAT formatter tool and burn it into flash as part of your initial programming step. For a read-only drive, this filesystem image never changes — the host simply reads files that were placed there at programming time.

Practical Exercises

Exercise 1 Beginner

RAM Disk MSC — Verify with OS File Manager

Implement the complete RAM disk MSC example from this article. Pre-format the RAM disk array with a valid FAT12 filesystem (use a 64 KB size with 128 blocks). Connect your device and verify: (a) Windows/macOS/Linux mounts the volume automatically without installing any drivers, (b) the drive appears in the file manager with the correct size, (c) you can create a small text file on the drive (it goes into RAM — lost on power cycle), (d) after unplugging and replugging, the file is gone (RAM was reset). This confirms the complete BBB + SCSI pipeline is working correctly.

TinyUSB MSC RAM Disk SCSI Callbacks
Exercise 2 Intermediate

SD Card MSC with Hot-Plug Detection

Connect an SD card to your STM32 via SDMMC or SPI. Implement the SD card MSC backend from this article. Wire up a card-detect GPIO (most SD card sockets have a CD pin). Implement an EXTI interrupt on the CD pin that calls on_sd_card_inserted() and on_sd_card_removed(). Test the following scenario: (a) start with SD card inserted — host sees the volume, (b) while USB is connected, remove the SD card — host should show a "device not ready" error, (c) reinsert the SD card — host should remount the volume cleanly without unplugging USB. Verify that the UNIT ATTENTION sense code is correctly triggering the host re-mount.

SD Card Hot-Plug UNIT ATTENTION
Exercise 3 Advanced

Data Logger with FATFS Exclusive Access State Machine

Build a data logger that writes sensor data to an SD card using FATFS while the device is powered, and exposes the SD card over USB MSC when connected to a PC. Implement the full exclusive-access state machine from Section 8: (a) device writes a CSV file at 10 Hz via FATFS when USB is not connected, (b) when USB connects, the MCU closes all open files, unmounts FATFS, and yields the disk to the host, (c) when USB disconnects, the MCU waits 500 ms (to allow host write-back), then remounts FATFS and resumes logging, (d) verify that no filesystem corruption occurs after 50 connect/disconnect cycles. Measure throughput in each state using a USB analyser or by timestamping reads with a logic analyser on the SDMMC clock.

FATFS State Machine Data Logger Exclusive Access

USB MSC Design Document Generator

Use this tool to document your USB Mass Storage design — storage backend, LUN configuration, block size, write protection, and notes. Download as Word, Excel, PDF, or PPTX for project documentation or design review.

USB MSC Design Generator

Document your USB Mass Storage Class design. Download as Word, Excel, PDF, or PPTX.

Draft auto-saved

All data stays in your browser. Nothing is sent to or stored on any server.

Conclusion & Next Steps

USB Mass Storage is the most complex single-class USB implementation you will encounter before composite devices — and now you have the complete picture:

  • BBB Protocol: Every MSC transaction is a three-phase sequence of CBW (command), optional data, and CSW (status) using only bulk endpoints. TinyUSB handles the framing; you service SCSI commands.
  • SCSI command set: Eight commands cover 99% of what any OS needs — INQUIRY, TEST UNIT READY, READ CAPACITY, READ(10), WRITE(10), REQUEST SENSE, MODE SENSE, and START STOP UNIT.
  • TinyUSB MSC callbacks: Five callbacks — inquiry_cb, test_unit_ready_cb, capacity_cb, read10_cb, write10_cb — plus scsi_cb for the rest. The RAM disk implementation fits in under 100 lines of C.
  • SD card backend: The bridge from TinyUSB callbacks to HAL_SD_ReadBlocks/HAL_SD_WriteBlocks is straightforward — LBA maps directly to SD block address, sector size is always 512 bytes.
  • FATFS exclusivity: Never mount FATFS while USB MSC has the disk active. Implement the state machine — USB connects, unmount FATFS; USB disconnects, remount FATFS — to prevent filesystem corruption.
  • Performance: Set CFG_TUD_MSC_EP_BUFSIZE to 512 bytes minimum, use SDMMC DMA, and consider double-buffering for near-maximum Full Speed throughput of ~900 KB/s.

Next in the Series

In Part 9: Composite Devices, we combine multiple USB classes into a single device descriptor — CDC + HID, CDC + MSC, and triple-class devices. You will learn Interface Association Descriptors (IAD), endpoint number allocation for multiple classes, how Windows assigns separate class drivers to each interface, and how to build a device that presents simultaneously as a serial port, a keyboard, and a flash drive through a single USB cable.

Technology