Pintos Project 2-1) UserProg- System Call

Jisu·2023년 5월 8일
0

PintOS

목록 보기
2/6
post-thumbnail

PintOS Project 2_ User Program: 시스템 콜 구현

시스템 콜, 예외적 제어흐름에 대한 글입니다.

 

  • Pintos 프로젝트2 소감
  • 예외적 제어 흐름(ECF)
    • Low level  / Higher level
      • Interrupt
      • Trap - System call
    • Exceptions handler
  • System Call
    • User ↔ Kernel mode 
  • Fork
    • Duplicate
    • Context switching
  • Execve
    • process_exec() 구현 및 Flow chart
  • Exit
    • exit_status
    • semaphore

프로젝트 소감

  • 유저 프로그램 시스템 콜 ‘핸들러’ 작성
    • 우리가 만든 건 시스템 콜 그 자체라기 보단 사실상 '핸들러'.
    • ‘exit()’과 같은 시스템 콜을 호출하면 기본적으로 lib/user/syscall.c 안의 syscall 호출
      • 이는 우리가 작성한 시스템 콜 핸들러(userprog/syscall.c)로 점프하고, 커널모드에서 시스템콜을 처리할 수 있도록 해줌.
      • 기존에 구현되어 있는 시스템콜은 특별한 기능이 없으나, 우리가 직접 작성한 시스템콜로 라우팅시켜 필요한 기능을 수행하게 하겠다는 것.

 

  • 핀토스는 User Program - Kernel간 상호작용할 수 있는 여러 장치를 만들어두었음.
    • 기본적으로 Stack, file descriptor Table은 프로세스마다 독립적으로 할당(per-process)
    • 동시에 커널은 System wide 장치가 있음. 시스템적으로 메모리가 어떻게 관리되고 있는지, 어떤 파일이 열려있는지 트래킹하기 위함
      • 커널 가상주소
      • Open file descripter Table
  • 시스템 콜은 대부분 이러한 System wide *주소를 통해 구현하게 되어있으며
    • ptov (physical to virtual address) 등 페이지 테이블 복제시 계속해서 등장하는 함수가 리턴하는 건 User VA(virtual address)가 아닌 Kernel VA. 
    • filesys_open()과 같이 커널단에서 실제 file path & inode를 리턴하는 함수는 핀토스 내부에 구현되어 있어 중간다리만 놓아주면 되며, 유저 프로그램단 file descriptor Table인덱스 및 file object만 업데이트 해주면 됨. 
    • 일은 커널에서 하고, 우리는 유저 프로그램 환경을 셋팅한다고 보면 됨

 

  • 가장 까다로웠던 부분은 fork()의 Context Switching
    • 운영체제로 문맥전환을 할 때 Trap을 발생시키는데, 이 때 하드웨어(CPU)와 운영체제간 교류가 있음. 
    • fork의 경우 자식 프로세스로 context를 복제하기 위해 CPU 레지스터 값을 push, pop 하는 과정 필요.
  • 시스템 콜은 어셈블리어를 모르면 결코 이해했다고 할 수 없다.
    - syscall_handler : 시스템 콜을 미리 정의된 핸들러로 보내주는 명령어
    - intr_entry : intr_frame 생성 명령어
    - iret() : 인터럽트 처리 후 반환할 때 사용하는 CPU 명령어
    - schedule(): 프로세스간 문맥전환 → thread_yield()
  • 시스템콜은 커널로 문맥전환 시 하드웨어(레지스터)를 이용한다. 훨씬 빠르기 때문이다.

 


예외적 제어 흐름 (Exceptional Control Flow)

  • Altering the Contrl Flow
    • CPU Control flow를 바꿀 수 있는 두 가지 메커니즘
      1. Jumps
      2. Call and return
    • CPU 프로그램 카운터(%rip) 실행명령줄을 바꾸며, 리턴주소를 스택에 저장해두고 돌아옴
  • 예외적 제어 흐름은 왜 필요한가?
    • 시스템 상태 변화에 대응할 방법이 필요
      • 디스크나 네트워크로부터 데이터가 전송되었을 때
      • devide by zero
      • 사용자가 Ctrl C와 같은 키보드 인터럽트를 발생시켰을 때
      • 시스템 타이머 expires
  • 예외적 제어흐름은 컴퓨터 시스템 all level에 걸쳐 존재한다.
    • 1. Low level 메커니즘
    • Exceptions
      • 주로 하드웨어 인터럽트. 비의도적으로 발생
    • 2. Higher level 메커니즘
      • 프로세스 문맥 전환 (OS + HW timer)
        • 프로세스 종료 또는 스케줄링
      • 시그널 (OS)

 

