C++函数调用过程解析

Wesley13
• 阅读 345

用一个简单的例子解释C++函数调用的过程,备忘。

实验环境

以下是本次实验的环境配置

* 操作系统: Ubuntu 14.04 x86_64
* 编译器: gcc-4.8.2

开始之前

阅读资料

开始之前,建议先阅读如下几篇文章,对call stack和asm多少有点了解,下文会涉及到很多这方面的东西。

预备知识

下面是一些在后面的解释中会用到的知识,以下说明均基于x86-64平台。

  1. 栈(call stack, runtime stack或stack): 在Linux上我们编译程序后一般生成的可执行文件是ELF格式的,下图是一个简化版的ELF文件结构[1]

     +----------------------+
     |       ... ...        |
     +----------------------+
     |        .text         | <- 程序的可执行指令
     +----------------------+
     |       ... ...        |
     +----------------------+
     |        .data         | <- 初始化的全局和静态变量
     +----------------------+
     |        .bss          | <- 未初始化的全局和静态变量
     +----------------------+
     |       ... ...        |
     +----------------------+
    

    可执行文件将会在运行时被载入内存中,下面是一个简化的进程虚拟地址空间图。

     +----------------------+ <- 高地址处
     |       ... ...        |
     +----------------------+
     |        stack         | <- 本文的主角,call stack,向低地址处生长
     +----------------------+
     |       ... ...        |
     +----------------------+
     |        heap          | <- 动态分配的内存,向高地址处生长
     +----------------------+
     |  uninitialized data  | <- 未初始化的全局变量和静态变量
     +----------------------+
     |   initialzed data    | <- 初始化的全局和静态变量,从ELF的.data区载入
     +----------------------+
     |        code          | <- 程序代码,从ELF的.text区载入
     +----------------------+
     |       ... ...        |
     +----------------------+ <- 低地址处
    
  2. 寄存器:

    • %rax: 通常用于返回第一个整数。
    • %rbp: base pointer,指向当前frame的底部附近(caller的%rbp)。
    • %rsp: stack pointer,用于保存最新分配的frame的顶部,也就是stack顶部。
  3. 调用者(caller)和被调用者(callee): 在函数foo的上下文中,调用者(caller)就是main函数,被调用者(callee)就是foo函数。

  4. stack frame: stack是由一个个的stack frmae组成的,每次函数调用都会在栈上分配一个新的frame,该frame内保存了当前函数调用的上下文信息,包括请求参数(可能直接保存在寄存器中[2]),返回地址和局部变量等内容。

  5. 返回地址: callq指令会在调用函数的时候将下一条指令的地址push到stack上,当本次调用结束后,retq指令会跳转到被保存的返回地址处使程序继续执行。

  6. stack上的数据是字节对齐的。

  7. stack由高地址处向低地址处生长,在下图中16(%rbp)表示地址16 + value of register[%rbp]

     +----------------------+ <- 高地址处
     |       ... ...        |
     +----------------------+
     |      parameters      | <- 没有保存到寄存器里的callee的函数参数(如果有的话)
     +----------------------+ <- 16(%rbp) + n*8 n=0,1,2,3 ...
     |    return address    | <- callee的返回地址,callee的stack frame开始处
     +----------------------+ <- 8(%rbp)
     |     caller's %rbp    | <- 保存的是caller的%rbp
     +----------------------+ <- (%rbp)
     |    saved registers   | <- 最开始是在函数调用中会被占用的寄存器(如果有的话),调用结束后恢复
     +----------------------+
     | callee's locals etc. | <- 之后是函数内定义的局部变量和临时变量(保存函数参数寄存器等)
     +----------------------+
     |       ... ...        |
     +----------------------+
     |      parameters      | <- callee的callee的函数参数(如果有的话)
     +----------------------+ <- n*8(%rsp) n=0,1,2,3 ...
     |       ... ...        | <- 栈顶,calleee的stack frame结束处
     +----------------------+ <- (%rsp)
     |       ... ...        |
     +----------------------+ <- 低地址处
    

    当callee的函数参数太多(或存在不能存储到寄存器上的参数类型)时,多余的函数参数会按照从右向左的顺序依次入栈,占用caller的stack frame的空间。[3]

  8. 汇编指令:

    • leaveq: 相当于以下两条指令[4]

      mov %rbp, %rsp // 恢复rsp,将stack frame里的局部变量出栈 pop %rbp // 恢复caller的rbp中

    • callq: 将下一条指令的地址入栈,然后跳转到目标地址处执行[5]

    • retq: 将返回地址出栈并跳转到该地址处继续执行[5]

