정글사관학교 개발일지/운영체제-PintOS

Pintos Project 2 - User Program (1) Introduction (정글사관학교 62일차 TIL)

Woonys 2022. 1. 1. 17:28
반응형

이번 과제 목표

사용자 프로그램을 시스템 콜을 통해서 OS와 상호작용할 수 있도록 만들어라!
  • userprog 디렉토리에서 작업할 것

 

Background

프로젝트 1에서 우리가 pintos 안에서 돌린 모든 코드는 OS 커널의 일부이다. 예를 들어, 지난 과제에서의 테스트 코드는 커널의 일부로서 동작한 것이다. 즉, system의 특권 명령에 대한 모든 접근 권한을 가진 상태이다(커널에서 돌렸으니 그럴 수밖에).

 

만약 우리가 사용자 프로그램을 OS 시스템 가장 윗단에서 돌렸다면? 이는 이전에 우리가 테스트를 돌릴 때와는 완전 다른 상황이다. 이번 프로젝트에서는 사용자 프로그램을 OS에서 돌리는 상황에 대해 배운다.

 

OS는 하나 이상의 프로세스를 동작하게 한다. 이때, Pintos에서는 각 프로세스가 하나의 스레드를 갖는다. User program은 각 프로그램이 각자 하나의 컴퓨터를 갖는 듯한 환상을 일으키는데, 이를 만들기 위해 우리는 동시에 여러 프로세스를 메모리에 올리고 실행시킬 수 있어야 한다. 따라서 메모리, 프로세스의 스케쥴링 및 상태들을 관리해줘야 한다.

 

이전까지 프로젝트에서는 테스트 코드를 커널에서 직접적으로 컴파일했다. 이때문에 우리는 구체적인 커널과 함께 하는 함수 인터페이스를 필요로 했다. 이제부터는, 유저프로그램을 돌려서 OS를 테스트할 것인데, 이것이 훨씬 더 자유로운 방식이다. 왜냐하면 커널에 대한 함수 인터페이스를 요구할 필요가 없기 때문. 이때, 유저 프로그램 interface는 여기서 소개할 spec을 만족해야 한다. 하지만 제약 조건을 만족한다면, 원하는 어떤 방식으로든 커널 코드를 재구성하거나 다시 쓰는데 제약이 없다.

 

이번 작업에서 모든 코드는 #ifdef VM으로 감싸진 블록 내에 위치하면 안된다. 이 블록은 가상 메모리 subsystem을 실행한 후에 포함될 것이며 project 3에서 시행된다. 만약 #ifdef VM으로 감싸진 코드가 있다면 이 코드들은 Project 3에서 만질 것이다(이번 플젝에서는 건들지 말라는 말)

 

Source Files

우리가 작업할 디렉토리는 userprog.

 

  • process.c, process.h: ELF binaries를 불러오고 프로세스를 시작함.
ELF binaries: 리눅스에서 실행 가능(Executable)하고 링크 가능(Linkable)한 File의 Format을 ELF(Executable and Linkable Format) 라고 한다. 즉, 실행 가능한 바이너리 또는 오브젝트 파일 등의 형식을 규정한 것이다.

*디테일한 공부는 위 링크로 접속!

 

  • syscall.c, syscall.h: 시스템콜 핸들러에 들어가는 어셈블리 코드. (이 코드를 이해할 필요는 없다)
  • exception.c, exception.h: 유저 프로세스가 특권 명령을 수행할 때 exception 혹은 fault로 커널에서 오류를 띄우는데, 이 파일은 excepion 오류를 다루는 코드.
    • 현재 모든 exception은 메세지를 띄우고 프로세스를 종료시킨다.
    • project 2의 솔루션에서는 page_fault()를 수정할 것이다.
  • gdt.c, gdt.h: x86-64는 segmented architecture. GDT(Global Descriptor Table)은 세그멘트를 기술하는 테이블이며 이 파일들은 GDT를 셋업하는 파일임. 이 프로젝트에서 여기 파일 코드는 건드리지 않으나 GDT가 어떻게 동작하는지 이해하고 싶다면 읽어보면 좋다.
segmentation 기법: 프로그램을 논리적 의미 단위인 세그먼트의 집합으로 구성하는 방식. 나중에 배운다! 
  • tss.c, tss.h: Task-State Segment(TSS)는 x86 아키텍쳐 task를 switching하는데 사용한다. 하지만 x86-64에서 task switching은 안된다. 하지만 TSS는 ring-switching 과정에서 여전히 스택 포인터를 찾기 위해 존재한다. 이 파일 역시 프로젝트에서 수정하면 안된다.

Using the File System

 

이번 프로젝트에서는 파일 시스템 코드에 접근하는 인터페이스가 필요하다. 유저 프로그램은 파일 시스템에서 불러오며, 우리가 실행해야 하는 많은 시스템 콜은 파일 시스템을 다루기 때문이다. 하지만, 이 프로젝트에서 포커스는 파일 시스템에 있지 않음. 따라서 핀토스에서는 간단하지만 완벽한 파일 시스템을 filesys 디렉토리에서 제공해준다.

 

