Exceptional Control Flow: Exceptions and Processes
Control Flow
Process는 한번에 한 가지 일만 수행
- CPU는 시작(Startup)에서 종료(Shutdown)까지 명령어(Instruction)를 순차적으로 읽고 실행
- 이 순차적인 실행 흐름을 Control Flow라고 함.

- 추후에 예외적인 상황이 발생하면 흐름이 변경될 수도 있긴 함 (인터럽트, 시스템 호출 … )
Altering the Control Flow
기본적인 Control Flow의 변경 방법은 두 가지 있음
- 점프(Jump)와 분기(Branches)
- 위에 것들로는 충분하지 않음 (System State 변화에 대한 대응이 부족하다!)
- 예외 적인 제어 흐름이 필요하다!
Exceptional Control Flow
예외적 제어 흐름은 일반적인 프로그램 실행 흐름과는 달리 예외 상황이 발생하면 실행 흐름이 변경되는 것을 말한다.
예외 상황: Interrupt, Exception, Signal
낮은 수준과 높은 수준에서 발생할 수 있다.
낮은 수준(Low Level/ 하드웨어, OS) 메커니즘
- 0으로 나누기 → CPU가 예외 발생
- 페이지 폴트(Page Fault) → OS가 메모리를 불러옴
높은 수준(High Level/ 소프트웨어) 매커니즘
- 프로세스 컨텍스트 스위칭(Process Context Switch)
- 시그널(Signal)
- 넌로컬 점프 (Nonlocal Jumps)
Exceptions
프로그램 실행 중 특정 이벤트가 발생하면 CPU가 커널(Kernel)로 제어를 넘김
커널이 예외 핸들러(Exception Handler)를 실행하여 문제를 해결
예외 처리 후, 프로그램 실행을 재개하거나 종료(Abort)
CPU가 평상시에는 유저모드로 일반적인 코드나 프로그램을 실행함.
근데 예외가 발생하면 운영체제가 개입해서 CPU를 커널모드로 바꾸고 예외 처리 후에 다시 유저모드로 복귀함.

이벤트 유형
|
설명
|
예제
|
산술 오류
|
연산 중 오류 발생
|
0으로 나누기(Divide by 0), 오버플로(Overflow)
|
페이지 폴트
|
잘못된 메모리 접근
|
가상 메모리에서 미할당 페이지 접근
|
입출력 완료
|
I/O 작업 완료 알림
|
디스크에서 데이터 읽기 완료
|
사용자 입력
|
특정 키 입력 감지
|
Ctrl + C (프로세스 종료)
|
Exception Tables
예외 발생 시 CPU가 어떤 핸들러를 실행할 지 결정하는 테이블
이게 다 정해져 있고 예외 유형마다 고유한 예외 번호가 붙어있음 (Exception Number) k 가 할당됨
- 만약 A라는 예외가 발생했고(여기서 실행 흐름이 커널 모드로 넘어간다)
- A의 예외 번호가 10이면 CPU가 예외 테이블을 참조하고
- 예외 번호가 10인 예외 핸들러의 주소를 가져온다.
- 그 다음에 CPU가 10번 예외 핸들러를 직접 호출.
- 예외 처리 끝났으면 다시 실행 흐름이 유저 모드로 복귀
(제어는 항상 CPU가 함!! 실행 흐름이 유저 모드인지 커널 모드인지 차이임!!)
Classes of Exceptions
Class (종류)
|
Cause (원인)
|
Async/Sync (동기/비동기)
|
Return Behavior (복귀 방식)
|
Interrupt (인터럽트)
|
I/O 장치의 신호
|
비동기(Async)
|
항상 다음 명령어로 복귀
|
Trap (트랩)
|
의도적인 예외 (ex. 시스템 호출)
|
동기(Sync)
|
항상 다음 명령어로 복귀
|
Fault (폴트)
|
복구 가능 오류 (ex. 페이지 폴트)
|
동기(Sync)
|
현재 명령어로 복귀 가능
|
Abort (어보트)
|
복구 불가능한 오류 (ex. 하드웨어 오류)
|
동기(Sync)
|
절대 복귀하지 않음
|
비동기 예외
프로그램이 실행하는 명령어와 관계없이 발생하는 예외
보통 외부 장치에서 발생한 인터럽트(Interrupt)가 원인이라 인터럽트 형태로 처리됨
예외 처리 후 원래 항상 프로그램의 다음 명령어부터 실행 계속 함.
CPU가 감시하는 느낌이네..

