RISC-V 개요: 명세 구성과 이름 읽기, 확장이 코드를 바꾸는 정도 재기

Sunhong··11 min read·0 views

이 글의 핵심 3줄

  • RISC-V는 작은 기본 정수 ISA 하나에 필요한 확장만 골라 붙이는 모듈식 명령어 집합이고, 명세는 비특권(1권)·특권(2권) 두 권과 ISA 바깥의 명세들로 나뉜다.
  • rv64gc 같은 이름은 "어떤 기본 ISA에 어떤 확장을 붙였나"를 그대로 적은 것이라, 코어를 설계할 때 가장 먼저 정하는 사양서가 된다.
  • 같은 C 함수 7개를 확장 조합만 바꿔 컴파일하니 rv32i 972바이트 → rv32imc 502바이트 → rv32imfc_zba_zbb 402바이트로 줄었다. 고른 확장이 코드와 하드웨어를 함께 바꾼다.

이 글은 Digital Design 카테고리의 첫 묶음인 RISC-V 시리즈의 1편이다. 시리즈의 목표는 RISC-V 명세를 빠짐없이 정리해, 나중에 코어를 RTL로 설계할 때 옆에 펴 놓고 찾아볼 수 있게 하는 것이다. 1편에서는 명령어 하나하나에 들어가기 전에 큰 지도를 그린다. RISC-V가 어디서 왔고, 명세가 어떻게 나뉘어 있고, rv64gc 같은 이름을 어떻게 읽는지, 그리고 그 이름이 설계에서 무엇을 정하는지까지다.

1. RISC-V는 무엇인가

명령어 집합 구조(ISA, Instruction Set Architecture)는 소프트웨어와 하드웨어 사이의 약속이다. "이 32비트 값이 오면 두 레지스터를 더해 세 번째에 넣는다"처럼, 프로그램이 보는 기계의 모습을 정한다. 같은 ISA를 지키면 작은 마이크로컨트롤러든 서버용 큰 코어든 같은 프로그램이 돈다. 그 안을 어떻게 만드느냐(파이프라인 단 수, 캐시 크기)는 마이크로아키텍처(microarchitecture)의 몫이다.

RISC-V는 2010년 UC 버클리에서 시작된 ISA다. 크르스테 아사노비치(Krste Asanović), 이윤섭(Yunsup Lee), 앤드루 워터먼(Andrew Waterman)이 데이비드 패터슨(David Patterson)과 함께 연구·교육용으로 만들었다. 이름의 V는 버클리에서 만든 다섯 번째 RISC 설계라는 뜻이다. 2015년 RISC-V 재단이 생겼고, 2020년 스위스의 RISC-V International로 옮겼다.

이미 x86과 ARM이 있는데 왜 새 ISA가 필요했을까. 이유는 세 가지로 정리된다.

  • 열려 있다. 명세를 누구나 무료로 읽고, 허락이나 라이선스 비용 없이 구현할 수 있다. 연구실에서 칩을 만들거나 회사가 자체 코어를 설계할 때 협상할 상대가 없다.
  • 작고 단순하다. 기본 정수 ISA는 명령어 40개 남짓이다. 수십 년 동안 호환성 때문에 명령어가 쌓인 ISA와 달리, 처음부터 깨끗하게 다시 설계했다.
  • 모듈식이다. 작은 기본 ISA는 고정해 두고 바꾸지 않는다. 곱셈, 부동소수점, 벡터 같은 기능은 확장으로 따로 정의해, 필요한 것만 골라 붙인다.

세 번째가 RISC-V를 다른 ISA와 가장 크게 구별하는 점이고, 이 글 나머지가 전부 이 이야기다.

2. 설계 원칙: 무엇을 아끼려고 했나

RISC-V를 만든 사람들은 ISA를 평가하는 기준으로 비용, 단순함, 성능, 구조와 구현의 분리, 확장 여지, 프로그램 크기, 프로그래밍·컴파일·링크의 쉬움을 들었다. 명세 곳곳에서 이 기준이 구체적인 결정으로 나타난다. 설계할 때 체감하는 것만 골라 보면 다음과 같다.

