모든 이야기

논블로킹 대입을 바꿨을 뿐인데 레지스터가 한 단계를 건너뛴다

두 개의 레지스터를 이어 붙였다고 생각했는데, 파형에서는 입력이 두 단계를 한 번에 건너뛴다. 클록 주파수나 배선 지연을 의심하기 전에 대입 기호부터 볼 필요가 있다. SystemVerilog에서 =와 <=의 차이는 글쓰기 취향이 아니다. 논블로킹 대입은 지금의 상태를 읽어 다음 상태를 예약하는 방식이고, 이 구분이 레지스터 사이의 시간 관계를 결정한다.

여기서는 같은 클록으로 동작하는 8비트 레지스터 두 개를 생각하자. 입력은 클록 상승 에지 전에 안정되어 있고, reset은 이미 끝났다고 가정한다. 아래 코드는 설명용 조각이며 실제 프로젝트에서 측정하거나 합성한 결과를 제시하는 것은 아니다.

두 줄이 만드는 두 가지 다음 상태

always_ff @(posedge clk) begin
  q1 <= din;
  q2 <= q1;
end

클록 에지 직전 q1=0, q2=0, din=7이라고 하자. 첫 줄은 q1을 바로 7로 덮어쓰지 않는다. 오른쪽의 din을 평가하고, q1에 7을 반영할 갱신을 예약한다. 두 번째 줄도 아직 이전 값인 q1을 읽으므로 q2에는 0을 예약한다. 예약한 갱신이 반영된 뒤의 상태는 q1=7, q2=0이다.

다음 에지 전에 din을 9로 바꾸면 q1=9, q2=7이 된다. 이를 상태 방정식으로 쓰면 q1_next = din_now, q2_next = q1_now다. q2가 q1의 이전 상태를 받으므로 두 레지스터가 직렬로 이어진 회로에 해당한다. 같은 블록에서 두 줄을 뒤집어 적어도, 서로 다른 목적지에 대한 이 예제의 상태 방정식은 바뀌지 않는다.

이제 두 줄을 모두 blocking 대입으로 바꿔보자.

always @(posedge clk) begin
  q1 = din;
  q2 = q1;
end

같은 초기 조건에서 첫 줄을 실행하면 q1은 즉시 7이 된다. 두 번째 줄은 갱신된 q1을 읽으므로 q2도 7이 된다. 이 블록의 의미는 q1_next = din_now, q2_next = din_now에 가깝다. 두 출력 레지스터가 같은 입력을 받는 구조를 기술한 셈이며, 직렬 두 단계라는 의도를 보존하지 못한다. 실제 합성기는 사용 여부와 최적화에 따라 레지스터를 합치거나 제거할 수도 있다.

blocking 버전에서는 순서를 뒤집으면 다시 결과가 달라진다. q2를 먼저 대입하면 이전 q1을 읽기 때문이다. 코드의 줄 순서가 회로의 시간 관계를 바꾸는 상황이다. lowRISC의 순차 논리 작성 지침이 레지스터 상태에 논블로킹 대입을 사용하도록 요구하는 이유도 이런 모호함을 줄이는 데 있다.

'나중에'는 한 클록 뒤라는 뜻이 아니다

논블로킹 대입을 설명할 때 “나중에 값이 바뀐다”고 말하면 또 다른 오해가 생긴다. q1이 다음 클록까지 0으로 남는다는 뜻이 아니다. 오른쪽 값의 평가와 왼쪽 상태의 갱신이 같은 시뮬레이션 시각 안에서 서로 다른 처리 단계에 놓인다는 뜻이다. q1은 현재 에지의 갱신 단계에서 7이 되고, q2가 읽었던 값은 그 에지 직전의 q1이다.

