컴퓨터 아키텍처와 RISC-V

컴퓨터 아키텍처와 RISC-V CPU의 구조에 대해 알아보겠습니다.

본 글의 내용은 포항공과대학교의 CSED311의 내용을 기반으로 수업에서 다루지 않은 몇몇 내용을 추가하여 구성하였습니다.

아키텍처란?

컴퓨터 아키텍처는 하드웨어와 소프트웨어 사이의 인터페이스 규약입니다. 구체적으로는 CPU가 이해하는 명령어 집합(ISA, Instruction Set Architecture)을 중심으로, 아래 내용들을 정의합니다.

  • 명령어(인스트럭션): 어떤 연산(덧셈,분기,메모리 접근 등)을 지원하고 어떤 형식으로 표현하는가
  • 레지스터: 레지스터가 몇 개이고, 각각 몇 비트인가, 각각의 용도는 무엇인가
  • 메모리 모델: 주소 공간 크기, 바이트 순서(엔디언)
  • 예외/인터럽트 처리: 오류나 외부 이벤트를 어떻게 다루는가

아키텍처는 실제 하드웨어적 구현과 무관하게 동일하게 유지되는 추상 계층 입니다. 컴파일러가 이 규약만 보고 해당 아키텍처에 맞는 코드를 생성하면, 해당 아키텍처를 구현한 어떤 칩에서든 동작합니다. 따라서 제조사가 다르더라도 아키텍처가 같으면 유저는 동일한 OS와 응용 프로그램을 실행할 수 있습니다. (AMD와 Intel의 x86-64 CPU를 생각할 수 있습니다)

반면 마이크로 아키텍처는 아키텍처라는 "약속"을 실제 하드웨어로 구현하는 내부 설계입니다. 파이프라인, 비순차 실행, 분기 예측 등 최적화나 세부적인 구현이 마이크로 아키텍처에 해당합니다.

아키텍처의 유형

컴퓨터 아키텍처의 주요 유형에는 폰 노이만 아키텍처, 하버드 아키텍처, 데이터플로우 아키텍처 등이 있습니다.

폰 노이만 아키텍처

폰 노이만 아키텍처는 존 폰 노이만이 제안한 컴퓨터의 기본 구조로, 오늘날 거의 모든 범용 컴퓨터의 토대가 됩니다. 폰 노이만 아키텍처 이전의 컴퓨터(ENIAC 등)는 프로그램을 바꾸려면 물리적으로 배선을 변경해야 했습니다.

당시 ENIAC을 개발한 모클리와 에커트는 후속 컴퓨터 EDVAC을 설계하면서 프로그램과 데이터를 같은 메모리에 저장하는 방법을 구상합니다. 이렇게 하면 프로그램을 메모리에 올리기만 하면 하나의 배선으로 서로 다른 작업을 수행할 수 있다는 장점이 있습니다. (이후 폰 노이만이 이 아이디어를 정리한 보고서를 단독 저자로 배포하게 되면서, 이러한 구조의 이름이 폰 노이만 아키텍처가 되었습니다.)

폰 노이만 아키텍처는 CPU가 메모리에서 명령어를 가져오고, 해석하고, 실행하는 과정을 반복하여 프로그램을 실행합니다. 이때 명령어와 데이터가 같은 메모리, 같은 데이터 버스를 공유하기 때문에, CPU가 아무리 빠르더라도 메모리 대역폭에 의해 성능이 제한되는 폰 노이만 병목(Von Neumann Bottleneck)이 일어납니다. 이러한 이유로, 현대 CPU는 폰 노이만 아키텍처를 기반으로 하지만, 전통적인 폰 노이만 아키텍처의 구조를 그대로 사용하지 않고, 아래 하버드 아키텍처의 구조와 적절히 결합하여 사용합니다.

하버드 아키텍처

하버드 아키텍처는 폰 노이만 아키텍처와 비슷한 시기 하버드 대학의 Mark I 컴퓨터에서 유래한 아키텍처입니다. Mark I은 명령어를 펀치 테이프에서, 데이터를 기계식 카운터에서 따로 읽었기 때문에, 폰 노이만 아키텍처와 달리 명령어 메모리와 데이터 메모리를 물리적으로 완전히 분리한 구조를 가집니다.

하버드 아키텍처는 명령어와 데이터의 버스를 구분하여 명령어와 데이터를 동시에 읽을 수 있기 때문에, 처리량이 높다는 장점이 있습니다. 하지만 두 메모리간의 용량을 유연하게 나눌 수 없고, 프로그램을 명령어 메모리로 옮기는 별도 메커니즘이 필요합니다. 현대에는 마이크로컨트롤러(아두이노에 들어가는 AVR 등)나 DSP(디지털 신호 처리기) 같은 일부 분야에서 하버드 아키텍처가 이용됩니다.

수정된 하버드 아키텍처

