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

调试教程第五部分:句柄泄漏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (36投票s)

2004 年 5 月 9 日

18分钟阅读

viewsIcon

296328

了解如何在 Windows 中调试句柄泄露。

引言

欢迎来到调试教程系列的第五部分。在本文中,我们将讨论 Windows 中的句柄、它们的含义以及如何调试泄露。我希望您在阅读本文之前已经熟悉了前面的 4 篇文章。这些文章的目的不是为了抄袭 WinDbg/NTSD 的帮助文件,而是介绍实际遇到的问题、它们是什么以及如何使用调试器来解决它们。

什么是句柄?

对于应用程序来说,“句柄”是设备、文件或其他系统对象或资源的某个实例。应用程序通过调用诸如 CreateFileRegOpenKey 之类的函数来创建资源的实例,并在后续对这些函数的调用中使用该句柄来对资源执行操作。

总的来说,您通常并不关心句柄的值是多少,因为它对您的应用程序几乎没有价值,除了将其传递给执行操作的函数。那么,句柄究竟是什么,它代表什么?

为了更清晰地了解,我们来看一个简单的 CreateFile 调用。

如果您调试 CreateFile,您最终会注意到它会到达 NtCreateFile

kernel32!CreateFileW+0x34a:
77e7b24c ff150810e677 call dword ptr [kernel32!_imp__NtCreateFile (77e61008)]
0:000> kb
ChildEBP RetAddr  Args to Child
0012f728 77e7b4a3 000007c4 80000000 00000000 kernel32!CreateFileW+0x40e
0012f74c 00401ba9 00406760 80000000 00000000 kernel32!CreateFileA+0x2e

NtCreateFile 会导致一个内核调用。调用的实际执行方式取决于系统,它可以是 sysenter 指令或软件生成的中断 int 2eh。无论哪种情况,都会调用内核,系统会将调用分派给正确的驱动程序。发生这种情况时,会在内核中创建一个对象,该对象代表请求的资源,在本例中是文件。如果您查看 NtCreateFile 的参数(此处发布),您会注意到 NtCreateFile 的第一个参数将返回句柄值。让我们看看第一个参数是什么。

eax=0012f730 ebx=00000000 ecx=80100080 edx=00200000 esi=77f58a3e edi=00000000
eip=77e7b24b esp=0012f69c ebp=0012f728 iopl=0         nv up ei ng nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=0038  gs=0000             efl=00000282
kernel32!CreateFileW+0x349:
77e7b24b 50               push    eax
0:000>

如我们所见,第一个参数是一个地址,很可能是函数中某个局部变量的地址。返回后,我们可以检查它指向的值。根据文档,它是一个文件句柄。

0:000> dd 0012f730
0012f730  000007c4

返回值为 7c4h,它不是您应用程序中任何内存的指针。它甚至不是内核内存中的指针。为了进一步调查,让我们找出我们可以从句柄中收集到哪些信息。有一个显示句柄信息的调试器命令。该命令称为 !handle。让我们用这个命令处理该值,看看会得到什么。

0:000> !handle 7c4 ff
Handle 7c4
  Type          File
  Attributes    0
  GrantedAccess 0x120089:
         ReadControl,Synch
         Read/List,ReadEA,ReadAttr
  HandleCount   2
  PointerCount  3
  No Object Specific Information available

因此,我们得知它确实是一个文件类型的对象,我们得知了它的属性,甚至获得了句柄的访问权限。我们没有从这些信息中得知的是该句柄指向的文件。对我来说,这似乎是最有价值的信息。我们之所以看不到这些信息是有原因的,但在我告诉您原因之前,我们必须继续弄清楚系统最初是如何找到这些信息的。

句柄的值代表什么?

为了向您展示这一点,我需要连接内核调试器。我已经在 Windows 2000 专业版机器上开始调试另一个进程,因为我的 Windows XP 机器没有设置好。我启动了记事本,并尝试在 notepad.exe 中获取句柄。

