RV32I 2: 정수 연산·비교·시프트와 lui·auipc로 상수·주소 만들기

Sunhong··11 min read·0 views

이 글의 핵심 3줄

  • RV32I의 정수 연산은 레지스터끼리 10개(add sub and or xor sll srl sra slt sltu)와 즉시값 버전 9개, 그리고 lui·auipc가 전부다. 오버플로 플래그도 예외도 없고, mv·not·neg·seqz 같은 명령어는 이것들의 조합(의사 명령어)이다.
  • 32비트 상수는 lui(위 20비트)와 addi(아래 12비트)로 만든다. addi의 즉시값이 부호 확장되기 때문에, 아래 12비트의 맨 윗비트가 1이면 lui 쪽을 1 올려야 한다. 무작위 상수 100만 개 중 499,908개가 이 보정을 받았다.
  • QEMU에서 경계값 15개를 실제로 실행해 Python 기준 모델과 모두 일치함을 확인했다. sltiu의 즉시값이 부호 확장된다는 점, sll이 시프트 양의 아래 5비트만 본다는 점처럼 설계 때 틀리기 쉬운 곳을 숫자로 짚었다.

2편에서 명령어의 모양(형식 여섯 가지와 즉시값 인코딩)을 봤다. 3편은 그 모양 안에 담긴 정수 연산이 실제로 무엇을 계산하는지 다룬다. RV32I 명령어의 절반 가까이가 여기 속하고, 코어 설계에서 ALU(산술 논리 장치)가 할 일이 바로 이 목록이다. 마지막에는 lui·auipc로 32비트 상수와 주소를 만드는 방법을 손으로 따라간다.

이번 편부터는 명령어를 실제로 실행해 확인한다. QEMU 11.1.3의 virt 보드에 운영체제 없이 RISC-V 프로그램을 올리고(베어메탈), 결과를 UART로 찍었다. 장비는 맥 M1 Max, 컴파일러는 riscv64-elf-gcc 16.2다.

1. 정수 연산 한눈에

종류 레지스터끼리 (R 형식) 즉시값 (I 형식) 계산
덧셈·뺄셈 add, sub addi rs1 + rs2, rs1 − rs2
논리 and, or, xor andi, ori, xori 비트별 연산
시프트 sll, srl, sra slli, srli, srai 왼쪽, 오른쪽(0 채움), 오른쪽(부호 채움)
비교 slt, sltu slti, sltiu rs1 < rs2 이면 1, 아니면 0
상위 즉시값 lui, auipc (U 형식) imm << 12, pc + (imm << 12)

빠진 것이 눈에 띈다. subi가 없다. addi에 음수를 넣으면 되기 때문이다. 즉시값은 언제나 12비트 부호 있는 값(−2048~2047)으로 부호 확장된다. 그래서 andi·ori·xori의 즉시값도 부호 확장된다는 점을 기억해 두자. 4절에서 이것이 함정이 되는 경우를 본다.

자주 쓰는 연산 중 상당수는 명령어가 아니라 의사 명령어(pseudo-instruction)다. 어셈블러가 진짜 명령어 하나로 바꿔 준다.

의사 명령어 실제 명령어 뜻
nop addi x0, x0, 0 아무것도 안 함
mv rd, rs addi rd, rs, 0 복사
not rd, rs xori rd, rs, -1 비트 반전
neg rd, rs sub rd, x0, rs 부호 반전
seqz rd, rs sltiu rd, rs, 1 rs == 0 이면 1
snez rd, rs sltu rd, x0, rs rs ≠ 0 이면 1
sltz rd, rs slt rd, rs, x0 rs < 0 이면 1
sgtz rd, rs slt rd, x0, rs rs > 0 이면 1

2편에서 본 x0=0 규칙이 여기서 빛난다. 하드웨어에 명령어를 더하지 않고도 쓸 만한 연산이 여덟 개 늘었다.

2. 오버플로: 플래그도 예외도 없다

