Home pintos project-2-userprogram guide translated
Post
Cancel

pintos project-2-userprogram guide translated

공식 gitbook 문서를 어느 정도 번역하고 있다.

https://casys-kaist.github.io/pintos-kaist/project2/system_call.html

프로젝트 2: User program

이제 pintos에 대해 충분히 코드를 작성하였고, pintos의 구조와 스레드에 익숙해졌을 것이다. 이제, pintos 시스템에서 user-program을 실행할 수 있는 부분에 대해서 작업할 차례이다.

기본적인 코드는 이미 user program을 로딩하고, 실행할 수 있으나, I/O와 같은 상호작용은 불가능하다. 이제, 이 프로젝트에서는 프로그램이 system call을 통해 OS와 상호작용 (interactive) 할 수 있도록 만들 것이다.

이 부분의 과제를 위해 거의 대부분은 ‘userprog’ 디렉터리에서 작업하게 될 것이지만, pintos의 거의 모든 다른 부분과도 함께 작업하게 될 것이다. 아래에서 관련 부분을 설명할 것이다.

프로젝트 2 (Userprog)를 이미 프로젝트 1(thread) 과제를 모두 끝내고 시작할 수도 있으나, 프로젝트 1을 모두 수행하지 않고 프로젝트 2부터 진행해도 큰 문제는 없다. 이 과제에는 프로젝트 1의 코드가 필요하지 않다. “Alram Clock” 기능은 프로젝트 3과 4에서 유용할 수 있지만 반드시 필요한 것은 아니다.

테스트 코드를 실행하는 방법을 다시 읽어보는 것이 유용할 수 있다. (1.2.1절.[테스트], 5페이지 참조).

Backgrounds

지금까지 핀토스에서 실행한 모든 코드는 운영 체제 (커널)의 일부였다. 다시 말하자면, 프로젝트1 과제의 모든 테스트 코드는 커널의 일부로 실행되었고, 이는 곧 시스템에 최고 권한을 가지고 모든 부분에 접근한 것이다. 하지만, 운영 체제 위에서 user program을 실행하기 시작하면, 이러한 사실은 틀린 것이 된다. 이 프로젝트는 이러한 상황에서 진행하게 된다.

pintos는 한 번에 하나 이상의 프로세스를 실행할 수 있다. 각 프로세스에는 하나의 스레드만이 존재한다. (멀티스레드 프로세스는 pintos에서 지원하지 않는다.). user program은 사용자의 프로그램이 마치 컴퓨터 전체를 소유한 것처럼 느끼게 하는 ‘환각’(illusion) 속에서 실행된다. 즉, 한 번에 여러 프로세스를 로드하고 실행할 때 이 ‘환각’을 유지하려면 메모리, 스케줄링 및 기타 상태를 올바르게 관리해야 한다.

이전 프로젝트에서는 테스트 코드를 커널 안에서 직접 컴파일하여, 커널의 기능을 직접 구현하는 일이 필요했다. 이제부터는 user program을 실행하여 운영 체제를 테스트한다. 이렇게 하면 훨씬 더 자유롭게 테스트가 가능하다. 당신은 당신의 user program 인터페이스가 여기에 설명된 요구 사항을 유지한다는 가정 하에서, 커널 코드를 원하는 대로 자유롭게 재구성하거나 재작성할 수 있다.

소스 파일

수행하게 될 프로그래밍에 대한 개요를 파악하는 가장 쉬운 방법으로는 작업해야 할 각 부분으로 이동하여 코드를 살펴보는 것이다.userprog/ 디렉토리에는 비록 소수의 파일만이 존재할 뿐이나, 대부분의 작업은 여기에 있다.

syscall.c syscall.h

사용자 프로세스가 커널 기능에 접근하고자 할 때 시스템 콜을 사용한다. 위의 소스 코드는 시스템 콜 핸들러의 기본 틀이다. 현재 상황에서 이 시스템 콜 핸들러는 메시지를 출력하고 사용자 프로세스를 종료시킨다. 이 프로젝트 2에서는 시스템 콜에 필요한 모든 코드를 추가하게 된다.

exception.c exception.h

사용자 프로세스가 특권이 필요하거나 금지된 작업을 수행할 경우, “execption” 또는 “fault”으로 커널에 trap 신호를 보내게 된다. 이 소스 코드들은 예외처리를 위한 코드이다. 현재 모든 exception의 경우는 간단히 메시지를 출력하고 프로세스를 종료하게다. 프로젝트 2의 과제의 일부에서는 이 파일의 page_fault() 함수를 수정하는 작업이 포함될 수 있다.

gdt.c gdt.h

intel 80x86은 segemented 아키텍처이다. 글로벌 디스크럽터 테이블(GDT)은 사용 중인 세그먼트에 대해 기술하는 테이블이다. 이 파일들은 GDT를 설정한다. 프로젝트에서 이 파일들을 수정할 필요는 없다. 다만, GDT 작동 방식에 관심이 있다면 코드를 읽어보는 것을 권한다.

tss.c tss.h

Task-State Segement (TSS)는 intel 80x86 아키텍처에서 task switching에 사용된다. Pintos는 user process가 인터럽트 핸들러로 진입할 때 linux의 방식처럼 스택을 스위칭하는 용도로 TSS를 사용한다. 이 파일들을 프로젝트에서 수정할 필요는 없다. 그러나, TSS 작동 방식에 관심이 있다면 코드를 읽어보는 것을 권한다.

파일 시스템 사용하기

