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

理解汇编语言如何帮助调试 .NET 应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (16投票s)

2012 年 2 月 16 日

CPOL

14分钟阅读

viewsIcon

54575

本文展示了在 .NET 应用程序调试中,理解汇编语言如何帮助解决看似不可能的问题的几种情况。

引言

在过去的几年里,我被问过很多次为什么我还要练习我的 x86 和 x64 汇编语言技能,特别是为什么我认为在课程、会议和一次性讲座中教授汇编语言很重要。毕竟,.NET 开发人员距离他们应用程序生成的实际汇编代码还有很远的距离,而且肯定不需要手工编写任何汇编代码。

我完全同意,除非你在进行非常底层的优化,否则你很少需要手工编写汇编代码;而且,也无法直接从 .NET 程序调用你的汇编代码。然而,我相信所有 .NET 开发人员都应该能够阅读汇编代码,主要是为了调试目的,也为了性能分析和优化。

在本文中,我将向您展示一些示例,说明理解汇编代码和通用堆栈结构(通常对 .NET 开发人员隐藏的细节)如何在没有“高级”工具甚至没有 Visual Studio 的情况下,帮助调试其他方式无法解决的问题。不过,我需要做一些假设。本文假定您基本熟悉 x86 汇编语言、堆栈结构、调用约定,以及对 WinDbg 和 SOS 命令有所了解。网上有一些优秀的资源可以帮助您学习这些主题。

分析损坏或不完整的调用堆栈

这种情况不常发生,但即使是托管应用程序有时也会遇到堆栈损坏。以下是导致堆栈损坏的一些可能原因:

  • 堆栈溢出——由于无限递归或大量重复的堆栈分配
  • P/Invoke 堆栈不平衡——托管和非托管函数签名不匹配
  • 随机内存损坏——通常由进程中的非托管组件引起

发生堆栈损坏时,通常很难确定原因,因为……堆栈已损坏!应用程序在损坏时正在做什么的任何痕迹可能已被堆栈上的垃圾覆盖。事实上,即使是调试器命令——例如 !CLRStack——也可能无法正常工作。当发生堆栈损坏时,您能做什么?自然,唯一剩下的方法就是手动遍历堆栈。

首先,我们假设堆栈指针 (ESP) 没有损坏(而 EBP 寄存器可能已损坏)。在这种情况下,我们知道堆栈的起始位置,并可以开始向后扫描执行的残余。也就是说,堆栈上的大多数帧都保留了 EBP 寄存器,通过找到一对 {EBP, 返回地址} 并沿着从 EBP 开始的帧链进行跟踪,就可以重新构建执行过程。下面是一个遵循这些步骤重建堆栈的分析。

0:000> !CLRStack
OS Thread Id: 0x3318 (0)
Child SP       IP Call Site
00233000 00450818 FileExplorer.MainForm.RecursivelyFillTreeview
(System.Windows.Forms.TreeNode, System.String)    

堆栈上只有一个帧,即使它看起来有效,显然堆栈也不是从该方法开始的,而且我们缺少更多的帧。现在是时候尝试使用 dds 命令从 ESP 手动重建堆栈了,该命令会转储内存并尝试解析符号。不幸的是,因为代码是托管的,如果没有 SOS 命令(如 !U)的帮助,我们将在堆栈上找不到任何有效的符号。

0:000> dds esp
00233000  00000000
00233004  00000000
00233008  00000000
0023300c  00000000
00233010  00000000
00233014  00000000
00233018  00000000
0023301c  00000000
00233020  0220e1ec
00233024  021e364c
00233028  00000000
0023302c  00000000
00233030  021e364c
00233034  00233058
00233038  0023307c
0023303c  00450826
00233040  021e513c
00233044  00000000
00233048  00000000
0023304c  00000000
00233050  00000000
00233054  00000000
00233058  00000000
0023305c  00000000
00233060  00000000
00233064  0220e1ec
00233068  021e364c
0023306c  00000000
00233070  00000000
00233074  021e364c
00233078  0023309c
0023307c  002330c0    

