错误处理机制:errno的使用、错误码定义规范、错误传播策略、集中式错误处理

错误处理,说白了就是代码的「安全气囊」。你开车上路,不能指望永远不出事故。写代码也一样,不能指望所有输入都合法、所有资源都申请成功、所有硬件都乖乖听话。

我刚开始做嵌入式那几年,吃过不少「不处理错误」的亏。有一次,一个串口驱动在内存分配失败后直接返回了垃圾数据,结果上层模块拿着这个数据去控制电机……嗯,电机直接飞车了。从那以后,我对错误处理的态度就变成了:宁可代码多写三行,不让bug多跑一秒

1. errno 的使用:C语言最古老的错误信号

errno 是 C 标准库提供的一个全局整数变量。很多库函数在出错时,会设置 errno 的值,然后返回一个特殊值(比如 -1 或 NULL)来告诉你「出事了」。

核心原则:调用可能失败的函数后,立即检查返回值。如果返回错误,再读取 errno 获取具体原因。

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

FILE *fp = fopen("/config/settings.bin", "r");
if (fp == NULL) {
    // 立刻检查 errno
    switch (errno) {
        case ENOENT:
            printf("配置文件不存在,使用默认配置\n");
            break;
        case EACCES:
            printf("权限不足,请检查文件权限\n");
            return -1;
        case ENOMEM:
            printf("系统内存不足,无法打开文件\n");
            return -1;
        default:
            printf("未知错误,错误码: %d\n", errno);
            return -1;
    }
}

我的习惯:每次调用系统函数后,如果可能失败,我会在下一行就检查返回值。绝不隔行。因为 errno 可能被中间的其他函数调用覆盖掉。

你可能会问:为什么不直接用 perror() 打印错误?嗯,在调试阶段可以,但在正式产品里,你不能把错误信息直接怼到用户脸上。你需要的是结构化的错误处理,而不是简单的打印。

2. 错误码定义规范:给你的错误一个「身份证」

errno 是系统级的。在应用层,我们需要自己的错误码体系。我见过太多项目,错误码定义得乱七八糟——有的用宏,有的用枚举,有的直接写数字。维护起来简直是一场噩梦。

我个人推荐的做法是:用枚举 + 前缀命名法

// 错误码定义:模块_错误类型
typedef enum {
    // 通用错误 (0x0000 - 0x00FF)
    ERR_OK          = 0x0000,   // 成功
    ERR_GENERAL     = 0x0001,   // 通用错误
    ERR_NOMEM       = 0x0002,   // 内存不足
    ERR_TIMEOUT     = 0x0003,   // 超时
    ERR_INVALID_PARAM = 0x0004, // 参数无效

    // 传感器模块 (0x0100 - 0x01FF)
    ERR_SENSOR_INIT     = 0x0100,   // 传感器初始化失败
    ERR_SENSOR_READ     = 0x0101,   // 传感器读取失败
    ERR_SENSOR_CALIB    = 0x0102,   // 传感器校准失败

    // 通信模块 (0x0200 - 0x02FF)
    ERR_COMM_OPEN       = 0x0200,   // 通信端口打开失败
    ERR_COMM_SEND       = 0x0201,   // 发送失败
    ERR_COMM_RECV       = 0x0202,   // 接收失败
    ERR_COMM_CRC        = 0x0203,   // CRC校验错误
} ErrorCode_t;

规范要点:

  • 每个错误码是唯一的,用 16 位整数表示
  • 高 8 位表示模块,低 8 位表示具体错误
  • 命名清晰:ERR_模块_具体错误
  • 预留空间:每个模块留 256 个码位

我曾经在一个项目里接手过这样的代码:错误码用 #define 定义,值从 1 排到 200,没有任何分组。你想查一个错误是什么意思,得翻遍整个头文件。后来我花了三天时间,把所有错误码重构成了枚举分组。从那以后,新来的同事再也没抱怨过「看不懂错误码」。

3. 错误传播策略:错误该「往上抛」还是「就地消化」?

这是嵌入式开发里最纠结的问题之一。一个函数出错了,是就地处理掉,还是把错误返回给调用者?

我的经验是:底层函数只负责「检测错误」,不负责「处理错误」。处理错误是调用者的责任。

// 底层驱动:只检测,不处理
int sensor_read_temperature(float *temp) {
    if (temp == NULL) {
        return ERR_INVALID_PARAM;  // 返回错误码,不自己处理
    }

    // 假设通过 I2C 读取传感器
    int ret = i2c_read(0x48, 0x00, (uint8_t*)temp, 4);
    if (ret != 0) {
        return ERR_SENSOR_READ;    // 返回错误码,不自己处理
    }

    return ERR_OK;
}

