苏黎 搜索开发工程师

C++函数调用堆栈详解-(2)

2017-08-04
suli
C++

函数的运行

从main函数开始程序的运行是怎样的一个过程,程序是怎么给一个函数输入参数的?程序是如何知道函数的返回结果的?林纳斯曾经说过我们应该对我们所写的代码运行的细节完全了解才是一个合格的程序员。

1.X86-64CPU的寄存器介绍

在了解64位处理器寄存器之前先了解一下32cpu的寄存器,如下表格所示:

32位CPU寄存器

寄存器 名称 作用
数据寄存器 EAX,EBX,ECX,EDX 存储中间数居,同时用作函数返地址等数据存储
变址和指针寄存器 ESI,EDI 存储器指针,源操作数指针和目的操作数指针
指针寄存器 ESP、EBP sp是栈顶指针,bp栈基指针
段寄存器 ES、CS、SS、DS、FS、GS 主要用来进行程序的寻址
指令指针寄存器 EIP 指向下一条指令的地址
标志寄存器 EFlags 记录状态系统和控制标志

上面寄存器中其中EAX、EBX、ECX、EDX、ESP、EBP、ESI、EDI 这8个称为通用寄存器。可以直接进行操作并没有限制。

64位CPU寄存器

在X86-64CPU中所有的寄存器都是64位的,32位寄存器是32位的并且部分寄存器还分高16位和低16位,相对于32位的x86cpu,标识符发生了很大变化,比如linux下gcc汇编代码中%ebp变成了%rbp,并且向后兼容,%ebp也可以使用只想%rbp的低32位。同时X86-64上还新加了8个寄存器,加上原来的8个一共16个寄存器,寄存器速度很快,寄存器多了以后编译器可以针对程序做更多的优化,性能也提高更多。X86-64CPU寄存器分别是:

%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15
  • 其中%rax作为返回值使用。
  • %rsp指向栈顶。
  • %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数。。。
  • %rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
  • %r10,%r11 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值。

栈帧

C语言最大特点就是讲函数过程分解为若干个过程(函数)而在程序运行的时候,这些函数就变成了栈帧,其中%ebp指向栈基地址,%esp指向栈顶地址用于区分一个栈。一个程序堆栈图如下:

image

调用分析

首先写一段简单函数调用代码,如下:

/**
 * Created by suli on 8/6/17.
 */

#include <stdio.h>

int function1(int a, int b) {
	int c = a;
	return 1;
}

void function2(int a) {
	int first = 5;
	int second = 6;
	int ret = function1(first, second);
}

int main(void) {
	int tmp = 1;
	function2(tmp);
	return 0;
}

在获得汇编代码的时候我们有几种方法,首先可以使用g++来直接把代码编译为汇编代码:

g++ -E stack.cc -o stack.i  //第一步我们先用-E命令来生成预处理文件

g++ -S stack.i -o stack.s   //使用预处理文件来编译成汇编程序

但是这种方法得到汇编是没有经过处理的汇编,里面还带有操作系统对程序的保护机制,比如下面是我用linux编译的汇编程序一小段。

_Z9function1ii:
.LFB0:
	.cfi_startproc
	.cfi_personality 0x3,__gxx_personality_v0
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	%edi, -20(%rbp)
	movl	%esi, -24(%rbp)
	movl	-20(%rbp), %eax
	movl	%eax, -4(%rbp)
	movl	$1, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

上面是function1函数的汇编代码,代码里面除了正常的程序逻辑外还加上了gcc为了调试而加上的调试信息和gcc为了保护堆栈不被恶意破坏而添加的堆栈保护代码。gcc的保护策略很有意思可以详细研究Buffer overflow protectionStackGuard: Simple Stack Smash Protection for GCC抽时间关于堆栈保护我也专门写一篇文章。

所以我们需要使用另外一种方法来获取比较干净的汇编代码,首先编译可执行程序:

g++ -g -O0 -fno-stack-protector stack.cc

-g是命令是添加编译调试信息,-O0是禁止使用编译优化,这样可以让编译器编译出所有细节部分, -fno-stack-protector用来关闭gcc针对程序所加的堆栈保护机制。这样函数的栈帧会更加自然不会出现其他乱数据。

应为没有指定目标文件名,我们得到可执行程序名为a.out,然后使用objdump工具来反汇编可执行程序,我们使用下面的命令来打印出对应的汇编代码、C代码和机器码。

