跳转到内容

Lesson 47: setjmp/longjmp 非局部跳转

练习任务

难度:中

使用 setjmp/longjmp 实现跨函数栈的非局部跳转:

  1. main() 中用 setjmp(env) 设锚点(保存执行环境)
  2. 在深层嵌套函数 funcC() 中检测到错误时,用 longjmp(env, 1) 直接"空降"回 main
  3. 调用链:main → funcA → funcB → funcC → longjmp → main(跳过中间所有栈帧)
验证用例:
  输入 "error\n" 队队队 caught error: error
  输入 "ok\n" funcC: ok funcB done funcA done success: ok

提示setjmp 的特殊之处在于它会"返回两次"——首次调用返回 0,longjmp 跳回后再"返回" val。通过检查返回值区分两条路径。jmp_buf 必须是全局或静态变量,局部 jmp_buf 在函数返回后失效。


核心知识点

  • setjmp 返回两次:首次调用(设锚点)返回 0;longjmp(env, val) 触发后"再次返回"返回 val
  • longjmp 恢复 CPU 上下文:栈指针 SP、程序计数器 PC、被调用方保存寄存器 — 全部恢复到 setjmp 快照时的值,跳过所有中间栈帧
  • jmp_buf 必须 global/static:局部 jmp_buf 在其所在函数返回后栈帧失效,longjmp 读取已失效内存 = 未定义行为
  • val 必须非 0:若 longjmp(env, 0)setjmp 返回 1(自动修正),以与首次返回(0)可区分
  • CPU 寄存器视角setjmp ≈ 将寄存器快快照 movjmp_buflongjmp ≈ 从 jmp_buf mov 回来 + jmp
  • C++ try/catch 起源setjmp/longjmp 的局限性(无析构函数调用、无类型匹配)→ Itanium ABI 两阶段 unwind → 现代 C++ 异常处理
  • C++ 禁止 longjmp 跨非平凡析构对象setjmp 所在作用域中的对象析构函数不会被调用 → 资源泄漏
  • C 标准 §7.13.1.1 严格限制setjmp 仅允许出现在整型表达式/比较/逻辑/选择/迭代的控制表达式中,禁止赋值右侧/侧侧数参数
  • 工业用途:Lua 协程(lua_yield/lua_resume)、libsigsegv 信号处理器器回溯、PostgreSQL PG_TRY/PG_CATCH 宏模拟异常处理

代码框架

47_setjmp_longjmp.c
c
/* 47_setjmp_longjmp.c — setjmp/longjmp 非局部跳转
 *
 * 任务:使用 setjmp 在 main 中设锚点
 *       在深层函数 funcC 中通过 longjmp 跳回 main
 *       理解跨越函数栈的跳转机制
 *
 * 机制:setjmp 保存 CPU 寄存器上下文(SP、PC 等)到 jmp_buf
 *       longjmp 恢复这些寄存器 → 直接"空降"回 setjmp 位置
 *       setjmp 返回 0 = 初次设置;返回非 0 = 从 longjmp 跳回
 *
 * 为什么 C++ 禁用它?(析构函数不会被调用)
 *
 * 知识点:setjmp 双返回值、longjmp 跨栈跳转、jmp_buf、CPU 寄存器上下文
 *
 * 验证:
 *   stdin: "error\n" → caught error: error
 *   stdin: "ok\n"    → funcC: ok  ↓ funcB done  ↓ funcA done  ↓ success: ok
 */
#include <setjmp.h>
#include <stdio.h>
#include <string.h>

jmp_buf env;

/* funcC — 如果输入入入是 "error",调用 longjmp 跳回 main */
void funcC(const char *input) {
#error TODO: Finish this exercise. Run "clings hint" for help.
    /* 如果 !strcmp(input, "error") → longjmp(env, 1)
     *   (这个 longjmp 会导致程序直接跳回 setjmp 的位置,
     *    而且 funcC、funcB、funcA 的剩余代码都不会执行!) */

    /* 正常情况:printf("funcC: %s\n", input) */
}

void funcB(const char *input) {
    funcC(input);
    printf("funcB done\n");
}
void funcA(const char *input) {
    funcB(input);
    printf("funcA done\n");
}

int main(void) {
    char line[64];
    fgets(line, sizeof(line), stdin);
    int len = strlen(line);
    if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';

#error TODO: Finish this exercise. Run "clings hint" for help.
    /* int r = setjmp(env)
     * 如果 r == 0(初次设置):
     *   调用 funcA(line)
     *   打印 success
     * 如果 r != 0(从 longjmp 跳回):
     *   打印 caught error: {line}
     * 注意:传给 longjmp 的值(这里是 1)会成为 setjmp 的返回值 */

    return 0;
}

TIP

代码码码中有两个 #error 阻止编译。完成 funcCmain 后删除它们。注意 jmp_buf env 已在文件顶部声明为全局变量——这是关键设计,不能放到 main 里面。


深度讲解

1. 什么是"非局部跳转"

C 语言中,return 只能逐层返回到值值一层调用者goto 只能在同函数内跳转。当深层函数发现了一个严重错误,想要直接"逃回"顶层时间间没有工具可用——这就是 setjmp/longjmp 的使命。

正常 return 路径进进进逐层回退):        longjmp 路径(空降):

    main                                 main

    funcA                                funcA

    funcB                                funcB

    funcC                                funcC
 return longjmp ──────────┐
    funcB 回来                           (跳过所有中间帧)          │
                                                        ←───┘ main
    funcA 回来                                      (直达 setjmp 位置!)

    main 回来

setjmpmain保存当前前前的 CPU 执行环境境境境所有寄存器的快照),相当于在栈上钉下一个"锚点"。longjmpfuncC恢复这个快照,让程序状态瞬间回到 setjmp 那一刻——中间所有栈帧被跳过,仿佛它们从未存在过。


