您的位置:首页 > 运维架构 > Linux

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

2013-04-21 20:56 387 查看
栈与函数调用惯例(又称调用约定)— 正篇

前篇笔记的基础上,本文继续介绍栈与函数调用约定的相关内容。

1. 函数调用的栈帧结构

IA32程序用栈来实现函数调用。机器用栈来传递函数参数、保存返回地址、保存寄存器(即函数调用的上下文)及存储本地局部变量等。为单个函数调用分配的那部分栈称为栈帧(stack frame),栈帧的边界由2个指针界定:寄存器%ebp为帧指针(严谨的说法是,帧指针存放在%ebp中),指向当前栈帧的起始处,通常较固定;寄存器%esp为栈指针,指向当前栈帧的栈顶位置,当程序执行时,栈指针可以移动,因此大多数数据的访问都是相对于帧指针的。

下图给出了栈帧的通用结构。



结合前篇笔记介绍的进程虚拟地址空间和上图的栈帧结构,我们可以看到,在经典的操作系统(如各种类UNIX操作系统)中,栈总是向下增长的,压栈(push)时栈顶地址减小,弹栈(pop)时栈顶地址增大。另外还可以注意到,堆通常是向上增长的,但在Windows系统中,大部分堆由HeapCreate()产生,而HeapCreate系列函数却完全不遵照堆向上增长的规律。

下面根据函数调用中典型的栈帧结构,对函数调用栈的实现过程做如下描述:

若函数P(调用者caller)调用函数Q(被调用者,callee),则Q的参数存放在P的栈帧中(在对应的汇编代码中,表现为在跳转至callee startaddress之前,通过push指令将函数Q所需的参数依次压入栈中,压栈顺序与本文主题—函数调用约定有关,具体实现过程大家看到后面的汇编代码就会清楚)。另外,当P调用Q时,P中的返回地址被压入栈中(call指令实现返回地址压栈并跳转至callee入口地址处),形成P的栈帧末尾。返回地址其实就是P中调用Q的指令执行完后下一条将要执行的指令地址。接着,需要保存P的帧指针(用于从Q返回时恢复P的栈帧结构)并将P栈帧当前的栈顶地址值(存放在%esp中,是P的栈帧边界之一)装入帧指针寄存器%ebp,Q的栈帧边界即从该%ebp开始,可见,经过这样的操作流程,当前的%ebp指向的位置既是P栈帧的结束边界,又是Q栈帧的开始边界。接着是保存其它寄存器的值。最后开始真正执行函数Q包含的功能指令。

对上段让人眼晕的关于函数调用栈实现的描述做个总结:

1)将被调用函数的参数压栈(注:在x86_64平台中,CPU拥有16个通用64-bit寄存器,故调用函数时,前6个参数通常由寄存器来传递,剩下的才通过栈传递)

2)将当前指令,即函数调用指令的下一条指令地址作为返回地址压入栈中

3)跳转到函数体执行

其中,第2、3步由call指令来实现。跳转到函数体后,该函数体的开始指令通常是这样的:

1)push %ebp :将调用者的帧指针%ebp压栈,即保存旧栈帧的帧指针以便函数返回时恢复旧栈帧

2)mov %esp, %ebp:将当前栈顶地址传给%ebp,此时,%ebp既是旧栈帧的结束地址,又是被调用者的新栈帧的起始地址

3)sub xxx, %esp:将栈顶下移,即为被调用函数开辟栈空间,xxx为立即数且通常为16的整数倍(这会浪费一些空间,但gcc采用该规则来保证数据的严格对齐)

4)push xxx:该命令为可选项,如有必要,由被调用者负责保存/恢复某些寄存器

不难推导出函数返回时通常由如下指令序列构成:

1)pop xxx:可选项,与进入函数时是否push xxx保存寄存器相对应

2)mov %ebp, %esp:恢复调用者的栈顶指针,即调用者栈帧的结束边界

3)pop %ebp:调用者的帧指针弹栈,即恢复调用者栈帧的起始边界

4)ret:从栈中得到返回地址,并跳转到该位置处继续执行

注意:函数退出前指令序列的第2、3步也可由指令leave来实现,具体用哪种方式,由编译器决定。

上面给出的是函数调用时,典型的进入/退出指令序列,某些情况下,编译器生成的指令序列并不按照上面的方式进行。例如若C函数被声明为static(只在本编译单元内可见)且函数在编译单元内被直接调用,没有被显示或隐式取地址(即没有任何函数指针指向该函数),在这种情况下,编译器确信该函数不会被其它编译单元调用,因此可以随意修改其进/出指令序列以达到优化的目的。

2. 寄存器使用约定

从前面介绍的函数进入/退出指令序列,已经看到,在函数调用过程中,有些寄存器是由被调用者负责保存/恢复的。在正式介绍函数调用惯例之前,有必要先对寄存器使用惯例做个说明。

程序寄存器组是唯一能被所有函数共享的资源,虽然在给定时刻只有一个函数是活动的,但我们必须保证当某个函数(caller)调用另一个函数(callee)时,callee不会覆盖caller稍后会使用到的寄存器值。为此,IA32采用了一套统一的寄存器使用惯例,所以的函数(包括库函数)调用都必须遵守。

