x86下的C函数调用惯例

1 从汇编到C

1.1 汇编语言的局限性

汇编语言是一种符号化了的机器语言(machine code),即用指令助记符、符号地址、标号等符号书写程序的语言。汇编语句与机器语句一一对应,它只是把每条指令及数据用便于记忆的符号书写而已。

汇编语言,使用人类语言的单词作为助记符与机器码建立一一对应关系。汇编器维护了对应关系映射表,并在汇编阶段将汇编代码翻译成机器码指令。相对于直接纸带打孔而言,汇编程序已经前进了一步。但是第一个编写汇编程序的人,或者说汇编器的设计本身还是需要手工汇编机器码,当然存储设备也不再是纸带,扫描译码设备也更先进。

汇编器甚至将常用指令序列有机组合成伪指令,以合成指令的形式向汇编程序员提供更简单和智能的操作接口。当然,在预编译阶段,汇编器需要将合成指令扩展分解成机器可识别的原子指令序列

尽管汇编程序消除了手工汇编机器码缺少创造性这一问题,但是汇编语言还是存在两个问题:第一,汇编语言冗长、乏味,因为在微处理器芯片级编程,必须熟悉每一个指令细节。第二个问题是汇编语言不可移植,各个型号的CPU的IA(Instruction Architecture)不同,汇编语言也不同,这样导致了汇编语言不可移植。为 Intel x86 编写的汇编程序无法在 MIPS CPU 上运行。

比如,我们要设计一个函数,计算两个整数的加和——A=B+C。无论哪种 CPU 都提供了基本的算术加法运算支持(加法器),但是这么一种简单的加法运算,如果用汇编语言编写,在不同的 CPU IA 中表现出不同的代码形式。在 Intel x86 的机器上用 x86 汇编语言编写一次,下一次在 MIPS 机器上又得用 MIPS 汇编语言编写一次。但这种通用的处理过程或者说运算都有着相似的逻辑(语义),不同语言的词汇集合和语法结构差异导致语言组织的不同。

1.2 高级程序设计语言

不同的语言造成了沟通隔阂,一个不懂德语的中国人和一个不懂中文的德国人的交谈无异于对牛弹琴。这个时候,他们需要一种双方都能听懂的第三方语言(比如国际通用的英语)来介入交流,然后双方将第三方语言还原到各自的语境文化中理解对应的意象和表义。这种第三方的语言为大家的共同语言,用共同语言发出的声音或编写的文档能够为大家所理解。

我们需要设计一种更高级的共同语言,这种语言将更接近人类语言的概念描述和语法组织。我们的一个重要目的就是实现可移植性,对于同一功能模块,只需使用共同语言编写一次,避免重复劳动。这种高级语言就是C语言,它具有更高的抽象性,提供更完善更自然的话语体系。

那么C语言是如何实现跨平台移植的呢?机器只能识别 IA 指令集对应的机器码,工具链中的汇编器实现了低级汇编语言到机器码的映射。鉴于此,我们只需要将高级 C 语言“映射”成汇编语言就行了。但是 C 语言文件本质是一种由字符串(C语言关键字)序列组成的文本文件,故首先需要对字符串文本进行解析;其次,C 语言的高度抽象性和语法复杂性导致无法与汇编语言建立简单的映射关系。将 C 语言翻译成汇编语言的工作,是一种叫做编译器的特殊程序处理的。特定体系架构工具链(toolchain)中的编译器(compiler)负责将C语言文件进行一系列词法分析、语法分析、语义分析及优化后生成相应的汇编代码,然后汇编器负责将汇编代码翻译成指令机器码。相比只是建立简单映射关系的汇编器,编译器要复杂得多。

有了编译器和汇编器的支持,高级语言C就可以实现跨平台移植了。C++、Java等面向对象的更高级语言,则更进一步的接近人类语言和思维习惯,它们使得现代软件更加工程化和模式化。当然,更高一级的语言,需要更高一级的编译处理支持或其他设施支持。实际上C++是基于C实现的,例如修饰符实现了重载类型识别,虚函数表实现了继承覆盖,这些都要求C++编译器提供比C编译器更多的内置支持。面向对象语言Java采用中间码和虚拟机机制实现了跨操作系统平台的开发、移植和运行。无论是C++或Java字节码,最终都必须转化成汇编语言,然后再翻译成指令机器码才能在特定CPU上执行。

