이 글의 핵심 3줄
- RV32I 명령어는 모두 32비트이고 형식은 R·I·S·B·U·J 여섯 가지다. 레지스터 번호(rs1·rs2·rd)는 어느 형식에서나 같은 비트 자리에 있고, 즉시값의 부호 비트는 언제나 31번 비트다.
- 즉시값 비트가 뒤섞여 보이는 이유는 하드웨어를 줄이기 위해서다. B는 S와, J는 I와 최대한 같은 자리를 쓰도록 비트를 옮겼다. 즉시값 생성기의 선택기 입력 수를 세어 보니 RISC-V 배치는 70개, 차례로 채운 단순한 배치는 98개였다.
- Python으로 인코더·디코더를 짜서 GNU 어셈블러와 대조했다. 손으로 인코딩한 6개, 무작위 5,000개가 모두 같았다.
1편에서 RISC-V의 큰 지도를 그렸다. 2편부터는 기본 정수 ISA인 RV32I로 들어간다. 이번 글은 명령어의 "모양", 즉 레지스터와 명령어 형식, 즉시값 인코딩을 다룬다. 각 명령어가 무슨 일을 하는지는 3편(연산)과 4편(분기·메모리)에서 본다. 형식을 먼저 보는 이유는, 코어를 설계할 때 디코더와 즉시값 생성기가 바로 이 형식 표에서 나오기 때문이다.
1. 레지스터
RV32I에는 32비트 정수 레지스터 32개(x0~x31)와 프로그램 카운터 pc가 있다. RV64I에서는 모두 64비트가 된다. 이 비트 수를 XLEN이라 부른다.
| 레지스터 | ABI 이름 | 쓰임 |
|---|---|---|
x0 |
zero |
항상 0. 써도 버려진다 |
x1 |
ra |
함수 복귀 주소 |
x2 |
sp |
스택 포인터 |
x3, x4 |
gp, tp |
전역 포인터, 스레드 포인터 |
x5~x7, x28~x31 |
t0~t6 |
임시값 (함수 호출이 덮어써도 됨) |
x8, x9, x18~x27 |
s0~s11 |
보존값 (함수가 돌려놓아야 함) |
x10~x17 |
a0~a7 |
함수 인자와 반환값 |
하드웨어 입장에서 특별한 레지스터는 x0 하나뿐이다. ABI 이름과 쓰임은 소프트웨어끼리의 약속이라(6편에서 자세히), 하드웨어는 x1~x31을 모두 똑같이 다룬다. pc는 일반 레지스터가 아니어서 명령어로 직접 읽거나 쓸 수 없다. auipc, jal, jalr, 분기만이 pc를 쓴다.
x0가 항상 0이라는 규칙 하나로 많은 명령어를 아낀다. 레지스터 복사 mv a0, a1은 addi a0, a1, 0이고, 부호 반전 neg a0, a1은 sub a0, x0, a1이며, 아무것도 안 하는 nop은 addi x0, x0, 0이다. 레지스터 파일을 만들 때는 0번 칸을 실제 플립플롭으로 두지 않고 "읽으면 0, 쓰기는 무시"로 처리하면 된다.
2. 32비트 명령어의 큰 틀

