본문 바로가기

[pintOS] 유저 프로그램 실행 흐름

@정소민fan2025. 5. 17. 16:51

Argument Passing을 통해 유저 프로그램의 실행 흐름을 간략하게나마 알 수 있었다.. 그럼 처음부터 한번 따라가보자

맨 처음에는 커널 부팅을 해줘야한다. 당연히 커널이 유저 프로그램을 실행시켜줄 테니까 !!

 

참가로 2번째 프로젝트의 첫번째 과제 테스트를 모두 통과한 함수이다. 원본 pintos 코드와 다를 수 있음에 주의하라.

하지만 과제에 대한 코드를 따로 설명해두지는 않겠다. 그건 다른 포스팅에서 ~

GPT의 코드 흐름 요약

threads/init.c

main(void)

...
	/* 11. 부팅 완료 메시지 출력 */
	printf("Boot complete.\n");
	
    /* 12. 커맨드라인 인자로 받은 실행 명령 수행 (예: run alarm-multiple) */
	run_actions(argv);

	/* 13. 옵션에 따라 자동 종료 */
	if (power_off_when_done)
		power_off();
	/* 14. main()은 NO_RETURN → 종료 시 명시적으로 thread_exit 호출 */
	thread_exit();
   }

여기서 run_actions(argv)을 보자. 커널 부팅이 완료된 후, 커맨드라인 인자로 받은 argv를 run_actions 함수에 넘겨준다

run_actions(char **argv)

	...
    	struct action
	{
		char *name;					   /* Action name. */
		int argc;					   /* # of args, including action name. */
		void (*function)(char **argv); /* Function to execute action. */
	};

	/* Table of supported actions. */
	static const struct action actions[] = {
		{"run", 2, run_task},
        ...
}

이 함수는 커맨드라인에서 입력받은 명령을 차례로 해석하고, 각각의 action(run, ls, cat, rm)에 따라 작업을 실행하는 함수이다.

만약 넘겨받은 argv가 run으로 실행한다면 run_task() 함수를 실행시켜준다.

 

action 구조체는 무엇일까? name에는 우리가 실행할 run, ls, cat 같은 액션들이 들어가있다.

argc에는 이 액션이 요구하는 인자의 갯수가 들어가 있다.

마지막 인자인 argv는 이 액션에 따라 실행해야 하는 함수의 이름이 들어간다. 함수 포인터를 사용하는 듯 하다.

...    
    /* Table of supported actions. */
    static const struct action actions[] = {
        {"run", 2, run_task},
    #ifdef FILESYS
        {"ls", 1, fsutil_ls},
        {"cat", 2, fsutil_cat},
        {"rm", 2, fsutil_rm},
        {"put", 2, fsutil_put},
        {"get", 2, fsutil_get},
    #endif
        {NULL, 0, NULL},
    };
 ...

 

이 구조체 배열들을 해석해보면, run은 2개의 인자가 필요하고, run_task 함수를 실행해야 한다.

ls는 1개의 인자가 필요하고, fsutil_ls 함수를 실행해야 한다 !

 

이 함수 끝이 while문도 궁금하다. 따라가보자

...
	while (*argv != NULL)
	{
		const struct action *a;
		int i;

		/* Find action name. */
		for (a = actions;; a++)
			if (a->name == NULL)
				PANIC("unknown action `%s' (use -h for help)", *argv);
			else if (!strcmp(*argv, a->name))
				break;

		/* Check for required arguments. */
		for (i = 1; i < a->argc; i++)
			if (argv[i] == NULL)
				PANIC("action `%s' requires %d argument(s)", *argv, a->argc - 1);

		/* Invoke action and advance. */
		a->function(argv);
		argv += a->argc;
	}
 }

우리의 핀토스는 여러개의 명령을 받아서 순차적으로 실행할 수 있다. 예를 들어 다음과 같은 명령이 커맨드 라인에 들어온다고 하자

pintos -q run alarm-multiple run priority-donate

그럼 argv는 다음과 같이 파싱되어 들어오게 된다

argv = {"run", "alarm-multiple", "run", "priority-donate", NULL}

 

Find action name이라 주석이 달려있는 부분부터 보자

    /* Find action name. */
    for (a = actions;; a++)
        if (a->name == NULL)
            PANIC("unknown action `%s' (use -h for help)", *argv);
        else if (!strcmp(*argv, a->name))
            break;

이는 단순히 만들어진 action 구조체 배열을 순회하며 인자로 받은 argv의 액션 이름을 찾는 과정이다. 만약 찾게 된다면 break문을 통해 빠져나오게 된다. 그러면 a는 break를 통해 멈춘 액션 구조체의 포인터를 가지고 있을 것이다.

 

