Proven C BookEnglish GitHub

77 비지역 점프 — <setjmp.h>

먼저 알아야 할 것

76장 신호 · 흐름을 끊고 들어오는 것
44장 수명과 저장 기간 · 호출 프레임이 쌓이고 걷히는 방식
51장 오류를 알리는 법 · 실패를 값으로 돌려주기

돌아보기

42장에서 함수를 부르면 프레임이 쌓이고, return하면 그 프레임이 걷히면서 한 칸씩 돌아간다고 했다. 그러면 열 겹 깊이에서 맨 위로 한 번에 돌아가는 일은 C에서 어떻게 하는가?

답. <setjmp.h>의 두 낱말이 그 일을 한다. setjmp가 “지금 여기”를 기록해 두고, longjmp가 그 기록을 되살려 스택을 통째로 되감는다. 열 겹이든 백 겹이든 한 번에 돌아간다.

다만 되감기는 것은 스택 포인터와 몇 개의 레지스터뿐이다. 그 사이에 열어 둔 파일도, 잡아 둔 기억도, 걸어 둔 잠금도 아무도 정리해 주지 않는다 — 이 장의 절반은 그 대가에 관한 이야기다.

이 장의 필요성과 맥락

76장과 짝을 이루는 장이다. 그리고 44장에서 배운 호출 프레임이 여기서 마지막으로 크게 쓰인다 — longjmp 가 무엇을 되감는지는 프레임이 쌓이고 걷히는 그림 없이는 그릴 수 없다. 스물세 장 전의 그림을 여기서 다시 꺼내 쓴다.

이 장이 끝나면

<setjmp.h> 하나를 정독한다. 왜 생겼는지(예외가 없는 언어의 궁여지책), 두 낱말의 정확한 모양과 반환값, jmp_buf의 정체, 표준이 정한 네 가지 문맥 제약과 volatile 규칙, 그리고 libjpeg·Lua 같은 실물이 이것을 어떻게 쓰는지와 오늘날의 대안까지.

이 장에서 답할 질문

  1. 그러면 이것이 C의 예외 처리인가?
  2. 왜 이런 이상한 제한이 있는가?
  3. alloca나 가변 길이 배열은 어떻게 되는가?

77.1 왜 있는가 — 예외가 없는 언어의 궁여지책

깊은 곳에서 문제가 생겼을 때, 언어에 예외가 있으면 throw 한 줄로 위층까지 빠져나간다. C에는 그것이 없다. 그래서 오류는 한 겹씩 반환값으로 올려야 하고, 중간의 모든 함수가 그 값을 받아 다시 올려 주어야 한다(51장).

setjmp/longjmp는 그 사슬을 건너뛰는 장치다. 1970년대 유닉스 V7에 이미 있었고, 등장 이유도 지금과 같았다 — 깊은 재귀에서 한 번에 빠져나오기.

표준의 각주는 이 함수들의 용도를 이렇게만 적어 둔다: “프로그램의 낮은 층에서 만나는 이례적인 상황을 다루는 데 쓸모가 있다.” 즉 예외의 대체물이 아니라 이례 상황용 탈출구라는 것이 원래의 자리다.

문. 그러면 이것이 C의 예외 처리인가?

답. 아니다. 결정적인 차이가 셋이다.

첫째, 정리해 주는 것이 없다. C++의 예외는 스택을 되감으며 소멸자를 부른다 (스택 되감기, stack unwinding). longjmp는 그냥 뛴다 — 열린 파일, 잡은 기억, 잠근 뮤텍스가 그대로 남는다.

둘째, 타입이 없다. 던지는 것은 int 하나뿐이다. 무엇이 잘못됐는지 담으려면 그 값을 색인 삼아 다른 곳에 적어 두어야 한다.

셋째, 지역이 제한된다. setjmp를 부른 함수가 아직 살아 있어야 한다. 이미 return한 함수의 자리로는 뛸 수 없다 — 뛰면 정의되지 않은 동작이다.

그래서 이 장의 결론을 미리 적으면 이렇다. 새 코드에서는 대개 쓰지 않는다. 다만 읽을 줄은 알아야 한다 — 널리 쓰이는 라이브러리들이 이것으로 지어졌기 때문이다.

77.2 두 낱말 — 정확한 모양

선언하는 일
int setjmp(jmp_buf env);지금의 호출 환경을 env에 저장한다. 매크로다
[[noreturn]] void longjmp(jmp_buf env, int val);env에 저장된 자리로 돌아간다. 돌아오지 않는다

표 77.1

77.2.1 setjmp — 두 번 돌아오는 것처럼 보이는 매크로

setjmp는 함수가 아니라 매크로다(표준은 함수로도 제공될 수 있다고 하지만, 매크로를 지우고 함수를 직접 부르는 것은 정의되지 않은 동작이다).

