错误处理的进化:从errno到返回码枚举、引入断言、使用setjmp/longjmp实现异常模拟

错误处理这事儿,我做了快二十年C语言,感触特别深。早期项目里,大家写代码都是「能跑就行」,错误处理?那是锦上添花的东西。直到有一次,我负责的一个嵌入式设备在产线上批量死机,排查了三天才发现是一个函数返回了-1,但调用方根本没检查——嗯,从那以后,我对错误处理的态度彻底变了。

说白了,错误处理不是「加不加」的问题,而是「怎么加才靠谱」的问题。今天咱们就聊聊C语言里错误处理的几种进化形态,从最原始的errno,到枚举返回码,再到断言和setjmp/longjmp模拟异常。每一种都有它的适用场景,也有它的坑。

1. errno:最原始的错误处理方式

errno这东西,C语言刚诞生就有了。它的设计思路很简单:全局变量,函数出错时设置一个错误码,调用方自己去查。

#include <errno.h>
#include <stdio.h>

FILE *fp = fopen("config.txt", "r");
if (fp == NULL) {
    // 检查errno
    if (errno == ENOENT) {
        printf("文件不存在\n");
    } else if (errno == EACCES) {
        printf("权限不足\n");
    } else {
        printf("未知错误: %d\n", errno);
    }
}

我在项目中遇到过一个问题:多线程环境下,errno会被覆盖。你想想看,线程A调了一个函数,还没来得及检查errno,线程B也调了一个函数,errno就被改了。这问题排查起来特别痛苦。

注意:errno不是线程安全的。现代C库虽然把errno实现为线程局部存储,但早期代码里可不是这样。如果你在维护遗留系统,一定要确认errno的实现方式。

另外,errno还有个问题:它只能表示「发生了什么错误」,但没法告诉你「具体是哪个资源出错了」。比如你同时操作了三个文件,其中一个打开失败,errno只能告诉你「文件不存在」,但你不知道是哪个文件。

2. 返回码枚举:让错误变得可读

后来大家发现,errno这种方式太原始了。于是开始用枚举定义返回码,每个函数都返回一个状态值。我个人习惯用这种模式,因为它让错误处理变得显式、可控。

typedef enum {
    SUCCESS = 0,
    ERR_NULL_POINTER = -1,
    ERR_INVALID_PARAM = -2,
    ERR_OUT_OF_MEMORY = -3,
    ERR_FILE_NOT_FOUND = -4,
    ERR_PERMISSION_DENIED = -5
} ErrorCode;

ErrorCode read_config(const char *path, Config *out) {
    if (path == NULL || out == NULL) {
        return ERR_NULL_POINTER;
    }
    
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        if (errno == ENOENT) return ERR_FILE_NOT_FOUND;
        if (errno == EACCES) return ERR_PERMISSION_DENIED;
        return ERR_INVALID_PARAM;
    }
    
    // 读取配置...
    fclose(fp);
    return SUCCESS;
}

这样做的好处很明显:调用方一眼就能看出函数可能返回哪些错误,而且每个错误都有明确含义。我在重构一个遗留系统时,把原来几百个散落的errno检查全部改成了枚举返回码,代码可读性提升了一大截。

我的建议:枚举值从0开始表示成功,负数表示错误。这样可以用 if (ret) 快速判断是否出错,语义上也说得通——「非零即错」。

不过,返回码枚举也有它的局限性。你想想看,如果一个函数调用链很深,每一层都要检查返回码、传递错误,代码会变得非常臃肿。我曾经维护过一个模块,一个简单的配置读取操作,因为要经过五层函数调用,每层都要写错误检查,结果代码膨胀了三倍。

3. 引入断言:捕获不该发生的错误

断言这东西,很多人把它当调试工具用。其实它的真正价值在于:表达「这个条件必须成立,否则就是程序bug」。

#include <assert.h>

void process_data(const uint8_t *data, size_t len) {
    // 如果data是NULL,说明调用方有bug
    assert(data != NULL);
    // 如果len为0,说明逻辑有问题
    assert(len > 0);
    
    // 正常处理...
}

我在项目中遇到过一种情况:一个函数内部调用了malloc,但调用方传进来的参数是用户输入,没有做校验。结果用户输入了一个超大值,malloc返回NULL,但代码没检查,直接用了——段错误。后来我在函数入口加了一个断言:

void allocate_buffer(size_t size) {
    // 防止恶意输入导致内存耗尽
    assert(size <= MAX_BUFFER_SIZE);
    
    void *buf = malloc(size);
    assert(buf != NULL);  // 如果这里失败,说明系统内存真的不够了
    
    // 使用buf...
}
注意:断言在release版本中默认是关闭的(定义了NDEBUG宏)。所以断言只适合检查「程序内部逻辑错误」,不适合检查「外部输入错误」。外部输入错误应该用返回码枚举来处理。

