第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 知识体系总览
下面这张图,把本章的核心逻辑串起来了:
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