반환값의 규칙은 단순하고, 이 헤더의 전부라 할 만하다.

어떻게 도달했는가setjmp가 돌려주는 값
직접 불렸을 때(자리를 저장하는 순간)0
longjmp(env, val)로 돌아왔을 때val — 단, val이 0이면 1

표 77.2

longjmp(env, 0)이 0을 만들지 못하는 이유가 여기 있다. 0은 “저장하는 중”을 뜻하는 값으로 이미 쓰이고 있으므로, 표준이 1로 바꿔 준다.

examples/ch77/jmp_basic.c

/* <setjmp.h> — 저장한 자리로 되돌아가기. 흐름을 눈으로 따라간다. */
#include <setjmp.h>
#include <stdio.h>

static jmp_buf env;

static void deep(int level)
{
    printf("  going in to depth %d\n", level);
    if (level == 3) {
        puts("  trouble at depth 3 - longjmp(env, 42)");
        longjmp(env, 42);        /* 여기서 돌아가지 않는다 */
    }
    if (level < 5) deep(level + 1);   /* 조건을 두어야 컴파일러가 무한 재귀로 보지 않는다 */
    printf("  leaving depth %d normally\n", level);  /* 도달하지 않는다 */
}

int main(void)
{
    /* setjmp 는 *두 번* 값을 내놓는 것처럼 보인다:
       ① 직접 불렸을 때 0, ② longjmp 로 돌아왔을 때 그 val */
    int rc = setjmp(env);

    if (rc == 0) {
        puts("① setjmp returned 0 - meaning the place was saved");
        deep(1);
        puts("we never get here");
    } else {
        printf("② setjmp returned %d - so we came back through longjmp\n", rc);
    }

    /* longjmp(env, 0) 을 불러도 setjmp 는 0 을 돌려주지 않는다 — 1 이 된다 */
    static int once;
    if (!once) {
        once = 1;
        puts("\ntrying longjmp(env, 0):");
        longjmp(env, 0);
    }
    printf("\nsizeof(jmp_buf) = %zu bytes\n", sizeof(jmp_buf));
    return 0;
}

실행 결과

① setjmp returned 0 - meaning the place was saved
  going in to depth 1
  going in to depth 2
  going in to depth 3
  trouble at depth 3 - longjmp(env, 42)
② setjmp returned 42 - so we came back through longjmp

trying longjmp(env, 0):
② setjmp returned 1 - so we came back through longjmp

sizeof(jmp_buf) = 200 bytes

시연이 그 흐름 그대로다. 처음에는 0이 나와 deep으로 내려가고, 깊이 3에서 longjmp(env, 42)를 하자 mainsetjmp 자리로 곧바로 돌아와 42가 나온다. 중간의 deep(2)·deep(1)은 “정상으로 빠져나온다”는 줄을 인쇄할 기회를 얻지 못한다 — 그 프레임들은 그냥 사라졌다.

마지막 시험이 longjmp(env, 0)이다. 0을 주었는데 1이 돌아왔다.

77.2.2 setjmp를 쓸 수 있는 자리 — 네 가지뿐

표준(§7.13.1.1)이 문맥을 열거로 제한한다. 이 넷 밖에서 쓰면 정의되지 않은 동작이다.

허용되는 문맥보기
if·switch·while 등의 제어식 전체if (setjmp(env)) { ... }
정수 상수식과의 비교의 한쪽(그리고 그 전체가 제어식)if (setjmp(env) == 0)
! 하나를 붙인 것(그리고 그 전체가 제어식)if (!setjmp(env))
수식문 전체(void로 캐스트해도 된다)setjmp(env);

표 77.3

int rc = setjmp(env);처럼 값을 변수에 담는 것은 표준의 문맥이 아니다. 많은 구현에서 실제로 동작하고 이 책의 시연도 그렇게 썼지만, 이식성을 따진다면 switch (setjmp(env))if (setjmp(env) == 0) 꼴로 적는 편이 안전하다.

문. 왜 이런 이상한 제한이 있는가?

답. setjmp한 번 불려 두 번 돌아오는 물건이기 때문이다. 컴파일러 입장에서 이것은 보통의 함수 호출이 아니다 — 반환값이 복잡한 수식의 중간에 끼어 있으면, 그 수식을 계산하다 만 상태(레지스터에 걸쳐 있는 임시값들)를 어떻게 되살릴지 정할 수 없다.

그래서 표준은 아예 임시값이 살아 있을 수 없는 자리로 문맥을 좁혔다. 제어식 하나, 상수와의 비교 하나, 그리고 값을 버리는 수식문. 제한이 이상해 보이지만 구현 가능성에서 나온 것이다.

77.2.3 longjmp — 뛰기 전에 확인할 세 가지

longjmp(env, val)은 돌아오지 않는다(C23에서 [[noreturn]]이 붙었다). 뛰기 전에 세 조건이 성립해야 하고, 하나라도 어기면 정의되지 않은 동작이다.

