이 페이지는 클록 도메인에 관한 세 편의 연재 중 마지막 글입니다.
비트 하나로는 부족할 때
실제로는 클록 도메인을 건너야 하는 신호가 단일 비트가 아니라 데이터 워드(data word)인 경우가 많습니다. 이런 경우 가장 확실한 해법은 FPGA 제조사가 제공하는 듀얼 클록 FIFO 를 사용하는 것입니다. 앞서 이미 권장한 대로 말이죠. 하지만 때로는 FIFO 를 쓸 수 없는 상황도 있습니다. 게다가 누군가는 과거에 그 FIFO 를 직접 구현해야 했을 테고요.
그러니 목표는 벡터(vector) 신호를 다른 클록 도메인(clock domain)에서 올바르게 나타나게 하는 것입니다. 먼저 클록 도메인 교차(clock domain crossing)에 대한 순진하고 잘못된 예를 하나 보면서, 왜 이 일이 쉽지 않은지 설명하겠습니다.
reg [7:0] foo, bar, bar_metaguard;
always @(posedge clk1)
foo <= foo + 1;
always @(posedge clk2)
begin
bar_metaguard <= foo; // This will fail sometimes!
bar <= bar_metaguard;
end
이 예는 이전 페이지의 단순한 메타안정성 가드(metastability guard) 예와 똑같지만, 레지스터가 8 비트 벡터이고 @foo 가 '0'과 '1' 사이를 오가는 대신 카운터로 동작한다는 차이가 있습니다.
그렇다면 왜 틀렸을까요? 문제는 @foo 에서 @bar_metaguard 로 가는 경로(path)들의 라우팅 지연(routing delay) 차이에 있습니다. 여덟 개의 비트는 각자 서로 다른 지연을 갖습니다. @foo 가 변할 때, 이 비트들 중 일부의 변화는 @bar_metaguard 의 플립플롭에 정상적인 타이밍으로 도착할 수 있지만, 다른 일부는 제때 도착하지 못할 수 있습니다.
따라서 @bar_metaguard 를 구성하는 8 개의 플립플롭 어디에서도 메타안정성(metastability)이 발생하지 않더라도, @foo 가 변할 때 @bar_metaguard 의 비트 중 일부만 새 값을 받고 나머지는 예전 값을 유지하는 상황이 생길 수 있습니다. @foo 가 0xff 에서 0x00 으로 변한다면, @bar_metaguard 의 다음 값은 어떤 값이든 될 수 있습니다. 일부 비트는 변화 전의 @foo 값을, 다른 비트는 변화 후의 값을 받기 때문입니다. 이 잘못된 값은 @clk2 의 한 클록 사이클이 지난 뒤 @bar 에 나타납니다.
이 문제를 해결하려면 먼저 요구 사항을 명확히 해야 합니다. @bar 가 항상 유효한 값을 담고 있어야 하는가, 아니면 가끔씩 어떤 정보를 한 클록 도메인에서 다른 클록 도메인으로 전달하는 용도인가? 이 두 가지 경우를 따로 살펴보겠습니다.
옵션 1: 지속적인 샘플링
목적지 워드(@bar)가 다른 클록 도메인의 워드(@foo)를 지속적으로 샘플링(sampling)하면서 항상 유효하고 의미 있는 값을 가져야 한다면, 이를 보장하는 방법은 하나뿐입니다. 위에서 보인 순진한 클록 도메인 교차 방식을 쓰되, @clk1 의 각 클록 사이클마다 @foo 의 비트 중 하나만(또는 아무것도) 변하도록 만드는 것입니다. 다시 말해 벡터 신호에 메타안정성 가드를 사용하되, 같은 클록 사이클에서 여러 비트가 동시에 변하는 문제를 피하라는 뜻입니다.
각 클록 사이클에서 한 비트만 변할 수 있기 때문에, 각 변화는 @bar_metaguard 가 샘플링하든 놓치든, 어느 쪽이든 @foo 가 가졌던 값 중 하나를 그대로 반영합니다.
그러므로 위 예의 @foo 가 일반적인 이진 코드(binary code) 대신 그레이 코드(Gray code)를 사용하는 카운터였다면 전혀 문제없이 동작했을 것입니다. 그레이 코드의 핵심은 값이 하나 증가할 때마다 비트 하나만 변한다는 것이므로, @bar 는 항상 유효한 값을 담는 것이 보장됩니다.
잠깐, @clk1 의 주파수가 @clk2 보다 높으면 어떻게 될까요? @bar 가 @foo 의 일부 값을 건너뛰어도 괜찮다면 상관없습니다. 예를 들어 @foo 가 그레이 코드 형식의 카운터라면, @bar 를 볼 때 일부 카운트 값이 건너뛰어질 수 있습니다. 그렇지만 @bar 에 나타나는 모든 값은 어느 순간 @foo 에 실제로 존재했던 값이므로 정확합니다.
이 방법이 가장 널리 쓰이는 곳은 듀얼 클록 FIFO 내부입니다. FIFO 의 RAM 주소를 두 클록 도메인 사이로 옮길 때 그레이 코드를 사용합니다. 쓰기 측은 자신이 RAM 에 마지막으로 쓴 워드의 주소를 그레이 코드로 인코딩합니다. 그 인코딩된 워드는 다른 클록 도메인에 있는 읽기 측으로 메타안정성 가드를 거쳐 전달됩니다. 반대 방향도 같은 방법을 씁니다.
각 측은 메타안정성 가드의 지연을 거친 상대측 주소를 알 수 있으므로, FIFO 에 현재 몇 개의 요소가 들어 있는지도 계산할 수 있습니다. 그 결과 empty(빈 상태)와 full(가득 참) 같은 신호를 만들어 낼 수 있습니다.
더 높은 빈도로 발생하는 이벤트 전달
이전 페이지에서 단일 비트용 메타안정성 가드에는 보내는 쪽 클록 도메인의 클록 주파수에 제약이 있다고 했습니다. 이 비트의 변화 하나하나가 상대방에게 어떤 이벤트를 알리는 방식이라면, 이벤트가 너무 자주 발생할 때 받는 쪽이 이벤트를 놓칠 위험이 있습니다.
이에 대한 해결책은 그레이 코드로 인코딩된 카운터를 클록 도메인 사이로 전달하는 것입니다. 이렇게 하면 받는 쪽이 지금까지 몇 번의 이벤트가 발생했는지 알 수 있으므로 정보를 잃지 않습니다. 이 카운터의 비트 수는, 이벤트가 @clk1 의 매 클록 사이클마다 발생하더라도 받는 쪽이 발생 횟수를 알아낼 수 있도록 충분히 크게 잡습니다.
그런 의미에서 단일 비트보다 벡터를 다루는 것이 오히려 더 쉬울 수 있습니다. 단일 비트가 변했는데 목적지 클록이 더 느려서 그 변화를 놓치면, 어떤 일이 있었는지조차 알 수 없기 때문입니다. 그러나 그레이 코드처럼 올바르게 전달되는 워드라면 정보가 유실되지 않습니다.
그리고 이런 용도로 단일 비트로 충분하다면, 그레이 코드 카운터는 각 이벤트마다 값이 반전되는 단일 비트에 불과합니다. 다시 말해 단일 비트의 경우, 그레이 코드 카운터를 쓰는 해법은 단순한 메타안정성 가드와 정확히 같습니다.
경로 타이밍에 대한 사소한 참고 사항
이 절의 제목이 암시하듯, 다음 절로 건너뛰어도 좋습니다.
벡터 신호용 메타안정성 가드에는 한 가지 암묵적인 가정이 있습니다. 바로 이 경로들 사이의 지연 차이가 보내는 쪽 클록의 한 주기를 넘지 않는다는 것입니다.
이 가정은 특별한 조치 없이도 거의 확실히 충족됩니다. 그렇더라도 이론적인 예를 하나 들어 보겠습니다. @clk1 의 주파수가 500 MHz 이고, @foo 에서 @bar_metaguard 로 가는 경로 중 하나의 라우팅 지연이 1 ns 라고 합시다. 또 다른 경로의 라우팅 지연은 4 ns 라고 가정해 보겠습니다. 물론 실제로 그럴 가능성은 극히 낮지만, 어떤 일이 생길 수 있는지 보겠습니다.
한 비트가 변하면서 4 ns 가 걸리는 여정을 시작합니다. 그다음 클록 사이클, 즉 2 ns 후에 다른 비트가 변하고, 1 ns 만에 @bar_metaguard 에 도달합니다. 그런데 이는 첫 번째 비트가 도착하기 1 ns 전입니다. 따라서 @bar_metaguard 는 @foo 가 실제로 가져 본 적 없는 값으로 워드 전체를 샘플링할 수 있습니다.
라우팅 지연은 실제로 이 예보다 훨씬 짧은 경우가 대부분이므로, 이런 일이 현실에서 일어나지는 않을 것으로 기대합니다. 그렇지만 이론상 어떤 라우팅 지연이든 가능합니다. 이런 가능성을 완전히 없애려면 다음과 같은 타이밍 제약 조건(timing constraints)을 사용할 수 있습니다(Vivado 형식으로 작성).
set_max_delay -datapath_only -from [ get_pins -hier -filter {name=~*/C} ] -to [ get_pins -hier -filter {name=~*_metaguard*/D} ] 1.5
이 제약 조건은 이전 페이지에서 본 set_max_delay 제약 조건과 비슷해 보입니다. 하지만 이전 페이지에서는 메타안정성 가드가 "-from" 부분에 있었고, 여기서는 "-to" 부분에 있습니다. 따라서 두 제약 조건은 같은 경로에 적용되지 않습니다. 이전 페이지의 제약 조건은 메타안정성 가드가 메타안정 상태에서 회복할 시간을 주기 위한 것이었습니다. 그래서 그 제약은 같은 클록 사이의 경로에 적용됩니다. 반면 위 제약 조건은 클록 도메인 교차 자체와 관련이 있습니다.
그래서 이 제약 조건은 다르게 작성됩니다. 관련 경로들이 비관련 클록(unrelated clocks)의 클록 도메인 사이를 연결하므로, 이 클록들의 스큐(clock skew)나 지터(jitter)를 고려하는 것은 의미가 없습니다. -datapath_only 옵션이 바로 그 뜻입니다. 클록이 플립플롭까지 도달하는 데 걸리는 시간은 무시하고, 데이터 경로만 재십시오.
이 제약 조건을 헷갈리게 만드는 부분은, 경로가 보내는 쪽 플립플롭의 클록 핀에서 시작해 목적지의 데이터 입력 핀(D)에서 끝난다는 점입니다. 따라서 스톱워치는 보내는 쪽 플립플롭에 클록이 도달할 때 시작되어, 갱신된 신호가 목적지에 도착할 때 멈추며, 그 도착이 목적지의 셋업 시간 요구 조건을 충족해야 합니다. 그러므로 이 경로에는 양쪽의 타이밍 요구 조건이 모두 포함됩니다.
이 타이밍 제약 조건처럼 모든 경로를 1.5 ns 로 제한하면 어떤 경로도 이 한계를 넘을 수 없고, 따라서 경로 지연 사이의 차이도 이 값 이하로 제한됩니다. 그래서 @clk1 의 클록 주기가 2 ns 라 하더라도 경로들이 잘못된 순서로 도착하는 일은 불가능합니다. 물론 이것도 실제로는 극히 일어나기 어려운 일이지만, 이 제약 조건이 그걸 보장하는 방법입니다.
메타안정성 가드로 가는 경로에 가짜 경로(false path) 제약 조건(예: set_false_paths 또는 set_clock_groups)이 적용되어 있다면 set_max_delay 는 아무 효과가 없을 가능성이 높습니다. 가짜 경로 제약 조건이 우선하기 때문입니다. 따라서 설계 도구가 제약 조건을 원하는 대로 해석하는지 확인하려면 항상 타이밍 리포트의 해당 경로들을 점검해야 합니다. 타이밍에 관한 다른 페이지에서 이 이야기를 다룹니다.
옵션 2: 레지스터를 가끔 갱신하기
매 클록 사이클마다 비트 하나만 변해야 한다는 제약은 때로 너무 엄격합니다. 데이터가 가끔씩만 갱신된다면 다른 기법을 사용할 수 있습니다. 아래 예에서는 @do_update 가 여러 클록 사이클에 한 번씩만 활성화(값 '1')된다고 가정하겠습니다. 또한 이 신호는 @foo 의 값을 @new_value 로 갱신해야 함을 나타내는 용도라고 가정합니다.
reg [7:0] foo, bar;
reg toggle, toggle_metaguard, toggle_a, toggle_b;
reg new_bar;
always @(posedge clk1)
if (do_update)
begin
foo <= new_value;
toggle <= !toggle;
end
always @(posedge clk2)
begin
toggle_metaguard <= toggle;
toggle_a <= toggle_metaguard;
toggle_b <= toggle_a;
if (toggle_a != toggle_b)
bar <= foo; // No metastability guard, because foo is stable
new_bar <= (toggle_a != toggle_b); // Not necessary, just side info
end
일단 @new_bar 는 무시하겠습니다. 나중에 다시 다루겠습니다.
동작 방식은 이렇습니다. @foo 는 @do_update 가 활성화될 때만 갱신됩니다. 그 순간 같은 클록 사이클에서 @toggle 도 반대 값으로 바뀝니다.
@clk2 클록 도메인에서는 @toggle_metaguard 가 메타안정성 가드를 거쳐 @toggle 값을 받습니다. 그다음 클록 사이클에 이 값이 @toggle_a 로 복사됩니다. 그리고 한 클록 사이클이 더 지나면 @foo 의 값이 @bar 로 직접 복사됩니다. @toggle_a 와 @toggle_b 가 정확히 한 클록 사이클 동안 서로 다른 값을 갖기 때문입니다.
@bar 와 @foo 가 서로 다른 클록 도메인에 있다는 사실은 문제가 되지 않습니다. @foo 가 타이밍 요구 조건을 충족하기에 충분한 시간, 아니 그보다 훨씬 오랫동안 안정적으로 유지되어 왔기 때문입니다.
왜 그렇게 확신할까요? 이번에는 그럴 만한 이유가 있습니다. 과정은 @toggle 이 변해서 @toggle_metaguard 의 값이 바뀌면서 시작됩니다. 만약 @bar 가 같은 @clk2 사이클에서 @foo 를 샘플링했다면 안전하지 않았을 것이고, 운이 좋으면 괜찮았을 수도 있습니다. 하지만 @toggle_metaguard 의 새 값이 @toggle_a 에 도달할 때까지 @clk2 의 클록 사이클이 한 번 더 지나갑니다. 그리고 그때에도 @bar 는 갱신되지 않고, @clk2 의 다음 클록 사이클이 되어서야 갱신됩니다.
따라서 @foo 가 변하는 순간부터 @foo 가 @bar 에 샘플링될 때까지의 시간은 최소한 @clk2 의 클록 사이클 두 번에 해당합니다. 어떤 플립플롭의 셋업 시간과 비교하면 영겁과도 같은 시간입니다. 그렇긴 해도 이전 페이지에서 설명한 set_max_delay 를 @toggle_metaguard 에 적용하는 것은 합리적입니다. 방금 말한 '영겁' 덕분에 필요할 가능성은 매우 낮지만, @bar 로 가는 경로에도 같은 작업을 할 수 있습니다.
이 방법의 아킬레스건은 @do_update 가 충분히 드물게 활성화되어, @bar 가 @foo 를 샘플링할 때 @foo 가 안정적으로 유지되도록 해야 한다는 것입니다. 이런 갱신 사이에 합리적으로 필요한 최소 시간은 @clk2 의 클록 사이클 네 번에 해당하는 시간입니다. 따라서 @clk1 의 몇 클록 사이클이 @clk2 의 네 클록 사이클과 맞먹는지 계산한 뒤 올림하여 정수로 만듭니다. @clk1 이 @clk2 보다 네 배 느리다면(또는 더 느리다면) 전혀 제약이 되지 않습니다. 그렇지 않다면, @do_update 가 허용된 것보다 더 자주 활성화되지 않도록 보장하는 메커니즘을 로직에 넣어야 합니다.
사실 실무 설계에서는 갱신 주기가 아주 느릴 때, @toggle 이 제공하는 것 같은 보호 장치 없이 클록 도메인을 무심코 건너는 경우가 있습니다. 그렇게 하면 @foo 가 @bar 로 계속 복사됩니다. @foo 가 아주 가끔 변할 때 @bar 는 클록 사이클 한 번 동안 잘못된 값을 가질 수 있지만, 누가 신경 쓸까요? 어쨌든 동작하니까요. 대부분 이런 실수는 클록 도메인 문제 자체를 무시한 결과입니다. 가끔은 그렇지 않을 때까지는요.
대충 처리하는 이야기가 나와서 말인데, 위 예에서 @toggle 이나 관련 레지스터들은 리셋되지도 않고 초기값도 할당되지 않았습니다. 보통은 문제없습니다. 합성 도구(synthesizer)가 이들을 모두 초기값 0 으로 지정할 가능성이 높기 때문입니다. 그리고 레지스터들의 초기값이 서로 같지 않더라도 결과는 @foo 를 한 번 불필요하게 샘플링하는 것 이상이 아닙니다. 그렇더라도 이 레지스터들을 리셋해 두는 것이 좋을 수도 있습니다.
좀 더 발전된 변형들
지금까지 세 가지 간단한 예를 보았습니다.
- 이전 페이지에서 다룬, 메타안정성 가드를 이용해 단일 비트를 클록 도메인 사이로 보내는 방법.
- 여러 비트에도 같은 방식을 적용하되, 매 클록 사이클마다 비트 하나만 변해야 한다는 제약이 따르는 방법.
- 워드에 대한 제약 없이 클록 도메인 사이를 건너되, 그 워드가 얼마나 자주 바뀔 수 있는지에 제약이 있는 방법. 이때 토글 비트를 사용해 워드가 안정적일 때만 샘플링되도록 했습니다.
이상의 간단한 예들은 여러 다른 메커니즘의 기초가 됩니다.
먼저, 앞의 예에서 @new_bar 에 대해 설명하겠다고 약속했습니다. @new_bar 는 그저 @bar 가 새 값을 가질 때 한 클록 사이클 동안 1 이 되는 레지스터입니다. 특별할 것 없는 레지스터입니다. 다만 @bar 와 @new_bar 가 다른 클록 도메인의 @foo 와 @do_update 를 반영한다는 점에 주목하세요. 즉 이것은 한 클록 도메인에서 다른 클록 도메인으로 명령이나 상태 메시지를 전달하는 한 가지 방법입니다. 가능하면 FIFO 를 써야 한다고 제가 이미 말했었나요?
마지막 예의 또 다른 흥미로운 확장은 @foo 와 @bar 라는 레지스터 쌍 대신 듀얼 포트 RAM(dual port RAM)을 두는 것입니다. 이것은 데이터 버퍼를 클록 도메인 사이로 전달하는 방법입니다. @clk1 클록 도메인의 로직이 RAM 에 데이터를 쓰다가 시간이 지나 RAM 의 절반을 채운다고 가정해 보겠습니다. 로직이 계속해서 RAM 의 나머지 절반을 채우기 시작할 때 @toggle 값을 바꿉니다. 이 레지스터는 위에서 본 방식 그대로 @clk2 클록 도메인으로 복사됩니다. 그런 다음 예에서처럼 @bar 를 갱신하는 대신, 로직은 RAM 의 첫 번째 절반에 있는 데이터를 소비합니다.
이렇게 간단한 레지스터 하나로 이중 버퍼(double-buffer) 메커니즘을 동기화할 수 있습니다. 이 메커니즘에서는 한쪽은 이 RAM 에 데이터를 쓰고, 다른 쪽은 이 RAM 에서 데이터를 읽습니다. 실제로 @toggle 의 역할은 단순히 값을 바꾸는 것뿐 아니라, 상대방에게 현재 RAM 의 어느 절반이 쓰이고 있는지도 알려줍니다.
그렇지만 가능하면 FIFO 를 사용하는 것이 가장 좋습니다. 이중 버퍼 메커니즘이 매력적으로 들릴 수는 있지만, 더 나은 대안이 없을 때만 사용해야 합니다. 예를 들어 RAM 에서 데이터를 쓴 순서와 다르게 읽어야 하는 경우가 그렇습니다.
요약
결국 다음과 같이 정리됩니다. 비관련 클록의 클록 도메인 사이를 건널 때는 항상 재동기화 로직(resynchronization logic)이 개입됩니다. 이 재동기화를 통과하는 데이터 워드에는 제약이 있어서, 보내는 쪽 클록(@clk1)의 각 클록 사이클마다 비트 하나만 값이 바뀔 수 있습니다. 그렇지 않으면 목적지에 잘못된 데이터가 도착할 수 있습니다.
어떤 응용에서는 이것으로 충분합니다. 하지만 그 제약이 너무 엄격하다면, 데이터 자체에는 재동기화 로직을 쓰지 않고 벡터 레지스터나 RAM 을 통해 클록 도메인 사이로 옮길 수도 있습니다. 이것이 가능한 이유는 데이터의 쓰기 동작과 읽기 동작 사이에 최소한의 시간 간격을 유지하는 로직이 있기 때문입니다. 이 시간 간격 덕분에 목적지에서 워드가 샘플링될 때 안정적입니다. 그렇지만 이 로직도 결국은 같은 클록 도메인 교차 기법에 기반합니다. 따라서 이 해법에도 재동기화 로직이 포함되는데, 그 로직은 한 번에 한 비트씩만 바꾸는 방식으로 동작하며, 그레이 코드를 사용하기도 합니다.
그래서 비관련 클록이 얽혀 있는 곳에는 언제나 재동기화 로직과 '한 번에 한 비트'라는 규칙이 따라옵니다. 단지 그것을 어떻게 적용하느냐가 다를 뿐입니다.