我是靠谱客的博主 欢呼芒果,这篇文章主要介绍《程序员自我修养》第十章读书笔记,现在分享给大家,希望可以做个参考。

第十章主要对程序的运行时内存布局进行分析。而本书接下来的几章主要是针对程序的运行环境进行研究。

首先来看程序的内存布局

虽然当前的内存空间使用平坦模型,即整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。但操作系统对于却不是将所有资源都交给用户的应用程序使用。在linux下默认将高地址1GB的空间分配给内核。

一般来讲,应用程序使用的内存空间里有如下“默认”的区域:

栈:用于维护函数调用的上下文(包括main函数),我个人感觉栈就是用于保存程序运行时所需要的参数、信息等。

堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。

可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。

保留区:保留区并不是一个单一的内存区域,而是对内存中收到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。这个区域其实在一定程度上起到了对进程地址空间的保护作用。对于使用空指针或小整型值指针引用内存的情况,操作系统可以马上就进行阻止,并产生“段错误”异常。

动态链接器映射区:这个区域用于映射装载的动态链接库。在linux下,如果可执行文件依赖于其他共享库,那么系统就会为它在从0x40000000(32位操作系统)开始的地址分配相应的空间,并将共享库装入该空间。动态链接器也是加载到这一地址,而后开始自举代码的功能。

在这里还是要给大家分享一篇内容上比较全面的blog:http://www.cnblogs.com/clover-toeic/p/3754433.html

通过对上述几个区域的分析,马上就可以与我们学到的知识联系到一起,这里每一区域就对应着我们在第七章中看到的进程的内存分布。在这里还是给大家先看一个进程的内存分布:

复制代码
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
cat /proc/5735/maps 00400000-00401000 r-xp 00000000 08:08 1187187 /home/andywang/project/DSO/program1 00600000-00601000 r--p 00000000 08:08 1187187 /home/andywang/project/DSO/program1 00601000-00602000 rw-p 00001000 08:08 1187187 /home/andywang/project/DSO/program1 7facdc69a000-7facdc85a000 r-xp 00000000 08:08 3412306 /lib/x86_64-linux-gnu/libc-2.21.so 7facdc85a000-7facdca5a000 ---p 001c0000 08:08 3412306 /lib/x86_64-linux-gnu/libc-2.21.so 7facdca5a000-7facdca5e000 r--p 001c0000 08:08 3412306 /lib/x86_64-linux-gnu/libc-2.21.so 7facdca5e000-7facdca60000 rw-p 001c4000 08:08 3412306 /lib/x86_64-linux-gnu/libc-2.21.so 7facdca60000-7facdca64000 rw-p 00000000 00:00 0 7facdca64000-7facdca65000 r-xp 00000000 08:08 1187174 /home/andywang/project/DSO/libtest.so 7facdca65000-7facdcc64000 ---p 00001000 08:08 1187174 /home/andywang/project/DSO/libtest.so 7facdcc64000-7facdcc65000 r--p 00000000 08:08 1187174 /home/andywang/project/DSO/libtest.so 7facdcc65000-7facdcc66000 rw-p 00001000 08:08 1187174 /home/andywang/project/DSO/libtest.so 7facdcc66000-7facdcc8a000 r-xp 00000000 08:08 3412278 /lib/x86_64-linux-gnu/ld-2.21.so 7facdce69000-7facdce6c000 rw-p 00000000 00:00 0 7facdce86000-7facdce89000 rw-p 00000000 00:00 0 7facdce89000-7facdce8a000 r--p 00023000 08:08 3412278 /lib/x86_64-linux-gnu/ld-2.21.so 7facdce8a000-7facdce8b000 rw-p 00024000 08:08 3412278 /lib/x86_64-linux-gnu/ld-2.21.so 7facdce8b000-7facdce8c000 rw-p 00000000 00:00 0 7ffdc783c000-7ffdc785d000 rw-p 00000000 00:00 0 [stack] 7ffdc794e000-7ffdc7950000 r--p 00000000 00:00 0 [vvar] 7ffdc7950000-7ffdc7952000 r-xp 00000000 00:00 0 [vdso] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

