17、函数与错误处理:错误码返回,errno全局变量,setjmp/longjmp的非局部跳转

大家好,我是你们的老朋友。今天我们来聊聊C语言里一个特别接地气,但又特别容易翻车的话题——函数与错误处理。

说实话,我刚开始写C程序那会儿,总觉得错误处理是“别人家的事”。反正程序跑起来就对了,出错了?重启一下呗。直到有一次,我在一个嵌入式项目里,因为没检查一个函数的返回值,导致整个系统在野外跑了三天后突然死机……嗯,从那以后,我再也不敢小看错误处理了。

C语言不像C++或Java那样有try-catch机制。它给我们的武器,说白了就三样:错误码返回errno全局变量、还有setjmp/longjmp的非局部跳转。今天我们就把这仨家伙掰开揉碎了讲清楚。

17.1 错误码返回:最朴素的防御

错误码返回,是C语言里最基础、最常用的错误处理方式。说白了,就是让函数返回一个值,告诉你“我干成了没”。

我个人习惯,把返回值设计成int类型:0表示成功,非0表示错误。当然,你也可以用枚举,更清晰。

// 一个简单的文件读取函数
int read_config(const char *path, char *buf, size_t size) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        return -1;  // 文件打开失败
    }
    
    if (fgets(buf, size, fp) == NULL) {
        fclose(fp);
        return -2;  // 读取失败
    }
    
    fclose(fp);
    return 0;  // 成功
}

你想想看,调用者拿到返回值后,只需要一个简单的if判断,就能知道函数执行情况。简单、直接、没有额外开销。

核心原则:每个可能失败的函数,都应该返回错误码。不要指望调用者去猜你出了什么问题。

但是,这里有个坑——调用者可能不检查返回值。我在项目中遇到过,有人写了这样的代码:

read_config("/etc/myapp.conf", buf, sizeof(buf));
// 然后直接使用buf里的数据……

如果read_config失败了,buf里的数据就是未初始化的垃圾。程序可能崩溃,也可能产生错误的结果。这种bug特别难查,因为它不是每次都崩溃,而是“偶尔抽风”。

我曾经在一个通信协议栈里,因为没检查一个socket发送函数的返回值,导致数据在网络上丢失了,对方收不到完整报文。排查了整整两天,最后发现是发送缓冲区满了,函数返回了-1,而我直接忽略了。

17.2 errno全局变量:错误信息的“小纸条”

错误码返回虽然好用,但它只能告诉你“出错了”,却说不清楚“出了什么错”。比如上面的例子,-1到底是因为文件不存在?还是权限不够?还是路径太长了?

这时候,errno就派上用场了。

errno是一个全局变量(其实是宏,但你可以当变量用),每个线程都有自己的副本(在多线程环境下)。当一个系统调用或库函数出错时,它会设置errno为一个特定的错误码。

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

int main() {
    FILE *fp = fopen("/nonexistent/file.txt", "r");
    if (fp == NULL) {
        printf("错误码: %d\n", errno);
        printf("错误描述: %s\n", strerror(errno));
        perror("fopen失败");
    }
    return 0;
}

输出大概是这样的:

错误码: 2
错误描述: No such file or directory
fopen失败: No such file or directory

你看,errno一下子就告诉你是“文件或目录不存在”,而不是“权限不够”或“内存不足”。这对调试来说,简直是救命稻草。

小技巧:我个人习惯用perror()来打印错误信息,它会自动加上你自定义的提示文字,非常方便。strerror()则适合把错误信息格式化到日志里。

但是,errno也有个让人头疼的问题——它不会自动清零。如果一个函数成功了,它不会把errno设为0。所以,正确的做法是:在调用可能设置errno的函数之前,先把errno置为0

errno = 0;
FILE *fp = fopen("somefile.txt", "r");
if (fp == NULL) {
    // 此时errno才有意义
    perror("打开文件失败");
} else {
    // 如果成功,errno可能是上次遗留的值,不要相信它
}

我曾经在一个多线程程序里,因为没注意errno的线程安全性,导致一个线程的错误信息被另一个线程覆盖了。排查了好久才发现,原来errno虽然是线程局部存储,但我在一个信号处理函数里调用了可能设置errno的函数……嗯,信号处理函数里尽量别干复杂的事。

17.3 setjmp/longjmp:C语言的“时空穿越”

错误码和errno虽然好用,但它们有一个共同的局限:只能逐层返回。如果你的函数调用链很深,比如A调用B,B调用C,C调用D,D出错了,你得一层一层地返回错误码,每一层都要检查、传递。代码会变得非常啰嗦。

这时候,setjmp/longjmp就登场了。它允许你从一个函数直接跳回到调用链中更早的某个位置,就像“时空穿越”一样。

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

