65.9K
CodeProject 正在变化。 阅读更多。
Home

更好的 C++ 异常处理

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (35投票s)

2010年3月29日

CPOL

22分钟阅读

viewsIcon

104180

downloadIcon

1374

自定义异常处理。快速、全面、强大

注意:当前实现是第二代版本。以下是与之前(第一代)实现的变化摘要:
  • 嵌套异常处理策略已更改。
  • 当从核心异常处理代码内部引发嵌套异常时,会触发受控终止。这表示不可恢复的损坏(例如堆栈内存被覆盖)。
  • 挤压出更多性能。
  • 支持隐式 SEH 异常的 Safeseh(有关 Safeseh 的更多信息,请参阅文档)。

引言

本文为 C++ 提供了自定义的异常处理实现。其主要特点是:

  • 速度快得多。
    • 当没有调试器连接到进程时(正常情况),速度快 20 倍。
    • 当调试器连接时,速度快数千(约 3.2K)倍。也没有烦人的“首次机会异常...”调试消息。
  • 处理对象析构函数在堆栈展开期间抛出异常的情况(稍后详述)。
  • 允许在堆栈展开之前处理异常(C++ 和 SEH)。
  • 允许取消异常(SEH)。

此实现适用于 Msvc (MS Visual C++) 编译器。但稍作努力,它可以移植到其他操作系统和编译器。根据编译器/平台的不同,可能不需要优化性能。然而,优雅地处理从析构函数抛出的异常,并在异常实际被捕获和堆栈展开之前“在”异常处理期间执行操作的能力——是一个很棒的功能。不幸的是,原生 C++ 中缺少此功能。

我必须提及这篇文章:C++ 编译器如何实现异常处理。它深入描述了编译器如何实现异常处理,并且包含了一个自定义实现。实际上,一段时间前,它帮助我解决了设备驱动程序开发中的异常处理问题。然而,那篇文章中提供的实现非常笨重,使用了大量 STL,无法直接用于设备驱动程序代码。因此——我制作了一个简化的异常处理实现,它只支持我需要的部分。后来我完善了这个实现,使其保持轻量和极简,但又完整而强大。

本文还包含一些关于异常处理及其实现的理论背景,这对于理解此自定义实现是如何设计的以及其特殊之处至关重要。

异常处理

令人惊讶的是,异常处理是整个编程领域,尤其是 C++ 中最神秘的主题之一。了解它是如何工作的以及它的优点和局限性非常重要。

我建议阅读我在开头提到的文章。我还建议阅读以下文章:驱动程序、异常和 C++。它探讨了 C++ 异常处理与 SEH 之间的关系,以及为编译器设置异常处理模型(同步、异步等)的效果。

在继续之前,以下是必须明确的关键事项:

  • 存在不同的异常处理模型。它们都包含以下步骤:
    1. 中断正常程序流,并将控制权(带有一些参数)传递给异常处理机制。
    2. 搜索适当的 catch 块。更准确地说——与异常处理块协商,直到其中一个决定处理它。
    3. 堆栈展开过程,直到捕获块。在此阶段,每个异常处理块都可以执行其清理代码。
    4. 将控制权传递给 catch 块。回到程序。
  • Windows 操作系统有其自己的异常处理机制(与 C++ 无关):SEH - 结构化异常处理。在 Msvc 编译器上,它可以通过 __try/__except__try/__finally 块和 RaiseException API 函数访问。
  • C++ 异常也由 Msvc 编译器通过 SEH 实现。
    • throw 语句调用 _CxxThrowException CRT 函数,该函数使用 C++ 特定的异常代码和参数调用 RaiseException API 函数。
    • 对于每个带析构函数的自动(在堆栈上)对象,编译器会自动生成异常记录,类似于 __try/__finally 块。
    • 对于 try/catch 块,编译器会自动生成异常记录,类似于 __try/__except 块。然而,它隐藏了与异常处理机制的“协商”。相反,它根据 C++ 类型匹配自动决定是否处理异常。

为了更清楚,我们来看以下示例:

void SomeFunc()
{
	// ...
	SomeClass obj1; // object with d'tor

	// ...
	// Eventually decide to throw an exception
	throw 10;
	// ...
}

