在看王道计组指令系统时,学到了很多关于汇编的知识,正好和当时的CSAPP结合起来,但是总感觉少点实战记忆的不清楚,故准备用GDB调试一下,以函数调用为例子,将这边的知识过一遍。
操作系统:Ubuntu 20.04.4 LTS x86_64
GCC:9.4.0
寄存器
在具体到代码之前,我们需要先了解一下常用的寄存器。
在调用关系中,假设P调用Q,我们将P称为调用者,Q为被调用者。由于寄存器在函数调用中是共享的,为了保护现场,我们需要对寄存器进行保护,具体分为:
被调用者保护寄存器
由被调用者负责保存的寄存器,此类寄存器通常有着比较特殊的作用,如
rbp
存储当前栈帧的起始地址,rbx
存储程序基地址。常有rbp rbx r12~r15
。调用者保护寄存器
此类寄存器常常由调用者保护,更加通用,如函数返回用的是
rax
,故每个函数的rax
寄存器就需要自己进行保护。常有rax rcx rdx rsi rdi r8~r11
此外,rsp
为栈指针寄存器,保存当前栈的栈顶,rip
的作用类似于pc,指向下一条指令的地址。
完整栈帧实例
以下是根据王道课上的导图结合x86画出的一个完整的栈帧,可以先有个印象
函数调用流程
下面,我们以一个实际的例子来讲解,函数调用是怎么在栈和寄存器的配合下完成的。
代码准备
首先我们写出以下源代码:
#include <stdio.h>
int test(int a, int b, int c, int d, int f, int g,int h){
return a + b;
}
int func(int x){
int i = 5;
int s = test(1,2,3,4,5,6,7);
s += i;
return s;
}
int main() {
int result = 2;
result += func(10);
return 0;
}
首先解释一下为什么在test
中定义七个参数,在64位下,前六个参数是由寄存器传递的,如果我们要看栈传参,就需要定义七个参数。
gcc -g -o hello hello.c
objdump -s -d -M intel hello > hello.s
得到以下的汇编代码(-M intel 获得intel分割的代码,默认AT&T):
0000000000001129 <test>:
1129: f3 0f 1e fa endbr64
112d: 55 push %rbp
112e: 48 89 e5 mov %rsp,%rbp
1131: 89 7d fc mov %edi,-0x4(%rbp)
1134: 89 75 f8 mov %esi,-0x8(%rbp)
1137: 89 55 f4 mov %edx,-0xc(%rbp)
113a: 89 4d f0 mov %ecx,-0x10(%rbp)
113d: 44 89 45 ec mov %r8d,-0x14(%rbp)
1141: 44 89 4d e8 mov %r9d,-0x18(%rbp)
1145: 8b 55 fc mov -0x4(%rbp),%edx
1148: 8b 45 f8 mov -0x8(%rbp),%eax
114b: 01 d0 add %edx,%eax
114d: 5d pop %rbp
114e: c3 retq
000000000000114f <func>:
114f: f3 0f 1e fa endbr64
1153: 55 push %rbp
1154: 48 89 e5 mov %rsp,%rbp
1157: 48 83 ec 18 sub $0x18,%rsp
115b: 89 7d ec mov %edi,-0x14(%rbp)
115e: c7 45 f8 05 00 00 00 movl $0x5,-0x8(%rbp)
1165: 6a 07 pushq $0x7
1167: 41 b9 06 00 00 00 mov $0x6,%r9d
116d: 41 b8 05 00 00 00 mov $0x5,%r8d
1173: b9 04 00 00 00 mov $0x4,%ecx
1178: ba 03 00 00 00 mov $0x3,%edx
117d: be 02 00 00 00 mov $0x2,%esi
1182: bf 01 00 00 00 mov $0x1,%edi
1187: e8 9d ff ff ff callq 1129 <test>
118c: 48 83 c4 08 add $0x8,%rsp
1190: 89 45 fc mov %eax,-0x4(%rbp)
1193: 8b 45 f8 mov -0x8(%rbp),%eax
1196: 01 45 fc add %eax,-0x4(%rbp)
1199: 8b 45 fc mov -0x4(%rbp),%eax
119c: c9 leaveq
119d: c3 retq
000000000000119e <main>:
119e: f3 0f 1e fa endbr64
11a2: 55 push %rbp
11a3: 48 89 e5 mov %rsp,%rbp
11a6: 48 83 ec 10 sub $0x10,%rsp
11aa: c7 45 fc 02 00 00 00 movl $0x2,-0x4(%rbp)
11b1: bf 0a 00 00 00 mov $0xa,%edi
11b6: e8 94 ff ff ff callq 114f <func>
11bb: 01 45 fc add %eax,-0x4(%rbp)
11be: b8 00 00 00 00 mov $0x0,%eax
11c3: c9 leaveq
11c4: c3 retq
11c5: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
11cc: 00 00 00
11cf: 90 nop
main
下面通过GDB一遍调试,一边过一遍函数调用的流程
gdb hello
我们先在三个函数中的关键地方打上断点:
键入run
就会运行,停止在第一个断点。
这时,我们输入disassemble
就可以看到当前函数的反汇编代码以及指令地址:
从图中看出rbp-4
应该是result
的地址。
我们输入info registers rsp rbp rip
来看一下三个重要的寄存器值:
rip
的值正是下一条指令(0x00005555555551b1
还没有执行)rsp
指向栈顶,rsp
指向当前栈帧的底部,注意栈是由高地址向低地址增长
我们知道int
占用4个字节,同时根据栈帧图,我们得出局部变量result
位于rbp-4=0x7fffffffde4C
。这时,输入x/1xw 0x7fffffffde4C
,用处是以十六进制方式,四个字节为单位,查看位于0x7fffffffde4C
的一个单位的内存值。
验证了我们的栈帧图中关于局部变量的部分。
func
接下来输入s
逐步调试,进入func函数
这时我们可以输入bt
查看以下当前的栈,发现当前栈中已经压入了两个栈帧
看一下反汇编代码:
再次查看三个寄存器的值:
rip
的值改为了func
的首条指令rbp
还没有改动,需要等到<+4> <+5>
时才会将其压栈,同时rsp
增长了八个字节
我们输入x/1hg 0x7fffffffde38
查看一下刚刚压入栈中的值,发现正是我们在main
函数中call
指令的下一条指令的地址。
这样就验证了关于call
指令的作用。
继续单步调试,发现执行了
push %rbp ; 将main函数的栈帧及地址压入栈中
mov %rsp,%rbp ; 将rbp指向rsp指向的位置
sub $0x18,%rsp ; 扩栈,扩充24个字节
mov %edi,-0x14(%rbp) ; 从%edi中获取第一个参数,放到刚刚扩充的倒数第二个位置
movl $0x5,-0x8(%rbp) ; 将局部变量i放在正数第二个位置
- 第一步和第二部实际上就是被调用者保存寄存器的保存步骤,将旧的ebp保存起来,同时扩栈
- 后面是对参数和局部变量的初始化
我们可以看一下内存布局
我们再看接下来的代码:
明显前六个参数使用的寄存器传参,而最后一个参数是栈传参。
我们接着向下单步调试。
test
进入test
中,还是先看三个寄存器:
还是先看一下刚刚压入栈的值:
正是func
的下一条指令,接着我们主要看一下函数参数的获取
我们发现%edi
中的第一个参数放入到了-0x4(%rbp)
中,后面的依次压栈,符合我们的栈帧图。
当前这个例子没有体现出获取栈参数,故我又改了一下代码,就是在test中改为return a + b + h;
再次GDB,可以看到
这次我们在获取前六个参数,并将前两个参数相加后,尝试从rbp加16个字节的位置获取栈参数,我们简单的计算发现就是旧的rbp
以及返回rip
,加起来正是0x10
.我们也可以看一下内存布局
从格式上不难看出,第一个正是旧的rbp
,第二个是旧的rip
(由于新运行的GDB,故值可能不连贯)。而第三个则是我们的栈传入参数。
现在,我们回到第一次运行的GDB,看看函数返回是怎样的。(si
单步汇编指令调试)
我们知道函数是通过%eax
返回的,我们查看一下%eax
的值确实为应有的值:3
然后经过 pop %rbp
后,我们的栈帧被释放,rbp
指向上一层栈帧的对应rbp
位置
至此结束。
一些问题
我们会发现在test
函数中,并没有像func
一样使用sub $0x18,%rsp
扩栈,返回也没有使用leave
指令,也就是说其没有栈帧,在函数周期中,其rsp
和rbp
一直处于相等的状态,这可能是一种优化,目前我也没有搞清楚。
总结
通过这一次的实际调试,感觉对书上的知识有了更深刻的理解,同时也简单的入门了GCC。
纸上得来终觉浅,绝知此事要躬行。