编译器设计(七)——过程抽象

一、过程调用

执行一个调用实际上是实例化了被调用者的命名空间,调用(caller)必须为被调用(callee)者声明的对象建立存储区。

下面调用图给出了多个过程之间可能发生的潜在调用的集合。执行Main可能导致两次调用Fee:一次是在Foe中,另一次是在Fum中。这些调用中的每一个都建立了Fee的一个不同实例,也称为激活(将过程处于执行状态下的一个实例称为激活(activation ))。等到调用Fum时,Fee的第一个实例已经不再处于活动状态,该实例是由Foe中的调用创建的,在其将控制返回到Foe之后,对应的实例即销毁。在控制从Fum中的调用进入到Fee时,会建立Fee的一次新的激活,从Fee返回到Fum将销毁这一次激活。
在这里插入图片描述
在编译器为调用和返回生成代码时,代码中必须保留足够的信息,确保调用和返回能够正确地运转。在Foe调用Fum时,编译器针对调用生成的代码必须记录Foe中的地址,Fum执行完毕后将控制返回到该地址。Fum可能因为运行时错误、无限循环或其调用的另一个过程没有返回等原因而不返回,也就是发散(一个计算不能正常终止就称为发散(diverge))。但是,调用机制仍然必须保留足够的信息,确保在Fum能够返回的情况下,利用预先设定的信息,在Foe中恢复执行。

在Fie调用Foe时,会将Foe在Fie中的返回地址压栈。在Foe返回时,它才会从栈中弹出该地址,并跳转到该地址执行。如果所有过程都使用同一栈,弹出返回地址后即暴露出上一个过程调用在栈中保存的返回地址。

许多程序设计语言允许程序将一个过程及其运行时上下文封装到一个对象中,称为闭包,如下面GCproto是LuaJIT中过程的闭包。在调用闭包时,过程在封装的运行时上下文中执行。使用简单的栈不足以实现这种控制抽象。相反,控制信息必须保存在某种更通用的结构中,如LuaJIT的运行时Stack,该结构必须能够表示更复杂的控制流关系。

二、运行时结构

2.1 类Algol语言的运行时结构

类Algol语言:国际计算机学会(ACM)将ALGOL模式列为算法描述的标准,启发ALGOL类现代语言Pascal、Ada、C语言等出现。

按程序中出现的顺序嵌套的作用域通常称为词法作用域。大多数类Algol语言允许程序员建立词法作用域,作用域的范围通过程序设计语言中特定的终结符标记。通常,每个新的过程都定义了一个涵盖其整个定义的作用域。Pascal用beginend来标定作用域的起始和结束,C语言使用大括弧{}来标记块语句的起始和结束,每个块语句都定义了一个新的作用域。

有了词法作用域之后,就可以静态坐标表示变量。对于作用域s中声明的变量名字x,其静态坐标是一个对<l, o>,其中l是s的词法嵌套层次,而o是x在作用域数据区中的偏移量。

类Algol语言需要运行时结构来支持词法作用域,编译器在实现过程调用转换时必须建立一组叫做活动记录(Activation Record, AR )的运行时数据结构,用于保存与单个过程的单次激活实例相关联的控制信息和数据存储。这是与对特定过程的特定调用相关联的一块私有内存区,原则上,每次过程调用都会产生一个新的AR。

下图给出了AR中内容可能的布局方式,整个AR都是通过一个活动记录指针(Activation Record Pointer, ARP)寻址,AR中的各个字段可以通过与ARP之间的(正/负)偏移量来找到。

  • 参数区包含了来自调用位置的各个实参,排列次序对应于其在调用中出现的次序。
  • 寄存器保存区包含足够的空间,可以保存因为发生过程调用而必须保存的寄存器。
  • 返回值槽位为从被调用者向调用者返回数据提供了空间(如果有返回值)。
  • 返回地址槽位包含了一个运行时地址,在被调用者终止后,应该从该地址恢复执行。
  • “可寻址性”槽位包含的信息允许被调用者访问外层词法作用域(不一定是调用者)中的变量。
  • 被调用者的ARP指向的槽位存储了调用者的ARP。被调用者执行结束时,需要用这个指针才能恢复调用者的执行环境。
  • 局部数据区包含了被调用者的局部作用域中声明的变量。
    在这里插入图片描述

