TO Be coming

Nov 4, 2026

UVM Course: Day 4: Transactions and TLM — How Data Moves Between Components

Day 4: Transactions and TLM — How Data Moves Between Components
Advertisement
Top banner — drop your 728×90 or responsive ad unit here.

Day 4: Transactions and TLM — How Data Moves Between Components

A 30-day SystemVerilog/UVM verification series. Day 1 built the skeleton. Day 2 wired the lifecycle. Day 3 put it in motion. Today we learn how the pieces actually talk to each other.

Yesterday's monitor was publishing transactions through an uvm_analysis_port, and the scoreboard was receiving them through an uvm_analysis_imp. That connection was one line in the env's connect_phase. But what is a TLM port? Why is it called "analysis"? What happens to a transaction after ap.write() is called — does it get copied, does it get queued, does the monitor have to wait?

Today we answer all of that. TLM (Transaction Level Modeling) is the plumbing of a UVM testbench. Get it right and components stay cleanly separated. Get it wrong and you'll spend days chasing mysterious race conditions.


1. The Two Families of TLM Connections

UVM has two fundamentally different kinds of TLM connections, and confusing them is the source of most TLM bugs:

FamilyBehaviorTypical use
Point-to-point (blocking or non-blocking)One producer, one consumer. Producer may block until the consumer is ready.Sequence → sequencer → driver
AnalysisOne producer, any number of consumers. Producer never blocks. Data is broadcast.Monitor → scoreboard, coverage, loggers
The rule of thumb: if the producer must wait for the consumer, use a TLM port. If the producer should never wait — because it's observing the DUT and must not slow down simulation — use an analysis port.

2. Why the Analysis Port Is Special

The monitor's job is to observe. If the monitor had to wait for the scoreboard to finish its comparison before it could publish the next transaction, the monitor would fall behind the DUT and start missing traffic. That would be catastrophic — a monitor that can't keep up with the DUT is a broken monitor.

The analysis port solves this by enforcing three guarantees:

  • Non-blocking. ap.write(tr) returns immediately, no matter how many subscribers are connected or how slow they are.
  • Broadcast. Every connected subscriber receives the same transaction reference. Zero, one, or fifty subscribers — the monitor doesn't care.
  • No back-pressure. The monitor can never be slowed by a consumer. If a scoreboard is slow, that's the scoreboard's problem to solve (usually with a queue and a separate thread).
// Monitor side — declare and use the port
uvm_analysis_port #(fifo_item) ap;

function new(string name, uvm_component parent);
    super.new(name, parent);
    ap = new("ap", this);
endfunction

task run_phase(uvm_phase phase);
    fifo_item tr;
    forever begin
        @(vif.mon_cb);
        if (vif.mon_cb.wr_en || vif.mon_cb.rd_en) begin
            tr = fifo_item::type_id::create("tr");
            tr.data     = vif.mon_cb.wr_data;
            tr.is_write = vif.mon_cb.wr_en;
            ap.write(tr);   // never blocks, broadcasts to all
        end
    end
endtask

3. The Analysis Import — the Consumer Side

The subscriber declares an uvm_analysis_imp and implements a write() function. The parameter list is #(TRANSACTION_TYPE, IMPLEMENTING_CLASS):

class fifo_scoreboard extends uvm_scoreboard;
    `uvm_component_utils(fifo_scoreboard)

    uvm_analysis_imp #(fifo_item, fifo_scoreboard) exp_imp;

    function new(string name, uvm_component parent);
        super.new(name, parent);
        exp_imp = new("exp_imp", this);   // "this" is the implementing class
    endfunction

    function void write(fifo_item tr);
        // Called by the analysis port for every published transaction
        `uvm_info("SB", $sformatf("got %s data=%0h",
                  tr.is_write ? "WRITE" : "READ", tr.data), UVM_MEDIUM)
    endfunction
endclass

That write function is the entire consumer API. It's called from the publisher's thread, synchronously. If write is slow, the publisher is slow — except the analysis port is designed so that it isn't, because real analysis imports should just push data into a queue and return immediately.

Real bug pattern: putting a full scoreboard comparison inside write(). Comparison can take microseconds. If it does, the monitor's analysis port — despite being "non-blocking" in name — effectively blocks because write() is called synchronously. Fix: push into a mailbox or uvm_tlm_fifo inside write(), then process in a separate thread inside run_phase.

4. The Connection Syntax

In the env's connect_phase, you connect the port to the import:

function void connect_phase(uvm_phase phase);
    agent.mon.ap.connect(sb.exp_imp);
endfunction

The direction is port.connect(import). Port is the source, import is the sink. If you get the direction backward — import.connect(port) — it won't compile. This is deliberate: UVM forces you to think about who is the producer and who is the consumer.

Multiple subscribers: one port can connect to many imports:
agent.mon.ap.connect(sb.exp_imp);
agent.mon.ap.connect(cov.exp_imp);
agent.mon.ap.connect(logger.exp_imp);
The port broadcasts to all three. The monitor doesn't change its code.

5. What Happens to the Transaction in Flight

Here's the subtle part. When the monitor calls ap.write(tr), it passes a reference, not a copy. Every subscriber gets the same handle to the same object.

This means: if the monitor modifies tr after ap.write() returns, all subscribers see the modification. If the scoreboard stashes the reference in a queue and reads it later, it may see a stale or corrupted value.

