Skip to content
DenseChip
Learn
PracticeCode labInterview

DenseChip

Navigate

Menu

LearnPracticeCode labInterview

Tools

All toolsSV editorCode repoResume
DenseChip

Practice, courses, and chip-design tools in one focused workspace.

LearnPracticeCode labInterviewToolsLearning paths

© 2026 DenseChip

AboutContactPrivacyTerms
Skip to lesson

Course contents

  • RTL Foundations36
  • Combinational Design25
  • Sequential Design27
  • FSM & Control27
    • Controller and Datapath
      • Split Design: Intent First
      • Signal Contract Between FSM and Datapath
      • Two-Process FSM Style
      • Coding Rules That Prevent Latent Bugs
      • Worked Example: GCD
      • FSM Bug Stories
    • Moore vs Mealy
    • State Encoding
    • Multi-Cycle Sequencers
    • UART Transmitter
    • Memory Controller
  • Timing & Clocking39
  • Reset & CDC34
  • Memory & Buses57
  • Advanced RTL28
  • Senior & Interview76
  • Glossary and Notation
Loading lesson

‹Controller and Datapath
  1. Topics
  2. RTL Design
  3. FSM & Control
  4. Controller and Datapath
  5. FSM Bug Stories

Part 4 · FSM & Control · Intermediate

FSM Bug Stories

Three FSM bugs that survive the coding rules, their waveform signatures and their one-line fixes.

