错误处理与日志模块:错误码定义、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;
}
你看,每个错误路径都有对应的日志,而且日志里包含了关键参数。这样出问题的时候,光看日志就能定位到具体原因。
知识体系总览
下面这张图,把错误处理和日志模块的核心逻辑串起来了:
总结一下:错误处理和日志模块,说白了就是给程序装上一双眼睛。错误码告诉你哪里出了问题,日志告诉你问题是怎么发生的。两者配合好了,调试效率能提升好几倍。
嗯,这里要注意一点:别把日志模块做得太复杂。我见过有人给日志模块加了十几层抽象,结果日志本身出了问题,反而没法调试了。保持简单,够用就好。
公众号:蓝海资料掘金营,微信deep3321