提示:*在 Windows XP/2003 中,系统可用的信息更多,并在调试器中显示。例如,对线程使用 !handle 会显示线程的入口函数。Windows 2000 则不会这样做。如果您想查找与句柄匹配的线程,尤其是在线程句柄丢失且线程已不存在的情况下,这会非常有用。还有一个命令 dt,在本教程前面已经展示过。Windows 2000 在显示 Windows 2003/XP 上始终显示的结构(指 NT 的 _EPROCESS 等系统结构)时可能会有问题。这可能是由于调试 Windows 2000 时 DBG 和 PDB 的处理方式,或者信息根本缺失。这些以及许多其他好处使得在 Windows XP/2003 上调试比 Windows 2000 更受欢迎。*

因此,我在记事本中选择了一个文件,并在 CreateFile 上设置了断点。在选择文件后,断点命中,我只需在返回地址上设置另一个断点。现在,我转储 EAX 以获取我选择的文件的句柄信息。

0:000> !handle eax ff
Handle 120
  Type          File
  Attributes    0
  GrantedAccess 0x120089:
         ReadControl,Synch
         Read/List,ReadEA,ReadAttr
  HandleCount   2
  PointerCount  3

这并没有告诉我太多信息,除了它确实是一个文件。现在,我要告诉您第一个秘密。这些句柄是进程特定的。这个值“120h”仅在此进程空间内已知。如果将此句柄值发送到另一个进程,它要么不存在,要么很可能是一个指向不同对象的句柄。这些句柄不像窗口句柄,它们的范围都在进程内。

句柄也是内核模式对象的表示。这意味着每个进程都有一个位于内核模式的句柄表,每个条目都指向一个内核内存位置。这是第二个秘密。这就是我连接内核调试器的原因。现在,让我们进入内核,尝试找到这个句柄。

我在内核调试器中首先执行的命令是“!process 0 0”列出所有进程。在列出所有进程后,我将使用 !handle 命令。但是语法略有不同。我需要指定进程才能列出正确的句柄。

kd> !process 0 0
**** NT ACTIVE PROCESS DUMP ****
...
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe
kd> !handle 120 ff fcc77200  
processor number 0
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use
0120: Object: fcd32448  GrantedAccess: 00120089
Object: fcd32448  Type: (fced7c40) File
    ObjectHeader: fcd32430
        HandleCount: 1  PointerCount: 1
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

第一个粗体数字是进程对象。它基本上是内存中的一个结构,包含特定于该进程的信息和数据位置。由于句柄是进程特定的,我们需要告诉 !handle 命令要查看哪个进程。“ff”我使用的参数只是设置所有位字段,以便获得它能够提供的所有信息。如果您想仔细研究显示的信息,帮助文件会告诉您每个位代表什么。我总是只使用“ff”,这样就无需费心设置精确的标志,而且我知道应该能获得所有可用信息。

第二个粗体地址是该进程的句柄表。这个内存位置指定了该进程使用的所有句柄的条目。请注意,它目前有 74 个打开的句柄。最后一个粗体是对象本身的内存位置。这是所有数据被提取的地方。请注意,“Name”属性提供了实际的位置和文件名。为什么我们在用户模式调试器中看不到这些信息?

在用户模式下显示句柄信息

显然,此表位于内核中,因此无法直接从用户模式下看到。NT 确实提供了可用于从内核查询这些句柄的 API。如果您阅读了我第 3 篇文章,我编写的 QuickView:System Explorer 工具实际上就是这样做的,并且会显示它能够访问的进程的句柄信息。这个 API 称为 NtQueryObject

此 API 的缺点是,某些对象在尝试查询时可能会挂起。这是因为这些对象,例如,是以 SYNC 访问权限打开的,并且其中一些对象确实很重要。系统中存在一些管道,如果您尝试查询它们,将会无限期地挂起。为了防止这种情况,调试器以及我编写的应用程序都尝试通过不查询可能导致死锁的对象来防止这种情况。在我的应用程序中,我不仅检查了 SYNC 标志是否设置,还找到了一些不会导致死锁的常见访问掩码,并允许查询它们。

