第17章:错误处理机制:errno使用、返回码约定、断言使用、异常模拟

错误处理,说白了就是代码的「安全气囊」。

我见过太多项目,功能跑得飞起,一遇到异常就崩得稀碎。为什么?因为错误处理没做好。嵌入式系统不像桌面应用,崩了还能弹个窗让你重启。嵌入式崩了,轻则死机,重则设备烧毁、数据丢失。

所以这一章,我们来聊聊C语言里那套「土办法」——没有try-catch,我们怎么把错误管得服服帖帖。

17.1 errno:全局错误码的利与弊

errno 是C标准库提供的一个全局整型变量。每次系统调用或库函数出错,它就会被设置成对应的错误码。

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

FILE *fp = fopen("nonexist.txt", "r");
if (fp == NULL) {
    printf("错误码: %d\n", errno);
    perror("打开文件失败");
}

输出大概是:

错误码: 2
打开文件失败: No such file or directory

嗯,这里要注意:errno 是线程不安全的。多线程环境下,一个线程刚检查完 errno,另一个线程可能就把它改了。

⚠️ 避坑指南
我曾经在一个多线程项目里直接用 errno 做错误判断,结果两个线程同时打开文件,一个成功一个失败,但 errno 被后一个线程覆盖了。排查了整整两天才发现是这个问题。后来我改用线程局部存储(TLS)来模拟 errno,才算彻底解决。

我个人习惯的做法是:在单线程或简单场景下用 errno 没问题,但复杂系统里,我会封装一层自己的错误码机制。

17.2 返回码约定:让函数说话

返回码是C语言最朴素的错误传递方式。但怎么约定,这里面有门道。

常见的做法有几种:

  • 返回0表示成功,非0表示失败——这是POSIX风格,最通用
  • 返回负值表示错误,0或正值表示成功——适合需要返回数据量的场景
  • 返回指针,NULL表示失败——适合内存分配、查找类函数
// 我常用的返回码风格
typedef enum {
    ERR_OK       = 0,
    ERR_PARAM    = -1,
    ERR_MEMORY   = -2,
    ERR_TIMEOUT  = -3,
    ERR_BUSY     = -4,
    ERR_UNKNOWN  = -99
} ErrorCode;

ErrorCode sensor_read(int32_t *value) {
    if (value == NULL) {
        return ERR_PARAM;
    }
    // 读取传感器...
    if (硬件失败) {
        return ERR_BUSY;
    }
    *value = 读取到的值;
    return ERR_OK;
}
💡 我的经验
返回码一定要统一。我见过一个项目,有的函数返回0表示成功,有的返回1表示成功,还有的返回-1表示成功。调试的时候简直想骂人。所以,团队里必须定死一套规则,写在编码规范里。

17.3 断言使用:调试时的照妖镜

断言(assert)是调试阶段的利器。它用来检查「绝对不应该发生」的情况。

#include <assert.h>

void uart_send(const uint8_t *data, size_t len) {
    assert(data != NULL);   // 数据指针不能为空
    assert(len > 0);        // 长度必须大于0
    assert(len <= 256);     // 单次发送不能超过256字节
    
    // 实际发送逻辑...
}

断言只在调试模式下生效。发布版本里,NDEBUG 宏一定义,所有 assert 就消失了。

🔑 关键原则
断言只用于检查「内部逻辑错误」,比如函数参数不合法、数据结构被破坏。不要用断言来处理「外部输入错误」,比如用户按了错误的按钮、网络数据包格式不对——这些应该用返回码来处理。

为什么?因为断言会直接终止程序。你想想看,用户按错一个键,程序就崩了,这合理吗?不合理。但如果是内存管理模块发现链表指针被篡改,那崩了反而是好事——至少你知道问题出在哪。

我曾经在一个项目中,同事把断言用在了传感器数据校验上。结果传感器偶尔受干扰输出异常值,程序直接断言崩溃。生产线上一片红。后来我改成用返回码+重试机制,问题就解决了。

