RTL로 설계하는 FSM의 구조, 동작 그리고 예시

Verilog로 FSM을 짜면 상태 값은 어디에 저장될까. 다음 상태는 combinational logic이 곧바로 계산하고, 상태는 clock edge에서 flip-flop(FF)이 한 번 잡아 둔다. 이 글은 그 과정을 latch와 비교해 정리한다.

FSM을 처음 배울 때 흔히 떠올리는 그림은 ‘다음 상태가 latch에 잠깐 담겼다가 다음 clock에 넘어간다’는 것이다. 이 그림이 어디까지 맞고 어디서부터 틀리는지를 표준 2-always 구조와 파형으로 따라가 본다. 마지막에는 RTL 시뮬레이터를 직접 만들면서 이 동작을 엔진 코드로 옮길 때 부딪힌 일을 적었다.

다음 상태는 latch를 거치지 않는다

정석적인 FSM에서 next_state는 어디에도 따로 담기지 않는다. 현재 상태나 입력이 바뀌면 combinational logic이 gate delay만큼 뒤에 새 값을 내놓고, 그 값은 net 위에 떠 있을 뿐이다. 이 값을 붙잡아 새 current_state로 만드는 것은 edge-triggered D FF이고, clock edge 순간에 딱 한 번 capture한다. 합성 결과에 transparent latch가 끼어 있다면 그것은 설계 의도가 아니라 코딩 실수에서 생긴 것이고, STA를 어렵게 하고 glitch를 통과시키는 결함으로 본다.

그렇다고 latch라는 직관이 완전히 틀린 것은 아니다. edge-triggered FF를 gate level에서 열어 보면 level-sensitive latch 두 개가 직렬로 붙어 있다. 앞의 것을 master, 뒤의 것을 slave라고 부른다.

master latch와 slave latch를 직렬로 이은 D FF, master는 clk=0일 때 slave는 clk=1일 때 transparent

rising edge FF에서 master latch는 clock이 0인 동안 transparent해서 D를 따라가고, slave latch는 clock이 1인 동안 transparent해서 master의 값을 Q로 내보낸다. clock이 0에서 1로 바뀌는 순간 master가 닫히면서 그 직전의 D가 갇히고, 동시에 열린 slave가 그 값을 밖으로 내보낸다. 두 latch가 번갈아 열리기 때문에 D에서 Q까지 한 번에 뚫리는 구간이 없고, 결과적으로 edge 순간의 값만 통과한다.

그래서 ‘값이 한 단계 잡혔다가 다음에 고정된다’는 감각은 FF 내부의 master-slave 동작을 가리킨 것으로 보면 맞다. 다만 RTL 코드에서는 이 구조 전체를 register 하나로 모델링한다. 코드 수준에서 combinational block 안에 latch가 따로 inference되는 것은 피해야 할 일이고, 이 두 층위를 구분해 두면 직관과 표준이 부딪히지 않는다.

latch와 FF는 clock이 High인 구간에서 갈린다

두 소자의 차이는 같은 입력 D를 level-sensitive latch와 rising edge FF에 동시에 넣어 보면 드러난다. 아래 파형에서 노란 띠는 clock이 High인 구간, 즉 latch가 transparent한 구간이고, 빨간 점선은 FF가 값을 capture하는 rising edge다.

같은 입력 D에 대한 level-sensitive latch 출력과 edge-triggered FF 출력의 타이밍 파형

첫 rising edge에서는 D가 1이라 두 출력이 함께 1로 올라간다. clock이 High인 동안 D가 0으로 떨어지면 latch 출력은 곧바로 따라 내려가지만, FF 출력은 edge에서 잡은 1을 그대로 유지한다.

clock이 Low인 구간에 D로 짧은 pulse가 들어오면 latch는 닫혀 있어서(opaque) 무시하고, FF도 edge가 아니라서 무시한다. 두 번째 rising edge에서 D는 이미 0이므로 FF 출력은 그제야 0으로 내려간다. latch는 transparent한 동안 들어오는 변화를 모두 통과시키고, FF는 edge라는 한 순간만 본다.

