
해당 문서는 크래프톤 정글에서 진행하는 KAIST Pintos x86_64 기준으로 작성된 문서입니다.
통과 가능 테스트 케이스: args-none, args-single, args-multiple, args-many, args-dbl-space
→ 지금 테스트 케이스는 FAIL나오는게 정상입니다. 스택이 안짤리고 잘 나오는지 확인만하세요. 시스템콜까지 구현시 PASS됩니다.
참고 사이트: https://casys-kaist.github.io/pintos-kaist/project2/argument_passing.html
지금 process_exec() 함수는 새 프로세스에 인수 전달을 지원하지 않습니다.
앞으로 process_exec() 프로그램 파일 이름, 데이터를 인수로 받고, 공백을 통해 단어로 나누어서 구현합니다. 해당과정을 파싱이라고 하며 저희가 첫번째로 해야될 일입니다.
그 이후에는 파싱된 인수들을 스택을 Git Book 에 주어진 스택 순서에 따라 구현합니다. 다음과 같습니다.
KAIST Pintos 64비트의 차이점
→ 참고로 인터넷이나 GPT의 글들을 보면 start_process() 라는 함수에서 유저 프로세스를 실행하는 스레드 함수로 load() 호출을 포함한다고 되어 있는데, 카이스트 Pintos 64비트 버전은 process_exec() 함수에 통합되어 있음을 유념하여 작성하여야합니다.
| Address | Name | Data | Type |
|---|---|---|---|
| 0x4747fffc | argv[3][...] | 'bar\0' | char[4] |
| 0x4747fff8 | argv[2][...] | 'foo\0' | char[4] |
| 0x4747fff5 | argv[1][...] | '-l\0' | char[3] |
| 0x4747ffed | argv[0][...] | '/bin/ls\0' | char[8] |
| 0x4747ffe8 | word-align | 0 | uint8_t[] |
| 0x4747ffe0 | argv[4] | 0 | char * |
| 0x4747ffd8 | argv[3] | 0x4747fffc | char * |
| 0x4747ffd0 | argv[2] | 0x4747fff8 | char * |
| 0x4747ffc8 | argv[1] | 0x4747fff5 | char * |
| 0x4747ffc0 | argv[0] | 0x4747ffed | char * |
| 0x4747ffb8 | return address | 0 | void (*) () |
코드 작성을 하기 위해서 해야될 준비가 있습니다. 기본적으로 process.c, string.c 와 같은 필수 파일들의 주석들은 한국어로 번역하는 것을 추천합니다. 중간중간 힌트와 우리가 해야될 것들, 테스트 시 주의 사항 등이 써져있는 경우가 있습니다. 아래의 process_wait 세팅이 그 예시입니다.
앞으로 추가된 부분은 ////////////////////////////// 표시를 했습니다.
해당 함수는 스레드 TID가 종료될 때까지 기다리고 종료 상태를 반환합니다. 예외(TID 유효하지 않음, 호출 프로세스의 자식 프로세스가 아님, 이미 성공적으로 호출됨)로 인해 커널에 의해 종료된 경우 -1을 반환합니다.
그러므로 for 문이나 while 문을 써야 결과를 확인할 수 있습니다.
int
process_wait (tid_t child_tid UNUSED) {
/* XXX: Hint) pintos는 process_wait(initd)가 발생하면 종료되므로,
* XXX: process_wait를 구현하기 전에 여기에 무한 루프를 추가하는 것이 좋습니다. */
//////////////////////////////
// 자식이 종료되지 않도록 유지
for(int i=0; i<2000000000; i++){} // for문으로 i만큼 제한을 걸어 반복합니다.
//////////////////////////////
return -1;
}

