모든 이야기

always_comb에서도 값이 기억된다: 조합 논리 미할당의 정체

조합 블록에 always_comb를 붙였는데 합성 보고서에 래치가 나타난다. 선언은 조합 논리를 가리키는데 구현은 값을 기억한다니, 도구가 말을 듣지 않은 것처럼 느껴질 수 있다. 그러나 먼저 확인할 것은 선언 이름보다 모든 입력 조건에서 출력에 새 값을 지정했는가다. 조합 논리 미할당은 문법의 빈칸이 시간에 대한 요구로 바뀌는 지점이다.

조합 회로의 출력은 현재 입력만으로 결정되어야 한다. 반면 어떤 조건에서는 “아무것도 하지 않는다”고 기술하면, 시뮬레이션 변수는 이전 값을 그대로 유지한다. 이전 입력의 결과를 기억하려면 저장 상태가 필요하다. 이 행동이 래치를 추론하는 근거가 되거나, 조합 블록에 맞지 않는 코드라는 오류와 경고를 만들 수 있다. 도구가 경고로 계속 진행할지 오류로 멈출지는 설정에 따라 다르다.

빠진 else를 시간 순서로 읽어보기

다음은 8비트 값을 선택하는 설명용 코드다.

always_comb begin
  if (enable)
    selected = data;
end

enable=1, data=8'h35일 때 블록을 평가하면 selected는 0x35다. 이후 enable=0, data=8'hA2로 바꾸면 어떻게 될까. 대입문은 실행되지 않는다. selected는 여전히 0x35다. enable=0인 현재 입력만 보고는 이 값을 알 수 없다. 앞서 enable=1이었던 때의 data를 알아야 한다.

이를 동작 식으로 쓰면 enable=1이면 selected=data, enable=0이면 selected=이전 selected다. enable이 높을 때 입력을 통과시키고 낮을 때 값을 유지하는 저장 동작이다. 의도한 것이 단순한 enable mux였다면 다음처럼 disabled 상태의 값을 정해야 한다.

always_comb begin
  selected = 8'h00;
  if (enable)
    selected = data;
end

이제 selected는 enable=0에서 항상 0이다. 이전 selected를 참조할 이유가 없다. 처음의 코드와 수정한 코드에서 달라진 것은 “else를 채워 넣었다”는 형식보다 비활성 상태의 출력 계약을 정의했다는 점이다. 0이 아니라 다른 입력을 선택해야 한다면 그 값을 지정해야 한다. 기본값은 설계 요구로 정하는 것이지, 래치 경고를 없애기 위해 임의로 넣는 숫자가 아니다.

default가 있어도 다른 신호는 빠질 수 있다

case 문에는 default를 넣었는데도 미할당이 생기는 경우가 있다. 여러 출력 중 일부만 모든 경로에서 정했기 때문이다.

always_comb begin
  case (mode)
    2'd0: begin
      value = a;
      hit   = 1'b1;
    end
    2'd1: value = b;
    default: value = 8'h00;
  endcase
end

value는 모든 case 경로에서 정해진다. 하지만 hit는 mode=0일 때만 대입된다. mode가 0에서 1로 변하면 value는 b로 바뀌고 hit는 1을 유지한다. default가 존재한다는 사실만으로 블록 전체가 완전해지지 않는다. 완전성은 출력 변수마다 따로 확인할 성질이다.

hit의 의미가 “mode=0일 때 선택이 성공했다”라면 블록 맨 위에서 hit=0을 정하고 해당 경로에서만 1로 바꾸면 된다. lowRISC의 case 작성 지침은 이런 공통 기본값을 case 앞에 두는 방식과, 각 분기에서 모든 변수를 대입하는 방식을 설명한다. 중요한 것은 어느 스타일을 선택하든 빠진 경로가 없어야 한다는 점이다.

'유지한다'는 의도는 어느 상태를 유지하는가

상태기계의 조합 블록에서 state_d = state_q를 기본값으로 두는 것을 보고, 출력에서도 selected = selected를 쓰면 된다고 생각하기 쉽다. 둘은 다르다. state_q는 별도의 플립플롭이 가진 현재 상태이며, state_d는 그 플립플롭에 넣을 다음 상태다. 조합 블록은 현재 입력인 state_q를 읽어 state_d를 계산할 뿐이다.

반면 selected=selected는 계산 대상 자체의 이전 값을 요구한다. 빠진 대입을 자기 대입으로 바꾸어도 값의 이력 의존성이 사라지지 않는다. 조합 루프나 저장 추론 문제를 다른 모양으로 옮길 수 있다. hold 동작이 필요하다면 저장을 담당하는 레지스터와 다음 입력을 구분해서 표현하는 편이 명확하다.

always_comb begin
  value_d = value_q;
  if (load)
    value_d = incoming;
end

always_ff @(posedge clk) begin
  value_q <= value_d;
end

이 예제는 load가 낮으면 플립플롭을 유지하는 동기 회로다. 앞의 enable 래치처럼 enable이 높은 시간 내내 투명하게 변하는 회로와는 시간 의미가 다르다. 둘 다 “값을 유지한다”는 한국어 문장으로 설명할 수 있어서, 의도를 코드로 옮길 때 혼동하기 쉽다.

always_comb가 보장하는 것과 맡겨 두지 말아야 할 것

always_comb는 조합 블록에 맞는 감지와 제약을 제공하며 도구가 설계 의도를 검사할 단서가 된다. 그러나 입력별 출력 값을 대신 채워 주는 기능은 아니다. 블록 실행이 끝난 뒤 어떤 변수가 대입되지 않았는지, 그것이 저장 행동을 의미하는지는 여전히 코드에 달려 있다. Yosys의 합성 흐름 설명에서도 프로세스로 기술한 동작을 하드웨어 구조로 낮추는 과정을 확인할 수 있다. 합성은 선언의 이름만 보고 회로를 만드는 작업이 아니다.

조건이 논리적으로 불가능하다고 여기는 경우에도 주의가 필요하다. 정상 운용에서 mode가 두 값만 나온다고 하더라도, reset 직후나 잘못된 제어 값에서 다른 분기가 발생할 수 있다. 정말 불가능하다는 계약이 있다면 그것을 검증 가능한 가정으로 적어야 한다. 조합 출력의 정의를 비워 놓은 뒤 “그 입력은 안 온다”는 기억에만 기대면 시뮬레이션, 형식 검증, 합성의 가정이 서로 어긋나기 쉽다.

반대로 모든 경로에 0을 넣었다고 기능이 맞는 것도 아니다. 오류 응답이 필요한 분기에 0을 조용히 내보내면 래치는 없어지지만 시스템은 실패를 감춘다. 미할당 검사는 회로의 형태를 확인하는 첫 관문이고, 그 뒤에는 각 경로의 값이 요구사항에 맞는지 확인해야 한다.

조합 블록을 읽을 때 입력의 대표 값을 몇 개 넣는 것보다, 출력 하나를 골라 모든 경로를 따라가는 편이 효과적이다. 경로 끝에서 “이 값은 어디에서 정해졌나”에 답할 수 없다면 이전 값이 끼어들 자리가 남아 있다. 처음의 래치 보고서는 도구가 조합 논리를 배신한 흔적이 아니다. 코드가 정의하지 않은 현재 값을, 이전 값으로 메우라고 요구했다는 신호다.

always_comb에서도 값이 기억된다: 조합 논리 미할당의 정체 · ariser