[Verilog] 비동기 신호 처리: CDC와 Metastability

지난 포스팅에서 UART Rx 모듈을 설계하면서, 외부에서 들어오는 비동기 신호(Rx)를 다룰 때 주의가 필요하다고 언급했었습니다.

RTL 설계를 하다 보면 CDC(Clock Domain Crossing)라는 말을 정말 많이 듣게 됩니다. 이 글에서는 서로 다른 clock domain을 사용할 때 발생하는 Metastability 문제와, 이를 완화하는 가장 기본적인 방법인 2-FF Synchronizer에 대해 정리해 보겠습니다.

CDC (Clock Domain Crossing)란?

CDC (Clock Domain Crossing)
CDC (Clock Domain Crossing)

CDC는 말 그대로 signal이 다른 clock domain을 건너간는 뜻입니다.

우리가 만드는 chip 내부의 모든 logic이 Single Clock으로만 동작하면 정말 좋겠지만, 실제 설계하는 chip은 그렇지 않습니다.

  • 외부 입력: 버튼 입력, UART/SPI/I2C 같은 peripheral 통신 신호는 우리 칩의 system clock과 전혀 상관없는 타이밍에 들어옵니다. (Asynchronous)
  • 다중 클럭: top module에 기본 clock은 100MHz로 돌고, 특정 sub module은 50MHz로 도는 등 서로 다른 속도의 clock이 사용되기도 합니다.

이렇게 Source(송신) 측의 clock과 Destination(수신) 측의 clock이 서로 다를 때 신호가 전달되는 것을 CDC라고 합니다.

왜 문제가 될까? : Metastability

“그냥 신호 연결하면 되는 거 아닌가?”라고 생각할 수 있지만, Flip-Flop의 Setup TimeHold Time 조건을 떠올려 봅시다.

  • Setup Time: Rising edge 전, 데이터가 일정 시간 동안 안정적으로 유지되어야 함.
  • Hold Time: Rising edge 후, 데이터가 일정 시간 동안 변하지 않아야 함.

만약 외부 신호가 우리 시스템 클럭이 딱! 뜨는 그 찰나의 순간(Setup/Hold window)에 0에서 1로 변해버리면 어떻게 될까요?

Flip-Flop은 0인지 1인지 판단하지 못하고, 중간 전압 레벨(Metastable state)에서 부들부들 떠는 상태가 됩니다. 이를 Metastability라고 합니다.

이 불안정한 값은 unknown 형태로 회로 내부로 전파되어 전체 시스템을 오작동(System Failure) 시킬 수 있습니다. 이것이 RTL 엔지니어가 Asynchronous signal을 다룰 때 조심해야 하는 이유입니다.

해결책: 2-FF Synchronizer (Double Flopping)

이런 상황을 최적화하는 가장 간단하고 강력한 방법은 Flip-Flop을 두 번 거치게 하는 것입니다. 이를 2-FF Synchronizer라고 부릅니다.

2-FF timing diagram

동작 원리

  1. 첫 번째 Flip-Flop(FF1)이 Metastability 상태에 빠졌다고 가정해 봅시다.
  2. 하지만 두 번째 Flip-Flop(FF2)은 다음 clock Rising edge까지 기다립니다.
  3. 그 한 clock 동안, FF1의 불안정한 전압이 서서히 0이나 1 중 하나의 안정된 상태로 자리를 잡게 됩니다. (이를 Settling Time이라고 합니다.)
  4. FF2는 안정된 값을 샘플링하여 내부 로직으로 전달합니다.

참고로, 여기서 말하는 해결책은 Metastability 문제를 완전히 해결하는 것이 아니라 발생 확률을 낮추는 것을 의미합니다.

Verilog code

module synchronizer (
     input  wire clk
    ,input  wire resetn
    ,input  wire async_in // 외부 비동기 입력
    ,output wire sync_out // 동기화된 안전한 출력
);

    reg [1:0] shift_reg;

    always @(posedge clk or negedge resetn) begin
        if (!resetn) begin
            shift_reg <= 2'b00;
        end
        else begin
            // [0]번 비트로 받고, [1]번 비트로 넘김 (Shift)
            shift_reg <= {shift_reg[0], async_in};
        end
    end

    // 두 번째 플립플롭의 출력을 사용
    assign sync_out = shift_reg[1];

endmodule

Pulse Synchronizer (Fast to Slow)

기본적인 2-FF Synchronizer에는 치명적인 약점이 하나 있습니다. 바로 빠른 clock에서 느린 clock으로 1주기 짜리 짧은 pulse를 보낼 때입니다.

  • Source: 100MHz (10ns 주기)
  • Destination: 10MHz (100ns 주기)

만약 Source에서 10ns짜리 pulse를 보냈는데, Destination clock이 그 10ns 사이를 비껴가서 샘플링한다면? 수신 측은 신호가 들어왔는지조차 모르게 됩니다.

해결책: Toggle 방식 사용

