Lab 5: Advanced System Verilog


Important Note

This lab is currently incomplete as the Lab Week 5 Repo is currently incomplete and there is no exercise for this lab. You should read over the information provided in this lab and then move on to Lab 7 for details on what to do next.

Purpose

System Verilog is a large language, but we have almost covered all of the synthesizable elements (meaning, “stuff that can be turned into hardware”) already. Non-synthesizable parts of the language are used for design verification. Since the Processor Design Team uses C++ for performing design verification, we won’t concern ourselves with the parts of System Verilog that cannot be synthesized.

This lab is designed to cover most of the remaining elements of System Verilog that are useful for our purposes. This will still consist of a substantial amount of work, and is not expected to be completed in a single week.

Having completed this lab, you will be sufficiently prepared to contribute to component design and implementation for the team.


Setup

Fork the Lab Week 5 repo to your personal Github account. As with lab 4, a great deal of the toolchain code has not yet been written. You are expected to write the necessary toolchain code to implement any required modules and design verification tests. It may be useful to review the instructions of lab 4 if you need a refresher on this.


Data Types

You are familiar with the standard names reg and wire, and have some intuition that these map onto concepts of “registers” and “buses” that represent physical flip flops and copper interconnects between components respectively.

This understanding is misleading, or at least insufficient. There is no requirement that a reg value be implemented as a physical register, and similarly there is no guarantee that a wire does not involve intermediate buffers or other timing elements. Because these names are misleading, in our code from here on out we never use reg and rarely use wire.

Let’s be formal for a moment, in the System Verilog specification there are two main kinds of data objects: nets and variables. These two groups differ in the way in which they are assigned and hold values.

Nets can be written by one or more continuous assignments, primitive outputs, or modules ports. A net cannot be procedurally assigned.

Variables can be written by one or more procedural statements. Alternatively, variables can be written by one continuous assignment or one port.

A wire is a type of net and, in plain English, the advantage of a wire is that is can be driven by multiple continuous assignments. When a wire has multiple drivers, it is resolved to the value of the “strongest” driver. Electrically speaking, this can be thought of as the input with the lowest impedance. In digital design, we rarely have any reason to have multiple continuous assignments to the same bus, and so we rarely have any reason to use a wire.

A reg isn’t even an object kind, it’s technically a data type and one that comes with tricky restrictions that we don’t have time to explore here.

logic is also a data type, by default it is a variable, and it has the same semantics as reg. logic does not run afoul of the same complications as reg and so it’s what we’ll use.

Further Reading: This explanation leaves a lot to be desired, I’ve written a blog post that explores this in much more depth than we have room for here. You’re encouraged to give it a read.

The quick and fast rules are these:

  • Use logic for single driver circuits (most things), wire for anything else

  • Always declare port direction for each port

  • Allow everything else in a port declaration to be implicit except if you need an output variable, then use output logic

Other Net Kinds

System Verilog includes many more kind of nets, and even allows for user-defined net kinds. These include kinds such as tri, wand, and wor.

The general rule for the Processor Design Team is this: Don’t use any of the other net kinds.

There may exist extremely rare instances it makes sense to violate this rule, in which case the question should be raised and the exception noted in the code.

Interfaces and Modports

An interface can be thought of as a special type of module that bundles ports and connections between ports together.

Consider the following modules:

module Controller(
  // Inputs from top-level module
  input alpha,
  input beta,


  // Interface with a peripheral module
  input phi,

  output chi,
  output psi,
  output omega
);
  // Do stuff
endmodule

This module has two sets of ports. The first set is used to receive directions from the top-level module, and the second set is used to communicate with a peripheral module.

The peripheral module will have an inverse set of ports:

module Peripheral(
  output phi,

  input chi,
  input psi,
  input omega
);
  // Do stuff
endmodule

The top-level module will have to do some work to connect the Controller to the Peripheral. Something like the following:

module TopLevel(
  input alpha,
  input beta
);

  logic phi, chi, psi, omega;

  Controller ctrl(alpha, beta, phi, chi, psi, omega);
  Peripheral prph(phi, chi, psi, omega);

  // Note: System Verilog has a couple shorthands called
  // "implicit named port connections" and "wildcard named
  // port connections" that we could use here. But those
  // are outside the scope of this example

endmodule;

This is less than ideal. We need to make sure we get all the ports in the right order in the module instantiations, if we ever modify the interconnects between the Controller and the Peripheral we will need to change the code in many places, and sometimes such interconnects can contain dozens of logics which becomes a lot of code to write.

The answer is an interface consider the following:

interface Com_if();

  logic phi, chi, psi, omega;

  modport ctrl(
    input phi,

    output chi,
    output psi,
    output omega
  );

  modport prph(
    output phi,

    input chi,
    input psi,
    input omega
  );

endinterface

Now we could modify our modules to use this new interface:

module Controller(
  input alpha,
  input beta,

  Com_if.ctrl com
);

  // phi, chi, psi, and omega can now be accessed via
  // com.phi, com.chi, com.psi, and com.omega respectively

endmodule

Similarly with Peripheral:

module Peripheral(
  Com_if.prph com
);
  // Do stuff
endmodule

And finally in the TopLevel, we can interconnect like so:

module TopLevel(
  input alpha,
  input beta
);

  Com_if com();

  Controller ctrl(alpha, beta, com.ctrl);
  Peripheral prph(com.prph);
endmodule

Since interfaces work just like modules, we can take this one step further and move alpha and beta into the interface as well, but only expose them to the Controller module:

interface Com_if(
  input alpha,
  input beta
);

  logic phi, chi, psi, omega;

  modport ctrl(
    input alpha,
    input beta,

    input phi,

    output chi,
    output psi,
    output omega
  );

  modport prph(
    output phi,

    input chi,
    input psi,
    input omega
  );

endinterface

The Controller:

module Controller(
  Com_if.ctrl com
);

  // alpha, beta, phi, chi, psi, and omega can now
  // be accessed via com.alpha, com.beta, com.phi,
  // com.chi, com.psi, and com.omega respectively

endmodule

Peripheral is unchanged, so the TopLevel now looks like this:

module TopLevel(
  input alpha,
  input beta
);

  Com_if com(alpha, beta);

  Controller ctrl(com.ctrl);
  Peripheral prph(com.prph);
endmodule

An interface is itself a module, and thus can have all the styles of logic implemented inside it as a normal module can. That said, this should be avoided. It may be appropriate for simple continuous assignments to be performed inside an interface, but in most cases an interface should consist only of logic elements and modports.

Parameterized Modules & Interfaces

We do not always know every way a module is going to be used, parameterization allows us to defer some elements of a module’s implementation until it is instantiated. In this way, parameterized modules work similarly to C++ templates.

Consider the following ALU-type module:

module ALU #(
  WordSize = 16
) (
  input clk,
  input logic [1:0] op,
  input logic [WordSize - 1:0] a,
  input logic [WordSize - 1:0] b,
  output logic [WordSize - 1:0] out
);

  always_ff @(negedge clk)
    case(op)
      0: out = a + b;
      1: out = a - b;
      2: out = a & b;
      3: out = a | b;
    endcase

endmodule

There is some unfamiliar syntax here, notably the #() element. This is a parameter list. It precedes the port list and contains a variable named WordSize which is given a default value of 16.

This will make our ALU module ports 16-bits wide if we instantiate it in the normal way, for example:

  // This will be a 16-bit ALU
  ALU alu(clk, op, a, b, out);

However, we don’t have to use the default values. We can expand our ALU without having to change the module code thanks to the WordSize parameter. The syntax is the following:

  // This will be a 32-bit ALU
  ALU #(32) alu(clk, op, a, b, out);

Exercise