[TIL/크래프톤 정글] DAY 45

배재준·2025년 4월 23일

크래프톤 정글 - TIL

목록 보기
38/93
post-thumbnail

2025.04.23

TIL(TODAY I LEARN)


  • 오늘한 내용 : CS - 예외적인 제어 흐름 8.1,8.5

  • WEEK06: 메모리 누수, 균형 이진 탐색 트리(AVL Tree, Red-Black Tree)


8. 예외적인 제어흐름(Exceptional Control Flow)

제어 흐름(Control Flow)

CPU가 명령어를 순차적으로 실행하면서 PC(Program Counter)를 바꾸는 흐름

  • 보통은 a0 → a1 → a2 ... 순서대로 진행
  • jmp, call, ret 같은 명령이 들어오면 흐름이 바뀜
→ 이는 **정상적인(controlled) 흐름 변경**

ECF

내부 변수 상태 변화가 아닌, 시스템 상태 변화로 인해 비정상적으로 흐름이 바뀌는 현상

  • 예: 타이머 인터럽트, 네트워크 패킷 도착, 디스크 완료, 자식 프로세스 종료 등

즉, "시스템 밖에서 발생한 일 때문에, 지금 실행 중인 코드 흐름이 갑자기 바뀌는 상황"

ECF는 시스템 모든 수준에서 발생

수준예시
하드웨어 수준예외(Exception), 인터럽트 (→ 커널 핸들러로 제어 이동)
OS 수준프로세스 컨텍스트 스위칭 (Context Switch)
응용 수준시그널(Signals), longjmp() 같은 비표준 흐름 변경

8.1 예외 상황

  • 어떤 프로세서 상태의 변화에 대한 대응, 제어흐름의 갑작스런 변화임
  • 프로세서가 이벤트가 발생을 감지하면 → 예외 테이블을 참조하여 간접 프로시저 콜을 함
    1. 현재 명령어로 복귀
    2. 다음 명령어로 복귀
    3. 프로그램을 종료

8.1.1 예외처리

  • 예외가 들어왔을 때 제어 흐름의 처리를 어떻게 하는가?
  • 주요 개념
    • 예외 번호 : 예외마다 정수 번호 k 가짐
    • 예외 테이블 : k 를 인덱스로 하여 핸들러 주소를 저장한 점프 테이블
    • 예외 핸들러 호출
      • 예외 발생 → 예외 번호 k 탐지 → k를 통해 간접 프로시저 콜
      • 커널 모드에서 실행된다.
  • 예외 처리 동작
    • 현재 명령어의 복귀 주소를 스택에 저장
      • 복귀 주소는 예외에 따라 다름
        • 다음 명령어 or 현재 명령어
      • 상태 복구를 위한 EFLAGS 레지스터 값도 스택에 저장
      • 커널 스택임.
  • 함수 호출과 비슷하지만 다르다.
항목함수 호출예외
실행 트리거명시적 call이벤트 (내부 or 외부)
복귀 주소다음 명령어현재 or 다음 명령어
상태 저장일부 레지스터추가 CPU 상태까지 저장
실행 컨텍스트사용자 모드커널 모드

8.1.2 예외의 종류

클래스원인동기성복귀 가능성
Interrupt외부 I/O 장치 신호 (타이머, 키보드 등)비동기항상 다음 명령어로 복귀
Trap명시적 요청 (ex. 시스템 콜)동기항상 다음 명령어로 복귀
Fault복구 가능한 오류 (ex. 페이지 폴트)동기현재 명령어로 재실행 or 중단
Abort치명적 오류 (ex. 하드웨어 에러)동기복귀 불가, 즉시 종료
  • 동기

    현재 실행 중인 명령어의 직접적인 결과로 이벤트가 발생하는 경우

  • 비동기

    실행 중인 명령어와 무관하게, 외부에서 예고 없이 발생하는 이벤트

Interrupt (인터럽트)

  • 외부 이벤트에 의해 발생 (I/O 장치 등)
  • 현재 명령어 실행 후, 인터럽트 처리기로 이동
  • 처리 후 항상 다음 명령어로 복귀

예시: 디스크 I/O 완료, 네트워크 패킷 도착


Trap (트랩) & System call (시스템 콜)

  • 사용자가 의도적으로 유발하는 예외
  • 가장 대표적인 예: 시스템 콜
  • 항상 다음 명령어로 복귀

예시: syscall 명령 → 커널의 trap handler 호출


Fault (오류)

  • 오류지만 복구 가능성 있음
  • 예외 처리기가 문제를 고치고 재시도할 수 있음
  • 복구 성공 시 → 현재 명령어 재실행

예시: 페이지 폴트 (없는 가상 페이지 접근 시 → 메모리 할당 후 다시 시도)


Abort (중단)

  • 복구 불가능한 치명적인 오류
  • 예외 처리기 → 프로그램을 강제 종료함
  • 복귀 불가

예시: 하드웨어 오류, 시스템 내부 결함


