Coding an FSM: states, encodings, the default

Code a state machine that has an answer for every value its register can hold, choose an encoding and measure it, and know what the synthesizer does to both.

Lesson
R3.2
Time
30 min
Runs on
Your browser
Check
5 questions

A five-state sequencer kept its state in three bits, so three of the eight codes had no name. An upset flipped one bit of RUN (010) and left 110 in the register. The case had no arm for 110, the next state was "stay", and the machine ignored every new job until reset.

The sequencer after an upsetThe second job runs normally. Between edges 8 and 9 the bench forces the state register to 110 for 1 ns. From then on, start is high and nothing happens.
0123456clkstartstIDLELOADRUN110load
  1. Cycle 1

    Edge 7: start was high, so the state is LOAD and load is 1.

  2. Cycle 2

    Edge 8: RUN, code 010. The upset follows: bit 2 flips and the register holds 110.

  3. Cycle 3

    Edge 9: no arm matches 110, so st_d = st_q stands. The register reloads 110.

  4. Cycle 6

    Edges 10 to 12: start is high and the machine still holds 110. Only reset gets it out.

Measured on the bench, cycles e6 to e12. The run prints "2 jobs started": the third never begins.

Every code the register can hold

A state register of N bits can hold 2^N codes. The enum names five of them; the hardware does not know which ones have names. The next-state logic must say what happens in every code, including the ones no transition produces. That is the job of the default arm.

No default arm

st_d = st_q;
case (st_q)
  ...
  DONE: st_d = IDLE;
endcase

In 101, 110 or 111 nothing matches, st_d keeps st_q, and the machine stays there.

A default arm

st_d = st_q;
case (st_q)
  ...
  DONE:    st_d = IDLE;
  default: st_d = IDLE;
endcase

Any unnamed code goes to IDLE at the next edge. The upset costs one job, not the chip.

Five named states and the codes without a nameArrows are transitions at a rising edge. The dashed box stands for the three binary codes the enum does not name.
Five named states and the codes without a nameArrows are transitions at a rising edge. The dashed box stands for the three binary codes the enum does not name. state IDLE 000 waits for start state LOAD 001 one cycle state RUN 010 until last state DRAIN 011 one cycle state DONE 100 one cycle queue 101, 110, 111 no name, no transitioninto them startnext edgelastnext edgenext edgedefault arm
  1. A job

    IDLE, LOAD, RUN, DRAIN, DONE and back: the only codes a correct run ever visits.

  2. An upset

    Only an upset, a glitch or a missed reset puts the register in 101, 110 or 111. The default arm is the only way back.

Read the diagram as text
  • IDLE 000: waits for start
  • LOAD 001: one cycle
  • RUN 010: until last
  • DRAIN 011: one cycle
  • DONE 100: one cycle
  • 101, 110, 111: no name, no transition into them
  • IDLE 000 → LOAD 001: start
  • LOAD 001 → RUN 010: next edge
  • RUN 010 → DRAIN 011: last
  • DRAIN 011 → DONE 100: next edge
  • DONE 100 → IDLE 000: next edge
  • 101, 110, 111 → IDLE 000: default arm

Without the default arm, the dashed box has no way out.

The tools can point at the gap before any upset. Lint (Verilator 5.052) reports %Warning-CASEINCOMPLETE: Case values incompletely covered (example pattern 0x5) on the case line. Written as unique case, the same block makes Icarus print value is unhandled for priority or unique case statement when the register holds 110.

The house style for a state machine

  1. An enum with a logic base type

    typedef enum logic [2:0] {...} state_e; The simulator then rejects a bare number assigned to the state, and waveforms show names.

  2. A clocked block for the register

    st_q <= st_d under the asynchronous reset. It looks the same in every machine.

  3. A combinational block for the next state

    st_d = st_q; first, so every path assigns st_d and no latch is possible. Then unique case (st_q) with one arm per state and a default arm back to IDLE.

  4. Outputs from the state

    Assigns or a third block decode the outputs. Moore or Mealy is the brief's choice (R3.1).

A one-block machine, with the next state and the outputs inside the clocked block, is legal. Its outputs are all registered, and its next-state logic is hidden among register updates. Reviews prefer separate blocks because each one does one job.

Encodings, and what the synthesizer did with yours

Binary numbers the states 0, 1, 2, ...: 3 flops for 5 states. One-hot gives each state its own flop, set only in that state: 5 flops, with outputs that are single register bits. Gray changes one bit per step and suits a state read by another clock domain (R6).

Press Synthesize on the lab and the report says 5 flops, although the enum has 3 bits. Yosys 0.70 recognized the state machine and re-encoded it. Its log, from a verbose run of the same flow, says so.

Only the five codes reachable from reset are mapped. 101, 110 and 111 have no place in the new machine.
Found FSM state register seq5.st_q.
Recoding FSM `$fsm$\st_q$23' from module `\seq5' using `auto' encoding:
  mapping auto encoding to `one-hot` for this FSM.
  000 -> ----1
  100 -> ---1-
  010 -> --1--
  001 -> -1---
  011 -> 1----

