Learn HardCaml in Half an Hour

inspired by fasterthanlime article on "Learn Rust in Half an Hour"

HardCaml is a domain-specific language embedded in OCaml for designing digital circuits. You know, the kind that eventually become chips. But unlike traditional hardware description languages like Verilog or VHDL, HardCaml lets you leverage OCaml's powerful type system and functional programming features to build hardware designs that are both correct and composable.

Ready? Let's dive in.

Variables and Mutability

Actually, let's start with something more fundamental. In HardCaml, everything is about signals.

let my_signal = Signal.of_int ~width:8 42

This creates an 8-bit signal with the constant value 42. The ~width:8 part is OCaml's labeled argument syntax - we're explicitly saying this signal should be 8 bits wide.

Unlike variables in imperative languages, signals in HardCaml represent wires in your circuit. Once you create a signal, it doesn't change - it represents a fixed connection in your hardware.

let a = Signal.of_int ~width:4 5
let b = Signal.of_int ~width:4 3
let sum = Signal.(a +: b)

The +: operator adds two signals. Notice the .() syntax - that's OCaml's way of opening a module locally. It's like saying "use the operators from the Signal module here."

Basic Operations

HardCaml has different operators for different kinds of operations:

let a = Signal.of_int ~width:8 10
let b = Signal.of_int ~width:8 5

(* Arithmetic *)
let sum = Signal.(a +: b)
let diff = Signal.(a -: b)
let product = Signal.(a *: b)

(* Logic *)
let and_result = Signal.(a &: b)
let or_result = Signal.(a |: b)
let xor_result = Signal.(a ^: b)

(* Comparison *)
let equal = Signal.(a ==: b)
let less_than = Signal.(a <: b)

The : at the end of each operator is HardCaml's convention. It helps distinguish hardware operators from regular OCaml operators.

Bit Manipulation

Working with individual bits or ranges of bits:

let byte_signal = Signal.of_int ~width:8 0b10110101

(* Get a single bit *)
let bit_2 = Signal.bit byte_signal 2

(* Get a range of bits *)
let low_nibble = Signal.select byte_signal 3 0  (* bits 3 down to 0 *)
let high_nibble = Signal.select byte_signal 7 4  (* bits 7 down to 4 *)

(* Concatenate signals *)
let reconstructed = Signal.concat_msb [high_nibble; low_nibble]

The select function takes the signal, the high bit index, and the low bit index. It reads a bit backwards from what you might expect, but that's the hardware convention.

Multiplexers (the if-then-else of Hardware)

let condition = Signal.of_bool true
let a = Signal.of_int ~width:8 42
let b = Signal.of_int ~width:8 24

let result = Signal.mux2 condition a b

mux2 is like a conditional: if condition is true (high), choose a, otherwise choose b.

For multiple choices, use mux:

let selector = Signal.of_int ~width:2 1
let choices = [
  Signal.of_int ~width:8 10;
  Signal.of_int ~width:8 20;
  Signal.of_int ~width:8 30;
  Signal.of_int ~width:8 40;
]
let result = Signal.mux selector choices

Sequential Logic (Memory!)

So far everything has been combinational - the output immediately depends on the input. But real hardware needs memory. Enter registers:

let spec = Reg_spec.create ~clock ~clear ()

let counter_next = Signal.wire 8
let counter = Signal.reg spec counter_next
let () = Signal.assign counter_next Signal.(counter +: (Signal.of_int ~width:8 1))

Whoa, what's happening here? We're creating a counter that increments every clock cycle. But there's this weird wire and assign business. The problem is that we have a cycle: the counter's next value depends on its current value. HardCaml solves this with wires - you create a placeholder signal, use it in your logic, then assign the actual value later.

A More Practical Example

Let's build a simple traffic light controller:

module Traffic_light = struct
  type state = Red | Yellow | Green

  let next_state current_state timer_expired =
    Signal.(mux current_state [
      (* Red -> Green when timer expires *)
      mux2 timer_expired (Signal.of_int ~width:2 2) current_state;
      (* Yellow -> Red when timer expires *) 
      mux2 timer_expired (Signal.of_int ~width:2 0) current_state;
      (* Green -> Yellow when timer expires *)
      mux2 timer_expired (Signal.of_int ~width:2 1) current_state;
    ])

  let create ~clock ~clear ~timer_expired =
    let spec = Reg_spec.create ~clock ~clear () in
    let state_next = Signal.wire 2 in
    let state = Signal.reg spec ~enable:Signal.vdd state_next in
    let next = next_state state timer_expired in
    Signal.assign state_next next;
    state
end

This creates a state machine that cycles through Red β†’ Green β†’ Yellow β†’ Red based on timer expiration.

Modules and Interfaces

HardCaml uses OCaml's module system to organize designs. Here's how you typically structure a module:

module Adder = struct
  module I = struct
    type 'a t = {
      a : 'a;
      b : 'a;
    } [@@deriving sexp_of, hardcaml]
  end

  module O = struct
    type 'a t = {
      sum : 'a;
      carry : 'a;
    } [@@deriving sexp_of, hardcaml]
  end

  let create (inputs : _ I.t) =
    let open Signal in
    let sum = inputs.a ^: inputs.b in
    let carry = inputs.a &: inputs.b in
    { O.sum; carry }
end

The [@@deriving hardcaml] part is PPX magic that generates helper functions for working with these interfaces. It creates functions to convert between records and signal lists, which is super useful for connecting modules together.

Simulation

Writing hardware is only half the fun - you need to test it! HardCaml includes a built-in simulator:

let testbench () =
  let module Sim = Cyclesim.With_interface(Adder.I)(Adder.O) in
  let sim = Sim.create Adder.create in
  
  let inputs = Cyclesim.inputs sim in
  let outputs = Cyclesim.outputs sim in
  
  (* Set input values *)
  inputs.a := Bits.of_int ~width:4 5;
  inputs.b := Bits.of_int ~width:4 3;
  
  (* Run one simulation cycle *)
  Cyclesim.cycle sim;
  
  (* Check outputs *)
  Printf.printf "Sum: %d, Carry: %d\n"
    (Bits.to_int !(outputs.sum))
    (Bits.to_int !(outputs.carry))

Notice that during simulation, we work with Bits.t values instead of Signal.t. Signals represent the structure of your circuit, while Bits represent actual values flowing through that circuit during simulation.

Expect Tests and Waveforms

HardCaml integrates beautifully with Jane Street's expect test framework:

let%expect_test "adder test" =
  let testbench () = 
    (* simulation code goes here *)
    let module Sim = Cyclesim.With_interface(Adder.I)(Adder.O) in
    let sim = Sim.create Adder.create in
    
    let inputs = Cyclesim.inputs sim in
    let outputs = Cyclesim.outputs sim in
    
    (* Set input values *)
    inputs.a := Bits.of_int ~width:4 5;
    inputs.b := Bits.of_int ~width:4 3;
    
    (* Run one simulation cycle *)
    Cyclesim.cycle sim;
    
    (* Check outputs *)
    Printf.printf "Sum: %d, Carry: %d\n"
      (Bits.to_int !(outputs.sum))
      (Bits.to_int !(outputs.carry))
  in
  testbench ();
  [%expect {|
    Sum: 8, Carry: 0
  |}]

And you can visualize what's happening with waveforms:

let%expect_test "counter waveform" =
   let sim = 
    let module Sim = Cyclesim.With_interface(Counter.I)(Counter.O) in
    Sim.create Counter.create
  in
  let waveform = Waveform.create sim in
  
  (* Run simulation for several cycles *)
  for i = 1 to 8 do
    Cyclesim.cycle sim;
  done;
  
  Waveform.print waveform;
  [%expect {|
    β”ŒSignalsβ”€β”€β”€β”€β”€β”€β”€β”€β”β”ŒWaves──────────────────────────────────────────────┐
    β”‚clock          β”‚β”‚β”Œβ”€β”€β”€β”   β”Œβ”€β”€β”€β”   β”Œβ”€β”€β”€β”   β”Œβ”€β”€β”€β”   β”Œβ”€β”€β”€β”   β”Œβ”€β”€β”€β”   β”Œβ”€β”€β”‚
    β”‚               β”‚β”‚    β””β”€β”€β”€β”˜   β””β”€β”€β”€β”˜   β””β”€β”€β”€β”˜   β””β”€β”€β”€β”˜   β””β”€β”€β”€β”˜   β””β”€β”€β”€β”˜  β”‚
    β”‚counter        β”‚β”‚ 00 β”‚01 β”‚02 β”‚03 β”‚04 β”‚05 β”‚06 β”‚07                   β”‚
    β”‚               ││────┴───┴───┴───┴───┴───┴───┴───────────────────   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  |}]

Always Blocks (State Machines Made Easy)

For complex sequential logic, HardCaml provides an Always DSL that looks like imperative code but generates proper hardware:

let create_state_machine ~clock ~clear ~start =
  let spec = Reg_spec.create ~clock ~clear () in
  let state = State_machine.create spec ~enable:Signal.vdd in
  
  Always.(compile [
    state.switch [
      IDLE, [
        when_ start [
          state.set_next COUNTING;
        ]
      ];
      COUNTING, [
        when_ (counter ==:. 100) [
          state.set_next DONE;
        ]
      ];
      DONE, [
        state.set_next IDLE;
      ];
    ]
  ])

This creates a state machine with three states that transitions based on conditions. The Always module compiles this into proper register assignments.

Hierarchical Design

Real designs are built by composing smaller modules:

module CPU = struct
  module I = struct
    type 'a t = {
      clock : 'a;
      clear : 'a;
      instruction : 'a [@bits 32];
    } [@@deriving sexp_of, hardcaml]
  end

  module O = struct
    type 'a t = {
      pc : 'a [@bits 32];
      result : 'a [@bits 32];
    } [@@deriving sexp_of, hardcaml]
  end

  let create (inputs : _ I.t) =
    let alu_out = ALU.create { 
      a = register_file_a; 
      b = register_file_b; 
      op = alu_op 
    } in
    let pc_next = PC.create {
      current_pc = pc;
      branch = branch_signal;
      jump = jump_signal;
    } in
    (* ... more logic ... *)
    { O.pc = pc_next.pc; result = alu_out.result }
end

The [@bits n] annotations tell HardCaml how wide each signal should be.

Memory

HardCaml can generate different kinds of memory:

(* Simple register file *)
let reg_file = Ram.create 
  ~collision_mode:Read_before_write
  ~size:32
  ~write_ports:[{
    write_clock = clock;
    write_enable = write_enable;
    write_address = write_addr;
    write_data = write_data;
  }]
  ~read_ports:[{
    read_clock = clock;
    read_enable = read_enable;
    read_address = read_addr;
  }]

This creates a 32-entry memory with one read port and one write port.

Instantiating Verilog

Sometimes you need to use existing Verilog IP. HardCaml makes this straightforward:

let ddr_controller = Signal.inst "ddr_controller" [
  "clk", clock;
  "reset", clear;
  "cmd_valid", cmd_valid;
  "cmd_addr", cmd_addr;
] ~outputs:["cmd_ready"; "data_valid"; "data_out"]

This instantiates a Verilog module called ddr_controller and connects its ports to HardCaml signals.

Generating Verilog

Once you've built your design, you can generate Verilog:

let () =
  let module Circuit = Circuit.With_interface(CPU.I)(CPU.O) in
  Rtl.output Verilog ~output_file:(open_out "cpu.v") 
    (Circuit.create_exn ~name:"cpu" CPU.create)

This generates a cpu.v file that you can synthesize with standard FPGA tools.

Testing and Formal Verification

HardCaml supports formal verification through integration with external tools:

let%expect_test "formal verification" =
  let property = Signal.(
    (* Property: output should always equal input when enable is high *)
    implies enable (output ==: input)
  ) in
  
  let result = Formal.check_property property circuit in
  match result with
  | Proved -> Printf.printf "Property holds!\n"
  | Counterexample trace -> Printf.printf "Failed: %s\n" (Trace.to_string trace)

Types and Width Inference

One of HardCaml's superpowers is its type system. It catches width mismatches at compile time:

let a = Signal.of_int ~width:8 42
let b = Signal.of_int ~width:4 3
let sum = Signal.(a +: b)  (* Compile error! Width mismatch *)

HardCaml will complain that you're trying to add an 8-bit signal to a 4-bit signal. You need to explicitly resize:

let sum = Signal.(a +: (uresize b 8))  (* OK! *)

Pattern Matching on Signals

You can pattern match on signal values during simulation:

let decode_instruction instruction =
  let opcode = Signal.select instruction 31 26 in
  match Bits.to_int !(opcode) with
  | 0 -> "ADD"
  | 1 -> "SUB" 
  | 2 -> "LOAD"
  | 3 -> "STORE"
  | _ -> "UNKNOWN"

Useful Libraries

HardCaml comes with several useful libraries:

  • Hardcaml_waveterm: Terminal-based waveform viewer

  • Hardcaml_xilinx: Xilinx-specific IP integration

  • Hardcaml_circuits: Common circuit patterns

  • Hardcaml_step_testbench: Advanced testing framework

Error Messages

HardCaml's error messages are generally quite helpful:

Error: Signal width mismatch
  Expected: 8 bits
  Got: 4 bits
  Hint: Use Signal.uresize or Signal.sresize to adjust width

Much better than the cryptic errors you get from traditional HDLs!

Performance Considerations

HardCaml designs can be highly optimized:

(* Parallel multiplication using DSP blocks *)
let parallel_multiply inputs =
  List.map inputs ~f:(fun (a, b) -> Signal.(a *: b))
  |> Signal.tree ~arity:2 ~f:Signal.(+:)

(* Pipeline for high clock frequencies *)
let pipelined_adder ~clock a b =
  let spec = Reg_spec.create ~clock () in
  let stage1 = Signal.reg spec Signal.(a +: b) in
  let stage2 = Signal.reg spec stage1 in
  stage2

Where to Go From Here

This covers the core of HardCaml! You should now be able to read most HardCaml code you encounter. Key concepts to remember:

  • Everything is a signal representing wires in your circuit

  • Combinational logic uses operators like +:, &:, mux2

  • Sequential logic uses registers and the Always DSL

  • Modules organize your design hierarchically

  • Simulation lets you test your designs before synthesis

  • The type system catches many errors at compile time

For more advanced topics, check out:


Last updated