导语 | 在恣意一门编程言语中,函数调用基本上都是十分常见的操作;咱们都知道,函数是由调用栈完结的,不同的函数调用会切换上下文;可是,你是否好奇,关于一个函数调用而言,其底层到底是怎样完结的呢?本文解说了函数调用的底层逻辑完结。
一、汇编概述
既然要解说函数调用的底层逻辑完结,那么汇编言语咱们是绕不过的。
因而,首要来温习一下汇编相关的常识。
咱们都知道,核算机只能读懂二进制指令,而汇编便是一组特定的字符,汇编的每一条句子都直接对应CPU的二进制指令,比方:mov rax,rdx便是咱们常见的汇编指令。
汇编言语便是经过一条条的 助记符+操作数完结的,而且汇编指令经过汇编器(assemble,例如Linux下的as)转变为实践的CPU二进制指令。
(一)一个简略的汇编比方
上面讲的有些空洞,来看一个实践的比方:
; 将存放器rsp的值存储到存放器rbp中
mov rbp, rsp
; 将四个字节的4存储到地址为rbp-4的栈上
mov DWORD PTR [rbp-4], 4
; 将rsp的值减去16
sub rsp, 16
需求留意的是:汇编言语是和实践底层的CPU休戚相关的;上面的汇编格局运用的便是Intel的语法格局。
常见的汇编言语有两种天壤之别的语法:
-
Intel格局:optcode destination,source,相似于语法int i=4。
-
AT&T格局:optcode source,destination,直观了解为move from source to destination。
若将上面的Intel汇编改写为AT&T汇编,则为:
movq %rsp, %rbp
movl $4, -4(%rbp)
subq $16, %rsp
能够看到,AT&T汇编的别的一个特点是:有前缀和后缀。
比方:前缀%,$;后缀q,l等等。
这些前缀后缀有特别的意思,后文会解说,不同的格局侧重点不太相同。
(二)常用汇编指令
下面是一些十分常用的汇编指令,在后文中都会用到:
二、通用存放器概述
关于汇编言语,只是了解其语法内容是远远不够的!
因为汇编言语和CPU是休戚相关的,因而在硬件层面咱们还需求关注CPU的通用存放器。
在所有CPU系统架构中,每个存放器一般都是有主张的运用办法的,而编译器也一般依照CPU架构的主张来运用这些存放器,因而咱们能够认为这些主张是强制性的。
(一)8086架构(16bit)
让咱们把视线首要转移到8086
下图展现了在8086 CPU中的各个存放器:
首要包含下面几类存放器:
-
通用存放器:均可用来存放地址和数据。
-
指针和变量存放器:用来存放,某一段内地址偏移量,用来形成操作数地址,首要用来再仓库操作或许变址操作中运用。
-
段存放器:因为存储器空间是分段的,所以这些段存放器则是每个段的首地址。
-
指令指针:IP用来存放将要履行的下一条指令再现在代码段的偏移量,将这个偏移量+段存放器中存放的基地址,就找到了下一条指令的地址。
-
标志位存放器:用来存放核算成果的特征,这些标志位常常被用作接下来程序运转的条件。
-
8086处理器内部有8个16位的通用存放器,也便是CPU内部的数据单元,分别是:AX、BX、CX、DX、SP、BP、SI、DI。
这些存放器的作用首要是:暂存核算机进程中的数据。
别的,AX、BX、CX、DX这四个存放器又能够分为两个8位的存放器来运用,分别是AH、AL、BH、BL、CH、CL、DH、DL。
留意:其中H标明高位(high),L标明低位(low)的意思。
下面来看下操控单元:
IP存放器便是指令指针存放器(Instruction Pointer Register),指向代码段中下一条指令的方位;CPU会依据它不断地从内存的代码段中取出指令并加载到CPU的指令行列中,然后交给运算单元去履行。CS、DS、SS、ES这四个存放器都是16位存放器,用来存储进程的地址空间信息。
比方:
-
CS是代码段存放器(Code Segment Register),经过它能够找到代码在内存中的方位。
-
DS是数据段存放器(Data Segment Register),经过它能够找到数据在内存中的方位。
-
SS是栈存放器(Stack Register),栈是程序运转进程所需求的一种数据结构,首要用于记录函数调用的关系。
-
ES是一个附加段存放器(Extra Segment Register),当发现段存放器不够用的时分,你能够考虑运用ES段存放器。
怎样依据上述段存放器找到所需的地址呢?
CS和DS中都存放着一个段的开端地址,代码段的偏移值存放在IP存放器中,而数据段的偏移值放在通用存放器中;因为8086架构中总线地址是20位的,而段存放器和IP存放器以及通用存放器都是16位的,所认为了得到20位的地址,先将段存放器中开端地址左移4位,然后再加上偏移量,就得到了20位的地址;也正是因为偏移量是16位的,所以每个段最大的巨细是64K的。
别的,关于20位的地址总线来说,能拜访到的内存巨细最多也就只要2^20=1MB。
假如核算得到某个要拜访的地址是1MB+X,那么终究拜访的是地址X,因为地址线只能发送低20位的。
- 关于标志位
8086CPU设置了一个:16位标志存放器PSW(也叫FR),其中规则了9个标志位,用来存放运算成果特征和操控CPU操作。
9个标志位能够分为两类大类:
- 条件码
- 操控标志位
其中条件码包含:
- OF(Overflow Flag)溢出标志,溢出时为1,不然置0:标明一个溢出了的核算,如:结构和方针不匹配。
- SF(Sign Flag)符号标志,成果为负时置1,不然置0。
- ZF(Zero Flag)零标志,运算成果为0时置1,不然置0。
- CF(Carry Flag)进位标志,进位时置1,不然置0;留意:Carry标志中存放核算后最右的位;
- AF(Auxiliary carry Flag)辅助进位标志,记录运算时第3位(半个字节)发生的进方位。有进位时1,不然置0;
- PF(Parity Flag)奇偶标志,成果操作数中1的个数为偶数时置1,不然置0;
操控标志位包含:
-
DF(Direction Flag)方向标志,在串处理指令中操控信息的方向。
-
IF(Interrupt Flag)中断标志。
-
TF(Trap Flag)陷井标志。
(二)x86架构
接着,让咱们步入32位机年代,来看看x86系统下的CPU存放器:
能够看到,为了使得运转在8086架构上的程序在移到32位架构之后也能履行,32位架构对8086架构进行了兼容:
-
通用存放器从16位变成了32位,也便是8个32位的通用存放器;可是为了坚持兼容,仍然保存了16位和8位的运用办法,即:AH、AL等。
-
指向下一条指令的指令指针存放器也从16位变成了32位,被称为EIP,可是相同兼容16位的运用办法。
-
段存放器改动比较大:在32位架构中段存放器仍是16位,可是它不再标明段的开端地址,而是标明索引;32位架构中,引入了段描述符表,表格中的每一项都是段描述符(Segment Descriptor),记录了段在内存中的开端方位,而这张表则存放在内存的某个地址;那么,段存放器中存的便是对应段在段表中的方位,称为选择子(selector)。
关于选择子:
先依据段存放器拿到段的开端地址,再依据段存放器中保存的选择子,找到对应的段描述符,然后从这个段描述符中取出这个段的开端地址;就相当于由之前的直接找到段开端地址变成了直接找到段开端地址;这样改动之后,段开端地址会变得很灵敏。
可是这样就跟本来的8086架构不兼容了,因而为了兼容8086架构,32位架构中引入了实形式和保存形式:8086架构中的办法就称为实形式,32位这种形式就被称为维护形式。
当系统刚刚发动的时分,CPU是处于实形式的,这个时分和8086形式是兼容的;当需求更多内存时,进行一系列的操作,将其切换到维护形式,这样就能运用32位了。
形式能够了解为:CPU和操作系统的一起干活的形式:
- 在实形式下,两者约好好了这些存放器是干这个的,总线是这样的,内存拜访是这样的。
- 在维护形式下,两者约好好了这些存放器是干那个的,总线是那样的,内存拜访是那样的。
这样操作系统给CPU下命令,CPU依照约好好的,就能得到操作系统意料的成果,操作系统也依照约好好的,将一些数据结构,例如段描述符表放在一个约好好的当地,这样CPU就能找到。两者就能够合作工作了。
下面是x86平台下一些存放器的调用特别约好:
作为通用存放器,进程调用中,调用者栈帧需求存放器暂存数据,被调用者栈帧也需求存放器暂存数据。
为防止调用进程中数据不会被损坏丢失,C/C++编译器恪守如下约好的规则:
当发生函数调用时,子函数内一般也会运用到通用存放器,那么这些存放器中之前保存的调用者(父函数)的值就会被掩盖!为了避免数据掩盖而导致从子函数回来时存放器中的数据不可康复,CPU 系统结构中就规则了通用存放器的保存办法。
假如一个存放器被标识为Caller Save, 那么在进行子函数调用前,就需求由调用者提早保存好这些存放器的值,保存办法一般是把存放器的值压入仓库中,调用者保存完结后,在被调用者(子函数)中就能够随意掩盖这些存放器的值了。
假如一个存放被标识为Callee Save,那么在函数调用时,调用者就不必保存这些存放器的值而直接进行子函数调用,进入子函数后,子函数在掩盖这些存放器之前,需求先保存这些存放器的值,即这些存放器的值是由被调用者来保存和康复的。
具体来讲:
当该函数是处于调用者人物时,假如该函数履行进程中发生的暂时数据会已存
储在%eax,%edx,%ecx这些存放器中,那么在其履行call指令之前会将这些存放器的数据写入其栈帧内指定的内存区域,这个进程叫做调用者保存约好(Caller Save)。
当该函数是处于被调用者人物时,那么在其运用这些存放器%ebx,%esp,%edi之前,那么该函数会保存这些存放器中的信息到其栈帧指定的内存区域,这个进程叫被调用者保存约好;%eax总会被用作回来整数值。
%esp,%ebp总被分别用着指向当时栈帧的顶部和底部,首要用于在当时函数推出时,将他们还原为原始值;往往会在栈帧开端处保存上一个栈帧的ebp,而esp是全栈的栈顶指针,一直指向栈的顶部。
注:在x86-64架构下也是相似的约好!
(三)x86-64架构
- 存放器约好
终究便是咱们现在主流的x86-64架构了;
关于x86-64架构,最常用的有16个64位通用存放器,各存放器及用途如下所示:
从上面的表能够看到,除了扩展本来存在的通用存放器,x64架构还引入了8个新的通用存放器:r8-r15。
这些存放器虽然都能够用,可是仍是做了一些规则,如下:
函数回来值存放的存放器:rax。
- rax一起也用于乘法和除法指令中。在imul指令中,两个64位的乘法最多会发生128位的成果,需求rax与rdx共同存储乘法成果,在div指令中被除数是128位的,相同需求rax与rdx共同存储被除数
- rsp是仓库指针存放器,一般会指向栈顶方位,仓库的pop和push操作便是经过改动rsp的值即移动仓库指针的方位来完结的。
- rbp是栈帧指针,用于标识当时栈帧的开端方位。
- %rdi,%rsi,%rdx,%rcx,%r8,%r9六个存放器用于存储函数调用时的6个参数(假如有6个或6个以上参数的话)。
- rbx被标识为 “miscellaneous registers”,归于通用性更为广泛的存放器,编译器或汇编程序能够依据需求存储任何数据。
- rbx、rbp、r12、r13、r14、r15:这些存放器由被调用者负责维护,在回来的时分要康复这些存放器中原本的值。
一起,和上面x32架构相似这儿也要差异Caller Save和Callee Save存放器,即存放器的值是由 调用者保存 仍是由 被调用者保存。
- 函数传参优化
在x32的年代,通用存放器少,参数传递都是经过入栈(汇编指令push)完结的(当然也有运用存放器传递的,比方著名的C++ this指针运用ecx存放器传递,不过能用的存放器毕竟不多),相对CPU存放器来说,拜访太慢,函数调用的功率就不高;
而在x86-64年代,存放器数量多了,CPU就能够利用额外的存放器rdi、rsi、rdx、rcx、r8、r9来存储参数!
存放器传参的优点是速度快,减少了对内存的读写次数。
注:多于6个的参数,仍然仍是经过入栈完结传递。
因而在x86_64位机器上编程时,需求留意:
- 为了功率尽量运用少于6个参数的函数。
- 传递比较大的参数,尽量运用指针,因为存放器只要64位。
留意:具体运用栈仍是用存放器传参数,这个不是编程言语决议的,而是编译器在编译生成CPU指令时决议的。假如编译器非要在x64架构CPU上运用线程栈来传参那也不是不可,这个对高档言语是无感知的。
(四)x86-64存放器的向下兼容
上述的存放器名字都是64位的名字,关于每个存放器,咱们还能够只运用它的一部分,并运用另一个新的名字:
下面这些存放器或许也会需求用到其他存放器:
- 8个80位的x87存放器(%st0~st7),用于浮点核算。
- 8个64位的MMX存放器,用于MMX指令(多媒体指令),这8个存放器跟 x87存放器在物理上是相同的存放器。
- 16个128位的SSE存放器,用于SSE指令。
- RIP指令存放器,保存指令地址。
- flags(rflags-64位,eflags-32位)存放器。每个位用来标识一个状况。比方,这些标识符或许用于比较和跳转的指令。
和上面所述的x86架构相似,在x86-64架构下也存在实形式;更多关于 x86-64 处理器架构:
c.biancheng.net/view/3460.h…
www.cnblogs.com/mazhimazhi/…
更多关于CPU存放器前史见: zhuanlan.zhihu.com/p/272135463
三、函数调用结构
上文简略温习了一下汇编和存放器相关的内容。下面来正式来看看函数调用的底层是怎样完结的!
注:这儿的阐明采用的是:
- 编译器:GCC 12.1。
- 优化级别为-O0。
- 汇编指令为intel架构。
(一)函数调用
子函数调用时,调用者与被调用者的栈帧结构如下图所示:
在子函数调用时,需求切换上下文使得当时调用栈进入到一个新的履行中:
- 父函数将调用参数从后向前压栈:由函数调用者完结(上文中的Caller逻辑)。
- 将回来地址压栈保存:call指令完结。
- 跳转到子函数开端地址履行:call指令完结。
- 子函数将父函数栈帧开端地址(%rpb)压栈:由函数被调用者完结(上文中的Callee逻辑);
- 将%rbp的值设置为当时%rsp的值,即将%rbp指向子函数栈帧的开端地址:由函数被调用者完结(上文中的Callee逻辑),完结函数上下文的切换。
保存回来地址和保存上一栈帧的%rbp都是为了函数回来时,康复父函数的栈帧结构(保存函数调用上下文)。
在运用高档言语进行函数调用时,由编译器主动完结上述整个流程;乃至关于”Caller Save”和“Callee Save”存放器的保存和康复,也都是由编译器主动完结的。
需求留意的是:父函数中进行参数压栈时,顺序是从后向前进行的(调用栈空间都是从大地址向小地址延伸,这一点刚好和堆空间相反)。
这一行为并不是固定的,是依赖于编译器的具体完结的。
至少在GCC中,运用的是从后向前的压栈办法,这种办法便于支撑相似于printf(“%d,%d”,i,j) 这样的运用变长参数的函数调用。
以下面的函数为例:
void func() {}
void my_func() {
func();
}
对应的汇编为:
func():
push rbp
mov rbp, rsp
nop
pop rbp
ret
my_func():
push rbp
mov rbp, rsp
call func()
nop
pop rbp
ret
在函数my_func和func中:开端的两句便是由编译器默许生成的切换上下文句子(函数my_func中也存在这个句子是因为它终究也会被其他函数s调用)。
当my-func函数调用func函数时:
- 首要,履行call指令,保存回来地址,并跳转至func函数开端地址(这儿没有压栈调用参数是因为func入参为空)。
- 随后,在func函数中,运用push rbp和mov rbp,rsp保存上下文,随后开端履行func函数中的逻辑。
- 因为没有代码,且没有回来值,此次为nop指令。
- 终究,康复上下文,并回来(函数回来鄙人文中介绍)。
函数最初的push rbp和mov rbp,rsp又叫做函数的序言(prologue),简直每个函数一开端都会该指令。
它和函数终究的pop rbp和ret(epilogue)起到维护函数的调用栈的作用。
接下来,水到渠成的咱们来看一下函数的回来进程。
(二)函数回来
函数回来时,咱们只需求得到函数的回来值(保存在%rax中),之后就需求将栈的结构康复到函数调用之差的状况,并跳转到父函数的回来地址处持续履行即可。
因为函数调用时现已保存了回来地址和父函数栈帧的开端地址,要康复到子函数调用之前的父栈帧,咱们只需求履行以下两条指令:
pop rbp
ret
首要履行pop rbp指令,直接将调用栈地址康复至调用函数之前的状况。
随后经过ret指令跳转至回来地址处并履行。
(三)数据参数传递
- 函数参数传递概述
在函数调用中,另一个需求关注的便是函数参数的传递:入参传递以及回来值传递。
函数在核算的时分,存储数据的当地总共有三个:
- 存放器;
- 内存:栈空间、堆(heap)空间、静态区。
- 程序自身:只读的程序数据片段,比方int i=4,这个4存储于程序自身,在汇编里边又叫当即数(immediate number)。
知道了数据的存储当地,那么数据的传递就分为以下四个方面:
- 从内存到存放器;
- 从存放器到内存;
- 从当即数到存放器;
- 从当即数到内存。
留意:数据不能从内存直接传递到内存,假如需求从内存传递到内存,要以存放器为中介!
一起需求留意的是:数据是有巨细的!
比方:一个word是两个字节(16bit),double words是四个字节(32bit),quadruple words是八个字节(64bit)。
所以传递数据的时分,要知道传递的数据巨细:
Intel格局的汇编会在数据前面阐明数据巨细:比方 mov DWORD PTR [rbp-4],4,意思是将一个4字节的4存储到栈上(地址为rbp-4)。
而AT&T格局是经过指令的后缀来阐明,相同的指令为movl 4,−4(4, -4(%rbp);而且存储的当地,AT&T汇编是经过前缀来差异,比方%q前缀标明存放器,标明当即数,()标明内存。
学习了数据的传递办法之后,让咱们看看函数的调用习气。
- 函数参数传递约好
之前咱们简略学习了一下Caller和Callee的差异,在这儿咱们会深化的学习。
首要,什么是函数调用约好?
在Caller调用Callee时,要将参数(arguements)传递给Callee,一个函数能够接收多个参数,而Caller与Callee之间约好的每个参数的应该怎样传递便是调用习气;这样,Callee才能到指定的方位获取到相应的参数。
比方下面的代码:
int square(int num) {
return num * num;
}
int main() {
int i = 4;
int j = square(i);
}
在main函数中调用square,参数i是怎样传递到square中的?
上面的代码对应的汇编如下:
square(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax
pop rbp
ret
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 4
mov eax, DWORD PTR [rbp-4]
mov edi, eax
call square(int)
mov DWORD PTR [rbp-8], eax
mov eax, 0
leave
ret
经过上面的汇编,咱们能够知道:
在main里边,4先存到栈上(mov DWORD PTR [rbp-4],4),然后存在edi里边(mov eax,DWORD PTR [rbp-4]、mov edi,eax),而sqaure函数直接就从edi里边读取4的值了!
这就阐明:参数4是经过存放器edi传给了callee (sqaure) 。
或许有同学会认为,从代码看,参数不是直接就传给了sqaure吗?实践上,在汇编中,这个变量i是不存在的,只要存放器和内存,因而咱们需求约好好入参i的值存在哪里。
下面让咱们来具体看看这些约好、常见存放器负责传递的参数以及一些作用(前文扼要介绍了一些):
在上面的列表中:
- 蓝色的是callee-owned、绿色背景的是caller-owned。
- callee-owned标明:callee能够自由地运用这些存放器,掩盖已有的值;假如caller要运用这些存放机,那么它在调用callee前,要把这些存放器保存好;例如:假如存放器%rax的值caller想要保存,那么在调用函数之前,caller需求赋值这个值到“安全”的当地。
- caller-owned标明:假如callee要运用这些存放器,那么它就要保存好这些存放器的值,而且回来到caller的时分要将这些值康复;caller-owned的存放器一般用于caller需求在函数之间保存的部分状况。
- 一共有六个通用的存放器用于传递参数;按顺序传递需求通用存放器传递的参数,假如通用存放器运用完了,那么就运用栈来传递。
一起,假如函数回来比较大的目标,那么第一个参数rdi会用来传递存储这个目标的地址(这个地址是由caller分配的)。
有了这些基础,咱们就更简单了解C++中的copy elision了。
相关阅读:
-
深化了解C++中的move和forward
-
Copy/move elision: C++ 17 vs C++ 11
四、常见操控结构
在知道了函数参数是怎样传递的之后,咱们来更升一级。
下面依据具体代码来看一看咱们经常运用的if、for、while等操控结构在底层是怎样完结的。
if,while循环等操控结构,在汇编里边,都是基于断定句子,跳转句子完结的:做一个核算,查看相应的flag,然后依据flag的值确定要跳转到哪里。
比方下面的if句子:
int multiply(int j) {
if (j > 6) {
return j*2;
} else {
return j*3;
}
}
对应的汇编句子如下:
multiply(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
cmp DWORD PTR [rbp-4], 6
jle .L2
mov eax, DWORD PTR [rbp-4]
add eax, eax
jmp .L3
.L2:
mov edx, DWORD PTR [rbp-4]
mov eax, edx
add eax, eax
add eax, edx
.L3:
pop rbp
ret
最前面和终究两条命令便是函数调用中的上下文切换,这个在前文中现已具体阐明晰。
函数的逻辑从第三条句子真实开端:
mov DWORD PTR [rbp-4],edi标明将存放器edi中的4个字节的值(DWORD PTR)移至 [rbp-4] 对应内存地址中。
这儿和上面所叙述的参数传递的约好是坚持一致的,因为咱们的入参j是int类型,只要32位,因而运用的是edi存放器来传递的参数。
随后,运用cmp指令将内存中的数和当即数6进行比较(即,j>6),此指令会改动标志存放器%eflags的状况。
然后jle会利用标志存放器%eflags中的状况进行跳转:
- 假如j<=6,跳转至.L2。
- 不然持续向下履行(对应j>6的场景)。
无论是向下履行仍是跳转至.L2履行,终究两者都会履行至.L3并回来。
下面再来看一个for循环的比方:
int add(int j) {
int ret = 0;
for (int i = 0; i < j; ++i) {
ret+= i;
}
return ret;
}
对应的汇编如下:
add(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-20], edi
mov DWORD PTR [rbp-4], 0
mov DWORD PTR [rbp-8], 0
jmp .L2
.L3:
mov eax, DWORD PTR [rbp-8]
add DWORD PTR [rbp-4], eax
add DWORD PTR [rbp-8], 1
.L2:
mov eax, DWORD PTR [rbp-8]
cmp eax, DWORD PTR [rbp-20]
jl .L3
mov eax, DWORD PTR [rbp-4]
pop rbp
ret
从上面的汇编咱们能够看到,入参j依旧是由存放器edi传递,并存储在了内存[rbp-20]中。
随后两行分别初始化了参数ret:[rbp-4]、i:[rbp-8]。
紧接着,指令直接跳转至.L2处,首要比较了[rbp-8]和[rbp-20]中的值(即比较i和j):假如i<j则跳转至.L3处履行。
这儿的判别是符合for循环的逻辑的:在进入for循环之前首要会判别一次条件。
.L3代码块是for循环的真实逻辑:
; ret += i;
mov eax, DWORD PTR [rbp-8]
add DWORD PTR [rbp-4], eax
; ++i
add DWORD PTR [rbp-8], 1
其他操控结构的逻辑也是相似的,这儿不再赘述了!
五、总结
本文首要扼要温习了汇编以及通用存放器相关的内容,随后进入到文章主题:函数调用。在函数调用中叙述了函数调用中的调用和回来细节、上下文切换维护、函数传递等内容。终究稍微引申了函数中常见操控结构的底层完结。
作者简介:
张凯,腾讯后台开发工程师。