Drivers, Exceptions and C++
在驱动程序中使用带析构函数和异常的 C++ 对象
引言
C++ officially不支持驱动程序开发。所有教程和示例都用纯 C 编写。但 C++ 实际上也可以使用,有一些合理的限制。这并不奇怪,因为 C++ 是基于 C 的,可以看作是“高级 C”。只要你不做 C++ 特有的事情,就没有问题。
有许多文章回顾了 C++ 在驱动程序中的使用。C++ 中“最‘有问题’”的特性之一是异常处理。最近我尝试在一个驱动程序中使用它,遇到了一些问题,这并不意外。真正让我感到惊讶的是当我开始阅读关于这个问题的文章时。有很多文章,但我找不到任何关于我遇到的问题的文章。
最后我找到了解决方案,该解决方案基于这篇文章。这是一篇关于异常处理的优秀参考,其中包含自定义的异常处理实现。但我认为我的文章也有必要,原因如下:
- 引用的文章中的异常处理实现相当臃肿,使用了大量的 STL,并且不能直接用于驱动程序。
- 关于异常存在太多歧义,我想对所有内容进行分类,以便识别问题和解决方案。
- 我相信许多其他人也遭受着这个问题,只是他们还没有意识到。
为了把事情说清楚,让我们先就异常处理到底是什么达成一致。
异常和异常处理
有不同的异常处理模型和机制。它们通常都提供以下能力:
- 抛出异常。正常的程序流被中断,有一个所谓的栈展开过程,实际上是从所有作用域快速返回,直到某个匹配的位置,我们称之为
catch
块。 - 通过异常传递一些参数,这些参数可以选择合适的
catch
块及其行为。 - 在栈展开过程中的每个作用域清理代码执行。
C++ 语言定义了支持所有这些特性的异常处理机制。
throw
语句。thrown
类型定义了合适的catch
块,并且抛出的值也会传递给它进行检查。- 清理通过自动(栈上)对象的析构函数来支持。
Windows 操作系统还提供了自己的异常处理机制,即 SEH(结构化异常处理)。
RaiseException
() 函数。__except
语句可以根据异常的代码和参数决定是否处理该异常。__finally
块总是会被执行。
这两种模型理论上都提供了我们同意称之为异常处理的内容。然而,SEH 在以下方面更强大:
__except
语句可以通过(返回EXCEPTION_CONTINUE_EXECUTION
)来取消异常。- 硬件和操作系统隐式抛出的异常也通过 SEH 来处理,因此拥有统一的错误处理。
- 可以在栈展开之前在
__except
语句中分析异常,因此整个调用栈(包括错误条件)都是有效的。这非常有价值,尤其是在调试时。
SEH 如何工作
我不会深入细节,有很多文章对此进行了描述。总的来说,存在一个特殊数据结构(EXCEPTION_RECORD
)链。每当你使用__try
/__except
或__try
/__finally
块时——编译器会自动生成这样一个结构并将其添加到链中,然后当程序离开该作用域时,该异常记录将从链中移除。当异常被抛出(无论是通过显式调用RaiseException
还是隐式由硬件抛出)时,控制权会传递给操作系统。它会检查异常记录链并调用适当的回调函数。这些函数决定是否处理异常(__except
语句),并在栈展开期间执行清理(__finally
块)。
C++ 异常与 SEH
首先,这里没有对决。Microsoft C++ 编译器通过 SEH 实现异常。
对于任何具有自动对象(带析构函数)或try
/catch
块的函数,编译器都会自动生成相应的异常记录,如果抛出异常,该记录将被激活。
但是,如果你查看 Microsoft 编译器选项,你会发现以下异常处理选项:
- 否,/EH-
- 是,/EHs(又名同步)
- 是,带 SEH,/EHa(又名异步)
“是”选项也称为“同步异常处理模型”,“是,带 SEH”则称为“异步异常处理模型”。
这可能看起来令人困惑,尤其是在我们刚才讨论了没有 SEH 的异常处理之后。
此外,你还可以选择“否”选项,实际上使用try
/catch
、__try
/__except
和__try
/__finally
块,使用throw
语句并调用RaiseException
函数。所有这些都会起作用!然而,编译器可能会生成以下警告:
warning C4530: C++ exception handler used, but unwind semantics are not enabled.
Specify /EHsc
但一切都会奏效!几乎一切都会奏效……
那么,这些选项到底是什么意思?为了回答这个问题,让我们回顾一下编译器必须支持的内容:
- 抛出异常
- 异常捕获
- 展开期间的清理
现在让我们看看在各种异常处理设置下编译器是如何实现这些的。
- 让我们从异常抛出开始。无论异常处理选项如何,它总是以相同的方式工作。如果你使用
throw
语句——编译器会将其转换为以下内容:
// The following line: throw obj; // is interpreted by the compiler in the following manner: _s__ThrowInfo exc_info; // this structure describes the type of the thrown object // compiler fills this structure with the thrown type info, so that the appropriate // catch block can recognize the thrown type. // .. _CxxThrowException(&obj, &exc_info);
这就是
_CxxThrowException
函数的实现方式:void _CxxThrowException(void* pObj, _s__ThrowInfo* type_info) { ULONG args[3]; args[0] = 0x19930520; args[1] = (ULONG) pObj; args[2] = (ULONG) type_info; RaiseException(0xe06d7363, EXCEPTION_NONCONTINUABLE, 3, args); }
所以,C++ 异常实际上是一个带有 C++ 特定代码和参数的常规 SEH 异常。
- 异常捕获:这由编译器通过 SEH 异常记录来实现。与前一种情况一样,无论异常处理设置如何,这始终会得到实现,但是,在以下场景中,不同选项之间会有一点区别:
如果你使用catch all
块
try { // ... do something } catch (...) { // catch all exceptions regardless to their type. }
如果你选择使用“是,带 SEH”,那么这样的
catch
块会捕获所有异常。
否则,如果你选择“否”或“是”选项——它将不会捕获非 C++ 异常。我所说的非 C++ 是指异常代码不是 0xe06d7363 的 SEH 异常。 - 展开期间的清理:这里,异常处理选项之间存在很大差异。“否”选项意味着编译器不应为自动对象生成异常记录。你可以随意使用异常处理,但要注意你的析构函数就会失效。即:
struct SomeClass { SomeClass() { TRACE(_T("SomeClass - c'tor\n")); } ~SomeClass() { TRACE(_T("SomeClass - d'tor\n")); } }; void RaiseExc() { throw 1; } void SomeFunc() { try { SomeClass obj; RaiseExc(); } catch (...) { } }
尝试使用“否”选项运行此示例,你会发现
obj
的 d'tor(析构函数)未被调用。如果将try
/catch
块替换为__try
/__except
,效果相同。void SomeFunc() { __try { SomeClass obj; RaiseExc(); } __except(EXCEPTION_EXECUTE_HANDLER) { } }
但是,如果你使用“是”或“是,带 SEH”选项编译,析构函数会被调用。
那么,“是”和“是,带 SEH”选项之间有什么区别?我们已经知道的一个区别是
catch
(...) 会忽略非 C++ 异常,但还有另一个区别。将异常处理设置为“是”,并更改
RaiseExc
函数:void RaiseExc() { ((char*) NULL)[0]++; // invalid memory access }
运行示例。如果你使用了
catch
(...)——正如预期的那样,异常不会被处理,应用程序将崩溃。现在尝试将try
/catch
替换为__try
/__except
。异常现在被捕获了,但是……析构函数未被调用!现在再次更改
RaiseExc
:void RaiseExc() { if (GetTickCount() == 50) throw 1; ((char*) NULL)[0]++; // invalid memory access }
现在析构函数被调用了!尽管抛出了完全相同的异常(假设
GetTickCount
() 不等于 50),但行为现在不同了。在“是,带 SEH”下,两种情况下的析构函数都会被调用。
这就是这两个选项的主要区别。
在这两个选项下,编译器都应该能感知异常并确保析构函数在栈展开期间被调用。区别在于:
- 在同步异常处理模型(“是”选项)下,编译器可以检查程序流程,如果它看不到异常发生的可能性——它将省略异常记录块。
- 异步异常处理(“是,带 SEH”选项)意味着编译器不允许假设异常可能发生在何处,并且它必须为每个具有析构函数all 的对象放置异常记录块。
这解释了为什么在“是”选项下更改
RaiseExc
后,编译器生成了不同的代码。尽管实际上没有发生throw
,但编译器假设它可能会发生,并为我们的obj
生成了异常记录。一旦生成——析构函数就会被调用,不仅仅是针对 C++ 异常。
我遇到的问题
我需要编写一个驱动程序,并且想在其中使用异常处理。标准的 SEH,而不是 C++ 异常。如我所述,纯 SEH 更强大,尤其是在调试方面。理论上应该没有任何问题,因为 SEH 支持驱动程序。
但不知怎的,我注意到异常发生时析构函数实际上没有被调用。如果你阅读了上一节,你就会意识到问题在于编译器没有为析构函数生成异常记录,这可能是因为编译器工作在“否”或“是”异常模型下。我设置了“是,带 SEH”选项,但随后出现了以下链接器错误:
error LNK2019: unresolved external symbol ___CxxFrameHandler3
referenced in function __ehhandler$...
我尝试链接不同的库,但没有帮助。
接下来,我找到一个不知名英雄制作的库,名为libcpp
。但不幸的是,它也无济于事。它实现了一些 C++ 特定功能,如new
/delete
操作符、全局对象构造函数和析构函数的调用,以及“C++ 异常支持”。这个支持实际上只是通过RtlRaiseException
内核函数实现了RaiseException
API 函数。
我尝试了其他几个库,但没有一个解决了我的问题。它们支持 RTTI 和其他无用的垃圾,但这些对我都没有兴趣。
我需要 C++ 的主要原因是因为析构函数。恕我直言——这是 C++ 相对于纯 C 最有价值的优势。我懒得为我分配的每个资源编写__try
/__finally
块,我宁愿使用 RAII。我不需要 C++ 异常、catch
块以及其他所有 C++ 相关的东西,我只希望我的析构函数在异常时被调用。
我的猜测是,许多在驱动程序(以及其他地方)中使用 C++ 和异常的人也遇到了这个问题,只是他们没有意识到。哦,是的,所有 C++ 功能都正常工作,没有编译/链接问题。但实际上你什么都没有,除非你将异常处理设置为“是,带 SEH”。而这个选项不是默认选项。而且,如果你通过 SEH(如libcpp
所做的)实现 C++ 异常而不设置正确的异常处理模型,那么你就是在搬起石头砸自己的脚。
解决方案
阅读了参考文章(我在开头提到过)后,我才意识到神秘的__CxxFrameHandler3
是做什么用的。当编译器为具有析构函数的对象生成异常记录时,它会在其中放置一个对该函数的引用,该函数接收帧信息(包括要调用的析构函数列表)作为参数,并负责以下事项:
- 检查异常并决定此块是否捕获它。
- 在展开过程中调用析构函数。
我从参考文章中获取了该函数的实现,但它非常复杂,特别是由于(1)——它必须识别帧中的所有catch
块,分析 C++ 类型信息,将此异常参数(抛出的值)分配给catch
块(无论是按值还是按引用),等等。因为我不需要所有这些,所以我只提取了处理栈展开和调用析构函数的部分,这部分很小。
此外,我还做了一个修改。想象以下场景:某个地方抛出了一个异常。然后我们进行栈展开过程,在此过程中(希望)析构函数被调用。但是,如果某个析构函数也抛出了异常呢?
好吧,有人认为 C++ 对象不应该在构造函数和析构函数中抛出异常,但如果它们确实抛出了呢?我的意思是,那会发生什么?如果你谈论纯 SEH——没问题。如果__finally
块之一抛出了异常——它会被以相同的方式处理:操作系统会再次搜索捕获块,然后后续的__finally
块仍然会被调用。
但是 C++ 中发生了什么(更准确地说——应该发生什么)?我怀疑 C++ 中是否有简短的规则。尽管如此,我做了一些实验,并发现了奇怪的问题。考虑以下示例:
void Crash() { TRACE("Crashing!"); ((char*) NULL)[0]++; } // invalid memory access
struct StupObjA {
StupObjA() { TRACE("c'tor A"); }
~StupObjA() { TRACE("d'tor A"); Crash(); }
};
struct StupObjB {
StupObjB() { TRACE("c'tor B"); }
~StupObjB() { TRACE("d'tor B"); }
};
请注意,StupObjA
的析构函数抛出了异常。现在发生了什么?
try {
StupObjB objB;
StupObjA objA;
Crash();
} catch (...) {
}
两个析构函数都被调用了,两个异常都被捕获了。现在让我们按以下方式重写:
void TstFunc()
{
StupObjB objB;
StupObjA objA;
Crash();
}
try {
TstFunc();
} catch (...) {
}
objB
的析构函数未被调用!又一次重新设计。
void TstFuncInner()
{
StupObjA objA;
Crash();
}
void TstFunc()
{
StupObjB objB;
TstFuncInner();
}
try {
TstFunc();
} catch (...) {
}
objB
的析构函数又回来了!
我称之为不一致的行为。如前所述,我不知道根据 C++ 规则,是否必须调用objB
的析构函数,或者是否根本存在这样的规则。
尽管如此,我修改了__CxxFrameHandler3
函数,使其所有析构函数始终被调用,就像在标准的 SEH 中所有__finally
部分总是执行一样。
结论
我希望这篇文章有用,既用于驱动程序开发,也作为异常的理论背景。关于异常存在很多模糊之处。
如果你想正确地使用异常处理和析构函数,那么你必须这样做:
- 设置正确的异常处理模型,否则你的析构函数毫无价值。异步模型保证了析构函数始终被调用,但会付出额外的代码代价,并且可能禁用一些优化。你也可以使用同步模型,但是你必须确保编译器知道异常可能发生在何处。例如,调用
RaiseException
或访问可能无法访问的内存必须通过throw
(...) 等内容进行标记。 - 如果出现适当的链接器错误,请将
__CxxFrameHandler3
函数的实现添加到你的项目中。 - 谨慎使用
try
/catch
块。如果你使用我修改过的__CxxFrameHandler3
函数,你根本无法使用它们——它根本忽略它们。否则你可以使用它们,但是要考虑到在同步模型中catch
(...) 只捕获 C++ 异常。
一如既往,欢迎评论。无论正面还是负面。