Check for required arguments 부분을 보자

    /* Check for required arguments. */
    for (i = 1; i < a->argc; i++)
        if (argv[i] == NULL)
            PANIC("action `%s' requires %d argument(s)", *argv, a->argc - 1);

해당 액션에 해당하는 액션 구조체의 argc를 체크한다

예를 들어 run alarm-multiple을 확인할 때, run 액션의 argc는 2이므로 argv[1], 즉 alarm-multiple만 확인하고 넘어간다.

 

마지막으로 invoke action and advance 부분을 확인하자

    ...
    /* Invoke action and advance. */
    a->function(argv);
    argv += a->argc;
    ...

a에 함수 포인터로 있던 함수를 실행시킨다. 지금은 run 액션 구조체이므로 run_task가 실행될 것이다.

이후 argv 포인터를 argc만큼 이동시킨다. 현재 포인터는 argv = {"run", "alarm-multiple", "run", "priority-donate", NULL} 이 부분에서 0번째 인덱스 "run"일 것이다. run 액션 구조체는 argc가 2이였다. 따라서 2번째 인덱스 "run"을 가리키게 된다.

 

이 함수를 해석해보면 우리가 리눅스에 일상적으로 사용하는 ls, cat, mkdir 같은 명령어들이 어떤 식으로 동작하는지 알 수 있을 것 같다. 물론 다른 OS라 다르긴 하겠지만

 

이제 run 액션 구조체가 가지고 있던 run_task가 어떤 일을 하는지 파헤쳐보자

run_task(char **argv)

const char *task = argv[1];
...
#ifdef USERPROG
	if (thread_tests)
	{
		run_test(task);
	}
	else
	{
		process_wait(process_create_initd(task));
	}
...

thread_tests는 우리가 test 인자를 추가해서 사용할 때 true로 설정되는 전역 변수다. 우리는 사용자 프로그램이 어떻게 실행되는지 흐름을 따라가고 있으니 false라고 가정하고 시작하겠다.

먼저 task는 넘겨받은 argv에서 첫번째 인자를 복사한다. run alarm-multiple 에서 run은 argv[0]이고, task는 argv[1]이니까 우리가 필요한 건 실행할 파일 이름인 argv[1]이다

process_create_initd(task) 는 initd 스레드에 task를 새로 생성하겠다는 의미이다. 이 함수는 새로 생성된 스레드의 tid를 반환한다.

process_wait(tid)는 tid를 가진 자식 프로세스가 종료될 때까지 부모 프로세스를 대기시키겠다는 의미이다.

여기서의 부모 프로세스는 pintOS를 동작시키고 있는 메인 스레드이다.

 

다음으로 process_create_initd를 따라가보자.

userprog/process.c

process_create_initd(const char *file_name)

이 함수는 run 명령이 한번 호출될때마다 한번씩 호출된다. 위 예시에서는 run을 두번 사용했으니 두번 사용되겠지?

이 함수는 run_task에서 argv[1], 즉 파일 이름을 인자로 받는다.

    	char *fn_copy;
	tid_t tid;
    ...
    	/* 이 코드를 넣어줘야 thread_name이 file name이 됩니다  */
	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;
}

위쪽 코드는 놔두고, 아래쪽부터 보자. strtok_r은 따로 추가해준 함수인데, 인자들이 파일명과 따로 파싱되지 않고 하나의 문자열로 들어와서 추가해뒀다. 이 작업을 해주지 않으면 thread_name이 'args-single onearg' 처럼 파일명+인자가 되어버린다. 그럼 테스트 실패다..

왜 하나의 문자열로 들어오는지 이유는 정확히 모르겠다.. 이 글을 보는 당신이 알아보자 !

 

먼저 thread_create 함수 원형을 보자

tid_t thread_create(const char *name, int priority, thread_func *function, void *aux);
  • name : 새로 만들 스레드의 이름
  • priority : 우선순위
  • *function : 새 스레드가 시작할 때 처음으로 실행할 함수
  • *aux : function에 인자로 전달될 포인터

이러면 해석이 된다. 스레드를 만들고 initd에 fn_copy를 인자로 주고 실행하라고 시킨다 !!

fn_copy는 file_name을 복사한 문자열이다. 레이스 컨디션을 막기 위해 복사해서 사용한다고 한다. 또한, 우리는 파일명과 인자를 같이 넘겨줘야 하기에 strtok_r을 호출하기 전에 미리 복사해둘 필요가 있다.

 

initd(void *f_name)

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();
}

바로 process_exec로 이동해보자. #idfdef VM은 3번째 과제에서 사용될 것 같다.

 

process_exec(void *f_name)

