Lesson 47: setjmp/longjmp 非局部跳转
练习任务
难度:中
使用 setjmp/longjmp 实现跨函数栈的非局部跳转:
- 在
main()中用setjmp(env)设锚点(保存执行环境) - 在深层嵌套函数
funcC()中检测到错误时,用longjmp(env, 1)直接"空降"回main - 调用链:
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)触发后"再次返回"返回vallongjmp恢复 CPU 上下文:栈指针 SP、程序计数器 PC、被调用方保存寄存器 — 全部恢复到setjmp快照时的值,跳过所有中间栈帧jmp_buf必须 global/static:局部jmp_buf在其所在函数返回后栈帧失效,longjmp读取已失效内存 = 未定义行为val必须非 0:若longjmp(env, 0)则setjmp返回 1(自动修正),以与首次返回(0)可区分- CPU 寄存器视角:
setjmp≈ 将寄存器快快照mov到jmp_buf;longjmp≈ 从jmp_bufmov回来 +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 信号处理器器回溯、PostgreSQLPG_TRY/PG_CATCH宏模拟异常处理
代码框架
/* 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 阻止编译。完成 funcC 和 main 后删除它们。注意 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 ← 回来setjmp 在 main 中保存当前前前的 CPU 执行环境境境境所有寄存器的快照),相当于在栈上钉下一个"锚点"。longjmp 在 funcC 中恢复这个快照,让程序状态瞬间回到 setjmp 那一刻——中间所有栈帧被跳过,仿佛它们从未存在过。
2. setjmp 返回两次的神奇特性
这是 setjmp/longjmp 最核心也是最反直觉的行为:setjmp 会被调用一次,但返回两次的的的
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"):
// ═══════════════════════════════════════════
// 第一遍:正常执行路径
// ═══════════════════════════════════════════
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
被跳过的栈帧(funcC → funcB → funcA)中的局部变量不会被"清理"——在 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 的伪汇编
; 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
ret4.2 longjmp 的伪汇编
; 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+ 返回 0longjmp= 从jmp_bufmov 回寄存器 + jmp 到保存的 PC + 传返回值
5. jmp_buf 必须为 global/static
这是最常见的错误:把 jmp_buf 声明为局部变量。
❌ 错误写法
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 也是指向已释放内存的野指针。
✅ 正确写法
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 的生命周期期期必须覆盖从 setjmp 到 longjmp 的整个过程。全局变量和 static 变量满足这个要求;局部栈上变量不满足——函数一旦返回,栈帧就作废。
6. val 必须非 0 — 避免与首次返回混淆
longjmp 的第二个参数 val 会成为 setjmp 的返回值。但有一个陷阱:
// 如果传 0:
longjmp(env, 0);
// setjmp 返回什么?标准规定:如果 val 是 0,setjmp 返回 1为什么需要这个规则?
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, thelongjmpfunction behaves as if the value were 1."
这就是 longjmp 汇编伪代码中有 test %esi, %esi; jnz .L_ok; mov $1, %eax 的原因——0 被自动修正为 1。
#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=17. 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)的自动对象的作用域,行为是未定义的。
为什么危险?一个具体例子
#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
唯一个个个全的方式:setjmp 在 main 的最外层,被跳过的帧中只有平凡类型(POD)的局部变量。
// 安全:被跳过的帧中只有 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 thesetjmpmacro 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)
✅ 合法用法
// 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); // 显式丢弃返回值❌ 非法用法(行为未定义)
// 函数参数 — 非法!
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); // ❌为什么有序序序些限制? setjmp 在 longjmp 后"返回两次",如果它出现在复杂表达式的中间,编译器无法确定表达式求值的状态——那些部分求值应该保留还是丢弃?限制只允许它出现在"控制点",这些位置编译器可以安全地处理恢复后的状态。
TIP
在实际编码中,最安全的做法永远是:
int r = setjmp(env);
if (r == 0) { ... } else { ... }这样既合合合又清晰。
10. 工业级应用实例
setjmp/longjmp 虽然不直接在应用层频繁露面,但在系统软件和语言运行时中有重要地位。
10.1 Lua 协程调度
Lua 使用 setjmp/longjmp 作为协程上下文切换的底层机制。
// 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 从信号处理器跳回。
#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++ 异常处理的宏系统:
// 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 — 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;
}关键理解点:
jmp_buf env是全局变量——不能在main里声明,因为当longjmp触发时main尚未返回,env必须仍然有效。setjmp(env)第一次返回 0,程序走正常路径进入funcA→funcB→funcC。- 当
input为"error"时,funcC调用longjmp(env, 1),直接跳回setjmp(env)那一行——这次它返回 1。 funcC、funcB、funcA中longjmp之后的代码全部不执行——栈被直接"切回"到setjmp的状态。
对照检查:
jmp_buf是全局的吗?setjmp返回值判断了吗?longjmp传的非 0 值吗?输出格式与测试用例匹配吗?
课堂讨论
setjmp/longjmp和goto有什么本质区别?为什么goto不能做到跨函数跳转,而setjmp/longjmp可以?longjmp(env, 0)会导致致什么后果?C 标准对此做了什么规定?为什么需要这个规定?- 为什么
jmp_buf不能是局部变量?给出一个具体的场景,说明局部jmp_buf导致未定义行为的完整时间线。 - C++ 的
try/catch底层是如何从setjmp/longjmp演化而来来来来?Itanium C++ ABI 的两阶段展开解决了setjmp/longjmp的哪些问题? - 如果在
setjmp和longjmp之间malloc了一块内存,longjmp跳走后这块内存会怎样?如何避免内存泄漏? - 在信号处理器中使用
longjmp与使用siglongjmp有什么区别?为什么标准专门提供了sigsetjmp/siglongjmp?
讨论答案
Q1: setjmp/longjmp 和 goto 的本质区别
goto 在编译期确定跳转目标,longjmp 在运行期确定——这决定了它们的能力边界。
goto | setjmp/longjmp | |
|---|---|---|
| 作用域 | 同函数内 | 跨任意层函数 |
| 跳转目标 | 编译期固定的标签 | 运行期从 jmp_buf 读取 |
| 栈帧 | 不改变栈 | 恢复 setjmp 时的栈指针→跳过中间帧 |
| 实现 | 编译器生成 jmp 指令 | 从内存恢复所有寄存器 + jmp |
goto 的目标是编译期刻在指令里的立即数地址;longjmp 的目标是运行期从 jmp_buf 里加载的值——这是"静态跳转"和"动态跳转"的本质差异。
// goto: 编译器在生成代码时就知道了跳转地址
goto label; // → jmp $label (立即数地址)
label:
// longjmp: 地址存在内存里,运行时加载
longjmp(env, 1); // → mov env+0x38, %rax; jmp *%raxQ2: longjmp(env, 0) 的后果
setjmp 返回 0(标准修正为 1),与首次调用可区分——程序走跳回路径。
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 的完整时间线
// 错误代码:
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 指向无效内存
// → 段错误或跳转到随机地址
}时间线:
outer入栈,env在outer的栈帧中setjmp(env)保存%rsp(指向outer栈帧内)到envinner(env)把env地址传下去outer返回,outer的栈帧被回收inner中longjmp(env, 1)恢复%rsp为已回收地址 → UB
关键原因:jmp_buf 保存的 %rsp 值只在对应栈帧存在时有效。栈帧一旦被回收,这个值就是野指针。
Q4: C++ try/catch 如何从 setjmp/longjmp 演化
Itanium C++ ABI 的两阶段展开解决了 setjmp/longjmp 的三大核心缺陷。
| 缺陷 | setjmp/longjmp | Itanium 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 影响——它不会被自动释放。
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); // 两条路径都会执行到这里
}避免方法:
- 在
longjmp前手动释放所有已分配资源 - 使用
volatile指针变量,在跳回路径中也能访问到指针进行释放 - 在 C++ 中用 RAII(智能指指指指),但注意
longjmp会破坏 RAII——改用try/catch
Q6: sigsetjmp/siglongjmp 与普通 setjmp/longjmp 的区别
sigsetjmp/siglongjmp 额外保存/恢复了信号掩码(signal mask)。
#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。
课后练习
实现嵌套异常处理:用两个全局
jmp_buf(env_outer和env_inner) 实现嵌套的"try-catch":外层在main中,内层在process_item中。当process_item出错时先尝试内层恢复,内层失败再跳外层。用两个标记值(1=内层错误, 2=外层错误)区分跳转目标。知识点提示:
setjmp保存的是完整上下文,longjmp跳回后内层env_inner的setjmp仍可再次使用。关键在于用不同的val区分错误级别。参考解答
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)volatile 变量的必要性:编写一个测试程序,在
setjmp的跳回路径中检查一个被longjmp前修改过的自动变量。先用普通变量,编译时加上-O2优化;再用volatile修饰,观察区别。解释为什么非volatile变量在优化后可能保持setjmp时的旧值。知识点提示:C 标准 §7.13.2.1 规定:
longjmp后自动变量的值是不确定的,除非该变量为volatile类型且所在作用域未结束。编译器可能将非volatile变量缓存存存寄存器中。参考解答
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; }从递归深度逃逸:编写一个深度递归函数(如计算斐波那契数列),当递归深度超过某个阈值时用
longjmp跳出整个递归栈。比较正常返回和longjmp跳出的时间复杂度(递归调用次数)差异。知识点提示:递归中的
longjmp是真正的"一键撤销"——栈上一层层return需要 O(深度) 次函数返回,longjmp一步到位。这是setjmp/longjmp相比逐层返回的最大性能优势。可参考 Lesson 41: 快速排序 中的递归深度讨论。参考解答
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; }用 setjmp/longjmp 实现超时保护:模拟一个"可能超过过"的操作。使用
alarm()设置定时时时,在SIGALRM处理器中调用siglongjmp跳出。当操作在超时前完成时取消定时器。验证两种路径的输出。知识点提示:信号处理器中必须用
siglongjmp而非longjmp(POSIX 要求)。alarm(0)取消定时器。注意sig_atomic_t是信号处理器中唯一可靠的变量类型。参考解答
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 abortedPostgreSQL 风格的 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,可以用全局变量。参考解答
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>),包含setjmp、longjmp的完整语义和 §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.c —
luaD_rawrunprotected和luaD_throw中 setjmp/longjmp 在协程调度中的使用 - PostgreSQL elog.c —
PG_TRY/PG_CATCH宏的完整实现,C 语言中结构化异常处理的工业级范例
"Any problem in computer science can be solved with another level of indirection." — David Wheeler.
setjmp/longjmp正是这一哲理的 C 语言级体现:把"执行位置"本身抽象成一个可保存/恢复的对象。