PintOS 제작기 - 2
PintOS
서론
PintOS 중 우리는 카이스트에서 개발한 운영체제를 직접 만들어본다. 이는 주차들로 나뉘어 각각의 프로젝트를 만들게 된다.
Project 1 - 서론
Project 1에서는 Base kernel의 Source Code를 제작하게 된다. 이는 threads directory에 있다.
또한, I/O device interfacing 과정을 진행하게 되는데, 이는 devices directory에 있다.
Project 2 - 서론
Project 2 에서는 User Program Loader를 제작하게 된다. 이는 userprog directory에 있다.
lib directory에서 standard C library의 일부를 implement 하게 된다.
Project 3 - 서론
Project 3 에서는 Virtual Memory를 구현하게 된다. 이는 vm directory에 있다.
Project 4 - 서론
Project 4 에서는 basic file system을 구현하게 된다. 이는 filesys directory에 있다.
이는 사실 project 2에서 실제로 처음 사용하게 되지만, 내부를 수정하는 것은 Project 4에 와서 진행한다.
본론
Project 2
pintos의 구조와 쓰레드 패키지에 친숙해졌다면, 이제는 유저 프로그램을 실행하는 부분을 작업한다. 유저 프로그램을 로드하고 실행하는 기본 코드는 존재하지만, 이는 I/O 및 상호작용이 가능하도록 작성되어 있지 않다. 이번 과제는 userprog
디렉토리 외부에서도 작업하게 될 것이며, pintos의 다른 부분들 전체와 상호작용하게 된다. 이는 하단에 조금 더 자세하게 작성될 것이다.
주의: 프로젝트 1에서 완성한 코드에서 이어서 작성해야 한다.
또한, TODO가 주석에 명시되어 있지 않아도 수정하는 것 역시 가능하다. 테스트 코드만 그대로 유지되면 된다.
배경
지금까지 Pintos에서 실행된 모든 코드는 OS 커널의 한 부분이었다. 예시로, 지난 과제에서 제출한 코드들은 커널의 일부로써, 시스템 내의 중요한 부분에 접근 권한을 가지는 특권을 지닌 상태로 실행되었다. 유저 프로그램은 더이상 그런 특권을 가지고 진행될 수 없다. 이번 프로젝트는 이런 상황을 다뤄야 한다.
우리는 한번에 하나 이상의 프로그램이 실행될 수 있도록 해야 한다. 각각의 프로세스들은 하나의 쓰레드를 가진다(다시 말해 멀티쓰레드 프로세스는 지원하지 않는다). 유저 프로그램들은 자신들이 컴퓨터 전체를 소유한다는 환상 안에서 실행되며, 우리가 한번에 여러 프로세스들을 동시에 로드하고 실행한다면, 우리는 메모리, 스케쥴링, 그 외의 다른 상태들이 프로세스가 가진 이 환상을 만족시키는 방향으로 관리해야 함을 의미한다.
이전 프로젝트에서, 우리는 테스트 코드를 커널에 바로 컴파일하였기에, 커널 안에서 상호작용하는 여러 함수들을 필요로 하였다. 이제부터 우리는 유저 인터페이스가 이 문서에 적힌 규칙(스펙)을 만족시키도록 작성해야 한다. 이 규칙만 지킨다면 원하는대로 커널 코드의 구조를 바꾸거나 다시 짜는것 역시 상관없다. 우리의 코드는 절대로 #ifdef VM
으로 감싸진 코드 블록 내에 위치해서는 안된다. 이 부분은 가상메모리의 하부 시스템을 가능하게 한(Project 3에서 진행될 내용) 이후 포함될 것이기 때문이다. 또한, 코드가 위 명령어에 감싸져 있다면, 이 코드들은 Project 3에서는 제외될 것이다.
Source Files
가장 쉽게 앞으로 해야할 프로그래밍 작업의 개요를 보려면, 앞으로 작업할 부분들을 훑어보도록 해라. userprog
디렉토리에는 파일이 많지 않지만, 여기서 많은 일들을 진행하여야 한다.
대부분의 .c 파일은 userprog에, .h 파일들은 include/userprog 내에 존재한다.
process.c
,process.h
ELF 바이너리들을 로드하고 프로세스를 실행하는 파일.syscall.c
,syscall.h
유저 프로세스가 일부 커널 기능에 접근하려고 할 때마다 시스템 콜이 호출된다. 이것이 시스템 콜 핸들러의 기본 구조이다. 현재 상태에서는 단지 메세지를 출력하고, 유저 프로세스를 종료시키도록 되어있다. 이번 프로젝트의 part2에서 시스템 콜이 필요로 하는 다른 일을 수행하는 코드를 수행하게 될 것이다.syscall-entry.S
시스템 콜 핸들러를 부트스트랩하는 어셈블리 코드이며, 이를 이해할 필요는 없다.exception.c
,exception.h
유저 프로그램이 특권이 필요한 연산이나 금지된 연산을 수행할 때, 예외(exception)나 오류(fault)가 발생하면 커널 내로 트랩(trap)된다. 이 파일들은 이런 예외 상황을 처리하는 코드가 담겨있다. 프로젝트 2의 일부 해결책은 이 파일 내의page_falut()
를 수정해야 한다.gdt.c
,gdt.h
x86-64 아키텍처는 세그먼트(segmented) 아키텍처를 사용한다. Global Descriptor Table(GDT)은 현재 사용 중인 세그먼트를 정의하는 표로, 이 파일들은 GDT를 설정하는 데 사용된다. 다른 프로젝트에서는 이 파일들을 수정할 필요가 없다.-
tss.c
,tss.h
Task-State Segment(TSS)는 x86 아키텍처에서 문맥 교환(context switch)에 사용되는 구조체이다. x86-64에서는 문맥 교환을 더 이상 TSS로 지원하지 않지만, 여전히 ring switching 동안 스택 포인터를 찾아내는 데 사용된다.이는 유저 프로세스가 인터럽트 핸들러에 진입할 때, 하드웨어는 TSS에게 커널의 스택 포인터를 찾아달라고 요청한다. TSS의 동작방식이 궁금하다면 읽어볼 것.
파일 시스템 사용하기
이번 프로젝트에서는 파일 시스템 코드와 인터페이스해야 한다. 유저 프로그램이 파일 시스템에서 로드되고, 우리는 구현해야 할 시스템 콜들이 파일 시스템을 다루기 때문이다. 하지만 이번 프로젝트의 주된 목표는 파일 시스템이 아니기 때문에, 간단하지만 완전한 파일 시스템을 filesys
디렉토리에 제공하고 있어다 파일 시스템을 사용하는 방법과 파일 시스템의 제약 사항들을 이해하고 싶다면 filesys.h
와 file.h
인터페이스를 확인해보면 된다.
파일 시스템 코드를 수정할 필요는 없고, 수정하지 않는 것을 추천한다. 파일 시스템에 집중하지 말고, 이번 프로젝트의 핵심 과제에 집중하도록 하자.
파일 시스템 루틴을 제대로 사용하는 것은 Project 4를 상당히 쉽게 만들어 줄 것이다. 프로젝트 4에서는 file system implementation을 향상시킨다. 그때까지, 다음과 같은 제한사항을 따라야 한다.
파일 시스템의 제한 사항
내부 동기화는 하지 마라: 동시 접근은 서로 방해가 될 것이다. 동기화는 한 번에 하나의 프로세스만이 파일 시스템 코드를 실행하게 보장하는 용도로만 사용해라.
파일 크기는 고정됨: 파일이 생성될 때 크기가 고정되며, 루트 디렉토리가 파일이기 때문에 생성할 수 있는 파일의 개수도 제한된다.
단일 연속 할당(single extent) 방식: 파일의 데이터는 디스크 섹터를 연속적으로 차지해야 하므로, 사용 시간이 길어질수록 외부 단편화가 문제가 될 수 있다.
하위 디렉토리 생성 금지: 하위 디렉토리를 만들 수 없다.
파일 이름 길이 제한: 파일 이름은 최대 14자까지 허용된다.
시스템 크래시 시 복구 불가: 시스템 크래시 중에 연산이 발생하면 자동으로 복구되지 않는 방식으로 디스크가 손상될 수 있다. 파일 시스템 복구 도구는 제공되지 않는다.
One important feature is included:
filesys_remove()
함수는 유닉스 계열의 의미론을 따른다. 즉, 삭제된 파일이더라도 그 파일을 열었던 스레드들이 닫아주지 않으면 블록이 할당 해제되지 않는다. 따라서 다른 스레드들이 해당 파일에 계속 접근할 수 있다. 더 자세한 내용은 ‘Removing an Open File’ 부분을 참고하자.
모든 테스트 프로그램이 커널 이미지로 존재했던 project 1과는 달리, 우리는 반드시 테스트 프로그램(유저 공간에서 작동되는)을 Pintos 가상 공간에 넣어줘야 한다. 과제를 조금 쉽게 하기 위해, make check
와 같은 테스팅 스크립트가 이를 자동으로 실행한다. 이를 이해하면 테스트 케이스를 돌릴 때 도움이 되겠지만, 반드시 필요한 것은 아니다.
Pintos 가상 머신에 파일을 넣기 위해서, 우리는 먼저 파일 시스템 파티션이 있는 모의 디스크를 만들 수 있어야 한다. pintos-mkdisk 프로그램은 이 기능을 제공한다. userprog/build
directory에서 pintos-mkdisk filesys.dsk 2
를 실행한다. 이 명령어는 2MB 크기의 pintos 파일 시스템 하나를 포함하는 filesys.dsk
라는 모의 디스크(simulated disk)를 만든다. 그리고 Pintos 명령어 뒤에 --fs-disk filesys.dsk
를 적어줌으로써 어떤 디스크인지 지정한다. --fs-disk
는 모의 커널이 아니라 pintos script를 위한 옵션이기 때문에 --
를 적어줘야 한다. 그 이후에 커맨드 라인에 -f -q
옵션을 적어, 파일 시스템 파티션을 포맷팅한다. 파일 시스템의 포맷팅을 위해 -f를, 포맷팅이 끝나면 핀토스가 끝나도록 하기 위해 -q를 적는다.
가상 파일 시스템 안밖으로 파일을 저장하는 방법이 필요할 것이다. Pintos의 -p
(저장)와 -g
(가져오기) 옵션이 이러한 기능을 담당한다. Pintos 파일 시스템에 파일을 복사하기 위해서는 pintos -p file -- -q
라는 명령어를 사용하면 된다. 복사할 파일의 이름을 지정하고 싶다면 우너본 파일 이름 바로 뒤에 :newname
을 추가하면 된다. 가상 머신에서 파일을 복사해서 가져오기 위한 명령어는 -p
를 -g
로만 바꾸면 된다.
이러한 커맨드들은 커널의 커맨드 라인에 추출 및 추가하는 특별한 커맨드를 전달하고, 특별히 시뮬레이션된 “scratch” 파티션에 복사하고, 이 파티션으로부터 복사해 오는 방식으로 작동한다. 이는 filesys/fsutil.c
를 살펴보고 구현 세부정보를 배울 수 있다.
파일 시스템 내에서 file system partition이 있는 disk를 만들고, file system을 포맷하고, 새로운 디스크에 args-single 프로그램(Project 2의 두번째 테스트 케이스)을 복사하고 실행하여, ‘onearg’ 인자를 전달한다.(인자를 전달하는 과정은 우리가 구현하기 전까지 작동하지 않는다.) 이는 우리가 이미 테스트 케이스를 만들었으며, 현재 디렉토리는 userprog/build
라고 가정한다.
pintos-mkdisk filesys.dsk 10
pintos --fs-disk filesys.dsk -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
만약 파일 시스템 디스크가 나중을 위해서 사용되거나 검사하는 것을 원치 않는다면, 우리는 네 단계를 하나의 커맨드로 통합시킬 수 있다. --filesys-size=n
옵션은 대략적으로 n MB 사이즈의 임시 파일 시스템 파티션을 Pintos가 도는 동안에만 구성한다. Pintos의 자동화된 테스트는 이 구문을 대다수 사용한다.
pintos --fs-disk=10 -p tests/userprog/args-single:args-single -- -q -f run 'args-single onearg'
How User Programs Work
Pintos는 메모리에 안에 들어올 수 있으며 우리가 구현한 system call만 사용한다면 일반적인 C 프로그램을 돌릴 수 있다. 단, malloc()
은 이 프로젝트의 모든 시스템 콜이 메모리 할당을 허용하지 않기 때문에 구현될 수 없다. Pintos는 또한 부동소수점 연산을 할 수 없다. 커널이 쓰레드 전환에서 프로세서의 소수점을 저장하고 복원하지 않기 때문이다.
Pintos는 ELF 실행파일을 로드할 수 있으며, 이는 userprog/process.c
에 있는 로더에 구현되어 있다. ELF는 Linux, Solaris 및 여러 OS에서 object files, shared libraries 및 실행파일으로 사용하는 파일 형식이다.
우리는 아무 컴파일러나 링커를 사용하여 x86-64 ELF 실행파일을 이용해 Pintos를 위한 프로그램을 만들 수 있다. (이를 위한 컴파일러와 링커가 준비되어 있다.) 우리는 또한 가상의 파일 시스템에 테스트 프로그램을 복사하기 전까지, Pintos는 제대로 된 작업을 할 수 없다는 것을 알아챌 것이다. 우리는 다양한 프로그램을 파일 시스템에 복사하기 전까지 어떠한 흥미로운 작업도 할 수 없을 것이다. 우리는 깨끗한 레퍼런스 파일 시스템 디스크를 만들고 이를 복사하여 filesys.dsk
가 좋지 않은 상태가 되면 이를 삭제하고 다시 가져올 수 있도록 하는 것이 필요할 지도 모른다.
Virtual Memory Layout
Pintos의 가상 메모리는 두 구역으로 나뉘어 있다. 유저 가상 메모리와 커널 가상 메모리가 그것이다. 유저 가상 메모리는 0
부터 KERN_BASE
까지 주소의 영역이 있으며, 이는 include/threads/vaddr.h
에 정의되어 있고, 0x8004000000
으로 초기화되어 있다. 나머지 구역은 커널 가상 메모리가 사용한다.
유저 가상 메모리는 per-pross이다. 커널이 한 프로세스에서 다른것으로 switch 할때, 동시에 유저 가상 주소 공간 역시 프로세서의 페이지 디렉토리 베이스 레지스터를 바꿈으로써 교체된다)(이는 thread/mmu.c
에 있는 pm14_activate()
에 정의되어 있다). 만들어진 쓰레드는 프로세스의 페이지 테이블의 포인터를 가지고 있다.
커널 가상 메모리는 global 하다. 이는 언제나 같은 방식으로 매핑되어 있으며, 유저 프로세스나 커널 쓰레드가 동작할 때와 무관하다. Pintos에서, 커널 가상 메모리는 하나당 하나씩 물리 메모리와 매핑되어 있으며, 이는 KERN_BASE
에서 시작된다. 이것은, 가상 메모리 KERN_BASE
는 물리 메모리 0
에 접근하며, 가상 메모리 KERN_BASE + 0x1234
는 물리 메모리 0x1234
에 접근하며, 이후에도 이와 같은 연속적인 방식으로 기계의 물리 주소에 매핑되어 있다.
유저 프로그램은 오직 자신의 가상 메모리에만 접근할 수 있다. 커널의 가상 메모리에 접근하려는 어떠한 시도든 page fault를 불러일으킬 것이며, 이는 userprog/exception.c
에 있는 page_fault()
에서 담당할 것이고, 프로세스는 종료될 것이다. 커널 쓰레드는 커널 가상 메모리와, 사용자 프로세스가 동작중이라면 유저 가상 메모리에 둘 다 접근 가능하다. 하지만, 커널 안이어도, 유저 가상 메모리에 매핑되어 있지 않는 메모리에 접근하면 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
28
USER_STACK +----------------------------------+
| user stack |
| | |
| | |
| V |
| grows downward |
| |
| |
| |
| |
| grows upward |
| ^ |
| | |
| | |
+----------------------------------+
| uninitialized data segment (BSS) |
+----------------------------------+
| initialized data segment |
+----------------------------------+
| code segment |
0x400000 +----------------------------------+
| |
| |
| |
| |
| |
0 +----------------------------------+
이 프로젝트에서, user stack은 고정된 크기일 것이나, project 3에서 이는 가변적으로 변할 수 있게 구성할 것이다. 전통적으로, initial 되지 않은 데이터 세그먼트의 크기는 system call로 조정할 수 잇으나, 우리는 이를 구현할 필요는 없다.
Pintos의 코드 세그먼트는 유저 가상 주소 0x400000에서 시작하고, 대략적으로 128MB의 공간을 가지게 된다. 이 값은 ubuntu에서 자주 쓰이는 값이며, 큰 의미는 없다.
linker는 유저 프로그램을 메모리에 구성하고, 이는 “linker script”에서 조정하며 다양한 프로그램 세그먼트들에서 프로그램의 이름과 위치를 말해준다. 우리는 이를 linker manual 에 있는 “Scripts” 챕터에서 더 자세히 확인할 수 있다.
특정 실행파일의 layout이 확인하고 싶다면, objdump를 실행할 때 -p
옵션을 추가하여 실행해라.
유저 메모리 접근
시스템 콜의 일부로써, 커널은 반드시 사용자 프로그램이 제공한 포인터를 통해 자주 메모리에 접근해야 한다. 커널은 이를 진행할 때 null pointer를 유저가 줄 수도 있고, 가상 메모리에 매핑되어 있지 않을수도 있고, 커널 가상 메모리 공간에 접근하는 포인터일 수도(KERN_BASE
보다 큰 값) 있기 때문에 조심해야 한다. 모든 이러한 사용불가능한 포인터는 커널이나 다른 실행중인 프로세스에 피해를 주지 않도록 거절되어야 하며, 이는 프로세스를 종료시키고 이의 자원을 풀어주는 방식으로 진행된다.
이를 올바르게 수행하는 방법은 최소 두 가지가 있다. 첫번째는 사용자가 제공한 포인터의 유효함을 확인하고, 이를 역참조하는 것이다. 이 방식을 선택했다면, 우리는 thread/mmu.c
와 include/threads/vaddr.h
에 있는 함수들을 확인해봐야 할 수 있다. 이는 유저 메모리 접근을 다루는 가장 간단한 방식이다.
두번째 방법은 사용자 포인터가 KERN_BASE
의 아래를 가르키는지만 확인하고, 역참조하는 것이다. 유효하지 않은 사용자 포인터는 “page-fault”를 불러올 것이고, 이는 우리가 userprog/exception.c
에 있는 page_fault()
함수에서 수정함으로써 다룰 수 있다. 이 방식은 프로세서의 MMU에게 도움을 주기에 일반적으로 더 빠르며, 이러한 이유로 실제 커널(Linux를 포함한)에서 사용된다.
어느 쪽을 고르든, 우리는 자원이 새지 않도록 확인해야 한다. 예시로, 우리의 시스템 콜이 lock을 획득하였거나, malloc()
을 통해 메모리를 할당하였다고 하자. 우리가 유효하지 않은 사용자 포인터를 이후에 마주치게 되면, 우리가 방금 전의 lock을 해제하거나, 메모리 페이지를 free 하는 것을 잊지 말아야 한다. 우리가 포인터 역참조 이전에 포인터를 검사하는 길을 택한다면, 이는 그리 어렵지 않을 것이다. 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
28
/* 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
이 섹션은 Unix의 64비트 x86-64 구현에서 일반적인 함수 호출에 사용되는 규약의 중요한 점을 요약한다. 간결성을 위해 일부 세부 사항은 생략되었다. 더 자세한 내용은 System V AMD64 ABI를 참조하라.
호출 규약은 다음과 같이 작동한다:
- 유저 레벨 애플리케이션은 정수 레지스터를 %rdi, %rsi, %rdx, %rcx, %r8, %r9의 순서로 인자를 전달하는 데 사용한다.
- 호출자는 스택에 자신의 다음 명령어(리턴 주소)의 주소를 푸시하고, 피호출자의 첫 번째 명령어로 점프한다. x86-64 명령어 CALL은 두 가지를 모두 수행한다.
- 피호출자가 실행된다.
- 만약 피호출자가 리턴 값을 가진다면, 이는 레지스터 RAX에 저장된다.
- 피호출자는 스택에서 리턴 주소를 팝하고, x86-64 RET 명령어를 사용하여 해당 위치로 점프하여 리턴한다.
예를 들어, 세 개의 정수 인자를 받는 함수 f()가 있다고 가정하자. 이 다이어그램은 f(1, 2, 3)이 호출되었다고 가정했을 때, 위의 3단계 시작 시 피호출자가 보는 예제 스택 프레임과 레지스터 상태를 보여준다. 초기 스택 주소는 임의의 값이다:
1
2
+----------------+ stack pointer --> 0x4747fe70 | return address |
+----------------+ RDI: 0x0000000000000001 | RSI: 0x0000000000000002 | RDX: 0x0000000000000003
프로그램 시작 세부 사항
Pintos의 유저 프로그램을 위한 C 라이브러리는 lib/user/entry.c
에 있는 _start()
를 유저 프로그램의 진입점으로 지정한다. 이 함수는 main()
을 호출하고 main()
이 리턴되면 exit()
을 호출하는 래퍼(wrapper)다:
1
2
3
4
void
_start (int argc, char *argv[]) {
exit (main (argc, argv));
}
커널은 유저 프로그램이 실행을 시작하기 전에 초기 함수의 인자들을 레지스터에 배치해야 한다. 인자들은 일반적인 호출 규약과 같은 방식으로 전달된다.
다음과 같은 예제 명령어를 어떻게 처리할지 생각해보자: /bin/ls -l foo bar
.
- 명령어를 단어로 나눈다:
/bin/ls
,-l
,foo
,bar
. - 스택의 상단에 단어들을 배치한다. 순서는 중요하지 않다. 왜냐하면 이들은 포인터를 통해 참조될 것이기 때문이다.
- 각 문자열의 주소와 널 포인터 센티넬(null pointer sentinel)을 오른쪽에서 왼쪽 순서로 스택에 푸시한다. 이것들이
argv
의 요소들이다. 널 포인터 센티넬은argv[argc]
가 C 표준에서 요구하는 대로 널 포인터임을 보장한다. 이 순서는argv[0]
이 가장 낮은 가상 주소에 위치하도록 한다. 단어 정렬된 접근이 비정렬된 접근보다 더 빠르므로, 최적의 성능을 위해 첫 번째 푸시 이전에 스택 포인터를 8의 배수로 내림(round down)해야 한다. -
%rsi
를argv
(즉,argv[0]
의 주소)로 설정하고,%rdi
를argc
로 설정한다. - 마지막으로, 가짜 “리턴 주소”를 푸시한다. 비록 진입 함수가 절대 리턴하지 않더라도, 스택 프레임은 다른 함수들과 같은 구조를 가져야 한다.
아래 표는 유저 프로그램이 시작되기 직전 스택과 관련 레지스터의 상태를 보여준다. 스택이 아래로 성장한다는 점에 유의하라.
주소 | 이름 | 데이터 | 타입 |
---|---|---|---|
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
에서 스택을 시작해야 한다.
인자 전달 코드를 디버깅하는 데 있어 비표준 hex_dump()
함수가 유용할 수 있다. 이 함수는 <stdio.h>
에 선언되어 있다.
인자 전달 구현
현재, process_exec()
는 새로운 프로세스에 인자를 전달하는 것을 지원하지 않는다. 이 기능을 구현하여, 프로그램 파일 이름을 단순히 인자로 받는 대신, 이를 공백에서 단어로 나누도록 process_exec()
을 확장하라. 첫 번째 단어는 프로그램 이름이고, 두 번째 단어는 첫 번째 인자이며, 계속해서 나머지 단어들이 인자가 된다. 즉, process_exec("grep foo bar")
는 grep
을 실행하고 두 개의 인자 foo
와 bar
를 전달해야 한다.
명령어 라인에서 여러 공백은 단일 공백과 동일하게 간주되므로, process_exec("grep foo bar")
는 원래 예제와 동일하다. 명령어 라인 인자의 길이에 대해 합리적인 제한을 둘 수 있다. 예를 들어, 단일 페이지(4kB)에 들어갈 수 있는 인자로 제한할 수 있다. (커널에 전달할 수 있는 명령어 라인 인자의 길이에는 128바이트의 제한이 있다.)
인자 문자열을 원하는 방식으로 파싱할 수 있다. 만약 어떻게 해야 할지 모르겠다면, include/lib/string.h
에 프로토타입된 strtok_r()
을 확인해봐라. 이 함수는 lib/string.c
에 자세한 주석과 함께 구현되어 있다. 더 많은 정보를 얻고 싶다면, 터미널에서 man strtok_r
을 실행하여 매뉴얼 페이지를 참조하라.
User Memory Access
유저 메모리 접근을 구현하라
시스템 콜을 구현하기 위해서는 유저 가상 주소 공간에서 데이터를 읽고 쓸 수 있는 방법을 제공해야 한다. 인자를 가져올 때는 이 기능이 필요하지 않다. 하지만 시스템 콜의 인자로 제공된 포인터로부터 데이터를 읽을 때는 반드시 이 기능을 통해 접근해야 한다. 이 과정은 다소 까다로울 수 있다: 만약 사용자가 유효하지 않은 포인터, 커널 메모리로의 포인터, 또는 이러한 영역의 일부에 속한 블록을 제공한다면 어떻게 해야 할까? 이러한 경우에는 유저 프로세스를 종료하여 처리해야 한다.
시스템 콜 (System Calls)
시스템 콜 인프라를 구현하라
userprog/syscall.c
에서 시스템 콜 핸들러를 구현하라. 제공된 스켈레톤 구현은 시스템 콜을 “처리”하는 대신 프로세스를 종료한다. 이를 수정하여 시스템 콜 번호를 가져오고, 필요한 인자를 추출한 후 적절한 작업을 수행해야 한다.
시스템 콜의 세부 사항
첫 번째 프로젝트에서는 운영 체제가 유저 프로그램으로부터 제어를 다시 획득하는 한 가지 방법을 다루었다: 타이머와 I/O 디바이스로부터의 인터럽트. 이러한 인터럽트는 CPU 외부의 엔티티에 의해 발생되기 때문에 “외부” 인터럽트라 한다.
운영 체제는 소프트웨어 예외도 처리하며, 이는 프로그램 코드 내에서 발생하는 이벤트들이다. 예외는 페이지 폴트(page fault)나 0으로 나누기와 같은 오류일 수 있다. 또한 예외는 유저 프로그램이 운영 체제에 서비스를 요청하는 수단(즉, “시스템 콜”)이기도 하다.
전통적인 x86 아키텍처에서는 시스템 콜이 다른 소프트웨어 예외와 동일하게 처리되었다. 그러나 x86-64에서는 제조업체가 시스템 콜을 위한 특수 명령어 syscall
을 도입했다. 이는 시스템 콜 핸들러를 호출하는 빠른 방법을 제공한다.
현재, x86-64에서 syscall
명령어는 시스템 콜을 호출하는 가장 일반적으로 사용되는 수단이다. Pintos에서는 유저 프로그램이 syscall
을 호출하여 시스템 콜을 수행한다. 시스템 콜 번호와 추가 인자는 syscall
명령어를 호출하기 전에 일반적인 방식으로 레지스터에 설정되어야 한다. 그러나 두 가지 예외가 있다:
- %rax는 시스템 콜 번호를 나타낸다.
- 네 번째 인자는 %rcx가 아닌 %r10이다.
따라서, 시스템 콜 핸들러 syscall_handler()
가 제어를 획득할 때, 시스템 콜 번호는 rax에 있으며, 인자는 %rdi, %rsi, %rdx, %r10, %r8, %r9의 순서로 전달된다.
호출자의 레지스터는 struct intr_frame
을 통해 접근할 수 있다. (struct intr_frame
은 커널 스택에 위치한다.)
x86-64의 함수 반환 값 규약에 따라, 반환 값은 RAX 레지스터에 저장된다. 값을 반환하는 시스템 콜은 struct intr_frame
의 rax 멤버를 수정하여 값을 반환할 수 있다.
다음 시스템 콜들을 구현하라
여기 나열된 프로토타입들은 include/lib/user/syscall.h
를 포함하는 유저 프로그램에 의해 보이는 것들이다. (이 헤더와 include/lib/user
내의 다른 모든 헤더들은 오직 유저 프로그램에서만 사용된다.) 각 시스템 콜에 대한 시스템 콜 번호는 include/lib/syscall-nr.h
에 정의되어 있다:
void halt(void);
power_off()
를 호출하여 Pintos를 종료한다. (이는src/include/threads/init.h
에 선언되어 있다.) 이 함수는 드물게 사용되어야 한다. 왜냐하면 잠재적인 교착 상태에 대한 정보 등을 잃기 때문이다.void exit(int status);
현재 유저 프로그램을 종료하고,status
를 커널에 반환한다. 만약 프로세스의 부모가 이를 기다린다면(아래 참조), 이 값이 반환된다. 관례적으로, 0의 상태 코드는 성공을 나타내고, 0이 아닌 값은 오류를 나타낸다.-
pid_t fork(const char *thread_name);
현재 프로세스의 클론으로 새로운 프로세스를 생성하며, 이 프로세스의 이름은THREAD_NAME
이다. %RBX, %RSP, %RBP, %R12 - %R15를 제외한 레지스터 값들은 복제할 필요가 없다. 이 레지스터들은 호출자가 저장하는 레지스터들이다. 자식 프로세스의 pid를 반환해야 하며, 그렇지 않으면 유효한 pid가 아니다. 자식 프로세스에서는 반환 값이 0이어야 한다. 자식은 파일 디스크립터와 가상 메모리 공간을 포함한 복제된 리소스를 가져야 한다. 부모 프로세스는 자식 프로세스가 성공적으로 복제되었는지 알기 전까지는 fork에서 반환되지 않아야 한다. 즉, 자식 프로세스가 리소스를 복제하지 못한 경우, 부모의fork()
호출은TID_ERROR
를 반환해야 한다.템플릿은
pml4_for_each()
를threads/mmu.c
에서 사용하여 대응하는 페이지 테이블 구조를 포함한 전체 유저 메모리 공간을 복사하지만, 전달된pte_for_each_func
의 누락된 부분을 채워야 한다. (가상 주소 참조) int exec(const char *cmd_line);
현재 프로세스를cmd_line
로 주어진 이름의 실행 파일로 변경하고, 주어진 인자를 전달한다. 성공하면 절대 반환하지 않는다. 그렇지 않은 경우, 프로그램을 로드하거나 실행할 수 없는 경우 -1의 상태 코드로 프로세스가 종료된다. 이 함수는exec
를 호출한 스레드의 이름을 변경하지 않는다. 파일 디스크립터는exec
호출 동안 열려 있는 상태를 유지한다는 점에 유의하라.int wait(pid_t pid);
자식 프로세스pid
를 기다리고 자식의 종료 상태를 가져온다. 만약pid
가 아직 살아 있다면, 종료될 때까지 기다린다. 그런 다음,pid
가exit
에 전달한 상태를 반환한다.pid
가exit()
를 호출하지 않았지만, 커널에 의해 종료되었다면(예: 예외로 인해 종료됨),wait(pid)
는 -1을 반환해야 한다. 부모 프로세스가 이미 종료된 자식 프로세스를 기다리는 것은 합법적이지만, 커널은 여전히 부모가 자식의 종료 상태를 가져오거나 자식이 커널에 의해 종료되었음을 알 수 있도록 해야 한다.
wait
는 다음 조건 중 하나가 참일 경우 즉시 실패하고 -1을 반환해야 한다:
-
pid
는 호출 프로세스의 직접적인 자식을 나타내지 않는다.pid
는 호출 프로세스가fork
의 성공적인 호출로부터 반환 값으로pid
를 받은 경우에만 호출 프로세스의 직접적인 자식이다. 자식은 상속되지 않는다: A가 자식 B를 생성하고, B가 자식 C를 생성한 경우, A는 C를 기다릴 수 없다. B가 죽었다 해도 마찬가지이다. A가 C에 대해wait(C)
를 호출하면 실패해야 한다. 마찬가지로, 부모 프로세스가 종료되기 전에 자식이 종료되지 않는 경우, 고아가 된 프로세스는 새로운 부모에게 할당되지 않는다. -
wait
를 호출한 프로세스가 이미pid
에 대해wait
를 호출했다. 즉, 프로세스는 주어진 자식을 최대 한 번만 기다릴 수 있다.
프로세스는 임의의 수의 자식을 생성하고, 어떤 순서로든 기다릴 수 있으며, 모든 자식을 기다리지 않고 종료할 수도 있다. 설계는 wait
가 발생할 수 있는 모든 방법을 고려해야 한다. 모든 프로세스의 자원, struct thread
를 포함하여, 부모가 자식을 기다리지 않더라도, 자식이 부모보다 먼저 종료하든 이후에 종료하든, 반드시 해제되어야 한다.
Pintos가 초기 프로세스가 종료될 때까지 종료되지 않도록 보장해야 한다. 제공된 Pintos 코드는 threads/init.c
의 main()
에서 userprog/process.c
의 process_wait()
를 호출하여 이를 시도한다. 우리는 process_wait()
를 함수의 최상단에 있는 주석에 따라 구현하고, 그런 다음 process_wait()
에 기초하여 wait
시스템 콜을 구현할 것을 제안한다.
이 시스템 콜을 구현하는 것은 다른 어떤 것보다도 상당히 많은 작업이 필요하다.
bool create(const char *file, unsigned initial_size);
이름이file
인 새로운 파일을 생성하고, 초기 크기를initial_size
바이트로 설정한다. 성공하면 true를 반환하고, 그렇지 않으면 false를 반환한다. 새로운 파일을 생성하는 것은 파일을 여는 것이 아니다. 파일을 열려면 별도의open
시스템 콜이 필요하다.bool remove(const char *file);
이름이file
인 파일을 삭제한다. 성공하면 true를 반환하고, 그렇지 않으면 false를 반환한다. 파일은 열려 있든 닫혀 있든 제거될 수 있으며, 열린 파일을 제거한다고 해서 그것이 닫히지는 않는다. 자세한 내용은 FAQ의 “Removing an Open File”을 참조하라.int open(const char *file);
file
이라는 이름의 파일을 연다. 성공 시 비음수가 아닌 “파일 디스크립터(fd)”라는 정수 핸들을 반환하고, 파일을 열 수 없는 경우 -1을 반환한다. 파일 디스크립터 번호 0과 1은 콘솔에 예약되어 있다: fd 0 (STDIN_FILENO
)는 표준 입력이고, fd 1 (STDOUT_FILENO
)는 표준 출력이다.open
시스템 콜은 이러한 파일 디스크립터를 반환하지 않는다. 이는 명시적으로 설명된 경우에만 시스템 콜 인자로 사용될 수 있다. 각 프로세스는 독립적인 파일 디스크립터 집합을 가진다. 파일 디스크립터는 자식 프로세스에 상속된다. 하나의 파일이 여러 번 열릴 때, 단일 프로세스나 다른 프로세스에서 열리든 상관없이, 각open
은 새로운 파일 디스크립터를 반환한다. 동일한 파일에 대해 서로 다른 파일 디스크립터는close
에 대한 개별 호출로 독립적으로 닫히며, 파일 위치를 공유하지 않는다. 리눅스의 방식을 따라, 0부터 시작하는 정수를 반환해야 한다.int filesize(int fd);
fd
로 열린 파일의 크기를 바이트 단위로 반환한다.int read(int fd, void *buffer, unsigned size);
fd
로 열린 파일에서buffer
로size
바이트를 읽어들인다. 실제로 읽은 바이트 수를 반환하며, 파일 끝에 도달하면 0을 반환한다. 파일을 읽을 수 없는 경우(예: 파일 끝 이외의 조건으로 인해) -1을 반환한다.fd
가 0인 경우, 키보드로부터input_getc()
를 사용하여 읽는다.int write(int fd, const void *buffer, unsigned size);
buffer
로부터fd
로 열린 파일에size
바이트를 쓴다. 실제로 기록된 바이트 수를 반환하며, 파일 끝을 넘어가는 쓰기는 구현되어 있지 않다. 예상되는 동작은 파일 끝까지 가능한 많은 바이트를 기록하고 실제 기록된 바이트 수를 반환하는 것이다. 기록할 수 없을 경우 0을 반환한다.fd
가 1인 경우, 콘솔에 기록한다. 콘솔에 기록하는 코드는 최소 몇백 바이트가 넘지 않는 한putbuf()
를 한 번 호출하여 전체buffer
를 기록해야 한다. 그렇지 않으면, 서로 다른 프로세스가 출력하는 텍스트 줄이 콘솔에서 혼합되어 인간 독자와 채점 스크립트를 혼란스럽게 할 수 있다.void seek(int fd, unsigned position);
열린 파일fd
에서 다음에 읽거나 쓸 바이트의 위치를 파일 시작으로부터position
바이트만큼 이동시킨다. 파일 끝을 넘어가는 위치로 이동하는 것은 오류가 아니다. 이후 읽기는 0 바이트를 반환하며, 파일의 끝을 나타낸다. 이후 쓰기는 파일을 확장하고, 기록되지 않은 부분을 0으로 채운다. (그러나, Pintos 파일은 프로젝트 4가 완료될 때까지 고정된 길이를 가지므로, 파일 끝을 넘어서 쓰는 것은 오류를 반환한다.) 이러한 의미론은 파일 시스템에 의해 구현되며, 시스템 콜 구현에서 특별한 노력이 필요하지 않다.unsigned tell(int fd);
열린 파일fd
에서 다음에 읽거나 쓸 바이트의 위치를 파일 시작으로부터 바이트 단위로 반환한다.void close(int fd);
파일 디스크립터fd
를 닫는다. 프로세스가 종료되거나 종료될 때, 이 함수가 각 파일 디스크립터에 대해 호출된 것처럼 모든 열린 파일 디스크립터가 암묵적으로 닫힌다.
파일은 다른 시스템 콜들도 정의한다. 지금은 이를 무시하라. 프로젝트 3에서 일부를 구현하고, 프로젝트 4에서 나머지를 구현할 예정이므로, 확장성을 염두에 두고 시스템을 설계해야 한다.
여러 유저 프로세스가 동시에 시스템 콜을 실행할 수 있도록 시스템 콜을 동기화해야 한다. 특히, 여러 스레드가 동시에 filesys 디렉터리에 제공된 파일 시스템 코드를 호출하는 것은 안전하지 않다. 시스템 콜 구현은 파일 시스템 코드를 중요한 섹션으로 취급해야 한다. process_exec()
도 파일에 접근한다는 것을 잊지 마라. 현재는 filesys 디렉터리의 코드를 수정하지 않는 것이 좋다.
각 시스템 콜에 대해 lib/user/syscall.c
에 사용자 수준의 함수를 제공하였다. 이 함수들은 C 프로그램에서 각 시스템 콜을 호출할 수 있는 방법을 제공한다. 각 함수는 시스템 콜을 호출하기 위해 약간의 인라인 어셈블리 코드를 사용하며(적절한 경우) 시스템 콜의 반환 값을 반환한다.
이 부분을 마친 후, 그리고 영원히, Pintos는 매우 견고해야 한다. 유저 프로그램이 할 수 있는 어떠한 행동도 OS가 충돌하거나, 패닉하거나, 어설션에 실패하거나, 그 외의 오작동을 일으키지 않아야 한다. 이 점을 강조하는 것이 중요하다: 우리의 테스트는 시스템 콜을 여러 가지 방법으로 깨뜨리려고 시도할 것이다. 모든 모서리 케이스를 생각하고 처리해야 한다. 유저 프로그램이 OS를 중지시킬 수 있는 유일한 방법은 halt 시스템 콜을 호출하는 것이다.
시스템 콜이 잘못된 인자를 전달받은 경우, 허용 가능한 옵션은 오류 값을 반환(값을 반환하는 호출의 경우), 정의되지 않은 값을 반환하거나 프로세스를 종료하는 것이다.
프로세스 종료 메시지
프로세스 종료 메시지를 출력하라
사용자 프로세스가 종료될 때마다, 그것이 exit
를 호출했거나 다른 이유로 종료되었을 때, 다음과 같이 프로세스의 이름과 종료 코드를 출력하라:
1
printf("%s: exit(%d)\n", ...);
출력되는 이름은 fork()
에 전달된 전체 이름이어야 한다. 사용자 프로세스가 아닌 커널 스레드가 종료될 때나 halt
시스템 콜이 호출될 때는 이 메시지를 출력하지 말아야 한다. 프로세스가 로드에 실패한 경우에는 이 메시지를 출력하지 않아도 된다.
이 외에는, Pintos에서 기본적으로 제공하지 않는 다른 메시지를 출력하지 말아야 한다. 디버깅 중에 추가 메시지가 유용할 수 있지만, 이는 채점 스크립트를 혼란스럽게 하여 점수를 낮출 수 있다.
실행 파일에 대한 쓰기 거부
실행 파일에 대한 쓰기를 거부하라.
실행 파일로 사용 중인 파일에 대한 쓰기를 거부하는 코드를 추가하라. 많은 운영 체제는 프로세스가 디스크에서 변경 중인 코드를 실행하려고 할 때 발생할 수 있는 예측 불가능한 결과 때문에 이러한 처리를 한다. 이는 프로젝트 3에서 가상 메모리가 구현된 이후에 특히 중요하지만, 지금도 문제가 되지 않는다.
file_deny_write()
를 사용하여 열린 파일에 대한 쓰기를 방지할 수 있다. file_allow_write()
를 호출하면 쓰기를 다시 활성화할 수 있다(다른 프로세스가 쓰기를 거부하지 않는 한). 파일을 닫으면 쓰기도 다시 활성화된다. 따라서, 프로세스의 실행 파일에 대한 쓰기를 거부하려면 해당 파일이 프로세스가 실행되는 동안 계속 열려 있어야 한다.
파일 디스크립터 확장 (추가 구현)
Pintos가 stdin
과 stdout
의 닫기를 지원하고, Linux의 dup2
시스템 콜을 지원하도록 구현하라.
현재 Pintos의 구현에서는 stdin
과 stdout
의 파일 디스크립터를 닫는 것이 금지되어 있다. 이 추가 점수를 위해, 먼저 Linux와 동일하게 사용자에게 stdin
과 stdout
을 닫을 수 있도록 허용해야 한다. 즉, stdin
을 닫으면 프로세스는 입력을 절대 읽어서는 안 되며, stdout
을 닫으면 프로세스는 절대 아무것도 출력해서는 안 된다.
다음으로, dup2
시스템 콜을 구현하라.
1
int dup2(int oldfd, int newfd);
dup2()
시스템 콜은 파일 디스크립터 oldfd
의 복사본을 newfd
로 지정된 파일 디스크립터 번호로 생성하며, 성공 시 newfd
를 반환한다. 만약 파일 디스크립터 newfd
가 이전에 열려 있었다면, 재사용되기 전에 조용히 닫힌다.
다음 사항에 유의하라:
-
oldfd
가 유효한 파일 디스크립터가 아닌 경우, 호출은 실패하고(-1 반환),newfd
는 닫히지 않는다. -
oldfd
가 유효한 파일 디스크립터이고,newfd
가oldfd
와 동일한 값인 경우,dup2()
는 아무 작업도 하지 않고newfd
를 반환한다. - 이 시스템 콜이 성공적으로 반환된 후, 이전과 새로운 파일 디스크립터는 상호 교환하여 사용할 수 있다. 서로 다른 파일 디스크립터이지만, 동일한 열린 파일 설명(open file description)을 참조하고, 따라서 파일 오프셋과 상태 플래그를 공유한다. 예를 들어, 하나의 디스크립터에서
seek
를 사용하여 파일 오프셋이 변경되면, 다른 디스크립터에서도 오프셋이 변경된다.
복제된 파일 디스크립터는 포킹(forking) 이후에도 그들의 의미를 유지해야 한다.
추가 점수를 받으려면 모든 테스트 케이스를 통과해야 한다. (모든 것을 성공하거나, 아무것도 얻지 못하는 조건이다.)
질문 및 도움말
FAQ
얼마나 많은 코드를 작성해야 하나요?
다음은 git diff --stat
로 생성된 참고 솔루션의 요약입니다. 마지막 행은 삽입 및 삭제된 총 줄 수를 나타냅니다. 변경된 줄은 삽입과 삭제로 모두 계산됩니다. 참고 솔루션은 가능한 많은 솔루션 중 하나에 불과합니다. 다른 솔루션도 가능하며, 그 중 일부는 참고 솔루션과 크게 다를 수 있습니다. 훌륭한 솔루션 중 일부는 참고 솔루션에서 수정한 모든 파일을 수정하지 않을 수도 있으며, 참고 솔루션에서 수정하지 않은 파일을 수정할 수도 있습니다. 또한, 여기에는 추가 요구사항의 구현이 포함되어 있습니다. 참고로 약 150줄은 추가 구현과 관련이 있습니다.
1
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
를 실행할 때 커널이 항상 패닉 상태가 됩니다.
- 파일 시스템을 포맷했나요? (
pintos -f
사용) - 파일 이름이 너무 긴가요? 파일 시스템은 파일 이름을 14자로 제한합니다.
-
pintos -p ../../examples/echo -- -q
명령은 제한을 초과합니다. - 대신
pintos -p ../../examples/echo:echo -- -q
명령을 사용하여 파일을echo
이름으로 복사하십시오.
-
- 파일 시스템이 가득 찼나요?
- 파일 시스템에 이미 16개의 파일이 있습니까? 기본 Pintos 파일 시스템은 16개의 파일 제한이 있습니다.
- 파일 시스템이 너무 단편화되어 파일을 위한 연속적인 공간이 부족할 수 있습니다.
pintos -p ../file --
을 실행할 때 ‘file’이 복사되지 않습니다.
파일은 기본적으로 참조된 이름으로 기록됩니다. 따라서 이 경우 복사된 파일은 ../file
이라는 이름을 갖게 됩니다. 대신 pintos -p ../file:file --
을 실행해야 합니다.
내 모든 사용자 프로그램이 페이지 폴트로 종료됩니다.
아직 인자 전달을 구현하지 않았거나 올바르게 구현하지 않았을 때 발생합니다. 사용자 프로그램을 위한 기본 C 라이브러리는 스택에서 argv
를 읽으려고 합니다. 스택이나 레지스터가 올바르게 설정되지 않으면 페이지 폴트가 발생합니다.
내 모든 사용자 프로그램이 시스템 콜로 종료됩니다!
시스템 콜을 먼저 구현해야 합니다. 모든 합리적인 프로그램은 최소한 하나의 시스템 콜(exit()
)을 시도하며, 대부분의 프로그램은 그 이상을 시도합니다. 특히 printf()
는 write
시스템 콜을 호출합니다. 기본 시스템 콜 핸들러는 시스템 콜을 호출할 때 단순히 “system call!”을 출력하고 프로그램을 종료합니다. 그때까지 인자 전달이 올바르게 구현되었는지 확인하려면 hex_dump()
를 사용할 수 있습니다.
사용자 프로그램을 어떻게 디스어셈블(disassemble)할 수 있나요?
objdump
유틸리티를 사용하여 전체 사용자 프로그램이나 오브젝트 파일을 디스어셈블할 수 있습니다. objdump -d file
로 호출하십시오. 개별 함수를 디스어셈블하려면 GDB의 disassemble
명령을 사용할 수 있습니다.
왜 많은 C 인클루드 파일이 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 이상의 스택 공간이 필요한 사용자 프로그램을 어떻게 실행할 수 있나요?
각 프로세스에 대해 하나 이상의 페이지의 스택 공간을 할당하도록 스택 설정 코드를 수정할 수 있습니다. 다음 프로젝트에서는 더 나은 솔루션을 구현하게 될 것입니다.