F·D 확장: FP 레지스터, 반올림 모드, fcsr 플래그, NaN boxing, 변환 규칙

Sunhong··12 min read·0 views

이 글의 핵심 3줄

  • F·D 확장은 부동소수점 레지스터 f0~f31과 제어 레지스터 fcsr 하나를 더한다. fcsr에는 반올림 모드(frm) 3비트와 예외 플래그(fflags) 5비트가 들어 있고, 0으로 나누기나 넘침이 나도 트랩 없이 플래그만 선다.
  • RISC-V는 IEEE 754가 구현에 맡긴 부분을 하나로 정해 두었다. NaN 결과는 언제나 정규 NaN 0x7fc00000이고, 범위 밖 정수 변환은 포화값을 내며, 64비트 레지스터에 담긴 단정도 값은 위 32비트가 모두 1이어야 한다(NaN boxing).
  • QEMU에서 명령어 229개를 실제로 실행해 결과 비트와 플래그를 찍고, 정확한 유리수(Fraction)로 직접 짠 Python 기준 모델과 대조했다. 229개 모두 일치했다.

앞 편들에서 정수 ISA(RV32I·RV64I)와 M·A 확장, 메모리 모델까지 봤다. 10편은 G에 들어가는 마지막 두 글자, F(단정도)와 D(배정도) 확장이다. 연산 자체는 IEEE 754를 따르므로 표준을 다시 설명하지 않고, RISC-V가 표준 위에 덧붙인 규칙만 다룬다. FPU를 RTL로 만들 때 하나라도 틀리면 호환성 테스트에서 걸리는 부분들이다.

실습은 3편과 같은 QEMU 11.1.3 virt 보드에서 rv64imafd로 했다. 장비는 맥 M1 Max, 컴파일러는 riscv64-elf-gcc 16.2다.

1. 레지스터와 FPU 켜기

F 확장은 32비트 부동소수점 레지스터 32개(f0~f31)를, D 확장은 이것을 64비트로 넓힌다. 레지스터 폭을 FLEN이라 부른다. F만 있으면 FLEN=32, D가 있으면 64다. XLEN(정수 레지스터 폭)과는 따로 정해지므로 RV32에 D를 붙이면 정수 레지스터는 32비트인데 f 레지스터는 64비트가 된다.

정수 쪽 x0과 달리 f0은 보통 레지스터다.

부분 내용
f0~f31 FLEN비트 레지스터 32개, 특별한 레지스터 없음
fcsr (CSR 0x003) frm[7:5] + fflags[4:0]
frm (CSR 0x002) 동적 반올림 모드
fflags (CSR 0x001) 누적 예외 플래그 5개

베어메탈에서는 FPU가 꺼진 채로 시작한다. 특권 명세의 mstatus.FS(비트 14:13)가 0(Off)이면 부동소수점 명령어와 fcsr 접근이 모두 불법 명령어 예외다. 그래서 시작 코드를 복사해 두 줄을 더했다.

    li   t0, 0x2000      # mstatus.FS = 01 (Initial)
    csrs mstatus, t0
    csrwi fcsr, 0        # frm = RNE, fflags = 0

FS는 Off·Initial·Clean·Dirty 네 상태이고, f 레지스터나 fcsr을 바꾸면 하드웨어가 Dirty로 올린다. 운영체제는 이것을 보고 문맥 전환 때 f 레지스터를 저장할지 정한다(23·24편).

2. fcsr: 반올림 모드와 예외 플래그

fcsr는 frm 3비트와 NV·DZ·OF·UF·NX 플래그 5비트로 되어 있고, FLEN=64 레지스터의 단정도 값은 위 32비트가 모두 1이어야 정상 입력으로 읽힌다

