Processor bus and peripherals#

Learning goals#

  • Understand how peripherals are connected to a microprocessor

  • Use language features to implement a bus

Introductory problem#

In chapter A programmable processor — RISC-V your goal was to create a system based on a microprocessor and an accelerator. The accelerator will be one of various peripherals on your processor system like a timer, input-output (IO) module etc.

Currently your processor can load and store from/to the RAM only. You want to modify it so that your microprocessor is capable of transferring data from/to various peripherals.

As an example peripheral you have the following peripheral:

Listing 69 code/io-no-interface/io.sv#
// Input-output peripheral
// - Does not support sparse writes (can only write whole words)
import mp_pkg::*;
module io #(
    int unsigned INPUT_COUNT  = 8,  // Up to XLen
    int unsigned OUTPUT_COUNT = 8
) (
    input clk,
    rst,

    // Bus target
    input word addr,
    wdata,
    output word rdata,
    input wsize_t wsize,

    // IO signals
    input  [ INPUT_COUNT-1:0] io_inp,
    output [OUTPUT_COUNT-1:0] io_out
);
  type (io_out) io_out_reg, io_out_regn;
  type (io_inp) io_inp_reg, io_inp_regn;

  assign io_out = io_out_reg;  // For consistent naming of `io_{inp,out}`

  always_ff @(posedge clk, posedge rst) begin
    io_inp_reg <= rst ? 0 : io_inp;  // Sample inputs @(posedge clk)
    io_out_reg <= rst ? 0 : io_out_regn;
  end

  import io_pkg::*;
  always_comb begin
    io_out_regn = io_out_reg;

    // Memory-mapped access
    unique case (addr[2])  // Only one bit tested
      INPUT: rdata = io_inp_reg;
      OUTPUT: begin
        rdata = io_out_reg;
        if (wsize) io_out_regn = wdata;
      end
    endcase
  end
endmodule

We define a package that exposes the register names of the peripheral:

Listing 70 code/io-no-interface/io_pkg.sv#
package io_pkg;
  localparam mp_pkg::word INPUT = 0, OUTPUT = 1;
endpackage

