The Four Transfer Types, from a Firmware Point of View

When do you use Interrupt, Bulk, Control, or Isochronous? The role of bInterval and SOF timing, and how they shape USB Device firmware design.

19 min read
Đọc bằng English Tiếng Việt
STM32 / Firmware cover

Post 2/8 in the series USB Device on STM32.

Before you read this

This post is the theoretical foundation for the whole USB series. It explains how the four transfer types work before we get into any implementation. You don’t need prior USB knowledge, though it’ll be easier to follow if you’ve worked with UART or SPI on STM32 and already have a feel for interrupts versus polling.

Key points

  • USB is a system controlled entirely by the host.
  • Each of the four transfer types has its own characteristics and serves a different purpose.
  • bInterval in Interrupt Transfer needs care, or you’ll end up designing an HID report with the wrong latency.
  • SOF plays a role across the whole USB Full Speed system, and that has real consequences for firmware design.

USB is host-centric

If there’s one thing to understand before looking at each transfer type individually, it’s this:

The host is the only party that ever initiates a transaction. A device can never push data to the host on its own.

The term “transaction” gets a full explanation below. For now, just know that a transaction is a single request-response cycle: the host asks, and the device responds, all within one transaction.

This sounds simple, but it’s easy to get wrong, especially with Interrupt Transfer, since the name makes people picture the device “firing” data up to the host the way a GPIO external interrupt fires.

What’s a transaction?

A transaction in USB is a single data-exchange cycle, always initiated by the host, made up of three parts:

  1. Token packet: the host says what it wants to do (IN, OUT, SETUP) and which endpoint it’s talking to.

  2. Data packet:

    • OUT/SETUP: the host sends data to the device
    • IN: the device sends data to the host
  3. Handshake packet: the device responds (ACK/NAK/STALL).

A transaction always completes within a single frame or microframe, and a device can never start one on its own. It only ever responds when the host sends a token.

These terms get more detailed explanations, with concrete examples, later in this post and in the rest of the series.

The bus’s heartbeat

Here’s what the bus timeline looks like at Full Speed:

Full Speed timeline:
|--Frame 0 (1ms)--|--Frame 1 (1ms)--|--Frame 2 (1ms)--|
SOF               SOF               SOF
 └─ transactions  └─ transactions   └─ transactions

Frames and microframes

First, the basic unit of time in USB, the frame:

  • USB Full Speed (12 Mbps): 1 frame = 1 ms
  • USB High Speed (480 Mbps): 1 microframe = 125 µs

A single frame can hold multiple transactions, depending on the transfer type and the host controller’s schedule. At High Speed, each frame splits into 8 microframes.

SOF

At the start of every frame, the host sends a SOF (Start of Frame) packet, a special packet carrying the frame number, broadcast to every device on the bus.

SOF plays two important roles:

  • Clock synchronization: devices use SOF to sync their internal clock with the host. This matters a lot for Isochronous transfers, which depend on precise timing.

  • Keepalive signal: as long as the host keeps sending SOF regularly, the device knows the connection is still alive.

How firmware keeps the connection with the host

Beyond just responding to transactions, firmware needs to pay attention to two important bus states: suspend and reset. Get either one wrong and the device can hang, or the connection to the host can break entirely.

Suspend: bus idle for more than 3ms

If a device doesn’t see an SOF within 3ms (at Full Speed), it concludes the bus has entered Suspend and can switch to a low-power mode.

Normal:  |SOF|...|SOF|...|SOF|...|SOF|...|SOF|......|
Suspend: |SOF|.......................(3ms+).........|
                                     ↑ Device detects suspend here

Once suspended, the device is expected to drop its current draw below 2.5mA (bus-powered) or 500µA (low-power suspend). Firmware should turn off or deinit any peripheral it doesn’t need during suspend.

A couple of things worth knowing:

  • The ST-Link debug tool keeps USB active. While debugging over ST-Link, the tool can keep USB active the whole time, so the device never actually suspends. Real-world behavior on the finished product can look completely different.

  • Single-bank Flash erase blocks the CPU. While a Flash erase is running, the CPU is blocked, so firmware can’t service USB interrupts even though the host is still sending SOF regularly. In practice this can show up as a transfer timeout, the host resetting the device, or Windows reporting a disconnect, depending on exactly when the erase happens.

Reset: the host starts enumeration