반올림이 필요한 명령어에는 3비트 rm 칸이 있다(정수 명령어의 funct3 자리). 값 0~4가 반올림 모드 다섯 가지이고, 111(DYN)은 "frm에 적힌 모드를 쓰라"는 뜻이다. 101과 110은 예약값이다. 어셈블러는 rm을 적지 않으면 dyn을 넣는다.

  • RNE(Round to Nearest, ties to Even): 가장 가까운 값으로, 정확히 가운데면 끝 비트가 0(짝수)인 쪽으로. IEEE 754 기본값.
  • RTZ(toward Zero): 버림. C의 (int)x 변환이 이것이다.
  • RDN(−∞ 쪽), RUP(+∞ 쪽): 구간 연산의 아래·위 경계.
  • RMM(to Nearest, ties to Max Magnitude): 가장 가까운 값으로, 가운데면 0에서 먼 쪽으로. 학교에서 배운 반올림이다.

차이는 정수 변환에서 가장 잘 보인다. QEMU에서 fcvt.w.s를 다섯 모드로 실행했다.

fcvt.w.s를 다섯 모드로 실행하면 2.5는 RNE에서 2, RMM과 RUP에서 3이 되고, -0.5는 RDN과 RMM에서만 -1이 된다

RNE와 RMM은 동점(x.5)에서만 갈린다. 2.5는 RNE에서 2(짝수), RMM에서 3이고, 1.5는 둘 다 2다.

일반 연산에서는 모드가 결과의 마지막 비트를 바꾼다. 1 근처에서 단정도의 간격은 2⁻²³이므로, 1 + 2⁻²⁴는 1(0x3f800000)과 1 + 2⁻²³(0x3f800001)의 정확히 가운데다. RNE는 끝 비트가 0인 0x3f800000을, RMM은 0에서 먼 0x3f800001을 고른다.

연산 RNE RTZ·RDN RUP·RMM
1 / 3 3eaaaaab 3eaaaaaa 3eaaaaab
1 + 2⁻²⁴ (동점) 3f800000 3f800000 3f800001
최대값 × 2 +∞ 최대값 +∞

마지막 줄(최대 유한값끼리 더하기)이 놓치기 쉽다. 넘침의 결과가 언제나 무한대는 아니다. RTZ와 양수의 RDN은 무한대 쪽으로 가지 않으므로 최대 유한값 0x7f7fffff가 되고, 플래그는 다섯 모드 모두 OF와 NX였다. fsrm으로 frm을 바꾸며 fdiv.s ...,dyn을 실행한 결과도 고정 모드와 같았다.

3. 예외는 트랩이 아니라 플래그

IEEE 754는 다섯 가지 예외를 정의하고, 구현이 트랩을 낼 수도 있게 했다. RISC-V는 부동소수점 예외로 트랩을 내지 않는다. 플래그만 세우고 정해진 기본 결과를 쓴다. 플래그는 누적된다. 한 번 서면 소프트웨어가 fflags를 지울 때까지 남는다.

연산 (단정도, RNE) 결과 플래그
1 / 0 +∞ 7f800000 DZ
0 / 0, √−1, ∞ − ∞ 7fc00000 NV
2¹²⁷ × 10 +∞ OF NX
10⁻³⁰ × 10⁻³⁰ +0 UF NX
2⁻¹²⁶ × 0.5 00400000 없음
1 + 2⁻³⁰, √2 반올림된 값 NX

다섯째 줄의 결과 2⁻¹²⁷은 비정규수(subnormal)지만 정확히 표현되므로 UF가 서지 않는다. UF는 결과가 아주 작고(tiny) 동시에 부정확할 때만 선다. 끝 비트가 1인 0x00800001로 같은 곱셈을 하면 반올림이 일어나 UF와 NX가 섰다. tiny 여부는 반올림한 뒤에 판단한다(tininess after rounding). 지수 범위가 무한하다고 보고 반올림한 결과가 2⁻¹²⁶ 이상이면 tiny가 아니다.

