카이스트 자료 간단 번역
Appendix의 내용을 읽고 PintOS의 구조를 파악하는게 구현하는데 있어서 가장 도움이 됐던 것 같다.
일반적인 설명인줄 알고 처음엔 안읽었다가 뒤늦게 알고 읽게되어서 많이 아쉬웠다..
userprog 디렉토리에서 주로 작업할 예정이지만, pintOS의 대부분을 살펴보면서 상호작용 해야한다. 프로젝트 1 위에 이어서 작업을 해야하는데, 이는 프로젝트 1의 코드가 프로젝트 2에 영향을 주지는 않지만 과거 테스트 케이스까지 쌓아서 평가를 하기 때문이다. Extra 과제의 경우, 제공되는 skeleton code 는 하나도 없으며, 테스트 케이스만 주어진다. (테스트를 돌리기 위해서는
userprog/Make.vars
파일을 조작해야한다.)
이번 프로젝트에서는, 스레드가 아닌 프로세스를 다룰 예정이고, 각각의 프로세스는 하나의 스레드만 가지고 있도록 한다. 각각의 프로그램은 하나의 기계를 전부 사용하고 있도록 착각하게 된다. (컴퓨터 기계 전체에 대한 abstraction) => memory, scheduling, 그 외 state 들을 정확하게 다루어서 이 환영이 깨지지 않도록 해야한다.
프로젝트 1에서는 테스트 코드를 커널에 직접적으로 컴파일했기 때문에 특정한 함수 인터페이스가 커널 내에서 요구되었다. (어떤 인터페이스를 말하는걸까..?) 프로젝트 2에서는 사용자 프로그램에서 커널을 테스트하도록 한다. 사용자 프로그램은 주어진 조건들에 맞도록 해야하며, 이 조건들을 만족하는 선에서 커널 코드들을 자유롭게 수정해도 된다.
#ifdef VM
으로 감싸진 블록 내에는 코드에는 수정사항이 들어가면 안된다. (이 부분은 프로젝트 3에서 다루게 된다)
process.*
, syscall.*
, exception.*
그 외 : gdt.*
(Global Descriptor Table - table that describes segments in use - 전체 핀토스 프로젝트에서 이파일을 조작할 일은 없음), tss.*
(Task-State Segment - ring switching 에서 stack pointer를 찾는 역할 - 프로세스가 인터럽트 핸들러에 들어가면 하드웨어는 커널의 stack pointer를 찾아달라고 이 코드를 호출하게 된다. 전체 프로젝트에서 코드를 수정할 일은 없음)
사용자 프로그램은 파일시스템에 로드되며, 앞으로 다둘 시스템 콜의 대부분은 이 파일시스템과 관련되어있다. 하지만 이번 프로젝트의 주요 포인트는 파일 시스템이 아니기 때문에 심플한 시스템을 핀토스에서 제공한다. ->
filesys.*
파일의 코드들을 살펴보는 것이 좋다! (어떻게 동작하며 어떤 한계가 있는지 확인)
파일시스템 파일을 조작할 필요 절대 없고, 이번 프로젝트에서 제대로 파일시스템의 루틴을 파악한다면 프로젝트 4 때 삶이 편해질 것이다 (비교적).
한계점들 : 내부 동기화 없음 -> 동기화를 직접 사용해서 하나의 프로세스만 접근하도록 해줘야 함, 생성할 때 파일 사이즈가 결정되며 루트 디렉토리도 마찬가지이므로 생성할 수 있는 파일의 총량도 한정되어있음, allocation extent가 한정되어있어서, 사용할 공간을 늘릴 수가 없음 따라서 외부 단편화가 심각해질 수 있음, subdirectory 없음, 파일명은 14글자로 한정, 시스템 중간에 crash 가 발생하면 저절로 고칠 수 있는 방법이 없음,
filesys_remove() 가 구현되어있어서, 파일이 열려있는 상태에서 제거를 하면, block들이 deallocate되지 않아서 그 파일에 접근하는 코드가 있는 다른 스레드가 언제든지 접근할 수 있다(?)프로젝트 1에서는 커널 이미지에 테스트 프로그램들이 존재했던 것에 비해 이번 프로젝트에는 사용자 스페이스에서 돌아가는 테스트 프로그램들을 핀토스 VM에 넣어주어야 한다. make check 스크립트가 알아서 해주겠지만, 어떻게 동작하는건지 알아두면 개별 테스트 케이스를 다루는데 도움이 될 것이다. (그 밑에는 어떻게 하는건지에 대한 설명)
메모리 안에 들어가는 사이즈이고, 우리가 만드는 시스템 콜만 사용했을 때, C로 작성된 프로그램이 핀토스에서 잘 돌아간다. 다르게 말하면 이 조건에 맞지 않는다면 돌아가지 않는다는 뜻이다. 예를들어, 이 프로젝트에서 사용되는 시스템 콜들 중에 메모리 할당을 하는 것이 없기 때문에
malloc()
구현이 불가능하다. floating point operation도 불가능하다.
PintOs에서도userprog/process.c
에서 제공되는 로더를 통해 object file, shared libraried, executables 등에 사용되는 ELF 파일 형식을 로딩할 수 있다. (이하 해당 파일 관련 설명)
PintOS의 가상 메모리는 사용자 공간과 커널 공간으로 나누어져 있다. (커널 공간 기본 설정에 대한 설명)
사용자 공간은 프로세스 별로 할당되게 된다. 커널이 하나의 프로세스에서 다른 프로세스로 넘어갈 때 가상 주소 공간 역시 바뀐다. (프로세서의 페이지 디렉토리 기반 레지스터pml4_activate()
inthread/mmu.c
) 각각의 스레드 구조체는 프로세스의 페이지 테이블에 대한 포인터를 가지고 있다.
커널 가상 공간은 항상 매핑이 같으며, 사용자 프로세스와 커널 스레드가 실행되는 것에 영향을 받지 않는다. PintOS에서 커널공간은 물리 메모리에 1대1 매핑이 되어있다. (이하 자세한 설명)
사용자 프로그램은 자신의 가상 메모ㄹ 공간만 사용할 수 있으며 커널 공간을 접근하려고 하면 page fault가 발생하며, 핸들러에 의해 프로세스가 종료된다.
커널 스레드는 커널 메모리 공간과 사용자 메모리 공간 모두 접근할 수 있다. 그러나 커널도 매핑이 되지 않은 사용자 가상 메모리 공간에 접근하려고 하면 page fault가 발생한다.
(스택, BSS, data segment, code segment 등 일반적인 설명)
프로젝트 2에서는 스택의 크기가 고정되어있지만 프로젝트 3에서는 확장이 가능할 것이다. 일반적으로는 시스템 콜을 통해서 data segment의 사이즈 조정이 가능하지만 PintOS에서는 구현되어있지 않다. (이하 code segment 위치에 대한 설명)
각각의 프로그램 segment의 이름과 위치를 알려주는 "linker script"에 따라서 linker가 사용자 프로그램의 메모리 레이아웃을 설정한다. (자세한 내용을 볼 수 있는 방법에 대한 설명)
시스템 콜을 통해 사용자 프로그램이 넘겨주는 포인터를 가지고 커널은 메모리에 자주 접근하게 된다. 사용자 프로그램이 null pointer를 보낼 수도 있고, 매핑되지 않은 메모리 공간에 대한 포인터를 넘겨줄 수도 있고, 커널의 메모리 공간에 대한 포인터를 넘겨줄 수도 있으니 이러한 invalid 포인터를 거부하고 커널과 다른 프로세스에 안좋은 영향을 주지 않도록 잘못된 값을 요청하는 프로세스를 죽이고 그 프로세스의 자원을 free 해주어야 한다.
방법 1 : 받은 포인터를 dereference 해서 값의 유효성을 검증
thread/mmu.c 와 include/threads/vaddr.h 의 함수들을 확인하면 된다. 사용자의 메모리 접근을 관리하는 제일 간단한 방법이다.
방법 2 : 사용자가 넘겨준 포인터가 KERN_BASE 밑으로만 가리키는지 확인한 후 역참조(??)
유효하지 않은 포인터는 page fault를 발생시킬 것이고, page_fault() 함수를 수정해 핸들링 할 수 있다. 프로세서의 MMU의 이점을 잘 사용하기 때문에 보통 속도가 더 빠르다. 따라서 실제 커널 프로그램에서 사용되는 편이다.어느 방법이든 자원의 leak를 조심해야한다. (이하 자세한 내용)
유저 프로그램은 특권 명령을 실행하기 위해 정해진 포맷과 시스템콜을 통해서, 하고자 하는 행동과 그에 필요한 데이터 등의 인자를 운영체제에게 보내준다. 운영체제는 이 내용을 받아서 적절한 validation을 거친 후 유저 프로그램이 원하는 내용을 처리해준다.
현재 PintOS에는 이러한 시스템콜이 구현되어있지 않은 상태이며, 직접 만들어야 한다.
userprog/process.c
process_init
: initd 등의 프로세스들을 초기화 해주는 역할process_create_initd
: 사용자 프로그램의 시작 (한번만 호출되어야 함)palloc_get_page
: 비어있는 페이지를 얻는 함수. (argument에 따라 사용자 풀 또는 커널 풀에서 페이지를 할당받아 가져올 수 있으며, 0으로 데이터를 지워서 가져올 수도 있음)initd
: 사용자 프로세스의 첫 시작에 대한 스레드 함수process_init
: 초기화process_exec
: 현재 실행중인 context를 인자로 받는 내용으로 변경process_exec
: 현재 실행을 filename의 프로그램으로 switchprocess_cleanup
: 새로 실행할 내용을 로드하기 전에 현재의 context를 정리load
: filename에 해당하는 실행파일을 로드palloc_free_page
: filename을 저장하기 위해 사용했던 페이지를 freedo_iret
: 커널모드를 나감으로서 새로 세팅된 (switch된) 프로세스를 시작process_wait
: 자식 스레드가 끝나서 exit status를 돌려줄 때까지 기다린다. process_exit
: 프로세스 종료. thread_exit 함수에 의해 불려짐process_cleanup
process_cleanup
: 현재 프로세스의 자원을 freeprocess_activate
: nest 스레드에서 사용자 코드를 실행할 수 있도록 CPU를 세팅pml4_activate
: 스레드의 페이지 테이블을 활성화 tss_update
: 프로세싱 인터럽트(?)를 위해서 스레드의 커널 스택을 세팅load
: filename의 ELF 실행파일을 현재 스레드로 로드, 실행파일의 엔트리 포인트를 rip에 저장, 초기 스택 포인터는 rsp에 저장process_activate
)filesys_open, file_read
)file_read
)setup_stack
, 인터럽트 프레임 if_
)validate_segment
load_segment
setup_stack
: USER_STACK에서 0으로 초기화한 페이지를 매핑함으로써 최소 스택을 생성install_page
: 사용자 주소공간과 커널 주소공간에 대한 매핑을 페이지 테이블에 추가한다.userprog/syscall.c
syscall_init
syscall_handler
: system call interfaceuserprog/process.c
process_fork
: 현재 프로세스를 복제해서 새로운 이름을 부여__do_fork
함수에 대해서 스레드 생성duplicate_pte
: 해당 함수를 pml4_for_each
함수에 넘겨줌으로써 부모의 주소공간을 복제newpage
에 저장writable
bool 설정va
주소에 writable
permission을 가지도록 설정)__do_fork
: 부모의 실행에 대한 context를 복사하는 thread 함수process_init
do_iret
: 새로 생성된 프로세스로 switchinit.c
main()
read_command_line
<- pintos
이하의 argument가 들어옴 (추측)parse_options
<- pintos
프로그램의 option을 확인 (-q 끝나고 전원 끄기, -f 파일시스템 관련 등) + option이 아닌 첫번째 argument에 대한 포인터(argv)를 returnrun_actions(argv)
: argv에서 수행할 task 단위로 실행 -> 이번 프로젝트에서 해당하는건 'run' task와 관련된 것 뿐run_tasks
함수로 넘김run_tasks
: process_create_initd(task)
를 통해 프로그램을 실행하며, process_wait
를 통해서 해당 프로그램이 종료하기를 기다림process.c
process_create_initd
thread_create(file_name, PRI_DEFAULT, initd, fn_copy)
: file_name
-> thread->name
, initd
-> function
, fn_copy
-> aux
(initd 함수에 들어가는 듯)initd(fn_copy)
process_exec(fn_copy)
load(file_name)
do_iret(interrupt_frame)
: interrupt out (get out of kernel) & start user mode!Is it okay to just do this :
*(uintptr_t*) (stack_top + idx * sizeof(uintptr_t)) = arg_addrs[idx];
Or do I have to memcpy like this :
memcpy((void *)stack_top, &arg_addrs[idx], sizeof(uintptr_t));
Both ways are valid for storing the addresses in the reserved space on the stack. However, using a typecast to uintptr_t and direct assignment like (uintptr_t) (stack_top + idx sizeof(uintptr_t)) = arg_addrs[idx]; is a more concise and potentially faster approach than using memcpy.
Using memcpy to copy the address from arg_addrs to the stack involves an extra function call and copying of bytes, which can be slower than direct assignment using a typecast. However, using memcpy can be more robust in certain situations, such as when the types of the source and destination pointers are not the same or when the source data is not aligned properly.
In general, both approaches are valid and can be used depending on the specific requirements and constraints of your application.
To determine how much padding to add after pushing the argument values into the stack, you need to calculate the difference between the stack pointer and the next higher multiple of 16.
The reason for aligning the stack on a 16-byte boundary is to comply with the System V AMD64 ABI (Application Binary Interface) specification, which requires that the stack pointer be 16-byte aligned at the time of a function call. This is important for efficient memory access and to ensure that the SSE registers are properly aligned.
일반적인 시스템에서 메모리 사용을 보다 효과적으로 하기 위해서 function call이 발생했을 때 stack pointer가 16byte 정렬이 되어있도록 한다. 따라서 이 조건을 만족하기 위해서 스택에 데이터를 push한 후 각 data의 주소와 return address를 push 하기 전에 8byte 또는 16byte에 정렬되도록 padding을 추가해준다.
In 64-bit architectures, the natural alignment for data types that are 8 bytes or smaller is 8 bytes. However, aligning the stack to a 16-byte boundary can improve performance because it allows for more efficient memory access by the processor. This is because many modern processors have a cache line size of 64 bytes, and aligning the stack to a 16-byte boundary ensures that each cache line contains data from only one stack frame. This can reduce cache misses and improve overall performance. Additionally, some instructions may require 16-byte alignment for proper execution, so aligning the stack to a 16-byte boundary can also prevent issues with instruction execution.
많은 운영체제에서는 8byte가 아닌 16byte 정렬을 사용한다. 그러나 PintOS에서는 8byte로 정렬을 하라고 나와있기 때문에 8byte 정렬로 구현하면 된다. 8byte로 구현하는게 사실 더 간단하다.
The alignment requirement of the return address on the stack depends on the total size of the arguments pushed onto the stack before it. If the total size of the arguments is a multiple of 16 bytes, then the return address can be placed directly on a 16-byte boundary. If not, padding bytes will need to be added to align the return address properly. The padding size is calculated as the difference between the total size of the arguments and the nearest lower multiple of 16 bytes. Once the padding is added, the return address can be placed at the top of the stack on a 16-byte boundary.
스택이 시작하는 지점(주로 return address가 들어가는 부분)을 16byte로 align 시키기 위해서는 argument 주소값을 push 하기 전에 padding을 준다. 이 padding의 크기는 argument의 개수, 데이터의 크기 등에 따라 달라져야할 것이라 생각한다. 8byte로 정렬해주면 되는 PintOS에서는 그저 데이터를 모두 push하고 난 후의 stack top을 8byte로 정렬해주면 그만인데, 16byte로 정렬하기 위해선 앞으로 들어올 내용의 크기에 따라 8byte를 추가로 넣어줘야할지 등을 결정해줘야 할 것이라 생각한다. ChatGPT는 이러한 방법이 실제 운영체제에서 채택되는게 맞다고 닫변해주긴 했지만, 진실인지는 잘 모르겠다.
To execute the system call in the context of the calling process, the kernel needs to set up the user stack with the appropriate arguments. The kernel will typically allocate a block of memory for the user stack and copy the arguments into it, taking care to align the stack pointer (RSP) to a 16-byte boundary before executing the system call.
Once the kernel has finished setting up the user stack with the necessary arguments for the system call, it executes the system call instruction. The system call instruction transfers control from the user program to the kernel, which then executes the corresponding system call.
Function call이 발생하면 위와 같은 사항들을 고려해 8byte 또는 16byte alignment에 맞춰서 stack을 세팅해주며, 스택 세팅이 완료되면 function call에 대한 instruction이 실행된다.
각각의 역할과 함수 호출에서의 역할
Callee-saved registers are a subset of registers that are used in some programming languages and calling conventions to preserve the state of the callee (the called function) across a function call. These registers are typically preserved by the callee, meaning that if the callee modifies the register, it is responsible for restoring the original value before returning control to the caller. The caller can therefore assume that the values in these registers will remain unchanged after a function call.
The purpose of callee-saved registers is to prevent the callee from modifying the state of the caller unintentionally. If the caller relies on a particular register to hold a value, and the callee modifies that register without restoring it, the caller's program may behave unexpectedly or even crash.
The specific set of callee-saved registers varies depending on the architecture and calling convention used. Typically, the registers that are considered callee-saved are those that the caller expects to remain unchanged after a function call. In some architectures, the set of callee-saved registers may be fixed, while in others, the programmer may have the option to specify which registers should be preserved across a function call.
In summary, callee-saved registers are a subset of registers that are preserved by the callee to maintain the state of the caller across a function call. They are used to prevent unintended modification of the caller's state and to maintain consistency in the program's behavior.
호출받은 함수는 해당 레지스터의 값을 보존해야하며, 호출한 함수에게 제어권이 넘어가기 전에 값을 원래대로 돌려놓을 의무가 있다. 그럼으로 인해 호출한 함수는 해당 레지스터의 값이 함수 호출에도 변하지 않을 것이라는 것을 보장받는다. callee-saved 레지스터의 목적은 callee가 caller의 상태값을 실수로 바꾸지 않도록 하는 것이며, caller는 바뀌지 않고 사용할 값을 저장하는 용도로 사용한다.
syscall을 구현하기 위해서는 가상 메모리에 데이터를 읽고 쓰는 기능이 제공되어야 한다. Argument을 가져올 때는 이 기능이 필요없지만, 시스템콜의 인자로 제공된 포인터에서 데이터를 읽어올 때에는 이 기능을 통해야 한다(좀 까다로울 수 있다) : 사용자가 유효하지 않은 포인터를 넘겨준다면? 커널 메모리에 대한 포인터라면? 혹은 유효하지 않은 공간이나 커널 메모리 공간을 일부 포함하고 있는 블록을 가리키고 있다면? 이러한 케이스에 대해서 사용자 프로세스를 종료시킴으로서 핸들링을 해야한다.
=> 시스템콜이 발생했을 때 OS는 유저 프로그램이 보낸 포인터가 유효한지 검증을 해야한다. Systemcall handler를 구현하면서 같이 구현하자.
시스템콜 기반 구조를 구현하기
현재 PintOS에 구현되어있는 시스템콜 핸들러는 시스템콜이 들어오는 경우 프로세스를 죽임으로서 처리를 해주고 있다. 우리는 system call number, argument 등을 얻어서 적절한 행동을 취할 수 있도록 구현을 해야한다.
프로젝트 1에서는 타이머와 I/O 장치와 같이 CPU 외부의 독립적인 장치에 의한 'external' interrupt로 인해 운영체제가 유저 프로그램으로부터 제어권을 넘겨받는 방법을 다뤘다.
운영체제는 프로그램의 코드에서 발생하는 software exception도 다룬다. (예, page fault, division by zero 등) 이러한 exception은 유저 프로그램이 운영체제에게 서비스(system call)를 요청하는 방법이기도 하다. (👉🏻: exception은 두 가지 1. external device 로부터의 hw interrupt = interrupt, 2. user로 부터의 sw exception= fault, trap, abort)
과거에 시스템콜은 software exception과 동일하게 다루어졌는데, 지금은 시스템콜을 위한 instruction이 추가되어 사용되고 있다. PintOS에서도 시스템콜 수행 전에 system call number와 argument를 레지스터에 저장해주면 되는데, 일반적인 방법과는 다르게 rax는 system call number 이며, 4번째 argument는 rcx가 아닌 r10을 사용한다. (rax - rdi, rsi, rdx, r10, r8, r9 순서로 사용)
시스템콜을 호출하는 유저 프로세스에서 레지스터는 커널 스택에 존재하는 interrupt frame을 넘겨받기에 접근이 가능하다. x86-64 관례에 의하면 함수의 리턴값을 rax 레지스터에 저장하는 것이다. 값을 리턴하는 Systemc call도 interrupt frame의 rax 요소의 값을 수정하는 방식으로 이 관례를 따를 수 있다.
각 시스템콜에 대한 system call number는
syscall-nr.h
에 정의되어있다.
include/lib/user/syscall.h
를 포함하는 유저 프로그램이 볼 수 있는 시스템콜 함수들:
(include/lib/user
디렉토리의 모든 헤드파일에서 정의된 함수들은 오로지 유저 프로그램에 의해서만 사용된다.)
void halt (void);
void exit (int status);
pid_t fork (const char *thread_name);
int exec (const char *cmd_line);
int wait (pid_t pid);
bool create (const char *file, unsigned initial_size);
bool remove (const char *file);
int open (const char *file);
int filesize (int fd);
int read (int fd, void *buffer, unsigned size);
int write (int fd, const void *buffer, unsigned size);
void seek (int fd, unsigned position);
unsigned tell (int fd);
void close (int fd);
유저 프로세스가 종료될 때 마다 프로세스 이름과 exit 코드를 다음과 같은 형식으로 출력한다.
printf ("%s: exit(%d)\n", ...);
프로세스 이름은
fork()
에 전달되는 이름 전체여야 한다. 유저 프로세스가 아닌 커널 스레드가 종료하는 상황이거나, halt 시스템 콜이 호출되는 상황이라면 이 메세지를 출력하지 않는다. 프로세스가 load 되는데 실패하는 경우 메세지 출력여부는 선택적이다. 그 외에, 처음 제공된 상태의 PintOS가 아직 인쇄하지 않는 다른 메세지는 디버깅 시 유용할 수 있으나, 성적 스크립트에 혼란을 줘 낮은 성적을 받을 수 있기 때문에 인쇄하지 않는게 좋다.
실행파일로 사용 중인 파일, 즉 실행중인 파일에 쓰기를 거부하는 코드를 추가. 디스크에서 수정되는 중인 코드를 어떤 프로세스가 실행하려고 한다면 어떤 예측하지 못한 결과가 발생할지 모르기때문에 많은 운영체제들이 이러한 조치를 취하고 있다. 이부분은 프로젝트 3에서 가상 메모리를 구현하면 특히 더 중요해지는데, 지금 한다고 손해는 아니다.
file_deny_write()
함수를 사용해 모든 열려있는 파일에 대해 쓰기를 막을 수 있다. 해당 파일에 대해 해당 함수를 다시 부르게 되면 다시 쓰기 기능을 가능케 할 수 있다(해당 파일을 열고있는 다른 곳에서 쓰기를 막고있는게 아니라면). 또한, 파일을 닫아도 쓰기가 가능해진다. 그러므로, 어떤 프로세스의 실행파일에 대해 쓰기를 막기 위해서는 프로세스가 돌아가는 동안 해당 파일을 계속 열려있어야 한다.
interrupt frame에 저장된 레지스터 값 중 rax
에 system call number가 저장되어 있다. rax
의 값을 가져와서 syscall_nr.h
에 정의된 enum 값에 따라 상황에 맞는 syscall 함수를 호출하도록 swtich-case문을 사용한다. 각각의 syscall에 따라서 argument를 1개 받는 것도, 3개를 받는 것도, 다양하게 있는데, 레지스터 순서대로 rdi, rsi, rdx, r10, ...
레지스터의 값을 인자의 개수만큼 차례대로 함수에 넘겨준다.
void exit (int status);
Terminates the current user program, returning status to the kernel. If the process's parent waits for it (see below), this is the status that will be returned. Conventionally, a status of 0 indicates success and nonzero values indicate errors.
현재 프로그램을 종료시키며, 커널에게 status를 돌려준다. 부모 프로세스를 해당 프로세스를 기다리고 있다면 여기서 입력되는 status가 부모에게 전달된다. 관례에 따라 0은 성공, 0이 아닌 값은 에러를 의미한다.
해당 status는 exit 함수의 caller가 결정하며, 커널에서 강제 종료시키는 경우 -1, 정상 종료하는 경우 0 등의 값을 넘긴다. 커널에서 강제 종료시킨다면 커널의 프로세스 중에서 exit(-1)
과 같이 부를 것이고, 유저 프로그램에 의해 syscall을 통해서 불러진다면 caller는 인터럽트 프레임의 rdi 필드에 exit status 값을 저장해 argument로 넘겨줄 것이다. 그러면 exit 함수는 thread struct에 해당 exit status를 저장해줌으로써 해당 스레드(프로세스)의 부모가 exit status를 확인할 수 있도록 해준다.
thread_exit()
을 사용
-> process_exit()
을 호출
-> interrupt disable
-> 현재 스레드는 THREAD_DYING
상태로 변경하며, 다음 스레드로 schedule()
process_exit()
-> process_cleanup()
-> TODO : implement sema, fd
pid_t fork (const char *thread_name);
Create new process which is the clone of current process with the name THREAD_NAME. You don't need to clone the value of the registers except %RBX, %RSP, %RBP, and %R12 - %R15, which are callee-saved registers. Must return pid of the child process, otherwise shouldn't be a valid pid. In child process, the return value should be 0. The child should have DUPLICATED resources including file descriptor and virtual memory space. Parent process should never return from the fork until it knows whether the child process successfully cloned. That is, if the child process fail to duplicate the resource, the fork () call of parent should return the TID_ERROR.
The template utilizes the pml4_for_each() in threads/mmu.c to copy entire user memory space, including corresponding pagetable structures, but you need to fill missing parts of passed pte_for_each_func (See virtual address).
입력되는 thread_name을 이름으로 가진 현재 프로세스의 복제본을 생성한다. callee-saved 레지스터인 %RBX, %RSP, %RBP, and %R12 - %R15 를 제외하고는 레지스터의 값을 복제해올 필요 없다. 생성되는 자식 프로세스의 pid(tid)를 리턴값으로 꼭 돌려줘야 하며, 그렇지 않는 경우 자식의 pid는 유효하지 않아야 한다. 자식 프로세스에서의 리턴값은 0이어야 한다. 자식은 file descriptor와 가상 메모리 공간을 포함한 부모의 자원의 복제본을 가지고 있어야 한다. 부모 프로세스는 자식 프로세스가 성공적으로 복제된 것을 확인하기 전까지는 fork 함수에서 return해서는 안된다. 즉, 자식 프로세스가 부모 자원의 복제를 실패하는 경우 부모의 fork 호출은 tid_error을 return 해야한다.
주어진 템플릿은
threads/mmu.c
에서 제공하는pml4_for_each()
를 사용해 사용자 메모리 공간 전체를 복사하고 있다. 해당하는 페이지 테이블 구조를 모두 포함해서. 하지만 여전히pte_for_each_func
의 빠진 부분들을 채워야 한다.
callee-saved 레지스터는 caller 입장에서 값이 유지될 것을 보장받는 레지스터이며, 프로세스를 fork할 때에도 해당 레지스터 값들은 복제가 되어야 한다.
process_fork(thread_name, intr_frame)
을 사용
-> thread_create()
: current thread에 대해서 __do_fork
int exec (const char *cmd_line);
Change current process to the executable whose name is given in cmd_line, passing any given arguments. This never returns if successful. Otherwise the process terminates with exit state -1, if the program cannot load or run for any reason. This function does not change the name of the thread that called exec. Please note that file descriptors remain open across an exec call.
현재의 프로세스를 cmd_line을 통해 받은 이름을 가진 실행파일로 변경하며, cmd_line으로 함께 받은 argument는 pass 해준다. 실행에 성공한다면 해당 함수는 절대 return 하지 않는다. 프로그램이 로드되지 않는다거나, 모종의 이유로 실행이 되지 않는 등 실행에 실패한다면 해당 프로세스는 exit state -1 로 종료한다. 이 함수는 이 함수를 부른 thread의 이름을 변경하지 않는다. file descriptor는 exec 호출의 처음부터 끝까지 열린 채로 유지된다는 것을 주의할 것.
int wait (pid_t pid);
Waits for a child process
pid
and retrieves the child's exit status. Ifpid
is still alive, waits until it terminates. Then, returns the status thatpid
passed to exit. Ifpid
did not callexit()
, but was terminated by the kernel (e.g. killed due to an exception),wait(pid)
must return-1
. It is perfectly legal for a parent process to wait for child processes that have already terminated by the time the parent calls wait, but the kernel must still allow the parent to retrieve its child’s exit status, or learn that the child was terminated by the kernel.
wait
must fail and return-1
immediately if any of the following conditions is true:
pid
does not refer to a direct child of the calling process.pid
is a direct child of the calling process if and only if the calling process receivedpid
as a return value from a successful call tofork
. Note that children are not inherited: if A spawns child B and B spawns child process C, then A cannot wait for C, even if B is dead. A call towait(C)
by process A must fail. Similarly, orphaned processes are not assigned to a new parent if their parent process exits before they do.- The process that calls
wait
has already calledwait
onpid
. That is, a process maywait
for any given child at most once.자식 프로세스를 기다린 후 자식 프로세스의 exit status를 가져온다. pid의 프로세스가 아직 살아있다면 해당 프로세스가 끝날 때까지 기다린다. 자식 프로세스가 종료되면, 자식 프로세스의 exit status를 return 한다. pid 프로세스가 exit 함수를 부른 것이 아니라 커널이 강제 종료시킨거라면 -1을 return 해야한다. 이미 종료한 자식에 대해서 부모가 wait을 하는 것은 지극히 정상적인 일이며, 이때 커널은 부모가 자식의 exit status를 여전히 가져올 수 있도록 해주거나 자식이 이미 종료했음을 알 수 있도록 해주어야 한다.
wait 함수는 다음의 경우에 즉시 -1을 return 하며 실패해야한다 :
- pid 프로세스가 부모 프로세스의 직계 자식이 아닐 때. 부모 프로세스가 성공적인 fork 함수 호출을 통해서 자식 프로세스의 pid를 받은 경우에만 pid 프로세스가 호출 프로세스의 직계 자식이 된다. 자식은 물려받지 않는 다는 것을 주의 : A가 B를 낳고 B가 C를 낳는다면, A는 C를 wait할 수 없으며, B가 죽었다고 해도 wait할 수 없다. A가 C에 대해 wait을 호출한다면 해당 호출은 실패가 뜬다. 이와 유사하게, 부모가 먼저 exit을 해서 부모를 잃어버린 프로세스는 새로운 부모에게 할당되지 않는다.
- wait을 호출하는 프로세스가 이미 이전에 pid 에 대해서 wait을 호출한 경우. (중복 호출하는 경우) 즉, 주어진 자식 프로세스에 대해서는 최대 1번까지만 wait할 수 (기다릴 수) 있다.
(같은 부모가 같은 자식에 대해서 동시에 여러번 기다릴 수 없으며, child 프로세스가 종료된 후에는 부모가 기다리지 않고 exit status만 가져오기 때문에 기다리는 것은 오로지 한번만 가능하다.)
process_wait()
Waits for thread TID to die and returns its exit status. If it was terminated by the kernel (i.e. killed due to an exception), returns -1. If TID is invalid or if it was not a child of the calling process, or if process_wait() has already been successfully called for the given TID, returns -1 immediately, without waiting.
입력된 입력된 tid가 유효하지 않거나 직계 자식이 아닌 경우,
bool create(const char *file, unsigned initial_size)
=> filesys_create()
을 호출
=> inode를 생성하며, disk에 해당 내용을 쓴다.
struct disk *filesys_disk;
/* Initializes the file system module.
* If FORMAT is true, reformats the file system. */
void filesys_init(bool format) {
filesys_disk = disk_get (0, 1);
if (filesys_disk == NULL)
PANIC ("hd0:1 (hdb) not present, file system initialization failed");
inode_init ();
#ifdef EFILESYS
fat_init ();
if (format)
do_format ();
fat_open ();
#else
/* Original FS */
free_map_init ();
if (format)
do_format ();
free_map_open ();
#endif
}
bool filesys_create(const char *name, off_t initial_size) {
disk_sector_t inode_sector = 0;
struct dir *dir = dir_open_root ();
bool success = (dir != NULL
&& free_map_allocate (1, &inode_sector)
&& inode_create (inode_sector, initial_size)
&& dir_add (dir, name, inode_sector));
if (!success && inode_sector != 0)
free_map_release (inode_sector, 1);
dir_close (dir);
return success;
}
/* An open file. */
struct file {
struct inode *inode; /* File's inode. */
off_t pos; /* Current position. */
bool deny_write; /* Has file_deny_write() been called? */
};
파일의 메타데이터를 저장하는 자료구조 (data structure for storing metadata about a file)
/* In-memory inode. */
struct inode {
struct list_elem elem; /* Element in inode list. */
disk_sector_t sector; /* Sector number of disk location. */
int open_cnt; /* Number of openers. */
bool removed; /* True if deleted, false otherwise. */
int deny_write_cnt; /* 0: writes ok, >0: deny writes. */
struct inode_disk data; /* Inode content. */
};
/* On-disk inode.
* Must be exactly DISK_SECTOR_SIZE bytes long. */
struct inode_disk {
disk_sector_t start; /* First data sector. */
off_t length; /* File size in bytes. */
unsigned magic; /* Magic number. */
uint32_t unused[125]; /* Not used. */
};
/* Index of a disk sector within a disk.
* Good enough for disks up to 2 TB. */
typedef uint32_t disk_sector_t;
/* List of open inodes, so that opening a single inode twice
* returns the same `struct inode'. */
static struct list open_inodes;