Part 2: Basic Syntax

The basic building blocks and how to use them.

The previous part of this series discussed how to set up a project with Gatery but didn't look too deeply into the actual logic construction. This part of the series will do the opposite: Ignore the project structure and workflow and instead focus on the actual RTL design.

Signals

Gatery supports two fundamental types of signals: Bit and BVec. Bit, as the name suggests, conveys a single bit much like a std_logic in VHDL. Unlike VHDL, it is also the go-to type for the result of boolean expressions. That is, there is no differentiation between bits and boolean expressions when it comes to signals. BVec is a bit vector of arbitrary size (including size 1). It can be compared to the std_logic_vector of VHDL and conveys words, numbers, etc. Unlike VHDL, there is no differentiation between BVec and unsigned integers. All BVecs have unsigned integer semantic and there is no dedicated unsigned type for signals.

Signals are the most fundamental concept in RTL design. They convey logical information from component to component and are the "variables" of RTL-design. However, unlike variables in software, they may actually result in wires on a chip and thus usually have a immutable semantic (not in Gatery though). That is, signals behave like mathematical variables: A signal can be assigned one value (or driver) and every read of the signal, independent of the order of statements, yields that value. Variables on the other hand have the mutable semantic of variables in programming languages. They can be assigned multiple times and always yield the value of the previous assignment in statement order.

Note that in Gatery, all signals are mutable. But more on that later.

Bit and BVec are implemented in Gatery as classes with various methods and overloaded operators. From these classes, objects can be instantiated to create signals.

#include <gatery/frontend.h>
// ...
using namespace gtry;
Bit my_undefined_bit;
BVec my_undefined_bvec;

These objects are ordinary C++ variables and in fact, the syntax for instantiation is the same as for instantiating ordinary integer variables. To distinguish in this tutorial between the actual wire on the chip and the C++ representation of it as an object, the rest of the tutorial series will refer to the former as the "signal" and to the latter as the "variable".

Instantiations in C++ are not limited to a specific code region but can happen almost anywhere in the code. It is considered good style to instantiate as close as possible to where the instance is used and in as narrow a scope as possible.

BVecs need a width which can be set through one of the many possibilities to initialize an object in C++ or through a later assignment. To differentiate between setting the width of a BVec and assigning an integer literal, the width must always be given as an instance of BitWidth or as a bitwidth literal, an integer literal with the _b postfix.

// ...
BVec undefined_8_wide_BVec = 8_b;
BVec undefined_10_wide_BVec = BitWidth(10);
BVec undefined_12_wide_BVec(12_b);
BVec undefined_16_wide_BVec;
undefined_16_wide_BVec = BitWidth(16);

By default, a signal is undriven and has the value "undefined" in the simulation.

An undefined state, much like undefined behavior in C++, is a state that is not strictly speaking random, but can not be relied on. It can arise when a registers is not being reset to a defined state on powerup, when accessing uninitialized (simulated) memory, or simply by a signal not being driven by anything.

Undefined states are, however, not a bad thing per se. It is completely ok for part of a circuit to operate on undefined values in a read only fashion as long as the output is ignored by the rest of the circuit. In fact, proper resetting and enabling/disabling can often cost more hardware resources than simply only resetting a control-path and letting a wider data-path run its course.

To deal with undefined states, the fact that a signal is undefined is modeled as an explicit state in the simulator and propagated through the digital circuit, like NAN/INF floating point numbers in a numerical computation. This ensures that undefied values in important signals do not go unnoticed in the simulation.

Operators

Assignment & Literals

Bit and BVec implement the assignment operator such that the right hand signal drives the left hand signal.

// ...
Bit driving_bit;
Bit driven_bit;
driven_bit = driving_bit;
BVec driving_bvec = 8_b;
BVec driven_bvec;
driven_bvec = driving_bvec;

Signals can be assigned constants, usually as literals. In the case of Bit, these literals can be given as char literals as follows:

// ...
Bit b;
b = '1'; // true
b = '0'; // false
b = 'X'; // undefined

To facilitate interaction with C++, bool values can also be assigned:

// ...
Bit b;
// Assigning bool literals
b = true;
b = false;

This, by extension, allows to assign the result of a boolean expression, such as one arising from a potential configuration.

// ...
int configuration_parameter = ...;
Bit b;
// Assigning construction-time constant
b = configuration_parameter == 42;

