M 확장: 곱셈과 나눗셈
RV32I에는 곱셈 명령이 없다. 덧셈과 시프트만으로도 곱셈을 흉내 낼 수 있기 때문이다. 하지만 실제 프로그램에서 곱셈과 나눗셈은 배열 주소 계산, 해시, 고정소수점 연산 등 곳곳에 나온다. M 확장은 정수 곱셈 4개와 나눗셈·나머지 4개, 단 8개 명령으로 이 빈자리를 채운다. 명령 수는 적지만 “곱의 위쪽 절반은 어떻게 얻나”, “0으로 나누면 어떻게 되나”처럼 따져 볼 규칙이 많다. 이 장에서는 그 규칙과 이유를, 그리고 그 명령을 실제로 계산하는 회로를 직접 돌려 보며 익힌다.
- M 확장 8개 명령(+ RV64의 W 명령 5개)의 인코딩과 뜻을 안다.
- MULH·MULHU·MULHSU와 MUL을 짝지어 2×XLEN 비트 곱을 만들고, 권장 순서가 왜 있는지 설명한다.
- 나눗셈이 0 쪽으로 버림하고, 나머지의 부호가 피제수를 따른다는 규칙을 적용한다.
- 0으로 나누기와 부호 있는 오버플로의 결과값을 외우고, 트랩을 두지 않은 이유를 설명한다.
- Zmmul이 무엇이고 어떤 코어에 쓰이는지 안다.
- shift-add 곱셈기와 복원 나눗셈기를 한 단계씩 따라가며 지연과 면적의 맞바꿈을 이해한다.
곱셈은 왜 베이스가 아니라 확장인가
2장에서 본 것처럼 RISC-V는 꼭 필요한 것만 베이스에 넣고 나머지는 확장으로 뺀다. 곱셈과 나눗셈이 그 첫 번째 대상이다. 덧셈기는 수백 게이트면 되지만, 한 사이클에 32×32비트를 곱하는 곱셈기는 그보다 수십 배 크고, 나눗셈기는 속도까지 느리다. 센서 값을 읽어 넘기기만 하는 작은 마이크로컨트롤러에 이 회로를 억지로 넣으면 칩 면적과 전력이 낭비된다.
그래서 RISC-V는 곱셈·나눗셈을 M 확장(Integer Multiplication and Division, v2.0)으로 분리했다. M이 없는 코어에서 컴파일러는 곱셈을 덧셈·시프트로 이루어진 라이브러리 함수 호출로 바꾼다. 예를 들어 GCC는 -march=rv32i에서 곱셈을 libgcc의 __mulsi3 호출로, 나눗셈을 __divsi3 호출로 만든다. 같은 프로그램이 M이 있는 코어에서는 명령 하나로 끝난다.
“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 — 저가형 구현을 단순하게 하고, 곱셈·나눗셈이 드물거나 별도 가속기에 맡기는 편이 나은 응용을 위해 베이스에서 뺐다는 설명이다.
예전 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개 명령 중 하나를 고른다.
| 명령 | 옵코드 | funct3 | 동작 | 비고 |
|---|---|---|---|---|
| mul | OP | 000 | rd ← (rs1 × rs2)의 하위 XLEN비트 | 부호 여부와 무관 |
| mulh | OP | 001 | rd ← (rs1 ×s rs2)의 상위 XLEN비트 | 부호 × 부호 |
| mulhsu | OP | 010 | rd ← (rs1 ×s rs2 u)의 상위 XLEN비트 | rs1 부호 있음, rs2 부호 없음 |
| mulhu | OP | 011 | rd ← (rs1 ×u rs2)의 상위 XLEN비트 | 무부호 × 무부호 |
| div | OP | 100 | rd ← rs1 ÷ rs2 (부호 있음, 0 쪽으로 버림) | |
| divu | OP | 101 | rd ← rs1 ÷ rs2 (부호 없음) | |
| rem | OP | 110 | rd ← div의 나머지 | 부호는 피제수를 따름 |
| remu | OP | 111 | rd ← divu의 나머지 | |
| mulw | OP-32 | 000 | 하위 32비트끼리 곱한 결과의 하위 32비트를 부호 확장 | RV64 전용 |
| divw · divuw | OP-32 | 100 · 101 | 하위 32비트끼리 나눈 32비트 몫을 부호 확장 | RV64 전용 |
| remw · remuw | OP-32 | 110 · 111 | 32비트 나머지를 부호 확장 | 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 하나로 충분하다.
mul은 하나뿐이고, 해석이 달라지는 상위 절반만 세 가지 명령으로 나뉜다.2의 보수에서 부호 있는 수 a와 부호 없는 수 a′는 232의 배수만큼 차이 난다. 곱하면 그 차이는 232의 배수로만 남으므로 하위 32비트에 아무 영향을 주지 못하고, 상위 32비트만 달라진다. 덧셈·뺄셈에 부호 있는 판과 없는 판이 따로 없는 것과 같은 이유다.
MULHSU는 어디에 쓰나
부호 있는 수 × 부호 없는 수라는 조합은 어색해 보이지만, 여러 워드로 된 큰 부호 있는 수(예: 128비트 정수)를 곱할 때 꼭 필요하다. 가장 높은 워드에는 부호 비트가 들어 있고, 아래 워드들은 부호 없는 숫자 조각이다. 규격도 “다중 워드 부호 있는 곱셈에서 부호 비트가 있는 최상위 워드와 부호 없는 아래 워드를 곱하는 데 쓴다”고 설명한다.
상위·하위를 함께 구할 때의 권장 순서
64비트 곱 전체가 필요하면 두 명령을 연달아 쓴다. 규격은 순서까지 권한다. 상위 절반 명령을 먼저, 같은 소스 레지스터를 같은 순서로, 그리고 상위 결과 레지스터(rdh)는 rs1·rs2와 달라야 한다.
이 모양을 지키면 마이크로아키텍처가 두 명령을 알아보고 명령 융합(macro-op fusion)으로 곱셈을 한 번만 할 수 있다. rdh가 rs1과 같으면 첫 명령이 a0을 덮어써서 둘째 mul이 엉뚱한 값을 곱하게 되므로, 이 조건은 융합 이전에 정확성의 문제이기도 하다.
div[u] rdq, rs1, rs2 다음에 rem[u] rdr, rs1, rs2(rdq ≠ rs1·rs2)를 쓰도록 권한다.두 명령으로 64비트 곱 조립하기
a0로 바꿔 보자. 첫 명령이 rs1을 덮어쓰는 바람에 둘째 mul이 엉뚱한 수를 곱해 하위 절반이 빨갛게 틀어진다. 이 결과는 riscv.js 에뮬레이터로 두 명령을 실제로 실행해서 얻은 것이다.나눗셈: 0 쪽으로 버리고, 나머지는 피제수의 부호
div와 divu는 XLEN비트를 XLEN비트로 나누며 0 쪽으로 버림(round towards zero)한다. rem·remu는 그 나눗셈의 나머지다. 0으로 나누기와 오버플로만 빼면 언제나 다음 식이 성립한다.
피제수 = 제수 × 몫 + 나머지, 그리고 0이 아닌 rem의 부호 = 피제수의 부호
div/rem은 C99 이후 C·C++의 /·%와 같은 규칙이므로 컴파일러가 명령 하나로 옮길 수 있다.| 식 | div 몫 | rem 나머지 | 확인: 제수 × 몫 + 나머지 |
|---|---|---|---|
7 ÷ 2 | 3 | 1 | 2 × 3 + 1 = 7 |
−7 ÷ 2 | −3 | −1 | 2 × (−3) + (−1) = −7 |
7 ÷ −2 | −3 | 1 | (−2) × (−3) + 1 = 7 |
−7 ÷ −2 | 3 | −1 | (−2) × 3 + (−1) = −7 |
Python의 -7 // 2는 −4, -7 % 2는 1이다. RISC-V(그리고 C)의 결과 −3, −1과 다르다. 언어마다 나눗셈 규칙이 다르므로, 내림 규칙을 쓰는 언어를 RISC-V로 옮기는 컴파일러는 몫과 나머지를 한 번 보정하는 코드를 더 넣는다.
0으로 나누기와 오버플로: 트랩 없이 정해진 값
나눗셈에는 수학적으로 답이 없거나 답이 레지스터에 들어가지 않는 경우가 두 가지 있다. 많은 ISA는 이때 예외를 일으키지만(x86의 DIV는 0으로 나누면 #DE 예외), RISC-V는 트랩을 일으키지 않고 정해진 값을 돌려준다.
- 0으로 나누기: 몫은 모든 비트가 1(부호 없는 최댓값 2L−1, 부호 있는 해석으로 −1), 나머지는 피제수 그대로.
- 부호 있는 오버플로: 가장 작은 음수 −2L−1을 −1로 나누면 참값 2L−1이 L비트 부호 있는 범위를 넘는다. 이때 몫은 피제수 그대로, 나머지는 0. 부호 없는 나눗셈에서는 오버플로가 생기지 않는다.
| 조건 | 피제수 | 제수 | DIVU[W] | REMU[W] | DIV[W] | REM[W] |
|---|---|---|---|---|---|---|
| 0으로 나누기 | x | 0 | 2L−1 | x | −1 | x |
| 오버플로 (부호 있음만) | −2L−1 | −1 | – | – | −2L−1 | 0 |
L은 연산 폭이다. div[u]·rem[u]는 XLEN, RV64의 W 명령은 32다. RV32에서 구체적인 값으로 보면 divu x, 0 = 0xffffffff, div 0x80000000, -1 = 0x80000000이다.
“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으로 나누면 즉시 예외”를 요구한다면 어떻게 할까? 규격의 답은 간단하다. 나눗셈 뒤에 분기 하나만 넣으면 된다. 이 분기는 거의 언제나 성립하지 않으므로 분기 예측기가 쉽게 맞히고, 비용은 매우 작다.
| ISA | 정수 0으로 나누기 | 최소 음수 ÷ −1 |
|---|---|---|
x86-64 (DIV/IDIV) | #DE 예외 (트랩) | #DE 예외 (트랩) |
Arm A64 (SDIV/UDIV) | 결과 0, 트랩 없음 | 결과 = 피제수 |
RISC-V (DIV/DIVU) | 몫 모든 비트 1, 나머지 = 피제수, 트랩 없음 | 몫 = 피제수, 나머지 0 |
RV32 M 명령 계산기
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비트)도 부호 확장한다는 점을 기억하자.
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가 존재하는 큰 이유 중 하나다.
곱셈기 하드웨어: shift-add에서 월리스 트리까지
손으로 하는 곱셈을 2진수로 하면 아주 간단하다. 승수의 각 비트가 1이면 피승수를 그 자리만큼 왼쪽으로 민 값을 더하고, 0이면 건너뛴다. 이것을 한 사이클에 한 비트씩 하면 덧셈기 하나와 레지스터 몇 개로 끝나는 반복형 shift-add 곱셈기가 된다. 대신 XLEN비트 곱에 XLEN 사이클 정도가 걸린다.
8비트 shift-add 곱셈기
같은 알고리즘을 RV32I 명령으로 쓰면 아래와 같다. M 확장이 없을 때 라이브러리 함수가 하는 일이 바로 이것이다. 루프 한 바퀴에 명령 6~7개가 실행되니, 하드웨어 곱셈기가 왜 반가운지 알 수 있다.
더 빠르게: 배열, Booth, 월리스 트리
고성능 코어는 한 비트씩 기다리지 않는다. 부분곱 XLEN개를 한꺼번에 만들어 더하는데, 그 더하는 방법이 속도와 면적을 가른다.
- 배열 곱셈기: 덧셈기를 계단처럼 XLEN줄 쌓아 부분곱을 차례로 더한다. 규칙적이라 만들기 쉽지만 지연이 XLEN에 비례한다.
- Booth 인코딩: 승수를 2비트씩 묶어(기수 4) 부분곱을 −2M, −M, 0, M, 2M 중 하나로 만든다. 부분곱 수가 절반으로 준다. 부호 있는 곱셈도 자연스럽게 처리한다.
- 월리스(Wallace) 트리: 자리올림을 바로 전파하지 않는 캐리 세이브 덧셈기(3개 입력 → 2개 출력)를 나무처럼 엮어 부분곱 더미를 log 단계 만에 두 줄로 줄이고, 마지막에만 빠른 덧셈기를 한 번 쓴다.
| 구현 방식 | 면적 | 지연 (XLEN비트) | 어울리는 곳 |
|---|---|---|---|
| 반복형 shift-add (1비트/사이클) | 덧셈기 1개 + 레지스터 | XLEN 사이클 정도 | 초소형 마이크로컨트롤러 |
| 반복형 기수-4 Booth (2비트/사이클) | 조금 더 큼 | XLEN/2 사이클 정도 | 작은 코어 |
| 배열 곱셈기 | 가산 셀 XLEN² 개 수준 | 한 사이클(긴 경로) 또는 나눠서 파이프라인 | 중간 성능 |
| Booth + 월리스 트리, 파이프라인 | 큼 | 몇 사이클 지연, 매 사이클 새 곱셈 시작 | 응용·서버 프로세서 |
| FPGA DSP 블록 | 칩에 이미 있음 | 1–몇 사이클 | FPGA 소프트 코어 (Zmmul이 잘 맞음) |
나눗셈기 하드웨어: 복원 나눗셈
나눗셈은 곱셈처럼 병렬로 펼치기 어렵다. 몫의 다음 비트를 정하려면 앞 단계의 나머지가 있어야 하기 때문이다. 그래서 대부분의 코어는 반복형 나눗셈기를 쓰고, 나눗셈은 곱셈보다 훨씬 오래 걸린다. 가장 기본이 되는 방법이 손으로 하는 긴 나눗셈과 똑같은 복원 나눗셈(restoring division)이다.
8비트 복원 나눗셈기
부호 있는 나눗셈은 흔히 피연산자의 절댓값으로 부호 없는 나눗셈을 한 뒤 결과의 부호를 고친다. 0으로 나눌 때 부호 있는 몫도 “모든 비트 1(−1)”로 정해 두면 이 부호 없는 회로의 결과를 그대로 쓸 수 있다. 규격도 같은 결과를 정한 까닭이 하드웨어를 단순하게 하기 위해서라고 밝힌다. 나눗셈기는 보통 몇 사이클에서 수십 사이클까지 걸리며, 피연산자가 작으면 일찍 끝내는 구현도 많다.
핵심 정리
- M 확장(v2.0)은
OP옵코드에funct7 = 0000001을 쓰는 R 형식 명령 8개다. funct3가mul(000)부터remu(111)까지 고른다. RV64는OP-32에 W 명령 5개를 더한다. mul은 곱의 하위 XLEN비트(부호 무관),mulh·mulhu·mulhsu는 상위 XLEN비트를 준다. 전체 곱은mulh[[s]u] rdh, rs1, rs2→mul rdl, rs1, rs2순서(같은 소스 순서, rdh ≠ rs1·rs2)로 얻으며, 구현은 이를 한 번의 곱셈으로 융합할 수 있다.- 나눗셈은 0 쪽으로 버림하고, 0이 아닌 나머지의 부호는 피제수를 따른다(C와 같다).
- 0으로 나누면 몫은 모든 비트 1, 나머지는 피제수. −2L−1 ÷ −1은 몫 = 피제수, 나머지 0. 어느 경우에도 트랩은 없다.
- 트랩을 두지 않은 이유: 표준 ISA의 유일한 산술 트랩이 되는 것을 피하고, 필요하면 분기 하나로 검사할 수 있으며, “모든 비트 1”은 단순한 나눗셈 회로가 자연스럽게 내놓는 값이기 때문이다.
- Zmmul(v1.0)은 곱셈만 있는 M의 부분집합으로, 나눗셈기가 부담스러운 작은 코어와 FPGA에 알맞다.
- 곱셈기는 shift-add 반복형부터 Booth·월리스 트리까지 지연과 면적을 맞바꾸고, 나눗셈은 본질적으로 순차적이라 반복형(복원·비복원·SRT)이 흔하다.
확인 퀴즈
RV32에서 mulhu a0, a1, a2가 a0에 쓰는 값은?
M 확장 명령을 다른 R 형식 정수 명령과 구별해 주는 필드 값은?
RV32에서 rem으로 −7을 2로 나눈 나머지는?
RV32에서 a2 = 0일 때 div a0, a1, a2와 rem a3, a1, a2의 결과는?
RV32에서 div로 0x80000000을 −1로 나누면 몫은?
상위·하위 절반을 함께 구할 때 규격이 권하는 순서와 어긋나는 것은?
Zmmul만 구현한 코어에 대한 설명으로 옳은 것은?
0으로 나누기의 몫을 “모든 비트 1”로 정한 이유로 규격이 드는 것은?
더 읽을거리
- The RISC-V Instruction Set Manual, Volume I — “M” Extension for Integer Multiplication and Division, Version 2.0 · Zmmul v1.0 (v20260120)
- Volume I — “Zc*” Extension for Code Size Reduction (Zcb의 c.mul)
- David Patterson, John Hennessy, Computer Organization and Design RISC-V Edition — 3장 “Arithmetic for Computers”의 곱셈기·나눗셈기 설명
- A. D. Booth, “A Signed Binary Multiplication Technique”, Quarterly Journal of Mechanics and Applied Mathematics (1951)
- C. S. Wallace, “A Suggestion for a Fast Multiplier”, IEEE Transactions on Electronic Computers (1964)
- Torbjörn Granlund, Peter L. Montgomery, “Division by Invariant Integers using Multiplication”, PLDI (1994)
- Andrew Waterman, “Design of the RISC-V Instruction Set Architecture”, UC Berkeley 박사 논문 (2016)