[转帖]解密微软中间语言MSIL之调试程序(1)

没有程序员敢保证没有经过调试的代码绝对没有错误,无论他/她智商多么高,开发出来的代码总是或多或少带有一些错误(当然是无意的:-))。这些错误可能是简单的语法错误或者复杂的逻辑错误。因此和其他语言一样,我们需要中间语言的调试工具/方法。由于中间语言是比较底层的语言,因此调试工具/方法对于程序员来说更加重要。

最简单的调试方法莫过于在程序中加入WriteLine方法,但是在中间语言中使用这种方法非常繁琐,因为调用WriteLine方法需要三行代码:

ldstr      "Hello World"
call       void [mscorlib]System.Console::WriteLine(string)
ret
 


如果你需要调试一个比较大的应用程序,显然上面的方法行不通。幸运的是,微软在.Net中提供了两个调试工具用于调试.Net的程序集。


调试工具


.Net提供了两个非常好的调试工具,分别是CLR调试器和运行库调试器:

· CLR调试器(DbgCLR.exe):提供图形界面帮助开发者调试程序。

· 运行库调试器(Cordbg.exe):使用运行库调试API,通过命令行对程序进行调试。

初看这两个工具提供的是相同的功能。但是事实上它们的功能还是有所区别的。DbgCLR.exe是一个Windows应用程序,提供了用户界面,并且很容易定义断点和即时窗口;而Cordbg.exe使一个命令行工具,它允许开发人员通过调试脚本的方式来调试程序。本文侧重于介绍DgbCLR.exe。


CLR调试器


为了展示CLR调试器的功能,我们需要编写一个带有错误的程序。下面是一个C#的程序,编译后我们将通过利用ildasm.exe获得它的中间语言代码。

using System;
namespace ErrorneousApp
{
    class ErrorneousClass
    {
        [STAThread]
        static void Main(string[] args)
        {
            int operand1;
            int operand2;
            int sum;
            operand1 = int.Parse(args[0]);
            operand2 = int.Parse(args[1]);
            sum = Add(operand1 , operand2);
            Console.WriteLine(sum);
        }
        private static int Add(int op1, int op2)
        {
            // 很明显的逻辑错误 :-)
            return 5 * (op1 + op2);
        }
    }
}
 


我们可以看到在这段代码中存在两个错误:

· 逻辑错误:Add方法返回了不正确的值

· 方法错误:如果调用方法是没有提供两个参数,程序将报错。

需要提醒大家的是,CLR调试器能够调试的错误远远不止以上两种。现在编译这段代码,生成可执行文件,然后用反汇编工具生成中间语言,其中去除了一些不重要的代码:

.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89)  .ver 1:0:3300:0
}
.assembly ErrorneousApp
{
  .ver 1:0:1026:17140
}
.module ConsoleApplication1.exe
.namespace ErrorneousApp
{
  .class private auto ansi beforefieldinit ErrorneousClass
         extends [mscorlib]System.Object
  {
    .method private hidebysig static void
            Main(string[] args) cil managed
    {
      .entrypoint
      .locals init ([0] int32 operand1, [1] int32 operand2, [2] int32 sum)
        ldarg.0
        ldc.i4.0
        ldelem.ref
        call       int32 [mscorlib]System.Int32::Parse(string)
        stloc.0
        ldarg.0
        ldc.i4.1
        ldelem.ref
        call       int32 [mscorlib]System.Int32::Parse(string)
        stloc.1
        ldc.i4.5
        ldloc.0
        ldloc.1
        add
        mul
        stloc.2
        ldloc.2
        call       void [mscorlib]System.Console::WriteLine(int32)
        ret
    } // end of method ErrorneousClass::Main

    .method private hidebysig static int32
            Add(int32 op1, int32 op2) cil managed
    {
      .locals init ([0] int32 CS$00000003$00000000)
        ldc.i4.5
        ldarg.0
        ldarg.1
        add
        mul
        stloc.0
        ldloc.0
        ret
    } // end of method ErrorneousClass::Add
    .method public hidebysig specialname rtspecialname
            instance void  .ctor() cil managed
    {
        ldarg.0
        call       instance void [mscorlib]System.Object::.ctor()
        ret
    } // end of method ErrorneousClass::.ctor
  } // end of class ErrorneousClass
} // end of namespace ErrorneousApp
 


在使用代码以前先解释一下代码。我们使用了Consle.WriteLine和Int.Parse方法,这两个方法定义在外部程序集mscorlib中,因此我们需要创建一个对它们的引用。通过使用带external参数的assembly命令我们可以达到这个目的。

然后代码中通过class命令定义了ErrorneousClass类,并在该类中用method命令创建了Main方法,该方法是程序的入口方法。接着用local命令初始化了三个本地变量:

.locals init ([0] int32 operand1, [1] int32 operand2, [2] int32 sum)
 


接下来需要给这些本地变量赋值。代码通过ldelm命令从程序的参数数组中提取相应的值赋给变量,但是在赋值之前需要用ldarg将参数(由指定索引值引用)加载到堆栈上,然后ldc命令将真正的值推送到计算堆栈上。ldc.i4.0的表示将0作为int32类型推送到计算堆栈上。接下来代码调用System.Int32.Parse方法将字符串转换为整数。当所有的变量都完成初始化后,代码调用Add方法计算operand1和operand2的和。最后通过调用System.Console.WriteLine方法来显示计算结果。

