본문 바로가기

실전! Verilog HDL RTL Design

[Verilog HDL] 16. FSM을 이용한 APB register file 설계

반응형

자 드디어 APB inteface를 갖는 register file을 설계할 때가 되었다. APB interface logic을 설계하기 위해서 우리는 FSM에 대해서 배울 것이다. FSM은 Finite State Machine의 약자로 하드웨어의 제어 로직을 만들 때 많이 사용하는 구조이다. 어떤 기능을 하는 하드웨어 로직을 제어 하기 위해서는 그 하드웨어가 어떤 상태에 있는지를 정의할 수 있어야 한다. 그래야 각 상태별로 필요한 제어 신호를 만들어 낼 수 있다. FSM은 유한한 하드웨어 상태를 정의하고 입/출력과 현재 상태에 따라 필요한 제어신호를 만들고 다음 상태는 어떤 상태가 되는지를 정의하는 하드웨어 로직이다.

 

우리가 설계하고자 하는 FSM의 목적은 APB bus protocol을 register file 내부의 memory에 읽고 쓸 수 있는 protocol로 변환하기 위한 제어 신호를 만드는 것이다 . register file을 설계하면서 배웠듯이 memory를 읽고 쓰기 위한 제어 신호에는 address, write enable이 필요하다. 14장의 APB interface에서 APB bus protocol은 3가지 상태를 가진다는 것을 배웠다. 따라서 이 3가지 상태를 이용하여 FSM을 만들수 있다. FSM은 이 3가지 상태에 따라서 address와 write enbale 신호를 적절히 생성해 주면 되는 것이다.

 

14장에서 살펴 본 state diagram을 다시 가져와 보자.

 

위 그림에서 각 원은 하나의 상태를 나타낸다. 여기서는 각각 'IDLE', 'SETUP', 'ACCESS' state라고 정의할 수 있다. 따라서 이 3가지 상태를 표시하기 위하여 binary code를 사용하여 parameter로 정의하여 보자. 그러면 3가지 상태는 아래와 같을 것이다.

parameter IDLE   =  2'b00;
parameter SETUP  =  2'b01;
parameter ACCESS =  2'b10;

Verilog에서 FSM을 설계하는 코드는 특정한 패턴을 갖고 있고, 거의 이 패턴에서 벗어나지 않는다.

먼저 위에서 처럼 state code를 정의하고 현재 상태 즉, current state 를 매 클럭 마자 다음 상태 즉, next state로 update하는 sequential logic과 next state를 결정하는 comination logic으로 구성되는 패턴이다.

FSM의 clock을 'clk'라고 하고 reset을 'rstn'이라고 한다면, current state update 로직은 아래와 같다.

reg [1:0] curr_state;
reg [1:0] next_state;

always @(posedge clk or needge rstn)
begin
    if (~rstn)
        curr_state <= IDLE;
    else
        curr_state <= next_state;
end

현재 상태는 reg type의 2bit curr_state로 정의했고 다음 상태는 reg type의 2bit의 next_state로 정의하였다.

그리고 앞에서 F/F sequentail logic설계에서 배웠던 것처럼 always 문을 사용하여 rstn이 '0'일 경우 curr_state를 IDLE state로 초기화 하고 아닌 경우 매 clock마다 next_state로 update한다.

 

그러면 next_state는 어떻게 결정할 지 생각해 보자. next_state는 curr_state와 입력에 따라서 결정된다.

이를 구현하기 위해 case문을 사용해 보자. case문은 C언어에서 switch문과 유사한 문법이다.

always @(*)
begin
    case(curr_state)
        IDLE :
            if (PSEL == 1)
                next_state = SETUP;
            else
                next_state = curr_state;
        SETUP :
            if (PENABLE == 1)
                next_state = ACCESS;
            else
                next_state = curr_state;
        ACCESS :
            if (PSEL == 0 && PENABLE == 0 && PREADY == 1)
                next_state = IDLE;
            else if (PSEL == 1 && PREADY == 1)
                next_state = SETUP;
            else
                next_state = curr_state;
        default:
            next_state = IDLE;
    endcase
end

