개발/프로젝트

Pintos Project 2 분업 기록 일지

cedis 2026. 5. 6. 13:16

 

 

Pintos Project 2 분업 기록

fork 파트는 어디까지 혼자 만들 수 있고,
어디부터 팀 코드와 맞물릴까

팀 브랜치를 합친 뒤 내 담당은 fork 쪽으로 정리됐다. 처음에는 fork 말고 다른 작업을 먼저 할 수 있을지 봤지만, fd table, wait, deny write가 서로 물려 있어서 임시 코드로 밀어붙이면 나중에 더 꼬일 가능성이 컸다. 그래서 이번 글은 fork 엔진 자체를 코드 흐름으로 다시 읽고, 다음 연결 지점을 표시하는 데 집중한다.

이 글의 완성 기준

읽는 목표 이 글만 보고 fork 관련 코드 블록을 기존 브랜치에 같은 구조로 옮길 수 있어야 한다.
핵심 흐름 SYS_FORK -> process_fork() -> thread_create() -> __do_fork() -> duplicate_pte() -> do_iret()
중심 테스트 fork-once를 기준으로 fork의 기본 분기와 주소공간 복사를 이해한다.
범위 밖 fork-read, fork-close, fork-recursive는 fd 상속과 정식 wait 구조까지 연결되어야 한다. 이 글은 그 연결 지점을 표시한다.

왜 새 코드를 더 넣기보다 fork를 다시 읽었나

fork 말고 독립적으로 할 수 있는 작업을 찾으면 deny write가 먼저 떠오른다. 하지만 rox-simple은 file syscall이 필요하고, rox-child, rox-multichild는 exec/wait 흐름과도 엮인다. 즉 deny write만 따로 넣어도 바로 의미 있는 테스트를 보기 어렵다.

fork 쪽도 마찬가지다. 기본 fork 엔진은 만들 수 있지만, fd 상속은 fd table이 필요하고, recursive fork는 child_info 기반 wait가 필요하다. 그래서 지금 필요한 것은 “임시로 더 붙일 코드”가 아니라, fork 엔진이 어디까지 담당하고 어디서 팀원 코드와 만나는지 구분하는 것이었다.

0. 회의 후 다시 세운 기준

이전 구현은 Argument Passing을 통과시키는 데 초점이 있었다. 명령어를 자르고, user stack을 만들고, 임시 대기 구조로 출력 테스트를 확인하는 흐름이었다. 하지만 팀 브랜치를 합치고 나니 기준이 달라졌다. 이제는 “args 테스트가 돈다”보다 “fork, wait, fd table, exit status가 서로 연결될 수 있는 구조인가”가 더 중요해졌다.

이전 흐름에서 보인 한계 왜 문제가 되는가 fork 브랜치에서 잡은 기준
process_exec() 중심으로 실행만 맞춤 새 프로세스가 아니라 기존 프로세스를 갈아끼우는 흐름이어서 fork의 복제 문제를 설명하지 못한다. process_fork()__do_fork()를 나누어 부모/자식 역할을 분리한다.
부모가 자식 준비 완료 전에 먼저 진행할 수 있음 자식 주소공간 복사가 실패했는데도 부모가 성공처럼 tid를 받을 수 있다. fork_semasuccess로 자식 복제 완료 여부를 부모에게 전달한다.
부모의 레지스터 상태를 자식에게 넘기는 기준이 없음 자식이 fork 다음 줄부터 실행되는 것처럼 보일 수 없다. intr_frame을 복사하고 자식의 R.rax만 0으로 바꾼다.
fd와 wait는 필드만 있다고 자동으로 연결되지 않음 fork-read, fork-close, fork-recursive는 fd 상속과 child_info 기반 wait가 필요하다. 현재 글에서는 fork 엔진을 정리하고, 다음 연결 지점을 명확히 표시한다.

1. 먼저 보는 조립 지도

아래 순서대로 넣어야 fork 코드가 한 덩어리로 맞물린다. 중간 블록 하나만 가져가면 컴파일은 되더라도 fork 흐름이 완성되지 않는다.

1. process.hprocess_fork(const char *, struct intr_frame *) 선언
2. process.c 상단에 fork_args 구조체 추가
3. process_fork()에서 부모 문맥을 담고 자식 thread 생성
4. duplicate_pte()에서 부모 유저 페이지를 자식 페이지로 복사
5. __do_fork()에서 자식의 실행 문맥과 주소공간 준비
6. syscall.cSYS_FORK에서 process_fork() 호출

2. process.h: fork 함수 원형부터 맞춘다

syscall.c에서 process_fork()를 호출하려면 헤더에 함수 원형이 있어야 한다. 두 번째 인자로 struct intr_frame *를 받는 이유는 fork 순간의 부모 레지스터 상태를 자식에게 넘기기 위해서다.

