[PintOS project] Part 2: User Programs - 1. Passing the arguments and creating a thread

@developer/takealittle.time·2024년 11월 15일
2

KAIST PintOS Project

목록 보기
7/9
post-thumbnail

이전 글


00. 들어가며

  • 이전 주차에서 우리는 커널 영역만을 다루었다.
    이번 주차에서는 본격적으로 OS에서 사용자 프로그램 (User Program)을 실행시키는 부분을 학습한다.

  • CLI 환경에서 커맨드 라인으로 내가 특정 명령어를 실행했을 때, OS는 이러한 명령어의 실행을 위해 어떤 작업을 수행할까?

00 - 1. 사용자 프로그램(User Program)을 실행하기 위해 우리가 Part2에서 해야 할 일들

  • 2주차에서 우리가 해야 할 일들을 크게 나열하면 다음과 같다.

1. 파일 디스크로부터 실행 파일 읽어오기

- 파일 시스템 (Filesystem) 이슈

2. 프로그램이 실행 될 메모리를 할당하기

- 가상 메모리(Virtual Memory) 할당

3. 프로그램에 인자 전달하기

- 사용자 스택 (User stack) 세팅

4. 사용자 프로그램 (User Program) 간 Context Switch

- OS는 사용자 프로그램이 끝날 때 까지 대기해야 한다.
  • 이번에 우리가 해야 할 작업은 우선 '3번, 프로그램에 인자 전달하기'이다.

00 - 2. 사용자 프로그램(User Program) 실행의 Flow Chart

  • 이번 주차에 사용자 프로그램을 실행할 때 PintOS에서 동작 수행은 다음 Flow Chart와 같다.

  • 우리는 process_create_initd(), process_exec() 함수를 수정 해 해당 과제를 해결할 것이다.

00-3. 2주차 과제를 들어가기 전에 1주차 내용 中 Trouble Shooting

  • 1주차 과제를 진행하며 작성한 함수 중에, 다음과 같은 함수가 있었다.
    ready_list의 가장 앞에 있는 스레드의 우선 순위를 확인하고, 해당 스레드의 우선 순위가 현재 실행 중인 스레드의 우선 순위보다 높다면 이를 선점해주는 함수이다.
/* threads/thread.c */

// 현재 실행 중인 스레드와 레디 리스트의 가장 앞 스레드의 우선 순위를 확인해 스케줄
void
schedule_by_priority () {
	struct thread * curr = thread_current();

	if (!list_empty(&ready_list)){
		struct thread *highest_priority_thread = list_entry(list_front(&ready_list), struct thread, elem);
		if (curr->priority < highest_priority_thread->priority)
			thread_yield();
}
  • 2주차 과제를 해결하면서 test case를 실행하는데에 있어 위의 코드가 문제가 되었다.

  • 결론부터 이야기하면, 위의 코드를 아래와 같이 수정해주면 해결된다.

/* threads/thread.c */

// 현재 실행 중인 스레드와 레디 리스트의 가장 앞 스레드의 우선 순위를 확인해 스케줄
void
schedule_by_priority () {
	struct thread * curr = thread_current();

	if (!list_empty(&ready_list)){
		struct thread *highest_priority_thread = list_entry(list_front(&ready_list), struct thread, elem);
		if (curr->priority < highest_priority_thread->priority)
			if (!intr_context()){
				thread_yield();
			}
			else{
				intr_yield_on_return();
			}
	}
}

🛠️ 위와 같이 수정해주어야 했던 이유?

  • 우선, thread_yield() 함수를 까보면 코드가 아래와 같다.

/* Yields the CPU.  The current thread is not put to sleep and
   may be scheduled again immediately at the scheduler's whim. */
void
thread_yield (void) {
	struct thread *curr = thread_current ();
	enum intr_level old_level;
	ASSERT (!intr_context ());

	old_level = intr_disable ();
	if (curr != idle_thread)
		list_insert_ordered(&ready_list, & curr->elem, cmp_thread_priority, NULL); //우선순위 기준으로 삽입
		// list_push_back (&ready_list, &curr->elem);
	do_schedule (THREAD_READY);
	intr_set_level (old_level);
}
  • 위의 ASSERT (!intr_context()); 문에서 thread_yield()interrupt_context()가 아닐 때만 실행되도록 해주고 있는데, 2주차 test case를 실행하던 중 아래와 같은 문제가 발생했다.

  • 위의 ASSERT (!intr_context()); 문에 걸려 Kernel PANIC이 발생했다. 인터럽트 상황에 해당 함수가 호출 되었다는 이야기이다.

  • backtrace를 찍어보니, 인터럽트 상황에 schedule_by_priority() 함수 실행되면서 thread_yield()가 호출되고, 이에 따라Kernel PANIC이 발생한 모양이었다.

  • 문제를 해결하기 위해, if 조건 분기를 이용 해 인터럽트 상황에는 thread_yield()가 아니라 intr_yield_on_return() 함수가 호출 되도록 수정해주었을 뿐이다.

    ** intr_yield_on_return() 함수는 인터럽트 핸들러의 실행이 끝난 후 thread의 스케줄링이 이루어지도록 하는 함수이다.


01. Passing the arguments and creating a thread

* Argument Parsing에 대해

  • 커맨드 라인에 ./echo x y z와 같이 명령을 입력했다고 생각해보자. 위의 명령어 중 echo는 파일(프로그램) 이름, x,y,z는 각각 이 echo라는 파일을 실행하는데 전달 되어야 할 인자(Argument)가 될 것이다.

  • 우리는 이렇게 커맨드 라인 명령어가 들어왔을 때, User Stack에 올리기 전에 이 명령어를 각각의 토큰으로 Parsing 해주는 작업을 해주어야 한다.

  • 이러한 Tokenizing 작업은 아래와 같이 strtok_r() 함수를 통해 진행할 수 있다.

01-1. process_create_initd()

  • 우선, process_create_initd() 함수에서 커맨드 라인 명령을 parsing 한 후 맨 앞 토큰(명령어에 해당)을 thread_create()로 전달한다.
/* userprog/process.c */
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;
	strlcpy (fn_copy, file_name, PGSIZE);

	/* file_name을 parsing해서 맨 앞 토큰을 thread_create()에 인자 전달 */
	char *save_ptr;
	strtok_r(file_name," ",&save_ptr);

	/* Create a new thread to execute FILE_NAME. */
	tid = thread_create (file_name, PRI_DEFAULT, initd, fn_copy);
	if (tid == TID_ERROR)
		palloc_free_page (fn_copy);
	return tid;
}
  • ex) process_create_initd()에 인자로 전달 되어 들어온file_name"my program -arg1 -arg2"와 같다고 가정해보자.

    1. fn_copyfile_name의 원본을 복사한다. 즉, my program -arg1 -arg2가 된다.

    2. file_namestrtok_r(file_name, " ", &save+ptr);를 거쳐 가장 첫 번째 토큰 "my_program"만 남게 된다.
      이 내용이 thread_create()로 전달되어, thread의 이름이 이 값이 된다.

    3. fn_copythread_create()에 전달된다. thread_create()로 실행되는 각 스레드는 initd()를 실행하게 되는데, 이 때 이 fn_copyinitd() 안의 process_exec()으로 전달되어 사용된다.

