笔记 | 计算机系统基础:04-函数调用时发生了什么?

零. 课程要点:
  • C语言内存模型
  • 函数调用的机器级表示

从这一章开始,我们要运用之前所学的计算机系统基础知识,来理解更复杂的C语言语句或结构。例如,在C语言中,一个函数调用时底层究竟做了哪些操作?知道细节之后我们才能更好的分析函数调用过程中有没有出错,开销大不大,有什么要注意的地方。不过,在此之前,我们需要先简单认识一下C语言的内存分配模型,在之后讲可执行程序的编译、链接、运行时会更深入探讨,这里我们需要对它有个基本的认知。

一. C语言内存模型
可执行文件的存储器映像

我们写好一个程序,代码是存储在硬盘上的,经过编译和链接后,当运行这个程序时,需要将其载入内存,上图的右侧就是内存分配的基本模型(如何映射之后会详细介绍),从高地址到低地址依次可分为内核虚存区(内核使用),用户栈(程序运行时存放局部变量,从高地址向低地址增长),共享库区域用户堆(程序运行时用于分配malloc和new申请的区域),读写数据段(存放全局变量和静态变量),只读代码段(存放程序和常量等),和未使用区域。

其中的就是我们常说的堆栈,二者是不同的,不过这里不详细讨论,我们重点关注,是我们理解函数调用的关键区域。

二. 函数调用的机器级表示

假设有以下函数调用:

int add ( int x, int y ) {
  return x+y;
}

int main ( ) {
  int t1 = 125;
  int t2 = 80;
  int sum = add(t1, t2);  /*调用add函数*/
  return sum;
}

那么其调用示意图是这样的:

函数调用示意图

在这个过程中需要做些什么呢?

  1. 首先main中的入口参数t1和t2必须先存到一个地方,并且这个地方add必须能访问到吧。
  2. 参数保存好后,需要把返回地址保存好,这样add执行完之后就能返回这继续往下执行。
  3. 保存好参数和返回地址后,就可以调用add函数啦。
  4. add执行开始时先为自己局部变量(如果有)分配空间
  5. 然后add取出之前的参数,并执行函数过程
  6. 执行完之后,需要取之前保存的返回地址,返回继续执行main。

以上的过程就是通过来完成的。这样讲比较抽象,我们来看看以上代码对应的汇编指令,并结合栈和栈帧的变化情况来具体说明:

main:
  pushl %ebp
  movl %esp, %ebp
  subl $24, %esp
  movl $125, -12(%ebp)
  movl $80, -8(%ebp)
  movl -8(%ebp), %eax
  movl %eax, 4(%esp)
  movl -12(%ebp), %eax
  movl %eax, (%esp)
  call add
  movl %eax, -4(%ebp)
  movl -4(%ebp), %eax
  leave
  ret

add:
  pushl %ebp
  movl %esp, %ebp
  movl 8(%ebp), %edx
  movl 12(%ebp), %eax
  addl %edx, %eax
  leave
  ret

在分析这段汇编语句之前,要先回忆下我们介绍IA-32体系结构时,提到的ebp和esp寄存器。ebp里存放的是“基址指针”,而esp里存放的是“堆栈指针”。

基址指针(Base Pointer) 指向系统栈最上面一个栈帧的底部,而堆栈指针(Stack Pointer)总是指向栈顶位置。由于ESP指针是会随时发生改变的,一般使用EBP寄存器来对堆栈进行访问。所以每个函数调用在开始时都要保存原来的EBP,然后设置自己的堆栈地址,并在函数结束返回时恢复原来的EBP,使上级函数可以正常使用EBP。(如果不太理解没关系,下面会解释)

  • pushl %ebp
    其实main()也是个函数,只不过是主函数,同样会被调用。因此,main开始执行时,需要把ebp的内容入栈,即保存原系统栈基址。
    请注意:这个时候ebp还是指向原栈基址,esp因为永远指向栈顶,所以它现在指向的是旧ebp入栈的地方。