tid_t process_create_initd (const char *file_name);
tid_t process_fork (const char *name, struct intr_frame *if_);
int process_exec (void *f_name);
int process_wait (tid_t);
void process_exit (void);
void process_activate (struct thread *next);

3. syscall.c: fork의 입구는 SYS_FORK 분기다

유저 프로그램이 fork()를 호출하면 syscall 번호가 SYS_FORK로 들어온다. 여기서는 이름 인자와 현재 intr_frame을 그대로 process_fork()에 넘긴다.

case SYS_FORK:
    f->R.rax = process_fork ((const char *) f->R.rdi, f);
    break;

f는 단순한 인자 묶음이 아니다. fork syscall을 호출한 순간의 부모 레지스터 상태다. 자식이 부모와 같은 코드 위치에서 이어지는 것처럼 보이려면 이 값을 복사해야 한다.

4. fork_args: 부모와 자식을 이어주는 상자

자식 thread는 thread_create()로 시작된다. 그런데 자식이 부모의 pml4와 fork 순간의 intr_frame을 알아야 하므로, 이 정보를 하나의 구조체에 묶어 aux로 넘긴다.

struct fork_args {
    struct thread *parent;        /* fork를 호출한 부모 thread */
    struct intr_frame parent_if;  /* fork 순간 부모의 레지스터 상태 */
    struct semaphore fork_sema;   /* 자식 복사가 끝날 때까지 부모를 재우는 신호 */
    bool success;                 /* 자식 복사가 성공했는지 부모에게 알려주는 값 */
};
필드 없으면 생기는 문제
parent 자식이 부모의 주소공간을 어디서 복사해야 하는지 모른다.
parent_if 자식이 fork 다음 줄부터 이어서 실행되는 것처럼 만들 수 없다.
fork_sema 부모가 자식 복사 완료 전에 먼저 반환할 수 있다.
success 자식 복사 실패를 부모에게 전달할 방법이 없다.

5. process_fork(): 부모 쪽 fork 흐름

process_fork()는 부모가 실행하는 함수다. 여기서 부모는 자식 thread를 만들고, 자식이 주소공간 복사를 마칠 때까지 sema_down()으로 기다린다.

tid_t
process_fork (const char *name, struct intr_frame *if_) {
    struct fork_args *args = palloc_get_page (PAL_ZERO);

    if (args == NULL)
        return TID_ERROR;

    args->parent = thread_current ();
    memcpy (&args->parent_if, if_, sizeof args->parent_if);

    sema_init (&args->fork_sema, 0);
    args->success = false;

    tid_t tid = thread_create (name, PRI_DEFAULT, __do_fork, args);

    if (tid == TID_ERROR) {
        palloc_free_page (args);
        return TID_ERROR;
    }

    sema_down (&args->fork_sema);

    bool success = args->success;
    palloc_free_page (args);

    return success ? tid : TID_ERROR;
}

이 블록이 해결하는 것

부모는 자식이 복제에 성공했는지 확인한 뒤에만 fork 결과를 반환한다.

조심할 것

부모가 args를 해제하므로, 자식은 sema_up() 이후에 args를 다시 참조하면 안 된다.

6. __do_fork(): 자식 쪽 fork 흐름

__do_fork()는 새로 만들어진 자식 thread에서 실행된다. 부모의 레지스터 상태를 복사하고, 자식 전용 pml4를 만든 뒤, 부모의 유저 페이지를 복사한다.

static void
__do_fork (void *aux) {
    struct intr_frame if_;
    struct fork_args *args = aux;
    struct thread *parent = args->parent;
    struct thread *current = thread_current ();

    memcpy (&if_, &args->parent_if, sizeof (struct intr_frame));
    if_.R.rax = 0;

    current->pml4 = pml4_create ();
    if (current->pml4 == NULL)
        goto error;

    process_activate (current);

#ifdef VM
    supplemental_page_table_init (&current->spt);
    if (!supplemental_page_table_copy (&current->spt, &parent->spt))
        goto error;
#else
    if (!pml4_for_each (parent->pml4, duplicate_pte, parent))
        goto error;
#endif

    process_init ();

    args->success = true;
    sema_up (&args->fork_sema);
    do_iret (&if_);

error:
    args->success = false;
    sema_up (&args->fork_sema);
    thread_exit ();
}

자식의 R.rax를 0으로 바꾸는 이유

fork는 한 번 호출했는데 부모와 자식에게 서로 다른 값을 돌려주는 함수다. 부모는 자식 tid를 받아야 하고, 자식은 0을 받아야 한다. 부모의 intr_frame을 그대로 복사하면 자식도 부모와 같은 반환값을 갖게 되므로, 자식 쪽에서는 if_.R.rax = 0으로 반환값을 직접 바꾼다.

부모

process_fork()의 최종 반환값으로 자식 tid를 받는다.

자식