堆栈上标记的词看起来像一对 {EBP, 返回地址}。我为什么这么说?因为第一个值与 ESP 的值足够接近,这让我相信它指向堆栈——正如 EBP 应该做的那样——而第二个值离堆栈足够远——实际上,它应该是一个可执行代码地址。为了验证它是一个地址,让我们使用 !U 命令。

0:000> !u 00450826
Normal JIT generated code
FileExplorer.MainForm.RecursivelyFillTreeview(System.Windows.Forms.TreeNode, System.String)
Begin 004507d0, size f6
...snipped...    

确实,这看起来像一个有效的函数,我们可以继续猜测。如果我们的 EBP 猜测是正确的,它应该指向另一个保存的 EBP,然后是另一个返回地址,这将使我们能够完全回溯堆栈。

0:000> dds 0023307c L2
0023307c  002330c0
00233080  00450826    

果然,第一个值再次看起来像一个有效的已保存 EBP,第二个值与之前是完全相同的地址,这似乎是递归函数失控了。我们可以重复这个过程,直到达到堆栈的顶部,从而获得整个调用堆栈,在本例中,这将跨越数百个屏幕。

另一种值得一提的堆栈损坏变体是 ESP 寄存器也损坏的情况,我们无法信任它来指向实际的堆栈。在简单的堆栈溢出场景中这种情况不太常见,但可能由于缓冲区溢出、随机内存损坏或堆栈不平衡而发生。在这种情况下,我们必须通过其他方式获取堆栈的顶部。幸运的是,每个 Windows 线程都有一个名为线程环境块 (TEB) 的数据结构,其中包含其堆栈的范围,并且 !teb 调试器命令可以方便地转储当前线程的 TEB。有了这些信息,我们就可以开始遍历堆栈,寻找 {EBP, 返回地址} 对。

0:000> dt ntdll!_NT_TIB
   +0x000 ExceptionList    : Ptr32 _EXCEPTION_REGISTRATION_RECORD
   +0x004 StackBase        : Ptr32 Void
   +0x008 StackLimit       : Ptr32 Void
   +0x00c SubSystemTib     : Ptr32 Void
   +0x010 FiberData        : Ptr32 Void
   +0x010 Version          : Uint4B
   +0x014 ArbitraryUserPointer : Ptr32 Void
   +0x018 Self             : Ptr32 _NT_TIB
 0:000> !teb
 ...snipped...    

将崩溃位置与源代码行关联

很多时候,你会遇到一个带有相对简单异常的崩溃转储,并希望将根本原因追溯到特定的代码行。!CLRStack 等命令以不能准确报告源代码行信息而闻名,如果你的函数有数百行,找到导致崩溃的代码行可能就像大海捞针。

在这些情况下,阅读一些反汇编可能正是你需要做的。在 SOS !U 命令的帮助下,生成的反汇编会为你提供提示,指向你的代码正在使用的各种 .NET 函数或 CLR 辅助程序。隔离出有问题的指令并将其与特定的代码行关联起来通常会非常简单。我们来看一个例子——我们有以下异常调用堆栈。

0:005> !PrintException
Exception object: 02c0fff0
Exception type:   System.NullReferenceException
Message:          Object reference not set to an instance of an object.
InnerException:   <none>
StackTrace (generated):
    SP       IP       Function
    0530F370 00380A8A fileexplorer!FileExplorer.MainForm+<>c__DisplayClass1.
                      <treeView1_AfterSelect>b__0(System.Object)+0x4a
    0530F3AC 67A3C958 mscorlib_ni!System.Threading.QueueUserWorkItemCallback.WaitCallback_Context
                      (System.Object)+0x3c
    0530F3B4 67A20846 mscorlib_ni!System.Threading.ExecutionContext.Run
                      (System.Threading.ExecutionContext, System.Threading.ContextCallback, 
                      System.Object, Boolean)+0xe6
    0530F3D8 67A3D872 mscorlib_ni!System.Threading.QueueUserWorkItemCallback.
                      System.Threading.IThreadPoolWorkItem.ExecuteWorkItem()+0x5a
    0530F3EC 67A3D0A7 mscorlib_ni!System.Threading.ThreadPoolWorkQueue.Dispatch()+0x13f
