setjmp与longjmp:异常处理机制、与内存管理的关系、资源清理问题

说实话,C语言里没有try-catch,这让很多从Java或C++转过来的朋友很不适应。但C语言其实有一套自己的“异常处理”方案——就是setjmp和longjmp。这两个函数,说白了就是“标记-跳转”机制。我最早接触它们是在一个嵌入式实时系统里,当时要处理深层嵌套的错误恢复,用返回值一层层往上抛,代码写得跟千层饼似的。后来用了setjmp/longjmp,清爽多了。

1. 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 ret = setjmp(env);
    if (ret == 0) {
        printf("第一次调用setjmp,ret=%d\n", ret);
        func();
    } else {
        printf("longjmp跳回,ret=%d\n", ret);
    }
    return 0;
}

运行结果:

第一次调用setjmp,ret=0
进入func
longjmp跳回,ret=42

setjmp第一次调用时返回0,相当于“注册”了一个跳转点。longjmp则像是一个“传送门”,直接把你传回setjmp的位置,并且让setjmp返回你指定的值(非0)。

我的习惯:用不同的返回值区分不同的错误类型。比如1表示内存分配失败,2表示文件打开失败,3表示设备超时。这样在setjmp返回后,一眼就能看出是什么问题。

2. 与内存管理的关系——一个容易踩的坑

这里有个关键问题:longjmp跳转时,栈上的局部变量会怎样?

嗯,答案是——不确定。C标准说,如果局部变量是volatile的,它的值在longjmp后是确定的;否则,行为未定义。说白了,编译器可能优化掉你的变量,或者恢复成旧值,或者保持新值——全看编译器心情。

我在项目中遇到过这样一个bug:

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

jmp_buf env;

void risky_func() {
    int *p = (int*)malloc(sizeof(int) * 100);
    if (!p) {
        longjmp(env, 1);  // 内存泄漏!
    }
    // 使用p...
    free(p);
}

int main() {
    if (setjmp(env) != 0) {
        printf("出错了,但p的内存已经泄漏了\n");
        return 1;
    }
    risky_func();
    return 0;
}

看到了吗?longjmp跳走了,free(p)那行根本没执行。p指向的内存就永远泄漏了。你想想看,如果这个函数被频繁调用,内存会涨得多快。

警告:longjmp不会自动清理栈上分配的堆内存。所有在setjmp和longjmp之间malloc的内存,如果没来得及free,就会泄漏。这是C语言异常处理中最常见的资源泄漏问题。

3. 资源清理问题——如何优雅地处理

那怎么办?总不能不用longjmp吧。其实有几种成熟的方案。

方案一:使用“清理栈”模式

我自己比较喜欢的一种做法,是维护一个资源链表,在longjmp之前统一清理:

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

jmp_buf env;

// 简单的资源链表
typedef struct Resource {
    void *ptr;
    struct Resource *next;
} Resource;

Resource *res_head = NULL;

void register_resource(void *ptr) {
    Resource *r = (Resource*)malloc(sizeof(Resource));
    r->ptr = ptr;
    r->next = res_head;
    res_head = r;
}

void cleanup_all() {
    while (res_head) {
        Resource *tmp = res_head;
        free(res_head->ptr);
        res_head = res_head->next;
        free(tmp);
    }
}

void safe_func() {
    int *p = (int*)malloc(sizeof(int) * 100);
    if (!p) {
        cleanup_all();
        longjmp(env, 1);
    }
    register_resource(p);
    
    // 正常使用...
    
    // 正常退出时清理
    cleanup_all();
}

int main() {
    if (setjmp(env) != 0) {
        printf("异常退出,资源已清理\n");
        return 1;
    }
    safe_func();
    return 0;
}

方案二:嵌套setjmp——分层错误处理

有时候我们需要在不同层级做不同的清理。比如底层函数只清理自己的资源,上层函数再处理业务逻辑:

jmp_buf top_env;
jmp_buf mid_env;

void bottom() {
    int *data = malloc(1000);
    if (!data) {
        longjmp(mid_env, 1);  // 跳到中层
    }
    // 使用data...
    free(data);
}

void middle() {
    if (setjmp(mid_env) != 0) {
        // 中层清理自己的资源
        printf("中层:清理中间资源\n");
        longjmp(top_env, 1);  // 继续向上抛
    }
    bottom();
}

int main() {
    if (setjmp(top_env) != 0) {
        printf("顶层:最终错误处理\n");
        return 1;
    }
    middle();
    return 0;
}

这种分层方式,说白了就是手动实现了try-catch的嵌套结构。每一层负责清理自己的资源,然后决定是否继续向上抛。

4. 实战中的避坑指南

我曾经在一个网络协议栈里吃过longjmp的大亏。当时有个函数在多个地方malloc了内存,然后调用了一个可能longjmp的函数。结果每次异常跳转,内存就泄漏一批。查了两天才找到原因。

总结几条经验:

  • 不要在longjmp之后依赖局部变量的值——除非你加了volatile。我习惯把所有可能在longjmp后使用的局部变量都声明为volatile。
  • longjmp之前,手动清理所有堆内存——或者用上面说的资源链表方案。别指望编译器帮你做。
  • 避免在信号处理函数中使用longjmp——信号处理函数的上下文非常有限,longjmp出去可能导致未定义行为。我见过有人这么干,结果程序随机崩溃。
  • longjmp不能跨越不同线程——setjmp和longjmp必须在同一个线程中使用。跨线程跳转?想都别想,行为未定义。
核心原则:longjmp是“暴力跳转”,它不会执行任何析构或清理代码。所有资源管理都得你自己手动搞定。用好了是利器,用不好是灾难。

5. 知识体系总览

下面这张图总结了setjmp/longjmp的核心知识点和它们之间的关系:

setjmp/longjmp 知识体系 setjmp / longjmp 基本用法 setjmp 注册跳转点 longjmp 跳回并返回值 返回值区分首次/跳回 内存管理 栈变量:volatile 保证 堆内存:手动管理 ⚠ 内存泄漏风险 资源清理 资源链表统一清理 嵌套setjmp分层处理 手动释放所有资源 避坑指南 不要跨线程使用 | 信号处理函数中慎用 | 局部变量加volatile longjmp前必须清理所有堆内存 | 嵌套跳转注意层级

这张图把setjmp/longjmp的核心机制、内存管理要点、资源清理方案以及常见陷阱都串起来了。你可以把它当作一个快速参考,写代码前瞄一眼,能少踩不少坑。

最后说一句:setjmp/longjmp是C语言里少有的“非局部跳转”机制。它很强大,但也很危险。我个人建议,除非你真的需要跨多层函数跳转,否则还是老老实实用返回值+错误码。毕竟,代码的可维护性比一时的方便重要得多。


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