objdump -dS a.out -j .text > code.log

这是我们就可以获取反汇编后的代码。我摘取其中main函数、function1、function2三个函数的汇编代码

0000000000400554 <_Z9function1ii>:
 * Created by suli on 8/6/17.
 */

#include <stdio.h>

int function1(int a, int b) {
  400554:	55                   	push   %rbp
  400555:	48 89 e5             	mov    %rsp,%rbp
  400558:	89 7d ec             	mov    %edi,-0x14(%rbp)
  40055b:	89 75 e8             	mov    %esi,-0x18(%rbp)
	int c = a;
  40055e:	8b 45 ec             	mov    -0x14(%rbp),%eax
  400561:	89 45 fc             	mov    %eax,-0x4(%rbp)
	return 1;
  400564:	b8 01 00 00 00       	mov    $0x1,%eax
}
  400569:	c9                   	leaveq
  40056a:	c3                   	retq

000000000040056b <_Z9function2i>:

void function2(int a) {
  40056b:	55                   	push   %rbp
  40056c:	48 89 e5             	mov    %rsp,%rbp
  40056f:	48 83 ec 18          	sub    $0x18,%rsp
  400573:	89 7d ec             	mov    %edi,-0x14(%rbp)
	int first = 5;
  400576:	c7 45 f4 05 00 00 00 	movl   $0x5,-0xc(%rbp)
	int second = 6;
  40057d:	c7 45 f8 06 00 00 00 	movl   $0x6,-0x8(%rbp)
	int ret = function1(first, second);
  400584:	8b 55 f8             	mov    -0x8(%rbp),%edx
  400587:	8b 45 f4             	mov    -0xc(%rbp),%eax
  40058a:	89 d6                	mov    %edx,%esi
  40058c:	89 c7                	mov    %eax,%edi
  40058e:	e8 c1 ff ff ff       	callq  400554 <_Z9function1ii>
  400593:	89 45 fc             	mov    %eax,-0x4(%rbp)
}
  400596:	c9                   	leaveq
  400597:	c3                   	retq

0000000000400598 <main>:

int main(void) {
  400598:	55                   	push   %rbp
  400599:	48 89 e5             	mov    %rsp,%rbp
  40059c:	48 83 ec 10          	sub    $0x10,%rsp
	int tmp = 1;
  4005a0:	c7 45 fc 01 00 00 00 	movl   $0x1,-0x4(%rbp)
	function2(tmp);
  4005a7:	8b 45 fc             	mov    -0x4(%rbp),%eax
  4005aa:	89 c7                	mov    %eax,%edi
  4005ac:	e8 ba ff ff ff       	callq  40056b <_Z9function2i>
	return 0;
  4005b1:	b8 00 00 00 00       	mov    $0x0,%eax
  4005b6:	c9                   	leaveq
  4005b7:	c3                   	retq
  4005b8:	90                   	nop
  4005b9:	90                   	nop
  4005ba:	90                   	nop
  4005bb:	90                   	nop
  4005bc:	90                   	nop
  4005bd:	90                   	nop
  4005be:	90                   	nop
  4005bf:	90                   	nop

下面我们开始分析一下整个函数的运行过程:

其实在main函数执行前其实还有个函数.start函数来引导程序进入main函数执行,这部分不再分析,直接从main函数的栈帧来看函数的调用过程。

    400598:	55                   	push   %rbp
    400599:	48 89 e5             	mov    %rsp,%rbp

首先将当前栈基指针rbp压入栈中,记录上一个栈的栈基地址,然后下一条命令是将上一个栈的栈顶指针给rbp,这时候%rbp相当于处在新开辟一个栈的栈基了。此时rbp已经准备好了,只剩下sp指针来分配栈空间了,所以下一步代码:

    40059c:	48 83 ec 10          	sub    $0x10,%rsp

由于Linux栈地址是从高到底的所以上面代码意思是在新的栈基地址上向下开辟了0x10字节的内存刚好是16字节。此时main函数的函数栈帧(stack-frame)已经开辟了。

    int tmp = 1;
    4005a0:	c7 45 fc 01 00 00 00 	movl   $0x1,-0x4(%rbp)

由于压栈的时候内存已经用了4个字节(此处有一个疑问,X64-64系统中,push栈应该是64位8个字节?),所以执行movl命令,该命令是32位操作,将0x1的长度为4个字节。

    4005a7:	8b 45 fc             	mov    -0x4(%rbp),%eax
    4005aa:	89 c7                	mov    %eax,%edi

由于64位系统中参数传递直接用寄存器传递,所以此处将刚才设置的1的函数参数放在eax作为function2函数的参数。

     4005ac:	e8 ba ff ff ff       	callq  40056b <_Z9function2i>

执行callq命令跳转到function2的地址部分,进入function2函数内.此时栈帧的结构如下图所示:

函数的栈帧

在进入function2的函数中后:

    40056b:	55                   	push   %rbp
    40056c:	48 89 e5             	mov    %rsp,%rbp

同样执行这两个步骤再把rbp压栈,把sp指针给rbp,此时的main函数栈帧的顶部变成了新函数栈帧的底部,新的栈帧开始建立

    40056f:	48 83 ec 18          	sub    $0x18,%rsp
    400573:	89 7d ec             	mov    %edi,-0x14(%rbp)

这两句第一句先把rsp指针向下移动0x18字节,开辟了一个新的栈帧出来。下一句开始吧edi寄存器的内容拷贝到rbp-0x14字节处,这个%edi寄存器不知道还有没有印象,是main函数中传参数调用function2时候的参数a的值,也就是tmp的值。此处有一个小细节在原来32位cpu的时候,gcc的传参数更多的是使用在堆栈中开辟空间来拷贝参数。此时已经ba把参数拷贝到了函数的栈里面了。

    int first = 5;
    400576:	c7 45 f4 05 00 00 00 	movl   $0x5,-0xc(%rbp)
	int second = 6;
    40057d:	c7 45 f8 06 00 00 00 	movl   $0x6,-0x8(%rbp)

这两句是在新的栈帧里面创建局部变量,都是用的movl32位操作指令。

    400584:	8b 55 f8             	mov    -0x8(%rbp),%edx
    400587:	8b 45 f4             	mov    -0xc(%rbp),%eax
    40058a:	89 d6                	mov    %edx,%esi
    40058c:	89 c7                	mov    %eax,%edi

这四句是在传递function1两个参数前的准备过程,先把6放到edx寄存器中,再把5放到eax中去,进而再把edx和eax放到esi和edi中去,作为两个传参数寄存器。

    40058e:	e8 c1 ff ff ff       	callq  400554 <_Z9function1ii>

此时进入function1函数内。

    400554:	55                   	push   %rbp
    400555:	48 89 e5             	mov    %rsp,%rbp
    400558:	89 7d ec             	mov    %edi,-0x14(%rbp)
    40055b:	89 75 e8             	mov    %esi,-0x18(%rbp)

同样是保留上一个栈帧的地址,其实也是返回上层函数的地址 然后把函数参数拷贝过来,分别放在了(%rbp)-0x14和(%rbp)-0x18地址处。

    int c = a;
    40055e:	8b 45 ec             	mov    -0x14(%rbp),%eax
    400561:	89 45 fc             	mov    %eax,-0x4(%rbp)

在funtion1函数内创建局部变量可以看到也是在rbp-4的字节处开始使用(请读者说明原因为什么减4字节)

    return 1;
    400564:	b8 01 00 00 00       	mov    $0x1,%eax

下面开始了函数栈帧的逐步回退了,首先先把返回值放到eax中,前面说过eax可以用来存放返回值的寄存器,gcc编译器首先会把返回值放入到eax中,执行下面两个命令:

    400569:	c9                   	leaveq
    40056a:	c3                   	retq

leaveq是下面两条汇编的合成:

    mov   %rbp %rsp
    pop   %rbp

恢复现场到上一个栈帧的地址,然后调用retq指令 该指令执行步骤是先把esp指针指向地址给eip寄存器,然后执行call命令执行下一个指令。此时函数已经回退到上一个栈帧内。 最后我画了一张图来直观把内存分布和跳转详细画出来,注意我省略了栈帧中其他空余的内存(这里保留一个疑问,空余内存可能是字节对齐的结果,具体原因还未清楚)

完全堆栈

不知道这张图是否把调用过程表达出来了。

总结

本篇文章首先介绍了X86-64寄存器的一些知识,作为铺垫,下面展开了函数堆栈的详细调用过程,其中在讲解中为了简单去掉了一些堆栈保护功能和编译器的优化,力争原汁原味讲出调用过程,代码放在了我的github上欢迎下载。


Similar Posts

Comments