深入浅出计算机组成原理
徐文浩
bothub创始人
立即订阅
13019 人已学习
课程目录
已完结 62 讲
0/4登录后,你可以任选4讲全文学习。
入门篇 (5讲)
开篇词 | 为什么你需要学习计算机组成原理?
免费
01 | 冯·诺依曼体系结构:计算机组成的金字塔
02 | 给你一张知识地图,计算机组成原理应该这么学
03 | 通过你的CPU主频,我们来谈谈“性能”究竟是什么?
04 | 穿越功耗墙,我们该从哪些方面提升“性能”?
原理篇:指令和运算 (12讲)
05 | 计算机指令:让我们试试用纸带编程
06 | 指令跳转:原来if...else就是goto
07 | 函数调用:为什么会发生stack overflow?
08 | ELF和静态链接:为什么程序无法同时在Linux和Windows下运行?
09 | 程序装载:“640K内存”真的不够用么?
10 | 动态链接:程序内部的“共享单车”
11 | 二进制编码:“手持两把锟斤拷,口中疾呼烫烫烫”?
12 | 理解电路:从电报机到门电路,我们如何做到“千里传信”?
13 | 加法器:如何像搭乐高一样搭电路(上)?
14 | 乘法器:如何像搭乐高一样搭电路(下)?
15 | 浮点数和定点数(上):怎么用有限的Bit表示尽可能多的信息?
16 | 浮点数和定点数(下):深入理解浮点数到底有什么用?
原理篇:处理器 (18讲)
17 | 建立数据通路(上):指令+运算=CPU
18 | 建立数据通路(中):指令+运算=CPU
19 | 建立数据通路(下):指令+运算=CPU
20 | 面向流水线的指令设计(上):一心多用的现代CPU
21 | 面向流水线的指令设计(下):奔腾4是怎么失败的?
22 | 冒险和预测(一):hazard是“危”也是“机”
23 | 冒险和预测(二):流水线里的接力赛
24 | 冒险和预测(三):CPU里的“线程池”
25 | 冒险和预测(四):今天下雨了,明天还会下雨么?
26 | Superscalar和VLIW:如何让CPU的吞吐率超过1?
27 | SIMD:如何加速矩阵乘法?
28 | 异常和中断:程序出错了怎么办?
29 | CISC和RISC:为什么手机芯片都是ARM?
30 | GPU(上):为什么玩游戏需要使用GPU?
31 | GPU(下):为什么深度学习需要使用GPU?
32 | FPGA和ASIC:计算机体系结构的黄金时代
33 | 解读TPU:设计和拆解一块ASIC芯片
34 | 理解虚拟机:你在云上拿到的计算机是什么样的?
原理篇:存储与I/O系统 (17讲)
35 | 存储器层次结构全景:数据存储的大金字塔长什么样?
36 | 局部性原理:数据库性能跟不上,加个缓存就好了?
37 | 高速缓存(上):“4毫秒”究竟值多少钱?
38 | 高速缓存(下):你确定你的数据更新了么?
39 | MESI协议:如何让多核CPU的高速缓存保持一致?
40 | 理解内存(上):虚拟内存和内存保护是什么?
41 | 理解内存(下):解析TLB和内存保护
42 | 总线:计算机内部的高速公路
43 | 输入输出设备:我们并不是只能用灯泡显示“0”和“1”
44 | 理解IO_WAIT:I/O性能到底是怎么回事儿?
45 | 机械硬盘:Google早期用过的“黑科技”
46 | SSD硬盘(上):如何完成性能优化的KPI?
47 | SSD硬盘(下):如何完成性能优化的KPI?
48 | DMA:为什么Kafka这么快?
49 | 数据完整性(上):硬件坏了怎么办?
50 | 数据完整性(下):如何还原犯罪现场?
51 | 分布式计算:如果所有人的大脑都联网会怎样?
应用篇 (5讲)
52 | 设计大型DMP系统(上):MongoDB并不是什么灵丹妙药
53 | 设计大型DMP系统(下):SSD拯救了所有的DBA
54 | 理解Disruptor(上):带你体会CPU高速缓存的风驰电掣
55 | 理解Disruptor(下):不需要换挡和踩刹车的CPU,有多快?
结束语 | 知也无涯,愿你也享受发现的乐趣
免费
答疑与加餐 (5讲)
特别加餐 | 我在2019年F8大会的两日见闻录
FAQ第一期 | 学与不学,知识就在那里,不如就先学好了
用户故事 | 赵文海:怕什么真理无穷,进一寸有一寸的欢喜
FAQ第二期 | 世界上第一个编程语言是怎么来的?
特别加餐 | 我的一天怎么过?
深入浅出计算机组成原理
登录|注册