모든 형식에 공통인 칸부터 보자.
| 칸 | 비트 | 뜻 |
|---|---|---|
opcode |
[6:0] | 명령어 종류(큰 분류). 아래 두 비트는 항상 11 |
rd |
[11:7] | 결과를 쓸 레지스터 번호 |
funct3 |
[14:12] | 같은 opcode 안에서 세부 연산 구분 |
rs1 |
[19:15] | 첫째 원본 레지스터 번호 |
rs2 |
[24:20] | 둘째 원본 레지스터 번호 |
funct7 |
[31:25] | 더 세부 구분(R 형식에서만) |
1편에서 본 대로 아래 두 비트가 11이면 32비트 명령어다(그 밖은 C 확장의 16비트 명령어). 그래서 opcode 7비트 중 실제로 종류를 가르는 건 [6:2]의 5비트다. 또 [4:2]가 111인 값은 48비트 이상의 긴 명령어를 위해 비워 두었다.
이 배치의 핵심은 레지스터 번호 칸이 형식과 상관없이 같은 자리라는 점이다. 명령어가 R인지 S인지 알기 전에 [19:15]와 [24:20]을 레지스터 파일 주소로 바로 보낼 수 있다. 파이프라인 코어에서 디코드와 레지스터 읽기를 같은 사이클에 병렬로 하는 것이 이 덕분이다. 어떤 형식은 rs2가 없지만(I, U, J), 그때 읽은 값은 그냥 쓰지 않으면 된다. 쓸데없이 읽는 쪽이, 형식을 먼저 알아내고 읽는 쪽보다 빠르다.
3. 형식 여섯 가지
| 형식 | 쓰는 곳 | 레지스터 | 즉시값 |
|---|---|---|---|
| R | 레지스터끼리 연산 add, sub, sll … |
rd, rs1, rs2 | 없음 |
| I | 즉시값 연산 addi, 로드 lw, jalr |
rd, rs1 | 12비트, imm[11:0] |
| S | 스토어 sw, sh, sb |
rs1, rs2 | 12비트, 두 조각 |
| B | 조건 분기 beq, blt … |
rs1, rs2 | 13비트(짝수), imm[12:1] |
| U | 상위 20비트 lui, auipc |
rd | imm[31:12] |
| J | 점프 jal |
rd | 21비트(짝수), imm[20:1] |
사실상 형식은 R·I·S·U 넷이고, B와 J는 각각 S와 U의 변형이다. B는 S처럼 rs1·rs2와 12비트 칸을 갖지만 분기 거리라서 2의 배수만 쓰고, J는 U처럼 20비트 칸을 갖지만 점프 거리라서 2의 배수만 쓴다. 명세가 B·J를 S·U의 변형으로 소개하는 이유다.
목적지가 늘 2의 배수인 이유는 C 확장 때문이다. 16비트 명령어가 있으면 명령어는 2바이트 경계에 놓인다. 그래서 imm[0]은 언제나 0이고, 저장하지 않는 대신 그 칸으로 범위를 두 배 늘렸다. 분기는 ±4KiB, jal은 ±1MiB까지 간다. C를 지원하지 않는 코어라면 목적지가 4의 배수가 아닐 때 명령어 주소 정렬 예외를 낸다(1편 체크리스트).
4. 즉시값 비트는 왜 뒤섞여 있나
그림의 B와 J를 보면 즉시값 비트 순서가 이상하다. B는 맨 왼쪽에 imm[12], 오른쪽 끝(7번 비트)에 imm[11]이 있고, J는 imm[20], imm[10:1], imm[11], imm[19:12] 순서다. 사람이 읽기에는 불편하다. 그런데 하드웨어 입장에서 보면 이유가 분명하다.
즉시값 생성기(immediate generator)는 명령어 32비트를 받아 형식에 맞는 32비트 즉시값을 만드는 회로다. 출력 비트 하나하나는 "형식에 따라 명령어의 몇 번 비트를 고를지" 정하는 선택기(multiplexer)다. 형식마다 같은 즉시값 비트가 같은 명령어 비트에서 오면 선택기 입력이 줄어든다. RISC-V는 이 규칙을 지키도록 비트를 옮겼다.
| 즉시값 비트 | I | S | B | U | J | 서로 다른 출처 |
|---|---|---|---|---|---|---|
| imm[0] | inst[20] | inst[7] | 0 | 0 | 0 | 3 |
| imm[4:1] | inst[24:21] | inst[11:8] | inst[11:8] | 0 | inst[24:21] | 3 |
| imm[10:5] | inst[30:25] | inst[30:25] | inst[30:25] | 0 | inst[30:25] | 2 |
| imm[11] | inst[31] | inst[31] | inst[7] | 0 | inst[20] | 4 |
| imm[19:12] | inst[31] | inst[31] | inst[31] | inst[19:12] | inst[19:12] | 2 |
| imm[30:20] | inst[31] | inst[31] | inst[31] | inst[30:20] | inst[31] | 2 |
| imm[31] | inst[31] | inst[31] | inst[31] | inst[31] | inst[31] | 1 |
표에서 몇 가지가 보인다.
- imm[10:5]는 네 형식에서 모두 inst[30:25]다. 그래서 출처가 둘(명령어 비트, 또는 U의 0)뿐이다.
- imm[4:1]은 B가 S와, J가 I와 같은 자리를 쓴다. B에서 imm[0]을 저장하지 않으니 S의 inst[7] 자리가 비는데, 그 칸에 imm[11]을 넣었다. 그 덕에 B의 imm[4:1]과 imm[10:5]가 S와 정확히 겹친다.
- J도 같은 방식이다. I의 inst[20] 자리(I에서는 imm[0])에 J의 imm[11]을 넣어서, imm[10:1]이 I의 inst[30:21]과 겹친다. imm[19:12]는 U와 같은 inst[19:12]다.
- imm[31]은 언제나 inst[31]이다. 부호 비트가 한 자리에 고정되어 있어서, 형식을 다 판별하기 전에 부호 확장을 시작할 수 있다. 선택기도 필요 없다(출처 1).
이 효과를 직접 세어 봤다. 출력 비트마다 형식 다섯 개(I·S·B·U·J)의 출처를 모으고, 서로 다른 출처의 수를 선택기 입력 수로 쳤다(상수 0도 입력 하나로 센다). 비교 대상으로는 같은 비트 칸을 쓰되 즉시값을 낮은 비트부터 차례로 채운 "단순한 배치"를 정했다. B는 S의 칸에 imm[12:1]을 순서대로, J는 inst[31:12]에 imm[20:1]을 순서대로 넣는 식이다.