위와 같이 결과가 제대로 출력되지 않음을 확인할 수 있다.
원래는 Executing ‘args-single onearg’: 에 결과가 출력된다.
경로: userprog/process.c 위치
수정 함수 순서: process_wait() → process_exec() → load()
스택을 우리가 직접 확인하기 위해서는 hex_dump() 라는 함수를 사용하여 확인하면됩니다. 그러므로 load() 를 불러온 다음 자리에 해당 함수를 작성합니다.
→ 자세한 내용은 Git Book 프로젝트 2 FAQ 페이지의 [All my user programs die with system call!] 항목을 참고하세요.
/* 현재 실행 컨텍스트를 f_name으로 전환합니다.
* 실패하면 -1을 반환합니다. */
int
process_exec (void *f_name) {
char *file_name = f_name;
bool success;
/* 스레드 구조체에서는 intr_frame을 사용할 수 없습니다.
* 현재 스레드가 재스케줄링될 때 실행 정보가 멤버에 저장되기 때문입니다. */
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);
//////////////////////////////
hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true); // 스택 확인
//////////////////////////////
/* 로드에 실패하면 종료합니다. */
palloc_free_page (file_name);
if (!success)
return -1;
/* 전환 프로세스를 시작합니다. */
do_iret (&_if);
NOT_REACHED ();
}
저희가 본격적으로 코드를 구현할 함수입니다. 상단의 Args 구현 절차에 따라, 파싱을 구현 후 스택 배치를 구현하면 됩니다.
각각 따로 결과를 확인을 할 수 있으므로 파싱을 하고 printf를 통해 인자가 잘 쪼개지는지 확인하고 스택 배치를 구현하는 것이 좋습니다. (디버그 편의성)
→ 코드 읽기의 가독성을 위해 변경하지 않은 부분은 … 표시를 통해 생략하였습니다. 추가부분은 ////////////////////////////// 부분을 참고하세요.
/* FILE_NAME에서 ELF 실행 파일을 현재 스레드로 로드합니다.
* 실행 파일의 진입점을 *RIP에 저장하고
* 초기 스택 포인터를 *RSP에 저장합니다.
* 성공하면 true를 반환하고, 그렇지 않으면 false를 반환합니다. */
static bool
load (const char *file_name, struct intr_frame *if_) {
.
.
.
/* 페이지 디렉토리를 할당하고 활성화합니다. */
t->pml4 = pml4_create ();
if (t->pml4 == NULL)
goto done;
process_activate (thread_current ());
//////////////////////////////
char *token, *save_ptr;
char *argv[128]; // 인자를 128개까지 저장
int argc = 0; // 인자 개수를 셈
int len = 0;
char *argv_address[128]; // 주소 인자를 담음
// string.c의 strtok_r 함수를 사용하여 argv 파싱을 진행합니다.
for (token = strtok_r(file_name, " ", &save_ptr); token != NULL; // 공백기준으로 나누어서 진행
token = strtok_r(NULL, " ", &save_ptr)) {
printf ("argv[%d] = '%s'\n", argc, token); // token, argc 확인용
argv[argc++] = token;
}
///////////////////////////////
/* 실행 파일을 엽니다. */
file = filesys_open (file_name);
if (file == NULL) {
printf ("load: %s: open failed\n", file_name);
goto done;
}
.
.
.
/* 스택 설정. */
if (!setup_stack (if_))
goto done;
/* 시작 주소. */
if_->rip = ehdr.e_entry;
//////////////////////////////
// 이제 스택을 넣어야함 git book을 참고하기.
// 1. argv[i] 문자열을 역순으로 스택에 push한다.
for(int i = argc - 1; i >= 0; i--) { // argc 인자가 없어지기 전까지 마지막 개행문자 제외하고 0될때까지 진행.
size_t len = strlen(argv[i]) + 1; // null 포함하여 계산
if_ -> rsp -= len;
memcpy((void *)if_->rsp, argv[i], len);
argv_address[i] = (char *)if_->rsp;
}
// 2. 16바이트 정렬에 맞게끔 패딩을 맞춰준다.
// while((if_->rsp) % 16 != 0) // rsp 길이값이 16로 나누었을때 0이 아닐때 수행.
// if_ -> rsp -= 1;
// *(uintptr_t *)(if_ -> rsp) = 0; // 문제 있는 내 코드 (나머지 값에 0을 입력한다.)
// memset((void *)if_ -> rsp, 0, sizeof(char)); // 권호형 코드
if_ -> rsp = (char *)((uintptr_t)(if_ -> rsp) & ~(uintptr_t)0x7); // 재준이형 코드(단독으로만 써도 작동)
// 3. NULL 포인터를 넣는다.
(if_ -> rsp) -= sizeof(char *);
*(void **)(if_ -> rsp) = NULL;
// 4. 각 argv[i] 주소를 역순으로 넣는다.
for (int i = argc - 1; i >= 0; i--) {
if_ -> rsp -= sizeof(char *);
*(void **)(if_ -> rsp) = argv_address[i];
}
// 5. 주소 리턴
if_ -> rsp -= sizeof(char *);
*(int *)(if_ -> rsp) = 0;
//////////////////////////////
success = true;
done:
/* 우리는 화물이 성공적으로 도착하든 실패하든 여기에 도착합니다. */
file_close (file);
return success;
}
위의 코드에서 크게 두가지 부분이 있는데 첫번째 부분을 먼저 확인해보겠습니다.
file_name 파싱
char *token, *save_ptr;
char *argv[128]; // 인자를 128개까지 저장
int argc = 0; // 인자 개수를 셈
int len = 0;
char *argv_address[128]; // 주소 인자를 담음
// string.c의 strtok_r 함수를 사용하여 argv 파싱을 진행합니다.
for (token = strtok_r(file_name, " ", &save_ptr); token != NULL; // 공백기준으로 나누어서 진행
token = strtok_r(NULL, " ", &save_ptr)) {
printf ("argv[%d] = '%s'\n", argc, token); // token, argc 확인용
argv[argc++] = token;
}
파싱을 하기 위해서는 받아온 인자 argv를 공백을 기준으로 나누어야 하고 argc를 통해 인자의 개수를 세어줘야합니다. 나눠진 후엔 매겨진 수에 따라 차례대로 스택에 쌓아지게 됩니다. 그러므로 strtok_r 함수를 활용하여 구현합니다. 당연히 어떻게 쓰는지는 스스로 알아봐야됩니다.
착각하면 안되는데 확인을 해보면 file_name 에 파일명, 데이터가 모두 들어가있습니다. 그러므로 해당 코드는 다음과 같이 전개됩니다.
file_name 을 대입합니다.file_name == "args-single onearg" → token = "args-single" )argv[argc] 에 각 인자를 저장합니다.→ printf 는 token 값과 argc 값의 일치를 확인하기 위한 디버깅용으로 작성했습니다. (파싱 확인)
테스트 케이스 args-dbl-space ****같은 경우에는 더블로 오는 NULL에 대해 하나의 NULL로 처리를 하는가를 보는 테스트로 파싱이 제대로 된다면 문제 없이 넘어가집니다. 왜 그런지는 lib / user / string.c 에 있는 strtok_r 함수의 작동원리를 확인해보세요. 주석이 친절해서 금방 알아볼 수 있습니다.
char *
strtok_r (char *s, const char *delimiters, char **save_ptr) {
char *token;
ASSERT (delimiters != NULL);
ASSERT (save_ptr != NULL);
/* S가 null이 아니면 S부터 시작합니다.
S가 null이면 저장된 위치에서 시작합니다. */
if (s == NULL)
s = *save_ptr;
ASSERT (s != NULL);
/* 현재 위치에서 구분 기호를 모두 건너뜁니다. */
while (strchr (delimiters, *s) != NULL) {
/* strchr()은 널 바이트를 검색하는 경우 항상 널이 아닌 값을 반환합니다.
모든 문자열에는 (문자열 끝에) 널 바이트가 포함되어 있기 때문입니다. */
if (*s == '\0') {
*save_ptr = s;
return NULL;
}
s++;
}
성공한다면 테스트 케이스 실행시 이런식으로 출력된다.

스택 쌓기 기본 규칙
스택을 쌓기 위해서는 기본적으로 알아야하는 것이 있다.
sizeof(void *) == 8)파싱된 argv 인자들 스택 쌓기
| Address | Name | Data | Type |
|---|---|---|---|
| 0x4747fffc | argv[3][...] | 'bar\0' | char[4] |
| 0x4747fff8 | argv[2][...] | 'foo\0' | char[4] |
| 0x4747fff5 | argv[1][...] | '-l\0' | char[3] |
| 0x4747ffed | argv[0][...] | '/bin/ls\0' | char[8] |
| 0x4747ffe8 | word-align | 0 | uint8_t[] |
| 0x4747ffe0 | argv[4] | 0 | char * |
| 0x4747ffd8 | argv[3] | 0x4747fffc | char * |
| 0x4747ffd0 | argv[2] | 0x4747fff8 | char * |
| 0x4747ffc8 | argv[1] | 0x4747fff5 | char * |
| 0x4747ffc0 | argv[0] | 0x4747ffed | char * |
| 0x4747ffb8 | return address | 0 | void (*) () |
위에 주어진 표에 따라 차례대로 쌓으면 됩니다.
argv[i] 문자열을 역순으로 push// 1. argv[i] 문자열을 역순으로 스택에 push한다.
for(int i = argc - 1; i >= 0; i--) { // argc 인자가 없어지기 전까지 마지막 null 제외하고 0될때까지 진행.
size_t len = strlen(argv[i]) + 1; // null 포함하여 계산
if_ -> rsp -= len;
memcpy((void *)if_->rsp, argv[i], len);
argv_address[i] = (char *)if_->rsp;
}
\0 ) 로 저장합니다.argv[i] 이 가리킬 수 있습니다. // 2. 16바이트 정렬에 맞게끔 패딩을 맞춰준다.
while((if_->rsp) % 16 != 0) // rsp 길이값이 16로 나누었을때 0이 아닐때 수행.
if_ -> rsp -= 1;
memset((void *)if_ -> rsp, 0, sizeof(char)); // 권호형 코드
/////////////////////////
if_ -> rsp = (char *)((uintptr_t)(if_ -> rsp) & ~(uintptr_t)0xF); // 재준이형 코드(단독으로만 써도 작동)
// 0xF == 15, 16바이트 마스킹
call → ret 간에 스택이 비정렬 상태가 되어 충돌되거나 유효하지 않은 역참조로 이어질 수 있습니다.rsp 를 16의 배수로 내림 정렬을 할 수 있습니다. // 3. NULL 포인터를 넣는다.
(if_ -> rsp) -= sizeof(char *);
*(void **)(if_ -> rsp) = NULL;
argv[argc] = NULL 포인터를 명시적으로 넣어야합니다. 경계와 비슷for (int i = 0; argv[i] != NULL; i++) 루프 종료를 위해 필요합니다.rsp 를 줄여야합니다.argv[i] 주소 역순 push // 4. 각 argv[i] 주소를 역순으로 넣는다.
for (int i = argc - 1; i >= 0; i--) {
if_ -> rsp -= sizeof(char *);
*(void **)(if_ -> rsp) = argv_address[i];
}
argv_address[] )를 기반으로 구성합니다.// Git book에 프로그램 시작 세부 정보의 4번째 단계로 써져있다.
if_->R.rdi = argc; // 첫 번째 인자 argc 포인터를 rdi에 저장합니다.
if_->R.rsi = if_->rsp + 8; // 두 번째 인자 argv 포인터를 rsi에 저장합니다.(argc 포인터 건너뛰기 위함)
// 5. 주소 리턴
if_ -> rsp -= sizeof(char *);
*(int *)(if_ -> rsp) = 0;
main() 함수에서 실행 종료 시, ret 명령어가 사용될 수 있습니다. (시스템콜 부분)// Git book에 프로그램 시작 세부 정보의 4번째 단계로 써져있다.
if_->R.rdi = argc; // 첫 번째 인자 argc 포인터를 rdi에 저장합니다.
if_->R.rsi = if_->rsp + 8; // 두 번째 인자 argv 포인터를 rsi에 저장합니다.(argc 포인터 건너뛰기 위함)
테스트 방법에는 여러가지가 있다. 모두 pintos 루트 폴더에서 source ./activate 와 pintos / userprog 에서 make 를 실행했다는 가정하에 작성합니다. 그럼 userprog내에 build폴더가 생기고, 밑의 테스트 방법 모두 build 폴더에서 실행합니다.

