Jungle

[TIL] pintos : 유저 프로세스 - 시스템 콜 구현(fork,wait,exec,exit)

손가든 2023. 12. 14. 19:11

2주차를 마치며 3주차에 들어가기 전에 바쁘게 달려오느라 작성하지 못했던 TIL을 작성해보려 한다.

 

 

pintos 운영체제 유저 프로세스 관리에 대해 구현하면서 가장 큰 의미는

 

프로세스의 생명 주기 동안 어떤일이 벌어지는지 머리속에 그림으로 정리할 수 있게 되었다는 것이다.

 

 

비록 실제로 운영되는 리눅스, 윈도우, MACOS 에서 하나의 상황(예를 들면 wait)을 서로 다른 방식으로 대처 하고 있다 하더라도

 

그 상황을 왜, 어떻게 처리 해주어야 하는지 이해할 수 있는 기간이었다.

 

 

오늘은 이러한 생각들을 최대한 기억에 오래 남기기 위해 코드와 함께 알게된 것들을 기록해 보겠다.

 


 

system call

 

시스템 콜에 대해 먼저 이야기 해보자.

 

유저 프로그램이 시스템 콜을 호출하면, 커널 영역의 system call 함수들이 실행된다.

 

 

이때 유저 프로그램은 시스템 콜을 호출하기 전에

 

어떤 시스템 콜을 호출하는지(예를 들면 exit = 1번)를 전달하기 위해 rax 레지스터에 시스템콜 넘버를 넣고,

 

시스템 콜에 필요한 인자들은 CSAPP 3장에서 공부한 것처럼 순서대로 rdi, rsi, rdx, r10, r8, r9 에 집어넣은 뒤

시스템콜을 호출한다.

 

 

커널은 시스템 콜 핸들러에서 rax 레지스터의 숫자를 확인하여

 

어떤 시스템 콜이 들어왔는지 살펴보고 인자 레지스터 값과 함께 해당 실행 함수로 이동시켜준다.

 

lib/user/syscall.c

 

위 코드는 유저 프로그램이 시스템 콜을 호출할 때 수행하는 코드이다.

 

이 코드 어셈블리어를 보면 각 인자들을 집어넣으며 시스템 콜을 하기 위한 작업을 수행하고 있다.

 

 

이후 syscall을 어셈블리어로 호출하면 커널 비트가 켜지면서 커널 모드로 넘어가고

 

현재 인터럽트 프레임은 인터럽트 핸들러로 넘어간다.

 

 

이 인터럽트 핸들러에서는 유저 프로세스로부터 전달받은 인터럽트 프레임의 vec_no로 시스템콜을 호출한다는 것을 전달받아

 

handler(frame)을 통해 PC를 systemcall_handler()함수로 옮겨준다.

 

 

 

그럼 커널 영역에서 유저 프로세스의 전달받은 인터럽트 프레임을 통해서 인자와 어떤 시스템 콜인지 확인하며 함수를 실행시켜줄 수 있다.

 


 

system call : exec ()

 

시스템 콜 중 exec() 시스템 콜은 유저 프로그램을 로딩하여 생명을 부여한 뒤 유저 프로세스를 만드는 작업을 수행한다.

 

우리 컴퓨터 내부에 있는 디스크는 많은 파일들을 저장해 두고 있다.

 

이 exec()는 디스크에 있는 사용자가 원하는 실행파일을 프로세스의 가상 메모리에 적재(load)하며 context를 채우는 역할을 한다.

 

process_exec()

 

위 코드는 process_exec() 함수를 구현한 것이다.

 

코드는 5단계를 거치며 process가 실행될 준비를 한다.

 

 

1번은 process_exec() 함수를 실행하는 스레드의 인터럽트 프레임과는 다른

 

새로운 process 인터럽트를 초기화해주고 있다.

 

이 인터럽트 프레임은 마지막 5번 do_iret() 함수에서 전달된 후

 

CPU를 점유하는 컨텍스트는 이제 exec()로 생성된 프로세스로 전환된다.

 

 

2번을 통해 현재 exec()를 실행하는 process의 컨텍스트는 삭제한다.

 

exec()를 실행하는 프로세스는 이제 끝나며 새롭게 실행할 프로세스로 탈바꿈할 준비를 한다.

 

 

3번에서 load()를 통해 실제로 디스크에 있는 프로그램을 읽어와 실행할 프로세스의 가상 메모리로 적재한다.

 

여기서 인터럽트 프레임의 코드 세그먼트, 데이터 세그먼트, 런타임 스택이 초기화된다.

 

 

이후 4번 단계를 통해 comandline에 사용자가 입력한 실행할 파일과 인자를 파싱한 후 가상 메모리의 런타임 스택에 채운다.

 

 

 마지막 5번 단계에서 context_switch가 일어난다.

 

 

 

아래는 load 함수 코드이다.

 

load함수에서는 다른 프로세스가 동일한 파일을 열어 로딩할 때 충돌이 발생하는 경우가 발생하여,

 

한번에 하나의 프로세스만 open하여 읽을 수 있게 lock으로 동기화했다.

 

그리고 만약 이 프로세스가 load에 성공했다면 close하지 않고 열어놓아

 

이 프로세스가 실행하는 동안에는 이 파일에 대한 쓰기 권한을 방지함으로써 현재 프로세스의 코드에 수정이 없도록 했다.

 

 

이 열어놓은 파일은 마지막에 이 프로세스가 exit할 때, close함으로써 이후 file_allow_write를 호출할 수 있게 했다.

 

process_exit() 함수 내에 들어가 있는 load_file close

 

 

 

system call : fork ()

fork 함수는 현재 진행중인 유저 프로세스와 완벽히 동일한 프로세스를 다른 스레드로 생성하는 함수이다.

 

 

 

fork()는 특별하게 인터럽트 프레임 자체를 인자로 전달한다.

 

왜냐하면 시스템콜을 호출한 부모 스레드의 인터럽트 프레임을 복사해야 하기 때문이다.

 

인터럽트 프레임은 매 함수를 타고갈때마다 변하기 때문에 (인자 전달 및 상태가 순간적으로 계속 변하기 때문)

 

인터럽트 프레임은 fork가 시작되는 process_fork 함수에서 바로 memcpy()를 통해 생성될 현재 스레드에 저장해준다.

 

 

이후 _do_fork함수를 수행하는 스레드를 생성하여 현재 프로세스를 복사하는 작업으로 들어간다.

 

do_fork를 통해 프로세스를 복제한 이후로는 자식이 복제가 끝날때 까지 기다리고 복제가 끝났다면 자식의 pid를 반환하도록 하였다.

 

 

여기서 꼭 이해해야 할 점은 

 

process_fork() 함수의 흐름은 부모 프로세스이고

 

process_fork() 안에서 thread_create로 생성되어 do_fork로 시작되는 스레드는 자식 프로세스이다.

 

따라서 do_fork에서 thread_current()는 자식 프로세스의 스레드,

 

process_fork()에서 thread_current()는 부모 프로세스의 스레드 이다.

 

 

__do_fork()

 

여기서부터는 current는 child이고, parent가 fork하려는 프로세스이다.

 

먼저 새로운 프로세스를 활성화시켜주기 위해 process_activate()를 수행한다.

 

이후 pml4_for_each() 함수 내에서 duplicate_pte 함수를 실행하여

 

현재 스레드의 가상메모리를 palloc함수로 할당받은 뒤 부모의 가상메모리를 복제한다.

 

 

이후 오른쪽 코드에서

 

for문을 통해 부모 스레드의 파일 디스크립터 테이블의 파일 값들을 자식 스레드에게 복제하고 있다.

 

 

이후 fork의 과정은 모두 마치게 되며

 

process_fork() 함수에서 sema_down을 통해 잠자고 있는 부모 스레드를 &dupl_sema를 up으로 깨워준다.

 

 

부모의 가상메모리를 복제하는 역할을 수행

 

duplicate_pte 함수 내부를 보면 newpage에 새로운 페이지를 할당받은 뒤

 

부모의 페이지로부터 memcpy로 복제하는 것을 확인할 수 있다.

 

 


system call : wait () & exit()

 

이번엔 인자로 들어온 pid의 프로세스가 끝날때까지 현재의 프로세스를 기다리게하는 wait() 시스템콜과

 

프로세스가 종료할때 사용하는 exit() 시스템콜을 살펴보자.

 

 

wait()는 exit()와 매우 깊게 상호작용 한다.

 

wait()는 인자로 들어온 프로세스가 exit()했을 때의 exit status를 return해야 하기 때문에

기다리는 프로세스가 exit()를 호출할 때까지 의무적으로 기다려야 한다.

 

따라서 서로는 semaphore를 통해 기다리는 상호작용이 필요하게 된다.

 

process_wait와 process_exit 함수

 

 

process_wait 함수를 통해 기다리고 있는 현재 스레드는 인자로 들어온 tid 스레드의 부모이다.

 

따라서 부모 스레드의 child_list에서 child를 찾을 수 있다.

 

 

이후 wait에서는 sema_down과 up을 한번씩 하며 exit와 상호작용하고 있다는 것을 알 수 있다.

 

이 child_wait_sema와 exit_sema는 모두 처음 세마포어가 init될때 0의 값으로 시작되어

 

누군가 up하지 않은 상태에서 down할 경우 스레드가 코드를 진행하지 않고 block된다.

 

 

이 메커니즘을 통해 스레드는 다른 스레드를 기다릴 수 있다.

 

자 그럼 이 방식을 활용하여 무엇을 기다리는지 exit()에서 sema_up(&child->child_sema_up)을 찾아보자.

 

 

자식은 exit() 시스템콜에서 부모에게 자신이 exit() 시스템콜을 통해 status를 저장했음을 알려준다.

 

하지만 이 status는 부모에게 전달되는 것이 아니라, 부모가 자식을 찾아 읽는 방식이다.

 

따라서 이 스레드가 만약 exit() 이후 page를 free하여 반환하면 본인에게 저장한 status 값이 날라가게 된다.

 

그래서 이번엔 자식이 thread를 완전히 삭제하기 전에 exit_sema를 통해 부모가 exit_status를 읽었는지 역으로 기다려야 한다.

 

 

따라서 process_wait()에서 sema_up으로 자식에게 죽어도 좋다는 신호를 주면 그제서야 thread는 dying 상태로 전환될 수 있다.