还有一个名为 HANDLE.EXE 的实用程序,可以从SysInternals 下载,它可以显示所有信息。那么,它是如何实现的,而不会导致死锁呢?它们有自己的内核驱动程序,并且由于系统空间中的所有内存都可以被运行在系统空间的任何驱动程序访问,因此很简单。如所示,可以找到内核对象的内存位置并直接读取,而无需获取系统锁,从而导致 NtQueryObject 调用死锁。我一直在考虑在 QuickView 的未来版本中添加一个驱动程序来解决死锁问题并显示更详细的信息。

多个句柄

您已经看到了我在 Windows 2000 机器上的上述示例。我打开了 \TripItinerary.txt 文件。那么,如果我用另一个记事本实例再次打开它会怎样?让我们试试看会发生什么。

在用户模式调试器中

0:000> !handle eax ff
Handle 58
  Type          File
  Attributes    0
  GrantedAccess 0x120089:
         ReadControl,Synch
         Read/List,ReadEA,ReadAttr
  HandleCount   2
  PointerCount  3

在内核调试器中,我找到了进程并列出了句柄

kd> !handle 58 ff fcd8ace0  
processor number 0
PROCESS fcd8ace0  SessionId: 0  Cid: 0258    Peb: 7ffdf000  ParentCid: 0198
    DirBase: 04b19000  ObjectTable: fccc9648  TableSize:  22.
    Image: notepad.exe

Handle Table at e1e89000 with 22 Entries in use
0058: Object: fcce7028  GrantedAccess: 00120089
Object: fcce7028  Type: (fced7c40) File
    ObjectHeader: fcce7010
        HandleCount: 1  PointerCount: 1
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

我也可以在原始记事本进程中列出句柄

kd> !handle 120 ff fcc77200  
processor number 0
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use
0120: Object: fcd32448  GrantedAccess: 00120089
Object: fcd32448  Type: (fced7c40) File
    ObjectHeader: fcd32430
        HandleCount: 1  PointerCount: 1
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

在这种情况下,它们不仅获得了自己的句柄,而且在内核中也获得了自己的内存位置。并非所有对象总是如此。有时内核对象可以在进程之间共享。如果同一进程打开文件,通常会获得两个句柄,但只有一个内核对象。您可以通过使用我编写的实用程序或 SysInternals 的句柄应用程序来显示句柄信息,并按内核对象或句柄号进行排序。这有助于您熟悉句柄。

为什么用户模式调试器显示 HandleCount 为 2,PointerCount 为 3?

如果您注意到,当我们使用用户模式调试器显示句柄信息时,有一个“HandleCount”为 2,“PointerCount”为 3。这是因为它在显示信息时没有进行自我调整。为了让调试器获取句柄信息,它必须使用 DuplicateHandle 来复制句柄。复制会将 HandleCount 增加 1,将 PointerCount 增加 2。如果调试器减去 1 和 2,它就可以显示正确的信息,但它选择不这样做。让我们来检查一下。

我首先在内核中查看了句柄 ObjectHeader:“fcd32430”。

kd> !handle 120 ff fcc77200  
processor number 0
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use
0120: Object: fcd32448  GrantedAccess: 00120089
Object: fcd32448  Type: (fced7c40) File
    ObjectHeader: fcd32430
        HandleCount: 1  PointerCount: 1
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

kd> dd fcd32430
fcd32430  00000001 00000001 fced7c40 00000800
fcd32440  fcce5fc8 00000000 00700005 fceccbd0
fcd32450  fcecc248 e1e730d8 e1e73250 fcc790b4
fcd32460  00000000 00000000 00000000 00010000
fcd32470  00010100 00040042 00380024 e1bf2568
fcd32480  00000000 00000000 00000000 00000000
fcd32490  00000000 00040001 00000000 fcd3249c
fcd324a0  fcd3249c 00040000 00000001 fcd324ac
kd> ba r1 fcd32430  
kd> ba r1 fcd32434
kd> g

