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

Drivers, Exceptions and C++

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (16投票s)

2008 年 1 月 9 日

CPOL

11分钟阅读

viewsIcon

99942

downloadIcon

989

在驱动程序中使用带析构函数和异常的 C++ 对象

引言

C++ officially不支持驱动程序开发。所有教程和示例都用纯 C 编写。但 C++ 实际上也可以使用,有一些合理的限制。这并不奇怪,因为 C++ 是基于 C 的,可以看作是“高级 C”。只要你不做 C++ 特有的事情,就没有问题。

有许多文章回顾了 C++ 在驱动程序中的使用。C++ 中“最‘有问题’”的特性之一是异常处理。最近我尝试在一个驱动程序中使用它,遇到了一些问题,这并不意外。真正让我感到惊讶的是当我开始阅读关于这个问题的文章时。有很多文章,但我找不到任何关于我遇到的问题的文章。

最后我找到了解决方案,该解决方案基于这篇文章。这是一篇关于异常处理的优秀参考,其中包含自定义的异常处理实现。但我认为我的文章也有必要,原因如下:

  • 引用的文章中的异常处理实现相当臃肿,使用了大量的 STL,并且不能直接用于驱动程序。
  • 关于异常存在太多歧义,我想对所有内容进行分类,以便识别问题和解决方案。
  • 我相信许多其他人也遭受着这个问题,只是他们还没有意识到。

为了把事情说清楚,让我们先就异常处理到底是什么达成一致。

异常和异常处理

有不同的异常处理模型和机制。它们通常都提供以下能力:

  1. 抛出异常。正常的程序流被中断,有一个所谓的栈展开过程,实际上是从所有作用域快速返回,直到某个匹配的位置,我们称之为catch 块。
  2. 通过异常传递一些参数,这些参数可以选择合适的catch 块及其行为。
  3. 在栈展开过程中的每个作用域清理代码执行。

C++ 语言定义了支持所有这些特性的异常处理机制。

  1. throw 语句。
  2. thrown 类型定义了合适的catch 块,并且抛出的值也会传递给它进行检查。
  3. 清理通过自动(栈上)对象的析构函数来支持。

Windows 操作系统还提供了自己的异常处理机制,即 SEH(结构化异常处理)。

  1. RaiseException() 函数。
  2. __except 语句可以根据异常的代码和参数决定是否处理该异常。
  3. __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

但一切都会奏效!几乎一切都会奏效……

那么,这些选项到底是什么意思?为了回答这个问题,让我们回顾一下编译器必须支持的内容:

  1. 抛出异常
  2. 异常捕获
  3. 展开期间的清理

现在让我们看看在各种异常处理设置下编译器是如何实现这些的。

  1. 让我们从异常抛出开始。无论异常处理选项如何,它总是以相同的方式工作。如果你使用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 异常。

  2. 异常捕获:这由编译器通过 SEH 异常记录来实现。与前一种情况一样,无论异常处理设置如何,这始终会得到实现,但是,在以下场景中,不同选项之间会有一点区别:
    如果你使用catch all

    try {
        // ... do something
    } catch (...) {
        // catch all exceptions regardless to their type.
    }

    如果你选择使用“是,带 SEH”,那么这样的catch 块会捕获所有异常。
    否则,如果你选择“否”或“是”选项——它将不会捕获非 C++ 异常。我所说的非 C++ 是指异常代码不是 0xe06d7363 的 SEH 异常。

  3. 展开期间的清理:这里,异常处理选项之间存在很大差异。“否”选项意味着编译器不应为自动对象生成异常记录。你可以随意使用异常处理,但要注意你的析构函数就会失效。即:

    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. 检查异常并决定此块是否捕获它。
  2. 在展开过程中调用析构函数。

我从参考文章中获取了该函数的实现,但它非常复杂,特别是由于(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++ 异常。

一如既往,欢迎评论。无论正面还是负面。

© . All rights reserved.