WEEK10 pintOS 2주차 유저프로세스(시스템콜) 회고

채림·2023년 5월 9일
0

이번 주도 다 못 끝내고 말았다.. 분명 fork, exec, wait 빼고 나머지를 되게 순탄하고 빠르게 구현해서 여유롭다고 생각했는데 토요일을 놀고 나니 이틀밖에 남지 않았는데 어쩌다보니 시간이 호록 지나가버렸다.

요즘 들어 집중을 잘 못하는 것 같아서 걱정이다. 엉덩이 붙이고 앉아있는건 잘 해서 보기에는 나름 열심히 하는 것 같아 보이는데, 막상 별로 한건 없다. 그래도 계속 하고 있으니 괜찮은 거겠지, 했는데 어제 잠깐 빡집중해서 디버깅하고 나니까 아 원래 이런 느낌이었지, 싶으면서 최근은 이게 아니었구나 했는데 그 뒤로도 또 집중은 안됐다. 밤은 항상 새는데 별로 한건 없어서 가성비 꽝.. 가성비 좋아하고 효율 좋아하는 채림이 왜 이렇게 됐니?

새로운 주차 시작하니 새로운 마음으로 다시 열심히 해야한다. 분명 2주차 들어설 때도 1주차는 털어버리고 하자고 했는데 2주차도 그렇게 돼버렸다. 팀원한테 왜 그렇게 나이브해졌냐는 말을 듣고 나니 씁쓸하면서 자괴감이 한층 증폭된다. 완벽주의 어디갔어! 완성하지 못한 완벽주의는 그저 게으름의 변명일 뿐 완벽주의를 갖다 붙일 수 없다는 걸 기억하자.





트러블슈팅1

첫번째 과제 argument passing에서 load 함수를 구현하면서 두가지 문제가 있었는데 그 첫번째이다.
load는 전달받은 파일 이름을 가진 실행파일을 불러와서 스택과 페이지테이블 등을 세팅해 현재 스레드에 올리는 함수이다. 그러기 위해 전달받은 command line을 parsing해서 스택에 인자들을 쌓아야 하는데, 과제 설명서에 친절하게도 순서와 어떤걸 넣어야 하는지까지 나와있어서 이대로 넣기만 하면 됐다.

근데 이 쉬운 과정에서 뭐가 문제였냐면, 분명히 스택 포인터도 잘 내리고 for문 돌면서 잘 넣었는데 gdb로 확인해보면 값이 안들어가 있는 문제가 있었다.

for(int i = argc - 1; i >= 0; i--){
		if_->rsp -= strlen(argv[i])+1;
		*(char *)(if_->rsp) = argv[i];
	}

포인터의 문제인가, parsing의 문제인가 고민을 꽤나 했는데 결론은 역시나 또시나 c언어에서 문자열 카피에 strcpy를 사용하지 않고 그냥 할당하려 한 문제였다. 스택 포인터가 가리키는 주소에 argument를 넣으려 한건데 그냥 =로 할당해버렸더니 char *로 캐스팅된 스택 포인터는 한 글자만 담을 수 있는 포인터가 돼버린 것이었다.

여기서 의문은, 이전에 argument parsing 할때도 할당을 사용했는데 왜 저건 되고 이건 안되느냐였다.

char *argv[COMMAND_LEN];
int argc = 0;

char *token, *save_ptr;
for (token = strtok_r(file_name, " ", &save_ptr); token != NULL; token = strtok_r(NULL, " ", &save_ptr))
{
	argv[argc] = token;
	argc++;
}

둘 사이의 사이점은, 위는 그냥 포인터가 가리키는 곳에 값을 할당했고 아래는 문자열 배열의 원소에 값을 할당했다는 것 뿐이다. 그렇지만 문자열 배열은 어차피 풀어 쓰면 문자열 포인터가 되므로 같은 작용 아닌가?

결론이야 뻔하지만 아니다!
아래는 token이 가리키는 문자열을 배열의 원소값에 통채로 집어넣은 것이고, 위는 char *가 가리키는 값에 string의 시작점을 넣었기 때문에 string의 맨 앞글자만 가리키는 포인터가 된 것이다.

따라서 코드를 다음과 같이 바꿨더니 정상적으로 동작했다.

for(int i = argc - 1; i >= 0; i--){
		if_->rsp -= strlen(argv[i])+1;
		strlcpy(if_->rsp, argv[i], strlen(argv[i]) + 1);
	}



트러블슈팅 2

