정글/Pintos

[pintos] 2주차 - User Memory, System Calls 구현 과정

nkdev 2025. 5. 28. 13:07

테스트 현황 。゚゚(*´□`*。)°゚。

5/31 기준

pass tests/userprog/args-none
pass tests/userprog/args-single
pass tests/userprog/args-multiple
pass tests/userprog/args-many
pass tests/userprog/args-dbl-space
pass tests/userprog/halt
pass tests/userprog/exit
pass tests/userprog/create-normal
FAIL tests/userprog/create-empty
pass tests/userprog/create-null
pass tests/userprog/create-bad-ptr
FAIL tests/userprog/create-long
FAIL tests/userprog/create-exists
pass tests/userprog/create-bound
FAIL tests/userprog/open-normal
FAIL tests/userprog/open-missing
FAIL tests/userprog/open-boundary
FAIL tests/userprog/open-empty
pass tests/userprog/open-null
pass tests/userprog/open-bad-ptr
FAIL tests/userprog/open-twice
FAIL tests/userprog/close-normal
FAIL tests/userprog/close-twice
pass tests/userprog/close-bad-fd
FAIL tests/userprog/read-normal
pass tests/userprog/read-bad-ptr
FAIL tests/userprog/read-boundary
FAIL tests/userprog/read-zero
pass tests/userprog/read-stdout
pass tests/userprog/read-bad-fd
FAIL tests/userprog/write-normal
pass tests/userprog/write-bad-ptr
FAIL tests/userprog/write-boundary
FAIL tests/userprog/write-zero
pass tests/userprog/write-stdin
pass tests/userprog/write-bad-fd
FAIL tests/userprog/fork-once
FAIL tests/userprog/fork-multiple
FAIL tests/userprog/fork-recursive
FAIL tests/userprog/fork-read
FAIL tests/userprog/fork-close
FAIL tests/userprog/fork-boundary
FAIL tests/userprog/exec-once
FAIL tests/userprog/exec-arg
FAIL tests/userprog/exec-boundary
FAIL tests/userprog/exec-missing
pass tests/userprog/exec-bad-ptr
FAIL tests/userprog/exec-read
FAIL tests/userprog/wait-simple
FAIL tests/userprog/wait-twice
FAIL tests/userprog/wait-killed
pass tests/userprog/wait-bad-pid
FAIL tests/userprog/multi-recurse
FAIL tests/userprog/multi-child-fd
FAIL tests/userprog/rox-simple
FAIL tests/userprog/rox-child
FAIL tests/userprog/rox-multichild
FAIL tests/userprog/bad-read
FAIL tests/userprog/bad-write
FAIL tests/userprog/bad-read2
FAIL tests/userprog/bad-write2
FAIL tests/userprog/bad-jump
FAIL tests/userprog/bad-jump2
FAIL tests/filesys/base/lg-create
FAIL tests/filesys/base/lg-full
FAIL tests/filesys/base/lg-random
FAIL tests/filesys/base/lg-seq-block
FAIL tests/filesys/base/lg-seq-random
FAIL tests/filesys/base/sm-create
FAIL tests/filesys/base/sm-full
FAIL tests/filesys/base/sm-random
FAIL tests/filesys/base/sm-seq-block
FAIL tests/filesys/base/sm-seq-random
FAIL tests/filesys/base/syn-read
FAIL tests/filesys/base/syn-remove
FAIL tests/filesys/base/syn-write
FAIL tests/userprog/no-vm/multi-oom
pass tests/threads/alarm-single
pass tests/threads/alarm-multiple
pass tests/threads/alarm-simultaneous
pass tests/threads/alarm-priority
pass tests/threads/alarm-zero
pass tests/threads/alarm-negative
pass tests/threads/priority-change
pass tests/threads/priority-donate-one
FAIL tests/threads/priority-donate-multiple
FAIL tests/threads/priority-donate-multiple2
FAIL tests/threads/priority-donate-nest
pass tests/threads/priority-donate-sema
FAIL tests/threads/priority-donate-lower
pass tests/threads/priority-fifo
pass tests/threads/priority-preempt
pass tests/threads/priority-sema
FAIL tests/threads/priority-condvar
FAIL tests/threads/priority-donate-chain
61 of 95 tests failed.

구현 요구사항

userprog/syscall.c 안에 

/* The main system call interface */
void
syscall_handler (struct intr_frame *f UNUSED) {
	// TODO: Your implementation goes here.
	printf ("system call!\n");
	thread_exit ();
}

요런 시스템 콜 핸들러가 있다. 

 

이 시스템 콜 핸들러 함수에는 아직 아무 로직도 구현되어있지 않다. 

그래서 우리가 만들어줘야 한다.

 

👶 시스템 콜 핸들러가 하는 일 :

  1. 시스템 콜 번호에 해당하는 동작을 수행하게 한다.
  2. 커널이 참조할 인자들이 유효한지 먼저 확인한다.
  3. 시스템 콜 리턴값을 eax 레지스터에 저장한다.

이게 무슨 말인지 아직 이해가 가지 않는다면..

시스템 콜의 프로세스를 간단히 이해해보자!

 

System Call의 전체적인 동작 과정

유저 프로그램이 write() 시스템 콜을 호출했다고 가정하자.

 

write() 시스템 콜은 내부적으로 syscall3()을 호출한다.

int
write (int fd, const void *buffer, unsigned size) {
	return syscall3 (SYS_WRITE, fd, buffer, size);
}

 

syscall3()은 다음과 같은 일을 한다.

  • 받은 인자 3개를 유저 공간의 레지스터에 push
      -> 유저 프로세스가 함수를 호출했으니 유저 스택에 인자 3개가 저장될 것이다.
  • int $0x30를 통해 커널 모드로 진입
      -> int $[시스템콜 번호]로 특정 시스템 콜을 수행한다.

syscall3()에 대해서 아주 쪼금만 더 살펴보자 !

 

첫 번째로, 받은 인자 3개를 유저 공간의 레지스터에 push한다.

 

유저 프로그램이 시스템 콜을 호출했다는 것은 커널에게 뭔가 맡길 일이 있다는 뜻이다.

이때 시스템 콜 함수 인자로 1. 어떤 시스템 콜인지(시스템 콜 넘버) 2. 필요한 데이터들을 넘겨준다.

 

lib/user/syscall.c에 구현된 시스템 콜 함수들을 보면 시스템 콜 종류 마다 전달되는 인자 개수가 다른 것을 알 수 있는데, write() 시스템콜은 인자가 3개라서 syscall3()을 호출한다.

 

이렇게 유저 프로세스가 syscall3()이라는 인터페이스를 통해, 커널에서 시스템 콜을 처리할 때 사용되어야 하는 인자값들을 전달하면 syscall3()는 다음과 같은 어셈블리를 통해 인자값들을 유저 공간의 레지스터에 저장한다.

syscall()함수 안에 정의된 코드

rax에는 시스템 콜 번호가 담기고, %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 인자가 저장된다.

 

그리고 두 번째로, int $0x30를 통해 커널 모드로 진입한다.

 

int $0x30 

이 부분이 유저 모드에서 커널 모드로 진입하게 하는 중요한 트랩 명령어이다.

wirte() 시스템 콜에서 가장 핵심적인 부분이라고 할 수 있다!

 

"0x30 트랩"을 발생시켜서 커널 모드로 진입하면 IDT(Interrupt Descriptor Table)의 0x30 위치에 등록된 트랩 핸들러가 실행된다. Pintos에서는 이 핸들러를 syscall_handler()라는 이름으로 지정해뒀고, 이게 우리가 구현해야 할 부분이다.

 


왜 int $0x30을 인터럽트 명령어가 아니라 '트랩' 명령어라고 할까?

CSAPP 8장 프로세스를 공부하면서 트랩이라는 개념을 배운 적이 있다.

트랩은 인터럽트와 비교되는 개념인데 잠깐 설명하고 넘어가면 !

 

사용자 프로세스가 커널 영역에서 뭔가 할 일이 생겨서 의도적으로 write()와 같은 시스템 콜을 호출하여 커널 진입을 요청하는 것이 트랩이다. 이 경우 트랩 핸들러가 호출되어 커널에서 해당 일이 처리된다. 

반면에 하드웨어 타이머의 cpu의 tick이 정해진 횟수 만큼 도달했을 때 cpu의 컨텍스트 스위치가 일어나는 것처럼 사용자 프로세스가 아니라 외부 장치가 cpu에게 signal을 보내 커널 모드의 처리를 요청하는 것을 인터럽트라고 한다. 이 경우 인터럽트 핸들러가 적절한 작업을 처리한다.

 

int $0x30은 write() 시스템 콜에 의해 호출된 트랩 명령어라는 것을 알 수 있다!


syscall()에 대해 조금 이해가 되었는가?

커널로 전달할 데이터들을 인자로 받고, 커널 모드로 전환하는 트랩을 발생시켜서 유저 프로세스와 커널 사이의 인터페이스 역할을 하고 있다.


 

 

자 그럼 우리는 이 그림의 절반을 이해한 것이다. 왼쪽의 User 공간에서 무슨 일이 일어나는지까지는 알겠다.

 

응 그래서 int $0x30에 의해 syscall_handler()가 호출됐고 커널 모드로 전환됐어.

 

그리고 유저->커널모드로 전환이 되는 중간 과정 어딘가~~~에서 유저 레지스터에 저장된 유저 스레드 컨텍스트(시스템콜 호출할 때 넘겨준 인자 포함한 데이터들)를 커널에서도 쓸 수 있게 intr_frame에 복사해서 syscall_handler의 인자로 넘겨줄 준비도 했어!! 

 

그렇다면 syscall_handler()가 하는 일은 뭔데 😳???

 

사실 글을 맨 처음으로 올려보면 이미 언급했었다.

다시 한 번 정리하면 다음과 같다. 

 

syscall_handler()가 하는 일

  1. 인자 값들이 유효한지 체크하기
  2. 시스템 콜 넘버에 해당하는 시스템 콜이 수행되게 하기
  3. eax 레지스터에 시스템 콜 리턴 값을 저장하기

 

자 이제 구현해보자.

syscall_handler() 구현하기

(1) 유효성 체크

먼저 유저가 넘긴 인자값의 포인터가 유효한 값인지 검사해야 한다. 만약 유효하지 않으면 프로세스를 종료한다.

 

(2) 시스템 콜 수행

syscall_handler()가 넘겨받은 intr_frame 구조체 안에 시스템 콜 넘버 값이 저장되어 있다.

struct intr_frame {
	/* Pushed by intr_entry in intr-stubs.S.
	   These are the interrupted task's saved registers. */
	struct gp_registers R;
    //...
}

intr_frame 안에 gp_registers라는 구조체가 또 있는데

/* Interrupt stack frame. */
struct gp_registers {
	uint64_t r15;
	uint64_t r14;
	uint64_t r13;
	uint64_t r12;
	uint64_t r11;
	uint64_t r10;
	uint64_t r9;
	uint64_t r8;
	uint64_t rsi;
	uint64_t rdi;
	uint64_t rbp;
	uint64_t rdx;
	uint64_t rcx;
	uint64_t rbx;
	uint64_t rax;
} __attribute__((packed));

 

이 구조체의 rax, rbx, rcx ... 순서로 인자가 저장되어 있을 것이다.

그 중 시스템 콜 넘버는 첫 번째 인자였으므로 rax값이 시스템 콜 넘버이다.


 

시스템 콜의 종류는 lib/user/syscall.c에 정의된 함수만 봐도 이렇게나 많다.

void
halt (void) {
	syscall0 (SYS_HALT);
	NOT_REACHED ();
}

void
exit (int status) {
	syscall1 (SYS_EXIT, status);
	NOT_REACHED ();
}

pid_t
fork (const char *thread_name){
	return (pid_t) syscall1 (SYS_FORK, thread_name);
}

int
exec (const char *file) {
	return (pid_t) syscall1 (SYS_EXEC, file);
}

int
wait (pid_t pid) {
	return syscall1 (SYS_WAIT, pid);
}

bool
create (const char *file, unsigned initial_size) {
	return syscall2 (SYS_CREATE, file, initial_size);
}

bool
remove (const char *file) {
	return syscall1 (SYS_REMOVE, file);
}

int
open (const char *file) {
	return syscall1 (SYS_OPEN, file);
}

int
filesize (int fd) {
	return syscall1 (SYS_FILESIZE, fd);
}

int
read (int fd, void *buffer, unsigned size) {
	return syscall3 (SYS_READ, fd, buffer, size);
}

int
write (int fd, const void *buffer, unsigned size) {
	return syscall3 (SYS_WRITE, fd, buffer, size);
}

void
seek (int fd, unsigned position) {
	syscall2 (SYS_SEEK, fd, position);
}

unsigned
tell (int fd) {
	return syscall1 (SYS_TELL, fd);
}

void
close (int fd) {
	syscall1 (SYS_CLOSE, fd);
}

int
dup2 (int oldfd, int newfd){
	return syscall2 (SYS_DUP2, oldfd, newfd);
}

void *
mmap (void *addr, size_t length, int writable, int fd, off_t offset) {
	return (void *) syscall5 (SYS_MMAP, addr, length, writable, fd, offset);
}

void
munmap (void *addr) {
	syscall1 (SYS_MUNMAP, addr);
}

bool
chdir (const char *dir) {
	return syscall1 (SYS_CHDIR, dir);
}

bool
mkdir (const char *dir) {
	return syscall1 (SYS_MKDIR, dir);
}

bool
readdir (int fd, char name[READDIR_MAX_LEN + 1]) {
	return syscall2 (SYS_READDIR, fd, name);
}

bool
isdir (int fd) {
	return syscall1 (SYS_ISDIR, fd);
}

int
inumber (int fd) {
	return syscall1 (SYS_INUMBER, fd);
}

int
symlink (const char* target, const char* linkpath) {
	return syscall2 (SYS_SYMLINK, target, linkpath);
}

int
mount (const char *path, int chan_no, int dev_no) {
	return syscall3 (SYS_MOUNT, path, chan_no, dev_no);
}

int
umount (const char *path) {
	return syscall1 (SYS_UMOUNT, path);
}

 

각 시스템 콜 함수를 살펴보면 목적에 맞는 동작을 수행하기 위해 syscall() 함수의 첫 번째 인자로 '시스템 콜 번호'를 전달하고 있다.

시스템 콜 번호는 include/lib/syscall-nr.h에 enum으로 정의되어 있다. 

/* System call numbers. */
enum {
	/* Projects 2 and later. */
	SYS_HALT,                   /* Halt the operating system. */
	SYS_EXIT,                   /* Terminate this process. */
	SYS_FORK,                   /* Clone current process. */
	SYS_EXEC,                   /* Switch current process. */
	SYS_WAIT,                   /* Wait for a child process to die. */
	SYS_CREATE,                 /* Create a file. */
	SYS_REMOVE,                 /* Delete a file. */
	SYS_OPEN,                   /* Open a file. */
	SYS_FILESIZE,               /* Obtain a file's size. */
	SYS_READ,                   /* Read from a file. */
	SYS_WRITE,                  /* Write to a file. */
	SYS_SEEK,                   /* Change position in a file. */
	SYS_TELL,                   /* Report current position in a file. */
	SYS_CLOSE,                  /* Close a file. */

	/* Project 3 and optionally project 4. */
	SYS_MMAP,                   /* Map a file into memory. */
	SYS_MUNMAP,                 /* Remove a memory mapping. */

	/* Project 4 only. */
	SYS_CHDIR,                  /* Change the current directory. */
	SYS_MKDIR,                  /* Create a directory. */
	SYS_READDIR,                /* Reads a directory entry. */
	SYS_ISDIR,                  /* Tests if a fd represents a directory. */
	SYS_INUMBER,                /* Returns the inode number for a fd. */
	SYS_SYMLINK,                /* Returns the inode number for a fd. */

	/* Extra for Project 2 */
	SYS_DUP2,                   /* Duplicate the file descriptor */

	SYS_MOUNT,
	SYS_UMOUNT,
};

#endif /* lib/syscall-nr.h */

그래서 switch문을 쓸 때 시스템 콜 넘버를 하드코딩하는 게 아니라 enum을 사용하면 된다.

강의에서도 시스템 콜 넘버가 미리 정의되어 있으니 이렇게 쓰라고 알려준다.

 

(3) eax 레지스터에 시스템 콜 리턴 값을 저장하기

kaist 핀토스에서는 R.rax 레지스터에 리턴값을 저장하면 된다.

 

ㅋㅋ 내가 처음으로 짜본 코드이다. ㅎ ㅎ 

/* The main system call interface */
void
syscall_handler (struct intr_frame *f UNUSED) {
	// TODO: Your implementation goes here.
	/*
		시스템 콜 핸들러 syscall_handler() 가 제어권을 얻으면 
		시스템 콜 번호는 rax 에 있고, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 전달됩니다.
		syscall() 함수를 보니 인자가 최대 3개라서 arg3까지만 선언함
	*/

	int sysnum = f->R.rax;
	int arg1 = f->R.rdi;
	int arg2 = f->R.rsi;
	int arg3 = f->R.rdx;

	switch(sysnum){
		case SYS_HALT:
			halt();
			break;
		case SYS_EXIT:
			exit(arg1);
			break;
		case SYS_FORK:
			fork(arg1);
			break;
		case SYS_EXEC:
			fork(arg1);
			break;
		case SYS_WAIT:
			fork(arg1);
			break;
		case SYS_CREATE:
			fork(arg1, arg2);
			break;
		case SYS_REMOVE:
			fork(arg1);
			break;
		case SYS_OPEN:
			fork(arg1);
			break;
		case SYS_FILESIZE:
			fork(arg1);
			break;
		case SYS_WRITE:
			fork(arg1, arg2, arg3);
			break;
		case SYS_SEEK:
			fork();
			break;
		case SYS_TELL:
			fork();
			break;
		case SYS_CLOSE:
			fork();
			break;
	}



	printf ("system call!\n");
	thread_exit ();
}

계속 구현하다 보니 문제가 하나씩 보이기 시작 하더니... 코딩을 멈추게 되었따 ㅋ

 

  1. 내가 유효성 검사해야 하는 값이 유저 영역에 저장된 인자 값인가, 커널 영역에 복사된 값인가?
     -> 우리 조원들, 성광이, 은수와 얘기해 본 결과 이 고민은 현재로선 할 필요가 없는 부분이라는 결론을 내렸다. 일단 이런 개념을 아는 것도 중요하지만, 지금은 시스템 콜 핸들러와 시스템 콜 함수 내부 로직을 구현하는 것에 더 초점을 맞춰야 되는 시간이기도 하고(핀토스 주간 빡빡해서 구현할 시간도 부족함 ㅠㅠ) 시스템 콜 인자로 넘겨준 값을 intr_frame을 이용해서 쓸 수 있다는 사실은 자명하므로 일단 그냥 쓰기로 했다. 유튜브의 Pintos 강의에서 유저 영역에 저장된 인자값들을 커널 영역으로 복사해서 쓴다고 하길래 나는 유저 모드에서 syscall() 인자로 넘겨준 데이터들을 커널 영역에 복사하는 과정이 분명히 있을 거라고 생각했는데 아직 그 과정에 해당하는 코드를 찾지 못 했다. ^ㅁ^.. process_exec() 안의 do_iret()인가 했는데 이건 유저->커널이 아니라 커널->유저 모드로 넘어갈 때 유저 프로세스를 실행하기 위해 cpu 레지스터, 스택 포인터 등을 세팅하는 과정이었고.. 아무튼 간에 intr_frame을 어떻게 그냥 쓸 수 있는 거지에 대해서 고민을 많이 했는데 일단 모르겠어서 그냥 넘어가기로 한다. 

 

-> 성광이가 같이 고민해준 결과 요런 과정이 존재하고

#include "threads/loader.h"

.text
.globl syscall_entry
.type syscall_entry, @function
syscall_entry:
	movq %rbx, temp1(%rip)
	movq %r12, temp2(%rip)     /* callee saved registers */
	movq %rsp, %rbx            /* Store userland rsp    */
	movabs $tss, %r12
	movq (%r12), %r12
	movq 4(%r12), %rsp         /* Read ring0 rsp from the tss */
	/* Now we are in the kernel stack */
	push $(SEL_UDSEG)      /* if->ss */
	push %rbx              /* if->rsp */
	push %r11              /* if->eflags */
	push $(SEL_UCSEG)      /* if->cs */
	push %rcx              /* if->rip */
	subq $16, %rsp         /* skip error_code, vec_no */
	push $(SEL_UDSEG)      /* if->ds */
	push $(SEL_UDSEG)      /* if->es */
	push %rax
	movq temp1(%rip), %rbx
	push %rbx
	pushq $0
	push %rdx
	push %rbp
	push %rdi
	push %rsi
	push %r8
	push %r9
	push %r10
	pushq $0 /* skip r11 */
	movq temp2(%rip), %r12
	push %r12
	push %r13
	push %r14
	push %r15
	movq %rsp, %rdi

check_intr:
	btsq $9, %r11          /* Check whether we recover the interrupt */
	jnb no_sti
	sti                    /* restore interrupt */
no_sti:
	movabs $syscall_handler, %r12
	call *%r12
	popq %r15
	popq %r14
	popq %r13
	popq %r12
	popq %r11
	popq %r10
	popq %r9
	popq %r8
	popq %rsi
	popq %rdi
	popq %rbp
	popq %rdx
	popq %rcx
	popq %rbx
	popq %rax
	addq $32, %rsp
	popq %rcx              /* if->rip */
	addq $8, %rsp
	popq %r11              /* if->eflags */
	popq %rsp              /* if->rsp */
	sysretq

.section .data
.globl temp1
temp1:
.quad	0
.globl temp2
temp2:
.quad	0

userprog/syscall-entry.S를 보면 이 과정이 그대로 나와있다. 사용자 프로그램이 system call을 하면 현재 실행 중인 유저 스레드가 커널 모드로 진입하기 위해 syscall_entry로 접근하여 유저 모드의 현재 컨텍스트를 커널 스택에 저장(push)하고, syscall_handler을 호출하여 커널 동작을 처리한다. 이때 syscall_handler()의 인자로 넘기는 값이 intr_frame이다. intr_frame에는 시스템 콜 번호와 인자들이 포함되어있다.

 

오호.. syscall_handler에서 참조하는 intr_frame값이 어디서 오는지 알았다. 유저 모드의 시스템 콜에 의해 syscall_entry 실행 결과로 저장되는 것이었다!

 

그렇다면 이 intr_frame값은 유저 스택의 값인가, 커널 스택의 값인가? 

 

struct intr_frame *f는 커널 스택에 위치하는 값이다. 유저 스택은 시스템 콜 인자 등 일부 값이 포함되어 있지만 intr_frame 자체는 아니며, 인터럽트 발생 시 하드웨어가 자동으로 커널 스택에 intr_frame을 세팅한다. 따라서 f->rsp와 같이 유저 스택 포인터 값을 확인할 수는 있지만, 그 자체는 커널 스택 상에 존재하는 구조체이다.

 

2. intr_frame 값을 f->R.xxx로 꺼낼 때 유효성 검사를 해야 하는 거 아닌가? 나는 지금 세 개의 포인터를 유효성 검사를 거치지도 않고 참조했다.
 -> 맞다. 참조한 값이 null pointer거나 유효하지 않을 수도 있다. 커널이 유효하지 않은 포인터를 참조하면 커널 패닉이 발생할 수 있기 때문에 무조건 검사해줘야 한다. 그런데 여기서 예찬님이 알려주신 핵심 포인트는.. 시스템 콜 마다 인자로 받는 데이터의 타입이 여러 가지이기 때문에 그 타입 별로 유효성 검사하는 함수를 따로 만들어서 써야 한다는 것이다. 예를 들어 write() 시스템 콜은 인자로 int fd, void *buffer, unsigned size 이렇게 받는데, 1. 세 값 모두 유효성 검사를 해줘야 하고 2. 타입 마다 다르게 유효성 검사를 해야 한다.  

 

3. 포인터로 참조한 값을 무조건 int타입 변수에 저장해도 되는가?
 -> 당연히 아님..

 

나의 엉망진창 코드를 다시 수정해보자. 

/* The main system call interface */
void
syscall_handler (struct intr_frame *f UNUSED) {
	// TODO: Your implementation goes here.
	/*
		시스템 콜 핸들러 syscall_handler() 가 제어권을 얻으면 
		시스템 콜 번호는 rax 에 있고, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 전달됩니다.
		syscall() 함수를 보니 인자가 최대 3개라서 arg3까지만 선언함
	*/

	int sysnum = f->R.rax;

	switch(sysnum){
		case SYS_HALT:
			halt();
			break;
		case SYS_EXIT:
			exit(f->R.rdi);
			break;
		case SYS_FORK:
			fork(f->R.rdi);
			break;
		case SYS_EXEC:
			exec(f->R.rdi);
			break;
		case SYS_WAIT:
			wait(f->R.rdi);
			break;
		case SYS_CREATE:
			create(f->R.rdi, f->R.rsi);
			break;
		case SYS_REMOVE:
			remove(f->R.rdi);
			break;
		case SYS_OPEN:
			open(f->R.rdi);
			break;
		case SYS_FILESIZE:
			filesize(f->R.rdi);
			break;
		case SYS_WRITE:
			write(f->R.rdi, f->R.rsi, f->R.rdx);
			break;
		case SYS_SEEK:
			seek(f->R.rdi, f->R.rsi);
			break;
		case SYS_TELL:
			tell(f->R.rdi);
			break;
		case SYS_CLOSE:
			close(f->R.rdi);
			break;
	}

	printf ("system call!\n");
	thread_exit ();
}

bool create (const char *file, unsigned initial_size){
	//check_valid
	filesys_create (file, initial_size);
}

 

f->R.rax에 저장되어 있는 시스템 콜 넘버를 참조하여 그 번호에 맞는 시스템 콜을 수행하게 했다.

이제 유효성 검사 로직과 시스템 콜 로직을 짜야 한다.

 

syscall_handler()가 바로 호출하는 함수이기 때문에 같은 파일에 바로 구현했다.

 

(1) 유효성 검사 구현하기

시스템 콜이 유효하지 않은 주소에 접근하면 커널 패닉이 발생하는 등 커널과 현재 실행 중인 프로세스 좋지 않은 영향을 미칠 수 있으므로 user memory에 access하기 전에는 반드시 주소가 유효한지 validation check를 해줘야 한다.

 

유저가 시스템 콜 인자로 넘겨준 값들은 1) kernel virtual address space가 아니라 user virtual address space를 가리키고 있어야 하고(즉 메모리 주소값이 KERN_BASE보다 커야 함), 2) null pointer가 아니어야 하며, 3)가상 메모리와 매핑된 주소여야 한다.

 

강의에서는 유효성을 검사하는 두 가지 방법을 소개한다.

 

나는 user-provided pointer인지 확인하는 첫 번째 방법을 사용했다.

include/threads/vaddr.h에서 is_user_vaddr()이라는 함수를 제공하는데, 얘를 사용하면 된다.

/* Returns true if VADDR is a user virtual address. */
#define is_user_vaddr(vaddr) (!is_kernel_vaddr((vaddr)))

 

시스템 콜 함수 안에 요런 식으로 구현했다.

/* user_vaddr가 아니거나 null pointer이라면 스레드 종료 */
if(!is_user_vaddr(addr) || file == NULL)
    process_exit();

(2) 시스템 콜 구현하기

공식 문서 (https://casys-kaist.github.io/pintos-kaist/project2/system_call.html)에 각 시스템 콜이 어떤 일을 해야 하는지 설명되어 있다. 하나씩 따라가보자 !

halt

pintos를 종료하는 시스템 콜이다. include/thread/init.h에 선언된 power_off()를 호출하기만 하면 된다.

/* power_off를 호출하여 머신을 종료한다. */
void halt(){
	power_off();
}

 

halt 테스트를 pass하기 위해서는 write() 시스템 콜이 구현되어 있어야 한다.

나는 begin이 출력되지 않는다는 에러가 뜨면서 테스트가 계속 fail되어서 gdb로 확인해봤는데 syscall_interrupt()에서 더 이상 넘어가지 않고 뭔가를 기다리고 있었다. 윤호 코드를 gdb를 사용해서 디버깅해보니 halt가 write()를 호출한다는 것을 알게 되었고.. 내 코드는 write()로 빠지는 switch case가 주석처리 되어있었다 ㅎ

exit

현재 실행되고 있는 유저 프로그램을 종료하고 status값을 커널에 리턴하는 시스템 콜이다.

만약 부모 프로세스가 wait()를 호출하여 이 프로세스가 종료되기를 기다리고 있었다면, 이 status값이 그 부모 프로세스에게 리턴된다.

/* 현재 실행 중인 유저 프로그램을 terminate하고 커널로 status를 리턴 */
void exit(int status){
	printf("%s: exit(%d)\n", thread_current()->name, status);
	thread_exit();
}

 

나는 thread_exit()을 호출해야 하는데 process_exit()을 호출해서 에러가 떴었다. 

Page fault at 0x403242: not present error reading page in user context.
exit: dying due to interrupt 0x0e (#PF Page-Fault Exception).
Interrupt 0x0e (#PF Page-Fault Exception) at rip=403242
 cr2=0000000000403242 error=               4
rax 0000000000000001 rbx 0000000000000000 rcx 0000000000403242 rdx 0000000000000000
rsp 000000004747ff30 rbp 000000004747ff80 rsi 0000000000000000 rdi 0000000000000039
rip 0000000000403242 r8 0000000000000000  r9 0000000000000000 r10 0000000000000000
r11 0000000000000206 r12 0000000000000000 r13 0000000000000000 r14 0000000000000000
r15 0000000000000000 rflags 00000206
es: 001b ds: 001b cs: 0023 ss: 001b
Execution of 'exit' complete.

page fault 오류였는데 이미 할당 해제되어 존재하지 않는 유저 컨텍스트 데이터에 접근하려고 시도했기 때문이다. 

코드를 살펴보면 thread_exit()은 USERPROG일 때 (현재 유저 프로세스를 실행 중일 때) process_exit()을 먼저 호출하여 process_cleanup()으로 현재 프로세스의 모든 리소스들을 반환한 후, do_schedule()로 새로운 프로세스를 스케줄한다.

/* Deschedules the current thread and destroys it.  Never
   returns to the caller. */
void
thread_exit (void) {
	ASSERT (!intr_context ());

#ifdef USERPROG
	process_exit ();
#endif

	/* Just set our status to dying and schedule another process.
	   We will be destroyed during the call to schedule_tail(). */
	intr_disable ();
	do_schedule (THREAD_DYING);
	NOT_REACHED ();
}

thread_exit()을 안 하고 process_exit()만 실행하게 되면 현재 프로세스의 리소스만 모두 할당 해제하고 다른 프로세스로 스케줄링하지 않으므로 현재 프로세스의 리소스가 모두 할당 해제된 채 계속해서 실행되기 때문에 page fault가 발생한 것이다.

fork

깃북의 요구사항을 해석해봤다.

  1. fork() 시스템 콜 호출할 때 넘겨준 thread_name을 이름으로 하는 스레드를 현재 스레드의 복사본으로 하나 만든다.
  2. 시스템 콜을 호출한 스레드(즉 부모 스레드)의 rbx, rsp, rbp, r12~r15 레지스터를 그대로 복사해서 넘겨줘야 한다.
  3. fork()는 자식 스레드의 pid를 리턴한다. 자식 스레드의 리턴값은 0이다.
  4. 자식의 file descriptor, virtual memory space는 부모의 것이 duplicated된 것이어야 한다. 
  5. thread/mmu.c의 pml4_for_each()으로 페이지 테이블을 포함한 유저 메모리 공간 전체를 복사할 수 있다.
  6. 부모는 자식이 성공적으로 복사되었다는 응답을 받기 전까지 리턴되지 않아야 한다. 자식 스레드가 리소스를 복사하는 데 실패했다면 부모 스레드의 fork() 시스템 콜은 TID_ERROR을 리턴해야 한다.

1~5번은 자식 프로세스를 생성하는 과정이고, 6번은 1~5번과 조금 다른 성격의 작업이다.

먼저 fork() 시스템 콜을 호출한 시점부터 함수 흐름을 쭉 따라가면서 1~5번을 만족하도록 자식 프로세스를 생성해보자!

 

fork() 실행 흐름

 

fork()시스템 콜은 userprog/process.c에 있는 process_fork()함수를 쓰면 된다.

process_fork()에 스레드 이름과 현재 스레드의 intr_frame을 넘겨줬다.

int fork (const char *thread_name){
	check_addr(thread_name);
	return process_fork(thread_name, &thread_current()->tf);
}

process_fork()에서는 thread_create()가 자식 프로세스를 생성한다. thread_create()는 현재 프로세스를 'name'이라는 이름으로 복사하고 새로운 스레드 id를 반환, 새로운 스레드를 생성하지 못 했으면 TID_ERROR을 반환하는 함수이다. 

tid_t
process_fork (const char *name, struct intr_frame *if_ UNUSED) {
	/* Clone current thread to new thread.*/
	return thread_create (name,
			thread_current()->priority, __do_fork, thread_current ());
}

thread_create()로 만들어진 자식 스레드가 처음 cpu를 할당받으면 인자로 전달된 do_fork()가 가장 먼저 실행된다.

do_fork()는 인자로 전달된 부모 프로세스를 복제하는 함수이다. 

  1. 자식 프로세스의 intr_frame상태(cpu 실행 흐름 상태)는 부모와 똑같아야 하기 때문에 memcpy()를 사용하여 부모의 intr_frame을 복사해준다. 그리고 코드상 부모의 실행흐름과 구분하기 위해 rax값(fork()시스템 콜 반환값)을 0으로 바꿔준다.
  2. 자식 프로세스의 fd_table(프로세스가 열어놓은 파일들)도 부모와 똑같아야 하기 때문에 file.c에 있는 file_duplicate()를 사용하여 부모의 fd_table을 자식의 fd_table에 복사한다. fd_idx도 잊지 말고 복사해주자.
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));
	if_->R.rax = 0;

	/* 2. Duplicate PT */
	current->pml4 = pml4_create();
	if (current->pml4 == NULL)
		goto error;

	process_activate (current);
#ifdef VM
	supplemental_page_table_init (&current->spt);
	if (!supplemental_page_table_copy (&current->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.*/

	//부모의 fd_table을 자식에게 복사
	for(int i=0; i<FD_MAX_SIZE; i++){
		struct file* file = parent->fd_table[i];

		if(file == NULL)
			continue;

		if(i <= FD_MIN_IDX){
			current->fd_table[i] = file;
			continue;
		}

		current->fd_table[i] = file_duplicate(file);
	}

	current->fd_idx = parent->fd_idx;

	process_init ();

	/* Finally, switch to the newly created process. */
	if (succ)
		do_iret (&if_);
error:
	thread_exit ();
}

 


* fork()로 자식 프로세스를 생성할 때 부모의 우선순위도 자식에게 복사되어야 한다.

부모 프로세스의 실행 컨텍스트 뿐만아니라 스케줄링 속성까지 복사해야 부모의 실행 상태를 그대로 복제한 형태가 된다고 한다.

궁금해서 CSAPP 책 8.4장을 찾아보니 virtual address space, open file descriptors 얘기만 있긴 한데 암튼 그렇다고 한다.

-> thread_create()할 때 인자로 부모 우선순위를 넘겨주면 된다.


지금까지 자식 프로세스를 복사하는 과정을 구현해봤다!

 

부모 프로세스는 자식 프로세스를 생성하는 do_fork()함수가 완전히 끝날 때까지 잠시 실행 흐름을 중단했다가 자식 프로세스 생성이 끝나면 다시 실행되어야 한다. 

 

 


참고로 sema_down을 언제 해줘야 될지 고민하다가 thread_create() 인자로 전달된 do_fork() 함수가 언제 실행되는 건지 궁금해서 찾아봤다.

부모가 fork()를 호출하면 thread_create()로 자식 스레드를 하나 생성하고 thread_unblock()으로 바로 ready_list에 넣는다. 그리고 나중에 자식 스레드가 스케줄링되면 do_fork()가 실행된다. 

즉 thread_create(..., func, arg)는 새로운 스레드를 만들어서 ready_list에 넣어주면서, 스케줄링되면 바로 func(arg)를 실행하라는 뜻이다.

tid_t
thread_create (const char *name, int priority,
		thread_func *function, void *aux) {
	struct thread *t;
	...
	t->tf.R.rdi = (uint64_t) function;
	...
	/* Add to run queue. */
	thread_unblock (t);

	return tid;
}

exec

wait

create

/**
 * 'file'이라는 이름의 initial_size 크기를 가진 새로운 파일을 생성한다.
 * 새로운 파일 생성을 성공하면 true, 실패하면 false를 반환한다.
 */
bool create (const char *file, unsigned initial_size){
	check_addr(file);
	return filesys_create (file, initial_size);
}

remove

open

  • include/threads/thread.h의 stuct thread 안에 fd table을 생성해준다.
  • struct thread 안에 생성해주는 이유?
    • Pinots에서는 프로세스 = 스레드로 본다. 스레드를 유저 프로그램을 하나 실행하는 독립 실행 단위로 취급하므로 스레드 마다 별개의 fd_table을 줘야 한다.
    • 일반 운영체제에서는 fd table이 프로세스 단위로 존재하고, 모든 스레드는 해당 fd table을 공유한다. 그래서 멀티 스레드 프로그램에서는 fd도 공유 자원이므로 read(), write()할 때 락을 걸거나 dup()으로 복사해서 사용한다고 함
#define FD_MAX_SIZE 128					/* 파일 디스크립터 테이블 사이즈 */
#define FD_MIN_IDX 2					/* 파일 디스크립터 가용 최소 인덱스 */

struct thread {
	...
    struct file *fd_table[FD_MAX_SIZE];	/* file descriptor table : fd 번호를 인덱스로 사용하는 배열 */
    int fd_idx; 						/* 다음으로 사용 가능한 fd 번호 관리 */
}
  1. struct file *fd_table[FD_MAX_SIZE] //구조체 안에 직접 배열 포함
  2. struct file **fd_table //포인터로 선언하고 동적 할당

fd table을 사용하는 두 가지 방식이 있다. 

 

첫 번째 방식을 쓰면 유저 스레드, 커널 스레드 모두 fd_table을 가지게 되는데 사실 커널 스레드는 fd_table이 필요 없는데도 얘를 가지고 있게 되어서 스택이 불필요하게 커질 수 있다. 그리고 사용 전 모든 공간을 NULL로 초기화해줘야 한다.

 

두 번째 방식은 아직 포인터만 있고 공간 할당을 안 해준 상태이므로 fd_table을 쓰기 전에 malloc으로 할당을 해줘야 한다.  실제 유저 프로세스에서만 malloc으로 필요한 만큼만 할당 가능하다는 게 장점이지만, free 작업을 신경써줘야 하며 malloc 할당이 실패할 수도 있다.

  • process_exec() 안에 fd_table을 NULL로 초기화해준다. 
  • NULL로 초기화해주는 이유?
    • struct thread는 palloc_get_page()로 커널 힙에서 동적 할당 되므로, 내부 필드는 쓰레기 주소로 시작할 수 있다.
    • 명시적으로 NULL로 초기화해줘야 open(), write()에서 fd_table[fd] == NULL 체크가 가능하고, 접근해도 오류가 발생하지 않는다.
  • process_exec() 위치에서 초기화해주는 이유?
    • process_exec()는 유저 프로세스의 시작점이다. 실제로 file descriptor가 쓰이기 시작하는 지점이므로 여기서 초기화했다.
    • init_thread() 또는 thread_create()에서 초기화 하면 스레드가 생성되는 시점에 미리 fd_table 공간을 할당하게 되므로, 커널 스택에도 불필요하게 fd_table을 위한 공간이 마련된다.
    • 커널 스택에 fd_table이 불필요한 이유 :
      커널 스레드는 fd_table이 필요 없다. 유저 스레드가 open()또는 write() 시스템 콜을 통해 커널을 진입하여 syscall_handler()가 호출되면 cpu는 커널 코드를 실행하지만, 여전히 그 커널 코드는 유저 스레드 컨텍스트에서 실행되고 있기 때문에 thread_current()로 현재 스레드를 가져오면 시스템 콜을 요청한 유저 스레드가 불러와진다. 즉 유저 스레드가 시스템 콜을 호출했고 커널 모드로 바뀌었다고 해서, '커널 스레드'가 시스템 콜을 실행하는 게 아니다. 커널 모드로 진입한 '유저 스레드'가 시스템 콜을 처리하는 것이다. 그래서 thread_current()->fd_table 이렇게 참조할 수 있다. 커널 스레드는 시스템 콜을 직접 호출하지 않으므로 fd_table이 필요 없다. (헉.. 커널 모드로 전환이 되었다고 커널 스레드가 실행되는 게 아니었다..! 누가 실행되느냐와는 별개로 cpu가 어떤 권한 수준으로 실행되고 있느냐가 커널 모드/유저 모드의 차이다.) 
      • 따라서 동적으로 공간 생성해주려면 process_exec()할 때 초기화해주는 게 좋다.

이쯤에서 참고해주면 좋은... Pintos가 부팅된 후 유저 프로그램을 실행하기까지의 커널 동작 흐름

그럼 process_exec()에서 thread_current() -> fd_table을 초기화 해보자!!

int
process_exec(){
    /* file descriptor table 초기화 */
	struct thread *cur = thread_current();
	for(int i=0; i<FD_MAX_SIZE; i++){
		cur -> fd_table[i] = NULL;
	}
	cur -> fd_idx = FD_MIN_IDX;
}
  • 인자값의 유효성을 체크
  • filesys_open()을 사용하여 파일 이름으로 파일을 연다.
  • 열린 파일을 현재 스레드의 fd table에 추가한다. -> 별도의 함수 add_file()로 구현해줬음! 
    • add_file()은 오픈한 파일을 인자 값으로 받는다.
    • 파일과 fd table의 유효성을 검사하고, fd table의 2번 인덱스부터 차례로 빈 곳(NULL)이 있는지 확인한다.
    • NULL이면 그 인덱스 위치에 엔트리(열린 파일)를 삽입한 후 인덱스를 반환하고, 삽입하지 못 했다면 -1을 반환한다.

들어보니 struct thread 안에 fd_idx를 두고 fd table의 어느 인덱스까지 엔트리를 넣었는지 저장해뒀다가 open()할 때 fd_idx부터 찾기 시작하는 방법을 쓰던데 그렇게 하면 구현이 복잡해지기도 하고, 일단 돌아가는 프로그램을 빠르게 만드는 게 목적이라서 매번 처음 인덱스부터 찾는 방법을 사용했다. 깃 독스에 따르면 fd 0, 1번은 표준 입출력으로 정해져있기 때문에 #define으로 FD_MIN_IDX값을 2로 지정해두고 이 값부터 찾도록 했다. 일반적으로는 0, 1, 2까지 정해져있는데 Pintos는 0, 1까지만 쓴다고 한다. 

/* fd table에 open file을 추가하고 fd를 받아온다.*/
int open (const char *file){
	check_addr(file);
	struct file* open_file = filesys_open(file); 

	if(open_file == NULL) 
		return -1;
	
	int fd = add_file(open_file);

	return fd;
}

int add_file(struct file *file){
	check_addr(file);
	
	struct thread *cur = thread_current();
	check_addr(cur->fd_table);

	for(int i=FD_MIN_IDX; i<FD_MAX_SIZE; i++){
		if(cur->fd_table[i] == NULL){
			cur->fd_table[i] = file;
			return i;
		}
	}
	return -1;
}

filesize

read

write

seek

tell

close

 

 

https://stay-present.tistory.com/98

 

https://e-juhee.tistory.com/entry/Pintos-KAIST-Project-2-%08System-Calls-3-File-System-User-Memory