错误处理模块:统一的错误码定义、错误信息的字符串化、断言与日志的结合使用

说实话,错误处理这事儿,很多工程师一开始都不太当回事。我早年也犯过这个毛病——觉得功能跑通了就行,错误处理嘛,随便返回个 -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 表示成功,非零表示失败,这个约定已经深入人心。咱们别跟主流对着干。

我的小技巧: 错误码之间留一些间隔值(比如 -1, -2, -3...),方便以后插入新的错误码。别问我怎么知道的——我曾经在一个项目里把错误码排得满满当当,结果要加新码时只能重新编号,那叫一个酸爽。

二、错误信息的字符串化

光有数字错误码还不够。你想想,调试的时候看到 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 宏做了两件事:先记录详细的日志(包括文件名、行号、函数名、自定义信息),再触发断言。这样即使断言被禁用了(比如发布版本),日志还在,你还能从日志里找到线索。

注意: 断言在 release 版本里通常会被 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 记录错误,返回错误码
  • 内部断言:检查内存池魔数是否被破坏,这是不应该发生的事
  • 运行时日志:记录分配成功的信息,方便追踪

五、知识体系总览

下面这张图,把咱们今天讲的内容串起来了:

错误处理模块知识体系 统一错误码定义 error_code_t 枚举 错误信息字符串化 error_to_string() 断言与日志结合 ASSERT_AND_LOG 负数表示错误 0 表示成功 预留间隔值 查找表映射 越界保护 同步更新机制 日志记录上下文 断言触发中止 Release 模式降级 最终目标:可追溯、可定位、可恢复 错误码 → 字符串 → 日志 → 断言 → 快速定位问题

六、一些实战建议

最后,分享几个我踩过的坑:

  1. 别在中断服务函数里用断言。中断里一旦触发断言,整个系统可能直接挂掉。我建议中断里只用日志,把错误记录下来,等主循环再去处理。
  2. 日志别打太多。我曾经在一个项目里,每分配一次内存就打印一行日志。结果系统跑着跑着就卡死了——日志缓冲区满了,CPU 全花在刷日志上。后来我加了日志级别控制,生产环境只开 WARN 以上级别。
  3. 错误码要设计成可扩展的。模块化的错误码,比如 ERR_MOD_A_001ERR_MOD_B_001,比全局统一编号好维护得多。我现在的做法是:高位表示模块 ID,低位表示具体错误。
  4. 断言信息要写清楚。别只写 "assert failed",要写 "assert failed: pool->magic == POOL_MAGIC, file mem_pool.c, line 128"。这样一看就知道哪里出了问题。
一句话总结: 好的错误处理模块,应该让你在出问题时,三分钟内定位到根因。如果做不到,说明你的错误处理设计还有改进空间。

好了,今天就聊到这儿。错误处理这事儿,说白了就是「防患于未然」。你花在设计上的每一分钟,都会在调试时省下十倍的时间。嗯,这话是我自己总结的,但确实是大实话。