RISC-V 배치는 입력이 모두 70개, 가장 넓은 선택기가 4입력(imm[11])이었다. 단순 배치는 98개, 가장 넓은 선택기가 5입력이었다. 차이는 낮은 비트에서 크게 난다. 단순 배치에서는 B와 J의 imm[1]이 S·I와 한 칸씩 어긋나서, imm[1]~imm[11]의 거의 모든 비트가 출처를 하나씩 더 갖는다. 선택기 입력이 줄면 면적과 함께 즉시값 생성기를 지나는 경로도 짧아진다. 이 경로는 디코드 단계의 임계 경로에 걸리기 쉽다.
5. 손으로 인코딩해 보기
형식을 익히는 가장 좋은 방법은 직접 비트를 채워 보는 것이다. 여섯 형식에서 하나씩 골라 손으로 인코딩하고, GNU 어셈블러(riscv64-elf-as)가 만든 값과 맞춰 봤다.
addi x5, x6, -20 (I 형식). -20을 12비트 2의 보수로 쓰면 1111 1110 1100(0xFEC)이다. rs1=6(00110), funct3=0, rd=5(00101), opcode=OP-IMM(0010011)을 이어 붙이면 다음과 같다.
imm[11:0] rs1 f3 rd opcode
111111101100 00110 000 00101 0010011
= 0xfec30293
beq x1, x2, -16 (B 형식). -16을 13비트로 쓰면 1 1111 1111 0000이다. 여기서 imm[12]=1, imm[11]=1, imm[10:5]=111111, imm[4:1]=1000이고, imm[0]은 저장하지 않는다. 이것을 B의 칸 순서대로 놓는다.
[12] [10:5] rs2 rs1 f3 [4:1] [11] opcode
1 111111 00010 00001 000 1000 1 1100011
= 0xfe2088e3
분기 거리는 그 분기 명령어의 주소를 기준으로 한다. 그래서 어셈블러에는 beq x1,x2,.-16처럼 현재 위치(.)에서의 거리로 적었다.
| 명령어 | 형식 | 손으로 구한 값 | GNU as |
|---|---|---|---|
add x3,x4,x5 |
R | 005201b3 |
005201b3 |
addi x5,x6,-20 |
I | fec30293 |
fec30293 |
sw x7,8(x2) |
S | 00712423 |
00712423 |
beq x1,x2,.-16 |
B | fe2088e3 |
fe2088e3 |
lui x10,0x12345 |
U | 12345537 |
12345537 |
jal x1,.+2048 |
J | 001000ef |
001000ef |
jal x1,.+2048이 재미있다. 2048은 imm[11]만 1인 값이다. J 형식에서 imm[11]은 inst[20]에 있으니, 32비트 값 중 20번 비트 하나만 켜진 0x00100000에 opcode와 rd를 더한 0x001000ef가 된다.
6. 실습: 인코더·디코더를 짜서 어셈블러와 대조
손계산 몇 개로는 부족하니 Python으로 RV32I 인코더와 디코더를 짜서 대조했다. 장비는 맥 M1 Max, 대조 기준은 GNU binutils(riscv64-elf-as·objdump, GCC 16.2와 함께 설치)다. 디코더의 즉시값 부분이 위 표를 그대로 옮긴 것이다.
def decode_imm(fmt, w):
b = lambda hi, lo: (w >> lo) & ((1 << (hi - lo + 1)) - 1)
if fmt == 'I': return sext(b(31, 20), 12)
if fmt == 'S': return sext(b(31, 25) << 5 | b(11, 7), 12)
if fmt == 'B': return sext(b(31, 31) << 12 | b(7, 7) << 11
| b(30, 25) << 5 | b(11, 8) << 1, 13)
if fmt == 'U': return sext(b(31, 12), 20) << 12
if fmt == 'J': return sext(b(31, 31) << 20 | b(19, 12) << 12
| b(20, 20) << 11 | b(30, 21) << 1, 21)
sext(v, n)은 n비트 값을 부호 확장하는 함수다. 시험은 이렇게 했다. RV32I 명령어 중 형식 여섯 가지를 대표하는 34종(시프트 즉시값·FENCE·시스템 명령어는 뺐다)에서 무작위로 5,000개를 고르고, 레지스터와 즉시값도 범위 안에서 무작위로 정했다(시드 2 고정). 같은 명령어를 내 인코더로 한 번, 어셈블리 문장으로 써서 GNU as로 한 번 만들어 32비트 값을 비교했다. 그다음 GNU as가 만든 값을 내 디코더로 되읽어, 원래의 레지스터 번호와 즉시값이 나오는지 봤다.
| 시험 | 개수 | 일치 |
|---|---|---|
| 손으로 인코딩 | 6 | 6 |
| 무작위 인코딩 (내 인코더 = GNU as) | 5,000 | 5,000 |
| 무작위 디코딩 (내 디코더로 되읽기) | 5,000 | 5,000 |
디코더를 하드웨어로 옮길 때도 같은 방법을 쓴다. 기준 모델(여기서는 GNU as, 나중에는 Spike 같은 명령어 시뮬레이터)과 무작위 입력으로 대조하는 것이다. 표 하나를 잘못 옮겨도 5,000개 중 수백 개가 바로 틀리게 나와서 실수를 숨기기 어렵다.
7. 설계 체크리스트: 디코더가 지켜야 할 것
RV32I 디코더는 opcode의 [6:2]를 보고 형식을 정한다. RV32I가 쓰는 주 opcode는 아홉 개다.
| opcode[6:0] | 이름 | 형식 | 명령어 |
|---|---|---|---|
0110111 |
LUI | U | lui |
0010111 |
AUIPC | U | auipc |
1101111 |
JAL | J | jal |
1100111 |
JALR | I | jalr |
1100011 |
BRANCH | B | beq bne blt bge bltu bgeu |
0000011 |
LOAD | I | lb lh lw lbu lhu |
0100011 |
STORE | S | sb sh sw |
0010011 |
OP-IMM | I | addi slti sltiu xori ori andi slli srli srai |
0110011 |
OP | R | add sub sll slt sltu xor srl sra or and |
여기에 FENCE(MISC-MEM, 0001111)와 ECALL·EBREAK(SYSTEM, 1110011)가 더해진다(4편). 디코더를 만들 때 놓치기 쉬운 점은 다음과 같다.
| 항목 | 규칙 |
|---|---|
| 없는 opcode | 위에 없는 opcode는 불법 명령어 예외 |
| 없는 funct3 | 예: LOAD의 funct3 011(RV32에는 ld가 없음), BRANCH의 010·011은 불법 |
| funct7 | OP에서 0000000과 0100000(sub·sra) 말고는 불법(M 확장이 없을 때) |
| 시프트 즉시값 | RV32I의 slli·srli·srai는 시프트 양이 5비트. inst[25]가 1이면 예약값이라 불법으로 처리 |
jalr |
funct3가 000이 아니면 불법 |
| 모두 0, 모두 1 | 명령어 0x00000000과 0xffffffff는 명세가 불법으로 정해 둔 값. 지워진 메모리로 뛰어든 실수를 잡으려는 것 |
| rd = x0 | addi x0, x5, 3처럼 결과를 x0에 쓰는 연산은 불법이 아니라 HINT다. 예외 없이 아무 일도 안 하고 넘어가야 한다 |
| 필드 추출 | rs1·rs2·rd는 형식을 판별하기 전에 고정 자리에서 꺼낸다. 즉시값의 부호는 inst[31]에서 바로 |
마지막에서 둘째 줄은 자주 틀린다. "x0에 쓰는 명령어는 쓸모없으니 불법"으로 처리하면, 명세가 미래 확장용 힌트로 남겨 둔 인코딩에서 멀쩡한 프로그램이 예외로 멈춘다. nop 자체가 addi x0, x0, 0이다.
정리
- RV32I는 32비트 레지스터 32개와
pc를 가진다. 하드웨어가 특별 취급하는 레지스터는 항상 0인x0뿐이다. - 명령어는 32비트, 형식은 R·I·S·B·U·J 여섯 가지다. rs1·rs2·rd는 언제나 같은 자리라 디코드와 레지스터 읽기를 동시에 할 수 있고, 즉시값 부호는 언제나 inst[31]이다.
- B는 S와, J는 I와 같은 자리를 쓰도록 즉시값 비트를 옮겼다. 선택기 입력이 단순 배치의 98개에서 70개로 줄었다.
- 직접 짠 인코더·디코더가 GNU as와 6개(손계산), 5,000개(무작위) 모두 일치했다. 디코더의 불법 명령어·HINT 규칙은 위 체크리스트로 정리했다.
다음 글: 3편에서는 RV32I의 정수 연산을 본다. 덧셈·논리·시프트·비교 명령어가 무엇을 계산하는지, slt와 sltu가 어떻게 다른지, 그리고 lui·auipc와 addi를 조합해 32비트 상수와 주소를 만드는 방법(부호 확장 때문에 생기는 +1 보정 포함)을 손으로 따라간다.
참고 문헌
- The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. 2장 RV32I(2.1 프로그래머 모델, 2.2 명령어 형식, 2.3 즉시값 인코딩, 2.9 HINT), 1.5 명령어 길이 인코딩. https://github.com/riscv/riscv-isa-manual
- D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, 2017. 2장 RV32I
- D. Harris, S. Harris, Digital Design and Computer Architecture: RISC-V Edition, Morgan Kaufmann, 2021. 6.4 기계어, 7.3 즉시값 확장
- RISC-V ELF psABI (레지스터 ABI 이름). https://github.com/riscv-non-isa/riscv-elf-psabi-doc
