Chapter 11

Subprograms

Subprograms in VHDL are very much like subprograms in software programming languages. They contain series of sequential statements and can be called from anywhere in a VHDL model to execute those statements.

There are two types of subprogram in VHDL: the function and the procedure. Operators (for example the add operator "+") are functions.

The use of subprograms in VHDL for logic synthesis is to carry out commonly repeated operations. Hierarchy is implemented using components (Chapter 10). This chapter describes how to write subprograms and when it is appropriate to do so.

11.1 The Role of Subprograms

In software languages, subprograms are the natural form of hierarchy. A task is broken down into subtasks, each of which is written as a subprogram and called from other subprograms.

However, in VHDL, the natural form of hierarchy is the entity/architecture pair, which is then invoked as a component. Designs should be partitioned into separate components, each of which can be simulated and tested in isolation. These components can then be used as instances (the term is instantiated) in a higher-level architecture.

It is easy for VHDL users with a software background to fall into the trap of using VHDL like a software language and partitioning a problem into subprograms. This is always a mistake. Bear in mind that only processes can model registers, and subprograms cannot contain processes because they contain only sequential statements, so subprograms must only contain combinational logic.

Subprograms are therefore usually restricted to small atomic operations.

11.2 Functions

A function is a subprogram that can be called in an expression. An expression can appear in many places in the VHDL language, but the most obvious examples are the source (right-hand side) of an assignment and the condition in an if or case statement.

11.2.1 Using a Function

If there was a function called carry for the carry function of a full-adder, then an example of its use would be:

library ieee;

use ieee.std_logic_1164.all;

entity carry_example is

  port (a, b, c : in std_logic;

        cout : out std_logic);

end;

architecture behaviour of carry_example is

begin

  cout <= carry(a, b, c);

end;

This example shows the function being called in a concurrent signal assignment. It omits the declaration of the function itself.

Functions can be declared in a number of different places: in the declaration part of a process (before the begin), in the declaration part of an architecture and in a package are the most common places, but they can also be declared within other subprograms, within a package body (and therefore only usable within the package itself) and one or two other places that are rarely used in practice.

This example would be complete if the function was included in the declarative part of the architecture. The use of subprograms declared in packages will be dealt with in more detail in Section 11.6.

Functions can be called in either sequential VHDL or concurrent VHDL, although the function itself can only contain sequential statements. The best way of thinking of a concurrent function call is by its equivalent process. The equivalent process for the signal assignment in the example above is:

process (a, b, c)

begin

  cout <= carry(a, b, c);

end process;

This shows that the assignment is sensitive to changes on any of the function parameters, so any changes on any of the parameters results in the assignment statement being re-executed. In other words, a concurrent signal assignment containing a function call models combinational logic, just as a concurrent signal assignment without a function call would.

11.2.2 Function Declaration

To show how a function declaration is made up, the carry function used in the example above will be used. The following VHDL shows how such a carry function would be written:

function carry (bit1, bit2, bit3 : in std_logic)

return std_logic is

  variable result : std_logic;

begin

  result := (bit1 and bit2) or (bit1 and bit3) or (bit2 and bit3);

  return result;

end;

Focusing first on the structure of the function, the first part is the function declaration that gives the name of the function and details of its parameters and its return type. In this case, the function is called carry, takes three in parameters called bit1, bit2 and bit3 and returns a result of type std_logic. The result returned from the function can then be assigned to a signal or variable of type std_logic or even be used to build up an expression of type std_logic.

The parameters of a function must be of mode in. Since mode in is the default mode anyway, the mode is usually dropped for function declarations:

function carry (bit1, bit2, bit3 : std_logic)

return std_logic is

...

Think of the parameters of the function as its inputs and the return value as its single output. A function can only return one value, although it can be of any type. Therefore, functions are suitable for describing blocks of combinational logic with any number of inputs and one output, like the carry logic. The output can be an array or record and this is how to implement multiple outputs.

The second part of the function is the declaration part. In this case, the function contains one local variable named result:

variable result : std_logic;

Variables can be used to accumulate results or store intermediate values used in the calculation of the return value (the output). Unlike processes, variables declared inside functions do not preserve their values between executions, they are reinitialised every time the function is called, so they cannot be used to store internal state.

A function may also include declarations used only within the function, such as types, subtypes, constants and other subprograms. However, except for local constants, these are rarely useful.

The third part of the function is the statement part. In this case it contains two statements:

result := (bit1 and bit2) or (bit1 and bit3) or (bit2 and bit3);

return result;

The first statement is a straightforward variable assignment. Any sequential VHDL can appear here. The last statement is a return statement. Functions must finish on a return statement; it is an error if a simulator can reach the end of the function without a return statement being reached.

The return statement specifies the value to be returned by the function – the output of the circuit. In this case, the return value is the value stored in the internal variable result. A function must exit via a return statement.

In fact, the return statement can have an expression of any complexity to calculate the return value, so the carry function could have been written:

function carry (bit1, bit2, bit3 : in std_logic) return std_logic is

begin

  return (bit1 and bit2) or (bit1 and bit3) or (bit2 and bit3);

end;

Of course, this would not have demonstrated the declaration of local variables or the use of variables in a function.

11.2.3 Initial Values

Initial values assigned to variables in a function are synthesised. This is different from the interpretation of the initial values of signals or the initial values of variables when they are declared in a process.

The reason for this is to do with the way in which simulators interpret subprograms. When a subprogram is called, the variables are initialised at the time of the call and are reinitialised each call. This is not the same as elaboration at time zero, it is just a part of the statement sequence. It can be synthesised as if it was a variable assignment. For example, the following function takes a boolean parameter and returns a bit with the same logical value:

function to_bit (a : in boolean) return bit is

  variable result : bit := '0';

begin

  if a then

    result := '1';

  end if;

  return result;

end;

In this example, the variable result is initialised with the value '0'. Then this value is overridden by the value '1' only if the input parameter is true. This is equivalent to:

function to_bit (a : in boolean) return bit is

  variable result : bit;

begin

  result := '0'

  if a then

    result := '1';

  end if;

  return result;

end;

The re-initialisation of variables every time a function is called allows a variable to be initialised with the value of one of the parameters. To take the use of initial values to its absurd extreme, the same function can be written such that the functionality is entirely in the initial value:

function to_bit (a : in boolean) return bit is

  variable result : bit := bit'val(boolean'pos(a));

begin

  return result;

end;

In this example, the attributes val and pos are used to make the conversion. The parameter, which is of type boolean, is converted to an integer value by the boolean'pos expression. This integer value is then converted to type bit by the bit'val expression. This conversion takes advantage of the fact that the positional values of the two types match up, so that both '0' and false have the same positional value (0) and '1' and true have the same positional value (1). This method could not be used for conversions of boolean to std_logic for example, because the positional values do not match up.

This example also shows how the initial value of the variable depended on the value of the input parameter of the function. Once again, the initialisation of the variable can be thought of as being equivalent to the declaration of an uninitialised variable followed by an assignment in the function body, so the following code is equivalent to the example above:

function to_bit (a : in boolean) return bit is

  variable result : bit;

begin

  result := bit'val(boolean'pos(a));

  return result;

end;

11.2.4 Functions with Unconstrained Parameters

One of the most useful features of functions is the ability to define them with unconstrained arrays as parameters. It would appear that this breaks the rule for synthesis that all datapaths must be of a known size. However, such a parameter will in fact be constrained to fit the size of the variable or signal passed to it when the function is called. Effectively there is a family of functions, one for every possible array size, in one function. Everywhere in a design that a function is called a new copy is created and that copy is constrained to the size of its parameters prior to synthesis. This means that the parameter sizes themselves must be a known size (you cannot, for example, use a variable array slice as a parameter), but that is the only constraint. However, there are some pitfalls that make such unconstrained functions tricky to write correctly.

An example of a function with an unconstrained parameter is:

function count_ones (vec : std_logic_vector) return natural is

  variable count : natural;

begin

  count := 0;

  for i in vec'range loop

    if vec(i) = '1' then

      count := count + 1;

    end if;

  end loop;

  return count;

end;