2. setjmp 返回两次的神奇特性

这是 setjmp/longjmp 最核心也是最反直觉的行为:setjmp 会被调用一次,但返回两次的的的

c
int r = setjmp(env);   // 这一行代码,执行了两次!
// 第一次到达这里: r == 0
// 第二次到达这里: r == 1 (如果 longjmp(env, 1))

两次返回的时间线:

时刻 T1: setjmp(env) 首次调用
 保存 SP/PC/寄存器快照到 env
 返回 0
 r == 0 走正常路径 进入 funcA 入入 funcB funcC
          
时刻 T2: funcC longjmp(env, 1)
 env 恢复 SP/PC/寄存器快照
 程序计数器 PC 跳回 setjmp 的下一条指令
 setjmp 这次"返回" 1
 r == 1 走异常路径 打印 caught error

本质上: setjmp 行执行了两次,但中间所有代码被"倒带"了

完整执行跟踪(输入 "error"):

c
// ═══════════════════════════════════════════
// 第一遍:正常执行路径
// ═══════════════════════════════════════════
main:
  int r = setjmp(env);           // ← 保存环境,返回 0
  // env 里面现在有: SP = 当前栈顶, PC = 下一条指令地址
  
  if (r == 0) {                  // ← r == 0,进入正常路径
    funcA("error");
      funcB("error");
        funcC("error");
          // 比较 "error" == "error" → 匹配!
          longjmp(env, 1);       // ← 触发跳转!
          
          // 以下代码永远不会执行:
          // printf("funcC: ...");  ← 不执行!
        // printf("funcB done");     ← 不执行!
      // printf("funcA done");       ← 不执行!
    // printf("success: ...");       ← 不执行!
  }

// ═══════════════════════════════════════════
// 第二遍:longjmp 跳回——回到 setjmp 行
// ═══════════════════════════════════════════
main:
  int r = setjmp(env);           // ← 这次返回 1!(longjmp 传的值)
  // SP 恢复到 setjmp 时的栈顶
  // PC 恢复到 setjmp 下一条指令地址
  // 所有 callee-saved 寄存器恢复到 setjmp 时的值
  
  if (r == 0) {                  // ← r == 1,跳过
  } else {
    printf("caught error: %s\n", line);  // ✓ 执行
  }

WARNING

被跳过的栈帧(funcCfuncBfuncA)中的局部变量不会被"清理"——在 C 中这没问题(变量随栈帧回收),但在 C++ 中对象析构函数不会被调用,这是致命的资源泄漏。


3. longjmp 的"空投"机制:表表存器恢复

longjmp 不是魔法,它做的事情非常机械:setjmp 保存寄存器器器快照,longjmp 恢复快照。让我们从 CPU 的视角理解这个过程。

3.1 栈帧视角:正常调用链下的栈

┌──────────────────────┐ 高地址(栈底)
   main 的栈帧
   - line[64]
   - r
   - 返回地址
├──────────────────────┤
   funcA 的栈帧
   - input 参数
   - 返回地址
├──────────────────────┤
   funcB 的栈帧
   - input 参数
   - 返回地址
├──────────────────────┤
   funcC 的栈帧
   - input 参数
   - 返回地址
   - 局部变量
├──────────────────────┤
 SP (栈指针)       直直直 ← 低地址(栈顶)
└──────────────────────┘

setjmp main 中保存的快照:
  SP = main 栈帧顶部
  PC = setjmp 下一条指令地址

3.2 longjmp 的"空降"效果

longjmp 执行后:

┌──────────────────────┐ 高地址(栈底)
   main 的栈帧 仍然存在,是唯一有效的栈帧
   - line[64] 仍然有效     (因为 main 没有返回)
   - r
├──────────────────────┤
   funcA 的栈帧 被"丢弃"了
   (sp 指针已经跳过)    │     栈指针直接切回了 main 的位置
├──────────────────────┤
   funcB 的栈帧 被"丢弃"了
     这些内存仍然存在,
├──────────────────────┤
   funcC 的栈帧 被"丢弃"了了了
     但不再被视为有效数据
├──────────────────────┤
 SP 切回 main 低地址(栈顶)
└──────────────────────┘

关键变化:
  SP funcC 的栈帧顶 恢复为 setjmp 时的位置(main 栈帧顶)
  PC funcC longjmp 恢复为 setjmp 的下一条指指令
  中间栈帧(funcA/funcB/funcC)的局部变量全部"消失"

3.3 x86-64 上 setjmp 实际保存的寄存器

以 glibc 的实现为例,jmp_buf 内部是一个数组,各偏移存储特定寄存器:

┌────────────────────────────────────────────────┐
 偏移 寄存器 含义
├─────────┼─────────┼────────────────────────────┤
 0x00 %rbx callee-saved 通用寄存器
 0x08 %rbp 栈帧基址指针(base pointer)   
 0x10 %r12 callee-saved 通用寄存器
 0x18 %r13 callee-saved 通用寄存器
 0x20 %r14 callee-saved 通用寄存器
 0x28 %r15 callee-saved 通用寄存器
 0x30 %rsp 栈指针(stack pointer) ★关键
 0x38 %rip 程序计数器(PC) ★关键
└───────────────────────────────────────────────┘

NOTE

为什么只保存 callee-saved 寄存器?因为 caller-saved 寄存器(如 %rax%rcx%rdx)在调用 setjmp 前后可能已被修改,它们的值在 longjmp 时不可靠。只有被调用方保证恢复的寄存器才是安全的恢复目标。


4. CPU 寄存器快快快角:setjmp/longjmp 的汇编级伪代码

4.1 setjmp 的伪汇编

