错误处理与断言:errno, perror, assert 的跨平台行为与自定义

大家好,我是你们的老朋友。今天我们来聊聊 C 语言里一个特别容易被忽视,但又特别重要的话题——错误处理与断言。

说实话,我见过太多项目,代码写得飞起,但一遇到异常就崩得稀里哗啦。为什么?因为错误处理没做好。你想想看,一个没有错误处理的程序,就像一辆没有刹车的跑车,跑得快,但死得也快。

今天我们就来系统地梳理一下 C 语言中的错误处理机制:errnoperrorassert,以及它们在跨平台开发中的那些坑。嗯,我会结合我自己的踩坑经历,给你一些实用的建议。

1. errno:全局错误码的利与弊

errno 是 C 标准库提供的一个全局整数变量,用于记录最近一次系统调用或库函数调用发生的错误代码。说白了,就是系统给你留的一个小纸条,告诉你刚才那步操作出了什么问题。

它的使用方式很简单:

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

int main() {
    FILE *fp = fopen("nonexistent.txt", "r");
    if (fp == NULL) {
        printf("errno = %d\n", errno);
        printf("错误描述: %s\n", strerror(errno));
    }
    return 0;
}

输出结果:

errno = 2
错误描述: No such file or directory

这里有个坑,我必须提醒你:errno 是线程不安全的。在早期的 POSIX 标准中,errno 被定义为一个全局变量。如果你的程序是多线程的,两个线程同时调用系统函数,errno 的值就会被互相覆盖。

⚠️ 跨平台警告:
  • 在 Linux/glibc 中,errno 被定义为宏,展开为线程局部存储(TLS),所以是线程安全的。
  • 在 Windows 的某些旧版编译器中,errno 仍然是全局变量,多线程下会有问题。
  • 在嵌入式系统(如 FreeRTOS、RT-Thread)中,errno 可能根本没有实现,或者实现不完整。

我个人习惯的做法是:在调用可能设置 errno 的函数后,立即读取并保存 errno 的值。不要等到后面再读,因为中间可能被其他函数调用覆盖掉。

// 我的习惯:立即保存 errno
FILE *fp = fopen("config.ini", "r");
int saved_errno = errno;  // 立即保存
if (fp == NULL) {
    // 使用 saved_errno,而不是 errno
    fprintf(stderr, "打开文件失败: %s\n", strerror(saved_errno));
}

2. perror:简单粗暴的错误输出

perrorerrno 的好搭档。它会将你传入的字符串加上冒号和空格,再拼接上当前 errno 对应的错误描述,一起输出到 stderr

#include <stdio.h>
#include <errno.h>

int main() {
    FILE *fp = fopen("config.ini", "r");
    if (fp == NULL) {
        perror("打开 config.ini 失败");
    }
    return 0;
}

输出:

打开 config.ini 失败: No such file or directory

嗯,这里要注意:perror 的输出是固定的格式,你没法自定义。而且它默认输出到 stderr,如果你想把错误信息输出到日志文件或者网络 socket,那就得自己动手了。

我在项目中遇到过这样的情况:一个嵌入式设备,没有标准输出,所有日志都要通过串口打印。这时候 perror 就不好使了。我一般会封装一个自己的错误输出函数:

void my_perror(const char *msg) {
    // 自定义错误输出,支持串口、日志文件等
    char buffer[256];
    snprintf(buffer, sizeof(buffer), "%s: %s\n", msg, strerror(errno));
    uart_send_string(buffer);  // 假设这是串口输出函数
}

3. assert:调试时的守护神

assert<assert.h> 中定义的宏,用于在调试阶段检查「不应该发生」的条件。如果条件为假,它会打印错误信息并调用 abort() 终止程序。

#include <assert.h>

int divide(int a, int b) {
    assert(b != 0);  // 除数不能为0
    return a / b;
}

b == 0 时,程序会输出类似这样的信息并崩溃:

Assertion failed: b != 0, file test.c, line 5

这里有个关键点:assert 只在调试模式下生效。如果你定义了 NDEBUG 宏,assert 就会被完全移除,不产生任何代码。

💡 我的建议:
  • 在开发阶段,不要定义 NDEBUG,让 assert 帮你抓 bug。
  • 在发布版本中,定义 NDEBUG,移除 assert 的开销。
  • 但要注意:不要在 assert 中写有副作用的表达式,比如 assert(++x > 0),因为发布版本中这行代码会被删掉,x 就不会自增了。

我曾经在一个项目中看到这样的代码:

// 错误示例:assert 中有副作用
assert(ptr = malloc(1024));  // 发布版本中,ptr 不会被赋值!

嗯,这种 bug 特别难查。因为调试版本一切正常,发布版本就莫名其妙地崩溃了。所以,永远不要在 assert 中写赋值、函数调用等有副作用的代码

4. 跨平台行为对比

不同平台对错误处理的支持差异很大。我整理了一个表格,方便你对照:

特性 Linux (glibc) Windows (MSVC) 嵌入式 (FreeRTOS)
errno 线程安全 ✅ 是(TLS) ⚠️ 旧版不安全 ❌ 通常不支持
perror 可用性 ✅ 完整支持 ✅ 支持 ⚠️ 需自定义输出
assert 行为 ✅ 标准行为 ✅ 标准行为 ⚠️ 可能无 abort()
错误码数量 130+ 个 400+ 个 通常很少
strerror 线程安全 ⚠️ 非线程安全 ⚠️ 非线程安全 ❌ 通常不支持
🔧 跨平台移植小技巧:

如果你需要写跨平台的代码,我建议你封装一层自己的错误处理接口。比如:

// my_error.h
#ifndef MY_ERROR_H
#define MY_ERROR_H

// 自定义错误码
typedef enum {
    MY_SUCCESS = 0,
    MY_ERR_FILE_NOT_FOUND,
    MY_ERR_PERMISSION_DENIED,
    MY_ERR_OUT_OF_MEMORY,
    // ...
} my_error_t;

// 获取最后一次错误
my_error_t my_get_last_error(void);

// 输出错误信息
void my_print_error(const char *msg);

// 断言(调试用)
#ifdef MY_DEBUG
    #define MY_ASSERT(cond) do { \
        if (!(cond)) { \
            my_print_error("Assertion failed: " #cond); \
            my_abort(); \
        } \
    } while(0)
#else
    #define MY_ASSERT(cond) ((void)0)
#endif

#endif

5. 自定义错误处理:打造你自己的错误体系

在实际项目中,标准库的错误处理往往不够用。你需要建立自己的错误处理体系。我一般会这样做:

  1. 定义统一的错误码枚举,覆盖项目中所有可能的错误场景。
  2. 封装错误获取和输出函数,屏蔽平台差异。
  3. 使用断言检查不可恢复的错误,比如空指针、数组越界等。
  4. 对于可恢复的错误,使用返回值或错误码,而不是直接崩溃。

下面是一个简单的自定义错误处理框架:

// error_framework.h
#ifndef ERROR_FRAMEWORK_H
#define ERROR_FRAMEWORK_H

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 错误级别
typedef enum {
    ERROR_LEVEL_DEBUG = 0,
    ERROR_LEVEL_INFO,
    ERROR_LEVEL_WARNING,
    ERROR_LEVEL_ERROR,
    ERROR_LEVEL_FATAL
} error_level_t;

// 错误信息结构体
typedef struct {
    error_level_t level;
    int code;
    char message[256];
    const char *file;
    int line;
} error_info_t;

// 全局错误信息(线程局部存储)
extern __thread error_info_t g_last_error;

// 设置错误
#define SET_ERROR(level, code, msg) do { \
    g_last_error.level = (level); \
    g_last_error.code = (code); \
    snprintf(g_last_error.message, sizeof(g_last_error.message), "%s", (msg)); \
    g_last_error.file = __FILE__; \
    g_last_error.line = __LINE__; \
} while(0)

// 获取错误
#define GET_ERROR() (&g_last_error)

// 打印错误
void print_error(const error_info_t *err) {
    const char *level_str[] = {"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"};
    fprintf(stderr, "[%s] %s:%d - code=%d, %s\n",
            level_str[err->level],
            err->file, err->line,
            err->code, err->message);
}

// 致命错误断言
#define FATAL_ASSERT(cond, msg) do { \
    if (!(cond)) { \
        SET_ERROR(ERROR_LEVEL_FATAL, -1, msg); \
        print_error(GET_ERROR()); \
        abort(); \
    } \
} while(0)

#endif

使用示例:

#include "error_framework.h"

int read_config(const char *path) {
    FILE *fp = fopen(path, "r");
    if (fp == NULL) {
        SET_ERROR(ERROR_LEVEL_ERROR, errno, "无法打开配置文件");
        return -1;
    }
    // ... 读取配置
    fclose(fp);
    return 0;
}

int main() {
    if (read_config("config.ini") != 0) {
        print_error(GET_ERROR());
        return 1;
    }
    return 0;
}

6. 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图:

C语言错误处理与断言知识体系 errno 全局错误码 perror 错误输出 assert 断言 线程安全(TLS vs 全局) 跨平台错误码差异 固定格式输出到 stderr 自定义输出(串口/日志) 调试模式 vs 发布模式 避免有副作用的表达式 自定义错误处理框架 统一错误码 + 封装接口 + 跨平台适配

7. 总结与避坑指南

好了,我们来快速回顾一下今天的内容:

  • errno:全局错误码,注意线程安全和跨平台差异。我的习惯是立即保存 errno 的值。
  • perror:简单但不够灵活。在嵌入式或特殊输出场景下,建议自己封装。
  • assert:调试利器,但不要在 assert 中写有副作用的代码。发布版本记得定义 NDEBUG。
  • 自定义错误处理:建立自己的错误码体系和接口,屏蔽平台差异,提高代码可移植性。
⚠️ 我曾经踩过的坑:

有一次我在一个多线程服务器中直接使用 errno,结果两个线程同时调用 fopen 失败,errno 被互相覆盖,导致错误信息完全对不上号。从那以后,我养成了「立即保存 errno」的习惯。

还有一次,我在 assert 中写了一个 malloc 调用,发布版本中 assert 被移除,导致内存泄漏。嗯,这种低级错误,犯过一次就再也不会犯了。

错误处理看似简单,但做不好真的会让人抓狂。希望今天的内容能帮你少走一些弯路。记住:好的错误处理,不是让程序不崩溃,而是让程序在崩溃时能告诉你它为什么崩溃


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