博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

虚函数的调用机制

Posted on 2008-04-24 01:11  [虫子]  阅读(4071)  评论(24编辑  收藏  举报
前几天在看《.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()
        
{
        }

    }

}


最后,从上面的汇编代码可以看出,非虚方法是无需借助这样的方式就可以调用的。因此可以推测这些非虚方法是不会放在“函数指针表”中去的。
那么至于文章开始提到很多资料说类的非虚方法也会放在方发表中,这个就要期待高手来验证一下了,偶的调试技巧还不够呵呵。