case다음 괄호 안에는 조건 분기에 사용할 변수가 온다. 여기서는 curr_state를 썼는데 이는 curr_state에 따라서 조건 분기문이 만들어 진다는 의미이다. 4번째 줄의 의미는 curr_state가 IDLE과 같을 때 ':' 다음 문장이 실행된다는 의미이다.  즉 curr_state가 IDLE (2'b00) 일 경우 뒤에 나오는 if 문이 실행된다.

if문을 살펴 보면 PSEL이 '1'인 경우 next_state는 SETUP state가 되고 아닌 경우에는 curr_state가 유지 된다.

이는 state diagram에서 PSEL이 1일 때 transfer가 발생하여 SETUP state로 천이 하는 것을 나타내는 것이다.

이런 식으로 curr_state에 대하여 입력 신호의 변화를 관찰하여 next_state를 결정한다.

다음으로 curr_state가 SETUP인 경우 PENABLE이 '1'이면 ACCESS state로 천이한다. 아니면 현재 상태를 유지한다. curr_state가 ACCESS인 경우 다소 복잡해 보이지만 위의 state diagram을 참조하여 그대로 코딩한 것에 불과하다. 즉 PREADY가 1인 경우에만 다른 state로 천이하는데 이는 PSEL과 PENABLE의 상태에 따라 달라지게 된다. 첫번째 if 문에서 PREADY가 '1'이고 'PSEL'과 'PENABLE'이 '0'인 경우 IDLE 상태로 천이한다.

이 조건이 아닌 경우 'PREADY'가 '1'이고 'PSEL'이 '1'이면 SETUP 상태로 천이한다. 그리고 이 두가지 상태가 아니면 curr_state를 유지한다.

주의 할 것은 이 case문은 cominational logic으로 구현할 것이기 때문에 모든 if 문에는 마지막 else문이 들어가야 하고 default 문이 반드시 들어가야 한다는 것이다. 그렇지 않을 경우 현재 상태를 기억하기 위한 메모리소자인 latch가 합성되어 의도한대로 동작하지 않을 수 있다.

case문의 마지막에 default문은 이러한 이유로 포함시킨 것이다. 사실 next_state는 3가지 상태 이외에 다른 값을 가질 수 없지만 합성시 latch가 생성되지 않도록 하기 위해서 default문을 반드시 포함시켜야 한다.

 

이제 상태 천이 로직을 완성하였으므로 이를 기반으로 register file의 각종 제어신호를 만들어 보자.

14장에서 state diagram과 timing diagram을 보면 ACCESS state일 때 register file memory의 읽고 쓰기 기능이 일어나는 것을 알 수 있고 이는 읽고/쓰기는 PWRITE신호에 의해서 결정됨을 알 수 있다.

따라서 15장의 register file module의 입출력 신호는 다음과 같이 만들 수 있다.

parameter ADDR_WIDTH = 4;
parameter DATA_WIDTH = 16;

wire [ADDR_WIDTH-1:0] ADDR;
wire WE;
wire [DATA_WIDTH-1:0] DIN;
wire [DATA_WIDTH-1:0] DOUT;

assign ADDR    = PADDR;
assign DIN     = PWDATA;
assign PRDATA  = DOUT;
assign PREADY  = 1;
assign WE      = (next_state == ACCESS) & PWRITE;

register_file #(
     .ADDR_WIDTH(ADDR_WIDTH),
     .DATA_WIDTH(DATA_WIDTH)
     )
     u_regfile (
    .CLK     (PCLK),
    .RSTn    (PRESETn),
    .ADDR    (ADDR),
    .DIN     (DIN),
    .DOUT    (DOUT),
    .WE      (WE)
    );

register file의 입출력 신호에서 중요한 제어 신호는 WE 즉 write enable신호이다.  이 신호는 앞서 설명한 바와 같이 next_state가 ACCESS이고 PWRITE가 1인 경우 write신호로 사용할 수 있다. curr_state는 현재 입력 상태를 1clock 지연 시킨 상태이므로 next_state를 사용하는 것에 유의한다. 그 이외의 경우는 read신호로 인식하여 해당 어드레스에 대한 데이터 값을 출력해 주면 된다.

 

지금 까지의 코드를 종합하여 apb_regfile 이라고 하는 모듈을 코딩하면 아래와 같다.

module apb_regfile #(
	parameter ADDR_WIDTH=8,
	parameter DATA_WIDTH=16
	)
	(
	input wire PCLK,
	input wire PRESETn,
	input wire [ADDR_WIDTH-1:0] PADDR,
	input wire PSEL,
	input wire PENABLE,
	input wire PWRITE,
	input wire [DATA_WIDTH-1:0] PWDATA,
	output reg [DATA_WIDTH-1:0] PRDATA,
	output wire PREADY
	);

	parameter IDLE   =  2'b00;
	parameter SETUP  =  2'b01;
	parameter ACCESS =  2'b10;

	reg [1:0] curr_state;
	reg [1:0] next_state;

	wire [ADDR_WIDTH-1:0] ADDR;
	wire [DATA_WIDTH-1:0] DIN;
	wire [DATA_WIDTH-1:0] DOUT;
	wire                  WE;

	always @(posedge PCLK or negedge PRESETn)
	begin
   		if (~PRESETn)
        		curr_state <= IDLE;
    		else
        		curr_state <= next_state;
	end

	always @(*)
	begin
	    case(curr_state)
	        IDLE :
	            if (PSEL == 1)
	                next_state = SETUP;
	            else
	                next_state = curr_state;
	        SETUP :
	            if (PENABLE == 1)
	                next_state = ACCESS;
	            else
	                next_state = curr_state;
	        ACCESS :
	            if (PSEL == 0 && PENABLE == 0 && PREADY == 1)
	                next_state = IDLE;
	            else if (PSEL == 1 && PREADY == 1)
	                next_state = SETUP;
	            else
	                next_state = curr_state;
	        default:
	            next_state = IDLE;
	    endcase
	end

	assign ADDR    = PADDR;
	assign DIN     = PWDATA;
	assign PREADY  = 1;
	assign WE      = (next_state == ACCESS) & PWRITE;

	always @(*) PRDATA  = DOUT;

	regfile #(
     	.ADDR_WIDTH(ADDR_WIDTH),
     	.DATA_WIDTH(DATA_WIDTH)
     	) u_regfile (
    	.CLK     (PCLK),
    	.RSTn    (PRESETn),
    	.ADDR    (ADDR),
    	.DIN     (DIN),
    	.DOUT    (DOUT),
    	.WE      (WE)
    	);
 
