现在的位置: 首页 > 综合 > 正文

从汇编层面去理解对象创建

2019年05月26日 ⁄ 综合 ⁄ 共 8932字 ⁄ 字号 评论关闭

自从拙文《从汇编层面深度剖析C++虚函数》 见于csdn首页后,受到很多读者的好评。本人甚是高兴,并且打算从汇编的角度来分析C++中的语言构造,比如对象的布局,多继承下的对象布局以及它们的虚函数表的结构,我想这些都是C++开发者所感兴趣的。

此外,本人目前从事的是Linux平台下的C语言系统开发,工作中并没有真正写过C++程序,因此,文中所使用的术语和分析未必见得准确无误,还请各位多多指正。

使用哪个汇编语法形式才适合读者呢?这是很多读者看不明白上篇文章的原因之一,很多读者都不了解AT&T的汇编语式格式。因此,我打算选用g++编译器+intel语法形式的汇编来生成相应的汇编代码,并相应翻译成C语言代码。

 

1. C++基本对象的组成

 

尽管C++社区最近十年兴起元编程范式和模板技术,面向对象编程仍是C++语言最基础,也是最重要的一种编程范式(paradigms)。C++的对象,将过程式的数据结构,以及操作在它们之上的函数,绑定到一个单一的语法单元,那就是类(class)。C++世界里,活生生的个体联系就是所有类的对象所进行的消息传递,对象协作而构成千变万化的世界。但对象的内部结构,对象的产生和消亡,数据成员和函数成员的结构,困扰了无数初学者。

C++基本对象由以下几方面元素组成:

对象数据成员(非静态数据成员)

类数据成员(静态数据成员)

函数成员

静态函数成员

这里,我们不考虑类的单继承,多重继承以及虚函数等复杂的特性。

下面我们对C++基本对象进行抽丝剥茧,深入分析对象的内存布局,与之相关的静态变量,成员函数。

 

2. 一个简单的例子

 

为简单起见,本文以class point为例子,它包含构造函数,析构函数,函数成员,对象变量,类变量和静态成员函数等语法结构。它的代码如下:

  1. class point  
  2. {  
  3.   public:  
  4.         point(int x, int y)  
  5.         {  
  6.                 this->x = x;  
  7.                 this->y = y;  
  8.                 ins_cnt++;  
  9.         }  
  10.         ~point()  
  11.         {  
  12.                 ins_cnt--;  
  13.         }  
  14.         static int get_instances()  
  15.         {  
  16.                 return ins_cnt;  
  17.         }  
  18.         point & move(int addx, int addy)  
  19.         {  
  20.                 this->x += addx;  
  21.                 this->y += addy;  
  22.                 return *this;  
  23.         }  
  24.   private:  
  25.         int x, y;  
  26.         static int ins_cnt;  
  27. };  
  28. int point::ins_cnt = 0;  
  29. int main()  
  30. {  
  31.         point x(3, 4);  
  32.         x.move(10, 10);  
  33.         int p_ins_cnt = point::get_instances();  
  34.         return 0;  
  35. }  

 

 

3. 编译生成汇编文件和可执行文件

 

g++命令行提供了简便方式来生成这两种文件,我们在下面根据实际需要来对这两文件进行分析,从而深入理解point对象的内存布局。

g++命令使用如下:

[lyt@t468 ~]$ g++  -g -o object object.cpp      
[lyt@t468 ~]$ g++  -g -S -o object.s object.cpp

object.s文件生成的汇编比较凌乱,因为它里面的符号还未重定位,只是使用一些符号来表示某些以后要分配内存的变量,编译器使用的变量或符号。因此,我们可以利用它来分析某些C++变量经编译器处理后,在汇编层面上的符号名称。

object文件可用来供gdb调试工具来使用,gdb可以对源代码以函数为单位,对每一行语句进行反汇编。

 

4. 所有与point类相关的符号

 

C++源代码生成可执行文件(linux下称为ELF格式文件)后,它专门有一个符号节区来记录执行文件中各个符号的类型,地址等相关信息。为了便于分析,我们使用readelf工具对生成的object文件,找出与point类相关的所有符号,以及使用c++filt工具,将这些符号转变成C++语言级别上的语义,如下:

 

