Testing UVM Drivers

It's that time again when I've started a new project at work. Since we're going to be using some new proprietary interfaces in this chip, this calls for some new UVCs. I wouldn't even consider developing a new UVC without setting up a unit testing environment for it first. Since this is a greenfield project, a lot of the specifications are volatile, meaning that the interface protocol can change at any moment. Having tests in place can help make sure that I don't miss anything. Even if the specification stays the same, I might decide to restructure the code and I want to be certain that it still works.

I first started with unit testing about two years ago, while developing some other interface UVCs. I've learned a few things throughout this time and I'd like to share some of the techniques I've used. In this post we'll look at how to test UVM drivers.

A driver is supposed to take a transaction (called a sequence item in UVM lingo) and convert it into signal toggles. Testing a driver is conceptually pretty straightforward: we supply it with an item and we check that the toggles it produces are correct. As an example, we'll take the Wishbone protocol, revision B.3. Our sequence item models the properties of an access:

typedef enum { READ, WRITE } direction_e;


class sequence_item extends uvm_sequence_item;
rand direction_e direction;
rand bit[31:0] address;
rand bit[31:0] data;

rand int unsigned delay;

// ...
endclass

Aside from the direction, address and data, we can also randomize how many clock cycles the driver should wait before starting the transfer.

All the drivers I've seen up to now consisted primarily of a loop in which an item is fetched and then driven:

class master_driver extends uvm_driver #(sequence_item);
virtual task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
drive();
seq_item_port.item_done();
end
endtask

// ...
endclass

Our goal is to write the drive() task.

Let's look at how to supply a driver with an item. A driver is an active component, that asks for items at its own pace. Inside an agent, it's connected to a sequencer, that feeds it with items when they become available. Inside our unit test, we need to emulate the same relationship by having a test double which the driver can interrogate. There's nothing stopping us from using uvm_sequencer itself:

module master_driver_unit_test;
master_driver driver;
uvm_sequencer #(sequence_item) sequencer;


function void build();
svunit_ut = new(name);

driver = new("driver", null);
sequencer = new("sequencer", null);
driver.seq_item_port.connect(sequencer.seq_item_export);
endfunction

// ...
endmodule

The first test we want to write is that when the driver gets an item with no delay, it drives CYC_O and STB_O immediately. We first create an item and we start it on the sequencer using execute_item(...). This models a `uvm_send(...) action inside a sequence:

module master_driver_unit_test;

`SVTEST(cyc_and_stb_driven)
sequence_item item = new("item");

fork
sequencer.execute_item(item);
join_none

@(posedge clk);
`FAIL_UNLESS(intf.CYC_O === 1)
`FAIL_UNLESS(intf.STB_O === 1)
`SVTEST_END

// ...
endmodule

Since execute_item(...) blocks until the driver finishes processing the item, we'll need to fork it out to be able to check what the driver does with the it.

After supplying the driver with the item, we need to check that it drives the appropriate signal values. Once an item is gotten, we expect the driver to start driving CYC_O and STB_O and their values to be valid on the next posedge. We have to check one clock cycle at a time. For example, if we would drive an item with a delay of three cycles, the unit test would look like this:

module master_driver_unit_test;

`SVTEST(cyc_and_stb_driven_with_delay)
sequence_item item = new("item");
item.delay = 3;

fork
sequencer.execute_item(item);
join_none

repeat (3) begin
@(posedge clk);
`FAIL_UNLESS(intf.CYC_O === 0)
`FAIL_UNLESS(intf.STB_O === 0)
end

@(posedge clk);
`FAIL_UNLESS(intf.CYC_O === 1)
`FAIL_UNLESS(intf.STB_O === 1)
`SVTEST_END

// ...
endmodule

It's not enough to just skip the first three clock cycles. We need to ensure that the driver signals idle cycles during that time. Also, notice the use of the === operator (4-state equality). If we were to use the == operator instead, we would get false positives if the driver doesn't drive any of the signals. This is because X (unknown value due to not being driven) matches anything.

We could write a few more unit tests for our driver. For example, a test could check that a read transfer is properly driven:

module master_driver_unit_test;

`SVTEST(read_transfer_driven)
sequence_item item = new("item");
item.direction = READ;
item.address = 'haabb_ccdd;