조건어기면
envsetjmp가 실제로 있었다저장된 적 없는 자리로 뛴다
setjmp를 부른 함수가 아직 살아 있다사라진 프레임으로 뛴다 — 대개 즉사
같은 스레드다다른 스레드의 스택으로 뛴다

표 77.4

두 번째가 가장 자주 나는 사고다. “오류 처리 함수를 만들어 두고 아무 데서나 부르면 되겠지”라고 생각하고 setjmp를 부른 함수가 이미 반환한 뒤에 longjmp 하면, 그 자리에는 이미 다른 함수의 프레임이 들어와 있다.

77.3 jmp_buf의 정체

jmp_buf배열 타입이다. 표준이 그렇게 못박아 둔 데는 실용적인 이유가 있다 — 배열이므로 함수에 넘기면 자동으로 첫 원소의 주소로 무너져(38장), setjmp(env)&env를 쓰지 않고도 원본을 고칠 수 있다.

안에 무엇이 들었는가. 표준은 “longjmp가 올바른 블록으로 돌아가기에 충분한 정보”라고만 말한다. 실제로는 대개 이런 것들이다.

저장되는 것
스택 포인터되감을 자리를 알아야 한다
프로그램 카운터(복귀 주소)어디로 돌아갈지
피호출자 보존 레지스터호출 규약상 함수가 지켜 주기로 한 값들
(구현에 따라) 신호 마스크POSIX sigsetjmp가 선택적으로

표 77.5

시연이 인쇄한 크기(이 구현에서 200바이트)가 그 내용물의 총합이다. 다만 속을 들여다보거나 손으로 고치는 것은 계약 밖이다 — 불투명한 자료로 다뤄야 한다.

표준은 저장되지 않는 것도 분명히 적어 둔다. 부동소수점 환경의 상태, 열린 파일, 그리고 추상 기계의 다른 어떤 구성 요소도 포함되지 않는다. 이 한 문장이 다음 절의 모든 함정을 낳는다.

77.3.1 여덟 칸을 들여다보면

이 구현(glibc, x86-64)에서 jmp_buf 200바이트는 이렇게 나뉜다.

부분크기
레지스터 여덟 칸(long [8])64바이트
신호 마스크를 저장했는가(int + 정렬 채움)8바이트
신호 마스크 자리128바이트

표 77.6

여덟 칸의 순서를 실제로 인쇄해 보면 이렇다 — RBX, RBP, R12, R13, R14, R15, 스택 포인터, 복귀 주소. x86-64 SysV 호출 규약이 피호출자 보존으로 정한 레지스터의 목록이 정확히 이것이다(44장의 자동 수명과 호출 프레임).

두 가지가 눈에 띈다.

첫째, 세 칸은 값이 뒤섞여 있다. RBP·스택 포인터·복귀 주소 자리를 인쇄하면 스택 주소처럼 보이지 않는다. glibc가 스레드마다 다른 가드값과 섞어 저장하기 때문이다. jmp_buf를 덮어써서 원하는 주소로 뛰게 만드는 공격을 막는 장치다 — jmp_buf의 속을 손으로 고치지 말라는 규칙이 여기서도 확인된다.

둘째, 그 목록에 없는 레지스터는 저장되지 않는다. RAX·RCX·RDX·RSI·RDI·R8~R11 은 호출 규약이 “부르는 쪽이 알아서 챙길 자리”(호출자 보존)로 정한 것들이라 setjmp가 담지 않는다.

77.3.2 이것이 곧 volatile 규칙이다

앞의 두 사실을 지역 변수의 처지에서 다시 읽으면, 다음 절의 규칙이 저절로 나온다. 컴파일러는 지역 변수를 세 자리 중 하나에 둔다.

변수가 있던 자리longjmp
스택 위의 기억바뀐 값 그대로longjmp는 스택 포인터만 되돌린다. 내용은 건드리지 않는다
피호출자 보존 레지스터setjmp 시점의 옛 값그 칸이 jmp_buf에서 통째로 복원된다
호출자 보존 레지스터아무 값저장도 복원도 되지 않는다

표 77.7

최적화를 끄면 컴파일러가 지역 변수를 대개 스택에 두므로 첫 줄만 일어난다 — 그래서 -O0에서는 아무 문제가 없어 보인다. 최적화를 켜면 자주 쓰는 변수가 레지스터로 올라가고, 그 순간 둘째·셋째 줄이 나타난다.

표준이 “옛 값이 된다”가 아니라 “표현이 정해지지 않는다”(indeterminate)라고 적은 이유도 이것이다. 어느 자리에 있었는지에 따라 결과가 다르므로, 표준은 그중 어느 것도 약속하지 않는다.

77.4 돌아왔을 때 값은 어떻게 되는가 — volatile 규칙