StackTraceString: <none>
HResult: 80004003    

异常发生在名称奇怪的函数 <>c__DisplayClass1.<treeView1_AfterSelect>b__0 中。如果你对 ILDASM 有一些经验,你可能知道 C# 编译器给匿名函数(或 lambda)起的名称就是这种类型。具体来说,treeView1_AfterSelect包含我们正在查看的 lambda 的函数。但我们在 lambda 的哪里崩溃了?源代码信息不可用(也许我们甚至没有该帧的符号),但我们可以检查故障地址处的反汇编。

0:005> !u 00380A8A 
Normal JIT generated code
FileExplorer.MainForm+<>c__DisplayClass1.<treeView1_AfterSelect>b__0(System.Object)
Begin 00380a40, size e1
...snipped...
00380a6c 33d2            xor     edx,edx
00380a6e 8955f0          mov     dword ptr [ebp-10h],edx
00380a71 33d2            xor     edx,edx
00380a73 8955dc          mov     dword ptr [ebp-24h],edx
00380a76 33d2            xor     edx,edx
00380a78 8955e0          mov     dword ptr [ebp-20h],edx
00380a7b c745ec00000000  mov     dword ptr [ebp-14h],0
00380a82 90              nop
00380a83 90              nop
00380a84 8b45e4          mov     eax,dword ptr [ebp-1Ch]
00380a87 8b4804          mov     ecx,dword ptr [eax+4]
>>> 00380a8a 3909        cmp     dword ptr [ecx],ecx
00380a8c e82fa8637a      call    System_Windows_Forms_ni+0x15b2c0 (7a9bb2c0) 
                         (System.Windows.Forms.TreeNode.get_Name(), mdToken: 06004a49)
00380a91 8945d8          mov     dword ptr [ebp-28h],eax
00380a94 8b4dd8          mov     ecx,dword ptr [ebp-28h]
00380a97 e8acee6767      call    mscorlib_ni+0x28f948 (679ff948) 
                         (System.IO.Directory.GetFiles(System.String), mdToken: 06004245)
00380a9c 8945d4          mov     dword ptr [ebp-2Ch],eax
00380a9f 8b45d4          mov     eax,dword ptr [ebp-2Ch]
00380aa2 8945dc          mov     dword ptr [ebp-24h],eax
00380aa5 33d2            xor     edx,edx
00380aa7 8955f0          mov     dword ptr [ebp-10h],edx
00380aaa 90              nop
...snipped...    

查看反汇编代码后,我们现在可以确定确切导致引用,以及我们在函数代码的哪个位置。具体来说,我们在调用 TreeNode.get_Name() 函数之前崩溃了,这是 TreeNode.Name 属性的 getter。在调用之前唯一可能出错的是 TreeNode 对象为(实际上,我们看到的 cmp 指令正是为了确保调用接收者不为)。此外,我们知道 TreeNode.get_Name() 函数调用的结果随后被转移到 ECX 寄存器并传递给 Directory.GetFiles 函数。这应该足以在源代码文件中识别出有问题的代码行。

ThreadPool.QueueUserWorkItem(_ =>
{
    foreach (string file in Directory.GetFiles(node.Name))
    {
        listBox1.Items.Add(Path.GetFileName(file));
    }
});    

确定函数参数

你可能经常遇到的另一件事是,你有一个崩溃转储或实时调试器,但无法从堆栈中检索函数参数。有很多命令试图做到这一点——!CLRStack -p 是托管选项,kb 尝试处理非托管帧,而出色的 SOSEX 扩展提供了 !mk 命令。尽管如此,由于 x86 调用约定种类繁多,特别是 JIT 使用自定义的类似 fastcall 的调用约定,有时这些命令都无法正常工作。

例如,考虑以下调用堆栈,其中你的线程显然正在等待一个 .NET 监视器,在 Monitor.Enter 调用中。

