树莓派开始,玩转Linux19:函数调用与进程空间

树莓派开始,玩转Linux19:函数调用与进程空间

在Linux中,应用程序位于整个架构的顶层。应用程序的进程会获得一块独立的内存空间,即进程空间。
C语言中变量的相关操作实际上就作用于进程空间。
应用程序大部分是面向过程的C语言编写的,因此进程空间的使用也受到面向过程思维的影响。这里将用一章的篇幅来讲解进程空间的结构。

1.函数调用:

函数是面向过程语言提供的抽象语法,也是C语言区别于指令式程序的关键。
在程序中,要先定义函数,然后才能调用函数。函数定义中说明了在函数调用发生时,进程应该做哪些事情。我们先以一个C程序为例,深入了解函数调用。
在这里插入图片描述
在这里插入图片描述
这段程序中出现了一种新的数据类型,即浮点数(float),用于存储1.68或3.14这样的浮点数。除此之外,程序中声明了三个函数,power、calculate_area和main。函数有不同的功能,power用于计算一个数的任意次方,calculate_area用于计算一个圆的面积。函数main是主函数,在计算出两个圆的面积之后,把结果打印了出来。下面研究这三个函数,以及它们之间的调用关系。

函数声明的第一行除了说明了函数名,还在一开始说明了函数返回值类型。函数power和函数calculate_area的返回值是浮点类型,而函数main的返回值是整数类型。在讲解bash时,我们提到了每个命令都会返回一个整数值,用来表示函数是否成功运行。命令的返回值,实际上就是命令程序的main函数的返回值。相应的,函数中return语句返回的值要和这个类型声明吻合。以calculate_area的函数定义为例,最开始的float说明了返回值类型。相应的,calculate_area中最后一句return返回的也应该是一个浮点数。在函数声明中,还说明了函数参数的类型。如果说函数的返回值是函数的输出,那么参数就是它的输入。函数定义的第一行,函数名后的括号中包含了函数的参数列表。函数calculate_area接受一个浮点数作为参数。根据参数列表,该参数名为r。函数内部就可以像使用一个变量那样,通过"r"这个名称使用参数。一个函数可以有多个参数。函数power就有两个参数,第一个参数是浮点数,第二个参数是整数。下面观察函数调用发生的顺序。main函数调用了两次calculate_area函数。每次调用calculate_area函数时,该函数内部又会调用power函数。上级函数调用下级函数后,被调用的下级函数就会开始运行。函数的调用过程如图所示。
在这里插入图片描述
下级函数运行时,上级函数只是暂停。等到被调用函数返回,上级函数才恢复运行,继续执行下面的语句。在C程序中,main函数总是最早被调用的,因此main函数位于最高级,后面被调用的函数置于下方,但下级函数先执行。除非下级函数运行结束,否则上级函数都处于暂停状态。也就是说,后来的函数将先获得执行。

2.跳转:

我们可以深入编译后的指令式程序,看看计算机如何在底层实现C语言的函数调用。首先,在进程空间中,有一块名为程序段(TEXT)的区域。
进程启动后,会先把程序文件加载到进程空间的程序段中。程序文件是编译后的指令式程序。加载到程序段之后,每个指令占据一个存储单元,并可以通过内存地址来定位。随后,计算机会按照指令顺序,依次执行每条指令。函数中包含了需要依次执行的多个指令。函数指令存储在一块连续的区域中,包含了多条指令。函数也可以通过内存地址来确定位置。这个内存地址就是函数第一条指令的内存地址。为了实现程序复用,每个函数在程序段中只会存一次。表是TEXT区域的示例。需要注意的是,表中的内存地址只是示意,每次编译运行时,具体的内存地址都会有差别。
在这里插入图片描述
如果要实现函数调用的流程,就没法使用指令式的顺序执行。在main函数顺序执行中,遇到calculate_area就不能继续执行自身的下一句指令,必须跳转到calculate_area指令所在的区域。在表的进程空间,就是跳转到152的内存位置。calculate_area函数运行完成后,也必须跳转回上级函数离开的位置。即使是指令式程序,也有跳转的用法。在跳转语句中,只需要说明指令的内存地址,就可以让进程在这个内存地址的位置进行执行。

在进程开始前,程序加载入程序段,每个函数就已经有了确定的内存地址。当函数调用时,进程只需要跳转到函数指令所在的位置就可以了。然而,函数返回时的跳转就变得复杂了。函数调用可能发生在不止一个地方。因此,当被调用函数返回时,应该返回到的指令是不固定的。比如在示例程序中,calculate_area函数被调用了两次,分别发生在main函数的第4行和第7行。因此,两次函数调用的返回地址应该是不同的。

问题的关键在于,函数调用的某些信息是可变的。为了记录函数调用中的可变信息,进程开辟了另一块名为栈(Stack)的内存空间。

3.栈与情景切换:

栈是为了配合函数调用而产生的。既然如此,栈的组织方式也和函数调用类似。回顾函数调用的逻辑顺序,当函数调用发生时,上级函数暂停,下级函数开始工作。因此,函数调用的逻辑顺序有个特点,总是最下级的函数处于激活状态。