이 장에서 가장 실질적인 규칙이다. 표준(§7.13.2.1)은 이렇게 정한다.

longjmp를 부른 시점의 값이 모든 객체에 그대로 남는다. 단 하나 예외가 있다 — setjmp를 부른 함수의 지역 자동 변수 중, volatile이 아니고, setjmp 이후에 값이 바뀐 것은 그 표현이 정해지지 않는다.

examples/ch77/jmp_volatile.c

/* longjmp 로 돌아왔을 때 지역 변수는 어떻게 되는가 — volatile 이 필요한 이유. */
#include <setjmp.h>
#include <stdio.h>

static jmp_buf env;

static void fail(void) { longjmp(env, 1); }

int main(void)
{
    /* 표준의 규칙(§7.13.2.1): setjmp 를 부른 함수의 자동 변수 중
       ① volatile 이 아니고
       ② setjmp 와 longjmp 사이에 값이 바뀐 것
       은 longjmp 로 돌아왔을 때 *표현이 정해지지 않는다*.
       그 밖의 모든 객체는 longjmp 를 부른 시점의 값을 그대로 가진다. */
    int                 plain    = 1;    /* 위험: 자동 + 비 volatile */
    volatile int        guarded  = 1;    /* 안전: volatile */
    static int          statik   = 1;    /* 안전: 정적 저장 기간 */

    if (setjmp(env) == 0) {
        plain   = 2;                     /* setjmp 와 longjmp 사이에서 바꾼다 */
        guarded = 2;
        statik  = 2;
        puts("setting all three to 2, then longjmp");
        fail();
    }

    printf("\nafter coming back through longjmp:\n");
    printf("  plain   (automatic, not volatile) = %d   <- the standard guarantees nothing\n",
           plain);
    printf("  guarded (automatic, volatile)     = %d   <- guaranteed\n", guarded);
    printf("  statik  (static)                  = %d   <- guaranteed\n", statik);

#ifdef __OPTIMIZE__
    puts("\n(this build has optimization on. Notice that plain went back to 1 -");
    puts(" the compiler had kept that variable in a register.)");
#else
    puts("\n(this build has optimization off. plain reads 2 as well, but that is not guaranteed -");
    puts(" build the same code with -O2 and it prints 1. We checked.)");
#endif
    puts("the rule is one line: a local that must survive a longjmp gets");
    puts("volatile.");
    return 0;
}

실행 결과

setting all three to 2, then longjmp

after coming back through longjmp:
  plain   (automatic, not volatile) = 2   <- the standard guarantees nothing
  guarded (automatic, volatile)     = 2   <- guaranteed
  statik  (static)                  = 2   <- guaranteed

(this build has optimization off. plain reads 2 as well, but that is not guaranteed -
 build the same code with -O2 and it prints 1. We checked.)
the rule is one line: a local that must survive a longjmp gets
volatile.

세 변수가 정확히 그 규칙을 갈라 보인다.

변수저장 기간·한정자longjmp
plain자동, 비 volatile, 중간에 바뀜보장 없음
guarded자동, volatile보장된다
statik정적보장된다

표 77.8

이 규칙은 실제로 물린다. 최적화를 끄고 빌드하면 셋 다 2로 보이지만, 같은 코드를 -O2로 빌드하면 plain1로 되돌아간다 — 컴파일러가 그 변수를 레지스터에 두었고, longjmp가 레지스터를 setjmp 시점 값으로 되살렸기 때문이다. 이 책은 두 빌드를 모두 돌려 그 차이를 확인했고, 예제가 __OPTIMIZE__를 보고 어느 쪽인지 밝히도록 해 두었다.

흔한 오해. volatile은 하드웨어 레지스터에나 쓰는 것이다”

이 자리가 volatile의 두 번째 정당한 용도다(첫 번째는 76장의 volatile sig_atomic_t). 셋을 나란히 두면 쓰임이 분명해진다.

자리무엇을 막는가
MMIO·하드웨어 레지스터읽기·쓰기를 지우거나 합치는 최적화
신호 처리기와 주고받는 깃발루프에서 읽기를 생략하는 최적화
longjmp를 건너는 지역 변수값을 레지스터에만 두는 최적화

표 77.9

셋 다 “컴파일러가 기억을 건너뛰는 것”을 막는다는 점에서 같다. 반대로 스레드 사이의 동기화에는 volatile이 쓸모없다 — 그 자리는 <stdatomic.h> 다(80장). 이 구별을 놓치면 volatile을 만능 부적으로 오해하게 된다.

77.5 자원은 아무도 정리해 주지 않는다

longjmp는 스택 포인터를 되돌릴 뿐이다. 그 사이에 무엇을 잡았든 그대로 남는다.

반례. 뛰어넘은 자리에 자원이 남는 코드

