错误处理与日志:errno、strerror()、perror()的跨平台行为、自定义日志库设计

说实话,错误处理这事儿,很多C语言开发者都把它当成了「后妈养的」。代码写得飞起,一遇到错误就随便打印个"Error!"了事。我早年也这么干过,直到有一次在Linux上跑得好好的程序,移植到某个嵌入式平台后,errno的值完全对不上号,排查了整整两天……嗯,从那以后,我再也不敢轻视跨平台的错误处理了。

今天咱们就好好聊聊这个话题。我会把errnostrerror()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()失败后errnoECONNREFUSED,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);
}

注意:不要在日志回调中做耗时操作,比如写数据库或发送网络请求。否则你的程序可能会被日志拖死。我一般把日志输出到环形缓冲区,由另一个线程异步写入文件。

五、知识体系总览

下面这张图总结了本章的核心内容,从错误获取到日志输出的完整链路:

错误处理与日志:知识体系总览 系统调用/库函数 open(), read(), malloc() 等 Windows 特有API CreateFile(), WSASocket() 等 自定义错误码 业务逻辑中的错误 errno 全局/线程局部错误码 GetLastError() Windows 特有错误码 自定义错误码 用户定义的错误枚举 strerror() 错误码 → 可读字符串 perror() 直接打印到 stderr 自定义格式化 统一错误信息格式 自定义日志库(分级、线程安全、跨平台)

从图中你可以看到,错误信息从系统调用或API出发,经过errnoGetLastError()获取,再通过strerror()perror()转换成可读信息,最终进入自定义日志库。每一步都有跨平台的坑,每一步都需要你精心设计。

六、写在最后

错误处理和日志,说白了就是程序的「黑匣子」。飞机失事了要找黑匣子,程序崩溃了也要找日志。我见过太多项目,功能写得花里胡哨,一遇到线上问题就抓瞎——日志要么没有,要么乱七八糟。

所以,花点时间把日志库设计好,绝对是值得的。你想想看,半夜被电话叫起来排查问题,结果日志清晰明了,几分钟就定位到bug,那种感觉……嗯,比喝咖啡还提神。

好了,今天就聊到这儿。记住:好的错误处理,不是让程序不出错,而是让出错后能快速定位问题。这是每个C语言开发者都应该有的觉悟。