那年声明理解不了定义与初始化(三)

那年声明理解不了定义与初始化

穷则独善其身,达则兼善天下 —— 《孟子》

第一部分内容

  • 编程之外
  • 追逐简单美
  • 编程之内
  • 回顾微机原理-浮点数

第二部分内容

  • 神秘角色-机器码
  • 神秘角色-机器码基础
  • 神秘角色-反汇编

第三部分内容

  • 当数组遇上指针
  • 引用的本质
  • 引用与类实例
  • new/malloc 选美之争

当数组遇上指针

当数组遇上指针,指针数组和数组指针就如同梦魇!曾经多少英雄就因为几个字的颠倒,搞得天地乾坤易位。有人用英文的明了的逻辑来解说两都的差别,其实也枉然,去掉英文的解析,再回来看“指针数组数组指针”,方向错乱依然如故。按我说,中文语境真够折磨的,老外要是用中文还学习编程,不进行青山疗养才怪。

要说破这两者的差别,也是挺明显的。平时听到乳黄、米白这类词时,一下就可以反应这是在讲颜色。而倒过来,白米,就是另一样东西了吧。所以按照中文这种“修饰·中心”语法结构来解读指针数组,首先是一种数组,还是用来保存指针的数组。另一个数组指针,也同样分解,首先是指针的一种,是指向数组的指针。

当然这个中文语境也不是总是这样好使的,例如来理解常量指针与指针常量,逻辑上可能更明确一点。常量指针,首先是个指针,指针就有指向,你说它是一个指向常量的指针还是指针本身是常?所以当内容变得丰富的时候,中文语境就有巨大的想象空间了,这也就是中文的最大魅力所在。然后是指针常量,首先它是个常量这点没问题,修饰词说它是指针呢,还是指针呢?好吧指针常量就是指针本身为常量的指针,指针本身是不能改变的了,但它指向的内容是变量时,这个变量还是可以被修改的啊。再来讨论一下“常量指针”的问题,按从简原则,使用“修饰·中心”这个中文语境时,不应该引入过多的影响因素。因此“常量”应该修饰“指针”,而不应该修饰“指针的指向”,这样就会发现指针常量和常量指针竟然是在说同一个东西!好神奇的中文!因为这个,我特意翻了压箱底的谭老那本黄色封面的《C语言程序设计》,打开目录,看到指针部分:

  • 9.2 变量的指针和指向变量的指针变量
  • 9.3 数组的指针和指向数组的指针变量
  • 9.4 字符串的指针和指向字符串的指针变量
  • 9.5 函数的指针和指向函数的指针变量
  • 9.7 指针数组和指向指针的指针

看看,这目录还是很清晰的吧,为什么当时就是有种“法海不懂爱”的感觉,是不是专家不懂学生的哀?

谭浩强 C语言程序设计

当数组和指针相遇,指针与变量这些高级语言概念混杂在一起时,脱清关系最有力的解释还是反汇编,从机器的角度,从编译器生成的机器码的视角去解读才是最浅显的。看以下代码,定义了数组和数组指针,当使用下标访问数组元素时,当指针用来运算时,究竟程序内容发生了什么:

volatile int j[2][2] = {{1,2},{3,4}};
volatile int k = 1;
j[1][1] = 2;
j[1][k] = 3;

volatile int (*p)[2][2] = &j;
*(p+1)[0][0] = 5;
*(p+1)[1][0] = 6;
*(*p + 1)[0] = 7;
*(*p + 1)[1] = 8;

先瞄一下源代码,不急着分析,这里重点不在于语法结构,而在于,程序是如何定位变量在内存地址的。先看第一行代码反汇编机器码,数据初始化使用了 movs 数据串拷贝指令,立即数 $0x6a5000 是初始化数据的地址,movs 指令会将4个初始值拷贝到数组所在的内存中 -0x48(%ebp):

23  volatile int j[2][2] = {{1,2},{3,4}};
0x401468    lea    -0x48(%ebp),%edx
0x40146b    mov    $0x6a5000,%ebx
0x401470    mov    $0x4,%eax
0x401475    mov    %edx,%edi
0x401477    mov    %ebx,%esi
0x401479    mov    %eax,%ecx
0x40147b    rep movsl %ds:(%esi),%es:(%edi)

再接下来一组语句,主要是试用变量的方式来指定数组的元素。通过常数来指定数组元素,编译器在编译阶段就可以确定地址,这个没什么特别的。通过变量的方式,在 0x401491 处看到了寄存器在寻址功能的不同应用,这是基址+变址+偏移的寻址方式,基址是 %ebp,变址是 %eax*4,偏移是 -0x40,有效地址就是 %ebp+%eax*4-0x40。这里变址的比例值选择4而不选2或8,主要是因为整形占4个字节,这样通过给 %eax 设定不同的数就可以找到数组的对应序号的元素,也就是前面的指令要把 k 拷贝到 %eax 的原因。这种变址寻址也是数组通过下标来定位的对应元素的基本实现方法:

24  volatile int k = 1;
0x40147d    movl   $0x1,-0x4c(%ebp)
25  j[1][1] = 2;
0x401484    movl   $0x2,-0x3c(%ebp)
26  j[1][k] = 3;
0x40148b    mov    -0x4c(%ebp),%eax
0x40148e    add    $0x2,%eax
0x401491    movl   $0x3,-0x48(%ebp,%eax,4)

再来看数组与指针运算的部分,这段汇编代码里,先将数组的首地址 -0x48(%ebp) 拷贝到指针p的内存中。在进行数组的指针运算之前,需要了解数组每一个维数对应的元素数量,这个值是直接作为指针运算时的参考值。一个规则就是,指针指向整个数组时,指针每加1表示地址移动一个数组的字节数;指向数组某一维时,指针每加1表示地址移动这个维数里的所有元素所占的内存字节数。所以,第29行代码中,p直接加1,表示地址偏移了整整一个数组j所占的字节数,即 4x4=16字节,所以汇编指令 0x4014a9 处要加上偏移值 0x10。而这里的内存实际上并不属于数组的,所以这里真实的情况就是数组越界!第30行源代码也可以知道存在数组越界的情况,但对于两个方括号的值再分析清楚。方括号的使用,地址的计算就是按上面的规则进行的,注意方括号优先于星号,所以这里实际的地址偏移为 4x(4x2)。到31行代码,对指针每个星号运算表示向维度减小的方向降一个维度,所以这里偏移值为 4x(2x1)=8。第32行,方括号的数字之前,指针只进行了一个星号的运算,所以方括号的数和圆括号的数具有等价的功能,实际偏移为 4x(2x(1+1)),结果和29行代码做了一样的事情,越界访问:

28  volatile int (*p)[2][2] = &j;
0x4014a0    lea    -0x48(%ebp),%eax
0x4014a3    mov    %eax,-0x1c(%ebp)
29  *(p+1)[0][0] = 5;
0x4014a6    mov    -0x1c(%ebp),%eax
0x4014a9    add    $0x10,%eax
0x4014ac    movl   $0x5,(%eax)
30  *(p+1)[1][0] = 6;
0x4014b2    mov    -0x1c(%ebp),%eax
0x4014b5    add    $0x20,%eax
0x4014b8    movl   $0x6,(%eax)
31  *(*p + 1)[0] = 7;
0x4014be    mov    -0x1c(%ebp),%eax
0x4014c1    add    $0x8,%eax
0x4014c4    movl   $0x7,(%eax)
32  *(*p + 1)[1] = 8;
0x4014ca    mov    -0x1c(%ebp),%eax
0x4014cd    add    $0x10,%eax
0x4014d0    movl   $0x8,(%eax)

引用的本质

引用 Reference 这东西是非常容易引人误解的东西,因为指针存在一个叫解引用 Dereference 的过程,通过指针解引用就可以直达变量的内容。来看这组C语言语句,定义了变量 j、引用 k 及数组a, 还有指向它们的指针或引用:

volatile int j=1;
volatile int &k = j;
volatile int *p1 = &j;
volatile int *p2 = &k;
volatile int a[2] = {1,2};
volatile int (&r)[2] = a;

如果学过C语言理解上面的代码是没有问题的,但会以汇编的角度来看代码,脉络会更清晰。反汇编后得到相对应的指令显示,编译器将内存地址 -0x44(%ebp) 分配给了变量 j,内存地址 -0x1c(%ebp) 则分配给了引用 k,并且将 j 的地址转存到 k 的内存中。再来看指针 p1,发现除了编译器给 p1 分配的内存地址 -0x20(%ebp) 不同外,其它的都和引用 k 是一致的。

26      volatile int j=1;
0x401478    movl   $0x1,-0x44(%ebp)
27      volatile int &k = j;
0x40147f    lea    -0x44(%ebp),%eax
0x401482    mov    %eax,-0x1c(%ebp)
28      volatile int *p1 = &j;
0x401485    lea    -0x44(%ebp),%eax
0x401488    mov    %eax,-0x20(%ebp)
29      volatile int *p2 = &k;
0x40148b    mov    -0x1c(%ebp),%eax
0x40148e    mov    %eax,-0x24(%ebp)

再来看看数组部分,前两条 movl 指令是将立即数 1 和 2 保存在内存中,再将其转存到寄存器 eax 和 edx 中,最后才将值填入数组中,数组的开始地址就是 -0x4c(%ebp),往低位偏移4个字节即一个整数的距离 -0x48(%ebp) 处就是数组的第二个元素。初始化过程显得累赘了点,这是因为代码是没有优化的,所以编译器想怎样来就怎样方便。注意后两条指令,它则是初始化引用 r 的机器码,可以发现内存地址是 -0x28(%ebp),这与数组的地址是不同的,也就是说引用也像指针一样占了内存。特别地,数组变量 a 也是引用,所以它也要占内存吗?我这个问题是指数组变量 a 和数组的内容要占据相应的内存吗?下面的机器码就可以显示这一切,数组变量是个引用,但它本身却没有点存储空间,或者更准确地说它所占的空间和数组的内容是重合的。

