2023年1月

来自Windows调试工具包的所有调试程序都使用相同的引擎dbgeng.dll。它包含一种特殊语言的脚本解释器,我们称之为WinDbg脚本语言以方便使用,我们对WinDbg脚本文件使用WDS文件扩展名。下面是在分析一个脚本时捕获的WinDbg线程的调用堆栈:

0:000> ~1kL 100ChildEBP RetAddr   
037cd084 6dd28cdc dbgeng
!TypedData::ForceU64+0x3037cd0ec 6dcbd08c dbgeng!GetPseudoOrRegVal+0x11c037cd134 6dcbceff dbgeng!MasmEvalExpression::GetTerm+0x12c037cd198 6dcbca23 dbgeng!MasmEvalExpression::GetMterm+0x36f037cd1d4 6dcbc873 dbgeng!MasmEvalExpression::GetAterm+0x13037cd220 6dcbc783 dbgeng!MasmEvalExpression::GetShiftTerm+0x13037cd254 6dcbc523 dbgeng!MasmEvalExpression::GetLterm+0x13037cd2c0 6dcbc443 dbgeng!MasmEvalExpression::GetLRterm+0x13037cd2f4 6dcbc424 dbgeng!MasmEvalExpression::StartExpr+0x13037cd308 6dcbbc2f dbgeng!MasmEvalExpression::GetCommonExpression+0xc4037cd31c 6dccdca3 dbgeng!MasmEvalExpression::Evaluate+0x4f037cd390 6dccd83d dbgeng!EvalExpression::EvalNum+0x63037cd3d0 6dd293cc dbgeng!GetExpression+0x5d037cd458 6dd2a7e2 dbgeng!ScanRegVal+0xfc037cd4ec 6dd17502 dbgeng!ParseRegCmd+0x422037cd52c 6dd194e8 dbgeng!WrapParseRegCmd+0x92037cd608 6dc8ed19 dbgeng!ProcessCommands+0x1278037cd644 6dc962af dbgeng!DotFor+0x1d9037cd658 6dd1872e dbgeng!DotCommand+0x3f037cd738 6dd19b49 dbgeng!ProcessCommands+0x4be037cd77c 6dc5c879 dbgeng!ProcessCommandsAndCatch+0x49037cdc14 6dd19cc3 dbgeng!Execute+0x2b9037cdc64 6dc89db0 dbgeng!ProcessCurBraceBlock+0xa3037cdc74 6dc962af dbgeng!DotBlock+0x10037cdc88 6dd1872e dbgeng!DotCommand+0x3f037cdd68 6dd19b49 dbgeng!ProcessCommands+0x4be037cddac 6dc5c879 dbgeng!ProcessCommandsAndCatch+0x49037ce244 6dd173ca dbgeng!Execute+0x2b9037ce2c4 6dd1863c dbgeng!ParseDollar+0x29a037ce3a0 6dd19b49 dbgeng!ProcessCommands+0x3cc037ce3e4 6dc5c879 dbgeng!ProcessCommandsAndCatch+0x49037ce87c 6dc5cada dbgeng!Execute+0x2b9037ce8ac00318693 dbgeng!DebugClient::ExecuteWide+0x6a037ce954 00318b83 windbg!ProcessCommand+0x143037cf968 0031ae46 windbg!ProcessEngineCommands+0xa3037cf97c 76fa19f1 windbg!EngineLoop+0x366037cf988 77c8d109 kernel32!BaseThreadInitThunk+0xe037cf9c800000000 ntdll!_RtlUserThreadStart+0x23

如果您调试了一段时间的崩溃转储,那么您可能遇到了这样的情况:调试器提供的初始转储上下文对应于在处理初始异常时发生的第二个异常,该异常可能更接近您正在调查的问题中的原始基础问题。
这可能很烦人,因为“.ecxr”命令将指向次要故障异常的位置,而不是原始异常上下文本身。然而,在大多数情况下,原始的、主要的异常上下文仍然在堆栈上;人们只需要知道如何找到它。

有两种方法可以解决这个问题:

  • 对于硬件生成的异常(如访问冲突),可以查找堆栈上的ntdll!KiUserExceptionDispatcher,它以PCONTEXT和PEXCEPTION_RECORD作为参数。
  • 对于软件生成的异常(如C++异常),情况会变得更糟。你可以找在堆栈上调用ntdll!RtlDispatchException,然后从那里获取PCONTEXT参数

如果堆栈展开失败,或者您正在处理其中一个转储,其中多个线程同时出现异常,这可能会有点乏味,这通常是由于崩溃转储写入失控。如果调试器能稍微自动化一下这个过程就好了。幸运的是,用一点蛮力的方法来做到这一点其实并不难。具体地说,只是一个普通的老“哑”内存扫描,以查找大多数上下文记录所共有的内容。这并不完全是一种巧妙的方法,但通常比手动在堆栈中查找要快得多,尤其是在涉及多个线程或多个嵌套异常的情况下。虽然可能会有误报,但通常很明显的一点是,涉及到一个活动异常有什么意义。然而,有时,快速而肮脏的暴力类型解决方案最终真的做到了这一点。
但是,为了基于内存搜索查找上下文记录,我们需要一些公共数据点,这些数据点通常对于所有上下文结构都是相同的,并且最好是连续的(为了便于使用“s”命令,调试器的内存搜索支持)。幸运的是,它以上下文结构的段寄存器的形式存在:

0:000> dt ntdll!_CONTEXT
+0x000 ContextFlags : Uint4B
[…]

+0x08c SegGs : Uint4B
+0x090 SegFs : Uint4B
+0x094 SegEs : Uint4B
+0x098 SegDs : Uint4B

[…]

现在,事实证明,对于给定进程中的所有线程,几乎总是具有相同的段选择器值,不包括异常的和非常不寻常的情况,如VDM进程。(x64上的段选择器值也是如此。)四个非零的32位值(实际上,零填充到32位的16位值)足以在不被误报的情况下合理地完成搜索。下面介绍如何使用臭名昭著的WinDbg调试器脚本(也适用于其他启用DbgEng的程序,如kd):

.foreach ( CxrPtr { s -[w1]d 0 l?ffffffff @gs @fs @es @ds } ) { .cxr CxrPtr – 8c }

这是一个有点冗长的命令,所以让我们把它分解成各个组件。首先,我们有一个“.foreach”构造,根据调试器文档,它遵循以下约定:

.foreach [Options] ( Variable { InCommands } ) { OutCommands }

.foreach命令(实际上是多个版本调试器脚本命令之一,一旦人们习惯使用它)基本上接受由输入命令(in commands)生成的一系列输入字符串,并调用一个命令来处理该输出(OutCommands),输入命令的结果作为变量参数。它很难看,并且基于文本解析进行操作(除其他外,还支持跳过每个X输入;请参阅调试器文档),但它完成了任务。

当你调试一个程序时,你最不想处理的是调试器不能正常工作。当你试图集中精力跟踪一个bug时,总是会因为次要的问题而被忽略,尤其是当调试器的问题导致你失去一个重新编程或者浪费了大量的时间等待调试器完成它,而调试器知道这需要永远做些什么的时候。

我发现自己在工作中和工作之外不时支持的一件事是使用程序的发布版本(无论如何,对于Windows应用程序)进行调试的价值。乍一看,这可能与某些人的想法相矛盾,因为人们往往认为调试构建实际上更适合调试(毕竟它被命名为“调试构建”)。

通常,您可能希望在调试器中跟踪的一类问题(除了崩溃)是某个特定函数以某种方式失败。在大多数Win32函数的情况下,通常会得到某种(希望是有意义的)最后的错误代码。有时,您可能需要知道返回错误的原因或错误的来源(在最后一个错误值通过几个函数向上传播的情况下)。
一种可能的方法是使用条件断点,但是SetLastError路径通常会被命中,因此这在性能方面经常是有问题的,即使在本地计算机上的用户模式调试中也是如此。
在Windows Vista上,NTDLL内部有一个未记录的钩子(它现在负责set last error背后的大部分逻辑),允许您配置一个程序,以便在将特定错误代码设置为最后一个错误时进入调试器。这是Vista的新功能,由于没有文档记录(至少在我能看到的任何地方都没有),它可能不会无限期地存在。

不过,现在可以设置ntdll!g_dwLastErrorToBreakOn设置为非零值(通过调试器中的ed命令),以便在NTDLL看到设置了最后一个错误值时要求它执行断点。显然,这不会直接捕获修改TEB中字段的内容,但是使用setlastererror或RtlSetLastWin32Error的任何内容都将根据该值进行检查(在debuggee的上下文中)。
例如,如果要求NTDLL在错误5(错误访问被拒绝)时中断,然后尝试打开您无权访问的文件或目录,您可能会看到类似的情况:

0:002> ed ntdll!g_dwLastErrorToBreakOn 5
0:002>g

[...] Perform an operation to cause ERROR_ACCESS_DENIED

(
1864.2774): Break instruction exception- code 80000003(first chance)
ntdll
!DbgBreakPoint:00000000`76d6fdf0 cc int 3 0:004>k
Call Site
ntdll
!DbgBreakPoint
ntdll
! ?? ::FNODOBFM::`string'+0x377b kernel32!BaseSetLastNTError+0x16kernel32!CreateFileW+0x325SHELL32!CEnumFiles::InitAndFindFirst+0x7aSHELL32!CEnumFiles::InitAndFindFirstRetry+0x3eSHELL32!CFileSysEnum::_InitFindDataEnum+0x5eSHELL32!CFileSysEnum::Init+0x135SHELL32!CFSFolder::EnumObjects+0xd3SHELL32!_GetEnumerator+0x189SHELL32!CEnumThread::_RunEnum+0x6dSHELL32!CEnumThread::s_EnumThreadProc+0x13SHLWAPI!WrapperThreadProc+0xfckernel32!BaseThreadInitThunk+0xdntdll!RtlUserThreadStart+0x1d