수정된 하버드 아키텍처는 현대 CPU 대부분이 채택한 폰 노이만 아키텍처와 하버드 아키텍처 사이의 절충안입니다. L1 캐시 레벨에서 I-cache와 D-cache를 분리해 하버드 아키텍처의 높은 대역폭이라는 이점을 취하고, 그 이하의 메모리는 통합하여 폰 노이만 아키텍처의 유연성을 유지합니다. CPU 입장에서는 L1 캐시의 메모리가 명령어와 데이터로 분리되어 있으므로 하버드 아키텍처처럼 동작하지만, 캐시를 직접 다루지 않는 컴파일러와 프로그래머의 입장에서는 하나의 주소공간으로 컴퓨터를 다룰 수 있습니다.

데이터플로우 아키텍처

데이터플로우 아키텍처는 위 하버드 아키텍처나 폰 노이만 아키텍처와 근본적으로 다른 아키텍처 패러다임입니다. 폰 노이만 방식은 프로그램 카운터가 "다음에 실행할 명령어"를 순차적으로 지정합니다. 데이터플로우 아키텍처는 이러한 폰 노이만 방식의 순차적인 연산 시행이라는 한계를 탈피하기 위해 설계된 아키텍처로, 프로그램 카운터가 없습니다. 데이터플로우 아키텍처에서는 프로그램 카운터가 순차적으로 어떤 명령어를 수행할지 지정하지 않아도 필요한 데이터가 전부 준비되면 명령어가 즉시 실행됩니다. 프로그램의 실행 순서를 프로그래머나 하드웨어가 정하는 것이 아니라, 데이터 자체가 실행 흐름을 결정하는 것입니다.

데이터플로우 아키텍처에서 프로그램은 순차적인 명령어 목록이 아니라, 방향 그래프로 표현됩니다. (a + b) * (c - d)와 같은 연산을 시행할 때, 덧셈 노드와 뺄셈 노드는 서로 의존성이 없으므로 동시에 실행됩니다. 두 연산의 결과가 모두 곱셈 노드에 도착하면 곱셈 연산이 발화(fire)합니다. 명시적으로 프로그래머가 병렬 연산을 지정하지 않아도, 자동으로 모든 연산이 병렬적으로 시행되는 것입니다.

1970년대 처음 등장한 데이터플로우 아키텍처는 1980-1990년대 차세대 컴퓨터로 활발하게 연구되었으나, 다양한 한계로 인해 상용화에는 실패합니다. 데이터플로우 아키텍처에 필요한 연산들이 하드웨어적으로 매우 비싸기도 했고, 대규모 데이터 구조를 다룸에 있어서의 어려움, 기존의 모든 소프트웨어 인프라가 순차 실행 모델을 전제로 작성되어 있다는 복합적인 문제 때문이었습니다.

순수한 데이터플로우 컴퓨터는 현재 사라졌지만, 아이디어는 현대까지 남아 이어지고 있습니다. 폰 노이만 모델 안에서의 비순차 실행(OoO) 구조, FPGA에서의 회로 설계 등에서 데이터플로우 아키텍처의 설계 사상이 이어지고 있고, 최근에는 AI 가속 프로세서 들이 데이터플로우 원리를 하드웨어에 다시 적용하고 있어, 비록 범용 컴퓨팅 머신으로 상용화되지는 않았지만, 다양한 특수 목적 하드웨어에서 데이터플로우 아키텍처를 이용하려는 연구가 지속되고 있습니다.

ISA

ISA는 Instruction Set Architecture의 약자로, 소프트웨어와 하드웨어 사이의 인터페이스라고 할 수 있습니다. ISA는 아래와 같은 사항들을 결정합니다.

  • 명령어 세트에 어떤 연산이 존재하는지, 각 명령어의 인코딩 형식은 어떤지
  • 하드웨어가 직접적으로 지원하는 데이터 타입에는 어떤 것들이 있는지, 바이트 순서는 어떤지 (리틀 엔디언, 빅 엔디언)
  • 레지스터 구성과 레지스터의 개수, 비트폭과 용도
  • 주소 지정 방식 (Addressing Mode)와 명령어가 피연산자의 위치를 어떻게 지정하는지
  • 메모리 모델의 주소 공간 크기
  • 어떤 상황(0으로 나누기, 페이지 폴트, 잘못된 명령어)에서 예외가 발생하는지, 인터럽트는 어떻게 처리되는지
  • 권한 수준과 보호 모드/커널 모드의 구분, 각 모드에서 접근 가능한 레지스터와 명령어의 범위, 가상 메모리 관련 부분

반면, ISA 아래에 위치하는 마이크로아키텍처와 관련된 부분(클럭 속도, 파이프라인 단계, 캐시 구조 등)과 소프트웨어 계층의 규약들(ABI, 메모리 맵, 펌웨어 인터페이스)은 ISA가 지정하지 않습니다.