void work(void) {
    FILE *f = fopen("data", "r");     /* 열었다 */
    char *buf = malloc(1024);          /* 잡았다 */
    parse(f, buf);                     /* 여기서 longjmp 가 일어나면… */
    free(buf);                         /* 실행되지 않는다 — 누수 */
    fclose(f);                         /* 실행되지 않는다 — 파일도 샌다 */
}

parse 안에서 longjmp가 일어나면 work의 프레임은 그냥 사라진다. freefclose도 실행되지 않는다. 오래 도는 프로그램에서 이 무늬는 요청마다 조금씩 새는 형태로 나타난다.

그래서 이 장치를 쓰는 코드는 예외 없이 하나의 규율을 지킨다 — 자원을 잡고 푸는 자리를 setjmp를 부른 함수 안에 모은다. 뛰어 돌아온 뒤 그 함수가 직접 정리하면 되기 때문이다.

77.5.1 더 조용한 사고 — 포인터가 옛 값으로 되돌아간다

앞의 반례는 실행되지 않은 free였다. 그보다 조용한 것이 하나 더 있다. 정리 코드는 멀쩡히 실행되는데, 무엇을 풀지를 가리키던 포인터가 앞 절의 규칙에 걸려 옛 값으로 되돌아가는 경우다.

examples/ch77/jmp_leak.c

/* longjmp 뒤에 포인터가 옛 값으로 되돌아가면 — 누수와 이중 해제의 경로. */
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>

static jmp_buf  env;
static void    *recorded;       /* malloc 이 실제로 돌려준 주소(정적이라 안전) */

[[noreturn]] static void fail(void) { longjmp(env, 1); }

int main(void)
{
    char          *risky = NULL;   /* 자동 + 비 volatile — 위험한 자리 */
    char *volatile safe  = NULL;   /* 포인터 자체를 volatile 로 */

    if (setjmp(env) == 0) {
        risky    = malloc(64);
        safe     = risky;
        recorded = risky;
        if (!risky) return 1;
        puts("64 bytes taken with malloc. Then something fails deep down.");
        fail();
    }

    /* 되돌아왔다. 두 변수가 같은 주소를 가리키고 있는가? */
    printf("is risky still the real allocation address? %s\n",
           (void *)risky == recorded ? "yes" : "no - it snapped back to the old value");
    printf("is safe  still the real allocation address? %s\n",
           (void *)safe  == recorded ? "yes" : "no - it snapped back to the old value");

#ifdef __OPTIMIZE__
    puts("\n(this build has optimization on. Notice that risky went back to the null it held at setjmp.");
    puts(" free(risky) then frees a null pointer, does nothing, and the 64 bytes leak for good.)");
#else
    puts("\n(this build has optimization off. Both survived, but risky is not guaranteed to -");
    puts(" build the same code with -O2 and risky goes back to null. We checked.)");
#endif

    free((void *)safe);            /* volatile 쪽으로 푼다 — 이것만이 계약 안 */
    puts("freed exactly once with free(safe).");
    return 0;
}

실행 결과

64 bytes taken with malloc. Then something fails deep down.
is risky still the real allocation address? yes
is safe  still the real allocation address? yes

(this build has optimization off. Both survived, but risky is not guaranteed to -
 build the same code with -O2 and risky goes back to null. We checked.)
freed exactly once with free(safe).

-O0에서는 둘 다 살아남지만, 같은 코드를 -O1 이상으로 빌드하면 비 volatile 쪽이 setjmp 시점의 널로 되돌아간다(직접 확인했다). 그러면 free(risky)는 널을 풀어 아무 일도 하지 않고, 64바이트는 영영 샌다 — 정적 변수의 사본을 지우고 ASan으로 확인하면 “Direct leak of 64 byte(s)”로 잡힌다.

정리 코드가 있는데도 누수가 난다. 눈으로 코드를 읽어서는 보이지 않고, 최적화 수준을 올린 배포 빌드에서만 나타난다. 이 무늬의 사고 중 가장 진단하기 어려운 것이다.

반대 방향도 있다. 옛 값이 널이 아니라 이미 푼 주소라면 이중 해제가 된다.

반례. 옛 주소로 되돌아가 두 번 푸는 코드

char *p = malloc(64);
if (setjmp(env) == 0) {
    free(p);            /* 한 번 풀고 */
    p = malloc(128);    /* 새로 잡는다 */
    work(p);            /* 여기서 longjmp */
}
free(p);                /* p 가 옛 주소라면 — 이미 푼 것을 또 푼다 */

45장에서 본 이중 해제가 그대로 재현된다. 할당자의 장부가 깨지므로, 그 뒤의 모든 할당이 오염된다.

규율은 하나다 — longjmp를 건너 살아남아야 하는 포인터는 지역 자동 변수에 두지 않는다. volatile을 붙이거나, 정적 변수나 문맥 구조체 안에 둔다. 다음 시연의 load_image가 그렇게 한다.