我们对比地看栈的工作方式。在main函数运行时,内存中就会有一个对应main函数的内存单元出现,用来记录main函数的可变信息,比如main函数返回时,应该跳转的地址。这个帧就是整个栈的起点。此后,每次有新的函数调用发生时,栈就会向下增加一个帧,对应这一次的函数调用。在创建这个帧时,进程就会记下离开上级函数前的地址,也就是新函数调用的返回地址,所有的帧就组成了一个栈。借助栈的存储能力,函数返回就不再是一个问题了。

图说明了示例程序运行时栈的变化情况。当帧最下方的函数完成时,栈会弹出最下方的帧,取出其中的返回地址,并删除帧的内存空间。进程跳转到返回地址继续运行,原本暂停了的上级函数继续执行。与此同时,暴露在栈最下方的帧恰好对应了恢复激活状态的上级函数。随着函数的调用和返回,栈也不断变化,增加一帧或减少一帧。等到main函数也返回时,栈最高级的帧也被删除,整个程序就运行结束了。
在这里插入图片描述
栈的变化,以及栈中存的返回地址

栈的变化过程和函数调用的变化过程很类似。这种相似性是个自然的结果。毕竟,帧本身就是配合着函数调用工作的。因此,栈完全符合了函数调用的逻辑顺序:总是最下方、对应当前函数的帧处于活跃状态。

4.本地变量:

除了返回地址,栈中还能存储其他数据。由于栈伴随着函数调用发生变化,栈中存储的其他数据也跟着函数调用诞生和消失。我们先来看每个帧中存储的本地变量(Local Variable)。所谓的"本地",就是指函数内部。一个本地变量只能在函数内部声明,比如calculate_area函数中的s。当函数被调用时,该函数的本地变量才在对应的帧中出现。当函数调用结束时,帧被清空,其中存储的本地变量自然会被清空。因此,本地变量只能用于存储函数调用相关的数据。

calculate_area函数的目的是计算圆的面积。在计算过程中,我们用本地变量square存储了中间结果,即半径的平方。等到函数结束时,我们已经计算出圆的面积并返回,那么square中存储的中间结果就不重要了。函数返回时,帧被清空,变量square也伴随着帧从内存中消失,内存空间自然而然地腾了出来。

再来观察power函数。这个函数用于计算一个数的任意次方。函数中的本地变量result同样用于计算中间结果。此外,函数中还有一个本地变量i,用于for循环。我们已经在bash中见过for循环,这种循环结构可以进行固定次数的循环操作,而C语言中的for循环也类似。在进行循环的过程中,变量i记录了当前循环的次数。换句话说,本地变量同样反映了函数当前的状态。power函数的帧中内容,如表22-2所示。
在这里插入图片描述
本地变量随着函数调用诞生,又随着函数返回消失。形象地说,本地变量只存活在函数内部。当然,如果函数调用了下级函数,上级函数的本地变量依然保持在帧中。不过,C语言只允许函数调用当前帧中的内容。因此,激活函数只能操作当前帧中的内容,没法读取或写入上级帧的本地变量。正是有了这样的机制,本地变量完全封闭到了函数内部。定义本地变量的函数内部,就称为本地变量的作用域。因为各个函数"看不到"其他函数的本地变量,所以不同函数可以使用相同的本地变量名。

除了本地变量,帧中还存储着函数的参数。参数用于存储函数的输入。我们在函数调用时输入的数据,就会放在帧中分配给参数的位置上。由于参数也存活于帧中,因此参数的作用域和本地变量完全相同。事实上,你完全可以像使用本地变量那样在函数内部使用参数,让参数记录函数的中间结果或状态。只不过,这种做法违背了参数的初衷,因此程序员很少会这么做。

5.全局变量和堆:

图22-3展示了完整的进程空间。
在这里插入图片描述
进程空间

除了局部变量,进程空间中还有全局变量和动态变量。在内存空间中,全局数据(Global Data)部分用于存放全局变量(GlobalVariable)。"全局"这个名字说明了全局变量的作用域,即所有的函数。在任何一次函数调用中,都可以使用全局变量。在C程序中,在函数之外声明的变量就是全局变量,如示例程序中的PI。在calculate_square函数中,就可以直接调用PI。我们也可以在任意函数中给某个全局变量赋值。因为全局变量的修改有可能影响到多个函数,所以修改全局变量很容易造成意想不到的错误。通常来说,全局变量只用于存储不变的常量。

堆(Heap)用于存放动态变量(Dynamic Variable)。和全局变量类似,动态变量可以被所有的函数看到。不过,全局变量的个数和类型在程序一开始就是确定的,全局数据区域的大小也是确定的,而动态变量可以在进程中产生和消失。当进程创建动态变量时,堆的区域就会增长,占据更多的内存空间。堆增长的部分就是动态变量的空间。

堆和栈是相互独立的区域,堆的空间不随着函数调用自动增长或清空。在堆的支持下,动态变量的作用域同样是全局。在任意一个函数的内部,都可以通过动态变量的地址来访问动态变量中的数据。每个函数都可以通过malloc系统调用来在堆上创建动态变量。这个系统调用返回的是动态变量的内存地址。函数之间可以通过参数或返回值来交换该地址,从而跨函数地共享数据,本地变量就无法实现上述功能。

当不再需要某个动态变量时,可以通过free系统调用来释放动态变量占据的内存空间。C语言中的一个常见错误是内存泄漏(MemoryLeakage),就是指没有释放不再使用的动态变量,导致进程空间的可用内存不足。

本章介绍了函数调用过程和进程空间的结构,两者相辅相成,共同来完成进程的任务。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值