Skip to content

Latest commit

 

History

History
 
 

umiram

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

umiram tutorial

Switchboard provides a Python interface for reading from and writing to RTL designs using UMI. This tutorial shows how to create such a setup, using a simple UMI memory design as an example. You'll learn how to instantiate switchboard Verilog modules that connect to the DUT, and how to interact with the DUT from Python.

Installation

If you haven't already cloned this repo and installed the switchboard Python package, please do that first. We recommend installing switchboard in a virtual env, conda env, etc. to keep things tidy.

$ git clone https://github.com/zeroasiccorp/switchboard.git
$ cd switchboard
$ git submodule update --init
$ pip install --upgrade pip
$ pip install -e .

Structure

The RTL structure of this example is a Verilog module, testbench, that instantiates UMI memory, umiram. Within testbench, the UMI udev_req and udev_resp ports of umiram are terminated using the queue_to_sb_sim and sb_to_queue_sim modules provided by switchboard.

A Verilator simulation is built using testbench as the top level. When it runs, UMI packets flow into udev_req from a switchboard queue, to_rtl.q, and flow out from udev_resp into a switchboard queue, from_rtl.q. These queues will show up on your computer as ordinary files, each representing a region of shared memory.

In the Python script test.py, a UMI driver is instantiated that sends packets to to_rtl.q and receives them from from_rtl.q. write() and read() methods provided by the driver allow numpy arrays and scalars to be written to and read from the DUT, with each operation mapping to one or more UMI operations with automatically calculated SIZE and LEN parameters.

image

These operations are automated by the Python script. If you haven't already, cd into this folder, and then execute test.py. You should see the following output, after the Verilator build completes:

$ ./test.py
...
### WRITES ###
### READS ###
Read: 0xbaadf00d
Read: 0xb0bacafe
Read: 0xdeadbeef
Read: 0xbaadd00dcafeface
...

In the sections that follow, we'll go into the different components of this example, to give a sense of how they might be modified for your own use.

testbench.sv

The main purpose of the top-level module, testbench, is to instantiate the DUT and connect its UMI ports to switchboard-provided "end caps": queue_to_sb_sim and sb_to_queue_sim. queue_to_sb_sim receives UMI packets from outside of the Verilog simulation and drives them into the DUT, while sb_to_queue_sim drives UMI packets to the outside world that are provided by the DUT. As you can see in the file, there is a one-to-one mapping of all interface pins on these modules to signals on the DUT.

queue_to_sb_sim rx_i (
    .clk(clk),
    .data(udev_req_data),
    .srcaddr(udev_req_srcaddr),
    .dstaddr(udev_req_dstaddr),
    .cmd(udev_req_cmd),
    .ready(udev_req_ready),
    .valid(udev_req_valid)
);
sb_to_queue_sim tx_i (
    .clk(clk),
    .data(udev_resp_data),
    .srcaddr(udev_resp_srcaddr),
    .dstaddr(udev_resp_dstaddr),
    .cmd(udev_resp_cmd),
    .ready(udev_resp_ready),
    .valid(udev_resp_valid)
);

Although only one TX and one RX end cap are used in this example, switchboard supports instantiating multiple end caps of each type in a single simulation.

Another important part of testbench.sv are the lines where these end caps are assigned switchboard queue names:

initial begin
    rx_i.init("to_rtl.q");
    tx_i.init("from_rtl.q");
end

There is nothing special about these names; all legal file names can be used, as long as they match up to the names used in the SW driver. For example, since the DUT is expecting to receive UMI packets from to_rtl.q, the Python script should be configured to send packets to to_rtl.q. The switchboard queues are ordinary files, and they may stick around after a simulation - for that reason, you may want to give them a globbable name so you can do things like rm *.q, or add *.q to .gitignore.

