개발/프로젝트

Pintos Project 2 중간 과정 기록

cedis 2026. 5. 4. 16:47

Pintos Project 2 중간 과정 기록

Argument Passing 이후,
choihyunjin_fork 브랜치에서 구조가 어떻게 바뀌었나

args 테스트가 어느 정도 움직이기 시작하자, 다음 질문은 “통과했나?”가 아니라 “이 코드 위에 다음 기능을 계속 얹어도 되나?”가 되었다. 팀 회의 후 기준을 choihyunjin_fork 브랜치로 옮기면서, 이전 구현과 새 구조의 차이를 코드 블록 단위로 다시 읽어보았다.

이 글을 쓰는 이유

처음에는 args-single을 통과시키는 것이 급했다. 그래서 문자열을 자르고, stack에 올리고, 레지스터를 맞추고, 부모가 먼저 끝나지 않게 하는 장치까지 한 흐름 안에서 빠르게 붙였다. 테스트를 보는 데는 도움이 됐지만, 이후 wait, user pointer 검증, file descriptor까지 이어가려면 “이 코드가 어디에 붙어 있어야 하는가”가 점점 중요해졌다.

회의 후에는 코드를 더 붙이기보다 먼저 역할을 나누기로 했다. process_exec()가 너무 많이 들고 있던 일을 줄이고, 실행 파일 이름 분리, argv stack 구성, 종료 상태 저장을 각각 어디에서 맡을지 다시 보았다. 이 글은 그 갈아타는 지점을 남기는 기록이고, 동시에 내가 이 브랜치에서 이어서 맡을 부분을 헷갈리지 않기 위한 작업 메모다.

분석 기준

이전 기준 choihyunjin1 브랜치
현재 기준 choihyunjin_fork 브랜치
핵심 파일 thread.h, thread.c, process.c, syscall.c
읽는 관점 Argument Passing이 단순히 “argv를 쌓는 코드”에서 “실행 이름, 스택 구성, 종료 상태, 대기 흐름을 나누는 구조”로 바뀐 과정
글의 성격 args 구현 이후 브랜치를 옮긴 이유와, 다음 구현을 어디에 붙일지 확인하는 중간 작업 메모

핵심부터 말하면

짧게 말하면, 이전 구현은 process_exec()가 너무 많은 일을 하고 있었다. 인자를 자르고, stack을 만들고, 레지스터를 세팅하는 코드가 한곳에 붙어 있으니 당장은 따라갈 수 있어도 다음 기능을 붙일 때 경계가 흐려졌다. fork 브랜치는 이 흐름을 process_create_initd(), load(), setup_args(), process_exit()로 나누었다. 이번 글은 그 차이를 확인하고, 내가 다음 코드를 어느 지점부터 이어야 하는지 표시해두는 글이다.

이전 구조

process_exec()가 명령어 분리, 파일 로드, user stack 구성까지 대부분 직접 처리

fork 구조

실행 이름 분리, load, argv stack 구성, exit status 처리를 역할별 함수로 분리

1. 전체 변경 지도

먼저 파일 단위로 보면, 이번 브랜치 이동의 핵심은 “기능을 더 많이 넣었다”보다 이전 실험 코드의 위치를 정리하고, 실행 흐름을 더 읽기 쉽게 분리한 것에 가깝다.

파일 바뀐 방향 학습 포인트
thread.h 사용하지 않던 wait 세마포어 제거, exit status 추가 프로세스 종료 결과는 thread 상태로 남겨야 한다
thread.c user process 필드 초기화 추가 초기값이 없으면 exit 흐름에서 쓰레기 값이 된다
process.c argument stack 구성을 setup_args()로 분리 process_exec()가 모든 일을 하지 않게 만든다
syscall.c exit 출력은 process_exit()로 이동, syscall은 상태 저장만 담당 종료 메시지는 시스템콜 경로가 아니라 프로세스 종료 경로에 두는 편이 자연스럽다

먼저 보는 적용 체크리스트

아래는 이 글을 보고 이전 브랜치에서 fork 브랜치 구조로 옮겨 적을 때 먼저 확인해야 하는 조립 순서다. 뒤의 섹션들은 각 블록을 왜 바꾸는지 설명하고, 이 표는 실제로 파일을 열었을 때 빠뜨리기 쉬운 선언과 위치를 정리한다.

1. thread.h

사용하지 않는 struct semaphore wait_sema와 그 때문에 필요했던 #include "threads/synch.h"를 제거하고, #ifdef USERPROG 블록에 int exit_status;를 추가한다.

2. thread.c