load 함수 구현에서의 두번째 문제이다.
위의 도표에서 word-align 이후 argv의 주소들을 저장하기 위해서는 각 argv string의 시작 주소값을 계산해야 했다. 우리는 USER_STACK에서부터 쌓기 시작했으므로 USER_STACK에서 (argv 길이 + 널문자 길이 1)만큼을 빼주어서 계산했다.

	for(int i = argc-1; i >= 0; i--){
		if_->rsp -= sizeof(char *);
		*(char *)(if_->rsp) = USER_STACK - strlen(argv[i]+1);
	}

이렇게 코드를 짰는데, strlen이 동작하지 않았다. 지금이야 한 걸음 떨어져서 보니 보이지만, strlen 괄호 안에 1을 더해버리는 바보같은 실수였다.
그런데 실수를 고치고 나서도 값이 for 문을 돌면서 같은 자리에서 덮어씌워지는 것 같은 현상이 일어났다. 왤까?

USER_STACK에서 argv[0] 길이만큼 떨어진 곳의 주소를 저장하고, argv[argc-1] 주소에서 argv[argc-2] 길이만큼 떨어진 곳의 주소를 저장하는 식이 되었어야 하는데 우리는 빼는 기준점의 업데이트 없이 계속해서 USER_STACK에서 빼었기 때문에 비슷한 위치에 덮어씌워지는 것이 맞았다.

	char *tmp = USER_STACK;
	for (int i = argc - 1; i >= 0; i--)
	{
		if_->rsp -= sizeof(char *);
		*(char **)(if_->rsp) = tmp - (strlen(argv[i]) + 1);
		tmp -= (strlen(argv[i]) + 1);
	}

빼는 기준점을 해당 회차에 계산한 주소값으로 업데이트해주니 해결되었다. 사실 tmp를 먼저 계산해서 할당해준 뒤 rsp에 그냥 tmp를 넣어주어도 될 것 같은데, 이상하게 안 되는것 같아서 코드가 이렇게 됐다. 안될 이유가 없는데 기분탓이었나?



트러블슈팅 3

fork 함수 테스트 케이스를 보면 fork()의 리턴값에 따라 부모 프로세스와 자식 프로세스가 if문과 else문으로 분기한다. fork를 호출한 건 한 번뿐이고 같은 호출에 대한 리턴값인데 어떻게 다른 결과가 나와서 분기될 수 있을까?

이걸 알려면 fork 호출 과정에서의 프로세스 전환을 완벽하게 파악하고 있어야 한다.

※ 아래 설명에서 부모/자식 프로세스와 스레드의 용어를 혼용해서 사용하고 있는데, 원래 fork 과정에서 생성되는 구현체는 부모/자식 프로세스가 맞는 말이나 핀토스에서는 스레드가 프로세스 개념을 대체해서 이용되므로 실제 구현 상으로는 스레드를 생성하게 된다. 따라서 번호가 붙은 구현상의 설명에서는 스레드로 통일해서 언급하며, 그 아래의 결론에서는 프로세스로 통일해서 이야기하기 때문에 헷갈리지 말길 바란다.


유저가 시스템 콜로 fork를 호출하면

  1. userprog/syscall.csyscall_handler가 호출되어 switch문에 따라 같은 파일의 fork 함수가 호출된다.
  2. fork 내에서 process_fork가 호출되고, process_fork 안에서는 thread_create 함수를 호출한다. 여기까지는 부모 스레드에서 일어나는 일이다.
  3. thread_create를 호출할 때 __do_fork가 인자로 전달되는데, 이는 새롭게 생기는 스레드의 rdi에 저장되는 함수로 해당 스레드가 run 상태로 바뀔 때 실행되는 메인 함수이다.
  4. 따라서 __do_fork 부터는 자식 스레드에서 실행되는 동작인 것이다.

따라서 부모 프로세스에서는 syscall handler에서 fork를 호출한 결과값을 rax에 저장한 것이 리턴값이 되므로 thread_create의 리턴값인 자식 프로세스의 id를 저장하면 된다.
__do_fork부터는 자식 프로세스에서 실행중인 함수라고 했으므로 여기에서 thread_current()를 받아보면 자식 프로세스가 될 것이다. 결과적으로 current->R.rax0을 할당해주면 자식 프로세스에서는 리턴값을 0으로 받을 수 있다.



트러블슈팅 4

wait을 구현하면서, process_wait에서는 child_tid만 넘겨받는데 childsemadown하기 위해서는 child thread 자체가 필요했다. 그러면 tid로 스레드 구조체를 어떻게 찾지?
처음에는 모든 스레드들을 돌면서 tid가 일치하는 걸 찾으려고 했다. 근데 모든 스레드들을 모아놓은 리스트는 없다. 그렇다면 ready_listsleep_listblock_list를 다 따로 돌아야 하나?라고 생각했는데 아무리 찾아도 우리는 이전에 block_list를 만든 적이 없다. 그래서 ready_listsleep_list만 돌면서 스레드를 찾는 함수를 작성했다.

