When there is no interface
Drive a design through a legacy BFM module that has no interface by passing an abstract class handle, give each instance its own handle, and explain why emulation splits a bench into two top modules.
- Lesson
- U12.3
- Time
- 25 min
- Runs on
- Verilator with UVM
- Check
- 5 questions
The lane design arrives with a bus functional model (BFM) from another team: a Verilog module whose tasks write and wait_result are the supported way to use the pins. A module is not an interface, so virtual legacy_bfm does not compile, and the team will not convert the file. The UVM driver, a class, still has to call those tasks.
A handle instead of a virtual interface
get returns a copy of the stored handle and the entry stays for any other reader.- Publish0 ns (illustrative)
At time 0, each
lane_wrapcreates its proxy and stores it as alane_bfm_apifor its own agent's path.More context
Lane 0's wrapper stores at "uvm_test_top.lane0" and lane 1's at "uvm_test_top.lane1". A shared scope such as "uvm_test_top.*" would let the later set win for both agents.
Follow the calls, values and waits
- data lane_bfm_api handle: lane_bfm_proxy → uvm_config_db
- Fetch
In
build_phasethe agent gets the handle and gives it to its driver and monitor. They know only the abstract class.More context
getcopies the handle; the entry stays in the database. The run's first line, "agent lane0 drives lane0; agent lane1 drives lane1", confirms each agent got its own lane.Follow the calls, values and waits
- data lane_bfm_api handle: uvm_config_db → bfm_driver
- Call10 ns (illustrative)
bfm.send(10)dispatches to the proxy, which callsbfm.write(10)in the same module instance. The task drives the pins at 10 ns.More context
writewaits for the falling edge at 10 ns, then raisesvalidwith x = 10. The driver's call is still open: the module task holds it.Follow the calls, values and waits
- call send(10): bfm_driver → lane_bfm_proxy
- call bfm.write(10): lane_bfm_proxy → legacy_bfm
- data valid, x = 10: legacy_bfm → incr_rtl pins
- Waiting: bfm_driver — in bfm.send(10)
- Return20 ns (illustrative)
writereturns at the next falling edge, 20 ns, sosendreturns to the driver, which can take its next item.More context
Measured by the predict item: "send returned at 20 and 40 ns". The design registered the beat at the rising edge of 15 ns.
Follow the calls, values and waits
- return task returns: legacy_bfm → lane_bfm_proxy
- return task returns: lane_bfm_proxy → bfm_driver
- Released: bfm_driver
- Observe25 ns (illustrative)
The monitor's
next_resultruns inside the wrapper too, so it can wait on the wrapper's wires and the BFM'swait_result.More context
wait_resultseesout_validat the rising edge of 25 ns and the call returns 11; the scoreboard logs "25 ns lane0 y=11 matches".Follow the calls, values and waits
- call next_result(r): bfm_monitor → lane_bfm_proxy
- return r = 11: lane_bfm_proxy → bfm_monitor
Read the diagram as text
- bfm_driver: Holds lane_bfm_api bfm; calls bfm.send(x)
- bfm_monitor: Calls next_input, next_result
- uvm_config_db: Entry "bfm" for uvm_test_top.lane0; get leaves it in place
- lane_bfm_proxy: Concrete class inside lane_wrap
- legacy_bfm: Module tasks write, wait_result
- incr_rtl pins: valid, x, out_valid, y
- lane_bfm_proxy → uvm_config_db: set at time 0
- uvm_config_db → bfm_driver: get returns the handle
- bfm_driver → lane_bfm_proxy: send(x)
- lane_bfm_proxy → legacy_bfm: bfm.write(x)
- legacy_bfm → incr_rtl pins: drives
- bfm_monitor → lane_bfm_proxy: next_result
The concrete class is the only code that knows both worlds. The agent package never names a module.
The fix is the abstract/concrete class pattern. The agent's package declares an abstract class with pure virtual tasks: what a lane can do, with no pins in it. A concrete class that implements those tasks is declared inside the wrapper module, next to the BFM. An object of it travels to the agent through the config database, typed as the abstract class.
The driver then calls bfm.send(req.x) exactly as it would call a task through a virtual interface. Dispatch lands in the concrete class, which runs inside the wrapper and can call bfm.write there.
The abstract class
virtual class lane_bfm_api;
pure virtual task send(bit [7:0] v); // drive one beat
pure virtual task next_input(output bit [7:0] v); // the next beat the design takes
pure virtual task next_result(output bit [7:0] r); // the next result
pure virtual function string where();
endclassvirtual class means no object of it can be created. pure virtual means every class that extends it and is meant to be created must implement the method. A wrapper that forgets one fails to compile, which is much better than a lane that does nothing.
Keep the abstract class small and transaction-level: one beat, one result. If the driver needs a new capability later, the abstract class and every concrete class change together.
The concrete class lives with the pins
bfm, clk, valid and x are names in this module instance.module lane_wrap #(parameter int LANE = 0) (input logic clk);
// ... the design, the BFM and their wires
class lane_bfm_proxy extends lane_bfm_api;
task send(bit [7:0] v); bfm.write(v); endtask
task next_input(output bit [7:0] v);
do @(posedge clk); while (!valid);
v = x;
endtask
// next_result and where() as in the example
endclass
lane_bfm_proxy proxy;
initial begin
proxy = new;
uvm_config_db #(lane_bfm_api)::set(null, $sformatf("uvm_test_top.lane%0d", LANE), "bfm", proxy);
end
endmoduleA class declared inside a module can use that module's names: its wires, its parameters, and the tasks of instances inside it. Each instance of lane_wrap therefore has its own lane_bfm_proxy, bound to its own BFM. The example has two lanes, and where() reports which lane a handle reaches.
Each wrapper stores its handle at its own agent's path. Store both at a shared scope such as "uvm_test_top.*", and the later set replaces the earlier one. One agent then drives the other lane's pins, and the type system cannot notice.
The BFM keeps its timing
A legacy BFM decides when its task returns, and the driver waits for it. write raises valid at one falling edge and lowers it at the next, so each beat costs two falling edges. The next call waits for another falling edge, leaving one idle cycle between beats.
Each column holds a value from that time until the next. Falling edges at 10, 20, 30, 40 ns; rising edges at 15, 25, 35, 45 ns. A rising edge samples the column before it, so the result shown from 15 ns is seen at 25 ns.
Two consequences follow. A pipelined or back-to-back sequence cannot run faster than the BFM allows, whatever the driver does. And a module task is static by default, so two threads calling write at once share its argument. Keep one caller per BFM.
Dual top, protocol modules and emulation
- The bench calls the API
An agent calls
send(x)on itslane_bfm_apihandle, exactly as in simulation. The handle now refers to a host proxy class on the workstation.More context
Only the concrete class changes. The agent package and the abstract class are the same ones the runnable example uses.
Follow the calls, values and waits
- call send(x): Tests and agents → Host proxy class
- The call crosses as a message
The host proxy turns the call into a message, and the emulation flow carries it to the transactor.
More context
No class handle and no hierarchical reference crosses this boundary; on an emulator they have no meaning on the hardware side.
Follow the calls, values and waits
- data message: x: Host proxy class → Message link
- data message: x: Message link → Transactor
- Hardware drives the pins
The synthesizable transactor, clocked in the emulator, puts x on the DUT's pins.
More context
The clock and reset are generated in hardware, not by an
alwaysblock inhdl_top. Nothing on this side is measured here: no emulator is available to this runner.Follow the calls, values and waits
- data clock edges: Clock and reset → Transactor
- data x on the pins: Transactor → DUT
Read the diagram as text
- Tests and agents: Unchanged: call lane_bfm_api
- Host proxy class: Implements lane_bfm_api; turns calls into messages
- Message link: Provided by the emulation flow
- Clock and reset: Generated in hardware
- Transactor: Synthesizable; replaces the BFM tasks
- DUT: Synthesizable RTL
- Tests and agents → Host proxy class: send(x)
- Host proxy class → Message link: message
- Message link → Transactor: message
- Transactor → DUT: pins
- Clock and reset → Transactor: clk
Conceptual. In the runnable example, lane_bfm_proxy and the BFM tasks still sit in hdl_top; that is a simulation arrangement. The table below lists what must move or be rewritten.
Dual top puts the hardware in one top module, hdl_top, and the class-based bench in another, hvl_top, which calls run_test. In the example the two meet only through the config database. That is a simulation arrangement: hdl_top still holds the class lane_bfm_proxy and the BFM's behavioural tasks.
Emulation is why the split matters. An emulator compiles the hardware side, which must then be synthesizable and free of class code. The figure shows that partition: the proxy moves to the workstation, the BFM becomes a synthesizable transactor, and each call crosses as a message. A hierarchical reference from a class into the DUT has no meaning there.
| Piece | Runnable example (simulation) | Emulation partition |
|---|---|---|
Agent, driver, monitor, lane_bfm_api | In hvl_top | Unchanged |
| Concrete class | lane_bfm_proxy, inside lane_wrap in hdl_top | A host proxy on the workstation that sends messages |
| BFM | Behavioural tasks that wait on @(negedge clk) | A synthesizable transactor driven by messages |
| Hand-off | config_db set from lane_wrap's initial block | The bench creates the host proxy; the emulation flow links it to the transactor |
| Clock | An always block in hdl_top | Generated in the emulator |
What must change to emulate. The UVM side stays as it is, which is the payoff of reaching the pins only through lane_bfm_api.
A protocol module wraps one DUT interface with what belongs to it: the interface or BFM, assertions, and the code that registers the handle. A change in how the bench connects stays inside that module and never reaches the top. The example's lane_wrap is a small protocol module.
Try it
The first line comes from end_of_elaboration_phase: each agent's handle reaches its own lane. Then each scoreboard checks its two results.
Change the scope in lane_wrap to "uvm_test_top.*" and rerun: both agents get the same handle. Then delete the next_result task from lane_bfm_proxy and see the compiler refuse the class.
agent lane0 drives lane0; agent lane1 drives lane1 25 ns lane0 y=11 matches 25 ns lane1 y=21 matches 45 ns lane0 y=12 matches 45 ns lane1 y=22 matches UVM_ERROR : 0Check yourself
Remember
- When the bench must call tasks of a module that is not an interface, pass a handle to an abstract class with pure virtual tasks instead of a virtual interface.
- Declare the concrete class inside the wrapper module: it can call that instance's BFM tasks and read its wires, and each wrapper instance gets its own class.
- Store each handle at its own agent's path; a shared scope lets the later
setwin and sends an agent to the wrong lane. - A legacy BFM keeps its own timing and, as a static module task, one caller at a time.
- Dual top separates synthesizable hardware from the class-based bench so the hardware side can run in an emulator; protocol modules keep each interface's connection details together.