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.

The producer knows an interface class, never a receiverArrows labelled call are run-time calls; arrows labelled implements are type relations, fixed when the code compiles. The port holds one handle of type put_if #(pkt), and any object whose class implements that interface class can sit behind it.
port (calls out)call: who calls whom
The producer knows an interface class, never a receiverArrows labelled *call* are run-time calls; arrows labelled *implements* are type relations, fixed when the code compiles. The port holds one handle of type `put_if #(pkt)`, and any object whose class implements that interface class can sit behind it. component producer Calls port.put(t) port mini_port Holds target, aput_if handle interface put_if #(pkt) Interface class: purevirtual task put(T t) component logger extends sink_base,implements put_if andreset_if component counter No base class;implements put_if port.put(t) calltarget.put(t) calltarget.put(t) callimplementsimplements
  1. Connect0 ns (illustrative)

    prod.port.connect(lg) stores the logger in target. The port sees it only as a put_if.

    More context

    mini_port::connect is an ordinary assignment to target, made by the test before the first send. Nothing moves along the call arrows yet.

  2. Call0 ns (illustrative)

    At 0 ns the producer calls port.put(#1). The port calls target.put(#1), and the object decides which put runs: the logger's.

    More context

    Packet #1 travels as the argument of the call. The port holds a put_if handle; dispatch on the object picks logger::put.

    Follow the calls, values and waits
    1. call put(#1): producer → mini_port
    2. call put(#1): mini_port → logger
    3. Waiting: producer — inside logger.put until 5 ns
  3. Wait5 ns (illustrative)

    The logger's put is 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. put has no output argument, so only control comes back.

    Follow the calls, values and waits
    1. Released: producer
  4. 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

    implements is a type relation fixed at compile time; only the assignment to target changes here.

  5. Call again5 ns (illustrative)

    At 5 ns the producer calls port.put(#2). The call reaches counter::put, which counts #2 and returns without waiting.

    More context

    Trace lines 4 to 6 share one time step: the counter's put has no delay, so the producer never waits.

    Follow the calls, values and waits
    1. call put(#2): producer → mini_port
    2. 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.

DeclarationWhat it saysWhat the receiver must doLimit
virtual function in a normal classHere is a default; override it if you likeNothing; the default runs if it does not overrideA forgotten override is silent
pure virtual in a virtual classThis method has no body; every concrete subclass supplies oneExtend the abstract class and implement each pure methodA class extends only one class
interface class with implementsA named set of pure methods, with no data and no familyAdd implements put_if and supply each methodNo 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.

  1. 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.

  2. port: target.put(#1)

    The handle type is put_if; the object is a logger. Dynamic dispatch runs logger::put.

  3. 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.

  4. 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.

SlipWhat happens hereWhen
A class implements put_if but has no putClass 'counter' implements 'put_if' but is missing implementation for 'put'Compile
new on an abstract classIllegal to call 'new' using an abstract virtual class 'sink_base'Compile
A base method is not virtualThe base body runs through a base handle; no message at allNever 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.

Inside one UVM put connectionThe three arrows along the top row are run-time calls; the two arrows labelled extends are class relations. The port reaches the imp through a base-class handle; the imp reaches the component through a handle of its exact class.
port (calls out)imp (implements)call: who calls whom
Inside one UVM put connectionThe three arrows along the top row are run-time calls; the two arrows labelled *extends* are class relations. The port reaches the imp through a base-class handle; the imp reaches the component through a handle of its exact class. component producer put_port.put(t) port uvm_blocking_put_port m_if: a base-class handle,set at end of elaboration imp uvm_blocking_put_imp#(pkt, consumer) m_imp: a consumer handle component consumer task put(pkt t) interface uvm_tlm_if_base Abstract class: every TLMmethod, each with a defaultbody that reports an error put(t) callm_if.put(t): dispatchm_imp.put(t): directextendsextends
  1. 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
    1. call put(t): producer → uvm_blocking_put_port
  2. Port side

    The port and the imp both extend uvm_port_base, which extends uvm_tlm_if_base. The port's m_if is a base-class handle, so m_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
    1. call put(t): uvm_blocking_put_port → uvm_blocking_put_imp #(pkt, consumer)
  3. Imp side

    The imp is a class parameterized by its owner's type. m_imp is a consumer handle, so m_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 put is a compile error here.

    Follow the calls, values and waits
    1. 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.

RUNVerilator + UVM 2020.3.1 · simulator service
Edit the code, then run it
Expected among the UVM messages: 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 : 0

Check yourself

Predict the outputA doubler implements put_if by passing twice the value to another put_if. The program sends 3 and 4 through the doubler and 1 straight to the logger. What does it print?

interface class put_if;
  pure virtual task put(int x);
endclass

virtual class sink_base;
  pure virtual function string kind();
  function string plain(); return "base"; endfunction
endclass

class logger extends sink_base implements put_if;
  int total;
  virtual task put(int x); total += x; endtask
  virtual function string kind(); return "logger"; endfunction
  function string plain(); return "logger"; endfunction
endclass

// Implements put by passing twice the value on to another put_if.
class doubler implements put_if;
  put_if next;
  virtual task put(int x); next.put(2 * x); endtask
endclass

module tb;
  initial begin
    logger    lg = new;
    doubler   d  = new;
    put_if    p;
    sink_base s  = lg;
    d.next = lg;
    p = d;
    p.put(3);
    p.put(4);
    lg.put(1);
    $display("%s %s %0d", s.kind(), s.plain(), lg.total);
  end
endmodule
Spot the bugThe port holds a sink_base handle and calls target.accept(t) three times. The scoreboard extends sink_base and declares virtual function void accept(pkt t), which counts. Each option is the declaration of accept in sink_base. Which one makes the scoreboard receive nothing?
ConceptA scoreboard already extends uvm_component. It must receive packets from a caller that holds a put_if #(pkt) handle, and reset notices from a caller that holds a reset_if handle. Which declaration lets one object serve both callers?
ConceptA consumer component declares uvm_blocking_put_imp #(pkt, consumer) put_imp; but has no put task. When is the mistake reported?
ConceptWhy does a UVM port reach its target through a base-class handle rather than a handle of the receiving component's class?

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 new on an abstract class are compile errors; a base method that is not virtual fails 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.