본문 바로가기

[pintOS] 시스템 콜 fork 구현기

@정소민fan2025. 5. 19. 16:38

두번째 과제인 시스템 콜 구현하기
나는 fork, exec, wait를 맡아서 구현하기로 했다.
fork를 구현하면서 수정한 함수, 마주쳤던 에러를 정리해두겠다.
 
과제 설명서에서 fork는 어떻게 구현해두라고 되어있는지 확인해보자

과제 설명서 번역본
이거네


그럼 이제 pintos 내에서 fork의 흐름을 따라가보자.
fork 테스트에는 여러개가 있지만, fork-once를 기준으로 두겠다

테스트 파일 fork-once.c

void
test_main (void) 
{
  int pid;

  if ((pid = fork("child"))){
    int status = wait (pid);
    msg ("Parent: child exit status is %d", status);
  } else {
    msg ("child run");
    exit(81);
  }
}

fork("child")에서 시스템 콜을 발생시킨다. 이전에 포스팅한 https://gooch123.tistory.com/16 시스템 콜 흐름에 따라 syscall_entry 함수가 있는 메모리 주소로 점프하게 되고, userprog/syscall.c의 syscall_handler를 호출하게 된다.
아래 함수는 일부 분기 처리가 구현되어있는 함수이다. 감안하고 보도록

syscall_handler

