【Linux x86汇编踩坑】函数调用过程 函数栈浅析

时间:2022-08-12 03:30:43

【Linux x86汇编踩坑】函数调用过程 函数栈浅析

前言

汇编也和其他高级语言一样,都有函数的概念,倒不如说是某些高级语言的函数调用的底层实现,总的来说,使用函数可以把功能细分,还可以划分模块,最重要的是减少重复代码,使程序更加容易维护。汇编的函数跟高级的语言的函数有些不一样,普通高级语言只需要定义函数,函数声明,调用函数就行了,有些语言还可以直接省略了函数声明的步骤,都由编译器或解释器来完成了。但是汇编就不一样,它需要自己来做被编译器或解释器省略掉的事情。

编写一个计算幂的函数

伪代码表示:

func power(param1,param2){return param1^param2}

用汇编写大概是这样的:(这里先上代码具体步骤后面再讲)

.section .data
.section .text
.globl _start
_start:
  #计算2的三次方的值
  #压入参数列表
  pushl $3
  pushl $2
  call power
  #栈指针归位
  addl $8,%esp
  movl %eax,%ebx
  movl $1,%eax
  int $0x80

#power 函数
#用于计算幂
.type power, @function
power:
  pushl %ebp
  movl %esp, %ebp
  #保存参数
  movl 8(%ebp),%eax
  movl 12(%ebp),%ecx
  #开始计算
  start_loop:
    cmpl $1,%ecx
    je loop_exit
    addl %eax,%eax
    decl %ecx
    jmp start_loop

  loop_exit:
    popl %ebp
    ret

这是一个计算2的三次方的函数
汇编

as power.s -o power.o

连接

ld power.o -o power

运行

./power
echo $? //8

函数的调用过程

可以看到成功打印出了8,但是函数调用究竟是怎样的过程呢?
首先函数调用需要用到一个栈,这个栈也可以叫做函数栈,栈是一个先进后出的数据结构整个栈的地址是由栈顶向栈底地址逐渐减小的,一个栈形如:

【Linux x86汇编踩坑】函数调用过程 函数栈浅析

一个函数调用时需要用到的栈称为函数栈,函数栈并没有什么两样,在栈中,存放函数需要用到的参数列表,局部变量等,同时还要存放函数的返回地址

在主程序中,调用函数时,要做两件事情,第一是把eip(指令指针)指向函数的首地址,第二是保存下一条指令,把它作为函数的返回地址入栈,幸运的是这两步都不需要我们去做。结合上面的计算幂的例子,该函数需要用到两个参数,一个是底数,第二个是指数,栈是先进后出,那么第二个参数就应该先入栈随后才是第一个,于是:

pushl $3 #指数为3
pushl $2 #底数为2

随后调用函数:

call power

这时候的栈形如:

【Linux x86汇编踩坑】函数调用过程 函数栈浅析

这时,返回地址已入栈,在栈上,还有一个叫esp的指针始终指向栈顶,记住这点是很重要的,接下来我们要做的事是让ebp入栈,ebp是一个基址指针寄存器,让它入栈的原因是,它可以加上偏移量来获取栈内的所有需要用到的数据,为什么不用esp?esp也同样可以做到,但是esp有更重要的事,比如始终指向栈顶,那为什么不用其他的指针寄存器,确实,其他的指针寄存器也能达到同样的效果,但是ebp在x86架构中,会快得多。

push %ebp //ebp入栈

我们把esp赋值给ebp,这样ebp就能加上偏移量来获取数据了,值得注意的是,这里是赋值的指针,而不是具体的值

movl %esp, %ebp

这样是赋值的具体的值:

movl (%esp),%ebp

【Linux x86汇编踩坑】函数调用过程 函数栈浅析

现在我们能用ebp加上偏移量来获取栈内的数据了,首先把两个参数保存至寄存器

  movl 8(%ebp),%eax
  movl 12(%ebp),%ecx

这里的类型是long,所以每个栈帧相距4个地址

接下来的话,我们需要计算2的三次方,按照高级语言来说,我们可以嵌套一个循环

//假设a为底数,e为指数
for(;e == 1;e--){
  a += a;
}

每次循环e都会自减,直到e为1为止。循环体是a的自加,a的x次方就相当于a加上x -1个a

start_loop:
    cmpl $1,%ecx
    je loop_exit
    addl %eax,%eax
    decl %ecx
    jmp start_loop

  loop_exit:
    popl %ebp
    ret

cpml判断ecx是否为1,ecx里存的是指数,按照上的思路,每次循环,指数都会自减,如果为1,那就跳出循环,然后就是底数自加,过程很简单,我就不再赘述了

下面看到loop_exit,这是跳出循环需要执行的代码,换句话说,这时候函数已经要快执行完毕了,在函数要执行完毕时,eip需要拿到函数的返回地址,也就是下一条指令的地址,这个操作由ret完成,ret会将栈顶的栈帧弹出,然后获取里面的值,将eip指向这个值,因此,我们需要把函数的返回地址进行弹出。当前esp没有进行变动,它指向edp的栈帧的首地址,所以我们可以先弹出edp,弹出之后,函数的返回地址就位于栈顶了。

loop_exit:
    popl %ebp
    ret

然而有的时候esp不在edp栈帧的首地址,比如我们需要局部变量,我们需要把esp向低地址方向移动以获得额外的空间来存放局部变量,这时候我们还需要做的一件事情是将edp赋值给esp,让它移动到edp的栈帧首地址,再进行弹出edp,很好理解,对吧?

movl %edp, %esp
popl %ebp
ret

现在一个函数到此执行完毕,ret过后,esp还在第一个参数的栈帧的首地址,如果不再需要这些参数,我们可以在函数调用完毕时,把esp往高地址方向移动8个地址

addl $8,%esp

至此,一个函数的调用过程就结束了

总结

函数栈保存了一个完整的局部作用域,在函数调用完毕我们应该将局部变量和参数全部释放