[1000줄 OS 구현하기] RISC-V Assembly

RISC-V 101 | OS in 1,000 Lines


1. RISC-V

RISC-V는 (”리스크 파이브”로 발음한다.) 축소 명령어 집합 컴퓨터 즉, RISC(Reduced Instruction Set Computer) 기반의 개발형 명령어 집합(ISA)이다.

대부분의 ISA와 달리 RISC-V ISA는 일부 목적으로는 자유로이 사용할 수 있으며, 누구든지 RISC-V 칩과 소프트웨어를 설계, 제조, 판매할 수 있게 허가되어 있다.

저자는 RISC-V를 CPU로 선택한 이유가 명세가 간단하고 초보자에게 적합하며, x86과 Arm과 함께 최근 주목받는 ISA이기 때문이라 한다.

Ratified Specifications

The RISC-V open-standard instruction set architecture (ISA) defines the fundamental guidelines for designing and implementing RISC-V processors.

RISC-V의 spec을 읽어볼 수 있다.

32비트 RISC-V를 사용한다.

조금의 수정을 통해서 64비트로 변경할 수 있지만 보다 복잡하고 주소를 읽기 어렵다는 단점이 있어 32비트로 진행한다고 한다.

출처 : https://ko.wikipedia.org/wiki/RISC-V


2. QEMU virt machine

이 책에서는 QEMU virt 머신을 사용한다.

‘virt’ Generic Virtual Platform (virt) — QEMU documentation

QEMU는 가상화 소프트웨어이고, virt는 QEMU의 가상 머신 플랫폼으로, 실제 하드웨어를 모방한 가상의 하드웨어 플랫폼이다.

QEMU 안에서 동작하는 가상화된 CPU, 메모리, 등 각종 하드웨어들을 제공한다.

이를 이용하면 가상화된 환경에서 안전하게 실험할 수 있다.

또 디버깅 때 QEMU의 소스 코드를 읽거나, QEMU 프로세스에 디버거를 연결하여 문제파악에 사용한다.


3. RISC-V assembly

OS를 구현하기 위해서 RISC-V의 assembly를 알아야 한다.

많이 어렵지 않다고 하니 배워보자.

RISC-V 101 | OS in 1,000 Lines

어셈블리어를 이해하는데 참고.

가. RISC-V의 Register 구성

RegisterABI Name (alias)Description
pcpcProgram counter (where the next instruction is)
x0zeroHardwired zero (always reads as zero)
x1raReturn address
x2spStack pointer
x3gpGlobal pointer
x4tpThread pointer
x5 – x7t0 – t2Temporary registers
x8fpStack frame pointer
x10 – x11a0 – a1Function arguments/return values
x12 – x17a2 – a7Function arguments
x18 – x27s0 – s11Temporary registers saved across calls
x28 – x31t3 – t6Temporary registers

나. Memory access

lw a0, (a1)  // Read a word (32-bits) from address in a1
             // and store it in a0. In C, this would be: a0 = *a1;
Code language: JavaScript (javascript)
sw a0, (a1)  // Store a word in a0 to the address in a1.
             // In C, this would be: *a1 = a0;
Code language: JavaScript (javascript)

여기서 (...)는 C 언어에서의 포인터처럼 생각하면 된다.

li a0, 42        # a0 레지스터에 42를 직접 저장
Code language: PHP (php)

반면에 li(load immediate)는 즉시 값을 레지스터에 로드하는 명령어다.

이는 C 언어에서 변수에 직접 값을 할당하는 것과 비슷하다. (예: int a = 123;)


다. Branch instructions

Branch instructions는 프로그램의 제어 흐름을 변경한다.

이는 if, for, while 문을 구현하는 데 사용된다.

    bnez    a0, <label>   // Go to <label> if a0 is not zero
    // If a0 is zero, continue here

<label>:
    // If a0 is not zero, continue here
Code language: HTML, XML (xml)
  • bnez : branch if not equal to zero – 값이 0이 아닐 때 분기
  • beq : branch if equal – 두 값이 같을 때 분기
  • blt : branch if less than – 첫 번째 값이 두 번째 값보다 작을 때 분기

이들은 C언어의 goto와 비슷하지만 조건이 있다는 점이 다르다.


다. Function calls

jal(jump and link)과 ret(return) 명령어는 함수를 호출하고 함수에서 반환하는 데 사용된다.

    li  a0, 123      // Load 123 to a0 register (function argument)
    jal ra, <label>  // Jump to <label> and store the return address
                     // in the ra register.

    // After the function call, continue here...

// int func(int a) {
//   a += 1;
//   return a;
// }
<label>:
    addi a0, a0, 1    // Increment a0 (first argument) by 1

    ret               // Return to the address stored in ra.
                      // a0 register has the return value.
Code language: PHP (php)

함수 인자는 calling convention에 따라 a0a7 레지스터에 전달되며, 반환값은 a0 레지스터에 저장된다.


라. Stack

스택은 함수 호출과 지역 변수를 위해 사용되는 후입선출(LIFO, Last-In-First-Out) 메모리 공간이다.

스택은 아래쪽으로 확장되며, 스택 포인터 sp는 스택의 맨 위를 가리킨다.

스택에 값을 저장하기 위해서는 스택 포인터를 감소시키고 값을 저장한다(일명 push 연산):

    addi sp, sp, -4  // Move the stack pointer down by 4 bytes
                     // (i.e. stack allocation).

    sw   a0, (sp)    // Store a0 to the stack
Code language: JavaScript (javascript)

스택에서 값을 로드하려면, 값을 로드하고 스택 포인터를 증가시킨다(일명 pop 연산):

    lw   a0, (sp)    // Load a0 from the stack
    addi sp, sp, 4   // Move the stack pointer up by 4 bytes
                     // (i.e. stack deallocation).
