이번 포스팅에서는 예외적인 제어 흐름(ECF, Exceptional Control Flow)에 대해 다룹니다. 이를 이해하기 위해 먼저 일반적인 제어 흐름에 대해 간단히 알아보겠습니다.
일반적인 제어 흐름
컴퓨터 프로그램은 보통 메모리에 연속적으로 저장된 명령어들을 순차적으로 실행합니다. 이러한 흐름은 다음과 같은 방식으로 이루어집니다:
- 순차 실행: 명령어들이 메모리에 저장된 순서대로 실행됩니다.
- 분기 및 반복: 조건문과 반복문을 통해 특정 조건에 따라 흐름이 변경됩니다.
- 함수 호출과 반환: 함수를 호출하면 해당 함수의 명령어들이 실행되고, 완료되면 원래 위치로 돌아옵니다.
하지만, 프로그램 실행 중 예기치 않은 상황이나 외부 이벤트로 인해 이러한 흐름이 갑자기 변경되는 경우가 있습니다. 이를 예외적인 제어 흐름(ECF)이라고 합니다.
예외적인 제어 흐름(ECF)란?
예외적인 제어 흐름(ECF)은 시스템 상태의 갑작스러운 변화에 대응하여 프로그램의 실행 흐름이 변화하는 것을 의미합니다. ECF는 하드웨어, 운영체제, 응용 프로그램 수준에서 모두 발생할 수 있으며, 이에 대한 대응도 준비되어 있어야 합니다.
ECF의 발생 수준
- 하드웨어 수준: 하드웨어에서 발생하는 이벤트(예: 키보드 입력, 타이머 인터럽트 등)에 의해 제어 흐름이 변경됩니다.
- 운영체제 수준: 문맥 전환(Context Switch)을 통해 프로세스 간 제어가 이동합니다.
- 응용 프로그램 수준: 시그널(Signal)이나 예외(Exception)를 통해 제어 흐름이 변경됩니다.
8.1 예외(Exceptions)
예외란?
프로그램 실행 중 발생하는 예기치 않은 사건으로, 일반적인 순차적 제어 흐름을 중단하고 핸들러로 분기한다. x86 계열에서는 인터럽트, 트랩, 폴트, 어보트 등을 모두 예외(Exception) 라 부르며, 이들은 하드웨어·소프트웨어 수준에서 각각 처리된다
예외 분류: 동기 VS 비동기
분류 | 설명 | 예시 |
동기 예외(Synchronous) | 현재 실행 중인 명령어가 문제를 일으켜 즉시 발생 | 0으로 나누기(divide by zero), 페이지 폴트(page fault) |
비동기 예외(Asynchronous) | 외부 장치, 타이머, 시그널 등 실행 흐름과 무관하게 발생 | I/O 인터럽트, POSIX 시그널(SIGINT) |
비유:
- 동기 예외: “송곳으로 찔렀더니 바늘이 부러진 걸 즉시 알아차리는 상황”
- 비동기 예외: “전화벨이 울려서 전화기를 받아야 하는 상황”
8.1.1 예외 처리(Exception Handling)
예외 처리 과정
- 예외 발생 감지: 예외 상황이 발생하면 프로세서가 이를 감지합니다.
- 상태 저장: 현재 프로세스의 상태(레지스터 값, 프로그램 카운터 등)를 저장합니다.
- 예외 핸들러 호출: 예외에 해당하는 핸들러로 제어를 이동합니다.
- 예외 처리: 핸들러에서 예외를 처리합니다.
- 상태 복구 및 복귀: 저장된 상태를 복구하고 원래의 프로그램 흐름으로 복귀하거나 프로그램을 종료합니다.
예외의 종류
클래스 | 원인 예시 | 동기/비동기 | 복귀 방식 |
Interrupt | I/O 장치 신호(타이머, 네트워크, 키보드) | 비동기 | 항상 다음 명령어(Inext) |
Trap | 시스템 콜, 디버거 브레이크 포인트 | 동기 | 항상 다음 명령어(Inext) |
Fault | 페이지 폴트, 0으로 나누기, 산술 오버 플로우 | 동기 | 주로 현재 명령어 재실행(Icurr) |
Abort | 치명적 하드웨어 오류, 보호 위반 | 동기 | 복귀 없이 종료 |
예제(C 언어 : 시스템 콜 사용)
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork(); // 새로운 프로세스 생성
if (pid == 0) {
// 자식 프로세스
execlp("ls", "ls", NULL); // 새로운 프로그램 실행
} else {
// 부모 프로세스
wait(NULL); // 자식 프로세스가 종료될 때까지 대기
printf("자식 프로세스가 종료되었습니다.\n");
}
return 0;
}
예제(터미널 : 시그널 처리)
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void handler(int sig) {
printf("시그널 %d을(를) 받았습니다.\n", sig);
exit(0);
}
int main() {
signal(SIGINT, handler); // Ctrl+C 시그널 처리
while (1) {
printf("프로그램 실행 중...\n");
sleep(1);
}
return 0;
}
8.2 프로세스
간단히 말해서, 프로세스는 실행 중인 프로그램입니다. 우리가 터미널이나 명령어 창에서 어떤 프로그램을 실행하면, 운영체제는 이 프로그램을 실행하기 위한 프로세스(=작업)를 생성합니다.
프로세스의 특징
- 논리적인 제어 흐름 (Logical Control Flow): 마치 프로그램이 한 줄씩 순서대로 혼자 실행되는 것처럼 보임
- 사적 주소 공간 (Private Address Space): 마치 프로그램이 메모리를 혼자 사용하는 것처럼 보임
8.2.1 논리적인 제어 흐름(Logical Control Flow)
컴퓨터에는 여러 프로그램이 동시에 실행되고 있지만, 사용자는 각 프로그램이 "자기 혼자 프로세서를 쓰는 것처럼" 느낍니다. 이를 논리적 흐름이라 합니다.
예: 디버거로 코드를 디버깅할 때, 각 줄의 명령어에 프로그램 카운터(PC) 값이 하나씩 연결되어 있는 걸 볼 수 있죠. 이 PC 값들의 순서를 "논리적 제어 흐름"이라고 부릅니다.
사실 하나의 프로세서(CPU)는 매우 빠른 속도로 여러 프로세스를 번갈아 실행합니다. 이 때문에 우리는 마치 모든 프로그램이 동시에 실행되는 것처럼 느낍니다.
8.2.2 동시성 흐름(Concurrent Flow)
동시성(Concurrency)
두 개 이상의 논리 흐름이 동시에 실행되는 것처럼 보이는 현상입니다. 이는 실제로 한 개의 CPU에서 번갈아 돌아가거나, 다중 코어 CPU에서 진짜 동시에 돌아갈 수도 있습니다.
- 동시성: 시간상으로 겹치는 두 흐름
- 병렬성: 실제로 동시에 실행되는 두 흐름 (다중 코어 이상)
동시성 vs 병렬성 표 비교
용어 | 설명 |
동시성(Concurrency) | 여러 흐름이 시간상 겹처 보이는 현상 |
병렬성(Parellelism) | 다중 코어에서 실제 동시에 실행되는 현상 |
관련 개념들
- 멀티태스킹 (Multitasking): 여러 프로세스를 빠르게 교체하며 실행함 (Time Slicing)
- 타임 슬라이스 (Time Slice): 하나의 프로세스가 CPU를 할당받는 짧은 시간 단위
8.2.3 사적 주소 공간(Private Address Space)
각 프로세스는 자신만의 메모리 공간을 가진 것처럼 보입니다. 실제로는 운영체제가 각각의 프로그램에 고립된 메모리 공간을 할당해 주는 것이죠.
예: 두 개의 메모장 프로그램을 동시에 실행해도 서로 영향을 주지 않는 이유입니다.
이 구조 덕분에 프로그램들이 서로 침범하지 않고 안전하게 실행될 수 있습니다. 구조는 동일하지만, 내용은 각각 다릅니다.
8.2.4 사용자 모드와 커널 모드(User Mode & Kernel Mode)
운영체제는 보안과 안정성을 위해 두 가지 모드로 구분합니다:
모드 | 설명 | 접근 권한 |
사용자 모드 | 일반 프로그램이 실행되는 모드 | 제한적(시스템 자원 접근 불가) |
커널 모드 | 운영체제가 실행되는 모드 | 모든 명령어 실행 가능 |
- 사용자 모드에서는 직접적으로 하드웨어 제어나 중요한 명령을 실행할 수 없습니다.
- 대신, **시스템 콜(system call)**을 통해 운영체제에 도움을 요청합니다.
모드 전환
- 예외나 인터럽트가 발생하면 → 사용자 모드 → 커널 모드 진입
- 예외 처리 끝나면 → 다시 사용자 모드 복귀
8.2.5 문맥 전환(Context Switch)
문맥(Context)이란?
프로세스가 실행되기 위해 필요한 정보의 집합입니다.
- 프로그램 카운터 (어디까지 실행했는지)
- 레지스터 값들
- 사용자 스택, 커널 스택 등
문맥 전환(Context Switch)이란?
운영체제가 한 프로세스에서 다른 프로세스로 전환할 때, 현재 프로세스의 상태를 저장하고, 다음 프로세스의 상태를 복구하는 과정입니다.
- 운영체제는 스케줄러라는 프로그램으로 어떤 프로세스를 실행할지 결정합니다.
- 전환 시기는 보통 타이머 인터럽트에 의해 정해집니다. (예: 1ms, 10ms 주기)
- 시스템 콜 실행 중 이벤트를 기다려야 하면, 다른 프로세스로 전환되기도 합니다.
문맥 전환 과정 요약
[레지스터·PC 저장] → [스케줄러] → [다음 프로세스 PCB 로드] → [실행 재개]
예시
사용자가 파일을 여는 프로그램을 실행 중 → 파일 입출력을 기다리는 동안 멈춤 → CPU는 다른 프로그램으로 전환하여 효율적으로 사용
8.3 시스템 콜의 에러 처리(System Call Error Handling)
시스템 콜이란?
시스템 콜(System Call)은 사용자 프로그램이 운영체제의 커널 기능을 사용할 수 있도록 해주는 인터페이스입니다. 예를 들어:
- 파일을 열거나 읽고 쓸 때
- 새로운 프로세스를 생성할 때
- 프로그램을 종료할 때
이러한 작업들은 모두 시스템 콜을 통해 이루어집니다.
에러 처리가 중요한 이유
시스템 콜을 사용할 때는 항상 에러 처리를 꼼꼼히 해야 합니다. 이유는 다음과 같습니다:
- 시스템 자원이 부족하거나
- 잘못된 인자를 전달하거나
- 권한이 없을 경우
이러한 상황에서는 시스템 콜이 실패할 수 있습니다. 실패 시 적절한 에러 처리를 하지 않으면 프로그램이 예기치 않게 종료되거나, 보안 취약점이 생길 수 있습니다.
에러 처리 방법
Unix 스타일 에러 처리
Unix 시스템에서는 시스템 콜이 실패할 경우 -1을 반환하고, 전역 변수 errno에 에러 코드를 설정합니다. 이를 확인하여 에러를 처리합니다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
int main() {
pid_t pid;
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(1);
}
// 자식 프로세스와 부모 프로세스의 처리
return 0;
}
이 방식은 매번 에러 처리를 위한 코드를 작성해야 하므로 코드가 길어질 수 있습니다.
에러 처리 함수로 코드 간결화
반복되는 에러 처리 코드를 함수로 만들어 사용하면 코드가 훨씬 간결해집니다.
void unix_error(char *msg) {
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(1);
}
이제 시스템 콜 후 에러 처리는 다음과 같이 간단하게 할 수 있습니다:
if ((pid = fork()) < 0)
unix_error("fork error");
에러 처리 래퍼 함수
더 나아가, 시스템 콜 자체를 래퍼 함수로 만들어 사용하면 에러 처리와 시스템 콜 호출을 한 번에 할 수 있습니다.
pid_t Fork(void) {
pid_t pid;
if ((pid = fork()) < 0)
unix_error("Fork error");
return pid;
}
사용 예:
pid_t pid = Fork();
이렇게 하면 fork() 호출과 에러 처리를 동시에 할 수 있어 코드가 더욱 깔끔해집니다.
요약
- 시스템 콜 사용 시 항상 에러 처리를 해야 합니다.
- 반복되는 에러 처리는 함수나 래퍼로 만들어 코드의 가독성을 높일 수 있습니다.
- 다양한 에러 처리 스타일을 이해하고 상황에 맞게 사용하는 것이 중요합니다.
'CSAPP > 8장 예외적인 제어흐름' 카테고리의 다른 글
시스템 콜(System Call) (0) | 2025.05.01 |
---|---|
[CSAPP] 8 예외적인 제어 흐름(Exceptional Control Flow) 8.4 ~ 8.8 (1) | 2025.04.21 |