Verilog Vectors and Arrays
Welcome back to my series covering mathematics and algorithms with FPGAs. In this part, we dig into vectors and arrays, including slicing, configurable widths, for loops, and bit and byte ordering. New to the series? Start with Numbers in Verilog.
Share your thoughts with @WillFlux on Mastodon or Twitter. If you like what I do, sponsor me. 🙏
Series Outline
- Numbers in Verilog - introduction to numbers in Verilog
- Vectors and Arrays (this post) - working with Verilog vectors and arrays
- Multiplication with DSPs - efficient multiplication with FPGA DSPs
- Fixed-Point Numbers in Verilog - precision without complexity
- Division in Verilog - divided we stand
- More maths to follow
What is a Vector?
A quick recap from Numbers in Verilog:
By default, a Verilog register or wire is 1 bit wide. This is a scalar:
wire x; // 1 bit wire
reg y; // also 1 bit
logic z; // me too!
A scalar can only hold 0 or 11.
We need a vector to hold something larger.
A vector is declared like this: type [upper:lower] name;
wire [5:0] a; // 6-bit wire
reg [7:0] b; // 8-bit reg
logic [11:0] c; // 12-bit logic
a
, b
, and c
are vectors:
- Wire a handles 0-63 inclusive (26 is 64).
- Register b handles 0-255 inclusive (28 is 256).
- Logic c handles 0-4095 inclusive (212 is 4096).
With that recap out of the way, let’s look at some things we can do with vectors.
Slicing Vectors
You select an individual bit using its index; for example:
wire [3:0] n; // 4-bit wire vector
wire p, q; // wire scalars
always_comb begin
p = n[0];
q = n[3];
end
You select a subset by specifying the start and end bits:
wire [11:0] a; // 12-bit wire vector
wire [3:0] x, y, z; // 4-bit wire vectors
always_comb begin
x = a[11:8];
y = a[7:4];
z = a[3:0];
end
You can also use the concat operator {}
to select bits from vectors. The following example is equivalent to the one above:
wire [11:0] a; // 12-bit wire vector
wire [3:0] x, y, z; // 4-bit wire vectors
always_comb begin
{x,y,z} = a;
end
Rather than specify an end bit, you can specify a width with -
and +
.
These three assignments all select the same four bits:
wire [11:0] a; // 12-bit wire vector
wire [3:0] x, y, z; // 4-bit wire vectors
always_comb begin
x = a[11:8]; // 11:8
y = a[11-:4]; // also 11:8
z = a[8+:4]; // also 11:8
end
ProTip: The start bit can be a variable, but not the width.
Loss of Sign
With signed variables, using slices will make the value unsigned, even if you select the whole range!
However, you can force a variable to be signed with the $signed
system function:
wire signed [11:0] a = -64; // 12-bit signed wire vector
initial begin
$display("a is signed: %d", a);
$display("a is unsigned: %d", a[11:0]);
$display("a is signed: %d", $signed(a[11:0]));
end
Produces the following:
a is signed: -64
a is unsigned: 4032
a is signed: -64
$signed
is no panacea: sign extension can still catch you out.
Configurable Widths
Avoid hard-coding vector widths; it limits your design flexibility.
Parameters provide a simple way to configure vector widths:
parameter ADDRW=16; // address width: 16 bits for 2^16 memory locations
logic [ADDRW-1:0] addr_read;
logic [ADDRW-1:0] addr_write;
The width of a vector often depends on another parameter, so calculating it yourself isn’t ideal.
Imagine you’re creating a game engine where the number of sprites is configurable:
parameter SPR_CNT=10; // maximum number of sprites on screen
logic [3:0] sprite_id; // 4 bits is correct for a count of 10, but if SPR_CNT changes?
Changing the sprite count will break the design if we hardcode the width.
Verilog 2005 introduced $clog2
to handle this.
Calculating Widths
The $clog2
function returns the ceiling of the logarithm to base 2.
For example, $clog2(10) = 4
because 23 < 10 ≤ 24.
If you need to handle N things (such as sprites or memory locations), then $clog2(N)
will tell you how wide your vector needs to be:
parameter SPR_CNT=10; // maximum number of sprites on screen
parameter SPR_BITW=$clog2(SPR_CNT); // sprite ID bit width
logic [SPR_BITW-1:0] sprite_id; // sprite identifier
$clog2
is handy, but you need to be careful.
If you’re specifying a maximum value (rather than a count), it doesn’t do what you want:
parameter MAX_VOLTAGE=256; // maximum voltage
parameter VOLTW=$clog2(MAX_VOLTAGE); // voltage bit width (INCORRECT!)
logic [VOLTW-1:0] volatage; // we can't handle 256!
$clog2
returns ‘8’, giving a voltage range of 0-255 inclusive. 256 is out of range.
If you’re specifying a maximum value, you need to add one to the value passed to $clog2
:
parameter MAX_VOLTAGE=256; // maximum voltage
parameter VOLTW=$clog2(MAX_VOLTAGE+1); // voltage bit width (add one for max)
logic [VOLTW-1:0] volatage; // we can now handle 256 volts :)
This problem is often hidden because it doesn’t occur if your parameter isn’t a power of 2. For example, if you specify ‘240’ as your MAX_VOLTAGE
, you won’t see any issues. Later, you increase MAX_VOLTAGE
to ‘256’, and the design has a subtle bug.
A Bit Significant
Earlier, we said a vector was declared like this: type [upper:lower] name;
A more general definition is: type [msb_index:lsb_index] name;
Where msb_index is the most significant bit index, and lsb_index is the least significant bit index.
The usual way of declaring vectors has the least significant bit at the lowest index (LSB first):
wire [5:0] a; // 6-bit wire (LSB first)
reg [11:0] b; // 12-bit reg (LSB first)
The most significant bit of a
is stored in a[5]
and that of b
in b[11]
.
Alternatively, we can declare vectors with the most significant bit at the lowest index (MSB first):
wire [0:5] c; // 6-bit wire (MSB first)
reg [0:11] d; // 12-bit reg (MSB first)
The most significant bit of c
is stored in c[0]
and that of d
in d[0]
.
Switching Ends
MSB-first vectors are comparatively rare in Verilog. However, some hardware interfaces send the most significant bit first, for example, I2C.
Say you’ve got an MSB first byte from I2C and want to convert it to LSB first.
You could try directly swapping the order:
wire [0:7] i2c_x; // 8-bit wire (MSB first)
reg [7:0] x; // 8-bit reg (LSB first)
always_ff @(posedge clk) x <= i2c_x; // Doesn't work in all tools :(
Alas, some tools won’t let you mix LSB- and MSB-first vectors in one expression.
A more general approach is to reverse the bits explicitly. All bits are swapped in parallel:
always_ff @(posedge clk) begin
x[0] <= i2c_x[7];
x[1] <= i2c_x[6];
x[2] <= i2c_x[5];
x[3] <= i2c_x[4];
x[4] <= i2c_x[3];
x[5] <= i2c_x[2];
x[6] <= i2c_x[1];
x[7] <= i2c_x[0];
end
Updating individual bits is tedious, but a for
loop can handle this for us:
always_ff @(posedge clk) begin
for (i=0; i<8; i=i+1) x[i] <= i2c_x[7-i];
end
Verilog for
is NOT like a software loop: this for
loop is unrolled into parallel bit swaps.
Big Endian, Little Endian
So far, we’ve been talking about ordering at the bit level, but it also occurs in the context of bytes. If you have a 32-bit word, do you store the least significant byte at the lowest address (little-endian) or the most significant byte at the lowest address (big-endian)?
RISC-V, x86, and ARM are little-endian, while Internet protocols (TCP/IP) and Motorola 68K are big-endian. There’s also the cursed middle-endian, but I won’t discuss that here.
I’m still writing this content.
Arrays
I’m still writing this content.
What’s Next?
If you enjoyed this post, please sponsor me. Sponsors help me create more FPGA and RISC-V projects for everyone, and they get early access to blog posts and source code. 🙏
Part three covers Multiplication with DSPs or jump ahead to Fixed-Point Numbers.
You can also check out our other maths posts: division, square root, and sine & cosine.
We’re ignoring X and Z for the purpose of this introduction. See Numbers in Verilog. ↩︎