How User Program Work

핀토스에서는 일반적인 C 프로그램을 실행시킬 수 있다. 단, 해당 프로그램 크기가 메모리 사이즈에 알맞게 들어가고 우리가 만든 시스템 콜에 대해서만 동작한다는 전제가 붙는다. 여기서 malloc()은 시행되지 않는다. 프로젝트 2에서 요구하는 어떤 시스템 콜도 메모리 할당을 요구하지 않기 때문이다.

 

핀토스는 부동 소수점 연산을 사용하는 프로그램 역시 실행할 수 없는데, 스레드를 switch할 때(context switch) 커널이 프로세서의 부동 소수점을 저장하고 복구할 수 없기 때문이다. 또한, 우리는 테스트 프로그램을 가상 파일 시스템에 복사하기 전까지는 핀토스가 작업할 수 없다.

 

 

Virtual Memory Layout

핀토스에서 가상메모리는 두 영역으로 분할된다.

  • User Virtual Memory
  • Kernel Virtual Memory

유저 가상 메모리는 가상 주소 범위가 0~KERN_BASE까지다. 이는 include/threads/vaddr.h에 정의되어 있으며, KERN_BASE 디폴트 값은 0x8004000000으로, 커널 가상 메모리가 여기서부터 시작해 나머지 가상 주소 영역을 점유한다. User virtual memory는 프로세스마다 있다. 커널이 어떤 프로세스로부터 다른 프로세스로 스위치시킬 때, user 가상 주소 영역 역시 스위치하는데 프로세서의 페이지 디렉토리 베이즈 레지스터를 바꿈으로써 스위칭한다.

 

커널 가상 메모리는 전역(global)에 해당한다. 어떤 user 프로세스 혹은 커널 스레드가 동작하건 간에 항상 동일한 방식으로 매핑된다.

 

핀토스에서 커널 가상 메모리는 물리 메모리와 일대일 대응으로 매핑되는데, 이는 KERN_BASE에서 시작한다. 즉, 가상 주소 KERN_BASE 가 물리 메모리 0 에 접근하고, 가상주소 KERN_BASE + 0x1234 가 물리주소 0x1234 에 접근한다.

 

사용자 프로그램은 오직 사용자 가상 메모리에만 접근 가능하다. 만약 사용자 프로그램이 커널 가상 메모리에 접근하려 시도하면 page fault를 일으키는데, 이는 userprog/exception.c 에 있는 page_fault() 에서 다룬다. 그리고 프로세스는 종료된다.

 

반면, 커널 스레드는 커널 가상 메모리와 (만약 사용자 프로세스가 동작 중이면) 동작 중인 프로세스의 사용자 가상 메모리에도 접근 가능하다. 하지만 커널 안에서조차도, 매핑되지 않은 사용자 가상 주소에 접근하려 시도하면 page fault를 일으킨다.

 

 


Argument Passing

 

사용자 프로그램에서 인자를 셋업하도록 process_exec()를 수정하라

 

 

x86-64 calling convention(함수 호출 규약)

이 섹션에서는 64-bit x86-64에서 일반적으로 함수를 호출할 때 사용하는 convention의 중요한 포인트를 요약한다.

 

잠깐, calling convention에 대한 Gitbook의 설명을 적기 전에 먼저 calling convention 자체에 대해 알아보자. 나중에 현업에서 협업할 때 해당 플젝에서 함께 하는 개발자 간에 하나의 규약을 설정하는데, 이를 coding convention이라고 한다. 읽고 관리하기 쉬운 코드를 작성하기 위한 일종의 코딩 스타일 규약이라고 하는데, 예전에 들었던 구글 스타일 가이드도 이것과 같은 맥락이라고 보면 될듯. 즉, 컨벤션이란 어떤 상황에 대해 어떤 식으로 진행할 것인가에 대한 규약을 뜻한다고 보면 되겠다.

 

같은 맥락에서 calling convention은 함수에서 호출자(caller - 다른 함수를 호출하는 함수)와 피호출자(callee - caller에 의해 호출당하는 함수. 예컨대 main() 자체는 callee이지 caller가 아니며 단 main()안에서 불리는 함수와 main() 사이에서는 main()이 caller, 불리우는 함수가 callee)가 있을텐데, 얘네 사이에 인자를 어떤 식으로 전달할 것인가에 대한 규약을 정의한 것을 의미한다. 예컨대 파라미터, 반환값(return) 등이 저수준 레벨에서 레지스터, 스택, 메모리 등에서 어디에 위치하게 할 것이며 등을 정의한 약속이라고 보면 되겠다. (참고 자료 링크1)(참고 자료 링크2)

 

