내가 만드는 RTL 시뮬레이터 vitamin에서 가장 먼저 정한 규칙은, 맞는 답을 낼 수 없으면 조용히 틀린 답을 내지 말고 에러를 내며 멈추라는 것이다. 이 규칙 때문에 다 만든 기능을 되돌린 적도 있다.

RTL을 짜다 보면 파형 어딘가에 X가 번져 있거나, 숫자가 한 사이클 밀려 있거나, 있어야 할 신호가 끝내 안 올라오는 걸 보게 된다. 그래도 시뮬레이터는 에러도 경고도 없이 끝까지 돌고 정상 종료한다. 내가 짠 회로가 틀렸고 시뮬레이터는 그 회로를 정확하게 흉내 냈을 뿐이니, 시뮬레이터가 알려 줄 일은 아니다. 그래도 이런 버그는 고치는 것보다 어디부터 봐야 할지 찾는 데 시간이 더 들었다.
용의자 목록에서 도구를 지우려면
값이 틀렸을 때 용의자는 대략 셋이다. 내 설계, 내 테스트벤치, 그리고 도구 자체다. 도구가 틀릴 때마다 반드시 에러를 낸다면 마지막 것은 지울 수 있다. 도구가 조용히 틀리면 셋 다 남는데, 도구는 나보다 훨씬 많이 검증됐을 테니 내 코드를 몇 시간 의심한 뒤에야 도구를 보게 된다.
조용히 틀리느니 시끄럽게 멈춘다
저장소의 docs/ENGINEERING_RULES.md는 이 규칙(correct-or-loud)으로 시작한다. 에러 없이 틀린 출력을 내는 것(silent-wrong)이 가장 나쁘고, 못 한다고 분명히 거절하는 것(honest-loud)은 언제나 안전하다는 내용이다. 그래서 맞는지 확인할 수 없는 기능은 만들지 않는다. 비교 기준으로 쓰는 다른 시뮬레이터(오라클)도, 전제조건도 없으면 거절하는 상태로 둔다. 기능이 없다고 하면 사용자는 다른 방법을 찾지만, 틀린 답은 그대로 믿기 때문이다.

