PintOS Project2: User Programs (Args)

Devkty·2025년 5월 21일
post-thumbnail

PintOS 프로젝트2 Argument Passing

해당 문서는 크래프톤 정글에서 진행하는 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

Argument Passing

구현해야될 것

지금 process_exec() 함수는 새 프로세스에 인수 전달을 지원하지 않습니다.

앞으로 process_exec() 프로그램 파일 이름, 데이터를 인수로 받고, 공백을 통해 단어로 나누어서 구현합니다. 해당과정을 파싱이라고 하며 저희가 첫번째로 해야될 일입니다.
그 이후에는 파싱된 인수들을 스택을 Git Book 에 주어진 스택 순서에 따라 구현합니다. 다음과 같습니다.

KAIST Pintos 64비트의 차이점
→ 참고로 인터넷이나 GPT의 글들을 보면 start_process() 라는 함수에서 유저 프로세스를 실행하는 스레드 함수로 load() 호출을 포함한다고 되어 있는데, 카이스트 Pintos 64비트 버전은 process_exec() 함수에 통합되어 있음을 유념하여 작성하여야합니다.

Args 전달을 위한 절차

  1. process_exec() 함수에서 파일 이름과 데이터를 인수로 받아 파싱합니다.
  2. 파싱된 인수들을 밑에와 같은 순서로 스택에 정렬하여 배치합니다.
AddressNameDataType
0x4747fffcargv[3][...]'bar\0'char[4]
0x4747fff8argv[2][...]'foo\0'char[4]
0x4747fff5argv[1][...]'-l\0'char[3]
0x4747ffedargv[0][...]'/bin/ls\0'char[8]
0x4747ffe8word-align0uint8_t[]
0x4747ffe0argv[4]0char *
0x4747ffd8argv[3]0x4747fffcchar *
0x4747ffd0argv[2]0x4747fff8char *
0x4747ffc8argv[1]0x4747fff5char *
0x4747ffc0argv[0]0x4747ffedchar *
0x4747ffb8return address0void (*) ()

코드 작성 전 준비

코드 작성을 하기 위해서 해야될 준비가 있습니다. 기본적으로 process.c, string.c 와 같은 필수 파일들의 주석들은 한국어로 번역하는 것을 추천합니다. 중간중간 힌트와 우리가 해야될 것들, 테스트 시 주의 사항 등이 써져있는 경우가 있습니다. 아래의 process_wait 세팅이 그 예시입니다.

앞으로 추가된 부분은 ////////////////////////////// 표시를 했습니다.

process_wait 반복설정 (process.c)

해당 함수는 스레드 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’: 에 결과가 출력된다.

process.c

경로: userprog/process.c 위치

수정 함수 순서: process_wait() → process_exec() → load()

process_exec 함수

스택을 우리가 직접 확인하기 위해서는 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 ();
}

load 함수

저희가 본격적으로 코드를 구현할 함수입니다. 상단의 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 에 파일명, 데이터가 모두 들어가있습니다. 그러므로 해당 코드는 다음과 같이 전개됩니다.

  1. for 첫째줄에 token에 file_name 을 대입합니다.
    (예시: file_name == "args-single onearg" → token = "args-single" )
  2. token이 NULL이 아닐때까지 argv[argc] 에 각 인자를 저장합니다.
  3. 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++;
	}

성공한다면 테스트 케이스 실행시 이런식으로 출력된다.


스택 쌓기 기본 규칙

스택을 쌓기 위해서는 기본적으로 알아야하는 것이 있다.

  • 스택은 아래 방향으로 자란다. (고주소 → 저주소)
  • 스택 프레임은 16바이트 단위로 정렬되어야합니다. 그러기 위해선 padding 이 필요합니다.
    (x86_64 기준으로 call 명령어 이후의 rsp 는 반드시 16바이트 정렬이되어야함)
  • 포인터는 8바이트 크기를 가집니다. (sizeof(void *) == 8)
  • 스택에서 push 하는 모든 값도 8바이트 단위로 다루는 것이 표준입니다.

파싱된 argv 인자들 스택 쌓기

AddressNameDataType
0x4747fffcargv[3][...]'bar\0'char[4]
0x4747fff8argv[2][...]'foo\0'char[4]
0x4747fff5argv[1][...]'-l\0'char[3]
0x4747ffedargv[0][...]'/bin/ls\0'char[8]
0x4747ffe8word-align0uint8_t[]
0x4747ffe0argv[4]0char *
0x4747ffd8argv[3]0x4747fffcchar *
0x4747ffd0argv[2]0x4747fff8char *
0x4747ffc8argv[1]0x4747fff5char *
0x4747ffc0argv[0]0x4747ffedchar *
0x4747ffb8return address0void (*) ()

