-
[정글 WEEK06] 컴퓨터 시스템 - 8.1 예외 상황, 8.5 시그널카테고리 없음 2025. 4. 21. 16:07
8.1 예외 상황
🔗 키워드: ECF(Exceptional Control Flow) 제어 흐름의 갑작스런 변화
"어플리케이션-운영체제 간의 상호작용은 모두 ECF를 중심으로 돌아간다!"
8.1.1 예외 처리
예외란,
프로세서 상태의 변화(event)로 인해 발생하는 제어 흐름의 갑작스러운 변화이다.
예외가 발생하면 운영체제의 예외 핸들러(Exception Handler)로 제어권이 넘어간다.
예외 처리 흐름
1. 현재 명령어 실행 중 Event 발생
2. 프로세서는 예외 번호를 기반으로 예외 테이블(Exception Table) 조회
3. 해당 예외 번호의 예외 핸들러로 제어 흐름 이동
4. 예외 핸들러 실행 후 다음 중 하나 예외 케이스에 따라 아래 중 하나 실행
1) 현재 명령어 재실행
: 이벤트 발생 시 실행되고 있던 인스트럭션(I_curr)에게 제어권이 돌아간다.
2) 다음 명령어로 이동
: 예외 상황이 발생하지 않았더라면 다음에 실행될 예정이었던 인스트럭션(I_next)에게 제어권이 돌아간다.
3) 프로그램 종료
* 예외 테이블은 시스템 부팅 시 커널이 초기화하며, 각 예외 번호에 대응하는 인덱스를 통해 핸들러 주소로 점프한다.
예외는 고정된 번호를 가지고 있고, 예외 테이블 엔트리는 보통 8byte이다.
따라서 'Exception number * 예외 테이블 엔트리 크기'로 원하는 예외 핸들러의 디스크립터 주소를 계산할 수 있고, CPU는 이렇게 찾아낸 디스크립터를 통해 핸들러 코드의 세그먼트 셀렉터와 오프셋을 추출해 예외 핸들러의 주소로 점프한다.예외 상황과 일반 프로시저 호출의 차이점
구분 예외(Exception) 프로시저 호출(Procedure Call) 제어 흐름 발생 시점 예기지 않은 이벤트 발생 시 call 명령 등 명시적인 호출 제어 흐름 대상 예외 테이블을 참조한 예외 핸들러의 주소 함수 정의 위치 주소 상태 저장 방식 CPU 상태 + 사용자/커널 스택 일반적인 사용자 스택만 사용 실행 권한 전환 여부 사용자 모드 → 커널 모드 전환 가능 사용자 모드 내에서만 동작 8.1.2 예외의 종류
클래스 원인 비동기/동기 처리 제어 흐름 복귀 위치 Interrupt 외부 장치의 신호 비동기 다음 명령어로 복귀 Trap 시스템 콜 등 의도적 예외 동기 다음 명령어로 복귀 Fault 복구 가능한 오류 동기 현재 명령어 재실행 또는 프로그램 종료 Abort 복구 불가능한 오류 동기 복귀하지 않고 프로그램 종료 1) 인터럽트(Interrupt)
: 입출력 디바이스의 시그널로 인해 발생
인터럽트는 특정 인스트럭션을 실행해서 발생한 것이 아니기 때문에 비동기적으로 발생한다.
인터럽트 핸들러는 응용프로그램의 제어흐름에서 다음 인스트럭션으로 제어권을 넘긴다.
2) 트랩(Trap)
: 시스템 콜 등 의도적인 예외로 인해 발생
대표적인 예시: 시스템 콜
유저 프로그램은 시스템 콜을 통해 운영체제의 서비스에 접근한다.
시스템 콜은 사용자 모드 → 커널 모드로의 전환을 유도하는 특수한 명령어(syscall)를 통해 실행된다.
트랩 핸들러는 응용프로그램의 제어흐름에서 다음 인스트럭션으로 제어권을 넘긴다.
3) 오류(Fault)
: 정정 가능한 에러를 유발
대표적인 예시: PageFault
페이지 폴트는 접근하려는 페이지가 메모리에 없는 경우 발생하며, 커널은 디스크에서 해당 페이지를 로딩한 뒤 해당 명령어를 다시 실행한다.
오류 핸들러는 오류를 발생시킨 인스트럭션을 재실행하거나 중단한다.
❗️Fault는 Error와 다르다
Fault: 시스템 내부에 존재하는 잠재적인 문제의 원인, 실수나 오류 상태 그 자체
Error: Fault에 의해 유발된 시스템의 잘못된 결과나 비정상적인 상태
Failure: Error가 외부에 드러나 시스템이 기대한 동작을 못 한 경우
➡️ Fault가 Error를, Error가 Failure를 유발한다. (Fault → Error → Failure)
Fault에서 Abort된 상황이 Error다! 즉, Fault에서 Error로 넘어갔다면 I_curr로 되돌아갈 수 없다.예시)int divide(int a, int b) { return a / b; }
- Fault: 이 함수에는 b == 0일 때를 고려하지 않음- Error: 사용자가 divide(10, 0)을 호출함 → 런타임에서 Division by Zero 예외 발생- Failure: 프로그램이 강제 종료됨 → 사용자에게 기능이 제공되지 못함4) 중단(Abort)
: 복구할 수 없는 치명적인 에러에서 발생
DRAM이나 SRAM이 고장날 때와 같이 하드웨어 오류로 인해 발생하며, 복구가 불가능하기 때문에 커널은 프로그램을 즉시 종료한다.
8.1.3 리눅스/x86-64 시스템에서의 예외상황
리눅스/x86-64 오류(Fault)와 중단(Abort)
예외 번호 이름 설명 클래스 0 나누기 에러(Division Error) 특정 값을 0으로 나눴을 때 발생 Fault 13 일반 보호 오류
(General protection Fault)접근 불가 영역 참조 등 Fault 14 페이지 오류(Page Fault) 메인메모리에 데이터 없는 경우 Disk I/O 필요한 경우 Fault 18 머신 체크(Machine Check) 하드웨어 오류 Abort 32~255 OS 정의 예외 I/O 인터럽트, 시스템 콜 등 Trap/Interrupt 리눅스/x86-64 주요시스템 콜
번호 이름 설명 번호 이름 설명 0 read 파일 읽기 33 pause 시그널이 올 때까지 프로세스 일시 중단 1 write 파일 쓰기 37 alarm 알람 시그널 예약 설정 2 open 파일 열기 39 getpid 프로세스 ID 얻기 3 close 파일 닫기 57 fork 프로세스 생성 4 stat 파일 정보 확인 59 execve 프로그램 실행 9 mmap 메모리 페이지를 파일에 매핑 60 _exit 프로세스 종료 12 brk 힙의 최상단 데이터 리셋 61 wait4 다른 프로세스 종료 대기 32 dup2 파일 디스크립터 복사 62 kill 프로세스에 시그널 보내기 8.5 시그널
8.5.1 시그널 용어
시그널이란, 커널이나 프로세스가 프로세스에게 특정 이벤트가 발생했음을 알리는 메세지이다.
프로세스는 시그널을 받고 다음 네 가지 반응 중 하나를 수행한다.
1) 종료
2) 종료 & 코어 덤프
3) 정지(지연)
4) 시그널 무시
* 커널이 보냈는데 프로세스가 아직 받지 않은 시그널은 펜딩 시그널(pending signal)이라고 한다.
동일한 타입의 펜딩 시그널이 다시 발생하면 해당 시그널은 버려진다. 동일한 신호는 한 번에 하나만 pending될 수 있고, 이미 pending 중이면 추가 신호는 버려지며, 신호가 차단된 경우에는 차단이 해제될 때까지 수신 자체가 지연된다.
시그널 이름 시그널 번호(타입) 설명 SIGINT 2 인터럽트 SIGTERM 15 종료 요청 SIGKILL 9 강제 종료 SIGALRM 14 알람 시그널 SIGUSR1 10 사용자 정의 시그널 1 8.5.2 시그널 보내기
모든 시그널 송신 메커니즘은 프로세스 그룹을 통해 시그널을 보낸다.
모든 프로세스는 정확히 하나의 프로세스 그룹에 속하기 때문이다.
전달 매체
1) /bin/kill
2) 키보드 - 사용자 입력
3) kill()
4) alarm()
8.5.3 시그널 받기
수신 시점: 커널이 프로세스 p를 커널모드에서 사용자모드로 전환할 때
→ 커널이 p에 대한 블록되지 않은 펜딩 시그널의 집합을 체크
해당 집합이 비어있다면 커널은 제어를 다음 인스트럭션(I_next)으로 전달한다.
만약 해당 집합이 비어있지 않다면 커널은 집합 내 특정 시그널 k를 선택해 p가 k를 수신하도록 한다. k를 수신한 프로세스가 k에 반응하는
동작을 완료하면 제어는 다음 인스트럭션(I_next)로 돌아간다.
k의 타입은 다음 네 가지 중 하나이다.
1) 프로세스 종료
2) 프로세스 종료 및 코어 덤프
3) SIGCONT에 의해 재시작될 때까지 정지
4) 시그널 무시
신호를 받으면 해당 신호에 등록된 핸들러가 실행되며, 핸들러는 다른 신호로 중단될 수 있고, 종료 후에는 중단된 지점으로 제어가 돌아간다.
e.g)
Ctrl+C 입력을 통한 Interrupt
↓
[1] SIGINT 시그널 발생
↓
[2] 커널 모드에서 시그널 수신 판단 (커널 → 사용자 모드 복귀 시점)
→ pending & ~blocked pending signal 집합에 SIGINT 존재 확인
↓
[3] signal()이나 sigaction()으로 등록된 핸들러 확인
→ 있으면 핸들러 실행, 없으면 default (프로세스 종료)
↓
[4] 유저 모드에서 핸들러 진입
→ 핸들러 종료 시 사용자 코드 복귀
📎 참고: 디폴트 동작은?예를 들어 SIGSEGV, SIGKILL처럼 사용자 핸들러가 없고 디폴트 동작이 "kill"인 경우:
- 커널이 직접 프로세스 종료 (do_exit() 등 호출)
- 사용자 모드로 가지 않고 종료되므로 핸들러 없음 = 사용자 코드 실행 없이 커널에서 처리 종료
8.5.4 시그널 블록하기와 블록 해제하기
1) 묵시적 블록 방식
커널은 이미 핸들러에 의해 처리되고 있는 유형의 모든 pending signal들의 처리를 block
2) 명시적 블록 방식
sigprocmask()와 이에 대한 helper 함수 이용
sigprocmask 함수는 현재 블록된 시그널의 집합 변경
sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
- how 종류
- SIG_BLOCK: set에 있는 시그널들을 blocked에 추가
- SIG_UNBLOCK: set에 있는 시그널들을 blocked에서 제거
- SIG_SETMASK: 기존 blocked 집합과 상관 없이 set에 있는 시그널들만 blocked로 변경
8.5.5 시그널 핸들러 작성하기
안전한 시그널 핸들러 작성 수칙
1) 핸들러는 간단하게 유지하라
예외상황은 비동기적으로 끼어드니 핸들러 로직이 길면 예측 불가능성이 커짐
2) 핸들러에서 비동기성 시그널과 안전한 함수만 호출하라
재진입 불가한 함수 호출 시 데이터 훼손 위험 있음
3) errorno를 저장하고 복원하라
핸들러가 원래 코드의 에러 상태를 망가뜨릴 수 있음
4) 모든 시그널을 블록시켜서 공유된 전역 자료구조들로부터의 접근을 보호하라
중첩된 핸들러 실행 시 전역 데이터 일관성이 깨짐
5) 전역변수들을 volatile로 선언해서 메모리에 접근하게 하라
컴파일러 최적화로 변수값이 캐싱되면 최신 값을 못 읽는다.
6) sig_atomic_t 타입으로 플래그들을 선언하라
일반 타입은 읽기/쓰기 연산이 atomic하지 않다.
정확한 시그널 핸들링
동일한 타입의 시그널은 큐잉되지 않기 때문에 한 번의 시그널 핸들러 실행에서 관련 이벤트(예: 종료된 자식 프로세스)를 모두 처리해야 한다.
8.5.6 치명적인 동시성 버그를 피하기 위해서 흐름을 동기화하기
자식은 부모의 block 상태도 상속받기 때문에 자식이 시그널을 받기 위해서는 unblock 처리를 따로 해주는 등 동기화를 위해 sigprocmask()와 같은 시그널 블로킹 함수를 적절히 활용해야 한다.
8.5.7 명시적으로 시그널 대기하기
: 시그널 핸들러가 실행되기를 기다리는 안전하고 효율적인 방법
sigsuspend()는 시그널 핸들러가 실행되기를 안전하게 기다릴 수 있는 경량 블로킹 대기 함수로, race condition 없이 효율적인 시그널 처리 흐름을 보장할 수 있다.
⚡️ 실습: 원자적인 프로그램 설계로 race condition 피하기
아래 pause()는 sleep(1); 이라는 시그널로 인해서 아무것도 기다리지 않는다.
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> int main() { signal(SIGUSR1, sig_handler); sleep(1); // 시그널이 미리 도착하면 pause(); // 여기에서 아무것도 안 기다림 (SIGUSR1이 이미 왔으면 무한 대기) return 0; }
반면, sigprocmask()로 블로킹하면 sleep(1);이라는 시그널이 와도 핸들러가 실행되지 않아서, sigsuspend()에서는 race condition 없이 안전하게 블로킹 대기할 수 있다.
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> // 시그널 핸들러와 메인 코드가 공유할 안전한 전역 플래그 // sig_atomic_t: 시그널 핸들러에서 읽고 쓰기 안전한 정수형 타입 volatile sig_atomic_t flag = 0; // 사용자 정의 시그널 핸들러 void sig_handler(int sig) { flag = 1; // 시그널을 수신했다는 플래그 설정 } int main() { sigset_t mask, old_mask; // SIGUSR1 시그널에 대한 핸들러 등록 signal(SIGUSR1, sig_handler); // mask를 초기화하고 SIGUSR1 추가 sigemptyset(&mask); sigaddset(&mask, SIGUSR1); sigprocmask(SIG_BLOCK, &mask, &old_mask); // [1] SIGUSR1 시그널을 일시적으로 차단 // → 시그널이 도착해도 핸들러가 실행되지 않고 pending 상태로 대기 sleep(1); // [2] 시그널이 이 시점에 와도 핸들러는 실행되지 않음 (차단됨 → pending) sigsuspend(&old_mask); // [3] sigsuspend()를 사용해 race condition 없이 안전하게 대기 // - 현재 마스크를 old_mask로 바꾸어 SIGUSR1을 잠시 허용 // - 동시에 대기 상태로 들어감 (atomic하게) // - pending된 시그널이 있으면 즉시 깨어나고 핸들러 실행됨 return 0; // [4] sigsuspend()에서 깨어났다면 시그널을 처리한 후이며, // 시그널 마스크는 자동으로 이전 상태(old_mask)로 복구됨 }