Introduction to OS + Process + Limited Direct Execution
Part 1. Introduction to Operating Systems
1.1 프로그램이 실행되면 무슨 일이 일어나는가?
OS를 이해하기 전에, 가장 근본적인 질문부터 시작합니다: 프로그램이 실행되면 무슨 일이 일어날까?
실행 중인 프로그램은 명령어(instruction)를 실행합니다. 프로세서는 아래 4단계를 끊임없이 반복합니다:
┌──────────┐ ┌──────────┐ ┌──────────────────────────────────────┐ ┌──────────────┐
│ 1. Fetch │ → │ 2.Decode │ → │ 3. Execute │ → │ 4. 다음 명령어│ → ...
│ 메모리에서│ │ 어떤 │ │ 두 수를 더하거나, 메모리에 접근하거나,│ │ 로 이동 │
│ 명령어를 │ │ 명령어인지│ │ 조건을 확인하거나, 함수로 점프하거나 │ │ │
│ 가져옴 │ │ 해석 │ │ 등등... │ │ │
└──────────┘ └──────────┘ └──────────────────────────────────────┘ └──────────────┘
이 단순한 사이클을 수백만 번, 수십억 번 반복하는 것이 프로그램의 실행입니다. 그런데 문제가 있습니다 — 우리는 동시에 수십 개의 프로그램을 실행하고 싶고, 각 프로그램이 서로를 방해하지 않았으면 좋겠습니다. 이 복잡한 문제를 해결하는 것이 바로 운영체제(OS) 의 역할입니다.
1.2 운영체제란?
운영체제(OS)는 하드웨어와 응용 프로그램 사이에 위치하여, 프로그램 실행을 쉽게 만들고, 프로그램 간 메모리를 공유하며, 장치와의 상호작용을 가능하게 하는 소프트웨어입니다.
핵심 역할: OS는 물리적 자원(CPU, 메모리, 디스크)을 관리하는 자원 관리자(Resource Manager) 입니다.
OS가 이 역할을 수행하기 위해 사용하는 핵심 기법은 가상화(Virtualization) 입니다 — 물리적 자원을 가상의 형태로 변환하여, 각 프로그램이 자신만의 전용 자원을 가진 것처럼 착각하게 만듭니다.
1.3 가상화 (Virtualization)
CPU 가상화
하나의 물리 CPU → 여러 개의 가상 CPU로 보이게 만듭니다.
- 핵심 기법: Time Sharing (시분할)
- CPU를 짧은 시간 단위로 번갈아 사용 → 여러 프로그램이 동시에 실행되는 것처럼 보임
- 예시:
./cpu A & ./cpu B & ./cpu C & ./cpu D &→ 4개 프로그램이 동시에 실행되는 것처럼 보임 (실제 CPU는 1개)
메모리 가상화
각 프로세스에게 자신만의 독립적인 주소 공간(Address Space) 을 제공합니다.
- 두 프로그램이 같은 가상 주소(예:
0x00200000)를 사용하더라도, 실제 물리 메모리에서는 서로 다른 위치에 저장됨 - OS가 주소 변환(Address Translation) 을 통해 이를 관리
- 한 프로세스의 메모리 접근이 다른 프로세스의 주소 공간에 영향을 주지 않음
1.4 동시성 (Concurrency)
여러 스레드가 공유 자원에 동시에 접근할 때 발생하는 문제입니다.
예시: 두 스레드가 counter++를 100,000번씩 실행
- 기대값: 200,000
- 실제 결과: 143,012 같은 엉뚱한 값!
- 원인:
counter++는 하나의 연산처럼 보이지만, 실제로는 3개의 명령어로 구성- Load: 메모리에서 레지스터로 값 읽기
- Increment: 레지스터 값 증가
- Store: 레지스터에서 메모리로 값 쓰기
- 이 3개 명령어가 원자적(atomic)으로 실행되지 않기 때문에 문제 발생
1.5 영속성 (Persistence)
DRAM은 휘발성 메모리이므로, 데이터를 영구 저장하려면 하드 드라이브나 SSD 같은 I/O 장치가 필요합니다.
- OS의 파일 시스템(File System) 이 디스크를 관리
open(),write(),close()같은 시스템 콜을 통해 파일을 다룸- 쓰기 도중 시스템이 크래시되더라도 데이터를 보호하기 위해 저널링(Journaling) 이나 Copy-on-Write 기법 사용
1.6 OS 설계 목표
| 설계 목표 | 설명 |
| 추상화 (Abstraction) | 시스템을 편리하고 사용하기 쉽게 만듦 |
| 고성능 (Performance) | OS의 오버헤드를 최소화 |
| 보호/격리 (Protection) | 한 프로그램의 오류가 다른 프로그램이나 OS에 영향을 주지 않도록 함 |
| 신뢰성 (Reliability) | OS는 중단 없이 계속 실행되어야 함 |
| 에너지 효율, 보안, 이동성 | 현대 OS의 추가 설계 고려사항 |
1.7 OS의 역사 (간략 정리)
| 세대 | 시기 | 특징 |
| 1세대 | 1945-55 | 진공관, OS 없음, 프로그래밍 언어 없음 |
| 2세대 | 1955-65 | 트랜지스터, Batch System (한 번에 하나의 job), CPU 활용률 낮음 |
| 3세대 | 1965-80 | IC, Multiprogramming (여러 job을 메모리에 로드), Time-sharing (대화형), Unix 탄생(1969) |
| 4세대 | 1980- | 마이크로프로세서, PC, GUI, 인터넷, 모바일, 가상화 |



