Welcome to Ceramic Hacker

Hi, I'm Alexander (Sasha) Skvortsov. I'm a computer science and math major at Penn State. I'll be using this blog to share some of my projects and thoughts, mainly focused around software engineering and pottery.
Hardcaml MIPSBlog

[5/x] Multi Module Circuits in Hardcaml

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.
Previous articleNext article

Comments (0)

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.
Write a Reply...