USB Gateware: Part 1 - Enumeration
This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:
USB Gateware: Part 1 - Enumeration (This tutorial)
The goal of this tutorial is to create a gateware design for the simplest USB Device that can still be enumerated by a host.
Prerequisites
Install the Cynthion tools by following Getting Started with Cynthion.
Complete the Gateware Blinky tutorial.
Define a USB Device
USB devices are defined using a hierarchy of descriptors that contain information such as:
The product name and serial number.
The vendor who made it.
The class of device it is.
The ways in which it can be configured.
The number and types of endpoints it has.
At the root of this hierarchy lies the Device Descriptor and a device can only have one.
Create the Device Descriptor
Create a new file called gateware-usb-device.py
and add the following code to it:
1from amaranth import *
2from usb_protocol.emitters import DeviceDescriptorCollection
3
4VENDOR_ID = 0x1209 # https://pid.codes/1209/
5PRODUCT_ID = 0x0001
6
7class GatewareUSBDevice(Elaboratable):
8 def create_descriptors(self):
9 descriptors = DeviceDescriptorCollection()
10
11 with descriptors.DeviceDescriptor() as d:
12 d.idVendor = VENDOR_ID
13 d.idProduct = PRODUCT_ID
14 d.iManufacturer = "Cynthion Project"
15 d.iProduct = "Gateware USB Device"
16 d.bNumConfigurations = 1
17
18 return descriptors
19
20 def elaborate(self, platform):
21 m = Module()
22 return m
We have now created a minimal device descriptor with a vendor id, product id, a manufacturer, a product description and one Configuration Descriptor.
USB devices can have multiple configurations but only one can be active at a time. This allows a USB device to be configured differently depending on the situtation. For example, a device might be configured differently if it’s bus-powered vs self-powered.
Create the Configuration Descriptor
Next, add a configuration descriptor for our device by adding the highlighted lines:
1from amaranth import *
2from usb_protocol.emitters import DeviceDescriptorCollection
3
4VENDOR_ID = 0x1209 # https://pid.codes/1209/
5PRODUCT_ID = 0x0001
6
7class GatewareUSBDevice(Elaboratable):
8 def create_descriptors(self):
9 descriptors = DeviceDescriptorCollection()
10
11 with descriptors.DeviceDescriptor() as d:
12 d.idVendor = VENDOR_ID
13 d.idProduct = PRODUCT_ID
14 d.iManufacturer = "Cynthion Project"
15 d.iProduct = "Gateware USB Device"
16 d.bNumConfigurations = 1
17
18 with descriptors.ConfigurationDescriptor() as c:
19 with c.InterfaceDescriptor() as i:
20 i.bInterfaceNumber = 0
21
22 return descriptors
23
24 def elaborate(self, platform):
25 m = Module()
26 return m
We have now created the descriptors for a device with a single configuration descriptor and one interface descriptor with no endpoints. (We’ll add some endpoints later!)
Note
Each USB Configuration can have multiple interface descriptors and they can all be active at the same time. This allows a USB device to create functional groups that are each responsible for a single function of the device. For example, a USB Audio Interface may have one interface descriptor with two endpoints for audio input/output and another interface descriptor with one endpoint for MIDI input.
Create Device Gateware
Now that we have defined our device’s descriptors we need to create the interface between our device’s physical USB port and the gateware that implements the device’s function(s). Fortunately the LUNA library takes care of all the hard work for us and we only need to add the following lines:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5VENDOR_ID = 0x1209 # https://pid.codes/1209/
6PRODUCT_ID = 0x0001
7
8class GatewareUSBDevice(Elaboratable):
9 def create_descriptors(self):
10 descriptors = DeviceDescriptorCollection()
11
12 with descriptors.DeviceDescriptor() as d:
13 d.idVendor = VENDOR_ID
14 d.idProduct = PRODUCT_ID
15 d.iManufacturer = "Cynthion Project"
16 d.iProduct = "Gateware USB Device"
17 d.bNumConfigurations = 1
18
19 with descriptors.ConfigurationDescriptor() as c:
20 with c.InterfaceDescriptor() as i:
21 i.bInterfaceNumber = 0
22
23 return descriptors
24
25 def elaborate(self, platform):
26 m = Module()
27
28 # configure cynthion's clocks and reset signals
29 m.submodules.car = platform.clock_domain_generator()
30
31 # request the physical interface for cynthion's TARGET C port
32 ulpi = platform.request("target_phy")
33 m.submodules.usb = usb = USBDevice(bus=ulpi)
34
35 # create our descriptors and add them to the device's control endpoint
36 descriptors = self.create_descriptors()
37 control_ep = usb.add_standard_control_endpoint(descriptors)
38
39 # configure the device to connect by default when plugged into a host
40 m.d.comb += usb.connect.eq(1)
41
42 return m
43
44if __name__ == "__main__":
45 from luna import top_level_cli
46 top_level_cli(GatewareUSBDevice)
Testing the Device
Connect
We need to connect our Cynthion before we can test our new USB device. If you followed the prerequisites above, you should already have connected the Cynthion’s CONTROL port to your computer.
Now also connect the TARGET C port to your computer as this is the port we requested our USB Device to run on. The control host and target host can be two separate computers, but in this tutorial we will use the same computer as both the control host and the target host.
Build
Build the device gateware and upload it to your Cynthion by typing the following into your terminal shell:
python ./gateware-usb-device.py
If everything went well and Cynthion’s TARGET C port is connected we should now be able to check if the target host managed to succesfully enumerate our device.
Test
To check if the device was recognized by the target host’s operating system follow the corresponding instructions:
Create a new file called test-gateware-usb-device.py
and add the following code to it:
1import usb1
2
3def list_available_usb_devices(context):
4 for device in context.getDeviceList():
5 try:
6 manufacturer = device.getManufacturer()
7 product = device.getProduct()
8 print(f"{device}: {manufacturer} - {product}")
9 except Exception as e:
10 print(f"{device}: {e}")
11
12if __name__ == "__main__":
13 with usb1.USBContext() as context:
14 list_available_usb_devices(context)
Run the file with:
python ./test-gateware-usb-device.py
And, if the device is recognized, you should see a line like:
Bus 000 Device 001: ID 1d5c:5010: Fresco Logic, Inc. - USB2.0 Hub
Bus 000 Device 002: ID 1d5c:5000: Fresco Logic, Inc. - USB3.0 Hub
Bus 000 Device 003: ID 1d50:615c: Great Scott Gadgets - Cynthion Apollo Debugger
Bus 000 Device 007: ID 1209:0001: Cynthion Project - Gateware USB Device
If you’re running on Windows you may instead see something like:
Bus 000 Device 001: ID 1d5c:5010: Fresco Logic, Inc. - USB2.0 Hub
Bus 000 Device 002: ID 1d5c:5000: Fresco Logic, Inc. - USB3.0 Hub
Bus 000 Device 003: ID 1d50:615c: Great Scott Gadgets - Cynthion Apollo Debugger
Bus 000 Device 007: ID 1209:0001: LIBUSB_ERROR_NOT_SUPPORTED [-12]
The devices Product and Vendor ID’s are correct (1209:0001
) but Windows could not obtain the product or manufacturer strings. This behaviour is expected and we’ll be taking a closer look at it in the next part of the tutorial.
Run the following command in a terminal window:
lsusb
If the device enumerated successfully you should see an entry similiar to the highlighted line:
% lsusb
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 001 Device 003: ID 2109:2822 VIA Labs, Inc. USB2.0 Hub
Bus 001 Device 045: ID 1d50:615c OpenMoko, Inc. Cynthion Apollo Debugger
Bus 001 Device 046: ID 1209:0001 Generic pid.codes Test PID
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
To view the device’s descriptors, pass the product and vendor id’s by running:
lsusb -d 1209:0001 -v
Run the following command in a terminal window:
ioreg -b -p IOUSB
If the device enumerated successfully you should see an entry similiar to the highlighted line:
% ioreg -b -p IOUSB
+-o Root <class IORegistryEntry, id 0x100000100, retain 30>
+-o AppleT8103USBXHCI@00000000 <class AppleT8103USBXHCI, id 0x100000331, registered, matched, ac$
+-o USB2.0 Hub@00100000 <class IOUSBHostDevice, id 0x1000ee65d, registered, matched, active, b$
| +-o USB2.0 Hub@00140000 <class IOUSBHostDevice, id 0x1000ee6b0, registered, matched, active,$
| | +-o Cynthion Apollo Debugger@00144000 <class IOUSBHostDevice, id 0x100180243, registered, $
| +-o Gateware USB Device@00130000 <class IOUSBHostDevice, id 0x100181cb3, registered, matched$
+-o USB3.0 Hub@00200000 <class IOUSBHostDevice, id 0x100181add, registered, matched, active, b$
+-o USB3.0 Hub@00240000 <class IOUSBHostDevice, id 0x100181aef, registered, matched, active,$
To view more information, pass the device name:
ioreg -b -p IOUSB -n "Gateware USB Device"
The easiest way to check a USB device is to open the Windows Device Manager. However, if you try this with our device you will notice there’s a small problem:

We can see our device, but it has a warning icon indicating that it does not have an installed device driver. Unlike macOS or Linux, Windows does not support a generic USB driver for non-class devices with custom vendor interfaces. In the next part of the tutorial we’ll look at how to solve this.
Conclusion
Our device can now be enumerated by a host but, if you’re running Microsoft Windows, you will have noticed that the device still requires a device driver to function.
The next part of the tutorial is optional and will cover WCID Descriptors which is a mechanism introduced by Microsoft to allow Windows applications to communicate directly with USB devices without the neccessity of writing device drivers.
If you don’t need to target Windows please feel free to skip the next part and jump straight to USB Gateware: Part 3 - Control Transfers to learn how to add the Control Request Handlers to our device that allow it to receive and respond to control requests from the host.
Exercises
Try changing the device descriptor information to match an existing hardware USB device. What happens?
More information
Source Code
1#!/usr/bin/env python3
2#
3# This file is part of Cynthion.
4#
5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
6# SPDX-License-Identifier: BSD-3-Clause
7
8from amaranth import *
9from luna.usb2 import USBDevice
10from usb_protocol.emitters import DeviceDescriptorCollection
11
12VENDOR_ID = 0x1209 # https://pid.codes/1209/
13PRODUCT_ID = 0x0001
14
15class GatewareUSBDevice(Elaboratable):
16 """ A simple USB device that can only enumerate. """
17
18 def create_standard_descriptors(self):
19 """ Create the USB descriptors for the device. """
20
21 descriptors = DeviceDescriptorCollection()
22
23 # all USB devices have a single device descriptor
24 with descriptors.DeviceDescriptor() as d:
25 d.idVendor = VENDOR_ID
26 d.idProduct = PRODUCT_ID
27 d.iManufacturer = "Cynthion Project"
28 d.iProduct = "Gateware USB Device"
29
30 d.bNumConfigurations = 1
31
32 # and at least one configuration descriptor
33 with descriptors.ConfigurationDescriptor() as c:
34
35 # with at least one interface descriptor
36 with c.InterfaceDescriptor() as i:
37 i.bInterfaceNumber = 0
38
39 # interfaces also need endpoints to do anything useful
40 # but we'll add those later!
41
42 return descriptors
43
44
45 def elaborate(self, platform):
46 m = Module()
47
48 # configure cynthion's clocks and reset signals
49 m.submodules.car = platform.clock_domain_generator()
50
51 # request the physical interface for cynthion's TARGET C port
52 ulpi = platform.request("target_phy")
53
54 # create the USB device
55 m.submodules.usb = usb = USBDevice(bus=ulpi)
56
57 # create our standard descriptors and add them to the device's control endpoint
58 descriptors = self.create_standard_descriptors()
59 control_endpoint = usb.add_standard_control_endpoint(descriptors)
60
61 # configure the device to connect by default when plugged into a host
62 m.d.comb += usb.connect.eq(1)
63
64 return m
65
66
67if __name__ == "__main__":
68 from luna import top_level_cli
69 top_level_cli(GatewareUSBDevice)
1import usb1
2
3# - list available usb devices ------------------------------------------------
4
5def list_available_usb_devices(context):
6 for device in context.getDeviceList():
7 try:
8 manufacturer = device.getManufacturer()
9 product = device.getProduct()
10 print(f"{device}: {manufacturer} - {product}")
11 except Exception as e:
12 print(f"{device}: {e}")
13
14
15# - main ----------------------------------------------------------------------
16
17if __name__ == "__main__":
18 with usb1.USBContext() as context:
19 list_available_usb_devices(context)