이때는 pulse를 토글(Toggle) 신호로 변환해서 보냅니다. 신호의 레벨(Level)을 바꿔주면 느린 클럭이 언젠가는 바뀐 레벨을 감지할 수 있기 때문입니다.

Verilog code

module pulse_synchronizer (
    input wire clk_fast,   // 송신 측 (빠른 클럭)
    input wire resetn,
    input wire pulse_in,   // 송신할 1-cycle 펄스
    input wire clk_slow,   // 수신 측 (느린 클럭)
    output wire pulse_out  // 수신된 1-cycle 펄스
);

    // 1. [Fast Domain] Pulse를 Toggle 신호로 변환
    reg toggle_reg;
    always @(posedge clk_fast or negedge resetn) begin
        if (!resetn) toggle_reg <= 1'b0;
        else if (pulse_in) toggle_reg <= ~toggle_reg; // 펄스가 올 때마다 뒤집기
    end

    // 2. [Slow Domain] Toggle 신호를 2-FF Synchronization
    reg [2:0] sync_reg; 
    always @(posedge clk_slow or negedge resetn) begin
        if (!resetn) begin
            sync_reg <= 3'b000;
        end
        else begin
            // sync_reg[2]는 Edge 감지용, [1]은 동기화 완료된 값
            sync_reg <= {sync_reg[1:0], toggle_reg};
        end
    end

    // 3. [Slow Domain] Edge Detection (Toggle이 변했음을 감지하여 다시 Pulse로 복원)
    // 현재 값(sync_reg[1])과 과거 값(sync_reg[2])이 다르면 펄스 생성
    assign pulse_out = sync_reg[1] ^ sync_reg[2]; 

endmodule

주의사항

2-FF Synchronizer는 만능이 아닙니다. 이 방식은 Single-bit Control Signal (예: Enable, Start 신호 등)에만 사용해야 합니다.

Data Bus(여러 비트)에는 사용하면 안 됩니다. 예를 들어 1-byte 데이터를 2-FF로 넘기면, 각 비트마다 딜레이 차이(Skew)가 발생해서 엉뚱한 값(Glich)이 전달될 수 있습니다.

  • Single Bit: 2-FF Synchronizer 사용
  • Multi Bit (Data Bus): FIFO(Async FIFO)나 Handshaking 프로토콜 사용

심화: 단순한 2-FF만으로는 부족하다

앞서 살펴본 2-FF Synchronizer는 단일 비트(Single-bit) 신호를 처리하는 데에는 가장 효과적인 방법입니다. 하지만 실전 SoC 설계에서는 데이터 버스와 같은 멀티 비트(Multi-bit) 신호를 다뤄야 하고, 시뮬레이션만으로는 찾을 수 없는 코너 케이스들이 존재합니다.

Multi-bit 신호의 함정: Data Incoherency

“데이터 버스도 그냥 각 비트마다 2-FF를 붙이면 되지 않을까요?” 이것은 가장 위험한 생각입니다. 서로 다른 clock domain 간에는 신호의 도달 시간 차이(Skew)가 존재하기 때문입니다.

문제 상황: 비트 간 스큐(Skew)

예를 들어, Source 도메인에서 b00b11로 값을 변경했다고 가정해 봅시다. 배선 길이의 차이나 공정 미세 변동으로 인해, Destination 도메인 입장에서는 두 비트가 동시에 도착하지 않을 수 있습니다.

  • Case 1: 하위 비트가 먼저 도착 → b01로 오인
  • Case 2: 상위 비트가 먼저 도착 → b10으로 오인

결국 0011로 보냈는데 중간에 01이나 10이라는 엉뚱한 값(Glitch)이 샘플링되어 시스템 오작동을 유발합니다. 이를 Data Incoherency(데이터 불일치)라고 합니다.

해결책: 상황별 동기화 기법

  1. Gray Code (그레이 코드):
    • 용도: 카운터나 포인터(FIFO Pointer 등)를 넘길 때.
    • 원리: 인접한 값끼리 오직 1개의 비트만 변하도록 인코딩합니다. (예: 00 -> 01 -> 11 -> 10)
    • 장점: 1비트만 변하므로 skew로 인한 중간 값 오류가 발생하지 않습니다.
  2. Handshake Protocol (핸드쉐이크):
    • 용도: 데이터가 자주 변하지 않을 때.
    • 원리: REQ(요청) 신호를 보내고, 수신 측에서 데이터를 잘 받았다는 ACK(응답) 신호를 보낼 때까지 데이터를 유지합니다.
    • 단점: 속도가 느려집니다(Latency 증가).
  3. Async FIFO (비동기 FIFO):
    • 용도: 대량의 데이터 스트림을 고속으로 넘길 때.
    • 원리: 내부적으로 Gray Code 포인터와 Dual-port memory를 사용하여 가장 안전하게 데이터를 전달합니다. 검증된 IP를 사용하는 것이 좋습니다.

참고: Nandland

유사한 게시물