
RTL에서 순차 로직에는 <=를, 조합 로직에는 =를 쓴다. 두 기호가 왜 다른 결과를 내는지, 그 차이를 지키려고 시뮬레이터가 한 시각 안에서 무슨 일을 하는지 vitamin 안쪽에서 들여다본다.
기호 하나만 바꾼 시프트 레지스터
두 단짜리 시프트 레지스터다. 값이 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에서는 = 대입과 연속 대입, 그리고 <=의 오른쪽 평가가 일어난다. #0으로 미뤄 둔 것들은 Inactive에서 돌고, <=의 왼쪽 갱신은 NBA에서 일어난다. 마지막 Monitor에서는 값이 다 정해진 뒤에 $monitor와 $strobe가 돈다.
중요한 건 NBA 칸이 따로 있다는 점이다. <=는 오른쪽 값을 Active에서 읽어 두고 왼쪽 갱신은 NBA까지 미룬다. 그 사이에 누가 같은 신호를 읽으면 아직 옛날 값을 보게 된다.

클럭이 한 번 올라갈 때 벌어지는 일
앞의 시프트 레지스터를 이 순서에 넣어 보면 왜 한 칸씩 흐르는지가 보인다. 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를 읽으니 두 레지스터가 같은 값이 된다. 한 글자 차이로 두 단 시프트 레지스터가 배선 하나가 된 것이다. 플립플롭 체인이 결정적으로 동작하려면 NBA 칸이 반드시 있어야 한다.
#0도 같은 틀로 풀린다. 0만큼 기다리라는 뜻이지만 실제로는 지금 칸을 끝내고 다음 칸에서 이어서 하라는 지시다. 테스트벤치에서 경쟁 상태를 피하려고 종종 쓰는데, 무엇의 뒤로 밀리는지 모르고 쓰면 문제를 옮길 뿐 없애지는 못한다.
$display와 $strobe가 다른 값을 찍은 이유
마지막 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첫 클럭에서 $display는 b=x, $strobe는 b=1을 찍었다. 버그는 아니다. $display는 Active에서 바로 찍으니 NBA 갱신 전이고, $strobe는 Monitor까지 기다렸다가 찍으니 갱신 후다. 파형에는 b=1인데 로그에는 x가 찍혀서 한참 헤매는 일이 여기서 생긴다. 이 출력도 vitamin과 Icarus Verilog가 일치한다.
끝나지 않는 delta cycle은 횟수 한계에서 멈춘다
NBA에서 값이 바뀌면 그 변화가 다른 프로세스를 깨우고, 시뮬레이터는 시각을 올리지 않은 채 Active로 돌아가 다시 돈다. 그림에서 NBA에서 Active로 되돌아가는 화살표가 이것이다. 1편에서 말한 delta cycle이 이 반복이고, 신호가 더는 바뀌지 않아야 다음 시각으로 넘어간다.
곤란한 건 이 루프가 끝나지 않을 때다.
assign a = ~a; // a가 바뀌면 다시 a를 바꾼다시간은 조금도 흐르지 않는데 루프만 계속 돈다. Icarus Verilog는 이걸 자동으로 감지하지 않아서 사용자가 직접 watchdog을 넣어야 한다. vitamin은 delta 횟수를 세다가 한계를 넘으면 멈추고, 시각 0에서 멈췄다는 것까지 남긴다.
fatal[VITA-F4016] F-RUN-NO-CONVERGE: did not converge:
delta limit (1000000) exceeded at time 0
(zero-delay loop / combinational oscillation)같은 칸 안의 순서는 선언 순서로 묶었다
같은 칸 안에서 무엇을 먼저 실행할지는 표준이 정해 주지 않는다. 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에게 도구를 쥐여 주는 것이라 대체로 AI가 돌리고 남긴 리포트를 읽는다.
그런데도 이 글을 쓰다가 내 설계 문서에서 틀린 문장을 하나 찾았다.
이전 편은 산출물 재빌드 판정이고, 다음 편은 조용히 틀리지 않기다. 연재 전체 목차는 1편에 모아 두었다.
코드는 github.com/tjddnr0912/vitamin-rtl-simulator에 있다(MIT / Apache-2.0 듀얼 라이선스). 본문의 실행 결과는 릴리즈 빌드로 직접 돌린 것이다.
개발 중인 개인 프로젝트의 기록이고 상용 검증 도구를 대체하지 않는다. 본문의 구현 범위는 집필 시점(2026-08-30, 0.2.0) 기준이라 이후 바뀐다. 이 프로젝트는 AI와 협업해 개발하고 있다.