RISC-V CPU #2: Instruction이란

명령어는 32비트 숫자다: 인코딩, 여섯 가지 포맷, 그리고 즉시값의 수수께끼

🇰🇷 한국어 | 🇺🇸 English

시리즈 로드맵

# 주제 상태
1 RISC-V란 무엇인가
2 Instruction이란: 명령어 인코딩과 포맷
3 거시적 파이프라인과 각 유닛의 하드웨어 구조 🔲
4 파이프라인 심화: 해저드 처리와 분기 예측 🔲
5 요약, 심화 아키텍처, 다른 CPU 아키텍처 🔲

명령어는 결국 32비트 숫자다

1편에서 ISA는 하드웨어와 소프트웨어의 계약이라고 했다. 그 계약의 최소 단위가 명령어(instruction) 다.

add x5, x6, x7 같은 어셈블리 표기는 사람이 읽기 위한 것이다. CPU가 실제로 받는 것은 32개의 0과 1이다.

사람이 쓰는 것:   add x5, x6, x7
CPU가 받는 것:    0000000 00111 00110 000 00101 0110011
                  (하나의 32비트 숫자)

CPU가 명령어를 실행하는 첫 단계는 이 32비트를 해석하는 것이다. 어떤 연산인지, 어떤 레지스터를 쓰는지, 상수가 있는지를 비트 위치에서 읽어낸다. 이 해석을 디코딩(decoding) 이라 한다.

명령어를 어떤 비트에 무엇을 담을지 정한 규칙이 명령어 포맷(instruction format) 이다.


왜 소수의 고정 포맷인가

CISC는 명령어 길이가 가변적이고 필드 위치도 명령어마다 다르다. 디코더는 먼저 명령어의 경계와 종류를 알아내야 다음 필드를 읽을 수 있다.

RISC-V는 정반대다. 기본 명령어는 모두 32비트 고정 길이이고, 필드가 놓이는 위치가 소수의 포맷으로 규격화돼 있다.

이 규칙성의 이득은 디코딩 속도다. 필드 위치가 고정되면 디코더는 명령어 종류를 완전히 파악하기 전에도 필요한 비트를 병렬로 뽑아낼 수 있다. 특히 레지스터 번호를 즉시 읽어 레지스터 파일 접근을 미리 시작할 수 있다. 이 단순함이 3편에서 다룰 파이프라인 실행의 토대가 된다.

RISC-V 기본 정수 명령어에는 여섯 가지 포맷이 있다.

RISC-V 기본 명령어 포맷

포맷 용도 대표 명령어
R 레지스터끼리 연산 add, sub, and, sll
I 즉시값 연산, 로드, jalr addi, lw, jalr
S 스토어 sw, sb
B 조건 분기 beq, bne, blt
U 상위 20비트 즉시값 lui, auipc
J 무조건 점프 jal

공통 필드: 왜 레지스터는 항상 같은 자리에 있는가

위 그림에서 파란색(레지스터) 필드를 보면, 포맷이 달라져도 위치가 거의 고정돼 있다.

  • opcode (비트 6:0): 모든 포맷에서 같은 자리. 연산의 대분류를 정한다.
  • rd (비트 11:7): 목적지 레지스터. R/I/U/J에서 같은 자리.
  • rs1 (비트 19:15): 첫 소스 레지스터. R/I/S/B에서 같은 자리.
  • rs2 (비트 24:20): 둘째 소스 레지스터. R/S/B에서 같은 자리.
  • funct3 (비트 14:12), funct7 (비트 31:25): 같은 opcode 안에서 세부 연산을 구분.

이것이 의도된 설계다. rs1, rs2가 항상 비트 19:15, 24:20에 있으므로, 디코더는 명령어 종류를 판정하기 전에 그 비트를 곧바로 레지스터 파일 주소로 보낸다. 레지스터 값 읽기가 디코딩과 겹쳐 진행된다. 만약 포맷마다 레지스터 위치가 달랐다면, 종류를 먼저 판정한 뒤에야 레지스터를 읽을 수 있어 지연이 생긴다.

funct3funct7은 opcode를 보조한다. 예를 들어 addsub는 opcode와 funct3가 같고 funct7만 다르다(0000000 vs 0100000). 하나의 대분류 안에서 세부 연산을 나누는 계층 구조다.


포맷별 상세

R-type: 레지스터-레지스터 연산

두 소스 레지스터로 계산해 목적지 레지스터에 쓴다. 즉시값이 없다.