이 차이는 테스트벤치에서도 드러난다. 클록 에지에 맞춰 곧바로 q1을 읽는 코드가 순차 블록의 갱신보다 먼저 실행되면 이전 값이 보일 수 있다. 설계가 한 클록 늦었다고 결론 내리기 전에 관측 코드가 어느 이벤트 단계에서 실행되는지 확인해야 한다. 테스트벤치의 샘플링을 안정된 관측 단계에 맞추는 일과, 회로 자체의 latency를 바꾸는 일은 별개다.

실리콘에서는 시뮬레이터의 이벤트 큐가 존재하지 않는다. 플립플롭이 에지에서 입력을 샘플링하고, 출력은 물리적인 전파 지연 뒤에 변한다. 논블로킹 대입은 그 지연을 정밀하게 모델링하려는 문법이 아니라, 에지 이전 상태로 다음 상태를 계산하는 동기 회로의 논리를 표현하는 도구다. 그래서 <=를 썼다고 setup이나 hold가 자동으로 만족되지는 않는다.

상태 계산을 눈에 보이게 분리하기

조금 복잡한 제어를 넣는다면 다음 상태와 현재 상태를 나누면 읽기가 쉬워진다.

always_comb begin
  q1_d = q1_q;
  q2_d = q2_q;
  if (advance) begin
    q1_d = din;
    q2_d = q1_q;
  end
end

always_ff @(posedge clk) begin
  q1_q <= q1_d;
  q2_q <= q2_d;
end

조합 블록의 =는 “같은 평가 안에서 다음 상태 계산을 순서대로 진행한다”는 의미로 사용한다. q2_d가 q1_q를 읽는 것은 의도적인 이전 상태 참조다. 만약 q2_d=q1_d라고 쓰면 advance일 때 q2 역시 din을 받게 된다. 두 블록으로 나누었어도 참조 대상이 틀리면 원래의 문제가 돌아온다.

따라서 always_ff라는 이름이나 대입 기호만 확인해서는 충분하지 않다. 각 상태 변수에 대해 다음 상태가 현재의 어느 값으로 결정되는지 적어보는 편이 정확하다. 두 단계 이동이라면 현재 q1이 q2의 입력이어야 한다는 요구가 코드에서도 그대로 보여야 한다.

논블로킹 대입도 충돌을 해결해 주지는 않는다

같은 블록에서 동일한 변수에 논블로킹 대입을 여러 번 예약하면, 실행된 대입 사이의 순서가 여전히 중요하다. 예를 들어 if (clear) count <= 0; 다음에 if (inc) count <= count + 1;이 있고 두 조건이 함께 참이면, 같은 프로세스에서 뒤에 실행한 대입이 최종 갱신을 정한다. “논블로킹이므로 모두 동시에 적용되어 합쳐진다”는 해석은 틀리다.

설계 의도가 clear 우선이라면 if (clear) ... else if (inc) ...로 우선순위를 드러내는 편이 낫다. 서로 다른 프로세스가 같은 상태를 쓰는 문제는 더 나쁘다. 프로세스 실행 순서에 기대는 설계가 될 수 있고, 단일 상태의 소유권도 불명확해진다. Verilator의 BLKSEQ와 다중 드라이버 관련 설명도 순차 대입 방식과 드라이버 충돌을 따로 다룬다. 논블로킹이라는 한 가지 규칙으로 모든 경쟁 상태가 사라지는 것은 아니다.

처음의 레지스터 체인으로 돌아오면 답은 간단해진다. 각 레지스터는 같은 에지 이전의 값을 읽어야 하고, 그 읽기 결과로 현재 에지의 다음 상태를 정해야 한다. 논블로킹 대입은 이 의도를 코드 순서의 우연에서 분리해 준다. 코드 리뷰에서 <=를 발견하는 데서 멈추지 말고 **“이 상태가 읽는 것은 이전 상태인가, 이번에 계산한 다음 상태인가”**를 물으면, 파이프라인을 건너뛰는 버그의 상당 부분이 눈에 보이기 시작한다.