8.1.3 리눅스/x86-64 시스템에서의 예외상황

  • 256가지 예외 번호 존재 (0–255)
    • 0–31: Intel 아키텍처에서 정의한 고정 예외 → 모든 x86-64 시스템에서 동일
    • 32–255: OS에서 정의 (예: 시스템 콜, I/O 인터럽트 등)
  • 주요 예외 예시 (Linux 기준)
예외 번호이름클래스설명
0Divide ErrorFault0으로 나누기, 오버플로우 등 → 즉시 종료 ("Floating exception")
13General Protection FaultFault권한 문제, 접근 불가 메모리 등 → 즉시 종료 ("Segmentation fault")
14Page FaultFault접근한 가상 주소가 메모리에 없음 → 페이지 디스크에서 로딩 후 재실행
18Machine CheckAbort치명적인 하드웨어 오류 → 복귀 불가, 프로그램 종료
  • 시스템 콜과 트랩
    • Linux에서는 수백 개의 시스템 콜 제공 (ex: read, write, open, fork, execve, exit, getpid, kill 등)
    • 시스템 콜은 syscall 명령어로 트랩을 발생시킴 → 커널 핸들러 호출됨
    • 시스템 콜 번호와 인자는 레지스터로 전달됨
레지스터용도
%raxsyscall 번호
%rdi, %rsi, %rdx, %r10, %r8, %r9최대 6개 인자
%rax반환값 (성공: 양수 or 0 / 실패: 음수 errno)

예: 시스템 콜 직접 호출하는 Hello 프로그램

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 → 커널 모드 진입 → 핸들러 수행 → 복귀


8.5 시그널(Signals)

  • 시그널은 커널이나 다른 프로세스가 어떤 이벤트가 발생했음을 프로세스에 알려주는 작은 메시지
[1] 사용자 공간
    main() 함수 실행 중...

[2] 커널 공간
    - 시그널 발생 감지 (e.g., Ctrl+C, 자식 종료, 알람 만료 등)
    - 시그널 pending → 처리 준비

[3] 커널 → 사용자 공간 전환
    - 핸들러가 등록되어 있다면 → 해당 사용자 정의 핸들러 실행

[4] 핸들러 종료 후 → 다시 원래 사용자 코드로 복귀

8.5.1 시그널 용어

  • 전송(Sending)
    1. 커널 이벤트 감지
    2. kill() 함수를 통한 사용자 요청
  • 수신(Receiving)
    1. 시그널 무시(SIG_IGN)
    2. 종료(기본 동작)
    3. 핸들러 함수 실행(사용자 정의 처리)
  • Pending Signal(보류 중인 시그널)
    • 보내졌지만 아직 처리되지 않은 시그널
    • 같은 종류의 시그널은 하나만 보류 가능
      • 예: SIGINT가 이미 pending이면, 추가 SIGINT버려짐
    • 시그널은 차단(blocked)될 수 있음
      • blocked 상태일 땐 pending으로 유지되지만 즉시 처리되지는 않음

        상태 비트역할
        pending 비트 벡터도착했지만 아직 수신 안 된 시그널들
        blocked 비트 벡터현재 수신을 차단 중인 시그널들
      • pending & ~blocked 상태에서만 실제 수신 처리됨

      • 수신이 완료되면 해당 pending 비트는 0으로 클리어됨

        | pending
        (signal mask) | blocked | 동작 결과 |
        | --- | --- | --- |
        | 0 | 0 | 시그널 없음 |
        | 1 | 0 | 즉시 수신 (핸들러 호출 등) |
        | 1 | 1 | 대기 중 (blocked 상태 유지됨) |
        | 0 | 1 | 차단됨 (시그널 안 온 상태 유지) |

8.5.2 시그널 보내기

  • 유닉스 시스템에서는 여러 방법으로 시그널 보내기 가능

  • 모든 방법은 프로세스 그룹(Process Group) 개념을 기반으로 한다.

    • 프로세스 그룹?
  • 프로세스 그룹

    • 모든 프로세스는 정확히 하나의 프로세스 그룹에 속함
    • 그룹은 양의 정수 ID로 식별됨
  • 여러 방법으로 시그널을 보낼 수 있다.

8.5.3 시그널의 수신

  • 처리 순서
    1. 커널이 유저 모드 진입 시점에 pending & ~blocked 검사
    2. 비어있으면(수신 가능한 시그널 없음) → 원래 흐름 (Inext) 계속
    3. 대기중인 signal 하나 이상 있으면 → 가장 작은 번호의 시그널 k 선택
    4. 해당 시그널의 기본 동작(default action)을 수행:
      • 프로세스 종료
      • 코어 덤프 후 종료
      • 일시 정지
      • 무시
    5. 또는 사용자가 정의한 핸들러가 있으면 → 해당 핸들러 실행

8.5.4 시그널 블로킹과 언블로킹

블로킹(blocking)

시그널이 도착하더라도 즉시 수신하지 않고, 대기(pending) 상태로 보류해두는 것
→ 나중에 조건이 풀리면 그제야 수신됨 (핸들러 호출)

  • 블로킹 메커니즘 종류

    • 암시적(Implicit) 블로킹
      • 커널이 자동으로 수행
      • 핸들러 실행 중인 시그널은 자동 블로킹됨
        • 예: SIGINT 처리 중일 때 또 SIGINT 오면 → pending 상태가 됨
        • 핸들러 끝난 후 그제서야 수신
    • 명시적(Explicit) 블로킹
      • 프로그래머가 직접 제어
      • sigprocmask() 시스템 콜 사용
        • 특정 시그널을 블로킹/언블로킹/마스크 설정 가능
  • pending vs blocked 한눈에 요약

항목pending (수신됨?)blocked (수신 허용?)
"시그널이 왔는가?""시그널을 지금 처리해도 되는가?"
상태0 = 아직 안 옴 / 이미 처리됨1 = 시그널 도착함0 = 처리 허용 / 1 = 현재 보류 중 (mask된 상태)
처리 조건pending == 1 && blocked == 0 일 때만 처리시그널 핸들러 호출 여부를 결정하는 조건 중 하나
저장 위치커널이 프로세스마다 따로 관리 (64비트 비트마스크)마찬가지로 커널이 프로세스마다 관리함
설정 방법kill(), alarm() 등으로 시그널 도착 시 1로 설정sigprocmask()나 암시적 블로킹으로 설정 가능
초기화 조건핸들러 실행 완료 시 0으로 리셋됨수동으로 해제해야 함 (sigprocmask()로 unblock)
  • pending은 같은 시그널 중복 실행을 막기 위한 것, 다른 시그널은 따로 돌아가므로 경쟁 조건을 막기 위해 블로킹이 필요하다!

8.5.5 시그널 핸들러 작성하기

시그널 핸들러를 작성할 때 주의 점


안전한 시그널 처리

  • G0. 핸들러는 최대한 간단하게 작성
    • 핸들러는 가능한 한 짧고 단순하게 유지
    • 예: 시그널을 받으면 전역 플래그 하나만 설정하고 바로 리턴
    • 나머지 처리는 메인 루프에서 플래그를 확인하여 수행
  • G1. async-signal-safe 함수만 호출
    • 대부분의 함수는 핸들러 안에서 호출하면 위험함 (non-reentrant 함수 포함)

    • 예: printf, malloc, exit → ❌ 안전하지 않음

    • 안전한 함수 예: write, _exit, sigprocmask

      → 리눅스에서 보장하는 async-signal-safe 함수 목록은 Figure 8.33 참고

  • G2. errno 저장 및 복원
    • 일부 안전한 함수도 errno를 변경함 → 핸들러 내부에서 오류 발생 시, errno 값을 임시 변수에 저장/복원해야 함
  • G3. 전역 공유 자원 접근 시 모든 시그널 일시적으로 블로킹
    • 핸들러/메인 루프가 공유 자원(g)을 동시에 접근하면 일관성 깨짐
    • 해결: 접근 전 sigprocmask로 시그널 전체 block, 접근 후 복원
  • G4. 전역 변수는 volatile로 선언
    • 컴파일러가 최적화 과정에서 메모리에 있는 값 대신 레지스터 복사본을 쓸 수 있음 → 시그널 핸들러가 바꾼 값이 메인에서 반영 안 되는 오류 발생 → volatile int g; 처럼 선언하여 항상 메모리에서 읽도록 강제
  • G5. 플래그 변수는 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() 호출✅ 교육/코드 단축용

8.5.6 치명적인 동시성 버그를 피하기 위해서 흐름을 동기화하기

예시 (Figure 8.39)

  • 프로그램 구조
    • 부모 프로세스가 자식 프로세스를 생성하고, 자식이 종료되면 SIGCHLD 시그널을 통해 처리
    • 전역 job 리스트에 자식 추가/삭제하는 구조
  • 문제 흐름
    1. 부모가 fork() 호출 → 자식이 곧바로 실행됨
    2. 자식이 즉시 종료 → 커널이 SIGCHLD 시그널을 부모에게 pending
    3. 부모가 아직 job 리스트에 자식 등록 안 했는데, 커널이 시그널 핸들러 실행
    4. 핸들러에서 deletejob() 호출 → 자식이 리스트에 없으니 삭제 못 함
    5. 핸들러 종료 후, 부모가 fork() 리턴 받아 addjob() 호출 → 이미 종료된 자식이 리스트에 등록됨
  • zombie가 등록된 것처럼 되어 버림 → 잘못된 상태 관리(race condition)

8.5.7 명시적으로 시그널 대기하기

  • 8.5.6의 race condition 해결하기
  • 메인 루프에서 어떤 시그널이 처리되기를 "명시적으로" 기다리는 방식
  • 앞의 8.5.6에서는 시그널이 비동기적으로 도착하고 처리되기 때문에 → 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 호출 이전에 활성화된 시그널 마스크를 저장하는 변수 
			이후에 복원할 수 있게

0개의 댓글