[lyt@t468 ~]$ readelf -s object | grep point 
    41: 08048530    10 FUNC    WEAK   DEFAULT   13 _ZN5point13get_instancesE 
    49: 0804853a    40 FUNC    WEAK   DEFAULT   13 _ZN5point4moveEii 
    56: 080484fa    35 FUNC    WEAK   DEFAULT   13 _ZN5pointC1Eii 
    58: 0804851e    18 FUNC    WEAK   DEFAULT   13 _ZN5pointD1Ev 
    59: 0804a01c     4 OBJECT  GLOBAL DEFAULT   25 _ZN5point7ins_cntE


[lyt@t468 ~]$ c++filt _ZN5point13get_instancesE 
point::get_instances 
[lyt@t468 ~]$ c++filt _ZN5point4moveEii        
point::move(int, int) 
[lyt@t468 ~]$ c++filt _ZN5pointC1Eii    
point::point(int, int) 
[lyt@t468 ~]$ c++filt _ZN5pointD1Ev    
point::~point() 
[lyt@t468 ~]$ c++filt _ZN5point7ins_cntE 
point::ins_cnt

从上面的结果可以看出来,point类的构造函数,析构函数,move成员函数,get_instances静态成员函数都对应一个函数符号。而令我们感到意外的是,point类的静态变量ins_cnt也对应一个全局变量符号,它的地址是0804a01c;下面对地址0804a01c 的读写汇编语言,都意味着相应的C++函数读写该变量,也即point类的静态变量。

5. point对象的内存布局和构造函数

 

对象的生命始于构造函数,而在执行构造函数之前,对象还处于混沌状态。在构造函数里面,它按对象内存所包含的每个成员依次进行初始化,因此我们从对象的构造函数就可以一窥它的内存布局。

为了方便大家较对C++源代码和汇编代码,使用gdb对point类的构造函数按源代码行进行反汇编。结果如下:

 

  1. (gdb) disassemble /m _ZN5pointC1Eii  
  2. Dump of assembler code for function point:  
  3. 5               point(int x, int y)  
  4. 0x080484fa <point+0>:   push   ebp  
  5. 0x080484fb <point+1>:   mov    ebp,esp  
  6. 6               {  
  7. 7                       this->x = x;  
  8. 0x080484fd <point+3>:   mov    eax,DWORD PTR [ebp+0x8]  
  9. 0x08048500 <point+6>:   mov    edx,DWORD PTR [ebp+0xc]  
  10. 0x08048503 <point+9>:   mov    DWORD PTR [eax],edx  
  11. 8                       this->y = y;  
  12. 0x08048505 <point+11>:  mov    eax,DWORD PTR [ebp+0x8]  
  13. 0x08048508 <point+14>:  mov    edx,DWORD PTR [ebp+0x10]  
  14. 0x0804850b <point+17>:  mov    DWORD PTR [eax+0x4],edx  
  15. 9                       ins_cnt++;  
  16. 0x0804850e <point+20>:  mov    eax,ds:0x804a01c  
  17. 0x08048513 <point+25>:  add    eax,0x1  
  18. 0x08048516 <point+28>:  mov    ds:0x804a01c,eax  
  19. 10              }  
  20. 0x0804851b <point+33>:  pop    ebp  
  21. 0x0804851c <point+34>:  ret     
  22. End of assembler dump.  

 

为了让大家更清楚构造函数到底作了什么事情,我对上面的汇编语句逐行分析:

7                       this->x = x; 
0x080484fd <point+3>:   mov    eax,DWORD PTR [ebp+0x8] 
0x08048500 <point+6>:   mov    edx,DWORD PTR [ebp+0xc] 
0x08048503 <point+9>:   mov    DWORD PTR [eax],edx

mov    eax,DWORD PTR [ebp+0x8] 将函数第一个参数的值存放到寄存器eax中 
mov    edx,DWORD PTR [ebp+0xc] 将函数第二个参数的值存放到寄存器edx中 
mov    DWORD PTR [eax],edx        将edx寄存器的值写到eax所指向的内存中

