Skip to content

RISC-V

애플리케이션을 개발할 때, 웹 브라우저가 어떤 OS에서 동작하는 지 그 차이를 숨겨주는 것 처럼 OS역시 OS의 그 아래 계층인 하드웨어(특히 CPU)의 차이를 숨겨주는 역할을 합니다. 즉, OS는 CPU를 제어하여 애플리케이션에게 추상화된 레이어 제공하는 프로그램이라 할 수 있습니다. 이 책에서는 학습용으로 RISC-V를 선택했고 다음과 같은 이유로 선택했습니다.

  • 명세가 단순합니다.
  • x86, Arm 과 함께 최근 트렌디한 명령어 세트 아키텍쳐 (ISA, Instruction Set Architecture) 입니다.
  • 문서화가 잘 되어 있고 읽기 편합니다.

이 책에서는 32비트 RISC-V를 사용합니다. 사실 64비트 RISC-V로도 거의 비슷하게 구현할 수 있지만, 비트가 커질수록 조금 더 복잡해지고 주소 길이도 늘어나서 초심자 입장에서는 부담이 될 수 있습니다. 따라서 처음 시작할 때는 32비트를 추천합니다.

QEMU virt machine

CPU하나만 있다고 컴퓨터라고 부를수는 없습니다. 메모리를 비롯해 네트워크, 스토리지 등 여러 장치를 포함해야 비로소 하나의 컴퓨터가 되죠. 예를 들어 iPhone과 Raspberry Pi는 둘 다 Arm CPU를 쓰지만, 실제론 완전히 다른 컴퓨터로 보는 것이 자연스럽습니다.

이 책에서는 RISC-V 기반 중에서도 QEMU의 virt 머신 (documentation)을 다루기로 했습니다. 그 이유는 다음과 같습니다.

  • 이름에서처럼 실제 존재하지 않는 가상의 컴퓨터이긴 하지만, 실제 기기를 다루는 것과 매우 흡사한 체험이 가능합니다.
  • QEMU 위에서 동작하기 때문에, 실제 기기를 구매하지 않아도 무료로 바로 시도해볼 수 있습니다. 또한 판매 종료 등으로 인해 기기를 구하기 어려워지는 일도 없습니다.
  • 디버깅에 막혔을 때, QEMU의 소스 코드를 직접 읽거나 QEMU 자체에 디버거를 연결하여 어디가 문제인지 조사할 수 있습니다.

RISC-V 어셈블리 입문 가이드

RISC-V 어셈블리를 빠르게 익히는 한 가지 방법은 “C 언어 코드가 어떤 어셈블리로 변환되는지 관찰하기”입니다. 그 다음, 컴파일러가 출력한 어셈블리에 등장하는 명령어들을 하나씩 분석하다 보면 자연스레 익히게 됩니다. 다음 항목들은 어셈블리 입문에 꼭 알아둬야 할 개념이니, 본서를 읽거나 ChatGPT 등에 물어가며 공부해보세요.

  • 레지스터란 무엇인가
  • 사칙연산 (add, sub, mul, div 등)
  • 메모리 접근 (lw, sw 등)
  • 분기 명령 (beq, bne, blt, bgez 등)
  • 함수 호출
  • 스택의 구조

TIP

어셈블리를 배울 때 유용한 (Compiler Explorer)라는 온라인 컴파일러가 있습니다. 여기서는 C 언어 코드를 입력하면, 컴파일 결과인 역어셈블리를 볼 수 있습니다. 어떤 기계어가 C 언어 코드의 어느 부분에 대응하는지 색상으로 명확히 확인할 수 있습니다.

또한 컴파일러 옵션으로 -O0(최적화 끔), -O2(최적화 레벨 2) 등을 지정해, 컴파일러가 어떤 어셈블리를 출력하는지 비교해보는 것도 공부에 도움이 됩니다.

WARNING

기본 설정에서는 x86-64용 어셈블리를 출력합니다. 오른쪽 패널에서 RISC-V rv32gc clang (trunk) 등을 선택하면 32비트 RISC-V 어셈블리가 출력됩니다.

어셈블리 언어 기초

어셈블리 언어는 기계어와 거의 1:1에 대응되는 표현입니다. 간단한 예시를 봅시다.

asm
addi a0, a1, 123
  • addi는 “즉시값(immediate)을 더한다”는 명령이며,
  • a0, a1, 123은 각각 오퍼랜드입니다.

즉, 레지스터 a1 값에 123을 더해 결과를 레지스터 a0에 저장한다는 뜻입니다.

Registers (레지스터)

레지스터는 CPU 내부의 임시 변수와 같습니다. 메모리보다 훨씬 빠르며, CPU는 메모리에서 데이터를 레지스터로 읽어와 레지스터에서 산술 연산을 수행한 후 결과를 다시 메모리/레지스터에 씁니다.