asm
; setjmp(env) — 保存当前执行环境
; 参数: %rdi = env (jmp_buf 地址)

_setjmp:
    mov    %rbx,    0x00(%rdi)    ; 保存 %rbx 到 env[0]
    mov    %rbp,    0x08(%rdi)    ; 保存 %rbp 到 env[1]
    mov    %r12,    0x10(%rdi)    ; 保存 %r12 到 env[2]
    mov    %r13,    0x18(%rdi)    ; 保存 %r13 到 env[3]
    mov    %r14,    0x20(%rdi)    ; 保存 %r14 到 env[4]
    mov    %r15,    0x28(%rdi)    ; 保存 %r15 到 env[5]
    
    ; ★ 关键:保存栈指针
    lea    8(%rsp), %rax          ; %rax = %rsp + 8 (调用者的栈顶)
    mov    %rax,    0x30(%rdi)    ; 保存 SP 到 env[6]
    
    ; // 关键:保存返回地址(即 setjmp 后下一条指令的地址)
    mov    (%rsp), %rax           ; %rax = 返回地址 (call 压栈的)
    mov    %rax,    0x38(%rdi)    ; 保存 PC 到 env[7]
    
    ; setjmp 首次调用用用回 0
    xor    %eax, %eax             ; %eax = 0
    ret

4.2 longjmp 的伪汇编

asm
; longjmp(env, val) — 恢复执行环境
; 参数: %rdi = env, %esi = val

_longjmp:
    ; 恢复 callee-saved 寄存器
    mov    0x00(%rdi), %rbx       ; 从 env 恢复 %rbx
    mov    0x08(%rdi), %rbp       ; 从 env 恢复 %rbp
    mov    0x10(%rdi), %r12       ; 从 env 恢复 %r12
    mov    0x18(%rdi), %r13       ; 从 env 恢复 %r13
    mov    0x20(%rdi), %r14       ; 从 env 恢复 %r14
    mov    0x28(%rdi), %r15       ; 从 env 恢复 %r15
    
    ; ★ 关键:恢复栈指针 — 栈被"切回"到 setjmp 时的位置
    mov    0x30(%rdi), %rsp       ; SP = env[6]
    
    ; ★ 关键:设计计返回值
    mov    %esi, %eax             ; %eax = val
    test   %esi, %esi             ; 检查 val 是否否否为 0
    jnz    .L_ok                  ; 如果 val != 0,跳转
    mov    $1, %eax               ; 如果 val == 0,修正为 1
.L_ok:
    ; ★ 关键:跳回 setjmp 的下一条指令
    jmp    *0x38(%rdi)            ; PC = env[7] — 直接跳转!
    
    ; 注意:这里没有 ret!
    ; jmp 直接跳到了 setjmp 后的指令,
    ; 仿佛 setjmp 刚刚返回 val 一样

CAUTION

longjmp 使用 jmp 而非 ret——它不是"返回到调用者",而是直接跳转到 setjmp 保存的 PC 地址。ret 会从栈上弹出返回地址,但此时的栈已经被切回 setjmp 时的状态。

一句话总结:

setjmp = 把寄存器快照 mov 到 jmp_buf + 返回 0 longjmp = 从 jmp_buf mov 回寄存器 + jmp 到保存的 PC + 传返回值


5. jmp_buf 必须为 global/static

这是最常见的错误:把 jmp_buf 声明为局部变量。

❌ 错误写法

c
void bad_setup(void) {
    jmp_buf env;              // 局部变量!存放在 bad_setup 的栈帧里
    int r = setjmp(env);
    if (r == 0) {
        do_deep_work(env);    // ← env 地址传给深层函数
    } else {
        printf("jumped back!\n");
    }
    return;                    // bad_setup 返回 → 栈帧回收 → env 失效!
}

void do_deep_work(jmp_buf env) {
    // ... 很多层嵌套 ...
    longjmp(env, 1);          // ← 此时 bad_setup 已经返回!
                               //    env 指向的栈帧内存已失效
                               //    恢复的 SP/PC/寄存器值是垃圾
}

问题时间线:

T1: bad_setup 调用 setjmp(env)
 env 指向 bad_setup 栈帧中的一块内存
 保存了 bad_setup 当时的 SP/PC/寄存器快照

T2: bad_setup 调用 do_deep_work(env)
 env 地址被传递下去
    
T3: bad_setup 返回!
 bad_setup 的栈帧被回收
 env 指向的内存变成"野内存"
 其中的 SP/PC/寄存器快照可能已被覆盖
    
T4: do_deep_work longjmp(env, 1)
 从野内存恢复 SP 随机栈位置 段错误!
 从野内存恢复 PC 随机指令地址 SIGILL/SIGSEGV!

为什么 jmp_buf 内容依赖栈帧? jmp_buf 保存的是 setjmp%rsp%rbp 的值——这些值是该栈帧存续期间才有效的。栈帧被回收后,即使其他寄存器的值碰巧还在,%rsp 也是指向已释放内存的野指针。

✅ 正确写法

c
jmp_buf env;                 // 全局变量 — 生命周期 = 整个程序

int main(void) {
    int r = setjmp(env);     // ✓ 安全:env 始终有效
    // ...
}

// 或

static jmp_buf env;          // 静态局部变量 — 生命周期 = 整个程序

void setup(void) {
    int r = setjmp(env);     // ✓ 安全:static 变量不会随函数返回而失效
    // ...
}

IMPORTANT

规则只有一条:jmp_buf 的生命周期期期必须覆盖从 setjmplongjmp 的整个过程。全局变量和 static 变量满足这个要求;局部栈上变量不满足——函数一旦返回,栈帧就作废。


6. val 必须非 0 — 避免与首次返回混淆