Use repo:code/riscv-single-cycle-ram as your microprocessor module. The peripherals should be modular, i.e., they should be implemented outside of the microprocessor.

  1. Which changes would you make?

    1. How does the microprocessor know whether the data must be sent to a peripheral or to RAM? (Data write)

    2. How do the peripherals or the RAM know that they are addressed? Is it important that the peripherals know that they are addressed or the microprocessor should be responsible for selecting the data that it wants to read? (Data read)

  2. A communication interface is already proposed above. How do you think the communication protocol work for the following cases?

    1. Processor loads data from …

    2. Processor stores data to …

  3. Implement your solution.

    • Create a new top level called system that integrates the microprocessor and an IO module.

    • After making the changes on the microprocessor, test it again using the RISCOF to be sure that the microprocessor still works after the modifications

    • For testing the system with the IO module you can use the following program that writes the pattern AA to the output:

      Listing 71 code/riscv-single-cycle-io-no-interface/io-test.S#
      .equ IO_OUTPUT_REG, 0xF0000004
      .equ DATA, 0x0000AA
      .section .text
      li t0, IO_OUTPUT_REG
      li t1, DATA
      sw t1, 0(t0)  # Store t1 to the address pointed by t0
      stop:
          j stop
      

      Use the following testbench that integrates an IO-test mode additional to the RISCOF testing features:

      Listing 72 code/riscv-single-cycle-io-no-interface/tb.sv#
      import util::*;
      module tb;
        logic clk, rst;
        system #(
            .RAM_INIT_HEX_FILE("program.mem"),
            .RAM_DEPTH_LG2(24)
        ) dut (
            .*,
            .io_inp('1),
            .io_out()
        );
      
        always #1 clk = !clk;
      
        initial dump_and_timeout(10_000_000);  // TODO set a value dependent on the length of each test
        int tohost_symbol_addr, test_signature_begin_addr, test_signature_end_addr;
        initial begin
          $value$plusargs("tohost-symbol-addr=%h", tohost_symbol_addr);
          $display("Address for communicating with the host (tohost): 0x%h", tohost_symbol_addr);
      
          $value$plusargs("test-signature-begin-addr=%h", test_signature_begin_addr);
          $display("Test signature begin addr: 0x%h", test_signature_begin_addr);
      
          $value$plusargs("test-signature-end-addr=%h", test_signature_end_addr);
          $display("Test signature end addr: 0x%h", test_signature_end_addr);
      
          rst = 1;
          @(posedge clk);
          #1 rst = 0;
          // Convert to word address
          tohost_symbol_addr = to_word_addr(tohost_symbol_addr);
          @(dut.ram.mem[tohost_symbol_addr] == 1);
          $display("RV_MODEL_HALT message received.");
          dump_signature;
          $finish;
        end
      
        function static void dump_signature;
          string  filename = "DUT-mp.signature";
          integer fd = $fopen(filename);
          for (
              int unsigned word_addr = to_word_addr(test_signature_begin_addr);
              word_addr < to_word_addr(test_signature_end_addr);
              word_addr += 1
          )
            // Signature file has a word on each line
            $fwriteh(
                fd, "%h\n", dut.ram.mem[word_addr]
            );
        endfunction
      
        initial
          if ($test$plusargs("io-test")) begin
            $display("Single test instead of RISCOF");
            // Wait for the third instruction to store the data
            @(dut.pc >> 2 == 3 + 1) assert (dut.io_out == 'hAA);
            #10;
            $finish;
          end
      endmodule
      

      Execute the testbench with the following arguments to execute the IO module program and activate the short IO module test:

      +io-test +mem-file=io-test.mem
      
  4. Implementation on the board:

    • Connect the the IO module to the LEDs and slide switches.

Tasks#

  • Read sections from section 11.10 to including 11.15 of Comer’s book Essentials of Computer Architecture.

  • Read the first two paragraphs of Bus (computing)

Mini lecture#

Processor bus#

Example buses for connecting microprocessors and peripherals:

TODO schematic that demonstrates write enable and and read-data multiplexing.

Alternative: network-on-chip (NoC). NoC can achieve higher throughput.

Nomenclature#

master-slave not preferred as culturally sensitive terms

  • initiator - responder (e.g., used by Cadence

  • manager - subordinate (used by ARM in AMBA AHB and AXI specification, presumably to keep the first letters m and s for backwards compatibility in signal names that use these letters to denote a master or slave)

  • requester - completer (used by ARM in AMBA APB specification)

  • initiator - target (used in SystemVerilog LRM 2023 section 25.5 in an example)

Modeling buses using interface construct#

… an interface is a named bundle of nets or variables. The interface is instantiated in a design and can be accessed through a port as a single item, and the component nets or variables referenced where needed. A significant proportion of a design often consists of port lists and port connection lists, which are just repetitions of names. The ability to replace a group of names by a single name can significantly reduce the size of a description and improve its maintainability.

Following example models the simple bus interface that we introduced in the introductory problem:

// Immediate bus interface
interface imm_bus_if (
    input clk,
    rst
);
  mp_pkg::word_signed
      addr,  // Address (Read & write)
      wdata,  // Written data
      rdata;  // Read data
  mp_pkg::wsize_t wsize;  // Write size (0=>1b, 1=>2b, 2=4b)

  modport initiator(input clk, rst, output addr, wdata, input rdata, output wsize);
  modport target(input clk, rst, input addr, wdata, output rdata, input wsize);
endinterface

The signals in the port section of the interface define external signals that are common to many instantiations of the same interface. For example, there are two buses in the following example and they share the same clk and rst, however the component signals of the buses, addr, wdata, etc, are not shared.

Example diagram

imm_bus_if bus1(clk, rst);
imm_bus_if bus2(clk, rst);

mp mp1(.i(bus1), .*);
ram ram1(.i(bus1), .*);

mp mp2(.i(bus2), .*);
ram ram2(.i(bus2), .*);

modport#

For separating an initiator from a target modport is useful. The signals defined in the body of imm_bus_if above do not have a direction, because they will have opposite directions for an initiator and a target

To restrict interface access within a module, there are modport lists with directions declared within the interface. The keyword modport indicates that the directions are declared as if inside the module.

Instead of using an interface, we can use a modport in the port declaration section of a bus participant. This allows signal direction checking like the usual input, output keywords.

module io (
    imm_bus_if.target i,

    // other signals
    ...
);

Modeling write-enable and read-data multiplexing#

wsize and rdata signals must be multiplexed: only the microprocessor should write to a single peripheral, and read from a single peripheral at the same time. We have the following approaches to achieve this functionality:

  1. Using a bus fabric module

  2. Creating an array of modports inside the interface using generate construct and using modport expressions to drive the wsize signal of the addressed interface.

Solution to the introductory problem#

The data can be know in peripherals or the RAM. So a logic must exist that selects (1) the destination when storing and (2) the origin when loading the addressed data. This logic can be implemented with a bus. The bus could connect the microprocessor, the RAM and peripherals and route the data according to the microprocessor’s instructions. Moreover the problem suggests that peripherals should be implemented outside the processor. So we go for implementing the bus, the microprocessor, the RAM and peripherals on the same hierarchy level. Basically we can see the RAM also as a peripheral.

There is an important difference between the RAM and other peripherals, however. In contrast to peripherals, RAM not only delivers data, but also instructions at the same time. We have to pay attention to this in our implementation.

Answers to the essay questions#

  1. The template implements the RAM inside the microprocessor. We must transfer the RAM outside the processor. This means adding additional ports for data and instruction transfer. We can introduce an additional level called system that houses the bus and the components which are interconnected.

    1. To ensure that the data is delivered to the right peripheral, We can reserve an address space for each peripheral. The microprocessor does not have to know about the existence of various peripherals — the bus will based on the address decide where the data is routed to. In the template wsize determines in each peripheral whether data must be written or not. So wsize of the addressed peripheral will be active, i.e., non-ZERO.

    2. In our implementation there is no enable signal when reading data, so every peripheral output some data for every address. Nevertheless the bus can choose one of the data outputs of the peripherals and route it to the microprocessor based on the address.

  2. Previously we had introduced ready-valid handshake, which is an common communication protocol where both sides can control transfer of data. Our interface does not include any ready signal for both writes and reads. For writes the valid signal is called wsize. In case of reads we assume that the read data is always valid. This simplifications can be done, because we use a single-cycle design for our system, where data can be read immediately, i.e., combinationally, and be written synchronously after a single clock cycle.

    1. For loading data, the microprocessor sets the address and deactivates wsize. The addressed peripheral outputs the data in response.

    2. For storing data, the microprocessor sets the address and activates wsize. The stored data will have been written in the next clock cycle.

    Note that even our RAM supports writing one, two or four bytes, the peripheral only supports reading whole words to simplify the logic. This is typically not forbidden in peripheral design — it must be documented so that the programmer can write a driver accordingly.

Implementation without the interface construct#

repo:code/riscv-single-cycle-io, repo:code/riscv-single-cycle-io-boolean

Modifications to the microprocessor:

Listing 73 code/riscv-single-cycle-io-no-interface/mp.sv#
--- /builds/fpga/fpga-programming/code/riscv-single-cycle-ram/mp.sv
+++ /builds/fpga/fpga-programming/code/riscv-single-cycle-io-no-interface/mp.sv
@@ -1,5 +1,3 @@
-// verilator lint_off WIDTHEXPAND
-// verilator lint_off WIDTHTRUNC
 import mp_pkg::*;
 import riscv_instr::*;
 
@@ -8,28 +6,29 @@
     int unsigned RAM_DEPTH_LG2 = 10  // In words
 ) (
     input clk,
-    rst
+    rst,
+
+    // Instructions
+    input word inst,  // Current instruction
+    output word_signed pc,  // Program counter
+
+    // Data Bus initiator
+    output word_signed addr,
+    wdata,
+    input word_signed rdata,
+    output wsize_t wsize
 );
-  word_signed rf[RegfileSize], rfn[RegfileSize];  // Register file
-  word_signed pc, pcn;  // Program counter
-  word inst;  // Current instruction
-
-  // verilator lint_off UNOPTFLAT
-  word_signed ram_daddr, ram_wdata, ram_rdata;
+  word_signed
+      rf[RegfileSize],
+      rfn[RegfileSize],  // Register file
+      pcn,  // Program counter
+      // verilator lint_off UNOPTFLAT
+      daddr;  // Data address
   // verilator lint_on UNOPTFLAT
-  wsize_t ram_wsize;
-  mp_ram #(
-      .INIT_HEX_FILE(RAM_INIT_HEX_FILE),
-      .DEPTH_LG2(RAM_DEPTH_LG2)
-  ) ram (
-      .clk,
-      .daddr(ram_daddr),
-      .wdata(ram_wdata),
-      .rdata(ram_rdata),
-      .wsize(ram_wsize),
-      .iaddr(pc),
-      .inst (inst)
-  );
+
+  assign addr = daddr;
+  // Naming `daddr` used to distinguish between instructions and data inside
+  // the processor.
 
   always_ff @(posedge clk, posedge rst) begin
     pc <= rst ? 0 : pcn;
@@ -44,12 +43,12 @@
   b_inst_t b_inst;
   s_inst_t s_inst;
   always_comb begin
-    rfn = rf;
-    pcn = pc + 4;  // Increment as default
-
-    ram_daddr = 0;
-    ram_wdata = 0;
-    ram_wsize = ZERO;
+    rfn   = rf;
+    pcn   = pc + 4;  // Increment as default
+
+    daddr = 0;
+    wdata = 0;
+    wsize = ZERO;
 
     unique case (inst) inside
       // Integer register-immediate instructions
@@ -184,36 +183,35 @@
       // Load and store instructions
       LW, LH, LHU, LB, LBU: begin
         i_inst = i_inst_t'(inst);
-        ram_daddr = rf[i_inst.rs1] + i_inst.imm11_0;
+        daddr  = rf[i_inst.rs1] + i_inst.imm11_0;
         unique casez (inst)
-          LW:  rfn[i_inst.rd] = ram_rdata;
-          LH:  rfn[i_inst.rd] = $signed(ram_rdata[ram_daddr[1]*16+:16]);
-          LHU: rfn[i_inst.rd] = ram_rdata[ram_daddr[1]*16+:16];
-          LB:  rfn[i_inst.rd] = $signed(ram_rdata[ram_daddr[1:0]*8+:8]);
-          LBU: rfn[i_inst.rd] = ram_rdata[ram_daddr[1:0]*8+:8];
+          LW:  rfn[i_inst.rd] = rdata;
+          LH:  rfn[i_inst.rd] = $signed(rdata[daddr[1]*16+:16]);
+          LHU: rfn[i_inst.rd] = rdata[daddr[1]*16+:16];
+          LB:  rfn[i_inst.rd] = $signed(rdata[daddr[1:0]*8+:8]);
+          LBU: rfn[i_inst.rd] = rdata[daddr[1:0]*8+:8];
         endcase
       end
 
       SW, SH, SB: begin
         s_inst = s_inst_t'(inst);
-        ram_wdata = rf[s_inst.rs2];
-        ram_daddr = rf[s_inst.rs1] + assemble_s_imm(s_inst);
+        wdata  = rf[s_inst.rs2];
+        daddr  = rf[s_inst.rs1] + assemble_s_imm(s_inst);
         unique casez (inst)
-          SW: ram_wsize = WORD;
+          SW: wsize = WORD;
           SH: begin
-            ram_wsize = HWORD;
-            ram_wdata <<= ram_daddr[1] * 16;
+            wsize = HWORD;
+            wdata <<= daddr[1] * 16;
           end
           SB: begin
-            ram_wsize = BYTE;
-            ram_wdata <<= ram_daddr[1:0] * 8;
+            wsize = BYTE;
+            wdata <<= daddr[1:0] * 8;
           end
         endcase
       end
 
       // Memory-ordering instructions
       FENCE: ;
-
     endcase
 
     // Hardwire rf[0] to zero

system houses the microprocessor and peripherals. The RAM/IO multiplexer and the interconnection signals like addr, wdata, ram_rdata etc make up the bus.

Listing 74 code/riscv-single-cycle-io-no-interface/system.sv#
import mp_pkg::*;
module system #(
    string RAM_INIT_HEX_FILE = "",  // Only used if non-empty
    int unsigned RAM_DEPTH_LG2 = 10,  // In bytes
    int unsigned IO_INPUT_COUNT = 16,
    int unsigned IO_OUTPUT_COUNT = 16
) (
    input clk,
    rst,

    // IO signals
    input  [ IO_INPUT_COUNT-1:0] io_inp,
    output [IO_OUTPUT_COUNT-1:0] io_out
);
  word inst, pc, addr, wdata, rdata;
  wsize_t wsize;

  mp #(
      //.RAM_INIT_HEX_FILE, <= Vivado does not accept this syntax
      .RAM_INIT_HEX_FILE(RAM_INIT_HEX_FILE),
      .RAM_DEPTH_LG2(RAM_DEPTH_LG2)
  ) mp (
      .*
  );

  // RAM
  word_signed ram_rdata;
  wsize_t ram_wsize;
  mp_ram #(
      .INIT_HEX_FILE(RAM_INIT_HEX_FILE),
      .DEPTH_LG2(RAM_DEPTH_LG2)
  ) ram (
      .clk,
      .daddr(addr),
      .wdata(wdata),
      .rdata(ram_rdata),
      .wsize(ram_wsize),
      .iaddr(pc),
      .inst (inst)
  );

  // IO
  word_signed io_rdata;
  wsize_t io_wsize;
  io #(
      .INPUT_COUNT (IO_INPUT_COUNT),
      .OUTPUT_COUNT(IO_OUTPUT_COUNT)
  ) io (
      .clk,
      .rst,
      .addr,
      .wdata,
      .rdata(io_rdata),
      .wsize(io_wsize),
      .io_inp,
      .io_out
  );

  // RAM / IO multiplexer
  localparam int unsigned IOAddrMask = 'hF?_??_??_??;
  always_comb begin
    unique if (addr ==? IOAddrMask) begin
      rdata = io_rdata;
      ram_wsize = ZERO;
      io_wsize = wsize;
    end else begin
      rdata = ram_rdata;
      ram_wsize = wsize;
      io_wsize = ZERO;
    end
  end
