[2/40] RTL 시뮬레이터 개발일지 – 소스에서 파형까지 7단계

vitamin에서 Verilog 소스가 파형 파일이 되기까지는 일곱 단계를 거친다. 이 단계를 밖으로 꺼내 셋으로 나눠 돌릴 수 있게 했는데, 나눠 돌리는 길에서 결과가 조용히 틀린 적이 두 번 있었다.

명령 한 줄이면 시뮬레이션은 돈다. vita counter.sv라고 치면 텍스트 파일이 들어가서 파형 파일이 나온다. 그런데 한 줄로 끝내 버리면 그 사이에서 무슨 일이 벌어지는지 알 수가 없다.

명령 한 줄 뒤에서 데이터가 일곱 번 모양을 바꾼다

소스가 파형이 되기까지 거치는 일곱 단계는 각각 독립된 크레이트이고, 각 단계는 바로 앞 단계가 내놓은 것만 본다.

단계하는 일나오는 것
1. preprocess`define, `ifdef, `include, `timescale 처리, 매크로 전개전처리된 소스
2. lex키워드, 식별자, 리터럴, 연산자로 자르고 소스 위치를 붙임토큰 스트림
3. parse문법 규칙으로 조립. 문법 검사가 여기 있음AST
4. elaborate파라미터를 풀고 인스턴스 계층을 폄. 연결성, 타입, 다중 구동 검사가 여기 있음sim IR
5. sim-irnet(폭, 4-state), process(트리거와 본문), continuous assign, builtin 호출 노드언어 중립 중간 표현
6. sim-engine이벤트 구동 커널. 계층화된 이벤트 큐, 64비트 시간 휠, delta cycle실행과 출력
7. VCD/FST writer신호 변화를 직렬화. RTL이 부를 때만 동작.vcd / .fst
명령 한 줄 뒤에서 데이터가 일곱 번 모양을 바꾼다 도식

언어를 아는 건 parse까지다

일곱 단계 중 가장 중요한 자리는 3번과 4번 사이다. 언어가 무엇인지 아는 것은 parse까지이고, 그 뒤의 elaborate, 중간 표현, 실행 엔진, 파형 기록기는 Verilog인지 SystemVerilog인지 신경 쓰지 않는다. 이들이 아는 것은 net과 process와 이벤트뿐이다.

이 경계 덕분에 중간 표현이 함부로 넓어지지 않는다. 특정 언어에만 있는 문법을 IR에 그대로 넣고 싶어질 때마다, 그건 3번 단계에서 풀어 놨어야 한다며 막게 된다. 중간 표현이 좁으면 그 위에 올릴 실행기를 갈아 끼우기가 쉽다.

문법 검사는 parse에, 연결 검사는 elaborate에

문법이 틀렸다는 것과 연결이 틀렸다는 것은 다른 종류의 질문이고 답할 수 있는 시점도 다르다. 문법 검사는 parse 안에 있고, 여기서 실패하면 elaborate로 넘어가지 않는다. 문장 구조도 모르는데 연결성을 따질 수는 없으니까. 반대로 포트나 타입이 안 맞는다거나 한 신호를 둘이 동시에 몰고 있다는 건 인스턴스를 다 펼쳐 봐야 알 수 있어서 elaborate 안에 있다.

오류 출력은 어느 단계에서 나든 같은 곳을 거친다. 오류를 화면에 그리는 일(파일:줄:열과 캐럿 표시)은 diag가, 트랜스크립트와 심각도 등급과 종료 코드는 vita-log가 맡는다. 그래서 단계 끝마다 합격 여부가 이런 줄로 찍힌다.

errors=0 warnings=0 notes=0

파형은 RTL이 불러야 나온다

7번 단계는 RTL 코드가 요청할 때만 동작한다. 테스트벤치 안에서 $dumpfile과 $dumpvars를 부르지 않으면 파형 파일은 아예 생기지 않고, 빈 파일도 경고도 없다. 파일 이름 끝이 .fst면 GTKWave와 Surfer가 바로 읽는 바이너리 포맷으로, 아니면 사람이 열어 볼 수 있는 텍스트 VCD로 나간다. 두 포맷을 다 쓰는 이유는 나중에 따로 쓴다.

같은 일곱 단계를 도는 두 길, 원샷과 단계별

vitamin에는 같은 일곱 단계를 도는 길이 두 개 있다.

같은 일곱 단계를 도는 두 길, 원샷과 단계별 구조도

위쪽 원샷은 vita 하나가 일곱 단계를 메모리에서 곧장 흘려보내서 중간 파일이 디스크에 남지 않는다. 아래쪽 단계별은 같은 파이프라인을 세 토막으로 끊고, 각 토막이 앞 토막이 디스크에 남긴 산출물을 읽어 이어받는다. 상용 시뮬레이터의 표준 흐름과 그대로 대응하는 모양이다.

vitamin단계Cadence XceliumSynopsys VCS
vcmpcompilexmvlogvlogan
velabelaboratexmelabvcs
vrunsimulatexmsimsimv
vita원샷xrun–

표가 이렇게 생긴 데는 개인적인 이유가 있다. 나는 Xcelium을 쓰던 사람이었고, 손에 익은 환경을 그대로 만들고 싶었다. 단계별로 하나씩 세밀하게 설정해 보고 싶었고, 단계마다 합격 여부를 분명히 확인할 수 있다는 것도 컸다. 무엇보다 중간 과정을 사용자에게 보여 주고 싶었다.

실제로 끊어서 돌리면 이렇다. 저장소의 예제 파일 하나를 세 번에 나눠 통과시킨 기록이다.

Terminal
$ vita vcmp 000_counter.sv --work work
errors=0 warnings=0 notes=0
  산출물: work/units/cu_457a0b1b0be6253c11a5c55706bc4d0f.vu

$ vita velab -L work --top tb
errors=0 warnings=0 notes=0
  산출물: tb.velab   (1,073 바이트)

$ vita vrun tb.velab
t=16  cnt=1 (0x1)
t=26  cnt=2 (0x2)
...
t=126  cnt=12 (0xc)
done: final cnt=12
simulation ended (Finish) at time 126
errors=0 warnings=0 notes=0

같은 파일을 vita 000_counter.sv 한 줄로 돌려도 똑같은 출력이 나온다.

중간 산출물 이름의 16진수는 내용 해시다. 파일 수정 시각(mtime)으로 최신 여부를 판단하지 않겠다는 뜻인데, 이건 따로 한 편을 쓸 만한 주제다.

그럼 둘 중 뭐가 진짜일까. 둘 다 진짜다. 설계의 흐름을 세 단계로 나눠 구성했고, 원샷은 그걸 한 번에 묶은 것이다. 어느 쪽도 틀리지 않았다. 그저 조금 다를 뿐이다.

2026년 6월, 25개 중 13개가 빠져 있었다

길이 둘이면 갈릴 수 있고, 실제로 두 번 갈렸다. 단계 사이에 남는 .velab 파일에는 중간 표현 본체 말고도 함수 테이블, 태스크 호출 바인딩, 클래스 레이아웃, 가상 함수 테이블 같은 곁딸린 표들이 실려 간다. 원샷은 이걸 메모리에서 그냥 넘기니 문제가 없었는데, 단계별 경로는 그중 절반을 직렬화하지 않고 있었다. 2026년 6월 당시 25개 중 13개가 빠져 있었다.

그래서 단계별로 돌리면 클래스 필드 값이 틀리고, 재귀 함수가 엉뚱한 답을 냈다. 그러면서 오류도 경고도 없었고 종료 코드는 0이었다. 그냥 다른 답이 나왔다. 게다가 이건 실험적인 기능이 아니라, 상용 도구 흐름과 1:1로 맞춰 문서에까지 적어 둔 정식 명령 경로였다.

이 문제는 AI에게 일부러 흠을 찾는 역할을 맡겨 코드를 검토시키다가 나온 것 같다. 처음엔 그저 중간 어딘가가 잘못됐다 싶었을 뿐이었다. 가장 이상했던 건 테스트가 있는데도 안 잡혔다는 점인데, 이 이야기는 따로 한 편을 쓸 예정이다.

두 달 뒤 같은 모양의 사고가 또 났다. 이번엔 값이 아니라 오류 메시지의 위치 정보였다. compile 단계가 전처리기의 위치 지도를 버리고 있어서, 단계별로 돌리면 진단에 파일명과 줄 번호가 붙지 않았다. 원샷으로 돌리면 잘 붙었다. 같은 오류인데 어느 길로 왔느냐에 따라 “d.sv:5:5: error[…] [in top.u1] [at time 5]”가 되기도 하고, 오류 문구만 남기도 했다.

조용히 틀리는 게 제일 무섭다. 내가 틀린 줄도 모르고 틀리기 때문이다. 두 사고를 겪고 나서 한 경로에서만 되는 것은 동작한다고 치지 않기로 했고, 지금은 단계별 결과와 원샷 결과가 같은지를 회귀 테스트로 확인한다.