ex) 타이머 인터럽트, I/O 인터럽트
동기 예외
현재 실행 중인 명령어가 원인이 되어서 발생하는 예외
CPU가 유저 코드 읽다가 발생한 예외
처리하고 나서 프로그램 계속 실행할지 말지 결정함.
Trap (트랩) - 의도적인 예외
- 시스템 호출 (System Call) → read(), write(), fork()
- 항상 다음 명령어로 복귀

Fault (폴트) - 복구 가능한 예외
- 페이지 폴트(Page Fault) → OS가 메모리를 할당한 후 다시 실행 가능
- 원래 명령어로 복귀할 수도 안할수도

Abort (어보트) - 복구 불가능한 예외
- 하드웨어 오류(메모리 오류, CPU 오류) → 프로그램 강제 종료
- 복귀 불가

System Call
사용자의 프로그램이 운영체제 커널에 기능을 요청하는 매커니즘
사용자 모드(User Mode)에서 실행되는 프로그램이 파일 조작, 프로세스 관리, 메모리 할당 등 중요한 작업을 수행하려면 커널 모드(Kernel Mode)로 전환해야됨.
유저 모드 → 커널 모드
각 시스템 호출을 할 때에는 고유한 번호가 할당됨.
운영 체제에서는 직접 시스템 호출을 하지 않고 C 라이브러리를 통해서 간접적으로 호출함.

