Week09 WIL

gitddabong·2022년 1월 11일
0

2주차 과제는 2가지를 진행했다.
1번 과제인 Argument Passing과
2번 과제인 System call에서 File descriptor 부분을 제외한 부분까지.

앞 부분 공부를 하다보니 뒷 부분까지 신경쓸 겨를이 없어서 지금까지 공부한 것이라도 놓치지 않으려고 선택과 집중을 했다.

Argument Passing

커맨드 라인에서 읽은 인자들을 이용해서 사용자 프로그램을 돌리기 위해 유저 스택에 인자들을 삽입하는 사전 작업을 진행한 프로젝트. 내가 이해한 바로는, 기존의 C언어의 메인 함수에도 argument를 인자로 받을 수 있는걸로 알고 있는데, pintOS위에서 동작하는 메인 함수로 넘겨 주기 전에 (즉, 이 작업은 pintOS 바깥에서 이루어짐) 파싱 및 유저 스택 적재를 진행한다. pintOS 내부에서 사용자 프로그램을 사용할 수 있게 밑준비를 해주는 작업이다.

/* Switch the current execution context to the f_name.
 * Returns -1 on fail. */
/* 현재 실행 컨텍스트를 f_name으로 전환합니다. 실패 시 -1 반환 */
int
process_exec (void *f_name) {
	char *file_name = f_name;	// f_name : 입력으로 받은 사용자 프로그램명 + 토큰 덩어리 문자열
	/* project 2. parsing */ 
	char *file_name_copy[48];
	// file_name_copy에 깊은 복사(메모리에 존재하는 값을 복사). strlen에 +1을 해주는 이유는 문자열의 끝인 \0이 strlen의 카운트에 포함되지 않으므로.
	memcpy(file_name_copy, file_name, strlen(file_name) + 1);

	bool success;

	/* 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을 사용할 수 없습니다.
	현재 스레드가 다시 예약되면 실행 정보를 멤버에게 저장하기 때문입니다.
	*/
	// 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 ();

	// 파싱 예시) /bin/ls -l foo bar
	/* project 2. parsing */ 
	char *token, *last;		// token : 파싱한 토큰 하나하나를 저장하는 변수, last : 파싱한 문자열(뒷 공백추가)의 다음 문자열
	int token_count = 0;
	char *arg_list[64];		// 토큰을 64개까지 받을 수 있는 리스트
	token = strtok_r(file_name_copy, " ", &last);	// 입력 맨 앞의 사용자 프로그램 이름까지 파싱. 리턴 값은 분리된 문자열의 시작 주소.
	char *tmp_save = token;
	arg_list[token_count] = token;	// 파싱한 사용자 프로그램 이름을 리스트에 추가

	// 다음 공백 문자열을 찾아서 그 문자열을 리스트에 저장
	while (token != NULL) {
		token = strtok_r(NULL, " ", &last);
		token_count++;
		arg_list[token_count] = token;
	}

	/* And then load the binary */
	// 여기서 _if의 rsp가 유저 스택 포인터로 초기화가 된다.
	success = load (tmp_save, &_if);

	argument_stack(arg_list, token_count, &_if);
	hex_dump(_if.rsp, _if.rsp, USER_STACK - _if.rsp, true);	// 스택 출력을 위한 출력문

	/* If load failed, quit. */ 
	palloc_free_page (file_name);	// process_create_initd 에서 file_name으로 페이지를 켜기 때문에 이 부분은 수정하면 안된다.
	if (!success)
		return -1;

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

strtok_r() 함수를 이용해 긴 문자열을 공백을 단위로 하나씩 잘라서 배열 안에 넣는 것이 가능했고 그렇게 자른 인자들을 하나씩 규칙에 맞게 삽입하기 위해 argument_stack() 이라는 함수를 만들어서 유저 스택에 적재하는 과정을 구현했다.

/* project 2. parsing */
void argument_stack(char **argv, int argc, struct intr_frame *if_) {
	/* insert argument's address */
	// argv : 사용자 프로그램명을 포함한 토큰 리스트, argc : 사용자 프로그램을 제외한 토큰의 개수, if_->rsp : 유저 스택의 포인터
	// if_->rsp는 load() 안의 setup_stack()에서 USER_STACK(유저 스택의 top)으로 값이 초기화됨.
	uintptr_t rsp = if_->rsp;

	// 0x4747ffed ~ 0x4747fffc
	char *argu_address[128];
	for (int i = argc - 1; i >= 0; i--) {	// 문자열 중에서 맨 뒤에 있는 문자열부터 반복문 시작
		int argv_len = strlen(argv[i]);		// 각 토큰들의 문자열 길이
		rsp = rsp - (argv_len + 1);	// 주소에서 문자열 길이만큼 빼는데, 이 뜻은 주소값이 높은곳부터 채운다는 뜻?
		/* store address seperately */
		memcpy(rsp, argv[i], argv_len + 1);	// 스택 포인터 위치에 문자열 깊은 복사
		argu_address[i] = rsp;			// argu_address에 각 토큰의 스택 포인터 위치 저장
	}

	/* insert padding for word-align */
	/* 단어 정렬을 위한 패딩 삽입 */
	/* 64bit 운영체제이므로 8byte 단위로 맞추기 위해 패딩 삽입 */
	// 정렬을 8byte단위로 해놓으면 포인터를 움직일 때 8byte씩만 움직여서 주소를 서치하면 되므로 빠른 속도로 처리 가능.

	// 0x4747ffe8
	while (rsp % 8 != 0) {
		rsp--;
		*(uint8_t *)(rsp) = 0;
	}

	/* insert address of strings including sentinel */
	/* 센티넬을 포함한 문자열의 주소 삽입 */
	// 인자를 3개 받았다면, 3개는 밑에서부터 채우고(memcpy), 마지막에는 0을 넣는다(memset)
	// 0을 채우는 것은 지금부터 주소값이 나온다고 명시해주는 역할

	// 0x4747ffc0 ~ 0x4747ffe0
	for (int i = argc; i >= 0; i--) {
		rsp = rsp - 8;
		if (i == argc)
			memset(rsp, 0, sizeof(char **));
		else
			memcpy(rsp, &argu_address[i], sizeof(char **));
	}

	/* fake return address */
	/* 함수 호출부의 다음 수행 명령어 주소 */
	// 0x4747ffb8
	rsp = rsp - 8;
	memset(rsp, 0, sizeof(void *));

	if_->R.rdi = argc;		// rdi에 인자의 갯수
	if_->R.rsi = rsp + 8;	// rsi에 첫 인자를 가리키는 주소의 주소

	if_->rsp = rsp;	// 현재 스택 포인터의 위치로 업데이트

	// 여기까지 진행하면 thread가 해당 file을 실행할 준비가 완료.
}

System Call

x86-64 호출 규칙 (깃북 내용)

syscall_handler() 가 제어권을 얻었을 때,

rax에 시스템 콜 호출 번호가 들어 있고,

rdi, rsi, rdx, r10, r8, r9 순으로 인수가 들어옴

얘들은 아까전에 argument passing에서 다룬 친구들과 전혀 다름.

														 +----------------+
stack pointer --> 0x4747fe70 | return address |
                             +----------------+
RDI: 0x0000000000000001 | RSI: 0x0000000000000002 | RDX: 0x0000000000000003

규칙이기 때문에 반드시 이러한 폼으로 들어온다고 생각.

이젠 Argument passing에서 rdi에 argc(인자의 개수), rsi에 argv[0]의 주소를 넣었었는데,

Argument passing에서 수행한 작업은

아직 pintOS가 실행되었지만 아직 pintOS의 main문에 인자를 전달하지 않은 상태이고

커맨드 라인에서 명령을 읽고 사용자 프로그램 인자를 파싱해서 pintOS의 main문에 넘겨주는 과정이다. 즉, pintOS 바깥에서 일어난 일.

시스템 콜이 진행되는 이 시점은 pintOS 내부에서 일어나는 일이고, 지금은 위의 규칙으로 rdi, rsi, rdx가 맵핑되어서 시스템 콜을 호출하는 작업을 수행한다.

시스템 콜

사용자 프로그램이 운영 체제의 기능을 일부 사용할 수 있게 사용자 프로그램에서 호출할 수 있는 함수.

사용자 프로그램에서 운영체제와 같은 권한을 줘버리면 어떤 이상한 짓을 할 지도 모르니 운영 체제 입장에서 ‘이 정도까지는 괜찮다’ 라고 허용해준 기능들을 일부 사용할 수 있다. 사용자 프로그램에서 시스템 콜을 호출하면 운영 체제에 트랩(소프트웨어의 인터럽트)가 걸리고, CPU의 점유권이 사용자 프로그램에서 운영 체제로 넘어가며, 시스템 콜을 모두 수행한 후에는 다시 사용자 프로그램으로 CPU의 점유권을 넘겨준다.

Halt()

pintOS를 종료시키는 시스템 콜로, 교착 상태 상황 등 일부 정보를 잃을 수도 있으므로 거의 사용하지 않는다.

Exit()

현재 사용자 프로그램을 종료하고 커널로 복귀.

현재 스레드의 부모가 wait인 경우 자식 스레드는 exit() 진행.

Create()

rdi에 담긴 값을 이름으로 하는 파일을 생성.

Remove()

인자로 받은 rdi값과 같은 이름을 가지는 파일 삭제.

Fork()를 왜 하는가?

= 자식 프로세스를 왜 복제하는가?

  1. 프로세스의 생성 속도가 빠르다

    하드 디스크로부터 프로그램을 새로 가져오지 않고 기존 메모리에서 복사하기 때문에 자식 프로세스의 생성 속도가 빠르다. 마치 크롬 브라우저 프로그램을 하나 더 켜는 것과 탭 하나를 늘리는 것이 속도가 다른 것처럼.

  2. 추가 작업 없이 자원을 상속할 수 있다.

    부모 프로세서가 사용하던 모든 자원을 추가 작업 없이 자식 프로세스에 상속할 수 있다.

    예를 들어, 부모 프로세스가 파일 A를 사용하기 위해 초기화했다면 자식 프로세스는 파일 A를 바로 사용할 수 있다.

    프로세스 A와 비슷한 작업을 할 새 프로세스가 필요하다면, 확실히 새 프로세스를 처음부터 초기화해서 만드는 것보다 훨씬 편하다.

  3. 시스템 관리를 효율적으로 할 수 있다.

    부모 프로세스와 자식 프로세스가 자식 프로세스 구분자와 부모 프로세스 구분자로 연결되어 있기 때문에, 자식 프로세스를 종료하면 자식이 사용하던 자원을 부모 프로세스가 정리할 수 있다. 프로세스를 종료하면 프로세스가 사용하던 메모리 영역, 파일, 하드웨어를 잘 정리해야 되는데, 이러한 정리를 부모 프로세스에 맡김으로써 시스템이 효율적으로 관리된다.

    이 부분은 이해를 잘 못했다.

process_fork()

tid_t
process_fork (const char *name, struct intr_frame *if_ UNUSED)
  • name : rdi 를 인자로 받음. rdi는 시스템 콜에서 파일 이름이나 스레드 이름으로 사용
  • if : 현재 스레드의 인터럽트 프레임.

주요 동작

  • 현재 프로세스를 ‘name’이라는 이름의 스레드로 복제.
  • tid에 자식 프로세스 스레드 id가 반환.
  • __do_fork 에서 부모의 실행 컨텍스트가 자식에게 복사

thread_create()

tid_t
thread_create (const char *name, int priority,
		thread_func *function, void *aux)
  • name : 위에서 받은 rdi 인자. 이 name을 가진 스레드를 생성
  • priority : 우선 순위를 default(31)로 받음
  • function : __do_fork()를 인자로 받았는데, 이는 사용자 스택에 쌓이고 나서 그 후에 실행
  • aux : 부모 스레드를 인자로 받음.

주요 동작

  • list_push_back()으로 부모 스레드의 자식 리스트에 t 추가
  • 자식 스레드의 intr_frame에 값 맵핑 rip = kernel_thread()(커널 스레드 호출)
    • rip : 프로그램 카운터

      rdi = __do_fork() 함수

      rsi = 부모 스레드 주소

      .

      .

      .

자식 프로세스를 생성까지는 했고 아직 부모의 실행 컨텍스트는 복사되지 않은 상태

즉 __do_fork()가 실행되지 않은 상태

do_fork()는 ready_list에 들어 있는 자식 스레드가 CPU를 점유하는 순간 function call이 발생해서 부모의 컨텍스트를 복사해옴.

__do_fork()

static void
__do_fork (void *aux)
  • aux : 부모 스레드

부모 스레드의 인터럽트 프레임과 페이지 테이블을 복사해오는 과정.

내부의 pml4_for_each()duplicate_pte 를 이용해 부모의 메모리 영역을 자식으로 복사한다.

중간중간에 프로그램의 안정성을 위해 세마포어를 가져오고 반납하거나 tss를 커널 영역으로 옮겨주는 작업 등이 있지만 상세하게 적지는 않겠다.

여기까지 진행하면, 부모 스레드와 pid만 다르고 내용이 완전히 똑같은 자식 스레드가 생성된다.

Exec()를 왜 하는가?

exec() : 프로세스를 그대로 둔 채 내용만 바꾸는 기능.

새 프로세스를 만드는 것보다 fork()로 자식 프로세스를 만드는 것이 비용 절감에 더 효율적이라는 것을 알고 있다. 같은 맥락으로, 새 프로세스를 만들어서 처음부터 메모리 할당하고, 필드 맵핑하고... 하는 것 보다, 프로세스 1개를 fork해서 자식 프로세스를 나눈 후에 그 프로세스에 내가 돌리고 싶은 사용자 프로그램을 맵핑하면 되는 것이다.

exec이 호출되는 경우는 총 2가지로,

  1. 스레드가 처음 생성되고 프로그램을 스레드에 적재하는 데 사용하는 경우
  2. 스레드를 fork해서 생긴 자식 스레드에 적재하고 싶은 사용자 프로그램을 적재하는 경우

1번의 경우 모든 스레드가 겪는 기본적인 과정이다.

2번의 경우가 조금 다른데, 사용자 프로그램을 돌리기 위해 사용한다.

새로운 프로세스를 만들어서 사용자 프로그램을 적재하려고 하면, 그 프로세스를 처음부터 끝까지 초기화 및 데이터 영역 할당 작업을 해주어야하고, 그 과정이 길다. 그러나 기존 프로세스에서 fork로 자식 프로세스를 생성하고 그 프로세스에 exec로 사용자 프로그램을 적재하는 방식을 사용하면 새 프로세스를 만드는 과정을 간단하게 할 수 있고, 자식 프로세스가 부모에 종속적이므로 메모리 관리도 쉽다고 한다. (이 부분은 아직 이해를 못했다.)

Wait()

자식 스레드가 종료될 때까지 부모 스레드가 대기하게 만드는 시스템 콜이다.

이 동작은 세마포어로 구현한다.

int
process_wait (tid_t child_tid UNUSED) {
	.
	.
	.

	sema_down(&child->wait_sema);	
	
	int exit_status = child->exit_status;	// 부모 스레드에게 자식 스레드 상태를 전달하기 위한 변수
	list_remove(&child->child_elem);	// 자식 리스트에서 삭제
	sema_up(&child->free_sema);			// free_sema 올림. 막혀 있던 free를 해제.
	
	return exit_status;	// process_exit()에서 자식 스레드가 종료되었으므로 그 상태를 반환
}

이 함수는 exit()과 같이 봐야 한다.

여기서 사용하는 세마포어 값들은 모두 0으로 초기화되어있는데,

부모 스레드는 wait()으로 들어와서 sema_down을 만나면 블록 상태로 바뀐다.

void
process_exit (void) {	
	// Wake up blocked parent
	// 블록된 부모 깨우기
	sema_up(&curr->wait_sema);
	
	// Postpone child termination until parents receives its exit status with 'wait'
	// 부모가 'wait'로 종료 상태를 받을 때까지 자식 종료 연기
	sema_down(&curr->free_sema);
}

이후 자식 스레드가 exit()으로 들어가서 종료되어야 wait_sema를 up해주면서 다시 부모 스레드가 활성화되고 자식 리스트에서 아까 exit()된 자식을 제거하고, 자식 스레드의 상태를 받아온다.

profile
성장형 개발자 gitddabong

0개의 댓글