第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;
}
}
避坑指南:我曾经在错误链的实现里忘记处理内存分配失败的情况,结果在压力测试下崩溃了。记住,错误处理本身也必须健壮,不能因为处理错误而引入新错误。
错误处理的整体流程
把上面的理念整合一下,你会得到这样一个流程:
这个图分成两部分,左边是错误怎么产生,右边是错误怎么处理。我在做项目时,会强制要求每个模块都按这个流程来,效果不错。
实战中的几个要点
- 不要吞掉错误:在项目中我看到有人为了提高性能,在循环里直接跳过错误检查。结果查bug查了三天。
- 日志要统一格式:错误日志要包含时间戳、模块名、文件行号、错误码和错误信息。不然你会在几千行日志里找“是什么时候出的错”。
- 测试错误路径:我记得有一次,我信心满满地提交了代码,结果测试同事构造了一个场景:文件系统满了。我的API直接就崩溃了。嗯,从那以后我就知道:不测试错误路径的代码,都他妈是玩具。
总结几个实用经验:错误码用枚举、错误信息用表、错误链用链表、日志用统一格式。这四点做到,你的API基本就是个“文明人”了。