User Programs Part2 구현하기 (동기화 처리된 fork 구현, 파일 시스템 관련 시스템 콜 마무리)
Pintos Project Part2의 핵심 목표는 파일 시스템과 메모리 관리 기능을 확장하는 것인데요. 곧 fork 시스템 콜을 구현하여 자식 프로세스를 복제 및 관리하고, 다양한 시스템 콜을 통해 사용자 프로그램이 커널 자원에 안전하게 접근하며, 이 과정에서 예외 상황에서도 안정적으로 동작할 수 있도록 만들어야 합니다.
이번 글에서는 Part2에 대한 구현에 대해 다뤄봅니다. 파일 시스템 도입을 위해 어떤 구조체에 필드들이 추가되는지, 사용자가 넘긴 포인터가 버퍼와 같이 단일 주소가 아닌 범위를 가질 때에는 어떻게 검증해야 하는지, 이를 통해 안전한 파일 시스템 관련 시스템 콜은 어떻게 구현되는지, 그리고 fork는 구체적으로 어떻게 구현되는지에 대한 전체 과정을 정리해보겠습니다.
파일 시스템, 프로세스 복제 준비 및 기존 함수 수정하기
이제 프로세스는 파일을 열고 닫으면서, 열린 파일에 대해 읽거나 쓸 수 있게 됩니다. 뿐만 아니라 기존의 프로세스를 복제해 이용할 수도 있게 되는데요.
이를 위해서는 곧 현재 사용 중인 파일을 관리하기 위한 파일 디스크립터 테이블(FDT, File Descriptor Table)이나 다음 사용 가능한 fd값, 프로세스에서 실행 중인 파일 등을 저장해 놓아야 하죠. 또한 프로세스 복제 시에 자식 프로세스의 초기화를 기다리기 위한 세마포어도 추가로 필요합니다.
thread 구조체에 파일 시스템, 프로세스 복제 관련 필드 추가하기 (thread.h)
// struct thread 안의 필드로 추가
struct semaphore fork_sema; // 자식 프로세스 초기화 대기용 세마포어
struct intr_frame intr_frame; // 자식 프로세스의 부모 레지스터 값 복제용 인터럽트 프레임
struct file **FDT; // File Descriptor Table
int next_FD; // 다음 사용 가능한 fd값
struct file *running_file; // 현재 프로세스에서 실행 중인 파일
추가한 필드 초기화하기 (thread.c, process.c)
fork_sema의 경우 해당 스레드가 생성될 때 바로 초기화되어 세마 업/다운에 사용되야 하므로 init_thread
에서 초기화하고, 나머지 파일 관련 필드들은 현재 실행 중인 스레드 초기화에 사용되는 process_init
에서 초기화해줍니다.
참고로 FDT의 경우, 특정 테스트를 통과하기 위해 여러 페이지(최소 2페이지)를 할당받아야 하는데요. 이를 위해서는 기존에 많이 쓰던 palloc_get_page
대신 palloc_get_multiple
을 사용해야 합니다.
// thread.c - init_thread 내부에서 초기화
sema_init(&t->fork_sema, 0);
// process.c - process_init 내부에서 초기화
current->FDT = palloc_get_multiple(PAL_ZERO, FDT_PAGES);
current->running_file = NULL;
current->next_FD = 3;
FDT용 매크로 추가하기 (thread.h)
/* thread.h 윗부분에 추가 */
#define FDT_PAGES 1 // 프로세스 FDT 초기화 시 할당할 페이지
#define MAX_FD (FDT_PAGES * (1 << 9)) // 최대 FD 개수 (전체 범위 순회 시 사용)
load 수정하기 (process.c)
ELF 실행 파일 로드 작업이 성공적으로 완료됐을 경우, file_deny_write
를 통해 현재 실행 중인 파일에 대해 외부로부터의 쓰기를 금지해줍니다. 또한 스레드의 running_file
필드 또한 현재 파일로 설정해줍니다. 그리고 마지막 done 레이블에서 존재하는 파일에 대해 load가 성공하지 못했을 경우 해당 파일을 닫는 부분을 삭제합니다.
static bool
load(const char *file_name, struct intr_frame *if_) {
// 이전 코드 생략
/* filesys_open 이후 NULL 검사문 아랫줄에 추가 */
file_deny_write(file); // 현재 실행 중인 파일 쓰기 금지
t->running_file = file; // 스레드의 running_file을 현재 파일로 설정
done:
// file_close(file); (주석 처리하거나 삭제)
return success;
}
process_exit 수정하기 (process.c)
기존 함수에 열린 파일, FDT 구조체, 그리고 마지막으로 현재 실행 중인 파일까지 해서 파일 시스템 관련 자원을 해제하는 부분을 추가해 줍니다.
void
process_exit (void) {
// 현재 종료 중인 프로세스(스레드) 가져오기
struct thread *cur = thread_current ();
// 파일 디스크립터 테이블(FDT)에 열려 있는 모든 파일을 닫기
// 일반적으로 stdin(0), stdout(1), stderr(2)는 닫지 않고 3번부터 닫음
for(int i = 3; i < cur->next_FD; i++){
// 만약 해당 FD 슬롯에 열린 파일이 있다면
if (cur->FDT[i] != NULL)
file_close(cur->FDT[i]); // 해당 파일 닫기
cur->FDT[i] = NULL; // 슬롯을 NULL로 초기화
}
// 파일 디스크립터 테이블에 할당했던 메모리 해제
palloc_free_multiple(cur->FDT, FDT_PAGES);
// 현재 실행 파일 닫기(deny_write 해제는 해당 함수 안에서 자동으로 적용)
file_close(cur->running_file);
/* process_cleanup() 이후 부분 생략.. */
}
FDT 관련 헬퍼 함수 1: process_add_file 함수 구현하기 (process.c)
주어진 file 객체를 FDT에서 비어 있는 슬롯에 추가하고, 할당된 fd를 반환합니다. 해당 함수는 파일 시스템 관련 시스템 콜에서 활용됩니다.
// process.h에 함수 선언 추가
int process_add_file(struct file *file);
int process_add_file(struct file *file) {
// 현재 실행 중인 스레드(=프로세스) 가져오기
struct thread *curr = thread_current();
// 파일 디스크립터(fd)는 0~2는 이미 예약된 상태(stdin, stdout, stderr)
// 따라서 일반 파일은 3번부터 사용
for (int fd = 3; fd < MAX_FD; fd++) {
// 현재 FDT(File Descriptor Table)에서 비어있는 슬롯 찾기
if (curr->FDT[fd] == NULL) {
// 비어 있는 슬롯을 찾으면 해당 위치에 파일 포인터 저장
curr->FDT[fd] = file;
// 다음 검색할 fd 번호를 갱신
curr->next_FD = fd + 1;
// 성공적으로 등록한 fd 번호 반환
return fd;
}
}
// 모든 슬롯이 차서 더 이상 파일을 열 수 없다면 -1 반환
return -1;
}
FDT 관련 헬퍼 함수 2: process_get_file 함수 구현하기 (process.c)
주어진 fd에 해당하는 파일 객체를 반환합니다. 해당 함수는 파일 시스템 관련 시스템 콜에서 활용됩니다.
// process.h에 함수 선언 추가
struct file *process_get_file(int fd);
struct file *process_get_file(int fd) {
// 현재 실행 중인 스레드(=프로세스) 가져오기
struct thread *curr = thread_current();
// fd가 0~2(stdin, stdout, stderr)인 경우 시스템 콜에서 따로 처리
// 또한, 허용되지 않는 범위의 fd인 경우도 NULL 반환
if (fd < 3 || fd >= MAX_FD) {
return NULL; // 유효하지 않은 fd → 실패
}
// 유효한 fd이면, 해당 위치의 파일 포인터를 반환
return curr->FDT[fd];
}
사용자 포인터 검증 로직 강화하기
기존 기본 시스템 콜 뿐만 아니라, 파일 시스템 관련 시스템 콜을 처리할 때는 사용자 포인터에 대한 철저한 검증이 필수라고 할 수 있는데요. 특히 이제는 단일 주소뿐만 아니라 버퍼나 문자열처럼 주소 범위 전체에 대한 검증도 요구됩니다.
이에 따라 기존에는 syscall.c
내부에서 사용자 주소를 직접 검증했다면, 이제는 이러한 검증과 안전한 읽기/쓰기 기능을 별도의 validate.c
파일로 분리하여 관리함으로써 코드의 모듈화와 유지보수성을 높였습니다.
사용자 포인터 검증 모듈 추가 및 연동하기
validate.h 파일 추가 (include/userprog 디렉터리)
#ifndef USERPROG_VALIDATE_H
#define USERPROG_VALIDATE_H
#include <stddef.h>
#include <stdbool.h>
#include <stdint.h>
void validate_ptr (const void *uaddr, size_t size);
void validate_str (const char *str);
int64_t get_user (const uint8_t *uaddr);
bool put_user (uint8_t *udst, uint8_t byte);
size_t copy_in (void *kernel_dst, const void *user_src, size_t size);
size_t copy_out (void *user_dst, const void *kernel_src, size_t size);
#endif /* userprog/validate.h */
validate.c 파일 추가 (userprog 디렉터리)
내부 헬퍼 함수인 check_page
는 기존 syscall.c
에 있었던 check_address
와 기능적으로 동일합니다. 이에 따라 기존 check_address
함수는 삭제해줍니다.
#include "userprog/validate.h"
#include "userprog/syscall.h" /* syscall_exit() */
#include "threads/thread.h" /* thread_current(), pml4 */
#include "threads/vaddr.h" /* PHYS_BASE, pg_ofs */
#include "threads/mmu.h" /* PGSIZE */
#include "threads/pte.h" /* pml4_get_page() */
#include <string.h> /* memcpy */
/* 내부 헬퍼: 단일 가상 주소 uaddr이
- NULL이 아니고
- 사용자 영역에 속하며
- 현재 프로세스의 페이지 테이블에 매핑되어 있는지 확인 */
static bool
check_page (const void *uaddr) {
return uaddr != NULL &&
is_user_vaddr(uaddr) &&
pml4_get_page (thread_current ()->pml4, uaddr) != NULL;
}
target.mk에 validate.c 추가 (userprog 디렉터리)
make 시 validate 파일 또한 포함될 수 있도록 target.mk
파일에 validate.c
를 추가해줍니다.
userprog_SRC += userprog/validate.c # 맨 밑줄에 추가
validate 모듈 참조 추가 (syscall.c)
#include "userprog/validate.h" // 상단에 추가
validate_ptr 함수 구현하기 (validate.c)
단일 주소뿐만 아니라 주어진 메모리 범위 전체가 사용자 영역에 유효하게 매핑되어 있는지 페이지 단위로 검사하며, 유효하지 않으면 프로세스를 종료시켜 시스템 콜의 안전한 메모리 접근을 보장합니다.
/* 사용자 포인터 uaddr로부터 size 바이트까지의 주소 범위를
페이지 단위로 하나씩 순회하며 모두 접근 가능한지 확인
→ 접근 불가능한 주소가 포함되어 있으면 프로세스를 종료함 (sys_exit(-1))
→ 사용 예: read(), write(), exec() 등에서 전달받은 사용자 버퍼 검증 */
void
validate_ptr (const void *uaddr, size_t size) {
if (size == 0) return; // 검사할 바이트 수가 0이면 return
const uint8_t *usr = uaddr; // 현재 검사할 위치 (byte 단위 포인터)
size_t left = size; // 검사해야 할 남은 바이트 수
// 검사할 전체 영역을 페이지 단위로 나누어 한 페이지씩 접근 가능 여부를 확인
while (left > 0) {
// 현재 usr 포인터가 가리키는 주소가 사용자 영역에 있고
// 실제로 물리 메모리에 매핑되어 있는지 확인
if (!check_page (usr))
sys_exit (-1); // 잘못된 주소일 경우, 즉시 프로세스 종료
// 현재 페이지에서 끝까지 남은 바이트 수 계산
size_t page_left = PGSIZE - pg_ofs (usr);
// 남은 전체 바이트와 현재 페이지에서 가능한 바이트 중 더 작은 만큼만 이동
size_t chunk = left < page_left ? left : page_left;
usr += chunk; // 검사할 포인터를 다음 영역으로 이동
left -= chunk; // 검사해야 할 남은 바이트 수 갱신
}
}
validate_str 함수 구현하기 (validate.c)
사용자 문자열이 끝날 때까지 모든 바이트가 유효한 사용자 영역에 존재하는지 확인하고, 그렇지 않으면 프로세스를 종료해 문자열 인자의 안정성을 보장합니다.
/* 사용자 문자열 str이 \0을 만날 때까지 매 바이트 접근 가능한지 확인
→ 문자열 전체가 사용자 영역 내에 안전하게 존재해야 함
→ 접근 불가능한 주소를 만나면 프로세스를 종료함 (sys_exit(-1))
→ 사용 예: exec("..."), open("...") 등에서 문자열 인자 검증 */
void validate_str(const char *str) {
for (const char *p = str;; ++p) {
validate_ptr(p, 1); // 현재 문자가 존재하는 1바이트 주소가 유효한지 확인
if (*p == '\0') break; // 문자열 끝(널 문자)에 도달하면 검사 종료
}
}
sys_exec에서 사용자 주소 검증을 validate_str로 변경하기 (syscall.c)
기존 sys_exec
에서는 check_address
로 사용자 주소 검증을 하고 있었는데요. 사실 사용자로부터 명령 줄 문자열을 받기 때문에 시작 주소만 검증하는 check_address
로는 명령 줄 전체가 유효한 사용자 영역에 위치해 있는지를 검증할 수 없습니다. 때문에 validate_str
을 사용하여 문자열 전체에 대해 검증을 수행하도록 바꿔줍니다.
validate_str(cmd_line);
get_user 함수 구현하기 (validate.c)
사용자 메모리 주소로부터 1바이트를 읽되, 예외 발생 시 -1을 반환하여 커널이 크래시하지 않도록 보호합니다.
/* 사용자 영역 주소 uaddr에서 단일 바이트를 안전하게 읽기
→ 예외(page fault) 발생 시 -1 반환 */
int64_t get_user (const uint8_t *uaddr) {
int64_t result;
__asm __volatile (
"movabsq $done_get, %0\n" // 예외 발생 시 점프할 주소를 %0에 저장
"movzbq %1, %0\n" // uaddr이 가리키는 1바이트 값을 읽어 zero-extend 후 %0에 저장
"done_get:\n" // 예외 발생 시 이곳으로 점프하여 결과 처리
: "=&a" (result) // 출력: result에 저장됨 (%rax 사용)
: "m" (*uaddr)); // 입력: uaddr이 가리키는 메모리 바이트
return result;
}
put_user 함수 구현하기 (validate.c)
사용자 메모리 주소에 1바이트를 쓰되, 예외 발생 시 false를 반환하여 커널이 안정적으로 실패를 감지할 수 있도록 합니다.
/* 사용자 영역 주소 udst에 단일 바이트 byte를 안전하게 쓰기
→ 예외(page fault) 발생 시 false 반환 */
bool put_user (uint8_t *udst, uint8_t byte) {
int64_t error_code; // 예외 발생 여부 확인을 위한 변수
printf("[put_user] trying to write to %p\n", udst); // 디버깅용 출력
__asm __volatile (
"movabsq $done_put, %0\n" // 예외 발생 시 복구할 위치 주소를 %0에 저장
"movb %b2, %1\n" // byte 값을 udst가 가리키는 주소에 저장 시도 (1바이트)
"done_put:\n" // 예외 발생 시 여기로 복귀
: "=&a" (error_code), // 출력: %rax에 저장될 복구 주소 → 예외가 없으면 그대로 통과
"=m" (*udst) // 출력 메모리 위치: 실제 쓰기 대상
: "q" (byte)); // 입력: 저장할 바이트 값
return error_code != -1; // 예외 발생 시 false, 아니면 true 반환
}
copy_in 함수 구현하기 (validate.c)
사용자 메모리로부터 size 바이트를 커널로 안전하게 복사하고, 복사 전 validate_ptr()
로 사용자 포인터의 유효성을 반드시 확인하여 커널 크래시를 방지합니다.
/* 사용자 영역에서 커널 영역으로 size 바이트만큼 메모리를 복사
→ 복사 전 대상 범위를 validate_ptr()로 검증 */
size_t
copy_in (void *kernel_dst, const void *user_src, size_t size) {
validate_ptr (user_src, size); // 복사 전에 사용자 포인터 범위 검증
memcpy (kernel_dst, user_src, size); // 검증 완료 → 커널 영역으로 복사 수행
return size; // 실제 복사한 바이트 수 반환
}
copy_out 함수 구현하기 (validate.c)
/* 커널 영역에서 사용자 영역으로 size 바이트만큼 메모리를 복사
→ 복사 전 대상 범위를 validate_ptr()로 검증 */
size_t
copy_out (void *user_dst, const void *kernel_src, size_t size) {
validate_ptr (user_dst, size); // 복사 전에 사용자 목적지 포인터 범위 검증
memcpy (user_dst, kernel_src, size); // 검증 완료 → 사용자 영역으로 복사 수행
return size; // 실제 복사한 바이트 수 반환
}
파일 시스템 관련 시스템 콜 완성하기
그동안 Pintos에서 기본적인 시스템 콜만을 지원했다면 이제는 파일 시스템 관련 시스템 콜을 포함하는, 전체 시스템 콜 처리를 지원하는 운영체제로 바꿔줄 차례입니다.
시스템 콜 핸들러 완성하기 (syscall.c)
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((int)arg1);
break;
case SYS_EXEC:
f->R.rax = sys_exec((const char *)arg1);
break;
case SYS_WAIT:
f->R.rax = sys_wait((int)arg1);
break;
case SYS_FORK:
f->R.rax = sys_fork((const char *)arg1, f);
break;
case SYS_CREATE:
f->R.rax = sys_create((const char *)arg1, (unsigned)arg2);
break;
case SYS_REMOVE:
f->R.rax = sys_remove((const char *)arg1);
break;
case SYS_OPEN:
f->R.rax = sys_open((const char *)arg1);
break;
case SYS_CLOSE:
sys_close((int)arg1);
break;
case SYS_FILESIZE:
f->R.rax = sys_filesize((int)arg1);
break;
case SYS_READ:
f->R.rax = sys_read((int)arg1, (void *)arg2, (unsigned)arg3);
break;
case SYS_WRITE:
f->R.rax = sys_write((int)arg1, (const void *)arg2, (unsigned)arg3);
break;
case SYS_SEEK:
sys_seek((int)arg1, (unsigned)arg2);
break;
case SYS_TELL:
f->R.rax = sys_tell(arg1);
break;
default:
thread_exit();
break;
}
}
함수 선언 추가하기 (syscall.c)
// syscall.c 위쪽에 함수 선언 추가
tid_t sys_fork(const char *thread_name, struct intr_frame *f);
static bool sys_create(const char *file, unsigned initial_size);
static bool sys_remove(const char *file);
static int sys_open(const char *file_name);
static void sys_close(int fd);
static int sys_filesize(int fd);
static int sys_read(int fd, void *buffer, unsigned size);
static void sys_seek(int fd, unsigned position);
static unsigned sys_tell(int fd);
파일 시스템 관련 모듈 참조 추가 (syscall.c)
#include "lib/kernel/console.h" // 커널 콘솔 입출력 함수 제공 (putbuf, printf 등)
#include "lib/user/syscall.h" // 유저 프로그램이 사용하는 시스템 콜 번호 및 인터페이스 정의
#include "filesys/directory.h" // 디렉터리 관련 자료구조 및 함수 (디렉터리 열기, 탐색 등)
#include "filesys/filesys.h" // 파일 시스템 전반에 대한 함수 및 초기화/포맷 인터페이스
#include "filesys/file.h" // 개별 파일 객체(file 구조체) 및 파일 입출력 함수 정의 (read, write 등)
파일 시스템 동기화용 락 구조체 추가하기 (syscall.c)
파일 시스템 접근 시 다른 프로세스와 경쟁 상태가 일어나지 않도록 락을 걸게 되는데, 이때 사용할 구조체입니다.
struct lock filesys_lock; // 파일 시스템 동기화용 전역 락
매 시스템 콜마다 락 초기화하기 (syscall.c)
이후 시스템 콜을 처리할 때마다 syscall_init
내부에서 해당 락 구조체를 초기화하여 활용합니다.
// syscall_init 맨 밑에 추가
lock_init(&filesys_lock);
페이지 폴트 시 규격에 맞게 종료하기 (exception.c)
User Programs 단계에서 페이지 폴트 발생 시, 규격에 맞는 종료 메시지를 출력 후에 프로세스를 종료시켜야 하는데요. 이를 위해 exception.c
에서 kill
함수 switch문 안의 case SEL_UCSEG
부분을 수정해줍니다.
case SEL_UCSEG:
{
struct thread *t = thread_current(); // 현재 프로세스 가져오기
printf("%s: exit(-1)\n", t->name); // 규격에 맞는 종료 메시지 출력
t->exit_status = -1; // exit_status를 -1로 설정
thread_exit(); // 프로세스 종료
}
sys_fork 시스템 콜 구현하기 (syscall.c)
현재 프로세스를 복제(fork)하여 자식 프로세스를 생성합니다. 다만 실질적인 처리는 이후에 구현할 process_fork
에서 전담합니다.
tid_t sys_fork(const char *thread_name, struct intr_frame *f) {
// 자식 프로세스에 넘겨줄 이름과 부모 프로세스 레지스터 상태를 인자로 전달
return process_fork(thread_name, f);
}
process 모듈 참조 추가 (syscall.c)
#include "userprog/process.h" // 상단에 추가
sys_create 시스템 콜 구현하기 (syscall.c)
사용자로부터 파일 이름과 크기를 받아, 해당 이름의 새 파일을 생성합니다. 추가적으로 유효성 검사, 이름 복사, 중복 검사도 처리합니다.
참고로, 이제까지 check_address
에서 수행했던 단일 포인터 검증은 validate_ptr
가 두번째 인자에 1을 전달받아 수행합니다.
static bool sys_create(const char *file, unsigned initial_size) {
// 사용자 포인터가 유효한지 검사
validate_ptr(file, 1);
// 유저 영역에 있는 파일 이름 문자열을 커널 영역의 안전한 버퍼로 복사
char kernel_buf[NAME_MAX + 1]; // 최대 이름 길이 + 널 문자 고려
if (!copy_in(kernel_buf, file, sizeof kernel_buf)) {
return false; // 문자열 복사 실패 → 파일 이름을 읽을 수 없으므로 실패
}
// 빈 문자열이면 파일 이름으로 부적절하므로 생성 불가
if (strlen(kernel_buf) == 0) {
return false;
}
// 루트 디렉토리 열기 → Pintos는 루트 디렉토리를 기본 작업 디렉토리로 사용
struct dir *dir = dir_open_root();
if (dir == NULL) {
return false; // 루트 디렉토리 열기에 실패한 경우
}
struct inode *inode;
// 동일한 이름의 파일이 이미 존재하는지 확인
if (dir_lookup(dir, kernel_buf, &inode)) {
dir_close(dir); // 디렉토리 닫기
return false; // 이미 존재하는 파일 이름 → 생성 실패
}
// 파일 시스템 락을 획득한 후 파일 생성 시도 (동시성 보호)
lock_acquire(&filesys_lock);
bool success = filesys_create(kernel_buf, initial_size);
lock_release(&filesys_lock); // 작업 완료 이후 락 해제
// 디렉토리 자원 정리
dir_close(dir);
// 파일 생성 성공 여부 반환
return success;
}
sys_remove 시스템 콜 구현하기 (syscall.c)
사용자가 지정한 파일 이름을 받아 해당 파일을 삭제합니다. 유효한 포인터인지 검사한 뒤, 파일 시스템의 삭제 기능을 호출합니다.
static bool sys_remove(const char *file) {
// 사용자 포인터가 유효한 사용자 영역 주소인지 검사
validate_ptr(file, 1);
// NULL 포인터가 넘어온 경우 삭제 실패
if (file == NULL) {
return false;
}
lock_acquire(&filesys_lock);
bool success = filesys_remove(file); // 파일 시스템에서 해당 파일 삭제 시도
lock_release(&filesys_lock);
// 성공/실패 여부 반환
return success;
}
sys_open 시스템 콜 구현하기 (syscall.c)
지정된 파일을 열고, 현재 프로세스의 파일 디스크립터 테이블에 등록하여 등록된 파일 디스크립터를 반환합니다.
static int sys_open(const char *file_name) {
// 사용자 포인터가 유효한 사용자 영역 주소인지 검사
validate_ptr(file_name, 1);
// 파일 시스템 접근을 위한 락 획득
lock_acquire(&filesys_lock);
// 파일 시스템에서 파일 열기 시도
struct file *file = filesys_open(file_name);
// 파일이 없거나 열기에 실패한 경우 -1 반환
if (file == NULL) {
lock_release(&filesys_lock);
return -1;
}
// 현재 프로세스의 파일 디스크립터 테이블(FDT)에 파일 등록
int fd = process_add_file(file);
// 파일 등록에 실패한 경우 → 열린 파일 닫기
if (fd == -1)
file_close(file);
// 파일 시스템 락 해제
lock_release(&filesys_lock);
// 파일 디스크립터 번호 반환, 실패 시 -1 반환
return fd;
}
sys_close 시스템 콜 구현하기 (syscall.c)
열린 파일 디스크립터를 닫고 해당 파일을 현재 프로세스의 파일 디스크립터 테이블에서 제거합니다.
static void sys_close(int fd) {
struct thread *curr = thread_current();
lock_acquire(&filesys_lock); // 파일 시스템 동기화
// 파일 디스크립터 테이블에서 파일 가져오기
struct file *file = process_get_file(fd);
if (file != NULL) {
file_close(file); // 파일 닫기
curr->FDT[fd] = NULL; // FDT에서 제거
}
lock_release(&filesys_lock);
}
sys_filesize 시스템 콜 구현하기 (syscall.c)
지정된 파일 디스크립터에 해당하는 파일의 크기를 바이트 단위로 반환합니다.
static int sys_filesize(int fd) {
// 파일 디스크립터 번호를 이용해 파일 객체 가져오기
struct file *file = process_get_file(fd);
// 유효하지 않은 fd이거나 파일이 열려 있지 않은 경우 -1 반환
if (file == NULL) {
return -1;
}
// 해당 파일의 크기(바이트 단위)를 반환
return file_length(file);
}
sys_read 시스템 콜 구현하기 (syscall.c)
파일 디스크립터를 통해 지정된 입력을 받아 사용자 버퍼에 저장합니다. 이때 표준 입력이면 키보드 입력으로부터, 일반 입력이면 해당 파일에서 읽어옵니다.
static int sys_read(int fd, void *buffer, unsigned size) {
// 사용자 버퍼 포인터가 유효한지 확인
validate_ptr(buffer, size);
// 버퍼를 문자 단위로 접근하기 위해 char 포인터로 변환
char *ptr = (char *)buffer;
int bytes_read = 0;
// 파일 시스템 동시 접근 방지를 위한 락 획득
lock_acquire(&filesys_lock);
if (fd == STDIN_FILENO) // 표준 입력일 경우
{
// 키보드 입력을 한 글자씩 읽어서 버퍼에 저장
for (int i = 0; i < size; i++) {
*ptr++ = input_getc();
bytes_read++;
}
lock_release(&filesys_lock);
}
else
{
// stdout(1), stderr(2), 음수 등 읽을 수 없는 fd는 실패 처리
if (fd < 3) {
lock_release(&filesys_lock);
return -1;
}
// 파일 디스크립터 테이블에서 파일 객체 가져오기
struct file *file = process_get_file(fd);
if (file == NULL) {
lock_release(&filesys_lock);
return -1;
}
// 파일에서 size만큼 읽어 버퍼에 저장
bytes_read = file_read(file, buffer, size);
lock_release(&filesys_lock);
}
// 읽은 바이트 수 반환 (0 이상)
return bytes_read;
}
sys_write 시스템 콜 수정하기 (syscall.c)
지정된 파일 디스크립터에 대해 사용자 버퍼의 데이터를 출력하거나 파일에 기록합니다. 이제 기존의 콘솔 출력(stdout) 뿐 아니라 일반 파일에 대한 쓰기 처리 또한 지원하도록 기능을 확장해줍니다.
static int sys_write(int fd, const void *buffer, unsigned size) {
// 사용자 버퍼 포인터가 유효한지 확인
validate_ptr(buffer, size);
// stdin(0), stderr(2)은 출력 대상이 아니므로 에러 처리
if (fd == 0 || fd == 2) {
return -1;
}
// stdout(1)인 경우 → 콘솔에 출력
if (fd == 1)
{
putbuf(buffer, size); // 버퍼 내용을 콘솔에 출력
return size; // 출력한 바이트 수 반환
}
// 일반 파일인 경우 → 해당 fd로 열린 파일 객체 조회
struct file *file = process_get_file(fd);
if (file == NULL)
return -1;
// 파일 시스템 접근을 위한 락 획득
lock_acquire(&filesys_lock);
// 파일에 버퍼 내용 쓰기
int bytes_write = file_write(file, buffer, size);
// 락 해제
lock_release(&filesys_lock);
// 쓰기 실패 시 -1 반환
if (bytes_write < 0)
return -1;
// 성공한 경우 실제로 쓴 바이트 수 반환
return bytes_write;
}
sys_seek 시스템 콜 구현하기 (syscall.c)
파일 디스크립터를 통해 열린 파일 내부에서 데이터를 읽고 쓰는 위치(offset)를 지정된 값으로 변경합니다.
static void sys_seek(int fd, unsigned position) {
// 파일 디스크립터를 통해 파일 객체 가져오기
struct file *file = process_get_file(fd);
// 유효하지 않은 fd이면 아무 작업도 하지 않고 종료
if (file == NULL) {
return;
}
// 파일의 읽기/쓰기 위치를 지정된 위치로 변경
file_seek(file, position);
}
sys_tell 시스템 콜 구현하기 (syscall.c)
파일 디스크립터를 통해 열린 파일 내부에서 데이터를 읽고 쓰는 위치(offset)를 반환합니다.
static unsigned sys_tell(int fd)
{
// 파일 디스크립터를 통해 파일 객체 가져오기
struct file *file = process_get_file(fd);
// 유효하지 않은 fd이면 0 반환 (unsigned 타입이므로 -1 대신 0 사용)
if (file == NULL)
return 0;
// 현재 파일의 읽기/쓰기 위치(offset)를 반환
return file_tell(file);
}
프로세스 복제 기능 구현하기
완성된 유저 프로그램을 향한 마지막 한 걸음은 바로 프로세스 복제 기능인 fork 구현입니다. 앞서 시스템 콜 처리 틀은 마련해두었으니, 이제는 그 안을 알차게 채워야 할 차례인데요.
프로세스를 복제한다는 것은 단순히 스레드를 하나 더 만드는 것이 아니라, 부모 프로세스의 인터럽트 프레임(레지스터 상태), 페이지 테이블(pml4), 그리고 파일 디스크립터 테이블(FDT)을 자식 프로세스가 그대로 물려받도록 복제하는 일련의 작업 전체를 의미합니다.
이처럼 fork는 단순한 복제가 아닌, 현재 실행 중인 부모 프로세스의 실행 맥락 전체를 자식에게 복제하는 보다 깊은 수준의 동작이라고 할 수 있지요.
process_fork 수정하기 (process.c)
현재 실행 중인 부모 프로세스를 복제하여 자식 프로세스를 생성하는 시스템 콜 구현의 하위 함수입니다. 부모의 실행 상태(intr_frame
)를 복사하고 __do_fork
를 통해 자식 실행을 시작하며, 이 과정에서 자식이 준비될 때까지 fork_sema
를 세마 다운하여 대기합니다.
tid_t process_fork(const char *name, struct intr_frame *if_) {
// 시스템 콜 진입 시점에 저장된 인터럽트 프레임의 내용을 부모의 intr_frame에 복사
// → 자식 프로세스 생성 시, 이를 참조해 동일한 실행 상태를 구성하게 함
memcpy(&thread_current()->intr_frame, if_, sizeof(struct intr_frame));
// 자식 스레드 생성: 이름, 우선순위, 시작 함수(__do_fork), 인자(부모 스레드 포인터)를 전달
// __do_fork는 자식 스레드가 시작할 때 호출되며, 부모의 상태를 복제하는 작업 수행
tid_t fork_tid = thread_create(name, PRI_DEFAULT, __do_fork, thread_current());
if (fork_tid == TID_ERROR)
return TID_ERROR; // 자식 생성 실패 시 오류 반환
// 자식의 tid를 이용해 자식 스레드 포인터를 가져옴
struct thread *child = get_child_by_tid(fork_tid);
// 자식 스레드가 복제 작업을 완료할 때까지 부모는 대기
// → 자식이 intr_frame 등의 초기화 작업을 마칠 때까지 동기화
if (child != NULL) {
sema_down(&child->fork_sema);
}
// 깨어난 뒤 자식 스레드의 tid를 반환
return fork_tid;
}
__do_fork 수정하기 (process.c)
부모 프로세스의 실행 상태, 메모리 공간, 파일 디스크립터 등을 복제해 자식 프로세스를 초기화하고 유저 모드로 실행을 시작하는 핵심 함수입니다. 모든 복제 작업을 완료하면 대기하고 있는 부모를 깨운 뒤 유저 모드로 전환하는데요. 만약 진행 도중 문제가 생겼다면 자식의 exit_status를 -1로 설정한 뒤, 부모를 깨워 문제가 발생했음을 알리고 자식 프로세스를 종료합니다.
static void
__do_fork(void *aux)
{
struct intr_frame if_; // 자식이 사용할 인터럽트 프레임
struct thread *parent = (struct thread *)aux; // 부모 스레드
struct thread *current = thread_current(); // 현재 실행 중인 자식 스레드
struct intr_frame *parent_if = &parent->intr_frame;
bool succ = true;
// 자식 프로세스용 필드 초기화 (children, FDT 등)
process_init();
// 부모의 인터럽트 프레임(CPU 상태)을 자식에 복사
memcpy(&if_, parent_if, sizeof(struct intr_frame));
// 자식 프로세스를 위한 새로운 페이지 테이블 생성
current->pml4 = pml4_create();
if (current->pml4 == NULL)
goto error; // 생성 실패 시 에러 처리
// 페이지 테이블 활성화 (CR3에 로드)
process_activate(current);
#ifdef VM
// 보조 페이지 테이블 초기화 및 복사 (VM 기능이 켜져 있는 경우)
supplemental_page_table_init(¤t->spt);
if (!supplemental_page_table_copy(¤t->spt, &parent->spt))
goto error;
#else
// 단순 페이지 테이블 복사 (VM 기능이 꺼져 있는 경우)
if (!pml4_for_each(parent->pml4, duplicate_pte, parent))
goto error;
#endif
// 파일 디스크립터 테이블(FDT) 복제
int fd_end = parent->next_FD;
for (int fd = 0; fd < fd_end; fd++) {
if (fd <= 2)
// stdin, stdout, stderr은 그대로 공유
current->FDT[fd] = parent->FDT[fd];
else {
// 일반 파일은 다시 열어서 자식이 독립적으로 사용하게 함
if (parent->FDT[fd] != NULL)
current->FDT[fd] = file_duplicate(parent->FDT[fd]);
}
}
current->next_FD = fd_end;
// 자식 프로세스는 fork()의 반환값으로 0을 받아야 하므로 레지스터 설정
if_.R.rax = 0;
// 세그먼트 레지스터와 EFLAGS 설정 (유저 모드 전환 준비)
if_.ds = if_.es = if_.ss = SEL_UDSEG;
if_.cs = SEL_UCSEG;
if_.eflags = FLAG_IF;
// 자식이 준비 완료되었음을 부모에게 알림 (부모의 sema_down을 깨움)
sema_up(¤t->fork_sema);
// 자식 프로세스를 유저 모드로 전환 (ret-from-fork)
if (succ)
do_iret(&if_);
error:
// 실패 시 자식 종료 처리
current->exit_status = -1;
sema_up(¤t->fork_sema); // 부모가 기다리는 경우를 위해 신호 보냄
thread_exit(); // 자식 프로세스 종료
}
duplicate_pte 수정하기 (process.c)
부모의 가상 주소 공간에 있는 데이터를 자식 프로세스의 새 물리 페이지로 복제하는데요. 곧, 부모 프로세스의 페이지 테이블에서 하나의 사용자 페이지를 복사하여 자식 프로세스의 페이지 테이블에 매핑하는 역할을 수행합니다.
static bool
duplicate_pte (uint64_t *pte, void *va, void *aux) {
// 현재 실행 중인 스레드(=자식 프로세스) 가져오기
struct thread *current = thread_current ();
// aux는 부모 스레드로 전달된 인자
struct thread *parent = (struct thread *) aux;
void *parent_page; // 부모 프로세스의 물리 주소를 저장할 변수
void *newpage; // 자식 프로세스용 새 물리 페이지
bool writable; // 페이지가 쓰기 가능한지 여부
// 커널 주소 공간은 복사하지 않음 → 사용자 영역만 처리
if (is_kernel_vaddr(va))
return true;
// 부모 프로세스의 페이지 테이블에서 해당 가상 주소에 대응하는 물리 주소를 가져옴
parent_page = pml4_get_page(parent->pml4, va);
if (parent_page == NULL)
return false; // 매핑된 페이지가 없다면 실패
// 자식 프로세스용으로 새로운 사용자 페이지를 할당 (0으로 초기화된 페이지)
newpage = palloc_get_page(PAL_USER | PAL_ZERO);
if (newpage == NULL)
return false; // 메모리 부족 등으로 할당 실패
// 부모 페이지 내용을 자식의 새 페이지로 복사
memcpy(newpage, parent_page, PGSIZE);
// 복사한 페이지가 쓰기 가능한 페이지인지 확인
writable = is_writable(pte);
// 자식의 페이지 테이블에 해당 가상 주소를 새 페이지에 매핑
if (!pml4_set_page(current->pml4, va, newpage, writable)) {
// 매핑 실패 시 false 반환 (예: 중복 매핑 등)
return false;
}
// 성공적으로 복제 완료
return true;
}
'크래프톤 정글 > Code 정글(C언어)' 카테고리의 다른 글
[Pintos] User Programs 테스트 및 디버깅 정보 공유 (0) | 2025.05.24 |
---|---|
[Pintos] 트러블슈팅: CRLF로 인해 맞는 코드의 테스트가 실패하는 현상 해결하기 (0) | 2025.05.23 |
[Pintos] User Programs Part 2 전체적인 큰 그림 그리기 (0) | 2025.05.21 |
[Pintos] User Programs Part1 구현하기 (프로세스 실행 및 인자 전달, 시스템 콜 및 사용자 포인터 검증, 프로세스 동기화 및 종료) (0) | 2025.05.20 |
[Pintos] Windows에서 make check가 안 될 때 해결 방법 (Error 127) (0) | 2025.05.19 |