Expceptions

  • 특정 이벤트에 의해 제어권이 OS 커널로 이동 

  • Exception 종류
    • Asynchronous : 디바이스에 의한 I/O 인터럽트(비의도적)
    • Synchronous 
      • 1. Traps
        • 시스템 콜이 가장 대표적. 의도적인 요청
        • next instruction으로 제어권을 돌려줌
      • 2. Faults
        • Page fault 등. recoverable. 
      • 3. Aborts
        • unrecoverable

 


System Call

  • 시스템 콜의 핵심은 ‘유저모드 → 커널모드’ 전환
    • Basically, user code interacts with the kernel via syscalls; invoking a syscall is a request to the kernel for a service. (시스템콜은 커널을 깨워서 커널로 진입하게 해줌)
    • System Call은 사용자 공간과 커널 공간 사이의 접점. 상호작용 매개체라 볼 수.
      • 핀토스도 lib 안에 kernel, userprog가 분리되어 컴파일
    • Open(2번 시스템콜)을 예로 들면, syscall 어셈블리 명령어로 커널모드로 이동, %rax로 리턴값 반환

     

  • syscall 명령어를 실행하면 user mode 권한에서 커널의 권한으로 권한 상승이 이루어짐과 동시에 
    • 현재 작동 중이던 테스트 프로그램은 일시중지되고 
    • syscall_handler가 실행되면서 우리가 작성한 커널 코드가 실행됨

Signal vs Interrupt vs System call

  • Interrupts can be viewed as a mean of communication between the CPU and the OS kernel
  • Signals can be viewed as a mean of communication between the OS kernel and OS processes
  • 시스템콜, 시그널은 상호 반대 방향. 
    • 시스템콜은 커널에 서비스를 요청하는 것이며(엄격히 정의된 목적과 API에 따라)
    • Signal은 커널이 유저 프로세스에 이벤트가 발생했음을 알리는 용도(generic)

Fork(): 복제 및 문맥전환

/* Clones the current process as `name`. Returns the new process's thread id, or TID_ERROR if the thread cannot be created. */
tid_t
process_fork (const char *name, struct intr_frame *if_ UNUSED) {
	/* Clone current thread to new thread.*/
	/* ------------------------ Project 2 --------------------*/
	struct thread *curr = thread_current();
	memcpy(&curr->parent_if, if_, sizeof(struct intr_frame));
	tid_t tid = thread_create(name, curr->priority, __do_fork, curr);
	if (tid == TID_ERROR) {
		return TID_ERROR;
	}
	struct thread *child = get_child(tid);	
	sema_down(&child->fork_sema);
	if (child->exit_status == -1) {
		return TID_ERROR;
	}
	return tid;
}