高级语言能够实现跨平台开发,并使软件工程逐渐规模化。但是高级语言的运行需要建立一定的环境,比如C函数调用需要堆栈支持。在系统启动之初的引导阶段(bootstrap),往往需要使用汇编语言编写代码实现对CPU或硬件设备的配置,待相关硬件和内存初始化好了之后,建立堆栈即可跳转到C函数执行。故一般在开发嵌入式系统的bootloader的bootstrap部分,还是需要使用更接近硬件的汇编语言编写。在涉及性能敏感性操作问题时,也可在C语言中嵌入汇编代码实现混合编程。

2 程序、可执行文件、进程和线程

2.1 程序

程序是计算机的一组指令的集合,它告诉计算机如何实现特殊的功能,程序设计中的程序=数据结构+算法。我们通常说“编程序”或“写程序”,可见程序往往具有文件属性,例如使用汇编语言编写的汇编程序保存为.s/.asm文件、使用C语言编写的C程序保存为.c文件。程序经过编译链接成可执行文件,被加载到操作系统中,最终执行特定CPU的指令流才能完成特定的计算任务。

2.2 可执行文件

编译器(汇编器)后端的代码生成器(Code Generator)负责将C代码或中间代码(AT&T汇编)转换成目标机器代码。这个过程十分依赖于目标机器,因为不同机器的CPU字长、寄存器、整数数据类型和浮点数数据类型等都不同,而且还要考虑流水线、多发射、超标量等诸多复杂的特性。

可执行文件 (executable file) ,可移植可执行文件格式的文件,它可以加载到内存中,并由操作系统加载程序执行。例如,Windows下的可执行文件格式为COFF/PE(.exe),Linux下的可执行文件格式为ELF(一般无后缀)。可执行文件一般由编译器(汇编器)生成的多个目标文件链接生成,它包含着组成可执行程序的全部目标机器代码。程序在可执行文件中被划分为代码段(.text)和数据段(.data+.rodata+.bss)。可执行文件头部有自描述性信息,其中定义了目标机器平台、OS/ABI、入口地址、段表等重要信息。躯干部分的代码段一般为只读,数据段为可读可写,这种分段管理,便于实现共享库和多进程实例共享一份代码。我们可以通过VC(COFF/PE)的dumpbin工具或gcc(ELF)的readelf/objdump工具查看可执行文件中的信息或对机器码进行反汇编。

2.3 进程

PC上存放在磁盘上的可执行文件是静态的,当鼠标点击或者通过命令行呼叫可执行文件,可执行文件被加载到内存中开始执行。在Windows/Linux下,将为可执行文件创建一个进程实例。进程Process)是具有一定独立功能的程序关于某个数据集合上的一次运行过程,是系统进行资源分配和调度的独立单位。进程是由进程控制块(PCB)、程序段、数据段三部分组成,这里的程序段和数据段当然来源于可执行文件,但加载器配合进程内存管理会做一些地址重定位。可见,进程是一个正在运行的程序,它是可执行程序(文件)的实例。

进程只是一个容器,它是不活泼的。进程要完成任何事情,必须由运行在其地址空间上的线程完成。线程是进程内执行代码的独立实体,它负责执行该进程地址空间的代码。每个进程至少拥有一个在其地址空间中运行的线程,对一个不包含任何线程的进程来说,它是没有理由继续存 在下去的,系统会自动地销毁此进程和它的地址空间。

2.4 线程

线程(Thread)是进程内的一个独立执行单元,是CPU调度和分派的基本单位。一个线程就是运行在一个进程上下文中的一个逻辑流,它描述了进程内代码的执行路径。每个线程都有自己的线程上下文(Thread Context),包括唯一的整数线程id,栈(Stack),栈指针(Stack Pointer),程序计数器(Program Counter),通用寄存器等。