x86이나 ARM은 덧셈 결과를 보고 캐리·오버플로 같은 플래그를 세운다. RISC-V는 그런 플래그가 없고, 오버플로가 나도 예외를 내지 않는다. 결과는 그냥 32비트에서 잘린다(2의 보수로 한 바퀴 돈다).

연산 rs1 rs2 결과 설명
add 0x7fffffff 1 0x80000000 가장 큰 양수 + 1 = 가장 작은 음수
sub 0x80000000 1 0x7fffffff 가장 작은 음수 − 1 = 가장 큰 양수

플래그가 없으면 오버플로가 필요한 소프트웨어(큰 정수 연산, 오버플로를 검사하는 언어)는 어떻게 할까. 명세는 명령어 몇 개로 검사하는 관용구를 제시한다.

  • 부호 없는 덧셈: add t0, t1, t2 뒤에 bltu t0, t1, overflow. 결과가 피연산자보다 작아졌으면 넘친 것이다.
  • 부호 있는 덧셈(일반): add t0, t1, t2, slti t3, t2, 0, slt t4, t0, t1, bne t3, t4, overflow. "더한 값이 음수인가"와 "결과가 줄었는가"가 다르면 넘친 것이다.

마지막 관용구를 0x7fffffff + 1에 적용해 보자. t2=1은 음수가 아니니 t3=0이다. 결과 0x80000000은 부호 있는 수로 −2,147,483,648이라 t1=2,147,483,647보다 작으니 t4=1이다. 둘이 다르므로 오버플로다. QEMU에서도 1이 나왔다.

설계 쪽에서 보면 이 결정은 큰 단순화다. 플래그 레지스터가 없으니 명령어 사이의 숨은 의존성(앞 명령어의 플래그를 뒤 명령어가 읽는 것)을 추적할 필요가 없고, 정수 연산이 예외를 내지 않으니 ALU에서 트랩 경로를 뺄 수 있다.

3. 비교: slt와 sltu

slt(set less than)는 두 값을 부호 있는 수로, sltu는 부호 없는 수로 비교한다. 같은 비트도 어떻게 읽느냐에 따라 답이 반대가 된다.

연산 rs1 rs2 결과 이유
slt 0xffffffff 1 1 부호 있게 읽으면 −1 < 1
sltu 0xffffffff 1 0 부호 없이 읽으면 4,294,967,295 < 1 은 거짓
sltiu 5 즉시값 -1 1 즉시값이 0xffffffff로 부호 확장된 뒤 부호 없이 비교
sltiu 0 즉시값 1 1 seqz: 0 < 1

셋째 줄이 중요하다. sltiu는 "부호 없는" 비교지만, 즉시값은 다른 I 형식처럼 먼저 부호 확장된다. 즉시값 −1은 0xffffffff가 되어 "가장 큰 부호 없는 수"로 비교된다. 그래서 sltiu rd, rs, -1은 rs가 0xffffffff일 때만 0이다. 하드웨어를 만들 때 "부호 없는 명령어니까 즉시값은 0으로 확장"이라고 하면 틀린다.

하드웨어로 slt를 만드는 방법은 두 가지다. 뺄셈 결과의 부호를 보되 오버플로를 보정하거나, 부호 비트를 먼저 비교하는 것이다.

