错误处理:errno与perror、strerror、自定义错误码、断言assert

说实话,错误处理是C语言里最容易被忽视、却又最致命的一环。我见过太多线上事故,根源就是「返回值没检查,错误码没处理」。你想想看,一个函数调用失败了,程序却假装没事继续往下跑——这就像开车时仪表盘报警灯亮了,你把它用胶带贴上继续开。迟早要出事。

这一章,我们就来聊聊C语言里那套经典的错误处理机制。errno、perror、strerror、自定义错误码,还有那个让人又爱又恨的assert。我会把我在项目中踩过的坑、积累的经验,一并倒给你。

errno:系统给你的错误暗号

每个线程都有一个全局变量叫 errno。当系统调用或库函数失败时,内核会往这个变量里塞一个整数,告诉你「兄弟,刚才出啥事了」。

比如你打开一个不存在的文件:

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

int main() {
    FILE *fp = fopen("no_such_file.txt", "r");
    if (fp == NULL) {
        printf("errno = %d\n", errno);
    }
    return 0;
}

输出:errno = 2。2 代表什么?ENOENT,No such file or directory。

这里有个坑,我当年就栽过:errno 只在函数失败时才有意义。如果函数成功了,errno 的值是未定义的。千万别在成功路径上读 errno,否则你会拿到上次失败留下的脏数据。

注意: 调用函数前,最好先把 errno 置为 0。因为有些函数失败时并不设置 errno,或者设置的值模棱两可。养成习惯:errno = 0; 然后再调用,最后检查。

perror 与 strerror:把错误码翻译成人话

errno 是个整数,正常人记不住 2 代表什么。所以标准库给了我们两个翻译官:

  • perror:直接往 stderr 打印错误信息,你只需要传一个前缀字符串。
  • strerror:返回错误码对应的字符串,你可以自己决定怎么输出。

看个例子:

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

int main() {
    FILE *fp = fopen("no_such_file.txt", "r");
    if (fp == NULL) {
        // 方式一:perror
        perror("fopen failed");

        // 方式二:strerror
        fprintf(stderr, "fopen failed: %s\n", strerror(errno));
    }
    return 0;
}

输出:

fopen failed: No such file or directory
fopen failed: No such file or directory

我个人习惯用 perror,因为它少写一行代码。但如果你需要把错误信息记录到日志文件里,或者做国际化处理,那 strerror 更灵活。

小技巧: strerror 返回的字符串是静态分配的,多线程环境下可能被覆盖。如果你用 pthreads,请用 strerror_r(线程安全版本)。

自定义错误码:打造你自己的错误体系

系统提供的 errno 只有几十个,根本不够用。你写一个库、一个工具,肯定有自己的错误场景。比如「配置文件格式错误」「网络连接超时」「内存池耗尽」——这些系统 errno 可不会告诉你。

我的做法是:

  1. 定义一个枚举,把所有可能的错误码列出来。
  2. 留一个范围给系统错误(比如 0~255),自定义错误从 256 开始。
  3. 写一个类似 strerror 的函数,把自定义错误码翻译成字符串。

代码示例:

#include <stdio.h>

// 自定义错误码
enum my_error {
    ERR_OK          = 0,
    ERR_CONFIG      = 256,  // 配置文件错误
    ERR_NETWORK     = 257,  // 网络错误
    ERR_MEMPOOL     = 258,  // 内存池耗尽
    ERR_TIMEOUT     = 259,  // 操作超时
};

// 错误码翻译
const char* my_strerror(int err) {
    switch (err) {
        case ERR_OK:      return "success";
        case ERR_CONFIG:  return "config file error";
        case ERR_NETWORK: return "network error";
        case ERR_MEMPOOL: return "memory pool exhausted";
        case ERR_TIMEOUT: return "operation timeout";
        default:          return "unknown error";
    }
}

int main() {
    int ret = ERR_CONFIG;
    printf("Error: %s\n", my_strerror(ret));
    return 0;
}

这样设计的好处是:你的错误码和系统 errno 不会冲突。而且你可以把系统 errno 也纳入自己的体系——比如 ERR_SYSTEM_BASE + errno,统一管理。

核心原则: 每个函数都应该有明确的错误返回值。要么返回错误码,要么返回 NULL/负值 并设置 errno。别让调用方去猜。

断言 assert:调试时的守护神

assert 是个宏,定义在 <assert.h> 里。它的作用很简单:如果条件为假,就打印错误信息并终止程序。

#include <assert.h>

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

assert 只在 NDEBUG 宏未定义时生效。发布版本里,通常会在编译时加上 -DNDEBUG,这样 assert 就变成空操作了。

嗯,这里要注意:assert 里不要写有副作用的代码。比如:

// 千万别这么写!
assert(ptr = malloc(100));  // 在 release 版本里,malloc 根本不会执行!

我曾经在一个项目里看到有人这么写,结果线上版本疯狂崩溃,查了两天才发现是 assert 把内存分配给吞了。从那以后,我定了个规矩:assert 里只放布尔表达式,不放函数调用。

SVG 知识结构图

下面这张图,把本章的核心知识点串起来了。你可以看到错误处理的完整链路:从错误发生,到 errno 记录,再到 perror/strerror 输出,以及自定义错误码和 assert 的定位。

C语言错误处理知识体系 错误发生 系统调用 / 库函数失败 自定义错误码 设置 errno 全局变量 perror() 直接打印 strerror() 转字符串 枚举定义 + 翻译函数 assert 断言 调试阶段启用,发布关闭 统一错误处理策略

实战建议:构建统一的错误处理策略

说了这么多,到底怎么用?我分享一套我自己在项目中沉淀下来的做法:

  1. 所有函数都返回错误码。成功返回 0,失败返回负值或正错误码。别搞什么「成功返回 1,失败返回 0」——那跟系统习惯反着来,容易乱。
  2. 错误码用枚举定义,注释写清楚每个码的含义。别用魔数。
  3. 提供统一的错误打印函数,内部调用 strerror 或 perror。这样整个项目的错误输出风格一致。
  4. assert 只用于「绝不可能发生」的情况。比如指针不应该为 NULL、除数不应该为 0。别拿 assert 做参数校验——那应该返回错误码。
  5. 日志里永远记录 errno 和对应的字符串。光记个数字,回头查问题还得翻头文件,太蠢了。
我的习惯: 在项目里定义一个 LOG_ERROR(fmt, ...) 宏,自动带上 __FILE____LINE__strerror(errno)。这样出问题一眼就能定位。

错误处理这件事,说白了就是「别让错误静默地溜走」。你每忽略一个错误码,就是在给未来的自己埋一颗雷。相信我,你迟早会踩上去的。


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