17.4 异常模拟:C语言里的try-catch

C语言没有原生的异常处理。但我们可以用 setjmp/longjmp 来模拟。

#include <setjmp.h>

jmp_buf exception_buf;

void risky_function() {
    if (发生严重错误) {
        longjmp(exception_buf, 1);  // 跳回setjmp处
    }
}

int main() {
    if (setjmp(exception_buf) == 0) {
        // 正常执行
        risky_function();
    } else {
        // 异常处理
        printf("捕获到异常!\n");
    }
    return 0;
}

这个机制的原理很简单:setjmp 保存当前执行上下文(寄存器、栈指针等),longjmp 恢复那个上下文,就像时光倒流一样。

⚠️ 使用限制
longjmp 跳回去时,局部变量的值可能不确定(除非是 volatile 类型)。另外,资源清理是个大问题——跳转时不会自动调用析构函数或释放内存。所以,我个人只在极少数场景用这个:比如深度嵌套的错误恢复、超时处理。

说实话,我很少在生产代码里用 setjmp/longjmp。它太容易造成资源泄漏了。我更推荐用「状态机+返回码」的方式来处理复杂错误路径。

17.5 知识体系总览

下面这张图,把本章的核心逻辑串起来了:

C语言错误处理机制 errno 全局错误码 线程不安全 适合简单场景 返回码 枚举定义错误码 0成功/非0失败 团队统一约定 断言 调试阶段使用 检查内部逻辑 发布版可关闭 异常模拟 setjmp/longjmp 资源清理困难 谨慎使用 选择策略 • 简单工具/库函数 → errno • 业务逻辑函数 → 返回码枚举 • 调试阶段内部检查 → 断言 • 极端异常恢复 → setjmp/longjmp(少用)

17.6 综合示例:一个完整的错误处理框架

最后,我分享一个实际项目中用过的错误处理框架雏形:

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

// 错误码定义
typedef enum {
    SUCCESS = 0,
    ERR_NULL_PTR,
    ERR_INVALID_PARAM,
    ERR_TIMEOUT,
    ERR_HARDWARE,
    ERR_BUSY
} Result;

// 错误码转字符串
const char* result_to_str(Result r) {
    switch (r) {
        case SUCCESS:         return "成功";
        case ERR_NULL_PTR:    return "空指针";
        case ERR_INVALID_PARAM: return "无效参数";
        case ERR_TIMEOUT:     return "超时";
        case ERR_HARDWARE:    return "硬件错误";
        case ERR_BUSY:        return "忙";
        default:              return "未知错误";
    }
}

// 传感器读取函数
Result sensor_read(int32_t *value) {
    assert(value != NULL);  // 调试时检查
    
    if (value == NULL) {
        return ERR_NULL_PTR;  // 发布版也检查
    }
    
    // 模拟硬件操作
    errno = 0;
    int ret = some_hw_operation();
    if (ret != 0) {
        if (errno == ETIMEDOUT) {
            return ERR_TIMEOUT;
        }
        return ERR_HARDWARE;
    }
    
    *value = 42;
    return SUCCESS;
}

// 使用示例
void demo() {
    int32_t val;
    Result r = sensor_read(&val);
    if (r != SUCCESS) {
        printf("读取失败: %s\n", result_to_str(r));
        // 执行恢复逻辑...
    }
}
💡 我的建议
错误处理不是「写完了再补」的东西。它应该从一开始就设计进架构里。我见过太多项目,功能都写完了,才想起来「哦,我们还没做错误处理」,结果到处打补丁,代码变得又臭又硬。你想想看,地基都没打好,后面怎么盖楼?

好了,这一章的内容就到这里。错误处理看似琐碎,但它是代码质量的试金石。一个错误处理做得好的项目,往往其他方面也不会差。


公众号:蓝海资料掘金营,微信deep3321