만약 유저가 open(filename, options) 라고 호출했다 치자.
그러면 C 라이브러리에 있는 __open() 함수가 실행됨.
__open() 함수는 내부적으로 시스템 콜 번호(여기서는 2)를 설정하고 syscall 명령어를 실행함.
syscall이 실행되면 유저 모드에서 커널 모드로 변경되고 운영체제 커널의 시스템 콜 핸들러가 실행됨.
시스템 콜 핸들러는 시스템 콜 번호(RAX=2)를 확인하고 함수들을 연쇄적으로 실행.
sys_openat() → do_sys_open() → do_filp_open()
파일을 찾아서 파일 디스크립터를 할당함. RAX에 파일 디스크립터를 저장하고 유저 모드로 복귀
유저는 open() 함수의 반환값으로 파일 디스크립터를 받음.
요약
open() → __open() → syscall with system call number(rax) → rax(file descripter)
Fault
Page Fault
운영체제는 가상 메모리를 사용함.
모든 프로세스가 자기만의 연속된 메모리 공간을 가지는 것이 아니라 일부만 물리적인 메모리에 올라가고 나머지는 디스크에 저장 프로세스가 사용하는 메모리는 페이지 단위로 관리되는데 운영체제는 자주 사용하는 페이지들만 메모리에 올려두고 나머지는 디스크에 저장함.
프로그램이 아직 메모리에 올라오지 않은 페이지를 접근하려고 하면 페이지 폴트가 발생함.
다시 말해서 해당 메모리 페이지가 현재 디스크에 있다는 것은 해당 페이지가 아직 RAM(물리적인 메모리)에 올라오지 않았다는 뜻 CPU는 해당 메모리 페이지가 RAM에 없으니까 페이지 폴트 발생
커널이 디스크에서 해당 페이지를 찾아서 RAM에 올려줌
CPU가 중단했던 유저 코드로 돌아와서 해당 라인을 다시 실행함.
이제 해당 메모리 페이지가 RAM에 있으니까 정상작동
#include <stdio.h>
#include <stdlib.h>
int main() {
int *arr = malloc(1000000 * sizeof(int));
for (int i = 0; i < 1000000; i++) {
arr[i] = i; // 페이지 폴트 발생!
}
free(arr);
return 0;
}
여기서 malloc() 은 RAM을 할당하는 것이 아니라 가상 주소 공간만 예약함.
실제로 arr[i] = i; 로 접근하는 순간 페이지 폴트가 발생하고 운영체제가 RAM을 할당해줌.
Invalid Memory Reference (Protection Fault)
int a[1000]; // 크기가 1000인 배열 선언
main () {
a[5000] = 13; // 유효 범위를 벗어난 메모리 접근 (잘못된 주소 접근)
}
현재 a[5000] = 13; 은 해당 메모리 주소에 값을 저장하려고 시도함.
하지만 이 주소가 운영체제에 의해 예약되지 않은 영역이면 CPU가 페이지 테이블에서 해당 주소를 찾을 수 없어서 페이지 폴트 예외가 발생함.
유저 모드에서 커널 모드로 변경
운영체제가 예외 처리 시작, 그래도 다시 한번 주소가 유효한지 운영체제가 검사
만약 이게 해결 가능했으면 운영체제는 필요한 메모리 할당하고 다시 실행을 했을 텐데, 이건 걍 불가능한 상황이라 SIGSEGB (세그 폴트) 발생 시키고 프로세스를 걍 종료함.
참고로 int a[1000]; 은 스택 메모리에 저장
이건 운영체제가 개입해서 메모리를 할당하는게 아니라 컴파일러와 CPU가 스택 구조를 조정해서 공간을 확보해줌.
RAM에 즉시 할당됨
Processes
프로세스는 실행중인 프로그램의 인스턴스를 의미함.
프로그램 자체는 코드의 집합인데, 프로세스는 실행 상태를 유지하는 데 필요한 메모리와 CPU 상태를 포함함.
각 프로세스마다 고유한 주소 공간을 가짐.
하나의 프로그램은 하나의 인스턴스
프로세스는 프로그램이 돌아가고 있는 상태를 생각하면 될 듯
Logical Control Flow
- 각 프로세스는 CPU를 독점적으로 사용하는게 아니라 컨텍스트 스위칭을 통해서 번갈아 가며 실행
- 컨텍스트 스위칭
Private Address Space
- 프로세스마다 독립적인 가상 메모리를 가지며, 다른 프로세스와 주소 공간을 공유하지 않음.
- 가상 메모리를 통해서 실제 메모리와는 분리된 독립적인 공간을 가짐
Multiprocessing
컴퓨터는 여러 개의 프로세스를 동시에 실행하는 것이 아니라 운영체제가 빠르게 번갈아 가면서 실행함 → 컨텍스트 스위칭
각 프로세스가 실행될 때 CPU 레지스터 값이 해당 프로세스의 상태로 변경됨
여기서 컨택스트 스위칭이 발생하면 현재 실행 중인 프로세스의 레지스터 값을 저장하고 다른 프로세스의 레지스터 값을 불러옴.
레지스터 값은 각 프로세스의 PCB에 저장됨


Context Switching
CPU가 실행 중인 프로세스를 변경하는 과정을 말한다.
운영체제의 커널이 개입해서 현재 실행 중인 프로세스의 상태를 저장하고 다른 프로세스를 로드하는 과정.
이 과정이 반복되면서 여러 프로세스가 동시에 실행되는 것처럼 보임
(커널은 별도의 프로세스가 아님!! 걍 운영체제의 일부로 동작하는 녀석임!!!)

