第4章:错误处理设计 – 返回码与错误信息

错误处理这事,说白了就是让别人知道“你挂了,而且你是怎么挂的”。我在早期做C库的时候,觉得函数能跑就行,错误码随便返回个-1就完事了。结果被合作方吐槽说“你这API跟个黑盒似的,我根本不知道哪步错了”。从那以后,我就再也不敢糊弄错误处理了。

这一章,我们聊聊怎么设计一套靠谱的错误处理机制。我保证,这些东西你在实际项目中早晚会遇到。

错误码的设计哲学

一个设计良好的错误码,应该像一本字典。调用者看一眼就知道大概出了什么问题。怎么做到?

  • 不要用魔法数字:直接返回-1、0、1这种数字,是对调用者的折磨。你想想看,谁记得住-1代表啥?
  • 系统性区分错误类别:用高位表示模块,低位表示具体错误。这样一眼就能看出是哪个模块出的问题。
  • 成功永远是0:这是一个不成文的约定。C语言里0表示成功,非0表示失败。别打破这个规矩,不然别人会骂你的。

我的个人习惯:我一般会用枚举来定义错误码,而不是宏。枚举在调试器里能看到符号,宏就只能看到一堆数字了。

来看看一个比较典型的错误码设计(嗯,这里要注意,模块ID不要冲突):

// 模块ID定义
#define MOD_NET      0x0100
#define MOD_DB       0x0200
#define MOD_CONFIG   0x0300

// 网络模块错误码
enum {
    ERR_NET_OK       = 0,
    ERR_NET_TIMEOUT  = MOD_NET | 1,
    ERR_NET_DISCONN  = MOD_NET | 2,
    ERR_NET_BUF_FULL = MOD_NET | 3,
};

// 数据库模块错误码
enum {
    ERR_DB_OK        = 0,
    ERR_DB_OPEN      = MOD_DB | 1,
    ERR_DB_QUERY     = MOD_DB | 2,
};

错误信息的管理

光有错误码还不够。你得告诉调用者“为什么失败”。我曾经在项目中见过一个API,失败就返回一个数字,调试起来简直要命。

错误信息的管理有几种常见方式:

错误信息管理模式对比

方式 优点 缺点 适用场景
全局错误变量(errno模式) 简单、兼容性好 线程不安全、容易被覆盖 单线程工具库
返回码+错误字符串指针 明确、线程安全 调用者需要管理字符串 多线程通用库
错误回调函数 灵活、可自定义 增加回调复杂性 框架级API
线程局部存储(TLS) 线程安全、使用方便 跨平台兼容性差 特定平台优化

我个人比较推荐第二种方式。来看看具体实现:

// 错误信息表
static const char* err_msgs[] = {
    [ERR_NET_OK]       = "成功",
    [ERR_NET_TIMEOUT]  = "网络超时",
    [ERR_NET_DISCONN]  = "连接断开",
    [ERR_NET_BUF_FULL] = "缓冲区已满",
    [ERR_DB_OPEN]      = "数据库打开失败",
    [ERR_DB_QUERY]     = "数据库查询失败",
};

// 获取错误信息的API
const char* get_err_msg(int err_code) {
    if (err_code == 0) return "成功";

    // 提取模块ID和错误号
    int mod_id = err_code & 0xFF00;
    int err_no  = err_code & 0x00FF;

    // 查找对应的错误信息
    if (err_no >= 0 && err_no < sizeof(err_msgs)/sizeof(err_msgs[0])) {
        if (err_msgs[err_no] != NULL) {
            return err_msgs[err_no];
        }
    }
    return "未知错误";
}

核心原则:错误码和错误信息必须一一对应,不要出现“同一个错误码,不同错误信息”的情况。不然调试起来会怀疑人生。

错误链的设计

在大型项目中,一个函数失败往往是因为某个下层函数失败了。这时候,如果只返回一个上层错误码,调试起来就麻烦了。我们需要错误链。

错误链的核心思想是:把底层错误“包裹”起来,向上传递时保留上下文。说白了就是“谁甩锅给谁”的证据链。

// 错误链节点
typedef struct err_chain_node {
    int         code;           // 当前错误码
    const char* func;           // 出错的函数名
    const char* file;           // 出错的文件名
    int         line;           // 出错的行号
    struct err_chain_node* next; // 下一个错误节点(更底层)
} err_chain_node_t;

// 错误上下文
typedef struct {
    err_chain_node_t* head;
    err_chain_node_t* tail;
} err_ctx_t;

// 向错误链追加节点
void err_chain_push(err_ctx_t* ctx, int code,
                    const char* func, const char* file, int line) {
    err_chain_node_t* node = malloc(sizeof(err_chain_node_t));
    if (!node) return;

    node->code = code;
    node->func = func;
    node->file = file;
    node->line = line;
    node->next = NULL;

    if (ctx->tail) {
        ctx->tail->next = node;
    } else {
        ctx->head = node;
    }
    ctx->tail = node;
}

// 打印错误链
void err_chain_print(const err_ctx_t* ctx) {
    const err_chain_node_t* cur = ctx->head;
    int depth = 0;
    while (cur) {
        printf("[%d] %s at %s:%d (0x%04x)\n",
               depth++, cur->func, cur->file, cur->line, cur->code);
        cur = cur->next;
    }
}

避坑指南:我曾经在错误链的实现里忘记处理内存分配失败的情况,结果在压力测试下崩溃了。记住,错误处理本身也必须健壮,不能因为处理错误而引入新错误。

错误处理的整体流程

把上面的理念整合一下,你会得到这样一个流程:

错误处理核心流程 函数返回错误码 检查错误码是否为0 调用get_err_msg()获取错误描述 记录错误日志 构建/追加错误链 返回错误码 或 尝试恢复 错误生成阶段 1. 发生错误 2. 构造错误码 3. 记录错误链 4. 返回错误码 错误处理阶段 1. 接收错误码 2. 查询错误描述 3. 分析错误链 4. 处理/恢复

这个图分成两部分,左边是错误怎么产生,右边是错误怎么处理。我在做项目时,会强制要求每个模块都按这个流程来,效果不错。

实战中的几个要点

  1. 不要吞掉错误:在项目中我看到有人为了提高性能,在循环里直接跳过错误检查。结果查bug查了三天。
  2. 日志要统一格式:错误日志要包含时间戳、模块名、文件行号、错误码和错误信息。不然你会在几千行日志里找“是什么时候出的错”。
  3. 测试错误路径:我记得有一次,我信心满满地提交了代码,结果测试同事构造了一个场景:文件系统满了。我的API直接就崩溃了。嗯,从那以后我就知道:不测试错误路径的代码,都他妈是玩具。

总结几个实用经验:错误码用枚举、错误信息用表、错误链用链表、日志用统一格式。这四点做到,你的API基本就是个“文明人”了。