add x5, x6, x7   →  x5 = x6 + x7   (funct7 0000000, funct3 000)
sub x5, x6, x7   →  x5 = x6 - x7   (funct7 0100000, funct3 000)
and x5, x6, x7   →  x5 = x6 & x7   (funct7 0000000, funct3 111)

비트 예 (add):
funct7   rs2   rs1   funct3   rd    opcode
0000000  00111 00110 000      00101 0110011
   |      x7    x6    ADD      x5    OP

addsubfunct7로, addandfunct3로 갈린다. opcode는 셋 다 같다.

I-type: 즉시값 연산과 로드

한 소스 레지스터와 12비트 즉시값으로 계산한다. 로드 명령어도 I 포맷이다(주소 = 베이스 레지스터 + 오프셋).

addi x5, x6, 10   →  x5 = x6 + 10           (즉시값 연산)
lw   x5, 8(x6)    →  x5 = 메모리[x6 + 8]     (로드)
jalr x1, x6, 0    →  x1 = PC+4, PC = x6 + 0  (레지스터로 점프)

비트 예 (addi):
imm[11:0]     rs1   funct3   rd    opcode
000000001010  00110 000      00101 0010011

세 명령어가 하는 일은 즉시값 연산, 메모리 로드, 레지스터 점프로 서로 다르지만 모두 I 포맷이다. 한 소스 레지스터와 12비트 즉시값이라는 공통 구조를 공유하기 때문이다. 12비트 즉시값은 부호 있는 수로, -2048 ~ +2047 범위다.

S-type: 스토어

레지스터 값을 메모리에 쓴다. 목적지가 레지스터가 아니라 메모리 주소이므로 rd가 없다. 대신 즉시값(오프셋)이 두 조각으로 나뉜다.

sw x7, 8(x6)   →  메모리[x6 + 8] = x7 전체 4바이트   (funct3 010)
sh x7, 8(x6)   →  메모리[x6 + 8] = x7 하위 2바이트   (funct3 001)
sb x7, 8(x6)   →  메모리[x6 + 8] = x7 하위 1바이트   (funct3 000)

비트 예 (sw):
imm[11:5]  rs2   rs1   funct3   imm[4:0]  opcode
0000000    00111 00110 010      01000     0100011

세 명령어는 funct3로 쓰기 크기를 구분한다(워드/하프워드/바이트). rd 자리(비트 11:7)에는 즉시값의 하위 5비트가 들어간다. 이렇게 나눠도 rs1, rs2는 원래 자리를 지킨다.

B-type: 조건 분기

두 레지스터를 비교해, 조건이 맞으면 PC를 상대 이동한다. S와 비슷하지만 즉시값이 분기 오프셋을 인코딩한다.

beq x6, x7, LABEL   →  x6 == x7 이면 분기   (funct3 000)
bne x6, x7, LABEL   →  x6 != x7 이면 분기   (funct3 001)
blt x6, x7, LABEL   →  x6 <  x7 이면 분기   (funct3 100, 부호 있는 비교)

비트 필드:
imm[12|10:5]  rs2   rs1   funct3   imm[4:1|11]  opcode

세 명령어는 funct3로 비교 조건을 구분한다(같음/다름/작음). 분기 오프셋은 2바이트 단위다(최하위 비트가 항상 0). 그래서 12비트로 ±4KB 범위를 표현한다. 비트 순서가 뒤섞인 이유는 아래에서 설명한다.

U-type: 상위 20비트 즉시값

20비트 즉시값을 결과의 상위 20비트에 놓는다. 큰 상수나 주소를 만들 때 쓴다.

lui   x5, 0x12345   →  x5 = 0x12345000        (상위 20비트에 상수)
auipc x5, 0x12345   →  x5 = PC + (0x12345 << 12)  (PC 상대 주소)

기본 정수 ISA의 U 타입 명령어는 luiauipc 이 둘이 전부다. 다른 타입과 달리 예시가 두 개뿐인 이유는, U 포맷 자체가 이 두 용도(절대 상수 상위비트, PC 상대 상위비트)만을 위해 존재하기 때문이다. luiaddi를 조합하면 32비트 임의 상수를 두 명령어로 만든다.

J-type: 무조건 점프

jal(jump and link)은 PC를 상대 이동하면서 반환 주소를 rd에 저장한다. 함수 호출에 쓴다.

jal x1, FUNC   →  x1 = PC + 4 (반환 주소), PC += 오프셋

기본 정수 ISA의 J 타입 명령어는 jal 하나뿐이다. 함수에서 돌아올 때 쓰는 jalr은 목적지를 레지스터로 지정하므로(고정 오프셋 + 레지스터) J가 아니라 I 타입이다. 즉 점프는 대상 주소를 즉시값으로 주면 J(jal), 레지스터로 주면 I(jalr)로 갈린다. 점프 오프셋도 분기와 마찬가지로 2바이트 단위다(최하위 비트가 항상 0). 그래서 jal은 20비트 즉시값으로 ±1MB 범위를 점프한다.


