[PintOS] Project 2 : Argument Passing

[PintOS] Project 2 : Argument Passing

Setup

아무 코드도 수정하지 않고 처음 빌드 시에, thread 부분에서의 커널 패닉 방지를 위한 코드를 다음과 같이 추가해주었다. 준비된 스레드가 없을 때 쓸데없이 idle thread로 context switch 되는 것을 막아 잘못된 스케줄링이 이루어지지 않도록 하는 코드이다.

void
thread_yield (void) {
    struct thread *curr = thread_current ();
    enum intr_level old_level;

    ASSERT (!intr_context ());        // Do Not yield in interrupt context

    /* Project 2 : args 에러 방어 코드 추가 */
    if(list_empty(&ready_list))
        return;

    old_level = intr_disable ();    // Disable interrupt to protect critical section
    if (curr != idle_thread)
        // list_push_back (&ready_list, &curr->elem);
        list_insert_ordered(&ready_list, &curr->elem, cmp_priority, NULL);
    do_schedule (THREAD_READY);
    intr_set_level (old_level);
}

void
test_max_priority(void){
    /* Project 2 : args 에러 방어 코드 추가 */
    if(list_empty(&ready_list))
        return;
    struct thread *t = list_entry(list_front(&ready_list), struct thread, elem);

    if(thread_current()->priority < t->priority){
        if(intr_context())
            intr_yield_on_return();
        else
            thread_yield();
    }
}

 

아무것도 손대지 않고 다음과 같이 테스트가 실행되면 성공이다.

경로 : pintos-kaist/userprog/build

  • userprog 경로에서 make 실행 후 하위 디렉토리 build 에서 다음과 같이 명령어 수행
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

 

구현 목표

  • 프로세스 생성 시 명령줄 인자를 적절히 파싱하고
  • 사용자 스택에 x86-64 calling convention에 맞추어 적재하고
  • 사용자 프로그램이 인자를 사용할 수 있도록 하는 기능을 process_exec()에 구현한다

함수 호출 흐름

Pintos 프로젝트를 진행하면서, "pintos run 'echo hi'" 명령이 어떻게 실제 사용자 프로그램을지 알아보자!

  1. 프로그램 실행
    • 사용자는 pintos run 명령을 통해 프로그램을 실행
    • 이 명령은 내부적으로 pintos 스크립트를 통해 QEMU를 실행하고 커널 이미지를 로드
  2. 커널 부팅 및 초기화
    • main() 함수가 진입점
    • 커맨드라인 인자를 파싱하고 적절한 액션을 결정
  3. 유저 프로그램 실행 준비
    • run_task() : 인자로 받은 프로그램을 실행하기 위해 새로운 유저 프로세스를 실행한다
    • process_create_initd() : 사용자 프로세스를 위한 최초 스레드 생성
    • thread_create 에서 “echo”라는 유저 프로세스를 initd()로 실행
  4. 유저 코드 진입
    • initd()에서 유저 프로세스를 진입
    • process_exec() 을 통해 유저 프로그램 ELF를 메모리에 로드하고 스택 설정
    • do_iret() 으로 커널 모드 → 유저 모드로 전환
  5. 유저 프로그램 실행
    • ELF의 entry_point로 지정된 _start() 함수에서 main()이 호출된다
  6. 시스템 콜 처리
    • 유저 프로그램이 write, exit 등의 시스템 콜을 호출하면 인터럽트를 통해 커널의 syscall_handler로 진입
  7. 프로그램 종료
    • exit 시스템 콜을 통해 상태값을 반환하고, 커널은 자원을 해제, 스레드 종료
    • halt를 호출하여 power_off 되거나, 부모 프로세스가 자식 종료 상태를 수거하며 종료

PintOS와 일반 운영 체제와의 차이점

 

PintOS는 “프로세스 = 스레드” 인 단일 스레드 환경이다. 프로세스 간 주소 공간 분리는 존재하지만, 그 안에서의 멀티 스레딩은 구현되어있지 않다.