make tests/userprog/args-dbl-space.result VERBOSE=1
해당 방법은 pintos 프로젝트에 있는 make 테스트 케이스로 결과를 확인하는 코드 입니다.
args-dbl-space 부분에 원하는 테스트 케이스를 바꿔서 작성하면 원하는 테스트케이스의 결과를 확인할 수 있습니다.
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
해당 방법은 pintos 명령어를 통해 커스텀 인자로 부팅을 실행합니다. 쓰는 방법이 어렵고 테스트 케이스 별로 바꿀 코드 부분이 많으므로 추천되지 않습니다.
[결과]

pintos 프로젝트에서는 디버깅을 위한 backtrace 기능을 제공한다. 보통 자동으로 보여주는데, 안될때가 있어서 [backtrace + Call stack들] 을 작성하여 실행하면 어디서 오류가 났는지 확인할 수 있다.

프로젝트2가 진행됨에 따라 자력으로 코드를 작성하는 것이 굉장히 힘듦을 알게 되었다. 팀원분들과 같이 생각한 예상 소요시간을 훌쩍 넘었다. (사실상 위의 문서를 작성하면서 이해하는데 생각보다 시간이 좀 많이 걸렸다.)
집단지성의 힘으로 내가 모르는건 다른 분들께 물어보고 내가 아는건 최대한 쉽게 알려드리는 방법을 택하고 있다. 되도록이면 GPT나 블로그들은 참조하지 않도록 하고 있다. 사실상 pintos 자체가 카이스트 32/64비트, 한양대 32비트 등 다양하고 우리가 쓰고 있는 pintos는 카이스트 64비트라 자료도 별로 없고 GPT가 이상한 답만 내놓는다…
앞으로의 시스템 콜, 프로세스 종료 메시지, 실행 파일 읽기 쓰기 거부 등도 힘내서 해보겠다.