Note, that this boolean expression, since it is using C++ variables, is evaluated during execution of the generator program. Its result is baked as a constant into the RTL-design.

BVecs can similarly be assigned constant literals in the form of string literals. A leading number prefixed to the string literal may specify the width of the resulting value, which can be wider than the given bits with the missing bits defaulting to zero. In addition, the prefix of the string literal must indicate the used base (b for binary, d for decimal or x for hex).

// ...
BVec bv_1, bv_2, bv_3, bv_4, bv_5;
bv_1 = "b1010"; // Binary, 4_b wide
bv_2 = "xff0f0"; // Hex, 20_b wide
bv_3 = "d42"; // Decimal, 6_b wide
bv_4 = "64b0"; // 64 zero bits
bv_5 = "6b00xx00"; // Mixture of zeros and undefined bits

Analogous to Bit, BVec allows assignment of C++ integer types. If the BVec was not set to a specifc width before, the width will be inferred from the assigned (unsigned) integer. Do note that similarly to the Bit case, all computations done with regular C++ variables are performed during construction time and have their results hard baked as constants into the RTL design.

// ...
BVec bv_1, bv_2, bv_3;
bv_1 = 32_b;
bv_1 = 42; // Still 32_b wide
bv_2 = 42; // 6_b wide
unsigned configuration_option = ...;
bv_3 = 10_b;
bv_3 = configuration_option; // 10_b wide

The width of a BVec can querried via the .getWidth() member function. This allows writing components that react programmatically to different input sizes. The similar .size() member function also returns the width, but as a regular integer instead of a BitWidth type, so care must be taken to not accidentally initialize a signal with the width as a constant integer instead of resizing it.

#include <iostream>
// ...
BVec bv;
bv = "d42";
// Prints "The width of bv is 6" to the console
std::cout
<< "The width of bv is " << bv.getWidth()
<< std::endl;

So far we have only looked at assignment and, except for the signal to signal assignment, all assigned values were constant or at least computed at construction time and thus constant with respect to the digital circuit. In the following, we will look at ways to actually do computations or operations as part of the RTL design.

Logical Operations

The Bit class overloads the common operators for logical operations. These work as usual in C/C++, and with the same operator precedence.

// ...
Bit a, b;
a = ...;
b = ...;
// Logical and bitwise negation both do the same
Bit not_a = ~a;
Bit also_not_a = !a;
// And, or, xor as usual
Bit a_and_b = a & b;
Bit a_or_b = a | b;
Bit a_xor_b = a ^ b;
// Composition and bracketing as usual
Bit a_nand_b = ~(a & b);
Bit a_nor_b = ~(a | b);

The BVec class does the same and performs the operations element-wise. The operands must have the same size, there is no implicit resizing. Any two-operand operation on BVecs of different sizes is reported as an error.

// ...
BVec a = 8_b;
BVec b = 8_b;
a = ...;
b = ...;
// Logical and bitwise negation both do the same
BVec not_a = ~a;
BVec also_not_a = !a;
// And, or, xor as usual
BVec a_and_b = a & b;
BVec a_or_b = a | b;
BVec a_xor_b = a ^ b;
// Composition and bracketing as usual
BVec a_nand_b = ~(a & b);
BVec a_nor_b = ~(a | b);
BVec c = 5_b;
c = ...;
// Illegal because c is 5 bits and a is 8 bits
// BVec illegal = a & c;

Note that this does not hold for operations involving Bit and BVec. A logical operation between a BVec and Bit always implicitly broadcasts the Bit to every bit in the BVec.

// ...
BVec a = 8_b;
a = ...;
// Whether or not to negate a
Bit do_negate_a = ...;
// xor every bit in a with do_negate_a
BVec possibly_negated_a = a ^ do_negate_a;

Extending vectors

In order to combine BVecs of different sizes in an operation they must be brought to the same size, usually by extending the smaller vector. The options for which gatery provides conveniance functions are zero-extension, one-extension, and sign-extension (duplicating the most significant bit). Single Bits can similarly be extended into BVecs.

Signed integers are often represented using two's complement. For 3-bit numbers, the following shows the possible signed decimal integers and their binary representation:

0 b000
1 b001
2 b010
3 b011
-4b100
-3b101
-2b110
-1b111