이 프로젝트에서는 파일 시스템 코드을 사용해야 한다. 사용자 프로그램은 파일 시스템에서 로드되며, 구현해야 할 많은 시스템 콜들이 파일 시스템과 관련되어 있기 때문이다. 그러나 이 프로젝트의 중심은 파일 시스템이 아니므로, 간단하면서도 완전한 파일 시스템을 ‘filesys’ 디렉토리에서 제공한다. 파일 시스템의 사용 방법과 특히 그 한계를 이해하기 위해서는 ‘filesys.h’ 및 ‘file.h’ 인터페이스를 살펴보는 것이 좋다.

이 프로젝트에서 파일 시스템 코드를 수정할 필요는 없으며, 수정하지 않는 것을 권한다. 파일 시스템 작업은 이 프로젝트의 중점에서 벗어나게 할 가능성이 높다.

현재 파일 시스템 루틴을 올바르게 사용하면, 프로젝트 4에서 파일 시스템 구현을 개선할 때 훨씬 수월해질 것이다. 하지만, 그 전까지는 다음과 같은 제약사항을 감수해야 한다:

  • 내부 동기화가 없다. (No internal Synchronization)
    • 동시적 접근 (Concurrent access)은 서로 방해가 될 것이다. 파일 시스템 코드를 실행하는 동안 한 프로세스만 실행되도록 동기화(Synchronization)를 사용해야 한다.
  • 파일 크기는 생성 시에 고정된다. 루트 디렉토리는 파일로 표현되므로 생성 가능한 파일 수도 제한된다.
  • 파일 데이터는 단일 연속 영역으로 할당된다. 즉, 단일 파일의 데이터는 디스크의 연속된 섹터 범위를 차지해야 한다. 따라서 외부 단편화가 시간이 지남에 따라 심각한 문제가 될 수 있다.
  • 서브디렉토리는 없다.
  • 파일 이름은 14자로 제한된다.
  • 작업 중 시스템 충돌이 발생하면 디스크가 자동으로 복구될 수 없는 방식으로 손상될 수 있다. 어쨌든 파일 시스템 수리 도구는 없다.

한가지 중요한 기능 포함

  • filesys_remove() 는 Unix-like 스타일으로 구현되어 있다. 즉, 파일이 제거될 때 열려 있으면, 그 블록은 할당 해제되지 않으며, 마지막으로 닫힐 때까지 해당 파일을 열고 있는 모든 스레드에 의해 여전히 접근될 수 있다. 자세한 내용은 [오픈 파일 제거하기], 페이지 35를 참조하라.

시뮬레이티드 디스크 생성

pintos-mkdisk 프로그램을 사용하여 파일 시스템 파티션을 갖는 시뮬레이티드 디스크를 생성할 수 있다. ‘userprog/build’ 디렉토리에서 pintos-mkdisk filesys.dsk --filesys-size=2 명령을 실행하여 2 MB Pintos 파일 시스템 파티션을 포함하는 ‘filesys.dsk’라는 시뮬레이티드 디스크를 생성한다. 그 다음, 커널의 커맨드 라인에 ‘-f -q’를 전달하여 파일 시스템 파티션을 포맷한다: pintos -f -q ‘-f’ 옵션은 파일 시스템을 포맷하게 하고, ‘-q’ 옵션은 포맷이 완료되자마자 Pintos가 종료되도록 한다.

파일 복사 방법

시뮬레이티드 파일 시스템에 파일을 넣고 빼는 방법이 필요하다. Pintos의 ‘-p’(“put”) 및 ‘-g’(“get”) 옵션이 이를 수행한다. Pintos 파일 시스템에 ‘file’을 복사하려면, ‘pintos -p file – -q’ 명령을 사용한다. (‘–’는 ‘-p’가 pintos 스크립트용이기 때문에 필요하다.) 파일을 ‘newname’이라는 이름으로 Pintos 파일 시스템에 복사하려면, ‘-a newname’: ‘pintos -p file -a newname – -q’를 추가한다. VM에서 파일을 복사하는 명령은 비슷하지만, ‘-p’ 대신 ‘-g’를 사용한다.

특별한 명령어를 커널의 명령 줄에 전달하고, 특별한 시뮬레이션된 “스크래치” 파티션에 데이터를 복사하는 방식으로 이 명령들은 작동한다. 더 많은 구현 세부사항을 알고 싶다면, pintos 스크립트와 ‘filesys/fsutil.c’를 살펴볼 수 있다.

파일 시스템 파티션이 있는 디스크를 생성하고, 파일 시스템을 포맷하고, 새 디스크에 echo 프로그램을 복사한 다음, x 인자를 전달하여 echo 를 실행하는 방법을 요약하면 다음과 같다. (인자 전달은 구현되기 전까지 작동하지 않는다.) 이미 ‘examples’에서 예제들을 빌드했고 현재 디렉토리가 ‘userprog/build’라고 가정한다:

  • pintos-mkdisk filesys.dsk –filesys-size=2
  • pintos -f -q
  • pintos -p ../../examples/echo -a echo – -q
  • pintos -q run ’echo x’

마지막 세 단계는 실제로 하나의 명령으로 결합될 수 있다:

  • pintos-mkdisk filesys.dsk –filesys-size=2
  • pintos -p ../../examples/echo -a echo – -f -q run ’echo x’

