크래프톤 정글/TIL

PintOS 프로젝트 2주차 [User Programs / Argument Passing]

양선규 2024. 5. 23. 19:45
728x90
반응형

PintOS는 Command Line에 명령어를 받아 프로그램을 실행할 때, 인자를 인식하지 못한다.

ex ) /bin/ls -l foo bar  일 경우, 명령(파일명)과 인자를 나누지 못하고 전부 하나로 인식

 

Argument Passing은 PintOS의 이러한 점을 개선하여 PintOS가 명령에 대한 파일명과 인자를 구분하여 받을 수 있도록 하는 것이 목표이다.

 

명령어와 인자들은 command Line으로 입력된 후 process_exec 함수에서 argv 배열에 저장되며, argc 변수엔 인자의 개수가 저장된다. (파일명도 인자다)

argv, argc를 이용하여 인자들은 Stack에 저장되어 User Program이 사용하게 되는데, 우리가 Stack에 저장되도록 구현해야 한다.

 

Stack 예시 사진, 코드랑 좀 다르다

 

%rsp는 스택 포인터 레지스터이다. 항상 스택의 top을 가리키고 있다.(가장 낮은 주소 값)

위 그림과 같이 Stack은 아래로 성장한다. 아래로 성장한다는 것은, 주소값이 줄어듦으로써 성장한다는 것이다.

%rsp 값이 100 -> 92 이렇게 줄어들었을 경우, 스택의 top은 92이며 8byte의 스택 공간을 확보한 것이다.

 

여기서 헷갈리지 말아야 할 점이 있는데, 성장은 아래로 하지만 데이터를 읽는 것은 반대로 읽는다는 것이다.

rsp를 읽어오면 92 91 90 ... 이렇게 읽는 게 아니라, 92 93 94 95 ... 99 이렇게 읽는다.

 

또한 64비트 운영체제이기 때문에 8byte 단위로 패딩해야 한다. 위 그림은 32비트 기준이다. 따라서 4바이트 단위로 패딩하지만, 우리는 64비트 기준으로 8byte 패딩을 해야 한다.

 

아래에서 설명하겠지만, strtok_r 함수를 이용해 argv배열에 담아둔 char 포인터를 이용해서 문자열을 인덱싱해, 실제 문자열을 스택에 담는다.

예를 들어 /bin/ls -l foo bar 일 때 뒤부터 스택에 넣는다. FIFO니까, 나올때 원래 순서대로 나오도록.

스택에 각 인자를 뒤쪽부터 넣는다. bar, foo, -l, /bin/ls 순서로. 물론 맨 뒤에 널 문자를 포함해야 한다(\0). 즉 널 문자부터 \0, r, a, b 이렇게 들어간다.

스택에 넣는 동시에 argv 배열에 현재 스택 주소를 담아둔다. 이것도 반대로 담는다. 즉 위 그림에선 ed, f5, f8 ... 순서로 담긴다.

 

그리고 필요한 경우 패딩을 한다. 우리는 64비트니까 8바이트 단위로 맞춰준다.

 

이후 인자들의 끝을 알리는 NULL 포인터 ( 0 ) 을 추가한다.

 

그리고 이번엔 방금 argv 배열에 담아두었던 인자 주소들을 다시 스택에 넣는다.

"실제 인자가 저장된 곳(스택)을 가리키는 포인터" 를 스택에 담는다. 그 값은 argv배열에 있는 것이고.

좀 헷갈리기는 하지만... 자세히 보면 이해된다.

 

그림에 있는 d4, d0 argv, argc는 내 코드엔 없다. 바로 return address가 나온다.

return address는 0으로 할당되는데, 일반 사용자 프로그램이라면 return address가 존재해야 한다. 함수가 실행완료된 후 돌아갈 곳을 의미하는 것이다.

 

하지만 스택에 인자를 넣는 이 작업은 커널 모드에서 실행되기 때문에 리턴할 곳이 필요 없어서, fake address인 0을 넣어두는 것이다.

 

 

프로그램 실행 흐름

 

전체적인 프로그램 실행 흐름이다. 우리가 하려는 일은 빨간 점선으로 표시된 Get the process name 부분이다.

Comman Line으로 명령어를 받아서, 파일명(명령어)과 인자를 각각 나누어 파일명과 인자들을 Stack에 넣고 해당 파일을 디스크에서 불러와(load) 새로운 스레드를 통해 실행하는 것이다. 

 

 

 