FSM의 state register로 FF를 쓰는 이유가 여기에 있다. 입력이 clock 주기 중간에 어떻게 흔들리든 상태는 clock edge마다 한 번씩만 갱신된다. 상태를 latch에 담았다면 clock이 High인 동안 들어온 glitch가 그대로 상태가 되고, 그 상태가 다시 combinational logic으로 feedback되면서 한 주기 안에서 상태가 여러 번 바뀔 수도 있다.

구분level-sensitive latchedge-triggered FF
capture 시점clock High 구간 내내rising edge 한 순간
transparent 구간High 동안 D가 Q로 그대로 통과없음
구간 중간의 glitch그대로 통과무시
FSM state 저장쓰지 않음(생기면 결함)표준

2-always 구조는 상태 저장과 다음 상태 계산을 나눈다

업계에서 표준처럼 쓰는 FSM coding style은 상태를 저장하는 sequential logic과 다음 상태를 계산하는 combinational logic을 서로 다른 always block에 두는 2-always 구조다. Synopsys와 Mentor 엔지니어들이 정리한 설계 재사용 지침서 RMM(Reuse Methodology Manual)도 FSM을 이렇게 두 process로 나눠 쓰라고 권한다. 전부 한 block에 넣는 1-always와 비교해 왜 이쪽이 표준이 됐는지는 글 뒷부분에서 따로 정리한다. 먼저 신호가 어떻게 도는지 block diagram으로 보면 이렇다.

입력, next-state logic, state register, output logic으로 이어지고 current_state가 feedback되는 FSM block diagram

입력과 current_state가 next-state logic으로 들어가 next_state가 계산되고, state register가 clock edge에서 그 값을 받아 current_state로 내보낸다. current_state는 다시 next-state logic으로 feedback되어 다음 주기 계산의 입력이 되고, output logic은 current_state로 출력을 만든다. Mealy machine이면 output logic에 입력도 직접 들어간다.

combinational logic은 clock을 기다리지 않고 입력과 current_state에 곧바로 반응한다. state register는 clock edge에서 값을 담는 일만 한다. 이 분업이 코드에서는 always block 두 개로 나타난다.

상태 두 개짜리 2-always FSM

아래는 IDLE과 WORK 두 상태만 있는 FSM이다. in_sig가 1이 되면 WORK로 가고, 0이 되면 IDLE로 돌아오며, WORK 상태에서 out_sig를 1로 낸다. 같은 회로를 Verilog와 SystemVerilog로 한 번씩 썼다. 동작은 같고, 아래 설명에서 괄호 안이 SystemVerilog 쪽 차이다.

Verilog
module fsm_2always_example (
    input  wire clk,
    input  wire rst_n,
    input  wire in_sig,
    output reg  out_sig
);
    localparam S_IDLE = 2'b00;
    localparam S_WORK = 2'b01;

    reg [1:0] current_state, next_state;

    // Block 1: sequential logic. Update current_state on the clock edge
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            current_state <= S_IDLE;
        else
            current_state <= next_state;   // non-blocking assignment
    end

    // Block 2: combinational logic. Compute next_state and out_sig
    always @(*) begin
        next_state = current_state;        // default assignment: prevents latch inference
        out_sig    = 1'b0;
        case (current_state)
            S_IDLE: if (in_sig) next_state = S_WORK;
            S_WORK: begin
                out_sig = 1'b1;            // Moore output: depends on current_state only
                if (!in_sig) next_state = S_IDLE;
            end
            default: next_state = S_IDLE;
        endcase
    end