endmodule

For testing we peripheral we can use the following program that writes a pattern to the output register:

Listing 75 code/riscv-single-cycle-io-no-interface/io-test.S#
.equ IO_OUTPUT_REG, 0xF0000004
.equ DATA, 0x0000AA
.section .text
li t0, IO_OUTPUT_REG
li t1, DATA
sw t1, 0(t0)  # Store t1 to the address pointed by t0
stop:
    j stop

Implementation on the board is similar to the solution in chapter Timing & FPGA primitives.

Listing 76 code/riscv-single-cycle-io-no-interface-boolean/system_boolean.sv#
module system_boolean (
    input clk,
    input [3:0] btn,
    input [15:0] sw,
    output [15:0] led
);
  logic clk_mp, pll_locked, rst;
  pll pll_i (
      .reset(rst),
      .clk_in1(clk),
      .clk_out1(clk_mp),
      .locked(pll_locked)
  );
  system #(
      .RAM_INIT_HEX_FILE("io-test.mem"),
      .RAM_DEPTH_LG2(4)
  ) mp_i (
      .clk(clk_mp),
      .rst(!pll_locked),
      .io_inp(sw),
      .io_out(led)
  );
  assign rst = |btn;
endmodule

Implementation with the interface construct#

First we implement the interface:

Listing 77 code/bus-imm/imm_bus_if.sv#
// Immediate bus interface
interface imm_bus_if (
    input clk,
    rst
);
  mp_pkg::word_signed
      addr,  // Address (Read & write)
      wdata,  // Written data
      rdata;  // Read data
  mp_pkg::wsize_t wsize;  // Write size (0=>1b, 1=>2b, 2=4b)

  modport initiator(input clk, rst, output addr, wdata, input rdata, output wsize);
  modport target(input clk, rst, input addr, wdata, output rdata, input wsize);
