22、头文件中的错误处理:错误码定义、errno 的使用、断言(assert)

错误处理这件事,说大不大,说小不小。我见过太多项目,功能跑得飞起,一遇到异常就崩得稀里哗啦。说白了,错误处理不是锦上添花,而是代码的底线。今天我们就聊聊在头文件里怎么把这条底线画清楚。

错误码定义:给每个错误一个名字

你想想看,如果函数返回 -1 表示失败,那到底是哪种失败?内存不够?参数非法?还是设备没响应?光靠一个魔法数字,谁也猜不透。

我个人习惯,在头文件里用枚举来定义错误码。这样既清晰,又方便扩展。

// error_codes.h
#ifndef ERROR_CODES_H
#define ERROR_CODES_H

typedef enum {
    ERR_OK          = 0,    // 一切正常
    ERR_NOMEM       = -1,   // 内存分配失败
    ERR_INVAL       = -2,   // 无效参数
    ERR_TIMEOUT     = -3,   // 操作超时
    ERR_BUSY        = -4,   // 资源忙
    ERR_NOTFOUND    = -5,   // 未找到
    ERR_IO          = -6,   // IO错误
    ERR_PERM        = -7,   // 权限不足
    ERR_AGAIN       = -8,   // 请重试
    ERR_MAX         = -9    // 预留边界
} error_t;

#endif

小技巧:把 ERR_OK 定义为 0,这样 if(!func()) 就能判断成功,符合 C 语言的习惯。我在项目中见过有人把成功码定义成 1,结果到处都要写 if(func() == 1),看着就累。

errno 的使用:全局变量的双刃剑

errno 是 C 标准库提供的一个全局变量,用来记录最近一次系统调用的错误号。它的好处是函数返回值可以留给真正的数据,错误信息通过 errno 传递。

但这里有个坑——errno 是全局的。多线程环境下,一个线程刚调完函数,还没来得及检查 errno,另一个线程就把值改了。你想想看,这 bug 多难查。

我建议的做法是:

  • 单线程程序:直接用 errno,简单粗暴
  • 多线程程序:用线程局部存储,或者干脆不用 errno,改用返回错误码

来看一个实际例子:

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

// 自定义函数,使用 errno 传递错误
int open_device(const char* path) {
    if (path == NULL) {
        errno = EINVAL;
        return -1;
    }
    // 模拟打开设备
    // ...
    return 0;
}

void test_open() {
    if (open_device(NULL) == -1) {
        // 用 strerror 把 errno 转成可读信息
        printf("打开失败: %s\n", strerror(errno));
    }
}

注意:调用任何可能修改 errno 的函数之前,先保存 errno 的值。我曾经在一个项目中,调试一个诡异的 bug 调了三天,最后发现是库函数内部悄悄把 errno 改了,而我的错误处理逻辑完全被带偏了。

断言(assert):调试时的守护神

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

我个人习惯,在头文件里这样用 assert:

#include <assert.h>

// 在头文件中声明内联函数时使用 assert
static inline void* buffer_alloc(size_t size) {
    // 断言:size 必须大于 0
    assert(size > 0);
    
    void* ptr = malloc(size);
    // 断言:分配不能失败(调试阶段)
    assert(ptr != NULL);
    
    return ptr;
}

但要注意,assert 只在 调试模式 下生效。如果你定义了 NDEBUG 宏,assert 就变成空操作了。所以:

  • 调试阶段:用 assert 检查「绝不应该发生」的情况
  • 发布版本:用 if 判断处理真正的错误

核心原则:assert 检查的是程序内部的逻辑错误,比如指针不该为 NULL、数组下标不该越界。而用户输入错误、网络超时这类外部错误,应该用错误码或异常来处理。

三种错误处理方式的对比

方式 适用场景 优点 缺点
错误码 函数返回值传递错误 清晰、可组合、线程安全 需要显式检查每个返回值
errno 系统调用、库函数 不占用返回值 全局变量、线程不安全
assert 调试阶段检查内部逻辑 自动终止、定位精准 发布版本不生效

错误处理的知识体系

下面这张图,帮你理清这三种方式的关系和适用边界:

C语言错误处理体系 错误码 (Error Code) errno 全局变量 断言 (assert) 函数返回值传递 系统调用/库函数错误 调试阶段内部逻辑检查 ✅ 线程安全 ❌ 需显式检查 ✅ 不占返回值 ❌ 线程不安全 ✅ 自动终止 ❌ 发布版失效 调试用 assert,运行时用错误码,系统调用用 errno

实际项目中的错误处理策略

我在一个嵌入式项目中,总结了一套比较实用的做法:

  1. 头文件里统一声明错误码枚举,所有模块共用一套错误体系
  2. 函数返回值用错误码,输出参数通过指针传递
  3. 只在系统调用层使用 errno,上层全部转成自定义错误码
  4. assert 只检查内部逻辑,比如数组下标、指针非空
  5. 发布版本关闭 assert,但保留错误码检查

举个例子:

// device.h
#include "error_codes.h"

/**
 * @brief 读取设备数据
 * @param buf 输出缓冲区
 * @param len 缓冲区长度
 * @return 错误码
 */
error_t device_read(uint8_t* buf, size_t len) {
    // 调试阶段检查参数
    assert(buf != NULL);
    assert(len > 0);
    
    if (buf == NULL || len == 0) {
        return ERR_INVAL;
    }
    
    // 实际读取操作...
    // 如果底层系统调用失败,转成自定义错误码
    if (ioctl(fd, ...) == -1) {
        if (errno == ETIMEDOUT) {
            return ERR_TIMEOUT;
        }
        return ERR_IO;
    }
    
    return ERR_OK;
}

个人经验:我习惯在头文件里把错误码定义和函数声明放在一起。这样别人看头文件时,一眼就能知道这个模块可能返回哪些错误。别把错误码藏到 .c 文件里,那等于没定义。

嗯,错误处理这件事,说白了就是给代码留条后路。你永远不知道用户会怎么调用你的函数,也不知道硬件会在什么时候掉链子。把错误处理写进头文件,让每个调用者都清楚「这个函数可能会怎么失败」,这才是负责任的模块化设计。