void AnotherFunc()
{
	// ...
	SomeClass obj2;
	
	try
	{
		SomeClass obj3;

		// ...
		SomeFunc();
	}
	catch (int n)
	{
		// Handle the exception
	}
}

在此示例中,编译器将在两个函数中生成所谓的帧处理程序。在 SomeFunc 中,其目的是,如果异常逃离函数,则调用 obj1 的析构函数。因此,在“协商”阶段它什么也不做(拒绝捕获异常)。在展开阶段,它调用 obj1 的析构函数。

AnotherFunc 生成的帧处理程序有更多事情要做。在协商期间,它会检查异常参数。如果它是一个 C++ 异常,并且 C++ 类型与捕获的类型(int)匹配,它就会处理异常。处理意味着几件事:

  1. 展开堆栈。在此过程中,SomeFunc 的帧处理程序被调用以销毁 obj1
  2. 所谓的“局部”展开。这意味着——位于相应 try 块内的对象应该被销毁。在我们的例子中,obj3 被销毁。AnotherFunc 中的其他对象保持活动状态。
  3. 将抛出的值分配给 catch 语句的相应变量。
  4. 调用 catch 块。
  5. catch 块返回后,将控制权传递到其之后。

如果异常未被捕获,它将传播,直到另一个块决定捕获它。然后它将调用 AnotherFunc 的帧处理程序来销毁其当前活动的对象。在我们的例子中,这些是 obj3obj2

现在让我们详细看看异常处理机制的工作原理:

  1. 对于 throw 语句,编译器会生成对 _CxxThrowException CRT 函数的调用。它接收两个参数:指向被抛出对象的指针和指向完整描述其类型信息的数据结构的指针。
  2. _CxxThrowException 调用 RaiseException API 函数,其异常代码和参数使得适当的 catch 块将其识别为 C++ 异常及其抛出类型和值。
  3. RaiseException 函数执行内核模式事务(一种中断)。这就是操作系统收到异常通知的方式。
  4. 如果进程附加了调试器,它会收到异常通知。
  5. 操作系统检查此异常是否需要其干预。某些异常(例如页面错误)由操作系统内部处理,应用程序继续执行,就像什么也没发生一样。也就是说,第一个异常处理程序实际上是操作系统。
  6. 显然,操作系统与 C++ 异常无关。因此,它只是将控制权传递给用户模式异常处理机制。
  7. 用户模式机制分析已注册异常注册记录的链。它会调用每个此类记录的帧处理程序,直到其中一个决定处理异常。这就是协商阶段。
  8. 决定处理异常的处理程序执行我们已经讨论过的操作:堆栈展开、其局部展开、调用 catch 块(进行一些准备),最后将控制权传递到 catch 块之后。

定制化

如我们所见,异常处理涉及几个组件。它们都可以被定制(替换)。重要的是它们都是独立的,并且定制实现与标准实现兼容(稍后详述)。也就是说,您可以只定制您想要的组件,以达到所需的效果。现在让我们讨论它们以及它们是如何实现的。

替换 RaiseException

RaiseException 函数非常繁重。因为它涉及内核模式事务(进入操作系统内部)。调用内核模式函数的成本是数千个 CPU 周期,即使该函数什么也不做。作为比较:在现代 CPU 上调用常规函数只需几个周期。当进程附加了调试器时,情况会变得更糟:操作系统立即挂起我们的进程并通知调试器我们收到了异常。调试器响应,发出烦人的“首次机会”废话,然后释放我们的进程。性能下降是如此灾难性,以至于在许多情况下根本无法调试程序。

异常处理经常因其繁重和缓慢而受到批评。但这实际上是实现问题。让我们再想想:异常处理的内核模式事务是否有真正的原因?有两个官方原因:

  • 让操作系统(和调试器)意识到异常。
  • 但我个人认为这弊大于利。我不明白为什么调试器应该知道程序中的每个异常。在很多情况下,异常是绝对正常的事情,不需要任何关注。
  • 给操作系统处理它的机会。
  • 这甚至更荒谬。操作系统与软件应用程序级异常无关。特别是,它显然与 C++ 异常无关。

因此,我决定实现 RaiseException 的用户模式变体。它是这样声明的:

namespace Exc
{
	void RaiseExc(EXCEPTION_RECORD&, CONTEXT* = NULL) throw (...);
} // namespace Exc

它应该用于引发软件 SEH 异常。

优点

  • 对于显式引发的 SEH 异常,性能更好。

替换 _CxxThrowException

下一个合乎逻辑的步骤是为 C++ 异常使用 Exc::RaiseExc。正如我们已经看到的,C++ 异常是通过在 CRT 中实现的 _CxxThrowException 函数抛出的。现在我们将使其通过我们的函数抛出异常。这通过以下方式启用/禁用:

namespace Exc
{
	void SetThrowFunction(bool bSet);
} // namespace Exc

此函数在运行时启用/禁用我们对 _CxxThrowException 的替换。这是通过用跳转指令覆盖函数体的前一部分到我们的函数来实现的。理论上,可以将程序静态链接到我们的函数而不是标准的 _CxxThrowException,但这种方法需要大量的链接器调整。此外,当在 EXE/DLL 边界之间使用时,这可能会造成混乱(稍后详述)。

优点

  • C++ 异常的性能大大提高。

替换帧处理程序

帧处理程序是一个函数,对于每个需要异常处理的函数都会被调用。它在 CRT 中实现,被称为 _CxxFrameHandler3。除其他参数外,它还接收所谓的帧信息,其中包含有关其 try/catch 块和带析构函数的对象的所有信息。

C++ 的一个已知问题是在异常处理期间从析构函数内部抛出异常。但实际上还有更多问题。如果你抛出 C++ 对象(而不是简单的普通类型),异常处理机制会通过其拷贝构造函数创建此对象的副本,并且在过程结束时,此临时对象的析构函数也会被调用。此外,如果你在 catch 语句中按值而不是按引用捕获此类对象,则会调用另一个拷贝构造函数。所有这些步骤(包括展开期间的析构函数)必须无错误。如果上述任何一个抛出异常,CRT 实现的帧处理程序会立即调用 terminate 函数,游戏就结束了。

值得注意的是,这个问题是 C++ 特有的。在 SEH 中,如果在异常处理期间引发另一个异常,则完全没有问题。在这种情况下,新异常会由相同的机制递归处理。此外,在 SEH 中,每个异常处理程序都会看到所有已引发但尚未处理的异常链。这称为嵌套异常。

然而,在 C++ 中,这似乎是一个设计问题。我个人认为,这源于 C++ 中的异常处理过于隐蔽。你只是抛出异常,然后……糟糕!你已经捕获它了!是的,你的对象的析构函数已经被调用了。但是等等,如果析构函数被调用了——这意味着我的程序实际上正在工作。而且,就像任何代码一样,它也有一些合理的失败机会。

嗯,有人认为析构函数绝不能失败。这很有道理:析构函数通常用于执行各种清理工作。如果你有清理问题,那么这个问题实际上是不可恢复的。也就是说,如果你无法打开文件,例如,这是合法的,但如果你无法关闭有效的句柄,API 中存在更深层次的问题。但另一方面,析构函数并非总是只用于清理。这里我们显然有问题。此外,正如我们已经说过,在异常处理期间,拷贝构造函数也存在问题,它们有合法的失败机会。

尽管如此,我创建了另一个帧处理程序实现来处理这种情况。如果在异常处理期间(在析构函数或拷贝构造函数中)引发异常,异常处理机制会递归激活。在此阶段,新异常“加入”当前正在处理的异常列表,然后我们根据所有异常进行处理,直到它们全部处理完毕。

现在,在这里处理异常的顺序有一些自由度。以前(第一代)版本以 LIFO(后进先出)顺序处理异常。简单地说——递归地。这种方法的缺点是它可能允许异常逃逸它们的捕获块,而这些捕获块前面有那些必须在外层捕获块中处理的异常。让我们看下面的例子:

try {
	try {
	
		struct X {
			~X() { throw A(); }
		} x;
	
		throw B();
	catch (B&) {
	}
} catch (A&) {
}

这里 Bcatch 块先于 A 的。在运行时抛出 B。但在它被捕获之前,A 也被抛出。在这种情况下,第一代实现会首先处理 A,这将堆栈展开到 Acatch 块。然后我们返回处理 B,但它现在已经无法阻止了。

问题是在这种情况下正确的行为应该是什么。是否可以“交换”异常的顺序以防止它们逃逸?我花了一些时间思考这个问题,我的结论是这确实是正确的做法。你可以将异常视为代码某个部分的中止方式,该部分正好在适当的 catch 块处结束。如果你同时有几个错误条件,每个错误条件都要求中止到某个点,那么显然你不应该中止超出它们中最外层的那个。

最后,我决定重写嵌套异常处理行为,这导致了第二代异常处理机制。当发生嵌套异常时,它们的处理方式是,每个 catch 块都尝试捕获任何活动异常。此外,如果同一个 catch 块可以捕获多个异常,它就会这样做,并且其中的代码将被多次调用。总之,关于嵌套异常的策略如下:

  • 每个 catch 块都可以捕获任何当前活动的异常。
  • 同一个 catch 块可以捕获多个异常。
  • catch 块可能会被调用多次。
  • catch 块被调用后,不保证控制权会立即传递到其之后。
  • 所有析构函数总是被调用一次。
  • 异常处理继续进行,直到所有异常都已处理。最后,控制权传递到最后一个(最外层)catch 块之后。
  • 如果异常无法处理,则调用 UnhandledExceptionFilter

通常你可以假设你的每个 catch 块被调用不超过一次。你也可以假设在 catch 块执行后,你肯定会获得其后的控制权。但是如果发生嵌套异常,这个规则可能会被打破。因此,我的自定义帧处理程序为你提供了优雅处理嵌套异常的机会,但确保如此是你的责任。

要替换标准的 CRT 帧处理程序,应调用以下函数:

namespace Exc
{
	void SetFrameHandler(bool bSet);
} // namespace Exc

此函数在运行时启用/禁用我们对 _CxxFrameHandler3 的替换。这是通过我们已经使用过的方法实现的:用 jmp 指令覆盖函数体的前一部分到我们的函数中。

优点

  • 处理异常处理期间发生异常的情况。
  • 比标准实现显著更快。

异常监控

SEH 中可用的一个功能,但在我们的实现中仍然缺失的是在协商阶段对异常进行显式处理并可选地取消它的能力。注意:你实际上有机会在展开开始之前执行操作。因此,整个堆栈仍然有效,并且它包含有价值的上下文信息。这对于调试、错误/崩溃处理甚至日志记录都极其宝贵。有关此内容的更多信息,我建议阅读这篇文章:卓越的日志设计。快速但全面

此功能可以通过纯 SEH 轻松实现。您可以使用 __try/__except 块包装某些代码块,您将有机会在 __except 语句中分析异常(并可能执行一些额外处理)。但是这里出现了另一个问题:事实证明,您不能在任何具有自动生成的异常处理记录的函数中使用 SEH 块。这意味着——任何具有 try/catch 块或带析构函数的对象的函数。克服这个问题的一种方法是将您的函数分成几个,这样我们就不会在一个函数中混合 SEH 和 C++ 异常处理。然而,这非常烦人。为了简化生活,我决定提供一个 C++ 包装的可能性来实现这一点。让我们看看下面的类声明:

namespace Exc
{
	class Monitor  {
		// ...
	public:

		Monitor();

		virtual bool Handle(EXCEPTION_RECORD* pExc, CONTEXT* pCpuCtx) { return false; }
		virtual void AbnormalExit() {}
	};
} // namespace Exc

在继承类中重写 Handle 函数,你将在协商期间被调用。如果你返回 true,则表示你已处理异常,并且执行应从异常引发的点继续(相当于 SEH 中的 EXCEPTION_CONTINUE_EXECUTION)。你也可以重写 AbnormalExit 以在异常导致作用域退出时收到通知。(相比之下,常规析构函数在正常和异常作用域退出时都会被调用)。但是,你必须遵守使用此技术的以下规则:

  • 始终只在堆栈上创建此对象(声明为自动变量)。绝不能动态创建此对象(通过 new 运算符或类似方式)。也不要对其生命周期进行任何复杂的调整。如果你违反此规则,异常注册记录链可能会损坏,程序将崩溃。
  • 不要尝试取消带有 EXCEPTION_NONCONTINUABLE 标志的异常。
  • 特别是 C++ 异常总是不可继续的,你不应该尝试使它们可继续。这是因为编译器假定执行永远不会在 throw 语句之后继续,并且它可能不会在其之后生成有效的代码。

实际上,我有一个关于 C++ 方式的异常监控的另一个想法——不直接使用 __try/__except。我考虑过在堆栈展开之前调用 catch 块中的代码,针对某些特定的抛出类型。我声明了一个抽象类,这种类型(以及任何从它派生的 C++ 类)的每个异常实际上都在堆栈展开之前被捕获。这样你就可以使用这些类型的异常,以便在 catch 块中做更多的事情。例如:

// Declare the exception type that is processed before the stack unwinding
struct MyExc :public Exc::ProcessInline {
	// some parameters
};

try
{
	// ...
	MyExc exc;
	throw exc;
	
}
catch (MyExc& exc)
{
	// The stack is still alive here!
}

不幸的是,这个变态的技巧由于与我无关的原因失败了。它在调试模式下运行良好,但在发布版本中崩溃了。原来编译器假定在 catch 块内,try 块中实例化所有对象都已经死亡,它们的内存可能会被重用。因此,对于在 catch 块中声明的对象,它生成了覆盖 try 块中声明对象内存的代码。我甚至还有更狂热的想法,即在调用适当的 catch 之前创建适当的 try 块的堆栈内存副本,但那样如果 catch 块重新抛出异常,就会出现问题,等等。最后,我怀着沉重的心情放弃了。然后我创建了 Monitor 类来解决这个问题。

约束

首先要担心的是在 EXE/DLL 边界之间使用我们的异常处理。如果您使用 CRT 的 DLL 版本(在所有 DLL 和 EXE 中),并且我们的异常处理的全局初始化只存在于 EXE 中,那么所有其他 DLL 与定制的异常处理没有直接连接,就不会有问题。在这种情况下,我们替换 CRT 版本的异常处理,所有模块都会自动使用它。但是,如果某些模块使用静态链接的 CRT 库,它们的异常处理将保持不变。

另一个问题与我们的实现使用 TLS(每线程变量)来保存当前正在处理的异常的一些信息有关。这对于正确实现“重新抛出”(throw;语句)和嵌套异常处理是必要的。问题是 CRT 实现也有这种每线程异常处理信息(尽管不支持嵌套异常),它与我们的无关。此外,当 CRT 作为 DLL 使用时,此信息对于 EXE 是不可访问的(访问它的内部 CRT 函数未导出)。当我们处理异常时,CRT 不知道,反之亦然。这有两层含义。

其中一个含义是 std::uncaught_exception() 将看不到我们处理的异常。为了克服这个问题,我添加了以下函数:

namespace Exc
{
	// Replaces std::uncaught_exception
	void SetUncaughtExc(bool bSet);

	// Returns the currently processed exception
	// In contrast to std::uncaught_exception this function not only tells if
	// there's currently exception being processed, but also what it is
	EXCEPTION_RECORD* GetCurrentExc();
	
} // namespace Exc

还有另一个小问题可能在非常特殊的情况下出现。如果我们的机制引发了 C++ 异常,在此期间又发生了隐式 SEH 异常(例如访问冲突),则新异常不会立即“链接”到前一个异常,因此一开始它不包含嵌套异常信息。在这种情况下,当我们的帧处理程序第一次被调用时,它会发现新异常尚未链接,并立即将其链接,这样后续的所有处理程序就都没有问题了。但是,如果第一个异常处理程序恰好是 SEH 异常处理程序(带有 __try/__except 块),它一开始将看不到嵌套异常。也就是说:

void Func()
{
	__try {
		__try {
			// Throw an exception via our mechanism: either a C++ exception or
			// SEH raised explicitly by Exc::RaiseExc
			throw 5; // C++ exception
			
		} __finally {
			*((int*) NULL)++; // access violation, leads to an implicit nested exception
		}
	} __except (AnalyzeExc(GetExceptionInformation()), EXCEPTION_EXECUTE_HANDLER) {
	}
}

void AnalyzeExc(EXCEPTION_POINTERS* pExc)
{
	// Here we'll see only the access violation exception.
	// No nested exception information.
}

嗯,我个人认为这个问题非常特殊。无论如何,嵌套异常常常被不幸地忽略,我们甚至将其考虑在内都是不寻常的事情。为了解决这个问题,要么至少在你通过我们的机制引发异常的地方不要直接使用 SEH 异常块(整个想法是摆脱直接使用 SEH),要么在分析异常时,你也可以调用 Exc::GetCurrentExc 来查看当前是否有更多异常正在处理。理论上,可以替换支持 SEH 异常块的 CRT 代码(_except_handler4),但这对于如此不寻常的事情来说,我个人认为付出的努力太多了。

另一个问题是我的异常处理机制目前不支持所谓的向量化异常处理。它是 Windows XP 中引入的 SEH 扩展。除了 SEH,它还允许注册全局独立于作用域的处理程序,这些处理程序会自动查看每个异常。我没有实现对它的支持的原因是我尚未发现如何检索所有已注册向量处理程序的表(没有官方 API 可以做到这一点)。此外,这是非常具体的东西,我怀疑它是否除了某些非常特殊的情况外实际使用。所以我觉得这没有问题。但是,如果你坚持保留向量化异常处理程序,你应该使用原生的 RaiseException 函数而不是我们快速的 Exc::RaiseExc。如果你想让它们也适用于 C++ 异常(尽管我看不出有什么理由这样做),就不要替换 _CxxThrowException

结论

C++ 经常受到批评,但我相信它毕竟是一种很棒的编程语言。如果你问我 C 和 C++ 之间的主要区别是什么,我会说以下几点:

  1. 表面上的差异(成员函数、继承、重载、运算符、模板、引用等)。
  2. 内置多态支持:虚函数和一些额外特性(RTTI、虚继承)。
  3. 析构函数。
  4. 异常。

第一类包含许多使 C++ 编写代码更容易的东西,但它们在机器层面没有带来任何新东西。它们只是允许你用更短、更干净的代码实现你在 C 中会达到的相同效果。第二类使编译器自动生成代码以支持多态性,但 C 程序员也可以使用多态性:在过去的好时光里,C 程序员手动构建函数指针表以在运行时实现所需的效果。剩下的两类:析构函数和异常。我个人认为这些是 C++ 相对于 C 的最大特性。而且,正如我们所见,它们实际上并不是两个独立的特性,它们之间有着非常紧密的联系。

一些人批评 C++ 不做的事情,比如没有反射,缺乏内置复杂类型等等。但我认为这种批评是不公正的。C++ 从 C 发展而来,而 C 是最简约的独立于汇编语言的编程语言。C++ 之所以伟大,是因为它比 C 提供了更多功能,同时在正确使用时,没有牺牲纯 C 的简约性和效率。另一方面,我批评 C++ 的一些事情是它做错了。不幸的是,异常处理就是一个完美的例子。我个人认为它的设计不好,即使它在某些情况下将抛出的异常定义未定义行为,程序应该立即终止——这是一种耻辱。此外,它在 Msvc 的 CRT 中实现得非常野蛮。

我们的自定义异常处理修复了这些缺陷。现在我们在所有可能的情况下都能很好地处理所有异常。此外,我们解决了荒谬的性能问题。异常处理不再那么繁重。根据我在最简单的异常处理场景中的实验,如下所示:

try {
	throw 1;
} catch (int) {
}

这里的异常处理复杂性与多次调用 strlen("Hello, World") 相当。相比之下,它比堆操作(mallocfree)轻得多。

仍然缺少的一点是语言集成的能力,用于在协商阶段分析异常。我尝试在堆栈展开之前调用 catch 块失败了,所以剩下的唯一方法是使用 Exc::Monitor 辅助类,或者直接使用纯 SEH。在标准 C++ 的限制下,似乎没有更好的解决方案。

异常处理的实现大约有 900 行代码,这并不算多。其中有一些非平凡的事情。它们可以分为以下几类:

  • 需要处理 SEH 和内置 C++ 类型,例如被抛出对象的类型信息和 catch 块、帧信息等等。
  • 一些用汇编语言编写的代码。
  • 非平凡的程序流。有些函数不进行条件返回,而是将控制权传递给其他代码块。
  • 这还包括在异常处理期间可能会发生新异常,并且可能会涉及基于 CRT 的异常处理程序。实际上,为了在所有这些情况下保证正确的行为,在异常处理期间内部使用了额外的异常处理程序。

尽管如此,我个人认为代码还算可读,并且稍加努力就可以进一步定制。

我将不胜感激您的评论。欢迎新想法和批评。

© . All rights reserved.