slt(a,b)={a31a31≠b31(a−b)31a31=b31\text{slt}(a, b) = \begin{cases} a_{31} & a_{31} \ne b_{31} \\ (a - b)_{31} & a_{31} = b_{31} \end{cases}

부호가 다르면 음수 쪽이 작으니 a의 부호 비트가 답이다. 부호가 같으면 뺄셈이 넘칠 수 없으니 뺄셈 결과의 부호 비트가 답이다. sltu는 33비트로 뺄셈을 해서 빌림(borrow)이 나는지 보면 된다. 둘 다 덧셈기를 함께 쓰므로 비교기를 따로 둘 필요가 없다.

4. 시프트와 논리 연산

시프트는 세 가지다. sll은 왼쪽으로 밀고 빈자리를 0으로, srl은 오른쪽으로 밀고 0으로, sra는 오른쪽으로 밀고 부호 비트로 채운다.

연산 rs1 시프트 양 결과 설명
srai 0x80000f00 8 0xff80000f 위를 부호(1)로 채움
srli 0x80000f00 8 0x0080000f 위를 0으로 채움
sra 0x80000000 31 0xffffffff
srl 0x80000000 31 0x00000001
sll 1 33 0x00000002 시프트 양의 아래 5비트(33 → 1)만 쓴다

마지막 줄을 보자. 레지스터 시프트는 rs2의 아래 5비트만 시프트 양으로 쓴다. 33은 이진수 100001이라 아래 5비트는 1이다. 그래서 1을 33번이 아니라 1번 밀어 2가 됐다. C 언어에서 32 이상 밀기는 정의되지 않은 동작인데, RISC-V 하드웨어는 이렇게 정해진 답을 낸다.

srai와 srli는 opcode와 funct3가 같다. 둘을 가르는 것은 inst[30] 한 비트다(2편 그림의 funct7 자리, sub·sra와 같은 비트). 즉시값 시프트는 I 형식이지만 시프트 양은 inst[24:20]의 5비트만 쓰고, 그 위 칸은 funct7처럼 쓴다.

논리 연산에서는 즉시값 부호 확장을 다시 조심해야 한다.

연산 rs1 즉시값 결과
xori 0x0000f0f0 -1 (0xffffffff) 0xffff0f0f
andi 0x12345678 0xff 0x00000078
andi 0x12345678 -2048 (0xfffff800) 0x12345000

셋째 줄처럼 andi rd, rs, -2048은 아래 12비트가 아니라 아래 11비트만 지운다. 즉시값 0x800이 부호 확장되어 0xfffff800이 되기 때문이다. 아래 12비트를 지우려면 0xfffff000이 필요한데, 12비트 즉시값으로는 표현할 수 없다. 이럴 때는 srli·slli로 밀었다 되돌리거나, lui로 마스크를 만든다.

5. lui와 32비트 상수

명령어가 32비트인데 32비트 상수를 한 명령어에 담을 수는 없다. RISC-V는 상수를 위 20비트와 아래 12비트로 나눠 두 명령어로 만든다. lui rd, imm(load upper immediate)은 20비트 즉시값을 위에 놓고 아래 12비트를 0으로 채운다. 그 뒤 addi가 아래 12비트를 더한다.

32비트 상수는 lui로 위 20비트, addi로 아래 12비트를 만들고, 아래 12비트의 bit 11이 1이면 lui 값을 1 올려 둔다

문제는 addi의 즉시값이 부호 있는 값이라는 점이다. 0x12345fff를 만들려고 lui a0, 0x12345와 addi a0, a0, 0xfff를 쓰면, 0xfff는 −1로 읽혀서 0x12344fff가 된다. 그래서 위 20비트를 1 올려 0x12346으로 두고, −1을 더해 맞춘다. 일반식은 다음과 같다.

hi=⌊c+0x800212⌋,lo=c−hi⋅212hi = \left\lfloor \frac{c + \texttt{0x800}}{2^{12}} \right\rfloor, \qquad lo = c - hi \cdot 2^{12}

c에 0x800을 더하는 것이 "아래 12비트가 0x800 이상이면 위로 1 올림"이다. 그러면 lo는 언제나 −2048~2047 범위에 들어온다. 어셈블러의 %hi(c)와 %lo(c)가 이 계산이다.

이 공식을 무작위 32비트 상수 100만 개와 경계값 7개에 적용해 봤다. hi·lo를 다시 합치면 언제나 원래 값이 됐고, lo는 언제나 범위 안이었다. 그중 499,908개(약 절반)가 +1 보정을 받았다. 아래 12비트의 맨 윗비트가 1일 확률이 절반이니 당연한 결과다.

실제 어셈블러는 li(load immediate) 의사 명령어를 이렇게 펼쳤다.

li a0, … 펼친 결과 명령어 수
100, 2047, -2048, -1 addi a0, x0, … 1
0x12345000 lui a0, 0x12345 1
2048 (0x800) lui a0, 0x1 + addi a0, a0, -2048 2
0x12345678 lui a0, 0x12345 + addi a0, a0, 1656 2
0x12345fff lui a0, 0x12346 + addi a0, a0, -1 2
0x7ffff800 lui a0, 0x80000 + addi a0, a0, -2048 2
0xfffff800 addi a0, x0, -2048 1

2048은 12비트 부호 있는 범위를 딱 1 넘어서 두 명령어가 필요하다. 반대로 0xfffff800은 −2048이라 addi 하나로 끝난다. 0x7ffff800에서는 lui가 0x80000을 받아 0x80000000(음수)을 만든 뒤 −2048을 더한다. 32비트에서 한 바퀴 도는 성질을 그대로 이용한 것이다.

6. auipc: pc 기준 주소

auipc rd, imm(add upper immediate to pc)은 20비트 즉시값을 위로 밀어 그 auipc 명령어 자신의 주소에 더한다. 상수 대신 현재 위치에서의 거리로 주소를 만드는 명령어다.

QEMU에서 실행한 la sp, _stack_top은 auipc sp, 0x4로 pc 0x80000000에 0x4000을 더하고 addi로 0x1d0을 더해 0x800041d0을 만든다

QEMU에서 실행한 시작 코드의 la sp, _stack_top이 좋은 예다. 어셈블러는 이것을 auipc sp, 0x4와 addi sp, sp, 464로 펼쳤다. 0x80000000에 있는 auipc가 0x4 << 12 = 0x4000을 더해 0x80004000, 이어 addi가 464(0x1d0)를 더해 스택 꼭대기 0x800041d0이 됐다. 이 두 명령어의 기계어에는 절대 주소가 없다. 프로그램 전체를 다른 주소에 올려도 같은 기계어로 같은 상대 위치를 찾는다. 이것이 위치 독립 코드(PIC)의 기반이다.

main 안에서도 확인했다. auipc로 읽은 pc는 0x80000020, la로 구한 전역 변수 target의 주소와 C의 &target은 둘 다 0x800001c0으로 같았다(거리 0x1a0).

auipc의 짝은 상황마다 다르다. 주소가 필요하면 addi, 그 주소의 값을 읽으려면 lw, 멀리 있는 함수를 부르려면 jalr이 아래 12비트를 더한다(call = auipc + jalr). 이 조합으로 pc에서 ±2GiB 안의 어디든 닿는다. 짝이 되는 명령어의 아래 12비트도 부호 확장되므로, 5절의 +1 보정이 여기서도 똑같이 적용된다(어셈블러의 %pcrel_hi·%pcrel_lo).

7. 실습: QEMU 실행 결과와 기준 모델 대조

위 표의 숫자는 모두 QEMU에서 실제로 실행한 값이다. C 안에 인라인 어셈블리로 명령어 하나씩을 넣고, 결과를 UART로 찍었다.

#define R(op, a, b) ({ uint32_t r_; \
    __asm__ volatile(op " %0,%1,%2" : "=r"(r_) : "r"(a), "r"(b)); r_; })