旧ebp入栈
  • movl %esp, %ebp
    这条指令把esp的内容赋给ebp,也就是说ebp现在指向跟esp一样的位置,即旧ebp入栈的地方。
形成帧底
  • subl $24, %esp
    这条指令将esp的地址,也就是栈顶的位置减24,即开辟出了24个字节的空间。
生成栈帧
  1. movl $125, -12(%ebp)
    movl $80, -8(%ebp)
    这两条指令是main为自己的变量分配空间,第一条指令将第一个参数t1=125,存放到ebp-12的地方,占4个字节。第二条指令将第二个参数t2=80,存放到ebp-8的地方,占4个字节。(ebp-4的地方是用来存放第三个变量sum,调用函数add后的结果会更新该内容)
分配变量
  • movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl -12(%ebp), %eax
    movl %eax, (%esp)
    这四条指令是准备入口参数,为调用add作准备。
    将ebp-8中的内容赋给eax,也就是eax中存放着的是80。然后将eax中的内容赋给esp-4的地方。
    将ebp-12中的内容赋给eax,也就是eax中存放着的是125。然后将eax中的内容赋给esp的地方。
准备入口参数
  • call add
    使用call指令调用add函数,call指令会先把call语句的下一条语句(movl %eax, -4(%ebp))的地址入栈,这样当add返回后,能够继续执行main函数。
add:
  pushl %ebp
  movl %esp, %ebp
  movl 8(%ebp), %edx
  movl 12(%ebp), %eax
  addl %edx, %eax
  leave
  ret

然后开始执行add函数。add函数开始执行时,也要先保存ebp的值,也就是同样要执行pushl %ebpmovl %esp, %ebp(参考main的做法),然后取入口参数(在哪呢?%ebp+8和%ebp+12处),进行相加后把结果存在eax中(返回参数总在eax中),然后leave并ret回main。
leave指令可以看成是movl %ebp,%esppopl %ebp,也就是先让栈顶指针回到ebp所指向的地方,然后把保存着ebp在main中的旧值出栈给ebp,也就是ebp回到了main函数时的栈基址位置。
ret指令可以看成是popl %eipjmp,也就是把返回地址出栈至指令指针,然后无条件跳转到相应地址,继续往下执行。

调用add函数
add函数返回
  • movl %eax, -4(%ebp)
    这条指令把add返回的结果存放到变量sum里,放在ebp-4的地方。
更新sum变量
  • movl -4(%ebp), %eax
    为什么又要把ebp-4的内容放到eax里呢?因为main准备要返回了,所以要把返回结果sum放到eax里。(如果main直接return 0就不用这样了)

  • leave
    跟子函数add一样,这条指令相当于movl %ebp,%esppopl %ebp,也就是先让栈顶指针回到ebp所指向的地方,然后把保存着ebp在main中的旧值出栈给ebp,也就是ebp回到了main函数时的栈基址位置。
    注意此时esp地址增4,指向的地方存放的是call调用main函数的上层函数的下一条指令的返回地址。

movl %ebp,%esp
popl %ebp
  1. ret
    跟子函数add一样,ret指令可以看成是popl %eipjmp,也就是把返回地址出栈至指令指针,然后无条件跳转到相应地址,继续往下执行。

总结上面的过程,可以看出函数调用的大致结构是这样的:

  • 准备阶段
    • 形成帧底:push指令 和 mov指令
    • 生成栈帧(如果需要的话):sub指令 或 and指令
    • 保存现场(如果有被调用者保存寄存器) :mov指令
  • 过程(函数)体
    • 分配局部变量空间,并赋值
    • 具体处理逻辑,如果遇到函数调用时
  • 准备参数:将实参送栈帧入口参数处
  • CALL指令:保存返回地址并转被调用函数
    • 在EAX中准备返回参数
  • 结束阶段
    • 退栈:leave指令 或 pop指令
    • 取返回地址返回:ret指令

你可能感兴趣的:(笔记 | 计算机系统基础:04-函数调用时发生了什么?)