Programmer Visible State는 ISA에서 프로그래머(컴파일러)가 명령어를 통해 직접 읽거나 쓸 수 있는 모든 하드웨어 상태입니다. 프로그램의 의미(sementics)는 이 상태들의 변화로 정의됩니다. 구체적으로는 범용 레지스터, 프로그램 카운터, 스택 포인터, 플래그 레지스터, 메모리 주소 공간 전체 등이 해당합니다.

이와 반대로 보이지 않는 상태(microarchitectural state)는 캐시 내용, 분기 예측기의 히스토리, 리오더 버퍼, 예약 스테이션, 물리 레지스터 파일, TLB 엔트리 등이 있습니다. 이들은 성능에만 영향을 주고, 프로그램의 논리적인 결과를 바꾸지 않습니다.

같은 ISA를 구현한 어떤 칩에서든 Programmer Visible State의 변화가 동일하면 정확한 구현이고, 긔 외의 내부 상태는 자유롭게 다를 수 있습니다.

ISA가 제공하는 명령어들을 기능별로 분류하면 아래와 같이 나눌 수 있습니다.

  • 산술/논리 연산(Arithmetic/Logical): 레지스터나 즉시값에 대해 연산을 수행합니다. 결과는 보통 레지스터에 저장되고, 부수적으로 플래그가 갱신됩니다.
  • 데이터 이동(Data Transfer/Memory): 레지스터와 메모리, 또는 레지스터 간에 데이터를 옮깁니다.
  • 제어 흐름(Control Flow): 프로그램 카운터를 변경해 실행 흐름을 바꿉니다. 무조건 분기(Jump), 조건 분기(Branch), 함수 호출/복귀 등이 있습니다.
  • 시스템 명령어(System/Privileged): OS나 하드웨어의 제어를 위한 명령어입니다. 사용자 모드에서 커널로 진입, 인터럽트 제어 등이 있습니다. 현대 마이크로프로세서에서는 이런 명령어는 대부분 커널 모드에서만 실행 가능하고, 유저 모드에서 실행하면 예외가 발생합니다.

이러한 명령어는 반드시 원자적으로(Atomicity) 실행되어야 합니다. 하나의 명령어는 Programmer Visible State를 여러 개 변경할 수 있지만, 이러한 중간 변경 과정은 프로그래머에게 노출되어서는 안됩니다. 실제 하드웨어에서는 파이프라인에서 레지스터를 먼저 쓰고, PC를 나중에 갱신하는 식으로 명령어를 단계적으로 처리할 수 있지만, 이는 Microarchitectural State이지, Programmer Visible State가 아닙니다.

ISA의 역사

EDSAC

EDSAC은 최초의 저장 프로그램 컴퓨터 중 하나로, 아주 단순한 ISA를 가지고 있습니다.

명령어는 5비트 opcode + 예약 비트 + 10비트 메모리 주소(n)로 구성되며, 단일 누산기 (Accumulator) 구조입니다. 레지스터는 Acc 하나뿐이고, 모든 연산은 이 Acc 레지스터를 중심으로 돌아갑니다. 명령어의 예시는 아래와 같습니다.

  • A n: M[n]ACC에 더합니다
  • T n: ACC의 내용을 M[n]으로 옮기고 ACC를 비웁니다
  • E n: ACC>=0이라면, M[n]으로 점프합니다
  • I n: 테이프로부터 다음 글자를 읽고, M[n]이 가르키는 주소에 저장합니다.
  • Z : 프로그램을 멈추고 벨을 울립니다.

모든 메모리 주소가 명령어에 하드코딩 되어 있다는 점이 특징입니다. 하지만 이러면 아래와 같은 문제가 발생합니다.

  1. 코드에서 함수를 실행할 경우, 함수는 호출 위치로 복귀해야합니다. 하지만EDSAC은 복귀 주소가 명령어에 하드 코딩 되어있으므로, 같은 함수를 여러 곳에서 호출할 경우 복귀 주소를 지정하기 어려웠습니다.
  2. 배열 원소를 접근할 때, 배열의 주소가 명령어에 하드코딩되어 있으므로 루프를 돌면서 배열에 접근하려면 매번 명령어 자체를 수정해서 주소를 바꿔야했습니다.

ISA의 발전

이러한 문제를 해결하고자, 후대 CPU는 다양한 방식으로 진화해왔습니다.

첫 번째로 레지스터는 누산기 레지스터 하나만을 이용하는 구조에서, 프로그램의 주소 지정을 위한 레지스터가 추가되었고, 현대는 범용 레지스터(GPR, General Purpose Register)가 다수 추가되어 모든 레지스터를 어떤 용도로든 사용할 수 있게 되었습니다.

두 번째로 명령어에 다양한 피연산자를 지정할 수 있게 되었습니다. EDSAC 처럼 명령어가 암묵적인 피연산자(ACC)를 가지고 명시적 피연산자는 하나 뿐인 구조를 Monadic, OP inout, in2 처럼 피연산자가 2개이고 입력 중 하나가 결과로 덮어쓰이는 구조를 Dyadic, OP out, in1, in2와 같이 출력과 입력 2개를 모두 명시하는 구조를 Triadic이라고 합니다.