파일 시스템 디스크를 나중에 사용하거나 검사를 위해 보관하고 싶지 않다면, 모든 네 단계를 하나의 명령으로 결합할 수 있다. –filesys-size=n 옵션은 Pintos 실행 기간 동안만 약 n 메가바이트 크기의 임시 파일 시스템 파티션을 생성한다. Pintos 자동 테스트 suite는 이 문법을 광범위하게 사용한다:

  • pintos –filesys-size=2 -p ../../examples/echo -a echo – -f -q run ’echo x’

Pintos 파일 시스템에서 파일을 삭제하려면, 예를 들어 pintos -q rm file 커널 동작을 사용할 수 있다. 또한, ls는 파일 시스템의 파일 목록을 보여주고 cat file은 파일의 내용을 디스플레이에 출력한다.

user program의 작동 방식

Pintos는 메모리에 맞고 구현된 시스템 콜만 사용하는 일반 C 프로그램을 실행할 수 있다. 주목할 점은, 이 프로젝트에 필요한 시스템 콜 중 어느한 것도 동적 메모리 할당을 허용하지 않기 때문에 malloc()을 구현할 수 없다. 또한, 커널이 스레드 전환 시 프로세서의 부동 소수점 유닛을 저장하고 복원하지 않기 때문에 부동 소수점 연산을 사용하는 프로그램도 실행할 수 없다.

‘src/examples’ 디렉토리에는 몇 가지 샘플 사용자 프로그램이 포함되어 있다. 이 디렉토리의 ‘Makefile’은 제공된 예제를 컴파일하며, 사용자 자신의 프로그램을 컴파일하기 위해 수정할 수도 있다. 일부 예제 프로그램은 프로젝트 3 또는 4가 구현된 후에만 작동한다.

Pintos는 ‘userprog/process.c’에 제공된 로더를 사용하여 ELF 실행 파일을 로드할 수 있다. ELF는 리눅스, 솔라리스 및 기타 많은 운영 체제에서 오브젝트 파일, 공유 라이브러리 및 실행 파일에 사용되는 파일 포맷이다. 실제로 80x86 ELF 실행 파일을 출력하는 어떤 컴파일러와 링커도 Pintos용 프로그램을 생성하는 데 사용할 수 있다. (이 과제에 필요한 적절한 컴파일러와 링커를 제공하고 있다.)

시뮬레이티드 파일 시스템에 테스트 프로그램을 복사하기 전까지는 Pintos가 유용한 작업을 수행할 수 없음을 알아야 한다. 다양한 프로그램을 파일 시스템에 복사할 때까지 흥미로운 작업을 수행할 수 없다. 디버깅하는 동안 ‘filesys.dsk’를 유용한 상태를 넘어서 버려버릴 수도 있으므로, 깨끗한 참조 파일 시스템 디스크를 만들어 두고 필요할 때마다 복사하는 것이 좋다.

가상 메모리 레이아웃

Pintos에서 가상 메모리는 사용자 가상 메모리와 커널 가상 메모리 두 영역으로 나뉜다. 사용자 가상 메모리는 가상 주소 0부터 PHYS_BASE까지이며, 이는 ‘threads/vaddr.h’에서 정의되어 있고 기본값은 0xc0000000(3 GB)이다. 커널 가상 메모리는 PHYS_BASE부터 4 GB까지의 나머지 가상 주소 공간을 차지한다.

사용자 가상 메모리는 Per-process이다. 커널이 한 프로세스에서 다른 프로세스로 전환할 때, 프로세서의 페이지 디렉토리 베이스 레지스터(page directory base register)를 변경함으로써 user virtual address space도 전환한다(‘userprog/pagedir.c’의 pagedir_activate() 참조). struct thread는 프로세스의 페이지 테이블을 가리키는 포인터를 포함한다.

커널 가상 메모리는 전역적 (global) 이다. 어떤 사용자 프로세스나 커널 스레드가 실행 중이든 항상 동일한 방식으로 매핑된다. Pintos에서 커널 가상 메모리는 PHYS_BASE에서 시작하여 물리 메모리와 일대일로 매핑된다. 즉, 가상 주소 PHYS_BASE는 물리 주소 0에 접근하고, 가상 주소 PHYS_BASE + 0x1234는 물리 주소 0x1234에 접근하는 식이다.

사용자 프로그램은 자신의 사용자 가상 메모리만 접근할 수 있다. 커널 가상 메모리에 접근하려는 시도는 페이지 폴트를 일으키며, ‘userprog/exception.c’의 page_fault()에 의해 처리되고 프로세스는 종료된다. 커널 스레드는 커널 가상 메모리와 실행 중인 사용자 프로세스의 사용자 가상 메모리에 접근할 수 있다. 그러나 커널 내에서도 매핑되지 않은 사용자 가상 주소에 메모리 접근을 시도하면 페이지 폴트가 발생한다.

일반적인 메모리 레이아웃

개념적으로는, 각 프로세스는 자신의 사용자 가상 메모리를 원하는 대로 배치할 자유가 있다. 실제로 사용자 가상 메모리는 다음과 같이 배치된다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
PHYS_BASE +----------------------------------+
           |            user stack            |
           |                |                 |
           |                |                 |
           |                V                 |
           |           grows downward         |
           |                                  |
           |                                  |
           |                                  |
           |                                  |
           |           grows upward           |
           |                ^                 |
           |                |                 |
           |                |                 |
           +----------------------------------+
           | uninitialized data segment (BSS) |
           +----------------------------------+
           |     initialized data segment     |
           +----------------------------------+
           |           code segment           |