30      volatile int a[2] = {1,2};
0x401491    movl   $0x1,-0xa0(%ebp)
0x40149b    movl   $0x2,-0x9c(%ebp)
0x4014a5    mov    -0xa0(%ebp),%eax
0x4014ab    mov    -0x9c(%ebp),%edx
0x4014b1    mov    %eax,-0x4c(%ebp)
0x4014b4    mov    %edx,-0x48(%ebp)
31      volatile int (&r)[2] = a;
0x4014b7    lea    -0x4c(%ebp),%eax
0x4014ba    mov    %eax,-0x28(%ebp)

有人认为数组名就是指向数组内容的指针,实情是不是呢?上面的代码已经解析了这一切,引用 r 所在的内存地址是 -0x28(%ebp),而数组地址是 -0x4c(%ebp) 分别占用了不同的内存空间。从本质上讲,引用和指针是等价概念,这点是可以理解的,但是不能简单地将数组名等价当作指针。否则的话,上面的汇编代码应该为 a 另外分配一个内存空间,并将数组的内容的地址 -0x4c(%ebp) 存放到相应的内存里才对。但是通过GDB调试命令可以看到有趣的信息,数组变量 a 的地址和引用 r 的地址是同一个,这就取决调试器的功能设计了,机器才是真实的情形。

> p &a
$9 = (int (*)[2]) 0x28fc8c
> p &r
$10 = (int (*)[2]) 0x28fc8c
> p a
$11 = {1, 2}
> p r
$12 = (int (&)[2]) @0x28fc8c: {1, 2}

要证明这一点只需要将基址寄存器寻址的数据打印出来就好了,以下结果会分别显示 a 和 r 的真实内存地址:

> p ($ebp)-0x4c
$13 = (void *) 0x28fc8c
> p ($ebp)-0x28
$14 = (void *) 0x28fcb0

所以结论很明显,从机器码的角度看,引用和指针就是等价概念,通过引用或指针都可以找到目标所在的内存地址。而从编程语言规范层面去看,它们的差别也很明显。首先,指针需要通过 * 星号运算才取得目标的引用,这个运算称解引用,可以理解为求解得到引用。其次访问成员时的操作时,引用可以直接的圆点表示,而指针则更麻烦需要使用 (*P). 或 p-> 这样形式,这一点差别各前一条是共同的内容不同视角而已。因此可以将引用看作是半自动化的指针更为恰切,使用引用时确实也不用编定解引用的运算符号,就这一点来看就有编译器的自动化处理作用。再有,指针变量可以改变其指向的地址,而引用不可以,至少在语言规范下是不可以的,通过修改内存改变引用指向的方法不算。还有一点,编译器在给引用和指针分配内存地址时的规则也不一样,这一点是比较隐诲的差异。

综合这些内容,可以给引用定下个接地气的概念:引用就是半自动化的指针!

C语言之父丹尼斯·里奇 (Dennis M. Ritchie,1941-2011)在他的《The C Programming Language》并没有将引用当作一个术语来看待,他只是在使用到引用的地方解释了引用是什么。

Dennis M. Ritchie,1941-2011

比如在函数参数传递的过程中,他在书中表示,C语言只有传值调用 Call-by-Value,这也就是说他本人设计的C语言没有引用传参 Call-by-Reference 这说。而当C++语言被开发出来后,引用传参的方式就非常受欢迎。而目前在用的编译器基本上都是C/C++混合体,所以基本上也认为C言语也使用引用传参了。

1.8 Arguments - Call by Value
One aspect of C functions may be unfamiliar to programmers who are used to some other languages, particulary Fortran. In C, all function arguments are passed by value. This means that the called function is given the values of its arguments in temporary variables rather than the originals. This leads to some different properties than are seen with call by reference languages like Fortran or with var parameters in Pascal, in which the called routine has access to the original argument, not a local copy.

在书的附录中,也提到数组变量就是引用,通过下标来访问元素的方法就是利用数组定义时元素的占用内存的字节数和引用地址进行指针运算的一类内存定位方法。

A.7.3.1 Array References
A postfix expression followed by an expression in square brackets is a postfix expression denoting a subscripted array reference. One of the two expressions must have type pointer to T, where T is some type, and the other must have integral type; the type of the subscript expression is T. The expression E1[E2] is identical (by definition) to *((E1)+(E2)). See Par.A.8.6.2 for further discussion.

引用与类实例

和数组相类似的引用,还有结构体,联合体,类实例等等,用 Pancake 这样一个类来测试:

class Pancake
{
    public:
        Pancake(){ }
        Pancake(float i){ weight = i; }
        Pancake(int &i){ weight = i; }
        Pancake& operator= (Pancake *pk)
        {
            delete this;
            return *pk;
        }
    private:
        float weight;
};

//=============== TEST CODE ================
Pancake p;
float j = 2;
Pancake pq(j);
int k = 2;
Pancake pk(k);
pk = new Pancake();

