ILove's Dev Home - 休息的时候不要忘记 别人还在奔跑

  博客园 :: 首页 :: 新随笔 :: 联系 ::  :: 管理 ::
  18 随笔 :: 5 文章 :: 236 评论 :: 2 Trackbacks
前几天在看《.Net框架程序设计》的时候,好像记得书中有提到说每个对象在创建后都会有一个字段保存了一个内存地址,这个内存地址指向对象实际类型的方法表,其中维护了类型每个方法的签名以及他们的入口地址的对应关系。每次调用方法的时候会到这个表中去查找方法入口地址。而根据我之前对于程序的了解,只有虚函数才会需要保存在这个“函数指针表”中,而非虚方法因为在编译时就已经知道了函数入口地址的相对偏移量(因为确切的知道将要调用的是哪个类的哪个方法),所以最终生成的cpu call指令中可以直接得到函数入口地址(模块加载时的基地址加上偏移量就是实际的入口地址)。而虚方法在编译时无法知道具体调用的是哪个方法,所以才会用这个“虚函数指针表”来使系统能够在运行时获得要调用的是哪个方法。

上面提到“虚方法在编译时无法知道具体调用的是哪个方法”。我们看下面的代码:
        public void Fun()
        
{
            
object obj;
            
string str;

            str 
= "abc";
            str.Clone();

            obj 
= str;
            obj.ToString();
        }

当编译器遇到str.Clone时,因为这个方法不是一个虚方法,所以编译器知道调用的一定是String.Clone方法。这时候我们就可以将String.Clone方法的入口地址直接生成到cpu指令里面去。
而当编译器遇到obj.ToString()时,编译器知道ToString是一个虚函数,所以他知道无法确定这里调用的到底是Ojbect类的ToString还是某个继承类重写后的ToString。当然,你也许会觉得这个很明显,因为obj被指向了一个字符串,所以“简单推理”就可以得出这里实际上是String类的ToString函数。但是,上面的代码只是最简单的情况。看下面这个稍微复杂点的:
        public void Fun()
        
{
            
object obj;

            obj 
= this.GetObject();
            obj.ToString();
        }


        
public object GetObject()
        
{
            
switch(DateTime.Today.DayOfWeek)
            
{
                
case DayOfWeek.Monday:
                   
return "a";

                
case DayOfWeek.Saturday:
                    
return new System.Collections.ArrayList();

                
default:
                    
return new System.Data.DataSet();
            }

        }

如果是这种情况,那编译器又该如何去分析呢?很容易看出,GetObject返回的到底是什么类型,只有执行了之后才知道,而且每次执行的结果还可能不同。因此编译器是无论如何也无法确定返回的是什么类型,自然也就无法确认obj.ToString()到底调用的是哪个类型的ToString方法。

所以,系统使用了一个叫做“虚函数指针表”的东西来使得程序在运行时可以调用到正确的方法,比如上面的代码,如果GetObject返回的是字符串,则obj.ToString实际上就会调用String.ToString,如果GetObject返回的是ArrayList,那么obj.ToString实际上就会调用ArrayList.ToString方法。

任何一个包含有虚方法的类型都对应有一个虚函数指针表。因为object类本身就定义了虚方法,而C#所有的类型都必须继承自object,因此所有的类都会有一个虚方法指针表。这个虚函数指针表中,保存了每个虚函数的签名和他的入口地址的对应关系。如果一个类型重写了基类的某个虚方法,那么就会在他的虚函数指针表中改写方法的入口地址,否则就把基类中这个虚函数的入口地址复制过来。如下面的代码:
    public class MyClass : Object
    
{
        
public override int GetHashCode()
        
{
            
return base.GetHashCode();
        }


        
public override string ToString()
        
{
            
return base.ToString();
        }

    }


    
public class AnotherClass : MyClass
    
{
        
public override string ToString()
        
{
            
return base.ToString();
        }

    }