위에 주어진 표에 따라 차례대로 쌓으면 됩니다.

  1. 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;
	}
  • 문자열은 null-terminated( \0 ) 로 저장합니다.
  • 인자들은 스택에 물리적으로 존재해야 argv[i] 이 가리킬 수 있습니다.
  • 역순으로 push 하는 이유는 스택이 고주소 → 저주소로 쌓아지기 때문입니다.
  1. Padding 또는 비트마스킹을 통한 16바이트 정렬 (Alignment)
	// 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바이트 마스킹
  • 64비트는 rsp 함수 진입시 항상 16바이트 정렬이 되야합니다.
  • 정렬이 깨지면, 함수 호출 시 callret 간에 스택이 비정렬 상태가 되어 충돌되거나 유효하지 않은 역참조로 이어질 수 있습니다.
  • memset을 통해 패딩을 줄 수도 있고, 비트마스킹을 통해 rsp 를 16의 배수로 내림 정렬을 할 수 있습니다.
  1. NULL 포인터 넣기
	// 3. NULL 포인터를 넣는다.
	(if_ -> rsp) -= sizeof(char *);
	*(void **)(if_ -> rsp) = NULL;
  • argv[argc] = NULL 포인터를 명시적으로 넣어야합니다. 경계와 비슷
  • 사용자 프로그램에서 for (int i = 0; argv[i] != NULL; i++) 루프 종료를 위해 필요합니다.
  • 포인터 크기는 64비트이므로 8바이트 단위로 rsp 를 줄여야합니다.
  1. 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[] 배열 자체를 스택에 올리는 과정입니다.
  • 앞서 복사해둔 문자열 주소( argv_address[] )를 기반으로 구성합니다.
  • 포인터 배열이므로 8바이트씩 push 합니다.
  1. main() 함수 실행되도록 레지스터 세팅
// Git book에 프로그램 시작 세부 정보의 4번째 단계로 써져있다.
	if_->R.rdi  = argc;        // 첫 번째 인자 argc 포인터를 rdi에 저장합니다.
	if_->R.rsi = if_->rsp + 8; // 두 번째 인자 argv 포인터를 rsi에 저장합니다.(argc 포인터 건너뛰기 위함)
  • Git Book 프로그램 시작 세부 정보 4번째 단계 힌트에 따라 구현합니다.
  • argc 인자들 가리키는 포인터는 rdi 레지스터에 저장되어야합니다.
  • argc 인자들의 수를 알려주는 argv 포인터는 argc포인터 크기를 건너뛴 rsi에 저장되어야합니다.
  1. 가짜 반환주소 push
	// 5. 주소 리턴
	if_ -> rsp -= sizeof(char *);
	*(int *)(if_ -> rsp) = 0;
  • 이건 너무 깊게 생각하면 머리아프다. 사실상 반환을 하진 않지만, 스택 프레임이 다른 함수와 동일한 구조를 갖기 위해 쓴다고 합니다.
  • main() 함수에서 실행 종료 시, ret 명령어가 사용될 수 있습니다. (시스템콜 부분)
  • fake return address (0) 를 넣어주지 않으면 fault 가 발생 가능합니다.
  • 64비트에서는 가짜 반환 주소도 8바이트 정렬이 필요하여 sizeof(char *)을 사용합니다.
  1. main() 함수 실행되도록 레지스터 세팅
// Git book에 프로그램 시작 세부 정보의 4번째 단계로 써져있다.
	if_->R.rdi  = argc;        // 첫 번째 인자 argc 포인터를 rdi에 저장합니다.
	if_->R.rsi = if_->rsp + 8; // 두 번째 인자 argv 포인터를 rsi에 저장합니다.(argc 포인터 건너뛰기 위함)
  • Git Book 프로그램 시작 세부 정보 4번째 단계 힌트에 따라 구현합니다.
  • argc 인자들 가리키는 포인터는 rdi 레지스터에 저장되어야합니다.
  • argc 인자들의 수를 알려주는 argv 포인터는 argc포인터 크기를 건너뛴 rsi에 저장되어야합니다.

테스트 방법

테스트 방법에는 여러가지가 있다. 모두 pintos 루트 폴더에서 source ./activatepintos / userprog 에서 make 를 실행했다는 가정하에 작성합니다. 그럼 userprog내에 build폴더가 생기고, 밑의 테스트 방법 모두 build 폴더에서 실행합니다.

  1. 테스트 케이스로 결과 확인 (추천!)
make tests/userprog/args-dbl-space.result VERBOSE=1

해당 방법은 pintos 프로젝트에 있는 make 테스트 케이스로 결과를 확인하는 코드 입니다.

args-dbl-space 부분에 원하는 테스트 케이스를 바꿔서 작성하면 원하는 테스트케이스의 결과를 확인할 수 있습니다.

  1. 커스텀 인자로 pintos 부팅하기 (비추)
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

해당 방법은 pintos 명령어를 통해 커스텀 인자로 부팅을 실행합니다. 쓰는 방법이 어렵고 테스트 케이스 별로 바꿀 코드 부분이 많으므로 추천되지 않습니다.

[결과]

추가정보 (backtrace 디버깅 방법)

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

느낀점

프로젝트2가 진행됨에 따라 자력으로 코드를 작성하는 것이 굉장히 힘듦을 알게 되었다. 팀원분들과 같이 생각한 예상 소요시간을 훌쩍 넘었다. (사실상 위의 문서를 작성하면서 이해하는데 생각보다 시간이 좀 많이 걸렸다.)

집단지성의 힘으로 내가 모르는건 다른 분들께 물어보고 내가 아는건 최대한 쉽게 알려드리는 방법을 택하고 있다. 되도록이면 GPT나 블로그들은 참조하지 않도록 하고 있다. 사실상 pintos 자체가 카이스트 32/64비트, 한양대 32비트 등 다양하고 우리가 쓰고 있는 pintos는 카이스트 64비트라 자료도 별로 없고 GPT가 이상한 답만 내놓는다…

앞으로의 시스템 콜, 프로세스 종료 메시지, 실행 파일 읽기 쓰기 거부 등도 힘내서 해보겠다.

profile
모든걸 기록하며 성장하고 싶은 개발자입니다. 현재 크래프톤 정글 8기를 수료하고 구직활동 중입니다.

0개의 댓글