根据惯例,寄存器%eax、%edx和%ecx为调用者保存寄存器(caller-saved
registers)
,当函数P调用Q时,Q可以覆盖这些寄存器,而不会破坏任何P所需的数据。寄存器%ebx、%esi和%edi为被调用者保存寄存器([b]callee-saved
registers
)[/b],即Q在覆盖这些寄存器的值时,必须先把它们保存到栈中,并在返回前恢复它们,因为P可能会用到这些值。此外,根据惯例,必须保持寄存器%ebp和%esp,即函数返回后,这两个寄存器必须恢复到调用前的值,换句话说,必须恢复调用者的栈帧。

只要各函数调用遵守上述惯例,就可以正常工作。

3. 函数调用惯例(calling convention,又称调用约定)

前面介绍的一系列基础知识都是为能彻底搞清楚本节内容,下面开始切入正题。目前,我们已经掌握了函数调用时函数栈的实现原理及寄存器的使用惯例,在这些知识的支撑下,理解函数调用约定变得相当容易。

根据前面的分析我们已知,编译器根据一套简单的惯例来产生栈结构代码。参数在栈上传递给函数,callee可以从栈中用相对于%ebp的正偏移量(+8,+12,…)来访问参数。可以用push指令或栈指针下移(sub一个立即数)为callee分配栈空间。在callee返回前,函数必须将栈恢复到调用前的状态(通过恢复所有的被调用者保存寄存器和%ebp且重置%esp使其指向返回地址来实现)。为保证程序能正确执行,让所有函数调用都遵守一套建立/恢复栈帧的一致惯例非常重要。

一个函数调用惯例一般会规定如下几方面内容:

1)函数参数的传递顺序和方式

函数参数传递方式由多种,最常见的是通过栈传递。caller将参数压入栈中,callee从栈中将参数取出。对于有多个参数的函数,调用惯例要规定caller将参数压栈的顺序(从左至右还是从右至左)。有些调用惯例还允许使用寄存器传参以提高性能。

2)栈的维护方式

在caller将参数压栈后,callee的函数体会被调用,返回时需要将被压栈的参数全部弹出,以便保持栈在函数调用前后的一致。这个弹栈过程可以由caller负责完成,也可由callee负责完成。

3)名字修饰(Name-mangling)策略

为了链接时对调用惯例进行区分,调用惯例要对函数本身的名字做修饰。不同的调用惯例有不同的名字修饰策略。几种主要的函数调用惯例总结如下:



此外,不少编译器还提供一种称为naked call的调用惯例,这种调用惯例特点是编译器不产生任何保存寄存器的代码,故称为naked call,用于一些特殊场合。

对C++而言,以上几种调用惯例的名字修饰策略有所改变,因为C++支撑函数重载、命名空间和成员函数等新语法特征,因此,一个函数名可以对应多个函数定义,上面提到的名字修饰策略显然无法区分各个不同同名函数定义,故C++有一套更加复杂的名字修饰策略。此外,C++还有一种特殊的调用惯例,称为thiscall,专用于类成员函数的调用。其特点随编译器不同而不同,对于gcc,thiscall和cdecl完全一样,只是将this看做函数的第1个参数;而在VC中,this指针存放在%ecx中,参数从右至左压栈。

4. 函数返回值传递

除参数传递外,函数与调用方还可以通过返回值进行交互。对于函数返回值的实现细节,有以下几条原则:

1)对于小于4字节的返回值,调用惯例通常将其存在%eax中,调用方通过读取%eax获取返回值。

2)对于返回值为5-8字节对象的情况,几乎所有的调用约定都采用%eax和%edx联合返回的方式进行,其中%edx保存返回值的高4字节,%eax保存返回值的低4字节。

3)对于超过8字节的返回类型,实现方式稍微复杂,可以总结为:a. 调用者在栈上额外开辟空间并将该空间的一部分作为传递返回值的临时对象,这里称为temp; b. 将temp对象地址作为隐藏参数传递给被调用的函数;c. 被调用函数将待返回数据拷贝给temp对象并将temp对象的地址存入%eax;d. 被调用函数返回后,调用者将%eax指向的temp对象拷贝给接收返回值的对象。

可见,如果返回值类型的size太大,C语言在函数返回时会使用一个临时的栈上内存区域作为中转,结构返回值对象会被拷贝两次。因此,不到万不得已,不要轻易返回size较大的对象,我们可以通过传入相应指针来接收返回值的方法来代替函数直接返回大对象。

此外还要注意,函数传递大size返回值所使用的方法是不可移植的,不同的编译器、不同的平台、不同的调用惯例甚至不同的编译参数都可能采用不同的实现方法。

关于大size返回值的实现细节分析,要涉及到较大篇幅的汇编代码分析,本文不再展开。感兴趣或想深究的同学,建议阅读《程序员的自我修养—链接、装载与库》一书10.2小节的内容。

【参考资料】

1. Computer Systems: A Programmer’sPerspective, 2E. 第3章

2. 程序员的自我修养—链接、装载与库. 第10章

3. http://www.cs.virginia.edu/~evans/cs216/guides/x86.html

================ EOF =================
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: