函数调用栈

由于不是科班出生,又是自学开发,对很多方面的知识都是只知其然而不知其所以然。加上最近公司事情不多,刚好乘此机会把长毛了小本本又翻了出来,希望能每天学习一点点,每天进步一点点。

之前没有接触过底层方面,所以这篇文章,会通过菜鸟视角,从基础概念到实战演练,一步步的揭开函数调用背后,寄存器,堆栈都干了些什么。

1. 栈区(stack)

高地址向低地址生长的一块连续的内存区域,所以栈顶地址和栈的最大容量都是系统预先规定好的;

编译器自动管理;

方式类似数据结构中的栈,后入先出(LIFO);

每个进程在用户态对应一个调用栈结构;

存放函数参数和返回值,函数局部变量(不包括 static 声明的变量,它们存放在静态变量区);

高效快速,但大小限制,数据不灵活(支持数据类型有限,一般是整型,指针,浮点型等系统直接支持的数据类型);

2. 栈帧(stack frame)

函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息,每个未完成运行的函数占用一个独立连续区域(包含这个函数涉及的参数,局部变量,返回地址等相关信息),称为栈帧。

当调用函数时,就要压入一个新的栈帧,发起调用函数的栈帧成为调用者栈帧,被调用函数的栈帧则称为当前栈帧(rsprbp 之间的内存空间);被调用的函数运行结束后回收栈帧,回到调用者栈帧。这一过程都是自动的,由系统分配与销毁,无需手动调度。

3. 寄存器 (register)

x86-64中,所有寄存器都是64位,相对32位的x86来说,标识符发生了变化,比如:从原来的 %ebp 变成了 %rbp。为了向后兼容性,%ebp 依然可以使用,不过指向了 %rbp 的低32位。

2017-07-31-memory-3.png

rip 指令地址寄存器,用来存储 CPU 即将要执行的指令地址。每次 CPU 执行完相应的汇编指令之后,rip 寄存器的值就会自行累加;rip 无法直接赋值,call, ret, jmp 等指令可以修改 rip

rbp 栈基地址寄存器,保存当前帧的栈底地址。

rsp 栈指针寄存器,保存当前栈顶。

栈帧中,最重要的是帧指针 rbp 和栈指针 rsp,有了这两个指针,我们就可以刻画一个完整的栈帧。

3.1 寄存器保存惯例

调用者栈帧需要寄存器暂存数据,被调用者栈帧也需要寄存器暂存数据。

如果调用者使用了 rbx,那被调用者就需要在使用之前把 rbx 保存起来,然后在返回调用者栈帧之前,恢复 rbx。遵循该使用规则的寄存器就是被调用者保存寄存器,对于调用者来说, rbx 就是非易失的。

调用者使用 r10 存储局部变量,为了能在子函数调用后还能使用 r10,调用者把 r10 先保存起来,然后在子函数返回之后,再恢复 r10。遵循该使用规则的寄存器就是调用者保存寄存器,对于调用者来说, r10 就是易失的。

4. 函数调用栈

4.1 参数入栈

参数从右向左依次入栈(支持可变参数)。

x86-64 中,有 6 个寄存器来存储参数,多于 6 个参数,依然还是通过入栈实现。

4.2 返回地址入栈

实际代码中我们是看不到 push rip 这句的;

它是包含在 call 指令之中的 call function = push rip + jmp function

x86 Instruction Set Reference

4.3 代码区跳转

它是包含在 call 指令之中的 call function = push rip + jmp function

4.4 栈帧调整

  1. 将调用帧的 push %rbp 入栈。
  2. 切换栈帧到当前栈帧 movq %rsp, %rbp
  3. 抬高栈顶,分配临时数据区 subq &xx, %rsp

5 实例测试

Xcode 新建工程,main.c 文件:

#include <stdio.h> //line 9

char* get_memory(char *a, char *b) {
    char p[]="hello world";
    return p;
}

int main(int argc, const char * argv[]) {
    // insert code here...
    char* str = NULL;
    char* a = "good";
    int   b = 3;
    float  d = 12345.67890;
    
    str = get_memory("h", "w");
    printf("%s",str);
    return 0;
}

5.1 实例分析

选中 main.c 文件,x86-64环境 Product -> Perform Action -> Assemble 'main.c'