With and without the default arm, Yosys 0.70 wrote the same 11-cell netlist: the arm for codes the machine cannot reach was dropped. Simulated as gates, that netlist failed in a new way. When an upset cleared its one set bit, the state stayed 00000 and start never moved it.

The attribute (* fsm_encoding = "none" *) on the state register tells Yosys to leave the machine alone. Then the code you wrote is the circuit you get, default arm included.

seq5 written asFlopsCellsLevels of logic
binary, no default arm, encoding kept3174
binary, with the default arm, encoding kept3132
one-hot, with the default arm, encoding kept5275
any of these without the attribute (re-encoded one-hot)5112

Measured with Yosys 0.70 on this flow. "Encoding kept" means (* fsm_encoding = "none" *) on st_q. The default arm made the kept binary machine smaller.

The lab

Design brief
PortDirectionWidthMeaning
clkinput1clock
rst_ninput1reset, asynchronous, active-low
startinput1start: LOAD one cycle, RUN until last, DRAIN one cycle, DONE one cycle, IDLE
lastinput1ends RUN
loadoutput1Moore: load in LOAD
runoutput1Moore: run in RUN
doneoutput1Moore: done in DONE
Clock
rising edge
Reset
rst_n asynchronous, active-low, state IDLE
Corner cases
any unnamed state code returns to IDLE at the next edge

