Development

This section details how to extend the testbench with new tests and targets.

Adding new tests

Each new test needs to follow all usual guidelines specified for cocotb. Basically this means each test function should be decorated with cocotb.test(), they then get the device passed as the first parameter (named dut in existing test cases) and you should use yield keyword whenever you want to transfer control to the simulator. See cocotb documentation for details.

Obtaining test harness

The device under test as seen by cocotb is the Verilog testbench file. To imitate USB host we attach to it one of the classes defined in cocotb_usb.*_host.

In order for the tests to be applicable to multiple devices, the host class is determined dynamically using TARGET variable from Makefile. At the beginning of the test script, use:

from cocotb_usb.harness import get_harness

Then in the test function pass the dut parameter to obtain correct harness:

harness = get_harness(dut)

Resetting the device

The test harness provides methods for both device reset and USB bus reset. At the beginning of the test, use

yield harness.reset()

to bring the device to a known state. Note that you have to use the yield keyword.

To issue a USB bus reset, use:

yield harness.port_reset()

Optionally you can provide reset duration as an argument. Remember that bus reset time mentioned in the USB specification is 10-20 ms, though most cores will work with much shorter periods.

Providing clock signals

By default harness shares its 48 MHz clock with the DUT. If you want to provide your own clock signal, set optional argument decouple_clocks to True and drive the input in the test script, for example using the standard cocotb.clock.Clock object.

device_clock = Clock(dut.clk48_device, 20830, 'ps')
cocotb.fork(device_clock.start())

harness = get_harness(dut, decouple_clocks=True)

Standard testbench provides provides clk48_host and clk48_device signals for this purpose, as well as clkdiff for tracking the difference during simulation.

Waiting and recovery periods

USB specifications mandates that the device is allowed some time to initialize. Most common cases are recovey period after port reset (10 ms) and after completing a SET_ADDRESS requests (2 ms after completing status phase). During this time the device is not required to accept any packets.

The test harness provides a dedicated wait function to use for these longer periods. It outputs a log entry every millisecond to make sure the simulation is still proceeding.

Sending requests

Some standard USB requests are abstracted by the harness to provide a more readable interface. These include setting device address and features, requesting descriptors and sending start-of-frame markers. There are also a number of lower level functions to verify more non-standard or specific situations the device will react to.

Verifying responses

Each target provides a JSON config file containg values for the various descriptors the device supports. This file is passed using TARGET_CONFIG variable to the test script. Then, a UsbDevice class can be instantiated to store those descriptors as Python objects.

Functions provided by the harness will verify the responses received from device against provided list of bytes. This expected response can be passed to the request function as follows:

yield harness.get_configuration_descriptor(
    total_length, response=model.configDescriptor[1].get())

If the response received from the device differs, the test fails.

Using low-level functions

Apart from the readily available methods, you can send any kind of data to the device and monitor its response. Folowing functions are available in the UsbTest class:

  • host_send and host_recv for sending any byte sequence
  • transaction_setup, transaction_data_in, transaction_data_out, transaction_status_in and transaction_status_out for specifying only parts of transaction
  • control_transfer_out and control_transfer_in for arbitrary transfers

See module reference for details.

Adding new test target

Necessary files

  1. Wrapper file for litex
  2. Makefile
  3. Testbench
  4. Descriptor config file

Naming scheme

Choose a unique name for your target. This will need to be passed to a Makefile variable during test runs.

Other files should be called as follows:

  • /wrappers/generate_TARGET.py
  • /wrappers/Makefile.TARGET
  • /wrappers/tb_TARGET.v
  • /configs/decriptors_TARGET.json

LiteX wrapper

We use LiteX to generate glue code in order to have a stable and readable interface to various IP cores. This is true whether the target is already written in LiteX or is available as Verilog/VHDL module.

Testing LiteX cores

Take a look at ValentyUSB target. This is an IP core written in LiteX. The generate_valentyusb.py script generates a minimal wrapper, routing clocks and reset signals, and assigning the USB pins.

Testing non-LiteX cores

Cores writtel in other HDL languages can be instantiated in LiteX. Take a look at generate_usb1device.py. Apart from providing clocks and routing pads, you need the following lines:

platform.add_source("../usb1_device/rtl/verilog/usb1_core.v")

This will add the Verilog files for your device to be used by LiteX.

self.specials += Instance(
    "usb1_core",
    i_clk_i=self.crg.cd_sys.clk,
    i_rst_i=usb_reset,
    # USB lines
    o_tx_dp=usb_p_tx,
    o_tx_dn=usb_n_tx,
    i_rx_dp=usb_p_rx,
    i_rx_dn=usb_n_rx,
    o_tx_oe=usb_tx_en_dut,
    i_rx_d=usb_p_rx,
    i_phy_tx_mode=0b1)  # You can hardwire signal state
    # Not needed signals can be omitted