In looking through testbench.sv, you may haved noticed that queue_to_sb_sim has a parameter called VALID_MODE_DEFAULT, and sb_to_queue_sim has a parameter called READY_MODE_DEFAULT. These control the ready/valid signaling style used by the switchboard modules. All of the styles are legal in terms of the UMI specification, but can help catch different kinds of hardware bugs.

  • VALID_MODE_DEFAULT on queue_to_sb_sim
    • 0 means to always set valid to 0 after completing a transaction. This means that if there is a steady stream of data being driven out and ready is kept asserted, valid will alternate between 0 and 1.
    • 1 means to try to keep valid asserted as much as possible, so if there is a constant stream of data being driven out and ready is kept asserted, valid will remain asserted.
    • 2 means to flip a coin before deciding to drive out the next packet. This causes the valid line to have a random pattern if there is a constant stream of data to be driven and ready is kept asserted.
  • READY_MODE_DEFAULT on sb_to_queue_sim
    • 0 means to hold off on asserting ready until valid has been asserted.
    • 1 means to assert ready as much as possible, so if valid is held high, ready will remain asserted as long as more packets can be pushed into the switchboard queue (i.e., as long as the SW driver isn't backpressuring)
    • 2 means to flip a coin on each cycle to determine if ready will be asserted in that cycle (provided that there is room in the switchboard queue)

test.py

This is a Python script that builds the Verilator simulator, launches it, and interacts with the running RTL simulation of umiram.

Simulator build

The logic for building the Verilator simulator is found in build_testbench(). As a convenience, switchboard provides a class called SbDut that inherits from siliconcompiler.Chip and abstracts away switchboard-specific setup required for using the queue_to_sb_sim and sb_to_queue_sim modules in your testbench. input() is used to specify RTL sources, and include directories/libraries are specified with add(). The simulator is built with build(); when fast=True, the simulator binary will not be rebuilt if it already exists.

Simulator interaction

The basic steps to interact with a simulator are to (1) create a UmiTxRx object that points at the switchboard queues used by the DUT, and (2) call the object's write() and read() to interact with the DUT via UMI.

The UmiTxRx constructor accepts two arguments tx_uri and rx_uri. Hence, when we write

umi = UmiTxRx('to_rtl.q', 'from_rtl.q')

this means, "send UMI packets to the queue called to_rtl.q, and receive UMI packets from the queue called from_rtl.q". This matches up with the way that these queues were defined in testbench.sv, since the DUT is expecting to receive UMI packets from to_rtl.q and send them to from_rtl.q.

write()

The interface of the write() command is write(addr, data), where

  • addr is a 64-bit UMI address
  • data is a numpy scalar or array. For example, data could be np.uint32(0xDEADBEEF) or np.arange(64, dtype=np.uint8).

The numpy data type determines the SIZE for the UMI transaction (e.g., 0 for np.uint8, 1 for np.uint16, etc.). The LEN for a UMI transaction is 0 for a scalar, and len(data)-1 for an array. If data is more than 32B (the data bus width provided by sb_to_queue_sim and queue_to_sb_sim), multiple UMI transactions will automatically be generated within the write() command. This means that you are generally free to write arbitrary binary blobs, without having to worry about bus widths.

read()

The interface for read() is read(addr, num_or_dtype, [dtype]), where

  • addr is a 64-bit UMI address
  • num_or_dtype is either an integer or a numpy data type.
  • dtype is an optional argument that only comes into play if num_or_dtype is an integer. It defaults to np.uint8

If num_or_dtype is an integer, it means the number of words that should be read starting at addr. The optional argument dtype specifies the word size. For example, read(0x10, 4) means "read four bytes starting at 0x10", and read(0x20, 2, np.uint16) means "read two uint16s starting at address 0x20". The result returned will be a numpy array with data type dtype.

As with the write() command, switchboard will automatically calculate LEN and SIZE based on the number of element and datatype, and will use multiple reads if necessary.

If num_or_dtype is a data type, then a single word of that data type will be read and returned as a scalar. For example, read(0x30, np.uint32) means "read a uint32 from address 0x30".

Other commands

The UmiTxRx object provides other methods for interaction over UMI, such as atomic(). This will be covered in a future tutorial.

fresh=True

You may have noticed the option fresh=True when creating the UmiTxRx object. switchboard queues are not automatically deleted at the end of a simulation, so it's important to make sure that any old queues are cleared out before launching a new simulation. The option fresh=True does this; the only rule is that UmiTxRx objects must be created before launching a simulation. At some point, fresh=True will be made the default (it's not at the moment for backwards-compatibility reasons).

Makefile

The Makefile is a simple convenience wrapper over the Python script. You may also notice that test.py can be invoked with make cpp, which demonstrates switchboard's C++ interface. That interface will be the subject of a future tutorial.