第2章 汇编及逆向工程基础

第2章 汇编及逆向工程基础

2.1 导言

本章,我们将介绍一些基础性的内容,包括汇编语言、Intel架构的处理器,以及进一步学习时需要掌握的概念。本书着眼于32位Intel架构(IA-32)的汇编语言,涉及Windows及Linux两种操作系统。我们假设读者至少了解一些IA-32汇编语言(尽管本章在一定程度上覆盖了架构及指令集)的知识,并能熟练使用C/C++。本章为入门者提供了开展工作所需要掌握的基础知识,以及作者认为有必要提及的参考资料。

2.2 汇编语言及IA-32处理器(1)

汇编是与计算机进行交互的一种有趣的方法。Donald Knuth曾说过:"科学就是我们已经充分理解并可以向计算机解释的,除此之外的就是艺术。"对我而言,这也普遍存在于汇编语言程序设计中,因为在编写汇编程序时,通常会滥用指令,而不是让它做"本职工作"(例如,用LEA(Load Effective Address,取有效地址指令)做与指针算法无关的事情)。但到底什么是汇编呢?汇编是指一些与处理器指令集一一对应的指令助记符的使用。也就是说,代码与处理器之间没有抽象层:所写即所得(尽管在有些平台上有例外:汇编器先输出伪指令,然后再把它们转换成多条处理器指令--但前面所说的依然正确)。

以一条C语句为例:

	
  
  
  1. return 0; 

我们最终会得到如下的汇编指令:

	
  
  
  1. leave  
  2. xor    eax,    eax  
  3. ret 

各种硬件平台的汇编语言语法不尽相同。上面的汇编代码段及本书中使用的大多数汇编代码段采用的是Intel语法;另外比较流行的、在Unix里大量使用的是AT&T语法,它和Intel语法有一些区别。我们在这里之所以选用Intel语法,主要是因为它被用于IDA的反汇编,而且它要比AT&T语法更普及些。这也意味着使用Intel语法,可以找到更多的参考书、官方文档及可以帮助解答问题的人。

本书并不准备介绍AT&T语法与Intel语法之间的异同,但通过下面的例子你可以了解个大概:

	
  
  
  1. Intel:  
  2.   leave  
  3. xor    eax, eax  
  4. ret  
  5. AT&T:  
  6.   leave  
  7. xorl    %eax, %eax  
  8. ret 

值得注意的是,很多地方仍在使用AT&T语法,特别是Unix中它通常被用作标准语法(但随着IA-32计算机上的Unix及类Unix操作系统的兴起,这种情形正在慢慢改变)。因此,花些时间学习它肯定不会错,特别是当你对Unix或其他硬件平台感兴趣时。

即使你对我们前面所列的汇编代码一无所知,也不用太担心。我只是想让你和汇编代码混个脸熟,知道它们就是所谓的IA-32汇编,并且知道它等同于C语言里的"return 0"语句就可以了。虽然汇编属于低级语言,但处理器本身并不理解汇编代码,汇编代码只是为了便于人们阅读而采用的等同于机器码的助记符。汇编代码经编译器处理后输出为操作码(opcodes),操作码是指令或执行单条指令所必需的开关序列的二进制表示形式。为了符合人们的胃口,操作码一般用十六进制表示,因为这种表示形式比二进制更容易读一些。例如,前面的汇编指令对应的操作码如下所示:

	
  
  
  1. 0xC9            (leave)  
  2. 0x31, 0xc0      (xor eax,eax)  
  3. 0xC9            (ret) 

正如我们所看到的,这里是三个基本的抽象层,但随着计算技术的发展,我们增加了越来越多的抽象层,比如说Java及.NET应用程序使用的虚拟机概念。不过,它们最终还是要还原成汇编代码,并最终以0、1序列的形式进入处理器。关于操作码的更完整说明请参阅Intel? 64 and IA-32 Architectures Software Developer's Manual Volume 2A,section 2.1.2。

至此,我们已经对汇编指令有了一些初步了解,但怎样使用它们呢?汇编指令的指令后面通常会跟一个参数(也称为操作数),视具体的指令而定,操作数可能是常量、内存变量或寄存器。常量是最简单的形式,一般是在源代码中定义的。例如,如果一段代码中用到了如下指令:

	
  
  
  1. mov eax, 0x1234 

