거북이의 쉼터

(2022.02.11) System Calls 구현 (1/4) 본문

코딩 삽질/KAIST PINTOS (CS330)

(2022.02.11) System Calls 구현 (1/4)

onlim 2022. 2. 11. 19:35

1. 서론 및 필요 내용 설명

가장 어려운 fork는 맨 나중에 해결하도록 한다. (쫄튀 ㅋㅋㅋ) 아래에 구현된 함수에서의 모든 인자들은 해당 함수가 호출되기 이전인 syscall_handler에서 검증을 받는 것을 전제로 한다. 또한, pintos는 1 프로세스 : 1 thread를 벗어나지 않기 때문에 pid와 tid를 사실상 같게 취급해도 문제가 없다. 따라서 pid_t와 tid_t를 동일하게 취급하도록 한다.

 


2. 구현해야 하는 것

오늘은 halt, exit, wait, create / remove, open / close, filesize, seek/tell을 구현하자. 나머지는 다음 포스팅에서...

 


3. 구현 과정

3-1. halt

간단하다.

void halt()
{
	power_off();
	return; // NO REACH
}

3-2. exit

인자인 status는 다음 subtask인 Process Termination Messages에 사용된다. 구현하는 김에 이것까지 구현하도록 한다.

Whenever a user process terminates, because it called exit or for any other reason, print the process's name and exit code, formatted as if printed by printf ("%s: exit(%d)\n", ...);

 

process_exit으로 exit할 때의 status를 넘겨주면 쉬울 것 같지만 process_exit은 일괄적으로 사용되어야 하기에 status인자를 받지 않는다. 따라서 종료되는 현재의 thread 어딘가에는 status 정보를 남겨놓아야 한다. 따라서 exit_status 멤버를 thread 구조체에 추가한다.

struct thread {
...
#ifdef USERPROG
	/* Owned by userprog/process.c. */
	uint64_t *pml4;                     /* Page map level 4 */
	int exit_status;
#endif
...
}

이제 exit에서는 인자로 들어온 status를 현재 thread의 exit_status에 기록하고 thread_exit을 호출한다.

void exit(int status)
{
	struct thread *curr = thread_current();
	curr->exit_status = status;
	thread_exit();
}

thread_exit은 process_exit을 호출하므로, process_exit에서 요구하는 종료 메시지를 출력하도록 한다.

void
process_exit (void) {
	struct thread *curr = thread_current ();
	process_cleanup ();
	printf("%s: exit(%d)\n", curr->name, curr->exit_status);
}

cleanup 요소는 나중에 생각하기로 하고 넘어간다.

3-3. exec

일단 process_exec을 사용하여 구현하는 것은 지난 포스팅에서 언급했다. process_exec의 코드를 살펴보면

/* Switch the current execution context to the f_name.
 * Returns -1 on fail. */