如我们所见,句柄计数为 1,指针计数为 1。然后我在句柄计数地址和指针计数地址上设置了“ba r1”断点。“BA”表示如果访问地址则中断。“r”表示如果通过读写访问。“r1”上的 1 仅仅表示 1 字节。

一旦我完成了这些并按“g”让内核继续,我现在将在用户模式调试器中键入“!handle 120 ff”。这将导致用户模式调试器访问此对象以获取信息。让我们看看这些指针何时被递增。

kd> kb
ChildEBP RetAddr  Args to Child              
fb66ebf0 8049ff0d 00000120 00000000 00000000 nt!ObReferenceObjectByHandle+0x1af
fb66ed40 80461691 00000074 00000120 ffffffff nt!NtDuplicateObject+0x12d
fb66ed40 77f83f85 00000074 00000120 ffffffff nt!KiSystemService+0xc4
0006ef28 00000000 00000000 00000000 00000000 ntdll!NtDuplicateObject+0xb

如我们所见,我们的第一个调用是 ObReferenceObjectByHandle。这会增加指针的引用。让我们看看我们的句柄计数现在是多少。

kd> !handle 120 ff fcc77200  
processor number 0
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use
0120: Object: fcd32448  GrantedAccess: 00120089
Object: fcd32448  Type: (fced7c40) File
    ObjectHeader: fcd32430
        HandleCount: 1  PointerCount: 2
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

如我们所见,我们的指针计数已增加到 2。让我们看看接下来会发生什么。

kd> kb
ChildEBP RetAddr  Args to Child              
fb66ebec 8049fffb 00000002 fcc77800 fcd32448 nt!ObpIncrementHandleCount+0x236
fb66ed40 80461691 00000074 00000120 ffffffff nt!NtDuplicateObject+0x3c3
fb66ed40 77f83f85 00000074 00000120 ffffffff nt!KiSystemService+0xc4
0006eed0 77e846ed 00000074 00000120 ffffffff ntdll!NtDuplicateObject+0xb
0006ef28 69b22188 00000074 00000120 ffffffff KERNEL32!DuplicateHandle+0xd4
0006f454 69b2252e 00000074 00000120 000000ff ntsdexts!GetHandleInfo+0x29
0006f4d0 0100d562 00000074 00000050 01001dec ntsdexts!handle+0x10f
0006f52c 0100e497 00233848 0006f54c 0006f553 ntsd!CallExtension+0x77
0006f65c 0100c2bb 01089c81 010241d8 01089138 ntsd!fnBangCmd+0x377
0006f868 0100be0f 80000003 00000001 0006fc2c ntsd!ProcessCommands+0x2ac
0006fab4 01008406 00000000 00000000 ffffffff ntsd!ProcessStateChange+0x687
0006fc2c 01008a14 0006fc4c 00000000 00000000 ntsd!DebugEventHandler+0x6a5
0006fca8 010076dc 00000000 00000000 7ffdf000 ntsd!NtsdExecution+0x13b
0006ff70 010226bf 00000002 002337b0 00232978 ntsd!main+0x3d7
0006ffc0 77e87903 00000000 00000000 7ffdf000 ntsd!mainCRTStartup+0xff
0006fff0 00000000 010225c0 00000000 000000c8 KERNEL32!BaseProcessStart+0x3d
kd> !handle 120 ff fcc77200  
processor number 0
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use
0120: Object: fcd32448  GrantedAccess: 00120089
Object: fcd32448  Type: (fced7c40) File
    ObjectHeader: fcd32430
        HandleCount: 2  PointerCount: 2
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

如我们所见,这现在调用 ObpIncrementHandleCount,我们的句柄计数现在也为 2。请注意,这两个调用都在 NtDuplicateObject 中,该函数是从用户模式 API DuplicateHandle 调用的。因此,复制对象将增加指向此内核内存的引用指针计数,并增加指向此内核对象的句柄数。这是一个进程引用同一内核对象的实例。那么,第三个指针引用从何而来?

kd> kb
ChildEBP RetAddr  Args to Child              
fb66ec68 804baed8 00000100 00000000 00000000 nt!ObReferenceObjectByHandle+0x1af
fb66ed48 80461691 00000100 00000002 0006ef4c nt!NtQueryObject+0xc1
fb66ed48 77f8c4e1 00000100 00000002 0006ef4c nt!KiSystemService+0xc4
0006ef24 69b22203 00000100 00000002 0006ef4c ntdll!NtQueryObject+0xb
0006f454 69b2252e 00000000 00000120 000000ff ntsdexts!GetHandleInfo+0xa4
0006f4d0 0100d562 00000074 00000050 01001dec ntsdexts!handle+0x10f
0006f52c 0100e497 00233848 0006f54c 0006f553 ntsd!CallExtension+0x77
0006f65c 0100c2bb 01089c81 010241d8 01089138 ntsd!fnBangCmd+0x377
0006f868 0100be0f 80000003 00000001 0006fc2c ntsd!ProcessCommands+0x2ac
0006fab4 01008406 00000000 00000000 ffffffff ntsd!ProcessStateChange+0x687
0006fc2c 01008a14 0006fc4c 00000000 00000000 ntsd!DebugEventHandler+0x6a5
0006fca8 010076dc 00000000 00000000 7ffdf000 ntsd!NtsdExecution+0x13b
0006ff70 010226bf 00000002 002337b0 00232978 ntsd!main+0x3d7
0006ffc0 77e87903 00000000 00000000 7ffdf000 ntsd!mainCRTStartup+0xff
0006fff0 00000000 010225c0 00000000 000000c8 KERNEL32!BaseProcessStart+0x3d
kd> !handle 120 ff fcc77200  
processor number 0
PROCESS fcc77200  SessionId: 0  Cid: 0338    Peb: 7ffdf000  ParentCid: 02c8
    DirBase: 079de000  ObjectTable: fccc30c8  TableSize:  74.
    Image: notepad.exe

Handle Table at e1e5f000 with 74 Entries in use
0120: Object: fcd32448  GrantedAccess: 00120089
Object: fcd32448  Type: (fced7c40) File
    ObjectHeader: fcd32430
        HandleCount: 2  PointerCount: 3
        Directory Object: 00000000  Name: \TripItinerary.txt {HarddiskVolume1}

就在那里,正如我之前提到的,调试器需要调用 NtQueryObject 来请求系统告诉它关于对象的信息。NtQueryObject API 在尝试读取内存之前,会再次调用 ObReferenceObjectByHandle 来引用它。这会导致指针计数增加到 3。但是,NtQueryObject 不需要创建另一个句柄实例或复制任何句柄。一旦它能够获得对象的指针引用,它就会简单地查询信息并释放引用计数。

另一个进程可能在此操作期间释放其句柄或指针计数,甚至可能只显示调试器对对象的引用。返回给调试器的信息是 1 个额外的句柄引用(它创建的),以及 2 个额外的指针引用(它在复制句柄时创建的一个,以及 NtQueryObject 读取对象信息时创建的一个)。如前所述,调试器总是可以减去这些值以仅显示相关信息,因为在 NtQueryObect 调用之后,一个指针引用会消失,它也会关闭其复制的句柄。这将使对象恢复到其原始状态(如果另一个进程现在也引用它,则为新状态)。

顺便说一下,DuplicateHandle API 的参数是用于复制句柄的进程句柄。因此,您必须能够使用复制权限调用 OpenProcess 才能复制其他进程中的句柄,以防您想知道。 DuplicateHandle

调试句柄泄露

那么,现在我们对句柄有了初步了解,我们如何找到泄露呢?除了“Bounds Checker”等常用应用程序外,我们还可以自己找到它们。首先要做的是在应用程序的良好时间点检查句柄,以获得句柄的基线。任务管理器有一个显示“句柄”列的选项。这是一个不错的起点。接下来,您应该使用该应用程序执行操作,当然,看看句柄是增加还是减少。当句柄增加时,您不应立即认为存在句柄泄露。您必须知道区分预期行为和意外行为。