0x08048000 +----------------------------------+
           |                                  |
           |                                  |
           |                                  |
           |                                  |
           |                                  |
         0 +----------------------------------+

이 프로젝트 2에서 유저 스택은 고정된 크기를 가지지만, 프로젝트 3에서는 성장할 수 있도록 허용될 것이다. 전통적으로 초기화되지 않은 데이터 세그먼트의 크기는 시스템 콜을 통해 조정될 수 있지만, 이를 구현할 필요는 없다. (sbrk())

Pintos에서 코드 세그먼트는 사용자 가상 주소 0x08084000에서 시작하며, 주소 공간의 하단에서 약 128MB 떨어진 곳에 위치한다. 이 값은 [SysV-i386]에 명시되어 있으며 특별한 의미는 없다.

링커는 “링커 스크립트”라고 불리는 지시에 따라 사용자 프로그램의 메모리 레이아웃을 설정한다. 이 스크립트는 다양한 프로그램 세그먼트의 이름과 위치를 알려준다. 링커 스크립트에 대해 더 자세히 알아보려면 링커 매뉴얼의 “Scripts” 장을 읽거나 ‘info ld’를 통해 접근할 수 있다.

특정 실행 파일의 레이아웃을 보려면, objdump(80x86) 또는 i386-elf-objdump(SPARC)를 ‘-p’ 옵션과 함께 실행하면 된다.

유저 메모리 접근

시스템 콜의 일부로서, 커널은 사용자 프로그램이 제공하는 포인터를 통해 메모리에 접근해야 할 때가 많다. 커널은 이를 수행할 때 매우 주의해야 한다. 왜냐하면 사용자는 널 포인터, 매핑되지 않은 가상 메모리에 대한 포인터, 또는 PHYS_BASE 이상의 커널 가상 주소 공간에 대한 포인터를 전달할 수 있기 때문이다. 이러한 유형의 유효하지 않은 포인터는 모두, 문제가 되는 프로세스를 종료하고 그 자원을 해제함으로써 커널이나 다른 실행 중인 프로세스에 해를 끼치지 않고 거부되어야 한다.

이를 올바르게 수행하는 두 가지 합리적인 방법이 있다. 첫 번째 방법은 사용자가 제공한 포인터의 유효성을 검증한 후 그것을 역참조하는 것이다. 이 방법을 선택한다면, ‘userprog/pagedir.c’와 ‘threads/vaddr.h’에 있는 함수들을 살펴보고 싶을 것이다. 이것은 사용자 메모리 접근을 처리하는 가장 간단한 방법이다.

두 번째 방법은 사용자 포인터가 PHYS_BASE 아래를 가리키는지만 확인한 후 그것을 역참조하는 것이다. 유효하지 않은 사용자 포인터는 “페이지 폴트”를 발생시킬 것이며, 이는 ‘userprog/exception.c’의 page_fault() 코드를 수정함으로써 처리할 수 있다. 이 기법은 프로세서의 MMU를 활용하기 때문에 일반적으로 더 빠르며, 실제 커널(리눅스 포함)에서 사용되는 경향이 있다.

어느 경우든, “resource leak”이 발생하지 않도록 해야 한다. 예를 들어, 시스템 콜이 락을 획득하거나 malloc()으로 메모리를 할당했다면, 그 후에 유효하지 않은 사용자 포인터를 만나더라도 여전히 락을 해제하거나 메모리 페이지를 해제해야 한다. 사용자 포인터를 역참조하기 전에 검증하는 방법을 선택한다면, 이는 간단할 것이다. 유효하지 않은 포인터가 페이지 폴트를 일으키는 경우 처리하기 더 어려운데, 메모리 접근에서 오류 코드를 반환할 방법이 없기 때문이다. 따라서 후자의 기법을 시도하고자 하는 사람들을 위해 약간의 유용한 코드를 제공할 것이다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* Reads a byte at user virtual address UADDR.
 * UADDR must be below KERN_BASE.
 * Returns the byte value if successful, -1 if a segfault occurred. */
static int64_t get_user (const uint8_t *uaddr) {
    int64_t result;
    __asm __volatile (
        "movabsq $done_get, %0\n"
        "movzbq %1, %0\n"
        "done_get:\n"
        : "=&a" (result) : "m" (*uaddr));
    return result;
}

/* Writes BYTE to user address UDST.
 * UDST must be below KERN_BASE.
 * Returns true if successful, false if a segfault occurred. */
static bool put_user (uint8_t *udst, uint8_t byte) {
    int64_t error_code;
    __asm __volatile (
        "movabsq $done_put, %0\n"
        "movb %b2, %1\n"
        "done_put:\n"
        : "=&a" (error_code), "=m" (*udst) : "q" (byte));
    return error_code != -1;
}

Suggested Implementation Order

  1. Argument passing
  2. User memory access
  3. System calls
  4. Process Termination messages
  5. Deny Write on Executables
  6. Extend File Descriptor (Extra)

Argument passing

Setup the argument for user program in process_exec()

x86-64 호출 규약 요약

이 섹션은 64비트 x86-64 Unix 구현에서 일반적인 함수 호출에 사용되는 규약 (Convention)의 중요한 점을 요약한다. 간결함을 위해 일부 세부 사항은 생략되었다. 더 자세한 정보는 System V AMD64 ABI를 참조하라. https://en.wikipedia.org/wiki/X86_calling_conventions#System_V_AMD64_ABI

