编程语言底层之函数执行
课程简介
本课程从语言底层的角度出发来全面了解函数调用的过程,把语言官方文档中很抽象的设计理念还原,站在 CPU 的角度把这些弄明白。通过这门课的学习将掌握把抽象还原过程,以便后面更加深入的了解学习其他开发语言知识。
相关联的系列达人课:《编程语言底层之数据结构》和《编程语言底层之系统和并发》。
作者介绍
李永京,从事互联网后端系统开发,擅长高并发分布式系统,熟悉 Go、C、C#、Python 等语言。架构龙珠直播平台基础框架,开发过道具、任务、红包、直播、聊天、调度系统等。曾任职阿里妈妈,开发过移动广告 DMP、DSP、广告数据人群分析等。个人博客 积分排名前 30,300万 PV。
课程内容
导读:课程概要
函数调用概况
一个进程启动后,由线程来执行所有的代码,当线程执行一个函数的时候,究竟会发生什么呢?我们用一个简单例子来描述和扩展这个系列的故事。
例如,main()
函数调用add(x,y)
函数,执行函数的时候,首先在栈上分配main()
函数内存空间,接下来分配add()
函数内存空间,add()
函数执行完需要恢复main()
函数的内存空间,就需要做现场保护和恢复。在add(x,y)
函数执行之前,少不了参数复制,如何实现参数传递的过程呢?理论上所有参数都是复制的,复制的到底是什么?
知道了普通函数的调用过程后,还有一些特殊函数调用过程是怎么样的呢。匿名函数已经成为现在语言很重点的标志,像 JS 里面大量使用匿名函数,Java 或者 C# 类似 Lambda 表达式,匿名函数调用上会有什么差别?作为返回值的匿名函数、直接调用匿名函数两种调用方式在语言底层有哪些不同的处理。
除了匿名函数,还有闭包这样的形态,闭包是通过指针引用环境变量,就会导致环境变量生命周期延长和在堆分配。那么闭包怎么调用的,既然引用环境变量,怎么处理数据竞争问题的。
当然,平时也可能去写递归调用,递归调用容易引起堆栈溢出,我们怎么避免呢?编译器如何对递归调用进行尾递归优化,当然不同的编译器对尾递归调用优化也是不同的。
高级语言也有自己的语法糖,Go 语言提供了 Defer,延迟调用有哪些用途,Defer 实现和执行机制是什么?带来哪些性能问题。
读者收获
如果需要学习这些仅仅看官方文档是没有用的,很多官方文档不会提这些实现层面的东西,看官方文档根本没有办法深入到具体细节里面去,这就需要有手段去逆向推导,怎么样去把官方文档很抽象的文法或者理念还原,就是所谓的用逆向手段来推导语言提供的某种句法、某种范式、某种原理,从汇编的角度站在 CPU 的角度把这些弄明白。
这个系列从语言底层全面了解这些过程,很多技巧跟语言无关,只和底层原理有关,你了解语言底层原理的话不管什么语言都是一脉相承的。
实际操作,带你认识实现原理:
- 函数调用,现场保护和恢复
- IP 寄存器的用途
- 相关汇编指令
- C 语言参数复制和处理返回值
- Go 语言参数复制和处理返回值
- 匿名函数调用方式
- 作为返回值的匿名函数
- 直接调用匿名函数
- 闭包如何导致环境变量生命周期延长和堆分配
- 闭包怎么调用的
- 闭包与数据竞争
- 什么是递归
- 为什么会引起堆栈溢出
- 什么是尾调用
- 什么是尾递归优化
- 为什么 Go 编译器对尾递归调用不做优化处理
- 延迟调用的用途
- defer 与 finally 的对比
- defer 实现和执行机制
- 利用匿名函数重构作用域
- defer 带来的性能问题
基本演示
下面使用 Go 语言演示下反汇编、GDB 调试学习过程,函数 main() 调用 add,调用 add 需要传递 x 和 y 两个参数,同时要接收一个返回值 z。怎么确定 x 和 y 是传值还是传指针?这个参数的内存是谁分配的?应该怎么做呢?
$ cat test.go
package mainfunc add(x, y int) int { return x + y}func main() { x := 0x100 y := 0x200 z := add(x, y) println(z)}
编译:
$ go build -gcflags "-N -l" -o test0 test.go
这两个开关 -N 禁止代码优化,-l 禁止内联,因为调试时候需要让生成的机器指令和源码一一对应,这样我们打断点时候才能正确跟踪。
反汇编:
$ go tool objdump -s "main\.main" test0
test.go:7 0x4509d0 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX test.go:7 0x4509d9 483b6110 CMPQ 0x10(CX), SP test.go:7 0x4509dd 7669 JBE 0x450a48 test.go:7 0x4509df 4883ec38 SUBQ $0x38, SP test.go:7 0x4509e3 48896c2430 MOVQ BP, 0x30(SP) test.go:7 0x4509e8 488d6c2430 LEAQ 0x30(SP), BP test.go:8 0x4509ed 48c744242800010000 MOVQ $0x100, 0x28(SP) test.go:9 0x4509f6 48c744242000020000 MOVQ $0x200, 0x20(SP) test.go:10 0x4509ff 488b442428 MOVQ 0x28(SP), AX test.go:10 0x450a04 48890424 MOVQ AX, 0(SP) test.go:10 0x450a08 488b442420 MOVQ 0x20(SP), AX test.go:10 0x450a0d 4889442408 MOVQ AX, 0x8(SP) test.go:10 0x450a12 e899ffffff CALL main.add(SB) test.go:10 0x450a17 488b442410 MOVQ 0x10(SP), AX test.go:10 0x450a1c 4889442418 MOVQ AX, 0x18(SP) test.go:11 0x450a21 e86a2efdff CALL runtime.printlock(SB) test.go:11 0x450a26 488b442418 MOVQ 0x18(SP), AX test.go:11 0x450a2b 48890424 MOVQ AX, 0(SP) test.go:11 0x450a2f e83c36fdff CALL runtime.printint(SB) test.go:11 0x450a34 e80731fdff CALL runtime.printnl(SB) test.go:11 0x450a39 e8e22efdff CALL runtime.printunlock(SB) test.go:12 0x450a3e 488b6c2430 MOVQ 0x30(SP), BP test.go:12 0x450a43 4883c438 ADDQ $0x38, SP test.go:12 0x450a47 c3 RET test.go:7 0x450a48 e8437dffff CALL runtime.morestack_noctxt(SB) test.go:7 0x450a4d eb81 JMP main.main(SB)
使用调试器调试 test0:
$ gdb test0$ l #查看源码$ b 10 #打断点$ b 4 #打断点$ r #执行$ info locals #查看局部变量$ p/x &x #查看x地址$ p/x &y #查看y地址$ c #继续执行$ info args #查看参数$ p/x &x #查看x地址,对比上面地址就可以知道对象是否被复制过$ p/x &y #查看y地址$ set disassembly-flavor intel #设置intel样式$ disass #反汇编
Dump of assembler code for function main.add: 0x00000000004509b0 <+0>: mov QWORD PTR [rsp+0x18],0x0=> 0x00000000004509b9 <+9>: mov rax,QWORD PTR [rsp+0x8] 0x00000000004509be <+14>: add rax,QWORD PTR [rsp+0x10] 0x00000000004509c3 <+19>: mov QWORD PTR [rsp+0x18],rax 0x00000000004509c8 <+24>: retEnd of assembler dump.
$ q #退出
视频演示:
通过简单的演示过程,从语言底层的角度来推导实现原理从而加深对理论的理解,这个系列6篇文章逐步来探究函数调用的种种细节。
第01课:函数执行
本节内容
- 调用堆栈和堆栈帧
- 函数调用、现场保护和恢复
- 用 GDB 查看调用堆栈、输出堆栈桢信息
- IP 寄存器的用途
- 相关汇编指令
- 总结
调用堆栈和堆栈帧
一个进程启动后,由线程来执行所有的代码,线程启动的时候首先分配一段内存,这段内存用来存储。分配内存有两种方式,第一种分配所有线程的内存,每个线程都会有一段内存。不同的操作系统默认给线程分配的内存大小会不一样,有 1MB 或者 10MB,另外有些程序比如 Go 语言会自主控制一个线程分配多少内存。我们把为线程分配的内存称之为栈,就是说所有线程带的内存通常称之为执行栈,栈基本的结构是先进后出。
第二种使用 new 或者 malloc 之类的命令分配的,称之为堆,堆上面有很多内存块,这些内存块按照需要的大小进行分配,分配完之后必须要释放,主动释放或者 GC 释放。堆上内存基本上是平面线性结构,在堆上分配内存有很多的讲究。先分配了一个区域,然后再分配另外一个区域,就会造成大大小小的碎块,在分配很大的内存块时需要一个没有分配的区域足够大的空间给它,所以很容易内存碎片化。很多 GC 程序在垃圾回收时候会把没有释放的内存搬到一起进行压缩,这样把回收过后正在用的内存搬到一块,后面全部是*空间,这样便于分配内存。
栈的空间从高往低分配,高位在下面低位在上面。当 main 调用 a 的时候,首先把 main() 函数的空间分配出来,因为 main() 函数调用 a 的时候还得返回,所以 main() 函数本身的状态必须保留,那么这个 main() 函数的内存块是保留的;然后当执行 a 的时候,在上面为 a 分配好内存,a 调用 b 的时候,再为 b 分配好内存,这样的话在线程栈上为函数调用分配不同的内存,当 b 调用结束以后,b 所在的内存就会被收回,当 a 结束时候,a 所在的内存也会被收回。假如 main 接下来去调用 c,c 调用 d,原来 a 的内存就会被分配 c 了,原来 b 的内存就会被分配 d。这样的结构很简单,相当于往垂直的空间里面放不同的书,一本一本的放,最先拿起的一本书肯定是最后放的一本,当你拿了很多书了以后空间实际上是可以重复使用的。
当执行一个函数的时候,传递的参数,函数返回值和函数中局部变量也都需要存储。线程栈怎么样去维持一个函数调用呢?假如 main() 函数调用了 a,a 调用了 b,b 再返回到 a,a 再返回到 main,调用的过程中形成了类似一种链状结构,这样的链状结构在内存上怎么去管理呢?这会涉及到两个专用的寄存器,BP 寄存器指向底部,SP 寄存器执行顶部。当我们进数据时候,SP 会一直往上增长,也就是说 SP 寄存器永远指向栈顶的位置,BP 表示某个基准位置。这样大概对栈的结构有了初步了解。那么简单了解一个函数调用是究竟什么状态。
main() 函数或者 add() 函数所在的内存块称之为堆栈帧(stack frame),整个函数调用过程的总和称之为调用堆栈(call stack),这个名字其实翻译成中文之后觉得很古怪,可能翻译成调用栈也许更合理一些。我们在调用堆栈上可以看到调用堆栈上一级甚至更上一级整个数据状态,例如在 add() 函数上下断点时候,除了看到 add() 函数里面的内存数据以外,实际上也可以看到 main() 函数的内存数据。
函数调用、现场保护和恢复
函数调用过程怎么样的呢,例如一个 main() 函数调用 add(x,y)函数。第一步在栈上分配 main() 函数空间,BP 和 SP 寄存器存储内存地址,BP 指向 main() 函数底部,SP 指向 main() 函数顶部。main() 函数所使用的这段内存空间大小就是 BP-SP。接下来调用 add() 函数的时候,暂时忽略参数传递,这时分配 add() 函数内存,SP 移到 add() 函数顶部,BP 指向 add() 函数底部。BP 指向当前函数的底部,SP 指向当前函数的顶部。
add() 函数执行完怎么恢复 main() 函数的内存空间呢?当调用一个函数的时候,首先做的是现场保护,现场保护就是保护当前函数执行场景上下文信息,再去执行 add() 函数,执行 add() 函数之后回收内存空间,再做现场恢复,现场恢复后才可以回到当时调用 main() 函数时的场景,除了需要把 BP、SP 保存起来,还要保存 IP 寄存器,因为如果 main() 函数有这样一条指令,分配 x,y 变量,接下来调用 add() 函数,接下来 print add() 函数的结果,正常情况下执行 add 的时候,接下来执行 print 指令,当执行 print 指令时 IP 指向 print,也就是说当执行完 print 时,IP 寄存器也需要恢复,要不然就不知道接下来执行哪一行了,当时从哪一行出去的回来时候需要从哪一行下面一行执行,最基本的 BP、SP、IP 三个寄存器的值需要保护,BP 描述了 main() 函数的底部,SP 描述了 main() 函数的顶部,IP 保存了执行完 add() 函数以后接下来要执行哪条指令,最起码有三个值需要做现场保护。
用 GDB 查看调用堆栈,输出堆栈桢信息
$ cat test.c
#include <stdio.h>#include <stdlib.h>__attribute__((noinline)) void info(int x){ printf("info %d\n", x);}__attribute__((noinline)) int add(int x, int y){ int z = x + y; info(z); return z;}int main(int argc, char **argv){ int x = 0x100; int y = 0x200; int z = add(x, y); printf("%d\n", z); return 0;}
这段代码分配了两个变量,调用了 add 方法,加法内部调用另外一个函数用于输出结果。
$ gcc -g -O0 -o test test.c #编译,去掉优化
使用 GDB 调试:
$ gdb test$ b main #符号名上加上断点,mian函数加上断点$ b add #add函数加上断点$ b info #info函数加上断点$ info breakpoints #查看断点,一共有三个断点$ r #执行,这时在main函数上中断了$ bt #查看当前调用堆栈,其中栈帧#0只为mian函数分配了内存,#0表示自己$ l main #查看main代码 19行中断内容,意味这19行还没有执行$ c #继续执行,这时在add函数上中断$ bt #查看当前调用堆栈,有两个,下面是main函数,上面是add函数,#0表示自己,#1表示谁调用你的$ l main #查看main代码 21行栈帧内容$ l add #查看add代码 11行中断内容,意味这11行还没有执行$ info frame #输出当前栈帧里面相关数据 rip表示IP寄存器中的值$ info args #输出当前参数$ info locals #输出当前局部变量$ frame 1 #查看上一级栈帧数据,切换帧1$ info frame #输出当前栈帧里面相关数据,这时显示main函数的栈帧内存$ down 1 #切换栈帧$ up 1 #向外切换栈帧,谁调用你的
IP 寄存器的用途
当执行callq <add>
指令的时候,IP 寄存器应该保存下一行指令位置,因为这样的话才能恢复。但是当进入 add() 函数里面的时候,IP 寄存器其实是指向 add 方法里面的地址,所以为了 add() 函数执行结束时候可以恢复到下一行,必须要把 IP 寄存器里面的值保存起来,接下来我们看下怎么样保存 IP 寄存器。
$ gdb test$ b main #符号名上加上断点,mian函数加上断点$ r #执行,这时在main函数上中断了$ set disassembly-flavor intel #设置intel样式$ disass #反汇编$ p/x $rip #IP寄存器保存的是下一行指令的位置,使用p/x查看$ ni #单步执行$ p/x $rip #再看IP寄存器值,和上面不一样了,永远指向下一行指令的地址。
main() 函数准备调用 add() 函数的时候,它需要保存哪些值?最简单的方法把这些值保存到栈上面,首先把 IP 寄存器压到栈上,然后 BP、SP 全部保存起来,保存完了以后把 BP 指向栈顶,执行 add 方法,SP 指向 add 方法的栈顶,这时 BP 就是 add 方法的栈底。当 add 方法执行完了以后只需要把栈里的那些值 pop 到指定的寄存器里面去,就可以恢复到 mian() 函数的状态了。只要 pop 出来就可以恢复 BP、SP、IP 的值,这样的话就可以完全的把 mian() 函数当时执行的状态恢复出来。所以把这些寄存器保存起来的方式称之为现场保护,pop 出来就叫做现场恢复。
$ b *0x0000000000000400597 #下一行call <add>函数打断点$ c #执行$ set disassembly-flavor intel #设置intel样式$ disass #反汇编$ info registers #记录一些值,rbp=e680,rsp=e660,rip=059c$ si #进入add函数里$ disass #反汇编
相关汇编指令
- call(push ip)
- leave(mov sp,bp;pop bp)
- ret(pop ip)
call 指令首先会把 IP 的值先入栈,执行 call 指令的汇编代码:
push %rbp #把BP寄存器入栈保存起来mov %rsp,%rbp #把SP里面的值赋值给BP,就是BP指向SPsub $0x20,%rsp #把SP减去20,地址从高往低分配的,就是SP指向上面位置,20的空间就是add函数空间
在调用 add() 函数之前做了几件事,第一件事 call 指令除了调用函数之外首先把 IP 保存起来,然后在函数头部又把 BP 保存起来。根据不同的函数调用不同的编译器也有关系。对于 GCC 来说如果保存这两个就足够用就没问题,因为它的编译器会分析需要保存哪些状态,这是编译器来处理的,现在起码知道 IP、BP 是被保护起来的。
接下来执行:
$ b *0x0000000000000400550 #下一行mov %edi,-0x14(%rbp)打断点$ c #执行$ p/x $rip #这时候IP是指向add函数里面的值,IP指向0x400550,因为这时候执行的是add函数的逻辑,所以你得告诉IP寄存器接下来执行什么。$ b *0x000000000000040056e #下一行leaveq打断点$ c #执行
leave 和 ret 指令完成现场恢复,leave 指令其实是一个复合指令。不是所有时候都会使用 leave 有些时候可能直接用 pop 这样的指令,总之在最后会用相关的指令完成现场恢复。复合指令实际上需要执行几次操作,mov sp,bp
首先它把 SP 指向当前 BP 的位置,就是把 add() 函数所需要的内存空间释放掉了,pop bp
然后把 BP pop 出来,因为 BP 是当时保存的,BP 就回到原来的位置,接下来 SP 调整位置。
ret 指令,pop ip
把 IP 寄存器 pop 出来,这样 IP 就指回了原来的位置,SP 继续调整位置指向 mian() 函数顶部了。所以 BP、SP、IP 都恢复了完成了现场恢复的过程。
$ info registers #查看寄存器值,rbp,rsp,rip$ info frame #查看当前栈桢$ set disassembly-flavor intel #设置intel样式$ disass #反汇编$ ni #单步执行下一行leave
执行这条指令,刚刚说过恢复 SP、BP 的值,下面看下是否恢复:
$ info registers #查看寄存器值,rbp=e680,rsp=e658,rip$ set disassembly-flavor intel #设置intel样式$ disass #反汇编$ info frame #查看当前栈桢$ p/x $rbp #e680$ ni #单步执行下一行ret$ disass #反汇编 恢复到main函数状态$ info registers #查看寄存器值,rbp=e680,rsp=e660,rip=059c$ p/x $rip #IP寄存器059c
编译器会自己处理需要哪些数据需要去保护,因为 GCC 在栈上所有的寻址都是基于 BP 寄存器来寻址的,所以关键的位置要保存 BP 和 IP 这两个状态,因为把栈上数据全部去掉以后 SP 自然就恢复了,这个对 GCC 来说的。
Go 语言可能不是基于 BP 来寻址,是基于 SP 来寻址的,那么它就需要把 SP 保护起来,而 BP 就不管了,不同的编译器不同的做法。因为 GCC 栈上的寻址都是基于 BP 做减法,因为 BP 在下面高位栈底的位置,往上寻址就减去偏移量就可以了,这是 GCC 基于 BP 寄存器做减法。
比如 add() 函数空间,BP 在栈底,高位地址。比如在 add() 函数上存放 x=100,就得把 BP 减去一个偏移量 0x8,那么 BP-0x8 就是 x 的地址。要么通过 BP 做减法寻址,要么通过 SP 做加法寻址。总归选择一个作为基准。不同的编译器不同的实现方式。
总结
函数调用的时候,首先函数是被线程执行的,这个线程要执行函数调用必须要内存分配,内存分为两块,一块称为堆,一块称为栈。每个线程都会有自己的栈内存,栈内存是个大整块,调用的时候通过 BP 或者 SP 这两个寄存器来维持当前函数需要操作哪块内存,操作完了以后,直接来调整 BP 或者 SP 寄存器的位置就可以把调用函数的所分配的栈桢空间释放掉。栈上的内存释放了以后那个内存还在,因为整个栈内存是个整体。这就是整个一大块,我们只不过就是调用时候通过两个寄存器来确定当前操作的时候在这一大块中操作哪一个区域。
栈上内存用 BP 和 SP 来操作一整块内存的一个区域,用完之后把 SP 寄存器指回去,那块空间接下来调用其他函数时候进行复用。整个栈内存是一大块,是一整块,它没有说释放某块内存这样的一个说法。除非就有一种可能,就是把整个栈空间释放掉。
在堆上我们申请了一段内存,不用的时候可以把这块释放掉,因为在一个函数里面可以多次调用堆内存分配,然后可以分块释放,栈上没有内存释放这种说法。所以这就有个好处在栈上只需要调整两个寄存器 BP、SP 的位置就可以来决定这个内存当前是正在用或者说是可以被其他函数调用来覆盖掉。所以有这样一个说法,我们尽可能把对象分配到栈上,因为不需要执行释放操作。现场恢复时候只需要调整寄存器,那块内存就变得可复用状态了。但是在堆上必须要释放,在栈上的效率显然是要高很多。而且栈这种特性就决定了它是有顺序操作的机制,所以它的效率就高很多。在堆上分配时候要么手动释放要么有垃圾回收器来释放。所以我们在栈上分配的时候,一是效率比较高,第二不会给垃圾回收器带来负担。
每个函数调用的时候都会在栈上用两个寄存器划出一个区域来存储参数、返回值、本地变量类似这样的一些内容,这个区域我们称之为叫栈桢。那么多级调用时候所有的栈桢串在一起我们称之为调用堆栈。
那么究竟有哪些东西分配在栈上呢?比如说在函数里面x=10
这种东西默认情况下肯定分配在栈上,*p=malloc()
这个时候这东西在堆上还是在栈上呢?这时候实际上有两块内存,第一 malloc 的确是在堆上分配一个内存空间,这个内存空间分配完了之后得有个指针指向它。
除了堆上的内存块,还有个指针变量,这个指针变量可能是在栈上。指针本身是个标准的变量,它是有内存空间的,因为可以给指针赋值的,能给它赋值肯定是个对象,没有对地址赋值这样一个说法,地址肯定不能赋值的。所以指针和地址不是一回事。指针是一个标准的变量,里面存了地址信息而已。
复合对象是不是分配在堆上也未必,这得看不同的语言对复合对象怎么定义了,比如说结构体算不算复合对象,数组算不算复合对象,默认情况在栈上分配没有问题,当然里面可以用指针指向堆上其他的地址。当里面有指针指向别的对象的时候,这个指针本身它依然是在栈上的。比如说有个复合对象结构体,有个x和一个指针 p,指针 p 指向堆上一个内存对象,堆上内存对象不属于结构体本身的内容。因为只有这个指针属于这个结构体,至于这个指针指向谁和这个结构体没关系,这结构体本身是完全分配在栈上的。只不过结构体里面有个东西记录了堆上的地址信息而已。
接下来了解对象参数究竟怎么去分配的。
第02课:参数传递
第03课:匿名函数
第04课:闭包
第05课:递归调用
第06课:延迟调用
阅读全文: http://gitbook.cn/gitchat/column/5a589f7ce286423809d4c075
上一篇: spring解决依赖循环
下一篇: 运行打包的jar报错 没有主类清单