후술할 CISC 아키텍처에서는 ADD [mem], reg와 같이 명령어 하나로 메모리 접근과 연산을 동시에 수행할 수 있는 반면, RISC 아키텍처에서는 메모리 접근은 load/store 만으로 합니다. (load-store architecture)

세 번째로 명령어에 다양한 메모리 주소를 지정할 수 있게 되었습니다.

  • Absoulte - LD rt, 100: EDSAC과 같이 주소가 명령어에 상수로 지정된 방식입니다.
  • Register Indirect - LD rt, (r_base): 레지스터에 들어가 있는 값을 주소로 사용합니다. 포인터 역참조가 이런 방식입니다.
  • Displaced - LD rt, offset(r_base): 레지스터 값에 상수 오프셋을 더해 주소를 만듭니다. 구조체 접근에 유용합니다.
  • Indexed - LD rt, (r_base, r_index): 두 레지스터 값을 더해 주소를 만듭니다.
  • Memory Indirect - LD rt, ((r_base)): 메모리 주소에서 값을 읽고, 그 값을 다시 주소로 사용합니다. 이중 포인터 역참조에 해당합니다.
  • Auto increment/decrement - LDR rt, (r_base): 레지스터 값을 주소로 사용하되, 접근 전이나 후에 레지스터 값을 자동으로 증가/감소 시킵니다. 배열을 순차적으로 순회할 때 유용합니다.

RISC의 등장

초기의 CPU 발전은, 단순했던 ISA를 점점 복잡하게 하고, 명령어 세트에 다양한 기능들을 넣으며 진행되었습니다. 당시에는 어셈블리로 직접 프로그래밍하는 것이 보편적이었으며, 다양한 명령어를 넣음으로서 프로그래머의 코딩을 간편하게 만들 수 있었습니다. 또한, 메모리 크기나 성능이 제한적이었기 때문에, 자주 사용되는 복잡한 연산들을 한번에 처리하는 것이 유용했습니다.

하지만, 이후 컴파일러 기술이 발전하며 프로그래머가 직접 복잡한 어셈블리 명령어를 이용해 프로그래밍하는 경우는 거의 없어졌습니다. 사람이 직접 사용하는 것을 상정하고 만들어진 복잡한 명령어들은 컴파일러가 최적하기 어려웠으며, 명령어마다 실행 시간이나 메모리 접근 횟수가 천차만별이라 파이프라인을 효율적으로 설계하기도 어려웠습니다.

이러한 배경에서 RISC (Reduced Instruction Set Computer)가 등장합니다. 모든 명령어의 크기는 같은 크기로, 메모리 접근은 반드시 지정된 명령어로, 각 명령어는 단순하게 하되 레지스터를 넉넉하게 제공하는 등의 명령어 세트를 단순하게 만들자는 철학으로 만든 RISC CPU는 복잡성을 하드웨어에서 컴파일러로 이동하고, 하드웨어를 단순하게 만드는 대신, 최적화 부담은 발전한 컴파일러에 넘깁니다.

RISC는 파이프라인을 쉽게 만들 수 있고, 클럭을 높이기에도 유리했습니다. 하드웨어가 단순하므로 트랜지스터를 캐시, 분기 예측기등 성능에 도움이 되는 곳에 투자할 수 있고, 검증과 디버깅이 쉬웠으며 설계 사이클도 짧았습니다.

RISC와 대비하여 RISC 이전의 아키텍처들(x86, M68K, Z80 등)을 CISC (Complex Instruction Set Computer)라고 부르는데, M68K, Z80등 많은 CISC 아키텍처들은 사라졌지만, x86은 거대한 소프트웨어 생태계 때문에 사라지지는 않고, 대신 외부 ISA는 CISC, 내부적으로는 RISC라는 절충안을 채택하여 복잡한 명령어를 CPU 내부적으로 RISC로 분해하여 처리하는 설계를 도입했습니다.

현대 RISC 아키텍처의 대표 주자로는 ARM이 있는데, ARM은 초기에 간단한 설계를 통한 높은 전성비(전력 당 성능비)를 무기로 새로운 분야인 모바일 시장에 빠르게 진출했고, 2020년대 이전까지는 RISC 아키텍처는 모바일, 임베디드 등 저전력 설계에서, CISC 아키텍처는 고전력을 소모하는 대신 높은 성능을 필요로 하는 워크스테이션이나 데스크탑, 서버 환경에서 이용되어 왔습니다.

하지만 2020년대 이후 Apple Silicon에서 M1 칩을 출시하면서 이러한 상황이 크게 역전되었습니다. 애플의 M1 등 ARM CPU는 RISC 아키텍처 기반의 CPU가 높은 성능과 CISC CPU보다 개선된 전성비로 빠르게 시장 점유율을 늘려나가고 있으며, 애플 이외에도 ARM Windows/Linux 노트북이 판매되는 등 모바일 시장 이외에도 RISC 아키텍처가 점유율을 조금씩 늘려가고 있습니다.

