现在的位置: 首页 > 综合 > 正文

WDM 驱动错误处理

2013年09月02日 ⁄ 综合 ⁄ 共 12024字 ⁄ 字号 评论关闭
文章目录

[返回] [上一页] [下一页]

错误处理


人总会犯错误,错误恢复是软件工程的一部分。程序中总会发生异常情况,其中一些源自程序中的Bug,或者在我们的代码中或者在调用我们代码的用户模式应用程序中。另一些涉及到系统装载或硬件的瞬间状态。无论什么原因,代码必须能对不寻常的情况作出恰当的反应。在这一节中,我将描述三种错误处理形式:状态代码、结构化异常处理,和bug check。一般,内核模式支持例程通过返回状态代码来报告意外错误。对于正常情况,它们将返回布尔值或者数值而不是正式的状态代码。结构化异常处理为异常事件发生后的清除工作提供了一个标准化方法,它可以避免因为异常事件而导致系统崩溃,异常事件是指诸如被零除或参考无效指针等的意外错误。Bug check实际上就是致命错误的内部名称,对于这种错误,唯一的解决办法就是重启动系统。

状态代码

内核模式支持例程(以及你的代码)通过向其调用者返回一个状态代码来表明调用是否成功。NTSTATUS是一个由多个子域组成的32位整数,如图3-2。高两位(Severity)指出状态的严重性——成功、信息、警告、错误。客户位(Customer)是一个标志,完成的IRP将携带一个表明完成状态的状态代码,如果这个状态代码中的Customer标志被设置,那么这个状态代码将被不修改地传回应用程序(应用程序通过调用GetLastError函数获得)。通常,状态代码在返给应用程序前要翻译成Win32错误代码(Win32错误代码可以在KBase Q113996文章中查到)。facility代码指出该状态是由哪个系统部件导致的,一般用于减少开发组之间的代码关联。剩下的16位代码指出实际的状态。

图3-2. NTSTATUS代码的格式

我们应该总是检测例程的返回状态。为了不让大量的错误处理代码干扰例子代码所表达的实际意图,我经常省略代码片段中错误检测部分,但你在实际练习中不要效仿我。

如果状态码高位为0,那么不管其它位是否设置,该状态代码仍旧代表成功。所以,绝对不要用状态代码与0比较来判断操作是否成功,应该使用NT_SUCCESS宏:

NTSTATUS status = SomeFunction(...);
if(!NT_SUCCESS(status))
{
  <handle error>
}

不仅要检测调用例程的返回状态,还要向调用你的例程返回状态代码。在上一章中,我讲述了两个驱动程序例程,DriverEntryAddDevice,它们都定义了NTSTATUS返回代码。所以,如果这些例程成功,则应返回STATUS_SUCCESS。如果在某个地方出错,应返回一个适当的错误状态代码,有时函数返回的状态代码就是出错函数返给你的状态代码。

例如,下面是AddDevice函数的一些初始化代码,包括完整的错误检测代码:

NTSTATUS AddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT pdo)
{
  NTSTATUS status;
  PDEVICE_OBJECT fdo;
  status = IoCreateDevice(DriverObject,
                          sizeof(DEVICE_EXTENSION),
                          NULL,
                          FILE_DEVICE_UNKNOWN,
                          0,
                          FALSE,
                          &fdo);
  if (!NT_SUCCESS(status))										<--1
  {
    KdPrint(("IoCreateDevice failed - %X/n", status));							<--2
    return status;
  }
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  pdx->DeviceObject = fdo;
  pdx->Pdo = pdo;
  pdx->state = STOPPED;
  IoInitializeRemoveLock(&pdx->RemoveLock, 0, 0, 255);							<--3
  status = IoRegisterDeviceInterface(pdo, &GUID_SIMPLE, NULL, &pdx->ifname);
  if (!NT_SUCCESS(status))										<--4
  {
    KdPrint(("IoRegisterDeviceInterface failed - %X/n", status));
    IoDeleteDevice(fdo);
    return status;
  }
    ...
}
  1. 如果IoCreateDevice失败,我们就把这个状态代码返回给上层调用者。注意代码中NT_SUCCESS宏的使用。
  2. 打印出任何错误状态信息是一个好的习惯,尤其是在调试驱动程序时。我将在本章后面讨论如何使用KdPrint
  3. IoInitializeRemoveLock是一个VOID函数,这意味着它不会失败。所以没有必要检测它的状态代码,该函数在第六章讨论。
  4. 如果IoRegisterDeviceInterface失败,我们就需要在返回前做些清除工作;即我们必须删除刚创建的设备对象。

