Lucid - Digital Design Made Clear

Lucid is a new HDL (Hardware Description Language) that is designed to make it easier to work with FPGAs. This is realized by not relying on "templates" for flip-flops and finite state machines, reducing the amount of code that has to be written, and real time error checking via the Mojo IDE.

Lucid is based on Verilog but pulls some syntax from C++/Java.

Books

Check out the official Lucid beginner's book written by Justin Rajewski, Lucid's creator. Learning FPGAs: Digital Design for Beginners with Mojo and Lucid HDL

Getting Started with Lucid

To start working with Lucid you need the Mojo IDE. You will also need to install Xilinx's tool called ISE to be able to build your projects. ISE can take a while to download so start it early. Checkout this tutorial for more information on installing ISE.

To install the Mojo IDE, download the latest version from the Mojo IDE page.

Once you have the Mojo IDE installed you can start the first tutorial while you download ISE. Just note that you will have to wait for ISE to be installed before you can build your project and program the FPGA.

Reference Guide

Modules

A typical Lucid file consists of a single module declaration. A module declaration consists of the module keyword, the module's name, an optional parameter list, a port list, and the module's body.

module my_module #(SIZE = 4)(
    input clk,
    input rst,
    input value_in,
    output value_out
  ){
 ... body ... 
}

In this example, a module with a name of my_module is declared.

Parameter List

This module uses the optional parameter list to declare a parameter named SIZE with a default value of 4. The parameter list is designated by #( ... parameters ... ). More than one parameter can be specified by separating them by commas.

The default value of a parameter is optional. If a default value is not specified, the value must be specified later when the module is used. However, if the default value is specified it can still be overloaded later when the module is used. It is best practice to give a reasonable default value when possible.

Since parameters can be overloaded, unless any value can be used, it is often helpful to add a constraint. The constraint will be evaluated whenever your module is used and throw an error if it's not met. The syntax for a constraint is parameter : constraint. For example, we likely would want to constrain SIZE, from the previous example, to be greater than zero.

module my_module #(
    SIZE = 4 : SIZE > 0
  )(
    input clk,
    input rst,
    input value_in,
    output value_out
  ){
 ... body ... 
}

Port List

Each signal that is passed in or out of a module is declared in it's port list. A signal can have one of three types: input, output, or inout. Any of these types can be an array.

Inputs are read only inside the module. Their value is supplied externally.

Outputs are write only inside the module. They are used to pass values out.

Inouts are special since they can be read and written, but not directly. They are not typically used except at the top level for signals that can be tri-stated (to talk to an I2C device, for example). To read from an inout named my_inout you can use the element my_inout.read. To write, you write to the element my_inout.write. However, for the value of my_inout.write to be driven, you need to set my_inout.en to 1. When it is 0, my_inout is tri-stated.

Body

The body of a Lucid file generally consists of a series of variable declarations, module instantiations, and always blocks. These will all be covered in more detail in later sections.

Numbers

There are a few ways to specify a number in Lucid.

The first way is to just type the number as you would anywhere else. For example, 9.

Sometimes it's easier to specify a number with a different radix than 10 (the implied default). Lucid supports three different radix, 10 (decimal), 16 (hexadecimal), and 2 (binary). To specify the radix, prepend d, h, or b to specify decimal, hexadecimal, or binary respectively. For example, hFF has the decimal value 255 and b100 has the decimal value 4. If you don’t append a radix indicator, decimal is assumed.

It is important to remember that all numbers will be represented as bits in your circuit. When you specify a number this way, the width of the number will be the minimum number of bits required to represent that value for decimal. For binary, it is simply the number of digits and for hexadecimal it is four times the number of digits. For example, the value 7 will be three bits wide (111), b1010 will be four bits wide, and hACB will be 12 bits wide.

Sometimes you need more control over the exact width of a number. In those cases you can specify the number of bits by prepending the number of bits and radix to the value. For example 4d2 will be the value 2 but using 4 bits instead of the minimum 2 (binary value 0010 instead of 10). You must specify the radix when specifying the width to separate the width from the value.

If you specify a width smaller than the minimum number of bits required, the number will drop the most significant bits. When this happens you will get a warning.

Strings

Strings are simply a series of characters enclosed between quotes.

