错误处理与断言:errno, perror, assert 的跨平台行为与自定义
大家好,我是你们的老朋友。今天我们来聊聊 C 语言里一个特别容易被忽视,但又特别重要的话题——错误处理与断言。
说实话,我见过太多项目,代码写得飞起,但一遇到异常就崩得稀里哗啦。为什么?因为错误处理没做好。你想想看,一个没有错误处理的程序,就像一辆没有刹车的跑车,跑得快,但死得也快。
今天我们就来系统地梳理一下 C 语言中的错误处理机制:errno、perror、assert,以及它们在跨平台开发中的那些坑。嗯,我会结合我自己的踩坑经历,给你一些实用的建议。
1. errno:全局错误码的利与弊
errno 是 C 标准库提供的一个全局整数变量,用于记录最近一次系统调用或库函数调用发生的错误代码。说白了,就是系统给你留的一个小纸条,告诉你刚才那步操作出了什么问题。
它的使用方式很简单:
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main() {
FILE *fp = fopen("nonexistent.txt", "r");
if (fp == NULL) {
printf("errno = %d\n", errno);
printf("错误描述: %s\n", strerror(errno));
}
return 0;
}
输出结果:
errno = 2
错误描述: No such file or directory
这里有个坑,我必须提醒你:errno 是线程不安全的。在早期的 POSIX 标准中,errno 被定义为一个全局变量。如果你的程序是多线程的,两个线程同时调用系统函数,errno 的值就会被互相覆盖。
- 在 Linux/glibc 中,
errno被定义为宏,展开为线程局部存储(TLS),所以是线程安全的。 - 在 Windows 的某些旧版编译器中,
errno仍然是全局变量,多线程下会有问题。 - 在嵌入式系统(如 FreeRTOS、RT-Thread)中,
errno可能根本没有实现,或者实现不完整。
我个人习惯的做法是:在调用可能设置 errno 的函数后,立即读取并保存 errno 的值。不要等到后面再读,因为中间可能被其他函数调用覆盖掉。
// 我的习惯:立即保存 errno
FILE *fp = fopen("config.ini", "r");
int saved_errno = errno; // 立即保存
if (fp == NULL) {
// 使用 saved_errno,而不是 errno
fprintf(stderr, "打开文件失败: %s\n", strerror(saved_errno));
}
2. perror:简单粗暴的错误输出
perror 是 errno 的好搭档。它会将你传入的字符串加上冒号和空格,再拼接上当前 errno 对应的错误描述,一起输出到 stderr。
#include <stdio.h>
#include <errno.h>
int main() {
FILE *fp = fopen("config.ini", "r");
if (fp == NULL) {
perror("打开 config.ini 失败");
}
return 0;
}
输出:
打开 config.ini 失败: No such file or directory
嗯,这里要注意:perror 的输出是固定的格式,你没法自定义。而且它默认输出到 stderr,如果你想把错误信息输出到日志文件或者网络 socket,那就得自己动手了。
我在项目中遇到过这样的情况:一个嵌入式设备,没有标准输出,所有日志都要通过串口打印。这时候 perror 就不好使了。我一般会封装一个自己的错误输出函数:
void my_perror(const char *msg) {
// 自定义错误输出,支持串口、日志文件等
char buffer[256];
snprintf(buffer, sizeof(buffer), "%s: %s\n", msg, strerror(errno));
uart_send_string(buffer); // 假设这是串口输出函数
}
3. assert:调试时的守护神
assert 是 <assert.h> 中定义的宏,用于在调试阶段检查「不应该发生」的条件。如果条件为假,它会打印错误信息并调用 abort() 终止程序。
#include <assert.h>
int divide(int a, int b) {
assert(b != 0); // 除数不能为0
return a / b;
}
当 b == 0 时,程序会输出类似这样的信息并崩溃:
Assertion failed: b != 0, file test.c, line 5
这里有个关键点:assert 只在调试模式下生效。如果你定义了 NDEBUG 宏,assert 就会被完全移除,不产生任何代码。
- 在开发阶段,不要定义
NDEBUG,让 assert 帮你抓 bug。 - 在发布版本中,定义
NDEBUG,移除 assert 的开销。 - 但要注意:不要在 assert 中写有副作用的表达式,比如
assert(++x > 0),因为发布版本中这行代码会被删掉,x就不会自增了。
我曾经在一个项目中看到这样的代码:
// 错误示例:assert 中有副作用
assert(ptr = malloc(1024)); // 发布版本中,ptr 不会被赋值!
嗯,这种 bug 特别难查。因为调试版本一切正常,发布版本就莫名其妙地崩溃了。所以,永远不要在 assert 中写赋值、函数调用等有副作用的代码。
4. 跨平台行为对比
不同平台对错误处理的支持差异很大。我整理了一个表格,方便你对照:
| 特性 | Linux (glibc) | Windows (MSVC) | 嵌入式 (FreeRTOS) |
|---|---|---|---|
| errno 线程安全 | ✅ 是(TLS) | ⚠️ 旧版不安全 | ❌ 通常不支持 |
| perror 可用性 | ✅ 完整支持 | ✅ 支持 | ⚠️ 需自定义输出 |
| assert 行为 | ✅ 标准行为 | ✅ 标准行为 | ⚠️ 可能无 abort() |
| 错误码数量 | 130+ 个 | 400+ 个 | 通常很少 |
| strerror 线程安全 | ⚠️ 非线程安全 | ⚠️ 非线程安全 | ❌ 通常不支持 |
如果你需要写跨平台的代码,我建议你封装一层自己的错误处理接口。比如:
// my_error.h
#ifndef MY_ERROR_H
#define MY_ERROR_H
// 自定义错误码
typedef enum {
MY_SUCCESS = 0,
MY_ERR_FILE_NOT_FOUND,
MY_ERR_PERMISSION_DENIED,
MY_ERR_OUT_OF_MEMORY,
// ...
} my_error_t;
// 获取最后一次错误
my_error_t my_get_last_error(void);
// 输出错误信息
void my_print_error(const char *msg);
// 断言(调试用)
#ifdef MY_DEBUG
#define MY_ASSERT(cond) do { \
if (!(cond)) { \
my_print_error("Assertion failed: " #cond); \
my_abort(); \
} \
} while(0)
#else
#define MY_ASSERT(cond) ((void)0)
#endif
#endif
5. 自定义错误处理:打造你自己的错误体系
在实际项目中,标准库的错误处理往往不够用。你需要建立自己的错误处理体系。我一般会这样做:
- 定义统一的错误码枚举,覆盖项目中所有可能的错误场景。
- 封装错误获取和输出函数,屏蔽平台差异。
- 使用断言检查不可恢复的错误,比如空指针、数组越界等。
- 对于可恢复的错误,使用返回值或错误码,而不是直接崩溃。
下面是一个简单的自定义错误处理框架:
// error_framework.h
#ifndef ERROR_FRAMEWORK_H
#define ERROR_FRAMEWORK_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// 错误级别
typedef enum {
ERROR_LEVEL_DEBUG = 0,
ERROR_LEVEL_INFO,
ERROR_LEVEL_WARNING,
ERROR_LEVEL_ERROR,
ERROR_LEVEL_FATAL
} error_level_t;
// 错误信息结构体
typedef struct {
error_level_t level;
int code;
char message[256];
const char *file;
int line;
} error_info_t;
// 全局错误信息(线程局部存储)
extern __thread error_info_t g_last_error;
// 设置错误
#define SET_ERROR(level, code, msg) do { \
g_last_error.level = (level); \
g_last_error.code = (code); \
snprintf(g_last_error.message, sizeof(g_last_error.message), "%s", (msg)); \
g_last_error.file = __FILE__; \
g_last_error.line = __LINE__; \
} while(0)
// 获取错误
#define GET_ERROR() (&g_last_error)
// 打印错误
void print_error(const error_info_t *err) {
const char *level_str[] = {"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"};
fprintf(stderr, "[%s] %s:%d - code=%d, %s\n",
level_str[err->level],
err->file, err->line,
err->code, err->message);
}
// 致命错误断言
#define FATAL_ASSERT(cond, msg) do { \
if (!(cond)) { \
SET_ERROR(ERROR_LEVEL_FATAL, -1, msg); \
print_error(GET_ERROR()); \
abort(); \
} \
} while(0)
#endif
使用示例:
#include "error_framework.h"
int read_config(const char *path) {
FILE *fp = fopen(path, "r");
if (fp == NULL) {
SET_ERROR(ERROR_LEVEL_ERROR, errno, "无法打开配置文件");
return -1;
}
// ... 读取配置
fclose(fp);
return 0;
}
int main() {
if (read_config("config.ini") != 0) {
print_error(GET_ERROR());
return 1;
}
return 0;
}
6. 知识体系总览
为了让你更直观地理解本章的知识结构,我画了一张图:
7. 总结与避坑指南
好了,我们来快速回顾一下今天的内容:
- errno:全局错误码,注意线程安全和跨平台差异。我的习惯是立即保存 errno 的值。
- perror:简单但不够灵活。在嵌入式或特殊输出场景下,建议自己封装。
- assert:调试利器,但不要在 assert 中写有副作用的代码。发布版本记得定义 NDEBUG。
- 自定义错误处理:建立自己的错误码体系和接口,屏蔽平台差异,提高代码可移植性。
有一次我在一个多线程服务器中直接使用 errno,结果两个线程同时调用 fopen 失败,errno 被互相覆盖,导致错误信息完全对不上号。从那以后,我养成了「立即保存 errno」的习惯。
还有一次,我在 assert 中写了一个 malloc 调用,发布版本中 assert 被移除,导致内存泄漏。嗯,这种低级错误,犯过一次就再也不会犯了。
错误处理看似简单,但做不好真的会让人抓狂。希望今天的内容能帮你少走一些弯路。记住:好的错误处理,不是让程序不崩溃,而是让程序在崩溃时能告诉你它为什么崩溃。