명세는 필요하면 소프트웨어가 플래그를 직접 확인하게 했고, 플래그로 분기하는 명령어도 ISA를 단순하게 두려고 뺐다. 설계 쪽에서는 FPU에 정밀 예외 경로가 필요 없고, 플래그만 순서대로 갱신하면 된다.

fsrmi 3(RUP) 뒤 1/0을 실행하고 frcsr로 읽으니 0x68(frm=011, DZ)이었다.

4. 정규 NaN: 페이로드를 옮기지 않는다

NaN은 지수가 전부 1이고 가수가 0이 아닌 값이다. 가수 맨 윗비트가 1이면 조용한 NaN(qNaN), 0이면 신호 NaN(sNaN)이고, 나머지 비트가 페이로드다. IEEE 754는 페이로드를 결과로 옮기라고 권할 뿐 강제하지 않는다.

RISC-V는 옮기지 않는다. 연산 결과가 NaN이면 언제나 정규 NaN(canonical NaN), 단정도 0x7fc00000, 배정도 0x7ff8000000000000이다. 부호 0, 조용한 NaN, 페이로드 0이다.

입력 연산 결과 플래그
qNaN 7fc12345 fadd.s +1 7fc00000 없음
sNaN 7f800001 fadd.s +1 7fc00000 NV
음수 qNaN ffc00000 fadd.s +1 7fc00000 없음
qNaN 7fc12345 fsgnjn.s (부호 반전) ffc12345 없음

마지막 줄은 예외다. 부호 주입(fsgnj·fsgnjn·fsgnjx, 의사 명령어 fmv.s·fneg.s·fabs.s)은 비트 조작이라 NaN을 그대로 두고 부호만 바꾸며, sNaN이어도 NV가 없다.

정규 NaN 규칙 덕분에 입력 NaN을 골라 보내는 선택기가 필요 없고, 상수 하나를 내면 된다.

비교 명령어는 조용한 비교와 신호 비교로 나뉜다. feq.s는 qNaN이 들어오면 0을 내고 플래그를 세우지 않는다(sNaN일 때만 NV). flt.s·fle.s는 qNaN이어도 NV를 세운다. QEMU에서도 qNaN끼리 feq.s는 0·플래그 없음, qNaN과 1의 flt.s는 0·NV였다. fclass.s는 10가지 분류 중 한 비트를 켠다(sNaN 0x100, qNaN 0x200, 음수 비정규수 0x4를 확인).

5. NaN boxing: 64비트 레지스터의 단정도 값

D가 있으면 f 레지스터는 64비트다. 단정도 명령어는 이 중 어디를 쓸까. RISC-V는 단정도 값을 아래 32비트에 두고 위 32비트를 모두 1로 채운다. 이것을 NaN boxing이라 부른다. 이 값은 배정도로 읽으면 NaN이라, 잘못 읽혀도 엉터리 숫자가 되지 않는다.

규칙은 두 가지다. 단정도 결과를 쓸 때는 위를 1로 채운다. 단정도 연산이 입력을 읽을 때는 위 32비트를 검사해서, 전부 1이 아니면 그 입력을 정규 NaN으로 본다. 위 그림의 아래 절반이 이것이고, QEMU 결과는 다음과 같았다.

입력 레지스터(64비트) 명령어 결과
ffffffff40000000 fadd.s 자기 자신 ffffffff40800000
0000000040000000 fadd.s 자기 자신 ffffffff7fc00000
3ff0000000000000 fadd.s 자기 자신 ffffffff7fc00000
0000000040000000 fsgnjn.s ffffffffffc00000

둘째 줄은 아래 32비트가 2.0인데도 결과가 정규 NaN이다. 셋째 줄은 배정도 1.0을 단정도 명령어로 읽은 경우다. 넷째 줄이 흥미롭다. 부호 주입은 NaN을 건드리지 않는다고 했지만, boxing이 깨진 입력은 먼저 정규 NaN 7fc00000으로 바뀐 뒤 부호가 반전되어 ffc00000이 됐다. fcvt.d.s로 넓히면 배정도 정규 NaN이 나왔다. 정규 NaN은 조용한 NaN이라 모두 플래그가 없었다.

