错误处理接口设计:返回码、errno、异常处理的C语言实现方式
错误处理这事儿,说起来简单,做起来全是坑。
我早年做嵌入式项目时,就吃过错误处理的亏。一个传感器驱动,返回值乱成一锅粥——有的函数返回0表示成功,有的返回1表示成功,还有的返回负数表示错误码。结果呢?联调时三天没睡好觉,就为了追一个“明明返回了成功,数据却不对”的bug。
后来我悟了:错误处理接口,是模块间最基础的契约。契约写得不清楚,后面全是扯皮。
1. 返回码设计:别让调用者猜
返回码是最直接的方式。函数执行完,扔一个整数回来,调用者看一眼就知道成没成。
但这里有个关键点:返回码必须统一。
我个人的习惯是:
- 0 表示成功
- 正数表示警告或特殊状态
- 负数表示错误码
举个例子,一个简单的文件读取接口:
// 错误码定义
#define ERR_SUCCESS 0
#define ERR_NOT_FOUND -1
#define ERR_PERMISSION -2
#define ERR_TIMEOUT -3
#define ERR_BUSY -4
int file_read(const char* path, uint8_t* buffer, size_t size);
调用者拿到返回值,直接跟预定义的宏比较就行。清晰,简单。
小技巧:错误码最好用枚举或宏定义,别用裸数字。我曾经在一个项目里看到返回-255的,查了半天文档才知道是“缓冲区溢出”。你说这谁记得住?
2. errno 模式:全局变量的利与弊
有些场景下,函数返回值已经被占用了——比如要返回指针、句柄之类的。这时候,errno 模式就派上用场了。
C标准库就是这么干的。fopen返回NULL时,你去看errno,就知道是文件不存在还是权限不够。
#include <errno.h>
FILE* fp = fopen("config.ini", "r");
if (fp == NULL) {
// 看 errno 判断具体原因
if (errno == ENOENT) {
printf("文件不存在\n");
} else if (errno == EACCES) {
printf("权限不足\n");
}
}
但我要提醒你:errno 是全局的,多线程环境下容易出问题。
我记得有一次排查一个偶发性的bug,折腾了两天。最后发现是线程A调用了某个函数,还没来得及检查errno,线程B就把它覆盖了。嗯,这种问题最难复现。
避坑指南:多线程环境下,要么用线程局部存储(TLS)版本的errno,要么干脆别用errno模式。我后来在项目中强制规定:所有新代码一律用返回码,禁止使用全局errno。
3. C语言中的“异常处理”:setjmp/longjmp
说到异常处理,C语言其实没有C++那种try-catch。但我们可以用setjmp和longjmp模拟出类似的效果。
原理很简单:setjmp设置一个“检查点”,longjmp跳回那个检查点。有点像goto,但能跨函数跳转。
#include <setjmp.h>
jmp_buf exception_env;
void risky_function() {
if (something_wrong) {
longjmp(exception_env, 1); // 跳回检查点
}
}
int main() {
if (setjmp(exception_env) == 0) {
// 正常执行路径
risky_function();
} else {
// 异常处理路径
printf("出错了,已恢复\n");
}
}
说实话,这玩意儿我很少用。为什么?跳转会跳过栈上的资源释放。你想想看,如果longjmp之前malloc了一块内存,跳回去之后那块内存就泄漏了。
我的建议:setjmp/longjmp只适合用在“发生了严重错误,程序需要快速恢复”的场景。比如底层硬件故障、看门狗超时等。日常的业务逻辑错误,老老实实用返回码。
4. 如何设计健壮的错误处理接口
讲了这么多,到底怎么设计才算“健壮”?我总结了三条原则:
4.1 错误信息要完整
光返回一个错误码是不够的。调用者还需要知道:哪个模块出的错?具体原因是什么?有没有上下文信息?
我见过一个设计,错误码只有ERR_FAILED(-1)。你想想看,一个函数返回-1,你根本不知道是参数错了、硬件挂了、还是内存不够。这种接口,调试起来能把你逼疯。
比较好的做法是分层设计:
// 错误码结构
typedef struct {
int32_t code; // 错误码
uint8_t module_id; // 模块ID
uint16_t line; // 出错行号(调试用)
const char* msg; // 错误描述
} error_info_t;
error_info_t sensor_read(uint8_t channel, int32_t* value);
4.2 错误传播要清晰
模块A调用模块B,模块B出错了。模块A应该怎么做?
- 直接透传:把B的错误码原样返回给上层。适合“我也不知道怎么处理”的情况。
- 包装后传播:加上自己的上下文信息。比如“传感器读取失败,原因是I2C通信超时”。
- 吞掉并重试:有些临时性错误,重试一次就好了。但要注意重试次数,别死循环。
个人经验:我一般会在每个模块的入口处做一次错误转换。底层错误码只在本模块内部使用,对外统一用模块自己的错误码体系。这样上层调用者不用关心底层细节,耦合度就降低了。
4.3 错误处理不能影响正常流程
这一点很多人容易忽略。错误处理代码本身,不能引入新的bug。
举个例子:
// 不好的写法
int result = do_something();
if (result != 0) {
// 错误处理
cleanup();
return result;
}
// 正常流程
// 更好的写法
int result = do_something();
if (result != 0) {
goto error_handling;
}
// 正常流程
return 0;
error_handling:
cleanup();
return result;
用goto统一处理错误,比在每个if分支里写cleanup要安全得多。至少你不会漏掉某个分支的资源释放。
5. 一张图总结错误处理设计
下面这张图,是我做错误处理接口设计时的核心思路。你可以把它当成一个检查清单:
6. 避坑指南:我踩过的几个坑
最后,分享几个我亲身经历过的错误处理“翻车现场”:
- 坑一:错误码重复。两个模块各自定义了ERR_TIMEOUT,一个值是-3,另一个是-5。联调时查了半天才发现。后来我强制要求所有错误码统一登记在同一个头文件里。
- 坑二:忽略返回值。有些同事写代码,调了函数但不检查返回值。我问为什么,他说“这个函数肯定不会失败”。结果呢?硬件一异常,程序直接跑飞。记住:任何函数都可能失败。
- 坑三:错误处理路径没测试。正常流程跑得飞起,一触发错误就死机。为什么?因为错误处理代码从来没被执行过,里面藏着野指针和空悬指针。所以,错误处理路径也要写单元测试。
总结一句话:错误处理不是“万一出错了怎么办”,而是“出错了之后,系统还能不能体面地活下去”。设计接口时,把错误处理当成一等公民来对待,你的代码会少很多坑。
公众号:蓝海资料掘金营,微信deep3321