01-2. process_exec()

  • 위의 process_create_initd()를 통해 스레드를 생성하고, 이 스레드 안에서 실행되는 initd()를 거쳐 process_exec()으로 들어온다.

  • 앞의 과정에서 사용자 프로그램을 적재할 스레드를 생성하고, 스케줄링 한 뒤 사실상 사용자 프로그램은 이제 이 스레드에서 process_exec()을 통해 실행되는 것이다.

  • 입력받은 커맨드 라인이 인자 void *f_name으로 들어올 것이다.
    우리는 이제 이 커맨드 라인을 토큰 단위로 parsing해서 명령어와 각 인자들을 User Stack으로 올려주어야 한다.

  • 우선, 커맨드 라인을 parsing 하는 부분은 다음과 같이 작성할 수 있다.
    strtok_r() 함수를 반복적으로 호출해서 parse[]라는 배열 안에 file_nametoken으로 분할해 추가한다.

/* userprog/process.c */
int
process_exec (void *f_name) { // 커맨드 라인을 f_name으로 전달
	char *file_name = f_name; //f_name은 void* 이므로 이를 char*로 수정
	bool success;
	
    	...
	
    /* We first kill the current context */
	process_cleanup ();

	/* 인자 parsing */
	char *parse[64];
	char *token, *save_ptr;
	int count = 0;
	for (token = strtok_r(file_name," ",&save_ptr); token != NULL; token = strtok_r(NULL, " ", &save_ptr))
		parse[count++] = token;
    
    /* And then load the binary */
	success = load (file_name, &_if);

        
        ...

}
  • 커맨드 라인의 parsing 작업을 끝냈다면, 이제 User stack에 해당 내용들을 올려줄 차례다.

  • User stack에 커맨드 라인 토큰들을 올릴 때는 다음과 같이 커맨드 라인의 오른쪽 내용부터 추가하고, 스택 포인터를 내리는 식으로 진행한다.

    * User Stack에 관련된 내용은 다음 글에서 다뤄보자!

  • User stack에 parse[] 배열을 전달해서 내용을 적재하는 함수를 아래와 같이 작성하자.
/* userprog/process.c */

