错误处理模块:统一的错误码定义、错误信息的字符串化、断言与日志的结合使用
说实话,错误处理这事儿,很多工程师一开始都不太当回事。我早年也犯过这个毛病——觉得功能跑通了就行,错误处理嘛,随便返回个 -1 完事。结果呢?产品上线后出了问题,光靠几个数字根本猜不到哪里崩了。那叫一个痛苦。
后来我慢慢悟出一个道理:错误处理不是事后补丁,而是系统设计的一部分。今天咱们就聊聊,怎么在 C 语言里把错误处理模块做得既规范又实用。
一、统一的错误码定义
你想想看,一个项目里如果每个人各写各的错误码,张三用 -1 表示内存不足,李四用 -2 表示参数错误,那代码根本没法维护。我个人的习惯是,所有错误码集中在一个头文件里定义,用枚举类型统一管理。
// error_codes.h
#ifndef ERROR_CODES_H
#define ERROR_CODES_H
typedef enum {
ERR_SUCCESS = 0, // 成功
ERR_GENERAL = -1, // 通用错误
ERR_INVALID_PARAM = -2, // 参数无效
ERR_OUT_OF_MEMORY = -3, // 内存不足
ERR_TIMEOUT = -4, // 超时
ERR_BUSY = -5, // 资源忙
ERR_NOT_FOUND = -6, // 未找到
ERR_PERMISSION = -7, // 权限不足
ERR_IO = -8, // IO错误
ERR_UNSUPPORTED = -9, // 不支持的操作
ERR_OVERFLOW = -10, // 溢出
ERR_UNDERFLOW = -11, // 下溢
ERR_CORRUPT = -12, // 数据损坏
ERR_AGAIN = -13, // 请重试
ERR_ABORT = -14, // 操作中止
ERR_MAX = -15 // 保留最大值
} error_code_t;
#endif // ERROR_CODES_H
这里有个细节:成功码用 0,错误码用负数。为什么?因为 C 语言里很多函数返回 0 表示成功,非零表示失败,这个约定已经深入人心。咱们别跟主流对着干。
二、错误信息的字符串化
光有数字错误码还不够。你想想,调试的时候看到 ERR_IO 和看到 "I/O operation failed: device not responding",哪个更直观?把错误码映射成可读的字符串,这是专业代码和业余代码的分水岭。
我常用的做法是搞一个查找表:
// error_strings.c
#include "error_codes.h"
static const char* error_strings[] = {
[ERR_SUCCESS] = "Success",
[ERR_GENERAL] = "General error",
[ERR_INVALID_PARAM] = "Invalid parameter",
[ERR_OUT_OF_MEMORY] = "Out of memory",
[ERR_TIMEOUT] = "Operation timed out",
[ERR_BUSY] = "Resource is busy",
[ERR_NOT_FOUND] = "Resource not found",
[ERR_PERMISSION] = "Permission denied",
[ERR_IO] = "I/O error",
[ERR_UNSUPPORTED] = "Unsupported operation",
[ERR_OVERFLOW] = "Buffer overflow",
[ERR_UNDERFLOW] = "Buffer underflow",
[ERR_CORRUPT] = "Data corruption detected",
[ERR_AGAIN] = "Please retry",
[ERR_ABORT] = "Operation aborted",
[ERR_MAX] = "Unknown error"
};
const char* error_to_string(error_code_t err) {
if (err > ERR_SUCCESS || err < ERR_MAX) {
return error_strings[ERR_MAX]; // 越界返回未知错误
}
return error_strings[-err]; // 注意:负数索引要取反
}
这里有个坑:C 语言的数组索引不能是负数。所以我在定义枚举时用了负数,但在查表时取绝对值。你也可以反过来,枚举用正数,查表时直接索引。我个人习惯用负数,因为这样和 POSIX 风格保持一致。
三、断言与日志的结合使用
断言(assert)和日志(log)是两兄弟,但很多人把它们割裂开来用。断言用于捕获不应该发生的内部错误,日志用于记录系统运行状态。把它们结合起来,效果是 1+1 > 2。
我设计了一个宏,既做断言检查,又记录日志:
// debug_utils.h
#ifndef DEBUG_UTILS_H
#define DEBUG_UTILS_H
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "error_codes.h"
// 日志级别
typedef enum {
LOG_LEVEL_DEBUG,
LOG_LEVEL_INFO,
LOG_LEVEL_WARN,
LOG_LEVEL_ERROR,
LOG_LEVEL_FATAL
} log_level_t;
// 日志输出函数(具体实现由平台决定)
void log_output(log_level_t level, const char* file, int line,
const char* func, const char* fmt, ...);
// 断言+日志组合宏
#define ASSERT_AND_LOG(cond, fmt, ...) do { \
if (!(cond)) { \
log_output(LOG_LEVEL_FATAL, __FILE__, __LINE__, __func__, \
"ASSERT FAILED: " fmt, ##__VA_ARGS__); \
assert(cond); \
} \
} while(0)
// 错误检查+日志宏
#define CHECK_ERROR(err, fmt, ...) do { \
if ((err) != ERR_SUCCESS) { \
log_output(LOG_LEVEL_ERROR, __FILE__, __LINE__, __func__, \
"Error %d: " fmt, (err), ##__VA_ARGS__); \
} \
} while(0)
#endif // DEBUG_UTILS_H
你看,这个 ASSERT_AND_LOG 宏做了两件事:先记录详细的日志(包括文件名、行号、函数名、自定义信息),再触发断言。这样即使断言被禁用了(比如发布版本),日志还在,你还能从日志里找到线索。
NDEBUG 宏禁用。所以不要用断言来做参数校验,那是 CHECK_ERROR 的活。断言只用来检查内部逻辑错误,比如指针不应该为 NULL、状态机不应该进入非法状态等。
四、实战中的组合用法
光说不练假把式。咱们看一个实际例子:一个简单的内存池分配函数。
error_code_t mem_pool_alloc(mem_pool_t* pool, size_t size, void** ptr) {
// 参数校验
if (pool == NULL || ptr == NULL) {
log_output(LOG_LEVEL_ERROR, __FILE__, __LINE__, __func__,
"Invalid parameters: pool=%p, ptr=%p", pool, ptr);
return ERR_INVALID_PARAM;
}
// 内部逻辑断言
ASSERT_AND_LOG(pool->magic == POOL_MAGIC,
"Pool corruption detected at %p", pool);
// 检查是否有足够空间
if (pool->free_size < size) {
log_output(LOG_LEVEL_WARN, __FILE__, __LINE__, __func__,
"Not enough memory: need %zu, available %zu",
size, pool->free_size);
return ERR_OUT_OF_MEMORY;
}
// 分配内存
*ptr = pool->base + pool->used_size;
pool->used_size += size;
pool->free_size -= size;
log_output(LOG_LEVEL_DEBUG, __FILE__, __LINE__, __func__,
"Allocated %zu bytes at %p, remaining %zu",
size, *ptr, pool->free_size);
return ERR_SUCCESS;
}
这个函数里,三种错误处理手段都用上了:
- 参数校验:用
log_output记录错误,返回错误码 - 内部断言:检查内存池魔数是否被破坏,这是不应该发生的事
- 运行时日志:记录分配成功的信息,方便追踪
五、知识体系总览
下面这张图,把咱们今天讲的内容串起来了:
六、一些实战建议
最后,分享几个我踩过的坑:
- 别在中断服务函数里用断言。中断里一旦触发断言,整个系统可能直接挂掉。我建议中断里只用日志,把错误记录下来,等主循环再去处理。
- 日志别打太多。我曾经在一个项目里,每分配一次内存就打印一行日志。结果系统跑着跑着就卡死了——日志缓冲区满了,CPU 全花在刷日志上。后来我加了日志级别控制,生产环境只开 WARN 以上级别。
- 错误码要设计成可扩展的。模块化的错误码,比如
ERR_MOD_A_001、ERR_MOD_B_001,比全局统一编号好维护得多。我现在的做法是:高位表示模块 ID,低位表示具体错误。 - 断言信息要写清楚。别只写 "assert failed",要写 "assert failed: pool->magic == POOL_MAGIC, file mem_pool.c, line 128"。这样一看就知道哪里出了问题。
好了,今天就聊到这儿。错误处理这事儿,说白了就是「防患于未然」。你花在设计上的每一分钟,都会在调试时省下十倍的时间。嗯,这话是我自己总结的,但确实是大实话。