22.2 跳转
除了 if、循环和 goto 这类函数内部的控制流,C 标准库还提供了非局部跳转能力:setjmp 与 longjmp。它们定义在 <setjmp.h>,可在深层调用栈中快速回到某个恢复点。
1. 基本语义
setjmp(env) 会保存当前执行上下文,并返回 0;后续若调用 longjmp(env, value),程序会跳回该 setjmp 位置,并让 setjmp 的返回值变为 value(若 value 为 0,则返回 1)。
setjmp 的返回值不能赋给对象
setjmp 的调用只能直接作为 if、switch 或循环的控制表达式(也可以与整数常量比较,或作为 ! 的操作数),或者单独构成一条表达式语句。int code = setjmp(env); 这样把返回值赋给对象的写法是未定义行为。
下面的程序用 switch 区分 setjmp 的返回值:
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
void deep_call(void) {
longjmp(env, 42);
}
int main(void) {
switch (setjmp(env)) {
case 0:
deep_call();
break;
case 42:
puts("recovered: 42");
break;
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
输出:
recovered: 42
2. 适用场景
它适合表达“统一失败回收点”这类机制,例如解释器、解析器或受限环境中的错误回退。若普通 return 就能清晰表达控制流,应优先使用普通 return,因为可读性更高。
3. 关键约束
longjmp 只能跳回仍然有效的调用栈上下文;若目标函数已经返回,再跳回去就是未定义行为。
4. 局部对象可见值提示
包含 setjmp 的函数中的局部对象,如果在 setjmp 之后、longjmp 之前被修改过,且没有声明为 volatile,那么 longjmp 回到该点后,它的值是不确定的,因为编译器可能把它放在寄存器里。需要在跳转后读取的这类对象,应声明为 volatile,例如 volatile int attempts = 0;。
5. 与线程模型的边界
jmp_buf 保存的是当前线程的执行上下文,不能跨线程使用。每个线程都应使用自己的 jmp_buf,只跳回本线程中设置的恢复点。
6. 习题
6.1 巩固
下面的程序用三种 case 区分 setjmp 的返回值,便于观察一次跳转的完整路径。先写出你预测的输出,再编译运行对照(gcc -std=c17 -Wall -Wextra)。
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
static void step(int code) {
longjmp(env, code);
}
int main(void) {
switch (setjmp(env)) {
case 0:
puts("start");
step(0);
break;
case 1:
puts("retry");
step(2);
break;
case 2:
puts("done");
break;
}
puts("after switch");
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(1) 程序打印哪几行?
(2) 把第一处 step(0) 改成 step(5),输出变成什么?没有匹配的 case 时,控制流去了哪里,after switch 那行还执行吗?
(3) 在第 (2) 问的基础上补上 default: puts("other"); break;,输出又是什么?
(4) 恢复原样,把第二处 step(2) 改成 step(7) 并保留 default,输出是什么?第一次调用 step(0) 为什么不会重新进入 case 0?
(5) 把 env 从文件作用域挪进 main,step 改收一个 jmp_buf *、写成 longjmp(*env, code),调用处相应改成 step(&env, 0)。程序还能正常运行吗?env 是自动存储期的对象,为什么这里不违反“目标函数必须仍然有效”这条约束?
下面两段程序都用了 setjmp,都违反了本节给出的规则。它们的共同点是编译时不一定有警告,运行时有时还看起来正常。
第一段把 setjmp 的返回值赋给对象:
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
static void parse(void) {
longjmp(env, 3);
}
int main(void) {
int code = setjmp(env);
if (code == 0) {
parse();
puts("parse returned");
} else {
printf("recovered: %d\n", code);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
第二段的恢复点在 arm 里,而 arm 在 longjmp 之前已经返回:
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
static void arm(void) {
if (setjmp(env) == 0) {
puts("armed");
}
}
static void fire(void) {
longjmp(env, 1);
}
int main(void) {
arm();
puts("arm returned");
fire();
puts("done");
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(1) [2.2] 分别指出两段违反的规则。第一段要对照 setjmp 允许出现的几种上下文来说明,第二段要说明“跳回仍然有效的调用栈上下文”的含义。
(2) [3.2] 两段都编译运行一次,记录输出与退出状态。本机第一段打印 recovered: 3,第二段能跑完(打印 armed、arm returned、done)。这些观察能说明什么,不能说明什么?标准有没有规定这两段必须怎样运行?读者机器上的结果可以不同,判定标准是有没有出现“看起来正常”的运行。
(3) [3.3] 把第一段改写成合法形式(用 switch 或 if 都可以)。再把 parse 里的 longjmp(env, 3) 改成 longjmp(env, 0) 重跑:这次 setjmp 返回什么,你的改写会走进哪一支?用本节开头那句规则解释。
(4) [4.3] 改写第二段,把恢复点留在一个不会提前返回的位置,使程序不再依赖未定义行为。
下面的程序在第三次尝试后跳回 setjmp 的位置,然后打印累计的次数与总和:
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
static int try_once(int attempt) {
return attempt * 10;
}
int main(void) {
int attempts = 0;
int total = 0;
if (setjmp(env) == 0) {
for (;;) {
++attempts;
total += try_once(attempts);
if (attempts == 3) {
longjmp(env, 1);
}
}
}
printf("attempts=%d total=%d\n", attempts, total);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(1) 用 gcc -std=c17 -Wall -Wextra 编译运行一次,记录输出和完整的警告文字。
(2) 指出哪些对象在跳转后的值不可靠,说明判断依据与它们和 setjmp 的先后关系。编译器的警告是否覆盖了全部不可靠的对象?这说明了警告与规则之间是什么关系?try_once 的形参为什么不在此列?
(3) 给相关对象加上 volatile,重新编译运行,确认输出为 attempts=3 total=60 且没有警告。
(4) 只给 attempts 加 volatile、total 保持不变,重新编译运行。total 的值还有保证吗?为什么?
(5) 除了 volatile,还可以把两个对象改成 static。说明这种改法为什么也能得到正确结果,以及它和 volatile 各自改变了什么。
6.2 编程
写一个逐行求值的除法器:从标准输入读取若干行,每行形如 a / b,输出每行的结果或错误,最后打印统计。程序要满足下列要求。
- 每行用
strtol解析两个整数,数字与/之间允许有一个或多个空格,行尾允许空白。其余情况都算格式错误。 - 解析与检查分别放在
parse_number和parse_line两个函数里。这两个函数不用返回值报告失败,而是用longjmp跳回main的恢复点;用不同的错误码区分“格式错误”和“除零”,由switch分别处理。 - 每读入一行,先复制到
malloc得到的缓冲区,解析在这份副本上进行。无论这一行成功还是出错,缓冲区都要释放。程序最后打印未释放的块数,必须为 0。 - 成功行输出
a / b = c,格式错误输出invalid line,除零输出division by zero。最后一行输出ok=成功行数 failed=失败行数 live=未释放块数。 - 用
gcc -std=c17 -Wall -Wextra -Werror编译不产生警告。
测试数据保存为 calc.txt:
12 / 3
7 / 0
abc / 2
100 / 4
6/2
9 / -32
3
4
5
6
期望输出:
12 / 3 = 4
division by zero
invalid line
100 / 4 = 25
6 / 2 = 3
9 / -3 = -3
ok=4 failed=2 live=02
3
4
5
6
7
最后回答两个问题。
- 把
setjmp挪到循环之前、整个程序只设一个恢复点,剩余各行还能不能继续处理?这种写法与每行设一个恢复点相比,多出哪一项必须自己完成的清理动作? - 只用
goto和统一清理出口(14.5 goto 与统一清理)也能写出同样的程序:解析函数改成返回错误码,每行检查返回值后goto到行尾的清理标签。把这一版也写出来,说明两种写法在以下三处的差别:出错信息如何从最深处传到调用方、清理代码写在哪里、新增一种错误时要改几个地方。结合本页第 2 节的建议,说明这个程序更该用哪一种。
6.3 综合与挑战
下面的任务运行器依次执行任务表。任务函数失败时用 longjmp 跳回循环里的恢复点,主程序继续跑下一个任务。它有两处问题:失败路径漏掉释放,跳转后要用的对象没有加 volatile。第 (3) 问另给一段把恢复点交给多个线程的程序。
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum { ERR_RANGE = 1, ERR_MEMORY = 2 };
static jmp_buf env;
static int live_blocks;
static void task_fail(int code) {
longjmp(env, code);
}
static int parse_task(const char *text, char *buf) {
strcpy(buf, text);
long value = strtol(buf, NULL, 10);
if (value <= 0) {
task_fail(ERR_RANGE);
}
return (int)value * 2;
}
static int run_tasks(const char *const *args, size_t n) {
int failed = 0;
for (size_t i = 0; i < n; ++i) {
switch (setjmp(env)) {
case 0: {
char *buf = malloc(16);
if (buf == NULL) {
task_fail(ERR_MEMORY);
}
++live_blocks;
printf("%s: result %d\n", args[i], parse_task(args[i], buf));
free(buf);
--live_blocks;
break;
}
case ERR_RANGE:
printf("%s: value out of range\n", args[i]);
++failed;
break;
case ERR_MEMORY:
printf("%s: out of memory\n", args[i]);
++failed;
break;
}
}
return failed;
}
int main(void) {
static const char *const args[] = {"21", "-4", "0", "9", "-8"};
int failed = run_tasks(args, 5);
printf("failed=%d live=%d\n", failed, live_blocks);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
(1) [3.3] 用 gcc -std=c17 -Wall -Wextra -Werror 编译一次,记下报的是哪些对象。说明每个被报的对象为什么需要 volatile。先只做这一问、不动释放逻辑,记录修好之后 live 还剩下几。
(2) [4.4] 用 gcc -std=c17 -Wall -Wextra 编译运行原程序,记录每一行输出与最后的 failed、live。定位漏掉的释放动作并修复:每个任务仍然从堆上取缓冲区,修复后输出与原先逐行相同,最后一行是 failed=3 live=0。修复必然要把缓冲区指针挪到 switch 之外,否则出错的那个 case 里根本看不到它;挪出来之后第 (1) 问的警告会再多报一个对象。
(3) [6.4**] 下面是一个把恢复点交给两个线程的最小程序。在本机 MinGW 上它每次都异常终止,退出码在 0xC0000005(访问违例)、0xC00000FD(栈溢出)、0xC0000028 之间变化,一次也没有打印完三行。
#define _POSIX_C_SOURCE 200809L
#include <pthread.h>
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
static void *worker(void *arg) {
const char *name = arg;
for (volatile int i = 0; i < 100; ++i) {
if (setjmp(env) == 0) {
longjmp(env, 1);
}
}
printf("%s: done\n", name);
return NULL;
}
int main(void) {
pthread_t a;
pthread_t b;
pthread_create(&a, NULL, worker, "A");
pthread_create(&b, NULL, worker, "B");
pthread_join(a, NULL);
pthread_join(b, NULL);
puts("main: done");
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
编译用 gcc -std=c17 -Wall -Wextra -pthread。先运行几次记录现象,再让每个线程使用自己的 jmp_buf(_Thread_local 或放进每线程的结构体都可以),要求连续运行 20 次都能打印 A: done、B: done、main: done 三行并正常退出;前两行的先后次序不作要求。最后说明为什么不能用一把互斥量把 env 保护起来:longjmp 恢复的是哪个线程的上下文,跳转时那把锁会处于什么状态?