Verify This! · the course to read

Day 4How the components talk

The same slides as the deck, with the instructor notes inside the text and the complete code. If you are taking the course on your own, start here and keep the deck alongside for the reviews.

Yesterday we left…

Where the testbench stands

The day 4 recap has to leave the observer problem served, because the whole unit is that: a producer that does not know its listeners. It is worth asking the question and waiting: "if tomorrow they want to measure coverage as well as compare, where do they touch?". The natural answer —"I add a call in the monitor"— is exactly the one the unit comes to take apart.

see this slide in the deck ›

Agenda

Day 4 · ≈ 4 h · unit 5 · how the components talk

By the end of the day you can:

The first one is row 11 of the closing self-assessment and it is what defines the unit: the observer exists so that adding a listener does not touch the one that publishes. The third one is the second exercise of the day and it stands as an objective on its own: the #500 is the patch everybody writes the first time, and taking it out forces you to understand what keeps the simulation alive.

see this slide in the deck ›

One producer, many listeners

Two ways of talking between objects

This slide is the map of the day: the four sections that follow are the two columns below, in order. It is worth writing the two columns on the board and coming back to them every time a new port shows up, because the question to ask yourself in front of any UVM port is always the same: can this block? If it runs in my thread, no; if it runs in another one, yes, and then it has to be a task. That is also the criterion that orders the vocabulary of the day: write() is a function —it consumes no time—, put() and get() are task. The signature tells you which side of the divide you are on before you read a line of the implementation.

see this slide in the deck ›

One producer, many listeners

The one who publishes does not know who reads it

The dice example is deliberate and it is worth defending if somebody asks why we do not start with the VTALU: you see the pattern without the noise of the protocol. When it shows up on the DUT in the analysis ports, the student is no longer learning two things at once. The question that orders the section: which is the only line that has to be written to add a fourth observer? A connect(). None in the producer. That is the criterion to look at every line of code from here on with. And it is worth anticipating the limit, because it is inter-thread communication: this is communication inside a single thread. write() is a function, it consumes no time, and it runs in the thread of the one publishing. To talk between threads something else is needed.

see this slide in the deck ›

One producer, many listeners

The problem: two dice, three things to count

Rolling 2d6: the sum goes from 2 to 12 and 7 is the most likely

It is worth posing it as a programming problem before showing a single line: they are three consumers of the same piece of data, and each one does something different with it. Nobody thinks about hardware here, which is exactly the idea. The three reports have something in common that is worth pointing out now: none of them can be worked out from a single roll. The three accumulate and only report at the end, and that is why the three are going to need two methods, not one. And a quick question for the classroom, one that gets collected later: if tomorrow they ask for a fourth report —the most repeated roll, say—, which files have to be touched?

see this slide in the deck ›

One producer, many listeners

The three observers: the same shape, three bodies

code/u5/varios-objetos/01-sin-analysis-port/average.svh
class average extends uvm_component;
   `uvm_component_utils(average);

   protected real dice_total;
   protected real count;

   function new(string name, uvm_component parent = null);
      super.new(name, parent);
      dice_total = 0.0;
      count = 0.0;
   endfunction : new

   function void write(int t);
      dice_total = dice_total + t;
      count++;
   endfunction : write

   function void report_phase(uvm_phase phase);
      `uvm_info("AVERAGE", $sformatf("DICE AVERAGE: %2.1f", dice_total / count), UVM_NONE)
   endfunction : report_phase

endclass : average

It is worth showing the whole class only once —this one— and after that only the write()s, because what matters is that the shape repeats. Two details that get collected later: the report_phase runs after the run_phase —and after extract and check—, not inside it, and that is why the average comes out complete. And dice_total and count are protected: the one publishing cannot touch them, only call write(). When this is the VTALU, in the analysis ports, average is going to be the scoreboard and the report_phase the verdict of the run. Same shape.

see this slide in the deck ›

One producer, many listeners

The other two: the body changes, not the shape

code/u5/varios-objetos/01-sin-analysis-port/histogram.svh · #write
   function void write(int t);
      rolls[t]++;
   endfunction : write
el archivo entero, 29 líneas
class histogram extends uvm_component;
   `uvm_component_utils(histogram);

   int rolls[int];

   function new(string name, uvm_component parent);
      super.new(name, parent);
      for (int ii = 2; ii <= 12; ii++) rolls[ii] = 0;
   endfunction : new

   function void write(int t);
      rolls[t]++;
   endfunction : write

   function void report_phase(uvm_phase phase);
      string bar;
      string message;

      message = "\n";
      foreach (rolls[ii]) begin
         string roll_msg;
         bar = "";
         repeat (rolls[ii]) bar = {bar, "#"};
         roll_msg = $sformatf("%2d: %s\n", ii, bar);
         message = {message, roll_msg};
      end
      `uvm_info("HISTOGRAM", message, UVM_NONE)
   endfunction : report_phase
endclass : histogram
code/u5/varios-objetos/01-sin-analysis-port/coverage.svh · #write
   function void write(int t);
      the_roll = t;
      dice_cg.sample();
   endfunction : write
el archivo entero, 24 líneas
class coverage extends uvm_component;
   `uvm_component_utils(coverage);
   int the_roll;

   covergroup dice_cg;
      coverpoint the_roll {bins twod6[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};}
   endgroup

   function new(string name, uvm_component parent = null);
      super.new(name, parent);
      dice_cg = new();
   endfunction : new

   function void write(int t);
      the_roll = t;
      dice_cg.sample();
   endfunction : write

   function void report_phase(uvm_phase phase);
      `uvm_info("COVERAGE", $sformatf("COVERAGE: %2.0f%%", dice_cg.get_inst_coverage()),
                UVM_NONE)
   endfunction : report_phase

endclass : coverage

The point of the slide is the last bullet: three classes that look nothing alike expose the same door. When uvm_subscriber shows up, that door is going to be a contract of the language instead of a convention. The coverage here is the answer to a question left open in the conventional testbench: where do I sample? Here the sample() is not tied to a clock edge, it is tied to a piece of data having arrived. That is the right thing, and it is what the coverage of every testbench of the course does from here on. The whole file of each one is in code/u5/varios-objetos/01-sin-analysis-port/: what is not shown is the report_phase, which only formats.

see this slide in the deck ›

One producer, many listeners

The producer: it rolls the dice and returns a number

code/u5/varios-objetos/01-sin-analysis-port/dice_roller.svh · #dice_roller
class dice_roller extends uvm_component;
   `uvm_component_utils(dice_roller);

   rand byte die1;
   rand byte die2;

   constraint d6 {
      die1 >= 1;
      die1 <= 6;
      die2 >= 1;
      die2 <= 6;
   }

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   function byte two_dice();
      byte the_roll;
      void'(randomize());
      the_roll = die1 + die2;
      return the_roll;
   endfunction : two_dice

endclass : dice_roller
el archivo entero, 25 líneas
class dice_roller extends uvm_component;
   `uvm_component_utils(dice_roller);

   rand byte die1;
   rand byte die2;

   constraint d6 {
      die1 >= 1;
      die1 <= 6;
      die2 >= 1;
      die2 <= 6;
   }

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   function byte two_dice();
      byte the_roll;
      void'(randomize());
      the_roll = die1 + die2;
      return the_roll;
   endfunction : two_dice

endclass : dice_roller

It is worth stopping on the absence: the producer is already well written. It does not need fixing, and in fact in the version with an analysis port it barely changes. The void'(randomize()) deserves a comment in passing: it throws away the return value on purpose. In the transactions we are going to see why that is a bad habit —a randomize() that fails and nobody finds out— and how it gets written properly. The question to leave hanging: if the producer does not have the list, who does? The slide that follows.

see this slide in the deck ›

One producer, many listeners

The one handing it out: the test, and it should not be

code/u5/varios-objetos/01-sin-analysis-port/dice_test.svh · #run_phase
   task run_phase(uvm_phase phase);
      int the_roll;
      phase.raise_objection(this);
      // This part needs attention.
      // What belongs here is:
      //	the Observer design pattern
      //	a UVM analysis port
      // Otherwise none of the OOP magic is being used:
      // these are plain functions passing data along
      repeat (20) begin
         the_roll = dice_roller_h.two_dice();
         coverage_h.write(the_roll);
         histogram_h.write(the_roll);
         average_h.write(the_roll);
      end
      phase.drop_objection(this);
   endtask : run_phase
