시작은 소박했다. 맥북에서 Verilog 파일 하나를 그냥 돌려 보고 싶었다. 설치 안내를 30분씩 읽지 않고, 빌드 도구를 세 개씩 깔지 않고, cargo build 한 줄로. 그렇게 만들기 시작한 게 vitamin이고, 1년쯤 지난 지금은 크레이트 17개에 테스트 6,240개짜리 물건이 됐다. 이 연재는 그 안을 뜯어보는 기록이고, 첫 편은 회로를 “실행한다”는 게 무슨 뜻인지에서 시작한다.
맥에서 그냥 돌아가는 시뮬레이터가 갖고 싶었다
하드웨어 설계에 쓰는 상용 시뮬레이터는 사실상 리눅스 전용이다. 라이선스 서버가 필요하고, 개인이 집에서 켜 볼 수 있는 물건도 아니다. 오픈소스 쪽은 대부분 C/C++ 빌드 체인 위에 있어서, 맥에서 처음 세팅하려면 컴파일러와 라이브러리 버전부터 맞춰야 한다. 정작 하고 싶은 건 회로 하나 돌려서 파형을 보는 것뿐인데.
그래서 처음에 세운 제약은 두 줄이었다. C/C++ 의존 없이 순수 Rust로 짜서 저장소를 받아 cargo build 하면 끝날 것. 그리고 맥과 리눅스에서 출력 바이트까지 같은 결과가 나올 것.
언어 후보는 셋이었다.
| 후보 | 결정적 요인 | 판정 |
|---|---|---|
| Rust | GC가 없어 이벤트 루프의 타이밍이 흔들리지 않음, enum과 패턴 매칭이 구문 트리 표현에 그대로 맞음, cargo가 여러 OS 소스 빌드를 기본 지원 | 채택 |
| C / C++ | 이 분야의 검증된 경로(Verilator, Icarus)지만 크로스 플랫폼 빌드 마찰이 애초에 피하려던 바로 그 문제 | 차점 |
| Go | 빌드는 편하지만 GC 지연이 정밀 시간 모델에 불리하고, 합타입이 없어 구문 트리 표현이 어색해짐 | 부적합 |
가장 크게 작용한 이유는 이렇다. 시뮬레이터의 버그는 컴파일 에러로 나타나지 않고 잘못된 파형으로 나타난다. 프로그램은 멀쩡히 끝나고 숫자도 그럴듯한데 값 하나가 틀려 있는 식이다. 그런 도구를 만들 때는 언어가 미리 잡아 주는 실수 한 종류가 통째로 사라지는 게 크다.
게이트는 입력이 바뀔 때만 움직인다
실제 회로에서 논리 게이트는 입력이 바뀔 때만 반응하고, 이벤트 구동 시뮬레이터는 이 성질을 그대로 흉내 낸다. 신호가 바뀌면 그 신호를 지켜보던 프로세스만 깨워 값을 다시 계산하고, 더 깨울 것이 없으면 시간을 건너뛰어 다음 사건이 예정된 시각으로 곧장 넘어간다.

