爱技术 & 爱分享
爱蛋蛋 & 爱生活

函数

函数是汇编语言中相当重要的一个组成部分。调用函数时,程序的执行路径被改变,切换到函数代码中的第一条指令。处理器从这个位置开始执行指令,直到函数表明它可以把控制返回到主程序中的原始位置。

function flow

创建函数

在汇编语言程序中创建函数需要3 个步骤:

  1. 定义需要的输入值。
    很多函数都需要某种形式的输入数据。程序员必须定义程序如何把这些信息传递给函数。

    基本上,可以使用3 种技术:

  • 使用寄存器
  • 使用全局变量
  • 使用堆栈

    当主程序调用函数时,主程序在调用函数时停止,函数从这个位置开始执行。函数可以访问位于内存中和寄存器中的任何数据。使用寄存器把输入值传递给函数是快速而且方便的。

    使用寄存器在主程序和函数之间传递数据时,记住使用数据的正确数据类型。数据存放在寄存器中时使用的数据类型必须是函数期待的相同类型。

  1. 定义对输入值执行的操作。
    函数被编写为程序中的一般汇编语言代码。在源代码文件中,函数指令必须和主程序的其余指令分离开。
    函数有别于主程序的其余部分的地方是为汇编器定义函数的方式。不同汇编器使用不同的方法定义函数。为了在GNU汇编器中定义函数,必须在程序中把函数名称声明为标签。为汇编器声明函数名称,可以使用.type命令:

    type funcl, @function
    funcl:
    

    type命令通知GNU汇编器,funcl标签定义将在汇编语言程序中使用的函数的开始。funcl标签定义函数的开始。funcl标签后面的第一条指令是函数的开头。

    在函数中,可以使用主程序中那样的代码。可以访问寄存器和内存位置,可以使用专门特性,比如FPU、MMX和SSE。函数的结束由RET指令定义。执行到RET指令时,程序控制返回主程序,返回的位置是紧跟在调用函数的CALL指令后面的指令。

  2. 定义如何生成输出值以及如何把输出值传递给发出调用的程序。
    当函数完成对数据的处理时,很可能希望把结果传递回发出调用的程序区域。函数必须能够把结果传递回主程序,以便主程序能够利用此数据做进一步的处理或者显示。和输入值的技术类似,有多种方式完成传送结果的工作,但是下面两种是最常见的:

  • 把结果存放在一个或者多个寄存器中。
  • 把结果存放在全局变址内存位置中。

函数代码可以被安排在主程序的源代码的后面,也可以把函数代码放在主程序的源代码的前面。当连接源代码目标文件时,连接器查找标签为_start(GCC 为 main)的代码段作为要执行的第一条指令。可以把任意数量的函
数代码放在_start之前,而且不会影响主程序的开始。而且和某些高级语言不同,不必在主程序中调用函数之前定义函数。所有CALL指令都会查找定义函数开始的标签来获得指令指针。

如果被调用的函数修改主程序使用的寄存器,那么在调用函数之前保存寄存器的当前状态,并且在函数返回之后恢复寄存器的状态,这是至关重要的。在调用函数之前,可以使用PUSH指令单独地保存特定寄存器,也可以使用PUSHA 指令同时保存所有寄存器。类似地,可以使用POP指令单独地恢复寄存器的原始状态,也可以使用POPA指令同时恢复所有寄存器的状态。

在函数调用之后恢复寄存器值时要谨慎。如果函数返回的值存放在寄存器中,那么在恢复原始寄存器值之前,必须把它传送到安全位置。

调用约定

输入值与输出值的选择多样性,看起来是好事,但是如果在大型项目中掺杂着多种风格的话会对程序的维护造成灾难性的后果。所以诞生了多种调用约定,比如_stdcall__cdecl__fastcall等。