0:000> !CLRStack
OS Thread Id: 0x2a88 (0)
ESP       EIP     
0037e8a8 76f2013d [GCFrame: 0037e8a8] 
0037e978 76f2013d [HelperMethodFrame_1OBJ: 0037e978] System.Threading.Monitor.Enter(System.Object)
0037e9d0 003f0b68 FileExplorer.MainForm.listBox1_DoubleClick(System.Object, System.EventArgs)
0037ea34 5933407c System.Windows.Forms.Control.OnDoubleClick(System.EventArgs)
0037ea4c 59666146 System.Windows.Forms.ListBox.WndProc(System.Windows.Forms.Message ByRef)
0037eaf8 58e086a0 System.Windows.Forms.Control+ControlNativeWindow.OnMessage
(System.Windows.Forms.Message ByRef)
0037eb00 58e08621 System.Windows.Forms.Control+ControlNativeWindow.WndProc
(System.Windows.Forms.Message ByRef)
0037eb14 58e084fa System.Windows.Forms.NativeWindow.Callback(IntPtr, Int32, IntPtr, IntPtr)
0037ecb8 007c09e4 [NDirectMethodFrameStandalone: 0037ecb8] 
System.Windows.Forms.UnsafeNativeMethods.DispatchMessageW(MSG ByRef)
0037ecc8 58e18cee System.Windows.Forms.Application+ComponentManager.
System.Windows.Forms.UnsafeNativeMethods.IMsoComponentManager.FPushMessageLoop(Int32, Int32, Int32)
0037ed64 58e18957 System.Windows.Forms.Application+ThreadContext.
RunMessageLoopInner(Int32, System.Windows.Forms.ApplicationContext)
0037edb8 58e187a1 System.Windows.Forms.Application+ThreadContext.
RunMessageLoop(Int32, System.Windows.Forms.ApplicationContext)
0037ede8 58dd5911 System.Windows.Forms.Application.Run(System.Windows.Forms.Form)
0037edfc 003f00ae FileExplorer.Program.Main()
0037f020 727b1b4c [GCFrame: 0037f020]    

嗯,一个显而易见的事情是找出你的线程正在锁定哪个同步对象,也就是说,传递给 Monitor.Enter 函数的参数是什么。尝试 !CLRStack -a 没有帮助。

0:000> !clrstack -a
OS Thread Id: 0x2a88 (0)
ESP       EIP     
0037e8a8 76f2013d [GCFrame: 0037e8a8] 
0037e978 76f2013d [HelperMethodFrame_1OBJ: 0037e978] System.Threading.Monitor.Enter(System.Object)
0037e9d0 003f0b68 FileExplorer.MainForm.listBox1_DoubleClick(System.Object, System.EventArgs)
    PARAMETERS:
        this = 0x02708308
        sender = 0x0271c4d4
        e = 0x02c8f400
    LOCALS:
        0x0037e9f4 = 0x02c8f470
        0x0037e9f0 = 0x02c8f4e8
        0x0037ea00 = 0x00000001
        0x0037e9ec = 0x02708990
        0x0037e9e8 = 0x027089b4
...snipped...    

正如你所见,SOS 无法报告 Monitor.Enter 的参数。也许非托管调用堆栈会有帮助?

