Unreachable code analysis identifies RTL constructs that can never be executed under any possible input stimulus. In a coverage-driven verification flow, unreachable code creates a specific problem: it appears as a permanent hole in structural coverage metrics, and no amount of additional testing will ever close it. Knowing which coverage holes are genuinely unreachable — rather than simply untested — is essential for making accurate signoff decisions and avoiding wasted verification effort.

What You'll Learn

  • What makes RTL code structurally unreachable and the common patterns that cause it
  • How unreachable code affects coverage closure and signoff decisions
  • The correct process for identifying, classifying, and formally excluding unreachable code
  • How to distinguish between a coverage hole that needs more tests and one that warrants an exclusion

What Is Unreachable Code in RTL?

In RTL, code is unreachable when the logic conditions required to execute it can never be simultaneously true, given the design's architectural constraints. This is a structural property of the design — it is independent of how much stimulus the testbench generates. No test vector can ever cause execution to reach unreachable code because the design's own logic prevents it.

This is fundamentally different from code that is simply untested. Untested code is reachable in principle but has not been exercised yet — adding the right test closes the coverage hole. Unreachable code will remain a coverage hole no matter how many tests are added, because the path to it is impossible by construction.

The distinction between unreachable and untested is the most important judgment call in coverage closure. Misclassifying untested code as unreachable leads to exclusions that paper over real verification gaps. Misclassifying unreachable code as untested leads to wasted engineering effort chasing holes that provably cannot be closed.

Common Sources of Unreachable Code in RTL

1. Fully Enumerated Case Statements with a Default Arm

When a case statement explicitly handles every possible value of its selector, the default arm is structurally unreachable. The selector has no remaining values that could fall through to it.


typedef enum logic [1:0] {
    IDLE  = 2'b00,
    READ  = 2'b01,
    WRITE = 2'b10,
    DONE  = 2'b11
} state_t;

state_t state;

always_comb begin
    case (state)
        IDLE:  next_state = READ;
        READ:  next_state = WRITE;
        WRITE: next_state = DONE;
        DONE:  next_state = IDLE;
        default: next_state = IDLE;  // Unreachable: all 2-bit values handled above
    endcase
end

All four 2-bit values are explicitly covered by the four named arms. The default arm can only be reached by an X or Z on state, which does not occur in normal synthesized logic. Branch coverage will flag this as a hole; the correct response is a documented exclusion, not a new test.

2. Conditions That Are Mutually Exclusive by Design

Some conditions are written as defensive guards but are architecturally impossible to satisfy simultaneously, making one branch of their logic unreachable.


// FIFO controller: count is always in range [0, DEPTH]
// count can never be both > DEPTH and < 0 simultaneously
always_comb begin
    if (count > DEPTH) begin
        // Unreachable: count is constrained to [0, DEPTH] by the
        // increment/decrement logic upstream
        overflow_flag = 1'b1;
    end else begin
        overflow_flag = 1'b0;
    end
end

If the upstream logic guarantees count is always in range, the count > DEPTH branch is unreachable by design. It may have been written as a defensive guard during development and never removed. Statement and branch coverage will flag it as a hole indefinitely.

3. Parameter-Constrained Logic

Parameterized RTL is a frequent source of unreachable code because parameters fix certain conditions at elaboration time, making branches that serve other parameter configurations permanently unreachable for the current instance.


module bus_interface #(
    parameter BUS_WIDTH = 32
) (
    input  logic [BUS_WIDTH-1:0] data_in,
    output logic [BUS_WIDTH-1:0] data_out
);
    always_comb begin
        if (BUS_WIDTH == 64) begin
            // This block is unreachable when BUS_WIDTH=32
            data_out = {data_in[31:0], data_in[31:0]};
        end else begin
            data_out = data_in;
        end
    end
endmodule

When this module is instantiated with BUS_WIDTH=32, the 64-bit branch is elaborated into the design but can never be reached during simulation. A synthesis tool would optimise it away entirely. Coverage tools, however, still instrument it and report the hole.