examples/ch77/jmp_error.c

/* 예외 흉내 — libjpeg 계열이 쓰는 오류 처리 무늬, 그리고 그 대가. */
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* ── libjpeg 의 error manager 를 줄여 옮긴 모양 ──────────────────
   구조체 안에 jmp_buf 를 넣어 두고, 깊은 곳에서 오류가 나면 그리로 뛴다.
   실제 libjpeg 의 setup_error_handler / png_jmpbuf 가 이 무늬다. */
typedef struct {
    jmp_buf env;
    char    message[64];
    int     code;
} error_ctx;

static error_ctx *current;          /* 지금 활성인 문맥(전역 하나) */

[[noreturn]] static void throw_error(int code, const char *msg)
{
    snprintf(current->message, sizeof current->message, "%s", msg);
    current->code = code;
    longjmp(current->env, 1);       /* 호출자의 setjmp 자리로 */
}

/* 깊은 곳의 함수들 — 오류를 값으로 돌려주지 않는다 */
static int parse_header(const char *s)
{
    if (strncmp(s, "IMG", 3) != 0) throw_error(2, "the magic number does not match");
    return 3;
}

static int parse_size(const char *s)
{
    int n = atoi(s);
    if (n <= 0)      throw_error(3, "the size is zero or less");
    if (n > 1000000) throw_error(4, "the size is too large");
    return n;
}

/* ── 흐름을 감싸는 쪽 ──────────────────────────────────────────── */
static int load_image(const char *text, char *err, size_t errcap)
{
    error_ctx ctx = { .code = 0 };
    error_ctx *saved = current;
    current = &ctx;

    /* 여기서 자원을 잡는다 — longjmp 로 돌아오면 *직접* 풀어야 한다 */
    void *buffer = malloc(1024);
    if (!buffer) { current = saved; return -1; }

    volatile int result;            /* longjmp 를 건너 살아남는다 */
    if (setjmp(ctx.env) == 0) {
        int off = parse_header(text);
        int n   = parse_size(text + off);
        printf("  ok: size %d\n", n);
        result = n;
    } else {
        snprintf(err, errcap, "%s (code %d)", ctx.message, ctx.code);
        result = -1;
    }

    free(buffer);                   /* 두 경로 모두 여기를 지난다 — 설계의 핵심 */
    current = saved;
    return result;
}

int main(void)
{
    const char *cases[] = { "IMG640", "BMP640", "IMG0", "IMG9999999" };
    for (size_t i = 0; i < sizeof cases / sizeof *cases; i++) {
        char err[96] = "";
        printf("input \"%s\":\n", cases[i]);
        int rc = load_image(cases[i], err, sizeof err);
        if (rc < 0) printf("  failed: %s\n", err);
    }

    puts("\nthree conditions make this pattern work:");
    puts("  ① every release of a resource sits in *one place*, inside the function that called setjmp");
    puts("  ② locals that must survive the longjmp are declared volatile");
    puts("  ③ longjmp happens only while the function that called setjmp is still alive");
    return 0;
}

실행 결과

input "IMG640":
  ok: size 640
input "BMP640":
  failed: the magic number does not match (code 2)
input "IMG0":
  failed: the size is zero or less (code 3)
input "IMG9999999":
  failed: the size is too large (code 4)

three conditions make this pattern work:
  ① every release of a resource sits in *one place*, inside the function that called setjmp
  ② locals that must survive the longjmp are declared volatile
  ③ longjmp happens only while the function that called setjmp is still alive

시연의 load_image가 그 구조다. mallocsetjmp 에서 하고, free는 성공·실패 두 경로가 모두 지나는 자리에 한 번만 둔다. 깊은 곳의 parse_header·parse_size는 자원을 잡지 않고 오직 뛰기만 한다.

77.6 실제로 무엇이 이것으로 지어졌는가

이 장치는 오늘날 새 코드에서 권하지 않지만, 이미 지어진 것은 아주 많다.

무엇어떻게 쓰는가왜 그렇게 했는가
libjpegstruct jpeg_error_mgrjmp_buf를 넣고, 오류 시 error_exit에서 뛴다디코딩이 깊은 재귀라 반환값 사슬이 너무 길다
libpngpng_jmpbuf(png_ptr) 매크로로 같은 무늬같은 이유. C API 로 예외 비슷한 것을 흉내
Lua 인터프리터LUAI_THROW/LUAI_TRY가 C 빌드에서 longjmp로 구현된다스크립트의 error()를 호스트 언어로 옮겨야 한다
일부 코루틴 구현setjmp로 문맥을 저장하고 스택을 바꿔치기표준만으로 문맥 전환을 흉내 내려면 이 길뿐
테스트 프레임워크단언 실패 시 그 시험만 중단하고 다음으로한 시험의 실패가 전체를 멈추면 안 된다