On this page

    Why this matters

    An FSM with a default on every output can still write junk, send one beat too many or hang. Each of these bugs leaves a fixed mark in the waveform, and the first wrong slot points at the case branch to fix.

    After this lesson you can: read a failing FSM waveform slot by slot, find the first wrong slot, name the state and input that caused it, and write the one-line fix for three common bug shapes.

    Most FSM bugs have one of three shapes

    A tester writes: “the memory took three writes, the driver sent two.” The FSM behind it has a default on every output and a recovery branch for every illegal state, and it still writes junk. Bugs that survive the coding rules usually take one of three shapes, and each leaves its own mark in the waveform.

    Shape

    What the tester reports

    What the trace shows

    An output decodes an input the protocol does not hold

    An extra or early action

    The output is high in a slot where the input's qualifier (valid, enable) is low

    An exit condition is off by one

    One beat too many or too few

    A counter value that should never appear in that state

    An event arrives in a state that does not test it

    A hang or a timeout

    The event pulses, then the state never changes again

    The three stories below are real simulations of small buggy FSMs. In each one, find the first wrong slot, read the state and inputs there, and the case branch at fault follows.

    Story 1: a write nobody sent

    The sender's cmd bus means something only while valid is high. Between offers the sender leaves the last value on the bus, which the protocol allows. The write controller pulses wr_en for one cycle and then spends one cycle in BUSY while the memory recovers.

    wr_ctrl.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    module wr_ctrl (
      input  logic       clk, rst_n,
      input  logic       valid,          // cmd means something only while valid = 1
      input  logic [1:0] cmd,            // 2'd1 = WRITE
      output logic       wr_en,
      output logic       busy
    );
      typedef enum logic {IDLE, BUSY} state_t;
      state_t state, next;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) state <= IDLE;
        else        state <= next;
      always_comb begin
        next = state;  wr_en = 1'b0;
        case (state)
          IDLE: if (cmd == 2'd1) begin wr_en = 1'b1; next = BUSY; end  // BUG: valid not checked
          BUSY: next = IDLE;                                           // one recovery cycle
          default: next = IDLE;
        endcase
      end
      assign busy = (state == BUSY);
    endmodule

    Read each slot as one cycle, and look for an output that breaks the protocol rule “write only for a valid WRITE”.

    • Slot 1: valid = 1 and cmd = 1, so wr_en = 1. This is the first real write.

    • Slot 2: BUSY. The sender drops valid but leaves cmd at 1, which is legal.

    • Slot 3, the first wrong slot: wr_en = 1 with valid = 0. The FSM is back in IDLE and decodes the stale cmd.

    • Slot 6: the second real write. The memory saw writes in slots 1, 3 and 6.

    Root cause: the IDLE branch tests cmd but not valid. A Mealy output reads its inputs in the same cycle, so it is only as clean as the inputs it decodes. Fix:

    design.sv
    // snippet
          IDLE: if (valid && cmd == 2'd1) begin wr_en = 1'b1; next = BUSY; end

    The same shape appears when a Mealy output decodes a bus during the cycles a mux is switching it, or reads a status flag the datapath updates one cycle late. Qualifying the decode is cheaper than turning the output into a Moore output, which costs a cycle of latency.

    Story 2: five beats in a four-beat burst

    The bus monitor reports five data beats for every burst of length 4, and the receiving FIFO overflows by one entry per burst. The counter cnt holds the number of beats already sent in this burst.

    burst_ctrl.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    module burst_ctrl #(parameter int LEN = 4) (
      input  logic clk, rst_n, start,
      output logic beat, done
    );
      typedef enum logic [1:0] {IDLE, BURST, DONE} state_t;
      state_t state, next;
      logic [2:0] cnt;                          // beats already sent in this burst
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) begin state <= IDLE; cnt <= 3'd0; end
        else begin
          state <= next;
          cnt   <= (state == BURST) ? cnt + 3'd1 : 3'd0;
        end
      always_comb begin
        next = state;  beat = 1'b0;  done = 1'b0;
        case (state)
          IDLE:  if (start) next = BURST;
          BURST: begin beat = 1'b1; if (cnt == LEN) next = DONE; end  // BUG: cnt starts at 0
          DONE:  begin done = 1'b1; next = IDLE; end
          default: next = IDLE;
        endcase
      end
    endmodule

    Count the slots where beat is high, and read cnt in each one.

    • Slot 1: start = 1 in IDLE. The edge at the end of the slot moves the FSM to BURST.

    • Slots 2 to 5: four beats, with cnt = 0, 1, 2 and 3. A correct burst ends here.

    • Slot 6, the first wrong slot: a fifth beat with cnt = 4. A four-beat burst should never show that value while beat is high.

    • Slot 7: DONE, one cycle late.

    Root cause: during the last beat cnt holds LEN − 1, because it counts beats sent before the current one. Fix:

    design.sv
    // snippet
          BURST: begin beat = 1'b1; if (cnt == LEN - 1) next = DONE; end

    Write what the counter means at the moment of the compare in a comment next to its declaration: “beats already sent” or “number of the current beat”. The exit value then follows from the comment, and a reviewer can check it without simulating.

    Story 3: a request that never finishes

    The regression times out on the first request that hits the responder's fast path; slow requests pass. The requester raises req in REQ, keeps it high in WAIT, and leaves WAIT on ack. The responder pulses ack once per request, from zero to three cycles after req rises.

    req_ctrl.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    module req_ctrl (
      input  logic clk, rst_n, start, ack,
      output logic req, done
    );
      typedef enum logic [1:0] {IDLE, REQ, WAIT, DONE} state_t;
      state_t state, next;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) state <= IDLE;
        else        state <= next;
      always_comb begin
        next = state;  req = 1'b0;  done = 1'b0;
        case (state)
          IDLE: if (start) next = REQ;
          REQ:  begin req = 1'b1; next = WAIT; end                  // BUG: ack ignored here
          WAIT: begin req = 1'b1; if (ack) next = DONE; end
          DONE: begin done = 1'b1; next = IDLE; end
          default: next = IDLE;
        endcase
      end
    endmodule

    Follow ack and ask, in each slot where it is high, which state was there to see it.

    • Slots 1 to 5: request 1. req rises in slot 2 (REQ), ack arrives in slot 4 (WAIT), and DONE follows in slot 5.

    • Slot 7, the first wrong slot: ack = 1 while state = 1 (REQ). REQ moves to WAIT whatever ack says.

    • Slots 8 to 11: WAIT with req high and ack low for ever. The responder has already answered and will not answer again.

    Root cause: the protocol allows an answer in the first request cycle, and REQ does not test ack. Fix:

    design.sv
    // snippet
          REQ:  begin req = 1'b1; if (ack) next = DONE; else next = WAIT; end

    Slot 7 is now a transition: the same ack that was lost moves the FSM to DONE in slot 8, and the machine is idle again in slot 9. Merging REQ into WAIT would fix it too. A lock-up always has this shape in a trace: the release event happens, but in a state that does not look for it, one or more slots before the hang becomes visible. Lock-ups caused by an illegal encoding are a different mechanism, covered in illegal states and recovery.

    A method for the next bug

    1. Write the symptom as a broken rule: which output, which promise (“wr_en only with valid”, “four beats”, “every request finishes”).

    2. Find the first wrong slot. Move back from the reported failure until the rule holds again; the slot after that is the one to study.

    3. In that slot, read the state and every input the FSM decodes in that state.

    4. The state names the case branch; the inputs name the condition that ran inside it.

    5. Compare that branch with the protocol: does it test the qualifier, the right count, and every event that can arrive in this state?

    6. Fix the branch, then rerun the failing stimulus and the bench below.

    Checking the fixes

    The bench drives the three fixed controllers together. Story 1 gets 200 cycles of random offers with junk on cmd whenever valid is low, and every write is counted against the valid WRITE offers. Story 2 runs three bursts and counts the beats in each. Story 3 runs one request for each ack latency from 0 to 3 and fails any request that is not done within ten cycles. Each check is aimed at one of the three bug lines.

    wr_ctrl.sv
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    module wr_ctrl (
      input  logic       clk, rst_n,
      input  logic       valid,
      input  logic [1:0] cmd,            // 2'd1 = WRITE
      output logic       wr_en,
      output logic       busy
    );
      typedef enum logic {IDLE, BUSY} state_t;
      state_t state, next;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) state <= IDLE;
        else        state <= next;
      always_comb begin
        next = state;  wr_en = 1'b0;
        case (state)
          IDLE: if (valid && cmd == 2'd1) begin wr_en = 1'b1; next = BUSY; end
          BUSY: next = IDLE;
          default: next = IDLE;
        endcase
      end
      assign busy = (state == BUSY);
    endmodule
    
    module burst_ctrl #(parameter int LEN = 4) (
      input  logic clk, rst_n, start,
      output logic beat, done
    );
      typedef enum logic [1:0] {IDLE, BURST, DONE} state_t;
      state_t state, next;
      logic [2:0] cnt;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) begin state <= IDLE; cnt <= 3'd0; end
        else begin
          state <= next;
          cnt   <= (state == BURST) ? cnt + 3'd1 : 3'd0;
        end
      always_comb begin
        next = state;  beat = 1'b0;  done = 1'b0;
        case (state)
          IDLE:  if (start) next = BURST;
          BURST: begin beat = 1'b1; if (cnt == LEN - 1) next = DONE; end
          DONE:  begin done = 1'b1; next = IDLE; end
          default: next = IDLE;
        endcase
      end
    endmodule
    
    module req_ctrl (
      input  logic clk, rst_n, start, ack,
      output logic req, done
    );
      typedef enum logic [1:0] {IDLE, REQ, WAIT, DONE} state_t;
      state_t state, next;
      always_ff @(posedge clk or negedge rst_n)
        if (!rst_n) state <= IDLE;
        else        state <= next;
      always_comb begin
        next = state;  req = 1'b0;  done = 1'b0;
        case (state)
          IDLE: if (start) next = REQ;
          REQ:  begin req = 1'b1; if (ack) next = DONE; else next = WAIT; end
          WAIT: begin req = 1'b1; if (ack) next = DONE; end
          DONE: begin done = 1'b1; next = IDLE; end
          default: next = IDLE;
        endcase
      end
    endmodule
    

    Common mistakes

    Common pitfall

    Fixing the slot where the failure is reported instead of the first wrong slot. In story 3 the hang is visible from slot 8, and a timeout added to WAIT makes the test pass: the request now fails slowly instead of hanging, and the lost ack in slot 7 is still lost. Move the cursor back to the first slot that breaks the rule before changing any RTL.

    Check yourself In story 2, a colleague starts cnt at 1 instead of 0 and keeps cnt == LEN. Does the burst have four beats now, and what else must they check?

    Yes: cnt now holds the number of the current beat (1 to 4), and the compare with 4 ends the burst after the fourth beat. The counter's meaning has changed, so every other reader of cnt, such as an address offset or a last-beat flag, must be checked against the new meaning.

    Check yourself In story 3, the responder model is changed so that ack stays high until req drops, instead of pulsing once. Does the buggy FSM still hang?

    No. In slot 7 REQ ignores ack, but ack is still high in slot 8 when the FSM is in WAIT, so it goes to DONE one cycle late. The bug becomes one cycle of extra latency, which is why it can pass a whole regression that uses a level-ack responder model and hang against the real pulse-ack responder.

    Remember

    • A Mealy output is only as clean as the inputs it decodes: qualify it with valid or enable.

    • Write what a counter means at the compare (beats already sent), and the exit value follows.

    • A lock-up starts before the hang: the release event arrived in a state that does not test it.

    Rule of thumb: find the first wrong slot, then ask which state and which input made it.

    How this was checked

    Waveforms: Icarus Verilog 13.0, 2026-10-17, node scripts/tools/gen-waves.mjs fsm:controller-datapath on rich/fsm/wave-src/controller-datapath/spurious-write.sv, burst-five-beats.sv, lockup.sv and lockup-fixed.sv. Every lane matched its hand-written expectation (no MISMATCH), and a second run left waves-controller-datapath.mjs unchanged.

    Bench: Icarus Verilog 13.0, 2026-10-17, iverilog -g2012 -s tb_fsm_fixes then vvp. Result: PASS, 42 writes for 42 valid offers, 3 bursts of 4 beats, requests done for ack latency 0-3. Each bug line put back alone fails it: story 1 with 69 writes for 36 offers, story 2 with 5 beats per burst, story 3 with a lock-up at latency 0.

    Scope: three small FSMs with one simplified responder or sender model each, directed and random stimulus from the default seed. This is not a formal proof, and other bug shapes (X propagation, illegal encodings, latches) are covered elsewhere.

    Keep going

    • Practice: FSM deadlock and livelock debugging

    • Practice: FSM output encoding: registered vs combinational hazards

    • Code lab: Handshake FSM

    • Interview: run a mock interview on this topic

    Related topics

    • Debug hierarchy before touching code

    • Illegal states and recovery

    Previous

    94 / 350

    Next: Moore vs Mealy

    Up next

    Up nextMoore vs Mealy

    Sign in to save progress

    Practice this lessonReport an error

    See also

    • Split Design: Intent First
    • Signal Contract Between FSM and Datapath
    • Two-Process FSM Style
    clkvalidcmd01210busywr_en
    Buggy wr_ctrl: valid and cmd come from the sender, busy and wr_en from the FSM; cmd 1 is WRITE, values in hex
    clkstartstate0120cnt0123450beatdone
    Buggy burst_ctrl with LEN = 4: state IDLE = 0, BURST = 1, DONE = 2; cnt is the number of beats already sent
    state: IDLE = 0, REQ = 1, WAIT = 2, DONE = 3clkstartstate0123012reqackdone
    Buggy req_ctrl: request 1 gets its ack two cycles after req rises, request 2 gets it in the same cycle
    state: IDLE = 0, REQ = 1, WAIT = 2, DONE = 3clkstartstate01230130reqackdone
    Fixed req_ctrl with the same stimulus: the ack in slot 7 is taken in REQ, and DONE follows in slot 8