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 | 调试阶段检查内部逻辑 | 自动终止、定位精准 | 发布版本不生效 |
错误处理的知识体系
下面这张图,帮你理清这三种方式的关系和适用边界:
实际项目中的错误处理策略
我在一个嵌入式项目中,总结了一套比较实用的做法:
- 头文件里统一声明错误码枚举,所有模块共用一套错误体系
- 函数返回值用错误码,输出参数通过指针传递
- 只在系统调用层使用 errno,上层全部转成自定义错误码
- assert 只检查内部逻辑,比如数组下标、指针非空
- 发布版本关闭 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 文件里,那等于没定义。
嗯,错误处理这件事,说白了就是给代码留条后路。你永远不知道用户会怎么调用你的函数,也不知道硬件会在什么时候掉链子。把错误处理写进头文件,让每个调用者都清楚「这个函数可能会怎么失败」,这才是负责任的模块化设计。