0:000> kb
ChildEBP RetAddr  Args to Child              
0037e4ac 76600bdd 00000002 0037e4fc 00000001 ntdll!ZwWaitForMultipleObjects+0x15
0037e548 75541a2c 0037e4fc 0037e570 00000000 KERNELBASE!WaitForMultipleObjectsEx+0x100
0037e590 7545086a 00000002 7efde000 00000000 KERNEL32!WaitForMultipleObjectsExImplementation+0xe0
0037e5e4 764b2bf1 00000054 004d61e8 ffffffff USER32!RealMsgWaitForMultipleObjectsEx+0x14d
0037e610 764a202d 004d61e8 ffffffff 0037e638 ole32!CCliModalLoop::BlockFn+0xa1
0037e690 7285d245 00000002 ffffffff 00000001 ole32!CoWaitForMultipleHandles+0xcd
0037e6b0 7285d1a6 00000000 ffffffff 00000001 mscorwks!NT5WaitRoutine+0x51
0037e71c 7285d10a 00000001 004d61e8 00000000 mscorwks!MsgWaitHelper+0xa5
0037e73c 729142c8 00000001 004d61e8 00000000 mscorwks!Thread::DoAppropriateAptStateWait+0x28
0037e7c0 7291435d 00000001 004d61e8 00000000 mscorwks!Thread::DoAppropriateWaitWorker+0x13c
0037e810 729144e1 00000001 004d61e8 00000000 mscorwks!Thread::DoAppropriateWait+0x40
0037e86c 727b5422 ffffffff 00000001 00000000 mscorwks!CLREvent::WaitEx+0xf7
0037e880 728e98e2 ffffffff 00000001 00000000 mscorwks!CLREvent::Wait+0x17
0037e90c 729136e0 00497728 ffffffff 00497728 mscorwks!AwareLock::EnterEpilog+0x8c
0037e928 72913664 e6620e7e 0037ea18 02708308 mscorwks!AwareLock::Enter+0x61
0037e9c8 003f0b68 02c8f4e8 02c8f4c8 02c8f470 mscorwks!JIT_MonEnterWorker_Portable+0xb3
WARNING: Frame IP not in any known module. Following frames may be wrong.
0037ea28 5933407c 02c8f400 0271c6bc 0271c4d4 0x3f0b68
0037ea44 59666146 00000000 00000000 0037ea84 System_Windows_Forms_ni+0x72407c
...snipped...    

请注意,JIT_MonEnterWorker_Portable 帧对应于 Monitor.Enter 函数调用。我怎么知道的?通过检查返回地址:非托管帧的返回地址是 003f0b68,这同时也是托管堆栈跟踪中 listBox1_DoubleClick 函数的 EIP 值。

现在我们可以预期在非托管堆栈跟踪中找到传递给 Monitor.Enter 的前三个参数。不幸的是,kb 仅在参数通过堆栈传递时才报告正确的参数信息——它不区分标准的 C 和 Win32 调用约定,以及 CLR JIT 使用的自定义调用约定。事实上,在这种情况下,如果我们继续沿着这条路走下去,我们可能会错误地诊断出问题!

那么,我们在哪里找到参数呢?只剩下检查调用函数的反汇编并尝试确定参数如何传递给 Monitor.Enter 了。

0:000> !u 0x3f0b68
Normal JIT generated code
FileExplorer.MainForm.listBox1_DoubleClick(System.Object, System.EventArgs)
Begin 003f0a10, size 1ca
...snipped...
003f0b3b 8b55cc          mov     edx,dword ptr [ebp-34h]
003f0b3e 8b4dc8          mov     ecx,dword ptr [ebp-38h]
003f0b41 3909            cmp     dword ptr [ecx],ecx
003f0b43 e8f8cff471      call    mscorlib_ni+0x68db40 (7233db40) 
(System.Threading.Thread.Start(System.Object), mdToken: 060012b3)
003f0b48 90              nop
003f0b49 b9c8000000      mov     ecx,0C8h
003f0b4e e82d87a971      call    mscorlib_ni+0x1d9280 (71e89280) 
(System.Threading.Thread.Sleep(Int32), mdToken: 060012d6)
003f0b53 90              nop
003f0b54 8b45d0          mov     eax,dword ptr [ebp-30h]
003f0b57 8b8050010000    mov     eax,dword ptr [eax+150h]
003f0b5d 8945c0          mov     dword ptr [ebp-40h],eax
003f0b60 8b4dc0          mov     ecx,dword ptr [ebp-40h]
003f0b63 e83d203c72      call    mscorwks!JIT_MonEnterWorker (727b2ba5)
>>> 003f0b68 90          nop
003f0b69 90              nop
003f0b6a 8b4dc8          mov     ecx,dword ptr [ebp-38h]
003f0b6d 3909            cmp     dword ptr [ecx],ecx
003f0b6f e8dccdf471      call    mscorlib_ni+0x68d950 (7233d950) 
(System.Threading.Thread.Join(), mdToken: 060012d1)
...snipped...    

