Chapter 09

M 확장: 곱셈과 나눗셈

RV32I에는 곱셈 명령이 없다. 덧셈과 시프트만으로도 곱셈을 흉내 낼 수 있기 때문이다. 하지만 실제 프로그램에서 곱셈과 나눗셈은 배열 주소 계산, 해시, 고정소수점 연산 등 곳곳에 나온다. M 확장은 정수 곱셈 4개와 나눗셈·나머지 4개, 단 8개 명령으로 이 빈자리를 채운다. 명령 수는 적지만 “곱의 위쪽 절반은 어떻게 얻나”, “0으로 나누면 어떻게 되나”처럼 따져 볼 규칙이 많다. 이 장에서는 그 규칙과 이유를, 그리고 그 명령을 실제로 계산하는 회로를 직접 돌려 보며 익힌다.

곱셈은 왜 베이스가 아니라 확장인가

2장에서 본 것처럼 RISC-V는 꼭 필요한 것만 베이스에 넣고 나머지는 확장으로 뺀다. 곱셈과 나눗셈이 그 첫 번째 대상이다. 덧셈기는 수백 게이트면 되지만, 한 사이클에 32×32비트를 곱하는 곱셈기는 그보다 수십 배 크고, 나눗셈기는 속도까지 느리다. 센서 값을 읽어 넘기기만 하는 작은 마이크로컨트롤러에 이 회로를 억지로 넣으면 칩 면적과 전력이 낭비된다.

그래서 RISC-V는 곱셈·나눗셈을 M 확장(Integer Multiplication and Division, v2.0)으로 분리했다. M이 없는 코어에서 컴파일러는 곱셈을 덧셈·시프트로 이루어진 라이브러리 함수 호출로 바꾼다. 예를 들어 GCC는 -march=rv32i에서 곱셈을 libgcc의 __mulsi3 호출로, 나눗셈을 __divsi3 호출로 만든다. 같은 프로그램이 M이 있는 코어에서는 명령 하나로 끝난다.

M 확장 v2.0 — 명령 8개 (RV64는 W 명령 5개를 더한다) 곱셈 = Zmmul v1.0 과 같은 부분 mul · mulh · mulhsu · mulhu RV64 추가: mulw 작은 코어도 감당할 만한 회로 나눗셈 · 나머지 div · divu · rem · remu RV64 추가: divw · divuw · remw · remuw 반복형 회로 · 수십 사이클이 흔하다 베이스 정수 ISA (RV32I · RV64I) 덧셈 · 시프트 · 분기만으로도 곱셈과 나눗셈을 소프트웨어로 흉내 낼 수 있다
그림 9-1. M 확장의 구성. 왼쪽 곱셈 부분만 따로 떼어 낸 것이 Zmmul이다. M을 구현하면 Zmmul은 저절로 포함된다(“M implies Zmmul”). 두 부분 모두 베이스 ISA의 정수 레지스터를 그대로 쓰므로 새 레지스터나 새 상태가 없다.
규격 원문에서
“We separate integer multiply and divide out from the base to simplify low-end implementations, or for applications where integer multiply and divide operations are either infrequent or better handled in attached accelerators.”

Volume I, “M” Extension v2.0 — 저가형 구현을 단순하게 하고, 곱셈·나눗셈이 드물거나 별도 가속기에 맡기는 편이 나은 응용을 위해 베이스에서 뺐다는 설명이다.

다른 ISA는 어떻게 했나

예전 MIPS는 곱셈·나눗셈 결과를 일반 레지스터가 아닌 HI/LO라는 특수 레지스터에 남기고 mfhi/mflo로 꺼내 쓰게 했다. 숨은 상태가 하나 더 생기는 셈이다. RISC-V는 모든 결과를 일반 정수 레지스터 rd에 바로 쓴다. 대신 상위·하위 절반을 따로 얻는 두 명령을 두었다. 이 선택이 아래에서 볼 “명령 융합” 권고로 이어진다.

인코딩: OP 옵코드와 funct7 = 0000001

M 확장 명령은 모두 4장의 R 형식이다. 옵코드는 add·sub와 같은 OP(0110011)를 쓰고, funct7 칸에 0000001을 넣어 “이건 곱셈·나눗셈 무리”라고 표시한다. 베이스 정수 명령은 funct7로 0000000(대부분)과 0100000(sub, sra)만 쓰므로 빈 자리에 깔끔하게 들어간다. 그다음 funct3 3비트가 8개 명령 중 하나를 고른다.

3125 2420 1915 1412 117 60 0000001 funct7 = MULDIV rs2 둘째 피연산자 rs1 첫째 피연산자 funct3 연산 rd 결과 0110011 OP (W 명령은 0111011) funct3 (비트 14–12) 가 고르는 명령 000mul 001mulh 010mulhsu 011mulhu 100div 101divu 110rem 111remu funct3[2] = 0 → 곱셈 funct3[2] = 1 → 나눗셈 · 나머지 mul a0, a1, a2 = 0000001 01100 01011 000 01010 0110011 = 0x02c58533
그림 9-2. M 확장 명령의 비트 배치. 레지스터 칸 위치는 다른 R 형식 명령과 똑같고, funct7과 funct3만 다르다. 나눗셈 쪽에서는 funct3[0]이 1이면 부호 없음, funct3[1]이 1이면 나머지라는 규칙도 보인다. 맨 아래 예시 인코딩은 LLVM 어셈블러 출력으로 확인한 값이다.
명령옵코드funct3동작비고
mulOP000rd ← (rs1 × rs2)의 하위 XLEN비트부호 여부와 무관
mulhOP001rd ← (rs1 ×s rs2)의 상위 XLEN비트부호 × 부호
mulhsuOP010rd ← (rs1 ×s rs2 u)의 상위 XLEN비트rs1 부호 있음, rs2 부호 없음
mulhuOP011rd ← (rs1 ×u rs2)의 상위 XLEN비트무부호 × 무부호
divOP100rd ← rs1 ÷ rs2 (부호 있음, 0 쪽으로 버림)
divuOP101rd ← rs1 ÷ rs2 (부호 없음)
remOP110rd ← div의 나머지부호는 피제수를 따름
remuOP111rd ← divu의 나머지
mulwOP-32000하위 32비트끼리 곱한 결과의 하위 32비트를 부호 확장RV64 전용
divw · divuwOP-32100 · 101하위 32비트끼리 나눈 32비트 몫을 부호 확장RV64 전용
remw · remuwOP-32110 · 11132비트 나머지를 부호 확장RV64 전용

RV64의 W 명령은 OP-32(0111011) 옵코드에서 같은 funct7·funct3 값을 쓴다. 32비트 곱의 상위 절반을 돌려주는 mulhw 같은 명령은 없다. RV64에서는 64비트 mul 하나로 32×32비트 곱 전체(64비트)를 얻을 수 있기 때문이다.

곱셈: 2×XLEN 비트 곱의 위와 아래

32비트 두 수를 곱하면 결과는 최대 64비트가 된다. 레지스터는 32비트이므로 결과를 한 번에 담을 수 없다. RISC-V는 곱을 둘로 잘라 하위 절반은 mul, 상위 절반은 mulh·mulhu·mulhsu로 따로 얻게 했다. C의 int 곱셈처럼 결과가 원래 폭에 들어간다고 가정하는 대부분의 코드는 mul 하나로 충분하다.