int
process_exec (void *f_name) {
	char *file_name = f_name;
	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 ();

	/* And then load the binary */
	success = load (file_name, &_if);

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

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

위와 같이 palloc_free_page로 들어온 f_name 인자를 free시키는 것을 볼 수 있다. 여기서 palloc_free_page가 호출되는 것은 process_exec을 호출하는 다른 함수인 process_create_initd 에서 복사한 이름을 process_exec의 인자로 넘기기 때문이다.

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

	/* 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;
}

/* A thread function that launches first user process. */
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에 직접적으로 syscall의 인자를 넣는 것은 위험하며, process_create_initd 처럼 복사본을 만들어 인자로 넣어주는 것이 좋을 것이다. 이를 생각하면서 exec을 구현하면 아래와 같다. 프로세스의 이름은 따로 바꿀 필요 없다고 매뉴얼에 명시되어 있기에 단순 실행만 시키면 끝이다.

int exec (const char *cmd_line)
{
	char *cmd_copy = palloc_get_page (0);
	if (cmd_copy == NULL)
		exit(-1);

	strlcpy (cmd_copy, cmd_line, PGSIZE);
	if (process_exec (cmd_copy) < 0)
		exit(-1);

	return -1; // Unreachable
}

3-4. wait

지난 포스팅에 이어 문제가 더 발생할 수 있는가를 더 생각해 봤다.

 

  • 부모가 wait를 호출하기 전에 자식이 종료되는 경우
  • 부모가 wait를 호출하지 않는 경우

첫 번째 경우, 부모 프로세스에서 자식의 exit status를 알기 어려워지는 문제가 존재한다. 이는 지난 포스팅에 설명한 방식으로 semaphore를 사용한다면 자식이 완전히 종료되지 않은채로 부모의 wait의 호출을 기다리기 때문에 exit status를 회수하기 쉬워진다.

 

두 번째의 경우 지난 포스팅에 설명한 것처럼 semaphore를 사용할 경우 발생하는 문제로서, 자식은 semaphore를 down한채 계속 block되어 있을 수 있다. 이에 대한 해결책은 부모 프로세스에서 wait을 호출하지 않았더라도 부모의 process_exit 시점에서 자식(들)의 semaphore를 미리 up 시켜 놓고 종료하는 것이다. 단순히 process_wait을 호출하면 semaphore down으로 인해 자식의 종료까지 기다리게 되므로 호출하지 말고 semaphore up만 시키도록 하자.

 

두 번째 문제의 해결을 위해서는 부모 프로세스가 종료 시점에 모든 자식들을 알아야 한다. 이를 위해 부모에서 본인의 자식들을 searching한다고 하면 두 가지 해결책이 있을 수 있는데,

 

  • 부모 thread에서 자식의 thread 포인터를 모두 리스트로 들고 있는다.
  • 각 thread마다 부모의 tid를 저장하고 전 thread를 순회하며 현재 부모 thread의 tid와 일치하는 thread만 고른다. 

각각 공간/시간 상 장단점이 있다고 본다. 나는 첫 번째 방식으로 구현할 경우 발생할 수 있는 dangling 포인터 등의 문제가 더 잡기 귀찮다고 생각했기 때문에 두 번째 방식으로 구현할 것이다. 해당 사항은 process_exit뿐만 아니라 process_wait에서도 구현되어야 한다. 이는 wait의 요구사항 상 먼저 process_wait에 주어진 child_tid가 현재 실행중인 부모의 자식인지 파악해야 하기 때문이다. 이에 바로 모든 thread가 포함된 all_list를 사용해 searching에 필요한 사항들을 thread.c에 구현하였다.

// thread.h
struct thread {
...
#ifdef USERPROG
	/* Owned by userprog/process.c. */
	uint64_t *pml4;                     /* Page map level 4 */
	
	/* For exit messages & wait syscalls */
	int exit_status;
	
	/* For finding child of current thread, holds the tid of parent thread */
	tid_t parent_tid;

	struct semaphore child_ready_sema; // For child->parent signaling exit_status ready
	struct semaphore parent_ready_sema; // For parent->child signaling fine to terminate
#endif
...
}

/* Loop through the all_list & inform each child threads that the parent is being terminated
 * without calling wait via sema_up */ 
void
nowait_terminate (void)
{
	struct list_elem *e;
	struct thread *t;
	struct thread *curr = thread_current();
	tid_t parent_tid = curr->tid;

	for (e = list_begin(&all_list); e != list_end(&all_list); e = list_next(e))
	{
		t = list_entry(e, struct thread, all_elem);
		if (t->parent_tid == parent_tid)
			sema_up(&t->parent_ready_sema);
	}

	return;
}

/* Searches for the thread that is child of current thread 
 * and has tid of child_tid */
struct thread *
search_child (tid_t child_tid)
{
	struct list_elem *e;
	struct thread *t;
	struct thread *curr = thread_current();
	tid_t parent_tid = curr->tid;

	for (e = list_begin(&all_list); e != list_end(&all_list); e = list_next(e))
	{
		t = list_entry(e, struct thread, all_elem);
		if (t->tid == child_tid && t->parent_tid == parent_tid)
			return t;
	}

	return NULL;
}

이를 기반으로 process_exit과 process_wait을 구현하면 아래와 같다.

int
process_wait (tid_t child_tid) {
	int child_exit_status;
	struct thread *child = search_child (child_tid);

	if (child == NULL)
		return -1;

	// wait for child to terminate and fetch exit status
	sema_down(&child->child_ready_sema);
	child_exit_status = child->exit_status;
	sema_up(&child->parent_ready_sema);

	return child_exit_status;
}

void
process_exit (void) {
	struct thread *curr = thread_current ();

	process_cleanup ();
	nowait_terminate ();

	sema_up(&curr->child_ready_sema);
	printf("%s: exit(%d)\n", curr->name, curr->exit_status);
	sema_down(&curr->parent_ready_sema);
}

프로세스가 커널에 의해서 강제로 종료당하는 경우는 현재 exception.c의 kill()에서 일어나고 있으며, 해당 경우 process_wait은 -1을 반환하도록 되어 있으므로, thread_exit()이 호출되기 전 exception.c에서 exit_status를 다음과 같이 설정해준다.

static void
kill (struct intr_frame *f) {
	switch (f->cs) {
		case SEL_UCSEG:
			/* User's code segment, so it's a user exception, as we
			   expected.  Kill the user process.  */
			printf ("%s: dying due to interrupt %#04llx (%s).\n",
					thread_name (), f->vec_no, intr_name (f->vec_no));
			intr_dump_frame (f);
			thread_current()->exit_status = -1;
			thread_exit ();
		...
	}
}

이제 한 번 호출된 tid에 대해 -1을 반환하도록 하면 wait은 얼추 끝이다. 두 번째 호출의 경우 child_ready_sema를 up해줄 자식의 process_exit 루틴이 종료된 이후일 것이기에, child_ready_sema down에서 잘못하면 계속 block된다. 이럴 경우는 search_child에서 자식이 정상적으로 찾아질 경우, 즉 all_list에서 아직 자식 프로세스의 thread가 제거되기 전 두 번째 wait이 호출될 경우이다. 이럴 경우를 방지하기 위해 한 번 wait의 대상이 된 thread는 표시를 하도록 하자. 정정을 한 뒤 코드는 아래와 같다.

struct thread {
...
#ifdef USERPROG
	/* Owned by userprog/process.c. */
	uint64_t *pml4;                     /* Page map level 4 */
	
	/* For exit messages & wait syscalls */
	int exit_status;
	
	/* For finding child of current thread, holds the tid of parent thread */
	tid_t parent_tid;
	bool wait_called;

	struct semaphore child_ready_sema; // For child->parent signaling exit_status ready
	struct semaphore parent_ready_sema; // For parent->child signaling fine to terminate
#endif
}

int
process_wait (tid_t child_tid) {
	int child_exit_status;
	struct thread *child = search_child (child_tid);

	if (child == NULL)
		return -1;

	if (child->wait_called)
		return -1;
	
	child->wait_called = true;
	
	// wait for child to terminate and fetch exit status
	sema_down(&child->child_ready_sema);
	child_exit_status = child->exit_status;
	sema_up(&child->parent_ready_sema);

	return child_exit_status;
}

이제 wait은 끝난 것 같다.

3-5.  create / remove

이제 남은 것들이 전부 file 관련 syscall이다. 지난 포스팅에서 설명한 대로 file system 접근시 사용할 lock과 관련 함수들을 thread.c에 만들자.

// thread.c
/* Lock for treating the file system code as a critical section */
static struct lock filesys_lock;

void
thread_init (void) {
	...
	/* Init the globla thread context */
	lock_init (&tid_lock);
	lock_init (&filesys_lock);
	...
}

void
filesys_enter ()
{
	lock_acquire (&filesys_lock);
	return;
}

void
filesys_exit ()
{
	lock_release (&filesys_lock);
	return;
}

해당 함수들을 사용해 create / remove를 구현하면 아래와 같다.

bool create(const char *file, unsigned initial_size)
{
	bool res;
	filesys_enter();
	res = filesys_create(file, initial_size);
	filesys_exit();
	return res;
}

bool remove (const char *file)
{
	bool res;
	filesys_enter();
	res = filesys_remove(file);
	filesys_exit();
	return res;
}

3-6. open / close

이제 중요한 부분이다. file descriptor를 어떻게 구현할지가 문제인데 내가 생각하는 방법은 구조체를 새로 하나 만드는 것이다. 해당 구조체는 file descriptor(int), 실제 file 포인터를 가지고 있도록 하고 list로 묶을 수 있도록 list_elem도 하나 가지고 있도록 한다. thread는 open한 file들을 해당 구조체의 리스트로 들고 있도록 한다. 해당 구조체를 struct fildes라고 하자.

/* An open file that is kept in thread */
struct fildes {
	int fd; // file descriptor
	struct file *fp;
	struct list_elem fd_elem;
};

struct thread {
	...
	/* Keeps the fildes in list form */
	struct list fd_list;
}

모든 프로세스마다 0, 1의 fd는 stdin, stdout으로 고정되어 있으므로, 프로세스가 새롭게 생성될 때 0, 1의 fd를 가지고 있는 fildes를 생성해 리스트에 넣어주면 기본적인 세팅은 완료된다.

 

중요한 것은 프로세스가 파일을 새로 열 때 어떻게 fd를 열린 파일에 배정하는지이다. 새롭게 파일이 open되면, 우선 fd_list를 fd 순으로 정렬해 비어있는 fd가 무엇인지 파악한다. 0부터 시작해서 1씩 늘려가면서 해당 fd가 fd_list에 있는지 파악하는 식으로 들어있지 않는 정수를 찾을 수 있다. 이렇게 구해진 정수를 열린 파일의 fd로서 반환하면 요구한 사항에 맞는 fd 배정이 완료된다.

 

물론 0과 1에 해당하는 파일은 고정이니 그냥 fd_list를 비우고 fd 배정을 2부터 시작하면 되지 않느냐고 생각할 수 있다. 이는 추후 dup2의 확장성을 위한 것으로 stdin, stdout이 다른 fd로 부여될 수 있으며, 0과 1 또한 항상 고정되지 않을 수 있는 fd라는 것을 고려한 것이다. 이를 위해 stdin, stdout도 fildes 형식에 맞춰서 가지고 있을 필요는 있으므로, stdin일 경우 fp = 0, stdout일 경우 fp = 1로 가지고 있도록 약속하자. 해당 약속에 따라 프로세스를 초기화할 때 fd_list에 fildes 구조체를 2개 넣는다.

 

이제 얼추 정의 및 구현할 방향이 잡혔으니 open/close를 구현한다. 필요한 sub함수는 다음과 같으며

 

  • 주어진 file 포인터에 대해 fd를 배정 및 반환하는 함수 (fd_allocate)
  • 현재 thread에서 주어진 fd에 대해 해당하는 fildes를 fd_list에서 찾아서 반환하는 함수 (fd_search)
  • 주어진 fd에 대해서 해당하는 fildes 등의 자원을 해제하여 해당하는 파일을 fd_list에서 없애는 함수 (fd_deallocate)

이는 아래와 같이 구현하였다. (sorting에 사용될 함수는 나중에 구현하자)

int fd_allocate (struct file *fp)
{
	struct list_elem *e;
	struct fildes *fd_entry, *res;
	struct thread *curr = thread_current();

	list_sort(&curr->fd_list, func, NULL);
	int fd = 0;

	for (e = list_begin(&curr->fd_list); e != list_end(&curr->fd_list); e = list_next(e))
	{
		fd_entry = list_entry(e, struct fildes, fd_elem);
		if (fd_entry->fd == fd)
			fd++;
		else
			break;
	}

	res = (struct fildes *)malloc(sizeof(struct fildes));
	res->fd = fd;
	res->fp = fp;
	list_insert_ordered(&curr->fd_list, &res->fd_elem, func, NULL);

	return fd;
}

struct fildes *fd_search (int fd)
{
	struct list_elem *e;
	struct thread *curr = thread_current ();
	struct fildes *fd_entry;

	for (e = list_begin(&curr->fd_list); e != list_end(&curr->fd_list); e = list_next(e))
	{
		fd_entry = list_entry(e, struct fildes, fd_elem);
		if (fd_entry->fd == fd)
			break;
	}

	return fd_entry;
}

struct file *fd_deallocate (int fd)
{
	struct fildes *fd_entry = fd_search(fd);
	struct file *fp;
	
	list_remove(&fd_entry->fd_elem);
	fp = fd_entry->fp;
	free(fd_entry); // always free what you malloc-ed;

	return fp;
}

이들 함수를 사용하여 구현된 open/close는 다음과 같다.

int open(const char *file)
{
	int fd;
	filesys_enter();
	struct file *fp = filesys_open(file);
	fd = fd_allocate(fp);
	filesys_exit();
	return fd;
}

void close(int fd)
{
	filesys_enter();
	struct file *fp = fd_deallocate(fd);
	file_close(fp);
	filesys_exit();
}

3-7. filesize

fd로 해당 파일 구조체를 찾은 뒤 적당한 함수를 호출하여 값을 반환한다.

int filesize (int fd)
{
	int res;
	filesys_enter();
	struct fildes *fd_entry = fd_search(fd);
	res = (int)file_length(fd_entry->fp);
	filesys_exit();
	return res;
}

3-8. seek / tell

비슷한 방식으로 sub함수들을 활용하면 큰 틀을 잡기 쉽다.

void seek (int fd, unsigned position)
{
	filesys_enter();
	struct fildes *fd_entry = fd_search(fd);
	file_seek(fd_entry->fp, position);
	filesys_exit();
}

unsigned tell (int fd)
{
	unsigned res;
	filesys_enter();
	struct fildes *fd_entry = fd_search(fd);
	res = (unsigned)file_tell(fd_entry->fp);
	filesys_exit();
	return res;
}

 

나머지 함수들은 다음 포스팅에서 마저 구현하자. 진 빠져....

 


4. 디버깅

아직은 디버깅 불가...

 


5. 후기

사실 지금까지 짠 코드들은 많이 엉성하다. 예외 처리도 거의 신경쓰지 못하고 있으며 당장 필요한 부분들만 처리하고 있는 것에 가깝다. 이는 나머지 함수들의 1차 구현이 완료된 이후에 리터치 작업을 하도록 하자.

 

근데 왜 아직도 많이 남아있지? 히히히히히ㅣ히히히히히ㅣ히ㅣ히히히히ㅣ히ㅣ히히ㅣ히히...

Comments