결정 이유 설계에서 느끼는 효과
레지스터 번호(rs1·rs2·rd)가 모든 형식에서 같은 비트 자리 명령어 종류를 알기 전에 레지스터 파일을 읽기 시작할 수 있다 디코드와 레지스터 읽기를 한 단계에서 병렬로
즉시값의 부호 비트가 항상 31번 비트 부호 확장을 형식 판별과 동시에 시작 즉시값 생성기가 짧아진다
x0는 항상 0 이동·비교·NOP을 따로 만들 필요 없음 쓰기는 버리고 읽기는 0, 하드웨어가 단순
조건 코드(플래그) 레지스터 없음 비교와 분기를 한 명령어로 플래그 의존성을 추적할 필요 없음
분기 지연 슬롯 없음 특정 파이프라인 구조에 묶이지 않게 파이프라인 단 수를 마음대로
메모리 접근은 로드·스토어만 산술 명령어가 메모리를 건드리지 않음 파이프라인이 단순

다섯 번째 줄이 "구조와 구현의 분리"의 좋은 예다. 옛 MIPS는 분기 뒤의 명령어 하나를 무조건 실행하는 지연 슬롯을 두었다. 5단 파이프라인에서는 이득이었지만, 단 수가 다른 코어에서는 짐이 됐다. RISC-V는 특정 구현에 유리한 장치를 ISA에 넣지 않는다는 원칙을 지켰다.

3. 명세의 구성

RISC-V 명세는 비특권·특권 두 권과 ABI·SBI·디버그 같은 ISA 바깥 명세로 나뉘고, 칩 하나는 이 중에서 고른 조합이다

RISC-V 명세는 크게 세 덩어리다(이 시리즈는 2024년 4월 판, 문서 날짜 20240411 기준).

1권, 비특권(Unprivileged) 명세. 응용 프로그램이 보는 부분이다. 기본 정수 ISA, 표준 확장, 메모리 모델이 들어 있다.

  • 기본 정수 ISA는 하나만 고른다. RV32I(레지스터 32개, 32비트), RV64I(64비트), 레지스터를 16개로 줄인 임베디드용 RV32E·RV64E가 있다. RV128I는 아직 확정되지 않았다.
  • 표준 확장은 골라서 더한다. 한 글자 확장(M, A, F, D, Q, C, B, V 등)과 Z로 시작하는 여러 글자 확장(Zicsr, Zifencei, Zicond 등)이 있다.
  • 메모리 모델 RVWMO는 여러 코어(하트, hart)가 메모리에 접근할 때 순서가 어떻게 보이는지 정한다.

2권, 특권(Privileged) 명세. OS, 펌웨어, 하이퍼바이저가 보는 부분이다. 권한 모드(M·S·U), CSR(제어·상태 레지스터), 트랩, 물리 메모리 보호, 가상 메모리, 하이퍼바이저 확장이 들어 있다. S로 시작하는 확장(Sstc, Svpbmt 등)이 여기에 속한다.

ISA 바깥의 명세. 명령어는 아니지만 실제 시스템을 만들려면 필요한 약속들이다. 호출 규약과 ELF 형식을 정하는 psABI, OS와 펌웨어 사이의 인터페이스 SBI, 디버그 명세, 인터럽트 컨트롤러(ACLINT, PLIC, AIA), 트레이스 명세가 있다.

명세에는 상태가 붙는다. 초안(draft)은 아직 바뀌는 중이고, 확정(frozen)은 공개 검토 단계, 비준(ratified)은 공식 표준이 된 것이다. 설계할 때는 비준된 것만 기준으로 삼는다. 비준된 내용은 앞으로 호환성을 깨는 방향으로 바뀌지 않는다는 약속이 있기 때문이다.

하트(hart)는 hardware thread의 줄임말로, 명령어를 독립적으로 가져와 실행하는 단위다. 코어 하나가 하트 하나일 수도 있고, 동시 멀티스레딩(SMT) 코어라면 코어 하나에 하트가 여럿일 수도 있다. 명세는 "코어" 대신 이 말을 쓴다.

4. 이름 읽기

rv64gc는 rv + 64 + g + c로 읽고, GCC 16이 객체 파일에 적은 정식 이름은 rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0_zifencei2p0 …다

RISC-V 코어나 컴파일 옵션에는 rv32imc, rv64gc 같은 이름이 붙는다. 이 이름은 그 코어가 지원하는 ISA를 그대로 적은 것이다. 읽는 규칙은 단순하다.

  1. rv 다음에 XLEN(32 또는 64)을 쓴다.
  2. 기본 ISA를 쓴다: i, e, 또는 묶음 g.
  3. 한 글자 확장을 정해진 순서로 붙인다(imafdqc…).
  4. 여러 글자 확장은 밑줄로 구분해 붙인다. Z… 다음 S…, 회사가 따로 정의한 X…는 맨 뒤다.
  5. 확장 뒤에 버전을 붙일 수 있다. 2p1은 버전 2.1이다.