검사하지 않는 명령어도 있다. 명세는 로드·스토어(flw·fsw)와 비트 이동(fmv.w.x·fmv.x.w)을 "전송" 명령어로 분류한다. 안으로 들어오는 전송은 boxing된 값을 만들고, 밖으로 나가는 전송은 위 32비트를 무시하고 아래 32비트를 그대로 꺼낸다. 12345678c0000000에 fmv.x.w를 하니 ffffffffc0000000이 나왔다. 아래 32비트 c0000000을 RV64 정수 레지스터에 부호 확장한 값이다.

6. 정수 변환: 범위 밖은 포화

fcvt.w.s(단정도 → 32비트 정수)는 NaN, 무한대, 너무 큰 수를 담을 수 없다. RISC-V는 이때 가장 가까운 끝값(포화값)을 내고 NV를 세운다. NaN은 양의 끝값으로 간다.

입력 fcvt.w.s fcvt.wu.s
NaN, +∞ 7fffffff NV ffffffff NV
−∞ 80000000 NV 0 NV
3×10⁹ 7fffffff NV b2d05e00
2³¹ 7fffffff NV 80000000
−2³¹ 80000000 0 NV
−1.0 ffffffff 0 NV
−0.5 0 NX 0 NX

표는 32비트로 적었다. 경계값을 더 보자.

  • fcvt.w.s 2,147,483,520(2³¹ 아래의 가장 큰 단정도)은 7fffff80·플래그 없음. −2³¹ 바로 아래 단정도(0xcf000001)는 80000000과 NV.
  • fcvt.wu.s −0.5는 RNE·RTZ에서 −0으로 반올림되어 범위 안이므로 0과 NX만 선다. RDN에서는 −1이 되어 범위 밖, 0과 NV다. "범위 안인가"는 반올림한 뒤 판단한다.
  • fcvt.w.d 2,147,483,647.0은 7fffffff·플래그 없음, 2,147,483,647.5는 2³¹로 반올림되어 같은 비트에 NV.
  • RV64에서 32비트 결과는 64비트로 부호 확장된다. fcvt.wu.s도 예외가 아니어서, NaN의 결과 ffffffff는 ffffffffffffffff로, 4,294,967,040은 ffffffffffffff00으로 레지스터에 들어갔다.

반대 방향(정수 → 부동소수점)은 넘치지 않지만 정밀도가 모자랄 수 있다. 2²⁴ + 1 = 16,777,217은 단정도 가수 24비트에 들어가지 않는다. fcvt.s.w는 RNE로 16,777,216(4b800000)과 NX를, RUP로 16,777,218(4b800001)을 냈다.

7. fmin·fmax와 fmadd 4종

fmin·fmax는 F 2.2판에서 규칙이 바뀌었다. 지금은 IEEE 754-2019의 minimumNumber·maximumNumber를 따른다. 한쪽만 NaN이면 NaN이 아닌 쪽을, 둘 다 NaN이면 정규 NaN을 낸다. sNaN이 있으면 NV를 세운다. 그리고 −0 < +0으로 본다.

입력 fmin.s 플래그
qNaN, 1.0 (순서 바꿔도) 1.0 없음
qNaN, qNaN 7fc00000 없음
−0, +0 −0 (fmax.s는 +0) 없음
sNaN, 1.0 1.0 NV

곱셈-덧셈 융합(fused multiply-add)은 네 가지다. 자체 opcode를 넷이나 쓰고, 레지스터 칸이 셋인 R4 형식(rs3가 [31:27])을 쓴다.

명령어 계산 2, 3, 1로 실행
fmadd a×b + c 7
fmsub a×b − c 5
fnmsub −(a×b) + c −5
fnmadd −(a×b) − c −7