并不是所有被调用例程导致的错误都要处理,有些错误是可以忽略的。例如,在第八章电源管理中,我将提到带有IRP_MN_POWER_SEQUENCE子类型的电源管理请求,使用它可以避免上电过程中不必要的状态恢复过程。这个请求不仅对你是可选的,而且总线驱动程序在实现该请求上也是可选的。所以如果该请求执行失败,你不用做任何处理,继续其它工作。同样,你也可以忽略IoAllocateErrorLogEntry产生的错误,因为不能向错误登记表添加一条记录根本不是什么严重错误。

结构化异常处理

Windows NT提供了一种处理异常情况的方法,它可以帮助我们避免潜在的系统崩溃。结构化异常处理与编译器的代码生成器紧密集成,它允许你在自己的代码段周围加上保护语句,如果被保护代码段中的任何语句出现异常,系统将自动调用异常处理程序。结构化异常处理还便于你提供清除语句,不管控制以何种方式离开被保护代码段,清除代码都会被执行。

许多读者并不熟悉结构化异常方法,所以我在这里先解释一些基本概念。使用这个方法可以写出更好更稳固的代码。在许多情况下,WDM驱动程序例程接收到的参数都是经过其它代码严格检验的,一般不会成为导致异常的原因。但我们仍要遵循这样基本原则:对用户模式虚拟内存直接引用的代码段应该用结构化异常帧保护起来。这样的引用通常发生在调用MmProbeAndLockPagesProbeForRead,和ProbeForWrite函数时。

注意

结构化异常机制可以使内核模式代码在访问一个非法的用户模式地址后避免系统崩溃。但它不能捕捉其它处理器异常,例如被零除或试图访问非法的内核模式地址。从这一点上看,这种机制在内核模式中不象在用户模式中那样具有通用性。

内核模式程序通过在内存堆栈上建立异常帧来实现结构化异常,这个堆栈就是程序用于参数传递、子程序调用,和分配自动变量所使用的内存堆栈,我不将详细描述这个机制的内部过程,因为该机制在不同的Windows NT平台上会不同。然而,这个机制与在用户模式程序中使用的结构化异常机制相同,你可以在这两个地方找到对这个机制的详细描述,Matt Pietrek的文章“A Crash Course on the Depths of Win32 Structured Exception Handling”Microsoft Systems Journal (January 1997)。Jeff Richter在《Programming Applications for Microsoft Windows, Fourth Edition (Microsoft Press, 1999)》中的专题讨论。

当异常发生时,操作系统通过扫描堆栈异常帧来寻找相应的异常处理程序。图3-3描绘了这个逻辑流程。每个异常帧都指定一个过滤函数,系统调用这个过滤函数来回答这样的问题:“你能处理这个异常吗?”,当系统找到对应的异常处理程序时,它就回卷堆栈异常帧以恢复处理程序所需要的上下文。回卷过程包括调用同一组过滤函数,并指定一个有这样含义的参数:“我们正在回卷;如果你回答:是,那么立即接管控制”,如果没有代码处理这个异常,那么这里将是一个默认处理例程,该例程使系统崩溃。

图3-3. 结构化异常处理逻辑

当使用Microsoft编译器时,你可以使用C/C++的Microsoft扩展,它隐藏了使用某些操作系统原语的复杂性。例如,用__try语句指定被保护代码段,用__finally语句指定终止处理程序,用__except语句指定异常处理程序。

注意

