Combinatorial Logic

The signature Comb.S is the combinatorial API of HardCaml and defines the basic arithmetic, logical, comparison etc vector operations.

constants `const constb consti vdd gnd zero one ones ...`
bitwise `(&:) (|:) (^:) (~:)`
arithmetic `(+:) (-:) (*:) (*+) negate`
comparison `(<:) (<=:) (>:) (>=:) (<+) (<=+) (>+) (>=+) (==:) (<>:)`
shift `sll srl sra log_shift`
resize `uresize sresize ue se`
selection `select select_e msb lsb msbs lsbs split bits`
multiplexors/roms `mux mux2 mux_init`
concatenation `concat concat_e (@:) repeat`
other `width input output ...`

There are a few general rules followed by the API

  • the operators append a : ie (+:) for addition
  • vector widths are important, and most operations require that operands are the same width. A runtime exception is raised if not.
  • with the above rule most operations do not care whether the operands are logically signed or unsigned. Where it matters (comparison, multiplication) a + symbol is appended for the signed variant rather than a :. If you dont like these rules, see the TypedMath modules for dealing with signed/unsigned vectors and the required zero/sign extension.
  • Many operators have a version with a . added ie (+:.). This indicates that the second operand is given as an integer and the appropriate constant is inferred ie a +:. 1 becomes a +: (consti (width a) 1).

Graphs

An important point to note is that HardCaml RTL designs are essentially graphs with defined inputs and outputs.

  • Inputs are wires with a (single) name that have not been assigned to.
  • Outputs are again wires with a (single) name.
  • Constants are the only other way to introduce a signal into the graph.

Everything else in the graph is a node which takes signals as input and produces one or more signals as output.

Circuits are constructed by providing a list of outputs. The graph is then traversed from the outputs to find all connected nodes and the inputs.

Exploring the API

Time for some examples. We will use the shallow embedding so we can see exactly what is happening. The code is equally relevant to the deep embedding, however.


In [1]:
open HardCaml.Bits.Comb.IntbitsList

Widths

Every signal may be queried for it's size in bits using the width function.


In [2]:
width (const "12'd0")


Out[2]:
- : int = 12

This means it is possible to design circuits that can take any width of argument and adapt themselves internally. This leads to generic designs often with little effort.

Constants

There are a number of functions to help creating constants. The most basic one is constb. It takes a string which must consist of 0 or 1 characters only. Note that the most significant bit is the left most (first) character.


In [3]:
let a = constb "0101"


Out[3]:
val a : t = [0; 1; 0; 1]

This results in the list [0; 1; 0; 1]. Again, the most significant bit is the left most (head) element of list.

The consti function is probably the most convienient of all the constant constructing functions.


In [4]:
let b = consti 4 3


Out[4]:
val b : t = [0; 0; 1; 1]

The first parameter is the vector width. The second parameter is it's value. Variants for int32 and int64 are called consti32 and consti64.

Constants can also be generated from strings of hex characters.


In [5]:
consthu 4 "e"


Out[5]:
- : t = [1; 1; 1; 0]

The most general function for creating constants is called simply const. It takes a string with an embedded width, type and value. It's format is very similar to that of Verilog style constants.

"<width>'<type><value>"


In [6]:
const "4'b0001", const "5'd-2", const "6'hf"


Out[6]:
- : t * t * t = ([0; 0; 0; 1], [1; 1; 1; 1; 0], [0; 0; 1; 1; 1; 1])

The following types are valid: b binary, d decimal, h hexidecimal. The types may also be capitalised. This causes sign extension if the value given is shorted than the given width.


In [7]:
const "6'hf", const "6'Hf"


Out[7]:
- : t * t = ([0; 0; 1; 1; 1; 1], [1; 1; 1; 1; 1; 1])

In [8]:
const "2'b1", const "2'B1"


Out[8]:
- : t * t = ([0; 1], [1; 1])

The two predefined functions vdd and gnd are commonly used to represent a single bit with value 1/true and 0/false respectively.

Arithmetic

HardCaml is strict about what it allows you to pass to operators.


In [9]:
consti 8 2 +: consti 7 3


Out[9]:
Exception: Comb.Failure Operands to +: must be the same width: 8 7.

