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:
| Family | Behavior | Typical use |
|---|---|---|
| Point-to-point (blocking or non-blocking) | One producer, one consumer. Producer may block until the consumer is ready. | Sequence → sequencer → driver |
| Analysis | One producer, any number of consumers. Producer never blocks. Data is broadcast. | Monitor → scoreboard, coverage, loggers |
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.
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.
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.
| Scenario | What happens |
|---|---|
Monitor creates a new tr for every write() | Safe. Each write has a distinct object. |
Monitor reuses one tr and mutates it every clock | Dangerous. Subscribers see the latest mutation, not the original. |
| Subscriber stashes the reference and reads later | Risky. The publisher may have mutated it. |
Publisher calls tr.clone() before write() | Safe but slow. Usually unnecessary if the publisher creates fresh objects. |
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_phaseis 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).
8. Common TLM Bugs
| Symptom | Cause |
|---|---|
| Scoreboard never sees anything | Forgetting ap.connect(exp_imp) in env's connect_phase |
| Scoreboard sees the wrong data | Monitor reuses tr across writes (section 5) |
| Simulation slows to a crawl | Slow write() blocking the monitor |
| Multiple subscribers see different data | One subscriber modified the shared reference in write() |
| Connect fails to compile | Port 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.
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()vsnew(), and whynew()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