假设object类型的虚函数指针表是这样的(内存地址是假设的,函数入口的偏移量,相对于模块加载时的基地址):
Equals 0x0001
GetHashCode 0x0002
ToString 0x0003
因为MyClass重写了GetHashCodeToString,所以MyClass的虚函数指针表会是这样的:
Equals 0x0001
GetHashCode 0x0004
ToString 0x0005
AnotherClass又重写了MyClass的ToString,所以AnotherClass的虚函数指针表是这样的:
Equals 0x0001
GetHashCode 0x0004
ToString 0x0006

在创建每个对象的时候,对象都会自动包含一个指针字段,该指针指向其所属类型的虚函数指针表的地址。比如第二段代码中的GetObject方法内,如果返回的是ArrayList,则obj实际上是一个ArrayList类型的实例。因此他的这个指针中保存的是ArrayList类的虚函数指针表地址。

在遇到obj.ToString这样的虚函数调用时,可以这样得到真正的函数方法(假设obj是MyClass类型):
1、从obj的内存中取得虚函数指针表的地址。这个地址保存在对象内存中最开始的位置。
2、因为要访问的是ToString,所以系统知道要到虚函数指针表的第三条中去找ToString入口地址的偏移量。(知道到第三条去取,是因为编译器在编译的时候就知道每个类一共又多少个虚方法,而且是编译器负责填充这个虚函数指针表的,编译器当然知道ToString要从第三条中去取。)
3、根据从虚函数指针表中找到的这个入口地址,调用函数。


为了验证这个,我写了一段简单的代码,并把反编译的的结果做了一个简单的注释。不过我对汇编也不是很熟悉,水平只有“大学的一点印象+今天一天”呵呵。所以如果各位发现有错误还请指出来。
这些是vs2003对Fun函数的反编译结果。源代码在最后面一部分:
        public void Fun()
        {
            ClassA a = null;
00000000  push        ebp  
00000001  mov         ebp,esp 
00000003  sub         esp,0Ch                 // 
00000006  push        edi  
00000007  push        esi  
00000008  push        ebx  
00000009  mov         dword ptr [ebp-4],ecx         // 
0000000c  xor         ebx,ebx                 // 变量声明时的内存分配。a 存放在 ebx 中
0000000e  xor         esi,esi                 // 变量声明时的内存分配。b 存放在 esi 中
00000010  xor         ebx,ebx                 // ebx 清零(a = null)
            ClassB b = null;
00000012  xor         esi,esi                 // esi 清零(b = null)
            a = new ClassA(1);
00000014  mov         ecx,0C55138h             // 
00000019  call        FF9F1F50                 // 
0000001e  mov         edi,eax 
00000020  mov         ecx,edi 
00000022  mov         edx,1                 // 参数
00000027  call        dword ptr ds:[00C55170h]         // 调用构造方法
0000002d  mov         ebx,edi                 // 将构造方法返回的值赋值给a(ebx)
            b = new ClassB(1);
0000002f  mov         ecx,0C55200h 
00000034  call        FF9F1F50 
00000039  mov         edi,eax 
0000003b  mov         ecx,edi 
0000003d  mov         edx,
1 
00000042  call        dword ptr ds:[00C55238h]         // 调用构造方法
00000048  mov         esi,edi                 // 将构造方法返回的值赋值给b(esi)
            a.ToString();
0000004a  mov         ecx,ebx                 
// 将 a 的地址向 ecx 复制一份(ToString 函数的隐藏参数 this )
0000004c  mov         eax,dword ptr [ecx]         // 从 ecx 指向的内存中复制一个 dword 值(a 的虚函数指针表的地址,放在对象 a 的最前面 4 字节中),放在 eax 中
0000004e  call        dword ptr [eax+28h]         // 调用虚方法。方法的入口地址存放在这里:eax 指向的内存向后偏移 28h 。(eax 指向虚函数指针表,偏移后是 ToString 方法的入口地址)
00000051  nop              
            b.ToString();
00000052  mov         ecx,esi                 // 将 b 的地址向 ecx 复制一份(ToString 函数的隐藏参数 this )
00000054  mov         eax,dword ptr [ecx]         // 从 ecx 指向的内存地址中复制一个 dword 值(a 的虚函数指针表的地址),放在 eax 中
00000056  call        dword ptr [eax+28h]         // 调用虚方法。方法的入口地址存放在这里:eax 指向的内存向后偏移 28h 。(eax 指向虚函数指针表,偏移后是 ToString 方法的入口地址)
00000059  nop              
            b.Copy();
0000005a  mov         ecx,esi                 
// 将 b 的地址向 ecx 复制一份(Copy 函数的隐藏参数 this )
0000005c  cmp         dword ptr [ecx],ecx 
0000005e  
call        dword ptr ds:[00C55244h]         // 通过直接制定 Copy 方法入口地址,调用 Copy 方法。
00000064  nop              
            b.Empty();
00000065  mov         ecx,esi                 // 将 b 的地址向 ecx 复制一份(函数的隐藏参数 this )
00000067  cmp         dword ptr [ecx],ecx 
00000069  call        dword ptr ds:[00C55248h]         // 调用 Empty 方法。
        }
