A USB 2.0 Device Controller in SystemC TLM (Part 1): Architecture and Enumeration
17th August, 2026
Some months ago I wrote about emulating a PCIe device with QEMU. The idea there was that if you’re building hardware, you want a software model of your device that can talk to a real OS before the silicon exists. Now putting your device in QEMU isn’t the only part of the puzzle, you need an actual “model” that responds to QEMU in the way an actual hardware would. Therefore, as an example, I wrote tlm-usb2.0, an open-source USB 2.0 device controller written as a SystemC TLM model, whose end goal is to co-simulate with QEMU: a guest OS running inside QEMU should think it has a USB device plugged in, when it actually has my SystemC model on the other end of a TLM socket.
What this article covers
When a USB device is plugged into a host, nothing useful happens until the host enumerates it, which basically means that we need to find out what it is, hand it an address on the bus, and pick a configuration. That entire handshake rides on control transfers over endpoints on a USB device. The Endpoint 0 (EP0) is the one endpoint every USB device must implement, which is specifically prescribed to service control requests. So the natural first milestone for a USB model is exactly that bootstrap:
- the USB device that can answer descriptors,
- a host controller to drive the bus,
- a tiny CPU whose firmware plays the part of the OS driver (later, QEMU will occupy this seat).
At the moment, the model supports precisely three things:
GET_DESCRIPTORat address 0,SET_ADDRESSwhich moves the device to a new address,GET_DESCRIPTORagain from the new address, proving the address actually stuck.
SET_CONFIGURATION (the step that would take the device into the USB_CONFIGURED state) is explicitly not implemented yet. This article walks through the architecture and the state machines exactly as the code implements it, so Part 2 and beyond can talk about what we add next without ambiguity.
Why SystemC TLM?
SystemC’s transaction-level modelling (TLM 2.0) is the standard way to describe hardware around the communication (or architecture), instead of worrying about how that communication takes place. Essentially, a module exposes a socket to QEMU where data packets are translated into tlm_generic_payloads carried by b_transport calls. That’s the ideal abstraction for co-simulation: a software model moves through a simulation with no clock-cycle accuracy, but with all the protocol fidelity. The current model defines three standard formats of data (read: packets) that it can accept from QEMU, which are: token_t, data_t and handshake_t payloads over TLM sockets, which our Bridge conveniently translates into TLM generic payloads.
The big picture
So we have three modules, three connections. The wiring in main.cpp is honest and short:
USB_Device usb_dev("usb_dev");
Controller controller("controller");
CPU cpu("cpu");
/* Bindings */
usb_dev.target.bind(controller.dev_out_sock);
controller.cpu_in_sock.bind(cpu.socket);
cpu.dma.bind(controller.dma_sock);
┌──────────────────────────────────────────────┐
│ CPU │
│ socket (initiator) dma (target) │
└──────────┬──────────────────────────▲─────────┘
│ │
│ register reads/writes │ DMA transfers
│ (TLM) │ into system RAM (TLM)
▼ │
┌────────────────────────────────────────────────┐
│ Host Controller │
│ cpu_in_sock (target) dma_sock (initiator)│
│ dev_out_sock (initiator) │
└──────────┬──────────────────────────────────────┘
│
│ USB token / data / handshake
│ packets (TLM)
▼
┌────────────────────────────────────┐
│ USB Device │
│ target (target socket) │
└────────────────────────────────────┘
The CPU only ever talks to the controller’s register file. The controller is the creature that speaks USB language on the wire side, and it uses a DMA channel back into the CPU’s memory both to fetch the data it must send (setup requests, OUT payloads) and to park the data it has received (descriptor bytes on IN). The device just sits on its target socket waiting for the controller to speak.
| Sockets | Module | Kind | Purpose |
|---|---|---|---|
socket |
CPU | initiator | writes/reads controller registers |
dma |
CPU | target | receives DMA writes/reads from the controller |
cpu_in_sock |
Controller | target | register access from the CPU |
dev_out_sock |
Controller | initiator | sends packets to the device |
dma_sock |
Controller | initiator | reads/writes CPU RAM |
target |
Device | target | receives packets from the controller |
This layout mirrors how real USB stacks are split (and, conveniently, how a QEMU co-sim will attach later, the guest writes controller registers and its own guest memory serves as the system RAM).
Control transfers in one paragraph
A control transfer has up to three stages:
- SETUP — the host sends a
SETUPtoken, then aDATA0packet containing an 8-byte standard request:bmRequestType(direction, type, recipient),bRequest(the command, e.g.0x06=GET_DESCRIPTOR),wValue/wIndex(arguments), andwLength(the number of data bytes the DATA stage may carry). - DATA — optional. Direction depends on
bmRequestTypebit 7:0= host-to-device (OUT),1= device-to-host (IN). Requests likeSET_ADDRESShave no data stage at all — the address is carried entirely insidewValue. - STATUS — the opposite direction of DATA, used as the “acknowledged, we’re done” handshake. For transfers with no data stage, STATUS is a single zero-length packet (ZLP).
That’s the whole language of enumeration, and my device’s state machine is basically this paragraph turned into states.
The packet layer (common/packet.h)
Everything on the bus is represented in the form of a header + payload. For packets that classify as tokens (see the three stages above), one byte has the PID type and its inverted copy in the other nibble (for validation purposes):
typedef struct __attribute__((packed)) {
packet_pid_t pid; // type : 4, check : 4
uint8_t address : 7;
uint8_t endp : 4;
uint8_t crc : 5; // covers address + endp
} token_t;
| PID | Value | Role |
|---|---|---|
PID_TOKEN_OUT |
0x1 | host → device |
PID_TOKEN_IN |
0x9 | device → host |
PID_TOKEN_SOF |
0x5 | start-of-frame marker |
PID_TOKEN_SETUP |
0xD | control transfer setup |
PID_DATA_DATA0 |
0x3 | data, even toggle |
PID_DATA_DATA1 |
0xB | data, odd toggle |
PID_HANDSHAKE_ACK |
0x2 | accepted |
PID_HANDSHAKE_NAK |
0xA | busy |
PID_HANDSHAKE_STALL |
0xE | not supported |
PID_HANDSHAKE_NYET |
0x6 | high-speed only |
A data_t on the wire is simply a packet of [PID | bytes | crc16]. The model carries the payload in the TLM transaction but unfortunately, does not validate the CRC for now (a listed limitation). handshake_t is just a PID. The sanity check that is present in USB_Device::b_transport (device.cpp) verifies each PID’s check-nibble against its type, which is how the device spots a garbled packet without CRC.
The host controller
The controller is a register device plus a worker thread. The CPU programs it -> it does the USB talking. Register map from controller/controller.h:
| Offset | Register | Access | Meaning |
|---|---|---|---|
| 0x00 | REG_USB_CMD |
R/W | bit 0 Run/Stop, bit 1 Reset |
| 0x04 | REG_USB_STS |
R | bit 0 Idle, bit 1 Error, bit 2 Trans. complete, bit 3 Busy, bit 4 Stopped |
| 0x08 | REG_PORT_SC |
R/W | bit 0 Connect, bit 1 Port Reset (unused for now) |
| 0x0C | REG_ADDR_ENDP |
R/W | [10:7] endpoint, [6:0] address |
| 0x10 | REG_DATA_PTR |
R/W | DMA address in system RAM |
| 0x14 | REG_TOKEN |
W | write 0 = SETUP, 1 = IN, 2 = OUT |
Writing REG_TOKEN (controller.cpp) stores the PID to run, sets status to HC_STS_BUSY, moves to HC_OPERATION and fires token_write_ev. The process_thread (controller.cpp) wakes, and, for a valid token, calls execute_usb_transaction. For these token calls, we perform the following operatiosn:
- SETUP / OUT —
dma_reads the 8-byte setup payload fromREG_DATA_PTR, wraps it inDATA0(SETUP) orDATA1(OUT), and sends it to the device, which parses the request straight out of the incoming DATA packet (device.cpp). - IN —
receive_data_packetlets the device fill a wire buffer (descriptor bytes after the PID), thendma_writestores the result back into system RAM atREG_DATA_PTR.
Success sets REG_USB_STS = HC_STS_TR_COMP and returns to HC_RUNNING; failure lands in HC_ERROR.
The controller’s own state machine, against what the code actually does:
┌────────────────┐
│ HC_STOPPED │◄──────────────────┐
└───────┬────────┘ │
│ REG_USB_CMD write │
│ (Run/Start) │
▼ │
┌────────────────┐ │
│ HC_RUNNING │ │
└───────┬────────┘ │
│ REG_TOKEN write │ REG_USB_CMD write
▼ │ (Reset or Stop →
┌────────────────┐ │ registers cleared,
│ HC_OPERATION │ │ straight to STOPPED)
└───────┬────────┘ │
┌───────┴────────┐ │
failure │ │ success │
▼ ▼ │
┌─────────────┐ ┌─────────────┐ │
│ HC_ERROR │ │ HC_RUNNING │─────┘
└─────────────┘ └─────────────┘
| From | To | Trigger |
|---|---|---|
HC_STOPPED |
HC_RUNNING |
REG_USB_CMD write with Run bit |
HC_RUNNING |
HC_OPERATION |
REG_TOKEN write (0/1/2) |
HC_OPERATION |
HC_RUNNING |
transaction completes (TR_COMP) |
HC_OPERATION |
HC_ERROR |
bad token or failed transaction |
| any | HC_STOPPED |
REG_USB_CMD Reset (or Stop) |
The device
The device side has three small state machines living in common/common.h. Two of their README diagrams need surgery (I know, I am too lazy to update the repo), the third was fine.
1. Device state machine (corrected for now)
The README shows the full textbook walk: ATTACHED → POWERED → DEFAULT → ADDRESS → CONFIGURED → SUSPENDED. Great, but the code doesn’t do most of that. The device constructor simply says “assume the device is freshly reset” and drops it straight into USB_DEFAULT (device.h). There is no modelling of VBUS, no explicit reset event, and GET-SET_CONFIGURATION doesn’t exist yet, so CONFIGURED and SUSPENDED, while declared in the enum, are unreachable today. The actually reachable machine is just two states:
┌────────────────┐
│ USB_DEFAULT │ initial state: "fresh reset",
└───────┬────────┘ addr = 0
│ SET_ADDRESS completes in the
│ STATUS stage (pending addr committed)
▼
┌────────────────┐
│ USB_ADDRESS │ device answers to its new addr
└────────────────┘
That’s it. state = USB_ADDRESS happens in exactly one place: process_data() handling the IN STATUS stage of a SET_ADDRESS, where the pending address is committed (device.cpp). Until then the device keeps answering at address 0, which, conveniently, is what the USB spec prescribes.
| State | Value | Reachable? |
|---|---|---|
USB_ATTACHED |
0 | no (enum only) |
USB_POWERED |
1 | no (enum only) |
USB_DEFAULT |
2 | yes — initial |
USB_ADDRESS |
3 | yes — after SET_ADDRESS |
USB_CONFIGURED |
4 | no (SET_CONFIGURATION TODO) |
USB_SUSPENDED |
5 | no (enum only) |
2. Transmission state machine
This one tracks whether the next packet on the bus is a token or data:
┌──────────────┐
│ USB_TOKEN │◄──────────────────┐
└──────┬───────┘ │
│ token packet received │
▼ (process_token success) │
┌──────────────┐ │
│ USB_DATA │───────────────────┘ data packet
└──────────────┘ processed
In b_transport (device.cpp): with tr_state == USB_TOKEN a token runs through process_token and, on success, we advance to USB_DATA; in USB_DATA a data packet runs through process_data and we fall back to USB_TOKEN. USB_NO_DATA is declared in the enum but never assigned anywhere — another item to scratch from the README. The PID sanity check noted earlier runs for both phases.
3. Control transfer state machine (corrected)
This is the machine that actually implements enumeration. Here’s the clean mapping straight from process_token/process_data:
SETUP token IN token OUT token STATUS data
┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ NONE │──►│ SETUP │──►│ DATA │──►│ STATUS │──►│ NONE │
└─────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│ ^
│ └── IN token while ctrl_data_skip is set
│ (SET_ADDRESS: no DATA stage)
▼
┌──────────┐
│ STATUS │──────────────► NONE
└──────────┘ STATUS data (device ZLP DATA1)
| From | To | Trigger | Where |
|---|---|---|---|
CTRL_NONE |
CTRL_SETUP |
SETUP token received | device.cpp:96 |
CTRL_SETUP |
CTRL_DATA |
IN token (data-stage request, e.g. GET_DESCRIPTOR) |
device.cpp:111-112 |
CTRL_SETUP |
CTRL_STATUS |
IN token while ctrl_data_skip set (SET_ADDRESS) |
device.cpp:102-103 |
CTRL_DATA |
CTRL_STATUS |
OUT token (host-to-device STATUS stage) | device.cpp:130-131 |
CTRL_STATUS |
CTRL_NONE |
STATUS data packet handled | device.cpp:288-289, 338-339 |
The ctrl_data_skip flag is how SET_ADDRESS skips a DATA stage that it doesn’t have: the SETUP data handler records pending_addr, sets ctrl_data_skip = true, and the next IN token goes SETUP → STATUS instead of SETUP → DATA.
Request handling
process_data dispatches on the 8-byte request after the PID:
GET_DESCRIPTOR(0x06) — the descriptor type/index arrive inwValue.DEVICE,CONFIGURATION(config + interface back-to-back; endpoint descriptors are a TODO),INTERFACE, andSTRING(index 0 = language IDs, 1 = vendor, 2 = product) each get copied into the device’s internal buffer, clamped torequest->wLengthif a host asks for fewer bytes than the full descriptor (device.cpp). Some hosts grab only the first 8 bytes of the device descriptor, which is exactly what that clamp is for.SET_ADDRESS(0x05) — stashwValueinpending_addr, skip the data stage, commit the address in the STATUS stage.
Out-of-order bits bite back: wrong PID check, or a DATA1 arriving when the toggle says DATA0, and the device answers with TLM_GENERIC_ERROR_RESPONSE. Unsupported request types are answered with an implicit error (also TODO: a proper STALL).
The CPU (stand-in firmware)
cpu::firmware_thread (cpu.cpp) plays the enumeration script the OS would run (does not matter when we connect with QEMU):
write REG_USB_CMD = Run
poll REG_USB_STS until Idle
────────────
GET_DESCRIPTOR(addr=0): SETUP → DATA(IN) → STATUS(OUT)
SET_ADDRESS(→4): SETUP → (no data) → STATUS(IN)
GET_DESCRIPTOR(addr=4): SETUP → DATA(IN) → STATUS(OUT) ← proves the address stuck
That’s the whole simulator right now, the CPU firmware drives exactly those three transfers, prints a hex dump of the descriptor, and the simulation ends.
What the model actually does today
The cut-down but honest feature list:
- Device descriptor at address 0, then again at the post-
SET_ADDRESSaddress which is the core loop of enumeration. - The rest of the descriptors are implemented and exercised, through only through the standalone testbench (
device_tb.cpp, commented out ofmain.cpp): configuration (config + interface), language ID, manufacturer, and product strings. SET_ADDRESSwith the correct plausibility condition: the device keeps talking on address 0 until the STATUS stage lands, then answers only on the new address (device.cpp).- Toggle tracking for
DATA0/DATA1on EP0. - A host controller register file plus a DMA channel into CPU RAM, so the data plane is real rather than faked.
The testbench exercises a slightly richer script (device_tb.cpp): device descriptor, SET_ADDRESS → 2, device descriptor again from address 2, config descriptor (requesting 25 bytes), then the three string descriptors. It’s all wrapped in <USB_Device_TB> and currently switched off in main.cpp, but it’s the fastest way to poke the device in isolation.
Descriptors (as shipped)
Device (DEVICE, 0x01):
| Offset | Field | Value | Notes |
|---|---|---|---|
| 0 | bLength |
18 | |
| 1 | bDescriptorType |
0x01 | DEVICE |
| 2 | bcdUSB |
0x0200 | USB 2.0 |
| 4‑6 | class/sub/proto | 0x00 | per-interface |
| 7 | bMaxPacketSize0 |
32 | EP0 max packet |
| 8 | idVendor |
0x1234 | |
| 10 | idProduct |
0x5678 | |
| 12 | bcdDevice |
0×0000 | |
| 14‑16 | string indices | 1, 2, 0 | manufacturer, product, none |
| 17 | bNumConfigurations |
1 |
Configuration (CONFIGURATION, 0x02). Note wTotalLength = 18 = 9 (config) + 9 (interface):
| Offset | Field | Value |
|---|---|---|
| 0 | bLength |
9 |
| 1 | bDescriptorType |
0x02 |
| 2 | wTotalLength |
18 |
| 4 | bNumInterfaces |
1 |
| 5 | bConfigurationValue |
1 |
| 6 | iConfiguration |
0 |
| 7 | bmAttributes |
0xC0 (self-powered; no remote wakeup) |
| 8 | bMaxPower |
0 |
Interface (INTERFACE, 0x04):
| Offset | Field | Value |
|---|---|---|
| 0 | bLength |
9 |
| 1 | bDescriptorType |
0x04 |
| 2 | bInterfaceNumber |
0 |
| 3 | bAlternateSetting |
0 |
| 4 | bNumEndpoints |
0 (EP0 only) |
| 5‑7 | class/sub/proto | 0x00 (per-interface; not vendor-specific) |
| 8 | iInterface |
0 |
Limitations
Straight from the code, nothing sugar-coated:
SET_CONFIGURATION(0x09) →USB_CONFIGURED: not implemented, the device never gets “ready for data transfers”.- No ACK handshakes: success is an implicit
TLM_OK_RESPONSE, not an explicitACKpacket. I don’t know, maybe this is fine. - No STALL: unsupported requests fail with an error status rather than a proper
STALL(device.cpp has the TODO). - No CRC validation on token or data packets; the check-nibble sanity test is all we have.
- No endpoint descriptors, no interrupt/bulk/isochronous.
- No suspend/resume:
USB_SUSPENDEDis a hostage of the enum. - Address filtering exists but is blunt: a token for the wrong address is silently ignored, which is fine for the single-device single-address script we run, and nothing more.
REG_PORT_SC(connect/port-reset) is a stub.
What’s next (Part 2 and friends)
The near-term list, roughly in dependency order:
SET_CONFIGURATIONso the device finally reachesUSB_CONFIGURED, the last state in the machine people actually care about.- Explicit ACK/STALL to replace the implicit TLM_OK with real handshake PIDs, and return STALL in the STATUS stage for unsupported requests. Not sure about this.
- CRC generation/validation and endpoint descriptors (EP0 IN/OUT at minimum).
In Part 2 I’ll pick up whichever of those lands first, likely the config request, and show the tooling and logs that let a real enumeration sequence play out end to end. Until then, the repo is at github.com/SadeemSajid/tlm-usb2.0, and it builds with a stock SystemC install and a plain CMake configure:
mkdir build && cd build
cmake -DSYSTEMC_HOME=$SYSTEMC_HOME ..
make -j$(nproc)
./usb