처음에 A가 실행되고 있다가 컨텍스트 스위칭(타이머 인터럽트, I/O 요청, 시스템 콜 … 에 의해서) 이 발생하면 운영체제의 커널 코드가 실행됨.
여기서 커널 코드는 프로세스를 변경하라는 코드일거임.
새로운 프로세스(유저 코드) 실행
System Call Error Handling
시스템 호출을 실패하면 -1을 반환하고 전역 변수 errno에 오류 원인을 저장함.
반환값이 void일 때만 제외하고는 오류를 처리해야함!
모든 시스템 호출은 반환값을 반드시 확인해야함!
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0);
}
fork() 함수는 실패하면 -1 를 반환
errno 값은 strerror(errno) 를 이용해 문자열로 변환해서 오류 메세지를 출력함.
이후에 exit(0)으로 프로그램 종료
fork()
fork() 는 프로세스를 복제해서 부모 프로세스와 자식 프로세스를 만들고 실행 결과에 따라서 반환값이 달라진다.
복제는 처음부터 끝까지 완전히 같은 코드로 복제되고 fork() 이후의 코드부터 실행됨
부모 프로세스에서 fork() 를 호출하면 새로운 자식 프로세스를 만들고, 부모 프로세스에서는 자식의 pid를 반환한다.
따라서 부모는 새로 생성된 자식 프로세스의 pid를 알게 된다.
자식 프로세스에서는 fork() 가 성공적으로 실행되면 자식 프로세스에서는 항상 0을 반환한다.
fork()는 실패하면 -1을 반환하고 errno에 오류에 대한 설명 코드가 저장된다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
pid_t pid = fork(); // 새로운 프로세스 생성
if (pid > 0) { // 부모 프로세스
printf("부모 프로세스: 자식 PID = %d\n", pid);
}
else if (pid == 0) { // 자식 프로세스
printf("자식 프로세스: PID = %d, 부모 PID = %d\n", getpid(), getppid());
}
else { // fork() 실패
perror("fork 실패");
exit(1);
}
return 0;
}
부모 프로세스: 자식 PID = 5678
자식 프로세스: PID = 5678, 부모 PID = 1234
// 위 문장들의 순서는 컨텍스트 스위칭 때문에 얼마든지 뒤바뀔 수 있음
pid_t getpid(void)
// 현재 실행 중인 프로세스의 PID를 반환
// 각 프로세스는 고유한 PID를 가지며, 이를 통해서 시스템은 프로세스를 식별함
pid_t getppid(void)
// 현재 프로세스의 부모 프로세스 PID를 반환
// 자식 프로세스는 getppid()로 자신을 생성한 부모 프로세스의 PID를 확인할 수 있음
Error-reporting functions


이 함수는 시스템 호출(fork(), open(), read() … )이 실패햇을 때 오류를 출력하고 종료하는 함수
msg는 사용자가 전달한 문자열
strerror(errno)는 errno에 저장된 오류 코드를 문자열로 변환해서 출력
exit(0) 으로 프로그램 종료
아래처럼 커스텀 Fork()만들어서 에러 처리까지 한번에 할 수 있음!