endinterface

The ports of the mp-ram, io and mp must be modified as follows. We used the modports *_if.initiator or *_if.target modports to indicate the direction of ports.

Listing 78 code/mp-ram/mp_ram.sv#
--- /builds/fpga/fpga-programming/code/mp-ram-no-interface/mp_ram.sv
+++ /builds/fpga/fpga-programming/code/mp-ram/mp_ram.sv
@@ -10,28 +10,22 @@
     string INIT_HEX_FILE = "",  // Only used if non-empty
     int unsigned DEPTH_LG2 = 5  // In log2(word)
 ) (
-    input clk,
-
-    // Bus target
-    word_signed daddr,  // Data address (Read & write)
-    wdata,  // Written data
-    output word_signed rdata,  // Read data
-    input wsize_t wsize,  // Write size (0=>1b, 1=>2b, 2=4b)
+    imm_bus_if.target i,
 
     input  word_signed iaddr,  // Instruction address (Only read)
     output word_signed inst    // Instruction (read)
 );
   word mem[2**DEPTH_LG2];
-  always_ff @(posedge clk)
-    unique case (wsize)
+  always_ff @(posedge i.clk)
+    unique case (i.wsize)
       ZERO:  ;  // Do not write
-      BYTE:  mem[to_word_addr(daddr)][8*daddr[1:0]+:8] <= wdata[8*daddr[1:0]+:8];
-      HWORD: mem[to_word_addr(daddr)][16*daddr[1]+:16] <= wdata[16*daddr[1]+:16];
-      WORD:  mem[to_word_addr(daddr)] <= wdata;
+      BYTE:  mem[to_word_addr(i.addr)][8*i.addr[1:0]+:8] <= i.wdata[8*i.addr[1:0]+:8];
+      HWORD: mem[to_word_addr(i.addr)][16*i.addr[1]+:16] <= i.wdata[16*i.addr[1]+:16];
+      WORD:  mem[to_word_addr(i.addr)] <= i.wdata;
     endcase
 