RISC-V

RISC-V는 2010년에 UC Berkeley에서 시작된 오픈소스 ISA입니다. 1981년에 설계된 최초의 RISC 프로세서인 RISC-I, RISC-II에 지난 30년간의 교훈을 반영해 레거시 없이 깨끗한 RISC ISA를 처음부터 설계했습니다. (V는 Berkeley에서 개발한 다섯 번째 RISC 설계라는 뜻입니다. RISC-I, RISC-II, SOAR, SPUR, RISC-V)

RISC-V는 특정 회사에 종속된 ARM이나 x86과 달리, 누구나 무료로 RISC-V ISA를 구현한 프로세서를 만들 수 있고, 스타트업, 학교, 대기업 모두 자유롭게 칩을 설계할 수 있습니다. 또한, 모듈식 설계를 이용해 하나의 거대한 ISA가 아니라, 최소한의 명령어를 가진 작은 ISA에 필요한 확장을 붙히는 구조로 개발되었습니다.

RISC-V의 Programmer Visible State

RISC-V에는 기본적으로 x0부터 x31까지 32개의 정수 범용 레지스터가 있습니다. x0은 항상 0으로 고정된 상태인데, 이는 x0을 0으로 고정함으로서 다양한 동작을 기존 명령어로 표현함으로서 명령어 가짓 수를 줄일 수 있기 때문입니다. (예를 들어, NOP를 ADDI x0, x0, 0으로, 즉시값 로드를 ADDI x1, x0, 42로 표현할 수 있습니다.)

또한, 범용 레지스터에 포함되지 않는 PC 레지스터와 부동소수점 연산을 제공하는 F/D 확장에 포함되는 부동소수점 레지스터 등이 존재합니다.

메모리는 바이트 주소 지정 방식이며, 기본적으로 리틀 엔디언 방식입니다. 메모리의 모든 바이트는 Programmer Visible State이며, 기본적으로 리틀 엔디언 정렬을 이용합니다.

RISC-V의 확장 체계

RISC-V는 작은 기본 ISA에 필요한 확장을 조합하여 사용하는 구조입니다.

기본이 되는 ISA는 아래와 같습니다.

  • RV32I : 32비트 정수 기본. 정수 산술/논리, load/store 등 최소한의 명령어만을 가집니다.
  • RV64I : 64비트 정수 기본. RV32I에 64비트 연산과 더블워드 명령어가 추가됩니다.
  • RV128I : 128비트 정수 기본. 다만 64비트보다 큰 주소 공간이 언제 필요할지는 아직 불분명하기 때문에, 사양이 완전히 확정(frozen)되지는 않은 상태입니다.
  • RV32E, RV64E : 임베디드용 축소 ISA, 레지스터를 32개에서 16개로 줄여 극소형 마이크로컨트롤러의 비용을 낮춥니다.

여기에 아래와 같은 표준 Extension 들이 존재합니다.

  • M : 정수 곱셈, 나눗셈 : MUL, MULH 등 하드웨어 곱셈기의 네이티브 정수 곱셈 연산 명령어를 추가합니다. 극소형 임베디드 기기에서는 하드웨어 곱셈 없이 곱셈과 나눗셈을 비트연산 등 다른 연산으로 대체할 수 있기 때문에, 곱셈과 나눗셈이 별도의 확장으로 분리되어 있습니다.
  • A : 원자적 연산 : 멀티 코어 환경에서 코어 간 메모리 공유를 위한 명령어입니다. 메모리에 대한 쓰기나 읽기를 원자적으로 수행하거나, 메모리를 예약하는 기능 등이 정의되어 있습니다.
  • F : 32비트 부동소수점 연산 : FPU를 통한 하드웨어 부동 소수점 연산을 추가합니다. 32개의 부동소수점 레지스터 f0-f31과 부동 소수점 연산의 결과를 기록하는 플래그인 fcsr이 추가됩니다.
  • D, Q : 각각 64비트 배정밀도 부동소수점 연산과 128비트 4배정밀도 부동소수점 연산 명령어를 추가합니다.
  • C : 자주 쓰는 명령어를 16비트로 인코딩할 수 있게 합니다. 코드의 크기를 줄일 수 있어 플래시 메모리 크기가 크게 제한되는 임베디드 환경에서 사용할 수 있습니다.
  • V : 벡터 확장. SIMD와 유사한 다양한 벡터 연산을 지원합니다.
  • H : 하이퍼바이저 확장. 하이퍼바이저가 지원하는 가상화 계층을 지원합니다.

