예외(Exception)
Exception이란 프로그램 실행 중 CPU가 잘못된 메모리 접근(일명 페이지 폴트), 유효하지 않은 명령(Illegal Instructions), 또는 시스템 콜이 발생했을 때 커널이 개입하도록 해주는 CPU 기능이다. CPU는 ISA에서 정의된 명령어만 수행하는데, 실행 도중에 정상적인 다음 동작이 정의되지 않은 상황을 만날 수 있다.
예를 들어, CPU의 디코더가 명령어를 32비트 비트 패턴으로 받아서 인코딩 표에 대조했는데, 어떤 항목에도 매칭되지 않는 경우가 있다. 또는 매칭은 되지만 조건이 맞지 않는 경우(읽기 전용 CSR에 쓰기 시도 등)도 있다. 이때 CPU는 “이 비트 패턴에 대응하는 실행 로직이 없다"는 것까지만 판단할 수 있고, 그다음에 무시할지, 프로그램을 죽일지, 소프트웨어로 에뮬레이션할지는 알지 못한다. 이런 정책적 판단은 커널(OS)이 해야 한다.
그래서 CPU는 이런 상황을 만나면, ISA에 미리 정의된 대로 현재 상태를 CSR에 저장하고 stvec에 등록된 커널의 예외 핸들러로 점프한다. 커널이 상황을 판단하고 적절히 처리한 뒤 sret으로 복귀하면, 프로그램은 아무 일 없었던 것처럼 재개될 수 있다. 이런 CPU가 처리할 수 없는 상황을 커널에게 위임하는 제어 흐름 전환 메커니즘이 예외다.
os01에서 다뤘던 Page Fault 시나리오
를 떠올려보면, U-Mode에서 lw s0, 0(s1)를 실행했을 때 MMU가 페이지 테이블에서 V=0인 엔트리를 만나는 것도 같은 구조다. CPU는 “변환 실패"라는 사실까지만 감지하고 커널로 점프하며, 디스크에서 페이지를 불러올지 프로그램을 종료할지는 커널이 결정했다. 이 글에서는 이 점프를 받아주는 예외 핸들러의 뼈대 코드를 직접 구현한다.
예외가 처리되는 과정
RISC-V에서 예외는 다음과 같은 단계를 거쳐 처리된다.
- CPU는 medelegMachine Exception Delegation이라는 레지스터로, 기본적으로 예외는 M-mode에서 처리되지만, 속도와 성능을 위해서 예외를 S-mode로 위임하여 처리할지를 결정하는 레지스터이다. 레지스터를 확인하여 어떤 모드(운영 모드)에서 예외를 처리할지 결정한다. 여기서는 OpenSBI가 이미 U-Mode와 S-Mode 예외를 S-Mode 핸들러에서 처리하도록 설정해뒀다.
- CPU는 예외가 발생한 시점의 상태(각종 레지스터 값)를 여러 CSR(제어/상태 레지스터)들에 저장하게 된다.(아래 표 참조).
stvec레지스터에 저장된 값이 프로그램 카운터로 설정되면서, 커널의 예외 핸들러로 점프한다.- 예외 핸들러는 일반 레지스터(프로그램 상태)를 별도로 저장한 뒤, 예외를 처리한다.
- 처리 후, 저장해둔 실행 상태를 복원하고
sret명령어를 실행해 예외가 발생했던 지점으로 돌아가 프로그램을 재개한다.
| 레지스터 | 내용 |
|---|---|
scause | Supervisor Cause Register로, 커널은 이를 읽어 어떤 종류의 예외인지 판단합니다. |
stval | Supervisor Trap Value Register로, 예외에 대한 부가적인 값을 담는다.(예: 페이지 폴트 시 접근하려던 잘못된 메모리 주소) |
sepc | Supervisor Exception Program Counter로, 예외가 발생했을 때의 프로그램 카운터(PC) 값. |
sstatus | Supervisor Status Register로, 현재 프로세스의 상태를 관리하는 레지스터다. 지금 인터럽트가 가능한지, 이전에 어떤 모드였는지, 어떤 메모리 권한을 가졌는지 등을 저장하는 레지스터다. |
예외 핸들러(Exception Handler) 구현
아래 코드는 stvec 레지스터에 등록할 예외 핸들러 진입점(entry point) 예시다.
kernel.c
__attribute__((naked))
__attribute__((aligned(4)))
void kernel_entry(void) {
__asm__ __volatile__(
"csrw sscratch, sp\n"
"addi sp, sp, -4 * 31\n"
"sw ra, 4 * 0(sp)\n"
"sw gp, 4 * 1(sp)\n"
"sw tp, 4 * 2(sp)\n"
"sw t0, 4 * 3(sp)\n"
"sw t1, 4 * 4(sp)\n"
"sw t2, 4 * 5(sp)\n"
"sw t3, 4 * 6(sp)\n"
"sw t4, 4 * 7(sp)\n"
"sw t5, 4 * 8(sp)\n"
"sw t6, 4 * 9(sp)\n"
"sw a0, 4 * 10(sp)\n"
"sw a1, 4 * 11(sp)\n"
"sw a2, 4 * 12(sp)\n"
"sw a3, 4 * 13(sp)\n"
"sw a4, 4 * 14(sp)\n"
"sw a5, 4 * 15(sp)\n"
"sw a6, 4 * 16(sp)\n"
"sw a7, 4 * 17(sp)\n"
"sw s0, 4 * 18(sp)\n"
"sw s1, 4 * 19(sp)\n"
"sw s2, 4 * 20(sp)\n"
"sw s3, 4 * 21(sp)\n"
"sw s4, 4 * 22(sp)\n"
"sw s5, 4 * 23(sp)\n"
"sw s6, 4 * 24(sp)\n"
"sw s7, 4 * 25(sp)\n"
"sw s8, 4 * 26(sp)\n"
"sw s9, 4 * 27(sp)\n"
"sw s10, 4 * 28(sp)\n"
"sw s11, 4 * 29(sp)\n"
"csrr a0, sscratch\n"
"sw a0, 4 * 30(sp)\n"
"mv a0, sp\n"
"call handle_trap\n"
"lw ra, 4 * 0(sp)\n"
"lw gp, 4 * 1(sp)\n"
"lw tp, 4 * 2(sp)\n"
"lw t0, 4 * 3(sp)\n"
"lw t1, 4 * 4(sp)\n"
"lw t2, 4 * 5(sp)\n"
"lw t3, 4 * 6(sp)\n"
"lw t4, 4 * 7(sp)\n"
"lw t5, 4 * 8(sp)\n"
"lw t6, 4 * 9(sp)\n"
"lw a0, 4 * 10(sp)\n"
"lw a1, 4 * 11(sp)\n"
"lw a2, 4 * 12(sp)\n"
"lw a3, 4 * 13(sp)\n"
"lw a4, 4 * 14(sp)\n"
"lw a5, 4 * 15(sp)\n"
"lw a6, 4 * 16(sp)\n"
"lw a7, 4 * 17(sp)\n"
"lw s0, 4 * 18(sp)\n"
"lw s1, 4 * 19(sp)\n"
"lw s2, 4 * 20(sp)\n"
"lw s3, 4 * 21(sp)\n"
"lw s4, 4 * 22(sp)\n"
"lw s5, 4 * 23(sp)\n"
"lw s6, 4 * 24(sp)\n"
"lw s7, 4 * 25(sp)\n"
"lw s8, 4 * 26(sp)\n"
"lw s9, 4 * 27(sp)\n"
"lw s10, 4 * 28(sp)\n"
"lw s11, 4 * 29(sp)\n"
"lw sp, 4 * 30(sp)\n"
"sret\n"
);
}코드 실행 흐름은 다음과 같다.
- 현재 스택 포인터 저장
sscratch(Supervisor Scratch Register): 임시 저장 레지스터로, 현재는 커널의 스택 포인터를 임시로 보관하고 있다.
- 레지스터 저장
- 이전 스택 포인터 저장
handle_trap이라는 함수 호출- 레지스터 복구
sret으로 복귀
kernel_entry는 trap으로 진입한 CPU 상태를 안전하게 저장하고, 이를 handle_trap함수가 처리할 수 있는 형태(trap_frame)로 변환한 뒤, 다시 원래 상태로 복구하는 역할을 한다.
kernel_entry 함수에 있던 31개의 레지스터 뭉탱이를 trap_frame이라는 구조체를 만들어서 관리하자. 그렇다면 우리는 handle_trap 함수를 다음과 같이 짤 수 있다. 현재로는 trap이 발생하면 커널 패닉을 발생시키도록 했지만, 추후에 처리를 추가할 예정이다.
kernel.c
void handle_trap(struct trap_frame *f) {
uint32_t scause = READ_CSR(scause);
uint32_t stval = READ_CSR(stval);
uint32_t user_pc = READ_CSR(sepc);
PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n", scause, stval, user_pc);
}커널 패닉은 디버깅을 위해 작성했다.
이때 사용되는 trap_frame, READ_CSR을 헤더파일에 정의하자.
kernel.h
#include "common.h"
struct trap_frame {
uint32_t ra;
uint32_t gp;
uint32_t tp;
uint32_t t0;
uint32_t t1;
uint32_t t2;
uint32_t t3;
uint32_t t4;
uint32_t t5;
uint32_t t6;
uint32_t a0;
uint32_t a1;
uint32_t a2;
uint32_t a3;
uint32_t a4;
uint32_t a5;
uint32_t a6;
uint32_t a7;
uint32_t s0;
uint32_t s1;
uint32_t s2;
uint32_t s3;
uint32_t s4;
uint32_t s5;
uint32_t s6;
uint32_t s7;
uint32_t s8;
uint32_t s9;
uint32_t s10;
uint32_t s11;
uint32_t sp;
} __attribute__((packed));
#define READ_CSR(reg) \
({ \
unsigned long __tmp; \
__asm__ __volatile__("csrr %0, " #reg : "=r"(__tmp)); \
__tmp; \
})
#define WRITE_CSR(reg, value) \
do { \
uint32_t __tmp = (value); \
__asm__ __volatile__("csrw " #reg ", %0" ::"r"(__tmp)); \
} while (0)__attribute__((packed)): 컴파일러가 임의로 구조체 사이에 넣는 빈 공간(패딩)을 제거하라는 지시어- 또
do - while(0)씀
마지막으로, kernel_main 함수에 stvecSupervisor Trap-Vector Base Address Register로, S-mode에서 예외나 인터럽트가 발생했을 때, CPU가 시작할 핸들러의 주소를 나타내는 레지스터이다. 레지스터를 설정하자.
kernel.c
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
PANIC("booted!");
printf("unreachable here!\n");
}
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry); // new
__asm__ __volatile__("unimp"); // new
}
unimp은 illegal instruction 로 간주되어 의도적으로 예외를 발생시키는 코드이다.kernel_entry라는 함수명을 변수로: C에서 함수의 이름은 그 자체로 메모리 주소를 나타내는 상수이다.
실행해보기
./run.sh
PANIC: kernel.c:95: unexpected trap scause=00000002, stval=00000000, sepc=8020011cscause=2는 “illegal instruction” 예외다. 또한 sepc가 가리키는 주소가 뭔지 확인해보자면 다음과 같이 하면된다. llvm-addr2line-14 -e kernel.elf 8020011c를 치면
llvm-addr2line-14 -e kernel.elf 8020011c
/home/leejw/projects/my-os-in-1000-lines/kernel.c:127으로 나오게 되고, 코드 위치를 살펴보면, __asm__ __volatile__("unimp");에 해당하는 것을 알 수 있다.