通过上面的图可以发现:

  1. “栈”区对应着[stack]。
  2. 由于程序中没有使用malloc等函数,因此不存在[heap]。
  3. “可执行文件映像”区一共对应三个VMA,通过对这三个VMA的权限进行分析,这三个VMA应该属于三个不同的“节”。通过readelf -l命令查看程序,发现其中load属性的节仅有两个,这两个节的标志分别是“RE”与“RW”,与第一个和第三个VMA对上了,但第二个“R”权限的VMA还没有对应的节。据本人估计应该是GNU_RELRO节,因为这个节的虚拟地址与权限都符合,在晚上找了找,没找到有关于这个节的信息,欢迎了解的同学给我补充。
  4. 动态链接器映射区中共包括三个不同的动态链接库,分别是glibc与ld,以及自己编写的libtest.so。而这三个动态链接库又分别对应这三个不同的VMA。
  5. 有三个比较特殊的VMA,分别是vvar、vdso、vsyscall,今天咱们先专注于书中的内容,这些内容留待以后在分析。

关于linux如何在可执行程序与进程的虚拟空间之间建立联系的,请见下面这篇文章:http://blog.chinaunix.net/uid-26833883-id-3193585.html

10.2 主要对栈进行了分析。有关于栈的基础知识在此就不给大家分享了,总结起来就是一句话“先进后出”。栈对于程序运行的作用主要在于栈“保存了一个函数调用所需要的维护信息,以上内容就是堆栈帧(stack frame)或活动记录(activate record)。堆栈帧主要由以下几部分内容组成:

  1. 函数的返回地址与参数
  2. 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
  3. 保存的上下文:包括在函数调用前后需要保持不变的寄存器。

在i386中,esp始终指向栈的顶部,同时也就指向了当前函数活动记录的顶部。而相对的,edp 指向了函数活动记录的一个固定位置(基本就可以是顶部),ebp 寄存器又被称为帧指针(frame pointer)。这里要明确的一个概念是:某个函数的活动记录是指,从函数参数开始到esp寄存器所指的部分,ebp 虽然不直接指向这一位置,但之所以认为ebp是函数活动记录的底部,是由于之前的内容写入栈中后就不会在改变(与临时变量的分配相对应)。ebp 所直接指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp 可以通过读取这个值恢复到调用前的值,即通过这一操作可以实现函数返回时函数活动记录的快速清除。

接下来让我们结合实例来看看,函数活动记录是如何形成这种形式的:

先来看看理论上的东西,在i386(确切的说是stdcall调用惯例)下的函数调用方式如下:

  1. 参数入栈(不过在x86-64下,函数通过不同的寄存器进行传递)
  2. 把当前指令的下一条指令的地址(返回地址)压入栈中,此时函数活动记录的雏形已经形成,只差ebp的入栈了。
  3. 跳转到函数体执行。

其中第2步和第3步由指令call一起执行。跳转到函数体之后开始执行函数,而i386函数体的”标准“开头是这样的(但也可以不一样):

push edp:将ebp压入栈中,忽然记起来好像学编译原理的时候有个什么”老sp“,这个书中给出的原文就是”old ebp“,我想翻译过来就是老ebp吧偷笑

mov ebp,esp:这是intel风格的汇编,将esp的值赋给ebp,这一步结束过后,ebp 也指向栈顶,同时此时的栈顶元素就是old ebp。

[可选] sub esp,XXX:在栈上分配XXX字节的临时空间。

[可选] push XXX:如有必要,保存名为xxx的寄存器(可重复多次)。对这些寄存器进行压栈操作是由于函数在运行过程中,会使用这些寄存器,因此会破坏这些寄存器中的值,因此为保护这部分数据,就先将这部分保存起来,待函数调用返回后再恢复。

在函数返回时,所进行的”标准“结尾与”标准“开头正好相反:

[可选] pop XXX:如有必要,恢复保存过的寄存器

mov esp,ebp:将ebp中的值赋给esp,则此时esp已经指向old ebp,此时标志着这个函数活动记录在栈中所占用的空间就被释放了。

