解析汇编中实现函数调用的若干方式

相同的c/c++语言的函数调用,在编译成汇编代码之后,会存在不同的解析方式,这些解析方式一方面依赖于编译器本身(比如gcc和g++编译出的代码就不尽相同),另一方面也会受到编译选项的影响(比如是否打开-O2 -fno-stack-protector这些编译开关)。网上很多其他的资料无视了实现函数调用的汇编代码的多样性,使得读者会在对照理解时出现一些困难。

在这篇文章中,我将通过几个小例子,介绍不同情况汇编实现C/C++语言函数调用的方式。具体的代码可以在 我的github 上面看到。


下图是一个简单的读入字符串的程序(注意,这个程序故意引入了不安全的gets函数)的C语言版本和C++版本——

 

这个程序中的getbuf函数就是我们今天讨论的主题

通过栈帧实现的函数调用

简单的例子

这是C语言代码版本通过编译命令 gcc c.c -o c -fno-stack-protector 生成的汇编代码——

理解%rsp和%rbp

其中两个重要的寄存器分别是 %rbp 和 %rsp , %rbp 称作帧指针,而 %rsp 称作栈指针。首先需要记住如下事实:

  • 函数的栈空间是从大地址空间向小地址空间生长。也就是说,在栈空间中的push操作会使 %rsp 越来越小。(对应右图中上面为高地址,下面为低地址)
  • 当前程序的局部变量储存在 %rsp 和 %rbp 两个指针所夹的那一段区域内(对应右图中紫色部分)

现在,在形式化定义 %rsp 和 %rbp :

%rsp 表示栈顶指针,永远指向当前使用栈空间中最下面的位置的地址。因此在我们的程序中,只用于定位栈顶的位置,并支持栈的插入弹出,而不用于地址索引。一般需要改变 %rsp 的地方有——

  • push指令:push指令将 %rsp 的值减一,并在新的 %rsp 位置放上需要push的值
  • pop指令:pop指令将 %rsp 的值加一,并将原来的 %rsp 位置上的值放在pop的寄存器中
  • 进入函数中,函数需要一定的空间,直接使用 sub $0x10,%rsp 分配整块的空间。
  • call指令和ret指令:其中内含了对于寄存器 %rip 的push和pop操作。关于call指令和ret指令更加详细的说明,在我的另一篇文章一句话解析汇编中的ret和call指令有更详细的解答。

%rbp 表示帧指针,一般在函数中用于定位,由于 %rsp 身兼多职,可能在一个函数块内修改,因此,给予 %rsp 的栈内信息定位是可行但不方便的。帧指针 %rbp 指向的位置是函数调用后,使用push操作保存上一个函数的帧指针后, %rsp 的位置,换句话说,这个位置就是没有进行任何局部变量分配是,栈顶 %rsp 的位置,之后的任何函数内的 %rsp 修改就都与 %rbp 无关了。从构造上来说, %rbp 还能用来实现函数的退出以及上一层函数的恢复。需要修改 %rbp 的地方有——

  • 调用函数时, %rbp 仍然指向的是上一层函数的帧,需要更新。为了之后可以恢复,需要将当前的 %rbp 的值使用push指令,放在栈顶,然后将 %rbp 赋值为 %rsp ,标记为“当前帧指针就是刚进入函数,没有分配任何内容的栈顶指针”。同时,新的 %rbp 指向的位置存有 %rbp 在函数退出时需要恢复的值。
  • 退出函数时,需要将%rbp更新回去。

汇编代码解读

回到之前的汇编代码,我们看看调用getbuf之后,究竟干了些啥:

整个getbuf的调用依次是——

  • 【位于main中】call指令,将程序结束下一条指令地址 %rip push至栈顶,并将call的函数入口地址赋值给 %rip ,实现指令流的跳转。
  • 保存旧的 %rbp 至栈顶,此时push使得 %rsp 加了1
  • 将栈顶指针 %rsp 赋值到 %rbp 中,此时新的 %rbp 可以理解成main函数内部变量和getbuf函数内部变量的分界线,而这个位置之后会作为getbuf函数内部变量定位的基准。
  • 函数内部变量需要使用接下来0x10大小的空间,在栈中预先分配
  • 我们将分配的那一块空间用来存buf数组,那么buf指针的值就应该是 %rbp-0x10 了
  • 将存有buf数组的寄存器 %rax 赋值给 %rdi
  • 将零时储存器 %rax 清零
  • 调用gets函数
  • 将储存器 %rax 赋值为1作为返回值
  • 使用leaveq指令恢复栈帧 %rbp
  • 使用ret指令将栈顶储存的“函数退出后下一条语句地址”弹出,并重定向为下一句执行位置。

C语言与C++语言

C++语言的getbuf函数汇编代码如下,可以发现,除了函数符号名有些区别外,没有其他的区别。

只使用栈指针实现函数调用

倘若在编译时,添加-O2优化,则C语言代码的getbuf部分变成了:

由于在之前的实现中, %rbp 的作用实际上是简化由于 %rsp 的变化导致定位偏移量不容易计算而设计出来的,如果我们成功追踪了 %rsp 在每个函数调用过程中的变化量,就很容易实现函数调用的恢复操作。

而在-O2优化下,编译器会以效率优先,如果我们完全知道了代码长什么样的,也知道每一次程序 %rsp 的变化量,实际上 %rbp 就不是那么有必要了。也就是说,这个程序是单纯使用 %rsp 实现函数调用的例子。

在上面程序中,getbuf调用步骤为:

  • 【main函数中】call指令调用getbuf,getbuf返回后下一条指令的地址被压栈,处理器指令运行的指针被置于getbuf其实位置
  • 移动 %rsp 分配足够空间
  • 清零 %eax
  • %rsp (即buf数组的地址),作为gets的参数,放在 %rdi 中
  • 调用gets函数
  • 将返回值放在 %eax 中
  • 将之前 %rsp 分配空间部分撤销
  • 退出

函数调用中的参数传递

尝试将原来的程序中,添加参数传入,得到下面的getbuf函数——

函数中传入了一大堆参数,这时因为,C++在传入参数少和传入参数多的时候,处理方式有所区别,因此,只有当 getbuf 函数传入足够多的参数时,才能成功涵盖两种方式。

将上述代码编译之后,得到汇编代码如下——

以及调用者main函数的反汇编代码。

 

可以发现,对于参数的传递,分为两种方式——

  • 当参数个数本身较少的时候,直接使用寄存器传参。对应了0x4005e6-0x400601的代码
  • 当参数个数比较多的话,无法用寄存器装下的参数,在调用前压到栈里面,由被调用者通过 %rbp 定位取之,在调用完成后,被调用者再将压栈的参数弹出。

在细节上,对于寄存器传参,依次使用了寄存器 %rdi , %rsi , %rdx , %rcx , %r8 , %r9 ;对于压栈传参,是按照参数位置,从右到左压栈。

包含金丝雀保护机制的函数调用

倘若编译参数中,没有 -fno-stack-protector,则C语言的getbuf编译出来如下——

多出来的一部分代码被称为金丝雀,是防止gets操作产生危险后果的,具体原理有时间再更~

环境与配置

gcc版本 (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609

g++版本 (Ubuntu 5.4.0-6ubuntu1~16.04.9) 5.4.0 20160609

Ubuntu版本 16.04 64位

原创文章地址:【解析汇编中实现函数调用的若干方式】,转载时请注明出处mhy12345.xyz

《解析汇编中实现函数调用的若干方式》有一个想法

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据