12. setjmp/longjmp安全:非局部跳转的风险、栈帧破坏、安全的异常处理

说实话,setjmp和longjmp这对函数,在C语言里算是个“双刃剑”。用好了,它能帮你实现类似异常处理的机制;用不好,那就是个定时炸弹。我见过不少项目因为滥用longjmp,导致栈帧混乱、内存泄漏,甚至安全漏洞。今天咱们就来好好聊聊这个。

12.1 什么是setjmp/longjmp?

简单来说,setjmp和longjmp提供了一种“非局部跳转”的能力。什么意思呢?就是你可以从一个函数直接跳转到另一个函数中事先设置好的位置,跳过中间所有的函数调用栈。

看个最基础的例子:

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

jmp_buf env;

void func() {
    printf("进入func\n");
    longjmp(env, 42);  // 跳回setjmp处,返回值为42
    printf("这行不会执行\n");
}

int main() {
    int val = setjmp(env);
    if (val == 0) {
        printf("首次调用setjmp\n");
        func();
    } else {
        printf("longjmp跳回,返回值: %d\n", val);
    }
    return 0;
}

运行结果:

首次调用setjmp
进入func
longjmp跳回,返回值: 42

看到了吗?func函数里的printf("这行不会执行\n")根本没机会跑。程序直接跳回了main函数中setjmp的位置。这就是非局部跳转的威力。

12.2 核心风险:栈帧破坏

这里我要重点强调一下——栈帧破坏是longjmp最大的坑。我在项目中遇到过好几次,调试起来简直让人抓狂。

为什么会这样?因为longjmp跳转时,它只恢复setjmp保存的寄存器状态和栈指针,但不会清理中间函数分配的资源。你想想看,如果func里malloc了一块内存,longjmp一跳,那块内存的指针就丢了——内存泄漏。

⚠️ 危险示例:资源泄漏
jmp_buf env;

void func() {
    char *buf = (char *)malloc(1024);
    if (!buf) return;
    
    // 发生错误,直接跳转
    if (some_error) {
        longjmp(env, 1);  // buf泄漏了!
    }
    
    free(buf);
}

嗯,这里要注意:longjmp不会调用任何析构函数,不会释放任何资源。C++的异常处理好歹会调用析构函数,但longjmp就是赤裸裸的跳转,什么都不管。

12.3 更隐蔽的问题:栈帧已失效

我个人觉得,最危险的情况是——setjmp所在的函数已经返回了,但你还用那个jmp_buf去longjmp。这时候栈帧早就被回收了,跳回去就是访问非法内存。

💀 典型错误:悬空jmp_buf
jmp_buf env;

void dangerous() {
    if (setjmp(env) == 0) {
        // 正常路径
    } else {
        // 这里可能已经出问题了
    }
}

void caller() {
    dangerous();  // dangerous返回后,env中的栈指针指向已释放的栈帧
    longjmp(env, 1);  // 未定义行为!栈帧已不存在
}

我曾经在维护一个老项目时,就遇到过这种问题。一个全局的jmp_buf被多个函数共用,结果某个函数返回后,另一个函数还拿着它longjmp。程序崩溃得毫无规律,最后用valgrind才定位到问题。

12.4 安全的异常处理模式

那是不是说longjmp就不能用了?也不是。关键是要有规矩。我总结了一套相对安全的用法,分享给你。

12.4.1 局部化jmp_buf

尽量把jmp_buf限制在同一个函数或紧密相关的函数组内,不要搞全局的。

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

typedef struct {
    jmp_buf env;
    int error_code;
} SafeContext;

void process_data(SafeContext *ctx) {
    // 模拟错误
    ctx->error_code = -1;
    longjmp(ctx->env, 1);
}

int main() {
    SafeContext ctx;
    
    if (setjmp(ctx.env) == 0) {
        // 正常处理
        process_data(&ctx);
    } else {
        // 错误处理
        printf("发生错误,错误码: %d\n", ctx.error_code);
    }
    return 0;
}

12.4.2 资源清理的惯用手法

如果你非要在longjmp之前分配资源,那就得在跳转前手动清理。我习惯用一个“清理表”来管理:

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

#define MAX_CLEANUP 16

typedef struct {
    void (*func)(void *);
    void *arg;
} CleanupEntry;

typedef struct {
    jmp_buf env;
    CleanupEntry cleanup[MAX_CLEANUP];
    int cleanup_count;
} SafeContext;

void add_cleanup(SafeContext *ctx, void (*func)(void *), void *arg) {
    if (ctx->cleanup_count < MAX_CLEANUP) {
        ctx->cleanup[ctx->cleanup_count].func = func;
        ctx->cleanup[ctx->cleanup_count].arg = arg;
        ctx->cleanup_count++;
    }
}

void run_cleanup(SafeContext *ctx) {
    for (int i = ctx->cleanup_count - 1; i >= 0; i--) {
        ctx->cleanup[i].func(ctx->cleanup[i].arg);
    }
    ctx->cleanup_count = 0;
}

void safe_longjmp(SafeContext *ctx, int val) {
    run_cleanup(ctx);
    longjmp(ctx->env, val);
}

// 使用示例
void free_buf(void *arg) {
    free(*(void **)arg);
    printf("清理资源\n");
}

void do_work(SafeContext *ctx) {
    char *buf = (char *)malloc(1024);
    if (!buf) {
        safe_longjmp(ctx, -1);
    }
    add_cleanup(ctx, free_buf, &buf);
    
    // 模拟错误
    safe_longjmp(ctx, 1);
}

int main() {
    SafeContext ctx = {0};
    
    if (setjmp(ctx.env) == 0) {
        do_work(&ctx);
    } else {
        printf("异常处理,错误码: %d\n", ctx.error_code);
    }
    return 0;
}

12.5 知识体系总览

下面这张图帮你理清setjmp/longjmp的核心要点:

setjmp/longjmp 安全知识体系 setjmp/longjmp 工作原理 setjmp:保存当前栈帧 longjmp:恢复栈帧并跳转 非局部跳转,跳过中间函数 核心风险 ⚠ 栈帧破坏 ⚠ 资源泄漏 ⚠ 悬空jmp_buf ⚠ 未定义行为 安全实践 ✅ 局部化jmp_buf ✅ 资源清理机制 ✅ 避免全局变量 ✅ 封装安全接口 核心原则:能不用就不用,非用则必须封装 setjmp/longjmp 是底层机制,不是日常工具

12.6 避坑指南

最后,我把自己踩过的坑整理成几条原则,你记一下:

  • 不要跨函数使用全局jmp_buf——我见过最惨的bug就是全局jmp_buf被多个模块乱用,最后谁都不知道当前栈帧是谁的。
  • longjmp前必须释放所有资源——malloc、fopen、锁,一个都不能漏。漏一个就是线上事故。
  • setjmp和longjmp必须在同一个线程——这个不用多说吧?跨线程longjmp就是找死。
  • 不要在信号处理函数里longjmp——信号处理函数的上下文太特殊,跳出去容易出问题。
  • 能用返回值传递错误就别用longjmp——说白了,90%的场景你根本不需要longjmp。错误码、errno、返回值,这些才是正道。
💡 我的建议:如果你真的需要异常处理机制,在C语言里可以考虑用“回调+错误码”的模式,或者用宏封装一套类似try-catch的语法糖。longjmp只适合极少数底层库或嵌入式场景,普通业务代码尽量别碰。

嗯,关于setjmp/longjmp的安全问题,今天就聊到这儿。记住一句话:非局部跳转是C语言给高级玩家准备的工具,新手慎用,老手也要三思


公众号:蓝海资料掘金营,微信deep3321