/* 현재 실행 컨텍스트를 f_name으로 전환합니다.
 * 실패 시 -1을 반환합니다. */
int process_exec(void *f_name)
{
	char *file_name = f_name;
	char cp_file_name[MAX_BUF];
	memcpy(cp_file_name, file_name, strlen(file_name) + 1);
	bool success;

	/* intr_frame을 thread 구조체 안의 것을 사용할 수 없습니다.
	 * 이는 현재 스레드가 재스케줄될 때,
	 * 그 실행 정보를 해당 멤버에 저장하기 때문입니다. */
	struct intr_frame _if;
	_if.ds = _if.es = _if.ss = SEL_UDSEG;
	_if.cs = SEL_UCSEG;
	_if.eflags = FLAG_IF | FLAG_MBS;

	/* 현재 컨텍스트를 제거합니다. */
	process_cleanup();

	/* 그리고 이진 파일을 로드합니다. */
	ASSERT(cp_file_name != NULL);
	success = load(cp_file_name, &_if);

	/* 로드 실패 시 종료합니다. */
	palloc_free_page(file_name);
	if (!success)
		return -1;

	hex_dump(_if.rsp, _if.rsp, USER_STACK - (uint64_t)_if.rsp, true);
	/* 프로세스를 전환합니다. */
	do_iret(&_if);
	NOT_REACHED();
}

이 함수는 initd에서 f_name을 인자로 받고 있다. f_name에는 "alarm-single onearg" 와 같이 인자와 파일명을 같이 받아오고 있다. file_name도 다른 곳에서 사용될 가능성이 충분하니 일단 cp_file_name으로 복사해두었다.

	/* intr_frame을 thread 구조체 안의 것을 사용할 수 없습니다.
	 * 이는 현재 스레드가 재스케줄될 때,
	 * 그 실행 정보를 해당 멤버에 저장하기 때문입니다. */
	struct intr_frame _if;
	_if.ds = _if.es = _if.ss = SEL_UDSEG;
	_if.cs = SEL_UCSEG;
	_if.eflags = FLAG_IF | FLAG_MBS;

또 인터럽트 프레임이 나오는데... 이 인터럽트 프레임은 process_exec가 실행되는 동안에만 살아있다. 애초에 인터럽트 프레임 함수가 인터럽트, 시스템 콜, 유저진입 등 그 순간에만 쓰는 스냅샷이라고 한다.

여기서 세그먼트들과 플래그들을 설정하는데, 이는 유저 모드에서 사용하기 위한 값들이라고 한다.

	/* 현재 컨텍스트를 제거합니다. */
	process_cleanup();

initd 스레드가 처음 생길 때, 그러니까 thread_create를 실행할 때는 아직 커널 스레드이다. 주소 공간, 페이지 테이블, 파일 디스크립터 등이 그에 맞게 세팅되어 있다. 이제 이 스레드에 유저 프로그램을 세팅해야 하니까 위 정보들을 싹 다 지워주는 것이다.

	/* 그리고 이진 파일을 로드합니다. */
	ASSERT(cp_file_name != NULL);
	success = load(cp_file_name, &_if);

이 코드에서 해당 파일을 로드하고, 그 결과를 success에 저장한다.

과제에서는 load()내에서 파일명과 인자들을 파싱해야하고, 스택에 인자들을 쌓아줘야 한다. load 내부 로직은 나도 이해를 잘 못해서... 나중에 시간이 되면 확인해보자

...
	/* 로드 실패 시 종료합니다. */
	palloc_free_page(file_name);
	if (!success)
		return -1;

	hex_dump(_if.rsp, _if.rsp, USER_STACK - (uint64_t)_if.rsp, true);
	/* 프로세스를 전환합니다. */
	do_iret(&_if);
	NOT_REACHED();
}

로드가 실패했을 경우, -1을 리턴하며 종료한다.

hex_dump는 과제를 위한 디버깅 함수이다.

이후 do_iret(&_if)를 통해 프로세스를 전환한다.

_if는 아까 load 내에서도 인자로 받았었는데, 유저 프로세스로 전환하기 위한 정보들이 들어가 있다.

do_iret 내에서는 _if 내의 정보들을 이용해 레지스터 정보, CPU 상태 등을 세팅하고, 유저 모드로 점프하게 된다. 이후 커널 모드로 절대로 전환되지 않는다! 그래서 NOT_REACHED가 마지막에 있는듯 하다.

do_iret 내부는 어셈블리어로 작성되어있는데 해석이 너무 힘들다 ㅠ

 

이렇게 유저 프로그램 실행 흐름을 따라가보았는데, 나름 유익하다. 디버거로 한번씩 해보는걸 추천한다.

 

정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차