As can be seen, the most significant bit indicates the sign. However, care must be taken when extending the bit width to not modify the interpretation. Positive numbers must be extended with zeros while negative numbers must be extended with ones. Hence, extending by duplicating the sign (or most significant) bit is a common operation when the interpretation of the underlying data is a signed integer.

// ...
BVec unsigned_8_wide = 8_b;
unsigned_8_wide = ...;
// Zero extend unsigned integer
BVec unsigned_10_wide = zext(unsigned_8_wide, 2);
BVec signed_8_wide = 8_b;
signed_8_wide = ...;
// Sign extend integer
BVec signed_10_wide = sext(signed_8_wide, 2);
BVec mask_8_wide = 8_b;
mask_8_wide = ...;
// Extend with ones
BVec mask_10_wide_one_extended = oext(mask_8_wide, 2);

The shown form of zext, oext, and sext expects the number of bits by which to enlarge as an argument. This can be cumbersome to compute, especially if two operands are simply to be brought to the same size. Since the latter scenario is very common, gatery provides overloaded versions of the extension functions that resize to the necessary width of the following two-operand operation:

// ...
BVec a = 10_b;
a = ...;
BVec b = 8_b;
b = ...;
// This would be illegal because a nd b have different sizes:
// BVec c = a & b;
// This zero-extends b to the width of a (10-bits) and then performs the element wise or
BVec a_or_b = a | zext(b);
// The same works for sext and oext.
BVec a_and_b = a & oext(b);

Integers and integer literals implicitly behave as if they were zexted, that is, they are always zero extended:

// ...
BVec a = 4_b;
a = 0; // zero-extended to b0000
BVec a_or_0001 = a | 1; // 1 is zero-extended to b0001
unsigned int i = 2;
BVec a_and_b = a & i; // i is zero-extended to b0010

Rewiring Operations

Individual bits and subranges can be sliced from a BVec.

// ...
BVec ieee_float_32 = 32_b;
ieee_float_32 = ...;
BVec mantissa = ieee_float_32(0, 23); // Extract 23 bits from bit 0 onwards
BVec exponent = ieee_float_32(23, 8); // Extract 8 bits from bit 23 onwards
Bit sign = ieee_float_32[31]; // Extract bit 31

Indices can be negative to index from the end. For the most significant and least significant bits, special functions are provided. In addition, the bits of a BVec can be iterated over.

// ...
BVec bvec = ...;
// Least and most significant bits, independent of size of bvec
Bit bvec_lsb_1 = bvec[0];
Bit bvec_msb_1 = bvec[-1];
// Least and most significant bits, independent of size of bvec
Bit bvec_lsb_2 = bvec.lsb();
Bit bvec_msb_2 = bvec.msb();
// Iterating over each bit in bvec in turn
for (auto &b : bvec) {
do_sth_with_bit(b);
}

To concatenate multiple Bit or BVec, the variadic pack(...) function can be used:

A variadic function has no fixed number of parameters. Arbitrary amounts and, in this case, types of parameters can be passed to the function.

// ...
BVec mantissa = 23_b;
mantissa = ...;
BVec exponent = 8_b;
exponent = ...;
Bit sign = ...;
// Concatentates all arguments, putting the last
// argument (mantissa) into the least significant bits.
BVec ieee_float_32 = pack(sign, exponent, mantissa);

Similarly to C/C++ integers, BVecs can be bit-shifted. Left-shifting inserts zeros on the LSB side and drops the shifted-out bits. The bit width remains the same. Right-shifting similarly inserts zeros on the MSB side and drops the shifted-out bits.

// ...
BVec value = 10_b;
value = ...;
BVec value_times_4 = value << 2;
BVec value_div_4 = value >> 2;

Instead of dropping the shifted-out bits, they can also be reinserted. This is commonly referred to as rotating the bitstring.

// ...
BVec value = 10_b;
value = ...;
BVec value_rotated_left_2 = rotl(value, 2);
BVec value_rotated_right_2 = rotr(value, 2);

Arithmetic Operations

Gatery also supports rudimentary arithmetic operations, most importantly addition and subtraction. Like logic operations, arithmetic operations require both operands to be of the same size or be explicitly zero/one/sign-extended. Analogous to C/C++, the result has the same bit width as the operands. More precisely, it is the less significant part of the result.