The host issues a USB reset by pulling both D+ and D- to 0V for at least 10ms. After a reset, every piece of USB state on the device returns to default:

  • Address = 0
  • Configuration = 0 (unconfigured)
  • Every endpoint back to its default state

After a reset, the host runs through a sequence of reads and writes to configure (or reconfigure) the device. This process is called enumeration. It usually happens right after you plug the device in, but it can also happen any time the host resets the bus, for example after a driver crash or if the device appears hung.

Whether the reset was expected or not, firmware needs to reinitialize its own variables, buffers, and flags. Skip this step and the device can end up holding on to stale state, which shows up as hangs, sending the wrong data, or not responding correctly during re-enumeration. This is one of the most important things to get right if you want reliable USB firmware.

Soft disconnect: firmware-triggered disconnect and reconnect

Sometimes you want to force a software-level USB disconnect so the host re-enumerates the device, for example right after a firmware update.

void USB_SoftReconnect(void){    /* Disconnect: turn off the D+ pull-up */    USBD_Stop(&hUsbDeviceFS);    HAL_Delay(200);  /* Long enough for the host to notice the disconnect */    /* Reconnect */    USBD_Start(&hUsbDeviceFS);    /* The host will re-enumerate on its own */}

On the STM32G0, USBD_Stop() clears the DPPU bit in the USB_BCDR register, which disables the D+ pull-up, and the host sees the device as unplugged.

The four transfer types

USB defines four transfer types, each built for a different job:

  • Control: commands and configuration setup.
  • Interrupt: small data, needs stable and bounded latency.
  • Bulk: large data, no timing guarantee, throughput matters most.
  • Isochronous: real-time data, no retries, drops are acceptable.

Control Transfer

Control transfer is the only transfer type every USB device is required to support. The entire enumeration process (GET_DESCRIPTOR, SET_ADDRESS, SET_CONFIGURATION) runs over control transfers on Endpoint 0.

A control transfer has 3 stages:

1. Setup Stage (8 bytes, always present):
    Host → Device: Setup token + Setup packet
    The Setup packet describes the request: type, command, parameters, data length

2. Data Stage (may or may not be present):
    If wLength > 0: data moves in the direction bmRequestType specifies
      IN : Device → Host (device sends data up to the host)
      OUT: Host → Device (host sends data down to the device)

3. Status Stage (always present, opposite direction from the Data Stage):
    A zero-length packet confirms completion

The Setup packet’s structure

The Setup Stage always sends exactly 8 bytes, in a fixed layout:

Byte 0:   bmRequestType  - direction + type + recipient of the request
Byte 1:   bRequest       - the specific command (GET_DESCRIPTOR, SET_ADDRESS...)
Byte 2-3: wValue         - a secondary parameter (e.g. descriptor type)
Byte 4-5: wIndex         - a second secondary parameter (e.g. interface number)
Byte 6-7: wLength        - number of bytes in the Data Stage (0 = no Data Stage)

bmRequestType (byte 0) is the most important byte here. It determines who sends the data, what kind of request this is, and who it’s addressed to:

bmRequestType (8-bit) : USB control request characteristics
Direction [7] Data transfer direction
0
Host → Device (OUT)
1
Device → Host (IN)
Type [6:5] Request type
00
Standard
01
Class
10
Vendor
11
Reserved
Recipient [4:0] Who the request targets
00000
Device
00001
Interface
00010
Endpoint
00011
Other

Here’s 0x80 decoded (the value used for GET_DESCRIPTOR):

0x80 · 1000 0000

Direction 1 Device → Host (IN)
Type 00 Standard
Recipient 00000 Device

A few other values you’ll run into often:

bmRequestTypeMeaning
0x00Host→Device, Standard, Device, used for SET_ADDRESS
0x21Host→Device, Class, Interface, used for HID SET_REPORT
0xC0Device→Host, Vendor, Device, used for custom vendor requests

Timing: Control transfers get 10% of the bus bandwidth reserved on Full Speed. That said, there’s no latency guarantee, so a transfer can still get delayed if the bus is busy.

On STM32, most control transfer handling is already done by ST’s middleware. You only need to implement it yourself for class-specific or vendor requests.

Interrupt Transfer

This one gets misunderstood a lot, usually along these lines:

“Interrupt transfer works like an MCU interrupt: the device signals the host the moment something happens.”

That’s completely wrong.

On an MCU, an interrupt is device-initiated: a peripheral sets a flag and the CPU jumps into a handler on its own.