Code language: JavaScript (javascript)

C 언어에서는 컴파일러가 스택 연산을 자동으로 생성하므로, 직접 작성할 필요가 없다.


마. CPU modes

CPU는 각기 다른 privilege 권한을 가진 여러 모드를 가지고 있다.

RISC-V의 경우 세 가지 모드가 있다:

ModeOverview
M-modeMode in which OpenSBI (i.e. BIOS) operates.
S-modeMode in which the kernel operates, aka. “kernel mode”.
U-modeMode in which applications operate, aka. “user mode”.

OpenSBI(Open Source Supervisor Binary Interface)는 RISC-V 하드웨어와 운영체제 사이의 인터페이스를 제공하는 펌웨어로, PC의 BIOS나 UEFI와 비슷한 역할을 한다.


바. Privileged instructions

CPU 명령어 중 privileged 명령어라고 분류되는 것들이 존재한다.

이는 user-mode 즉 일반 application에선 실행할 수 없다.

우리는 아래의 privileged instructions만 사용할 예정이다.

Opcode and operandsOverviewPseudocode
csrr rd, csrRead from CSRrd = csr;
csrw csr, rsWrite to CSRcsr = rs;
csrrw rd, csr, rsRead from and write to CSR at oncetmp = csr; csr = rs; rd = tmp;
sretReturn from trap handler (restoring program counter, operation mode, etc.) 
sfence.vmaClear Translation Lookaside Buffer (TLB) 
명령어의미동작예시
csrrControl and Status Register ReadCSR에서 값을 읽어서 지정된 레지스터에 저장csrr rd, csr
csrwControl and Status Register Write레지스터의 값을 CSR에 쓰기csrw csr, rs
csrrwControl and Status Register Read and WriteCSR 읽기와 쓰기를 원자적으로 수행csrrw rd, csr, rs
sretSupervisor Return트랩 핸들러에서 복귀, PC와 CPU 모드 복원sret
sfence.vmaSupervisor Fence Virtual Memory AddressTLB(Translation Lookaside Buffer) 초기화sfence.vma

💡트랩 핸들러(Trap Handler)란?

예외나 인터럽트와 같은 특별한 이벤트가 발생했을 때 이를 처리하는 코드를 말합니다.

예를 들어 시스템 콜이 발생했을 때, 페이지 폴트(메모리 접근 오류)가 발생했을 때, 하드웨어 인터럽트가 발생했을 때 등등.

이러한 상황에서 CPU는 현재 실행 중인 프로그램을 일시 중단하고 트랩 핸들러로 제어를 넘깁니다.

트랩 핸들러는 이러한 상황을 처리한 후 sret 명령어를 사용하여 원래 프로그램으로 제어를 돌려줍니다.


사. Inline assembly

C 코드 내부에서 assembly를 사용하는 문법을 “inline assembly”라고 한다.

uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));
Code language: JavaScript (javascript)

1) 문법

__asm__ __volatile__("assembly" : output operands : input operands : clobbered registers);
Code language: JavaScript (javascript)
구성 요소설명
__asm__인라인 어셈블리임을 나타냅니다.
__volatile__컴파일러가”assembly”코드를 최적화하지 않도록 지시합니다.
“assembly”문자열 리터럴로 작성된 어셈블리 코드입니다.
output operands어셈블리의 결과를 저장할 C 변수들입니다.
input operands어셈블리에서 사용될 C 표현식들입니다 (예:123,x).
clobbered registers어셈블리에서 내용이 변경되는 레지스터들입니다. 이를 명시하지 않으면 C 컴파일러가 해당 레지스터들의 내용을 보존하지 않아 버그가 발생할 수 있습니다.

Output and input operands는 콤마로 구분되며, 각 operand는 constraint (C expression) 형식으로 작성된다.

Constraint는 operand의 타입을 지정하기 위해서 사용되며, 보통 output operand에는 =r (register)를 input operand에는 r을 사용한다.

어셈블리 코드에서 input과 output operand는 %0, %1, %2 와 같이 쓰고 순서대로 접근할 수 있다.

2) 예시

uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));
Code language: JavaScript (javascript)

RISC-V의 sepc(Supervisor Exception Program Counter) CSR의 값을 읽어서 value 변수에 저장하는 인라인 어셈블리다.

  • uint32_t value; : 32비트 부호 없는 정수형 변수를 선언
  • __asm__ : 인라인 어셈블리임을 나타내냄
  • __volatile__ : 컴파일러가 이 코드를 최적화하지 않도록 지시
  • "csrr %0, sepc" : CSR에서 값을 읽어와서 %0로 표시된 위치(여기서는 value 변수)에 저장
  • : "=r"(value) : value 변수에 어셈블리 명령어의 실행 결과를 저장합니다
__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));
Code language: JavaScript (javascript)

이는 csrw 명령어를 사용하여 sscratch CSR에 123을 쓰는 것입니다. %0123을 포함하는 레지스터(r 제약조건)에 해당하며, 실제로는 아래와 같이 동작합니다.

li    a0, 123        // Set 123 to a0 register
csrw  sscratch, a0   // Write the value of a0 register to sscratch register
Code language: JavaScript (javascript)

인라인 어셈블리에는 csrw 명령어만 작성되어 있다.

li 명령어는 "r" 제약 조건(레지스터의 값)을 만족시키기 위해 컴파일러가 자동으로 삽입한다.

💡TIP

인라인 어셈블리는 CPU 아키텍처에 따라 문법이 다르다.

C 언어 spec에 포함되지 않는 컴파일러 확장 기능이다.

자세한 사용법은 GCC 문서에서 확인.

초보자의 경우 HinaOSxv6-riscv 실제 사용 예시 참고하면 좋음.


댓글 남기기