비교 대상은 사이클 단위 시뮬레이터다. 이쪽은 클럭이 한 번 뛸 때마다 회로 전체 상태를 통째로 다시 계산한다. 구현이 단순하고 규칙적인 동기 회로에서는 빠르지만, 클럭 사이에서 벌어지는 일은 표현하지 못한다.
| 항목 | 사이클 단위 | 이벤트 구동 |
|---|---|---|
| 계산 시점 | 매 클럭마다 전체 | 바뀐 신호에 걸린 것만 |
| 비동기 회로 | 표현 못 함 | 표현 가능 |
| 클럭 사이 타이밍 | 보이지 않음 | 임의 해상도로 추적 |
| 구현 난이도 | 낮음 | 높음 (이 연재가 다루는 쪽) |
Keccak 한 번에 Verilator보다 42배 느리다
표만 보면 이벤트 구동이 일방적으로 좋아 보이지만 대신 느리다. 신호 하나가 바뀔 때마다 누가 깨어나야 하는지를 매번 따져야 하고, 그 판단 자체가 비용이다. 같은 암호 연산(Keccak)을 한 번 도는 데 걸린 시간을 재 보면 이렇다.
| 시뮬레이터 | 한 번당 | 상대 |
|---|---|---|
| Verilator (컴파일드, 2-state) | 7.0 µs | 1배 |
| vitamin (호출 없는 설계) | 295 µs | 42배 느림 |
| Icarus Verilog | 4,470 µs | 639배 느림 |
Verilator와는 한두 자릿수 차이가 난다. 튜닝으로 좁힐 수 있는 격차가 아니고 구조에서 오는 차이다. Verilator는 4-state(0, 1, x, z)와 이벤트 순서를 포기하는 대신 그 속도를 얻는다. 나는 그걸 포기하지 않기로 했으니 값을 치르는 게 맞다. 같은 이벤트 구동 진영인 Icarus Verilog보다는 앞선다. 남이 쓴 설계 다섯 개에서 기하평균 1.9배 빠르다.
시각은 멈춰 있고 계산만 도는 delta cycle
신호 a가 바뀌어 b를 바꾸고, b가 다시 c를 바꾼다고 해 보자. 실제 회로라면 아주 짧은 지연이 있겠지만 시뮬레이터의 시각은 1나노초도 전진하지 않고, 같은 시각 안에서 전파가 멎을 때까지 반복한다. 이 0-시간 반복을 delta cycle이라고 부른다. 조합 논리에 피드백이 있으면 이 반복이 끝나지 않을 수도 있는데, 그 이야기와 한 시각 안에도 정해진 순서가 있다는 이야기는 뒤 편에서 쓴다.
4비트 카운터를 실제로 돌려 보면
말로 하면 추상적이니 실물을 보자. 클럭이 올라갈 때마다 1씩 증가하고, 리셋이 걸려 있으면 0으로 돌아가는 4비트 카운터다.
module counter #(parameter WIDTH = 4) (
input clk,
input rst,
output reg [WIDTH-1:0] cnt
);
always @(posedge clk) begin
if (rst) cnt <= {WIDTH{1'b0}};
else cnt <= cnt + 1'b1;
end
endmodule테스트벤치가 10나노초 주기로 클럭을 흔들고 리셋을 한 번 넣은 뒤 값을 12번 찍는다. 명령은 한 줄이다.
$ vita examples/000_counter.sv
t=16 cnt=1 (0x1)
t=26 cnt=2 (0x2)
t=36 cnt=3 (0x3)
...
t=126 cnt=12 (0xc)
done: final cnt=12
simulation ended (Finish) at time 126
errors=0 warnings=0 notes=0시각이 16, 26, 36으로 뛴다. 1씩 세지 않고 사건이 있는 자리로만 건너뛴다. 같은 실행이 파형 파일도 하나 남기는데, 열어 보면 사람이 읽을 수 있는 텍스트다.
$timescale 1ns $end
$scope module tb $end
$var reg 1 ! clk $end
$var wire 4 # cnt [3:0] $end
...
#5 5ns 시점
1! clk 가 1 로
b0000 # cnt 은 0000
#15
b0001 # cnt 은 0001기호 하나에 신호 하나를 배정하고 값이 바뀐 순간만 기록한다. 안 바뀐 신호는 아예 적지 않으니, 이벤트 구동이라는 사고방식이 저장 포맷에까지 이어져 있는 셈이다. 파형으로 그리면 이렇다.

첫 상승 에지에서는 리셋 때문에 cnt에 0이 들어가고, 그 뒤로는 상승 에지마다 1씩 오른다.
이 카운터가 얼마나 게으른지는 숫자로 셀 수 있다. 위 실행은 126ns를 시뮬레이션했고 정밀도는 1ns라서 시각 후보는 127개다. 그런데 파형 파일에 실제로 기록된 시각은 38개뿐이고, 나머지 89개 시각에서는 아무것도 하지 않았다. 사이클 단위 방식이었다면 127번을 전부 돌았을 것이다. 이렇게 70%를 건너뛰는 게으름이 이벤트 구동의 전부이고, 회로가 커질수록 그 비율은 더 벌어진다.
안 한다고 적어 둔 목록이 하나씩 뒤집혔다
여기까지가 처음에 하고 싶었던 전부였다. 그런데 설계 문서를 쓰기 시작하니 범위가 저절로 자랐다. 카운터가 돌면 조건문이 필요하고, 조건문이 되면 배열이, 배열이 되면 테스트벤치가 필요하고, 테스트벤치를 쓰다 보면 어서션이 필요해진다. 그 흔적은 초기 문서에 “이건 안 하기로 한다”고 적어 둔 비목표 항목에 그대로 남아 있다.
| 처음에 안 한다고 적은 것 | 지금 |
|---|---|
| 컴파일드 실행 백엔드 | 컴파일 백엔드가 제품의 기본값 (바이트코드 VM은 그 뒤 은퇴) |
| 삼각함수, 로그 같은 수학 함수 | 21종 구현 (순수 Rust 수학 라이브러리 내장) |
| 클래스, 제약 랜덤 검증 | 상속, 가상 디스패치, 제약 랜덤 구현 |
| VCD 외 파형 포맷 | FST 출력 지원 (GTKWave, Surfer 네이티브) |
| 기능 커버리지 | 코어 구현 |
2026년 8월 기준으로 제품 백엔드는 native이고 공개 릴리스는 0.1.0이다. 처음의 두 줄짜리 제약은 아직 그대로다. C/C++ 의존은 여전히 0이고, 맥과 리눅스의 출력은 바이트까지 같다. 훨씬 빠를 수 있는 실행 방식을 하나 포기하게 만든 것도 저 두 줄이었는데, 그 이야기는 성능 편에서 하겠다.
성공담만 쓰지는 않을 생각이다
이 연재는 HDL은 쓰는데 시뮬레이터 안쪽은 안 봤던 사람, 그리고 컴파일러나 시스템 프로그래밍은 아는데 EDA는 낯선 사람을 생각하고 쓴다. 그래서 크레이트 이름부터 던지지 않고, 개념과 설계 이유를 먼저 말한 다음 코드와 수치를 보인다.
그리고 이 프로젝트에는 예측이 빗나간 자리, 스스로 철회한 판단, 되돌려 보니 테스트가 아무것도 지키고 있지 않던 사건이 날짜와 수치로 남아 있다. 잘된 이야기보다 그쪽이 훨씬 쓸모 있다고 생각해서, 그런 것들을 빼지 않고 쓰려고 한다.
아직 만들고 있는 물건이라 이 목차도 계속 늘어난다. 발행되는 대로 이 자리에 링크를 채워 넣는다.
개념 기초
- [1/40] 이벤트 구동 시뮬레이션 (지금 이 글)
- [3/40] 명령 4개로 나눈 구조
- 예정: C/C++ 없이 Rust로만, 빌드 가능성을 제약으로 삼기
파이프라인 구조
- [2/40] 소스에서 파형까지 7단계
- 예정: 17개 크레이트로 쪼갠 이유 · 파라미터를 풀고 계층을 펴는 단계 · 언어를 모르는 중간 표현
시뮬레이션 엔진
- [5/40] 이벤트 큐와 실행 순서
- 예정: delta cycle과 무한 루프 · 프로세스를 멈췄다 재개하기 · 병렬 블록 · 시간 모델 · 파형 출력
결정성과 산출물
- [4/40] 산출물 재빌드 판정
- 예정: 바이트까지 같은 출력을 계약으로 만들기 · 타입 모양 해싱
정확성 방법론
- [6/40] 조용히 틀리지 않기
- [7/40] 외부 RTL로 검증하기
- 예정: 다른 시뮬레이터를 기준으로 쓰기 · “앞서 있다”는 결론이 틀렸던 사건 · 진단 코드 관리 · 테스트가 초록인데 아무것도 안 지키고 있던 이야기
성능과 백엔드
- 예정: 예측한 병목이 둘 다 틀렸던 날 · 층을 셋 만들었다가 둘로 줄인 이야기 · 착수하지 않기로 한 최적화 · 만들었는데 1.00배였던 최적화 · 실물 IP로 재보기
언어 기능 확장
- 예정: 합성 가능한 범위 · 동적 자료형 · 어서션 · 클래스와 제약 랜덤
관찰성과 공개
- 예정: AI 에이전트가 쓰기 좋은 시뮬레이터 · 0.1.0을 내기까지
코드는 github.com/tjddnr0912/vitamin-rtl-simulator에 있고 MIT와 Apache-2.0 듀얼 라이선스다. 이 글의 예제는 저장소의 examples/000_counter.sv이고, 출력은 실제로 돌린 결과 그대로다.
개발 중인 개인 프로젝트의 기록이라 상용 검증 도구를 대체하지 않고, 본문의 수치와 구현 범위는 글을 쓴 시점 기준이라 이후에 바뀐다.