Showing posts with label UVM. Show all posts
Showing posts with label UVM. Show all posts

Sunday, March 29, 2020

Favor Composition Over Inheritance - Even for Constraints

Simulation is currently the dominant functional verification technique, with constrained random verification the most widely used methodology. While producing random data is a big part of it, letting the solver blindly generate stimulus isn't going to be very efficient. Constraints are needed to guide the stimulus toward interesting scenarios.

A good constrained random test suite contains a mixture of tests with varying degrees of randomness. This is achieved by progressively adding constraints to tests to reduce their randomness. This is best explained with an example.

Let's assume we're verifying a device that can handle read and write accesses to locations in its address map. These accesses can either be done in secure mode or in non-secure mode. We model an access using a UVM sequence item:

class sequence_item extends uvm_sequence_item;

  typedef enum {
    READ,
    WRITE
  } direction_e;

  typedef enum {
    SECURE,
    NONSECURE
  } sec_mode_e;

  rand direction_e direction;
  rand bit [31:0] address;
  rand sec_mode_e sec_mode;

  // ...
endclass

Not all accesses are legal and illegal accesses would be rejected by the device.

Only certain address ranges are mapped, while accesses to unmapped addresses are illegal. If we were to write a test that only accesses mapped addresses, we would have to add the following constraints to generated items:

constraint only_mapped_addresses {
  address inside {
      [CODE_START_ADDR:CODE_END_ADDR],
      [SRAM_START_ADDR:SRAM_END_ADDR],
      [PERIPHERAL_START_ADDR:PERIPHERAL_END_ADDR] };
}

Our device also only allows writes to aligned addresses. For a 32-bit bus, this would mean that the lowest two address bits have to be 0:

constraint only_writes_to_aligned_addresses {
  direction == WRITE;
  address[1:0] == 0;
}

Lastly, certain ranges of our device's address map are restricted to secure code. Let's assume that the address map is split into 16 regions of 256 MB each. Within each of these regions, the lower half is reserved for secure accesses. This means that bit 27 of the address is always 0 for a secure access:

constraint only_secure_accesses_to_lower_half_of_range {
  sec_mode == SECURE;
  address[27] == 0;
}

The test suite for this device would contain a random test where unconstrained items are generated. One test would be directed toward generating accesses to mapped addresses, another test would only perform writes to aligned addresses, while another test would perform only secure accesses. At the same time, we would also need tests that lie at the intersection of the three features, so we would want tests that do pairwise combinations: aligned writes to mapped addresses, aligned writes in secure mode and secure access to mapped addresses. Finally, we also need a test that combines all three and only does secure writes to mapped addresses.