Multics vs Unix: Multics는 탑다운(복잡, 150 man-years), Unix는 바텀업(단순 우아, 2 man-years). Unix가 현대 OS의 뿌리가 됨.
Part 2. 프로세스 (The Process)
2.1 프로그램 vs 프로세스 — 가장 기본적인 구분
이 둘의 차이는 OS의 가장 기본적인 개념입니다.
| 구분 | 프로그램 (Program) | 프로세스 (Process) |
| 본질 | 디스크에 저장된 정적인 코드와 데이터의 모음 | 그 프로그램이 메모리에 로드되어 실행되고 있는 동적인 인스턴스 |
| 비유 | 요리 레시피 (종이에 적힌 것) | 실제로 요리하고 있는 과정 (재료, 불, 냄비 등 모두 포함) |
| 상태 | 변하지 않음 (디스크에 그대로 있음) | 계속 변함 (레지스터, 메모리, 열린 파일 등이 시시각각 바뀜) |
| 개수 | 디스크에 하나 | 하나의 프로그램에서 여러 프로세스를 만들 수 있음 |
| 예시 | /usr/bin/chrome 실행 파일 |
Chrome 탭 하나하나가 별도의 프로세스 |
⚠️ 핵심: 프로세스는 단순히 "실행 중인 코드"가 아닙니다. 코드 + 데이터 + 스택 + 힙 + 레지스터 + 열린 파일 등 실행에 필요한 모든 상태를 포함하는 개념입니다.
그렇다면 정적인 프로그램이 어떻게 동적인 프로세스가 되는 걸까요?
2.2 프로세스 생성 과정 — 프로그램이 프로세스가 되기까지
디스크에 있던 실행 파일이 프로세스가 되려면 OS가 다음 5단계를 수행합니다:
디스크 (프로그램) 메모리 (프로세스)
┌────────────────┐ ┌──────────────────────┐
│ 실행 파일 │ │ ┌────────────────┐ │
│ ┌───────────┐ │ ① 코드/데이터 │ │ Stack │ │ ← ② 스택 할당
│ │ Code │ │──── 메모리로 로드 ──→ │ │ (argc, argv) │ │ (지역변수, 리턴주소)
│ ├───────────┤ │ │ ├────────────────┤ │
│ │ Data │ │ │ │ (빈 공간 ↕) │ │
│ └───────────┘ │ │ ├────────────────┤ │
└────────────────┘ │ │ Heap │ │ ← ③ 힙 생성
│ ├────────────────┤ │ (malloc/free)
④ I/O 초기화 │ │ Data │ │
stdin(0), stdout(1), stderr(2) │ ├────────────────┤ │
│ │ Code │ │
⑤ main() 실행 시작! │ └────────────────┘ │
OS → CPU 제어권을 프로세스에 넘김 └──────────────────────┘
| 단계 | 작업 | 설명 |
| 1 | 코드 로딩 | 디스크의 실행 파일에서 코드와 정적 데이터를 메모리로 로드. 현대 OS는 Lazy Loading 사용 (필요할 때만 로드) |
| 2 | 스택 할당 | 지역 변수, 함수 매개변수, 리턴 주소를 위한 공간. argc/argv로 초기화 |
| 3 | 힙 생성 | malloc()/free()로 동적 할당되는 메모리 영역 |
| 4 | I/O 초기화 | stdin(0), stdout(1), stderr(2) 세 개의 파일 디스크립터를 기본으로 열어줌 |
| 5 | main() 실행 | entry point인 main() 함수부터 프로그램 실행 시작. OS가 CPU 제어권을 넘김 |
2.3 Machine State (기계 상태)
프로세스가 실행되면서 읽고 쓸 수 있는 모든 것을 Machine State라고 합니다:
- Memory (주소 공간): 코드(code), 정적 데이터(static data), 힙(heap), 스택(stack)
- Registers: PC(Program Counter) — 다음에 실행할 명령어의 주소, Stack Pointer — 스택의 현재 위치, 범용 레지스터 등
두 프로세스를 구별하는 것은 결국 이 Machine State가 다르다는 것입니다. 같은 프로그램에서 만들어진 두 프로세스도 각각 다른 PC, 다른 스택, 다른 힙 데이터를 가집니다.
2.4 프로세스 상태 (Process States)
프로세스는 항상 다음 세 가지 상태 중 하나에 있습니다:
| 상태 | 의미 | 전이 조건 |
| Running | CPU에서 실제로 실행 중 | Ready에서 Scheduled 되어 옴 |
| Ready | 실행 준비 완료, 하지만 OS가 다른 프로세스를 선택 | Running에서 Descheduled 되어 옴 |
| Blocked | I/O 등의 작업을 기다리는 중 | Running에서 I/O 요청 시 전이 |