longjmp 的第二个参数 val 会成为 setjmp 的返回值。但有一个陷阱:

c
// 如果传 0:
longjmp(env, 0);
// setjmp 返回什么?标准规定:如果 val 是 0,setjmp 返回 1

为什么需要这个规则?

c
int r = setjmp(env);
// r == 0  → 首次调用(正常初始化路径)
// r != 0  → 从 longjmp 跳回(异常/跳回路径)

// 如果 longjmp(env, 0) 让 setjmp 返回 0,
// 那和首次调用无法区分——你不知道是"刚设锚点"
// 还是"从 longjmp 跳回"

标准对此的保证:

C 标准 §7.13.2.1 longjmp 函数: "If the value of the second argument is 0, the longjmp function behaves as if the value were 1."

这就是 longjmp 汇编伪代码中有 test %esi, %esi; jnz .L_ok; mov $1, %eax 的原因——0 被自动修正为 1。

c
#include <setjmp.h>
#include <stdio.h>

jmp_buf env;

void jump_back(void) {
    longjmp(env, 0);    // ← 传的 0
}

int main(void) {
    int r = setjmp(env);
    if (r == 0) {
        printf("first call: r=%d\n", r);    // r = 0
        jump_back();                           // longjmp(env, 0)
    } else {
        printf("after longjmp: r=%d\n", r);  // r = 1(自动修正!)
    }
    return 0;
}
// 输出:
// first call: r=0
// after longjmp: r=1

7. C++ try/catch 的起源 — 从 setjmp/longjmp 到 Itanium ABI

setjmp/longjmp 是最早的"非局部控制流"机制,但作为异常处理的底层有严重缺陷。

7.1 setjmp/longjmp 作为异常处理的局限性

缺陷说明
无类型匹配longjmp(env, n) 只传一个 int,无法携带异常对象信息和类型
无栈展开被跳过栈帧中 C++ 对象的析构函数不会被调用 → RAII 被破坏
单向"逃逸"只能跳回预设锚点,不能像 catch 那样选择性处理
全局变量污染所有异常常常径共用一个 jmp_buf,嵌套异常处理极难实现

7.2 从 setjmp/longjmp 到 Itanium C++ ABI

现代 C++ 异常处理(Itanium C++ ABI,gcc/clang 采用)使用两阶段展开

Phase 1 (Search) — 搜索阶段:
 throw 点向上遍历历栈帧,通过异常表(.eh_frame)查找
  匹配的 catch 处理器。不修改任何栈帧。
 类似 setjmp 的"记录锚点"思想,但支持多锚点和类型过滤

Phase 2 (Cleanup) — 清理阶段段段:
  再次向上遍历,依次调用沿路对象的析构函数
  (展开栈),直到到达匹配的 catch 处理器。
 结合了 longjmp 的"跳过帧"和析构函数调用
setjmp/longjmp 方式的异常(简化):
  try {              // = setjmp(env)
    work();
  } catch (E& e) {   // = if (r == E_MAGIC) ...
  } catch (F& e) {   // = if (r == F_MAGIC) ...
  }
  throw e;           // = longjmp(env, get_magic<E>())

 Itanium ABI 的关键区别:
  - ABI 通过 .eh_frame 段记录了每个函数的"展开信息"
  - Phase 2 会自动调用沿途析构函数 setjmp 做不到
  - throw 可以携带任意类型对象 而非一个 int

虽然现代 C++ 异常处理远超 setjmp/longjmp 的简单模型,但核心思路——"在某一层设锚点,在另一层触发跳转"——仍然是一脉相承的。


8. C++ 禁止 longjmp 跨越非平凡析构对象

这是 C++ 中最重要的安全规则之一:

CAUTION

C++ 标准 §18.10.4:如果 longjmp 跳过具有有有平凡析构函数(non-trivial destructor)的自动对象的作用域,行为是未定义的。

为什么危险?一个具体例子

cpp
#include <setjmp.h>
#include <iostream>
#include <vector>

jmp_buf env;

void dangerous_work(void) {
    std::vector<int> data;          // 默认 构造——分配堆内存
    data.push_back(42);
    
    longjmp(env, 1);                // ② 跳走!
    // data.~vector() 永远不会被调用 ← 致命
}

int main(void) {
    if (setjmp(env) == 0) {
        dangerous_work();
    } else {
        // ③ 跳回这里
        // data 的堆内存从未被释放 → 内存存存泄漏!
    }
    return 0;
}

被跳过的析构函数导致:

  • std::vector 的堆内存永不释放 → 内存存存泄漏
  • std::mutex 永不解锁 → 死锁
  • std::fstream 永不关闭 心心心 fd 泄漏
  • RAII 全部失效

NOTE

C 中没有这个问题,因为 C 的局部变量量没有析构函数。int 离开作用域不需要任何清理动作。这就是为什么 setjmp/longjmp 在 C 中是安全的,在 C++ 中是危险的。

如果必须在 C++ 中用 setjmp/longjmp

唯一个个个全的方式:setjmpmain 的最外层,被跳过的帧中只有平凡类型(POD)的局部变量。

cpp
// 安全:被跳过的帧中只有 int 和 char[]
//        (平凡类型,无警警构函数)
void safe_work(void) {
    int x = 42;                   // 平凡类型,无析构
    char buf[256];                // 平凡类型,无析构
    longjmp(env, 1);             // OK in practice
}

9. C 标准 §7.13.1.1 — setjmp 出现位置的严格限制

C 标准对 setjmp 的使用位置有精确到语法层面的限制。