endmodule

다음은 이 apb_regfile을 test하기 위한 testbench 를 만들어야 하는데, 이전 처럼 모듈의 각 입력 포트를 일일히 제어 해 주는 것은 상당히 귀찮은 일이다. 특히 APB bus protocol 같이 일정한 패턴이 있는 경우 task 문을 이용하여 APB read, write 기능을 구현해 주면 편리하다. APB write인 경우 아래와 같이 task를 만들면 APB spec에 나와 있는 timing diagram과 같이 APB 신호를 만들어 줄 수 있다.

task apb_write (
	input  [ADDR_WIDTH-1:0] addr,
	input  [DATA_WIDTH-1:0] data
);
begin
	psel <= 1;
	pwrite <= 1;
	@(posedge pclk);
	penable <= 1;
	paddr <= addr;
	pwdata <= data;
	@(posedge pclk);
	while(pready == 0) @(posedge pclk);
	psel <= 0;
	penable <= 0;
	pwrite <= 0;
end
endtask

write task는 입력으로 address와 data를 받는다. 

task문에는 timing을 나타내는 여러가지 문법을 사용할 수 있는데 여기서는 @(posedge pclk);를 사용하여 pclk 1 clock을 기다리는 기능을 구현하였다. @('event') 는 괄호 안에 있는 event가 참이 될 때까지 기다리라는 의미이다.

APB 입력신호는 모두 동시에 toggle 해야 하기 때문에 non-blocking assignment 구문을 사용하였다. 

while(pready == 0) @(posedge pclk);는 pready가 '0'이면 다음 pclk의 positive edge 까지 기다리는 명령이다. 즉 pready가 '1'이 될 때까지 clock을 소비하며 기다리는 것이다. pready가 '1'이 되면 바로 아래 구문들이 실행이 된다. 아래의 모든 구문들은 non-blocking assignment이므로 동시에 실행된다.

위의 task는 APB spec에 나와 있는 write timing diagram를 verilog 로 구현한 것이다.

마찬가지로 read timing diagram도 아래와 같이 구현할 수 있다.

task apb_read (
	input  [ADDR_WIDTH-1:0] addr
	);
begin
	psel <= 1;
	pwrite <= 0;
	@(posedge pclk);
	penable <= 1;
	paddr <= addr;
	@(posedge pclk);
	while(pready == 0) @(posedge pclk);
	psel <= 0;
	penable <= 0;
end
endtask

read task에는 입력으로 address 하나만 받는다. 이를 이용하여 아래와 같이 testbench를 만들 수 있다.

