지난 RTL CDC 글을 통해 우리는 단일 비트(1-bit) 신호를 동기화하는 2-FF Synchronizer와 Multi-bit 신호의 위험성에 대해 배웠습니다.
이제 CDC 이론의 ‘끝판왕’이자, SoC 및 FPGA 설계에서 가장 많이 사용되는 블록 중 하나인 Asynchronous FIFO를 직접 설계해 볼 차례입니다. 서로 다른 clock domain 사이에서 대량의 데이터를 안전하게 넘기기 위해 반드시 거쳐야 할 관문입니다.
단순히 코드를 복사해서 쓰는 것을 넘어, “도대체 왜 포인터를 그레이 코드(Gray Code)로 변환해야 하는지”, “Full/Empty 플래그는 어떤 기준으로 만들어지는지” 그 내부 원리를 파헤쳐 보겠습니다.
1. Asynchronous FIFO의 RTL 설계 난제
FIFO(First-In-First-Out)의 원리는 간단합니다.
- Write Pointer (wptr): 데이터를 쓸 주소. 데이터가 들어오면 1 증가합니다.
- Read Pointer (rptr): 데이터를 읽을 주소. 데이터가 나가면 1 증가합니다.
- Empty:
wptr == rptr - Full:
wptr가 한 바퀴를 돌아rptr를 따라잡았을 때.
동기식(Synchronous) FIFO에서는 두 포인터가 같은 클럭을 사용하므로 비교가 쉽습니다. 하지만 비동기식(Asynchronous) FIFO에서는 Write Clock 도메인과 Read Clock 도메인이 서로 다릅니다.
상대방의 포인터를 내 도메인으로 가져와서 비교해야 하는데, 여기서 두 가지 치명적인 문제가 발생합니다.
- Metastability: 상대방 포인터를 샘플링하는 순간 값이 변할 수 있습니다.
- Data Incoherency (Multi-bit Skew): 포인터는 여러 비트(Multi-bit)로 구성됩니다. 예를 들어 이진수
0111에서1000으로 변할 때, 4개의 비트가 동시에 변합니다. 배선 지연(Skew)으로 인해 수신 측에서는 찰나의 순간1111이나0000같은 엉뚱한 값을 볼 수 있습니다.
이 문제를 해결하는 열쇠가 바로 그레이 코드(Gray Code)입니다.
2. 해결사: 그레이 코드 (Gray Code)
그레이 코드는 인접한 수로 변할 때 오직 하나의 비트만 변하는 특성을 가집니다.
- Binary:
0111 (7)->1000 (8)(4비트 변화 -> 위험!) - Gray:
0100 (7)->1100 (8)(1비트 변화 -> 안전!)
만약 Gray code를 사용하면, Metastability이나 skew가 발생하더라도 수신 측에서는 “값이 변하기 전(7)” 혹은 “값이 변한 후(8)” 둘 중 하나로만 인식됩니다. 중간 단계의 엉뚱한 값이 생길 수 없으므로 포인터가 튀는 것을 막을 수 있습니다.
따라서 Asynchronous FIFO 설계의 대원칙은 다음과 같습니다.
“내 도메인의 포인터를 그레이 코드로 변환하여 상대방에게 넘겨준다.”
3. Full / Empty 생성의 비밀: 보수적 설계 (Pessimistic Design)
CDC 설계에서 가장 중요한 개념은 보수적(Pessimistic) 설계입니다.
상대방의 포인터가 2-FF Synchronizer를 거쳐 넘어오는 동안 최소 2~3 clock의 지연(Latency)이 발생합니다. 즉, 내가 지금 보고 있는 상대방의 포인터는 “현재 값이 아니라 아주 조금 전의 과거 값”입니다.
(1) Empty Generation (Read Domain)
Empty는 Read 도메인에서 만듭니다.- 조건:
rptr == synchronized_wptr - Write Pointer는 계속 증가하고 있는데, 동기화 지연 때문에 Read 도메인에서는 아직 증가하지 않은 것처럼 보일 수 있습니다.
- 결과: 실제로는 데이터가 1~2개 들어와서 Empty가 풀렸는데도, 여전히 Empty라고 판단할 수 있습니다.
- 괜찮은가? 네! 데이터가 있는데 없다고 판단해서 읽기를 멈추는 것은 성능(Throughput) 저하일 뿐, 기능 고장(Underflow)은 아니기 때문에 안전합니다.
(2) Full Generation (Write Domain)
Full은 Write 도메인에서 만듭니다.- Gray Code에서의 Full 조건은 조금 복잡합니다.
- MSB가 달라야 함 (한 바퀴 차이)
- 2nd MSB도 달라야 함 (Gray Code의 대칭성 때문)
- 나머지 비트는 같아야 함
- Read Pointer가 계속 데이터를 읽어서 공간을 비우고 있는데, 동기화 지연 때문에 Write 도메인에서는 아직 꽉 찼다고 보일 수 있습니다.
- 결과: 실제로는 공간이 생겼는데 Full이라고 판단해서 쓰기를 멈춥니다.
- 괜찮은가? 네! 덮어쓰기(Overflow)를 방지하는 것이 목적이므로, 일찍 Full을 띄우는 것은 안전합니다.
4. Verilog 구현 (Full Source Code)
실전에서 바로 사용할 수 있는 5개의 전체 모듈 코드입니다.
(1) Asynchronous FIFO Top Module
module async_fifo #(
parameter DSIZE = 8, // Data Size
parameter ASIZE = 4 // Address Size (Depth = 2^4 = 16)
)(
input wire wclk, winc, wrst_n,
input wire rclk, rinc, rrst_n,
input wire [DSIZE-1:0] wdata,
output wire [DSIZE-1:0] rdata,
output wire wfull,
output wire rempty
);
wire [ASIZE-1:0] waddr, raddr;
wire [ASIZE:0] wptr, rptr, wq2_rptr, rq2_wptr;
// 1. Dual Port RAM
fifomem #(DSIZE, ASIZE) u_mem (
.wdata(wdata), .waddr(waddr), .wclk(wclk), .wclken(winc & ~wfull),
.rdata(rdata), .raddr(raddr), .rclk(rclk), .rclken(rinc & ~rempty)
);
// 2. Synchronizers (2-FF)
sync_r2w u_sync_r2w (.wclk(wclk), .wrst_n(wrst_n), .rptr(rptr), .wq2_rptr(wq2_rptr));
sync_w2r u_sync_w2r (.rclk(rclk), .rrst_n(rrst_n), .wptr(wptr), .rq2_wptr(rq2_wptr));
// 3. Write Control Logic (Binary -> Gray, Full Logic)
wptr_full #(ASIZE) u_wptr_full (
.wclk(wclk), .wrst_n(wrst_n), .winc(winc),
.wq2_rptr(wq2_rptr),
.wfull(wfull), .waddr(waddr), .wptr(wptr)
);
// 4. Read Control Logic (Binary -> Gray, Empty Logic)
rptr_empty #(ASIZE) u_rptr_empty (
.rclk(rclk), .rrst_n(rrst_n), .rinc(rinc),
.rq2_wptr(rq2_wptr),
.rempty(rempty), .raddr(raddr), .rptr(rptr)
);
endmodule(2) FIFO Memory (Dual Port RAM)
module fifomem #(
parameter DSIZE = 8, // Data Width
parameter ASIZE = 4 // Address Width
)(
input wire wclk, wclken,
input wire [ASIZE-1:0] waddr,
input wire [DSIZE-1:0] wdata,
input wire rclk, rclken,
input wire [ASIZE-1:0] raddr,
output wire [DSIZE-1:0] rdata
);
// 2^ASIZE depth memory array
reg [DSIZE-1:0] mem [0:(1<<ASIZE)-1];
// Write Logic
always @(posedge wclk) begin
if (wclken) mem[waddr] <= wdata;
end
// Read Logic (No Reset needed for memory cells usually)
assign rdata = mem[raddr];
endmodule(3) Synchronizers (2-FF)
안전한 포인터 전달을 위한 2-stage Flip-Flop입니다.
// Read Pointer to Write Domain Synchronizer
module sync_r2w #(
parameter ASIZE = 4
)(
input wire wclk, wrst_n,
input wire [ASIZE:0] rptr,
output reg [ASIZE:0] wq2_rptr
);
reg [ASIZE:0] wq1_rptr;
always @(posedge wclk or negedge wrst_n) begin
if (!wrst_n) begin
wq1_rptr <= 0;
wq2_rptr <= 0;
end else begin
wq1_rptr <= rptr;
wq2_rptr <= wq1_rptr;
end
end
endmodule
// Write Pointer to Read Domain Synchronizer
module sync_w2r #(
parameter ASIZE = 4
)(
input wire rclk, rrst_n,
input wire [ASIZE:0] wptr,
output reg [ASIZE:0] rq2_wptr
);
reg [ASIZE:0] rq1_wptr;
always @(posedge rclk or negedge rrst_n) begin
if (!rrst_n) begin
rq1_wptr <= 0;
rq2_wptr <= 0;
end else begin
rq1_wptr <= wptr;
rq2_wptr <= rq1_wptr;
end
end
endmodule(4) Read Control Logic (Empty Generation)
Binary to Gray 변환과 Empty 플래그 생성 로직입니다.
module rptr_empty #(
parameter ASIZE = 4
)(
input wire rclk, rrst_n, rinc,
input wire [ASIZE:0] rq2_wptr, // Synced Write Pointer (Gray)
output reg rempty,
output wire [ASIZE-1:0] raddr,
output reg [ASIZE:0] rptr // Read Pointer (Gray)
);
reg [ASIZE:0] rbin;
wire [ASIZE:0] rbin_next, rgray_next;
// 1. Binary Counter Update
// Empty가 아닐 때만 rinc를 받아들임
assign rbin_next = rbin + (rinc & ~rempty);
assign raddr = rbin[ASIZE-1:0];
// 2. Binary to Gray Conversion
assign rgray_next = (rbin_next >> 1) ^ rbin_next;
// 3. Empty Flag Generation
// 조건: Read Pointer(Gray) == Synced Write Pointer(Gray)
wire rempty_val = (rgray_next == rq2_wptr);
always @(posedge rclk or negedge rrst_n) begin
if (!rrst_n) begin
rbin <= 0;
rptr <= 0;
rempty <= 1; // Reset 상태에서는 Empty여야 함
end else begin
rbin <= rbin_next;
rptr <= rgray_next;
rempty <= rempty_val;
end
end
endmodule(5) Write Control Logic (Full Generation)
Binary to Gray 변환과 Full 플래그 생성 로직입니다.
module wptr_full #(
parameter ASIZE = 4
)(
input wire wclk, wrst_n, winc,
input wire [ASIZE:0] wq2_rptr, // Synced Read Pointer (Gray)
output reg wfull,
output wire [ASIZE-1:0] waddr,
output reg [ASIZE:0] wptr // Write Pointer (Gray)
);
reg [ASIZE:0] wbin;
wire [ASIZE:0] wbin_next, wgray_next;
// 1. Binary Counter Update
// Full이 아닐 때만 winc를 받아들임
assign wbin_next = wbin + (winc & ~wfull);
assign waddr = wbin[ASIZE-1:0];
// 2. Binary to Gray Conversion
assign wgray_next = (wbin_next >> 1) ^ wbin_next;
// 3. Full Flag Generation
// 조건 (Gray Code): MSB와 2nd MSB는 다르고, 나머지는 같아야 함
wire wfull_val = (wgray_next == {~wq2_rptr[ASIZE:ASIZE-1], wq2_rptr[ASIZE-2:0]});
always @(posedge wclk or negedge wrst_n) begin
if (!wrst_n) begin
wbin <= 0;
wptr <= 0;
wfull <= 0;
end else begin
wbin <= wbin_next;
wptr <= wgray_next;
wfull <= wfull_val;
end
end
endmodule5. 결론 및 요약
Asynchronous FIFO는 겉보기엔 데이터를 저장하는 통처럼 보이지만, 그 내부에는 CDC 문제를 해결하기 위한 치밀한 전략이 숨어 있습니다.
- Gray Code 사용: Multi-bit 포인터가 도메인을 넘어갈 때 값이 깨지는 것(Glitch)을 방지합니다.
- 보수적 설계: 동기화 지연으로 인해 Full/Empty가 조금 일찍 뜨는 것은 허용하지만, 늦게 뜨는 것은 절대 허용하지 않습니다. (Safety First)
- 2n Depth: Gray Code의 대칭성을 활용하여 Full/Empty 로직을 간단히 하기 위해 FIFO의 깊이는 보통 2의 승수(16, 32, 1024 등)로 설정합니다.
참고: VLSI verify