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虽然强大,但我个人建议慎用。为什么?
- 资源泄漏:如果你在A函数里malloc了一块内存,然后跳到了B函数,这块内存就永远没人释放了。
- 栈状态混乱:longjmp会恢复栈指针,但不会恢复局部变量的值(除非是volatile)。
- 中间状态不一致:如果跳转时,某个数据结构正处于半更新状态,那后果不堪设想。
// 危险的例子
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 知识体系总览
为了让你对本章内容有个整体把握,我画了一张图:
17.7 我的个人建议
讲了这么多,最后说说我的个人习惯吧。
在大多数项目中,我优先使用错误码返回,配合errno提供详细信息。这样代码清晰、可维护性强。对于深度嵌套的解析器或状态机,我会考虑setjmp/longjmp,但一定会做好资源清理工作。
记住一句话:错误处理不是锦上添花,而是程序的最后一道防线。你永远不知道用户会在什么奇怪的环境下运行你的代码。多检查一次返回值,可能就少一次线上事故。
好了,今天就聊到这里。希望你在自己的项目里,能把错误处理做得滴水不漏。