Hi, welcome back to our Hardcaml MIPS project! Now that we've finished our instruction fetch stage and become a bit more comfortable with Hardcaml, we'll clean up our code a bit and adopt some conventions to keep future development organized.
The Small Stuff
There's a few things we changed for consistency / cleanup across our codebase:
One Hardcaml convention is to name each circuit's implementation function create
. However, there's a lot of things called create
in Hardcaml, so we're renaming all our implementation functions to circuit_impl
.
We fixed a bug where instruction memory repeats the last instruction in the program forever once finished executing. Instead, it'll just output noop
s to avoid unexpected behavior.
We cleaned up our explicit type hinting a bit, so that only circuit_impl
functions have typehinted arguments. This provides some visual documentation without unnecessary clutter.
Width Safety
One of my biggest frustrations with Verilog is that you can connect a 5-bit wire to an 8-bit input, and Verilog won't complain. Hardcaml already does a lot to prevent issues like this: for instance, all options in a mux must be the same width. That being said, one place that's missing a check is module composition: Hardcaml won't error if we pass an X-bit input to a subcircuit that expects Y bits.
Luckily, the I
and O
modules we use in each circuit to define input/output structure have an assert_widths
function (automatically available through through a ppx rewriter), which we can run on the provided input and generated output. Problem is, we don't want to put duplicate logic for this in every single circuit.
To get around this, we can use a functor to abstract away these checks for any given I
and O
:
width_check.ml
open Hardcaml
module With_interface (I : Interface.S) (O : Interface.S) =
struct
let check_widths circuit_impl i =
I.Of_signal.assert_widths i;
let out = circuit_impl i in
O.Of_signal.assert_widths out;
out
end
Now, we can use this to wrap our circuit_impl
functions:
let circuit_impl_exn program scope input =
let module W = Width_check.With_interface (I) (O) in
W.check_widths (circuit_impl program scope) input
The _exn
naming is a convention that indicates that the function may throw exceptions. This we do have to put in every circuit, but by using a functor, we've managed to centralize the actual logic in one place.
Accordingly, we changed all any code using circuit_impl
to usecircuit_impl_exn
instead.
File Organization
Our source code for each circuit will follow a simple convention:
- First, we define the
I
and O
modules that represent the structure of the input/output of our circuit.
- Then, we have the actual implementation code of our circuit. This includes the
circuit_impl
function, as well as any logic we've factored out into functions.
- Finally, we put any util functions wrapping
circuit_impl
, such as circuit_impl_exn
, and hierarchical
for subcircuits.
For an example, see instruction_fetch.ml.
In our codebase, we'll have a directory for each stage. Files/modules used across stages go directly in lib
.
Testing
We haven't talked about Hardcaml's testing tools much in these posts, largely because Andrew Ray, the creator of Hardcaml, already has an excellent blog post about it.
That being said, just because we haven't been writing about it doesn't mean we haven't been doing it! Our project's test
directory has automated tests for every circuit we write. This makes it a lot easier to be confident that our design is right. We're also using GitHub Actions to run all our tests every time we push code to GitHub. Hardcaml's testing/simulation tools are one of it's strongest advantages over Verilog, so if you're making a Hardcaml project, it's definitely worth doing.