Part 2 - Basic Gatery Syntax for RTL Design
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, specifically, on combinatory logic.
Signals
Symbolic Computation
Building circuits with gatery can be likened to symbolic computation. In the code, variables are created and composed in various operations like additions. But during execution, instead of actually performing those operations right there right then, gatery is supposed to take note of those operations and later simulate or translate them.
Gatery achieves this by providing a set of elementary signals as classes with overloaded operators. When operations are performed on instances of these classes, the operators construct a data flow graph in the background.
Elementary Signals
Gatery supports three fundamental types of signals: Bit
, bit vectors, and enums.
Bit vectors come in three flavors that represent the most common arithmetic interpretations.
Type | Description | Width | VHDL | Verilog |
---|---|---|---|---|
Bit |
single boolean or bit | 1 | std_logic |
wire |
BVec |
untyped bit vector | variable, >= 0 | std_logic_vector |
- |
SInt |
signed integer | variable, >= 0 | signed |
wire signed [...] |
UInt |
unsigned integer | variable, >= 0 | unsigned |
wire[...] |
Enum<c> |
Enum type signal based on a C/C++ enum c |
Depends on c |
custom type | - |
Bit
conveys a single bit but 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
, SInt
, and UInt
are bit vectors of arbitrary size (including size 1) and conveys words, numbers, etc.
They differ in their interpretation and thus their support of and behavior in arithmetic and comparison operations.
UInt
and SInt
function mostly the same, except that SInt
is interpreted as a two-complement signed integer and by default sign extended whereas UInt
is by default zero extended (more on this later).
BVec
as an untyped, raw bit vector does not support any arithmetic and can only be compared for equality and inequality.
Usually, UInt
is the preferred choice in designs, which is why the rest of this tutorial will mostly use this signal type.
Except where noted, SInt
and BVec
function the same.
Signals (vs Variables)
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.The elementary signals 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>
int main() {
// ...
using namespace gtry;
Bit my_undefined_bit;
UInt my_undefined_uint;
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.
Bit vectors 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 UInt
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.
UInt undefined_8_wide_uint = 8_b;
UInt undefined_10_wide_uint = BitWidth(10);
UInt undefined_12_wide_uint(12_b);
UInt undefined_16_wide_uint;
undefined_16_wide_uint = BitWidth(16);
By default, a signal is undriven and has the value “undefined” in the simulation.
Undefined signals
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 power up, 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 undefined values in important signals do not go unnoticed in the simulation.Operators
Assignment & Literals
Elementary signals 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;
UInt driving_bvec = 8_b;
UInt 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.
UInt
s 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).
UInt 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
, UInt
allows assignment of C++ integer types.
While SInt
also supports negative numbers, assigning a negative number to an UInt
will result in an error.
The given integer number is zero extended for UInt
s and sign extended for SInt
s to the width of the signal.
If the UInt
was not set to a specific width before, the width will be inferred from the assigned integer.
UInt 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
Do note that similarly to the
Bit
case, all computations done with regular C++ variables are performed during construction time.
Type conversion between bit vector types has to be explicit through casts.
UInt uint_signal = 42;
BVec bvec_signal = (BVec) uint_signal;
SInt sint_signal = (SInt) bvec_signal;
The width of a bit vectors can queried via the .width()
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>
// ...
UInt bv;
bv = "d42";
// Prints "The width of bv is 6" to the console
std::cout << "The width of bv is " << bv.width() << 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 bit vector classes do the same and perform the operations element-wise. The operands must have the same size, there is no implicit resizing.
UInt a = 8_b;
UInt b = 8_b;
a = ...;
b = ...;
// Bitwise negation
UInt not_a = ~a;
// And, or, xor as usual
UInt a_and_b = a & b;
UInt a_or_b = a | b;
UInt a_xor_b = a ^ b;
// Composition and bracketing as usual
UInt a_nand_b = ~(a & b);
UInt a_nor_b = ~(a | b);
UInt c = 5_b;
c = ...;
// UInt illegal = a & c; <-- Illegal because c is 5 bits and a is 8 bits
Any two-operand operation on bit vectors of different sizes is reported as an error.
This does not hold for operations involving Bit
and bit vectors.
A logical operation between a bit vector and Bit
always implicitly broadcasts the Bit
to every bit in the bit vector.
UInt a = 8_b;
a = ...;
// Whether or not to negate a
Bit do_negate_a = ...;
// xor every bit in a with do_negate_a
UInt possibly_negated_a = a ^ do_negate_a;
Extending Vectors
In order to combine bit vectors of different sizes in an operation they must be brought to the same size, usually by extending the smaller vector.
The ext()
function extends a biz vector by concatenating new most-significant-bits.
For UInt
zeros are attached, for SInt
the sign bit is replicated to keep the sign intact.
UInt unsigned_8_wide = "8b0";
// Zero extends by 2 bits
UInt unsigned_10_wide = ext(unsigned_8_wide, +2_b);
SInt signed_8_wide = (SInt) "8b0";
// Sign extends by 2 bits
SInt signed_10_wide = ext(signed_8_wide, +2_b);
Sign extension and two's complement numbers
Signed integers are often represented using [two's complement](https://en.wikipedia.org/wiki/Two%27s_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 |
-4 | b100 |
-3 | b101 |
-2 | b110 |
-1 | b111 |
Additionally, gatery provides explicit functions for zero-extension, one-extension, and sign-extension.
UInt unsigned_8_wide = "8b0";
// Zero extend unsigned integer
UInt unsigned_10_wide = zext(unsigned_8_wide, +2_b);
UInt signed_8_wide = "8b0";
// Sign extend integer
UInt signed_10_wide = sext(signed_8_wide, +2_b);
UInt mask_8_wide = "8b0";
// Extend with ones
UInt mask_10_wide_one_extended = oext(mask_8_wide, +2_b);
Single Bit
s can similarly be extended into UInts
.
Bit bit = '1';
// Sign extends by 9 bits
UInt ten_1 = sext(bit, +9_b);
The shown forms of ext
, 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:
UInt a = "10b0";
UInt b = "8b0";
// This would be illegal because a nd b have different sizes:
// UInt c = a & b;
// This zero-extends b to the width of a (10-bits) and then performs the element wise or
UInt a_or_b = a | zext(b);
// The same works for sext and oext.
UInt a_and_b = a & oext(b);
Integers and integer literals implicitly behave as if they were zext
ed, that is, they are always zero extended, when assigned to UInt
or BVec
:
UInt a = 4_b;
a = 0; // zero-extended to b0000
UInt a_or_0001 = a | 1; // 1 is zero-extended to b0001
unsigned int i = 2;
UInt a_and_b = a & i; // i is zero-extended to b0010
Similarly, integers and integer literals are sign extended when assigned to SInt
s.
Rewiring Operations
Individual bits and subranges can be sliced from bit vectors.
UInt ieee_float_32 = 32_b;
ieee_float_32 = ...;
UInt mantissa = ieee_float_32(0, 23_b); // Extract 23 bits from bit 0 onwards
UInt exponent = ieee_float_32(23, 8_b); // 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 bit vectors can be iterated over.
UInt 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);
}
Subranges and individual bits can also be selected “dynamically” with an index or offset that is itself an UInt
.
UInt bvec = ...;
UInt index = ...;
Bit bit = bvec[index];
UInt subrange = bvec(index, 2_b);
Note that this seemingly simple operation can result in costly multiplexers.
To concatenate multiple Bit
s or bit vectors, the variadic pack(...)
function can be used.
The similar function cat(...)
performs the same operation but with the arguments in reverse.
pack(...)
should be used when packing signals into larger structs where the arguments are expected to be packed in order.
cat(...)
should be used when concatenating parts of numbers, where we intuitively think in reverse order because we order digits from most significant to least significant.
UInt mantissa = 23_b;
mantissa = ...;
UInt exponent = 8_b;
exponent = ...;
Bit sign = ...;
// Concatenates all arguments, putting the last
// argument (mantissa) into the least significant bits.
UInt ieee_float_32 = cat(sign, exponent, mantissa);
// Packs all arguments, putting the first
// argument (mantissa) into the least significant bits.
UInt same_ieee_float_32 = pack(mantissa, exponent, sign);
Variadic functions
A variadic function has no fixed number of parameters. Arbitrary amounts and, in this case, types of parameters can be passed to the function.Similarly to C/C++ integers, bit vectors 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 drops the shifted-out bits, but on the MSB side inserts zeros for BVec
and UInt
replicates the sign bit for SInt
.
UInt value = 10_b;
value = ...;
UInt value_times_4 = value << 2;
UInt 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.
UInt value = 10_b;
value = ...;
UInt value_rotated_left_2 = rotl(value, 2);
UInt 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.
UInt a = 23_b;
a = ...;
UInt b = 23_b;
b = ...;
UInt a_plus_b = a + b; // Exported to VHDL as a+b
UInt a_minus_b = a - b; // Exported to VHDL as a-b
UInt a_times_b = a * b; // Exported to VHDL as a*b so your mileage may vary
UInt 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.
Arithmetic operations are not (and will not) be implemented for the
BVec
s as they are not supposed to have a numeric interpretation.
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 specific implementation but rather is forwarded to VHDL.
The result of a comparison is always a Bit
.
UInt a = 10_b;
a = ...;
UInt 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;
BVec
s can only be compared for equality or inequality as they do not assume a numeric interpretation.
SInt
s correctly handle negative numbers.
Misc Operations
For building (large) multiplexers, gatery has a dedicated function that selects one input from a number of inputs based on a UInt
index.
UInt idx = 2_b;
idx = ...; // Can be anything from 0..3
UInt a_0 = 10_b;
UInt a_1 = 10_b;
UInt a_2 = 10_b;
UInt a_3 = 10_b;
UInt 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.
UInt push_buttons = pinIn(4_b);
Bit single_button = pinIn();
UInt 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.
UInt value = 4_b;
value = 0;
UInt a = value;
value = 1;
UInt b = value;
value = 2;
UInt 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”.
UInt 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 odd.
This means that for all operators discussed so far, gatery also implements the customary in-place operators:
UInt a = 10_b;
a = ...;
UInt 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 UInt
, the returned handles still reference the original UInt
such that parts of it can be modified through the slices.
UInt 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_b) = 0; // The mantissa is all 0 (the "1." is implicit with floats)
ieee_float_32(23, 8_b) = 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:
// Resize but don't assign value yet.
UInt data = 10_b;
// 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).
UInt value = 4_b;
UInt a = value;
value = 0;
UInt b = value;
value = 1;
UInt 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:
UInt value = 4_b;
value = ...;
Bit do_mul_2 = ...;
// Do the multiplication only if do_mul_2 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 { }
.
UInt 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.
UInt 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
UInt mantissa;
UInt exponent;
Bit sign;
// 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 traveling in opposite directions can be bundled into the same struct (more on how to handle streams in a later part).
Certain utility functions of gatery can directly operate on structs if the structs are Simple Aggregates, that is, don’t use inheritance or custom constructors.
The following shows how the pack
function can recursively collapse a struct into a single UInt
.
struct MyFloat {
// Signals
UInt mantissa = 23_b;
UInt exponent = 8_b;
Bit sign;
// Meta Information
unsigned biasOffset = 127;
};
MyFloat myFloat;
myFloat.mantissa = ...;
// Packs the struct into one 32-bit word with the
// last member (mantissa) in the least significant bits
UInt packed_float = pack(myFloat);
Whats more, the contents of a UInt
can also be unpack
ed back into a struct.
struct MyFloat {
// Signals
UInt mantissa = 23_b; // This works like the constructor in the previous example
UInt exponent = 8_b;
Bit sign;
// Meta Information
unsigned biasOffset = 127;
};
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
UInt 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);
pack
and unpack
also have support for std::vector or std::array.
Note that the member signals of the target struct must be resized to the correct sizes before unpacking and that meta information can not be restored from the packed bit vector. The meta information of the destination struct remains untouched.
To resize the members of one struct to match the size of the other, the constructFrom(...)
function can be used:
MyFloat myFloat;
myFloat.exponent = "10b0";
myFloat.biasOffset = 511;
MyFloat myFloat2 = constructFrom(myFloat);
// myFloat2.exponent has size 10 but is unconnected
// myFloat2.biasOffset == 511
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 it in combination with C++ concepts to build reusable components.