void
argument_stack(char **parse, int count, void **rsp){
	for (int i = count - 1; i > -1; i--)
	{
		for (int j = strlen(parse[i]); j>-1; j--)
		{
			(*rsp)--;
			**(char **)rsp = parse[i][j];
		}
		parse[i] = *(char **)rsp;
	}

	int padding = (int)*rsp % 8;
	for (int i = 0; i < padding; i++)
	{
		(*rsp)--;
		**(uint8_t **)rsp = 0;
	}

	(*rsp) -= 8;
	**(char ***)rsp = 0;

	for (int i = count-1; i>-1; i--)
	{
		(*rsp) -= 8;
		**(char ***)rsp = parse[i];
	}

	(*rsp) -= 8;
	**(void ***)rsp = 0;
}
  • 위의 함수를 process_exec()에서 호출해준다.
int
process_exec (void *f_name) { // 커맨드 라인을 f_name으로 전달
	char *file_name = f_name; //f_name은 void* 이므로 이를 char*로 수정
	bool success;

	...

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

	/* 인자 parsing */
	char *parse[64];
	char *token, *save_ptr;
	int count = 0;
	for (token = strtok_r(file_name," ",&save_ptr); token != NULL; token = strtok_r(NULL, " ", &save_ptr))
		parse[count++] = token;

	/* And then load the binary */
	success = load (file_name, &_if);
    
	/* User Stack에 명령어와 인자 적재 */
	argument_stack(parse, count, &_if.rsp);
	_if.R.rdi = count;
	_if.R.rsi = (char *)_if.rsp + 8;
	/* user stack을 16진수로 프린트 */
	hex_dump(_if.rsp, _if.rsp, USER_STACK - (uint64_t)_if.rsp, true);

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

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

위에서 hex_dump는 user_stack을 16진수로 출력해주는 함수이다. 이제 테스트 케이스를 돌려보면, 해당 작업을 수행했을 때 user stack의 상태를 16진수로 출력해줄 것이다.

  • 새로운 함수를 추가했으니, process.h에 프로토 타입 선언을 해주도록 하자.
/* userprog/process/h */
void argument_stack(char **parse, int count, void **rsp);

02. process_wait()

  • 위처럼 Argument Parsing 작업도 수행하고, User Stack에 적재하는 작업까지 끝냈다면 이제 테스트 케이스를 돌려볼 생각을 했을 것이다.
    이 때, 아직 해주어야 할 작업이 남아있다.

  • 우리가 사용자 프로그램을 실행할 때, 이 사용자 프로그램은 threads/init.cmain()에서 run_actions() 안의 run_task에서 돌아가게 되는데,
    init thread (우리가 OS를 켜면 가장 먼저 실행되는, 가장 하단에서 기본적인 동작을 수행하는 스레드)는 해당 함수 안에서 process_create_initd() 함수를 호출한 뒤 process_wait()를 호출해 사용자 프로그램이 끝날 때까지 기다린다.

/* threads/init.c */
  
/* Runs the task specified in ARGV[1]. */
static void
run_task (char **argv) {
	const char *task = argv[1];

	printf ("Executing '%s':\n", task);
#ifdef USERPROG
	if (thread_tests){
		run_test (task);
	} else {
		process_wait (process_create_initd (task));
	}
#else
	run_test (task);
#endif
	printf ("Execution of '%s' complete.\n", task);
}
  • 문제는 우리가 제공 받은 process_wait이 아래에서 보듯 이 모양 이꼴이기 때문에, 위의 그림처럼 사용자 프로그램이 종료되기 전에 그냥 끝나버린다.
    따라서, 사용자 프로그램도 뭘 해보기도 전에 끝나버리게 된다.
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 -1;
}
  • 아마 사용자 프로그램이 끝날 때까지 기다리는 내용은 추후에 작성하겠지만, 우리는 실행 결과를 확인해보고 싶으니 다음과 같이 thread_sleep()을 이용해 임시로 process_wait()을 작성 해보자.
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. */

	thread_sleep(100);

	return -1;
}

03. 실행 결과

✅ 2주차 User Program 부터는 테스트 케이스를 실행할 때, 다음과 같이 가상 디스크에 대한 명령어에 대해 이해하고 있으면 좋다.

  • make check를 이용해 테스트 케이스 전부를 테스트하는 경우에는 아래 과정을 skip할 수 있도록 구성 되어있지만, 테스트 케이스를 하나씩 돌려보고 싶은 경우에는 파일 디스크의 생성과 포맷 정도는 알고 있어야 한다.

  • 위에서 우리가 만들어준 내용을 테스트 해보고 싶다면 우선 다음과 같은 명령어를 입력해주면 된다.
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'

  • hex_dump를 통해 출력된 User stack 내용과, 입력된 커맨드 라인이 잘 출력되고 있는 것을 확인할 수 있다!

* 참고 자료 / 이미지 출처

profile
능동적으로 사고하고, 성장하기 위한. 🌱

0개의 댓글

관련 채용 정보