시뮬레이터는 정직했는데, 나는 어디부터 봐야 할지 몰랐다

오픈소스 RTL 시뮬레이터 만들기 · 6편 RTL을 짜다 보면 시뮬레이션에서 값이 이상하게 흘러가는 걸 본다. 파형 어딘가에 X가 번져 있거나, 숫자가 한 사이클 밀려 있거나, 있어야 할 신호가 영영 안 올라온다. 그때 시뮬레이터는 아무 말도 하지 않는다. 에러도 없고 경고도…

오픈소스 RTL 시뮬레이터 만들기 · 6편

RTL을 짜다 보면 시뮬레이션에서 값이 이상하게 흘러가는 걸 본다. 파형 어딘가에 X가 번져 있거나, 숫자가 한 사이클 밀려 있거나, 있어야 할 신호가 영영 안 올라온다.

그때 시뮬레이터는 아무 말도 하지 않는다. 에러도 없고 경고도 없다. 끝까지 돌고 정상 종료한다.

이건 시뮬레이터가 고장난 게 아니다. 내가 짠 회로가 틀렸고, 시뮬레이터는 그 틀린 회로를 정확하게 흉내 낸 것이다. RTL 시뮬레이터는 논리 오류를 보고하지 않는다. 그건 애초에 그 도구의 일이 아니다.

A single old analog measurement gauge on a dark workbench, its needle resting exactly at zero, lit by one narrow beam of

그런데도 시간은 들었다. 오래 걸린 건 고치는 일이 아니었다. 어디부터 봐야 할지 찾는 일이었다.

이번 편은 그 경험이 도구를 만드는 쪽에서 규칙 한 줄로 굳은 이야기다.

정직한 도구가 사주는 것

디버깅의 첫 단계는 고치는 게 아니라 용의자 목록을 만드는 것이다. RTL 시뮬레이션에서 값이 틀렸으면 용의자는 대략 셋이다. 내 설계, 내 테스트벤치, 그리고 도구 자체.

도구가 정직하면 세 번째가 목록에서 빠진다. 그럼 남은 둘에 집중할 수 있다. 별거 아닌 것 같지만 이게 전부다.

반대로 도구가 조용히 틀리면 목록이 줄지 않는다. 더 나쁜 건 순서다. 내 코드를 몇 시간 의심한 뒤에야 도구를 의심하게 된다. 그리고 그 몇 시간은 되돌아오지 않는다. 도구를 먼저 의심하는 사람은 없다. 그래야 할 이유도 없다 — 도구는 나보다 훨씬 많이 검증됐을 테니까.

그래서 시뮬레이터를 짜기 시작하면서 제일 먼저 정한 규칙이 이거다.

조용히 틀리느니 시끄럽게 멈춘다

저장소의 docs/ENGINEERING_RULES.md는 이 규칙으로 시작한다. 원문은 세 문장이다.

silent-wrong(틀린 출력·에러 없음) = 최악 > honest-loud(명시적 거부) = 항상 안전. 검증할 수 없으면 구현하지 말 것 — 오라클도 전제조건도 없으면 loud를 유지한다.

핵심은 기능이 없는 것보다 나쁜 상태가 있다는 것이다. 없으면 사용자는 다른 방법을 찾는다. 있는 척하면서 틀린 답을 주면 사용자는 그 답을 믿는다.

graphviz diagram

위에서 아래로 내려가는 첫 번째 화살표는 언제든 허용된다. 못 만들겠으면 거절하면 된다. 두 번째 화살표가 이 프로젝트에서 금지된 유일한 거래다.

그리고 실무에서 자주 나오는 함정이 하나 있다. 경고를 찍고 아무 일도 안 하는 것은 조용히 틀린 것의 사촌이다. 경고는 로그 수백 줄 사이에 묻히고, 결과는 틀린 채로 남고, 종료 코드는 0이다. 사람은 그걸 “통과”로 읽는다.

파일 이름이 변수였더니 아무 일도 일어나지 않았다