1)局部存储

对过程q的一次调用中,其AR包含了该次调用的局部数据和状态信息。对q的每一次调用都产生一个独立的AR。AR中所有的数据都通过ARP访问。因为过程通常会频繁访问其AR,大多数编译器会专门用一个硬件寄存器保存当前过程调用的ARP。在ILOC(一种三地址码的IR,详细介绍在附录A)中,我们用r_arp来引用该寄存器。

ARP总是指向AR中一个指定的位置。AR中间部分的布局是静态的,所有的字段都是定长的。这确保了所有编译后的代码都能够通过与ARP之间的定长偏移量来访问相应的数据项。AR的两端则用于分配变长存储区,其长度在(同一过程)的不同调用可能是不同的;通常一段保存局部数据,另一端保存参数。

2)分配活动记录

当p在运行时调用q时,实现调用的代码必须为q分配一个AR,并用适当的值初始化它。这个AR在p中必须是可用的,以便p向其中存储实参、返回地址、p的AR、可寻址信息等。所以这迫使在p中分配q的AR,但此时q的局部数据区的长度是未知的。如果过程调用,参数传递是通过寄存器传递的,则AR的分配也可以放到q中进行。

AR的分配方式有以下几种:

  • 基于栈的分配:大多数变量的生命周期都内含于创建变量的过程的激活(将过程处于执行状态下的一个实例称为激活)期,大多数过程的激活期也内含于调用者的激活期。在这种约束下,调用和返回是平衡的,他们遵循后进先出的规范。从p中对q的调用最终会返回,而在p调用q、q返回到p这两个时间点之间,任何操作都来源于q,则AR也遵循后进先出的顺序,所以可以在栈上分配。
  • 基于堆的分配:如果过程的激活期超出其调用者的激活期,在栈上分配AR的规范就会被破坏。如过程返回一个对象,其显式或隐式的引用已返回过程的局部变量,这样在栈上分配AR是不合适的,因为会留下悬挂指针。在对分配的AR中,变长对象可以分配为堆上独立的对象。如果堆中的AR需要显示释放,则处理过程返回的代码必须释放AR本身以及额外分配的变长对象(有GC就可以不用管)。
  • 静态分配:如果过程q不调用其他过程,那么在q激活期间,不需要处理其他过程的活动记录。我们称q为叶过程(leaf procedure,不包含过程调用的过程称为叶过程),因为它终止了调用图中的一条调用路径。编译器可以为叶过程静态分配活动记录。这样做消除了运行时分配AR的代价。如果调用约定要求调用者保存自身的寄存器,那么q的AR不需要寄存器保存区。
  • 合并AR:如果编译器发现一组过程总是按固定的序列调用,那么编译器可以合并其活动记录。例如,如果从p调用q总是会导致调用r和s,那么编译器同时为q、r和s分配AR可能比较有利。合并AR可以节省分配操作的代价,这种做法的收益与分配操作的代价成正比。实际上,这种优化受到了分离编译和函数值参数的限制。二者都限制了编译器确定运行时实际调用关系的能力。

2.2 支持面向对象语言的运行时结构

与Algol语言一样,面向对象语言也需要运行时结构支持词法作用域层次类层次结构。其中一些结构与类Algol语言使用的结构是相同的,如方法的控制信息以及局部名字对应的存储,都是存储在AR中。但对象的生命周期不必匹配任何特定方法的某一次调用,因其持久状态无法存储在某个AR中,所以每个对象需要自身的对象记录(object record, OR)来保存其状态。类的OR实例化了继承层次结构,它们在转换和执行中发挥了关键的作用。

以下是Pint和ColorPoint类的定义:

class Point {
    public int x, y;
    public void draw() {...};
    public void move() {...}; 
}

class ColorPoint extends Point {
    Color c;
    public void draw() {...}public void test() {...; draw(); };
}

下图给除了上述代码实例化的三个对象所形成的运行时结构。SimplePoint对象是Point的实例,而LeftCorner和RightCorner对象都是ColorPoint的实例。每个对象、Point类、ColorPoint类都有自身的OR。

