不用VS调试.Net

将来,任何开发人员都将需要调试应用程序,并且将无法访问Visual Studio,在某些情况下甚至无法访问源代码。例如,在生产web或应用服务器上调试问题时,我真的不想安装Visual Studio并跨所有源代码进行复制;这是不实际的,有时甚至是不允许的。正是在这种时候,我们需要另一个工具,一个调试windows应用程序的工具,而微软正好提供了一系列这样的调试器,非常适合这种情况。在本文中,我将解释哪些调试器可用,以及在Visual Studio不实用或不可用的情况下,如何使用它们来简化调试.NET应用程序的过程。在本文中,我将解释哪些调试器可用,以及如何调试一个简单的、相当常见的示例。我希望这将展示如何以简单直接的方式调试代码。

何必费心

总的来说,如果您在开发人员机器上工作并且能够重现报告的问题,那么在Visual Studio中进行调试是最容易的。但是,正如我在介绍中所暗示的,有很多原因使您不能总是使用Visual Studio,以及您应该学习和理解替代方案:

  • Visual Studio崩溃-虽然不是常见事件,但每个开发人员都知道VS有时会崩溃,而且通常是在您最需要它的时候。WinDBG/cdb偶尔会崩溃,但很少,如果有问题的话,下载一个旧的或更新的版本应该很简单
  • 速度—如果您很匆忙,只想快速看到一些东西,那么启动cdb只需启动Visual Studio所需时间的一小部分,而且占用的空间要小得多。
  •  控制——调试工具提供了广泛的命令和选项,允许对调试过程进行细粒度的控制。例如,可以在加载的特定模块上设置断点,或者更改应用程序的数据和代码(即,可以在运行时对应用程序应用一次性修补程序!).
  •  
    免费-调试器和SOS是免费的,您可以从Microsoft站点下载它们,并且它们会定期更新。因此,当Visual Studio不可用时,它们可以提供非常有用的替代方案。
  • 崩溃转储-创建和查看崩溃转储非常简单,因此您可以从客户处获取转储,或者在拍摄快照以进行后续调试时仅短暂中断实时应用程序上的服务。可以调试从任何常用工具(如DrWatson、ADPlus和debug Diag)获取的转储。
  • SOS包含各种帮助函数——这些函数允许您调试.NET死锁并列出内存中的所有对象,同时能够找到创建这些对象的原因以跟踪内存泄漏。
  • 远程调试要简单得多——只要在服务器和客户端安装工具附带的Remote.exe,就可以了。
  • X64支持-使用调试工具调试X64应用程序没有问题,而使用Visual Studio进行调试则有问题。

与调试器会面

有两种类型的调试器:内核和用户模式。内核调试器用于调试驱动程序和Windows内核。用户模式调试器用于应用程序和服务。我们对用户模式调试器感兴趣,在Windows调试工具包中有两个:WinDbg,它是基于GUI的(具有大约90年代的接口)和cdb,它是一个命令行工具。
这两个调试器都提供了dbgEng.dll的包装器,dbgEng.dll实际上完成了调试。所有调试器的命令和响应都是相同的,所以只需选择您喜欢的工具并坚持使用它。我更喜欢cdb,因为我喜欢它只在准备好接受输入时给您一个提示。另一方面,WinDbg提供了堆栈和可变窗口等功能,让您可以在它准备好之前愉快地输入。调试器是程序集调试器。它们允许您控制正在调查的进程、设置断点以及查看程序集代码中的线程和变量。这意味着您需要调试和理解机器代码、调用约定、堆栈、堆和内存等。调试器以汇编语言提供源代码中的符号,这允许您获取行位置并查看不同的结构和类,但它仍然相当复杂。即使有符号,也必须确保它们是用可执行文件编译的,否则它们将不匹配,并将给您带来奇怪的结果。
幸运的是,这场噩梦可以在一定程度上避免,因为微软的一些好人决定帮助开发人员社区,并在.NET框架sos.DLL中发布一个助手DLL。这个名字真是太贴切了。这个DLL可以由上面提到的任何调试器加载,并理解CLR是如何工作的。所有.NET程序在运行时都提供了丰富的信息,至少与本机应用程序相比,我们可以利用这些信息,因此.NET调试非常简单。
现在花点时间了解一些简单的过程真的会有好处,如果你和我一样,你会认为没有VS的调试非常酷,有助于你彻底了解.NET框架是如何工作的。

