벌써 두 번째 프로젝트 UserProgram 주차가 시작됐다.
두 번째 프로젝트 까지는 첫 번째 프로젝트 팀원들과 함께 진행을 한다.
그러므로 Thread 프로젝트 처럼 분업을 하여 진행을 해서
내가 담당한 부분을 주로 포스팅 할 예정이다.
그럼 바로 시작해보자 !
사용자 프로그램이 운영체제의 기능(예: 파일 읽기, 쓰기, 프로세스 생성 등)을 직접 실행할 수는 없다. 운영체제는 커널이라는 보호된 영역에서 실행되기 때문이다. 사용자 프로그램이 커널의 기능을 이용하려면 시스템 콜(System Call)이라는 "문을 두드리는" 방식으로 요청을 보내야 한다.
예를 들어, printf()를 호출하면 결국 내부적으로는 write() 시스템 콜을 통해 파일 디스크립터 1(stdout)에 데이터를 출력한다. 시스템 콜은 사용자 모드와 커널 모드 간의 통로 역할을 하며, 이 과정에서는 컨텍스트 전환과 보안 검증이 수반된다 !
PintOS는 교육용 운영체제로, 처음에는 시스템 콜 인터페이스가 구현되어 있지 않다. userprog 프로젝트에서는 이를 직접 구현함으로써 운영체제가 어떻게 사용자 프로그램의 요청을 처리하는지를 학습하게 된다.
PintOS에서 사용자 프로그램은 syscall 명령어를 통해 커널로 진입한다. 이 명령어는 x86-64에서 제공되는 시스템 콜 진입점이며, 이는 커널의 특정 주소에 있는 syscall_entry() 함수로 연결된다. 우리는 이 진입점을 통해 들어온 요청을 분석하고, 요청된 시스템 콜에 따라 적절한 커널 함수를 호출해야 한다.
이제부터는 내가 직접 구현한 syscall.c를 바탕으로, PintOS에서 시스템 콜이 어떻게 처리되는지 알아보도록 하자 !!
void syscall_init(void) - 시스템 콜 초기화 함수#define MSR_STAR 0xc0000081 // 세그먼트 셀렉터 설정용 MSR
#define MSR_LSTAR 0xc0000082 // SYSCALL 명령 진입 주소 설정용 MSR
#define MSR_SYSCALL_MASK 0xc0000084 // EFLAGS 마스크 설정용 MSR
void syscall_init(void)
{
// 사용자 → 커널 진입 시 사용할 세그먼트 셀렉터 설정
write_msr(MSR_STAR,
((uint64_t)SEL_UCSEG - 0x10) << 48 | // 유저 코드 세그먼트
((uint64_t)SEL_KCSEG) << 32); // 커널 코드 세그먼트
// SYSCALL 명령이 호출되면 실행될 커널 함수 주소 등록
write_msr(MSR_LSTAR, (uint64_t)syscall_entry);
// 시스템 콜 진입 시 유저가 건드릴 수 없도록 막을 EFLAGS 비트 지정
write_msr(MSR_SYSCALL_MASK,
FLAG_IF | // 인터럽트 플래그
FLAG_TF | // 트랩 플래그
FLAG_DF | // 방향 플래그
FLAG_IOPL | // I/O 권한 레벨
FLAG_AC | // 정렬 검사
FLAG_NT); // 네스트 태스크
// 파일 시스템에 접근하는 시스템 콜들의 경쟁 조건 방지를 위한 락 초기화
lock_init(&filesys_lock);
}
syscall 명령을 사용할 수 있도록 CPU에게 진입점 정보 등록
시스템 콜 중단 시, 유저가 조작할 수 없도록 보안 플래그 마스킹
파일 관련 시스템 콜에서 race condition 방지를 위한 락 준비
void syscall_handler(struct intr_frame *f UNUSED)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:
f->R.rax = sys_fork((const char *)arg1, f);
break;
case SYS_EXEC:
f->R.rax = sys_exec((const char *)arg1);
break;
case SYS_CREATE:
f->R.rax = sys_create(arg1, arg2);
break;
case SYS_REMOVE:
f->R.rax = sys_remove(arg1);
break;
case SYS_OPEN:
f->R.rax = sys_open(arg1);
break;
case SYS_FILESIZE:
f->R.rax = sys_filesize(arg1);
break;
case SYS_READ:
f->R.rax = sys_read(arg1, arg2, arg3);
break;
case SYS_WRITE:
f->R.rax = sys_write(arg1, arg2, arg3);
break;
case SYS_SEEK:
sys_seek(arg1, arg2);
break;
case SYS_TELL:
f->R.rax = sys_tell(arg1);
break;
case SYS_CLOSE:
sys_close(arg1);
break;
case SYS_WAIT:
f->R.rax = sys_wait(arg1);
break;
default:
thread_exit(); // 정의되지 않은 시스템 콜 번호일 경우 프로세스 종료
break;
}
}
사용자 프로그램이 syscall 명령어로 커널에 진입했을 때 호출됨
rax에 담긴 시스템 콜 번호를 기준으로 어떤 동작을 수행할지 분기
각 시스템 콜 함수에 인자를 넘기고, 반환값은 다시 rax 레지스터에 저장
x86-64 ABI 규칙에 따라 시스템 콜 인자는 순서대로 rdi, rsi, rdx, r10, r8, r9에 저장됨
rax는 반환값 저장용으로도 쓰이므로, 대부분의 콜은 f->R.rax = ... 형태로 결과를 다시 써줌
f->R.rax에 값을 설정하지 않으면 사용자 프로그램 입장에선 "값이 반환되지 않는 시스템 콜"처럼 보임
sys_exec() 같이 호출에 실패하면 내부에서 직접 sys_exit(-1)을 호출해 프로세스를 종료시킴
void check_address(void *addr) - 포인터 인자 검증 함수void check_address(void *addr)
{
if (addr == NULL)
sys_exit(-1);
if (!is_user_vaddr(addr))
sys_exit(-1);
if (pml4_get_page(thread_current()->pml4, addr) == NULL)
sys_exit(-1);
}
사용자 프로그램이 전달한 포인터가 커널을 침범하거나, 잘못된 주소가 아닌지 확인하는 함수
문제가 있는 주소일 경우, 프로세스를 즉시 종료시켜 커널 오작동이나 보안 문제를 방지해준다.
NULL 체크
addr == NULL : 명백한 잘못된 포인터 → 바로 죽여야 함.유저 영역 주소인지 확인
is_user_vaddr(addr)는 주소가 0x08048000 ~ 0xC0000000 사이인지 확인
커널 주소를 넘기는 것을 방지 (예: 버그성 공격, 취약점 탐지용 주소 넘김)
해당 주소에 물리 페이지가 매핑되어 있는지 확인
pml4_get_page(...) == NULL : 페이지 테이블 상에 매핑된 유효한 주소가 아니라는 뜻
이는 해당 주소가 아직 할당되지 않았거나, 잘못된 접근이라는 뜻이므로 즉시 종료
⚠️ 실수 방지 포인트
단순히 is_user_vaddr()만 쓰는 건 불충분하다.
예: 0xbffffff0 같은 값은 유저 주소이지만, 매핑되지 않았을 수도 있음.
pml4_get_page()가 꼭 필요하다. → lazy loading이나 stack growth가 없다면 이걸로 충분히 막을 수 있음.
static int64_t get_user (const uint8_t *uaddr)static bool put_user (uint8_t *udst, uint8_t byte)static int64_t get_user (const uint8_t *uaddr) {
int64_t result;
__asm __volatile (
"movabsq $done_get, %0\n"
"movzbq %1, %0\n"
"done_get:\n"
: "=&a" (result) : "m" (*uaddr));
return result;
}
static bool put_user (uint8_t *udst, uint8_t byte) {
int64_t error_code;
__asm __volatile (
"movabsq $done_put, %0\n"
"movb %b2, %1\n"
"done_put:\n"
: "=&a" (error_code), "=m" (*udst) : "q" (byte));
return error_code != -1;
}

커널 코드에서 직접 유저 포인터에 접근하다가 Segfault가 발생하면 커널이 뻗어버린다.
그래서 이 함수는 어셈블리로 접근을 시도해보고, 실패하면 안전하게 빠져나올 수 있게 해줌.
보통 strlcpy_user()처럼 유저 문자열을 커널 버퍼로 복사할 때 많이 사용됨.
참고로 put_user()는 구현만 해놓고 사용은 안하고 있다. 나중에 vm 할때 주로 사용한다고 한다.
static void sys_halt() - 시스템 종료 시스템 콜static void sys_halt() {
power_off();
}
시스템을 강제로 종료하는 시스템 콜
실제 구현은 threads/init.c 에 있는 power_off() 호출
static int sys_write(int fd, const void *buffer, unsigned size) - 콘솔/파일 출력 시스템 콜static int sys_write(int fd, const void *buffer, unsigned size)
{
// 유저 포인터가 유효한지 검증 (전체 영역 검사 이전에 시작 주소 먼저)
check_address(buffer);
// buffer가 가리키는 전체 메모리 영역이 유저 공간에 있는지 확인
for (unsigned i = 0; i < size; i++) {
check_address((const uint8_t *)buffer + i);
}
// stdin (fd == 0)은 write 대상이 아님 → 에러 반환
if (fd == 0) {
return -1;
}
// stdout (fd == 1): 콘솔 출력 → putbuf로 출력하고 size만큼 썼다고 리턴
if (fd == 1)
{
putbuf(buffer, size); // 콘솔에 buffer 내용을 출력
return size; // 실제 쓴 바이트 수 반환
}
// 일반 파일에 대해 file descriptor 테이블에서 file 객체를 가져옴
struct file *file = process_get_file(fd);
if (file == NULL)
return -1; // 해당 fd에 해당하는 파일이 없으면 에러 반환
// 파일 시스템 접근 시 동시성 제어 위해 lock 획득
lock_acquire(&filesys_lock);
// 파일에 buffer 내용을 size 바이트만큼 write
int bytes_write = file_write(file, buffer, size);
// 파일 시스템 락 해제
lock_release(&filesys_lock);
// write 실패 시 음수 반환 (보통 -1)
if (bytes_write < 0)
return -1;
// 성공 시 실제로 write한 바이트 수 반환
return bytes_write;
}