말로 하면 당연한 원칙인데, 이 프로젝트에서 실제로 나온 사고를 하나 보자.

RISC-V 코어를 시뮬레이션하는 테스트벤치는 거의 다 이렇게 생겼다. 메모리에 펌웨어를 올리고 코어를 놓아준다.

SystemVerilog

reg [1023:0] firmware_file;   // 파일 이름을 담는 흔한 관용구

initial begin
  firmware_file = "firmware.hex";
  $readmemh(firmware_file, mem);
  ...

vitamin은 이 자리에서 문자열 리터럴만 받았다. 변수가 오면 함수는 그냥 return했다. 파일을 열지도 않고, 진단도 내지 않고, 종료 코드는 0.

결과가 어떻게 되냐면, 메모리는 안 채워진 채로 남는다. 코어는 X를 인출한다. 파형 파일은 정상적으로 나온다. 종료 코드는 0이다. “펌웨어를 못 읽었다”고 말해 주는 곳이 어디에도 없다.

표준(IEEE 1800 §21.4)이 요구하는 건 문자열 이지 리터럴이 아니다. 리터럴로 좁힌 건 순전히 우리 사정이었고, 그 사정을 사용자에게 알리지 않았다.

지금은 이렇게 동작한다. 위 관용구를 그대로 넣고 aa bb cc dd가 든 파일을 읽혔다.

text

$ vita mem_load.sv
mem[0] = aa
mem[1] = bb
mem[2] = cc
mem[3] = dd
simulation ended (Finish) at time 1
errors=0 warnings=1 notes=0
$ echo $?
0

덧붙이자면 이 결함은 찾으러 가서 찾은 게 아니다. 전혀 다른 기능을 만들다가 그 밑에서 나왔고, 만든 기능은 결국 되돌렸는데 밑에서 나온 이것만 남았다. 그 이야기는 따로 한 편이 필요하다.

소리는 냈는데 멈추지는 않는다

그럼 파일이 아예 없으면 어떻게 될까. 방금과 같은 코드에 없는 파일 이름을 줬다.

text

$ vita missing.sv
missing.sv:5:5: warning[VITA-W4023] W-RUN-READMEM: $readmem: unable to open
  'does_not_exist.hex' for reading [in top] [at time 0]
mem[0] = xx
simulation ended (Finish) at time 1
errors=0 warnings=1 notes=0
$ echo $?
0

경고가 나온다. 파일 이름, 줄과 칸, 인스턴스 경로, 시각까지 붙는다. 그런데 종료 코드는 여전히 0이고 시뮬레이션은 계속 돌았다.

봐준 게 아니다. 표준이 이 상황을 에러로 규정하지 않았고, 다른 시뮬레이터와 종료 코드가 어긋나면 CI 스크립트가 도구를 바꿀 때마다 깨지기 때문이다. 이 도구는 진단 코드마다 자기 결정 근거를 문서로 들고 있어서, 물어보면 답한다.

text

$ vita explain VITA-W4023
### VITA-W4023 · `W-RUN-READMEM` (Warning)

$readmemb/$readmemh가 파일을 열지 못했거나, 주소 지시어 없는 파일의 토큰 수가
요청 범위와 안 맞음(부족/초과). 메모리는 부분 적재되고 실행은 계속된다
(iverilog는 missing file을 "ERROR" 텍스트로 찍지만 exit 0 — vitamin은 exit
패리티를 유지하며 Warning으로 분류).

여기가 correct-or-loud의 회색지대다. loud는 “멈춘다”가 아니라 “말한다”이다. 말했는데 아무도 안 읽으면 결과적으로는 조용한 것과 같아진다. 그래서 진단마다 -Werror=VITA-W4023 같은 게이트를 걸 수 있게 해뒀다. 어떤 경고를 에러로 승격할지는 그 프로젝트가 정할 일이지 도구가 정할 일이 아니다.

규칙에 붙은 첫 번째 단서가 이것이다. loud도 읽혀야 loud다.