In USB, an interrupt transfer is host-initiated: the host polls on a fixed schedule, and the device can only send data when it’s asked. What the device sends back is called a “report.”

Here’s how USB in a NutShell puts it:

“Any one who has had experience of interrupt requests on microcontrollers will know that interrupts are device generated. However under USB, if a device requires the attention of the host, it must wait until the host polls it before it can report that it needs urgent attention.”

How it actually works

Every bInterval ms:

  Host:    IN token → [Endpoint address]

  Does the device have data?   YES → DATA packet (the report's data)

                    Host receives it OK → ACK

  Does the device have data?   NO  → NAK

                    Host notes it, nothing to process
                    Waits for the next poll cycle

ACK and NAK:

  • ACK: the device had data, sent it, and the host received it successfully
  • NAK: the device has no data, this isn’t an error, just “nothing new”
  • STALL: an error condition, the endpoint failed or the request was invalid

The endpoint buffer and how firmware handles it

Firmware places data into the endpoint buffer (a RAM region managed by the USB controller). When the host polls and finds data in the buffer, the USB hardware sends it automatically, no CPU involvement needed.

The key part is clearing the buffer after each poll and marking TX state back to idle so the endpoint is ready for the next valid report. For an HID keyboard, that means:

  • Sending a new report whenever there’s a new event (a key state change).
  • Sending a null report (every byte zero) to clear the previous report and avoid the stale report bug.

The stale report bug happens when firmware resends an old report and the host mistakes it for current state, which shows up as a key getting stuck or repeating on its own.

/* In usbd_hid.c, the callback fired when TX completes */static uint8_t USBD_HID_DataIn(USBD_HandleTypeDef *pdev, uint8_t epnum){    HID_HandleTypeDef *hhid = (HID_HandleTypeDef *)pdev->pClassData;    hhid->state = HID_IDLE;  /* Mark the buffer as consumed */    return USBD_OK;}/* Only send a report when there's new data AND the buffer is idle */uint8_t USB_HID_SendReport(uint8_t *report, uint8_t len){    HID_HandleTypeDef *hhid = hUsbDeviceFS.pClassData;    if (hhid->state == HID_IDLE) {        hhid->state = HID_BUSY;        /* Write to the buffer, the host will fetch it on the next poll cycle */        USBD_LL_Transmit(&hUsbDeviceFS, HID_EPIN_ADDR, report, len);        return USBD_OK;    }    return USBD_BUSY;  /* Buffer isn't idle yet, wait */}

bInterval: the host’s poll cycle

Full Speed and Low Speed interrupt endpoints: bInterval is the number of frames between host polls, from 1 to 255. bInterval=10, for example, means the device is asking the host to poll roughly every 10ms.

In practice though, the host controller and OS can schedule things their own way. When I checked with a USB protocol analyzer, bInterval=10 actually polled every 8ms on Windows. It’s worth sticking to values like 1ms, 2ms, 4ms, 8ms and so on, and then measuring the real behavior rather than trusting the descriptor value alone.

High Speed interrupt endpoints: bInterval is an exponent, and the actual interval is:

interval = 2^(bInterval-1) x 125µs

For example:

bInterval (HS)Actual interval
12^0 = 1 x 125µs = 125µs
42^3 = 8 x 125µs = 1ms
82^7 = 128 x 125µs = 16ms

Why does the host controller prefer powers of two?

Host controllers schedule interrupt endpoint polling using a binary tree scheduler. Each level of the tree corresponds to a time slice that’s a power of two.

  • A device at level 0 (bInterval=1ms) gets polled every frame.
  • A device at level 1 (bInterval=2ms) gets polled every 2 frames.
  • A device at level 2 (bInterval=4ms) gets polled every 4 frames.

This structure lets the host controller schedule polling efficiently without needing a separate timer for every endpoint.

If you set bInterval to something that isn’t a power of two (say, 3ms or 5ms), the host controller rounds down to the nearest power of two:

  • bInterval=3 actually becomes 2ms
  • bInterval=5 actually becomes 4ms
/* In usbd_hid.h, ST middleware's default */#define HID_FS_BINTERVAL  0x0A  /* declared as 10ms, host actually polls at 8ms *//* If you need it faster */#define HID_FS_BINTERVAL  0x04  /* 4ms, fast enough for most HID input *//* Don't set 0x01 unless firmware can keep up within 1ms */

A note on bInterval=1ms: the host polls every single frame. Only use this if firmware can finish its callback, clear the buffer, and prepare the next report before the next frame arrives. For most HID devices, 4ms or 8ms is plenty, and a lot safer.

A note on bandwidth

Fast interrupt polling eats into the bus schedule on a recurring basis. For a small HID report, that cost is usually fine. But if you have several interrupt endpoints all polling at 1ms, the bandwidth left over for Bulk transfers starts to shrink.

Bulk Transfer

Bulk transfer is for moving large amounts of data where exact timing doesn’t matter, like firmware updates, log dumps, or file transfers.

How it works

The host sends an IN token, and the device responds with data or a NAK. Unlike interrupt, bulk has no reserved bandwidth. It only gets whatever bandwidth is left over after interrupt and isochronous have been served. When the bus is busy, bulk can get delayed indefinitely.

Host:   IN token
Device: DATA0 (64 bytes) → Host: ACK
Host:   IN token
Device: DATA1 (64 bytes) → Host: ACK   ← DATA0/DATA1 toggle
Host:   IN token
Device: NAK (buffer not ready yet)
...
Host:   IN token (retries with no limit)
Device: DATA0 (64 bytes) → Host: ACK

DATA0/DATA1 toggle: guarding against duplicate packets

USB uses two packet types, DATA0 and DATA1, to mark the order of packets within a transfer. If a device just sent DATA0, the host expects DATA1 next. If it sees DATA0 again, it knows that’s a stale packet and discards it.

Framing: the part the spec doesn’t cover

At the USB layer, Bulk just moves raw bytes. There’s no defined message structure at all. Firmware and the host app have to agree on their own protocol for where one message starts and ends. This is something a lot of people don’t think about until it bites them.

The problem: a bulk endpoint’s max packet size is 64 bytes (at Full Speed). If a message is longer than that, you have to split it into multiple packets and send them in sequence. Once the host is receiving a stream of packets, how does it know whether it’s looking at one long message or several separate ones?

Strategy 1: Fixed length

Every message is exactly N bytes. Simple, but not very flexible.

/* Device: always sends 64 bytes, padding if needed */typedef struct {    uint8_t  cmd;    uint8_t  status;    uint16_t data_len;    uint8_t  data[60];} __attribute__((packed)) BulkMsg_t;  /* Always 64 bytes */

Strategy 2: Length-prefixed

Use the first 2-4 bytes to carry the total message length. This is the most common approach, easy to implement and easy to debug.

/* Header: 4-byte length, followed by the payload */typedef struct {    uint32_t total_len;  /* Total payload bytes that follow */    uint8_t  payload[];  /* Variable length */} __attribute__((packed)) BulkFrame_t;/* Host: read the 4-byte header, then read total_len more bytes */

Strategy 3: Start/end markers

Define special bytes to mark the start and end of a message. Flexible, but needs byte stuffing whenever the data itself happens to contain a marker byte.

#define FRAME_START  0x7E#define FRAME_END    0x7F#define FRAME_ESCAPE 0x7D  /* Escape byte when data contains START/END *//* Encoding: if a data byte is 0x7E or 0x7F, send 0x7D followed by (byte XOR 0x20) */

Strategy 4: CRC framing

Each frame is header + payload + CRC. The host reads the header to know the length, reads the payload, then computes a CRC and compares it. A mismatch means drop the frame.

typedef struct {    uint8_t  magic[2];   /* 0xAA, 0x55, start marker */    uint16_t length;     /* Payload length */    uint8_t  payload[]; /* Variable */    /* uint16_t crc;    ← comes after the payload */} __attribute__((packed)) BulkFrame_t;

Short packets and ZLP

If your protocol relies on a short packet to mark the end of a transfer, and the total data happens to be an exact multiple of the max packet size (64 bytes), the device needs to send an extra Zero-Length Packet (ZLP) to signal the transfer is done.

If your protocol already has an explicit length prefix, the host knows in advance how many bytes to read, and a ZLP may not be necessary depending on the USB stack and how the protocol is designed. ST’s CDC middleware usually handles ZLP on its own, but if you’re writing a custom bulk class, check this carefully.

Here’s pseudo-code for the idea (note the endpoint can still be busy; see the real state machine in post 5):

void BulkSend(const uint8_t *data, uint32_t len){    /* Send the data */    CDC_Transmit_FS((uint8_t*)data, len);    /* If len is a multiple of 64, send a ZLP as well */    if (len % USB_FS_MAX_PACKET_SIZE == 0) {        CDC_Transmit_FS(NULL, 0);  /* ZLP, only when the endpoint is idle */    }}

An important caveat

With bulk transfer, the device still can’t push data to the host on its own. It can only send when the host actively sends an IN token to say it’s ready to receive. If the host never polls, the device will sit there forever, no matter how many times firmware calls transmit.

That leads to a couple of real-world consequences:

  • The device never pushes data up to the host. Bulk is entirely dependent on the host. If the host doesn’t act, the device can’t send anything.
  • Vendor-specific bulk usually needs its own driver on Windows. If the bulk endpoint belongs to a standard class like CDC or MSC, the OS already has a matching class driver. But inside a vendor-specific interface, Windows typically needs WinUSB, libusbK, or a Microsoft OS Descriptor before a host app can access it directly.

Isochronous Transfer

Isochronous is used for audio and video, where data has to arrive on time but doesn’t need to be 100% accurate. If a packet is corrupted, it’s simply dropped immediately, no retry, no ACK. It’s built for streaming data, where dropping one bad packet is far better than stalling the entire stream.

I don’t have hands-on experience with this one to share, so I won’t go deep, but here are a few things worth knowing:

  • Isochronous gets guaranteed bandwidth. The host always reserves bandwidth ahead of every frame/microframe.
  • There’s no handshake (ACK/NAK/STALL). The host sends, the device receives, done.
  • bInterval at Full Speed is always 1 (every frame, 1ms). You can’t set it any slower.
  • USB Audio Class relies on isochronous, think USB microphones, speakers, and headsets.
  • At Full Speed, up to 90% of each frame’s bandwidth can go to isochronous traffic.
Full Speed isochronous: 1 packet per frame, up to 1023 bytes/packet
High Speed isochronous: 1-3 packets per microframe, up to 1024 bytes/packet

Comparing the four transfer types

Type Characteristics Notes
Control Endpoint 0, Setup / Data / Status sequence Required for enumeration. The host reserves ~10% of bandwidth for it. Firmware rarely has to handle this directly since middleware covers most of it.
Interrupt Host polls on a schedule set by bInterval, device responds ACK or NAK Used for HID: mice, keyboards, card readers. Guarantees a maximum latency. Watch bInterval and null reports carefully.
Bulk Low-priority transfer, host can retry as many times as needed Used for CDC data, MSC, firmware updates. High throughput but no timing guarantee. Firmware has to frame the data itself. Many systems need a dedicated driver or libusb for the host to talk to the endpoint correctly.
Isochronous Guaranteed bandwidth, no retry mechanism Used for audio/video. Very low latency. No ACK/NAK, corrupted packets are simply dropped.

Where do these transfers show up in this series?

So this post isn’t purely theoretical, the whole series builds on one real firmware: stm32g0-usb-device-lab, developed step by step from a simple HID device into a full composite device (HID + CDC log + vendor request + bulk dump).

Here’s where each transfer type shows up in the series:

TransferWhere it shows up
ControlPost 3, when the host reads descriptors during enumeration; post 7, when vendor requests go over EP0
InterruptPost 5, when the HID keyboard sends 8-byte reports, polled by the host at bInterval
BulkPost 6, when CDC logs stream over a bulk endpoint; post 7, when a 144 KB RAM dump goes out over vendor bulk IN
IsochronousNot used anywhere in the series, since I don’t have hands-on experience with it yet

Summary

All four transfer types share one rule: the host always initiates the transaction. Firmware just prepares data and waits to be asked.

A few things worth keeping in mind when designing firmware:

  • Interrupt: never resend a stale report, mark TX idle right after each transfer completes, and always measure the real bInterval with a USB protocol analyzer instead of trusting the declared value.
  • Bulk: you have to design your own framing, and remember that vendor-specific bulk usually needs WinUSB or libusbK on Windows.
  • Control: middleware handles most of it already. You only need to write your own when there’s a class-specific or vendor request involved.
  • SOF: losing SOF for 3ms straight is the signal for suspend. Any operation that blocks the CPU for a while, like a Flash erase, can delay the USB response enough to cause a timeout or a host-triggered reset.
  • Never carry over old USB state after a reset. Reinitialize every variable, buffer, and flag.

References

Found this article useful?

Share, give feedback, or support if you find this content valuable.

Feedback