07 | 函数调用:为什么会发生stack overflow?

徐文浩 2019-05-10
在开发软件的过程中我们经常会遇到错误,如果你用 Google 搜过出错信息,那你多少应该都访问过Stack Overflow这个网站。作为全球最大的程序员问答网站,Stack Overflow 的名字来自于一个常见的报错,就是栈溢出(stack overflow)。
今天,我们就从程序的函数调用开始,讲讲函数间的相互调用,在计算机指令层面是怎么实现的,以及什么情况下会发生栈溢出这个错误。

为什么我们需要程序栈?

和前面几讲一样,我们还是从一个非常简单的 C 程序 function_example.c 看起。
// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
这个程序定义了一个简单的函数 add,接受两个参数 a 和 b,返回值就是 a+b。而 main 函数里则定义了两个变量 x 和 y,然后通过调用这个 add 函数,来计算 u=x+y,最后把 u 的数值打印出来。
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
我们把这个程序编译之后,objdump 出来。我们来看一看对应的汇编代码。
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
可以看出来,在这段代码里,main 函数和上一节我们讲的的程序执行区别并不大,它主要是把 jump 指令换成了函数调用的 call 指令。call 指令后面跟着的,仍然是跳转后的程序地址。
这些你理解起来应该不成问题。我们下面来看一个有意思的部分。
取消
完成
0/1000字
划线
笔记
复制
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
该试读文章来自付费专栏《深入浅出计算机组成原理》,如需阅读全部文章,
请订阅文章所属专栏。
立即订阅
登录 后留言