生成的代码中会有很多 . 开头的,例如 .loc.section 等等,这些都是汇编器需要的,我们可以直接忽略,这篇文章对这些指令做了一些说明,清除掉它们和相关注释后我们重点关注 main 函数:

    .section    __TEXT,__text,regular,pure_instructions     //.section __TEXT 只读和可执行的代码段
    .macosx_version_min 10, 12
    .file   1 ".../Project/test" ".../Project/test/test/main.c"
    .globl  _get_memory     //`_get_memory` 是一个外部符号(Symbol),对于二进制文件外部可见。
    .p2align    4, 0x90     //指出了后面代码的对齐方式。在我们的代码中,后面的代码会按照 16(2^4) 字节对齐,如果需要的话,用 0x90 补齐。
_get_memory:                            ## @get_memory
Lfunc_begin0:
    .cfi_startproc
## BB#0:
    pushq   %rbp
Ltmp0:
    .cfi_def_cfa_offset 16
Ltmp1:
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
Ltmp2:
    .cfi_def_cfa_register %rbp
    subq    $48, %rsp
    leaq    -20(%rbp), %rax
    movq    ___stack_chk_guard@GOTPCREL(%rip), %rcx 
    movq    (%rcx), %rcx
    movq    %rcx, -8(%rbp)
    movq    %rdi, -32(%rbp)
    movq    %rsi, -40(%rbp)
Ltmp3:
    ##DEBUG_VALUE: get_memory:p <- %RAX
    movq    L_get_memory.p(%rip), %rcx
    movq    %rcx, -20(%rbp)
    movl    L_get_memory.p+8(%rip), %edx
    movl    %edx, -12(%rbp)
    movq    ___stack_chk_guard@GOTPCREL(%rip), %rcx
    movq    (%rcx), %rcx
    movq    -8(%rbp), %rsi
    cmpq    %rsi, %rcx
    movq    %rax, -48(%rbp)         ## 8-byte Spill
Ltmp4:
    ##DEBUG_VALUE: get_memory:p <- [%RBP+-48]
    jne LBB0_2  //如果 rsi 和 rcx 不相等,那么就跳转到 LBB0_2
## BB#1:
    ##DEBUG_VALUE: get_memory:p <- [%RBP+-48]
    movq    -48(%rbp), %rax         ## 8-byte Reload
    addq    $48, %rsp
    popq    %rbp
    retq
LBB0_2:
    ##DEBUG_VALUE: get_memory:p <- [%RBP+-48]
    callq   ___stack_chk_fail
Ltmp5:
Lfunc_end0:
    .cfi_endproc

    .section    __TEXT,__literal4,4byte_literals
    .p2align    2
LCPI1_0:
    .long   1178658487              ## float 12345.6787

    .section    __TEXT,__text,regular,pure_instructions     
    .globl  _main
    .p2align    4, 0x90     
_main:                                  ## @main
Lfunc_begin1:
    .cfi_startproc                  //函数开始标识,用于初始化某些内部数据结构
## BB#0:
    pushq   %rbp                    //保存调用者的栈帧基址--控制链
Ltmp6:
    .cfi_def_cfa_offset 16          //此处距离 CFA 16 字节(用的 rsp 计算)
Ltmp7:
    .cfi_offset %rbp, -16           //rbp 的值,保存在距离 CFA 16 字节处
    movq    %rsp, %rbp              //设置新的栈帧基址
Ltmp8:
    .cfi_def_cfa_register %rbp      //修改计算 CFA 所用的寄存器,设成 rbp
    subq    $48, %rsp               //分配临时数据区
    leaq    L_.str.1(%rip), %rax    //将 L_.str.1 的指针加载到 rax 寄存器 "h"
    leaq    L_.str.2(%rip), %rcx    //将 L_.str.2 的指针加载到 rcx 寄存器 "w"
    movss   LCPI1_0(%rip), %xmm0    ## xmm0 = mem[0],zero,zero,zero //将 LCPI1_0 的单精度值加载到 xmm0 寄存器的低双字
    leaq    L_.str(%rip), %rdx      //将 L_.str  的指针加载到 rdx 寄存器 "good"
    movl    $0, -4(%rbp)            
    movl    %edi, -8(%rbp)          //将第一个参数(int argc)的值存在 rbp 低位偏移8字节
    movq    %rsi, -16(%rbp)         //将第二个参数(char *argv[])的值存在 rbp 低位偏移16字节