#define I(op, a, imm) ({ uint32_t r_; \
    __asm__ volatile(op " %0,%1," #imm : "=r"(r_) : "r"(a)); r_; })

show("sltu", m1, one, R("sltu", m1, one));      /* 0xffffffff < 1 ? */
show("sll", one, s33, R("sll", one, s33));      /* 시프트 양 33 */
show("sltiu", 5, -1, I("sltiu", 5, -1));        /* 즉시값 부호 확장 */

컴파일러가 상수 계산을 미리 해 버리지 않도록 입력값은 volatile 변수에 담았다. 출력은 Python 기준 모델(32비트 마스크와 부호 있는 해석을 직접 쓴 몇 줄)과 대조했다.

시험 개수 일치
QEMU 실행 결과 = Python 기준 모델 15 15
상수 분해 공식 (hi·lo로 되돌리기, lo 범위) 1,000,007 1,000,007

처음 돌렸을 때는 15개 중 1개가 틀렸다. 시험 코드에 andi의 즉시값을 0xff로 넣고 표에는 0xfff로 적은 내 실수였다. 기준 모델과 대조하지 않았다면 글에 틀린 숫자가 실렸을 것이다.

8. 설계 체크리스트: ALU와 그 주변

항목 규칙
ALU 연산 10가지: add, sub, and, or, xor, sll, srl, sra, slt, sltu. funct3와 inst[30]으로 고른다
addi와 inst[30] OP-IMM에서 inst[30]은 srai일 때만 연산 선택에 쓴다. addi의 즉시값 bit 10이 1이라고 뺄셈으로 처리하면 안 된다(흔한 버그)
즉시값 확장 sltiu·andi·ori·xori도 즉시값은 부호 확장
시프트 양 레지스터 시프트는 rs2[4:0]만. 즉시값 시프트에서 inst[25]=1은 RV32에서 예약값(불법, 2편)
slt·sltu 덧셈기를 함께 쓴다. slt는 부호가 다르면 a[31], 같으면 (a−b)[31]
오버플로 플래그 없음, 예외 없음. ALU에 트랩 경로가 필요 없다
lui 결과 = 즉시값(아래 12비트 0). ALU에서 "0 + imm"으로 처리하면 덧셈기를 재사용
auipc 결과 = 그 명령어의 pc + imm. ALU 첫째 입력에 pc를 넣는 선택기가 필요하다
rd = x0 결과를 버린다. 예외 없음(HINT)

auipc 줄이 데이터패스 설계에 영향을 준다. 대부분의 명령어는 ALU 첫째 입력에 rs1을 넣지만, auipc(그리고 4편의 jal·분기 주소 계산)는 pc를 넣는다. 그래서 단일 사이클 코어에도 "ALU 입력 A = rs1 또는 pc" 선택기가 생긴다.

정리

  • RV32I 정수 연산은 레지스터끼리 10개, 즉시값 9개, lui·auipc가 전부다. mv·not·neg·seqz 같은 연산은 x0를 활용한 의사 명령어다.
  • 오버플로 플래그와 예외가 없다. 필요하면 slt·bltu로 소프트웨어가 검사하고, 하드웨어는 플래그 의존성과 트랩 경로를 덜어 낸다.
  • sltiu·andi 등의 즉시값도 부호 확장되고, 레지스터 시프트는 아래 5비트만 쓴다. QEMU 실행 15개가 기준 모델과 모두 일치했다.
  • 32비트 상수와 주소는 lui/auipc(위 20비트)와 짝 명령어(아래 12비트)로 만들고, 아래 12비트의 bit 11이 1이면 위쪽을 1 올린다. 무작위 100만 개 중 499,908개가 보정을 받았다.

다음 글: 4편에서는 RV32I의 나머지, 분기·점프와 메모리 접근을 본다. beq~bgeu 여섯 가지 분기, jal·jalr로 함수 호출과 복귀를 만드는 방법, 바이트·하프워드 로드의 부호 확장, 정렬 안 된 접근이 어떻게 처리되는지, 그리고 fence·ecall·ebreak를 QEMU에서 실행해 확인한다.

참고 문헌

  • The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. 2.4 정수 연산 명령어(오버플로 검사 관용구 포함). https://github.com/riscv/riscv-isa-manual
  • RISC-V Assembly Programmer's Manual (의사 명령어, %hi·%lo·%pcrel_hi). https://github.com/riscv-non-isa/riscv-asm-manual
  • D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, 2017. 2장 RV32I, 3장 어셈블리 언어
  • D. Harris, S. Harris, Digital Design and Computer Architecture: RISC-V Edition, 2021. 5.2 산술 회로(덧셈기·비교기·시프터)
  • QEMU virt 보드 문서. https://www.qemu.org/docs/master/system/riscv/virt.html

비슷한 글 추천