错误处理与日志模块:错误码定义、errno机制、日志级别与输出

说实话,错误处理和日志模块,是很多C语言项目里最容易被忽视的部分。我见过太多项目,代码跑起来一切正常,一旦出问题,连个像样的错误信息都找不到。你想想看,一个没有日志的系统,就像一架没有黑匣子的飞机——出了问题只能靠猜。

今天我们就来聊聊,怎么在C语言里把错误处理和日志模块做得既专业又实用。

错误码定义:给每个错误一个身份

我个人习惯,在项目一开始就定义一套完整的错误码。别等到代码写了一半才想起来加,那时候就乱了。

错误码通常用枚举来定义,这样既清晰又方便扩展:

// error_codes.h
#ifndef ERROR_CODES_H
#define ERROR_CODES_H

typedef enum {
    ERR_OK          = 0,      // 一切正常
    ERR_NULL_PTR    = -1,     // 空指针
    ERR_INVALID_PARAM = -2,   // 无效参数
    ERR_MEM_ALLOC   = -3,     // 内存分配失败
    ERR_TIMEOUT     = -4,     // 操作超时
    ERR_IO_FAILURE  = -5,     // IO操作失败
    ERR_BUSY        = -6,     // 资源忙
    ERR_NOT_FOUND   = -7,     // 未找到
    ERR_PERMISSION  = -8,     // 权限不足
    ERR_UNKNOWN     = -99     // 未知错误
} ErrorCode_t;

#endif

小技巧:错误码最好用负数,0表示成功。这样在条件判断里可以直接写 if (ret != ERR_OK),读起来很自然。

我在项目中遇到过一个问题:不同模块的错误码范围重叠了。比如模块A的ERR_TIMEOUT是-4,模块B的ERR_TIMEOUT也是-4,但含义完全不同。后来我学乖了,给每个模块分配一个错误码段:

// 模块错误码范围
#define MODULE_BASE_IO       -100
#define MODULE_BASE_NETWORK  -200
#define MODULE_BASE_STORAGE  -300

typedef enum {
    ERR_IO_READ_FAIL  = MODULE_BASE_IO - 1,
    ERR_IO_WRITE_FAIL = MODULE_BASE_IO - 2,
    ERR_NET_CONNECT   = MODULE_BASE_NETWORK - 1,
    ERR_NET_TIMEOUT   = MODULE_BASE_NETWORK - 2,
    ERR_STORAGE_FULL  = MODULE_BASE_STORAGE - 1
} ModuleError_t;

errno机制:C标准库的遗产

C标准库里的errno机制,说白了就是一个全局的错误码变量。每次系统调用失败,它就会被设置成对应的错误值。

但这里有个坑——errno是线程不安全的。你想想看,两个线程同时调用系统函数,一个失败了,另一个成功了,errno到底是谁的?

注意:在多线程环境下,请使用 errno 的线程安全版本。Linux下用 __errno_location(),或者直接使用 strerror_r() 而不是 strerror()

我曾经在一个多线程项目里吃过这个亏。两个线程同时做文件操作,线程A的open失败了,线程B的read成功了。结果线程A去查errno,拿到的是线程B的返回值。排查了整整一天才找到原因。

正确的做法是这样:

#include <errno.h>
#include <string.h>

int safe_file_open(const char *path) {
    int fd = open(path, O_RDONLY);
    if (fd == -1) {
        // 立即保存errno,避免被后续操作覆盖
        int saved_errno = errno;
        char buf[256];
        strerror_r(saved_errno, buf, sizeof(buf));
        log_error("打开文件失败: %s, 错误: %s", path, buf);
        return -1;
    }
    return fd;
}

日志级别:信息要有层次

日志级别这东西,说白了就是给信息分个三六九等。我一般用这五个级别:

级别 数值 含义 使用场景
FATAL 0 致命错误 系统无法继续运行,必须立即处理
ERROR 1 一般错误 操作失败,但系统还能继续
WARN 2 警告 可能有问题,但当前操作成功
INFO 3 信息 正常流程的关键节点
DEBUG 4 调试 开发阶段用的详细输出

核心原则:生产环境只开INFO及以上级别。DEBUG日志太多,会拖慢系统性能。我见过有人把DEBUG日志开在生产环境,结果日志文件一天涨了10个G。

