728x90
Introduction · GitBook
Project2: User Programs Now that you've worked with Pintos and are becoming familiar with its infrastructure and thread package, it's time to start working on the parts of the system that allow running user programs. The base code already supports loading
casys-kaist.github.io
Instruction
Source Files
process.c, process.h
- ELF 바이너리(=ELF 실행파일)들을 로드하고 프로세스를 실행한다.
- ELF : ELF는 많은 운영체제에서 목적 파일, 공유 라이브러리, 그리고 실행 파일들을 위해 사용되는 파일 포맷이다.
syscall.c,syscall.h
- 유저 프로세스가 일부 커널 기능에 접근하려고 할때마다 시스템 콜이 호출된다. 이것이 시스템 콜 핸들러의 기본 구조이다. 현재 상태에서는 단지 메세지를 출력하고 유저 프로세스를 종료시키게 되어있다.
syscall-entry.S
- 시스템 콜 핸들러를 부트스트랩하는 어셈블리 코드이다. 이 코드를 이해할 필요는 없다.
- 부트스트랩 프로그램 : 전원을 켜거나 재부팅을 할 때 적재되는 프로그램
exception.c, exception.h
- 유저 프로그램이 특별한 접근 권한을 필요로 하거나 금지된 연산을 수행할 때, 이는 exception 도는 fault로 커널 내로 트랩한다. exception.c, exception.h 파일들은 예외사항을 처리한다. 현재 모든 예외사항들은 단지 메세지를 출력하고 프로세스를 끝내고 있다.
- 프로젝트 2에 대한 일부 해결책은 이 파일 내에 있는 page_fault()를 수정하는 것이다.
- 응용 프로그램이 시스템 콜을 호출하면 하드웨어는 ‘트랩 핸들러’를 실행하여 하드웨어 특권 수준을 커널모드로 격상시킨다.
- 커널모드에서 운영체제는 시스템 하드웨어를 자유롭게 접근할 수 있다.
gdt.c, gdt.h
- x86-64는 segmented 아키텍쳐이다. Global Descriptor Table(GDT)는 사용중인 세그멘트를 알려주는 표이다.앞으로 어떤 프로젝트에서도 이 파일들을 수정할 필요는 없다. GDT가 어떻게 작동하는지에 대해 궁금하다면 읽어보면 된다.
tss.c, tss.h
- Task-State Segment(TSS)는 x86 아키텍쳐의 문맥교환에 사용된다. 하지만 x86-64에서 문맥교환(context switching = task switching)은 지원이 중단된 기능이다. 그래도 여준히 tss는 ring switching 동안 스택 포인터를 찾아내기 위해 사용되고 있다.
- 이는 유저 프로세스가 인터럽트 핸들러에 진입할 때, 하드웨어는 tss에게 커널의 스택 포인터를 찾아달라고 요청한다는 의미이다. 앞으로 어떤 프로젝트에서도 이 파일들을 수정할 필요는 없지만 어떻게 TSS가 동작하는지 궁금하다면 읽어보면 된다.
Using the File System
- 이번 프로젝트에서는 파일 시스템 코드와 인터페이스해야 한다. 왜냐하면 유저 프로그램이 파일 시스템으로부터 로드되기도 하고, 여러분들이 구현해야 할 시스템 콜들이 파일 시스템을 다루기 때문이다. 하지만 이번 프로젝트의 중점 과제는 파일 시스템은 아니므로, 간단하지만 완전한 파일 시스템을 filesys 디렉토리에 제공하고 있다. 파일 시스템을 사용하는 방법과 파일 시스템의 수많은 제약 사항들을 이해하고 싶다면 filesys.h 과 file.h 인터페이스를 살펴보아라.
- 이번 프로젝트에서 이 파일 시스템 코드를 수정할 필요도 없고, 수정하지 않기를 권한다. 파일 시스템에 정신을 뺏기지 말고, 이번 프로젝트의 중점 과제에 집중해라
- 파일 시스템 루틴을 적절히 사용하는 것은 파일 시스템 구현을 향상시켜야 하는 프로젝트 4에서의 여러분들의 삶을 훨씬 쉽게 만들어 줄 것이다. 그 전까지는 아래의 제한 사항들을 꼭 지켜주어야 한다.
- 내부 동기화를 하지마라. 동시 접근은 서로를 방해할 것이다. 동기화는 한번에 하나의 프로세스만이 파일 시스템 코드를 실행한다는 걸 보장하기 위해서만 사용해라.
- 파일 사이즈는 파일이 생성될 때 고정된다. 루트 디렉토리가 파일이기 때문에 생성될 수 있는 파일의 갯수들도 제한되게 된다.
- 파일의 데이터는 single extent로 할당된다. 즉, 한 파일의 데이터는 디스크 섹터를 연속적으로 차지해야 한다. 그래서 파일 시스템이 사용되고 시간이 흐름면서 외부 단편화가 큰 문제가 될 수 있다.
- 하위 디렉토리를 만들지마라
- 파일 이름은 14자로 제한한다.
- 연산중에 시스템 crash가 발생하면 자동으로 복구 되지 못하는 방식으로 디스크를 망쳐버릴 수 있다. 파일 시스템 복구 툴 같은 것은 어차피 없다.
- filesys_remove()를 위한 유닉스 계역의 semantic들은 구현되어 있다. 즉, 삭제된 파일이라도 그 파일을 열었던 스레드들이 닫아주지 않는다면 메모리에서 블록이 할당 해제되지 않는다. 따라서 다른 스레드들이 해당 파일에 접근할 수 있다.
- 모든 테스트 프로그램이 커널 이미지로 존재했던 프로젝트1과 달리, 당신은 반드시 테스트 프로그램을 핀토스 가상 공간에 넣어줘야한다. 과제를 조금 쉽게 하기위해, make check 와 같은 테스팅 스크립트들이 자동적으로 위 과정을다루며, 대부분 경우에 당신은 이 과정을 이해할 필요는 없다.
- pintos 가상 머신에 파일을 넣기 위해서 당신은 먼저 파일 시스템 파티션이 있는 모의 디스크(simulated disk)를 만들 수 있어야 한다. pintos-mkdisk 프로그램은 이 기능을 제공한다. userprog/build 디렉토리에서 pintos-mkdisk filesys.dsk 2를 실행한다. 이 명령어는 2MB크기의 pintos 파일 시스템 파티션 하나를 포함하는 filesys.dsk 라는 모의 디스크(simulated disk)를 만든다. 그리고 pintos 명령어 뒤에 --fs-disk filesys.dsk를 적어줌으로써 어떤 디스크인지 지정한다..--fs-disk는 모의 커널(simulated kernel)이 아니라 pintos script를 위한 옵션이므로 --를 써줘야 한다. 그 이후에 커맨드 라인에 -f -q 옵션을 적어줘서 파일 시스템 파티션을 포맷팅한다. 파일 시스템이 포맷팅하기 위해서 -f옵션을 쓰고, -q 옵션은 핀토스가 포맷팅이 끝나면 바로 끝나도록 한다.
- 당신은 simulated 파일 시스템 안팎으로 파일을 저장하는 방법이 필요하다. 핀토스 -p(put, 저장)와 -g(get, 가져오기) 옵션이 이 기능을 해준다. 핀토스 파일 시스템에 파일을 복사(put)하기 위해서는 pintos -p file -- -q 라는 명령어를 사용하라. 복사할 파일의 이름을 지정하고 싶다면 원본 파일 이름 바로 뒤에 :newname 을 추가해라. 가상 머신에서 파일을 복사해서 가져오기(get) 위한 명령어는 -p를 -g로 바꿔주면 된다.
- 그런데 이러한 커맨드들은 커널의 커맨드 라인에 추출 및 추가하는 특별한 커맨드를 전달하고, 특별히 시뮬레이션된 ‘스크래치’ 파티션에 복사하고, 이 파티션으로부터 복사해오는 방식으로 작동한다. 이에 더 궁금하다면 Pintos 스크립트와 filesys/futil.c 를 살펴보고 구현 세부정보를 배워라
- 첫 줄 : 10 메가바이트 사이즈의 pintos 파일 시스템 파티션 하나를 포함하는 모의디스크filesys.dsk 를 생성
- 둘째 줄: filesys.dsk 디스크에 tests/userprog/args-single이라는 파일을 args-single이라는 이름으로 넣은 후(p), onearg라는 인자로 args-single 파일 을 실행
- pintos-mkdisk filesys.dsk 10 pintos --fs-disk filesys.dsk -p tests/userprog/args-single:args-single -- -q -f run 'args-single o
- 위의 코드는 파일 시스템 파티션으로 어떻게 디스크를 생성하고, 파일 시스템의 형식을 만들고 ,args-single이라는 이름의 프로그램을 어떻게 새 디스크에 만들고. onearg라는 인자를 전달해서 실행하는지에 대한 요약 코드이다. 위의 코드는 여러분이 테스트 케이스는 다 빌드했고, 현재 디렉토리가 userprog/build 라고 가정 하에 쓰인 코드이다.
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
- 만약 파일 시스템 디스크가 당장 필요 없다면, 이 네 단계를 하나의 커맨드로 합칠 수도 있다. --filesys-size=n 옵션을 쓰면 pintos를 실행하는 동안 대략 n메가 바이트 크기의 임시 파일 시스템 파티션을 생성하게 된다. pintos의 자동 시스템 suite은 이 구문을 아주 다양하게 사용한다.
How User Programs Work
- 핀토스는 메모리에 적합하고 당신이 구현한 시스템 콜만 사용하는 보통의 C 프로그램을 실행시킬 수 있다. 이 프로젝트에서 쓰이는 어느 시스템 콜도 메모리 할당을 허락하지 않기 때문에 malloc()은 구현될 수 없다. 핀토스는 또한 부동소수점 연산을 사용하는 프로그램을 실행시킬 수 없다. 왜냐하면 커널은 문맥교환이 일어날 때 프로세서의 부동 소수점을 저장하고 복구하지 않기 떄문이다.
- 핀토스는 userprog/process.c 에 제공된 로더로 ELF 실행 파일을 로드할 수 있다. ELF는 linux, solarix 등의 운영체제에서 목적 파일, 공유 라이브러리, 그리고 실행 파일들을 위해 사용되는 파일 포맷이다.
- 실제로 당신은 x86-64 ELF 실행파일들을 만들어내는 임의의 컴파일러와 링커를 사용해서 핀토스를 위한 프로그램을 만들 수 있다. (우리는 잘 동작할 컴파일러와 링커를 제공한다.) 테스트 프로그램을 simulated 파일 시스템에 복사할 때까지 핀토스가 유용한 작업을 할 수 없다는 것을 당신이 바로 깨달았기를 바란다. 당신이 다양한 프로그램들을 파일 시스템에 복사하기 전까지는 아무런 흥미로운 작업을 할 수 없다. 그리고 간혹 디버깅을 하다보면 filesys.dsk가 사용 불가능한 상태가 되는 경우가 발생할텐데, 그럴때마다 복사해서 덮어쓸 수 있는 클린한 레퍼런스 파일 시스템 디스크를 만들어 놓으면 좋다.
Virtual Memory Layout
- 핀토스의 가상 메모리는 2개의 영역으로 나눌 수 있다. 1) 유저 가상 메모리 2) 커널 가상 메모리. 유저 가상 메모리는 가상주소 0부터 KERN_BASE 까지의 범위를 가진다. KERN_BASE는 include/threads/vaddr.h에 정의되어 있고 기본적으로 0x8004000000 이다. 커널 가상 메모리는 가상 주소 공간의 나머지를 차지한다.
- 하나의 프로세스는 하나의 유저 가상 메모리를 가진다. 프로세스 문맥 교환이 일어날 때, 커널은 프로세서의 ‘페이지 디렉토리 베이스 레지스터’를 바꿈으로써 유저 가상 주소공간 또한 바꿔준다. 스레드의 구조체는 하나의 프로세스의 페이지 테이블을 가리키는 포인터를 가지고 있다.
- 커널 가상 메모리는 전역적이다. 커널 가상 메모리는 어떠한 유저 프로세스 또는 커널 스레드가 CPU 제어권을 획득해 running 인지에 관계 없이 항상 같은 방식으로 매핑된다. 핀토스에서 KERN_BASE에서 시작하는 커널 가상 메모리는 물리 메모리와 일대일 매핑이 된다. 다시 말해, 가상 주소인 KERN_BASE는 물리 주소 0에 매핑이되고, 가상 주소 KERN + 0x1234 물리주소 0x1234에 매핑된다. 이 매핑 방식은 머신의 물리 메모리 사이즈 까지 유효하다.
- 유저 프로그램은 자신이 유저 가상 메모리에만 접근할 수 있다. 유저프로그램이 커널 가상 메모리에 접근하려는 시도는 page fault를 야기하고 프로세스는 종료된다. page fault는 userprog/exception.c 에 있는 page_fault() 라는 함수에 의해 이루어진다. 한편, 커널 스레드들은 커널 가상 메모리에 접근 가능하고 만일 유저 프로세스가 running 상태라면 이 유저 프로세스의 유저 가상 메모리에도 접근할 수 있다. 하지만, 심지어 커널에서 매핑되지 않은 유저 가상 주소로 메모리에 접근하려는 시도조차도 page fault를 야기한다.
Typical Memory Layout
- 개념적으로 각각의프로세스는 자유롭게 자신의 가상 메모리를 배치할 수 잇다. 유저 가상 메모리는 아래와 같은 레이아웃을 가진다.
USER_STACK +----------------------------------+
| user stack |
| | |
| | |
| V |
| grows downward |
| |
| |
| |
| |
| grows upward |
| ^ |
| | |
| | |
+----------------------------------+
| uninitialized data segment (BSS) |
+----------------------------------+
| initialized data segment |
+----------------------------------+
| code segment |
0x400000 +----------------------------------+
| |
| |
| |
| |
| |
0 +----------------------------------+
- 이 프로젝트에서, 유저 스택의 크기는 고정되어 있지만, 프로젝트 3에서는 유저 스택의 크기는 가변적이게 된다. 전통적으로 초기화되지 않은 데이터 세그먼트의 사이즈는 시스템 콜에 의해 조정될 수 있습니다만, 당신이 이것을 구현할 필요는 없다.
- 핀토스에서 코드 세그먼트는 가상 메모리 0x400000에서 시작하고, 대략 주소 공간의 바닥에서 128MB만큼 떨어져 있다. 이 주소값은 우분투에서 일반적인 값이며 큰 의미는 없다.
- 링커는 다양한 프로그램 세그먼트의 이름과 위치를 알려주는 링크 스크립트가 알려주는대로 메모리에 유저 프로그램의 레이아웃을 설정한다. 당신은 linker manual의 script 챕터를 보면 링커 스크립트에 대해서 더 많이 알 수 있다.
- 한 실행파일의 레이아웃을 보기 위해서는 -p 옵션과 함께 objdump 명령어를 실행시켜 보아라
Accessing User Memory
- 시스템 콜의 일부로서, 커널은 유저 프로그램이 제공한 포인터들을 통해 메모리에 자주 접근해야 한다. 유저가 null 포인터나 매핑되지 않은 가상 메모리를 가리키는 포인터를 전달할 수 있기 때문에 커널은 매우 신중해야 한다. 잘못된 포인터들은 커널과 다른 running 프로세스들에게 아무 악영향을 미치지 않고 거부되어져야 하고, 이를 위해서 문제가 되는 프로세스를 종료시키고 그 프로세스의 자원들을 해제해야 한다.
- 유저가 전달한 잘못된 포인터에 문제 없이 잘 대응하기 위해서는 적어도 2개의 합리적인 방법이 있다. 첫 번째 방법은 유저가 전달한 포인터에 문제가 없는지 검사한 후에 역참조하는 것이다. 만일 당신이 이 방법을 선택한다면, thread/mmu.c 와 include/threads/vaddr.h에 있는 함수들을 살펴봐라. 이 방법이 유저 메모리 접근을 가장 쉽게 처리할 수 있는 방법이다.
- 두 번째 방법은 유저가 전달한 포인터가 KERN_BASE 보다 아래 부분을 가리키고 있는지 검사한 후에 역참조 하는 것이다. 잘못된 유저 포인터는 당신이 userprog/exception.c 의 page_fault() 함수 코드를 수정함으로써 당신이 page fault를 발생시킨다. 유효하지 않은 유저 포인터는 페이지 폴트를 발생시킬수 있고, 여러분이 userprog/exception.c 파일에 있는 page_fault() 함수의 코드를 수정함으로써 페이지 폴트를 다룰 수 있다. 이 방법은 프로세서의 MMU를 활용하기 때문에 보통은 더 빠르고, 리눅스를 포함한 실제 커널에서도 많이 사용된다.
- 두 가지 방법 모두에서 당신은 리소스가 누수되지 않도록 해야한다.
- 유저 포인터가 가리키는 데이터를 역참조하기 전에 포인터를 검증하는 방법을 택했다면, 좀 수월할것이다.
- 두 번째 방법은 다루기가 조금 더 까다로운데, 잘못된 포인터가 페이지 폴트를 발생시키게 되면, 메모리 저븐으로 에러코드를 리턴하는 것 불가능하기 때문에 그렇다. 그러므로 후자의 방법을 도와줄 코드를 제공한다.
/* 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;
}
- 이 함수들 각각은 유저 주소가 KERN_BASE 아래에 존재한다는 게 검증되었다고 가정하고 있다. 또한 이 함수들은 여러분이 page_fault()를 수정했고, 그 결과로 커널의 page fault는 단지 rax를 -1로 설정하고, 이의 이전 값을 %rip로 복사한다고 가정한다.
Argument Passing
- process_exec() 함수에 있는 ‘유저 프로그램’을 위한 인자를 세팅한다.
x86-64 Calling Convention
- 호출 규약은 다음과 같다.
- 유저 - 레벨 어플리케이션은 %rdi, %rsi, %rdx, %rcx, %r8, %r9 시퀀스들을 전달하기 위해 정수 레지스터를 사용한다.
- 호출자는 다음 인스트럭션의 주소(리턴 어드레스)를 스택에 푸시하고, 피호출자의 첫 번째 인스트럭션으로 점프한다. CALL이라는 x86-64 인스트럭션 하나가 이 두 가지를 모두 수행한다.
- 피호출자가 실행된다.
- 만약 피호출자가 리턴 값을 가지고 있다면, 리턴 값은 레지스터 RAX에 저장된다.
- 피호출자는 x86-64 인스트럭션인 RET(리턴) 를 사용해서, 스택에 받았던 리턴 어드레스를 pop하고 그 주소가 가리키는 곳으로 점프함으로써 리턴된다.
- 세 걔의 정수 인자를 받는 함수 f()가 있다고 생각해보자.
- 아래 도식은 위의 3번 항목에 있는 피호출자가 실행되는 시점에, 스택 프레임과 레지스터 상태가 어떤 식으로 되어있는지에 대한 예시를 보여준다. f()가 f(1, 2, 3)으로 호출되었다고 가정한다.
+----------------+
stack pointer --> 0x4747fe70 | return address |
+----------------+
RDI: 0x0000000000000001 | RSI: 0x0000000000000002 | RDX: 0x0000000000000003
Program Startup Details
- 유저 프로그램을 위한 PintOS C 라이브러리는 _start() 함수를 유저 프로그램의 시작 포인트로 지정한다. _start()는 lib/user/entry.c 에 있다.
- 여기서 _start() 는 main() 함수를 감싸고 있는 함수이다. main() 은 리턴되면서 exit()을 호출한다.
void
_start (int argc, char *argv[]) {
exit (main (argc, argv));
}
- 커널은 유저 프로그램이 실행되는 것을 허가하기 전에, 레지스터에 올라가 있는 초기 함수를 위한 인자를 반드시 넣어줘야 한다.
- 이 인자들은 일반적인 호출 규약과 동일한 방식으로 전달된다.
- /bin/ls -l foo bar 와 같은 명령이 주어졌을 때, 인자들을 어떻게 다뤄야 하는지 생각해보자.
- 명령을 단어들로 쪼갠다. /bin/ls, l, foo, bar
- 이 단어들을 스택의 맨 처음 부분에 놓는다. 순서는 상관이 없다. 왜냐하면 포인터에 의해 참조될 예정이기 때문이다.
- 각 문자열의 주소 + 경계조건을 위한 널 포인터를 스택에 오른쪽 → 왼쪽 순서로 푸시한다. 이들은 argv의 원소가 된다. 널포인터의 경계는 argv[argc] 가 널포인터 라는 사실을 보장해준다. 그리고 이 순서는 argv[0]이 가장 낮은 가상 주소를 가진다는 사실을 보장해준다. 또한 word 크기에 정렬된 접근이 정렬되지 않은 접근보다 빠르므로, 최고의 성능을 위해서는 스택에 첫 푸시가 발생하기 전에 스택포인터를 8의 배수로 반올림하여야 한다.
- %rsi 가 argv 주소(argv[0]의 주소)를 가리키게 하고, %rdi를 argc로 설정한다.
- 마지막으로 가짜 리턴 어드레스를 푸시한다. : entry 함수는 절대 리턴되지 않겠지만, 해당 스택 프레임은 다른 스택 프레임들과 같은 구조를 가져야 한다.
- 아래의 표는 스택과 관련 레지스터들이 유저 프로그램이 시작되기 직전에 어떤 상태인지를 보여준다.
- 스택은 아래 방향으로 커진다는 사실을 알아두자.
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 (*) () |
RDI: 4 | RSI: 0x4747ffc0
- 이 예시에서, 스택 포인터는 위에 보이는 것처럼 0x0x4747ffb8로 초기화될 것이고, 당신의 코드는 include/threads/vaddr.h에 정의된 USER_STACK 값에서부터 스택을 시작시켜야 한다.
- 당신은 <stdio.h>에 선언된 비표준 함수인 hex_dump()을 발견하게 될 것이다. 이는 당신의 인자 전달 코드를 디버깅 하는데에 유용할 것이다.
Implement the argument passing
- 현재, process_exec() 함수는 새로운 프로세스들에 인자를 전달하는 것을 지원하지 않는다.
- process_exec() 함수를 확장 구현해서, 지금처럼 단순히 프로그램 파일 이름만을 인자로 받아오게 하는 대신 공백을 기준으로 여러 단어로 나누어지게 만들어라.
- 첫 번째 단어는 프로그램 이름이고, 두 번째 단어는 첫 번째 인자이며, 그런 식으로 계속 이어지게 만들면 된다.
- 따라서, 함수 process_exec(”grep foo bar”)는 두 개의 인자 foo와 bar을 받아서 grep 프로그램을 실행시켜야 한다.
- 커맨드라인에서, 여러개의 공백은 하나의 공백과 같게 처리해야 한다. 그러므로 process_exec(”grep foo bar”)는 위의 예시와 동일하게 동작해야 한다.
- 또한 당신은 납득할만한 수준에서 커맨드라인 인자들의 길이 제한을 강요할 수 있다. 예를 들면, 인자들이 한 페이지 크기(4kb)안에 들어가게끔 제한하는 것이다.
- 인자로 들어온 문자열은 당신이 원하는대로 파싱하면 된다.
- 만약 어떻게 해야할 지 모르겠다면, include/lib/string.h 에 있는 strtok_r() 함수와, lib/string.c 에 구현된 것들을 참고해보세요.
- man 페이지를 보면 더 많은 정보를 볼 수 있다.
User Memory Access
Implement user memory access
- 유저 메모리 접근을 구현해라
- 시스템 콜 기능을 구현하기 위해, 유저 가상 주소 공간에 데이터를 읽고 쓰기 위한 방법을 제공해야 한다. 인자를 받을 때는 이 방법이 필요하지 않지만, 시스템 콜의 인자로 들어온 포인터로부터 데이터를 읽어야 할 때라면 이 방법을 통해 해야 한다. 이는 다소 까다로울 수 있다.
- 만약에 유저가 유효하지 않은 포인터, 예를 들면 커널 메모리를 가리키는포인터라던가, 아니면 블록의 일부가 이런 영역(region)들 중 하나인 블록을 가리키는 포인터이면 어떻게 해야 할까? 이러한 상황들이 발생하면 유저 프로세스를 종료시킴으로써 대응할 수 있어야 한다.
System Calls
Implement system call infrasturcture
- userprog/syscall.c 안에 시스템 콜 핸들러를 구현해라. 우리가 제공한 최소한의 기능은 프로세스를 종료 시키므로써 시스템 콜을 다룬다. 여러분이 구현하는 시스템 콜 핸들러는 시스템 콜 번호를 받아오고, 어떤 시스템 콜 인자들을 받아오고, 그에 알맞은 액션을 취해야 한다.
System Call Details
- 첫 프로젝트에서 이미 운영체제가 유저 프로그램에게서 제어권을 되찾아 갈 수 있는 방법을 다뤘다. : 타이머와 I/O 디바이스들로 부터의 인터럽트들 = CPU 외부의 독립적인 장치가 야기하는 외부(external) 인터럽트들 이다.
- 운영체제는 프로그램 코드에 발생하는 이벤트인 sw exception도 다룬다. page fault나 division by zero 같은 에러들이 이런 것들 이다. 또한 sw exception은 유저 프로그램이 시스템콜이라는 서비스를 운영체제에게 요청하는 수단이다.
- exception은 두 가지
-
- external device로 부터의 hw interrupt = interrupt
-
- user로 부터의 sw exception = fault, trap, abort
-
- exception은 두 가지
- 전통적인 x86 아키텍쳐에서 시스템 콜은 다른 sw exception과 동일하게 다뤄진다. 하지만 x86-64에서는 제조사가 syscall 이라는 시스템 콜을 위한 특별한 명령어를 제공한다. 이 명령어는 시스템 콜 핸들러를 호출하는 빠른 방법을 제공한다.
- 요즘엔 syscall 명령어가 x86-64에서 시스템 콜을 불러올 때 가장 흔하게 사용되는 수단이다. Pintos에서 유저 프로그램은 시스템 콜을 만들기 위해서 syscall를 불러온다. syscall 명령어를 불러오기전에 시스템 콜 번호와 추가적인 인자는 레지스터에 일반적인 방법으로 설정된다. 이때 일반적이지 않은 점 두 가지가 있다.
- %rax는 시스템 콜 번호이다.
- 4번째 인자는 %r10이다. %rcx가 아니다.
- 그러므로 시스템 콜 핸들러 syscall_handler() 가 제어권을 얻으면 시스템 콜 번호는 rax에 있고, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9 순서로 전달된다.
- 시스템 콜 핸들러를 호출한 콜러의 레지스터는 전달받은 struct_intr_frame 에 접근할 수 있다.(struct_intr_frame 은 커널 스택에 있다.)
- 함수 리턴 값을 위한 x86-64의 관례는 그 값을 rax 레지스터에 넣는 것이다. 값을 리턴하는 시스템 콜도 struct_intr_frame의 rax 멤버를 수정하는 식으로 이 관례를 따를 수 있다.
Implement the following system calls
- include/lib/user/syscall.h 을 포함하는 유저 프로그램이 보게되는 시스템 콜들의 프로토 타입 목록이다. (이 헤더파일과 include/lib/user 에 있는 모든건 유저 프로그램만 사용한다.) 각 시스템 콜의 시스템 콜 번호는 include/lib/syscall-nr.h 에 정의 되어 있다.
void halt (void);
- power_off()를 호출해서 Pintos를 종료한다.(power_off()는 src/include/threads/init.h에 선언되어 있다.) 이 함수는 웬만하면 사용되지 않아야 한다. deadlock 상황에 대한 정보 등등 뭔가 조금 잃어 버릴지도 모른다.
void exit (int status);
- 현재 동작중인 유저 프로그램을 종료한다. 커널에 상태를 리턴하면서 종료한다. 만약 부모 프로세스가 현재 유저 프로그램의 종료를 기다리던 중이라면, 그 말은 종료되면서 리턴될 그 상태를 기다린다는 것 입니다. 관례적으로, 상태 = 0 은 성공을 뜻하고 0 이 아닌 값들은 에러를 뜻한다.
pid_t fork (const char *thread_name);
- THREAD_NAME 이라는 이름을 가진 현재 프로세스의 복제본인 새 프로세스를 만든다. 피호출자(callee) 저장 레지스터인 %RBX, %RSP, %R12 - %R15를 제외한 레지스터 값을 복제할 필요가 없다. 자식 프로세스의 PID를 반환해야 한다. 그렇지 않으면 유효한 PID가 아닐 수 있다. 자식 프로세스에서 반환 값은 0이어야 한다. 자식 프로세스에는 파일 식별자 및 가상 메모리 공간을 포함한 복제된 리소스가 있어야 한다. 부모 프로세스는 자식 프로세스가 성공적으로 복제되었는지 여부를 알 때까지 fork에서 반환해서는 안된다. 즉, 자식 프로세스가 리소스를 복제하지 못하면 부모의 fork() 호출이 TID_ERROR를 반환할 것이다.
- 템플릿은 threads/mmu.c 의 pm14_for_each를 사용하여 해당되는 페이지 테이블 구조를 포함한 전체 사용자 메모리 공간을 복사하지만, 전달된 pte_for_each_func의 누락된 부분을 채워야 한다.
int exec (const char *cmd_line);
- 현재의 프로세스가 cmd_line 에서 이름이 주어지는 실행 가능한 프로세스로 변경된다. 이때 주어진 인자들을 전달한다. 성공적으로 진행된다면 어떤 것도 반환하지 않는다. 만약 프로그램이 이 프로세스를 로드하지 못하거나 다른 이유로 돌리지 못하게 되면 exit state -1을 반환하며 프로세스가 종료됩니다. 이 함수는 exec 함수를 호출한 쓰레드의 이름은 바꾸지 않는다. file descriptor는 exec 함수 호출 시에 열린 상태로 있다는 것을 알아둬라.
int wait (pid_t pid);
- 자식 프로세스(pid)를 기다려서 자식의 종료 상태(exit status)를 가져온다. 만약 pid(자식 프로세스)가 아직 살아있으면, 종료 될 때 까지 기다린다. 종료가 되면 그 프로세스가 exit 함수로 전달해준 상태(exit status)를 반환한다.
- 만약 pid(자식 프로세스)가 exit() 함수를 호출하지 않고 커널에 의해서 종료된다면, wait(pid)는 -1을 반환해야 한다.
- 부모 프로세스가 wait 함수를 호출한 시점에서 이미 종료되어버린 자식 프로세스를 기다리도록 하는 것은 완전히 합당하지만, 커널은 부모 프로세스에게 자식의 종료 상태를 알려주든지, 커널에 의해 종료되었다는 사실을 알려주든지 해야 한다.
- 다음의 조건들 중 하나라도 참이면 wait은 즉시 fail하고 -1을 반환한다.
- pid는 호출하는 프로세스의 직속 자식을 참조하지 않는다. 오직 호출하는 프로세스가 fork() 호출 후 성공적으로 pid를 반환받은 경우에만, pid는 호출하는 프로세스의 직속 자식이다. 자식들은 상속되지 않는다. 만약 A가 B를 낳고 B가 C를 낳는다면 A는 C를 기다릴 수 없다. 프로세스 A가 wait(C)를 호출하는 것은 실패해야 한다. 마찬가지로, 부모 프로세스가 먼저 종료되버리는 고아 프로세스들도 새로운 부모에게 할당되지 않는다.
- wait를 호출한 프로세스가 이미 pid에 대해 기다리는 wait을 호출한 상태일 때 이다. 즉, 한 프로세스는 어떤 주어진 자식에 대해서 최대 한번만 wait을 할 수 있다.
- 프로세스들은 자식을 얼마든지 낳을 수 있고 그 자식들을 어떤 순서로도 기다릴 수 있다. 자식 몇 개로부터의 신호는 기다리지 않고도 종료될 수 있다.(전부를 기다리지 않기도 한다.) 여러분의 설계는 발생할 수 있는 기다림의 모든 경우를 고려해야 한다. 한 프로세스 자원들은 꼭 할당 해제 되어야 한다.
- 부모가 그 프로세스를 기다리든 아니든, 자식이 부모보다 먼저 종료되든 나중에 종료되든 상관없이 이뤄져야 한다.
- 최초의 process가 종료되기 전에 pintos가 종료되지 않도록 해라
- 제공된 pintos 코드는 main()에서 process_wait()를 호출하여 pintos가 최초의 process보다 먼저 종료되는 것을 막으려고 시도한다.
- 여러분은 함수 설명의 제일 위의 코멘트를 따라서 process_wait()를 구현하고 process_wait() 의 방식으로 wait system call을 구현해야 한다.
- 이 시스템 콜을 구현하는 것이 다른 어떤 시스템 콜을 구현하는 것보다 더 많은 작업을 요구한다.
bool create (const char *file, unsigned initial_size);
- file(첫 번째 인자)를 이름으로 하고 크기가 initial_size(두 번째 인자)인 새로운 파일을 생성한다. 성공적으로 파일이 생성되었다면 true를 반환하고, 실패했다면 false를 반환한다. 새로운 파일을 생성하는 것이 그 파일을 여는 것을 의미하지는 않는다. 파일을 여는 것은 open 시스템 콜의 역할로, ‘생성’과 개별적인 연산이다.
bool remove (const char *file);
- file(첫 번째 인자)이라는 이름을 가진 파일을 삭제한다. 성공적으로 삭제했다면 true를 반환하고, 그렇지 않으면 false를 반환한다. 파일은 열려있는지 닫혀있는지 여부와 관계없이 삭제될 수 있고, 파일을 삭제하는 것이 그 파일을 닫았다는 것을 의미하지는 않는다.
int open (const char *file);
- file(첫 번째 인자)이라는 이름을 가진 파일을 연다. 해당 파일이 성공적으로 열렸다면, 파일 식별자로 불리는 비음수 정수(0또는 양수)를 반환하고, 실패했다면 -1을 반환한다. 0번 파일식별자와 1번 파일식별자는 이미 역할이 지정되어 있다. 0번은 표준 입력(STDIN_FILENO)를 의미하고 1번은 표준 출력(STDOUT_FILENO)을 의미한다.
- open 시스템 콜은 아래에서 명시적으로 설명하는 것처럼 시스템 콜 인자로서만 유효한 파일 식별자들을 반환하지는 않는다. 각각의 프로세스는 독립적인 파일 식별자드르을 갖는다. 파일 식별자는 자식 프로세스들에게 상속된다. 하나의 프로세스에 의해서든 다른 여러개의 프로세스에 의해서든, 하나의 파일이 두 번 이상 열리면 그때마다 open 시스템콜은 새로운 식별자를 반환한다.
- 하나의 파일을 위한 서로 다른 파일 식별자들은 개별적인 close 호출에 의해서 독립적으로 닫히고 그 한 파일의 위치를 공유하지 않는다. 당신이 추가적인 작업을 하기 위해서는 open 시스템 콜이 반환하는 정수(fd)가 0보다 크거나 같아야 한다는 리눅스 체계를 따라야 한다.
int filesize (int fd);
- fd(첫 번째 인자)로서 열려 있는 파일의 크기가 몇 바이트인지 반환한다.
int read (int fd, void *buffer, unsigned size);
- buffer 안에 fd로 열려있는 파일로부터 size 바이트를 읽는다. 실제로 읽어낸 바이트의 수를 반환한다.(파일 끝에서 시도하면 0) 파일이 읽어질 수 없었다면 -1을 반환 한다.(파일 끝이 아니라 다른 조건 때문에 못 읽은 경우이다)
int write (int fd, const void *buffer, unsigned size);
- buffer로부터 open file fd로 size 바이트를 적어준다. 실제로 적힌 바이트의 수를 반환해주고, 일부 바이트가 적히지 못했다면 size보다 더 작은 바이트 수가 반환될 수 있다. 이 파일의 끝을 넘어서 작성하는 것은 보통 파일을 확장하는 것이지만, 파일 확장은 basic file system에 의해서는 불가능하다.
- 이로 인해 파일의 끝까지 최대한 많은 바이트를 적어주고 실제 적힌 수를 반환하거나, 더 이상 바이트를 적을 수 없다면 0을 반환한다. fd 1은 콘솔에 적어준다. 콘솔에 작성한 코드가 적어도 몇 백 바이트를 넘지 않는 사이즈라면, 한번의 호출에 있는 모든 버퍼 putbuf()에 적어주는 것이다.(더 큰 버퍼는 분해하는 것이 합리적이다)
- 그렇지 않다면, 다른 프로세스에 의해 텍스트 출력 라인들이 콘솔에 끼게 되고, 읽는 사람과 우리 채점 스크립트가 헷갈릴 것이다.
void seek (int fd, unsigned position);
- open file fd에서 읽거나 쓸 다음 바이트를 position으로 변경한다. position은 파일 시작부터 바이트 단위로 표시된다.( 따라서 position 0은 파일의 시작을 의미한다.) 이후에 read를 실행하면 파일의 끝을 가리키는 0바이트를 얻는다. 이후에 write를 실행하면 파일이 확장되어 기록되지 않은 공백이 0으로 채워진다.(하지만 Pintos에서 파일은 프로젝트 4가 끝나기 전까지 길이가 고정되어 있기 때문에 파일의 끝을 넘어서 작성하려고 하면 오류를 반활할 것이다. 이러한 의미론은 filesystem안에서 구현되며 system call을 구현할 때는 특별히 노력할 필요는 없다.
unsigned tell (int fd);
- 열려진 파일 fd에서 읽히거나 써질 다음 바이트의 위치를 반환한다. 파일의 시작지점 부터 몇 바이트인지로 표현된다.
void close (int fd);
- 파일 식별자 fd를 닫는다. 프로세스를 나가거나 종료하는 것은 묵시적으로 그 프로세스의 열려있는 파일 식별자들을 닫는다. 마치 각 파일 식별자에 대해 이 함수가 호출 되는 것과 같다.
- 파일은 다른 시스템 콜들을 정의한다 지금으로서는 이러한 시스템 콜들은 무시해라. 당신이 추후에 프로젝트3,4 에서 구현하게 될테니까 확장성을 고려하면서 시스템을 설계해라
- 당신은 시스템 콜을 동기화하여 여러개의 유저 프로세스가 한 번에 여러 개의 시스템 콜을 호출할 수 있도록 해야 한다.. 특히 filesys 디렉토리에 있는 파일 시스템 코드를 여러 스레드가 한번에 호출하는 것은 안전하지 않다. 시스템 콜을 구현하는 부분에서 파일 시스템 코드는 임계 영역으로 처리해야 한다. process_exec() 또한 파일들에 접근한다는 것을 잊어선 안된다. 일단 지금은 filesys 디렉토리에 있는 코드를 수정하지 않는 것을 추천한다.
- 각 시스템 콜에 대한 user-level 함수를 lib/user/syscall.c 파일에서 제공하고 있다. 이 함수들은 C프로그램에서 유저 프로세스들이 각 시스템 콜을 호출하는 방법을 제공한다. 각각의 함수들은 시스템 콜을 호출하기 위한 짧은 어셈블리 코드를 사용하고 있고, 제대로 시스템 콜이 호출되었을 경우 반환 값을 리턴한다.
Process Termination Message
- 프로세스 종료 메세지를 출력해라.
printf ("%s: exit(%d)\\n", ...);
- exit 함수를 호출 했거나 다른 어떤 이유들로 유저 프로세스가 종료될 때 마다 프로세스의 이름과 exit코드를 출력한다. 마치 위 printf 문을 통해 출력된 것과 같은 형식으로 나온다.
- 출력되는 프로세스의 이름은 fork()에 전달되는 이름 전체여야 한다. 유저 프로세스가 아닌 커널 쓰레드가 프로세스를 종료하는 상황이거나, halt 시스템 콜이 호출된 상황이라면 이 메세지를 출력하지 마라. 프로세스가 load되는데 실패한 경우라면 메세지를 출력하고 말고는 선택이다.
- 이 외에도, Pintos가 아직 인쇄하지 않은 다른 메세지를 인쇄하지 말아라. 디버깅에 유용한 다른 메세지들을 생각해낼 수도 있겠지만 이 메세지들이 grading 스크립트를 혼동시켜 여러분의 점수를 낮게 만들 수도 있다.
Deny Write on Executables
- 실행 파일에 쓰기를 거부하라
- 실행중인 파일, 즉 실행 파일에 쓰기를 거부하는 코드를 작성하라.
- 어떤 코드가 디스크에서 수정되고 있는 도중에 프로세스가 그 코드를 실행하려고 시도하면 예상치 못한 결과를 낳을 수 있기 때문에, 많은 OS들이 이를 방지한다.
- 이는 프로젝트 3에서 가상 메모리가 구현된 이후에 특히 중요해지는 부분이지만, 그렇다고 해서 지금 코드가 손상되어도 괜찮다는 말은 아니다.
- 열려있는 파일에 쓰기를 방지하려면 file_deny_write() 함수를 사용하면 된다.
- 그리고 file_allow_write()를 파일 안에서 호출하면 다시 쓰기가 가능해지도록 만들 수 있다.(이 파일을 여는 또 다른 무언가에 의해 쓰기가 거부되지만 않는다면). 그리고 파일을 닫아도 다시 쓰기가 가능해지게 된다. 그러므로, 프로세스의 실행 파일에 쓰기를 계속 거부하려면, 프로세스가 돌아가는 동안에는 실행 파일이 쭉 열려 있게끔 해야 한다.
Extend File Descriptor(Extra)
- 여러분의 핀토스가 리눅스의 stdin, stdout를 닫는 기능과 dup2 시스템 콜을 지원하도록 만들어라.
- 현재 구현된 핀토스에서는 stdin과 stdout 파일 디스크립터를 닫는 것이 금지되어있다. 이번 여분 과제에서 여러분의 핀토스가 리눅스처럼 유저가 stdin과 stdout을 닫을 수 있도록 해봐라. 즉, 프로세스가 stdin를 닫으면 절대 input을 읽을 수 없고, stdout을 닫으면 어떤 것도 출력할 수 없게 만들어라.
- 다음으로는, dup2 시스템 콜을 구현해보아라.
int dup2(int oldfd, int newfd);
- dup2() 시스템 콜은 인자로 받은 oldfd 파일 디스크립터의 복사본을 생성하고, 이 복사본의 파일디스크립터 값은 인자로 받은 newfd 값이 되게 한다. dup() 시스템 콜이 파일 디스크립터를 복사해서 새 파일 디스크립터를 생성하는 데 성공한다면 newfd를 리턴한다. 만약 newfd 값이 이전에 이미 열려있었다면, newf는 재사용되기 전에 조용히 닫힌다.
- 아래 사항을 기억하라
- 만약 oldfd가 유효한 파일 디스크립터가 아니라면, dup2() 콜은 실패하여 1을 반환하고, newfd는 닫히지 않는다.
- 만약 oldfd가 유효한 파일 디스크립터이고, newfd는 oldfd와 같은 값을 가지고 있다면 dup2()가 할일은 따로 없고(이미 같기 때문에)newfd값을 리턴한다.
- 이 시스템콜로부터 성공적으로 값을 반환받은 후에, oldfd와 newfd는 호환해서 사용이 가능하다. 이 둘은 서로 다른 파일 디스크립터이긴 하지만, 똑같은 열린 파일 디스크립터를 의미하기 때문에 같은 file offset과 status flags를 공유하고 있다. 예를 들어 만약 다른 디스크립터가 seek을 사용해서 file offset이 수정되었다면, 다른 스크립터에서도 이 값은 똑같이 수정된다.
- dup된 파일은 forking 이후에 자신의 semantic을 보존하고 있어야 한다는 것을 기억하라.
- 엑스트라 포인트를 얻으려면 EXTRA에 대한 모든 테스트를 통과해야 한다.
참조
https://yjohdev.notion.site/KAIST-PINTOS-ebdc8be9d02d4475a4675c7b920e3653
KAIST PINTOS 과제 설명서 | Notion
프로젝트가 진행되면서 점점 과제설명서 스압이 심해져서 페이지로 분리했습니다! 만약 바꾸기 전 상태가 더 편하다면 댓글로 알려주세요!
yjohdev.notion.site
728x90
'Study > PintOS' 카테고리의 다른 글
Project3. Virtual Memory (0) | 2024.04.03 |
---|---|
Project2. User Program (2) | 2024.03.21 |
Project1. Threads (4) | 2024.03.12 |
Project 1. 주의 깊게 봐야 할 함수들 (0) | 2024.03.03 |