이외에 단일 알파벳 대신, 확장 이름 앞에 Z를 붙힌 세분화된 확장들도 있습니다.

  • Zicsr : CSR 접근 명령어
  • Zifencei : 명령어 캐시 동기화, 자기 수정코드, JIT 컴파일시 I-Cache와 D-Cache 일관성 보장
  • Zba, Zbb, Zbc, Zbs : 주소 계산 가속, 기본 비트 연산, 캐리리스 곱셈 등 세부 비트 조작 명령어
  • Zfinx : 부동소수점 연산을 정수 레지스터에서 수행
  • Ztso : Total Store Ordering 메모리 모델 보장. x86 바이너리 변환시 유용
  • Zicond : 조건부 연산. 분기 없이 조건부로 값을 선택

정말 다양한 확장이 존재하기 때문에 소프트웨어 호환성을 위해 자주 사용하는 확장들을 묶어 정의하는 RISC-V 프로파일이 존재합니다. 예를 들어 RVA20U64는 I, M, A, F, D, C, Zicsr, Zifencei 확장을 필수로 가지도록 정의되어 있습니다.

RISC-V 명령어 포멧

RISC-V는 6가지의 기본 명령어 포멧을 정의합니다. RV32I에서 모두 32비트 고정 길이이며, opcode, rd, rs1, rs2의 위치가 포맷 간 최대한 일관되게 배치되어 디코딩을 단순하게 만듭니다.

R-Type

레지스터 간 연산에 사용됩니다.

[31:25]  [24:20]  [19:15]  [14:12]  [11:7]  [6:0]
funct7   rs2      rs1      funct3   rd      opcode
7비트    5비트    5비트    3비트    5비트   7비트

rs1, rs2 두 소스 레지스터로 연산하고 결과를 rd에 저장합니다. funct7funct3가 구체적인 연산 종류를 구분합니다.

예: ADD x1, x2, x3: x1 <- x2 + x3

I-Type

즉시값 연산, load, JALR 등에 사용됩니다.

[31:20]     [19:15]  [14:12]  [11:7]  [6:0]
imm[11:0]   rs1      funct3   rd      opcode
12비트      5비트    3비트    5비트   7비트

12비트 즉시값이 부호 확장(sign-extend)되어 사용됩니다. R-type에서 funct7rs2 자리가 즉시값으로 대체된 형태입니다.

예: ADDI x1, x2, 10: x1 <- x2 + 10

R-type에서 funct7이 빠졌기 때문에, 약간의 명령어 차이가 존재합니다. 예를 들어 R-type에서 funct7으로 구분되는 ADD/SUB의 경우, I-type에서는 음수 즉시값을 쓰면 되므로 명령어 자체가 필요 없어 빠졌고, Immeidate field 를 나눠 세부 명령어를 구분하는 경우도 있습니다. (RV32I 기준 즉시값이 최대 5비트까지만 필요한 SLLI, SRLI, SRAI 등 시프트 명령어는, 앞 7비트를 명령어 지정자로, 나머지 5비트를 시프트 즉시값으로 사용합니다.)

메모리에서 값을 가져오는 Load 명령어 또한 I-Type 인데, opcode 자리에 Load를 의미하는 0000011을 넣고, rs1을 베이스로, 12비트 imm은 오프셋으로 이용합니다. funct3에 따라 32비트를 읽는 LW, 16비트를 읽고 부호 확장하는 LH, 16비트를 읽고 제로 확장하는 LHU, 8비트를 읽는 LB, LBU 등이 구분됩니다.

또한, 즉시값은 항상 12비트로 제한됩니다. 프로그램에서 사용하는 즉시값은 통상적으로 작은 값이고, 큰 상수가 필요한 경우는 통상적으로 드물기 때문에, 최적화를 위한 의도한 설계입니다. 꼭 큰 상수가 필요한 경우에는 후술할 U-type 명령어나 AUIPC 등을 이용해 값을 만들어 쓰게 됩니다. 이는 하나의 명령어로 모든 것을 하려 하지 말고, 간단한 여러 명령어의 조합으로 해결하자는 RISC의 철학을 따른 것입니다.

S-Type

메모리에 값을 저장하는데 사용됩니다.

[31:25]   [24:20]  [19:15]  [14:12]  [11:7]   [6:0]
imm[11:5] rs2      rs1      funct3   imm[4:0] opcode
7비트     5비트    5비트    3비트    5비트    7비트

결과를 레지스터에 쓰지 않으므로 rd가 없고, 대신 rd 자리에 즉시값 하위 비트를, funct7 자리에 즉시값의 상위비트를 넣습니다.

예: SW x3, 12(x2): MEM[x2 + 12] <- x3

B-Type (SB-Type)

조건 분기에 사용됩니다.

[31]     [30:25]    [24:20]  [19:15]  [14:12]  [11:8]   [7]     [6:0]
imm[12]  imm[10:5]  rs2      rs1      funct3   imm[4:1] imm[11] opcode
1비트    6비트      5비트    5비트    3비트    4비트    1비트   7비트