아래는 PintOS에서 프로그램이 실행되는 순서이다.

process_wait 수정

 

핀토스는 명령을 받으면 run_task 함수에서 해당 명령을 실행하게 된다. 여기서 process_wait가 사용되는데, 사용자가 명령한 프로그램이 끝날 때까지 기다리는 함수이다.

 

그러나 현재 핀토스는 기다리는 게 구현이 안 되어있다. process_wait에 진입하자마자 바로 return을 때려버린다.

따라서 바로 return되지 않도록 간단한 설정을 해 주어야 한다.

int
process_wait (tid_t child_tid UNUSED) {
    /* XXX: Hint) The pintos exit if process_wait (initd), we recommend you
     * XXX:       to add infinite loop here before
     * XXX:       implementing the process_wait. */

    // 바로 return하지 않도록 for문으로 기다린다
    for(int i = 0; i < 900000000; i++) {

    }
    return -1;
}

 

hex_dump 함수가 메모리를 출력할 때 까지 시간을 벌기 위해서 9억 번의 반복을 하는 for문을 추가해 준다.

물론 while로 하든 for문 반복을 늘리든 줄이든 크게 관계는 없지만, 9억 번이면 적당히 짧지도 길지도 않은 듯 하다.

 

 

 

 

=============  구현 =============

 

 

 

 

userprog/process.c

// process_execute와 동일한 함수
// file_name은 "/bin/ls -l foo bar" 와 같은 명령어이다.
tid_t
process_create_initd (const char *file_name) {
    char *fn_copy;
    tid_t tid;

    /* Make a copy of FILE_NAME.
     * Otherwise there's a race between the caller and load(). */
    // 새로운 페이지 할당받기 ( 페이지 단위로 메모리 할당 )
    fn_copy = palloc_get_page (0);
    if (fn_copy == NULL)
        return TID_ERROR;

    // file_name을 fn_copy로 복사한다
    strlcpy (fn_copy, file_name, PGSIZE);

    /* Create a new thread to execute FILE_NAME. */
    // initd라는 함수를 fn_copy(파일이름)를 인자로 받아 실행한다.
    tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
    if (tid == TID_ERROR)
        palloc_free_page (fn_copy);
    return tid;
}

 

"/bin/ls -l foo bar" 와 같은 명령어는 file_name 파라미터를 통해 들어온다.

해당 명령어를 fn_copy에 복사해서 thread_create 함수를 실행하는데, 이 때 인자에 들어가는 "initd 함수"가 fn_copy를 인자로 받아 실행된다.

 

 

userprog/process.c

// 사용자 프로세스를 시작하는 스레드 함수
static void
initd (void *f_name) {
#ifdef VM
    supplemental_page_table_init (&thread_current ()->spt);
#endif

    process_init ();

    if (process_exec (f_name) < 0)
        PANIC("Fail to launch initd\n");
    NOT_REACHED ();
}

 

initd함수이다. 여기서 process_exec 함수를 호출한다. 만약 실패했다면( 파일을 디스크에서 로드하는 것이 실패했다면 ) PANIC을 일으켜 프로그램을 즉시 중단한다.

 

NOT_REACHED는 추가적인 안전장치 같은건데, 프로그램의 흐름이 절대 NOT_REACHED()에 도달해서는 안 된다는 걸 의미한다. 이건 구현에 따라 다른데 프로그램을 중단시킬 수도 있고, 단순히 경고 메시지만 출력할 수도 있다.

 

 

userprog/process.c