if_.R.rax = 0 덕분에 fork가 0을 반환한 것처럼 보인다.

7. duplicate_pte(): 부모의 유저 페이지를 자식에게 복사한다

부모와 자식은 같은 가상주소를 볼 수 있어야 한다. 하지만 실제 페이지까지 공유하면 한쪽의 쓰기가 다른 쪽에 영향을 준다. 그래서 자식 전용 페이지를 새로 만들고, 부모 페이지 내용을 복사한 뒤, 같은 가상주소에 연결한다.

static bool
duplicate_pte (uint64_t *pte, void *va, void *aux) {
    struct thread *current = thread_current ();
    struct thread *parent = (struct thread *) aux;

    if (is_kernel_vaddr (va))
        return true;

    void *parent_page = pml4_get_page (parent->pml4, va);
    if (parent_page == NULL)
        return false;

    void *newpage = palloc_get_page (PAL_USER);
    if (newpage == NULL)
        return false;

    memcpy (newpage, parent_page, PGSIZE);

    bool writable = is_writable (pte);

    if (!pml4_set_page (current->pml4, va, newpage, writable)) {
        palloc_free_page (newpage);
        return false;
    }

    return true;
}

주소공간 복사 그림

부모
va = 0x8048000
parent_page = A
복사
자식
va = 0x8048000
newpage = B

가상주소는 같게 보이지만 실제 페이지는 다르다. 이것이 fork 이후 부모와 자식이 독립적으로 실행될 수 있는 기본 조건이다.

8. thread 쪽 필드: fork가 앞으로 연결해야 할 대상

통합 코드에는 이미 fd_table, children, child_info 필드가 들어와 있다. 이 필드들은 fork 엔진의 다음 연결 지점이다. 단, 필드가 존재한다고 해서 fork가 자동으로 fd와 wait 관계를 상속하는 것은 아니다.

#ifdef USERPROG
    uint64_t *pml4;
    int exit_status;
    struct file **fd_table;
    int fd_table_size;
    int fd_next;
    struct list children;
    struct child_info *child_info;
#endif

struct child_info {
    tid_t pid;
    int exit_status;
    bool exited;
    bool waited;
    struct semaphore wait_sema;
    struct list_elem elem;
};

thread 초기화도 같이 맞아야 한다

#ifdef USERPROG
    t->pml4 = NULL;
    t->exit_status = -1;
    t->fd_table = NULL;
    t->fd_table_size = 0;
    t->fd_next = 2;
    list_init (&t->children);
    t->child_info = NULL;
#endif

fork 엔진의 다음 작업은 부모의 fd_table을 자식에게 복제하는 것이다. 이때 단순히 포인터 주소만 복사하면 부모와 자식의 close 동작이 꼬일 수 있다. file_duplicate()를 사용해 file object를 복제하는 방향으로 연결해야 한다.

9. 테스트별로 어디까지 내 코드인가

테스트 fork 엔진과의 관계 추가로 필요한 것
fork-once 부모/자식 분기, R.rax = 0, 주소공간 복사 확인 현재 fork 엔진의 핵심 확인 대상
fork-read 자식이 부모의 열린 파일을 읽을 수 있어야 함 fd table 상속, file duplicate
fork-close 부모/자식의 fd close가 서로 잘 분리되어야 함 fd 복제와 close 정책
fork-recursive 여러 세대의 fork/wait 관계가 이어져야 함 정식 child_info, wait(pid), exit status 관리

10. 내가 이 파트에서 답할 수 있어야 하는 질문

Q1. 왜 fork에서 부모의 intr_frame을 복사해야 하는가?
Q2. 왜 자식의 R.rax를 0으로 바꾸는가?
Q3. 왜 부모는 fork_sema에서 기다려야 하는가?
Q4. 왜 kernel page는 duplicate_pte()에서 건너뛰는가?
Q5. 왜 fd 상속은 fork 엔진만으로 끝나지 않는가?

11. 다음 작업 체크리스트

순서 작업 목표 테스트
1 fork-once 기준으로 현재 흐름 재검증 fork-once
2 부모 fd table을 자식 fd table로 복제 fork-read, fork-close
3 child_info 기반 wait 구조와 fork 연결 fork-recursive, wait-*
4 deny write와 process_exit 정리 흐름 충돌 확인 rox-*

마무리하며

이번에 얻은 결론은 “fork 말고 할 게 없다”가 아니었다. fork는 메모리 복사, 실행 문맥, fd 상속, wait 관계가 만나는 중심에 있었다. 그래서 지금 단계에서 필요한 일은 임시 코드를 더 붙이는 것이 아니라, fork 엔진을 정확히 읽고 팀원 코드와 만나는 지점을 표시하는 것이었다. 다음 작업은 이 흐름 위에 fd 상속과 wait 구조를 실제로 연결하는 것이다.