23、宏实现错误处理框架:错误码宏定义,错误传播宏,资源清理宏(goto cleanup模式)

错误处理,说白了就是程序出问题时,你得知道怎么收场。我见过太多嵌入式项目,代码里充斥着 if (error) return -1; 这种写法,资源也不释放,最后系统跑着跑着就崩了。嗯,今天我们就用宏来搭一个靠谱的错误处理框架。

为什么需要统一的错误处理框架?

你想想看,一个函数里可能打开文件、申请内存、获取锁。如果中间某一步失败了,你得把前面已经申请的资源全部释放掉。如果每个函数都手写清理代码,那代码会变得又臭又长,还容易漏掉某个资源。

我个人习惯用 goto cleanup 模式。别一听 goto 就摇头,在错误处理这个场景下,它反而是最清晰的做法。配合宏定义,能让代码既简洁又安全。

第一步:定义错误码宏

错误码不能随便写个数字就完事。我在项目中遇到过,不同模块的错误码冲突,调试时根本分不清是哪个模块报的错。所以,错误码要有体系。

// 错误码基础定义
#define ERR_BASE_MODULE_A     0x1000
#define ERR_BASE_MODULE_B     0x2000
#define ERR_BASE_MODULE_C     0x3000

// 通用错误码
#define ERR_SUCCESS           0
#define ERR_GENERIC           -1
#define ERR_NOMEM             -2
#define ERR_TIMEOUT           -3
#define ERR_INVALID_PARAM     -4
#define ERR_BUSY              -5

// 模块A专用错误码
#define ERR_A_INIT_FAIL       (ERR_BASE_MODULE_A + 1)
#define ERR_A_CONFIG_INVALID  (ERR_BASE_MODULE_A + 2)

// 模块B专用错误码
#define ERR_B_COMM_FAIL       (ERR_BASE_MODULE_B + 1)
#define ERR_B_DATA_CORRUPT    (ERR_BASE_MODULE_B + 2)
我的习惯:每个模块预留 0x1000 的偏移量,这样看错误码的高字节就知道是哪个模块出的问题。调试时一眼就能定位。

第二步:错误传播宏

错误传播,说白了就是「如果出错了,就别往下走了」。但直接 return 会漏掉资源释放。所以我们需要一个宏,既能检查错误,又能跳转到统一的清理标签。

// 错误检查并跳转到清理标签
#define CHECK_AND_GOTO(expr, label)     \
    do {                                \
        int _ret = (expr);              \
        if (_ret != ERR_SUCCESS) {      \
            goto label;                 \
        }                               \
    } while(0)

// 带错误码记录的版本
#define CHECK_AND_RECORD(expr, label, err_var)  \
    do {                                        \
        (err_var) = (expr);                     \
        if ((err_var) != ERR_SUCCESS) {         \
            goto label;                         \
        }                                       \
    } while(0)

为什么要用 do { ... } while(0) 包起来?因为这样宏展开后,分号不会破坏 if-else 的配对。我曾经见过同事没加这个,结果宏展开后 else 匹配错了,查了一下午的 bug。

第三步:资源清理宏

资源清理是重头戏。不同的资源有不同的释放方式,但我们可以用宏来统一管理。

// 文件资源清理
#define CLEANUP_FILE(fp)            \
    do {                            \
        if ((fp) != NULL) {         \
            fclose(fp);             \
            (fp) = NULL;            \
        }                           \
    } while(0)

// 内存资源清理
#define CLEANUP_PTR(ptr)            \
    do {                            \
        if ((ptr) != NULL) {        \
            free(ptr);              \
            (ptr) = NULL;           \
        }                           \
    } while(0)

// 锁资源清理
#define CLEANUP_LOCK(lock)          \
    do {                            \
        if ((lock) != NULL) {       \
            pthread_mutex_unlock(lock); \
            (lock) = NULL;          \
        }                           \
    } while(0)

// 统一清理宏(根据类型选择)
#define CLEANUP_RESOURCE(res, type) \
    do {                            \
        switch (type) {             \
            case RES_FILE:          \
                CLEANUP_FILE(res);  \
                break;              \
            case RES_MEM:           \
                CLEANUP_PTR(res);   \
                break;              \
            case RES_LOCK:          \
                CLEANUP_LOCK(res);  \
                break;              \
            default:                \
                break;              \
        }                           \
    } while(0)