Ltmp9:
    movq    $0, -24(%rbp)           //第一个变量(char* str)值为0,存在 rbp 低位偏移24字节
    movq    %rdx, -32(%rbp)         //第二个变量(char* a)值为 rdx 的值(即前面 L_.str 的指针),存在 rbp 低位偏移32字节
    movl    $3, -36(%rbp)           //第三个变量(int b)值为3,存在 rbp 低位偏移36字节
    movss   %xmm0, -40(%rbp)        //第四个变量(float d)值为 xmm0 的值,存在 rbp 低位偏移40字节
    movq    %rax, %rdi              //将 get_memory 函数第一个参数值(之前存在 rax 寄存器的指针)设置到寄存器 edi
    movq    %rcx, %rsi              //将 get_memory 函数第二个参数值(之前存在 rcx 寄存器的指针)设置到寄存器 rsi
    callq   _get_memory             //调用 get_memory 函数
    leaq    L_.str.3(%rip), %rdi    //将 printf 函数第一个参数(L_.str.3 的指针)加载到 rdi 寄存器中 "%s"
    movq    %rax, -24(%rbp)         //将 get_memory 返回值设置给前面初始化过的第一个变量
    movq    -24(%rbp), %rsi         //将 printf 函数第第二个参数(char* str)设置到寄存器 rsi
    movb    $0, %al                 //printf 是可变参数函数,ABI 调用约定指定,将会把使用来存储参数的寄存器数量存储在寄存器 al 中,这里是 0
    callq   _printf                 //调用 printf 函数
    xorl    %r8d, %r8d              //清 0 r8d 寄存器
    movl    %eax, -44(%rbp)         ## 4-byte Spill //printf 的返回值存在 rbp 低位偏移44字节
    movl    %r8d, %eax              //清 0 eax 低32位
    addq    $48, %rsp               //堆栈指针 rsp 上移 48 字节
    popq    %rbp                    //之前存储至 rbp 中的值弹出
    retq                           
Ltmp10:
Lfunc_end1:
    .cfi_endproc                    //与开始时的 .cfi_startproc 对应,结束

    .section    __TEXT,__cstring,cstring_literals
L_get_memory.p:                         ## @get_memory.p
    .asciz  "hello world"
L_.str:                                 ## @.str
    .asciz  "good"
L_.str.1:                               ## @.str.1
    .asciz  "h"
L_.str.2:                               ## @.str.2
    .asciz  "w"
L_.str.3:                               ## @.str.3
    .asciz  "%s"

5.2 栈图

2017-07-31-memory-4.png

5.3 说明

CFI 是调用框架指令(Call Frame Information)缩写,提供的调用框架信息, 为实现堆栈回绕(stack unwiding)或异常处理(exception handling)提供了方便。

  • .cfi_startproc 用于函数开始,.cfi_endproc 用于函数结束,两者配套使用。

  • .cfi_def_cfa_offset 16 指令表示此处(rsp)距离 CFA 16 字节。

    CFA(Canonical Frame Address)是标准框架地址,指调用者栈帧中调用点处的栈指针值。

    .cfi_def_cfa_offset offset modifies a rule for computing CFA(Canonical Frame Address). Register remains the same, but offset is new. Note that it is the absolute offset that will be added to a defined register to compute CFA address.

  • .cfi_offset %rbp, -16 指令表示 rbp 的值保存在距离 CFA 16 字节。

    rbp 是被调用者保存寄存器,按照惯例,被调者在使用之前要保存起来。

  • .cfi_offset register %rbp 指令表示,从这里开始,使用 rbp 作为计算 CFA 的基址寄存器:

    1. 在这之前的 cfi_def_cfa_offset 16 用的是 rsp
    2. 前一条指令 movq %rsp, %rbp 已经将 rsp 设置为新的 rbp
  • movl $3, -36(%rbp)

    1. % 用于直接寻址寄存器,$ 表示立即数。 movl $3, %rbp 表示把立即数 3 存到 rbp 中。
    2. () 用于内存间接寻址,movl $3, (%rbp) 表示将立即数 3 保存到 rbp 所指向的内存地址中。
    3. -36(%rbp) 表示先找到 rbp 所指向地址,再 -36 后所得到的地址。
  • 浮点数存储方式

    LCPI1_0:
    .long   1178658487              ## float 12345.6787
    ...
    movss LCPI1_0(%rip), %xmm0
    ...
    

    单精度浮点数的存储方式: | 1位符号数 | 8位指数 | 23位尾数 |

    1. 12345.67890 被转成了了十进制数 1178658487
    2. 我们把1178658487 转换成二进制 0 10001100 10000001110011010110111
    3. 第一位符号位 0 表示正数;
    4. 第二位到第九位转为十进制 140
    5. 实际指数 = 140 - 127 = 13

      由于指数需要表示正负两种数据,IEEE标准规定单精度指数以127为分割线,实际存储的数据是指数加127所得结果,127为高位为零,后7位为1所得,其他双精度也以此方式计算。
      6. 尾数加上 1.1.10000001110011010110111 扩大2 ^ 23 次方为 110000001110011010110111 十进制 12641975
      7. 12641975 / 2 ^ (23 - 13(步骤5算出的指数)) = 12641975 / 1024 = 12345.6787109375;