这个 Pancake 类有一个显式的默认构造函数,还有另外两个重载构造函数,分别接收浮点、整数作为参数,由于这些显式构造函数的存在,编译就不会添加隐式的默认构造函数了。另外重载了赋值运算符,它从一个本类型的指针中取得引用并返回给左值,注意这点,这样在使用 new 的时候就不用指针了。第一次分析自定义类的机器码,上面简简单单的两条测试语句就衍生出下面众多的机器码。C++引入的新机制带来便利的同时,也直接增加了机器硬件的负担。因此,那些像PHP、Javascript这类解析型的脚本语言带来使用上的极大便利,是以加重机器负担为基础的。

初入编程门时,很多人都分不清楚定义一个原生数类型的变量和定义一个自定义类实例的区别,特别是第一条测试语句。看这个例子对应的机器码就很明显了,最大的差别在于,类的实例化是和构造函数直接关联的,机器码首先将编译器分配好的内存地址,放到寄存器 ecx 上,然后调用默认构造,这个 call 指令会先将当前指令指针寄存器 eip 压栈,执行到 call 指令时,指令指针 eip 是指向紧跟 call 之后的那条指令的。

42      Pancake p;
0x401478    lea    -0x3c(%ebp),%eax
0x40147b    mov    %eax,%ecx
0x40147d    call   0x685580 <Pancake::Pancake()>

先通过GDB命令将 Pancake 默认构造函数的机器码打印出来,前三条指令是在管理堆栈,先是将基址指针 ebp 压栈,再将堆栈指针 esp 拷贝到基址指针,再紧接着将堆栈指针下移4个字节,这四个字节就是压栈 ebp 时使用的。所以整个过程就是在建立一个新的栈层,函数内部定义变量时,内存就是从这个栈层里分配的。每使用多少字节内存,堆栈指针就要往下偏移多少个字节。当函数返回时,就需要将原有的栈层恢复,也就是净基址指针 ebp 恢复到堆栈指针,再从堆栈中恢复原来的基址指针内容,做这个恢复工作的就是 leave 指令,返回指令 ret 则是将 call 指令执行时入栈的指令指针 eip 还原出来,这样程序控制点就可以从函数中返回到调用函数的地方断续执行了。回到类实列化主题上来,前面已经知道,类变量的地址已经保存在 ecx 上。在以下汇编指令的 +6 处就有相关的动作,先是拷贝一份到堆栈,再拷贝一份到 edx。最后只是将内存的一个值拷贝到到类变量存储的内存中,这个值就是前面定义好的类在内存中的数据,因为这个类只有 weight 一个成员,没有其他内容,所以这个类实列的拷贝动作显得相当的隐蔽,不小心去看,根本不会留意到这是一个类的实列化过程。这就是一个典型的默认构造函数,基本上是什么什么也不做的函数:

> disas 0x685580
Dump of assembler code for function Pancake::Pancake():
   0x00685580 <+0>: push   %ebp
   0x00685581 <+1>: mov    %esp,%ebp
   0x00685583 <+3>: sub    $0x4,%esp
   0x00685586 <+6>: mov    %ecx,-0x4(%ebp)
   0x00685589 <+9>: mov    -0x4(%ebp),%edx
   0x0068558c <+12>:    mov    0x6a80a4,%eax
   0x00685591 <+17>:    mov    %eax,(%edx)
   0x00685593 <+19>:    leave  
   0x00685594 <+20>:    ret    
End of assembler dump.

程序接着运行,再来看看另一个通过传值调用的构造函数,通过前两条指令可以看到变量 j 的地址是 -0x1c(%ebp),在类的初始化代码中,会将它的值转存到 eax 并通过 mov 指令将其压入堆栈,这就是传值过程。注意参数是浮点数,2.0 在内存的数据就是 0x40000000。这样做的结果是数据入栈了,但堆栈指针没变化,所心如果再执行一条 push 入栈指令,则这个数据就破坏掉了。现在关心的实列 pq 的情况,初始化代码又再次使用 ecx 来保存类变量的指针了,因些可以推断编译器就以 ecx 作为类实例化过程的对象指针来用的:

43      float j = 2;
0x401482    mov    0x6a80a8,%eax
0x401487    mov    %eax,-0x1c(%ebp)
44      Pancake pq(j);
0x40148a    lea    -0x40(%ebp),%edx
0x40148d    mov    -0x1c(%ebp),%eax
0x401490    mov    %eax,(%esp)
0x401493    mov    %edx,%ecx
0x401495    call   0x685568 <Pancake::Pancake(float)>
0x40149a    sub    $0x4,%esp

这里提示一下如何通过GDB来获取汇编指令的对应机器码,以上面的 call 指令为例,知道它的开始地址,然后通过后一条指令可以计算出整条 call 指令占5个字节,通过GDB的内存检查命令可以查询到这5个字节的机器码是什么:

> x/5b 0x401495
0x401495 :  0xe8    0xce    0x40    0x28    0x00

