C语言过程(函数)的机器级表示
一个函数调用包括将数据(包括参数和返回值)和控制从代码一部分传到另一部分。还包括对函数内局部变量分配空间,并在退出时释放空间。 其中,转移控制到过程 和 从过程转移出控制——使用指令;局部变量的分配和释放通过 程序栈 来实现。
1.栈帧结构
栈由高地址向低地址方向增长。对单个过程分配的栈称为 栈帧。以两个指针来界定:帧指针%ebp和栈指针%esp.栈指针是不断变化的,所以大多数信息基于帧指针%ebp.(注意在我的电脑上,帧指针是%esp,所以在汇编时总是由 movl 8(%esp) %eax来得到参数)。
从上图的栈帧结构中看到,假设P调用Q。
- P栈帧部分参数为传入Q的参数;
- P的返回地址形成P栈帧的末尾;
- Q在%ebp+4+4i的位置上得到其参数;
- 栈向低地址增长,故栈指针减小——分配空间;栈指针增大——释放空间。
2.转移控制
一个简单的sum调用函数:
int accum = 0; int sum(int x, int y) { int t = x + y; accum += t; return t; } int main() { return sum(1,3); }
该函数使用gcc –o sum sum.c –O1得到可执行文件sum。对该可执行文件使用objdump –d sum 得到反汇编代码如下:
080483b4 <sum>: 80483b4: 8b 44 24 08 mov 0x8(%esp),%eax 80483b8: 03 44 24 04 add 0x4(%esp),%eax 80483bc: 01 05 18 a0 04 08 add %eax,0x804a018 80483c2: c3 ret 080483c3 <main>: 80483c3: 83 ec 08 sub $0x8,%esp 80483c6: c7 44 24 04 03 00 00 movl $0x3,0x4(%esp) 80483cd: 00 80483ce: c7 04 24 01 00 00 00 movl $0x1,(%esp) 80483d5: e8 da ff ff ff call 80483b4 <sum> 80483da: 83 c4 08 add $0x8,%esp 80483dd: c3 ret
【汇编的一个习惯技巧】
对下面的一个代码片段:
call next next: popl %eax
这是一个汇编代码的习惯用法,结果是把popl指令地址放入%eax中——将程序计数器值放入整数寄存器的唯一方法。
这不是一个过程调用,因为跳转的结果与指令顺序相同:
执行call next前,PC设为popl指令的地址(PC始终设置为下条指令地址);
执行call next后,%esp被设置为popl指令地址(因为它是调用者的返回地址);程序同时也跳转到了popl指令,(此时帧指针和栈指针是重合的,栈指针%esp只有在声明新局部变量时会增大,所以%esp还存储的是popl的地址),根据: %eax = M[R[%esp]],所以popl指令地址放入了%eax中
3.寄存器使用惯例
寄存器组是被所有过程共享的资源,但同一时刻只有一个过程激活,所以需要保证被调用者不会影响调用者在寄存器中的值。
将%eax, %edx, %ecx作为调用者保存寄存器,被调用者可以覆盖这些寄存器;
将%ebx, %ebi, %edi作为被调用者保存寄存器,需要在过程开始前pushl,在过程结束后popl到寄存器。
4.过程示例
int swap_add(int *xp, int *yp) { int x = *xp; int y = *yp; *xp = y; *yp = x; return x+y; } int caller() { int arg1 = 534; int arg2 = 1057; int sum = swap_add(&arg1, &arg2); return sum; }
汇编代码为:
swap_add: .LFB0: .cfi_startproc pushl %ebx .cfi_def_cfa_offset 8 .cfi_offset 3, -8 movl 8(%esp), %ebx movl 12(%esp), %ecx movl (%ebx), %edx movl (%ecx), %eax movl %eax, (%ebx) movl %edx, (%ecx) addl %edx, %eax popl %ebx .cfi_def_cfa_offset 4 .cfi_restore 3 ret .cfi_endproc caller: .LFB1: .cfi_startproc subl $24, %esp .cfi_def_cfa_offset 28 movl $534, 16(%esp) movl $1057, 20(%esp) leal 20(%esp), %eax movl %eax, 4(%esp) leal 16(%esp), %eax movl %eax, (%esp) call swap_add addl $24, %esp .cfi_def_cfa_offset 4 ret .cfi_endproc
对caller栈帧,分配如下:
共32个字节:
+24 (一个空闲位,在书上该位用来存放了“保存的%ebp") |
+20 arg2 |
+16 arg1 |
…… |
+8 |
+4 &arg2 |
&arg1(%esp保存该地址) |
返回地址(from ret) |
对swap_add栈帧,%esp从上面的返回地址后面的地址开始,由于首先pushl %edx, %esp变为下一个地址:
…… |
&arg1 |
返回地址(call调用会存储该地址) |
Initial %ebx(%esp保存该地址,由于没有”保存的%ebp“,所以该位置就类似于书上的%ebp位置。) |
所以可以从%esp+8处取得参数值,注意此处与书上有所不同,因为一直没有用到%ebp帧地址,书上使用%edp帧地址,所以在每次初始部分多了:
pushl %ebp
movl %esp %ebp (这一步结束,就将%ebp指向了”保存的%ebp“的地址。)
在结束部分,要恢复%ebp的值:
popl %ebp
ret
练习3.32: 不论从书上还是实际试验都可以看到,前面位置的参数里%ebp越近(%ebp+4+4i为第i个参数的地址)。
练习3.33:不要犯下面三个错误:(1)注意地址相减表示由高位到低位的扩展;(2)注意做十六进制相加(每个寄存器4字节);(3)C对应12,E对应14.
5.递归过程
栈规则的关键是:每次函数调用它都会保存自己的私有信息(包括返回地址、栈指针%ebp、被调用者保存寄存器值%ebp等,如果需要还有其他局部变量)。