
OS의 가장 기본적인 기능은 code를 동적으로 실행하고 관리하는 것입니다.
예를 들어 terminal command line에 명령어가 들어오거나, 데스크톱에 아이콘이 클릭 되는 상황 등이 있습니다.
process는 프로그램 실행에 가장 기본적이 유닛입니다.
Program은 storage에 저장되어 있는 실행 가능한 파일을 의미하고, Process는 RAM에 저장되는 Program의 실행 instance입니다.
프로그램을 실행시키기 위해 다수의 프로세스가 RAM에 올라가게 되어, Program과 Process는 1-n관계라고 할 수 있습니다.
하나의 Program이 로드되면, Kernel은 새로운 process로 관리 해야합니다. 이때 PCB(Process Control Block)를 사용하는데, PCB는 kernel 내부에 있는 Process를 나타내는 구조입니다.
PCB에는 하나의 thread 또는 필요에 따라 여러 thread를 가지고 있고, 메모리 사용 정보에 실행 중인 기계어 코드가 로드된 영역인 code segment와 전역·정적 데이터뿐만 아니라 스택과 힙 같은 런타임 데이터 영역인 data segment를 가지고 있습니다. 또한 process의 runtime 상태와 관련된 CPU 레지스터 값들과 EIP값을 가지고 있습니다.
다음은 Unix PCB 구조를 나타낸 코드입니다.
struct task_struct{
pid t_pid; // process 식별자
long state; // process 상태
unsigned int time_slice; // scheduling 정보
struct task_struct *parent; // 이 process의 부모
struct list_head children; // 이 process의 자식
struct files_struct *files; // open file list
struct mm_struct *mm; // 이 process의 주소공간
}
Process가 실행되면, state는 계속해서 변합니다. process의 상태는 다음과 같이 나타냅니다.
cpu에 명령을 올리는 과정을 dispatch라 하고, process가 running 상태에서 ready 상태로 가는 것을 interrupt라고 합니다. ready와 wainting은 Queue 구조로 되어 있습니다.
Unix/Linux에 모든 process는 부모를 가지고 있습니다. Process는 tree 구조를 가지기 때문에 한 process가 다른 process들을 생성하면 생성된 process들은 이미 있던 process의 자식 process가 됩니다.
child process는 orphan, zombie 두 가지 비정상적 상태를 가질 수 있는데, 만약 부모 process가 자식 process보다 먼저 exit하게 되면 orphan상태가 되고, parent가 wait()를 call하기 전에 자식 process가 exit하게되면 zombie 상태가 됩니다.
init은 kernel에 의해 실행되는 특별한 process입니다. 항상 process tree의 최상단(root)에 위치해 있습니다.
위 사진은 fork()의 동작을 시각화 한것입니다. Original Process를 복사하여 pid = 0으로 설정하고 exec()을 이용해 child process를 실행 시키고 있습니다.
UNIX fork() 구현 방법은 다음과 같습니다.
1. PCB 생성 및 초기화
2. 새로운 주소 공간 생성
3. 부모의 주소 공간에 있는 모든 내용을 복사하여 주소 공간 초기화
4. 부모 process의 실행중인 context를 상속
5. 스케쥴러에 새로운 프로세스가 실행 준비중이라고 알림
UNIX exec() 구현 방법은 다음과 같습니다.
1. 새로운 프로그램을 현재 프로세스 주소 공간에 load
2. command line 인자를 새로운 주소 공간 메모리에 할당
3. 실행을 시작할 하드웨어 context 초기화
EIP: ELF 헤더에 명시된 진입점
ESP: 새로 할당 된 stack
자식 process들이 완료될 때까지 wait(pid)하고, child process가 완료되면 abolt(pid)를 call하여 종료시킵니다.
Context switching은 다른 process로 switching하기 전 현재 process의 상태를 저장하고, 다시 switching했을 때 복원 시킵니다. 간단한 개념이지만 어떻게 상태를 저장, 정지, 재시작 할건지에 대해 생각 해봐야 합니다.
각각의 process들은 memory에 local 변수, 함수 인자, 함수의 return address와 같은 것들을 담아 stack 형태로 저장됩니다.
x86에서 이 stack은 downwards 방식이고, push, pop, call, ret, int, iret과 같은 명령으로 stack을 수정합니다.
int bar(int a, int b) {
int r = rand();
return a + b - r;
}
int foo(int a) {
int x, y;
x = a * 2;
y = a - 7;
return bar(x, y);
}
int main(void) {
...
foo(12);
...
}
위 코드는 예시 코드입니다. main 함수에서 foo에 12를 인자로 전달하여 foo 함수를 call하고, foo 함수에서 bar함수에 지역변수 인자를 전달하여 call하고 있습니다.
아래 두 사진은 foo, bar 각각의 함수에 대해 어셈블리 수준에서 어떻게 컴파일되고, 그에 따라 스택에 어떻게 프레임이 쌓이는지를 보여주는 그림입니다.
push ebp, mov ebp,esp; sub esp,…로 프레임을 만들고, 로컬 변수 공간을 sub esp,0x28로 확보한 뒤,
[ebp+8]에서 함수 인자 a를 읽어 x,y를 계산 및 저장하고, 다시 [ebp-0xc], [ebp-0x10]에서 x, y를 읽어 스택에 인자로 놓은 뒤 call bar를 실행합니다. leave, ret로 스택을 정리하고 반환합니다.
bar도 마찬가지로 프레임을 만들고, rand() 호출 결과를 로컬 변수 r에 저장한 뒤, [ebp+8], [ebp+0xc]에서 인자 a,b를 읽어 a+b-r을 계산하고 그 결과를 eax에 담아 반환합니다. leave, ret 로 프레임을 정리하고 반환합니다.
각 process는 context switching을 위해 Switch()를 호출 해야하고, process가 2개 있다고 가정할 때 process간 switching은 다음과 같은 순서로 일어납니다.
// Process 1
a = b + 1;
switch();
b--;
<switch>:
push eax
push ebx
...
push edx
mov [cur_esp], esp
mov esp, [saved_esp]
pop edx
...
pop ebx
pop eax
ret
// Process 2
puts(my_str);
switch();
my_str[0] = '\n';
i = strlen(my_str);
switch();
Process 1과 OS code, Process 2가 위와 같이 작성되어 있다고 할 때, Process 1에서 switch가 call되면, eax ~ edx까지 cpu 레지스터들이 Process 1 stack에 push 되고, 현재 esp 위치로 Process1 저장을 위한 esp가 이동합니다.
그리고 현재 esp가 저장 되어 있던 즉, process2 stack의 esp 위치로 이동시켜 process 2를 로드합니다. 그리고 process를 context switch 하기 전에 push 해 두었던 레지스터 값을 다시 레지스터에 복원하기 위해 pop 해주고, return하게 되면 context switching이 됩니다.
신규 Process는 Process stack을 가지고 있지 않고, switch()를 한번도 호출한적이 없습니다.
그렇기 때문에 신규 Process는 이전에 switch()를 호출한거 처럼 stack을 미리 생성하는 식으로 stack 초기화를 진행합니다.
초기화 시 stack frame에 레지스터 값들을 모두 null로 지정하고, return address를 main()으로 지정합니다.
// Process 1
a = b + 1;
switch();
b--;
<switch>:
push eax
push ebx
...
push edx
mov [cur_esp], esp
mov esp, [saved_esp]
pop edx
...
pop ebx
pop eax
iret
// New Process
main() {
...
}
이때 그냥 return이 아닌 iret(interrupt return)을 해주는 이유는 ret만 쓰면 “EIP←스택[ESP++]” 정도만 복원해 줄 뿐, CPU 상태의 나머지 플래그 레지스터와 세그먼트 선택자 등은 복원해 주지 않습니다.
반면 iret 명령어는 스택에 미리 쌓아둔 EIP, CS, EFLAGS까지 한꺼번에 팝(pop)해서 복원 해주기 때문에 사용합니다.
여러 Process 간에 CPU를 공유하려면 결국 제어권이 OS로 반환되어야 합니다. 그렇다면 언제 제어권 반환이 일어나고, 어떤 방식으로 사용자 process에서 OS로의 전환을 구현할까요?
이때 다음 네 가지 방식이 있습니다.
1. Voluntary yielding (자발적 포기)
2. Switch during API calls to the OS (OS API 호출)
3. Switch on I/O (Process가 I/O 요청시)
4. Switch based on a timer interrupt (timer interrupt에 의한 switching)
Process가 thread_yield()와 같은 OS API를 호출하여 CPU에 대한 사유권을 포기하는 것입니다.
하지만 몇 가지 문제점이 있는데, 잘못 동작하거나 bug가 있는 app들은 context switching이 동작하지 않을 수 있고, 특정 app이 얼만큼에 시간동안 동작할거다라는 보장이 없으며, CPU 자원을 그냥 낭비하고 있을 수 있는데 예를들어 user에게 input 요청 후 yield하지 않으면 입력시간 동안 CPU는 동작하지 않습니다.
OS API 호출 시 Context Switching하는 방법입니다. printf(), fopen()과 같은 System call이 발생하면 수행권한이 OS로 넘어오고 이때 OS는 Context Switching을 할 기회가 발생합니다.
하지만 잘못 동작하거나 bug가 있는 app들은 yield하지 않을 수 있고, 일반적인 App들은 오랜 시간동안 OS API를 사용하지 않을 수 있습니다.
I/O 발생시 다른 process와 context switching을 하는 것입니다. I/O는 OS API를 사용하기 때문에 쉽게 switching이 가능합니다. 하지만 I/O 작업이 없는 App도 존재한다는 문제가 있습니다.
// 사용자 화면
struct terminal {
queue<char> keystroke;
process *waiting;
...
};
process *current; // 현재 실행중인 process
queue<process *> active; // ready 상태인 다른 process
// 입력이 될 때까지 process 정지
char get_char(terminal *term) {
// key 입력이 없을 때
if (term -> keystrokes.empty()) {
term -> waiting = current;
switch_to(active.pop_head());
}
return term -> keystroke.pop_head();
}
// 입력 발생시 interrupt 발생
void interrupt(terminal *term, char key) {
term -> keystrokes.push_tail(key);
if (term -> waiting) {
active.push_tail(term -> waiting); // sleep 상태 process를 깨움
term -> waiting = NULL;
}
}
위 코드는 I/O Context Switch 예시 코드입니다. 입력이 없을 때 get_char 함수가 호출 process를 재워 다른 process가 cpu 자원을 사용할 수 있도록 하고, 입력이 들어오면 인터럽트가 호출 process를 ready queue에 돌려 보내 CPU를 다시 할당하도록 함으로써, Context Switching을 수행합니다.
타이머를 설정하여 현재 process가 CPU를 충분히 사용했으면 다른 Process로 context switching을 시키는 방법입니다. Interrupt handler가 현재 process가 얼마나 실행됐는지 측정하고, 설정한 최대치에 도달하면 다른 process로 점유권이 넘어가는 식으로 동작합니다. 하지만 process 주기 측정을 위한 별도의 하드웨어 자원이 필요하다는 단점이 존재하지만, 대부분의 현대 CPU에는 이 기능이 탑재되어 있습니다.
Context Switching을 통해 동시에 여러 process들을 실행시킬 수 있습니다. 하지만 실행중인 process가 Kernel memory를 덮어쓰거나, 다른 process들로 data를 Read/Write하거나 interrupt를 비활성화 하는 등과 같은 문제가 발생했을 때 어떻게 process를 멈출 수 있을까요?
위와 같은 문제로 인해 실행 권한을 제한해야 하는데, 제한된 권한으로 실행을 어떻게 구현할까요? interpreter 또는 simulator를 사용하는 방법이 있는데, simulator에서 각 명령을 실행하고, 해당 명령이 허가되면 실행하고 아니면 process를 정지시키는 방식입니다. 그러나 interpreter와 simulater를 사용하는 방식은 느리다는 단점이 있습니다. 더 빠르게 수행하는 방법은 CPU에서 직접 실행하는 방법이 있습니다.
대부분 현대 CPU는 protected mode를 지원합니다. x86 CPU는 0 ~ 3까지 RIng형태로 Ring 0은 OS kernel, Ring 1, 2는 device driver, Ring3는 User로 각 계층별로 권한을 나눕니다.
보통 1, 2는 사용하지 않고, Ring0과 Ring3만 사용하는데, Ring 0는 Hardware에 대한 모든 권한을 가지고 있어, 어떤 memory던 read/write가 가능하고, I/O device에 모두 접근 가능하고, 어떤 packet이던 send/read가 가능하는 등 모든 동작을 할 수 있다라고 생각할 수 있고, Ring 3는 제한된 권한을 가짐으로 kernel이 부여한 것만을 수행가능합니다.
전체적인 시스템이 시작되면 CPU는 16-bit Real mode로 동작을 시작합니다. 당연히 protected mode는 비활성화 되어 있고, segment는 offset addressing 방식을 사용합니다.
mov eax, cr0
or eax, 1
mov cr0, eax
일반적으로 bootloader는 protected mode 전환시 cr0의 1번 bit를 1로 설정하여 protected mode를 활성화시킵니다.
예를들어 sti/cli는 interrupt를 활성화 또는 비활성화 하는 명령어고, CR0 레지스터 값을 수정하는 명령어들은 protected mode 활성화 여부를 제어하고, hlt는 CPU를 정지하는 명령입니다.
user level program이 privileged 명령을 수행하려고 하면 CPU가 GP(General protection)를 예외 처리하고 OS의 예외 핸들러로 제어가 전달됩니다.
Application들은 system call을 할 때 예를들어 file write, network를 통해 data를 받는 등과 같은 상황에서 OS에 접근할 필요가 있습니다.
하지만 OS는 Ring 0, Application은 Ring 3기 때문에 Application이 int 0x80 같은 소프트웨어 인터럽트 명령을 실행하여 CPU가 자동으로 링 3에서 링 0으로 전환(mode switch)할 수 있도록 하여 해당 인터럽트 벡터(0x80번)에 등록된 커널의 시스템 호출 핸들러로 제어를 넘기게 되어 OS에 접근할 수 있게 됩니다.
mode 전환 과정을 정리하면 다음과 같습니다:
1) Application이 int 0x80을 실행(system call)하면 EIP, CS, EFLAGS가 스택에 저장되고, Ring3에서 0으로 mode 전환
2) EAX, EBX 등 stack에 push하여 현재 process의 상태를 저장
3) 올바른 system call handler를 찾음
4) EAX, EBX 등과 같은 레지스터에 저장된 값을 pop하여 process 상태를 복원
5) EAX에 return 값을 설정
6) iret 명령으로 Ring 0 에서 3으로 다시 전환