那么十六进制数0x1234就是一个常量。常量简单明了,一般直接编码到指令中,除此以外几乎没有什么要额外说明的了。但有一点比较有意思,如果思考过我们在前面提到的返回零的C语句,聪明的读者可能已经注意到了,编译器生成的汇编语句并没有包含常量--即使源代码中有。这是编译器优化导致的结果,它认为复制零的操作比执行异或运算更消耗资源(两者的执行结果是一样的)。

我们接下来要介绍的是寄存器。寄存器与C/C++中的变量类似。通用寄存器可以保存整数、偏移量、立即数、指针,或任何能用32位二进制表示的东西。它本质上是预分配的、物理上存在于处理器中的变量,总是位于一定范围内。它们的用法与变量还是有些差异;对寄存器而言,它们可以被反复使用,但在C或C++程序里,定义的变量通常只用于一个目的,且不再把它用在其他地方。

操作码和Shellcode

当我们刚进入数字世界,初次面对shellcode时几乎都会发懵。这些神秘的十六进制数字组成的数组在我们面前跳来跳去,而我们却不了解它到底要做什么。研究过破解程序(exploit)的人应该都见过它,但我们不建议读者过早地接触它们,因为欲速则不达。

不过可以肯定的是,一些关于它的说法被神秘化复杂化了。Shellcode其实只是一系列的操作码,一般保存在C字符串数组里。称它为shellcode主要是因为这一系列的操作码是执行shell(例如/bin/sh 或 cmd.exe)所必需的指令。在这里,如果我们以下面的C语句为例生成shellcode:

	
  
  
  1. return 0; 

则使用的是这条指令所对应的操作码,即0xC9,0x31,0xC0,0xC9。如果把它放到C程序里,看起来应该和下面差不多:

	
  
  
  1. unsigned char shellcode[]="\xc9\x31\xc0\xc9"; 

现在知道这是怎么一回事了吧?你现在可能会因为以前把它们想得太复杂而感到自己有点愚蠢,不必这样!我认为,这是每个人认知过程中必定会经历的阶段--至少我是这样的。

IA-32中有8个32位通用寄存器,6个16位段寄存器,1个32位指令指针寄存器,1个32位状态寄存器,5个控制寄存器,3个内存管理寄存器,8个调试寄存器,等等。在大多数情况下,我们只用到通用寄存器、指令指针寄存器、段寄存器及状态寄存器。如果处理OS的驱动程序之类的,则更有可能会碰到其他的寄存器。在这里,我们只准备介绍通用寄存器、指令指针寄存器、状态寄存器及段寄存器。对于其他的寄存器,只要知道它们存在就可以了。当然,如果你有兴趣进一步学习,则可以阅读Intel文档。

8个32位通用寄存器分别是:EAX、EBX、ECX、EDX、ESI、EDI、EBP和ESP。这些寄存器中除了几个有专门的用处外,其他的可以随便用。例如,不少指令会默认把某些寄存器作为参数(操作数)。比如,多数字符串指令通常把ECX用作计数器,把ESI作为源指针,把EDI作为目的指针。此外,在某些内存模型中,有些指令默认把某些寄存器作为段的基址(这些下面很快会讲到)。最后,有些操作会涉及一些寄存器,虽然它们并没有在指令中体现出来。例如,栈操作通常会使用EBP和ESP寄存器,如果它们包含的值没有映射到当前进程的地址空间里,通常会导致应用程序崩溃。IA-32架构几乎完全向后兼容8086处理器,寄存器的使用上也反映出这一点。我们可以访问所有通用寄存器的32位内容,也可以访问它的低16位,更甚之,像EAX,EBX,ECX和EDX寄存器的低16位又可以分为高8位及低8位而分别访问之。图2-1中使用的名字反映了这种情况。例如,为了访问EAX寄存器的低8位,需要用AL替换指令中的EAX;为了访问EBP寄存器的低16位,需要用BP替换EBP;为了访问EDX寄存器低16位中的高8位,需要用DH替换EDX。除了通用寄存器外,不要忘了还有指令指针--EIP。EIP寄存器指向处理器将要执行的下一条指令,在几乎所有的基于应用程序的攻击中,目标都是控制这个寄存器。然而,它和通用寄存器不一样,我们并不能直接修改它。也就是说,你并不能通过执行一条指令修改其中的值,但是你可以通过执行一组操作来间接地修改它的值。例如,在压入栈段后紧接着执行ret指令。如果你不理解这些叙述,不要担心,我们稍后就会介绍刚才提到的指令以及栈段的概念。现在,你只需知道不能直接修改指令指针的值就可以了。

 
图2-1 通用寄存器
除了EIP寄存器和通用寄存器,还有6个16位的段寄存器:代码段(CS)、数据段(DS)、栈段(SS)、额外段(ES)、FS及GS。后3个是额外的通用目的段。段寄存器包含我们称之为段选择子的指针,通常以偏移量的基址形式出现。例如,看下面的指令:
	
  
  
  1. mov DS:[eax], ebx 

