SystemVerilog · Module 4
Shift Operators
<<, >>, <<<, >>> — logical vs arithmetic right shift, signed behavior, sign extension.
Module 4 · Page 4.6
The One Operator That Actually Has a Trap
If you are verifying a DSP core, a fixed-point multiplier, or any design that divides signed values by powers of two, shift operators become genuinely important. The arithmetic right shift (>>>) is what makes signed_val >>> 1 equivalent to dividing by 2 while preserving the sign. The logical right shift (>>) shifts in zeros from the left — which is correct for unsigned values but gives the wrong answer for negative signed values.
The concrete failure: on an 8-bit signed variable, -8 >> 1 gives +124. -8 is 8'b1111_1000; shifting right by one and filling with zero gives 8'b0111_1100, which read as a signed 8-bit value is +124. -8 >>> 1 gives -4, the correct arithmetic result. Both operations succeed silently — no error, no warning, just a wrong number feeding the rest of the datapath.
Notice what kind of wrong number it is. +124 is not a wild value that a range check would catch; it is a plausible byte. Sign errors in shifted data tend to surface as an accumulator that drifts, or a filter whose output is asymmetric about zero, long after the shift itself.
Left shifts have no such distinction — both << and <<< always shift in zeros on the right. They are functionally identical. The double-angle form <<< exists for symmetry with the arithmetic right shift, but adds nothing different.
Four Operators, Two Distinctions
| Operator | Name | Fill direction | Fill value | Use for |
|---|---|---|---|---|
<< | Logical left shift | Fills right (LSB side) | Always 0 | Multiply by 2^n, bit-mask generation |
<<< | Arithmetic left shift | Fills right (LSB side) | Always 0 — identical to << | Signed multiply by 2^n (same result as <<) |
>> | Logical right shift | Fills left (MSB side) | Always 0 | Unsigned divide by 2^n, extract upper bits |
>>> | Arithmetic right shift | Fills left (MSB side) | Sign bit for signed types, 0 for unsigned | Signed divide by 2^n — preserves sign |
How Arithmetic Right Shift Works
When you right-shift a signed negative number, the vacated MSB positions need to be filled. Logically shifting in 0s turns the number positive — which is arithmetically wrong. Arithmetically shifting in copies of the sign bit (1 for negative, 0 for positive) preserves the sign and gives the correct floor-division-by-power-of-two result.
Logical left shift
<< shifts bits toward the MSB and fills zeros on the right — a multiply by 2 per position. Bits shifted past the MSB are lost, which silently changes the sign of a signed value. Identical in every case to <<<.
Logical right shift
Shifts bits toward LSB, fills 0s on the left. Correct for unsigned divide-by-2. Wrong for signed negative — turns negative into large positive.
Arithmetic right shift
Fills with the sign bit (MSB). For negative signed values, fills 1s. For positive or unsigned, fills 0s. This is what you want for signed division.
Arithmetic left shift
<<< fills zeros on the right — identical to << in all cases. It exists for symmetry with >>>, not because left shifting has an arithmetic variant. Either form is safe.
Syntax & Type Rules
// General syntax: operand shift_op shift_amount
result = value << n; // logical left — n can be an expression
result = value >> n; // logical right
result = value <<< n; // arith left — same as <<
result = value >>> n; // arith right — sign-extends if value is signed
// ── Unsigned examples (logic [7:0]) ──────────────────────────────
logic [7:0] u = 8'b0001_0000; // 16
u << 2; // 8'b0100_0000 = 64 (×4)
u >> 2; // 8'b0000_0100 = 4 (÷4, zero-fill)
u >>> 2; // 8'b0000_0100 = 4 (same — unsigned)
// ── Signed examples (logic signed [7:0]) ─────────────────────────
logic signed [7:0] s = -8; // 8'b1111_1000
s >> 2; // 8'b0011_1110 = +62 WRONG: shifted in 0s
s >>> 2; // 8'b1111_1110 = -2 CORRECT: shifted in sign bit (1)
// ── Shift amount rules ───────────────────────────────────────────
// Shift amount is always treated as UNSIGNED
// If shift amount >= operand width, result is 0 (or all sign bits for >>>)
logic [7:0] v = 8'hFF;
v >> 8; // 8'h00 — shifted out entirely
v >> 100; // 8'h00 — same
// ── Variable shift (generates barrel shifter in synthesis) ───────
logic [3:0] shift_amt;
result = data << shift_amt; // shift_amt computed at runtimeType Determines >>> Behavior
| Declaration | Type | >>> behavior |
|---|---|---|
logic [7:0] v | Unsigned 4-state | Zero-fill — same as >> |
logic signed [7:0] v | Signed 4-state | Sign-extend — fills MSB copies |
int v | Signed 2-state 32-bit | Sign-extend |
bit [7:0] v | Unsigned 2-state | Zero-fill |
shortint v | Signed 2-state 16-bit | Sign-extend |
$signed(v) >>> n | Cast to signed context | Sign-extend regardless of original declaration |
Step-by-Step Visual Evaluation
Left Shifts — Bit Movement Diagram
Starting value: 8'b0001_1010 = 26
| Operation | Bit 7 | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 | Decimal |
|---|---|---|---|---|---|---|---|---|---|
| Original | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 26 |
<< 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 0 | 52 (×2) |
<< 2 | 0 | 1 | 1 | 0 | 1 | 0 | 0 | 0 | 104 (×4) |
<< 3 | 1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 208 (×8, fits in 8 bits) |
<< 4 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 160 — MSB of original lost |
Green cells = zeros shifted in from the right.
Right Shifts — Logical vs Arithmetic on -8
logic signed [7:0] s = -8 → binary 8'b1111_1000. Watch what fills in from the left:
| Operation | Bit 7 | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 | Value | Correct? |
|---|---|---|---|---|---|---|---|---|---|---|
| Original (-8) | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | -8 | — |
>> 1 (logical) | 0 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | +124 | ❌ Wrong |
>>> 1 (arithmetic) | 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | -4 | ✅ Correct (-8/2) |
>> 2 (logical) | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 0 | +62 | ❌ Wrong |
>>> 2 (arithmetic) | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 0 | -2 | ✅ Correct (-8/4) |
Green = zero fill (logical). Orange = sign-bit fill (arithmetic).
Shift Amount Effects
| Shift amount | Effect on 8'hFF >> n | Effect on signed -8 >>> n |
|---|---|---|
| 0 | 8'hFF (unchanged) | -8 (unchanged) |
| 1 | 8'h7F = 127 | -4 |
| 4 | 8'h0F = 15 | -1 (all sign bits) |
| 7 | 8'h01 = 1 | -1 (still all sign bits) |
| 8 | 8'h00 = 0 (fully shifted out) | -1 (sign bit fills entire result) |
| more than 8 | 8'h00 = 0 | -1 for negative, 0 for positive |
Code Examples — From Basics to Production
Example 1 — Beginner: All Four Operators
module tb_shift_basic;
logic [7:0] u_val = 8'd16; // unsigned 16 = 0001_0000
logic signed [7:0] s_neg = -8; // signed -8 = 1111_1000
logic signed [7:0] s_pos = 8'd16; // signed +16 = 0001_0000
initial begin
// ── Left shifts — zero fills right, identical for << and <<< ──
$display("u_val << 2 = %0d (16×4=64)", u_val << 2); // 64
$display("u_val <<< 2 = %0d (same)", u_val <<< 2); // 64
// ── Logical right — zero fills left ───────────────────────────
$display("u_val >> 2 = %0d (16÷4=4)", u_val >> 2); // 4
$display("s_neg >> 2 = %0d (WRONG!)", s_neg >> 2); // +62
// ── Arithmetic right — sign fills left ────────────────────────
$display("s_neg >>> 2 = %0d (-8÷4=-2)", s_neg >>> 2); // -2
$display("s_pos >>> 2 = %0d (16÷4=4)", s_pos >>> 2); // 4 (positive: 0 fills)
$display("u_val >>> 2 = %0d (unsigned: 0 fill)", u_val >>> 2); // 4
$finish;
end
endmoduleExpected output:
u_val << 2 = 64 (16×4=64)
u_val <<< 2 = 64 (same)
u_val >> 2 = 4 (16÷4=4)
s_neg >> 2 = 62 (WRONG!)
s_neg >>> 2 = -2 (-8÷4=-2)
s_pos >>> 2 = 4 (16÷4=4)
u_val >>> 2 = 4 (unsigned: 0 fill)Example 2 — Intermediate: Address Calculation and Mask Generation
module tb_shift_address;
logic [31:0] base_addr = 32'h1000_0000;
logic [31:0] word_addr, byte_mask, field_mask;
int index;
initial begin
// ── Word-aligned address from byte index ──────────────────────
// AXI/APB: word addresses are byte_addr >> 2
// Byte address from word index: word_idx << 2
for (int i = 0; i < 4; i++) begin
word_addr = base_addr + (32'(i) << 2);
$display("Word[%0d] addr = 0x%08h", i, word_addr);
end
// 0x10000000, 0x10000004, 0x10000008, 0x1000000C
// ── Generate single-bit mask at position n ────────────────────
for (int bit_pos = 0; bit_pos < 4; bit_pos++) begin
byte_mask = 32'h1 << bit_pos;
$display("Bit mask [%0d] = 0x%08h (%032b)",
bit_pos, byte_mask, byte_mask);
end
// ── Extract byte N from 32-bit word ──────────────────────────
logic [31:0] data = 32'hAABBCCDD;
for (int byte_n = 0; byte_n < 4; byte_n++) begin
logic [7:0] extracted = (data >> (byte_n * 8)) & 8'hFF;
$display("Byte[%0d] = 0x%02h", byte_n, extracted);
end
// Byte[0]=0xDD, Byte[1]=0xCC, Byte[2]=0xBB, Byte[3]=0xAA
$finish;
end
endmoduleExample 3 — Verification-Oriented: Fixed-Point Arithmetic Checker
// DSP core verification: right-shift with rounding
// DUT computes: (A × B) >>> SCALE, with half-up rounding
class DspScoreboard;
localparam int SCALE = 8; // right-shift amount
function int expected_result(input int a, b);
longint product = longint'(a) * longint'(b);
longint half = 1 << (SCALE - 1); // 0.5 in fixed-point
longint rounded = (product + half) >>> SCALE; // arithmetic shift
return int'(rounded);
endfunction
function void check(input int a, b, dut_out);
int exp = expected_result(a, b);
if (exp !== dut_out)
$error("DSP FAIL: %0d * %0d >> %0d = exp %0d, got %0d",
a, b, SCALE, exp, dut_out);
else
$display("DSP OK: %0d * %0d >> %0d = %0d", a, b, SCALE, exp);
endfunction
endclass
module tb_dsp;
DspScoreboard sb;
initial begin
sb = new();
sb.check(100, 200, 78); // 100×200=20000, 20000>>8 ≈ 78
sb.check(-100, 200, -78); // -100×200=-20000, -20000>>8 ≈ -78
$finish;
end
endmoduleExample 4 — Corner Case: >>> on Unsigned Type — No Sign Extension
module tb_unsigned_arith_shift;
logic [7:0] u_val = 8'hF8; // unsigned — MSB = 1, but not signed
logic signed [7:0] s_val = 8'hF8; // signed — same bits = -8
initial begin
$display("u_val = %0d (unsigned 248)", u_val);
$display("s_val = %0d (signed -8)", s_val);
// >>> on UNSIGNED — no sign extension, fills 0
$display("u_val >>> 2 = %0d (zero-fill: 248/4=62)", u_val >>> 2);
// >>> on SIGNED — sign extension, fills 1s
$display("s_val >>> 2 = %0d (sign-fill: -8/4=-2)", s_val >>> 2);
// If you WANT signed behavior on an unsigned variable — cast first
$display("$signed(u_val) >>> 2 = %0d", $signed(u_val) >>> 2);
// Now sign-extends: -8/4 = -2
$finish;
end
endmoduleExpected output:
u_val = 248 (unsigned 248)
s_val = -8 (signed -8)
u_val >>> 2 = 62 (zero-fill: 248/4=62)
s_val >>> 2 = -2 (sign-fill: -8/4=-2)
$signed(u_val) >>> 2 = -2Waveform & Simulation Thinking
Result Width — Does Shifting Change the Width?
A shift's result width is set by context, not by the left operand alone. This is the most consequential rule on the page, and stating it as "the result is the same width as the value being shifted" — the common shorthand — is wrong in a way that produces real bugs.
The precise rule: in a shift expression the left operand is context-determined and the right operand (the shift amount) is self-determined. So the left operand is first extended to the width of the surrounding expression, and then shifted. If the shift stands alone — inside a $display argument, or compared against something narrow — its width is its own, and bits shift off the edge and are lost. There is no automatic widening. This is important when left-shifting a value that might overflow.
logic [7:0] a = 8'hFF;
logic [15:0] wide_result;
logic [7:0] narrow_result;
// 8-bit context: a stays 8 bits, the MSB shifts out and is lost.
narrow_result = a << 1; // 8'hFE
// 16-bit context: a is EXTENDED TO 16 BITS FIRST, then shifted.
// No concatenation and no cast is needed - the assignment target
// already widened the expression.
wide_result = a << 1; // 16'h01FE, not 16'h00FE
// These are the same value, written more explicitly. Use them when the
// intent should be visible without knowing the sizing rules - but know
// that they are documentation, not a fix for a truncation that would
// otherwise happen here.
wide_result = {8'h00, a} << 1; // 16'h01FE
wide_result = 16'(a) << 1; // 16'h01FE
// Self-determined context: no assignment target to widen it, so the
// shift is evaluated at 8 bits and the top bit is lost.
$display("in a display arg: 0x%0h", a << 1); // 0xfe <-- differs!
$display("narrow_result: 0x%0h", narrow_result); // 0xfe
$display("wide_result: 0x%0h", wide_result); // 0x1feLook at the last three lines. a << 1 appears twice, character for character, and evaluates to 8'hFE in one place and 16'h01FE in the other. Nothing about the expression decides that — the surrounding context does.
This is the mechanism behind a whole family of reports that read as "the model and the DUT agree in simulation but disagree in the log", or "it works until I widen the accumulator." It is also why the signed case in the next section is dangerous: extension of a signed operand into a wider context happens before the shift, so s >> 1 in a 32-bit context shifts a sign-extended value and produces a large positive number rather than a small one.
Synthesis — Constant vs Variable Shift
| Shift type | Hardware generated | Area / Timing impact |
|---|---|---|
Constant shift (val << 3) | Pure wiring — no gates at all | Zero area, zero delay — just rewire the bits |
Variable shift (val << n) | A select network — conventionally a barrel shifter of ~log2(W) mux stages, though the tool chooses | Real area and delay: cost ≈ data width × number of select stages |
Arithmetic right (>>>) constant | Wiring + sign replication | Near-zero area — replicate MSB N times |
| Arithmetic right variable | The same select network, with the fill driven by the sign bit rather than tied to ground | Slightly larger than the logical form — the fill comes from a signal, not a constant |
Where You'll Use These in Real Projects
// ── 1. Single-bit mask generation — error injection ───────────────
function logic [31:0] bit_mask(input int pos);
return 32'h1 << pos;
endfunction
// ── 2. Protocol field packing — build from named fields ───────────
function logic [31:0] pack_axi_ctrl(
input logic [1:0] burst_type,
input logic [2:0] burst_len,
input logic write
);
return ({29'h0, burst_type} << 16)
| ({29'h0, burst_len} << 8)
| {31'h0, write};
endfunction
// ── 3. Byte lane enable from address ──────────────────────────────
function logic [3:0] byte_enables(input logic [31:0] addr, input int size);
logic [3:0] mask = (4'h1 << size) - 4'h1; // size bytes → N-bit mask
return mask << addr[1:0]; // align to byte offset
endfunction
// ── 4. Scoreboard: extract field from DUT response ────────────────
function logic [7:0] extract_status(input logic [31:0] reg_val);
return (reg_val >> 16) & 8'hFF; // bits [23:16]
endfunction
// ── 5. Constraint: aligned address generation ─────────────────────
class AlignedTxn;
rand logic [31:0] addr;
rand logic [1:0] size; // 0=byte, 1=halfword, 2=word
constraint c_align {
addr[1:0] == 2'b00; // word-aligned
addr inside {[32'h1000:32'h1FFF]};
}
endclassCommon Bugs & How to Debug Them
Bug 1 — Logical >> on Signed Value: Wrong Arithmetic Result
// DUT computes signed accumulator / 4
// Scoreboard model uses wrong shift
int accumulator = -100;
// BUGGY: logical right shift fills 0s — makes negative → large positive
int result_wrong = accumulator >> 2;
$display("Wrong: %0d", result_wrong); // 1073741799 — completely wrong
// CORRECT: arithmetic right shift preserves sign
int result_right = accumulator >>> 2;
$display("Right: %0d", result_right); // -25 — correct (-100/4)Bug 2 — Left Shift Overflow: Result Truncated to Operand Width
logic [7:0] val = 8'hC0; // 1100_0000
// BUGGY: expecting 0x300 but result truncated to 8 bits
logic [7:0] result = val << 2;
$display("result = 0x%0h", result); // 0x00 — top bits lost!
// CORRECT: widen before shifting
logic [9:0] result_wide = {2'b00, val} << 2;
$display("result_wide = 0x%0h", result_wide); // 0x300 — correctBug 3 — >>> on logic [N:0] — No Sign Extension Happens
logic [7:0] data = 8'hF8; // MSB=1 but type is UNSIGNED
// BUGGY: engineer expects sign extension because "MSB is 1"
// But logic [7:0] is unsigned — >>> fills 0s, not the MSB
logic [7:0] wrong = data >>> 2;
$display("Wrong: %0d", wrong); // 62 — zero filled, not sign extended
// CORRECT option 1: declare as signed
logic signed [7:0] s_data = 8'hF8; // same bits = -8
logic [7:0] correct1 = s_data >>> 2;
$display("Correct1: %0d", correct1); // -2
// CORRECT option 2: cast at the shift expression
logic [7:0] correct2 = $signed(data) >>> 2;
$display("Correct2: %0d", correct2); // -2Proving It — Signedness, Context, and the Cast
Three rules decide every shift result: whether the left operand is signed, how wide the surrounding context is, and which operator you used. This testbench exercises all three against each other and self-checks, so a wrong belief fails here rather than in a scoreboard.
// shift_semantics_proof.sv
//
// Self-checking proof of >> vs >>>, signed vs unsigned operands, and the
// effect of the surrounding expression width. Every claim in this chapter
// has a corresponding check below.
module shift_semantics_proof;
int errors = 0;
task automatic chk(string name, logic [31:0] got, logic [31:0] exp);
if (got !== exp) begin
errors++;
$display("FAIL %-46s got=%08h exp=%08h", name, got, exp);
end else
$display("pass %-46s = %08h", name, got);
endtask
initial begin
logic signed [7:0] s;
logic [7:0] u;
logic signed [7:0] r8s;
logic [7:0] r8u;
int r32;
// ================================================================
// 1. SAME BITS, DIFFERENT DECLARED SIGNEDNESS
// u and s hold the identical bit pattern 8'b1111_1000.
// ================================================================
s = -8; // 8'b1111_1000
u = 8'hF8; // 8'b1111_1000 - the same bits
r8s = s >>> 2; chk("signed s >>> 2 (sign fill)", r8s, 8'hFE); // -2
r8u = u >>> 2; chk("unsigned u >>> 2 (ZERO fill)", r8u, 8'h3E); // +62
// The operator did not change. The DECLARATION did. This is the whole
// point: >>> is not "the sign-extending shift", it is the shift that
// respects signedness - and it has none to respect on `u`.
// 2. The cast restores it, for this expression only.
r8u = $signed(u) >>> 2;
chk("$signed(u) >>> 2 (cast to signed)", r8u, 8'hFE);
// 3. >> ignores signedness entirely - both give the zero-filled result.
r8s = s >> 2; chk("signed s >> 2 (still zero fill)", r8s, 8'h3E);
r8u = u >> 2; chk("unsigned u >> 2", r8u, 8'h3E);
// ================================================================
// 4. CONTEXT WIDTH - the same expression, two answers
// ================================================================
r8s = s >> 1; chk("s >> 1 into 8-bit target", r8s, 8'h7C); // +124
r32 = s >> 1; chk("s >> 1 into 32-bit target", r32, 32'h7FFF_FFFC);
// Why: assigning to `int` makes the shift a 32-bit expression, so the
// context-determined left operand is SIGN-extended to 32'hFFFF_FFF8
// FIRST, and only then shifted right with a zero fill.
//
// The arithmetic shift is stable across contexts, because sign-extending
// and then sign-filling gives the same value at any width:
r8s = s >>> 1; chk("s >>> 1 into 8-bit target", r8s, 8'hFC); // -4
r32 = s >>> 1; chk("s >>> 1 into 32-bit target", r32, 32'hFFFF_FFFC); // -4
// 5. Left shifts: <<< is identical to <<, for signed and unsigned alike.
chk("s << 1 equals s <<< 1", 8'(s << 1), 8'(s <<< 1));
chk("u << 1 equals u <<< 1", 8'(u << 1), 8'(u <<< 1));
// 6. Over-shift. Logical goes to zero; arithmetic goes to the sign.
chk("u >> 8 (over-shift, logical)", 8'(u >> 8), 8'h00);
chk("s >>> 8 (over-shift, negative)", 8'(s >>> 8), 8'hFF);
s = 8'sd8;
chk("s >>> 8 (over-shift, positive)", 8'(s >>> 8), 8'h00);
// 7. Floor division, not truncation. This is the reference-model trap.
s = -7;
chk("-7 >>> 2 is -2 (floor)", 8'(s >>> 2), 8'hFE);
chk("-7 / 4 is -1 (truncate)", 8'(s / 4), 8'hFF);
// They agree only when the division is exact:
s = -8;
chk("-8 >>> 2 == -8 / 4", 8'(s >>> 2), 8'(s / 4));
if (errors == 0) $display("\nshift_semantics_proof: ALL CHECKS PASSED");
else $display("\nshift_semantics_proof: %0d FAILURES", errors);
$finish;
end
endmoduleCheck 1 is the one to sit with. s and u hold the same eight bits. The same operator applied to them produces -2 and +62. Nothing in the expression distinguishes the two cases — only the declaration twenty lines away does.
A signed audio sample was declared unsigned, so the arithmetic shift filled zeros
UNSIGNED-FIELD-BREAKS-SIGN-FILL// ❌ BUG: the sample field is 16-bit two's-complement audio, but the struct
// declares it unsigned. The gain stage uses >>> deliberately, to
// preserve the sign - and it does not, because there is no sign to
// preserve at the language level.
typedef struct packed {
logic [7:0] channel;
logic [15:0] sample; // ❌ two's-complement data, declared UNSIGNED
logic [7:0] flags;
} audio_word_t;
always_comb begin
audio_word_t w = unpack(bus_data);
scaled = w.sample >>> gain_shift; // intent: divide, keep the sign
end // actual: zero fill, positive result
// For w.sample = 16'hFF00 (-256) and gain_shift = 2:
// intended -256 >>> 2 = -64 = 16'hFFC0
// actual 16'hFF00 >>> 2 = 16'h3FC0 = +16320
// ✅ FIX (preferred): declare the field with the signedness the data has.
// This fixes every operation on it - shift, compare, extend, divide -
// not just the one expression that happened to be noticed.
typedef struct packed {
logic [7:0] channel;
logic signed [15:0] sample; // ✅ signed, as the data actually is
logic [7:0] flags;
} audio_word_t;
// ✅ FIX (when the struct is not yours to change): cast at the point of use.
scaled = $signed(w.sample) >>> gain_shift;A digital gain stage passed every directed test and failed in the lab as a distinctive, reproducible distortion: quiet passages were clean, loud passages buzzed, and an oscilloscope on the output showed the negative half of the waveform folded up into the positive rail.
The RTL used >>>. The reviewer had specifically asked for >>> rather than >> during code review, and had confirmed it was there.
gain_shift = 2
input sample 16'hFF00 (-256)
expected 16'hFFC0 (-64)
observed 16'h3FC0 (+16320)>>> is not "the sign-extending shift." It is the shift that respects the signedness of its left operand, and logic [15:0] is unsigned. Presented with an unsigned operand, >>> fills zeros — which is correct, specified behaviour, and identical to >>.
So the operator that was chosen specifically to protect the sign did nothing, and the code review that confirmed its presence confirmed the wrong thing. The reviewer checked the operator; the bug was in the declaration, in a different file.
Two properties made this survive testing.
The directed tests used positive samples. For any non-negative value >> and >>> produce identical results, so the entire distinction is invisible until a negative number appears. A test suite built from ascending ramps and tone bursts starting at zero can exercise the gain stage thousands of times without ever discriminating between the two operators.
The wrong answer is in range. +16320 is a perfectly legal 16-bit sample. There is no X, no overflow flag, no assertion to trip. The only detector is a model that knows the value should have been negative — and the reference model had inherited the same unsigned struct definition, so it computed the same wrong answer and the scoreboard agreed with the DUT.
That last point is the general lesson. A reference model that shares a type definition with the DUT cannot catch a bug in that type definition. The struct was the single source of truth for both, so the error was outside what the comparison could see.
Fix the declaration, not the expression. logic signed [15:0] sample corrects the shift and also every comparison, extension, and division that touches the field — including ones not yet written. The cast form $signed(w.sample) >>> gain_shift is correct but repairs exactly one expression and leaves the next author to rediscover the problem.
Three guards, in increasing order of what they catch:
// 1. Make the intent checkable at the boundary. A signed field that has
// just been shifted right arithmetically cannot have changed sign.
a_gain_preserves_sign: assert property (@(posedge clk) disable iff (!rst_n)
(gain_shift != 0) |-> ($signed(scaled) <= 0) == ($signed(w.sample) <= 0))
else $error("gain stage changed the sign of a sample");
// 2. Cover the case the directed tests never produced. A `negative` bin
// at zero hits is the signature of a suite that could not have found
// this, whatever else it covered.
covergroup cg_sample;
cp_sign : coverpoint $signed(w.sample) {
bins negative = {[-32768:-1]};
bins zero = {0};
bins positive = {[1:32767]};
}
endgroup
// 3. Lint. Most tools can flag >>> applied to an operand that is not
// signed. It is a warning by default in several; make it an error.The habits worth taking from this beyond the immediate fix:
- Declare signedness where the data is defined, not where it is used. A cast fixes one expression; a declaration fixes the type. If a field holds two's-complement data, say so in the struct.
>>>on an unsigned operand is a code smell, not a safeguard. It reads as protective and is inert. Either the operand should be signed, or>>is the honest operator.- A test suite that never drives a negative value cannot distinguish
>>from>>>. Cover the sign of every signed field. It is a one-line coverpoint and it is the only guard here that works when the reviewer, the model, and the lint rule have all been defeated by a shared type definition.
Interview Questions
Best Practices & Coding Guidelines
Declare signed types explicitly
For signed data, use int, shortint, or logic signed [N:0]. Never rely on a high MSB alone — the type determines >>> behavior.
Widen before shifting left
If the left shift result needs to be wider than the operand, widen first: 16'(narrow) << n or concatenate zeros. Shifting a narrow variable silently discards high bits.
Use >>> only on genuinely signed operands
Use >>> exclusively for signed division-by-power-of-two. For unsigned operations, >> is clearer and communicates intent.
Match model to DUT implementation
If the DUT uses arithmetic shift, your scoreboard reference model must also use arithmetic shift — not integer division, which truncates differently for negative non-exact values.
| Task | Correct approach | Common mistake |
|---|---|---|
| Multiply unsigned by 2^n | val << n | Forgetting result is same width — overflow silently |
| Divide unsigned by 2^n | val >> n | Using >>> — works but wrong operator semantically |
| Divide signed by 2^n | signed_val >>> n | Using >> — wrong result for negative values |
| Generate bit mask at position n | 32'h1 << n | 1 << n — 1 is 32-bit int but expression context may truncate |
| Extract upper byte from 32-bit | (val >> 24) & 8'hFF | Forgetting the AND mask — implicit sign-extension can corrupt |
| Left shift wider result needed | 16'(val) << n | val << n — result stays at original width |
Summary
Four shift operators, but the real decision you make is between two: >> and >>>. Left shifts (<< and <<<) are identical. Right shifts diverge only for signed negative values.
>>>only sign-extends when the operand is a signed type. Applying>>>tologic [N:0]produces the same zero-fill result as>>. The operator does not override the declaration. Use$signed()to cast if needed.- Arithmetic right shift is floor division, not truncation. For negative non-exact values,
x >>> ngives a different result thanx / (1 << n). Your scoreboard reference model must match the DUT's implementation exactly. - Left shifts do not widen the result. The result is always the same width as the left operand. Bits shifted past the MSB are lost. Widen before shifting when the full result is needed.
Related Pages & References
Signedness is the other half of this topic. A shift operator can only respect the signedness its operand was declared with, so Integer Types and 2-state vs 4-state Types decide what >>> will do before the shift is ever written. Arithmetic Operators covers the division that a shift is often standing in for — and the truncate-toward-zero behaviour that makes / 4 differ from >>> 2 on negative operands.
Where shifts sit among the other operators. Shift binds below arithmetic, so a + 2 << 3 is (a + 2) << 3 — see Operator Precedence, which also covers why parentheses do not contain a width. For the per-bit operations shifts are often combined with, see Bitwise Operators; for building a shifted field into a wider word, Concatenation, Replication & Conditional, whose braces are a sizing boundary in the way parentheses are not. For the loops that a variable shift is sometimes written as by mistake, see Loops. For the unary &, |, ^ that collapse a shifted vector to a status bit, see Reduction Operators; for the truth-value forms used in the conditions that guard a shift, Logical Operators.
References.
- IEEE 1800 (SystemVerilog) — the shift operators are defined in the operators-and-expressions clause. Two rules on this page trace directly to it and were corrected against it:
>>>fills with the sign bit only when the left operand is signed, filling zero otherwise; and in a shift expression the left operand is context-determined while the shift amount is self-determined, which is why the same expression yields a different value depending on the width of what it is assigned to. The signedness rules for expressions, and the$signed/$unsignedsystem functions, are in the same clause. - IEEE 1364 (Verilog) —
<<<and>>>were introduced in Verilog-2001 along with signed types; the semantics are unchanged in SystemVerilog.
Requirement versus practice. The fill behaviour, the context-determination rule, and the over-shift results are language requirements — a conforming simulator has no latitude on any of them. What a synthesis tool builds for a variable shift is not specified: a barrel shifter of roughly log₂(W) mux stages is the conventional structure and a reasonable cost model, but the topology depends on the library, the constraints, and the surrounding logic. This page gives the cost model and avoids asserting the structure.
Part of SystemVerilog Fundamentals·Operators & Expressions·Lesson 24 of 53
View program