简单的例子

下面是一个非常简单的例子。

cpp源代码

// call_stack_example.cpp
int foo2(int a, long b, int c, int d, int e, int f, int g, int i) {
    return a + b;
}
int foo(int &a, long b) {
    int m = 1;
    int o[3] = {0x1, 0x2, 0x3};
    return foo2(0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x9);
}
int main() {
    int z = 0xa;
    int r = foo(z, 0xb);
    return r;
}

使用如下命令编译call_stack_example.cpp

g++ -g -O0 -fno-stack-protector call_stack_example.cpp -o a.out

为了让汇编代码更简单,我们在编译选项里使用-fno-stack-protector,这可以禁止gcc默认启用的Stack Protection[6],关于Stack Protection的详细信息可参考Buffer overflow protectionStackGuard: Simple Stack Smash Protection for GCC

汇编代码和注释

然后使用objdump输出汇编代码

objdump -dS a.out -j .text

下面是call_stack_example.cpp内三个函数的汇编代码和注释,gcc默认使用的是AT&T汇编语法。

int foo2(int a, long b, int c, int d, int e, int f, int g, int i) {
  push %rbp               // 将caller的%rbp入栈
  mov %rsp,%rbp           // 初始化callee的%rbp
  mov %edi,-0x4(%rbp)     // a: mem[R[rbp]-0x4] = R[edi]
  // 暂存寄存器的值,使其可以被重用,使用-O选项可以优化掉这部分代码
  mov %rsi,-0x10(%rbp)    // b
  mov %edx,-0x8(%rbp)     // c
  mov %ecx,-0x14(%rbp)    // d
  mov %r8d,-0x18(%rbp)    // e
  mov %r9d,-0x1c(%rbp)    // f

return g + i;
  mov 0x18(%rbp),%eax     // g在caller的stack frame的底部
  mov 0x10(%rbp),%edx     // i在caller的stack frame的底部
  add %edx,%eax           // R[eax] += R[edx]
}
  pop %rbp    // 将caller的rbp出栈(恢复%rbp)
  retq        // 将返回地址出栈,跳转到该地址处

int foo(int &a, long b) {
  push %rbp             // 将caller的%rbp入栈
  mov %rsp,%rbp         // 初始化callee的%rbp
  sub $0x30,%rsp        // 为当前stack frame分配0x30字节的空间
  mov %rdi,-0x18(%rbp)  // mem[R[rbp]-0x18] = R[rdi]
  mov %rsi,-0x20(%rbp)  // mem[R[rbp]-0x20] = R[rsi]

int m = 1;
  movl $0x1,-0x4(%rbp)  // mem[R[rbp]-0x4] = 0x1

int o[3] = {0x1, 0x2, 0x3};
  movl $0x1,-0x10(%rbp) // mem[R[rbp]-0x10] = 0x1
  movl $0x2,-0xc(%rbp)  // mem[R[rbp]-0xc] = 0x1
  movl $0x3,-0x8(%rbp)  // mem[R[rbp]-0x10] = 0x1

return foo2(0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x9);
  movl $0x9,0x8(%rsp)   // mem[R[rsp]+0x8] = 0x9; 参数太多,无法用寄存器传递参数0x9
  movl $0x7,(%rsp)      // mem[$[rsp]] = 0x7; 参数太多,无法用寄存器传递参数0x7
  mov $0x6,%r9d         // R[r9d] = 0x6 使用寄存器传递函数参数,下同
  mov $0x5,%r8d         // R[r8d] = 0x5
  mov $0x4,%ecx         // R[ecx] = 0x4
  mov $0x3,%edx         // R[edx] = 0x3
  mov $0x2,%esi         // R[esi] = 0x2
  mov $0x1,%edi         // R[edi] = 0x1
  callq 4004ed <_Z4foo2iliiiiii> // 调用foo2
}
  leaveq    // 将caller的rbp出栈(恢复%rbp),将已保存的局部变量和临时变量出栈
  retq      // 将返回地址出栈,跳转到该地址处

