A UVM pipelined driver keeps several transfers in flight at once, one in each stage of a pipelined bus protocol. It runs one thread per pipeline stage, takes items with seq_item_port.get() so the sequence can send the next item immediately, uses semaphores to keep each bus phase in order, and returns results with seq_item_port.put() because the sequence has already moved on.

Many buses split a transfer into phases. A typical two-stage bus has an address phase, where the address and direction are presented, and a data phase, where the data moves. The address phase of the next transfer can overlap the data phase of the current one. A simple driver that finishes one transfer before starting the next never creates that overlap, so it runs the bus at half its bandwidth and never tests the DUT's pipelining logic.

8 min read | Intermediate

What You'll Learn

  • Explain why get_next_item() and item_done() cannot keep a pipeline full
  • Write a driver with one thread per pipeline stage, coordinated by semaphores
  • Return responses from a pipelined driver with put()
  • Write sequences that use the pipeline, and sequences that deliberately don't

How Pipelining Works

With two pipeline stages, two transfers are active at any time: one in its address phase and one in its data phase. A driver can model this with two identical threads. Each thread takes one transfer through both phases. While thread A is in the data phase of T1, thread B is already presenting the address of T2.

Two rules keep the threads from getting in each other's way:

  • Only one transfer may be in the address phase at a time, and transfers must enter it in the order the sequence sent them.
  • Only one transfer may be in the data phase at a time, and data phases must follow the same order as the address phases.

A semaphore with one key per stage enforces each rule.

Why get() Instead of get_next_item()

With get_next_item(), the sequence's finish_item() waits until the driver calls item_done(). If the driver only calls item_done() at the end of the data phase, the sequence cannot send the next item until then, and the pipeline is never full.

seq_item_port.get() retrieves the item and signals item done in one step. The sequence is released at once and can prepare the next item while the first one is still on the bus. The result of the transfer is sent back later as a response with seq_item_port.put(), which places it in the sequence's response queue or hands it to its response handler.

Syntax


seq_item_port.get(req);      // take the next item; the sequence's finish_item() returns now
// ... drive the transfer through every pipeline stage ...
seq_item_port.put(req);      // return the completed item as the response

Returning the request object itself as the response works because the sequencer already gave it the sequence and transaction IDs. A separate response object would need rsp.set_id_info(req) first, as described in UVM Sequence Responses.

Example: A Two-Stage Pipelined Driver

The bus in this example has an address phase that ends when the slave raises aready, and a data phase that ends when it raises dready. pbus_item has random addr, write and wdata fields, plus rdata filled in by the driver.