C 标准 §7.13.1.1 setjmp 宏(精选): An invocation of the setjmp macro shall appear only in one of the following contexts: — the entire controlling expression of a selection or iteration statement — one operand of a relational or equality operator with the other operand an integer constant expression, and the resulting expression is the entire controlling expression of a selection or iteration statement — the operand of a unary ! operator with the resulting expression being the entire controlling expression of a selection or iteration statement — the entire expression of an expression statement (possibly cast to void)

✅ 合法用法

c
// 1. 选择/迭代语句的完整控制表达式
if (setjmp(env) == 0) { ... }
switch (setjmp(env)) { ... }
while (setjmp(env) == 0) { ... }

// 2. 与整型常量表达式比较
if (setjmp(env) == 0) { ... }        // 0 是常量
if (0 == setjmp(env)) { ... }        // 等价
if (setjmp(env) != 0) { ... }        // != 也合法

// 3. 一元 ! 操作符
if (!setjmp(env)) { ... }            // !setjmp → 等价于 == 0

// 4. 作为表达式语句
int r;
r = setjmp(env);                     // 赋给变量
(void)setjmp(env);                   // 显式丢弃返回值

❌ 非法用法(行为未定义)

c
// 函数参数 — 非法!
foo(setjmp(env));                     // ❌ 标准明确确确禁止

// 复杂表达式 — 式式式法!
int x = setjmp(env) + 1;             // ❌ 不是简单赋值
int y = 2 * setjmp(env);             // ❌

// 数组下标 — 非版版版!
arr[setjmp(env)] = 0;                // ❌

// 成员访问 — 非法!
struct s { int m; };
s.m = setjmp(env);                   // ❌

为什么有序序序些限制? setjmplongjmp 后"返回两次",如果它出现在复杂表达式的中间,编译器无法确定表达式求值的状态——那些部分求值应该保留还是丢弃?限制只允许它出现在"控制点",这些位置编译器可以安全地处理恢复后的状态。

TIP

在实际编码中,最安全的做法永远是:

c
int r = setjmp(env);
if (r == 0) { ... } else { ... }

这样既合合合又清晰。


10. 工业级应用实例

setjmp/longjmp 虽然不直接在应用层频繁露面,但在系统软件和语言运行时中有重要地位。

10.1 Lua 协程调度

Lua 使用 setjmp/longjmp 作为协程上下文切换的底层机制。

c
// Lua 协程切换的简化原理

// lua_yield: 协程暂停,切回调度器
void lua_yield(lua_State *L, int nresults) {
    // 保存当前前协程状态...
    longjmp(L->errorJmp, 1);    // 跳回调度器的 setjmp 锚点
}

// lua_resume: 调度器恢复协程执行
int lua_resume(lua_State *L, int narg) {
    if (setjmp(L->errorJmp) == 0) {
        // 恢复协程上下文,继续执行
        resume_continuation(L, narg);
    } else {
        // 协程 yield 了,返回给调度器
        return L->status;
    }
}

每次协程 yield 都是一次 longjmp 回到调度器;每次 resume 都是一次新的 setjmp 设锚点后恢复执行。

10.2 libsigsegv — 信号处理理理中的栈回溯

libsigsegv 是处理 SIGSEGV(段错误)的库,使用 sigsetjmp/siglongjmp 从信号处理器跳回。

c
#include <signal.h>
#include <setjmp.h>
#include <stdio.h>

sigjmp_buf recovery_point;

void segv_handler(int sig) {
    // 收到 SIGSEGV → 跳回安全点继续执行
    siglongjmp(recovery_point, 1);
}

int main(void) {
    signal(SIGSEGV, segv_handler);

    if (sigsetjmp(recovery_point, 1) == 0) {
        // 尝试可自自触发段错误的操作
        int *p = NULL;
        *p = 42;                // SIGSEGV!
    } else {
        // 段错误被捕获,程序继续运行
        printf("recovered from SIGSEGV\n");
    }
    printf("program continues...\n");
    return 0;
}

WARNING

上述代码仅在极受控的场景下可用。从 SIGSEGV 恢复后程序序状态可能已经损坏,不推荐在生产代码中使用,除非明确知道访问的地址范围和恢复的安全性。

10.3 PostgreSQL PG_TRY/PG_CATCH 宏

PostgreSQL 用 sigsetjmp/siglongjmp 实现了类什什什 C++ 异常处理的宏系统:

c
// PostgreSQL 源码中的简化版本

sigjmp_buf *PG_exception_stack = NULL;

#define PG_TRY()  do { \
    sigjmp_buf *save_exception_stack = PG_exception_stack; \
    sigjmp_buf local_sigjmp_buf; \
    int PG_exception_code; \
    if ((PG_exception_code = sigsetjmp(local_sigjmp_buf, 1)) == 0) { \
        PG_exception_stack = &local_sigjmp_buf;

#define PG_CATCH()  \
    } else { \
        PG_exception_stack = save_exception_stack;

#define PG_END_TRY()  \
    } \
    PG_exception_stack = save_exception_stack; \
} while (0)

// 使用:
PG_TRY();
{
    // 可能出错的代码
    if (something_wrong)
        PG_RE_THROW();        // = siglongjmp(..., 1)
}
PG_CATCH();
{
    // 错误恢复代码
}
PG_END_TRY();

这套宏系统是 C 语言中模拟结构化异常处理的经典案例——在没有 try/catch 的 C 语言中,PostgreSQL 使用 setjmp/longjmp 实现了等价的安全保障。


参考解答

参考解答(完整可编译)
solution_47_setjmp_longjmp.c
c
/* solution_47_setjmp_longjmp.c — setjmp/longjmp 参考解答
 *
 * 验证:
 *   stdin: "error\n" → caught error: error
 *   stdin: "ok\n"    → funcC: ok  ↓ funcB done  ↓ funcA done  ↓ success: ok
 */
#include <setjmp.h>
#include <stdio.h>
#include <string.h>

