计算机系统概论第十四章.docx
- 文档编号:10233655
- 上传时间:2023-02-09
- 格式:DOCX
- 页数:17
- 大小:29.14KB
计算机系统概论第十四章.docx
《计算机系统概论第十四章.docx》由会员分享,可在线阅读,更多相关《计算机系统概论第十四章.docx(17页珍藏版)》请在冰豆网上搜索。
计算机系统概论第十四章
第十四章函数
14.1简介
函数就是子程序,而子程序是现代程序设计语言的灵魂。
函数为程序员提供了一个扩充写程序的基本程序块的集合的方式。
也就是说,它们能使程序员扩充被语言自然支持的包括新的基本要素的运算和结构的集合。
函数是如此重要的一个概念,以至它们很多年前就成为语言的一部分,所有的指令集结构包括LC-3,都直接支持它们。
函数为什么如此重要?
函数(或过程,或子程序,或方法——它们都是相同主题的变种)提供了抽象的能力。
也就是说,它们提高了我们把一个组件的功能与其实现的细节分隔开来的能力。
一旦组件被构建,并且我们理解了其结构,不考虑其实现的细节,我们就能够把该组件作为一个程序块使用。
如果没有抽象,我们在构建复杂的系统诸如计算机和运行于其上的软件的能力就会严重被削弱。
函数对我们而言并不是什么新事物。
自从我们使用LC-3的汇编语言编程时我们就已经使用过函数的变种,虽然LC-3的汇编语言与C语言在语法上有区别,但是它们后面的思想在很大程度上是相同的。
C语言主要是面向函数的。
C程序本质上是函数的集合。
每条语句属于一个(并仅属于一个)函数。
所有的C程序总是从main函数开始和结束执行。
main函数可能会调用其他的函数,并且这些函数也可能依次调用更多的函数。
控制最终将会返回main函数,然后当main结束的时候,程序结束(假如没有任何事件使这个程序预先结束)。
在这一章,我们提供了一个关于C语言中函数的介绍。
为了得到包含函数的C的语法的感觉,我们从查看几个短小的程序开始。
然后,我们查看函数是如何实现的,查看对于函数在高级语言中运行所必须的低级操作。
在这一章的最后一部分,我们把问题解决的思想应用于受益于使用函数的编程问题。
14.2C语言中的函数
让我们从一个简单的包括函数的C程序入手。
图14.1是一个使用一个名为PrintBanner的函数来打印一条消息的程序。
这个程序首先从main函数开始执行,然后调用函数PrintBanner。
这个函数打印一行由字符“=”组成的文本到输出设备。
PrintBanner是一个函数的最简单形式:
它不需要来自调用者的输入,就可以实现,它也不会给调用者提供输出数据(不考虑打印到屏幕上的标记)。
换句话说,main没有将任何变元传给PrintBanner,Printbanne也没有返回值传给main。
我们把main函数称为调用者,把PrintBanner称为被调用者。
14.2.1有一个参数的函数
PrintBanner和main不需要任何的信息交换的事实简化了它们的接口。
然而,一般说来,我们想在调用者和被调用者之间传递一些信息。
下一个例子示范了这一点是如何在C中实现的。
图14.2中的代码包含了一个基于输入的参数执行一个运算的Factorial函数。
Factorial执行从1到n之间的所有整数的乘法运算,这里的n是由调用函数(此处为main)提供的值。
由这个函数执行的计算可以被表示为代数式:
factorial(n)=n!
=1×2×3×…×n
在图14.2的C代码中,这个函数计算出的结果被命名为result。
它的值被返回(使用return语句)给调用者。
我们说Factorial函数需要一个来自于它的调用者的一个整数变元,它将一个整数值返回给它的调用者。
在这个特殊的例子里面,调用者中的变量answer被赋值为来自Factorial的返回值。
让我们更进一步看看C里面包含函数的语法。
在图4.12的代码中,有4行是我们特别感兴趣的。
对Factorial的声明在第3行。
它的定义从第19行开始。
对Factorial的调用是在第14行;这条语句调用了这个函数。
在第27行,Factorial返回到它的调用者。
声明
在前面的例子中,对Factorial的函数声明出现在第3行。
函数的声明有什么目的呢?
与变量的声明是告诉编译器关于变量的属性的方式相同,函数声明是告诉编译器关于函数的一些相关属性。
函数声明有时被称为函数的原型,它包括函数的名称,它的返回值的类型,以及它所期望的输入值的列表。
函数声明以分号结束。
出现在函数声明中的第一项是函数返回值的类型。
这个类型可以是任何一种C的数据类型(例如:
int、char、double)。
这个类型描述了函数产生的单个输出值的类型。
不是所有的函数都有返回值。
例如,先前的一个例子中的PrintBanner函数就没有返回值。
如果一个函数没有返回值,那么它的返回值类型就被声明为void,告诉编译器该函数没有任何返回值。
接着出现在声明中的是函数的名称。
一个函数的名称可以是任何一个合法的C的标识符。
经常地,程序员仔细的选择函数名称,以反映函数执行的行为。
例如,Factorial对我们所举例子中的函数是个很好的选择,因为它所执行的运算的数学术语就是factorial。
另外,使用函数名和变量名能够轻易地被区分的命名规范是一个好的风格。
在本书的例子中,我们通过把函数名的第一个字母大写,例如Factorial,来实现这一点。
最后,一个函数的声明也描述了函数中所需要的输入参数的类型和顺序。
这些是函数期望从它的调用者那里获得的值的类型以及接受它们的顺序。
我们可以有选择地(而且是经常地)在声明指定每个参数的名称。
例如,Factorial函数把一个整数作为输入参数,并且在内部把这个值称作n。
有些函数可能不需要输入值。
PrintBanner函数不需要任何的输入参数,因此它的参数列表为空。
调用
我们例子中的第14行是调用Factorial的函数调用。
在这个语句中,main函数调用了Factorial。
然而在Factorial能够开始前,main必须传一个整数值给Factorial。
在调用者内部被传给被调用者的值被称为变元。
变元可以是任意的合法表达式,但是,它们必须与被调用者所期望的类型相匹配。
这些变元位于被调用者的名称后面的圆括号里。
在这个例子中,main函数传递变量number的值作为变元。
从Factorial返回的值接着被赋值给整数变量answer。
定义
从19行开始的代码是对Factorial的函数定义。
注意定义的第一行与函数声明相匹配(然而,没了分号)。
在函数名后面的圆括号里的是函数的形式参数列表。
这个形式参数列表是一个变量声明的列表,其中每一个变量都被初始化为调用者提供的对应的变元。
在这个例子中,当Factorial在14行被调用,参数n会被初始化为main中的number的值。
不管函数在程序中的哪个地方被调用,出现在调用中的实际变元都应当与形式参数列表的类型和顺序相匹配。
函数体出现在参数列表后面的大括号中。
函数体包含了定义函数所要执行的计算的语句和声明。
任何在大括号中声明的变量都是该函数的局部变量。
在C语言中,有一个关于认识函数的非常重要的概念是任何调用者的局部变量对于被调用函数都是不可见的。
特别地,Factorial不能修改number变量。
在C语言中,调用者的变元作为值被传给被调用者。
返回值
在27行中,控制从Factorial传回到调用者main。
既然Factorial要返回一个值,一个表达式必须跟在return关键词之后,而且这个表达式的类型应当与函数声明的返回值类型相匹配。
在Factorial这个例子中,语句returnresult;把存储在result中的计算的数值传回调用者。
一般说来,返回一个数值的函数必须在函数体中包含至少一个return语句。
不返回数值的函数——声明为void类型的函数——不需要return语句,return是可选的。
对于这些函数,在最后一条语句执行之后,控制就传回调用者。
那么main函数呢?
它的类型是int(ANSI标准要求的),然而它不包含return。
严格地说,在我们至今已看过的例子中,main的结尾处应当包含一个return0。
在C语言中,如果非void函数没有一个明确的返回值的话,最后一条语句的值就会返回给调用者。
既然main的返回值会被大多数调用者所忽略(main的调用者是谁?
),我们就在本书中将它们省略,使我们的例子更紧凑。
让我们总结一下这些各种语法成分:
函数声明(或者原型)告诉编译器关于函数,指明它的名字,调用者期望函数所包含的参数的数目和类型,以及函数的返回值的类型。
函数定义是关于函数的实际源代码。
定义包含了一个形式参数列表,参数列表指明函数的参数名和调用者期望的参数顺序。
函数是被函数调用所调用。
函数的输入值,或变元,被列在函数调用的圆括号之内。
从字面上看,列于函数调用中的每一个变元的值都被赋值到参数列表中的对应的参数中,第一个变元被赋值到第一个参数上,第二个变元被赋值到第二个参数上,等等。
返回值是函数的输出,并被传回调用函数。
14.2.2例子:
环的面积
我们在图14.3中用一个短小的例子更深入地示范C语言的函数语法。
这个C程序将计算一个被移出一个小圆的圆的面积。
换句话说,它使用一个指定的外部和内部的半径来计算这个环的面积。
在这个程序里,使用一个函数计算一个给定半径的圆的面积。
函数AreaOfCircle接受一个double类型的参数,然后返回一个double类型的数值给调用者。
下面这点对于我们很重要,要反复说明:
当函数AreaOfCircle执行的时候,它可以“看到”并且修改其局部变量pi和参数radius。
然而,它不能修改任何main函数中的变量,除了那些通过它返回的值。
本例中的函数AreaOfCircle与我们在这一章的前面的例子中看到的函数在用法上有一点不同。
注意在main函数中多次调用函数AreaOfCircle。
在这种情况下,AreaOfCircle执行了一个有用的、简单的计算,所以将其封装成一个函数是有益的。
真正的程序会以更大的规模在成千上万个不同的地方包含函数的调用。
通过构建AreaOfCircle,把相同的基本运算封装到函数中,我们就可能节省程序中的代码数量,这对于维护代码是有益的。
此程序还采取了一个更好的结构。
使用AreaOfCircle,与直接嵌入公式相比,代码的意图更明显。
你也许还记得我们在12.6.2节对常量的讨论,那时我们指出,变量pi应该通过使用代码中25行的const标识符被声明为常量。
在这里我们将这些省去是为了让那些跳过12章的“附加的主题”这一节的人更容易接受这个例子。
14.3C语言中函数的实现
让我们现在仔细地看一下C语言中的函数是怎样在机器中执行的。
函数在C语言中和LC-3汇编语言中的子程序(我们在第9章讨论的)是相当的,并且它们操作的核心是一样的。
在C语言中,调用一个函数需要3个步骤:
(1)调用者的参数传给被调用者,并且控制被传给被调用者;
(2)被调用者执行它的任务;(3)返回值被传回给调用者,并且控制权返回调用者。
我们放在调用机制中的一个重要约束条件是函数必须是与调用者无关的。
那就是说,一个函数应该能够被任何一个函数调用。
在这一节,我们将通过LC-3来查看这个是怎样完成的。
14.3.1运行时栈
在我们开始之前,我们首先需要讨论C以及其他现代高级语言中函数的重要的组成部分。
在一个函数被调用之前,我们需要一个激活它的办法,那就是,当一个函数开始执行时,它的局部变量必须被分配存储单元。
让我们解释:
每一个函数都有一个能够存储它的局部变量的存储器模板。
回忆我们在12.5.2节的讨论,函数的一个活动记录就是它的局部变量在存储器的相关位置的模板。
函数中每一个被声明的变量在活动记录中都将被给予一个位置。
回忆框架指针指明了活动记录的开始。
问题:
活动记录放在存储器的什么位置上?
让我们考虑一些选择。
选择1:
编译器可以系统地为每一个函数在存储器中分配空间以放置其活动记录。
函数A可能被分配到存储单元X,以放置其活动记录,当然,假设活动记录不重叠的话,函数B可能被分配到单元Y,等等。
这看起来像是以一种最直接的方式去管理其分配,然而这个选择有一个严重的限制。
如果函数A调用它本身将会发生什么呢?
我们称其为“递归”,它是一个很重要的编程概念,我们将在第17章中讨论。
如果函数A调用它自己,函数A的被调用版本将会覆盖函数A的调用版本的局部变量,程序就将不能像我们所期望的那样运行。
对于允许递归函数的C程序设计语言,选择1不能工作。
选择2:
每当一个函数被调用时,在存储器中为其分配一个活动记录。
当函数返回调用者时,它的活动记录将被回收,以便被分配给后面的函数。
这个选择从概念上看起来要比第一个选择困难得多,然而它允许函数可以递归。
每一次函数调用都会在存储器中为其局部数值获得它自己的空间。
例如,如果函数A调用函数A,被调用版本将被分配一个它自己的活动记录,用来存储其局部数值,这个记录与调用版本不同。
有一个因素降低了选择2工作的复杂性:
函数的调用模式(即函数A调用B,B调用C等)可以容易的通过一个栈数据结构(第十章)被跟踪。
让我们通过一个例子来示范。
图14.4中的代码包含了3个函数,main、Watt和Volta。
每个函数做了什么对于这个例子并不重要,所以我们省略了它们的一些细节,但是对于明确它们之间的调用模式已经足够了。
main函数调用Watt,Watt调用Volta。
然后,main调用Volta,最后,控制返回到main。
每一个函数都有一个由局部变量、一些薄记信息以及由调用者输入的参数组成的活动记录(我们将在下面的段落中再讨论参数和薄记信息)。
不管函数什么时候被调用,它的活动记录都将被分配到存储器的某个空间,正如我们在前面的段落所说的,以类似于栈的方式。
图14.5中的图表明了这一点。
每个阴影部分表示某个特定的函数的活动记录。
这一系列图表明,当不同的函数被调用以及从其调用者返回时,运行时栈是如何增长和缩小的。
紧记一点,当我们向栈中压入数值时,栈顶是向着低数字的存储单元方向移动,或“增长”的。
图14.5(a)是当程序开始执行时运行时栈的图。
由于C程序都是由main开始执行,因此main的活动记录在栈中首先得到分配。
图14.5(b)显示了当Watt被main调用之后的运行时栈。
注意,活动记录是以类似栈的方式被分配的。
也就是说,无论函数什么时候被调用,它的活动记录就会被压入栈中。
无论什么时候从函数返回,它的活动记录就会从栈中被弹出。
图14.5的(c)到(f)部分显示了在执行这段代码的过程中,在不同点时的运行时栈的状态。
值得注意的是R5指向活动记录中的某个内部地址(它指向局部变量的最底部)。
还要注意R6如何总是指向栈的最顶部——它被称作栈指针。
一般说来,这两个寄存器在运行时栈和C语言的函数的实现中都起着关键的作用。
14.3.2全部工作
很清楚,当一个函数被调用的时候,在机器层要进行很多工作。
参数必须被传递,活动记录被压入、弹出,控制从一个函数转移到另一个。
其中某些工作由调用函数完成,某些由被调用函数完成。
要完成所有的这些,需要下面几步:
第一,调用函数的代码把它的变元复制进被调用函数可以访问的存储区域。
第二,被调用函数开头的代码把它的活动记录压入栈中,并且保存一些薄记信息使得当控制返回调用函数时,调用者的局部变量和寄存器看起来好像都没被动过。
第三,被调用函数执行它的任务。
第四,当被调用函数完成它的工作时,它的活动记录被从栈中弹出,并且控制返回到调用函数。
最后,一旦控制返回到调用函数,执行代码取回被调用函数的返回值。
现在我们将查看一下执行这些操作的实际的LC-3代码。
我们通过检查与下面的函数调用:
w=Volta(w,10);,即图14.4中的18行的代码,相关的LC-3代码,来实现这一点。
调用
在语句w=Volta(w,10);中,函数Volta通过使用两个变元被调用。
然后,由Volta返回的数值被赋值给局部整数变量w。
在翻译这个函数调用时,编译器会生成实现下面操作的LC-3代码:
1、通过把两个变元直接压入运行时栈的顶部,把数值传递给函数Volta。
回想一下R6指向运行时栈的顶部。
也就是说,它包含了当前在运行时栈顶部的数据项的地址。
为了将一个数据项压入栈中,我们首先递减R6,然后把R6用作基本地址来存储该数据值。
在LC-3中,C的函数调用中的变元按照它们出现在函数调用中的顺序,从右向左被压入栈中。
在Watt的情况下,我们首先压入数值10(最右边的变元),然后是w的数值。
2、通过JSR指令将控制传给Volta。
执行这个函数调用的LC-3代码如下所示:
ANDR0,R0,#0;R00
ADDR0,R0,#10;R010
ADDR6,R6,#-1
STRR0,R6,#0;压入10
LDRR0,R5,#0;加载w
ADDR6,R6,#-1
STRR0,R6,#0;压入w
JSRVolta
图14.6描述了由这些指令造成的运行时栈的改变。
注意这些变元的数值被立即压入调用者(Watt)的活动记录的顶部。
被调用者的活动记录将会被直接构建于调用者的活动记录的顶部。
开始被调用函数
在函数Watt中的JSR之后立即执行的指令,是被调用函数Volta中的第一条指令。
被调用者开头的代码处理一些与调用有关的重要的薄记。
第一件事就是为返回值分配存储空间。
被调用者通过递减栈指针,把一个存储单元压入栈中。
并且,这个单元会在返回调用者之前,被写入一个返回值。
接下来,被调用函数保存了足够的关于调用者的信息,以便当最终调用完成的时候,调用者可以正确的重新获得程序的控制。
特别是,我们需要保存调用者的返回地址,它在R7中(为什么在R7中?
回忆JSR指令是如何工作的。
),和调用者的框架指针,它在R5中。
制作一个调用者的框架指针的副本是很重要的,我们把这个叫做动态链接,以便当控制返回至调用者时,它能够再次访问它的局部变量。
如果返回地址或动态链接被破坏了,那么当被调用者结束时,我们重新正确的开始调用者就会有麻烦。
因此我们为这两个在存储器中制作副本,是很重要的。
最后,当这些都完成后,被调用者将通过调整R6,在栈上为它的局部变量分配足够的空间,并且设置R5指向它的局部变量的最底部。
为了扼要重述,下面是在函数开始时需要发生的行为列表:
1、被调用者为返回值在栈上留下空间。
返回值被分配到调用者的参数的顶上。
2、被调用者把R7中的返回地址的一份副本压入栈中。
3、被调用者把R5中的动态链接(调用者的框架指针)的一份副本压入栈中。
4、被调用者为它的局部变量在栈上分配足够的空间,并且调整R5指向局部变量的最底部,R6指向栈顶。
为Volta完成的代码如下:
VoltaADDR6,R6,#-1;为返回值分配空间
ADDR6,R6,#-1
STRR7,R6,#0;压入R7(返回地址)
ADDR6,R6,#-1
STRR5,R6,#0;压入R5(框架指针,动态链接)
ADDR5,R6,#-1;设置新的框架指针
ADDR6,R6,#-2;为Volta的局部变量分配空间
图14.7总结了到目前为止被我们遇到的代码完成的存储器的改变。
这两个活动记录——一个是Watt,一个是Volta——在存储器中的安排是显而易见的。
注意Volta的活动记录的一些记录是被Watt写的。
特别是Volta的活动记录的参数区域。
Watt把它的局部变量w的值作为第一个参数,把数值10作为第二个参数写入。
记住这些数值是根据它们在函数调用中的位置从右向左被压入栈中。
因此,w的值出现在数值10的上面。
一旦被调用,Volta将使用q和r引用这些数值。
问题:
Volta的局部变量的初值是什么?
回忆第11章中像这样的局部变量是未被初始化的。
关于局部变量的初值的一个习题见习题14.10。
注意栈上的每个活动记录都有相同的结构。
每个活动记录都包含为函数的局部变量、簿记信息(由调用程序的返回地址和动态链接组成)、返回值,和函数的参数分配的空间。
结束被调用函数
一旦被调用函数完成了它的工作,它在将控制权返还给调用函数之前,必须先执行几个任务。
首先,返回一个数值的函数需要一个能将返回值正确的传给调用函数的机制。
其次,被调用函数必须弹出当前的活动记录。
列举如下:
1、如果存在返回值,它会被写入活动记录中的返回值记录中;
2、局部变量会被从栈中弹处;
3、动态链接被恢复;
4、返回地址被恢复;
5、RET指令将控制权返回给调用程序。
与Volta对应的LC-3指令如下:
LDRR0,R5,#0;加载局部变量k
STRR0,R5,#3;把它写入返回值的位置
ADDR6,R5,#1;局部变量出栈
LDRR5,R6,#0;动态链接出栈
ADDR6,R6,#1
LDRR7,R6,#0;返回地址出栈
ADDR6,R6,#1
RET
前两个指令将返回值写入Volta的活动记录的返回值记录中,在这个例子中,返回值是局部变量k。
接着,通过把栈指针移动到框架指针之下,弹出局部变量。
动态链接被恢复,然后,返回地址被恢复,最后,我们返回到给调用者。
你需要记住的是:
即使Volta的活动记录从栈中被弹出,数值仍留在存储器中。
从调用函数返回
当被调用函数执行了RET指令之后,控制被传回调用函数。
在一些情况下,没有返回值(如果被调用函数被声明为void),而且在一些情况下,调用函数忽略返回值。
另一方面,在我们前面的例子中,返回值被赋值给Watt中的变量w。
特别的,有两个行为必须被执行:
1、返回值(如果有一个)从栈中弹出。
2、变元从栈中弹出。
JSR后面的代码看起来如下:
JSRVolta
LDRR0,R6,#0 ;加载栈顶的返回值
STRR0,R5,#0;w=Volta(w,10);
ADDR6,R6,#1;返回值出栈
ADDR6,R6,#2;变元出栈
一旦代码完成,调用就完成了,调用函数就能恢复它正常的操作。
注意,在返回到调用者之前,被调用者恢复调用者的环境。
对于调用者,看起来好象除了一个新值(返回值)被压入到栈中之外什么事情也没改变。
调用者保存/被调用者保存
在我们结束关于函数实现的讨论之前,我们需要讨论一个到现在还没有明确的问题。
在一个函数的执行过程中,R0到R3可以包含作为正在执行的计算的一部分的临时数据。
寄存器R4到R7则被用作其它的用途:
R4是全局数据段的指针,R5是框架指针,R6是栈指针,R7则被用来存放返回地址。
如果我们调用一个函数,根据我们描述的调用规则,R4到R7不会改变,或者按预先决定的方式改变。
但是寄存器R0、R1、R2和R3将发生什么事情呢?
在一般情况下,我们希望能保证被调用函数不会改写它们。
为了实现这一点,调用规则典型的采取两种策略之一:
(1)调用者通过把这些寄存器压入活动记录中,保存它们。
这叫做调用者保存规则。
(我们在第9章也讨论过。
)当控制返回到调用者的时候,调用者通过把这些寄存器从栈中弹出,恢复它们。
(2)另一选择,被调用者通过在活动记录的薄记区域中增加四个区域来保存寄存器。
这叫做被调用者保存。
当被调用者开始运行时,它把R0到R3,和R5到R7都保存到薄记区域中,在返回调用者之前再恢复它们。
14.3.3全部结合在一起
在Watt中的函数调用和Volta的开始和结束的代码被列于图14.8中。
在前面几节中出现过的LC-3的代码片断被全部结合在一起,显示了代码的总体结构。
这些代码比起原先的代码优化了很多。
我们已经把对栈指针R6的操作和返回值的压入和弹出结合成一个指令。
总之,当一个函数调用另一个函数的时候,我们的LC-3的C的调用规则包括一系列被执行的步骤。
调用函数把每个参数的值压入栈中,并且执行一个JSR(跳转到子程序)到被调用函数。
被调用者为返回值分配一个空间,保存关于调用者的一些薄记信息,然后为它的局部变量分配空间。
然后被调用者执行它的任务。
当任务被完成的时候,被调用者把返回值写
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 计算机系统 概论 第十四