즉시값 인코딩의 수수께끼

S, B, J 포맷을 보면 즉시값 비트가 이상하게 흩어져 있다. imm[12|10:5], imm[4:1|11] 같은 표기는 즉시값의 비트가 명령어 안에서 연속되지 않고 뒤섞여 배치됨을 뜻한다. 왜 이렇게 복잡하게 만들었을까.

이유는 두 가지 불변식을 지키기 위해서다.

1. 레지스터 필드 위치 고정

앞서 본 대로 rs1, rs2, rd는 모든 포맷에서 같은 비트에 있어야 한다. 즉시값이 이 자리를 침범할 수 없으므로, 남는 비트에 즉시값을 조각내어 채운다. 레지스터를 먼저 읽는 이득을 지키기 위한 대가다.

2. 부호 비트 위치 고정

모든 즉시값의 최상위 부호 비트가 항상 명령어의 비트 31에 오도록 배치했다.

부호 확장(sign extension) 하드웨어:
  즉시값이 몇 비트든, 부호 비트는 언제나 명령어[31]
  → 부호 확장 회로가 명령어[31] 하나만 보면 됨 (단순, 고속)

부호 비트 위치가 고정되면 부호 확장 회로가 단순해진다. 12비트든 20비트든 명령어의 비트 31을 부호로 삼아 상위를 채우면 된다. 만약 부호 비트 위치가 포맷마다 달랐다면, 부호 확장 전에 포맷 판정을 기다려야 해 느려진다.

즉시값 비트를 재조립하는 비용은 배선(멀티플렉서)뿐이다. 이 재배치는 병렬로 이뤄지고 임계 경로에 놓이지 않는다. 사람이 읽기엔 복잡하지만 하드웨어에는 유리한 거래다. RISC-V 설계의 실용성을 보여주는 대표적 예다.


디코딩: 32비트를 필드로 나누기

정리하면, CPU의 디코더는 32비트 명령어를 받아 다음을 병렬로 수행한다.

32비트 명령어
      │
      ├─ opcode[6:0]      → 연산 대분류 판정
      ├─ rs1[19:15]       → 레지스터 파일 읽기 시작 (즉시)
      ├─ rs2[24:20]       → 레지스터 파일 읽기 시작 (즉시)
      ├─ rd[11:7]         → 목적지 레지스터 번호 보관
      ├─ funct3, funct7   → 세부 연산 확정
      └─ immediate        → 재조립 + 부호 확장

이 모든 추출이 고정된 비트 위치 덕분에 동시에 일어난다. 명령어 포맷의 규칙성이 곧 디코딩 속도이며, 이는 다음 편에서 다룰 파이프라인이 명령어를 매 클럭 하나씩 흘려보낼 수 있는 이유다.


레퍼런스

기본 정수 ISA(RV32I)의 전체 명령어 목록과 정확한 인코딩은 다음에서 확인할 수 있다.

RV32I 기본 집합은 약 40개 명령어로 구성된다. 이 글에서 다룬 예시는 그중 각 포맷의 대표만 뽑은 것이다.


요약

항목 내용
명령어 32비트 고정 길이 숫자
포맷 R / I / S / B / U / J 여섯 가지
공통 필드 opcode, rd, rs1, rs2, funct3/7이 고정 위치
레지스터 고정 디코딩 전에 레지스터 읽기 시작 가능
즉시값 분산 레지스터 위치 + 부호 비트(항상 [31]) 고정을 위해
디코딩 고정 위치 덕분에 필드 추출이 병렬로

RISC-V 명령어는 32비트 고정 길이이며, 여섯 가지 포맷으로 필드 위치를 규격화한다. 레지스터와 부호 비트를 고정 위치에 두기 위해 즉시값을 조각내는 설계는, 사람의 가독성을 희생해 하드웨어의 디코딩 속도를 얻은 결정이다. 이제 명령어가 무엇이고 어떻게 인코딩되는지 보았으니, 다음 편에서는 CPU가 이 명령어들을 실제로 실행하는 구조로 들어간다. 명령어 하나가 거치는 다섯 단계와, 각 단계를 담당하는 하드웨어 유닛을 거시적으로 조망한다.

다음 글: RISC-V CPU #3 - 거시적 파이프라인과 각 유닛의 하드웨어 구조 (예정)

Share: LinkedIn