精选留言(67)

  • CGer_AJ
    这个很好 讲的很细 这两章我要反复看 并手动实践 感觉作者 把这个课讲的生动形象~这个绝对是最值的课程
    2019-05-10
    25
  • kdb_reboot
    倒数第二图比较好
    补充一下寄存器说明
    rbp - register base pointer (start of stack)
    rsp - register stack pointer (current location in stack, growing downwards)
    建议将图编号这样评论的时候也能有所指代

    作者回复: kdb_reboot,谢谢建议。这个建议不错,我麻烦编辑稍后加上。

    2019-05-10
    1
    23
  • 董某人
    老师,既然push rbp 的作用是把"main 函数栈帧的栈底地址,压到栈顶",那下一句mov rbp,rsp 又是把"栈顶地址复制给栈帧"。
    原本rbp 中存储的不就是main 函数栈帧地址,压到栈顶后rsp 中存储的不也是main 函数栈帧地址,mov 这句的作用究竟是什么呢?
    2019-05-15
    2
    11
  • chengzise
    老师这里需要补冲一下,函数调用call指令时,(PC)指令地址寄存器会自动压栈,即返回地址压栈,函数返回ret指令时会自动弹栈,即返回地址赋值给PC寄存器,把之前。图片有显示压栈,没有文字说明,其他同学可以不太理解。

    作者回复: 👍,谢谢 chengzise 同学,谢谢补充。call 在调用的时候会做push eip的操作,而在ret的时候会做pop eip的操作。

    2019-05-10
    7
  • JStFs
    “push rbp 就把之前调用函数,也就是 main 函数的栈帧的栈底地址,压到栈顶。”

    一直不明白为什么要把main的栈底压到栈顶?没有图很难理解
    2019-06-20
    3
    6
  • Better me
    push rbp;
    mov rbp rsp;
    老师,想问这两句是如何控制函数调用的

    作者回复: 这两个是在维护函数调用的栈帧。

    指令地址本身的压栈和出栈是在 call 和 ret 的部分进行的。
    你可以认为 call 的同时进行了一次 push rip 把PC寄存器里面的内容压栈了,而在 ret 的时候 pop 把这部分数据出栈写回到PC寄存器里面了。

    2019-05-10
    6
  • Akizuki
    function_example.c 反汇编结果:

        int u = add(x, y);
      2a: 8b 55 f8 mov edx, DWORD PTR [rbp-0x8]
      2d: 8b 45 fc mov eax, DWORD PTR [rbp-0x4]
      30: 89 d6 mov esi, edx
      32: 89 c7 mov edi, eax

    老师,这里为什么没有编译成:

    mov esi, DWORD PTR [rbp-0x8]
    mov edi, DWORD PTR [rbp-0x4]

    谢谢~
    2019-05-12
    1
    5
  • 小美
    老师 巨大数组为什么是分配在栈空间的呢?(java里面是分配到堆上的 c预约和java不同吗)

    作者回复: 如果是函数作用域内的临时变量,就是分配在栈上的啊。

    首先Java运行时候的JVM自己就是一个应用程序,和C编译出来的机器码就不一样。

    Java通过New出来的对象是在堆上,但是函数作用域里面的临时变量,以及对应的引用都是放在栈上的。

    2019-06-06
    3
  • Allen
    int main()
    {
       d: 55 push ebp
       e: 89 e5 mov ebp,esp
      10: 83 ec 18 sub esp,0x18
        int x = 5;
      13: c7 45 f4 05 00 00 00 mov DWORD PTR [ebp-0xc],0x5
        int y = 10;
      1a: c7 45 f8 0a 00 00 00 mov DWORD PTR [ebp-0x8],0xa

    老师,请教下:
       sub esp,0x18 的目的是干什么? 0x18 是怎么计算的?

    作者回复: 这个是在维护栈帧,因为后面有两个临时变量需要在调用其他函数之前保留到栈里面。0x18是16进制的24
    两个int各需要8 bit,一共16bit,然后ebp本来就要8bit,一共只有24bit,考虑对齐到16bit的整数倍还要额外的8bit一共24bit

    2019-05-16
    3
  • Linuxer
    0x0000000000400508 <+0>: push %rbp
    0x0000000000400509 <+1>: mov %rsp,%rbp
    0x000000000040050c <+4>: sub $0x18,%rsp
    0x0000000000400510 <+8>: movl $0x1,-0x4(%rbp)
    0x0000000000400517 <+15>: movl $0x2,-0x8(%rbp)
    0x000000000040051e <+22>: mov -0x8(%rbp),%esi
    0x0000000000400521 <+25>: mov -0x4(%rbp),%eax
    0x0000000000400524 <+28>: movl $0x7,(%rsp)
    => 0x000000000040052b <+35>: mov $0x6,%r9d
     0x0000000000400531 <+41>: mov $0x5,%r8d
     0x0000000000400537 <+47>: mov $0x4,%ecx
     0x000000000040053c <+52>: mov $0x3,%edx
     0x0000000000400541 <+57>: mov %eax,%edi
     0x0000000000400543 <+59>: callq 0x4004cd <add>
     0x0000000000400548 <+64>: mov %eax,-0xc(%rbp)
     0x000000000040054b <+67>: mov $0x0,%eax
     0x0000000000400550 <+72>: leaveq
     0x0000000000400551 <+73>: retq
    (gdb) i r
    rcx 0x400560 4195680
    rdx 0x7fffffffe4e8 140737488348392
    rbp 0x7fffffffe3f0 0x7fffffffe3f0
    rsp 0x7fffffffe3d8 0x7fffffffe3d8
    r12 0x4003e0 4195296
    r13 0x7fffffffe4d0 140737488348368
    r14 0x0 0
    r15 0x0 0
    rip 0x40052b 0x40052b <main+35>
    eflags 0x216 [ PF AF IF ]
    cs 0x33 51
    ss 0x2b 43
    ds 0x0 0
    es 0x0 0
    fs 0x0 0
    gs 0x0 0
    (gdb) x/24x $rbp
    0x7fffffffe3f0: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
    0x7fffffffe3f8: 0xd5 0x03 0xa3 0xf7 0xff 0x7f 0x00 0x00
    0x7fffffffe400: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
    (gdb) x/24x ($rbp-24)
    0x7fffffffe3d8: 0x07 0x00 0x00 0x00 0x00 0x00 0x00 0x00
    0x7fffffffe3e0: 0xd0 0xe4 0xff 0xff 0xff 0x7f 0x00 0x00
    0x7fffffffe3e8: 0x02 0x00 0x00 0x00 0x01 0x00 0x00 0x00
    从gdb的结果来看,保存了局部变量 调用函数的参数,但是这里不理解的是我的main方法里面只定义了三个局部变量,为什么要分配24字节呢? 加上传参的4字节应该16字节也够了

    作者回复: 6个还是8个以内的调用参数会放在寄存器内而不是stack frame里面。
    X64 下 stack 需要按 16 bytes 做 alignment 可能是导致你需要的空间变成 24 bytes 的原因。这里的 24 bytes 加上你的 8 bytes 的 rbp 正好是 32 bytes 能是16的倍数。
    可以看看这个 stackoverflow 的问题 https://stackoverflow.com/questions/40580914/why-more-space-on-the-stack-frame-is-reserved-than-is-needed-in-x86

    2019-05-10
    3
  • Spring
    call之后,原函数的bp就会赋值为sp,因此只要把bp压栈就好了,call之后再把之前压栈的bp出栈赋值给sp就好了。
    函数返回后会把返回值放到ax寄存器,如果有多个返回值的话就将返回值的内存地址放到ax中。
    因此call之后恢复回原函数还要保存bp和返回值。

    作者回复: 还需要考虑函数的调用参数传递哦。

    2019-05-10
    3
  • zlw
    ret接着出栈后的栈顶继续执行,是rbp还是rsp?rsp始终指向栈顶怎么做到的?

    作者回复: 其实是在call的时候会对PC寄存器进行压栈,做了一个push eip 的操作,而在ret的时候做了pop eip的操作。

    2019-05-10
    3
  • Captain perison
    老师,栈是按照线程进行区分的吗?那个线程都有各自对应的栈吗?

    作者回复: 每个线程都有一个自己的栈。

    2019-05-10
    3
  • once
    老师 call指令已经将pc寄存器里的下一个指令(add函数执行完的跳转地址)压栈了 那 add函数里面的 push rbp压的又是什么栈 还有把main函数从栈底压到栈顶这个是什么意思 没有图看了好几遍也懵懵的 help老师

    作者回复: 这两个是在维护函数调用的栈帧。

    指令地址本身的压栈和出栈是在 call 和 ret 的部分进行的。
    你可以认为 call 的同时进行了一次 push rip 把PC寄存器里面的内容压栈了,而在 ret 的时候 pop 把这部分数据出栈写回到PC寄存器里面了。

    这一部分的确是有不少同学表示写得不够清楚,我晚点看单独会在FAQ里面更详细地写一下这个过程。也再修订一下这一讲希望能讲解地更清楚一些。

    2019-08-28
    2
  • DreamItPossible
    首先,以函数P调用函数Q为例进行说明:
    程序栈里需要保存的信息有:
    - 函数P调用函数Q完成后的下一个指令的地址,即返回地址;
    - 如果函数Q的参数个数超过6个,则剩余的参数值需要保存在栈上;
    - 某些共用的寄存器值;
    - 为指针类型参数生成的地址信息;
    - 数组和结构体等复杂数据结构;
    其次,需要保存的信息其实可以反过来解答文章开头的问题:为什么需要程序栈?
    最后,总结一下:资源有限,即寄存器个数有限,需要结合栈来实现复杂的功能,比如函数调用等
    2019-07-30
    2
  • 秋天
    java程序应该不是那种分页的形式,在虚机起动的时候我们根据配置或者是起动参数指定需要的内存大小,应该是预先分配好一大段连续的内存供程序使用,所以在程序运行过程中如果超出啦,预分配大小的内存就会出现内存溢出的错误

    作者回复: java虚拟机其实是一个应用层的程序,java虚拟机的内部内存分配其实是在虚拟内存地址层面的分配。的确不涉及到操作系统和硬件层面的分页问题。

    2019-05-27
    2
  • 秋天
    现在有点模糊的是栈只是用来做函数调用,记录跳转地址的?它和寄存器的本质区别吗?这两者能给解释一下吗?谢谢!

    作者回复: 秋天同学你好,

    我们在这里先要分清楚 抽象概念 和 实际的硬件实现部分。

    寄存器 和 内存,是在硬件层面就是放在不同的位置,使用不同的物理硬件来实现的。

    而栈是一个抽象概念,实际是存放在内存里面的。栈是用来管理函数调用的“现场”的。确保函数调用完成后,还能回到调用者那里。

    2019-05-27
    2
  • 小猪
    老师,我觉得用goto就可以实现函数调用,起先跳转到函数,运行完,在用goto跳回来就行了

    作者回复: 小猪同学你好,

    那么被调用的函数运行完之后,怎么知道要跳回到哪一个地址呢?

    2019-05-22
    1
    2
  • 愤怒的虾干
    我记得在中断处理时,需要保护现场,将cs、ip寄存器压栈,保存Flags寄存器,清除中断标识,然后跳转到中断处理命令;推广开来方法调用也需要将cs、ip寄存器压栈,保存调用前Flags寄存器状态,清除Flags寄存器的标识。老师,是这样吗?

    作者回复: 愤怒的虾干同学你好,

    中断不是函数调用啊。不过中断的确会对cs,ip进行压栈,以及保存flags寄存器。但是函数调用虽然对于ip会进行压栈,但是并不需要取清理条件码寄存器,也就是flags寄存器的状态,也不需要对于cs进行压栈。

    2019-05-21
    2
  • Alphalin
    请问地址34后面的地址怎么直接到39了? 35地址在哪呢

    作者回复: 34的整个指令有长度啊,你数一数这条指令对应的机器码需要多少空间呢?

    2019-05-15
    2
收起评论
67
返回
顶部