int main() {
  push %rbp         // 将caller的%rbp入栈
  mov %rsp,%rbp     // 初始化callee的%rbp
  sub $0x10,%rsp    // 为当前stack frame分配0x10字节的空间

int z = 0xa;
  movl $0xa,-0x8(%rbp) // mem[R[%rbp]-0x8] = 0xa

int r = foo(z, 0xb);
  lea -0x8(%rbp),%rax   // R[rax] = &z
  mov $0xb,%esi         // R[esi] = 0xb
  mov %rax,%rdi         // R[rdi] = R[rax]
  callq 400513 <_Z3fooRil>  // 调用foo
  mov %eax,-0x4(%rbp)       // mem[R[rbp]-0x4] = R[eax] (%eax保存了之前foo的返回值)

return r;
  mov -0x4(%rbp),%eax   // R[eax] = mem[R[rbp]-0x4]
}
  leaveq    // 将caller的rbp出栈(恢复%rbp),将已保存的局部变量和临时变量出栈
  retq      // 将返回地址出栈,跳转到该地址处
  nopl (%rax)

其他例子

函数参数传递

下面的例子取自System V Application Binary Interface的图3.5 Parameter Passing Example和图3.6 Register Allocation Example

假设有以下结构体和函数

typedef struct {
    int a, b;
    double d;
} structparm;

extern void func(
    int e,
    int f,
    structparm s,
    int g,
    int h,
    long double ld,
    double m,
    __m256 y,
    double n,
    int i,
    int j,
    int k);

然后如下调用函数func:

structparm s;
int e, f, g, h, i, j, k;
long double ld;
double m, n;
__m256 y;

func (e, f, s, g, h, ld, m, y, n, i, j, k);

则函数参数的传递方式如下表

General Purpose Registers

Floating Point Registers

Stack Frame Offset

%rdi: e

%xmm0: s.d

0: ld

%rsi: f

%xmm1: m

16: j

%rdx: s.a, s.b

%ymm2: y[7]

24: k[8]

%rcx: g

%xmm3: n

 

%r8: h

 

 

%r9: i

 

 

额外阅读资料

  1. System V Application Binary Interface

  2. Gentle Introduction to x86-64 Assembly

  3. x86-64 Machine-Level Programming

  4. Review of assembly language

  5. Buffer Overflows and You

  6. Protection against buffer overflows

  7. Stack frame layout on x86-64

  8. BUFFER OVERFLOW 6: The Function Stack

  9. Linux x86 Program Start Up

  10. x64 Architecture

  11. X86_64 Assembly Language Tutorial: Part 1

  12. Amd64 Overview

  13. man elf,引用于2014-05-07。 

  14. 关于参数是否通过寄存器传递的具体细则,请参考System V Application Binary Interface3.2.3 Parameter Passing部分。 

  15. 参考System V Application Binary Interface的3.2 Function Calling Sequence,引用于2014-05-07。 

  16. Wikipedia:function prologue, 引用于2014-05-04。 

  17. Call vs Jmp: The Stack Connection,引用于2014-05-05。  

  18. 参考man gcc中关于-fstack-protector选项的说明。 

  19. %ymm0-%ymm15是256位的浮点数寄存器,其低128位对应%xmm0-%xmm15 

  20. 栈上的函数参数要按8字节对齐 

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Stella981 Stella981
2年前
Opencv中Mat矩阵相乘——点乘、dot、mul运算详解
Opencv中Mat矩阵相乘——点乘、dot、mul运算详解2016年09月02日00:00:36 \牧野(https://www.oschina.net/action/GoToLink?urlhttps%3A%2F%2Fme.csdn.net%2Fdcrmg) 阅读数:59593
Stella981 Stella981
2年前
KVM调整cpu和内存
一.修改kvm虚拟机的配置1、virsheditcentos7找到“memory”和“vcpu”标签,将<namecentos7</name<uuid2220a6d1a36a4fbb8523e078b3dfe795</uuid
Easter79 Easter79
2年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Stella981 Stella981
2年前
HIVE 时间操作函数
日期函数UNIX时间戳转日期函数: from\_unixtime语法:   from\_unixtime(bigint unixtime\, string format\)返回值: string说明: 转化UNIX时间戳(从19700101 00:00:00 UTC到指定时间的秒数)到当前时区的时间格式举例:hive   selec
Wesley13 Wesley13
2年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Wesley13 Wesley13
2年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
3个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这