rs1 XLEN비트 × rs2 XLEN비트 곱셈기 결과는 2×XLEN비트 상위 XLEN비트 곱의 [2·XLEN−1 : XLEN] 하위 XLEN비트 곱의 [XLEN−1 : 0] mulh (부호×부호) · mulhu (무부호×무부호) mulhsu (rs1 부호 × rs2 무부호) mul 부호 해석과 상관없이 같은 값
그림 9-3. 곱의 두 절반. 하위 절반은 피연산자를 부호 있는 수로 보든 없는 수로 보든 비트가 똑같다. 그래서 mul은 하나뿐이고, 해석이 달라지는 상위 절반만 세 가지 명령으로 나뉜다.
왜 하위 절반은 부호와 상관없을까

2의 보수에서 부호 있는 수 a와 부호 없는 수 a′는 232의 배수만큼 차이 난다. 곱하면 그 차이는 232의 배수로만 남으므로 하위 32비트에 아무 영향을 주지 못하고, 상위 32비트만 달라진다. 덧셈·뺄셈에 부호 있는 판과 없는 판이 따로 없는 것과 같은 이유다.

MULHSU는 어디에 쓰나

부호 있는 수 × 부호 없는 수라는 조합은 어색해 보이지만, 여러 워드로 된 큰 부호 있는 수(예: 128비트 정수)를 곱할 때 꼭 필요하다. 가장 높은 워드에는 부호 비트가 들어 있고, 아래 워드들은 부호 없는 숫자 조각이다. 규격도 “다중 워드 부호 있는 곱셈에서 부호 비트가 있는 최상위 워드와 부호 없는 아래 워드를 곱하는 데 쓴다”고 설명한다.

상위·하위를 함께 구할 때의 권장 순서

64비트 곱 전체가 필요하면 두 명령을 연달아 쓴다. 규격은 순서까지 권한다. 상위 절반 명령을 먼저, 같은 소스 레지스터를 같은 순서로, 그리고 상위 결과 레지스터(rdh)는 rs1·rs2와 달라야 한다.

mulhu t1, a0, a1 # 상위 32비트 (rdh = t1 은 a0, a1 과 다르다) mul t0, a0, a1 # 하위 32비트 (소스 순서 a0, a1 그대로)

이 모양을 지키면 마이크로아키텍처가 두 명령을 알아보고 명령 융합(macro-op fusion)으로 곱셈을 한 번만 할 수 있다. rdh가 rs1과 같으면 첫 명령이 a0을 덮어써서 둘째 mul이 엉뚱한 값을 곱하게 되므로, 이 조건은 융합 이전에 정확성의 문제이기도 하다.

명령어 흐름 mulhu t1, a0, a1 mul t0, a0, a1 디코더 두 명령을 하나로 묶음 곱셈 1회 상위 → t1 하위 → t0 융합 조건: ① 두 명령의 rs1 · rs2 가 같은 순서 ② rdh(t1) ≠ rs1 · rs2 ③ 바로 붙여 쓴다 소스 순서만 다르면 결과는 맞지만 융합되지 않는다. rdh 가 rs1 · rs2 와 같으면 둘째 mul 이 덮어쓴 값을 읽어 결과가 틀린다.
그림 9-4. 명령 융합. 단순한 코어는 두 명령을 따로 실행해도 되고, 성능을 원하는 코어는 둘을 한 번의 곱셈으로 처리한다. ISA는 그대로 두고 소프트웨어 관례만으로 구현에게 기회를 준 예다. 나눗셈도 같은 방식으로 div[u] rdq, rs1, rs2 다음에 rem[u] rdr, rs1, rs2(rdq ≠ rs1·rs2)를 쓰도록 권한다.
SIMULATOR

두 명령으로 64비트 곱 조립하기

① 상위 절반
mulhu t1, a0, a1
—
② 하위 절반
mul t0, a0, a1
—
합친 64비트 곱 (상위 레지스터 : 하위 레지스터)
t1 · [63:32]
—
t0 · [31:0]
—
해석
상위 결과 레지스터 rdh
보기
기대한 곱 (10진)—
에뮬레이터 결과—
해석을 바꾸면 하위 절반은 그대로이고 상위 절반만 바뀌는 것을 확인하자. 그다음 rdh를 a0로 바꿔 보자. 첫 명령이 rs1을 덮어쓰는 바람에 둘째 mul이 엉뚱한 수를 곱해 하위 절반이 빨갛게 틀어진다. 이 결과는 riscv.js 에뮬레이터로 두 명령을 실제로 실행해서 얻은 것이다.

