[SW사관학교 정글] Week09. PintOS User Program

Youngeui Hong·2023년 10월 11일
0

SW사관학교 정글 7기

목록 보기
10/16

💚 들어가며

이번 주차에는 PintOS에서 User Program이 돌아갈 수 있도록 구현하는 작업을 했다.

이 과정을 통해 User Program이 OS에 어떻게 로드되는지, 프로세스는 어떻게 동작하는지, System Call을 통해 User Program과 커널이 어떻게 상호작용할 수 있는지에 대해 공부할 수 있었다.

🔀 User Program Flow


🤝 Argument Passing

main 함수는 프로그램의 진입점인데, 프로그래밍 언어에 따라 main 함수에 파라미터를 전달하는 것이 허용되는 경우가 있다. 이 파라미터는 프로그램 실행 시에 사용자가 프로그램에 전달하는 값이며, 프로그램의 동작을 제어하는 데 사용된다. Java를 예를 들어 살펴보면 public static void main(String args[])와 같은 형태로 args를 통해 원하는 옵션을 전달할 수 있다. args에 들어갈 값은 프로그램을 실행할 때 command line arguments를 덧붙여서 지정할 수 있다.

$ myprogram.exe -input file.txt -output result.txt

이번 주의 첫 번째 과제는 User Program을 실행할 때 Command line으로 들어온 arguments를 PintOS에게 전달하는 것이었다. PintOS에서 User Program의 진입점은 _start 함수이다.

void
_start (int argc, char *argv[]) {
    exit (main (argc, argv));
}

📣 x86-64 Function Call Convention

PintOS의 Arguement Passing은 x86-64 Function Call Convention에 맞춰서 구현해야 한다. 따라서 Arguement Passing을 살펴보기에 앞서 x86-64 Function Call Convention에 대해 알아보자.

  1. User Program은 정수 레지스터를 사용해서 파라미터를 전달하는데, 파라미터를 %rdi, %rsi, %rdx, %rcx, %r8, %r9 순으로 레지스터에 담는다.

  2. 함수를 호출한 곳에서는 다음 인스트럭션의 주소, 즉 리턴할 주소를 스택에 push하고 호출된 함수의 첫 번째 인스트럭션으로 jump한다.
    👉🏻 이게 x86-64의 CALL 인스트럭션

  3. 호출된 함수가 실행된다.

  4. 만약 호출된 함수에 리턴값이 있으면 이를 RAX 레지스터에 저장한다.

  5. 호출된 곳은 스택으로부터 리턴할 주소를 pop해서 그 주소로 jump한다.
    👉🏻 이게 x86-64의 RET 인스트럭션

👀 Argument Passing의 과정

Pintos의 Argument Passing 과정은 아래와 같다.

  1. process_exec() 함수의 인자로 주어진 명령어를 공백을 기준으로 쪼갠다. 첫 번째 인자는 파일 이름이고, 나머지는 argument이다. 예를 들어 process_exec("grep foo bar")와 같이 함수가 실행되면 grep 파일을 foobar를 인자로 해서 실행하겠다는 의미이다.

  2. 1번 과정을 통해 쪼개진 argument들을 스택에 쌓는다.

  3. 2번 과정에서 쌓인 argument들의 주소를 스택에 쌓는다. 이 때 오른쪽에 위치한 argument의 주소부터 스택에 쌓아서 argv[0]이 스택의 가장 상단에 위치할 수 있도록 한다.

  4. %rsiargv[0]의 주소를 넣어주고, %rdiargc 값을 넣어준다.

  5. 스택에 fake return address로 0을 넣어준다.

💻 load

위의 Argument Passing을 코드로 구현하면 아래와 같다.

static bool load(const char *file_name_and_arg, struct intr_frame *if_)
{
	struct thread *t = thread_current();
	struct ELF ehdr;
	struct file *file = NULL;
	off_t file_ofs;
	bool success = false;
	int i;
	uint64_t argc = 0;

	char *file_name;

	char *token, *save_ptr;
	char *argv[30];
	int total_bytes = 0;
	void *destination;
	char *stack_addr[30];

	// 1. Break the command into words
	for (token = strtok_r(file_name_and_arg, " ", &save_ptr); token != NULL; token = strtok_r(NULL, " ", &save_ptr))
	{
		argv[argc] = token;
		total_bytes += (strlen(token) + 1); // 널 문자 포함해서 바이트 수 세기
		argc++;
	}

	file_name = argv[0];

	/* Allocate and activate page directory. */
	t->pml4 = pml4_create();
	if (t->pml4 == NULL)
		goto done;
	process_activate(thread_current());

	/* Open executable file. */
	file = filesys_open(argv[0]);
	if (file == NULL)
	{
		printf("load: %s: open failed\n", file_name);
		goto done;
	}

	/* executable file을 편집하지 못하도록 하기 */
	thread_current()->exe = file;
	file_deny_write(file);

	/* Read and verify executable header. */
	if (file_read(file, &ehdr, sizeof ehdr) != sizeof ehdr || memcmp(ehdr.e_ident, "\177ELF\2\1\1", 7) || ehdr.e_type != 2 || ehdr.e_machine != 0x3E // amd64
		|| ehdr.e_version != 1 || ehdr.e_phentsize != sizeof(struct Phdr) || ehdr.e_phnum > 1024)
	{
		printf("load: %s: error loading executable\n", file_name);
		goto done;
	}

	/* Read program headers. */
	file_ofs = ehdr.e_phoff;
	for (i = 0; i < ehdr.e_phnum; i++)
	{
		struct Phdr phdr;

		if (file_ofs < 0 || file_ofs > file_length(file))
			goto done;
		file_seek(file, file_ofs);

		if (file_read(file, &phdr, sizeof phdr) != sizeof phdr)
			goto done;
		file_ofs += sizeof phdr;
		switch (phdr.p_type)
		{
		case PT_NULL:
		case PT_NOTE:
		case PT_PHDR:
		case PT_STACK:
		default:
			/* Ignore this segment. */
			break;
		case PT_DYNAMIC:
		case PT_INTERP:
		case PT_SHLIB:
			goto done;
		case PT_LOAD:
			if (validate_segment(&phdr, file))
			{
				bool writable = (phdr.p_flags & PF_W) != 0;
				uint64_t file_page = phdr.p_offset & ~PGMASK;
				uint64_t mem_page = phdr.p_vaddr & ~PGMASK;
				uint64_t page_offset = phdr.p_vaddr & PGMASK;
				uint32_t read_bytes, zero_bytes;
				if (phdr.p_filesz > 0)
				{
					/* Normal segment.
					 * Read initial part from disk and zero the rest. */
					read_bytes = page_offset + phdr.p_filesz;
					zero_bytes = (ROUND_UP(page_offset + phdr.p_memsz, PGSIZE) - read_bytes);
				}
				else
				{
					/* Entirely zero.
					 * Don't read anything from disk. */
					read_bytes = 0;
					zero_bytes = ROUND_UP(page_offset + phdr.p_memsz, PGSIZE);
				}
				if (!load_segment(file, file_page, (void *)mem_page,
								  read_bytes, zero_bytes, writable))
					goto done;
			}
			else
				goto done;
			break;
		}
	}

	/* Set up stack. */
	if (!setup_stack(if_))
		goto done;

	/* Start address. */
	if_->rip = ehdr.e_entry;

	// argv 문자열 길이만큼 rsp를 내려서 스택 영역 확장
	if_->rsp -= ROUND_UP(total_bytes, 8);

	// 데이터를 쓰기 시작하는 지점
	destination = (void *)(USER_STACK);

	// 스택에 argv 작성
	for (int i = (argc - 1); i >= 0; i--)
	{
		destination -= (strlen(argv[i]) + 1);
		memcpy(destination, argv[i], strlen(argv[i]) + 1);
		stack_addr[i] = destination;
	}

	// word-align 고려해서 destination 재조정
	memset(if_->rsp, 0, destination - if_->rsp);
	destination = if_->rsp;

	// (argc + 2) * 8 byte만큼 rsp를 감소시켜서 스택 영역 확장
	if_->rsp -= 8 * (argc + 1);

	// argv[argc] ~ argv[0]의 주소를 스택에 작성
	for (int i = argc; i >= 0; i--)
	{
		destination -= 8;
		if (i == argc)
		{
			memset(destination, 0, 8);
		}
		else
		{
			__asm __volatile(
				/* Fetch input once */
				"movq %0, %%rax\n"
				"movq %1, %%rcx\n"
				"movq %%rcx, (%%rax)\n"
				: : "r"(destination), "r"(stack_addr[i]) : "memory");
		}
	}

	// 4. Point %rsi to argv (the address of argv[0]) and set %rdi to argc.
	if_->R.rsi = (uint64_t)if_->rsp;
	if_->R.rdi = argc;

	// fake return address를 스택에 푸시
	if_->rsp -= 8;
	destination -= 8;
	memset(destination, 0, 8);

	success = true;

done:
	/* We arrive here whether the load is successful or not. */
	if (!success)
	{
		file_close(file);
		exit(-1);
	}

	return success;
}

📲 System Call

👀 System Call이란?

이번 주차의 두 번째 과제는 exit, fork, exec 등 PintOS의 System Call을 직접 구현해보는 것이었다.

User Program이 돌아가다 보면 OS에 무언가 작업 요청을 보내야 하는 경우가 있다. System Call은 사용자 프로세스가 커널에게 요청을 보낼 수 있도록 인터페이스를 제공한다.

x86-64 아키텍처의 syscall 명령어는 사용자 모드에서 커널 모드로 전환하고 시스템 콜을 호출할 수 있도록 한다.

운영체제에는는 시스템 및 프로세스 간의 권한을 관리하기 위해 Protection Ring이라는 개념이 존재한다. x86-64 아키텍처의 경우 0부터 3까지 4개의 보호 링이 있는데, 0번 링이 가장 높은 권한을 나타내고, 3번 링이 가장 낮은 권한을 나타낸다. 이때 시스템 콜은 0번 링(커널 모드)으로 전환하여 커널의 권한을 활용한다.

💁🏻‍♀️ System Call의 작동 과정

System Call이 어떤 과정을 거쳐 작동할 수 있는지 살펴보자.

syscall_init()

syscall.c 파일을 보면 syscall_init() 함수가 있는데, 이 함수는 시스템 콜 관련 초기 설정을 해주고 있다.

MSR_LSTAR는 시스템 콜이 호출될 때 실행할 함수의 주소인데, 여기에 syscall_entry 함수의 주소를 넣어줘서 시스템콜이 호출되면 syscall_entry가 실행되도록 한다.

User Program의 시스템 콜 호출

User Program은 레지스터를 사용하여 시스템 콜 번호와 시스템 콜에 대한 파라미터를 전달한다. 아래 코드에서 볼 수 있듯이 %rax에는 시스템 콜 번호를 담고, %rdi, %rsi, %rdx, %r10, %r8, %r9에 차례대로 파라미터를 담아준다.

syscall-entry.S

커널 내부에서 syscall을 처리하는 과정은 syscall-entry.S를 보면 이해할 수 있다.

먼저 아래 코드를 보면 TSS(Task State Segment) 구조체 내에 저장된 값을 읽어서 %rsp로 로드하는 부분이 있는데, 이는 스택 포인터를 유저 스택에서 커널 스택으로 옮기는 코드로 유저 모드에서 커널 모드로 전환하게 된다.

다음으로 현재 유저 모드의 레지스터 상태를 백업해두는데, 이는 커널 모드에서 다시 사용자 모드로 돌아갈 때 필요한 정보이다.

그 다음에는 check_intr에서 인터럽트 상태를 system call이 호출된 시점의 상태로 복구하고, syscall_handler 함수를 호출하여 시스템 콜 처리가 이루어지도록 한다.

시스템 콜 처리가 이뤄지고 나면 커널 스택에 저장해놨던 유저 모드의 상태를 복원하고, sysretq 명령어를 통해 커널 모드에서 유저 모드로 돌아간다.

0개의 댓글