汇编语言学习笔记(九)-函数

编程语言为了不至于使代码过于庞大,便于管理,都提供了函数的功能,提供输入和输出接口,完成独立的功能,细分代码。汇编语言同样也有函数的概念, 但是这里函数有其本身的复杂性,像高级语言,直接提供声明格式,输入输出便可,而汇编需要透彻的理解堆栈,如果需要与c函数进行交互,还需理解c的堆栈处 理过程,所以此篇最复杂的不是定义一个函数,而是理解堆栈的处理过程。

首先从基本的函数语法形式说起,先来说MASM语法,因为他的语法比较复杂,相对AT&T就很简单了:

	name proc [uses reg1 reg2 ...] [,参数:类型]
		...
	name endp

这里函数定义比较简单,唯一有点迷惑的是uses伪指令,因为函数需要做很多操作,这样不免修改某些寄存器,uses伪指令指定该函数 要修改的寄存器,由编译器对其做保护,当然,此项是可选的,不规定一定要把修改的寄存器保护起来。编译器保护寄存器的方式就是在函数开始把reg通过 push指令保存到堆栈,退出时再通过pop反向的取出寄存器。

	test_uses proc uses eax ebx
		; 编译器再此添加
		; push eax
		; push ebx
		...
		; 编译器再此添加
		; pop ebx
		; pop eax
	test_uses endp

函数声明倒不复杂,先来看参数的传递,传递参数最简单的情况就是通过寄存器,调用点把数据放至寄存器中,到函数中便可直接使用,但寄存 器毕竟数量有限,更为通用的方式就是通过堆栈传输,再调用点先把参数依次传入堆栈(push 指令),函数中便可从堆栈中读取参数,对c函数来讲,参数的入栈顺序是倒序的,也就是最后一个参数先入栈,依次向前。

调用点调用函数需要指 令:call address,call指令先把接下来要执行的指令(EIP)放入堆栈,紧接着修改EIP的值为address,这样就到address的位置开始执 行,即函数入口。因为函数结束时需要跳转到刚才存入堆栈的EIP地址执行,所以必须有一个指令与call对应,修改EIP的值,那就是ret指令,ret 指令从堆栈中取出返回地址,然后修改EIP为该地址,便可回到调用函数的地址开始往下执行。

所以,再调用点现在可以写成:

	push param
	call function

另外,MASM提供了一个方便的指令invoke,语法规则为:

	invoke procName, Arg1, Arg2 ...

可把函数调用过程简化为 invoke function param,invoke可以让调用汇编像调用c函数一样简单,不过如果需要invoke调用则需先声明,当然如果函数在使用之前定义,则可不进行声明, 这与C语言的语法颇为相似,MASM提供了另一个伪指令proto来声明函数,无需函数体,仅仅规定函数格式,proto语法与proc大致相同,修改 proc到proto也很简单,只需要proc改为proto,去掉uses伪指令,去掉函数体。

函数的返回值可以以任意方式返回,不过c函数一般是通过eax寄存器返回,如果调用c函数,可直接使用eax得到返回值。

到这里似乎有了很多的堆栈操作,比如保护寄存器,返回地址,参数,还有局部变量,这里问题是不会发生堆栈的错乱么?这还要通过堆栈的变化来看。

首先堆栈是从大到小增长的,也就是后push进去的元素在小地址。需要传递参数时,先用push指令把参数入栈,然后用call进行跳转,call指令再把 返回地址入栈,接下来的空间作为局部变量的地址,也就是说以返回地址为分割,小地址方向为局部变量,大地址方向存储参数。但是如果函数中再次调用其他函数 会修改堆栈,这样影响了局部变量的空间,所以再进入函数时,需要把esp减去一定字节数以腾出空间作为局部变量,但是esp本身是随着push指令而变化 的,不能使用esp来寻址参数或者局部变量,一般做法是在函数开始处保存ebp的值,再把esp赋值给ebp作为固定的基址寻址参数,esp减去一定空间 作为局部变量。再函数退出时只需把ebp赋值给esp即可清理一切局部堆栈信息。所以基本的函数框架如下:

	name proc
		push ebp
		mov ebp, esp
		sub esp, 16
		...
		mov esp, ebp
		pop ebp
		ret
	name endp

如此,在读取第一个参数时使用语法 [ebp + 8], 第二个参数便是 [ebp + 12],依次类推,第一个局部变量便是 [ebp - 4], 第二个是 [ebp - 8] 等等。当然这只是规律但不是规定,如果使用uses伪指令再函数开始处增加push指令而改变堆栈,如此的规律便不再适用。

为了方便理解,这里用一图示来展现函数堆栈的分布,当然这一图示是静态的,难以表示整个函数调用过程中动态增衰的过程,还需更多的思考:

由图可以清楚的看到堆栈状态,这样我们甚至可以直接修改返回地址为另外一个函数,来改变整个调用的流程!甚至这个过程可以用c语言指针操作来完成,有兴趣的可以一试。