C++语言标准的函数调用约定为__stdcall(pascal)–Standard Call的缩写(在Visual C++ 系列编译器中使用 PASCAL 宏,WINAPI 宏和 CALLBACK 宏来指定函数的调用方式为__stdcall),C语言默认的函数调用约定为cdecl(C Declaration的缩写)。

在__cdecl中约定了返回值的传递方式,EAX寄存器用于32位结果(比如短整数),EDX:EAX寄存器对用于64位整数值,FPU的ST(O)寄存器用于浮点值。

__cdel约定要求参数存放到堆栈中的顺序和函数的原型中的顺序相反。

执行CALL指令时,它把发出调用的程序的返回地址也存放到堆栈的顶部,这样函数可以知道返回到什么位置。

堆栈指针(ESP) 指向堆栈的顶部,这里加载了返回地址。在堆栈中,函数的所有输入参数都位于返回地址的“下面” 。把值弹出堆栈以获得输入参数会导致一个问题,因为返回地址可能在处理过程中丢失。

替换的做法是,使用间接寻址方法从堆栈获得输入参数,这种技术提供根据寄存器中的变址值访问内存中的位置的方法。因为ESP指针指向堆栈的顶部(这里包含函数的返回地址),函数可以根据ESP寄存器使用间接寻址的方式访问输入参数,不必把值弹出堆栈。

但是,这种技术有个问题。因为在函数处理的某个部分可能包含把数据压入堆栈的操作。如果发生这种情况,就会改变ESP堆栈指针的位置,并且丢失用于访问堆栈中的参数的间接寻址值。

为了避免这个问题,通用的做法是进入函数时把ESP寄存器复制到EBP寄存器。这样确保有一个寄存器永远包含指向调用函数时的堆栈顶部的正确指针。函数执行过程中压入堆栈的任何数据都不会影响EBP寄存器的值。为了避免ESP破坏原始的EBP寄存器值,如果主程序中使用它的话,在复制ESP 寄存器的值之前,EBP寄存器的值也袚存放到堆栈中。

现在EBP寄存器包含堆栈的开始位置(现在是旧的EBP寄存器值)。来自主程序的第一个输人参数位于间接寻址位置8 (%ebp) ,第二个参数位于12 (%ebp) 的位置,等等。可以在函数中使用这些值,而无需担心其他值被压入堆栈或者从堆栈删除。

function stack

使用堆栈引用函数的输入数据的技术创建了一组标准指令,使用__cdecl调用约定下编写的所有函数都使用它们。下面这个代码片断演示在函数代码的开头和结尾使用什么指令:

function:
pushl %ebp
movl %esp, %ebp

~

movl %ebp, %esp
popl %ebp
ret

函数代码开头的前两条指令把EBP的原始值保存到堆栈的顶部,然后把当前ESP堆栈指针(现在指向堆栈中EBP的原始值)复制到EBP寄存器。

函数处理完成之后,函数的最后两条指令获取存储在EBP寄存器中的原始的ESP寄存器值, 并且恢复EBP寄存器的原始值。重新设置ESP寄存器的值确保当执行返回主程序时,函数中存放到堆栈中、但是还没有清除的任何数据都会被丢弃(否则, RET指令就会返回到错误的内存位置) 。

ENTER和LEAVE指令被设计为专门用于建立函数开头(ENTER指令)和结尾(LEAVE指令)。可以使用它们替代手工地创建开头和结尾。

局部变量

当程序控制权在函数代码中时,处理过程很可能需要在某个位置存储数据元素。使用寄存器,只提供数量有限的工作区域。使用全局变量需要额外要求主程序为函数提供专门的数据元素。所以堆栈又成了最优的选择。

EBP 寄存器被设置为指向堆栈的顶部之后,函数中使用的任何附加的数据都可以存放在堆栈中这个指针之后,这不会影响对输入值的访问。在堆栈中定义局部变量之后,可以使用EBP寄存器很容易地引用它们。假设对于4 字节的数据值,可以通过引用-4 (%ebp) 访问第一个局部变量,引用- 8 (%ebp) 访问第二个局部变量。