init_thread()에서 pml4exit_status를 초기화한다. 이 단계가 빠지면 exit 흐름에서 초기화되지 않은 값을 읽을 수 있다.

3. process.c 상단

threads/synch.h include, initd_sema, initd_exit_status, MAX_ARGS, setup_args() 선언 위치를 확인한다.

4. process.c 본문

process_create_initd(), process_exec(), load(), setup_args(), process_wait(), process_exit()를 한 묶음으로 봐야 한다. 하나만 옮기면 흐름이 끊긴다.

5. syscall.c

SYS_WRITE는 stdout 출력만, SYS_EXITexit_status 저장 후 thread_exit()만 담당하게 둔다. putbuf()를 쓰는 경우 lib/kernel/console.h include 여부도 확인한다.

process.c 조립 위치 1: include와 전역 상태

#include "threads/synch.h"      /* sema_init, sema_down, sema_up 사용 */

static void process_cleanup (void);
static bool load (const char *file_name, struct intr_frame *if_);
static void initd (void *f_name);
static void __do_fork (void *);

static struct semaphore initd_sema;  /* initd 종료를 기다리기 위한 현재 단계의 전역 세마포어 */
static int initd_exit_status;        /* initd가 종료하며 남긴 status */

process.c 조립 위치 2: load() 아래쪽 helper 선언부

setup_args() 선언은 loader helper 선언들이 모여 있는 곳에 둔다. 실제 파일에서는 #define Phdr ELF64_PHDR가 나온 뒤에 setup_stack(), validate_segment() 같은 선언들이 모여 있으므로, 그 근처에 넣는 편이 안전하다.

#define MAX_ARGS 64

static bool setup_stack (struct intr_frame *if_);
static bool setup_args (struct intr_frame *if_, char *cmdline);
static bool validate_segment (const struct Phdr *, struct file *);
static bool load_segment (struct file *file, off_t ofs, uint8_t *upage,
        uint32_t read_bytes, uint32_t zero_bytes, bool writable);

여기서 setup_args() 선언을 빠뜨리면 load() 안에서 호출할 수 없다. 또 initd_semainitd_exit_status는 정식 wait 구조가 아니라 현재 브랜치의 임시 연결부이므로, 이후 child_info 기반 wait로 교체해야 한다.

2. thread.h: wait_sema를 빼고 exit_status를 넣은 이유

이전 코드의 문제

thread 구조체에 wait_sema를 넣었지만 실제 wait 흐름은 전역 세마포어를 사용했다. 즉 구조체 필드와 실제 흐름이 어긋나 있었다.

fork 브랜치의 해결

wait_sema를 제거하고, 종료 상태를 보관할 exit_status만 thread에 둔다.

수정 전

#include "threads/synch.h"

#ifdef USERPROG
    uint64_t *pml4;

    /* 부모가 이 thread의 종료를 기다릴 때 쓰는 세마포어 */
    struct semaphore wait_sema;
#endif

수정 후

#ifdef USERPROG
    /* Owned by userprog/process.c. */
    uint64_t *pml4;        /* Page map level 4 */
    int exit_status;       /* 이 thread가 종료될 때 부모에게 넘길 종료 상태 */
#endif
항목 수정 전 수정 후
헤더 의존성 thread.hsynch.h까지 알아야 함 불필요한 의존성 제거
상태 보관 wait용 세마포어 중심 종료 결과인 exit status 중심
의미 실제 코드 흐름과 필드가 어긋남 exit 시스템콜과 process_exit()가 공유할 값이 생김

3. thread.c: user process 필드를 초기화한다

구조체에 필드를 추가하는 것만으로는 충분하지 않다. 커널에서는 초기화되지 않은 값이 바로 오동작으로 이어질 수 있다. fork 브랜치는 init_thread()에서 user process 관련 값을 명시적으로 초기화한다.

수정 후 핵심 블록

t->priority = priority;
t->base_priority = priority;
list_init (&t->donations);
t->wait_on_lock = NULL;

#ifdef USERPROG
    t->pml4 = NULL;          /* 아직 user address space가 연결되지 않았다는 뜻 */
    t->exit_status = -1;     /* 기본 종료 상태. 명시적 exit 전에는 실패/미정 값 */
#endif

t->magic = THREAD_MAGIC;

여기서 중요한 점은 exit_status의 기본값을 -1로 둔 것이다. 유저 프로그램이 정상적으로 exit(0)을 호출하면 syscall에서 이 값을 0으로 바꾼다. 반대로 비정상 종료 흐름에서는 기본값 -1이 남을 수 있다.

4. process_create_initd(): 전체 명령어와 실행 파일 이름을 분리한다

