C/C++ 错误处理全解:从 errno 到 C++ 异常
前言
写 C/C++ 的时候,错误处理是一个绕不开的话题。C 语言没有内置的异常机制,靠的是返回值、errno、assert 这些老路子;C++ 则提供了 try-catch-throw 这套结构化异常处理。两种语言的设计哲学不同,适用的场景也不一样。这篇文章把两边的错误处理方式都捋一遍,该用哪种、怎么用,看完心里大概就有数了。
关于安全警告 :在 Windows 下使用 Visual Studio 编译时,
fopen、strcpy、strerror等函数会被标记为"不安全",建议改用带_s后缀的安全版本(如fopen_s)。这些安全函数会额外检查缓冲区大小,减少缓冲区溢出风险。本文示例均采用安全版本,同时在注释中说明如何通过定义_CRT_SECURE_NO_WARNINGS来禁用此警告(适用于兼容旧代码或跨平台项目)。
一、C语言的错误处理方式
C 语言不提供对错误处理的直接支持。作为一种系统编程语言,它靠返回值让你判断函数调用是否成功,同时配合一个全局的错误码变量 errno 来记录具体错误原因。
1.1 errno、perror() 与 strerror_s()
errno 是一个全局变量,定义在 <errno.h> 中。C 标准库的很多函数在执行出错时会设置 errno 的值。开发人员应该在程序初始化时把 errno 设为 0,0 表示没有错误。
C 语言提供了两个函数来显示 errno 对应的文本信息:
perror():显示你传给它的字符串,后跟一个冒号、一个空格和当前errno值的文本表示strerror_s():安全版本的strerror,将错误信息写入用户提供的缓冲区,并指定缓冲区大小
来看一个打开不存在的文件的例子(使用安全函数):
c
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main()
{
FILE *pf;
errno_t err;
char errbuf[256];
err = fopen_s(&pf, "unexist.txt", "rb");
if (err != 0) {
// 获取当前错误码
int errnum = errno;
fprintf(stderr, "Value of errno: %d\n", errnum);
perror("Error printed by perror");
// 使用 strerror_s 获取安全错误信息
if (strerror_s(errbuf, sizeof(errbuf), errnum) == 0) {
fprintf(stderr, "Error opening file: %s\n", errbuf);
}
} else {
fclose(pf);
}
return 0;
}
输出结果:
Value of errno: 2
Error printed by perror: No such file or directory
Error opening file: No such file or directory
需要注意 :错误信息应该输出到 stderr 而不是 stdout。
跨平台提示 :
fopen_s和strerror_s是 C11 标准 Annex K 中定义的可选函数,在 Windows 平台完全支持。如果要在 Linux/macOS 下编译,可以定义_CRT_SECURE_NO_WARNINGS并继续使用fopen/strerror,或者自行封装条件编译。
1.2 被零除的处理
C 语言中除以零会引发运行时错误。避免的办法是在做除法之前先检查除数:
c
#include <stdio.h>
#include <stdlib.h>
int main()
{
int dividend = 20;
int divisor = 0;
int quotient;
if (divisor == 0) {
fprintf(stderr, "Division by zero! Exiting...\n");
exit(EXIT_FAILURE);
}
quotient = dividend / divisor;
fprintf(stderr, "Value of quotient: %d\n", quotient);
exit(EXIT_SUCCESS);
}
程序成功退出时返回 EXIT_SUCCESS(定义为 0),出错退出时返回 EXIT_FAILURE(定义为 -1)。
1.3 assert 断言
assert 是 C 语言中用于调试的宏,定义在 <assert.h> 中。它在运行时检查一个条件,如果条件为假,就在 stderr 打印错误信息(包括出问题的表达式、文件名和行号),然后调用 abort() 终止程序。
c
#include <stdio.h>
#include <assert.h>
int main()
{
int a = 10;
int *p1 = &a;
assert(p1 != NULL); // 表达式为真,不执行
int *p2 = NULL;
assert(p2 != NULL); // 表达式为假,程序崩溃
return 0;
}
assert 只应该用在调试阶段,用来捕获"绝不应该发生"的逻辑错误。如果程序已经确认没问题,可以在 #include <assert.h> 之前定义 NDEBUG 宏来禁用所有 assert:
c
#define NDEBUG
#include <stdio.h>
#include <assert.h>
// 所有 assert 语句都会被跳过
几个需要留意的点:
assert会降低程序运行效率,不要过度使用assert导致程序立即终止,可能无法完成资源释放- Release 版本通常应该移除或替换
assert
1.4 setjmp() 与 longjmp():C语言中的"异常"
setjmp 和 longjmp 定义在 <setjmp.h> 中,提供了一种非局部跳转的机制------可以跨函数跳转。这就像在 C 语言里模拟 try-catch。
基本用法:
setjmp(jmp_buf env):保存当前执行环境到jmp_buf,第一次调用返回 0longjmp(jmp_buf env, int val):跳转回setjmp保存的位置,让setjmp返回val
c
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void f2()
{
printf("f2 begins\n");
longjmp(env, 1); // 跳转回 setjmp 的位置
printf("f2 returns\n"); // 这行不会执行
}
void f1()
{
printf("f1 begins\n");
f2();
printf("f1 returns\n");
}
int main()
{
if (setjmp(env) == 0) {
printf("setjmp returned 0\n");
} else {
printf("Program terminates: longjmp called\n");
return 0;
}
f1();
printf("Program terminates normally\n");
return 0;
}
输出:
setjmp returned 0
f1 begins
f2 begins
Program terminates: longjmp called
setjmp 第一次调用返回 0,程序继续执行;f2 中调用 longjmp 后,程序直接跳回 setjmp 的位置,这次返回的是 longjmp 的第二个参数 1。
使用 setjmp/longjmp 的注意事项:
longjmp的env参数必须先被setjmp初始化- 包含
setjmp调用的函数不能在longjmp之前返回 - 这两个函数会让代码难以理解和维护,除非必要否则慎用
二、C++ 的异常处理机制
C++ 提供了结构化的异常处理,核心是三个关键字:try、throw、catch。
2.1 基本语法
cpp
#include <iostream>
#include <stdexcept>
#include <limits>
void MyFunc(int c)
{
if (c > std::numeric_limits<char>::max()) {
throw std::invalid_argument("Argument too large");
}
}
int main()
{
try {
MyFunc(256); // 触发异常
} catch (const std::invalid_argument& e) {
std::cerr << "Caught: " << e.what() << std::endl;
return -1;
}
return 0;
}
流程是这样的:
try块括住可能抛出异常的代码throw抛出异常对象(可以是任意类型)catch按类型匹配捕获异常- 如果找不到匹配的
catch,调用std::terminate()终止程序
建议 :按值抛出、按 const 引用捕获。异常类型最好直接或间接继承自 std::exception。
catch(...) 可以捕获所有异常,但应该放在最后,并且只用来记录错误和做清理工作,不要让程序继续运行。
2.2 栈展开(Stack Unwinding)
C++ 抛出异常后,运行时会从 throw 的位置开始,逐层往回查找匹配的 catch 块。在这个过程中,沿途所有栈上的局部对象都会被自动析构。
cpp
#include <iostream>
#include <memory>
class A
{
int num;
public:
A(int i) : num(i) {
std::cout << "A constructed, num=" << num << std::endl;
}
~A() {
std::cout << "A destructed, num=" << num << std::endl;
}
};
void test1()
{
A* pa = new A(1);
throw 1; // 异常抛出,delete 不会执行
delete pa; // 内存泄漏
}
void test2()
{
std::auto_ptr<A> pa(new A(2));
pa->show();
throw 2; // auto_ptr 会在栈展开时自动析构,释放内存
}
栈展开过程中,裸指针 不会自动释放,可能导致内存泄漏。解决方案是使用 RAII 手法------用智能指针(auto_ptr、unique_ptr、shared_ptr)管理资源,让析构函数在栈展开时自动释放。
重要 :析构函数中不要抛出异常。如果析构函数在栈展开过程中又抛出异常,而另一个异常已经在传播中,std::terminate 会立即被调用,程序直接终止。C++11 起,析构函数默认带有 noexcept 属性。
2.3 异常规范:noexcept
C++11 引入了 noexcept 关键字,用来声明一个函数不会抛出异常。
cpp
void func() noexcept; // 保证不抛异常
void func() noexcept(true); // 同上
void func() noexcept(false); // 可能抛异常
如果 noexcept 函数真的抛出了异常,会直接调用 std::terminate() 终止程序。
什么时候用 noexcept?
- 移动构造函数和移动赋值运算符(能提升性能)
- 析构函数(默认就是
noexcept) - 任何你确定不会抛出异常的函数
旧的 throw() 动态异常规范在 C++11 中被弃用,C++17 中已移除。
2.4 自定义异常类
标准库提供了一系列异常类,都继承自 std::exception:
| 异常类 | 说明 |
|---|---|
std::exception |
所有标准异常的基类 |
std::bad_alloc |
内存分配失败 |
std::bad_cast |
类型转换失败 |
std::logic_error |
逻辑错误(编译时能发现的) |
std::invalid_argument |
无效参数 |
std::out_of_range |
越界访问 |
std::runtime_error |
运行时错误 |
如果这些不够用,可以继承 std::exception 自定义异常类。为了安全起见,推荐用 std::string 保存错误信息,避免手工管理 char*:
cpp
#include <exception>
#include <string>
class MyException : public std::exception
{
private:
std::string msg;
public:
explicit MyException(const std::string& m) : msg(m) {}
const char* what() const noexcept override {
return msg.c_str();
}
};
三、C 与 C++ 错误处理对比
| 维度 | C | C++ |
|---|---|---|
| 错误表示 | 整型错误码 / errno | 任意类型的异常对象 |
| 传播路径 | 手动逐层返回 | 自动栈展开 |
| 处理位置 | 必须立即处理 | 可在任意上层捕获 |
| 默认行为 | 忽略错误继续执行 | 未捕获则终止程序 |
| 性能开销 | 几乎为零 | 异常抛出时有较大开销 |
选型建议:
- C 项目:用返回值 + errno 做常规错误处理,用 assert 做调试断言
- C++ 项目 :
- 用异常处理"可能发生"的运行时错误(I/O 失败、参数校验等)
- 用
assert处理"绝不应该发生"的逻辑错误 - 性能敏感的循环中,考虑用错误码代替异常
四、完整示例:一个简单的银行账户系统
下面这个示例把 C 和 C++ 的错误处理方式都用上了,可以对照着看。所有可能触发安全警告的函数都已替换为安全版本(fopen_s、strerror_s),并检查了返回值。
cpp
#include <iostream>
#include <exception>
#include <string>
#include <cstring>
#include <cerrno>
// ==================== C风格错误处理 ====================
// 用 errno 模拟错误
void c_style_divide(int a, int b)
{
if (b == 0) {
errno = EINVAL; // 设置错误码
return;
}
printf("Result: %d\n", a / b);
}
// ==================== C++ 自定义异常类 ====================
class InsufficientFundsException : public std::exception
{
private:
std::string msg; // 使用 std::string 避免手工内存管理
public:
explicit InsufficientFundsException(const std::string& m) : msg(m) {}
const char* what() const noexcept override {
return msg.c_str();
}
};
class NegativeAmountException : public std::exception
{
public:
const char* what() const noexcept override {
return "Amount cannot be negative";
}
};
// ==================== 银行账户类 ====================
class BankAccount
{
private:
double balance;
std::string owner;
public:
BankAccount(const std::string& name, double initial) : owner(name), balance(initial) {}
void deposit(double amount)
{
if (amount < 0) {
throw NegativeAmountException();
}
balance += amount;
std::cout << "Deposited " << amount << ", new balance: " << balance << std::endl;
}
void withdraw(double amount)
{
if (amount < 0) {
throw NegativeAmountException();
}
if (amount > balance) {
throw InsufficientFundsException("Insufficient balance");
}
balance -= amount;
std::cout << "Withdrew " << amount << ", new balance: " << balance << std::endl;
}
double getBalance() const { return balance; }
std::string getOwner() const { return owner; }
};
// ==================== 资源管理示例(RAII,使用安全 fopen_s) ====================
class FileGuard
{
private:
FILE* fp;
public:
FileGuard(const char* filename, const char* mode) : fp(nullptr) {
errno_t err = fopen_s(&fp, filename, mode);
if (err != 0 || fp == nullptr) {
// 出错时抛出异常,构造函数不返回
throw std::runtime_error("Failed to open file");
}
}
~FileGuard() {
if (fp) {
fclose(fp);
std::cout << "File closed automatically" << std::endl;
}
}
FILE* get() const { return fp; }
};
// ==================== main ====================
int main()
{
std::cout << "========== C风格错误处理 ==========" << std::endl;
// C风格:检查返回值 + errno,并使用 strerror_s 安全获取错误信息
errno = 0;
c_style_divide(10, 0);
if (errno != 0) {
char errbuf[256];
if (strerror_s(errbuf, sizeof(errbuf), errno) == 0) {
std::cerr << "C style error: " << errbuf << std::endl;
} else {
std::cerr << "C style error: unknown" << std::endl;
}
}
c_style_divide(10, 2);
if (errno != 0) {
char errbuf[256];
if (strerror_s(errbuf, sizeof(errbuf), errno) == 0) {
std::cerr << "C style error: " << errbuf << std::endl;
}
}
std::cout << "\n========== C++ 异常处理 ==========" << std::endl;
BankAccount account("张三", 1000.0);
try {
account.deposit(500);
account.withdraw(200);
account.withdraw(2000); // 余额不足,抛出异常
} catch (const InsufficientFundsException& e) {
std::cerr << "Exception caught: " << e.what() << std::endl;
} catch (const NegativeAmountException& e) {
std::cerr << "Exception caught: " << e.what() << std::endl;
} catch (const std::exception& e) {
std::cerr << "Unknown exception: " << e.what() << std::endl;
}
std::cout << "Final balance: " << account.getBalance() << std::endl;
std::cout << "\n========== RAII 资源管理 ==========" << std::endl;
try {
// 文件在栈上分配,异常发生时自动析构关闭
FileGuard fg("test.txt", "w");
fprintf(fg.get(), "Hello, World!\n");
// 即使这里抛出异常,fg 的析构函数也会被调用
throw std::runtime_error("Something went wrong");
} catch (const std::exception& e) {
std::cerr << "Caught: " << e.what() << std::endl;
}
// 注意:如果 FileGuard 构造函数中 fopen_s 失败,它会直接抛异常,
// 不会产生未关闭的文件句柄
std::cout << "\n========== 多个 catch 块 ==========" << std::endl;
try {
account.withdraw(-100); // 负数金额
} catch (const NegativeAmountException& e) {
std::cerr << "Negative amount: " << e.what() << std::endl;
} catch (const InsufficientFundsException& e) {
std::cerr << "Insufficient: " << e.what() << std::endl;
} catch (...) {
std::cerr << "Unknown exception caught" << std::endl;
}
return 0;
}
运行结果:
========== C风格错误处理 ==========
C style error: Invalid argument
Result: 5
========== C++ 异常处理 ==========
Deposited 500, new balance: 1500
Withdrew 200, new balance: 1300
Exception caught: Insufficient balance
Final balance: 1300
========== RAII 资源管理 ==========
File closed automatically
Caught: Something went wrong
========== 多个 catch 块 ==========
Negative amount: Amount cannot be negative
这个示例展示了:
- C 风格 :通过
errno和strerror_s()安全地传递错误信息 - 自定义异常类 :继承
std::exception,使用std::string存储消息,避免手工内存操作 - try-catch-throw:基本异常处理流程
- 多个 catch 块 :按类型匹配,
catch(...)兜底 - RAII :
FileGuard类在析构时自动释放资源,即使发生异常也能保证资源不泄漏,且使用fopen_s保证文件操作安全
总结
- C 语言的错误处理靠返回值和
errno,简单直接但没有强制约束 assert适合调试阶段捕获逻辑错误setjmp/longjmp能在 C 中模拟跨函数跳转,但可读性差,慎用- C++ 的异常机制让错误处理代码和业务逻辑分离,但需要理解栈展开和异常安全
- RAII 是 C++ 资源管理的核心,配合异常机制使用效果最好
- 自定义异常类应该继承
std::exception,提供有意义的错误信息 - 在 Windows 环境下优先使用
_s安全函数,或定义_CRT_SECURE_NO_WARNINGS来兼容旧代码
没有哪种方式是万能的。根据项目规模、性能要求和团队习惯,选择合适的错误处理策略才是正道。