x86 汇编调用框架分析

youncyb 发布于 2024-11-03 498 次阅读 reverse


本文主要分析 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 标签下的前三行。由于栈是从高到低的生长,所以对应栈帧如下:
file1 保存的基址寄存器(EBP),将栈顶指针(ESP)赋值给 EBP,同时在栈中申请 16 字节的空间。这一步是为了保存程序当前的栈底。我们知道,对栈的操作是由 EBP 和 ESP 配合完成,ESP 永远指向栈的顶部,而 EBP 永远指向栈的底部。

而 main 函数也是一个被操作系统调用的函数,所以也需要对 EBP 进行保存。同时,这里为什么要开辟 16 个字节的空间呢?根据 GNU 的要求:函数局部变量栈的分配上,采用 16 字节的对齐方式 [1]

所以,尽管 main 只有两个 int 型局部变量 = 8 字节,但仍然要申请 16 字节。

接着分析接下来的 5 行: file2 如蓝线所示,将两个变量的值分别装入 EBP-4 和 EBP-8,同时将这两个变量当做参数压入栈中。接下来调用 call_add 函数,CPU 会自动将下一条指令的地址压入栈中。这里对应了 call_add 函数的调用初始过程。

接下来分析 call_add 函数: file3 如红线所示,call_add 子函数首先保存了 main 的栈底 EBP,然后读取栈中的两个参数到 edx 和 eax,相加后放入 eax。最后恢复 main 的栈底,然后执行 ret 指令,ret 指令相当于:

popl %eip 

最后返回到 main 函数: file4 如绿线所示,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 地址。如下图: file5

.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 之前的指令是 leaveleave 是释放栈并且恢复 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。

由于本程序中没有全局变量,所以没什么作用。如果改代码,可在汇编代码中看到如何访问全局变量: file6

参考

  1. https://gcc.gnu.org/onlinedocs/gcc-4.2.3/gcc/i386-and-x86_002d64-Options.html
  2. https://www.ibm.com/developerworks/systems/library/es-gnutool/
  3. https://stackoverflow.com/questions/7534420/gas-explanation-of-cfi-def-cfa-offset/7535848#7535848