32、断言与错误处理:assert、errno、异常处理模式
错误处理这件事,说实话,是很多C语言新手最容易忽视的环节。我见过太多代码,函数跑得欢,但一遇到异常情况就直接崩了。你想想看,一个嵌入式系统跑在产线上,突然因为一个空指针挂了——那可不是闹着玩的。
今天我们就来聊聊C语言里的错误处理三板斧:assert、errno,以及更高级的异常处理模式。这些东西,说白了就是给你的代码装上「安全气囊」。
assert:开发阶段的「哨兵」
assert 是个宏,定义在 <assert.h> 里。它的作用很简单:检查一个表达式是否为真。如果为假,就打印错误信息并终止程序。
#include <assert.h>
void set_baudrate(int rate) {
assert(rate > 0); // 波特率必须为正数
// 实际设置代码...
}
我个人习惯在函数入口处放几个 assert,专门检查参数合法性。比如指针不能为NULL、索引不能越界、除数不能为零。这些检查在调试阶段能帮你快速定位问题。
关键点: assert 只在 NDEBUG 宏未定义时生效。发布版本通常定义 NDEBUG,此时所有 assert 都会被编译器忽略。所以别指望它来做运行时错误处理。
我的经验: 我在一个电机控制项目里,曾经因为一个索引越界导致数组写穿,直接覆盖了关键配置参数。从那以后,我所有涉及数组访问的函数入口都加了 assert。调试时一跑就抓到问题,省了至少两天排查时间。
errno:系统调用的「晴雨表」
errno 是一个全局变量,定义在 <errno.h> 里。很多标准库函数(比如文件操作、数学函数)在出错时会设置 errno 的值,告诉你具体出了什么问题。
#include <stdio.h>
#include <errno.h>
#include <string.h>
FILE *fp = fopen("config.txt", "r");
if (fp == NULL) {
fprintf(stderr, "打开文件失败: %s\n", strerror(errno));
return -1;
}
这里有个坑要注意:errno 的值不会自动清零。如果前一个函数出错了,后一个函数成功了,errno 可能还保留着之前的错误码。所以正确的做法是:在调用可能设置 errno 的函数之前,先把 errno 置为0。
我曾经踩过的坑: 有一次我在一个循环里反复调用 strtol 解析字符串,结果某个错误码一直没清掉,导致后续判断全乱套了。排查了半天才发现是 errno 残留的问题。嗯,从那以后我养成了「用前清零」的习惯。
异常处理模式:从「裸奔」到「武装」
C语言没有C++/Java那种 try-catch 机制,但我们可以自己搭一套异常处理模式。常用的有几种:
1. 返回值模式
这是最基础的方式。函数返回一个状态码,调用方检查这个码来判断是否成功。
typedef enum {
ERR_OK = 0,
ERR_NULL_PTR,
ERR_INVALID_PARAM,
ERR_TIMEOUT,
ERR_HARDWARE
} error_t;
error_t sensor_read(float *value) {
if (value == NULL) return ERR_NULL_PTR;
if (!sensor_is_ready()) return ERR_HARDWARE;
// 读取传感器...
return ERR_OK;
}
// 调用方
float temp;
error_t err = sensor_read(&temp);
if (err != ERR_OK) {
// 处理错误
}
我个人比较推荐这种方式。它清晰、可控,而且没有额外的性能开销。唯一的缺点就是代码里会多出很多 if 判断——但这是必要的代价。
2. goto + 清理标签模式
在资源分配比较复杂的函数里,goto 反而能让错误处理更干净。
int init_system(void) {
int ret = -1;
void *buf = NULL;
FILE *fp = NULL;
buf = malloc(1024);
if (buf == NULL) goto cleanup;
fp = fopen("data.bin", "rb");
if (fp == NULL) goto cleanup;
// 初始化成功
ret = 0;
return ret;
cleanup:
if (fp) fclose(fp);
if (buf) free(buf);
return ret;
}
我的建议: 别怕用 goto。只要遵循「只向下跳转、用于统一清理」的原则,它比嵌套一堆 if-else 要清晰得多。我在一个多线程项目里用这种模式处理了十几个资源的申请和释放,代码反而比原来短了一半。
3. setjmp/longjmp 模式
这是C语言里最接近「异常捕获」的机制。不过说实话,我很少用。因为它会跳过栈上的局部变量清理,容易造成资源泄漏。
#include <setjmp.h>
jmp_buf env;
void risky_function(void) {
if (something_wrong) {
longjmp(env, 1); // 跳回 setjmp 处
}
}
int main(void) {
if (setjmp(env) == 0) {
risky_function(); // 正常路径
} else {
// 异常处理
}
}
注意: longjmp 不会自动释放动态分配的内存、不会关闭文件、不会解锁互斥量。如果你用了它,必须确保所有资源都在跳转前被妥善处理。否则,嗯,你会收获一个内存泄漏的烂摊子。
知识体系总览
下面这张图把今天讲的内容串了起来。你可以看到三种错误处理手段各自的应用场景和关系。
实战建议:怎么选?
说了这么多,到底该用哪种?我个人的经验是这样的:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 调试阶段检查参数 | assert | 零开销,快速定位 |
| 文件/网络/数学函数 | errno | 标准库自带,信息丰富 |
| 一般函数错误 | 返回值模式 | 清晰可控,无副作用 |
| 复杂资源管理 | goto + 清理标签 | 统一出口,避免泄漏 |
| 深层嵌套错误传播 | setjmp/longjmp | 慎用,仅限特殊场景 |
最后说一句: 错误处理不是可有可无的装饰。它是系统健壮性的基石。我见过太多「先跑起来再说」的项目,最后都栽在了错误处理上。你想想看,一个连空指针都不检查的函数,你敢把它放到产品里吗?
嗯,今天就聊到这里。记住:好的错误处理,是你看不到它,但它一直在保护你。