나눗셈: 0 쪽으로 버리고, 나머지는 피제수의 부호

div와 divu는 XLEN비트를 XLEN비트로 나누며 0 쪽으로 버림(round towards zero)한다. rem·remu는 그 나눗셈의 나머지다. 0으로 나누기와 오버플로만 빼면 언제나 다음 식이 성립한다.

피제수 = 제수 × 몫 + 나머지,  그리고 0이 아닌 rem의 부호 = 피제수의 부호

−5 −4 −3 −2 −1 0 1 2 3 4 5 −7 ÷ 2 = −3.5 RISC-V div: −3 (0 쪽으로 버림, C와 같다) 내림(floor): −4 rem: −7 − 2 × (−3) = −1 나머지 부호 = 피제수(−7)의 부호 내림 방식: −7 − 2 × (−4) = 1 Python 의 // 와 % 가 이 방식
그림 9-5. 같은 −7 ÷ 2라도 몫을 어느 쪽으로 버리느냐에 따라 몫과 나머지가 달라진다. RISC-V의 div/rem은 C99 이후 C·C++의 /·%와 같은 규칙이므로 컴파일러가 명령 하나로 옮길 수 있다.
식div 몫rem 나머지확인: 제수 × 몫 + 나머지
7 ÷ 2312 × 3 + 1 = 7
−7 ÷ 2−3−12 × (−3) + (−1) = −7
7 ÷ −2−31(−2) × (−3) + 1 = 7
−7 ÷ −23−1(−2) × 3 + (−1) = −7
Python으로 검산할 때 주의

Python의 -7 // 2는 −4, -7 % 2는 1이다. RISC-V(그리고 C)의 결과 −3, −1과 다르다. 언어마다 나눗셈 규칙이 다르므로, 내림 규칙을 쓰는 언어를 RISC-V로 옮기는 컴파일러는 몫과 나머지를 한 번 보정하는 코드를 더 넣는다.

0으로 나누기와 오버플로: 트랩 없이 정해진 값