通过 Intel 手册可以知道 E8 这个机器码对应的汇编是 CALL rel16CALL rel32 这两条近跳转指令。如何读懂机器码,可以参考第二部分或以前发布的文章《反汇编基本原理与x86指令构造》。在当前的32位环境下,后面的四个字节就是跳转的偏移地址了,因为这四个字节是一个整数一个整体,而且是一个符号整数,按x86的 Little-Endian 规则,它本来的值就是 0x002840ce,是一个正整数。偏移值是相对下一条将要执行指令地址来说的,因为CPU执行 call 指令时,指令指针 eip 就已经指令下一条指令了,所以真实的跳转地址就是应该这样计算:

addr. = 0x40149a + 0x2840ce = 0x685568

回来接着分析代码,由于前后两个构造函数都很相似,可以接着上面的分析往下走,通过这个例子可以解答引用传参究竟是神马情况。毫无意外地,这次 ecx 还是被当作实例指针来用了,与前一例不同的的是参数 k 的地址被拷贝到了堆栈。

45      int k = 2;
0x40149d    movl   $0x2,-0x44(%ebp)
46      Pancake pk(k);
0x4014a4    lea    -0x48(%ebp),%eax
0x4014a7    lea    -0x44(%ebp),%edx
0x4014aa    mov    %edx,(%esp)
0x4014ad    mov    %eax,%ecx
0x4014af    call   0x685548 <Pancake::Pancake(int&)>
0x4014b4    sub    $0x4,%esp

同时注意调用构造函数前,参数入栈并不是通过 push 指令,而是通过 mov 指令实现的,这意味着什么呢?要知道 CALL 指令的执行过程,手册提供了指令的执行伪码,它解析了指令在CPU内部的执行过程,它显示 CALL 指令是先向下移动堆栈指针的指向,再将源操作数压栈的,因此使用 MOV 指令入栈意味着之前的入栈的数据被覆盖掉了:

IF OperandSize = 64
    THEN
        ESP ← ESP – 8;
        Memory[SS:ESP] ← SRC; (* push quadword *)
ELSE IF OperandSize = 32
    THEN
        ESP ← ESP – 4;
        Memory[SS:ESP] ← SRC; (* push dword *)
ELSE (* OperandSize = 16 *)
    ESP ← ESP – 2;
        Memory[SS:ESP] ← SRC; (* push word *)
FI;

这里再试着反汇编这条入栈用到的 MOV 指令,算是对第二部分内容的复习。用GDB命令将其机器码打印出来,可以看到它的指令码是 0x89,如果会使用一些反汇编工具如 OllyDbg, IDA会更有效率,用 OllyDbg 找到任意和程序文件,就可以在代码中使用 Ctrl+E 来编辑二进制机器码,使用 Space 就可以用来使用汇编指令来生成机器码:

> x/3xb 0x4014aa
0x4014aa <overloadApp::OnInit()+128>:   0x89    0x14    0x24

通过指令摘要表找到和这个机器码对应的指令有以下两个,那么接下来要通过 ModR/M=0x14 来确定操作数的寻址及指令的其它组成部分:

89 /r MOV r/m16,r16 MR (MR 指示 Reg 指定目标操作数)
89 /r MOV r/m32,r32 MR (MR 指示 Reg 指定目标操作数)

ModR/M      Mod Reg/Opcode R/M
0001 0100   00  010        100

Reg=010 可以确定指令的源操作数为 EDX 或 DX。R/M=100 指示指令还有 SIB 这个字节:

SIB         Scale               Base            Index
0010 0100   00 -> [Index]*1     100 -> [ESP]    100 -> [NONE]

通过分解,SIB确定了目标操作数为 [ESP],即 32-bit 寄存器,同时,因为是 32-bit 程序 此可以推断得到源操作数应为 EAX,无立即数及偏移地址值,因此指令解码完成,指令机器码刚好是前面显示的3个字节,而对应的汇编命令是:

MOV %EAX, (%ESP) # AT&T
MOV [ESP], EAX   # Intel

在调用构造函数后,紧接一个 sub 指令来管理堆栈,其作用随后讲到。进入构造函数,前三条指令依然是在做栈层管理,只是栈顶往下移动了8个字节,这意味这突出两个整形的空间,分别用来保存基址指针 ebp 和传入参数 k。

29          Pancake(int &i){ weight = i; }
0x685548    push   %ebp
0x685549    mov    %esp,%ebp
0x68554b    sub    $0x8,%esp
0x68554e    mov    %ecx,-0x4(%ebp)
0x685551    mov    0x8(%ebp),%eax
0x685554    mov    (%eax),%eax
0x685556    mov    %eax,-0x8(%ebp)
0x685559    fildl  -0x8(%ebp)
0x68555c    mov    -0x4(%ebp),%eax
0x68555f    fstps  (%eax)
0x685561    leave
0x685562    ret    $0x4