el archivo entero, 38 líneas
class dice_test extends uvm_test;
   `uvm_component_utils(dice_test);

   dice_roller dice_roller_h;
   coverage coverage_h;
   histogram histogram_h;
   average average_h;

   function void build_phase(uvm_phase phase);
      coverage_h = new("coverage_h", this);
      histogram_h = new("histogram_h", this);
      average_h = new("average_h", this);
      dice_roller_h = new("dice_roller_h", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      int the_roll;
      phase.raise_objection(this);
      // This part needs attention.
      // What belongs here is:
      //	the Observer design pattern
      //	a UVM analysis port
      // Otherwise none of the OOP magic is being used:
      // these are plain functions passing data along
      repeat (20) begin
         the_roll = dice_roller_h.two_dice();
         coverage_h.write(the_roll);
         histogram_h.write(the_roll);
         average_h.write(the_roll);
      end
      phase.drop_objection(this);
   endtask : run_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass : dice_test

This slide is the "before", and it has to be allowed to look ugly. Look at the repeat (20): three write()s written out by hand, one per observer. Adding a fourth means touching this class; removing one, too. Worse still, and it is what is worth pointing at: the one left in charge of handing out the data is the test. Which means the dice_roller produces and the test distributes — two responsibilities that have no reason to be together, and the distributing one is the one that is going to change all the time. The question to throw out before moving on to the next one: if this were the VTALU, who would the dice_roller be and who the three write()s? The monitor, and the scoreboard plus the coverage. The analysis ports are literally this slide with other names.

see this slide in the deck ›

One producer, many listeners

Observer Design Pattern

Observer: one sender publishes and N observers receive

The analogy works better the other way round: the one who publishes does not know who reads them, and that is exactly the property you want in a monitor. Adding a new subscriber —another coverage, a log— does not touch one line of the one producing the data. The dice example is deliberately foreign to hardware: you see the pattern without the noise of the protocol.

see this slide in the deck ›

One producer, many listeners

The pattern, ready-made: the two ends of the wire

Notation: diamond = uvm_analysis_port, circle = analysis_export

The names cost a minute and save half an hour later: port is the end of the one producing, export the end of the one consuming, and the connect() is always called on the port. uvm_analysis_port is a broadcast: it does not care whether it has zero, one or ten subscribers, and writing to an unconnected port is not an error — the data is lost in silence. It is one of the silent traps of the course, and the symptom is a scoreboard that never reports anything. uvm_subscriber is the other end and it is a parameterized class: #(int) here, #(command_transaction) in the analysis ports. The connection ends up typed by the compiler, not by a string.

see this slide in the deck ›

One producer, many listeners

uvm_analysis_port

code/u5/varios-objetos/02-con-analysis-port/dice_roller.svh · #dice_roller
// Same as version 01, but publishing: instead of returning the number it writes
// it to an analysis port, and stops knowing who reads it.
class dice_roller extends uvm_component;
   `uvm_component_utils(dice_roller);

   rand byte die1;
   rand byte die2;

   constraint d6 {
      die1 >= 1;
      die1 <= 6;
      die2 >= 1;
      die2 <= 6;
   }

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   // 1) declare the port, with the data type it will carry
   uvm_analysis_port #(int) roll_ap;

   // 2) instantiate it in build_phase, with new(): ports do not go through the factory
   function void build_phase(uvm_phase phase);
      roll_ap = new("roll_ap", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      int the_roll;
      phase.raise_objection(this);
      repeat (20) begin
         void'(randomize());
         the_roll = die1 + die2;
         // 3) write: the port calls write() on every subscriber
         roll_ap.write(the_roll);
      end
      phase.drop_objection(this);
   endtask : run_phase

endclass : dice_roller
el archivo entero, 40 líneas
// Same as version 01, but publishing: instead of returning the number it writes
// it to an analysis port, and stops knowing who reads it.
class dice_roller extends uvm_component;
   `uvm_component_utils(dice_roller);

   rand byte die1;
   rand byte die2;

   constraint d6 {
      die1 >= 1;
      die1 <= 6;
      die2 >= 1;
      die2 <= 6;
   }

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   // 1) declare the port, with the data type it will carry
   uvm_analysis_port #(int) roll_ap;

   // 2) instantiate it in build_phase, with new(): ports do not go through the factory
   function void build_phase(uvm_phase phase);
      roll_ap = new("roll_ap", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      int the_roll;
      phase.raise_objection(this);
      repeat (20) begin
         void'(randomize());
         the_roll = die1 + die2;
         // 3) write: the port calls write() on every subscriber
         roll_ap.write(the_roll);
      end
      phase.drop_objection(this);
   endtask : run_phase

endclass : dice_roller

Three steps and no more: declare the port, instantiate it in build_phase, write with write(). On the other side the subscriber implements write() and gets connected in connect_phase. The detail that surprises: ports are not created with the factory, they are instantiated with new().

see this slide in the deck ›

One producer, many listeners

uvm_subscriber

code/u5/varios-objetos/02-con-analysis-port/coverage.svh
// uvm_subscriber:
//    extends the uvm_component class and lets you hook onto an analysis port
//    It is a parameterizable class!
//    The class gives you something and asks for something in return
//       * It gives you an object called analysis_export
//       * It asks you to write a write() method that handles the data

class coverage extends uvm_subscriber #(int);
   `uvm_component_utils(coverage);
   int the_roll;

   covergroup dice_cg;
      coverpoint the_roll {bins twod6[] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};}
   endgroup

   function new(string name, uvm_component parent = null);
      super.new(name, parent);
      dice_cg = new();
   endfunction : new

   function void write(int t);
      the_roll = t;
      dice_cg.sample();
   endfunction : write

   function void report_phase(uvm_phase phase);
      `uvm_info("COVERAGE", $sformatf("COVERAGE: %2.0f%%", dice_cg.get_inst_coverage()),
                UVM_NONE)
   endfunction : report_phase

endclass : coverage

uvm_subscriber is a deal in two parts and it is worth stating it that way: I give you an analysis_export, you give me back a write(). Nothing else. The analysis_export is neither declared nor instantiated — it comes with the class. The #(int) of the extends is the parameterized classes getting collected again: it defines what type the t of the write() is, and the compiler takes care that a port of int cannot be connected to a subscriber of something else. The connection is typed, it is not a string. Comparing it with the previous version of this same class is worth it: the write() is identical. The only thing that changed is who it inherits from and who calls it. That is the entire point of the section — the observer never finds out that it is now an observer. And a detail that shows up in the output: here the coverage is read with get_inst_coverage(), not with get_coverage(). It is a Verilator limitation —the type-wide one always returns 0— and it is in docs/en/verilator.md.

see this slide in the deck ›

One producer, many listeners

The producer, publishing now

code/u5/varios-objetos/02-con-analysis-port/dice_roller.svh · #port-and-run
   // 1) declare the port, with the data type it will carry
   uvm_analysis_port #(int) roll_ap;

   // 2) instantiate it in build_phase, with new(): ports do not go through the factory
   function void build_phase(uvm_phase phase);
      roll_ap = new("roll_ap", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      int the_roll;
      phase.raise_objection(this);
      repeat (20) begin
         void'(randomize());
         the_roll = die1 + die2;
         // 3) write: the port calls write() on every subscriber
         roll_ap.write(the_roll);
      end
      phase.drop_objection(this);
   endtask : run_phase
el archivo entero, 40 líneas
// Same as version 01, but publishing: instead of returning the number it writes
// it to an analysis port, and stops knowing who reads it.
class dice_roller extends uvm_component;
   `uvm_component_utils(dice_roller);

   rand byte die1;
   rand byte die2;

   constraint d6 {
      die1 >= 1;
      die1 <= 6;
      die2 >= 1;
      die2 <= 6;
   }

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   // 1) declare the port, with the data type it will carry
   uvm_analysis_port #(int) roll_ap;

   // 2) instantiate it in build_phase, with new(): ports do not go through the factory
   function void build_phase(uvm_phase phase);
      roll_ap = new("roll_ap", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      int the_roll;
      phase.raise_objection(this);
      repeat (20) begin
         void'(randomize());
         the_roll = die1 + die2;
         // 3) write: the port calls write() on every subscriber
         roll_ap.write(the_roll);
      end
      phase.drop_objection(this);
   endtask : run_phase

endclass : dice_roller

The comparison with the previous version is the whole slide: the dice_roller did not gain a handle to anybody. It gained one port. Why ports go with new() and not with type_id::create(): the factory is there to substitute types, and a port never gets substituted. It is a question that always comes up and that is the short answer. And the write() here is worth looking at with inter-thread communication in mind: it is a function, so it consumes no time and it runs in the producer's thread. When the one consuming needs to make the one producing wait, this is not enough.

see this slide in the deck ›

One producer, many listeners

connect_phase(): where the wiring gets assembled

The order of the two phases is not an implementation detail, it is what makes this work: you cannot connect what does not exist yet. That is why build_phase is top-down and finishes entirely before the first connect_phase starts. That connect_phase is bottom-up matters less in practice, but it has its reason: a composite component connects its children to each other only once every child has connected its own. In the agents, when the agent connects driver and sequencer inside itself and the env connects the agent outwards, it will show. The analysis_export that shows up on its own is one of the few things in UVM that come free: it comes with the class, and forgetting to instantiate it is not a mistake because there is nothing to instantiate.

see this slide in the deck ›

One producer, many listeners

connect_phase(): where the wiring gets assembled

code/u5/varios-objetos/02-con-analysis-port/dice_test.svh · #build-and-connect
   // build_phase: UVM calls it TOP-DOWN, the parent first.
   function void build_phase(uvm_phase phase);
      dice_roller_h = new("dice_roller_h", this);
      coverage_h = new("coverage_h", this);
      histogram_h = new("histogram_h", this);
      average_h = new("average_h", this);
   endfunction : build_phase

   // connect_phase: BOTTOM-UP, and always port.connect(export).
   function void connect_phase(uvm_phase phase);
      dice_roller_h.roll_ap.connect(coverage_h.analysis_export);
      dice_roller_h.roll_ap.connect(histogram_h.analysis_export);
      dice_roller_h.roll_ap.connect(average_h.analysis_export);
   endfunction : connect_phase
el archivo entero, 30 líneas
// The structure of the example: it instantiates the four components and WIRES the
// producer to the three observers. The run_phase is gone.
class dice_test extends uvm_test;
   `uvm_component_utils(dice_test);

   dice_roller dice_roller_h;
   coverage    coverage_h;
   histogram   histogram_h;
   average     average_h;

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   // build_phase: UVM calls it TOP-DOWN, the parent first.
   function void build_phase(uvm_phase phase);
      dice_roller_h = new("dice_roller_h", this);
      coverage_h = new("coverage_h", this);
      histogram_h = new("histogram_h", this);
      average_h = new("average_h", this);
   endfunction : build_phase

   // connect_phase: BOTTOM-UP, and always port.connect(export).
   function void connect_phase(uvm_phase phase);
      dice_roller_h.roll_ap.connect(coverage_h.analysis_export);
      dice_roller_h.roll_ap.connect(histogram_h.analysis_export);
      dice_roller_h.roll_ap.connect(average_h.analysis_export);
   endfunction : connect_phase

endclass : dice_test

This is the "after", and it has to be put next to the "before", the version without the analysis port. The run_phase of the test is left without the three write()s: now it only raises the objection and starts the producer. The distribution moved to the connect_phase, which is where the structure goes. The line to read slowly is dice_roller_h.roll_ap.connect(coverage_h.analysis_export), and the direction matters: the port connects to the export, never the other way round. It is the mantra that comes back in threads, in put and get and in agents, and the compiler does not always catch you. Pocket rule to remember the direction, and it has to be said this way because the easy version breaks straight away: the one that calls connect() is the one that has the port, and it hands it the other one's export. Here the one with the port is the producer; in put and get the uvm_get_port belongs to the consumer, and there it is the consumer that connects. "The producer connects" works on this slide and fails in the next section. And the acid test of the section: to add a fourth observer you have to write the class and one line here. Not one in dice_roller.

see this slide in the deck ›

One producer, many listeners

The solution, wired up

Wiring of the dice example: roll_ap against the three analysis_export

It is the first TLM diagram of the course and it is worth teaching how to read it now, because the ones from the rest of the day and the one from the agents use the same convention: diamond is analysis port, square is put/get port, circle is export. The arrow goes from the one producing to the one consuming. What has to be pointed out about the drawing is that three arrows come out of the roll_ap and a single line of code comes out of the dice_roller: the write(). The multiplicity is not in the producer, it is in the wiring. And the closing of the section, which links to the next one: all of this happened inside a single thread. write() is a function, it cannot have an @ or a #, and it came back before time moved. When the one producing and the one consuming have to wait for each other, this is not enough — and that is inter-thread communication.

see this slide in the deck ›

One producer, many listeners

Summary of the unit

The slide to open and close day 4 with, because it is the map. Write the two columns on the board and come back to them every time a new port shows up. And the criterion that orders the whole vocabulary: write() is a function —it consumes no time—, put() and get() are task. In SystemVerilog that is not a naming convention, it is the language telling you which side you are on.

see this slide in the deck ›

A single place that watches the wire

A single place that watches the wire

It is the same pattern as the dice, now on the DUT — and that is the whole section. What has to be stressed is where the dividing line of the testbench runs, because it is the one that orders everything that comes after: on one side of the monitor you speak in signals, on the other in transactions. The BFM of interfaces and BFM did that for the stimulus; the monitor does it for the analysis. The practical consequence is worth saying out loud: from here on, any analysis component can be written without knowing anything about the protocol. A new subscriber does not need to know that a done exists. And the one that only shows up in the agents: if everything that knows how to speak the protocol is together —BFM, monitors, driver—, that box can be instantiated twice. That is the agent, and this section is the one that gathers the pieces.

see this slide in the deck ›

A single place that watches the wire

Which pieces show up

The slide is the inventory of the section and it is worth using it as a map: there are five pieces and only two are new. It is worth saying out loud which ones, because the student tends to believe that a new topic rewrites the whole testbench. That there are two monitors and not one is the decision that gets asked about the most, so better to get ahead of it: they watch different things and at different moments. The command_monitor watches when an operation starts; the result_monitor, when the DUT answers. Between those two instants there are cycles in the middle, so they cannot be the same always. And the last bullet is the one to underline, because it is the promise the section keeps: the tester does not get touched. All the work of this section happens on the analysis side, and the stimulus side never finds out. It is the first time the course separates the two halves of the testbench, and in the section that follows the other one gets done.

see this slide in the deck ›

A single place that watches the wire

Testbench diagram

VTALU testbench with analysis ports

see this slide in the deck ›

A single place that watches the wire

The handles the BFM keeps

code/u5/analysis-ports/vtalu_bfm.sv · #handles
interface vtalu_bfm;
   import vtalu_pkg::*;

   // These handles get assigned in the build_phases of the classes
   command_monitor        command_monitor_h;
   result_monitor         result_monitor_h;

   byte unsigned          A;
   byte unsigned          B;
   bit                    clk;
   bit                    reset_n;
   bit                    start;
   wire                   done;
   wire            [15:0] result;
   wire                   ovf;
   operation_t            op_set;
el archivo entero, 87 líneas
interface vtalu_bfm;
   import vtalu_pkg::*;

   // These handles get assigned in the build_phases of the classes
   command_monitor        command_monitor_h;
   result_monitor         result_monitor_h;

   byte unsigned          A;
   byte unsigned          B;
   bit                    clk;
   bit                    reset_n;
   bit                    start;
   wire                   done;
   wire            [15:0] result;
   wire                   ovf;
   operation_t            op_set;

   // Here is the first monitor (commands)
   // A very simple FSM. If start is up, check whether this is a new command
   // If it is, send it to the TB with commands_monitor.write_to_monitor()
   always @(posedge clk) begin : cmd_monitor
      bit new_command;
      if (!start) new_command = 1;
      else if (new_command) begin
         command_monitor_h.write_to_monitor(A, B, op_set);
         new_command = (op_set == 3'b000);  // handle no_op
      end
   end : cmd_monitor

   always @(negedge reset_n) begin : rst_monitor
      if (command_monitor_h != null)  //guard against VCS time 0 negedge
         command_monitor_h.write_to_monitor(A, B, rst_op);
   end : rst_monitor

   always @(posedge clk) begin : rslt_monitor
      if (done) result_monitor_h.write_to_monitor(result);
   end : rslt_monitor

   initial begin
      clk = 0;
      forever begin
         #10;
         clk = ~clk;
      end
   end


   // --- the protocol, in the BFM ---
   // Everything that knows HOW the DUT is talked to lives here, in one place:
   // the rest of the testbench asks for an operation and never touches a wire.
   // That is the idea of the interfaces-and-BFM section, and it holds to the end of the course.

   task reset_alu();
      reset_n = 1'b0;
      @(negedge clk);
      @(negedge clk);
      reset_n = 1'b1;
      start = 1'b0;
   endtask : reset_alu

   task send_op(input byte iA, input byte iB, input operation_t iop);
      if (iop == rst_op) begin
         @(posedge clk);
         reset_n = 1'b0;
         start = 1'b0;
         @(posedge clk);
         #1;
         reset_n = 1'b1;
      end else begin
         @(negedge clk);
         op_set = iop;
         A = iA;
         B = iB;
         start = 1'b1;
         if (iop == no_op) begin
            @(posedge clk);
            #1;
            start = 1'b0;
         end else begin
            do @(negedge clk); while (done == 0);
            start = 1'b0;
         end
      end  // else: !if(iop == rst_op)

   endtask : send_op

endinterface : vtalu_bfm
code/u5/analysis-ports/tb_classes/command_monitor.svh · #build_phase
   function void build_phase(uvm_phase phase);
      virtual vtalu_bfm bfm;

      if (!uvm_config_db#(virtual vtalu_bfm)::get(this, "", "bfm", bfm))
         `uvm_fatal("COMMAND MONITOR", "Failed to get BFM")

      bfm.command_monitor_h = this;

      ap = new("ap", this);

   endfunction : build_phase
el archivo entero, 46 líneas
class command_monitor extends uvm_component;
   `uvm_component_utils(command_monitor);

   uvm_analysis_port #(command_s) ap;

   function void build_phase(uvm_phase phase);
      virtual vtalu_bfm bfm;

      if (!uvm_config_db#(virtual vtalu_bfm)::get(this, "", "bfm", bfm))
         `uvm_fatal("COMMAND MONITOR", "Failed to get BFM")

      bfm.command_monitor_h = this;

      ap = new("ap", this);

   endfunction : build_phase

   function void write_to_monitor(byte A, byte B, bit [2:0] op);
      command_s cmd;
      cmd.A = A;
      cmd.B = B;
      cmd.op = op2enum(op);
      `uvm_info("COMMAND MONITOR", $sformatf("A:0x%2h B:0x%2h op: %s", A, B, cmd.op.name()
                ), UVM_HIGH)
      ap.write(cmd);
   endfunction : write_to_monitor

   function operation_t op2enum(bit [2:0] op);
      case (op)
         3'b000: return no_op;
         3'b001: return add_op;
         3'b010: return sub_op;
         3'b011: return and_op;
         3'b100: return xor_op;
         3'b101: return mul_op;
         3'b111: return rst_op;
         default:
         `uvm_fatal("COMMAND MONITOR", $sformatf("Illegal operation on op bus: %3b", op))
      endcase  // case (op)
   endfunction : op2enum

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction

endclass : command_monitor

This slide holds the inversion that costs the most in the section and it is worth saying it slowly: up to now the class read the interface. Here it is the other way round — the interface has a handle to the class (bfm.command_monitor_h = this) and it is the interface that calls the method. The reason is that an always of the interface can have a sensitivity list, and a class function cannot. We need the hardware to give notice; for that the hardware has to know who to give notice to. The line that does everything is bfm.command_monitor_h = this, and it goes in the build_phase. If you forget it, the monitor compiles, runs, and never publishes: the scoreboard is left without commands and the uvm_fatal shows up on the other side, in a class that is not to blame. It is one of those errors that get hunted in the wrong place. A detail that helps: the handle is to the class, not virtual. That is why the always can call write_to_monitor() directly without the Verilator hole that affects tasks — here the one declaring the task is the class, not the interface.

see this slide in the deck ›

A single place that watches the wire

Monitoring the VTALU commands

code/u5/analysis-ports/vtalu_bfm.sv · #monitors
   // Here is the first monitor (commands)
   // A very simple FSM. If start is up, check whether this is a new command
   // If it is, send it to the TB with commands_monitor.write_to_monitor()
   always @(posedge clk) begin : cmd_monitor
      bit new_command;
      if (!start) new_command = 1;
      else if (new_command) begin
         command_monitor_h.write_to_monitor(A, B, op_set);
         new_command = (op_set == 3'b000);  // handle no_op
      end
   end : cmd_monitor

   always @(negedge reset_n) begin : rst_monitor
      if (command_monitor_h != null)  //guard against VCS time 0 negedge
         command_monitor_h.write_to_monitor(A, B, rst_op);
   end : rst_monitor

   always @(posedge clk) begin : rslt_monitor
      if (done) result_monitor_h.write_to_monitor(result);
   end : rslt_monitor
el archivo entero, 87 líneas
interface vtalu_bfm;
   import vtalu_pkg::*;

   // These handles get assigned in the build_phases of the classes
   command_monitor        command_monitor_h;
   result_monitor         result_monitor_h;

   byte unsigned          A;
   byte unsigned          B;
   bit                    clk;
   bit                    reset_n;
   bit                    start;
   wire                   done;
   wire            [15:0] result;
   wire                   ovf;
   operation_t            op_set;

   // Here is the first monitor (commands)
   // A very simple FSM. If start is up, check whether this is a new command
   // If it is, send it to the TB with commands_monitor.write_to_monitor()
   always @(posedge clk) begin : cmd_monitor
      bit new_command;
      if (!start) new_command = 1;
      else if (new_command) begin
         command_monitor_h.write_to_monitor(A, B, op_set);
         new_command = (op_set == 3'b000);  // handle no_op
      end
   end : cmd_monitor

   always @(negedge reset_n) begin : rst_monitor
      if (command_monitor_h != null)  //guard against VCS time 0 negedge
         command_monitor_h.write_to_monitor(A, B, rst_op);
   end : rst_monitor

   always @(posedge clk) begin : rslt_monitor
      if (done) result_monitor_h.write_to_monitor(result);
   end : rslt_monitor

   initial begin
      clk = 0;
      forever begin
         #10;
         clk = ~clk;
      end
   end


   // --- the protocol, in the BFM ---
   // Everything that knows HOW the DUT is talked to lives here, in one place:
   // the rest of the testbench asks for an operation and never touches a wire.
   // That is the idea of the interfaces-and-BFM section, and it holds to the end of the course.

   task reset_alu();
      reset_n = 1'b0;
      @(negedge clk);
      @(negedge clk);
      reset_n = 1'b1;
      start = 1'b0;
   endtask : reset_alu

   task send_op(input byte iA, input byte iB, input operation_t iop);
      if (iop == rst_op) begin
         @(posedge clk);
         reset_n = 1'b0;
         start = 1'b0;
         @(posedge clk);
         #1;
         reset_n = 1'b1;
      end else begin
         @(negedge clk);
         op_set = iop;
         A = iA;
         B = iB;
         start = 1'b1;
         if (iop == no_op) begin
            @(posedge clk);
            #1;
            start = 1'b0;
         end else begin
            do @(negedge clk); while (done == 0);
            start = 1'b0;
         end
      end  // else: !if(iop == rst_op)

   endtask : send_op

endinterface : vtalu_bfm
code/u5/analysis-ports/tb_classes/command_monitor.svh · #write_to_monitor
   function void write_to_monitor(byte A, byte B, bit [2:0] op);
      command_s cmd;
      cmd.A = A;
      cmd.B = B;
      cmd.op = op2enum(op);
      `uvm_info("COMMAND MONITOR", $sformatf("A:0x%2h B:0x%2h op: %s", A, B, cmd.op.name()
                ), UVM_HIGH)
      ap.write(cmd);
   endfunction : write_to_monitor
el archivo entero, 46 líneas
class command_monitor extends uvm_component;
   `uvm_component_utils(command_monitor);

   uvm_analysis_port #(command_s) ap;

   function void build_phase(uvm_phase phase);
      virtual vtalu_bfm bfm;

      if (!uvm_config_db#(virtual vtalu_bfm)::get(this, "", "bfm", bfm))
         `uvm_fatal("COMMAND MONITOR", "Failed to get BFM")

      bfm.command_monitor_h = this;

      ap = new("ap", this);

   endfunction : build_phase

   function void write_to_monitor(byte A, byte B, bit [2:0] op);
      command_s cmd;
      cmd.A = A;
      cmd.B = B;
      cmd.op = op2enum(op);
      `uvm_info("COMMAND MONITOR", $sformatf("A:0x%2h B:0x%2h op: %s", A, B, cmd.op.name()
                ), UVM_HIGH)
      ap.write(cmd);
   endfunction : write_to_monitor

   function operation_t op2enum(bit [2:0] op);
      case (op)
         3'b000: return no_op;
         3'b001: return add_op;
         3'b010: return sub_op;
         3'b011: return and_op;
         3'b100: return xor_op;
         3'b101: return mul_op;
         3'b111: return rst_op;
         default:
         `uvm_fatal("COMMAND MONITOR", $sformatf("Illegal operation on op bus: %3b", op))
      endcase  // case (op)
   endfunction : op2enum

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction

endclass : command_monitor

The three always are the seam between the hardware and the classes, and it is worth reading the condition of each one: the command one fires when start goes up and there was no command in flight; the reset one, on the falling edge of reset_n; the result one, when the DUT raises done. The guard if (command_monitor_h != null) is not paranoia: at time 0 the UVM tree does not exist yet and the negedge reset_n has already arrived. Without the guard it is a null pointer from the simulator. The write_to_monitor() is short on purpose: it assembles the command_s, logs it with UVM_HIGH —which means it is only visible with +UVM_VERBOSITY=UVM_HIGH— and calls ap.write(). Everything else happens on the other side of the port, and the monitor does not know who is over there. And the detail that gets collected in the transactions: what travels is still a struct. The day it is a transaction, this same line is going to be a create().

see this slide in the deck ›

A single place that watches the wire

The VTALU Coverage Class as a Subscriber

code/u5/analysis-ports/tb_classes/coverage.svh · #write
   // With the analysis port this is far simpler,
   // because nothing here has to know about the ALU signals:
   // what arrives is a command_s struct that wraps the command
   //
   function void write(command_s t);
         A = t.A;
         B = t.B;
         op_set = t.op;
         op_cov.sample();
         zeros_or_ones_on_ops.sample();
   endfunction : write
el archivo entero, 127 líneas
class coverage extends uvm_subscriber #(command_s);
   `uvm_component_utils(coverage)

   byte         unsigned        A;
   byte         unsigned        B;
   operation_t  op_set;

   covergroup op_cov;

      coverpoint op_set {
         bins single_cycle[] = {[add_op : xor_op], rst_op,no_op};
         bins multi_cycle = {mul_op};

         // Transition bins. Verilator 5.052 does NOT implement them yet: any form
         // with "=>" or "[* n]" aborts with an Internal Error. Minimal repro in
         // code/verilator/repro-cg-transition.sv. They stay because they are part
         // of the topic; when Verilator supports them, drop the ifndef, nothing else.
`ifndef VERILATOR
         bins opn_rst[] = ([add_op:mul_op] => rst_op);
         bins rst_opn[] = (rst_op => [add_op:mul_op]);

         bins sngl_mul[] = ([add_op:xor_op],no_op => mul_op);
         bins mul_sngl[] = (mul_op => [add_op:xor_op], no_op);

         bins twoops[] = ([add_op:mul_op] [* 2]);
         bins manymult = (mul_op [* 3:5]);

         bins rstmulrst   = (rst_op => mul_op [=  2] => rst_op);
         bins rstmulrstim = (rst_op => mul_op [-> 2] => rst_op);
`endif

      }

   endgroup

   covergroup zeros_or_ones_on_ops;

      all_ops : coverpoint op_set {
         ignore_bins null_ops = {rst_op, no_op};}

      a_leg: coverpoint A {
         bins zeros = {'h00};
         bins others= {['h01:'hFE]};
         bins ones  = {'hFF};
      }

      b_leg: coverpoint B {
         bins zeros = {'h00};
         bins others= {['h01:'hFE]};
         bins ones  = {'hFF};
      }
      // VTALU rev2: the borrow of the subtraction, the row of the plan that the
      // scoreboard can only close by looking at TWO outputs. Without this bin, a
      // subtraction that never went negative looks exactly like one that did.
      borrow: coverpoint ((op_set == sub_op) && (A < B)) {
         bins hubo_borrow = {1};
         ignore_bins resto = {0};
      }


      op_00_FF:  cross a_leg, b_leg, all_ops {
         bins add_00 = binsof (all_ops) intersect {add_op} &&
                       (binsof (a_leg.zeros) || binsof (b_leg.zeros));

         bins add_FF = binsof (all_ops) intersect {add_op} &&
                       (binsof (a_leg.ones) || binsof (b_leg.ones));

         bins sub_00 = binsof (all_ops) intersect {sub_op} &&
                       (binsof (a_leg.zeros) || binsof (b_leg.zeros));

         bins sub_FF = binsof (all_ops) intersect {sub_op} &&
                       (binsof (a_leg.ones) || binsof (b_leg.ones));

         bins and_00 = binsof (all_ops) intersect {and_op} &&
                       (binsof (a_leg.zeros) || binsof (b_leg.zeros));

         bins and_FF = binsof (all_ops) intersect {and_op} &&
                       (binsof (a_leg.ones) || binsof (b_leg.ones));

         bins xor_00 = binsof (all_ops) intersect {xor_op} &&
                       (binsof (a_leg.zeros) || binsof (b_leg.zeros));

         bins xor_FF = binsof (all_ops) intersect {xor_op} &&
                       (binsof (a_leg.ones) || binsof (b_leg.ones));

         bins mul_00 = binsof (all_ops) intersect {mul_op} &&
                       (binsof (a_leg.zeros) || binsof (b_leg.zeros));

         bins mul_FF = binsof (all_ops) intersect {mul_op} &&
                       (binsof (a_leg.ones) || binsof (b_leg.ones));

         bins mul_max = binsof (all_ops) intersect {mul_op} &&
                        (binsof (a_leg.ones) && binsof (b_leg.ones));

         ignore_bins others_only =
                                  binsof(a_leg.others) && binsof(b_leg.others);

      }

endgroup

   function new (string name, uvm_component parent);
      super.new(name, parent);
      op_cov = new();
      zeros_or_ones_on_ops = new();
   endfunction : new

   // With the analysis port this is far simpler,
   // because nothing here has to know about the ALU signals:
   // what arrives is a command_s struct that wraps the command
   //
   function void write(command_s t);
         A = t.A;
         B = t.B;
         op_set = t.op;
         op_cov.sample();
         zeros_or_ones_on_ops.sample();
   endfunction : write

   // typedef enum bit[2:0] {no_op  = 3'b000,
   //                     add_op = 3'b001,
   //                     and_op = 3'b011,
   //                     xor_op = 3'b100,
   //                     mul_op = 3'b101,
   //                     rst_op = 3'b111} operation_t;

endclass : coverage

It is worth opening the coverage class of the object-based testbench alongside and comparing: over there was a forever waiting for edges; here there is a write() and nothing else. The coverage stopped having any notion of time. And that is not cosmetic, it is the answer to the question left open in the conventional testbench: where the sampling happens. Before, it sampled on the clock, which means cycles got counted; now it samples when the monitor sees a command, which means operations get counted. That is the right thing, and the coverage number changes meaning. The question for the group: if the DUT sat ten cycles doing nothing, how many samples did the old version take and how many does this one? Ten and zero. The old one was filling bins with nothing.

see this slide in the deck ›

A single place that watches the wire

Subscribing to multiple analysis ports

The scoreboard needs two sources —the command and the result— and a uvm_subscriber can listen to only one. The FIFO turns "they tell me when it arrives" into "I go and get it when I want", which is what it needs in order to compare in pairs.

see this slide in the deck ›

A single place that watches the wire

Subscribing to multiple analysis ports

code/u5/analysis-ports/tb_classes/scoreboard.svh · #two-ports
   uvm_tlm_analysis_fifo #(command_s) cmd_f;

   function void build_phase(uvm_phase phase);
      cmd_f = new("cmd_f", this);
   endfunction : build_phase

   // Every time the DUT hands a result to the analysis port, this method
   // gets triggered
   function void write(shortint t);
      shortint  predicted_result;
      command_s cmd;
      cmd.op = no_op;

      // Here is where the prediction happens: look in our FIFO for the command
      // that was sent, skipping the resets and the no_ops with the
      // do....while
      // since whenever there is a result there must be an operation,
      // a result with no operation behind it is an error
      do
      if (!cmd_f.try_get(cmd)) `uvm_fatal("SCOREBOARD", "No command in self checker")
      while ((cmd.op == no_op) || (cmd.op == rst_op));

      case (cmd.op)
         add_op: predicted_result = cmd.A + cmd.B;
         sub_op: predicted_result = cmd.A - cmd.B;
         and_op: predicted_result = cmd.A & cmd.B;
         xor_op: predicted_result = cmd.A ^ cmd.B;
         mul_op: predicted_result = cmd.A * cmd.B;
      endcase  // case (op_set)

      if (predicted_result != t)
         `uvm_error("SCOREBOARD", $sformatf(
                    "FAILED: A: %2h  B: %2h  op: %s actual result: %4h   expected: %4h",
                    cmd.A,
                    cmd.B,
                    cmd.op.name(),
                    t,
                    predicted_result
                    ))
   endfunction : write
el archivo entero, 55 líneas
// The basic UVM analysis port mechanism lets a uvm_subscriber watch 1 port
// To analyze 2 ports, the simplest way is to instantiate another subscriber
// inside our class that listens to that port
// For that, UVM provides a class called uvm_tlm_analysis_fifo
//	that class gives us an analysis_export and the try_get() method

class scoreboard extends uvm_subscriber #(shortint);
   `uvm_component_utils(scoreboard);

   uvm_tlm_analysis_fifo #(command_s) cmd_f;

   function void build_phase(uvm_phase phase);
      cmd_f = new("cmd_f", this);
   endfunction : build_phase

   // Every time the DUT hands a result to the analysis port, this method
   // gets triggered
   function void write(shortint t);
      shortint  predicted_result;
      command_s cmd;
      cmd.op = no_op;

      // Here is where the prediction happens: look in our FIFO for the command
      // that was sent, skipping the resets and the no_ops with the
      // do....while
      // since whenever there is a result there must be an operation,
      // a result with no operation behind it is an error
      do
      if (!cmd_f.try_get(cmd)) `uvm_fatal("SCOREBOARD", "No command in self checker")
      while ((cmd.op == no_op) || (cmd.op == rst_op));

      case (cmd.op)
         add_op: predicted_result = cmd.A + cmd.B;
         sub_op: predicted_result = cmd.A - cmd.B;
         and_op: predicted_result = cmd.A & cmd.B;
         xor_op: predicted_result = cmd.A ^ cmd.B;
         mul_op: predicted_result = cmd.A * cmd.B;
      endcase  // case (op_set)

      if (predicted_result != t)
         `uvm_error("SCOREBOARD", $sformatf(
                    "FAILED: A: %2h  B: %2h  op: %s actual result: %4h   expected: %4h",
                    cmd.A,
                    cmd.B,
                    cmd.op.name(),
                    t,
                    predicted_result
                    ))
   endfunction : write

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass : scoreboard

The scoreboard is asymmetric and that is where the whole point is: the result reaches it through write() —it gets pushed— and the command it goes and fetches itself with try_get() from the FIFO. One is push, the other is pull. Why like this and not two write()s: a uvm_subscriber has a single write(), and besides the order matters. The scoreboard does not want to be told about the command when it arrives; it wants it at the moment the result appears, so that it can compare in pairs. The FIFO turns "they tell me" into "I go and get it when I want". The do ... while that skips no_op and rst_op is the part that gets copied wrong: those two operations produce no result, so if they are not thrown away, the comparison shifts by one place and from there on everything fails. The symptom is a scoreboard that screams on every line, and it is exactly the day 5 exercise. And the uvm_fatal of the try_get() is not paranoia: if there is a result and there is no command, the broken one is the testbench, not the DUT. Better to die there than to report a thousand false errors.

see this slide in the deck ›

A single place that watches the wire

The other way: `uvm_analysis_imp_decl

`uvm_analysis_imp_decl(_cmd)        // makes the class uvm_analysis_imp_cmd
`uvm_analysis_imp_decl(_result)

class scoreboard extends uvm_component;
   uvm_analysis_imp_cmd    #(command_s, scoreboard) cmd_imp;
   uvm_analysis_imp_result #(shortint,  scoreboard) result_imp;

   function void write_cmd(command_s t);    ... endfunction   // one write per port
   function void write_result(shortint t);  ... endfunction
endclass

Same treatment as the `uvm_field_* macros of the components and as `uvm_do of the sequences: they get named, they get explained, and it gets said why the course does not use them. The rule of the course, once again: read them all, write the explicit ones. What the macro hides and is worth saying: uvm_analysis_imp is a class that already exists in UVM; the only thing `uvm_analysis_imp_decl does is manufacture variants with a different name, because SystemVerilog does not let you have two write()s in the same class. Which means the macro adds no mechanism, it adds names. Where it goes: in the package, outside every class, and only once per suffix in the whole testbench. Declaring it twice with the same suffix is a compilation error that shows up far from the place where you wrote it. And the honest comparison: with the macro the scoreboard comes out shorter and more coupled —it has to carry the command by hand until the result arrives—; with the FIFO it comes out one line longer and it decides the order itself. For a scoreboard that matches things up in pairs, the FIFO wins. For a component that only counts things from two ports, the macro is better.

see this slide in the deck ›

A single place that watches the wire

Subscribing to the monitors

code/u5/analysis-ports/tb_classes/env.svh
class env extends uvm_env;
   `uvm_component_utils(env);

   random_tester   random_tester_h;
   coverage        coverage_h;
   scoreboard      scoreboard_h;
   command_monitor command_monitor_h;
   result_monitor  result_monitor_h;

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   function void build_phase(uvm_phase phase);
      random_tester_h = random_tester::type_id::create("random_tester_h", this);
      coverage_h = coverage::type_id::create("coverage_h", this);
      scoreboard_h = scoreboard::type_id::create("scoreboard_h", this);
      command_monitor_h = command_monitor::type_id::create("command_monitor_h", this);
      result_monitor_h = result_monitor::type_id::create("result_monitor_h", this);

   endfunction : build_phase

   function void connect_phase(uvm_phase phase);

      result_monitor_h.ap.connect(scoreboard_h.analysis_export);
      command_monitor_h.ap.connect(scoreboard_h.cmd_f.analysis_export);
      command_monitor_h.ap.connect(coverage_h.analysis_export);

   endfunction : connect_phase

endclass

Three connect()s and there is the whole testbench. Worth reading them out loud as sentences: the result goes to the scoreboard, the command goes to the scoreboard's FIFO, the command also goes to the coverage. The surprising one is the second: command_monitor_h.ap.connect(scoreboard_h.cmd_f.analysis_export). The export does not belong to the scoreboard, it belongs to the FIFO the scoreboard has inside it. A uvm_analysis_port can have several destinations —the ap of the command_monitor feeds two— and it costs it nothing: write() walks through all of them. And the detail that gets forgotten and gives no warning: cmd_f is instantiated with new(), not with the factory. The FIFO is registered —`uvm_component_param_utils in tlm1/uvm_tlm_fifos.svh:256— but the course builds it with new() because it passes the size through the constructor and create() cannot. What is not in the factory are the *ports* and the *exports*: they extend uvm_port_base with no utils macro. If you forget the new() the connect() blows up with a null, and that one at least is an honest error.

see this slide in the deck ›

A single place that watches the wire

When the DUT does not answer in order

transaccion esperado [int];              // associative, indexed by whatever pairs them up

function void write_cmd(transaccion c);  esperado[c.id] = c;  endfunction

function void write_result(respuesta r);
   if (!esperado.exists(r.id)) `uvm_error("SB", "a response nobody asked for")
   else begin comparar(esperado[r.id], r); esperado.delete(r.id); end
endfunction

The question that opens the slide is the one to ask of anybody else's scoreboard before trusting it: "and what if the DUT answers out of order?". The one in this section falls over, and it is fine that it falls over — the VTALU does not do that. What is not fine is not knowing it. The symptom when it happens is cruel and worth anticipating, because it sends you hunting in the wrong place: it is not one comparison that fails, it is every one after the first crossed-over one. The testbench says a thousand errors and the DUT has zero. It is the row "the scoreboard screams on every one" of the debug appendix, with another cause. The associative array is the canonical answer and there is no mystery to it; what always gets forgotten is the check_phase that verifies it was left empty. Without that, a DUT that stops answering passes green: there is never a bad comparison, there are simply comparisons that were not made. It is the same hole as the drain_time of the tests, seen from the other side. And the variant that shows up when there is no ID: pairing by content. You keep a queue of expected ones and look for the one that matches, instead of the first. It is more expensive and it has a trap of its own —two identical transactions—, and that is why the ID exists.

see this slide in the deck ›

A single place that watches the wire

On one side of the monitor you speak in signals, on the other in transactions

The sentence in the title is the real summary of the section and it is worth leaving it written up: on one side of the monitor you speak in signals, on the other in transactions. That border is what makes it possible for the scoreboard of the transactions to exist without looking at a wire. The third bullet is the hinge towards inter-thread communication: since it is all a chain of functions, no link can wait. If the one consuming needs to make the one producing wait —or the other way round—, something else is needed, and that is the uvm_tlm_fifo. Worth asking the question before moving on: what would happen if the write() of a subscriber had an @(posedge clk) inside it? It does not compile. A function cannot consume time, and that is the whole limitation.

see this slide in the deck ›

A single place that watches the wire

Summary of the unit

Closing of the unit where the testbench takes the shape it is going to have until the end. Worth saying it like this: from here on, every time something new has to be analysed —one more coverage, one more checker— the answer is always going to be the same, hang a subscriber off it, and never touch the monitor. It is literally the exercise of the day, and it is also what is going to be left outside the agent in the agents.

see this slide in the deck ›

When somebody has to wait

You were already doing this, with modules

code/u5/threads/01-modulos/modules.sv · #producer-and-consumer
// Sends the data through the shared variable and signals it by toggling get_it
// Then the producer blocks on the put_it signal
module producer (
    output byte shared,
    input  bit  put_it,
    output bit  get_it
);
   initial
      repeat (3) begin
         $display("Sent %0d", ++shared);
         get_it = ~get_it;
         @(put_it);
      end
endmodule : producer

// The consumer blocks on the get_it signal, waiting for data from the producer
// when the producer toggles that signal, it processes the data and toggles put_it
// when done, to unblock the producer
module consumer (
    input  byte shared,
    output bit  put_it,
    input  bit  get_it
);
   initial
      forever begin
         @(get_it);
         $display("Received: %0d", shared);
         put_it = ~put_it;
      end
endmodule : consumer
el archivo entero, 47 líneas
// InterThread Communication
// 	The initials of each module are 2 different threads

// Sends the data through the shared variable and signals it by toggling get_it
// Then the producer blocks on the put_it signal
module producer (
    output byte shared,
    input  bit  put_it,
    output bit  get_it
);
   initial
      repeat (3) begin
         $display("Sent %0d", ++shared);
         get_it = ~get_it;
         @(put_it);
      end
endmodule : producer

// The consumer blocks on the get_it signal, waiting for data from the producer
// when the producer toggles that signal, it processes the data and toggles put_it
// when done, to unblock the producer
module consumer (
    input  byte shared,
    output bit  put_it,
    input  bit  get_it
);
   initial
      forever begin
         @(get_it);
         $display("Received: %0d", shared);
         put_it = ~put_it;
      end
endmodule : consumer

module top;
   byte shared;
   producer p (
       shared,
       put_it,
       get_it
   );
   consumer c (
       shared,
       put_it,
       get_it
   );
endmodule : top

The slide exists to defuse the fright before the word TLM shows up. It is worth pointing at the code and saying it that bluntly: here are two processes running at the same time and passing data to each other over a wire, and nobody called that "inter-thread communication" in twenty years of Verilog. It is the same thing. The only thing that changes in the rest of the section is where each process lives: instead of two always in two modules, two run_phase() in two objects. And since an object has no port list, the wire has to be replaced by something. The question to leave hanging, which is exactly the one on the next slide: what does the wire get replaced with? The answers that are going to come out —a shared variable, a semaphore, a mailbox— are all correct, and that is precisely the problem UVM comes to solve.

see this slide in the deck ›

When somebody has to wait

An object has no port list

What has to be taken away from the slide is that size 1 is not a limitation of the class: it is the default argument of the constructor. With new("f", this, 0) the FIFO is unbounded, and there the one putting never blocks. Size 1 is the one that synchronizes: it blocks the producer until the consumer takes out, and that replaces the semaphore and the flag everybody used to write by hand. It is the same start/done handshake of the BFM, but between objects.

see this slide in the deck ›

When somebody has to wait

The producer as an object

code/u5/threads/02-bloqueante/producer.svh
// UVM solution to interthread communication
//	It has 2 parts:
//		*	Ports-Objects: instantiated in our uvm_components so that
//				run_phase can talk to other threads
//		*	TLM Fifos (they hold only 1 element)

class producer extends uvm_component;
   `uvm_component_utils(producer);

   int shared;
   // uvm_put_port is a parameterized class
   // this class takes care of the whole synchronization mess around
   // get_it and put_it
   uvm_put_port #(int) put_port_h;

   function void build_phase(uvm_phase phase);
      put_port_h = new("put_port_h", this);
   endfunction : build_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   task run_phase(uvm_phase phase);
      phase.raise_objection(this);
      repeat (3) begin
         put_port_h.put(++shared);
         `uvm_info("PRODUCER", $sformatf("Sent %0d", shared), UVM_MEDIUM)
      end
      phase.drop_objection(this);
   endtask : run_phase
endclass : producer

What has to be pointed out is what is not in this class: there is no @(posedge something), there is no wait, there is no variable saying "ready". The producer puts and carries on; if the other one has not taken it out yet, the put() stops it on its own. The detail of form, which is the one that gets copied wrong: the port gets instantiated in the build_phase like any other component, with new("name", this). It is not a data field: it takes a parent, and that is why it shows up in print_topology(). But it is not a uvm_component either — uvm_put_port extends uvm_port_base #(IF), and what hangs off the tree is an internal uvm_port_component the port builds for itself (uvm_port_base.svh:137). The FIFO on the next slide really is a component. And the parameter: uvm_put_port #(T). That T has to be the same in the port, in the get port and in the FIFO. If they do not match it does not compile, which is the good news —it is one of the few UVM connection errors you see at compile time and not at three in the morning—.

see this slide in the deck ›

When somebody has to wait

The consumer as an object

code/u5/threads/02-bloqueante/consumer.svh
class consumer extends uvm_component;
   `uvm_component_utils(consumer);

   // uvm_get_port handles all the interthread coordination
   uvm_get_port #(int) get_port_h;
   int shared;

   function void build_phase(uvm_phase phase);
      get_port_h = new("get_port_h", this);
   endfunction : build_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   task run_phase(uvm_phase phase);
      forever begin
         get_port_h.get(shared);
         `uvm_info("CONSUMER", $sformatf("Received: %0d", shared), UVM_MEDIUM)
      end
   endtask : run_phase
endclass : consumer

get() is a task, and that word is the whole section. A function cannot have an @ or a #, so it cannot wait for anybody; a task can. If somebody wonders why the analysis ports of the previous section did not block, the answer is right there: write() is a function. The argument by reference is also worth naming: get(t) does not return the data, it writes it into the variable you hand it. It is the TLM convention and it surprises you the first time, because you expect a t = get(). And the practical warning: a get() on a FIFO that never fills up is a thread hung forever. There is no error and no warning — the simulation ends when the objection drops and that consumer simply never ran. It is a sibling of the forgotten item_done() of day 6.

see this slide in the deck ›

When somebody has to wait

Instantiating the three: two components and a FIFO

code/u5/threads/02-bloqueante/communication_test.svh
class communication_test extends uvm_test;
   `uvm_component_utils(communication_test)

   producer producer_h;
   consumer consumer_h;
   uvm_tlm_fifo #(int) fifo_h;

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   function void build_phase(uvm_phase phase);
      producer_h = new("producer_h", this);
      consumer_h = new("consumer_h", this);
      fifo_h = new("fifo_h", this);
   endfunction : build_phase

   // Connect phase: the two ports and the fifo already exist -- build_phase
   // runs first, top-down -- so this is where they get wired together.
   function void connect_phase(uvm_phase phase);
      producer_h.put_port_h.connect(fifo_h.put_export);
      consumer_h.get_port_h.connect(fifo_h.get_export);
   endfunction : connect_phase

endclass : communication_test

It is worth saying again why the order is that one, because here you see what it is for: the connect_phase connects a port to an export, and for that both objects have to exist. If connect were top-down, the test would try to connect children that had not been built yet. The classic error of this slide, which compiles and fails at run time: creating the FIFO in the connect_phase instead of the build_phase. UVM catches it with an ILLCRT fatal —*"It is illegal to create a component ('x' under 'y') after the build phase has ended"* (uvm_component.svh:1721)—, and at least it warns you with the name. Forgetting the connect() of a put/get port also warns, and well: resolve_bindings() reports a UVM_ERROR [Connection Error] connection count of 0 does not meet required minimum of 1 with the name of the port, in end_of_elaboration and before a single cycle runs (uvm_port_base.svh:886). The one that stays quiet is the analysis port, because its minimum is 0: a write() into an unconnected port is legal and prints nothing. It is the same asymmetry as the previous section, and it is the one to remember. And one worth pointing out in passing: here the producer and the consumer are children of the test, not of an env. It is on purpose, because the section is about the mechanism. In the real testbench this goes inside the agent, with the sequencer on one side and the driver on the other.

see this slide in the deck ›

When somebody has to wait

Connecting them: put_export and get_export

code/u5/threads/02-bloqueante/communication_test.svh · #connect_phase
   // Connect phase: the two ports and the fifo already exist -- build_phase
   // runs first, top-down -- so this is where they get wired together.
   function void connect_phase(uvm_phase phase);
      producer_h.put_port_h.connect(fifo_h.put_export);
      consumer_h.get_port_h.connect(fifo_h.get_export);
   endfunction : connect_phase
el archivo entero, 25 líneas
class communication_test extends uvm_test;
   `uvm_component_utils(communication_test)

   producer producer_h;
   consumer consumer_h;
   uvm_tlm_fifo #(int) fifo_h;

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

   function void build_phase(uvm_phase phase);
      producer_h = new("producer_h", this);
      consumer_h = new("consumer_h", this);
      fifo_h = new("fifo_h", this);
   endfunction : build_phase

   // Connect phase: the two ports and the fifo already exist -- build_phase
   // runs first, top-down -- so this is where they get wired together.
   function void connect_phase(uvm_phase phase);
      producer_h.put_port_h.connect(fifo_h.put_export);
      consumer_h.get_port_h.connect(fifo_h.get_export);
   endfunction : connect_phase

endclass : communication_test
code/u5/threads/02-bloqueante/result.txt
UVM_INFO @ 0: reporter [RNTST] Running test communication_test...
UVM_INFO producer.svh(28) @ 0: uvm_test_top.producer_h [PRODUCER] Sent 1
UVM_INFO consumer.svh(19) @ 0: uvm_test_top.consumer_h [CONSUMER] Received: 1
UVM_INFO producer.svh(28) @ 0: uvm_test_top.producer_h [PRODUCER] Sent 2
UVM_INFO consumer.svh(19) @ 0: uvm_test_top.consumer_h [CONSUMER] Received: 2
UVM_INFO producer.svh(28) @ 0: uvm_test_top.producer_h [PRODUCER] Sent 3
UVM_INFO consumer.svh(19) @ 0: uvm_test_top.consumer_h [CONSUMER] Received: 3
UVM_INFO $UVM_HOME/src/base/uvm_report_server.svh(1009) @ 0: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---
** Report counts by severity
UVM_INFO :    7
UVM_WARNING :    0
UVM_ERROR :    0
UVM_FATAL :    0
** Report counts by id
[CONSUMER]     3
[PRODUCER]     3
[RNTST]     1
- $UVM_HOME/src/base/uvm_root.svh:633: Verilog $finish
- S i m u l a t i o n   R e p o r t: Verilator 5.052 2026-09-05
- Verilator: $finish at 0s

Two things about the output, which do not belong to this section but are seen here first. The messages come out with `uvm_info, not with $display: that is why every line brings the time, the hierarchical path of the component and the file along on its own. That is free, and it is reporting, which they already saw closing day 3. And the other one: every @ says 0. In this example there is not a single #delay —the blocking handshake suspends and resumes within the same instant—, so the synchronization of the two threads does not come from the clock: it comes from the one-element FIFO. In the example that follows the clocks show up and the numbers stop being 0.

see this slide in the deck ›

When somebody has to wait

NON-blocking communication

code/u5/threads/03-no-bloqueante/consumer.svh
class consumer extends uvm_component;
   `uvm_component_utils(consumer);

   uvm_get_port #(int) get_port_h;
   virtual clk_bfm clk_bfm_i;
   int shared;

   function void build_phase(uvm_phase phase);
      get_port_h = new("get_port_h", this);
      clk_bfm_i = example_pkg::clk_bfm_i;
   endfunction : build_phase

   // Consumer and producer run on different clocks
   // The consumer has its first positive edge at 7ns
   //    and then checks the data every 14ns, because it uses bfm.clk (= 14ns)
   // The producer produces one datum every 17ns
   task run_phase(uvm_phase phase);
      forever begin
         @(posedge clk_bfm_i.clk);
         if (get_port_h.try_get(shared))
            `uvm_info("CONSUMER", $sformatf("Received: %0d", shared), UVM_MEDIUM)
      end
   endtask : run_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass : consumer
code/u5/threads/03-no-bloqueante/results.txt
UVM_INFO @ 0: reporter [RNTST] Running test communication_test...
UVM_INFO producer.svh(18) @ 17: uvm_test_top.producer_h [PRODUCER] Sent 1
UVM_INFO consumer.svh(21) @ 21: uvm_test_top.consumer_h [CONSUMER] Received: 1
UVM_INFO producer.svh(18) @ 34: uvm_test_top.producer_h [PRODUCER] Sent 2
UVM_INFO consumer.svh(21) @ 35: uvm_test_top.consumer_h [CONSUMER] Received: 2
UVM_INFO producer.svh(18) @ 51: uvm_test_top.producer_h [PRODUCER] Sent 3
UVM_INFO consumer.svh(21) @ 63: uvm_test_top.consumer_h [CONSUMER] Received: 3
UVM_INFO $UVM_HOME/src/base/uvm_report_server.svh(1009) @ 68: reporter [UVM/REPORT/SERVER] 
--- UVM Report Summary ---
** Report counts by severity
UVM_INFO :    7
UVM_WARNING :    0
UVM_ERROR :    0
UVM_FATAL :    0
** Report counts by id
[CONSUMER]     3
[PRODUCER]     3
[RNTST]     1
- $UVM_HOME/src/base/uvm_root.svh:633: Verilog $finish
- S i m u l a t i o n   R e p o r t: Verilator 5.052 2026-09-05
- Verilator: $finish at 68ns

try_get() returns 0 and carries on: it does not wait. It is the difference between a consumer that hangs until there is data and one that can do something else in the meantime. Careful, in results.txt the zeros are not visible —the consumer only prints when it gets data—: they are in the timeline of the slide that follows. The time on each line is not printed by the message: UVM puts it in the @, and that is why here you can read the race between the two clocks without having written a single line of formatting.

see this slide in the deck ›

When somebody has to wait

NON-blocking communication: the timeline

Timeline of the non-blocking example: at 7 ns and 49 ns the FIFO is empty and try_get() returns 0

The numbers are not from a drawing: they are the @ of the Sent and Received of results.txt, the previous slide. The edge to make them see is the one at 49 ns. Data 3 only gets put in at 51, so the consumer arrives a little early, leaves empty-handed and comes back 14 ns later. With get() that thread was left blocked right there, and in a testbench with a clock that is a lost edge.

see this slide in the deck ›

When somebody has to wait

How TLM diagrams are read

Interthread communication with put port, get port and uvm_tlm_fifo

It is worth the student taking the convention home, because it is the same one in the UVM User Guide, in the Verification Academy and in any testbench diagram they get handed at work. It does not belong to this course. The rule that orders the three figures, and the one that makes a TLM diagram readable without a legend: the square end always points at the circle. The one with the square is the one that calls; the one with the circle is the one that has the method written. That is why a port connects to an export and never to another port. The diamond is the special case they have already seen: an analysis port can point at many circles at once, and that is why it is drawn differently. The other two are one to one.

see this slide in the deck ›

When somebody has to wait

fork: the three ways of starting in parallel

fork  wait_done();  count_cycles();  join        // goes on when BOTH have finished
fork  wait_done();  count_cycles();  join_any    // goes on with THE FIRST; the other stays alive
fork  wait_done();  count_cycles();  join_none   // goes on NOW; both stay running
Variant The parent goes on… What it is used for
join when all of them finish two checks that both have to close
join_any when the first one finishes response against a timeout
join_none right away launching threads that live for the whole test

It is the unit called threads and up to here the threads were put there by UVM. This slide and the next two are the three words of the language any production monitor or driver is written with, and the three fit in one table. The point worth repeating, because it is where everybody gets confused the first time: all three start the same. The fork processes are all launched at the same instant in the three variants. The only thing that changes is what the parent process does immediately afterwards, and that is why the middle column is the one to read. One example per variant is enough to make it stick. join: send the stimulus and count the cycles, and do not go on until both have finished. join_any: wait for the DUT's response or for a timeout to expire, whichever happens first. join_none: the monitor's run_phase, which starts and stays watching forever while the rest of the testbench goes on. And the hook back to day 2, worth making explicit: the hand-written testbench class used fork ... join_none to start the three objects. It was not a coincidence nor a trick — it is exactly what UVM does when it runs the run_phase of a hundred components at once.

see this slide in the deck ›

When somebody has to wait

disable fork and wait fork: turning off what stayed on

// The response-against-timeout idiom. The outer fork ISOLATES
fork begin
   fork
      begin  wait_done();          `uvm_info("BFM", "it arrived", UVM_LOW)  end
      begin  repeat (100) @(posedge clk);  `uvm_error("BFM", "timeout")        end
   join_any
   disable fork;      // kills the sister that lost, and nobody else
end join

wait fork;            // kills nobody: it waits for ALL the children of this thread

The disable fork without the isolating fork ... join is the classic bug of this construct and is worth drawing: it kills every child of the current thread, not those of the fork next door. If the run_phase had already launched a monitor with join_none and then does a bare disable fork, the monitor dies and the testbench goes on running blind. There is no error, there is no warning: there is a log that stops having lines. The way to remember it is to think about the scope: disable fork does not say which fork. It says "the children of this process". The outer fork begin ... end join creates a new process whose only children are the two of the join_any, and that is why the disable cannot reach any further. wait fork is the quiet partner and gets used far less than it should: it is what is needed at the end of a sequence or of a run_phase that launched things with join_none and does not want the phase to end with half the stimulus in the air. In UVM the same problem is solved with objections — but inside a task, wait fork is the answer.

see this slide in the deck ›

When somebody has to wait

⚠ The trap: the for index inside the fork

code/verilator/repro-fork-automatic.sv · #las-cuatro-variantes
    // A -- a single variable, shared by the three threads. A join_none runs
    // nothing: it schedules. By the time the three run, the for is over.
    for (i = 0; i < 3; i++)
      fork got = {got, $sformatf("%0d", i)}; join_none
    wait fork;
    check("int i outside the for, no copy", "333");

    // B -- declared inside the for: one per pass, and nothing to copy.
    for (int j = 0; j < 3; j++)
      fork got = {got, $sformatf("%0d", j)}; join_none
    wait fork;
    check("int j declared in the for", "012");

    // C -- the slide's one: the automatic as the first line of a begin/end.
    // Here the copy is NOT made when the fork is scheduled, so k reads the same
    // i that A does and gives the same answer as A. This is the finding.
    for (i = 0; i < 3; i++)
      fork begin
        automatic int k = i;
        got = {got, $sformatf("%0d", k)};
      end join_none
    wait fork;
    check("fork begin automatic k = i; end  (the slide's)", "333");

    // D -- the LRM 9.3.2 one: the automatic declared in the fork, no begin/end.
    // Same workaround, written where the LRM puts it, and this one does copy.
    for (i = 0; i < 3; i++)
      fork
        automatic int k = i;
        got = {got, $sformatf("%0d", k)};
      join_none
    wait fork;
    check("fork automatic k = i;  no begin/end (LRM 9.3.2)", "012");
el archivo entero, 88 líneas
// Minimal repro: what a `fork ... join_none` copies from the loop index, and when.
//
//   $ verilator --binary --timing --top-module top -o sim repro-fork-automatic.sv
//   ./obj_dir/sim
//
// Why this file exists. The trap slide of the threads section --
// slides/es/110-threads.md and its English twin -- shows the `for` index inside
// a `fork` in three variants and closes by claiming, with the seal of a
// measurement on Verilator 5.052, that the three lines above print 3 3 3,
// 0 1 2 and 0 1 2. The third one, the
// `automatic int k = i;` written as the FIRST LINE of a `fork begin ... end
// join_none`, prints 3 3 3 here: the copy is not made when the fork is
// scheduled. So the slide taught a workaround that does not work around
// anything in the only simulator of the course, and it taught it with the seal
// of a measurement -- which is worse than not measuring at all, because nobody
// re-checks a number that says it was measured.
//
// The form the LRM blesses (IEEE 1800-2017, 9.3.2) is the declaration inside
// the `fork` ITSELF, with no `begin`/`end` around it, and that one does print
// 0 1 2. That is the difference this file pins, so that the slide has a source
// instead of a claim: the block on the slide is code pasted into markdown, not
// a {{code:}}, so until this file existed nobody ever compiled it.
//
// Measured on 5.052 (2026-09-10), deterministic, three runs:
//
//   A  int i outside the for, fork with no copy       3 3 3
//   B  int j declared in the for                      0 1 2
//   C  fork begin automatic int k = i; ... end        3 3 3   <- the slide says 0 1 2
//   D  fork automatic int k = i; ... join_none (LRM)  0 1 2
//
// The four expected strings are asserted below, so a future Verilator that
// changes any of them ends this file in $fatal instead of printing OK -- and
// the day C starts printing 0 1 2 is the day the slide can go back to what it
// used to say. Like the other repros here, `make repros` runs it. Seconds.
module top;
  int    i;
  string got;

  // Each variant leaves its three digits glued together in `got`. The whole
  // string is compared, not digit by digit, because the ORDER is part of what
  // is at stake: "012" and "210" would both be "0 1 2" if we only counted.
  function automatic void check(string what, string want);
    $display("  %-48s %-4s (expected %s)", what, got, want);
    if (got != want) $fatal(1, "%s: printed '%s', expected '%s'", what, got, want);
    got = "";
  endfunction

  initial begin
    $display("Verilator 5.052, the for index inside the fork:");

    // A -- a single variable, shared by the three threads. A join_none runs
    // nothing: it schedules. By the time the three run, the for is over.
    for (i = 0; i < 3; i++)
      fork got = {got, $sformatf("%0d", i)}; join_none
    wait fork;
    check("int i outside the for, no copy", "333");

    // B -- declared inside the for: one per pass, and nothing to copy.
    for (int j = 0; j < 3; j++)
      fork got = {got, $sformatf("%0d", j)}; join_none
    wait fork;
    check("int j declared in the for", "012");

    // C -- the slide's one: the automatic as the first line of a begin/end.
    // Here the copy is NOT made when the fork is scheduled, so k reads the same
    // i that A does and gives the same answer as A. This is the finding.
    for (i = 0; i < 3; i++)
      fork begin
        automatic int k = i;
        got = {got, $sformatf("%0d", k)};
      end join_none
    wait fork;
    check("fork begin automatic k = i; end  (the slide's)", "333");

    // D -- the LRM 9.3.2 one: the automatic declared in the fork, no begin/end.
    // Same workaround, written where the LRM puts it, and this one does copy.
    for (i = 0; i < 3; i++)
      fork
        automatic int k = i;
        got = {got, $sformatf("%0d", k)};
      join_none
    wait fork;
    check("fork automatic k = i;  no begin/end (LRM 9.3.2)", "012");

    $display("OK");
    $finish;
  end
endmodule

It is the most expensive threads bug and the hardest to see when reading the code, because the three versions look very much alike. It is worth asking the question before showing the answer: "what does the first one print?". Almost everybody says 0 1 2. The explanation to leave behind is a single sentence: join_none does not run the thread, it schedules it. The for walks straight to the end, and only then does the scheduler give the three threads their turn — and they read the variable now, not when they were launched. With i outside there is a single variable, and now it holds 3. The version with for (int j …) works and it is worth saying why, so it does not look like magic: the LRM declares automatic the variable of a for that declares it, so each pass has its own copy. It is measured here, it is not theory. The case where the automatic is needed anyway is the one that shows up in real life: the index is not the for's, it is a field of the class or an argument of the task. There is no copy per pass there and it has to be made by hand — and here is the part this slide used to get wrong: the automatic as the first line of a fork begin ... end does not copy. On Verilator 5.052 variant C prints 3 3 3, just like A. The one that copies is D, the LRM §9.3.2 one: the declaration inside the fork, with no begin/end. The two look so alike that it is easy to write the one that does not work. The practical rule to take home: if a thread launched with join_none reads a variable from outside, declare the copy in the fork, not inside a begin.

see this slide in the deck ›

When somebody has to wait

Summary of the unit

Worth closing with the two-column table that one producer, many listeners opened with, because it is the one that orders the four units of the day: Intra-thread — uvm_analysis_port + uvm_subscriber, write() is a function, it consumes no time, a single thread, and it is for the analysis layer. Inter-thread — uvm_put_port / uvm_get_port + uvm_tlm_fifo, put() and get() are task, they block, two threads, and they are for handing stimulus to a driver. The short way of telling them apart, and it is question 3 of the review: if it is a function, it is intra-thread. A function cannot have an @ or a #, so it cannot wait for anybody. The word task is the one that gives away that there are two threads.

see this slide in the deck ›

Who waits for whom

What to send and how to send it

It is the third time the course does the same operation, and it is worth saying it that explicitly because it is what has to be taken home: in interfaces and BFM we took the protocol out of the test and put it in the BFM; in the analysis ports we took the observing out of the analysers and put it in the monitor; here we take the applying of the stimulus out of the generator and put it in the driver. The question that orders everything: does this change when the test changes, or when the design changes? If it changes with the test, it is stimulus. If it changes with the design, it is structure. They do not go in the same class. And the notice of where this is heading: in the agents this put/get pair is called a sequencer and comes already in place, and the tester is called a sequence. What we are assembling by hand here is exactly what UVM gives you ready-made — that is why it gets assembled by hand first.

see this slide in the deck ›

Who waits for whom

The tester does two jobs, and one of them is not its own

code/u5/analysis-ports/tb_classes/base_tester.svh · #run_phase
   task run_phase(uvm_phase phase);
      byte unsigned iA;
      byte unsigned iB;
      operation_t   op_set;

      phase.raise_objection(this);
      bfm.reset_alu();
      repeat (1000) begin : random_loop
         op_set = get_op();
         iA = get_data();
         iB = get_data();
         bfm.send_op(iA, iB, op_set);
      end : random_loop
      #500;
      phase.drop_objection(this);
   endtask : run_phase
el archivo entero, 35 líneas
virtual class base_tester extends uvm_component;
   `uvm_component_utils(base_tester)
   virtual vtalu_bfm bfm;

   function void build_phase(uvm_phase phase);
      if (!uvm_config_db#(virtual vtalu_bfm)::get(this, "", "bfm", bfm))
         `uvm_fatal("BASE TESTER", "Failed to get BFM")
   endfunction : build_phase

   pure virtual function operation_t get_op();

   pure virtual function byte get_data();

   task run_phase(uvm_phase phase);
      byte unsigned iA;
      byte unsigned iB;
      operation_t   op_set;

      phase.raise_objection(this);
      bfm.reset_alu();
      repeat (1000) begin : random_loop
         op_set = get_op();
         iA = get_data();
         iB = get_data();
         bfm.send_op(iA, iB, op_set);
      end : random_loop
      #500;
      phase.drop_objection(this);
   endtask : run_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass : base_tester

It is worth reading the code pointing a finger at where one job ends and the other starts: get_op() and get_data() are the what; everything that follows —the bfm.send_op(), the wait, the cycle— is the how. They are glued together in the same task and that is why they cannot be changed separately. The proof that they are wrongly together is a question: if tomorrow the DUT adds a signal to the protocol, how many classes have to be touched? Every one that inherits from base_tester, even though none of them knows anything about protocol. That is coupling, and the classic symptom is exactly this one — a class that changes for two different reasons. The name driver is worth saying already, even though the class does not exist yet, so that the word does not show up for the first time on day 6. A driver is what turns a piece of data into signals. Nothing more than that, and that is why it is the only class of the testbench that has the right to write an @(negedge clk).

see this slide in the deck ›

Who waits for whom

How it ends up split

The VTALU tester and driver separated by a uvm_tlm_fifo

The box in the middle is the one to point at, because it is the one that surprises: between the tester and the driver there is no arrow. There is a FIFO. The tester does not call the driver, and it does not even have a handle to it. It is worth making the comparison with the diagram of the analysis ports, which is two sections back: over there the monitor did not know its listeners either. It is the same idea applied on the other side of the testbench, and it is the reason why on day 6 every sequence can be changed without touching the driver. And the difference between the two diagrams that does matter, because it is question 3 of the review: that one was intra-thread —write() is a function and does not wait for anybody—, this one is inter-thread. Here the one putting can be left waiting, and that is precisely the synchronization that used to have to be written by hand.

see this slide in the deck ›

Who waits for whom

The env: seven objects and five connect()

code/u5/put-get/tb_classes/env.svh · #build-and-connect
   function void build_phase(uvm_phase phase);
      command_f = new("command_f", this);
      random_tester_h = random_tester::type_id::create("random_tester_h", this);
      driver_h = driver::type_id::create("driver_h", this);

      coverage_h = coverage::type_id::create("coverage_h", this);
      scoreboard_h = scoreboard::type_id::create("scoreboard_h", this);
      command_monitor_h = command_monitor::type_id::create("command_monitor_h", this);
      result_monitor_h = result_monitor::type_id::create("result_monitor_h", this);
   endfunction : build_phase

   function void connect_phase(uvm_phase phase);
      // PORTS CONNECT TO EXPORTS!
      driver_h.command_port.connect(command_f.get_export);
      random_tester_h.command_port.connect(command_f.put_export);

      result_monitor_h.ap.connect(scoreboard_h.analysis_export);

      command_monitor_h.ap.connect(scoreboard_h.cmd_f.analysis_export);
      command_monitor_h.ap.connect(coverage_h.analysis_export);
   endfunction : connect_phase
el archivo entero, 40 líneas
class env extends uvm_env;
   `uvm_component_utils(env);

   random_tester             random_tester_h;
   driver                    driver_h;
   uvm_tlm_fifo #(command_s) command_f;

   coverage                  coverage_h;
   scoreboard                scoreboard_h;

   command_monitor           command_monitor_h;
   result_monitor            result_monitor_h;

   function void build_phase(uvm_phase phase);
      command_f = new("command_f", this);
      random_tester_h = random_tester::type_id::create("random_tester_h", this);
      driver_h = driver::type_id::create("driver_h", this);

      coverage_h = coverage::type_id::create("coverage_h", this);
      scoreboard_h = scoreboard::type_id::create("scoreboard_h", this);
      command_monitor_h = command_monitor::type_id::create("command_monitor_h", this);
      result_monitor_h = result_monitor::type_id::create("result_monitor_h", this);
   endfunction : build_phase

   function void connect_phase(uvm_phase phase);
      // PORTS CONNECT TO EXPORTS!
      driver_h.command_port.connect(command_f.get_export);
      random_tester_h.command_port.connect(command_f.put_export);

      result_monitor_h.ap.connect(scoreboard_h.analysis_export);

      command_monitor_h.ap.connect(scoreboard_h.cmd_f.analysis_export);
      command_monitor_h.ap.connect(coverage_h.analysis_export);
   endfunction : connect_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass

Count the objects of this build_phase: there are seven, plus five connect(). That number is the one to remember, because in the agents this same testbench is going to have one agent and two subscribers. Worth writing it on the board so you can come back to it. The two new lines of the connect_phase are a pair and get read together: the tester puts into the put_export of the FIFO, the driver takes out of the get_export. Nobody connects the tester to the driver — they do not know each other, and that is the point. The mantra has a reason worth giving instead of repeating it like a parrot: the port is the one that asks for the service, the export is the one that offers it. The FIFO offers both ends, so it has two exports. If you write it the other way round it does not compile, and the compiler message is one of the worst in UVM. And the detail that gets forgotten: command_f = new(...), not create(). It is not that the FIFO is missing from the factory —it is registered, tlm1/uvm_tlm_fifos.svh:62—: it is that the size goes through the constructor and create() does not know how to pass it. The ones that really are not in the factory are the ports and the exports.

see this slide in the deck ›

Who waits for whom

The base_tester, without a single signal

code/u5/put-get/tb_classes/base_tester.svh · #class-and-run
virtual class base_tester extends uvm_component;

   `uvm_component_utils(base_tester)

   // the handle to the BFM is replaced by a uvm_put_port
   uvm_put_port #(command_s) command_port;

   function void build_phase(uvm_phase phase);
      command_port = new("command_port", this);
   endfunction : build_phase

   pure virtual function operation_t get_op();

   pure virtual function byte get_data();

   task run_phase(uvm_phase phase);
      byte unsigned iA;
      byte unsigned iB;
      operation_t   op_set;
      command_s     command;

      phase.raise_objection(this);
      command.op = rst_op;
      command_port.put(command);
      repeat (1000) begin : random_loop
         command.op = get_op();
         command.A = get_data();
         command.B = get_data();
         command_port.put(command);
      end : random_loop
      #500;
      phase.drop_objection(this);
   endtask : run_phase
el archivo entero, 43 líneas
// This version does not hold a handle to the BFM
// Since add_tester and random_tester inherit from this one
// and the connection is untouched, those classes get new behaviour
// just by changing this base class
virtual class base_tester extends uvm_component;

   `uvm_component_utils(base_tester)

   // the handle to the BFM is replaced by a uvm_put_port
   uvm_put_port #(command_s) command_port;

   function void build_phase(uvm_phase phase);
      command_port = new("command_port", this);
   endfunction : build_phase

   pure virtual function operation_t get_op();

   pure virtual function byte get_data();

   task run_phase(uvm_phase phase);
      byte unsigned iA;
      byte unsigned iB;
      operation_t   op_set;
      command_s     command;

      phase.raise_objection(this);
      command.op = rst_op;
      command_port.put(command);
      repeat (1000) begin : random_loop
         command.op = get_op();
         command.A = get_data();
         command.B = get_data();
         command_port.put(command);
      end : random_loop
      #500;
      phase.drop_objection(this);
   endtask : run_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass : base_tester

What matters about this class is what disappeared: there is not a single @(negedge clk), nor a bfm.start, nor the wait for the done. The tester was reduced to picking what to send and putting it on the port. And yet random_tester and add_tester were not touched: they only define get_op() and get_data(), and neither of the two knew how the stimulus was applied. That is what separating properly buys you — the change stopped at the base class. The put() is a task and it blocks: if the driver is still handling the previous operation, the tester waits there. The one-element FIFO of inter-thread communication is what synchronizes the two threads, without a single semaphore. And there is a #500 at the end, before the drop_objection, worth pointing out as what it is: a patch. It is there so that the objection does not drop while the last operation is still travelling. In the sequences it disappears, because finish_item() only comes back when the operation has really finished. An eyeballed #500 in a testbench is always an unanswered question.

see this slide in the deck ›

Who waits for whom

The driver: the only one that touches the wire

code/u5/put-get/tb_classes/driver.svh · #class-and-run
class driver extends uvm_component;
   `uvm_component_utils(driver)

   virtual vtalu_bfm bfm;

   uvm_get_port #(command_s) command_port;

   function void build_phase(uvm_phase phase);
      if (!uvm_config_db#(virtual vtalu_bfm)::get(this, "", "bfm", bfm))
         `uvm_fatal("DRIVER", "Failed to get BFM")
      command_port = new("command_port", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      command_s command;

      forever begin : command_loop
         // Blocking: if there are no commands to send, it waits
         command_port.get(command);
         bfm.send_op(command.A, command.B, command.op);
      end : command_loop
   endtask : run_phase
el archivo entero, 28 líneas
class driver extends uvm_component;
   `uvm_component_utils(driver)

   virtual vtalu_bfm bfm;

   uvm_get_port #(command_s) command_port;

   function void build_phase(uvm_phase phase);
      if (!uvm_config_db#(virtual vtalu_bfm)::get(this, "", "bfm", bfm))
         `uvm_fatal("DRIVER", "Failed to get BFM")
      command_port = new("command_port", this);
   endfunction : build_phase

   task run_phase(uvm_phase phase);
      command_s command;

      forever begin : command_loop
         // Blocking: if there are no commands to send, it waits
         command_port.get(command);
         bfm.send_op(command.A, command.B, command.op);
      end : command_loop
   endtask : run_phase

   function new(string name, uvm_component parent);
      super.new(name, parent);
   endfunction : new

endclass : driver

This division is the one UVM formalizes in the agents: the tester decides WHAT to send, the driver knows HOW to send it. In a real agent the tester is going to be called a sequence and this put/get is going to be called get_next_item / item_done. Anticipating it here makes day 6 start downhill.

see this slide in the deck ›

Who waits for whom

Summary of the unit

It is worth closing the day with the three layers written on the board, because it is the map that orders everything that comes: stimulus, protocol, analysis. The four sections of today were about building that separation, and tomorrow's do not add layers — they put UVM names on the ones already there. The control question, which besides answers itself if the day closed properly: in which of the three layers does an @(negedge clk) live? In one only, the protocol. If it shows up in another one, something got separated wrong. And the notice about what is missing, worth giving now: between the tester and the driver travel command_s, which are structs. They work, but they do not randomize themselves, they do not compare themselves and they do not print themselves — and that is exactly what day 5 is going to fix by turning them into transactions.

see this slide in the deck ›

Review · Day 4

1 of 5 · Observer Pattern

In the Observer pattern, what does the observed object know about its observers?

It knows nothing — and that ignorance is the whole point. Adding a fourth subscriber does not force you to touch one line of the one publishing.

see this slide in the deck ›

Review · Day 4

2 of 5 · Analysis Ports

A uvm_subscriber needs data from two different analysis ports. How does that get solved?

With a uvm_tlm_analysis_fifo — a uvm_subscriber has a single write(), so it can only listen to one port. The FIFO gives an analysis_export on one side and a try_get() on the other. It is what the VTALU scoreboard does. The other way is `uvm_analysis_imp_decl, which manufactures one write_ per suffix: they are not two write() in the same class, which SystemVerilog does not allow.

see this slide in the deck ›

Review · Day 4

3 of 5 · Intra vs. inter thread

When the monitor publishes and the write()s of the subscribers run, how many threads are involved?

Only one, and it is intra-thread communication — write() is a function, not a task: it consumes no time and runs in the thread of the one publishing. That is precisely why another mechanism (put/get + FIFO) is needed to talk between threads.

see this slide in the deck ›

Review · Day 4

4 of 5 · Put and get ports

The consumer calls get() and the uvm_tlm_fifo is empty. What happens?

It blocks — get() is blocking, and that is why it is declared task and not function. That is the whole synchronization: no hand-written signal handshake is needed.

see this slide in the deck ›

Review · Day 4

5 of 5 · try_get()

And try_get() with the FIFO empty?

It returns 0 and carries on — it is the non-blocking version, and that is why it can be a function. Notice that the scoreboard uses it in a do ... while to skip over the no_ops and the rst_ops.

see this slide in the deck ›

Exercise · Day 4 · 1 of 2

One more observer, without touching the ones already watching

cd code/ejercicios/d4 && bash run.sh

The short exercise of the course: twenty minutes. What is being practised is not writing the class —it is ten lines— but the connect_phase: if they forget the connect, the class compiles, runs, and counts zero. That is why the marking is done by cross-checking against the lines the command_monitor prints: it is not enough for the number to exist, it has to match what another component saw.

see this slide in the deck ›

Exercise · Day 4 · 2 of 2

The #500 is a patch

cd code/ejercicios/d4b && bash run.sh

It is the exercise that puts hands on the two things the section claims and does not demonstrate: that put() blocks, and that the tester's #500 is a patch. The number is measured and is worth saying: with the size-1 FIFO the testbench sends the thousand operations; with the unbounded FIFO, 13. Nobody touched the DUT, nobody touched the stimulus, and the difference is a constructor's default argument. What the back-pressure was doing without anybody having designed it that way: keeping the tester in step with the bus. Take it away and the tester is left talking to itself. The fix has to be discussed, because the obvious answer —raise the #500— is the one to reject. The question that orders it: who knows when the bus is done? Not the tester, which knows when it is done putting. It is the driver, which is the one driving. That is why the objection goes there, and that is why it is the pattern of any real UVM driver. And the detail that decides whether the exercise works, which is also from the field: the raise/drop pair goes after the get(), not around it. Around it, the driver holds an objection waiting for work that is never coming, and the test never ends — which is the other failure mode of the debug appendix table.

see this slide in the deck ›