Calling Convention은 다음과 같이 작동한다:

  • User level 애플리케이션은 인자 전달 순서로 정수 레지스터 %rdi, %rsi, %rdx, %rcx, %r8 및 %r9를 사용합니다.
  • Caller (호출자) 는 다음 명령의 주소(리턴 주소)를 스택에 푸시하고 호출된 함수의 첫 번째 명령으로 점프한다. x86-64 명령어 CALL이 이 두 작업을 모두 수행한다.
  • 호출된 함수 (Callee) 가 실행된다.
  • 호출된 함수에 리턴 값이 있다면, 이를 RAX 레지스터에 저장한다.
  • 호출된 함수는 스택에서 리턴 주소(return address)를 pop하고 지정된 위치로 점프하여 RET 명령어를 사용하여 리턴한다.
  • f() 함수가 세 개의 int 인자를 취한다고 가정할 때, 호출된 함수가 3단계에서 처음 볼 수 있는 샘플 스택 프레임과 레지스터 상태는 다음과 같다. f()f(1, 2, 3)으로 호출된다고 가정한다. 초기 스택 주소는 임의로 정했다:
1
2
3
4
                             +----------------+
stack pointer --> 0x4747fe70 | return address |
                             +----------------+
RDI: 0x0000000000000001 | RSI: 0x0000000000000002 | RDX: 0x0000000000000003

프로그램 시작 세부 정보

Pintos 사용자 프로그램을 위한 C 라이브러리는 lib/user/entry.c_start()를 사용자 프로그램의 진입점으로 지정한다. 이 함수는 main()을 둘러싸는 래퍼 (wrapper)로, main()이 리턴하면 exit()을 호출한다:

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

커널은 user program이 실행을 시작하도록 허용하기 전에 레지스터에 초기 함수의 인자를 넣어야 한다. 인자들은 일반 호출 규약과 동일한 방식으로 전달된다.

예를 들어, /bin/ls -l foo bar 명령어에 대한 인자 처리를 생각해보자.

  • 명령어를 단어 단위로 나눈다: /bin/ls, -l, foo, bar.
  • 스택의 맨 위에 단어들을 배치한다. 포인터를 통해 참조될 것이므로 순서는 중요하지 않다.
  • 스택에 각 문자열의 주소와 null 포인터 센티널 (sentienel, 감시값)을 오른쪽에서 왼쪽 순서로 push 한다. 이들은 argv의 요소이다. null 포인터 센티널은 C 표준에 요구되는 대로 argv[argc]가 null 포인터임을 보장한다. 순서는 argv[0]이 가장 낮은 가상 주소에 있게 한다. 정렬된 접근 (aligned) 이 정렬되지 않은 접근보다 빠르므로, 최고의 성능을 위해 첫 푸시 전에 스택 포인터를 8의 배수로 내린다.
  • %rsiargv (즉, argv[0]의 주소)로 지정하고 %rdiargc로 설정한다.
  • 마지막으로, 가짜 “리턴 주소”를 푸시한다 (fake return address):
    • 엔트리 함수는 결코 리턴하지 않지만, 그 스택 프레임은 다른 모든 것과 동일한 구조를 가져야 한다.

아래 표는 사용자 프로그램이 시작되기 바로 직전에 스택과 관련 레지스터의 상태를 보여준다. 스택은 아래로 자란다. (grows down)

1
2
3
4
5
6
7
8
9
10
11
12
13
주소       이름           데이터          타입
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 (*) ()
RDI: 4      | RSI: 0x4747ffc0

이 예제에서는 스택 포인터를 0x4747ffb8로 초기화한다. 위에 나온 것처럼, 코드는 스택을 include/threads/vaddr.h에 정의된 USER_STACK에서 시작해야 한다.

인자 전달 코드를 디버깅하는데에 <stdio.h>에 선언된 비표준 함수 hex_dump()를 유용하게 사용할 수 있다.

현재 process_exec()은 새 프로세스에 인자를 전달하는 기능을 지원하지 않고 있다. process_exec() 함수의 기능을 확장하여 프로그램 이름을 전달받아 단어를 공백(space) 기준으로 나누어 프로그램 이름, 첫 번째 인자 등을 지정하는 기능을 구현하라. 즉, process_exec("grep foo bar")은 두 인자 foobar를 전달하여 grep을 실행해야 한다.

사용자 메모리 접근

사용자 메모리 접근을 구현하라.

시스템 콜을 구현하려면 user virtural address space에서 데이터를 읽고 쓸 수 있는 방법을 제공해야 한다. 인자를 가져올 때 이 기능이 필요하지 않지만, 시스템 콜의 인자로 제공된 포인터에서 데이터를 읽을 때는 이 기능이 필요하다. 이것은 약간 까다로울 수 있다: 사용자가 유효하지 않은 포인터, 커널 메모리로의 포인터, 또는 그 영역들 중 일부에 걸쳐 있는 포인터를 제공하면 어떻게 될까? 이러한 경우들은 사용자 프로세스를 종료함으로써 처리해야 할 것이다.

시스템 콜

시스템 콜 구조를 구현하라.

userprog/syscall.c에서 시스템 콜 핸들러를 구현합니다. 기본적으로 제공하는 스켈레톤 (skeleton) 구현은 프로세스를 종료하여 시스템 콜을 “처리”한다. 시스템 콜 번호를 검색한 후에 시스템 콜 인자를 가져오고 적절한 작업을 수행하도록 해야 한다.

시스템 콜 details

