Pintos 두 번째 프로젝트는 User Program이다. 이전까지 Pintos에서의 모든 코드는 OS kernel의 일부였다. 이 말인 즉 system의 중요한 자원에 접근할 때 모든 privilege를 가진 채 프로그램이 실행되었다는 의미이다. 그러나 OS 위에서 유저 프로그램을 실행시킬 경우 이러한 privilege를 갖지 못한다. 운영체제는 기본적으로 운영체제가 아닌 프로그램은 신뢰하지 않고, 따라서 privilege를 주지 않는다.
https://en.wikipedia.org/wiki/Protection_ring
이제부터는 Ring 3 영역에서 유저 프로그램이 실행될 것이다. 이 때 유저 프로그램은 자신의 User virtual memory에만 접근할 수 있다. 만약 유저 프로그램이 Kernel virtual memory에 접근을 시도하면, page fault를 야기하고 프로세스가 종료된다.
USER_STACK +----------------------------------+
| user stack |
| | |
| | |
| V |
| grows downward |
| |
| |
| |
| |
| grows upward |
| ^ |
| | |
| | |
+----------------------------------+
| uninitialized data segment (BSS) |
+----------------------------------+
| initialized data segment |
+----------------------------------+
| code segment |
0x400000 +----------------------------------+
| |
| |
| |
| |
| |
0 +----------------------------------+
유저 가상 메모리는 위 그림과 같은 레이아웃을 가진다.
유저 프로그램이 실행되던 도중 interrupt가 발생하는 순간 CPU는 커널 모드로 바꾸고 운영체제는 발생한 interrupt를 handling 한다.
즉, interrupt가 발생하면 유저 모드에서 커널 모드로 변환이 일어난다고 할 수 있다.
운영체제의 구성 상, 커널과 Application은 CPU의 권한 수준이나 하드웨어 접근 능력이 다르다. 유저 프로그램이 주어진 instruction들을 수행하다가, Direct I/O와 같이 privilege가 필요한(즉, OS 커널만 수행할 수 있는) 작업이 필요한 경우, 커널 모드로의 전환이 필요하다.
이 때 유저 모드 -> 커널 모드로 변하게 만드는 다른 한 가지 방법이 바로 시스템 콜(system call)이다.
시스템 콜(System call)
👉 운영체제의 커널이 제공하는 서비스에 대해, Application의 요청에 따라 커널에 접근하기 위한 Interface
유저 프로그램은 시스템 콜을 호출하여 커널 모드로 전환하여 필요한 작업들을 수행하고, 다시 유저 모드로 돌아간다. 이 때 사용되는 low-level instruction이 syscall
이라는 instruction 이다. syscall
instruction을 사용하기 위해서는 적절한 시스템 콜 number가 CPU 레지스터에 로드되어야 한다. pintos에서 유저프로그램은 시스템 콜 번호를 %rax
에, 1~6번째 인자는 각각 %rdi
, %rsi
, ...%r10
에 저장한 후, 시스템 콜을 만들기 위해 syscall을 호출한다.
시스템 콜 핸들러 syscall_handler()
가 제어권을 얻으면 시스템 콜 번호는 rax
에 있고, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 전달
이 때 커널 스택에 있는 interrupt frame이 전달되므로 시스템 콜 핸들러를 호출한 프로세스의 레지스터에 접근할 수 있다. 시스템 콜 핸들링을 마치고 리턴되는 값은 rax
에 저장된다.
__attribute__((always_inline))
static __inline int64_t syscall (uint64_t num_, uint64_t a1_, uint64_t a2_,
uint64_t a3_, uint64_t a4_, uint64_t a5_, uint64_t a6_) {
int64_t ret;
register uint64_t *num asm ("rax") = (uint64_t *) num_; // system call number
register uint64_t *a1 asm ("rdi") = (uint64_t *) a1_;
register uint64_t *a2 asm ("rsi") = (uint64_t *) a2_;
register uint64_t *a3 asm ("rdx") = (uint64_t *) a3_;
register uint64_t *a4 asm ("r10") = (uint64_t *) a4_;
register uint64_t *a5 asm ("r8") = (uint64_t *) a5_;
register uint64_t *a6 asm ("r9") = (uint64_t *) a6_;
__asm __volatile(
"mov %1, %%rax\n"
"mov %2, %%rdi\n"
"mov %3, %%rsi\n"
"mov %4, %%rdx\n"
"mov %5, %%r10\n"
"mov %6, %%r8\n"
"mov %7, %%r9\n"
"syscall\n"
: "=a" (ret)
: "g" (num), "g" (a1), "g" (a2), "g" (a3), "g" (a4), "g" (a5), "g" (a6)
: "cc", "memory");
return ret;
}
유저 프로그램이 올바르게 실행되기 위해서는, User stack을 Setting하고, 실행할 프로그램의 argument들을 User stack에 적절히 넣어주어야 한다.
예를 들어 /bin/ls -l foo bar
와 같은 명령이 주어졌을 때, 아래와 같이 stack에 푸쉬해야 한다. 이를 위해서
1) 주어진 명령을/bin/ls
, l
, foo
, bar
과 같이 실행 파일과 argument들로 분리한다.
2) parsing된 arg들을 스택의 맨 처음 부분에 넣는다. 이 arg들은 char pointer(char *)로 참조할 것이다.
3) 각 문자열의 주소 + 경계조건을 위한 널포인터를 스택에 오른쪽→왼쪽 순서로 푸시한다.
이 null pointer는 argv[argc]
가 null pointer라는 사실을 보장해준다. 또한 이 순서는 argc-> argc-1 -> argc-2, ... 순으로 내려가면서 argv[0]
이 가장 낮은 가상 주소를 가진다는 사실을 보장해준다.
word-align을 수행하는 이유는 word-size로 정렬된 접근의 속도가 더 빠르기 때문이다. 따라서 스택 포인터를 word-size(8의 배수)로 반올림하기 위해 rsp가 8의 배수를 가질 때 까지 null pointer를 삽입한다.
4) %rsi
가 argv
주소(argv[0]
의 주소)를 가리키게 하고, %rdi
를 argc
로 설정한다.
5) 마지막으로 fake “return address”를 푸시한다. 이는 해당 모든 스택 프레임이 동일한 구조를 갖도록 하기 위함이다.
위 과정을 통해 전달 받은 /bin/ls -l foo bar
명령이 아래와 같이 User stack에 쌓는다.
Address | Name | Data | Type |
---|---|---|---|
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 (*) () |
void push_args(char **argv, int argc, struct intr_frame *if_){
char *arg_address[128];
for (int i=argc-1; i>=0; i--){
size_t arg_size = strlen(argv[i]) + 1; // include sentinel (\0)
if_->rsp -= arg_size; // 인자 크기만큼 스택을 늘려줌
memcpy(if_->rsp, argv[i], arg_size);
arg_address[i] = if_->rsp; // arg_address에 인자를 복사해준 주소값을 저장
}
while((uintptr_t)if_->rsp % 8 != 0){
if_->rsp--;
*(uint8_t *)if_->rsp = 0;
}
for (int i=argc; i>=0; i--){
if_->rsp -= sizeof(char *);
if (i == argc){
memset(if_->rsp, 0, sizeof(char *));
}
else{
memcpy(if_->rsp, &arg_address[i], sizeof(char *));
}
}
if_->rsp -= sizeof(void *);
memset(if_->rsp, 0, sizeof(void (*)));
if_->R.rdi = argc;
if_->R.rsi = if_->rsp + sizeof(void *);
}
Pintos Project 2에서 시스템 콜 기능을 구현하고, 유저 가상 주소 공간에 데이터를 읽고 쓰는 과정이 필요하다. 이 과정에서 만약 User mode에서 실행하던 프로그램이 유효하지 않은 메모리 영역(커널 영역 또는 할당받지 않은 영역)을 가리킬 경우 이를 처리해주어야 한다.
이것을 Page fault라고 하는데, Page fault에 대한 handling은 Project 3 : Virtual Memory에서 다루기 때문에 현재로서는 page fault handler에서 exit(-1)을 해줌으로써, 유저 프로그램이 유효하지 않은 주소에 접근하면 프로세스를 종료시키도록 한다.
Pintos Project 2 : User program의 핵심이다. 시스템 콜의 기반이 되는 구조와 기능들을 구현한다. 리눅스에서 사용하는 시스템 콜은 300개가 넘는데, pintos에서는 핵심 시스템 콜 함수들을 구현한다.
void
syscall_handler (struct intr_frame *f UNUSED) {
/* Arguments: %rdi %rsi %rdx %r10 %r8 %r9 */
switch(f->R.rax){
case SYS_HALT:
{
halt();
}
case SYS_EXIT:
{
int status = f->R.rdi;
exit(status);
}
case SYS_FORK:
{
f->R.rax = fork(f);
break;
}
case SYS_EXEC:
{
const char *file = f->R.rdi;
f->R.rax = exec(file);
break;
}
case SYS_WAIT:
{
pid_t pid = f->R.rdi;
f->R.rax = wait(pid);
break;
}
case SYS_CREATE:
{
const char *file = f->R.rdi;
unsigned int initial_size = f->R.rsi;
f->R.rax = create(file, initial_size);
break;
}
case SYS_REMOVE:
{
const char *file = f->R.rdi;
f->R.rax = remove(file);
break;
}
case SYS_OPEN:
{
const char *file = f->R.rdi;
int fd = open(file);
f->R.rax = fd;
break;
}
case SYS_FILESIZE:
{
int fd = f->R.rdi;
int file_size = filesize(fd);
f->R.rax = file_size;
break;
}
case SYS_READ:
{
int fd = f->R.rdi;
void *buffer = f->R.rsi;
unsigned int size = f->R.rdx;
f->R.rax = read(fd, buffer, size);
break;
}
case SYS_WRITE:
{
int fd = f->R.rdi;
void *buffer = f->R.rsi;
unsigned int size = f->R.rdx;
f->R.rax = write(fd, buffer, size);
break;
}
case SYS_SEEK:
{
int fd = f->R.rdi;
unsigned position = f->R.rsi;
seek(fd, position);
break;
}
case SYS_TELL:
{
int fd = f->R.rdi;
unsigned result = tell(fd);
f->R.rax = result;
break;
}
case SYS_CLOSE:
{
int fd = f->R.rdi;
close(fd);
break;
}
default:
{
thread_exit();
}
}
}
모든 시스템 콜 함수를 구현하는데 성공했다. File-related 시스템 콜들은 file을 다루는 기존 함수들이 존재해서 file descriptor table을 만든 뒤 fd를 할당 하고 예외 처리를 해주면 모든 테스트 케이스들이 통과했다.
다만 Project 2에서는 예외처리가 촘촘히 되었는지 확인하기 어렵다. 그 이유는 유저가 유효하지 않은 주소(할당받지 않았거나, 유저 영역이 아닌 곳)에 접근했을 때 page_fault handler에서 exit(-1)을 수행하도록 했기 때문에, 예외 상황에서 대부분 exit(-1)이 되면 pass가 출력되기 때문이다.
또한 공유 자원을 사용하기 위해 임계 영역(critical section)에 들어 왔을 때, file을 위한 lock을 만들어 사용 했지만, load 함수가 호출 될 때는 global lock을 사용하지 않았음에도 모든 테스트 케이스들이 통과했다. 물론 테스트 케이스를 통과하는 것이 전부가 아니지만, 일단은 여기까지 하고 Project 3,4에 넘어갔을 때 문제가 생기면 그 때 해결하도록 할 것이다.