g는 "범용(general)" 묶음으로, imafd 다섯에 zicsr·zifencei를 더한 것이다. 원래 CSR 명령어와 FENCE.I는 기본 I에 들어 있었는데, 2019년 명세에서 Zicsr·Zifencei 확장으로 빠져나왔다. 그래서 rv32i만 지원하는 코어에는 CSR 명령어가 없다. 인터럽트를 받으려면 CSR이 필요하므로, 실제 코어는 거의 항상 zicsr를 함께 넣는다.

컴파일러가 이름을 어떻게 읽는지는 직접 확인할 수 있다. GCC로 객체 파일을 만들면 Tag_RISCV_arch라는 속성에 펼친 이름이 기록된다(riscv64-elf-readelf -A로 본다). 아래는 GCC 16.2가 실제로 기록한 값이다.

-march=rv32i    →  rv32i2p1
-march=rv32imc  →  rv32i2p1_m2p0_c2p0_zmmul1p0_zca1p0
-march=rv64gc   →  rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0
                   _zicsr2p0_zifencei2p0_zmmul1p0
                   _zaamo1p0_zalrsc1p0_zca1p0_zcd1p0

두 가지가 눈에 띈다. 첫째, g는 정말로 imafd_zicsr_zifencei로 펼쳐진다. 둘째, 큰 확장이 작은 조각으로 나뉘어 함께 적힌다. zmmul은 M에서 곱셈만 떼어 낸 것이고, A는 zaamo(원자적 읽기-수정-쓰기)와 zalrsc(LR/SC)로, C는 zca(정수 압축 명령어)와 zcd(D용 압축 로드·스토어)로 나뉜다. 작은 코어가 나눗셈기 없이 곱셈기만 넣고 싶을 때 zmmul만 지원한다고 말할 수 있게 하려고 쪼갠 것이다.

이름 규칙이 얼마나 엄격한지도 시험해 봤다. 기본 ISA가 맨 앞에 없는 rv32mi는 GCC가 first ISA subset must be 'e', 'i' or 'g'라며 거부했다. 한 글자 확장의 순서가 뒤바뀐 rv32icm은 GCC 16이 받아서 정식 순서로 고쳐 주었다. 도구가 너그러워도 문서와 하드웨어에는 정식 순서로 쓰는 것이 좋다.

프로파일

확장이 수십 개로 늘자 새로운 문제가 생겼다. 리눅스 배포판이 "어떤 RISC-V 칩에서나 돈다"고 하려면, 칩마다 다른 확장 조합을 다 맞출 수 없다. 그래서 나온 것이 프로파일(profile)이다. 프로파일은 "이 등급의 코어라면 이 확장들은 반드시 있다"고 묶어 둔 목록이다. 응용 프로세서용 RVA20, RVA22, RVA23이 있고, 2024년에 비준된 RVA23은 벡터(V)를 필수로 넣었다. 마이크로컨트롤러에는 프로파일이 크게 상관없지만, 리눅스가 도는 코어를 설계한다면 목표 프로파일이 사실상 사양서가 된다.

5. 실습: 확장이 코드를 얼마나 바꾸나

이름 안의 글자 하나가 실제로 무엇을 바꾸는지 재 봤다. 성격이 다른 C 함수 7개를 만들고, 소스는 그대로 둔 채 -march만 바꿔 컴파일했다.

함수 하는 일 영향받을 확장
crc32 비트 연산과 분기 없음(기준)
matmul 정수 행렬 곱 M(곱셈)
utoa10 정수를 10진 문자열로 M(나눗셈·나머지)
isort 삽입 정렬 없음
popcount_sum 1인 비트 세기 B의 Zbb(cpop)
index_sum base[idx[i]] 더하기 B의 Zba(sh2add)
dot float 내적 F

장비는 맥 M1 Max, 컴파일러는 Homebrew의 riscv64-elf-gcc 16.2.0이다. 모두 -O2 -ffreestanding -c로 객체 파일만 만들고, 심볼 크기와 명령어 수, 라이브러리 호출을 셌다. 핵심은 이 몇 줄이다.

CFGS = [('rv32i', 'ilp32'), ('rv32im', 'ilp32'),
        ('rv32imc', 'ilp32'), ('rv32imfc', 'ilp32f'),
        ('rv32imfc_zba_zbb', 'ilp32f'),
        ('rv64imafdc', 'lp64d'), ('rv64imafdc_zba_zbb', 'lp64d')]
