TLM-2.0 sockets for a reference model
Call a reference model through TLM-2.0 sockets with a generic payload, read its response status and annotated time, run two nonblocking transactions through their phases, and put the model behind a scoreboard's predictor.
- Lesson
- U9.4
- Time
- 35 min
- Runs on
- Verilator with UVM
- Check
- 6 questions
The platform team's memory model answers every read the scoreboard sends it, and the scoreboard copies the data out of each reply. A read to an unmapped address comes back with data 00, the design also returns 00 there, and the check passes for weeks. The model had said address error, and the design should have flagged one too. Nobody looked at the reply's status, only at its data.
| Time | Call | Response status | Data | Annotated |
|---|---|---|---|---|
| 0 ns | WRITE 01 = 11 | OK | 11 | 20 ns |
| 20 ns | WRITE 40 = 01 (inv on) | OK | 01 | 20 ns |
| 40 ns | READ 01 | OK | ee | 20 ns |
| 60 ns | READ 12 | OK | 2d | 40 ns |
| 100 ns | READ 80 | ADDRESS_ERROR | 00 (untouched) | 0 ns |
The example's blocking calls to the memory model. The last reply's data is whatever the initiator left in the payload.
A model behind a socket
U9.3 called its model through a function with arguments the two sides agreed on. TLM-2.0 is a standard for that agreement between models: one transaction class, the generic payload, and a few calls to move it. Models written to it plug into each other without glue, which is how the virtual platforms of U9.5 are assembled.
UVM ships the SystemVerilog side of the standard. The example turns U9.1's memory into a TLM-2.0 target that implements b_transport. The initiator starts transactions; in a bench it is the scoreboard's predictor. ini.b_sock.connect(tgt.b_sock) joins them in connect_phase.
- Blocking0 ns (illustrative)
b_transportruns in the target and returns with the status set: one call, one transaction.More context
Log line
0 ns b_transport WRITE 01 -> OK, data 11, annotated 20 ns. The target adds its 20 ns latency todelayinstead of waiting; the initiator waits it out, so its next call is at 20 ns.Follow the calls, values and waits
- data WRITE 01 = 11: initiator → generic payload
- call b_transport(gp, delay): initiator → mem_model
- data status OK: mem_model → generic payload
- return returns; delay 20 ns: mem_model → initiator
- Nonblocking: the request100 ns (illustrative)
The forward call returns at once with
END_REQ. The target has already read the memory and starts a responder.More context
Log lines
100 ns target fw BEGIN_REQ 12, returns UVM_TLM_UPDATED with END_REQand the initiator's matching line.nb_transport_fwis a function, so it cannot wait for the response.Follow the calls, values and waits
- data READ 12: initiator → generic payload
- call nb_transport_fw: BEGIN_REQ: initiator → mem_model
- data data 2d, status OK: mem_model → generic payload
- call start a responder: mem_model → responder thread
- return UPDATED, END_REQ: mem_model → initiator
- Waiting: responder thread — waits 40 ns for bank 1
- Nonblocking: the response140 ns (illustrative)
A responder answers later on the backward path, with the same payload.
More context
Log line
140 ns initiator bw BEGIN_RESP 12, data 2d, returns UVM_TLM_COMPLETED. The responder calls from its own thread, after the forward call has returned.Follow the calls, values and waits
- call nb_transport_bw: BEGIN_RESP: responder thread → initiator
- return COMPLETED: initiator → responder thread
- Released: responder thread
Read the diagram as text
- initiator: b_sock, nb_sock, nb_transport_bw
- generic payload: command, address, data, length, status
- mem_model: b_transport, nb_transport_fw
- responder thread: one per nonblocking request
- initiator → generic payload: creates, fills
- initiator → mem_model: forward call
- mem_model → generic payload: sets status, data
- mem_model → responder thread: fork
- responder thread → initiator: nb_transport_bw
Initiator sockets connect to target sockets; connect is called on the initiator side.
| Generic payload field | Set by | In the example |
|---|---|---|
| command | Initiator | UVM_TLM_WRITE_COMMAND or UVM_TLM_READ_COMMAND |
| address | Initiator | 00-1f two banks, 40 the inv register |
| data, data length, streaming width | Initiator; target fills data on a read | One byte; length and width both 1 |
| response status | Target | Starts UVM_TLM_INCOMPLETE_RESPONSE; target sets OK or an error |
The fields this target supports. Byte enables and extensions exist too; the UVM library carries a DMI flag but has no DMI interface.
The response status is part of the answer. A new payload starts as INCOMPLETE, and a target that forgets to set it leaves it there, so an initiator can tell "never handled" from "handled, OK". The data length is a separate field from the data array, and the target reads get_data_length().
This target supports a restricted subset: one-byte reads and writes on the map shown, no byte enables. Anything else gets an error status, not a guess: BURST_ERROR for another length, BYTE_ENABLE_ERROR for byte enables, COMMAND_ERROR for an ignore command and ADDRESS_ERROR off the map.
Blocking transport and annotated time
b_transport(t, delay) is a task. When it returns, the target has finished the transaction and written the status into t. The second argument is a uvm_tlm_time, an object that carries a time offset with its own resolution, so models compiled under different timescales agree on what "20 ns" means.
This target never waits. It adds its latency to delay and returns at the same simulation time: 20 ns for bank 0, 40 for bank 1, the memory's minimum latencies at 10 ns a cycle. It leaves out the arbitration that can make a bank 1 answer later (U9.1): a timing abstraction, good enough for values, not for checking latency. That style is called loosely timed: the target annotates, and the initiator decides when to let simulation time catch up. The example's initiator waits out each annotation before its next call, so the calls happen at 0, 20, 40, 60 and 100 ns.
Reads the data only
b_sock.b_transport(t, dly);
t.get_data(d);
expected = d[0];
An address error leaves the initiator's 00 in the payload, and it becomes the prediction.
Reads the status first
b_sock.b_transport(t, dly);
if (!t.is_response_ok())
expect_error(t.get_response_status());
else begin t.get_data(d); expected = d[0]; end
The model's error becomes an expected error response from the design.
The usage rules are short. The initiator creates the payload and may reuse it once a call returns, as the example does. The target may change the payload only during the call and must not keep the handle afterwards. So the observation point is the return: once b_transport is back, the status and data are final and the initiator may read them.
Temporal decoupling and its price
An initiator does not have to wait after every call. It can keep one uvm_tlm_time as its local time offset, let each call add to it, and synchronise with simulation time only now and then. The quiz's initiator makes three calls at simulation time 0, collects 80 ns of annotations, waits once and resets the offset.
Columns are steps of the initiator. The model runs ahead of simulation time and catches up once.
This is temporal decoupling, and it is why a model of a whole chip runs fast: it does not hand control back to the simulator for every transaction. Platforms bound the run-ahead with a quantum, a maximum offset after which the initiator must synchronise.
The price is causality. An event that happens in simulation time while the initiator runs ahead, such as an interrupt raised at 30 ns, is not seen until it synchronises at 80. If that interrupt should have changed what the model did between 30 and 80, the values are now wrong too, not just late. Run-ahead is correct only while nothing outside the initiator can influence what it computes, so synchronise before such an event can matter. A quantum bounds how far the initiator runs ahead; it does not by itself make the results right.
Nonblocking transport with two reads in flight
A model of a pipelined bus must accept a second request before the first is answered. The nonblocking interface allows this by splitting a transaction into phases: BEGIN_REQ, END_REQ, BEGIN_RESP, END_RESP. The initiator calls nb_transport_fw on the forward path; the target answers later with nb_transport_bw on the backward path. Both are functions: neither side may wait inside them.
Columns are calls, not clock edges. Both requests are accepted at 100 ns; the bank 0 read, started second, completes first.
Each call returns a uvm_tlm_sync_e. UVM_TLM_ACCEPTED means nothing changed; UVM_TLM_UPDATED means the callee moved the phase, here from BEGIN_REQ to END_REQ; UVM_TLM_COMPLETED ends the transaction. Because nb_transport_fw cannot wait, the target queues each request and starts a responder thread per request.
- Both requests accepted100 ns (illustrative)
Each forward call returns
UVM_TLM_UPDATEDwithEND_REQat 100 ns, and the target starts one responder per read.More context
Read 12 goes first, then read 01, both at 100 ns. Each read has its own payload object, so neither overwrites the other's address.
Follow the calls, values and waits
- data payload #1: READ 12: payload #1 → nb_transport_fw
- call responder 1, 40 ns: nb_transport_fw → responder 1
- data payload #2: READ 01: payload #2 → nb_transport_fw
- call responder 2, 20 ns: nb_transport_fw → responder 2
- Waiting: responder 1 — bank 1: until 140 ns
- Waiting: responder 2 — bank 0: until 120 ns
- Bank 0 first120 ns (illustrative)
The read started second completes first, at 120 ns, carrying payload #2.
More context
Log line
120 ns initiator bw BEGIN_RESP 01, data ee, returns UVM_TLM_COMPLETED. Payload #1 is still in flight.Follow the calls, values and waits
- data payload #2: data ee: responder 2 → nb_transport_bw
- Released: responder 2
- Bank 1 later140 ns (illustrative)
Payload #1 completes at 140 ns. The handle tells the initiator which read it is.
More context
Log line
140 ns initiator bw BEGIN_RESP 12, data 2d, returns UVM_TLM_COMPLETED. No tag is needed: the payload handle is the identity.Follow the calls, values and waits
- data payload #1: data 2d: responder 1 → nb_transport_bw
- Released: responder 1
Read the diagram as text
- payload #1: READ 12, bank 1
- payload #2: READ 01, bank 0
- nb_transport_fw: access now, END_REQ
- responder 1: waits 40 ns
- responder 2: waits 20 ns
- nb_transport_bw: returns COMPLETED
- payload #1 → nb_transport_fw: 100 ns
- payload #2 → nb_transport_fw: 100 ns
- nb_transport_fw → responder 1: fork
- nb_transport_fw → responder 2: fork
- responder 2 → nb_transport_bw: 120 ns, #2
- responder 1 → nb_transport_bw: 140 ns, #1
A payload is one transaction until its final COMPLETED; refilling it early would corrupt the read in flight.
Two reads in flight need two payload objects. The backward call passes the same object the forward call carried, which is how the initiator knows which read is answered. Reusing one payload for the second read would overwrite the first one's address while the target still holds it, so nb_read creates a payload per read.
One more rule keeps the paths apart: a forward call must never call the backward path, directly or through other code, and the reverse holds too. The example's target answers from its own threads, after the forward call has returned.
The model as a scoreboard's predictor
In a bench, the initiator is the predictor of U8.1. It receives a monitored request, calls the model and turns the reply into an expected response. One rule shapes it: an analysis write is a function and b_transport is a task, and a function may not call a task, whatever the task does inside. So the predictor stores requests in an analysis FIFO, which keeps their order, and calls the socket from its own run_phase.
- Buffer
The analysis
writeonly stores the request; it cannot call the socket.More context
The quiz's unmapped read at 80.
writereturns in the same time step; a function may not call the taskb_transport.Follow the calls, values and waits
- data bus_req: READ 80: request monitor → req_fifo
- Waiting: run_phase — blocked in req_fifo.get
- Call the model
A task takes requests in order and calls
b_transport; the reply carries the status and, for a read, the data.More context
The task may wait, so it is the right place for the socket call. Here the model answers ADDRESS_ERROR: nothing is mapped at 80.
Follow the calls, values and waits
- data bus_req: READ 80: req_fifo → run_phase
- call b_transport(gp, delay): run_phase → mem_model
- return ADDRESS_ERROR: mem_model → run_phase
- Released: run_phase
- Compare
The comparator checks the status before the data, so an unmapped read answered with OK is caught.
More context
The report is
design UVM_TLM_OK_RESPONSE, model UVM_TLM_ADDRESS_ERROR_RESPONSE. Comparing the data alone, 00 against 00, would have passed.Follow the calls, values and waits
- data expected: ADDRESS_ERROR: run_phase → comparator
- data actual: OK, 00: response monitor → comparator
Read the diagram as text
- request monitor: bus_req
- req_fifo: Keeps request order
- run_phase: get, fill payload, b_transport
- mem_model: Sets status and data
- response monitor: bus_rsp from the design
- comparator: Status first, then data
- request monitor → req_fifo: write (function)
- req_fifo → run_phase: get (task)
- run_phase → mem_model: b_transport (task)
- run_phase → comparator: expected: status, data
- response monitor → comparator: actual
Function to FIFO to task: the socket call happens where waiting is allowed.
// Requests arrive by analysis write, a function; b_transport is a task. So the predictor
// buffers requests in an analysis FIFO, which keeps their order, and calls the model from run_phase.
class tlm_predictor extends uvm_component;
`uvm_component_utils(tlm_predictor)
uvm_tlm_analysis_fifo #(bus_req) req_fifo; // the monitor connects to req_fifo.analysis_export
uvm_tlm_b_initiator_socket #(gp) b_sock; // to the model
uvm_analysis_port #(bus_rsp) ap; // predictions, in request order
function new(string name, uvm_component parent);
super.new(name, parent);
req_fifo = new("req_fifo", this);
b_sock = new("b_sock", this);
ap = new("ap", this);
endfunction
task run_phase(uvm_phase phase);
gp t = gp::type_id::create("t");
uvm_tlm_time dly = new("dly", 1e-9);
forever begin
bus_req r;
bus_rsp e;
byte unsigned d[] = new[1];
req_fifo.get(r);
d[0] = r.data;
if (r.is_write) t.set_command(UVM_TLM_WRITE_COMMAND); else t.set_command(UVM_TLM_READ_COMMAND);
t.set_address(r.addr);
t.set_data(d);
t.set_data_length(1);
t.set_streaming_width(1);
t.set_response_status(UVM_TLM_INCOMPLETE_RESPONSE);
dly.reset();
b_sock.b_transport(t, dly); // a task, called from a task
e = bus_rsp::type_id::create("e");
e.addr = r.addr;
e.is_read = !r.is_write;
e.status = t.get_response_status(); // the status is part of the prediction
t.get_data(d);
e.data = d[0];
ap.write(e);
end
endtask
endclass
The second rule is the hook's: the status is part of the prediction. The expected response carries the model's status, and the comparator checks status first and data only for a read the model completed with OK. A reply still INCOMPLETE means the model did not handle the request, and is reported as a model fault. Comparing a status needs a bus whose responses carry one; U9.1's memory returns data only, so the quiz program uses that memory with a status added.
The quiz's design answers the unmapped read at 80 with OK and 00, as in the hook. This comparator reports design UVM_TLM_OK_RESPONSE, model UVM_TLM_ADDRESS_ERROR_RESPONSE, the error the hook's bench never saw. U9.5 takes the same structure to the scale of a virtual platform.
Try it
Five blocking calls, each followed by a wait for its annotated time, then two nonblocking reads started at 100 ns. The read at 80 returns ADDRESS_ERROR and leaves the initiator's 00 in the data. The bank 0 read completes at 120 ns, before the bank 1 read started ahead of it.
Delete the #(dly.get_realtime(1ns) * 1ns); line in b_call and rerun: every blocking call now happens at 0 ns, and the nonblocking reads start at 0 too. The values are the same; only time has stopped advancing.
0 ns b_transport WRITE 01 -> OK, data 11, annotated 20 ns 20 ns b_transport WRITE 40 -> OK, data 01, annotated 20 ns 40 ns b_transport READ 01 -> OK, data ee, annotated 20 ns 60 ns b_transport READ 12 -> OK, data 2d, annotated 40 ns 100 ns b_transport READ 80 -> ADDRESS_ERROR, data 00, annotated 0 ns 100 ns target fw BEGIN_REQ 12, returns UVM_TLM_UPDATED with END_REQ 100 ns initiator fw 12 returned UVM_TLM_UPDATED, phase END_REQ 100 ns target fw BEGIN_REQ 01, returns UVM_TLM_UPDATED with END_REQ 100 ns initiator fw 01 returned UVM_TLM_UPDATED, phase END_REQ 120 ns initiator bw BEGIN_RESP 01, data ee, returns UVM_TLM_COMPLETED 120 ns target bw 01 returned UVM_TLM_COMPLETED 140 ns initiator bw BEGIN_RESP 12, data 2d, returns UVM_TLM_COMPLETED 140 ns target bw 12 returned UVM_TLM_COMPLETED UVM_ERROR : 0Check yourself
Remember
- TLM-2.0 connects models through sockets: an initiator calls
b_transportornb_transport_fw, a target implements them, all on one generic payload. - The response status is part of the answer: it starts as
INCOMPLETE, the target sets OK or an error, and the initiator checks it before using the data. - A loosely timed target annotates latency on a
uvm_tlm_timeand returns at once; the initiator chooses when simulation time catches up, which allows temporal decoupling. - Nonblocking transport splits a transaction into phases on a forward and a backward path; both calls are functions, so a target answers later from its own threads, possibly out of order, and each transaction in flight needs its own payload.
- A predictor buffers requests in an analysis FIFO and calls the socket from
run_phase(a function cannot call a task); the model's status is part of the prediction.