Process States
운영체제에서 프로세스는 3가지 상태 중 하나에 있을 수 있다.
Running
- 프로세스가 현재 실행 중이거나 CPU에서 실행될 차례를 기다리는 상태
- 운영체제의 스케줄러가 선택하면 CPU에서 실행됨
Stopped
- 프로세스의 실행이 일시적으로 중단된 상태
- SIGSTOP, SIGTSTP 등의 시그널을 받으면 중단됨
- 이후에 다시 SIGCONT 시그널을 받으면 다시 실행 가능
Terminated
- 프로세스가 완전히 종료된 상태
- exit() 호출, SIGKILL , SIGTERM 시그널 등을 받아서 프로세스가 종료되는 상태
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
int main() {
pid_t pid = fork();
if (pid > 0) { // 부모 프로세스
printf("부모 프로세스: 자식 PID = %d\n", pid);
sleep(2);
kill(pid, SIGSTOP); // 자식 프로세스를 STOP 상태로 만듦
sleep(2);
kill(pid, SIGCONT); // 자식 프로세스를 다시 실행 (Running)
sleep(2);
kill(pid, SIGTERM); // 자식 프로세스를 종료 (Terminated)
}
else if (pid == 0) { // 자식 프로세스
while (1) {
printf("자식 프로세스 실행 중...\n");
sleep(1);
}
}
else {
perror("fork 실패");
exit(1);
}
return 0;
}
자꾸 위의 코드에서 kill kill 거리는데 걍 생긴게 저래 생긴거 진짜 죽인다는 의미 아님..
Terminating Processes
프로세스가 종료되는 3가지 방법을 소개한다.
- 시그널 수신
- main 함수에서 return
- exit(status) 함수 호출
- exit()를 호출하면 프로세스가 종료되며, 종료 상태(exit status) 를 설정할 수 있음.
- 종료 상태는 부모 프로세스가 wait()를 통해 확인 가능.
Creating Processes
fork() 시스템 호출을 통해 새로운 자식 프로세스를 만드는 방법
사실 위에서 살펴봤던 내용임
Exceptional Control Flow: Exceptions and Processes - fork()
fork()의 반환값
실행되는 프로세스
|
fork() 반환값 (pid)
|
의미
|
부모 프로세스
|
자식의 PID (양수 값)
|
자식 프로세스의 ID
|
자식 프로세스
|
0
|
자신이 자식 프로세스임을 의미
|
실패 (에러)
|
-1
|
fork() 실패 (새 프로세스 생성 불가)
|
부모와 자식 프로세스의 차이점
- 메모리 공간
- 파일 디스크립터 (File Descriptors)
- 프로세스 ID (PID)
fork()는 한 번 호출되지만, 두 번 반환된다
- fork()는 부모 프로세스에서 한 번 호출되지만,
Process Graph
병렬 실행(Concurrent Execution) 의 부분 순서(Partial Ordering) 를 표현하는 도구.





