panic!()이 발생할 때 러스트 표준 라이브러리 측에서 어떤 고수준 메커니즘과 인터페이스가 작동하는지 정리한다. 패닉 런타임/핸들러/훅, std::panic!과 core::panic!의 경로 차이, 각 진입점과 페이로드 전달, no_std 환경에서의 동작 등을 개관한다.
panic!()이 일어날 때 정확히 무슨 일이 벌어질까? 최근 표준 라이브러리의 관련 부분을 꽤 오래 들여다봤는데, 답은 생각보다 꽤 복잡했다! 러스트에서 패닉의 고수준 그림을 설명하는 문서를 찾지 못해, 정리해 둘 가치가 있다고 느꼈다.
(홍보 한 스푼: 제가 이걸 들여다보게 된 계기는 @Aaron1011 님이 Miri에 언와인딩 지원을 구현했기 때문이다. 예전부터 Miri에 이 기능이 있길 바랐지만 직접 구현할 시간은 없었는데, 어느 날 갑자기 PR이 올라와서 정말 반가웠다. 여러 차례 리뷰 끝에 최근에 머지되었다. 아직 다소 거친 부분이 남아 있지만, 토대는 탄탄하다.)
이 글의 목적은 이 문제의 러스트 쪽에서 작동하는 고수준 구조와 관련 인터페이스들을 문서화하는 것이다. 언와인딩의 실제 메커니즘은 완전히 다른 주제이며(그리고 나는 그 부분을 설명할 자격이 없다).
Note: 이 글은 이 커밋 시점의 패닉 동작을 설명한다. 여기서 다루는 인터페이스들 중 상당수는 libstd의 불안정한 내부 구현 세부사항이며 언제든 변경될 수 있다.
Update (2025-08-18): 예상대로, 이 글의 많은 부분이 이제는 꽤 구식이 되었다. 패닉 관련 장치의 지속적인 정리 작업을 추적하는 이슈는 여기를 참고하라.
libstd의 코드를 읽으며 패닉이 어떻게 작동하는지 파악하려 하면 쉽게 미로 속에서 길을 잃기 쉽다. 링커가 결합해 주기 전까지는 서로 연결되지 않는 여러 겹의 간접화가 있고, #[panic_handler] 속성과 “패닉 런타임” (패닉 _전략_에 의해 제어되며 -C panic으로 설정)과 “패닉 훅”이 있으며, #[no_std] 문맥에서의 패닉은 전혀 다른 코드 경로를 탄다… 정말 많은 일들이 동시에 일어난다. 더구나 패닉 훅을 설명하는 RFC는 그것을 “panic handler”라고 부르는데, 지금은 그 용어가 다른 의미로 재사용되고 있다.
시작점으로 가장 좋은 것은 두 가지 간접화를 제어하는 인터페이스다:
_패닉 런타임_은 libstd가 stderr에 패닉 정보를 출력한 이후에 무엇을 할지 제어할 때 사용한다. 패닉 _전략_에 의해 결정되며, 중단(abort)할지(-C panic=abort), 언와인드(unwind)할지(-C panic=unwind)를 고른다. (패닉 런타임은 또한 catch_unwind의 구현도 제공하지만, 여기서는 다루지 않는다.)
_패닉 핸들러_는 libcore가 (a) 코드 생성에 의해 삽입되는 패닉(산술 오버플로우나 배열/슬라이스 인덱스 범위 초과 같은 경우)과 (b) core::panic! 매크로(이는 libcore 자체 및 일반적인 #[no_std] 문맥에서의 panic! 매크로)의 구현에 사용한다.
이 두 인터페이스는 모두 extern 블록을 통해 구현된다: 각각 libstd/libcore는 자신들이 위임할 함수를 그냥 import만 하고, 그 함수의 실제 구현은 크레이트 트리 어딘가 전혀 다른 곳에서 제공된다. 이 import는 링킹 시점에야 해소된다. 그러니 로컬에서 그 코드를 보는 입장에서는 실제 구현이 어디 있는지 알 도리가 없다. 내가 여러 번 길을 잃었던 것도 무리가 아니다.
이하에서 이 두 인터페이스가 계속 등장한다. 헷갈릴 때 가장 먼저 확인할 것은 패닉 _핸들러_와 패닉 _런타임_을 뒤섞지 않았는가 하는 점이다. (그리고 패닉 _훅_도 있다는 걸 기억하자. 곧 다루겠다.) 나도 자주 헷갈린다.
게다가 core::panic!과 std::panic!은 동일하지 않다. 곧 보겠지만, 이 둘은 매우 다른 코드 경로를 탄다. libcore와 libstd는 각자 패닉을 일으키는 고유한 방식을 구현한다:
core::panic!은 하는 일이 거의 없고, 즉시 패닉 _핸들러_에 위임한다.std::panic!(러스트에서의 “일반적인” panic! 매크로)은 사용자 제어 가능한 패닉 훅을 제공하는, 풀 스택의 패닉 장치를 구동한다. 기본 훅은 패닉 메시지를 stderr에 출력한다. 훅이 끝나면, libstd는 패닉 _런타임_에 위임한다.libstd는 또한 같은 장치를 호출하는 패닉 _핸들러_를 제공하므로, core::panic!도 결국 여기로 모인다.
이제 각 요소를 조금 더 자세히 들여다보자.
패닉 런타임 인터페이스(이 RFC로 도입)는 libstd가 import하고 나중에 링커가 해소하는 __rust_start_panic(payload: usize) -> u32 함수다.
여기서 usize 인자는 사실 *mut &mut dyn core::panic::BoxMeUp이다. 여기로 패닉의 “페이로드”(catch될 때 접근 가능한 정보)가 전달된다. BoxMeUp은 불안정한 내부 구현 세부사항이지만, 트레이트를 보면 실상은 dyn Any + Send를 감싸는 것뿐임을 알 수 있다. 이는 catch_unwind와 thread::spawn이 돌려주는 패닉 페이로드의 타입이다. BoxMeUp::take_box는 Box<dyn Any + Send>를 반환하는데, 정의된 문맥에서는 Box를 쓸 수 없으므로 생 포인터(raw pointer)로 돌려준다. BoxMeUp::get은 내용을 단순히 빌려온다.
러스트가 제공하는 구현은 두 가지다. 대부분의 플랫폼에서 기본인 -C panic=unwind에는 libpanic_unwind, -C panic=abort에는 libpanic_abort가 해당한다.
std::panic!패닉 런타임 인터페이스 위에, libstd는 내부 모듈인 std::panicking에 기본 러스트 패닉 장치를 구현한다.
rust_panic_with_hook거의 모든 것이 통과하는 핵심 함수는 rust_panic_with_hook이다:
fn rust_panic_with_hook(
payload: &mut dyn BoxMeUp,
message: Option<&fmt::Arguments<'_>>,
file_line_col: &(&str, u32, u32),
) -> !
이 함수는 패닉 발생 소스 위치, 포맷되지 않은 패닉 메시지(자세한 내용은 fmt::Arguments 문서 참조), 그리고 페이로드를 받는다.
주요 역할은 현재 설정된 패닉 훅을 호출하는 것이다. 패닉 훅은 PanicInfo를 인자로 받으므로, 패닉 소스 위치와 패닉 메시지에 대한 포맷 정보, 그리고 페이로드가 필요하다. 이는 rust_panic_with_hook의 인자들과 아주 잘 대응된다! file_line_col과 message는 앞의 두 요소에 직접 사용할 수 있고, payload는 BoxMeUp 인터페이스를 통해 &(dyn Any + Send)로 바뀐다.
흥미롭게도, 기본 패닉 훅은 message를 완전히 무시한다. 실제로 출력되는 것은 페이로드를 &str 또는 String으로 다운캐스트한 것이다(가능한 쪽으로). 호출자가, message가 있으면 그것을 포맷한 결과가 동일하도록 보장해야 한다고 되어 있다. (그리고 아래에서 보겠지만, 우리가 논의하는 호출자들은 실제로 그렇게 보장한다.)
마지막으로, rust_panic_with_hook은 현재의 패닉 _런타임_으로 디스패치한다. 이 시점에서는 오직 payload만이 여전히 중요하다 — 그리고 이것이 중요하다: message는(그에 붙은 '_ 수명이 가리키듯) 짧은 수명의 참조를 포함할 수 있지만, 패닉 페이로드는 스택을 따라 전파되므로 반드시 'static이어야 한다. 그 'static 제약은 꽤 잘 숨겨져 있지만, 한참 후에야 나는 Any가 'static을 내포함을 깨달았다(그리고 dyn BoxMeUp은 결국 Box<dyn Any + Send>를 얻기 위한 수단임을 기억하자).
rust_panic_with_hook은 std::panicking 내부의 비공개 함수다. 이 모듈은 이 핵심 함수 위에 세 가지 진입점을 제공하고, 하나는 그 경로를 우회한다:
begin_panic_handler: 기본 패닉 핸들러 구현으로, (아래에서 보겠지만) core::panic! 및 내장 패닉(산술 오버플로우나 배열/슬라이스 인덱스 범위 초과로 인한 패닉)을 뒷받침한다. 이 함수는 입력으로 PanicInfo를 받고, 그것을 rust_panic_with_hook의 인자로 바꾸어야 한다. 흥미롭게도, PanicInfo의 구성요소와 rust_panic_with_hook의 인자들이 꽤 잘 맞아떨어져서 그냥 전달되리라 기대되지만, 실제로는 그렇지 않다. libstd는 PanicInfo의 payload 구성요소를 완전히 _무시_하고, 실제 페이로드(즉 rust_panic_with_hook에 전달되는 값)를 포맷된 message를 담도록 설정한다.
특히, 이는 no_std 애플리케이션에서는 패닉 _런타임_이 무관함을 뜻한다. libstd의 패닉 핸들러 구현이 사용될 때만 런타임이 개입한다. (물론 -C panic으로 선택하는 패닉 _전략_은 코드 생성에도 영향을 주므로 여전히 의미가 있다. 예컨대 -C panic=abort에서는 언와인딩을 지원할 필요가 없으므로 코드가 더 단순해질 수 있다.)
begin_panic_fmt: 포맷 문자열 형태의 std::panic!(즉 매크로에 여러 인자를 넘기는 경우)을 뒷받침한다. 이는 기본적으로 포맷 문자열 인자들을 PanicInfo(그리고 더미 페이로드)에 담아 우리가 방금 논의한 기본 패닉 핸들러를 호출한다.
begin_panic: 단일 인자 형태의 std::panic!을 뒷받침한다. 흥미롭게도, 이는 다른 두 진입점과 매우 다른 코드 경로를 사용한다! 특히, 이 진입점만이 _임의의 페이로드_를 전달하도록 허용한다. 그 페이로드는 단지 Box<dyn Any + Send>로 변환되어 rust_panic_with_hook으로 넘겨질 뿐이다.
따라서, 훅이 전달받는 PanicData의 message 필드를 들여다보는 패닉 훅은 std::panic!("do panic")에서는 메시지를 볼 수 없지만, std::panic!("panic with data: {}", data)에서는 메시지를 볼 수 있다. 후자는 begin_panic_fmt를 경유하기 때문이다. 꽤 놀라운 점이다. (다만 PanicData::message()는 아직 안정화되지 않았다는 점도 주의.)
rust_panic_without_hook은 이들과 결이 다르다. 이 진입점은 resume_unwind를 뒷받침하며, 실제로 패닉 훅을 호출하지 않는다. 대신 즉시 패닉 런타임으로 디스패치한다. begin_panic처럼 호출자가 임의의 페이로드를 고를 수 있게 해 주지만, begin_panic과 달리 페이로드의 boxing과 unsizing은 호출자 책임이다. update_count_then_panic은 그것을 거의 그대로 패닉 런타임으로 전달한다.
std::panic! 장치는 매우 유용하지만, Box를 통한 힙 할당에 의존하므로 항상 사용 가능한 것은 아니다. libcore가 패닉을 일으킬 수 있도록, 패닉 핸들러가 도입되었다. 앞서 보았듯이 libstd가 사용 가능하다면, 그 인터페이스의 구현을 제공하여 core::panic!이 libstd의 패닉 장치로 연결되도록 한다.
패닉 핸들러 인터페이스는 libcore가 import하고 나중에 링커가 해소하는 fn panic(info: &core::panic::PanicInfo) -> ! 함수다. PanicInfo 타입은 패닉 훅에서 쓰는 것과 동일하다. 패닉 소스 위치, 패닉 메시지, 페이로드(dyn Any + Send)를 담고 있다. 패닉 메시지는 fmt::Arguments, 즉 아직 포맷되지 않은 포맷 문자열과 그 인자들로 표현된다.
core::panic!패닉 핸들러 인터페이스 위에, libcore는 최소한의 패닉 API를 제공한다. core::panic! 매크로는 fmt::Arguments를 만든 뒤 패닉 핸들러로 전달한다. 여기서는 포맷ting이 일어나지 않는데, 그것이 힙 할당을 필요로 하기 때문이다. 이 때문에 PanicInfo는 “해석되지 않은” 포맷 문자열과 그 인자들을 담는다.
흥미롭게도, 패닉 핸들러로 전달되는 PanicInfo의 payload 필드는 항상 더미 값으로 설정된다. 이는 libstd의 패닉 핸들러가 페이로드를 무시하고(대신 message로부터 새로운 페이로드를 구성한다) 있는 이유를 설명해 주지만, 그렇다면 왜 그 필드가 애초에 패닉 핸들러 API의 일부인지 의문이 든다. 또 다른 결과로, core::panic!("message")와 std::panic!("message")(포맷팅 없는 변형)은 실제로 아주 다른 패닉을 야기한다: 전자는 fmt::Arguments로 변환되어 패닉 핸들러 인터페이스를 거치고, 그 후 libstd가 그것을 포맷해 String 페이로드를 만든다. 반면 후자는 &str 자체를 페이로드로 직접 사용하며, message 필드는 (앞서 언급한 대로) None으로 남는다.
libcore 패닉 API의 일부 요소는 lang item인데, 이는 컴파일러가 코드 생성 중에 이 함수들 호출을 삽입하기 때문이다:
panic lang item은 컴파일러가 포맷팅을 필요로 하지 않는 패닉을 발생시켜야 할 때(산술 오버플로우 같은 경우) 호출된다. 이는 단일 인자 형태의 core::panic!도 뒷받침한다.panic_bounds_check lang item은 배열/슬라이스 범위 점검이 실패했을 때 호출된다. 이는 포맷팅을 동반한 core::panic!과 같은 경로를 호출한다.우리는 4개의 API 레이어를 훑어보았고, 그중 2개는 import된 함수 호출을 통한 간접화와 링킹 시점 해소를 사용한다. 꽤 긴 여정이었다! 하지만 이제 끝에 도달했다. 여정을 따라오며 당신 자신이 패닉하지 않았기를 바란다. ;)
나는 몇 가지 놀라운 점들을 언급했다. 알고 보니 그것들은 모두 패닉 훅과 패닉 핸들러가 인터페이스로 PanicInfo 구조체를 공유한다는 사실과 관련이 있다. 이 구조체에는 포맷되지 않은 선택적 message와 타입이 지워진 payload가 둘 다 들어 있다:
payload에서 찾을 수 있으므로, 훅 관점에서는 message가 무의미해 보인다. 실제로, payload에 메시지가 들어 있어도(예: std::panic!("message")) message는 비어 있을 수 있다.payload를 결코 받지 못하므로, 핸들러 관점에서는 그 필드가 무의미해 보인다.패닉 핸들러 RFC를 읽어보면, core::panic!에서도 임의의 페이로드를 지원하려는 계획이 있었던 듯하지만 아직 실현되지는 않았다. 하지만 설령 그런 확장이 있어도, 나는 다음 불변조건이 성립한다고 본다: message가 Some이라면, payload == &NoPayload(즉 페이로드는 중복)거나, payload가 포맷된 메시지(즉 메시지가 중복)다. 두 필드가 둘 다 유용한 경우가 과연 있을까? 없다면, 그것을 enum의 두 variant로 인코딩할 수 있지 않을까? 아마 현재 설계를 택한 좋은 이유가 있을 것이고, 그것이 문서화되어 있으면 좋겠다. :)
더 할 말이 많지만, 이제는 위에 포함해 둔 소스 코드 링크들을 따라가 보기를 권한다. 고수준 구조를 머리에 넣고 보면 코드를 따라가기 더 쉬울 것이다. 이 개요를 보다 항구적인 어딘가에 넣을 가치가 있다고 생각한다면, 이 글을 문서 형태로 다듬는 것도 기꺼이 해 보겠다 — 다만 어디가 좋은 자리일지는 잘 모르겠다. 그리고 내가 틀린 부분을 발견한다면 꼭 알려주기 바란다!