0000006f  
nop              
00000070  pop         ebx  
00000071  pop         esi  
00000072  pop         edi  
00000073  mov         esp,ebp 
00000075  pop         ebp  
00000076  ret              


对应的C#源代码:
using System;

namespace ConsoleApp
{
    
public class Class2
    
{
        
public Class2()
        
{
        }


        [STAThread]
        
static void Main(string[] args)
        
{
            Class2 obj 
= new Class2();
            obj.Fun();
        }


        
public void Fun()
        
{
            ClassA a 
= null;
            ClassB b 
= null;

            a 
= new ClassA(1);
            b 
= new ClassB(1);

            a.ToString();
            b.ToString();
            b.Copy();
            b.Empty();
        }

    }


    
public class ClassA
    
{
        
private int _value;

        
public ClassA(int value)
        
{
            
this._value = value;
        }


        
public override string ToString()
        
{
            
return this._value.ToString();
        }

    }


    
public class ClassB
    
{
        
private int _value;

        
public ClassB(int value)
        
{
            
this._value = value;
        }


        
public override string ToString()
        
{
            
return this._value.ToString();
        }


        
public int Value
        
{
            
get
            
{
                
return this._value;
            }

            
set
            
{
                
this._value = value;
            }

        }


        
public ClassB Copy()
        
{
            ClassB b 
= new ClassB(this._value);
            
return b;
        }


        
public void Empty()
        
{
        }

    }

}


最后,从上面的汇编代码可以看出,非虚方法是无需借助这样的方式就可以调用的。因此可以推测这些非虚方法是不会放在“函数指针表”中去的。
那么至于文章开始提到很多资料说类的非虚方法也会放在方发表中,这个就要期待高手来验证一下了,偶的调试技巧还不够呵呵。
posted on 2008-04-24 01:11 没有昵称 阅读(1837) 评论(21)  编辑 收藏 所属分类: .Net FrameworkOperating System Principles

评论

#1楼  2008-04-24 01:15 墙头草      
高深....
  回复  引用  查看    

#2楼 [楼主] 2008-04-24 01:31 没有昵称      
@墙头草
看来没说明白。^_^
我也觉得说得不够浅显易懂。慢慢锻炼吧呵。
  回复  引用  查看    

#3楼  2008-04-24 08:28 李战      
请教楼主,怎样能得到C#程序的本机指令的反汇编?不是中间语言IL的,而是你那种X86指令形式的。
  回复  引用  查看    

#4楼  2008-04-24 08:50 good man      
高深啊,看到有一点晕
  回复  引用  查看    

#5楼  2008-04-24 08:58 镜涛      
不错,对于.NET编译有了新的认识。赫赫。
  回复  引用  查看    

#6楼  2008-04-24 08:58 信110 [未注册用户]
帖下俺写的东西,希望没写错呵呵。
using System;
class A
{
public virtual void Say()
{
Console.WriteLine("In A");
}
}
class B:A
{
public override void Say()
{
Console.WriteLine("In B");
}
}
class C:A
{
public override void Say()
{
Console.WriteLine("In C");
}
}
class Test
{
static void Main()
{
A a = new B();
a.Say();
}
}
a.Say();
00000039 mov ecx,edi
0000003b mov eax,dword ptr [ecx]
0000003d call dword ptr [eax+38h]
用Vs2005+sos看看:
先找到this:(寄存器窗口也能看见)
!clrstack -a
PDB symbol for mscorwks.dll not loaded
OS Thread Id: 0xb0 (176)
ESP EIP
0013f444 00f400b1 Test.Main()
LOCALS:
<CLR reg> = 这个:0x013e1b64

