详解C语言中函数是如何被调用的
详解C语言中函数是如何被调用的
函数调用是程序执行的核心,无论是递归调用、事件驱动还是回调函数,都需要通过函数调用来控制程序的执行顺序。本文将从CPU的基本组成单元开始,逐步深入讲解函数调用的底层机制,帮助读者理解计算机是如何执行函数调用的。
前言
函数(方法)调用是程序执行的核心。尤其是在递归调用、事件驱动、回调函数等模式,都需要根据函数调用来控制程序的执行顺序。
函数如何被调用?
众所周知,计算机中CPU负责执行指令、控制程序的流向并进行数据处理。它的主要组成是:
算术逻辑单元(ALU,Arithmetic and Logic Unit):负责执行算术运算(加、减、乘、除)和逻辑运算(与、或、非等)。
控制单元(CU,Control Unit):负责解码指令并控制其他硬件的执行。
寄存器组(Registers):由多个寄存器构成,提供高速的临时数据存储,辅助CPU执行操作。
缓存(Cache):存储常用数据和指令,以提高CPU访问速度。
其中控制单元(CU)是CPU的核心,它负责从内存中取出指令并进行解码,控制指令的执行顺序,主要负责从内存中获取指令,并将其送到寄存器,解码指令并决定哪些操作需要执行,发送控制信号给其他单元,并通过控制信号来启动 ALU(算术逻辑单元)或其他部件的操作,或执行数据存储、读取等任务。
算术逻辑单元(ALU)负责在控制单元指示 ALU 执行加法或其他算术操作时,ALU 会接收两个操作数(通常来自寄存器或内存),并计算出结果,或者进行逻辑运算,例如检查两个数字是否相等(比较运算)或执行位运算。ALU 计算的结果通常会存储回寄存器中,或者传递到控制单元进行下一步操作。
当然,CPU只能机械执行指令。不知道整个程序的执行顺序和步骤,因此需要将函数的参数、内部变量、计算结果、返回地址等大量中间数据暂存下来给CPU指路。上述过程中反复被提及的寄存器,就是负责为CPU临时存储正在执行的指令、数据和状态信息。它们比内存速度更快,但存储容量较小。正是依靠存储在寄存器中的信息,使得CPU能按照我们的想法完成程序的执行。
在早期计算机涉及或者教育性CPU结构中,寄存器通常被明确地定义和使用。而在现代复杂的架构(如x86、ARM等)中,这些寄存器的功能被集成进了不同的硬件单元中,通过指令流水线、寄存器重命名等方式进行优化,因此不再显式定义为独立的寄存器。不过现在我们依然能通过这些早期寄存器更好的理解CPU工作。如在冯·诺依曼架构的计算机模型中,CPU主要通过几个明确的寄存器来执行操作:
IR(Instruction Register):用于存储当前正在执行的指令,通常与程序计数器(PC)紧密配合。在一些简单的架构中,IR是一个单独的寄存器,明确地存储当前指令。
PC(Program Counter):指示下一条指令的内存地址。
AR(Address Register):在某些简单架构中,AR寄存器专门用于存储内存地址。例如,在早期的微处理器中,AR寄存器用于计算内存访问地址。
DR(Data Register):用于存储数据,通常在从内存读取数据或写入数据时使用。
这些寄存器的协作保证了函数调用的正常执行和数据的正确传递。
到这里不免要好奇究竟CPU利用这些数据做了什么才能完成函数执行,我们通过一个例子来说明。
现在我们有一个模型要拼装,拼装模型需要用到工具,于是在拼模型之前我们先去工具店买了点工具,接着回来完成模型拼装。
在这段逻辑中,我们按照任务出现的时间顺序将拼模型视作任务1,拿工具视作任务2,时间序列为:
即要先完成后出现的任务2才能完成任务1。
现在这里有一个计算a的值程序:
int plus5(int a) {
return a+5;
}
int main() {
int a = 6;
printf("%d", plus5(a));
return 0;
}
我们通过执行入口函数main,main函数再调用plus5函数计算,最终由main返回计算结果。即
可以看到程序执行过程中,先被调用的main函数却是在Plus5后面输出。
我们把这个程序稍微拆分一下
int plus3(int a) {
return a + 3;
}
int plus2(int a) {
a = a + 2;
return plus3(a);
}
int main() {
int a;
scanf("%d", &a);
printf("%d", plus2(a));
return 0;
}
现在的调用链是这样
可以看到最开始被调用的函数总是在最后输出,最后被调用的函数总是最先输出。基于这种先进后出(FILO)的特性,我们采用栈(Stack)来为CPU存储调用链信息,称这个栈为“调用栈”(Call Stack),调用栈中每个元素都是一个函数的调用信息,每当程序调用一个函数时,相关的信息就会被推入调用栈,执行完一个函数后,栈中的相应信息会被弹出,CPU按照先进后出的顺序调用函数。想要根据调用栈上的信息完成程序调用,栈元素必须包含必要的函数信息,通常包括:
返回地址(Return Address): 记录函数调用完成后,程序应该跳转的地方(即调用该函数的下一条指令的位置)。
参数(Arguments): 调用函数时传递给函数的参数。如果参数较多,它们通常被压入栈中,或通过寄存器传递。
局部变量(Local Variables): 函数内部声明的局部变量,通常存储在栈帧中。
保存的寄存器(Saved Registers): 如果函数调用修改了一些寄存器的值,调用者需要保存这些寄存器的值,以便函数返回时可以恢复这些寄存器。
这样一来,在main调用plus2时,会在调用栈中压入一段存有plus2信息的内存空间,我们把这段内存空间称为栈帧(Stack Frame),即调用栈是由栈帧组成。如下图所示:
在单线程程序中,只有一个Call Stack,所有函数的调用与返回都通过这个单一的栈来管理。
而在多线程程序中,每个线程都有一个独立的Call Stack,栈帧和函数调用都在各自的线程栈中管理。
有了这些函数信息,现在的CPU可以使用IR寄存器存储当前正在执行的指令。
在函数调用时,IR首先加载调用指令,并执行该指令的解码。PC寄存器存储下一条执行指令的地址。PC会保存当前指令地址作为返回地址,并更新为目标函数的地址。AR寄存器指向栈中保存的函数参数地址或局部变量地址,确保函数可以正确访问其数据。DR寄存器用来传递参数、存储返回值或临时存储计算结果。现在,这些寄存器的协作保证了函数调用的正常执行和数据的正确传递。通过控制PC跳转、使用AR访问参数和局部变量、利用DR传递数据,CPU能够顺利地进行函数调用并确保执行流能够正确地返回到调用点。
在CPU主流架构如x86中,函数间调用涉及到两个关键指针ESP与EBP。它们是 x86 架构中的栈指针和基指针寄存器,分别用于栈操作和函数调用的管理。
ESP(Extended Stack Pointer),即栈指针寄存器,用于指向当前栈的顶端位置。在x86 架构中,它跟踪栈的当前位置,在函数调用、局部变量分配等操作时非常重要。
EBP (Extended Base Pointer),即基指针寄存器,通常用于存储当前栈帧的基地址。它帮助在函数调用中访问参数和局部变量,尤其是在调用约定中用来计算和定位局部变量和传递给函数的参数。
关于x86架构函数调用中具体操作可以查阅下面这个文章:
【详解】函数栈帧——多图(c语言)