透过汇编另眼看世界之函数调用

时间:2023-01-19 08:07:55

在我的另外一篇文章中 ,我提到了要通过汇编语言来分析虚函数调用的真相。我们现在就开始踏上这次艰辛却非常有意思的旅程。其他闲话少说,直接进入主题。本文中使用的C++代码:

#include "stdafx.h"
#include 
<iostream>

class CBase {
public
:
    
virtual void
 callMe();
};

class CDerived: public
 CBase {
public
:
    
virtual void
 callMe();
};

void
 CBase::callMe() {
    std::cout
<<"Hello,I'm CBase. "<<
std::endl;
}

void
 CDerived::callMe() {
    std::cout
<<"Hello,I'm CDerived. "<<
std::endl;
}

int _tmain(int argc, _TCHAR*
 argv[])
{
    CDerived dObj;
    CBase bObj 
=
 dObj;
    CBase
* pBase = &
dObj;
    pBase->callMe();     dObj.callMe();                    
    ((CBase)dObj).callMe();    

    (
*
pBase).callMe();
    (
*((CDerived*
)pBase)).callMe();
    
return 0
;
}

我将对每一个C++语句逐句分析,在每个C++语句后面有相应的汇编代码,便于比较。

第一个语句:

29   :     CDerived dObj;

    lea    ecx, DWORD PTR _dObj$[ebp]
    call    
??0CDerived@@QAE@XZ

从语法的角度,这里应该是调用dObj的无参数的构造函数。
从汇编的角度,我们发现两点需要注意的地方:
1.需要调用的函数在编译阶段就已经确定下来了,并且采用直接取函数地址的方式调用它。
2.类的成员函数的调用采用"this"调用约束,隐式的this指针被存放在EXC寄存器中。

第二个语句:

31   :     CBase bObj = dObj;

    lea    eax, DWORD PTR _dObj$[ebp]
    push    eax
    lea    ecx, DWORD PTR _bObj$[ebp]
    call    
??0CBase@@QAE@ABV0@@Z

从语法的角度,这里是用一个派生类对象去构造一个基类对象,应该调用基类对象的拷贝构造函数去构造它.
从汇编的角度,我们又发现了一点需要注意的地方:构造函数的第二个参数被存放在EAX中。EAX中存放的是被拷贝对象的首地址。

第三个语句:

36   :     pBase->callMe();

    mov    eax, DWORD PTR _pBase$[ebp]
    mov    edx, DWORD PTR [eax]
    mov    esi, esp
    mov    ecx, DWORD PTR _pBase$[ebp]
    call    DWORD PTR [edx]
    cmp    esi, esp
    call    __RTC_CheckEsp

 从语法的角度,这里是用基类指针去调用虚函数,实际被调用的函数依赖于基类指针所指的内存空间中的虚函数表指针。这是标准的使用基类指针调用派生类的虚函数的例子。
从汇编的角度,虚函数调用的所有秘密就隐藏在这里:
1.取出pBase指针所指的内容空间的第一个DWORD元素的值,并将它存放在EAX中。EAX中实际存放的应该就是"虚函数表指针"。
2.取出EAX的值,得到虚函数表的地址,取出虚函数表中的第一个元素的值,并将它存放在EDX中。EDX中实际上存放的应该就是需要被调用的虚函数的地址。本例中由于只有一个虚函数,所以虚函数表中的第一个元素就是我们需要的函数,如果虚函数表中有多个虚函数,在调用第二个,第三个虚函数的时候,我们需要在虚函数表的首地址后面添加一个偏移量从而获得相应的虚函数地址。

第四个语句:

34   :     dObj.callMe();                        

    lea    ecx, DWORD PTR _dObj$[ebp]
    call    
?callMe@CDerived@@UAEXXZ        ; CDerived::callMe

从语法的角度,这里是使用类对象调用类的成员函数,并且这个成员函数被声明成虚函数。
从汇编的角度,这里的调用情况和第一个语句一样,采用"this"调用约束在编译阶段直接获取被调用函数的地址,并调用这个函数。

第五个语句:

35   :     ((CBase)dObj).callMe();    

    lea    eax, DWORD PTR _dObj$[ebp]
    push    eax
    lea    ecx, DWORD PTR $T1758[ebp]
    call    
??
0CBase@@QAE@ABV0@@Z
    ;