endmodule
SystemVerilog
module fsm_2always_example (
    input  logic clk,
    input  logic rst_n,
    input  logic in_sig,
    output logic out_sig
);
    typedef enum logic [1:0] {S_IDLE = 2'b00, S_WORK = 2'b01} state_e;

    state_e current_state, next_state;

    // Block 1: sequential logic. Update current_state on the clock edge
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            current_state <= S_IDLE;
        else
            current_state <= next_state;   // non-blocking assignment
    end

    // Block 2: combinational logic. Compute next_state and out_sig
    always_comb begin
        next_state = current_state;        // default assignment: prevents latch inference
        out_sig    = 1'b0;
        case (current_state)
            S_IDLE: if (in_sig) next_state = S_WORK;
            S_WORK: begin
                out_sig = 1'b1;            // Moore output: depends on current_state only
                if (!in_sig) next_state = S_IDLE;
            end
            default: next_state = S_IDLE;
        endcase
    end
endmodule

port와 내부 변수는 wire와 reg로 선언한다(SystemVerilog는 둘 다 logic 하나로 쓴다. reg라는 이름 때문에 reg가 곧 FF라고 오해하기 쉬웠는데, 그 혼동이 사라진다). 상태 값은 localparam으로 정한다(SystemVerilog는 typedef enum으로 state 타입을 만든다. 시뮬레이터 파형에 IDLE, WORK 같은 이름이 그대로 보이고, enum 변수에 cast 없이 다른 정수를 넣으면 compile error가 난다).

첫 번째 block은 posedge clk에서 non-blocking assignment(<=)로 FF만 기술한다(SystemVerilog는 always_ff로 쓴다. 이 block이 sequential logic이라는 의도를 선언하는 것이라, 툴이 FF로 해석되지 않는 내용을 경고할 수 있다). non-blocking을 쓰는 이유는 같은 edge에서 갱신되는 register들이 모두 이전 값을 읽고 새 값을 동시에 잡게 하기 위해서다.

두 번째 block은 always @(*)로 combinational logic만 기술한다(SystemVerilog는 always_comb로 쓴다. sensitivity list를 툴이 알아서 정하고, time 0에 한 번 실행되며, block 안에서 latch가 생길 만한 코드가 있으면 툴이 경고한다). 핵심은 block 맨 위의 default assignment(next_state = current_state; out_sig = 1’b0;)이다. case나 if에서 어떤 분기가 빠지더라도 모든 경로에서 값이 정해지므로, 합성 툴이 값을 유지하려고 storage element를 만드는 일, 즉 latch inference가 생기지 않는다. 앞에서 말한 의도치 않은 latch를 막는 표준 coding style이 이것이다(always_comb를 써도 이 default assignment는 그대로 필요하다. always_comb는 latch를 경고할 뿐 막아 주지는 않는다).

next_state는 곧바로 바뀌고 current_state는 다음 edge에 바뀐다

위 코드가 돌 때 next_state와 current_state가 시간축에서 어떻게 어긋나는지 보면 분업이 더 분명해진다. 빨간 점선은 current_state가 바뀌는 clock edge다.

in_sig가 바뀌면 next_state는 곧바로, current_state는 다음 rising edge에 바뀌는 타이밍 파형

in_sig가 1로 바뀌는 순간 next_state는 clock과 상관없이 곧바로 WORK가 되고, current_state는 그다음 rising edge에서야 WORK가 된다. in_sig가 0으로 돌아갈 때도 next_state가 먼저 IDLE이 되고, current_state는 다음 edge에서 따라간다.

next_state가 바뀌는 데 걸리는 시간은 gate delay뿐이고, 그 사이 어디에도 값을 기다렸다가 저장하는 단계는 없다. combinational logic은 다음 edge 전까지 값을 준비해 두고, FF는 edge에서 그 값을 확정한다. combinational path의 delay가 clock 주기 안에 들어와야 한다는 setup timing 조건도 이 분업에서 나온다.

시뮬레이터를 만들면서 다시 확인한 것

나는 SystemVerilog와 Verilog를 해석해 실행하는 오픈소스 RTL 시뮬레이터를 만들고 있다. ‘combinational은 곧바로 계산하고, FF는 edge에서 확정한다’는 한 줄을 엔진 코드로 옮기면서, 교과서에서는 잘 다루지 않는 부분을 몇 가지 직접 겪었다.