// ...
BVec a = 23_b;
a = ...;
BVec b = 23_b;
b = ...;
BVec a_plus_b = a + b; // Exported to VHDL as a+b
BVec a_minus_b = a - b; // Exported to VHDL as a-b
BVec a_times_b = a * b; // Exported to VHDL as a*b so your mileage may vary
BVec a_div_b = a / b; // Exported to VHDL as a/b so your mileage may vary

As of now, arithmetic operations are represented as abstract operations without a specific implementation and are also exported to VHDL as the corresponding operations. We will show in a later example how to use explicit addition implementations.

Comparison Operations

Gatery also supports all common comparison operations. Again, both operands must be of the same bit width and the comparison does not imply a specifc implementation but rather is forwarded to VHDL. The result of a comparison is always a Bit.

// ...
BVec a = 10_b;
a = ...;
BVec b = 10_b;
b = ...;
// Less / greater
Bit a_lt_b = a < b;
Bit a_gt_b = a > b;
// Less or equal / greater or equal
Bit a_le_b = a <= b;
Bit a_ge_b = a >= b;
// Equal / not equal
Bit a_eq_b = a == b;
Bit a_ne_b = a != b;

Do keep in mind that the assumed semantic of BVec is that of unsigned integers. Thus the "less than" and "greater than" comparisons don't work on signed, negative numbers as they are simply interpreted as unsigned positive numbers.

Misc Operations

For building (large) multiplexers, gatery has a dedicated function that selects one input from a number of inputs based on a BVec index.

// ...
BVec idx = 2_b;
idx = ...; // Can be anything from 0..3
BVec a_0 = 10_b;
BVec a_1 = 10_b;
BVec a_2 = 10_b;
BVec a_3 = 10_b;
BVec a = mux(idx, {a_0, a_1, a_2, a_3});

Finally, gatery has the concept of IO-pins. IO-pins are either actual IO-pins of the chip/fpga, or signals going to a parent module in case only a sub module is being build with gatery. IO-pins can be generated anywhere in the design, at any level of the module hierarchy (more on how to create modules in gatery later). They are always automatically routed to the top entity when exporting a gatery design to VHDL.

// ...
BVec push_buttons = pinIn(4_b);
Bit single_button = pinIn();
BVec color_led = 3_b;
color_led = ...;
pinOut(color_led);
Bit led = ...;
pinOut(led);

Simultaneous Variable and Signal Behavior

Mutable Signals

Signals in Gatery are actually mutable. That is, they can be written to by multiple statements and reading from them always yields the last written value.

// ...
BVec value = 4_b;
value = 0;
BVec a = value;
value = 1;
BVec b = value;
value = 2;
BVec c = value;
// a is 0, b is 1, c is 2

This is extremely useful when constructing circuits iteratively. New operations can always be "added" simply by modifying an "accumulator".

// ...
BVec value = 10_b;
value = ...;
// Start with true
Bit parity = true;
// Xor all bits together by "accumulating" them
// one by one into the parity.
for (auto &b : value)
parity = parity ^ b;
// Now parity is true iff number of set bits in value is even.

This means that for all operators discussed so far, gatery also implements the customary in-place operators:

// ...
BVec a = 10_b;
a = ...;
BVec b = 10_b;
b = ...;
a &= b; // compute b & a and store in a
a |= b; // compute b | a and store in a
// ...
// Also holds for Bit
a += b; // add b to a and store in a
a -= b; // subtract b from a and store in a
// ...
a <<= 2; // Shift a by 2 bits to the left and store in a
a >>= 2; // Shift a by 2 bits to the right and store in a

When slicing a BVec, the returned handles still reference the original BVec such that parts of it can be modified through the slices.

// ...
BVec ieee_float_32 = 32_b;
// Lets build a 1.0f float
ieee_float_32[31] = '0'; // The sign is positive
ieee_float_32(0, 23) = 0; // The mantissa is all 0 (the "1." is implicit with floats)
ieee_float_32(23, 8) = 127; // The exponent is exactly the bias to end up with 2^0.

Order Independence

Even though signals in gatery are mutable, they still allow construction of components in arbitrary order. This is necessary when a producer and a consumer interact with each other, potentially requiring data and control signals to flow in opposite directions. Specifically, the following is possible:

// ...
BVec data = ...;
// Build consumer first
do_sth_with(data);
// Build producer second
data = ...;

