시스템 콜이 필요한 이유

이전 글 에서 sret으로 U-Mode 전환에 성공했다. shell.cmain이 실제로 실행되고, 커널 메모리에 접근을 시도하면 Page Fault가 발생하는 것도 확인했다.

그런데 한 가지 문제가 있다. U-Mode 프로세스는 SBI 콜도, 커널 함수도 직접 호출할 수 없다. putcharexit가 아직 빈 껍데기인 이유다. 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 콜 과 거의 동일하다.

user.c
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으로 돌아온다.

common.h
#define SYS_PUTCHAR 1
#define SYS_GETCHAR 2
#define SYS_EXIT    3
user.c
void 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!
}

exitfor (;;)는 실행될 일이 없다. SYS_EXIT 처리 후 커널은 이 프로세스를 스케줄러에서 제거하므로 sret으로 돌아오지 않는다. 버그로 인해 커널이 예상치 못하게 sret으로 돌아오면 noreturn 함수가 실제로 return하는 상황이 되어 undefined behavior가 발생한다. for (;;)는 그 경우를 막는 안전망이다. 무한 루프를 배치함으로써 “절대 반환되지 않는다"는 약속을 물리적으로 지키는 것이라고 볼 수 있다.

user.h
int getchar(void);

커널에서 ecall 처리

handle_trap 수정

kernel.h
#define SCAUSE_ECALL 8
kernel.c

여기서 user_pc += 4 가 핵심이다. sepc는 예외를 일으킨 명령어, 즉 ecall 자신을 가리킨다. 이 상태에서 그냥 sret으로 복귀하면 ecall을 다시 실행해 무한 루프에 빠진다. ecall은 RISC-V에서 4바이트 고정이므로, sepc에 4를 더해 다음 명령어로 복귀하도록 만든다.

RISC-V 예외와 sepcPage Fault 같은 하드웨어 예외는 해당 명령어를 재실행해야 하는 경우(예: 메모리를 채운 뒤 다시 시도)가 있어서 sepc가 예외 명령어 자체를 가리킨다. 시스템 콜은 재실행이 아니라 '완료 후 다음 줄로' 가야 하므로 소프트웨어가 직접 sepc를 앞으로 밀어야 한다.

시스템 콜 핸들러

kernel.c
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을 남겨둬서 혹시라도 돌아오는 경우를 잡는다.

kernel.h
#define PROC_EXITED   2

셸 구현

이제 putchar가 실제로 동작하므로, printf도 쓸 수 있다. getcharexit까지 더하면 간단한 셸을 만들기에 충분하다.

shell.c
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 process

exit 명령 시 셸 프로세스가 종료되고, 실행 가능한 프로세스가 없으므로 스케줄러가 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에서 시스템 콜이 성능 병목이 되는 이유가 여기 있다.


참고 자료