例如,如果您有一个服务器应用程序,并且您正在检查网络连接。您注意到当您将许多客户端连接到应用程序时,服务器上的网络连接数量有所增加。这是预期之中的,显然如此,因此需要确定是否超过了预期。此外,当所有客户端断开连接时,预期所有网络连接都会被清理。所以,如果您知道您的句柄计数是故意增加的,但是选择另一个选项应该会减少句柄计数,那么就应该进行测试。

第一步:确定泄露

如果您一直在监控应用程序,请确定句柄计数是否符合预期。查看它是否随时间稳定增长,增长快或慢。一旦确定这确实是泄露,或者此时不确定,可能就该进行下一步了,即识别被泄露的句柄。如果可能,最好尝试确定泄露的模式。例如,每次选择某个菜单项或打开文件时。这将有助于缩小重现步骤的范围,并限制似乎是导致问题的位置的源区域。

第二步:确定类型和对象信息

做到这一点最好的方法是使用 SysInternals 的“Handle”等工具和/或调试器。我创建了一个很快就泄露句柄的简单程序。我运行了该应用程序并在任务管理器中查看。我发现泄露了超过 65,000 个句柄。因此,我想做的第一件事是确定泄露的是哪种类型的句柄。这样,我就可以限制我要在我的应用程序中查找的 API,以找到泄露。

0:001> !handle 0 0
65545 Handles
Type            Count
Event           3
Section         1
File            1
Port            1
Directory       3
WindowStation   2
Semaphore       2
Key             65530
Desktop         1
KeyedEvent      1

正如我们在下面这个夸张的例子中所见,似乎在泄露的句柄是“Key”,这是一个注册表句柄。应用程序中的某个地方正在打开注册表。现在要做的是尝试识别位置,以及特定键的打开次数是否比其他键多。如果我们能获得键信息,我们就可以按打开次数对键进行排序,如果有一个键打开次数最多,那么我们就可以限制我们在应用程序中的搜索范围。

!handle 0 ff Key
Handle 2de0
  Type          Key
  Attributes    0
  GrantedAccess 0xf003f:
         Delete,ReadControl,WriteDac,WriteOwner
         QueryValue,SetValue,CreateSubKey,EnumSubKey,Notify,CreateLink
  HandleCount   2
  PointerCount  3
  Name          \REGISTRY\USER\S-1-5-21-789336058-706699826-1202660629-1003\Software
  Object Specific Information
    Key last write time:  01:10:03. 5/9/2004
    Key name Software

使用“Key”,我们很幸运。调试器向我们显示正在打开的键,并且 HKCU\Software 似乎被打开得最多。我们已确定它是一个键对象,这意味着创建点将是 RegOpenKeyRegOpenKeyEx。我们还确定了实际打开的键,HKCU\Software。第三步

第三步:浏览源代码并调试应用程序

我们现在有了类型、API,并且幸运的是甚至有了实际打开的资源。所以,现在我们可以做诸如在源代码中查找打开此键的位置,或者在应用程序中对 RegOpenKey/RegOpenKeyEx 设置断点以获取堆栈跟踪之类的操作。然后,我们可以跟踪已分配的句柄,并确定哪些句柄被关闭了。Bounds Checker 也可以作为此过程的辅助工具。

另一种技术,在大多数情况下可能有些过度,但可以做到的是使用“Heap”教程中提到的内存泄露技巧。可以围绕创建句柄的 API 编写包装函数,并将句柄添加到链表中。释放时,您可以搜索链表并删除句柄。此操作将允许句柄本身仍然被返回,因此您不必为所有函数都使用包装器。唯一的缺点是它会变慢,因为在关闭时您需要遍历列表。

//This method could return the actual key, but would require 
//a search of the linked list on a close.

DWORD MyOpenKey(..., phKey)
{
    dwResult = RegOpenKey(... phKey);

    pTemp = Allocate();
    pTemp->pNext = gpHead;
    gpHead = pTemp;
    gpHead->hKey = phKey;
  
    return dwResult;
}

//This method would allow faster look up in the close but 
//would also require a wrapper for all functions.