class pbus_driver extends uvm_driver #(pbus_item);
  `uvm_component_utils(pbus_driver)
  virtual pbus_if vif;
  semaphore addr_phase = new(1);       // one transfer in the address phase at a time
  semaphore data_phase = new(1);       // one transfer in the data phase at a time
  // Rest of the code (constructor, build_phase getting vif)

  virtual task run_phase(uvm_phase phase);
    vif.valid <= 0;
    @(posedge vif.rstn);
    fork
      transfer_thread();               // one thread per pipeline stage
      transfer_thread();
    join
  endtask

  virtual task transfer_thread();
    pbus_item req;
    forever begin
      addr_phase.get();                // our turn to start a transfer
      seq_item_port.get(req);          // releases the sequence immediately

      // Address phase
      vif.valid <= 1;
      vif.addr  <= req.addr;
      vif.write <= req.write;
      @(posedge vif.clk);
      while (!vif.aready) @(posedge vif.clk);
      vif.valid <= 0;

      data_phase.get();                // wait until the previous data phase is over
      addr_phase.put();                // the next transfer may present its address now

      // Data phase
      if (req.write) vif.wdata <= req.wdata;
      @(posedge vif.clk);
      while (!vif.dready) @(posedge vif.clk);
      if (!req.write) req.rdata = vif.rdata;
      data_phase.put();

      seq_item_port.put(req);          // send the completed item back as the response
    end
  endtask
endclass

The order of the semaphore calls is the important part. A thread takes the data-phase key before giving back the address-phase key. That way the next transfer can only start its address phase once this transfer is guaranteed to be next in the data phase, so the two phases can never get out of order.

When thread B takes the address-phase key in the same clock edge where thread A cleared valid, B's assignment of 1 is the later nonblocking update and wins. valid stays high, and the next address follows without a gap.

A deeper pipeline uses the same pattern: one thread and one semaphore per stage, each stage taking the next stage's key before releasing its own.

Quick Check

Q: What happens if the driver above calls addr_phase.put() before data_phase.get()?

Show Answer

The next transfer could finish its address phase and grab the data-phase key while this thread is still waiting for it. The data phases would then run in a different order from the address phases, and the bus would return data for the wrong transfer.

Sequences for a Pipelined Driver

Keeping the pipeline full

To use the pipeline, a sequence must send items without waiting for each result. The response handler collects the results as they come back:


class pbus_stream_seq extends uvm_sequence #(pbus_item);
  `uvm_object_utils(pbus_stream_seq)
  int unsigned n_done;
  // Rest of the code (constructor)

  virtual function void response_handler(uvm_sequence_item response);
    n_done++;                                  // a scoreboard checks the data
  endfunction

  virtual task body();
    use_response_handler(1);
    for (int i = 0; i < 16; i++) begin
      req = pbus_item::type_id::create("req");  // a new item for every transfer
      start_item(req);
      if (!req.randomize() with { write == 1; }) `uvm_error("SEQ", "Randomization failed")
      finish_item(req);                         // returns as soon as a driver thread has it
    end
    wait (n_done == 16);                        // don't end while transfers are in flight
  endtask
endclass

Creating a new item for every transfer is essential. The driver still holds earlier items while they move through the pipeline. Reusing one handle and randomizing it again would change the contents of a transfer that is already on the bus.

The wait at the end keeps the sequence alive until every response has arrived. Without it the sequence would end while transfers are still in the pipeline.

One transfer at a time

Not every sequence wants pipelining. Software-style register accesses often need the result of one read before deciding the next access. The same driver handles this too: the sequence simply waits for each response before sending the next item.


start_item(req);
if (!req.randomize() with { write == 0; addr == 8'h40; }) `uvm_error("SEQ", "Randomization failed")
finish_item(req);
get_response(rsp);                              // blocks until the data phase is done
if (rsp.rdata[0]) begin
  // ... act on the value read ...
end

Here rsp is the sequence's own response handle. Because the driver returns the request object itself, rsp and req end up pointing to the same item, now holding the read data.

Data checking normally belongs in a scoreboard fed by a monitor, not in the sequence. The response is there so a sequence can react to a result, or simply know when its transfers have finished.

Common Mistakes

Mistake #1: Using get_next_item() and item_done() in a pipelined driver

If item_done() is only called at the end of the transfer, the sequence is blocked for the whole transfer and the pipeline never fills. Use get() and put().

Mistake #2: Reusing one item handle in the sequence

With get(), finish_item() returns while the item is still being driven. Randomizing the same object again corrupts a transfer in flight. Create a new item for each transfer.

Mistake #3: Ending the sequence before the responses arrive

When body() returns as soon as the last item is handed over, the final transfers are still in the pipeline. Count responses and wait for the last one.

Mistake #4: Releasing a stage before claiming the next one

Each thread must take the next stage's semaphore before releasing the current one. Otherwise transfers can overtake each other between stages.

Summary

In this article, you learned:

  • A pipelined driver runs one thread per pipeline stage, so several transfers are on the bus at once
  • seq_item_port.get() releases the sequence immediately, and put() returns the result later as a response
  • One semaphore per stage, taken before the previous one is released, keeps every phase in order
  • Pipelined sequences create a new item per transfer and collect responses with a handler
  • The same driver serves one-at-a-time sequences that call get_response() after each item
Did you find what you were looking for?

Quiz

Test Your Knowledge

1 of 5 Score: 0 / 5