19、断言与错误处理:assert的副作用、错误码与errno、setjmp/longjmp作为异常处理

嵌入式开发中,错误处理从来不是锦上添花,而是保命符。我见过太多系统因为一个野指针、一次越界访问,直接死机重启。更可怕的是——有些bug在实验室跑三天都复现不了,一上线就翻车。

今天我们来聊聊C语言里三个最常用的错误处理手段:asserterrnosetjmp/longjmp。它们各有各的脾气,用对了是神器,用错了是定时炸弹。

19.1 assert:调试利器,但别留在发布版里

assert 是个宏,定义在 <assert.h> 里。它的作用很简单:如果表达式为假,就打印错误信息并终止程序。

#include <assert.h>

void set_pin_mode(int pin, int mode) {
    assert(pin >= 0 && pin < 16);  // 引脚号必须在0~15之间
    // 实际配置代码...
}

你看,这就像在代码里埋了个哨兵。一旦条件不满足,程序立刻“自爆”,帮你定位问题。

assert 的副作用陷阱

这里有个坑,我年轻时踩过。你想想看,assertNDEBUG 宏定义后会被完全移除。这意味着:

警告:永远不要在 assert 里写有副作用的表达式!
// 错误示范
assert(ptr = malloc(100));  // 发布版中 malloc 不会执行!

// 正确做法
ptr = malloc(100);
assert(ptr != NULL);

我曾经接手过一个项目,前任工程师在 assert 里调用了硬件复位函数。调试版跑得好好的,发布版一运行就死机。查了两天才发现——assert 被优化掉了,复位函数根本没执行。

我的个人习惯

我一般只在以下场景用 assert:

  • 函数入口参数校验(指针非空、枚举值合法)
  • 算法执行前后的状态不变式检查
  • 永远不会发生的分支(比如 switch 的 default)

说白了,assert 是给开发者看的,不是给用户看的。用户不该看到程序崩溃。

19.2 错误码与 errno:标准C的错误传递方式

说到错误码,就绕不开 errno。这是个全局变量,很多库函数在出错时会设置它。

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

FILE *fp = fopen("config.bin", "r");
if (fp == NULL) {
    fprintf(stderr, "打开文件失败: %s\n", strerror(errno));
    return -1;
}

嗯,这里要注意:errno 是线程局部存储的,在多线程环境下是安全的。但有个问题——函数调用成功时,errno 不会被清零

技巧:在调用可能设置 errno 的函数前,先手动置零:errno = 0;

错误码设计的三个原则

我自己写库函数时,会遵循这几条:

  1. 返回值优先:能用返回值表示错误,就别用 errno。比如返回 -1 表示失败,0 表示成功。
  2. 错误码要分层:模块号 + 错误类型。比如 0x0102 表示“I2C模块-超时错误”。
  3. 提供错误描述函数:类似 strerror,方便打印。
// 我常用的错误码定义方式
typedef enum {
    ERR_OK          = 0,
    ERR_I2C_TIMEOUT = 0x0101,
    ERR_I2C_NACK    = 0x0102,
    ERR_SPI_CRC     = 0x0201,
    // ...
} error_code_t;

const char* error_to_string(error_code_t err) {
    switch (err) {
        case ERR_OK:          return "成功";
        case ERR_I2C_TIMEOUT: return "I2C 超时";
        case ERR_I2C_NACK:    return "I2C 无应答";
        case ERR_SPI_CRC:     return "SPI CRC 校验失败";
        default:              return "未知错误";
    }
}

19.3 setjmp/longjmp:C语言里的“异常处理”

说实话,C语言没有 try-catch,但 setjmp/longjmp 能模拟类似的效果。它允许你从一个函数直接跳回到调用栈中更早的位置。

#include <setjmp.h>

jmp_buf env;

void risky_function() {
    if (something_wrong) {
        longjmp(env, 1);  // 跳回 setjmp 处,返回值为 1
    }
}

int main() {
    if (setjmp(env) == 0) {
        // 正常执行路径
        risky_function();
    } else {
        // 异常处理路径
        printf("发生错误,已恢复\n");
    }
}

这东西有多危险?

我在项目中用过一次,后来再也不敢随便用了。为什么?

  • 资源泄漏:longjmp 跳过所有中间函数的清理代码。malloc 的内存、打开的文件、上锁的互斥量,全都没人管。
  • 栈状态不确定:如果 longjmp 时中间函数已经返回,jmp_buf 就失效了。这叫“悬空跳转”,后果是未定义行为。
  • 可读性灾难:代码执行流像过山车,后来人根本看不懂。
警告:在嵌入式系统中,setjmp/longjmp 可能导致中断状态不一致。我见过一个案例,longjmp 跳过了关中断的恢复代码,导致系统再也没能响应中断。

什么时候可以用?

我个人认为,只有两种场景值得考虑:

  1. 深度嵌套的错误恢复:比如一个协议栈解析到第5层发现头部错误,用 longjmp 直接跳回最外层重新开始。
  2. 超时处理:配合信号或硬件定时器,在超时时跳回主循环。

但即便如此,我建议你优先用状态机或回调函数来重构。说白了,longjmp 是最后的手段。

19.4 知识体系总览

下面这张图总结了三种错误处理机制的核心区别和适用场景:

C语言错误处理三大机制 assert • 调试阶段使用 • NDEBUG 下被移除 • 不能有副作用 • 直接终止程序 ⚠ 发布版必须禁用 ⚠ 别写 malloc 在里面 errno + 错误码 • 标准C错误传递 • 线程局部存储 • 函数成功不清零 • 需手动 errno=0 ⚠ 返回值优先原则 ⚠ 错误码要分层设计 setjmp/longjmp • 模拟异常处理 • 跨函数跳转 • 跳过清理代码 • 资源泄漏风险 ⚠ 悬空跳转 UB ⚠ 中断状态不一致 核心原则:调试用 assert,运行时用错误码,不到万不得已别碰 setjmp/longjmp

19.5 避坑指南

最后,分享几条我用血泪换来的经验:

我曾经在一个通信协议栈里用了 longjmp 做错误恢复。结果有一次 longjmp 时,中间函数正握着互斥锁。跳回去之后,所有等待这个锁的任务全部死锁。从那以后,我宁愿写三层嵌套的 if-else,也不再碰 longjmp。

我的建议是

  • assert 只用在“绝对不可能发生”的地方
  • 错误码要统一风格,整个团队用同一套枚举
  • 如果非要用 setjmp,确保 jmp_buf 的生命周期和跳转目标函数一致
  • 多线程环境下,errno 是安全的,但 longjmp 会破坏线程局部存储

嗯,错误处理这件事,说白了就是“防患于未然”。你花10分钟写好错误处理,可能省下后面10小时的调试时间。这笔账,怎么算都划算。


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