프로젝트 1에서는 운영 체제가 사용자 프로그램으로부터 제어를 되찾는 한 가지 방법을 다루었다. 그것은 바로 타이머와 I/O 장치로부터의 인터럽트이다. 이러한 인터럽트는 CPU 외부의 요인로 인해 발생하기 때문에 “외부” 인터럽트라고 한다.

운영 체제는 또한 프로그램 코드에서 발생하는 소프트웨어 예외 (Exception)을 처리한다. 이것은 페이지 폴트 또는 0으로 나누기와 같은 오류일 수 있다. 예외는 또한 사용자 프로그램이 운영 체제에서 서비스(“시스템 콜”)를 요청하는 수단이다. 전통적인 x86 아키텍처에서는 시스템 콜이 다른 소프트웨어 예외와 같은 방식으로 처리된다. 그러나 x86-64에서는 시스템 콜을 위한 특별한 명령어 (instruction), syscall을 제조업체가 도입했다. 이것은 시스템 콜 핸들러를 빠르게 호출하는 방법을 제공한다.

요즘에는 x86-64에서 syscall 인스트럭션이 시스템 콜을 호출하는 가장 일반적인 수단이다. Pintos에서 사용자 프로그램은 시스템 콜을 만들기 위해 syscall을 호출한다. 시스템 콜 번호와 추가 인자는 syscall 명령어를 호출하기 전에 레지스터에 미리 설정되어야 한다. 다음의 중요한 사항을 기억하라.

  • %rax는 시스템 콜 번호이다.
  • 네 번째 인자는 %rcx가 아닌 %r10이다.

따라서 시스템 콜 핸들러 syscall_handler()가 제어권을 얻을 때, 시스템 콜 번호는 rax에 있으며, 인자들은 %rdi, %rsi, %rdx, %r10, %r8, %r9의 순서로 전달되게 된다. caller의 레지스터는 struct intr_frame에 접근할 수 있다. (struct intr_frame은 커널 스택에 있다.) 함수 리턴 값에 대한 x86-64 convention은 RAX 레지스터에 반환값을 두어야 한다. 리턴 값을 가진 시스템 콜은 struct intr_frame의 rax 멤버를 수정함으로써 리턴 값을 설정할 수 있다.

아래의 system call에 대한 프로토타입들은 include/lib/user/syscall.h를 include하면 볼 수 있는 것들이다. (이 헤더와 include/lib/user에 있는 모든 다른 헤더는 오직 사용자 프로그램용임을 기억하라.). 각 시스템 콜에 대한 시스템 콜 번호는 include/lib/syscall-nr.h에 정의되어 있다:

System call prototypes