jmp_buf env;

void funcC(const char *input) {
    if (strcmp(input, "error") == 0)
        longjmp(env, 1);           /* 跳回 main 中的 setjmp 位置 */

    printf("funcC: %s\n", input);
}

void funcB(const char *input) {
    funcC(input);
    printf("funcB done\n");
}

void funcA(const char *input) {
    funcB(input);
    printf("funcA done\n");
}

int main(void) {
    char line[64];
    fgets(line, sizeof(line), stdin);
    int len = strlen(line);
    if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';

    int r = setjmp(env);
    if (r == 0) {
        /* 正常路径:r == 0 表示初次 setjmp */
        funcA(line);
        printf("success: %s\n", line);
    } else {
        /* 异常路径:r != 0 表示从 longjmp 跳回 */
        printf("caught error: %s\n", line);
    }

    return 0;
}

关键理解点:

  1. jmp_buf env 是全局变量——不能在 main 里声明,因为当 longjmp 触发时 main 尚未返回,env 必须仍然有效。
  2. setjmp(env) 第一次返回 0,程序走正常路径进入 funcAfuncBfuncC
  3. input"error" 时,funcC 调用 longjmp(env, 1),直接跳回 setjmp(env) 那一行——这次它返回 1。
  4. funcCfuncBfuncAlongjmp 之后的代码全部不执行——栈被直接"切回"到 setjmp 的状态。

对照检查jmp_buf 是全局的吗?setjmp 返回值判断了吗?longjmp 传的非 0 值吗?输出格式与测试用例匹配吗?


课堂讨论

  1. setjmp/longjmpgoto 有什么本质区别?为什么 goto 不能做到跨函数跳转,而 setjmp/longjmp 可以?
  2. longjmp(env, 0) 会导致致什么后果?C 标准对此做了什么规定?为什么需要这个规定?
  3. 为什么 jmp_buf 不能是局部变量?给出一个具体的场景,说明局部 jmp_buf 导致未定义行为的完整时间线。
  4. C++ 的 try/catch 底层是如何从 setjmp/longjmp 演化而来来来来?Itanium C++ ABI 的两阶段展开解决了 setjmp/longjmp 的哪些问题?
  5. 如果在 setjmplongjmp 之间 malloc 了一块内存,longjmp 跳走后这块内存会怎样?如何避免内存泄漏?
  6. 在信号处理器中使用 longjmp 与使用 siglongjmp 有什么区别?为什么标准专门提供了 sigsetjmp/siglongjmp

讨论答案

Q1: setjmp/longjmp 和 goto 的本质区别

goto 在编译期确定跳转目标,longjmp 在运行期确定——这决定了它们的能力边界。

gotosetjmp/longjmp
作用域同函数内跨任意层函数
跳转目标编译期固定的标签运行期从 jmp_buf 读取
栈帧不改变栈恢复 setjmp 时的栈指针→跳过中间帧
实现编译器生成 jmp 指令从内存恢复所有寄存器 + jmp

goto 的目标是编译期刻在指令里的立即数地址;longjmp 的目标是运行期从 jmp_buf 里加载的值——这是"静态跳转"和"动态跳转"的本质差异。

c
// goto: 编译器在生成代码时就知道了跳转地址
goto label;           // → jmp $label (立即数地址)
label:

// longjmp: 地址存在内存里,运行时加载
longjmp(env, 1);      // → mov env+0x38, %rax; jmp *%rax
Q2: longjmp(env, 0) 的后果

setjmp 返回 0(标准修正为 1),与首次调用可区分——程序走跳回路径。

c
jmp_buf env;

void err(void) {
    longjmp(env, 0);    // ← 传了 0!
}

int main(void) {
    int r = setjmp(env);
    // 第一次: r == 0 → 进入 err()
    // err 调用 longjmp(env, 0)
    // 回到 setjmp: r == 1(标准自动修正为 1)
    
    if (r == 0) {
        printf("NO ERROR\n");    // 第一个个走到这里
    } else {
        printf("ERROR\n");       // 第二次走到这里 ✓
    }
    return 0;
}

C 标准 §7.13.2.1 明确规定:如果 val 为 0,longjmp 的行为等同传入 1。这保证了返回值 0 始终代表"初始设置",非 0 代表"从 longjmp 跳回"。

Q3: 局部 jmp_buf 导致 UB 的完整时间线
c
// 错误代码:
void outer(void) {
    jmp_buf env;              // ① 在 outer 的栈帧上分配
    int r = setjmp(env);      // ② 保存 outer 栈帧时的 SP/PC 到 env
    if (r == 0) {
        inner(env);           // ③ 传递 env 地址
        // inner 返回后...
    } else {
        printf("jumped\n");   // ⑥ 永远到不了这里
    }
    return;                    // ④ outer 返回 → 栈帧回收
}

void inner(jmp_buf env) {
    longjmp(env, 1);          // ⑤ 从已回收的栈帧恢复!
                               //    恢复的 SP 指向无效内存
                               //    → 段错误或跳转到随机地址
}

时间线:

  1. outer 入栈,envouter 的栈帧中
  2. setjmp(env) 保存 %rsp(指向 outer 栈帧内)到 env
  3. inner(env)env 地址传下去
  4. outer 返回,outer 的栈帧被回收
  5. innerlongjmp(env, 1) 恢复 %rsp 为已回收地址 → UB

关键原因jmp_buf 保存的 %rsp 值只在对应栈帧存在时有效。栈帧一旦被回收,这个值就是野指针。

Q4: C++ try/catch 如何从 setjmp/longjmp 演化

Itanium C++ ABI 的两阶段展开解决了 setjmp/longjmp 的三大核心缺陷。

