Process Creation 과정을 돌이켜보자.
(1) Load : OS는 Program Code를 Memory의 Process Address Space에 Load한다. 즉, Program이 Process가 되는 것이다.
Program을 Load하기 전, Program은 Disk에 Executable Format으로 저장되어 있다.
ex) a.out이란 Program이 Disk에 Executable Format으로 Reside!
이때, OS는 Lazy Loading 방식으로 Process Loading을 진행한다.
Lazy Loading : Code(Text)와 Data Segment에서 Program Execution에 필수적인 일부분만 Loading하는 것으로, Demand Paging, Demand Segmentation이라고도 부른다.
Demand의 의미 : 필요한 것들만 올린다는 것!!!
Virtual Memory Concept를 토대로 구현
(2) Run-Time Stack Set-Up : Stack은 Local Variable, Function Parameter, Return Address 등을 담는데, 이러한 Run-Time Stack을 초기화한다.
(3) Heap Creation : Explicit Dynamic Memory Allocation 시에 필요한 Heap 영역을 생성한다.
(4) OS의 추가적인 Initialiation 작업들이 수행된다.
I/O Set-Up
Process의 Default File Descriptor 3개가 할당된다.
File Descriptor Table 초기화 등의 작업이 여기서 이뤄진다.
(5) OS는 main()이라는 Entry Point에서부터 Program Running을 수행한다.
~> 기억나는가? 이것이 바로 Process Creation의 과정이었다.
이러한 Flow의 Process Execution을 가장 빠른 성능으로 수행하는 방법엔 무엇이 있을까? 간단히 생각해보면, Process를 그냥 CPU 위에서 Direct하게 돌리면 가장 빠를 것이다. Program 실행 중간에 별다른 처리없이 말이다. 이를 'Direct Execution'이라 한다.
Direct Execution : Program이 수행되기 시작하면, OS의 별다른 도움 없이 Program을 CPU 위에서 Direct하게 돌리는 것
위의 그림처럼, OS가 Program에 대한 초기, 후기 작업만을 담당하고, Program이 시작되고 나선 CPU 제어권을 오로지 Program에게 넘기는 것!
즉, Running Program에게 Limit을 걸지 않는 것이다.
이렇게 되면, OS는 사실상 Program 수행 중간에는 그저 Library로서의 역할만 수행하는 것이다. (Direct Execution) ★★★
~> 즉, 상기한, 여태 소개했던 Process Creation은 Direct Execution 방식이라고 봐도 무방한 것이다. Program Execution이 시작되고 나서의 OS의 업무를 전혀 명시하지 않았으므로!!
이러한 Direct Execution은 하나의 Process Execution에 대한 Performance는 높아질지 몰라도, 아래와 같은 문제점들이 존재한다.
Problem 1 : Process가 Restricted Operations를 수행하고자 할 때 문제가 발생한다.
Restricted Operation이라 함은, Disk에 대한 I/O Request나, CPU/Memory와 같은 System Resource에 대한 접근 요청 등을 의미한다.
OS의 도움없이 Restricted Operation을 수행한다고 해보자. 모든 Process가 어떠한 작업이든 수행할 수 있게 되면, System의 중요 부분에 대한 접근 및 변형이 가능해질 터이고, 나아가 System 전체를 다운시키는 것과 같은 치명적인 문제가 발생할 수 있다. ★
그래서 등장하는 개념이 바로 'Limited Direct Execution'이다. 쉽게 말해, Process를 CPU 위에서 Direct Execution하되, OS를 이용해 몇 가지 Limit을 제공해 Virtualization을 구현하고, Process Switching도 가능케 하는 것이다.
위에서 소개한 Direct Execution의 Problem 1인 'Restricted Operations'에 대한 Solution이 바로 'Dual Mode의 도입'이다.
Dual Mode는 Direct Execution 시 SW에 아무런 Limit이 없어 생기는 'Restricted Operation 접근 가능성 문제'에 대한 Solution이다.
Dual Mode : 두 개의 Mode를 만들어 Process의 부분과 OS의 부분을 나누어, OS가 자기 자신과 다른 System Component들을 보호할 수 있게 만들어준다.
Dual Mode = User Mode + Kernel Mode ★★★
특정 Instruction들은 'Privileged'로, 오로지 Kernel Mode에서만 수행될 수 있다.
즉, User Mode에선 사용할 수 없다.
User Mode에 있는 Process는 Privileged Operation을 수행할 수 없다.
이러한 Privileged Operation에는 I/O Request, File System Request 등이 있다.
~> 즉, User Mode에 있는 Process에서는 Non-privileged Operation만 수행할 수 있는 것이다. Privileged Operation을 사용하려고 시도하면 OS가 Exception을 발생시켜 해당 User Program을 Kill하는 것이다.
~> Kernel Mode에서는 당연히 Non-privileged/Privileged Operation을 모두 사용할 수 있다.
~> 허나, User Mode의 Program도 당연 I/O Request와 같은 Privileged(Restrictive) Operation이 필요하고, 따라서 User는 System Call과 같은 Interface를 통해 Kernel Mode 전환를 이뤄내는 것이다.
System Call은 User Mode를 Kernel Mode로 전환시킨다. System Call이 Return할 때, User로 돌아간다.
이러한 Mode는 HW의 Mode Bit라는 것으로 관리된다. ★
~> read, write와 같은 System Call 시 이처럼 Dual Mode Operation Concept를 이용한 처리가 이루어지는 것이다. ★★★
위에서 Interrupt Handling 과정이 언급되고 있다. 과거 SP에서 다루었던 Exception 개념이 기억나는가? 이때의 개념을 SW 관점에서 간단히 풀어써보겠다.
Interrupt는 CPU 제어권(Control)을 Interrupt Service Routine인 Interrupt Handler로 넘긴다.
이때, 'Interrupt Vector Table (IDT, Interrupt Descriptor Table)'이 있는데, 이들은 각각의 Handler에 대한 Address를 담고 있다.
일반적으로 이러한 Vector는 Memory의 낮은 주소에 저장되어 있다.
각 Interrupt에 대응하는 Interrupt Handler를 포인팅하는 역할인 것!
이러한 Interrupt Handler들은 OS의 부분으로, 즉, CPU Control이 Kernel로 넘어가는 것이다. ★
Interrupt Architecture는 Interrupted Instruction의 주소를 반드시 저장해야한다. ★
이러한 Interrupt에는 여러 종류가 있는데, 그 중 Trap Exception은 SW가 발생시킨 Interrupt로서, Error 발생 시, 또는 User Request로 인해 발생한다.
아래 그림은 Trap Interrupt 시의 동작 Flow를 나타낸다.
~> User Program이 쭈욱 수행되다가 Trap Interrupt 발생 시, 해당 Interrupt가 가리키는 Service가 무엇인지를 Interrupt Vector Table을 통해 찾고, 그에 대한 Interrupt Handler가 저장되어 있는 OS 내의 Address가 어딘지를 찾아내 그 주소로 따라간다.
~> 그 다음, Interrupt를 Disabling하고, Interrupt 시점의 Program의 Processor-State를 기억시켜놓는다. (복구 목적으로)
~> 이어서, Interrupt Handler가 Service를 수행하고, 수행을 마치면 다시 Processor-State를 Restore한다. (이 과정에서 'Service'는 간단히 표현한 것이다. 아래에서 그 내막을 좀 더 자세히 알 수 있다.)
~> Interrupt를 Enabling한 후, User Program에서 Trap을 발생시켰던 Instruction 다음의 명령으로 복귀한다. (Trap의 특징)
System Call은 Kernel이 User Program에게 중요 기능을 제공하는 매개체로서, 그러한 중요 기능에는 File System 접근, Process 생성 및 해제, Process 소통, Memory 할당 등이 있다.
System Call : User Program이 원하는 OS의 Fuctionality를 사용할 수 있도록 API 형태로 만들어서 Library로 제공하는 것!
즉, User Program이 System Call을 통해 Trap Interrupt를 발생시키고, 그에 대한 Handler가 동작하면서 각 종 OS Fuctionality를 User Program이 사용할 수 있게 되는 것이다. CPU Control이 User Mode에서 Kernel Mode로 넘어가면서 말이다. 이러한 전체적인 Flow를 우리는 다음과 같은 그림으로 확인할 수 있다. 이제 조금 더 디테일하게 설명할 것이다.
open 함수를 이루는 가장 겉 껍데기는 당연 C Library open Function이다. 따라서, C 런타임 라이브러리를 참조한다.
해당 라이브러리 함수는 System Call을 위한 Wrapper를 호출한다.
C 언어와 Unix System을 기준으로, System Call을 할 때, 곧바로 System Call이 시작되는 것이 아니라, 특정 Function이나 Macro의 형태로 System Call Invocation을 담당하는 중간 단계가 포함된다. (이유 ASM 코드를 반복적으로 사용해야하기 때문)
즉, 특정 함수나 매크로로 Wrapper를 두어, 그 안에서 System Call을 도모하는 것이다.
이러한 Wrapper 내부에는 Interrupt Instruction이란 것이 존재한다. ★★★
Trap Instruction이라고도 부르는 이것은, 'Trap Interrupt를 발생시키는 일'을 하는 명령으로, User가 OS에게 Interrupt를 발생시키고자 할 때 사용하는 명령이다. ★★★
이러한 Interrupt Instruction은 Assembly Level로 작성된다.
x86 System 기준으로 0x80, pintOS 기준으로 0x30이 이러한 Instruction에 해당한다. x86에선 이를 INT라고 줄여 부르기도 한다. Software Interrupt를 발생시키는 Instruction이라고 이해하면 된다.
모든 Software Interrupt는 이 Trap(Interrupt) Instruction을 필요로 한다.
여기서 발생한 Interrupt Instruction은 Kernel 내부의 Interrupt Vector Table로 전달된다.
이때, 지금까지는 이전 단계에서 이후 단계를 단순히 Call하는 것이었다면, 이 단계에서부턴 몇 가지 중요 Data를 함께 넘긴다.
Interrupt Number, System Call Number, Parameters 등이 Interrupt Vector Table로 넘어간다.
Interrupt Vector에서는 넘겨받은 Number 정보를 토대로 상응하는 Interrupt Handler를 호출한다. (Trap Interrupt를 처리하는 Handler를 호출하는 것임)
이 Interrupt Handler는 상기한 Interrupt Instruction에 대응하는 Interrupt Handler로서, 모든 System Call은 이 Handler를 거쳐간다.
Interrupt Handler는 System Call Handler를 호출한다. (또는 이 단계 없이, 바로 Interrupt Handler가 System Call Handler로서 작동한다. 따라서, 그냥 두 Handler가 같은 것이라고 봐도 무방하다. 이는 Design Dependent!!!)
Interrupt(or System Call) Handler는 넘겨받은 System Call Number를 토대로 자신이 (주로 논리적으로만) 가지고 있는 System Call Table을 토대로 대응되는 System Call Function을 호출한다.
여담) System Call Table은 주로 논리적으로만 존재하는데, 그 말은 무엇이냐면, 따로 Data Structure 형태로 메모리를 잡아먹는 것이 아니라, 그냥 Code 내에서 Switch문 따위로 관리한다는 것이다.
자, 더 깊은 이해를 위해 이 과정을 다시 한 번 더 설명한다. 아래의 그림은 여태 설명한 상황을 조금 다르게 묘사한 것이다. (pintOS 기준으로 설명 ★)
이때, Mode Bit는 Interrupt Instruction이 실행될 때 내부적으로 자동으로 바뀐다.
SW Interrupt가 아닌, 외부의 Interrupt가 걸릴 때는 Application Program의 Request로 인해 이뤄지는 것이 아니기 때문에 Application Program의 '현 상태(Processor-State)'를 잠시 기억시키고, 다시 복구시키는 루틴이 필요하다. ★★★
즉, Interrupt Instruction이 가리키는 Index는 Interrupt Vector Table에서 System Call 수행을 관장하는 Handler의 주소가 담긴 Index를 나타내고, 이를 따라가 Interrupt(System Call) Handler가 수행되면, 해당 Handler 내부에서 System Call의 종류에 따른 처리를 수행하는 것임. 이 관계성을 반드시 기억해야한다. ★★★★★
이는 OS의 대표 Project 중 하나인 pintOS Project의 User Program 설계 Step의 핵심 Key가 되는 개념이다. ★★★★★
이제부터, pintOS에서 상기한 과정이 어떻게 돌아가는지를 Code-Level로 살펴보자. pintOS를 예시로 드는 이유는, 1.1 포스팅에서도 언급했듯, OS 학습 과정에서의 유명한 프로젝트가 바로 이 pintOS 프로젝트이고, 본 시리즈에서도 마찬가지로 이를 채택할 것이기 때문이라고 밝힌 바 있다.
pintOS에서 User Program이 write Function을 호출했다고 가정하자.
syscall3는 ASM Code로 정의되고 있는데, 이때, pushl은 Stack Push 연산이다. arg2, arg1, arg0, System Call Number 순으로 Stack에 Push하고 있다. 따라서 최종적으로 esp(Stack Pointer)가 가리키는 Stack의 Top은 Number Slot 부분이다.
그 다음, int 0x30을 통해 Interrupt Instruction을 실행하고 있다. 이제 본격적으로 Interrupt를 핸들링할 것이다. ★
참고로, int 0x30 이후 부분을 보면, esp에 16(4개의 Parameter)을 더해 esp를 원래 위치로 돌려놓는 코드가 존재하는데, 이는 Interrupt가 끝났을 때, Stack의 Top을 System Call 이전으로 돌려놓기 위함임을 기억하라. ★★★
이처럼, pintOS에선, pintos/src/lib/user/syscall.c 파일에 각 종 C Library Function들과 이들 중 System Call과 연관된 Function들이 사용하는 Wrapper Macro syscall-k들도 정의되어 있음을 기억하자.
참고로, pintos/src/lib/user/syscall-nr.h 파일에는 System Call Number들에 정보가 enum Type으로 정의되어 있다.
스탠포드 대학교 pintOS Project의 첫 번째 Phase인 User Program 개발에서의 Task는, Vector에서 Handler로 넘어온 직후의 일을 만들어주는 것이다.
그리고, System Call Handler의 Return Value는 eax Register와 esp Stack Pointer 등에 반영될 것이다.
이렇게 해서, Dual Mode 개념을 말미암은 Limited Direct Execution 이론과, 그 과정에서 Kernel의 기능을 제공하는 방법론인 System Call이 어떤 Flow를 통해서 동작하는지를 알아보았다. 이 개념들을 토대로 스탠포드대학교의 pintOS 첫 번째 Project를 수행할 수 있는 것이다. 잘 기억해두자.
마지막으로, System Call에 대한 이야기를 조금 더 해보겠다. 일반적으로, Interrupt처럼 System Call에도 특정 Number가 부여된다.
System Call Interface는 특정 Table인 'System Call Table'을 운용한다. System Call Number를 Index로 한다.
내부 Detail은 Programmer에게는 보이지 않는다. Programmer는 오로지 API(Application Programming Interface)를 이용해 이러한 Interface를 사용하기만 하면 된다. 내가 사용하는 Interface를 통해 OS가 무슨 일을 할지만 알면 된다.
Run-Time Support Library가 Compiler와 함께 이를 관리한다.
System Call의 Parameter Passing 방법에는 대표적으로 아래와 같은 3가지 방식이 존재한다.
(1) : Register를 이용해 Parameter를 Passing한다.
(2) : 메모리 내부에 Block이나 Table을 두고, 그 안에 Parameter들을 저장한 다음, 해당 Block/Table의 주소를 특정 Register에 Parameter로서 넘긴다.
(3) : 복수의 Parameter를 Program이 Stack에 Push하고, OS가 Stack에서 Pop한다.
(2)와 (3)의 Block Method, Stack Method는 Parameter의 개수나 길이에 제한이 없다.
OS마다 이러한 System Call Parameter Passing 방법이 다르며, 이러한 방법론을 ABI(Application Binary Interface)라고 부른다. ★★
API와 구분하자!
금일 포스팅은 여기까지이다.