最好总使用带有双下划线的关键字,如__try__finally、和__except。在C编译单元中,DDK头文件WARNING.H也把tryfinally、和except宏定义成这些双下划线的关键字。DDK例子程序使用这些宏而不是直接使用带双下划线的关键字。有一点需要注意:在C++编译单元中,try语句必须与catch语句成对出现,这是一个完全不同的异常机制,是C++语言的一部分。C++异常机制不能用于驱动程序中,除非你自己从运行时间库中复制出某些基础结构。Microsoft不推荐那样做,因为这将增加驱动程序的内存消耗并增大执行文件的大小。

Try-Finally块

try-finally块开始解释结构化异常处理最为容易,用它你可以写出象下面这样的清除代码:

__try
{
  <guarded body>
}
__finally
{
  <termination handler>
}

在这段伪代码中,被保护体<guarded body>是一系列语句和子例程。通常,这些语句会有副作用,如果没有副作用,就没有必要使用一个try-finally块,因为没有东西需要清除。终止处理程序<termination handler>包含一些恢复语句,用于部分或全部恢复被保护体产生的副作用。

语法上,try-finally按下面方式工作。首先,计算机执行被保护体<guarded body>。由于某种原因控制离开被保护体,计算机执行终止处理程序。如图3-4。

图3-4. try-finally中的控制流程

这里有一个简单的例子:

LONG counter = 0;
__try
{
  ++counter;
}
__finally
{
  --counter;
}
KdPrint(("%d/n", counter));

首先,被保护体执行并把counter变量的值从0增加到1。当控制穿过被保护体右括号后,终止处理程序执行,又把counter减到0。打印出的值将为0。

下面是一个稍复杂的修改:

VOID RandomFunction(PLONG pcounter)
{
  __try
  {
    ++*pcounter;
    return;
  }
  __finally
  {
    --*pcounter;
  }
}

该函数的结果是:pcounter指向的整型值不变,不管控制以何种原因离开被保护体,包括通过return语句或goto语句,终止处理程序都将执行。开始,被保护体增加计数器值并执行一个return语句,接着清除代码执行并减计数器值,之后该子程序才真正返回。

下面例子可以加深你对try-finally语句的理解:

static LONG counter = 0;
__try
{
  ++counter;
  BadActor();
}
__finally
{
  --counter;
}

这里我们调用了BadActor函数,我假定该函数将导致某种异常,这将触发堆栈回卷。作为回卷“执行和异常堆栈”过程的一部分,操作系统将调用我们的恢复代码并把counter恢复到以前的值。然后操作系统继续回卷堆栈,所以不论我们在__finally块后有什么代码都得不到执行。

Try-Except块

结构化异常处理的另一种使用方式是try-except块:

__try
{
  <guarded body>
}
__except(<filter expression>)
{
  <exception handler>
}

try-except块中的被保护代码可能会导致异常。你可能调用了象MmProbeAndLockPages这类的内核模式服务函数,这些函数使用来自用户模式的指针,而这些指针并没有做过明确的有效性检测。也许是因为其它原因。但不管什么原因,如果程序在通过被保护代码段时没有发生任何错误,那么控制将转到异常处理代码后面继续执行,你可以认为这是正常情况。如果在你的代码中或任何你调用的子例程中发生了异常,操作系统将回卷堆栈,并对__except语句中的过滤表达式求值。结果将是下面三个值中的一个:

  • EXCEPTION_EXECUTE_HANDLER 数值上等于1,告诉操作系统把控制转移到你的异常处理代码。如果控制走到处理程序的右大括号之外(如执行了return语句或goto语句),那么控制将转到紧接着异常处理代码的后面继续执行。(我看过了平台SDK中关于异常控制返回点的文档,但那不正确)
  • EXCEPTION_CONTINUE_SEARCH 数值上等于0,告诉操作系统你不能处理该异常。系统将继续扫描堆栈以寻找其它处理程序。如果没有找到为该异常提供的处理程序,系统立即崩溃。
  • EXCEPTION_CONTINUE_EXECUTION 数值上等于-1,告诉操作系统返回到异常发生的地方。关于这个值我稍后再谈。

图3-5显示了try-except块中可能出现的控制路径。

图3-5. try-except块中的控制流程

