A USB 2.0 Device Controller in SystemC TLM (Part 1): Architecture and Enumeration

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:

  1. GET_DESCRIPTOR at address 0,
  2. SET_ADDRESS which moves the device to a new address,
  3. GET_DESCRIPTOR again 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:

  1. SETUP — the host sends a SETUP token, then a DATA0 packet containing an 8-byte standard request: bmRequestType (direction, type, recipient), bRequest (the command, e.g. 0x06 = GET_DESCRIPTOR), wValue/wIndex (arguments), and wLength (the number of data bytes the DATA stage may carry).
  2. DATA — optional. Direction depends on bmRequestType bit 7: 0 = host-to-device (OUT), 1 = device-to-host (IN). Requests like SET_ADDRESS have no data stage at all — the address is carried entirely inside wValue.
  3. 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 / OUTdma_reads the 8-byte setup payload from REG_DATA_PTR, wraps it in DATA0 (SETUP) or DATA1 (OUT), and sends it to the device, which parses the request straight out of the incoming DATA packet (device.cpp).
  • INreceive_data_packet lets the device fill a wire buffer (descriptor bytes after the PID), then dma_write stores the result back into system RAM at REG_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 in wValue. DEVICE, CONFIGURATION (config + interface back-to-back; endpoint descriptors are a TODO), INTERFACE, and STRING (index 0 = language IDs, 1 = vendor, 2 = product) each get copied into the device’s internal buffer, clamped to request->wLength if 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) — stash wValue in pending_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_ADDRESS address 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 of main.cpp): configuration (config + interface), language ID, manufacturer, and product strings.
  • SET_ADDRESS with 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/DATA1 on 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 explicit ACK packet. 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_SUSPENDED is 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:

  1. SET_CONFIGURATION so the device finally reaches USB_CONFIGURED, the last state in the machine people actually care about.
  2. 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.
  3. 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

Sadeem Sajid