운영체제의 핵심 역할 중 하나는 **가상화(virtualization)**다.
물리적으로 하나뿐인 CPU와 메모리를 마치 여러 프로그램이 각자 전용으로 소유한 것처럼 보이게 만드는 것이다. OSTEP 제2판의 제I편은 이 가상화라는 거대한 주제를 CPU 가상화와 메모리 가상화라는 두 축으로 풀어낸다.
이번 글에서는 그 첫 번째 축인 CPU 가상화를 다룬다
프로세스의 탄생, 제한적 직접 실행(LDE), 그리고 CPU 스케줄링
프로세스란 무엇인가?
프로세스(process)는 실행 중인 프로그램이다. 디스크에 저장된 바이너리 파일은 단순한 명령어 모음에 불과하지만, OS가 이를 메모리에 적재하고 CPU 위에서 실행을 시작하는 순간 프로세스가 된다.
프로세스를 구성하는 기계 상태(machine state)는 크게 세 가지다.
- 주소 공간(Address Space): 프로세스가 접근할 수 있는 메모리 영역이다. 코드(code/text) 세그먼트, 힙(heap), 스택(stack)이 여기에 포함된다. 코드 세그먼트에는 실행할 명령어가 올라가고, 힙은
malloc()같은 동적 할당으로 위쪽으로 확장되며, 스택은 함수 호출과 지역 변수를 위해 아래쪽으로 성장한다. - 레지스터 상태: 프로그램 카운터(PC, 다음에 실행할 명령어 주소), 스택 포인터(SP), 프레임 포인터(FP), 그리고 범용 레지스터들의 현재 값이다. 이 레지스터 상태가 프로세스의 "현재 실행 위치"를 정확히 결정한다.
- I/O 정보: 프로세스가 열고 있는 파일 디스크립터 목록 등이다. 예컨대 UNIX에서 프로세스는 표준 입력(stdin, fd 0), 표준 출력(stdout, fd 1), 표준 에러(stderr, fd 2)를 기본으로 갖고 시작한다.
OS는 이 모든 정보를 **PCB(Process Control Block)**라는 자료구조에 보관한다. 문맥 교환(context switch)이 일어나면 현재 프로세스의 레지스터 상태를 PCB에 저장하고, 다음 프로세스의 PCB에서 레지스터 상태를 복원한다. 사용자 입장에서는 마치 각 프로그램이 자기만의 CPU를 갖고 있는 것처럼 보인다
이것이 CPU 가상화의 본질이다.

위 다이어그램은 프로세스를 구성하는 세 가지 요소를 보여준다. 왼쪽의 주소 공간은 Code, Heap, Stack으로 나뉘며 힙과 스택은 서로를 향해 성장한다.
이 모든 정보가 오른쪽의 PCB에 통합 저장되어, 문맥 교환 시 저장과 복원의 단위가 된다.
프로세스 상태 전이
프로세스는 생애 주기 동안 몇 가지 상태를 오간다.
- 생성(New):
fork()등으로 프로세스 구조체가 만들어진 직후의 상태다. 아직 실행 대기열에 올라가지 않았다. - 준비(Ready): 실행 준비가 끝나고 CPU를 배정받기를 기다리는 상태다. 스케줄러가 이 프로세스를 선택하면 Running으로 전이된다.
- 실행(Running): CPU 위에서 실제로 명령어가 실행되고 있는 상태다. 타이머 인터럽트에 의해 선점(preempt)되면 다시 Ready로 돌아간다.
- 대기(Blocked): I/O 요청 등으로 외부 이벤트를 기다리는 상태다. I/O가 완료되면 Ready로 복귀한다.
- 종료(Zombie):
exit()을 호출하여 실행이 끝났지만, 부모 프로세스가 아직wait()로 종료 상태를 수거하지 않은 상태다. 부모가wait()를 호출하면 비로소 PCB가 해제된다.

핵심은 Running ↔ Ready 사이의 전환인데, 이것이 바로 CPU 가상화의 핵심 메커니즘인 **시분할(time sharing)**이다. 타이머 인터럽트가 주기적으로 발생할 때마다 OS가 개입해서 현재 프로세스를 중단하고, 준비 큐에 있는 다른 프로세스에게 CPU를 넘긴다.
프로세스 API: fork, exec, wait
UNIX 계열 OS에서 프로세스를 다루는 핵심 시스템 콜은 세 가지다.
fork(): 현재 프로세스를 거의 그대로 복제하여 자식 프로세스를 만든다. 부모에게는 자식의 PID를, 자식에게는 0을 반환한다. 자식은 부모의 주소 공간, 파일 디스크립터, 레지스터 상태를 복사받지만, 별도의 프로세스로 독립적으로 실행된다.
int rc = fork();
if (rc == 0) {
// 자식 프로세스: rc == 0
printf("I am child (pid:%d)\n", getpid());
} else {
// 부모 프로세스: rc == 자식의 PID
printf("I am parent of %d (pid:%d)\n", rc, getpid());
}
exec(): 현재 프로세스의 주소 공간을 새로운 프로그램으로 완전히 덮어씌운다.
fork()가 복제라면, exec()은 변신이다. fork() + exec() 조합이 UNIX에서 새 프로그램을 실행하는 표준 패턴인데, 이 둘을 분리한 설계 덕분에 fork() 이후 exec() 이전에 파일 디스크립터를 조작하여 리다이렉션(>, <)이나 파이프(|)를 구현할 수 있다. 쉘이 이 틈을 활용한다.
wait(): 부모 프로세스가 자식의 종료를 기다린다.
이를 통해 실행 순서를 결정론적으로 만들 수 있다. wait() 없이 부모가 먼저 종료하면 자식은 고아(orphan) 프로세스가 되고, 자식이 종료했는데 부모가 wait()를 하지 않으면 좀비(zombie) 프로세스로 남는다.
이 세 API의 분리는 UNIX 철학의 핵심 설계 결정이다.

다이어그램에서 강조한 것처럼, fork()와 exec() 사이의 틈이 핵심이다.
셸에서 $ wc file.txt > output.txt를 실행하면, 셸은 fork() → 자식에서 stdout을 output.txt로 리다이렉트 → exec("wc", "file.txt") 순서로 처리한다.
이 틈이 없었다면 리다이렉션과 파이프의 구현이 훨씬 복잡해졌을 것이다.
제한적 직접 실행 (Limited Direct Execution)
CPU 가상화에서 OS가 풀어야 할 핵심 문제 두 가지가 있다.
성능: 프로그램 실행에 과도한 오버헤드를 부과하지 않으면서 어떻게 가상화를 구현할 것인가?
제어권: CPU를 효율적으로 사용하면서도 어떻게 프로세스가 시스템을 망가뜨리지 못하도록 통제할 것인가?
직접 실행(Direct Execution)이란 프로그램의 명령어를 하드웨어 CPU 위에서 직접 돌리는 것이다. 가장 빠르지만, 아무런 제한 없이 사용자 프로그램이 모든 명령어를 실행할 수 있다면 OS는 아무것도 통제할 수 없다. 이를 해결하기 위해 제한적 직접 실행(LDE) 프로토콜이 도입된다.
이중 모드(Dual Mode)
하드웨어는 **커널 모드(kernel mode)**와 **사용자 모드(user mode)**를 구분한다. 사용자 모드에서는 특권 명령어(I/O 접근, 인터럽트 비활성화, 메모리 보호 레지스터 변경 등)가 금지된다. 이를 시도하면 프로세서가 예외(exception)를 발생시키고, OS가 해당 프로세스를 종료시킬 수 있다.
사용자 프로그램이 디스크 읽기 같은 특권 작업을 수행하려면 반드시 **시스템 콜(system call)**을 통해 OS에 요청해야 한다. 시스템 콜은 특별한 trap 명령어를 실행하여 프로세서의 특권 수준을 커널 모드로 상승시키고, 미리 정해진 **트랩 핸들러(trap handler)**로 점프한다.
트랩 핸들러의 주소는 **트랩 테이블(trap table, IDT)**에 기록되어 있으며, 이 테이블은 부팅 시 OS가 설정한다. 사용자 프로그램이 트랩 핸들러의 주소를 임의로 지정할 수 없다는 점이 보안의 핵심이다 — OS가 "어떤 코드를 실행할지"를 완전히 통제한다.
프로세스 간 전환: 타이머 인터럽트
OS가 프로세스를 선점(preempt)하려면 CPU 제어권을 되찾아야 한다. 프로세스가 자발적으로 시스템 콜을 하면 자연스럽게 커널에 제어권이 넘어오지만, 무한 루프를 도는 악의적 프로세스는 어떻게 할 것인가?
하드웨어 타이머 인터럽트가 답이다. 부팅 시 OS가 타이머를 설정하면, 수 밀리초마다 인터럽트가 발생한다. 인터럽트가 발생하면 현재 실행 중인 프로세스가 중단되고 OS의 인터럽트 핸들러가 호출된다. 이때 OS는 현재 프로세스를 계속 실행할지, 다른 프로세스로 전환할지 결정한다 — 이 결정을 내리는 것이 바로 스케줄러다.

다이어그램의 상단은 이중 모드와 트랩 테이블의 구조를, 하단은 System Call과 Context Switch의 전체 시퀀스를 보여준다.
Process A가 User Mode에서 실행 중이다가 ① trap으로 커널에 진입하고, syscall이 처리된 후 return-from-trap으로 복귀한다.
이후 ② 타이머 인터럽트가 발생하면 OS가 ③ context switch를 수행하여 A의 레지스터를 A PCB에 저장하고 B의 PCB에서 복원한 뒤,
④ return-from-trap으로 B를 User Mode로 보낸다. B의 입장에서는 마치 중단된 적 없이 실행을 계속하는 것처럼 보인다