一、错误码的哲学:为什么C语言没有try-catch?

说实话,这个问题我几乎每次培训都会被问到。

很多从Java、Python转过来的朋友,第一反应就是:「C语言怎么连个try-catch都没有?这怎么干活?」

嗯,我理解这种焦虑。但我想说的是——C语言不是没有异常处理,而是选择了另一条路。

1.1 先聊聊try-catch的本质

try-catch这套机制,说白了就是「假设代码会出错,出了错我来兜底」。它把正常流程和错误处理强行分开,看起来很美。

但代价是什么?

  • 性能开销:每次进入try块都要建立异常处理栈帧
  • 代码膨胀:编译器要生成额外的异常处理表
  • 控制流混乱:一个throw可能跳到十万八千里外的catch块

我在做嵌入式实时系统时,曾经测过一个C++项目的异常开销——一个简单的try-catch块,让中断响应时间增加了30%。这在航天、医疗设备里是绝对不能接受的。

1.2 C语言的选择:显式优于隐式

C语言的哲学是什么?你写的每一行代码,都应该能精确预测它的行为。

所以C语言选择了三条腿走路:

机制 适用场景 典型用法
返回值 函数内部可预见的错误 返回-1表示失败,0表示成功
errno 系统调用/库函数的错误详情 open()失败后检查errno
信号 异步/硬件级别的异常 SIGSEGV、SIGFPE

这三者各有分工,互不替代。我个人的习惯是:能用返回值解决的,绝不动errno;能用errno说明的,绝不碰信号。

1.3 返回值:最朴素的错误处理

这是C语言最核心的错误处理方式。你想想看,一个函数执行完,要么成功要么失败,用返回值告诉调用者,天经地义。

// 典型模式:返回0表示成功,非0表示错误码
int open_device(const char *path) {
    if (path == NULL) {
        return -EINVAL;  // 参数无效
    }
    // ... 实际打开设备的逻辑
    return 0;  // 成功
}

// 调用方必须检查
int ret = open_device("/dev/ttyS0");
if (ret != 0) {
    // 处理错误
    return ret;
}

核心原则:每个函数的返回值都必须被检查。不检查返回值,等于在代码里埋雷。

我曾经在一个通信协议栈项目里,发现一个同事连续跳过了三个返回值检查。结果呢?数据包在内存里乱飞,最后查了整整两天才定位到问题。从那以后,我们团队定了个规矩:所有函数返回值必须检查,编译器警告当错误处理。

1.4 errno:给错误一个「身份证」

返回值只能告诉你「出错了」,但具体是什么错?这就需要errno了。

errno是一个全局变量(其实是宏),每个线程都有自己的副本。系统调用或库函数出错时,会设置errno来表明具体原因。

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

FILE *fp = fopen("nonexist.txt", "r");
if (fp == NULL) {
    // errno 被设置为 ENOENT (No such file or directory)
    printf("错误码: %d\n", errno);
    printf("错误描述: %s\n", strerror(errno));
    // 输出: 错误码: 2
    // 输出: 错误描述: No such file or directory
}

避坑指南:errno只在函数调用失败后才有效。成功调用后errno的值是未定义的。我曾经见过有人先检查errno再判断返回值——顺序反了,结果全是假阳性。

这里有个细节很多人不知道:errno的初始值是0,但没有任何标准规定成功调用后errno会被清零。所以正确的做法是:先判断返回值,再检查errno。

1.5 信号:处理「意外中的意外」

信号是C语言里最底层的异常处理机制。它处理的是那些「正常情况下不应该发生」的事:

  • SIGSEGV:访问了不该访问的内存
  • SIGFPE:整数除以零
  • SIGILL:执行了非法指令
  • SIGABRT:调用了abort()

信号处理函数有个大坑:你永远不知道信号到来时,程序正在干什么。可能正在malloc,可能正在printf,可能正在更新某个链表。

#include <signal.h>
#include <stdio.h>

void sigsegv_handler(int sig) {
    // 注意:这里能做的事情极其有限
    // 不能调用 malloc、printf 等非可重入函数
    write(STDERR_FILENO, "段错误!\n", 10);
    _exit(1);  // 直接退出,不要返回
}

int main() {
    signal(SIGSEGV, sigsegv_handler);
    
    int *p = NULL;
    *p = 42;  // 触发 SIGSEGV
    return 0;
}

警告:信号处理函数必须是「异步信号安全」的。这意味着你不能调用printf、malloc、free等大多数标准库函数。我见过最惨的案例是有人在信号处理函数里调了free(),结果死锁了——因为信号到来时malloc的内部锁正被持有。

1.6 三种机制的协作关系

这三种机制不是孤立的,它们构成了一个分层体系:

C语言错误处理分层体系 第一层:返回值(函数级) 处理函数内部可预见的错误 示例:open()返回-1表示失败 第二层:errno(系统级) 提供错误的具体原因 示例:errno=ENOENT表示文件不存在 第三层:信号(硬件/内核级) 处理异步/硬件异常 示例:SIGSEGV处理非法内存访问 频率最高 ↓ 频率最低

从上往下看:

  • 返回值处理的是「预期内的错误」——比如文件不存在、参数非法
  • errno给返回值补充「为什么错」的详细信息
  • 信号处理的是「预期外的灾难」——比如段错误、非法指令

我个人的经验是:90%的错误应该在第一层解决,9%在第二层,只有不到1%才会落到第三层。如果你发现自己的代码里信号处理函数写得比返回值检查还多,那一定是设计出了问题。

1.7 为什么不用setjmp/longjmp模拟try-catch?

我知道有人会问:C语言不是有setjmp/longjmp吗?能不能用它来模拟try-catch?

嗯,技术上可以,但我不建议。

#include <setjmp.h>

jmp_buf env;

void risky_function() {
    if (something_wrong) {
        longjmp(env, 1);  // 相当于 throw
    }
}

int main() {
    if (setjmp(env) == 0) {  // 相当于 try
        risky_function();
    } else {  // 相当于 catch
        // 处理错误
    }
    return 0;
}

为什么我不推荐?

  • 资源泄漏:longjmp跳过栈上所有局部变量的析构(C没有析构函数,但你可能需要手动释放资源)
  • 不可预测:longjmp会破坏正常的控制流,让代码变得难以调试
  • 性能问题:setjmp需要保存整个CPU上下文,开销不小

我曾经在一个项目里看到有人用setjmp/longjmp做错误处理,结果出了bug后,调试器根本没法跟踪控制流——因为longjmp一执行,调用栈就全乱了。最后我们花了三天才把那段代码重构掉。

1.8 总结一下

C语言没有try-catch,不是因为它「做不到」,而是因为它「不想做」。

C语言的哲学是:错误是程序的一部分,不是意外。每个函数都应该明确告诉调用者「我成功了」还是「我失败了」,而不是偷偷抛出一个异常让调用者去猜。

这种设计让C语言在嵌入式、操作系统、驱动开发等领域无可替代——因为在这些场景里,你承受不起任何「隐式」的控制流跳转。

记住三条原则:

  1. 检查每个返回值——这是你的第一道防线
  2. 用errno获取错误详情——但别依赖它做控制流
  3. 信号是最后的手段——能不用就不用,用了就只做最少的处理

下一章,我会聊聊实际项目中怎么设计一套好用的错误码体系。嗯,那才是真正考验功力的地方。

可可精品资料库