pop ebp:从栈中将old ebp的值恢复到ebp中,此时ebp也指向old ebp。

ret:从栈中取得返回地址,并跳转到该位置。

好,让我们接下来看一个实际的例子:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h> int foo1(int i); int foo2(int i); int main() { int x = 1; foo1(x); return 0; } int foo1(int i) { int y = i; foo2(y); return y; } int foo2(int i) { int z = i; return z; }

以上程序反汇编结果如下:

复制代码
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
00000000004004f6 <main>: 4004f6: 55 push %rbp 4004f7: 48 89 e5 mov %rsp,%rbp 4004fa: 48 83 ec 10 sub $0x10,%rsp 4004fe: c7 45 fc 01 00 00 00 movl $0x1,-0x4(%rbp) 400505: 8b 45 fc mov -0x4(%rbp),%eax 400508: 89 c7 mov %eax,%edi 40050a: e8 07 00 00 00 callq 400516 <foo1> 40050f: b8 00 00 00 00 mov $0x0,%eax 400514: c9 leaveq 400515: c3 retq 0000000000400516 <foo1>: 400516: 55 push %rbp 400517: 48 89 e5 mov %rsp,%rbp 40051a: 48 83 ec 20 sub $0x20,%rsp 40051e: 89 7d ec mov %edi,-0x14(%rbp) 400521: 8b 45 ec mov -0x14(%rbp),%eax 400524: 89 45 fc mov %eax,-0x4(%rbp) 400527: 8b 45 fc mov -0x4(%rbp),%eax 40052a: 89 c7 mov %eax,%edi 40052c: e8 05 00 00 00 callq 400536 <foo2> 400531: 8b 45 fc mov -0x4(%rbp),%eax 400534: c9 leaveq 400535: c3 retq 0000000000400536 <foo2>: 400536: 55 push %rbp 400537: 48 89 e5 mov %rsp,%rbp 40053a: 89 7d ec mov %edi,-0x14(%rbp) 40053d: 8b 45 ec mov -0x14(%rbp),%eax 400540: 89 45 fc mov %eax,-0x4(%rbp) 400543: 8b 45 fc mov -0x4(%rbp),%eax 400546: 5d pop %rbp 400547: c3 retq 400548: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1) 40054f: 00

先来分析最简单的foo2,与咱们分析的理论情况基本一样,首先是将rbp的值压入栈中,再来将rsp的值赋给rbp(注意objdump的结果是at&t风格的,因此源与目的操作数是相反的)。此处edi中存放着函数参数,首先赋给了-0x14(%rbp)这一地址,又将这一地址赋给了eax,这还没完,又把eax的值赋给了-0x4(%rbp)这个地址,再翻过头来又赋给了eax,返回值通过eax传递,一句能解决的事却反反复复做了四句。此时栈顶的元素还是old rbp,“pop %rbp”一方面将old ebp 重新赋给ebp,另一方面rsp所指向的值也变为返回地址。retq 一方面回到返回地址继续执行,另一方面也使rsp执行退栈操作,则此时栈已恢复成函数调用之前的情况。这里还有一点要注意的是“leaveq”这一句,这一句的作用其实就是

复制代码
1
2
mov esp,ebp pop ebp

这两句是我通过gdb跟踪寄存器值分析得到的。之所以foo2中不包括leaveq,可能是由于foo2中不包括临时变量,因此rsp并没有向下移动,因此rsp与rbp始终指向old ebp,因此就不需要mov esp,ebp 这一步,仅执行pop rbp 即可。

之所以会形成这样的函数活动记录,是因为在函数的调用方与被调用方之间存在着统一的理解,这个所谓的统一的理解就是所谓的“调用惯例”,一个调用管理主要由以下三个方面的内容组成:

  1. 函数参数的传递顺序和方式,x86-64已改为通过寄存器传递。
  2. 栈的维护方式,对于栈中压入数据的弹出工作既可以由函数调用方完成,也可以由函数本身完成。
  3. 名字修饰(name-mangling)的策略,为了链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。

讨论过了函数参数的传递,函数活动记录的形成与释放过程,接下来看看函数返回值的传递。