For parameterized modules with parameter-gated branches, it is common practice to use `ifdef or generate blocks instead of runtime if statements, which prevents the unreachable branch from being instrumented for coverage in the first place.

4. Impossible Encoding Combinations on Multi-Bit Signals

When a signal is constrained to a subset of its possible bit-pattern encodings by upstream logic, the case arms covering the unused encodings become unreachable. The key is that the constraint must be visible in the RTL — either through the signal's declared type, a parameter bound, or an upstream assignment that limits the range.

A common example is a 3-bit opcode field that is driven directly from a 2-bit instruction decoder. The upstream decoder only ever outputs values 0–3, so the 3-bit opcode wire can never carry values 4–7:


// Upstream decoder: 2-bit input maps to 3-bit opcode
// The output can only ever be 3'd0 through 3'd3
module instr_decode (
    input  logic [1:0] instr,
    output logic [2:0] opcode
);
    always_comb begin
        case (instr)
            2'd0: opcode = 3'd0;   // ADD
            2'd1: opcode = 3'd1;   // SUB
            2'd2: opcode = 3'd2;   // AND
            2'd3: opcode = 3'd3;   // OR
        endcase
    end
endmodule

// Downstream ALU: receives opcode, but values 4-7 can never arrive
// because the upstream decoder is constrained to a 2-bit input
always_comb begin
    case (opcode)
        3'd0: result = a + b;
        3'd1: result = a - b;
        3'd2: result = a & b;
        3'd3: result = a | b;
        default: result = '0;   // Unreachable: opcode is driven by a 2-bit
                                // source; values 4-7 are impossible by construction
    endcase
end

The default arm is unreachable not because the designer declared it so, but because the 2-bit input to the upstream decoder structurally limits the opcode to four values. This constraint may not be visible locally in the ALU — which is exactly why cross-module analysis is needed to confirm the exclusion, rather than just looking at the ALU file in isolation.

Quick Check

Q: A 2-bit signal controls a case statement with only three explicit arms and a default. Is the default arm necessarily unreachable?

Show Answer

No — not necessarily. A 2-bit signal has four possible values (00, 01, 10, 11). With only three explicit arms, the fourth value can reach the default. The default is only unreachable when all possible encoding values are explicitly covered by named arms. With one encoding unaccounted for, the default is reachable and should be tested.

How Unreachable Code Affects Coverage Closure

Every unreachable construct generates a permanent coverage hole. If left unresolved, these holes prevent the design from ever reaching the project's coverage threshold — even after every reachable scenario has been tested. This creates two practical problems:

  • False coverage ceiling: The maximum achievable coverage percentage is less than 100% because unreachable code is counted in the denominator. A design with 5% unreachable code can never exceed 95% coverage, regardless of test quality.
  • Noise in hole analysis: Unreachable coverage holes mix with genuine untested holes in the coverage report. Engineers spend time investigating holes that are provably impossible to close, instead of focusing on real verification gaps.

The resolution is a formal coverage exclusion — removing unreachable constructs from the coverage calculation with documented justification. This adjusts the denominator so the percentage accurately reflects the testable design surface.

The Exclusion Process

Applying exclusions correctly is a controlled process. Blanket exclusions applied without investigation are one of the most common sources of false confidence in verification sign-off.

Step 1: Investigate the hole

Before excluding anything, confirm the construct is genuinely unreachable. Check the RTL: is the guarding condition constrained by upstream logic, a parameter value, or an encoding rule? If the answer is not obvious, ask the RTL designer to confirm.

Step 2: Classify the cause

Determine which category the unreachable code falls into:

CategoryExampleCorrect Action
Fully enumerated case defaultAll enum values handled; default guards against X/ZExclude with justification
Parameter-gated branchif (BUS_WIDTH == 64) in a 32-bit instanceExclude for this instance; consider using generate
Architecturally impossible conditionOverflow guard on a constrained counterExclude with justification; consider removing the dead code from RTL
Looks unreachable but may not beError path that requires a fault injection testAdd a directed test before excluding
Genuinely untestedMissing test scenario, not a design constraintAdd tests — do not exclude

Step 3: Document the exclusion

Every exclusion must include a written justification that explains why the code is unreachable. Undocumented exclusions create audit risk at tapeout review and make future maintenance harder — the next engineer to work on the design has no way to know whether the exclusion was intentional or accidental.


// Example: inline exclusion comment for tool-based exclusion files

// COVERAGE EXCLUDE: default arm of state_t case statement.
// All four 2-bit enum values (IDLE, READ, WRITE, DONE) are explicitly handled.
// The default arm guards against X/Z propagation in simulation only.
// Reviewed and approved by: [lead verification engineer]
// Date: [review date]
default: next_state = IDLE;

Step 4: Review exclusions as a team

Coverage exclusions should be reviewed in coverage closure meetings, not applied unilaterally. A second reviewer — either a peer engineer or the verification lead — should confirm each exclusion before it is committed to the exclusion file.

Excluding code that is hard to reach but technically reachable is not the same as excluding code that is provably unreachable. Difficult-to-reach paths — error injection scenarios, fault conditions, boundary values — must be tested, not excluded. The test may require a directed test case or a targeted constraint, but the code must be exercised.

Unreachable Code Analysis at Tapeout

In a production verification flow, coverage exclusions are a formal deliverable reviewed during tapeout readiness meetings alongside the coverage report itself. The exclusion file records every construct removed from the coverage calculation, with the category, justification, and reviewer sign-off. Auditors look for exclusions that cover large blocks of logic or that lack clear architectural justification — these are the most common indicators of coverage closure being achieved on paper rather than in practice.

Unreachable Code vs Dead Code

These terms are related but not identical, and confusing them leads to different incorrect responses.

Unreachable code is code that exists in the RTL and is structurally sound, but whose guard condition can never be true given the design's architecture. The code is logically correct — it simply has no path leading to it under normal operation. A default arm guarding against X/Z is unreachable, but it is not a bug.

Dead code is code that was once active but has become obsolete — typically due to a design revision that made the feature it implements permanently disabled or bypassed. Dead code is a design quality issue: it adds complexity, may confuse future maintainers, and should generally be removed from the RTL rather than simply excluded from coverage.


// Dead code example: a feature disabled by a parameter that will
// never be enabled in production
parameter ENABLE_LEGACY_MODE = 0;  // Never enabled; legacy feature removed

always_comb begin
    if (ENABLE_LEGACY_MODE) begin
        // Dead code: this block was active in v1.0 but the feature
        // was removed. The parameter is hardwired to 0 everywhere.
        out = legacy_process(in);
    end else begin
        out = normal_process(in);
    end
end

The correct action here is not to exclude the dead block but to remove it from the RTL entirely. Keeping dead code in the design adds maintenance burden and perpetuates confusion about the design's actual behaviour.

Quick Check

Q: A coverage hole persists on an error-handling branch after 10,000 simulation cycles of constrained-random testing. A team member proposes excluding it as "unreachable." What should happen first?

Show Answer

Investigation before exclusion. The fact that random stimulus did not hit the branch does not make it unreachable — error-handling paths often require specific fault conditions that constrained-random generation rarely produces. The correct first step is to confirm with the RTL designer whether the branch is architecturally reachable. If it is, write a directed test that forces the error condition. Only if the designer confirms it is provably unreachable — and can explain why — should an exclusion be applied and documented.

Common Mistakes

Mistake #1: Using exclusions to meet a coverage target rather than to remove genuinely unreachable code

Excluding code to reach a numerical threshold without confirming it is truly unreachable is the most dangerous misuse of coverage exclusions. It produces a report that shows target compliance while leaving real verification gaps open. Every exclusion must be grounded in a provable architectural reason — not in the difficulty of generating the required stimulus.

Mistake #2: Leaving dead code in the RTL and treating it as an exclusion

Dead code — logic made permanently inactive by design decisions — should be removed from the RTL, not excluded from coverage. Keeping it adds clutter, risks future accidental re-activation, and forces the exclusion list to grow with every design revision. If a feature is permanently disabled, delete it.

Mistake #3: Applying exclusions without a documented justification

An exclusion file without justifications is opaque to every engineer who inherits the design. Future maintainers cannot tell whether an exclusion was deliberate or accidental, whether the architectural constraint that justified it still holds, or whether it should be re-examined after a design change. Always document the reason, the reviewer, and the date alongside each exclusion.

Mistake #4: Not revisiting exclusions after RTL changes

An exclusion that was valid for one version of the RTL may become invalid after a design change. A parameter value that was fixed may become configurable; a constrained counter range may be widened; an encoding that was fully enumerated may gain a new value. Coverage exclusions must be reviewed whenever the RTL they reference is modified, to confirm the architectural justification still applies.

Summary

In this tutorial, you learned:

  • Unreachable code is structurally impossible to execute given a design's architecture — no amount of stimulus can close these coverage holes, which distinguishes them from untested code that simply needs more tests
  • Common sources include fully enumerated case default arms, parameter-gated branches, architecturally constrained conditions, and impossible encoding combinations
  • The correct response is a formal, documented coverage exclusion — not a blanket exclusion to meet a target, but a per-construct justification confirming the architectural reason the code is unreachable
  • Dead code — permanently disabled logic — should be removed from the RTL entirely rather than excluded from coverage, to keep the design clean and maintainable
Did you find what you were looking for?

Quiz

No quiz questions available for this article.