이 calling convention에서 정의하는 것 중 하나가 argument passing과 stack frames이다(참고자료 2에 잘 나와있음). 어떻게 함수 인자를 전달하고 반환값을 받아오냐를 관리하는데, x86-64 리눅스에서는 6개의 함수 인자가 레지스터 %rdi, %rsi, %rdx, %rcx, %r8, %r9로 전달된다. 만약 7개 혹은 그 이상의 인자가 들어오면 스택으로 들어간다. 그리고 반환값(return value)는 레지스터 %rax로 전달된다.

 

이때 레지스터에 어떤 식으로 들어가는지 예시를 한 번 보자.

 

  1. 만약 구조체 인자가 하나의 word size(64 bits/8 bytes)보다 작다면 하나의 단일 레지스터 안에 들어간다.
    • struct small { char a1, a2; } - char는 각각 1바이트 크기이니 둘이 합쳐도 2 바이트 < 8 바이트이기에 각각 하나의 레지스터에 들어간다.
  2. 구조체가 2 ~ 4 word size에 들어가면 마치 여러 인자인 것처럼 연속된 레지스터에 들어간다.
    • struct medium { long a1, a2; } - long type 크기는 4바이트.  구조체 내 인자 크기만 8바이트인데, (정확히 모르겠으나) 이게 8바이트보다 커서인지 16-32바이트 내에 들어가는 사이즈라 하나의 구조체가 쪼개져서 연속된 레지스터에 들어가는듯..? (이건 좀 더 알아봐야겠다)
  3. 32바이트 이상 크기면 항상 스택에 저장된다.
    • struct large { long a, b, c, d, e, f, g; }

 

아래 예시 프로그램을 하나 보자. small 구조체를 선언한 다음, 구조체 내 멤버끼리 + 2 를 더하는 함수 f를 정의하고 있다.

 

struct small { char a1, a2; };
int f(small s) {
    return s.a1 + 2 * s.a2;
}

 

위를 컴파일하면 어셈블리어에는 이렇게 들어간다. %edi에 전체 인자가 들어가고 , %eax에 구조체 내 두 인자 a1, a2가 각각 들어간다. 

movl %edi, %eax           # copy argument to %eax
movsbl %dil, %edi         # %edi := sign-extension of lowest byte of argument (s.a1)
movsbl %ah, %eax          # %eax := sign-extension of 2nd byte of argument (s.a2)
movsbl %al, %eax
leal (%rdi,%rax,2), %eax  # %eax := %edi + 2 * %eax
ret

 

어셈블리어는 이정도만 하고(여기에 대해 더 자세히 파보려면 이 링크 참조), 다시 Gitbook으로 돌아오자.

 

1. x86-64 calling convention에서는 user-level application에서 인자 전달을 위해 위와 같은 정수 레지스터를 사용한다.

2. caller는 다음 instruction(return address)를 stack에 넣어주고 callee의 첫 인스트럭션으로 점프한다.

3. 이어서 callee가 실행된다.

4. callee가 반환값을 갖는 경우, %rax 레지스터에 넣는다.

5. callee는 return address를 stack에서 pop하면서 그 위치로 점프한다. (x86-64 인스트럭션에서는 RET가 이를 해준다.)

6. f()가 3개의 정수형 인자를 받는 경우 callee가 보는 stack frame의 예시

 

프로그램 시작 전 디테일

 

사용자 프로그램 진입점은 /lib/user/entry.c 의 _start()에서부터이다. 이 함수는 main()을 실행하고 main이 return 하면 exit()을 호출한다.

@/lib/user/entry.c

void
_start (int argc, char *argv[]) {
	exit (main (argc, argv));
}

커널은 유저 프로그램 실행을 허용하기 전에 초기 함수에 대한 인자를 레지스터에 넣어줘야 한다. 이때 일반적인 calling convention과 동일한 방식으로 인자가 전달된다.

 

/bin/ls -l foo bar 가 전달될 때 예시

 

  1. 커맨드를 단어로 분리한다 => /bin/ls, -l,  foo, bar
  2. 단어들을 스택 최상단에 넣어준다. 이때 포인터로 참조되기 때문에 순서는 상과없다.
  3. 각 문자열의 주소와 null pointer sentinel을 스택에서 오른쪽->왼쪽 순서로 넣는다.
    1. 얘네들(/bin/ls, -l,  foo, bar)이 argv의 요소들이다.
    2. null pointer를 넣음으로써 argv[argc]가 null pointer가 된다. 이는 C 표준 요구사항이다.
    3. word로 정렬되어 있는 경우가 더 빠르기 때문에 스택 포인터를 처음 넣기 전에 stack 포인터를 8의 배수가 되도록 조정해준다.
  4. %rsi 가 argv, %rdi가 argc를 가리키도록 한다.
  5. 마지막으로 가짜 return address를 넣어준다. 엔트리 함수는 반환되지 않으나 다른 스택 프레임과 동일한 구조를 만들어주기 위해 넣는다.

아래는 유저 프로그램이 실행되기 직전의 stack 상태와 관련된 레지스터를 보여준다.

 

 

반응형