[SWJungle][WIL][PintOS] Project 2 - User Program

재혁·2023년 5월 9일
0

Intro

@gitbook

지난 주 까지 작업했던 부분은 커널의 일부분으로서 특권을 가지고 실행되던것이었지만 이번주에는 그런 특권층이 아닌 유저 프로그램을 실행 할 수 있도록 PintOS를 수정 해 주어야한다. 한번에 하나 이상의 프로그램이 실행 될 수 있어야 하고, 각각의 프로세스들은 하나의 쓰레드를 갖는다(멀티스레드x). 한번에 여러 프로세스들을 로드하고 실행할 때 프로세스가 보는 환상을 만족시키는 방향으로 관리되도록 구현을 해야한다.

핀토스는 userprog/process.c 에 제공된 loader로 ELF 실행 파일을 로드 할 수 있다. ELF는 linux, solarix 등의 운영체제에서 목적 파일, 공유 라이브러리, 실행 파일들을 위해 사용되는 포맷이다.

핀토스의 가상 메모리는 2개의 영역으로 나뉜다.

  • 유저 가상 메모리 : 가상 주소 0부터 KERN_BASE(0x8004000000)까지
  • 커널 가상 메모리

하나의 프로세스는 하나의 유저 가상 메모리를 가진다. 프로세스 문맥 교환이 일어날 때, 커널은 프로세서의 페이지 디렉토리 베이스 레지스터를 바꿈으로써 유저 가상 주소 공간을 변환해준다. 커널 가상 메모리는 global 하고, KERN_BASE에서 부터 시작하는데 이는 물리 메모리와 일대일 매핑이 된다. (예를들어 KERN_BASE는 0에 매핑되고, 가상주소 KERN_BASE + 0x1234는 물리주소 0x1234에 매핑된다.)


static void
page_fault(struct intr_frame *f)
{
	bool not_present; /* True: not-present page, false: writing r/o page. */
	bool write;		  /* True: access was write, false: access was read. */
	bool user;		  /* True: access by user, false: access by kernel. */
	void *fault_addr; /* Fault address. */

	/* Obtain faulting address, the virtual address that was
	   accessed to cause the fault.  It may point to code or to
	   data.  It is not necessarily the address of the instruction
	   that caused the fault (that's f->rip). */

	fault_addr = (void *)rcr2();

	/* Turn interrupts back on (they were only off so that we could
	   be assured of reading CR2 before it changed). */
	intr_enable();

	/* Determine cause. */
	not_present = (f->error_code & PF_P) == 0;
	write = (f->error_code & PF_W) != 0;
	user = (f->error_code & PF_U) != 0;

#ifdef VM
	/* For project 3 and later. */
	if (vm_try_handle_fault(f, fault_addr, user, write, not_present))
		return;
#endif

	/* Count page faults. */
	page_fault_cnt++;

	/* If the fault is true fault, show info and exit. */
	printf("Page fault at %p: %s error %s page in %s context.\n",
		   fault_addr,
		   not_present ? "not present" : "rights violation",
		   write ? "writing" : "reading",
		   user ? "user" : "kernel");
	exit(-1);
}

유저 프로그램은 자신의 가상 메모리에만 접근할 수 있고, 만약 커널 가상메모리에 접근 하려 하면 page fault를 (userprog/exception.c) 일으킨다. 커널에서 매핑되지 않은 유저 가상 주소로 접근하려는 시도에서도 page fault가 발생한다.


Argument Passing

커맨드 라인의 문자열을 토큰으로 파싱해서 이름, 인자로 구분해 스택에 저장한다. 그리고 그 인자를 프로그램에 전달해주어야 한다. 이 때 space가 여러개 들어있는 문자열도 space 하나와 동일하게 취급 하도록 주의해야한다.

