소스에서 파형까지, 일곱 번 모양이 바뀐다

소스에서 파형까지, 일곱 번 모양이 바뀐다

오픈소스 RTL 시뮬레이터 만들기 · 2편 — 파이프라인 구조

명령 한 줄이면 시뮬레이션은 돈다.

vita counter.sv라고 치면 텍스트 파일이 들어가서 파형 파일이 나온다. 그런데 그 사이에서 무슨 일이 벌어지는지는, 한 줄로 끝내버리면 더더욱 알 수가 없다.

그래서 이 시뮬레이터는 그 과정을 일부러 쪼개서 밖으로 꺼내 놨다. 이번 글은 그 쪼갠 자리를 하나씩 여는 이야기다. 중간에 조용히 틀렸던 기록도 함께 나온다 — 쪼갰기 때문에 생긴 사고였으니까.

명령 한 줄 뒤에서 모양이 일곱 번 바뀐다

소스 텍스트가 파형이 되기까지, 데이터는 일곱 번 다른 모양으로 다시 태어난다. 각 단계는 독립된 크레이트이고, 앞 단계가 내놓은 것만 본다. 그 앞은 안 본다.

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

경계선은 parse와 elaborate 사이에 있다

일곱 단계 중 가장 중요한 자리는 3번과 4번 사이다. 언어가 무엇인지 아는 것은 parse까지다. 그 뒤로는 아무도 Verilog인지 SystemVerilog인지 신경 쓰지 않는다.

elaborate도, 중간 표현도, 실행 엔진도, 파형 기록기도 마찬가지다. 이들이 아는 것은 net과 process와 이벤트뿐이다. 어떤 문법으로 쓰여 있었는지는 3번 단계에서 이미 벗겨져 나갔다.

이 경계선이 하는 일은 하나 더 있다. 중간 표현이 넓어지지 못하게 누른다. 특정 언어의 편의 문법을 IR에 넣고 싶어질 때마다, 경계선이 “그건 3번 단계에서 끝냈어야 한다”고 답한다. 중간 표현이 좁게 유지되면 그 위에 올릴 실행기를 갈아 끼우기가 쉬워진다.

검사는 각자 자기 단계에 산다

검사를 어디에 둘 것인가는 취향 문제처럼 보이지만 아니다. 문법이 틀렸다연결이 틀렸다는 서로 다른 종류의 질문이고, 답할 수 있는 시점도 다르다.

문법 검사는 parse 안에 있다. 여기서 실패하면 elaborate로 넘어가지 않는다 — 문장 구조도 모르는데 연결성을 따지는 건 의미가 없으니까. 반대로 포트가 안 맞는다, 타입이 안 맞는다, 한 신호를 둘이 동시에 몰고 있다는 건 parse가 답할 수 없다. 인스턴스를 다 펼쳐보기 전에는 알 수가 없기 때문이다. 그래서 elaborate 안에 있다.

진단 자체는 단계를 가로지른다. 오류를 화면에 그리는 일(파일:줄:열과 캐럿 표시)은 diag가, 트랜스크립트와 심각도 등급과 종료 코드는 vita-log가 맡는다. 어느 단계에서 났든 같은 통로로 흐른다.

덕분에 단계마다 합격 여부가 그 자리에서 찍힌다. 뒤에 나올 실행 기록을 보면 매 단계 끝에 이 줄이 붙어 있다.

text

errors=0 warnings=0 notes=0

파형은 부탁받아야 나온다

7번 단계에는 남들과 다른 규칙이 하나 있다. RTL 코드가 요청할 때만 동작한다.

테스트벤치 안에서 $dumpfile$dumpvars를 부르지 않으면 파형 파일은 아예 생기지 않는다. 빈 파일이 생기는 것도 아니고 경고가 뜨는 것도 아니다. 그냥 없다. 자동으로 항상 덤프하는 방식이 아니다.

파일 이름 끝이 .fst면 GTKWave와 Surfer가 바로 읽는 바이너리 포맷으로, 아니면 사람이 열어볼 수 있는 텍스트 VCD로 나간다. 두 포맷을 다 쓰는 이유는 나중에 따로 다룬다.

같은 파이프라인을 걷는 두 가지 방법

여기서부터가 이 글의 본론이다. vitamin에는 같은 일곱 단계를 도는 길이 두 개 있다.

d2 diagram

위쪽은 원샷이다. vita 하나가 일곱 단계를 메모리에서 곧장 흘려보낸다. 중간 파일이 디스크에 안 남는다.

아래쪽은 단계별이다. 같은 파이프라인을 세 토막으로 끊고, 각 토막이 앞 토막이 디스크에 남긴 산출물을 읽어 이어받는다. 이 모양은 상용 시뮬레이터의 표준 흐름과 그대로 대응한다.

vitamin 단계 Cadence Xcelium Synopsys VCS
vcmp compile xmvlog vlogan
velab elaborate xmelab vcs
vrun simulate xmsim simv
vita 원샷 xrun

이 표가 이렇게 생긴 데는 개인적인 이유가 있다. 나는 Xcelium을 쓰던 사람이었고, 손에 익은 환경을 그대로 만들고 싶었다. 하지만 그게 전부는 아니었다. 단계별로 하나씩 세밀하게 설정해보고 싶었고, 각 단계에서 합격 여부를 명확히 확인할 수 있다는 것도 컸다.

그리고 무엇보다, 중간 과정을 사용자에게 보여주고 싶었다. 한 번에 끝내버리면 그 안에서 무슨 일이 일어나는지 알 길이 없으니까.

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

Bash

$ 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 한 줄로 돌리면 똑같은 출력이 나온다. 셋으로 나눈 것과 한 번에 한 것이 결과에서 구별되지 않는다 — 그게 정상이고, 그게 계약이다.