Add方法的实现也很简单。需要提醒大家的是由于MSIL工作在基于堆栈的内存结构上,因此最后使用的变量需要最先保存。由于算法是将两个数相加在乘以5,因此需要先用ldc.i4.5命令将5放入堆栈中,然后加载两个被操作的数,使用add命令计算它们的和(add命令自动将堆栈中最顶层的两个数相加),将得到的结果放回堆栈中。最后代码调用mul命令将两个数相乘。

如果仔细察看中间代码,我们会发现在Main方法中并没有直接调用Add方法。这是因为C#编译器用内嵌代码替代了对静态方法的调用。


调试MSIL代码


调试中间代码的同时我们需要程序数据库文件ErrorneuosApp.pdb,在该文件中包含了调试和工程状态信息。我们可以利用ilasm工具来获得该文件。

ilasm errorneousApp.il /debug
 


现在运行DbgClr.exe并打开ErroneousApp.il文件(如图一所示),然后设定需要调试的可执行文件(如图二所示)。现在就可以调试程序了。开发人员可以设定断点,查看寄存器和内存中的值等。下面让我们一一了解这些功能。


图一 CLR调试器



图二 选择要调试的程序

解密微软中间语言MSIL之调试程序(2) Xinsoft,2004-03-02 08:34:14

中断程序的执行

CLR调试器最基本的目的是显示被调试程序的状态信息。有很多工具可以监视和修改程序的状态,但是大部分的工具在程序中断时才能够使用。调试器在程序运行到一个断点或遇到错误时将中断程序的运行。

开发人员可以点击中间代码中任何可执行的行左边的空白处就可以设置位置断点了;也可以通过菜单上调试->新断点来设置位置断点(如图三所示)。第二种方法允许开发人员设定带有条件的位置断点,开发人员可以设定三种类型的断点:

· "函数断点"使程序在执行到达指定函数内的指定位置时中断。

· "文件断点"使程序在执行到达指定文件内的指定位置时中断。

· "地址断点"使程序在执行到达指定的内存地址时中断。


图三 设置新断点

开发人员还可以根据设定条件判断是否需中断程序,或者根据断点的命中次数来确定是否在断点中断程序(点击次数是断点被点击的次数。对于位置断点,它是程序执行达到指定位置并满足可能有的断点条件的次数,命中次数决定执行中断前点击发生的次数)。当程序执行到断点位置时,调试器就会就会计算实现设定好的表达式。如果表达式的结果为真(在"断点条件"对话框中是"为真")或者表达式的值更改时(在"断点条件"对话框中为"已更改")则调试器点击断点,如果点击断点并且点击次数正确,调试器将中断程序执行。

局部变量窗口

在局部变量窗口中显示了在当前上下文关系中的局部变量变量和它们的值。开发人员可以通过选择调用堆栈或线程来切换上下文。使用局部变量窗口的好处在于能够在程序运行的时候改变某个变量的值,开发人员只需要双击变量的值,输入新的值就可以了。


图四 局部变量窗口


快速监视窗口

开发人员可以通过快速监视窗口获得一个变量或者表达式的值。

监视窗口

监视窗口同快速监视窗口的功能类似,唯一不同的是快速监视窗口是模态窗口。

寄存器窗口

寄存器窗口中显示当前寄存器中的状态。最近被改变了的寄存器值用红色显示。

图五 寄存器窗口


调用堆栈窗口

通过调用堆栈窗口,开发人员可以看到当前在堆栈中的方法或函数。除了方法或函数的名称外,窗口中还提供了诸如参数名称和类型、参数值、行号和偏移量等信息。


图六 调用堆栈窗口


内存窗口

开发人员可以用内存窗口来监视大的缓冲区、字符串或者其他在监视窗口中无法完全显示的数据。如果开发人员希望跳转到内存的某个地址,只需要在地址栏填入相应的地址或者表达式即可。缺省情况下,内存窗口动态监视表达式,当表达式的结果发生变化时,地址窗口也会相应发生变化。

调试程序

现在我们有了这些调试工具,调试中间代码程序就变得很容易。首先打开局部变量窗口监视局部变量。如果我们输入的参数是3和4,当单步执行到sum的值从0变为35时,可以注意到代码中不仅有加法,而其还有乘法。我们可以去掉乘法来修正这个错误。修改的代码如下所示:

.method private hidebysig static void
            Main(string[] args) cil managed
    {
        …
        //ldc.i4.5
        ldloc.0
        ldloc.1
        add
        //mul
        stloc.2
        …
    } // end of method ErrorneousClass::Main
 


调试库文件

在CRL调试器中,开发人员可以逐语句执行库文件。如果需要调试库文件的话,开发人员需要一个测试装置(Test Harness)文件,引用了库中的类或方法的可执行文件,以及可执行文件和库文件的程序数据库(.pdb)文件。开发人员需要在CLR调试器中打开测试装置文件,然后在调试时就可以进入需要调试的外部库的代码行。


小结


在这篇文章中我们介绍了用与调试中间语言程序的调试工具。尽管中间语言平时不会经常使用到,但是对中间语言的熟悉可以帮助程序员深刻理解.Net程序是如何工作的,并且根据.Net程序的特点编写出快速高效的代码。同时中间语言也可以应用到反向工程中,当然这涉及到知识产权的问题。
posted @ 2008-08-09 12:59 gecko 阅读(...) 评论(...) 编辑 收藏