jmp_buf env;

void deep_function() {
    printf("进入深层函数\n");
    // 发生严重错误,直接跳回
    longjmp(env, 1);
    printf("这行不会执行\n");
}

int main() {
    if (setjmp(env) == 0) {
        printf("正常路径\n");
        deep_function();
        printf("这行也不会执行\n");
    } else {
        printf("错误处理路径,从深层函数直接跳回来了\n");
    }
    return 0;
}

输出:

正常路径
进入深层函数
错误处理路径,从深层函数直接跳回来了

你看,deep_function里的longjmp直接跳回了main函数里setjmp的位置,而且setjmp返回了非0值(我们传的1),从而进入了else分支。

这玩意儿在嵌入式系统里特别有用。比如,你有一个深度嵌套的解析器,遇到语法错误时,与其一层层返回错误码,不如直接longjmp到一个统一的错误处理点。

核心机制:setjmp保存当前的执行上下文(包括程序计数器、栈指针、寄存器等)到jmp_buf中。longjmp则恢复这个上下文,让程序“跳回”到setjmp的位置。

但是,这玩意儿有坑,而且坑不小

17.4 setjmp/longjmp的避坑指南

说实话,setjmp/longjmp虽然强大,但我个人建议慎用。为什么?

  1. 资源泄漏:如果你在A函数里malloc了一块内存,然后跳到了B函数,这块内存就永远没人释放了。
  2. 栈状态混乱:longjmp会恢复栈指针,但不会恢复局部变量的值(除非是volatile)。
  3. 中间状态不一致:如果跳转时,某个数据结构正处于半更新状态,那后果不堪设想。
// 危险的例子
jmp_buf env;

void risky_function() {
    char *buf = malloc(1024);
    if (buf == NULL) {
        longjmp(env, 1);  // 内存泄漏!buf没释放
    }
    // 使用buf...
    free(buf);
}

我曾经在一个网络协议栈里,用setjmp/longjmp来实现“超时重试”机制。结果发现,每次超时跳转后,socket的状态就变得不可预测了。后来我改用状态机+超时标志,反而更稳定。

那么,什么时候适合用setjmp/longjmp呢?

  • 深度嵌套的错误处理:比如解析器、编译器,遇到致命错误时直接跳转到顶层错误处理。
  • 信号处理:在某些信号处理场景下,可以用longjmp跳出信号处理函数。
  • 协程模拟:在一些没有线程支持的嵌入式系统里,可以用setjmp/longjmp模拟简单的协程切换。

17.5 三种错误处理方式的对比

为了让你看得更清楚,我整理了一个表格:

方式 优点 缺点 适用场景
错误码返回 简单、高效、无额外开销 调用者可能忽略;深层调用链传递麻烦 大多数函数,尤其是库函数
errno全局变量 提供详细错误信息;标准库广泛使用 不会自动清零;多线程需注意线程安全 系统调用、标准库函数
setjmp/longjmp 可以跨函数跳转;简化深层错误处理 资源泄漏风险;栈状态复杂;易出错 深度嵌套错误处理、信号处理、协程模拟

17.6 知识体系总览

为了让你对本章内容有个整体把握,我画了一张图:

C语言函数错误处理机制 错误码返回 errno全局变量 setjmp/longjmp 特点 • 返回int值:0成功,非0失败 • 调用者必须检查返回值 • 简单高效,无额外开销 • 深层调用链传递麻烦 • 适合大多数库函数 ⚠ 调用者可能忽略 ⚠ 错误信息不够详细 特点 • 全局变量(线程局部) • 提供详细错误码 • perror()/strerror()打印 • 标准库广泛使用 • 需配合错误码返回使用 ⚠ 不会自动清零 ⚠ 多线程需注意 特点 • 跨函数跳转 • setjmp保存上下文 • longjmp恢复跳转 • 适合深度嵌套错误 • 信号处理/协程模拟 ⚠ 资源泄漏风险 ⚠ 栈状态复杂 选择建议:日常开发优先用错误码+errno;深度嵌套场景谨慎使用setjmp/longjmp

17.7 我的个人建议

讲了这么多,最后说说我的个人习惯吧。

在大多数项目中,我优先使用错误码返回,配合errno提供详细信息。这样代码清晰、可维护性强。对于深度嵌套的解析器或状态机,我会考虑setjmp/longjmp,但一定会做好资源清理工作。

记住一句话:错误处理不是锦上添花,而是程序的最后一道防线。你永远不知道用户会在什么奇怪的环境下运行你的代码。多检查一次返回值,可能就少一次线上事故。

好了,今天就聊到这里。希望你在自己的项目里,能把错误处理做得滴水不漏。


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