在看王道计组指令系统时,学到了很多关于汇编的知识,正好和当时的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

我们先在三个函数中的关键地方打上断点:

image-20240615171913761

键入run就会运行,停止在第一个断点。

这时,我们输入disassemble就可以看到当前函数的反汇编代码以及指令地址:

image-20240615172120034

从图中看出rbp-4应该是result的地址。

我们输入info registers rsp rbp rip 来看一下三个重要的寄存器值:

image-20240615172334899
  • rip的值正是下一条指令(0x00005555555551b1还没有执行)
  • rsp指向栈顶,rsp指向当前栈帧的底部,注意栈是由高地址向低地址增长

我们知道int占用4个字节,同时根据栈帧图,我们得出局部变量result位于rbp-4=0x7fffffffde4C。这时,输入x/1xw 0x7fffffffde4C,用处是以十六进制方式,四个字节为单位,查看位于0x7fffffffde4C的一个单位的内存值。

image-20240615175144957

验证了我们的栈帧图中关于局部变量的部分。

func

接下来输入s逐步调试,进入func函数

image-20240615180330862

这时我们可以输入bt查看以下当前的栈,发现当前栈中已经压入了两个栈帧

image-20240615180416402

看一下反汇编代码:

image-20240615180657077

再次查看三个寄存器的值:

image-20240615180553343
  • rip的值改为了func的首条指令
  • rbp还没有改动,需要等到<+4> <+5>时才会将其压栈,同时rsp增长了八个字节

我们输入x/1hg 0x7fffffffde38查看一下刚刚压入栈中的值,发现正是我们在main函数中call指令的下一条指令的地址。

image-20240615181100312

这样就验证了关于call指令的作用。

继续单步调试,发现执行了

image-20240615182159188image-20240615185541242
push %rbp               ; 将main函数的栈帧及地址压入栈中
mov %rsp,%rbp           ; 将rbp指向rsp指向的位置
sub    $0x18,%rsp       ; 扩栈,扩充24个字节
mov    %edi,-0x14(%rbp) ; 从%edi中获取第一个参数,放到刚刚扩充的倒数第二个位置
movl   $0x5,-0x8(%rbp)  ; 将局部变量i放在正数第二个位置
  • 第一步和第二部实际上就是被调用者保存寄存器的保存步骤,将旧的ebp保存起来,同时扩栈
  • 后面是对参数和局部变量的初始化

我们可以看一下内存布局

image-20240615183507806

我们再看接下来的代码:

image-20240615184017035

明显前六个参数使用的寄存器传参,而最后一个参数是栈传参。

我们接着向下单步调试。

test

进入test中,还是先看三个寄存器:

image-20240615184256821

还是先看一下刚刚压入栈的值:

image-20240615185804977

正是func的下一条指令,接着我们主要看一下函数参数的获取

image-20240615185927649

我们发现%edi中的第一个参数放入到了-0x4(%rbp)中,后面的依次压栈,符合我们的栈帧图。

当前这个例子没有体现出获取栈参数,故我又改了一下代码,就是在test中改为return a + b + h;

再次GDB,可以看到

image-20240615192802036

这次我们在获取前六个参数,并将前两个参数相加后,尝试从rbp加16个字节的位置获取栈参数,我们简单的计算发现就是旧的rbp以及返回rip,加起来正是0x10.我们也可以看一下内存布局

image-20240615193007240image-20240615192951412

从格式上不难看出,第一个正是旧的rbp,第二个是旧的rip(由于新运行的GDB,故值可能不连贯)。而第三个则是我们的栈传入参数。

现在,我们回到第一次运行的GDB,看看函数返回是怎样的。(si 单步汇编指令调试)

image-20240615193357534

我们知道函数是通过%eax返回的,我们查看一下%eax的值确实为应有的值:3

image-20240615193459291

然后经过 pop %rbp 后,我们的栈帧被释放,rbp指向上一层栈帧的对应rbp位置

image-20240615193705073

至此结束。

一些问题

我们会发现在test函数中,并没有像func一样使用sub $0x18,%rsp扩栈,返回也没有使用leave指令,也就是说其没有栈帧,在函数周期中,其rsprbp一直处于相等的状态,这可能是一种优化,目前我也没有搞清楚。

总结

通过这一次的实际调试,感觉对书上的知识有了更深刻的理解,同时也简单的入门了GCC。

纸上得来终觉浅,绝知此事要躬行。

上次更新:
Contributors: YangZhang