오픈소스 RTL 시뮬레이터 만들기 · 4편
예전에 시뮬레이션을 돌리면 로그가 주루룩 쏟아지곤 했다. 그런데 같은 걸 다시 돌리면 몇 줄 띡 하고 끝나버릴 때가 있었다.
그때는 고장난 줄 알았다.
고장이 아니었다. 도구가 “바뀐 게 없으니 앞 단계는 건너뛴다”고 판단한 것이었다. 컴파일을 다시 안 했으니 컴파일 로그가 없고, 그래서 화면이 조용했던 것뿐이다.

편리한 기능이다. 그리고 이 시리즈에서 가장 무서운 종류의 기능이기도 하다. 건너뛰기가 한 번이라도 잘못 판단하면, 도구는 지금 소스와 상관없는 낡은 결과물을 그대로 써서 시뮬레이션을 돌린다. 에러 없이. 정상 종료로.
지난 편에서 명령을 넷으로 가른 이야기를 했다. 가르고 나면 중간 산출물이 디스크에 남고, 남으면 재사용하고 싶어진다. 이번 편은 그 재사용을 언제 허락할 것인가를 정하는 규칙에 대한 글이다.
무엇이 바뀌면 다시 해야 하는가
문제를 한 문장으로 좁히면 이렇다. 어떤 단계를 건너뛰어도 안전하려면, 그 단계의 출력을 바꿀 수 있는 모든 입력이 “이거 아직 쓸 만한가”를 판단하는 값에 들어가 있어야 한다. 하나라도 빠지면 그 하나로 사고가 난다.
가장 알기 쉬운 사고는 이렇게 생겼다.
+define+WIDTH=8로 한 번 컴파일했다. 그다음+define+WIDTH=16으로 다시 돌린다. 그런데 소스 파일의 바이트는 하나도 안 바뀌었다. 판단 근거가 파일 내용뿐이라면 도구는 “바뀐 게 없다”고 결론내고 컴파일을 건너뛴다. 그러면 WIDTH=8로 컴파일된 결과물을 16인 줄 알고 시뮬레이션한다.
에러는 안 난다. 파형도 나온다. 숫자만 틀린다.
그래서 매크로 정의는 소스 파일과 함께 판단 근거에 들어가야 한다. 여기까지는 누구나 동의할 것이다. 문제는 그다음이다 — 그럼 나머지 수십 개 플래그는?
어느 바이너리가 읽느냐가 아니라, 무엇을 망치느냐
처음에는 플래그를 명령별로 나누고 싶어진다. vcmp가 받는 것, velab이 받는 것, vrun이 받는 것. 자연스러운 분류다.
그런데 이 기준으로는 답이 안 나온다. 같은 플래그를 여러 명령이 받을 수 있고, 어떤 명령이 받았는지는 그 플래그가 무엇을 바꾸는지와 별개이기 때문이다.
그래서 규칙을 이렇게 세웠다.
플래그를 어느 바이너리가 파싱하느냐가 아니라 어느 단계의 출력을 교란하느냐로 분류한다.
그러면 버킷이 셋 나온다.
- A — 전처리 결과를 바꾼다. 컴파일 단계의 판단 근거에 들어간다. 인클루드 경로, 매크로 정의, 라이브러리 탐색 경로, 언어 표준 지정 같은 것들.
- B — 펼쳐진 설계를 바꾼다. 엘라보레이트 단계의 판단 근거에 들어간다. 최상위 모듈 지정, 파라미터 오버라이드, 다중 구동 정책, 라이브러리 결합 같은 것들.
- C — 둘 다 안 바꾼다. 어떤 판단 근거에도 안 들어간다. 로그 파일 이름, 시드, 실행 시간 제한, 경고 표시 옵션, 출력 파일 이름 같은 것들.
실제로 분류해 놓으면 이렇게 된다.
| 플래그 | 하는 일 | 버킷 |
|---|---|---|
+incdir+, -I |
인클루드 탐색 경로 | A |
+define+, -D |
매크로 정의 | A |
-y, +libext+ |
라이브러리 탐색·확장자 | A |
--std, -sv |
언어 표준 전환 | A |
--work |
라이브러리 이름 공간 | A |
--timescale |
시간 단위·정밀도 | A 그리고 B |
-s, --top-module |
최상위 모듈 지정 | B |
-G, -P |
파라미터 오버라이드 | B |
-L, --lib-map |
라이브러리 결합 | B |
--multi-driver |
다중 구동 정책 | B |
+plusargs, -sv_seed |
실행 시 값 전달·시드 | C |
--log, -o, -Wall |
로그·출력 이름·경고 표시 | C |
목록을 훑어보면 규칙이 눈에 들어온다. 설계를 바꾸는 것은 전부 A 아니면 B이고, 결과를 어떻게 보여줄지에 대한 것은 전부 C다. 파일 목록을 담는 커맨드 파일도 C에 속한다 — 소스를 어떤 파일에 나눠 적었는지, 그 파일 이름을 뭘로 지었는지는 설계와 무관하니까. 다만 그 안에 적힌 내용은 원래 자리대로 A나 B로 간다.
여기서 눈여겨볼 것은 C다.
덜 넣어도 틀리고, 더 넣어도 틀린다
판단 근거에 무언가를 빠뜨리면 낡은 결과물이 조용히 재사용된다. 이건 앞에서 봤다.
반대로 과하게 넣으면 어떻게 될까. 로그 파일 이름을 판단 근거에 넣었다고 해보자. 그러면 --log a.log로 돌린 다음 --log b.log로 돌릴 때 도구는 “입력이 달라졌다”고 판단해서 전체를 다시 컴파일한다. 아무것도 안 바뀌었는데.
이건 성능 문제로 보이지만 사실 계약 위반이다. 같은 결과물을 런타임 옵션만 바꿔서 두 번 돌리면 두 번 다 유효해야 한다. 그 보장이 깨지면 단계를 가른 의미가 절반 사라진다.
정직하게 덧붙이면, 이 규칙은 내가 당해서 만든 게 아니다. 앞의 매크로 사고와 달리 “과하게 해싱해서 고생했다”는 기억은 없다. 설계하면서 미리 정한 쪽에 가깝다. 이 시리즈에서 나는 사건이 있었던 규칙과 그렇지 않은 규칙을 구분해서 쓰려고 하는데, 이건 후자다.
그리고 예외가 하나 있다. 타임스케일 지정은 양쪽 모두에 정당하게 들어간다. 전처리 단계에서는 소스에 주입되는 디렉티브라서 컴파일 결과를 바꾸고, 동시에 설계 전체의 시간 정밀도를 정하는 근원이라서 펼쳐진 설계도 바꾼다. 하나의 플래그가 두 단계를 다 건드리는 유일한 경우다.
당하고 나서 알게 된 것
여기까지가 설계하면서 정한 것들이다. 다음 것은 아니다. 이건 당하고 나서 알았고, 한참 헤맸다.
Verilog에는 파일 하나를 넘어 계속 살아 있는 지시자들이 있다. 타임스케일이나 기본 넷 타입 같은 것들인데, 한 번 선언하면 그 뒤에 오는 파일들에도 계속 적용된다.
그래서 이런 일이 벌어진다. 파일 B는 자기 내용이 한 글자도 안 바뀌었다. 그런데 앞에 오던 파일 A가 바뀌면 B의 컴파일 결과가 달라진다. B가 A로부터 물려받던 타임스케일이 달라졌으니까. 파일 순서만 바꿔도 마찬가지다.
판단 근거를 “그 파일 자신의 바이트”로 잡으면 이 경우가 통째로 새어 나간다. 그래서 두 가지를 고쳤다. 판단 근거를 상속을 반영한 뒤의 내용으로 계산하도록 옮겼고, 정렬된 파일 목록 자체를 별도 판단 근거로 추가해서 파일을 더하거나 빼거나 순서를 바꾸면 그것만으로 무효화되게 했다.
이 항목은 설계 문서에 지금도 차단 대상으로 표시돼 있다. 놓치면 조용히 틀리는 부류이고, 실제로 놓쳤던 자리라서 그렇다.
규칙을 지키는 것보다, 어길 수 없게 만드는 것
규칙이 여섯 개쯤 되면 다음 걱정이 생긴다. 새 플래그를 추가하는 사람이 이 규칙을 기억할까?
기억에 맡기지 않는 방법을 하나 썼다. 판단 근거를 계산하는 함수가 명령줄 인자를 통째로 받지 못하게 만든 것이다. 대신 필요한 것만 담은 전용 구조체를 받는다. 전처리용 구조체에는 인클루드 경로와 매크로 정의 같은 것들이 들어 있고, 엘라보레이트용 구조체에는 최상위 모듈과 파라미터 오버라이드 같은 것들이 들어 있다.
로그 파일 이름을 넣을 자리는 그 구조체에 아예 없다. 그러니 실수로 넣는 일이 안 생긴다. 넣으려면 구조체에 필드를 추가해야 하고, 그건 눈에 띄는 변경이다. 규칙을 문서에서 코드로 옮겨놓은 셈이다.
솔직히 말하면 이걸 무슨 원칙처럼 의식하면서 짠 건 아니다. 나는 평소에 상황에 맞춰 이것저것 많이 바꾸는 편이다. 다만 옵션이 너무 많아서 뭘 어떻게 설정해야 할지 몰라 헤맸던 경험은 확실히 많다. 상용 도구를 쓰다 보면 플래그가 수백 개인데 그중 어떤 게 결과를 바꾸고 어떤 게 안 바꾸는지 알려주는 사람이 없다. 그 답답함이 어딘가에 남아 있었던 것 같다.
그래서 이 구조는 “규율 있는 개발자가 되자”는 다짐이 아니라, 덜렁대는 나를 전제로 짠 안전장치에 가깝다.
판단이 실제로 흐르는 모양