缺陷setjmp/longjmpItanium ABI 解决方案
无析构调用跳过所有栈帧,析构永不执行Phase 2 遍历栈帧,依次调用析构函数
无类型匹配只传 int,无法携带异常对象throw 携带完整整整型信息和对象副本
无嵌套支持全局 jmp_buf,多级异常难以实现基于异常表(.eh_frame)的栈式异常处理
// 简化的 C 模拟(单级、无析构):
if (setjmp(env) == 0) {
    work();                    // "try"
} else {
    handle_error();            // "catch"
}
throw longjmp(env, MAGIC);   // "throw"

// 真正的 C++(多 catch、析构函数自动调用):
try { work(); }
catch (IOException& e)  { ... }  // 类型匹配
catch (std::exception& e) { ... }  // catch
// 局部对象自动析构 setjmp 做不到这个

.eh_frame 段记录了每个函数需要析构哪些对象,Phase 2 展开时逐帧查找这些信息并依次调用析构函数——这是 setjmp/longjmp 完全缺失的能力。

Q5: longjmp 跳走后的内存泄漏

malloc 分配的堆内存不受 longjmp 影响——它不会被自动释放。

c
jmp_buf env;

void leaky(void) {
    char *buf = malloc(1024);    // ① 分配堆内存
    // ... 操作 buf ...
    longjmp(env, 1);             // ② 跳走!
    free(buf);                   // ③ 永远不会执行 → 内存泄漏
}

void safe(void) {
    char *buf = malloc(1024);
    if (setjmp(env) != 0)
        goto cleanup;            // 跳回路径也能走到 cleanup

    // ... 操作 buf ...
    if (error)
        longjmp(env, 1);

cleanup:
    free(buf);                   // 两条路径都会执行到这里
}

避免方法:

  1. longjmp 前手动释放所有已分配资源
  2. 使用 volatile 指针变量,在跳回路径中也能访问到指针进行释放
  3. 在 C++ 中用 RAII(智能指指指指),但注意 longjmp 会破坏 RAII——改用 try/catch
Q6: sigsetjmp/siglongjmp 与普通 setjmp/longjmp 的区别

sigsetjmp/siglongjmp 额外保存/恢复了信号掩码(signal mask)。

c
#include <setjmp.h>
#include <signal.h>

// 普通版本: 不保存/恢复信号掩码
jmp_buf env;
setjmp(env);
longjmp(env, 1);            // 信号掩码不变

// 信号安全版本: 可选的信号掩码保存/恢复
sigjmp_buf sig_env;
sigsetjmp(sig_env, 1);      // 第二个空空数 1 = 保存信号掩码
siglongjmp(sig_env, 1);     // 恢复信号号号掩码

为什么需要?在信号处理器中调用 longjmp 离开处理器后,信号掩码可能仍处于阻塞状态,导致后续信号丢失。siglongjmp 将信号掩码一并恢复到 sigsetjmp 时的状态。POSIX 标准要求从信号处理器跳转必须使用 siglongjmp