线程的运行体现为函数的调用,具体来说创建线程时会指定一个线程入口函数,线程从这个入口点函数开始执行。之后的线程运行可以当做一系列的嵌套函数调用,这些函数都运行于线程上下文中。

在进程中运行的所有线程共享该进程的整个虚拟地址空间,所有线程的动态申请内存都从进程堆空间分配,每个线程在创建时都会指定申请的栈空间。

在Windows中,通过CreateThread()调用创建线程,通过参数dwStackSize指定栈空间大小。在Linux中通过pthread_attr_init()初始化线程属性参数pthread_attr_t,再调用pthread_attr_setstacksize()设置栈空间大小,然后调用pthread_create(pthread_attr_t)创建线程。栈空间也是在线程所在的虚拟地址空间开辟的。

3 C函数调用惯例

下面将重点梳理C函数调用惯例(Calling Convention),主要以x86体系架构的 x86 Disassembly Calling Convention 为例,偶尔也会和MIPS对比。

  • MSDN:Calling Convention / Overview of x64 Calling Conventions
  • Apple:OS X ABI Function Call Guide

3.1 函数调用

子函数(SubRoutine)往往被设计用来完成计算子任务,以期提高代码的模块化和可重用性。调用子函数,就是中断当前调用函数,完成计算子任务后,再将计算结果和控制权回交给调用函数。

x86下的C函数调用惯例_第1张图片

函数调用流程图

函数调用涉及到调用函数和被调用函数,我们在下文中将使用“caller”指代调用函数,使用“callee”指代被调用函数。以下代码只是演示demo,无法编译运行。

/*线程(任务)函数:taskEntry*/  
thread_start_routine()
{
	/*…*/
	caller();
	/*…*/
}

/*调用函数*/
caller()
{
	int x=1, y=2;
	int z = x+y;
	/* callee_before(); */ 
	callee(z);
	/* callee_after(); */
}

/*被调用子函数:sub routine*/
callee(int i)
{
	int x=1, y=2;
	/*...*/
	printf(“%d\n”, i);
	/* add(x, y); */ /* further nest */
}


3.2 函数活动记录——栈帧

(1)栈

在经典数据结构中,栈(std::stack)是一种特殊的容器,用户可以将数据压入栈中(入栈stack::push),也可以将已经压入栈中的数据弹出(出栈stack::pop)。但是入栈和出栈操作遵循一条规则:先入栈的数据后出栈,即后进先出(Last In First Out, LIFO)。

在计算机系统中,栈是一种具有以上栈容器属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压(入)栈操作使得栈(stack::size)增大,而出栈操作使得栈减小。汉诺塔和纸牌游戏规则是典型的栈模型。

在经典的操作系统中,栈通常是向下增长的(stack grows down)。在i386中,栈顶stack::top由寄存器esp(Stack Pointer)标识定位。在栈上压入数据将会是esp减小,弹出数据则使esp增大。x86 提供了 push/pop 指令用于压栈、出栈操作,它们使用esp对栈进行寻址。

由于出栈操作需要将两个值写回寄存器中(栈中的数据以及递增的栈指针值),因此它不适合流水线。所以,MIPS没有提供对栈(Stack)操作的硬件支持,没有像 x86 那样的 push/pop 指令。编译器通过函数开头减小sp开辟空间,增大sp回收空间。

<1> 入栈——push src

源操作数允许为16位或32位通用寄存器、存储器和立即数以及16位段寄存器。将src压入堆栈后,esp将减小sizeof(src)。

<2> 出栈——pop dst

目的操作数允许为16或32位通用寄存器、存储器和16位段寄存器。将esp匹配dst数据类型的字节弹出到dst,esp将增大sizeof(dst)。

<3> 栈的初始化

系统或进程初始化后,esp被初始化为某个内存地址——“lea esp mem”。线程/任务都有独立的栈,在进程空间中开辟空间,一般由线程控制块的StackBase(high top)/StackEnd(low bottom)界定。线程上下文维护了各自的栈指针,它表明了当前栈活动(使用)状态。

可参考《程序员的自我修养——链接、装载和库》的6.4.2<堆和栈>和6.4.5<进程栈初始化>。