표 77.10

실제 사례. libjpeg 의 오류 관리자 — 왜 이 모양이 되었나

libjpeg 를 쓰는 코드는 거의 언제나 이렇게 시작한다.

struct my_error_mgr { struct jpeg_error_mgr pub; jmp_buf setjmp_buffer; };

static void my_error_exit(j_common_ptr cinfo) {
    struct my_error_mgr *err = (struct my_error_mgr *)cinfo->err;
    longjmp(err->setjmp_buffer, 1);
}

라이브러리의 기본 동작은 오류가 나면 exit 이었다. 라이브러리가 응용 프로그램을 죽이는 셈이라, 이미지 하나가 깨졌다고 편집기 전체가 종료되는 일이 벌어진다. 그래서 libjpeg 은 “오류가 났을 때 부를 함수”를 사용자가 갈아 끼울 수 있게 열어 두었고, 그 자리에서 되돌아올 유일한 방법이 longjmp 였다.

이 사례가 보여 주는 것은 설계의 순서다. 깊은 호출 사슬 + C API + 라이브러리가 프로세스를 죽이면 안 됨 — 이 셋이 겹치면 setjmp/longjmp 말고는 선택지가 거의 없었다. 오늘날 새로 설계한다면 51장의 “실패는 값” 쪽을 택하겠지만, 1990년대의 제약 안에서는 합리적인 결정이었다.

77.6.1 이들은 기억을 어떻게 감당하는가 — 답이 전부 같다

앞의 표를 다시 읽되, 이번에는 「뛴 뒤에 잡아 둔 기억은 누가 푸는가」만 본다.

무엇누가 푸는가장치
libjpegjpeg_destroy_* 호출 한 번라이브러리가 자체 기억 관리자로 모든 할당을 풀에 묶어 둔다
libpngpng_destroy_read_struct 한 번같은 방식
Lua쓰레기 수집기모든 할당이 인터프리터 상태에 등록되어 있다
PostgreSQL오류를 받은 자리에서 기억 문맥을 리셋질의·트랜잭션마다 아레나
시험 프레임워크시험 하나가 끝나면 통째로시험별 아레나, 또는 아예 프로세스 분리

표 77.11

공통점이 보인다 — 전부 아레나다. longjmp가 정리해 주지 않는다는 결함을, 이들은 “개별 free를 하지 않아도 되는 구조”로 우회했다. 잡은 것을 하나로 묶어 두고, 뛰어 돌아온 자리에서 그 묶음 하나만 버린다.

그래서 결론이 하나 더 붙는다. 아레나 없이 setjmp를 쓰면 누수는 시간문제다. 제12부의 아레나는 이 무늬를 정당하게 만드는 전제 조건이기도 하다.

실제 사례. PostgreSQL 의 `PG_TRY`/`PG_CATCH`

PostgreSQL 은 이 장치로 예외와 거의 같은 것을 만들어 쓴다. PG_TRY·PG_CATCH 는 매크로이고, 속은 sigsetjmp와 전역 예외 스택이다. 깊은 곳에서 오류를 보고하면 가장 가까운 PG_CATCH로 뛴다.

그런데 질의 하나가 잡는 기억은 수천 조각이다. 그것을 어떻게 감당하는가 — 기억 문맥(memory context)이다. 모든 할당이 어느 문맥에 속하고, 오류를 받은 자리에서 그 문맥을 리셋하면 수천 조각이 한 번에 사라진다. 아레나를 언어 장치 처럼 쓴 셈이다.

그리고 이 소스에는 앞 절의 규칙이 규약으로 적혀 있다 — PG_TRY 블록에서 값이 바뀌고 PG_CATCH에서 쓰이는 지역 변수에는 volatile을 붙일 것. 대규모 C 프로그램이 이 장치를 안전하게 쓰려면 무엇이 필요한지를 그대로 보여 주는 목록이다: 아레나 하나, volatile 규약 하나, 그리고 뛰는 자리를 매크로로 묶어 사람이 직접 longjmp를 쓰지 않게 하는 것.

문. alloca나 가변 길이 배열은 어떻게 되는가?

답. 그것만은 자동으로 정리된다. 스택에서 잡은 것이므로 스택 포인터가 되감기면 함께 사라진다 — 뛰어넘은 프레임의 VLA 는 새지 않는다.

다만 표준이 한 가지를 못박아 둔다. 가변 변경 타입(VLA 등) 식별자의 스코프 밖으로 나가게 되는 longjmp는 정의되지 않은 동작이다. 구현이 그 자리를 정리할 기회를 잃기 때문이다. 그래서 규칙은 “VLA 가 살아 있는 블록을 가로질러 뛰지 않는다”가 된다.

기억의 관점에서 정리하면 이렇다 — 스택에서 잡은 것은 공짜로 정리되고, 더미 에서 잡은 것은 아무도 정리해 주지 않는다. 위의 라이브러리들이 전부 아레나로 간 이유가 이 한 줄이다.