// start_process와 동일한 함수
int
process_exec (void *f_name) {

    // 인자로 받은 f_name을 수정하기 위해 포인터를 가져온다
    char *file_name = f_name;
    bool success;
    struct thread *cur = thread_current();

    /* 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. */
    /* 우리는 스레드 구조에서 intr_frame을 사용할 수 없다.
     * 이는 현재 스레드가 다시 예약될 때,
     * 실행정보를 회원에게 저장한다.*/
    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 ();

    // 인자 파싱하기
    char *argv[64]; // 인자를 가리키는 "포인터"를 담을 배열
    int argc = 0;  // 인자 개수

    // strtok_r 함수를 이용한 명령줄 파싱
    // argv[0] : 파일명, argv[1] ~ : 인자
    char *token;
    char *save_ptr;  // 분리된 문자열 남는 부분 시작주소

    // strtok_r 함수는 문자열 "시작 주소(포인터)"를 반환한다
    // 즉 token엔 각 인자가 시작되는 포인터가 담긴다
    token = strtok_r(file_name, " ", &save_ptr);
    while(token != NULL) {

        // 파일명과 인자에 대한 포인터들이 argv에 들어간다
        argv[argc] = token;
        token = strtok_r(NULL, " ", &save_ptr);

        // 인자 개수 + 인덱스 기능
        argc++;
    }

    /* And then load the binary */
    // 디스크로부터 file_name에 해당하는 파일 메모리로 load하기
    success = load (file_name, &_if);


    /* If load failed, quit. */
    // 로드에 실패했다면 return 한다
    if (!success) {
        palloc_free_page (file_name);
        return -1;
    }
 
    // 스택에 인자 넣기
    // 인터럽트 프레임의 rsp 포인터를 가져오기
    void **rspp = &_if.rsp;

    // 인자, 인자개수, rsp포인터
    // 이거 하면 return address까지 알아서 추가되고 rsp는 return address를 가리킨다
    argument_stack(argv, argc, rspp);

    // rdi에 argc 넣기
    _if.R.rdi = argc;

    // rsp는 return address를 가리키는데 return address는 (void *)형태로써 8byte이다.
    // 따라서, return address 바로 전에 있는 argv를 가리키기 위해 8byte를 더해준다.
    _if.R.rsi = (uint64_t)*rspp + sizeof(void *);

    // 메모리 내용 출력
    hex_dump(_if.rsp, _if.rsp, USER_STACK - (uint64_t)*rspp, true);

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

 

좀 길다. 주석을 많이 달아서 좀 더 길다. 이 함수는 전체적으로 이러한 작업들을 수행한다.

 

1. 명령어에서 인자 파싱하기

2. 디스크에서 파일 로드하기

3. 스택에 인자 넣기

4. hex_dump로 메모리 내용 출력하기

 

 

우선 인자 파싱부터 보겠다.

// 인자 파싱하기
    char *argv[64]; // 인자를 가리키는 "포인터"를 담을 배열
    int argc = 0;  // 인자 개수

    // strtok_r 함수를 이용한 명령줄 파싱
    // argv[0] : 파일명, argv[1] ~ : 인자
    char *token;
    char *save_ptr;  // 분리된 문자열 남는 부분 시작주소

    // strtok_r 함수는 문자열 "시작 주소(포인터)"를 반환한다
    // 즉 token엔 각 인자가 시작되는 포인터가 담긴다
    token = strtok_r(file_name, " ", &save_ptr);
    while(token != NULL) {

        // 파일명과 인자에 대한 포인터들이 argv에 들어간다
        argv[argc] = token;
        token = strtok_r(NULL, " ", &save_ptr);

        // 인자 개수 + 인덱스 기능
        argc++;
    }

 

strtok_r 함수를 통해서 인자를 파싱한다.

"/bin/ls -l foo bar" 를 공백 기준으로 나누어 token에 저장한 후 argv배열에 넣는다.

argc는 인덱스 기능을 하며, 동시에 인자 개수를 센다.

 

file_name엔 /bin/ls가 할당되어 있다.

 

 

 

/* And then load the binary */
    // 디스크로부터 file_name에 해당하는 파일 메모리로 load하기
    success = load (file_name, &_if);


    /* If load failed, quit. */
    // 로드에 실패했다면 return 한다
    if (!success) {
        palloc_free_page (file_name);
        return -1;

    }

 

디스크에서 파일명에 해당하는 파일을 메모리로 load한다.

만약 로드에 실패했다면 file_name에 할당된 메모리를 해제하고 return 한다.

 

 

 

// 스택에 인자 넣기
    // 인터럽트 프레임의 rsp 포인터를 가져오기
    void **rspp = &_if.rsp;

    // 인자, 인자개수, rsp포인터
    // 이거 하면 return address까지 알아서 추가되고 rsp는 return address를 가리킨다
    argument_stack(argv, argc, rspp);

    // rdi에 argc 넣기
    _if.R.rdi = argc;

    // rsp는 return address를 가리키는데 return address는 (void *)형태로써 8byte이다.
    // 따라서, return address 바로 전에 있는 argv를 가리키기 위해 8byte를 더해준다.
    _if.R.rsi = (uint64_t)*rspp + sizeof(void *);

    // 메모리 내용 출력
    hex_dump(_if.rsp, _if.rsp, USER_STACK - (uint64_t)*rspp, true);

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

 

**rspp 포인터 변수에  rsp포인터를 할당하고, argument_stack함수를 이용해 argv, argc를 rspp 위치에 넣어준다.

또 rdi엔 argc, rsi엔 argv를 넣어준다.

 

마지막으로 hex_dump로 메모리 내용을 출력하고 유저 모드로 점프한다.

 

 

userprog/process.c

void argument_stack(char **argv, int argc, void **rsp)
{

    // Save argument strings (character by character)

    // argv는 인자들의 시작주소를 가진 배열이다.
    // argv 배열을 역순 순회
    for (int i = argc - 1; i >= 0; i--)
    {  

        // i번 인자의 길이
        // strlen은 char 타입 포인터 주소를 이용해 %s 처럼 실제 문자열 길이를 리턴한다
        int argv_len = strlen(argv[i]);

        // 인자 역순으로 순회, 널문자 포함
        for (int j = argv_len; j >= 0; j--)
        {
            // 인자 뒤부터 1글자씩 빼내기
            // bar\0 -> \0 부터 뺀다
            // 마찬가지로 char 포인터지만 [j]로 인덱싱해 문자열 가져올 수 있다
            char argv_char = argv[i][j];

            // 스택 1byte 늘리기
            // *rsp : 스택 top 주소
            (*rsp)--;

            // rsp는 void ** 타입이기 때문에 char를 저장하기 위해 캐스팅
            // 스택 top에 인자 1글자를 저장한다
            // **rsp : 스택 top에 존재하는 값
            **(char **)rsp = argv_char; // 1 byte
        }

        // 기존 argv[i]에 있던 인자 시작 주소를 사용해서 스택에 인자값을 넣었다.
        // 이제 값을 rsp에 담았으니 마지막 rsp 위치를 argv[i]에 저장한다.
        // FIFO로 꺼내지기 때문에 마지막에 담은 위치 저장해두면 제대로 읽어온다.
        argv[i] = *(char **)rsp; // 배열에 실제 인자값 시작주소 넣기
    }

    // Word-align padding
    // 8바이트 단위로 패딩
    // 8로 나눈 나머지만큼 주소를 감소시키면(스택은 성장) 된다.
    int pad = (int)*rsp % 8;
    for (int k = 0; k < pad; k++)
    {
        // rsp 주소 1바이트 감소
        (*rsp)--;
        // rsp에 값 0을 넣는다.
        // rsp는 1byte 단위를 맞추기 위해 주로 uint8_t 또는 char로 자주 선언된다.
        // int : 4byte / char, uint8_t : 1byte / uint64_t : 8byte 등등 캐스팅 형태에 따라 차지하는 크기가 달라진다.
        **(uint8_t **)rsp = 0;
    }

    // Pointers to the argument strings
    // NULL 포인터를 삽입함으로써 argv 배열의 끝을 나타낸다
    // rsp 주소 8바이트 감소
    (*rsp) -= 8;
    // rsp가 가리키는 곳 실제 값이 포인터인데 그게 NULL포인터
    **(char ***)rsp = 0; // NULL 포인터

    for (int i = argc - 1; i >= 0; i--)
    {
        // rsp 주소 8바이트 감소
        (*rsp) -= 8;
        // rsp가 가리키는 곳 실제 값에 argv[i] 포인터를 넣는다
        **(char ***)rsp = argv[i];
    }

    // Return address
    (*rsp) -= 8;
    **(void ***)rsp = 0;
}

 

스택에 인자를 삽입하는, Argument Passing에서 가장 중요하다고도 볼 수 있는 argument_stack 함수이다.

 

우선 argv배열에 담겨있는 char포인터를 이용해서 인자 문자열을 하나씩 인덱싱한 후 스택을 1byte씩 늘려가며 직접 담는다. 동시에 argv 배열엔 방금 문자열을 저장한 스택의 주소를 넣어준다.

 

이후 8비트(1바이트)단위로 필요하다면 패딩을 해준 후,  argv가 끝났다는 의미로 NULL 포인터를 넣어준다.

 

그리고 아까 argv 배열엔 실제 인자 문자열이 저장된 스택 주소를 저장했다고 했는데, 그 주소에 대한 포인터를 다시 스택에 넣어준다.

 

마지막으로 Return address(fake address)를 넣고 끝낸다.

 

==========================

 

실행 결과

 

출력 성공

 

pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

 

우측을 보면, 스택에 args-single과 onearg가 구분되어 저장되어 있다는 걸 확인할 수 있다.

728x90
반응형