같은 값인데 철자가 다르면 답이 달랐다

더 고약한 부류가 있다. 배열의 범위 밖 인덱스를 생각해 보자. 원소가 넷인 배열에 인덱스 0x1_0000_0002로 쓰면 명백히 범위 밖이고, 도구는 그걸 말해야 한다.

그런데 한동안 이랬다. 상수를 그대로 적으면 거절했는데, 똑같은 값~64'hFFFF_FFFE_FFFF_FFFD처럼 뒤집어 적으면 아무 말 없이 다른 원소에 썼다.

원인은 구현 쪽에 있었다. “이 인덱스가 컴파일 타임에 정해지는 값인가”라는 질문에 식의 생김새 목록으로 답하고 있었다. 목록에 있는 철자면 상수, 없으면 상수가 아님. 그런데 “상수가 아님”으로 떨어지는 순간 정적 범위 검사가 통째로 사라진다. 검사가 없으니 조용히 통과한다.

지금은 두 철자가 같은 답을 낸다.

text

$ vita spell.sv        # m[64'h1_0000_0002] = 8'hAA;
spell.sv:5:5: error[VITA-E4002] E-RUN-RANGE: array word index of `top.m`
  (out of range; read X / write ignored) [in top] [at time 0]
errors=1 warnings=0 notes=0     → exit 1

$ vita spell2.sv       # m[~64'hFFFF_FFFE_FFFF_FFFD] = 8'hAA;  (같은 값)
spell2.sv:5:5: error[VITA-E4002] E-RUN-RANGE: array word index of `top.m`
  (out of range; read X / write ignored) [in top] [at time 0]
errors=1 warnings=0 notes=0     → exit 1

여기서 나온 규칙이 저장소 문서에 한 줄로 들어가 있다. 철자를 세는 분류기는 같은 설계 안에서 자기와 모순된다. 성질을 묻는 술어는 생김새를 열거하지 말고 식을 끝까지 걷어야 하고, 모르는 변종을 만나면 안전한 쪽으로 닫혀야 한다.

같은 부류의 더 나쁜 형태도 있었다. “아직 모른다”를 “아니다”로 접는 것이다. 어떤 참조는 처리 시점에 아직 해소되지 않아 자리표시자 상태인데, 거기에 대고 “이 신호는 부호가 있나?”를 물으면 없는 걸 읽고 1비트 무부호라고 답한다. 그건 틀린 답이 아니라 지어낸 답이다. 그리고 호출한 쪽은 참/거짓만 받으니 지어낸 줄 모른다. 결정에 쓰이는 질의는 참·거짓이 아니라 세 번째 상태(모름)를 가져야 한다.

통과율을 올리는 방법은 언제나 있다

거절을 줄이는 가장 쉬운 방법은 모르는 걸 대충 넘기는 것이다. 통과하는 설계 수는 즉시 오른다. 표에 찍히는 숫자가 좋아진다.

이 축에서는 타협이 거의 없었는데, 이유가 원칙 때문은 아니다. 어설프게 통과된 코드가 훨씬 뼈아프게 시간을 갉아먹은 경험이 있어서다. 거절당하면 5분 안에 안다. 어설프게 통과되면 하루 뒤에 안다.

실제로 그것 때문에 다 만든 기능을 되돌린 적이 있다. 다른 모듈의 메모리를 계층 경로로 지목해 파일을 올리는 기능이었고, 40가지가 넘는 형태가 두 개의 다른 시뮬레이터와 일치했다. 실제 SoC 하나가 끝까지 돌기도 했다.

그런데 그 기능을 켜니 자기 동기가 됐던 바로 그 관용구에서 조용히 틀린 답이 나왔다. 부모와 자식 중 누가 먼저 초기화되느냐가 달라서 자식이 자기 메모리를 덮어썼다. aa bb cc dd여야 하는 자리에 01 02 03 04가 나왔고, 종료 코드는 0이었다.

