16비트에 담아도 곱셈이 잘리는 이유, 식 비트폭
8비트 두 수를 곱해 16비트 변수에 넣었다. 최대 결과도 들어갈 공간이 있으니 안전해 보인다. 그런데 식 주위에 중괄호를 하나 추가한 뒤 결과가 65025에서 1로 바뀔 수 있다. 저장 공간은 그대로인데 왜 계산이 달라질까. 식 비트폭은 목적지의 크기만으로 정해지지 않으며, 연산이 놓인 문맥에 따라 계산 단계의 폭도 달라지기 때문이다.
SystemVerilog의 폭 규칙은 “피연산자가 8비트면 무조건 8비트로 계산한다”는 한 문장으로 설명할 수 없다. 어떤 식에는 대입 문맥의 폭이 전파되고, 어떤 연산은 자기 피연산자의 폭을 독립적으로 결정한다. 이 차이를 놓치면 경고를 없애는 수정이 계산의 뜻까지 바꿀 수 있다.
결과를 담을 공간과 계산하는 공간
다음은 폭 규칙을 보기 위한 가상 예제다. a와 b는 unsigned이고 모두 255라고 가정한다.
logic [7:0] a, b;
logic [15:0] direct_product;
logic [15:0] packed_product;
assign direct_product = a * b;
assign packed_product = {a * b};
첫 번째 대입에서 곱셈은 16비트 목적지의 문맥을 받는다. 이 예제의 피연산자는 그 폭으로 확장되어 계산되므로 255×255=65025, 즉 16'hFE01을 담을 수 있다. “8비트끼리 곱했으니 상위 절반이 항상 잘린다”는 해석은 이 코드에 맞지 않는다.
두 번째 식의 중괄호는 단순한 괄호가 아니다. 연결 연산, 즉 concatenation이다. 연결 안의 곱셈은 외부 16비트 목적지와 분리되어 자기 피연산자에 따라 폭이 결정된다. 여기서는 8비트 결과가 된다. 65025의 하위 8비트만 남으면 8'h01이고, 그 뒤 16비트 목적지에 0으로 확장되어 16'h0001이 들어간다. 잘린 값을 넓은 변수로 옮겨도 이미 사라진 상위 비트는 돌아오지 않는다.
이 규칙의 근거는 Sutherland와 Mills의 Standard Gotchas 논문에 정리된 문맥에 따른 폭 결정과 연결 연산의 자기 결정 규칙에서 확인할 수 있다. 위 숫자와 코드는 그 규칙을 설명하기 위해 새로 구성한 것이다.
의도를 폭으로 먼저 적어 놓기
곱셈 결과 전체가 필요하다면 연산에 들어가는 값을 먼저 넓혀 두면 코드의 의도가 선명해진다.
logic [15:0] a_wide, b_wide;
logic [15:0] product;
assign a_wide = {8'h00, a};
assign b_wide = {8'h00, b};
assign product = a_wide * b_wide;
원래 입력의 범위가 0~255라는 조건 아래에서 16비트 결과면 충분하다. 하지만 a_wide와 b_wide가 나중에 실제 16비트 입력으로 바뀌면 최대 곱은 32비트가 필요하다. “피연산자도 16비트로 넓혔다”는 수정은 현재 범위의 의도를 설명할 뿐, 앞으로 모든 범위에 대한 충분성을 보장하지 않는다. 폭과 값의 범위를 함께 관리해야 한다.
반대로 하위 8비트만 쓰는 순환 연산을 의도했다면 잘림은 오류가 아니다. 그 경우에도 전체 곱과 사용 부분을 명시하거나, 인터페이스 문서에 modulo 256 동작을 적어 두는 편이 좋다. 같은 8'h01이 한 설계에서는 정확한 결과이고 다른 설계에서는 치명적인 손실이다. 경고의 유무보다 “어떤 정보를 보존하기로 했는가”가 먼저다.
부호는 폭과 별도의 문제다
비트 수가 충분한데도 음수가 큰 양수로 바뀔 수 있다. 다음 예제에서 signed 값 s는 -4이고, bump는 1이라고 하자.
logic signed [7:0] s;
logic bump;
logic signed [15:0] bad_sum, good_sum;
assign bad_sum = s + bump;
assign good_sum = s + $signed({1'b0, bump});
bad_sum의 더하기에는 unsigned 피연산자가 섞여 있다. 이 식에서는 s의 8비트 패턴 8'hFC가 unsigned 값 252로 취급되고, 목적지의 폭으로 0 확장되어 253이 된다. good_sum에서는 bump를 먼저 양의 signed 값으로 만든다. 앞에 0을 붙였으므로 2비트 signed 01은 +1이며, signed 연산에서 s가 부호 확장되어 -3을 얻는다.
왜 $signed(bump)만 쓰지 않았을까. 1비트 signed에서 비트 1은 -1을 뜻한다. signed라는 이름을 붙였다고 원래 의도한 +1이 되는 것은 아니다. 부호 해석을 바꾸는 것과 부호를 보존하며 폭을 늘리는 것은 다른 작업이다. lowRISC의 signed 연산 지침도 unsigned 값이 섞일 때의 해석 변화를 강조한다.
'경고만 지우는 캐스트'가 위험한 이유
폭 경고를 만나면 결과 전체를 목적지 타입으로 캐스팅하고 끝내고 싶어진다. 하지만 캐스트가 어느 경계에 놓이는지를 확인해야 한다. 이미 좁게 평가된 연결 결과를 넓히는 캐스트라면 손실을 복구할 수 없다. 부호 캐스트도 비트 수를 자동으로 충분하게 만드는 해결책이 아니다.
연결, 비트 선택, 함수의 반환 타입은 식의 정보가 잘리는 경계를 만들 수 있다. 예를 들어 8비트 반환값을 가진 함수를 호출한 뒤 32비트 변수에 넣는다면, 함수 밖에서는 이미 8비트 결과만 보인다. 함수 안의 곱셈을 어떤 폭으로 계산했는지와 무관하게 반환 경계에서 필요한 상위 비트가 없어질 수 있다.
그러므로 긴 식을 볼 때에는 가장 바깥의 선언에서 안쪽으로 폭을 한 번에 추측하기보다, 정보가 넘어가는 경계를 표시하는 편이 낫다. 각 연산의 입력 폭과 signed 여부, 문맥이 전파되는 범위, 잘라 내는 위치를 차례로 확인한다. Verilator의 WIDTH 계열 설명은 확장과 잘림 경고를 나누어 설명하며, 연결에 크기를 알 수 없는 상수를 넣는 문제도 별도로 다룬다. 경고를 하나로 묶어 무시하기 어려운 이유다.
처음의 두 곱셈은 결국 서로 다른 계산 계약을 가진다. 16비트 변수는 결과를 담을 수 있는 공간을 제공하지만, 모든 내부 연산이 그 공간을 사용한다는 약속은 아니다. 식 비트폭을 제대로 읽는다는 것은 변수 선언을 세는 일이 아니라 값이 계산되고 확장되고 잘리는 경로를 따라가는 일이다. 그 경로가 명확해지면 65025가 1이 된 이유도, 그것을 고칠 위치도 함께 보인다.