The width of the operands must be the same, as is the case for the constants a and b defined earlier.


In [10]:
a +: b


Out[10]:
- : t = [1; 0; 0; 0]

For addition (and subtraction) the width of the result is the same as the operands. In some cases this is not what is wanted ie overflow.


In [11]:
let a, b = const "111", const "001"
let c = a +: b


Out[11]:
val a : t = [1; 1; 1]
val b : t = [0; 0; 1]
Out[11]:
val c : t = [0; 0; 0]

In this case we could use the ue function to increase the size of the operands by 1 bit.


In [12]:
ue a +: ue b


Out[12]:
- : t = [1; 0; 0; 0]

If we know that our operands are specifically meant to represent, say, signed vectors we could write a generic addition function as follows.


In [13]:
let add_signed a b = 
    let w = max (width a) (width b) + 1 in
    sresize a w +: sresize b w


Out[13]:
val add_signed : t -> t -> t = <fun>

In [14]:
add_signed (consti 8 22) (consti 6 (-23))


Out[14]:
- : t = [1; 1; 1; 1; 1; 1; 1; 1; 1]

In fact this sort of properly defined arithmetic is common enough that it is provided by the Signed and Unsigned modules (: TypedMath).


In [15]:
let a, b = Signed.(of_signal (consti 8 22), of_signal (consti 6 (-23)))


Out[15]:
val a : Signed.v = <abstr>
val b : Signed.v = <abstr>

In [16]:
Signed.(to_signal (a +: b))


Out[16]:
- : t = [1; 1; 1; 1; 1; 1; 1; 1; 1]

Multiplication

Multipliers are one of the few operators which do not care about vector sizes.


In [17]:
consti 2 2 *: consti 3 3


Out[17]:
- : t = [0; 0; 1; 1; 0]

That being said it does care about signs and there is a seperate signed version of the operator.


In [18]:
consti 2 2 *+ consti 3 3


Out[18]:
- : t = [1; 1; 0; 1; 0]

Bitwise operators

The bitwise operators behave similarly to addition and subtraction - operand and result are all the same width.


In [19]:
let a,b = consti 2 1, consti 2 3


Out[19]:
val a : t = [0; 1]
val b : t = [1; 1]

In [20]:
a ^: b


Out[20]:
- : t = [1; 0]

In [21]:
a |: b


Out[21]:
- : t = [1; 1]

In [22]:
a &: b


Out[22]:
- : t = [0; 1]

Comparison

The operands of the comparison operators must be the same width and lead to a 1 bit result


In [23]:
a <: b


Out[23]:
- : t = [1]

In [24]:
a >: b


Out[24]:
- : t = [0]

In [25]:
a ==: b


Out[25]:
- : t = [0]

Comparison requires a seperate set of operators to deal with signed/unsigned operations. Like multiplication the signed variants are appended with a +.


In [26]:
a <+ b


Out[26]:
- : t = [0]

Multiplexers

The mux function implements a multiplexer and is a very common function in HardCaml. It takes the place of if and (simple) match expressions and is also used to generate ROMs.


In [27]:
mux (consti 2 1) [gnd;vdd;gnd;gnd]


Out[27]:
- : t = [1]

The (unsigned) value of the first argument selects the appropriate value from the list given as the second argument. This corresponds to a verilog case statement.

case (index)
0: q <= a;
1: q <= b;
2: q <= c;
...
let q = mux index [a;b;c;...]

If there are more data elements in the list than can be reached by the index an exception is raised. If there are fewer then the last element is repeated - it is treated as though it is the default element in a verilog case statement.

When the list given to mux only consists of constants this effectively generates a ROM.

Selection

You can extract parts of a vector with the select function.


In [28]:
let a = const "001100"


Out[28]:
val a : t = [0; 0; 1; 1; 0; 0]

In [29]:
select a 3 2


Out[29]:
- : t = [1; 1]

The hardcaml syntax extension implements a short cut for this as it's a very common operation: signal.[ expr : expr ]


In [30]:
a.[3:2]


Out[30]:
- : t = [1; 1]

There are a number of predefined selection operations for common cases, in particular msb and lsb


In [31]:
lsb (const "00001")


Out[31]:
- : t = [1]

In [32]:
msb (const "01111")


Out[32]:
- : t = [0]