struct thread *get_thread_by_tid(tid_t tid)
{
	struct list_elem *e;
	for (e = list_begin(&ready_list); e != list_end(&ready_list); e = list_next(e))
	{
		struct thread *t = list_entry(e, struct thread, elem);
		if (t->tid == tid)
		{
			return t;
		}
	}
	for (e = list_begin(&sleep_list); e != list_end(&sleep_list); e = list_next(e))
	{
		struct thread *t = list_entry(e, struct thread, elem);
		if (t->tid == tid)
		{
			return t;
		}
	}
	return NULL;

이 함수로 child 스레드를 얻을 수는 있는데, child->exit_status를 가져오려고 하면 exit(-1)이 호출되면서 kernel panic이 발생했다.
스레드를 제대로 못 찾는건가 싶었는데 그 문제는 아니었다. 해당 함수 내에서는 올바른 스레드를 잘 찾아서 올바른 주소값을 리턴하는데, 리턴값을 받아와서 보면 0x8004242000 중에서 0x4242000만 가져오는 문제가 있었다. 값이 잘려서 일부만 오는건 리턴 형식이 문제인가 싶어서 리턴값을 struct thread *가 아니라 uint64_t로 했는데도 동작하지 않았다.
그러다 함수 위치를 찾아가려고 process.c에서 함수명에 마우스를 올렸는데, 리턴값이 int로 보였다. 나는 uint64_t형으로 지정했는데?? 게다가 컨트롤+클릭으로 이동이 안되었다. 그렇다면 import가 제대로 안됐나 싶어서 봤더니 thread.h에 함수 정의가 없었다! 추가하고 나니 올바르게 불러와져서 주소값이 잘리는 문제는 해결되었다.
근데 사실, 위의 문제를 해결하고 나니까 저건 중요한 문제가 아니었다. 이후의 유효성 검증문에서 해당 child가 부모의 직속 자식이 맞는지 확인하기 위해 child->parent를 불러오는데 이게 계속 0이 나왔다.

if (!child 
	|| child->exit_status != 0 
	|| (child->parent) && (child->parent != parent->tid) 
    || is_already_waiting(parent->tid, child->fork_sema.waiters))
{
	return -1;
}

생각해보니 fork 과정이 완료되기 전에 process_wait이 호출 될 수도 있는 거였다. 그러면 어떡하지? fork가 끝날 때까지 기다리도록 해야 했다. 그래서 다들 sema가 여러개 있는 거였다! fork_semawait_sema를 나누자.
fork_semafork를 호출한 쪽에서 fork 과정이 끝나기를 기다리기 위해 있는 세마포어이고, wait_semawait을 호출한 쪽에서 자식이 exit되기를 기다리기 위해 있는 세마포어이다.
여기서 유의해야 할 것은, fork_semafork가 완료되어 자식 스레드가 생성되기 이전에 부모 스레드에서 기다려야하므로 부모의 것을 sema_down해야 한다는 것이다.



트러블슈팅 5

위에서 넘어갔던 tid로 child thread 찾기가 역시나 문제였다. blocked_list가 없는데 무시하고 ready_listsleep_list만 탐색했더니 모든 스레드를 알 수 없어서 스레드를 제대로 찾지 못했다. 이걸 해결하기 위해서는 모든 스레드를 저장하는 리스트가 있거나, 내 자식 스레드를 저장하는 리스트가 필요하다. 그리고 당연하게도 속도면에서 후자가 훨씬 우월하다.
따라서 스레드마다 child_list를 만들어 그 안에서 tid가 일치하는 자식 스레드를 찾기로 했다. 그러려면 exit_sema라는 세마포어가 하나 더 필요해진다. wait할 때 exit_sema로 자식 스레드의 exit이 완전히 종료되지 않도록 막아두어아 child_list에서 자식 스레드를 제거할 수 있기 때문이다. 제거가 완료된 뒤에 exit_semasema_up 해주어서 exit 과정을 완수한다.




이번 주차는 어쩌다보니 2인팀이 되어 페어 프로그래밍을 했는데 생각보다 괜찮았다. 내가 생각한 로직의 헛점을 짚어주기도 하고 혼자 하는게 아니니 게으름 피울 수도 없어서 진도가 쭉쭉 나갔다.
내 페어는 괜찮지 않았던 것 같지만...😅 내가 답이나 기타 힌트들을 하나도 못 보게하고 스파르타식으로 깡구현을 밀어붙였더니 많이 힘들었는지 뒤에 가서는 따로 구현하게 되었다. 너무 내 욕심만 부린 것 같아서 다음 협업때는 조심해야겠다.

profile
나는 말하는 감자... 감자 나부랭이....

0개의 댓글