错误处理的进化:从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还有个问题:它只能表示「发生了什么错误」,但没法告诉你「具体是哪个资源出错了」。比如你同时操作了三个文件,其中一个打开失败,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检查全部改成了枚举返回码,代码可读性提升了一大截。
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...
}
说白了,断言和返回码的分工很明确:断言管「不该发生的事」,返回码管「可能发生的事」。比如用户输入了一个非法值,这是「可能发生的」,用返回码;但函数内部指针被意外置空,这是「不该发生的」,用断言。
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有个大坑:它会跳过栈上的局部变量析构。在C语言里虽然没有析构函数,但如果你在函数里分配了资源(比如malloc、fopen),longjmp跳走时这些资源不会被释放。我曾经因为这个原因导致内存泄漏,排查了好久才发现。
5. 四种方式的对比与选择
说了这么多,到底该用哪种?我整理了一个对比表,方便你根据场景选择:
| 方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| errno | 系统调用、库函数 | 标准、兼容性好 | 线程不安全、可读性差 |
| 返回码枚举 | 业务逻辑、模块接口 | 可读性强、显式控制 | 代码膨胀、传播麻烦 |
| 断言 | 内部逻辑检查、调试 | 快速定位bug | release版关闭 |
| setjmp/longjmp | 深层嵌套、异常模拟 | 简化错误传播 | 资源管理复杂 |
我个人习惯的组合是:
- 模块内部用返回码枚举,每个函数都返回ErrorCode
- 关键入口加断言,防止调用方传非法参数
- 深层调用链用setjmp/longjmp,但只在「异常路径」使用,正常路径还是用返回码
- 系统调用保留errno,但封装成自己的错误处理函数
你想想看,错误处理不是「选一种用到底」的事。不同的场景、不同的团队、不同的项目阶段,适合的方式都不一样。关键是理解每种方式的本质,然后灵活组合。
最后说一句:错误处理不是「锦上添花」,而是「雪中送炭」。一个没有错误处理的系统,就像一栋没有消防通道的大楼——平时看着挺好,一旦出事,后果不堪设想。