19、断言与错误处理:assert的副作用、错误码与errno、setjmp/longjmp作为异常处理
嵌入式开发中,错误处理从来不是锦上添花,而是保命符。我见过太多系统因为一个野指针、一次越界访问,直接死机重启。更可怕的是——有些bug在实验室跑三天都复现不了,一上线就翻车。
今天我们来聊聊C语言里三个最常用的错误处理手段:assert、errno、setjmp/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 的副作用陷阱
这里有个坑,我年轻时踩过。你想想看,assert 在 NDEBUG 宏定义后会被完全移除。这意味着:
// 错误示范
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 = 0;
错误码设计的三个原则
我自己写库函数时,会遵循这几条:
- 返回值优先:能用返回值表示错误,就别用 errno。比如返回 -1 表示失败,0 表示成功。
- 错误码要分层:模块号 + 错误类型。比如
0x0102表示“I2C模块-超时错误”。 - 提供错误描述函数:类似
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 就失效了。这叫“悬空跳转”,后果是未定义行为。
- 可读性灾难:代码执行流像过山车,后来人根本看不懂。
什么时候可以用?
我个人认为,只有两种场景值得考虑:
- 深度嵌套的错误恢复:比如一个协议栈解析到第5层发现头部错误,用 longjmp 直接跳回最外层重新开始。
- 超时处理:配合信号或硬件定时器,在超时时跳回主循环。
但即便如此,我建议你优先用状态机或回调函数来重构。说白了,longjmp 是最后的手段。
19.4 知识体系总览
下面这张图总结了三种错误处理机制的核心区别和适用场景:
19.5 避坑指南
最后,分享几条我用血泪换来的经验:
我曾经在一个通信协议栈里用了 longjmp 做错误恢复。结果有一次 longjmp 时,中间函数正握着互斥锁。跳回去之后,所有等待这个锁的任务全部死锁。从那以后,我宁愿写三层嵌套的 if-else,也不再碰 longjmp。
我的建议是:
- assert 只用在“绝对不可能发生”的地方
- 错误码要统一风格,整个团队用同一套枚举
- 如果非要用 setjmp,确保 jmp_buf 的生命周期和跳转目标函数一致
- 多线程环境下,errno 是安全的,但 longjmp 会破坏线程局部存储
嗯,错误处理这件事,说白了就是“防患于未然”。你花10分钟写好错误处理,可能省下后面10小时的调试时间。这笔账,怎么算都划算。
公众号:蓝海资料掘金营,微信deep3321