接下来的四条 mov 指令的操作好像是在 eax 这里打转,先是类实列引用 ecx 拷贝到新栈层开始处存放。-0x4(%ebp)这里的-4就是预留4个字节空间,因为在数据入栈时,堆栈指针是往下偏移的,而数据写入内存时是从低往高写入的,所以这里使用偏移值是个负数,表示栈层的第一个变量空间。接下来三条 mov 指令先将参数读取存入 eax,注意,因为是引用传参数,这里就体现了引用的传参的作用了。接下来 0x685554 处的 mov 指令是将 eax 作为一个指针,将其指向的内容读取出来并直接存放到 eax 中,这里 eax 就保存了参数 k 的原始值。fildl 指令则负责将整形参数送入浮点处理单元的寄存器栈 FPU Stack 中将其转换成浮点数,稍后透过 fstps 指令读取。也因为是这个类的结构简单,所以 fstps 这条指令将浮点提取保存到 eax 指定的地址中的同时,类实例就完成了初始化的工作。最后构造函数返回时,ret 指令使用了一个 16bits 的立即操作数,表示返回动作需要堆栈弹出多少字节的内容,这些字节是在恢复指令指针 eip 的情况下,再做的弹栈动作,也就是说 ret $0x4 这条指令总共弹栈的数据为 8 个字节。而调用函数时,call 指令只压栈了 eip 共为 4 个字节,而退出函数时,却在堆栈中弹出了 8 个字节,这就使用得堆栈前后不平衡了。因些,前面提到 sub 指令目的就是用来平衡堆栈的。至于编译器为何要在函数内部的返回指令中额外弹栈,完全由编译的算法决定,同时也可能是没有经过代码优化,所以产生了一些无作用指令。

透过以上 Pancake 类的分析,和主题相关内容已经分解的比较全面了。接下来的内容涉及语言课程一般不涉及的内存管理,试着通过结合 new 关键字来分析 Pancake 类实例化及内存回收的过程。

new/malloc 选美之争

在刚学习C语言的时候,关键字是既简单又复杂的事,简单是因为用它只是打几个字母的事而已,不像汇编要知道指令叫什么又要知道指令会干些什么。而复杂是因为这几个字母背后代表着编译程序的一套算法规则,还有相应的机器指令。而C++标准作为C标准的一个升级版,它不仅要继承C标准的 malloc 和 free 函数,还要为C++面向对象编程车策略提供实现,new 和 delete 这两个关键字就是内存管理方面的实现。内存管理是C++标准的一个组成部分,又或者更直接说是C++编译器必要实现的一个功能。它提供一个用于内存分配的类,allocator 这个模板类就可以用来管理对象的内存。

要区别 new 和 malloc,上面已经提到它们的差别。从更底层的技术面来看,C语言作为一种功能简洁的高级语言,它不需要实现面向对象编程的一系列复杂的对象关系。所以C程序需要的动态内存时,只需要透过 malloc 动态分配就好,它只返回系统中可用的一块内存地址,用完之后只需要透过 free 函数通知系统收回。

而C++的内存管理却不是这么简单的事情,当一个复杂的对象被定义时,它可能需要使用大量的内存分配工作。例如一个动态数组,在它的元素很少时,只需要少量内存就可以了,当元素动态增加超出已分配内存数量时,就需要重新分配一个更大的内存块。而C++作为面向对象的语言 Object-Oriented Programming,所有对象都像变量一样有存活期,从生效到失效;如这里提到的动态数组对象,被创建时需要动态分配内存;当对象失效时,比如局部定义的对象在函数退出后。动态分配的内存块就会来了一个潜在的问题:对象创建时、失效了,它动态分配的内存怎么办?C++的实现就需要这样一套机制来处理这个问题,这也就是为什么C++的对象需要引入构造函数 Constructor 和析构函数 Destructor 的原因之一,然而需要注意构造函数的内涵并不局限于此。而 new 和 delete 两个关键字则是构造函数和析构函数自动执行的实现,这也是 new 和 malloc 的最大区别。在底层上来讲,new 也需要通过调用 malloc 来为对象分配在编译阶段可以确定的内存数量,这部分由 new 引用的内存分配由系统自行管理,当对象失效时就会被完全回收。而用户动态分配的内存则不然,它需要自行处理分配与释放。

作为对比,malloc 和 new 的最大相同点就是它们都是动态内存分配,所以它们需要配对使用 free 和 delete。在函数内通过 new 来实列化对象或变量,当函数退出后它们还依然是有效的,可以在其它地方释放它。当然一个强壮的操作系统不会等待程序员来管理那此没有显式释放的内存,只要程序结束,程序使用的所有内存都会被回收。但是想要开发强壮的程序,就必要有必要的内存管理策略,否则永远只会申请内存而不会释放过期的内存,程序终会把系统的内存都吃光,这就是所谓的内存泄漏 Memory Leak。

int ia = 1;             // 自动释放内存
int *ip = new int(2);   // 手动释放整形变量内存,但指针所占的内存会自动释放
Pancake pk;             // 自动释放内存
pk = new Pancake();     // 手动释放,delete(&pk)

注意 Pancake 类重载了赋值运算符,它从一个本类型的指针中取得引用并返回给左值,所以使用 new 的时候没有指针。C++作为一个具有重载机制的语言,当把 new 作为一个函数看待时,就可以对它进行重载,C++本身已经内置了 new 函数的各种重载版,而上面的 new 表达式,注意这两种称谓的差别,new 表达式和 new 函数是C++内置的两种不同的东西。因为它们差别的细微极可能引起读者的误解,特别是中文读者。和 new 函数一样,delete 函数也是有对应的重载版本的,当使用 delete 表达式回收内存时,就会调用重载的 delete 函数。来看看重载的 new、delete 库函数的定义:

void *operator new(size_t);       // allocate an object
void *operator new[](size_t);     // allocate an array

void *operator delete(void*);     // free an object
void *operator delete[](void*);   // free an array

我试着追踪到 new 重载函数,但是汇编指令确实杂乱,也就不分析下去了。本文使用的是 GCC 4.7.1 编译器,在GCC源代码目录 libsupc++ 下可以找到 new 关键字的实现源代码,有兴趣可以一探究竟。GCC支持多种语言,它的源代码包中含有各种语言的库,以lib打头的目录就是。C/C++语言库对应目录是 libcpp,它包含了C/C++ 的词法分析和预处理,这就是 C/C++ 的编译器实现源代码,想看懂这里面的内容就学点编译原理课程。另外还有一部分在 gcc 目录下,对应不同的硬件平台。要了解 malloc 源代码,可以下载GNU实现的C语言库 glibc。

前面的 new 表达式背后其实就是对重载 new 函数的调用,后面的反汇编代码中可以看到被调用的重载 new 函数,它接收了一个参数,用来表示 Pancake 类实列的内存占用字节数。很明显,Pancake 类实列的大小为 $0x4,刚好只够用来保存 weight 成员,而类的函数成员放哪了呢?从初始化调用的构造函数地址可以看到,它位于内存的高地址处。其实每个对象都有自己的数据,这一块可以等价为结构体,但是所有实列都共享类成员函数。当使用this指针去调用成员函数时,是涉及两个地址的,一个是类实例的地址,另一个是类成员函数的地址,编译器为二者的联系提供低层支持。通过这段指令,关于类实例化的意义似乎就更清晰了,所谓类实例化就是根据类定义,将所有数据成员都保存到新分配的数据空间,并通过构造器初始化数据的一个过程。

48      pk = new Pancake();
0x4014cb    movl   $0x4,(%esp)
0x4014d2    movl   $0x1,-0x9c(%ebp)
0x4014dc    call   0x647af8 <operator new(unsigned int)>
0x4014e1    mov    %eax,-0xa4(%ebp)
0x4014e7    mov    -0xa4(%ebp),%ecx
0x4014ed    call   0x685590 <Pancake::Pancake()>
0x4014f2    lea    -0x50(%ebp),%eax
0x4014f5    mov    -0xa4(%ebp),%edx
0x4014fb    mov    %edx,(%esp)
0x4014fe    mov    %eax,%ecx
0x401500    call   0x6855a8 <Pancake::operator=(Pancake*)>
0x401505    sub    $0x4,%esp

注意,这里说的实列初始化并不是指有等号在这,是因为 new 表达式产生了 Pancake 实例化的动作,而等号实际上是在执行重载的赋值运行符功能 operator=。追踪 new 函数会发现它也要通过 malloc 来申请内存,调用重载 new 函数后,%eax 返回分配内存地址。这个地址指向的是一块未初始化的内存,它将要送入构造函数进行初始化设置。0x4014d2 处的指令纯属干扰,重新编译后并未出现这样的无聊代码,完全可以忽视它。

在第二条 call 指令,即构造函数后出现的 -0x50(%ebp) 是 pk 的地址。追踪到第三条 call 指令,即重载赋值运算符执行时,源代码中含有代码用来清理 pk 原有数据,为下一步的赋值作准备,这相当自动回收内存过程。重载赋值运算符结尾通过 %eax 来返回新的类实例地址,但是,返回值并未被使用,见上面的指令,0x401500 行后并未见有任何指令将新实例的地址关联到 pk 上。所以类定义有错误,重载运行算符时,仅返回正确的值并没有将值与目标变量关联,也就是说48行源代码的等号并没有像内置的数据类型那样正常赋值,这点可以说是重载赋值运算的一大差别。至此,我又发现C++是个让人恶心的编程语言,我最受不了的是 -> 这样的表达,构造函数就不能返回引用吗!非得返回指针,C++这点真恶心!所以,正确的赋值运算符重载的代码应该是实现数据成员的拷贝功能,而不是替换指针,而且 this 是个常量不可更改,除非从编译器的实现层面来修改。

尽管,通过上面的分析,类实列化的过程已经了然于掌,但却不能滥用构造函数。构造函数,作为C++语言中弊病最多的地区之一,还是以简洁为妙。C++构造函数中不规矩的地方还有,拷贝构造函数中可以直接访问参数对象的私有成员,这个功能不就是拷贝构造函数的特权吗!设想,通过任意的定义分配一个内存空间,并将这个空间转型为 Pancake 类实列,然后显式调用构造函数来强制初始化。

Pancake init(int i){
    weight = i;
    return *this;
}

有了上面这个初始化函数,就可以这样写代码来手动实例化 Pancake,重点提示,在使用 reinterpret_cast 前要知道自己在干什么,也就是要知道它背后的指令在干什么:

char buf[sizeof(Pancake)];
Pancake ps = *( reinterpret_cast<Pancake *>(buf) );
ps.init(3);

来分析一下手动初始化的反汇编指令,指令显示,buf 的内存地址是 -0x50(%ebp),而重析转型后的 ps 引用存于 -0x54(%ebp)。源代码很清晰地表达了,buf 这块内存只是一个字符数组空间而,但通过 ps 执行初始化函数时,却是正确地调用了这个函数。指令到此,已经不用进入初始化函数,去检查 this 指针能不能正确指向 buf 的所在了,因为 this 指针总是通过 %ecx 作为参数传入函数内的。所以,手动实列化类对象是不是很神经的事?并不是,只是利用 C++ 的实现机制而已:

55      char buf[sizeof(Pancake)];
56      Pancake ps = *( reinterpret_cast<Pancake *>(buf) );
0x40152c    lea    -0x50(%ebp),%eax
0x40152f    mov    (%eax),%eax
0x401531    mov    %eax,-0x54(%ebp)
57      ps.init(3);
0x401534    lea    -0x54(%ebp),%eax
0x401537    movl   $0x3,(%esp)
0x40153e    mov    %eax,%ecx
0x401540    call   0x6855a8 <Pancake::init(int)>
0x401545    fstp   %st(0)
0x401547    sub    $0x4,%esp

当用 new 表达式来实例化对象数组时,如以下这样,其实就是在背后调用重载的 new[] 函数来实列化对象:

new Pancake[12];

50      Pancake pa[10];
0x4015c8    lea    -0x7c(%ebp),%eax
0x4015cb    mov    %eax,-0xe4(%ebp)
0x4015d1    movl   $0x9,-0xe8(%ebp)
0x4015db    jmp    0x4015f5 <overloadApp::OnInit()+459>
0x4015dd    mov    -0xe4(%ebp),%ecx                    <--+
0x4015e3    call   0x6856a0 <Pancake::Pancake()>          |
0x4015e8    addl   $0x4,-0xe4(%ebp)                       |
0x4015ef    decl   -0xe8(%ebp)                        loop|
0x4015f5    cmpl   $0xffffffff,-0xe8(%ebp)                |
0x4015fc    setne  %al                                    |
0x4015ff    test   %al,%al                                |
0x401601    jne    0x4015dd <overloadApp::OnInit()+435> --+

反汇编代码中并未出现预期的 operator new[],而是以一个循环替代了,对于带初始化参数的形式:

Pancake ps[10] = {1,2};

49      Pancake ps[10] = {1,2};
0x401515    lea    -0x7c(%ebp),%edx
0x401518    mov    0x6a80ac,%eax
0x40151d    mov    %eax,(%esp)
0x401520    mov    %edx,%ecx
0x401522    call   0x685628 <Pancake::Pancake(float)>
0x401527    sub    $0x4,%esp
0x40152a    lea    -0x7c(%ebp),%eax
0x40152d    lea    0x4(%eax),%edx
0x401530    mov    0x6a80a8,%eax
0x401535    mov    %eax,(%esp)
0x401538    mov    %edx,%ecx
0x40153a    call   0x685628 <Pancake::Pancake(float)>
0x40153f    sub    $0x4,%esp
0x401542    lea    -0x7c(%ebp),%eax     #-----LOOP #1------
0x401545    add    $0x8,%eax
0x401548    mov    %eax,%ecx
0x40154a    call   0x685640 <Pancake::Pancake()>
0x40154f    lea    -0x7c(%ebp),%eax     #-----LOOP #2------
0x401552    add    $0xc,%eax
0x401555    mov    %eax,%ecx
0x401557    call   0x685640 <Pancake::Pancake()>
...

在 C++ Primer 的最后一章讲到内存分配的优化 Optimizing Memory Allocation,其中讲到 new 函数的另一种重载方式 placement new,placement 可以理解为“安插”,安插人手的意思。这种方式的 new 使用一个已经分配好的缓冲区来初始对象,这样做的好处是不用执行内存的申请,从预先分配的内存中实列化对象而加速程序的执行速度,这种初始化可以用前面提到的手动初始化来实现:

new (place_address) type
new (place_address) type (initializer-list)

placement new 定义有以下这两种:

void* operator new (std::size_t size, void* ptr) throw();
void* operator new[] (std::size_t size, void* ptr) throw();

当然,定义类时也可以自行重载 new 和 delete 函数,但它们必需同时配对重载。考虑到重载 new 带来的复杂度,更好的办法还是创建一个类来管理内存。

有人说,既然使用C++编程了,那就不要用 malloc 来分配内存了,这样才正宗嘛。其实这是肤浅的看法,因为 new 和 malloc 在分本内存的目的是有差异的,在不同的场合使用正确的内存分配策略才是关键。当然想要用好C++这个花姑娘,尽管现在C++11标准也出来了,请把代码写得朴实点,不用或少用花哨的功能。不然等到一过完年自己写的代码都不认得,那可就是天大的悲哀,搬了花瓶砸自己的脚。

资源参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值