이 글을 쓰다가, 내 문서가 틀린 걸 찾았다
오픈소스 RTL 시뮬레이터 만들기 · 5편 — 한 시각 안의 순서

RTL을 쓰는 사람이라면 규칙을 안다. 순차 로직에는 <=를 쓴다. 조합 로직에는 =를 쓴다.
이번 편은 그 규칙을 시뮬레이터 안쪽에서 본다. 두 기호가 왜 다른 결과를 내는지, 그리고 그걸 보장하려고 시뮬레이터가 한 시각 안에서 무슨 일을 하는지.
그리고 이 글을 준비하다가 내가 쓴 설계 문서가 틀렸다는 걸 발견했다. 그 이야기가 뒷부분이다.
같은 회로, 기호 하나 차이
두 단 시프트 레지스터다. 값이 a에서 b로, 다시 c로 한 클럭에 한 칸씩 흘러야 한다.
always_ff @(posedge clk) begin
b <= a;
c <= b;
end
두 줄의 <=를 =로만 바꿔서 각각 돌린 결과다. 나머지는 한 글자도 다르지 않다.
<= (non-blocking) = (blocking)
t a b c t a b c
6 1 1 x 6 1 1 1
16 0 0 1 16 0 0 0
26 0 0 0 26 0 0 0
왼쪽은 1이 b를 거쳐 다음 클럭에 c로 간다. 시프트 레지스터다. 오른쪽은 첫 클럭에 b와 c가 동시에 1이 된다. 두 단이 아니라 한 단이다.
참고로 이 출력은 vitamin과 Icarus Verilog가 양쪽 케이스 모두 완전히 일치한다. 아래에서 설명할 규칙이 특정 시뮬레이터의 사정이 아니라 표준이 정한 것이라는 뜻이다.
한 시각은 하나의 순간이 아니다
지난 편들에서 시뮬레이터가 시간을 뛰어다닌다고 썼다. 그런데 한 시각에 도착한 뒤에도 할 일이 남아 있다. 표준은 하나의 시각을 여러 구간으로 쪼개고, 그 구간에는 순서가 있다.
Verilog 표준이 정한 기본 구간은 넷이다.
- Active —
=대입, 연속 대입, 그리고<=의 오른쪽 평가 - Inactive —
#0으로 미뤄둔 것들 - NBA —
<=의 왼쪽 갱신 - Monitor —
$monitor·$strobe, 값이 다 정해진 뒤
핵심은 세 번째 칸이 따로 있다는 것이다. <=는 오른쪽을 Active에서 읽어두고, 왼쪽 갱신은 NBA까지 미룬다. 그 사이에 누가 같은 신호를 읽으면 아직 옛날 값을 본다.