아래의 시스템 콜의 세부적인 부분을 모두 구현하여라. (Prototype 완성)

  1. void halt (void);
    • Pintos를 종료하고 시스템의 전원을 끄는 power_off() 함수를 호출한다. 해당 함수는 src/include/threads/init.h에 선언되어 있으며, 일반적인 사용을 피해야 한다. 전원을 갑자기 끄게 되면 교착 상태 (Deadlock)와 같은 중요한 시스템 정보를 잃어버릴 수 있기 때문이다.
  2. void exit (int status);
    • 현재 사용자 프로그램을 종료하고, 지정된 상태 값을 커널에 반환한다. 프로세스의 부모가 해당 프로세스의 종료를 대기하고 있다면, 이 상태 값이 반환된다. 일반적으로 상태 값 0은 성공을, 0이 아닌 값은 오류를 나타낸다.
  3. pid_t fork (const char *thread_name);
    • 현재 프로세스를 복제하여 thread_name이라는 이름의 새 프로세스를 생성한다. 복제 시 %RBX, %RSP, %RBP, %R12 - %R15 같은 callee-saved 레지스터만 복제하며, 다른 레지스터의 값은 복제할 필요가 없다. 부모 프로세스에게는 자식 프로세스의 pid를 반환하며, 다른 경우에는 유효한 값이 아니어야 한다. 자식 프로세스에서는, 반환값이 0이 되어야 한다. 자식은 파일 디스크럽터와 가상 메모리 주소 공간을 복사해야 한다. 부모 프로세스는 fork() 에서 자식 프로세스가 성공적으로 복제되기 전까지는 return해서는 안된다.
    • 자원의 복제에 실패하면 부모 프로세스에서 TID_ERROR를 반환한다.
    • threads/mmu.c 내부에 pml4_for_each() 함수는 page table을 포함한 유저 메모리 공간 전부를 복사하는 템플릿이 미리 정의되어 있다. 하지만, 여전히 당신은 pte_for_each_func의 미구현된 부분을 모두 구현해야 한다.
  4. int exec (const char *cmd_line);
    • 현재 프로세스를 cmd_line에 명시된 실행 파일로 변경한다. 성공적으로 변경되면 이 함수는 반환하지 않고, 실패하면 프로세스는 상태 -1로 종료된다.
    • 이 함수는 exec() 으로 호출된 스레드의 이름을 변경해서는 안된다.
    • 파일 디스크럽터는 exec()이 호출된 이후부터 열려 있는 상태로 계속 유지되는 것을 유념해라.
  5. int wait (pid_t pid);
    • pid로 지정된 자식 프로세스가 종료될 때까지 대기하고, 그 프로세스가 종료되면 해당 프로세스가 exit()을 통해 반환한 상태를 가져온다. 만약 지정된 프로세스가 여전히 실행 중이라면, 종료할 때까지 대기하고, 프로세스가 종료되면 exit() 함수에 반환된 값을 가져온다.
    • 만약 pid로 지정된 프로세스가 exit() 함수를 호출하지 않았으나, 커널에 의해 종료된 경우 (예 : 예외로 인해 종료된 경우) wait(pid)는 -1을 반환해야 한다.
    • 부모 프로세스가 wait()을 호출할 때, 이미 종료된 자식 프로세스를 기다리는 것은 가능하다. 하지만, 커널은 여전히 부모 프로세스가 자식의 종료 상태를 가져오거나, 자식 프로세스가 이미 커널에 의해 종료되었음을 알 수 있도록 해야 한다.
    • wait() 시스템 콜은 만약 아래의 조건이 참인 경우, 바로 -1을 반환하고 종료되어야 한다.
      • pid로 지정된 프로세스가 현재 wait()을 호출한 프로세스의 직접적인 자식이 아닐 경우. pid로 지정된 프로세스는 wait()을 호출한 프로세스가 성공적인 fork()로 인해 pid값을 반환받았을 경우에만 자식 프로세스가 된다. 자식 프로세스는 상속되지 않는다. (inherited) 예로, A가 자식 B를 생성하고, B가 자식 C를 생성하였을 경우, A는 B가 실행을 종료하더라도 C를 wait() 함수로 대기할 수 없다. wait(C)는 즉시 종료되고 -1을 반환하여야 한다. 유사하게, 고아 프로세스 (orphaned process)는 부모 프로세스가 먼저 종료된다고 새로운 부모 프로세스에 할당되지 않는다.
      • wait(pid)을 호출한 프로세스가 이미 이 pid에 대해 wait()을 호출한 경우. 즉, wait() 을 호출한 프로세스는 어떤 자식에 대해서라도 한번만 wait()을 호출할 수 있다.
    • 프로세스는 여러 개의 children을 가질 수 있고, 이러한 children들에 대해 어떠한 순서로도 wait() 할수 있어야 하고, 이러한 일부 children에 대해 대기하기 전에 미리 종료할 수 도 있다. 당신의 코드 디자인은 이러한 wait()이 발생하는 모든 상황에 대해 대처할 수 있어야 한다. struct thread 를 포함한 모든 프로세스의 자원은 부모 프로세스가 wait()하든, 그렇지 않던, 심지어 child 프로세스가 부모 프로스세보다 더 빨리 종료하던, 더 늦게 종료하던 적절히 반환되어야 한다. *
  6. bool create (const char *file, unsigned initial_size);
    • 지정된 이름과 초기 크기로 새 파일을 생성한다. 성공하면 true, 실패하면 false를 반환한다. 파일을 생성해도 자동으로 파일을 열지 않으므로, 파일을 열기 위해서는 별도의 open 시스템 호출이 필요하다.
  7. bool remove (const char *file);
    • 지정된 파일을 삭제한다. 성공하면 true, 실패하면 false를 반환한다. 파일이 열려 있어도 삭제할 수 있으며, 열린 파일을 삭제해도 자동으로 닫히지 않는다.
  8. int open (const char *file);
    • 파일을 열고 파일 디스크립터를 반환한다. 성공하면 양의 정수, 실패하면 -1을 반환한다. 파일 디스크립터 0과 1은 각각 표준 입력과 표준 출력을 위해 예약되어 있다. 파일을 열 때마다 새로운 파일 디스크립터가 반환된다.

이러한 시스템 호출들은 Pintos 내에서 다양한 사용자 프로세스가 동시에 안전하게 호출할 수 있도록 동기화가 필수적이다. 이를 구현하는 과정에서 시스템의 안정성을

최우선으로 고려하여, 사용자 프로그램이 시스템을 종료시키는 것을 제외하고는 어떠한 경우에도 OS를 크래시나 패닉 상태로 만들어서는 안된다.

FAQ

코드를 얼마나 작성해야 하는가?

여기에 reference solution에 대한 요약이 있다. 이것은

1
git diff --stat

으로 생성된 것이다. 최종 행은 삽입된 행과 삭제된 행의 총계를 보여준다; 변경된 행은 삽입과 삭제 모두로 계산된다. 참고 솔루션은 가능한 솔루션 중 하나에 불과하다. 많은 다른 솔루션들도 가능하고 그 중 많은 것들이 참조 솔루션과 크게 다르다. 우수한 솔루션 중 일부는 참조 솔루션에 의해 수정된 모든 파일을 수정하지 않을 수도 있고, 일부는 참조 솔루션에 의해 수정되지 않은 파일을 수정할 수도 있다. 또한, 이것은 추가 요구 사항의 구현을 포함한다. FYI, 약 150줄은 추가와 관련이 있다.

1
2
3
4
5
6
7
src/include/threads/thread.h   |  23 ++
src/include/userprog/syscall.h |   3 +
src/threads/thread.c           |   5 +
src/userprog/exception.c       |   4 +
src/userprog/process.c         | 355 +++++++++++++++++++++++++++++++++++++++++-
src/userprog/syscall.c         | 429 ++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 782 insertions(+), 37 deletions(-)

커널이 항상 pintos -p file -- -q를 실행할 때 kernel panic이 발생한다.

  1. 파일 시스템을 포맷했는가? (pintos -f로)
  2. 파일 이름이 너무 긴가? 파일 시스템은 파일 이름을 14자로 제한한다. pintos -p ../../examples/echo – -q와 같은 명령은 제한을 초과할 것이다. 대신 pintos -p ../../examples/echo:echo – -q를 사용하여 파일을 ‘echo’라는 이름으로 넣어라.
  3. 파일 시스템이 가득 차 있지 않은가?
  4. 파일 시스템에 이미 16개의 파일이 포함되어 있는가? 기본 Pintos 파일 시스템은 16개 파일 한도를 가진다.
  5. 파일 시스템이 너무 단편화되어 파일을 위한 연속적인 공간이 충분하지 않을 수도 있다.