-  assign rdata = mem[to_word_addr(daddr)];
-  assign inst  = mem[to_word_addr(iaddr)];
+  assign i.rdata = mem[to_word_addr(i.addr)];
+  assign inst = mem[to_word_addr(iaddr)];
 
   initial begin
     // Read program and data memfile from command line in simulation only.
Listing 79 code/io/io.sv#
--- /builds/fpga/fpga-programming/code/io-no-interface/io.sv
+++ /builds/fpga/fpga-programming/code/io/io.sv
@@ -5,14 +5,7 @@
     int unsigned INPUT_COUNT  = 8,  // Up to XLen
     int unsigned OUTPUT_COUNT = 8
 ) (
-    input clk,
-    rst,
-
-    // Bus target
-    input word addr,
-    wdata,
-    output word rdata,
-    input wsize_t wsize,
+    imm_bus_if.target i,
 
     // IO signals
     input  [ INPUT_COUNT-1:0] io_inp,
@@ -23,9 +16,9 @@
 
   assign io_out = io_out_reg;  // For consistent naming of `io_{inp,out}`
 
-  always_ff @(posedge clk, posedge rst) begin
-    io_inp_reg <= rst ? 0 : io_inp;  // Sample inputs @(posedge clk)
-    io_out_reg <= rst ? 0 : io_out_regn;
+  always_ff @(posedge i.clk, posedge i.rst) begin
+    io_inp_reg <= i.rst ? 0 : io_inp;  // Sample inputs @(posedge i.clk)
+    io_out_reg <= i.rst ? 0 : io_out_regn;
   end
 
   import io_pkg::*;
@@ -33,11 +26,11 @@
     io_out_regn = io_out_reg;
 
     // Memory-mapped access
-    unique case (addr[2])  // Only one bit tested
-      INPUT: rdata = io_inp_reg;
+    unique case (i.addr[2])  // Only one bit tested
+      INPUT: i.rdata = io_inp_reg;
       OUTPUT: begin
-        rdata = io_out_reg;
-        if (wsize) io_out_regn = wdata;
+        i.rdata = io_out_reg;
+        if (i.wsize) io_out_regn = i.wdata;
       end
     endcase
   end
Listing 80 code/riscv-single-cycle-io/mp.sv#
--- /builds/fpga/fpga-programming/code/riscv-single-cycle-io-no-interface/mp.sv
+++ /builds/fpga/fpga-programming/code/riscv-single-cycle-io/mp.sv
@@ -5,34 +5,25 @@
     string RAM_INIT_HEX_FILE = "",  // Only used if non-empty
     int unsigned RAM_DEPTH_LG2 = 10  // In words
 ) (
-    input clk,
-    rst,
+    imm_bus_if.initiator i,
 
     // Instructions
     input word inst,  // Current instruction
-    output word_signed pc,  // Program counter
-
-    // Data Bus initiator
-    output word_signed addr,
-    wdata,
-    input word_signed rdata,
-    output wsize_t wsize
+    output word_signed pc  // Program counter
 );
   word_signed
       rf[RegfileSize],
       rfn[RegfileSize],  // Register file
       pcn,  // Program counter
       // verilator lint_off UNOPTFLAT
