《程序员自我修养》第十章读书笔记

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

首先来看程序的内存布局

虽然当前的内存空间使用平坦模型,即整个内存是一个统一的地址空间,用户可以使用一个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]

通过上面的图可以发现:

  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:从栈中取得返回地址,并跳转到该位置。

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

#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 即可。

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

  1. 函数参数的传递顺序和方式,x86-64已改为通过寄存器传递。
  2. 栈的维护方式,对于栈中压入数据的弹出工作既可以由函数调用方完成,也可以由函数本身完成。
  3. 名字修饰(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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值