(While a real test suite would definitely need a lot more classes of tests, this post isn't focused on verification planning, but on the mechanical aspects of implementing a robust constraint management strategy, so please ignore the simplicity of the example.)

It might be the case that these behaviors will get tweaked over time as the project moves forward or as a new derivative of the device is developed. The address map might change as regions are moved, removed or resized, or new regions are added. The bus width might change, which would change which addresses are aligned, or we could get a feature request to implement writes of other granularities (e.g. half-word). The definition of secure regions could also change or they could become configurable via special function registers. Any of these changes should be easy to handle and shouldn't require massive changes to the verification code.

Let's skip the obvious idea of putting all constraints into the sequence item class and activating/deactivating them selectively based on the test. This won't scale for real projects, where we would have many more constraints, which would make the code unreadable.

Using mixins

Quite a while back I wrote about how to use mixins to manage constraints.

The mixin approach is flexible, because it allows us to handle each aspect individually. Instead of having all constraints in a single class, we can have one mixin for each constrained feature.

We need one for mapped addresses:

class only_mapped_addresses_mixin #(type T = sequence_item) extends T;

  constraint only_mapped_addresses {
    address inside {
        [CODE_START_ADDR:CODE_END_ADDR],
        [SRAM_START_ADDR:SRAM_END_ADDR],
        [PERIPHERAL_START_ADDR:PERIPHERAL_END_ADDR] };
  }

  // ...
endclass

We also need one for writes:

class only_legal_writes_mixin #(type T = sequence_item) extends T;

  constraint only_writes_to_aligned_addresses {
    direction == WRITE;
    address[1:0] == 0;
  }

  // ...
endclass

Finally, we need a mixin for secure accesses:

class only_legal_secure_accesses_mixin #(type T = sequence_item) extends T;

  constraint only_secure_accesses_to_lower_half_of_range {
    sec_mode == SECURE;
    address[27] == 0;
  }

  // ...
endclass

Assuming that we have a random test that starts regular sequence items, we would use these mixins to write our more directed tests by replacing the original sequence item type with one with constraints mixed in.

The test that only accesses mapped addresses would do the following factory override:

class test_mapped_addresses extends test_all_random;

  protected virtual function void set_factory_overrides();
    sequence_item::type_id::set_type_override(
        only_mapped_addresses_mixin #(sequence_item)::get_type());
  endfunction

  // ...
endclass

The other two feature tests would look similar, but would use their respective mixins.

To only perform writes to mapped addresses we would need to chain the two mixins:

class test_legal_writes_to_mapped_addresses extends test_all_random;

  protected virtual function void set_factory_overrides();
    sequence_item::type_id::set_type_override(
        only_legal_writes_mixin #(only_mapped_addresses_mixin #(sequence_item))::get_type());
  endfunction

  // ...
endclass

Of course, we would do the same to handle the other two pairs.

Similarly, we could use the same principle to combine all three features:

class test_legal_writes_to_mapped_addresses_in_secure_mode extends test_all_random;

  protected virtual function void set_factory_overrides();
    sequence_item::type_id::set_type_override(
        only_legal_writes_mixin #(
            only_mapped_addresses_mixin #(
                only_legal_secure_accesses_mixin #(sequence_item)))::get_type());
  endfunction

  // ...
endclass

The mixin approach comes with some issues, though.

Constraints are always polymorphic, so we have to be very careful to use unique constraint names across all mixins. Applying two different mixins that use the same constraint name would result in only the outer mixin's constraints being applied, because it would override the constraint defined in the inner mixin. It's very easy to run into this issue when using copy/paste to define a new mixin and forgetting to change the name of the constraint. Frustration will follow, as the code looks right, but leads to unexpected results. Moreover, the more mixins are used in the code base, the easier it is for constraint name collisions to happen.

Chaining of mixins is not particularly readable. It is bearable for one or two levels, but the more levels there are, the worse it's going to get.

Finally, using mixins will cause the number of types in our code to explode. Each mixin on top of a class will create a new type. From a coding standpoint this isn't such a big deal, as we won't be referencing those types directly. The more types we have, though, the longer our compile times are going to get. Also, note that for the compiler mixin1 #(mixin2 #(some_class)) is a distinct type from mixin2 #(mixin1 #(some_class)), regardless if it results in the "same" class. It's very easy to use mixin1 #(mixin2 #(some_class)) in one test, but use mixin3 #(mixin2 #(mixin1 #(some_class))) in another, which would make the compiler "see" an extra type.

The mixin pattern uses inheritance, which doesn't match the call to action in the post title, so obviously we're not going to stop here.

Using aspect oriented programming

It's much easier to write our test family using aspect oriented programming (AOP). AOP allows us to alter the definition of a class from a different file. Even though SystemSystemverilog doesn't support AOP, I'd still like to show an example in e, as it can provide us with some hints into how we could improve the mixin-base solution.

(Please note that the following code may not be idiomatic, so don't take it as a reference on how to handle constraints in e.)

Our sequence item definition would look similar:

<'
struct sequence_item like any_sequence_item {

  direction: direction_e;
  address: uint(bits: 32);
  sec_mode: sec_mode_e;

};
'>

In our test that only does mapped accesses, we would tell the compiler to add the constraint to the sequence item:

<'
import test_all_random;

extend sequence_item {
  keep address in [CODE_START_ADDR..CODE_END_ADDR] or
      address in [SRAM_START_ADDR..SRAM_END_ADDR] or
      address in [PERIPHERAL_START_ADDR..PERIPHERAL_END_ADDR];
};
'>

This does not result in a new type. It tweaks the existing sequence_item type for the duration of that test.

If we would like to reuse the constraint in the test that only writes to mapped addresses, we could put the extension into its own file. We could do the same for the other extensions that handle the other features. This would allow each test to load the relevant extension files. For example, for legal writes to mapped addresses we would have:

<'
import test_all_random;
import constraints/only_legal_writes;
import constraints/only_mapped_addresses;
'>

The file structure is similar to what we had when we used mixins, but the code is much cleaner.

Pay special attention to the natural language description of what we are doing: in test_mapped_addresses we are adding the constraint to the sequence_item type.

Using constraint objects

Regular object oriented programming doesn't allow us to change type definitions. What we can do, however, is build our code in such a way as to allow it to be extended when it is being used.

Back in 2015, there was an exciting DVCon paper that presented how to add constraints using composition. It showed how to add additional constraints to an instance of an object without changing the type of that object. This is done by encapsulating the constraints into their own objects which extend the behavior of the original object's randomize() function. Have a quick look at the paper before proceeding, to understand the exact way this is done.

While the paper shows how to add constraints to object instances, we can extend the approach to add constraints globally, to all instances of a type. If we look back at the AOP case from before, this would be conceptually similar to what we were doing there. We would be emulating the addition of constraints to the sequence_item type.

The paper makes an attempt at global constraints in its final section, by using the UVM configuration DB. While that approach works, I feel that it is not expressive enough. A better API, consisting of a static function to add constraints globally, would make the code much more readable than a very verbose config DB set(...) call.

To get the extensibility we want, we have to set up the necessary infrastructure for it. If the sequence item class is under our control, we can modify it directly. Alternatively, if the sequence item is part of an external UVC package, we can define a sub-class which contains the necessary code.

We'll assume that sequence_item can't be changed and we'll create a new constrained_sequence_item class. We would either use this sub-class in our sequences directly or use a factory override.

To execute code that affects all instances, the sequence item class needs a static function through which constraints are added:

class constrained_sequence_item extends sequence_item;

  static function void add_global_constraint(abstract_constraint c);
    // ...
  endfunction

  // ...
endclass

The abstract_constraint class would be the base class for our constraints and would provide us with a reference to the object that is being randomized:

virtual class abstract_constraint;

  protected sequence_item object;

  function void set_object(sequence_item object);
    this.object = object;
  endfunction

endclass

The code to handle global constraints is similar to the one presented in the paper. We store all global constraints in a static array:

class constrained_sequence_item extends sequence_item;

  local static rand abstract_constraint global_constraints[$];

  static function void add_global_constraint(abstract_constraint c);
     global_constraints.push_back(c);
  endfunction

  // ...
endclass

Before randomizing a sequence item instance, we have to set up the constraint objects to point to it:

class constrained_sequence_item extends sequence_item;

  function void pre_randomize();
    foreach (global_constraints[i])
      global_constraints[i].set_object(this);
  endfunction

  // ...
endclass

With the infrastructure set up, we can move on. We encapsulate the constraints for our features into their own constraint classes:

class only_mapped_addresses_constraint extends abstract_constraint #(sequence_item);

  constraint c {
    object.address inside {
        [CODE_START_ADDR:CODE_END_ADDR],
        [SRAM_START_ADDR:SRAM_END_ADDR],
        [PERIPHERAL_START_ADDR:PERIPHERAL_END_ADDR] };
  }

endclass
class only_legal_writes_constraint extends abstract_constraint #(sequence_item);

  constraint c {
    object.direction == sequence_item::WRITE;
    object.address[1:0] == 0;
  }

endclass
class only_legal_secure_accesses_constraint extends abstract_constraint #(sequence_item);

  constraint c {
    object.sec_mode == sequence_item::SECURE;
    object.address[27] == 0;
  }

endclass

In the test that only accesses mapped addresses we would make sure to add the required constraints:

class test_mapped_addresses extends test_all_random;

  protected virtual function void add_constraints();
    only_mapped_addresses_constraint c = new();
    constrained_sequence_item::add_global_constraint(c);
  endfunction

  // ...
endclass

The add_constraints() function should be called before any sequence items are started. A good place to call it from is the end_of_elaboration_phase(...) function.

In the other feature oriented tests we would simply add their respective constraints.

For the test that does writes to mapped addresses we just need to make sure that both constraints are added. We could do this by extending the random test and making two add_global_constraint(...) calls, one for each constraint object:

class test_legal_writes_to_mapped_addresses extends test_random;

  protected virtual function void add_constraints();
    only_legal_writes_constraint c0 = new();
    only_mapped_addresses_constraint c1 = new();
    constrained_sequence_item::add_global_constraint(c0);
    constrained_sequence_item::add_global_constraint(c1);
  endfunction

  // ...
endclass

We could also extend the test that only does legal writes and add the constraints for mapped addresses:

class test_legal_writes_to_mapped_addresses extends test_legal_writes;

  protected virtual function void add_constraints();
    only_mapped_addresses_constraint c = new();
    super.add_constraints();
    constrained_sequence_item::add_global_constraint(c);
  endfunction

  // ...
endclass

Of course, this approach can be used to handle all combinations of constraints.

Adding constraints dynamically has the same advantages as the mixin approach we looked at earlier.

It doesn't suffer from the same readability issue, because we don't rely on long parameterization chains. It suffers from a bit too much verboseness due to the multiple add_global_constraint(...) calls, though this could be improved by adding a variant of the function that accepts a list of constraint objects.

This approach also avoids the type explosion issue that mixins have and is potentially faster to compile.

There is a bit of boilerplate code required for the infrastructure. This can be extracted into a reusable library.

The first thing we need to do is to make the abstract constraint class parameterizable:

virtual class abstract_constraint #(type T = int);

  protected T object;

  function void set_object(T object);
    this.object = object;
  endfunction

endclass

The package should expose a macro to handle the constraint infrastructure:

`define constraints_utils(TYPE) \
  static function void add_global_constraint(constraints::abstract_constraint #(TYPE) c); \
  // ...

There was a subtle issue with the simplistic infrastructure code we looked at before. It wasn't able to handle randomization of multiple instances at the same time (for example, when randomizing an array of sequence items). As this is a more exotic use case, the problem won't show up immediately. It's a simple fix to make, but it would be very annoying to have to make it in mutiple projects. Even when the code might look deceptively simple and have us think it's not worth the hassle to put into an own library, doing so makes it easier to implement and propagate fixes for such issues.

The macro makes the definition of constrained_sequence_item much cleaner:

class constrained_sequence_item extends sequence_item;

  `constraints_utils(sequence_item)

  // ...
endclass

You can find the constraints library on GitHub.

It already supports instance and global constraints. What I would like to add is the possibility to add constraints to all items under a UVM scope, similar to what the paper presents at the end, but using a nicer API that doesn't require the user to do any UVM config DB calls.

I also want to look at the possibility of magically generating all test combinations, given a list of constraint objects. Currently we had to enumerate all combinations of constraints by writing a test class for each, which is very repetitive. It would be great if we could get this automatically and save ourselves the typing. This is something I'll definitely look at in a future post.

In the meantime you can have a look at the full example code on GitHub and try it out yourselves. I hope it inspires you to write flexible test suites that help you reach you verification goals faster.

Friday, March 25, 2016

An Overview of UVM End-of-Test Mechanisms

A lot traffic coming from Google to the blog is from searches about setting the UVM drain time. That's because one of my first posts was about how to set the drain time prior to going into the run phase. At the time of writing, this was the third most viewed post.

End-of-test handling in UVM seems to be a topic a lot of people are interested in. In this post we’re going to look at different ways of implementing it.

End-of-test relies on objections. Each component can raise objections during the run phase, meaning that it’s not yet ready to let the test finish. We typically raise an objection in the test, when starting our root sequence:

class test extends uvm_test;
  virtual task run_phase(uvm_phase phase);
    phase.raise_objection(this);
    seq.start(sequencer);
    phase.drop_objection(this);
  endtask

  // ...
endclass

This means that while the sequence is running, the test will keep going. Once we’ve finished pushing all of our traffic into the DUT, it will stop. This works great for designs without any latency. If our design processes data in the clock cycle it got it, then it’s fine if we just stop the simulation at that point. The isn’t usually the case. Due to the sequential nature of today’s designs, the effect of any kind of transaction fed to the DUT can only be seen one or more clock cycles later. If we stop the simulation at the time the transaction was accepted by the design, then we won’t be able to check what happens as an effect of that transaction.

As an example, let’s take a very boring design. Our DUT will have two APB interfaces, one slave and one master. Whatever comes in on the north (master) interface is going to come out of the south (slave) interface 16 clock cycles later. We're going to use the AMIQ APB UVC to talk to our design.

We'll need to instantiate two agents:

class env extends uvm_env;
  amiq_apb_master_agent master_agent;
  amiq_apb_slave_agent slave_agent;

  // ...
endclass

I'll spare you the code for actually instantiating and configuring the agents, since it's pretty much boilerplate.

What every testbench needs is a scoreboard to check that the DUT is doing what it's supposed to do. In this case, the scoreboard is pretty trivial. Whenever an item comes from the master agent, we should expect another item with identical characteristics to come from the slave agent.

class scoreboard extends uvm_scoreboard;
  `uvm_analysis_imp_decl(_north)
  `uvm_analysis_imp_decl(_south)

  uvm_analysis_imp_north #(amiq_apb_mon_item, scoreboard) north_aimp;
  uvm_analysis_imp_south #(amiq_apb_mon_item, scoreboard) south_aimp;

  // ...
endclass

Since it can be a while until a south side item comes, in the meantime we'll need to buffer the north side items in a queue. The APB UVC sends out two items per transfer through its analysis port, one for the setup phase and another for the access phase. I don't particularly like this approach, since it forces us to implement logic to throw out the setup phase item (two analysis ports would have been better):

class scoreboard extends uvm_scoreboard;
  protected int unsigned num_seen_north_items;

  protected amiq_apb_mon_item item_stream[$];


  virtual function void write_north(amiq_apb_mon_item item);
    num_seen_north_items++;
    if (num_seen_north_items % 2 == 1)
      return;

    `uvm_info("WRNORTH", "Got a north item", UVM_NONE)
    item_stream.push_back(item);
  endfunction

  // ...
endclass

When a south side item comes, we'll need to compare it with the first item in the queue:

class scoreboard extends uvm_scoreboard;
  protected int unsigned num_seen_south_items;

  protected amiq_apb_mon_item item_stream[$];


  virtual function void write_south(amiq_apb_mon_item item);
    num_seen_south_items++;
    if (num_seen_south_items % 2 == 1)
      return;

    `uvm_info("WRSOUTH", "Got a south item", UVM_NONE)
    if (!item.compare(item_stream.pop_front()))
      `uvm_error("DUTERR", "Mismatch")
  endfunction

  // ..
endclass

What we absolutely need to check is that at the end of the simulation there aren't any outstanding north side items that didn't yet make it to the south side. This means our queue must be empty. A great place to put this check is the check_phase(...) function:

class scoreboard extends uvm_scoreboard;
  virtual function void check_phase(uvm_phase phase);
    if (item_stream.size() != 0)
      `uvm_error("DUTERR", "There are still unchecked items")
  endfunction

  // ...
endclass

Here's where gracious test termination becomes important. If we just stop the simulation once the last north side item was sent, we're going have at least one item in our queue, which will cause the test to fail. This means we can't just simply start our sequence in this way:

class test extends uvm_test;
  virtual task run_phase(uvm_phase phase);
    apb_pipeline_tb::pipeline_sequence seq =
      apb_pipeline_tb::pipeline_sequence::type_id::create("seq", this);

    phase.raise_objection(this);
    seq.start(tb_env.master_agent.sequencer);
    phase.drop_objection(this);
  endtask

  // ...
endclass

We need to make sure that the objection gets dropped once the last item comes out through the south side APB interface. The naïve approach would be to add a delay inside the test between the sequence finishing and dropping the objection:

class test_delay extends test;
  virtual task run_phase(uvm_phase phase);
    apb_pipeline_tb::pipeline_sequence seq =
      apb_pipeline_tb::pipeline_sequence::type_id::create("seq", this);

    phase.raise_objection(this);
    seq.start(tb_env.master_agent.sequencer);

    #(16 * 2);

    phase.drop_objection(this);
  endtask

  // ...
endclass

This is going to work, though it might need an extra time step to avoid any race conditions when stopping the simulation (because the south side monitor might not get a chance to publish its item). There are a few drawbacks, though:

  1. We're going to have to add such a delay to each test we write. Once our designers decide that they need a 17 cycle deep pipeline, we're going to have to modify each and every one of these tests. This can, of course, be solved by writing a function that drops the objection and applies the delay beforehand.
  2. We've implemented the delay in terms of simulation steps, when we're actually interested in clock cycles (hence the multiplication with 2 - a clock cycle takes two simulation time steps). The same argument applies also if we were to wait for a certain number of time units. If someone decides that we need a longer clock, we're going to have to update the delays. This can also be solved by sending the APB clock to the test and using it for the delay. This is easier said than done in SystemVerilog, since what this entails is defining an interface, instantiating it, putting it into the config DB and getting it in the test.
  3. For complicated designs it might be difficult, if not impossible, to figure out how much time to wait before dropping the objection.
  4. It's very easy to forget to add the delay, leading to wasted debug time.

What UVM also provides is a "drain time" mechanism. After all objections have been dropped, the simulation end is delayed by the drain time configured by the user. The cool thing about it is that it can be set once in the base test and other tests don't need to take care of it anymore. A good place to do it is before the run phase starts, in either one of the end_of_elaboration_phase(...) or the start_of_simulation_phase(...) functions:

class test_drain_time extends test;
  virtual function void end_of_elaboration_phase(uvm_phase phase);
    uvm_phase run_phase = uvm_run_phase::get();
    run_phase.phase_done.set_drain_time(this, 16 * 2);
  endfunction

  // ...
endclass

The drawback here is, as in the previous case, that we are specifying the duration in simulation steps, not clock cycles. Moreover, in this case, the actual delay will be done by code in the UVM package. This means the time settings used when compiling UVM will be taken into account, so it might get really funky when working with a pre-compiled library from a vendor (which is usually the case).

The best thing would be if the scoreboard itself could decide when to allow the test to stop. What it could do is raise an objection whenever a north side item is received. This means that the DUT is processing something. Once a south side item comes out, it can drop an objection. Since (ideally) the number of north and south side items should match, once the DUT is done processing everything the scoreboard should drop all of its objections:

class scoreboard_with_objection extends apb_pipeline_tb::scoreboard;
  virtual function void write_north(amiq_apb_pkg::amiq_apb_mon_item item);
    uvm_phase run_phase;

    super.write_north(item);
    if (num_seen_north_items % 2 == 1)
      return;

    run_phase = uvm_run_phase::get();
    run_phase.raise_objection(this);
  endfunction


  virtual function void write_south(amiq_apb_pkg::amiq_apb_mon_item item);
    uvm_phase run_phase;

    super.write_south(item);
    if (num_seen_south_items % 2 == 1)
      return;

    run_phase = uvm_run_phase::get();
    run_phase.drop_objection(this);
  endfunction

  // ...
endclass

The great thing about this approach is that it works regardless of what pipeline depth we have. The only reason why someone might not want to implement a scoreboard like this is if they hang out too much on Verification Academy. The guys at Mentor Graphics say that raising objections in any place other than the test is a performance killer, particularly if its done on a per item basis, like we have here. This is because objections have to propagate throughout the hierarchy, which can take a significant toll on the simulator. In a toy example like this one it's probably not going to make much of a dent, but I can imagine that things can go overboard fast when dealing with complicated designs with many interfaces. With the (rather) new UVM 1.2 release, objections have gotten leaner, so the argument might not hold up anymore.

If you have a really big design and you're stuck using UVM 1.1, don't despair! There is a way to leave the scoreboard in control of when to end the test, without having to raise and drop objections for each item it gets. Each uvm_component has a phase_ready_to_end(...) function that is called before the phase is stopped. If our scoreboard still has items queued when the test sequence finishes, it can raise an objection to delay the end of the simulation. Once the queue becomes empty, it can drop the objection and allow the test to end:

class scoreboard_with_phase_ready_to_end extends apb_pipeline_tb::scoreboard;
  virtual function void phase_ready_to_end(uvm_phase phase);
    if (phase.get_name != "run")
      return;

    if (item_stream.size() != 0) begin
      phase.raise_objection(this);
      fork
        delay_phase_end(phase);
      join_none
    end
  endfunction


  virtual task delay_phase_end(uvm_phase phase);
    wait (item_stream.size() == 0);
    phase.drop_objection(this);
  endtask

  // ...
endclass

This combines the best of both worlds. It works regardless of pipeline depth, since we don't have to specify any kind of delay. It's also very efficient in terms of performance, since we don't need to execute anything for each item that the scoreboard receives. We only need to fork the drain task in the last stage of the simulation, which should have a negligible impact on the run time. There is one caveat, though. In more complicated testbenches, it might be the case that multiple components want to delay the end of the test. This could lead to situations where all objections for the run phase (for example) are dropped, phase_ready_to_end(...) gets called and a component decides to prolong the phase by raising another objection, eventually drops it, phase_ready_to_end(...) gets called again, another component wants to prolong the phase, and so on. If this process repeats too many times, a fatal error is flagged, as mentioned in this thread. Such a situation shouldn't happened very often in practice.

These are the ways of handling end-of-test that currently come to mind. If I missed anything, do let me know in the comments section. If you want to experiment, the code can be found on GitHub. Out of all outlined methods, using phase_ready_to_end(...) seems to be the best by far. I'm definitely using it in my future projects.

Sunday, February 14, 2016

Registering Abstract Classes with the UVM Factory

Every now and again I stumble upon a situation where it's natural to use an abstract class. A typical example is when working with parameterized classes and wanting to swap parameterizations:

virtual class some_abstract_component extends uvm_component;
  pure virtual function void do_stuff();

  virtual task run_phase(uvm_phase phase);
    do_stuff();
  endtask
endclass


class some_concrete_param_component #(type T = int) extends
  some_abstract_component;

  virtual function void do_stuff();
    $display("I'm doing %s stuff", $typename(T));
  endfunction
endclass

This is something we used in the post on coverage extendability, when we talked about having policy classes as parameters to coverage collector components.

Each parameterization of a class creates an own inheritance tree independent of the others. If we want to be able to store any parameterization in a variable, we need a common base class:

some_abstract_component comp;
some_concrete_param_component #(bit) bit_comp = new();
some_concrete_param_component #(real) real_comp = new();

// allowed
comp = bit_comp;

// also allowed
comp = real_comp;

By tagging the base class as virtual we've marked it as incomplete. We've declared that it can do something (via the do_stuff() function), but we haven't yet told the compiler how it can do it. This is left to sub-classes. We want to be able to easily swap parameterizations, without resorting to a big if/else cascade or a long case statement. This is what the UVM factory is for. Assuming that both types are registered with the factory we could do the following:

some_abstract_component comp = some_abstract_component::type_id::create(...);

// In a test, before creating 'comp'
some_abstract_component::type_id::set_type_override(
  some_concrete_param_component #(bit)::get_type());

// In a different test, also before creating 'comp'
some_abstract_component::type_id::set_type_override(
  some_concrete_param_component #(real)::get_type());

The type overrides let the factory know that wherever we wanted to instantiate some_abstract_component it should instantiate the overridden type instead. We can't (officially) do this out of the box.

Depending on your simulator you can see one of the following outcomes when using the uvm_object_utils macro with a virtual class:

  1. the code compiles without any problems; if the virtual class constructor ends up being called (which would happen if we'd forget to set an override) a fatal error is issued
  2. you get a warning that you're trying to instantiate a virtual class, but the code still compiles; as above, actually calling the constructor is not allowed
  3. the compiler stops with an error saying that it's illegal to instantiate a virtual class

Out of the three above, only outcome number 3 follows the LRM strictly. In the interest of portability, we shouldn't be writing code that relies on vendor "features" to compile. This is the whole reason why we as an industry have moved to SystemVerilog, isn't it?

Let's look at what the uvm_component_utils macro expands to:

virtual class some_abstract_class extends uvm_component;
  // ...

  // `uvm_component_utils(some_abstract_class)
  `m_uvm_component_registry_internal(some_abstract_class, some_abstract_class)
  `m_uvm_get_type_name_func(some_abstract_class)
endclass

Let's dig deeper and see how factory registration is done by also expanding the m_uvm_component_registry_internal(...) macro:

virtual class some_abstract_class extends uvm_component;
  // ...

  // `m_uvm_component_registry_internal(some_abstract_class, ...)
  typedef uvm_component_registry #(some_abstract_class,
    "some_abstract_class") type_id;

  static function type_id get_type();
    return type_id::get();
  endfunction

  virtual function uvm_object_wrapper get_object_type();
    return type_id::get();
  endfunction
endclass

The veil of secrecy is being lifted. The ominous type_id that we've been using is actually a typedef that is shorthand for a parameterization of uvm_component_registry. If we open up its source code we can find the offending function:

class uvm_component_registry #(...) extends uvm_object_wrapper;

  // After elaboration with 'some_abstract_class' as an parameter
  virtual function uvm_component create_component (string name,
                                                   uvm_component parent);
    some_abstract_class obj;
    obj = new(name, parent);  // !!!
    return obj;
  endfunction

  // ...
endclass

The create_component(...) function calls new(...) to get an object of the type its parameterized with, which in our case is some_abstract_class. Because of this, we can't parameterize uvm_component_registry with a virtual class. The same point also applies for classes that inherit directly from uvm_object and its corresponding uvm_object_registry.

Before we continue, it might be a good idea to take a step back and look at what exactly happens when we create an object using the factory. The first cog in the machine is the uvm_object_wrapper class, which contains two methods, create_object(...) and create_component(...). The class itself is virtual and these methods are almost pure virtual (in the sense that they don't do anything):

virtual class uvm_object_wrapper;
  virtual function uvm_object create_object (string name="");
    return null;
  endfunction

  virtual function uvm_component create_component (string name,
                                                   uvm_component parent);
    return null;
  endfunction
endclass

A class that can be created by the factory must have a corresponding uvm_object_wrapper associated with it, that implements the corresponding create_*(...) function to call that class's constructor, thereby returning an instance of that class:

class some_class extends uvm_object;
  extern function new(string name);
endclass

class some_class_wrapper extends uvm_object_wrapper;
  virtual function uvm_object create_object(string name);
    some_class obj = new(name);
    return obj;
  endfunction
endclass

The factory can create an instance of some_class by using its wrapper:

class uvm_factory;
  function uvm_object create_object_by_type(uvm_object_wrapper requested_type,
    string name = ""
  );
    // ...
    return requesed_type.create_object(name);
  endfunction

  // ...
endclass

It would be rather boring if this is all it would do, because what would then be the point of using the factory? We could just as easily create an object in our user code. What the factory first does is it checks if there is an override in place. If there is, instead of calling create_object(...) on the wrapper it got as an argument, it's going to call it on the wrapper of the overriding type:

class uvm_factory;
  function uvm_object create_object_by_type(uvm_object_wrapper requested_type,
    string name = ""
  );
    uvm_object_wrapper returned_type;
    if (has_override(requested_type))
      returned_type = get_override(requested_type);
    else
      returned_type = requested_type;
    return returned_type.create_object(name);
  endfunction

  // ...
endclass

For simplicity, we can imagine that the lookup mechanism works like a look up table, which maps one wrapper to a potentially different wrapper.

Let's go back to how wrappers are defined. What we've looked at up to now are the classes behind the curtain of encapsulation, that we as users don't normally see. As the task of registering a class with the factory is something that we need to do quite often, the nice people at Accellera defined some classes that can handle this easily: uvm_object_registry and uvm_component_registry. These are sub-classes of uvm_object_wrapper and parameterized with the class they are supposed to create:

class uvm_object_registry #(type T=uvm_object) extends uvm_object_wrapper;
  typedef uvm_object_registry #(T) this_type;

  // ...
endclass

This way, these classes can provide generic implementations of create_object(...) and create_component(...), respectively:

class uvm_object_registry #(type T=uvm_object) extends uvm_object_wrapper;
  // ...

  virtual function uvm_object create_object(string name);
    T obj = new(name);
    return obj;
  endfunction
endclass

By having the class of interest as a parameter, we avoid having to always declare a sub-class for each new class we want to use with the factory. The *_registry classes also provide the famous create(...) function we've been told to always call instead of new(...). This function gets an instance of the registry class its being called on and passes it to the factory to do the actual creation:

class uvm_object_registry #(type T=uvm_object) extends uvm_object_wrapper;
  // ...

  static function T create(string name);
    uvm_object obj;
    uvm_factory f = uvm_factory::get();
    obj = f.create_object_by_type(get());
    if (!$cast(create, obj))
      uvm_report_fatal("FCTTYP", "...", UVM_NONE);
  endfunction
endclass

The registry classes are implemented as singletons; this means that there can only ever be a single instance of a certain registry and the get(...) function will return it. The object returned by the factory needs to be cast to make sure that it's compatible with the original class. This means it has to be of either the same type or a sub-class.

Now that we know a little more about how the factory mechanism is implemented, we can get back to the problem at hand: associating abstract classes with registry classes.

A cool feature that C++ has is template specialization. This means that it's possible to modify the implementation of the code that gets generated when a template is specialized with a certain type. For our example, this would mean that we would writing something like:

class uvm_component_registry #(some_abstract_class, ...) extends
  uvm_object_wrapper;

  // After elaboration with 'some_abstract_class' as an parameter
  virtual function uvm_component create_component (string name,
                                                   uvm_component parent);
    `uvm_fatal("NEWERR", "Trying to create a virtual class")
    return null;
  endfunction
endclass

This means that for all other classes except some_abstract_class the create_component(...) function does exactly what it used to do in the generic implementation (i.e. it instantiates an object). For some_abstract_class it issues a fatal error instead, because this means that the user forgot something (most likely a factory override). This mechanism isn't supported by SystemVerilog, so we'll have to find another solution.

If we go back to the macro expansion, we can see that we can replace the type of type_id with something other than uvm_component_registry. We need a different class that extends uvm_object_wrapper, but doesn't call the constructor of the class it's parameterized with and instead issues a fatal error:

class vgm_abstract_component_registry #(type T=uvm_component,
  string Tname="<unknown>") extends uvm_object_wrapper;

  virtual function uvm_component create_component(string name,
    uvm_component parent
  );
    `uvm_fatal("INTERR", $sformatf("Trying to create an instance of class %s",
      Tname))
    return null;
  endfunction

  // ...
endclass

We also need to implement the singleton infrastructure required to get an instance of the class:

class vgm_abstract_component_registry #(type T=uvm_component,
  string Tname="<unknown>") extends uvm_object_wrapper;

  typedef vgm_abstract_component_registry #(T, Tname) this_type;

  local static this_type me = get();

  static function this_type get();
    if (me == null) begin
      uvm_factory f = uvm_factory::get();
      me = new();
      f.register(me);
    end
    return me;
  endfunction

  // ...
endclass

The key part of the whole mechanism, the create(...) function is still missing. Notice that we've extended our class from uvm_object_wrapper directly. What I first tried was to extend uvm_component_registry, so I would only need to override the methods of interest and inherit the rest from the base class. This didn't work, because that would mean elaborating the base class with a virtual class as its parameter, which results in an instant compile error. Even if the latter doesn't happen, since the create(...) function of uvm_component_registry calls the class's get() function (which is static) it won't ever be possible to get it to call the get() function defined in the sub-class. This means that the factory would always receive an instance of uvm_component_registry.

What we need to do is (gasp!) duplicate the code that implements create(...) and set_*_override(...), to allow users to use the new registry class in the same way as the original one from UVM:

class vgm_abstract_component_registry #(type T=uvm_component,
  // ...

  static function T create(string name, uvm_component parent, string contxt="");
    // ...
  endfunction


  static function void set_type_override (uvm_object_wrapper override_type,
                                          bit replace=1);
    // ...
  endfunction


  static function void set_inst_override(uvm_object_wrapper override_type,
                                         string inst_path,
                                         uvm_component parent=null);
    // ...
  endfunction
endclass

A lot of this clipboard based inheritance could have been avoided with some better choice of class hierarchy. Even the uvm_object_registry and uvm_component_registry classes contain a lot of duplicated code that could have been refactored. For example, a uvm_registry base class could have provided the required infrastructure for creation and factory overrides.

Aside from registering a class with the factory, we've seen that the uvm_*_utils macros do a bit more, like implementing the get_type_name() function. To have a similar look and feel to the UVM macros, we could define their abstract counterparts:

`define vgm_abstract_component_utils(T) \
   `m_vgm_abstract_component_registry_internal(T,T) \
   `m_uvm_get_type_name_func(T)

`define m_vgm_abstract_component_registry_internal(T,S) \
   typedef vgm_abstract_component_registry #(T,`"S`") type_id; \
   static function type_id get_type(); \
     return type_id::get(); \
   endfunction \
   virtual function uvm_object_wrapper get_object_type(); \
     return type_id::get(); \
   endfunction

I've only shown the abstract_component_utils macro, but we can extend the concept to the abstract_object_utils macro, their begin/end variants and the param versions. I know we're not supposed to be using the m_uvm_* versions of the macros, but I like to live on the wild side. For code that is supposed to enhance the library, I guess it's not a big problem if it's tightly coupled to its implementation.

If you find yourself in need of registering an abstract class with the factory, you can download the full code from GitHub.

Monday, November 23, 2015

Accessing Multiple Registers at Once

As I already mentioned, I gave a talk at DVCon Europe this year on how to implement burst accesses to memories modeled using UVM_REG. The motivation for that was that the register package can already handle bus protocols that don't support burst operation, but it requires more user guidance for protocols that do support it. A question that came up afterwards on the conference floor was what the best way to handle burst accesses to multiple registers might be. I tried to sketch out an answer on a piece of paper, but it was rather late in the day and I couldn't really gather my thoughts. I also have a difficult time expressing myself verbally when talking about abstract concepts. Talking is much more difficult than writing, because you don't get the chance to go back and iterate over certain aspects of the topic. Talking about coding problems is also particularly difficult to do when not in front of a computer. People who've worked with me know that I always like to have an editor window open and sketch out some pseudo-code when discussing something in more detail.

The handling of register bursts is a question that comes up from time to time, on places like the Accellera forum or StackOverflow. Since the person who asked me the question is also a reader of the blog, I thought it would be worth making a post out of it.

A solution would need to take two factors into account. It would need to pragmatic, i.e. do the job with the least amount of code necessary. If you would have asked me this question a little while back I would have stopped here. In the mean time, I've been toying with the idea of using the register abstraction layer as a means of achieving reuse (both lateral and vertical) of sequences. Most probably you'll be seeing more posts on the topic. The second factor I would thus consider important is portability, i.e. being able to take sequences from one project and use them in another.

As an example, let's take a simple design that has four registers, located at consecutive addresses:

class some_reg extends uvm_reg;
  rand uvm_reg_field FIELD0;
  rand uvm_reg_field FIELD1;
  rand uvm_reg_field FIELD2;
  rand uvm_reg_field FIELD3;

  // ...
endclass


class some_reg_block extends uvm_reg_block;
  rand some_reg SOME_REGS[4];

  virtual function void build();
    // ...
    foreach (SOME_REGS[i]) begin
      SOME_REGS[i].build();
      SOME_REGS[i].configure(this);
      default_map.add_reg(SOME_REGS[i], 'h4 * i);
    end
  endfunction

  // ...
endclass

When updating a register, we would use the built-in methods of uvm_reg to set the desired value and trigger a write:

class write_some_reg0 extends sequence_base;
  // ...

  virtual task body();
    uvm_status_e status;
    model.SOME_REGS[0].FIELD0.set('hff);
    model.SOME_REGS[0].update(status);
  endtask
endclass

Let's assume that the DUT has an AHB interface that supports burst accesses. This means that it's possible to access all four registers using a single AHB transaction. Converting from register accesses to bus items is usually done using a register adapter:

class reg_adapter extends uvm_reg_adapter;
  virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
    burst b = burst::type_id::create("burst");
    if (!b.randomize() with {
      address == rw.addr;
      kind == SINGLE;
      direction == rw.kind == UVM_READ ? READ : WRITE;
      data[0] == rw.data;
    })
      `uvm_fatal("RANDERR", "Randomization error")
    return b;
  endfunction

  // ...
endclass

I'll assume everybody is familiar with how an adapter works. If not, the UVM User Guide is a good resource to get you up to speed on how the register model is integrated. This adapter can only handle accessing one register at a time. We need some way of telling it that we actually want to access more registers.

As seen in the links above, people will recommend using the optional extension argument of the read(...) and write(...) tasks to instruct the adapter that the access it's converting is actually a burst to more registers. The use model would be to have a class containing information about whether a register access is a burst:

class reg_burst_extension extends uvm_object;
  rand int unsigned num_regs;

  constraint valid_num_regs {
    num_regs inside { 1, 4 };
  }

  // ...
endclass

If num_regs is 1, then the access is a normal one, otherwise it's a burst. It's also a good idea to make the field of the extension random to allow for more generic sequences. When wanting to write all four registers at a time, we could set the values that we want our registers to take, construct an object of this class, set num_regs to 4 and pass it to the update(...) task:

class write_some_regs extends sequence_base;
  // ...

  virtual task body();
    uvm_status_e status;
    reg_burst_extension ext = reg_burst_extension::type_id::create("ext");
    ext.num_regs = 4;

    model.SOME_REGS[0].FIELD0.set('hff);
    model.SOME_REGS[1].FIELD1.set('hff);
    model.SOME_REGS[2].FIELD2.set('hff);
    model.SOME_REGS[3].FIELD3.set('hff);
    model.SOME_REGS[0].update(status, .extension(ext));
  endtask
endclass

The vanilla register adapter doesn't know anything about the extension we passed. We'll need a sub-class that can interpret the extra information and use it to generate a burst. If we don't pass an extension or pass an unsuitable extension, then we can just generate a SINGLE AHB transaction as before:

class ahb_reg_adapter extends vgm_ahb::reg_adapter;
  function new(string name = "ahb_reg_adapter");
    super.new(name);
  endfunction

  virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
    uvm_reg_item item = get_item();
    reg_burst_extension ext;

    if (item.extension == null || !$cast(ext, item.extension) || ext.num_regs == 1)
      return super.reg2bus(rw);

    // ...
  endfunction
endclass

If we do want to do a burst access, then we need to collect the information from all registers and store it inside the AHB transaction that we want to start:

class ahb_reg_adapter extends vgm_ahb::reg_adapter;
  // ...

  virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
    vgm_ahb::burst b = vgm_ahb::burst::type_id::create("burst");
    uvm_reg_item item = get_item();
    reg_burst_extension ext;
    uvm_reg regs[];
    uvm_reg_addr_t offset;
    uvm_reg_data_t data[];

    // ...

    offset = regs[0].get_offset(item.map);
    data = new[ext.num_regs];
    for (int i = 1; i < ext.num_regs; i++)
      regs[i] = item.map.get_reg_by_offset(offset + i*4);

    foreach (regs[i])
      data[i] = regs[i].get();

    if (!b.randomize() with {
      address == rw.addr;
      kind == vgm_ahb::INCR4;
      direction == rw.kind == UVM_READ ? vgm_ahb::READ : vgm_ahb::WRITE;
      foreach (data[i])
        data[i] == local::data[i];
    })
      `uvm_fatal("RANDERR", "Randomization error")
    return b;
  endfunction
endclass

This is the tried and true way of doing it. It's also pretty easy to implement. The problem with it, though, is that it's rather coupled with the verification environment. Let's assume that we get a second variant of our DUT that is a bit more bare-bones and only has an APB interface. Ideally, we'd want to be able to run the same sequences (or a subset thereof) in this second verification environment. Accessing single registers isn't a problem, as these would be handled by the vanilla APB register adapter (code not shown for brevity). When starting the burst access sequence (the one with the extension), we'd still like to see all four registers getting accessed, albeit via four different APB transfers. This means we'd need to have a register adapter that can start four transactions in one go:

class apb_reg_adapter extends vgm_apb::reg_adapter;
  // ...

  virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
    // ...

    foreach (regs[i]) begin
      if (i == 0)
        continue;
      if (rw.kind == UVM_READ)
        fork
          automatic uvm_reg rg = regs[i];
          rg.read(status, data);
        join_none
      else
        fork
          automatic uvm_reg rg = regs[i];
          rg.write(status, rg.get());
        join_none
    end

    return super.reg2bus(rw);
  endfunction
endclass

The reg2bus(...) method is a function, so it can't block. It can also only return one bus transaction. That would be the one corresponding to the register we called write(...) on. If we'd like to access the other three registers as well, one would optimistically think that the other accesses could be forked out. This could get us in a world of trouble with race conditions, because the order in which the accesses would get processed isn't defined. It also doesn't work as expected, because the update(...) task returns before all accesses are finished. For writes this might not be such a big issue, but for reads this would be fatal, since we wouldn't be able to rely on the values stored in the registers to be up-to-date. I didn't really investigate how to improve on this, since the whole idea seems silly. A register adapter isn't meant for this kind of operation. It can only start one bus transaction based on one register access, not more. This was all fine and dandy when that transaction could be a burst (as for AHB), but it falls apart when we need to translate sequences that try to access all four registers at once. This means we can't reliably run the sequences that use the extension mechanism in the APB verification environment, at least not while having them go through an adapter. They could still be reused if we employed a different means of translating from register accesses, using a register sequencer layered on the APB sequencer that would run a translation sequence (more on this later).

The main takeaway point, though, is that while using the extension is easy to set up for the initial DUT (the one with AHB), it becomes trickier to port it to any subsequent variants of the design that use different bus protocols, particularly so if the protocols don't intrinsically support burst accesses. Even for other protocols that do support burst accesses (e.g. AXI), we'd still need to create a sub-class of the corresponding register adapter that can extract the information contained in the extension.

The problem stems from the fact that we're trying to shoehorn an unsuitable abstraction. Calls to uvm_reg::read(...)/write(...) ultimately end up creating an abstract register access, of type uvm_reg_item. Such a register item (which is a sub-class of uvm_sequence_item) can model anything from a small access that takes one bus cycle, to a very big access that takes multiple bus cycles (also called a burst). We're trying to model an access to four registers as an access to one of the registers that includes some side information to say if it's actually a burst or not.

A better idea might be to not go the way of using an extension. Instead, we could create a register item "by hand", fill it up with the appropriate information and send it out to be processed:

class write_some_regs extends sequence_base;
  // ...

  virtual task body();
    uvm_reg_item item;
    `uvm_create_on(item, model.default_map.get_sequencer());

    model.SOME_REGS[0].FIELD0.set('hff);
    model.SOME_REGS[1].FIELD1.set('hff);
    model.SOME_REGS[2].FIELD2.set('hff);
    model.SOME_REGS[3].FIELD3.set('hff);

    item.kind         = UVM_BURST_WRITE;
    item.offset       = model.SOME_REGS[0].get_offset();
    item.value        = new[4];
    foreach (item.value[i])
      item.value[i] = model.SOME_REGS[i].get();

    `uvm_send(item)
  endtask
endclass

Instead of starting a register item indirectly via a call to uvm_reg::write(...), we create one ourselves. We explicitly state that this is a burst access, by setting the kind field appropriately. The (misleadingly named) value field is actually an array that contains one element per burst transfer. Since we want to write to four registers, we set its size to 4 and its elements to the desired values of the registers.

This is one piece of the puzzle. Now we need to translate this uvm_reg_item to the bus transaction that the DUT needs to see. Trying to send this access through a register adapter might work for the AHB DUT, because the AHB adapter can start a single AHB transaction that is capable of representing the entire register item. Trying to send it through the APB adapter will lead to the same problem that we had before before, namely that we can't start multiple APB transactions based on it.

The UVM User Guide show us how to implement a different translation scheme, more sophisticated than the register adapter. As briefly mentioned above, it involves layering. As described in section 5.9.2.3 of the User Guide (UVM 1.1), we can have a register sequencer that serves as a landing pad for uvm_reg_items. A translation sequence running on the bus sequencer would get items from this register sequencer and could convert them to bus transactions.

For AHB, this is pretty easy to write:

class reg_xlate_sequence extends uvm_reg_sequence #(uvm_sequence #(burst));
  // ...

  virtual task do_reg_item(uvm_reg_item rw);
    burst b = burst::type_id::create("burst");

    if (!b.randomize() with {
      if (rw.kind inside { UVM_READ, UVM_BURST_READ })
        direction == READ;
      else
        direction == WRITE;
      address == rw.offset;
      data.size() == rw.value.size();
      foreach (data[i])
        data[i] == rw.value[i];
    })
      `uvm_fatal("RNDERR", "Randomization error")
    `uvm_send(b)
  endtask
endclass

Our translation sequence extends the built in uvm_reg_sequence, which already provides some facilities to perform translation (albeit based on a register adapter, which is the very thing we're trying to avoid). By overriding the do_reg_item(...) task, which gets called for each item that gets started on the register sequencer, we can implement our own scheme that generates one AHB transaction based on the contents of the uvm_reg_item to be converted. When creating this sequence, we need to specify the instance of the register sequencer and afterwards start it on the bus sequencer:

    vgm_ahb::reg_xlate_sequence reg2ahb_seq =
      vgm_ahb::reg_xlate_sequence::type_id::create("reg2ahb_seq");
    reg2ahb_seq.reg_seqr = reg_sequencer;
    uvm_config_db #(uvm_sequence_base)::set(ahb_agent.sequencer, "run_phase",
      "default_sequence", reg2ahb_seq);

For APB, the translation sequence is also pretty straightforward:

class reg_xlate_sequence extends uvm_reg_sequence #(uvm_sequence #(transfer));
  // ...

  virtual task do_reg_item(uvm_reg_item rw);
    transfer t = transfer::type_id::create("transfer");

    foreach (rw.value[i]) begin
      if (!t.randomize() with {
        if (rw.kind inside { UVM_READ, UVM_BURST_READ })
          direction == READ;
        else
          direction == WRITE;
        address == rw.offset + 4 * i;
        data == rw.value[i];
      })
        `uvm_fatal("RNDERR", "Randomization error")
      `uvm_send(t)
    end
  endtask
endclass

Now you might ask what the advantage is when doing it this way, as opposed to using the extension argument. Clearly we could save ourselves the trouble of creating our own uvm_reg_item in the register burst sequence (which takes up quite a bit of code, but even that could be encapsulated in a task) and just pass an extension to a call to write(...)/read(...) as we did before. The downside to this, though, would be that we would need a translation sequence that can extract the extension, which would create an unnecessary dependency. If we would be more diligent in creating our register item, we could even save ourselves the trouble of having to start a translation sequence for APB. If we'd fill a few more of its fields (like local_map and some others), the register package itself could handle splitting a burst into multiple transfers and run each of those through a register adapter. I didn't look too much into this, though... The reason for that is that I see this idea of creating our own uvm_reg_item for a register burst as a stepping stone for the next idea.

We could conceptually think of our burst access that covers multiple registers as a memory burst starting at a certain offset (in our case the offset of the first register) that is of a certain size (in our case 4). The uvm_mem class provides, aside from the write(...) and read(...) tasks, the burst_write(...) and burst_read(...) tasks which trigger bursts. We could shadow the registers with a dummy memory, that we would only use to start bursts. The register package would handle the heavy lifting of creating a uvm_reg_item based on our desired access.

Defining such a memory is trivial:

class shadow_mem extends uvm_mem;
  function new(string name = "shadow_mem");
    super.new(name, 4, 32);
  endfunction

  // ...
endclass

Since our register model is probably generated from a specification, we don't want to touch that code. Instead, we can instantiate the shadow memory inside a sub-class and make sure that we instantiate this class in our verification environment instead of the original one:

class ext_reg_block extends regs::some_reg_block;
  shadow_mem SOME_REGS_MEM;

  virtual function void build();
    super.build();

    SOME_REGS_MEM = shadow_mem::type_id::create("SOME_REGS_MEM");
    SOME_REGS_MEM.configure(this);

    default_map.add_mem(SOME_REGS_MEM, SOME_REGS[0].get_offset(default_map));
  endfunction

  // ...
endclass

We'll get warnings that the memory and the registers overlap, but these can be silenced.

We could call burst_write(...) on this memory with the appropriate arguments to trigger a burst that accesses all four registers. Since we have quite a few arguments to pass, this could get tedious, so we can define a helper task:

class shadow_mem extends uvm_mem;
  // ...

  virtual task update_regs(
    output uvm_status_e      status,
    input  uvm_path_e        path   = UVM_DEFAULT_PATH,
    input  uvm_reg_map       map = null,
    input  uvm_sequence_base parent = null,
    input  int               prior = -1,
    input  uvm_object        extension = null,
    input  string            fname = "",
    input  int               lineno = 0
  );
    uvm_reg_data_t values[4];
    ext_reg_block model;

    if (!$cast(model, get_parent()))
      `uvm_fatal("CASTERR", "Cast error")

    foreach (values[i])
      values[i] = model.SOME_REGS[i].get();

    burst_write(status, model.SOME_REGS[0].get_offset(), values, path, map,
      parent, prior, extension, fname, lineno);
  endtask
endclass

The update_regs(...) task is similar to burst_write(...), but it doesn't require us to pass an offset or the data values to be written. These are computed based on the desired values of the registers that the memory shadows. A similar task could be defined to read all the registers.

Our sequence that does a register burst would look like this:

class write_some_regs extends sequence_base;
  // ...

  virtual task body();
    uvm_status_e status;

    model.SOME_REGS[0].FIELD0.set('hff);
    model.SOME_REGS[1].FIELD1.set('hff);
    model.SOME_REGS[2].FIELD2.set('hff);
    model.SOME_REGS[3].FIELD3.set('hff);

    model.SOME_REGS_MEM.update_regs(status);
  endtask
endclass

Integrating this sequence is even more straight forward than before. For APB, we don't even need the register sequencer; the register adapter will suffice. For AHB, we could either have a register sequencer layered on the AHB sequencer (as in the previous section) or we could use a custom frontdoor sequence (as described in my DVCon Europe paper).

I've omitted a lot of the infrastructure code to keep the post focused. You can download the full example from SourceForge.

I don't consider the third approach, using a shadow memory, to be much more complicated than the first one, where we were using the extension argument. Sure it requires a bit more code to declare the shadow memory, especially the convenience tasks, but even that could could be abstracted and made reusable. Layering the shadow memory on bus protocols that don't support bursts is effortless (assuming that a register adapter is already available with the protocol UVC), because UVM_REG already contains a lot of code to handle this. It's only for bus protocols that support burst operation that we need to make sure that register/memory bursts get converted properly. A good UVC for such a protocol will also provide infrastructure for this, in the form of a translation sequence.

When using a custom extension argument to implement such register bursts, the translation scheme always has to be tailored to support this, by extending the generic register adapter or translation sequence to extract the information stored in the extension. It's also rather unintuitive that simpler protocols (that don't support burst operation) cause more headaches. Using the extension argument in this way might also interfere with other uses for it, where a user needs to pass in other side information (such as protection levels) to be translated.

The decision which scheme to use in a certain verification environment depends on whether portability (due to lateral or vertical reuse) is or isn't important.

If you have any other approaches to handling register bursts, I'd love to hear them in the comments section below.