async FIFO는 클록 도메인을 넘는 데이터 경로의 정석입니다. 한 번 제대로 만들어 두면 어디에 붙여도 안전하다고 믿기 쉽습니다. 저도 그랬습니다. 그래서 같이 다녀야 하는 데이터 두 종류를 각자의 async FIFO로 보냈고, FIFO 하나하나는 문제없이 동작했습니다.
그런데 외부 검증팀이 돌린 시뮬레이션에서 두 데이터의 짝이 어긋나는 문제가 나왔습니다. 저희 쪽 시뮬레이션에서는 한 번도 보이지 않던 증상이었습니다. 각 FIFO는 맞게 동작했는데, 두 FIFO를 함께 쓰는 방식이 문제였고, 그 문제를 가리고 있던 것은 시뮬레이션이 동기화기를 다루는 방식이었습니다. 결국 두 FIFO를 넓은 FIFO 하나로 합쳤고, 그 뒤로 증상도 사라지고 CDC 결과도 단순해졌습니다.
지난 글에서는 블록을 SoC에 붙이는 wrapper를 만들며 클록 경계와 CDC 리포트를 정리한 이야기를 했습니다. 이번 글은 “3년 차가 프로젝트에서 배운 것들” 시리즈의 이어지는 기록으로, 그 경계 위에 놓인 FIFO에서 실제로 겪은 일입니다.
이번 글에서는 async FIFO 두 개가 왜 서로 어긋나는지, 왜 일반적인 RTL 시뮬레이션에서는 이 문제가 보이지 않는지, 미스샘플 주입으로 어떻게 드러났는지, 하나로 합칠 때 무엇을 기준으로 판단했는지, 그리고 합친 뒤 달라진 점을 살펴보겠습니다.
1. 같이 다녀야 하는 데이터를 FIFO 두 개로 보냈을 때
구조는 단순했습니다. 쓰기 도메인에서 매번 데이터와 그 데이터에 딸린 부가 정보가 함께 만들어지고, 읽기 도메인에서는 둘을 짝지어 써야 했습니다. 두 데이터는 성격이 달라서 처음에는 각자 다른 FIFO에 넣었습니다. 쓰기 쪽은 같은 사이클에 두 FIFO에 쓰고, 읽기 쪽은 두 FIFO에서 하나씩 꺼내 짝을 맞추는 방식이었습니다.
얼핏 보면 문제가 없어 보입니다. 같은 사이클에 썼으니 같은 사이클에 꺼낼 수 있을 것 같기 때문입니다. 하지만 이 기대는 두 FIFO의 상태 판단이 항상 같은 사이클에 바뀐다는 가정 위에 서 있습니다. async FIFO에서는 이 가정이 보장되지 않습니다.
2. 왜 어긋나나: 포인터 동기화는 FIFO마다 따로 일어난다
2.1. empty 판단은 동기화된 포인터로 한다
Asynchronous FIFO 설계하기에서 정리했듯이, async FIFO의 읽기 쪽은 쓰기 포인터를 Gray code로 바꿔 동기화기 두어 단을 거쳐 받아 옵니다. 읽기 쪽이 “데이터가 있다”고 판단하는 시점은 실제 쓰기 시점이 아니라 동기화된 쓰기 포인터가 도착한 시점.
이 동기화 지연은 고정된 값이 아닙니다. 쓰기 포인터가 바뀐 순간이 읽기 클록 엣지와 얼마나 가까운지에 따라, 동기화기 첫 단이 새 값을 이번 엣지에 잡을 수도 있고 다음 엣지에 잡을 수도 있습니다. 그래서 지연은 한 사이클 정도 흔들립니다. FIFO 하나만 놓고 보면 이 흔들림은 문제가 되지 않습니다. 조금 늦게 보일 뿐 데이터는 안전하게 넘어가기 때문입니다.
2.2. FIFO가 두 개면 흔들림도 두 개다
FIFO가 두 개면 동기화기도 두 벌이고, 각자 독립적으로 흔들립니다. 같은 사이클에 두 FIFO에 썼더라도 한쪽은 이번 사이클에 데이터가 보이고, 다른 쪽은 한 사이클 뒤에 보일 수 있습니다.
읽기 쪽이 이 한 사이클을 제대로 다루지 못하면 짝이 어긋납니다. 예를 들어 한쪽 FIFO의 상태만 보고 둘을 같이 꺼내면, 다른 쪽은 아직 비어 있는데 꺼내게 됩니다. 두 FIFO의 상태를 따로 보고 각자 꺼내면, 한쪽이 한 사이클 먼저 나가서 짝이 한 칸 밀립니다. 쓰기가 쉬지 않고 들어오고 읽기 쪽 제어가 한 박자 늦게 반응하는 구조라면, 한 번 생긴 차이가 회복되지 않고 계속 남거나 쌓일 수도 있습니다.
💡 Pro Tip: async FIFO 하나가 안전하다고 해서 여러 개를 묶은 구조까지 안전한 것은 아닙니다. 각 FIFO는 데이터를 안전하게 넘겨 줄 뿐, 다른 FIFO와 같은 사이클에 보이도록 맞춰 주지는 않습니다.
3. 왜 우리 시뮬레이션에서는 안 보였나
3.1. RTL 시뮬레이션의 동기화기는 늘 같은 시간에 값을 잡는다
2장에서 말한 “한 사이클 흔들림”은 실제 실리콘에서 메타스테이블 상태가 어느 쪽으로 풀리느냐에 따라 생깁니다. 그런데 보통의 RTL 시뮬레이션에서 동기화기는 그냥 플립플롭 두 단입니다. 입력이 바뀌면 다음 엣지에 정확히 그 값을 잡고, 지연은 매번 똑같습니다. 메타스테이블 상태라는 개념 자체가 없습니다.
그래서 두 FIFO에 같은 사이클에 쓰면, 시뮬레이션에서는 두 동기화기가 늘 같은 사이클에 새 포인터를 넘겨줍니다. 두 FIFO의 empty도 늘 같은 사이클에 풀립니다. 설계에 숨어 있던 가정이 시뮬레이션 환경 덕분에 우연히 항상 맞았던 것입니다. 저희 쪽 검증 환경이 잘못됐다기보다, 동기화기를 이상적으로 다루는 RTL 시뮬레이션의 기본 동작이 이 종류의 버그를 가리는 구조였습니다.
3.2. 외부 검증팀은 미스샘플을 주입해서 돌렸다
외부 검증팀의 환경은 한 가지가 달랐습니다. 이 FIFO들은 상용 듀얼 클록 FIFO IP로 만들어져 있었는데, 그 IP의 동기화기 시뮬레이션 모델은 동기화기 미스샘플(메타스테이블) 모델링 옵션을 제공합니다. 동기화 지연이 매번 같지 않은 실제 하드웨어 동작을 시뮬레이션에서 흉내 내는 옵션입니다. 외부 검증팀은 이 옵션을 켜고, 랜덤 시드를 정해 시뮬레이션을 돌렸습니다.
동기화기 미스샘플(메타스테이블) 모델링 옵션을 켜면, 입력이 바뀔 때마다 비트별로 무작위로 골라 첫 단 플롭이 새 값 대신 이전 값을 잡게 합니다. 그래서 같은 변화라도 어떤 비트는 바로, 어떤 비트는 한 사이클 늦게 도착합니다. 즉 동기화 지연이 매번 같지 않은 실제 하드웨어 동작을 흉내 내며, 늦어지는 쪽으로만 흔들릴 뿐 일찍 도착하지는 않습니다.
이 옵션을 켜자 두 FIFO의 동기화 지연이 서로 한 사이클씩 달라지는 순간이 생겼습니다. 2장의 그림에서 본 그 한 사이클이 시뮬레이션에서도 실제로 나타난 것입니다. 그 순간부터 두 데이터의 짝이 어긋났습니다.
3.3. 파형에서 확인한 것
파형에서 두 FIFO의 empty 신호를 나란히 띄워 보니, 같은 사이클에 쓴 데이터인데도 empty가 풀리는 사이클이 서로 다른 구간이 있었고, 불일치가 시작되는 지점이 정확히 그 구간과 겹쳤습니다. 각 FIFO의 동작은 사양대로였습니다. 틀린 것은 “두 FIFO는 같이 움직인다”는 설계의 가정이었습니다.
💡 Pro Tip: 증상이 시드에 따라 나타났다 사라진다면, 데이터 경로보다 클록 경계에서 “같은 사이클에 도착한다”고 가정한 곳을 먼저 의심해 보세요.
4. 핵심 교훈: 구조적 CDC 검사만으로는 다 잡히지 않는다
이 일에서 가장 크게 배운 점은 CDC 검증을 두 층으로 나눠 봐야 한다는 것이었습니다.
- 구조적 CDC 검사: CDC 툴이 “이 신호는 동기화기를 거쳤는가”, “Gray code 포인터인가”를 봅니다. 두 FIFO 각각은 교과서대로 만들어져 있었으므로 이 검사로는 문제가 보이지 않았습니다.
- 동기화 지연 차이에 의존하는 기능 문제: 각각 따로 동기화된 신호들이 “같은 사이클에 도착할 것”이라고 가정한 로직입니다. 흔히 reconvergence(재수렴) 문제라고 부르며, 구조 검사만으로는 놓치기 쉽고 시뮬레이션에서 지연이 흔들려야 드러납니다.
그래서 클록 경계를 넘는 동기화기가 여러 개 있고 그 결과를 다시 합쳐 쓰는 설계라면, 메타스테이블 효과를 흉내 내는 시뮬레이션을 회귀 테스트에 넣는 것이 좋습니다. 사용하는 동기화기 IP나 라이브러리가 미스샘플(메타스테이블) 모델링 옵션을 제공하면 그것을 켜고, 그렇지 않다면 직접 만든 동기화기에 지연을 랜덤하게 흔드는 시뮬레이션 전용 모델을 넣는 방법도 있습니다. 그리고 시드 하나로 통과했다고 끝내지 말고, 시드를 여러 개 바꿔 가며 돌려야 합니다.
💡 Pro Tip: 미스샘플 주입은 “버그를 만드는” 옵션이 아니라, 실리콘에서 언젠가 일어날 일을 시뮬레이션에 미리 보여 주는 옵션입니다. 이 옵션을 켜고 깨지는 설계는 실리콘에서도 깨질 수 있습니다.
5. 넓은 FIFO 하나로 합친 판단 기준
5.1. 해결 방법 후보
떠올린 방법은 세 가지였습니다.
- 읽기 쪽에서 둘 다 준비됐을 때만 같이 꺼내기: 동작은 하지만, 두 FIFO의 깊이와 full 판단까지 맞물려야 해서 제어가 복잡해집니다.
- 두 FIFO가 포인터 한 쌍을 같이 쓰도록 바꾸기: 사실상 FIFO 하나를 둘로 나눠 놓은 것과 같습니다.
- 데이터와 부가 정보를 한 엔트리로 묶어 넓은 FIFO 하나로 보내기: 상태 판단이 하나뿐이라 짝이 어긋날 여지가 구조적으로 없어집니다.
저는 세 번째를 골랐습니다. 첫 번째 방법은 문제를 제어 로직으로 덮는 것이고, 세 번째 방법은 문제가 생길 구조를 없애는 것이기 때문입니다.
5.2. 합쳐도 되는지 확인한 조건
합치기 전에 아래 조건을 하나씩 확인했습니다.
- 같은 클록 쌍인가: 두 FIFO의 쓰기 클록이 같고, 읽기 클록도 같아야 합니다. 어느 한쪽이라도 다르면 하나로 합칠 수 없습니다.
- 항상 같이 쓰고 같이 읽는가: 한쪽만 쓰는 사이클이 있다면, 묶은 엔트리의 빈 칸을 어떻게 채울지부터 정해야 합니다.
- 깊이 요구가 같은가: 합친 FIFO의 깊이는 두 요구 중 큰 쪽을 따라야 합니다. 한쪽이 훨씬 얕아도 되는 경우라면 그만큼 메모리가 낭비됩니다.
- 면적과 메모리 구성: 좁은 메모리 두 개가 넓은 메모리 하나로 바뀌고, 포인터와 동기화기는 한 벌로 줄어듭니다. 사용하는 메모리 구성에 따라 면적이 늘 수도 줄 수도 있으니 비교해 봐야 합니다.
제 경우에는 두 FIFO의 클록 쌍이 같았고, 항상 같은 사이클에 함께 쓰고 함께 읽었으며, 깊이 요구도 같았습니다. 합치는 쪽이 손해 볼 것이 거의 없는 조건이었습니다.
6. 합친 뒤 달라진 점
6.1. 증상이 구조적으로 사라졌다
FIFO가 하나가 되자 empty 판단도 하나가 되었습니다. 데이터와 부가 정보는 같은 엔트리에 들어가 같은 사이클에 나오므로, 짝이 어긋날 경로 자체가 없어졌습니다. 미스샘플이 생겨도 두 데이터가 함께 늦어질 뿐, 한쪽만 먼저 나오는 일은 없습니다.
6.2. CDC 결과가 단순해졌다
포인터 동기화 경로가 두 벌에서 한 벌로 줄면서, CDC 툴이 보고하는 항목도 함께 줄었습니다. 리뷰할 항목이 적어지니 남은 항목 하나하나의 근거를 정리하기도 쉬워졌습니다. wrapper 글에서 다룬 waive 기록도 그만큼 짧아졌습니다.
6.3. 이름이 바뀌면 제약도 같이 바꿔야 한다
놓치기 쉬운 부분도 있었습니다. FIFO 인스턴스 이름을 기준으로 걸어 둔 타이밍 제약이나 CDC 설정은, 구조를 바꾼 뒤 옛 이름을 그대로 가리키고 있으면 경고 없이 적용되지 않을 수 있습니다. 구조 변경 뒤에는 제약 파일과 CDC 설정이 새 이름을 가리키는지 확인하고, 제약이 실제로 적용됐는지 리포트로 확인했습니다.
💡 Pro Tip: 구조를 바꾸는 RTL 수정은 RTL 파일에서 끝나지 않습니다. 그 인스턴스 이름을 참조하는 제약, CDC 설정, 문서까지 함께 바꿔야 수정이 끝납니다.
7. 체크리스트: 클록 경계를 함께 건너는 데이터
- 같이 써야 하는 데이터가 서로 다른 async FIFO로 넘어가고 있지 않은가?
- 읽기 쪽이 여러 FIFO의 상태를 따로 판단하고 있지 않은가?
- 두 FIFO의 쓰기 클록과 읽기 클록이 같은가? 같다면 하나로 합칠 수 있는가?
- 합친다면 깊이는 큰 쪽 요구를 만족하는가? 메모리 면적은 비교해 봤는가?
- 동기화기 IP가 제공하는 미스샘플(메타스테이블) 모델링 옵션을 켜고 시뮬레이션해 봤는가?
- 미스샘플 모델링의 랜덤 시드를 여러 개 바꿔 가며 돌렸는가?
- 구조적 CDC 검사 통과를 “동기화 지연 차이에도 안전하다”는 뜻으로 받아들이고 있지 않은가?
- 여러 클록 비율과 위상 조건에서도 돌려 봤는가?
- 구조를 바꾼 뒤 타이밍 제약과 CDC 설정이 새 인스턴스 이름을 가리키는가?
- CDC 리포트와 waive 기록을 새 구조 기준으로 다시 정리했는가?
8. 마무리: FIFO 하나보다 FIFO 사이의 관계
async FIFO를 공부할 때는 Gray code와 동기화기, full과 empty 판단처럼 FIFO 하나의 정확성에 집중하게 됩니다. 실제 설계에서 문제가 된 것은 FIFO 하나가 아니라, 여러 FIFO가 같이 움직일 거라는 가정이었습니다. 그리고 그 가정은 동기화기를 이상적으로 다루는 시뮬레이션 안에서는 늘 맞아 보였습니다.
이번 일로 클록 경계를 볼 때 “이 신호가 안전하게 넘어가는가”에 더해 “같이 넘어가야 하는 것들이 같이 도착하는가”, 그리고 “내 시뮬레이션은 그 차이를 보여 줄 수 있는가”를 묻게 되었습니다. 다음에는 검증을 마무리하면서 coverage를 어디까지 채우고, 남은 항목을 어떻게 판단했는지 정리해 보겠습니다.
References: wiki