Argument Passing에서 가장 먼저 헷갈리는 지점은 이것이다. 커널이 받은 문자열은 하나다.

커널이 받은 문자열

"args-single onearg"
thread 이름: args-single
argument stack 재료: args-single onearg

수정 전 핵심 블록

char thread_name[16];
char *save_ptr;

strlcpy (fn_copy, file_name, PGSIZE);

strlcpy (thread_name, file_name, sizeof thread_name);
strtok_r (thread_name, " ", &save_ptr);

tid = thread_create (thread_name, PRI_DEFAULT, initd, fn_copy);

이 방식도 기본 args 테스트에서는 동작할 수 있다. 하지만 thread_name[16]은 Pintos thread 이름 크기에 맞춘 임시 배열이라, 공백으로 자르기 전에 먼저 16바이트로 잘릴 수 있다는 아쉬움이 있었다.

수정 후 핵심 블록

tid_t
process_create_initd (const char *file_name) {
    char *fn_copy;
    char *name_copy;
    tid_t tid;

    sema_init (&initd_sema, 0);

    fn_copy = palloc_get_page (0);
    if (fn_copy == NULL) {
        return TID_ERROR;
    }
    strlcpy (fn_copy, file_name, PGSIZE);

    name_copy = palloc_get_page (0);
    if (name_copy == NULL) {
        palloc_free_page (fn_copy);
        return TID_ERROR;
    }
    strlcpy (name_copy, file_name, PGSIZE);

    char *save_ptr;
    char *filename_only = strtok_r (name_copy, " ", &save_ptr);

    tid = thread_create (filename_only, PRI_DEFAULT, initd, fn_copy);
    palloc_free_page (name_copy);

    if (tid == TID_ERROR)
        palloc_free_page (fn_copy);
    return tid;
}
수정점 해결되는 문제 남은 주의점
fn_copyname_copy를 분리 전체 명령줄은 args 구성용으로 보존하고, thread 이름만 따로 자를 수 있음 복사본을 여러 개 만들기 때문에 실패 시 free 경로를 꼼꼼히 봐야 함
filename_only 사용 종료 메시지에 args-single onearg가 아니라 args-single만 나오게 됨 빈 문자열이 들어오면 filename_only == NULL이 될 수 있으므로 방어 코드가 있으면 더 좋다

5. process_exec()에서 setup_args()로 책임을 분리한다

이전 구현에서 가장 부담이 컸던 부분은 process_exec() 안에 너무 많은 일이 몰려 있었다는 점이다. 명령어를 자르고, 파일 이름을 고르고, 문자열을 stack에 복사하고, argv 포인터를 넣고, 레지스터까지 세팅했다.

문자열 분리
파일 열기
스택 구성
iret 진입

fork 브랜치는 이 중 스택 구성setup_args()로 분리했다. 그래서 process_exec()는 다시 “프로그램 실행 흐름을 준비하는 함수”에 가까워졌다.

수정 후 process_exec() 흐름

int
process_exec (void *f_name) {
    char *file_name = f_name;
    bool success;

    struct intr_frame _if;
    _if.ds = _if.es = _if.ss = SEL_UDSEG;
    _if.cs = SEL_UCSEG;
    _if.eflags = FLAG_IF | FLAG_MBS;

    process_cleanup ();

    success = load (file_name, &_if);

    palloc_free_page (file_name);
    if (!success)
        return -1;

    do_iret (&_if);
    NOT_REACHED ();
}

이 구조의 장점은 process_exec()를 읽을 때 stack 세부 구현에 빠지지 않아도 된다는 점이다. 자세한 argv 배치는 setup_args()를 따로 열어서 보면 된다.

6. load(): 파일 이름은 첫 단어, argv 재료는 전체 명령줄

load()에서도 같은 원칙이 반복된다. 실행 파일을 열 때는 첫 단어만 필요하지만, argv stack을 만들 때는 전체 명령줄이 필요하다.

수정 후 핵심 블록

char *cp4_cmdline = NULL;
char *cp4_filename = NULL;

cp4_cmdline = palloc_get_page (0);
cp4_filename = palloc_get_page (0);

strlcpy (cp4_cmdline, file_name, PGSIZE);
strlcpy (cp4_filename, file_name, PGSIZE);

char *save_ptr;
char *filename_only = strtok_r (cp4_filename, " ", &save_ptr);

file = filesys_open (filename_only);

stack 구성 호출

if (!setup_stack (if_))
    goto done;

if (!setup_args (if_, cp4_cmdline)) {
    goto done;
}
용도 예시
cp4_filename 실행 파일 이름 추출용 args-single
cp4_cmdline argv stack 구성용 args-single onearg