거절 하나를 조용한 오답과 맞바꾸는 거래였다. 그래서 되돌렸다. 되돌리면서 이유를 이렇게 적었다 — “두 시뮬레이터 모두 자식을 먼저 돌리는데 우리만 부모를 먼저 돌린다.”

그리고 그 문장이 틀렸다.

되돌린 이유도 측정 대상이다

다음 날 같은 설계를 두 시뮬레이터에 다시 넣어 봤다. Icarus Verilog는 aa bb cc dd를 찍었다. 그런데 Verilator는 01 02 03 04을 찍었다. 우리 답과 같았다.

표준(IEEE 1800 §4.7)은 여러 initial 블록 사이의 실행 순서를 명시적으로 비결정으로 둔다. 두 도구가 그 자유를 반대 방향으로 쓴 것이고, 우리는 그중 한쪽과 같았다. 우리가 틀린 자리가 아니라 오라클이 갈리는 자리였다. 되돌린 이유가 사라졌으니 기능은 다시 들어갔다.

여기서 두 가지가 남았다.

하나는 “두 오라클이 일치한다”는 두 부분으로 된 주장이고, 하중을 받는 건 뒷부분이라는 것이다. 첫 번째 도구는 실제로 돌렸다. 두 번째 도구는 “당연히 같겠지”였다. 그래서 규칙이 한 줄 늘었다 — 오라클을 인용할 때는 결론이 아니라 그 도구가 실제로 뱉은 출력 원문을 적는다.

다른 하나가 더 무섭다. 되돌린 지 하루 만에 그 틀린 이유가 네 군데에 복제돼 있었다. 할 일 목록의 한 줄, 결함 표에 새로 생긴 행, 다음 작업 브리핑, 그리고 테스트 코드의 주석. 틀린 이유는 다음 날 아침이면 자기를 뒷받침하는 사본을 셋 거느린다. 그때부터는 그게 근거처럼 보인다.

되돌린 것 자체는 옳은 반사였다고 생각한다. 조용한 오답을 받느니 기능을 빼는 건 이 서열이 시키는 그대로다. 여기서 규칙이 또 한 줄 늘었다 — 부분 지원이 “이 문맥은 고치고 저 문맥은 깨는” 형태로만 가능하면, 정답은 더 나은 절반이 아니라 명시적 거절과 연기다. 빼는 것도 결과이고, 그 판단은 둘 다 지어서 재본 뒤에만 내릴 수 있다.

다만 빼는 결정도 재고 나서 해야 한다. 나는 그걸 재지 않고 뺐다.

이 원칙의 값은 사용자가 아니라 내가 치른다

correct-or-loud를 사용자 입장에서 보면 “거절이 많은 도구”다. 그래서 이게 사용자에게 부담을 지우는 규칙처럼 읽히기 쉬운데, 실제로 청구서를 받는 건 만드는 쪽이다.

조용히 틀린 게 하나 나올 때마다 할 일이 하나 늘어난다. 재현을 만들고, 다른 시뮬레이터 두 대에 같은 걸 물어보고, 원인을 찾고, 고치고, 같은 성질을 가진 자리가 몇 군데 더 있는지 세고, 되돌려도 테스트가 빨개지는지 확인한다. 그게 한 덩어리다. 방금 이야기한 계층 메모리 건은 그 덩어리를 두 번 지불했다 — 지었다가, 되돌렸다가, 되돌린 이유를 재고, 다시 넣었다.

솔직히 이 프로젝트에서 제일 힘들었던 건 특정 버그가 아니었다. 고칠 게 계속 나오면서 일정이 밀리는 부담이었다. 버그 하나하나는 잡을 만한데, 잡을수록 목록이 안 줄어드는 구간이 있다.

지금 숫자는 이렇다. 테스트 7,203개, 진단 코드 68종, 산출물 포맷 버전 31, 남이 쓴 RTL 워크로드 10개 중 10개 통과. 이 숫자들은 자랑이라기보다 저 규칙이 청구한 금액에 가깝다.

