【Linux学习笔记】栈与函数调用惯例—上篇

来源:互联网 发布:ubuntu路径设置 编辑:程序博客网 时间:2024/05/21 09:20

栈与函数调用惯例(又称调用约定)— 基础篇

        记得一年半前参加百度的校招面试时,被问到函数调用惯例的问题。当时只是懂个大概,比如常见函数调用约定类型及对应的参数入栈顺序等。最近看书过程中,重新回顾了这些知识点,对整个调用栈又有了较深入的理解。作为笔记,记录于此。

        NOTICE:本文笔记以32位Linux系统为背景,可能与Windows操作系统的底层机制有些小差异(比如进程虚拟空间的布局),但总的来说,原理是相通的。

1. 进程虚拟地址空间

         犹记得当年一个困扰了自己很长时间的问题:“我的机器物理内存只有1G,为什么很多资料提到32位系统下,每个进程都拥有4G的地址空间”?更不理解的是“为啥各进程的4G地址空间还相互独立,不会冲突”?对于非计算机科班出身、当时没学过操作系统的学生来说,实在是难以理解这其中的奥妙。后来读到Andrew.S.Tanenbaum教授写的《Modern Operating Systems》一书,才有种被醍醐灌顶、豁然开朗的感觉,原来都是操作系统在背后“作怪”!在此建议对这部分概念有疑问的同学去读经典的操作系统教材,可以少走很多弯路。

         进程虚拟空间看似与本文主题无关,其实不然,若不对进程空间建立整体概念,理解函数调用栈容易陷入“只见树木不见森林”的困境。这些都是我自学过程中的体会,因此废话有点多,下面开始切入正题。

       Linux系统中,进程的虚拟地址空间典型布局如下图所示。

                           

          由上图可知,32位平台中,进程虚拟地址范围为0x00000000-0xFFFFFFFF(共4GB),其中0x00000000-0xBFFFFFFF(共3GB)为用户空间,位于高地址部分的1GB为内核空间,范围为0xC0000000-0xFFFFFFFF。整个进程虚拟地址可分为几个部分,下面从地址从低到高的方向进行说明:

       1)保留区

        它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。

        2)代码和只读数据区

        对于所有进程来说,代码都是从同一固定地址开始,如Linux系统通常从0x08048000开始代码段(如前所述,从地址0到代码段起始地址的部分通常为操作系统保留区)。代码及只读数据区是直接按照可执行目标文件的内容初始化的,与目标文件中的代码段(.text)、初始化段(.init)及只读数据段(.rodata)相对应。

        3)可读/写数据区

        可执行文件中的数据被映射至该区,包括.data和.bss。想进一步理解.data/.bss区别的同学,可查阅其它资料(比如这里),此处略过。

        4)堆

        代码和数据区往上是运行时堆。与代码/数据段在程序加载时就确定了大小不同,堆可以在运行时动态扩展或收缩。调用如malloc/free、new/delete这样的库函数时,操作的内存区域就在堆中。堆的范围通常较大,如在32位Linux系统中,堆的上限理论值可以达到2.9GB。

        5)共享库

        该区域用于映射可执行文件用到的动态链接库。在Linux 2.4版本中,若可执行文件依赖共享库,则系统会为这些动态库在从0x40000000开始的地址分配相应空间,并在程序装载时将其载入到该空间。在Linux 2.6内核中,共享库的起始地址被往上移动至更靠近栈区的位置(见下文加粗部分的特别说明)。

        6)栈

        栈用于维护函数调用的上下文,编译器用栈来实现函数调用。跟堆一样,用户栈在程序运行期间可以动态扩展和收缩。与堆相比,栈通常较小,典型值为数MB。

        7)内核空间

        内核总是驻留在内存中,是操作系统的一部分。内核空间就是为内核保留的,不允许应用程序读写这个区域的内容或直接调用内核代码定义的函数。32位Linux系统中,默认将高地址的1GB分配为内核空间;而Windows默认将高地址的2GB分配为内核空间,当然,也可以配置为1GB。

        需要特别说明的问题:

        从进程地址空间的布局可以看到,在有共享库的情况下,留给堆的可用空间还有两处:一处是从.bss段到0x40000000,约不到1GB的空间;另一处是从共享库到栈之间的空间,约不到2GB。这两块空间大小取决于栈、共享库的大小和数量。这样来看,是否应用程序可申请的最大堆空间只有2GB?事实上,这与Linux内核版本有关。在上面给出的进程地址空间经典布局图中,共享库的装载地址为0x40000000,这实际上是Linux kernel 2.6版本之前的情况了,在2.6版本里,共享库的装载地址已经被挪到靠近栈的位置,即位于0xBFxxxxxx附近,因此,此时的堆范围就不会被共享库分割成2个“碎片”,故kernel 2.6的32位Linux系统中,malloc申请的最大内存理论值在2.9GB左右。

        2. IA32的通用寄存器组

        函数调用栈的实现与CPU的寄存器组密切相关,因此,有必要做简单介绍。

        Intel 32位体系结构(简称IA32)的CPU包含一组通用寄存器,由8个32-bit寄存器构成,如下图所示。

                  

         在最初的8086中,寄存器是16-bit的,每个都有特殊用途,这些寄存器的名字就是反映这些不同的用途。由于IA32平台采用了平坦寻址模式,其对特殊寄存器的需求大大降低,但由于历史原因,这些寄存器的名称就这样保留下来。在大多数情况下,上图所示的前6个寄存器均可作为通用寄存器使用。之所以说“大多数情况”,是因为有些指令以固定的寄存器作为源寄存器或目的寄存器(如一些特殊的算术操作指令imull/mull/cltd/idivl/divl要求一个参数必须在%eax中,其运算结果存放在%edx(higher 32-bit)和%eax (lower32-bit)中;又如函数返回值通常保存在%eax中)。剩下两个寄存器(%ebp和%esp)在函数栈中起着重要作用,具体内容后面介绍函数栈时会做说明。

        对于寄存器%eax, %ebx, %ecx和%edx,各自可被作为2个独立16-bit的寄存器使用,而对于其中的lower 16-bit寄存器,还可以继续分为2个独立8-bit的寄存器使用。编译器会根据操作数的大小选择合适的寄存器来生成汇编码。在汇编语言层面,这组通用寄存器以%e(AT&T syntax)或直接以e(Intel syntax)开头来引用,例如mov $5, %eax或mv eax, 5是指将立即数5赋值给register %eax。关于两种主流assembly在语法上的区别,可参见wikipedia相关词条。

        本文介绍了两部分基础知识,为理解栈与函数调用惯例做铺垫,正篇内容请见下篇笔记。^_^


=================== EOF ==================



原创粉丝点击