This block instantiates the IP core in LiteX design. First argument is module name, and the rest are its signals assigned to corresponding LiteX objects.

Note that module signals are prefixed with either i_ or o_ to denote their direction.

In the makefile you need to point to your module sources for the simulator:

VERILOG_SOURCES += $(WPWD)/../usb1_device/rtl/verilog/*.v

Testing targets with a software stack

The wrapper can include a CPU that will execute whatever code you need in the simulation. We rely on LiteX to instantiate the module. An example is available in generate_foboot.py, where we add a VexRiscV CPU and then run Foboot firmware (limited to the USB part in order not to simulate every peripheral on FOMU). Important part here is to include a ROM init file for LiteX, which will set the start address automatically in SoCCore class. CPU type is passed as parameter in the makefile:

TARGET_OPTIONS = --cpu-type vexriscv --variant epfifo --rom-init ../foboot/sw/foboot.bin
VERILOG_SOURCES += $(WPWD)/../litex/litex/soc/cores/cpu/vexriscv/verilog/VexRiscv.v

Target-specific makefile

Options and variables specific to the target are contained in a separate makefile. It is included by the main one, so any paths here should be relative to the usb-test-suite directory.

Testbench file

A testbench is the top-level module for the simulator. It should provide common interface to the DUT to be used between tests. For Icarus Verilog the setting to dump simulation signals is also contained here.Device under test is instantiated as a module here.

Descriptor config file

Descriptor values expected from the device are stored in a JSON file. At a top level, it contains an array the parser will iterate over. Each descriptor is stored as a JSON object.

Device Descriptor

Fields in this descriptor follow the USB standard specification. Note that as JSON does not allow for hex values, they can be stored as strings or decimal values.

Configuration Descriptor

Fields in this descriptor follow the USB standard specification. Last element is an array of Interface Descriptors supported by this configuration.

Interface Descriptor

Last element here is an array of subdescriptors. They can either be Endpoint Descriptors or class-specific ones.

Endpoint Descriptors

Endpoint descriptor attributes and address can be given either as a byte value or spelled out as key : value pairs.

{
  "name":                "Endpoint",
  "bLength":                      7,
  "bDescriptorType":              5,
  "bEndpointAddress":     [2, "IN"],
  "bmAttributes": {
    "Transfer":              "Bulk",
    "Synch":                 "None",
    "Usage":                 "Data"
  },
  "wMaxPacketSize":        "0x0200",
  "bInterval":                    0
}

String Descriptors

String descriptors are a bit special in that the descriptor at index 0 should contain an array of supported language IDs. This descriptor should have the same content regardless of langId specified in the USB request. Then the rest of the string descriptors should be provided for each language that was declared as supported.

To cope with this difference in the JSON file at index 0 (what could be interpreted as unspecified langId just as well) there should be the said array of language IDs. Then, for each element in that array there should be a field in the JSON object, containing a set of paired indexes and strings that the device should report for this index.

Consider a following entry in the JSON file:

{
  "name":     "String",
  "bDescriptorType": 3,
  "0":      ["0x0409"],
  "0x0409" : {
    "1":     "Generic",
    "2":      "IpCore",
    "3":  "1234567890"
  }
}

Here the device supports only one language ID (English), then reports three strings for that ID. If queried with following requests:

  • langId 0x0000, idx 0 - would respond with descriptor content 0x0409
  • langId 0x0409, idx 0 - would respond with descriptor content 0x0409
  • langId 0x0409, idx 2 - would respond with descriptor content IpCore

Other Descriptors

Non-standard descriptors that are not stored as part of the configuration can be added to the descriptor array in config file. It is up to the parser to determine if they can be extracted and used in test.

Adding new USB class

If the device uses a USB class not supported in cocotb-usb, you are welcome to extend it. It should be placed as a separate file in cocotb_usb/descriptors.

Class descriptors

All descriptors for the new class should inherit from the Descriptor class. It provides some basic types and methods to access the descriptor in the test functions.

Descriptor functions should implement a __bytes__ functions to represent the object in a byte form. For this the inbuilt Python struct module is used. Descriptor class can either include a format string describing its structure or generate it dynamically, for example when its length is not fixed.

Class requests

Class-specific requests are implemented as functions, internally using USBDeviceRequest to build the request.

Parser and config file support

Each class needs a parser function to make sure its descriptors can be filled with device-specific values and compared in the test cases. The class file should include a parsing function that takes a field from the JSON file and returns an initialized descriptor object specific to this class.

Class specific parsers should be stored in a dictionary with keys corresponding to their descriptor types. This dictionary should then be added along with the class code to getClassParsers in device.py.

Documentation

Documentation for the module is generated using sphinx and autodoc, so it relies on proper docstrings. All objects that are intended to be used in tests (including all descriptors and requests) should be documented.