Linux Kernel 관련 온라인 강의 요약본입니다.
https://olc.kr/course/course_online_view.jsp?id=35&s_keyword=Kernel&x=0&y=0
fork()
아래의 프로그램이 컴파일되어 실행되면 fork를 기준으로 2개의 분기점이 생성됩니다.
fork() 이후 실행되는 RIP는 fork() 이후를 가리키게 되며, 커널이 fork()를 수행한 뒤 새롭게 생성된 자식 프로세스의 반환 값(rax)에는 0을, 부모 프로세스에는 자식 프로세스의 pid를 반환합니다.
#include <unistd.h>
#include <stdio.h>
int main(){
int pid;
pid = fork();
if(pid == 0){
printf("I am child! Now I'll run date\n");
execlp("/bin/date", "/bin/date", (char *)0);
}
else{
printf("I am parent!\n");
}
}
C
복사
exec.c
이 반환 값을 가지고 프로그램은 2개의 분기점이 생기고, 자식 프로세스는 execlp를 실행하게 됩니다.
exce 계열의 함수는 지금 프로세스의 실행 context와 image를 첫 번째 인자의 program으로 변경합니다.
exec 계열 함수
exec 계열의 함수의 특징에 대해서 먼저 알아보겠습니다.
int execl(const char *pathname, const char *arg0, ... /* (char *)0*/);
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, .../*(char *)0, char *const envp[] */);
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const c har *arg0, .../* (char *)0 */);
int execvp(const char *filename, char *const argv[]);
C
복사
•
exec 계열 공통 특징
현재 프로세스를 새로운 프로그램으로 덮어씀
•
함수별 차이 정리
1.
l(list)
인자를 가변 인자로 전달, 마지막은 반드시 (char *)0
2.
v(vector)
인자를 argv 배열로 전달
3.
e(environment)
환경변수(envp)를 직접 지정, 없는 경우 기존 환경 사용
4.
p(path)
PATH 환경변수 검색, pathname 대신 filename 사용
wait(2) system call
아래 프로그램의 경우 부모 프로세스가 wait()을 호출합니다.
#include <unistd.h>
#include <stdio.h>
int main(){
int pid;
pid = fork();
if(pid == 0){
printf("I am child! Now I'll run date\n");
execlp("/bin/date", "/bin/date", (char *)0);
}
else{
wait();
printf("I am parent!\n");
}
}
C
복사
wait.c
커널은 wait 시스템 콜을 호출하는 즉시, 해당 부모 프로세스를 자식 프로세스의 종료를 기다는 상태(sleep 상태)로 전환하고 대기시킵니다.
이후 자식 프로세스에서 exit()을 통해 종료되면, 커널은 부모 프로세스를 깨워 wait() 호출 이후의 실행을 재개합니다.
exit(2) system call
우리가 작성한 소스코드는 exit()을 넣지 않아도 기본적으로 exit()이 호출됩니다. 이는 프로그램 시작 시 호출되는 __libc_start_main() 이후, 최종적으로exit()을 호출하기 때문입니다.
자세한 내용은 이미 전에 정리한 내용에서 확인가능합니다.
아래의 코드에서 부모 프로세스에서는 wait()을 호출하여 자식 프로세스의 종료를 기다리며 sleep 상태에 들어갑니다.
exit.c
자식 프로세스에서 execlp()를 통해 /bin/date를 실행하고, 해당 프로그램이 종료되면 내부적으로 exit()을 호출합니다.
이 시점에서 커널은 자식 프로세스의 종료를 처리하고, wait()으로 대기 중이던 부모 프로세스를 깨워 실행을 재개합니다.
즉, /bin/date도 결국 컴파일된 바이너리이며, main이후 exit()을 호출하는 시점이 존재하기 때문입니다.
Summary: system calls for process
마지막으로 정리를 해보겠습니다.
1.
fork
부모 프로세스를 기반으로 새로운 자식 프로세스 생성
2.
exec
현재 프로세스의 주소 공간을 디스크에 존재하는 새로운 프로그램 이미지로 교체(PID는 유지, 실행 코드만 변경)
3.
wait
자식 프로세스가 종료될 때 까지 부모 프로세스를 sleep 상태로 전환
4.
exit
현재 프로세스를 종료하고 할당 받은 리소스를 반환, 부모 프로세스가 sleep 상태에 들어가 있다면, ready 상태로 전환
Context Switch by wait() & exit()
shell에서 ls를 실행하는 상황을 예제로 설명해보겠습니다.
1.
사용자가 shell에 ls라고 입력합니다. shell은 fork()를 호출하여 자식 프로세스를 생성합니다.
이때 생성된 자식 프로세스는 부모 프로세스의 PCB와 이미지를 복사합니다.
2.
복사된 자식 프로세스는 ready queue에서 대기하고 있습니다. 아직 부모 프로세스에서 CPU를 넘겨준 것이 아니기 때문에 실행이 되지는 않습니다. 이후 부모 프로세스에서 wait()을 호출하면 CPU는 부모 프로세스를 sleep queue에 넣고 ready queue에 존재하는 다른 프로세스(ex. child process)를 실행합니다.
3.
shell에서 fork() 이후 자식 프로세스는 execlp로 디스크로 부터 ls의 이미지를 받아와 자식 프로세스(shell)에 덮어씁니다.
4.
ls의 main이 실행되고, 종료되면서 내부적으로 exit()을 호출합니다.
5.
exit()으로 자식 프로세스(ls)를 종료하고 자원을 반환합니다.
6.
이후 커널은 sleep queue에 존재하는 부모 프로세스의 상태를 ready queue로 전달합니다.
7.
CPU는 ready queue에 존재하는 프로세스(ex. shell)를 실행합니다.
Concept of Timesharing
CPU 사용 시점을 시각화하면 아래의 그림과 같습니다.
부모 프로세스와 자식 프로세스를 실행하면서 커널에 개입에 따라 사용자 모드(user mode)와 커널 모드(kernel mode)를 반복적으로 전환하며 실행하게 됩니다.
이러한 모드 전환은 system call, interrupt, exception 발생 시 이루어집니다.
Context Switch -- CPU & PCB
본격적으로 문맥 교환(Context Switch)가 무엇인지 알아보기 전에, 먼저 테스크(Task)에 대해 알아보겠습니다.
테스크(Task)
테스크(Task)란 컴퓨터에서 최소 작업(실행) 단위를 뜻합니다. 일반적으로 하나의 스레드(Thread)가 한 개의 테스크에 해당한다고 볼 수 있습니다.
커널의 관점에서 보면, 프로세스와 스레드를 구분하기보다 모두 테스크(Task) 단위로 관리하며, 각 테스크(Task)CPU를 할당하여 실행합니다. Linux는 이러한 테스크를 struct task_struct로 표현합니다.
문맥 교환(Context Switch)
문맥 교환(Context Switch)이란 CPU가 한 개의 테스크(Task)를 실행하다가, 다른 테스크를 실행하기 위해 기존 테스크의 실행 상태(Context)를 저장하고 새로운 테스크의 실행 상태(Context)로 교체하는 것을 말합니다.
PCB
현재 P1, P2, Kernel 프로세스가 실행되고 있습니다.
커널에는 PCB(Process Control Block)라는 각 프로세스의 정보를 가지고 있는 자료 구조가 프로세스 별로 존재합니다.
CPU가 테스크(Task)를 실행하던 중, P1이 wait()을 호출하면 wait 시스템 콜이 호출되고 커널 모드로 변경됩니다.
커널은 wait()을 처리하면서 P1 프로세스의 CPU state vector(PC, SP, 각종 레지스터 등), 즉 실행 문맥(Context) P1의 PCB에 저장합니다.
사실상 CPU 관점에서 보면, 할당된 자원만 유지되는 한 CPU 레지스터 값들만 복원해서 원하는 실행 상태로 이동할 수 있습니다.
P1의 PCB에 실행 문맥(Context)을 저장했다면, P1을 sleep queue로 전환시키고, ready queue에 가장 우선순위가 높은 프로세스를 찾아 CPU를 전달합니다.
만약 선택된 테스크(Task)가 P2라면, 커널은 P2의 PCB에서 실행 문맥(Context)를 가져와 CPU에 복원하고 복원된 P2 프로세스는 CPU를 점유하여 동안 실행됩니다.
이렇게 PCB에 저장된 실행 문맥(Context)를 기반으로 CPU에서 실행 중인 테스크(Task)를 다른 테스크(Task)로 변경하는 것을 문맥 교환(Context Switching)이라고 부릅니다.
Context Switch - schedule()
schedule() 함수는 커널 내부에 존재하는 함수입니다. 사용자가 직접로 호출할 수 없으며 커널 내부에서만 호출 가능합니다.
실제로 wait뿐만 아니라, read와 exit에서 문맥 교환(Context Switching)이 일어날 수 있습니다.
CPU 관점에서 disk I/O는 매우 오래 걸리는 작업입니다. 커널은 disk I/O를 하는동안 CPU를 유휴하지 않고 사용하도록 문맥 교환(Context Switching)을 진행합니다.
이후 disk I/O를 완료하여 데이터를 받아왔다면, 인터럽트(Interrupt)를 발생시킵니다.
문맥 교환을 수행하기 위해서 커널은 schedule()을 호출하고, 내부적으로 context_switch() 함수를 호출합니다.
context_switch()가 호출되면 PCB에 문맥을 저장하고 sleep queue로 프로세스를 전환시킵니다.
이후 ready queue에 존재하는 우선 순위가 높은 테스크(Task)를 선택하여 PCB에 저장된 문맥을 CPU에 복원합니다.
복원된 CPU는 RIP로 부터 명령어를 받아와 fetch하여 실행하며 문맥 교환이 완료됩니다.
Process
이제 설명한 내용을 종합하여 프로세스의 실행 과정을 한 단계씩 보겠습니다.
1.
부모 프로세스가 fork()를 호출하여 자식 프로세스(Task)를 생성합니다.
2.
fork() 이후 부모와 자식 프로세스는 모두 실행 가능한 상태(runnable)입니다. 어느 프로세스가 먼저 CPU를 할당받을지는 스케줄러에 의해 결정됩니다. → 지금은 부모 프로세스를 계속 실행한다고 가정하겠습니다.
3.
부모 프로세스는 wait()을 호출하면, 자식 프로세스의 종료를 기다리기 위해 부모 프로세스는 sleep 상태로 전환 됩니다.
4.
커널은 wait 시스템 콜을 처리하면서 schedule()을 호출하고, 내부적으로 context_switch()를 통해 문맥 교환(Context Switching)을 수행합니다.
5.
커널은 context_switch() 함수를 실행하며 문맥(Context)를 PCB에 저장하고 부모 프로세스를 sleep queue로 전달하고 ready queue에 존재하는 테스크에 CPU를 양도합니다.
6.
스케줄러는 ready queue에 있는 테스크 중 하나를 선택하며, 이 때 자식 프로세스가 실행되어 PCB를 기반으로 문맥(Context)를 복구하고 fork() 이후의 흐름을 따라갑니다. → 지금은 자식 프로세스를 ready queue에서 선택했다고 가정하겠습니다.
7.
자식 프로세스는 분기에서 exec 함수를 실행합니다.
8.
커널에서 exec 시스템 콜을 받아서 디스크에 있는 ls 실행 파일을 메모리로 로드합니다.
9.
로드된 프로그램 이미지는 자식 프로세스의 기존 실행 이미지를 덮어씁니다.
11.
덮어쓴 자식 프로세스는ls 프로그램의 main()부터 실행이 시작됩니다.
12.
main이 종료되면 최종적으로 exit()이 호출됩니다. 커널에서 exit을 처리하며, 자식 프로세스를 종료하고 부모 프로세스를 ready queue로 변경합니다.
13.
ready queue에 존재하는 부모 프로세스를 가져와서 context_switch를 진행합니다.
14.
wait 이후 코드가 실행되며, 다시 shell이 동작하기 시작합니다.
What constitutes a Process “Context”
프로세스에서 문맥(Context)은 아래와 같습니다.
1.
사용자 공간(User Space)
•
text
실행 코드
•
data
초기화된 전역 변수 및 정적 변수
•
bss
초기화되지 않은 전역 변수 및 정적 변수
•
heap
동적 메모리 영역
•
stack
함수 호출 및 지역 변수
bss, data 영역의 차이는 초기화 여부입니다.
data 영역은 초기값을 저장하고 있어야 함으로 컴파일 타임에 공간이 할당됩니다.
bss의 경우 초기값이 모두 0이기 때문에 바이너리에는 크기 정보만 기록되고, 프로그램 로딩 시 메모리에 할당되어 0으로 초기화됩니다.
2.
커널 공간(Kernel Space)
•
프로세스(테스크)를 관리하기 위한 PCB
•
시스템 콜, 인터럽트 처리 시 사용하는 커널 스택(Kernel Stack)
3.
HW
•
PC, SP, flags, regsiter가 존재합니다.
Daemon
마지막으로 데몬 프로세스에 대해서 알아보겠습니다.
데몬은 부팅시 실행되는 프로그램입니다.
백그라운드 프로세스로 동작하며, 메모리에 상주하다 사용자의 요청이 들어오면 해당 요청을 처리하고 다시 이벤를 대기하는 프로세스입니다.
일반적으로 데몬 프로세스의 ppid는 1(init process)입니다.
ppid가 1이 될 수 있는 이유는 fork()로 자식 프로세스를 생성하고 fork()를 실행한 부모 프로세스를 종료해 init process가 고아 프로세스를 가져가 부모가 되어주기 때문입니다.










