Finite State Machines in RTL

A counter can count; a shift register can slide. But most hardware needs to behave — to be in the middle of something: waiting for a request, half-way through a transaction, holding a traffic light on yellow. The universal tool for "being in the middle of something" is the finite state machine (FSM), an idea you have already met as the instruction cycle: a machine that is always in exactly one of a small set of named states, and hops between them on clock edges according to fixed rules. Every controller on every chip — bus arbiters, cache controllers, USB engines, the brain of the CPU itself — is an FSM. This lesson gives you the standard SystemVerilog idiom for writing one, and it is an idiom worth memorising: you will type it hundreds of times.

The three-part idiom

An FSM in RTL is always the same three pieces of hardware, and good style writes them as three separate blocks:

Here is a complete traffic-light controller. Note the typedef enum: it gives the states names, so the code (and the waveform viewer!) says YELLOW instead of 2'b01:

typedef enum logic [1:0] { GREEN, YELLOW, RED } state_t; state_t state, next; // 1) State register: the ONLY flip-flops in the machine. always_ff @(posedge clk) begin if (!rst_n) state <= RED; // reset into a known, safe state else state <= next; end // 2) Next-state logic: pure combinational lookup. always_comb begin next = state; // default: stay put (no latches, no holes) case (state) GREEN: if (timer_done) next = YELLOW; YELLOW: if (timer_done) next = RED; RED: if (timer_done) next = GREEN; default: next = RED; // an illegal state? escape to safety endcase end // 3) Output logic: derived from the state. assign go = (state == GREEN); assign brake = (state != GREEN);

The line next = state; at the top of the comb block is load-bearing: it means every path through the case assigns next something, so no latch can be inferred and no transition can be silently forgotten. The default arm is the escape hatch for states that should never occur — with two bits encoding three states, the fourth encoding 2'b11 exists physically, and a cosmic ray really can put you there.

The picture designers actually draw

Before writing the case, a designer sketches the state diagram — and the RTL above is nothing but this picture transcribed:

Reading rule: circles are states, arrows are transitions, and an arrow is taken at a clock edge when its condition is true. If no arrow's condition is true, the machine holds its state — which is exactly what next = state; says in the code. The diagram and the always_comb block must contain identical information; when they disagree, one of them is a bug.

Moore or Mealy?

Two classic flavours, named after their 1950s inventors, differing only in where outputs come from:

Practical advice: default to Moore for control outputs (predictable timing), reach for Mealy when that one-cycle head start genuinely matters. Many real controllers are quietly a mixture.

There is also a choice of state encoding — what bit pattern each named state gets:

Second worked example: spotting a start bit

A serial line (a UART) idles at 1; a frame begins when the sender pulls the line to 0 — the start bit. The receiver's front end is a tiny FSM that must tell a real start bit from a stray glitch, by sampling the line again half a bit-time later:

typedef enum logic [1:0] { IDLE, CONFIRM, START } state_t; state_t state, next; always_comb begin next = state; case (state) IDLE: if (rx == 1'b0) next = CONFIRM; // line dropped: candidate! CONFIRM: if (half_bit_done) // wait half a bit-time… next = (rx == 1'b0) ? START : IDLE; // …still low? real. High? glitch. START: next = IDLE; // hand off to the data sampler default: next = IDLE; endcase end assign start_found = (state == START); // Moore output, one clean pulse

Same skeleton, different diagram: this is the whole craft. Design the picture, transcribe the case, choose Moore or Mealy for each output. Everything from here to a working CPU controller is this pattern with more circles.

Run the traffic light

Our two-phase simulator handles FSMs beautifully: the state object holds the state register (plus a dwell timer), and next() is the always_comb block. Settle, then snap:

// ── The FSM as data: state register + timer, and next() = the comb logic. ── type Light = "GREEN" | "YELLOW" | "RED"; interface State { light: Light; timer: number } const DWELL: Record<Light, number> = { GREEN: 4, YELLOW: 2, RED: 3 }; const NEXT_LIGHT: Record<Light, Light> = { GREEN: "YELLOW", YELLOW: "RED", RED: "GREEN" }; function next(s: State): State { if (s.timer > 0) return { light: s.light, timer: s.timer - 1 }; // no arrow taken: hold const light = NEXT_LIGHT[s.light]; // timer_done: transition return { light, timer: DWELL[light] - 1 }; } // ── The clock loop: print, then snap to the next state. ── let state: State = { light: "RED", timer: DWELL.RED - 1 }; // reset state console.log("cycle | state | go"); console.log("------+--------+---"); for (let cycle = 0; cycle < 14; cycle++) { const go = state.light === "GREEN" ? 1 : 0; // Moore output console.log(`${String(cycle).padStart(4)} | ${state.light.padEnd(6)} | ${go}`); state = next(state); // one rising edge }

Watch the rhythm: three cycles of RED, four of GREEN, two of YELLOW, forever. The go column changes only when the state does — the Moore signature.

Surprisingly small. A cache controller might have a dozen states; a PCIe link-training machine, specified in the standard as an actual state diagram, has around twenty-odd substates and is considered a monster. The reason is not that big machines are impossible — it is that nobody can review a 200-state diagram. When a control problem grows, designers split it into several communicating FSMs (one per concern) or move regular structure into counters and datapaths, keeping each machine small enough to draw on one whiteboard. A useful rule of thumb from industry: if your state diagram no longer fits on a page, it is not one machine — it is several machines in a trench coat.

The classic FSM bug: you add a state, but forget one transition arm — some (state, input) combination that assigns next nothing. Two evils follow. In a block not guarded by a default assignment, the synthesizer must make next remember its old value: an inferred latch, exactly the pathology from combinational logic, now inside your control logic where it hurts most. And behaviourally, the machine sticks: it reaches the state, no arrow out is ever taken, and your chip sits in CONFIRM until power-off — a real and embarrassingly common way for hardware to hang. The two-line vaccine: next = state; at the top of the block (every path assigned, holding is explicit), and a default: arm sending illegal states somewhere safe. Write both, every time, even when "obviously" unnecessary.