나눗셈에는 수학적으로 답이 없거나 답이 레지스터에 들어가지 않는 경우가 두 가지 있다. 많은 ISA는 이때 예외를 일으키지만(x86의 DIV는 0으로 나누면 #DE 예외), RISC-V는 트랩을 일으키지 않고 정해진 값을 돌려준다.

조건피제수제수DIVU[W]REMU[W]DIV[W]REM[W]
0으로 나누기x02L−1x−1x
오버플로 (부호 있음만)−2L−1−1––−2L−10

L은 연산 폭이다. div[u]·rem[u]는 XLEN, RV64의 W 명령은 32다. RV32에서 구체적인 값으로 보면 divu x, 0 = 0xffffffff, div 0x80000000, -1 = 0x80000000이다.

div · divu · rem · remu 제수 = 0 ? 예 몫 = 모든 비트 1 나머지 = 피제수 아니오 부호 있음, 피제수 = −2L−1, 제수 = −1 ? div · rem 에서만 확인 예 몫 = 피제수 나머지 = 0 보통 나눗셈 (0 쪽으로 버림) 어느 길로 가도 트랩은 일어나지 않고 rd 에 값이 써진다 L = XLEN (W 명령은 32)
그림 9-6. 나눗셈 결과를 정하는 순서. 모든 경우에 rd에 값이 써지고 다음 명령으로 넘어간다. 파이프라인 입장에서는 “예외를 낼 수 있는 산술 명령”이 하나도 없다는 뜻이기도 하다(8장의 정밀 예외 참고).
규격 원문에서
“We considered raising exceptions on integer divide by zero, with these exceptions causing a trap in most execution environments. However, this would be the only arithmetic trap in the standard ISA … The value of all bits set is returned for both unsigned and signed divide by zero to simplify the divider circuitry.”

Volume I, “M” Extension · Division Operations — 표준 ISA에 산술 트랩이 이것 하나만 생기는 것을 피했고, “모든 비트 1”은 단순한 부호 없는 나눗셈 회로가 자연스럽게 내놓는 값이라 회로가 쉬워진다고 설명한다.

언어가 “0으로 나누면 즉시 예외”를 요구한다면 어떻게 할까? 규격의 답은 간단하다. 나눗셈 뒤에 분기 하나만 넣으면 된다. 이 분기는 거의 언제나 성립하지 않으므로 분기 예측기가 쉽게 맞히고, 비용은 매우 작다.

div a0, a1, a2 # 먼저 나눈다 (a2 = 0 이어도 트랩 없음) beqz a2, divide_by_zero # 거의 항상 성립하지 않는 분기
ISA정수 0으로 나누기최소 음수 ÷ −1
x86-64 (DIV/IDIV)#DE 예외 (트랩)#DE 예외 (트랩)
Arm A64 (SDIV/UDIV)결과 0, 트랩 없음결과 = 피제수
RISC-V (DIV/DIVU)몫 모든 비트 1, 나머지 = 피제수, 트랩 없음몫 = 피제수, 나머지 0
SIMULATOR

RV32 M 명령 계산기

예제
특수 경우—
riscv.js 에뮬레이터와 비교—
“0으로 나누기”를 누르면 div는 −1, divu는 0xffffffff로 비트가 똑같고 두 나머지는 피제수 그대로임을 확인하자. “오버플로”에서는 divu는 평범한 결과를 내지만 div만 특수 경우가 된다. “−1 × −1”에서는 mul이 같아도 mulh(0)와 mulhu(0xfffffffe)가 갈린다. 각 결과는 같은 입력을 riscv.js 에뮬레이터로 실행한 값과도 대조한다.

RV64의 W 명령과 Zmmul

W 명령: 32비트 결과를 부호 확장

6장에서 본 addw와 같은 원리로, RV64의 mulw·divw·divuw·remw·remuw는 64비트 레지스터의 하위 32비트만 읽어 계산하고, 32비트 결과를 64비트로 부호 확장해 쓴다. C의 int가 32비트인 LP64 환경에서 int 연산을 명령 하나로 처리하기 위해서다. divuw처럼 부호 없는 연산도 결과는 부호 확장한다는 점, 그리고 remw·remuw는 0으로 나눌 때(나머지 = 피제수의 하위 32비트)도 부호 확장한다는 점을 기억하자.

rs1 (64비트) rs1[63:32] — 읽지 않음 rs1[31:0] rs2 (64비트) rs2[63:32] — 읽지 않음 rs2[31:0] 32 × 32 곱의 하위 32비트 rd (64비트) 곱[31] 을 32번 복사 (부호 확장) 곱[31:0] 비트 31 복사
그림 9-7. RV64 mulw. 상위 32비트에 무엇이 들어 있든 결과에 영향이 없다. 결과는 항상 “32비트 값을 부호 확장한 64비트” 꼴이므로, 다음 W 명령이 그대로 받아 쓸 수 있다.

Zmmul: 곱셈만 있는 M

Zmmul(v1.0)은 M에서 곱셈 명령(mul, mulh, mulhu, mulhsu, RV64의 mulw)만 떼어 낸 확장이다. 인코딩은 M과 완전히 같다. 마이크로컨트롤러 프로그램에서 곱셈은 흔하지만 나눗셈은 드물어서, 나눗셈기 면적을 아끼고 싶은 코어에 알맞다. 특히 FPGA는 칩 안에 곱셈 전용 블록(DSP 슬라이스)이 박혀 있어 곱셈기는 사실상 공짜지만, 나눗셈기는 일반 논리 소자로 지어야 해서 비싸다.

구성곱셈 명령나눗셈 명령나눗셈은 어떻게?
M 없음 (RV32I)없음 → __mulsi3 호출없음 → __divsi3 등 호출소프트웨어
Zmmul있음없음 (명령을 쓰면 잘못된 명령)소프트웨어
M있음있음하드웨어

코드 크기를 아끼는 13장의 Zcb 확장에는 16비트 c.mul도 있다. c.mul은 M 또는 Zmmul이 있어야 쓸 수 있다.

상수 나눗셈은 곱셈으로 바뀐다

나눗셈은 곱셈보다 몇 배 느리다. 그래서 컴파일러는 제수가 상수이면 나눗셈 대신 “역수의 근삿값을 곱하고 시프트”하는 코드를 만든다(Granlund·Montgomery 1994). 예를 들어 32비트 부호 없는 x / 10은 mulhu로 0xCCCCCCCD를 곱한 상위 절반을 3비트 오른쪽으로 시프트한 값과 모든 x에서 같다. mulhu가 존재하는 큰 이유 중 하나다.

lui t0, 0xccccd addi t0, t0, -819 # t0 = 0xcccccccd mulhu a0, a0, t0 # (a0 × 0xcccccccd) 의 상위 32비트 srli a0, a0, 3 # 2^35 / 0xcccccccd ≈ 10 이므로 결과는 a0 / 10

곱셈기 하드웨어: shift-add에서 월리스 트리까지

손으로 하는 곱셈을 2진수로 하면 아주 간단하다. 승수의 각 비트가 1이면 피승수를 그 자리만큼 왼쪽으로 민 값을 더하고, 0이면 건너뛴다. 이것을 한 사이클에 한 비트씩 하면 덧셈기 하나와 레지스터 몇 개로 끝나는 반복형 shift-add 곱셈기가 된다. 대신 XLEN비트 곱에 XLEN 사이클 정도가 걸린다.

ANIMATION

8비트 shift-add 곱셈기

실행
반복0 / 8
곱 P0
덧셈 횟수0
승수 Q의 최하위 비트(금색 테두리)가 1일 때만 피승수 M이 P에 더해진다. M은 매번 왼쪽으로, Q는 오른쪽으로 한 칸씩 밀린다. 승수를 255(비트 8개가 모두 1)로 두면 덧셈이 8번, 128로 두면 1번만 일어난다. 반복 횟수는 언제나 8번이다.

같은 알고리즘을 RV32I 명령으로 쓰면 아래와 같다. M 확장이 없을 때 라이브러리 함수가 하는 일이 바로 이것이다. 루프 한 바퀴에 명령 6~7개가 실행되니, 하드웨어 곱셈기가 왜 반가운지 알 수 있다.

li t0, 0 # 곱을 모을 곳 loop: andi t1, a1, 1 # 승수의 최하위 비트 beqz t1, skip add t0, t0, a0 # 비트가 1이면 피승수를 더한다 skip: slli a0, a0, 1 # 피승수는 왼쪽으로 srli a1, a1, 1 # 승수는 오른쪽으로 bnez a1, loop # 남은 1 비트가 없으면 끝 mv a0, t0

더 빠르게: 배열, Booth, 월리스 트리

고성능 코어는 한 비트씩 기다리지 않는다. 부분곱 XLEN개를 한꺼번에 만들어 더하는데, 그 더하는 방법이 속도와 면적을 가른다.

(a) 반복형 shift-add M ≪ Q ≫ + LSB P (곱 누적) 덧셈기 1개 XLEN 사이클 정도 (b) 배열 곱셈기 부분곱 한 줄마다 덧셈기 한 줄 가산 셀 XLEN² 개 수준 지연 ∝ XLEN (c) Booth + 월리스 트리 CSA CSA CSA 빠른 덧셈기 부분곱 절반 (기수 4 Booth) 지연 ∝ log XLEN · 면적 큼
그림 9-8. 곱셈기 구조 세 가지. (a)는 시간을, (c)는 면적을 많이 쓴다. 응용 프로세서는 보통 (c) 계열을 파이프라인으로 나눠 몇 사이클의 지연으로 매 사이클 새 곱셈을 시작하고, 아주 작은 마이크로컨트롤러는 (a)를 쓰거나 아예 Zmmul조차 빼기도 한다.
구현 방식면적지연 (XLEN비트)어울리는 곳
반복형 shift-add (1비트/사이클)덧셈기 1개 + 레지스터XLEN 사이클 정도초소형 마이크로컨트롤러
반복형 기수-4 Booth (2비트/사이클)조금 더 큼XLEN/2 사이클 정도작은 코어
배열 곱셈기가산 셀 XLEN² 개 수준한 사이클(긴 경로) 또는 나눠서 파이프라인중간 성능
Booth + 월리스 트리, 파이프라인큼몇 사이클 지연, 매 사이클 새 곱셈 시작응용·서버 프로세서
FPGA DSP 블록칩에 이미 있음1–몇 사이클FPGA 소프트 코어 (Zmmul이 잘 맞음)

나눗셈기 하드웨어: 복원 나눗셈

나눗셈은 곱셈처럼 병렬로 펼치기 어렵다. 몫의 다음 비트를 정하려면 앞 단계의 나머지가 있어야 하기 때문이다. 그래서 대부분의 코어는 반복형 나눗셈기를 쓰고, 나눗셈은 곱셈보다 훨씬 오래 걸린다. 가장 기본이 되는 방법이 손으로 하는 긴 나눗셈과 똑같은 복원 나눗셈(restoring division)이다.

① R:Q 를 왼쪽으로 1비트 ② 빼 본다 R ← R − D R < 0 ? 예 ③ 복원: R ← R + D q = 0 아니오 q = 1 (뺀 값 유지) XLEN 번 반복 → Q = 몫, R = 나머지
그림 9-9. 복원 나눗셈 한 단계. 나머지 R과 피제수 Q를 한 덩어리로 왼쪽으로 밀고, 제수 D를 빼 본다. 음수가 되면 “빼면 안 됐다”는 뜻이므로 다시 더해 되돌리고(복원) 몫 비트를 0으로, 아니면 1로 정한다. 복원 단계를 없앤 비복원 나눗셈, 한 번에 여러 비트를 정하는 SRT 나눗셈은 이 방법을 다듬은 것이다.
ANIMATION

8비트 복원 나눗셈기

실행
반복0 / 8
몫 Q—
나머지 R—
제수를 0으로 두고 끝까지 돌려 보자. 0을 빼면 절대 음수가 되지 않으니 몫 비트가 전부 1이 되어 몫 = 255(모든 비트 1), 나머지 = 피제수가 된다. 규격이 0으로 나누기의 결과를 “모든 비트 1, 나머지는 피제수”로 정한 이유가 바로 이것이다. 단순한 나눗셈 회로가 아무 검사 없이 내놓는 값을 그대로 결과로 삼았다.
부호 있는 나눗셈은?

부호 있는 나눗셈은 흔히 피연산자의 절댓값으로 부호 없는 나눗셈을 한 뒤 결과의 부호를 고친다. 0으로 나눌 때 부호 있는 몫도 “모든 비트 1(−1)”로 정해 두면 이 부호 없는 회로의 결과를 그대로 쓸 수 있다. 규격도 같은 결과를 정한 까닭이 하드웨어를 단순하게 하기 위해서라고 밝힌다. 나눗셈기는 보통 몇 사이클에서 수십 사이클까지 걸리며, 피연산자가 작으면 일찍 끝내는 구현도 많다.

핵심 정리

  1. M 확장(v2.0)은 OP 옵코드에 funct7 = 0000001을 쓰는 R 형식 명령 8개다. funct3가 mul(000)부터 remu(111)까지 고른다. RV64는 OP-32에 W 명령 5개를 더한다.
  2. mul은 곱의 하위 XLEN비트(부호 무관), mulh·mulhu·mulhsu는 상위 XLEN비트를 준다. 전체 곱은 mulh[[s]u] rdh, rs1, rs2 → mul rdl, rs1, rs2 순서(같은 소스 순서, rdh ≠ rs1·rs2)로 얻으며, 구현은 이를 한 번의 곱셈으로 융합할 수 있다.
  3. 나눗셈은 0 쪽으로 버림하고, 0이 아닌 나머지의 부호는 피제수를 따른다(C와 같다).
  4. 0으로 나누면 몫은 모든 비트 1, 나머지는 피제수. −2L−1 ÷ −1은 몫 = 피제수, 나머지 0. 어느 경우에도 트랩은 없다.
  5. 트랩을 두지 않은 이유: 표준 ISA의 유일한 산술 트랩이 되는 것을 피하고, 필요하면 분기 하나로 검사할 수 있으며, “모든 비트 1”은 단순한 나눗셈 회로가 자연스럽게 내놓는 값이기 때문이다.
  6. Zmmul(v1.0)은 곱셈만 있는 M의 부분집합으로, 나눗셈기가 부담스러운 작은 코어와 FPGA에 알맞다.
  7. 곱셈기는 shift-add 반복형부터 Booth·월리스 트리까지 지연과 면적을 맞바꾸고, 나눗셈은 본질적으로 순차적이라 반복형(복원·비복원·SRT)이 흔하다.

확인 퀴즈

RV32에서 mulhu a0, a1, a2가 a0에 쓰는 값은?

mulhu는 무부호 × 무부호 곱의 상위 절반이다. 부호 × 부호는 mulh, 부호 × 무부호는 mulhsu다.

M 확장 명령을 다른 R 형식 정수 명령과 구별해 주는 필드 값은?

옵코드는 add와 같은 OP(0110011)이고, funct7 = 0000001이 곱셈·나눗셈 무리를 뜻한다. 0100000은 sub·sra가 쓰는 값이다.

RV32에서 rem으로 −7을 2로 나눈 나머지는?

몫은 0 쪽으로 버려 −3, 나머지는 −7 − 2 × (−3) = −1이다. 나머지의 부호는 피제수(−7)를 따른다.

RV32에서 a2 = 0일 때 div a0, a1, a2와 rem a3, a1, a2의 결과는?

0으로 나누면 트랩 없이 몫은 모든 비트 1, 나머지는 피제수가 된다. “a0 = a1, a3 = 0”은 부호 있는 오버플로의 결과다.

RV32에서 div로 0x80000000을 −1로 나누면 몫은?

참값 231은 32비트 부호 있는 범위를 넘는다. 규격은 이때 몫 = 피제수, 나머지 = 0으로 정했고, 트랩은 없다.

상위·하위 절반을 함께 구할 때 규격이 권하는 순서와 어긋나는 것은?

rdh(a0)가 rs1(a0)과 같다. 첫 명령이 a0을 덮어써서 둘째 mul이 틀린 값을 곱하고, 융합도 할 수 없다.

Zmmul만 구현한 코어에 대한 설명으로 옳은 것은?

Zmmul은 M의 곱셈 부분이며 인코딩도 같다. M을 구현하면 Zmmul은 저절로 포함된다.

0으로 나누기의 몫을 “모든 비트 1”로 정한 이유로 규격이 드는 것은?

복원 나눗셈기에서 0을 빼면 늘 성공하므로 몫 비트가 모두 1이 된다. 시뮬레이터에서 제수 0으로 직접 확인할 수 있다. C에서는 0으로 나누기가 정의되지 않은 동작이다.

더 읽을거리