Project F

Verilog Vectors and Arrays

Published

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

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?

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.


  1. We’re ignoring X and Z for the purpose of this introduction. See Numbers in Verilog↩︎