在标记的五行中,有参数传递过程,但它没有通过堆栈。请注意,只使用了两个寄存器——EAXECX,并且它们都被初始化为相同的值(位于地址 EBP-40h)。太好了——剩下要做的就是获取其中一个寄存器的值,我们就完成了!

……不过,没那么快。x86 寄存器稀缺,并且很可能在函数调用之间被重用。理所当然,这两个寄存器都可能被其他值覆盖,导致无法找到它们以前包含的内容。事实上,它们当前的数值没有意义。

0:000> r eax
eax=00000054
0:000> r ecx
ecx=00000000    

幸运的是,我们有 EBP 来帮忙!回想一下,为了重建堆栈,我们访问了连接堆栈上所有帧的整个 EBP 链。这意味着我们总是有任何帧的 EBP 值,而 k 命令很方便地为我们报告它。

0:000> k
ChildEBP RetAddr  
0037e4ac 76600bdd ntdll!ZwWaitForMultipleObjects+0x15
0037e548 75541a2c KERNELBASE!WaitForMultipleObjectsEx+0x100
0037e590 7545086a KERNEL32!WaitForMultipleObjectsExImplementation+0xe0
0037e5e4 764b2bf1 USER32!RealMsgWaitForMultipleObjectsEx+0x14d
0037e610 764a202d ole32!CCliModalLoop::BlockFn+0xa1
0037e690 7285d245 ole32!CoWaitForMultipleHandles+0xcd
0037e6b0 7285d1a6 mscorwks!NT5WaitRoutine+0x51
0037e71c 7285d10a mscorwks!MsgWaitHelper+0xa5
0037e73c 729142c8 mscorwks!Thread::DoAppropriateAptStateWait+0x28
0037e7c0 7291435d mscorwks!Thread::DoAppropriateWaitWorker+0x13c
0037e810 729144e1 mscorwks!Thread::DoAppropriateWait+0x40
0037e86c 727b5422 mscorwks!CLREvent::WaitEx+0xf7
0037e880 728e98e2 mscorwks!CLREvent::Wait+0x17
0037e90c 729136e0 mscorwks!AwareLock::EnterEpilog+0x8c
0037e928 72913664 mscorwks!AwareLock::Enter+0x61
0037e9c8 003f0b68 mscorwks!JIT_MonEnterWorker_Portable+0xb3
WARNING: Frame IP not in any known module. Following frames may be wrong.
0037ea28 5933407c 0x3f0b68
0037ea44 59666146 System_Windows_Forms_ni+0x72407c
0037eaf0 58e086a0 System_Windows_Forms_ni+0xa56146
0037eaf8 58e08621 System_Windows_Forms_ni+0x1f86a0    

现在生活很轻松。我们只需要从这个值中减去 0x40,然后在该地址找到传递给 Monitor.Enter 的参数。

