9. 주요 시스템 콜 동작 원리
2강에서 설명했던
fork()
의 작동 원리에 대해서 이어서 설명한다.fork()
뿐만아니라 이번 3강에서는 다양한 시스템 콜에 대해 학습한다. 또한 데몬(Daemon)과 서버(Server)에 대해서도 간단히 학습할 것이다.시작하기 앞서 이번 강의에서 등장할 그림들에 오류가 있다는 점을 언급하고 싶다. 오류가 있는 부분은 별도로 빨간색으로 마크해서 원래 있어야할 곳으로 표식을 해놓거나 중간 중간 어떤 부분에 오류가 있는지를 언급을 했으니 부디 설명을 읽으면서 헷갈리지 않길 바란다.
9.1 Fork(2)의 동작 원리
그림에서 수정된 사안이 하나 있는데, printf(“I am parent!\n”)부분이 else 구문에 속해야하는 것이 맞다. 이점을 주의해서 아래 설명을 보자.
위의 소스코드로 동작하고 있는 프로그램이 쉘(Shell) 프로그램이라고 해보자. 쉘 프로그램은 사용자로부터 입력을 기다리고 입력된 명령을 토대로 프로그램을 실행하는 교통 정리 프로그램이라고 우리는 배웠다. 쉘이 시작되면 명령어를 입력할 수 있는 터미널 혹은 프롬프트 창이 등장할 것이고 쉘은 터미널 혹은 프롬프트 창에 사용자로의 명령이 입력되기를 기다리고 있다. 아래와 같은 화면을 생각하면 된다.
우리가 쉘에 Microsoft의 Word 프로그램을 실행시키는 word라는 명령을 터미널에 입력했다고 해보자. 입력된 명령어를 받은 쉘은 가장먼저 fork( )를 진행한다. fork( )를 호출하면 자식 프로세스가 생성되면서 부모 프로세스와 완전히 동일한 소스코드(image) 갖게된다. 코드 뿐만 아니라 부모 프로세스의 PCB(Process Control Block)도 그대로 물려 받는다. (PCB에 대해서는 2강 강의노트에서 자세히 다뤘으니, 기억이 나지 않는다면 2강 강의노트에서 PCB 키워드로 검색을 해서 확인하길 바란다.)
# Note
fork()는 두번 리턴된다. 한 번의 리턴은 자식 프로세스에게 0값을 리턴하고 나머지 한 번은 부모 프로세스에게 자식 프로세스의 프로세스 아이디값을 리턴한다.
아직은 부모 프로세스가 CPU를 점유하고 있기에 fork( )로부터 리턴된 pid값은 자식 프로세스의 pid값이고 부모 프로세스는작업printf("I am parent!n")
을 마저 진행한다. 자식 프로세스의 pid값을 리턴 받음으로써 부모 프로세스는 자식 프로세스를 알고 통제할 수 있는 것이다. 부모 프로세스로부터 복제되어 생성된 자식 프로세스는 현재 ready queue에서 CPU가 자신에게 할당되기를 기다리는 중이다.
앞서 2강에서 fork( )는 두 번 리턴된다고 설명한 바 있다. 첫 번째 리턴에서는 자식의 pid(Process Id)를 리턴하므로 if 조건문을 건너 띄고 else 구문으로 넘어간다. else구문으로 넘어가면 printf(“I am parent!n”);가 실행되고 모니터 화면에는 I am parent가 나타나게 될 것이다. 작업을 다 마친 부모 프로세스는 종료가 된다.
이후 CPU의 점유권은 자식 프로세스에게 넘어가게 된다. 이론상 ready queue에 대기하고 있던 다른 프로그램들이 없었다고 가정한다면, 부모 프로세스가 끝남과 동시에 자식 프로세스는 CPU를 쥐게 된다.
자식 프로세스는 어떻게 동작할까? 위에서 fork()
가 실행되면서 부모의 코드(이미지) 뿐만 아니라 PCB를 통째로 복사했기 때문에 다음에 어디서부터 실행해야할지 알려주는 PC(Program Counter)와 SP(Stack Pointer) 등 또한 복사되었다. 즉 PCB에 존재하는 State Vector Save Area영역 (이하 state vector로 서술함)에 있는 PC
와 SP
등을 복사했기 때문에 자식 프로세스의 코드가 실행될 때는 맨 처음부터 실행되는 것이 아니라 fork()
중간에서부터 다시 진행하게 되어 있다.
대부분의 프로그램은 초기 실행될 때 main()
부터 시작한다. PCB에 그렇게 명시되어 초기화가 되기 때문이다. 하지만 지금 다루고 있는 자식 프로세스의 경우는 PCB에서 가리키고 있는 다음 실행주소(Program Counter)가 fork()
에 있었기 때문에, 자식프로세스는 **fork()**
중간 영역부터 진행한다. (중간 영역이라는 건 fork()
함수가 한창 진행중일 때 복사가 일어났으므로, 그 진행중이었던 파트부터 다시 진행된다는 의미로 해석하면 된다.)
자식 프로세스가 fork()
에서 리턴되면, 자식 프로세스 코드 안의 pid 변수는 0의 값(자식 프로세스 pid는 보통 0)을 가지기 때문에 I am Child \n이 화면에 출력되게 된다. 지금까지 다룬 내용을 다시한 번 정리하면서 아래 그림을 살펴보자.
위에서 수정했던 것과 마찬가지로 일단, printf("I am Parentn")
는 else문에 속해 있어야 하는 것을 염두하고 살펴보면, 첫 번째 출력값인 I am Parent는 일단 부모 프로세스가 시행한 작업이다. 그리고 부모프로세스가 끝나면서 자식 프로세스가 CPU를 점유하게 되면서 fork()
로부터 리턴 값을 받아 if문 조건을 만족하게 되고, printf("I am Child n")
를 실행하게 된다. 따라서 I am Child는 자식 프로세스가 시행한 작업이라고 할 수 있다. if문 끝단에 있는 execlp
구문 같은 경우는 바로 아래에서 이어서 설명한다.
9.2 Exec(2) 동작 원리
exec(2) 시스템 콜에 대해 알아보기 전 몇 가지 배경지식에 대해 먼저 짚어보고자 한다. 위 그림을 보면서 함께 설명을 따라가보자. 먼저 exec()
에 매개변수를 살펴보면, /bin
이 보인다. /bin
은 바이너리(binary) 파일만 모아둔 폴더(directory)를 의미한다. 그 폴더 안에는 바이너리 프로그램들이 수 십개가 존재하고 있는데, 그 바이너리 프로그램 마다 원래는 a.out
의 형식으로 되어 있지만 그 이름을 각자의 프로그램 제작사의 입맛에 맞게끔 설정해 놓았다(ls, cat, hwp, ppt 등).
코드를 살펴 보면 자식 프로세스 차례가 왔을 때 I am child!
부분의 출력문을 출력하고, execlp(exec 계열 함수)를 실행하게 되어 있다. exec
시스템 콜은 현재 돌아가고 있는 프로세스 위에 자신의 프로세스로 완전히 덮어씌어(over write) 버린다. 덮어쓴 후 exec 매개변수로 왔던 그 프로그램의 main( )으로 가는 것이 exec
의 작동 원리다.
새로운 프로세스가 생기는 것이 아니기 때문에, pid(Process Id)는 변하지 않는다. 다만 프로세스를 구성하는 코드(기계어 코드)와 데이터, 힙, 그리고 스택 영역의 값들이 exec으로 발생하는 새로운 프로그램의 것으로 바뀌게 된다.
설명은 위와 동일하다. exec은 자신의 프로세스를 현재 진행 중인 프로세스 위에 덮어 써버린다. 덮어 씀과 동시에 date의 main( )으로 넘어가는 것이고, 그 쪽에서 날짜를 출력해주는 작업을 진행한다. 그래서 유닉스나 리눅스에서는 프로세스의 생성이 fork( )하고 exec( )을 하는 두 스텝으로 존재한다.
fork( )는 image(= 소스코드)와 PCB를 전부 복사하는데, exec()
의 경우에는 현재 image에 새로운실행(execute)코드를 디스크로부터 바이너리 파일 형태로 가져온 후에현재 image에 덮어 씌우기(over write)를 진행하고 자신 프로세스의 main( )으로 진행하는 것이다. 한마디로 기존의 작업하던 것을 자신의 프로그램으로 갈아 치우고 자신의 프로그램을 가동시키는 행위라고 할 수 있다.
9.3 Wait(2) 동작 원리
시스템 콜은 결국 커널모드로 진입하는 것을 뜻한다. 위 그림을 보면서 wait( )에 대해 알아보자. 어떤 프로그램이 wait()
를 호출하면 해당 프로그램의 CPU 사용권한을 박탈한다. 위 그림의 본문 첫 줄에 등장하는 것처럼 프로세스 P_A로부터 CPU 사용 권한을 박탈한다(preempt).
임의의 프로세스 A(위 그림에서 P_A 라고 표현되어 있음)가 wait(2)
시스템 콜을 호출하면 커널모드(K)의 트랩 핸들러(Trap Handler)에 진입하여 wait( ) 시스템 콜 실행을 하게 되는데, 이때 시스템콜을 호출한 프로세스로부터 CPU를 뺐는다(preempt).
풀어쓰자면, 커널은 보통 자신의 작업을 다 하고 나면 호출한 프로세스의 유저 모드로 돌아가야 하는데, 유저모드로 돌아가지 않는다.
커널이 아닌 프로그램은 자신의 주소(address)에 한정되서 read, jump 등을 수 할 수 있지만 커널은 어디로든 가고 jmp(점프)할 수 있기 때문에 ready queue
에 가서 준비된 프로세스 중 우선순위가 가장 높은 프로그램의 PCB를 찾아서 PC(Program Counter)를 알아낸 후에 PC(프로그램 카운터)가 가리키 쪽으로 가는 것(jmp)이다. 이 과정이 preempt라 부른다.
그 아래의 그림을 살펴보자. 이번에는 부모 프로세스에 초점을 맞춰서 살펴보자. fork( )
후에 if문을 통과한 후에 else문에서 부모 프로세스는 자신의 일을 수행한다. 모든 일을 마친 후 소스코드의 마지막으로 가보니 wait( ) 시스템 콜을 호출하고 있다.
wait( ) 시스템 콜을 호출하면, 부모 프로세스는 잠들게 된다.자식 프로세스가 끝날 때까지 잠을 잔다(sleep). CPU는 자식 프로세스에게 넘어가고 자식프로세스는 자신이 할 일을 수행한다. 자식이 하는 일 중에 execlp("/bin/date"...)
라는 명령어가 마지막으로 있으니 해당 명령어를 마지막으로 수행하고 자식 프로세스는 중료한다.
자식 프로세스가 종료했을 때 CPU는자식 프로세스로부터 부모 프로세스를 찾는다. 그 후 CPU는 부모 프로세스를 대기명단(ready queue)에 등록시킨다. 이후 부모가 CPU 점유권을 받았을 때! 그 때가 바로 wait( ) 시스템 콜이 끝나는 지점이다. 부모는 이후 자신의 남은 일이 있었다면 해당 작업을 진행하게 된다.
# Note
비유를 들자면, 메일 프로그램을 들 수 있다. 메일 프로그램을 이용하는 목적은 상대에게 메일을 보내는 것이므로 우리는 ‘메일 쓰기’를 클릭할 것이고, 곧 텍스트를 입력할 수 있는 에디터가 나타난다. 여기서 메일은 부모프로세스고 텍스트 에디터는 자식 프로세스라고 할 수 있는데, 우리가 메일 쓰기를 마치면 자식 프로세스(텍스트 에디터)가 종료하면서 부모 프로세스(메일 프로그램)가 다시 등장하게 된다.
9.4 Exit(2) 동작 원리
메인함수 main()
가 끝날 때는 반드시 exit(2) 시스템 콜이 존재한다. 설령 우리가 소스 프로그램을 작성할 때, exit()
을 직접 기입하지 않았더라도 컴파일러가 알아서 main( ) { }의 마지막에 exit(2) 시스템 콜을 삽입하게 되어 있다. 아래 그림을 살펴보자.
자식 프로세스(pid: 0
)의 작업 중 execlp("/bin/date", ...)
가 있고 위에서 배웠듯이 exec(2)
계열의 시스템 콜(exec, execv, execlp ...
)이 실행되면서 현재 있는 프로세스 위에 인자로 주어진 프로세스(date)를 덮어 씌어버린다. 그리고 곧장 해당 프로세스의 main()
을 실행시키게 된다. 원래 저 노란 박스(main 함수가 들어 있는)에는 exit()
이라는 소스코드가 존재하지 않았다. 하지만 컴파일러가 컴파일을 할 때 삽입을 해줬고, 실제 만들어진 이진파일(binary file)을 열어 보면, exit(2)
에 해당하는 코드가 들어있게 된다.
위 그림에는 exit(2)
의 작동 원리가 좀 더 상세하게 적혀 있다. 이후 들어오는 신호들을 전부 무시해버리고, 파일들이 열려 있다면 파일들을 닫는다. 또한 메모리 영역에서 해당 프로세스가 차지하고 있는 부분(image)을 해제(deallocate) 해버리고, 부모 프로세스에게 통보한다. 그리고 exit(2)
을 호출한 프로세스의 상태를 좀비(ZOMBIE)상태로 설정한다. (좀비 상태라는 건 다음 강의에서 다루게 된다.)
커널에서 일어나는 동작으로는 먼저 exit(2)을 호출한 프로세스의 CPU를 빼았고, ready queue에 있던 다른 프로세스에게 CPU를 넘겨준다. 이 과정을 스케쥴링(scheduling)한다고 표현하는데, 실제로 exit(2)을 호출하게 되면 커널 안의 schedule( ) 함수가 호출된다. 스케쥴 함수 관련 설명은 글의 마지막 3번 부분에서 다룬다.
10. 시스템 콜 요약 정리 (Summary)
지금까지 우리는 프로세스를 위한 4가지 시스템 콜에 대해 살펴 보았다. fork()
는 부모 프로세스와 아주 유사한 자식 프로세스를 만들어 내고, exec()
은 진행 중인 프로세스 위에 새로운 프로세스 이미지를 덮어 씌운 후 main()
으로 가게 된다. wait()
은 이 시스템 콜을 호출한 프로세스를 잠들게 하는 것이고, exit()
은 가지고 있던 모든 자원(resource)을 반환하고 부모 프로세스에게 알려주는 역할을 한다.
'임베디드 > [ Linux Kernel ]' 카테고리의 다른 글
[ Linux Kernel ] 09. 중간정리 및 Daemon(데몬)/Server(서버) (0) | 2020.08.15 |
---|---|
[ Linux Kernel ] 08. Context Switch (0) | 2020.08.15 |
[ Linux Kernel ] 06. Child Process 생성하기 (0) | 2020.08.15 |
[ Linux Kernel ] 05. Process Management (0) | 2020.08.15 |
[ Linux Kernel ] 04. 시스템콜(System Call) (0) | 2020.08.15 |