Why Learn About 2-State Data Types? A logic variable can hold four values, but most testbench code never needs the extra two. A packet length, a loop counter, an array index — none of these should ever legitimately be X or Z. SystemVerilog's 2-state types like bit, byte, and int exist for exactly this kind of data: plain numbers, modeled the same way a C program would.
4-state types and the logic keyword were already covered in SystemVerilog logic and Verilog Data Types. This article covers the other half: SystemVerilog's family of 2-state integer types.
8 min read | Beginner Level
What You'll Learn
- Why 2-state types exist and how they differ from 4-state types
- The five 2-state integer types and how their ranges compare
- What happens when a 4-state value is assigned to a 2-state variable
- How to work with signed and unsigned values, and how to cast between them
2-State Data Types
2-state data types like bit and int can only represent 0 and 1 — there's no X or Z. That simplification means each bit needs only one bit of simulator storage instead of two, which reduces memory use and speeds up simulation.
| Data Type | Size | Signed Range | Unsigned Range | Description |
|---|---|---|---|---|
bit | 1-bit | — | 0 and 1 | 2-state variable |
byte | 8-bit | -2⁷ to 2⁷-1 | 0 to 2⁸-1 | 2-state variable, similar to C char |
shortint | 16-bit | -2¹⁵ to 2¹⁵-1 | 0 to 2¹⁶-1 | 2-state variable, similar to C short int |
int | 32-bit | -2³¹ to 2³¹-1 | 0 to 2³²-1 | 2-state variable, similar to C int |
longint | 64-bit | -2⁶³ to 2⁶³-1 | 0 to 2⁶⁴-1 | 2-state variable, similar to C long int |
2-state types suit more abstract levels of modeling than RTL, such as system-level or transaction-level code. When modeling a network packet with a header field for length, that length should be a plain number — there's no meaningful scenario where it should read as unknown or high-impedance.
When a 4-state value is converted to a 2-state value, any X or Z bits are converted to 0.
module tb;
bit [3:0] var_b; // Declare a 4-bit variable of type "bit"
logic [3:0] x_val; // Declare a 4-bit variable of type "logic"
initial begin
x_val = 4'b01zx;
// If a logic type or any 4-state variable assigns its value to a "bit" type
// variable, then X and Z get converted to zero
var_b = x_val;
$display ("x_val=%b var_b = %b", x_val, var_b);
end
endmodule
Output
xcelium> run x_val=01zx var_b = 0100 xmsim: *W,RNQUIE: Simulation is complete.
x_val holds a Z in bit 1 and an X in bit 0. Once that value is assigned into the 2-state var_b, both unknown bits collapse to 0, giving 0100. This silent collapsing is exactly why 2-state types are the wrong choice for RTL signals that genuinely need to represent an unknown or undriven state — see SystemVerilog logic for that side of the picture.
Quick Check
Q: What does a 2-state variable read back as, if a 4-state signal assigned into it was holding Z?
Show Answer
0. Any X or Z bit is silently converted to 0 the moment it's assigned into a 2-state type — there's no error or warning by default.
bit
The bit type is a 2-state type that defaults to 0. It's the most commonly used 2-state type in testbenches and other verification code, where the extra X/Z states aren't needed. A range specification ([MSB:LSB]) turns a single bit into a multi-bit vector.
Syntax
bit signal_name; // single-bit
bit [msb:lsb] signal_name; // multi-bit vector
module tb;
bit var_a; // Declare a 1 bit variable of type "bit"
bit [3:0] var_b; // Declare a 4 bit variable of type "bit"
logic [3:0] x_val; // Declare a 4 bit variable of type "logic"
initial begin
// Initial value of "bit" data type is 0
$display ("Initial value var_a=%0b var_b=0x%0h", var_a, var_b);
// Assign new values and display the variable to see that it gets the new values
var_a = 1;
var_b = 4'hF;
$display ("New values var_a=%0b var_b=0x%0h", var_a, var_b);
// If a "bit" type variable is assigned with a value greater than it can hold
// the left most bits are truncated. In this case, var_b can hold only 4 bits
// and hence 'h481 gets truncated leaving var_b with only 'ha;
var_b = 16'h481a;
$display ("Truncated value: var_b=0x%0h", var_b);
end
endmodule
Output
ncsim> run Initial value var_a=0 var_b=0x0 New values var_a=1 var_b=0xf Truncated value: var_b=0xa ncsim: *W,RNQUIE: Simulation is complete.
var_b is only 4 bits wide, so assigning it 16'h481a keeps only the bottom 4 bits (a) and silently discards the rest — no error, no truncation warning by default.
This kind of silent truncation is one of the most common sources of "why is my value wrong" bugs. If a variable might receive a value wider than its declared width, check the width explicitly rather than relying on the assignment to catch it.
byte
The byte type is an 8-bit, 2-state integer, signed by default, using two's complement representation to hold both positive and negative values — a range of -128 to 127. It's a natural fit anywhere memory efficiency matters, such as large arrays of small values.
module tb;
byte m_var_s; // By default signed, and can represent positive and negative numbers
initial begin
#10 m_var_s = 0; // assign 0
#20 m_var_s = 2**7 - 1; // assign 127
#30 m_var_s = 2**7; // assign 128
#40 m_var_s = 2**8 - 1; // assign 255
end
initial
$monitor("[%0t] m_var_s = 'd%0d (0x%0h)", $time, m_var_s, m_var_s);
endmodule
Output
xcelium> run [0] m_var_s = 'd0 (0x0) [30] m_var_s = 'd127 (0x7f) [60] m_var_s = 'd-128 (0x80) [100] m_var_s = 'd-1 (0xff) xmsim: *W,RNQUIE: Simulation is complete.
Assigning 128 (one past the signed maximum of 127) wraps around to -128, and 255 wraps to -1 — classic two's-complement rollover, the same behavior you'd see assigning out-of-range values to a signed char in C.
Quick Check
Q: Why does assigning 255 to a signed byte display as -1, not 255?
Show Answer
255 in binary is 8'hFF, all eight bits set to 1. In two's-complement signed representation, that bit pattern represents -1, not 255 — the value itself never changes, only how it's interpreted based on whether the type is signed or unsigned.
shortint
shortint is a 2-state, 16-bit signed integer, exactly like byte but wider.
module tb;
shortint m_var_s; // By default signed, and can represent positive and negative numbers
initial begin
#10 m_var_s = 0; // assign 0
#20 m_var_s = 2**15 - 1; // assign 32767
#30 m_var_s = 2**15; // assign 32768 (wraps to -32768, signed 16-bit)
#40 m_var_s = 2**16 - 1; // assign 65535 (wraps to -1, signed 16-bit)
end
initial
$monitor("[%0t] m_var_s = 'd%0d (0x%0h)", $time, m_var_s, m_var_s);
endmodule
Output
xcelium> run [0] m_var_s = 'd0 (0x0) [30] m_var_s = 'd32767 (0x7fff) [60] m_var_s = 'd-32768 (0x8000) [100] m_var_s = 'd-1 (0xffff) xmsim: *W,RNQUIE: Simulation is complete.
Just like byte, values that overflow the signed 16-bit range wrap around: 32768 becomes -32768, and 65535 becomes -1 — exactly what the output above shows.
int
int is a 2-state, 32-bit signed integer — the default choice for general-purpose counters, loop variables, and array indices in a testbench.
module tb;
int m_var_s; // By default signed, and can represent positive and negative numbers
initial begin
#10 m_var_s = 0;
#20 m_var_s = 2**31 - 1;
#30 m_var_s = 2**31;
#40 m_var_s = 2**32 - 1;
end
initial
$monitor("[%0t] m_var_s = 'd%0d (0x%0h)", $time, m_var_s, m_var_s);
endmodule
Output
xcelium> run [0] m_var_s = 'd0 (0x0) [30] m_var_s = 'd2147483647 (0x7fffffff) [60] m_var_s = 'd-2147483648 (0x80000000) [100] m_var_s = 'd-1 (0xffffffff) xmsim: *W,RNQUIE: Simulation is complete.
int is by far the most commonly used integer type in testbench code — loop counters, transaction counts, and array sizes almost always default to int unless there's a specific reason to pick a narrower or wider type.
longint
longint is a 2-state, 64-bit signed integer, useful for counters or addresses that could exceed the 32-bit range of a plain int.
module tb;
longint m_var_s; // By default signed, and can represent positive and negative numbers
initial begin
#10 m_var_s = 0;
#20 m_var_s = 2**63 - 1;
#30 m_var_s = 2**63;
#40 m_var_s = 2**64 - 1;
end
initial
$monitor("[%0t] m_var_s = 'd%0d (0x%0h)", $time, m_var_s, m_var_s);
endmodule
Output
xcelium> run [0] m_var_s = 'd0 (0x0) [30] m_var_s = 'd9223372036854775807 (0x7fffffffffffffff) [60] m_var_s = 'd-9223372036854775808 (0x8000000000000000) [100] m_var_s = 'd-1 (0xffffffffffffffff) xmsim: *W,RNQUIE: Simulation is complete.
Signed and Unsigned
Signed types can represent both positive and negative values; unsigned types can only represent zero and positive values. A signed type spends one bit (the MSB) on the sign, which halves its maximum positive value compared to an unsigned type of the same width — the total range stays the same size, just shifted.
Example
The sign can be set explicitly with the signed and unsigned keywords.
module tb;
byte s_byte; // By default byte is signed
byte unsigned u_byte; // Byte is set to unsigned
initial begin
$display ("Size s_byte=%0d, u_byte=%0d", $bits(s_byte), $bits(u_byte));
// Assign the "assumed" maximum value to both variables
#1 s_byte = 'h7F;
u_byte = 'h7F;
// Increment by 1, and see that s_byte rolled over to negative because
// byte is signed by default. u_byte keeps increasing because it is
// unsigned and can go upto 255
#1 s_byte += 1;
u_byte += 1;
// Assign 255 (8'hFF) to u_byte -> this is the max value it can hold
#1 u_byte = 'hFF;
// Add 1 and see that it rolls over to 0
#1 u_byte += 1;
end
initial begin
$monitor ("[%0t ns] s_byte=%0d u_byte=%0d", $time, s_byte, u_byte);
end
endmodule
Output
ncsim> run Size s_byte=8, u_byte=8 [0 ns] s_byte=0 u_byte=0 [1 ns] s_byte=127 u_byte=127 [2 ns] s_byte=-128 u_byte=128 [3 ns] s_byte=-128 u_byte=255 [4 ns] s_byte=-128 u_byte=0 ncsim: *W,RNQUIE: Simulation is complete.
Both variables are 8 bits wide, but they interpret the same bit patterns differently: past 127, s_byte wraps to negative, while u_byte keeps counting up to 255 before it wraps to 0.
Quick Check
Q: Do signed and unsigned versions of the same type use a different number of bits?
Show Answer
No. As the example shows, $bits() returns 8 for both s_byte and u_byte — the width is identical. Only the interpretation of the top bit (as a sign, or as part of the magnitude) differs.
Sign Cast
Signed and unsigned values can be converted into one another with a cast, without changing the underlying bits.
module tb;
shortint m_var_s; // By default signed, and can represent positive and negative numbers
shortint unsigned m_var_us; // Explicitly set to 'unsigned' and represents only positive numbers
initial begin
m_var_us = 2**16 - 1;
m_var_s = -1;
// Convert an unsigned variable value to a signed variable value by casting
$display("m_var_us=%0d m_var_us=%0d", signed' (m_var_us), m_var_us);
$display("m_var_s=%0d m_var_s=%0d", unsigned' (m_var_s), m_var_s);
end
endmodule
Output
xcelium> run m_var_us=-1 m_var_us=65535 m_var_s=65535 m_var_s=-1 xmsim: *W,RNQUIE: Simulation is complete.
m_var_us and m_var_s hold the exact same 16-bit pattern (all 1s), just declared with opposite signedness — that's why signed'(m_var_us) reads as -1 and unsigned'(m_var_s) reads as 65535. The cast doesn't touch a single bit; it only changes how those bits are interpreted for that one expression. Note the first $display's format string prints the label m_var_us twice, once for the cast result and once for the plain value — a labeling quirk to watch for, not a difference in what's being computed.
Operations on unsigned integers can be marginally faster in some tools, since there's no sign bit to account for during arithmetic — one more reason to reach for unsigned explicitly when a value can never legitimately go negative, like an index or a count.
Common Mistakes
Mistake #1: Assuming truncation or overflow raises a warning
As var_b = 16'h481a; showed, assigning a value that's too wide for the target variable silently keeps only the low bits — there's no default warning. The same applies to signed overflow, like byte wrapping from 127 to -128. Don't rely on the simulator to flag either case; check widths and ranges explicitly where it matters.
Mistake #2: Using a 2-state type where X/Z detection matters
A 2-state variable can never hold X or Z — any 4-state value assigned into one gets those bits silently zeroed. This makes 2-state types a poor choice for signals where catching an uninitialized or undriven condition is the whole point, such as most RTL signals. Use logic for those instead.
Mistake #3: Forgetting that signed and unsigned share the same width
byte and byte unsigned are both 8 bits — adding unsigned doesn't gain you an extra bit of storage, it only shifts which values that same 8 bits can represent (0 to 255 instead of -128 to 127).
Mistake #4: Assuming a cast changes the stored value
signed'(...) and unsigned'(...) only change how an expression's existing bits are interpreted for that one use — they don't modify the variable itself. Casting m_var_us doesn't turn m_var_us into a signed variable; it produces a signed view of its value for that expression only.
Practice Exercise
Challenge: Explore Unsigned Rollover
Declare a shortint unsigned variable named counter. Assign it its maximum possible value, then add 1 to it and print the result. Also print $bits(counter) to confirm the width.
Hint
The maximum value of a 16-bit unsigned type is 2**16 - 1. Adding 1 past that should roll over to 0, the same way u_byte did in the Signed and Unsigned example.
Solution
module tb;
shortint unsigned counter;
initial begin
$display ("bits = %0d", $bits(counter));
counter = 2**16 - 1;
$display ("counter = %0d", counter);
counter += 1;
$display ("counter = %0d (rolled over)", counter);
end
endmodule
Summary
In this tutorial, you learned:
- 2-state types can only hold
0and1, use less simulator memory than 4-state types, and are best suited to abstract, non-RTL data like counters and packet fields bit,byte,shortint,int, andlongintrange from 1 to 64 bits, each signed by default exceptbit- Assigning a 4-state value into a 2-state variable silently converts any
X/Zbits to0 - Assigning a value wider than a variable's declared width silently truncates it, and signed overflow wraps around — neither raises a warning by default
signed'(...)andunsigned'(...)reinterpret an expression's existing bits without changing the underlying variable