零. 课程要点:
- 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;
}
那么其调用示意图是这样的:
在这个过程中需要做些什么呢?
- 首先main中的入口参数t1和t2必须先存到一个地方,并且这个地方add必须能访问到吧。
- 参数保存好后,需要把返回地址保存好,这样add执行完之后就能返回这继续往下执行。
- 保存好参数和返回地址后,就可以调用add函数啦。
- add执行开始时先为自己局部变量(如果有)分配空间
- 然后add取出之前的参数,并执行函数过程
- 执行完之后,需要取之前保存的返回地址,返回继续执行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入栈的地方。
-
movl %esp, %ebp
这条指令把esp的内容赋给ebp,也就是说ebp现在指向跟esp一样的位置,即旧ebp入栈的地方。
-
subl $24, %esp
这条指令将esp的地址,也就是栈顶的位置减24,即开辟出了24个字节的空间。
-
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 %ebp
和movl %esp, %ebp
(参考main的做法),然后取入口参数(在哪呢?%ebp+8和%ebp+12处),进行相加后把结果存在eax中(返回参数总在eax中),然后leave并ret回main。
leave指令可以看成是movl %ebp,%esp
和popl %ebp
,也就是先让栈顶指针回到ebp所指向的地方,然后把保存着ebp在main中的旧值出栈给ebp,也就是ebp回到了main函数时的栈基址位置。
ret指令可以看成是popl %eip
和jmp
,也就是把返回地址出栈至指令指针,然后无条件跳转到相应地址,继续往下执行。
-
movl %eax, -4(%ebp)
这条指令把add返回的结果存放到变量sum里,放在ebp-4的地方。
movl -4(%ebp), %eax
为什么又要把ebp-4的内容放到eax里呢?因为main准备要返回了,所以要把返回结果sum放到eax里。(如果main直接return 0就不用这样了)leave
跟子函数add一样,这条指令相当于movl %ebp,%esp
和popl %ebp
,也就是先让栈顶指针回到ebp所指向的地方,然后把保存着ebp在main中的旧值出栈给ebp,也就是ebp回到了main函数时的栈基址位置。
注意此时esp地址增4,指向的地方存放的是call调用main函数的上层函数的下一条指令的返回地址。
-
ret
跟子函数add一样,ret指令可以看成是popl %eip
和jmp
,也就是把返回地址出栈至指令指针,然后无条件跳转到相应地址,继续往下执行。
总结上面的过程,可以看出函数调用的大致结构是这样的:
- 准备阶段
• 形成帧底:push指令 和 mov指令
• 生成栈帧(如果需要的话):sub指令 或 and指令
• 保存现场(如果有被调用者保存寄存器) :mov指令 - 过程(函数)体
• 分配局部变量空间,并赋值
• 具体处理逻辑,如果遇到函数调用时 - 准备参数:将实参送栈帧入口参数处
- CALL指令:保存返回地址并转被调用函数
• 在EAX中准备返回参数 - 结束阶段
• 退栈:leave指令 或 pop指令
• 取返回地址返回:ret指令