Cynthion Documentation
Cynthion Project Description
Cynthion is an all-in-one tool for building, testing, monitoring, and experimenting with USB devices. Built around a unique FPGA-based architecture, Cynthion’s digital hardware can be fully customized to suit the application at hand. As a result, it can act as a no-compromise High-Speed USB protocol analyzer, a USB-hacking multi-tool, or a USB development platform.
Out-of-the-box, Cynthion acts as a USB protocol analyzer capable of capturing and analyzing traffic between a host and any Low-, Full-, or High-Speed (“USB 2.0”) USB device. It works seamlessly with our Packetry open-source analysis software.
Combined with our LUNA gateware and Facedancer libraries, Cynthion becomes a versatile USB-hacking and development tool. Facedancer makes it quick and easy to create or tamper with real USB devices—not just emulations—even if you don’t have experience with digital hardware design, HDL, or FPGA architecture!
Getting Started with Cynthion
Prerequisites
To use Cynthion you will need to ensure the following software is installed:
Python v3.9, or later.
Cynthion Host Software Installation
The Cynthion host software distribution can be obtained from the Python Package Index (PyPI) or directly from source.
Note
For more information on installing Python packages from PyPI please refer to the “Installing Packages” section of the Python Packaging User Guide.
Use pip to install the Cynthion host software:
pip install cynthion
Install udev Rules
Configure your system to allow access to Cynthion for logged in users:
cynthion setup
If you’d prefer to perform this step manually, you can download and install the rules as follows:
# download udev rules curl -O https://raw.githubusercontent.com/greatscottgadgets/cynthion/main/cynthion/python/assets/54-cynthion.rules # install udev rules sudo cp 54-cynthion.rules /etc/udev/rules.d # reload udev rules sudo udevadm control --reload # apply udev rules to any devices that are already plugged in sudo udevadm trigger
You can check that the rules are installed correctly with:
cynthion setup --check
Use Homebrew to install Python and libusb:
brew install python libusb
Use pip to install the Cynthion host software:
pip install cynthionNote
The Cynthion host software uses the
libusb1Python package to communicate with the hardware. On macOS, the package does not install the native dynamic library with it, so it’s necessary to install thelibusbnative library through Homebrew, MacPorts or some other route.If you are not using a Python distribution from Homebrew you may be able to direct Cynthion to the correct location by explicitly setting
DYLD_FALLBACK_LIBRARY_PATHto the location of thelibusbnative library.For example:
DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib" cynthion info
Use pip to install the Cynthion host software:
pip install cynthion
Test Installation
Connect Hardware
Connect the Host computer to the Cynthion CONTROL port.
Check that the LED A power-on indicator lights up.
Test Hardware Connectivity
Open a terminal and confirm that everything is working by running:
cynthion info --force-offline
If everything is working you will see the following output:
Found Cynthion device! Hardware: Cynthion r1.4 Manufacturer: Great Scott Gadgets Product: Cynthion Apollo Debugger Serial number: xxxxxxxxxxxxxxxxxxxxxxxxxx Vendor ID: 1d50 Product ID: 615c bcdDevice: 0104 Firmware version: v1.0.6 USB API version: 1.1 Flash UID: xxxxxxxxxxxxxxxx
Updating Cynthion Host Software
To update the Cynthion host software to the latest version run:
pip install --upgrade cynthion
Updating Cynthion Microcontroller Firmware and FPGA configuration flash
To upgrade the Cynthion Microcontroller firmware and FPGA configuration flash to the latest versions run:
cynthion update
You can update the Microcontroller firmware separately with:
cynthion update --mcu-firmware
You can update the FPGA configuration flash separately with:
cynthion update --bitstream
Using Cynthion with Packetry
Together with Packetry, Cynthion can be used as a USB 2.0 protocol analyzer capable of capturing and analyzing traffic between a host and any Low, Full, and High Speed USB device.
Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion section.
Prerequisites
To use Cynthion’s USB Analyzer you will need to ensure the following software is installed:
USB Analyzer Bitstream
Cynthion ships from the factory with the USB Analyzer as the default bitstream for the FPGA.
If you have previously flashed a different default bitstream you can run the USB Analyzer bitstream with:
cynthion run analyzer
If you want to configure USB Analyzer as the default bitstream for the FPGA:
cynthion flash analyzer
You can verify that everything is working by running:
cynthion info
You should see output like:
Detected a Cynthion device!
Bitstream: USB Analyzer (Cynthion Project)
Hardware: Cynthion r1.4
Flash UID: xxxxxxxxxxxxxxxx
Connect Hardware
Next, see the Packetry documentation for more detail, or the tutorial Protocol analysis of a USB keyboard for a worked example.
Using Cynthion with Facedancer
Together with Facedancer, Cynthion can be used to quickly and easily emulate USB devices controlled from Python running on the host computer.
Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion section.
Install the Facedancer library
You can install the Facedancer library from the Python Package Index (PyPI), a release archive or directly from source.
Install From PyPI
You can use the pip tool to install the Facedancer library from PyPI using the following command:
pip install facedancer
For more information on installing Python packages from PyPI please refer to the “Installing Packages” section of the Python Packaging User Guide.
Install From Source
git clone https://github.com/greatscottgadgets/facedancer.git
cd facedancer/
Once you have the source code downloaded you can install the Facedancer library with:
pip install .
Load Facedancer Bitstream and Firmware
You can run the Facedancer Bitstream and Firmware by running:
cynthion run facedancer
You can verify that everything is working by running:
cynthion info
You should see output like:
Detected a Cynthion device!
Bitstream: Facedancer (Cynthion Project)
Hardware: Cynthion r1.4
Flash UID: xxxxxxxxxxxxxxxx
Connect Hardware
Make sure that the target host is running a program that can receive keyboard input such as a terminal or text editor and that it has focus.
Run a Facedancer example
Create a new Python file called rubber-ducky.py with the following content:
1import asyncio
2
3from facedancer import main
4from facedancer.devices.keyboard import USBKeyboardDevice
5
6device = USBKeyboardDevice()
7
8async def type_letters():
9 # Wait for device to connect
10 await asyncio.sleep(2)
11
12 # Type a string with the device
13 await device.type_string("echo hello, facedancer\n")
14
15main(device, type_letters())
Open a terminal and run:
python ./rubber-ducky.py
If all goes well, you should see the string hello, facedancer typed into the target host.
More Information
For further information, see the Facedancer documentation.
Using Cynthion with USB Proxy
Together with USB Proxy, Cynthion can proxy packets between a target host and a target device attached to the control computer.
Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion and Using Cynthion with Facedancer sections.
Connect Hardware
Run a USB Proxy example
Create a new Python file called usbproxy.py with the following content:
1#!/usr/bin/env python3
2#
3# This file is part of Facedancer.
4#
5""" USB Proxy example; forwards all USB transactions and logs them to the console. """
6
7from facedancer import main
8
9from facedancer.proxy import USBProxyDevice
10from facedancer.filters import USBProxySetupFilters, USBProxyPrettyPrintFilter
11
12# replace with the proxied device's information
13ID_VENDOR = 0x1050
14ID_PRODUCT = 0x0407
15
16
17if __name__ == "__main__":
18 # create a USB Proxy Device
19 proxy = USBProxyDevice(idVendor=ID_VENDOR, idProduct=ID_PRODUCT)
20
21 # add a filter to forward control transfers between the target host and
22 # proxied device
23 proxy.add_filter(USBProxySetupFilters(proxy, verbose=0))
24
25 # add a filter to log USB transactions to the console
26 proxy.add_filter(USBProxyPrettyPrintFilter(verbose=5))
27
28 main(proxy)
Open a terminal and run:
python ./usbproxy.py
Note
USBProxy requires root privileges on macOS in order to claim the device being proxied from the operating system.
sudo python ./usbproxy.py
python ./usbproxy.py
If all goes well you should see the output from device enumeration in your terminal and the proxied USB device should be detected by the target computer.
More Information
For further information, see the Facedancer USB Proxy documentation.
Using Cynthion USER I/O with Facedancer
In addition to the four USB ports Cynthion also includes the following user i/o ports:
Apart from PMOD B, which is used for the Facedancer SoC UART and JTAG interface, all of these can be accessed from within Python Facedancer devices.
Before proceeding, please ensure you have completed all steps in the Getting Started with Cynthion and Using Cynthion with Facedancer sections.
Requirements
A Cynthion running the Facedancer bitstream.
Two USB Cables.
Using Cynthion APIs
To access Cynthion USER i/o using Python you first need an instance of the Cynthion object, which can be created as follows:
from cynthion import Cynthion
c = Cynthion()
Once you have a Cynthion instance you will be able to access USER i/o using the leds and gpio APIs.
For example, open a new Python shell:
$ python
Python 3.11.11 (main, Feb 12 2025, 14:40:14) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
First obtain a Cynthion instance:
>>> from cynthion import Cynthion
>>> c = Cynthion()
Turn on USER Led5:
>>> c.leds[5].on()
Turn off USER Led5:
>>> c.leds[5].off()
Toggle USER Led4:
>>> c.leds[4].toggle()
Get the USER Button:
>>> user_button = c.gpio.get_pin("USER")
Wait for the USER Button to be pressed: (hit enter twice to start)
>>> while user_button.read() == False: pass
...
USER Pmod inputs and outputs
In addition to the USER Button and Leds, Facedancer can also make use of Cynthion USER Pmod A (USER Pmod B is used for JTAG and UART duties) to trigger or respond to external hardware. They use the same gpio APIs as the USER Button but individual pins can also be configured as inputs or outputs.
Let’s build a simple example that uses two of the USER Pmod A pins to connect a switch and a LED to Cynthion.
You will need:
1x SPST Switch
1x 1 kOhm resistor
1x 10 kOhm resistor
1x LED
1x Breadboard
Circuit Diagram
Breadboard Layout
Source Code
Create a new Python file called cynthion-user-pmod.py with the following content:
1import time
2
3from cynthion import Cynthion
4from cynthion.interfaces.gpio import PinDirection
5
6# Get Cynthion instance
7c = Cynthion()
8
9# Get USER Pmod Pin A1 and configure it as an input
10a1 = c.gpio.get_pin("A1")
11a1.set_direction(PinDirection.Input)
12
13# Get USER Pmod Pin A3 and configure it as an output
14a3 = c.gpio.get_pin("A3")
15a3.set_direction(PinDirection.Output)
16
17# Continuously read the input value of Pin A1 and output it to Pin A3.
18while True:
19 value = a1.read()
20 a3.write(value)
21 time.sleep(0.1)
Open a terminal and run:
python ./cynthion-user-pmod.py
If all goes well, the LED should light up when you press the switch and turn off when you release it.
The cynthion command line interface
$ apollo
usage: cynthion [-h] command ...
Cynthion command line interface
positional arguments:
command
run run a bitstream on the FPGA
flash overwrite the FPGA's configuration flash with the target bitstream
update update MCU firmware and FPGA configuration flash to the latest
installed versions
info print device information
setup install Cynthion support files required for operation (Linux only)
optional arguments:
-h, --help show this help message and exit
Command Documentation
Display Cynthion Information
Display Cynthion bitstream information:
cynthion info
Display Cynthion Microcontroller information:
cynthion info --force-offline
Note
Once you have switched to the Cynthion Microcontroller by pressing the PROGRAM button or
the --force-offline option you will need to press the RESET button to return control
to the FPGA.
Set up Cynthion
Check that your host environment is set up for Cynthion:
cynthion setup --check
Set up your host environment for Cynthion:
cynthion setup
Remove all files installed during set up:
cynthion setup --uninstall
Update Cynthion
Update both the Cynthion Debug Microcontroller firmware and USB Analyzer bitstream to the latest installed factory versions:
cynthion update
Update Cynthion Debug Microcontroller firmware to the latest installed factory version:
cynthion update --mcu-firmware
Update Cynthion USB Analyzer bitstream to the latest installed factory version:
cynthion update --bitstream
Run bitstream
Runs the given factory bitstream on the FPGA:
cynthion run <analyzer|facedancer|selftest>
Runs the bitstream specified by <filename> on the FPGA.
cynthion run --bitstream <filename>
Flash firmware and bitstreams
Overwrite the FPGA’s default bitstream with the given factory bitstream:
cynthion flash <analyzer|facedancer>
Overwrite the FPGA’s default bitstream with the one specified by <filename>:
cynthion flash --bitstream <filename>
Overwrite the Microcontroller firmware with the one specified by <filename>:
cynthion flash --mcu-firmware <filename>
Overwrite the SoC firmware with the one specified by <filename>:
cynthion flash --soc-firmware <filename>
Protocol analysis of a USB keyboard
This tutorial walks through the whole process of running a USB protocol capture of a target device, in this case a keyboard. Hopefully most people have a USB keyboard available and can follow along, though this process is also applicable to any USB peripheral device.
Prerequisites
Install the Cynthion tools by following Getting Started with Cynthion
Install Packetry by following Getting Started with Packetry
Run the analyzer gateware on Cynthion by following Using Cynthion with Packetry
Determine device speed
USB 2.0 supports three different speeds: Low (1.5 Mbit/s), Full (12 Mbit/s), and High (480 Mbit/s). The analyzer needs to know what speed to expect, so we need to detemine what speed the target device is using.
Note
Soon this step won’t be necessary, as the analyzer will be able to determine the speed automatically, but that feature is currently in development.
To determine the speed, we plug the target device into a host check what speed it reports. The way to check depends on the host operating system:
Run the command sudo dmesg -W in a terminal window and then plug the target device in, some new lines should show up with information about it:
[975321.743878] usb 1-6.1: new full-speed USB device number 12 using xhci_hcd
[975321.821329] usb 1-6.1: New USB device found, idVendor=3434, idProduct=0108, bcdDevice= 2.00
[975321.821341] usb 1-6.1: New USB device strings: Mfr=1, Product=2, SerialNumber=0
[975321.821345] usb 1-6.1: Product: Keychron Q1
[975321.821348] usb 1-6.1: Manufacturer: Keychron
Here, it shows that the target device has enumerated at full-speed.
Go to the Apple menu -> About This Mac -> More Info -> System Report -> Hardware -> USB. Highlight the target device and check the Speed field.
The easiest way to look at USB device information on Windows is to install and run USBView, then find the target device and check its Device Bus Speed field.
Connect
Next, we’ll connect everything up for capture. Within the Cynthion hardware the TARGET C and TARGET A ports are connected together, and the analyzer gateware will capture any packets that are seen going between those ports. These packets are then sent out through the CONTROL port.
First, connect the CONTROL port to the host that will be running Packetry. Next, connect the TARGET C port to a host. This can be a different host from the one running Packetry, or it can be the same host. If it is the same host, the two connections must not be on the same hub.
The TARGET A port will connect to the target device, but for now we leave it disconnected.
Capture
Open Packetry. At the top of the window, you should see the action bar:
Select the correct speed, press the capture button (the filled circle), and plug in the target device. The cable connections should look like this:
Upon plugging in the target device, a collection of entries should show up in the Traffic Pane, these show the requests that a host makes to find out information about a new device (known as USB enumeration).
The target device should also show up in the Devices pane to the right. All of the entries in the Traffic and Device panes can be expanded for more detail, by clicking on the black triangles. Clicking on an entry in the Traffic pane to highlight it will show extra detail in the pane below.
If you press and release a key on the keyboard, some new entries should show up:
Here I pressed the a key, causing two interrupt transfers; one for the press and one for the release event.
If you want to go further, you could look at the USB human interface device class (HID) specification,
to learn about the class-specific descriptors and endpoints, and match up what you see on the bus to keypress events.
Troubleshooting
Below are some common issues you may run into, with some advice for resolving them. If you run into further issues, please see the Getting Help section for more support.
No traffic shows up during capture
First, make sure the target device is operating correctly. If following along with a keyboard, make sure that any keypresses get through to the target host. If traffic still isn’t showing up, this is usually caused by selecting the wrong capture speed, try capturing with each of the other two speed options.
Traffic shows “Invalid Groups”
This means that the analyzer is detecting packets that are invalid. This is usually caused by selecting the wrong capture speed, try capturing with each of the other two speed options.
Traffic shows “unidentified transfer” and Devices shows an “Unknown” device
This means that valid packets have been captured, but Packetry did not see the target device enumeration so it doesn’t have enough information to fully decode the USB transactions/transfers. If you would like to see the full decoding, make sure to start the capture in Packetry before plugging in the target device.
Emulation of a USB Device
This tutorial walks through the whole process of emulating a USB device with Cynthion and Facedancer. We’ll emulate HackRF One, a software-defined radio platform. The goal of our emulation is to fool the hackrf_info command into reporting that a HackRF One is connected.
Prerequisites
Install the Cynthion tools by following Getting Started with Cynthion.
Install HackRF Tools by following Installing HackRF Software.
Install the Facedancer library and run the Facedancer bitstream and firmware as described in Using Cynthion with Facedancer.
Note
If you would like to configure your Cynthion for Facedancer operation permanently instead of temporarily, use
cynthion flash facedancerinstead ofcynthion run facedancer.
Try to Detect a HackRF One
Use the hackrf_info command to detect any connected HackRF devices:
hackrf_info
The command output should indicate that no HackRF devices are found:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
No HackRF boards found.
Connect
We need to connect our Cynthion before we can use it to emulate a HackRF One. 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. Facedancer software uses CONTROL to control the Cynthion and TARGET C to connect to the target host, the computer which we’ll try to fool into thinking that there is a HackRF One connected. 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.
Emulate the Vendor ID and Product ID
Use your favorite text editor to create a new Python program called hackrf_emulation.py with the following contents:
from facedancer import *
from facedancer import main
@use_inner_classes_automatically
class HackRF(USBDevice):
product_string : str = "HackRF One (Emulated)"
manufacturer_string : str = "Facedancer"
serial_number_string : str = "1234"
vendor_id : int = 0x1d50
product_id : int = 0x6089
class DefaultConfiguration(USBConfiguration):
class DefaultInterface(USBInterface):
pass
main(HackRF)
Every USB device identifies itself to its host computer using a 16-bit Vendor ID and 16-bit Product ID. This program uses the Facedancer library to implement a device with the Vendor ID and Product ID associated with HackRF One. It also configures some strings which make our emulated HackRF distinguishable from an actual HackRF One (with tools such as lsusb) for convenience.
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, open another terminal and execute hackrf_info. It should display output similar to this:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
hackrf_board_id_read() failed: Pipe error (-1000)
We’ve just convinced hackrf_info that it has found a HackRF device! However, hackrf_info failed to read the HackRF’s board ID which distinguishes between the various hardware platforms supported by HackRF software. The pipe error indicates that the device did not provide the expected response to the host’s request for the board ID.
Terminate hackrf_emulation.py by typing ctrl-c. Because we used the --suggest option, it should provide output like this:
Automatic Suggestions
These suggestions are based on simple observed behavior;
not all of these suggestions may be useful / desirable.
Request handler code:
@vendor_request_handler(number=14, direction=USBDirection.IN)
@to_device
def handle_control_request_14(self, request):
# Most recent request was for 1B of data.
# Replace me with your handler.
request.stall()
Try the Suggested Code
Add the suggested code to the HackRF class in hackrf_emulation.py. The program should now look like:
from facedancer import *
from facedancer import main
@use_inner_classes_automatically
class HackRF(USBDevice):
product_string : str = "HackRF One (Emulated)"
manufacturer_string : str = "Facedancer"
serial_number_string : str = "1234"
vendor_id : int = 0x1d50
product_id : int = 0x6089
class DefaultConfiguration(USBConfiguration):
class DefaultInterface(USBInterface):
pass
@vendor_request_handler(number=14, direction=USBDirection.IN)
@to_device
def handle_control_request_14(self, request):
# Most recent request was for 1B of data.
# Replace me with your handler.
request.stall()
main(HackRF)
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, execute hackrf_info in another terminal:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
hackrf_board_id_read() failed: Pipe error (-1000)
It turns out that our emulation still results in a pipe error. This is because we are stalling vendor request number 14 which is meant to return a 1 byte board ID. Terminate hackrf_emulation.py and replace the request_stall() line with:
request.reply([1])
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, execute hackrf_info in another terminal:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 1 (Jawbreaker)
hackrf_version_string_read() failed: Pipe error (-1000)
We’ve now convinced hackrf_info that our Cynthion is a HackRF Jawbreaker which was the beta platform that preceded HackRF One. Let’s try a higher board ID number. Replace request.reply([1]) with:
request.reply([2])
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, execute hackrf_info in another terminal:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
hackrf_version_string_read() failed: Pipe error (-1000)
We did it! Our new board ID represents HackRF One! In this example we guessed low numbers for the board ID byte, but we could have discovered that 2 represents HackRF One by observing the behavior of an actual HackRF One or by reading the libhackrf source code or HackRF firmware source code.
Handle the Version String Request
Unfortunately, hackrf_info still indicates an error, this time with reading a version string. The --suggest option on your Facedancer program should give you an idea of how to handle that request:
@vendor_request_handler(number=15, direction=USBDirection.IN)
@to_device
def handle_control_request_15(self, request):
# Most recent request was for 255B of data.
# Replace me with your handler.
request.stall()
Notice that this time the host has requested 255 bytes instead of just one byte. USB devices often return a smaller number of bytes than the length requested by the host. In this case we can guess that the host is requesting a maximum length string and that we can probably return something shorter. Let’s try adding this to the HackRF class in hackrf_emulation.py:
@vendor_request_handler(number=15, direction=USBDirection.IN)
@to_device
def handle_control_request_15(self, request):
# Most recent request was for 255B of data.
request.reply(b"tutorial version")
The complete program should now look like:
from facedancer import *
from facedancer import main
@use_inner_classes_automatically
class HackRF(USBDevice):
product_string : str = "HackRF One (Emulated)"
manufacturer_string : str = "Facedancer"
serial_number_string : str = "1234"
vendor_id : int = 0x1d50
product_id : int = 0x6089
class DefaultConfiguration(USBConfiguration):
class DefaultInterface(USBInterface):
pass
@vendor_request_handler(number=14, direction=USBDirection.IN)
@to_device
def handle_control_request_14(self, request):
# Most recent request was for 1B of data.
# Replace me with your handler.
request.reply([2])
@vendor_request_handler(number=15, direction=USBDirection.IN)
@to_device
def handle_control_request_15(self, request):
# Most recent request was for 255B of data.
request.reply(b"tutorial version")
main(HackRF)
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, execute hackrf_info in another terminal:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
Firmware Version: tutorial version (API:0.00)
hackrf_board_partid_serialno_read() failed: Pipe error (-1000)
Handle the Part ID Request
Now we can see another unhandled request made by hackrf_info. The --suggest output tells us that we can handle it with something like:
@vendor_request_handler(number=18, direction=USBDirection.IN)
@to_device
def handle_control_request_18(self, request):
# Most recent request was for 24B of data.
# Replace me with your handler.
request.stall()
The host is asking for 24 bytes this time, suggesting that it is looking for exactly 24 bytes. Let’s try replying with 24 bytes of dummy data:
@vendor_request_handler(number=18, direction=USBDirection.IN)
@to_device
def handle_control_request_18(self, request):
# Most recent request was for 24B of data.
request.reply(b"A" * 24)
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, execute hackrf_info in another terminal:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
Firmware Version: tutorial version (API:0.00)
Part ID Number: 0x41414141 0x41414141
hackrf_close() failed: Pipe error (-1000)
It looks like the part ID was interpreted as a valid number, and now hackrf_info is trying to close the device! We’re almost done!
Handle the Close Request
Based on the --suggest output, add the following to hackrf_emulation.py:
@vendor_request_handler(number=1, direction=USBDirection.OUT)
@to_device
def handle_control_request_1(self, request):
request.ack()
Notice that this time the direction of the vendor request is OUT instead of IN. This means that the host is sending data to the device, not asking the device to send data to the host. We acknowledge the request instead of replying with data.
Execute the program:
python hackrf_emulation.py --suggest
While the program is running, execute hackrf_info in another terminal:
hackrf_info version: 2023.01.1
libhackrf version: 2023.01.1 (0.8)
Found HackRF
Index: 0
Serial number: 1234
Board ID Number: 2 (HackRF One)
Firmware Version: tutorial version (API:0.00)
Part ID Number: 0x41414141 0x41414141
Success! hackrf_info now exits without error!
Put It All Together
With a few edits based on what we’ve learned, our complete program might look like this:
from facedancer import *
from facedancer import main
@use_inner_classes_automatically
class HackRF(USBDevice):
product_string : str = "HackRF One (Emulated)"
manufacturer_string : str = "Facedancer"
serial_number_string : str = "1234"
vendor_id : int = 0x1d50
product_id : int = 0x6089
class DefaultConfiguration(USBConfiguration):
class DefaultInterface(USBInterface):
pass
@vendor_request_handler(number=14, direction=USBDirection.IN)
@to_device
def handle_board_id_request(self, request):
# return 1-byte board ID
request.reply([2])
@vendor_request_handler(number=15, direction=USBDirection.IN)
@to_device
def handle_version_string_request(self, request):
# return up to 255 bytes
request.reply(b"tutorial version")
@vendor_request_handler(number=18, direction=USBDirection.IN)
@to_device
def handle_part_id_request(self, request):
# return 24 byte part ID
request.reply(b"A" * 24)
@vendor_request_handler(number=1, direction=USBDirection.OUT)
@to_device
def handle_close_request(self, request):
request.reply([])
main(HackRF)
Gateware Blinky
This tutorial walks through the process of developing a simple “blinky” example for Cynthion’s ECP5 FPGA. We’ll use the Amaranth Language & toolchain to create the design and generate a FPGA bitstream using the Yosys Open SYnthesis Suite.
Prerequisites
Before you begin, please make sure you have installed the Cynthion tools by following Getting Started with Cynthion.
Install Toolchain
To generate bitstreams for Cynthion you will need a synthesis toolchain that can convert the Verilog produced by Amaranth into a bitstream for Cynthion’s ECP5 FPGA.
For these tutorials we recommend YoWASP which provides unofficial WebAssembly-based packages for Yosys and NextPNR. It runs a little slower than the official OSS CAD Suite distribution but it’s platform-independent and much easier to get started with.
Install YoWASP using pip:
pip install yowasp-yosys yowasp-nextpnr-ecp5
Create a new Amaranth module
Create a new file called gateware-blinky.py and add the following code to it:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 return m
Amaranth designs are built from a hierarchy of smaller modules, which are called elaboratables. The Top class expresses that this will be the top-level or entry-point of your design.
Right now the Top module does not do anything except to create an Amaranth Module() and return it from the elaborate(...) method.
The elaborate(...) method also takes an argument called platform that contains resources specific to the board or platform the module is compiled for.
In this case, the argument will be an instance of the Cynthion Board Description and contain a map of Cynthion resources such as LEDs, USB PHY’s, USER PMOD connectors and board constraints.
Obtain a platform resource
Edit gateware-blinky.py and add the highlighted line:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9 return m
Amaranth platform resources can be obtained via the platform.request(name, number=0) method where name is the name of the resource and number is the index of the resource if there are more than one defined.
In this case we use a Python list comprehension to obtain all six FPGA led’s and concatenate them into a six-bit addressable Amaranth Signal using the Cat operation.
Timer State
To make the LED blink at predictable intervals we’ll use a simple timer.
To start with, let’s define the timer state by adding the highlighted lines:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9 half_freq: int = int(60e6 // 2)
10 timer: Signal(25) = Signal(range(half_freq))
11
12 return m
First we’ll declare a variable half_freq which is exactly half of Cynthion FPGA’s default clock frequency in Hz, next we’ll declare timer to be an Amaranth Signal which is wide enough to contain a value equal to half_freq - 1.
If we increment the timer by one for each clock cycle until it reaches half_freq - 1 we get a timer with a 500ms period.
Timer Logic
Now that we have a state definition for our timer we can move forward to the implementation logic, edit your file and add the highlighted lines:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9 half_freq: int = int(60e6 // 2)
10 timer: Signal(25) = Signal(range(half_freq))
11
12 with m.If(timer == half_freq - 1):
13 m.d.sync += leds.eq(~leds)
14 m.d.sync += timer.eq(0)
15
16 with m.Else():
17 m.d.sync += timer.eq(timer + 1)
18
19 return m
Amaranth combines normal Python expressions with Amaranth in order to describe a design. Whenever you see the prefix m. you are making a call to the Module object you created at the beginning of the elaborate(...) method. These calls are what build the logic which makes up a design.
The with m.If(...): and with m.Else(): blocks operate much like you’d expect where, every clock-cycle, the expression timer == half_freq - 1 will be evaluated and trigger the corresponding branch.
The first block represents the point at which the timer has expired and we’d like to change the state of the LEDs and then reset the timer back to zero.
In the second block the timer is still active so we simply increment timer by one.
Put It All Together
The contents of gateware-blinky.py should now look like this:
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 *
9
10class Top(Elaboratable):
11 def elaborate(self, platform):
12 m = Module()
13
14 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
15
16 half_freq: int = int(60e6 // 2)
17 timer: Signal(25) = Signal(range(half_freq))
18
19 with m.If(timer == half_freq - 1):
20 m.d.sync += leds.eq(~leds)
21 m.d.sync += timer.eq(0)
22
23 with m.Else():
24 m.d.sync += timer.eq(timer + 1)
25
26 return m
27
28if __name__ == "__main__":
29 from luna import top_level_cli
30 top_level_cli(Top)
Build and Upload FPGA Bitstream
Make sure your Cynthion CONTROL port is plugged into the host, open a terminal and then run:
python gateware-blinky.py
The blinky gateware will now be synthesized, placed, routed and automatically uploaded to the Cynthion’s FPGA.
Once this process has completed successfully all six of Cynthion’s FPGA LEDs should be flashing on and off.
Exercises
Modify the tutorial to turn the FPGA LEDs into a binary counter that increments by one every 250ms.
Connect the USER PMOD A port to the output of your counter and use a logic analyzer (e.g. GreatFET One) to view the values as they increment.
More information:
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)
USB Gateware: Part 2 - WCID Descriptors
This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:
USB Gateware: Part 2 - WCID Descriptors (This tutorial)
The goal of this tutorial is to define the descriptors that will tell Microsoft Windows to use the built-in generic WinUSB driver to communicate with our device.
This tutorial is optional and only required if you would like to use your device on Windows.
Prerequisites
Complete the USB Gateware: Part 1 - Enumeration tutorial.
WCID Devices
WCID devices or “Windows Compatible ID devices”, are USB devices that provide extra information to Windows in order to facilitate automatic driver installation or, more frequently, allow programs to obtain direct access to the device.
Historically, Windows required manual installation of drivers for non-class devices with custom vendor interfaces. Contrasted with Linux or macOS which will automatically assign a generic USB driver that allows for direct interaction with the device’s endpoints via a cross-platform library such as libusb or operating system API’s.
Microsoft eventually relented and now provide a Windows-specific mechanism for a device to advertise that it requires a generic WinUSB driver.
The full details are documented in the Microsoft OS 1.0 and Microsoft OS 2.0 specifications but the basic mechanism consists of a set of Windows-specific descriptor requests made by the host whenever a new device is plugged in.
For Microsoft OS 1.0, this boils down to three descriptor requests we need to be able to handle:
Microsoft OS String Descriptor
Microsoft Compatible ID Feature Descriptor
Microsoft Extended Properties Feature Descriptor
Microsoft OS String Descriptor
To start with, edit your gateware-usb-device.py file from the previous tutorial and add/modify the highlighted lines:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5from usb_protocol.emitters.descriptors.standard import get_string_descriptor
6
7...
8
9class GatewareUSBDevice(Elaboratable):
10 ...
11
12 def elaborate(self, platform):
13 m = Module()
14
15 # configure cynthion's clocks and reset signals
16 m.submodules.car = platform.clock_domain_generator()
17
18 # request the physical interface for cynthion's TARGET C port
19 ulpi = platform.request("target_phy")
20
21 # create the USB device
22 m.submodules.usb = usb = USBDevice(bus=ulpi)
23
24 # create our standard descriptors and add them to the device's control endpoint
25 descriptors = self.create_standard_descriptors()
26 control_endpoint = usb.add_standard_control_endpoint(descriptors)
27
28 # add the microsoft os string descriptor
29 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
30
31 # configure the device to connect by default when plugged into a host
32 m.d.comb += usb.connect.eq(1)
33
34 return m
The Microsoft OS String Descriptor responds to a standard String Descriptor request with an index of 0xee. It encodes two values:
0x12, # Descriptor Length: 18 bytes
0x03, # Descriptor Type: 3 = String
0x4d, 0x00, # M
0x53, 0x00, # S
0x46, 0x00, # F
0x54, 0x00, # T
0x31, 0x00, # 1
0x30, 0x00, # 0
0x30, 0x00, # 0
0xee, 0x00, # Vendor Code: 0xee
The first 14 bytes correspond to the little-endian encoded Unicode string MSFT100, with the remaining two bytes corresponding to the Vendor Code Windows should use when requesting the other descriptors. This is often set to the same value as the Microsoft OS String Descriptor index of 0xee, but you can use another value if it conflicts with an existing Vendor Code used by your device.
Microsoft Compatible ID Feature Descriptor
Next, add the Microsoft Compatible ID Feature Descriptor:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5from luna.gateware.usb.request.windows import (
6 MicrosoftOS10DescriptorCollection,
7)
8from usb_protocol.emitters.descriptors.standard import get_string_descriptor
9
10
11VENDOR_ID = 0x1209 # https://pid.codes/1209/
12PRODUCT_ID = 0x0001
13
14class GatewareUSBDevice(Elaboratable):
15 ...
16
17 def elaborate(self, platform):
18 ...
19
20 # add the microsoft os string descriptor
21 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
22
23 # add a microsoft descriptor collection for our other two microsoft descriptors
24 msft_descriptors = MicrosoftOS10DescriptorCollection()
25
26 # add the microsoft compatible id feature descriptor
27 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
28 with c.Function() as f:
29 f.bFirstInterfaceNumber = 0
30 f.compatibleID = 'WINUSB'
31
32 # configure the device to connect by default when plugged into a host
33 m.d.comb += usb.connect.eq(1)
34
35 return m
Our remaining descriptors are not returned via Standard Requests, instead they are implemented as Vendor Requests with Microsoft-defined Vendor Indices and the Vendor Code supplied in the Microsoft OS String Descriptor. We will implement the actual vendor request handler in the final step of the tutorial but for now we are just defining the Microsoft OS 1.0 Descriptor Collection that will contain these descriptors.
Our example is defining the simplest possible Compatible ID Feature descriptor, specifying a Function with a device interface number of 0 and a compatible ID of WINUSB. This is how we tell Windows to use the generic WinUSB driver for the interface.
If our device had multiple interfaces we could simply extended this by adding additional functions for each interface like so:
with msft_descriptors.ExtendedCompatIDDescriptor() as c:
with c.Function() as f:
f.bFirstInterfaceNumber = 0
f.compatibleID = 'WINUSB'
with c.Function() as f:
f.bFirstInterfaceNumber = 1
f.compatibleID = 'WINUSB'
...
Microsoft Extended Properties Feature Descriptor
We now come to our third descriptor, the Microsoft Extended Properties Feature Descriptor:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5from luna.gateware.usb.request.windows import (
6 MicrosoftOS10DescriptorCollection,
7)
8from usb_protocol.emitters.descriptors.standard import get_string_descriptor
9from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
10
11..
12
13class GatewareUSBDevice(Elaboratable):
14 ...
15
16 def elaborate(self, platform):
17 ...
18
19 # add the microsoft os string descriptor
20 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
21
22 # add a microsoft descriptor collection for our other two microsoft descriptors
23 msft_descriptors = MicrosoftOS10DescriptorCollection()
24
25 # add the microsoft compatible id feature descriptor
26 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
27 with c.Function() as f:
28 f.bFirstInterfaceNumber = 0
29 f.compatibleID = 'WINUSB'
30
31 # add microsoft extended properties feature descriptor
32 with msft_descriptors.ExtendedPropertiesDescriptor() as d:
33 with d.Property() as p:
34 p.dwPropertyDataType = RegistryTypes.REG_SZ
35 p.PropertyName = "DeviceInterfaceGUID"
36 p.PropertyData = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
37
38 # configure the device to connect by default when plugged into a host
39 m.d.comb += usb.connect.eq(1)
40
41 return m
The Extended Properties Feature Descriptor can be used to define additional device registry settings but, in our example, we only define the Device Interface GUID we’d like our device to be accessed with.
In this case it’s the Microsoft-defined GUID of {88bae032-5a81-49f0-bc3d-a4ff138216d6} which is defined as “all USB devices that don’t belong to another class”. If, for example, our device were a Keyboard or Mouse we’d need to use the appropriate value here.
Microsoft Descriptor Request Handler
Finally, now that all our descriptors are defined we need to add the actual Vendor Request Handler that will be responsible for responding to descriptor requests from a Windows Host:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5from luna.gateware.usb.request.windows import (
6 MicrosoftOS10DescriptorCollection,
7 MicrosoftOS10RequestHandler,
8)
9from usb_protocol.emitters.descriptors.standard import get_string_descriptor
10from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
11
12..
13
14class GatewareUSBDevice(Elaboratable):
15 ...
16
17 def elaborate(self, platform):
18 ...
19
20 # add the microsoft os string descriptor
21 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
22
23 # add a microsoft descriptor collection for our other two microsoft descriptors
24 msft_descriptors = MicrosoftOS10DescriptorCollection()
25
26 # add the microsoft compatible id feature descriptor
27 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
28 with c.Function() as f:
29 f.bFirstInterfaceNumber = 0
30 f.compatibleID = 'WINUSB'
31
32 # add microsoft extended properties feature descriptor
33 with msft_descriptors.ExtendedPropertiesDescriptor() as d:
34 with d.Property() as p:
35 p.dwPropertyDataType = RegistryTypes.REG_SZ
36 p.PropertyName = "DeviceInterfaceGUID"
37 p.PropertyData = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
38
39 # add the request handler for Microsoft descriptors
40 msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
41 control_endpoint.add_request_handler(msft_handler)
42
43 # configure the device to connect by default when plugged into a host
44 m.d.comb += usb.connect.eq(1)
45
46 return m
LUNA provides a pre-defined implementation for handling Microsoft OS10 Descriptor Requests and only requires the descriptor collection and the vendor request code we defined in the Microsoft OS10 String Descriptor.
Testing the Device
Connect
For this tutorial you will need to connect the Cynthion TARGET C port to a Windows computer for testing.
Plug the CONTROL port into the computer you’ve been using to control Cynthion. If this is the same machine as the Windows computer you’re using to test, plug it in there.
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 we should now be able to check if Windows can recognize the device.
Test
To test whether the WCID descriptors have been recognized, open the Windows Device Manager and look for the device under the “Universal Serial Bus devices” section:
You should find that the Python test program from USB Gateware: Part 1 - Enumeration now works as expected:
1import usb1
2
3def list_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_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
Conclusion
Our device can now be enumerated by Microsoft Windows but it can’t actually do anything yet. In the next part we’ll learn how to add Vendor Request Handlers to our device that allow it to receive and respond to control requests from the host: USB Gateware: Part 3 - Control Transfers
Exercises
Modify the example to use a different request code, does it still work?
Could you use the information you learnt in this tutorial modify the LUNA ACM Serial example example to support Windows?
Modify the
PropertyDatafield of the extended properties descriptor to one of the Microsoft-provided USB device class drivers. What happens?
More information
Pete Batard’s excellent introduction to WCID Devices.
Microsoft USB device class drivers included in Windows.
Microsoft System-defined device setup classes available to vendors.
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
12from luna.gateware.usb.request.windows import (
13 MicrosoftOS10DescriptorCollection,
14 MicrosoftOS10RequestHandler,
15)
16from usb_protocol.emitters.descriptors.standard import get_string_descriptor
17from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
18
19VENDOR_ID = 0x1209 # https://pid.codes/1209/
20PRODUCT_ID = 0x0001
21
22class GatewareUSBDevice(Elaboratable):
23 """ A simple USB device that can also enumerate on Windows. """
24
25 def create_standard_descriptors(self):
26 """ Create the USB descriptors for the device. """
27
28 descriptors = DeviceDescriptorCollection()
29
30 # all USB devices have a single device descriptor
31 with descriptors.DeviceDescriptor() as d:
32 d.idVendor = VENDOR_ID
33 d.idProduct = PRODUCT_ID
34 d.iManufacturer = "Cynthion Project"
35 d.iProduct = "Gateware USB Device"
36
37 d.bNumConfigurations = 1
38
39 # and at least one configuration descriptor
40 with descriptors.ConfigurationDescriptor() as c:
41
42 # with at least one interface descriptor
43 with c.InterfaceDescriptor() as i:
44 i.bInterfaceNumber = 0
45
46 # interfaces also need endpoints to do anything useful
47 # but we'll add those later!
48
49 return descriptors
50
51
52 def elaborate(self, platform):
53 m = Module()
54
55 # configure cynthion's clocks and reset signals
56 m.submodules.car = platform.clock_domain_generator()
57
58 # request the physical interface for cynthion's TARGET C port
59 ulpi = platform.request("target_phy")
60
61 # create the USB device
62 m.submodules.usb = usb = USBDevice(bus=ulpi)
63
64 # create our standard descriptors and add them to the device's control endpoint
65 descriptors = self.create_standard_descriptors()
66 control_endpoint = usb.add_standard_control_endpoint(descriptors)
67
68 # add the microsoft os string descriptor
69 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
70
71 # add a microsoft descriptor collection for our other two microsoft descriptors
72 msft_descriptors = MicrosoftOS10DescriptorCollection()
73
74 # add the microsoft compatible id feature descriptor
75 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
76 with c.Function() as f:
77 f.bFirstInterfaceNumber = 0
78 f.compatibleID = 'WINUSB'
79
80 # add microsoft extended properties feature descriptor
81 with msft_descriptors.ExtendedPropertiesDescriptor() as d:
82 with d.Property() as p:
83 p.dwPropertyDataType = RegistryTypes.REG_SZ
84 p.PropertyName = "DeviceInterfaceGUID"
85 p.PropertyData = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
86
87 # add the request handler for Microsoft descriptors
88 msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
89 control_endpoint.add_request_handler(msft_handler)
90
91 # configure the device to connect by default when plugged into a host
92 m.d.comb += usb.connect.eq(1)
93
94 return m
95
96
97if __name__ == "__main__":
98 from luna import top_level_cli
99 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)
USB Gateware: Part 3 - Control Transfers
This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:
USB Gateware: Part 3 - Control Transfers (This tutorial)
The goal of this tutorial is to define a control interface for the device we created in Part 1 that will allow it to receive and respond to control requests from a host.
Prerequisites
Complete the USB Gateware: Part 1 - Enumeration tutorial.
Complete the USB Gateware: Part 2 - WCID Descriptors tutorial. (Optional, required for Windows support)
Data Transfer between a Host and Device
USB is a host-centric bus, what this means is that all transfers are initiated by the host irrespective of the direction of data transfer.
For data transfers to the device, the host issues an OUT token to notify the device of an incoming data transfer. When data has to be transferred from the device, the host issues an IN token to notify the device that it should send some data to the host.
The USB 2.0 specification defines four endpoint or transfer types:
Control Transfers: Typically used for command and status operations, control transfers are the only transfer type with a defined USB format.
Bulk Transfers: Bulk transfers are best suited for large amounts of data delivered in bursts such as file transfers to/from a storage device or the captured packet data from Cynthion to the control host.
Interrupt Transfers: Interrupt transfers are a bit of a misnomer as the host needs to continuously poll the device to check if an interrupt has occurred but the principle is the same. Commonly used for peripherals that generate input events such as a keyboard or mouse.
Isochronous Transfers: Finally, isochronous transfers occur continuously with a fixed periodicity. Suited for time-sensitive information such as video or audio streams they do not offer any guarantees on delivery. If a packet or frame is dropped it’s is up to the host driver to decide on how to best handle it.
By default all LUNA devices have a default implementation for two endpoints: An OUT Control Endpoint and an IN Control endpoint. These endpoints are used by the host to enumerate the device but they can also be extended to support various other class or custom vendor requests.
We’ll start by extending our control endpoints to support two vendor requests: One to set the state of the Cynthion FPGA LEDs and another to get the state of the Cynthion USER BUTTON.
Extend Default Control Endpoints
To implement vendor requests, begin by adding a VendorRequestHandler to our device’s control endpoint:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5from luna.gateware.usb.request.windows import (
6 MicrosoftOS10DescriptorCollection,
7 MicrosoftOS10RequestHandler,
8)
9from usb_protocol.emitters.descriptors.standard import get_string_descriptor
10from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
11
12from luna.gateware.stream.generator import StreamSerializer
13from luna.gateware.usb.request.control import ControlRequestHandler
14from luna.gateware.usb.usb2.transfer import USBInStreamInterface
15
16VENDOR_ID = 0x1209 # https://pid.codes/1209/
17PRODUCT_ID = 0x0001
18
19class VendorRequestHandler(ControlRequestHandler):
20 VENDOR_SET_FPGA_LEDS = 0x01
21 VENDOR_GET_USER_BUTTON = 0x02
22
23 def elaborate(self, platform):
24 m = Module()
25
26 # shortcuts
27 interface: RequestHandlerInterface = self.interface
28 setup: SetupPacket = self.interface.setup
29
30 # get a reference to the FPGA LEDs and USER button
31 fpga_leds = Cat(platform.request("led", i).o for i in range(6))
32 user_button = platform.request("button_user").i
33
34 # create a streamserializer for transmitting IN data back to the host
35 serializer = StreamSerializer(
36 domain = "usb",
37 stream_type = USBInStreamInterface,
38 data_length = 1,
39 max_length_width = 1,
40 )
41 m.submodules += serializer
42
43 return m
44
45class GatewareUSBDevice(Elaboratable):
46
47 ...
48
49 def elaborate(self, platform):
50 m = Module()
51
52 # configure cynthion's clocks and reset signals
53 m.submodules.car = platform.clock_domain_generator()
54
55 # request the physical interface for cynthion's TARGET C port
56 ulpi = platform.request("target_phy")
57
58 # create the USB device
59 m.submodules.usb = usb = USBDevice(bus=ulpi)
60
61 # create our standard descriptors and add them to the device's control endpoint
62 descriptors = self.create_standard_descriptors()
63 control_endpoint = usb.add_standard_control_endpoint(descriptors)
64
65 # add microsoft os 1.0 descriptors and request handler
66 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
67 msft_descriptors = MicrosoftOS10DescriptorCollection()
68 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
69 with c.Function() as f:
70 f.bFirstInterfaceNumber = 0
71 f.compatibleID = 'WINUSB'
72 with msft_descriptors.ExtendedPropertiesDescriptor() as d:
73 with d.Property() as p:
74 p.dwPropertyDataType = RegistryTypes.REG_SZ
75 p.PropertyName = "DeviceInterfaceGUID"
76 p.PropertyData = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
77 msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
78 control_endpoint.add_request_handler(msft_handler)
79
80 # add our vendor request handler
81 control_endpoint.add_request_handler(VendorRequestHandler())
82
83 # configure the device to connect by default when plugged into a host
84 m.d.comb += usb.connect.eq(1)
85
86 return m
Vendor requests are unique to a device and are identified by the 8-bit bRequest field of the control transfer setup packet. Here we’ve defined two id’s corresponding to setting the led states and getting the button state.
So far our VendorRequestHandler contains references to Cynthion’s FPGA LEDs and USER BUTTON, as well as a StreamSerializer we’ll be using to send data back to the host when it asks for the USER BUTTON status.
Implement Vendor Request Handlers
Let’s implement that functionality below:
1class VendorRequestHandler(ControlRequestHandler):
2 VENDOR_SET_FPGA_LEDS = 0x01
3 VENDOR_GET_USER_BUTTON = 0x02
4
5 def elaborate(self, platform):
6 m = Module()
7
8 # Shortcuts.
9 interface: RequestHandlerInterface = self.interface
10 setup: SetupPacket = self.interface.setup
11
12 # Grab a reference to the FPGA LEDs and USER button.
13 fpga_leds = Cat(platform.request("led", i).o for i in range(6))
14 user_button = platform.request("button_user").i
15
16 # Create a StreamSerializer for sending IN data back to the host
17 serializer = StreamSerializer(
18 domain = "usb",
19 stream_type = USBInStreamInterface,
20 data_length = 1,
21 max_length_width = 1,
22 )
23 m.submodules += serializer
24
25 # we've received a setup packet containing a vendor request.
26 with m.If(setup.type == USBRequestType.VENDOR):
27 # use a state machine to sequence our request handling
28 with m.FSM(domain="usb"):
29 with m.State("IDLE"):
30 with m.If(setup.received):
31 with m.Switch(setup.request):
32 with m.Case(self.VENDOR_SET_FPGA_LEDS):
33 m.next = "HANDLE_SET_FPGA_LEDS"
34 with m.Case(self.VENDOR_GET_USER_BUTTON):
35 m.next = "HANDLE_GET_USER_BUTTON"
36
37 with m.State("HANDLE_SET_FPGA_LEDS"):
38 # take ownership of the interface
39 m.d.comb += interface.claim.eq(1)
40
41 # if we have an active data byte, set the FPGA LEDs to the payload
42 with m.If(interface.rx.valid & interface.rx.next):
43 m.d.usb += fpga_leds.eq(interface.rx.payload[0:6])
44
45 # once the receive is complete, respond with an ACK
46 with m.If(interface.rx_ready_for_response):
47 m.d.comb += interface.handshakes_out.ack.eq(1)
48
49 # finally, once we reach the status stage, send a ZLP
50 with m.If(interface.status_requested):
51 m.d.comb += self.send_zlp()
52 m.next = "IDLE"
53
54 with m.State("HANDLE_GET_USER_BUTTON"):
55 # take ownership of the interface
56 m.d.comb += interface.claim.eq(1)
57
58 # write the state of the user button into a local data register
59 data = Signal(8)
60 m.d.comb += data[0].eq(user_button)
61
62 # transmit our data using a built-in handler function that
63 # automatically advances the FSM back to the 'IDLE' state on
64 # completion
65 self.handle_simple_data_request(m, serializer, data)
66
67 return m
When handling a control request in LUNA the first thing we look at is the setup.type field of the setup packet interface. We could check for other types such as USBRequestType.CLASS or USBRequestType.DEVICE if we wanted to implement handlers for them but, in this case, we’re only interested in vendor requests.
Next, we take ownership of the interface, in order to avoid conflicting with the standard, or other registered request handlers. Then we sequence the actual request handling with an Amaranth Finite State Machine, starting in the IDLE state.
While in IDLE we wait for the setup.received signal to go high and signal the arrival of a new control request. We then parse the setup.request field to identify the next state to advance our FSM to. (We could also use the other setup packet fields such as wValue and wIndex for dispatch or as arguments but for now we’re just intered in bRequest.)
We then implement two handlers, the first is HANDLE_SET_FPGA_LEDS, which needs to read the data sent with our OUT control request in order to set the fpga leds state.
Then the second, in HANDLE_GET_USER_BUTTON we will use one of the built-in LUNA helper function to respond to our IN control request with the data containing the state of the user button.
Test Control Endpoints
First, remember to build and upload the device gateware to your Cynthion with:
python ./gateware-usb-device.py
Then, open your test-gateware-usb-device.py script from the previous tutorials and add the following code to it:
1import usb1
2import time
3
4VENDOR_ID = 0x1209 # https://pid.codes/1209/
5PRODUCT_ID = 0x0001
6
7VENDOR_SET_FPGA_LEDS = 0x01
8VENDOR_GET_USER_BUTTON = 0x02
9
10# - list available usb devices ------------------------------------------------
11
12def list_available_usb_devices(context):
13 for device in context.getDeviceList():
14 try:
15 manufacturer = device.getManufacturer()
16 product = device.getProduct()
17 print(f"{device}: {manufacturer} - {product}")
18 except Exception as e:
19 print(f"{device}: {e}")
20
21
22# - wrappers for control requests ---------------------------------------------
23
24def set_fpga_leds(device_handle, led_state):
25 response = device_handle.controlWrite(
26 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
27 request = VENDOR_SET_FPGA_LEDS,
28 index = 0,
29 value = 0,
30 data = [led_state],
31 timeout = 1000,
32 )
33
34def get_user_button(device_handle):
35 response = device_handle.controlRead(
36 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
37 request = VENDOR_GET_USER_BUTTON,
38 index = 0,
39 value = 0,
40 length = 1,
41 timeout = 1000,
42 )
43 return response[0]
44
45
46# - test control endpoints ----------------------------------------------------
47
48def test_control_endpoints(device_handle):
49 led_counter = 0
50 last_button_state = False
51
52 while True:
53 # led counter
54 set_fpga_leds(device_handle, led_counter)
55 led_counter = (led_counter + 1) % 256
56
57 # reset led counter when the USER button is pressed
58 button_state = get_user_button(device_handle)
59 if button_state:
60 led_counter = 0
61
62 # print button state when it changes
63 if button_state != last_button_state:
64 print(f"USER button is: {'ON' if button_state else 'OFF' }")
65 last_button_state = button_state
66
67 # slow the loop down so we can see the counter change
68 time.sleep(0.1)
69
70
71# - main ----------------------------------------------------------------------
72
73if __name__ == "__main__":
74 with usb1.USBContext() as context:
75 # list available devices
76 list_available_usb_devices(context)
77
78 # get a device handle to our simple usb device
79 device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
80 if device_handle is None:
81 raise Exception("Device not found.")
82
83 # claim the device's interface
84 device_handle.claimInterface(0)
85
86 # pass the device handle to our control endpoint test
87 test_control_endpoints(device_handle)
Run the file with:
python ./test-gateware-usb-device.py
And, if all goes well you should see the FPGA LEDs on Cynthion counting in binary. If you press and release the USER button you should see the count reset back to zero and the following text in the terminal.
USER button is: ON
USER button is: OFF
Job done!
In the next part of the tutorial we’ll finish up by adding IN and OUT Bulk endpoints to our device.
Exercises
Add a vendor request to retrieve the current state of the FPGA LEDs.
Add a vendor request that will disconnect and then re-connect your device to the USB bus.
More information
Beyond Logic’s USB in a NutShell.
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
12from luna.gateware.usb.request.windows import (
13 MicrosoftOS10DescriptorCollection,
14 MicrosoftOS10RequestHandler,
15)
16from usb_protocol.emitters.descriptors.standard import get_string_descriptor
17from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
18
19from luna.gateware.stream.generator import StreamSerializer
20from luna.gateware.usb.request.control import ControlRequestHandler
21from luna.gateware.usb.request.interface import SetupPacket
22from luna.gateware.usb.usb2.request import RequestHandlerInterface
23from luna.gateware.usb.usb2.transfer import USBInStreamInterface
24from usb_protocol.types import USBRequestType
25
26VENDOR_ID = 0x1209 # https://pid.codes/1209/
27PRODUCT_ID = 0x0001
28
29class VendorRequestHandler(ControlRequestHandler):
30 VENDOR_SET_FPGA_LEDS = 0x01
31 VENDOR_GET_USER_BUTTON = 0x02
32
33 def elaborate(self, platform):
34 m = Module()
35
36 # shortcuts
37 interface: RequestHandlerInterface = self.interface
38 setup: SetupPacket = self.interface.setup
39
40 # get a reference to the FPGA LEDs and USER button
41 fpga_leds = Cat(platform.request("led", i).o for i in range(6))
42 user_button = platform.request("button_user").i
43
44 # create a streamserializer for transmitting IN data back to the host
45 serializer = StreamSerializer(
46 domain = "usb",
47 stream_type = USBInStreamInterface,
48 data_length = 1,
49 max_length_width = 1,
50 )
51 m.submodules += serializer
52
53 # we've received a setup packet containing a vendor request.
54 with m.If(setup.type == USBRequestType.VENDOR):
55 # use a state machine to sequence our request handling
56 with m.FSM(domain="usb"):
57 with m.State("IDLE"):
58 with m.If(setup.received):
59 with m.Switch(setup.request):
60 with m.Case(self.VENDOR_SET_FPGA_LEDS):
61 m.next = "HANDLE_SET_FPGA_LEDS"
62 with m.Case(self.VENDOR_GET_USER_BUTTON):
63 m.next = "HANDLE_GET_USER_BUTTON"
64
65 with m.State("HANDLE_SET_FPGA_LEDS"):
66 # take ownership of the interface
67 m.d.comb += interface.claim.eq(1)
68
69 # if we have an active data byte, set the FPGA LEDs to the payload
70 with m.If(interface.rx.valid & interface.rx.next):
71 m.d.usb += fpga_leds.eq(interface.rx.payload[0:6])
72
73 # once the receive is complete, respond with an ACK
74 with m.If(interface.rx_ready_for_response):
75 m.d.comb += interface.handshakes_out.ack.eq(1)
76
77 # finally, once we reach the status stage, send a ZLP
78 with m.If(interface.status_requested):
79 m.d.comb += self.send_zlp()
80 m.next = "IDLE"
81
82 with m.State("HANDLE_GET_USER_BUTTON"):
83 # take ownership of the interface
84 m.d.comb += interface.claim.eq(1)
85
86 # write the state of the user button into a local data register
87 data = Signal(8)
88 m.d.comb += data[0].eq(user_button)
89
90 # transmit our data using a built-in handler function that
91 # automatically advances the FSM back to the 'IDLE' state on
92 # completion
93 self.handle_simple_data_request(m, serializer, data)
94
95 return m
96
97
98class GatewareUSBDevice(Elaboratable):
99 """ A simple USB device that can communicate with the host via vendor requests. """
100
101 def create_standard_descriptors(self):
102 """ Create the USB descriptors for the device. """
103
104 descriptors = DeviceDescriptorCollection()
105
106 # all USB devices have a single device descriptor
107 with descriptors.DeviceDescriptor() as d:
108 d.idVendor = VENDOR_ID
109 d.idProduct = PRODUCT_ID
110 d.iManufacturer = "Cynthion Project"
111 d.iProduct = "Gateware USB Device"
112
113 d.bNumConfigurations = 1
114
115 # and at least one configuration descriptor
116 with descriptors.ConfigurationDescriptor() as c:
117
118 # with at least one interface descriptor
119 with c.InterfaceDescriptor() as i:
120 i.bInterfaceNumber = 0
121
122 # interfaces also need endpoints to do anything useful
123 # but we'll add those later!
124
125 return descriptors
126
127 def elaborate(self, platform):
128 m = Module()
129
130 # configure cynthion's clocks and reset signals
131 m.submodules.car = platform.clock_domain_generator()
132
133 # request the physical interface for cynthion's TARGET C port
134 ulpi = platform.request("target_phy")
135
136 # create the USB device
137 m.submodules.usb = usb = USBDevice(bus=ulpi)
138
139 # create our standard descriptors and add them to the device's control endpoint
140 descriptors = self.create_standard_descriptors()
141 control_endpoint = usb.add_standard_control_endpoint(descriptors)
142
143 # add microsoft os 1.0 descriptors and request handler
144 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
145 msft_descriptors = MicrosoftOS10DescriptorCollection()
146 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
147 with c.Function() as f:
148 f.bFirstInterfaceNumber = 0
149 f.compatibleID = 'WINUSB'
150 with msft_descriptors.ExtendedPropertiesDescriptor() as d:
151 with d.Property() as p:
152 p.dwPropertyDataType = RegistryTypes.REG_SZ
153 p.PropertyName = "DeviceInterfaceGUID"
154 p.PropertyData = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
155 msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
156 control_endpoint.add_request_handler(msft_handler)
157
158 # add the vendor request handler
159 control_endpoint.add_request_handler(VendorRequestHandler())
160
161 # configure the device to connect by default when plugged into a host
162 m.d.comb += usb.connect.eq(1)
163
164 return m
165
166
167if __name__ == "__main__":
168 from luna import top_level_cli
169 top_level_cli(GatewareUSBDevice)
1import usb1
2import time
3
4VENDOR_ID = 0x1209 # https://pid.codes/1209/
5PRODUCT_ID = 0x0001
6
7VENDOR_SET_FPGA_LEDS = 0x01
8VENDOR_GET_USER_BUTTON = 0x02
9
10# - list available usb devices ------------------------------------------------
11
12def list_available_usb_devices(context):
13 for device in context.getDeviceList():
14 try:
15 manufacturer = device.getManufacturer()
16 product = device.getProduct()
17 print(f"{device}: {manufacturer} - {product}")
18 except Exception as e:
19 print(f"{device}: {e}")
20
21
22# - wrappers for control requests ---------------------------------------------
23
24def set_fpga_leds(device_handle, led_state):
25 response = device_handle.controlWrite(
26 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
27 request = VENDOR_SET_FPGA_LEDS,
28 index = 0,
29 value = 0,
30 data = [led_state],
31 timeout = 1000,
32 )
33
34def get_user_button(device_handle):
35 response = device_handle.controlRead(
36 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
37 request = VENDOR_GET_USER_BUTTON,
38 index = 0,
39 value = 0,
40 length = 1,
41 timeout = 1000,
42 )
43 return response[0]
44
45
46# - test control endpoints ----------------------------------------------------
47
48def test_control_endpoints(device_handle):
49 led_counter = 0
50 last_button_state = False
51
52 while True:
53 # led counter
54 set_fpga_leds(device_handle, led_counter)
55 led_counter = (led_counter + 1) % 256
56
57 # reset led counter when the USER button is pressed
58 button_state = get_user_button(device_handle)
59 if button_state:
60 led_counter = 0
61
62 # print button state when it changes
63 if button_state != last_button_state:
64 print(f"USER button is: {'ON' if button_state else 'OFF' }")
65 last_button_state = button_state
66
67 # slow the loop down so we can see the counter change
68 time.sleep(0.1)
69
70
71# - main ----------------------------------------------------------------------
72
73if __name__ == "__main__":
74 with usb1.USBContext() as context:
75 # list available devices
76 list_available_usb_devices(context)
77
78 # get a device handle to our simple usb device
79 device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
80 if device_handle is None:
81 raise Exception("Device not found.")
82
83 # claim the device's interface
84 device_handle.claimInterface(0)
85
86 # pass the device handle to our control endpoint test
87 test_control_endpoints(device_handle)
USB Gateware: Part 4 - Bulk Transfers
This series of tutorial walks through the process of implementing a complete USB device with Cynthion and LUNA:
USB Gateware: Part 4 - Bulk Transfers (This tutorial)
The goal of this tutorial is to define Bulk Endpoints for the device we created in Part 3 that will allow us to efficiently perform larger data transfers than those allowed by Control Transfers.
Prerequisites
Complete the USB Gateware: Part 1 - Enumeration tutorial.
Complete the USB Gateware: Part 2 - WCID Descriptors tutorial. (Optional, required for Windows support)
Complete the USB Gateware: Part 3 - Control Transfers tutorial.
Add Bulk Endpoints
While Control transfers are well suited for command and status operations they are not the best way to exchange large quantities of data. Control transfers have high per-packet protocol overhead and can only transfer packets of 8 bytes on low speed (1.5Mbps) devices and 64 bytes on full (12Mbps) and high (512Mbps) speed devices.
On the other hand, Bulk transfers support a packet size of up to 512 bytes on high speed devices and do not require any protocol overhead.
In the first section we’ll begin by updating our device’s descriptors so it can inform the host that it has bulk endpoints available.
Update Device Descriptors
Open gateware-usb-device.py and add the highlighted lines:
1from amaranth import *
2from luna.usb2 import USBDevice
3from usb_protocol.emitters import DeviceDescriptorCollection
4
5from luna.gateware.usb.request.windows import (
6 MicrosoftOS10DescriptorCollection,
7 MicrosoftOS10RequestHandler,
8)
9from usb_protocol.emitters.descriptors.standard import get_string_descriptor
10from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
11
12from luna.gateware.stream.generator import StreamSerializer
13from luna.gateware.usb.request.control import ControlRequestHandler
14from luna.gateware.usb.request.interface import SetupPacket
15from luna.gateware.usb.usb2.request import RequestHandlerInterface
16from luna.gateware.usb.usb2.transfer import USBInStreamInterface
17from usb_protocol.types import USBRequestType
18
19from luna.usb2 import (
20 USBStreamInEndpoint,
21 USBStreamOutEndpoint,
22)
23from usb_protocol.types import (
24 USBDirection,
25 USBTransferType,
26)
27
28VENDOR_ID = 0x1209 # https://pid.codes/1209/
29PRODUCT_ID = 0x0001
30
31MAX_PACKET_SIZE = 512
32
33class VendorRequestHandler(ControlRequestHandler):
34 ...
35
36class GatewareUSBDevice(Elaboratable):
37 def create_standard_descriptors(self):
38 descriptors = DeviceDescriptorCollection()
39
40 with descriptors.DeviceDescriptor() as d:
41 d.idVendor = VENDOR_ID
42 d.idProduct = PRODUCT_ID
43 d.iManufacturer = "Cynthion Project"
44 d.iProduct = "Gateware USB Device"
45 d.bNumConfigurations = 1
46
47 with descriptors.ConfigurationDescriptor() as c:
48 with c.InterfaceDescriptor() as i:
49 i.bInterfaceNumber = 0
50 # EP 0x01 OUT - receives bulk data from the host
51 with i.EndpointDescriptor() as e:
52 e.bEndpointAddress = USBDirection.OUT.to_endpoint_address(0x01)
53 e.bmAttributes = USBTransferType.BULK
54 e.wMaxPacketSize = MAX_PACKET_SIZE
55 # EP 0x82 IN - transmits bulk data to the host
56 with i.EndpointDescriptor() as e:
57 e.bEndpointAddress = USBDirection.IN.to_endpoint_address(0x02)
58 e.bmAttributes = USBTransferType.BULK
59 e.wMaxPacketSize = MAX_PACKET_SIZE
60
61 return descriptors
62
63 def elaborate(self, platform):
64 ...
This adds two endpoint descriptors to our default interface, each of type USBTransferType.BULK and with a MAX_PACKET_SIZE of 512. Where the endpoints differ is in their endpoint address. USB endpoint descriptors encode their direction in an 8 bit endpoint address. The first four bits encode the endpoint number, the next three bits are reserved and set to zero and the final bit encodes the direction; 0 for OUT and 1 for IN.
This means that an OUT endpoint number of 0x01 encodes to an endpoint address of 0x01 while an IN endpoint number of 0x02 encodes to the address 0x82. (0b0000_0010 + 0b1000_0000 = 0b1000_0010 = 0x82)
Add USB Stream Endpoints
Once our endpoint descriptors have been added to our device configuration we will need some gateware that will be able to respond to USB requests from the host and allow us to receive and transmit data.
LUNA provides the USBStreamOutEndpoint and USBStreamInEndpoint components which conform to the Amaranth Data streams interface. Simply put, streams provide a uniform mechanism for unidirectional exchange of arbitrary data between gateware components.
1...
2
3class GatewareUSBDevice(Elaboratable):
4 def create_standard_descriptors(self):
5 ...
6
7 def elaborate(self, platform):
8 ...
9
10 # add the vendor request handler
11 control_endpoint.add_request_handler(VendorRequestHandler())
12
13 # create and add stream endpoints for our device's Bulk IN & OUT endpoints
14 ep_out = USBStreamOutEndpoint(
15 endpoint_number=0x01, # (EP 0x01)
16 max_packet_size=MAX_PACKET_SIZE,
17 )
18 usb.add_endpoint(ep_out)
19 ep_in = USBStreamInEndpoint(
20 endpoint_number=0x02, # (EP 0x82)
21 max_packet_size=MAX_PACKET_SIZE
22 )
23 usb.add_endpoint(ep_in)
24
25 # configure the device to connect by default when plugged into a host
26 m.d.comb += usb.connect.eq(1)
27
28 return m
We now have two streaming endpoints that are able to receive and transmit data between any other module that supports the Amaranth Data streams interface.
However, before we can stream any data across these endpoints we first need to come up with a USB Function for each of our endpoints. In other words, what does our device actually _do_?
This could be any data source and/or sink but for the purposes of this tutorial let’s create a simple loopback function that will accept a bulk OUT request from the host and then return the request payload when the host makes a bulk IN request.
Define Endpoint Functions
A simple implementation for our device’s endpoint functions could be a simple FIFO (First In First Out) queue with enough space to hold the 512 bytes of a bulk transfer.
Using the OUT endpoint we could then transmit a stream of data from the host to Cynthion and write it into the FIFO. Then, when we transmit a request from the host to the IN endpoint we can stream the previously queued data back to the host.
We’re only working in a single clock-domain so we can use a SyncFIFO from the Amaranth standard library for our queue:
1from amaranth import *
2from amaranth.lib.fifo import SyncFIFO
3from luna.usb2 import USBDevice
4from usb_protocol.emitters import DeviceDescriptorCollection
5...
6
7class VendorRequestHandler(ControlRequestHandler):
8 ...
9
10class GatewareUSBDevice(Elaboratable):
11 ...
12
13 def elaborate(self, platform):
14 ...
15
16 # create and add stream endpoints for our device's Bulk IN & OUT endpoints
17 ep_out = USBStreamOutEndpoint(
18 endpoint_number=0x01, # (EP 0x01)
19 max_packet_size=MAX_PACKET_SIZE,
20 )
21 usb.add_endpoint(ep_out)
22 ep_in = USBStreamInEndpoint(
23 endpoint_number=0x02, # (EP 0x82)
24 max_packet_size=MAX_PACKET_SIZE
25 )
26 usb.add_endpoint(ep_in)
27
28 # create a FIFO queue we'll connect to the stream interfaces of our
29 # IN & OUT endpoints
30 m.submodules.fifo = fifo = DomainRenamer("usb")(
31 SyncFIFO(width=8, depth=MAX_PACKET_SIZE)
32 )
33
34 # connect our Bulk OUT endpoint's stream interface to the FIFO's write port
35 stream_out = ep_out.stream
36 m.d.comb += fifo.w_data.eq(stream_out.payload)
37 m.d.comb += fifo.w_en.eq(stream_out.valid)
38 m.d.comb += stream_out.ready.eq(fifo.w_rdy)
39
40 # connect our Bulk IN endpoint's stream interface to the FIFO's read port
41 stream_in = ep_in.stream
42 m.d.comb += stream_in.payload.eq(fifo.r_data)
43 m.d.comb += stream_in.valid.eq(fifo.r_rdy)
44 m.d.comb += fifo.r_en.eq(stream_in.ready)
45
46 # configure the device to connect by default when plugged into a host
47 m.d.comb += usb.connect.eq(1)
48
49 return m
Note
Something to take note off is the use of an Amaranth DomainRenamer component to wrap SyncFIFO in the following lines:
m.submodules.fifo = fifo = DomainRenamer("usb")(
fifo.SyncFIFO(width=8, depth=MAX_PACKET_SIZE)
)
Any moderately complex FPGA hardware & gateware design will usually consist of multiple clock-domains running at different frequencies. Cynthion, for example, has three clock domains:
sync- the default clock domain, running at 120 MHz.usb- the clock domain for USB components and gateware, running at 60 MHz.fast- a fast clock domain used for the HyperRAM, running at 240 MHz.
Because our designs so far have all been interfacing with Cynthion’s USB components we’ve only needed to use the usb clock domain. However, reusable Amaranth components such as SyncFIFO are usually implemented using the default sync domain. We therefore need to be able to rename its clock domain to match the domain used in our design. This is what DomainRenamer does.
And that’s it, we’ve defined our endpoint functions! Let’s try it out.
Test Bulk Endpoints
Open up test-gateware-usb-device.py and add the following code to it:
1import usb1
2import time
3import random
4
5VENDOR_ID = 0x1209 # https://pid.codes/1209/
6PRODUCT_ID = 0x0001
7
8VENDOR_SET_FPGA_LEDS = 0x01
9VENDOR_GET_USER_BUTTON = 0x02
10
11MAX_PACKET_SIZE = 512
12
13# - list available usb devices ------------------------------------------------
14
15def list_available_usb_devices(context):
16 for device in context.getDeviceList():
17 try:
18 manufacturer = device.getManufacturer()
19 product = device.getProduct()
20 print(f"{device}: {manufacturer} - {product}")
21 except Exception as e:
22 print(f"{device}: {e}")
23
24
25# - wrappers for control requests ---------------------------------------------
26
27def set_fpga_leds(device_handle, led_state):
28 response = device_handle.controlWrite(
29 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
30 request = VENDOR_SET_FPGA_LEDS,
31 index = 0,
32 value = 0,
33 data = [led_state],
34 timeout = 1000,
35 )
36
37def get_user_button(device_handle):
38 response = device_handle.controlRead(
39 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
40 request = VENDOR_GET_USER_BUTTON,
41 index = 0,
42 value = 0,
43 length = 1,
44 timeout = 1000,
45 )
46 return response[0]
47
48
49# - test control endpoints ----------------------------------------------------
50
51def test_control_endpoints(device_handle):
52 led_counter = 0
53 last_button_state = False
54
55 while True:
56 # led counter
57 set_fpga_leds(device_handle, led_counter)
58 led_counter = (led_counter + 1) % 256
59
60 # reset led counter when the USER button is pressed
61 button_state = get_user_button(device_handle)
62 if button_state:
63 led_counter = 0
64
65 # print button state when it changes
66 if button_state != last_button_state:
67 print(f"USER button is: {'ON' if button_state else 'OFF' }")
68 last_button_state = button_state
69
70 # slow the loop down so we can see the counter change
71 time.sleep(0.1)
72
73
74# - wrappers for bulk requests ------------------------------------------------
75
76def bulk_out_transfer(device_handle, data):
77 response = device_handle.bulkWrite(
78 endpoint = 0x01,
79 data = data,
80 timeout = 1000,
81 )
82 return response
83
84def bulk_in_transfer(device_handle, length):
85 response = device_handle.bulkRead(
86 endpoint = 0x02,
87 length = length,
88 timeout = 1000,
89 )
90 return response
91
92
93# - test bulk endpoints -------------------------------------------------------
94
95def test_bulk_endpoints(device_handle):
96 # bulk_out - write a list of random numbers to memory
97 data = list([random.randint(0, 255) for _ in range(MAX_PACKET_SIZE)])
98 response = bulk_out_transfer(device_handle, data)
99 print(f"OUT endpoint transmitted {response} bytes: {data[0:4]} ... {data[-4:]}")
100
101 # bulk_in - retrieve the contents of our memory
102 response = list(bulk_in_transfer(device_handle, MAX_PACKET_SIZE))
103 print(f"IN endpoint received {len(response)} bytes: {response[0:4]} ... {response[-4:]}")
104
105 # check that the stored data matches the sent data
106 assert(data == list(response))
107
108
109# - main ----------------------------------------------------------------------
110
111if __name__ == "__main__":
112 with usb1.USBContext() as context:
113 # list available devices
114 list_available_usb_devices(context)
115
116 # get a device handle to our simple usb device
117 device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
118 if device_handle is None:
119 raise Exception("Device not found.")
120
121 # claim the device's interface
122 device_handle.claimInterface(0)
123
124 # pass the device handle to our bulk endpoint test
125 test_bulk_endpoints(device_handle)
126
127 # pass the device handle to our control endpoint test
128 test_control_endpoints(device_handle)
Run the file with:
python ./test-gateware-usb-device.py
Assuming everything is going to plan you should see two matching sets of random numbers:
OUT endpoint transmitted 512 bytes: [252, 107, 106, 56] ... [109, 175, 112, 126]
IN endpoint received 512 bytes: [252, 107, 106, 56] ... [109, 175, 112, 126]
Congratulations, if you made it this far then you’ve just finished building your first complete USB Gateware Device with custom vendor request control and bulk data transfer!
Exercises
Create a benchmark to test the speed of your device when doing Bulk IN and OUT transfers.
Move the device endpoint to
aux_phyand attempt to capture the packets exchanged between a device plugged into a host via thetarget_phyport.
More information
Beyond Logic’s USB in a NutShell.
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 amaranth.lib.fifo import SyncFIFO
10from luna.usb2 import USBDevice
11from usb_protocol.emitters import DeviceDescriptorCollection
12
13from luna.gateware.usb.request.windows import (
14 MicrosoftOS10DescriptorCollection,
15 MicrosoftOS10RequestHandler,
16)
17from usb_protocol.emitters.descriptors.standard import get_string_descriptor
18from usb_protocol.types.descriptors.microsoft10 import RegistryTypes
19
20from luna.gateware.stream.generator import StreamSerializer
21from luna.gateware.usb.request.control import ControlRequestHandler
22from luna.gateware.usb.request.interface import SetupPacket
23from luna.gateware.usb.usb2.request import RequestHandlerInterface
24from luna.gateware.usb.usb2.transfer import USBInStreamInterface
25from usb_protocol.types import USBRequestType
26
27from luna.usb2 import USBStreamInEndpoint, USBStreamOutEndpoint
28from usb_protocol.types import USBDirection, USBTransferType
29
30VENDOR_ID = 0x1209 # https://pid.codes/1209/
31PRODUCT_ID = 0x0001
32
33MAX_PACKET_SIZE = 512
34
35class VendorRequestHandler(ControlRequestHandler):
36 VENDOR_SET_FPGA_LEDS = 0x01
37 VENDOR_GET_USER_BUTTON = 0x02
38
39 def elaborate(self, platform):
40 m = Module()
41
42 # shortcuts
43 interface: RequestHandlerInterface = self.interface
44 setup: SetupPacket = self.interface.setup
45
46 # get a reference to the FPGA LEDs and USER button
47 fpga_leds = Cat(platform.request("led", i).o for i in range(6))
48 user_button = platform.request("button_user").i
49
50 # create a streamserializer for transmitting IN data back to the host
51 serializer = StreamSerializer(
52 domain = "usb",
53 stream_type = USBInStreamInterface,
54 data_length = 1,
55 max_length_width = 1,
56 )
57 m.submodules += serializer
58
59 # we've received a setup packet containing a vendor request.
60 with m.If(setup.type == USBRequestType.VENDOR):
61 # use a state machine to sequence our request handling
62 with m.FSM(domain="usb"):
63 with m.State("IDLE"):
64 with m.If(setup.received):
65 with m.Switch(setup.request):
66 with m.Case(self.VENDOR_SET_FPGA_LEDS):
67 m.next = "HANDLE_SET_FPGA_LEDS"
68 with m.Case(self.VENDOR_GET_USER_BUTTON):
69 m.next = "HANDLE_GET_USER_BUTTON"
70
71 with m.State("HANDLE_SET_FPGA_LEDS"):
72 # take ownership of the interface
73 m.d.comb += interface.claim.eq(1)
74
75 # if we have an active data byte, set the FPGA LEDs to the payload
76 with m.If(interface.rx.valid & interface.rx.next):
77 m.d.usb += fpga_leds.eq(interface.rx.payload[0:6])
78
79 # once the receive is complete, respond with an ACK
80 with m.If(interface.rx_ready_for_response):
81 m.d.comb += interface.handshakes_out.ack.eq(1)
82
83 # finally, once we reach the status stage, send a ZLP
84 with m.If(interface.status_requested):
85 m.d.comb += self.send_zlp()
86 m.next = "IDLE"
87
88 with m.State("HANDLE_GET_USER_BUTTON"):
89 # take ownership of the interface
90 m.d.comb += interface.claim.eq(1)
91
92 # write the state of the user button into a local data register
93 data = Signal(8)
94 m.d.comb += data[0].eq(user_button)
95
96 # transmit our data using a built-in handler function that
97 # automatically advances the FSM back to the 'IDLE' state on
98 # completion
99 self.handle_simple_data_request(m, serializer, data)
100
101 return m
102
103
104class GatewareUSBDevice(Elaboratable):
105 """ A simple USB device that can communicate with the host via vendor and bulk requests. """
106
107 def create_standard_descriptors(self):
108 """ Create the USB descriptors for the device. """
109
110 descriptors = DeviceDescriptorCollection()
111
112 # all USB devices have a single device descriptor
113 with descriptors.DeviceDescriptor() as d:
114 d.idVendor = VENDOR_ID
115 d.idProduct = PRODUCT_ID
116 d.iManufacturer = "Cynthion Project"
117 d.iProduct = "Gateware USB Device"
118
119 d.bNumConfigurations = 1
120
121 # and at least one configuration descriptor
122 with descriptors.ConfigurationDescriptor() as c:
123
124 # with at least one interface descriptor
125 with c.InterfaceDescriptor() as i:
126 i.bInterfaceNumber = 0
127
128 # an endpoint for receiving bulk data from the host
129 with i.EndpointDescriptor() as e:
130 e.bEndpointAddress = USBDirection.OUT.to_endpoint_address(0x01) # EP 0x01 OUT
131 e.bmAttributes = USBTransferType.BULK
132 e.wMaxPacketSize = MAX_PACKET_SIZE
133
134 # and an endpoint for transmitting bulk data to the host
135 with i.EndpointDescriptor() as e:
136 e.bEndpointAddress = USBDirection.IN.to_endpoint_address(0x02) # EP 0x82 IN
137 e.bmAttributes = USBTransferType.BULK
138 e.wMaxPacketSize = MAX_PACKET_SIZE
139
140 return descriptors
141
142 def elaborate(self, platform):
143 m = Module()
144
145 # configure cynthion's clocks and reset signals
146 m.submodules.car = platform.clock_domain_generator()
147
148 # request the physical interface for cynthion's TARGET C port
149 ulpi = platform.request("target_phy")
150
151 # create the USB device
152 m.submodules.usb = usb = USBDevice(bus=ulpi)
153
154 # create our standard descriptors and add them to the device's control endpoint
155 descriptors = self.create_standard_descriptors()
156 control_endpoint = usb.add_standard_control_endpoint(descriptors)
157
158 # add microsoft os 1.0 descriptors and request handler
159 descriptors.add_descriptor(get_string_descriptor("MSFT100\xee"), index=0xee)
160 msft_descriptors = MicrosoftOS10DescriptorCollection()
161 with msft_descriptors.ExtendedCompatIDDescriptor() as c:
162 with c.Function() as f:
163 f.bFirstInterfaceNumber = 0
164 f.compatibleID = 'WINUSB'
165 with msft_descriptors.ExtendedPropertiesDescriptor() as d:
166 with d.Property() as p:
167 p.dwPropertyDataType = RegistryTypes.REG_SZ
168 p.PropertyName = "DeviceInterfaceGUID"
169 p.PropertyData = "{88bae032-5a81-49f0-bc3d-a4ff138216d6}"
170 msft_handler = MicrosoftOS10RequestHandler(msft_descriptors, request_code=0xee)
171 control_endpoint.add_request_handler(msft_handler)
172
173 # add the vendor request handler
174 control_endpoint.add_request_handler(VendorRequestHandler())
175
176 # create and add stream endpoints for our device's Bulk IN & OUT endpoints
177 ep_out = USBStreamOutEndpoint(
178 endpoint_number=0x01, # (EP 0x01)
179 max_packet_size=MAX_PACKET_SIZE,
180 )
181 usb.add_endpoint(ep_out)
182 ep_in = USBStreamInEndpoint(
183 endpoint_number=0x02, # (EP 0x82)
184 max_packet_size=MAX_PACKET_SIZE
185 )
186 usb.add_endpoint(ep_in)
187
188 # create a FIFO queue we'll connect to the stream interfaces of our
189 # IN & OUT endpoints
190 m.submodules.fifo = fifo = DomainRenamer("usb")(
191 SyncFIFO(width=8, depth=MAX_PACKET_SIZE)
192 )
193
194 # connect our Bulk OUT endpoint's stream interface to the FIFO's write port
195 stream_out = ep_out.stream
196 m.d.comb += fifo.w_data.eq(stream_out.payload)
197 m.d.comb += fifo.w_en.eq(stream_out.valid)
198 m.d.comb += stream_out.ready.eq(fifo.w_rdy)
199
200 # connect our Bulk IN endpoint's stream interface to the FIFO's read port
201 stream_in = ep_in.stream
202 m.d.comb += stream_in.payload.eq(fifo.r_data)
203 m.d.comb += stream_in.valid.eq(fifo.r_rdy)
204 m.d.comb += fifo.r_en.eq(stream_in.ready)
205
206 # configure the device to connect by default when plugged into a host
207 m.d.comb += usb.connect.eq(1)
208
209 return m
210
211
212if __name__ == "__main__":
213 from luna import top_level_cli
214 top_level_cli(GatewareUSBDevice)
1import usb1
2import time
3import random
4
5VENDOR_ID = 0x1209 # https://pid.codes/1209/
6PRODUCT_ID = 0x0001
7
8VENDOR_SET_FPGA_LEDS = 0x01
9VENDOR_GET_USER_BUTTON = 0x02
10
11MAX_PACKET_SIZE = 512
12
13# - list available usb devices ------------------------------------------------
14
15def list_available_usb_devices(context):
16 for device in context.getDeviceList():
17 try:
18 manufacturer = device.getManufacturer()
19 product = device.getProduct()
20 print(f"{device}: {manufacturer} - {product}")
21 except Exception as e:
22 print(f"{device}: {e}")
23
24
25# - wrappers for control requests ---------------------------------------------
26
27def set_fpga_leds(device_handle, led_state):
28 response = device_handle.controlWrite(
29 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE,
30 request = VENDOR_SET_FPGA_LEDS,
31 index = 0,
32 value = 0,
33 data = [led_state],
34 timeout = 1000,
35 )
36
37def get_user_button(device_handle):
38 response = device_handle.controlRead(
39 request_type = usb1.TYPE_VENDOR | usb1.RECIPIENT_DEVICE | usb1.ENDPOINT_OUT,
40 request = VENDOR_GET_USER_BUTTON,
41 index = 0,
42 value = 0,
43 length = 1,
44 timeout = 1000,
45 )
46 return response[0]
47
48
49# - test control endpoints ----------------------------------------------------
50
51def test_control_endpoints(device_handle):
52 led_counter = 0
53 last_button_state = False
54
55 while True:
56 # led counter
57 set_fpga_leds(device_handle, led_counter)
58 led_counter = (led_counter + 1) % 256
59
60 # reset led counter when the USER button is pressed
61 button_state = get_user_button(device_handle)
62 if button_state:
63 led_counter = 0
64
65 # print button state when it changes
66 if button_state != last_button_state:
67 print(f"USER button is: {'ON' if button_state else 'OFF' }")
68 last_button_state = button_state
69
70 # slow the loop down so we can see the counter change
71 time.sleep(0.1)
72
73
74# - wrappers for bulk requests ------------------------------------------------
75
76def bulk_out_transfer(device_handle, data):
77 response = device_handle.bulkWrite(
78 endpoint = 0x01,
79 data = data,
80 timeout = 1000,
81 )
82 return response
83
84def bulk_in_transfer(device_handle, length):
85 response = device_handle.bulkRead(
86 endpoint = 0x02,
87 length = length,
88 timeout = 1000,
89 )
90 return response
91
92
93# - test bulk endpoints -------------------------------------------------------
94
95def test_bulk_endpoints(device_handle):
96 # bulk_out - write a list of random numbers to memory
97 data = list([random.randint(0, 255) for _ in range(MAX_PACKET_SIZE)])
98 response = bulk_out_transfer(device_handle, data)
99 print(f"OUT endpoint transmitted {response} bytes: {data[0:4]} ... {data[-4:]}")
100
101 # bulk_in - retrieve the contents of our memory
102 response = list(bulk_in_transfer(device_handle, MAX_PACKET_SIZE))
103 print(f"IN endpoint received {len(response)} bytes: {response[0:4]} ... {response[-4:]}")
104
105 # check that the stored data matches the sent data
106 assert(data == list(response))
107
108
109# - main ----------------------------------------------------------------------
110
111if __name__ == "__main__":
112 with usb1.USBContext() as context:
113 # list available devices
114 list_available_usb_devices(context)
115
116 # get a device handle to our simple usb device
117 device_handle = context.openByVendorIDAndProductID(VENDOR_ID, PRODUCT_ID)
118 if device_handle is None:
119 raise Exception("Device not found.")
120
121 # claim the device's interface
122 device_handle.claimInterface(0)
123
124 # pass the device handle to our bulk endpoint test
125 test_bulk_endpoints(device_handle)
126
127 # pass the device handle to our control endpoint test
128 test_control_endpoints(device_handle)
Introduction
Cynthion Hardware
Device Overview
Top View
A-E - Five status LEDs managed by the debug microcontroller.
A - Power Indicator.
B - FPGA is online.
C - FPGA has requested control of the CONTROL port.
D - FPGA has control of the CONTROL port.
E - Reserved for future use.
0-5 - Six USER LEDs managed by the FPGA.
Left View
PROGRAM - Press this button to return control of the CONTROL port to the debug microcontroller and hold the FPGA in an unconfigured state.
Recovery mode: Press this button during power-on to invoke the Saturn-V bootloader on the CONTROL port.
CONTROL - Primary USB connector used by the host computer to control Cynthion.
USER - A user-assignable button that can be used in your own designs.
AUX - An auxiliary USB connection that can be used in your own designs.
Right View
TARGET C - USB Type-C connector for Packetry traffic capture and Facedancer device emulation.
TARGET A - USB Type-A connector shared with the TARGET C connector.
RESET - Press this button to reset Cynthion’s debug microcontroller and reconfigure the FPGA from flash.
Front View
A & B - Two Digilent Pmod™ Compatible I/O connectors for a total of 16 high-speed FPGA user IOs.
B can also be configured to act as a serial port and JTAG connector for debugging SoC designs:
1 - SERIAL RX
2 - SERIAL TX
7 - JTAG TMS
8 - JTAG TDI
9 - JTAG TDO
10 - JTAG TCK
Bottom View
Self-made Hardware Bringup
This guide is intended to help you bring up a Cynthion board you’ve built yourself. If you’ve received your board from Great Scott Gadgets, it should already be set up, and you shouldn’t need to follow these steps.
Prerequisites
A Cynthion board with a populated Debug Controller microprocessor. This is the SAMD microcontroller located in the Debug section at the bottom of the board. When powering the board, the test points should have the marked voltages. The FPGA LEDs might be dimly lit.
A programmer capable of uploading firmware via SWD. Examples include the Black Magic Probe; the Segger J-Link, and many OpenOCD compatible boards.
A toolchain capable of building binaries for Cortex-M0 processors, such as the GNU Arm Embedded toolchain. If you’re using Linux or macOS, you’ll likely want to fetch this using a package manager; a suitable toolchain may be called something like
arm-none-eabi-gcc.A DFU programming utility, such as dfu-util.
Bring-up Process
The high-level process for bringing up your board is as follows:
Compile and upload the Saturn-V bootloader, which allows Debug Controller to program itself.
Compile and upload the Apollo Debug Controller firmware, which allows FPGA configuration & flashing; and provides debug interfaces for working with the FPGA.
Install the
cynthiontools, and run through the self-test procedures to validate that your board is working.
Build/upload Saturn-V
The “recovery mode (RVM)” bootloader for Cynthion boards is named Saturn-V; as it’s the first stage in “getting to Cynthion”. The bootloader is located in in its own repository
You can clone the bootloader using git:
$ git clone https://github.com/greatscottgadgets/saturn-v
Build the DFU bootloader by invoking make. An example invocation
for modern Cynthion hardware might look like:
$ cd saturn-v
$ make
If you’re building a board that predates r0.3 hardware, you’ll need to specify the board you’re building for:
$ cd saturn-v
$ make BOARD=luna_d21
The build should yield two useful build products: bootloader.elf and
bootloader.bin; your SWD programmer will likely consume one of these
two files.
Next, connect your SWD programmer to the header labeled uC, and
upload bootloader image. You can use both the ports labelled
Sideband and Main Host to power the board in this process.
If you’re using the Black Magic Probe, this might look like:
$ arm-none-eabi-gdb -nx --batch \
-ex 'target extended-remote /dev/ttyACM0' \
-ex 'monitor swdp_scan' \
-ex 'attach 1' \
-ex 'load' \
-ex 'kill' \
bootloader.elf
If you are using openocd, the process might look similar to the following (add the configuration file for your SWD adapter:
$ openocd -f openocd/scripts/target/at91samdXX.cfg
Open On-Chip Debugger 0.11.0-rc2
Licensed under GNU GPL v2
Info : Listening on port 4444 for telnet connections
Info : clock speed 400 kHz
Info : SWD DPIDR 0x0bc11477
Info : at91samd.cpu: hardware has 4 breakpoints, 2 watchpoints
Info : at91samd.cpu: external reset detected
$ nc localhost 4444
Open On-Chip Debugger
> targets
TargetName Type Endian TapName State
-- ------------------ ---------- ------ ------------------ ------------
0* at91samd.cpu cortex_m little at91samd.cpu reset
> at91samd chip-erase
chip erase started
> program Luna/saturn-v/bootloader.bin verify reset
target halted due to debug-request, current mode: Thread
xPSR: 0xf1000000 pc: 0xfffffffe msp: 0xfffffffc
** Programming Started **
SAMD MCU: SAMD21G18A (256KB Flash, 32KB RAM)
** Programming Finished **
** Verify Started **
** Verified OK **
** Resetting Target **
If your programmer works best with .bin files, be sure to upload the
bootloader.bin to the start of flash (address 0x00000000).
Once the bootloader is installed, you should see LED A blinking
rapidly. This is the indication that your board is in Recovery Mode
(RVM), and can be programmed via DFU.
You can verify that the board is DFU-programmable by running
dfu-util while connected to the USB port labelled Sideband:
$ dfu-util --list
dfu-util 0.9
Copyright 2005-2009 Weston Schmidt, Harald Welte and OpenMoko Inc.
Copyright 2010-2016 Tormod Volden and Stefan Schmidt
This program is Free Software and has ABSOLUTELY NO WARRANTY
Please report bugs to http://sourceforge.net/p/dfu-util/tickets/
Found DFU: [1d50:615c] ver=0000, devnum=22, cfg=1, intf=0, path="2-3.3.1.2", alt=1, name="SRAM"
Found DFU: [1d50:615c] ver=0000, devnum=22, cfg=1, intf=0, path="2-3.3.1.2", alt=0, name="Flash"
If your device shows up as a Cynthion board, congratulations! You’re ready to move on to the next step.
Optional: Bootloader Locking
Optionally, you can reversibly lock the bootloader region of the Debug Controller, preventing you from accidentally overwriting the bootloader. This is most useful for users developing code for the Debug Controller.
If you choose to lock the bootloader, you should lock the first 2KiB
of flash. Note that currently, the bootloader lock feature of Black
Magic Probe devices always locks 8KiB of flash; and thus cannot be
used for Cynthion.
Build/upload Apollo
The next bringup step is to upload the Apollo Debug Controller firmware, which will provide an easy way to interface with the board’s FPGA and any gateware running on it. The Apollo source is located in its own repository.
You can clone the bootloader using git:
$ git clone --recurse-submodules https://github.com/greatscottgadgets/apollo
You can build and run the firmware in one step by invoking
make. In order to ensure your firmware matches the hardware it’s
running on, you’ll need to provide board type and hardware revision
using the APOLLO_BOARD, BOARD_REVISION_MAJOR and
BOARD_REVISION_MINOR make variables.
The board’s hardware revision is printed on its silkscreen in a
r(MAJOR).(MINOR) format. Board r1.4 would have a
BOARD_REVISION_MAJOR=1 and a BOARD_REVISION_MINOR=4. If your
board’s revision ends in a +, do not include it in the revision
number.
An example invocation for a r1.4 board might be:
$ make APOLLO_BOARD=cynthion BOARD_REVISION_MAJOR=1 BOARD_REVISION_MINOR=4 dfu
Once programming is complete, LED’s A, B, C, D and E should all be on;
indicating that the Apollo firmware is idle.
You can also upload a firmware binary using dfu-util with:
$ dfu-util -d 1d50:615c -D firmware.bin
Running Self-Tests
The final step of bringup is to validate the functionality of your hardware. This is most easily accomplished by running Cynthion’s interactive self-test applet.
Before you can run the applet, you’ll need to have a working
cynthion development environment. See
Introduction to get your environment set up.
Next, we can check to make sure your Cynthion board is recognized by the
Cynthion toolchain. Running the apollo info command will list any
detected devices:
$ apollo info
Detected a Cynthion device!
Hardware: Cynthion r1.4
Serial number: <snip>
Once you’ve validated connectivity, you’re ready to try running the
cynthion selftest command.
$ cynthion selftest
INFO | __init__ | Building and uploading gateware to attached Cynthion r1.4...
INFO | __init__ | Upload complete.
INFO | selftest | Connected to onboard debugger; hardware revision r1.4 (s/n: <snip>).
INFO | selftest | Running tests...
Automated tests:
Debug module: ✓ PASSED
AUX PHY: ✓ PASSED
HyperRAM: ✓ PASSED
CONTROL PHY: ✓ PASSED
TARGET PHY: ✓ PASSED
Troubleshooting
Issue: some of the build files weren’t found; make produces a message like “ no rule to make target “.
Chances are, your clone of Apollo was pulled down without its
submodules. You can pull down the relevant submodules using git:
$ git submodule update --init --recursive
Issue: the ``apollo info`` command doesn’t see a connected board.
On Linux, this can be caused by a permissions issue. Check first for the
presence of your device using lsusb; if you see a device with the
VID/PID 1d50:615c, your board is present – and you likely have a
permissions issue. You’ll likely need to install permission-granting
udev rules.
The apollo command line utility
The cynthion distribution provides the apollo command-line utility,
that can be used to perform various simple functions useful in development;
including simple JTAG operations, SVF playback, manipulating the board’s flash,
and debug communications.
$ apollo
usage: apollo [-h] command ...
Apollo FPGA Configuration / Debug tool
positional arguments:
command
info Print device info.
jtag-scan Prints information about devices on the onboard JTAG chain.
flash-info Prints information about the FPGA's attached configuration
flash.
flash-erase Erases the contents of the FPGA's flash memory.
flash-program Programs the target bitstream onto the FPGA's configuration
flash.
flash-fast Programs a bitstream onto the FPGA's configuration flash using
a SPI bridge.
flash-read Reads the contents of the attached FPGA's configuration flash.
svf Plays a given SVF file over JTAG.
configure Uploads a bitstream to the device's FPGA over JTAG.
reconfigure Requests the attached ECP5 reconfigure itself from its SPI flash.
force-offline Forces the board's FPGA offline.
spi Sends the given list of bytes over debug-SPI, and returns the
response.
spi-inv Sends the given list of bytes over SPI with inverted CS.
spi-reg Reads or writes to a provided register over the debug-SPI.
jtag-spi Sends the given list of bytes over SPI-over-JTAG, and returns the
response.
jtag-reg Reads or writes to a provided register of JTAG-tunneled debug SPI.
leds Sets the specified pattern for the Debug LEDs.
optional arguments:
-h, --help show this help message and exit
Getting Help
Before asking for help with Cynthion, check to see if your question is answered in this documentation, or addressed in the Cynthion GitHub repository issues.
For assistance with Cynthion general use or development, please look at the issues on the GitHub project. This is the preferred place to ask questions so that others may locate the answer to your question in the future.
We invite you to join our community discussions on Discord. Note that while technical support requests are welcome here, we do not have support staff on duty at all times. Be sure to also submit an issue on GitHub if you’ve found a bug or if you want to ensure that your request will be tracked and not overlooked.
Cynthion Projects and Mentions
Have you done something cool with Cynthion or mentioned Cynthion in one of your papers or presentations? Email us at info@greatscottgadgets.com with a link to what you’ve done and we might post it here!
Safety Information
Warnings
This product shall only be connected to an external power supply rated at 5 V DC. Any power supply used with this product shall comply with relevant regulations and standards applicable in the country of intended use.
This product should be used in a shielded enclosure. Removing this product from its enclosure or using this product without a shielded enclosure may expose it to additional risks of damage. Extra care should be taken to prevent damage from electrostatic discharge and impact damage when this product is removed from its enclosure or used without a shielded enclosure.
This product should only be connected to another device that is rated to be used at the power level this device is configured to provide. Attaching this product to another device that is not rated to be used at the power level that this device is configured to provide may result in damage.
Instructions For Safe Use
Do not expose this product to water or moisture.
Do not expose this product to excessive heat while in use.
Do not place undue stress on any connector port.
Any equipment connected to Cynthion should comply with relevant standards for the country of use and be marked accordingly to ensure that safety and performance requirements are met.
Do not allow equipment connected to Cynthion to make contact with any internal component of Cynthion other than a connector.
A pass-through power supply connected to Cynthion must supply at least 5 V DC and no more than 20 V DC. The pass-through power supply must be rated for the current drawn by the pass-through device connected to Cynthion.
A device connected to Cynthion must be rated for 5 V DC or the pass-through power supply voltage and must draw no more than 3 A current from the pass-through power supply or 500 mA current from Cynthion’s power supply.
Use USB cables of no more than 2 m length with Cynthion.
Do not expose Pmod or mezzanine connectors to voltages greater than 3.3 V.
Introduction
Setting up a Development Environment
This guide highlights the installation and setup process for setting up a local copy of the Cynthion source code for development.
Prerequisites
Python v3.9, or later.
A working FPGA toolchain. We only officially support a toolchain composed of the Project Trellis ECP5 tools, the yosys synthesis suite, and the NextPNR place-and-route tool. You can obtain the latest binary distribution of this software from the oss-cad-suite-build project.
A working Rust development environment if you want to develop firmware for Cynthion’s SoC bitstream.
A RISC-V GNU Compiler Toolchain if you want to use
gdbfor SoC firmware debugging over JTAG.
Installation
For development you’ll need a local copy of the Cynthion repository:
Use git to clone the repository:
git clone https://github.com/greatscottgadgets/cynthion.git
Please perform the following steps to enable support for symlinks before attempting to clone the repository on Windows:
Open the “For developers” page in System settings and enable Developer Mode.
Restart your computer.
Open the Group Policy editor: gpedit.msc
Navigate to Computer Configuration → Windows Settings → Security Settings → Local Policies → User Rights Assignment → Create symbolic links and check that you have user permission to create symbolic links.
Restart your computer.
Configure git to enable symbolic links on Windows:
git config --global core.symlinks true
Use git to clone the repository:
git clone https://github.com/greatscottgadgets/cynthion.git
Note
To install the cynthion Python package and allow for in-place editing of the sources you can use the pip --editable command:
# change to the 'cynthion' Python package directory
cd cynthion/python/
# install the 'cynthion' Python package, including dependencies required for gateware development
pip install --editable .
Bitstream Generation
Before proceeding, please ensure you have followed the prerequisites in the Setting up a Development Environment section.
Cynthion Gateware
The Cynthion repository contains gateware for two designs:
analyzer– USB analyzer for using Cynthion with Packetry.facedancer– System-on-Chip for using Cynthion with Facedancer.
Bitstreams can be generated from the cynthion Python package sub-directory as follows:
Analyzer Gateware
# change to the 'cynthion' Python package directory
cd cynthion/python/
# generate bitstream
python3 -m cynthion.gateware.analyzer.top
Facedancer SoC Gateware
# change to the 'cynthion' Python package directory
cd cynthion/python/
# generate bitstream
python3 -m cynthion.gateware.facedancer.top
Additional Options
Additional options for bitstream generation can be listed by appending --help to the command:
$ python3 -m cynthion.gateware.analyzer.top --help
usage: top.py [-h] [--output filename] [--erase] [--upload] [--flash]
[--dry-run] [--keep-files] [--fpga part_number] [--console port]
Gateware generation/upload script for 'USBAnalyzerApplet' gateware.
optional arguments:
-h, --help show this help message and exit
--output filename, -o filename
Build and output a bitstream to the given file.
--erase, -E Clears the relevant FPGA's flash before performing
other options.
--upload, -U Uploads the relevant design to the target hardware.
Default if no options are provided.
--flash, -F Flashes the relevant design to the target hardware's
configuration flash.
--dry-run, -D When provided as the only option; builds the relevant
bitstream without uploading or flashing it.
--keep-files Keeps the local files in the default `build` folder.
--fpga part_number Overrides build configuration to build for a given
FPGA. Useful if no FPGA is connected during build.
--console port Attempts to open a convenience 115200 8N1 UART console
on the specified port immediately after uploading.
Facedancer SoC Firmware Compilation
Prerequisites
Before proceeding, please ensure you have followed the prerequisites in the Setting up a Development Environment section.
Install Rust Dependencies
You will need to install RISC-V embedded target support to compile the firmware:
rustup target add riscv32imac-unknown-none-elf
rustup component add llvm-tools-preview
cargo install cargo-binutils
Optionally, to use gdb for firmware debugging over JTAG, you will need a RISC-V GNU tool chain:
# debian
apt install gcc-riscv64-unknown-elf
# arch
pacman -S riscv-gnu-toolchain-bin
# macos brew - https://github.com/riscv-software-src/homebrew-riscv
brew tap riscv-software-src/riscv
brew install riscv-gnu-toolchain
Building and Running
Firmware for the Cynthion SoC can be found in the firmware/moondancer/ sub-directory.
You can rebuild the firmware using cargo as follows:
# change to the Cynthion firmware directory
cd firmware/moondancer/
# rebuild the firmware
cargo build --release
To upload the firmware binary to Cynthion and flash the SoC bitstream you can run:
# change to the Cynthion firmware directory
cd firmware/moondancer/
# upload firmware and run it
cargo run --release
Note
By default the firmware’s flash script will look for the SoC UART
on /dev/ttyACM0, if this is not the case on your machine you
will need to specify the correct path using the UART environment
variable, for example:
UART=/dev/cu.usbmodem22401 cargo run --release
By default the SoC bitstream is obtained from the latest build in
cynthion/python/build/top.bit but you can override
it with:
BITSTREAM=path/to/bitstream.bit cargo run --release
Running Firmware Unit Tests
Once the firmware is running on the SoC you can execute some unit tests to exercise the firmware.
In order to do this you will need to connect both the CONTROL and AUX ports of the Cynthion to the host and then run:
# change to the Cynthion firmware directory
cd firmware/moondancer/
# run firmware unit tests
python -m unittest
Firmware Examples
There are a number of firmware examples in the firmware/moondancer/examples/ sub-directory.
# change to the Cynthion firmware directory
cd firmware/moondancer/
# run example
cargo run --release --example <example name>