7. setup_args(): user stack을 실제로 조립하는 함수

이 글에서 가장 중요한 코드는 setup_args()다. 이 함수는 커널이 받은 문자열을 유저 프로그램의 main(argc, argv)가 이해할 수 있는 stack 모양으로 바꾼다.

목표 stack 모양

fake return address = 0
argv[0] 주소
argv[1] 주소
argv[argc] = NULL
"args-single\0", "onearg\0"

수정 후 전체 블록

#define MAX_ARGS 64

static bool
setup_args (struct intr_frame *if_, char *cmdline) {
    char *argv[MAX_ARGS];
    char *arg_addr[MAX_ARGS];
    int argc = 0;

    char *token;
    char *save_ptr;

    for (token = strtok_r (cmdline, " ", &save_ptr);
         token != NULL;
         token = strtok_r (NULL, " ", &save_ptr)) {
        argv[argc++] = token;
    }

    for (int i = argc - 1; i >= 0; i--) {
        size_t len = strlen (argv[i]) + 1;
        if_->rsp -= len;
        memcpy ((void *) if_->rsp, argv[i], len);
        arg_addr[i] = (char *) if_->rsp;
    }

    const size_t word_size = sizeof (char *);
    while (if_->rsp % word_size != 0) {
        if_->rsp--;
        *(uint8_t *) if_->rsp = 0;
    }

    if_->rsp -= word_size;
    *(char **) if_->rsp = NULL;

    for (int i = argc - 1; i >= 0; i--) {
        if_->rsp -= word_size;
        *(char **) if_->rsp = arg_addr[i];
    }

    char **argv_entry = (char **) if_->rsp;

    if_->rsp -= word_size;
    *(char **) if_->rsp = NULL;

    if_->R.rdi = (uint64_t) argc;
    if_->R.rsi = (uint64_t) argv_entry;

    return true;
}
코드 구간 하는 일 왜 필요한가
strtok_r 반복 공백 기준으로 인자를 나누고 argc 계산 유저 프로그램은 하나의 문자열이 아니라 argc/argv를 기대한다
문자열 역순 복사 각 인자 문자열을 user stack 아래쪽으로 복사 stack은 높은 주소에서 낮은 주소로 자란다
8바이트 정렬 rsp를 포인터 크기에 맞게 정렬 x86-64 호출 규약에 맞는 stack 모양을 만든다
argv[argc] = NULL argv 배열의 끝 표시 C 프로그램은 argv 끝을 NULL로 판단한다
R.rdi, R.rsi argc와 argv 시작 주소를 레지스터에 저장 64비트 호출 규약에서 첫 번째, 두 번째 인자는 rdi, rsi로 전달된다

8. wait와 exit: 임시 대기에서 종료 상태 전달로 한 단계 이동

이전에는 args 테스트를 보기 위해 전역 세마포어로 부모가 자식을 기다리게만 했다. fork 브랜치에서는 여기에 exit status 전달이 추가되었다.

수정 전

static struct semaphore initd_wait_sema;

int
process_wait (tid_t child_tid UNUSED) {
    sema_down (&initd_wait_sema);
    return 0;
}

void
process_exit (void) {
    struct thread *curr = thread_current ();

    sema_up (&initd_wait_sema);

    process_cleanup ();
}

수정 후

static struct semaphore initd_sema;
static int initd_exit_status;

int
process_wait (tid_t child_tid UNUSED) {
    sema_down (&initd_sema);
    return initd_exit_status;
}

void
process_exit (void) {
    struct thread *curr = thread_current ();

    if (curr->pml4 != NULL) {
        initd_exit_status = curr->exit_status;
        printf ("%s: exit(%d)\n", curr->name, curr->exit_status);
        sema_up (&initd_sema);
    }

    process_cleanup ();
}

1. syscall

유저 프로그램이 exit(status)를 호출하면 thread의 exit_status에 저장한다.

2. process_exit

종료 메시지를 출력하고, 기다리던 부모를 sema_up으로 깨운다.

3. process_wait

부모는 sema_down으로 기다렸다가 자식의 종료 상태를 반환받는다.

9. syscall.c: exit 출력 책임을 process_exit()로 옮긴다

이전에는 SYS_EXIT를 처리하는 곳에서 바로 종료 메시지를 출력했다. fork 브랜치에서는 syscall handler가 직접 출력하지 않고, 현재 thread의 exit_status만 저장한 뒤 thread_exit()로 넘긴다.

수정 후 핵심 블록