run = lambda *a: subprocess.run(a, capture_output=True,
                                text=True, check=True).stdout
for march, mabi in CFGS:
    obj = f'/tmp/k-{march}.o'
    run('riscv64-elf-gcc', f'-march={march}', f'-mabi={mabi}',
        '-O2', '-ffreestanding', '-c', 'kernels.c', '-o', obj)
    nm = run('riscv64-elf-nm', '-S', obj)          # 함수별 크기
    raw = run('riscv64-elf-objdump', '-dr', obj)   # 명령어·호출

-mabi는 함수 인자를 어떤 레지스터로 넘길지 정하는 ABI다. 부동소수점 레지스터가 없는 조합은 ilp32, F가 있으면 ilp32f, 64비트에 D가 있으면 lp64d를 쓴다.

같은 C 함수 7개가 rv32i 972바이트, rv32im 772바이트, rv32imc 502바이트, rv32imfc_zba_zbb 402바이트로 줄어든다

결과를 확장 하나씩 따라가 보자.

M이 없으면 곱셈이 함수 호출이 된다. rv32i에서 matmul은 곱셈마다 __mulsi3를, utoa10은 __udivsi3·__umodsi3를 부른다. 하드웨어 곱셈기가 없으니 컴파일러가 소프트웨어로 곱한다. M을 켜니 matmul이 260바이트에서 132바이트로, utoa10이 204바이트에서 132바이트로 줄었다. 그림의 숫자는 호출하는 쪽만 센 것이고, 불려 가는 라이브러리 함수의 크기와 시간은 빠져 있다. 실제 차이는 이보다 크다.

C는 명령어 수는 그대로 두고 바이트만 줄인다. rv32im과 rv32imc는 둘 다 명령어 193개다. 그런데 크기는 772바이트에서 502바이트로 35% 줄었다. 193개 중 135개가 16비트 압축 명령어로 바뀌었기 때문이다. C 확장의 명령어는 새로운 연산이 아니라, 자주 쓰는 32비트 명령어(예: addi a1,a1,4)를 16비트로 줄여 쓴 별명이다. 그래서 디코더 앞에 "16비트를 32비트로 펼치는" 단계 하나를 두면 나머지 하드웨어는 그대로 쓸 수 있다.

F가 없으면 float 연산이 함수 호출이 된다. dot은 rv32imc에서 __addsf3·__mulsf3를 부르며 96바이트였다. F를 켜니 fmadd.s(곱하고 더하기) 같은 명령어로 바뀌어 32바이트, 명령어 12개가 됐다.

B는 자주 나오는 명령어 묶음을 하나로 합친다. popcount_sum은 Zbb가 없으면 __popcountsi2를 불렀고, 있으면 cpop 한 명령어로 끝났다(68바이트 → 32바이트). index_sum에서는 "인덱스를 4배 해서 주소에 더하기"가 slli+add 두 명령어에서 sh2add 하나로 줄었다. 아래는 Zba를 켠 index_sum의 실제 루프다.

158:  419c        lw      a5,0(a1)        # idx[i]   (16비트 C 명령어)
15a:  0591        addi    a1,a1,4
15c:  20a7c7b3    sh2add  a5,a5,a0        # base + idx*4 (Zba)
160:  439c        lw      a5,0(a5)
162:  973e        add     a4,a4,a5
164:  feb61ae3    bne     a2,a1,158

한 루프 안에 2바이트 명령어와 4바이트 명령어가 섞여 있다. 하드웨어는 메모리에서 읽은 값을 보고 명령어 길이를 알아내야 한다. RISC-V는 이를 쉽게 하려고 명령어의 아래 두 비트가 11이면 32비트, 아니면 16비트로 정했다. 20a7c7b3의 아래 두 비트는 11이고, 419c의 아래 두 비트는 00이다. 두 비트만 보면 되니 길이 판별은 게이트 몇 개로 끝난다.

64비트로 가면 조금 커진다. rv64imafdc는 468바이트로 rv32imfc의 438바이트보다 크다. utoa10에서 32비트 값을 64비트 레지스터에서 다룰 때 부호·영 확장 명령어가 더 들어갔다(110바이트 대 94바이트). RV64의 W 명령어(32비트 연산 후 부호 확장)가 이 문제를 줄이려고 있다. 5편에서 자세히 본다.

6. 설계 체크리스트: 이름이 하드웨어에서 정하는 것