정작 이걸 읽는 건 사람이 아닐지도 모른다

진단을 이렇게까지 다듬는 이유가 뭐냐면, 처음에는 당연히 사람이 읽을 거라고 생각했다. 그런데 이 시리즈에서 이미 두 번 나온 이야기가 있다. 나는 이 도구로 파형을 안 본다. AI가 남긴 리포트를 읽는다.

냉정하게 말해서, 사람이 vita로 설계를 할까? 상용 EDA 도구가 더 빠르고, 호환성도 좋고, 기능도 훨씬 많다. 그걸 두고 이걸 쓸 이유가 별로 없다.

그럼 왜 만드느냐 하면, 시작은 단순했다. 맥에서 돌아가는 EDA 도구가 없었다. 윈도우도 EDA 쪽 지원은 빈약하다. 쓰다 보니 목적이 하나 더 붙었는데, 이쪽이 지금은 더 크다.

RTL 설계를 하는 곳은 대개 인터넷이 끊겨 있다. 상용 LLM을 붙일 수가 없다. 그렇다면 그 안에서 도는 에이전트에게 쥐여 줄 도구가 필요하고, 그 도구는 사람이 아니라 기계가 읽을 수 있어야 한다.

수신자가 바뀌면 이 규칙의 값이 달라진다. 사람은 이상한 숫자를 보면 “어?” 하고 멈춘다. 경험이 감지기 역할을 한다. 에이전트는 조용히 틀린 값을 그대로 리포트에 싣는다. 의심할 자리가 표시돼 있지 않으면 의심하지 않는다. 그래서 진단은 위치를 갖고, 고유 코드를 갖고, 자기 문서를 알고 있어야 한다. 아까 vita explain이 답한 게 그거다.

느리다는 것도 조용히 넘어가지 않기로 했다

같은 규칙을 문서에도 적용해야 한다는 걸 뒤늦게 깨달았다. 그동안 README는 결정성과 correct-or-loud를 주장했는데, 이걸 돌리는 데 얼마가 드는지는 한 번도 말하지 않았다. 읽는 사람이 자기 작업 규모를 가늠할 수가 없었다.

지금은 평문으로 적어 뒀다. 컴파일 방식의 2-state 시뮬레이터에 비해 한두 자릿수 느리다. 숫자도 같이 적었다.

text

Keccak-f[1600], 2,000 permutations, macOS arm64, release 빌드, 교대 측정·첫 라운드 폐기

Verilator 5.050 (컴파일·2-state)         7.0 µs      1×
vitamin — 서브루틴 호출 없는 설계         295 µs     42× 느림
vitamin — 함수/태스크 호출이 있는 설계  2,035 µs    291× 느림
Icarus Verilog 13                       4,470 µs    639× 느림

인정하기가 쉽지는 않았다. 다만 이 격차는 튜닝을 덜 한 결과가 아니라 4-state와 이벤트 순서를 안 버린 대가다. Verilator는 그 둘을 포기해서 속도를 산다. 구조에서 나온 차이라면 감추는 것보다 이유와 함께 적는 쪽이 낫다고 봤다.

같은 문서에 쓰임새 권고까지 붙였다. 회귀 테스트를 통째로 돌리는 용도가 아니라 대표 케이스를 빠르게 확인하는 용도로 쓰라고, 긴 소크 테스트나 풀칩은 Verilator나 상용 도구로 가라고 적었다. 도구를 쓰지 말라고 말하는 문단을 도구 문서에 넣는 건 이상한 기분이었다.

덧붙이면 정직하게 적는다는 게 나쁜 숫자만 적는다는 뜻은 아니다. 같은 계열인 Icarus Verilog 대비로는 앞선다 — 서드파티 설계 7종 기하평균 1.93배, 시간을 잰 9행 전체로는 1.74배. 이것도 같은 표에 있다.

오라클이 없는 곳에서 이 규칙은 무엇을 보장하나