fork
sequencer.execute_item(item);
join_none

@(posedge clk);
`FAIL_UNLESS(intf.WE_O === 0)
`FAIL_UNLESS(intf.ADR_O === 'haabb_ccdd)
`SVTEST_END

// ...
endmodule

Another test would check that a write transfer is properly driven:

module master_driver_unit_test;

`SVTEST(write_transfer_driven)
sequence_item item = new("item");
item.direction = WRITE;
item.address = 'h1122_3344;

fork
sequencer.execute_item(item);
join_none

@(posedge clk);
`FAIL_UNLESS(intf.WE_O === 1)
`FAIL_UNLESS(intf.ADR_O === 'h1122_3344)
`SVTEST_END

// ...
endmodule

Notice that in the last two tests we didn't check CYC_O and STB_O anymore. This is because we already checked that they get driven when sending an item.  When we write the unit tests, we don't write them in isolation from the production code. They evolve together. To save effort and make the tests more readable, we can focus on certain aspects of the class we want to test.

The implementation of the drive() task would look something like this:

class master_driver extends uvm_driver #(sequence_item);
virtual protected task drive();
repeat (req.delay)
@(posedge intf.CLK_I);

intf.CYC_O <= 1;
intf.STB_O <= 1;
intf.WE_O <= req.direction;
intf.ADR_O <= req.address;

// ...
endtask

// ...
endclass

We can see that we drive WE_O and ADR_O at the same time we write CYC_O and STB_O. It wouldn't bring us much if we, for example, exhaustively checked that the address can be driven with different delays.

Up to now we only tested that the driver properly reacts to requests from the sequencer. Most of the times, the driver also has to react to other events triggered by its partner on the bus. In our case, since we're developing a master driver, it needs to be sensitive to toggles on signals driven by the slave. One such requirement is that a master is supposed to keep the control signals stable until the slave acknowledges the transfer. This is signaled by raising the ACK_I signal.

We need to write a test where, aside from executing an item on the sequencer, we also model the behavior of the slave:

module master_driver_unit_test;

`SVTEST(transfer_held_until_ack)
sequence_item item = new("item");
intf.ACK_I <= 0;

fork
sequencer.execute_item(item);
join_none

repeat (3) begin
@(posedge clk);
`FAIL_UNLESS(intf.CYC_O === 1)
`FAIL_UNLESS(intf.STB_O === 1)
end

intf.ACK_I <= 1;
@(posedge clk);
`FAIL_UNLESS(intf.CYC_O === 1)
`FAIL_UNLESS(intf.STB_O === 1)
`SVTEST_END

// ...
endmodule

We basically model all collaborators of the driver, where some might communicate with it via method calls (like the sequencer) and some might communicate with it via signal toggles on the interface (like a connected slave).

This last test would suggest that we should update the drive() task with the following code:

class master_driver extends uvm_driver #(sequence_item);
virtual protected task drive();
// ...

@(posedge intf.CLK_I iff intf.ACK_I);
endtask

// ...
endclass

While this is what we would need to do, the test still passes without this line. This is because, once the driver starts driving an item, it won't touch the signals anymore until it gets another item. The extra wait statement would cause the driver to mark an item as finished  at a later time. This is hints that it isn't enough to just check signal toggles. It's also important that the driver makes calls to item_done() at the right time. This isn't something that we can check easily check with uvm_sequencer. We'd need to capture information about method calls from the driver to the sequencer.

We could choose to implement such extra testing functionality in a sub-class of uvm_sequencer. We could capture the number of times item_done() (or any other method) got called during a test. This wouldn't be a problem to implement, but have you ever taken a look at uvm_sequencer? That class is massive. The functionality is shared with two of its subclasses, uvm_sequencer_base and uvm_sequencer_param_base. Most of the stuff a sequencer does (prioritization, locking, etc.) we don't even need. Debugging anything would be a nightmare. Interrupting an item in the middle of it being driven (due to a unit test finishing early) is also going to cause fatal errors to be issued (e.g. "get_next_item() called twice without a call to item_done() in between").

A better alternative would be to start from scratch with a lightweight class that mimics the uvm_sequencer functionality we need and provides us with test diagnostic information. When talking about test doubles, I like to use the same terminology as outlined in this article. I can't really decide whether what we want to create should be called a fake (since we'll implement working functionality, but only a limited part of what uvm_sequencer can do) or a stub (since we want to collect information about method calls). I chose 'stub', for now, based on gut feeling.

Our stub has to be parameterizable, just like uvm_sequencer, to be able to interact with any driver. Just like a sequencer, it's going to have a seq_item_export for the driver to connect to:

class sequencer_stub #(type REQ = uvm_sequence_item, type RSP = REQ)
extends uvm_component;

uvm_seq_item_pull_imp #(REQ, RSP, this_type) seq_item_export;

// ...
endclass

Since in every test we always forked out execute_item(...), we'll provide a function that just schedules an item to be picked up by the driver at its convenience:

class sequencer_stub #(type REQ = uvm_sequence_item, type RSP = REQ)
extends uvm_component;

extern virtual function void add_item(REQ item);

// ...
endclass

Requests (items from the sequencer to the driver) and responses (items from the driver to the sequencer) will be stored in FIFOs:

class sequencer_stub #(type REQ = uvm_sequence_item, type RSP = REQ)
extends uvm_component;

protected uvm_tlm_fifo #(REQ) reqs;
protected uvm_tlm_fifo #(RSP) rsps;

// ...
endclass

Last, but not least, the sequencer methods (get_next_item(), item_done(...), etc.) operate on these FIFOs to emulate the behavior of a real sequencer. For example, get_next_item() peeks inside the request FIFO:

task sequencer_stub::get_next_item(output REQ t);
reqs.peek(t);
endtask

The item_done(...) function pops a request from the FIFO, because it's been handled:

function void sequencer_stub::item_done(RSP item = null);
REQ t;
void'(reqs.try_get(t));

// ...
endfunction

At the same time, we can also count the number of times a certain method was called. This information could be useful to check that the driver requests items at defined intervals:

function bit sequencer_stub::has_do_available();
num_has_do_available_calls++;
return !reqs.is_empty();
endfunction

Last, but not least, we'll need to ensure that all unit tests start from the same state. This means that there aren't any items queued from previous unit tests and that the diagnostic information has been cleared. A flush() method (similar to uvm_tlm_fifo::flush()) should be called in teardown() to enforce this rule:

function void sequencer_stub::flush();
reqs.flush();
rsps.flush();
num_get_next_item_calls = 0;
num_try_next_item_calls = 0;
// ...
endfunction

We can replace the sequencer in our unit test with this sequencer_stub class:

module master_driver_unit_test;
sequencer_stub #(sequence_item) sequencer;

// ...
endmodule

Replacing the calls to execute_item(...) with add_item(...) will make the code a bit more concise:

module master_driver_unit_test;
`SVTEST(cyc_and_stb_driven)
sequence_item item = new("item");
sequencer.add_item(item);

@(posedge clk);
`FAIL_UNLESS(intf.CYC_O === 1)
`FAIL_UNLESS(intf.STB_O === 1)
`SVTEST_END

// ...
endmodule

We could also do the more funky stuff, like testing that we only get a certain amount of calls to item_done(...) in a certain time window. Let's skip this for now to keep the post short.

I've uploaded the code for the sequencer_stub to GitHub under the name vgm_svunit_utils. I'll use this as an incubation area for additions to SVUnit. These could eventually get integrated into the main library if deemed to be worthy and useful to others.

You can also find the example code for this post here. I hope it inspired you to give unit testing a try!

Comments