앞에서 두 길이 그저 조금 다를 뿐이라고 했는데, 달라도 되는 건 쓰는 방식까지다. 설정을 얼마나 잘게 나누는지, 디스크에 남는 파일, 지원하는 옵션은 실제로 다르다. 예를 들어 관측 데이터를 뽑는 옵션을 단계별 실행에 주면 이렇게 거절한다.

error[VITA-E0001]: '--obs-dir obs' is a one-shot `vita` argument
                   — 'vrun' does not emit the obs rail

조용히 무시하고 넘어가지 않고, 못 하는 일이면 못 한다고 말한다. 옵션은 달라도 되지만, 시뮬레이션 결과는 두 길에서 반드시 같아야 한다.

지도에 그려만 두고 비워 둔 자리

파이프라인 지도에는 아직 안 채워진 칸이 두 곳 있다. 하나는 시스템 태스크 라이브러리다. 설계 문서에는 hdl-builtins라는 크레이트가 $display부터 파일 입출력, 수학 함수, 난수까지 범주별로 나눠 담는 것으로 그려져 있는데, 실제 파일은 이게 전부다.

Rust
//! hdl-builtins — stub (PR1-B). Real implementation lands in a later PR.

실제 핸들러들은 아직 실행 엔진 안에 인라인으로 들어 있고, 나중에 뜯어낼 지점을 표시한 주석만 박혀 있다.

큰 틀을 먼저 잡고 채워 넣으려고 일부러 그렇게 뒀다. 다만 지금 돌아보면 생각보다 큰 도움은 안 됐던 것 같고, 예상 못 한 비용도 있었다. 이 프로젝트는 AI와 협업해 개발하고 있는데, 빈 크레이트가 거기 있다는 걸 AI가 매번 의식했다. 그래도 남겨 둔 건 계획이 코드에 미리 그려져 있으면 더 선명하게 보이기 때문이다.

다른 하나는 VHDL이다. 3번과 4번 사이 경계선의 가장 큰 명분이 다른 언어의 프론트엔드를 나중에 같은 중간 표현 위에 얹을 수 있다는 것인데, 지금 저장소에 VHDL 코드는 한 줄도 없다. 크레이트 전체를 뒤져도 그 단어조차 안 나오고 문서에만 있다.

VHDL 자리는 일부러 비워 뒀다. 요즘 대부분이 Verilog와 SystemVerilog를 쓰는 건 사실이지만, 범용성을 키우고 legacy 언어를 지원하려면 VHDL은 나중에 반드시 구현하는 게 맞다고 본다.

2026년 8월 19일의 숫자

글을 쓴 시점 기준이고, 진행 중인 프로젝트라 계속 바뀐다.

항목값
릴리스0.1.0, 개발 진행 중
테스트5,608개 통과
크레이트17개 (제품 15, 개발용 2)
산출물 포맷버전 29
진단 코드68종
빌드cargo build 한 줄, C/C++ 의존 0
플랫폼Linux, macOS

설계 문서도 코드보다 낡을 때가 있다. 이번 글을 쓰면서 확인한 것만 해도 크레이트 목록이 실제와 어긋났고, 문서가 소개하는 중간 산출물 확인 옵션은 지금 명령줄에 없었다. 그럴 때 어느 쪽이 맞는지는 늘 코드를 보고 정한다.

일곱 단계로 쪼갠 건 지금도 잘한 결정이다

단계마다 독립적인 모듈로 기능하니, 관리 측면에서든 업그레이드 측면에서든 각자의 입출력 형식만 맞춘다면 훨씬 자유롭게 고칠 수 있다.

다만 앞에서 본 두 번의 사고가 정확히 그 조건이 깨진 자리였다. 단계 사이로 넘기는 데이터가 한쪽 경로에서만 빠지고 있었는데 에러도 경고도 없었다. 그래도 경계를 없애면 사고와 함께 따로 고칠 수 있는 자유도 없어지니, 지금 방식이 치른 값만큼은 한다고 생각한다.

이전 편은 이벤트 구동 시뮬레이션이고, 다음 편은 명령 4개로 나눈 구조다. 연재 전체 목차는 1편에 모아 두었다.

코드는 github.com/tjddnr0912/vitamin-rtl-simulator에 있고 MIT와 Apache-2.0 듀얼 라이선스다. 이 글의 실행 기록은 저장소의 examples/000_counter.sv를 실제로 돌린 결과 그대로다.

개발 중인 개인 프로젝트의 기록이라 상용 검증 도구를 대체하지 않고, 본문의 수치와 구현 범위는 글을 쓴 시점 기준이라 이후에 바뀐다.

댓글 남기기

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