void
syscall_handler (struct intr_frame *f UNUSED) {
    switch (f->R.rax) {
        case SYS_WRITE:
            if (f->R.rdi == STDOUT_FILENO) {
                putbuf ((char *) f->R.rsi, (size_t) f->R.rdx);
                f->R.rax = f->R.rdx;
            } else {
                f->R.rax = -1;
            }
            break;

        case SYS_EXIT:
            thread_current ()->exit_status = (int) f->R.rdi;
            thread_exit ();
            break;

        default:
            printf ("system call!\n");
            thread_exit ();
            break;
    }
}
수정점 좋아진 점 주의할 점
SYS_EXIT는 status 저장만 담당 종료 메시지 출력 위치가 process_exit()로 모인다 커널에 의해 강제 종료되는 경우도 같은 출력 규칙을 맞춰야 한다
SYS_WRITE는 stdout만 처리 args 테스트의 printf 출력은 가능하다 fd table, file read/write는 아직 별도 구현이 필요하다

10. 이번 브랜치에서 오히려 빠진 것: user pointer 검증

diff를 보면 이전 브랜치에 있던 is_valid_user_ptr, is_valid_user_buffer, is_valid_user_string 같은 검증 함수가 제거되어 있다. 이건 “필요 없는 코드”라서 빠졌다기보다, 아직 완성되지 않은 검증 흐름을 걷어내고 Argument Passing과 기본 syscall 흐름에 집중한 것으로 보는 편이 맞다.

비판적으로 봐야 할 지점

현재 SYS_WRITEf->R.rsi를 바로 putbuf()에 넘긴다. 즉 유저 포인터가 잘못된 주소인지, 커널 주소인지, 페이지가 매핑되어 있는지 검증하지 않는다. args 계열 테스트에는 충분할 수 있지만, Project 2 전체 관점에서는 반드시 다시 보강해야 한다.

11. 최종 코드 리뷰: 지금 구조의 장점과 남은 빚

좋아진 점

  • 실행 파일 이름과 전체 명령줄을 구분한다.
  • argument stack 구성 로직이 setup_args()로 분리됐다.
  • exit status가 thread 상태로 저장된다.
  • 종료 메시지 출력이 process_exit()로 모였다.
  • 이전의 사용되지 않던 wait_sema 필드가 제거됐다.

아직 남은 빚

  • process_wait()는 아직 initd 전역 세마포어 기반이다.
  • 여러 자식 프로세스, 중복 wait, 직접 자식 여부 검사를 처리하지 못한다.
  • setup_args()에서 argc < MAX_ARGS 검사가 없다.
  • palloc_get_page() 실패 검사가 일부 부족하다.
  • user pointer 검증이 빠져 있어 syscall 방어가 약하다.

12. 다음 단계 체크리스트

이 브랜치는 Argument Passing을 더 읽기 좋은 구조로 옮기는 중간 단계로는 의미가 크다. 하지만 Project 2 전체로 보면 여기서 끝이 아니다. 회의 후 정리한 이 구조를 바탕으로, 브랜치에 남아 있는 아래 구현 지점들을 이어서 내가 담당해 작성해야 한다.

우선순위 할 일 이유
1 user pointer 검증 복구 잘못된 유저 주소가 커널을 깨지 않게 해야 한다
2 정식 wait(pid) 구현 전역 세마포어로는 여러 자식을 구분할 수 없다
3 fd table 설계 open/read/write/close 구현의 기반이 된다
4 setup_args() 방어 코드 추가 인자 개수 초과, 긴 문자열, stack overflow 가능성을 줄여야 한다

내가 이 브랜치에서 이어서 맡을 일

지금까지의 작업은 “Argument Passing을 어떻게 볼 것인가”를 정리한 단계였다. 이제부터는 이 정리 위에서 Project 2의 남은 요구사항을 실제 코드로 연결해야 한다.

검증

user pointer가 안전한지 확인하는 방어막 추가

관계

전역 세마포어가 아닌 부모-자식 wait 구조로 확장

파일

fd table을 설계해 file syscall 구현의 기반 마련

마무리하며

이번에 확인한 것은 단순하다. Argument Passing은 문자열을 stack에 넣는 문제로 시작했지만, 조금만 지나면 실행 파일 이름, argv 구성, 종료 상태, 부모 대기 흐름이 한꺼번에 엮인다. 그래서 지금 필요한 건 코드를 더 빨리 붙이는 것이 아니라, 어느 함수가 어떤 책임을 가져야 하는지 먼저 정리하는 일이었다. 이 글은 그 정리의 기록이고, 다음에는 이 위에 user pointer 검증, 정식 wait 구조, fd table을 하나씩 얹어볼 예정이다.