运维笔记

Fil-C 内存安全上下文切换:setjmp/longjmp 的硬核生存法则

Networking 技术可视化

一、开篇:老司机也翻车的 setjmp/longjmp

说实话,写 C 的老炮儿们对 setjmplongjmp 的感情极其复杂。这俩函数就像一把没保险的 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 的解法很暴力但有效:

  1. 标记栈帧:在调用 setjmp 时,Fil-C 会记录当前栈帧的 “能力范围”。
  2. 追踪引用jmp_buf 里保存的不是裸地址,而是带能力的引用。
  3. 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 有什么区别?

siglongjmplongjmp 的超集,它还会恢复线程的信号掩码——前提是之前调用 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 的优化效果更明显。

Elvin Hui

关于作者:Elvin Hui

Elvin 拥有 10+ 年企业级数据中心、云原生架构和网络安全经验。持有 CCNA、AWS 解决方案架构师认证。我致力于将一线的“踩坑”经验沉淀为真实、硬核的技术指南,拒绝空洞理论。