Chinaunix首页 | 论坛 | 博客
  • 博客访问: 51773
  • 博文数量: 38
  • 博客积分: 0
  • 博客等级: 民兵
  • 技术积分: 10
  • 用 户 组: 普通用户
  • 注册时间: 2015-03-04 01:14
个人简介

不止于此

文章分类
文章存档

2015年(38)

我的朋友

分类: C/C++

2015-05-13 23:10:17


    首先,让我们对程序的运行环境有一个总览,下图描述了一个典型的程序环境。
    

1、程序的内存布局
    现代的应用程序都运行在一个内存空间里,在32位的系统里,这个内存空间拥有4GB的寻址能力。相对于16位时代i386的段地址加段内偏移的寻址模式,如今的应用程序可以直接使用32位的地址进行寻址,这被称为平坦(flat)的内存模型。在平坦内存模型中,整个内存是一个统一的地址空间,用户可以使用一个32位的指针访问任意内存位置。例如:
  1. int *p = (int *)0x12345678;
  2. ++*p;
    这段代码展示了如何直接读写指定地址的内存数据。不过,尽管当今的内存空间号称是平坦的,但是实际上内存仍然在不同的地址空间上有着不同的地位,例如,大多数OS都会将4GB的内存空间中的一部分留给内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间。Windows下默认情况下会将高地址的2GB空间分配给内核,而Linux默认情况下将高地址的1GB空间分配给内核。
  
    用户使用的剩下2GB或3GB的内存空间称为用户空间。在用户空间中,也有许多地址区间有特殊的地位,一般来讲,应用程序使用的内存空间有如下“默认”的区域。
    (1)栈:栈用于维护函数调用的上下文,离开了栈函数调用就没办法实现。栈通常在用户空间的最高地址处发呢皮,通常有数兆字节的大小。
    (2)堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自于堆里。堆通常在栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多。
    (3)可执行文件映像:这里存储着可执行文件在内存里的映像。
    (4)保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如NULL。通常C语言将无效指针赋值为0也是出于这个考虑,因为0地址上正常情况下不可能有有效访问数据。
    下图是Linux下一个进程里典型的内存布局。
    
    在上图中,有一个没有介绍的区域:“动态链接库映射区”,这个区域用于映射装载的动态链接库。在Linux下,如果可执行文件依赖于其他共享库,那么系统就会它在从0x40000000开始的地址分配相应的空间,并将共享库载入到该空间。
    这上图中可以清晰的看出栈向低地址增长,堆向高地址增长。当栈或堆现有的大小不够用时,它将按照图中的增长方向扩大自己的尺寸,直到预留的空间被用完为止。
    

2、栈与调用惯例
    什么是栈
    栈是现代计算机程序里的最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今能够看见的所有的计算机语言。
    在经典的计算机科学中,栈被定义为一个特殊的容器,用户可以将数据压入栈中,也可以将已经压入栈中的数据弹出,但栈这个容器必须遵守一条规则:先入栈的数据后出栈。
    在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。
    在经典的操作系统中,栈总是向下增长的。在i386下,栈顶由称为esp的寄存器进行定位。压栈的操作使栈顶的地址减小,弹出的操作使栈顶地址增大。
    下图是一个栈的实例。
    

    栈在程序运行中具有举足轻重的地位。最为重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧或活动记录。堆栈帧一般包括如下几方面的内容:
    (1)函数的参数和返回地址
    (2)临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
    (3)保存上下文:包括在函数调用前后需要保持不变的寄存器
    在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也就指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置。一个很常见的活动记录示例如下图所示。
    
    在参数之后的数据(包括参数)即是当前函数的活动记录,ebp固定在图中所示的位置,不随这个函数的执行而发生变化,相反地,esp始终指向栈顶,因此随函数的执行,esp会不断的发生变化。固定不变的ebp可以用来丁文函数活动记录中的各个数据。在ebp之前首先是这个函数的返回地址,它的地址是ebp-4,再往前是压入栈中的参数,它们的地址分别是ebp-8、ebp-12等,视参数的数量和大小而定。
    ebp所直接指向的数据是调用该函数前ebp的值,这样在函数返回的时候,ebp可以通过读取这个值恢复到调用前的值。
    之所以函数的活动记录会形成这样的结构,是因为函数调用本身是如此书写的:一个i386下的函数总是这样调用的:
    (1)把所有参数或一部分参数压入栈中,如果有其他参数没有入栈,那么使用特定的寄存器传递;
    (2)把当前指令的下一条指令的地址压入栈中;
    (3)跳转到函数体执行;
    其中第2步和第3步由指令call一起执行。跳转到函数体之后即开始执行函数,而i386函数体的“标准”开头是这样的(但也可以不一样):
    (1)push ebp:把ebp压入栈中(称为old ebp)
    (2)mov ebp,esp:ebp=esp(这时ebp指向栈顶,而此时栈顶就是old ebp)
    (3)【可选】sub esp,XXX:在栈上分配XXX字节的临时空间
    (4)【可选】push  XXX:如有必要,保存名为XXX寄存器(可重复多个)
    把ebp压入栈中,是为了在函数返回时便于恢复以前的ebp值。而之所以可能要保存一些寄存器,在于编译器可能要求某些寄存器在调用前后保持不变,那么函数就可以在调用开始时将这些寄存器的值压入战中,在结束后再取出。
    不难想象,在函数返回时,所进行“标准”结尾与“标准”开头正好相反:
    (1)【可选】pop XXX:如有必要,恢复保存过的寄存器(可重复多个)
    (2)mov esp,ebp:恢复esp同时回收局部变量空间
    (3)pop ebp:从栈中恢复保存的ebp的值
    (4)ret:从栈中取得返回地址,并跳转到该位置

    为了加深印象,下面我们反汇编一个函数看看:
  1. int foo()
  2. {
  3.    return 123;
  4. }
    这个函数反汇编(VC9,i386,Debug模式)得到的结果如下图所示(非粗体部分为调试用的代码)。
    我们可以看到头两行保存了旧的ebp,并让ebp指向当前的栈顶。接下来的一行指令
  1. 004113A3 sub esp,0C0h
    将栈扩大了0xC0个字节,其中多出来的空间的值并不确定。这么一大段多出来的空间可以存储局部变量、某些临时数据或调试信息。
    在第3步里,函数将3个寄存器保存在了栈中。这3个寄存器在函数随后的执行中可能被修改,所以要先保存一下这些寄存器原本的值,以便退出函数时恢复。
    第4步的代码用于调试。这段汇编代码大致等价于如下伪代码:
  1. edi = ebp - 0x0C;
  2. ecx = 0x30;
  3. eax = 0xCCCCCCCC;
  4. for( ; ecx != 0; --ecx, edi += 4)
  5.     *((int *)edi) = eax
    可以计算出,0x30*4 = 0xC0。所以实际上这段代码将内存地址从ebp-0xC0到ebp这一段全部初始化为0xCC。恰好就是第2步在栈上分配出来的空间。
    
    在第5步,函数将0x7B(即123)赋值给eax,作为返回值传出。在函数返回之后,调用方可以通过读取eax寄存器来获取返回值。
    接下来的几步是函数的资源清理阶段,从栈中恢复保存的寄存器、ebp等。
    最后使用哪个ret指令从函数返回。
    
    以上介绍的是i386标准函数进入和退出指令序列,它们的基本的形式为:
    
    其中x为栈上开辟出来的临时空间的字节数,reg1...reg2分别代表需要保存的n个寄存器。方括号部分为可选项。
    不过在有些场合下,编译器生成函数的进入和退出指令序列时并不按照标准的方式进行。例如一个满足如下要求的C函数:
    (1)函数被声明为static(不可在此编译单元之外访问)
    (2)函数在本编译单元仅被直接调用,没有显示或隐式取地址(即没有任何函数指针指向过这个函数)
    编译器可以确信满足这两条的函数不会在其他编译单元内被调用,因此可以随意地修改这个函数的各个方面——包括进入和退出指令序列——来达到优化的目的。----->这里讲的这点我不懂。