77.7 POSIX의 sigsetjmp/siglongjmp

유닉스 계열에는 짝이 하나 더 있다.

int  sigsetjmp(sigjmp_buf env, int savemask);
[[noreturn]] void siglongjmp(sigjmp_buf env, int val);

차이는 신호 마스크(76장)를 함께 저장할지다. savemask가 0이 아니면 지금의 마스크를 함께 저장하고, siglongjmp가 그것을 복원한다.

왜 필요한가. 신호 처리기 안에서 longjmp로 빠져나오는 코드를 생각해 보자. 처리기가 도는 동안에는 그 신호가 막혀 있는데, 표준 longjmp로 뛰어나오면 막힌 채로 남을 수 있다 — 그 뒤로 같은 신호가 영영 오지 않는다. sigsetjmp는 그 구멍을 막는다.

흔한 오해. “신호 처리기에서 longjmp로 빠져나오면 된다”

널리 쓰이지만 위험한 무늬다. 두 가지가 겹친다.

첫째, 마스크 문제 — 위에서 본 그대로다. 유닉스에서는 sigsetjmp 짝을 써야 한다.

둘째, 그리고 더 근본적으로, 어디서 끊겼는지 모른다. 신호는 malloc이나 printf의 중간에서도 온다(76장). 그 상태에서 뛰어나오면 그 자료구조는 반쯤 고쳐진 채로 남는다. 뛰어나온 뒤에 다시 malloc을 부르면 그때 무너진다.

그래서 이 무늬가 그나마 정당한 자리는 좁다 — 뛴 다음 곧바로 끝내거나 (_Exit), 표준 라이브러리를 더는 쓰지 않는 경로로 가는 경우다. 오래 도는 프로그램에서 “타임아웃을 신호로 걸고 longjmp로 빠져나오는” 무늬는, 겉보기에 잘 돌다가 드물게 무너진다.

77.8 오늘날의 자리 — 언제 쓰고, 무엇으로 대신하는가

하고 싶은 일권하는 방법
깊은 곳의 실패를 위로 올리기반환값으로 올린다(51장). 중간 층이 지루해도 이 쪽이 안전하다
함수 하나 안의 여러 실패 경로 정리goto cleanup 하나로 모은다 — 리눅스 커널의 관행
자원 정리를 잊지 않기C23의 [[maybe_unused]]가 아니라 구조로 — 잡는 자리와 푸는 자리를 한 함수에
파서·인터프리터의 깊은 탈출setjmp가 정당한 몇 안 되는 자리. 단 자원 규율을 지킨다
스레드를 넘나드는 탈출불가능하다 — longjmp는 같은 스레드 안에서만

표 77.12

goto cleanup 무늬를 굳이 다시 적어 두는 이유는, 실무에서 setjmp로 하려던 일의 대부분이 사실 이것으로 충분하기 때문이다.

int work(void) {
    int rc = -1;
    FILE *f = fopen("data", "r");
    if (!f) goto out;
    char *buf = malloc(1024);
    if (!buf) goto close;
    if (parse(f, buf) != 0) goto free_buf;
    rc = 0;
free_buf: free(buf);
close:    fclose(f);
out:      return rc;
}

한 함수 안이라면 이 편이 낫다. 흐름이 눈에 보이고, 정리 순서가 코드에 적혀 있고, 컴파일러가 검사해 준다. setjmp가 필요한 것은 여러 함수를 건너뛸 때 뿐이다.

복습 정리

기억할 것요점
정체스택을 통째로 되감는 탈출구 — 예외가 아니다
setjmp매크로. 직접 부르면 0, 뛰어 돌아오면 val(0이면 1)
문맥 제약제어식·상수 비교·!·수식문 넷뿐
longjmp의 전제저장한 함수가 아직 살아 있고, 같은 스레드
jmp_buf배열 타입. 피호출자 보존 레지스터 여덟 칸 + 신호 마스크 자리
레지스터보존 레지스터만 복원된다 → 스택에 있던 변수는 살고, 레지스터에 있던 변수는 옛 값
포인터되돌아간 포인터로 free하면 누수 또는 이중 해제 — 실측으로 확인
volatile 규칙자동·비 volatile·중간에 바뀐 변수는 보장 없음(실측으로 확인)
자원아무도 정리하지 않는다 — 잡고 푸는 자리를 한 함수에 모은다
오늘의 선택대개 반환값과 goto cleanup. 깊은 파서에서만 정당하다

표 77.13

흐름을 끊고 뛰는 두 장치를 보았다 — 밖에서 들어오는 신호와, 안에서 뛰는 비지역 점프다. 다음 장은 표준이 새로 보탠 것들과 “안전한 함수”를 둘러싼 오래된 논쟁이다.