在这条指令中,EBX寄存器中的内容被复制到EAX指定的数据段里的一个偏移地址。这个地址也可以解释成"DS的地址加上EAX的值"。段选择子是16位的段标识符,也就是说段选择子不直接指向段,而是指向定义段的段描述符。因此,段寄存器指向段选择子,用于确定8 192个可能的标识段的段描述符中的一个。没把你绕糊涂吧?

段选择子的结构相对比较简单。3至15位用作描述符表的索引(三个内存管理寄存器之一),第2位指定正确的描述符表,低2位指定请求的特权级(从0到3--本章后面将讨论特权级)。段描述符非常有趣,对OS设计而言也非常重要,但为了确保本章中相关内容的唯一性,我们就不过多介绍了。当然,我们鼓励有兴趣的读者把Intel开发者手册找来读一读。此外,EFLAGS寄存器也很重要,它包含了各种标志,指示前一条指令执行后的状态、情形、当前的特权级,以及是否允许中断等内容。在上下文里,如果我们没有领会使用它的那些指令,EFLAGS寄存器对我们而言就没什么意义。我们稍后会完整介绍它。

到现在为止,我们介绍了常量及寄存器操作数,接下来讨论内存操作数。尽管我们对内存操作数的描述比较有限,但相比较而言它要复杂一些。内存操作数基本上相当于高级语言程序员眼中的变量。也就是说,当你在像C或C++这样的语言里声明一个变量,它基本上就会以内存操作数的形式出现在内存中。在程序中通常以指针的形式访问它们,如果取消了指向它们的指针,则需要通过寄存器或直接从内存访问。这个概念本身很简单,但要真正理解它还需要有扎实基本功,比如说了解内存是怎样寻址的,而这又依赖于内存模型、操作模式以及使用的特权级。这为接下来要介绍的操作模式起了个好头。

2.2 汇编语言及IA-32处理器(2)

在IA-32中,有3种基本的操作模式以及1种伪操作模式。分别是保护模式、实地址模式、系统管理模式,以及是保护模式子集的伪模式(称为虚拟8086模式)。本着从简的原则,我们将着重讨论保护模式。各种操作模式之间最大的不同是它们修改指令或体系结构特征的表示。例如,RM(Real Mode,实模式)意味着向后兼容,在RM里,只支持实地址模式内存模型。这里要着重注意的是(除非你打算逆向分析古老的DOS应用程序或类似的东西),重启或冷启动IA-32机器时,它肯定处于实模式。SMM(System Management Mode,系统管理模式),自80386以来就出现在Intel架构里了,常用于电源管理、系统硬件控制等地方。它基本上会阻塞所有其他的操作,并切换到新的地址空间。不过一般而言,你碰到和使用的大都是保护模式。

Intel在80286处理器中引入了保护模式,并在80386中进一步完善,体现了Intel架构质的飞跃。在此之前的关键问题是,这些老的处理器只支持一种操作模式,没有在硬件上强制保护指令和内存。这不仅给恶意操作者肆意妄为的机会,也加大了因缺陷的程序出错而导致整个系统崩溃的机率;因此,它是可靠性与安全性的关键所在。这些老处理器另外的问题是640KB限制;不过,这些在PM中早已不存在了。此外,它还有另外一些好处,例如,从硬件上支持多任务,修改了中断的处理方式等。286和386代表了个人计算机的巨大成就。