첫째, non-blocking assignment는 값을 계산하는 일과 실제로 쓰는 일을 떼어 놓아야 한다. current_state <= next_state를 제대로 흉내 내려면 RHS를 evaluate하는 순간 LHS target(배열이면 index까지)도 함께 정해 두고, 실제 write는 같은 time step의 NBA region으로 미뤄야 한다.

한 time step 안에서 Active region이 값을 계산하고 NBA region이 한꺼번에 write하는 순서

Active region에서는 non-blocking assignment마다 이전 값을 읽어 RHS를 계산하고 쓸 target을 기억해 둔다. write는 NBA region에서 한꺼번에 일어나므로 a <= b; b <= a;가 두 값을 제대로 맞바꾼다. 이 둘을 섞어 blocking assignment처럼 곧바로 써 버리면 swap이 깨지고, shift register가 한 단으로 주저앉는다.

둘째, edge에서 sampling한다는 한 줄은 timing 가정 하나만 어긋나도 한 clock이 밀린다. clocking block의 input sampling을 구현할 때 입력은 time step이 진행되기 직전의 값(preponed 값)으로 잡아야 한다. 같은 testbench를 reference simulator(iverilog)와 나란히 돌려 출력을 비교했더니, 상위 module이 그 sample 값을 읽을 때 한 clock 묵은 값이 나오는 조용한 mismatch가 잡혔다. 원인은 sample 값을 담은 register를 언제 commit하느냐였고, clock edge를 감지한 그 시점에 commit하도록 고치고 나서야 두 결과가 일치했다. 앞에서 말한 ‘한 edge 늦게 확정된다’는 동작이 코드에서는 이만큼 예민했다.

셋째, 의도치 않은 latch는 합성 결함이기 전에 simulation 파형부터 바꾼다. 엔진에서 level-sensitive latch(enable이 High인 동안 glitch까지 그대로 통과시키고 Low면 hold)와 edge-triggered FF를 따로 모델링하고, 각 파형을 reference simulator와 맞춰 검증했다. 그래서 default assignment로 latch inference를 막는 coding style이 나에게는 추상적인 규칙이 아니다. latch가 끼면 합성 report보다 먼저 simulation 파형이 달라진다.

always를 하나로 합치지 않는 이유

모든 logic을 always @(posedge clk) 하나에 넣는 1-always 구조도 문법상 문제는 없고, 합성도 된다. 그래도 2-always가 표준이 된 이유를 lint, 코드 유지보수, 합성과 timing closure 순서로 보면 각각 무게가 다르다.

먼저 lint다. 상용 lint 툴의 FSM 규칙은 FSM을 2~3개 process로 나눠 기술하도록 요구하는 경우가 많다. Aldec은 자사 lint 툴 ALINT-PRO의 FSM 점검 항목으로, error-prone한 1-process 대신 2~3개 process로 FSM을 기술할 것을 든다. 흔히 같은 always에서 state를 쓰고 다시 읽기 때문이라고 알려져 있지만, clocked block 안에서 state <= f(state)처럼 non-blocking으로 읽고 쓰는 것 자체는 counter와 같은 평범한 RTL이고, Verilator도 이 패턴에는 경고를 내지 않는다. 경고가 실제로 붙는 곳은 1-always 안에서 중간값을 blocking assignment로 계산할 때다. Verilator는 sequential block 안의 blocking assignment를 BLKSEQ로 잡고, 같은 변수에 blocking과 non-blocking을 섞으면 BLKANDNBLK로 막는다. Verilator 5.052에서 -Wall로 확인하면, state를 non-blocking으로만 읽고 쓰는 1-always FSM은 경고가 없고, 같은 FSM의 중간값을 blocking으로 계산하게 바꾸면 그 두 줄에 BLKSEQ가 붙는다. 이 글의 2-always 코드는 Verilog와 SystemVerilog 버전 모두 경고 없이 통과한다.