중간 산출물 파일 이름을 한 번 더 보자 — cu_457a0b1b0be6253c11a5c55706bc4d0f.vu. 저 16진수는 내용 해시다. 파일이 언제 수정됐는지(mtime)로 최신 여부를 판단하지 않겠다는 뜻이고, 이건 나중에 한 편을 통째로 쓸 만한 주제다.

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

그런데 두 길이 조용히 갈라져 있었다

길이 둘이면 갈릴 수 있다. 실제로 갈렸다. 두 번이나.

단계 사이에 남는 .velab 파일에는 중간 표현 본체 말고도 곁딸린 표들이 실려 간다. 함수 테이블, 태스크 호출 바인딩, 클래스 레이아웃, 가상 함수 테이블 같은 것들이다. 원샷은 이걸 메모리에서 그냥 넘기니 문제가 없다. 그런데 단계별 경로는 그중 절반을 직렬화하지 않고 있었다.

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

단계별로 돌리면 클래스 필드 값이 틀리고, 재귀 함수가 엉뚱한 답을 냈다. 문제는 그러면서 오류가 0이었다는 것이다. 경고도 없었다. 종료 코드도 0이었다. 그냥 다른 답이 나왔다.

그리고 이건 실험적인 기능이 아니라, 상용 도구 흐름과 1:1로 맞춰 문서에까지 적어둔 일급 명령 경로였다.

발견은 적대적 리뷰 과정에서 나왔던 것 같다. 의심할 만한 것이 많지 않았다. 그저 중간 어딘가가 잘못됐다 싶었을 뿐이었다. 가장 이상했던 건 따로 있었다 — 테스트가 있는데 안 잡혔다는 것. (이 이야기는 이 시리즈에서 따로 한 편을 받을 예정이다.)

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

무서운 건 조용히가 제일 무섭다. 내가 틀린 줄 모르면서 틀리기 때문이다.

두 사고에서 나온 규칙은 하나다. 한 경로에서만 되는 것은 “동작한다”가 아니다. 지금은 단계별 결과와 원샷 결과가 같은지를 회귀 테스트로 붙들어 놓았다.

앞에서 두 길이 “그저 조금 다를 뿐”이라고 했는데, 여기서 선을 하나 그어야 한다. 다를 수 있는 것은 편의다. 설정의 입도, 디스크에 남는 파일, 지원하는 옵션 — 이런 건 달라도 된다. 실제로 다르다. 예를 들어 관측 데이터를 뽑는 옵션을 단계별 실행에 주면 이렇게 거절한다.

text

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 코드는 한 줄도 없다. 크레이트 전체를 뒤져도 그 단어조차 안 나온다. 문서에만 있다.

그런데 그 자리를 비워둔 건 우연이 아니다. 범용성을 키우고 legacy 언어를 지원하려면 VHDL은 필요하다고 본다. 요즘 대부분이 Verilog와 SystemVerilog를 쓰는 건 사실이지만, 그렇다고 해도 VHDL은 나중에 반드시 구현되는 게 맞는 방향이다. 경계선은 그 계획의 값을 미리 치러둔 자리다.

지금 어디까지 와 있나

글 쓰는 시점(2026년 8월 19일) 기준 숫자다. 진행 중인 프로젝트라 이 표는 계속 바뀐다.

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

그리고 정직하게 덧붙이면, 설계 문서도 코드보다 낡을 때가 있다. 이번 글을 쓰면서 확인한 것만 해도 크레이트 목록이 실제와 어긋났고, 문서가 소개하는 중간 산출물 확인 옵션은 지금 명령줄에 존재하지 않았다. 지도와 땅이 갈리는 건 지도 잘못이 아니라 땅이 계속 움직여서다. 다만 그럴 때 어느 쪽이 진실인지는 항상 코드다.

경계는 비용이 아니라 자유도다

일곱 단계로 쪼갠 것을 지금도 잘한 결정이라고 본다. 여러 단계로 나눠 각 스텝별로 진행된다는 건 각각이 독립적인 모듈로서 기능한다는 뜻이다. 관리 측면에서든 업그레이드 측면에서든, 각자의 입출력 형식만 맞춘다면 훨씬 자유롭게 개선할 수 있다.

그런데 이 문장에는 조건절이 하나 붙어 있다 — “각자의 입출력 형식만 맞춘다면.” 앞에서 본 두 번의 사고가 정확히 그 조건이 깨진 자리였다. 경계 하나가 한쪽에서만 새고 있었고, 아무도 소리 내지 않았다.

그래서 치른 대가가 있었을지언정, 현행 방식은 그 값을 한다고 생각한다. 경계를 없애면 사고도 없어지지만 자유도도 같이 없어진다. 남는 일은 경계를 지우는 게 아니라 경계마다 소리가 나게 만드는 것이다.

이 시뮬레이터가 단계를 밖으로 꺼내 놓은 이유는 하나였다. 중간 과정을 보여주고 싶었다. 한 줄로 끝내버리면 안에서 무슨 일이 벌어지는지 알 수 없으니까. 이 글이 한 일도 정확히 같다. 도구가 사용자에게 열어 보이려던 것을, 글이 독자에게 한 번 더 열어 보인 것뿐이다.

📚 시리즈← 1편 · RTL 시뮬레이터는 무엇을 하는 프로그램인가 · 시리즈 목차

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

개발 중인 개인 프로젝트의 기록입니다. 상용 검증 도구를 대체하지 않으며, 본문의 수치와 구현 범위는 집필 시점 기준으로 이후 변경됩니다.

댓글 달기

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

위로 스크롤