`timescale 1ns/1ns
 
module test;
 
// delare variables
reg pclk;
reg presetn;
reg [1:0] paddr;
reg [3:0] pwdata;
reg psel;
reg penable;
reg pwrite;
wire pready;
wire [3:0] prdata;
 
parameter ADDR_WIDTH = 2;
parameter DATA_WIDTH = 4;

// clock generation
always #10 pclk = ~pclk;

task apb_write (
	input  [ADDR_WIDTH-1:0] addr,
	input  [DATA_WIDTH-1:0] data
);
begin
	psel <= 1;
	pwrite <= 1;
	@(posedge pclk);
	penable <= 1;
	paddr <= addr;
	pwdata <= data;
	@(posedge pclk);
	while(pready == 0) @(posedge pclk);
	psel <= 0;
	penable <= 0;
	pwrite <= 0;
end
endtask

task apb_read (
	input  [ADDR_WIDTH-1:0] addr
	);
begin
	psel <= 1;
	pwrite <= 0;
	@(posedge pclk);
	penable <= 1;
	paddr <= addr;
	@(posedge pclk);
	while(pready == 0) @(posedge pclk);
	psel <= 0;
	penable <= 0;
end
endtask
 
apb_regfile #(.ADDR_WIDTH(ADDR_WIDTH), .DATA_WIDTH(DATA_WIDTH)) u_apb_regfile (
	.PCLK		(pclk),
	.PRESETn	(presetn),
	.PADDR		(paddr),
	.PSEL		(psel),
	.PENABLE	(penable),
	.PWRITE		(pwrite),
	.PWDATA		(pwdata),
	.PRDATA		(prdata),
	.PREADY		(pready)
);
 
 
// create wave dump file
initial
begin
    $dumpfile("apb_regfile.vcd");
    $dumpvars(0, test);
end
 
// input stimulus
initial
begin
    pclk <= 0;
    presetn <= 0;
	paddr<=0;
	pwdata<=0;
	psel<=0;
	penable<=0;
	pwrite<=0;
    #100;
    presetn <= 1;
	apb_write(2'd0, 4'd1); 
	apb_read(2'd0); 
	apb_write(2'd1, 4'd2); 
	apb_read(2'd1); 
	apb_write(2'd2, 4'd3); 
	apb_read(2'd2); 
	apb_write(2'd3, 4'd4); 
	apb_read(2'd3); 
	#100;
    $finish();
end
 
endmodule

제일 마지막에 initial 문을 보면 apb_write와 apb_read를 이용하여 apb_regfile 모듈에 APB interface를 통하여 쓰고 읽는 기능을 수행한다.  아래와 같이 시뮬레이션을 실행한다.

$ iverilog test.v apb_regfile.v regfile.v
$ ./a.out

이렇게 시뮬레이션 진행 후 waveform을 열어 보면 아래와 같다.

APB spec의 write/read timing diagram과 동일한 파형이 생성되는 것을 볼 수 있다. 단 여기서는 pready가 항상 '1'이므로 wait상태가 없음에 유의한다.

 

지금까지 FSM을 이용한 APB interface를 갖는 APB register file을 설계하였다. 하지만 실제로는 FSM을 사용하지 않더라도 같은 기능을 하는 register file을 설계할 수 있다. 어떤 기능을 하는 하드웨어 로직을 설계하는 방법은 무궁 무진하다. 그 때 상황에 맞는 방법을 선택하면 된다. 이번 경우에도 FSM을 사용하지 않고 간단히 입력 신호를 조합한 제어 신호를 만들어 APB protocol을 만족 시키는 설계를 할 수 있다. address는 PADDR을 그냥 사용하면 되고 중요한 것은 PWRITE 신호를 어떻게 만드느냐 하는 것이었다. read같은 경우는 PREADY를 항상 '1'로 고정하기 때문에 register file 특성상 address에 대한 메모리 데이터가 항상 출력되게 되어있다. 따라서 write를 어떤 시점에 하느냐가 중요한데 APB timing diagram을 보면 PSEL, PENABLE, PWRITE 그리고 PREADY가 동시에 '1'일 때 데이터가 써지면 되는 것을 알 수 있다. 여기서 PREADY는 항상 '1'이므로 write enable 신호는 는 다음과 같이 만들 수 있다.

assign WE      = PSEL & PENABLE & PWRITE;

이렇게 FSM을 사용하지 않고 code를 refactoring하면 아래와 같다.

module apb_regfile #(
	parameter ADDR_WIDTH=8,
	parameter DATA_WIDTH=16
	)
	(
	input wire PCLK,
	input wire PRESETn,
	input wire [ADDR_WIDTH-1:0] PADDR,
	input wire PSEL,
	input wire PENABLE,
	input wire PWRITE,
	input wire [DATA_WIDTH-1:0] PWDATA,
	output reg [DATA_WIDTH-1:0] PRDATA,
	output wire PREADY
	);

	wire [ADDR_WIDTH-1:0] ADDR;
	wire [DATA_WIDTH-1:0] DIN;
	wire [DATA_WIDTH-1:0] DOUT;
	wire                  WE;

	assign ADDR    = PADDR;
	assign DIN     = PWDATA;
	assign PREADY  = 1;
	assign WE      = PSEL & PENABLE & PWRITE;

	always @(*) PRDATA  = DOUT;

	regfile #(
     	.ADDR_WIDTH(ADDR_WIDTH),
     	.DATA_WIDTH(DATA_WIDTH)
     	) u_regfile (
    	.CLK     (PCLK),
    	.RSTn    (PRESETn),
    	.ADDR    (ADDR),
    	.DIN     (DIN),
    	.DOUT    (DOUT),
    	.WE      (WE)
    	);
 
endmodule

코드가 매우 간단해졌다. 이 코드를 사용하여 시뮬레이션을 돌리고 wave form을 확인하면 이전과 같음을 알 수 있다.

 

반응형