<4> 栈空间的管理

直接减小esp的值,等效于在栈上开辟空间;直接增大esp的值,等效于在站上回收空间,如果需要取得栈上的变量则需要出栈操作。可见,通过调整栈顶位置可以实现对栈空间的管理。

(2)栈帧

栈在程序运行中具有举足轻重的地位。函数调用作为一种“中断”,需要栈保存需要维护的现场信息、提供函数调用的交互以及临时开销,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。栈帧一般包括如下几方面的内容:

<1>函数调用是一种粗鲁的中断,故首先需要保存函数的返回地址。callee返回后,caller的继续执行点。

<2>函数调用是caller与callee的交互,故需要保存函数传递的参数。由caller压入栈中,传递给callee访问。

<3>函数体可能会破坏既有寄存器,故需要保存寄存器上下文。保存寄存器,以便临时借用它们周转,完毕恢复。

<4>函数体自身逻辑周转需要借助临时变量,这个自动变量控件也需要在栈中开辟。临时变量包括函数的非静态局部变量以及编译器自动生成的其他临时变量。

在i386中,函数栈帧的基地址保存在ebp(Base Pointer)寄存器中。一个函数的活动记录用ebp和esp这两个寄存器划定了范围,注意这里的“划定”并非严格的起始边界。esp寄存器始终指向当前栈顶,同时也就指向了当前函数活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又称为帧指针(Frame Pointer)。

ebp本质上是函数入口的esp。考虑caller调用callee的情况,caller准备就绪,执行call跳转到callee,此时caller的esp将作为callee的栈帧起点,也就是callee的ebp。callee从ebp开始开辟自己的临时空间,保存现场,然后执行代码逻辑。callee在使用ebp寄存器标识新栈帧时,必须保存旧的栈帧指针,即caller的ebp。

前面提到,线程中调用的函数都运行于线程上下文中,所有嵌套函数都在线程栈中做道场,即运行于线程上下文。调用函数和被调用函数的活动在时间和空间上都具有延续性,它们的活动记录(栈帧)也是连续的。下图演示了thread_start_routine()->caller()->callee()三层函数调用路径中的活动记录布局,其中子函数的栈帧紧随父函数,但它们都在线程栈内活动。

 x86下的C函数调用惯例_第2张图片

线程上下文中的嵌套函数栈框架布局图

从上图看以看出,函数在执行期间,栈指针esp会不断变化,栈指针ebp是固定的。固定不变的ebp可以用来定位函数活动记录中的各个数据。参数位于ebp的偏移量处:ebp+4为返回地址;ebp+8、ebp+12为参数;本地局部变量位于ebp的偏移量处:ebp-4、ebp-8为局部变量。

MIPS也有对应的fp($8)寄存器,如果编译时选择使用帧指针,则对帧内部的访问都可以通过fp来实现。有时本地变量增长过大以致一些栈帧的内容距离sp太远,无法通过单一的MIPS load/store指令(只能访问sp上下偏移32KB的内容),因此这个时候借助fp指针就比较方便。gcc编译器有个-fomit-frame-pointer可以取消帧指针,即不使用帧指针,而是通过esp直接计算帧上变量的位置,这么做的好处是可以多出一个ebp寄存器,但坏处是帧上寻址速度变慢。另外,帧指针存放在栈的某个已知位置且具有嵌套迭代性,使得调试器能够跟踪定位函数的调用轨迹(Invoking Path),这对于栈回溯(Stack Backtrace)非常有利。

3.3 调用惯例

caller和callee交互涉及到参数传递方式和顺序、返回值传递、堆栈平衡。

(1)调用约定

<1> 参数的传递方式

尽管大部分时候,x86中都是使用栈传递参数,但有些情况下编译器优化使用寄存器传递参数,以提高性能。如果caller使用寄存器传递参数,而callee仍然以为参数放在栈上,那么显然callee无法获取正确的参数。

MIPS CPU预留了4个32位参数槽a0~a3,在旧的调用约定中必须为这四个参数槽预留空间。

<2> 参数的传递顺序

