汇编语言入门八:函数调用(二)

时间:2022-01-15 00:55:01

回顾

上回说道,x86汇编中专门提供了两个指令call和ret,用于实现函数调用的效果。实际上函数调用就是程序跳转,只是在跳转之前,CPU会保存当前所在的位置(即返回地址),当函数返回时,又可以从调用的位置恢复。返回地址保存在一个叫做“堆栈”的地方,堆栈中可以保存很多个返回地址,同时借助于堆栈的进出逻辑,还能实现函数嵌套、递归等效果。

同时前面还简单地提到了函数调用过程中的参数和返回值的传递过程。实际上,在汇编语言中,函数调用的参数和返回值均可以通过寄存器来传送,只要函数内外相互配合,就可以精确地进行参数和返回值传递。

没那么简单

到这里,看起来好像函数调用的基本要素都有了,但实际上还是有一些问题的。比如说递归调用这样的场景。通过对递归的研究,你也就能明白前面说到的函数调用机制存在什么样致命的问题。

好了,先说下,这部分内容,很关键。

举个例子,通过递归调用来计算斐波那契数列中的某一项,用高级语言编写已经非常容易:

int fibo(int n) {
if(n == 1 || n == 2) {
return 1;
}
return fibo(n - 1) + fibo(n - 2);
}

我们来进行一波改造,改造成接近汇编的形式:

int fibo(int n) {
if(n == 1) {
return 1;
}
if(n == 2) {
return 1;
}
int x = n - 1;
int y = n - 2;
int a = fibo(x);
int b = fibo(y);
int c = a + b;
return c;
}

拆分成这样之后,就能够比较方便地和汇编对应起来了,再改造一下,把变量名全都换成寄存器名,就能够看得更清楚了(先约定eax寄存器作为函数的第一个参数,通过eax也用来传递返回值):

int fibo(int eax) {

int ebx, ecx;
if(eax == 1) {
return eax;
}
if(eax == 2) {
eax = 1;
return eax;
}

int edx = eax;

eax = edx - 1;
eax = fibo(eax);
ebx = eax;

eax = edx - 2;
eax = fibo(eax);
ecx = eax;

eax = ebx + ecx;
return eax;
}

因为eax会被用作参数和返回值,所以进入函数后就需要将eax保存到别的寄存器,一会需要的时候才能够更方便地使用。

看起来,这里的fibo函数已经比较完美了,这个函数在C语言下是能够正常运行的。接下来把它翻译成汇编:

fibo:
cmp eax, 1
je _get_out
cmp eax, 2
je _get_out

mov edx, eax
sub eax, 1
call fibo
mov ebx, eax

mov eax, edx
sub eax, 2
call fibo
mov ecx, eax

mov eax, ebx
add eax, ecx
ret

_get_out:
mov eax, 1
ret

然而,当你使用这个C语言代码翻译出来的汇编的时候,却发现结果怎么都不对了。

那么,问题出在哪里呢?

问题就出在从C语言翻译到汇编的过程中。

警惕作用域

在C函数中,虽然我们把各个变量名换成寄存器名,把复杂的语句拆分成简单语句,最后就能够和汇编语句等同起来,但是,在将C代码翻译到汇编的过程中,出现了不等价的变换。其中,变量的作用域便是引起不等价的原因之一。这个C代码:

int fibo(int eax) {

int ebx, ecx;
if(eax == 1) {
return eax;
}
if(eax == 2) {
eax = 1;
return eax;
}

int edx = eax;

eax = edx - 1;
eax = fibo(eax);
ebx = eax;

eax = edx - 2;
eax = fibo(eax);
ecx = eax;

eax = ebx + ecx;
return eax;
}

本身是没有任何问题的。但是,翻译后的汇编就有问题了,实际上上述汇编语言等价为这样的C代码:

int ebx, ecx, edx;

void fibo() {

if(eax == 1) {
eax = 1;
return;
}
if(eax == 2) {
eax = 1;
return;
}

edx = eax;

eax = edx - 1;
eax = fibo(eax);
ebx = eax;

eax = edx - 2;
eax = fibo(eax);
ecx = eax;

eax = ebx + ecx;
}

原因很简单,CPU中的寄存器是全局可见的。所以使用寄存器,实际上就是在使用一个像全局变量一样的东西

那么,到这里,通过这个例子,你应该能够发现问题了,现有的做法,无法实现递归或者嵌套的结构。

到底需要什么

实际上,要实现递归,那么就需要函数的状态是局部可见的,只能在当前这一层函数内访问。递归中会出现层层调用自己的情况,每一层之间的状态都应当保证局部性,不能相互影响。

在C语言的环境下,函数内的局部变量,抽象来看,实际上就是函数执行时的局部状态。在汇编环境下,寄存器是全局可见的,不能用于充当局部变量。

那怎么办呢?

堆栈

前面说到,堆栈是用来保存函数调用后的返回地址。其实在这里,函数的返回地址,其实就是当前这一层函数的一个状态,这个状态对应的是这一层函数当前执行到哪儿了。

借鉴call指令保存返回地址的思路,如果,在每一层函数中都将当前比较关键的寄存器保存到堆栈中,然后才去调用下一层函数,并且,下层的函数返回的时候,再将寄存器从堆栈中恢复出来,这样也就能够保证下层的函数不会破坏掉上层函数的状了。

也就是,当下要解决这样一个问题:被调用函数在使用一些寄存器的时候,不能影响到调用者所使用的寄存器值,否则函数之间就很难配合好了,也很容易乱套。

入栈与出栈

实际上,CPU的设计者们已经考虑过这个问题了,所以还专门提供了对应的指令来干这个事。入栈与出栈分别是两个指令:

push eax            ; 将eax的值保存到堆栈中去
pop ebx ; 将堆栈顶的值取出并存放到ebx中

有了这两个玩意儿,递归调用这个问题就可以解决了。注意了,这里发生了入栈和出栈的情况,那么,进行栈操作的时候对应的栈顶指针也会发生相应的移动,这里也一样。

搞一个不会影响全世界的函数

先来试一试堆栈的使用,我就不废话了,举个例子,一个通过循环来计算1+2+3+4+5+6+7+...+n的函数(这里还是约定eax为第一个参数,同时eax也是返回值,暂不考虑参数不合法的情况),直接上代码:

sum_one_to_n:
mov ebx, 0

_go_on:
cmp eax, 0
je _get_out:
add ebx, eax
sub eax, 1
jmp _go_on

_get_out:
mov eax, ebx
ret

你可以发现,在这个函数中,不可避免地需要使用到eax之外的寄存器。但是有一个很致命的问题,调用方或者更上层的函数如果使用了ebx寄存器,这里又拿来用,最终,这个sum_one_to_n不小心把上层函数的状态给改了,最后结果和前面的递归例子差不多,总之不是什么好结果。

那么,这里就需要在使用ebx之前,先把ebx保存起来,使用完了之后,再把ebx恢复回来,就不会产生上述问题了。好了,接下来就需要调整代码了,只需要加一行push和pop就能完事儿了。像这样:

sum_one_to_n:

push ebx

mov ebx, 0

_go_on:
cmp eax, 0
je _get_out:
add ebx, eax
sub eax, 1
jmp _go_on

_get_out:
mov eax, ebx
pop ebx
ret

在函数的第一行和倒数第二行分别加入了push ebx和pop ebx指令。

通过push ebx,将当前的ebx寄存器保存起来。

通过pop ebx,堆栈中保存的ebx寄存器恢复回来。

当然了,进行push和pop的时候也得稍加小心,破坏了call指令保存到堆栈中的返回地址,也会坏事的。不过好在,函数内的入栈和出栈操作是保持一致的,不会影响到call指令保存的返回地址,也就不会影响到ret指令的正常工作。

再来递归

那么,我们就已经解决了函数内保存局部状态的问题了,其中的套路之一便是,让函数在使用某个寄存器之前,先把旧的值保存起来,等用完了之后再恢复回去,那么这个函数执行完毕后,所有的寄存器都是干干净净的,不会被函数玷污。

有了push和pop的解决方案,那么前面那个递归的问题也可以解决了。

先来分析下:

fibo:
cmp eax, 1
je _get_out
cmp eax, 2
je _get_out

mov edx, eax
sub eax, 1
call fibo
mov ebx, eax

mov eax, edx
sub eax, 2
call fibo
mov ecx, eax

mov eax, ebx
add eax, ecx
ret

_get_out:
mov eax, 1
ret

这段代码中使用到了除eax之外的寄存器有ebx、ecx、edx三个。为了保证这三个寄存器不会在不同的递归层级串场,我们需要在函数内使用它们之前将其保存起来,等到不用了之后再还原回去(注意入栈和出栈的顺序是需要反过来的),像这样:。

fibo:
global main

fibo:
cmp eax, 1
je _get_out
cmp eax, 2
je _get_out

push ebx
push ecx
push edx

mov edx, eax
sub eax, 1
call fibo
mov ebx, eax

mov eax, edx
sub eax, 2
call fibo
mov ecx, eax

mov eax, ebx
add eax, ecx

pop edx
pop ecx
pop ebx

ret

_get_out:
mov eax, 1
ret

main:
mov eax, 7
call fibo
ret

编译运行一看,第7项的值为13,诶,这下结果可靠了。我们得到了一个汇编语言实现的、通过递归调用来计算斐波那契数列某一项值的函数。

写在后面

前面扯了这么多,我们说到了这样一些东西:

  • 函数调用相关指令
  • 通过寄存器传递参数和返回值
  • 函数调用后的返回地址会保存到堆栈中
  • 函数的局部状态也可以保存到堆栈中

C语言中的函数

在C语言中,x86的32位环境的一般情况下,函数的参数并不是通过寄存器来传递的,返回值也得视情况而定。这取决于编译器怎么做。

实际上,一些基本数据类型,以及指针类型的返回值,一般是通过寄存器eax来传递的,也就是和前面写的汇编一个套路。而参数就不是了,C中的参数一般是通过堆栈来传递的,而非寄存器(当然也可以用寄存器,不过需要加一些特殊的说明)。这里准备了一个例子,供大家体会一下C语言中通过堆栈传递参数的感觉:

(在32位环境下编译)

#include <stdio.h>

int sum(int n, int a, ...) {
int s = 0;
int *p = &a;
for(int i = 0; i < n; i ++) {
s += p[i];
}
return s;
}

int main() {

printf("%d\n", sum(5, 1, 2, 3, 4, 5));
return 0;
}

编译运行:

$ gcc -std=c99 -m32 demo.c -o demo
$ ./demo
15

函数的参数是逐个放到堆栈中的,通过第一个参数的地址,可以挨着往后找到后面所有的参数。你还可以尝试把参数附近的内存都瞧一遍,还能找到混杂在堆栈中的返回地址。

若读者想要对C的函数机制一探究竟,可以尝试编写一些简单的程序,进行反汇编,研究整个程序在汇编这个层面,到底在做些什么。

好了,汇编语言的函数相关部分就可以告一段落了。这部分涉及到一个非常重要的东西:堆栈。这个需要读者下来多了解一些相关的资料,尝试反汇编一些有函数调用的C程序,结合相关的资料不断动手搞事情,去实实在在地体会一下堆栈。