例如,下面代码演示了如何防止接收非法指针。(见光盘中的SEHTEST例子)

PVOID p = (PVOID) 1;
__try
{
  KdPrint(("About to generate exception/n"));
  ProbeForWrite(p, 4, 4);
  KdPrint(("You shouldn't see this message/n"));
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
  KdPrint(("Exception was caught/n"));
}
KdPrint(("Program kept control after exception/n"));

ProbeForWrite测试一个数据区域是否有效。在这个例子中,它将导致一个异常,因为我们提供的指针参数没有以4字节边界对齐。然后,异常处理程序得到控制。最后,异常处理完成后控制将转到异常处理程序后面的代码。

在上面的例子中,如果你返回EXCEPTION_CONTINUE_SEARCH,操作系统将继续回卷堆栈以寻找适合的异常处理程序。这时,异常处理程序和跟在它后面的代码都得不到控制,此时或者系统崩溃或者由更高级的处理程序接管控制。

不能在内核模式中返回EXCEPTION_CONTINUE_EXECUTION,因为你没有办法改变导致异常的情况,所以就不能实现重试。

注意,你不能用结构化异常捕获算术异常、页故障,和非法指针引用等等。你必须保证你的代码不产生这样的异常。

异常过滤表达式

你也许会惊奇,仅对一个能产生三种值的表达式求值,如何能执行麻烦的错误检测和修正。你可以用C/C++的逗号操作符把多个表达式串联起来:

__except(expr-1, ... EXCEPTION_CONTINUE_SEARCH){}

逗号操作符总是放弃它左边的表达式而对右边表达式求值。所以,最后一个表达式的值就是整个表达式的求值结果。

你可以使用C/C++条件操作符来执行更复杂的表达式计算:

__except(<some-expr> ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)

如果some_expr表达式的值为TRUE,则执行你自己的处理程序。否则,通知操作系统继续寻找堆栈中的其它异常处理程序。

当然,你也可以写一个返回EXCEPTION_Xxx值的子程序:

LONG EvaluateException()
{
  if (<some-expr>)
    return EXCEPTION_EXECUTE_HANDLER;
  else
    return EXCEPTION_CONTINUE_SEARCH;
}
 
...
__except(EvaluateException())
...

如果你需要获得更多的关于异常的信息,有两个函数可以在__except的求值表达式中调用,它们可以提供本次异常的相关信息。实际上,这两个函数是在Microsoft编译器的内部实现的,所以仅能用于特定时刻:

  • GetExceptionCode() 返回当前异常的数值代码。该值是一个NTSTATUS值。该函数仅在__except表达式和其后的异常处理代码中有效。
  • GetExceptionInformation() 返回EXCEPTION_POINTERS结构的地址,该结构包含异常的所有详细信息,在哪发生、发生时寄存器的内容,等等。该函数仅在__except表达式中有效。

由于这两个函数在使用上的限制,你可以以调用某过滤函数的形式使用它们,象下面这样:

LONG EvaluateException(NTSTATUS status, PEXCEPTION_POINTERS xp)
{
  ...
}
...
__except(EvaluateException(GetExceptionCode(), GetExceptionInformation()))
...

生成异常

程序中的bug可以导致异常并使系统调用异常处理机制。应用程序开发者应该熟悉Win32 API中的RaiseException函数,它可以生成任意异常。在WDM驱动程序中,你可以调用表3-1列出的例程。由于下面规则,我不能给你举一个使用这些函数的例子:

    仅当你知道存在一个异常处理代码并知道你真正在做什么时,才可以在非任意线程上下文下生成一个异常。

表3-1. 用于生成异常的服务函数

服务函数

描述

ExRaiseStatus

用指定状态代码触发异常

ExRaiseAccessViolation

触发STATUS_ACCESS_VIOLATION异常

ExRaiseDatatypeMisalignment

触发STATUS_DATATYPE_MISALIGNMENT异常

特别地,不要通过触发异常来告诉你的调用者你在普通执行状态中的信息,你完全可以返回状态代码。应该尽量避免使用异常,因为堆栈回卷机制非常消耗资源。