结合this->x = x;这个C++代码,我们可以大胆推测,point构造函数生成汇编后,它对应的函数名(或者符号名)为

_ZN5pointC1Eii。该函数的第一个参数为this,类型为point类内存布局的表示类型,我们姑且称为struct point *类型;第二参数为int类型的x。 

接下来的this->y = y;语句的反汇编,与上面this->x = x; 语句如同一辙,唯有x和y在point对象的内存偏移量不同。 
从而得出,x成员在point对象内存的偏移量为0,而y的为4。 

比较迷惑的是最后这句:

 

  1. 9                       ins_cnt++;  
  2. 0x0804850e <point+20>:  mov    eax,ds:0x804a01c  
  3. 0x08048513 <point+25>:  add    eax,0x1  
  4. 0x08048516 <point+28>:  mov    ds:0x804a01c,eax   

第一个mov是将内存0x804a01c的值读到eax中,add指令是将eax加1,最后一个mov是将eax最后的值写回到内存中。还记得0x804a01c是哪个符号的地址吗?没错,它就是point类静态变量ins_cnt的地址。

由此,我们可以使用point类的对象在内存的布局如下:

  1. struct point {  
  2.     int x;  
  3.     int y;  
  4. };  
  5. // point::ins_cnt 变量,在汇编层面上,它是一个全局变量  
  6. int point_ins_cnt = 0;  

 

它的构造函数翻译成如下:

 

  1. void point::point(struct point *thisint x, int y)  
  2. {  
  3.     this->x = x;  
  4.     this->y = y;  
  5.     point_ins_cnt++;  
  6. }  

正如你早已知道的秘密,C++编译器悄悄地将你写的非静态 函数 成员(当然包括构造函数的析构函数)加上this指针作为第一个参数,这就是C++资料上所说的this隐藏参数。在汇编的曝光下,这一切都真相大白了。

下面是move成员函数反汇编的结果,如有不明白,可以对比分析一下:

 

  1. (gdb) disassemble /m _ZN5point4moveEii  
  2. Dump of assembler code for function _ZN5point4moveEii:  
  3. 22              point & move(int addx, int addy)  
  4. 0x0804853a <_ZN5point4moveEii+0>:       push   ebp  
  5. 0x0804853b <_ZN5point4moveEii+1>:       mov    ebp,esp  
  6. 23              {  
  7. 24                      this->x += addx;  
  8. 0x0804853d <_ZN5point4moveEii+3>:       mov    eax,DWORD PTR [ebp+0x8]  
  9. 0x08048540 <_ZN5point4moveEii+6>:       mov    eax,DWORD PTR [eax]  
  10. 0x08048542 <_ZN5point4moveEii+8>:       mov    edx,eax  
  11. 0x08048544 <_ZN5point4moveEii+10>:      add    edx,DWORD PTR [ebp+0xc]  
  12. 0x08048547 <_ZN5point4moveEii+13>:      mov    eax,DWORD PTR [ebp+0x8]  
  13. 0x0804854a <_ZN5point4moveEii+16>:      mov    DWORD PTR [eax],edx  
  14. 25                      this->y += addy;  
  15. 0x0804854c <_ZN5point4moveEii+18>:      mov    eax,DWORD PTR [ebp+0x8]  
  16. 0x0804854f <_ZN5point4moveEii+21>:      mov    eax,DWORD PTR [eax+0x4]  
  17. 0x08048552 <_ZN5point4moveEii+24>:      mov    edx,eax  
  18. 0x08048554 <_ZN5point4moveEii+26>:      add    edx,DWORD PTR [ebp+0x10]  
  19. 0x08048557 <_ZN5point4moveEii+29>:      mov    eax,DWORD PTR [ebp+0x8]  
  20. 0x0804855a <_ZN5point4moveEii+32>:      mov    DWORD PTR [eax+0x4],edx  
  21. 26  
  22. 27                      return *this;  
  23. 0x0804855d <_ZN5point4moveEii+35>:      mov    eax,DWORD PTR [ebp+0x8]  
  24. 28              }  
  25. 0x08048560 <_ZN5point4moveEii+38>:      pop    ebp  
  26. 0x08048561 <_ZN5point4moveEii+39>:      ret     
  27. End of assembler dump.  

 

