C++ Exception Basics

TL;DR

  • C++ 예외 처리는 Itanium C++ ABI의 규칙을 따른다.

  • 정상 경로는 비용이 없고(=zero-cost), 예외가 던져질 때만 스택 언와인딩이 수행된다.

  • 언와인딩은 DWARF 언와인드 정보(.eh_frame) 를 이용하여 스택 프레임을 한 단계씩 되돌리며,
    프레임마다 personality 함수가 “catch 대상인지/정리만 할지”를 판단한다.

  • 예외 객체는 heap 에 만들어지며 reference count 로 생명주기를 관리한다.

  • 언와인딩 과정에서 callee-saved 레지스터만 복원되고, caller-saved 레지스터는 필요 시 landing pad 코드가 재구성한다.

  • 성능 병목은 동적 라이브러리 목록을 보호하는 전역 로더 락.eh_frame 탐색 비용이며,
    언와인더 구현체(libgcc_s, LLVM libunwind 등)는 이를 캐싱 전략으로 최적화한다.


Overview

1) 예외 발생 시 전체 흐름

  1. throw → 컴파일러가 생성한 코드가 예외 객체를 heap에 할당하고 __cxa_throw 호출

  2. __cxa_throw → Itanium ABI의 _Unwind_RaiseException 호출

  3. 언와인더가 현재 스택 프레임 위치에서 DWARF CFI(unwind info) 를 읽는다

  4. 1단계(Search phase)

    • 각 프레임마다 personality 함수를 호출해
      “이 프레임이 이 예외를 잡을 수 있는가?” 검사
  5. 2단계(Cleanup phase)

    • 다시 처음부터 프레임을 순회하며

      • destructor 호출

      • catch 대상 프레임까지 스택 롤백

    • 도착하면 landing pad로 점프

  6. landing pad에서 __cxa_begin_catch 실행 → 예외 객체 참조 획득

  7. catch 블록 종료 시 __cxa_end_catch 호출 → refcount 감소 및 필요 시 삭제


2) Itanium C++ ABI가 보장하는 것

  • 예외 객체 메모리 레이아웃

  • __cxa_throw, __cxa_begin_catch, __cxa_end_catch, __gxx_personality_v0 등의 인터페이스

  • personality 함수가 수행해야 하는 동작과 판단 규칙

  • DWARF 기반 스택 언와인딩 절차
    컴파일러(gcc/clang)가 달라도 예외 모델은 호환됨


3) DWARF 언와인드 정보(.eh_frame)

  • 각 함수의 스택 레이아웃 변화, 프롤로그/에필로그에서 저장된 레지스터 위치 등을 기록한 metadata

  • 언와인더는 이를 해석하여 “이전 프레임의 SP/IP/FP/저장 레지스터”를 복원할 수 있다

  • 예외 처리 전용이며, 디버그 정보(.debug_info)와는 별개


4) personality 함수

프레임마다 호출되는 “언어별 처리 엔진” 역할.

  • C++: __gxx_personality_v0

  • 책임

    1. 이 프레임이 해당 예외 타입을 catch할 수 있는지 판단

    2. cleanup-only 영역인지 판단

    3. landing pad의 위치(IP)를 언와인더에 알려줌


5) 레지스터 복원 규칙

  • callee-saved 레지스터만 DWARF를 통해 반드시 복원

    • (예: x86-64: RBX, RBP, R12~15 등)
  • caller-saved는 landing pad 코드가 직접 필요 시 다시 로딩

  • SP, IP(PC), FP 같은 프레임 관련 레지스터는 항상 언와인더가 복원


6) 성능 병목과 최적화

  • 동적 라이브러리 목록은 로더의 전역 락(dl loader lock) 으로 보호됨
    .eh_frame 위치 탐색 시 락 획득 필요

  • 대규모 프로그램에서 예외 비용이 급증하는 이유

    • 프레임마다 DWARF를 파싱

    • 잦은 전역 락 획득

  • LLVM libunwind, libgcc_s 등은

    • IP → CFI 캐싱

    • 함수 단위 또는 호출 지점 단위 캐싱

    • backtrace fast path 최적화
      등을 사용해 성능 개선


Example (개념 흐름 예시)

예외 발생 → __cxa_throw
_Unwind_RaiseException
→ Frame N personality 검사(부적합)
→ Frame N–1 personality 검사(부적합)
→ Frame N–2 personality 검사(catch 가능 → search phase 종료)
→ cleanup phase 시작(위 프레임들 destructor 실행)
→ landing pad IP로 점프
__cxa_begin_catch
→ catch 블록 실행
__cxa_end_catch → refcount 0 → 예외 객체 delete


Takeaways

  • C++ 예외 처리는 zero-cost 모델이며, 실행 중 예외가 없을 때 비용이 사실상 없다.

  • 언와인딩은 DWARF + Itanium ABI + personality 함수의 조합으로 동작한다.

  • 예외 객체는 heap에 있으며 refcount로 생명주기 관리된다.

  • caller-saved / callee-saved 구분은 언와인딩 비용 최적화를 위해 중요하다.

  • 동적 라이브러리 환경에서는 로더 락 경쟁이 예외 비용을 악화시킬 수 있으며,
    현대 언와인더는 이를 캐싱 전략으로 완화한다.

  • GCC/Clang 등 서로 다른 C++ 런타임이라도 Itanium ABI를 준수하는 한 상호 운용 가능하다.

References

CppCon 2017: Dave Watson “C++ Exceptions and Stack Unwinding” C++ Exceptions for Smaller Firmware - Khalil Estell - CppCon 2024