课后练习

  1. 实现嵌套异常处理:用两个全局 jmp_buf (env_outerenv_inner) 实现嵌套的"try-catch":外层在 main 中,内层在 process_item 中。当 process_item 出错时先尝试内层恢复,内层失败再跳外层。用两个标记值(1=内层错误, 2=外层错误)区分跳转目标。

    知识点提示setjmp 保存的是完整上下文,longjmp 跳回后内层 env_innersetjmp 仍可再次使用。关键在于用不同的 val 区分错误级别。

    参考解答
    nested_exception.c
    c
    #include <setjmp.h>
    #include <stdio.h>
    
    jmp_buf env_outer;
    jmp_buf env_inner;
    
    void process_item(int id) {
        int r = setjmp(env_inner);
        if (r == 0) {
            printf("  processing item %d\n", id);
            if (id == 3) longjmp(env_inner, 1);   // 内层可恢复错误
            if (id == 6) longjmp(env_outer, 2);   // 内层不可恢复→跳外层
            printf("  item %d done\n", id);
        } else {
            printf("  item %d recovered from error\n", id);
        }
    }
    
    int main(void) {
        int r = setjmp(env_outer);
        if (r == 0) {
            for (int i = 1; i <= 8; i++)
                process_item(i);
            printf("all items processed\n");
        } else {
            printf("outer handler: unrecoverable error (r=%d)\n", r);
        }
        return 0;
    }
    // 输出:
    //   processing item 1
    //   item 1 done
    //   processing item 2
    //   item 2 done
    //   processing item 3
    //   item 3 recovered from error
    //   processing item 4
    //   item 4 done
    //   processing item 5
    //   item 5 done
    //   processing item 6
    //   outer handler: unrecoverable error (r=2)
  2. volatile 变量的必要性:编写一个测试程序,在 setjmp 的跳回路径中检查一个被 longjmp 前修改过的自动变量。先用普通变量,编译时加上 -O2 优化;再用 volatile 修饰,观察区别。解释为什么非 volatile 变量在优化后可能保持 setjmp 时的旧值。

    知识点提示:C 标准 §7.13.2.1 规定:longjmp 后自动变量的值是不确定的,除非该变量为 volatile 类型且所在作用域未结束。编译器可能将非 volatile 变量缓存存存寄存器中。

    参考解答
    volatile_test.c
    c
    #include <setjmp.h>
    #include <stdio.h>
    
    jmp_buf env;
    
    int main(void) {
        // 普通变量 量量量 longjmp 后值不确定!
        int plain = 0;
        // volatile 变量 — longjmp 后值保证正确
        volatile int vval = 0;
    
        int r = setjmp(env);
        if (r == 0) {
            plain = 999;
            vval = 999;
            longjmp(env, 1);
        } else {
            // 编译优化 -O2 下,plain 可能仍是 0(寄存器缓存的旧值)
            // vval 保证是 999(volatile 强制每次从内存读取)
            printf("plain = %d (may be 0 under -O2)\n", plain);
            printf("vval  = %d (guaranteed 999)\n", vval);
        }
        return 0;
    }
  3. 从递归深度逃逸:编写一个深度递归函数(如计算斐波那契数列),当递归深度超过某个阈值时用 longjmp 跳出整个递归栈。比较正常返回和 longjmp 跳出的时间复杂度(递归调用次数)差异。

    知识点提示:递归中的 longjmp 是真正的"一键撤销"——栈上一层层 return 需要 O(深度) 次函数返回,longjmp 一步到位。这是 setjmp/longjmp 相比逐层返回的最大性能优势。可参考 Lesson 41: 快速排序 中的递归深度讨论。

    参考解答
    recursive_escape.c
    c
    #include <setjmp.h>
    #include <stdio.h>
    
    jmp_buf env;
    int call_count = 0;
    
    int fib_safe(int n) {
        call_count++;
        if (n <= 1) return n;
    
        // 深度 > 10 时触发逃生
        if (n > 10) longjmp(env, 1);
    
        return fib_safe(n - 1) + fib_safe(n - 2);
    }
    
    int main(void) {
        if (setjmp(env) == 0) {
            int result = fib_safe(20);
            printf("result = %d, calls = %d\n", result, call_count);
        } else {
            printf("escaped! depth limit exceeded\n");
            printf("calls before escape = %d\n", call_count);
            // 正常计算 fib(20) 需要 ~21891 次调用
            // 用 longjmp 只需到达达达度 11 就跳出了
        }
        return 0;
    }
  4. 用 setjmp/longjmp 实现超时保护:模拟一个"可能超过过"的操作。使用 alarm() 设置定时时时,在 SIGALRM 处理器中调用 siglongjmp 跳出。当操作在超时前完成时取消定时器。验证两种路径的输出。

    知识点提示:信号处理器中必须用 siglongjmp 而非 longjmp(POSIX 要求)。alarm(0) 取消定时器。注意 sig_atomic_t 是信号处理器中唯一可靠的变量类型。

    参考解答
    timeout_protection.c
    c
    #include <setjmp.h>
    #include <signal.h>
    #include <stdio.h>
    #include <unistd.h>
    
    sigjmp_buf timeout_env;
    
    void alarm_handler(int sig) {
        siglongjmp(timeout_env, 1);   // 超时!跳出
    }
    
    int main(void) {
        signal(SIGALRM, alarm_handler);
    
        if (sigsetjmp(timeout_env, 1) == 0) {
            alarm(2);                  // 2 秒超时
            printf("working...\n");
            sleep(1);                  // 模拟 1 秒工作(未超时)
            alarm(0);                  // 取消超时
            printf("done before timeout\n");
        } else {
            printf("TIMEOUT! operation aborted\n");
        }
        return 0;
    }
    // 如果 sleep(1) → done before timeout
    // 如果 sleep(3) → TIMEOUT! operation aborted
  5. PostgreSQL 风格的 TRY/CATCH 宏:实现一对简化版的 MY_TRY/MY_CATCH/MY_END_TRY 宏,隐藏 setjmp/longjmp 的细节。要求:在 TRY 块中调用 MY_THROW() 可以跳转到 CATCH 块,错误代码通过 MY_ERROR_CODE 变量获取。

    知识点提示:宏中使用 do { ... } while(0) 包装。MY_THROW 调用 longjmp 时需要知道道道当前使用的 jmp_buf,可以用全局变量。

    参考解答
    my_try_catch.c
    c
    #include <setjmp.h>
    #include <stdio.h>
    
    jmp_buf   MY_exception_buf;
    int       MY_ERROR_CODE = 0;
    
    #define MY_TRY  \
        MY_ERROR_CODE = setjmp(MY_exception_buf); \
        if (MY_ERROR_CODE == 0)
    
    #define MY_CATCH  \
        else
    
    #define MY_END_TRY  /* nothing */
    
    #define MY_THROW(code)  \
        do { longjmp(MY_exception_buf, (code) ? (code) : 1); } while(0)
    
    void divide(int a, int b) {
        if (b == 0) MY_THROW(1);        // 除零错误
        printf("%d / %d = %d\n", a, b, a / b);
    }
    
    int main(void) {
        MY_TRY {
            divide(10, 2);              // OK
            divide(10, 0);              // 触发异常常
            printf("never reached\n");  // 不执行
        }
        MY_CATCH {
            printf("error code: %d (division by zero?)\n",
                   MY_ERROR_CODE);
        }
        MY_END_TRY;
    
        printf("program continues...\n");
        return 0;
    }

参考资料

  • C 标准 §7.13 — Non-local jumps (<setjmp.h>),包含 setjmplongjmp 的完整语义和 §7.13.1.1 使用限制
  • glibc setjmp/longjmp 源码 — x86-64 上的实际汇编实现,寄存器保存/恢复的精确操作
  • Itanium C++ ABI §1.7 — Exception Handling,Phase 1 (search) 和 Phase 2 (cleanup) 的详细规范,是 setjmp/longjmp 思想在 C++ 中的继承与发展
  • Lua 5.4 源码 ldo.cluaD_rawrunprotectedluaD_throw 中 setjmp/longjmp 在协程调度中的使用
  • PostgreSQL elog.cPG_TRY/PG_CATCH 宏的完整实现,C 语言中结构化异常处理的工业级范例

"Any problem in computer science can be solved with another level of indirection." — David Wheeler. setjmp/longjmp 正是这一哲理的 C 语言级体现:把"执行位置"本身抽象成一个可保存/恢复的对象。

Released under the MIT License.