그림에서 정확한 답을 아직 못 만들 때 거절로 내려가는 첫 번째 화살표는 언제든 괜찮다. 거절에서 조용히 틀린 답으로 한 번 더 내려가는 두 번째 화살표만 이 프로젝트에서 금지돼 있다.
경고만 찍고 아무 조치도 하지 않는 것도 조용히 틀리는 것과 비슷하다. 결과는 틀린 채로 남고 종료 코드가 0이면, 경고는 로그 수백 줄 사이에 묻혀서 통과한 것처럼 보인다.
펌웨어 파일 이름을 변수로 넘겼더니
실제로 있었던 일이다. RISC-V 코어를 돌리는 테스트벤치는 거의 다 이렇게 메모리에 펌웨어를 올린 뒤 코어를 돌린다.
reg [1023:0] firmware_file; // 파일 이름을 담는 흔한 관용구
initial begin
firmware_file = "firmware.hex";
$readmemh(firmware_file, mem);
...vitamin은 이 자리에서 문자열 리터럴만 받고 있었다. 변수가 오면 파일을 열지도, 에러나 경고 같은 진단 메시지를 내지도 않고 그냥 return했다. 메모리는 빈 채로 남고 코어는 X를 fetch하는데, 파형 파일은 멀쩡히 나오고 종료 코드는 0이다. 펌웨어를 못 읽었다고 말해 주는 곳이 어디에도 없었다.
표준(IEEE 1800 §21.4)에 따르면 이 자리에는 문자열 식이 올 수 있다. 리터럴만 받게 좁힌 건 내 사정이었는데, 그걸 사용자에게 알리지 않았다. 지금은 같은 코드로 aa bb cc dd가 든 파일을 제대로 읽는다.
$ 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이 결함은 전혀 다른 기능을 만들다가 찾았다.
경고는 했지만 멈추지는 않는 자리
파일이 아예 없으면 어떻게 될까. 같은 코드에 없는 파일 이름을 줘 봤다.
$ 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 스크립트가 깨지기 때문이다. 진단마다 고유 코드가 있고, 코드마다 이렇게 정한 이유를 적은 문서가 붙어 있어서 물어보면 답한다.
$ vita explain VITA-W4023
### VITA-W4023 · `W-RUN-READMEM` (Warning)
$readmemb/$readmemh가 파일을 열지 못했거나, 주소 지시어 없는 파일의 토큰 수가
요청 범위와 안 맞음(부족/초과). 메모리는 부분 적재되고 실행은 계속된다
(iverilog는 missing file을 "ERROR" 텍스트로 찍지만 exit 0 — vitamin은 exit
패리티를 유지하며 Warning으로 분류).여기는 correct-or-loud로도 딱 잘라지지 않는 자리다. 경고를 냈어도 아무도 안 읽으면 조용히 넘어간 것과 같아서, -Werror=VITA-W4023처럼 경고를 골라 에러로 올릴 수 있게 해 뒀다. 어떤 경고를 에러로 올릴지는 쓰는 프로젝트가 정한다.
같은 인덱스를 다르게 적었을 뿐인데
더 고약한 경우도 있었다. 원소가 넷인 배열에 인덱스 0x1_0000_0002로 쓰면 분명히 범위 밖이다. 그런데 한동안은 이 상수를 그대로 적으면 거절하고, 같은 값을 ~64’hFFFF_FFFE_FFFF_FFFD처럼 뒤집어 적으면 아무 말 없이 다른 원소에 썼다. 컴파일 타임 상수인지를 ‘이런 모양이면 상수’라는 목록으로 판단했기 때문이다. 목록에 없는 모양은 상수가 아닌 것으로 처리됐고, 그러면 컴파일 때 하는 범위 검사를 통째로 건너뛰었다. 지금은 두 표기가 같은 결과를 낸다.
$ 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비트 unsigned라고 답해 버린다. 참과 거짓만 받는 쪽은 그게 지어낸 답인 줄 모른다. 그래서 판단에 쓰는 질문에는 참과 거짓 말고 ‘모름’이라는 답이 하나 더 있어야 한다.
다 만든 기능을 되돌렸다가 다시 넣은 일
거절을 줄이는 가장 쉬운 방법은 모르는 걸 대충 넘기는 것이다. 그러면 통과하는 설계 수가 바로 오른다. 그래도 여기서 거의 타협하지 않은 건 어설프게 통과된 코드에 시간을 크게 뺏겨 봤기 때문이다. 거절은 5분 안에 알 수 있지만, 어설프게 통과된 문제는 하루 뒤에야 드러난다.
그 때문에 다 만든 기능을 되돌린 적이 있다. 다른 모듈의 메모리를 계층 경로로 지정해 파일을 올리는 기능이었다. 40가지가 넘는 형태에서 다른 시뮬레이터 두 개와 결과가 같았고, 실제 SoC 하나가 끝까지 돌기도 했다. 그런데 이 기능을 켜니 애초에 이 기능을 만들게 한 바로 그 관용구에서 조용히 틀린 답이 나왔다. 부모 모듈과 자식 모듈 중 어느 쪽이 먼저 초기화되느냐가 달라서 자식이 자기 메모리를 덮어썼고, aa bb cc dd가 나와야 할 자리에 01 02 03 04가 나왔다. 종료 코드는 0이었다.
거절 하나를 없애는 대신 조용한 오답을 하나 들이는 꼴이라 되돌렸다. 되돌리면서 이유를 “두 시뮬레이터 모두 자식을 먼저 돌리는데 우리만 부모를 먼저 돌린다”라고 적었는데, 이 문장이 틀렸다.
다음 날 같은 설계를 두 시뮬레이터에 다시 넣어 봤다. Icarus Verilog는 aa bb cc dd를 찍었는데 Verilator는 vitamin과 같은 01 02 03 04를 찍었다. 표준(IEEE 1800 §4.7)은 여러 initial block 사이의 실행 순서를 정하지 않는다. 두 도구가 서로 다른 순서를 골랐고 vitamin은 그중 한쪽과 같았다. 기준이 되는 두 시뮬레이터부터 답이 갈리니 되돌린 이유가 없어졌고, 기능은 다시 들어갔다.
틀린 사유는 하루 만에 네 군데로 퍼져 있었다
되돌린 이유에 ‘두 시뮬레이터 모두’라고 적었지만 실제로 돌려 본 건 하나였고, 두 번째는 당연히 같겠거니 했다. 그 뒤로 다른 시뮬레이터 결과를 근거로 들 때는 결론 말고 그 도구가 실제로 낸 출력을 붙인다는 규칙이 생겼다.
더 무서웠던 건 되돌린 지 하루 만에 그 틀린 이유가 네 군데에 옮겨 적혀 있었다는 점이다. 할 일 목록의 한 줄, 결함 표에 새로 생긴 행, 다음 작업 지시문, 테스트 코드의 주석이었다. 같은 말이 네 곳에 있으니 서로가 서로의 근거처럼 보였다.
되돌린 것 자체는 맞았다고 생각한다. 조용한 오답을 받느니 기능을 빼는 게 앞의 규칙대로다. 일부만 지원하면 어떤 코드는 고쳐지고 어떤 코드는 깨지는 기능이라면, 차라리 거절하고 미루는 게 맞다는 규칙도 이때 생겼다. 다만 빼기로 할 때도 넣은 쪽과 뺀 쪽을 둘 다 만들어 돌려 보고 정해야 했는데, 나는 확인하지 않고 뺐다.
거절이 많은 도구의 비용은 만드는 내가 치른다
사용자에게 vitamin은 거절이 많은 도구지만, 실제 비용은 만드는 내가 치른다. 조용히 틀린 게 하나 나오면 재현 코드를 만들고 다른 시뮬레이터 두 개로 돌려 본다. 원인을 찾아 고친 다음에는 같은 문제가 있는 곳이 몇 군데 더 있는지 세고, 고친 걸 되돌리면 테스트가 실패하는지도 확인한다. 계층 메모리 건은 이걸 두 번 했다.
솔직히 이 프로젝트에서 제일 힘들었던 건 특정 버그보다, 고칠 게 계속 나오면서 일정이 밀리는 부담이었다. 잡을수록 목록이 줄지 않는 구간이 있다. 지금 숫자는 테스트 7,203개, 진단 코드 68종, 산출물 포맷 버전 31, 남이 쓴 RTL 설계 10개 중 10개 통과다. 자랑이라기보다 이 규칙을 지키느라 쌓인 숫자에 가깝다.
진단을 읽는 쪽이 사람이 아닐 수도 있다
진단을 처음 다듬을 때는 당연히 사람이 읽을 거라고 생각했다. 그런데 앞 편들에서 말했듯이 나는 이 도구로 파형을 보지 않고 AI가 남긴 리포트를 읽는다. 냉정하게 보면 사람이 vita로 설계할 이유도 별로 없다. 상용 EDA 도구가 더 빠르고 호환성도 좋고 기능도 훨씬 많다.
처음 만든 이유는 맥에서 돌아가는 EDA 도구가 없어서였다. 윈도우도 EDA 쪽 지원은 빈약하다. 만들다 보니 목적이 하나 더 붙었고 지금은 이쪽이 더 크다. RTL 설계를 하는 곳은 대개 인터넷이 끊겨 있어서 상용 LLM을 붙일 수 없으니, 그 안에서 도는 AI 에이전트에게 쥐여 줄 도구가 필요하다.
읽는 쪽이 에이전트면 이 규칙이 더 중요해진다. 사람은 이상한 숫자를 보면 경험으로 멈칫하지만, 에이전트는 어디가 의심스러운지 표시돼 있지 않으면 틀린 값을 그대로 리포트에 싣는다. 그래서 진단에는 위치와 고유 코드가 붙고, 앞에서 본 vita explain처럼 코드마다 설명 문서를 꺼내 볼 수 있게 했다.
느리다는 사실도 README에 적었다
같은 규칙을 문서에도 적용해야 한다는 건 뒤늦게 알았다. README는 같은 입력이면 늘 같은 결과가 나온다는 점과 correct-or-loud를 내세우면서, 돌리는 데 시간이 얼마나 드는지는 말하지 않았다. 지금은 컴파일 방식의 2-state 시뮬레이터보다 한두 자릿수 느리다고 그대로 적고 숫자도 붙였다. Keccak-f[1600] 순열 2,000회를 macOS arm64 release 빌드에서 교대로 재고, 첫 회차는 버렸다.
| 시뮬레이터 | 시간 | Verilator 대비 |
|---|---|---|
| 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는 그 둘을 포기하고 속도를 얻는다. 구조에서 나온 차이라면 이유와 함께 적는 편이 낫다고 봤다. 같은 문서에 vitamin은 대표 케이스를 빠르게 확인하는 데 쓰고, 오래 돌리는 soak test나 풀칩은 Verilator나 상용 도구를 쓰라는 권고도 붙였다. 도구 문서에 그 도구를 쓰지 말라는 문단을 넣는 건 이상한 기분이었다.
README 표에는 같은 계열인 Icarus Verilog보다 빠르다는 숫자도 있다. 남이 쓴 설계 7종 기하평균으로 1.93배, 시간을 잰 9행 전체로 1.74배다.
비교할 다른 시뮬레이터가 없는 기능
이 규칙에도 한계가 있다. 에러는 무언가와 비교하거나 검사해야 낼 수 있고, 검사하지 않은 곳에서 문제가 0건이라는 건 아직 본 적이 없다는 뜻이다. assertion, class와 객체지향, constrained random, clocking block처럼 비교할 시뮬레이터가 없는 기능이 실제로 있다. Icarus Verilog 13은 clocking block을 파싱조차 못 한다.
그런 곳에서는 표준 문서를 직접 읽고 기대값을 손으로 적은 다음, 일부러 흠을 찾는 리뷰를 붙인다. 그 리뷰도 내가 AI에게 역할을 나눠 맡겨서 한다. 다른 시뮬레이터와 대조하는 쪽과 표준 문서로 따지는 쪽을 일부러 갈라 놓았다.
이것도 완전하지는 않다. 어떤 항목에서 다른 시뮬레이터와 결과가 갈렸을 때 “표준에 그 단계가 없으니 우리가 맞다”고 기록하고 테스트까지 넣은 적이 있다. 두 번째 시뮬레이터로 돌려 보니 그쪽도 첫 번째와 같은 답을 냈고, 다른 답을 낸 건 vitamin 하나뿐이었다. 그 뒤로 vitamin이 앞서 있다고 적기 전에는 다른 시뮬레이터를 하나 더 돌려 보라는 규칙이 붙었다. 이 이야기는 연재에서 따로 다룰 생각이다.
파서도 이벤트 큐도 내 분야가 아니었다
만들면서 계속 드는 생각도 있다. RTL 설계는 오래 했지만 파서도, elaboration도, 이벤트 큐도, 런타임도 내 분야가 아니었다. 그런 내가 시뮬레이터를 구현하고 있으니 시대가 좋아지긴 했다.
다음 편은 남이 쓴 RTL 열 개를 다 통과하고 나서야 그 벤치마크가 내가 재려던 걸 재고 있지 않았다는 걸 안 이야기다.
이전 편은 이벤트 큐와 실행 순서이고, 다음 편은 외부 RTL로 검증하기다. 연재 전체 목차는 1편에 모아 두었다.
코드는 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와 협업해 개발하고 있다.