本文主要分析 X86 汇编的调用框架。对函数调用过程的栈帧进行一步一步的解读,同时对 .cli
指令进行解读。读者读完后,会对函数调用过程以及为什么会出现 .cli
指令和 .cli
指令的作用,有一个清晰的认识。
编写 call 程序并将其转换为汇编代码
1. call.c
int call_add(int a, int b){
return a + b;
}
int main(void){
int num1 = 1;
int num2 = 2;
call_add(num1, num2);
return 0;
}
2. call.s
通过 GAS(GNU Assembly)生成汇编程序
gcc -m32 -O0 -S call.c
.file "call.c"
.text
.globl call_add
.type call_add, @function
call_add:
.LFB0:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_, %eax
movl 8(%ebp), %edx
movl 12(%ebp), %eax
addl %edx, %eax
popl %ebp
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE0:
.size call_add, .-call_add
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $16, %esp
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_, %eax
movl $1, -4(%ebp)
movl $2, -8(%ebp)
pushl -8(%ebp)
pushl -4(%ebp)
call call_add
addl $8, %esp
movl $0, %eax
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.LFE1:
.size main, .-main
.section .text.__x86.get_pc_thunk.ax,"axG",@progbits,__x86.get_pc_thunk.ax,comdat
.globl __x86.get_pc_thunk.ax
.hidden __x86.get_pc_thunk.ax
.type __x86.get_pc_thunk.ax, @function
__x86.get_pc_thunk.ax:
.LFB2:
.cfi_startproc
movl (%esp), %eax
ret
.cfi_endproc
.LFE2:
.ident "GCC: (Debian 10.2.1-1) 10.2.1 20201207"
.section .note.GNU-stack,"",@progbits
为了方便理解调用过程,我们对 call.s
进行简化:
call_add:
pushl %ebp
movl %esp, %ebp
movl 8(%ebp), %edx
movl 12(%ebp), %eax
addl %edx, %eax
popl %ebp
ret
main:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $1, -4(%ebp)
movl $2, -8(%ebp)
pushl -8(%ebp)
pushl -4(%ebp)
call call_add
addl $8, %esp
movl $0, %eax
leave
ret
对简化的 call.s 进行解读
首先,分析 main 标签下的前三行。由于栈是从高到低的生长,所以对应栈帧如下:
保存的基址寄存器(EBP),将栈顶指针(ESP)赋值给 EBP,同时在栈中申请 16 字节的空间。这一步是为了保存程序当前的栈底。我们知道,对栈的操作是由 EBP 和 ESP 配合完成,ESP 永远指向栈的顶部,而 EBP 永远指向栈的底部。
而 main 函数也是一个被操作系统调用的函数,所以也需要对 EBP 进行保存。同时,这里为什么要开辟 16 个字节的空间呢?根据 GNU 的要求:函数局部变量栈的分配上,采用 16 字节的对齐方式 [1]。
所以,尽管 main 只有两个 int 型局部变量 = 8 字节,但仍然要申请 16 字节。
接着分析接下来的 5 行:
如蓝线所示,将两个变量的值分别装入 EBP-4 和 EBP-8,同时将这两个变量当做参数压入栈中。接下来调用 call_add 函数,CPU 会自动将下一条指令的地址压入栈中。这里对应了 call_add 函数的调用初始过程。
接下来分析 call_add 函数:
如红线所示,call_add 子函数首先保存了 main 的栈底 EBP,然后读取栈中的两个参数到 edx 和 eax,相加后放入 eax。最后恢复 main 的栈底,然后执行 ret 指令,ret 指令相当于:
popl %eip
最后返回到 main 函数:
如绿线所示,call_add 函数返回后,执行
add $8, %esp
,即释放栈中保存的参数 1 和参数 2。然后执行 movl $0, %eax
,代表 main 函数返回值(一般存放在 eax)。最后执行 leave ret
指令,leave
相当于:
movl %ebp, %esp
pop %ebp
代表了对 main 函数进行栈上的局部变量释放和 ebp 的状态返回。
CFI 伪指令解析
现在回到原始的 call.s,可以看到里面有许多 .cfi
开头的指令。这些都是伪指令,而 cfi 全称是 “Call Frame Information”,调用框架信息。顾名思义,里面描述了函数调用时栈的变化信息。
If you find yourself in the position of combining C++ with assembly language routines, you can incorporate exception handling support by using the Call Frame Information directives. These directives generate stack unwinding information, such that any routines written in assembler will integrate nicely with C++ and other high-level languages.[2]
从 IBM 的开发者文档可以得出:CFI 用于集成高级语言的 exception 机制,这些指令基本上描述的栈展开信息。但 CFI 不止用于 exception hanlding,同时还用于 debug [3]。
CFI 伪指令在编译、链接程序后,会生成 .eh_frame 元信息,包含了函数的栈大小和寄存器入栈相关。下面开始实际分析 CFI 伪指令:
main:
.LFB1:
.cfi_startproc
pushl %ebp
.cfi_def_cfa_offset 8
.cfi_offset 5, -8
movl %esp, %ebp
.cfi_def_cfa_register 5
subl $16, %esp
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_, %eax
movl $1, -4(%ebp)
movl $2, -8(%ebp)
pushl -8(%ebp)
pushl -4(%ebp)
call call_add
addl $8, %esp
movl $0, %eax
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
.cfi_startproc
和 .cfi_endproc
分别代表程序的入口和结束。
.cfi_def_cfa_offset 8
表示距离 CFA 8 个字节。这里的 CFA 全称:“Canonical Frame Address”,即规范框架地址。而这个规范框架地址是指一个基石地址,如同参照系中的被参照物。而这个指令就是说距离 CFA 的地址是 8 个字节,但是为什么是 8 个字节呢?答案很简单,CFA 保存的是函数 call 之前的 esp 地址。如下图:
.cfi_offset 5, -8
表示 5 号寄存器距离 CFA 的距离为-8 字节。这里的 5 号寄存器很显然是 esp,从上图中可以看到 CFA = 目前 esp+8 字节。
.cfi_def_cfa_register 5
表示把 5 号寄存器作为 CFA 的地址,修改 CFA 的原因是了方便记录接下来的 call_add 函数的栈信息,也就是调用 call_add 时 movl %esp, %ebp
后 ebp 的位置。
.cfi_restore 5
表示恢复 5 号寄存器的值。.cfi_restore 5
之前的指令是 leave
,leave
是释放栈并且恢复 ebp。所以,.cfi_restore 5
代表释放栈空间,而恢复 ebp:popl %ebp
即:.cfi_def_cfa 4, 4
。4 号寄存器此时就指代 ebp,表示 CFA 恢复为最上层的 ebp 地址。
题外解释 __x86.get_pc_thunk.ax
......
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_, %eax
......
__x86.get_pc_thunk.ax:
movl (%esp), %eax
ret
__x86.get_pc_thunk.ax
是在 64 位系统上生成 32 程序出现,用于访问全局变量和全局指针。
call __x86.get_pc_thunk.ax
将下一条指令:
addl $_GLOBAL_OFFSET_TABLE_, %eax
的地址放入 eax 中,然后调用 addl $_GLOBAL_OFFSET_TABLE_, %eax
,此时 eax = GOT。
由于本程序中没有全局变量,所以没什么作用。如果改代码,可在汇编代码中看到如何访问全局变量:
参考
- https://gcc.gnu.org/onlinedocs/gcc-4.2.3/gcc/i386-and-x86_002d64-Options.html
- https://www.ibm.com/developerworks/systems/library/es-gnutool/
- https://stackoverflow.com/questions/7534420/gas-explanation-of-cfi-def-cfa-offset/7535848#7535848
Comments NOTHING