S-Type과 유사하지만, 즉시값의 비트 배치가 다릅니다. 즉시값이 분기 오프셋으로 사용되며, RISC-V 명령어는 최소 16비트 단위로 정렬되어 분기 오프셋의 최하위 비트는 항상 0이므로 인코딩하지 않습니다. 따라서 13비트 범위의 분기가 가능합니다.

예 : BEQ x1, x2, offset: x1 == x2면 PC <- PC + offset

비트 배치가 특이하게 꼬여있는 이유는 S-type과 최대한 많은 비트 위치를 공유해서 하드웨어를 단순화하기 위함입니다. 이러한 S-Type과의 유사성 때문에 B-Type을 SB-Type이라고 부르기도 합니다.

U-Type

상위 20비트 즉시값을 사용하는 명령어입니다.

[31:12]     [11:7]  [6:0]
imm[31:12]  rd      opcode
20비트      5비트   7비트

20비트 즉시값이 상위 20비트에 배치되고, 하위 12비트는 0으로 채워집니다. I-Type 명령어에서 최대 12비트까지의 즉시값을 사용할 수 있기 때문에, 이를 보완하기 위해 만들어진 명령어입니다.

  • LUI (Load Upper Immedate): rd <- imm << 12, 큰 상수를 만들 때 ADDI와 조합하여 사용합니다. 0x12345678을 만들 때, LUI x1, 0x12345로 상위 20비트를 채우고, ADDI x1, x1, 0x678로 하위 12비트를 채웁니다.
  • AUIPC (Add Upper Immediate to PC): rd <- PC + (imm << 12), PC 상대 주소 계산에 사용되며, JAL과 load와 조합하여 현재 위치에서 ±2GB 범위의 주소를 만들 수 있습니다.

J-Type (UJ-Type)

무조건 점프에 사용됩니다.

[31]     [30:21]    [20]     [19:12]    [11:7]  [6:0]
imm[20]  imm[10:1]  imm[11]  imm[19:12] rd      opcode
1비트    10비트     1비트    8비트      5비트   7비트

20비트 즉시값을 점프 오프셋으로 사용하며, B-Type과 동일하게 bit0은 항상 0이므로 인코딩하지 않습니다. 따라서 21비트 범위(±1MB)의 점프가 가능합니다.

B-Type과 유사하게, U-Type과 비트 위치를 최대한 공유하도록 설계되어 있습니다. 따라서 UJ-Type이라고 불리기도 합니다.

RV32I 명령어 세트

정수 산술/논리 (R-type)

명령어 동작 설명
ADD rd, rs1, rs2 rd ← rs1 + rs2 덧셈
SUB rd, rs1, rs2 rd ← rs1 - rs2 뺄셈
AND rd, rs1, rs2 rd ← rs1 & rs2 비트 AND
OR rd, rs1, rs2 rd ← rs1 \| rs2 비트 OR
XOR rd, rs1, rs2 rd ← rs1 ^ rs2 비트 XOR
SLL rd, rs1, rs2 rd ← rs1 << rs2[4:0] 논리 좌시프트
SRL rd, rs1, rs2 rd ← rs1 >> rs2[4:0] 논리 우시프트 (0 채움)
SRA rd, rs1, rs2 rd ← rs1 >>> rs2[4:0] 산술 우시프트 (부호 유지)
SLT rd, rs1, rs2 rd ← (rs1 < rs2) ? 1 : 0 부호 있는 비교
SLTU rd, rs1, rs2 rd ← (rs1 < rs2) ? 1 : 0 부호 없는 비교

즉시값 산술/논리 (I-type)

명령어 동작 설명
ADDI rd, rs1, imm rd ← rs1 + sext(imm) 즉시값 덧셈
ANDI rd, rs1, imm rd ← rs1 & sext(imm) 즉시값 AND
ORI rd, rs1, imm rd ← rs1 \| sext(imm) 즉시값 OR
XORI rd, rs1, imm rd ← rs1 ^ sext(imm) 즉시값 XOR
SLLI rd, rs1, shamt rd ← rs1 << shamt 즉시값 논리 좌시프트
SRLI rd, rs1, shamt rd ← rs1 >> shamt 즉시값 논리 우시프트
SRAI rd, rs1, shamt rd ← rs1 >>> shamt 즉시값 산술 우시프트
SLTI rd, rs1, imm rd ← (rs1 < sext(imm)) ? 1 : 0 즉시값 부호 있는 비교
SLTIU rd, rs1, imm rd ← (rs1 < sext(imm)) ? 1 : 0 즉시값 부호 없는 비교

상위 즉시값 (U-type)

명령어 동작 설명
LUI rd, imm rd ← imm << 12 상위 20비트 로드
AUIPC rd, imm rd ← PC + (imm << 12) PC 기준 상위 20비트 덧셈

Load (I-type)