PMYKEY MyOpenKey(...)
{
    hKey = RegOpenKey(...);

    pTemp = Allocate();
    pTemp->pNext = gpHead;
    gpHead = pTemp;
    gpHead->hKey = hKey;
  
    return gpHead;
}

另外,请记住,以上示例几乎是伪代码。在需要时将使用关键部分,并且实现可能会有所不同。全局列表的目的是允许编写调试器扩展(教程第四部分)来遍历链表,并使用调试符号来查找此全局变量的位置来遍历列表。对于大多数问题来说,这可能有些过度,而且实现可能只在它预先构建到所有构建中而不需要一直实现的情况下才有益(例如,零售版本中的 #define MyOpenKey RegOpenKey)。

更小的泄露

并非所有句柄泄露都会像上述那样出现。例如,您可能会注意到某个操作每次调用时都会导致一两个句柄泄露。然后,您可以中断应用程序,获取所有值的句柄计数,然后执行泄露操作,然后获取下一个快照。发生这种情况后,您可以再次重现问题,但这次在会创建您已看到增加的对象的位置设置断点。

其他技巧

以下是处理句柄时的一些其他技巧。

无效线程等待退出循环

我注意到许多应用程序使用 Sleep()/GetExitCodeThread() 循环组合来等待线程退出。例如

   do {
     Sleep(10);
     bReturn = GetExitCodeThread(hThread, &ExitCode);
   } while(bReturn && ExitCode == STILL_ACTIVE) ;

MSDN 表示,此问题的症结在于线程可能返回 STILL_ACTIVE(值为 259)作为其返回值。虽然这可能是真的,但这并不是我担心的循环原因。我发现的一个问题是,一些应用程序正在使用 MFC 的 CThread 库并尝试进行此循环。问题在于 AfxBeginThread() 将线程句柄发送到创建的线程。创建的线程使用 AfxEndThread() 退出,然后关闭句柄。如果您没有复制此句柄,该句柄现在就无效了。

总的来说,这可能不是问题,因为循环在收到句柄使用失败时仍会退出。问题发生在当另一个对象现在使用旧线程句柄创建时!请记住,这些句柄在释放后会被系统重新使用。这意味着另一个对象可以在调用函数再次的这段时间内被分配给该句柄。这个对象可以是任何东西,并且调用可能会因无效对象而失败。它也可能是一个新创建的线程,这将导致此循环无法退出!

因此,一般来说,即使在 Afx* 函数之外,如果您将在其他地方关闭句柄,请务必复制它。其次,您不需要循环,因为线程句柄在线程退出时会被信号化。

  WaitForSingleObject(hThread, INFINTE);
  GetExitCodeThread(hThread, &ExitCode);
  CloseHandle(hThread);

所以,上面的问题可以通过上面建模的代码系列来更好地解决。

.DMP 文件缺少句柄信息?

如果您有一个 .DMP 文件并使用 !handle,有时您会收到错误。这是因为如果您有一个 .DMP 文件,您就无法再调用 NtQueryObject 来获取句柄信息了。在这种情况下,调试器需要在创建 .DMP 文件时查询所有对象并将信息保存到 .DMP 中。某些 .dump 标志不执行此操作。我发现几乎所有 NTSD/CDB/WinDbg 版本在使用 .dump /f x.dmp(完整转储)时从不保存句柄信息。它们有一个单独的选项,即 /mh.dump /mh x2.dmp 但这不是完整转储,而是“迷你转储”。我过去的做法是使用这两个选项创建两个转储。

但是,如果您有从 Microsoft 网站下载的较新调试器,则无需这样做。有一个新选项 /ma,它会创建带有句柄信息的完整转储。.dump /ma x3.dmp,这就足够了。这是我建议用于创建所有用户模式转储的标志,因为您永远不知道您是否需要查看句柄信息。

结论

句柄是 Windows 的另一个组成部分,我们必须学会与之协作并正确地“处理”它们。检查您编写的应用程序是否存在内存泄露和其他问题时,请务必检查句柄计数!希望本文帮助您了解句柄是什么以及如何调查它们。

© . All rights reserved.