크래프톤 정글 8-9주차
Project 2는 사용자 프로그램에 대한 구현으로 사용자가 이용할 시스템콜에 대한 구현이 중점이다.
Argument Passing
터미널에서 프로그램을 실행할 때, 프로그램에서 사용할 변수를 함께 입력하는 경우가 있다.
우리가 기본 코딩 스켈레톤에서 자주 볼 수 있는 main(argc, argv[])를 보면,
두 매개변수 중 argv[]가 프로그램 시작과 함께 입력받은 인자 배열이다.
이 인자로 들어온 문자들을 프로세스마다 갖고 있는 USERSTACK에 직접 Push하는 작업을 구현해야 한다.
프로세스가 사용하는 스택은 두 종류가 있다. 유저스택과 커널스택, 두 스택 모두 각 프로세스마다 고유한 가상 메모리 구역을 받는다.
USER가 사용하는 스택은 User Mode에서 호출하는 함수들에 대해,
KERNEL이 사용하는 스택은 Kernel Mode에서 호출하는 함수들에 대한 정보를 저장한다.
다른 점
서로 다른 두 프로세스에서 User 스택은 특정 가상 주소(va)가 같더라도 다른 물리 주소로 매핑될 수 도 있지만, Kernel 스택은 가상 주소가 같다면 동일한 물리 주소로 매핑된다.
(출처: 유저 스택과 커널 스택)
스택에 인자를 push하기 위해 먼저 해야할 일은
1. 하나의 문자열로 들어온 인자들을 word 단위로 passing하기 -> strtok()를 이용하면 간단하다.
2. 문자열 배열 등 원하는 자료구조에 문자들을 담아 stack_push() 호출하기
stack_push()
void stack_push(struct intr_frame *if_, char **argv, int argc)
process.c에서 작성해야 하는 stack_push()는 위치를 자유로이 해도 된다.
우리는 load()의 TODO 자리에 작성했지만, 다른 정글러 중에 process_excu()에서 호출한 팀도 있다.
rsp 포인터를 알기 위해 intr_frame을 가져왔으며, 미리 passing한 문자열 argv와 인자의 수 argc를 매개변수로 받아온다.
rsp 포인터를 문자열 크기나 포인터 크기만큼 아래로 옮기며 해당 자리에 데이터를 입력(복사)하는 작업을 수행하면 된다.
아래 표는 스택의 예시이다. 가장 위 인덱스가 스택의 가장 아래에 있는 데이터로 아래 방향으로 자라는 스택의 모습을 표로 표현했다. GitBook 에서 확인할 수 있다.
0x4747fffc | argv[3][...] | 'bar\0' | char[4] |
0x4747fff8 | argv[2][...] | 'foo\0' | char[4] |
0x4747fff5 | argv[1][...] | '-l\0' | char[3] |
0x4747ffed | argv[0][...] | '/bin/ls\0' | char[8] |
0x4747ffe8 | word-align | 0 | uint8_t[] |
0x4747ffe0 | argv[4] | 0 | char * |
0x4747ffd8 | argv[3] | 0x4747fffc | char * |
0x4747ffd0 | argv[2] | 0x4747fff8 | char * |
0x4747ffc8 | argv[1] | 0x4747fff5 | char * |
0x4747ffc0 | argv[0] | 0x4747ffed | char * |
0x4747ffb8 | return address | 0 | void (*) () |
문자열의 가장 뒤 요소부터 스택에 push한다. 문자열을 모두 push한 다음, 각 인자가 push된 주소를 스택에 저장한다.
! 문자열의 주소를 저장하는 이유
문자열은 고정된 크기로 저장되지 않고, 각 word의 들쭉날쭉한 크기로 push된다.
그래서 인자에 접근할 때, OS는 각 word가 어디 주소에서부터 시작하는 지 알 수가 없다.
예를 들어 위 표의 argv[2]에 접근하고 싶을 때, 미리 저장한 주소가 없다면 argv[2][0]이 0x4747fff5에서 시작하는지, 0x4747fff7에서 시작하는지 알 수 있는 방법이 없다.
이런 상황을 방지하기 위해 각 word의 시작 주소를 저장해 접근을 용이하게 한다.
주소에 데이터를 넣는 과정이 가장 어려웠다. 방법 자체가 어려운 것은 아닌데 주소에 무언가를 복사해 넣는 것이 처음이라 어떤 방법을 사용해야 할지 몰랐었다.
주소에 어떤 데이터를 복사해 넣을 때는 memcpy()를 사용하면 된다. 앞으로도 계속 사용하게 될 함수다.
스택에 값이 제대로 들어갔는 지 확인할 때는 hex_dump()라는 함수를 이용한다. 아직 다른 부분이 구현되지 않았기 때문에 스택의 내용만 출력해보는 방법이다. 아래와 같이 사용하면 된다.
hex_dump(if_->rsp, if_->rsp, USER_STACK - if_->rsp, true);
주의할 점
테스트 케이스를 실행해 hex_dump를 찍어볼 때, 'pintos -v -k ~'가 아니라 args-none.result 형식의 파일을 사용했다. 이 형식으로 실행하면 터미널에는 아무 정보도 출력되지 않고, output 파일에서만 출력을 확인할 수 있다. TIMEOUT이 아닐 수 있으니 output 파일을 확인해보시길...
VS Code에서 구현했는데 .result 형식을 사용하면 ctrl+C 커맨드 사용이 더 자유롭고, 테스트 결과가 이상할 때 터미널 오류가 발생하는 확률도 적으니 .result 사용을 추천한다.
↓ stack_push()
포인터에 대한 이해가 부족하면 이해가 조금 어려울 수 있다.
포인터를 잘 이해하고 있다고 생각했으나 uintptr_t 같은 자료형에 대한 이해가 부족했고, 포인터에 대한 이해도 아직 부족함을 많이 느꼈던 함수다.
void stack_push(struct intr_frame *if_, char **argv, int argc)
{
char *addr[128];
for (int i = argc - 1; i >= 0; i--) // 뒤에서 부터 스택에 저장
{
int size = strlen(argv[i]); // + 1;
if_->rsp = if_->rsp - (size + 1);
memcpy(if_->rsp, argv[i], (size + 1));
addr[i] = if_->rsp;
}
while (if_->rsp % 8 != 0)
{
if_->rsp--;
*(uint8_t *)if_->rsp = 0;
}
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, &addr[i], sizeof(char *));
}
if_->R.rdi = argc;
if_->R.rsi = if_->rsp;
if_->rsp = if_->rsp - 8;
memset(if_->rsp, 0, sizeof(void *));
// hex_dump(if_->rsp, if_->rsp, USER_STACK - if_->rsp, true);
}
System Call
시작하기 전...
Project 1을 구현하며 sync.c 에서 사용했던 thread_yield()에서 문제가 생겼다.
정해진 조건에서만 thread_yield()를 호출하도록 새 함수를 만들어 해결했다.
↓ thread_try_yield()
void thread_try_yield(void)
{
if (list_empty(&ready_list) || thread_current() == idle_thread)
return;
struct thread *priority = list_entry(list_front(&ready_list), struct thread, elem);
if (thread_current()->priority < priority->priority)
thread_yield();
}
File System
파일 입출력과 관련된 시스템콜을 먼저 구현하기를 추천한다.
특히 write()를 먼저 구현해야 hex_dump()가 아닌 제대로 된 테스트 결과를 확인할 수 있다.
파일 시스템은 예외처리 지옥이다...
파일 시스템을 구현하기 위해서 file descriptor를 저장할 수 있게 배열 등을 만들어야 한다.
우리는 정적 배열 -> 동적 배열 순으로 구현했고, 이외에 구조체, 동적 할당 등을 이용해 만든 팀도 있었다.
먼저 정적 배열은 추천하지 않는다. 깃북에 적절한 정적 배열의 개수(128개)를 안내하고 있지만, 프로젝트가 진행되면서 thread 구조체에 담아야 할 정보가 계속 늘어난다. 정적 배열을 사용하면 필연적으로 스레드 스택 오버플로우를 접하게 될 것이다.
open()
파일 작업을 시작하기 위해 파일을 열고, 파일 디스크립터를 부여하는 작업을 해야 한다.
더블 포인터로 선언한 fd_table을 가져와 STDIN, STDOUT을 제외한 2번부터 fd를 부여해준다.
int open(const char *file)
{
check_addr(file);
struct thread *t = thread_current();
struct file **table = t->fd_table;
struct file *open_file = NULL;
int fd = 2;
lock_acquire(&file_lock);
open_file = filesys_open(file);
if (open_file == NULL)
{
lock_release(&file_lock);
return -1;
}
while (fd < 128)
{
if (table[fd] == NULL)
{
break;
}
fd++;
}
if (fd >= 128)
{
lock_release(&file_lock);
return -1;
}
table[fd] = open_file;
lock_release(&file_lock);
return fd;
}
read()
read와 write는 동기화를 위한 lock을 사용하는 것을 추천한다.
open()에서 부여했던 fd를 이용해 fd_table에 접근한다. STDIN일 경우와 아닐 경우를 나누어 구현해야 한다.
int read(int fd, void *buffer, unsigned size)
{
struct thread *t = thread_current();
struct file **table = t->fd_table;
struct file *open_file;
int ret = 0;
char *ptr = (char *)buffer;
check_addr(buffer);
if (fd > 128 || fd < 0)
return -1;
if (table[fd] == NULL)
return -1;
if (fd == 1)
return 0;
lock_acquire(&file_lock);
if (fd == 0)
{
for (int i = 0; i < size; i++)
{
*ptr++ = input_getc();
ret++;
}
lock_release(&file_lock);
}
else
{
open_file = table[fd];
if (open_file == NULL)
{
lock_release(&file_lock);
return -1;
}
ret = file_read(open_file, buffer, size);
lock_release(&file_lock);
}
return ret;
}
STDIN의 경우, 위 코드보다 간단하게 return input_getc()만 해도 통과하는데 문제없다.
write()
STDOUT에 대한 처리를 따로 해줘야 한다. read()와 마찬가지로 filesys의 함수를 잘 이용하면 된다.
int write(int fd, const void *buffer, unsigned size)
{
struct thread *t = thread_current();
struct file **table = t->fd_table;
struct file *write_file;
int write_size = 0;
check_addr(buffer);
if (fd < 0 || fd > 128)
{
return -1;
}
if (fd == 0)
return 0;
if (fd == 1) // STD_OUT
{
putbuf(buffer, size);
write_size = size;
}
else
{
if (table[fd] != NULL)
{
write_file = table[fd];
lock_acquire(&file_lock);
write_size = file_write(write_file, buffer, size);
lock_release(&file_lock);
}
}
return write_size;
}
close()
파일을 닫아주는 함수다. 파일 디스크립터 테이블에 접근해 close할 예정인 fd 인덱스로 가서 초기화를 해준다.
void close(int fd)
{
struct thread *t = thread_current();
struct file **table = t->fd_table;
struct file *open_file;
if (fd == 1 || fd == 0 || fd > 128)
exit(-1);
if (table[fd] != NULL)
{
open_file = table[fd];
table[fd] = NULL;
file_close(open_file);
}
}
Process
process_fork()
우리는 가장 먼저 fork()를 구현했다. 이때 wait()이 구현되기 전으로 부모 프로세스가 fork후 자식을 기다리지 않고, 종료해버리는 상황이 생긴다. 따라서 wait()을 구현하기 전에는 process_wait()에 무한루프에 가깝게 반복문을 만들어두어야 테스크케이스 통과를 받을 수 있다.
process_fork()를 구현할 때 인터럽트 프레임이 필요하다. 자식이 부모의 인터럽트 프레임을 그대로 물려받기 때문이다.
그런데 do_fork()를 호출하기 전의 thread_current()->intr_frame은 rsp가 가리키는 곳이 User 스택이 아니다. 시스템 콜이 호출되면서 rsp는 커널 스택 속 어딘가를 가리키고, 우리가 원하는 스택의 주소는 알 수 가 없다.
그래서 syscall에서 process_fork()로 넘어갈 때, 레지스터->system call로 넘겨진 intr_frame을 그대로 넘겨준다. 이 인터럽트 frame의 rsp가 우리가 원하던 User 스택을 가리키는 rsp다.
pid_t fork(const char *thread_name, struct intr_frame *if_)
{
pid_t pid = 0;
pid = process_fork(thread_name, if_); // 자식 프로세스의 pid를 반환
return pid;
}
process_fork()는 자식 프로세스를 생성하고, 자식이 모든 복사를 완료할 때까지 기다려야 한다.
|| (부모) process_fork -> (자식) do_fork -> (자식) duplicate_pte -> (자식) do_fork 나머지 || 순서대로 함수가 진행되며, 부모는 자식의 fork가 끝나면 tid를 반환하며 함수를 나가고, 자식 프로세스는 do_fork를 끝내며 rsp에 저장된 return address로 이동해 부모가 fork() 시스템 콜을 호출했던 자리로 돌아가 명령어를 실행한다.
tid_t process_fork(const char *name, struct intr_frame *if_) // UNUSED)
{
/* Clone current thread to new thread.*/
struct thread *curr = thread_current();
memcpy(&curr->user_if, if_, sizeof(struct intr_frame));
tid_t tid = thread_create(name, PRI_DEFAULT, __do_fork, curr);
if (tid == TID_ERROR)
return TID_ERROR;
struct thread *child = get_child(tid);
if (child == NULL)
return TID_ERROR;
sema_down(&child->fork_wait);
return tid;
}
get_child()는 tid를 이용해 프로세스의 자식 리스트에서 하나의 자식을 찾아 반환하는 함수다.
do_fork 등의 함수는 여기서 확인하시길
process_wait()
무한루프만 만들어 두었던 wait()을 제대로 구현할 때가 됐다.
process_wait()은 부모 프로세스가 자식의 종료를 기다리는 장소다. semaphore를 이용해서 waiting 할 수 있도록 한다. process.c에서는 비슷한 이유로 총 3개의 semaphore를 사용한다.
부모 프로세스는 자식의 종료를 기다리고, 자식의 종료 신호를 받으면 자식에게 종료 가능하다는 신호를 보낸다. 이후 자식의 종료 상태를 반환한다.
int process_wait(tid_t child_tid) // UNUSED)
{
struct thread *status = get_child(child_tid);
if (status == NULL)
return -1;
sema_down(&status->exit_wait);
list_remove(&status->ch_elem);
sema_up(&status->load_wait);
return status->exit_status;
}
process_exit()
프로세스가 종료될 때 마지막으로 호출하는 함수로 이곳에서 자식 프로세스는 부모 프로세스에게 종료하겠다는 신호를 보낸다.
void process_exit(void)
{
struct thread *curr = thread_current();
process_cleanup();
sema_up(&curr->exit_wait);
sema_down(&curr->load_wait);
}
process_exec()
프로세스가 f_name을 가진 프로그램을 실행하기 위해 호출하는 함수. argument passing 할 때 많이 본 함수다.
거의 모든 기능이 구현되어 있기 때문에 로직을 고치지는 않는다.
여기에서 사용한 sema는 위의 exit, wait에서 사용하는 sema를 쓰고, 용도도 비슷하다.
메인 스레드는 메인 프로그램을 실행할 스레드를 생성하는데, 이 스레드가 프로그램을 load하기 전에 종료해버리는 경우가 있다. 이를 방지하기 위해 자식 스레드의 load를 기다리도록 sema를 사용했다.
int process_exec(void *f_name)
{
char *file_name = f_name;
bool success;
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. */
palloc_free_page(file_name);
if (!success)
return -1;
sema_up(&main_thread->load_wait);
/* Start switched process. */
do_iret(&_if);
NOT_REACHED();
}
주저리
fork도 그렇고 구현은 참 오래걸렸는데 정리하니 양이 그렇게 많지는 않다.
오래걸린 이유는 아무래도 일부만 해결되는 코드를 계속 디벨롭하는 방식으로 구현했기 때문이라고 생각한다. 당연히 한 번에 완벽하게 구현하기는 힘들지만, 아키텍쳐를 확실하게 한 번 그려놓고 구현을 한다면 더 도움이 될 것 같다.
syscall을 구현하면서 fork에 대해서는 제대로 이해할 수 있었다고 생각한다. PintOs 시스템 자체의 흐름에 대해서도 많이 이해했다.
Project 3를 하면서도 2에서 이해한 내용이 도움이 되었다. VM 정리는 언제하지
'PintOs' 카테고리의 다른 글
[PintOs] Project 3 : Virtual Memory (0) | 2024.04.11 |
---|---|
[PintOs] PROJECT1: THREADS 크래프톤정글 7주차 (0) | 2024.03.31 |