타임스케일만 첫 질문과 두 번째 질문에 모두 “그렇다”로 답해서 A와 B 양쪽에 들어간다.
한 번에 돌리면 이 값을 아예 안 치른다
여기까지 읽고 나면 이상한 점이 하나 있다. 지난 편에서 나는 사실상 원샷 명령만 쓴다고 했는데, 원샷에는 중간 산출물이 없다.
맞다. 원샷 실행은 설계를 메모리에 들고 다니면서 끝까지 간다. 디스크에 쓰는 것도 없고, 따라서 낡았는지 판단하는 계산을 한 번도 하지 않는다. 낡을 대상이 없으니까.
그러니까 이 편에서 설명한 규칙들은 전부 단계를 나눠 쓸 때만 작동한다. 지난 편에서 이야기한 mtime 지름길도 마찬가지다. 만들어놓고 정작 내 일상 작업에서는 거의 타지 않는다는 뜻이 된다.
그런데도 이 규칙들이 없으면 안 된다. 단계를 나눠 쓰는 사람에게는 이게 정확성의 전부이고, 무엇보다 두 경로가 같은 답을 낸다는 보장이 여기에 걸려 있다. 원샷이 빠른 이유가 “검사를 안 해서”라면, 그건 빠른 게 아니라 그냥 안전하지 않은 것이다. 원샷이 검사를 안 해도 되는 이유는 애초에 재사용할 것이 없기 때문이어야 한다.
몇 줄로 끝난 로그로 돌아가서
맨 앞의 이야기로 돌아가면, 그때 화면이 조용했던 건 도구가 판단을 내렸기 때문이다. 그 판단이 맞았는지 나는 확인할 방법이 없었고, 확인해야 한다는 생각도 못 했다.
지금 내가 만들고 있는 도구에서는 그 판단이 어떤 근거로 이뤄지는지를 위와 같이 적어두려고 한다. 다 적어놓고 보니 규칙이 여섯 개고, 그중 하나는 당하고 나서야 알았고, 하나는 지난 편에서 스스로 예외를 뚫었다.
이 편을 쓰면서 계속 든 생각이 하나 있다. 시작해보고 나니 예상보다 수십 배는 어렵게 느껴졌다.
“안 바뀌었으면 다시 안 한다”는 문장은 한 줄이다. 그 한 줄을 안전하게 만드는 데 규칙이 여섯 개 필요했고, 그중 절반은 처음에 생각조차 못 했던 것들이다. 캐시가 어렵다는 말은 많이 들었는데, 어렵다는 게 이런 모양일 줄은 몰랐다.
시리즈 — ← 3편 · 명령을 넷으로 가르고, 두 번 반박당했다 · 시리즈 목차
코드 — github.com/tjddnr0912/vitamin-rtl-simulator (MIT / Apache-2.0). 본문의 규칙은 저장소 docs/preview/14-staged-artifacts.md의 해시 결합 규칙 절을 풀어 쓴 것이다.
개발 중인 개인 프로젝트의 기록입니다. 상용 검증 도구를 대체하지 않으며, 본문의 구현 범위는 집필 시점(2026-08-28 · 0.2.0 · 테스트 6,240 green) 기준으로 이후 변경됩니다. 이 프로젝트는 AI와 협업해 개발하고 있습니다.