Introduction ๐
Round two: FIGHT! Ready to clash again? If you have been following along, you already know we have covered some important ground. If not, no worries, feel free to hop back to part one to get up to speed, before joining the action. It will make everything we do here a lot easier to follow.
Anyways, this is the second installment of the series. As promised previously, we will start off by quickly diving into some fundamental Haskell concepts - nothing too heavy, just enough to make sure we are all on the same page, and then move on to the well anticipated hardware development with Clash.
Haskell fundamentals ๐
In imperative programming languages (C/C++, Java, Python, et cetera), we accomplish tasks by providing a sequence of code statements to execute. We can have a global state, we can define and re-define variables and utilized various control flow statements. Where as in purely functional programming languages we are much more limited in above mentioned aspects, and we focus on defining what things are, rather than specifying step-by-step actions for the computer to perform. For instance, instead of telling a computer how to calculate each Fibonacci number, in Haskell we would define a Fibonacci number as the sum of the two preceding ones, starting with 0 and 1.
Interactive environment
Assuming you have Haskell installed and ready to go, let's launch GHCI, the interactive environment of Haskell's compiler. To launch the interpreter, type
ghci
in command prompt.
You should be greeted with the following output (independent of the version).
Now, at this point, we could directly write and execute haskell code there, but for ease of management and future usage, let's create a dedicated file. This is not mandatory, but recommended! (Otherwise skip over to the next sub-section).
- I called it 'prog.hs', the naming/location does not really matter, just make sure to add the appropriate file extension '.hs' and remember the file path.
- Now having this file, we can compile it and execute it with a single command.
:load path_to_file/prog.hs - Having this in place, further on we only have to 'reload' the file after every modification we make.
:reload
Type system
As mentioned in the first series, Haskell has a strict static type system, which means that the compiler knowns the type of every expression during compile time, thus eliminating any rule violations. So, while it's not mandatory to write types ourselves, since the compiler can reason about our code and deduce the most generic types itself, it definitely is a good practice and will be needed later on, when we start working with Clash.
Let's try it ourselves!
:type 1
(con)1 :: Num a => a
As we can see, the compiler has deduced the type, and the format is as follows [Value] :: [Type]. Here's a brief breakdown of what it means:
- "::" This symbol is read as 'has the type' and is used to specify the type of an expression.
- "Num a => a" The first part before that arrow (Num a) is a type constraint.
Put together, it simply means that number '1' can be of any type 'a', as long as 'a' is part of type class 'Num'. We will get into type classes in the next subsection.
Classes, primitives & functions
In Haskell, classes are a way to group together types that share common set of functions or operations. This concept is analogue to interface data type in object-oriented languages. For example, already mentioned above, the 'Num' type class includes types that behave like numbers ('Int', 'Float', 'Integer', 'Double'), and it defines operations such as addition, multiplication, et cetera. Note! Even though types such as 'Int' and 'Float' are of the same type class, they are still distinct and can't be mixed directly in operations, you must convert the values to a common type.
Every great language starts with its 'alphabet', Haskell also has it's primitives, which generally are very similar to those proposed in other languages. To name a few, 'Bool', 'Char', 'Int', et cetera. While primitive types tend to cover many common needs, we can end up in a situation where defining our own data types is necessary for modeling complex or domain specific concepts. We can define a custom data type using the 'data' keyword.
data CarBrand = Ferrari | Bugatti | Toyota | Ford
In such a definition, 'CarBrand' is the name of the newly introduced data type (on type level), where as 'Ferrari', 'Bugatti', 'Toyota', and 'Ford' are the constructors (on value level, each of them represent a different value of 'CarBrand').
Haskell proposes quite a flexible approach when it comes to functions. Functions can be called either by infix or prefix statement.
Let's define our own function called 'successor' that returns the successor value of a given number.
successor :: Num a => a -> a
successor val = val + 1
successor 5
(con)6
Don't be confused! As visible, haskell separates it's function arguments by white space rather than having comma separated and enclosed with curly braces.
Let's turn our attention to pattern matching. that we are running a car rental dealership (we will reuse the already introduced custom type 'CarBrand'). We are grateful to own two different supercars, whose rental price greatly varies from the standard rentable cars. A solution we could come up with would be the following:
data CarBrand = Ferrari | Bugatti | Toyota | Ford
rentCost :: CarBrand -> Int
rentCost Ferrari = 150
rentCost Bugatti = 800
rentCost anyOtherCar = 30
The function parameters are pattern matched in top-to-down manner, it simply means that provided car brand being 'Ferrari' would return the cost as 150, 'Bugatti' - 180, where as all of the remaining brands, 'Toyota', 'Ford', would yield the cost 30. While this works, we might run into a problem whose solution requires a different approach - not the regular pattern matching. In such an occasion we would turn towards case expressions or guards (examples attached below, respectively).
rentCost :: CarBrand -> Int
rentCost car = case car of
Ferrari -> 150
Bugatti -> 800
_ -> 30
data CarBrand = Ferrari | Bugatti | Toyota | Ford deriving (Eq)
rentCost :: CarBrand -> Int
rentCost car
| car == Ferrari = 150
| car == Bugatti = 800
| otherwise = 30
(The last code block that uses guard approach introduces 'deriving (Eq)'. Haskell doesn't know how to evaluate the equivalence of the values for our custom data type, therefore either we must implement such equivalence class instance ourselves or we can derive from 'Eq', which in simple terms introduces the comparison logic for us).
Recursion & Higher-order functions
Recursion and higher-order functions are crucial strategies to understand in order to solve problems in functional programming approach. The term 'recursion' when applied to functions merely refers to the idea that the function has the ability to invoke itself various times, breaking down problems into smaller sub-problems, and terminate when a base condition is met.
Higher-order function is a function that takes other functions as arguments or returns a function as result.
You might ask, what does recursion have to do with higher order functions? Well, these two concepts often work together, with higher-order functions providing a 'framework' (abstraction) for recursive solutions. Let's put these ideas into action with some concrete examples!
Summing elements in a list (recursive approach).
sumList :: Num a => [a] -> a
sumList [] = 0
sumList (x:xs) = x + sumList xs
sumList [1, 2, 3] =
(con)1 + sumList [2, 3] =
(con)1 + (2 + sumList [3]) =
(con)1 + (2 + (3 + sumList [])) =
(con)1 + (2 + (3 + 0)) =
(con)1 + (2 + 3) = 1 + 5 =
(con)6
Here's a short breakdown of what is happening:
- "Num a => [a] -> a" indicates that the function takes a list of numbers, denoted by [a], and returns a number.
- "sumList [] = 0" defines the base case, which will act as recursion terminator upon being pattern matched to.
- "(x:xs)" pattern matches x to be the list head element and xs to be the remaining list.
- "sumList (x:xs) = x + sumList xs" recursively calculates the sum of the numbers in a list by breaking the problem into sub-problems (first element in the list + the first element of the remaining list, and so on...)
Summing elements in a list (higher-order function approach).
An alternative, often more elegant approach would be to use built in higher-order functions, for example, foldl.
Foldl takes the second argument and the first item of the list and applies the function to them, then feeds the function with this result and the second argument and so on.
sumListHigherOrder :: Num a => [a] -> a
sumListHigherOrder x = foldl (+) 0 x
To have a clearer understanding of 'foldl', let's see the arguments and their types it expects.
:type foldl
(con)foldl :: Foldable t => (b -> a -> b) -> b -> t a -> b
Essentially, 'foldl' works on any 'Foldable' structure, such as lists, trees, et cetera. The first argument '(b -> a -> b)' is a binary function (in our case an accumulator function), that takes an accumulated value 'b' (initially 0), an element from a Foldable typeclass (in our case an element from a list), and returns a new accumulated value 'b' (performed by the '+' function). Argument 'b' is the initial accumulator value, 't a' represents a foldable element (list) of type 'a'.
Why bother with 'foldl' or higher-order functions in particular?
Consider them your hidden weapon in functional programming, akin to upgrading from a sedan to a sleek sports car. They make your code more concise, and in addition can introduce performance optimizations when working with complex or large data.
But there's more to the story! Here's the real kicker, Clash struggles with recursion when translating Haskell code into hardware description languages, but we are allowed to use higher-order functions that capture our recursive function patterns.
We'll dive deeper into that in the following section, but as a conclusion to take home, just know that using higher-order functions isn't just about writing elegant Haskell.
Introducing Clash ๐
You've trained, you've prepared, now it's time to Clash! The main event starts now. Anyways, after all of the pre-requisites we have finally arrived at the core of the clash series - the part where we actually start working on hardware design. Let's not beat around the bush, and jump straight into some action!
Interactive environment
In a similar manner, let's proceed to launch clashi - the interactive environment of Clash compiler.
clashi
We should be greeted with the following message (ignore the warning).
It might take a moment for clashi to load. Be patient, the fun's almost here.
Combinational design
We will start off simple by creating a combinational circuit design. I've included a definition of combinational logic for those of you who might not be too familiar.
Combinational logic is a type of digital logic that is implemented by Boolean circuits, where the output is a pure function of the present input only. This is in contrast to sequential logic, in which the output depends not only on the present input but also on the history of the input.
If this still puzzles your mind, don't fret, a practical demonstration will shed some light.
Let's build our first digital circuit together! We will start off with a primitive yet fundamental building block - the 'AND' gate. It produces a 1 only when both of its inputs are 1. Create a file named 'ANDGate.hs' with the following initial code:
module ANDGate where
import Clash.Prelude
These preliminaries purely help preventing naming conflicts with other modules or the main program and imports the main clash library - Prelude.
We can define our 'AND' gate in the following way:
module ANDGate where
import Clash.Prelude
andBit :: Bit -> Bit -> Bit
andBit x y = x .&. y
We observe something new. The function 'andBit' takes two 'Bit' values as input and returns a resulting 'Bit' value (0 or 1). It uses the bitwise AND operator '.&.' to perform the operation on both input bits.
Load your file in the same manner as before and test the functionality.
:load path_to_file/prog.hs
andBit 1 0
(con)0
andBit 1 1
(con)1
That's it, you have just created your first digital circuit. Let's bring it to life by visualizing and analyzing its inner workings in the following subsection.
Visualizing circuits
Remember that AMD Xilinx tool we installed in the first series? Now it's time to put it to work! But let's not get ahead of ourselves, we have couples of things to do first:
- We need to tell Clash which function should be compiled down to HDL
- We need to compile the Clash code to our preferred HDL (either Verilog or VHDL)
To tackle the first endeavour let's modify 'ANDGate.hs' file.
module ANDGate where
import Clash.Prelude
andBit :: Bit -> Bit -> Bit
andBit x y = x .&. y
topEntity :: Bit -> Bit -> Bit
topEntity = andBit
The new addition is visible in line's 7 and 8. We have introduced a new function called 'topEntity', which serves as the entry point for our circuit.
When Clash is compiling our code to hardware description language, it needs to be aware of which function represents the top-level circuit (in our case 'andBit') - the circuit that connects to the 'external world' (by means of IO, et cetera). In essence, 'topEntity' informs Clash: "This is the function you should focus on when generating the hardware description language code".
To tackle the second step, we have to exit the 'clashi' environment (or open up a new command prompt window). Having done that, let's execute the following command:
clash --verilog path_to_file/ANDGate.hs
Give it a moment to complete. Once done, you will find a new folder named 'verilog/ANDGate' containing file 'topEntity.v'. This file contains our clash code in the format of hardware description. You can briefly examine it and try to identify the corresponding statements that implement the 'andBit' function, but we will not delve into specifics, however keep this file handy, as we will need it shortly.
/* AUTOMATICALLY GENERATED VERILOG-2001 SOURCE CODE.
** GENERATED BY CLASH 1.8.1. DO NOT MODIFY.
*/
`default_nettype none
`timescale 100fs/100fs
module topEntity
( // Inputs
input wire c$arg
, input wire c$arg_0
// Outputs
, output wire result
);
assign result = c$arg & c$arg_0;
endmodule
Great, now let's open 'AMD Vivado' and load our design. Use the image below as a guide for the next steps. We will be creating a new project, adding our Verilog file (topEntity.v), and setting it as the top-level module. These steps will configure 'Vivado' to let us work with our AND gate design.
Step 1: Create a new project
Step 2: Proceed
Step 3: Give your project a recognizable name, the default location is fine
Step 4: Select the type to be RTL Project
Step 5: From the dropdown select 'Add Files...'
Step 6: Locate the 'topEntity.v' file
Step 7: Project summary, proceed
Step 8: Select the FPGA development board. This tutorial doesn't assume you have a physical board, we will be only simulating our designs. So, in this case the selected board choice does not really matter, but the board we opt for is 'Kria KV260 Vision AI Starter Kit SOM'.
Step 9: Click on 'Open Elaborated Design' and wait for the process to finish
Step 10: New 'Schematic' window appears that contains our design in RTL (Register-transfer level)
Step 11: (Optional) Run Synthesis
Step 12: (Optional) Press 'OK'. After finished, choose 'Schematic' under 'Open Synthesized Design' dropdown
Step 13: New 'Schematic' window appears that contains our synthesized design (Note the difference, and remember, FPGA's don't contain AND gates!)
Analyzing results
Figure 1 shows the Register Transfer Level (RTL) schematic of our design. RTL schematic is a high-level abstraction of a circuit that models the flow between various components. In our case, it's made up of two input signals, whose names clash had generated based on the context it could perceive. These inputs are fed into an 'AND' gate, and the output of the AND gate represents the result of logical operation by our 'andBit' function.
While the RTL schematic depicts an AND gate for clarity and simplicity purposes, FPGAs actually implement logic functions using Look-up Tables (LUTs) instead (read more about it in the part one of the series). Figure 2 portrays that, it shows the corresponding LUT, named 'result_OBUF_instr_i_1', which is configured to perform the AND operation.
Sequential design
While combinatorial designs are great for certain applications, the world of digital circuits quite often demands more dynamic and sophisticated solutions. On this note, I welcome you to sequential designs. Sequential designs introduce circuit elements like registers (memory units) and a clock signal (periodic signal that allows us to synchronize the circuit). With these elements established we empower our circuits to keep track of past states and react to changes over time. This in turn opens up a vast array of possibilities - from creating counters and timers to implementing complex state machines.
To better understand sequential design development, let's create a counter that counts up or down based on the provided input control signal. We will employ a single register for keeping track of the current counter value, and a clock signal to ensure precise timing. Let's create a file named 'Counter.hs' and populate it with the following contents.
module Counter where
import Clash.Prelude
type Val = Unsigned 3
incrementer :: Val -> Val
incrementer v = v + 1
decrementer :: Val -> Val
decrementer v = v - 1
counter :: (HiddenClockResetEnable dom) => Signal dom Bool -> Signal dom Val
counter incr = state
where
state = register 0 (mux incr (incrementer <$> state) (decrementer <$> state))
Don't worry if this feels a bit overwhelming at first, we will break down the code step by step.
At line 4, we start off by defining a type alias 'Val' that simply represents an unsigned 3-bit integer, meaning that it can hold values from 0 to 2^3-1. It mainly serves its purpose as a pseudonym making our code simpler and easier to read.
At line 6 through 10, we define a simple helper functions that increment or decrement our value.
At line 12, we have arrived at the type signature of our counter function. This is where things get exciting.
- (HiddenClockResetEnable dom): This is a type constraint that ensures that the function will operate in a special domain where the clock, reset and enable signals are automatically handled for us. Which means that we don't have to worry about manually managing these signals.
- Signal dom Bool -> Signal dom Val: This indicates that our function accepts a boolean signal as an input and returns a signal of type 'Val' while operating under the special domain.
At line 13 through 15, we encounter the body of our counter function. The 'state' is stored in a register, just as we had planned. Let's explore the type signature of a register element.
:type register
(con)register
(con) :: (HiddenClockResetEnable dom, NFDataX a) =>
(con) a -> Signal dom a -> Signal dom a
As visible, a register expects an initial value and a signal as input of the same type. Navigating by this guide, we initialize our register with a 0.
Furthermore in our case, the input signal to the register is determined by a multiplexer 'mux' that selects the first part '(incrementer <$> state)' given that boolean signal 'incr' is True, otherwise the second part '(decrementer <$> state)' is selected.
However, the current 'state' is on signal level, where as our 'incrementer' and 'decrementer' helper functions work with raw values, thus we we employ fmap (<$>), which applies a function to the values inside the signal, to correctly update it.
Great, let's simulate our implementation. Add the following line of code at the bottom of the file.
simulateCounter = simulate @System counter [True, True, True, False, False, False]
With that in place, load the file and run the simulation.
:load Counter.hs
simulateCounter
(con)[0,1,2,3,2,1,0,*** Exception: X: finite list
(con)CallStack (from HasCallStack):
(con)...
As visible, we can observe that our sequential design has the expected behavour. The counter was incremented three times and then decremented three times to end up at result 0.
Please not that the initial 0 in the output is a known artifact and can be ignored. It can be thought of as some kind of noise being spat out just as the 'system starts up'.
Visualization & analysis of the counter
To visualize our sequential circuit, we will follow a process similar to that of our combinatorial design. First and foremost, let's add the 'topEntity' definition. The final code looks as following:
module Counter where
import Clash.Prelude
type Val = Unsigned 3
incrementer :: Val -> Val
incrementer v = v + 1
decrementer :: Val -> Val
decrementer v = v - 1
counter :: (HiddenClockResetEnable dom) => Signal dom Bool -> Signal dom Val
counter incr = state
where
state = register 0 (mux incr (incrementer <$> state) (decrementer <$> state))
simulateCounter = simulate @System counter [True, True, True, False, False, False]
topEntity :: Clock System -> Reset System -> Signal System Bool -> Signal System Val
topEntity clk rst incr = withClockResetEnable clk rst enableGen counter incr
With that in place, in a separate command prompt lets compile our Clash code into Verilog.
clash --verilog path_to_file/Counter.hs
A new folder is created 'verilog/Counter' containing 'topEntity.v' that contains the translated code.
/* AUTOMATICALLY GENERATED VERILOG-2001 SOURCE CODE.
** GENERATED BY CLASH 1.8.1. DO NOT MODIFY.
*/
`default_nettype none
`timescale 100fs/100fs
module topEntity
( // Inputs
input wire clk // clock
, input wire rst // reset
, input wire incr
// Outputs
, output wire [2:0] state
);
// Counter.hs:12:1-76
reg [2:0] state_1 = 3'd0;
// Counter.hs:12:1-76
wire [2:0] t;
// Counter.hs:12:1-76
wire [2:0] f1;
wire [2:0] result;
// register begin
always @(posedge clk or posedge rst) begin : state_1_register
if ( rst) begin
state_1 <= 3'd0;
end else begin
state_1 <= result;
end
end
// register end
assign t = state_1 + 3'd1;
assign f1 = state_1 - 3'd1;
assign result = incr ? t : f1;
assign state = state_1;
endmodule
To visualize it we have to return back to 'AMD Vivado'. Luckily, we don't have to go through all of the steps again, instead we can simply replace the previous 'topEntity.v' file in our project with the new one.
Step 1: Remove the existing 'topEntity.v' file from sources
Step 2: Choose option to add new sources
Step 3: Choose 'Add Files' option
Step 4: Select the 'topEntity.v' source file for the counter circuit design
Step 5: Click on 'Open Elaborated Design' and wait for the process to finish. New 'Schematic' window appears that contains our design in RTL (Register-transfer level)In Figure 3, you can see the corresponding design of our developed circuit. It contains three input signals - reset, clock and increment, a multiplexer and a register.
Wrapping up ๐
Congratulations! You have successfully completed the second and final part of our Clash journey. We have delved into the fundamentals of Clash, explored combinatorial and sequential designs, and even created our own counter circuit.
However, the journey does not necessarily end here... There's a lot more to explore both in semantics and features of Clash and hardware development itself.
If you have purchased a physical FPGA board (which tend to be on the pricier end), you can generate a bitstream (a configuration file that tells the FPGA how to configure its internal circuitry) and load it onto the board. This is where you would truly see your designs in action.
Imaging building smart IoT devices that control your household, custom hardware accelerators, or even embedded systems that power anything from cards to industrial machines. The possibilities are endless!