In gatery, this is possible because signals actually build loops. Reading from a signal always yields the value that was last written to it (in statement/construction order). When reading from a signal that was not written to before, the read yields the last value that is written to the signal (again, in statement/construction order).

// ...
BVec value = 4_b;
BVec a = value;
value = 0;
BVec b = value;
value = 1;
BVec c = value;
value = 2;
// a is 2, b is 0, c is 1

Through this mechanism, gatery fuses the semantics of signals and variables into one type. If used like signals (only one write to the signal), they behave exactly like immutable signals in other description languages and the order of reads and writes doesn't matter. If used like variables (multiple writes to the signal), they behave like variables in the sense that each read returns the previous write.

Condition Scopes

Much like programming languages make heavy use of "if" statements to control execution flow, HDLs heavily use "if" to control data flow and gatery is no different in that regard. Through a bit of black C++ vodoo that we are not proud of (actually, we are), the IF statement (note the capitalization) creates condition scopes that behave much like their regular C/C++ counterparts. The logic inside is build, but if it modifies signals that exist outside of the IF scope, a multiplexer is inserted to switch between the modified and unmodified signals based on the condition of the IF. The following:

// ...
BVec value = 4_b;
value = ...;
Bit do_mul_2 = ...;
// Do the multiplication only if do_multiply_two is asserted
IF (do_mul_2)
value <<= 1; // Left shift by one bit to multiply with 2

yields a circuit like this:

Much like with the regular "if", multiple statements can be made conditional by scoping them in { }.

// ...
BVec value = 4_b;
value = ...;
Bit do_mul_2_inc = ...;
IF (do_mul_2_inc) {
value <<= 1; // Left shift by one bit to multiply with 2
value += 1; // Increment
}

The capitalized ELSE statement builds multiplexers with the inverted condition, just as one would expect.

// ...
BVec value = 4_b;
value = ...;
Bit some_condition = ...;
IF (some_condition) {
value <<= 1;
} ELSE {
value += 1;
}

Naturally, these conditional scopes can be nested with the expected behavior.

Structs

Since gatery signals are regular C++ variables, they can be grouped into structs and classes or bundled into containers.

