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 내부에 있는 아주 빠른 저장소지만, 개수가 제한되어 있습니다. 반면에 메모리는 훨씬 크지만, 속도가 느립니다.

따라서:

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

lw: 메모리에서 값 읽기 ​

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

sw: 메모리에 값 저장 ​

asm
sw 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 변수에 저장합니다. 여기서 %0은 value 변수와 연동됩니다.

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 같은 프로젝트의 인라인 어셈블리 코드를 보면 큰 도움이 됩니다.