명령어 funct3 동작 설명
LB rd, imm(rs1) 000 rd ← sext(MEM8[rs1 + sext(imm)]) 바이트 로드 (부호 확장)
LH rd, imm(rs1) 001 rd ← sext(MEM16[rs1 + sext(imm)]) 하프워드 로드 (부호 확장)
LW rd, imm(rs1) 010 rd ← MEM32[rs1 + sext(imm)] 워드 로드
LBU rd, imm(rs1) 100 rd ← zext(MEM8[rs1 + sext(imm)]) 바이트 로드 (제로 확장)
LHU rd, imm(rs1) 101 rd ← zext(MEM16[rs1 + sext(imm)]) 하프워드 로드 (제로 확장)

Store (S-type)

명령어 funct3 동작 설명
SB rs2, imm(rs1) 000 MEM8[rs1 + sext(imm)] ← rs2[7:0] 바이트 저장
SH rs2, imm(rs1) 001 MEM16[rs1 + sext(imm)] ← rs2[15:0] 하프워드 저장
SW rs2, imm(rs1) 010 MEM32[rs1 + sext(imm)] ← rs2 워드 저장

조건 분기 (B-type)

명령어 funct3 동작 설명
BEQ rs1, rs2, offset 000 if (rs1 == rs2) PC ← PC + offset 같으면 분기
BNE rs1, rs2, offset 001 if (rs1 != rs2) PC ← PC + offset 다르면 분기
BLT rs1, rs2, offset 100 if (rs1 < rs2) PC ← PC + offset 부호 있는 미만
BGE rs1, rs2, offset 101 if (rs1 >= rs2) PC ← PC + offset 부호 있는 이상
BLTU rs1, rs2, offset 110 if (rs1 < rs2) PC ← PC + offset 부호 없는 미만
BGEU rs1, rs2, offset 111 if (rs1 >= rs2) PC ← PC + offset 부호 없는 이상

점프

명령어 타입 동작 설명
JAL rd, offset J-type rd ← PC+4, PC ← PC + offset 직접 점프 (함수 호출)
JALR rd, rs1, imm I-type rd ← PC+4, PC ← (rs1 + sext(imm)) & ~1 간접 점프 (함수 복귀 등)

메모리 순서

명령어 설명
FENCE pred, succ 메모리 접근 순서 보장 (pred/succ: I, O, R, W의 조합)

시스템 명령어

명령어 동작 설명
ECALL 환경 호출 트랩 발생 시스템 콜 (OS 진입)
EBREAK 디버그 트랩 발생 브레이크포인트

과거에는 Zicsr, Zifencei 확장이 RV32I에 포함되어 있었으나, 2.1에서 별도 확장으로 분리되어서 현재는 총 40개의 명령어가 RV32I를 구성하고 있습니다.

RISC-V ABI 컨벤션

레지스터 컨벤션

ISA 수준에서는 강제되지 않지만, 모든 시스템에서 권장되는 레지스터의 사용 컨벤션입니다. x0 = 0 만이 유일하게 ISA에서 강제되는 것이고, 나머지는 하드웨어 관점에서 동등합니다. 실제로 이러한 컨벤션을 준수하는 것은 ABI, Calling Convention의 역할입니다.

하지만, 대부분의 컴파일러, OS, 디버거가 이러한 컨벤션을 준수하고 있기 때문에, 특수한 베어메탈 임베디드에서 외부 라이브러리조차 사용하지 않는 매우 특수한 경우를 제외하고는 이러한 컨벤션을 준수해야합니다.

레지스터 ABI 이름 용도 Caller/Callee saved
x0 zero 항상 0 (ISA 수준에서 고정)
x1 ra 복귀 주소 (Return Address) Caller
x2 sp 스택 포인터 (Stack Pointer) Callee
x3 gp 전역 포인터 (Global Pointer)
x4 tp 스레드 포인터 (Thread Pointer)
x5 t0 임시 레지스터 Caller
x6 t1 임시 레지스터 Caller
x7 t2 임시 레지스터 Caller
x8 s0 / fp 저장 레지스터 / 프레임 포인터 Callee
x9 s1 저장 레지스터 Callee
x10 a0 함수 인자 1 / 반환값 1 Caller
x11 a1 함수 인자 2 / 반환값 2 Caller
x12 a2 함수 인자 3 Caller
x13 a3 함수 인자 4 Caller
x14 a4 함수 인자 5 Caller
x15 a5 함수 인자 6 Caller
x16 a6 함수 인자 7 Caller
x17 a7 함수 인자 8 Caller
x18 s2 저장 레지스터 Callee
x19 s3 저장 레지스터 Callee
x20 s4 저장 레지스터 Callee
x21 s5 저장 레지스터 Callee
x22 s6 저장 레지스터 Callee
x23 s7 저장 레지스터 Callee
x24 s8 저장 레지스터 Callee
x25 s9 저장 레지스터 Callee
x26 s10 저장 레지스터 Callee
x27 s11 저장 레지스터 Callee
x28 t3 임시 레지스터 Caller
x29 t4 임시 레지스터 Caller
x30 t5 임시 레지스터 Caller
x31 t6 임시 레지스터 Caller

Previous Post