시스템 콜이 필요한 이유
이전 글
에서 sret으로 U-Mode 전환에 성공했다. shell.c의 main이 실제로 실행되고, 커널 메모리에 접근을 시도하면 Page Fault가 발생하는 것도 확인했다.
그런데 한 가지 문제가 있다. U-Mode 프로세스는 SBI 콜도, 커널 함수도 직접 호출할 수 없다. putchar와 exit가 아직 빈 껍데기인 이유다. U-Mode에서 printf를 호출하면 내부적으로 putchar가 호출되는데, 문자를 출력하려면 결국 SBI의 sbi_console_putchar를 거쳐야 하고, 이는 S-Mode에서만 할 수 있다.
이 문제를 해결하는 것이 시스템 콜(System Call) 이다. U-Mode 프로세스가 ecall 명령어로 의도적으로 예외를 발생시키면, 제어권이 커널(S-Mode)로 넘어간다. 커널은 요청을 처리하고 결과를 반환한 뒤, sret으로 다시 U-Mode 프로세스에게 돌려준다.
U-Mode (shell) S-Mode (kernel)
────────────── ────────────────
putchar('H')
└─ ecall ──▶ handle_trap
└─ handle_syscall
└─ putchar('H') // SBI 경유
sret ◀──
└─ 다음 명령 실행os01에서 봤던 트랩 메커니즘
과 구조가 동일하다. 차이는 예외의 원인이 Page Fault가 아니라 ecall이라는 것뿐이다.
사용자 라이브러리 (user.c)
syscall 함수
시스템 콜의 핵심은 ecall 명령어 하나다. 구현은 SBI 콜
과 거의 동일하다.
int syscall(int sysno, int arg0, int arg1, int arg2) {
register int a0 __asm__("a0") = arg0;
register int a1 __asm__("a1") = arg1;
register int a2 __asm__("a2") = arg2;
register int a3 __asm__("a3") = sysno;
__asm__ __volatile__("ecall"
: "=r"(a0)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3)
: "memory");
return a0;
}SBI 콜도 ecall을 쓰고, 시스템 콜도 ecall을 쓴다. 같은 명령어지만 트랩 목적지가 다르다. SBI 콜의 ecall은 S-Mode -> M-Mode 전환이고, 시스템 콜의 ecall은 U-Mode -> S-Mode 전환이다. CPU가 현재 특권 레벨에 따라 어느 핸들러로 진입할지를 결정한다.
레지스터 역할도 SBI 콜과 비슷하게 인자를 a0~a2에, 시스템 콜 번호(SBI에서의 FID/EID에 해당)를 a3에 넣는다. 반환값은 a0으로 돌아온다.
#define SYS_PUTCHAR 1
#define SYS_GETCHAR 2
#define SYS_EXIT 3void putchar(char ch) {
syscall(SYS_PUTCHAR, ch, 0, 0);
}
int getchar(void) {
return syscall(SYS_GETCHAR, 0, 0, 0);
}
__attribute__((noreturn)) void exit(void) {
syscall(SYS_EXIT, 0, 0, 0);
for (;;); // Just in case!
}exit의 for (;;)는 실행될 일이 없다. SYS_EXIT 처리 후 커널은 이 프로세스를 스케줄러에서 제거하므로 sret으로 돌아오지 않는다. 버그로 인해 커널이 예상치 못하게 sret으로 돌아오면 noreturn 함수가 실제로 return하는 상황이 되어 undefined behavior가 발생한다. for (;;)는 그 경우를 막는 안전망이다. 무한 루프를 배치함으로써 “절대 반환되지 않는다"는 약속을 물리적으로 지키는 것이라고 볼 수 있다.
int getchar(void);커널에서 ecall 처리
handle_trap 수정
#define SCAUSE_ECALL 8kernel.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);
}
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);
if (scause == SCAUSE_ECALL) {
handle_syscall(f);
user_pc += 4;
} else {
PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n", scause, stval, user_pc);
}
WRITE_CSR(sepc, user_pc);
}
여기서 user_pc += 4 가 핵심이다. sepc는 예외를 일으킨 명령어, 즉 ecall 자신을 가리킨다. 이 상태에서 그냥 sret으로 복귀하면 ecall을 다시 실행해 무한 루프에 빠진다. ecall은 RISC-V에서 4바이트 고정이므로, sepc에 4를 더해 다음 명령어로 복귀하도록 만든다.
시스템 콜 핸들러
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
case SYS_PUTCHAR:
putchar(f->a0);
break;
case SYS_GETCHAR:
while (1) { // 인터럽트가 없어서 폴링 방식
long ch = getchar();
if (ch >= 0) {
f->a0 = ch;
break;
}
yield();
}
break;
case SYS_EXIT:
printf("process %d exited\n", current_proc->pid);
current_proc->state = PROC_EXITED;
yield();
PANIC("unreachable");
default:
PANIC("unexpected syscall a3=%x\n", f->a3);
}
}trap_frame에는 ecall 시점의 레지스터 값이 그대로 저장되어 있다. f->a3으로 시스템 콜 번호를, f->a0으로 첫 번째 인자를 읽는 것이 그래서 가능하다.
SYS_GETCHAR: SBI의 sbi_console_getchar는 입력이 없으면 -1을 반환한다. 단순 while (1)로 폴링하면 idle 프로세스조차 CPU를 쓸 수 없으므로, 매 루프마다 yield()를 호출해 CPU를 넘긴다.
다만 이는 인터럽트 기반 입력과 PROC_BLOCKED 상태가 없는 단순화된 구현의 한계다. 실제 OS라면 입력이 없을 때 프로세스를 sleep 상태로 전환하고, 키보드 인터럽트가 왔을 때 깨우는 방식을 쓴다.
SYS_EXIT: 프로세스 상태를 PROC_EXITED로 바꾼 뒤 yield()를 호출한다. 스케줄러는 PROC_RUNNABLE 상태만 선택하므로 이 프로세스는 영원히 다시 실행되지 않는다. yield() 이후 코드는 실행될 수 없지만, PANIC을 남겨둬서 혹시라도 돌아오는 경우를 잡는다.
#define PROC_EXITED 2셸 구현
이제 putchar가 실제로 동작하므로, printf도 쓸 수 있다. getchar와 exit까지 더하면 간단한 셸을 만들기에 충분하다.
void main(void) {
while (1) {
prompt:
printf("> ");
char cmdline[128];
for (int i = 0;; i++) {
char ch = getchar();
putchar(ch);
if (i == sizeof(cmdline) - 1) {
printf("command line too long\n");
goto prompt;
} else if (ch == '\r') { // 디버그 콘솔에서는 줄바꿈 문자가 '\r'임.
printf("\n");
cmdline[i] = '\0';
break;
} else {
cmdline[i] = ch;
}
}
if (strcmp(cmdline, "hello") == 0)
printf("Hello world from shell!\n");
else if (strcmp(cmdline, "exit") == 0)
exit();
else
printf("unknown command: %s\n", cmdline);
}
}실행 결과
$ ./run.sh
> hello
Hello world from shell!
> exit
process 2 exited
PANIC: kernel.c:450: switched to idle processexit 명령 시 셸 프로세스가 종료되고, 실행 가능한 프로세스가 없으므로 스케줄러가 idle 프로세스를 선택해 PANIC이 발생한다. 의도된 동작이다.
전체 시스템 콜 흐름
shell:
char ch = getchar(); [shell.c]
└─ syscall(SYS_GETCHAR, 0, 0, 0) [user.c]
├─ ecall
│ ├─ kernel_entry
│ │ └─ handle_trap(f) [kernel.c]
│ │ ├─ handle_syscall(f) [kernel.c]
│ │ │ └─ getchar() [kernel.c]
│ │ └──── sepc += 4
│ ├─ kernel_entry 복귀 (레지스터 복원 -> sret)
│ └─ sret // S-Mode -> U-Mode 복귀
└─ return a0
-> ch에 'h'가 들어있음
shell:
putchar('h'); [shell.c]
└─ syscall(SYS_PUTCHAR, 'h', 0, 0) [user.c]
└─ ecall
├─ kernel_entry
| └─ handle_trap(f) [kernel.c]
| ├─ handle_syscall(f) [kernel.c]
│ | └─ putchar('h') [kernel.c]
│ │ └─ 실제 화면에 h 출력
│ └─ sepc += 4
├─ kernel_entry 복귀 (레지스터 복원 -> sret)
└─ sret // S-Mode -> U-Mode 복귀
shell:
cmdline[0] = 'h'
... 'e', 'l', 'l', 'o' 반복 ...
shell:
char ch = getchar(); [shell.c] // '\r' 입력
└─ syscall(SYS_GETCHAR, 0, 0, 0) [user.c]
├─ ecall
│ ├─ kernel_entry
│ │ └─ handle_trap(f) [kernel.c]
│ │ ├─ handle_syscall(f) [kernel.c]
│ │ │ └─ getchar() [kernel.c]
│ │ │ └─ f->a0 = '\r'
│ │ └──── sepc += 4
│ ├─ kernel_entry 복귀 (레지스터 복원 -> sret)
│ └─ sret // S-Mode -> U-Mode 복귀
└─ return a0
-> ch에 '\r'이 들어있음
shell:
else if (ch == '\r')
printf("\n"); [common.c] // '\n' 출력
└─ putchar('\n') [user.c]
└─ syscall(SYS_PUTCHAR, '\n', 0, 0)
└─ ecall
├─ kernel_entry
| └─ handle_trap(f) [kernel.c]
│ ├─ handle_syscall(f) [kernel.c]
│ | └─ putchar('\n') [kernel.c]
│ │ └─ 실제 화면에 '\n' 출력
│ └─ sepc += 4
├─ kernel_entry 복귀 (레지스터 복원 -> sret)
└─ sret
cmdline[i] = '\0';
break;
-> cmdline = "hello"
shell:
strcmp(cmdline, "hello") == 0
printf("Hello world from shell!\n"); [common.c]
└─ putchar('H') [user.c]
└─ syscall(SYS_PUTCHAR, 'H', 0, 0)
└─ ecall
├─ kernel_entry
| └─ handle_trap(f) [kernel.c]
│ ├─ handle_syscall(f) [kernel.c]
│ | └─ putchar('H') [kernel.c]
│ │ └─ 실제 화면에 'H' 출력
│ └─ sepc += 4
├─ kernel_entry 복귀 (레지스터 복원 -> sret)
└─ sret
└─ putchar('e'), putchar('l'), ... // 나머지 문자도 동일한 사이클U-Mode에서 문자 하나를 찍을 때마다 syscall -> ecall -> kernel_entry -> handle_trap -> handle_syscall -> sbi_call 사이클이 돈다. 실제 OS에서 시스템 콜이 성능 병목이 되는 이유가 여기 있다.