LeftCorner的OR,包含一个指针指向定义了LeftCorner对象的类、一个指针指向类的方法向量和用于保存x、y、c三个字段的空间。请注意,ColorPoint实例中继承而来的字段的偏移量,与这些字段在基类Point中的偏移量是相同的。ColorPoint类的OR,首先是“逐字”复制了Point的OR,而后在其基础上有所扩展。由此产生的一致性,使得超类方法(如Point.move)可以在子类对象(如LeftCorner)上正确地运行。

类的OR包含一个指向其类class的指针、一个指向class方法向量的指针以及本身的字段 superclass和class methods。在图中,绘制的所有方法向量都是完备的,即其中包含了对应类的所有方法,无论是本身定义的还是继承而来的。superclass字段记录了继承层次,在开放的类结构中该字 段是必需的;class methods字段指向类实例所使用的方法向量。

在这里插入图片描述

三、过程之间值的传递

3.1 传递参数

1)传值调用

使用传值参数传递时(如C语言),调用者将实参的值复制到与相应形参对应的位置上:可以是寄存器,或是被调用者AR中的某个参数槽位。只有一个名字能引用该值,即形参的名字。其值是一个初始条件,在调用时通过对实参求值确定。如果被调用者改变了其值,这一改变仅在被调用者内部可见,调用者是看不到的。

2)传引用调用

利用传引用(call-by-reference)参数传递,调用者将对应于实参的指针存储在AR的槽位中。如果实参是变量,则AR中存储的是其地址。如果实参是表达式,调用者首先对表达式求值,将结果存储在自身AR的局部数据区中,然后将指向该结果的指针存储到被调用者AR适当的参数槽位中。常数应该作为表达式处理,以避免被调用者改变常数值。一些语言禁止使用表达式作为传引用形参的实参。如果实参是一个变量(而非表达式),那么改变形参的值会导致实参的值同样发生改变。

3)参数的存储空间

参数表示的长度会影响到过程调用的代价。标量值(如变量和指针)是存储在寄存器中,或存储在被调用者AR的参数区中。使用传值参数时,将存储参数的实际值;而使用传引用参数时,将存储参数的地址。

大型的值,如数组、记录或结构,对传值调用提出了问题。如果语言要求复制大型的值,那么将其复制到被调用者参数区的开销,会显著增加过程调用的代价。(在这种情况下,程序员可能想要模拟传引用,即传递指向对象的指针,而非对象本身。)一些语言允许实现对这样的对象传引用。另一些语言则包含一些规定,程序员能够据此指定对特定的参数传引用是可接受的,例如,C语言中的const属性向编译器保证,具有该属性的参数不会被修改。

3.2 返回值

为从函数返回一个值,编译器必须为返回值预留空间。因为根据定义,返回值将在被调用者终止之后使用,它需要的存储空间应该在被调用者AR以外。如果编译器编写者能够确保返回值是定长类型且长度较小,那么它可以将该值存储在调用者的AR或某个指定的寄存器中(使用传值参数时,链接约定通常指定为第一个参数分配的寄存器来保存返回值)。

我们所有的AR绘图中都包含一个用于返回值的槽位。为使用该槽位,调用者需要在自身的AR中为返回值分配空间,然后将指向该空间的一个指针存储到其自身AR的返回值槽位中。而被调用者可以从调用者的返回值槽位加载该指针(使用调用者的ARP指针的副本,它已经被预置在被调用者的AR中)。被调用者可以使用该指针访问调用者AR中为返回值分配的空间。只要调用者和被调用者能就返回值的长度达成一致,这种方案就可以工作。

如果调用者不知道返回值的长度,被调用者可能需要为其分配空间(大概在堆上)。在这种情况下,被调用者分配空间,将返回值存储在其中,并将指向该空间的指针存储在调用者AR中的返回值槽位上。在返回时,调用者可以使用在返回值槽位中找到的指针来访问返回值。而调用者必须释放被调用者分配的空间

如果返回值本身长度很短,小于等于返回值槽位本身的长度,那么编译器可以消除这种间接性。对于长度小的返回值,被调用者可以将该值直接存储到调用者AR的返回值槽位中。调用者接下来可以直接使用其AR中的这个值。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yelvens

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值