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

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

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


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

#include<stdio.h>
#define BUFFER_SIZE 5

int getbuf() {
	char buf[BUFFER_SIZE];
	gets(buf);
	return 1;
}
int main() {
	int t = getbuf();
	printf("%d\n",t);
}

 

#include<cstdio>
#include<iostream>
using namespace std;
#define BUFFER_SIZE 5

int getbuf() {
	char buf[BUFFER_SIZE];
	gets(buf);
	return 1;
}
int main() {
	int t = getbuf();
	cout<<t<<endl;
}

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

通过栈帧实现的函数调用

简单的例子

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

0000000000400566 <getbuf>:
  400566:	55                   	push   %rbp
  400567:	48 89 e5             	mov    %rsp,%rbp
  40056a:	48 83 ec 10          	sub    0x10,%rsp   40056e:	48 8d 45 f0          	lea    -0x10(%rbp),%rax   400572:	48 89 c7             	mov    %rax,%rdi   400575:	b8 00 00 00 00       	mov0x0,%eax
  40057a:	e8 d1 fe ff ff       	callq  400450 <gets@plt>
  40057f:	b8 01 00 00 00       	mov    0x1,%eax   400584:	c9                   	leaveq    400585:	c3                   	retq    </pre> <h3>理解%rsp和%rbp</h3> 其中两个重要的寄存器分别是<span class="lang:default decode:true crayon-inline ">%rbp</span> 和<span class="lang:default decode:true crayon-inline ">%rsp</span> ,<span class="lang:default decode:true crayon-inline ">%rbp</span> 称作帧指针,而<span class="lang:default decode:true crayon-inline ">%rsp</span> 称作栈指针。首先需要记住如下事实:  <img class="size-medium wp-image-1412 alignright" src="https://mhy12345.xyz/wp-content/uploads/2018/08/Function-call-2-300x300.png" alt="" width="300" height="300" /> <ul>  	<li>函数的栈空间是从大地址空间向小地址空间生长。也就是说,在栈空间中的push操作会使<span class="lang:default decode:true crayon-inline ">%rsp</span> 越来越小。(对应右图中上面为高地址,下面为低地址)</li>  	<li>当前程序的局部变量储存在<span class="lang:default decode:true crayon-inline ">%rsp</span> 和<span class="lang:default decode:true crayon-inline ">%rbp</span> 两个指针所夹的那一段区域内(对应右图中紫色部分)</li> </ul> 现在,在形式化定义<span class="lang:default decode:true crayon-inline ">%rsp</span> 和<span class="lang:default decode:true crayon-inline ">%rbp</span> :  <span class="lang:default decode:true crayon-inline ">%rsp</span> 表示栈顶指针,永远指向当前使用栈空间中最下面的位置的地址。因此在我们的程序中,只用于定位栈顶的位置,并支持栈的插入弹出,而不用于地址索引。一般需要改变<span class="lang:default decode:true crayon-inline ">%rsp</span> 的地方有—— <ul>  	<li>push指令:push指令将<span class="lang:default decode:true crayon-inline ">%rsp</span> 的值减一,并在新的<span class="lang:default decode:true crayon-inline ">%rsp</span> 位置放上需要push的值</li>  	<li>pop指令:pop指令将<span class="lang:default decode:true crayon-inline ">%rsp</span> 的值加一,并将原来的<span class="lang:default decode:true crayon-inline ">%rsp</span> 位置上的值放在pop的寄存器中</li>  	<li>进入函数中,函数需要一定的空间,直接使用<span class="lang:default decode:true crayon-inline ">sub0x10,%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之后,究竟干了些啥:
    0000000000400586 <main>:
      400586:	55                   	push   %rbp
      400587:	48 89 e5             	mov    %rsp,%rbp
      40058a:	48 83 ec 10          	sub    0x10,%rsp   40058e:	b8 00 00 00 00       	mov0x0,%eax
      400593:	e8 ce ff ff ff       	callq  400566 <getbuf>
      400598:	89 45 fc             	mov    %eax,-0x4(%rbp)
      40059b:	8b 45 fc             	mov    -0x4(%rbp),%eax
      40059e:	89 c6                	mov    %eax,%esi
      4005a0:	bf 44 06 40 00       	mov    0x400644,%edi   4005a5:	b8 00 00 00 00       	mov0x0,%eax
      4005aa:	e8 81 fe ff ff       	callq  400430 <printf@plt>
      4005af:	b8 00 00 00 00       	mov    0x0,%eax   4005b4:	c9                   	leaveq    4005b5:	c3                   	retq      4005b6:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)   4005bd:	00 00 00</pre> <pre class="lang:default decode:true " title="c.s-getbuf">0000000000400566 <getbuf>:   400566:	55                   	push   %rbp   400567:	48 89 e5             	mov    %rsp,%rbp   40056a:	48 83 ec 10          	sub0x10,%rsp
      40056e:	48 8d 45 f0          	lea    -0x10(%rbp),%rax
      400572:	48 89 c7             	mov    %rax,%rdi
      400575:	b8 00 00 00 00       	mov    0x0,%eax   40057a:	e8 d1 fe ff ff       	callq  400450 <gets@plt>   40057f:	b8 01 00 00 00       	mov0x1,%eax
      400584:	c9                   	leaveq 
      400585:	c3                   	retq   
    

    整个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函数汇编代码如下,可以发现,除了函数符号名有些区别外,没有其他的区别。

    0000000000400866 <_Z6getbufv>:
      400866:	55                   	push   %rbp
      400867:	48 89 e5             	mov    %rsp,%rbp
      40086a:	48 83 ec 10          	sub    0x10,%rsp   40086e:	48 8d 45 f0          	lea    -0x10(%rbp),%rax   400872:	48 89 c7             	mov    %rax,%rdi   400875:	e8 b6 fe ff ff       	callq  400730 <gets@plt>   40087a:	b8 01 00 00 00       	mov0x1,%eax
      40087f:	c9                   	leaveq 
      400880:	c3                   	retq

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

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

    00000000004005c0 <getbuf>:
      4005c0:	48 83 ec 18          	sub    0x18,%rsp   4005c4:	31 c0                	xor    %eax,%eax   4005c6:	48 89 e7             	mov    %rsp,%rdi   4005c9:	e8 92 fe ff ff       	callq  400460 <gets@plt>   4005ce:	b8 01 00 00 00       	mov0x1,%eax
      4005d3:	48 83 c4 18          	add    0x18,%rsp   4005d7:	c3                   	retq      4005d8:	0f 1f 84 00 00 00 00 	nopl   0x0(%rax,%rax,1)   4005df:	00</pre> <img class="size-medium wp-image-1411 alignright" src="https://mhy12345.xyz/wp-content/uploads/2018/08/Function-call-300x300.png" alt="" width="300" height="300" />  由于在之前的实现中,<span class="lang:default decode:true crayon-inline ">%rbp</span> 的作用实际上是简化由于<span class="lang:default decode:true crayon-inline ">%rsp</span> 的变化导致定位偏移量不容易计算而设计出来的,如果我们成功追踪了<span class="lang:default decode:true crayon-inline ">%rsp</span> 在每个函数调用过程中的变化量,就很容易实现函数调用的恢复操作。  而在-O2优化下,编译器会以效率优先,如果我们完全知道了代码长什么样的,也知道每一次程序<span class="lang:default decode:true crayon-inline ">%rsp</span> 的变化量,实际上<span class="lang:default decode:true crayon-inline ">%rbp</span> 就不是那么有必要了。也就是说,这个程序是单纯使用<span class="lang:default decode:true crayon-inline ">%rsp</span> 实现函数调用的例子。  在上面程序中,getbuf调用步骤为: <ul>  	<li>【main函数中】call指令调用getbuf,getbuf返回后下一条指令的地址被压栈,处理器指令运行的指针被置于getbuf其实位置</li>  	<li>移动<span class="lang:default decode:true crayon-inline ">%rsp</span> 分配足够空间</li>  	<li>清零<span class="lang:default decode:true crayon-inline ">%eax</span></li>  	<li>将<span class="lang:default decode:true crayon-inline ">%rsp</span> (即buf数组的地址),作为gets的参数,放在<span class="lang:default decode:true crayon-inline ">%rdi</span> 中</li>  	<li>调用gets函数</li>  	<li>将返回值放在<span class="lang:default decode:true crayon-inline ">%eax</span> 中</li>  	<li>将之前<span class="lang:default decode:true crayon-inline ">%rsp</span> 分配空间部分撤销</li>  	<li>退出</li> </ul> <h2>函数调用中的参数传递</h2> 尝试将原来的程序中,添加参数传入,得到下面的getbuf函数—— <pre class="lang:c decode:true " title="c-call-with-params.c">#include<stdio.h> #define BUFFER_SIZE 5  int getbuf(int a,int b,int c,int d,int e, 		int f,int g,int h,int i,int j) { 	char buf[BUFFER_SIZE]; 	buf[0] = a; 	buf[1] = b; 	buf[2] = c; 	buf[3] = d; 	buf[4] = e; 	buf[0] = f; 	buf[1] = g; 	buf[2] = h; 	buf[3] = i; 	buf[4] = j; 	gets(buf); 	return 1; } int main() { 	int t = getbuf(1,2,3,4,5,6,7,8,9,10); 	printf("%d\n",t); }</pre> 函数中传入了一大堆参数,这时因为,C++在传入参数少和传入参数多的时候,处理方式有所区别,因此,只有当<span class="lang:default decode:true crayon-inline ">getbuf</span> 函数传入足够多的参数时,才能成功涵盖两种方式。  将上述代码编译之后,得到汇编代码如下—— <pre class="lang:default decode:true" title="c-call-with-params.s">0000000000400566 <getbuf>:   400566:	55                   	push   %rbp   400567:	48 89 e5             	mov    %rsp,%rbp   40056a:	48 83 ec 30          	sub0x30,%rsp
      40056e:	89 7d ec             	mov    %edi,-0x14(%rbp)
      400571:	89 75 e8             	mov    %esi,-0x18(%rbp)
      400574:	89 55 e4             	mov    %edx,-0x1c(%rbp)
      400577:	89 4d e0             	mov    %ecx,-0x20(%rbp)
      40057a:	44 89 45 dc          	mov    %r8d,-0x24(%rbp)
      40057e:	44 89 4d d8          	mov    %r9d,-0x28(%rbp)
      400582:	8b 45 ec             	mov    -0x14(%rbp),%eax
      400585:	88 45 f0             	mov    %al,-0x10(%rbp)
      400588:	8b 45 e8             	mov    -0x18(%rbp),%eax
      40058b:	88 45 f1             	mov    %al,-0xf(%rbp)
      40058e:	8b 45 e4             	mov    -0x1c(%rbp),%eax
      400591:	88 45 f2             	mov    %al,-0xe(%rbp)
      400594:	8b 45 e0             	mov    -0x20(%rbp),%eax
      400597:	88 45 f3             	mov    %al,-0xd(%rbp)
      40059a:	8b 45 dc             	mov    -0x24(%rbp),%eax
      40059d:	88 45 f4             	mov    %al,-0xc(%rbp)
      4005a0:	8b 45 d8             	mov    -0x28(%rbp),%eax
      4005a3:	88 45 f0             	mov    %al,-0x10(%rbp)
      4005a6:	8b 45 10             	mov    0x10(%rbp),%eax
      4005a9:	88 45 f1             	mov    %al,-0xf(%rbp)
      4005ac:	8b 45 18             	mov    0x18(%rbp),%eax
      4005af:	88 45 f2             	mov    %al,-0xe(%rbp)
      4005b2:	8b 45 20             	mov    0x20(%rbp),%eax
      4005b5:	88 45 f3             	mov    %al,-0xd(%rbp)
      4005b8:	8b 45 28             	mov    0x28(%rbp),%eax
      4005bb:	88 45 f4             	mov    %al,-0xc(%rbp)
      4005be:	48 8d 45 f0          	lea    -0x10(%rbp),%rax
      4005c2:	48 89 c7             	mov    %rax,%rdi
      4005c5:	b8 00 00 00 00       	mov    0x0,%eax   4005ca:	e8 81 fe ff ff       	callq  400450 <gets@plt>   4005cf:	b8 01 00 00 00       	mov0x1,%eax
      4005d4:	c9                   	leaveq
      4005d5:	c3                   	retq

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

    00000000004005d6 <main>:
      4005d6:	55                   	push   %rbp
      4005d7:	48 89 e5             	mov    %rsp,%rbp
      4005da:	48 83 ec 10          	sub    0x10,%rsp   4005de:	6a 0a                	pushq0xa
      4005e0:	6a 09                	pushq  0x9   4005e2:	6a 08                	pushq0x8
      4005e4:	6a 07                	pushq  0x7   4005e6:	41 b9 06 00 00 00    	mov0x6,%r9d
      4005ec:	41 b8 05 00 00 00    	mov    0x5,%r8d   4005f2:	b9 04 00 00 00       	mov0x4,%ecx
      4005f7:	ba 03 00 00 00       	mov    0x3,%edx   4005fc:	be 02 00 00 00       	mov0x2,%esi
      400601:	bf 01 00 00 00       	mov    0x1,%edi   400606:	e8 5b ff ff ff       	callq  400566 <getbuf>   40060b:	48 83 c4 20          	add0x20,%rsp
      40060f:	89 45 fc             	mov    %eax,-0x4(%rbp)
      400612:	8b 45 fc             	mov    -0x4(%rbp),%eax
      400615:	89 c6                	mov    %eax,%esi
      400617:	bf b4 06 40 00       	mov    0x4006b4,%edi   40061c:	b8 00 00 00 00       	mov0x0,%eax
      400621:	e8 0a fe ff ff       	callq  400430 <printf@plt>
      400626:	b8 00 00 00 00       	mov    0x0,%eax   40062b:	c9                   	leaveq   40062c:	c3                   	retq   40062d:	0f 1f 00             	nopl   (%rax)</pre>    可以发现,对于参数的传递,分为两种方式—— <ul>  	<li>当参数个数本身较少的时候,直接使用寄存器传参。对应了0x4005e6-0x400601的代码</li>  	<li>当参数个数比较多的话,无法用寄存器装下的参数,在调用前压到栈里面,由被调用者通过<span class="lang:default decode:true crayon-inline ">%rbp</span> 定位取之,在调用完成后,被调用者再将压栈的参数弹出。</li> </ul> 在细节上,对于寄存器传参,依次使用了寄存器<span class="lang:default decode:true crayon-inline ">%rdi</span> ,<span class="lang:default decode:true crayon-inline ">%rsi</span> ,<span class="lang:default decode:true crayon-inline ">%rdx</span> ,<span class="lang:default decode:true crayon-inline">%rcx</span> ,<span class="lang:default decode:true crayon-inline ">%r8</span> ,<span class="lang:default decode:true crayon-inline ">%r9</span> ;对于压栈传参,是按照参数位置,从右到左压栈。 <h2>包含金丝雀保护机制的函数调用</h2> 倘若编译参数中,没有<span class="lang:default decode:true crayon-inline">-fno-stack-protector</span>,则C语言的getbuf编译出来如下—— <pre class="lang:default mark:5,6,7,14,15,16,18 decode:true " title="c-with-protector.s-getbuf">00000000004005d6 <getbuf>:   4005d6:	55                   	push   %rbp   4005d7:	48 89 e5             	mov    %rsp,%rbp   4005da:	48 83 ec 10          	sub0x10,%rsp
      4005de:	64 48 8b 04 25 28 00 	mov    %fs:0x28,%rax
      4005e5:	00 00 
      4005e7:	48 89 45 f8          	mov    %rax,-0x8(%rbp)
      4005eb:	31 c0                	xor    %eax,%eax
      4005ed:	48 8d 45 f0          	lea    -0x10(%rbp),%rax
      4005f1:	48 89 c7             	mov    %rax,%rdi
      4005f4:	b8 00 00 00 00       	mov    0x0,%eax   4005f9:	e8 c2 fe ff ff       	callq  4004c0 <gets@plt>   4005fe:	b8 01 00 00 00       	mov0x1,%eax
      400603:	48 8b 55 f8          	mov    -0x8(%rbp),%rdx
      400607:	64 48 33 14 25 28 00 	xor    %fs:0x28,%rdx
      40060e:	00 00 
      400610:	74 05                	je     400617 <getbuf+0x41>
      400612:	e8 79 fe ff ff       	callq  400490 <__stack_chk_fail@plt>
      400617:	c9                   	leaveq 
      400618:	c3                   	retq   
    

    多出来的一部分代码被称为金丝雀,是防止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位

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

    发表评论

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

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