2.2 Sequential Logic
March 30, 2026 ยท View on GitHub
Sequential logic is built from primitives such as registers and memories.
A key concept in sequential logic is the clock. Sequential logic
updates on the rising (or, less commonly, falling) edge of the clock and
holds its value throughout the clock cycle.
Reg_spec
The clock and related reset and clear signals are grouped together in
a type called a Reg_spec.t.
# open Hardcaml
# let clock = Signal.input "clock" 1
val clock : Signal.t = (wire (names (clock)) (width 1))
# let clear = Signal.input "clear" 1
val clear : Signal.t = (wire (names (clear)) (width 1))
# let spec = Signal.Reg_spec.create ~clock ~clear ()
val spec : Signal.Reg_spec.t = <abstr>
Multiple sequential elements are then able to refer to the same
Reg_spec.t.
Registers, Pipelines
A simple register takes a signal as input and basically delays it for one cycle.
# let d_in = Signal.input "d_in" 8
val d_in : Signal.t = (wire (names (d_in)) (width 8))
# let q_out = Signal.reg spec ~enable:Signal.vdd d_in
val q_out : Signal.t =
(register (width 8)
((clock clock) (clock_edge Rising) (clear clear) (clear_to 0b00000000))
(data_in d_in))
The pipeline function will delay its input for multiple cycles.
# let q_out_after_3_clocks = Signal.pipeline spec ~enable:Signal.vdd ~n:3 d_in
val q_out_after_3_clocks : Signal.t =
(register (width 8)
((clock clock) (clock_edge Rising) (clear clear) (clear_to 0b00000000))
(data_in register))
Registers with Feedback
We noted previously that combinational logic could not contain cycles. We can lift this restriction with sequential logic so long as the cycle passes through a register (or memory).
The function reg_fb encodes a simple example of this pattern. For
example, to build a counter, we need to access the current value to produce
the next one.
# let counter = Signal.reg_fb spec ~enable:Signal.vdd ~width:8 ~f:(fun d -> Signal.(d +:. 1))
val counter : Signal.t =
(register (width 8)
((clock clock) (clock_edge Rising) (clear clear) (clear_to 0b00000000))
(data_in wire))
Wires
In Hardcaml a wire is a signal which can be declared before
providing its input driver. Logically, it does nothing - it just
passes its input through to its output.
# let w = Signal.wire 1;;
val w : Signal.t = (wire (width 1))
Wires can later be assigned an input driver.
# Signal.(w <-- vdd);;
- : unit = ()
# w;;
- : Signal.t = (wire (width 1) (data_in 0b1))
Apart from the fact they logically do nothing, they are really useful!
It is how the reg_fb function is implemented.
# let reg_fb spec ~enable ~w f =
let d = Signal.wire w in
let q = Signal.reg spec ~enable (f d) in
Signal.(d <-- q);
q
val reg_fb :
Signal.Reg_spec.t ->
enable:Signal.t -> w:int -> (Signal.t -> Signal.t) -> Signal.t = <fun>
Without wires we cannot express the above function since the input
to reg refers to its own output.
Two words of caution when using wires.
- Combinational cycles! We can't stop you from creating them, but we can throw an exception when you do. It's worth repeating - cycles must pass through a sequential primitive.
- The hairy unassigned wire exception. Hardcaml will detect when you forget to assign a value to a wire, but the error will not be especially useful in finding out where. You can enable an extra level of debugging information by setting the following value, which will help track down where the wire was originally defined.
Caller_id.set_mode Top_of_stack
State Machines
State machines are just a complex combination of registers and multiplexers, so it is possible to build them directly with the primitives described here - but not very conveniently. Rather, we recommend using the Always DSL.
Memories
Core asynchronous memory primitive
Hardcaml provides the multiport_memory primitive for describing
memory structures.
They are synchronously written using write_ports. Each write port
can have a different clock.
let clock = Signal.input "clock" 1;;
let address = Signal.input "address" 8;;
let write_enable = Signal.input "write_enable" 1;;
let data = Signal.input "data" 32;;
# let write_port =
{ Write_port.write_clock = clock
; write_address = address
; write_enable = write_enable
; write_data = data }
val write_port : Signal.t Write_port.t =
{Hardcaml.Write_port.write_clock = (wire (names (clock)) (width 1));
write_address = (wire (names (address)) (width 8));
write_enable = (wire (names (write_enable)) (width 1));
write_data = (wire (names (data)) (width 32))}
The memory is read asynchronously using read_addresses. The read data
is returned as an array, one for each read port.
# let read_address = address
val read_address : Signal.t = (wire (names (address)) (width 8))
# let q =
Signal.multiport_memory
256
~write_ports:[|write_port|]
~read_addresses:[|read_address|]
val q : Signal.t array =
[|(memory_read_port (width 32)
((memory multiport_memory) (read_addresses address)))|]
RAMs
On their own, multiport_memorys are not that useful. Instead, we
provide the Ram
module, which can target the physical RAM blocks in
FPGAs (i.e. BlockRAM or UltraRAM in Xilinx devices).
This is done by instantiating a multiport_memory and a register placed on either the read address or output data. Note that this means the read ports are now synchronous and read data is returned one cycle later.
# let read_port =
{ Read_port.read_clock = clock
; read_address
; read_enable = Signal.input "read_enable" 1 }
val read_port : Signal.t Read_port.t =
{Hardcaml.Read_port.read_clock = (wire (names (clock)) (width 1));
read_address = (wire (names (address)) (width 8));
read_enable = (wire (names (read_enable)) (width 1))}
Each read port can have a different clock.
# let q =
Ram.create
~collision_mode:Read_before_write
~size:256
~write_ports:[|write_port|]
~read_ports:[|read_port|]
()
val q : Signal.t array =
[|(register (width 32) ((clock clock) (clock_edge Rising) (enable read_enable))
(data_in memory_read_port))|]
For this to work we use a feature of FPGA synthesizers called RTL RAM inference. This process is notoriously finicky, so read the tool reports to ensure it is doing what you expect (I'm looking at you, Vivado!).
Practical considerations
The RAM structures provided with Hardcaml are very flexible regarding the number of read and write ports they can provide. Physically, however, FPGAs provide RAMs with 1 or 2 ports. If you specify more than this, you will probably not get the results you intended.
The hardcaml_xilinx
library offers a more targeted solution for Xilinx FPGAs and may be
more suitable for applications where precise control of the Vendor RAM
primitive is required.