无论是在较早的8086/80186处理器时代,还是现代处理器位于实地址模式的今天,段寄存器都是表示线性地址的高16位,而在保护模式里,选择子是描述表里的索引。此外,就像前面提过的,以前的CPU没有内存保护或指令限制;但在保护模式里,有四个称为环(ring)的特权级。一般用0至3表示它们,数越小表示特权级越高。环0一般是操作系统使用,而应用程序一般运行在环3,这将通过中止恶意程序运行来防止它们修改操作系统的数据结构和对象,也限制应用程序可以运行的指令(如果环3应用程序可以切换其特权级,有什么好处呢?)。在IA-32里,在三个地方可以找到特权级:CS寄存器的低2位--CPL(Current Privilege Level,当前特权级),段描述符的低2位--DPL(Descriptor Privilege Level,描述符特权级),段选择子的低2位--RPL(Requestor's Privilege Level,请求特权级)。CPL是当前正在执行代码的特权级,DPL是给定描述符的特权级,而RPL则显然是创建段的代码的特权级。

特权级限制了对系统数据的可信组件的访问,例如,环3应用程序就不能访问环2组件的数据,不过,环2组件可以访问环3组件的数据。这就是你为什么不能从Windows或Linux内核里任意读取数据的原因了,但它们可以读你的数据。特权级分离的另一种作用是它对执行转移控制进行检查。程序请求从当前段改变到其他段时,将引发系统对其进行检查,确保CPL和段的DPL是一样的。间接执行转移通过诸如调用门(call gates,后面会作简短的介绍)之类的机制进行。最后,特权级会限制程序访问某些指令,比如说那些从根本上改变操作系统环境或为操作系统保留的操作(诸如读写串口)。

保护模式下有三个不同的内存模型:平坦、分段及实地址。实地址模式常用于启动时且保持向后兼容,不过,你很有可能从来不会(或很少)碰到它,因此,本章不准备讨论它。平坦内存模型和它名字表述含义差不多:它是平坦的(参见图2-2)!这意味着系统内存看起来像一片连续的地址空间,通常是地址0~4294967296。这里的地址空间被称为线性地址空间,任何单独的地址都会被认为是线性的地址。在这种内存模型里,所有的段--代码、栈、数据段等--都属于同一个地址空间。现在的操作系统几乎都在使用这种内存模型,包括你现在正在使用的操作系统也可能如此。这看起来不那么可靠,也可能导致灾难发生,不过因为OS使用了分页技术,这些担心有点多余。我们接下来做一个简单的介绍。

 
图2-2 平坦内存模型

保护模式里的平坦内存模型与其他模型相比唯一的不同是设置了段限制,可以确保被访问的地址都是真正存在的。这和其他模式不一样,那些模式使用整个地址空间,而不管地址空间里是否有不能用的内存空隙。

接下来讨论分段内存模型。它一般用于较早的操作系统,但现在似乎有点复苏的迹象,在某些地方它意味着速度(由于它略过了重定位的操作)及安全性的增加;虽然如此,也只有极少数操作系统使用它。我们在这里提及它是因为,如果你需要在Linux下做大量的逆向工程工作或开发漏洞破解程序,碰到它的可能性就会大大增加。

在分段内存模型里,系统内存分成称为段的小块。(参见图2-3)这些段之间彼此相互独立。我们把分段内存模型和平坦内存模型做个比较,可以看出它们之间最大的区别是它们在操作系统或应用程序中的表现形式。在这两种情形下,数据仍是以线性的方式保存,但数据的视图改变了。对分段内存来说,不是以线性地址访问内存,而是使用了逻辑地址(也称为far指针)。逻辑地址是基址--保存在段选择子里--加上偏移量的结合。两者(基址及偏移量)相加后对应段里的一个地址,然后映射到线性地址空间里。这样做可以保证高度分离--由处理器强制执行,确保一个进程不会跑到另一个进程的地址空间里(不过,在平坦内存模型里,由操作系统做同样的事情)。因此,基址加上偏移量等于处理器地址空间里的线性地址。此外,除了每个应用程序被给定它自己的段集及段描述符外,多段模型也可以用于使用单一分段模型的地方。不过目前我想不起来还有哪个操作系统在使用它,因此,进一步讨论它怎样工作也没什么意义。

 
(点击查看大图)图2-3 分段内存模型

2.2 汇编语言及IA-32处理器(3)

分段的安全性

我们前面提到过,分段内存模型最近在一些团体中的吸引力有所回升,特别是grsecurity(http:// grsecurity.com/)和PaX(http://pax.grsecurity.net/)--第三方Linux内核补丁,为Vanilla内核提供卓越的安全性。grsecurity的首席开发者Brad Spengler演示了平坦内存模型带来的不安全性--被认为是第一个可利用的Linux内核NULL指针解除索引漏洞,在下面的URL里可以了解详情:http://marc.info/ ?l=dailydave&m=117294179528847&w=2。此外,PaX的匿名作者已经实现了称为UDEREF的特性,此特性企图阻止意外解除内核里由用户空间指针提供的指针索引(从而导致潜在的可利用条件)。已有人撰文介绍它,建议有兴趣的读者找出来读一读,进一步理解UDEREF对平坦内存模型所做的安全改进。在此刻写作之时,在http://grsecurity. net/~spender/uderef.txt可以找到这篇文章。

现代操作系统能使用的地址空间往往比物理内存更大一些,而寻址的数据并不一定保存在物理内存里。这是如何做到的呢?答案是分页和虚拟内存-- 一个现代计算的基础术语,经常会被一些有很多操作经验的人们所误解。不难碰到能理解给定的应用程序可以访问4GB内存的人,但他们并不理解仅仅给他们1GB或2GB物理内存时,程序怎么工作。

简而言之,分页利用了这样的事实:只有当前必需的数据才需要随时保存在物理内存里。它把需要的数据保存在物理内存里,把暂时用不上的数据保存到硬盘上。把数据从磁盘读入内存的过程,或向磁盘写数据,被称为交换数据,这就不难理解Windows为什么会有交换文件,而Linux有交换分区。当不启用分页时,线性地址(不管它是否由far指针组成)与物理地址一一对应,它和使用的地址空间之间没有转换。然而,当启用分页时,应用程序使用的所有指针都是虚拟地址。(这就是为什么在保护模型里平坦内存模型中的两个应用程序使用分页访问完全一样的地址却不会彼此破坏。)这些虚拟地址与物理内存地址间不存在一一对应的关系。

使用分页技术时,处理器把物理内存分成4KB、2MB或4MB大小的页。地址转换成线性地址时,将通过分页机制查找它,如果这个地址不在当前的物理内存里,将抛出page-fault(页面故障)异常,操作系统收到此异常后,将把指定的页面加载到内存里,然后接着执行刚才导致此异常的指令。

把虚拟地址转换成物理地址的过程依赖使用的页面大小,但基本概念是一样的。线性地址分成2个或3个部分。首先用PDBR(Page Directory Base Register,页面目录基址寄存器)或CR3(Control Register 3,控制寄存器3)查找Page Directory(页面目录)。在这之后,线性地址里的第22~31位将作为Page Directory里的偏移量,标识使用的Page Table(页面表)。(参见图2-4)一旦找到Page Table,将用第12~21位查找PTE(Page Table Entry,页面表项)(标识使用的内存页)。最后,线性地址的第0~11位用作在页面里定位请求的数据的偏移量。当使用其他页面时,除了省略一个间接层外,过程几乎是一样的;在这种过程中,目录项直接指向页面、页面表且PTE被完全省略了。PDE(Page Directory Entries,页面目录项)和PTE的内容对我们来说不是很重要。如果你在工作时碰到它很重要的情况,或者你很好奇它的原理,请参考处理器的文档。

 
(点击查看大图)图2-4 4KB地址转换
现在,我们对指令、操作数、内存模型、操作模式等概念有了一些理解,可以继续学习其他内容了。全书及本章稍后将使用的大多数术语都已定义了,因此,当你阅读全书时,如果感到有些内容还不理解,可以再回到这里看看

2.3 栈、堆及二进制可执行文件中的其他区段(1)

前一节介绍了段、段寄存器、段描述符及段选择子,但我们并没有真正深入研究它们包含的数据。而理解这些部分对理解二进制可执行文件的布局相当重要。在本节,我们将尽量深入地讨论这些概念。当然,因为篇幅的关系,有些内容--例如堆,我们并没有对实现细节进行深入分析。碰到这种情形,我们通常会提供一般性的介绍,而对细节的理解就由读者自己练习了。

读者应该理解,这里所定义的区和段与分段内存模型并没有什么关系。在内存模型里,应用程序和操作系统所享受的待遇是不一样的,应该说,应用程序不知道实现细节是幸运的。此外,在本章,术语段和区是可以互换使用的。

我们在前面曾讨论过段寄存器,特别是CS、DS及SS段寄存器,但我们并没有介绍代码段、数据段、栈段是些什么。在传统的设计里,应用程序有一些基本的组成部分(和一些与具体实践有关的部分),比如说代码段(或text段,或简单地记成 .text)、数据段(通常简单地记成 .data),以符号开始的块(BSS/.bss)段、栈段及堆段等。我们来看一个例子,下面的C代码将演示各种段之间的不同点:

		
   
   
  1. unsigned int variable_zero;  
  2. unsigned int variable_one = 0x1234;  
  3. int  
  4. main(void)  
  5. {  
  6.         void*variable_two = malloc(0x1234);  
  7.         […]  

在这个例子里,我们定义了3个变量和1个函数。第一个变量被命名为variable_zero,是一个全局变量,未被初始化。在这个例子里,C编译器将为其在二进制文件里分配空间并填充零,它将位于二进制区段里的BSS区段。名为variable_one的变量是另一个全局变量。不过,在这个例子里它被初始化为0x1234。在这个例子里,编译器将在二进制文件里为其预分配空间,把数值保存在数据段里。之后是函数main。main明显是一个函数,因此它位于代码段里。在main之后我们发现了称为variable_two的变量,它给我们出了个难题:我们有一个指针(它的作用域是main之内和它指向的内存地址)。这个指针本身是函数的一部分,是动态分配的,存在于栈段之中,与函数的生命周期相同;malloc()返回的指针存在于堆上,是动态分配的,具有全局作用域及"use-until-free()"生命周期。此外,也常见一些其他的区段,例如,GCC(GNU Compiler Collection,GNU编译器套装)编译生成的程序里,在源文件里声明的常量字符串通常保存在名为.rodata的区段里,而只读数据可能会保存在代码段里。

栈是一种更重要的区段,在应用程序的例行操作中扮演着非常重要的角色。计算机科班出身的人肯定都知道栈是什么以及它是怎样工作的。但出于完整性的考虑,我们在此还是要大致介绍一下。栈是一种简单的数据结构,栈数据彼此之间紧挨着,元素以LIFO(Last-In First-Out,后进先出)的方式增加和移除。当你向栈上增加数据时,是把它压入栈;当你从栈上移除数据时,则是从栈上弹出它。(参见图2-5)在大多数计算平台上,栈之所以如此重要有两个原因。第一个原因是所有的自动或局部变量都保存在它上面,也就是说,当一个函数被调用时,任何它声明的不是静态或类似的局部变量都会在栈上分配相应的空间。这通常通过把当前栈指针加或减一个数值来实现。栈指针是ESP寄存器,通常指向栈段的顶端,或者更严格地说SS:ESP指向栈段的顶端。栈的顶端是栈使用地址中的最小值--之所以是最小值,是因为栈在IA-32上是向下增长的。栈的底部通常也是有限制的,而不是我们想象中的绝对的底,它一般是当前栈帧的底,由EBP寄存器指向。

 
(点击查看大图)图2-5 栈
栈帧指的是与当前正在执行的函数有关的当前视图;当处理器进入新过程时,会执行我们称之为预处理例程(procedure prologue)的步骤(参见图2-6)。预处理执行如下操作:首先把调用帧里下一条指令的地址压入栈,接下来把当前栈的基址(EBP)保存到栈上,然后把ESP寄存器复制到EBP寄存器里,最后,把ESP寄存器里的值减去一部分,从而为函数里的变量分配空间。当函数被调用时,函数的参数以逆序压入栈(或者说最后的先压入)。预处理对应汇编指令如下:
		
   
   
  1. push ebp  
  2. mov ebp, esp  
  3. sub esp, 0x1234 

看到这里,我们肯定会奇怪这里为什么没有保存返回地址,或我们在调用例程里继续执行的地址。例如,假如有下面这样的C代码:

		
   
   
  1. A();  
  2. B(); 


 
图2-6 栈帧


当进入函数A()时返回地址将是指令B()的地址。这么说理解起来可能有些困难,特别是我们并没有看到把地址保存到栈上的指令,但其实调用指令背地里帮我们做了这件事,稍后将会讨论。函数除了预处理例程外,还有对应的扫尾例程(procedure epilogue)。扫尾例程所做的基本上是恢复预处理所做的改变,包括调大栈指针的值(作用是取消分配的局部变量),调用leave和ret指令(作用是移去保存的帧指针和返回地址,并把执行流返回给调用函数)。因此,栈的要点可以总结如下:

在IA-32平台上,栈向着较小的地址增长;

栈以LIFO的顺序移去或增加数据;

局部变量及只存在于函数生命周期的变量随着栈的取消而结束;

每个函数都会有包含局部变量的栈帧(除非是编译器故意忽略了);

在每个函数的栈帧之前有保存的帧指针、返回地址和例程的参数;

栈帧在预处理期间构造,在扫尾例程执行期间取消。

2.3 栈、堆及二进制可执行文件中的其他区段(2)

堆是另一种重要的数据结构,不是说处理器对它有多依赖,而是因为它被使用的频率很高。堆只是内存中一个区段(主要供那些动态分配的变量使用,这些变量需存在于当前栈帧之外)。由此,大多数对象和应用程序使用的大量数据都保存在堆上。堆通常是随机映射的,或(在更经典的例子里)是数据段的动态扩展(尽管DS几乎不会指向堆)。从此种意义上说,处理器是不知道这些细节的。此外,操作系统也不太了解用户区的堆,它只在需要时尽可能地分给应用程序更多的内存,否则操作失败。它是典型的提供堆操作并定义相应语义的libc或类似的东西。

特别是在初始化时,堆将向操作系统请求尽可能大的内存区段,然后按应用程序的需求分给小内存块。这些块包含内联的元数据,指示块大小及其他元素,例如前一块内存的大小。

通过指向给定块的指针,再加上它(给定块)的大小就可以找到下一个块,或者通过把块开头地址减去前一个块的大小来发现前一个块。例如,在图2-7里你会看到Glibc分配的块。在这个例子里,标记为mem的指针指示数据的开始(由malloc()或类似方式返回给API 用户),而标记为chunk的指针则是实际块的开始。在这里,我们发现了包含前一个块的大小、当前块的大小,以及指示各种状态条件的元数据。这个块(通常是Linux使用)与大多数操作系统及动态内存分配实现(当然,还是有一些区别)所用的块是相似的。因为从操作系统获取大内存块或扩展数据段的大小需要花费相当多的操作,因此,通常会维护分类的cache(高速缓冲存储器)。这个cache通常以指向前面free()的内存块的指针链表形式出现。这个链表一般会很复杂,相邻的空闲块会合并在一起,从而减少碎片。通过大小或其他特性对链表进行排序,从而在请求发生时以最有效的手段尽可能定位候选的内存块。

 
(点击查看大图)图2-7 Glibc 分配chunk
为了和前一个例子保持一致,我们在图2-8里提供了用Glibc表示的空闲内存块。在这个例子里,标为mem的指针指示返回给API用户的指针通常所处的位置,标为chunk的指针指向使用的物理数据结构的开头。两者最大的不同之处是使用过的用户数据中保存着两个指针,一个指向链表里下一个空闲的内存块,另一个指向链表里前一个内存块。这暗示,不像已分配的块是通过大小来遍历的,空闲的内存块直接通过链表遍历。虽然结构列表的细节是关于Glibc的,但概念本身在大多数实现中是雷同的。因此,在普通应用程序生命周期里频繁发生分配和释放操作时,把原来从操作系统里获取的内存块取出来,返回给空闲列表,如果可能的话,当再有分配请求时,将会利用空闲列表里的内存块,如此这般,直到所有的内存都被使用或应用程序结束。
 
图2-8 Glibc空闲块

本节,我们讨论了二进制可执行文件中常见的区段,以及它们的作用,深入探讨了栈段与堆段,并在一定程度上讨论了它们的工作原理。这对于本书的学习已经够用了,但我们仍建议感兴趣的读者仔细阅读相关的参考文档。


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值