错误处理接口设计:返回码、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. 一张图总结错误处理设计

下面这张图,是我做错误处理接口设计时的核心思路。你可以把它当成一个检查清单:

错误处理接口设计核心思路 设计原则 错误信息要完整 错误传播要清晰 不影响正常流程 具体做法 错误码 + 模块ID + 描述信息 分层错误码体系 具体做法 透传 / 包装 / 吞掉重试 模块入口做错误转换 具体做法 统一错误处理路径(goto) 避免全局errno 实现方式:返回码 | errno(慎用) | setjmp/longjmp(仅限严重错误)

6. 避坑指南:我踩过的几个坑

最后,分享几个我亲身经历过的错误处理“翻车现场”:

  • 坑一:错误码重复。两个模块各自定义了ERR_TIMEOUT,一个值是-3,另一个是-5。联调时查了半天才发现。后来我强制要求所有错误码统一登记在同一个头文件里。
  • 坑二:忽略返回值。有些同事写代码,调了函数但不检查返回值。我问为什么,他说“这个函数肯定不会失败”。结果呢?硬件一异常,程序直接跑飞。记住:任何函数都可能失败
  • 坑三:错误处理路径没测试。正常流程跑得飞起,一触发错误就死机。为什么?因为错误处理代码从来没被执行过,里面藏着野指针和空悬指针。所以,错误处理路径也要写单元测试

总结一句话:错误处理不是“万一出错了怎么办”,而是“出错了之后,系统还能不能体面地活下去”。设计接口时,把错误处理当成一等公民来对待,你的代码会少很多坑。


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