错误处理与断言

在程序运行过程中,错误是不可避免的:文件不存在、内存分配失败、输入无效等等。 良好的错误处理机制能够使程序在遇到异常时优雅地恢复或给出清晰的提示,而不是崩溃或产生错误结果。 C语言没有像C++或Java那样的异常处理机制,而是通过返回值、全局变量errno、断言(assert)等方式来处理错误。 本章将详细介绍C语言中常见的错误处理方法及断言的使用技巧。

⚠️ C语言中的错误处理方式

C语言中的错误处理通常采用以下几种策略:

  • 函数返回值:通过返回特殊值(如-1、NULL)表示错误,调用者检查返回值。
  • 全局错误变量 errno:许多库函数在出错时会设置 errno,可以通过 perrorstrerror 获取错误信息。
  • 断言(assert):用于检测程序中不应发生的情况,帮助调试。
  • 自定义错误码:定义自己的错误码枚举,通过函数返回错误码来传递错误信息。

🔢 errno、perror 与 strerror

errno<errno.h> 中定义的全局整数变量,当标准库函数(如文件操作、内存分配等)发生错误时,会设置 errno 为一个正值,表示具体的错误类型。 程序可以通过 perror() 打印错误信息,或使用 strerror() 获取错误描述的字符串。

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

int main() {
    FILE *fp = fopen("nonexist.txt", "r");
    if (fp == NULL) {
        // 方法1:使用 perror 直接打印错误信息(自动添加前缀)
        perror("打开文件失败");

        // 方法2:使用 strerror 获取错误描述字符串
        printf("错误码: %d, 错误信息: %s\n", errno, strerror(errno));

        return 1;
    }
    fclose(fp);
    return 0;
}
注意: errno 只在函数出错时被设置,成功时不会清零。因此,在调用可能出错的函数前,最好将 errno 设置为0,然后再检查。

🔸 常见的 errno 错误码

错误码含义
1EPERM操作不允许
2ENOENT文件或目录不存在
13EACCES权限不足
22EINVAL无效参数
24EMFILE打开文件过多
28ENOSPC设备无剩余空间

🏷️ 自定义错误处理

在实际项目中,可以定义自己的错误码和错误处理函数,使错误处理更统一。

#include <stdio.h>

// 定义错误码枚举
typedef enum {
    ERR_SUCCESS = 0,
    ERR_FILE_OPEN,
    ERR_MEMORY_ALLOC,
    ERR_INVALID_ARG,
    ERR_DIVIDE_BY_ZERO
} ErrorCode;

// 错误信息映射
const char *errorMessages[] = {
    "成功",
    "文件打开失败",
    "内存分配失败",
    "无效参数",
    "除零错误"
};

// 自定义错误处理函数
void handleError(ErrorCode code) {
    fprintf(stderr, "错误: %s\n", errorMessages[code]);
}

// 示例:除法函数,返回错误码
ErrorCode divide(int a, int b, int *result) {
    if (b == 0) return ERR_DIVIDE_BY_ZERO;
    *result = a / b;
    return ERR_SUCCESS;
}

int main() {
    int res;
    ErrorCode err = divide(10, 0, &res);
    if (err != ERR_SUCCESS) {
        handleError(err);
        return 1;
    }
    printf("结果: %d\n", res);
    return 0;
}

✅ 断言 assert

assert<assert.h> 中定义的宏,用于在调试阶段检测程序中不应出现的条件。 如果断言的条件为假,程序会输出错误信息并终止(调用 abort())。 在发布版本中,可以通过定义 NDEBUG 宏来禁用所有断言,以提高性能。

#include <stdio.h>
#include <assert.h>

int factorial(int n) {
    assert(n >= 0);   // 确保 n 非负
    int result = 1;
    for (int i = 1; i <= n; i++) {
        result *= i;
    }
    return result;
}

int main() {
    int x = -5;
    int fact = factorial(x);   // 触发断言失败,程序终止
    printf("%d! = %d\n", x, fact);
    return 0;
}
注意: 断言只应用于检测编程逻辑错误(如参数不应为NULL、数组索引不应越界),不应替代运行时的错误处理(如用户输入错误、文件不存在)。 在发布版本中,断言通常会被关闭,因此不能依赖断言进行必要的业务逻辑检查。

🔸 禁用断言

#define NDEBUG   // 放在 #include <assert.h> 之前
#include <assert.h>
// 之后的所有 assert 都不会产生任何效果

也可以在编译时定义:gcc -DNDEBUG program.c

📌 静态断言 _Static_assert