5.4 刨根

接下来,我们直接深入内存,来验证一下我们上面是否在一本正经的胡说八道。

Xcode 中勾选 Debug -> Debug Workflow -> Always Show Disassembly 后,在 main 方法打断点,就能进入汇编调试界面。

2017-07-31-memory-5.png
  • All 的位置,默认选项是 auto,看不到寄存器状态。
  • 也可以用 register read 指令查看寄存器状态。

直接在 callq 0x100000c90 ; get_memory at main.c:11 位置,检查进入 get_memory 方法之前的寄存器和栈,是否和我们上面的栈图吻合。

息栏可以看到各寄存器中保存的信息,可以直接看到 rbp 指向的地址 0x00007fff5fbff6e0

subq $0x30, %rsp 这句将栈抬高了 48 字节,所以我们查看内存的时候要从 0x7FFF5FBFF6B0 开始。

上方菜单栏 Debug -> Debug Workflow -> View Memory 打开内存调试界面。

2017-07-31-memory-6.png

为了方便查看,我将图中的内存,按照我们之前的栈图进行了调整:

0x7fff5fbff6e0      rbp
0x7fff5fbff6dc      00 00 00 00                 0                 -4(%rbp)
0x7fff5fbff6d8      01 00 00 00                 1                 -8(%rbp)
0x7fff5fbff6d0      00 F7 BF 5F FF 7F 00 00     00007FFF5FBFF700  -16(%rbp)
0x7fff5fbff6c8      00 00 00 00 00 00 00 00     0                 -24(%rbp)
0x7fff5fbff6c0      5C 0F 00 00 01 00 00 00     0000000100000F5C  -32(%rbp)
0x7fff5fbff6bc      03 00 00 00                 3                 -36(%rbp)
0x7fff5fbff6b8      B7 E6 40 46                 12345.67890       -40(%rbp)
0x7fff5fbff6b4      00 00 00 00                 0                 -44(%rbp)
0x7fff5fbff6b0      00 00 00 00                 0                 -48(%rbp)

根据栈图 -32(%rbp) 位置应该存放的是 "good" 的内存地址,同样的步骤直接查看地址 0x0000000100000F5C

2017-07-31-memory-7.png

Xcode 已经帮我们标出来16进制对应的ASCII可显示内容了,当然也可以到这里对照验证一下。

参考

用户态和内核态的概念区别

iOS内核架构浅谈

X86-64寄存器和栈帧

C程序的函数栈作用机理

X86汇编调用框架浅析与CFI简介

几种基本汇编指令详解

xcode反汇编调试iOS模拟器程序

浮点数如何存储

详解大端模式和小端模式

Mach-O 可执行文件

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容

  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,598评论 1 19
  • 栈: 在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函...
    zjfclimin阅读 3,954评论 0 5
  • 阅读经典——《深入理解计算机系统》04 函数调用时的栈结构变化是一个很有趣的话题,本文就来详细剖析这个过程。 栈帧...
    金戈大王阅读 23,193评论 14 36
  • 首先先看图: 在main函数调用func_A的时候,首先在自己的栈帧中压入函数返回地址,然后为func_A创建新栈...
    zjfclimin阅读 7,314评论 1 2
  • 人生难得相伴游, 舒畅怡情自忘忧。 世界珍奇遗九寨, 山清水秀刻心留!
    张邦祥阅读 129评论 0 2