RISC-V에서 주로 쓰이는 레지스터는 다음과 같습니다.

RegisterABI Name (alias)Description
pcpc프로그램 카운터 (다음 실행할 명령어 주소)
x0zero항상 0인 레지스터
x1ra함수 호출에서 복귀 주소 저장
x2sp스택 포인터
x5 - x7t0 - t2임시용 레지스터 (임의 용도로 사용 가능)
x8fp스택 프레임 포인터
x10 - x11a0 - a1함수 인자 및 반환값
x12 - x17a2 - a7함수 인자
x18 - x27s0 - s11함수 호출 사이에도 값이 보존되는 레지스터
x28 - x31t3 - t6임시용 레지스터

TIP

호출 규약(Calling Convention):

RISC-V에서는 함수 인자 및 반환값을 a0~a7 레지스터에 할당하고, 임시 레지스터와 저장(reg-save) 레지스터를 구분하는 등 사용 방식이 미리 정해져 있습니다. 더 자세한 내용은 RISC-V 호출 규약 문서에서 확인할 수 있습니다.

Memory access (메모리 접근)

레지스터는 CPU 내부에 있는 아주 빠른 저장소지만, 개수가 제한되어 있습니다. 반면에 메모리는 훨씬 크지만, 속도가 느립니다.

따라서:

  • 자주 사용하는 값은 레지스터에 저장합니다.
  • 많은 양의 데이터는 메모리에 저장합니다. lwsw는 레지스터와 메모리 간 데이터를 주고받는 명령어입니다.

lw: 메모리에서 값 읽기

asm
lw a0, (a1)
  • 의미: 메모리에서 a1이 가리키는 주소의 32비트 값을 가져와 a0 레지스터에 저장.
  • C언어 표현: a0 = *a1; (a1이 가리키는 메모리 주소의 값을 읽어 a0에 넣는다.)

sw: 메모리에 값 저장

asm
lw a0, (a1)
  • 의미: a0 레지스터에 있는 값을 a1이 가리키는 메모리 주소에 저장.
  • C언어 표현: *a1 = a0; (a1이 가리키는 메모리 주소에 a0 값을 저장한다.)

Branch instructions (분기 명령)

bnez(0이 아니면 분기), beq(두 레지스터의 값이 같으면 분기), blt(작으면 분기) 등으로 프로그램 흐름을 제어합니다. C의 if나 while, for 구문을 구현할 때 사용됩니다.

asm
bnez a0, label   // a0가 0이 아니면 label로 점프
// a0가 0이면 계속 아래 코드 실행

<label>:
    // a0가 0이 아닐 때 실행

Function calls (함수 호출)

jal(jump and link) 명령을 통해 함수를 호출하고, ret 명령으로 복귀합니다.

asm
li  a0, 123 // 인자 준비: 123을 a0 레지스터에 저장 (함수 인자), 
jal ra, func   //  func(a0=123) 호출 -> ra에 복귀 주소 저장
// 여기서 함수 func가 끝나서 돌아오면, 결과값은 a0에 저장되어 있음

func:
    addi a0, a0, 1  // a0에 1을 더함
    ret             // ra 레지스터로 복귀

// 위 함수의 c언어 예시    
int func(int a) {
    a += 1;
    return a;
}

함수 인자는 a0 - a7 레지스터에 전달되고, 반환 값은 호출 규약에 따라 a0 레지스터에 저장됩니다.

Stack (스택)

스택은 함수 호출과 로컬 변수를 위해 사용되는 LIFO(Last-In-First-Out) 구조입니다. sp(스택 포인터)가 스택의 현재 최상단을 가리키며, 스택은 위에서 아래로 확장됩니다.

asm
addi sp, sp, -4  // 스택을 4바이트만큼 확장
sw   a0, (sp)    // sp가 가리키는 위치에 a0 저장

lw   a0, (sp)    // 다시 읽고
addi sp, sp, 4   // 스택을 원래대로 복원

TIP

C 언어로 코드를 작성하면, 컴파일러가 자동으로 스택을 할당/해제하므로 보통 이런 로우레벨 코드를 직접 작성할 일은 많지 않습니다.

CPU 모드

CPU에는 M-mode, S-mode, U-mode 세 가지 특권 수준이 있습니다. 구체적으로는:

ModeOverview
M-modeOpenSBI(일종의 펌웨어)나 부트로더 등
S-mode운영체제 커널이 동작하는 모드 "kernal mode" 라고 부름
U-mode일반 애플리케이션이 동작하는 모드 "user mode" 라고 부름

Privileged instructions (특권 명령)

애플리케이션(유저 모드)에서는 실행할 수 없는 명령이 있고, 이런 명령을 특권 명령이라 부릅니다. 아래는 예시 목록입니다.

