Part 1 · Language Foundations · Intermediate

4-State vs 2-State Types

logic/reg/wire vs bit/byte/int — X/Z representation cost, initialization semantics, and where each family belongs.

What 4-state actually buys you

A 4-state type (logic, reg, wire, integer) stores each bit as one of 0, 1, X, Z . X means the simulator does not know the value — an uninitialized flop, a bus conflict, a don't-care from synthesis. Z means high-impedance — nothing is driving the net. These two extra states are not decoration: they are how simulation models the physical reality that silicon powers up in an unknown state and that tri-state buses can float.

A 2-state type (bit, byte, shortint, int, longint) stores only 0 or 1 per bit. The simulator packs them two bits-of-state-per-bit cheaper than 4-state: each 4-state bit needs two physical bits of host memory (one value plane, one control plane for X/Z), while a 2-state bit needs one. On million-element scoreboards and long queues, 2-state types roughly halve memory and speed up every comparison and copy — which is why testbench class fields default to them.

Initialization differs in a way that matters for debug: 4-state variables start at X , 2-state variables start at 0 . An X screams "nobody assigned me" in a waveform. A 0 looks like a perfectly legal value, so a forgotten assignment to an int field sails through checks silently. Interviewers regularly probe exactly this: "what is the reset value of a bit vs a logic, and why does it matter?"

diagram
Legend: [TYPE]

  PER-BIT STORAGE [TYPE]

  4-state bit (logic)                2-state bit (bit)
  ┌───────────┬───────────┐          ┌───────────┐
  │ value bit │ ctrl bit  │          │ value bit │
  ├───────────┼───────────┤          ├───────────┤
  │   0   0   │  0       │          │   0  0   │
  │   1   0   │  1       │          │   1  1   │
  │   0   1   │  Z       │          └───────────┘
  │   1   1   │  X       │           init value: 0
  └───────────┴───────────┘           memory: 1 bit
   init value: X                      ops: native CPU words
   memory: 2 bits
   ops: dual-plane logic (slower)

Where each family belongs

The placement rule almost every methodology converges on: anything that touches the DUT is 4-state; anything internal to the class-based testbench is 2-state . RTL needs X so that reset bugs, don't-care abuse, and bus conflicts are visible. The testbench transaction's addr field never legitimately holds X — by the time the monitor builds a transaction it should have checked the bus for X — so paying the 4-state cost there is waste.

systemverilog
// RTL: 4-state, X-propagating
module dff (
  input  logic clk, rst_n,
  input  logic [7:0] d,
  output logic [7:0] q
);
  always_ff @(posedge clk or negedge rst_n) begin
    if (!rst_n) q <= '0;
    else        q <= d;        // if d is X, q becomes X — visible bug
  end
endmodule

// Testbench: 2-state class fields
class bus_txn;
  rand bit [31:0] addr;        // 2-state: fast, init 0
  rand bit [31:0] data;
  rand bit        write;
  int unsigned    delay;       // loop math, never X
endclass

Conversion at the boundary

Assigning 4-state to 2-state silently maps X and Z to 0 (in most simulators; the LRM says X/Z convert to 0 for 2-state types). That is the single most dangerous implicit conversion in the language: the monitor copies an X-laden bus into a bit [31:0] transaction field, the X becomes a clean-looking 0, and the scoreboard compares garbage against the reference model without complaint. Guard the boundary with an explicit $isunknown check before the copy.

systemverilog
// Monitor sampling: guard the 4-state → 2-state copy
task sample_bus();
  logic [31:0] raw_addr;
  bus_txn t = new();

  raw_addr = vif.cb.addr;                       // 4-state sample
  if ($isunknown(raw_addr))
    `uvm_error("MON", $sformatf("X/Z on addr: %h", raw_addr))

  t.addr = raw_addr;                            // X→0 happens HERE
  t.data = vif.cb.data;                         // unguarded: X vanishes
endtask

Boundary rules

  • Sample DUT pins into 4-state locals first; check with $isunknown before copying to 2-state fields.

  • Driving direction is safe: 2-state values are always valid 4-state values.

  • Never declare DUT-facing interface signals as bit — you blind the testbench to X.

  • If a scoreboard mismatch shows a suspicious 0, ask whether it was born as X upstream.

Key takeaways

  • 4-state costs two host bits per design bit; 2-state costs one and runs faster — use 4-state only where X/Z is meaningful.

  • 4-state initializes to X (loud), 2-state to 0 (silent) — the X is a feature for catching missing assignments.

  • RTL and DUT-facing signals: logic/wire. Class-based TB internals: bit/int.

  • 4-state → 2-state conversion turns X into 0 silently — check $isunknown at every monitor boundary.

Common pitfalls

  • Declaring interface or port signals as bit — X from the DUT is converted to 0 before the TB can see it.

  • Using int for a field compared against DUT output — a forgotten assignment reads as legal 0, not obvious X.

  • Assuming logic is 'the new reg' only — it is also usable where wire was, as long as there is a single driver.

  • Copying vif signals straight into 2-state transaction fields without $isunknown — the classic hidden-X bug.