caller将参数压入栈中,callee再从栈中将参数取出。对于有多个参数的函数调用,参数是按照“从左至右”还是“从右至左”的顺序入栈,双方必须有一个明确的约定。

<3> 返回值的传递

除了参数的传递之外,caller与callee的交互还有一个重要的渠道就是返回值。caller调用callee就是为了让callee完成一定的计算子任务,因此caller调用callee后,期望能看到结果。callee的返回值即callee的运算结果,一般存储到约定的寄存器中。

在x86中,eax寄存器往往作为传递返回值的通道。eax寄存器宽度为4字节,对于返回值大于4个字节(5~8字节)的情况,使用edx和eax联合返回,eax为低4字节,edx为高处的1~4个字节。对于返回对象超过8字节的返回类型,将使用caller栈空间内存区域作为中转,在callee返回前将返回值的对象(callee栈上的临时变量,即将销毁)拷贝到caller栈空间中。

在MIPS中,往往通过v0和v1存储返回值,类似x86中的eax和edx。当返回大于8字节的数据时,需要caller在栈里分配一个匿名的结构变量,设置一个指向该参数的指针,该指针藏在所有显式的参数之后,callee将结果存储到这个模板中。

关于函数返回值传递方式,可参考《程序员的自我修养——编译、链接与库》10.2.3<函数返回值的传递>。

<4> 栈平衡

callee返回时,一般会回收自己的栈空间,但是传递的参数作为交互部分,该由caller还是callee负责修正栈指针到调用前的状态(回收参数空间,以维持栈平衡),双方也必须有一个约定。

(2)调用惯例

毫无疑问,caller和callee对于如何调用须有一个明确的约定,只有双方都遵守同样的约定,callee才能正确被调用。这样的约定称为“调用约定”或“调用惯例”(Calling Convention)。

调用惯例属于二进制程序接口标准(ABI,Application Binary Interface)范畴,需要参考特定体系架构CPU的芯片文档说明。除了跟硬件设施(寄存器约定、堆栈)支持背景外,调用惯例还跟工具链中的编译器有很大的关系,因为编译器要将C语言翻译成汇编语言。对于不同的编译器,分配局部变量和保存寄存器的策略不同,会导致栈布局不同。

为了在链接的时候对调用惯例进行区分,调用管理要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略(Name-mangling Policy)。这属于链接过程中的符号管理范畴,可参考《程序员的自我修养——链接、装载与库》中3.5.3<符号修饰语函数签名>。

cdecl 和 stdcall

在C语言中,存在多种调用惯例,默认调用惯例为cdecl。在默认调用惯例cdecl中,参数从右至左的顺序入栈,这样左边第一个参数先出栈;参数出栈(回收栈空间)的平衡操作由caller完成。cdecl的名字修饰策略为“下划线+函数名”,例如callee在编译后其函数名被修饰为“_callee”。

在MS Visual Studio的“项目属性->配置属性->C/C++->高级->调用约定”默认为“__cdecl(/Gd)”,该属性可由函数重写。

在Windows的windef.h中定义了WindowsAPI的默认调用约定为stdcall

#define WINAPI     __stdcall

/*线程入口函数原型*/

typedef DWORD (WINAPI*PTHREAD_START_ROUTINE)(

   LPVOID lpThreadParameter

   );

__stdcall是WindowsAPI的标准调用方法。stdcall和cdecl的参数入栈顺序一样,都是从右到左,但是__stdcall 的参数栈平衡维护方为callee自身。stdcall的名字修饰策略为“下划线+函数名+@+参数的字节数”,如函数callee(inti)将被修饰为“_callee@4”。

除了cdecl和stdcall调用惯例外,常见的还有fastcall和pascal调用惯例,参考 MSDN 专题Argument Passing and Naming Conventions。

4 C函数调用流程

由于编译器将C语言翻译成汇编语言,无法通过简单的映射完成,而是经历了一次从C语言到机器语言的大跃迁。首先,从代码规模上来说,汇编代码比C代码大很多,一个简单的C函数可能需要扩展为数十条指令;其次,汇编语言是最接近硬件的底层语言,它需要充分考虑各种指令细节和控制逻辑。因此,在阅读汇编代码时,需要直接站在硬件的角度以二进制思维来演绎复原C语义。