对于只有四字节的数据可以直接通过eax进行传递,对于返回5-8字节的数据,则采用eax与edx联合返回的形式,eax返回低4字节,edx返回高4字节。对于超过8字节的返回类型,请看如下分析,源代码如下:

复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct big_thing { char buf[128]; }big_thing; big_thing return_test() { big_thing b; b.buf[0] = 0; return b; } int main() { big_thing n = return_test(); }

反汇编结果如下,对于它的分析,就直接写在汇编代码里了:

复制代码
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
0000000000400566 <return_test>: 400566: 55 push %rbp 400567: 48 89 e5 mov %rsp,%rbp //前面这两句还是一般的函数开头 40056a: 48 81 ec a0 00 00 00 sub $0xa0,%rsp //通过使rsp减0xa0,以开辟空间 400571: 48 89 bd 68 ff ff ff mov %rdi,-0x98(%rbp) //此时rbp已经指向old rbp,rdi 为传入的参数,由于本函数没有参数,因此这个传入的参数实际上n的地址 400578: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 40057f: 00 00 400581: 48 89 45 f8 mov %rax,-0x8(%rbp) 400585: 31 c0 xor %eax,%eax //这三句什么作用没看出来 400587: c6 85 70 ff ff ff 00 movb $0x0,-0x90(%rbp) //b.buf[0] = 0,rbp-0x90为b的地址,可以通过gdb对以上数据进行验证 40058e: 48 8b 85 68 ff ff ff mov -0x98(%rbp),%rax //此时rbp-0x98中存储的是传入的参数的地址,而这个地址恰好是n的地址,最后这个地址又通过rax传回 400595: 48 8b 95 70 ff ff ff mov -0x90(%rbp),%rdx // 先传入rdx中 40059c: 48 89 10 mov %rdx,(%rax) //再传入rax中保存的地址中 40059f: 48 8b 95 78 ff ff ff mov -0x88(%rbp),%rdx //递减8个字节,并反复这一过程 4005a6: 48 89 50 08 mov %rdx,0x8(%rax) 4005aa: 48 8b 55 80 mov -0x80(%rbp),%rdx 4005ae: 48 89 50 10 mov %rdx,0x10(%rax) 4005b2: 48 8b 55 88 mov -0x78(%rbp),%rdx 4005b6: 48 89 50 18 mov %rdx,0x18(%rax) 4005ba: 48 8b 55 90 mov -0x70(%rbp),%rdx 4005be: 48 89 50 20 mov %rdx,0x20(%rax) 4005c2: 48 8b 55 98 mov -0x68(%rbp),%rdx 4005c6: 48 89 50 28 mov %rdx,0x28(%rax) 4005ca: 48 8b 55 a0 mov -0x60(%rbp),%rdx 4005ce: 48 89 50 30 mov %rdx,0x30(%rax) 4005d2: 48 8b 55 a8 mov -0x58(%rbp),%rdx 4005d6: 48 89 50 38 mov %rdx,0x38(%rax) 4005da: 48 8b 55 b0 mov -0x50(%rbp),%rdx 4005de: 48 89 50 40 mov %rdx,0x40(%rax) 4005e2: 48 8b 55 b8 mov -0x48(%rbp),%rdx 4005e6: 48 89 50 48 mov %rdx,0x48(%rax) 4005ea: 48 8b 55 c0 mov -0x40(%rbp),%rdx 4005ee: 48 89 50 50 mov %rdx,0x50(%rax) 4005f2: 48 8b 55 c8 mov -0x38(%rbp),%rdx 4005f6: 48 89 50 58 mov %rdx,0x58(%rax) 4005fa: 48 8b 55 d0 mov -0x30(%rbp),%rdx 4005fe: 48 89 50 60 mov %rdx,0x60(%rax) 400602: 48 8b 55 d8 mov -0x28(%rbp),%rdx 400606: 48 89 50 68 mov %rdx,0x68(%rax) 40060a: 48 8b 55 e0 mov -0x20(%rbp),%rdx 40060e: 48 89 50 70 mov %rdx,0x70(%rax) 400612: 48 8b 55 e8 mov -0x18(%rbp),%rdx 400616: 48 89 50 78 mov %rdx,0x78(%rax) //从rbp-0x90到rbp-0x18共0x78个字节,换为十进制就是120个字节,正好是struct的大小。 40061a: 48 8b 85 68 ff ff ff mov -0x98(%rbp),%rax //这一句有什么作用不太清楚,之前已经做这一操作了,而且寄存器的值也没有变化 400621: 48 8b 4d f8 mov -0x8(%rbp),%rcx 400625: 64 48 33 0c 25 28 00 xor %fs:0x28,%rcx //以上两句什么作用又不知道 40062c: 00 00 40062e: 74 05 je 400635 <return_test+0xcf> //跳过下一句 400630: e8 0b fe ff ff callq 400440 <__stack_chk_fail@plt> 400635: c9 leaveq //清栈操作 400636: c3 retq //返回 0000000000400637 <main>: 400637: 55 push %rbp 400638: 48 89 e5 mov %rsp,%rbp //还是函数的开头 40063b: 48 81 ec 90 00 00 00 sub $0x90,%rsp //开辟空间 400642: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 400649: 00 00 40064b: 48 89 45 f8 mov %rax,-0x8(%rbp) 40064f: 31 c0 xor %eax,%eax //这几句的作用没有搞清楚 400651: 48 8d 85 70 ff ff ff lea -0x90(%rbp),%rax //n的地址与rbp-0x90的值相同 400658: 48 89 c7 mov %rax,%rdi //把这一地址作为参数传入rdi中 40065b: b8 00 00 00 00 mov $0x0,%eax //这一句的作用没搞清楚 400660: e8 01 ff ff ff callq 400566 <return_test> 400665: 48 8b 55 f8 mov -0x8(%rbp),%rdx 400669: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx 400670: 00 00 400672: 74 05 je 400679 <main+0x42> 400674: e8 c7 fd ff ff callq 400440 <__stack_chk_fail@plt> 400679: c9 leaveq 40067a: c3 retq 40067b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)