-----------------------------------

    mov    DWORD PTR tv72[ebp], eax
    mov    ecx, DWORD PTR tv72[ebp]
    mov    edx, DWORD PTR [ecx]
    mov    esi, esp
    mov    ecx, DWORD PTR tv72[ebp]
    call    DWORD PTR [edx]
    cmp    esi, esp
    call    __RTC_CheckEsp

从语法的角度,这里是将dObj转化到基类,并调用虚函数。
从汇编的角度,这里比我们猜测的情况要稍微复杂一点。仔细分析一下,这里其实可以被分为两个步骤:
1.使用dObj构造一个临时基类变量。
2.使用这个临时基类变量的地址去调用虚函数
所以上面的C++代码可以写成这样的形式:

CBase  tempbObj  = dDObj;CBase* pTempbase = &tempbObj;
pTempbase
->callMe();

第六个语句:

37   :     (*pBase).callMe();

    mov    eax, DWORD PTR _pBase$[ebp]
    mov    edx, DWORD PTR [eax]
    mov    esi, esp
    mov    ecx, DWORD PTR _pBase$[ebp]
    call    DWORD PTR [edx]
    cmp    esi, esp
    call    __RTC_CheckEsp

从语法的角度,这里是通过pBase所指的对象来调用虚函数。
从汇编的角度,这里和常见的标准的虚函数调用方式是一样的。

第七个语句:

38   :     (*((CDerived*)pBase)).callMe();

    mov    eax, DWORD PTR _pBase$[ebp]
    mov    edx, DWORD PTR [eax]
    mov    esi, esp
    mov    ecx, DWORD PTR _pBase$[ebp]
    call    DWORD PTR [edx]
    cmp    esi, esp
    call    __RTC_CheckEsp

从语法的角度,这里是将pBase指针转化到派生类指针,并通过所指的对象来调用虚函数。
从汇编的角度,这里和常见的标准的虚函数调用方式是一样的。

通过比较以上形形色色的函数调用方式,我们可以深刻的认识到:
1.虚函数并不因被声明成虚函数就能够在调用的时候表现出"虚"性,虚函数本质上和普通的成员函数是一样的,具有确定的函数地址。
2.虚函数要表现出"虚"性,本质上只有一种方式:通过对象的地址获得类对象的虚函数表指针,从而获得虚函数表的地址,间接获得被调用虚函数的地址。

而且,通过这次学习,我也有了这样的感觉:
1.在看似简单的语法背后有时候却可能隐藏这巨大的秘密
2.再看似复杂的语法背后有时候却是难以相信的简单和"干净"
3.当我们对底层,对汇编了解的越多,我们对语法的理解就会越深,对语法的驾驭能力会越来越强。

在本文涉及到的函数调用中,被调用函数和调用函数处在同一个模块的,在随后的文章中,我会涉及到处于不同模块中函数调用的问题,最常见的例子就是应用程序调用DLL的情况。先在这里作个预告吧。

特别注释:
1.在VC环境下我们可以通过这样的方式获得程序的汇编代码:
打开项目-》在“解决方案资源管理器"中选择需要编译的项目,点右键,选择"属性"-》C/C++-》输出文件-》汇编输出-》选择"带源代码的程序集"。然后编译这个项目,在项目的输出目录中就可以看到以.asm结尾的文件,这个就是于C/C++源码对应的汇编代码。

2.C++编译在编译的过程中会对函数名,变量名等符号名进行"修饰",这个叫"Name Mangling"。修饰的结果是我们再很难识别这些符号名了,例如我们就很难判断出??0CDerived@@QAE@XZ指的是那个函数。VC开发包中提供了一个小工具,可以帮我们"反修饰"那些已经被修饰的符号名。这个工具位于:VS安装目录/VC目录/bin/undname.exe。有了这个工具,我们可以使用这样的命令方式进行"反修饰":

undname.exe ??0CDerived@@QAE@XZ

得到的结果是:

Undecoration of :- "??0CDerived@@QAE@XZ"
is :- "public: __thiscall CDerived::CDerived(void)"

V1.1

自从这篇文章发表以后,我又在CSDN论坛中看到一些关于虚函数调用的问题,当我试图用文章中总结的那两条结论去分析这些新的虚函数调用的问题,我发现我总结的那两条结论还是挺能站得住脚的(得意中。。。透过汇编另眼看世界之函数调用),但是还漏掉了一些内容,特在此补充这些内容。