注意:清理后一定要把指针置为 NULL。否则 double free 会导致程序崩溃。我曾经在一个项目中,因为忘记置 NULL,同一个内存块被释放了两次,系统直接挂掉。

第四步:完整的 goto cleanup 模式

把上面的宏组合起来,就是一个完整的错误处理框架。看个实际例子:

int process_data(const char *filename) {
    FILE *fp = NULL;
    char *buffer = NULL;
    int ret = ERR_SUCCESS;
    int err_code = ERR_SUCCESS;

    // 打开文件
    fp = fopen(filename, "r");
    CHECK_AND_GOTO(fp != NULL, cleanup);

    // 申请内存
    buffer = (char *)malloc(1024);
    CHECK_AND_GOTO(buffer != NULL, cleanup);

    // 读取数据
    err_code = read_file(fp, buffer, 1024);
    CHECK_AND_RECORD(err_code, cleanup, ret);

    // 处理数据
    err_code = process_buffer(buffer);
    CHECK_AND_RECORD(err_code, cleanup, ret);

    // 一切正常
    ret = ERR_SUCCESS;
    goto cleanup;  // 正常情况也要走清理

cleanup:
    // 统一清理
    CLEANUP_FILE(fp);
    CLEANUP_PTR(buffer);

    return ret;
}

你看,不管中间哪一步出错,都会跳转到 cleanup 标签,统一释放资源。这样就不会有资源泄漏的问题了。

第五步:进阶技巧——带日志的错误传播宏

在实际项目中,光返回错误码还不够,你还需要知道出错的位置和原因。我习惯在宏里加上日志记录:

#define CHECK_AND_LOG(expr, label, fmt, ...)                    \
    do {                                                        \
        int _ret = (expr);                                      \
        if (_ret != ERR_SUCCESS) {                              \
            log_error("[%s:%d] " fmt, __FILE__, __LINE__,       \
                      ##__VA_ARGS__);                           \
            goto label;                                         \
        }                                                       \
    } while(0)

// 使用示例
int connect_and_send(const char *ip, int port) {
    int sock = -1;
    int ret = ERR_SUCCESS;

    sock = socket_create();
    CHECK_AND_LOG(sock >= 0, cleanup, "创建socket失败, ip=%s", ip);

    ret = socket_connect(sock, ip, port);
    CHECK_AND_LOG(ret == ERR_SUCCESS, cleanup, "连接失败, port=%d", port);

    // ... 发送数据

cleanup:
    if (sock >= 0) {
        socket_close(sock);
    }
    return ret;
}
核心思想:错误处理框架要解决三个问题——
1. 错误码要可追溯(模块+编号)
2. 错误传播要自动跳转到清理代码
3. 资源清理要统一、不漏掉

知识体系图

宏实现错误处理框架知识体系 错误码宏定义 错误传播宏 资源清理宏 goto cleanup 模式 完整错误处理框架 模块化 自动跳转 统一释放 集中清理 可追溯 带日志 防泄漏 可维护

避坑指南

我用这个模式写过几十万行代码,踩过不少坑。这里列几个最常见的:

  • 不要嵌套 goto:一个函数只用一个 cleanup 标签,不要搞多个。否则代码逻辑会乱成一团。
  • 清理顺序要逆序:先申请的资源后释放,后申请的先释放。这样可以避免依赖关系导致的问题。
  • 宏参数不要有副作用:比如 CHECK_AND_GOTO(ptr = malloc(100), cleanup) 这种写法,宏展开后可能会执行多次。我建议把有副作用的操作单独写在外面。
  • 错误码不要用 0:0 表示成功,这是约定俗成的。如果你用 0 表示错误,那所有检查逻辑都得反过来写,容易搞混。
我的经验:在大型项目中,我会把错误码定义放在一个单独的头文件里,每个模块维护自己的错误码区间。这样多人协作时不会冲突,而且通过错误码就能快速定位到出错的模块。

好了,这套错误处理框架我用了很多年,从单片机到 Linux 应用层都适用。你想想看,如果每个函数都这样写,代码的健壮性会提升一大截。而且后期维护时,看到 cleanup 标签就知道资源释放都在那里,心里踏实多了。

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