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.
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.
bIntervalin 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:
-
Token packet: the host says what it wants to do (IN, OUT, SETUP) and which endpoint it’s talking to.
-
Data packet:
- OUT/SETUP: the host sends data to the device
- IN: the device sends data to the host
-
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:
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
1 Device → Host (IN) 00 Standard 00000 Device A few other values you’ll run into often:
| bmRequestType | Meaning |
|---|---|
0x00 | Host→Device, Standard, Device, used for SET_ADDRESS |
0x21 | Host→Device, Class, Interface, used for HID SET_REPORT |
0xC0 | Device→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 |
|---|---|
| 1 | 2^0 = 1 x 125µs = 125µs |
| 4 | 2^3 = 8 x 125µs = 1ms |
| 8 | 2^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=3actually becomes 2msbInterval=5actually 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.
bIntervalat 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:
| Transfer | Where it shows up |
|---|---|
| Control | Post 3, when the host reads descriptors during enumeration; post 7, when vendor requests go over EP0 |
| Interrupt | Post 5, when the HID keyboard sends 8-byte reports, polled by the host at bInterval |
| Bulk | Post 6, when CDC logs stream over a bulk endpoint; post 7, when a 144 KB RAM dump goes out over vendor bulk IN |
| Isochronous | Not 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
bIntervalwith 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
- USB 2.0 Specification, the official spec, Chapter 5 (Transfer Types) and Chapter 8 (Protocol Layer)
- USB in a NutShell - beyondlogic.org, a solid explainer for the USB protocol
- USB Bulk and Interrupt Transfer - learn.microsoft.com, from a Windows driver perspective
- USB Developers FAQ - janaxelson.com, covers
bIntervaland plenty of other practical questions - STM32G0B1 Reference Manual RM0444, the USB DRD_FS controller
Found this article useful?
Share, give feedback, or support if you find this content valuable.