⚠️ 핵심: Blocked에서 바로 Running으로 가지 않고, 반드시 Ready를 거칩니다! I/O가 끝나면 Ready로 가고, 스케줄러가 선택해야 비로소 Running이 됩니다.
상태 추적 예시 (CPU + I/O)
| Time | Process₀ | Process₁ | Notes |
| 1 | Running | Ready | |
| 2 | Running | Ready | |
| 3 | Running | Ready | Process₀ initiates I/O |
| 4 | Blocked | Running | Process₀ blocked, Process₁ runs |
| 5 | Blocked | Running | |
| 6 | Blocked | Running | |
| 7 | Ready | Running | I/O done → Ready (Running 아님!) |
| 8 | Ready | Running | Process₁ now done |
| 9 | Running | – | |
| 10 | Running | – | Process₀ now done |
2.5 프로세스 자료구조
OS는 각 프로세스의 정보를 PCB(Process Control Block) 에 저장합니다. PCB는 프로세스의 "신분증"과 같습니다.
PCB에 저장되는 주요 정보:
CPU 레지스터 값, PID, 프로세스 상태, 메모리 관리 정보, 열린 파일 목록, 부모 프로세스 포인터 등
xv6의 프로세스 상태 6가지: UNUSED → EMBRYO → RUNNABLE → RUNNING → SLEEPING → ZOMBIE
xv6의 struct proc 주요 필드:
| 필드 | 용도 |
char *mem |
프로세스 메모리 시작 주소 |
uint sz |
프로세스 메모리 크기 |
char *kstack |
커널 스택의 바닥 |
enum proc_state state |
프로세스 상태 |
int pid |
프로세스 ID |
struct proc *parent |
부모 프로세스 |
struct context context |
컨텍스트 스위치용 레지스터 저장 |
struct trapframe *tf |
인터럽트 시 트랩 프레임 |
Part 3. 프로세스 API (fork, wait, exec)
3.1 fork()
현재 프로세스를 복제하여 새로운 자식 프로세스를 생성합니다.
| 특징 | 설명 |
| 반환값 (부모) | 자식의 PID (양수) |
| 반환값 (자식) | 0 |
| 반환값 (실패) | 음수 (-1) |
| 복제 범위 | 주소 공간(코드, 데이터, 힙, 스택), 레지스터, PC 등 거의 모든 것을 복사 |
| 실행 시작 위치 | 자식은 fork() 반환 이후 시점부터 실행 (fork() 이전 코드는 실행 안 함) |
int rc = fork();
if (rc < 0) { // fork 실패
exit(1);
} else if (rc == 0) { // 자식 프로세스
printf("I am child (pid:%d)\n", getpid());
} else { // 부모 프로세스
printf("I am parent of %d (pid:%d)\n", rc, getpid());
}
⚠️ 주의: fork() 이후 부모와 자식 중 누가 먼저 실행될지는 비결정적(non-deterministic) 입니다. CPU 스케줄러에 따라 달라집니다.
3.2 wait()
부모 프로세스가 자식 프로세스의 종료를 기다립니다.
wait()을 호출하면 자식이 끝날 때까지 부모는 Blocked 상태- 자식의 종료 순서를 보장할 수 있음
- wait()이 없으면? 부모와 자식의 실행 순서를 전혀 예측할 수 없음
} else { // 부모 프로세스
int wc = wait(NULL); // 자식이 끝날 때까지 대기
printf("I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, getpid());
}
이 코드에서는 자식이 항상 먼저 출력하고, 부모가 항상 나중에 출력합니다.
3.3 exec()
현재 프로세스의 주소 공간을 완전히 새로운 프로그램으로 교체합니다.
- 새 프로그램의 코드와 데이터로 메모리를 덮어씀
- 힙과 스택을 초기화
- 새 프로그램의
main()부터 실행
⚠️ 핵심: exec() 호출이 성공하면 절대 리턴하지 않습니다! 주소 공간이 통째로 교체되므로 기존 코드 자체가 사라집니다.
} else if (rc == 0) {
printf("I am child (pid:%d)\n", getpid());
char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("p3.c");
myargs[2] = NULL;
execvp(myargs[0], myargs); // wc 프로그램으로 교체!
printf("this shouldn't print out"); // exec 성공 시 여기 도달 불가
}
3.4 fork()와 exec()의 분리가 강력한 이유
fork()와 exec() 사이에 코드를 넣어서 자식 프로세스의 환경을 변경할 수 있습니다. 이것이 Unix의 핵심 설계 철학입니다.
I/O 리다이렉션 예시
} else if (rc == 0) {
close(STDOUT_FILENO); // stdout 닫기
open("./p4.output", O_CREAT|...); // 이 파일이 fd 1(stdout)이 됨
execvp("wc", myargs); // wc의 출력이 파일로 저장!
}
쉘에서 wc p4.c > p4.output 명령이 바로 이 원리입니다.
파이프(|)도 같은 원리: ls | grep txt → fork()와 exec() 사이에서 파이프를 설정하여 구현
Part 4. Limited Direct Execution (제한적 직접 실행)
4.1 핵심 문제: CPU 가상화의 두 가지 과제
Part 1에서 OS는 CPU를 가상화하여 여러 프로그램을 동시에 실행한다고 배웠습니다. 그런데 구체적으로 어떻게? 해결해야 할 문제가 두 가지입니다:
- 성능(Performance): 가상화를 위한 오버헤드를 최소화하려면?
- 제어(Control): 프로세스를 효율적으로 실행하면서도 CPU에 대한 통제권을 유지하려면?
4.2 Direct Execution (직접 실행)
가장 단순한 방법: 프로그램을 CPU에서 직접 실행합니다.
OS 프로그램
────────────────── ──────────────────
1. 프로세스 리스트에 등록
2. 메모리 할당
3. 코드 로딩
4. argc/argv로 스택 설정
5. 레지스터 초기화
6. main() 호출 ──────────→ 7. main() 실행
8. return from main()
9. 메모리 해제 ←──────────
10. 프로세스 리스트에서 제거
문제점: 이대로라면 OS는 그냥 라이브러리에 불과합니다. 프로그램이 실행되는 동안 OS가 아무런 통제권을 갖지 못합니다. 그래서 "제한적(Limited)"이라는 단어가 붙습니다 — 프로그램을 직접 실행하되, 제한을 걸어서 OS가 통제권을 유지합니다.
4.3 문제 1: 제한된 연산 (Restricted Operation)
프로세스가 디스크 I/O, 추가 메모리 할당 같은 특권 연산을 하고 싶다면?
해결책: 이중 모드 (Dual Mode)
| 모드 | 설명 |
| User Mode (사용자 모드) | 하드웨어 자원에 대한 접근이 제한됨 |
| Kernel Mode (커널 모드) | OS가 모든 하드웨어 자원에 접근 가능 |
System Call (시스템 콜)
커널이 사용자 프로그램에게 제한적으로 노출하는 기능입니다:
파일 시스템 접근, 프로세스 생성/종료, 다른 프로세스와 통신, 메모리 추가 할당 등
Trap과 Return-from-trap
| 명령어 | 동작 |
| Trap | 커널로 점프 + 특권 수준을 커널 모드로 상승 |
| Return-from-trap | 사용자 프로그램으로 복귀 + 특권 수준을 유저 모드로 하락 |
Trap 처리 과정 (xv6 기반)
trap이 발생하면 "커널 안에서 어떤 코드를 실행할지" 어떻게 알까요? 두 가지 테이블이 사용됩니다:
- Trap Table (트랩 테이블): 부팅 시 OS가 설정하는 테이블로, 각 트랩 원인(시스템 콜, 인터럽트 등)에 대한 핸들러 주소를 저장합니다. xv6에서는
struct gatedesc idt[256](trap.c)로 구현됩니다. - System-call Number (시스템 콜 번호): 각 시스템 콜에 번호가 부여되어 있고, 사용자 코드가 원하는 시스템 콜 번호를 레지스터에 넣습니다.
xv6 시스템 콜 번호 예시:
| 매크로 | 번호 | 설명 |
| SYS_fork | 1 | 새로운 자식 프로세스 생성 |
| SYS_exit | 2 | 현재 프로세스 종료 |
| SYS_wait | 3 | 자식 프로세스 종료 대기 |
| SYS_pipe | 4 | 파이프 생성 |
| SYS_read | 5 | 파일 디스크립터에서 데이터 읽기 |
| SYS_kill | 6 | 주어진 PID의 프로세스 종료 |
| SYS_exec | 7 | 바이너리 파일 로드 및 실행 |
Trap 처리 상세 흐름 (kill() 시스템 콜 예시)

