일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- Project 1
- 핀토스 프로젝트 4
- botw
- 황금 장미
- 셋업
- 내일부터
- 끝
- 가테
- 핀토스 프로젝트 3
- 자 이제 시작이야
- 아직도 실험 중
- 핀토스 프로젝트 1
- 바빠지나?
- 글루민
- 글리치
- alarm clock
- 핀토스 프로젝트
- 파란 장미
- 제발흰박
- 일지 시작한 지 얼마나 됐다고
- 핀토스 프로젝트 2
- multi-oom
- 노가다
- PINTOS
- 마일섬
- Today
- Total
거북이의 쉼터
(2022.02.08) System Calls 가이드라인 (2/2) 본문
오늘은 fork의 구현에 사용될 process_fork의 세부적인 동작을 코드 단위로 뜯어보면서 어디를 고쳐야 할지를 살펴보자.
/* Clones the current process as `name`. Returns the new process's thread id, or
* TID_ERROR if the thread cannot be created. */
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 ());
}
우선 시작인 process_fork이다. 현재 실행되고 있는 thread의 복사본인 새로운 thread를 생성하고 해당 thread의 tid를 반환하는 코드이다. 만약 생성이 불가하다면 TID_ERROR를 반환하라고 나와 있다. 참고로 thread_create의 현재 코드는 다음과 같은데,
tid_t
thread_create (const char *name, int priority,
thread_func *function, void *aux) {
struct thread *t;
tid_t tid;
ASSERT (function != NULL);
/* Allocate thread. */
t = palloc_get_page (PAL_ZERO);
if (t == NULL)
return TID_ERROR;
/* Initialize thread. */
init_thread (t, name, priority);
tid = t->tid = allocate_tid ();
/* Call the kernel_thread if it scheduled.
* Note) rdi is 1st argument, and rsi is 2nd argument. */
t->tf.rip = (uintptr_t) kernel_thread;
t->tf.R.rdi = (uint64_t) function;
t->tf.R.rsi = (uint64_t) aux;
t->tf.ds = SEL_KDSEG;
t->tf.es = SEL_KDSEG;
t->tf.ss = SEL_KDSEG;
t->tf.cs = SEL_KCSEG;
t->tf.eflags = FLAG_IF;
/* Add to run queue. */
thread_unblock (t);
preemption ();
return tid;
}
이를 볼 때, process_fork에서 복제본 thread 생성이 불가능한 경우는 크게 2개를 생각할 수 있다.
- thread_create 자체에서 palloc_get_page가 실패해 생성 불가로 TID_ERROR가 반환된 경우
- 인자로 주어진 function, 해당 경우에는 __do_fork에서 현재 thread를 완전히 복제하는데 실패한 경우
1번은 반환이 바로 되어 실패했다는 것을 바로 알 수 있지만 2번에서 실패하는 경우, thread_create에서 자식 프로세스의 tid가 반환이 되었어도 process_fork는 TID_ERROR를 반환해야 한다. 따라서, 자식 프로세스가 thread_create에서 생성된 이후, __do_fork까지의 실행결과를 기다리는 과정이 필요하다. 이를 위해서는 이전 포스팅에서 언급한 대로 semaphore를 활용한 방법을 생각해볼 수 있다. __do_fork의 스켈레톤 코드는 다음과 같으며,
/* A thread function that copies parent's execution context.
* Hint) parent->tf does not hold the userland context of the process.
* That is, you are required to pass second argument of process_fork to
* this function. */
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. Read the cpu context to local stack. */
memcpy (&if_, parent_if, sizeof (struct intr_frame));
/* 2. Duplicate PT */
current->pml4 = pml4_create();
if (current->pml4 == NULL)
goto error;
process_activate (current);
#ifdef VM
supplemental_page_table_init (¤t->spt);
if (!supplemental_page_table_copy (¤t->spt, &parent->spt))
goto error;
#else
if (!pml4_for_each (parent->pml4, duplicate_pte, parent))
goto error;
#endif
/* TODO: Your code goes here.
* TODO: Hint) To duplicate the file object, use `file_duplicate`
* TODO: in include/filesys/file.h. Note that parent should not return
* TODO: from the fork() until this function successfully duplicates
* TODO: the resources of parent.*/
process_init ();
/* Finally, switch to the newly created process. */
if (succ)
do_iret (&if_);
error:
thread_exit ();
}
제대로 복사가 되게 된다면 error 쪽 코드로 빠지는 일 없이 do_iret으로 성공적으로 context switching이 이루어질 것이다. 여기서 semaphore를 활용하기 위한 방안은 다음과 같다.
- thread 구조체 내에 미리 semaphore 멤버를 추가한다.
- process_fork에서 thread_create로 생성된 thread의 tid를 확인한다.
- tid가 TID_ERROR가 아니라면 해당 tid를 활용해 자식 프로세스 thread의 포인터를 확보한다.
- 앞 단계에서 확보한 thread 포인터를 이용하면 자식 프로세스 thread 내의 semaphore를 부모 프로세스에서 접근할 수 있으므로, down하여 __do_fork까지 기다리도록 한다.
- __do_fork에서 복제에 성공한다면 do_iret이 호출되기 이전에, 실패한다면 error 쪽 코드에서 semaphore 멤버를 up시켜 부모 프로세스에 통보한다. 실패했다면 실패한 정보를 자식 프로세스 어딘가에 남겨놔 부모가 해석할 수 있도록 한다.
- 부모 프로세스 쪽에서는 자식 프로세스의 최종 복제 성공 여부를 판단하여, 실패했다면 TID_ERROR를, 성공했다면 해당 자식 프로세스의 tid를 반환한다.
이렇게 하면 자식 프로세스의 복제 성공 여부가 완전히 판별되기 전에는 process_fork는 반환되지 않는다.
이제 __do_fork 내에서 해야 할 일을 살펴보도록 하자. 코드에 있는 주석 부분이 마음에 걸린다.
/* A thread function that copies parent's execution context.
* Hint) parent->tf does not hold the userland context of the process.
* That is, you are required to pass second argument of process_fork to
* this function. */
...
/* TODO: somehow pass the parent_if. (i.e. process_fork()'s if_) */
parent thread 포인터는 aux로부터 제공을 받을 수 있지만, 해당 thread 내의 tf 멤버는 user 프로세스의 context를 포함하지 않는다고 한다. 유저 쪽의 intr_frame을 얻기 위해서는 syscall이 호출되어 syscall_handler에 intr_frame이 인자로 주어졌을 때를 노려야 한다.
/* The main system call interface */
void
syscall_handler (struct intr_frame *f UNUSED) {
// TODO: Your implementation goes here.
printf ("system call!\n");
thread_exit ();
}
해당 f를 __do_fork까지 넘겨줘야 한다. 부모 thread 포인터는 기존 코드로도 __do_fork까지 넘어가므로, thread 멤버로 parent_if 같이 intr_frame이 들어갈 공간을 확보하고, 인자로 주어지는 f를 부모 thread 내에 복사하면 성공적으로 __do_fork까지 해당 정보를 넘길 수 있다. 이렇게 cpu context를 복제하는데 성공했다. 단, 지난 포스팅에서도 설명했듯이 자식 프로세스의 반환값으로 해석될 rax만은 0으로 초기화해야 한다. 이를 기억해서 코드에 반영하도록 하자.
cpu context를 복제했으면 다음으로 할 일은 메모리 매핑, 즉 페이지 테이블을 복제하는 일이다. 해당 부분을 시작하기 전에 알아둬야 하는 것은 가상 메모리와 관련된 개념이다. 대체 왜 프로젝트 2를 하고 있는데 프로젝트 3 내용을 알아야 하는지 전혀 감도 안오지만 이걸 모르면 실질적으로 더 나아갈 수 없다.
x64 운영체제는 64 비트 주소공간을 전제하고 설계된다. 주어진 한 주소(상상할 수 있는 아무 주소)에 1 byte(= 8 bit)가 들어가는 것은 알 것이다. 이를 기반으로, 64 비트 주소 공간의 크기를 추산하면 가능한 주소의 종류가 2^64 = 18,446,744,073,709,551,616이므로 18,446,744,073,709,551,616 byte 공간이라는 것을 알 수 있다. 이는 약 18.4 엑사바이트인데, 컴퓨터를 좀 다뤄봤으면 알다시피 이 정도의 메모리를 위해 RAM을 꽂아서 사용한다면 파산할 것이다. 우리가 실제로 갖고 있는 물리적 메모리 공간(RAM)은 좋은 컴퓨터래봤자 32GB를 넘지 않는 경우가 태반이다. 그럼 어떻게 이 정도의 용량만 가지고 64 비트 프로세스를 돌릴 수 있는 것일까? 정답은 그 모든 공간을 안 쓰기 때문이다. 한 프로세스에서 2^64 바이트에 해당하는 영역을 모두 쓰지 않으며, 필요한 부분만 물리적 메모리 공간에 둘 수 있으면 족하다. 이를 위해 64 비트 주소 체계를 갖고 있는 가상 주소와 훨씬 적은 크기를 갖고 있는 물리 주소간의 매핑이 필요하며, 이를 가상화(virtualization), 더 구체적으로는 메모리 매핑이라 한다. 관습적으로 가상 주소에서의 4kB를 페이지로, 물리 주소에서의 대응되는 4kB를 프레임이라고 표현한다.
이러한 메모리 매핑에 사용되는 기법 중 하나가 바로 페이지 테이블 기법이다. 주어진 가상 주소에 대해 대응하는 물리 주소를 갖고 있는 테이블이 있다면 편하게 가상 주소에서 물리 주소를 찾을 수 있다. 물론 1:1로 전부 나열해놓는 것은 가상화를 하는 이유가 없다. 그래서 사람들이 착안한 방법이 이러한 테이블을 여러 단계로 만드는 것이었다. 가상 주소를 몇 비트씩 나누어서 각 부분을 인덱스로 생각하고 해당 인덱스로 상위 테이블의 각 엔트리를 찾으면 하위 단계의 테이블로 연결되는 방식을 고안한 것이다. 최종 테이블은 실제 물리 주소를 담고 있으며, 관습적으로 4kB를 1페이지로 두기 때문에 가상 주소의 하위 12비트는 단순히 해당 물리 주소에서의 offset을 표현하게 한다. 이렇게 만들면 필요없는 가상 주소에 대해서는 페이지 테이블을 만들지 않으면서 일괄적인 규칙으로 물리 주소를 찾을 수 있도록 만들 수 있다. 이러한 페이지 테이블 방식 중 pintos가 채택하고 있는 방식은 페이지 테이블을 4단계로 나눈 방식인 pml4이며, 이는 가장 상위 테이블을 나타내기도 한다. pml4로 가상 주소(virtual address, VA)가 해석되는 방식을 도식화하면 다음과 같다.
지금까지 가상화에 대해 장황하게 설명을 한 것은 pintos에서는 가상 주소와 물리 주소의 느낌이 조금 다르기 때문이다. 프로젝트 3의 introduction을 보면 각 프로세스에서 KERN_BASE 미만의 가상 주소는 각 프로세스만이 접근할 수 있도록 분리되어 있다. (물론 커널에서는 모두 접근 가능하지만) 이와 반대로 모든 프로세스의 KERN_BASE 이상의 가상 주소는 커널에 해당하는 영역으로 global이며, 어떤 thread나 프로세스가 돌아가더라도 같은 위치라는 것이다.
설명을 더 읽어보면 pintos는 커널 가상 주소를 직접적으로 물리 공간으로 매핑하고 있다고 설명한다. 즉, 커널 가상 주소의 첫 페이지가 물리 주소 공간의 첫 프레임이 된다는 것이다. 이 말을 해석하면 커널 페이지가 곧 물리 프레임에 해당한다는 것이며, 이 때문에 페이지를 할당해주는 palloc_get_page 함수에서 순수히 커널 역할로 활용될 부분과 유저 페이지가 될 부분을 pool로 나누어 관리했던 것이다. 아무튼 지금까지 설명한 내용을 그림으로 표현하자면 다음과 같다.
예를 들어 물리 주소 0번지에 해당하는 내용을 바꾸려면 0 + KERN_BASE = KERN_BASE에 해당하는 가상 주소 공간에 있는 내용을 바꾸면 되며, 나머지 물리 주소에 대해서도 비슷한 방식이다. 이러한 점을 종합하면 pintos의 유저 가상 페이지의 내용은 실제로 물리 프레임 중 하나에 내용이 들어있으며, 물리 프레임은 곧 커널 가상 공간 페이지이기 때문에 pintos 아래의 모든 유저 가상 페이지는 커널 가상 공간에 들어가 있게 된다.
여기까지 이해했으면 이제 다시 돌아가서 메모리가 복사되는 부분의 코드를 살펴보자. 해당 코드 부분을 가져오면,
/* 2. Duplicate PT */
current->pml4 = pml4_create();
if (current->pml4 == NULL)
goto error;
process_activate (current);
#ifdef VM
supplemental_page_table_init (¤t->spt);
if (!supplemental_page_table_copy (¤t->spt, &parent->spt))
goto error;
#else
if (!pml4_for_each (parent->pml4, duplicate_pte, parent))
goto error;
#endif
아직 프로젝트 3를 하기 전이므로 #else 쪽의 코드로 해석될 것이다. 이를 보면 pml4_for_each로 부모 프로세스의 pml4를 복제해야 하는 것으로 보인다. pml4_for_each의 코드를 타고 들어가면서 최종적으로 당도하는 함수인 pt_for_each를 살펴보면,
static bool
pt_for_each (uint64_t *pt, pte_for_each_func *func, void *aux,
unsigned pml4_index, unsigned pdp_index, unsigned pdx_index) {
for (unsigned i = 0; i < PGSIZE / sizeof(uint64_t *); i++) {
uint64_t *pte = &pt[i];
if (((uint64_t) *pte) & PTE_P) {
void *va = (void *) (((uint64_t) pml4_index << PML4SHIFT) |
((uint64_t) pdp_index << PDPESHIFT) |
((uint64_t) pdx_index << PDXSHIFT) |
((uint64_t) i << PTXSHIFT));
if (!func (pte, va, aux))
return false;
}
}
return true;
}
으로 func에 들어갈 인자인 pte, va, aux가 여기서 결정되는 것을 확인할 수 있다. 이번에 사용되는 func인 duplicate_pte는 나중에 확인하고 우선 pte, va, aux가 무엇인지 확인부터 해보자.
첫 번째 인자는 pte이다. pte = &pt[i]로 나와 있으며, 이는 도식에서 마지막 페이지 테이블 내 엔트리의 주소값, 즉, 도식에서의 PTE가 위치한 주소를 나타낸 것이다. (도식에서 가장 오른쪽에 위치한 사각형들은 실제 physical frame을 표현한 것으로, 가장 마지막 페이지 테이블은 오른쪽에서 두 번재 위치한 사각형들이다) 따라서 해당 pte 변수를 역참조한 *pte 값은 physical frame의 시작 지점인 실제 물리 주소가 되는 것이다.
PTE 값, 그러니까 최종 페이지 테이블의 주소를 직접 주는 대신 왜 그 값이 들어있는 주소를 쥐어준걸까 생각을 해 보았다. mmu.h에 나와있는 기본 매크로를 살펴보면 해당 매크로들은 PTE 값이 아닌 그 값이 들어있는 주소를 요구한다는 것을 알 수 있다.
#define is_writable(pte) (*(pte) & PTE_W)
#define is_user_pte(pte) (*(pte) & PTE_U)
#define is_kern_pte(pte) (!is_user_pte (pte))
#define pte_get_paddr(pte) (pg_round_down(*(pte)))
이와 맞추기 위해 PTE 값이 위치한 최종 페이지 테이블 엔트리의 주소값을 주는 것으로 해석된다.
두 번째 인자는 va이다. va는 pml4_for_each부터 pt_for_each까지 타고 내려오면서 어떤 va를 해석하면 첫 번째 인자의 페이지 테이블이 구해지는지를 나타내고 있다. 도식에서의 Physical Page Offset을 나타내는 아래 12bit를 제외하고 나머지 인덱스를 합쳐서 만들어졌으므로 이를 pml4식으로 해석하면 첫 인자의 *pte와 연동되는 가상 주소가 va가 되는 것이다.
세 번째 인자는 aux이며, 이는 가장 처음에 pml4_for_each의 3번째 인자로 넘겨주었던 parent (부모 thread 포인터)가 그대로 들어간다. 즉, duplicate_pte에서 부모 프로세스의 thread 포인터를 사용할 수 있음을 알 수 있다. 이제 실제로 페이지 테이블을 복제하는 함수인 duplicate_pte의 코드를 살펴보자.
#ifndef VM
/* Duplicate the parent's address space by passing this function to the
* pml4_for_each. This is only for the 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: If the parent_page is kernel page, then return immediately. */
/* 2. Resolve VA from the parent's page map level 4. */
parent_page = pml4_get_page (parent->pml4, va);
/* 3. TODO: Allocate new PAL_USER page for the child and set result to
* TODO: NEWPAGE. */
/* 4. TODO: Duplicate parent's page to the new page and
* TODO: check whether parent's page is writable or not (set WRITABLE
* TODO: according to the result). */
/* 5. Add new page to child's page table at address VA with WRITABLE
* permission. */
if (!pml4_set_page (current->pml4, va, newpage, writable)) {
/* 6. TODO: if fail to insert page, do error handling. */
}
return true;
}
#endif
달려있는 주석을 종합하면 다음과 같이 해석할 수 있다.
- 주어진 va를 pml4를 통해 물리 프레임(커널 주소 공간 내의 페이지)으로 해석한 것이 parent_page이다.
- 해당 parent_page가 커널로 활용되는 페이지라면, 즉 kernel pool의 페이지라면 바로 반환한다.
- 새로운 user pool의 페이지를 하나 할당하고 newpage라고 명명한다. 해당 newpage가 자식의 메모리로서 활용될 것이다.
- newpage로 parent_page의 내용을 복사하고, parent_page의 writable 여부에 따라 writable 플래그를 설정한다.
- newpage를 자식의 pml4를 통해 찾을 수 있도록 매핑을 추가한다.
- 만약 매핑을 추가하는데 실패할 경우 에러 핸들링을 시행한다.
va에 대응되는 물리 프레임이 커널 용도로 사용되는 프레임이라면 해당 페이지는 복사할 필요가 없다. 왜냐하면 앞서 봤듯이 커널 공간은 모든 프로세스에 대해 공통적이기 때문이다. 다만 이를 달성하기 위해 복잡하게 parent_page로 해석해서 kernel pool에 해당하는 페이지인지 검사하는 것보다는 주어진 va가 KERN_BASE 이상인지를 판단하는 것이 더 편할 것이라 생각한다. 인자로 들어가는 va의 예상 범위는 모두 KERN_BASE 아래이다. 왜냐하면 기존 프로세스의 페이지 테이블에서 KERN_BASE 이상의 가상 주소 공간을 해석할 정상적인 이유가 없기 때문에 복제하는 과정에서도 재조합했을 때 KERN_BASE 이상의 va가 나올 수 없기 때문이다. 때문에 va가 KERN_BASE 이상일 때는 어떠한 오류가 일어나거나 의도치 않은 동작임으로 메모리 매핑을 복제할 수 없다고 판단해야 한다. 다른 경우에서도 이는 마찬가지인데, newpage가 할당이 되지 않거나, 복사가 성공적으로 되지 않는 등 1~6 중 하나라도 실패한다면 메모리 매핑을 복제할 수 없다고 판단해야 한다. 매 pt_for_each에서 호출되는 duplicate_pte들 중 하나라도 return false로 반환되면 pml4_for_each의 반환값은 false가 되면서 fork에 실패하기 때문에 1~6 중 하나라도 실패하면 return false를 통해 fork가 되는 것을 막는다.
(2023.01.11 수정, 구현 포스팅에 있는 내용 일부 발췌)
결론적으로 va가 커널 영역인지 검사하는 코드에서 반환값이 false가 아닌 true가 되어야 할 것 같았다. 해당 조건이 만족할 때 바로 반환하라는 주석의 의도는 커널 주소가 인자로 들어오면 바로 fork에 실패하라는 것은 아닐 것이다. 애당초 pml4_for_each의 동작 원리를 생각하면 duplicate_pte의 va인자로 커널 주소는 반드시 들어가도록 되어 있다.
따라서 해당 주석의 의미는 아마도 커널 영역은 모든 프로세스를 통틀어 공통이기 때문에 복사할 필요가 없다는 것이다. 복사할 영역에 해당하는 페이지들은 유저 페이지로 한정되어 있다는 것이다. 따라서 인자로 커널 영역이 들어올 경우 true를 반환하도록 수정하면 문제가 해결될 것이다.
이 과정까지 마치면 메모리 매핑까지 자식 프로세스에 복제된 것을 알 수 있다. 이제 남은 것은 파일 리소스를 복제하는 것이다.
/* TODO: Your code goes here.
* TODO: Hint) To duplicate the file object, use `file_duplicate`
* TODO: in include/filesys/file.h. Note that parent should not return
* TODO: from the fork() until this function successfully duplicates
* TODO: the resources of parent.*/
file_duplicate를 활용하면 된다고 나와있다. 해당 코드는 아래와 같다.
/* Duplicate the file object including attributes and returns a new file for the
* same inode as FILE. Returns a null pointer if unsuccessful. */
struct file *
file_duplicate (struct file *file) {
struct file *nfile = file_open (inode_reopen (file->inode));
if (nfile) {
nfile->pos = file->pos;
if (file->deny_write)
file_deny_write (nfile);
}
return nfile;
}
file_duplicate는 열려있는 file을 가리키는 file 포인터의 복제본을 만드는 것이다. 복제본을 만드는 것의 의의는 복제된 시점에서는 같은 파일, 같은 읽기/쓰기 위치에 커서가 위치해 있지만, 차후에는 독자적으로 움직일 수 있도록 하기 위함이다. 따라서 file 포인터의 복제본을 만들기 위해서 file 구조체 내 각 멤버를 생각하면 다음과 같다.
- 실제로 가리키는 파일의 내용은 같아야 하기 때문에 파일 시스템 내에 파일의 내용이 들어있는 inode 멤버는 동일하게 가져와야 한다. 다만, fork를 통해 새로운 프로세스 하나가 해당 파일을 open한 상태가 되기 때문에 파일을 open한 카운트는 증가해야 한다. 이를 위해 inode_reopen을 하는 것이다.
- pos는 복제된 시점에서는 기존 프로세스와 같아야 한다. 따라서 nfile->pos = file->pos로 같게 만든다.
- deny_write도 같게 유지한다.
기초 스켈레톤 코드에서는 아직 구현이 되지 않았지만 이번 프로젝트를 진행하면서 file descriptor를 구현하게 되면 부모 프로세스의 file descriptor table을 순회하면서 연동되는 file 포인터를 모두 복제해 부모 프로세스와 같은 file descriptor를 배정해야 한다. 여기까지 성공적으로 진행했다면 부모 프로세스의 모든 리소스를 자식 프로세스에 복제한 것이 된다.
여기까지 fork의 동작 과정 및 구현 요구 사항을 코드 단위에서 차근차근 뜯어보았다. 이제 다음 포스팅부터 실제로 구현하면서 신나는 버그와의 전쟁을 해보도록 하자.
'코딩 삽질 > KAIST PINTOS (CS330)' 카테고리의 다른 글
(2022.02.15) System Calls 구현 (2/4) (0) | 2022.02.15 |
---|---|
(2022.02.11) System Calls 구현 (1/4) (0) | 2022.02.11 |
(2022.02.06) System Calls 가이드라인 (1/2) (0) | 2022.02.06 |
(2022.02.03) User Memory Access 구현 (0) | 2022.02.03 |
(2022.02.03) User Memory Access 가이드라인 (0) | 2022.02.03 |