개발/공부 기록

Pintos 완주 후 다시 읽기 2: 프로세스는 실행 파일과 커널 상태의 묶음이었다

cedis 2026. 5. 22. 21:53

Pintos 완주 후 다시 읽기

Argument Passing부터 wait, fork, fd table까지를 하나의 process 상태로 다시 연결한다.

이 글에서 다루는 것

Project 2를 처음 풀 때 Argument Passing은 문자열을 stack에 넣는 문제처럼 보였다. 하지만 뒤로 갈수록 그것만으로는 부족했다. 프로그램은 파일 하나가 아니라, 커널이 관리하는 여러 상태의 묶음이었다.

  • 실행 명령이 user stack의 argc/argv로 바뀌는 과정
  • syscall이 user mode 요청을 kernel mode 작업으로 바꾸는 과정
  • wait, fork, fd table이 왜 process 구조의 일부가 되어야 했는지

1. 실행 파일 이름과 실행 인자는 분리되어야 했다

명령줄 args-single onearg는 커널 입장에서는 하나의 문자열이다. 하지만 user program 입장에서는 argv[0]argv[1]이 나뉘어 있어야 한다.

명령줄이 process의 첫 stack이 되는 과정

1
명령줄을 공백 기준으로 나누어 tmp_argv를 만든다.
2
실행 파일을 열 때는 첫 단어인 tmp_argv[0]만 사용한다.
3
문자열 본문을 user stack 위쪽부터 역순으로 복사한다.
4
argv 포인터 배열, argc, fake return address를 호출 규약에 맞춰 배치한다.
for (int i = argc - 1; i >= 0; i--) {
    size_t len = strlen (tmp_argv[i]) + 1;
    if_->rsp -= len;
    memcpy ((void *) if_->rsp, tmp_argv[i], len);
    argv[i] = (char *) if_->rsp;
}

padding = if_->rsp % 8;
if_->rsp -= padding;

argv[argc] = NULL;
for (int i = argc; i >= 0; i--) {
    if_->rsp -= sizeof (argv[i]);
    memcpy ((void *) if_->rsp, &argv[i], sizeof (argv[i]));
}

if_->R.rsi = if_->rsp;
if_->R.rdi = argc;

void *fake_ret = NULL;
if_->rsp -= sizeof (fake_ret);
memcpy ((void *) if_->rsp, &fake_ret, sizeof (fake_ret));

여기서 핵심은 문자열과 포인터를 모두 user stack에 둔다는 점이다. 커널 내부 배열을 잘 만들어도 user program은 그것을 직접 볼 수 없다. 그래서 user mode에서 접근할 수 있는 stack에 argv 모양을 다시 만들어야 한다.

2. syscall은 함수 호출이 아니라 경계 통과다

user program이 파일을 열거나 기다리거나 fork를 요청할 때, 커널 함수를 직접 부르는 것이 아니다. 정해진 syscall 번호와 인자를 레지스터에 담고 커널로 들어온다. 커널은 그 번호를 보고 어느 기능을 수행할지 분기한다.

switch (f->R.rax) {
case SYS_FORK:
    user_check_string (thread_name);
    f->R.rax = process_fork (thread_name, f);
    break;

case SYS_EXEC:
    user_check_string (cmd_line);
    cmd_copy = user_strdup (cmd_line);
    if (process_exec (cmd_copy) < 0)
        process_exit_with_status (-1);
    NOT_REACHED ();

case SYS_WAIT:
    f->R.rax = process_wait (child_tid);
    break;
}
일반 함수 호출처럼 보면

호출자와 피호출자가 같은 주소공간을 공유한다고 착각하기 쉽다. user pointer 검증과 복사가 왜 필요한지 흐려진다.

경계 통과로 보면

user 주소는 믿을 수 없고, 커널은 요청을 검증한 뒤 내부 객체로 번역해야 한다는 점이 보인다.

3. process는 thread보다 더 넓은 상태를 가진다

Pintos에서는 thread 구조체가 process 상태도 많이 품고 있다. Project 2 이후에는 단순히 CPU에서 실행될 단위가 아니라, 주소공간, file descriptor, child 관계, 실행 파일 잠금 같은 정보가 붙는다.

상태 왜 필요한가 어떤 테스트 감각과 연결되는가
pml4 / spt 이 process가 어떤 user address space를 가지는지 나타낸다. fork, page fault, mmap
fd_table user가 보는 fd 번호를 kernel file 객체로 바꾼다. open, read, write, close
children / wait status 부모가 특정 자식의 종료를 기다리고 exit status를 받아야 한다. wait, fork, exec
executable_file 실행 중인 파일에 write가 들어가지 않도록 막는다. rox 계열 테스트
cwd 상대 경로를 현재 디렉터리 기준으로 해석한다. Project 4 dir, chdir

4. fork는 부모의 순간을 복사하지만, 반환값은 달라야 한다

fork에서 가장 흥미로운 부분은 intr_frame이다. 부모의 레지스터 상태를 자식에게 복사해야 자식도 같은 코드 위치에서 이어서 실행할 수 있다. 하지만 자식의 반환값은 0이어야 하므로 rax를 다르게 맞춘다.

fork를 process 관점으로 읽기

1
부모의 intr_frame을 복사한다.
2
자식 thread를 만든다.
3
자식 주소공간과 SPT, fd table 등 필요한 process 상태를 복제한다.
4
부모는 child tid를 받고, 자식은 rax = 0 상태로 user mode에 들어간다.

이번 글에서 기억할 것

  • Argument Passing은 stack 배치 문제이지만, 동시에 process 시작 상태를 만드는 작업이다.
  • syscall은 user mode와 kernel mode 사이의 신뢰 경계다.
  • wait, fork, fd table은 따로 외우기보다 process가 가진 상태로 묶어 읽어야 한다.

스스로 점검

  • 왜 실행 파일 이름은 command line 전체가 아니라 첫 단어여야 하는가?
  • 왜 user pointer는 곧바로 역참조하면 안 되는가?
  • fork에서 부모와 자식이 같은 코드 위치에서 돌아오는데도 서로 다른 반환값을 받는 이유는 무엇인가?

다음 글 예고

다음 글에서는 user 주소 접근이 실패했을 때 무조건 프로세스를 죽이지 않는 이유를 본다. Project 3의 VM에서는 page fault가 오류이면서 동시에 lazy loading, stack growth, COW의 입구가 된다.

한 줄 정리

프로세스는 실행 파일 하나가 아니라 user stack, 주소공간, fd table, 부모-자식 관계를 함께 가진 커널 관리 단위다.