Pintos Project 2 완성 정리: 85/95에서 95/95까지 간 코드 흐름
이번 글은 이미 구현된 main branch를 기준으로, 남아 있던 실패 테스트를 닫기 위해 어떤 코드를 추가했는지 정리한 글이다. 목표는 단순히 “어디를 고쳤다”가 아니라, 왜 그 코드가 필요한지와 어떤 테스트를 해결하는지를 연결해서 다시 재현할 수 있게 만드는 것이다.
핵심 요약
복사본에서 기존 결과 파일을 모두 지우고 전체 테스트를 새로 돌렸다. 최종 결과는 다음과 같다.
cd pintos/userprog/build
export PATH=$PWD/../../utils:$PATH
find tests -name "*.result" -delete
find tests -name "*.output" -delete
find tests -name "*.errors" -delete
make check
All 95 tests passed.
1. 남은 실패를 기능 단위로 다시 분류했다
처음에는 실패 목록이 흩어져 보였다. 하지만 실제로는 몇 개의 기능 축으로 묶인다. 여기서 분류가 중요하다. 실패 하나마다 땜질하면 코드가 늘어지고, 같은 원인으로 터지는 테스트를 반복해서 잡게 된다.
| 실패 그룹 | 보이는 증상 | 실제 원인 | 수정 방향 |
|---|---|---|---|
exec-read |
exec 이후 기존 fd를 못 읽음 | load()가 fd table을 새로 만들어 기존 fd를 날림 |
exec에서는 fd table을 보존 |
rox-* |
실행 중인 파일에 write가 됨 | 실행 파일을 열어 둔 채 deny-write 하지 않음 | exec_file을 유지하고 file_deny_write() |
lg-random, sm-random, syn-remove |
system call! 또는 중간 종료 |
remove, seek, tell syscall 누락 |
누락 syscall 분기 추가 |
syn-write |
동시 파일 접근에서 결과 불안정 | 기본 파일 시스템은 내부 동기화가 없음 | 파일 시스템 진입을 전역 lock으로 감쌈 |
multi-oom |
반복 fork 깊이가 부족함 | fd, 실행 파일, child_info 정리가 부족함 | exit cleanup과 참조 수 정리 |
2. 전체 수정 흐름
이번 보강은 하나의 큰 방향으로 묶인다. 유저 프로세스가 가진 자원을 “만들 때”, “복제할 때”, “갈아입을 때”, “죽을 때” 끝까지 추적하게 만드는 것이다.
halt/remove/seek/tell을 정상 분기한다.filesys_lock으로 모든 파일 접근을 보호한다.load()에서 기존 fd table을 보존한다.exec_file을 열어 두고 deny-write 상태를 유지한다.따라 만들기용 함수 지도
아래 순서대로 보면 실제 패치가 덜 헷갈린다. 테스트를 통과시키는 핵심은 코드를 많이 넣는 것이 아니라, 각 함수가 맡은 생명주기를 정확히 나누는 것이다.
| 순서 | 파일 / 함수 | 맡은 역할 | 주로 닫는 테스트 |
|---|---|---|---|
| 1 | thread.h, thread.c |
exec_file, ref_cnt 상태를 thread 생명주기에 추가 |
rox-*, multi-oom |
| 2 | syscall.h, syscall_init() |
파일 시스템 전역 lock을 만들고 process.c에서도 쓸 수 있게 노출 | syn-write, filesys 계열 |
| 3 | syscall_handler() |
halt/remove/seek/tell 분기와 파일 syscall lock 처리 |
lg-random, sm-random, syn-remove |
| 4 | load() |
실행 파일을 deny-write 상태로 유지하고, exec 때 fd table을 보존 | exec-read, rox-simple |
| 5 | __do_fork() |
자식에게 실행 파일 보호 상태까지 복제 | rox-child, rox-multichild |
| 6 | process_exit(), process_cleanup() |
fd, exec_file, child_info 장부를 닫아 누수 방지 | multi-oom, wait 계열 |
중간 테스트는 작은 단위로 볼 수 있지만, 최종 판단은 반드시 result/output/errors를 지운 뒤 전체 make check로 해야 한다. stale result가 남아 있으면 이미 고친 테스트가 계속 실패처럼 보일 수 있다.
3. thread 구조체에 실행 파일과 child_info 참조 수를 추가했다
기존 코드는 fd table과 children 목록은 들고 있었지만, 현재 실행 중인 executable을 계속 붙잡아 둘 공간이 없었다. rox 테스트는 바로 이 지점을 찌른다. 실행 중인 파일이 수정되면 안 되므로 process가 죽을 때까지 파일을 열어 둬야 한다.
또 하나는 child_info다. 부모와 자식이 같은 child_info를 공유하므로, 어느 한쪽이 먼저 종료됐다고 바로 free하면 다른 쪽이 죽은 포인터를 볼 수 있다. 그래서 아주 단순한 참조 수를 둔다.
/* pintos/include/threads/thread.h */
#ifdef USERPROG
uint64_t *pml4;
struct file **fd_table;
int fd_table_size;
int fd_next;
struct file *exec_file; /* 현재 실행 중인 executable. 실행 중 write 금지용 */
struct list children;
struct child_info *child_info;
#endif
struct child_info {
tid_t pid;
int exit_status;
bool exited;
bool waited;
int ref_cnt; /* 부모 장부와 자식 thread가 공유하는 참조 수 */
struct semaphore wait_sema;
struct list_elem elem;
};
/* pintos/threads/thread.c / init_thread() */
#ifdef USERPROG
t->pml4 = NULL;
t->fd_table = NULL;
t->fd_table_size = 0;
t->fd_next = 2;
t->exec_file = NULL;
list_init (&t->children);
t->child_info = NULL;
#endif
4. 파일 시스템 lock을 syscall과 process가 같이 쓰게 했다
Pintos의 기본 파일 시스템은 내부 동기화가 없다고 깃북에서 못 박고 있다. 그래서 filesys_open(), file_read(), file_write(), file_close() 같은 진입은 커널 쪽에서 한 번에 하나만 들어가게 막아야 한다.
중요한 점은 이 lock이 syscall.c 안에서만 쓰이면 부족하다는 것이다. load()도 실행 파일을 열고 읽는다. 그래서 syscall.h에 lock acquire/release 함수를 노출해 process.c에서도 같은 lock을 사용하게 했다.
/* pintos/include/userprog/syscall.h */
void syscall_init (void);
void filesys_lock_acquire (void);
void filesys_lock_release (void);
/* pintos/userprog/syscall.c */
#include "threads/init.h"
#include "threads/synch.h"
static struct lock filesys_lock;
void
syscall_init (void) {
lock_init (&filesys_lock);
/* 기존 MSR 초기화 코드는 그대로 둔다. */
}
void
filesys_lock_acquire (void) {
lock_acquire (&filesys_lock);
}
void
filesys_lock_release (void) {
lock_release (&filesys_lock);
}
5. 누락된 syscall 분기를 채웠다
system call!로 죽는 테스트는 대부분 구현되지 않은 syscall을 만났다는 뜻이다. 파일 시스템 base 테스트는 remove, seek, tell을 자연스럽게 사용한다. userprog 초반 테스트만 보고 있으면 놓치기 쉬운 지점이다.
/* pintos/userprog/syscall.c / syscall_handler() */
case SYS_HALT:
power_off ();
break;
case SYS_REMOVE: {
const char *name = (const char *) arg0;
is_crazy_user_string (name);
filesys_lock_acquire ();
f->R.rax = filesys_remove (name);
filesys_lock_release ();
break;
}
case SYS_SEEK: {
const int fd = (int) arg0;
const unsigned position = (unsigned) arg1;
struct file *file = fd_table_get_file (fd);
if (file != NULL) {
filesys_lock_acquire ();
file_seek (file, position);
filesys_lock_release ();
}
break;
}
case SYS_TELL: {
const int fd = (int) arg0;
struct file *file = fd_table_get_file (fd);
if (file == NULL) {
f->R.rax = -1;
} else {
filesys_lock_acquire ();
f->R.rax = file_tell (file);
filesys_lock_release ();
}
break;
}
read/write/create/open/filesize/close도 같은 전역 파일 시스템 lock 안으로 들어가야 한다. 특히 syn-write는 구현 자체보다 동시 접근 보호가 핵심이다.
5-1. syscall은 분기만 추가하면 부족하다
비판적으로 보면 누락 syscall만 채우는 패치는 반쪽짜리다. 파일 시스템은 여러 process가 동시에 들어오면 깨질 수 있으므로, 기존에 구현돼 있던 open/read/write/close도 같은 기준으로 보호해야 한다.
case SYS_OPEN: {
const char *name = (const char *) arg0;
is_crazy_user_string (name);
filesys_lock_acquire ();
struct file *opened = filesys_open (name);
filesys_lock_release ();
int fd = fd_allocate (opened);
f->R.rax = fd;
if (fd == -1 && opened != NULL) {
filesys_lock_acquire ();
file_close (opened);
filesys_lock_release ();
}
break;
}
/* read/write/filesize/close도 같은 원칙 */
filesys_lock_acquire ();
f->R.rax = file_read (read_file, buffer, size);
filesys_lock_release ();
filesys_lock_acquire ();
f->R.rax = file_write (write_file, buffer, size);
filesys_lock_release ();
filesys_lock_acquire ();
file_close (t->fd_table[fd]);
filesys_lock_release ();
6. exec는 fd table을 초기화하면 안 된다
여기서 가장 헷갈렸던 부분은 exec다. exec는 process를 새로 만드는 함수가 아니다. 같은 process가 몸만 새 실행 파일로 갈아입는 것이다. 그래서 열린 fd는 유지되어야 한다.
기존 코드처럼 load() 끝에서 매번 fd table을 새로 만들면, fork 후 exec된 자식이 부모에게서 물려받은 fd를 잃는다. exec-read가 이 문제를 정확히 잡는다.
load가 성공할 때마다 fd_table을 새로 할당한다. exec가 기존 fd를 날린다.
fd_table이 없을 때만 만든다. 이미 fd_table이 있으면 그대로 보존한다.
/* pintos/userprog/process.c / load() 후반 */
if (t->fd_table == NULL) {
t->fd_table_size = 128;
t->fd_next = 2;
t->fd_table = malloc (sizeof (struct file *) * t->fd_table_size);
if (t->fd_table == NULL)
goto done;
for (int i = 0; i < t->fd_table_size; i++)
t->fd_table[i] = NULL;
}
success = true;
7. 실행 파일은 닫지 말고 들고 있어야 한다
rox는 read-only executable의 약자처럼 보면 된다. 실행 중인 파일을 다른 쪽에서 수정하려고 할 때 막아야 한다. 구현 관점에서는 load()에서 executable을 열고, file_deny_write()를 호출한 뒤, process가 종료될 때까지 닫지 않는 방식이다.
/* pintos/userprog/process.c / load() */
filesys_lock_acquire ();
filesys_locked = true;
file = filesys_open (filename_only);
if (file == NULL) {
printf ("load: %s: open failed\n", filename_only);
goto done;
}
/* ELF 검증과 segment load가 끝난 뒤 */
file_deny_write (file);
t->exec_file = file;
file = NULL;
filesys_lock_release ();
filesys_locked = false;
process가 종료되거나 exec로 새 실행 파일을 입을 때는 기존 executable을 닫아야 한다. 그래야 deny-write가 풀린다.
/* pintos/userprog/process.c / process_cleanup() */
if (curr->exec_file != NULL) {
filesys_lock_acquire ();
file_close (curr->exec_file);
filesys_lock_release ();
curr->exec_file = NULL;
}
8. fork된 자식도 실행 파일 보호를 이어받아야 한다
부모가 실행 파일을 deny-write 상태로 들고 있다면, fork된 자식도 같은 실행 파일 보호 규칙을 유지해야 한다. 그렇지 않으면 rox-child, rox-multichild 같은 테스트에서 자식 쪽 실행 흐름이 보호를 잃는다.
/* pintos/userprog/process.c / __do_fork() */
if (parent->exec_file != NULL) {
filesys_lock_acquire ();
current->exec_file = file_duplicate (parent->exec_file);
filesys_lock_release ();
if (current->exec_file == NULL)
goto error;
}
fork는 주소 공간과 fd만 복사하는 작업이 아니다. process가 들고 있는 실행 파일 상태도 process 자원의 일부로 봐야 한다.
9. exit 때 fd와 child_info를 반드시 정리했다
multi-oom은 단순히 메모리가 부족한지 보는 테스트가 아니다. 같은 일을 반복했을 때 이전 process들이 자원을 제대로 반납했는지 확인한다. fd table이나 child_info가 누수되면 어느 순간 이전보다 얕은 깊이에서 실패한다.
/* pintos/userprog/process.c / process_fork() */
child->ref_cnt = 2; /* 부모 children 장부 1개 + 자식 thread 1개 */
/* pintos/userprog/process.c / process_exit() */
if (curr->child_info != NULL) {
curr->child_info->exit_status = curr->exit_status;
curr->child_info->exited = true;
sema_up (&curr->child_info->wait_sema);
release_child_info (curr->child_info);
curr->child_info = NULL;
}
close_process_file_descriptors (curr);
release_unwaited_children (curr);
process_cleanup ();
static void
close_process_file_descriptors (struct thread *t) {
if (t->fd_table == NULL)
return;
filesys_lock_acquire ();
for (int i = 2; i < t->fd_table_size; i++) {
if (t->fd_table[i] != NULL) {
file_close (t->fd_table[i]);
t->fd_table[i] = NULL;
}
}
filesys_lock_release ();
free (t->fd_table);
t->fd_table = NULL;
t->fd_table_size = 0;
t->fd_next = 2;
}
static void
release_child_info (struct child_info *child) {
if (child == NULL)
return;
child->ref_cnt--;
if (child->ref_cnt == 0)
free (child);
}
static void
release_unwaited_children (struct thread *t) {
while (!list_empty (&t->children)) {
struct list_elem *e = list_pop_front (&t->children);
struct child_info *child = list_entry (e, struct child_info, elem);
release_child_info (child);
}
}
10. 이 수정이 실제로 닫은 테스트
기존 main 기준에서 핵심으로 남아 있던 실패는 아래 축으로 닫혔다.
exec-read, multi-child-fdrox-simple, rox-child, rox-multichildlg-random, sm-random, syn-removesyn-write, multi-oom실수하기 쉬운 지점
exec를 새 process 생성처럼 생각하면 fd table을 날리게 된다. exec는 같은 process의 프로그램 교체다.file_deny_write()만 호출하고 파일을 닫아버리면 효과가 오래 유지되지 않는다. process가 살아 있는 동안 executable을 들고 있어야 한다.- 파일 시스템 lock을 syscall에만 두면
load()경로가 빠진다. 실행 파일 로딩도 파일 시스템 접근이다. child_info는 부모와 자식이 공유한다. 한쪽 기준으로 바로 free하면 wait 또는 exit 순서에 따라 터질 수 있다.
스스로 점검
load()에서 fd table을 매번 만들면 exec-read가 실패하는가?file_deny_write() 뒤에 바로 file_close() 하면 rox 테스트를 통과하기 어려운가?child_info는 단순히 부모가 wait할 때 free하면 부족한가?이번 글에서 기억할 것
Project 2의 마지막 10개 실패는 새로운 거대한 기능 하나가 아니라, process 자원 수명 관리의 빈틈이었다. fd는 exec 후에도 살아야 하고, executable은 process가 죽을 때까지 보호되어야 하며, child_info는 부모와 자식 양쪽의 수명을 동시에 견뎌야 한다.
한 줄로 정리하면, Project 2 완성은 syscall을 많이 추가하는 일이 아니라 process가 가진 자원을 끝까지 책임지는 일이다.