MASM为了方便参数的读取(不必计算偏移)而制定了参数列表,为了方便局部变量的读取而指令了local指令,语法如下:

	name proc [uses reg1 reg2 ...]
		local var:byte
		local array[10]:dword
		...
	name endp

如此,在程序中便可直接使用var来直接引用byte局部变量。

另外由于函数的开始和结尾都是定式,intel又提供了两个指令来简化操作,enter指令,有两个操作数,第一个位局部变量大小,第二个暂时填写0即可,leave指令无操作数,上面函数框架也可写作:

	name proc
		enter 16, 0
		; 等价于以下三条指令
		; push ebp
		; mov ebp, esp
		; sub esp, 16
		...
	
		leave
		; 等价于一下两条指令
		; mov esp, ebp
		; pop ebp
		ret
	name endp

以上都是MASM语法形式,当然,大多数的原理性东西也对AT&T语法适用,MASM提供了很多宏去方便程序员适用,从函数调用的invoke伪指令,到参数列表,到局部变量,都做了全副武装。这在AT&T中就没那么幸运了。

对AT&T汇编来说,函数仅仅是一个符号,跟其他label并没什么不同。定义的语法规则如下:

	.type func_name, @function
	func_name:
		...
		ret

这里仅仅是对linux适用,如果在mingw下写,发现这样甚至连编译都过不了,其实只要有一个符号即可,.type完全可以不写, 如果要导出函数,可用.globl func_name。另外,AT&T的函数需要自己计算参数的偏移,没有参数列表没有局部声明,也没有invoke调用。这么简陋的设施或许也没 什么可以说明的。

如果仔细研究堆栈变化,在调用函数的时候先要把参数入栈,然后把返回地址入栈,之后跳到函数入口点执行。这样就产生一个问题,谁应该把压入的参数弹出(即清理堆栈)。

对于__cdecl函数(c函数)是调用者进行清理,gcc的处理方式是在局部变量的最后多开辟一点空间,然后每次调用函数之前,先把参数赋值给esp的正偏移,这样等函数返回之后就没有必要清理堆栈了。

对于__stdcall函数(Win32 API),清理堆栈的工作需要交给被调用者,也就是说,所有__stdcall的函数在返回之前使用 ret (n-bype) 清理了参数,如果返回之后再次清理堆栈就会造成堆栈的混乱。

这里由函数本身清理堆栈时,还用到一个ret指令的变形,可以增加一个操作数来指定堆栈清理的字节数。

	; MASM
	ret bytes;
	; AT&T
	retn bytes;

最后,举一个例子简单的说明一下函数的用法:

	.section .data
		out: .asciz "result is %d\n"
	
	.section .text
	# 这里定义一个两数相加的例子
	# 为了说明,先存结果到局部变量
	.globl _Function
	_Function:
		pushl %ebp
		movl %esp, %ebp
		subl $16, %esp
	
		movl 8(%ebp), %ebx
		addl 12(%ebp), %ebx
	
		#存入局部变量位置偏移为 -4
		movl %ebx, -4(%ebp)
		movl -4(%ebp), %eax
	
		movl %ebp, %esp
		popl %ebp
		ret
	
	.globl _main
	_main:
		pushl $5
		pushl $9
		call _Function
	
		#结果位于eax
		#调用printf输出
		pushl %eax
		pushl $out
		call _printf
	
		pushl $0
		call _exit

这里需要注意的是,_Function这个函数符合c编程的规范,也就是说c语言也可直接调用此函数,这种方式再下篇会有提及。对于 MASM,也适用这种写法,不过为了介绍简化过程的伪指令,现把此程序改为由伪指令简化过的形式,注意(enter虽然简化过程,但是intel的指令, 不算伪指令):

	.386
	.model flat, stdcall
	option casemap:none
	
	include    windows.inc
	include    kernel32.inc
	includelib kernel32.lib
	includelib msvcrt.lib
	
	.data
		outstring db "result is %d", 0
	.code
	
	printf proto c s:dword, p:dword
	
	Function proc p1:dword, p2:dword
		local var:dword
		mov ebx, p1
		add ebx, p2
		mov var, ebx
		mov eax, var
		ret 8
	Function endp
	
	start:
		invoke Function, 5, 9
		invoke printf, addr outstring, eax
	    invoke ExitProcess, NULL
	end start

这里需要做几点说明,由于.model设定了stdcall,所以proc指定的都会是stdcall函数,即堆栈清理由函数自己执 行,所以Function最终使用ret 8来返回。另外,由于指定了函数Function的参数,编译器会自动生成框架,所以无需使用enter和leave,对于printf,没有使用头文 件,而是自己进行了生命,当然,这里并没有使用可变长参数,仅仅写了两个参数,proto后加了函数的类型为c类型。另外,连接期间无需连接成 windows类型,可用命令link /subsystem:console fun.obj 来连接成控制台程序。

本篇主要介绍了函数堆栈变化,这个过程虽然并不复杂,但是要是讲解清晰还是非常困难。

Built with Hugo
主题 StackJimmy 设计