유저 포인터(buffer)는 전체 영역에 대해 주소 유효성 검사
파일 시스템 조작은 락(filesys_lock)으로 보호해서 동시성 문제 방지
fd == 1 처리 안 해주면 콘솔 출력이 안 됨 → echo 류 테스트 실패
check_address()는 buffer 자체뿐만 아니라 buffer + i 범위도 반드시 검사해야 함
락 안 걸고 file_write() 하면 경쟁 조건(race condition) 발생 가능
void sys_exit(int status) - 프로세스 종료 시스템 콜void sys_exit(int status)
{
struct thread *cur = thread_current();
cur->exit_status = status;
printf("%s: exit(%d)\n", thread_name(), status);
thread_exit();
}
현재 프로세스를 종료하고, 종료 코드를 저장함
thread_exit() 는 현재 스레드를 커널에서 제거하고, context switch 발생시킴
exit_status를 저장하는 이유: 부모 프로세스의 wait()에서 확인할 수 있도록
printf() 메시지는 테스트 채점 스크립트가 파싱해서 PASS/FAIL 판단에 사용함
int sys_wait(int pid) - 자식 프로세스 대기int sys_wait(int pid)
{
return process_wait(pid);
}
pid 로 지정된 자식 프로세스가 종료될 때까지 현재 프로세스를 블로킹
자식이 종료되면 그 종료 코드를 반환
내부 동작은
process_wait()에 구현
sys_fork() - 현재 프로세스를 복제tid_t sys_fork(const char *thread_name, struct intr_frame *f)
{
return process_fork(thread_name, f);
}
thread_name 이름으로 현재 프로세스 복사 (fork)
레지스터 상태를 저장한 intr_frame 도 복사해서 자식이 부모와 동일 상태에서 시작
💡
f는 부모의 레지스터 상태(rax,rip,rsp등)를 담고있다
💡 자식 프로세스는 복사된 이 상태를 기반으로__do_fork()에서 다시 실행
sys_remove() - 파일 삭제bool sys_remove(const char *file) {
check_address(file);
if (file == NULL)
return false;
return filesys_remove(file);
}
파일 이름을 받아 해당 파일을 삭제 시도
포인터가 유효한 유저 주소인지 검사 후, 파일 시스템에 요청
check_address(file) 가 먼저인데도 file == NULL 을 또 체크하는 건 이중 방어용
filesys_remove() 는 내부적으로 inode 제거 처리를 해주는 함수 ! 추상화 개념으로 이정도만 알고가자
sys_filesize() - 파일 크기 조회int sys_filesize(int fd) {
struct file *file = process_get_file(fd);
if (file == NULL)
return -1;
return file_length(file);
}
주어진 파일 디스크립터 fd 에 대해 열려있는 파일의 크기(바이트)를 반환
파일이 없으면 -1 로 에러 반환
sys_read() - 입력 또는 파일에서 읽기int sys_read(int fd, void *buffer, unsigned size)
{
// 유저 공간 포인터가 유효한지 확인 (시작 주소만 검사)
check_address(buffer);
// buffer를 char 포인터로 변환해서 문자 단위로 접근
char *ptr = (char *)buffer;
int bytes_read = 0;
// 파일 시스템 동시 접근 방지용 글로벌 락 획득
lock_acquire(&filesys_lock);
if (fd == STDIN_FILENO) // 표준 입력인 경우
{
// 한 글자씩 키보드 입력 받아서 buffer에 저장
for (int i = 0; i < size; i++)
{
*ptr++ = input_getc(); // 키보드에서 한 글자 입력 받아 저장
bytes_read++; // 실제 읽은 바이트 수 증가
}
lock_release(&filesys_lock); // 락 해제
}
else
{
// 잘못된 fd(1: stdout이거나 음수인 경우)는 읽을 수 없음 → 에러
if (fd < 2)
{
lock_release(&filesys_lock);
return -1;
}
// 현재 프로세스의 fd 테이블에서 해당 파일 객체 조회
struct file *file = process_get_file(fd);
if (file == NULL)
{
lock_release(&filesys_lock);
return -1; // 파일이 없으면 실패
}
// 파일에서 size만큼 읽어서 buffer에 저장
bytes_read = file_read(file, buffer, size);
lock_release(&filesys_lock); // 락 해제
}
// 실제로 읽은 바이트 수 반환 (0 이상)
return bytes_read;
}