반면에 일반 운영체제는 다중 프로세스 + 다중 스레드 기반이다. 커널은 여러 CPU 코어에서 여러 스레드를 동시에 실행할 수 있도록 스케줄링한다. 이 구조적 차이를 바탕으로 process_exec()이나 syscall_handler() 같은 부분을 구현할 때도, Pintos는 “단순하게 단일 유저 실행 흐름”에 집중하면 된다는 것을 기억해야 한다! 우리가 CSAPP에서 배운 내용과 실제 구현의 차이점도 바로 이 부분에서 나타난다.

구현

참조 라이브러리 함수

  • strtok_r() : 문자열을 토큰으로 분리한다. PintOS에서는 lib/string.c에 구현되어 있다.
  • memcpy(void *dest, const void *src, size_t n) : src 메모리 영역에서 n 바이트만큼 복사해서 dest 메모리 영역에 저장한다. 바이트 단위 복사.
  • memset(void *ptr, int value, size_t num) : 메모리 주소 ptr부터 num 바이트를 value 값 (정수 0~255)을 바이트 단위로 채운다.

수정 함수

process_exec()

  • 사용자 프로그램 실행 시 인자(argument)를 스택에 적절히 적재하기 위한 준비 단계이다.
  • strtok_r을 사용하여 명령어 문자열을 분리한다.
    • 입력 문자열(file_name)을 공백 기준으로 잘라서 argv 배열에 하나씩 저장한다.
    • 예 : echo x, y, z → echo\0, x\0, y\0, z\0 이렇게 분리한다
  • load 함수가 호출되며 ELF 헤더를 읽고 setup_stack 으로 rsp의 초기 위치를 확보한다
  • 스택 페이지가 만들어지면, argument_stack 함수를 호출하여 실제 데이터를 스택에 올린다.
int
process_exec (void *f_name) {
    char *file_name = f_name;
    bool success;

    /* We cannot use the intr_frame in the thread structure.
     * This is because when current thread rescheduled,
     * it stores the execution information to the member. */
    struct intr_frame _if;
    _if.ds = _if.es = _if.ss = SEL_UDSEG;
    _if.cs = SEL_UCSEG;
    _if.eflags = FLAG_IF | FLAG_MBS;

    /* We first kill the current context */
    process_cleanup ();

    /* Project 2 : args */
    char *token, *save_ptr;
    char *argv[MAX_ARGC];    // 128 byte
    int argc = 0;

    /* 문자열을 파싱하여 argv 포인터 배열로 저장한다 */
    for (token = strtok_r(file_name, " ", &save_ptr); token != NULL; 
        token = strtok_r(NULL, " ", &save_ptr))
    {
        argv[argc++] = token;
    }

    /* And then load the binary */
    success = load (file_name, &_if);

    /* Project 2 : args */
    argument_stack(argv, argc, &_if);

    /* If load failed, quit. */
    palloc_free_page (file_name);
    if (!success)
        return -1;

    hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true);

    /* Start switched process. */
    do_iret (&_if);
    NOT_REACHED ();
}

추가 함수

argument_stack()

  • 스택 메모리에 데이터를 적재하는 함수이다.
  • argv[] 에 저장된 문자열 인자들을 스택 메모리에 복사하고, 각 인자의 주소를 스택에 저장하고, 레지스터를 설정해 사용자 프로그램이 인자를 사용할 수 있게 한다.
  • 다음과 같은 순서로 스택에 푸시되어야 한다.