In this example, the parameter vec has been defined as a std_logic_vector – an unconstrained array type – without giving any bounds. This function can therefore be used on any signal or variable that is a std_logic_vector.

The declaration part of the function contains the declaration for an intermediate variable called count, which is going to be used to accumulate the return value of the function. At the start of the statement part of the function the variable is initialised to 0, then in the for loop it is incremented every time a '1' is found in the input array vec.

The key point to the way the function has been written is that neither the size nor the direction of the input array are known. By using the range attribute in the for loop, the function is guaranteed to visit the leftmost bit first and to go through the array from left to right. This will be true, regardless of whether the array starts at 0 or at 100, whether it has an ascending range or a descending range, or what size it is.

This example is a simple one, since the range and direction of the input array does not have any effect on the algorithm. This is not always the case. A more complicated example will be used to illustrate other issues that arise with unconstrained parameters.

The reason for the need for flexibility in the design of a function is that it is not known at the time of writing the function, exactly what array range is going to be passed to it. When an array is passed to an unconstrained function parameter like this, the parameter takes on exactly the range of the array being passed to it. It is important to write general-purpose functions so that the user of the function has complete freedom in the way they use it. It is not good practice to assume that users will conform to the convention that bit-array types are descending ranges ending in zero. Furthermore, there are situations where, even if the convention is conformed to, the parameter still could have a different range. Take the following example. In this, the user wishes to count the number of ones in the top half of a 16-bit word:

library ieee;

use ieee.std_logic_1164.all;

entity count_top_half is

  port (vec : in std_logic_vector(15 downto 0);

        count : out natural range 0 to 8);

end;

architecture behaviour of count_top_half is

begin

  count <= count_ones(vec(15 downto 8));

end;

In this example, the top half of the vector has been passed to the function using the slice vec(15 downto 8). Therefore, the parameter inside the function will have the range(15 downto 8).

A final cause of problems is the string value. Remember that a constant value of a character array type such as std_logic_vector can be represented by a string value. For example:

count <= count_ones("00001111");

In this case, the VHDL language definition says that the range of the string value should be(0 to 7), an ascending range. If the function was defined assuming a descending range, then it would not work with string values. For example, a loop that went from the high value to the low value would visit the elements from right to left instead of from left to right. Furthermore, different synthesis vendors have been inconsistent with their implementations of string values. A quick survey of three synthesis and simulation tools revealed three different interpretations: the first used the range 7 downto 0; the second used the range 0 to 7 (the correct interpretation) and the third used the range 1 to 8, all for the same 8-bit string. The result is that no assumptions can be made about the range of a string value, since it may vary from one VHDL tool to another, and indeed does.

This means that, to write a function that will work for all parameters on all systems, it is essential to make no assumptions at all about the range or direction of an unconstrained array parameter.

The main technique that is used to simplify the writing of functions with unconstrained parameters is a technique that will be referred to as normalisation. In this, the in parameters are immediately assigned to local variables that are exactly the same size as the parameters, but with ranges that conform to the common conventions, and these variables are then used instead of the parameters.

The following example shows how a function could be written that counts the number of matches between the bits in two std_logic_vector parameters:

function count_matches(a, b : std_logic_vector)

  return natural