现代C函数调用都是依赖于栈这一容器,不同体系架构CPU的IA及其汇编语言不同,但是C函数调用惯例大同小异。鉴于x86的渗透性,以下调用流程主要基于x86/MSVC平台下的C语言汇编展开阐述,其他平台的大同小异,可类比分析。在分析时,重点注意寄存器(ebp/esp/eax)约定、局部变量分配和栈平衡策略,厘清caller和callee各自的维护任务和出入口操作

x86汇编中是通过call指令实现对子函数的调用——“call subroutine”。callee将中断当前caller,因此必须保护好caller被中断现场,以便后期能恢复现场。

4.1 保护caller现场

caller运行期间可能需要临时借用寄存器辅助周转,这些寄存器有可能也被callee使用。为了保证借用前后完好无损,需要借助栈来保存寄存器。例如caller在调用callee之前往往会执行“pusheax”保存当前eax寄存器,因为callee将使用eax存储返回值。caller可能需要将callee调用的结果和上一次调用callee_before的结果做进一步计算。

4.2 caller向callee传递参数

假如实参通过临时变量计算放在eax中,则在call之前可能有以下指令传递实参:

mov esp, eax;

4.3 call

x86的call指令将函数的返回地址压栈,这个返回地址就是EIP寄存器的值。大概扩展为“push eip; jmpcallee”。

保存call callee指令的下一句指令地址,是为了callee返回时继续执行caller的后续流程。

MIPS中的ra寄存器用于保存返回地址(return address)。

说明:尽管参数和返回地址压栈都是caller负责的,但是参数和返回地址属于callee的活动记录范畴,因为callee需要访问参数,并且在退出时正确返回中断现场。

4.4 enter callee

(1)保存旧栈帧,标识新栈帧

push        ebp

mov         ebp,esp

第一句保存caller的栈帧基址(old_ebp),esp将指向old_ebp。第二句将栈顶指针esp赋值给ebp,ebp作为callee栈帧基址。

由于每次函数调用都用这两句迭代更新栈帧,故Intel提供了简化的合成指令enter来替代以上两句指令。

(2)开辟栈空间

sub         esp, 0C0h;

对于最简单的Debug版C程序,VC往往也会开辟一个192字节的栈空间,用于存储局部变量、某些临时数据以及调试信息。

(3)保存寄存器

callee运行期间可能需要借用寄存器辅助周转临时腾挪,这些寄存器极有可能也正在被caller使用。为了保证借用前后完好无损,需要借助栈来保存寄存器。

[push        reg1]

...

[push        regn]

注意,上一步为局部变量开辟空间后,esp已经调整,这里的push将使esp继续向低地址增长。

(4)访问参数

每次调用函数时,都会重新创建该函数所有的形参,此时所传递的参数将会初始化对应的形参。

mov 0x8(%ebp), %eax

mov %eax, 0x4(%esp)

第一句将实参数一(ebp+8)复制到eax,第二句再将eax赋值给形参局部变量(esp+4)。后面的操作都是针对形参(esp+4),而并没有访问caller所传递的实参本身(ebp+8)。

(5)局部变量

编译器在C函数转换为汇编代码时,已经为临时变量在ebp的负偏移量处分配了内存。

假设在callee()中调用int add(int x, int y);执行加法计算,以下为callee创建临时变量“int x=1,y=2;”的汇编代码:

mov         dword ptr [ebp-4],1
mov         dword ptr [ebp-8],2

以下为使用寄存器ecx和eax传递参数的汇编代码:

mov         eax,dword ptr [ebp-8]
push        eax
mov         ecx,dword ptr [ebp-4]
push        ecx
call        _add

注意,编译器对于普通函数有优化措施,会使用寄存器来传递参数。这里已经将形参x/y拷贝到了寄存器ecx/eax,而未使用栈传递参数,因此在add()中直接访问寄存器取参数,而无需使用ebp正偏移索引。

在开辟栈空间时,编译器已经连估带猜分配了一块内存(ebp~ebp-0xC0)供创建局部变量,这个在将C翻译成汇编时已经尘埃落定。正常情况下,运行时不会出现超容。