-      daddr;  // Data address
+      daddr;
   // verilator lint_on UNOPTFLAT
-
-  assign addr = daddr;
-  // Naming `daddr` used to distinguish between instructions and data inside
-  // the processor.
-
-  always_ff @(posedge clk, posedge rst) begin
-    pc <= rst ? 0 : pcn;
-    rf <= rst ? '{default: '0} : rfn;
+  // Rename to  `daddr` used for activating `UNOPTFLAT` on `i.addr`.
+  assign i.addr = daddr;
+
+  always_ff @(posedge i.clk, posedge i.rst) begin
+    pc <= i.rst ? 0 : pcn;
+    rf <= i.rst ? '{default: '0} : rfn;
   end
 
   // Variables used in instruction parsing
@@ -43,12 +34,12 @@
   b_inst_t b_inst;
   s_inst_t s_inst;
   always_comb begin
-    rfn   = rf;
-    pcn   = pc + 4;  // Increment as default
+    rfn = rf;
+    pcn = pc + 4;  // Increment as default
 
     daddr = 0;
-    wdata = 0;
-    wsize = ZERO;
+    i.wdata = 0;
+    i.wsize = ZERO;
 
     unique case (inst) inside
       // Integer register-immediate instructions
@@ -185,27 +176,27 @@
         i_inst = i_inst_t'(inst);
         daddr  = rf[i_inst.rs1] + i_inst.imm11_0;
         unique casez (inst)