다음은 출력을 쓰는 방식이다. 1-always에서는 출력도 register로 나온다. 출력을 현재 state 기준으로 적으면 상태보다 한 clock 늦게 나오므로, 이를 피하려면 이번 전이로 도착할 state의 출력, 즉 다음 출력을 전이(transition arc)마다 따로 적어야 한다. 2-always는 출력을 state마다 한 번만 적으면 되는데, 1-always는 그 state로 들어오는 화살표 수만큼 적어야 하는 셈이다. Cummings는 SNUG 2019 논문에서 이 때문에 1-always 코드가 state와 출력이 늘수록 빠르게 불어나고, 가장 큰 예제에서는 3-always의 두 배가 넘는 코드가 필요했다고 정리했다. 1998년 논문에서도 이 다음 출력 코딩이 error-prone하고, 1-always FSM은 수정과 debugging이 더 어렵다고 적었다.

Mealy machine은 1-always로는 아예 쓸 수 없다. 출력이 current state와 입력에 함께 달린 Mealy FSM에서는 입력이 바뀌면 clock과 상관없이 출력이 곧바로 바뀌어야 하는데, clocked block 안에서 assign한 출력은 전부 FF를 거친다. combinational logic을 따로 떼어야 입력을 출력에 바로 이을 수 있다.

합성과 timing closure 쪽은 짐작과 조금 다르다. always를 나눈다고 회로가 빨라지지는 않는다. Cummings가 네 가지 스타일을 Design Compiler로 합성해 비교했더니 1-always가 오히려 3-always보다 area와 timing이 약간 좋았다. 1-always는 next state와 다음 출력을 같은 combinational logic에서 병렬로 계산하기 때문이다. FSM을 FSM으로 알아보는 것도 스타일을 가리지 않는다. Xilinx XST 가이드는 한 개의 sequential block에 모두 쓴 FSM부터 세 block으로 나눈 FSM까지 네 방식을 모두 FSM으로 추출해 state encoding을 최적화한다고 적고 있다.

timing closure에서 실제로 중요한 것은 FSM 출력을 register로 내보내는 것이다. module 출력이 FF에서 바로 나오면 다른 module과 사이에 input/output delay 제약을 따로 나눠 잡지 않아도 timing을 맞추기 쉽고, 출력에 glitch도 없다. 2-always의 combinational output은 glitch가 날 수 있지만 chip 내부에서 다음 edge 전에 안정되면 문제가 없고, 이는 STA로 확인한다. next_state가 신호로 따로 있으면 registered output을 짧은 코드로 만들 수 있다. Cummings가 3-always라고 부르는 방식은 세 번째 always_ff에서 next_state를 decode해 다음 출력을 register에 담아, 출력이 늦지 않으면서 FF에서 바로 나오게 한다. 여기서 다음 출력 계산을 always_comb 하나로 더 떼어 낸 4-always는 1-always와 같은 합성 결과를 훨씬 짧은 코드로 냈다. next_state를 다른 block이 미리 볼 수 있다는 점도 큰 controller에서는 쓸모가 있다.

정리하면 2-always 계열이 표준이 된 것은 회로가 좋아서라기보다, 같은 회로를 더 짧고 덜 틀리게 쓸 수 있고, lint 규칙과 맞으며, registered output처럼 timing closure에 유리한 구조로 넓히기 쉬워서다.

결국 상태와 control logic을 다른 always로 나누는 것은 코딩 취향이 아니라 sequential logic과 combinational logic이라는 두 동작 방식을 코드에 그대로 옮기는 방법이다. combinational block은 clock을 기다리지 않고 다음 상태를 계산하고, sequential block은 clock edge에서 그 값을 FF에 담는 일만 한다. ‘latch에 담겼다가 다음 clock에 고정된다’는 그림은 RTL 수준에서는 맞지 않지만, FF 안의 master-slave latch를 가리킨 것으로 읽으면 틀린 그림도 아니다. FSM을 처음 배울 때 latch와 FF의 timing 차이 하나를 정확히 잡아 두면, 그 뒤의 synchronous design이 훨씬 또렷하게 보인다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다