fnmsub는 "−(a×b − c)"로 읽으면 이름이 맞는다.

융합의 핵심은 반올림을 한 번만 한다는 것이다. a = b = 1 + 2⁻¹²(0x3f800800), c = −(1 + 2⁻¹¹)로 해 보자. 정확한 곱은 1 + 2⁻¹¹ + 2⁻²⁴다. fmul.s로 따로 곱하면 2⁻²⁴가 반올림에서 사라져(1 + 2⁻²³의 가운데, 짝수 쪽) 1 + 2⁻¹¹이 되고, c를 더하면 0이다. fmadd.s는 곱을 반올림하지 않고 더하므로 2⁻²⁴(0x33800000)를 정확히 냈다. 따로 한 쪽은 NX가 섰고, 융합한 쪽은 결과가 정확해서 플래그가 없었다.

곱이 ∞ × 0이면 c가 qNaN이어도 NV를 세워야 한다. QEMU에서도 fmadd.s(∞, 0, qNaN)은 정규 NaN과 NV였다.

8. 실습: QEMU 결과를 Fraction 기준 모델과 대조

시험 프로그램은 인라인 어셈블리로 명령어 하나를 감싸, 실행 전에 fflags를 지우고 실행 후 결과 레지스터 64비트와 fflags를 UART로 찍는다.

#define FF(s, a, b, c) do { /* 단정도 입력 3개 */  \
  __asm__ volatile("fsflags x0\n\t"               \
    "fmv.w.x ft0,%2\n\tfmv.w.x ft1,%3\n\t"         \
    "fmv.w.x ft2,%4\n\t" s "\n\t"                  \
    "fmv.x.d %0,ft3\n\tfrflags %1"                 \
    : "=&r"(r_), "=&r"(f_)                         \
    : "r"(a_), "r"(b_), "r"(c_)                    \
    : "ft0", "ft1", "ft2", "ft3");                 \
  show(s, a_, b_, c_, r_, f_); } while (0)

FF("fadd.s ft3,ft0,ft1,rmm", ONE, 0x33800000, 0);
FF("fmadd.s ft3,ft0,ft1,ft2", 0x7f800000, 0, QNAN);

결과를 fmv.x.d로 64비트 전부 꺼내므로 모든 단정도 결과의 boxing도 함께 검사된다.

기준 모델은 Python으로 직접 짰다. 입력 비트를 fractions.Fraction의 정확한 유리수로 풀어 연산을 정확히 계산하고 모드별로 한 번 반올림한다(제곱근은 math.isqrt로 400비트까지 구하고 나머지를 sticky로). 정규 NaN, boxing 검사, 포화 변환, tininess는 명세 문장대로 옮겼다. 모델 자체는 Python float(배정도, RNE)와 무작위 1만 쌍으로 먼저 확인했다.

시험 개수 일치
기준 모델 = Python float (배정도 덧셈·나눗셈) 10,000 10,000
QEMU = 기준 모델 (결과 64비트 + fflags) 229 229
예약 rm(101), frm=101에서 DYN 2 2 (불법 명령어, mcause=2)

처음 대조에서는 단정도 입력 줄이 무더기로 어긋났다. 출력에 찍은 입력값이 boxing 전의 32비트여서 기준 모델이 "깨진 boxing"으로 읽은 내 실수였다. 예약 rm은 .insn r OP_FP, 5, 0, ft3, ft0, ft1로 직접 인코딩하고, mcause를 기록한 뒤 명령어를 건너뛰는 작은 트랩 처리기로 확인했다.

9. 설계 체크리스트: FPU가 지켜야 할 것