위에는 걍 Process Graph 하는 방법을 알려주는겨
Reaping Child Processes
Zombie Processes
자식 프로세스가 종료된 후에도 부모 프로세스가 정리를 안하면 해당 자식은 좀비 프로세스가 되어버림
따라서 부모 프로세스는 종료(Terminate)된 자식 프로세스에 대해서 자식 프로세스의 종료 상태를 회수 해야함.
그렇지 않으면 자식 프로세스는 프로세스 테이블의 커널 리소스를 차지하고 있는 상태로 잔류함.
(커널 리스소: 프로세스 테이블, 파일 디스크립터, 시그널 핸들러 정보, 프로세스 스케줄링 큐, 메모리 매핑 정보, …)
(RAM에 저장 되어있음)
프로세스 테이블이 가득 차면 새로운 프로세스를 만들 수 없게 됨.
Orphan Processes
부모가 wait()이나 waitpid()를 호출하면 자식의 종료 상태를 받아오고, 커널이 좀비 프로세스를 삭제함.
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid > 0) { // 부모 프로세스
int status;
wait(&status); // 자식 프로세스를 리핑 (좀비 방지)
printf("부모: 자식 프로세스 종료됨\n");
}
else if (pid == 0) { // 자식 프로세스
printf("자식: 실행 중...\n");
sleep(2);
printf("자식: 종료\n");
exit(0);
}
else {
perror("fork 실패");
exit(1);
}
return 0;
}
위 코드는 부모가 wait() 를 호출하면 커널이 자식 프로세스를 완전히 제거해줌.
근데 부모가 wait()을 호출하지 않으면 자식 프로세스는 좀비 상태로 남음.
여기서 부모가 종료되어버리면 고아 프로세스가 되고, init(PID 1)프로세스가 자동으로 wait()을 호출해서 수거함.
그럼 걍 wait() 안해줘도 수거하는 거 아님??
ㅇㅇ 아님
서버나 쉘처럼 부모 프로세스가 아주 장시간 실행되는 상황에서는 이 녀석들이 무수히 많은 자식을 만들고 종료된 자식에 대해서 지속적으로 wait()을 해주지 않으면 좀비가 계속 쌓여서 커널 리소스가 부족해 질 수 있다.
위에 코드처럼 짧은 부모 프로세스면 걍 init 이 처리해주니까 자식 프로세스들이 고아 프로세스가 되어도 걱정 안해도 되긴 함.
근데 내가 궁금한건 아래 코드 부분
if (pid > 0) { // 부모 프로세스
int status;
wait(&status); // 자식 프로세스를 리핑 (좀비 방지)
printf("부모: 자식 프로세스 종료됨\n");
}
부모 프로세스는 자식 프로세스에 대한 정보를 wait()할 때 사용하지 않는데 어떻게 자식을 알고 기다리는거지?
FLOW
- 부모 프로세스가 wait(&status); 를 호출하면 커널에 자식이 종료될 때까지 기다리겠다고 요청함(부모는 BLOCKED 상태).
- 커널은 부모 프로세스의 자식 프로세스 목록을 확인함.
- 커널이 자식 프로세스의 종료를 감지하면 해당 종료 상태(exit status)를 status에 부모에게 전달하고 부모를 다시 실행시킴.
- 부모는 wait()에서 일어나고 실행을 계속함.
wait()이 기다릴 자식 프로세스를 자동으로 찾을 수 있던 이유?
→ 커널이 부모 - 자식 관계를 관리하기 때문
→ 운영체제는 모든 프로세스의 부모 - 자식 관계를 트래킹 하고 있음.
→ 부모 프로세스는 fork() 를 통해서 자식이 생성되면, 커널의 프로세스 테이블에 부모 - 자식 관계가 저장됨
→ 따라서 부모가 wait() 을 호출하면 커널이 알아서 부모의 자식 프로세스 중 하나가 종료될 때까지 기다리는 걸로 판단함.
놓치지마! fork() 이후 wait()을 호출하면 자식이 먼저 실행되고 해당 자식이 종료된 후에 부모가 실행된다.
만약 wait() 을 사용하지 않아도 괜찮을 정도의 짧은 부모 프로세스라고 가정해보자.
wait() 을 호출하면 부모는 자식이 완료될 때까지 기다린다고 했음. 근데 wait() 을 작성해주지 않으면 실행순서는 컨텍스트 스위칭 때문에 뒤죽박죽이 될거고 자식 프로세스는 고아가 될 수도 안될 수도 있음.
만약 고아가 된다고 해도 init 프로세스가 알아서 다 수거해줌.


- 부모는 N번 wait()을 호출하여 모든 자식이 종료될 때까지 하나씩 기다림.
- wait()은 종료된 자식 중 하나를 랜덤하게 수거함 (자식이 종료된 순서대로 기다리는 게 아니라, 종료된 것부터 처리).

기존의 wait()와 다르게, waitpid(pid, &status, options)는 특정 PID를 가진 자식 프로세스가 종료될 때까지 기다릴 수 있음.
여기서는 생성의 역순으로 자식 프로세스를 수거하고 있음.
execve: Loading and Running Programs
execve()는 현재 프로세스를 새로운 프로그램으로 대체하는 함수
int execve(char *filename, char *argv[], char *envp[]);
- filename: 실행할 프로그램의 경로 (예: "/bin/ls").
- argv[]: 프로그램의 명령줄 인자(커맨드 라인) (예: {"/bin/ls", "-l", NULL}).
- envp[]: 환경 변수 리스트 (예: { "USER=abc", "HOME=/home/abc", NULL }).
execve() 는 새로운 프로그램을 로드하여 기존의 프로세스를 덮어쓴다.
pid는 유지하면서 코드, 데이터, 스택은 새로운 프로그램의 것으로 바꿈.
호출된 후 성공하면 절대로 반환되지 않음. 새로운 프로그램이 실행되고 있으니까
실패하면 -1을 반환하고 기존 프로그램이 계속 실행됨.
새로운 프로그램이 시작될 때의 스택 구조

execve() 사용 예제
