32、断言与错误处理:assert、errno、异常处理模式

错误处理这件事,说实话,是很多C语言新手最容易忽视的环节。我见过太多代码,函数跑得欢,但一遇到异常情况就直接崩了。你想想看,一个嵌入式系统跑在产线上,突然因为一个空指针挂了——那可不是闹着玩的。

今天我们就来聊聊C语言里的错误处理三板斧:asserterrno,以及更高级的异常处理模式。这些东西,说白了就是给你的代码装上「安全气囊」。

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 不会自动释放动态分配的内存、不会关闭文件、不会解锁互斥量。如果你用了它,必须确保所有资源都在跳转前被妥善处理。否则,嗯,你会收获一个内存泄漏的烂摊子。

知识体系总览

下面这张图把今天讲的内容串了起来。你可以看到三种错误处理手段各自的应用场景和关系。

C语言错误处理体系 assert 开发阶段断言检查 errno 系统调用错误码 异常处理模式 运行时错误处理 调试时启用 发布时关闭 fopen/strtol等 用前清零 返回值模式 goto + 清理标签 setjmp/longjmp 核心原则 开发阶段用assert抓逻辑错误,运行时用返回值/errno处理可恢复错误

实战建议:怎么选?

说了这么多,到底该用哪种?我个人的经验是这样的:

场景 推荐方式 原因
调试阶段检查参数 assert 零开销,快速定位
文件/网络/数学函数 errno 标准库自带,信息丰富
一般函数错误 返回值模式 清晰可控,无副作用
复杂资源管理 goto + 清理标签 统一出口,避免泄漏
深层嵌套错误传播 setjmp/longjmp 慎用,仅限特殊场景

最后说一句: 错误处理不是可有可无的装饰。它是系统健壮性的基石。我见过太多「先跑起来再说」的项目,最后都栽在了错误处理上。你想想看,一个连空指针都不检查的函数,你敢把它放到产品里吗?

嗯,今天就聊到这里。记住:好的错误处理,是你看不到它,但它一直在保护你

专注资料整理