항목 규칙
FPU 꺼짐 mstatus.FS=Off면 F·D 명령어와 fcsr 접근 모두 불법 명령어
FS 갱신 f 레지스터·fcsr을 바꾸면 FS=Dirty. 언제 올릴지(쓰기마다, 실제로 바뀔 때만)는 구현 선택
rm 칸 101·110은 불법. DYN이면 frm을 보고, frm이 101~111이면 불법
예외 트랩 없음. 플래그는 OR로 누적(지우는 건 소프트웨어)
넘침 결과 RTZ, RDN(양수), RUP(음수)는 무한대가 아니라 최대 유한값
UF tiny(반올림 뒤 판단) 그리고 부정확할 때만
NaN 결과 언제나 정규 NaN. 페이로드를 옮기지 않는다
부호 주입 NaN을 바꾸지 않고 플래그도 없다. 단, boxing이 깨진 입력은 먼저 정규 NaN
NaN boxing 단정도 결과는 위를 1로. 입력 위가 전부 1이 아니면 정규 NaN. 전송 명령어는 예외
비교 feq는 sNaN만 NV, flt·fle는 모든 NaN에 NV
fmin·fmax NaN 아닌 쪽, 둘 다 NaN이면 정규 NaN, −0 < +0, sNaN이면 NV
정수 변환 반올림 뒤 범위 검사. 범위 밖·NaN은 포화값 + NV. RV64는 32비트 결과(W·WU 모두)를 부호 확장
융합 반올림 한 번. ∞×0이면 c가 qNaN이어도 NV
비정규수 하드웨어로 처리해야 한다(0으로 내리는 모드 없음)

마지막 줄이 면적에 영향을 준다. 일부 ISA는 비정규수를 0으로 바꾸는(flush-to-zero) 모드로 하드웨어를 줄이지만, RISC-V F·D는 비정규수를 IEEE 754대로 처리하게 해서 작은 FPU라도 정규화(앞자리 0 세기와 시프트)를 갖춰야 한다.

정리

  • F·D는 f0~f31과 fcsr(frm 3비트 + fflags 5비트)을 더한다. FPU는 mstatus.FS를 켜야 쓸 수 있다.
  • 반올림 모드는 명령어의 rm 칸에 고정하거나 DYN으로 frm을 따른다. 2.5는 RNE에서 2, RMM에서 3이었고, 넘침은 모드에 따라 무한대 대신 최대 유한값이 됐다.
  • 예외는 트랩 없이 플래그만 누적한다. NaN 결과는 언제나 0x7fc00000, 범위 밖 정수 변환은 포화값, FLEN=64의 단정도 값은 위 32비트가 모두 1이어야 한다.
  • QEMU에서 실행한 229개가 Fraction 기준 모델과 모두 일치했다. 반올림 뒤 범위 검사(−0.5의 fcvt.wu.s), 융합의 한 번 반올림(2⁻²⁴ 대 0)처럼 틀리기 쉬운 곳을 숫자로 짚었다.

다음 글: 11편은 C 확장이다. 자주 쓰는 명령어를 16비트로 줄인 압축 명령어가 어떤 형식으로 되어 있는지, 3비트 레지스터 칸이 왜 x8~x15인지, 각 압축 명령어가 어떤 32비트 명령어로 펼쳐지는지 대응표로 정리한다. 직접 짠 압축 해제기를 objdump와 대량으로 대조하고, 실제 프로그램에서 16비트 명령어가 얼마나 쓰이는지 잰다.

참고 문헌

  • The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. 20장 F 확장 2.2판(20.2 fcsr, 20.3 NaN 생성과 전파, 20.4 비정규수, 20.6 연산, 20.7 변환과 이동, 20.8 비교, 20.9 분류), 21장 D 확장(NaN boxing). https://github.com/riscv/riscv-isa-manual
  • The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Version 20240411. 3.1.6 mstatus의 FS 필드.
  • IEEE Std 754-2019, IEEE Standard for Floating-Point Arithmetic.
  • D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, 2017. 5장 RV32F·RV32D
  • QEMU virt 보드 문서. https://www.qemu.org/docs/master/system/riscv/virt.html

비슷한 글 추천