
2025.04.23
오늘한 내용 : CS - 예외적인 제어 흐름 8.1,8.5
WEEK06: 메모리 누수, 균형 이진 탐색 트리(AVL Tree, Red-Black Tree)
CPU가 명령어를 순차적으로 실행하면서 PC(Program Counter)를 바꾸는 흐름
- 보통은
a0 → a1 → a2 ...순서대로 진행jmp,call,ret같은 명령이 들어오면 흐름이 바뀜
→ 이는 **정상적인(controlled) 흐름 변경**
내부 변수 상태 변화가 아닌, 시스템 상태 변화로 인해 비정상적으로 흐름이 바뀌는 현상
- 예: 타이머 인터럽트, 네트워크 패킷 도착, 디스크 완료, 자식 프로세스 종료 등
즉, "시스템 밖에서 발생한 일 때문에, 지금 실행 중인 코드 흐름이 갑자기 바뀌는 상황"
| 수준 | 예시 |
|---|---|
| 하드웨어 수준 | 예외(Exception), 인터럽트 (→ 커널 핸들러로 제어 이동) |
| OS 수준 | 프로세스 컨텍스트 스위칭 (Context Switch) |
| 응용 수준 | 시그널(Signals), longjmp() 같은 비표준 흐름 변경 |
k 탐지 → k를 통해 간접 프로시저 콜EFLAGS 레지스터 값도 스택에 저장| 항목 | 함수 호출 | 예외 |
|---|---|---|
| 실행 트리거 | 명시적 call | 이벤트 (내부 or 외부) |
| 복귀 주소 | 다음 명령어 | 현재 or 다음 명령어 |
| 상태 저장 | 일부 레지스터 | 추가 CPU 상태까지 저장 |
| 실행 컨텍스트 | 사용자 모드 | 커널 모드 |
| 클래스 | 원인 | 동기성 | 복귀 가능성 |
|---|---|---|---|
| Interrupt | 외부 I/O 장치 신호 (타이머, 키보드 등) | 비동기 | 항상 다음 명령어로 복귀 |
| Trap | 명시적 요청 (ex. 시스템 콜) | 동기 | 항상 다음 명령어로 복귀 |
| Fault | 복구 가능한 오류 (ex. 페이지 폴트) | 동기 | 현재 명령어로 재실행 or 중단 |
| Abort | 치명적 오류 (ex. 하드웨어 에러) | 동기 | 복귀 불가, 즉시 종료 |
현재 실행 중인 명령어의 직접적인 결과로 이벤트가 발생하는 경우
실행 중인 명령어와 무관하게, 외부에서 예고 없이 발생하는 이벤트
예시: 디스크 I/O 완료, 네트워크 패킷 도착
예시: syscall 명령 → 커널의 trap handler 호출
예시: 페이지 폴트 (없는 가상 페이지 접근 시 → 메모리 할당 후 다시 시도)
예시: 하드웨어 오류, 시스템 내부 결함
| 예외 번호 | 이름 | 클래스 | 설명 |
|---|---|---|---|
0 | Divide Error | Fault | 0으로 나누기, 오버플로우 등 → 즉시 종료 ("Floating exception") |
13 | General Protection Fault | Fault | 권한 문제, 접근 불가 메모리 등 → 즉시 종료 ("Segmentation fault") |
14 | Page Fault | Fault | 접근한 가상 주소가 메모리에 없음 → 페이지 디스크에서 로딩 후 재실행 |
18 | Machine Check | Abort | 치명적인 하드웨어 오류 → 복귀 불가, 프로그램 종료 |
read, write, open, fork, execve, exit, getpid, kill 등)syscall 명령어로 트랩을 발생시킴 → 커널 핸들러 호출됨| 레지스터 | 용도 |
|---|---|
%rax | syscall 번호 |
%rdi, %rsi, %rdx, %r10, %r8, %r9 | 최대 6개 인자 |
%rax | 반환값 (성공: 양수 or 0 / 실패: 음수 errno) |
int main() {
write(1, "hello, world\n", 13);
//1 <= stdout
//"hello,world\n" <= write로 가는 byte 배열
//13 <= write할 byte 수
//('h' 'e' 'l' 'l' 'o' ',' ' ' 'w' 'o' 'r' 'l' 'd' '\n')
_exit(0);
}
이 코드는 printf 대신 직접 write와 _exit syscall을 호출
→ 유저 모드에서 syscall → trap → 커널 모드 진입 → 핸들러 수행 → 복귀
[1] 사용자 공간
main() 함수 실행 중...
[2] 커널 공간
- 시그널 발생 감지 (e.g., Ctrl+C, 자식 종료, 알람 만료 등)
- 시그널 pending → 처리 준비
[3] 커널 → 사용자 공간 전환
- 핸들러가 등록되어 있다면 → 해당 사용자 정의 핸들러 실행
[4] 핸들러 종료 후 → 다시 원래 사용자 코드로 복귀
kill() 함수를 통한 사용자 요청SIG_IGN)SIGINT가 이미 pending이면, 추가 SIGINT는 버려짐blocked 상태일 땐 pending으로 유지되지만 즉시 처리되지는 않음
| 상태 비트 | 역할 |
|---|---|
pending 비트 벡터 | 도착했지만 아직 수신 안 된 시그널들 |
blocked 비트 벡터 | 현재 수신을 차단 중인 시그널들 |
pending & ~blocked 상태에서만 실제 수신 처리됨
수신이 완료되면 해당 pending 비트는 0으로 클리어됨
| pending
(signal mask) | blocked | 동작 결과 |
| --- | --- | --- |
| 0 | 0 | 시그널 없음 |
| 1 | 0 | 즉시 수신 (핸들러 호출 등) |
| 1 | 1 | 대기 중 (blocked 상태 유지됨) |
| 0 | 1 | 차단됨 (시그널 안 온 상태 유지) |
유닉스 시스템에서는 여러 방법으로 시그널 보내기 가능
모든 방법은 프로세스 그룹(Process Group) 개념을 기반으로 한다.
프로세스 그룹
여러 방법으로 시그널을 보낼 수 있다.
k 선택시그널이 도착하더라도 즉시 수신하지 않고, 대기(pending) 상태로 보류해두는 것
→ 나중에 조건이 풀리면 그제야 수신됨 (핸들러 호출)
블로킹 메커니즘 종류
SIGINT 처리 중일 때 또 SIGINT 오면 → pending 상태가 됨sigprocmask() 시스템 콜 사용pending vs blocked 한눈에 요약
| 항목 | pending (수신됨?) | blocked (수신 허용?) |
|---|---|---|
| 뜻 | "시그널이 왔는가?" | "시그널을 지금 처리해도 되는가?" |
| 상태 | 0 = 아직 안 옴 / 이미 처리됨1 = 시그널 도착함 | 0 = 처리 허용 / 1 = 현재 보류 중 (mask된 상태) |
| 처리 조건 | pending == 1 && blocked == 0 일 때만 처리 | 시그널 핸들러 호출 여부를 결정하는 조건 중 하나 |
| 저장 위치 | 커널이 프로세스마다 따로 관리 (64비트 비트마스크) | 마찬가지로 커널이 프로세스마다 관리함 |
| 설정 방법 | kill(), alarm() 등으로 시그널 도착 시 1로 설정 | sigprocmask()나 암시적 블로킹으로 설정 가능 |
| 초기화 조건 | 핸들러 실행 완료 시 0으로 리셋됨 | 수동으로 해제해야 함 (sigprocmask()로 unblock) |
시그널 핸들러를 작성할 때 주의 점
안전한 시그널 처리
대부분의 함수는 핸들러 안에서 호출하면 위험함 (non-reentrant 함수 포함)
예: printf, malloc, exit → ❌ 안전하지 않음
안전한 함수 예: write, _exit, sigprocmask 등
→ 리눅스에서 보장하는 async-signal-safe 함수 목록은 Figure 8.33 참고
errno를 변경함 → 핸들러 내부에서 오류 발생 시, errno 값을 임시 변수에 저장/복원해야 함volatile로 선언volatile int g; 처럼 선언하여 항상 메모리에서 읽도록 강제sig_atomic_t 타입으로 선언일반적인 int는 원자적(atomic) 접근이 보장되지 않음
sig_atomic_t는 단일 명령어로 읽고 쓸 수 있어 안전함
volatile sig_atomic_t flag;
→ 단, flag++처럼 복합 연산은 여전히 unsafe임
flag++ : 실제적으로는 여러개의 명령어로 이루어짐
movl flag, %eax ; flag의 값을 레지스터에 복사
addl $1, %eax ; 1을 더함
movl %eax, flag ; 결과를 다시 flag에 저장
이 사이에 시그널이 인터럽트 되면, 값이 깨질 수 있음
핸들러 안에서는 flag만 세우고, 실제 로직에서 연산을 수행하자.
정확한 시그널 처리
시그널 핸들러는 "신호 발생 횟수"가 아니라 "한 번은 왔다는 사실"만 알려줌
따라서 동일 시그널이 연속 발생해도 한 번만 처리됨
→ 핸들러 안에서 한 번 들어왔을 때 가능한 한 모든 처리를 끝내야 함
| 개념 | 설명 |
|---|---|
| 리눅스 시그널은 큐잉되지 않음 (동일 시그널은 한번만 처리) | 같은 시그널이 여러 번 오면 1개만 pending 상태로 유지됨 |
| 핸들러 실행 중 시그널이 오면 | pending이 이미 1이면 무시됨 |
| 핸들러에서 여러 이벤트 처리 필요 | 핸들러 내에서 반복적으로 처리해야 함 (while(waitpid)) |
| 안전한 처리법 | 핸들러 1회 진입 시, 해당 이벤트를 다 처리하고 나와야 신뢰 가능 |
호환성 있는 시그널 핸들링
signal()은 오래된 방식 → 호환성이 떨어짐
sigaction()이 POSIX 표준으로 공식 채택된 현대적 방식
Signal()은sigaction()을 간단히 쓰기 위한 사용자 정의 래퍼 함수
- 래퍼함수(wrapper function) : 다른 함수를 내부에서 호출해, 복잡한 동작을 감추고 사용자에게 단순한 인터페이스를 제공하는 함수
| 이름 | 의미/역할 | 실제 동작 | 쓰임 |
|---|---|---|---|
signal() | 전통적인 C 시그널 처리 함수 (옛날 방식) | 내부적으로 시스템 구현 따라 다름 (비표준적) | ❌ 추천 안 함 |
sigaction() | POSIX 표준 시그널 인터페이스 (정확하고 안전함) | ✅ 커널 호출 | ✅ 실무 사용 |
Signal() | 책/실무에서 만든 사용자 정의 래퍼 함수 | 내부적으로 sigaction() 호출 | ✅ 교육/코드 단축용 |
fork() 호출 → 자식이 곧바로 실행됨deletejob() 호출 → 자식이 리스트에 없으니 삭제 못 함fork() 리턴 받아 addjob() 호출 → 이미 종료된 자식이 리스트에 등록됨addjob()과 deletejob()의 경쟁 상태(race)가 발생했음while (!pid) ; //자식 pid로 pid를 설정, pid 반환할때까지 대기
while (!pid) // while 조건 검사와 pause() 사이에
pause(); // 시스템 콜 오면 영원히 잠들 수 있음
while (!pid)
sleep(1); // 올바르지만 너무 느림! 1초씩 대기
sigprocmask(SIG_BLOCK, &sigset, &prev); // 시그널을 막아둠
while (!pid) /// 시그널 마스크를 바꾸고 시그널을 바꾼다.
sigsuspend(&mask); // 올바른 방법
-----------------------------------0------
sigsuspend(&mask);
sigsuspend가 호출될 때 기존에 차단된 시그널을 일시적으로 처리할 수 있도록 함
1. 현재 시그널 마스크 저장
2. 시그널 마스크를 temp_mask로 교체
3. 시그널이 올 때까지 잠듦 (대기)
4. 시그널 오면 → 핸들러 실행
5. 깨어나면 → 이전 마스크로 자동 복구
sigprocmask(SIG_BLOCK, &sigset, &prev);
&sigset: 차단하려는 시그널들이 포함된 시그널 집합
자식 프로세스 종료를 나타내는 SIGCHLD 시그널을 차단
&prev: sigprocmask 호출 이전에 활성화된 시그널 마스크를 저장하는 변수
이후에 복원할 수 있게