C11 标准引入了 _Static_assert(也可通过 static_assert 宏使用,需要包含 <assert.h>),用于在编译时检查常量表达式。 如果条件不满足,编译器会产生错误信息,阻止编译。静态断言非常适用于检查结构体大小、类型对齐等编译时可知的条件。

#include <stdio.h>
#include <assert.h>   // 提供 static_assert 宏

// 静态断言:确保 int 类型至少 4 字节
static_assert(sizeof(int) >= 4, "int 类型太小,至少需要4字节");

struct Student {
    int id;
    char name[50];
    float score;
};

// 确保结构体没有意外填充(不推荐这样严格要求,仅演示)
static_assert(sizeof(struct Student) == (sizeof(int) + 50 + sizeof(float)),
              "结构体存在额外填充");

int main() {
    printf("程序编译成功\n");
    return 0;
}
注意: 静态断言的条件必须是编译时常量表达式,不能使用变量。它在编译时检查,因此非常适合库开发者确保某些假设成立。

📚 错误处理与断言的最佳实践

  • 区分错误类型:对于可预见的运行时错误(如文件不存在),使用返回值 + errno 处理;对于编程逻辑错误(如传入NULL指针),使用断言。
  • 始终检查标准库函数的返回值:如 fopenmallocscanf 等,不要假设它们一定会成功。
  • 在错误信息中包含上下文:使用 perror 或自定义日志函数时,应包含文件名、行号等信息,方便定位。
  • 资源清理:发生错误时,确保已分配的资源(如文件指针、动态内存)得到正确释放,避免泄漏。
  • 断言用于调试,不用于业务逻辑:不要将用户输入验证等运行时检查交给断言,因为发布版本可能禁用断言。
  • 使用静态断言确保编译期约束:在依赖特定平台特性时,使用静态断言可以及早发现问题。

📋 综合示例:安全读取文件内容

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

// 读取整个文件到动态分配的内存
char* readFile(const char *filename) {
    FILE *fp = fopen(filename, "rb");
    if (fp == NULL) {
        fprintf(stderr, "无法打开文件 %s: %s\n", filename, strerror(errno));
        return NULL;
    }

    // 获取文件大小
    if (fseek(fp, 0, SEEK_END) != 0) {
        perror("fseek 失败");
        fclose(fp);
        return NULL;
    }
    long size = ftell(fp);
    if (size == -1) {
        perror("ftell 失败");
        fclose(fp);
        return NULL;
    }
    rewind(fp);

    // 分配内存(多一个字节用于存储 '\0')
    char *buffer = (char*)malloc(size + 1);
    if (buffer == NULL) {
        fprintf(stderr, "内存分配失败\n");
        fclose(fp);
        return NULL;
    }

    // 读取文件内容
    size_t readBytes = fread(buffer, 1, size, fp);
    if (readBytes != (size_t)size) {
        fprintf(stderr, "读取文件不完整: 期望 %ld 字节,实际 %zu 字节\n", size, readBytes);
        free(buffer);
        fclose(fp);
        return NULL;
    }
    buffer[size] = '\0';   // 添加字符串结束符

    fclose(fp);
    return buffer;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s 文件名\n", argv[0]);
        return 1;
    }

    char *content = readFile(argv[1]);
    if (content == NULL) {
        return 1;
    }

    // 使用断言检查 content 非空(已处理,但可用来验证逻辑)
    assert(content != NULL);

    printf("文件内容 (%s):\n%s\n", argv[1], content);
    free(content);
    return 0;
}

⚠️ 常见错误与注意事项

  • 忽略错误返回值:未检查 fopenmalloc 等的返回值,直接使用可能为NULL的指针,导致崩溃。
  • 误用断言处理运行时错误:断言在发布版可能被禁用,导致错误未被检测,造成不可预期行为。
  • errno 残留:某些库函数成功时不会重置 errno,因此错误检查前应主动设置 errno = 0;
  • 混淆 errno 与 strerror 返回值strerror 返回的字符串可能被后续调用覆盖,如需保存应复制一份。
  • 资源泄漏:错误处理路径中忘记释放已分配的资源(如文件句柄、内存)。
  • 在头文件中使用静态断言:C11的静态断言可以放在头文件中,若条件不满足会阻止编译,有助于库的可移植性检查。

错误处理和断言是编写健壮、可维护C程序的重要技术。通过合理使用 errno、返回值检查、断言以及自定义错误机制,你可以使程序在遇到异常时更稳定、更易于调试。 在实际开发中,建议结合日志系统(如 syslog 或自定义日志)进一步增强错误报告能力。