探讨C++ 变量生命周期、栈分配方式、类内存布局、Debug和Release程序的区别

释放双眼,带上耳机,听听看~!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
1#include "stdafx.h"
2#include<iostream>
3#include<windows.h>
4using namespace std;
5
6class T
7{
8protected:
9    int t;
10public:
11    T(int r=0):t(r){}
12    void showNum(){cout<<t<<endl;}
13};
14
15class T1:public T
16{
17private:
18   int x;
19public:
20    T1(int r):x(r),T(r){}
21    void show(){cout<<"x="<<x<<endl;}
22};
23
24class T2:public T
25{
26private:
27    int x;
28public:
29    T2(int r):x(r*r),T(r){}
30    void show(){cout<<"x="<<x<<endl;}
31};
32
33void main()
34{
35    T* p[10];
36    for(int i=0;i<5;i++)
37    {
38        if(i%2==0)
39        {
40            T1 r(2);
41            p[i]=&r;
42            cout<<&r<<endl;
43        }
44        else
45        {
46            T2 r(3);
47            p[i]=&r;
48            cout<<&r<<endl;
49        }
50    }
51    for(int i=5;i<10;i++)
52    {
53        if(i%2==0)
54        {
55            T1 r(4);
56            p[i]=&r;
57            cout<<&r<<endl;
58        }
59        else
60        {
61            T2 r(5);
62            p[i]=&r;
63            cout<<&r<<endl;
64        }
65    }
66    for(int i=0;i<10;i++)
67    {
68        p[i]->showNum();
69    }
70    system("pause");
71}
72

Debug版本:

当0 <= i < 5时,输出5个地址,是一种交替状输出,分别是001FF750和001FF740。

当5 <= i < 10时,又输出5个地址,也是交替状输出,分别是001FF714和001FF724。

最后循环调用p[i]->showNum()这个方法10次,输出的结果是正确的,似乎那些栈中的局部对象未失效。

 

Release版本:

当0 <= i < 5时,输出5个地址,是一种交替状输出,分别是001CFB00和001CFB08。

当5 <= i < 10时,又输出5个地址,也是交替状输出,奇怪的是地址的值也是001CFB00和001CFB08。

最后循环调用p[i]->showNum()这个方法10次,输出的结果全是5和4,似乎前5次输出失效了,后5次没有失效。

1、Debug版本输出现象解析

先来说说Debug版本的输出,前5次输出,交替输出,后5次输出,交替输出,但是,前5次和后5次的地址是不一样的。

我们来看看反汇编:


1
2
3
4
5
6
7
8
9
1T1 r(2);
201363A0D  push        2  
301363A0F  lea         ecx,[r]
401363A12  call        T1::T1 (1361172h)
5              p[i]=&amp;r;
601363A17  mov         eax,dword ptr [i]
701363A1A  lea         ecx,[r]
801363A1D  mov         dword ptr p[eax*4],ecx
9

关键是看对象r的地址是如何分配的,但是,反汇编中似乎没有明显的信息,只有一句:lea  ecx,[r],这条语句是什么意思呢?将对象r的地址取到通用寄存器ecx中。

我们知道,程序在编译链接的时候,变量相对于栈顶的位置就确定了,称为相对地址确定。所以,此时程序在运行了,根据所在环境,变量的绝对地址也就确定了。

通过lea指令取得对象地址,调用对象的构造函数来进行构造,即语句call  T1::T1 (1361172h). 构造完之后,对象所在地址的值才被正确填充。

 

好了,我们知道了这些局部变量相对于栈的相对地址,其实在编译链接的时候就确定了,那么,这个策略是什么样的呢?就是说,编译器是如何来决定这些局部变量的地址的呢?

一般来说,对于不同的变量,编译器都会分配不同的地址,一般是按照顺序分配的。但是,对于那些局部变量,而且非常明显的生命周期已经结束了,同一个地址,也会分配给不同的变量。

举个例子,地址0X00001110,被编译器用来存放变量A,同时也可能被编译器用来存放变量B,如果A和B的大小相等,并且肯定不会同时存在。

 

编译器在判断一个地址是否能够被多个变量同时使用的时候,这个判断策略取决于编译器本身,不同的编译器判断策略不同。

微软的编译器,就是根据代码的自身逻辑来判断的。当编译器检测到以下代码的时候:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1for(int i=0;i&lt;5;i++)
2    {
3        if(i%2==0)
4        {
5            T1 r(2);
6            p[i]=&amp;r;
7            cout&lt;&lt;&amp;r&lt;&lt;endl;
8        }
9        else
10        {
11            T2 r(3);
12            p[i]=&amp;r;
13            cout&lt;&lt;&amp;r&lt;&lt;endl;
14        }
15    }
16

微软的编译器认为,只需要分配两个地址则可,分别用来保存两个对象,循环执行的话,因为前一次生成对象的生命周期已经结束,直接使用原来的地址则可。

因此,我们在用VS编译这段程序时,就出现了地址交替输出的情况。

当微软的编译器接着又看到以下代码的时候,


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
1for(int i=5;i&lt;10;i++)
2    {
3        if(i%2==0)
4        {
5            T1 r(4);
6            p[i]=&amp;r;
7            cout&lt;&lt;&amp;r&lt;&lt;endl;
8        }
9        else
10        {
11            T2 r(5);
12            p[i]=&amp;r;
13            cout&lt;&lt;&amp;r&lt;&lt;endl;
14        }
15    }
16