调试环境

在深入研究如何实际调试代码之前,了解运行.NET应用程序时发生的情况非常重要:

  •  .NETcode使用多种语言编写:C#、VB.NET或任何其他符合CLI的语言
  • 代码被编译为通用的MSIL格式
  • 在运行时,或者如果是NGEN'd,MSIL被编译为运行在其上的CPU体系结构的二进制指令(JITed),然后执行

作为一个例子,让我们看看一些执行循环并输出一些文本的C#代码,看看代码在转换成MSIL然后再转换成机器代码时的样子。我已经强调了每种格式匹配的区域。

1、c#代码

static void DoSomething()  
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine(String.Format("Number: {0}", i));
            }
        }

2、MSIL Version:

.method private hidebysig static void  DoSomething() cil managed  
{
  // Code size       43 (0x2b)
  .maxstack  2
  .locals init ([0] int32 i,
           [1] bool CS$4$0000)
  IL_0000:  nop
  IL_0001:  ldc.i4.0
  IL_0002:  stloc.0
  IL_0003:  br.s       IL_0021
  IL_0005:  nop
  IL_0006:  ldstr      "Number: {0}"
  IL_000b:  ldloc.0
  IL_000c:  box        [mscorlib]System.Int32
  IL_0011:  call       string [mscorlib]System.String::Format(string,
                                                              object)
  IL_0016:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_001b:  nop
  IL_001c:  nop
  IL_001d:  ldloc.0
  IL_001e:  ldc.i4.1
  IL_001f:  add
  IL_0020:  stloc.0
  IL_0021:  ldloc.0
  IL_0022:  ldc.i4.s   10
  IL_0024:  clt
  IL_0026:  stloc.1
  IL_0027:  ldloc.1
  IL_0028:  brtrue.s   IL_0005
  IL_002a:  ret
} // end of method Program::DoSomething

3、CPU理解的字节,即机器代码:

55 8b ec 83 ec 18 83 3d 14 2e 92 00 00 74 05 e8 c5 a3 f4 76 33 d2 89 55-fc c7 45 f8 00 00 00 00 90 33 d2 89 55 fc 90 eb 41 90 b9 38 2b 33 79 e8 40 1f 79 fd 89 45 f4 8b 05 30 20 fb 01 89 45 ec 8b 45 f4 8b 55 fc 89 50 04 8b 45 f4 89 45 e8 8b 4d ec 8b 55 e8 e8 ee 72 13 76 89 45 f0 8b 4d f0 e8 cb 36 61 76 90 90 ff 45 fc 83 7d fc 0a 0f 9c c0 0f b6 c0 89 45 f8 83-7d f8 00 75 ac 90 8b e5 5d c3
调试器解码字节并显示程序集,如下所示。一开始这有点神秘,但仍然比在步骤3中看到的机器代码可读性强得多。您会注意到第二列显示了上面列出的字节,558b ec
031800a8 55              push    ebp  
031800a9 8bec            mov     ebp,esp
031800ab 83ec18          sub     esp,18h
031800ae 833d142e920000  cmp     dword ptr ds:[922E14h],0
031800b5 7405            je      031800bc
031800b7 e8c5a3f476      call    mscorwks!JIT_DbgIsJustMyCode (7a0ca481)
031800bc 33d2            xor     edx,edx
031800be 8955fc          mov     dword ptr [ebp-4],edx
031800c1 c745f800000000  mov     dword ptr [ebp-8],0
031800c8 90              nop
031800c9 33d2            xor     edx,edx
031800cb 8955fc          mov     dword ptr [ebp-4],edx
031800ce 90              nop
031800cf eb41            jmp     03180112
031800d1 90              nop
031800d2 b9382b3379      mov     ecx,offset mscorlib_ni+0x272b38 (79332b38) (MT: System.Int32)
031800d7 e8401f79fd      call    0091201c (JitHelp: CORINFO_HELP_NEWSFAST)
031800dc 8945f4          mov     dword ptr [ebp-0Ch],eax
031800df 8b053020fb01    mov     eax,dword ptr ds:[1FB2030h] ("Number: {0}")
031800e5 8945ec          mov     dword ptr [ebp-14h],eax
031800e8 8b45f4          mov     eax,dword ptr [ebp-0Ch]
031800eb 8b55fc          mov     edx,dword ptr [ebp-4]
031800ee 895004          mov     dword ptr [eax+4],edx
031800f1 8b45f4          mov     eax,dword ptr [ebp-0Ch]
031800f4 8945e8          mov     dword ptr [ebp-18h],eax
031800f7 8b4dec          mov     ecx,dword ptr [ebp-14h]
031800fa 8b55e8          mov     edx,dword ptr [ebp-18h]
031800fd e8ee721376      call    mscorlib_ni+0x1f73f0 (792b73f0) (System.String.Format(System.String, System.Object), mdToken: 060001bd)
03180102 8945f0          mov     dword ptr [ebp-10h],eax
03180105 8b4df0          mov     ecx,dword ptr [ebp-10h]
03180108 e8cb366176      call    mscorlib_ni+0x6d37d8 (797937d8) (System.Console.WriteLine(System.String), mdToken: 060007c8)
0318010d 90              nop
0318010e 90              nop
0318010f ff45fc          inc     dword ptr [ebp-4]
03180112 837dfc0a        cmp     dword ptr [ebp-4],0Ah
03180116 0f9cc0          setl    al
03180119 0fb6c0          movzx   eax,al
0318011c 8945f8          mov     dword ptr [ebp-8],eax
0318011f 837df800        cmp     dword ptr [ebp-8],0
03180123 75ac            jne     031800d1
<?XML:NAMESPACE PREFIX = SKYPE /> 03180125 90               nop
03180126 8be5            mov     esp,ebp
03180128 5d              pop     ebp
03180129 c3              ret

在没有Visual Studio的情况下进行调试时,需要认识到的重要一点是,您正在查看的代码不再是用编写它的CLI语言编写的。它已经被优化,删减,并变成CPU可以理解的东西。虽然这听起来有点吓人,但相信我,当我说在这个级别上使用SOS进行调试是容易和有趣的。

我们来调试一下

为了调试这个级别的代码,了解汇编语言的基本知识是很有帮助的。当你开始的时候,你可以不用它,但是你对汇编语言、过程架构和CLR的了解越多,你就会发现调试越快,越容易。
我已经编写了一个测试控制台应用程序,并提供了一些示例。我们将使用第一个测试,这将导致抛出由空catch块捕获的异常。当您启动example1.exe应用程序,然后按1再按enter,您将看到菜单被重新打印,看起来好像没有发生什么事情,但实际上,一个异常正在被抛出、捕获和吞没。执行吞咽操作的代码如下所示:

try  
  {   
AppSettingsReader asr = new AppSettingsReader();
      string date = 
asr.GetValue("DateFormat", typeof (string)).ToString();
      RunCommand(date);
   }catch(Exception)
   {
       //HA HA HA No one can hear you scream in here!!!!!!
   }

面对这种情况,我们需要附加一个调试器。如果您还没有调试程序,请继续下载windows的调试工具。我总是将我的安装到c:\调试器以便于使用,但是可以在任何您喜欢的地方安装它们。我还创建了一个批处理文件doDebug.cmd来设置我的符号路径,并将cdb添加到path变量中,这样我就不必每次都键入c:\调试器:

SET _NT_SYMBOL_PATH=srv*c:\debuggers\symbols*http://msdl.microsoft.com/download/symbols;  SET PATH=%PATH%;c:\debuggers

创建此文件,并将c:\debuggers更改为任何调试器路径都将保存您的工作。

堆栈和异常

所以让我们启动example1.exe应用程序。如果运行的是Visual Studio解决方案,请确保通过选择“不调试就启动”或双击example1.exe来启动应用程序。如果在运行时选择选项1,菜单将再次显示,并且看起来没有发生任何事情。

在命令提示符下,运行doDebug.cmd文件设置环境变量,然后键入“cdb-pn example1.exe”。这将启动cdb并指示它附加到名为example1.exe的进程。启动cdb的另一种常见方法是发出命令“cdb-p 1234”,该命令附加到pid 1234。您可以附加到服务器上运行的任何进程或服务;如果某个进程位于任务管理器中,则该进程是公平的,但您需要是管理员或具有调试器用户权限:

这将启动cdb,停止example1.exe应用程序的运行,并等待您的命令。键入g,然后输入,以便运行正在调试的进程。现在,在示例程序中再次选择选项1,在cdb中,您将看到以下消息: "(b18.7a4): CLR exception - code e0434f4d (first chance)"

这意味着CLR首次出现异常。调试时,调试器在应用程序命中异常之前遇到异常。如果调试器允许应用程序继续运行,而后者正确处理异常,则应用程序将继续正常运行。如果应用程序不处理异常,调试器将再次看到它,此时称为“第二次机会”异常,应用程序将崩溃(如果允许继续)。在我们的例子中,我们有一个try…catch块,它安静地处理异常,并且不向前端报告任何内容。

现在让我们进入调试器。将焦点设置在cbd上,然后按ctrl+c键打开命令提示符。我们想让调试器在第一次出现异常(特别是CLR异常)时中断,所以输入“sxe e0434f4d”。sxe命令(大声读这个命令时要小心!)指示调试器在遇到异常时停止执行,随后的代码是上面输出的代码。按g和enter运行调试器,然后在示例应用程序中再次选择数字1。现在调试器应该在异常点处中断,所以我们来进行一些真正的调试。在这些情况下,sos.dll是我们的救星,但首先需要加载它,因此键入“.loadby sos mscorwks”(注意加载前的句号)。这应该不会出现错误,并将您返回到提示符。现在让我们看看托管堆栈跟踪,如果您输入“!CLRStack“(区分大小写),您应该看到如下内容:

内存地址可能不同,但堆栈跟踪应该相同。这将告诉我们异常发生的位置杰出的!现在我们需要看到一个例外。有很多方法可以找到异常:因为我们指示调试器在抛出异常时停止运行应用程序,所以可以使用命令“!pe“打印当前线程上最近的异常。如果你继续跑“!pe“你应该看到:

这表明我们得到了一个无效的日期时间格式,并且,和!CLRStack输出,我们知道我们在“DateTime.Parse”中,所以问题的根本原因开始变得更清楚了。让我们再做一次堆栈跟踪,但这次我们将添加“-p”,即“!CLRStack-p“,它显示传入的参数:

 

不幸的是,有时候,我们得不到想要的帮助,在这种情况下,我们看不到参数的值。然而,有一个更分散的方法来寻找参数,这是使用“!DumpStackObjects“(!dso),它将遍历堆栈并转储找到的任何对象:

您将看到,在屏幕顶部,我们得到了Exception对象,然后是一个字符串,看起来它包含一个无效的日期格式,“2009年1月32日”。答对 了!如果我们现在执行“!CLRStack-l“,调试器将列出所有变量的内存地址。我们可以通过这些查找哪个方法具有指向字符串的局部变量。我们通过读取错误字符串“0x01d03fdc”的内存地址的堆栈输出来完成此操作,该字符串位于“!dso“输出。在这里,我们可以看到它是example1.DotNetSwallowed.SwallowCommand()中的一个局部变量。

 