一些真实环境中的例子

尽管建立异常帧然后撕去异常帧会非常消耗资源,但在特殊情况下,驱动程序必须使用结构化异常语法。并且,在一些时间不是特别重要的场合中,如果想要得到更好的程序也可以使用结构化异常机制。

有一个地方你必须使用结构化异常处理机制,那就是当调用MmProbeAndLockPages函数锁定被MDL(内存描述符表)使用的内存页时,必须建立一个异常处理例程。对于WDM驱动程序,这个问题不经常出现,因为通常你使用的MDL都已经被其它程序探测并锁定(probe-and-lock)过。但是,由于你可以定义使用METHOD_NEITHER缓冲方法的I/O控制(IOCTL)操作,所以你必须按下面方式写代码:

PMDL mdl = MmCreateMdl(...);
__try
{
  MmProbeAndLockPages(mdl, ...);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
  NTSTATUS status = GetExceptionCode();
  ExFreePool((PVOID) mdl);
  return CompleteRequest(Irp, status, 0);
}

CompleteRequest是用于实现I/O请求完成机制的辅助函数。在第五章我将详细解释I/O请求和完成I/O请求。ExFreePool是一个内核模式服务例程,它释放由MmCreateMdl等函数创建的内存块。

另一个真实环境中的例子,考虑一下我在本章前面提到的关于AddDevice函数的错误处理代码。当你进入这个函数后,所有累积的副作用在发现一个错误后都必须消除。结构化异常处理可以使这个函数更具维护性。下面代码略去了一些不相关代码而着重错误处理:

NTSTATUS AddDevice(...)
{
  NTSTATUS status = STATUS_UNSUCCESSFUL;
  PDEVICE_OBJECT fdo;
  PDEVICE_EXTENSION pdx;
  status = IoCreateDevice(..., &fdo);
  if (!NT_SUCCESS(status))
    return status;
  __try
  {
    pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
    ...
    IoInitializeRemoveLock(&pdx->RemoveLock, ...);
    status = IoRegisterDeviceInterface(..., &pdx->ifname);
    if (!NT_SUCCESS(status))
      return status;
    ...
  }
  __finally
  {
    if (!NT_SUCCESS(status))
    {
      ...
      if (pdx->ifname.Buffer)
        RtlFreeUnicodeString(&pdx->ifname);
      IoDeleteDevice(fdo);
    }
  }
  return status;
}

这里的关键思想是:一旦我们发现某个服务函数执行失败,我们仅仅执行一个return status语句。return status语句触发了终止处理程序的执行,这个处理程序消除了所有累积的副作用。为了利用这种技术,必须先做两件事:第一,由于终止处理程序总是被执行,即便被保护代码无错误发生时也是这样,所以必须知道何时消除副作用何时不用。在这里我们通过测试status变量。如果状态为成功,我们不必做任何清除工作,否则,我们必须彻底清除由于异常给程序带来的副作用。要做的第二件事是了解需要清除哪一个副作用。我们把所有可能产生副作用的变量都初始化为NULL。如果在寄存一个设备接口时失败,那么pdx->ifname中将不会存在一个串,从而也不需要释放操作,如此等等。

在上面情况中使用try-finally块的一个最大好处是易于代码修改。你可以在IoCreateDevice调用和IoRegisterDeviceInterface调用间加入任何语句,这些语句在成功执行后也可能对函数造成副作用。正确的清除操作就是在终止处理程序中加入与产生副作用相反的补偿语句。相反,如果不用结构化异常块语句,那么你必须在每次状态代码测试后都加入明确的清除代码,这更容易导致错误,你可能从这样的地方退出函数,因此不得不记住每个加入了清除语句的代码位置。

如果要分配一块内存,我们只需向AddDevice直接插入一些语句即可(粗体部分):