ScenarioWhat happens
Monitor creates a new tr for every write()Safe. Each write has a distinct object.
Monitor reuses one tr and mutates it every clockDangerous. Subscribers see the latest mutation, not the original.
Subscriber stashes the reference and reads laterRisky. The publisher may have mutated it.
Publisher calls tr.clone() before write()Safe but slow. Usually unnecessary if the publisher creates fresh objects.
Rule: the publisher should always create a fresh transaction object per write(). Never reuse one across writes. If you find yourself optimizing by reusing, you're about to introduce a class of bug that's very hard to find.

6. The Scoreboard With Real Comparison

Now that we understand TLM, let's upgrade the scoreboard from "counts writes" to "checks the DUT actually works." For a FIFO, that means tracking expected state in a shadow queue and comparing against observed reads.

class fifo_scoreboard extends uvm_scoreboard;
    `uvm_component_utils(fifo_scoreboard)

    uvm_analysis_imp #(fifo_item, fifo_scoreboard) exp_imp;

    fifo_item model_q[$];       // shadow queue — what the FIFO should contain
    int unsigned writes, reads, mismatches;

    function new(string name, uvm_component parent);
        super.new(name, parent);
        exp_imp = new("exp_imp", this);
    endfunction

    function void write(fifo_item tr);
        fifo_item expected;

        if (tr.is_write) begin
            model_q.push_back(tr);
            writes++;
        end else begin
            if (model_q.size() == 0) begin
                `uvm_error("SB", "read on empty FIFO")
                return;
            end
            expected = model_q.pop_front();
            reads++;
            if (tr.data !== expected.data) begin
                mismatches++;
                `uvm_error("SB", $sformatf("read mismatch: exp %0h got %0h",
                          expected.data, tr.data))
            end
        end
    endfunction

    function void check_phase(uvm_phase phase);
        if (writes == 0 && reads == 0)
            `uvm_error("SB", "no traffic observed")
        if (mismatches != 0)
            `uvm_error("SB", $sformatf("%0d mismatches total", mismatches))
    endfunction
endclass

Three things to notice:

  • The shadow queue models the FIFO's behavior. A write pushes; a read pops. If the DUT also implements a FIFO, the queue and the DUT should always agree.
  • The comparison happens in write(). For our small example, this is fine — comparison is cheap. For a real design with complex checking, use the queue-and-process pattern (section 3's warning).
  • check_phase is where end-of-test assertions go. Day 2 covered this — it's not just decorative.

7. When Transactions Get Modified in Flight

A subtle bug is easier to understand now. Suppose the driver does this:

seq_item_port.get_next_item(req);
req.data = req.data + 1;         // "helpful" modification
vif.drv_cb.wr_data <= req.data;
@(vif.drv_cb);
seq_item_port.item_done();

The driver just modified the item it received. Does the scoreboard see the original data or the modified data? Neither — the scoreboard only sees what the monitor publishes. The monitor samples the pins, sees the modified value, and publishes that. So if the scoreboard is modeling expected behavior using the item the sequence sent, it will see a mismatch (because the DUT actually received data + 1).

Design principle: the driver must not modify the transaction it's given. The driver's job is to faithfully translate the transaction into pin activity. If translation requires modification (e.g., adding a parity bit), that logic should live in the driver but must be reproducible by the scoreboard's model.

8. Common TLM Bugs

SymptomCause
Scoreboard never sees anythingForgetting ap.connect(exp_imp) in env's connect_phase
Scoreboard sees the wrong dataMonitor reuses tr across writes (section 5)
Simulation slows to a crawlSlow write() blocking the monitor
Multiple subscribers see different dataOne subscriber modified the shared reference in write()
Connect fails to compilePort direction reversed (import.connect(port))

9. Layering TLM — Multi-stage Pipelines

Real scoreboards often chain TLM stages: the monitor publishes raw items, a reference model transforms them, the model publishes expected items, and a comparator matches expected against observed. The pattern:

monitor.ap → ref_model.in_imp
                │
                ▼
           ref_model.out_ap → comparator.exp_imp
monitor.ap ─────────────────→ comparator.obs_imp

Each stage is its own component with its own port/import. The wiring all happens in the env's connect_phase. This is the scalable pattern — it's how you avoid a scoreboard that's 3000 lines long trying to do comparison inline.


10. What Day 5 Will Add

Tomorrow: uvm_config_db and the factory. How to configure components from the test without hardcoding, how type_id::create() lets you swap drivers or monitors per-test, and how the factory override mechanism works. If you've ever wanted to run the same testbench against a golden model and the real DUT without changing code, that's exactly what the factory gives you.


11. Quiz

Ten questions on TLM, transactions, and scoreboards. One at a time, options shuffled per page load, Submit reveals all answers with explanations.

Question 1 of 10
Loading…
    0 / 10
    Advertisement
    Bottom banner — drop your 728×90 or responsive ad unit here.

    Coming Up Next: Day 5 — uvm_config_db and the Factory

    • uvm_config_db::set and ::get — the correct path strings, when set must happen before build, and why a mismatch between set and get paths fails silently.
    • The factory in depth — type_id::create() vs new(), and why new() is almost always a mistake in UVM.
    • Type and instance overrides — swapping one driver class for another in a single test without touching env code.
    • Common factory bugs — mismatched registration macros, override timing, and why a type override might silently not apply.

    Series: 30 days of SystemVerilog/UVM verification. Day 1 built the skeleton. Day 2 wired the lifecycle. Day 3 put it in motion. Day 4 wired the plumbing.

    No comments:

    Post a Comment

    SiteMap