이전 시간까지 syscall에서 파일 디스크립터와 lock을 다루는 기능들을 구현했다.
2025.05.25 - [크래프톤 정글] - [pintos] Week2~3: User Program Part.6 - lock 적용
[pintos] Week2~3: User Program Part.6 - lock 적용
이 전 포스팅에서 파일 디스크립터에 대해 알아보고 pintos에서 파일 디스크립터를 어떻게 구현할지 알아보았다. 이제 파일과 관련된 시스템콜 중 lock을 이용해 파일에 대한 동시 접근을 제어하
www.gowoong.com
이제 시스템 콜에서 매우 중요하다고 할 수. 있는 기능들을 구현하려고 한다. 먼저 fork를 먼저 살펴보겠다.
Fork() 란?
fork()는 현재 실행 중인 프로세스를 완전히 복제하여 새로운 자식 프로세스(child process)를 생성하는 시스템 콜이다.
fork() 호출 결과
항목 | 부모 프로세스 | 자식 프로세스 |
리턴값 | 자식의 PID (tid 등) | 0 |
주소 공간 | 동일한 내용을 갖는 별도 복사본 | |
열린 파일 | 동일한 파일들을 공유 (file* 공유 + 참조 카운팅 증가) | |
코드 흐름 | 부모와 자식이 같은 위치에서 실행 재개 |
단, 메모리 공간, 스택, 레지스터 등은 별개 공간이어야 함 (복사 필요)
fork()의 주요 복사 대상
항목 | 설명 |
주소 공간 | 부모의 가상 메모리(코드, 데이터, 스택)를 자식에게 복제 |
레지스터 상태 | 부모의 레지스터(intr_frame 등)를 자식에게 복제 |
파일 디스크립터 테이블(FDT) | 공유되지만 struct file은 참조 카운트 방식으로 관리 |
커널 스택 | 공유하지 않음. 새로 할당 |
PCB (struct thread) | 새 스레드로 새로 생성해야 함 |
pintos에서 구현 시 사용하는 함수의 역할
함수 | 설명 |
process_fork(const char *name, struct intr_frame *if_) | syscall.c 또는 process.c에서 호출되는 entry 함수 |
__do_fork(void *aux) | 자식 스레드에서 실행되며 주소 공간, 레지스터 복사 등 수행 |
duplicate_pte() | 페이지 테이블 복사 (VM 켜진 경우) |
file_duplicate() | FDT 복사 시 file 참조 카운트 증가 |
부모 자식 구조 구현
헤더파일 선언
- include/threads/thread.h
#include "threads/synch.h" // semaphore 적용을 위함
thread 구조체 수정
- include/threads/thread.h
struct thread {
#ifdef USERPROG
int exit_status;
struct list child_list;
struct list_elem child_elem;
struct thread *parent;
struct file *running;
struct intr_frame parent_if;
struct file **fd_table; /* 파일 디스크립터 테이블 */
int fd_idx; /* 다음 할당할 FD 번호 */
uint64_t *pml4; /* 페이지 맵 레벨 4 포인터 */
struct semaphore wait_sema;
struct semaphore fork_sema;
struct semaphore exit_sema;
#endif
- semaphore가 적합하다. 조건 변수(cond)를 대신 사용할 수는 있지만, Pintos에서의 fork, wait, exit처럼 프로세스 간 1:1 시그널링과 절대 순서 보장이 필요한 상황에서는 semaphore가 더 안정적이고 간결하다.
fork_sema: 자식 프로세스의 load() 완료 대기
- 부모는 process_fork()에서 sema_down(&child->fork_sema)로 자식의 초기화가 끝날 때까지 기다림
- 자식은 __do_fork()에서 준비 완료 후 sema_up(&t->fork_sema)로 부모를 깨움
- 단방향적 신호 (자식 → 부모)
exit_sema: 자식 프로세스 종료 후 부모가 수거될 때까지 대기
- 자식은 process_exit()에서 sema_down(&exit_sema)로 부모의 수거를 기다림
- 부모는 process_wait()에서 자식 종료를 감지하면 sema_up(&child->exit_sema)로 자식을 종료시킴
wait_sema: 부모가 자식의 종료를 기다릴 때 사용
- 자식이 먼저 종료되면 부모는 wait에서 block 중 → sema_up(&wait_sema)로 깨워줌
왜 condition variable이 아닌가?
- 조건 변수를 쓰려면 반드시 락이 필요하다.
- 세마 포어는 그 자체로 동기화 수단이지만 조건 변수는 기다리는 조건과 락 해제와 획득 등의 복합 적인 설계가 필요하다.
init_thread() 함수 수정
- threads/thread.c
static void init_thread (struct thread *t, const char *name, int priority) {
.
.
.
#ifdef USERPROG
t->exit_status = 0;
t->running = NULL;
sema_init(&t->wait_sema, 0);
sema_init(&t->fork_sema, 0);
sema_init(&t->exit_sema, 0);
/* 프로세스 관계 초기화 */
list_init(&t->child_list);
#endif
.
.
.
}
- 세마포어는 전부 0의 값을 가지고 생성된다. (신호 대기용)
- 세마포어의 초기값을 0으로 설정하면, 처음부터 락이 잠긴 상태, 즉 누군가의 sema_up()이 오기 전까지는 sema_down()에서 무조건 대기하게 된다.
thread_create() 함수 수정
- threads/thread.c
tid_t thread_create (const char *name, int priority,
thread_func *function, void *aux) {
struct thread *t;
tid_t tid;{
.
.
.
#ifdef USERPROG
t->fd_table = palloc_get_multiple(PAL_ZERO, FDT_PAGES);
if (t->fd_table == NULL)
return TID_ERROR;
t->exit_status = 0; // exit_status 초기화
t->fd_idx = 3;
t->fd_table[0] = 0; // stdin 예약된 자리 (dummy)
t->fd_table[1] = 1; // stdout 예약된 자리 (dummy)
t->fd_table[2] = 2; // stderr 예약된 자리 (dummy)
list_push_back(&thread_current()->child_list, &t->child_elem);
#endif
.
.
.
}
- 현재 부모 프로세스의 자식 리스트에 새로운 자식 프로세스를 등록하는 역할을 한다.
- child_list는 부모가 소유한 자식 프로세스들을 모아둔 리스트이다.
get_child_process() (추가 구현 함수)
- userprog/process.h
struct thread *get_child_process(int pid);
int process_add_file(struct file *f);
struct file *process_get_file(int fd);
int process_close_file(int fd);
- get_child_process(int pid) 함수는 Pintos에서 process_wait(pid) 시스템 콜 구현에 직접적으로 사용되는 자식 프로세스 검색 함수이다.
- 이 함수는 현재 스레드(부모)의 자식 리스트에서 특정 pid(tid)를 가진 자식 프로세스를 찾고, 찾은 자식의 struct thread * 포인터를 반환한다.
- 현재 스레드(= 부모)의 child_list를 순회하여, 해당 pid(= tid)를 갖는 자식 프로세스를 찾아 반환한다. 없으면 NULL.
- userprog/process.c
struct thread *get_child_process(int pid) {
struct thread *curr = thread_current();
struct thread *t;
for (struct list_elem *e = list_begin(&curr->child_list);
e != list_end(&curr->child_list);
e = list_next(e)) {
t = list_entry(e, struct thread, child_elem);
if (pid == t->tid)
return t;
}
return NULL;
}
fork 구현
fork() syscall 구현
void
syscall_handler (struct intr_frame *f UNUSED) {
switch (f->R.rax)
{
case SYS_HALT:
halt(); // 핀토스 종료
break;
case SYS_EXIT:
exit(f->R.rdi); // 프로세스 종료
break;
case SYS_FORK:
f->R.rax = fork(f->R.rdi, f);
break;
.
.
.
}
- userprog/syscall.c
tid_t fork(const char *thread_name, struct intr_frame *f) {
check_address(thread_name);
return process_fork(thread_name, f); // 실제 유저 컨텍스트를 넘긴다
}
process_fork() 함수 구현
- userprog/process.c
아래는 정석? 방식의 process_fork이다.
tid_t process_fork (const char *name, struct intr_frame *if_ UNUSED) {
struct thread *curr = thread_current();
struct intr_frame *f = (pg_round_up(rrsp()) - sizeof(struct intr_frame));
memcpy(&curr->parent_if, f, sizeof(struct intr_frame));
tid_t tid = thread_create(name, PRI_DEFAULT, __do_fork, curr);
if (tid == TID_ERROR)
return TID_ERROR;
struct thread *child = get_child_process(tid);
sema_down(&child->fork_sema);
if (child->exit_status == TID_ERROR)
return TID_ERROR;
return tid;
}
- process_fork() 함수는 Pintos에서 fork() 시스템 콜의 핵심 구현 함수다. 즉, 현재 실행 중인 부모 프로세스를 복제하여 자식 프로세스를 생성하고, 초기화가 완료될 때까지 동기화하며, 성공적으로 생성된 자식의 tid를 부모에게 반환하는 역할을 한다.
정석 방식 -> 구조체 위치를 계산하여 intr_frame을 가져온다.
더보기
struct intr_frame *f = (pg_round_up(rrsp()) - sizeof(struct intr_frame));
- 현재 스택에서 레지스터 상태 저장용 intr_frame 구조체의 위치를 계산한다. 이는 자식 프로세스를 부모와 똑같은 상태로 실행 재개 시키기 위해서 이다.
- intr_frame은 인터럽트 진입 시 커널이 저장해 두는 레지스터 상태 전체이다.
- 시스템 콜이 발생하면 유저 모드에서 커널 모드로 전환되며, 이때의 레지스터 값(RIP, RSP, RAX 등)이 struct intr_frame에 저장된다.
- syscall_handler()는 이 구조체 포인터를 통해 유저의 호출 정보를 읽고 결과(RAX)를 설정한다.
왜 pg_round_up(rrsp()) - sizeof(struct intr_frame)인가?
struct intr_frame *f = (pg_round_up(rrsp()) - sizeof(struct intr_frame));
- 현재 스택 포인터(RSP)를 기준으로 intr_frame이 저장된 정확한 위치를 계산하기 위해서이다.
- 이 intr_frame은 보통 커널 스택 최상단 부근에 위치하므로, 페이지 경계를 기준으로 그 바로 아래에 위치시킨다.
- pg_round_up()으로 페이지 상단까지 올리고, 거기서 sizeof(intr_frame)만큼 빼면 위치를 정확히 알 수 있다.
- 즉, syscall_entry 시점에 저장되었던 intr_frame과 같은 형식을 유지하기 위해, 동일한 위치에 만들고 복사하는 것이다.
🤔 하지만 나는 다르게 생각했다.
- 이미 syscall에서 intr_frame 정보를 가지고 있는데? 굳이 process_fork()에서 다시 해당 구조체를 찾기 위해 주소 계산을 해야 할까?
- 그러한 생각으로 syscall.c 를 수정하여 intr_frame 구조체를 받아서 해당 구조체를 복사해서 사용하는 것을 채택했다.
tid_t process_fork(const char *name, struct intr_frame *if_ UNUSED) {
struct thread *curr = thread_current();
// 직접 넘겨받은 intr_frame을 복사
memcpy(&curr->parent_if, if_, sizeof(struct intr_frame));
tid_t tid = thread_create(name, PRI_DEFAULT, __do_fork, curr);
if (tid == TID_ERROR)
return TID_ERROR;
struct thread *child = get_child_process(tid);
sema_down(&child->fork_sema); // 자식이 준비될 때까지 기다림
if (child->exit_status == TID_ERROR)
return TID_ERROR;
return tid;
}
memcpy(&curr->parent_if, f, sizeof(struct intr_frame));
- 현재 부모의 인터럽트 프레임(레지스터 상태)을 curr->parent_if에 저장해 둔다.
- 이 정보를 자식에게 그대로 전달하여, 자식이 부모와 동일한 상태로 실행 재개할 수 있도록 준비한다.
- 이 레지스터 복사 덕분에 fork() 이후 자식도 main 루틴이 아닌 정확히 fork 리턴 지점부터 실행된다.
tid_t tid = thread_create(name, PRI_DEFAULT, __do_fork, curr);
- 이때 커널 스택과 thread 구조체만 생성되고, 주소 공간, 레지스터 등은 아직 복사되지 않았음.
- 복사는 __do_fork()에서 수행된다.
if (tid == TID_ERROR)
return TID_ERROR;
- 자식 생성 실패 시 오류 코드 반환
struct thread *child = get_child_process(tid);
- 부모의 자식 리스트에서 tid에 해당하는 자식 스레드 구조체를 찾아온다.
- 이를 통해 자식이 sema_up(&fork_sema)할 때까지 기다릴 수 있게 된다.
sema_down(&child->fork_sema);
- 부모는 자식이 __do_fork()에서 주소 공간, 파일 테이블 복사 등을 마칠 때까지 대기한다.
- 자식은 __do_fork() 끝에서 sema_up()을 호출하여 부모를 깨운다.
- 이 동기화 덕분에 부모는 자식이 준비되었을 때만 tid를 안전하게 리턴한다.
if (child->exit_status == TID_ERROR)
return TID_ERROR;
- 자식이 __do_fork() 도중 실패했다면, exit_status를 -1 또는 TID_ERROR로 설정하고 exit()로 종료된다.
- 이 경우 부모는 오류임을 감지하고 fork()도 실패로 처리한다.
__do_fork() 구현
- userprog/process.c
/* 부모의 실행 컨텍스트를 복사하는 스레드 함수다.
* 힌트) parent->tf는 프로세스의 사용자 영역 컨텍스트를 유지하지 않는다.
* 즉, process_fork의 두 번째 인수를 이 함수에 전달해야 한다. */
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 = &parent->parent_if;
bool succ = true;
/* 1. Read the cpu context to local stack. */
memcpy (&if_, parent_if, sizeof (struct intr_frame));
if_.R.rax = 0; // 자식 프로세스의 return값 (0)
/* 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
if (parent->fd_idx >= FDCOUNT_LIMIT)
goto error;
current->fd_idx = parent->fd_idx; // fdt 및 idx 복제
for (int fd = 3; fd < parent->fd_idx; fd++) {
if (parent->fd_table[fd] == NULL)
continue;
current->fd_table[fd] = file_duplicate(parent->fd_table[fd]);
}
sema_up(¤t->fork_sema); // fork 프로세스가 정상적으로 완료됐으므로 현재 fork용 sema unblock
process_init();
/* Finally, switch to the newly created process. */
if (succ)
do_iret(&if_); // 정상 종료 시 자식 Process를 수행하러 감
error:
sema_up(¤t->fork_sema); // 복제에 실패했으므로 현재 fork용 sema unblock
exit(TID_ERROR);
}
__do_fork() 함수는 Pintos에서 fork() 시스템 콜 시 새로 생성된 자식 스레드가 실제 프로세스 복제를 수행하는 핵심 함수다.
struct intr_frame *parent_if = &parent->parent_if;
memcpy(&if_, parent_if, sizeof(struct intr_frame));
if_.R.rax = 0; // 자식의 fork 리턴값은 0
- 부모가 fork()를 호출했던 시점의 레지스터 상태를 복제
- 자식은 동일한 콘텍스트로 실행을 재개하되, rax = 0으로 설정 (fork의 리턴값은 자식에서 0이어야 하므로)
current->pml4 = pml4_create();
if (current->pml4 == NULL)
goto error;
process_activate(current); // CR3에 새 페이지 테이블 등록
- 새 페이지 테이블(PML4) 생성
- process_activate()는 이 페이지 테이블을 현재 CPU에 반영함
#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
- 부모의 주소 공간을 자식에게 1:1 복제
- VM이 켜진 경우는 보조 페이지 테이블(spt)도 복사
- pml4_for_each()는 페이지 매핑을 하나하나 복사하며, duplicate_pte()로 실제 복사 수행
if (parent->fd_idx >= FDCOUNT_LIMIT)
goto error;
current->fd_idx = parent->fd_idx;
for (int fd = 3; fd < parent->fd_idx; fd++) {
if (parent->fd_table[fd] == NULL)
continue;
current->fd_table[fd] = file_duplicate(parent->fd_table[fd]);
}
- 부모의 열린 파일 디스크립터를 자식도 동일하게 갖도록 복제
- 단, stdin, stdout, stderr는 0~2로 생략 (fd = 3부터 복사)
- file_duplicate()는 참조 카운트를 증가시키며 공유 상태 유지
sema_up(¤t->fork_sema);
- process_fork()에서 부모는 sema_down()으로 자식이 복제를 마칠 때까지 기다림
- 자식이 성공적으로 복제를 마치고 나면 sema_up()으로 부모를 깨워 진행하게 함
if (succ)
do_iret(&if_);
- 이제 레지스터/intr_frame 정보가 준비됐으므로, 실제 유저 모드로 복귀
- do_iret()는 iretq 어셈블리 명령으로 CPU 상태를 복원하여 자식 프로세스를 유저 모드에서 실행하게 함
error:
sema_up(¤t->fork_sema); // 부모 대기 중 → unblock
exit(TID_ERROR); // 자식 프로세스를 실패로 종료
- 복제 도중 메모리/파일/페이지 테이블 오류 발생 시
- 부모는 sema_down()으로 대기 중이므로 sema_up() 호출은 항상 보장
- exit(-1) 대신 exit(TID_ERROR)로 에러 표시
duplicate_pte() 함수 구현
- userprog/process.c
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. */
if (is_kernel_vaddr(va))
return true;
/* 2. Resolve VA from the parent's page map level 4. */
parent_page = pml4_get_page(parent->pml4, va);
if (parent_page == NULL)
return false;
/* 3. TODO: Allocate new PAL_USER page for the child and set result to
* TODO: NEWPAGE. */
newpage = palloc_get_page(PAL_ZERO);
if (newpage == NULL)
return false;
/* 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). */
memcpy(newpage, parent_page, PGSIZE);
writable = is_writable(pte);
/* 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 false;
}
return true;
}
- duplicate_pte() 함수는 Pintos에서 fork() 구현 시 부모 프로세스의 페이지 테이블(PML4)에 있는 가상 주소와 물리 페이지 매핑을 자식 프로세스에 복제하는 함수다.
- 이 함수는 pml4_for_each()에 전달되어 부모의 모든 페이지 매핑을 순회하면서 호출된다.
if (is_kernel_vaddr(va))
return true;
- Pintos에서는 유저 프로세스가 커널 주소 공간을 사용할 수 없다.
- 커널 주소(보통 va >= 0x8000000000 등)는 공유되거나 이미 고정 매핑되기 때문에 복사 대상 아님.
parent_page = pml4_get_page(parent->pml4, va);
if (parent_page == NULL)
return false;
- 부모 프로세스의 페이지 테이블(PML4)에서 가상 주소 va에 매핑된 실제 물리 주소를 얻는다.
- NULL이면 매핑이 존재하지 않으므로 실패 처리
newpage = palloc_get_page(PAL_ZERO);
if (newpage == NULL)
return false;
- 자식 프로세스를 위한 새로운 유저 공간 페이지를 동적 할당한다.
- PAL_ZERO를 사용해 초기화(0으로 채움) – 안정성과 보안성 확보
- 실패 시 메모리 부족 → 에러 반환
memcpy(newpage, parent_page, PGSIZE);
writable = is_writable(pte);
- parent_page 내용을 newpage로 복사 (복제)
- 이때 해당 페이지가 쓰기 가능한(writable) 페이지인지 여부도 확인하여,
자식 페이지에 동일한 권한 부여 예정 - is_writable(pte)는 페이지 테이블 항목에서 writable 비트를 확인
if (!pml4_set_page(current->pml4, va, newpage, writable)) {
return false;
}
- 자식 프로세스의 페이지 테이블(PML4)에 va → newpage 매핑을 등록
- writable 여부에 따라 권한도 설정
- 실패하면 복사 실패 처리
'크래프톤 정글' 카테고리의 다른 글
[pintos] Week2~3: User Program 외전 - Linked List를 이용한 FD관리 (0) | 2025.05.26 |
---|---|
[pintos] Week2~3: User Program Part.8 - exec, wait (0) | 2025.05.26 |
[pintos] Week2~3: User Program Part.6 - lock 적용 (1) | 2025.05.25 |
[pintos] Week2~3: User Program Part.5 - 파일 디스크립터 (0) | 2025.05.25 |
[pintos] Week2~3: User Program Part.4 - halt, exit, create, remove (0) | 2025.05.20 |