Concatentation and resizing

Vectors are concatenated with the concat function or (@:) operator.


In [33]:
a @: b


Out[33]:
- : t = [0; 0; 1; 1; 0; 0; 1; 1]

In [34]:
concat [a;b;a]


Out[34]:
- : t = [0; 0; 1; 1; 0; 0; 1; 1; 0; 0; 1; 1; 0; 0]

Concatention is msb first - the left most bit of the left most (head) element of the list passed to concat is the msb of the result.

A single vector can be concatented with itself using the repeat function.


In [35]:
repeat (const "01") 3


Out[35]:
- : t = [0; 1; 0; 1; 0; 1]

Selection and concatention are how resizing is performed. For example to extend a signed vector by 3 bits


In [36]:
let signed_extend_by_3bits v = repeat (msb v) 3 @: v


Out[36]:
val signed_extend_by_3bits : t -> t = <fun>

In [37]:
signed_extend_by_3bits (const "100")


Out[37]:
- : t = [1; 1; 1; 1; 0; 0]

Similarly we can reverse a vector


In [38]:
let rev v = concat (List.rev (bits v))


Out[38]:
val rev : t -> t = <fun>

In [39]:
rev (const "10")


Out[39]:
- : t = [0; 1]

Shifting

The shift functions are a convenience built upon selection and concatenation.


In [40]:
sra (const "100") 1


Out[40]:
- : t = [1; 1; 0]

The log_shift function uses the basic shift functions and generates a barrel shifter.


In [41]:
log_shift sll (consti 4 1) (consti 2 2)


Out[41]:
- : t = [0; 1; 0; 0]

Although the difference is perhaps not obvious here, the point is that the final argument to log_shift is a signal rather than an integer and in a hardware circuit it could shift by different amounts on differet clock cycles.

Tree

The tree function is a good example of using OCaml to create more complex functions from the simple primtives provided by HardCaml. The idea to that given a list of signals we want to apply some operator between then all. For example


In [42]:
let x = Array.init 8 (fun i -> consti 8 i) |> Array.to_list


Out[42]:
val x : t list =
  [[0; 0; 0; 0; 0; 0; 0; 0]; [0; 0; 0; 0; 0; 0; 0; 1];
   [0; 0; 0; 0; 0; 0; 1; 0]; [0; 0; 0; 0; 0; 0; 1; 1];
   [0; 0; 0; 0; 0; 1; 0; 0]; [0; 0; 0; 0; 0; 1; 0; 1];
   [0; 0; 0; 0; 0; 1; 1; 0]; [0; 0; 0; 0; 0; 1; 1; 1]]

Lets say we want to sum this list of 8 signals


In [43]:
List.fold_left (+:) (zero 8) x


Out[43]:
- : t = [0; 0; 0; 1; 1; 1; 0; 0]

Indeed there is a Comb.S function for this as well


In [44]:
reduce (+:) x


Out[44]:
- : t = [0; 0; 0; 1; 1; 1; 0; 0]

The problem with both these approaches is that the result hardware will have a critical path through 7 adders.

(a + (b + (c + (d + (e + (f + (g + h)))))))

What we really want is for the brackets to be arranged thus;

(((a + b) + (c + d)) + ((e + f) + (g + h)))

This has the same total number of adders except the critical path is now through only 3 adders.

This is exactly what the tree function does for us.


In [45]:
tree 2 (function [a] -> a | [a;b] -> a +: b | _ -> failwith "wont happen") x


Out[45]:
- : t = [0; 0; 0; 1; 1; 1; 0; 0]

There is a small complication in above definition due to the fact that tree will build trees with an arbitrary number of branches. We can simplify the above with the reduce function.


In [46]:
tree 2 (reduce (+:)) x


Out[46]:
- : t = [0; 0; 0; 1; 1; 1; 0; 0]

In [47]:
tree 4 (reduce (+:)) x


Out[47]:
- : t = [0; 0; 0; 1; 1; 1; 0; 0]

This ability becomes especially useful when mapping to FPGAs with either 4 or 6 input luts. Consider a xor tree based reduction for 6 input LUT FPGAs

tree 6 (reduce (^:)) x

It is frankly disturbing how this is able to build more optimal hardware than $50,000 commerical synthesizers...