了解字符串的设置位置以及传递给RunCommand和DateTime.Parse的内容后,可以通过深入源代码找出日期格式不正确的原因。如果您没有访问源代码的权限,则可以使用.NET Reflector,即使代码被混淆,您仍然有正确的类和方法名称,因此应该相当直接。

查看对象

使用这个简单的方法,我们已经学会了如何在异常上中断,检查CLR堆栈并列出堆栈上的所有对象。还有一个更重要、更常见的任务我们还没有看到,那就是检查特定的对象。在.NET中,有两种变量:引用类型和值类型。查看引用类型很简单;您只需使用命令“!DumpObject“,简称”!do”。
当我们调用“!do”,我们需要传递一个对象的地址。如果从最后一步(在我的示例中为“0x01d03fdc”)中获取地址并将其传入,则可以看到底层对象结构:

这显示了System.String对象中的文本,我还突出显示了Offset列,因为与sos.dll一样,它也不是魔术。它通过查找方法表来检索对象的类型,方法表基本上描述了类型在内存中的布局方式。方法表地址存储在对象的前4个字节中。
我们现在要研究如何!do,所以如果不是用!do address,请输入“dc address”。注意,没有感叹号或句号;dc是用于转储内存的本机调试器命令,显示dwords(或Int32):

绿色突出显示的文本是方法表,应该在您执行时列出“!do”。显示的数字是十六进制的,因此19转换为25,18转换为24,依此类推。所以,如果你知道你想转储一个字符串,但不想使用SOS,那么因为到第一个字符(m_fisrchar)的偏移量是+0xC(或者对你我来说是12个字节),你可以这样做:du memoryAddress+0xC(du是d的另一种格式,它转储unicode字符串):

你可以看到字符串,我们甚至不需要使用SOS。SOS可以帮助我们解码结构,但重要的是要记住,如果必须的话,我们仍然可以手动解码。希望这显示了sos.dll如何解码特定的引用类型。现在,我提到了还有一些值类型。可能是由于性能原因,它们不是通过引用传递的,因此方法表不会随变量一起传递。因此,我们需要告诉SOS类型是什么。如果您不知道类型是什么,那么恐怕您运气不好,只能使用“dc address”,它将为您提供实际的字节和字符串表示形式。如果你用的话!dso获取“example1.DotNetSwallowed”对象的内存地址,然后执行“!do address“你得到:

它有一个初始化的变量,偏移量是4,所以尝试执行“!do address+0x4“,您应该会得到以下错误:Note: this object has an invalid CLASS field

无效对象

这意味着它没有方法表。我们可以使用dotnswallowed被丢弃时的细节,SOS为我们做了这项工作,但是让我们自己来展示它是如何工作的。我们知道的是它是一个“System.Boolean”,所以要查找方法表,我们使用我最喜欢的SOS命令之一“!Name2EE”(或者我喜欢称之为Name 2 Ed Elliott ):

 这将遍历所有寻找类型的模块。如果您知道它在哪个模块中,则可以使用它而不是*。当你有了方法表,你就可以用“!DumpVC”转储值类型:

 

这将显示值类型的值,在本例中为1,为true。

断点

设置断点的能力是基本的调试要求。cdb可以设置它们,sos.dll也可以,但实际的断点是使用本机地址而不是MSIL地址设置的。要设置断点,可以使用“!bpmd“命令,它接受模块和方法名或“方法描述”。
方法描述由一系列SOS命令使用,因此此时讨论它们很有用。获取方法描述有多种方法;第一种方法是使用“Name2EE”获取方法表,然后获取与该类型关联的方法描述的列表。例如,如果你回到国开行,并做:“!Name2EE*example1.DotNetSwallowed“,它将显示方法表的地址。相反,做“!DumpMT -md“,它转储方法表并列出方法描述:

这将显示类型上的方法,以及它们是否已编译为二进制(Jit或PreJit)。让我们继续并转储一个方法描述。我们将使用swentcommand md并执行“!DumpMD address“:

如果代码已经过JIT处理,那么在CodeAddr下,我们得到二进制字节的地址,并且可以使用本机cdb命令使用“bp”设置断点。例如,在这种情况下,我们可以使用“bp 01ae0218”。如果不是JIT,或者你想使用SOS,就执行“!bpmd-md methodDesc“,使用以绿色突出显示的方法描述。它应该显示它正在设置断点。若要验证是否已设置一个断点,请使用cdb命令“bl”列出所有断点。
设置断点后,按“g”,然后在示例应用程序中选择选项1。这一次,您将看到调试器在异常发生之前中断,并显示消息“Breakpoint 0 hit”。做一个“!CLRStack“来确认你的位置并得到一个变量列表。最简单的事情就是“!所以你可以看到堆栈上的所有对象,包括“this pointer”,它将作为类本身的名称出现在堆栈中,所以你可以把它转储出来,看看它处于什么状态。要删除断点,只需执行“bc X”,其中X是断点号。你也可以做“bd”来禁用它,直到你调用“be”。 

查看IL code

如果我们有方法描述,我们可以使用命令“!DumpIL methodDescriptionAddress“轻松查看MSIL

你看到的代码,比如ldloc.1,在MSDN上有文档记录(Link: http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes_fields.aspx),在“OpCodes Fields”下

无痛调试

好了,给你使用一些简单的命令,我们已经能够找出为什么一个程序什么也不做通过检查正在吞噬的异常,我们可以看到它是一个无效的日期格式。然后我们可以获取这些信息,找出字符串的来源并修复它。要总结此过程,请再次执行以下步骤:

  1. 附加调试器–cdb-pn example1.exe
  2. 告诉调试器中断CLR异常–sxe e0434f4d
  3. 看看例外情况吧– !pe
  4. 寻找堆栈上可能解释它的对象– !dso
  5. 看一个特定的引用类型– !do address
  6. 为值类型查找方法表– !Name2EE * System.Boolean
  7. 显示该类型和变量的值– !DumpVC methodTable address
  8. 使用方法表查找方法描述– !DumpMT -md methodTable
  9. 设置一个断点– !bpmd -md方法描述
  10. 看看MSI– !DumpIL methodDescription
我已经展示了一些本机调试器和SOS命令,以及如何使用它们来理解和检查失败的过程。一开始这似乎有点让人难以接受,但它是直截了当的,而且有很好的记录。

Visual Studio中的sos.dll

必须记住,SOS不是调试工具的一部分;它是.NET框架的一部分,现在甚至随每个版本一起提供。虽然不像在cdb中那样优雅和易于使用,但是可以在Visual Studio中使用即时窗口并键入“.load C:\ Windows\Microsoft.NET\Framework\v2.0.50727\sos.dll”(更改正在使用的框架版本的路径)来加载帮助程序。
SOS有许多令人兴奋的特性,我鼓励任何人进一步研究使用SOS.dll进行调试,因为它在某些时候会派上用场。如果您有兴趣了解更多信息,那么这些功能是一个很好的开始:

  • !DumpHeap -stat“–列出内存中的所有对象,并显示每种类型使用的内存数量和数量,以便跟踪内存泄漏。
  • “!GCRoot [address]“–查找引用特定对象的位置,以便轻松跟踪内存泄漏
  • !SyncBlk“–显示线程等待锁诊断死锁的位置。
  • !Help [command]“关于如何使用工具附带的每个函数的非常方便的参考和示例。
  • !Help faq“看看,这是我从微软看到的最有用的帮助命令之一。

推荐阅读

调试器附带的帮助文件是非常宝贵的信息源,有许多不同的调试器命令和使用它们的方法,通常至少有几种方法可以完成所有任务。

posted on 2019-11-11 19:28  活着的虫子  阅读(489)  评论(0编辑  收藏  举报

导航