虚函数的静态调用
在前面的内容中,我花了大部分的篇幅去分析虚函数是如何做到动态调用的,但是我也明确的指出(两条结论中的第一条):虚函数本质和普通的函数没有什么区别,它也有自己的地址,这也意味着虚函数和其他的普通函数一样,可以被静态调用。静态调用的意思就是在编译阶段就能够确定被调用的是那一个函数,从而生成指令直接调用那个函数。从前面的分析中我们已经知道了虚函数的动态调用需要满足"指针(或引用)+虚表"这两个条件,所以可以想像任何不满足这两个条件的虚函数调用都是静态调用。在C++中我们可以使用以下两种方式对虚函数进行静态调用:
1.使用类对象直接调用虚函数。这个在前面的例子中已经有所表现:

    CDerived dObj;
    dObj.callMe();    

2.通过类对象指针(或者引用)来调用虚函数,但是并不是通过虚表来查找虚函数并调用,而是直接指定被调用的虚函数。这种情况初看起来是多此一举,没有必要:既然你需要这个函数被动态调用,那你为什么要把它声明为虚函数?声明为普通成员函数不就解决问题了么?对于这样的置疑,考虑这样的问题:如何在派生类的虚函数中调用基类对应的虚函数?请看下面的代码: void CDerived::callMe() {
    
//how to call CBase::callMe() here?
    
//this->callMe();   //ERROR
    std::cout<<"Hello,I'm CDerived. "<<std::endl;
}

在这样的情况下,"this->callMe();"调用的是"CDerived::callMe()",这样就形成了无穷的递归调用。如果不提供某种机制,我们就无法做到在派生类的虚函数中调用基类的虚函数。C++已经想到了这一点,并且提供了一个机制完美的解决这个问题:在使用指针调用虚函数的过程中通过指定类域的方式指定调用那个类的虚函数。这个机制使得虚函数的调用跳过了查询虚表的过程,直接"定位"到需要调用的虚函数,从而实现了虚函数的静态调用。这样代码就可以写成: void CDerived::callMe() {
    
this->CBase::callMe();  // or CBase::callMe();
    std::cout<<"Hello,I'm CDerived. "<<std::endl;
}    

相对应的汇编代码是: //this->CBase::callMe();
mov    ecx, DWORD PTR _this$[ebp]
call    
?callMe@CBase@@UAEXXZ            ; CBase::callMe

纯虚函数
我们知道在一个虚函数的声明后面添加"=0"表示这个虚函数是纯虚函数,而这个类也变成了"抽象基类":

class CBase {
public:
    
virtual void foobar() = 0 {
        std::cout
<<"Hello,I'm abstract virtual function. "<<std::endl;
    };
    
virtual void callMe();
};   

这里的问题是,对于纯虚函数,也就是说当编译器看到虚函数声明后的"=0"会做那些处理?通过论坛中网友们的讨论以及我自己对这一部分内容的理解和分析,我得出了以下几个结论:
1。纯虚函数和普通函数以及普通的虚函数一样,可以有实现体的。在C++中对于抽象基类是不允许实例化的,很多时候我们就主观的认为(或者受了某些书籍和文章的误导)纯虚函数是可以有实现体的,其实这是不正确的。
2。纯虚函数声明中的"=0"只能表示:在虚表中纯虚函数所对用的项中的函数地址不是纯虚函数的地址,而是0(NULL)。实际上在VC编译环境中,编译器会在虚表中纯虚函数对应的项中填充一个编译器生成的函数的地址,当通过虚表调用纯虚函数的时候实际上调用的是那个函数,而那个函数就是抛出"调用纯虚函数的"异常。
3。我们知道在一个由抽象基类派生出来的派生类中,纯虚函数必须要被实现,如果没有被实现的话,这个派生类仍然是个抽象基类。如果多个派生类中有公用的部分,这时候就可以把这个公用的部分放在纯虚函数的实现体内,在派生类的虚函数的实现体内调用纯虚函数的实现体: class CDerived: public CBase {
public:
    
virtual void foobar() {
        CBase::foobar();
        printf(
"Hello,I'm abstract virtual function in CDerived class. ");
    }
    
virtual void callMe();
};

由此可以看出,纯虚函数并没有什么神秘,它和普通的虚函数没有什么差别,仅有的差别表现在虚表中并不是纯虚函数的地址,而是存放另外一个特殊函数的地址。

历史记录
10/15/2006  v1.0
原文的第一个正式版
01/05/2007  v1.1
添加:在原文的基础上添加了虚函数静态调用和纯虚函数调用的内容