NTSTATUS AddDevice(...)
{
  NTSTATUS status = STATUS_UNSUCCESSFUL;
  PDEVICE_OBJECT fdo;
  PDEVICE_EXTENSION pdx;
  status = IoCreateDevice(..., &fdo);
  if (!NT_SUCCESS(status))
    return status;
  __try
  {
    pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
    ...
    pdx->DeviceDescriptor = (PUSB_DEVICE_DESCRIPTOR)
      ExAllocatePool(NonPagedPool, sizeof(USB_DEVICE_DESCRIPTOR));
    if (!pdx->DeviceDescriptor)
      return STATUS_INSUFFICIENT_RESOURCES; 
    IoInitializeRemoveLock(&pdx->RemoveLock, ...);
    status = IoRegisterDeviceInterface(..., &pdx->ifname);
    if (!NT_SUCCESS(status))
      return status;
    ...
  }
  __finally
  {
    if (!NT_SUCCESS(status))
    {
      ...
      if (pdx->ifname.Buffer)
        RtlFreeUnicodeString(&pdx->ifname);
      if (pdx->DeviceDescriptor)
        ExFreePool((PVOID) pdx->DeviceDescriptor); 
      IoDeleteDevice(fdo);
    }
  }
  return status;
}

如果不使用结构化异常,那么在剩下的程序中,你必须在每个返回错误的代码后面都加入一个ExFreePool调用。

Bug Checks

Bug check是系统检测到的错误,一旦发现这种错误,系统立即以一种可控制的方式关闭。许多内核模式部件运行时都进行一致性检测,如果某个系统部件发现一个不可恢复的错误,将生成一个bug check。如果可能,所有内核模式部件都先登记遇到的错误,然后继续运行,而不是调用KeBugCheckEx,除非这种错误将使系统本身变得不可靠。程序可以在任何IRQL上调用KeBugCheckEx。如果程序发现一个不可恢复的错误,并且该程序继续运行将会破坏系统,那么该程序就调用KeBugCheckEx函数,这个函数将使系统以一种可控制的方式关闭。

当内核模式中出现不可恢复错误时,会出现一个称为死亡蓝屏(BSOD blue screen of death)的画面,驱动程序开发者应该十分熟悉它。图3-6就是一个例子(出自手写,因为在这种情况下根本不可能运行屏幕截取软件)。在内部,这种错误被称为bug check,它的主要特征是,系统尽可能以正常的方式关闭并弹出一个死亡蓝屏。一旦死亡蓝屏出现,则表明系统已经死掉必须重启动。

图3-6. “死亡蓝屏”

可以按下面方式调用KeBugCheckEx:

KeBugCheckEx(bugcode, info1, info2, info3, info4);

bugcode是一个数值,指出出错的原因,info1info2等是整型参数,将出现在死亡蓝屏中以帮助程序员了解错误细节。该函数从不返回(!)。

我不将解释死亡蓝屏中的信息。你可以在Art Baker的《The Windows NT Device Driver Book (Prentice Hall, 1997)》17.3段中找到更多的信息。Microsoft自己的bugcheck代码在DDK头文件bugcodes.h中列出;对该代码的更完整解释以及各种参数的含义可以在KBase文章Q103059 “Descriptions of Bug Codes for Windows NT”中找到。

如果需要,你也可以创建自己的bugcheck代码。Microsoft定义的值是从1(APC_INDEX_MISMATCH)到0xDE(POOL_CORRUPTION_IN_FILE_AREA)之间的整数。为了创建你自己的bugcheck代码,你需要定义一个整型常量(类似STATUS_SEVERITY_SUCCESS的状态代码),并指出customer标志或非0的facility代码。例如:

#define MY_BUGCHECK_CODE 0x002A0001
...
KeBugCheckEx(MY_BUGCHECK_CODE, 0, 0, 0, 0);

使用非0的facility代码(例子中为42)或customer标志(例子中为0)是为了与Microsoft使用的代码区分开。

现在,我已经告诉你如何生成自己的BSOD,那么我们什么时候使用它呢?回答是决不,或者仅在驱动程序的内部调试中使用。我们不可能写出这样的驱动程序,它发现了一个错误并且只有通过关闭系统才能解决。更好的做法是记录这个错误(使用错误登记工具,将在第九章中描述)并返回一个状态码。

 

抱歉!评论已关闭.