0013f69c 79e88f63 [GCFrame: 0013f69c]


由this找到typehandle:
!do 013e1b64
Name: B
这个:MethodTable: 00a63118
EEClass: 00a61458
Size: 12(0xc) bytes
(E:\vs2005Pro\EditILTest\ConsoleApplication1\bin\Debug\ConsoleApplication1.exe)
Fields:
None
call dword ptr [eax+38h] 等于 call dword ptr [00a63118+38h]
等于call dword ptr [00a63150]
通过内存窗口可看到00a63150存放着00a63160
call dword ptr [00a63150] 就是 call 00a63160 (汇编很不熟 希望没说错)
00a63160又是什么:
!dumpmt -md 00a63118
EEClass: 00a61458
Module: 00a62c14
Name: B
mdToken: 02000003 (E:\vs2005Pro\EditILTest\ConsoleApplication1\bin\Debug\ConsoleApplication1.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 6
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
79354bec 7913bd48 PreJIT System.Object.ToString()
793539c0 7913bd50 PreJIT System.Object.Equals(System.Object)
793539b0 7913bd68 PreJIT System.Object.GetHashCode()
7934a4c0 7913bd70 PreJIT System.Object.Finalize()
这个:00a63160 00a63108 JIT B.Say()
00a63170 00a63110 JIT B..ctor()

call 00a63160看见没,是B.Say()的入口。
问题的关键在这三行:
00000039 mov ecx,edi
0000003b mov eax,dword ptr [ecx]
0000003d call dword ptr [eax+38h]
【eax+38h】 这个38h就是虚方法在方法表中的位移,clr通过保证同一虚方法在继承层次结构中的方法位移是不变的,来实现了多态。
就像您文中:
a.ToString()--->00000056 call dword ptr [eax+28h]
b.ToString()--->00000056 call dword ptr [eax+28h]
这两个28h很醒目哈
以上是自己的理解,不对还望指教-_-
  回复  引用    

#7楼  2008-04-24 09:00 紫色阴影      
我也写过一篇 :)
http://www.cnblogs.com/blusehuang/archive/2007/07/27/net_framework_virtual_function_1.html
  回复  引用  查看    

#8楼 [楼主] 2008-04-24 09:16 没有昵称      
@李战
调试模式下,“调试——窗口——反汇编”,或者ctrl + alt + d
  回复  引用  查看    

#9楼  2008-04-24 09:42 生鱼片      
学习
  回复  引用  查看    

#10楼  2008-04-24 09:50 Da Vinci      
和C++虚函数表机制有些类似 不过似乎不同编译器产生的vtbl构造不同
LZ也应该把IL贴出来 与汇编结果比较分析就好了
  回复  引用  查看    

#11楼  2008-04-24 09:55 Da Vinci      
@李战
反汇编就用编译器就可以 windbg什么的都行
  回复  引用  查看    

#12楼 [楼主] 2008-04-24 09:58 没有昵称      
@信110
嗯,在生成子类的虚函数指针表时,如果子类没有override父类的虚方法,会把基类对应虚方法的地址拷贝一份放在自己的虚函数指针表中来,而且顺序是跟基类完全一样的。如果子类有新定义的虚函数,会添加在表的后面。

这样就保证了子类的虚函数指针表中的前半部分跟父类的是结构相似的:个数一样、函数顺序一样。这样在整个类层次结构中,就可以保证同一个虚方法在虚函数指针表中的便宜量是相同的。从而实现多态。
  回复  引用  查看    

#13楼 [楼主] 2008-04-24 10:13 没有昵称      
从IL代码中根本看不出什么。

我改了下源代码:


public void Fun()
{
ClassA a = null;
ClassB b = null;

a = new ClassA(1);
b = new ClassB(1);

a.ToString();
b.ToString();

a = b;
a.ToString();
}

