Writing sequences for the SystemVerilog UVM testbench is an essential, but often misunderstood, part of the design verification process.
The SystemVerilog universal verification methodology (UVM) is an efficient way to generate tests and check results for functional verification, best used for block level IC or FPGA or other “smaller” systems. In a UVM testbench, most activity is generated by writing sequences which are the workhorse elements of a verification program that cause stuff like stimulus generation and results checking to happen. So sequences are something you should concentrate on. There are many moving parts to the UVM; thinking about sequences lets you focus on a program that gets stuff done.
Fortunately, after reading this article you'll realize there is nothing mysterious about them, rather they are simply code that can be written to do many different things, from random transaction generation to synchronization to interrupt service routines.
In this article you will learn about sequences that:
- generate sequence items
- cause other sequences to occur
- manage sequences on other sequencers
- generate out of order transactions
We'll also show you how to code self-checking sequences and introduce the basics of building and writing basic sequences before venturing into more advanced uses. In the end, these tips will help you become more comfortable with coding and debugging UVM sequences.
Creating and running a UVM sequence
As we mentioned earlier, a UVM sequence is a collection of SystemVerilog code that causes events to occur within the testbench. They are frequently used to create a transaction, randomize it, send it to a sequencer, and then on to a driver. In the driver, the generated transaction will normally cause some activity on the interface pins.
For example, as shown in Figure 1, a write_read_sequence could generate a random write transaction and send it to the sequencer and driver. The driver will interpret the write transaction payload and cause a write with the specified address and data.
A UVM sequence is a SystemVerilog object that can be constructed from many different places, but normally a test might construct sequences and then run them—they embody the test.
For example a test might be pseudo-coded as:
LOAD ALL MEMORY LOCATIONS
READ ALL MEMORY LOCATIONS,
CHECK THAT EXPECTED VALUES MATCH.
There might be a sequence to write all memory locations from A to B. And another sequence to read all memory locations from A to B. Or something simpler: A write_read_sequence that first writes all the memory locations and then reads all the memory locations.
The test below creates a sequence inside a fork/join_none. There will be four sequences running in parallel. Each sequence has a LIMIT variable set and starts to run at the end of the fork/join_none. Once all of the forks are done, the test completes.
In the code below, my_sequence, is a simple sequence that creates transactions and sends them to the sequencer and driver. The body () task is implemented. It is a simple for-loop that iterates through the loop LIMIT times. LIMIT is a variable in the sequence that can be set from outside.
Within the for-loop, a transaction object is constructed by calling new () or using the factory. Then start_item is called to begin the interaction with the sequencer. At this point the sequencer halts the execution of the sequence until the driver is ready. Once the driver is ready, the sequencer causes start_item to return. Once start_item has returned, then this sequence has been granted permission to use the driver. Start_item should really be called request_to_send. Now that the sequence has permission to use the driver, it randomizes the transaction, or sets the data values as needed. This is the so-called late randomization that is a desirable feature. The transactions should be randomized as close to executing as possible, that way they capture the most recent state information in any constraints.
After the transaction has been randomized, and the data values set, it is sent to the driver for processing using finish_item. Finish_item should really be called execute_item. At this time, the driver gets the transaction handle and will execute it. Once the driver calls item_done (), then finish_item will return and the transaction has been executed.
Executing the driver
The driver code is relatively simple. It derives from a uvm_driver and contains a run_phase. The run_phase is a thread started automatically by the UVM core. The run_phase is implemented as a forever begin-end loop. In the begin-end block, the driver calls seq_item_port.get_next_item (t). This is a task that will cause execution in the sequencer – essentially asking the sequencer for the next transaction that should be executed. It may be that no transaction is available, in which case this call will block, i.e. will not return, because it is “waiting for something.” (Note: There are other non-blocking calls that can be used, but are beyond the scope of this article, and not a recommended usage). When the sequencer has a transaction to execute, then the get_next_item call will unblock and return the transaction handle in the task argument list (variable “t” in the example below). Now the driver can execute the transaction.
For this example, execution is simple – it prints a message using the transaction convert2string () call, and waits for an amount of time controlled by the transactions duration class member variable.
Once that execution is complete, the seq_item_port.item_done () call is made to signal back to the sequencer, and in turn the sequence, that the transaction has been executed.
Virtual sequences and related sequences
Sequences can have handles to other sequences; after all, a sequence is just a class object with data members, and a task body(), which will run as a thread.
A virtual sequence is a sequence that may not generate sequence items, but rather starts sequences on other sequencers. This is a convenient way to create parallel operations from one control point. A virtual sequence simply has handles to other sequences and sequencers. It starts them or otherwise manages them. The virtual sequence may have acquired the sequencer handles by being assigned from above, by using a configuration database lookup, or other means. It may have constructed the sequence objects, or have acquired them by similar other means.
The virtual sequence is like a puppet master, controlling other sequences. A virtual sequence might look like this:
In the code snippet below, there are two sequences, ping and pong. They each have a handle to each other. They are designed to take turns. The first one sends five transactions, then the other, and so on. See the appendix for the complete code.
First the handles are constructed and a run limit is set up.
Then the handles get their partner handle.
Finally the two sequences are started in parallel.
Self-checking and traffic generator sequences
A self-checking sequence is a sequence that causes some activity and then checks the results for proper behavior. The simplest self-checking sequence issues a write at an address, then a read from the same address. Now the data read is compared to the data written. In some ways the sequence becomes the golden model.
A video traffic generator can be written to generate a stream of background traffic that mimics or models the amount of data a video display might require. There is a minimum bandwidth that is required for video. That calculation will change with the load on the interface or bus, but a simplistic calculation is good enough for this example. The video traffic will generate a screen of data 60 times a second. Each screen will have 1920×1024 dots. Each dot is represented by a 32-bit word. Using these numbers, the traffic generator must create 471MB per second.
A more complete traffic generator would adjust the arrival rate based on the current conditions – as the other traffic goes up or down, the video traffic generation rate should be adjusted.
The synchronizer control is rather simple. It just says GO for 20 ticks and STOP for 100 ticks.
The synchronized sequences are started. They run to completion and then simply get restarted. They run forever.
The simple synchronizer with two states – GO and STOP.
Sequence utility libraries will be created and used. Utility libraries are simple bits of code that are useful for the sequence writer, helper functions or other abstractions of the verification process.
The open_door sequence below does just as its name implies. It opens the door to the sequencer and driver. Outside calls can now be made at will using the sequence object handle (seq.read () and seq.write () for example).
Calling C code (using DPI-C) from sequences is easy, but there are a few limitations. DPI import and export statements cannot be placed inside a class, so they must be outside the class in file, global, or package scope. As such, they have no design or class object scope.
A DPI-C import function or task is a C function with a return value. For a task, the return value is an "int" (see the SystemVerilog LRM for details). For a function, the return value is whatever the return value should be.
The dpiheader.h is a handy way to check the API for DPI-C. In this example, the dpiheader.h (below) is very simple.
This sequence does nothing particularly special. It generates transactions, but it does call a C function (c_code_add red line below). In terms of writing sequences that call C code, there is really nothing special to do in terms of sequences. The DPI-C code must be properly written and must be declared in a proper scope.
In the example code discussed throughout this article, each of the sequences is running in parallel – at the same time on the single sequencer. It is easy to see in the two screenshots below (figures 3 and 4) how the sequences each take turns sending and executing a transaction on the driver.