Run the lab: two jobs, the upset, then 110 until the end and "2 jobs started". Press Lint and read the CASEINCOMPLETE warning. Add default: st_d = IDLE; and run again: IDLE at e9, LOAD at e10, "3 jobs started". Synthesize: still 11 cells and 5 flops. Now write (* fsm_encoding = "none" *) in front of state_e st_q;: 13 cells, 3 flops, 2 levels. Last, change the enum to one-hot (IDLE = 5'b00001, ...): the bench still recovers, and Synthesize reports 27 cells, 5 flops, 5 levels.

Asked in interviews
  1. Binary or one-hot, and when?

    Binary uses the fewest flops; one-hot uses one flop per state and makes each output a register bit. Which one is smaller or shallower depends on the machine and the tool, so measure. Here the kept one-hot was larger.

  2. What happens when the machine lands in an illegal state?

    Whatever the next-state logic says for that code. With a default arm it returns to a known state. Check that the arm survives synthesis: a tool that re-encodes the machine can drop it.

  3. Why an enum for the state?

    The enum names the states and limits what can be assigned, so a typo in a constant is an error. Waveforms show names. Give it a logic base type of the right width.

Try it

The first job runs IDLE, LOAD, RUN, DRAIN, DONE, IDLE (edges 2 to 6). The second reaches RUN at edge 8, then the upset writes 110. From edge 9 on the register holds 110 and the request for a third job is ignored.

With the default arm, e9 shows 000, e10 shows 001 with load = 1, and the summary reads "3 jobs started". The bench forces 3'b110; after the switch to one-hot it lands as 00110, two bits set, which is just as illegal.

RTLIcarus Verilog · in your browser · Lint: Verilator service · Synthesize: Yosys service
Edit the design, then Run, Lint or Synthesize
Expected: e2 start=1 last=0 st=001 load=1 run=0 done=0 e3 start=0 last=0 st=010 load=0 run=1 done=0 e4 start=0 last=1 st=011 load=0 run=0 done=0 e5 start=0 last=0 st=100 load=0 run=0 done=1 e6 start=0 last=0 st=000 load=0 run=0 done=0 e7 start=1 last=0 st=001 load=1 run=0 done=0 e8 start=0 last=0 st=010 load=0 run=1 done=0 upset after e8: st_q = 110 e9 start=0 last=0 st=110 load=0 run=0 done=0 e10 start=1 last=1 st=110 load=0 run=0 done=0 e11 start=1 last=1 st=110 load=0 run=0 done=0 e12 start=1 last=1 st=110 load=0 run=0 done=0 2 jobs started

Original design · 11 cells · 5 flip-flops · 0 latches · 2 logic levels. Synthesis uses Yosys; counts depend on the toolchain.

Read included file: tb.sv
`timescale 1ns/1ps
module tb;
  logic clk = 0;
  always #5 clk = ~clk;                   // rising edges at 5, 15, 25, ...
  logic rst_n = 1;                        // falls at 1 ns: a real edge, so an asynchronous reset acts before any clock
  int edge_n = -1;
  always @(posedge clk) edge_n = edge_n + 1;
  logic start = 0, last = 0;
  logic load, run, done;
  int   jobs = 0;
  seq5 dut (.clk(clk), .rst_n(rst_n), .start(start), .last(last), .load(load), .run(run), .done(done));
  // Advance to the next rising edge, wait for the design to settle, then print one line.
  task automatic tick();
    @(posedge clk); #1;
    if (load) jobs++;
    if (edge_n >= 2) $display("e%0d start=%b last=%b st=%b load=%b run=%b done=%b", edge_n, start, last, dut.st_q, load, run, done);
  endtask
  initial begin
    #1 rst_n = 0; tick(); tick(); rst_n = 1;   // asserted at 1 ns, edges 0 and 1 in reset, released 1 ns after edge 1
    start = 1; tick(); start = 0;           // e2: LOAD
    tick();                                 // e3: RUN
    last = 1;  tick(); last = 0;            // e4: DRAIN
    tick(); tick();                         // e5: DONE, e6: IDLE
    start = 1; tick(); start = 0;           // e7: LOAD
    tick();                                 // e8: RUN
    #2 force dut.st_q = 3'b110;             // an upset: bit 2 of RUN (010) flips
    #1 release dut.st_q;
    $display("upset after e8: st_q = 110");
    tick();                                 // e9
    start = 1; last = 1;                    // ask for a third job
    tick(); tick(); tick();                 // e10 to e12
    $display("%0d jobs started", jobs);
    $finish(0);                             // quiet: no "$finish called" line
  end
endmodule

Check yourself

Predict the outputThe sequencer has the default arm. An upset writes 101 while it is in RUN, and start is held high. What does the program print two edges later?
// seq5: a five-state sequencer. start begins a job: LOAD for one cycle, RUN until last,
// DRAIN for one cycle, DONE for one cycle, then IDLE.
module seq5 (
  input  logic clk,
  input  logic rst_n,      // asynchronous, active-low
  input  logic start,
  input  logic last,
  output logic load,
  output logic run,
  output logic done
);
  typedef enum logic [2:0] {IDLE, LOAD, RUN, DRAIN, DONE} state_e;   // binary: 0 to 4
  state_e st_q;
  state_e st_d;

  always_ff @(posedge clk or negedge rst_n)
    if (!rst_n) st_q <= IDLE;
    else        st_q <= st_d;

  always_comb begin
    st_d = st_q;                        // stay, unless an arm says otherwise
    case (st_q)
      IDLE:  if (start) st_d = LOAD;
      LOAD:  st_d = RUN;
      RUN:   if (last) st_d = DRAIN;
      DRAIN: st_d = DONE;
      DONE:  st_d = IDLE;
      default: st_d = IDLE;            // any other code: back to IDLE
    endcase
  end

  assign load = (st_q == LOAD);
  assign run  = (st_q == RUN);
  assign done = (st_q == DONE);
endmodule

`timescale 1ns/1ps
module tb;
  logic clk = 0;
  always #5 clk = ~clk;                   // rising edges at 5, 15, 25, ...
  logic rst_n = 1;                        // falls at 1 ns: a real edge, so an asynchronous reset acts before any clock
  int edge_n = -1;
  always @(posedge clk) edge_n = edge_n + 1;
  logic start = 0, last = 0;
  logic load, run, done;
  seq5 dut (.clk(clk), .rst_n(rst_n), .start(start), .last(last), .load(load), .run(run), .done(done));
  // Advance to the next rising edge, wait for the design to settle, then print one line.
  task automatic tick();
    @(posedge clk); #1;
    
  endtask
  initial begin
    #1 rst_n = 0; tick(); tick(); rst_n = 1;   // asserted at 1 ns, edges 0 and 1 in reset, released 1 ns after edge 1
    start = 1; tick(); start = 0; tick();   // e2 LOAD, e3 RUN
    #2 force dut.st_q = 3'b101;             // an upset in RUN: an unnamed code
    #1 release dut.st_q;
    start = 1;
    tick(); tick();                         // two edges later
    $display("st=%b load=%b run=%b", dut.st_q, load, run);
    $finish(0);                             // quiet: no "$finish called" line
  end
endmodule
Spot the bugEach option is the body of the next-state always_comb block. The bench runs a job, forces the state to 111, then holds start high and expects a second job. Which body fails?
ConceptWritten with the encoding kept, how many flops hold the state of this five-state machine in binary and in one-hot?
ConceptWithout the attribute, Yosys 0.70 wrote the same 11-cell netlist with and without the default arm. Why?
Read the waveformThis is the run with the default arm. The upset happens between edges 8 and 9. In which cycle is the machine back in a named state?
Recovery after the upset
0123456startstIDLELOADRUNIDLELOADRUNDRAINload

One value per cycle, cycles e6 to e12 from the left.

Remember

  • An N-bit state register can hold 2^N codes; the next-state logic needs an answer for all of them, and the default arm gives it.
  • House style: an enum with a logic base type, a clocked block for st_q, and an always_comb that starts with st_d = st_q and ends with a default arm.
  • Binary uses the fewest flops, one-hot one flop per state; measure which is smaller, as the kept one-hot here was 27 cells against 13.
  • Yosys 0.70 re-encodes a machine it recognizes as one-hot and drops unreachable codes, default arm included; (* fsm_encoding = "none" *) keeps the code you wrote.