public class ClassB : ClassA
{
private int _value;

public ClassB(int value) : base(value)
{
this._value = value;
}

public override string ToString()
{
return this._value.ToString();
}


便以后的 IL 代码是这样的:

.method public hidebysig instance void Fun() cil managed
{
// 代码大小 42 (0x2a)
.maxstack 2
.locals ([0] class ConsoleApp.ClassA a,
[1] class ConsoleApp.ClassB b)
IL_0000: ldnull
IL_0001: stloc.0
IL_0002: ldnull
IL_0003: stloc.1
IL_0004: ldc.i4.1
IL_0005: newobj instance void ConsoleApp.ClassA::.ctor(int32)
IL_000a: stloc.0
IL_000b: ldc.i4.1
IL_000c: newobj instance void ConsoleApp.ClassB::.ctor(int32)
IL_0011: stloc.1
IL_0012: ldloc.0
IL_0013: callvirt instance string ConsoleApp.ClassA::ToString()
IL_0018: pop
IL_0019: ldloc.1
IL_001a: callvirt instance string ConsoleApp.ClassB::ToString()
IL_001f: pop
IL_0020: ldloc.1
IL_0021: stloc.0
IL_0022: ldloc.0
IL_0023: callvirt instance string ConsoleApp.ClassA::ToString()
IL_0028: pop
IL_0029: ret
} // end of method Class2::Fun

可以看到IL并没有对虚函数做处理,而是在JIT编译的时候才去处理的。实际上虚函数指针表要保存函数的便宜量,而只有在JIT编译了之后才会知道函数生成的代码段有多大,才会知道函数相对于基址的便宜量。
  回复  引用  查看    

#14楼 [楼主] 2008-04-24 10:23 没有昵称      
需要指出的是,上面的IL代码中,全部都是 callvirt 指令而不是 call 指令,之际上如果这里不是虚方法,产生的也是 callvirt 指令,因为他们的区别是如果对象为null,call指令不会产生空引用异常,而callvirt会产生空引用异常。

所以对于引用类型的函数调用,产生的总是callvirt指令,因为需要在调用方法时判断对象是否为空。call指令只会在调用值类型的方法时才会产生,因为值类型的实例永远不会为null。
  回复  引用  查看    

#15楼  2008-04-24 10:48 信110 [未注册用户]
class A{ public override string ToString(){return base.ToString();}}
为避免循环调用, base.ToString() 生成的是:
call instance string [mscorlib]System.Object::ToString()

int i =3;
Console.Write(i.ToString());
值类型虚调用不存在多态 所以:
i.ToString() 生成的是:
call instance string [mscorlib]System.Int32::ToString()

  回复  引用    

#16楼  2008-04-24 11:07 Da Vinci      
@没有昵称
引用类型的函数调用产生的都是callvirt指令? call好像也可以调虚方法吧 避免执类型装箱的时候用的是call, 对于密封类型的引用的虚方法也是用call的, 因为没必要检查实际类型
  回复  引用  查看    

#17楼 [楼主] 2008-04-24 11:14 没有昵称      
@Da Vinci
@信110
呵呵,,谢谢两位补充。
本来是想说这里的callvirt并不一定是调用的虚方法,结果描述的不严谨,导致很多错误哈。加上两位的说法就好了。^_^

用call的都是能够明确调用的是哪个方法的;不过对于15楼提到的“为避免循环调用”而使用call,偶还没弄明白呵:
如果ClassA继承object,重写了ToString,ClassB继承ClassA,重写ToString时使用了return base.ToString(),那么下面这句话:
object obj = new ClassB();
obj.ToString();
究竟是怎样避免循环调用的呢?编译器咋知道应该调用的是ClassA.ToString,而不是Object.ToString或者ClassB.ToString?如果说在ClassB的虚函数表中没有更改这个方法的入口,明显是不合理的。因为如果ClassB.ToString在return之前执行了其他操作,那这样处理明显会使得这些操作无效。那么究竟是怎样避免循环调用的呢?思考中。。。

顺便请教两位。^_^
  回复</