日志输出:写一个自己的日志模块

别用printf打日志。printf没有级别控制,没有时间戳,没有文件行号。我习惯自己写一个简单的日志模块:

// logger.h
#ifndef LOGGER_H
#define LOGGER_H

#include <stdio.h>
#include <time.h>

typedef enum {
    LOG_LEVEL_FATAL = 0,
    LOG_LEVEL_ERROR = 1,
    LOG_LEVEL_WARN  = 2,
    LOG_LEVEL_INFO  = 3,
    LOG_LEVEL_DEBUG = 4
} LogLevel_t;

// 设置当前日志级别
void log_set_level(LogLevel_t level);

// 核心日志函数
void log_message(LogLevel_t level, const char *file, 
                 int line, const char *fmt, ...);

// 宏定义,自动传入文件名和行号
#define LOG_FATAL(fmt, ...) \
    log_message(LOG_LEVEL_FATAL, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) \
    log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...)  \
    log_message(LOG_LEVEL_WARN, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...)  \
    log_message(LOG_LEVEL_INFO, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...) \
    log_message(LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)

#endif

实现部分也不复杂:

// logger.c
#include "logger.h"
#include <stdarg.h>
#include <string.h>

static LogLevel_t g_log_level = LOG_LEVEL_INFO;

static const char *level_names[] = {
    "FATAL", "ERROR", "WARN", "INFO", "DEBUG"
};

void log_set_level(LogLevel_t level) {
    g_log_level = level;
}

void log_message(LogLevel_t level, const char *file, 
                 int line, const char *fmt, ...) {
    if (level > g_log_level) {
        return;  // 级别不够,直接跳过
    }

    time_t now = time(NULL);
    struct tm *tm_info = localtime(&now);
    char time_buf[20];
    strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", tm_info);

    // 输出格式: [时间] [级别] 文件名:行号 - 消息
    fprintf(stderr, "[%s] [%s] %s:%d - ", 
            time_buf, level_names[level], file, line);

    va_list args;
    va_start(args, fmt);
    vfprintf(stderr, fmt, args);
    va_end(args);

    fprintf(stderr, "\n");
    fflush(stderr);  // 确保日志立即写入
}

建议:日志输出到stderr而不是stdout。这样即使程序把stdout重定向了,日志依然能正常显示。我在嵌入式设备上调试时,经常把stdout重定向到串口,stderr保留在本地,方便排查问题。

错误码与日志的联动

光有错误码和日志还不够,得把它们串起来。我常用的模式是这样:

ErrorCode_t read_sensor_data(int sensor_id, float *value) {
    if (value == NULL) {
        LOG_ERROR("空指针参数: sensor_id=%d", sensor_id);
        return ERR_NULL_PTR;
    }

    if (sensor_id < 0 || sensor_id > MAX_SENSORS) {
        LOG_ERROR("无效传感器ID: %d (范围: 0-%d)", 
                  sensor_id, MAX_SENSORS);
        return ERR_INVALID_PARAM;
    }

    int ret = i2c_read(sensor_id, value);
    if (ret != 0) {
        LOG_ERROR("读取传感器%d失败, I2C错误码=%d", sensor_id, ret);
        return ERR_IO_FAILURE;
    }

    LOG_DEBUG("传感器%d读取成功: value=%.2f", sensor_id, *value);
    return ERR_OK;
}

你看,每个错误路径都有对应的日志,而且日志里包含了关键参数。这样出问题的时候,光看日志就能定位到具体原因。

知识体系总览

下面这张图,把错误处理和日志模块的核心逻辑串起来了:

错误处理与日志模块架构 错误码定义 枚举 + 模块分段 errno机制 线程安全 + 立即保存 日志级别 FATAL/ERROR/WARN/INFO/DEBUG 日志输出模块 时间戳 + 文件名 + 行号 + 格式化消息 stderr输出 日志文件 串口/网络 核心原则:每个错误路径都有对应的日志输出

总结一下:错误处理和日志模块,说白了就是给程序装上一双眼睛。错误码告诉你哪里出了问题,日志告诉你问题是怎么发生的。两者配合好了,调试效率能提升好几倍。

嗯,这里要注意一点:别把日志模块做得太复杂。我见过有人给日志模块加了十几层抽象,结果日志本身出了问题,反而没法调试了。保持简单,够用就好。


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