通过以上分析我们可以发现x86-64中对于大对象的返回进行了一定的优化,直接将返回参数的地址作为函数的隐藏参数传入,在返回时将结果直接写入这一地址。

书中还给出了有关于c++的分析,有机会下次再给大家分析。

10.3 节主要对“堆”的概念及管理方法进行介绍。对于进程地址空间中的堆,其管理者就是运行库。其实对于“堆”空间的管理,程序可以直接将这项工作交给操作系统内核完成,而之所以操作系统内核并没有接手这项工作,而是将这项工作交给运行库进行,是由于如果程序频繁的使用系统调用,会造成很大的开销,因此以上方法并不可行。

首先来看看运行库是如何为程序分配堆空间的,本书中介绍的是使用brk() 与 mmap(),brk()的作用是调整数据段的结束地址,即它可以扩大或缩小数据段。将数据段的地址向高地址移动则相当于分配存储空间,而向低地址移动则相当于释放空间(实际处理上更加复杂)。mmap() 则首先申请一段虚拟地址空间,当文件不映射进这一内存区域时,我们称这块空间为匿名空间,这一部分空间被映射进入动态链接库映射区。

10.3.4 还介绍了三种堆分配算法,分别是“空闲链表”、“位图”、“对象池”。

最后给大家分享几篇博客

http://blog.csdn.net/g_brightboy/article/details/22793439

这一篇blog从概念上对c/c++中用到的动态内存管理的函数进行了介绍。

http://blog.chinaunix.net/uid-20786208-id-4979967.html

这一篇blog非常好,建议大家认真的读一读,这篇文章对glic2.21中malloc的源码进行了分析,我的电脑中安装的glibc的版本就是2.21

http://drops.wooyun.org/tips/6595

最后

以上就是欢呼芒果最近收集整理的关于《程序员自我修养》第十章读书笔记的全部内容,更多相关《程序员自我修养》第十章读书笔记内容请搜索靠谱客的其他文章。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(48)

评论列表共有 0 条评论

立即
投稿
返回
顶部