一、开篇:老司机也翻车的 setjmp/longjmp
说实话,写 C 的老炮儿们对 setjmp 和 longjmp 的感情极其复杂。这俩函数就像一把没保险的 AK47——威力巨大,但走火概率也高得离谱。
我见过太多生产事故了。某次线上服务莫名其妙地 core dump,查了三天三夜,最后定位到一个协程库里滥用 longjmp 跳出了正确的栈帧,直接把堆干碎了。那次事故后,我们团队直接把 setjmp/longjmp 列入了代码审查的黑名单。
但问题来了——有些场景下,你不用这俩函数还真不行。比如信号处理中的异常恢复,比如轻量级协程的上下文切换,再比如那些祖传了十几年的嵌入式代码。
所以当 Fil-C 宣布要搞 “内存安全的 setjmp/longjmp” 时,我第一反应是:“这玩意儿能安全?” 第二反应是:“真香。”
Fil-C 本质上是一个 fanatically compatible(极度兼容)的内存安全 C/C++ 实现。它的思路很直接:保留 setjmp/longjmp 的语义,但通过隐式能力(InvisiCaps)和并发垃圾回收来兜底。说白了,就是你随便跳,跳错了 Fil-C 帮你擦屁股——以 panic 的形式告诉你:“兄弟,你越界了。”
二、底层机制:InvisiCaps 怎么驯服野指针?
要理解 Fil-C 怎么做内存安全的上下文切换,得先搞明白它的核心——InvisiCaps(隐形能力)。
传统 C 语言的指针就是裸地址,你拿到一个地址就能随便读写。Fil-C 不一样,每个指针背后都藏着一个 “能力”(capability),记录了它指向什么对象、能读还是能写、范围多大。这玩意儿对程序员是透明的,但在运行时会被严格检查。
回到 setjmp/longjmp。传统实现里,longjmp 跳回去的时候会恢复 jmp_buf 里保存的栈环境。问题在于,如果这期间栈上某个对象已经被释放了,或者被移动了(比如 GC 干的),那 longjmp 就跳到了一个悬空栈帧上,这就是经典的 use-after-free。
Fil-C 的解法很暴力但有效:
- 标记栈帧:在调用
setjmp时,Fil-C 会记录当前栈帧的 “能力范围”。 - 追踪引用:
jmp_buf里保存的不是裸地址,而是带能力的引用。 - GC 协作:如果
longjmp时目标栈帧已经被回收了,Fil-C 会直接 panic,而不是让你访问野内存。
说白了,Fil-C 把 longjmp 从 “无条件跳转” 变成了 “有条件跳转”。条件就是:目标栈帧必须是活的,且你有权访问它。
flowchart TD
A[调用 setjmp] --> B[保存环境到 jmp_buf]
B --> C[InvisiCaps 标记栈帧能力]
C --> D{后续调用 longjmp}
D --> E{目标栈帧是否存活?}
E -->|是| F[检查能力范围]
E -->|否| G[Fil-C panic: 悬空栈帧]
F --> H{能力是否匹配?}
H -->|是| I[安全跳转]
H -->|否| G
三、实战:一个带异常的轻量协程
理论说完了,上代码。下面是一个用 setjmp/longjmp 实现的简单异常处理机制,在 Fil-C 下是内存安全的。
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
jmp_buf exception_env;
void risky_function(int *data, size_t len) {
// 模拟一个越界操作
for (size_t i = 0; i <= len; i++) {
if (i >= len) {
longjmp(exception_env, 1); // 抛出异常
}
data[i] = 42;
}
}
int main() {
int *arr = malloc(5 * sizeof(int));
if (!arr) return 1;
int ret = setjmp(exception_env);
if (ret == 0) {
// 正常路径
risky_function(arr, 5);
printf("正常完成\n");
} else {
// 异常处理
printf("捕获越界错误,错误码: %d\n", ret);
}
free(arr);
return 0;
}
这段代码在传统 C 下跑没问题,但如果你在 risky_function 里意外地 longjmp 到一个已经 free 的栈帧上,那就炸了。Fil-C 下,如果 arr 被提前释放了,longjmp 时会检测到 exception_env 引用的栈帧已经失效,直接 panic。
再看一个更复杂的场景——协程切换。下面是一个用 setjmp/longjmp 模拟的简单协程调度器:
#include <setjmp.h>
#include <stdio.h>
jmp_buf main_env, coroutine_env;
void coroutine() {
if (setjmp(coroutine_env) == 0) {
longjmp(main_env, 1); // 让出控制权
}
printf("协程: 第一次执行\n");
if (setjmp(coroutine_env) == 0) {
longjmp(main_env, 2); // 再次让出
}
printf("协程: 第二次执行\n");
longjmp(main_env, 3); // 结束
}
int main() {
int state = setjmp(main_env);
if (state == 0) {
// 第一次进入,启动协程
longjmp(coroutine_env, 0);
} else if (state == 1) {
printf("主程序: 协程第一次让出\n");
longjmp(coroutine_env, 0);
} else if (state == 2) {
printf("主程序: 协程第二次让出\n");
longjmp(coroutine_env, 0);
} else if (state == 3) {
printf("主程序: 协程结束\n");
}
return 0;
}
这个模式在传统 C 里风险极高——如果协程的栈帧被回收了,longjmp 回去就是 UB。Fil-C 通过 GC 保证:只要还有 jmp_buf 引用着协程的栈帧,这块内存就不会被回收。只有所有引用都释放了,GC 才会清理。
四、性能与安全:Fil-C 的代价
说实话,没有免费的午餐。Fil-C 的内存安全是有代价的。
| 指标 | 传统 C (gcc/clang) | Fil-C |
|---|---|---|
| setjmp 调用开销 | ~10 纳秒 | ~50 纳秒 |
| longjmp 调用开销 | ~15 纳秒 | ~80 纳秒 |
| 内存开销 | 无额外开销 | 每个指针+8字节能力元数据 |
| 安全保证 | 无 | 100% 内存安全 |
| 兼容性 | 全兼容 | 大部分代码零修改 |
从上表可以看出,Fil-C 的 setjmp/longjmp 性能大约是传统 C 的 4-5 倍。但这对于绝大多数场景来说是可以接受的——毕竟你是在用 50 纳秒的代价换取一个永远不会 core dump 的协程库。
我个人的经验是:如果 setjmp/longjmp 在你的代码路径中不是热点(比如不是每微秒调用一次),那 Fil-C 的开销完全可以忽略。但如果是在信号处理这种高频场景中,建议还是用 sigsetjmp/siglongjmp 替代,Fil-C 对这两个函数的优化更激进一些。
五、替代方案:为什么不用 setcontext?
有人可能会问:setjmp/longjmp 这么危险,为什么不用 setcontext/getcontext/makecontext 这套接口?
答案是:这套接口在 POSIX 标准里已经被标记为废弃了(obsolescent),而且很多现代系统(比如 macOS)已经不实现了。更关键的是,setcontext 系列函数在内存安全方面做得并不比 setjmp/longjmp 好——它们同样有悬空栈帧的问题。
另一种选择是用 ucontext_t,但它的可移植性更差。Linux 上还能用,FreeBSD 上就各种坑。
所以坦率地说,setjmp/longjmp 在可预见的未来里仍然是 C 语言上下文切换的 “事实标准”。Fil-C 的做法不是去替代它,而是给它加上安全带。
六、最佳实践总结
| 场景 | 建议 | 说明 |
|---|---|---|
| 信号处理中的异常恢复 | 使用 sigsetjmp/siglongjmp | 保存信号掩码,避免竞态 |
| 轻量级协程 | 使用 setjmp/longjmp + Fil-C | 确保栈帧引用有效 |
| 嵌入式裸机 | 谨慎使用,手动管理栈 | Fil-C 可能不适用 |
| 高性能计算 | 避免使用 | 上下文切换开销不可控 |
| 错误处理 | 优先用 errno/返回值 | setjmp/longjmp 应作为最后手段 |
FAQ
setjmp 和 longjmp 在 C 中是什么?
setjmp 保存当前的栈环境到 jmp_buf 结构体中,longjmp 则恢复这个环境,让程序跳回到 setjmp 调用点继续执行。它们通常用于实现异常处理和轻量级协程。
Fil-C 是内存安全的吗?
是的。Fil-C 是一个 fanatically compatible 的内存安全 C/C++ 实现。它通过并发垃圾回收和隐式能力(InvisiCaps)来捕获所有内存安全错误。大量软件在 Fil-C 下无需修改或仅需少量修改即可编译运行。
siglongjmp 和 longjmp 有什么区别?
siglongjmp 是 longjmp 的超集,它还会恢复线程的信号掩码——前提是之前调用 sigsetjmp 时保存了信号掩码。在多线程信号处理中,应该始终使用 siglongjmp 而不是 longjmp。
setjmp 和 longjmp 在 Unix 中如何工作?
setjmp 调用将当前栈环境(包括寄存器、栈指针、程序计数器等)保存到 env 参数中。后续的 longjmp 调用恢复这个环境,控制流回到 setjmp 调用点,仿佛 setjmp 刚刚返回了 longjmp 指定的值。
References & Community Insights
本文的技术观点综合自 Hacker News、Reddit 和 X 上的工程讨论。特别感谢 Fil-C 社区在内存安全上下文切换方面的开创性工作。社区反馈显示,虽然 Fil-C 的 setjmp/longjmp 实现增加了约 4-5 倍的开销,但对于大多数非性能关键场景,这是值得的取舍。部分用户提到,在信号处理中使用 sigsetjmp/siglongjmp 时,Fil-C 的优化效果更明显。