is

  variable va : std_logic_vector (a'length-1 downto 0) := a;

  variable vb : std_logic_vector (b'length-1 downto 0) := b;

  variable count : natural := 0;

begin

  assert va'length = vb'length

    report "count_matches: parameters must be the same size"

    severity failure;

  for i in va'range loop

    if va(i) = vb(i) then

      count := count + 1;

    end if;

  end loop;

  return count;

end;

This function takes two parameters, a and b. These are both normalised by assignment to va and vb. The assignment has been incorporated into the variable declarations as the initial values of the variables. Note how the sizes of va and vb are calculated in terms of the sizes of the parameters, by using a'length and b'length in the range constraint of the two local variables.

The next feature of this function is the assertion. This is a simulation-only feature, ignored by synthesis, which checks that the two arrays are of equal size. The assertion is present because the rest of the function has been written assuming that the two arrays are of equal size. It is always good practice to use assertions to check such assumptions are being honoured. If anyone ever uses the function wrongly by passing different-sized parameters, the simulator will report the fact by printing the assertion message. There are some conditions (namely, when a is longer than b) where trying to continue after the assertion would cause a subsequent failure of the simulator, so the severity of the assertion is failure, which stops the simulator immediately.

The reason why the normalisation is necessary is that the for loop is controlled by the value i, which takes on the values in the range of va. This index is then used to access the elements of vb. This is only safe to do if vb has the same range as va.

It is generally good practice to always normalise unconstrained array parameters. This will avoid all the common pitfalls inherent in writing such functions.

11.2.5 Unconstrained Return Values

It is also possible to write functions that have an unconstrained array as their return type. Once again, there are some important guidelines to follow if the common pitfalls are to be avoided.

Consider the case where there is a function that returns a std_logic_vector, then the size of the std_logic_vector must be known by the user when writing the VHDL that uses the function in order that a signal or variable of the right size can be declared to receive the result. Furthermore, the size of the return value must be calculable by the synthesiser prior to synthesis in order that the right number of bits can be allocated to the bus. This also means that the size of the result must not vary during a simulation run since it would then be impossible to define a signal or variable to receive that value. What this effectively means is that the size of the result array must be either of a fixed size or entirely dependent on the sizes of the input parameters. The return size must not depend on the values of the parameters since that would cause it to vary. The exception to this is where the parameter values are constant literals.

In fact, the general rules of VHDL do allow the return of differing length results under some conditions (not in signal assignments though), but these do not apply to synthesis. The rule stated above is a requirement of synthesis VHDL, regardless of the circumstances. The reason is quite simple: the synthesiser must know how many bits are going to be required to implement the return value and this must be clear from the structure of the function with knowledge only of the size and not the values of the parameters.

For example, assume there is a function called matches, which returns the bitwise equality of two std_logic_vector values. This function is also based on an example from Section 8.7 where it was used to illustrate for loops:

function matches(a, b : std_logic_vector)

return std_logic_vector is

  variable va : std_logic_vector(a'length-1 downto 0) := a;

  variable vb : std_logic_vector(b'length-1 downto 0) := b;

  variable result : std_logic_vector(a'length-1 downto 0);

begin

  assert va'length = vb'length

    report "matches : parameters must be the same size"

    severity failure;

  for i in va'range loop

    result(i) := va(i) xnor vb(i);

  end loop;

  return result;

end;

In this example, the result is constrained to be the same size as the input parameter a. Furthermore, the assertion constrains b to be the same size as a. In use, then, this constraint will be known by the user (either from reading documentation or the function itself), so a signal or variable of the right size can be used to receive the result. Furthermore, it is known to the synthesiser since the constant expression a'length-1 used to constrain the result will be evaluated prior to synthesis of the function body.

Taking the original example from Section 8.7, which was a complete entity/architecture pair, the following code shows how this circuit could have been written with the aid of the function.

library ieee;

use ieee.std_logic_1164.all;

entity match_bits is

  port (a, b : in std_logic_vector (7 downto 0);

        result : out std_logic_vector (7 downto 0));

end;

architecture behaviour of match_bits is

begin

  result <= matches(a, b);

end;

This example is still incomplete, because nothing has been said about where the function itself is defined. This will be addressed in Section 11.6, so for now the example will be left in this slightly incomplete state.

The one exception to the general rule that the return size must not depend on the value of a parameter is when that parameter is always going to be given a constant value. In this case, the synthesiser can use the constant value to calculate the size of the return value. An example of this is the following simple sign-extension function that interprets a std_logic_vector as a signed integer and sign-extends the integer to a specified width:

function extend (a : std_logic_vector; size : natural)

return std_logic_vector is

  variable va : std_logic_vector(a'length-1 downto 0) := a;

  variable result : std_logic_vector(size-1 downto 0);

begin

  assert va'length <= size

     report "extend: must extend to a longer length"

     severity failure;

  assert va'length >= 1

    report "extend: need at least a sign bit to sign extend"

    severity failure;

  result := (others => va(va'left));

  result (va'range) := va;

  return result;

end;

In this example, the length of result is defined in terms of the input parameter size. This will only be legal if size is associated with a constant value, so synthesis will fail if it is ever associated with a variable or signal. The reason it will fail is that the signal result will not be of a known size when it comes to synthesis, although it will not necessarily fail in simulation.

The assertions enforce the usage rules for the function: in this case it is only legal to sign extend to a length greater than the current length of the input parameter and there must be at least one bit in the input parameter for the sign extension to work. The sign-extension algorithm itself works by pre-filling the result with the sign bit from the input parameter and then copying the input parameter into the subrange corresponding to its length.

In use, this function must be used with a constant size parameter. A simple example is the following entity/architecture pair that shows how the function could be used to sign extend an 8-bit input to create a 16-bit output:

library ieee;

use ieee.std_logic_1164.all;

entity extend_example is

  port (a : in std_logic_vector(7 downto 0);

        z : out std_logic_vector(15 downto 0));

end;

architecture behaviour of extend_example is

begin

  z <= extend(a, 16);

end;

This will be synthesised by first taking a copy of the function, performing constant calculations within that function copy (including array-size calculations), then synthesising the result.

After parameter substitution, the function looks like:

function extend (a : std_logic_vector(7 downto 0); 16 : natural)

  return std_logic_vector

is

  variable va : std_logic_vector(7 downto 0) := a;

  variable result : std_logic_vector(15 downto 0);

begin

  assert 8 <= 16

    report "extend: must extend to a longer length"

    severity failure;

  assert 8 >= 1

    report "extend: need at least a sign bit to sign extend"

    severity failure;

  result := (others => va(7));

  result (7 downto 0) := va;

  return result;

end;

It is clear that the sizes of all the buses are now known. The assertions, once checked, can be ignored, leaving the basic function, which can be synthesised.

It is pushing the limits of what is possible in synthesis to write functions like this, and so it is highly recommended that the synthesiser documentation is checked and possibly some examples tried to establish what is possible.

11.2.6 Multiple Returns

There can be more than one return statement in a function. The only unbreakable rule is that the function must be exited by a return statement; it must not be possible to reach the end of the function without encountering one. However, it is not necessary for there to be a return statement physically at the end of the function.

Multiple return statements are generally used in two contexts: to break out of a for loop and return a value, or to return different values from different branches of a condition. Both of these common cases will be illustrated here.

Using a return statement to break out of a for loop and return a value is very similar to the use of an exit statement in a for loop. The following example shows a function that has two return statements:

function count_trailing (vec : std_logic_vector) return natural is

  variable result : natural := 0;

begin

  for i in vec'reverse_range loop

    if vec(i) = '1' then

      return result;

    end if;

    result := result + 1;

  end loop;

  return result;

end;

In this example, the result is incremented for every trailing '0' in the parameter. The value of the result is returned immediately from the function when a '1' is found. However, if a '1' is never found – because the parameter contains only '0's – then the final return statement catches this special case and ensures that the result is still returned.

The second use of multiple returns is to give different results depending on the value of a condition. This can give a clearer structure to a function than setting the value of an intermediate variable in the conditional and then returning the result.

The use of multiple returns comes into its own with type-conversion functions for types that cannot be converted directly. The following shows a function that converts a std_logic parameter to a character:

function to_character (a : std_logic) return character is

begin

  case a is

    when 'U' => return 'U';

    when 'X' => return 'X';

    when '0' => return '0';

    when '1' => return '1';

    when 'Z' => return 'Z';

    when 'W' => return 'W';

    when 'L' => return 'L';

    when 'H' => return 'H';

    when '-' => return '-';

  end case;

end;

Note that this function does not actually end in a return statement, but that the rule that it is impossible to reach the end of the function without encountering a return statement has been met.

In this example, there are two uses of each of the character literals 'U' through to '-'. This looks confusing but is quite clear on closer inspection. The case statement is branching on the value of the input parameter a, which is of type std_logic, so the values used as the choices of the case statement (in the when clauses) are the character literals defined in type std_logic. However, the return type of the function is type character, so the values used in the return statements are the character literals defined in type character.

A final point on the use of multiple returns is that, when returning an unconstrained array type, all the return statements must return an array of the same size. This is a direct consequence of the need to know the size of the return array prior to synthesis.

As an example, here is the count_trailing example rewritten to return an unsigned rather than an integer result. Since the return value is an unconstrained array it has an extra parameter specifying the size of the return subtype.

function count_trailing(vec : std_logic_vector, size : natural)

  return unsignedis

  variable result : unsigned(size-1 downto 0) := (others => '0'),

begin

  for i in vec'reverse_range loop

    if vec(i) = '1' then

      return result;

    end if;

    result := result + 1;

  end loop;

  return result;

end;

11.2.7 Function Overloading

It is not necessary to find unique names for all your functions. It is possible to reuse names by overloading. When a function is called in VHDL, it is not just the name that is used to determine which function is being called; the number of parameters and their types and also the return type are all used to identify the function. This means that a function is unique if one or more of these characteristics is unique. The process that a VHDL analyser goes through to identify an overloaded function is called overload resolution.

There are pitfalls in overloading functions excessively. There is not always sufficient information available for the analyser to know the types of all the parameters or the expected return type. The biggest problems seem to occur when functions are differentiated by return types alone. If the function call is itself a parameter to an overloaded function or operator, then the analyser cannot work out from the context which of the functions is intended.

It is therefore recommended that functions that differ in their return type but not their parameters are given different names. Indeed, other programming languages that allow function overloading, such as C++, disallow the overloading of functions distinguished only by their return type for exactly this reason. So even though VHDL allows it, it is strongly recommended that you don't do it.

One application of overloading is where two types are expected to have similar behaviour, although they are used in different circumstances. A good example of this is functions acting on the types bit and std_logic. Both types are logical types and they are almost interchangeable at the RTL level of modelling. Using type bit gives simpler modelling and simpler simulation, but using std_logic gives access to tristates. Therefore, when writing general-purpose utilities, it is common to provide both bit and std_logic versions of a function with the same name.

11.3 Operators

The operators in VHDL have been dealt with in some detail in Chapter 5. That chapter concentrated on the synthesis interpretation of the built-in operators. This section will look at operators as subprograms.

Operators are just functions with special rules that allow them to be used in-fix. That means, they can be used in expressions such as:

z <= a + b * c;

The source expression in this signal assignment (the right-hand side) contains two operators, "+" and "*". These are in fact function calls, and the same signal assignment could have been written:

z <= "+"(a, "*"(b, c));

The two different ways of writing the expressions shown above are exactly equivalent. This example shows that operators are special functions with names corresponding to the operator symbol enclosed in double quotes to make it a string value.

When a type is defined, a set of operator functions are automatically defined by the language. These are the built-in operators. In addition, operators can be written by a user to either add operators to a type that the type does not have built-in, or to replace the built-in operator with a function with different behaviour. In both cases, the writing of user-defined operators is referred to as operator overloading. Examples of both kinds of operator overloading will be shown.

First, however, it is helpful to summarise the built-in operators that will be predefined for any type.

11.3.1 Built-In Operators

The set of built-in operators that a type has depends on the type. This section will summarise the relationship between operators and types.

Table 11.1 shows which types have which operators predefined. It only lists the synthesisable types from package standard, since these are the only built-in types.

Table 11.1 Built-in operators for each type.

img

The following six groupings of operators are used in the table to keep it simple. These are the same groups as used in earlier chapters when describing operators, but the comparison has been further split into equality and ordering operators:

boolean: not, and, or, nand, nor, xor, xnor

equality: =, /=

ordering: <, <=, >, >=

shifting: sll, srl, sla, sra, rol, ror

arithmetic: **, abs, *, /, mod, rem, sign +, sign -, +, -

concatenation: &

There are some notable special cases here, namely the built-in logical types bit and boolean. These have predefined logical operators, even though they are enumeration types. Furthermore, any array type defined in terms of these two logical types will also have bitwise logical operations predefined.

Other logical types, such as std_logic, are simply enumeration types, so only have the basic set of relational operators when first defined. This means that the author of a multi-valued logic package is responsible for overloading the other operators required for the type, and this has been done for std_logic to provide the logical operators.

Similarly, array types such as the types signed and unsigned will have non-numeric comparison and no arithmetic operators, so the authors of the numeric_std package had to add the arithmetic operators and replace the built-in comparison operators.

11.3.2 Operator Overloading

An operator is just a function, the name of which is the symbol for the operator enclosed in quotes. For example, the function declaration of the "+" operator for type integer is:

function "+" (l, r : integer) return integer;

Since operators are functions, they can be overloaded in the same way that functions can.

There are two situations where operator overloading is used. The first is to add operators to a type that hasn't got them. The second is to change the behaviour of existing operators for a type.

In order to overload the "+" operator for integer, a function with this name, parameter types and return type would be written. However, changing the behaviour of predefined types by overloading their operators is not good practice, because it can be very confusing. Operator overloading comes into its own when defining your own types.

The rules for operator overloading mean that it is only possible to overload operators that are already defined in the language. These are the set listed above. You couldn't, for example, define a new operator "@" because there is no such operator in the language.

Secondly, the number of parameters for the operator must be correct. Most operators take two arguments (binary operators), so these must be written as functions with two parameters. A few of the operators take only one argument (unary operators) so these must be written as functions with one parameter. Some operators have both unary and binary variants, such as the "−" operator: with one parameter you overload the minus sign, with two you overload subtraction.

Operators can be defined taking any combination of types as parameters and returning any type. The VHDL analyser is responsible for working out which of a number of operators with the same symbol is intended. The process of working out which operator to use is called operator resolution, and is identical to function overload resolution except it works on operator symbols rather than function names. It works by trying to match the operator name, the number of parameters, the parameter types and the return type with the context in which the operator has been used. However, excessive overloading of operators causes ambiguities that make operator resolution impossible, thus making the operators almost unusable. For example, if two operators take exactly the same parameters but return different types, then the VHDL analyser may not be able to work out from the context, which of the two operator functions to use. This problem is exhibited by the deprecated package std_logic_arith (Chapter 7), which excessively overloads the arithmetic operators.

To avoid such problems, it is good practice to provide only one operator of each kind for a particular type and to use type conversions to cover the other permutations. For example, to allow the addition of a std_logic_vector to an integer, provide an operator that can add two std_logic_vector parameters, giving a std_logic_vector result. Then require the user to type convert the integer to a std_logic_vector and then add that result to the other std_logic_vector.

This approach will avoid all of the common problems encountered with operator overloading.

When overloading operators, the following templates are the recommended way to overload each of the operators. In each case, the word type refers to the type that the operator is being overloaded for. In the array concatenation operators, the word element refers to the element type of the array.

The one-parameter functions corresponding to the unary operators are:

function "not" (r : type) return type;

function "-" (r : type) return type;

function "+" (r : type) return type;

function "abs" (r : type) return type;

The following are the reducing logic operators that are only allowed in VHDL-2008:

function "and" (l : type) return element;

function "or" (l : type) return element;

function "nand" (l : type) return element;

function "nor" (l : type) return element;

function "xor" (l : type) return element;

function "xnor" (l : type) return element;

The two-parameter functions corresponding to the binary operators are:

function "and" (l, r : type) return type;

function "or" (l, r : type) return type;

function "nand" (l, r : type) return type;

function "nor" (l, r : type) return type;

function "xor" (l, r : type) return type;

function "xnor" (l, r : type) return type;

function "and" (l : element; r : type) return type;

function "or" (l : element; r : type) return type;

function "nand" (l : element; r : type) return type;

function "nor" (l : element; r : type) return type;

function "xor" (l : element; r : type) return type;

function "xnor" (l : element; r : type) return type;

function "and" (l : type; r : element) return type;

function "or" (l : type; r : element) return type;

function "nand" (l : type; r : element) return type;

function "nor" (l : type; r : element) return type;

function "xor" (l : type; r : element) return type;

function "xnor" (l : type; r : element) return type;

function "=" (l, r : type) return boolean;

function "/=" (l, r : type) return boolean;

function "<" (l, r : type) return boolean;

function "<=" (l, r : type) return boolean;

function ">" (l, r : type) return boolean;

function ">=" (l, r : type) return boolean;

function "sll" (l : type; r : integer) return type;

function "srl" (l : type; r : integer) return type;

function "sla" (l : type; r : integer) return type;

function "sra" (l : type; r : integer) return type;

function "rol" (l : type; r : integer) return type;

function "ror" (l : type; r : integer) return type;

function "**" (l, r : type) return type;

function "*" (l, r : type) return type;

function "/" (l, r : type) return type;

function "mod" (l, r : type) return type;

function "rem" (l, r : type) return type;

function "+" (l, r : type) return type;

function "-" (l, r : type) return type;

function "&" (l, r : type) return type;

function "&" (l : element; r : type) return type;

function "&" (l : type; r : element) return type;

function "&" (l : element; r : element) return type;

These templates correspond to the predefined operators that are automatically defined for one or other of the standard types.

A final point to be aware of is that it is the basetype that is used in operator resolution, not the subtype. It is not possible to overload different functions for different subtypes. If an operator is defined with subtype parameters, then the operator will be applied to all signals and variables of the parameters' basetypes or any other of their subtypes, but there will be an additional constraint on the values of the type that may be used. For example, if an operator "+" was defined for natural, which is a subtype of integer, then the operator effectively replaces the integer operator. However, the range constraint of natural would mean that negative values could no longer be used with that operator.

When overloading operators, all the guidelines for writing functions apply. For example, to overload an operator for an unconstrained array type, use the normalisation technique described in Section 11.2. If the operator needs to return an unconstrained type, use the guidelines on unconstrained return types in the same section. Finally, use the source code of std_logic_1164 and numeric_std as a reference.

11.4 Type Conversions

Built-in type conversions and user-defined type conversions are quite different, so it is not possible to overload the built-in type conversions to change their behaviour. However, user-defined type conversions are subprograms and can be written as functions.

11.4.1 Built-In Type Conversions

There are type conversion functions automatically available for conversion between what the language reference manual describes as `closely related types'. This term needs some explanation.

All integer types are considered closely related, so it is possible to convert a value of any integer type to any other integer type. The type conversion is done by using the name of the target type as if it were a function. It is possible to use either a type or a subtype for the name of the type-conversion function. In the case of a subtype name being used, there is no difference in the type conversion compared with using the basetype, but the result will be checked against the constraints of the subtype during simulation. It is good practice to use subtype names for type conversions so that out-of-range values can be detected by the simulator during the type conversion.

For example, suppose you are using a type called short and wish to convert it to a natural. This would be carried out using the name natural as a function call.

signal sh : short;

signal int : natural;

...

int <= natural(sh);

Array types are considered closely related under the following conditions:

1. they have the same number of dimensions;

2. they are indexed by types which can be converted to each other;

3. the elements are of the same type.

The first condition must be true for a synthesisable model, because only one-dimensional arrays are allowed for synthesis. The second rule will always be true if the array is indexed by any integer type; indeed, most array types used in synthesis are indexed by natural. However, if you use an array type indexed by an enumeration type, it will only be convertible to other array types indexed by the same enumeration type, because enumeration types are not convertible to each other.

The final condition is generally the only constraining condition in synthesis VHDL.

The outcome of this is that most arrays of bit, for example, bit_vector and the types signed and unsigned defined in numeric_bit, can be converted between each other. Similarly, arrays of std_logic, for example, std_logic_vector and the types signed and unsigned in numeric_std can also be converted between each other.

11.4.2 User-Defined Type Conversions

It is not possible to define a function with the same name as a type to create a new type conversion. User-defined type conversions are simply functions that take a value of one type and return another type, presumably with the same value. However, there are some conventions for writing type conversions that are worth knowing and using.

The first convention is the name of the type-conversion function. The convention is to call the function to_type where type is the name of the target type of the type conversion and therefore also the return type of the function. This means that all type-conversion functions that convert to type integer, for example, will be called to_integer. The VHDL analyser will still have some function overloading to resolve, but experience has shown that overloading in this way, which ensures that the function input parameter is always a different type for the same name and that the function return type is always the same for the same name, rarely causes ambiguity and so the analyser will generally have no problem resolving which to_integer function is being referred to. Also, if this convention is always kept, there is no problem remembering the name of the type-conversion function and it is obvious when reading a VHDL model what the function is doing.

A simple example of the type-conversion function is a conversion from boolean to bit:

function to_bit (arg : boolean) return bit is

begin

  case arg is

    when true => return '1';

    when false => return '0';

  end case;

end;

A complete type conversion is not always possible and it is necessary to decide which values to discard. For example, converting from std_logic to bit requires the metalogical values to be discarded. The function for this is:

function to_bit (arg : std_logic) return bit is

begin

  case arg is

    when '1' => return '1';

    when others => return '0';

  end case;

end;

In this case the arbitrary decision has been made to map the metalogical values onto the '0' value. Alternatively, an assertion could be raised. In this case only a warning will be given because the change of value from, say, 'Z' to '0' may not be significant in itself. It certainly doesn't warrant an error:

function to_bit (arg : std_logic) return bit is

begin

  case arg is

    when '1' => return '1';

    when '0' => return '0';

    when others =>

      assert false report "conversion from metalogical value"

        severity warning;

      return '0';

  end case;

end;

Note that a return value is still needed, since the assertion will not stop the simulation.

Extra parameters are sometimes needed to give the type-conversion function extra information on how to convert the type. The second convention when writing type-conversion functions is, where there is more than one parameter, always make the value to be converted the first parameter of the function.

One use of a second parameter is when converting from a non-array type to an array type. The parameter specifies how many bits to use in the conversion. For example, in the conversion from integer to std_logic_vector, it is necessary to tell the type-conversion function how many bits to use in the bitwise representation:

function to_std_logic_vector (arg : integer; size : natural)

  return std_logic_vector

is

  variable v : integer := arg;

  constant negative : boolean := arg < 0;

  variable result : std_logic_vector(size-1 downto 0);

begin

  if negative then

    v := -(v + 1);

  end if;

  for count in 0 to size-1 loop

    if (v rem 2) = 1 then

      result(count) := '1';

    else

      result(count) := '0';

    end if;

    v := v / 2;

  end loop;

  if negative then

    result := not result;

  end if;

  return result;

end;

A second use for an additional argument is to specify what to do with the extra values when converting between logical types that have metalogical values. For example, an alternative version of the function to convert from std_logic to bit is:

function to_bit (arg : std_logic; xmap : bit := '0') return bit is

begin

  case arg is

    when '1' => return '1';

    when '0' => return '0';

    when others => return xmap;

  end case;

end;

Bit has only two values, whereas std_logic has nine, of which two map onto the bit values directly. In this example, the extra parameter specifies the value of bit to map the other seven metalogical values of std_logic onto. It has been given a default value so that, if not specified, then the metalogical values will map onto '0', giving exactly the same functionality as the original version of the function. This extra parameter only effects simulation anyway; it will have no effect on synthesis, because the seven metalogical values are eliminated by the synthesiser, so this synthesises to exactly the same circuit as the original to_bit.

11.5 Procedures

Procedures are subprograms and, in that sense, are like functions. Indeed, they look very like functions in their declaration. However, they are used differently and have different rules. Like functions, they can be declared in any declarative region: architectures, processes, within other subprograms, but most of all, in packages, where they are most useful.

The same general guidelines apply to the use of procedures as to functions: they should not be used as a means of partitioning a design; components should be used for this. The main use should be small, atomic operations and commonly needed routines.

11.5.1 Procedure Parameters

The first difference with procedures is that the parameters can be of mode in, mode out or mode inout. These modes should not be confused with the modes of the ports on entities that have a slightly different interpretation.

Procedures do not have return values, so the parameters are the only means by which values can be passed into and out of a procedure.

Mode in is comparable to the input parameters of a function. Parameters of mode in are used to pass values into the procedure but cannot be used to pass values back out again. Mode out is used to pass parameters out of a procedure but cannot be used to pass values in. In this sense, they are comparable to the return value of a function, except that there can be any number of out parameters. Finally, mode inout is used to pass a value into a procedure where it can be modified and then passed back out again. Mode inout does not model tristates or bidirectional signals.

To illustrate the use of procedures, consider the following example that describes a single full-adder. The reason for using a procedure here is that a full-adder has two outputs:

procedure full_adder (a, b, c : in std_logic;

                      sum, cout : out std_logic) is

begin

  sum := a xor b xor c;

  cout := (a and b) or (a and c) or (b and c);

end;

Notice that the assignments used in the procedure are variable assignments. This is because out parameters on a procedure are variables by default. However, signal parameters can also be specified, and these will be dealt with separately later in this section.

This procedure can only be used in a sequential procedure call, because the out parameters are variables and therefore can only be used to assign to variables. They cannot be used with signals.

An example of the use of this procedure in a sequential procedure call is shown in the following example that puts together four full-adders to make a four-bit adder with carry in and carry out.

library ieee;

use ieee.std_logic_1164.all;

entity adder4 is

  port (a, b : in std_logic_vector (3 downto 0);

        cin : in std_logic;

        sum : out std_logic_vector (3 downto 0);

        cout : out std_logic);

end;

architecture behaviour of adder4 is

begin

  process (a, b, cin)

    variable result : std_logic_vector(3 downto 0);

    variable carry : std_logic;

  begin

    full_adder (a(0), b(0), cin, result(0), carry);

    full_adder (a(1), b(1), carry, result(1), carry);

    full_adder (a(2), b(2), carry, result(2), carry);

    full_adder (a(3), b(3), carry, result(3), carry);

    sum <= result;

    cout <= carry;

  end process;

end;

There are a number of subtleties in this model. The first is that signals are used on the in parameters of the procedure. This is because, like with functions, there is no difference between passing a signal or a variable to a subprogram in parameter. However, the out parameters must be associated with variables, so two intermediate variables result and carry are used for this purpose. Notice how the carry is both the input and output of procedure calls add1 to add3. Because this is sequential VHDL, the value of carry is passed in first, then the procedure is executed, calculating a new carry output, which is then passed back out through the cout parameter and thus assigned back to the carry variable. Finally, these variables are assigned to the entity out parameters, which are signals.

11.5.2 Procedures with Unconstrained Parameters

Procedures can also be declared with unconstrained parameters in the same way as functions. For in parameters, the rules are exactly the same as for functions. The main difference is that it is possible to declare out parameters in this way as well.

The use of unconstrained out parameters has its own set of pitfalls similar to those of the use of unconstrained in parameters. The problem is that, when a procedure is called, the parameter inherits its range from the variable associated with the parameter in the call, just as with in parameters.

For example, consider the case of a procedure with the following interface:

procedure add (a, b : in std_logic_vector;

               sum : out std_logic_vector);

When the procedure is called, all three parameters take their ranges from the variables passed to them in the call. For example:

library ieee;

use ieee.std_logic_1164.all;

entity add_example is

  port (a, b : in std_logic_vector(7 downto 0);

        sum : out std_logic_vector(7 downto 0));

end;

architecture behaviour of add_example is

begin

  process (a, b)

    variable result : std_logic_vector(7 downto 0);

  begin

    add(a, b, result);

    sum <= result;

  end process;

end;

In this case, the parameters all take on the range 7 downto 0. However, the writer of the subprogram cannot assume anything about the range of a parameter. Note that this is the same argument as used with function parameters, but here it is an out parameter that has also been constrained by the variable passed to it. In a sense, the range of the parameter has been passed in to the procedure, even though the parameter is of mode out.

The safe way of writing such procedures is to normalise all of the unconstrained parameters. Normalisation of out parameters is slightly different from that of in parameters. A local variable is created to be the temporary working variable for that output and it is assigned to the parameter at the end of the procedure instead of at the beginning.

To illustrate how this is done, the add procedure would be written:

procedure add (a, b : in std_logic_vector;

               sum : out std_logic_vector) is

  variable a_int : std_logic_vector(a'length-1 downto 0) := a;

  variable b_int : std_logic_vector(b'length-1 downto 0) := b;

  variable sum_int : std_logic_vector(sum'length-1 downto 0);

  variable carry : std_logic := '0';

begin

  assert a_int'length = b_int'length

    report "inputs must be same length"

    severity failure;

  assert sum_int'length = a_int'length

    report "output and inputs must be same length"

    severity failure;

  for i in a_int'range loop

    sum_int(i) := a_int(i) xor b_int(i) xor carry;

    carry := (a_int(i) and b_int(i)) or

              (a_int(i) and carry) or (b_int(i) and carry);

  end loop;

  sum := sum_int;

end;

The assertions check that the assumptions made in writing the procedure have been kept by the user of the procedure. In this case, the procedure has been kept simple by insisting that all three parameters are the same length. Notice how the length attribute has been used to find the length of the sum parameter, even though the sum parameter is an output and therefore unreadable. It is only the values of out parameters that are unreadable, not their size attributes.

11.5.3 Using Inout Parameters

An inout parameter is a parameter that can be modified by the procedure. That is, it can be read and then given a new value. Conceptually, it is both an in and an out parameter. It is worth stating again at this stage that this is not equivalent to a bidirectional or a tristate signal.

A simple example of the use of an inout parameter is:

procedure invert (arg : inout std_logic_vector) is

begin

  for i in arg'range loop

    arg(i) := not arg(i);

  end loop;

end;

This example simply inverts each element of the parameter and passes the result back. This example also shows that inout parameters can be unconstrained too.

The example does not show the normalisation of inout parameters, but this is simply a combination of the techniques used for in parameters and out parameters. The same example could have been written with normalisation:

procedure invert (arg : inout std_logic_vector) is

  variable arg_int : std_logic_vector(arg'length-1 downto 0);

begin arg_int := arg;

  for i in arg_int'range loop

    arg_int(i) := not arg_int(i);

  end loop;

  arg := arg_int;

end;

11.5.4 Signal Parameters

So far, all the examples of procedures have had out and inout parameters that were variables. This meant that all the examples could only be called in sequential VHDL – either in a process or from another subprogram.

The full story of parameters is more complex than these examples have shown, although they illustrated the most common usage of subprograms. However, for completeness, here's the whole story of parameters, with a practical example of the use of signal parameters.

A subprogram parameter can have three modes: in, out and inout. It can also have three classes: constant, variable and signal. Not all permutations are usable, for example, a constant out parameter does not make any sense, but there are still a number of legal combinations. Function parameters may only be of mode in, but they can still have any class.

Parameters of class signal must be associated with a signal. Parameters of class variable must be associated with a variable. However, a parameter of class constant can be associated with any expression – for example, a variable, a signal, another function call – but can only be of mode in.

Parameters of mode in are of class constant by default. This is the class of parameter that has been used so far in all the examples. This is why it is possible to use the same function with either variables or signals as the parameters.

It is possible to restrict the use of in parameters by making them class variable or signal, but this is rarely done in practice.

Parameters of modes out and inout are class variable by default. This is why they must be associated with variables when the procedure is used, thus restricting them to use within sequential VHDL. They cannot be of class constant, but they can be of class signal.

By making out and inout parameters class signal, they may be associated with signals and it is then possible to use the procedure in either sequential VHDL or concurrent VHDL. However, signal class parameters cannot then be associated with variables.

For example, a signal parameter version of the full-adder example shown before is:

procedure full_adder_s (a, b, c : in std_logic;

                         signal sum, cout : out std_logic) is

begin

  sum <= a xor b xor c;

  cout <= (a and b) or (a and c) or (b and c);

end;

There are a number of features of this procedure to highlight. The first is that only the out parameters have been changed to class signal. The in parameters are class constant and so can be associated with either signals or variables anyway. Within the procedure, the assignments to the out parameters have become signal assignments. Finally, the name of the procedure has been changed. This is because overload resolution does not take into account the class of a parameter when resolving procedure calls, only the types, so this procedure has exactly the same parameter profile as the original version and could not be distinguished from it if the name was the same.

To avoid problems with overload resolution, it is good practice to use a notation to distinguish between procedures written with class variable parameters and procedures written with class signal parameters. The recommended notation is to use the suffix ‘_s’ or even ‘_signal’ on the names of procedures defined with class signal parameters.

In use, there is now no need to declare intermediate variables to accumulate the result as was the case before. However, it is necessary to declare intermediate signals for the carry path. The same 4-bit adder example now looks like:

library ieee;

use ieee.std_logic_1164.all;

entity adder4 is

  port (a, b : in std_logic_vector (3 downto 0);

        cin : in std_logic;

        sum : out std_logic_vector (3 downto 0);

        cout : out std_logic);

end;

architecture behaviour of adder4 is

  signal c : std_logic_vector (2 downto 0);

begin

  full_adder_s (a(0), b(0), cin, sum(0), c(0));

  full_adder_s (a(1), b(1), c(0), sum(1), c(1));

  full_adder_s (a(2), b(2), c(1), sum(2), c(2));

  full_adder_s (a(3), b(3), c(2), sum(3), cout);

end;

In this solution, the procedures have been used as concurrent procedure calls, so there are no processes.

A concurrent procedure call will be re-evaluated every time one of its inputs changes. The inputs are the set of mode in and mode inout parameters. Therefore it is equivalent to a combinational logic block. It is in fact equivalent to a process containing the procedure call in sequential VHDL, so the first procedure call in the above example is equivalent to:

process (a(0), b(0), cin)

begin

  full_adder_s (a(0), b(0), cin, sum(0), c(0));

end process;

It is legal in VHDL to have wait statements in a procedure (but not in a function), but wait statements are disallowed for synthesis so that the procedure can be implemented as combinational logic using this equivalence.

11.6 Declaring Subprograms

Most of the examples so far have been incomplete! They have shown a subprogram and then an example of how to use it without really addressing the issue of where the subprogram is declared.

This section covers the rules for placement of subprogram declarations and then discusses the use of packages for subprograms.

11.6.1 Local Subprogram Declarations

Subprograms can be declared locally in architectures, processes and within other subprograms. In these cases, the subprograms can only be used within that structure. For example, a subprogram declared in a process can only be used in that process.

The only reason for having local declarations is to clarify a model by breaking it down into manageable parts. However, for hardware modelling for synthesis, it is better practice to break down a model into components. Nevertheless, there will be situations where this is appropriate, so the rules are covered here.

The main rule has already been stated: a subprogram declared locally can only be used locally. The only place that subprograms can be declared and then used elsewhere is the package, which will be discussed in the next section.

An example of the declaration and use of a local subprogram is shown in the following example, which puts together a function used in an earlier example and its use.

library ieee;

use ieee.std_logic_1164.all;

entity carry_example is

  port (a, b, c : in std_logic;

        cout : out std_logic);

end;

architecture behaviour of carry_example is

  function carry (bit1, bit2, bit3 : in std_logic) return std_logic

  is

  begin

    return (bit1 and bit2) or (bit1 and bit3) or (bit2 and bit3);

  end;

begin

  cout <= carry(a, b, c);

end;

This function is local to the architecture and so can be used anywhere in the architecture.

Alternatively, a function can be declared in a process. The same architecture could have been written with a single combinational process:

architecture behaviour of carry_example is

begin

  process (a, b, c)

    function carry (bit1, bit2, bit3 : in std_logic)

      return std_logic is

    begin

      return (bit1 and bit2) or (bit1 and bit3) or

             (bit2 and bit3);

    end;

  begin

    cout <= carry(a, b, c);

  end process;

end;

11.6.2 Subprograms in Packages

The main reason for writing subprograms is to model common operations that will be useful elsewhere. These subprograms can be collected together in a package. From there, they can be used throughout a design or even in other designs by sharing that package.

A package is in two parts, the package header (also known as just the package) and the package body. The package header contains the declarations of the subprograms, whilst the package body contains the subprogram bodies.

Normally, a package will be used to collect together a set of closely related subprograms. Often, although not always, the subprograms will be associated with a new type that is declared in the package – subprograms acting on that type will be stored in the same package as the type, so that they are always available to users of the type. This is particularly true of operators, for which it is always good practice to have the operators with the type they act on.

As an example, consider the earlier examples of the carry function and the full_adder procedure, and add to these a sum function. Finally, put them together into a package called std_logic_vector_arith and the package would look something like the following example:

library ieee;

use ieee.std_logic_1164.all;

package std_logic_vector_arith is

  function carry (a, b, c : std_logic) return std_logic;

  function sum (a, b, c : std_logic) return std_logic;

  procedure full_adder (a, b, c : in std_logic;

                        s, cout : out std_logic);

end;

The package header declares the subprograms, but not the subprogram bodies. The subprogram bodies are defined in the package body:

package body std_logic_vector_arith is

  function carry (a, b, c : std_logic) return std_logic is

  begin

    return (a and b) or (a and c) or (b and c);

  end;

  function sum (a, b, c : std_logic) return std_logic is

  begin

    return a xor b xor c;

  end;

  procedure full_adder (a, b, c : in std_logic;

                        s, cout : out std_logic) is

  begin

    s := sum (a, b, c);

    cout := carry (a, b, c);

  end;

end;

The separation of the package header and the package body is similar to the separation of an entity and an architecture. It means that the interface is separate from the contents. This makes it possible to make modifications to the contents of the package body without affecting the interface. Probably more significantly for users of packages, it makes the package more readable.

Note the difference between the subprogram declaration and the subprogram body. The subprogram declaration is:

function carry (a, b, c : std_logic) return std_logic;

Note the semi-colon immediately after the return type. For procedures, the semi-colon follows the close parenthesis:

procedure full_adder (a, b, c : in std_logic;

                      s, cout : out std_logic);

The subprogram bodies are distinguished by the fact that the semi-colon is replaced with the keyword is:

function carry (a, b, c : std_logic) return std_logic is ...

procedure full_adder (a, b, c : in std_logic;

                      s, cout : out std_logic) is ...

11.6.3 Using Packages

Having declared a package like that above, it can be used by placing a use clause before the design unit where the package is needed. In fact, there are a number of places where the use clause can go, but the only common placing is before the design unit. Thus, to finish off the first example of the use of the carry function in this chapter, here's the same VHDL but with the use clause added:

library ieee;

use ieee.std_logic_1164.all;

entity carry_example is

  port (a, b, c : in std_logic;

        cout : out std_logic);

end;

use work.std_logic_vector_arith.all;

architecture behaviour of carry_example is

begin

  cout <= carry(a, b, c);

end;

In this case, the use clause is before the architecture, because the architecture was where the package was needed. If there was a type defined in the package and that type was to be used in the interface, then the use clause would have been placed before the entity.

The make-up of the use clause needs some clarification. The example has a three-part use clause work.std_logic_vector_arith.all.

The first part of the use clause, in this example work, refers to the library that the package is to be found in. The keyword work refers to the current working library – that is, the library that the architecture itself is being compiled into. Library work can always be referred to in a use clause, but other libraries can only be referred to if there is also a library clause declaring the existence of another library. In fact, there is, in effect, an implicit library clause for library work before every design unit. The library clause looks like:

library work;

Since this is implicit, there is never any need for a library clause for the work library, but all other libraries must be declared like this.

The second part of the use clause is the name of the package, std_logic_vector_arith. This is fairly self-explanatory. It makes that package available for use in the design unit.

The third part of the use clause, in this case all, is the item or items in that package that are to be made available for use in the design unit. The keyword all makes everything in the package available for use. It would be possible to specify individual subprograms, but this is rarely done in practice and it is very rare indeed to see any other form of use clause.

11.7 Worked Example

To give a realistic illustration of operator overloading, consider the situation where a package defining a new type is to be written, which models complex numbers as arrays of std_logic.

The complex number representation will use the left half of the array as the real part and the right half as the imaginary part. The array will be constrained to have a length that is even such that the two parts are always the same length.

The first stage is to create a skeleton package declaration containing the type definition:

library ieee;

use ieee.std_logic_1164.all;

package complex_std is

  type complex is array (natural range <>) of std_logic;

end;

This defines an unconstrained array of std_logic called complex. Note that this is not the same as std_logic_vector, it is a completely new and separate type.

By declaring the type, a set of built-in operators will automatically become available for use on this type. The built-in operators for this type, referring back to Table 11.1, are the equality, ordering and concatenation operators. That is, the following operators will automatically be generated by the VHDL analyser:

equality: =, /=

ordering <, <=, >, >=

concatenation &

More specifically, the following functions will be automatically declared:

function "=" (l, r : complex) return boolean;

function "/=" (l, r : complex) return boolean;

function "<" (l, r : complex) return boolean;

function "<=" (l, r : complex) return boolean;

function ">" (l, r : complex) return boolean;

function ">=" (l, r : complex) return boolean;

function "&" (l, r : complex) return complex;

function "&" (l : std_logic; r : complex) return complex;

function "&" (l : complex; r : std_logic) return complex;

function "&" (l : std_logic; r : std_logic) return complex;

However, the boolean and arithmetic operators will not be predefined:

boolean: not, and, or, nand, nor, xor

arithmetic: **, abs, *, /, mod, rem, sign +, sign -, +, -

The concatenation operators are fine as they are, since they can be used to build up a complex number from its component parts. There are in fact four concatenation operators, one to concatenate a complex with a complex, one to concatenate a complex with a std_logic, one to concatenate a std_logic with a complex and one to concatenate two std_logic. All of them create a complex.

The problem areas lie with the predefined equality and ordering operators and with the lack of boolean and arithmetic operators.

The problem with the equality operators is that the predefined equality for arrays do not give the correct ordering for any numeric types, including the complex type. The problem lies with arrays of different lengths, which are not equal according to the rules for array comparisons, regardless of their values. This means that leading zeros are significant in the comparison. To resolve this problem, the equality and the inequality operators will have to be overloaded for type complex.

The ordering operators also cause problems. There is no sensible definition of the ordering operators for type complex, and yet they are implicitly defined by the language. The best response is to overload them with operators that raise errors if called.

Also, the type does not have boolean and arithmetic operators, so these will also be added. All the boolean operators will be added in this way, plus the set of generally synthesisable arithmetic operators, these being the sign "-", abs, "+", "-" and "*" operators.

Finally, it will be useful to be able to break complex numbers up into real and imaginary parts and to put the parts back together again. It will also be useful to be able to change the precision of a complex number, so that, for example, a 16-bit complex number can be converted into a 24-bit complex number. A set of utility functions will be defined to do these jobs.

All the internal behaviour is going to be defined in terms of the integer arithmetic defined in the numeric_std package. It is always good practice to build up in layers using existing packages in this way, rather than re-inventing basic operations, in the same way as it is good practice to reuse hardware components. The utility functions for assembling and disassembling type complex will use type signed in numeric_std to represent the real and imaginary parts.

The first stage is to add the function declarations of those operators that are to be overloaded to the complex_std package. These should be declared after the complex type, but can appear in any order. Also, the utility function declarations can be added.

library ieee;

use ieee.std_logic_1164.all;

use ieee.numeric_std.all;

package complex_std is

  type complex is array (natural range <>) of std_logic;

  function "not" (r : complex) return complex;

  function "and" (l, r : complex) return complex;

  function "or" (l, r : complex) return complex;

  function "nand" (l, r : complex) return complex;

  function "nor" (l, r : complex) return complex;

  function "xor" (l, r : complex) return complex;

  function "=" (l, r : complex) return boolean;

  function "/=" (l, r : complex) return boolean;

  function "<" (l, r : complex) return boolean;

  function "<=" (l, r : complex) return boolean;

  function ">" (l, r : complex) return boolean;

  function ">=" (l, r : complex) return boolean;

  function "-" (r : complex) return complex;

  function "abs" (r : complex) return complex;

  function "+" (l, r : complex) return complex;

  function "-" (l, r : complex) return complex;

  function "*" (l, r : complex) return complex;

  function real_part (arg : complex) return signed;

  function imag_part (arg : complex) return signed;

  function create (R, I : signed) return complex;

  function resize (arg : complex; size : natural) return complex;

end;

The library and use clause make the packages std_logic_1164 and numeric_std available for use in the interface to this package. It also makes the packages available for use in the package body, since package bodies inherit use clauses from their own package header.

The final stage is to write the package body, containing the function bodies for these operators. To keep things clear, the function bodies have been separated out from the package body so that each can be discussed individually, but in practice they would appear where the ellipsis (…) is.

package body complex_std is

  ...

end;

The first function to be examined is a purely local function for calculating the maximum of two integers. This function will be used inside the package for calculating the lengths of results. The purpose of this function will become clear when it is used.

function max (l, r : natural) return natural is

begin

  if l > r then

    return l;

  else

    return r;

  end if;

end;

The first set of functions to be examined are the utilities create, real_part, imag_part and resize since these will be used by the operators.

The create function takes two elements of type signed and concatenates them to form a complex. To make a complex, both real and imaginary parts must be of equal length. There is a design choice here as to what happens if the two arguments are not the same size. Either an error could be raised, or the arguments could be normalised to the same size. The more flexible solution is the latter, so this is the one that has been used. This means that the complex returned from the function is twice the length of the longer of the real and imaginary arguments to the function. In practice, the most common usage will be with arguments of the same size, so the result will be the simple concatenation of the arguments.

function create (R, I : signed) return complex is

  constant length : natural := max(R'length,I'length);

  variable R_int, I_int : signed (length-1 downto 0);

begin

  R_int := resize(R, length);

  I_int := resize(I, length);

  return complex(R_int & I_int);

end;

The resize functions here are resizing type signed from numeric_std, then the result is formed by concatenating the two signed numbers (using the concatenation operator for signed again) and then type converting the result to complex. In fact it can be written as a single expression:

function create (R, I : signed) return complex is

  constant length : natural := max(R'length,I'length);

begin

  return complex(resize(R, length) & resize(I, length));

end;

The real_part and imag_part functions perform simple slice operations on the argument. They also check the rule that complex numbers are of even length. Finally, they convert the result to type signed.

function real_part (arg : complex) return signed is

  variable arg_int : complex (arg'length-1 downto 0) := arg;

begin

  assert arg'length rem 2 = 0

     report "complex.real_part: argument length must be even"

     severity failure;

  return signed(arg_int(arg_int'length-1 downto arg_int'length/2));

end;

function imag_part (arg : complex) return signed is

  variable arg_int : complex (arg'length-1 downto 0) := arg;

begin

  assert arg'length rem 2 = 0

     report "complex.imag_part: argument length must be even"

     severity failure;

  return signed(arg_int(arg_int'length/2-1 downto 0));

end;

Notice how the argument in each case is normalised in order to make the rest of the function simpler to write. However, it does mean that the return value of the real_part function is offset, which might be undesirable. It is good practice to stick to the normalisation convention for all functions, so the function needs a normalised intermediate variable to store the result.

function real_part (arg : complex) return signed is

  variable arg_int : complex (arg'length-1 downto 0) := arg;

  variable result : signed(arg'length/2-1 downto 0);

begin

  assert arg'length rem 2 = 0

     report "complex.real_part: argument length must be even"

     severity failure;

  result :=

     signed(arg_int(arg_int'length-1 downto arg_int'length/2));

  return result;

end;

The resize function changes the size of a complex argument and returns the result. It is defined in terms of the numeric_std resize function and the three utilities just defined.

function resize (arg : complex; size : natural) return complex is

begin

  assert size rem 2 = 0

     report "complex.resize: size must be even"

     severity failure;

  return create (resize(real_part(arg),size/2),

                 resize(imag_part(arg),size/2));

end;

This function turns out to be quite simple, since it uses existing functions from both package complex_std and package numeric_std. It creates a complex from two signed numbers, each of which is created by resize from numeric_std, acting on the real and imaginary parts of the argument. The assertion enforces the rule that complex numbers must be of even length by preventing the user from specifying an odd size. The length of the complex argument will be checked when it is passed to the real_part and imag_part functions so does not need an additional assertion.

Bear in mind once again that the resize function from numeric_std has unexpected behaviour when truncating in that the sign is preserved, whereas the normal convention is to simply truncate by discarding the most significant bits and allowing the result to wrap round if the truncation causes overflow. If the normal convention is the desired behaviour, then the function should be rewritten without the use of the resize function calls.

The boolean operators are quite simple, in that they are implemented by simply applying the operator to each element in turn. The common convention, used in both std_logic_1164 and the numeric packages, is that boolean operators take two arguments of the same size and return a result of that size. They do not allow different length arguments. In the case of the not operator, there is only one argument of course. In this case, I will follow these conventions but add an extra rule, which is that the return value will have a normalised range.

The not operator is:

function "not" (arg : complex) return complex is

  variable result : complex(arg'length-1 downto 0) := arg;

begin

  for i in result'range loop

    result(i) := not result(i);

  end loop;

  return result;

end;

The not operator used within the loop is that for type std_logic, so the not of a type complex is defined in terms of the logical behaviour of its element type – again this is good practice.

The binary operators are similar, but there are two parameters and there must be an assertion to enforce the rule that they must be of equal length.

function "and" (l, r : complex) return complex is

  variable l_int : complex(l'length-1 downto 0) := l;

  variable r_int : complex(r'length-1 downto 0) := r;

  variable result : complex(l'length-1 downto 0);

begin

  assert l'length = r'length

    report "complex_std: ""and"" arguments are different lengths"

    severity failure;

  for i in result'range loop

    result(i) := l_int(i) and r_int(i);

  end loop;

  return result;

end;

All the other boolean operators are written in exactly the same way, with the appropriate element operator used inside the loop. All of the boolean operators could have an extra assertion to check the even-length rule if it was considered desirable.

Many of the other operators are layered onto numeric_std in the same way. For example, the equality function is true if both the real parts and the imaginary parts are equal. The parts of the complex number are of type signed, so the equality for signed will be used for the comparison. The equality operator for type signed is written to compare operands of different lengths and to take into account leading zeros, so suits our purposes exactly.

function "=" (l, r : complex) return boolean is

begin

  return real_part(l) = real_part(r)

         and

         imag_part(l) = imag_part(r);

end;

The inequality function is best defined in terms of the equality function:

function "/=" (l, r : complex) return boolean is

begin

  return not (l = r);

end;

The ordering operators are required to give errors if called. This is done with an assertion that will always fail.

function "<" (l, r : complex) return boolean is

begin

  assert false

     report "complex_std:""<"" illegal operation"

     severity failure;

  return false;

end;

The return statement after the assertion is still needed, even though the assertion will stop a simulation, because most VHDL analysers will insist that a function has at least one return statement in it. If the return statement was missing, the package would not even compile successfully.

All the other ordering operators are written in the same way.

Finally, the arithmetic operators for sign "-", abs, "+", "-" and "*" are defined using the arithmetic operators from numeric_std and will use the same conventions. This means that the sign "-" and abs operators will return a result that is the same length as the argument, the "+" and "-" operators will return a result that is the length of the longer operand, and the "∗" operator will return a result that is the sum of the lengths of the arguments.

function "-" (r : complex) return complex is

begin

  return create(-real_part(r), -imag_part(r));

end;

function "abs" (r : complex) return complex is

begin return create(abs real_part(r), abs imag_part(r));

end;

function "+" (l, r : complex) return complex is

begin

  return create(real_part(l)+real_part(r),

                imag_part(l)+imag_part(r));

end;

function "-" (l, r : complex) return complex is

begin

  return create(real_part(l)-real_part(r),

                imag_part(l)-imag_part(r));

end;

function "*" (l, r : complex) return complex is

begin

  return

    create(

      real_part(l)*real_part(r)-imag_part(l)*imag_part(r),

      real_part(l)*imag_part(r)+imag_part(l)*real_part(r));

end;

An important point about this package is that, nowhere in it are there definitions of how to actually perform arithmetic, or even how to perform bitwise logic. The predefined and, presumably, well tested packages std_logic_1164 and numeric_std have been used to provide the boolean and arithmetic operations throughout.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.188.66.13