错误处理与日志:errno、strerror()、perror()的跨平台行为、自定义日志库设计
说实话,错误处理这事儿,很多C语言开发者都把它当成了「后妈养的」。代码写得飞起,一遇到错误就随便打印个"Error!"了事。我早年也这么干过,直到有一次在Linux上跑得好好的程序,移植到某个嵌入式平台后,errno的值完全对不上号,排查了整整两天……嗯,从那以后,我再也不敢轻视跨平台的错误处理了。
今天咱们就好好聊聊这个话题。我会把errno、strerror()、perror()这些家伙的跨平台坑点,以及怎么设计一个靠谱的自定义日志库,一次性讲清楚。
一、errno:那个全局的「背锅侠」
errno是个全局整数变量,系统调用或库函数出错时,会给它赋一个错误码。听起来很简单对吧?但跨平台时,问题就来了。
1.1 不同平台,不同定义
我举个例子你就明白了。在Linux上,EAGAIN的值是11,而在某些Unix系统上,它可能是35。更离谱的是,有些平台压根没有EAGAIN,只有EWOULDBLOCK。
核心问题:错误码的数值在不同操作系统间没有统一标准。你写死的数字,换个平台就废了。
我个人习惯的做法是:永远不要直接比较errno的数值。用宏名,比如errno == EAGAIN,而不是errno == 11。这样编译器会帮你处理平台差异。
1.2 线程安全?别天真了
早期的errno就是个全局变量,多线程环境下,一个线程的错误码可能被另一个线程覆盖。你想想看,这得多坑?
好在现代平台都支持线程局部存储(TLS),errno被定义成了类似这样的宏:
// Linux glibc 中的实现(简化版)
#define errno (*__errno_location())
每个线程都有自己的errno副本。但要注意:Windows上的errno实现方式不同,它用的是_errno()函数。所以跨平台代码里,我建议统一用errno宏,别直接操作变量。
注意:某些老旧嵌入式RTOS可能不支持线程安全的errno。如果你在搞裸机或RTOS开发,务必查一下文档。
二、strerror() 与 perror():把错误码变成人话
errno只是个数字,谁记得住啊?所以我们需要strerror()和perror()来帮忙。
2.1 strerror():返回错误描述字符串
#include <string.h>
#include <errno.h>
int fd = open("nonexistent.txt", O_RDONLY);
if (fd == -1) {
printf("Error: %s\n", strerror(errno));
}
这段代码在Linux上会输出类似"Error: No such file or directory"的信息。但跨平台时,有两个坑:
- 返回值类型:Linux上返回
char *,Windows上返回char *(但某些旧版MSVC返回const char *)。 - 线程安全:标准的
strerror()可能使用静态缓冲区,多线程调用会互相覆盖。我建议用strerror_r()(Linux)或strerror_s()(Windows)这种线程安全版本。
我的建议:写跨平台代码时,自己封装一个线程安全的错误信息获取函数。下面是我常用的写法:
#ifdef _WIN32
#define strerror_r(errno, buf, buflen) strerror_s(buf, buflen, errno)
#endif
void safe_strerror(int errnum, char *buf, size_t buflen) {
if (buf == NULL || buflen == 0) return;
strerror_r(errnum, buf, buflen);
buf[buflen - 1] = '\0';
}
2.2 perror():直接打印到stderr
perror()更粗暴,它直接把错误信息打印到标准错误输出:
FILE *fp = fopen("config.ini", "r");
if (fp == NULL) {
perror("fopen failed");
}
输出类似:fopen failed: No such file or directory。
跨平台时,perror()的行为基本一致,但有个细节:Windows上perror()可能不会刷新输出缓冲区。我遇到过好几次,程序崩溃了,错误信息却没打印出来。后来我养成了习惯:在调用perror()后手动fflush(stderr)。
三、跨平台错误处理:一张表说清楚
我把常见的跨平台差异整理成了表格,方便你查阅:
| 特性 | Linux/Unix | Windows (MSVC) | 建议 |
|---|---|---|---|
| errno 定义 | 宏,线程局部存储 | 宏,调用 _errno() | 统一用 errno 宏 |
| 错误码数值 | POSIX 标准 | 部分兼容,部分不同 | 只用宏名,不用数字 |
| strerror() | 可能非线程安全 | 线程安全(较新版本) | 用 strerror_r/s |
| perror() | 自动刷新 stderr | 可能不刷新 | 手动 fflush(stderr) |
| 额外错误码 | POSIX 错误码 | GetLastError() | 封装统一接口 |
说到Windows的GetLastError(),这是个特别坑的地方。Windows的系统调用失败后,errno可能不更新,你得用GetLastError()获取真正的错误码。我曾经在移植一个网络库时,被这个坑得死去活来——Linux上connect()失败后errno是ECONNREFUSED,Windows上errno却是0,得用WSAGetLastError()。
四、自定义日志库设计
好了,错误处理的基础讲完了。但光靠perror()和strerror(),在实际项目中根本不够用。你需要一个自己的日志库。
我设计日志库时,遵循几个原则:
- 分级输出:DEBUG、INFO、WARN、ERROR、FATAL,至少五级。
- 可配置输出目标:控制台、文件、网络,甚至回调函数。
- 线程安全:多线程环境下不能乱。
- 跨平台:同一套API,Linux、Windows、嵌入式都能跑。
4.1 核心数据结构
typedef enum {
LOG_LEVEL_DEBUG = 0,
LOG_LEVEL_INFO,
LOG_LEVEL_WARN,
LOG_LEVEL_ERROR,
LOG_LEVEL_FATAL
} log_level_t;
typedef struct {
log_level_t level; // 当前日志级别
void (*output)(const char *msg); // 输出回调
void *user_data; // 用户自定义数据
int include_timestamp; // 是否包含时间戳
int include_file_line; // 是否包含文件名和行号
} logger_t;
4.2 核心API设计
// 初始化日志器
void logger_init(logger_t *logger, log_level_t level);
// 设置输出回调
void logger_set_output(logger_t *logger,
void (*output)(const char *msg));
// 日志宏(自动捕获文件名和行号)
#define LOG_DEBUG(logger, fmt, ...) \
log_message(logger, LOG_LEVEL_DEBUG, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(logger, fmt, ...) \
log_message(logger, LOG_LEVEL_ERROR, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
// 内部实现
void log_message(logger_t *logger, log_level_t level,
const char *file, int line,
const char *fmt, ...);
4.3 跨平台时间戳处理
获取时间戳是跨平台的一大难点。Linux用gettimeofday(),Windows用GetSystemTimeAsFileTime()。我封装了一个统一接口:
#ifdef _WIN32
#include <windows.h>
void get_timestamp(char *buf, size_t buflen) {
SYSTEMTIME st;
GetLocalTime(&st);
snprintf(buf, buflen, "%04d-%02d-%02d %02d:%02d:%02d.%03d",
st.wYear, st.wMonth, st.wDay,
st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
}
#else
#include <sys/time.h>
void get_timestamp(char *buf, size_t buflen) {
struct timeval tv;
struct tm *tm;
gettimeofday(&tv, NULL);
tm = localtime(&tv.tv_sec);
snprintf(buf, buflen, "%04d-%02d-%02d %02d:%02d:%02d.%03ld",
tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
tm->tm_hour, tm->tm_min, tm->tm_sec,
tv.tv_usec / 1000);
}
#endif
4.4 线程安全实现
多线程环境下,日志输出必须加锁。我推荐用pthread_mutex_t(Linux)或CRITICAL_SECTION(Windows)。当然,你也可以用C11的mtx_t,但兼容性嘛……你懂的。
// 伪代码:线程安全的日志输出
void log_message(logger_t *logger, ...) {
lock(&logger->lock);
// 格式化消息
char msg[1024];
format_message(logger, msg, sizeof(msg), ...);
// 调用输出回调
if (logger->output) {
logger->output(msg);
}
unlock(&logger->lock);
}
注意:不要在日志回调中做耗时操作,比如写数据库或发送网络请求。否则你的程序可能会被日志拖死。我一般把日志输出到环形缓冲区,由另一个线程异步写入文件。
五、知识体系总览
下面这张图总结了本章的核心内容,从错误获取到日志输出的完整链路:
从图中你可以看到,错误信息从系统调用或API出发,经过errno或GetLastError()获取,再通过strerror()或perror()转换成可读信息,最终进入自定义日志库。每一步都有跨平台的坑,每一步都需要你精心设计。
六、写在最后
错误处理和日志,说白了就是程序的「黑匣子」。飞机失事了要找黑匣子,程序崩溃了也要找日志。我见过太多项目,功能写得花里胡哨,一遇到线上问题就抓瞎——日志要么没有,要么乱七八糟。
所以,花点时间把日志库设计好,绝对是值得的。你想想看,半夜被电话叫起来排查问题,结果日志清晰明了,几分钟就定位到bug,那种感觉……嗯,比喝咖啡还提神。
好了,今天就聊到这儿。记住:好的错误处理,不是让程序不出错,而是让出错后能快速定位问题。这是每个C语言开发者都应该有的觉悟。