0:000> dd 0037ea28-40 L1
0037e9e8  027089b4
0:000> !do -nofields 027089b4
Name: System.String
MethodTable: 71f20b70
EEClass: 71cdd66c
Size: 44(0x2c) bytes
 (C:\Windows\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
String: SecondaryLock    

现在我们得到了一个看起来像对象的東西,并且我们可以通过检查进程的同步块(使用 !SyncBlk 命令)来验证该对象是否确实用于同步。

0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info  SyncBlock Owner
   16 004d61a4            3         1 00497728  2a88   0   02708990 System.String
   17 004d61d4            3         1 004eb4d8  2504   5   027089b4 System.String
...snipped...    

找到了!我们不仅找到了线程正在等待的对象,还找到了它的拥有线程,这使得进一步重建应用程序的等待链成为可能。

请注意,上述方法之所以有效,是因为调用 Monitor.Enter 的函数将其本地变量传递给它,该变量位于堆栈上。类似的方法也可以用于函数参数,但可能存在更复杂的情况,即参数在调用函数的堆栈帧中不易获得。尽管如此,根据我们对 Monitor.Enter 的了解,即它通过 ECX 寄存器接收其参数,我们可以检查 Monitor.Enter 的反汇编。

0:000> u mscorwks!JIT_MonEnterWorker_Portable
mscorwks!JIT_MonEnterWorker_Portable:
729135bc 6a7c            push    7Ch
729135be b8cc13ca72      mov     eax,offset mscorwks! ?? ::FNODOBFM::`string'+0x1ccc4 (72ca13cc)
729135c3 e8c1eae9ff      call    mscorwks!_EH_prolog3_catch (727b2089)
729135c8 894dec          mov     dword ptr [ebp-14h],ecx
729135cb 33db            xor     ebx,ebx
729135cd 8d8d78ffffff    lea     ecx,[ebp-88h]
...snipped...    

很早就,Monitor.Enter 将参数存储在堆栈上(这通常称为“参数溢出”),我们可以期望从中检索它。事实上,JIT_MonEnterWorker_Portable 帧的 EBP 值为 0037e9c8,参数地址位于该位置偏移 -0x14 处。

0:000> dd 0037e9c8-14 L1
0037e9b4  027089b4    

查找引用您对象的静态根

使用 SOS 进行典型的内存泄漏分析会话涉及识别一堆被泄漏(未释放)的对象,然后识别从某个 GC 根指向它们的引用链。这是一个相当繁琐的过程(分析器在这方面做得更好),而且更糟的是,有时实际的根信息不可用。一种这样的情况是当根是 static 变量时。

static GC 根保留的托管对象的典型根引用链会有一个固定对象数组出现在被根对象中。下面是一个典型的引用链。(注意,我在这里使用 x64 示例——这使得内存搜索阶段更有趣,也为示例带来了一些异质性。)

0:010> !gcroot 0000000002bcaf58 
...snipped...
DOMAIN(0000000000C1C5F0):HANDLE(Pinned):5017f8:Root:0000000012761018(System.Object[])-> 
00000000039b3c30(System.EventHandler)-> 
0000000002bcab38(System.Object[])-> 
0000000002bcf8d8(System.EventHandler)-> 
0000000002bcaf58(FileExplorer.MainForm+FileInformation)    

这个对象数组无处不在,似乎所有 static 根引用都源于它。事实上(这是 CLR 的实现细节),static 字段存储在这个数组中,并且它们相对于 GC 的保留就是通过它。这也使得确定哪个类的哪个 static 字段负责 static 引用变得困难。例如,在上面的引用链中,很明显有一个 EventHandler 类型的 static 字段(可能是事件)保留了 FileInformation 实例——但找到该 static 字段的详细信息非常可取。

六年多前,Doug Stewart 撰写了一篇 简短博客文章,概述了这些情况下的通用过程。这个过程通常有效,但在 64 位时代需要一些调整,所以我们继续。首先,让我们看一下那个被根引用的数组。

0:010> !do 0000000012761018 
Name: System.Object[] 
MethodTable: 000007fef68858f8 
EEClass: 000007fef649eb78 
Size: 8192(0x2000) bytes 
Array: Rank 1, Number of elements 1020, Type CLASS 
Element Type: System.Object 
Fields: 
None    

好的,这是一个包含 1020 个元素的数组,其中一个元素一定是我们的事件处理程序。是这样吗?让我们搜索它的内存并确保。

0:010> s -q 0000000012761018 L2000 00000000039b3c30 
00000000`12762e10  00000000`039b3c30 00000000`0278b380    

果然,我们的事件处理程序是数组元素之一,地址是 00000000`12762e10。现在有两个关键的观察。

  1. EventHandler 实例以某种方式进入了数组。也许如果我们能找到这个数组地址的其他引用,我们就能找到谁把它放进去了,然后确定它是谁的 static 字段。
  2. 从那个 EventHandler 实例有一个指向我们应用程序对象的引用(最终)。那么,应该有对这个数组地址的附加引用,它们构成了指向我们应用程序对象的引用链。

坦白说,这两种方法都有点碰运气,因为地址可能是动态计算的,但让我们试试吧。Doug 最初的指导是启动一个内存搜索来查找对数组位置的任何引用,这在 32 位地址空间中可能只需要几秒钟;但对于 64 位地址空间来说,那就不是那么回事了!

但是,我们只在托管代码中查找引用,因此无需遍历整个地址空间。查看当前 AppDomain 中模块的地址范围就足够了。

0:010> !dumpdomain 
...snipped... 
-------------------------------------- 
Domain 1: 0000000000c1c5f0 
LowFrequencyHeap: 0000000000c1c638 
HighFrequencyHeap: 0000000000c1c6c8 
StubHeap: 0000000000c1c758 
Stage: OPEN 
SecurityDescriptor: 0000000000c1de90 
Name: FileExplorer.exe 
Assembly: 0000000000c3cd80 [C:\Windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll]
ClassLoader: 0000000000c3ce40 
SecurityDescriptor: 0000000000c3cc40 
  Module Name 
000007fef6461000 C:\Windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll
000007ff000f2568 C:\Windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\sortkey.nlp
000007ff000f2020 C:\Windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\sorttbls.nlp
Assembly: 0000000000c57480 [D:\courses\NET Debugging\Exercises\4_MemoryLeak\Binaries\FileExplorer.exe] 
ClassLoader: 0000000000c57540 
SecurityDescriptor: 0000000000c57390 
  Module Name 
000007ff000433d0 D:\courses\NET Debugging\Exercises\4_MemoryLeak\Binaries\FileExplorer.exe 
...many more of these guys...    

现在我们有了一对模块地址,可以限制我们的内存搜索范围。从 7ff`00000000 开始,遍历几百兆字节查找我们的地址似乎是安全的。总的来说,正确的 WinDbg 命令应该是。

0:010> s -q 000007ff`00000000 L?00000000`40000000 00000000`12762e10    

(...回想一下,我们正在寻找一个完整的 QWORD。)问题是我们可能会错过对该地址的未对齐引用,当它硬编码到某个指令中时(例如 MOV)可能会发生这种情况。所以,我们应该寻找单个字节序列,并记住我们在小端架构上。

0:010> s -b 000007ff`00000000 L?00000000`40000000 10 2e 76 12
000007ff`001913d3  10 2e 76 12 00 00 00 00-48 8b 00 48 89 44 24 60  ..v.....H..H.D$` 
000007ff`00191440  10 2e 76 12 00 00 00 00-48 8b d0 e8 60 c1 87 f7  ..v.....H...`...    

看!两个对数组位置的引用,现在让我们用 !U 命令看看它们代码。

0:010> !u 000007ff`001913d3 
Normal JIT generated code 
FileExplorer.MainForm+FileInformation..ctor(System.String) 
Begin 000007ff001912d0, size 18d 
...snipped...
000007ff`001913d0 90              nop 
000007ff`001913d1 48b8102e761200000000 mov rax,12762E10h 
...snipped...
000007ff`0019143e 48b9102e761200000000 mov rcx,12762E10h 
000007ff`00191448 488bd0          mov     rdx,rax 
...snipped...    

它们都匹配 FileInformation 构造函数中的内容,这为我们提供了查找方向。事实上,这里是显示事件注册序列的源代码。

public FileInformation(string fullPath)
{
    Path = fullPath;
    Name = System.IO.Path.GetFileName(Path);
    FirstFewLines = File.ReadAllLines(Path).Take(100).ToArray();
    FileInformationNeedsRefresh += FileInformation_FileInformationNeedsRefresh;
}    

结论

希望您现在更加确信,基本的汇编阅读技能、调用约定知识以及对堆栈结构的熟悉程度,在调试 .NET 应用程序或分析崩溃转储时可以提供实际的好处。

汇编阅读技能不会自动获得;您必须经常练习它们。最好的方法是编译一组与上面类似的示例,并定期进行练习。如果敏捷开发人员提倡代码 katas 来练习 TDD,为什么我们不能有反汇编 katas 来练习我们的汇编阅读技能呢?

延伸阅读

© . All rights reserved.