// ...
struct MyFloat {
// Signals
Bit sign;
BVec exponent;
BVec mantissa;
// Meta Information
unsigned biasOffset;
};
int main() {
// ...
MyFloat myFloat;
myFloat.mantissa = ...;

This is somewhat comparable to records in VHDL with two important exceptions: Signals and meta information (regular C++ variables) can reside side by side in a struct. Also, since there are no port maps with explicit input/output directions, signals travelling in opposite directions can be bundled into the same struct.

Certain functions of gatery can directly operate on custom structs. Since C++ lacks introspection (yet), these functions require that the members are annotated such that e.g. gatery functions can enumerate all members. Gatery uses Boost-Hana to achieve this. The following shows how the pack function can reccursively collapse a struct into a single BVec.

// ...
struct MyFloat {
// Signals
Bit sign;
BVec exponent;
BVec mantissa;
// Meta Information
unsigned biasOffset;
// Default constructor that initializes to IEEE754 float
MyFloat() {
mantissa = 23_b;
exponent = 8_b;
biasOffset= 127;
}
};
// This must list all members. It also must be
// outside of any namespaces. See Boost-Hana
// documentation for more details
BOOST_HANA_ADAPT_STRUCT(MyFloat, sign, exponent, mantissa, biasOffset);
int main() {
// ...
MyFloat myFloat;
myFloat.mantissa = ...;
// Packs the struct into one 32-bit word with the
// last member (mantissa) in the least significant bits
BVec packed_float = pack(myFloat);

Whats more, the contents of a BVec can also unpacked back into a struct. It is important to note, that member signals of the target struct must be resized to the correct sizes before unpacking.

// ...
struct MyFloat {
// Signals
Bit sign;
BVec exponent = 8_b;
BVec mantissa = 23_b; // This works like the constructor in the previous example
// Meta Information
unsigned biasOffset = 127;
};
// This must list all members. It also must be
// outside of any namespaces. See Boost-Hana
// documentation for more details
BOOST_HANA_ADAPT_STRUCT(MyFloat, sign, exponent, mantissa, biasOffset);
int main() {
// ...
MyFloat myFloat; // Constructor resizes all members
myFloat.mantissa = ...;
// Packs the struct into one 32-bit word with the
// last member (mantissa) in the least significant bits
BVec packed_float = pack(myFloat);
MyFloat myFloat2; // Constructor resizes all members
// Unpacks the contents of packed_float into the signals of myFloat2
unpack(packed_float, myFloat2);

This mechanism will become important later on again, for naming all members of a struct, registering all members of a struct, or making a memory of structs.

C++ control flow

This might be a good point to reflect on the differences between C++ control-flow and variables vs. gatery "control flow" and signals. Gatery signals (Bit, BVec) and gatery "control flow" (IF, ELSE) end up in the RTL design and will perform computations in the FPGA/chip (unless they are optimzed away). Everything involving C++ variables (int, float, std::vector, ...) or C++ control flow (if, else, for, function-calls, ...) happens at construction time, when the generator program is running.

Evaluated at construction-time Evaluated on chip
if, else IF, ELSE
switch mux(...)
for, goto, function-calls
Operations on C++ variables (int, float, std::string, ...) Operations involving at least one BVec or Bit
Setting the width of a BVec

Unlike in High Level Synthesis, no behavior for the STL-design is inferred from the C++ control flow and variables. The latter does, however, control the generation of the RTL-design. Constants can be computed from configuration parameters, bit widths can be adjusted, components enabled or disabled. In essence, regular C++ is the meta-programming level of gatery. What preprocessor macros are to C, what templates are to C++, and what generics are to VHDL, the entire expressive power of C++ is to gatery.

And from this arises an important difference between hardware construction languages like gatery and hardware description languages like VHDL. The goal of gatery is not to write a hardware description like in VHDL but with different syntax. Instead, the goal is to write algorithms, programs in fact, that compose the hardware programatically based on configuration options, where the configuration options are much more diverse than simple bit widths.

While the full power of this really shines with large designs, we want to demonstrate this on a small toy example: a carry safe adder.

A carry safe adder is a special type of adder for more than two summands. The naive way of adding multiple summands is to add them one after another, each time again resolving the carry chain.

This leads to unpleasantly long signal paths. The idea of the carry safe adder is to not resolve the carry chain for each individual addition. Each carry bit is not fed into the adder of the next bit, but into the adder of the next bit of the next addition.

Let us start sketching the logic without any regard for structure for now. The input is an array of summands.

// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;

The carry safe adder consists essentially of many full adders, each ingesting the running sum, the running carry, and the next summand and outputing a new sum and a new carry. Thus we need two BVecs, one to accumulate the sum and one for the carries. The final step is to resolve the carry chain by adding the carries to the sum.

// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;
BVec sum;
BVec carry;
// magic
BVec result = sum + carry;

Note that if only two summands exist, the carry safe adder doesn't make much sense and we can directly use the normal addition. What's more, if only one summand exists, no additions need to be performed at all.

// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;
BVec result;
if (summands.size() == 1) {
result = summands[0];
} else if (summands.size() >= 1) {
BVec sum = summands[0];
BVec carry = summands[1];
// magic
result = sum + carry;
}

To add the third summand, we need to implement the actual full adder. Note how we exploit the mutable nature of gatery signals to update the existing sum and carries.

// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;
BVec result;
if (summands.size() == 1) {
result = summands[0];
} else if (summands.size() >= 1) {
BVec sum = summands[0];
BVec carry = summands[1];
// One full adder step to add summands[2]
BVec new_carry = (sum & carry) | (sum & summands[2]) | (m_carry & summands[2]);
sum ^= carry ^ summands[2];
carry = new_carry << 1;
result = sum + carry;
}

This only adds the first three summands. Luckily for the additional summands, we need to do exactly the same which, thanks to the mutable signals, can be easily done in a loop.

// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;
BVec result;
if (summands.size() == 1) {
result = summands[0];
} else if (summands.size() >= 1) {
BVec sum = summands[0];
BVec carry = summands[1];
// One full adder step per additional summand
for (unsigned i = 2; i < summands.size(); i++) {
BVec new_carry = (sum & carry) | (sum & summands[i]) | (m_carry & summands[i]);
sum ^= carry ^ summands[i];
carry = new_carry << 1;
}
result = sum + carry;
}

We now have a carry safe adder. The Right Thing To Do now would be to test it. However, we will cover simulation and testing in a later tutorial part (after we have covered clocks and registers), so let's not get ahead of ourselves. Instead, we want to structure it more nicely.

We want to encapsulate the details of the adder and make it easy to (re)use. We also don't want to force the user to arrange all the summands into an std::vector. We move the BVecs into a class and refactor the code into individual member functions.

// ...
class CarrySafeAdder {
public:
void add(const BVec &b) {
if (m_count == 0)
m_sum = b;
else if (m_count == 1)
m_carry = b;
else {
BVec new_carry = (m_sum & m_carry) | (m_sum & b) | (m_carry & b);
m_sum ^= m_carry ^ b;
m_carry = new_carry << 1;
}
m_count++;
}
BVec sum() {
if (m_count <= 1)
return m_sum;
return m_sum + m_carry;
}
protected:
unsigned m_count = 0;
BVec m_carry;
BVec m_sum;
};
int main() {
// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;
// Just to demonstrate the usage, tying in here with the previous std::vector
CarrySafeAdder adder;
for (const auto &b : summands)
adder.add(b);
BVec result = adder.sum();

Since C++ allows operator overloading, we can make it even nicer and additionally bind those methods to the add and cast operators.

// ...
class CarrySafeAdder {
public:
void add(const BVec &b) {
// snip
}
BVec sum() {
// snip
}
CarrySafeAdder operator + (const BVec& b) { CarrySafeAdder ret = *this; ret.add(b); return ret; }
CarrySafeAdder& operator += (const BVec& b) { return add(b); }
operator BVec () const { return sum(); }
protected:
unsigned m_count = 0;
BVec m_carry;
BVec m_sum;
};
int main() {
// ...
// Assume resized and all of equal width
std::vector<BVec> summands = ....;
// Just to demonstrate the usage, tying in here with the previous std::vector
CarrySafeAdder adder;
for (const auto &b : summands)
adder += b;
BVec result = adder;

With these additions, using the carry safe adder can be as simple as

// ...
BVec a, b, c, d;
a = ...;
b = ...;
c = ...;
d = ...;
BVec result = CarrySafeAdder{} + a + b + c + d;

And there you have it. A carry safe adder that adapts seamlessly to different bit widths and numbers of summands. In designs that use the carry safe adder, the entire invocation is a simple line of additions that immediately communicates to the reader the desired intent.

This particular design is actually part of the standard component library of gatery (more on that later), so it can be just used, freely, and without reimplementing it.

Structuring the Export

The exported VHDL code is, at the end of the day, machine generated code and as such will never be on the same level as hand written VHDL code. But depending on the workflow and tools, it can be important to actually look at the VHDL code and to have some structure and stability in it. Gatery allows some level of control over the generated code, specifically over the signal naming and the formation of entities.

Signal Naming

The signals that are most likely to interact with hand written VHDL code are the IO-pins and clocks/resets (the latter are discussed in a later part of this tutorial). These allow explicitly setting a desired name (identifier) that will be used for the signal in the top level entity. If these desired names are unavailable (because they clash with a VHDL keyword or the identifier of a different signal), gatery will postfix them with increasing numbers to ensure that the VHDL code is still valid.

// ...
BVec push_buttons = pinIn(4_b).setName("push_buttons");
Bit single_button = pinIn().setName("that_single_button_that_is_all_alone");
BVec color_led = 3_b;
color_led = ...;
pinOut(color_led).setName("led");
Bit led = ...;
pinOut(led).setName("led"); // Clashes with previous pinOut, one of them will be renamed to led_2

Signals can also be given explicit names with a similar behavior (name clashes are resolved by postfixing with numbers). Signals have the same .setName() member function that allows setting an arbitrary name. A special preprocessor macro HCL_NAMED can be used to automatically set the C++ identifier as the desired name. It is important to note that the given name is always associated to that current state of the signal.

// ...
BVec v = 32_b;
v = ...;
// (try to) name the current state of a "initial_v" in the VHDL code
v.setName("initial_v");
v += 2;
// (try to) name this new, incremented state of v "modified_v" in the VHDL code
v.setName("modified_v");
BVec some_other_vector = v;
// (try to) name some_other_vector "some_other_vector" in the VHDL code
HCL_NAMED(some_other_vector);

Structs, if they have been annotated with boost-hana, can be named in the same fashion:

// ...
struct MyFloat {
// Signals
Bit sign;
BVec exponent;
BVec mantissa;
// Meta Information
unsigned biasOffset;
};
// This must list all members. It also must be
// outside of any namespaces. See Boost-Hana
// documentation for more details
BOOST_HANA_ADAPT_STRUCT(MyFloat, sign, exponent, mantissa, biasOffset);
int main() {
// ...
MyFloat myFloat;
myFloat = ...;
HCL_NAMED(myFloat);
// myFloat.mantissa will now on export be named "myFloat_mantissa", etc.

This even works for signals in containers:

#include <vector>
// ...
int main() {
// ...
std::vector<Bit> allTheBits;
allTheBits.resize(42);
allTheBits[0] = ...;
// ...
HCL_NAMED(allTheBits);
// allTheBits[0] will now on export be named "allTheBits_0", etc.

Sometimes, the export needs to instantiate VHDL-signals (or variables) which have not been named explicitely. In this case, the name is composed of the names and operations out of which the signal is composed. However, it can be tricky to relate the name back to the gatery code, so if in doubt, proper naming is to be preferred.

Modules/Entities

By default, the VHDL export will create one top level entity with one combinatorial process (and one clocked process per clock and reset configuration, more on clocks later). That is, everything is combined into one entity, which can be extremely cumbersome when synthesis errors or timing reports refer to specific signals or paths.

RTL-designs are usually structured as a tree of entity instantiations. Each instantiation of a sub entity is given an instantiation name to differentiate between different instantiations of the same entity. When a tool refers to a specifc signal (or path of signals), the concatenation of the signal's name and all instance names leading up to it is concatenated to yield a unique and easy to follow identifier. This can be thought of like a semi-manual stack trace: It not only specifies which line of code is the culprit, but also how it got to be there.

Gatery allows grouping logic using GroupScopes. Similarly to the DesignScope, everything that happens is always added to the innermost, most recent GroupScope that is active.

// ...
// Some logic (or just rewiring) that will end up in top level entity
BVec vec = 4_b;
vec = ...;
BVec shift_amount = 2_b;
shift_amount = ...;
BVec shifted_vec;
// Built a dynamic bit shifter that will end up in it's own sub-entity
{
// This creates the sub-entity
GroupScope group(GroupScope::GroupType::ENTITY);
// All logic that is build while the variable "group" exists
// (and is not superseeded by another GroupScope) will end up in the sub-entity.
// vec and shift_amount automatically become inputs of the sub-entity.
std::vector<BVec> shifted;
for (auto i : utils::Range(vec.size()))
shifted.push_back(vec << i);
shifted_vec = mux(shift_amount, shifted);
}
// Build more logic in the top level entity
// shifted_vec automatically becomes an output of the sub-entity
do_sth_with(shifted_vec);

GroupScopes can be entities or areas. Entities are always translated into VHDL entities. Each nested entity becomes a new entity and respective entity instantiation. Areas on the other hand become processes, blocks, or are fused together, depending on how deep they are nested.

GroupScopes can be given a name. The instance is inferred from it. In the exported VHDL code, these names are used with the already familiar mechanisms for resolving name clashes.

// ...
BVec shifted_vec;
// Built a dynamic bit shifter that will end up in it's own sub-entity
{
// This creates the sub-entity
GroupScope group(GroupScope::GroupType::ENTITY);
group.setName("dynamic_bitshifter");
// ...

Note that inside gatery, there is not (yet) an actual instantiation mechanism. The preferred way is duplication through C++ for loops, function calls, etc. in order to be fully able to exploit C++ as the meta-level of RTL construction. Entities, created through GroupScopes, are similarly duplicated rather than instantiated. As of now, these duplicates are all exported to VHDL as individual entities without detecting and merging identical ones. In other words, the generated VHDL code will still suffer heavily from code duplication, even when using entities.

Despite this, structuring code into entities has a big benefit on the "generation stability" of the export. While the VHDL generation is perfectly deterministic (the same input always yields the same output), small changes in gatery code can potentially result in large changes in the generated VHDL code. This is an effect well known from other hardware construction languages as well. Lines of code can change around, name clashes can resolve in different ways, etc. Segmenting the export into entities not only makes the exported code much easier to navigate, name clashes and code generation inside an entity is largely unaffected by changes to other entities, thus resulting in much more stable output.

Conclusion

This part of the tutorial introduced all the concepts for building combinatorial logic with gatery. The next part of this series will show how to use clocks and registers, either to add state to a circuit or for pipelining purposes.