// 上层应用:负责处理错误
void control_loop(void) {
    float temperature;
    int err = sensor_read_temperature(&temperature);

    if (err != ERR_OK) {
        // 集中处理:记录日志、使用默认值、报警
        log_error("温度读取失败,错误码: 0x%04X", err);
        temperature = 25.0f;  // 使用默认值
        // 或者:触发看门狗复位
        // 或者:切换到备用传感器
    }

    // 继续执行控制逻辑
    adjust_heater(temperature);
}

避坑指南:我曾经在一个项目中,底层函数在检测到错误后,自己调用了 printf 打印错误信息,然后返回了 0。上层调用者看到返回 0,以为一切正常,继续往下执行。结果系统在错误状态下运行了整整两天,直到数据完全错乱才被发现。教训是:底层函数不要「悄悄消化」错误,一定要把错误信号明确传递出去

错误传播的几种常见策略:

策略 适用场景 优点 缺点
返回错误码 大多数函数 简单、明确、可组合 调用者必须检查返回值
全局错误变量 中断服务程序、回调函数 不改变函数签名 线程不安全、容易遗漏
异常(setjmp/longjmp) 深度嵌套的错误处理 跳过多层调用栈 资源泄漏风险、调试困难
断言(assert) 不可恢复的错误 快速失败、便于调试 发布版本中会被移除

我个人最常用的是「返回错误码」。它最直接,也最容易让代码的意图变得清晰。你想想看,一个函数返回 int,调用者看到 if (ret != ERR_OK) 就知道要处理错误,多清爽。

4. 集中式错误处理:把「救火队」放在一个地方

如果每个函数都自己处理错误,代码里会到处都是 if-else,读起来像一团乱麻。集中式错误处理,就是把所有错误处理的逻辑收拢到一个地方。

我常用的模式是:函数内部只检查错误并返回,在调用链的「顶层」统一处理

// 集中式错误处理器
void error_handler(int err_code, const char *module, const char *func) {
    static int error_count = 0;
    error_count++;

    // 记录错误日志
    log_system("ERROR [%s][%s] 错误码: 0x%04X, 累计错误: %d",
               module, func, err_code, error_count);

    // 根据错误严重级别采取不同措施
    switch (err_code) {
        case ERR_NOMEM:
            // 内存不足,尝试回收缓存
            cache_cleanup();
            break;

        case ERR_TIMEOUT:
            // 超时错误,重试
            break;

        case ERR_SENSOR_READ:
            // 传感器故障,切换到备用传感器
            sensor_switch_to_backup();
            break;

        default:
            // 未知严重错误,系统复位
            system_reset();
            break;
    }
}

// 使用示例
int result = sensor_read_temperature(&temp);
if (result != ERR_OK) {
    error_handler(result, "SENSOR", __func__);
    // 根据错误类型决定是否继续执行
    if (result == ERR_SENSOR_READ) {
        temp = 25.0f;  // 使用默认值继续
    } else {
        return;  // 严重错误,退出当前任务
    }
}

集中式错误处理的好处:

  • 错误处理逻辑集中在一处,修改方便
  • 可以统一记录日志、统计错误次数
  • 可以根据错误严重级别统一决策(重试、降级、复位)
  • 代码可读性大幅提升,业务逻辑和错误处理分离

嗯,这里要注意一点:集中式处理不是让你把所有错误都丢到一个函数里不管了。你还是要区分错误的严重程度。比如,一个校验和错误可能只需要重发数据包,但一个内存访问越界可能就需要立即复位系统。集中式处理器的职责是「根据错误码做出统一决策」,而不是「把所有错误都当成一样的」。

知识体系总览

下面这张图,是我对本章知识体系的总结。你可以把它当作一个「错误处理决策地图」来用。

错误处理机制知识体系 errno 使用 • 全局整数变量 • 函数失败时设置 • 立即检查返回值 • 防止被覆盖 错误码定义规范 • 枚举 + 前缀命名 • 高8位模块号 • 低8位错误号 • 预留扩展空间 错误传播策略 • 返回错误码 • 全局错误变量 • setjmp/longjmp • 断言快速失败 集中式错误处理 • 统一入口 • 分级决策 • 日志统计 • 业务分离 目标:构建可预测、可维护、可追溯的错误处理体系 可预测:错误路径清晰 每个函数都有明确错误出口 可维护:错误码统一管理 修改一处,全局生效 可追溯:日志记录完整 错误发生时有据可查

最后说一句:错误处理不是「锦上添花」,而是「雪中送炭」。你花在错误处理上的每一分钟,都是在为未来的自己减少一个通宵调试的夜晚。别偷懒,把错误处理当成代码的一部分,而不是事后补丁。


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