6. 静态成员函数

 

是否还记得静态函数成员不能使用非静态变量成员?为什么不能使用非静态变量成员呢?原因很简单,是因为静态函数成员没有this参数。C++的静态函数成员,和静态数据成员一样,是属于类的,而不是属于对象的,访问它们时,不需要使用任何现成的对象,直接使用<class-name>::<member>形式即可,所以它的函数不需要this指针。

下面point::get_instances()函数反汇编的结果:

 

  1. (gdb) disassemble /m _ZN5point13get_instancesEv  
  2. Dump of assembler code for function _ZN5point13get_instancesEv:  
  3. 17              static int get_instances()  
  4. 0x08048530 <_ZN5point13get_instancesEv+0>:      push   ebp  
  5. 0x08048531 <_ZN5point13get_instancesEv+1>:      mov    ebp,esp  
  6. 18              {  
  7. 19                      return ins_cnt;  
  8. 0x08048533 <_ZN5point13get_instancesEv+3>:      mov    eax,ds:0x804a01c  
  9. 20              }  
  10. 0x08048538 <_ZN5point13get_instancesEv+8>:      pop    ebp  
  11. 0x08048539 <_ZN5point13get_instancesEv+9>:      ret     
  12. End of assembler dump.  

 

在函数体内,没有从堆栈里面读取任何参数信息,我们可以认为该函数是没有带参数,即它的参数型类为void。其实我们可以从调用该函数的地方去验证。下面是main函数反汇编的部分结果:

 

  1. 39              x.move(10, 10);  
  2. 0x080484ba <main+38>:   mov    DWORD PTR [esp+0x8],0xa  
  3. 0x080484c2 <main+46>:   mov    DWORD PTR [esp+0x4],0xa  
  4. 0x080484ca <main+54>:   lea    eax,[esp+0x14]  
  5. 0x080484ce <main+58>:   mov    DWORD PTR [esp],eax  
  6. 0x080484d1 <main+61>:   call   0x804853a <_ZN5point4moveEii>  
  7. 40  
  8. 41              int p_ins_cnt = point::get_instances();  
  9. 0x080484d6 <main+66>:   call   0x8048530 <_ZN5point13get_instancesEv>  
  10. 0x080484db <main+71>:   mov    DWORD PTR [esp+0x1c],eax  

 

在x.move(10, 10);调用时,它使用了两个mov …, 0xa将常量10压入堆栈中,作为_ZN5point4moveEii函数的第二和第三个参数,第一个当然是this拉。

而x.move(10, 10) 调用完后,它接着call _ZN5point13get_instancesEv,说明_ZN5point13get_instancesEv函数不带任何参数。

因此point::get_instances()函数翻译成C语言代码相应如下:

 

  1. int point::get_instances(void)  
  2. {  
  3.     return point_ins_cnt;  
  4. }  

 

7. 总结

 

不考虑C++虚函数,继承等语法功能后的C++基本对象内存布局模式格外简单。具有以下特点:

1)class内定义的非静态数据成员,它将占用对象的内存,它的布局类似于一种相应的结构体定义相应的字符。

2) class内定义的静态数据成员,它是类变量,每种类只有唯一的一份,它以全局变量的身份挤身于全局变量列表。当然g++可根据它的初始化值是否为0来安排它放在.bss节区还是.data节区。

3)非静态函数成员,不占用对象的内存,它经C++编译器处理后,它是一个全局函数,它的第一个参数为this指针,其余参数类型和名字,与用户定义的一致。

4) 静态函数成员,同样不占用对象的内存,它经C++编译器处理后,它是一个全局函数,它没有this指针,它的参数类型和名字与用户定义的一致。

8. 给读者的问题

 

看到这样,你是不是明白了C++基本对象内存布局的里里外外?其实,上面的分析中,还少考虑了一种函数,那就是const成员函数。你能说出成员函数和const成员函数在反汇编后的区别吗?成员函数和const成员函数的重载关系能否转换成函数参类间类型不同的重载关系。

 

抱歉!评论已关闭.