예외적인 제어흐름(Exceptional Control Flow)
8.1 예외상황
- 하드웨어와 운영체제 의해서 구현된 예외적인 제어흐름의 한 가지의 형태
- 어떤 프로세서 상태 변화(이벤트)에 대한 대응
특징
- 프로세서가 이벤트가 발생했다는 것을 감지하면, '예외 테이블'이라고 하는 점프 테이블을 통해서 이 특정 종류의 이벤트를 처리하기 위해 예외처리 핸들러로 간접 프로시저 콜을 하게 됨
- 예외 테이블 : ISR(Interrupt Service Routine)의 집합 -> 예외 내용(정보)를 담는 테이블
- 간접 프로시저 콜 : 직접 함수의 주소를 사용하지 않고, 포인터를 통해 호출함(객체의 실제 타입을 알지 못해도 올바른 함수를 호출할 수 있음 -> 다형성)
예외처리 종료 후 상황
- 제어를 현재 인스트럭션(예외 발생)으로 돌려줌
- 제어를 다음 인스트럭션(예외 발생 다음)으로 돌려줌 -> 예외 상황이 발생하지 않았더라면 실행될 다음 명령
- 중단된 프로그램을 종료
8.1.1 예외처리
특징
- 예외상황의 종류마다 중복되지 않는 양의 정수를 예외번호로 할당
- 예외번호는 예외 테이블에서 인덱스이며, 예외 테이블 시작주소는 '예외 테이블 베이스 레지스터'라는 특별한 CPU 레지스터에 저장
- 프로세서 설계자가 부여하거나 운영체제 커널 설계자가 할당함
- 시스템 부팅 시 예외 테이블(점프 테이블)은 매번 초기화해서 예외상황에 대한 핸들러의 주소를 새롭게 할당받음
- 에러가 발생하면 현재 에러가 발생한 명령어 리턴주소 or 다음 명령어 리턴주소를 스택에 푸시함(에러 처리 후 복귀하기 위해서)
- 프로세서의 상태(Flag)를 포함하는 EFLAGS 레지스터를 스택에 푸시함
- 예외 핸들러는 커널모드에서 돌아감(모든 시스템 자원에 완전히 접근 가능)
8.1.2 예외의 종류
예외상황은 4가지 종류로 구분할 수 있음
내부에서 발생(동기적) : 트랩(Trap), 오류(Fault), 중단(Abort)
외부에서 발생(비동기적) : 인터럽트(Interrupt)
비동기적(Asynchronous)
인터럽트(Interrupt)
프로세서 외부에 있는 입출력 디바이스로부터의 시그널의 결과로 비동기적으로 발생
(ex. 키보드, 마우스 입력)
- 비동기적 : 하드웨어가 직접 제어하는 것이 아님(외부 입력장치), 시간을 예상할 수 없음
- 하드웨어 인터럽트를 위한 예외 핸들러를 인터럽트 핸들러라고 부름
발생 과정
- 입출력 디바이스들은 프로세서 칩의 핀에 시그널을 보내서 인터럽트를 발생시키고, 예외번호를 시스템 버스에 보냄
(프로세서 칩의 핀 : 외부와 연결되어 전원을 공급받고, 데이터와 제어신호를 주고 받음) - 현재의 명령을 실행 완료한 후에, 프로세서는 인터럽트 핀이 high로 올라간 것을 발견하고 시스템 버스에서 예외번호를 읽음
- 적절한 인터럽트 핸들러를 호출하고, 핸들러가 리턴할 때 제어를 다음 명령으로 돌려줌
- 그 결과 프로그램이 인터럽트가 발생하지 않았던 것처럼 실행되도록 함
동기적(Synchronous)
트랩(Trap)
의도적인 예외상황으로, 어떤 명령을 실행한 결과로 발생 / 내부에서 발생(동기적으로 발생)
트랩 핸들러는 제어를 다음 인스트럭션으로 리턴함
(ex. 시스템 콜, 디버깅)
발생 과정
- syscall(시스템 콜)명령을 실행하면 트랩이 인자들을 해독하고 적절한 커널 루틴을 호출하는 예외 핸들러로 가게함
- 시스템 콜은 커널 모드에서 돌아가기 때문에 커널 내의 스택에 접근하며, 권한이 필요한 명령을 실행할 수 있음
오류(Fault)
핸들러가 복구할 수 있는 가능성이 있는 에러일 경우 발생(무조건 복구가 되는 건 아님)
발생 과정
- 에러를 복구할 수 있다면 오류를 발생시킨 명령으로 돌아가서 다시 재실행함
- 복구가 불가하다면 커널 내부의 Abort 루틴으로 리턴해서 응용 프로그램을 종료시킴
중단(Abort)
패리티 에러(Parity Error)와 하드웨어 같은 복구할 수 없는 치명적인 에러(Fatal Error)에서 발생
특징
- 중단 핸들러는 절대로 응용 프로그램으로 제어권을 넘겨주지 않음(보안상 위험)
- 제어를 응용 프로그램을 종료하는 중단 루틴으로 넘김
8.1.3 리눅스/x86-64 시스템에서의 예외상황
리눅스/x86-64 오류와 중단
- 나누기 에러(divide by zero) : 응용 프로그램이 0으로 나누는 연산을 할 때 / 나눗셈 명령의 결과가 목적지 오퍼랜드에 비해 너무 큰 경우(리눅스 쉘은 '부동소수 예외')
- 일반 보호 오류(General Protection Fault) : 보통 가상 메모리의 정의되지 않은 영역을 참조하거나, read-only 텍스트 세그먼트에 쓰려고 하는 경우 -> 세그멘테이션 오류 발생(Segmentation fault)
- 페이지 오류
- 머신 체크 : 오류 명령을 실행하는 동안 검출된 치명적인 에러의 결과로 발생 -> 제어권을 절대 넘겨주지 않음(보안이 위험)
리눅스/x86-64 시스템 콜
- 각 시스템 콜은 커널 점프 테이블의 오프셋에 대응되는 유일한 정수를 가짐(예외 테이블과는 다름)
- C에서는 시스템 콜에 대해서 래퍼(wrapper) 함수를 제공
- 래퍼 함수 : 하나 이상의 함수나 프로세스를 감싸고, 새로운 인터페이스나 기능을 제공하는 함수
- 시스템 수준 함수 : 운영체제 수준에서 동작하는 함수
- -4,095부터 -1 사이의 음수 리턴 값은 errono(에러 번호)에 대응하는 에러를 나타냄
8.5 시그널(Signal)
작은 메시지 형태로, 프로세스에게 시스템 내에 어떤 종류의 이벤트가 일어났다는 것을 알림
시그널 큐는 일반적으로 프로세스 제어 블록(PCB)에 위치함
8.5.1 시그널 용어
목적지 프로세스로 전달하는 것은 두 단계로 이루어짐
- 시그널 보내기
- 커널이 0으로 나누기나 자식 프로세스 종료 등 시스템 이벤트를 감지
- 커널에게 명시적으로 시그널을 보냄(시스템 콜)
- 시그널 받기
- 목적지 프로세스는 시그널을 받아서 시그널 핸들러라고 부르는 사용자 수준 함수를 실행함
- 시그널을 무시하거나, 종료하거나, 획득할 수 있음
펜딩 시그널(Pending Signal)
보내졌지만 아직 받지 않은 시그널
- 특정 타입에 대해 최대 한 개의 펜딩 시그널만 존재할 수 있음(그 이상은 큐에 담기지 않고 버려짐)
- 프로세스는 선택적으로 시그널의 수신을 블록할 수 있음(해당 시그널을 의도적으로 받지 않도록 설정)
- 시그널이 블록될 때 시그널 큐에는 담길 수는 있기 때문에, 블록이 해제되면 프로세스가 받을 수 있음
- 커널은 펜딩 비트 벡터(시그널 마스크) 내에 대기중인 시그널의 집합을 관리하며, 블록된 시그널 집합을 관리함
- 커널은 시그널 큐에 대기중이던 시그널을 보내고 프로세스에서 받을 때마다 펜딩의 비트를 0으로 만듬
(각각의 하나의 비트(1)를 갖고 있다가, 수신될 때마다 0으로 변경)
8.5.2 시그널 보내기
Unix 시스템은 시그널을 프로세스로 보내는 여러 가지 메커니즘을 제공
모든 메커니즘은 프로세스 그룹 개념을 사용
- 모든 프로세스는 정확히 한 개의 프로세스 그룹에 속하며, 양수의 Process ID로 식별(PID)
- getpgrp : 현재 프로세스의 프로세스 그룹 ID를 리턴
- setpgrp : 프로세스 그룹을 변경할 수 있음
- 기본적으로 자식 프로세스는 자신의 부모와 동일한 프로세스 그룹에 속함
시그널을 /bin/kill 프로그램을 사용해서 보내기
linux> /bin/kill -9 15213
- -9 : 시그널 9번(SIGKILL)을 나타냄
- 15213이 양수이기 때문에 '15213' 프로세스에 시그널 9번을 보냄(특정 프로세스에게만 보냄)
linux> /bin/kill -9 -15213
- -15213이 음수이기 때문에 |15213|(절대값) '15213' 프로세스 그룹 내의 모든 프로세스에게 시그널 9번을 보냄
키보드에서 시그널 보내기
쉘(Shell)은 작업의 추상화를 사용해 한개의 명령줄을 해석해 결과를 프로세스에 반영
- 파이프(|) : 파이프는 한 명령의 출력을 다른 명령의 입력으로 연결하는데 사용 / 여러 명령을 조합하여 작업을 수행
- 리다이렉션(>, <, >>) : 명령의 입출력을 파일로 이동시키는데 사용됨
- > : 출력을 파일에 덮어씀(파일이 이미 존재하면, 새로운 내용으로 덮어씀)
- >> : 출력을 파일에 추가함(파일에 내용을 뒤에 추가함)
- < : 파일의 내용을 명령의 입력으로 사용(파일의 내용을 읽어서 명령어에게 처리하게 함)
kill 함수로 시그널 보내기
프로세스는 kill 함수를 호출해서 시그널을 다른 프로세스로 보냄(자기 자신에게도 가능)
다른 프로세스에게 signal을 보내기 위해서 적합한 권한을 가지고 있어야 함
(위의 예시 확인해보기)
int kill(pid_t pid, int sig);
- pid가 0보다 큰 경우(양수) : 특정 프로세스(pid)에게만 signal을 보냄
- pid가 0인 경우 : 현재 pid가 속한 프로세스 그룹 내의 모든 프로세스에 signal을 보냄
- pid가 0보다 작은 경우(음수) : |pid|(절대값) 프로세스 그룹 내의 모든 프로세스에 signal을 보냄
alarm 함수로 시그널 보내기
커널이 sec초마다 프로세스로 SIGALRM 시그널을 보내도록 함
만일 sec를 0으로 세팅하면 시그널은 발생하지 않음
- 어떤 이벤트가 발생하면 alarm은 남은 시간을 초 단위 숫자로 반환하거나, 대기하고 있는 알람이 없었다면 0을 반환
8.5.3 시그널의 수신
커널 모드에서 사용자 모드로 전환할 때(예외 처리 후) 커널은 프로세스에 블록되지 않은 시그널 큐를 확인
시스템 콜을 종료하고 나서 시그널 큐를 확인함
- 시그널 큐가 비어있다면 다음 명령을 실행
- 시그널 큐가 비어있지 않다면 프로세스가 시그널을 받아서 처리하도록 함(보통 가장 낮은 시그널을 보냄) / 시그널을 처리하고 나서는 다음 명령을 실행
- 일부 시스템에서 시스템 콜들이 에러가 발생하면 즉시 반환함
기본 동작
프로세스는 시그널과 연결된 기본 동작을 signal 함수를 사용해 수정할 수 있음
- SIGKILL, SIGCHLD 함수를 제외한 모든 시그널은 수정할 수 있음
8.5.4 시그널 블록하기와 블록 해제하기
리눅스는 시그널을 블록하기 위해 묵시적이고 명시적인 방법을 제공
- 묵시적 블록 방법
- 현재 핸들러가 처리중인 것과 동일한 시그널이 pending 중이면 그 시그널은 시그널 큐에 들어가지 못함(커널이 막음)
- 처리중인 시그널은 시그널 큐에서 블록상태로 만들어놓음
- 명시적 블록 방법
- sigprocmask 함수를 이용해 블록하거나 블록일 해제할 수 있음(블로킹한 시그널들을 set으로 취급)
- 아예 시그널을 무시하는 것이 아니라 블로킹해놓은 시그널들을 해제하는 순간 시그널 큐에 들어감
sigset_t mask, prev_mask;
Sigemptyset(&mask);
Sigaddset(&mask, SIGINT);
/* Block SIGINT and save previous blocked set */
Sigprocmask(SIG_BLOCK, &mask, &prev_mask);
// Code region that will not be interrupted by SIGINT
/* Restore previous blocked set, unblocking SIGINT */
Sigprocmask(SIG_SETMASK, &prev_mask, NULL);
8.5.5 시그널 핸들러 작성하기
- 핸들는 메인 프로그램과 동시적으로 돌아가고, 같은 전역변수를 공유하기 때문에, 메인 프로그램과 다른 핸들러들과 섞일 수 있음
- 어떻게 언제 시그널들이 수신될 수 있는지 종종 직관적이지 않음(비동기적으로 발생하는 경우 : 키보드 입력 등..)
안전하게 시그널을 처리하는 방법
- 핸들러는 가능한 간단하게 유지하라
- 핸들러에서 비동기성-시그널-안전한 함수만 호출하라
- 시스템 수준 함수 or SIO(Safe I/O) 패키지 등을 사용
- errono를 저장하고 복원하라
- 핸들러에 진입할 때 지역벽수에 에러번호(errono)을 저장하고, 핸들러가 리턴하기 전에 복원
- 모든 시그널을 블록시켜서 공유된 전역 자료구조들의 접근을 보호하라
- 모든 시그널을 블록시켜서 명령 실행동안 중단되지 않게함(일관성)
- 전역변수들을 volatile로 선언하라
- 해당 변수가 예상치 못하게 변할 수 있기 때문에 변수의 값을 메모리에 저장할 것을 컴파일러에게 요청
- sig_atomic_t로 플래그들을 선언하라
- 원자형(atomic : 중단불가)을 보장하기 위해서 하나의 인스트럭션에서만 적용될 수 있음
- 읽기 작업은 읽기만, 쓰기 작업은 쓰기만
- 중단이 불가하므로 예상치 못하게 변경될 문제가 없음
- 레지스터 이용 중에 중간에 레지스터 오염이 불가능하게 함
호환성 있는 시그널 핸들링
서로 다른 시스템들이 서로 다른 시그널 처리방식을 가짐
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);
- sigaction 함수는 사용자 복잡한 구조의 항목들을 설정하도록 허용하기 때문에 불편
- Signal 래퍼 함수를 정의해 시그널 핸들러로 사용
handler_t *Signal(int signum, handler_t *handler) // 랩퍼 함수를 사용
{
struct sigaction action, old_action; // Singal 구조체 도입
action.sa_handler = handler; // 핸들러 등록
sigemptyset(&action.sa_mask); // 블로킹을 위한 emptyset 설정
action.sa_flags = SA_RESTART; // Abort하지 않고 재시작하도록 설정
if (sigaction(signum, &action, &old_action) < 0) // old_action 대신 action을 하도록 함
unix_error("Signal error");
return (old_action.sa_handler);
}
8.5.6 치명적인 동시성 버그를 피하기 위해서 흐름을 동기화하기
작업들 사이에 '대화'를 만들어서 올바른 순서로 작업이 일어나도록 해야 함
이런 대화를 만들어서 문제를 피할 수 있게 되면, 소프트웨어가 정확하고 안정적으로 작동할 수 있음
경주(race)
두개 이상의 프로세스나 쓰레드가 공유된 자원에 동시에 접근하려고 할 때 발생
8.5.7 명시적으로 시그널 대기하기
pause와 sleep을 통해 명시적으로 시그널을 명시적으로 대기시킬 수 있음
두 개보다 더 좋은 방법은 sigsuspend임
pause
- 시그널이 발생할 때까지 대기
- 비동기적인 이벤트 처리시에 사용
sleep
- 지정된 시간동안 대기상태로 만듬
- 시간이 종료되면 대기상태에서 해제
- nanosleep과 같은 정밀도가 높은 함수도 있음
- 길이를 결정하는 좋은 규칙이 없음
sigsuspend
- 현재 블록된 집합들을 일시적으로 mask로 교체
- 프로세스를 실행시키거나, 프로세스를 종료시키는 시그널이 오기 전까지 프로세스를 유예함
- 시그널 동작(실행) : 핸들러를 작동시키고 종료 후 호출했을 때의 상태로 복원
- 시그널 동작(종료) : 리턴하지 않고 종료
- 동시에 시그널을 처리하는 동안 다른 시그널을 무시하는 것이 보장됨
'크래프톤 정글 - TIL' 카테고리의 다른 글
크래프톤 정글 5기 TIL - Day 42(키워드 정리) (1) | 2024.05.01 |
---|---|
크래프톤 정글 5기 TIL - Day 39 ~ 41 (0) | 2024.04.29 |
크래프톤 정글 5기 TIL - Day 34(CS:APP) (0) | 2024.04.23 |
크래프톤 정글 5기 TIL - Day 33(RB Tree 코드 구현) (0) | 2024.04.23 |
크래프톤 정글 5기 TIL - Day 32 (0) | 2024.04.22 |