void syscall_handler(struct intr_frame *f UNUSED)
{
	uint64_t syscall_num = f->R.rax;
	uint64_t arg1 = f->R.rdi;
	uint64_t arg2 = f->R.rsi;
	uint64_t arg3 = f->R.rdx;
	uint64_t arg4 = f->R.r10;
	uint64_t arg5 = f->R.r8;
	uint64_t arg6 = f->R.r9;

	switch (syscall_num)
	{
	case SYS_HALT:
		sys_halt();
		break;
	case SYS_EXIT:
		sys_exit(arg1);
		break;
	case SYS_FORK:
		//TODO: process_fork 호출
		break;

시스템 콜 발생 시에 CPU가 자동으로 현재 RIP, RSP, 플래그 등 일부 값을 커널 스택 내부의 인터럽트 프레임에 저장되고, 나머지 레지스터 까지 커널 어셈블리 핸들러가 저장해준다고 한다.
syscall_handler에서 인자로 받는 intr_frame *f 는 사실 커널 스택 내부의 인터럽트 프레임 시작 주소인 것이다 !!
 
위 과제 설명서에서 복제 시에 callee-saved 레지스터들을 복사해야한다고 했다. 하지만 내 생각에는 실행 흐름을 완전히 따라가려면 모든 레지스터들을 같이 복사해와야 할 것 같은데... 일단 인터럽트 프레임 내부의 모든 정보들을 복사해보기로 하자
 
그런데... thread.h 내에 선언된 thread 구조체 내에도 인터럽트 프레임이 있고.. 커널 스택에도 인터럽트 프레임이 있는데 둘의 차이가 대체 뭘까? 기회가 되면 다른 포스팅에서 다뤄보도록 하겠다
 
여기서는 단순히 process_fork를 호출해주고, 인자만 넘겨주면 끝이다.

case SYS_FORK:
    f->R.rax = process_fork((const char *)arg1, f);
    break;

 
 
userprog/process.c의 process_fork로 이동해보자

process_fork

/* 현재 프로세스를 `name`이라는 이름으로 복제합니다.
 * 새 프로세스의 스레드 ID를 반환하거나, 생성할 수 없으면 TID_ERROR를 반환합니다. */

tid_t process_fork(const char *name, struct intr_frame *if_ UNUSED)
{
	/* Clone current thread to new thread.*/
	return thread_create(name, PRI_DEFAULT, __do_fork, thread_current());
}

인자로 name, 그리고 인터럽트 프레임을 받는다. 인터럽트 프레임이 왜 필요하다고? 레지스터 상태들을 다 복제해주기 위해서 !!
그러면 이제 thread_create를 해주면서
1. 생성한 thread의 이름을 name으로 정해주고,
2. 우선순위는 PRI_DEFAULT,
3. 가장 먼저 실행할 함수는 __do_fork,
4. 그리고 __do_fork를 실행하면서 넘겨줄 인자는 thread_current()가 되겠다.
 
우리가 이 함수에서 해야할 일은 뭘까? 아까 위에서 레지스터 상태를 복사해줘야 한다고 했다. 그런데? 우리가 __do_fork에 넘겨주는 인자는 thread_current 밖에 없다. 따라서 우리는 thread 구조체에 *if를 저장해두고 넘겨주면 될 것 같다.
두번째로는 thread_create를 부모 프로세스가 대기하도록 해야하는 것이다. 만약 대기하는 로직을 넣어두지 않고 바로 넘어갈 경우, 프로세스의 복제가 완료되지 않은 상태에서 부모 프로세스가 자식 프로세스의 정보를 읽어버릴수 있다고 한다. 따라서 우리는 대기를 위한 세마포어도 하나 추가해두어야 한다.
근데 여기서 한가지 이상한 에러가 발견되었다.
 
__do_fork에 넘어간 parent 자체의 주소는 남아있지만, parent의 내부 필드들이 모조리 쓰레기 값으로 바뀌어버려서 페이지 폴트가 발생하는 것이다 !!
대체 왜 이런지 여기저기 찾아봤지만... 이유를 알 수가 없었다. GPT의 답변으로는 세마포어로 부모의 실행 흐름을 일시 중지시킨다고 해서 항상 부모의 필드가 안전하게 남아있다는 보장이 없다는 것이다. 내 추측으로는 아마 wait 시스템 콜이 구현되지 않아서 그런거 아닐까 한데...
 
그래서 thread.h내에 fork에 필요한 정보만 넘기는 구조체를 따로 만들어두고 이를 넘기기로 했다.

thread.h

struct fork_info
{
	struct thread *parent;
	struct intr_frame parent_if;
};

이 구조체는 부모의 주소와 process_fork에서 인자로 받은 인터럽트 프레임을 복사해두고, __do_fork로 전달하기 위한 구조체이다.
그리고 자식 프로세스의 복제를 대기할 세마포어도 thread 구조체 내에 추가해주자

struct thread
{
	...
	struct semaphore *fork_sema; // fork 동기화를 위한 세마포어
    ...
}

세마포어를 포인터 구조체로 만들어주었으니 동적할당도 해주어야 한다. 그런데 왜 포인터로 사용할까? GPT의 답변으로는 헤더 순환이 생겨서 그렇다고 한다. 
그냥 내가 include<synch.h>를 안 해줘서 그렇다... include 해주고 포인터 빼고 써도 된다 ㅠ
그럼 동적할당은 어디서 해줄까?? 스레드가 처음 생성될 때 한번만 실행되는 함수에 하는게 좋을 것 같다. process_init 내에서 해주자!!

process_init

/* General process initializer for initd and other process. */
static void
process_init(void)
{
	struct thread *current = thread_current();
	current->fork_sema = malloc(sizeof(struct semaphore));
	sema_init(current->fork_sema, 0);
}

현재 스레드의 세마포어를 동적할당하고, 초기화까지 진행해주었다 !!
 
이제 process_fork의 새로 작성한 코드를 보자

정답 코드

/* 현재 프로세스를 `name`이라는 이름으로 복제합니다.
 * 새 프로세스의 스레드 ID를 반환하거나, 생성할 수 없으면 TID_ERROR를 반환합니다. */

tid_t process_fork(const char *name, struct intr_frame *if_ UNUSED)
{
	struct fork_info *info = malloc(sizeof(struct fork_info));
	ASSERT(info != NULL);
	struct thread *parent = thread_current();
	info->parent = parent;
	memcpy(&info->parent_if, if_, sizeof(struct intr_frame));

	tid_t child_tid = thread_create(name, PRI_DEFAULT, __do_fork, info);

	sema_down(parent->fork_sema); // 동기화를 위한 sema_down
	return child_tid;
}

이렇게 만들어서 넘겨주었더니, info 내의 parent 스레드 내부 필드가 모두 잘 살아있었다. 대체 이유가 뭔지...
 
그러면 다음으로 봐야할 함수는 __do_fork겠지?
같은 파일 내의 __do_fork로 가보자

__do_fork

/* 부모의 실행 컨텍스트를 복사하는 스레드 함수입니다.
 * 힌트) parent->tf는 프로세스의 사용자 영역 컨텍스트를 저장하지 않습니다.
 *       즉, 이 함수에는 process_fork의 두 번째 인자인 if_를 넘겨야 합니다. */
static void
__do_fork(void *aux)
{
	struct intr_frame if_;
	struct thread *parent = (struct thread *)aux;
	struct thread *current = thread_current();
	/* TODO: somehow pass the parent_if. (i.e. process_fork()'s if_) */
	struct intr_frame *parent_if;
	bool succ = true;

	/* 1. CPU 컨텍스트를 지역 스택으로 복사합니다. */
	memcpy(&if_, parent_if, sizeof(struct intr_frame));

	/* 2. 페이지 테이블 복제 */
	current->pml4 = pml4_create();
	if (current->pml4 == NULL)
		goto error;

	process_activate(current);
#ifdef VM
	supplemental_page_table_init(&current->spt);
	if (!supplemental_page_table_copy(&current->spt, &parent->spt))
		goto error;
#else
	if (!pml4_for_each(parent->pml4, duplicate_pte, parent))
		goto error;
#endif

	/* TODO: 이 아래에 코드를 작성해야 합니다.
	 * TODO: 힌트) 파일 객체를 복제하려면 include/filesys/file.h의 `file_duplicate`를 사용하세요.
	 * TODO:       이 함수가 부모의 자원을 성공적으로 복제할 때까지 부모는 fork()에서 반환되면 안 됩니다. */

	process_init();

	/* 마침내 새로 생성된 프로세스로 전환합니다. */
	if (succ)
		do_iret(&if_);
error:
	thread_exit();
}

맨 처음 아무것도 건드리지 않은 __do_fork 함수는 부모 스레드를 aux를 통해 인자로 받아오는것을 상정하고 만들어져 있는듯 하다. 
우리가 여기서 구현해야 할 부분은 TODO 주석 부분에서 파일 디스크립터 테이블을 복사해오는 것이다. 그런데 파일 디스크립터 테이블은 우리의 thread 구조체에 없다 !! 그러니까 따로 또 만들어줘야겠지?

thread.h

struct file *fd_table[MAX_FD];		 // 파일 디스크럽터 테이블

처음엔 이렇게 선언해주었는데, 바로 커널 패닉이 떠버렸다
이유가 무엇인지 찾아봤더니, thread 구조체의 총 크기는 4KB를 넘으면 안되는데, 이렇게 선언하면 그 크기를 넘어버려서 생기는 듯 했다.

struct file **fd_table;		 // 파일 디스크럽터 테이블

이렇게 이중 포인터로 선언하고, 동적할당으로 할당받자. 그러면 다시 스레드 초기화 함수에서 해줘야겠지?

process_init

static void
process_init(void)
{
	struct thread *current = thread_current();
	current->fd_table = calloc(MAX_FD, sizeof(struct file *));
	current->fork_sema = malloc(sizeof(struct semaphore));
	sema_init(current->fork_sema, 0);
    	ASSERT(current->fd_table != NULL);
}

이렇게 동적할당까지 완료하자
 

다시 do_fork 구현으로

자, 우리는 인자로 thread 대신에 fork_info라는 구조체를 대신 넘겨줬었다. 그러면 그에 맞게 인자도 수정해주자

static void __do_fork(void *aux)
{
	struct fork_info *info = aux;
	struct intr_frame if_;
	struct thread *parent = info->parent;
	struct thread *current = thread_current();
	/* TODO: somehow pass the parent_if. (i.e. process_fork()'s if_) */
	struct intr_frame *parent_if = &info->parent_if;
	bool succ = true;
    ...

fork_info에서 각 인자들을 꺼내줘서 각 변수들에 배치해주자

	/* 1. CPU 컨텍스트를 지역 스택으로 복사합니다. */
	memcpy(&if_, parent_if, sizeof(struct intr_frame));

	/* 2. 페이지 테이블 복제 */
	current->pml4 = pml4_create();
	if (current->pml4 == NULL)
		goto error;

1. 먼저 do_iret에서 사용할 if_ 에 가져온 인터럽트 프레임을 복사해준다
2. 그 다음 자식 프로세스가 사용할 페이지 테이블 공간을 만들어주자. 이 영역은 현재 아무것도 없는 영역이다.
 

if (!pml4_for_each(parent->pml4, duplicate_pte, parent))
    goto error;

우리는 이 코드에서 부모의 4단계 페이지 테이블들을 모두 각각 순회하며 자식의 페이지 테이블들에 복사해줄 것이다.
pml4_for_each 내부 로직을 따라가 보면 pml4 -> pdpt -> pd -> pt 순으로 따라가며 결국 모든 페이지 테이블 엔트리를 복사해오는 것을 알 수 있다.
여기서 구현해줄 부분은 duplicate_pte ! 여기는 나중에 일단 따라가보자
 

/* TODO: 이 아래에 코드를 작성해야 합니다.
 * TODO: 힌트) 파일 객체를 복제하려면 include/filesys/file.h의 `file_duplicate`를 사용하세요.
 * TODO:       이 함수가 부모의 자원을 성공적으로 복제할 때까지 부모는 fork()에서 반환되면 안 됩니다. */
/* 부모의 fd_table을 순회하며 복사 */
if (parent->fd_table[0] != NULL)
    current->fd_table[0] = parent->fd_table[0]; // 표준 입력
if (parent->fd_table[1] != NULL)
    current->fd_table[1] = parent->fd_table[1]; // 표준 출력
for (int i = 2; i < MAX_FD; i++)
{
    if (parent->fd_table[i] != NULL)
        current->fd_table[i] = file_duplicate(parent->fd_table[i]);
}

여기서는 파일 디스크립터 테이블을 복사해주기만 하면 끝 !!
file_duplicate 함수를 사용하면 된다고도 친절하게 달려있다. 
물론 부모의 fd_table이 null 일 수도 있으니 검사를 미리 해 줘야한다.
 

if_.R.rax = 0;

마지막으로는 만들어진 임시 인터럽트 테이블의 rax값을 0으로 저장해줘야 한다.
우리가 fork 후에 자식과 부모 프로세스를 구분할 때는 pid가 0이냐 아니냐로 구분한다. 어떻게 그렇게 구분할수 있느냐? 여기 나와있잖아ㅋ rax 값을 0으로 만들어주니까~
그럼 0이 되야 한다는건 누가 정해준거지?? 그건 UNIX/POSIX 운영체제 표준에서 약속된거라고 한다. 뭐 약속된거라고 하니 우리가 뭐라고 할 부분은 아닌 것 같다.
그럼 왜 rax에다 저장해주는건가? 그것도 시스템 콜 호출 규약때문이란다.
 
그럼 미뤄뒀던 duplicate_pte로 가보자

duplicate_pte

/* 부모의 주소 공간을 복제하기 위해 이 함수를 pml4_for_each에 전달합니다.
 * 이 함수는 project 2에서만 사용됩니다. */
static bool
duplicate_pte(uint64_t *pte, void *va, void *aux)
{
	struct thread *current = thread_current();
	struct thread *parent = (struct thread *)aux;
	void *parent_page;
	void *newpage;
	bool writable;

	/* 1. TODO: parent_page가 커널 페이지이면 즉시 반환해야 합니다. */

	/* 2. 부모의 page map level 4에서 VA를 해석합니다. */
	parent_page = pml4_get_page(parent->pml4, va);

	/* 3. TODO: 자식 프로세스를 위해 새로운 PAL_USER 페이지를 할당하고 결과를
	 *    TODO: NEWPAGE에 저장해야 합니다. */

	/* 4. TODO: 부모 페이지를 새 페이지에 복사하고
	 *    TODO: 부모 페이지가 쓰기 가능한지 여부를 검사합니다.
	 *    TODO: 결과에 따라 WRITABLE을 설정합니다. */

	/* 5. VA 주소에 WRITABLE 권한으로 새 페이지를 자식의 페이지 테이블에 추가합니다. */

	if (!pml4_set_page(current->pml4, va, newpage, writable))
	{
		/* 6. TODO: 페이지 삽입 실패 시 에러 핸들링을 해야 합니다. */
	}
	return true;
}

정말 친절하게 TODO 주석이 달려있다. 우리는 이 주석을 따라서 코드를 추가해주기만 하면 장땡이다. 아 쉽다 쉬워
이 함수는 각 페이지 테이블 엔트리를 복사해주는 역할을 한다. 우리가 그 역할을 구현해주면 된다.

/* 1. TODO: parent_page가 커널 페이지이면 즉시 반환해야 합니다. */
if (is_kernel_vaddr(va))
    return true;

미리 구현되어있는 is_kernel_vaddr(va)로 커널 페이지인지 아닌지 확인하자. 리턴값은 나도 false인지 true 잘 모르겠다. 둘 다 출력은 똑같이 나오는데.. false를 반환하면 그 즉시 복사가 종료된다고 하니 일단 true로 해 주었다.
 

/* 2. 부모의 page map level 4에서 VA를 해석합니다. */
parent_page = pml4_get_page(parent->pml4, va);
if (parent_page == NULL)
    return true;

/* 3. TODO: 자식 프로세스를 위해 새로운 PAL_USER 페이지를 할당하고 결과를
 *    TODO: NEWPAGE에 저장해야 합니다. */
/* PAL_USER = 유저 풀에서 페이지를 할당해라 */
newpage = palloc_get_page(PAL_USER | PAL_ZERO);
if (newpage == NULL)
    return false;

2번은 사실 건드릴 필요 없이 제대로 부모의 페이지를 가져왔는지만 확인하자.
3번은 페이지를 복사받을 공간을 palloc_get_page로 할당받아줘야 한다. 여기서 넘겨주는 플래그는 PAL_USER, PAL_ZERO인데, PAL_USER는 메모리의 유저 영역에서 페이지를 할당받겠다는 뜻이고, PAL_ZERO는 할당한 페이지의 내용을 0으로 초기화해주겠다는 뜻이다.
 

/* 4. TODO: 부모 페이지를 새 페이지에 복사하고
 *    TODO: 부모 페이지가 쓰기 가능한지 여부를 검사합니다.
 *    TODO: 결과에 따라 WRITABLE을 설정합니다. */
memcpy(newpage, parent_page, PGSIZE);
writable = is_writable(pte);

/* 5. VA 주소에 WRITABLE 권한으로 새 페이지를 자식의 페이지 테이블에 추가합니다. */
if (!pml4_set_page(current->pml4, va, newpage, writable))
{
    palloc_free_page(newpage);
    return false;
}

이제 memcpy로 새로 할당받은 newpage에 부모의 페이지를 PGSIZE = 페이지 크기만큼 복사받고, 이미 구현되어있는 is_writeable 매크로를 통해 이 페이지 테이블 엔트리의 제어 비트 중 쓰기 비트를 writable에 저장해둔다.
엥? 페이지 테이블 엔트리의 제어 비트? 이게 뭐지? 싶으면 다음 노션을 다시 보고 옵시다


https://www.notion.so/1bd7bb7778c980c9b272ddca6167bb93#1da7bb7778c9804bada8eed236f60bd4

 

가상 메모리 | Notion

페이지 테이블

verdant-bathtub-bae.notion.site

페이지 테이블 엔트리의 제어 비트들

여기서의 쓰기 비트도 가져와야 하니까 !! 그럼 다른 비트들은 왜 복제해오질 않지?


P 비트 = 페이지가 실제 메모리에 존재하는가? -> 당연히 새로 만들어진 페이지니까 존재한다 !!
U/S 비트 = 복사되는건 당연히 유저 메모리 주소다 !!
Dirty, Accessed, 기타 캐시/상태 비트 = 런타임 도중 커널/하드웨어가 자동으로 세팅해서 굳이 복제할 필요가 없다고 한다


그 다음으로 pml4_set_page로 우리가 이미 저장해둔 변수들로 가상 주소에 새 페이지를 자식의 페이지 테이블에 추가해주고 실패한다면 할당받은 newpage의 메모리 누수를 막기 위해 free를 진행해주면 된다
 

결과

결과

현재 결과는 이렇게 나온다. 물론 pass가 뜨지는 않는다. pass를 보려면 process_wait 까지 구현을 해줘야 한다.
실제 fork-once의 기대 결과는 다음과 같다

다음은 wait를 구현해보자

정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차