📊 다이어그램 요약: 한 시각에 도착하면 Active → Inactive → NBA 순으로 돈다. NBA에서 값이 바뀌어 새 이벤트가 생기면 시각을 올리지 않고 Active로 되돌아가 다시 돈다. 큐가 비면 Monitor를 거쳐 다음 시각으로 넘어간다.
클럭 한 번에 실제로 벌어지는 일
앞의 시프트 레지스터를 이 순서에 대입해 보면 왜 한 칸씩 흐르는지가 눈에 보인다. a=1, b=0, c=0인 상태에서 클럭이 올라간다고 하자.
[Active] b <= a → 오른쪽 a(=1)를 읽어둔다. b는 아직 0
c <= b → 오른쪽 b를 읽는다. 지금 b는 0. c는 아직 0
↑ 위 줄을 이미 지났지만 b는 안 바뀌었다
[NBA] b ← 1 읽어둔 값을 이제 쓴다
c ← 0
결과: b=1, c=0 (한 칸 이동)
핵심은 두 번째 줄이 읽는 시점이다. 소스에서는 b <= a 아래에 있지만, 그 대입의 효과는 아직 안 일어났다. 그래서 c는 이번 클럭 이전의 b를 받는다. 플립플롭이 하는 일이 정확히 그것이다.
같은 것을 =로 바꾸면 두 줄 다 Active 안에서 끝난다.
[Active] b = a → b가 즉시 1이 된다
c = b → 방금 바뀐 b(=1)를 읽는다 → c도 1
결과: b=1, c=1 (한 칸이 아니라 통과)
두 코드는 한 글자 차이인데 회로가 다르다. 앞은 두 단 시프트 레지스터이고, 뒤는 사실상 배선 하나다.
그래서 c <= b는 b의 이전 값을 집는다. 위 줄에서 b <= a를 이미 썼더라도 그 갱신은 아직 안 일어났으니까. 한 클럭에 한 칸씩 흐르는 이유가 이것이다.
=는 Active 안에서 즉시 끝난다. b = a가 끝난 뒤 c = b가 이미 바뀐 b를 읽는다. 두 레지스터가 같은 값이 되는 게 당연하다. 플립플롭 체인이 결정적으로 동작하려면 NBA 칸이 반드시 있어야 한다.
두 번째 칸(Inactive)도 한마디 붙일 만하다. #0은 “0만큼 기다린다”는 뜻이라 아무 일도 안 할 것 같지만, 실제로는 지금 칸을 끝내고 다음 칸에서 이어서 하라는 지시다. 시각은 그대로인데 순서만 뒤로 밀린다. 경쟁 상태를 피하려고 테스트벤치에서 종종 쓰는데, 정확히 무엇의 뒤로 밀리는지 모르고 쓰면 문제를 옮기기만 하고 없애지는 못한다. 칸의 순서를 알고 나면 이 기호가 왜 그렇게 동작하는지도 같이 풀린다.
네 번째 칸이 있는 이유
마지막 칸(Monitor)은 있으나 마나 해 보이지만, 이것도 직접 보면 바로 납득된다. 같은 always 블록 안에서 $display와 $strobe로 똑같은 신호를 똑같은 시각에 찍어 봤다.
$display : t=5 a=1 b=x
$strobe : t=5 a=1 b=1
$display : t=15 a=1 b=1
$strobe : t=15 a=1 b=1
첫 클럭에서 한쪽은 b=x, 다른 쪽은 b=1이다. 버그가 아니다. $display는 Active에서 즉시 찍히니 아직 NBA 갱신 전이고, $strobe는 Monitor까지 기다렸다가 찍히니 갱신 후다.
파형에는 b=1로 보이는데 로그에는 x로 찍혀서 한참 헤매는 상황이 여기서 나온다. 로그가 거짓말을 한 게 아니라 다른 칸에서 찍은 것이다. 이 출력 역시 vitamin과 Icarus Verilog가 일치한다.
네 칸을 한 번만 도는 게 아니다
한 가지가 더 있다. NBA에서 값이 바뀌면 그 변화가 또 다른 프로세스를 깨운다. 그러면 시뮬레이터는 시각을 올리지 않고 Active로 돌아가 다시 돈다. 시계는 멈춰 있는데 계산만 도는 이 반복을 delta cycle이라고 부른다.
조합 논리가 여러 단으로 이어져 있으면 한 시각 안에서 이 루프가 몇 바퀴 돈다. 신호가 더 안 바뀌면 그 시각이 안정된 것이고, 그때야 다음 시각으로 넘어간다.
문제는 안 끝나는 경우다.
assign a = ~a; // a가 바뀌면 다시 a를 바꾼다
시계는 1도 안 흐르는데 루프만 영원히 돈다. 도구마다 대응이 갈리는 자리이고, Icarus Verilog는 자동 감지가 없어서 사용자가 직접 watchdog을 넣어야 한다. vitamin은 delta 횟수를 세다가 한계를 넘으면 멈춘다.
fatal[VITA-F4016] F-RUN-NO-CONVERGE: did not converge:
delta limit (1000000) exceeded at time 0
(zero-delay loop / combinational oscillation)
시각 0에서 멈췄다는 것까지 말해 준다. 매달리는 대신 왜 못 끝냈는지를 남기고 죽는다.
표준이 준 자유를 반납했다
여기서 표준에 재밌는 구멍이 하나 있다. 같은 칸 안에서의 실행 순서는 표준이 정해주지 않는다. Active에 프로세스 셋이 들어 있으면 어떤 걸 먼저 돌리든 표준 위반이 아니다.
vitamin은 그 자유를 쓰지 않는다. 선언 순서로 고정한다. 그래야 리눅스와 맥에서 바이트까지 같은 출력이 나오기 때문이다. 표준이 허용한 재량을 스스로 반납한 셈이다.
고민이 없지는 않았다. 가장 강력한 진실은 표준이다. 표준이 자유를 준 자리를 내가 막아도 되는가. 그런데 처음에 “어디서나 똑같이 돌게 하자”고 했던 생각은 시간이 갈수록 오히려 의구심이 들었다 — 그 약속을 지키는 값이 생각보다 비쌌기 때문이다.
그 값을 치러 보고 나서야 알았다. EDA 회사들이 OS별로 지원 여부를 따지는 데는 이유가 있었다.
열일곱 개 중 여섯 개만 만들었다
SystemVerilog로 오면 칸이 넷에서 열일곱 개로 늘어난다. 어서션, program block, 외부 인터페이스 콜백을 끼워 넣을 자리가 필요해서다.
vitamin은 열일곱 개를 다 만들지 않았다. 기본 네 칸을 코어로 두고 두 개(Observed·Reactive)를 더 얹어 여섯 개로 간다. 어서션은 칸을 늘리는 대신 클럭 달린 검사기로 바꿔서 네 칸짜리 코어 위에서 돌게 했다.
이건 타협이면서 설계다. 필요한 동작은 여섯 칸으로 낼 수 있었고, 열일곱 칸을 전부 짓는 값은 그 시점에 치를 이유가 없었다. 다만 영구히 안 만들겠다는 뜻은 아니다. 언젠가는 다 만들 것이다.
그런데 확인해보니 문서가 틀려 있었다
이 글을 쓰면서 남은 칸을 정리하다가 내 설계 문서에서 이 문장을 봤다.
Preponed 리전(clocking block 입력 샘플링)은 아직 부재 — clocking block은 그래서 NO-GO.
“그럼 이번 기회에 만들어야겠다”고 생각하고, 확인차 clocking block을 하나 써서 돌려봤다.
t d q | cb.d cb.q
5 1 0 | 1 0
15 0 1 | 0 1
25 1 0 | 1 0
돈다. 그것도 정확하게 — clocking 이벤트에서 읽은 값이 엣지 직전 값으로 잡힌다. 없다던 그 샘플링이 하고 있어야 할 일을 정확히 하고 있었다.
안 되는 건 따로 있었다. skew를 #1step 말고 다른 형태로 쓰면 거부한다. 그런데 그 거부가 조용하지 않다.
error[VITA-E3009] clocking skew `#1` on `q` is unsupported in this subset
(`#1step` is the only accepted skew ... `#0`/`#N`/`##N` need a
different sampling region — follow-on slice)
진단이 남은 작업을 스스로 지목한다. 이 시리즈에서 계속 이야기한 “조용히 틀리느니 시끄럽게 멈춘다”가 여기서도 지켜지고 있었다.
즉 빠진 것은 코드가 아니라 지도였다. 기능은 들어와 있었고, 그 사실을 문서가 따라가지 못한 채 몇 달을 “없음”으로 남아 있었다. 나는 이 글을 쓰기 전까지 그걸 몰랐다.
왜 이렇게 됐는지는 짐작이 간다. 시간이 지나면서 이런저런 문제가 우후죽순 발견되고, 그걸 고치다 보면 고친 사실을 문서에 반영하는 단계가 빠진다. 코드는 앞으로 가고 지도는 그 자리에 남는다.
그래서 이 글을 계기로 한 일은 구현이 아니라 문서 수정이다. 그리고 코드 쪽에 실제로 부족한 게 뭔지는 따로 다시 점검할 생각이다 — 한 군데가 이랬다면 다른 데도 그럴 수 있으니까.
규칙을 알고 시작했는데도
이 편에는 다른 편들과 달리 “당해서 배웠다”는 이야기가 없다. =와 <= 때문에 시간을 날린 기억이 나에게는 없다. 이 규칙들은 알고 시작했다. 설계 룰은 몸에 익어 있었고, 그 지식을 바탕으로 이 시뮬레이터를 견고하게 만들고 있다. 지식이 도구에서 나온 게 아니라 도구로 들어간 쪽이다.
덧붙이면 요즘 나는 이 도구로 파형을 들여다보며 디버깅하지 않는다. 이 프로젝트의 목적 자체가 AI에게 도구를 쥐여 주는 것이라서, 나는 대체로 그들이 돌리고 남긴 리포트를 읽는다.
그런데도 이 글을 쓰다가 틀린 문장을 하나 찾았다. 규칙을 다 알고 시작해도, 도구를 직접 만들고 있어도 그렇다.
개발 중간에는 새로운 수정이 계속 생긴다.
그러니 항상 계획 대비 놓친 게 있는지 꼼꼼히 살펴보는 수밖에 없다. 이번엔 놓친 것이 기능이 아니라 기능이 있다는 사실이었다.
📚 시리즈 — ← 4편 · 다시 돌렸더니 로그가 몇 줄 만에 끝났다 · 시리즈 목차
🔗 코드 — github.com/tjddnr0912/vitamin-rtl-simulator (MIT / Apache-2.0 듀얼 라이선스). 본문의 실행 결과는 릴리즈 빌드로 직접 돌린 것이고, 시프트 레지스터 출력은 Icarus Verilog와 양쪽 케이스 모두 일치한다.
개발 중인 개인 프로젝트의 기록입니다. 상용 검증 도구를 대체하지 않으며, 본문의 구현 범위는 집필 시점(2026-08-30 · 0.2.0) 기준으로 이후 변경됩니다. 이 프로젝트는 AI와 협업해 개발하고 있습니다.