-          LW:  rfn[i_inst.rd] = rdata;
-          LH:  rfn[i_inst.rd] = $signed(rdata[daddr[1]*16+:16]);
-          LHU: rfn[i_inst.rd] = rdata[daddr[1]*16+:16];
-          LB:  rfn[i_inst.rd] = $signed(rdata[daddr[1:0]*8+:8]);
-          LBU: rfn[i_inst.rd] = rdata[daddr[1:0]*8+:8];
+          LW:  rfn[i_inst.rd] = i.rdata;
+          LH:  rfn[i_inst.rd] = $signed(i.rdata[daddr[1]*16+:16]);
+          LHU: rfn[i_inst.rd] = i.rdata[daddr[1]*16+:16];
+          LB:  rfn[i_inst.rd] = $signed(i.rdata[daddr[1:0]*8+:8]);
+          LBU: rfn[i_inst.rd] = i.rdata[daddr[1:0]*8+:8];
         endcase
       end
 
       SW, SH, SB: begin
-        s_inst = s_inst_t'(inst);
-        wdata  = rf[s_inst.rs2];
-        daddr  = rf[s_inst.rs1] + assemble_s_imm(s_inst);
+        s_inst  = s_inst_t'(inst);
+        i.wdata = rf[s_inst.rs2];
+        daddr   = rf[s_inst.rs1] + assemble_s_imm(s_inst);
         unique casez (inst)
-          SW: wsize = WORD;
+          SW: i.wsize = WORD;
           SH: begin
