일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 29 | 30 | 31 |
- 내일부터
- 가테
- 자 이제 시작이야
- PINTOS
- 핀토스 프로젝트
- 아직도 실험 중
- 제발흰박
- 노가다
- 핀토스 프로젝트 1
- alarm clock
- 글루민
- 바빠지나?
- 일지 시작한 지 얼마나 됐다고
- multi-oom
- 핀토스 프로젝트 4
- 핀토스 프로젝트 3
- 끝
- 핀토스 프로젝트 2
- 황금 장미
- 파란 장미
- 셋업
- botw
- 마일섬
- Project 1
- 글리치
- Today
- Total
거북이의 쉼터
(2022.02.06) System Calls 가이드라인 (1/2) 본문
가이드를 쓰는데 좀 오래걸렸다. 읽어봐야 할 부분이 한 두 개가 아니라....
드디어 오고야 말았다. 가장 귀찮았던 부분... syscall을 하나하나 구현하면 된다.
syscall 요청이 들어올 경우, syscall.c에 있는 syscall_handler가 호출되면서 필요한 syscall을 실행시키는 것이 주요 과정이다. 어떤 syscall을 호출할지는 호출 시 rax 레지스터에 무슨 값이 들어있는지를 판단하여 호출하며, 해당 syscall을 호출할 때 필요한 인자 또한 비슷한 방식으로 레지스터에서 넘겨받는다. 이것이 x86이랑 비교했을 때 크게 달라지는 부분이다. x86의 경우 argument가 stack에 들어가 있는 상태였으며, esp에 대해서 상대 주소를 참조해서 가져오는 방식이었다. 그러나 x64 방식을 채택한 현 pintos의 경우, rdi, rsi, rdx, r10, r8, r9 순서로 레지스터에 argument가 들어가 있다. 따라서 argument에 접근할 때 레지스터를 활용하여 구현하는 것이 큰 차이이다. 해당 레지스터들은 인자로 넣어준 intr_frame 내에 멤버로 들어가 있다.
해당 프로젝트에서는 halt, exit, fork, exec, wait, create, remove, open, filesize, read, write, seek, tell, close의 syscall을 구현하는 것이 목표이다. (그리고 extra 프로젝트로 dup2까지) 지금은 해당 종류가 끝이지만, 점차 진행할 수록 그 수가 늘어날 것이다. 예전에 진행했던 기억을 되짚어보면, 주로 XXX syscall을 구현하기 위해서는 process_XXX 또는 filesys_XXX 함수를 이용하는 것이 정석적인 방법이었다. 따라서 해당 코드를 살펴보면 구현에 있어 실마리가 잡힐 것이다.
또 한 가지 중요한 것은 아직 filesys에 되도록 손을 대지 않는 상태에서 file과 관련된 함수들에 대해서 요구사항이 있다는 것이다. 여러 개의 프로세스가 하나의 파일에 접근할 때 문제가 발생하지 않도록 file system code를 critical section 처럼 다뤄야 한다는 것인데, 이는 프로젝트 1에서 구현한 락을 통해 해결할 수 있을 것이다. 해당 문제가 발생하는 syscall은 exec, create, remove, open, filesize, read, write, tell, close가 있다. (지금은 dup2는 일단 생각하지 않도록 하자) 해당 함수들에서는 파일 관련 함수를 호출하기 전에 락을 걸어주는 작업을 하도록 한다.
이제 함수 각각을 보면서 주의 사항이나 도움이 되는 함수들을 살펴보도록 하자. fork는 복잡하므로 가장 나중에 다루기로 하자.
1. halt
친절하게 매뉴얼에서 power_off()를 사용하라고 알려준다.
2. exit
현재 실행되고 있는 process의 부모 process에게 종료시 status를 넘기고 process를 종료시키는 역할을 한다. process_exit을 사용해야 하는 것으로 보이며, 주석을 보면 thread_exit을 통해 호출할 수 있다. process_exit에서는 process_cleanup을 호출하고 있는데, 해당 함수에서는 종료하려는 현재 process에서 점유하고 있는 자원을 정리해야 하는 것으로 보인다.
문제는 해당 process_cleanup이 process_exec을 하는 중에도 호출이 된다는 것이다. 단순히 현재 프로세스를 종료하는 것과 현재 프로세스에서 새로운 프로세스를 생성하고 종료하는 것에 차이가 있기 때문에 공통으로 제거해야 하는 자원만 process_cleanup에서 제거하고 나머지는 각 함수에서 제거하도록 한다.
3. exec
process_exec 함수로 구현하면 될 것이다. 매뉴얼 상 나와있는 주의점은 exec을 할 때 기존 프로세스에서 열어두었던 file descriptor를 그대로 열어두어야 한다는 것이다. 실제로 리눅스에서 실험해보면 기존 process에서 열어둔 fd를 통해서도 exec을 통해 실행된 새로운 process가 작업을 하는 것이 가능했다.
그런데, 앞의 exit 관련에서도 살펴보았듯, process_exec을 할 때는 process_cleanup이 호출이 된다. 이 때문에 process_cleanup 내에서는 file descriptor와 관련된 것은 건들이면 안 된다.
4. wait
fork된 자식(child)을 부모(parent)가 기다릴 때 사용한다. 반환 결과는 child가 어떻게 종료됐는지를 알려주는 exit code이다. 가장 우선시해서 구현해야 하는 함수 중 하나인데, 이는 지난 포스팅에서 살펴본 run_task에서 process_wait을 통해 유저 프로세스가 제대로 실행되도록 대기하기 때문이다.
process_wait (process_create_initd (task));
때문에 지난 포스팅에서는 임시적으로 무한 루프를 돌려놨으나 제대로 된 해결 방식으로 구현할 필요가 있다. 제대로 동작하기 위해서는 child에서 process_exit이 호출될 때까지 parent를 멈춰놓고, child의 exit code가 결정되면 이를 parent에 넘겨준 뒤 child는 종료되면 된다. 이를 위한 해결책으로는 semaphore를 이용해서 프로세스간 상태를 확인하는 방법을 활용한다.
우선 parent는 0으로 초기화된 semaphore A를 down하면서 block된다. child는 process_exit까지 당도했으면, semaphore A를 up시키면서 parent에게 주도권을 넘겨줘야 한다. 때문에 child를 잠시 block시키기 위해 또다른 0으로 초기화된 semaphore B를 child에서 down하면서 block된다. 이렇게 하면 parent가 주도권을 잡아 child의 exit code를 알아내는 것이 가능하다. exit code를 알아낸 뒤, parent에서 semaphore B를 up해주면 언젠가 child가 종료가 된다.
물론 실제로는 여기에 더 복잡한 과정이 추가될 필요가 있다. 자식을 기다리지 않고 부모가 죽으면 발생하는 고아 프로세스 등 실제 커널에서는 더 복잡한 문제가 있다. 그러나 우리의 친절한 매뉴얼님은 그런 문제따위 신경쓰지 말라고 하신다.
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 to wait(C) by process A must fail. Similarly, orphaned processes are not assigned to a new parent if their parent process exits before they do.
상속 문제는 피했지만 문제는 semaphore로 구현할 경우, 부모가 먼저 죽어버리면 child에서 semaphore를 down할 때 block에서 풀어줄 대상이 없다는 것이다. 아 젠장 어떻게 구현하지...
5. create
filesys_create를 사용해서 구현할 수 있을 것이다. 인자까지 비슷한 구조니 그대로 사용해보자. 파일을 실제로 여는 것은 open 함수가 할 일이니 열지는 않는다.
6. remove
filesys_remove를 사용해서 구현하자. 주의할 점은 open된 상태의 파일은 제거되더라도 close되는 것이 아닌 close를 할 때까지 시스템 상에 남아있다는 것이다. FAQ에 자세한 내용이 나와있다.
You should implement the standard Unix semantics for files. That is, when a file is removed any process which has a file descriptor for that file may continue to use that descriptor. This means that they can read and write from the file. The file will not have a name, and no other processes will be able to open it, but it will continue to exist until all file descriptors referring to the file are closed or the machine shuts down.
이름도 없고, 다른 프로세스도 열 수는 없지만 file descriptor를 통해 접근할 수는 있어야 한다고 나온다. 이와 관련되어 filesys_remove의 코드를 타고 들어가면서 보면 실제로 시스템 내에서 파일을 지우는 inode_remove가 호출되더라도 open_cnt가 0으로 떨어지지 않으면 제거되지 않는다는 것을 확인할 수 있다. remove가 호출된 상황에 파일이 열려있다는 것은 open_cnt가 0으로 떨어지지는 않았다는 것이므로 제거되지 않는다는 것을 알 수 있다. 따라서 별개로 filesys의 코드를 바꿀 필요는 없어 보인다.
7. open
filesys_open을 통해 구현한다. 중요한 점은 create나 remove와 달리 filesys_open과 open syscall은 반환하는 값의 타입이 다르다는 것이다. filesys_open은 file *를 반환하는데 반해 open은 열린 파일의 file descriptor, int를 반환해야 하기 때문이다. 따라서 열린 파일의 file descriptor를 지정해주는 루틴이 필요하다.
모든 프로세스가 시작할 때 0과 1은 이미 표준 입출력인 stdin과 stdout에 지정이 되어있는 상태이다. 따라서, 이들은 원칙적으로는 반환하면 안 된다. 2부터 시작해서 빈 정수가 있으면 채워넣는 식으로 반환하면 될 것이다. 다만 해당 문구 때문에 추가로 생각할 것이 생겼다.
You should follow the linux scheme, which returns integer starting from zero, to do the extra.
0부터 시작해서 반환해야 한다는 것인데 이는 왜일까. extra 프로젝트는 dup2이다. 이는 해당 프로젝트를 할 때 자세히 설명하겠지만, 간단히 설명하면 file descriptor를 복제하는 것이다. stdin과 stdout에 해당하는 0, 1을 다른 file descriptor로 복제한 뒤, 0과 1을 닫게 되면 0, 1이 없는 상태로 정상적인 기능을 할 수 있다. 따라서 0과 1도 상황에 따라서는 충분히 open의 반환 결과로 나올 수 있도록 해야 하며, 이를 고려해서 열린 파일의 file descriptor를 지정하는 루틴을 구현해야 한다.
또 다른 제약조건은 FAQ에서 찾을 수 있다. 열 수 있는 파일 개수에 제한이 있는지를 묻는 질문인데, 답은 다음과 같다.
It is better not to set an arbitrary limit. You may impose a limit of 128 open files per process, if necessary. But if you want to implement extra requirements, there should be no limitation.
열린 파일 수의 제약이 없어야 한다는 것인데 막막하다... 일단 염두해두고 넘어가도록 하자.
8. filesize
file.c에 있는 file_length를 사용하면 될 것이다.
9. read
fd (file descriptor)가 0, 즉 stdin에서 읽어오는 것은 input_getc()를 활용하라고 매뉴얼에 나와있다. (dup2까지 확장되면 stdin과 연결된 모든 fd에 대해서) 나머지 fd에 대해서는 file.c에 있는 file_read를 활용하면 될 것 같다.
10. write
주어진 버퍼에 담긴 string을 주어진 길이 이하에서 쓸 수 있을만큼 작성하고 실제로 작성된 byte 수를 반환한다. 일반적으로는 End of File (EOF)을 만나게 됐을 때, 파일을 확장하는 file growth 루틴으로 인해 파일이 확장되면서 주어진 길이만큼 모두 작성이 될 것이다. 그러나 아직 기초 file system에는 file growth 루틴이 없어 EOF 너머는 더 작성되지 않는다. 작성이 되지 못하는 경우 0을 반환한다.
fd가 1, 즉 stdout으로 쓰는 것은 putbuf()를 활용하라고 매뉴얼에 나온다. (dup2까지 확장될 경우 stdout과 연결된 모든 fd에 대해서) 나머지 fd에 대해서는 file.c에 있는 file_write를 이용할 것이다.
11. seek
file.c에 정의된 file 구조체에는 현재 파일의 어디 부분에 읽기/쓰기가 위치해 있는지를 나타내는 pos 멤버가 있다. 해당 멤버의 값을 변경하는 함수인 file_seek를 이용하자.
12. tell
file 구조체 내의 pos 멤버를 반환하는 함수인 file_tell을 활용해 구현한다.
13. close
file.c에 있는 file_close로 구현한다. exit 등으로 인해 프로세스가 종료될 경우 열려있는 파일은 전부 이 함수를 호출하는 것과 같이 close되어야 하므로, 이를 고려한다.
14. fork
드디어 가장 복잡한 syscall을 맞이한다. 사실상 wait와 한 쌍인 syscall이다. 현재 실행되고 있는 프로세스의 복사본을 만드는 함수이며, 주어진 thread_name을 이름으로 하는 프로세스를 만든다. 원래 프로세스에서는 새롭게 만들어진 프로세스의 tid가 반환되며, 새롭게 만들어진 프로세스에서는 0이 반환된다. 원래 프로세스에서 실제로 새로운 프로세스가 성공적으로 생성되었는지를 판단해서 실패하면 TID_ERROR를 반환해야 하기 때문에, 원래 프로세스(부모)는 새로운 프로세스(자식)의 복제 성공 여부를 알기 전까지는 return하면 안 된다.
복사본을 만들어야 하는 것이기에 원래 프로세스에서 가져와야 할 것이 많다. rbx, rsp, rbp, r12 ~ r15의 레지스터 값, 메모리 매핑, file descriptor를 모두 복제해서 새로운 프로세스가 가지고 있어야 한다. 메모리 매핑 복사에 대해서는 매뉴얼에 참고할 사항들이 나와 있다.
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).
pte_for_each에 사용될 함수를 설계해서 각 메모리 매핑을 복사하면 될 것이다. file의 경우에는 file.c에 있는 file_duplicate를 활용하라고 주석에 나와있다. 우선 file descriptor를 관리할 형태가 구현이 되지 않았으니, 다른 syscall 부터 구현한 뒤에 차차 하도록 하자. 남은 문제는 두 가지 정도이다.
첫 번째 문제는 어떻게 child가 정상적으로 생성됐는지를 판단하는 시기까지 대기하고 반환하는지인데, 이는 앞서 살펴본 wait와 비슷한 문제이다. 따라서 fork에 대한 해법도 semaphore를 사용하면 될 것이라고 우선 추측만 하고 넘어간다.
두 번째 문제는 어떻게 반환 값을 두 프로세스에서 다르게 하는지이다. 이는 새롭게 생성된 프로세스에서 반환값을 0으로 설정하는 루틴을 추가하면 된다. 스켈레톤 코드를 보면 process_fork를 통해 새롭게 thread (pintos에서는 thread와 프로세스가 동류)를 생성하면서 시작 루틴으로 __do_fork를 실행하게 한다. 따라서 __do_fork에서 반환될 값을 0으로 설정하면 된다. 이를 위해서는 반환값이 어떻게 처리되는지를 우선 알아야 한다.
유저 프로세스는 syscall을 호출하면서 커널에 주도권을 맡긴다. 커널은 유저 프로세스를 대신해 호출을 처리하면서 그 반환을 rax 레지스터에 넣어서 다시 유저 프로세스에 주도권을 넘긴다. 자세한 것은 코드를 더 뜯어봐야 하겠지만 fork를 호출할 때, 유저 프로세스는 syscall을 호출하고 rax로 그 반환값을 받는 것을 대기하는 상태에서 멈춰있을 것이다. 따라서 새롭게 생성된 프로세스, 즉 자식 프로세스에서만 인위적으로 rax를 0으로 설정해주기만 하면 해당 프로세스에서 fork의 반환값은 0으로 설정될 것이다. 부모 프로세스에서는 정상적으로 process_fork의 반환값, 즉 thread_create를 통해 얻은 자식의 tid를 rax에 넣어주면 fork의 반환값이 두 프로세스에서 각각 다르게 설정될 수 있을 것이다.
코드 상으로는 process_fork와 그 하위 함수들을 이용해야 할 것으로 보인다. 주석을 읽어보면 해당 함수들에서 구현할 것이 많기에 이는 다음 가이드라인에서 더 자세히 설명하도록 한다.
아.. 많아라... fork에 대해서 조금만 더 코드를 읽어보고 바로 코딩으로 들어가겠다.
'코딩 삽질 > KAIST PINTOS (CS330)' 카테고리의 다른 글
(2022.02.11) System Calls 구현 (1/4) (0) | 2022.02.11 |
---|---|
(2022.02.08) System Calls 가이드라인 (2/2) (2) | 2022.02.08 |
(2022.02.03) User Memory Access 구현 (0) | 2022.02.03 |
(2022.02.03) User Memory Access 가이드라인 (0) | 2022.02.03 |
(2022.02.02) Argument Passing 구현 (0) | 2022.02.02 |