이름은 소프트웨어용 옵션이기 전에 하드웨어 사양서다. 코어 설계를 시작할 때 이름에서 바로 정해지는 것을 모아 두면 다음과 같다.

정할 것 고르는 방법 놓치기 쉬운 점
XLEN과 기본 ISA 32비트 MCU면 RV32I(또는 레지스터를 줄인 RV32E), 리눅스면 RV64I E는 레지스터 파일이 반으로 줄지만 호출 규약이 달라 ilp32e ABI가 필요
확장 목록 필요한 연산과 면적으로 정한다 고르지 않은 확장의 명령어는 모두 불법 명령어 예외를 내야 한다. 아무 일도 안 하고 넘어가면 안 된다
C 지원 여부 코드 메모리가 작으면 거의 필수 C가 있으면 명령어가 2바이트 경계에 놓인다(IALIGN=16). 4바이트 명령어가 두 워드에 걸칠 수 있어 페치가 복잡해진다
정렬 안 된 점프 목적지 C가 없으면 IALIGN=32 분기·점프 목적지가 4의 배수가 아니면 명령어 주소 정렬 예외를 낸다
Zicsr 인터럽트·예외를 받으려면 사실상 필수 rv32i만 적으면 CSR 명령어가 빠진다
misa CSR 지원하는 확장을 비트로 알려 준다 WARL(아무 값이나 써도 되고, 읽으면 올바른 값만 나옴) 필드라 읽기 전용으로 만들어도 된다. 23편에서 자세히
엔디안 기본은 리틀 엔디안 명령어는 항상 리틀 엔디안으로 읽는다. 빅 엔디안 데이터는 특권 명세의 선택 기능
명세 판 비준된 판을 하나 정해 문서에 적는다 확장 이름과 버전(예: zicsr2p0)을 하드웨어 문서와 컴파일 옵션에서 똑같이

마지막 줄이 실무에서 자주 사고가 나는 곳이다. 하드웨어는 rv32imc인데 펌웨어를 rv32imac로 빌드하면, 컴파일러가 넣은 원자 연산 명령어에서 불법 명령어 예외가 난다. 이름을 하드웨어 문서, 컴파일 옵션, misa 값 세 곳에서 같게 맞추는 것을 첫 규칙으로 삼는다.

정리

  • RISC-V는 바뀌지 않는 작은 기본 정수 ISA에 확장을 골라 붙이는 모듈식 ISA다. 명세는 비특권 1권, 특권 2권, 그리고 ABI·SBI·디버그·인터럽트 같은 바깥 명세로 나뉜다.
  • rv64gc는 rv64i_m_a_f_d_c_zicsr_zifencei의 줄임말이다. 이름은 컴파일 옵션이자 하드웨어 사양서다.
  • 실측: 같은 C 함수 7개가 rv32i 972바이트, rv32im 772바이트, rv32imc 502바이트, rv32imfc_zba_zbb 402바이트. M과 F는 함수 호출을 명령어로 바꾸고, C는 명령어 수는 그대로 두고 크기를 35% 줄였다.
  • 설계는 이름을 정하는 데서 시작한다. 고르지 않은 확장은 불법 명령어 예외, C를 고르면 2바이트 정렬 페치, 인터럽트를 받으려면 Zicsr.

다음 글: 2편에서는 기본 정수 ISA RV32I로 들어간다. 레지스터 32개와 명령어 형식 6가지(R·I·S·B·U·J)를 보고, 즉시값 비트가 왜 그렇게 흩어져 있는지를 디코더 입장에서 따라간다. 명령어 몇 개를 손으로 인코딩해 디스어셈블러 결과와 맞춰 본다.

참고 문헌

  • The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. https://github.com/riscv/riscv-isa-manual
  • The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Version 20240411. https://github.com/riscv/riscv-isa-manual
  • RISC-V Profiles (RVA20·RVA22·RVA23). https://github.com/riscv/riscv-profiles
  • D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, Strawberry Canyon, 2017. 1장(왜 RISC-V인가)
  • A. Waterman, Y. Lee, D. Patterson, K. Asanović, "The RISC-V Instruction Set Manual, Volume I: Base User-Level ISA," UCB/EECS-2011-62, 2011. https://www2.eecs.berkeley.edu/Pubs/TechRpts/2011/EECS-2011-62.html
  • RISC-V ELF psABI. https://github.com/riscv-non-isa/riscv-elf-psabi-doc

비슷한 글 추천