概述
第十章主要对程序的运行时内存布局进行分析。而本书接下来的几章主要是针对程序的运行环境进行研究。
首先来看程序的内存布局
虽然当前的内存空间使用平坦模型,即整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。但操作系统对于却不是将所有资源都交给用户的应用程序使用。在linux下默认将高地址1GB的空间分配给内核。
一般来讲,应用程序使用的内存空间里有如下“默认”的区域:
栈:用于维护函数调用的上下文(包括main函数),我个人感觉栈就是用于保存程序运行时所需要的参数、信息等。
堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自堆里。
可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里。
保留区:保留区并不是一个单一的内存区域,而是对内存中收到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。这个区域其实在一定程度上起到了对进程地址空间的保护作用。对于使用空指针或小整型值指针引用内存的情况,操作系统可以马上就进行阻止,并产生“段错误”异常。
动态链接器映射区:这个区域用于映射装载的动态链接库。在linux下,如果可执行文件依赖于其他共享库,那么系统就会为它在从0x40000000(32位操作系统)开始的地址分配相应的空间,并将共享库装入该空间。动态链接器也是加载到这一地址,而后开始自举代码的功能。
在这里还是要给大家分享一篇内容上比较全面的blog:http://www.cnblogs.com/clover-toeic/p/3754433.html
通过对上述几个区域的分析,马上就可以与我们学到的知识联系到一起,这里每一区域就对应着我们在第七章中看到的进程的内存分布。在这里还是给大家先看一个进程的内存分布:
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]
通过上面的图可以发现:
- “栈”区对应着[stack]。
- 由于程序中没有使用malloc等函数,因此不存在[heap]。
- “可执行文件映像”区一共对应三个VMA,通过对这三个VMA的权限进行分析,这三个VMA应该属于三个不同的“节”。通过readelf -l命令查看程序,发现其中load属性的节仅有两个,这两个节的标志分别是“RE”与“RW”,与第一个和第三个VMA对上了,但第二个“R”权限的VMA还没有对应的节。据本人估计应该是GNU_RELRO节,因为这个节的虚拟地址与权限都符合,在晚上找了找,没找到有关于这个节的信息,欢迎了解的同学给我补充。
- 动态链接器映射区中共包括三个不同的动态链接库,分别是glibc与ld,以及自己编写的libtest.so。而这三个动态链接库又分别对应这三个不同的VMA。
- 有三个比较特殊的VMA,分别是vvar、vdso、vsyscall,今天咱们先专注于书中的内容,这些内容留待以后在分析。
关于linux如何在可执行程序与进程的虚拟空间之间建立联系的,请见下面这篇文章:http://blog.chinaunix.net/uid-26833883-id-3193585.html
10.2 主要对栈进行了分析。有关于栈的基础知识在此就不给大家分享了,总结起来就是一句话“先进后出”。栈对于程序运行的作用主要在于栈“保存了一个函数调用所需要的维护信息,以上内容就是堆栈帧(stack frame)或活动记录(activate record)。堆栈帧主要由以下几部分内容组成:
- 函数的返回地址与参数
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量。
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器。
在i386中,esp始终指向栈的顶部,同时也就指向了当前函数活动记录的顶部。而相对的,edp 指向了函数活动记录的一个固定位置(基本就可以是顶部),ebp 寄存器又被称为帧指针(frame pointer)。这里要明确的一个概念是:某个函数的活动记录是指,从函数参数开始到esp寄存器所指的部分,ebp 虽然不直接指向这一位置,但之所以认为ebp是函数活动记录的底部,是由于之前的内容写入栈中后就不会在改变(与临时变量的分配相对应)。ebp 所直接指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp 可以通过读取这个值恢复到调用前的值,即通过这一操作可以实现函数返回时函数活动记录的快速清除。
接下来让我们结合实例来看看,函数活动记录是如何形成这种形式的:
先来看看理论上的东西,在i386(确切的说是stdcall调用惯例)下的函数调用方式如下:
- 参数入栈(不过在x86-64下,函数通过不同的寄存器进行传递)
- 把当前指令的下一条指令的地址(返回地址)压入栈中,此时函数活动记录的雏形已经形成,只差ebp的入栈了。
- 跳转到函数体执行。
其中第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:从栈中取得返回地址,并跳转到该位置。
好,让我们接下来看一个实际的例子:
#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;
}
以上程序反汇编结果如下:
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”这一句,这一句的作用其实就是
mov esp,ebp
pop ebp
这两句是我通过gdb跟踪寄存器值分析得到的。之所以foo2中不包括leaveq,可能是由于foo2中不包括临时变量,因此rsp并没有向下移动,因此rsp与rbp始终指向old ebp,因此就不需要mov esp,ebp 这一步,仅执行pop rbp 即可。
之所以会形成这样的函数活动记录,是因为在函数的调用方与被调用方之间存在着统一的理解,这个所谓的统一的理解就是所谓的“调用惯例”,一个调用管理主要由以下三个方面的内容组成:
- 函数参数的传递顺序和方式,x86-64已改为通过寄存器传递。
- 栈的维护方式,对于栈中压入数据的弹出工作既可以由函数调用方完成,也可以由函数本身完成。
- 名字修饰(name-mangling)的策略,为了链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
讨论过了函数参数的传递,函数活动记录的形成与释放过程,接下来看看函数返回值的传递。
对于只有四字节的数据可以直接通过eax进行传递,对于返回5-8字节的数据,则采用eax与edx联合返回的形式,eax返回低4字节,edx返回高4字节。对于超过8字节的返回类型,请看如下分析,源代码如下:
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();
}
反汇编结果如下,对于它的分析,就直接写在汇编代码里了:
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
最后
以上就是欢呼芒果为你收集整理的《程序员自我修养》第十章读书笔记的全部内容,希望文章能够帮你解决《程序员自我修养》第十章读书笔记所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复