Hi, welcome back to our Hardcaml MIPS project! My name is Mayur, and I’m a rising junior at Penn State. In this post, I will use Hardcaml’s module hierarchy feature to split our MIPS processor into simple components. If you want to see the end result of this post, we've tagged it as v0.2.0 on Github.
Designing, testing, and maintaining a system is easier when it’s broken down into simple units which are, in turn, compositions of other simple units. In hardware, we can use small, simple circuits as pieces of a larger circuit. Hardcaml’s mechanism for this is called Module Hierarchies.
Circuit Database and Scope
Hardcaml’s Verilog generation function (Rtl.print
) takes a single circuit and outputs its Verilog representation. However, if our design uses multiple circuits, we want to convert all of them to Verilog. Hardcaml provides a data structure called the “circuit database”, to store circuit implementations. When we call Rtl.print
, we can provide a circuit database instance as an argument, and Hardcaml will also generate Verilog for all the circuits in the database.
So how can we get all of our circuits into the database? One way is to have there be a centralized location where we manually update the database. The issue with this is if we want to add or remove circuits we have to remember to adjust the circuit database every time, which is messy and prone to error. Hardcaml provides a Scope
utility, which is a mutable object we can pass through our circuit design. It adds circuits to the circuit database in the process. By using Scope, Hardcaml handles updating the database for us, reducing the likelihood of error.
Instantiating Subcircuits
Hardcaml uses functions to represent circuits. If we want to use a smaller circuit in a larger circuit, we call the smaller circuit's implementation function within the larger circuit. For example:
open Hardcaml
open Hardcaml.Signal
module I = struct
type 'a t = { clock : 'a } [@@deriving sexp_of, hardcaml]
end
module O = struct
type 'a t = { instruction : 'a [@bits 32] } [@@deriving sexp_of, hardcaml]
end
let create (scope : Scope.t) (_i : _ I.t) =
let instruction_fetch =
Instruction_fetch.hierarchical scope { pc = of_string "0" }
in
{ O.instruction = instruction_fetch.instruction }
This module (called Datapath
), is the implementation of our CPU. Remember that the MIPS CPU uses a pipelined design. We designed each pipeline stage as a subcircuit, with each subcircuit containing the logic for that stage. We connect all of the stages via the datapath. For now, all we have is the instruction fetch stage.
For a recap on how Datapath
works, take a look at our previous post. Note that we’ve slightly changed the inputs and outputs of datapath: now, we output the instruction we’re currently processing (for testing purposes. This will be changed later).
Datapath needs to instantiate instruction fetch to use its outputs in other stages. It does so by calling the Instruction_fetch.hierarchical
function and passing in wire inputs. It also passes scope
so that instruction fetch can instantiate subcircuits (if needed). The output wires are also available as a result of the function call.
Let’s take a look at how Instruction_fetch.hierarchical
works:
open Hardcaml
open Hardcaml.Signal
module I = struct
type 'a t = { pc : 'a [@bits 32] } [@@deriving sexp_of, hardcaml]
end
module O = struct
type 'a t = { instruction : 'a; [@bits 32] next_pc : 'a [@bits 32] }
[@@deriving sexp_of, hardcaml]
end
let create (_scope : Scope.t) (i : _ I.t) =
{ O.next_pc = i.pc +:. 4; O.instruction = of_string "32'h014B4820" }
let hierarchical (scope : Scope.t) (input : _ I.t) =
let module H = Hierarchy.In_scope (I) (O) in
H.hierarchical ~scope ~name:"instruction_fetch" create input
This is the Instruction_fetch module. The circuit’s actual implementation is defined in create
. Instruction_fetch.hierarchical
acts as a wrapper, calling the create
function. In the process, it uses the scope to register create
to the circuit database, so that Verilog can be generated later on.
Generating Multiple Modules
Finally, we need to adjust our main.ml
script to output multiple modules. Our Datapath.create
function now expects a scope
argument (as we’ve discussed at length). To support this, we create a scope instance and bind it to Datapath.create
via currying before packaging it up as a Hardcaml Circuit. We also pass the circuit database as an input to Rtl.print
, so all our registered circuits can be converted to Verilog.
open Hardcaml
open Mips
module MipsCircuit = Circuit.With_interface (Datapath.I) (Datapath.O)
let scope = Scope.create ()
let circuit =
let bound_create = Datapath.create scope in
MipsCircuit.create_exn ~name:"datapath" bound_create
let () = Rtl.print ~database:(Scope.circuit_database scope) Verilog circuit
As a result of these changes, the executable compiled by dune build
will now output separate Verilog modules for each of our Hardcaml modules, allowing us to split our design into simple components. These will be easier to implement, test, and change than a single, massive circuit.
To read more about module hierarchy, take a look at the official HardCaml documentation:
Hardcaml Observations
- Even though we don’t use
scope
in Instruction Fetch’s create
because it has no subcircuits, we still need to include it as an argument because Hierarchy.In_scope.hierarchy
requires a Scope.t -> t I.t -> t O.t
value.