这种设置还有一个残留的问题。如果函数把任何数据压入堆栈, ESP寄存器仍然指向局部变量被存放之前的位置,并且将覆盖这些变量。

为了解决这个问题,在函数代码的开始添加了另一行,通过从ESP寄存器减去一个值,为局部变量保留一定数量的距栈空间。现在,如果把任何数据压入堆栈,数据会被存放在局部变量下面,这保护了它们,使得仍然可以通过EBP寄存器指针访问它们。

function stack

一般的ESP寄存器仍然可以用于把数据压入堆栈和弹出堆栈,且不会影响局部变量。到达函数的结尾并且ESP寄存器被设置回其原始值时,局部变量会从堆栈丢失,并且无法从发出调用的程序使用ESP或者EBP寄存器直接访问它们(这就是"局部变量”这个术语的由来)。

清空函数调用堆栈

当使用__cdecl函数调用约定时,还有一个细节需要考虑。调用函数之前,发出调用的函数把所有必须的输入值存放到堆栈中。函数返回时,这些值仍然在堆栈中(因为函数访问它们且不把它们弹出堆栈)。如果主程序使用堆栈进行其他操作,它很可能希望从堆栈中删除旧的输入值,以便使堆栈恢复到函数调用之前的状态。

虽然可以使用POP指令完成这个工作,但是也可以把ESP堆栈指针移动回函数调用之前的原始位置。使用ADD指令把压入堆栈的数据元素的长度加上去,就完成了这个工作。

例如,如果把两个4字节的整数值存放到堆栈中,然后调用函数,那么就必须使ESP寄存器加上8 以便把数据清除出堆栈:

pushl %eax
pushl %ebx
call compute
addl $8, %esp

这样确保堆栈恢复到应该的状态, 以便主程序的其余部分使用。

命令行参数

不同的操作系统使用不同的方法把命令行参数传递给程序。在试图解释Linux 中如何把命令行参数传递给程序之前,首先解释Linux如何从命令行执行程序。

从Linux 的Shell提示符运行程序时, Linux 系统为要执行的程序在内存中创建一个区域。分配给程序的内存区域可以位于系统物理内存的任何位置。为了使这一过程简化,每个程序都被分配相同的虚拟内存地址。虚拟内存地址由操作系统映射到物理内存地址。

在Linux 中,分配给程序运行的虚拟内存地址从地址0x80480000开始,到地址0xbfffffff结束。

Linux操作系统按照专门的格式把程序存放在虚拟内存地址中。内存区域中的第一块区域包含汇编程序的所有指令和数据(来自bss和data 段)。指令不仅包含汇编程序的指令代码,而且包含Linux运行程序的连接过程所需的指令信息。

内存区域中的第二块区域是程序堆栈,堆栈从内存区域的底部向下增长。鉴于此,大家可能会认为程序每次启动时,堆栈指针会被
设置为Oxbfffffff, 但是情况并非如此。在加载程序之前, Linux 把一些内容存放到堆栈中,命令行参数就在这里。

从堆栈指针(ESP) 开始,启动程序所使用的命令行参数的数目作为4字节的无符号整数值被指定。在这之后,指向程序名称位置的4字节指针被存放在堆栈中的下一个位置。再之后,指向每个命令行参数的指针存放在堆栈中(同样,32位系统上,每个指针的长度是4 字节)。在命令行参数指针之后,4字节的空值被存放到堆栈中,用于把参数和指向环境变量的指针的开始位置分隔开来。

Command Line Layout

记住,所有命令行参数都被指定为字符串,即使它们看上去像是数字,这一点很重要。

上述笔记内容学习自 AT&T Professional Assembly Language — Chapter XI

赞(2)
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。编程日志 » 函数
分享到: 更多 (0)

游戏 && 后端

传送门传送门