int process_exec(void *f_name)
{
	char *file_name = f_name; // 매개변수 void* 로 넘겨받은 f_name을 char* 로
	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. */
	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();

	// strtok_r 함수 이용, 공백 기준으로 명령어 parsing
	char *argv[128];
	char *token, *save_ptr;
	int argc = 0;

	token = strtok_r(file_name, " ", &save_ptr);

	while (token != NULL)
	{
		argv[argc] = token;
		token = strtok_r(NULL, " ", &save_ptr);
		argc++;
	}
	/* And then load the binary */
	success = load(file_name, &_if);
    
    /* - - - - - - - - - - - - - - - - - - - - - - - - - -*/
  • proccess_exec 안에서 현재 레지스터가 작업하고 있는 context를 인터럽트 프레임 (struct intr_frame 에 담아놓고 현재 컨텍스트를 종료해준다.
  • 명령어를 strtok_r() 함수를 통해 파싱해 load() 함수에 파일 이름과 인터럽트 프레임을 넘겨준다.strtok_r() 함수는 특정 delimiter를 기준으로 문자열을 파싱하고 남은 문자열을 리턴해준다. 임시로 문자열들을 저장해줄 포인터를 선언해주고, file_name을 잘라온 뒤 나머지 인자들은 argv[]에 넣어주는 모습이다.
  • load() 함수를 통해 성공적으로 file_name 적재에 성공했다면 argv를 파싱해 인터럽트 프레임의 rsp(스택 포인터)를 인자길이 + '\n'(1)만큼 내리고 인자를 복사해준다. 이 때 인자의 주소도 따로 저장을 해 주어야한다.
  • rsp의 주소 정렬 단위는 8byte 인데, 만약 인자를 담은 길이가 8바이트로 나눠떨어지지 않는다면 '0'으로 패딩 처리를 해 준다.
  • 인자를 넣었을 때와 같이 rsp를 내리면서 앞에서 저장한 인자들의 주소를 넣어준다.
  • rsp를 내리고 fake return address(8바이트(void *) 의크기)를 0으로 초기화 해 넣어준다
    함수를 실행한 측(caller)에서 함수를 실행하면 스택의 맨 아래쪽에 자동으로 돌아갈 주소를 넣어주는 기능이 있는데, 프로그램에서 처음 실행되는 함수는 caller가 없어서 커널에서 직접 주소를 넣어줘야 한다.
  • rdi를 argc(첫번째 인자)로, rsi를 rsp + 8 (리턴 주소 바로 위)로 초기화해준다.

rsp(스택포인터)는 스택 맨 아래쪽 블럭의 메모리 주소를 담고 있는 레지스터다. 그러므로 스택 영역에 데이터를 추가하거나 뺄 때 마다 값을 변경 해 주어야한다.

User Memory Access

메모리 가상화의 목적은 관리와 보호. 앞으로 프로세스가 잘못된 영역의 주소를 참조하려할 때 올바른 주소를 참조하는 것인지 걸러줄 필요가 있다. 주소를 체크 해 주는 함수를 하나 만들어 활용했다. 이 함수는 include/threads/vaddr.h에 선언되어있는 매크로와 threads/mmu.c 에 있는 물리주소를 알아볼 수 있는 pml4_get_page를 이용해 구현했다.

void check_address(void *addr)
{
    struct thread *curr = thread_current();
    // User VA 인지 ; 커널 VA가 아닌지
    // 주소가 NULL 은 아닌지
    // 유저 주소 영역내를 가르키지만 아직 할당되지 않았는지 (pml4_get_page)
    if (!is_user_vaddr(addr) || addr == NULL || 
    pml4_get_page(curr->pml4, addr) == NULL)
    exit(-1);
}

System Call

유저 프로그램이 함부로 허가되지 않은 곳에 접근하고, 악의적인 일들을 벌인다면 곤란할 것이다. 시스템콜은 운영 체제가 제공하는 서비스에 대한 프로그래밍 인터페이스로서, 유저 프로그램의 권한은 제한하고 합당한 요청에 대해서 커널이 제한공간을 액세스 해 필요한 값을 리턴해준다. 귀금속 가게나, 위험한 물건을 파는 곳에서는 고객이 직접 물건을 가지러 가지 않고 직원에게 한번 보여줄 수 있는지 물어보는 것을 연상하면 이해가 잘 될것 같다.

시스템콜의 흐름은 이렇다.

  • 유저 프로그램이 실행되면 유저 프로그램은 필요한 시스템콜을 호출한다.
  • argument들을 스택에 넣고 나서 커널 모드로 진입한다.
  • 시스템 콜 핸들러가 호출된다.
  • 시스템콜 핸들러에서 rax값(시스템 콜의 번호)을 읽어 알맞은 함수로 분기한다. 이후 rax에 함수의 결과값이 담긴다.

switch-case문을 사용해 유저 스택의 rax값에 따라 함수를 호출해준다. 시스템콜 번호는 include/lib/syscall-nr.h 에 정의되어있었다. 개인적으로 가장 까다로웠던 fork()

fork()


@gitbook

Create new process which is the clone of current process with the name THREAD_NAME. You don't need to clone the value of the registers except %RBX, %RSP, %RBP, and %R12 - %R15, which are callee-saved registers. Must return pid of the child process, otherwise shouldn't be a valid pid. In child process, the return value should be 0. The child should have DUPLICATED resources including file descriptor and virtual memory space. Parent process should never return from the fork until it knows whether the child process successfully cloned. That is, if the child process fail to duplicate the resource, the fork () call of parent should return the TID_ERROR.

The template utilizes the pml4_for_each() in threads/mmu.c to copy entire user memory space, including corresponding pagetable structures, but you need to fill missing parts of passed pte_for_each_func

tid_t process_fork(const char *name, struct intr_frame *if_ UNUSED)
{
	/* Clone current thread to new thread.*/

	struct thread *curr = thread_current();
    
	memcpy(&curr->parent_if, if_, sizeof(struct intr_frame));
	tid_t pid = thread_create(name, PRI_DEFAULT, __do_fork, curr);

	if (pid == TID_ERROR)
		return TID_ERROR;

	struct thread *child = get_child(pid);

	sema_down(&child->fork_sema);

	if (child->exit_status == -1)
		return TID_ERROR;

	return pid;
}

process_fork() 는 인자로 프로세스 이름과 intr_frame을 받는다. 이 intr_frame가 물고있는 컨텍스트를 복사해 넘겨주어야한다. thread_create() 의 파라미터에 콜백함수로 __do_fork() 가 들어가는데 이 함수가 이를 도와준다.
스레드 구조체에 세마포어를 추가적으로 만들어서 자식 프로세스가 로드 되는동안 세마포어를 잡고있도록 하고 자식이 성공적으로 로드되면 세마포어를 올리도록 해 준다.


도움이 되었던 것

  • GDB ; pwndbg 모듈을 설치해 조금 더 시각적인 지원을 받을 수 있었다.
  • pintos -v -k -T 600 -m 20 -m 20 --fs-disk=10 -p tests/userprog/no-vm/multi-oom:multi-oom -- -q -f run multi-oom 와 같이 FAIL이 뜨는 테스트 케이스를 하나씩 돌려보며 대략 어떤 문제가 생겼었는지 아주 빠르게 힌트를 얻어 가설을 세워 볼 수 있었다.
  • backtrace 를 활용해 콜스택을 분석해 어떤 함수에서 문제가 있었는지 확인 할 수 있었다.

FAQ

  • Q: x86-64 Calling Convention 에서는 스택을 16바이트로 정렬하는데, 카이스트 핀토스에서는 8바이트로 하는것 같아요. 8바이트로 해도 문제가 없나요?
    A: x86-64의 stack alignment 규칙에 따르면 return address를 제외하고 직전까지의 상황에서 %rsp가 16의 배수가 되는 것을 원칙으로 하고 있습니다. (다르게 말하면 함수가 호출된 직후에 %rsp+8이 16의 배수가 되어야 합니다) Gitbook에서의 stack 구조를 보았을 때 return address 바로 위에 들어간 argv[0]이 16의 배수의 주소에 저장되었으므로 제대로 정렬되어 있음을 확인할 수 있습니다.
  • Q: thread는 kernel 메모리의 stack영역에서 관리되는것이 맞나요? 지난 주 Thread 프로젝트에서는 그렇게 이해하고 넘어왔는데, User program으로 넘어오니 좀 혼동이 생깁니다.
    A: kernel thread에 사용되는 memory는 physical memory를 할당하는 palloc_get_page 함수에 의해 할당되며, 이는 thread_create 함수에서 확인할 수 있습니다. (link)
    또한, PintOS에서 kernel thread의 memory layout이 어떻게 구성되는지는 include/threads/thread.h에서 확인할 수 있습니다. (link)
  • Q: 하나의 process에서 여러개의 thread가 실행될 때, stack이 어떻게 사용되는지 궁금합니다. 해당 경우에 thread들은 동일한 가상주소 공간을 공유하고, 그러면 가상주소 공간의 stack 또한 공유하고 있을텐데, 각 thread들이 실행될 때 어떻게 stack을 사용하는지 잘 이해가 되지 않습니다.
    A: PintOS의 경우 kernel thread와 user thread가 1:1 대응되는 design을 채택하고 있어, user process의 multi-threading은 불가능합니다. UNIX OS의 POSIX pthread와 같은 thread API의 경우 각 user thread별로 고유한 stack을 동일한 가상주소 공간상에 할당하는 방식으로 thread stack을 관리합니다. 자세한 사항은 다음 문서를 참고하세요.

0개의 댓글