说白了,断言和返回码的分工很明确:断言管「不该发生的事」,返回码管「可能发生的事」。比如用户输入了一个非法值,这是「可能发生的」,用返回码;但函数内部指针被意外置空,这是「不该发生的」,用断言。

4. setjmp/longjmp:C语言里的「异常模拟」

说到异常处理,Java有try-catch,C++有try-catch,但C语言没有。不过,C语言提供了setjmp和longjmp,可以模拟出类似异常处理的效果。

我第一次用setjmp/longjmp是在一个协议栈项目里。那个协议栈有七八层嵌套调用,每一层都可能出错。如果用返回码,每一层都要写错误传播代码,太痛苦了。后来我用setjmp/longjmp实现了异常处理,代码量减少了一半。

#include <setjmp.h>

jmp_buf exception_env;

typedef enum {
    EXCEPTION_NONE = 0,
    EXCEPTION_MEMORY,
    EXCEPTION_IO,
    EXCEPTION_PROTOCOL
} ExceptionType;

void handle_error(ExceptionType ex) {
    switch (ex) {
        case EXCEPTION_MEMORY:
            printf("内存错误\n");
            break;
        case EXCEPTION_IO:
            printf("IO错误\n");
            break;
        case EXCEPTION_PROTOCOL:
            printf("协议错误\n");
            break;
        default:
            printf("未知错误\n");
    }
}

void risky_function() {
    // 模拟一个深层调用中的错误
    if (some_condition) {
        longjmp(exception_env, EXCEPTION_MEMORY);
    }
}

int main() {
    int ret = setjmp(exception_env);
    if (ret == 0) {
        // 正常执行路径
        risky_function();
        printf("执行成功\n");
    } else {
        // 异常处理路径
        handle_error((ExceptionType)ret);
    }
    return 0;
}
核心思想:setjmp设置一个「跳转点」,longjmp跳回到这个点。setjmp的返回值表示「是第一次到达(0)」还是「从longjmp跳回来的(非0)」。利用这个特性,我们可以模拟try-catch的行为。

不过,setjmp/longjmp有个大坑:它会跳过栈上的局部变量析构。在C语言里虽然没有析构函数,但如果你在函数里分配了资源(比如malloc、fopen),longjmp跳走时这些资源不会被释放。我曾经因为这个原因导致内存泄漏,排查了好久才发现。

避坑指南:使用setjmp/longjmp时,一定要确保跳转点之前的所有资源都被正确管理。我个人的做法是:在setjmp之前先注册一个清理函数,或者在跳转点之后统一释放资源。

5. 四种方式的对比与选择

说了这么多,到底该用哪种?我整理了一个对比表,方便你根据场景选择:

方式 适用场景 优点 缺点
errno 系统调用、库函数 标准、兼容性好 线程不安全、可读性差
返回码枚举 业务逻辑、模块接口 可读性强、显式控制 代码膨胀、传播麻烦
断言 内部逻辑检查、调试 快速定位bug release版关闭
setjmp/longjmp 深层嵌套、异常模拟 简化错误传播 资源管理复杂

我个人习惯的组合是:

  • 模块内部用返回码枚举,每个函数都返回ErrorCode
  • 关键入口加断言,防止调用方传非法参数
  • 深层调用链用setjmp/longjmp,但只在「异常路径」使用,正常路径还是用返回码
  • 系统调用保留errno,但封装成自己的错误处理函数

你想想看,错误处理不是「选一种用到底」的事。不同的场景、不同的团队、不同的项目阶段,适合的方式都不一样。关键是理解每种方式的本质,然后灵活组合。

我的经验:如果你在重构一个遗留系统,建议先从「统一返回码枚举」开始。这是投入产出比最高的改进——改动量不大,但代码可读性和可维护性提升非常明显。等返回码枚举稳定了,再考虑引入setjmp/longjmp来简化深层调用链。

最后说一句:错误处理不是「锦上添花」,而是「雪中送炭」。一个没有错误处理的系统,就像一栋没有消防通道的大楼——平时看着挺好,一旦出事,后果不堪设想。

C语言错误处理进化路线图 errno 全局错误码 返回码枚举 显式错误类型 断言 内部逻辑检查 异常模拟 setjmp/longjmp → 进化 → → 进化 → → 进化 → 各方式特性对比 特性 errno 返回码枚举 异常模拟 线程安全 ✗ 不安全 ✓ 安全 ⚠ 需注意 可读性 中等 优秀 中等 适用调用深度 浅层 任意 深层 资源管理 简单 简单 复杂
公众号:蓝海资料掘金营,微信deep3321