-            wsize = HWORD;
-            wdata <<= daddr[1] * 16;
+            i.wsize = HWORD;
+            i.wdata <<= daddr[1] * 16;
           end
           SB: begin
-            wsize = BYTE;
-            wdata <<= daddr[1:0] * 8;
+            i.wsize = BYTE;
+            i.wdata <<= daddr[1:0] * 8;
           end
         endcase
       end

system.sv instantiates three interfaces, because rdata and wsize must be routed based on the address. It is also possible to use the interface without modports, but then the ports will be declared with inout direction, which is less strict. This may lead to more bugs.

Listing 81 code/riscv-single-cycle-io/system.sv#
--- /builds/fpga/fpga-programming/code/riscv-single-cycle-io-no-interface/system.sv
+++ /builds/fpga/fpga-programming/code/riscv-single-cycle-io/system.sv
@@ -12,14 +12,15 @@
     input  [ IO_INPUT_COUNT-1:0] io_inp,
     output [IO_OUTPUT_COUNT-1:0] io_out
 );
-  word inst, pc, addr, wdata, rdata;
-  wsize_t wsize;
+  word inst, pc;
+  imm_bus_if mp_if (.*), ram_if (.*), io_if (.*);
 
   mp #(
       //.RAM_INIT_HEX_FILE, <= Vivado does not accept this syntax
       .RAM_INIT_HEX_FILE(RAM_INIT_HEX_FILE),
       .RAM_DEPTH_LG2(RAM_DEPTH_LG2)
   ) mp (
+      .i(mp_if.initiator),
       .*
   );
 
@@ -30,28 +31,16 @@
       .INIT_HEX_FILE(RAM_INIT_HEX_FILE),
       .DEPTH_LG2(RAM_DEPTH_LG2)
   ) ram (
-      .clk,
-      .daddr(addr),
-      .wdata(wdata),
-      .rdata(ram_rdata),
-      .wsize(ram_wsize),
       .iaddr(pc),
-      .inst (inst)
+      .inst(inst),
+      .i(ram_if)
   );
 
-  // IO
-  word_signed io_rdata;
-  wsize_t io_wsize;
   io #(
       .INPUT_COUNT (IO_INPUT_COUNT),
       .OUTPUT_COUNT(IO_OUTPUT_COUNT)
   ) io (
-      .clk,
-      .rst,
-      .addr,
-      .wdata,
-      .rdata(io_rdata),
-      .wsize(io_wsize),
+      .i(io_if),
       .io_inp,
       .io_out
   );
@@ -59,14 +48,19 @@
   // RAM / IO multiplexer
   localparam int unsigned IOAddrMask = 'hF?_??_??_??;
   always_comb begin
-    unique if (addr ==? IOAddrMask) begin
-      rdata = io_rdata;
-      ram_wsize = ZERO;
-      io_wsize = wsize;
+    ram_if.wdata = mp_if.wdata;
+    io_if.wdata  = mp_if.wdata;
+
+    ram_if.addr  = mp_if.addr;
+    io_if.addr   = mp_if.addr;
+    unique if (mp_if.addr ==? IOAddrMask) begin
+      mp_if.rdata  = io_if.rdata;
+      ram_if.wsize = ZERO;
+      io_if.wsize  = mp_if.wsize;
     end else begin
-      rdata = ram_rdata;
-      ram_wsize = wsize;
-      io_wsize = ZERO;
+      mp_if.rdata  = ram_if.rdata;
+      ram_if.wsize = mp_if.wsize;
+      io_if.wsize  = ZERO;
     end
   end
 endmodule

Module system_boolean.sv does not need any modifications compared to code/riscv-single-cycle-io-no-interface-boolean/system_boolean.sv, because the ports stay the same.

Complete sources can be found under:

Homework#

Exercise 35

Create an interface that implements the signals used in AMBA APB specification).

Exercise 36

Create a bus-capable seven-segment display controller that uses imm_bus_if that we implemented in section Implementation with the interface construct. Use the seven-segment controller that we implemented in Exercise.

The easiest solution interconnects two modules that we implemented. Do you know which?

Spoiler

The easiest solution is to connect the seven-segment controller to the bus-capable IO peripheral that you know from this chapter.