(6)运行函数体

经历了以上准备工作之后,才真正进入到函数实体,即我们编写的C函数实际代码内容。

(7)返回值

callee运行完毕,使命结束,一般将返回值存储到约定的寄存器中,以便caller获取计算结果。可参考调用约定中关于返回值传递的相关说明。

4.5 leave callee

callee函数实体执行完毕,需要执行进入逆过程。

(1)恢复寄存器保存寄存器的逆步骤

依次逆序pop之前保存的各个寄存器,恢复到调用前的状态。

[pop        regn]

...

[pop        reg1]

(2)回收栈空间开辟栈空间的逆步骤

add         esp,0C0h

释放当初使用sub开辟的栈空间,主要是局部变量数据。说到这里,就顺便梳理一下局部变量的生存周期问题。

当callee返回到caller时,辅助运算的局部变量的使命结束,ebp已经恢复为caller当初的ebp,callee栈帧不复存在。此时,曾经callee栈上的临时变量处于悬浮状态,即使没有清零销毁,也无法通过ebp访问了。前面提到当callee需要返回大数据对象时,在caller的栈中分配一块内存空间,并将这块空间的地址作为隐藏参数传递给callee,这样做是为了让callee将栈上的临时对象拷贝到caller的内存空间上。当然,我们也可以使用堆来创建管理多线程和嵌套函数之间的交互对象,在线程或嵌套函数间传递malloc/new出来的指针,这样可以避免拷贝,但要在某个路径上确定不再引用时调用free/delete销毁释放这块堆上的内存。

(3)恢复栈帧保存旧栈帧的逆步骤

mov        esp,ebp

pop         ebp

恢复到caller调用callee(callcallee)之前的状态,实际上就是恢复caller的栈帧,此时callee栈帧已经是过去式。

由于每次函数调用都用这两句恢复栈帧,故Intel提供了简化的合成指令leave来替代以上两句指令。

(4)跳转到返回地址(call的逆步骤)

ret

从栈中取得返回地址,并跳转到该位置,将控制权回交给caller,大概扩展为“pop eip; jmp eip”。

ret指令后面可以追加一个操作数,表示在ret后把栈指针esp加上操作数,例如“ret 8”表示在ret之后执行sp=sp+8。

4.6 恢复caller现场

(1)回收参数空间(参数压栈的逆步骤)

如果caller在栈上向callee传递了2个int参数,那么会调用以下指令回收8字节的参数空间:

add esp, 8;

当然,如上所述,此步也可以合并到ret中:ret 8。

(2)继续前行

callee调用结束,控制权回交给caller后,caller可到约定的寄存器中读取利用callee的返回值(计算结果),继续执行。可参考调用约定中关于返回值传递的相关说明。


参考:

《程序员的自我修养——链接、装载与库》

《C++函数调用栈空间结构探究&《程序员的自我修养》纠错》


Guide: Function Calling Conventions

C Function Call Conventions and the Stack

《x86寄存器说明》x86指令编码格式解析

《X86-64下的栈帧布局简介》X86-64寄存器和栈帧

x86-64 下函数调用及栈帧原理


《汇编调用c函数为什么要设置栈》

《函数调用堆栈变化分析》

《windows下的函数调用栈》


《C/C++函数调用约定与函数名称修饰规则探讨》

《C++异常机制的实现方式和开销分析》


《函数调用栈,通过汇编语言分析》

《深入剖析GCC函数调用堆栈变化过程》

从X86指令RET和CALL的意义看进程的自由切换


《函数调用过程探究》

《x86平台上的函数调用及返回机制》

《从汇编角度看英特尔x86函数调用规范》

《C函数调用机制(x86的linux环境下)》

《linux下C函数调用机制(X86平台)》


《栈及函数调用惯例》

《x86、arm、mips架构函数调用实例分析》


《x86体系结构下Linux-2.6.26启动流程》


《Linux学习笔记 - 程序的执行(一)》

《Linux学习笔记 - 程序的执行(完结)》


你可能感兴趣的:(嵌入式开发)