Intel 아키텍처에서 프로세스 P가 kill() 시스템 콜을 호출하는 과정:
- 사용자 모드: 프로세스 P는 자신의 메모리만 볼 수 있음 (커널 영역은 숨겨져 있음)
- 시스템 콜 번호 설정:
movl $6, %eax— kill()의 번호 6을 eax 레지스터에 저장 - trap 발생:
int $64— 소프트웨어 인터럽트 실행 - 커널 모드 전환: mode bit가 0으로 설정되어 커널 모드 진입
- trap table 참조: trap-table에서 인터럽트 64에 대한 핸들러 주소를 찾음
- syscall 디스패처: eax 레지스터의 시스템 콜 번호(6)를 확인하고, syscall-table에서 해당 함수(
sys_kill)를 호출 - 실행 및 복귀:
sys_kill실행 후 return-from-trap으로 사용자 모드 복귀
LDE 프로토콜 (전체 흐름)

부팅 시:
OS (커널 모드) 하드웨어
───────────── ─────────
trap table 초기화 ──────────→ syscall 핸들러 주소 기억
실행 시:
OS (커널 모드) 하드웨어 프로그램 (유저 모드)
───────────── ───────── ──────────────────
프로세스 리스트 생성
메모리 할당, 코드 로드
스택 설정 (argv)
커널 스택에 reg/PC 저장
return-from-trap ──→ 커널 스택에서 레지스터 복원
유저 모드로 전환
main()으로 점프 ──────→ main() 실행
...
시스템 콜 호출
←───────────────────── trap into OS
레지스터를 커널 스택에 저장
커널 모드로 전환
트랩 핸들러로 점프
트랩 처리
시스템 콜 작업 수행
return-from-trap ──→ 커널 스택에서 레지스터 복원
유저 모드로 전환
trap 이후 PC로 점프 ──→ ...
return from main()
←───────────────────── trap (via exit())
프로세스 메모리 해제
프로세스 리스트에서 제거
4.4 문제 2: 프로세스 간 전환 (Switching Between Processes)
프로세스가 CPU에서 실행 중일 때, OS는 어떻게 CPU 제어권을 다시 가져올까요?
방법 1: 협력적 접근 (Cooperative Approach)
프로세스가 자발적으로 시스템 콜(예: yield)을 통해 CPU를 양보합니다.
- 불법 연산(0으로 나누기, 잘못된 메모리 접근)이 발생해도 OS로 제어 이전
- 치명적 약점: 프로세스가 무한 루프에 빠지면? → 재부팅밖에 방법이 없음!
- 예시: 초기 Macintosh OS, Xerox Alto 시스템
방법 2: 비협력적 접근 (Non-Cooperative Approach) — Timer Interrupt
타이머 인터럽트를 사용하여 OS가 강제로 제어권을 회수합니다.
- 부팅 시 OS가 타이머를 시작
- 타이머가 일정 밀리초마다 인터럽트를 발생
- 인터럽트 발생 시:
- 현재 실행 중인 프로세스가 중단됨
- 프로그램의 상태를 충분히 저장
- 미리 설정된 인터럽트 핸들러가 실행
타이머 인터럽트가 OS에게 CPU에서 다시 실행될 능력을 부여합니다.
4.5 Context Switch (컨텍스트 스위치)
스케줄러가 현재 프로세스를 계속 실행할지, 다른 프로세스로 전환할지 결정합니다. 전환하기로 결정하면 컨텍스트 스위치를 수행합니다.
Context Switch가 하는 일 (어셈블리 수준)
- 현재 프로세스의 레지스터 값들을 해당 프로세스의 커널 스택에 저장
- 범용 레지스터, PC, 커널 스택 포인터
- 다음 프로세스의 레지스터 값들을 해당 프로세스의 커널 스택에서 복원
- 다음 프로세스의 커널 스택으로 전환
⚠️ 핵심: 레지스터 저장이 2번 일어난다!