"This is a string!"

Each letter in a string is the 8 bit representation of that letter. If a string consists of more than one letter, the string becomes two dimensional. The first dimension specifies which letter and the second is the bits of that letter. This allows for easy indexing of specific letters.

Similar to numbers, the right most letter is the first index. Below is an example of a specific letter being indexed.

const MY_STRING = "Hello";
...
if (MY_STRING[3] == "e") { // this is true
  ...
}

Variable Types

There are a handful of variable types that are used in Lucid. They all serve a different purpose and are intended to make your intent clear.

Signals (sig)

The variable type sig, which is short for signal, is used to temporarily hold the value of some operation. Signal names must start with a lowercase letter and consist only of letters (upper and lower), numbers, and underscores.

Signals can be arrays.

Example sig declaration.

sig sum[16];

D-type Flip Flops (dff)

Flip flops are the basic memory element used in FPGA designs. They are used to hold a constant value between clock cycles (see here for more info). Their name must start with a lowercase letter and consist only of letters (upper and lower), numbers, and underscores.

Unlike other HDLs, flip flops can be instantiated directly in Lucid by using the type dff.

Dff’s require that a clock signal be specified at the time of instantiation.

They have three inputs, clk, rst, and d. The rst and clk inputs can currently only be specified when the dff is being instantiated. The rst input is optional and does not need to be used if you don’t need a reset. When used, the reset is active high. The d input servers as the input value to the flip flop.

Dff’s have only one output, q. This output reflects the value of d from the previous rising edge of clk. If rst has been activated, the value of q will be set to the value of the parameter INIT.

Dff’s have one parameter, INIT. This is an optional parameter and it specifies the value the flip flop will initialize to at power up and reset. If INIT is not provided, a default value of 0 is used.

Dff’s can be arrays.

Example dff instantiation.

dff ctr[8](.clk(clk), .rst(rst));

Finite State Machines (fsm)

Lucid provides the type fsm for create finite state machines. This type is there to make it easy to declare and maintain the states used and prevent mistakes when assigning states. Their name must start with a lowercase letter and consist only of letters (upper and lower), numbers, and underscores.

An fsm is similar to a dff in that it has the same three inputs, clk, rst, d, the same output, q, and the same parameter, INIT. They behave the same way as a dff with rst being optional and clk and rst needing to be specified at declaration.

What makes an fsm different is that it has associated states. A list of states must be provided when the fsm is declared. State names must start with a capital letter and can only consist of capital letters, numbers, and underscores.

If INIT is not specified. The first state is used.

If multiple fsms are used, they can have states that have the same name as the states are scoped to a single fsm.

Fsms can be arrays.

Example fsm declaration.