3、调用惯例
   
经过前面的分析和讨论,我们大致知道了函数调用时实际发生的事件。从这样的信息里能够发现一个现象,那就是函数的调用方和被调用方对函数如何调用有着统一的理解,例如它们双方都一致地认同函数的参数是按照某个固定的方式压入栈。如果不这样的话,函数将无法正确运行。
    函数的调用方和被调用方对于函数如何调用须要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确的调用,这样的约定就称为调用惯例
    一个调用惯例一般会规定如下几个方面的内容:
    (1)函数参数的传递顺序和方式
    函数参数的传递有很多种方式,最常见的一种是通过栈传递。函数的调用方将参数压入栈中,函数自己再从栈中将参数取出。对于有多个参数的函数,调用惯例要规定函数调用方将参数压入栈的顺序:是从左到右还是从右到左。有些调用惯例还允许使用寄存器传递参数,以提高性能。
    (2)栈的维护方式
    在函数将参数压入栈之后,函数体会被调用,此后需要将压入栈中参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由函数的调用方来完成,也可以由函数本身来完成。
    (3)名字修饰的策略
    为了链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
    
    事实上,在C语言中,存在着多个调用惯例,而默认的调用惯例是cdecl。任何一个没有显式指定调用惯例的函数都默认是cdecl惯例。对于函数foo的声明,它的完整形式是:
    int _cdecl foo(int n, float m);
    cdecl这个调用惯例是C语言默认的调用惯例,它的内容如下所示。
    
    
    下面是函数foo在_cdecl调用惯例下的调用过程,不再一个字一个字敲打,很好的理解。
    
    
    
    



    补充:
    我们在VC下调试程序的时候,常常看到一些没有初始化的变量或内存区域的值是“烫”。例如下列代码:
  1. int main()
  2. {
  3.     char p[12];
  4. }
    此代码中的数组p没有初始化,当我们在debug模式下运行这个程序,在main中设下断点并监视数组p时,就能看到如下图的情形。
    
    之所以会出现“烫”这么一个奇怪的字,就是因为Debug模式在第4步里,将所有的分配出来的栈空间的每一个字节都初始化为0xCC。0xCCCC(即两个连续排列的0xCC)的汉字编码就是烫,所以0xCCCC如果被当作文本就是“烫”。
    将未初始化数据设置为0xCC的理由是这样的,可以有助于判断一个变量是不是没有初始化。如果一个指针变量的值是0xCCCCCCCC,那么我们就可以基本相信这个指针没有经过初始化。当然,这个信息仅供参考,编译器检查未初始化变量的方法不能以此为证据。有时编译器还会使用0xCDCDCDCD作为未初始化的标记,此时我们就会看到汉子“屯屯”。


    感谢作者。

























































阅读(585) | 评论(0) | 转发(0) |
0

上一篇:linux多线程练习

下一篇:Linux设备之I2C

给主人留下些什么吧!~~