(가장 높은 주소)
+---------------------+
| 'bar\0'             | ← argv[3]
+---------------------+
| 'foo\0'             | ← argv[2]
+---------------------+
| '-l\0'              | ← argv[1]
+---------------------+
| '/bin/ls\0'         | ← argv[0]
+---------------------+
| Word alignment (0)  |
+---------------------+
| NULL (argv[4])      |
+---------------------+
| argv[3] 포인터        |
+---------------------+
| argv[2] 포인터        |
+---------------------+
| argv[1] 포인터        |
+---------------------+
| argv[0] 포인터        | ← RSI (%rsi)
+---------------------+
| Fake return address | ← RSP (%rsp)
+---------------------+
(가장 낮은 주소)
  1. 문자열 데이터를 역순으로 복사한다
    • 스택은 높은 주소 → 낮은 주소로 자라기 때문에 for 문으로 역순회하여 복사한다
    • 각 문자열의 시작 주소를 arg_addr[]에 저장하고, 나중에 이 포인터를 4번에서 스택에 넣는다.
    • 여기서 strlen(argv[i])에서 +1 을 하는 이유는 널 종료문자 (\0) 길이를 포함하기 위함이다
  2. 스택을 8 바이트로 정렬한다
  3. NULL 포인터를 삽입한다
    • C의 argv[] 배열은 마지막 원소가 NULL이어야 한다
  4. 각 문자열의 포인터를 역순으로 저장한다
    • argv[0], argv[1]argv[argc] 까지 포인터들을 저장한다
  5. rdiargc, rsiargv의 시작 주소를 담은 레지스터를 설정한다
  6. 프로그램이 main() 함수를 마치고 ret 명령어를 실행할때 돌아올 가짜 반환 주소를 설정한다. 실제로는 돌아올 곳이 없기 때문에 0을 넣는다.
void
argument_stack (char **argv, int argc, struct intr_frame *if_){
    char *arg_addr[MAX_ARGC];
    int arg_len;

    /* 1. 문자열 데이터를 스택에 역순으로 push */
    for(int i= argc - 1 ; i >= 0 ;i--){
        arg_len = strlen(argv[i]) + 1;
        if_->rsp -= arg_len;
        memcpy(if_->rsp, argv[i], arg_len);        // 문자열 복사 (destination, source, size)
        arg_addr[i] = if_->rsp;                    // 해당 문자열의 시작 주소 저장
    }

    /* 2. word alignment by 8
     * 하위 3 비트를 0으로 &연산하여 스택 포인터를 8의 배수로 맞춘다 */
    while (if_->rsp % 8 != 0){
        if_->rsp--;
    }

    /* 3. NULL 포인터 삽입 : argv[argc] = NULL */
    if_->rsp -= sizeof(char *);        // 공간 확보
    *(char **)if_->rsp = NULL;        // 실제 NULL 값을 저장 (sentinel)

    /* 4. argv[i] 포인터들을 역순으로 스택에 Push */
    for(int i = argc - 1; i >= 0; i--){
        if_->rsp -= sizeof(char *);
        *(char **)if_->rsp = arg_addr[i];
    }

    /* 5. 레지스터 설정: rsi = argv 주소, rdi = argc 값 */
    if_->R.rsi = if_->rsp;
    if_->R.rdi = argc;

    /* 6. Fake return address 삽입 (0으로 설정) */
    if_->rsp -= sizeof(void *);
    *(void **)if_->rsp = 0;
}

디버깅 (hex_dump)

process_exec 내부에 hex_dump를 삽입한다.

hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true);

  • 메모리의 특정 영열을 바이트 단위로 출력하여 값이 어떻게 저장되어있는지 확인하기 위한 디버깅 함수이다.
  • 첫 번째 _if.rsp : 출력할 주소의 시작 위치. 스택 포인터가 현재 가리키는 위치
  • 두 번째 _if.rsp : 해당 메모리 주소를 출력할 때 기준 주소
  • USER_STACK - _if.rsp : 출력할 크기
    • 여기서 KERN_BASE(최상단 주소)로 넣었으나, 너무 큰 영역의 데이터를 모두 출력하려 해서 커널 패닉이 오거나 제대로 작동하지 않았다. 디버깅을 위해 봐야할 곳의 위치를 적절히 설정하는 것이 중요하다.
  • true : ASCII 값 출력 여부.
  • 이 함수는 argument_stack 이 호출된 뒤에, 즉 스택에 문자열과 포인터가 모두 적재된 뒤에 호출되어야 한다.
  • 결국 우리가 원하던 대로 스택이 구성되었는지를 확인용 함수이기 때문이다.

args-single 테스트 케이스

system call! 이 출력되면 성공 🎉