유저 포인터 유효성 검사(check_address) 는 필수 !
락 없이 file_read() 쓰면 동시성 오류가 발생할 수 있기 때문에 락 꼭 걸기 !
fd == 1 같은 출력 전용 디스크립터에 read() 시도하면 무조건 실패해야 한다
sys_seek() - 파일 오프셋 이동void sys_seek(int fd, unsigned position) {
struct file *file = process_get_file(fd);
if (file == NULL)
return;
file_seek(file, position);
}
열린 파일의 읽기/쓰기 위치(offset)를 position 으로 설정
이후의 read() 나 write() 는 이 위치부터 수행된다.
fd 가 유효하지 않으면 조용히 무시 (프로세스를 죽이지 않음)
내부적으로 struct file의 pos값이 바뀜
sys_tell() - 현재 오프셋 조회unsigned sys_tell(int fd) {
struct file *file = process_get_file(fd);
if (file == NULL)
return 0;
return file_tell(file);
}
현재 파일 오프셋(위치)를 반환
보통 read() 직후 위치 확인 등에 사용한다.
unsigned 라서에러시 -1 을 못 씀 → 대신 0 반환으로 방어
명확한 에러 체크가 필요하면 인터페이스 수정을 고려해볼 수 있다.
sys_close() - 파일 닫기void sys_close(int fd) {
struct file *file = process_get_file(fd);
if (file == NULL)
return;
file_close(file);
thread_current()->FDT[fd] = NULL;
}
열린 파일 디스크립터를 닫고, 자원을 해제
커널에서 열려있는 파일 수 제한 때문에 꼭 닫아줘야 한다 !
file_close() 호출 후 반드시 FDT 엔트리도 NULL 로 초기화
닫은 후에도 FDT[fd] 가 그대로면 나중에 use-after-close 버그 발생 가능
sys_create() - 새 파일 생성bool sys_create(const char *file, unsigned initial_size) {
// 유저 포인터가 유효한지 검사
check_address(file);
// 유저 영역 문자열을 커널 버퍼로 안전하게 복사
char kernel_buf[NAME_MAX + 1];
if (!strlcpy_user(kernel_buf, file, sizeof kernel_buf)) {
return false; // 복사 실패 → 파일 이름을 읽지 못했음
}
// 빈 문자열이면 생성 불가
if (strlen(kernel_buf) == 0) {
return false;
}
// 루트 디렉토리 열기 (기본 디렉토리)
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;
}
주어진 이름과 초기 크기를 갖는 새 파일을 생성
유저 문자열을 커널 공간으로 복사한 뒤, 루트 디렉토리에 존재 여부를 확인하고 생성 시도
유저 포인터는 check_address() 로 검사하고, strlcpy_user() 로 복사
락 없이 filesys_create() 호출하면 동시성 오류가 발생할 수 있다 !
이름 중복은 허용되지 않는다.
strlcpy_user() - 유저 문자열 안전 복사 (헬퍼 함수)bool strlcpy_user(char *dst, const char *src_user, size_t size) {
for (size_t i = 0; i < size; i++) {
check_address((void *)(src_user + i));
int val = get_user((const uint8_t *)src_user + i);
if (val == -1) return false;
dst[i] = val;
if (val == '\0') return true;
}
dst[size - 1] = '\0';
return false;
}
유저 주소에서 널 종료 문자열을 하나씩 읽어와 커널 버퍼에 복사
문자열 끝까지 읽기 성공하면 true, 실패하거나 너무 길면 false
유저 포인터는 get_user()로 직접 메모리에 접근 -> 보호된 방식
종료 조건 '\0' 검출이 필수 -> null 미포함 시 강제 종료
sys_open() - 파일 열기int sys_open(const char *file_name) {
check_address(file_name);
lock_acquire(&filesys_lock);
struct file *file = filesys_open(file_name);
if (file == NULL) {
lock_release(&filesys_lock);
return -1;
}
int fd = process_add_file(file);
if (fd == -1)
file_close(file);
lock_release(&filesys_lock);
return fd;
}
주어진 이름의 파일을 열고, 파일 디스크립터를 반환
process_add_file() 로 현재 프로세스의 FDT에 등록
파일 열기에 실패했거나 FDT가 가득 찼을 경우 -> -1 반환
파일을 열었다면 file_close() 로 누수를 방지해줘야 된다.
sys_exec() - 새로운 프로그램 실행static tid_t sys_exec(const char *cmd_line) {
validate_str(cmd_line);
char *cmd_line_copy = palloc_get_page(PAL_ZERO);
if (cmd_line_copy == NULL)
sys_exit(-1);
strlcpy(cmd_line_copy, cmd_line, PGSIZE);
if (process_exec(cmd_line_copy) == -1)
sys_exit(-1);
}
사용자 프로그램의 실행 파일 경로를 받아 새로운 프로그램으로 자기 자신을 덮어 씀
성공 시 현재 프로세스는 사라지고, 새로운 프로세스가 그 자리를 대체
process_exec() 는 절대 되돌아오지 않는다 -> 실패 시 직접 sys_exit() 호출
명령줄은 페이지 단위로 복사해서 전달 (palloc_get_page + strlcpy)
validate_str() 로 포인터 유효성 검증 필수
이렇게 System Call 함수의 구현을 알아보았다.
사실 여기도 중요하지만 process.c 에서 실제로 동작하는 코드들이 많기 때문에, 그 부분을 더 세심하게 살펴보아야 한다.

