Contracts in classes
Write a caller that holds only an abstract or interface-class handle, so that any class that keeps its promise can receive the calls, and see why every UVM connection is built this way.
- Lesson
- U3.1
- Time
- 30 min
- Runs on
- Verilator with UVM
- Check
- 5 questions
You write a packet producer for a block bench. Today it hands packets to a logger, next week to a scoreboard, next month to a bus model. If the producer stores a logger handle, every new receiver means editing the producer.
put_if #(pkt), and any object whose class implements that interface class can sit behind it.- Connect0 ns (illustrative)
prod.port.connect(lg)stores the logger intarget. The port sees it only as aput_if.More context
mini_port::connectis an ordinary assignment totarget, made by the test before the first send. Nothing moves along the call arrows yet. - Call0 ns (illustrative)
At 0 ns the producer calls
port.put(#1). The port callstarget.put(#1), and the object decides whichputruns: the logger's.More context
Packet #1 travels as the argument of the call. The port holds a
put_ifhandle; dispatch on the object pickslogger::put.Follow the calls, values and waits
- call put(#1): producer → mini_port
- call put(#1): mini_port → logger
- Waiting: producer — inside logger.put until 5 ns
- Wait5 ns (illustrative)
The logger's
putis a task with#5. The producer's thread runs inside it, so the producer resumes at 5 ns.More context
Trace lines 2 and 3: the logger stores #1 and the producer's
put(#1)returns in the same 5 ns time step.puthas no output argument, so only control comes back.Follow the calls, values and waits
- Released: producer
- Swap5 ns (illustrative)
connect(cnt)replaces the target. The same producer code now reaches the counter, which shares no base class with the logger.More context
implementsis a type relation fixed at compile time; only the assignment totargetchanges here. - Call again5 ns (illustrative)
At 5 ns the producer calls
port.put(#2). The call reachescounter::put, which counts #2 and returns without waiting.More context
Trace lines 4 to 6 share one time step: the counter's
puthas no delay, so the producer never waits.Follow the calls, values and waits
- call put(#2): producer → mini_port
- call put(#2): mini_port → counter
Read the diagram as text
- producer: Calls port.put(t)
- mini_port: Holds target, a put_if handle
- put_if #(pkt): Interface class: pure virtual task put(T t)
- logger: extends sink_base, implements put_if and reset_if
- counter: No base class; implements put_if
- producer → mini_port: port.put(t) call
- mini_port → logger: target.put(t) call
- mini_port → counter: target.put(t) call
- logger → put_if #(pkt): implements
- counter → put_if #(pkt): implements
The producer compiles once and works with every receiver that implements put_if. This is the shape of every UVM port.
The fix is a type that names only the methods the caller uses: the producer holds a handle of that type, and every receiver promises to provide them.
Three ways to declare the methods a caller needs
Lesson 4.4 showed the core rule. A call through a handle runs the method of the object, if the method is virtual. SystemVerilog gives you three ways to build on that rule, and they differ in what they force on the receiver.
| Declaration | What it says | What the receiver must do | Limit |
|---|---|---|---|
virtual function in a normal class | Here is a default; override it if you like | Nothing; the default runs if it does not override | A forgotten override is silent |
pure virtual in a virtual class | This method has no body; every concrete subclass supplies one | Extend the abstract class and implement each pure method | A class extends only one class |
interface class with implements | A named set of pure methods, with no data and no family | Add implements put_if and supply each method | No default bodies, no data members |
All three rely on dynamic dispatch. They differ in how much they force and how many a class can have.
An abstract class is a class declared virtual class. It cannot be constructed, and it may hold pure virtual methods: declarations with no body. Our sink_base has one pure method, kind(), plus a default describe() and a plain plain().
An interface class contains only pure virtual methods, types and parameters. A class lists it after implements. A class extends at most one class but may implement any number of interface classes, which is why logger can be a sink_base, a put_if and a reset_if at once.
A caller that holds only an interface handle
The example builds the smallest possible port by hand. mini_port has one member, put_if #(T) target, and a put task that calls target.put(t). It has never heard of loggers or counters.
producer: port.put(#1) at 0 ns
The producer calls its port. The port is an ordinary object, so this is an ordinary task call.
port: target.put(#1)
The handle type is
put_if; the object is alogger. Dynamic dispatch runslogger::put.logger.put: stored #1 at 5 ns
The logger waits 5 ns and stores the packet. The producer's thread is inside this task for all 5 ns.
producer: put(#1) returned at 5 ns
Control comes back the way it went. The packet travelled with the call, from caller to receiver.
Then the test connects the counter instead and sends #2. The counter has no base class, yet it receives the packet through the same producer code. Trace lines 4 to 6 show it in the same time step, because the counter's put does not wait.
One logger object can be seen through three handle types. Through sink_base the example calls kind() and describe(), and through reset_if it calls on_reset(). Each handle shows only its own methods: r.put(t) does not compile, even though the object has a put.
What breaks, and when you find out
These declarations move mistakes to compile time where they can. These are the errors this tool reports for the three common slips.
| Slip | What happens here | When |
|---|---|---|
A class implements put_if but has no put | Class 'counter' implements 'put_if' but is missing implementation for 'put' | Compile |
new on an abstract class | Illegal to call 'new' using an abstract virtual class 'sink_base' | Compile |
A base method is not virtual | The base body runs through a base handle; no message at all | Never reported |
Measured with this course's simulator. The third row is the dangerous one.
The silent case is in the example on purpose. plain() is not virtual, so s.plain() prints sink_base.plain although the object is a logger with its own plain(). An interface class cannot have this bug: every method in it is pure virtual, so every implementation is reached by dispatch.
Going back from an interface handle to a class takes a checked cast. $cast(back, s) succeeds only when the object really is a logger, and returns 0 otherwise. Use it rarely: a caller that casts to the concrete class depends on that class again.
Why UVM is built on this
A UVM port must call a method in a component that someone else will write, perhaps years later. That is the producer problem at library scale, and UVM solves it with two techniques at once.
- Call the port
The producer calls
put_port.put(t). To the producer this is an ordinary task call on a member object.More context
The packet travels as the argument of the call; the producer's thread follows it all the way to the consumer.
Follow the calls, values and waits
- call put(t): producer → uvm_blocking_put_port
- Port side
The port and the imp both extend
uvm_port_base, which extendsuvm_tlm_if_base. The port'sm_ifis a base-class handle, som_if.put(t)dispatches to whatever imp was connected.More context
The extends arrows are class relations and never carry anything. The call is dispatched through the base-class handle set at end of elaboration.
Follow the calls, values and waits
- call put(t): uvm_blocking_put_port → uvm_blocking_put_imp #(pkt, consumer)
- Imp side
The imp is a class parameterized by its owner's type.
m_impis aconsumerhandle, som_imp.put(t)is checked when the code compiles.More context
This last hop needs no dispatch: the imp knows its owner's exact class, so a missing
putis a compile error here.Follow the calls, values and waits
- call put(t): uvm_blocking_put_imp #(pkt, consumer) → consumer
Read the diagram as text
- producer: put_port.put(t)
- uvm_blocking_put_port: m_if: a base-class handle, set at end of elaboration
- uvm_blocking_put_imp #(pkt, consumer): m_imp: a consumer handle
- consumer: task put(pkt t)
- uvm_tlm_if_base: Abstract class: every TLM method, each with a default body that reports an error
- producer → uvm_blocking_put_port: put(t) call
- uvm_blocking_put_port → uvm_blocking_put_imp #(pkt, consumer): m_if.put(t): dispatch
- uvm_blocking_put_imp #(pkt, consumer) → consumer: m_imp.put(t): direct
- uvm_blocking_put_port → uvm_tlm_if_base: extends
- uvm_blocking_put_imp #(pkt, consumer) → uvm_tlm_if_base: extends
The caller knows only the base class; the imp knows its owner exactly. U3.2 builds a real connection like this one.
The port side is your mini_port, with uvm_tlm_if_base in the role of put_if. It is an abstract class whose methods have bodies that report UVM TLM interface task not implemented, rather than an interface class.
The imp side is different. The imp class takes the component's type as a parameter, uvm_blocking_put_imp #(pkt, consumer), and calls m_imp.put(t) on a consumer handle. The component needs no special base class, and a missing put is a compile error in the imp. That adapter is what lets any uvm_component receive TLM calls.
Try it
Lines 1 to 6 are two trips through the same producer and port, first to a logger that takes 5 ns, then to a counter that takes none. Line 7 shows dispatch at work for kind(), a default for describe(), and the silent non-virtual trap for plain().
Try it: delete the put task from counter and rebuild to see the missing-implementation error. Then make plain() virtual in sink_base and watch line 7 change to logger.plain.
1 0 ns producer: port.put(#1) 2 5 ns logger.put: stored #1 (1 kept) 3 5 ns producer: put(#1) returned 4 5 ns producer: port.put(#2) 5 5 ns counter.put: #2 is number 1 6 5 ns producer: put(#2) returned 7 5 ns through sink_base: kind()=logger describe()=a sink plain()=sink_base.plain 8 5 ns logger.on_reset: cleared 9 5 ns $cast(back, s) = 1, back.kind() = logger UVM_ERROR : 0Check yourself
Remember
- A caller that holds a base-class or interface-class handle works with every receiver that implements those methods; dynamic dispatch picks the receiver's method.
- An abstract class (
virtual class) can carry pure methods, defaults and data; an interface class carries only pure methods, and a class may implement many. - A missing implementation and
newon an abstract class are compile errors; a base method that is notvirtualfails silently. - A UVM port reaches the imp through a base-class handle; the imp reaches its component through a handle of the component's exact class, given as a type parameter.