微软的编译器认为,需要再分配两个地址,分别用来保存这两个新的对象,

于是,我们再次看到了地址交替输出的情况,只是这一次交替输出的地址与前一次交替输出的地址不同。

延伸1:稍微修改代码再试试

我们已经能够理解VS下Debug版本为什么会输出这样的结果了,再延伸一下,我们把代码进行修改:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1修改前的代码:
2
3    for(int i=0;i&lt;5;i++)
4    {
5        if(i%2==0)
6        {
7            T1 r(2);
8            p[i]=&amp;r;
9            cout&lt;&lt;&amp;r&lt;&lt;endl;
10        }
11        else
12        {
13            T2 r(3);
14            p[i]=&amp;r;
15            cout&lt;&lt;&amp;r&lt;&lt;endl;
16        }
17    }
18

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
1修改后的代码为:
2  if (0 == i)
3      {
4            T1 r(2);
5            p[i]=&amp;r;
6            cout &lt;&lt; &amp;r &lt;&lt; endl;
7        }
8        else if (1 == i)
9        {
10            T2 r(3);
11            p[i]=&amp;r;
12            cout &lt;&lt; &amp;r &lt;&lt; endl;
13        }
14        else if (2 == i)
15        {
16            T1 r(2);
17            p[i]=&amp;r;
18            cout &lt;&lt; &amp;r &lt;&lt; endl;
19        }
20        else if (3 == i)
21        {
22            T2 r(3);
23            p[i]=&amp;r;
24            cout &lt;&lt; &amp;r &lt;&lt; endl;
25        }
26        else if (4 == i)
27        {
28            T1 r(2);
29            p[i]=&amp;r;
30            cout &lt;&lt; &amp;r &lt;&lt; endl;
31        }
32)
33

代码修改之后,功能完全一样,那么前五次循环的输出会有什么不同吗?

也许你猜到了,修改完代码之后,前5次地址输出,是5个不同的地址,按规律递增或者递减。

很明显,代码的改动,编译器的认知也改变了,分配了5个地址来给这5个对象使用。

 

延伸2:GCC编译器是如何编译这段代码的呢?

我们再延伸一下,不同的编译器,对代码的编译是不同的,GCC编译器是如何编译这段代码的呢?默认编译之后,运行结果如下:

 

不用我说,大家也知道了,GCC编译器检测到这些变量生命周期结束了,尽管有十次循环,尽管代码有改动,但是GCC仍然只有分配一个地址供这些变量使用。

理由很简单,变量的生命周期结束了,它的地址自然就可以给其他变量用了,更何况这样变量的大小还是一样的呢!

 

 
2、VS下Release版本输出现象解析:

不再延伸,回到正题,VS下Release版本的表现为什么和Debug版本不一样呢?

同样,我们来看原始代码的反汇编:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1if(i%2==0)
2         {
3              T1 r(2);
4              p[i]=&amp;r;
5              cout&lt;&lt;&amp;r&lt;&lt;endl;
600C11020  mov         ecx,dword ptr [__imp_std::endl (0C12044h)]
700C11026  push        ecx
800C11027  mov         ecx,dword ptr [__imp_std::cout (0C12048h)]
900C1102D  test        bl,1
1000C11030  jne         main+42h (0C11042h)
1100C11032  lea         eax,[esp+14h]
1200C11036  mov         dword ptr [esp+14h],ebp
1300C1103A  mov         dword ptr [esp+18h],ebp
1400C1103E  mov         edx,eax
15         }
16         else
1700C11040  jmp         main+50h (0C11050h)
18         {
19              T2 r(3);
20              p[i]=&amp;r;
2100C11042  lea         eax,[esp+1Ch]
2200C11046  mov         dword ptr [esp+1Ch],edi
2300C1104A  mov         dword ptr [esp+20h],esi
24              cout&lt;&lt;&amp;r&lt;&lt;endl;
25         }
26

Release版本做了进一步的优化,esp内的值在本程序运行的过程中未曾改变,因此,尽管有十次循环,也只分配了两个对象的空间,即两个地址。

最后,我们看到,前5次循环和后5次循环的交替输出的地址是一样的。

 

3、再提一点:最后的十次输出现象解析:

    for(int i=0;i<10;i++)

    {

        p[i]->showNum();

    }

其实是没有意义的,因为这10个指针指向的对象的生命周期早就结束了。

那么为什么还能输出正确的值呢?因为,这些对象的生命周期虽然结束了,但是这些对象的内存没有遭到破坏,仍还存在,并且数据未被改写。

如果此程序后续还增加代码,这些地址的内容是否会被其他对象占用都是不可知的,所以,请不要使用生命周期已经结束了的对象。

 

4、总结:

给大家建议,C++语言的对象生命周期的概念很重要,要重视,另外,使用指针要注意空指针的问题。

有时候,可以直接使用对象的方式,就不要使用太多指针,都是坑!

给TA打赏
共{{data.count}}人
人已打赏
安全技术

C++ lambda表达式

2022-1-11 12:36:11

安全资讯

PHP5 年底停止更新,六成用户将面临安全风险

2018-10-16 16:13:00

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索