static void
__do_fork (void *aux) {
	struct intr_frame if_;
	struct thread *parent = (struct thread *) aux;
	struct thread *current = thread_current ();
	printf("jisu: child's tid is %d\n", current->tid);
	/* TODO: somehow pass the parent_if. (i.e. process_fork()'s if_) */
	struct intr_frame *parent_if;
	bool succ = true;
	parent_if = &parent->parent_if;
	/* 1. Read the cpu context to local stack. */
	memcpy (&if_, parent_if, sizeof (struct intr_frame));
	if_.R.rax = 0; 			/* child's return value = 0 */
	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
	if (parent->fd_idx == FDCOUNT_LIMIT)
		goto error;
	current->fd_table[0] = parent->fd_table[0];
	current->fd_table[1] = parent->fd_table[1];
	for (int i=2; i<FDCOUNT_LIMIT; i++){
		struct file *f = parent->fd_table[i];
		if (f == NULL) {
			continue;
		}
		current->fd_table[i] = file_duplicate(f);
	}
	
	current->fd_idx = parent->fd_idx;
	/* if child load success, wake up parent in process_fork */
	sema_up(&current->fork_sema);
	/* Finally, switch to the newly created process. */
	if (succ)
		do_iret (&if_);
error:
	current->exit_status = TID_ERROR;
	sema_up(&current->fork_sema);
	exit(TID_ERROR);
	// thread_exit ();
}

  • 포크를 할 때 자식 프로세스는 부모의 커널스택이 아니라 부모의 유저스택을 복사
    • 따라서 인터럽트에 의해 자동으로 커널스택으로 push된 intr_frame을 parent_if에 넣어주고, 
    • 그걸 자식 프로세스의 유저스택에 복사
    • 커널단 &if_에도 복사해 do_iret을 호출해 실제 child process 유저 프로그램으로 전환
  • process_fork의 *if_ 파라미터가 Unused인 이유는, 유저 스페이스에서 직접 인용하는 게 아니라 인터럽트 핸들러에서 do_fork()로 전달되기 때문

 

  • (참고) intr_handler
    • intr_frame 구조체에 명시된 인터럽트 벡터 넘버에 알맞는 interrupt handler를 부르는 함수
    • interrupt는 시스템콜에 대응하는 단어. intr_handler는 syscall_handler를 부르고,
      • 만약 시스템콜이 SYS_FORK이면 syscall_handler는 process_fork()를 부름
      • 이 때, 현재 프로세스의 이름과 a pointer to the intr_frame structure를 인자로 넘겨줌
  • iret 
    - 인터럽트에서 반환, 유저프로그램으로 돌아오는 데 사용되는 프로세서 명령어

    유저 → 커널

    • 인터럽트 발생 → 인터럽트된 프로그램 상태 저장 → 인터럽트 루틴 실행(처리)
      • intr_entry에 의해 컨텍스트를 intr_frame에 저장 
      • intr_handler()를 호출해 실제 인터럽트 수행

    커널 → 유저

    • iret() 호출시 저장된 상태 복원, 중단된 프로그램 재개
  • intr_frame : 인터럽트 발생 시 intr_entry가 생성. 
    • intr_entry push the ‘struct intr_frame’ then jump here. We save the rest of the `struct intr_frame' members to the stack

 


Execve(): 새로운 프로그램 실행

/* Switch the current execution context to the f_name.
 * Returns -1 on fail. 
 */
int
process_exec (void *f_name) {
	char *file_name = f_name;	// void -> char
	bool success;
	int argc = 0;
	char *argv[128];
	char *token, *save_ptr;
	token = strtok_r(file_name, " ", &save_ptr);
	while (token) {
		argv[argc++] = token;
		token = strtok_r(NULL, " ", &save_ptr);
	}
	/* We cannot use the intr_frame in the thread structure.
	 * This is because when current thread rescheduled,
	 * it stores the execution information to the member. */
	/* 인터럽트 프레임 구조체 선언 */
	struct intr_frame _if;
	_if.ds = _if.es = _if.ss = SEL_UDSEG;
	_if.cs = SEL_UCSEG;
	_if.eflags = FLAG_IF | FLAG_MBS;
	/* We first kill the current context */
	process_cleanup ();
	/* And then load the binary */
	success = load (file_name, &_if);
	/* If load failed, quit. */
	if (!success) {
		palloc_free_page (file_name);
		return -1;
	}
	/* set up (User's) stack */
	argument_stack(argc, argv, &_if);
	
	// hex_dump(_if.rsp, _if.rsp, USER_STACK-_if.rsp, true);
	/* Start switched process. */
	do_iret (&_if);
	NOT_REACHED ();
}
  • 핀토스 메인 실행파일 로직은 다음과 같다
    • 유저 프로그램의 경우 부모 프로세스가 자식 프로세스 종료를 wait하는 구조.
    • 부모 프로세스가 먼저 종료되는 일이 없도록 Semaphore를 활용.
    • 세마포어는 두 가지 활용
      • wait\_sema: 자식 프로세스 종료를 기다림
      • free\_sema: 부모가 exit_status 회수할 때까지 기다림
  • process\_cleanup()
    • execve 시스템 콜은 load()가 핵심.
      • 유저 커맨드라인으로 부터 파일명을 추출해 load에 file name으로 넣음
      • 기존 메모리 영역을 깨끗하게 cleanup 한 후 사용해야 함
      • 마찬가지로 종료할 때도 cleanup.

 

main() 함수 플로우차트

init.c 실행 로직을 다음과 같이 그려보았다.
자식 스레드를 생성하면서 process_exec()를 시키는데,
가장 중요한 부분인 load()가 커널단에서 작업하고 파일 로딩, 페이지 할당 등 모든 셋팅이 끝나고 나면 iret을 통해 실제 유저 프로그램으로 전환된다.
즉, 자식 스레드는 파일을 실행하기 위해 시스템콜을 호출해서 커널모드로 전환 후 다시 돌아와야 한다.


Fork 와 execve 차이

  • fork : 같은 프로그램을 child가 복제해서 실 (두 개의 프로세스가 같은 프로그램)
  • execve : 한 프로세스내 새로운 프로그램을 실행
    - execve는 기존 주소공간을 덮어써버림
    - process_cleanup() 후 다시 셋팅. 현재 프로세스의 유저스택 뿐만 아니라 페이지 디렉토리도 비움
    - load 함수에서 페이지 디렉토리를 새롭게 생성하고 활성화 process_activate()
  • 그러나 PID, open files는 그대로임.
    - 새롭게 실행하는 프로그램은 해당 프로세스가 돌리고 있는 여러 open file 중 하나이므로, fd_index가 + 1 늘어날 뿐.
    - 다만 execve는 새로운 프로세스에서 파일을 실행하는 경우도 많으며, 핀토스에서 구현한 것도 새로운 스레드를 생성하여 process_exec()를 호출하였음.

 


Argument Passing

void argument_stack (int argc, char **argv, struct intr_frame *if_)
{
	char *arg_address[128];
	/* Insert argument value */
	for (int i = argc-1; i>=0; i--){	// right to left, (n-1)~0 
		int argv_len = strlen(argv[i]);
		if_->rsp = if_->rsp - (argv_len + 1);	
		memcpy(if_->rsp, argv[i], argv_len + 1);	
		arg_address[i] = if_->rsp;
	}
	/* Insert padding for word-alignment 8 byte (64bit) */
	while (if_->rsp % 8 != 0){
		if_->rsp--;	
		*(uint8_t *)(if_->rsp) = 0; 
	}
	/* Insert addresses of strings including word-padding */
	for (int i = argc; i>=0; i--){
		if_->rsp = if_->rsp -8;		
		if (i == argc){		
			memset(if_->rsp, 0, sizeof(char **));
		}
		else
			memcpy(if_->rsp, &arg_address[i], sizeof(char **));
	}
	/* Fake return address */
	// it's just newly created thread process, so it doesn't have return address. just put zero.
	if_->rsp = if_->rsp - 8;
	memset(if_->rsp, 0, sizeof(void *));
	/* 레지스터 값 설정 */
	/* main으로 가는 세 개의 인자
	1) argc
	2) argv[] 배열 첫 항목으로의 포인터 (**argv)
	3) envp[] 전역변수 배열 첫 항목에 대한 포인터
	*/
	if_->R.rdi = argc;			
	if_->R.rsi = if_->rsp + 8;	
}

argument_stack() 의 역할 

  • Set up stack (유저 스택 초기화)
  1. argv[i] 인자 값 저장
  2. argv[i] 주소 저장
  3. fake 리턴주소
  4. main(argc, **argv) 메인함수 인자로 R.rdi, R.rsi 레지스터 값 설정
  • 새로운 파일 실행 시 유저스택은 다음과 같은 모습을 띄어야 함.
  • 주소를 같이 저장하는 이유: 프로세스마다 페이지 테이블의 물리주소가 다르기 때문에, 인자 주소를 같이 전달해야 올바르게 찾아갈 수 있음.

    

 


Load()

  • 주요 구현 파트
static bool
load (const char *file_name, struct intr_frame *if_) {
	struct thread *t = thread_current ();
	struct ELF ehdr;
	struct file *file = NULL;
	off_t file_ofs;
	bool success = false;
	int i;
	/* Allocate and activate page directory. */
	t->pml4 = pml4_create ();
	process_activate (thread_current ());
	/* Open executable file. */
	file = filesys_open (file_name);
        /* 스레드의 파일 구조체 포인터 running이 *file을 가리키게 함 */
	t->running = file;			
	file_deny_write(file);
	/* Read and verify executable header. */
	if (file_read (file, &ehdr, sizeof ehdr) != sizeof ehdr
        ... 
	}
	/* Read program headers. */
	file_ofs = ehdr.e_phoff;
	for (i = 0; i < ehdr.e_phnum; i++) {
		struct Phdr phdr;
        /* Sets the current position in FILE to NEW_POS bytes from the start of the file. */
		file_seek (file, file_ofs);
		file_read (file, &phdr, sizeof phdr)
		file_ofs += sizeof phdr;
        ... 
	/* Set up stack. */
	if (!setup_stack (if_))
		goto done;
	/* Start address. */
	if_->rip = ehdr.e_entry;
	success = true;
done:
	/* We arrive here whether the load is successful or not. */
	file_close (file);
	return success;
}

load() 주요 로직

  1. t->pml4 = pml4_create: 페이지 디렉토리 생성
  2. process_activate: 페이지 테이블 활성화
  3. filesys_open: 프로그램 파일 Open
  4. file_deny_write(file): 해당 파일에 쓰기 금지 설정
  5. file_read: ELF 파일 헤더를 읽음
  6. file_seek: setting the file position to the beginning of each program header. 알맞은 위치에서 데이터를 읽어오기 위해 현재 파일을 가리키는 포인터를 ‘프로그램 헤더’ 위치로 옮김.
  7. setup_stack (if_) :
    -intr_frame을 활용해 rsp를 유저스택의 유효 주소공간으로 이동시키고,
    -page allocation이 성공하면 page table entry(PTE)를 스택포인터(rsp)에 매핑한다. → rip 실행명령줄을 이동시키며 전환하는 것과도 같음.
  • 보다시피, load는 정말 많은 일을 수행한다.
    • 새로운 메모리 공간을 할당하고
    • 파일을 하드디스크에서 메모리로 적재하는 것부터
    • 실행파일 헤더를 읽고
    • 코드 실행명령줄(rip)를 실행할 파일 엔트리 포인터로 설정하는 것까지(setup_stack) 
    • 말그대로 커널 영역에서 수행하는 일들이다. 
      • 프로젝트 3부터는 페이지 테이블 생성에 있어 User vs Kernel pool을 알맞게 활용하는 부분이 중요하게 다뤄질 예정이다. 

 


Exit()

  • exit() 시스템콜 구현은 메모리 관리 차원에서 매우 중요하다.
    • 프로세스를 새로 생성(fork)하는 것 못지않게 종료시 제대로 회수하는 것이 중요하다.
      • 이를테면 한 서버가 수백만개 스레드를 생성한다고 생각해보자.
        • 자식이 종료하기 전에 부모가 종료되거나(orphan child)
        • 자식이 종료되었으나 부모가 명시적으로 회수하지 않았을 때(zoombie child)
      • 상당한 메모리 누수를 수반할 수 있다. 
  • 따라서 fork, execve, exit과 같이 생성/종료와 관련된 시스템 콜에는
    • 세마포어가 자주 등장함을 볼 수 있다.
      • wait_sema: 자식이 종료할 때까지 기다림으로써 asynchronous한 작업을 보장
      • fork_sema: 자식이 부모의 맥락을 완전히 복제할 때까지 기다림
      • free_sema: 자식이 완전히 죽기 전 부모가 exit_status를 받을 때까지 잠시 기다려줌.
    • 부모가 exit_status를 받기도 전에 자식이 스스로를 완전히 cleanup 해버리면 부모는 영원히 잠에서 깨어나지 못할 수도 있다.
      • 부모는 자식이 정상적으로 종료되었는지 상태를 회수해야 하며,
      • 자식이 process_cleanup() or do_schedule(THREAD_DYING)로 완전히 terminate 하기 전에 sema_down(&curr->free_sema)로 잠시 락을 걸어둔다. 
        - 프로세스 종료는 process_cleanup
        - 스레드 종료는 t->status를 THREAD_DYING 전환

 

Exit() 구현 로직

/* Exit the process. This function is called by thread_exit (). */
void
process_exit (void) {
	struct thread *curr = thread_current ();
	/* 열려있는 모든 파일 종료 */
	for (int i=0; i<FDCOUNT_LIMIT; i++){
		close(i)            // file_close -> allow_write()
	}
	/* Destroy the current process's page directory */
	palloc_free_multiple(curr->fd_table, FDT_PAGES);   // fd_table 해제
	
	/* Current executalbe file 종료  */
	file_close(curr->running);
	/* Wake up blocked parent */
	sema_up(&curr->wait_sema);
	/* Postpone child termination until parents receive exit status with 'wait' */
	sema_down(&curr->free_sema);
	/* Switch to Kernel-only page directory */
	process_cleanup ();
}

/* close() system call ---------------------------*/
void
file_close (struct file *file) {
	if (file != NULL) {
		file_allow_write (file);
		inode_close (file->inode);
		free (file);
	}
}
void close(int fd) {
	struct file *fileobj = find_file_by_fd(fd);
	if (fileobj == NULL)
		return;
	remove_file_from_fdt(fd);
}
void remove_file_from_fdt(int fd) {
	struct thread *cur = thread_current();
	if (fd < 0 || fd >= FDCOUNT_LIMIT)
		return;
	cur->fd_table[fd] = NULL;
}
  1. close : 열려있는 모든 파일 종료. close 안에는 file_allow_write()로 쓰기 권한을 풀어줌
  2. palloc_free_multiple : 페이지 테이블 해제
  3. file_close(curr->running) : ‘현재’ 파일 종료
  4. sema_up(wait_sema) : 부모 프로세스를 Unblock
  5. sema_down(free_sema) : 부모가 exit_status 반환받을 때까지 기다림
  6. 부모가 exit_status를 받고 sema_up(free_sema) 를 하면
  7. process_cleanup :Destroy the current process's page directory and switch back to the kernel-only page directory

0개의 댓글

관련 채용 정보