fsm state(.clk(clk), #INIT(RUN)) = {IDLE, START, RUN, STOP};

Example fsm state assignment

state.d = state.START;

Variables (var)

Variables are used only to make it easier to write code. They are not present directly in the synthesized circuit. The only current use case for them is as the loop index of a for loop. Their name must start with a lowercase letter and consist only of letters (upper and lower), numbers, and underscores.

Variables can be arrays.

Example var declaration.

var index;

Constants (const)

Constants are used when it is more convenient, or clear, to replace a number with a name. For example, if your module has many places a number shows up that may need to be changed later, it is often easier to use a constant so you only have to change the value in one place.

Like the states of an fsm, constants must start with an uppercase letter and consist only of uppercase letters, numbers, and underscores. Their value must also be specified at the time of declaration.

Constants cannot be arrays.

Example const declaration.

const MAX_VALUE = 100;

Arrays

Arrays in Lucid are designed to be as flexible as possible. To create a multi-bit signal simply append [x] to the end of the signal name, where x is the number of bits.

Arrays can also be multi-dimensional. For example, to create a 3D array, the following could be used.

sig my_array[2][4][8];

This creates a 2x4x8 array of bits, or a 2x4 array of bytes (8 bits) depending how you think of it.

To access elements of an array simply provide the indexes.

my_array[1][3][7];

These indexes point to the last element in the array since they are zero-indexed (index 0 is the first value). It is also possible to select multiple bits.

my_array[1][3];

This will select the last 8 bits of the array.

To select only a partial number of bits, you can use bit selectors.

Bit Selectors

Bit selectors are used to select a sub-array. They can be used as the last index into an array.

The most basic selector is the explicit selector. The explicit selector has the format [top:bottom] where top is the highest index and bottom is the lowest index, inclusive, to be selected.

There are two other selectors: up-from and down-from. These are selectors that have the format [start+:bits] and [start-:bits], respectively. Here, start specifies the starting bit and bits specifies how many bits to include. With the up-from selector, the bits from start and bits above it are selected. With the down-from selector, the bits from start and bits below it are selected.

The following examples select the middle 4 bits (5,4,3,2) of our previous 8 bits.

my_array[1][3][5:2]; // explicit selector
my_array[1][3][2+:4]; // up-from selector
my_array[1][3][5-:4]; // down-from selector

These are all equivalent in this example. However, it is important to note that the explicit selector cannot be used with signals as an index. To use a signal to select multiple bits you must use the up-from or down-from selectors with bits set to a constant. This is to ensure that the resulting selection has a constant width, as a varying width array is impossible to implement in hardware.

Array Builder

Sometimes it can be convenient to build arrays out of constants, or other values. This is when the array builder comes in handy. The array builder has the syntax { val, val, val, ..., val }, where each val can be any value as long as all vals have the same width and dimensions.

Multiple array builder's can be nested to create multidimensional arrays.

Array Concatenation

You can concatenate two arrays together by using the syntax c{ arr1, arr2, arr3, ..., arrN }. Each element in the concatenation must have the same dimensions but not necessarily the same initial width. For example the following is allowed.

sig arr1[4][8][5];
sig arr2[5][8][5];
sig result[9][8][5];
...
result = c{arr1, arr2};

In this example arr2 is stuck at the lower end of the array. That means result[4:0] corresponds to arr2 and result[8:5] corresponds to arr1.

Array Duplication

Arrays can be duplicated into larger arrays with the syntax M x{ arr }, where M is the number of times to duplicate the array. The space between the M and x{ is optional and typically left out when M is a number.

This operation is the same as using the concatenation operator with the same element specified M times. This just allows for a more compact notation. See the following for an example.

sig arr[8];
...
3x{arr} == c{arr, arr, arr};

Assigning Arrays

If two arrays have the same width and dimensions, they can simply be assigned to one another.

sig x[8][4];
sig y[8][4];
...
x = y; // each element is assigned

Array Width

To get the width of an array you can use the WIDTH attribute. If the array is a single dimension then WIDTH will simply be a number. However, if the array is multi-dimensional then WIDTH will be a 2D array with each element corresponding to its respective dimension.

For example, if we declare a signal y as follows, then y.WIDTH[0] is equal to 8 and y.WIDTH[1] is equal to 4.

sig y[8][4];

Instantiations

When instantiation a module, dff, or fsm, there are two ways to specify parameters and certain inputs. They are direct connections, and connection blocks.

Direct Connections

Direct connections are local to a single instance and the list of connections and parameters comes directly after the item to be instantiated. The following example is for a counter module.

counter myCounter(#WIDTH(8), .clk(clk2), .rst(rst2));

In this example, myCounter is a module with type counter. The parameter WIDTH is set to 8, the input clk is connected to clk2, and the input rst is connected to rst2. The ordering of the list doesn’t matter. Parameters are designated by #NAME, while inputs are .NAME.

Note that in general you do not have to connect any inputs and they can be assigned later on in an always block. This is simply a way to easily connect basic inputs, like clock and reset.

If you instantiate an array of something, the inputs are treated as being connected to each individual element. For example, if we were to instantiate an array of counters as follows, then clk and rst will be connected to each counter's clk and rst inputs.

counter myCounter[8](#WIDTH(8), .clk(clk), .rst(rst));

If we didn't connect them here, but rather later in an always block (covered in the next section), the inputs would be joined into an array.

counter myCounter[8](#WIDTH(8));

always {
    myCounter.clk = 8x{clk};
    myCounter.rst = 8x{rst};
}

You can see here we had to expand clk and rst into 8 bit arrays to match those of the signals myCounter.clk and myCounter.rst.

Connection Blocks

The second way to specify connections and parameters is through connection blocks. These are simply a shorthand way to specify the same connection for many modules, dffs, or fsms at the same time. Here is an example.

.clk(clk), rst(rst) {
  dff myDff1;
  dff myDff2;
  counter myCounter[8](#WIDTH(7));
}

This is equivalent to adding the connections to each instance individually, but helps reduce redundancy and clean up the code. You can also nest these blocks.

.clk(clk) {
  rst(rst) {
    dff myDff1;
  }
  dff myDff2;
}

Here myDff1 is connected to rst and clk, while myDff2 only has clk connected. This is useful for specifying dffs that have resets and others that don’t need them. It is best practice to only use resets when you need them.

Always Block

An always block is where the bulk of your code resides. This block is used to group a section of combinational logic together. The syntax for an always block is as follows.

always block

Inside the block you can describe combinational logic by using various control statements and operators. The most important characteristic of the always block is the ability to assign signals values.

The block can consist of a single statement, or a set of statements grouped by { }.

Assignments

An always block is interpreted from top to bottom with statements that happen lower having priority over previous statements. An always block runs instantaneously, meaning if you assign a signal two different values, that signal will only ever show the last value assigned to everything outside the always block. See the following example.

module example (
    output led
  ) {

  always {
    led = 0;
    led = 1;
  }
}

In this example, if the output led was connected to an actual LED and we probed it with an oscilloscope, the signal would be consistently 1. It would never have the value 0, not even for an undetectable amount of time.

A signal can only be assigned in one always block. For example, the following is illegal.

module example (
    output led
  ){

  always led = 0;
  always led = 1; // can't assign led in different blocks
}

This is because this would result in multiple drivers and the value of led would be ambiguous.

Sigs in Always Blocks

In an always block, sig types can be read and written. They must however, always be written before being read (otherwise they have no value). The value of the sig is whatever value was last written to it excluding any statements that come after the read. Take a look at the following example.

  sig mySig;

  always {
    mySig = 0;
    led1 = mySig;
    mySig = 1;
    led2 = mySig;
  }

In this example, led1 will be connected to 0, while led2 will be connected to 1. However, if you read mySig outside of the always block, it will always have the value 1 (in this case). If the value of a signal is used outside the always block, then it must be assigned a value at all times. The easiest way to ensure this is to assign a default value at the beginning of the always block.

  sig mySig;

  always {
    if (button)
      mySig = 1;
  }

  always {
    led = mySig; // illegal to use mySig here
  }

In the above example, it is illegal to use mySig in the second always block because if button is 0, it won't have a value. However, either of the following are allowed.

  sig mySig;

  always {
    mySig = 0;   // default value
    if (button)
      mySig = 1;
  }

  always {
    led = mySig; // mySig always has a known value here = ok to use
  }
  sig mySig;

  always {
    led = 0;       // default value

    if (button) {
      mySig = 1;
      led = mySig; // mySig always has a known value here = ok to use
    }
  }

If Statements

If statements are used to conditionally do something. They have the following syntax.

if (expression) block else block

The block can consist of a single statement, or a set of statements grouped by { }.

If the expression evaluates to anything other than 0, the statements in the block are considered. If the expression is equal to 0, the statements are ignored.

The else clause is optional. If it is included, the statements in its block are considered only when the first block is ignored. The following is an example.

module example (
    input button,
    output led
  ) {

  always {
    if (button) {
      led = 1;
    } else {
      led = 0;
    }
  }
}

The signal, led, will only be 1 when the button signal is 1. Otherwise, it will be 0.

The same behavior could have been achieved with the following.

module example (
    input button,
    output led
  ) {

  always {
    led = 0;
    if (button) 
      led = 1;
  }
}

In this example, the value of led defaults to 0. However, if button is 1, led is set to 1.

It is important that a signal that is assigned in an if statement is assigned a value in all cases. For example, if the led = 0; line was taken out of the above example, when button is 0, led would never be assigned. This is illegal. To avoid this, either cover all cases with an else clause or assign a value before the if statement.

Case Statements

Case statements are used to select a block of statements out of a group based on the value of some expression. They are equivalent to a series of if else statements but are simply a cleaner way to express the selection.

The syntax for a case statement is as follows.

case (expr) {
  const: statements
  const: statements
  const: statements
  const: statements
  default: statements
}

Here, statements can be a single statement or a series of statements.

A group of statements are evaluated if expr is equal to const. The default selector is a special case and is matched only if no other cases match.

Below is an example use of a case statement.

module decoder(
    input in[2],
    output out[4]
  ) {

  always{
    case (in) {
      0: out = 4b0001;
      1: out = 4b0010;
      2: out = 4b0100;
      3: out = 4b1000;
      default: out = 4bxxxx;
    }
  }
}

Similarly to if statements, it is important to remember that a value needs to be assigned under all conditions. This can be achieved by including a default selector or by writing a value before the case statement. In the above example, since we never expect the default selector to be used, we set out to x's which means we don’t care what value they get. This is helpful for the synthesizer since it can choose to assign it whatever is most optimal.

For Statements

For statements are used as shorthand when something needs to be repeated.

A for loop has the following syntax.

for (var = const; var op const; expr) block

Again, here, block can be a single statement or a series of statements grouped by { }.

var is a variable.

The first const is the variable's initial value.

op can be any comparison operator, >, <, ==, !=, >=, <=.

The second const is used to compare against the variable.

expr is the loop increment.

The overall picture is the first statement sets the initial value of the variable, the second statement sets the condition to continue the loop, and the last statement specifies how the variable changes each iteration.

It is important to remember that a for loop can always be replaced by simply writing out each iteration of the loop. In other words, the loop needs to be unravelable. That means that at synthesis time the tools need to be able to figure out exactly how many times the loop will execute and what the value of var will be for each iteration. If you can't manually duplicate block and replace var with each value then the for loop can't be realized in hardware.

This is easily guaranteed by requiring you to use constants for the initialization, comparison, and increment statements.

The reason for this requirement is because this will eventually be turned into hardware by duplicating the circuit over and over. If the tools can't determine how many times to duplicate the circuit, the circuit can't be produced.

Below is an example of a for loop.

sig values[8][8];
var i;

always {
  for (i = 0; i < 8; i++) {
    values[i] = i;
  }
}

This loop could also be rewritten as the following

sig values[8][8];

always {
  values[0] = 0;
  values[1] = 1;
  values[2] = 2;
  values[3] = 3;
  values[4] = 4;
  values[5] = 5;
  values[6] = 6;
  values[7] = 7;
}

However, as you can tell, the for loop makes it a lot more compact.

Expressions

An expression is something that can be evaluated to a single value. They can include signals and their values may change overtime (as your circuit operates). This can be something like x*y, where it has a single value that can be computed.

The following are operators that can be used in expressions.

Negation

Negation is performed by using the - operator. If this operator comes before an expression, the value of the expression is negated (turned negative).

Bitwise Invert

The ~ operator inverts the value of each bit of an expression. For example ~4b1001 is equivalent to 4b0110.

Logical Invert

The ! operator performs a logical inversion. That means if the expression following it is zero, it becomes 1, and if it is non-zero it becomes 0. This is usually used in conjunction with comparison operators. For example, !(x == 6).

Multiplication

The * operator is used to multiply two expressions. For example, 4d4 * 4d4 is equal to 8d16. The width of a multiplication is typically larger than either operand (except when multiplying by a 1 bit number). Multiplication can be expensive to implement in hardware so this operator should be used sparingly when possible.

Addition and Subtraction

The operators + and - are used for addition and subtraction. For example, 4d8 + 4d4 is equal to 5d12.

Bitwise And/Or/Nand/Nor/Xor/Xnor

The operators & and | perform bitwise and and or operations between two expressions. For example 4b1100 & 4b0101 is equal to 4b0100 and 4b1100 | 4b0101 is equal to 4b1101. There is also ^ for xor. Each of these can have ~ as a prefix to make them the inverted version. For example ~& is nand and ~^ is xnor.

Bit Compression

The &, |, and ^ operators, and their inverted versions, can also be used for bit compression when used as a predicate to an expression instead of between two expressions. The & operator will and each bit in the expression following it which will be 1 if all the bits are 1. Otherwise it will be 0. The | operator will or each bit and will be 1 if any bit is 1. Otherwise it will be 0. For example, &4b1001 is equal to 1b0 and |4b1001 is equal to 1b1. The ^ operator will tell you if there is an odd number of 1s in the value. For example ^4b1011 is equal to 1b1 and ^4b1001 is equal to 1b0.

Logical Comparisons

There are six different logical comparison operators. Each one sits between two expressions and performs some comparison between the two.

Less than, <, checks if the left expression is less than the right expression.

Greater than, >, checks if the left expression is greater than the right expression.

Equal to, ==, checks if both expressions have the same value.

Not equal to, !=, checks if the expressions have a different value.

Greater than or equal to, >=, checks if the right expression is greater or equal to the left expression.

Less than or equal to, <=, checks if the right expression is less than or equal to the left.

Logical And/Or

The operators && and || are used to compare boolean expressions. An expression is considered to be true if it has a nonzero value and false otherwise. && checks to see if both expressions are true and has the value 1 if they are and 0 otherwise. || checks to see if either expression is true and has the value 1 if either is and 0 otherwise. These are most commonly used to combine multiple logical comparisons.

Shifts

The operators >>, <<, >>>, and <<< are used to shift the bits of a value. The syntax is below.

value << bits

Here value is the value to shift and bits is the number of bits to shift it. << is the shift right operator, >> is shift left, and the <<< and >>> operators are the signed versions of the first two.

When shifting left, the rightmost (least significant bits) are zero filled. This is true for both << and <<< (they are identical). For example 4b1011 << 2 becomes 4b1100.

When shifting right, the leftmost bits (most significant bits) are zero filled for >>. However, if >>> is used and the value is signed, the leftmost bits are signed extended. This means the most significant bit (the sign bit) is used to fill. Here are some examples.

4b0110 >> 2 == 4b0001
4b0110 >>> 2 == 4b0001
$signed(4b0110) >> 2 == 4b0001
$signed(4b0110) >>> 2 == 4b0001

4b1100 >> 2 == 4b0011
4b1100 >>> 2 == 4b0011
$signed(4b1100) >> 2 == 4b0011
$signed(4b1100) >>> 2 == 4b1111

Functions

Functions take the form $function_name(value) and perform some operation on the specified value. There are currently only three supported functions.

$signed(a) is used to specify that a certain value should be treated as a signed number. The bits themselves do not change nor does their width. Here are some examples.

$signed(4b1001) == -7
$signed(24) == -8

$unsigned(a) is used to specify that a certain value should be treated as an unsigned number. The bits themselves do not change nor does their width. Here are some examples.

$unsigned(-7) == 4b1001
$unsigned(-8) == 24

$clog2(a) is used to calculate the ceiling log base 2 of a value. This is useful when trying to figure out how many bits are required to store a number of combinations. For example if you want to create a timer that goes from 0 to MAX - 1 (so you have MAX different values) then $clog2(MAX) will be the number of bits required for your counter. Note that if you want to know how many bits are required to store a value, you need to use $clog2(VALUE + 1). Unlike $signed and $unsigned, $clog2 can only be used on constant expressions since it must be evaluated at synthesis time.

$pow(a,b) is used to calculate a to the power of b. This is typically used when a is 2 to figure out the number of combinations a signal of width b can store. Similar to $clog2(), the arguments must both be constant expressions.

$reverse(a) is used to reverse the order of an array. In multi-dimensional arrays, this only reverses the top most dimension. This is particularly useful with strings as we like to think of the left most letter being the first instead of the right. For example $reverse("Hello") is the same as "olleH". Similar to $clog2(), $reverse() only works on constant expressions.

Constant Expressions

Constant expressions are expressions that the tools can figure out their value at synthesis time. It more or less boils down to any expression that doesn’t contain any signals. For example, 2 + 2 is a constant expression since the tools can figure out the value is 4. However, x + 2 is not since the value of x may not be known at synthesis time.

They have all the same operators as regular expressions, except they also include the division operator /.

The reason the division operator exists only for constant expressions is because the division can be computed at synthesis and replaced with a constant number. If division was used in an expression, there would need to be a circuit in place that could perform the division on the fly later. While there are division circuits, there are too many trade offs that the tools can't make for you. If you need to divide an expression, check out the Core Generator tool to create a divider.

Constant expressions are used in many cases. The width of arrays must be specified with a constant expression, the selectors in case statements must be constant expressions, and the width of an array selection must be a constant expression.