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)
第二步:错误传播宏
错误传播,说白了就是「如果出错了,就别往下走了」。但直接 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)
第四步:完整的 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 标签,不要搞多个。否则代码逻辑会乱成一团。
- 清理顺序要逆序:先申请的资源后释放,后申请的先释放。这样可以避免依赖关系导致的问题。
- 宏参数不要有副作用:比如
CHECK_AND_GOTO(ptr = malloc(100), cleanup)这种写法,宏展开后可能会执行多次。我建议把有副作用的操作单独写在外面。 - 错误码不要用 0:0 表示成功,这是约定俗成的。如果你用 0 表示错误,那所有检查逻辑都得反过来写,容易搞混。
好了,这套错误处理框架我用了很多年,从单片机到 Linux 应用层都适用。你想想看,如果每个函数都这样写,代码的健壮性会提升一大截。而且后期维护时,看到 cleanup 标签就知道资源释放都在那里,心里踏实多了。