이 규칙의 한계를 적어 두는 게 맞겠다. loud는 관측이 있어야 발화한다. 관측이 없는 자리에서 위반 0건은 “위반이 없다”가 아니라 “관측한 적이 없다”이다.

비교 대상이 없는 영역이 실제로 있다. 어서션, 클래스와 객체지향, 제약 랜덤, 클러킹 블록 같은 것들이다. Icarus Verilog 13은 클러킹 블록을 파싱조차 못 한다. 물어볼 데가 없다.

그런 자리에서는 표준 문서를 직접 읽고 손으로 기대값을 박는다. 그리고 적대적으로 읽어 주는 리뷰가 붙는다. 여기서 정직하게 적자면 그 리뷰어도 나다. 정확히는, AI에게 역할 구분을 주려고 만들어 둔 자리다. 다른 시뮬레이터와 대조하는 쪽과 표준 문서로 따지는 쪽을 일부러 갈라 놓았다.

이게 완전한 보장이 아니라는 것도 안다. 실제로 어떤 칸에서 다른 시뮬레이터와 갈렸을 때 “표준에 그 단계가 없으니 우리가 맞다”고 기록하고 테스트까지 심은 적이 있다. 두 번째 시뮬레이터를 데려오니 그쪽도 첫 번째와 같았다. 우리는 앞선 게 아니라 혼자였다.

그래서 규칙 옆에 규칙이 하나 더 붙었다. “우리가 앞서 있다”를 선언하기 전에 오라클을 하나 더 대라. 그 이야기는 이 시리즈에서 따로 다룰 생각이다.

지워지는 건 용의자 목록의 한 줄뿐이다

맨 앞의 이야기로 돌아가면, 시뮬레이터가 아무리 정직해도 내 회로가 틀린 건 알려주지 않는다. 그건 여전히 내 몫이다.

정직함이 사주는 건 용의자 목록에서 한 줄을 지우는 것뿐이다. 그것도 조용히 지워진다 — 아무 일도 안 일어나는 형태로 지워지기 때문에, 잘 되고 있을 때는 그 규칙이 뭘 해주고 있는지 보이지도 않는다. 규칙이 일하고 있다는 걸 알게 되는 건 그게 없을 때뿐이다.

그리고 이 도구를 만들면서 계속 드는 생각이 하나 있다. RTL 설계는 오래 했지만 시뮬레이터를 만드는 지식은 없었다. 파서도, 엘라보레이션도, 이벤트 큐도, 런타임도 내 분야가 아니었다.

시대가 좋아졌다. 이런 지식이 부족한 나도 시뮬레이터를 구현할 수 있다니 말이다.

다음 편은 이 정직함을 무엇으로 검사하느냐에 대한 이야기다. 우리가 만든 테스트는 우리가 의심하는 것만 찾는다. 그래서 남이 쓴 RTL을 가져다 값을 매기기 시작했고, 열 개 중 열 개를 통과하게 됐다. 그런데 만점을 받고 나서 그 벤치마크가 사실은 우리가 재려던 걸 재고 있지 않았다는 걸 알게 됐다.


시리즈 — ← 5편 · 이 글을 쓰다가, 내 문서가 틀린 걸 찾았다 · 시리즈 목차

코드 — github.com/tjddnr0912/vitamin-rtl-simulator (MIT / Apache-2.0). 본문의 서열은 저장소 docs/ENGINEERING_RULES.md의 정확도 서열 절, 성능 표는 README.md의 Performance 절에 있다. 본문의 실행 출력은 모두 0.2.0 릴리스 빌드에서 직접 돌린 것이다.

개발 중인 개인 프로젝트의 기록입니다. 상용 검증 도구를 대체하지 않으며, 본문의 구현 범위는 집필 시점(2026-09-08 · 0.2.0 · 테스트 7,203 green) 기준으로 이후 변경됩니다. 이 프로젝트는 AI와 협업해 개발하고 있습니다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다