[1/40] RTL 시뮬레이터 개발일지 – 이벤트 구동 시뮬레이션

시작은 소박했다. 맥북에서 Verilog 파일 하나를 그냥 돌려 보고 싶었다. 설치 안내를 30분씩 읽지 않고, 빌드 도구를 세 개씩 깔지 않고, cargo build 한 줄로. 그렇게 만들기 시작한 게 vitamin이고, 1년쯤 지난 지금은 크레이트 17개에 테스트 6,240개짜리 물건이 됐다. 이 연재는 그 안을 뜯어보는 기록이고, 첫 편은 회로를 “실행한다”는 게 무슨 뜻인지에서 시작한다.

맥에서 그냥 돌아가는 시뮬레이터가 갖고 싶었다

하드웨어 설계에 쓰는 상용 시뮬레이터는 사실상 리눅스 전용이다. 라이선스 서버가 필요하고, 개인이 집에서 켜 볼 수 있는 물건도 아니다. 오픈소스 쪽은 대부분 C/C++ 빌드 체인 위에 있어서, 맥에서 처음 세팅하려면 컴파일러와 라이브러리 버전부터 맞춰야 한다. 정작 하고 싶은 건 회로 하나 돌려서 파형을 보는 것뿐인데.

그래서 처음에 세운 제약은 두 줄이었다. C/C++ 의존 없이 순수 Rust로 짜서 저장소를 받아 cargo build 하면 끝날 것. 그리고 맥과 리눅스에서 출력 바이트까지 같은 결과가 나올 것.

언어 후보는 셋이었다.

후보결정적 요인판정
RustGC가 없어 이벤트 루프의 타이밍이 흔들리지 않음, enum과 패턴 매칭이 구문 트리 표현에 그대로 맞음, cargo가 여러 OS 소스 빌드를 기본 지원채택
C / C++이 분야의 검증된 경로(Verilator, Icarus)지만 크로스 플랫폼 빌드 마찰이 애초에 피하려던 바로 그 문제차점
Go빌드는 편하지만 GC 지연이 정밀 시간 모델에 불리하고, 합타입이 없어 구문 트리 표현이 어색해짐부적합

가장 크게 작용한 이유는 이렇다. 시뮬레이터의 버그는 컴파일 에러로 나타나지 않고 잘못된 파형으로 나타난다. 프로그램은 멀쩡히 끝나고 숫자도 그럴듯한데 값 하나가 틀려 있는 식이다. 그런 도구를 만들 때는 언어가 미리 잡아 주는 실수 한 종류가 통째로 사라지는 게 크다.

게이트는 입력이 바뀔 때만 움직인다

실제 회로에서 논리 게이트는 입력이 바뀔 때만 반응하고, 이벤트 구동 시뮬레이터는 이 성질을 그대로 흉내 낸다. 신호가 바뀌면 그 신호를 지켜보던 프로세스만 깨워 값을 다시 계산하고, 더 깨울 것이 없으면 시간을 건너뛰어 다음 사건이 예정된 시각으로 곧장 넘어간다.

게이트는 입력이 바뀔 때만 움직인다 도식

비교 대상은 사이클 단위 시뮬레이터다. 이쪽은 클럭이 한 번 뛸 때마다 회로 전체 상태를 통째로 다시 계산한다. 구현이 단순하고 규칙적인 동기 회로에서는 빠르지만, 클럭 사이에서 벌어지는 일은 표현하지 못한다.

항목사이클 단위이벤트 구동
계산 시점매 클럭마다 전체바뀐 신호에 걸린 것만
비동기 회로표현 못 함표현 가능
클럭 사이 타이밍보이지 않음임의 해상도로 추적
구현 난이도낮음높음 (이 연재가 다루는 쪽)

Keccak 한 번에 Verilator보다 42배 느리다

표만 보면 이벤트 구동이 일방적으로 좋아 보이지만 대신 느리다. 신호 하나가 바뀔 때마다 누가 깨어나야 하는지를 매번 따져야 하고, 그 판단 자체가 비용이다. 같은 암호 연산(Keccak)을 한 번 도는 데 걸린 시간을 재 보면 이렇다.

시뮬레이터한 번당상대
Verilator (컴파일드, 2-state)7.0 µs1배
vitamin (호출 없는 설계)295 µs42배 느림
Icarus Verilog4,470 µs639배 느림

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비트 카운터다.

Verilog
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번 찍는다. 명령은 한 줄이다.

Terminal
$ 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

기호 하나에 신호 하나를 배정하고 값이 바뀐 순간만 기록한다. 안 바뀐 신호는 아예 적지 않으니, 이벤트 구동이라는 사고방식이 저장 포맷에까지 이어져 있는 셈이다. 파형으로 그리면 이렇다.

4비트 카운터를 실제로 돌려 보면 파형 타이밍도

첫 상승 에지에서는 리셋 때문에 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.00배였던 최적화 · 실물 IP로 재보기

언어 기능 확장

  • 예정: 합성 가능한 범위 · 동적 자료형 · 어서션 · 클래스와 제약 랜덤

관찰성과 공개

  • 예정: AI 에이전트가 쓰기 좋은 시뮬레이터 · 0.1.0을 내기까지

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

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

댓글 남기기

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