2025. 2. 5. 00:27ㆍCS/OS
1. 예외
운영체제(OS)에서 예외(Exception)는 프로그램 실행 중에 발생하는 예기치 않은 상황이나 오류를 의미한다.
이는 하드웨어나 소프트웨어에서 발생할 수 있으며, 정상적인 프로그램의 흐름을 방해하는 사건입니다.
예외는 크게 다음과 같은 상황에서 발생할 수 있다:
- 하드웨어 인터럽트: 외부 장치로부터의 신호나 이벤트
- 소프트웨어 인터럽트: 시스템 콜과 같은 프로그램의 의도적인 요청
- 프로그램 오류: 0으로 나누기, 잘못된 메모리 접근, 페이지 폴트 등
운영체제는 이러한 예외 상황이 발생했을 때 적절한 예외 처리 루틴을 실행하여 시스템의 안정성을 유지해야 한다.
프로세스의 정상적인 실행을 방해하지 않으면서 문제를 해결하거나, 필요한 경우 프로그램을 안전하게 종료해야 한다.
가. 예외 처리 과정
RISC-V에서 예외는 다음과 같은 단계를 거쳐 처리한다.
- CPU는
medeleg
(Machine Delegation) 레지스터를 확인하여 어떤 모드에서 예외를 처리할지 결정한다. 여기서는 OpenSBI가 이미 U-Mode와 S-Mode 예외를 S-Mode 핸들러에서 처리하도록 설정해 두었다.
- CPU는 예외가 발생한 시점의 상태(각종 레지스터 값)를 여러 CSR(제어/상태 레지스터)들에 저장한다.
레지스터 | 줄임말 | 내용 |
scause | Supervisor Cause | 예외 유형. 커널은 이를 읽어 어떤 종류의 예외인지 판단합니다. |
stval | Supervisor Trap Value | 예외에 대한 부가 정보(예: 문제를 일으킨 메모리 주소). 예외 종류에 따라 다르게 사용됩니다. |
sepc | Supervisor Exception Program Counter | 예외가 발생했을 때의 프로그램 카운터(PC) 값. |
sstatus | Supervisor Status | 예외가 발생했을 때의 운영 모드(U-Mode/S-Mode 등). |
stvec
(Supervisor Trap Vector)레지스터에 저장된 값이 프로그램 카운터로 설정되면서, 커널의 예외 핸들러(트랩 핸들러)로 점프한다.- 예외 핸들러는 일반 레지스터(프로그램 상태)를 별도로 저장한 뒤, 예외를 처리한다.
- 처리 후, 저장해 둔 실행 상태를 복원하고
sret
명령어를 실행해 예외가 발생했던 지점으로 돌아가 프로그램을 재개한다.
나. 예외 핸들러 진입점
stvec
레지스터에 등록할 예외 핸들러 진입점이다.
예외 핸들러 진입점(entry point)은 커널에서 가장 까다롭고 실수하기 쉬운 부분 중 하나라고 한다.
원래의 일반 레지스터 값을 전부 스택에 저장하고, sp
는 sscratch
를 통해 우회적으로 저장한다.
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"
);
}
"csrw sscratch, sp\n”
:sscratch
(Supervisor Scratch) 레지스터는 RISC-V 아키텍처에서 제공하는 특수 레지스터로, 주로 예외 처리 시 임시 값을 저장하는 용도로 사용됨.
:sscratch
레지스터를 임시 저장소로 이용해 예외 발생 시점의 스택 포인터를 저장한다.
: 예외를 처리한 후 복귀해야 한다."addi sp, sp, -4 * 31\n"
: 스택에 31개의 레지스터를 저장할 공간 확보
:sp
를-4*31
만큼 이동
: 4바이트만큼 이동하는 이유는 하나의 레지스터가 32개의 플립플롭으로 구성되어 있어서.
: RISC-V 아키텍처에서 우리가 저장하는 일반 레지스터들(ra
,gp
,tp
,t0-t6
,a0-a7
,s0-s11
)은 모두 32비트(32개의 플립플롭)로 구성되어 있다."sw ra, 4 * 0(sp)\n"
…
: 모든 일반 레지스터(ra
,gp
,tp
,t0-t6
,a0-a7
,s0-s11
)를 스택에 순차적으로 저장.csrr a0, sscratch
,sw a0, 4 * 30(sp)
:sscratch
에 저장해둔 원래sp
값을 복구하여 스택에 저장mv a0, sp
: 현재 스택 포인터sp
를a0
레지스터로 이동시켜handle_trap
함수의 인자로 전달"call handle_trap\n”
: 현재 스택 포인터를 인자로 하여handle_trap
함수 호출"lw ra, 4 * 0(sp)\n”
…
:handle_trap
함수 실행 후, 저장해둔 모든 레지스터 값을 복원sret
명령어로 예외 처리 종료 및 원래 실행 지점으로 복귀
- tmi 1. 커널에서는 부동소수점(FPU) 레지스터를 사용하지 않으므로 여기서는 저장하지 않았습니다. 일반적으로 쓰레드 스위칭 시에만 부동소수점 레지스터를 저장 및 복원한다.
- tmi 2.
__attribute__((aligned(4)))
는 함수 시작 주소를 4바이트 경계에 맞추기 위함.stvec
레지스터는 예외 핸들러 주소뿐 아니라 하위 2비트를 모드 정보 플래그로 사용하기 때문에, 핸들러 주소가 4바이트 정렬이 되어 있어야 함.
- 레지스터 참고
Register | ABI Name (alias) | Description |
pc | pc | Program counter (where the next instruction is) |
x0 | zero | Hardwired zero (always reads as zero) |
x1 | ra | Return address |
x2 | sp | Stack pointer |
x3 | gp | Global pointer |
x4 | tp | Thread pointer |
x5 - x7 | t0 - t2 | Temporary registers |
x8 | fp | Stack frame pointer |
x10 - x11 | a0 - a1 | Function arguments/return values |
x12 - x17 | a2 - a7 | Function arguments |
x18 - x27 | s0 - s11 | Temporary registers saved across calls |
x28 - x31 | t3 - t6 | Temporary registers |
다. handle_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);
}
scause
: 예외 원인stval
: 예외 부가정보 (예: 잘못된 메모리 주소 등)sepc
: 예외가 일어난 시점의 PCPANIC(…)
: 디버깅을 위한 커널 패닉
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)
struct trap_frame {…} __attribute__((packed));
: 성능 최적화를 위해 컴파일러가 자동으로 추가하는 정렬 패딩을 비활성화하는 GCC 컴파일러 지시어
: 구조체 멤버들 사이에 자동으로 추가되는 패딩을 없애서 메모리를 더 적게 사용
: 구조체의 각 멤버를 메모리상에서 연속적으로 배치#reg
: 문자열 연결을 위한 토큰 결합 연산자
: 매크로 인자 reg를 문자열 리터럴로 변환하여 어셈블리 코드에 직접 삽입할
: 만약READ_CSR(scause)
라면#reg
는"scause"
라는 문자열로 변환되어"csrr %0, scause"
와 같은 어셈블리 명령어를 생성.
kernel.c
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
WRITE_CSR(stvec, (uint32_t) kernel_entry);
: 예외가 발생했을 때 실행할 핸들러의 주소를 설정하는 코드
:stvec
(Supervisor Trap Vector) CSR에kernel_entry
함수의 주소를 저장
: 이후 예외가 발생하면 CPU는 자동으로kernel_entry
함수를 실행하게 됨__asm__ __volatile__("unimp");
:unimp
명령어 unimplemented instruction
: illegal instruction 로 간주됨
: 일부러 예외를 일으킨다.
2. 실행
./run.sh
...
PANIC: kernel.c:37: unexpected trap scause=00000002, stval=00000000, sepc=8020010a
scause
가 2는 “Illegal instruction” 예외를 의미.
stval
는 예외 부가정보이고, sepc
는 예외가 일어난 시점의 PC라고 했다.
$ llvm-addr2line -e kernel.elf 8020010a
/home/tiredi/Desktop/MyOS/08_exception/kernel.c:122
llvm-addr2line은 실행 파일의 주소를 소스 코드의 위치로 변환해 주는 디버깅 도구다.
8020010a에서 발생한 예외가 kernel.c 파일의 122번째 줄에서 발생했다고 알려준다.
122번째 줄은 __asm__ __volatile__("unimp");
로 정확하게 동작했다.
'CS > OS' 카테고리의 다른 글
[1000줄 OS 구현하기] 커널 패닉 (0) | 2025.02.03 |
---|---|
[1000줄 OS 구현하기] C 표준 라이브러리 (0) | 2025.02.03 |
[1000줄 OS 구현하기] Hello World! (1) | 2025.02.03 |
[1000줄 OS 구현하기] Boot (0) | 2025.01.21 |
[1000줄 OS 구현하기] RISC-V Assembly (0) | 2025.01.17 |