-
-
[原创]从GDB中观察x86-64函数调用约定的完整过程
-
发表于: 4天前 860
-
测试代码:
#include <stdio.h>
// 测试参数传递:6个参数(刚好全用寄存器)
int six_args(int a, int b, int c, int d, int e, int f) {
return a + b + c + d + e + f;
}
// 测试参数传递:8个参数(超出6个,后两个走栈)
int eight_args(int a, int b, int c, int d, int e, int f, int g, int h) {
return a + b + c + d + e + f + g + h;
}
// 测试callee-saved寄存器
int use_callee_saved(int x) {
int arr[3] = {100, 200, 300};
return arr[0] + arr[1] + arr[2] + x;
}
int main() {
printf("six: %d\n", six_args(1, 2, 3, 4, 5, 6));
printf("eight: %d\n", eight_args(1, 2, 3, 4, 5, 6, 7, 8));
printf("saved: %d\n", use_callee_saved(42));
return 0;
}运行环境:
Ubuntu 20.04.6 LTS
编译指令:
gcc -g -O0 -o main main.c
gcc版本
gcc version 9.4.0
调试工具:
GDB
开始执行:
进入main 函数,查看反汇编代码:
endbr64
push %rbp
mov %rsp,%rbp
mov $0x6,%r9d
mov $0x5,%r8d
mov $0x4,%ecx
mov $0x3,%edx
mov $0x2,%esi
mov $0x1,%edi
callq 1169 <six_args>
mov %eax,%esi
lea 0xd8c(%rip),%rdi
mov $0x0,%eax
callq 1070 <printf@plt>
pushq $0x8
pushq $0x7
mov $0x6,%r9d
mov $0x5,%r8d
mov $0x4,%ecx
mov $0x3,%edx
mov $0x2,%esi
mov $0x1,%edi
callq 11a3 <eight_args>
add $0x10,%rsp
mov %eax,%esi
lea 0xd55(%rip),%rdi
mov $0x0,%eax
callq 1070 <printf@plt>
mov $0x2a,%edi
callq 11e7 <use_callee_saved>
mov %eax,%esi
lea 0xd43(%rip),%rdi
mov $0x0,%eax
callq 1070 <printf@plt>
mov $0x0,%eax
leaveq
retq
验证函数在小于等于6个参数时,函数传参的方式是按照 rdi(函数的第一个参数),rsi,rdx,rcx,r8,r9(函数的第六个参数)顺序进行传参的
反汇编代码如下:
mov $0x6,%r9d
mov $0x5,%r8d
mov $0x4,%ecx
mov $0x3,%edx
mov $0x2,%esi
mov $0x1,%edi
进入six_args函数: 在执行call的过程中观察栈中的信息发现,在执行call命令后会自动的将call命令的下一跳指令的rip压入栈中(return address),保证函数在返回后可以回到当前执行流中继续执行
callq 1169 <six_args>
; gdb查看rsp中的值 x /10x $rsp
反汇编代码如下;
endbr64
push %rbp
mov %rsp,%rbp
mov %edi,-0x4(%rbp)
mov %esi,-0x8(%rbp)
mov %edx,-0xc(%rbp)
mov %ecx,-0x10(%rbp)
mov %r8d,-0x14(%rbp)
mov %r9d,-0x18(%rbp)
mov -0x4(%rbp),%edx
mov -0x8(%rbp),%eax
add %eax,%edx
mov -0xc(%rbp),%eax
add %eax,%edx
mov -0x10(%rbp),%eax
add %eax,%edx
mov -0x14(%rbp),%eax
add %eax,%edx
mov -0x18(%rbp),%eax
add %edx,%eax
pop %rbp
retq
在函数调用时,会保存调用方的栈帧,为了防止被调用函数污染了调用方的栈帧,也是为了保证堆栈平衡
即:
push %rbp
mov %rsp,%rbp
继续向下执行:
mov %edi,-0x4(%rbp)
mov %esi,-0x8(%rbp)
mov %edx,-0xc(%rbp)
mov %ecx,-0x10(%rbp)
mov %r8d,-0x14(%rbp)
mov %r9d,-0x18(%rbp)
发现函数在保存调用方的传参时,并没有为当前函数开辟一块栈空间来储存这些参数的值,而是直接使用了rbp向下增长来保存参数
经过查阅发现 x86-64的System V ABI(Linux/macOS使用的调用约定)定义了一个叫做Red Zone红区的概念:
大概就是在函数的调用过程中,rsp-128字节的空间被编译器都认为是安全的,可以直接使用,并不需要sub rsp, x
继续向下执行:
mov -0x4(%rbp),%edx
mov -0x8(%rbp),%eax
add %eax,%edx
mov -0xc(%rbp),%eax
add %eax,%edx
mov -0x10(%rbp),%eax
add %eax,%edx
mov -0x14(%rbp),%eax
add %eax,%edx
mov -0x18(%rbp),%eax
add %edx,%eax
计算6个参数的值,最后结果保存在eax中作为函数的返回值,在x86-64 System V ABI规则中,函数的返回值:整数/指针的返回式是保存在rax(小的值可能保存在eax,ax, al)中的,而浮点数是保存在xmm0中
继续向下执行: 恢复栈帧,将栈帧恢复调用前的状态,retq后会取出压入的return address,修改rip的值,恢复调用方执行流
pop %rbp
retq
继续向下执行: 根据函数的调用约定,分别将"six: %d\n"字符指针以及 six_args的返回值分别放入了 rdi以及 rsi中
mov %eax,%esi
lea 0xd8c(%rip),%rdi
mov $0x0,%eax
; 通过x/10s $rdi 验证字符指针中的内容
在放入对应的寄存器后,还需要将al寄存器的值赋值为0:
因为在x86-64 ABI对不定数量的参数有要求,在传递可变参数函数的时候需要判断是否使用了浮点数寄存器xmm,而al中存放的值就为使用了xmm寄存器的数量
ps:当前函数并没有给al赋值,
即反汇编代码:
mov $0x0,%eax
进入printf函数,输出six: 21
继续向下执行:
pushq $0x8
pushq $0x7
mov $0x6,%r9d
mov $0x5,%r8d
mov $0x4,%ecx
mov $0x3,%edx
mov $0x2,%esi
mov $0x1,%edi
发现并验证,在函数参数超过6个的时候,后面的参数是通过压栈去传递的,根据函数调用约定cdecl,参数从右到左压入栈中
进入eight_args函数:
endbr64
push %rbp
mov %rsp,%rbp
mov %edi,-0x4(%rbp)
mov %esi,-0x8(%rbp)
mov %edx,-0xc(%rbp)
mov %ecx,-0x10(%rbp)
mov %r8d,-0x14(%rbp)
mov %r9d,-0x18(%rbp)
mov -0x4(%rbp),%edx
mov -0x8(%rbp),%eax
add %eax,%edx
mov -0xc(%rbp),%eax
add %eax,%edx
mov -0x10(%rbp),%eax
add %eax,%edx
mov -0x14(%rbp),%eax
add %eax,%edx
mov -0x18(%rbp),%eax
add %eax,%edx
mov 0x10(%rbp),%eax
add %eax,%edx
mov 0x18(%rbp),%eax
add %edx,%eax
pop %rbp
retq
验证rsp栈中内容: x/10w $rsp
栈底部16个字节分别储存的值为:0x555552ab 0x00005555 0x00000007 0x00000000 0x00000008 0x00000000
0x555552ab 0x00005555 return address
int g的值 0x00000007 0x00000000
int h的值 0x00000008 0x00000000 小端排序,所以值为0x08
继续执行,保存返回值到eax中,恢复栈帧,返回到调用方函数内部;发现
反汇编代码:
add $0x10,%rsp
根据函数调用约定cdecl,由调用方进行平栈,恢复栈帧。
继续执行:再次调用printf函数
mov %eax,%esi
lea 0xd55(%rip),%rdi
mov $0x0,%eax
callq 0x555555555070 <printf@plt>
调用结束后输出:eight: 36
继续执行: 将0x2a放入edi中调用use_callee_saved函数
mov $0x2a,%edi
callq 0x5555555551e7 <use_callee_saved>
并没有发现caller-saved: push rax push rcx等类似代码,因为代码实现的过于简单
GCC 在 -O0 下没有需要用到需要保存的寄存器RAX, RCX, RDX, RSI, RDI, R8-R11
进入函数内部:
endbr64
push %rbp
mov %rsp,%rbp
sub $0x30,%rsp
mov %edi,-0x24(%rbp)
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
xor %eax,%eax
movl $0x64,-0x14(%rbp)
movl $0xc8,-0x10(%rbp)
movl $0x12c,-0xc(%rbp)
mov -0x14(%rbp),%edx
mov -0x10(%rbp),%eax
add %eax,%edx
mov -0xc(%rbp),%eax
add %eax,%edx
mov -0x24(%rbp),%eax
add %edx,%eax
mov -0x8(%rbp),%rcx
xor %fs:0x28,%rcx
je 1240 <use_callee_saved+0x59>
callq 1060 <__stack_chk_fail@plt>
leaveq
retq
发现代码:
endbr64
push %rbp
mov %rsp,%rbp
sub $0x30,%rsp ; 再次印证sub rsp, x ; 计算结果rsp的值永远是16的倍数。
继续向下,并没有发现期望看到的callee-saved:即push rbx 以及push r12等类似代码,同理也是因为太简单,所以并没有拥到对应的RBX, R12-R15寄存器,只用到了rbp寄存器
但是发现了:
mov %edi,-0x24(%rbp)
mov %fs:0x28,%rax
mov %rax,-0x8(%rbp)
栈溢出保护的代码,他在当前函数栈帧中,保存的旧RBP值的下方存入了一个金丝雀的值(8字节),当此处的值被覆盖,或者修改时,会触发栈空间保护,程序异常终止,对应函数末尾:
xor %fs:0x28,%rcx
je 1240 <use_callee_saved+0x59> ; 如果值不相等则调用__stack_chk_fail函数
callq 1060 <__stack_chk_fail@plt>
继续执行:初始化数组
movl $0x64,-0x14(%rbp)
movl $0xc8,-0x10(%rbp)
movl $0x12c,-0xc(%rbp)
继续执行到程序结束....
发现:在传入函数参数时,函数还要在函数栈内部保存一次传参例如use_callee_saved中%edi,-0x24(%rbp)
尝试思考:
会不会是为了保证传入的参数栈上的值或者寄存器的值不会被修改,也对应了c语言中的值传递和指针传递的特性。
查阅后发现:
寄存器会被复用。函数执行过程中会用 RDI、ESI 等寄存器做计算,
原始的参数值会被覆盖。所以 -O0 编译器在函数入口就把它们存到栈上,
后续都从栈上读取。
这不是"值传递"的特性,而是 -O0 编译器的策略:
把所有变量都放到栈上,方便 GDB 调试。
后续验证-O2情况下编译器的处理
总结:
1. 验证在64位linux系统中,如果函数参数小于等于6,函数传参是通过寄存器传参的
rdi,rsi,rdx,rcx,r8,r9来进行传参的,而超过6个则根据栈来传参,遵守cdecl调用约定
2. 可变参数函数如果传递了浮点数时,则需要告诉该函数处理浮点数的数量保存在al中
3. 在连续movl在连续地址中保存4字节数据,那他可能是在初始化一个数组(特征发现)
4. 函数返回值如果为指针或者整数时,用rax寄存区传递,如果为浮点数则用xmm0寄存器传递
5. 在函数栈向下128字符的空间为红区,编译器保证了函数在执行过程中可以自由的使用这块空间(红区)
6. 接触到了线程局部存储(TLS)以及“金丝雀”的作用;
7. 发现了栈保护机制Stack Canary
six_args函数栈帧布局对比eight_args函数栈帧布局