Opcode and operandsOverviewPseudocode
csrr rd, csrCSR 값을 rd에 읽어옴rd = csr;
csrw csr, rsrs의 값을 CSR에 씀csr = rs;
csrrw rd, csr, rsCSR 값을 읽고 쓰는 과정을 한 번에 수행tmp = csr; csr = rs; rd = tmp;
sret트랩 핸들러 복귀(PC, 모드 등 복원)
sfence.vmaTLB(Translation Lookaside Buffer) 초기화

CSR (Control and Status Register) 은 CPU 내부 상태와 제어를 담당하는 레지스터이며, 전체 목록은 RISC-V Privileged Specification 에서 확인할 수 있습니다.

TIP

sret같은 명령은 내부적으로 PC 수정, 모드 변경 등 복잡한 과정을 수행합니다. 실제 동작을 이해하려면 rvemu 같은 간단한 RISC-V 에뮬레이터의 소스 코드를 참고해보는 것도 좋습니다. (e.g. sret implementation).

Inline assembly (인라인 어셈블리)

이 책 후반부에서, C 코드 중간에 RISC-V 어셈블리를 직접 삽입하는 예시를 자주 보게 될 것입니다. 이 기법은 ‘인라인 어셈블리’라 하며, 별도의 .S 파일을 만드는 대신, 다음과 같은 형태로 작성합니다.

c
__asm__ __volatile__("assembly code" 
                     : output operands 
                     : input operands 
                     : clobbered registers);

C 변수와 어셈블리 명령을 긴밀히 연결할 수 있고, 레지스터 할당·저장/복원을 컴파일러가 맡아주므로 편리합니다. 자세한 설명은 GCC 문서에서 확인할 수 있으며, xv6-riscv, HinaOS 등의 오픈소스 예시도 참고해볼 만합니다.

PartDescription
__asm__“이 코드는 인라인 어셈블리다”라고 컴파일러에 알립니다.
__volatile__컴파일러에게 해당 "assembly" 코드를 최적화로 제거하거나 변경하지 말라고 지시합니다.
"assembly"실제 어셈블리 코드를 문자열 리터럴 형태로 작성합니다.
output operands어셈블리가 실행된 뒤 결과를 저장할 C 변수(혹은 메모리 위치)를 지정합니다.
input operands어셈블리 코드에서 참조할 C 언어 표현식(예: 상수 123, 변수 x 등)을 지정합니다.
clobbered registers어셈블리 내부에서 덮어써서 “내용이 파괴될 수 있는” 레지스터를 지정합니다. 누락하면 컴파일러가 해당 레지스터를 보존하지 않아, 예기치 않은 버그가 발생할 수 있습니다.

출력 오퍼랜드와 입력 오퍼랜드는 쉼표(,) 로 구분해 나열하며, 각 오퍼랜드는 제약(constraint) (C 표현식) 형태로 작성합니다. 제약은 보통:

  • 출력 오퍼랜드에는 =r (register)을 사용하며, “이 변수는 레지스터를 통해 값을 돌려받는다”는 의미입니다.
  • 입력 오퍼랜드에는 r를 사용하며, “이 표현식을 레지스터에 담아 어셈블리에서 쓸 수 있다”는 의미입니다.

어셈블리 코드에서 출력/입력 오퍼랜드는 %0, %1, %2 등의 형태로 참조하며, 출력 오퍼랜드부터 순서대로 번호를 매깁니다.

예시

c
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));

위 예시는 csrr 명령어로 sepc CSR 레지스터의 값을 읽어와 value 변수에 저장합니다. 여기서 %0value 변수와 연동됩니다.

c
__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));

위 예시는 csrw 명령어로 sscratch CSR에 123을 쓰는 코드입니다. %0은 123을 담은 레지스터(“r” 제약)에 해당하고, 실제로 컴파일러가 다음과 같은 코드를 생성합니다.

li    a0, 123        // a0 레지스터에 123을 로드
csrw  sscratch, a0   // sscratch 레지스터에 a0 값을 기록

인라인 어셈블리에는 csrw 명령어만 적었지만, “r” 제약을 만족시키기 위해 컴파일러가 li 명령어를 자동으로 삽입해줍니다. 매우 편리한 방식입니다.

TIP

인라인 어셈블리는 표준 C 문법이 아니라, GCC/Clang 계열 컴파일러의 확장 기능입니다. CPU 아키텍처마다 제약(constraint) 문법이 조금씩 다르고, 지원 기능이 많아서 처음에는 이해하는 데 시간이 걸릴 수 있습니다.

초보자라면 실제 프로젝트 예시를 참고해보는 것을 추천합니다. 예를 들어, xv6-riscv나 HinaOS 같은 프로젝트의 인라인 어셈블리 코드를 보면 큰 도움이 됩니다.