컨텍스트 스위치에서 가장 혼동하기 쉬운 부분은, 레지스터 저장/복원이 두 단계에 걸쳐 일어난다는 것입니다:
| 단계 | 누가 | 무엇을 | 어디에 |
| 1단계: 타이머 인터럽트 시 | 하드웨어 | 현재 프로세스 A의 유저 레지스터를 | A의 커널 스택에 저장 |
| 2단계: OS의 switch 루틴 | OS (소프트웨어) | A의 커널 레지스터를 A의 PCB(proc-struct) 에 저장하고, B의 커널 레지스터를 B의 PCB에서 복원 | |
| 3단계: return-from-trap | 하드웨어 | B의 유저 레지스터를 | B의 커널 스택에서 복원 |
LDE 프로토콜 (타이머 인터럽트 포함 전체 흐름)

부팅 시:
OS (커널 모드) 하드웨어
───────────── ─────────
trap table 초기화 ──────────→ syscall 핸들러 주소 기억
timer 핸들러 주소 기억
인터럽트 타이머 시작 ──────→ 타이머 시작, X ms마다 인터럽트
실행 시 (프로세스 A → B 전환):
OS (커널 모드) 하드웨어 프로세스 (유저 모드)
───────────── ───────── ──────────────────
프로세스 A 실행 중...
타이머 인터럽트 발생!
regs(A)를 k-stack(A)에 저장 ← 하드웨어가 자동으로!
커널 모드로 전환
트랩 핸들러로 점프
트랩 처리
switch() 루틴 호출:
regs(A)를 proc-struct(A)에 저장 ← OS가 소프트웨어로!
regs(B)를 proc-struct(B)에서 복원
k-stack(B)로 전환
return-from-trap ──→ regs(B)를 k-stack(B)에서 복원 ← 하드웨어가 자동으로!
유저 모드로 전환
B의 PC로 점프 ──────→ 프로세스 B 실행 재개!
Context Switch 상세 메커니즘 (보충자료 기반)
아래는 프로세스 A(PC=0x10, SP=0x100)에서 프로세스 B(PC=0x30, SP=0x300)로의 컨텍스트 스위치를 단계별로 보여줍니다:

초기 상태:
- 프로세스 A 실행 중: PC=0x10, SP=0x100
- 프로세스 B 대기 중: PCB에 PC=0x30, SP=0x300 저장되어 있음
단계 1 — 하드웨어가 레지스터를 커널 스택에 저장:
- 인터럽트/트랩 발생 시, 하드웨어가 프로세스 A의 레지스터(PC, SP 등)를 A의 커널 스택에 자동 저장
단계 2 — OS가 커널 스택 포인터를 PCB에 저장:
- OS가 A의 커널 스택 포인터(KSP)를 A의 PCB에 저장
- 이를 통해 나중에 A로 돌아올 때 커널 스택을 찾을 수 있음
단계 3 — OS가 다음 프로세스의 커널 스택 포인터를 복원:
- B의 PCB에서 B의 커널 스택 포인터(KSP)를 가져와 SP에 설정
- KSP base도 업데이트
단계 4 — 하드웨어가 커널 스택에서 레지스터를 복원:
- return-from-trap 시 하드웨어가 B의 커널 스택에서 레지스터를 복원
- PC=0x30, SP=0x300으로 설정되어 프로세스 B가 실행 재개
핵심 포인트:
- PCB는 커널 스택 포인터(KSP) 를 저장하는 역할 → 실제 레지스터 값은 커널 스택 안에 있음
- 하드웨어와 OS가 역할을 분담: 하드웨어는 유저↔커널 전환 시 레지스터를 커널 스택에 저장/복원, OS는 프로세스 간 커널 스택 전환을 담당
xv6의 Context Switch in Action

xv6에서 shell → cat으로 프로세스 전환이 일어나는 과정:
- shell이 시스템 콜/인터럽트로 커널 진입 → 유저 레지스터가 shell의 커널 스택에 save
- 첫 번째
swtch: shell의 커널 컨텍스트 → 스케줄러의 커널 컨텍스트 - 두 번째
swtch: 스케줄러의 커널 컨텍스트 → cat의 커널 컨텍스트 - cat의 커널 스택에서 유저 레지스터 restore → cat이 유저 공간에서 실행 재개
xv6는 스케줄러가 별도의 커널 스택을 가집니다. 프로세스 A → 스케줄러 → 프로세스 B, 이렇게 두 번의 swtch가 일어납니다.
xv6의 swtch 코드 (어셈블리)
# void swtch(struct context **old, struct context *new);
# old 컨텍스트의 레지스터를 저장하고, new 컨텍스트의 레지스터를 로드
.globl swtch
swtch:
# === 현재 레지스터 저장 ===
movl 4(%esp), %eax # old 포인터를 eax에 저장
popl 0(%eax) # 리턴 주소(IP)를 저장
movl %esp, 4(%eax) # 스택 포인터 저장
movl %ebx, 8(%eax) # 나머지 callee-saved 레지스터 저장
movl %ecx, 12(%eax)
movl %edx, 16(%eax)
movl %esi, 20(%eax)
movl %edi, 24(%eax)
movl %ebp, 28(%eax)
# === 새 컨텍스트 레지스터 복원 ===
movl 4(%esp), %eax # new 포인터를 eax에 저장
movl 28(%eax), %ebp # 레지스터 복원
movl 24(%eax), %edi
movl 20(%eax), %esi
movl 16(%eax), %edx
movl 12(%eax), %ecx
movl 8(%eax), %ebx
movl 4(%eax), %esp # ★ 여기서 스택이 전환됨!
pushl 0(%eax) # 새 컨텍스트의 리턴 주소를 스택에 push
ret # 새 컨텍스트로 "리턴" (실제로는 점프)
movl 4(%eax), %esp — 이 한 줄이 스택 전환의 핵심입니다. ESP가 바뀌는 순간, 이전 프로세스의 커널 스택에서 새 프로세스의 커널 스택으로 넘어갑니다. 그 후 ret은 새 스택의 리턴 주소로 점프하므로, 결과적으로 새 프로세스의 실행이 재개됩니다.
4.6 동시성 문제: 인터럽트 중 인터럽트가 오면?
인터럽트나 트랩 처리 중에 또 다른 인터럽트가 발생하면 어떻게 될까요?
OS가 이 상황을 처리하는 방법:
- 인터럽트 비활성화 (Disable Interrupts): 인터럽트 처리 중에는 다른 인터럽트를 받지 않음. 단, 너무 오래 비활성화하면 인터럽트를 놓칠 수 있으므로 주의 필요.
- 정교한 락(Lock) 메커니즘: 커널 내부 자료구조에 대한 동시 접근을 보호하기 위해 락킹 스킴을 사용.
Part 5. 개념 확인 퀴즈
아래 문제를 먼저 풀어본 후, 정답을 확인하세요.
O/X 문제
- 운영체제는 하드웨어 자원을 가상화하여 각 프로그램이 전용 자원을 가진 것처럼 보이게 한다.
- CPU 가상화의 핵심 기법은 Space Sharing이다.
- 두 프로세스가 같은 가상 주소를 사용할 수 있다.
- 프로그램과 프로세스는 같은 개념이며, 단지 실행 여부만 다르다.
- Blocked 상태의 프로세스는 I/O가 완료되면 바로 Running 상태가 된다.
- fork() 호출 시 자식 프로세스는 부모의 코드를 처음(main)부터 다시 실행한다.
- exec()는 성공하면 호출한 함수로 리턴한다.
- fork()와 exec()가 분리되어 있어서 I/O 리다이렉션 같은 기능을 쉽게 구현할 수 있다.
- PCB에는 프로세스의 레지스터 값, PID, 메모리 정보 등이 저장된다.
- counter++ 연산은 원자적(atomic)이므로 동시성 문제가 발생하지 않는다.
- 프로세스 생성 시 OS는 항상 프로그램의 모든 코드를 한 번에 메모리에 로드한다.
- User Mode에서는 프로세스가 하드웨어 자원에 직접 접근할 수 있다.
- Trap 명령어를 실행하면 특권 수준이 Kernel Mode로 상승한다.
- 시스템 콜 호출 시, 사용자 코드가 직접 커널 함수의 주소를 지정하여 점프한다.
- 타이머 인터럽트는 협력적(Cooperative) 접근 방식에서 사용되는 기법이다.
- 컨텍스트 스위치 시 레지스터 저장은 하드웨어와 OS(소프트웨어)가 각각 한 번씩, 총 두 번 일어난다.
- xv6의 swtch() 함수에서
movl 4(%eax), %esp명령은 스택 전환을 수행한다. - 인터럽트 처리 중에 다른 인터럽트가 발생할 수 있으며, OS는 이를 위해 인터럽트 비활성화나 락을 사용한다.
빈칸 채우기
- OS가 CPU를 짧은 시간 단위로 번갈아 사용하는 기법을 __ 이라고 한다.
- 프로세스의 세 가지 상태는 __, __, __ 이다.
- fork()의 반환값이 0이면 현재 실행 중인 것은 __ 프로세스이다.
- 프로세스의 정보를 저장하는 OS 자료구조를 __ 라고 한다.
- xv6에서 프로세스 정보를 저장하는 구조체 이름은 __ 이다.
- 프로세스가 시스템 콜을 호출할 때 커널로 진입하기 위해 실행하는 명령어를 __ 이라고 한다.
- 부팅 시 OS가 설정하는, 각 트랩 원인에 대한 핸들러 주소를 저장하는 테이블을 __ 이라고 한다.
- OS가 타이머 인터럽트를 사용하여 CPU 제어권을 강제로 회수하는 방식을 __ 접근이라고 한다.
- 컨텍스트 스위치 시, 하드웨어는 유저 레지스터를 __에 저장하고, OS는 커널 레지스터를 __에 저장한다.
서술형 문제
- "프로그램"과 "프로세스"의 차이를 설명하세요. 단순히 "실행 중이냐 아니냐"를 넘어서, 프로세스가 포함하는 상태(Machine State)까지 언급하세요.
- 아래 코드의 출력 결과로 가능한 것을 모두 적으세요. (PID는 임의의 숫자 사용 가능)
int main() {
printf("start\n");
int rc = fork();
if (rc == 0) {
printf("child\n");
} else {
printf("parent\n");
}
printf("end\n");
return 0;
}
30. fork() 후에 wait()을 사용하면 출력 순서가 어떻게 달라지는지 설명하세요.
31. exec() 호출이 성공했을 때 exec() 뒤에 있는 코드가 실행되지 않는 이유를 설명하세요.
32. fork()와 exec()가 하나의 함수(예: spawn())로 합쳐져 있다면, I/O 리다이렉션을 어떻게 구현해야 할지 생각해보고, 왜 Unix가 이 두 함수를 분리했는지 설명하세요.
33. User Mode와 Kernel Mode를 구분하는 이유를 설명하고, 시스템 콜이 이 두 모드 사이의 전환에 어떤 역할을 하는지 서술하세요.
34. 협력적(Cooperative) 접근과 비협력적(Non-Cooperative) 접근의 차이를 설명하고, 현대 OS가 타이머 인터럽트를 사용하는 이유를 서술하세요.
35. 컨텍스트 스위치 과정에서 "레지스터 저장이 두 번 일어난다"는 것의 의미를 구체적으로 설명하세요. 각 단계에서 누가(하드웨어/OS), 무엇을(어떤 레지스터를), 어디에(커널 스택/PCB) 저장하는지 구분하여 답하세요.
36. xv6의 swtch() 코드에서 ret 명령어가 실행되면 어디로 점프하는지 설명하고, 왜 이것이 "새 프로세스로의 전환"을 의미하는지 서술하세요.
Part 6. 정답 및 해설
O/X 정답
- O — 가상화는 OS의 핵심 역할입니다.
- X — CPU 가상화는 Time Sharing(시분할)입니다. Space Sharing은 메모리에 해당하는 개념입니다.
- O — 메모리 가상화 덕분에 각 프로세스는 독립적인 가상 주소 공간을 가지므로, 같은 가상 주소를 사용해도 물리 메모리에서는 다른 위치입니다.
- X — 프로그램은 디스크에 저장된 정적인 코드이고, 프로세스는 그것이 메모리에 로드되어 실행 중인 동적인 인스턴스입니다. 프로세스는 코드뿐 아니라 레지스터 값, 스택, 힙, 열린 파일 등 모든 실행 상태(Machine State) 를 포함하는 더 넓은 개념입니다.
- X — Blocked → Ready → Running 순서입니다. I/O가 끝나면 Ready 상태로 가고, 스케줄러가 선택해야 Running이 됩니다.
- X — 자식은 fork() 이후 시점부터 실행됩니다. fork() 이전의 코드는 실행하지 않습니다.
- X — exec()는 성공하면 절대 리턴하지 않습니다. 주소 공간이 완전히 새 프로그램으로 교체되기 때문입니다.
- O — fork()와 exec() 사이에 환경을 변경하는 코드를 삽입할 수 있어서 리다이렉션, 파이프 등을 쉽게 구현 가능합니다.
- O — PCB(Process Control Block)에는 프로세스의 모든 관리 정보가 저장됩니다.
- X — counter++는 load, increment, store 3개의 명령어로 구성되어 원자적이지 않습니다.
- X — 현대 OS는 Lazy Loading을 사용하여 필요한 부분만 그때그때 로드합니다.
- X — User Mode에서는 하드웨어 자원에 대한 접근이 제한됩니다. 직접 접근하려면 Kernel Mode가 필요합니다.
- O — Trap 명령어는 커널로 점프하면서 특권 수준을 Kernel Mode로 상승시킵니다.
- X — 사용자 코드는 시스템 콜 번호를 레지스터에 넣을 뿐, 커널 함수 주소를 직접 지정하지 않습니다. 커널이 trap table과 syscall table을 통해 적절한 핸들러를 찾습니다. 이는 보안을 위한 핵심 설계입니다.
- X — 타이머 인터럽트는 비협력적(Non-Cooperative) 접근 방식입니다. 협력적 접근은 프로세스가 자발적으로 CPU를 양보하는 방식입니다.
- O — 첫 번째는 하드웨어가 유저 레지스터를 커널 스택에 저장하고, 두 번째는 OS가 커널 레지스터를 PCB(proc-struct)에 저장합니다.
- O — 이 명령어가 ESP를 새 프로세스의 커널 스택으로 변경하여 스택 전환을 수행합니다.
- O — 인터럽트 비활성화와 락은 인터럽트 처리 중 동시성 문제를 방지하는 OS의 대표적 기법입니다.
빈칸 정답
- Time Sharing (시분할)
- Running, Ready, Blocked
- 자식(child) 프로세스
- PCB (Process Control Block)
- struct proc
- Trap (트랩 명령어) — xv6/x86에서는
int명령어 (예:int $64) - Trap Table (트랩 테이블)
- 비협력적 (Non-Cooperative)
- 하드웨어는 유저 레지스터를 커널 스택(kernel stack) 에 저장하고, OS는 커널 레지스터를 PCB (proc-struct / process structure) 에 저장한다.
서술형 정답
28번:
프로그램은 디스크에 저장된 정적인 실행 파일(코드와 데이터의 모음)이고, 프로세스는 그 프로그램이 메모리에 로드되어 CPU에서 실행되고 있는 동적인 인스턴스입니다. 하나의 프로그램에서 여러 개의 프로세스를 만들 수 있습니다.
프로세스가 단순히 "실행 중인 코드"와 다른 점은, 프로세스는 Machine State 전체를 포함한다는 것입니다: 주소 공간(코드, 데이터, 힙, 스택), CPU 레지스터(PC, Stack Pointer, 범용 레지스터), 열린 파일 목록(file descriptors) 등이 모두 프로세스의 상태입니다. 같은 프로그램에서 만들어진 두 프로세스도 각각 다른 PC, 다른 스택, 다른 힙 데이터를 가지므로 서로 다른 프로세스입니다.
29번:start는 항상 첫 번째로 출력됩니다. 그 이후 parent, child, end(부모), end(자식)의 순서는 스케줄러에 따라 달라집니다. 가능한 출력 예시:
가능한 출력 1: 가능한 출력 2: 가능한 출력 3:
start start start
child parent parent
end end child
parent child end
end end end
핵심: start는 fork() 전이라 1번만 출력되고, end는 fork() 후라 2번 출력됩니다.
30번:
wait() 없이는 부모와 자식의 실행 순서가 비결정적이지만, 부모가 wait()을 호출하면 자식이 종료될 때까지 부모가 Blocked 상태가 되므로, 자식의 출력이 항상 부모보다 먼저 나옵니다. 즉, 실행 순서가 결정적(deterministic)이 됩니다.
31번:
exec()가 성공하면 현재 프로세스의 주소 공간(코드, 데이터, 힙, 스택)이 새 프로그램의 것으로 완전히 교체됩니다. 따라서 기존 코드 자체가 메모리에서 사라지기 때문에, exec() 뒤의 코드는 더 이상 존재하지 않아 실행될 수 없습니다.
32번:
만약 fork()와 exec()가 spawn()으로 합쳐져 있다면, 새 프로세스를 만들면서 동시에 새 프로그램을 실행하게 됩니다. 이 경우 I/O 리다이렉션을 하려면 spawn() 자체에 리다이렉션 옵션을 매개변수로 전달해야 하고, 파이프, 환경변수 변경 등 새로운 기능이 필요할 때마다 spawn()의 인터페이스를 계속 확장해야 합니다.
Unix가 fork()와 exec()를 분리한 이유는, fork() 이후 exec() 이전의 시점에 자식 프로세스 환경을 자유롭게 변경할 수 있기 때문입니다. close/open으로 파일 디스크립터를 바꾸면 리다이렉션, pipe()를 설정하면 파이프, 환경변수를 변경하면 실행 환경 커스터마이징 등 무한히 유연한 조합이 가능합니다. 이것이 Unix 설계의 핵심 철학인 "단순하지만 강력한 조합"입니다.
33번:
User Mode와 Kernel Mode를 구분하는 이유는 보호(Protection) 때문입니다. 만약 모든 프로세스가 하드웨어에 직접 접근할 수 있다면, 악의적이거나 버그가 있는 프로그램이 디스크를 덮어쓰거나 다른 프로세스의 메모리를 침범할 수 있습니다.
시스템 콜은 이 두 모드 사이의 통제된 전환 메커니즘입니다. 사용자 프로그램이 시스템 콜을 호출하면, trap 명령어가 실행되어 커널 모드로 전환되고, OS가 미리 정해놓은 핸들러만 실행됩니다. 작업이 끝나면 return-from-trap으로 유저 모드로 복귀합니다. 이렇게 함으로써 사용자 프로그램은 필요한 특권 연산을 할 수 있지만, 반드시 OS가 허용한 방식으로만 가능합니다.
34번:
협력적 접근은 프로세스가 시스템 콜(예: yield)을 통해 자발적으로 CPU를 양보하는 방식입니다. 프로세스가 "착하게" 행동해야 하며, 무한 루프에 빠지면 OS가 제어권을 회수할 방법이 없어 재부팅해야 합니다.
비협력적 접근은 타이머 인터럽트를 사용하여 OS가 강제로 제어권을 회수하는 방식입니다. 일정 시간마다 하드웨어가 인터럽트를 발생시키므로, 프로세스의 협력 없이도 OS가 주기적으로 실행됩니다.
현대 OS가 타이머 인터럽트를 사용하는 이유는, 악의적이거나 버그가 있는 프로세스로부터 시스템을 보호하고, 모든 프로세스에게 공정하게 CPU 시간을 배분하기 위해서입니다.
35번:
컨텍스트 스위치에서 레지스터 저장은 다음과 같이 두 단계로 나뉩니다:
1단계 (하드웨어, 타이머 인터럽트 시): 하드웨어가 현재 실행 중인 프로세스 A의 유저 모드 레지스터 (PC, SP, 범용 레지스터 등)를 프로세스 A의 커널 스택에 자동으로 저장합니다. 이는 trap/인터럽트 발생 시 하드웨어가 자동으로 수행하는 동작입니다.
2단계 (OS 소프트웨어, switch 루틴): OS의 switch 루틴이 프로세스 A의 커널 모드 레지스터 (커널에서 사용하던 레지스터 값들)를 프로세스 A의 PCB(proc-struct) 에 저장하고, 프로세스 B의 PCB에서 B의 커널 레지스터를 복원합니다.
이후 return-from-trap 시 하드웨어가 B의 커널 스택에서 B의 유저 레지스터를 복원하여 프로세스 B가 유저 모드에서 실행을 재개합니다.
36번:
xv6의 swtch() 코드에서 ret 명령어가 실행되면, 새 컨텍스트의 스택 꼭대기에 있는 리턴 주소로 점프합니다. ret 직전에 pushl 0(%eax)가 새 컨텍스트의 저장된 IP(Instruction Pointer)를 스택에 push했고, 그 직전에 movl 4(%eax), %esp가 ESP를 새 프로세스의 커널 스택으로 전환했습니다.
따라서 ret이 실행되면, 이미 스택은 새 프로세스(B)의 것이고, 리턴 주소도 B가 이전에 swtch()를 호출했던 지점의 다음 명령어입니다. 결과적으로 ret은 프로세스 B가 마지막으로 중단되었던 지점으로 "리턴"하게 되므로, 이것이 곧 "프로세스 A에서 프로세스 B로의 전환"이 됩니다.
다음 세션: Scheduling (FIFO, SJF, STCF, RR, MLFQ)
'CS' 카테고리의 다른 글
| OS 중간고사 대비 (0) | 2026.04.20 |
|---|