pintos -p ../file –를 실행할 때 ‘file’이 복사되지 않는다.

기본적으로 파일은 참조하는 이름으로 작성되므로, 이 경우에는 ‘../file’이라는 이름으로 복사된 파일이 될 것이다. 대신 pintos -p ../file:file –를 실행하는 것이 좋다.

내 모든 user program이 페이지 폴트로 인해 kernel panic이 발생한다.

인자 전달을 구현하지 않았거나 제대로 구현하지 않은 경우에 발생할 수 있다. 사용자 프로그램을 위한 기본 C 라이브러리는 스택에서 argv를 읽으려고 한다. 스택이나 레지스터가 제대로 설정되지 않으면 페이지 폴트가 발생한다.

내 모든 user program이 시스템 콜로 인해 kernel panic이 발생한다.

적어도 하나의 시스템 콜(exit())을 시도하는 합리적인 프로그램이 있으며, 대부분의 프로그램은 그 이상을 시도한다. 특히, printf()는 write 시스템 콜을 호출한다. 기본 시스템 콜 핸들러는 단순히 system call!을 출력하고 프로그램을 종료한다. 그때까지, 인자 전달이 구현되었음을 스스로 확신하기 위해 hex_dump()를 사용할 수 있다.

프로그램을 어떻게 디스어셈블할 수 있는가?

objdump 유틸리티는 전체 사용자 프로그램이나 오브젝트 파일을 디스어셈블할 수 있다. objdump -d file로 호출한다. GDB의 disassemble 명령을 사용하여 개별 함수를 디스어셈블할 수 있다.

왜 많은 C include 파일이 Pintos 프로그램에서 작동하지 않는가? Pintos 프로그램에서 libfoo를 사용할 수 있는가?

제공된 C 라이브러리는 매우 제한적이다. 실제 운영 체제의 C 라이브러리에 기대되는 많은 기능들이 포함되어 있지 않다. C 라이브러리는 I/O 및 메모리 할당을 위해 시스템 콜을 수행해야 하므로 특정 운영 체제(및 아키텍처)용으로 구축되어야 한다. (모든 함수가 그런 것은 아니지만, 보통 라이브러리는 하나의 단위로 컴파일된다.)

원하는 라이브러리가 Pintos에서 구현하지 않은 C 라이브러리의 일부를 사용할 가능성이 크다. Pintos에서 작동하도록 하려면 적어도 일부 포팅 노력이 필요할 것이다. 특히, Pintos 사용자 프로그램 C 라이브러리에는 malloc() 구현이 없다.

새로운 사용자 프로그램을 어떻게 컴파일하는가?

src/examples/Makefile을 수정한 다음 make를 실행한다.

디버거에서 사용자 프로그램을 실행할 수 있는가?

그렇다. 몇 가지 제한이 있긴 하지만 가능하다. GDB 참조.

tid_t와 pid_t의 차이점은 무엇인가?

tid_t는 커널 스레드를 식별하며, process_fork()로 생성된 경우에는 사용자 프로세스가 실행 중일 수 있고, thread_create()로 생성된 경우에는 그렇지 않을 수 있다. 이는 커널에서만 사용되는 데이터 유형이다. pid_t는 사용자 프로세스를 식별한다. 이는 사용자 프로세스와 커널에서 exec 및 wait 시스템 콜에서 사용된다. tid_t와 pid_t에 대해 적합한 유형을 선택할 수 있다. 기본적으로, 둘 다 int이다. 동일한 값을 둘 다 같은 프로세스를 식별하는 데 사용할 수 있게 하나로 매핑할 수도 있고, 더 복잡한 매핑을 사용할 수도 있다. 그것은 당신에게 달려 있다.

struct file *를 캐스팅해서 파일 디스크립터를 얻을 수 있는가? struct thread *를 pid_t로 캐스팅할 수 있는가?

아니다. pid_t와 파일 디스크립터는 포인터 유형보다 작기 때문에 직접 캐스팅할 수 없다.

프로세스당 열린 파일의 최대 수를 설정할 수 있는가?

임의의 제한을 설정하는 것보다는 제한을 두지 않는 것이 좋다. 필요한 경우, 프로세스당 128개의 열린 파일로 제한할 수 있다. 그러나 추가 요구 사항을 구현하려면 제한이 없어야 한다.

열린 파일이 제거되면 어떻게 되는가?

파일의 표준 Unix식 방법을 구현해야 한다. 즉, 파일이 제거되면 해당 파일에 대한 파일 디스 크립터가 있는 모든 프로세스는 해당 디스크립터를 계속 사용할 수 있다. 이는 파일을 읽고 쓸 수 있다는 것을 의미한다. 파일은 이름이 없게 되지만, 파일을 참조하는 모든 파일 디스크립터가 닫히거나 기계가 종료될 때까지 계속 존재할 것이다.

4kB 스택 공간보다 많이 필요한 사용자 프로그램을 어떻게 실행할 수 있는가?

각 프로세스에 대해 한 페이지 이상의 스택 공간을 할당하도록 스택 설정 코드를 수정할 수 있다. 다음 프로젝트에서 더 나은 해결책을 구현할 것이다.

This post is written by david61song